diff --git a/src/Analyzer/Dto/Yanked.php b/src/Analyzer/Dto/Yanked.php new file mode 100644 index 0000000..0e43a77 --- /dev/null +++ b/src/Analyzer/Dto/Yanked.php @@ -0,0 +1,14 @@ + $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/Extractor/Imports.php b/src/Analyzer/Extractor/Imports.php new file mode 100644 index 0000000..3fcd81c --- /dev/null +++ b/src/Analyzer/Extractor/Imports.php @@ -0,0 +1,48 @@ +counter(TokenFilter::eq('{'), TokenFilter::eq('}'), $this->blockLevel); + + $tokenEmitter->on( + TokenFilter::ofType(T_USE), + $this->handleUse(...), + ); + } + + private function handleUse(array $token): void + { + if ($this->blockLevel > 0) { + return; + } + + $statement = [$token]; + + $capture = $this->tokenEmitter->all(function (string|array $token) use (&$statement): void { + $statement[] = $token; + }); + + $this->tokenEmitter->once( + TokenFilter::eq(';'), + function () use ($capture, &$statement): void { + $this->imports[] = $statement; + + $this->tokenEmitter->remove($capture); + } + ); + } +} diff --git a/src/Analyzer/Extractor/MethodArgs.php b/src/Analyzer/Extractor/MethodArgs.php new file mode 100644 index 0000000..de9ca21 --- /dev/null +++ b/src/Analyzer/Extractor/MethodArgs.php @@ -0,0 +1,180 @@ +toCapture[0][0])) { + $this->targetAnonymousClasses(); + } else { + $this->targetRealClasses(); + } + } + + private function targetRealClasses(): void + { + $this->tokenEmitter->on( + TokenFilter::any( + TokenFilter::ofType(T_CLASS), + TokenFilter::ofType(T_INTERFACE), + ), + fn () => $this->tokenEmitter->once( + TokenFilter::ofType(T_STRING), + $this->handleClass(...) + ), + ); + } + + private function targetAnonymousClasses(): void + { + $this->tokenEmitter->onLineChange(function (): void { + $this->anonymousClassIndex = 0; + }); + + $this->tokenEmitter->on( + TokenFilter::ofType(T_NEW), + fn () => $this->tokenEmitter->once( + fn (string|array $token) => is_string($token) || $token[0] !== T_WHITESPACE, + $this->handlePotentialAnonymousClass(...), + ) + ); + } + + /** + * @param 'class'|'method'|'arg' $type + * @param string $value + * @return bool + */ + private function shouldCapture(string $type, string $value): bool + { + $position = [ + 'class' => 0, + 'method' => 1, + 'arg' => 2, + ][$type]; + + if ($this->toCapture[$position] === $value) { + return true; + } + + return false; + } + + private function handleClass(array $token): void + { + if (!$this->shouldCapture('class', $token[1])) { + return; + } + + $blockLevel = 0; + + $this->tokenEmitter->counter(TokenFilter::eq('{'), TokenFilter::eq('}'), $blockLevel); + + $capture = $this->tokenEmitter->on( + TokenFilter::ofType(T_FUNCTION), + fn () => $this->tokenEmitter->once( + TokenFilter::ofType(T_STRING), + $this->handleMethod(...), + ) + ); + + $this->tokenEmitter->once(function (string|array $token) use (&$blockLevel) { + return $blockLevel === 0 && $token === '}'; + }, function () use ($capture): void { + $this->tokenEmitter->remove($capture); + }); + } + + public function handlePotentialAnonymousClass(string|array $token): void + { + if (!is_array($token) || $token[0] !== T_CLASS) { + return; + } + + $fakeToken = [ + T_STRING, + self::anonymousClassInternalName($token[2], $this->anonymousClassIndex++), + $token[2], + ]; + + $this->handleClass($fakeToken); + } + + public function handleMethod(array $token): void + { + if (!$this->shouldCapture('method', $token[1])) { + return; + } + + $capture = $this->tokenEmitter->on(TokenFilter::ofType(T_VARIABLE), $this->handleArg(...)); + + $this->tokenEmitter->once( + TokenFilter::any( + TokenFilter::eq('{'), + TokenFilter::eq(';'), + ), + fn () => $this->tokenEmitter->remove($capture), + ); + } + + public function handleArg(array $token): void + { + if (!$this->shouldCapture('arg', $token[1])) { + return; + } + + $arg = []; + $parenthesisLevel = 0; + + $toCancel = []; + + $toCancel[] = $this->tokenEmitter->on(TokenFilter::eq('('), function () use (&$parenthesisLevel): void { + $parenthesisLevel++; + }); + + $toCancel[] = $this->tokenEmitter->on(TokenFilter::eq(')'), function () use (&$parenthesisLevel): void { + $parenthesisLevel--; + }); + + $toCancel[] = $this->tokenEmitter->all(function (array|string $token) use (&$arg): void { + $arg[] = $token; + }); + + $this->tokenEmitter->once(function (string|array $token) use (&$parenthesisLevel) { + $nextArgStarting = $parenthesisLevel === 0 && $token === ','; + $methodEnding = $parenthesisLevel === -1 && $token === ')'; + + return $nextArgStarting || $methodEnding; + }, function () use (&$arg, $toCancel): void { + $this->tokenEmitter->remove(...$toCancel); + + $this->captured = array_slice($arg, 3, -1); + }); + } +} diff --git a/src/Analyzer/Extractor/Nemaspace.php b/src/Analyzer/Extractor/Nemaspace.php new file mode 100644 index 0000000..4e34354 --- /dev/null +++ b/src/Analyzer/Extractor/Nemaspace.php @@ -0,0 +1,31 @@ +tokenEmitter->on(TokenFilter::ofType(T_NAMESPACE), function (array $token): void { + $this->namespace = [$token]; + + $capture = $this->tokenEmitter->all(function (string|array $token): void { + $this->namespace[] = $token; + }); + + $this->tokenEmitter->once(TokenFilter::eq(';'), fn () => $this->tokenEmitter->remove($capture)); + }); + } +} diff --git a/src/Analyzer/TokenEmitter.php b/src/Analyzer/TokenEmitter.php new file mode 100644 index 0000000..cc0b5de --- /dev/null +++ b/src/Analyzer/TokenEmitter.php @@ -0,0 +1,123 @@ + + */ + private array $handlers = []; + + public function on(Closure $match, Closure $handler): int + { + $id = $this->getId(); + $this->handlers[$id] = ['match' => $match, 'handler' => $handler]; + + return $id; + } + + public function onLineChange(Closure $handler): int + { + $line = 0; + + return $this->all(function (string|array $token) use (&$line, $handler): void { + if (is_string($token)) { + return; + } + + if ($token[2] > $line) { + $line = $token[2]; + $handler(); + } + }); + } + + public function all(Closure $handler): int + { + $id = $this->getId(); + $this->handlers[$id] = ['handler' => $handler]; + + return $id; + } + + public function once(Closure $match, Closure $handler): void + { + $id = $this->getId(); + $this->handlers[$id] = ['match' => $match, 'handler' => $handler, 'once' => true]; + } + + public function counter(Closure $up, Closure $down, int &$value): array + { + return [ + $this->on($up, function () use (&$value): void { + $value++; + }), + $this->on($down, function () use (&$value): void { + $value--; + }), + ]; + } + + public function remove(int ...$ids): void + { + foreach ($ids as $id) { + unset($this->handlers[$id]); + } + } + + private function getId(): int + { + do { + $id = random_int(100_000, 999_999); + } while (isset($this->handlers[$id])); + + return $id; + } + + public function emit(array $tokens): void + { + $wasWhitespace = false; + foreach ($tokens as $token) { + if (static::isType($token, T_COMMENT)) { + continue; + } + + $isWhitespace = static::isType($token, T_WHITESPACE); + if ($isWhitespace) { + if ($wasWhitespace) { + continue; + } + + $wasWhitespace = true; + } else { + $wasWhitespace = false; + } + + $this->emitSingularToken($token); + } + } + + private function emitSingularToken(array|string $token): void + { + foreach ($this->handlers as $i => $handler) { + if (!isset($handler['match']) || $handler['match']($token)) { + $handler['handler']($token); + + if ($handler['once'] ?? false) { + unset($this->handlers[$i]); + } + } + } + } + + private static function isType(string|array $token, int $type): bool + { + return is_array($token) && $token[0] === $type; + } +} diff --git a/src/Analyzer/TokenFilter.php b/src/Analyzer/TokenFilter.php new file mode 100644 index 0000000..8f73043 --- /dev/null +++ b/src/Analyzer/TokenFilter.php @@ -0,0 +1,34 @@ + $token === $value; + } + + public static function ofType(int $type): Closure + { + return fn (array|string $token) => is_array($token) && $token[0] === $type; + } + + public static function any(Closure ...$closures): Closure + { + return function (array|string $token) use ($closures) { + foreach ($closures as $closure) { + if ($closure($token)) { + return true; + } + } + + return false; + }; + } +} diff --git a/src/Analyzer/Utilize.php b/src/Analyzer/Utilize.php index f4746b7..9191215 100644 --- a/src/Analyzer/Utilize.php +++ b/src/Analyzer/Utilize.php @@ -54,6 +54,7 @@ private static function parseLine($line): array { array_shift($line); // use array_shift($line); // (whitespace) + array_pop($line); // ; return array_map(static::parseIndividualUse(...), static::individualUses($line)); } diff --git a/src/Analyzer/Yanker.php b/src/Analyzer/Yanker.php new file mode 100644 index 0000000..bc434d0 --- /dev/null +++ b/src/Analyzer/Yanker.php @@ -0,0 +1,40 @@ +emit($tokens); + + return new Yanked($namespace, $imports, $methodArgs); + } +} diff --git a/src/Methods/Mocker.php b/src/Methods/Mocker.php index b577172..da5bc7e 100644 --- a/src/Methods/Mocker.php +++ b/src/Methods/Mocker.php @@ -4,13 +4,14 @@ namespace Exan\Moock\Methods; -use Exan\Moock\Analyzer\Extractor; use Exan\Moock\Analyzer\Utilize; +use Exan\Moock\Analyzer\Yanker; use Exan\Moock\Formatting\Variables as FormatsVariables; use ReflectionClass; use ReflectionMethod; use ReflectionNamedType; use ReflectionParameter; +use RuntimeException; /** * @internal @@ -21,7 +22,7 @@ class Mocker public readonly array $methods; - /** @param string[] */ + /** @param string[] $interfaces */ public function __construct( public readonly array $interfaces, ) { @@ -118,51 +119,50 @@ private function getParameterSignature(ReflectionParameter $parameter, Reflectio $defaultValue = $parameter->getDefaultValue(); $signature .= ' = ' . (is_object($defaultValue) - ? $this->extractDefaultParamSignature($parameter, $declaringMethod, $declaringClass) + ? $this->extractDefaultParamSignature($parameter, $declaringMethod) : $this->formatValue($defaultValue)); } return $signature; } - private function extractDefaultParamSignature(ReflectionParameter $parameter, ReflectionMethod $method, ReflectionClass $class): string + private function extractDefaultParamSignature(ReflectionParameter $parameter, ReflectionMethod $method): 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]) - ); + $class = $method->getDeclaringClass(); - $tokens = array_values($tokens); + $fileName = $class->getFileName(); - $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]); - } + if (str_contains($fileName, 'eval()\'d code')) { + throw new RuntimeException( + sprintf( + 'Unable to retrieve class construction in default parameters from eval-based class `%s`', + $fileName + ) + ); } - $tokens = array_values($tokens); + $fileContents = file_get_contents($class->getFileName()); - $uses = Extractor::uses($tokens); - $namespace = Extractor::namespace($tokens); - $utilize = Utilize::fromTokens(count($namespace) > 0 ? $namespace[2][1] : null, $uses); + if ($class->isAnonymous()) { + $fullName = explode(':', $class->getName()); + $className = array_pop($fullName); + } else { + $fullName = explode('\\', $class->getName()); + $className = array_pop($fullName); + } - $class = Extractor::lines($tokens, $method->getStartLine(), $method->getEndLine()); - $method = Extractor::function($class, $method->getName()); - $arg = Extractor::arg($method, $parameter->getName()); + $yanked = Yanker::fetch($fileContents, [$className, $method->getName(), '$' . $parameter->getName()]); - while ( - count($arg) - && (!is_array($arg) || $arg[0][0] !== T_NEW) - ) { - array_shift($arg); - } + $utilize = Utilize::fromTokens( + $yanked->namespace === null ? null : $yanked->namespace[2][1], + $yanked->uses + ); + + $arg = $yanked->args; array_shift($arg); // new array_shift($arg); // (whitespace) diff --git a/tests/Analyzer/AnalyzerTestCase.php b/tests/Analyzer/AnalyzerTestCase.php new file mode 100644 index 0000000..63ed8a0 --- /dev/null +++ b/tests/Analyzer/AnalyzerTestCase.php @@ -0,0 +1,54 @@ +containsStringRecursive($needle, $haystack); + + Assert::assertTrue( + $found, + $message !== '' ? $message : "Failed asserting that array contains token '{$needle}'." + ); + } + + protected function assertDoesntContainTokenAnywhere(string $needle, array $haystack, string $message = ''): void + { + $found = $this->containsStringRecursive($needle, $haystack); + + Assert::assertFalse( + $found, + $message !== '' ? $message : "Failed asserting that array does not contain token '{$needle}'." + ); + } + + private function containsStringRecursive(string $needle, mixed $value): bool + { + if (is_string($value)) { + return $value === $needle; + } + + if (is_array($value)) { + foreach ($value as $item) { + if ($this->containsStringRecursive($needle, $item)) { + return true; + } + } + } + + return false; + } + + protected function implode(array $tokens) + { + $tokens = array_map(fn (string|array $token) => is_array($token) ? $token[1] : $token, $tokens); + return implode('', $tokens); + } +} diff --git a/tests/Analyzer/YankerTest.php b/tests/Analyzer/YankerTest.php new file mode 100644 index 0000000..6478e70 --- /dev/null +++ b/tests/Analyzer/YankerTest.php @@ -0,0 +1,210 @@ +assertContainsTokenAnywhere('Some\Cool\Namespace', $yanked->namespace); + + $this->assertContainsTokenAnywhere('Some\Namespaced\Class', $yanked->uses); + + $this->assertContainsTokenAnywhere('Some\OtherClass\Too', $yanked->uses); + $this->assertContainsTokenAnywhere('SomeAlias', $yanked->uses); + + $this->assertContainsTokenAnywhere('A\Third\Class', $yanked->uses); + $this->assertContainsTokenAnywhere('With\Funky\Syntax', $yanked->uses); + $this->assertContainsTokenAnywhere('WayToMakeYouCry', $yanked->uses); + $this->assertContainsTokenAnywhere('AndMore', $yanked->uses); + + $this->assertDoesntContainTokenAnywhere('ShouldntBeCaptured', $yanked->uses); + + $this->assertContainsTokenAnywhere('Some\Import\In\Wrong\But\Valid\Place', $yanked->uses); + + $this->assertDoesntContainTokenAnywhere('/* Why would you put a comment here? */', $yanked->uses); + $this->assertDoesntContainTokenAnywhere('// There\'s even a comment here', $yanked->uses); + + foreach ($yanked->uses as $import) { + $this->assertEquals(T_USE, $import[0][0]); + $this->assertEquals(';', $import[count($import) - 1]); + } + } + + #[Test] + public function it_extracts_method_args_from_classes(): void + { + $yanked = Yanker::fetch(<<assertEquals([[ + T_CONSTANT_ENCAPSED_STRING, + "'test'", + 5, + ]], $yanked->args); + } + + #[Test] + public function it_extracts_args_not_at_the_start_of_method(): void + { + $yanked = Yanker::fetch(<<assertEquals('new Something(new OtherThing())', $this->implode($yanked->args)); + } + + #[Test] + public function it_extracts_args_not_at_the_start_or_end(): void + { + $yanked = Yanker::fetch(<<assertEquals('new Something(\'class MyClass\')', $this->implode($yanked->args)); + } + + #[Test] + public function it_extracts_args_from_anonymous_classes(): void + { + $yanked = Yanker::fetch(<<assertEquals('new Something(\'class MyClass\')', $this->implode($yanked->args)); + } + + #[Test] + public function it_extracts_args_from_the_correct_anonymous_class_on_single_line(): void + { + $code = 'assertEquals([[ + T_CONSTANT_ENCAPSED_STRING, + '"expected"', + 1, + ]], $yanked->args); + } + + #[Test] + public function it_extracts_args_from_the_correct_anonymous_class_on_separated_lines(): void + { + $yanked = Yanker::fetch(<<assertEquals([[ + T_CONSTANT_ENCAPSED_STRING, + "'expected'", + 13, + ]], $yanked->args); + } +} diff --git a/tests/Components/InstantiatedDefaultArgsAbstractClass.php b/tests/Components/InstantiatedDefaultArgsAbstractClass.php new file mode 100644 index 0000000..6b1fb75 --- /dev/null +++ b/tests/Components/InstantiatedDefaultArgsAbstractClass.php @@ -0,0 +1,14 @@ +assertTrue($mock->{$method}()); } + #[Test] + #[DataProvider('interfaceObjectInstantiationDataProvider')] + public function it_instantiates_objects_when_declared_on_abstract_class(string $method, Closure $validator): void + { + $mock = Mock::interface(InstantiatedDefaultArgsAbstractClass::class); + + Mock::method($mock->{$method}(...))->replace($validator); + + $this->assertTrue($mock->{$method}()); + } + + #[Test] + #[DataProvider('interfaceObjectInstantiationDataProvider')] + public function it_instantiates_objects_when_declared_on_parent_class(string $method, Closure $validator): void + { + $mock = Mock::interface(InstantiatedDefaultArgsExtension::class); + + Mock::method($mock->{$method}(...))->replace($validator); + + $this->assertTrue($mock->{$method}()); + } + public static function interfaceObjectInstantiationDataProvider(): array { return [