diff --git a/docs/architecture/build-manifest.md b/docs/architecture/build-manifest.md index b4fc134c..31d89732 100644 --- a/docs/architecture/build-manifest.md +++ b/docs/architecture/build-manifest.md @@ -43,7 +43,7 @@ One signal: both baseline files are deleted, and the issues named below are clos - [x] **step-23** — `SymbolResolver::resolveCallable` answers every callable-shaped node (`FuncCall`, `MethodCall`, `NullsafeMethodCall`, `StaticCall`, `New_`, `Attribute`) by delegating to `ExpressionResolver`: `FuncCall`, `MethodCall`, `NullsafeMethodCall`, and `StaticCall` go through one `ExpressionResolver::resolve` call, and `New_` and `Attribute` go through one `ExpressionResolver::resolveConstructor` call (the constructor question is separate from the type question `resolve(New_)` answers). The method-call path applies late-bound return-type resolution the same way the static-call path does. Done: `resolveCallable` has no `match`/`switch`/`instanceof` on the call-node kind and no direct `MemberResolver::findMethod` call; hover on `$obj->foo()` where `foo(): static` reports the receiver's class the same way hover on `Foo::bar()` does; a parity test asserts hover-signature agreement across all callable node kinds for `self`/`static`/`parent` return types. - [x] **step-24** — Member lookup in `ExpressionResolver` is one function taking the receiver expression, the member name, and a kind-specific finder; `resolveMethodCall`, `resolveStaticCall`, `resolvePropertyFetch`, and `resolveStaticPropertyFetch` call it. Done: the four methods share one member-lookup helper; adding a fifth member-access node kind is one call site, not four; the existing hover, definition, completion, and signature-help suites remain green. - [x] **step-25** — `ExpressionResolver::docblockForExpression` reads the resolved symbol's docblock through one `resolve(...)?->getDocumentation()` call — no per-kind branch. If the wrapper carries no logic once the branch is gone, delete it and inline the call at every caller. Done: the method either does not exist or is one line with no `match`/`instanceof` on the expression node; `@return list` and `@var` docblock inference works on `FuncCall`, `MethodCall`, `NullsafeMethodCall`, `StaticCall`, `PropertyFetch`, `NullsafePropertyFetch`, `StaticPropertyFetch`, `ClassConstFetch`, and `ConstFetch` the same way it works on `$this->items()`; a test covers each of those node kinds. -- [ ] **step-26** — The three late-binding keywords (`self`, `static`, `parent`) resolve in one place: `Domain\LateBindingKeyword`. Every reader (`ScopeFinder`, `MemberAccessDetector`'s text and AST paths, any other) identifies a keyword through `LateBindingKeyword::tryFrom(strtolower($name))` and resolves it through one function on the enum; the `parent`-of-non-`Class_` guard exists there once. A test under `tests/Architecture/` fails if a string comparison against `'self'`, `'static'`, or `'parent'` appears in `src/` outside `src/Domain/LateBindingKeyword.php` — the tighten that pins the seam. Done: no `src/` file outside the enum compares against the three keyword literals in a class-name-resolution context; a text-path and an AST-path test exercise the same behavior through one code path; the architecture test above is green. +- [x] **step-26** — The three late-binding keywords (`self`, `static`, `parent`) resolve in one place: `Domain\LateBindingKeyword`. Every reader (`ScopeFinder`, `MemberAccessDetector`'s text and AST paths, any other) identifies a keyword through `LateBindingKeyword::tryFrom(strtolower($name))` and resolves it through one function on the enum; the `parent`-of-non-`Class_` guard exists there once. A test under `tests/Architecture/` fails if a string comparison against `'self'`, `'static'`, or `'parent'` appears in `src/` outside `src/Domain/LateBindingKeyword.php` — the tighten that pins the seam. Done: no `src/` file outside the enum compares against the three keyword literals in a class-name-resolution context; a text-path and an AST-path test exercise the same behavior through one code path; the architecture test above is green. - [ ] **step-27** — `ExpressionResolver::resolveMember` (introduced in step-24) iterates every class it gets from the receiver's `Type::getResolvableClassNames()` instead of indexing `[0]`, the same way `SymbolResolver::getAccessibleMembers` iterates. `MemberAccessDetector`'s three instance-receiver sites route through the same helper (or apply the same iteration). Tighten: `disallowedMethodCalls` restricts `Type::getResolvableClassNames()` to the shared helper and to `SymbolResolver::getAccessibleMembers`, so a future direct caller fails PHPStan. Done: no callsite in `src/Resolution/` indexes `[0]` on `getResolvableClassNames()`; hover, definition, and signature-help on `$x->onlyB()` where `$x: A|B` and only `B` declares `onlyB` answer the same way completion offers it; a parity test asserts the four positional handlers and completion agree on union and intersection receivers; the phpstan baseline for the rule reaches zero. - [ ] **step-28** — `resolveConstFetch` iterates `NameContext::candidates(short, NameKind::Constant)` the way `resolveFuncCall` iterates `NameKind::Function_`, so PHP name-resolution rules 5-7 (namespaced-first, global fallback) apply to constants as they do to functions. Tighten: `disallowedMethodCalls` restricts `SymbolSource::lookupConstant` to `src/Resolution/ExpressionResolver.php` (mirroring the #478 pattern for `findMethod`/`findProperty`), so a future direct `lookupConstant` outside the candidate loop fails PHPStan. Done: hover and definition on `X` in `namespace App; const X = 1; echo X;` answer; hover and definition on `PHP_INT_MAX` in a namespaced file with no `use const` answer; `resolveConstFetch` has no direct `lookupConstant` call that bypasses the candidate loop; a test covers both the namespaced-constant and global-fallback paths. - [ ] **step-29** — `SymbolCandidates` reads a symbol's documentation through the `ResolvedSymbol::getDocumentation()` interface method, not by direct `->docblock` field access plus `DocblockParser::extractDescription`. Tighten: `disallowedMethodCalls` restricts `DocblockParser::extractDescription` to `src/Domain/HasSymbolLocation.php`, so a second bypass of the interface fails PHPStan. Done: `SymbolCandidates` does not name `->docblock` or `DocblockParser` directly; a future change to `getDocumentation()` (e.g. tag stripping) reaches completion detail the same way it reaches hover. diff --git a/src/Domain/LateBindingKeyword.php b/src/Domain/LateBindingKeyword.php index 9f8ddfe7..fd22752f 100644 --- a/src/Domain/LateBindingKeyword.php +++ b/src/Domain/LateBindingKeyword.php @@ -4,9 +4,62 @@ namespace Firehed\PhpLsp\Domain; +use PhpParser\Node\Name; +use PhpParser\Node\Stmt; + +/** + * The three PHP keywords that stand in for a class in a class-name position: + * `self`, `static`, and `parent`. Every reader identifies one through + * {@see self::tryFromName()} and resolves it through {@see self::resolveIn()}, + * so no other file in `src/` compares a string against these keywords. + */ enum LateBindingKeyword: string { case Self = 'self'; case Static = 'static'; case Parent = 'parent'; + + /** + * The keyword named by `$name`, matched case-insensitively (PHP is + * case-insensitive for these), or null if `$name` is not one of them. + */ + public static function tryFromName(string $name): ?self + { + return self::tryFrom(NameCase::Insensitive->normalize($name)); + } + + /** + * Resolve this keyword to the concrete class name in the context of an + * enclosing class-like node. + * + * `self`/`static` resolve to the enclosing class-like's name. `parent` + * resolves to the enclosing class's extends target: only `class` may + * extend, so this returns null when the enclosing node is an interface, + * trait, or enum, or when the class has no extends clause. Returns null + * with no enclosing node at all. + * + * @return ?class-string + */ + public function resolveIn(?Stmt\ClassLike $enclosing): ?string + { + if ($enclosing === null) { + return null; + } + if ($this === self::Parent) { + if (!$enclosing instanceof Stmt\Class_ || $enclosing->extends === null) { + return null; + } + $extends = $enclosing->extends; + $resolved = $extends->getAttribute('resolvedName'); + /** @var class-string */ + return $resolved instanceof Name ? $resolved->toString() : $extends->toString(); + } + if ($enclosing->name === null) { + return null; + } + /** @var class-string */ + return isset($enclosing->namespacedName) + ? $enclosing->namespacedName->toString() + : $enclosing->name->toString(); + } } diff --git a/src/Domain/TypeFactory.php b/src/Domain/TypeFactory.php index cb840a0e..99978276 100644 --- a/src/Domain/TypeFactory.php +++ b/src/Domain/TypeFactory.php @@ -154,7 +154,7 @@ private static function tryLateBindingType( ?string $parentContext, bool $preserveLateBinding, ): ?Type { - $keyword = LateBindingKeyword::tryFrom($name); + $keyword = LateBindingKeyword::tryFromName($name); if ($keyword === null) { return null; } diff --git a/src/Repository/DefaultClassInfoFactory.php b/src/Repository/DefaultClassInfoFactory.php index 30503c4a..7d02dcfb 100644 --- a/src/Repository/DefaultClassInfoFactory.php +++ b/src/Repository/DefaultClassInfoFactory.php @@ -13,6 +13,7 @@ use Firehed\PhpLsp\Domain\EnumCaseName; use Firehed\PhpLsp\Domain\EnumImplicits; use Firehed\PhpLsp\Domain\FileUri; +use Firehed\PhpLsp\Domain\LateBindingKeyword; use Firehed\PhpLsp\Domain\MethodInfo; use Firehed\PhpLsp\Domain\MethodName; use Firehed\PhpLsp\Domain\ParameterInfo; @@ -94,14 +95,10 @@ enumCases: $this->extractEnumCasesFromReflection($class, $className), private function resolveClassName(Stmt\ClassLike $node): ClassName { - if ($node->name === null) { + $fqn = LateBindingKeyword::Self->resolveIn($node); + if ($fqn === null) { throw new \InvalidArgumentException('Cannot create ClassInfo for anonymous class'); } - - /** @var class-string */ - $fqn = isset($node->namespacedName) - ? $node->namespacedName->toString() - : $node->name->toString(); return TypeFactory::className($fqn); } diff --git a/src/Resolution/MemberAccessDetector.php b/src/Resolution/MemberAccessDetector.php index fec30dc3..f1d2ced0 100644 --- a/src/Resolution/MemberAccessDetector.php +++ b/src/Resolution/MemberAccessDetector.php @@ -6,6 +6,7 @@ use Firehed\PhpLsp\Document\TextDocument; use Firehed\PhpLsp\Domain\ClassName; +use Firehed\PhpLsp\Domain\LateBindingKeyword; use Firehed\PhpLsp\Domain\NameCase; use Firehed\PhpLsp\Domain\NameKind; use Firehed\PhpLsp\Domain\PrimitiveType; @@ -289,36 +290,33 @@ private function resolveStaticText( int $line, ): ?MemberAccessContext { $className = $match['class']; - $lowerClassName = NameCase::Insensitive->normalize($className); + $keyword = LateBindingKeyword::tryFromName($className); - if ($lowerClassName === 'self' || $lowerClassName === 'static') { - $enclosingClass = $this->textFallback->findEnclosingClass($document, $line); - if ($enclosingClass === null) { + if ($keyword === LateBindingKeyword::Parent) { + $offset = $document->offsetAt($line, 0); + $classLike = Scope::atOffset($ast, $offset)->getEnclosingClassLike(); + $parentClassName = $keyword->resolveIn($classLike); + $enclosingName = LateBindingKeyword::Self->resolveIn($classLike); + if ($parentClassName === null || $enclosingName === null) { return null; } - $target = TypeFactory::className($enclosingClass); - return MemberAccessContext::forStatic( + $target = TypeFactory::className($parentClassName); + return MemberAccessContext::forParent( $target, - $this->visibilityBetween($target, $target), + $this->visibilityBetween(TypeFactory::className($enclosingName), $target), $match['prefix'], ); } - if ($lowerClassName === 'parent') { - $offset = $document->offsetAt($line, 0); - $classLike = Scope::atOffset($ast, $offset)->getEnclosingClassLike(); - if (!$classLike instanceof Stmt\Class_) { - return null; - } - $parentClassName = ScopeFinder::resolveExtendsName($classLike); - $enclosingName = ScopeFinder::getClassLikeName($classLike); - if ($parentClassName === null || $enclosingName === null) { + if ($keyword !== null) { + $enclosingClass = $this->textFallback->findEnclosingClass($document, $line); + if ($enclosingClass === null) { return null; } - $target = TypeFactory::className($parentClassName); - return MemberAccessContext::forParent( + $target = TypeFactory::className($enclosingClass); + return MemberAccessContext::forStatic( $target, - $this->visibilityBetween(TypeFactory::className($enclosingName), $target), + $this->visibilityBetween($target, $target), $match['prefix'], ); } @@ -424,18 +422,16 @@ private function resolveStaticAccessContext( $prefix = $node->name instanceof Identifier ? $node->name->toString() : ''; $rawName = $class->toString(); + $keyword = LateBindingKeyword::tryFromName($rawName); $enclosingClassLike = Scope::atOffset($ast, $offset)->getEnclosingClassLike(); - $enclosingName = $enclosingClassLike !== null - ? ScopeFinder::getClassLikeName($enclosingClassLike) - : null; + $enclosingName = LateBindingKeyword::Self->resolveIn($enclosingClassLike); $vantage = $enclosingName !== null ? TypeFactory::className($enclosingName) : null; - if ($rawName === 'parent') { - if (!$enclosingClassLike instanceof Stmt\Class_ || $enclosingClassLike->extends === null) { + if ($keyword === LateBindingKeyword::Parent) { + $parentClassName = $keyword->resolveIn($enclosingClassLike); + if ($parentClassName === null) { return null; } - $parentClassName = ScopeFinder::resolveExtendsName($enclosingClassLike); - assert($parentClassName !== null); $target = TypeFactory::className($parentClassName); return MemberAccessContext::forParent( $target, diff --git a/src/Utility/Scope.php b/src/Utility/Scope.php index d3bb86bd..0f946d8c 100644 --- a/src/Utility/Scope.php +++ b/src/Utility/Scope.php @@ -5,6 +5,7 @@ namespace Firehed\PhpLsp\Utility; use Firehed\PhpLsp\Domain\ClassName; +use Firehed\PhpLsp\Domain\LateBindingKeyword; use Firehed\PhpLsp\Domain\TypeFactory; use PhpParser\Node; use PhpParser\Node\Expr\ArrowFunction; @@ -64,13 +65,8 @@ public static function forNode( ): self { $enclosingClassLike ??= ScopeFinder::findEnclosingClassNode($node); - $selfContext = $enclosingClassLike !== null - ? ScopeFinder::getClassLikeName($enclosingClassLike) - : null; - - $parentContext = ($enclosingClassLike instanceof Stmt\Class_) - ? ScopeFinder::resolveExtendsName($enclosingClassLike) - : null; + $selfContext = LateBindingKeyword::Self->resolveIn($enclosingClassLike); + $parentContext = LateBindingKeyword::Parent->resolveIn($enclosingClassLike); $thisType = ($node instanceof Stmt\ClassMethod && $selfContext !== null) ? TypeFactory::className($selfContext) diff --git a/src/Utility/ScopeFinder.php b/src/Utility/ScopeFinder.php index a0175734..281f2d54 100644 --- a/src/Utility/ScopeFinder.php +++ b/src/Utility/ScopeFinder.php @@ -4,6 +4,7 @@ namespace Firehed\PhpLsp\Utility; +use Firehed\PhpLsp\Domain\LateBindingKeyword; use PhpParser\Node; use PhpParser\Node\Expr\ArrowFunction; use PhpParser\Node\Expr\Closure; @@ -113,18 +114,9 @@ public static function resolveClassName(Name $name): string */ public static function resolveClassNameInContext(Name $name, Node $contextNode): ?string { - $rawName = $name->toString(); - - if ($rawName === 'self' || $rawName === 'static') { - return self::findEnclosingClassName($contextNode); - } - - if ($rawName === 'parent') { - $enclosingClass = self::findEnclosingClassNode($contextNode); - if (!$enclosingClass instanceof Stmt\Class_) { - return null; - } - return self::resolveExtendsName($enclosingClass); + $keyword = LateBindingKeyword::tryFromName($name->toString()); + if ($keyword !== null) { + return $keyword->resolveIn(self::findEnclosingClassNode($contextNode)); } return self::resolveClassName($name); @@ -137,13 +129,7 @@ public static function resolveClassNameInContext(Name $name, Node $contextNode): */ public static function getClassLikeName(Stmt\Class_|Stmt\Interface_|Stmt\Trait_|Stmt\Enum_ $node): ?string { - if ($node->name === null) { - return null; - } - /** @var class-string */ - return isset($node->namespacedName) - ? $node->namespacedName->toString() - : $node->name->toString(); + return LateBindingKeyword::Self->resolveIn($node); } /** @@ -156,24 +142,7 @@ public static function getClassLikeName(Stmt\Class_|Stmt\Interface_|Stmt\Trait_| */ public static function findEnclosingClassName(Node $node): ?string { - $classNode = self::findEnclosingClassNode($node); - if ($classNode === null) { - return null; - } - return self::getClassLikeName($classNode); - } - - /** - * Resolve the parent class name from a class node's extends clause. - * - * @return ?class-string - */ - public static function resolveExtendsName(Stmt\Class_ $class): ?string - { - if ($class->extends === null) { - return null; - } - return self::resolveClassName($class->extends); + return LateBindingKeyword::Self->resolveIn(self::findEnclosingClassNode($node)); } /** diff --git a/tests/Architecture/LateBindingKeywordConfinementTest.php b/tests/Architecture/LateBindingKeywordConfinementTest.php new file mode 100644 index 00000000..69bf9d05 --- /dev/null +++ b/tests/Architecture/LateBindingKeywordConfinementTest.php @@ -0,0 +1,190 @@ + $keyword) { + $violations[] = "{$relative}:{$line} compares against '{$keyword}'"; + } + } + + self::assertSame( + [], + $violations, + 'compare through LateBindingKeyword::tryFromName() instead of a bare string; ' + . 'the parent-of-non-Class guard lives on resolveIn()', + ); + } + + /** + * A rule that reports nothing is indistinguishable from a rule that scans + * for nothing. This canary drives every comparison form the scanner cares + * about so a regression in the visitor cannot pass unnoticed. + */ + public function testScannerCatchesEveryComparisonForm(): void + { + $canary = self::root() . '/tests/Architecture/data/compares-late-binding-keyword.php'; + + $keywords = []; + foreach (self::comparisonsAgainstKeywords($canary) as $keyword) { + $keywords[] = $keyword; + } + + sort($keywords); + self::assertSame( + ['parent', 'parent', 'parent', 'self', 'self', 'self', 'self', 'static', 'static'], + $keywords, + 'the scanner must catch identity, equality, and their negations, plus match and switch arms', + ); + } + + /** + * @return iterable + */ + private static function sourceFiles(): iterable + { + $root = self::root() . '/src'; + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS), + ); + foreach ($iterator as $entry) { + if ($entry instanceof \SplFileInfo && $entry->isFile() && $entry->getExtension() === 'php') { + yield $entry->getPathname(); + } + } + } + + /** + * Every line in `$file` that compares a string literal against one of the + * three keywords, mapped to the keyword's value. + * + * @return iterable + */ + private static function comparisonsAgainstKeywords(string $file): iterable + { + $content = file_get_contents($file); + self::assertIsString($content, "unable to read {$file}"); + + $parser = (new ParserFactory())->createForNewestSupportedVersion(); + $ast = $parser->parse($content) ?? []; + + $visitor = new class extends NodeVisitorAbstract { + /** @var list */ + public array $hits = []; + + public function enterNode(Node $node): ?int + { + if ($this->isEquality($node)) { + /** @var BinaryOp $node */ + $this->recordIfKeyword($node->left, $node->getStartLine()); + $this->recordIfKeyword($node->right, $node->getStartLine()); + return null; + } + if ($node instanceof Match_) { + foreach ($node->arms as $arm) { + if ($arm->conds === null) { + continue; + } + foreach ($arm->conds as $cond) { + $this->recordIfKeyword($cond, $cond->getStartLine()); + } + } + return null; + } + if ($node instanceof Switch_) { + foreach ($node->cases as $case) { + if ($case->cond === null) { + continue; + } + $this->recordIfKeyword($case->cond, $case->getStartLine()); + } + } + return null; + } + + private function isEquality(Node $node): bool + { + return $node instanceof BinaryOp\Identical + || $node instanceof BinaryOp\NotIdentical + || $node instanceof BinaryOp\Equal + || $node instanceof BinaryOp\NotEqual; + } + + private function recordIfKeyword(Expr $expr, int $line): void + { + if (!$expr instanceof String_) { + return; + } + $value = strtolower($expr->value); + if (in_array($value, LateBindingKeywordConfinementTest::KEYWORDS, true)) { + $this->hits[] = [$line, $value]; + } + } + }; + + $traverser = new NodeTraverser(); + $traverser->addVisitor($visitor); + $traverser->traverse($ast); + + foreach ($visitor->hits as [$line, $keyword]) { + yield $line => $keyword; + } + } + + private static function relativePath(string $file): string + { + $root = self::root() . '/'; + if (str_starts_with($file, $root)) { + return substr($file, strlen($root)); + } + // @codeCoverageIgnoreStart + return $file; + // @codeCoverageIgnoreEnd + } + + private static function root(): string + { + return dirname(__DIR__, 2); + } +} diff --git a/tests/Architecture/data/compares-late-binding-keyword.php b/tests/Architecture/data/compares-late-binding-keyword.php new file mode 100644 index 00000000..03e405ab --- /dev/null +++ b/tests/Architecture/data/compares-late-binding-keyword.php @@ -0,0 +1,56 @@ + 1, + 'static', 'parent' => 2, + default => 0, + }; + } + + public function switchOnKeyword(string $n): int + { + switch ($n) { + case 'self': + return 1; + case 'parent': + return 2; + default: + return 0; + } + } +} diff --git a/tests/Domain/LateBindingKeywordTest.php b/tests/Domain/LateBindingKeywordTest.php new file mode 100644 index 00000000..eff22238 --- /dev/null +++ b/tests/Domain/LateBindingKeywordTest.php @@ -0,0 +1,136 @@ + + * @codeCoverageIgnore data provider runs before coverage begins + */ + public static function names(): iterable + { + yield 'self lower' => ['self', LateBindingKeyword::Self]; + yield 'Self mixed' => ['Self', LateBindingKeyword::Self]; + yield 'SELF upper' => ['SELF', LateBindingKeyword::Self]; + yield 'static' => ['static', LateBindingKeyword::Static]; + yield 'Static' => ['Static', LateBindingKeyword::Static]; + yield 'parent' => ['parent', LateBindingKeyword::Parent]; + yield 'PARENT' => ['PARENT', LateBindingKeyword::Parent]; + yield 'other' => ['User', null]; + yield 'empty' => ['', null]; + yield 'ns-qualified self' => ['App\\self', null]; + } + + #[DataProvider('names')] + public function testTryFromNameIsCaseInsensitive(string $name, ?LateBindingKeyword $expected): void + { + self::assertSame( + $expected, + LateBindingKeyword::tryFromName($name), + 'the three keywords resolve regardless of source case; anything else is not a keyword', + ); + } + + public function testResolveInReturnsNullWithoutEnclosingClassLike(): void + { + self::assertNull( + LateBindingKeyword::Self->resolveIn(null), + 'no enclosing class means no self', + ); + self::assertNull( + LateBindingKeyword::Static->resolveIn(null), + 'no enclosing class means no static', + ); + self::assertNull( + LateBindingKeyword::Parent->resolveIn(null), + 'no enclosing class means no parent', + ); + } + + public function testResolveInSelfReturnsEnclosingClassName(): void + { + $class = new Stmt\Class_('Foo'); + $class->namespacedName = new Name('App\\Foo'); + + self::assertSame('App\\Foo', LateBindingKeyword::Self->resolveIn($class)); + self::assertSame('App\\Foo', LateBindingKeyword::Static->resolveIn($class)); + } + + public function testResolveInSelfFallsBackToShortNameWithoutNamespaceName(): void + { + $class = new Stmt\Class_('Foo'); + + self::assertSame('Foo', LateBindingKeyword::Self->resolveIn($class)); + } + + public function testResolveInSelfReturnsNullForAnonymousClass(): void + { + $class = new Stmt\Class_(null); + + self::assertNull(LateBindingKeyword::Self->resolveIn($class)); + } + + public function testResolveInParentReadsExtendsResolvedName(): void + { + $extends = new Name('Base'); + $extends->setAttribute('resolvedName', new Name('App\\Base')); + $class = new Stmt\Class_('Foo', ['extends' => $extends]); + + self::assertSame('App\\Base', LateBindingKeyword::Parent->resolveIn($class)); + } + + public function testResolveInParentFallsBackToRawExtendsName(): void + { + $class = new Stmt\Class_('Foo', ['extends' => new Name('App\\Base')]); + + self::assertSame('App\\Base', LateBindingKeyword::Parent->resolveIn($class)); + } + + public function testResolveInParentReturnsNullWithoutExtends(): void + { + $class = new Stmt\Class_('Foo'); + + self::assertNull( + LateBindingKeyword::Parent->resolveIn($class), + 'a class with no extends clause has no parent', + ); + } + + public function testResolveInParentReturnsNullForInterface(): void + { + $interface = new Stmt\Interface_('Bar'); + $interface->namespacedName = new Name('App\\Bar'); + + self::assertNull( + LateBindingKeyword::Parent->resolveIn($interface), + 'interfaces cannot use parent for class resolution', + ); + } + + public function testResolveInParentReturnsNullForTrait(): void + { + $trait = new Stmt\Trait_('T'); + $trait->namespacedName = new Name('App\\T'); + + self::assertNull(LateBindingKeyword::Parent->resolveIn($trait)); + } + + public function testResolveInParentReturnsNullForEnum(): void + { + $enum = new Stmt\Enum_('E'); + $enum->namespacedName = new Name('App\\E'); + + self::assertNull(LateBindingKeyword::Parent->resolveIn($enum)); + } +} diff --git a/tests/Utility/ScopeFinderTest.php b/tests/Utility/ScopeFinderTest.php index 55ae268e..81b4be3a 100644 --- a/tests/Utility/ScopeFinderTest.php +++ b/tests/Utility/ScopeFinderTest.php @@ -208,40 +208,6 @@ public function testFindEnclosingClassNameReturnsNullForAnonymousClass(): void self::assertNull($className); } - public function testResolveExtendsNameReturnsNullWhenNoExtends(): void - { - $code = $this->loadFixture('src/Utility/ScopePatterns.php'); - $ast = self::parseWithParents($code); - $namespace = $ast[1]; - self::assertInstanceOf(Stmt\Namespace_::class, $namespace); - $class = self::findFirstClassLike($namespace->stmts, Stmt\Class_::class); - self::assertNotNull($class); - - self::assertNull(ScopeFinder::resolveExtendsName($class)); - } - - public function testResolveExtendsNameReturnsParentName(): void - { - $code = $this->loadFixture('Inheritance/NoNamespaceChild.php'); - $ast = self::parseWithParents($code); - $class = self::findFirstClassLike($ast, Stmt\Class_::class); - self::assertNotNull($class); - - self::assertSame('NoNamespaceParent', ScopeFinder::resolveExtendsName($class)); - } - - public function testResolveExtendsNameUsesResolvedNameWhenAvailable(): void - { - $code = $this->loadFixture('src/Utility/ImportedExtends.php'); - $ast = self::parseWithParents($code); - $namespace = $ast[1]; - self::assertInstanceOf(Stmt\Namespace_::class, $namespace); - $class = self::findFirstClassLike($namespace->stmts, Stmt\Class_::class); - self::assertNotNull($class); - - self::assertSame('Fixtures\Inheritance\ParentClass', ScopeFinder::resolveExtendsName($class)); - } - public function testResolveNameReturnsRawNameWhenNoResolvedAttribute(): void { $code = $this->loadFixture('src/Inheritance/ParentClass.php');