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/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/Analyzer/Extractor.php b/src/Analyzer/Extractor.php new file mode 100644 index 0000000..fb9d1a8 --- /dev/null +++ b/src/Analyzer/Extractor.php @@ -0,0 +1,190 @@ + $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 === '{') { + break; + } + + if ($capture) { + $captured[] = $token; + } + } + + while ( + count($captured) + && array_pop($captured) !== $trimToLast + ) { + } + + 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..f4746b7 --- /dev/null +++ b/src/Analyzer/Utilize.php @@ -0,0 +1,177 @@ + $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/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..b577172 100644 --- a/src/Methods/Mocker.php +++ b/src/Methods/Mocker.php @@ -4,6 +4,8 @@ namespace Exan\Moock\Methods; +use Exan\Moock\Analyzer\Extractor; +use Exan\Moock\Analyzer\Utilize; use Exan\Moock\Formatting\Variables as FormatsVariables; use ReflectionClass; use ReflectionMethod; @@ -70,7 +72,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 +97,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 +117,79 @@ 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 = array_filter( + token_get_all($fileContents), + fn (string|array $token) => !is_array($token) || !in_array($token[0], [T_COMMENT, T_DOC_COMMENT]) + ); + + $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); + + $class = Extractor::lines($tokens, $method->getStartLine(), $method->getEndLine()); + $method = Extractor::function($class, $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(function (string|array $token, int $index) use ($utilize, $arg) { + if (is_string($token)) { + return $token; + } + + $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, array_keys($arg)); + + return 'new \\' . $parameter->getDefaultValue()::class . implode(' ', $flattenedTokens); + } + private function getInternalMockCallArgs(ReflectionMethod $method): string { $parameters = $method->getParameters(); 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 @@ +')): bool + { + return false; + } + + public function methodDefaultNewKeywordInString(MixedConstructor $prop = new MixedConstructor('new ClassName()')): bool + { + return false; + } + + public function methodAliasedEmpty(AliasedMC $prop = new AliasedMC()): bool + { + return false; + } + + public function methodAliasedWithString(AliasedMC $prop = new AliasedMC('::aliased string::')): bool + { + return false; + } + + public function methodAliasedRecursive(AliasedMC $prop = new AliasedMC(new AliasedMCTwo())): bool + { + return false; + } + + public function methodAliasedRecursiveWithString(AliasedMC $prop = new AliasedMC(new AliasedMCTwo('::aliased nested string::'))): bool + { + return false; + } + + public function methodIndividualAliasEmpty(IndividualAliasMC $prop = new IndividualAliasMC()): bool + { + return false; + } + + public function methodIndividualAliasWithString(IndividualAliasMC $prop = new IndividualAliasMC('::individual alias string::')): bool + { + return false; + } + + public function methodSubNamespaceGroupUseEmpty(SubNamespaceMC $prop = new SubNamespaceMC()): bool + { + return false; + } + + public function methodSubNamespaceGroupUseWithString(SubNamespaceMC $prop = new SubNamespaceMC('::sub namespace string::')): bool + { + return false; + } +} diff --git a/tests/Components/InstantiatedDefaultArgsInterface.php b/tests/Components/InstantiatedDefaultArgsInterface.php new file mode 100644 index 0000000..8c472a3 --- /dev/null +++ b/tests/Components/InstantiatedDefaultArgsInterface.php @@ -0,0 +1,14 @@ + $ref->getMethod($method), $methods); - } - public function assertStringContainsInOrder(string $haystack, array $needles): void { foreach ($needles as $needle) { @@ -93,4 +90,230 @@ public function assertStringContainsInOrder(string $haystack, array $needles): v $haystack = substr($haystack, $pos); } } + + #[Test] + #[DataProvider('objectInstantiationDataProvider')] + public function it_instantiates_objects_for_arg_defaults(string $method, Closure $validator): void + { + $mock = Mock::class(InstantiatedDefaultArgs::class); + + Mock::method($mock->{$method}(...))->replace($validator); + + $this->assertTrue($mock->{$method}()); + } + + public static function objectInstantiationDataProvider(): array + { + return [ + 'Empty constructor' => [ + 'methodDefaultEmpty', + fn (MixedConstructor $mixedConstructor) => $mixedConstructor->property === null, + ], + 'String in constructor' => [ + 'methodDefaultString', + fn (MixedConstructor $mixedConstructor) => $mixedConstructor->property === '::my string::', + ], + 'Recursive empty constructor' => [ + 'methodDefaultRecursiveEmpty', + fn (MixedConstructor $mixedConstructor) => $mixedConstructor->property instanceof MixedConstructor + && $mixedConstructor->property->property === null, + ], + 'Recursive constructor with string' => [ + 'methodDefaultRecursiveWithString', + fn (MixedConstructor $mixedConstructor) => $mixedConstructor->property instanceof MixedConstructor + && $mixedConstructor->property->property === '::nested string::', + ], + 'PHP variable syntax in string' => [ + 'methodDefaultPhpVariableSyntaxString', + fn (MixedConstructor $mixedConstructor) => $mixedConstructor->property === '$variable', + ], + 'PHP tag syntax in string' => [ + 'methodDefaultPhpTagSyntaxString', + fn (MixedConstructor $mixedConstructor) => $mixedConstructor->property === '', + ], + 'New keyword in string' => [ + 'methodDefaultNewKeywordInString', + fn (MixedConstructor $mixedConstructor) => $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::', + ], + ]; + } + + #[Test] + #[DataProvider('weirdFormattingObjectInstantiationDataProvider')] + public function it_instantiates_objects_with_weird_formatting(string $method, Closure $validator): void + { + $mock = Mock::class(WeirdFormattingDefaultArgs::class); + + Mock::method($mock->{$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::', + ], + ]; + } }