Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/architecture/build-manifest.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>` 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.
Expand Down
53 changes: 53 additions & 0 deletions src/Domain/LateBindingKeyword.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
2 changes: 1 addition & 1 deletion src/Domain/TypeFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
9 changes: 3 additions & 6 deletions src/Repository/DefaultClassInfoFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}

Expand Down
48 changes: 22 additions & 26 deletions src/Resolution/MemberAccessDetector.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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'],
);
}
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 3 additions & 7 deletions src/Utility/Scope.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand Down
43 changes: 6 additions & 37 deletions src/Utility/ScopeFinder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand All @@ -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);
}

/**
Expand All @@ -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));
}

/**
Expand Down
Loading