Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions bridge/rector/FEATURE_PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,3 +158,6 @@ both reverse directions produce it — a `markTestSkipped('literal')` opening a
(`MarkTestSkippedToSkipAttributeRector`) and Pest's `->skip('reason')` modifier. What stays a
`SkipTest` throw is exactly what cannot be declared: a guarded call, one deeper in the body, or a
message no attribute argument can hold.

String assertions preserve named binding, weak/strict coercion, argument evaluation order and void
returns through a typed inline closure when needed. Other named assertion shapes remain manual.
8 changes: 8 additions & 0 deletions bridge/rector/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,14 @@ Testo's comparison assertions are `(actual, expected)`; PHPUnit's are `(expected
Both `AssertCall*` rules swap the first two arguments accordingly — getting this wrong would
silently invert every comparison, so it is covered by fixtures.

### String assertion contracts

String assertion calls with effectful or potentially coercible arguments, or a used result, use an inline
typed closure with PHPUnit's parameter names. It keeps source evaluation order, weak/strict string
coercion, message validation and the original `void` return. Simple standalone calls with native string
types keep the shorter fluent form, normalising supported named arguments positionally. Other named
assertion shapes remain for manual migration.

## Why Testo → PHPUnit

Testo is self-hosted: the engine that discovers and runs tests is the same code Infection
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,13 @@ public function refactor(Node $node): ?Node
return null;
}

# Keep unsupported argument bindings visible for manual migration.
foreach ($node->args as $argument) {
if (!$argument instanceof Arg || $argument->unpack || $argument->name !== null) {
return null;
}
}

if (isset(self::NOT_BOOL[$method])) {
return $this->notBool(self::NOT_BOOL[$method], $node->args);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

class UnsupportedNamedFlatAssertions extends \PHPUnit\Framework\TestCase
{
public function run(): void
{
$this->assertSame(expected: 1, actual: 1);
$this->assertTrue(actual: true);
$this->assertSame(...$arguments);
}
}
7 changes: 5 additions & 2 deletions bridge/rector/src/PhpunitToTesto/TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,8 +155,11 @@ Mocks are out of this set: they convert through `phpunit-to-double` or `phpunit-
error check, so a match that fails at runtime (backtrack or JIT stack limit) failed `assertRegExp`
and passed `assertNotRegExp`; `matchesRegex()`/`notMatchesRegex()` throw
`\InvalidArgumentException` there, as PHPUnit 10+ throws a framework exception. The string matchers
also need a string subject: outside `strict_types` PHPUnit's `string` parameter coerces an int,
float or `Stringable`, which `Assert::string()` rejects.
preserve the caller's string boundary through an inline typed void closure when native string types,
simple expressions and an unused result do not justify the shorter chain. Safe named string
arguments are normalised positionally; other named strings bind to
PHPUnit's source parameter names; argument evaluation order, Stringable coercion and message validation
stay at the call boundary. Other named assertion shapes remain manual.

**Whitespace residual:** PHPUnit runs `preg_replace('/\s+/u', ' ')` and `trim()`, while
`ignoringWhitespace(lineBreaks: true)` also counts the `\h`/`\v` characters outside `\s` (U+180E)
Expand Down
147 changes: 111 additions & 36 deletions bridge/rector/src/PhpunitToTesto/TypedAssertCallToTestoRector.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use PhpParser\Node\Expr\BinaryOp\Concat;
use PhpParser\Node\Expr\BooleanNot;
use PhpParser\Node\Expr\ClassConstFetch;
use PhpParser\Node\Expr\Closure;
use PhpParser\Node\Expr\ConstFetch;
use PhpParser\Node\Expr\Empty_;
use PhpParser\Node\Expr\FuncCall;
Expand All @@ -21,8 +22,11 @@
use PhpParser\Node\Identifier;
use PhpParser\Node\Name;
use PhpParser\Node\Name\FullyQualified;
use PhpParser\Node\Param;
use PhpParser\Node\Scalar\MagicConst;
use PhpParser\Node\Scalar\String_;
use PhpParser\Node\Stmt\Expression;
use PhpParser\NodeFinder;
use PHPStan\Reflection\ClassReflection;
use PHPStan\Reflection\ReflectionProvider;
use PHPStan\Type\FloatType;
Expand All @@ -39,8 +43,10 @@
* Rewrites the PHPUnit assertions that map onto a Testo **typed head + matcher** chain rather than a
* flat facade call — the shape {@see AssertCallToTestoRector} deliberately does not handle.
*
* The subject moves from an argument position to the head argument (still evaluated exactly once,
* so no hoisting is needed) and the remaining argument becomes the matcher argument:
* The subject moves from an argument position to the head argument. For string assertions, the
* short form requires a standalone call with simple expressions and native string types. Otherwise a typed
* void closure keeps source parameter names, evaluation order and weak/strict coercion, including
* the message. PHPDoc-only string types do not justify bypassing the parameter boundary.
*
* - $this->assertGreaterThan($e, $a) → \Testo\Assert::numeric($a)->greaterThan($e)
* - $this->assertGreaterThanOrEqual(...) → …->greaterThanOrEqual(…)
Expand Down Expand Up @@ -114,7 +120,8 @@
* either, so one on `assertJson` is dropped as well.
*
* Recognises the same call spellings as {@see AssertCallToTestoRector}, `PHPUnit\Framework\assert*()`
* functions included, and likewise only inside a class.
* functions included, and likewise only inside a class. Unsupported named argument shapes outside
* the string mappings remain for manual migration.
*/
#[TestRectorFixtures('TypedAssertCallToTestoRector')]
final class TypedAssertCallToTestoRector extends AbstractRector
Expand Down Expand Up @@ -142,22 +149,6 @@ final class TypedAssertCallToTestoRector extends AbstractRector
'assertArrayNotHasKey' => 'doesNotHaveKeys',
];

/**
* String assertions with a comparison mode → `Assert::string($subject)-><modifier>()-><matcher>()`.
* `$message` is preserved. PHPUnit's whitespace mode turns every run of whitespace, line breaks
* included, into one space and trims, which is `ignoringWhitespace(lineBreaks: true)`.
*
* @var array<non-empty-string, array{non-empty-string, non-empty-string}>
*/
private const STRING_MODIFIED = [
'assertStringContainsStringIgnoringCase' => ['ignoringCase', 'contains'],
'assertStringNotContainsStringIgnoringCase' => ['ignoringCase', 'notContains'],
'assertStringContainsStringIgnoringLineEndings' => ['ignoringLineEndings', 'contains'],
'assertStringEqualsStringIgnoringLineEndings' => ['ignoringLineEndings', 'same'],
'assertStringEqualsStringIgnoringWhitespace' => ['ignoringWhitespace', 'same'],
'assertStringNotEqualsStringIgnoringWhitespace' => ['ignoringWhitespace', 'notSame'],
];

/**
* Case-insensitive equality → `Assert::string($actual)->ignoringCase()->same|notSame($expected)`,
* only when both sides are strings: PHPUnit also compares other scalars and arrays, recursively.
Expand Down Expand Up @@ -315,34 +306,29 @@ public function refactor(Node $node): ?Node
return null;
}

$string = $this->stringAssertion($method);
if ($string !== null) {
return $this->stringChain($node, ...$string);
}

# Other mappings do not yet model PHPUnit's named parameter contracts.
foreach ($node->args as $argument) {
if (!$argument instanceof Arg || $argument->unpack || $argument->name !== null) {
return null;
}
}

return match (true) {
isset(self::NUMERIC[$method]) => $this->typedChain('numeric', self::NUMERIC[$method], $node->args, keepMessage: true),
isset(self::ARRAY_KEY[$method]) => $this->typedChain('array', self::ARRAY_KEY[$method], $node->args, keepMessage: false),
$method === 'assertEqualsCanonicalizing' => $this->typedChain('array', 'sameElementsAs', $node->args, keepMessage: true),
$method === 'assertStringStartsWith' => $this->typedChain('string', 'startsWith', $node->args, keepMessage: true),
$method === 'assertStringEndsWith' => $this->typedChain('string', 'endsWith', $node->args, keepMessage: true),
$method === 'assertStringStartsNotWith' => $this->typedChain('string', 'notStartsWith', $node->args, keepMessage: true),
$method === 'assertStringEndsNotWith' => $this->typedChain('string', 'notEndsWith', $node->args, keepMessage: true),
$method === 'assertStringContainsString' => $this->typedChain('string', 'contains', $node->args, keepMessage: true),
$method === 'assertStringNotContainsString' => $this->typedChain('string', 'notContains', $node->args, keepMessage: true),
isset(self::STRING_MODIFIED[$method]) => $this->typedChain(
'string',
self::STRING_MODIFIED[$method][1],
$node->args,
keepMessage: true,
modifier: self::STRING_MODIFIED[$method][0],
),
isset(self::EQUALS_IGNORING_CASE[$method]) && $this->areStrings($node->args) => $this->typedChain(
'string',
self::EQUALS_IGNORING_CASE[$method],
$node->args,
keepMessage: true,
modifier: 'ignoringCase',
),
$method === 'assertMatchesRegularExpression', $method === 'assertRegExp'
=> $this->typedChain('string', 'matchesRegex', $node->args, keepMessage: true),
$method === 'assertDoesNotMatchRegularExpression', $method === 'assertNotRegExp'
=> $this->typedChain('string', 'notMatchesRegex', $node->args, keepMessage: true),
$method === 'assertNotContains' => $this->typedChain('iterable', 'notContains', $node->args, keepMessage: true),
$method === 'assertObjectHasProperty' => $this->typedChain('object', 'hasProperty', $node->args, keepMessage: true),
$method === 'assertIsList' => $this->subjectChain('array', 'isList', [], $node->args),
Expand All @@ -363,6 +349,95 @@ public function refactor(Node $node): ?Node
};
}

/**
* @return array{non-empty-string, non-empty-string, non-empty-string, non-empty-string|null}|null
*/
private function stringAssertion(string $method): ?array
{
return match ($method) {
'assertStringStartsWith' => ['prefix', 'string', 'startsWith', null],
'assertStringEndsWith' => ['suffix', 'string', 'endsWith', null],
'assertStringStartsNotWith' => ['prefix', 'string', 'notStartsWith', null],
'assertStringEndsNotWith' => ['suffix', 'string', 'notEndsWith', null],
'assertStringContainsString' => ['needle', 'haystack', 'contains', null],
'assertStringNotContainsString' => ['needle', 'haystack', 'notContains', null],
'assertStringContainsStringIgnoringCase' => ['needle', 'haystack', 'contains', 'ignoringCase'],
'assertStringNotContainsStringIgnoringCase' => ['needle', 'haystack', 'notContains', 'ignoringCase'],
'assertStringContainsStringIgnoringLineEndings' => ['needle', 'haystack', 'contains', 'ignoringLineEndings'],
'assertStringEqualsStringIgnoringLineEndings' => ['expected', 'actual', 'same', 'ignoringLineEndings'],
'assertStringEqualsStringIgnoringWhitespace' => ['expected', 'actual', 'same', 'ignoringWhitespace'],
'assertStringNotEqualsStringIgnoringWhitespace' => ['expected', 'actual', 'notSame', 'ignoringWhitespace'],
'assertMatchesRegularExpression', 'assertRegExp' => ['pattern', 'string', 'matchesRegex', null],
'assertDoesNotMatchRegularExpression', 'assertNotRegExp' => ['pattern', 'string', 'notMatchesRegex', null],
default => null,
};
}

/**
* Keep the native string parameter boundary and lexical evaluation order when a direct
* head/matcher split would change them. Named arguments bind to the source parameter names.
*
* @param MethodCall|StaticCall|FuncCall $node
*/
private function stringChain(Node $node, string $first, string $second, string $matcher, ?string $modifier): ?Expr
{
$args = $node->args;
$bound = [];
$named = false;
$parameters = [$first, $second, 'message'];
foreach ($args as $index => $argument) {
if (!$argument instanceof Arg || $argument->unpack || $argument->byRef
|| ($named && $argument->name === null)
) {
return null;
}
$named = $argument->name !== null;
$name = $argument->name?->toString() ?? ($parameters[$index] ?? null);
if ($name === null || !\in_array($name, $parameters, true) || isset($bound[$name])) {
return null;
}
$bound[$name] = $argument;
}
if (!isset($bound[$first], $bound[$second])) {
return null;
}

$direct = (new NodeFinder())->findFirst(
$this->getFile()->getNewStmts(),
static fn(Node $statement): bool => $statement instanceof Expression && $statement->expr === $node,
) !== null;
foreach ($args as $argument) {
$direct = $direct
&& ($argument->value instanceof String_ || ($argument->value instanceof Variable && \is_string($argument->value->name)))
&& $this->getNativeType($argument->value)->isString()->yes();
}
if ($direct) {
$ordered = [];
foreach ($parameters as $name) {
if (isset($bound[$name])) {
$ordered[] = new Arg($bound[$name]->value);
}
}
return $this->typedChain('string', $matcher, $ordered, keepMessage: true, modifier: $modifier);
}

$params = [];
$inner = [];
foreach ($parameters as $index => $name) {
$variable = new Variable($name);
$params[] = new Param($variable, default: $index === 2 ? new String_('') : null, type: new Identifier('string'));
$inner[] = new Arg($variable);
}
$chain = $this->typedChain('string', $matcher, $inner, keepMessage: true, modifier: $modifier);
\assert($chain instanceof MethodCall);
return new FuncCall(new Closure([
'static' => true,
'params' => $params,
'returnType' => new Identifier('void'),
'stmts' => [new Expression($chain)],
]), $args);
}

/**
* `assertIsString($subject)` → `Assert::string($subject)`. A type head takes no message, so a call
* that carries one falls back to the predicate form, which keeps it.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,12 @@ class ContainsListPropertyTest
$this->assertIsList($rows);
$this->assertIsList($rows, 'sequential keys');
}

public function namedArguments(string $needle, string $haystack): void
{
$this->assertStringContainsString(needle: 'needle', haystack: 'haystack');
$this->assertStringContainsString(haystack: $haystack, needle: $needle);
}
}
-----
<?php
Expand All @@ -20,12 +26,22 @@ class ContainsListPropertyTest
{
public function test()
{
\Testo\Assert::string($haystack)->contains('needle');
\Testo\Assert::string($output)->notContains('error', 'no errors');
(static function (string $needle, string $haystack, string $message = ''): void {
\Testo\Assert::string($haystack)->contains($needle, $message);
})('needle', $haystack);
(static function (string $needle, string $haystack, string $message = ''): void {
\Testo\Assert::string($haystack)->notContains($needle, $message);
})('error', $output, 'no errors');
\Testo\Assert::iterable($items)->notContains($removed);
\Testo\Assert::iterable($letters)->notContains('x', 'x is gone');
\Testo\Assert::object($entity)->hasProperty('id', 'has an id');
\Testo\Assert::array($rows)->isList();
\Testo\Assert::array($rows)->isList('sequential keys');
}

public function namedArguments(string $needle, string $haystack): void
{
\Testo\Assert::string('haystack')->contains('needle');
\Testo\Assert::string($haystack)->contains($needle);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,17 @@ class RegularExpressionAssertTest
{
public function test()
{
\Testo\Assert::string($date)->matchesRegex('/^\d{4}-\d{2}-\d{2}$/');
\Testo\Assert::string($date)->notMatchesRegex('/\s$/', 'no trailing space');
\Testo\Assert::string($name)->matchesRegex('/^a/i', 'starts with a');
\Testo\Assert::string($name)->notMatchesRegex('/^b/');
(static function (string $pattern, string $string, string $message = ''): void {
\Testo\Assert::string($string)->matchesRegex($pattern, $message);
})('/^\d{4}-\d{2}-\d{2}$/', $date);
(static function (string $pattern, string $string, string $message = ''): void {
\Testo\Assert::string($string)->notMatchesRegex($pattern, $message);
})('/\s$/', $date, 'no trailing space');
(static function (string $pattern, string $string, string $message = ''): void {
\Testo\Assert::string($string)->matchesRegex($pattern, $message);
})('/^a/i', $name, 'starts with a');
(static function (string $pattern, string $string, string $message = ''): void {
\Testo\Assert::string($string)->notMatchesRegex($pattern, $message);
})('/^b/', $name);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,11 @@ class RegularExpressionFunctionCallTest
{
public function test()
{
\Testo\Assert::string($id)->matchesRegex('/^id-\d+$/', 'id format');
\Testo\Assert::string($id)->notMatchesRegex('/\s/');
(static function (string $pattern, string $string, string $message = ''): void {
\Testo\Assert::string($string)->matchesRegex($pattern, $message);
})('/^id-\d+$/', $id, 'id format');
(static function (string $pattern, string $string, string $message = ''): void {
\Testo\Assert::string($string)->notMatchesRegex($pattern, $message);
})('/\s/', $id);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ class StringNotStartsEndsWithTest
{
public function test()
{
\Testo\Assert::string($path)->notStartsWith('/tmp');
\Testo\Assert::string($path)->notEndsWith('.md', 'not markdown');
(static function (string $prefix, string $string, string $message = ''): void {
\Testo\Assert::string($string)->notStartsWith($prefix, $message);
})('/tmp', $path);
(static function (string $suffix, string $string, string $message = ''): void {
\Testo\Assert::string($string)->notEndsWith($suffix, $message);
})('.md', $path, 'not markdown');
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,11 @@ class TypeChecksTest
\Testo\Assert::false(\is_int($value), 'not an int');
\Testo\Assert::true(\file_exists($path));
\Testo\Assert::false(\is_dir($dir));
\Testo\Assert::string($message)->startsWith('Name must');
\Testo\Assert::string($file)->endsWith('.json', 'suffix');
(static function (string $prefix, string $string, string $message = ''): void {
\Testo\Assert::string($string)->startsWith($prefix, $message);
})('Name must', $message);
(static function (string $suffix, string $string, string $message = ''): void {
\Testo\Assert::string($string)->endsWith($suffix, $message);
})('.json', $file, 'suffix');
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

class UnsupportedNamedAssertions extends \PHPUnit\Framework\TestCase
{
public function run(): void
{
$this->assertStringContainsString(wrong: 'x', haystack: 'xyz');
$this->assertStringContainsString(needle: 'x', haystack: 'xyz', wrong: 'context');
$this->assertStringContainsString(...$arguments);
$this->assertStringContainsString(...);
$this->assertIsString(actual: 'xyz');
$this->assertGreaterThan(expected: 0, actual: 1);
}
}
Loading