From 667557af2ac3c08d0f1f3680602efbfe22234140 Mon Sep 17 00:00:00 2001 From: Exan Date: Tue, 23 Jun 2026 13:16:16 +0200 Subject: [PATCH 1/9] Extract default arg from declaring class in for objects --- src/Extractor.php | 127 +++++++++++++++++++ src/Formatting/Variables.php | 1 - src/Methods/Mocker.php | 37 +++++- tests/Components/InstantiatedDefaultArgs.php | 16 +++ tests/Components/MixedConstructor.php | 12 ++ tests/Components/TestClass.php | 2 + tests/Methods/MockerTest.php | 34 +++++ 7 files changed, 225 insertions(+), 4 deletions(-) create mode 100644 src/Extractor.php create mode 100644 tests/Components/InstantiatedDefaultArgs.php create mode 100644 tests/Components/MixedConstructor.php diff --git a/src/Extractor.php b/src/Extractor.php new file mode 100644 index 0000000..08fa52f --- /dev/null +++ b/src/Extractor.php @@ -0,0 +1,127 @@ + $tokens + * @return array + */ + public static function lines(array $tokens, int $start, int $end): array + { + $capture = false; + $captured = []; + + foreach ($tokens as $token) { + if (!$capture && is_array($token)) { + $isWithinLines = $token[2] >= $start && $token[2] <= $end; + + if (!$capture && $isWithinLines) { + $capture = true; + } elseif ($capture && !$isWithinLines) { + break; + } + } + + if ($capture) { + $captured[] = $token; + } + } + + return $captured; + } + + /** + * @param array $tokens + * @return array + */ + public static function function(array $tokens, string $functionName): array + { + $capture = false; + $captured = []; + + foreach ($tokens as $i => $token) { + if ( + is_array($token) + && $token[0] === T_FUNCTION + && is_array($tokens[$i + 2]) + && $tokens[$i + 2][0] === T_STRING + ) { + $isRequestedFunction = $tokens[$i + 2][1] === $functionName; + + if (!$capture && $isRequestedFunction) { + $capture = true; + } elseif ($capture && !$isRequestedFunction) { + break; + } + } + + if ($capture) { + $captured[] = $token; + } + } + + while( + count($captured) + && is_array($captured[count($captured) - 1]) + && in_array( + $captured[count($captured) - 1][0], + [T_WHITESPACE, T_PUBLIC, T_PROTECTED, T_PRIVATE] + ) + ) { + array_pop($captured); + } + + return $captured; + } + + /** + * @param array $tokens + * @return array + */ + public static function arg(array $tokens, string $argName): array + { + $argName = '$' . trim($argName, '$'); + $capture = false; + $captured = []; + + $trimToLast = ')'; + + foreach ($tokens as $token) { + if (is_array($token) + && $token[0] === T_VARIABLE + ) { + $isRequestedArg = $token[1] === $argName; + + if (!$capture && $isRequestedArg) { + $capture = true; + } elseif ($capture && !$isRequestedArg) { + $trimToLast = ','; + break; + } + } + + if ($token === ':' || $token === '{') { + break; + } + + if ($capture) { + $captured[] = $token; + } + } + + while( + count($captured) + && array_pop($captured) !== $trimToLast + ) { + } + + return $captured; + } +} diff --git a/src/Formatting/Variables.php b/src/Formatting/Variables.php index fa9da48..be68303 100644 --- a/src/Formatting/Variables.php +++ b/src/Formatting/Variables.php @@ -6,7 +6,6 @@ use ReflectionClass; use ReflectionIntersectionType; -use ReflectionMethod; use ReflectionNamedType; use ReflectionUnionType; diff --git a/src/Methods/Mocker.php b/src/Methods/Mocker.php index 7911473..480aefa 100644 --- a/src/Methods/Mocker.php +++ b/src/Methods/Mocker.php @@ -4,6 +4,7 @@ namespace Exan\Moock\Methods; +use Exan\Moock\Extractor; use Exan\Moock\Formatting\Variables as FormatsVariables; use ReflectionClass; use ReflectionMethod; @@ -70,7 +71,7 @@ private function getFormattedMethod(ReflectionMethod $method): string $functionArgs = implode( ', ', array_map( - fn (ReflectionParameter $parameter) => $this->getParameterSignature($parameter, $declaringClass), + fn (ReflectionParameter $parameter) => $this->getParameterSignature($parameter, $method, $declaringClass), $method->getParameters(), ), ); @@ -95,7 +96,7 @@ public function $name($functionArgs) $returnSignature { FUNC; } - private function getParameterSignature(ReflectionParameter $parameter, ReflectionClass $declaringClass): string + private function getParameterSignature(ReflectionParameter $parameter, ReflectionMethod $declaringMethod, ReflectionClass $declaringClass): string { $type = $parameter->getType(); @@ -115,12 +116,42 @@ private function getParameterSignature(ReflectionParameter $parameter, Reflectio if ($parameter->isDefaultValueAvailable()) { $defaultValue = $parameter->getDefaultValue(); - $signature .= ' = ' . $this->formatValue($defaultValue); + $signature .= ' = ' . (is_object($defaultValue) + ? $this->extractDefaultParamSignature($parameter, $declaringMethod, $declaringClass) + : $this->formatValue($defaultValue)); } return $signature; } + private function extractDefaultParamSignature(ReflectionParameter $parameter, ReflectionMethod $method, ReflectionClass $class): string + { + // public function myMethod(MyClass $myArg = new MyClass()) + // Unfortunately, reflection only gives the exact instance of the default value. It is therefore impossible to recreate the + // code used to instantiate an object based on reflection. It needs to be extracted from the original file instead. + + $fileContents = file_get_contents($class->getFileName()); + + $tokens = Extractor::lines(token_get_all($fileContents), $method->getStartLine(), $method->getEndLine()); + $method = Extractor::function($tokens, $method->getName()); + $arg = Extractor::arg($method, $parameter->getName()); + + while( + count($arg) + && (!is_array($arg) || $arg[0][0] !== T_NEW) + ) { + array_shift($arg); + } + + array_shift($arg); // new + array_shift($arg); // (whitespace) + array_shift($arg); // (classname) + + $flattenedTokens = array_map(fn (string|array $token) => is_array($token) ? $token[1] : $token, $arg); + + return 'new \\' . $parameter->getDefaultValue()::class . implode(' ', $flattenedTokens); + } + private function getInternalMockCallArgs(ReflectionMethod $method): string { $parameters = $method->getParameters(); diff --git a/tests/Components/InstantiatedDefaultArgs.php b/tests/Components/InstantiatedDefaultArgs.php new file mode 100644 index 0000000..47225d7 --- /dev/null +++ b/tests/Components/InstantiatedDefaultArgs.php @@ -0,0 +1,16 @@ +{$method}(...))->replace($validator); + + $this->assertTrue($mock->{$method}()); + } + + public static function objectInstantiationDataProvider(): array + { + return [ + 'Empty constructor' => [ + 'methodDefaultEmpty', + function (MixedConstructor $mixedConstructor) { + return $mixedConstructor->property === null; + } + ], + 'String in constructor' => [ + 'methodDefaultString', + function (MixedConstructor $mixedConstructor) { + return $mixedConstructor->property === '::my string::'; + } + ], + ]; + } } From 3a1dcafc652c6edeaa757a2745c5e239bb0001c7 Mon Sep 17 00:00:00 2001 From: Exan Date: Tue, 23 Jun 2026 20:52:44 +0200 Subject: [PATCH 2/9] More or less reimplement PHP import logic --- src/{ => Analyzer}/Extractor.php | 65 ++++++- src/Analyzer/Utilize.php | 178 +++++++++++++++++++ src/Methods/Mocker.php | 25 ++- tests/Components/InstantiatedDefaultArgs.php | 22 +++ tests/Methods/MockerTest.php | 44 +++-- 5 files changed, 317 insertions(+), 17 deletions(-) rename src/{ => Analyzer}/Extractor.php (67%) create mode 100644 src/Analyzer/Utilize.php diff --git a/src/Extractor.php b/src/Analyzer/Extractor.php similarity index 67% rename from src/Extractor.php rename to src/Analyzer/Extractor.php index 08fa52f..1d21a04 100644 --- a/src/Extractor.php +++ b/src/Analyzer/Extractor.php @@ -2,10 +2,14 @@ declare(strict_types=1); -namespace Exan\Moock; +namespace Exan\Moock\Analyzer; /** * @internal + * + * I am not a language dev, please don't judge me too harshly for this poor excuse of a parser :) + * + * Valid PHP can be assumed, as the files have gone through reflection already prior to reaching this stage. */ class Extractor { @@ -124,4 +128,63 @@ public static function arg(array $tokens, string $argName): array return $captured; } + + /** + * @param array $tokens + * @return array + */ + public static function uses(array $tokens): array + { + $uses = []; + $use = null; + + $blockScope = 0; + + foreach ($tokens as $token) { + if ($token === '{') { + $blockScope++; + } + + if ($token === '}') { + $blockScope--; + } + + if ($token === ';') { + $use = null; + } + + if ($blockScope === 0 && is_array($token) && $token[0] === T_USE) { + $use = count($uses); + $uses[] = []; + } + + if ($use !== null) { + $uses[$use][] = $token; + } + } + + return $uses; + } + + public static function namespace(array $tokens): array + { + $namespace = []; + $capturing = false; + + foreach ($tokens as $token) { + if (is_array($token) && $token[0] === T_NAMESPACE) { + $capturing = true; + } + + if ($capturing && $token === ';') { + break; + } + + if ($capturing) { + $namespace[] = $token; + } + } + + return $namespace; + } } diff --git a/src/Analyzer/Utilize.php b/src/Analyzer/Utilize.php new file mode 100644 index 0000000..d9df720 --- /dev/null +++ b/src/Analyzer/Utilize.php @@ -0,0 +1,178 @@ + $uses + */ + public function __construct( + private readonly ?string $declaringNamespace, + private readonly array $uses, + ) { + } + + public function fullyQuantify(string $className): string + { + if (isset($this->uses[$className])) { + return $this->format($this->uses[$className]); + } + + return $this->format(($this->declaringNamespace ?? '') . '\\' . $className); + } + + private function format(string $fullyQualifiedClass): string + { + return '\\' . trim($fullyQualifiedClass, '\\'); + } + + /** + * @param string $declaringNamespace + * @param array> $uses + */ + public static function fromTokens(?string $declaringNamespace, array $uses): static + { + $convertedUses = array_merge(...array_map(static::parseLine(...), $uses)); + + return new static($declaringNamespace, array_merge(...$convertedUses)); + } + + /** + * @param array $line + */ + private static function parseLine($line): array + { + array_shift($line); // use + array_shift($line); // (whitespace) + + return array_map(static::parseIndividualUse(...), static::individualUses($line)); + } + + /** + * @param array $tokens + * @return array> + */ + private static function individualUses($tokens): array + { + $individual = [[]]; + $blockLevel = 0; + $i = 0; + + foreach ($tokens as $token) { + if ($token === '{') { + $blockLevel++; + } + + if ($token === '}') { + $blockLevel--; + } + + if ($blockLevel === 0 && $token === ',') { + $individual[] = []; + $i++; + continue; + } + + $individual[$i][] = $token; + } + + return $individual; + } + + private static function parseIndividualUse(array $use): array + { + while (is_array($use[0]) && $use[0][0] === T_WHITESPACE) { + array_shift($use); + } + + if (count($use) === 1) { + // use Some\Potentially\Namespaced\Class + $split = explode('\\', $use[0][1]); + return [array_pop($split) => $use[0][1]]; + } + + if (count($use) === 5 && is_array($use[2]) && $use[2][1] === 'as') { + // use Some\Potentially\Namespaced\Class as Alias + $trueImport = array_shift($use); + $alias = array_pop($use); + + return [$alias[1] => $trueImport[1]]; + } + + // use Some\Potentially\Namespaced\{Class, Or\Other\Class} + return static::parseGroupUse($use); + } + + /** + * @param array $tokens + */ + private static function parseGroupUse(array $tokens): array + { + $namespacePrefix = ''; + while (count($tokens) && $tokens[0] !== '{') { + $token = array_shift($tokens); + + $namespacePrefix .= (is_array($token) ? $token[1] : $token); + } + + array_shift($tokens); // { + array_pop($tokens); // } + + $imports = static::splitGroupUse($tokens); + + return array_merge(...array_map(function (array $tokens) use ($namespacePrefix) { + while (count($tokens) && is_array($tokens) && $tokens[0][0] === T_WHITESPACE) { + array_shift($tokens); + } + + $fullImport = $namespacePrefix . $tokens[0][1]; + + if (count($tokens) < 3) { // Not aliased, possibly followed by whitespace + $split = explode('\\', $fullImport); + + $alias = array_pop($split); + } else { // Aliased + $alias = $tokens[4][1]; + } + + return [$alias => $fullImport]; + }, $imports)); + } + + /** + * @param array $tokens + * @return array> + */ + private static function splitGroupUse(array $tokens): array + { + $imports = [[]]; + $i = 0; + + foreach ($tokens as $token) { + if ($token === '}') { + break; + } + + if ($token === ',') { + $imports[] = []; + $i++; + continue; + } + + $imports[$i][] = $token; + } + + return $imports; + } +} diff --git a/src/Methods/Mocker.php b/src/Methods/Mocker.php index 480aefa..bd130e0 100644 --- a/src/Methods/Mocker.php +++ b/src/Methods/Mocker.php @@ -4,7 +4,8 @@ namespace Exan\Moock\Methods; -use Exan\Moock\Extractor; +use Exan\Moock\Analyzer\Extractor; +use Exan\Moock\Analyzer\Utilize; use Exan\Moock\Formatting\Variables as FormatsVariables; use ReflectionClass; use ReflectionMethod; @@ -132,8 +133,14 @@ private function extractDefaultParamSignature(ReflectionParameter $parameter, Re $fileContents = file_get_contents($class->getFileName()); - $tokens = Extractor::lines(token_get_all($fileContents), $method->getStartLine(), $method->getEndLine()); - $method = Extractor::function($tokens, $method->getName()); + $tokens = token_get_all($fileContents); + + $uses = Extractor::uses($tokens); + $namespace = Extractor::namespace($tokens); + $utilize = Utilize::fromTokens(count($namespace) > 0 ? $namespace[2][1] : null, $uses); + + $class = Extractor::lines($tokens, $method->getStartLine(), $method->getEndLine()); + $method = Extractor::function($class, $method->getName()); $arg = Extractor::arg($method, $parameter->getName()); while( @@ -147,7 +154,17 @@ private function extractDefaultParamSignature(ReflectionParameter $parameter, Re array_shift($arg); // (whitespace) array_shift($arg); // (classname) - $flattenedTokens = array_map(fn (string|array $token) => is_array($token) ? $token[1] : $token, $arg); + $flattenedTokens = array_map(function (string|array $token) use ($utilize) { + if (is_string($token)) { + return $token; + } + + if ($token[0] === T_STRING) { + return $utilize->fullyQuantify($token[1]); + } + + return $token[1]; + }, $arg); return 'new \\' . $parameter->getDefaultValue()::class . implode(' ', $flattenedTokens); } diff --git a/tests/Components/InstantiatedDefaultArgs.php b/tests/Components/InstantiatedDefaultArgs.php index 47225d7..771a0b6 100644 --- a/tests/Components/InstantiatedDefaultArgs.php +++ b/tests/Components/InstantiatedDefaultArgs.php @@ -4,6 +4,8 @@ namespace Tests\Components; +use Tests\Components\MixedConstructor; + class InstantiatedDefaultArgs { public function methodDefaultEmpty(MixedConstructor $prop = new MixedConstructor()) @@ -13,4 +15,24 @@ public function methodDefaultEmpty(MixedConstructor $prop = new MixedConstructor public function methodDefaultString(MixedConstructor $prop = new MixedConstructor('::my string::')) { } + + public function methodDefaultRecursiveEmpty(MixedConstructor $prop = new MixedConstructor(new MixedConstructor())) + { + } + + public function methodDefaultRecursiveWithString(MixedConstructor $prop = new MixedConstructor(new MixedConstructor('::nested string::'))) + { + } + + public function methodDefaultPhpVariableSyntaxString(MixedConstructor $prop = new MixedConstructor('$variable')) + { + } + + public function methodDefaultPhpTagSyntaxString(MixedConstructor $prop = new MixedConstructor('')) + { + } + + public function methodDefaultNewKeywordInString(MixedConstructor $prop = new MixedConstructor('new ClassName()')) + { + } } diff --git a/tests/Methods/MockerTest.php b/tests/Methods/MockerTest.php index 7fa9374..789c1a2 100644 --- a/tests/Methods/MockerTest.php +++ b/tests/Methods/MockerTest.php @@ -10,8 +10,6 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; -use ReflectionClass; -use ReflectionMethod; use Tests\Components\InstantiatedDefaultArgs; use Tests\Components\MixedConstructor; @@ -78,16 +76,6 @@ public function myMethod(string $myArg = 'string', array $myArray = ['default' = ]); } - /** - * @param string[] $methods - * - * @return ReflectionMethod[] - */ - private function getReflectionMethods(array $methods, ReflectionClass $ref): array - { - return array_map(fn (string $method) => $ref->getMethod($method), $methods); - } - public function assertStringContainsInOrder(string $haystack, array $needles): void { foreach ($needles as $needle) { @@ -125,6 +113,38 @@ function (MixedConstructor $mixedConstructor) { return $mixedConstructor->property === '::my string::'; } ], + 'Recursive empty constructor' => [ + 'methodDefaultRecursiveEmpty', + function (MixedConstructor $mixedConstructor) { + return $mixedConstructor->property instanceof MixedConstructor + && $mixedConstructor->property->property === null; + } + ], + 'Recursive constructor with string' => [ + 'methodDefaultRecursiveWithString', + function (MixedConstructor $mixedConstructor) { + return $mixedConstructor->property instanceof MixedConstructor + && $mixedConstructor->property->property === '::nested string::'; + } + ], + 'PHP variable syntax in string' => [ + 'methodDefaultPhpVariableSyntaxString', + function (MixedConstructor $mixedConstructor) { + return $mixedConstructor->property === '$variable'; + } + ], + 'PHP tag syntax in string' => [ + 'methodDefaultPhpTagSyntaxString', + function (MixedConstructor $mixedConstructor) { + return $mixedConstructor->property === ''; + } + ], + 'New keyword in string' => [ + 'methodDefaultNewKeywordInString', + function (MixedConstructor $mixedConstructor) { + return $mixedConstructor->property === 'new ClassName()'; + } + ], ]; } } From 17923a83749dec7e4809c47f239eda794e236bf3 Mon Sep 17 00:00:00 2001 From: Exan Date: Tue, 23 Jun 2026 21:10:43 +0200 Subject: [PATCH 3/9] cs --- src/Analyzer/Extractor.php | 4 +-- src/Analyzer/Utilize.php | 5 ++- src/Methods/Mocker.php | 2 +- tests/Components/InstantiatedDefaultArgs.php | 21 ++++++++----- tests/Components/MixedConstructor.php | 4 +-- tests/Methods/MockerTest.php | 32 ++++++-------------- 6 files changed, 29 insertions(+), 39 deletions(-) diff --git a/src/Analyzer/Extractor.php b/src/Analyzer/Extractor.php index 1d21a04..4f34a20 100644 --- a/src/Analyzer/Extractor.php +++ b/src/Analyzer/Extractor.php @@ -71,7 +71,7 @@ public static function function(array $tokens, string $functionName): array } } - while( + while ( count($captured) && is_array($captured[count($captured) - 1]) && in_array( @@ -120,7 +120,7 @@ public static function arg(array $tokens, string $argName): array } } - while( + while ( count($captured) && array_pop($captured) !== $trimToLast ) { diff --git a/src/Analyzer/Utilize.php b/src/Analyzer/Utilize.php index d9df720..f4746b7 100644 --- a/src/Analyzer/Utilize.php +++ b/src/Analyzer/Utilize.php @@ -20,8 +20,7 @@ class Utilize public function __construct( private readonly ?string $declaringNamespace, private readonly array $uses, - ) { - } + ) {} public function fullyQuantify(string $className): string { @@ -131,7 +130,7 @@ private static function parseGroupUse(array $tokens): array $imports = static::splitGroupUse($tokens); - return array_merge(...array_map(function (array $tokens) use ($namespacePrefix) { + return array_merge(...array_map(function (array $tokens) use ($namespacePrefix) { while (count($tokens) && is_array($tokens) && $tokens[0][0] === T_WHITESPACE) { array_shift($tokens); } diff --git a/src/Methods/Mocker.php b/src/Methods/Mocker.php index bd130e0..be50513 100644 --- a/src/Methods/Mocker.php +++ b/src/Methods/Mocker.php @@ -143,7 +143,7 @@ private function extractDefaultParamSignature(ReflectionParameter $parameter, Re $method = Extractor::function($class, $method->getName()); $arg = Extractor::arg($method, $parameter->getName()); - while( + while ( count($arg) && (!is_array($arg) || $arg[0][0] !== T_NEW) ) { diff --git a/tests/Components/InstantiatedDefaultArgs.php b/tests/Components/InstantiatedDefaultArgs.php index 771a0b6..9352dbb 100644 --- a/tests/Components/InstantiatedDefaultArgs.php +++ b/tests/Components/InstantiatedDefaultArgs.php @@ -8,31 +8,38 @@ class InstantiatedDefaultArgs { - public function methodDefaultEmpty(MixedConstructor $prop = new MixedConstructor()) + public function methodDefaultEmpty(MixedConstructor $prop = new MixedConstructor()): bool { + return false; } - public function methodDefaultString(MixedConstructor $prop = new MixedConstructor('::my string::')) + public function methodDefaultString(MixedConstructor $prop = new MixedConstructor('::my string::')): bool { + return false; } - public function methodDefaultRecursiveEmpty(MixedConstructor $prop = new MixedConstructor(new MixedConstructor())) + public function methodDefaultRecursiveEmpty(MixedConstructor $prop = new MixedConstructor(new MixedConstructor())): bool { + return false; } - public function methodDefaultRecursiveWithString(MixedConstructor $prop = new MixedConstructor(new MixedConstructor('::nested string::'))) + public function methodDefaultRecursiveWithString(MixedConstructor $prop = new MixedConstructor(new MixedConstructor('::nested string::'))): bool { + return false; } - public function methodDefaultPhpVariableSyntaxString(MixedConstructor $prop = new MixedConstructor('$variable')) + public function methodDefaultPhpVariableSyntaxString(MixedConstructor $prop = new MixedConstructor('$variable')): bool { + return false; } - public function methodDefaultPhpTagSyntaxString(MixedConstructor $prop = new MixedConstructor('')) + public function methodDefaultPhpTagSyntaxString(MixedConstructor $prop = new MixedConstructor('')): bool { + return false; } - public function methodDefaultNewKeywordInString(MixedConstructor $prop = new MixedConstructor('new ClassName()')) + public function methodDefaultNewKeywordInString(MixedConstructor $prop = new MixedConstructor('new ClassName()')): bool { + return false; } } diff --git a/tests/Components/MixedConstructor.php b/tests/Components/MixedConstructor.php index 1870e96..8bc58d5 100644 --- a/tests/Components/MixedConstructor.php +++ b/tests/Components/MixedConstructor.php @@ -6,7 +6,5 @@ class MixedConstructor { - public function __construct(public mixed $property = null) - { - } + public function __construct(public mixed $property = null) {} } diff --git a/tests/Methods/MockerTest.php b/tests/Methods/MockerTest.php index 789c1a2..c5e794e 100644 --- a/tests/Methods/MockerTest.php +++ b/tests/Methods/MockerTest.php @@ -103,47 +103,33 @@ public static function objectInstantiationDataProvider(): array return [ 'Empty constructor' => [ 'methodDefaultEmpty', - function (MixedConstructor $mixedConstructor) { - return $mixedConstructor->property === null; - } + fn (MixedConstructor $mixedConstructor) => $mixedConstructor->property === null, ], 'String in constructor' => [ 'methodDefaultString', - function (MixedConstructor $mixedConstructor) { - return $mixedConstructor->property === '::my string::'; - } + fn (MixedConstructor $mixedConstructor) => $mixedConstructor->property === '::my string::', ], 'Recursive empty constructor' => [ 'methodDefaultRecursiveEmpty', - function (MixedConstructor $mixedConstructor) { - return $mixedConstructor->property instanceof MixedConstructor - && $mixedConstructor->property->property === null; - } + fn (MixedConstructor $mixedConstructor) => $mixedConstructor->property instanceof MixedConstructor + && $mixedConstructor->property->property === null, ], 'Recursive constructor with string' => [ 'methodDefaultRecursiveWithString', - function (MixedConstructor $mixedConstructor) { - return $mixedConstructor->property instanceof MixedConstructor - && $mixedConstructor->property->property === '::nested string::'; - } + fn (MixedConstructor $mixedConstructor) => $mixedConstructor->property instanceof MixedConstructor + && $mixedConstructor->property->property === '::nested string::', ], 'PHP variable syntax in string' => [ 'methodDefaultPhpVariableSyntaxString', - function (MixedConstructor $mixedConstructor) { - return $mixedConstructor->property === '$variable'; - } + fn (MixedConstructor $mixedConstructor) => $mixedConstructor->property === '$variable', ], 'PHP tag syntax in string' => [ 'methodDefaultPhpTagSyntaxString', - function (MixedConstructor $mixedConstructor) { - return $mixedConstructor->property === ''; - } + fn (MixedConstructor $mixedConstructor) => $mixedConstructor->property === '', ], 'New keyword in string' => [ 'methodDefaultNewKeywordInString', - function (MixedConstructor $mixedConstructor) { - return $mixedConstructor->property === 'new ClassName()'; - } + fn (MixedConstructor $mixedConstructor) => $mixedConstructor->property === 'new ClassName()', ], ]; } From ea8bd9d1d44dd47e8db2bc19c07401d69997d3bd Mon Sep 17 00:00:00 2001 From: Exan Date: Tue, 23 Jun 2026 21:30:26 +0200 Subject: [PATCH 4/9] Expand tests --- tests/Components/FqcnDefaultArgs.php | 18 +++ tests/Components/InstantiatedDefaultArgs.php | 44 ++++++- .../InstantiatedDefaultArgsInterface.php | 14 +++ tests/Components/SameNamespaceDefaultArgs.php | 18 +++ tests/Methods/MockerTest.php | 112 ++++++++++++++++++ 5 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 tests/Components/FqcnDefaultArgs.php create mode 100644 tests/Components/InstantiatedDefaultArgsInterface.php create mode 100644 tests/Components/SameNamespaceDefaultArgs.php diff --git a/tests/Components/FqcnDefaultArgs.php b/tests/Components/FqcnDefaultArgs.php new file mode 100644 index 0000000..6e21837 --- /dev/null +++ b/tests/Components/FqcnDefaultArgs.php @@ -0,0 +1,18 @@ + $mixedConstructor->property === 'new ClassName()', ], + 'Aliased import - empty constructor' => [ + 'methodAliasedEmpty', + fn (MixedConstructor $mixedConstructor) => $mixedConstructor->property === null, + ], + 'Aliased import - string in constructor' => [ + 'methodAliasedWithString', + fn (MixedConstructor $mixedConstructor) => $mixedConstructor->property === '::aliased string::', + ], + 'Aliased import - recursive empty constructor' => [ + 'methodAliasedRecursive', + fn (MixedConstructor $mixedConstructor) => $mixedConstructor->property instanceof MixedConstructor + && $mixedConstructor->property->property === null, + ], + 'Aliased import - recursive with string' => [ + 'methodAliasedRecursiveWithString', + fn (MixedConstructor $mixedConstructor) => $mixedConstructor->property instanceof MixedConstructor + && $mixedConstructor->property->property === '::aliased nested string::', + ], + 'Individual aliased import - empty constructor' => [ + 'methodIndividualAliasEmpty', + fn (MixedConstructor $mixedConstructor) => $mixedConstructor->property === null, + ], + 'Individual aliased import - string in constructor' => [ + 'methodIndividualAliasWithString', + fn (MixedConstructor $mixedConstructor) => $mixedConstructor->property === '::individual alias string::', + ], + 'Sub-namespace group use - empty constructor' => [ + 'methodSubNamespaceGroupUseEmpty', + fn (MixedConstructor $mixedConstructor) => $mixedConstructor->property === null, + ], + 'Sub-namespace group use - string in constructor' => [ + 'methodSubNamespaceGroupUseWithString', + fn (MixedConstructor $mixedConstructor) => $mixedConstructor->property === '::sub namespace string::', + ], + ]; + } + + #[Test] + #[DataProvider('fqcnObjectInstantiationDataProvider')] + public function it_instantiates_objects_when_using_fqcn(string $method, Closure $validator): void + { + $mock = Mock::class(FqcnDefaultArgs::class); + + Mock::method($mock->{$method}(...))->replace($validator); + + $this->assertTrue($mock->{$method}()); + } + + public static function fqcnObjectInstantiationDataProvider(): array + { + return [ + 'Empty constructor' => [ + 'methodEmpty', + fn (MixedConstructor $m) => $m->property === null, + ], + 'String in constructor' => [ + 'methodWithString', + fn (MixedConstructor $m) => $m->property === '::fqcn string::', + ], + ]; + } + + #[Test] + #[DataProvider('sameNamespaceObjectInstantiationDataProvider')] + public function it_instantiates_objects_when_in_same_namespace(string $method, Closure $validator): void + { + $mock = Mock::class(SameNamespaceDefaultArgs::class); + + Mock::method($mock->{$method}(...))->replace($validator); + + $this->assertTrue($mock->{$method}()); + } + + public static function sameNamespaceObjectInstantiationDataProvider(): array + { + return [ + 'Empty constructor' => [ + 'methodEmpty', + fn (MixedConstructor $m) => $m->property === null, + ], + 'String in constructor' => [ + 'methodWithString', + fn (MixedConstructor $m) => $m->property === '::same namespace string::', + ], + ]; + } + + #[Test] + #[DataProvider('interfaceObjectInstantiationDataProvider')] + public function it_instantiates_objects_when_declared_on_interface(string $method, Closure $validator): void + { + $mock = Mock::interface(InstantiatedDefaultArgsInterface::class); + + Mock::method($mock->{$method}(...))->replace($validator); + + $this->assertTrue($mock->{$method}()); + } + + public static function interfaceObjectInstantiationDataProvider(): array + { + return [ + 'Empty constructor' => [ + 'methodEmpty', + fn (MixedConstructor $m) => $m->property === null, + ], + 'String in constructor' => [ + 'methodWithString', + fn (MixedConstructor $m) => $m->property === '::interface string::', + ], ]; } } From e80380eb9622535bbd9842a8e16be8ba71cde92e Mon Sep 17 00:00:00 2001 From: Exan Date: Tue, 23 Jun 2026 21:42:03 +0200 Subject: [PATCH 5/9] Add more test cases, including non-passing ones --- .php-cs-fixer.dist.php | 1 + .../Components/WeirdFormattingDefaultArgs.php | 92 +++++++++++++++++++ tests/Methods/MockerTest.php | 71 ++++++++++++++ 3 files changed, 164 insertions(+) create mode 100644 tests/Components/WeirdFormattingDefaultArgs.php diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 70bc001..c0ffd62 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -17,5 +17,6 @@ ->setFinder( (new Finder()) ->in(__DIR__) + ->notPath('tests/Components/WeirdFormattingDefaultArgs.php') ) ; diff --git a/tests/Components/WeirdFormattingDefaultArgs.php b/tests/Components/WeirdFormattingDefaultArgs.php new file mode 100644 index 0000000..89d8a3c --- /dev/null +++ b/tests/Components/WeirdFormattingDefaultArgs.php @@ -0,0 +1,92 @@ +{$method}(...))->replace($validator); + + $this->assertTrue($mock->{$method}()); + } + + public static function weirdFormattingObjectInstantiationDataProvider(): array + { + return [ + 'Extra spaces around equals' => [ + 'methodExtraSpacesAroundEquals', + fn (MixedConstructor $m) => $m->property === null, + ], + 'Multiline signature' => [ + 'methodMultilineSignature', + fn (MixedConstructor $m) => $m->property === null, + ], + 'Multiline signature with string' => [ + 'methodMultilineSignatureWithString', + fn (MixedConstructor $m) => $m->property === '::multiline string::', + ], + 'Spaces inside constructor args' => [ + 'methodSpacesInsideConstructorArgs', + fn (MixedConstructor $m) => $m->property === '::spaced args string::', + ], + 'Space between class and opening paren' => [ + 'methodSpaceBetweenClassAndParens', + fn (MixedConstructor $m) => $m->property === '::spaced parens::', + ], + 'Everything on its own line' => [ + 'methodEverythingOnItsOwnLine', + fn (MixedConstructor $m) => $m->property === '::everything separate::', + ], + 'Tabs as indentation' => [ + 'methodTabsAsIndentation', + fn (MixedConstructor $m) => $m->property === '::tabs indented::', + ], + 'Trailing comma in constructor' => [ + 'methodTrailingCommaInConstructor', + fn (MixedConstructor $m) => $m->property === '::trailing comma::', + ], + 'Comment inside constructor args' => [ + 'methodCommentInsideArgs', + fn (MixedConstructor $m) => $m->property === '::commented args::', + ], + 'Multiline nested constructors' => [ + 'methodMultilineNested', + fn (MixedConstructor $m) => $m->property instanceof MixedConstructor + && $m->property->property === '::multiline nested::', + ], + 'Comment between new and class name' => [ + 'methodCommentBetweenNewAndClass', + fn (MixedConstructor $m) => $m->property === '::new comment::', + ], + 'Named argument in constructor' => [ + 'methodNamedArg', + fn (MixedConstructor $m) => $m->property === '::named arg::', + ], + 'Comment between function keyword and method name' => [ + 'methodFunctionKeywordCommented', + fn (MixedConstructor $m) => $m->property === '::function commented::', + ], + ]; + } } From b321c37932bb79f97d569a8e9bd324d0b69a9702 Mon Sep 17 00:00:00 2001 From: Exan Date: Wed, 24 Jun 2026 12:53:08 +0200 Subject: [PATCH 6/9] Filter out comments --- phpunit.xml | 2 +- src/Methods/Mocker.php | 15 ++++++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/phpunit.xml b/phpunit.xml index 18ca5eb..00b1722 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -7,7 +7,7 @@ beStrictAboutOutputDuringTests="true" failOnRisky="true" failOnWarning="true" - stopOnError="true" + stopOnError="false" cacheDirectory=".phpunit.cache" requireCoverageMetadata="false" beStrictAboutCoverageMetadata="false" diff --git a/src/Methods/Mocker.php b/src/Methods/Mocker.php index be50513..caeba88 100644 --- a/src/Methods/Mocker.php +++ b/src/Methods/Mocker.php @@ -133,7 +133,18 @@ private function extractDefaultParamSignature(ReflectionParameter $parameter, Re $fileContents = file_get_contents($class->getFileName()); - $tokens = token_get_all($fileContents); + $tokens = array_filter( + token_get_all($fileContents), + fn (string|array $token) => !is_array($token) || !in_array($token[0], [T_COMMENT, T_DOC_COMMENT]) + ); + + $isWhitespace = fn (string|array $token) => is_array($token) && $token[0] === T_WHITESPACE; + + foreach ($tokens as $i => $token) { + if ($isWhitespace($token) && isset($tokens[$i + 1]) && $isWhitespace($tokens[$i + 1])) { + unset($tokens[$i]); + } + } $uses = Extractor::uses($tokens); $namespace = Extractor::namespace($tokens); @@ -160,6 +171,8 @@ private function extractDefaultParamSignature(ReflectionParameter $parameter, Re } if ($token[0] === T_STRING) { + dump($token, $utilize->fullyQuantify($token[1])); + return $utilize->fullyQuantify($token[1]); } From b46929026c23465a415c0d5f5f4880b444c6ba19 Mon Sep 17 00:00:00 2001 From: Exanlv <51094537+Exanlv@users.noreply.github.com> Date: Thu, 25 Jun 2026 18:22:35 +0200 Subject: [PATCH 7/9] Filter out comments, dedupe whitespace --- src/Methods/Mocker.php | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Methods/Mocker.php b/src/Methods/Mocker.php index caeba88..691b4e6 100644 --- a/src/Methods/Mocker.php +++ b/src/Methods/Mocker.php @@ -138,14 +138,17 @@ private function extractDefaultParamSignature(ReflectionParameter $parameter, Re fn (string|array $token) => !is_array($token) || !in_array($token[0], [T_COMMENT, T_DOC_COMMENT]) ); - $isWhitespace = fn (string|array $token) => is_array($token) && $token[0] === T_WHITESPACE; + $tokens = array_values($tokens); + $isWhitespace = fn (string|array $token) => is_array($token) && $token[0] === T_WHITESPACE; foreach ($tokens as $i => $token) { if ($isWhitespace($token) && isset($tokens[$i + 1]) && $isWhitespace($tokens[$i + 1])) { unset($tokens[$i]); } } + $tokens = array_values($tokens); + $uses = Extractor::uses($tokens); $namespace = Extractor::namespace($tokens); $utilize = Utilize::fromTokens(count($namespace) > 0 ? $namespace[2][1] : null, $uses); @@ -171,8 +174,6 @@ private function extractDefaultParamSignature(ReflectionParameter $parameter, Re } if ($token[0] === T_STRING) { - dump($token, $utilize->fullyQuantify($token[1])); - return $utilize->fullyQuantify($token[1]); } From 8d83c3623b30d6ce822d82d870e77d434bd86981 Mon Sep 17 00:00:00 2001 From: Exanlv <51094537+Exanlv@users.noreply.github.com> Date: Thu, 25 Jun 2026 18:43:57 +0200 Subject: [PATCH 8/9] Fix issue with named args --- src/Analyzer/Extractor.php | 2 +- src/Methods/Mocker.php | 13 ++++++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/Analyzer/Extractor.php b/src/Analyzer/Extractor.php index 4f34a20..fb9d1a8 100644 --- a/src/Analyzer/Extractor.php +++ b/src/Analyzer/Extractor.php @@ -111,7 +111,7 @@ public static function arg(array $tokens, string $argName): array } } - if ($token === ':' || $token === '{') { + if ($token === '{') { break; } diff --git a/src/Methods/Mocker.php b/src/Methods/Mocker.php index 691b4e6..78f40d8 100644 --- a/src/Methods/Mocker.php +++ b/src/Methods/Mocker.php @@ -168,17 +168,24 @@ private function extractDefaultParamSignature(ReflectionParameter $parameter, Re array_shift($arg); // (whitespace) array_shift($arg); // (classname) - $flattenedTokens = array_map(function (string|array $token) use ($utilize) { + $flattenedTokens = array_map(function (string|array $token, int $index) use ($utilize, $arg) { if (is_string($token)) { return $token; } - if ($token[0] === T_STRING) { + $argIsColon = fn (int $i) => isset($arg[$i]) && $arg[$i] === ':'; + $argIsWhitespace = fn (int $i) => isset($arg[$i]) && is_array($arg[$i]) && $arg[$i][0] === T_WHITESPACE; + + if ( + $token[0] === T_STRING + && !($argIsColon($index + 1)) + && !($argIsWhitespace($index + 1) && $argIsColon($index + 2)) + ) { return $utilize->fullyQuantify($token[1]); } return $token[1]; - }, $arg); + }, $arg, array_keys($arg)); return 'new \\' . $parameter->getDefaultValue()::class . implode(' ', $flattenedTokens); } From cf40dab192723686448ac040585603b3be2468b0 Mon Sep 17 00:00:00 2001 From: Exanlv <51094537+Exanlv@users.noreply.github.com> Date: Thu, 25 Jun 2026 18:45:33 +0200 Subject: [PATCH 9/9] cs --- src/Methods/Mocker.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Methods/Mocker.php b/src/Methods/Mocker.php index 78f40d8..b577172 100644 --- a/src/Methods/Mocker.php +++ b/src/Methods/Mocker.php @@ -180,7 +180,7 @@ private function extractDefaultParamSignature(ReflectionParameter $parameter, Re $token[0] === T_STRING && !($argIsColon($index + 1)) && !($argIsWhitespace($index + 1) && $argIsColon($index + 2)) - ) { + ) { return $utilize->fullyQuantify($token[1]); }