From 6b3854ad2563843c5ecef9692f90f777f55257fd Mon Sep 17 00:00:00 2001 From: "Barry vd. Heuvel" Date: Mon, 30 Dec 2024 11:31:22 +0100 Subject: [PATCH] Add ContextFactory (#25) --- composer.json | 2 +- .../Reflection/DocBlock/ContextFactory.php | 422 ++++++++++++++++++ .../DocBlock/ContextFactoryTest.php | 227 ++++++++++ 3 files changed, 650 insertions(+), 1 deletion(-) create mode 100644 src/Barryvdh/Reflection/DocBlock/ContextFactory.php create mode 100644 tests/Barryvdh/Reflection/DocBlock/ContextFactoryTest.php diff --git a/composer.json b/composer.json index beb4db9..270e557 100644 --- a/composer.json +++ b/composer.json @@ -6,7 +6,7 @@ {"name": "Mike van Riel", "email": "mike.vanriel@naenius.com"} ], "require": { - "php": ">=5.3.3" + "php": ">=7.1" }, "autoload": { "psr-0": {"Barryvdh": ["src/"]} diff --git a/src/Barryvdh/Reflection/DocBlock/ContextFactory.php b/src/Barryvdh/Reflection/DocBlock/ContextFactory.php new file mode 100644 index 0000000..9f0b902 --- /dev/null +++ b/src/Barryvdh/Reflection/DocBlock/ContextFactory.php @@ -0,0 +1,422 @@ + $reflector */ + + return $this->createFromReflectionClass($reflector); + } + + if ($reflector instanceof ReflectionParameter) { + return $this->createFromReflectionParameter($reflector); + } + + if ($reflector instanceof ReflectionMethod) { + return $this->createFromReflectionMethod($reflector); + } + + if ($reflector instanceof ReflectionProperty) { + return $this->createFromReflectionProperty($reflector); + } + + if ($reflector instanceof ReflectionClassConstant) { + return $this->createFromReflectionClassConstant($reflector); + } + + throw new UnexpectedValueException('Unhandled \Reflector instance given: ' . get_class($reflector)); + } + + private function createFromReflectionParameter(ReflectionParameter $parameter): Context + { + $class = $parameter->getDeclaringClass(); + if (!$class) { + throw new InvalidArgumentException('Unable to get class of ' . $parameter->getName()); + } + + return $this->createFromReflectionClass($class); + } + + private function createFromReflectionMethod(ReflectionMethod $method): Context + { + $class = $method->getDeclaringClass(); + + return $this->createFromReflectionClass($class); + } + + private function createFromReflectionProperty(ReflectionProperty $property): Context + { + $class = $property->getDeclaringClass(); + + return $this->createFromReflectionClass($class); + } + + private function createFromReflectionClassConstant(ReflectionClassConstant $constant): Context + { + //phpcs:ignore SlevomatCodingStandard.Commenting.InlineDocCommentDeclaration.MissingVariable + /** @phpstan-var ReflectionClass $class */ + $class = $constant->getDeclaringClass(); + + return $this->createFromReflectionClass($class); + } + + /** + * @phpstan-param ReflectionClass $class + */ + private function createFromReflectionClass(ReflectionClass $class): Context + { + $fileName = $class->getFileName(); + $namespace = $class->getNamespaceName(); + + if (is_string($fileName) && file_exists($fileName)) { + $contents = file_get_contents($fileName); + if ($contents === false) { + throw new RuntimeException('Unable to read file "' . $fileName . '"'); + } + + return $this->createForNamespace($namespace, $contents); + } + + return new Context($namespace, []); + } + + /** + * Build a Context for a namespace in the provided file contents. + * + * @see Context for more information on Contexts. + * + * @param string $namespace It does not matter if a `\` precedes the namespace name, + * this method first normalizes. + * @param string $fileContents The file's contents to retrieve the aliases from with the given namespace. + */ + public function createForNamespace(string $namespace, string $fileContents): Context + { + $namespace = trim($namespace, '\\'); + $useStatements = []; + $currentNamespace = ''; + $tokens = new ArrayIterator(token_get_all($fileContents)); + + while ($tokens->valid()) { + $currentToken = $tokens->current(); + switch ($currentToken[0]) { + case T_NAMESPACE: + $currentNamespace = $this->parseNamespace($tokens); + break; + case T_CLASS: + case T_TRAIT: + // Fast-forward the iterator through the class so that any + // T_USE tokens found within are skipped - these are not + // valid namespace use statements so should be ignored. + $braceLevel = 0; + $firstBraceFound = false; + while ($tokens->valid() && ($braceLevel > 0 || !$firstBraceFound)) { + $currentToken = $tokens->current(); + if ( + $currentToken === '{' + || in_array($currentToken[0], [T_CURLY_OPEN, T_DOLLAR_OPEN_CURLY_BRACES], true) + ) { + if (!$firstBraceFound) { + $firstBraceFound = true; + } + + ++$braceLevel; + } + + if ($currentToken === '}') { + --$braceLevel; + } + + $tokens->next(); + } + + break; + case T_USE: + if ($currentNamespace === $namespace) { + $useStatements += $this->parseUseStatement($tokens); + } + + break; + } + + $tokens->next(); + } + + return new Context($namespace, $useStatements); + } + + /** + * Deduce the name from tokens when we are at the T_NAMESPACE token. + * + * @param ArrayIterator $tokens + */ + private function parseNamespace(ArrayIterator $tokens): string + { + // skip to the first string or namespace separator + $this->skipToNextStringOrNamespaceSeparator($tokens); + + $name = ''; + $acceptedTokens = [T_STRING, T_NS_SEPARATOR, T_NAME_QUALIFIED]; + while ($tokens->valid() && in_array($tokens->current()[0], $acceptedTokens, true)) { + $name .= $tokens->current()[1]; + $tokens->next(); + } + + return $name; + } + + /** + * Deduce the names of all imports when we are at the T_USE token. + * + * @param ArrayIterator $tokens + * + * @return string[] + * @psalm-return array + */ + private function parseUseStatement(ArrayIterator $tokens): array + { + $uses = []; + + while ($tokens->valid()) { + $this->skipToNextStringOrNamespaceSeparator($tokens); + + $uses += $this->extractUseStatements($tokens); + $currentToken = $tokens->current(); + if ($currentToken[0] === self::T_LITERAL_END_OF_USE) { + return $uses; + } + } + + return $uses; + } + + /** + * Fast-forwards the iterator as longs as we don't encounter a T_STRING or T_NS_SEPARATOR token. + * + * @param ArrayIterator $tokens + */ + private function skipToNextStringOrNamespaceSeparator(ArrayIterator $tokens): void + { + while ($tokens->valid()) { + $currentToken = $tokens->current(); + if (in_array($currentToken[0], [T_STRING, T_NS_SEPARATOR], true)) { + break; + } + + if ($currentToken[0] === T_NAME_QUALIFIED) { + break; + } + + if (defined('T_NAME_FULLY_QUALIFIED') && $currentToken[0] === T_NAME_FULLY_QUALIFIED) { + break; + } + + $tokens->next(); + } + } + + /** + * Deduce the namespace name and alias of an import when we are at the T_USE token or have not reached the end of + * a USE statement yet. This will return a key/value array of the alias => namespace. + * + * @param ArrayIterator $tokens + * + * @return string[] + * @psalm-return array + * + * @psalm-suppress TypeDoesNotContainType + */ + private function extractUseStatements(ArrayIterator $tokens): array + { + $extractedUseStatements = []; + $groupedNs = ''; + $currentNs = ''; + $currentAlias = ''; + $state = 'start'; + + while ($tokens->valid()) { + $currentToken = $tokens->current(); + $tokenId = is_string($currentToken) ? $currentToken : $currentToken[0]; + $tokenValue = is_string($currentToken) ? null : $currentToken[1]; + switch ($state) { + case 'start': + switch ($tokenId) { + case T_STRING: + case T_NS_SEPARATOR: + $currentNs .= (string) $tokenValue; + $currentAlias = $tokenValue; + break; + case T_NAME_QUALIFIED: + case T_NAME_FULLY_QUALIFIED: + $currentNs .= (string) $tokenValue; + $currentAlias = substr( + (string) $tokenValue, + (int) (strrpos((string) $tokenValue, '\\')) + 1 + ); + break; + case T_CURLY_OPEN: + case '{': + $state = 'grouped'; + $groupedNs = $currentNs; + break; + case T_AS: + $state = 'start-alias'; + break; + case self::T_LITERAL_USE_SEPARATOR: + case self::T_LITERAL_END_OF_USE: + $state = 'end'; + break; + default: + break; + } + + break; + case 'start-alias': + switch ($tokenId) { + case T_STRING: + $currentAlias = $tokenValue; + break; + case self::T_LITERAL_USE_SEPARATOR: + case self::T_LITERAL_END_OF_USE: + $state = 'end'; + break; + default: + break; + } + + break; + case 'grouped': + switch ($tokenId) { + case T_STRING: + case T_NS_SEPARATOR: + $currentNs .= (string) $tokenValue; + $currentAlias = $tokenValue; + break; + case T_AS: + $state = 'grouped-alias'; + break; + case self::T_LITERAL_USE_SEPARATOR: + $state = 'grouped'; + $extractedUseStatements[(string) $currentAlias] = $currentNs; + $currentNs = $groupedNs; + $currentAlias = ''; + break; + case self::T_LITERAL_END_OF_USE: + $state = 'end'; + break; + default: + break; + } + + break; + case 'grouped-alias': + switch ($tokenId) { + case T_STRING: + $currentAlias = $tokenValue; + break; + case self::T_LITERAL_USE_SEPARATOR: + $state = 'grouped'; + $extractedUseStatements[(string) $currentAlias] = $currentNs; + $currentNs = $groupedNs; + $currentAlias = ''; + break; + case self::T_LITERAL_END_OF_USE: + $state = 'end'; + break; + default: + break; + } + } + + if ($state === 'end') { + break; + } + + $tokens->next(); + } + + if ($groupedNs !== $currentNs) { + $extractedUseStatements[(string) $currentAlias] = $currentNs; + } + + return $extractedUseStatements; + } +} diff --git a/tests/Barryvdh/Reflection/DocBlock/ContextFactoryTest.php b/tests/Barryvdh/Reflection/DocBlock/ContextFactoryTest.php new file mode 100644 index 0000000..3b6065f --- /dev/null +++ b/tests/Barryvdh/Reflection/DocBlock/ContextFactoryTest.php @@ -0,0 +1,227 @@ + + */ +class ContextFactoryTest extends TestCase +{ + /** + * @covers ::createFromReflector + * @covers ::createForNamespace + * @uses Barryvdh\Reflection\DocBlock\Context + */ + public function testReadsNamespaceFromClassReflection(): void + { + $fixture = new ContextFactory(); + $context = $fixture->createFromReflector(new ReflectionClass($this)); + + $this->assertSame(__NAMESPACE__, $context->getNamespace()); + } + + /** + * @covers ::createFromReflector + * @covers ::createForNamespace + * @uses Barryvdh\Reflection\DocBlock\Context + */ + public function testReadsAliasesFromClassReflection(): void + { + $fixture = new ContextFactory(); + $context = $fixture->createFromReflector(new ReflectionClass($this)); + + $this->assertNamespaceAliasesFrom($context); + } + + /** + * @covers ::createForNamespace + * @uses Barryvdh\Reflection\DocBlock\Context + */ + public function testReadsNamespaceFromProvidedNamespaceAndContent(): void + { + $fixture = new ContextFactory(); + $context = $fixture->createForNamespace(__NAMESPACE__, file_get_contents(__FILE__)); + + $this->assertSame(__NAMESPACE__, $context->getNamespace()); + } + + /** + * @covers ::createForNamespace + * @uses Barryvdh\Reflection\DocBlock\Context + */ + public function testReadsAliasesFromProvidedNamespaceAndContent(): void + { + $fixture = new ContextFactory(); + $context = $fixture->createForNamespace(__NAMESPACE__, file_get_contents(__FILE__)); + + $this->assertNamespaceAliasesFrom($context); + } + + /** + * @covers ::createForNamespace + * @uses Barryvdh\Reflection\DocBlock\Context + */ + public function testTraitUseIsNotDetectedAsNamespaceUse(): void + { + $php = 'createForNamespace('Foo', $php); + + $this->assertSame([], $context->getNamespaceAliases()); + } + + /** + * @covers ::createForNamespace + * @uses Barryvdh\Reflection\DocBlock\Context + */ + public function testAllOpeningBracesAreCheckedWhenSearchingForEndOfClass(): void + { + $php = 'createForNamespace('Foo', $php); + + $this->assertSame([], $context->getNamespaceAliases()); + } + + /** + * @covers ::createForNamespace + * @uses Barryvdh\Reflection\DocBlock\Context + */ + public function testTraitContainsClosureWithUseStatement(): void + { + $php = 'createForNamespace('Foo', $php); + + $this->assertSame([], $context->getNamespaceAliases()); + } + + /** + * @covers ::createFromReflector + */ + public function testEmptyFileName(): void + { + $fixture = new ContextFactory(); + $context = $fixture->createFromReflector(new ReflectionClass(stdClass::class)); + + $this->assertSame([], $context->getNamespaceAliases()); + } + + /** + * @covers ::createFromReflector + */ + public function testEvalDClass(): void + { + eval( + <<createFromReflector(new ReflectionClass('Foo\Bar')); + + $this->assertSame([], $context->getNamespaceAliases()); + } + + public function assertNamespaceAliasesFrom(Context $context) + { + $expected = [ + 'm' => '\\' . m::class, + 'DocBlock' => '\\' . DocBlock::class, + 'Tag' => '\\' . Tag::class, + 'Barryvdh' => '\\' . 'Barryvdh', + 'TestCase' => '\\' . TestCase::class, + 'Assert' => '\\' . Assert::class, + 'e' => '\\' . e::class, + ReflectionClass::class => '\\' . ReflectionClass::class, + \stdClass::class => '\\' . \stdClass::class, + ]; + + $actual = $context->getNamespaceAliases(); + + // sort so that order differences don't break it + asort($expected); + asort($actual); + + $this->assertSame($expected, $actual); + } +}