diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 3cb893bd..66f23a89 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -40,33 +40,6 @@ parameters: count: 1 path: src/Domain/EnumImplicits.php - - - message: ''' - #^Call to deprecated method fromAstNode\(\) of interface Firehed\\PhpLsp\\Repository\\ClassInfoFactory\: - sweep\: source\-context factory$# - ''' - identifier: method.deprecated - count: 1 - path: src/Knowledge/DeclarationSymbolInfoFactory.php - - - - message: ''' - #^Call to deprecated method fromGlobalDeclaration\(\) of class Firehed\\PhpLsp\\Domain\\ConstantInfo\: - sweep\: source\-context factory$# - ''' - identifier: staticMethod.deprecated - count: 1 - path: src/Knowledge/DeclarationSymbolInfoFactory.php - - - - message: ''' - #^Call to deprecated method fromNode\(\) of class Firehed\\PhpLsp\\Domain\\FunctionInfo\: - sweep\: source\-context factory$# - ''' - identifier: staticMethod.deprecated - count: 1 - path: src/Knowledge/DeclarationSymbolInfoFactory.php - - message: ''' #^Call to deprecated method fromReflection\(\) of class Firehed\\PhpLsp\\Domain\\FunctionInfo\: @@ -74,64 +47,47 @@ parameters: ''' identifier: staticMethod.deprecated count: 1 - path: src/Knowledge/ReflectionSymbolInfoFactory.php - - - - message: ''' - #^Call to deprecated method fromReflection\(\) of interface Firehed\\PhpLsp\\Repository\\ClassInfoFactory\: - sweep\: source\-context factory$# - ''' - identifier: method.deprecated - count: 1 - path: src/Knowledge/ReflectionSymbolInfoFactory.php + path: src/Knowledge/BuiltinBackend.php - message: ''' - #^Call to deprecated method fromNode\(\) of class Firehed\\PhpLsp\\Domain\\ParameterInfo\: - sweep\: source\-context factory$# - ''' - identifier: staticMethod.deprecated - count: 1 - path: src/Repository/DefaultClassInfoFactory.php - - - - message: ''' - #^Call to deprecated method fromReflection\(\) of class Firehed\\PhpLsp\\Domain\\ParameterInfo\: - sweep\: source\-context factory$# + #^Call to method className\(\) of deprecated class Firehed\\PhpLsp\\Domain\\TypeFactory\: + sweep\: source\-context factory; resolution should go through + TypeSource, construction primitives will migrate elsewhere$# ''' - identifier: staticMethod.deprecated - count: 1 - path: src/Repository/DefaultClassInfoFactory.php + identifier: staticMethod.deprecatedClass + count: 3 + path: src/Knowledge/BuiltinBackend.php - message: ''' - #^Call to method className\(\) of deprecated class Firehed\\PhpLsp\\Domain\\TypeFactory\: + #^Call to method fromReflection\(\) of deprecated class Firehed\\PhpLsp\\Domain\\TypeFactory\: sweep\: source\-context factory; resolution should go through TypeSource, construction primitives will migrate elsewhere$# ''' identifier: staticMethod.deprecatedClass count: 4 - path: src/Repository/DefaultClassInfoFactory.php + path: src/Knowledge/BuiltinBackend.php - message: ''' - #^Call to method fromNode\(\) of deprecated class Firehed\\PhpLsp\\Domain\\TypeFactory\: + #^Call to method className\(\) of deprecated class Firehed\\PhpLsp\\Domain\\TypeFactory\: sweep\: source\-context factory; resolution should go through TypeSource, construction primitives will migrate elsewhere$# ''' identifier: staticMethod.deprecatedClass - count: 4 - path: src/Repository/DefaultClassInfoFactory.php + count: 2 + path: src/Knowledge/DeclarationSymbolInfoFactory.php - message: ''' - #^Call to method fromReflection\(\) of deprecated class Firehed\\PhpLsp\\Domain\\TypeFactory\: + #^Call to method fromNode\(\) of deprecated class Firehed\\PhpLsp\\Domain\\TypeFactory\: sweep\: source\-context factory; resolution should go through TypeSource, construction primitives will migrate elsewhere$# ''' identifier: staticMethod.deprecatedClass - count: 3 - path: src/Repository/DefaultClassInfoFactory.php + count: 6 + path: src/Knowledge/DeclarationSymbolInfoFactory.php - message: ''' @@ -141,7 +97,7 @@ parameters: ''' identifier: staticMethod.deprecatedClass count: 1 - path: src/Repository/DefaultClassInfoFactory.php + path: src/Knowledge/DeclarationSymbolInfoFactory.php - message: ''' @@ -155,33 +111,33 @@ parameters: - message: ''' - #^Call to method fromNode\(\) of deprecated class Firehed\\PhpLsp\\Domain\\TypeFactory\: + #^Call to method union\(\) of deprecated class Firehed\\PhpLsp\\Domain\\TypeFactory\: sweep\: source\-context factory; resolution should go through TypeSource, construction primitives will migrate elsewhere$# ''' identifier: staticMethod.deprecatedClass count: 1 - path: src/Resolution/ParameterTyping.php + path: src/Resolution/ExpressionResolver.php - message: ''' - #^Call to method union\(\) of deprecated class Firehed\\PhpLsp\\Domain\\TypeFactory\: + #^Call to method className\(\) of deprecated class Firehed\\PhpLsp\\Domain\\TypeFactory\: sweep\: source\-context factory; resolution should go through TypeSource, construction primitives will migrate elsewhere$# ''' identifier: staticMethod.deprecatedClass - count: 1 - path: src/Resolution/ExpressionResolver.php + count: 5 + path: src/Resolution/MemberAccessDetector.php - message: ''' - #^Call to method className\(\) of deprecated class Firehed\\PhpLsp\\Domain\\TypeFactory\: + #^Call to method fromNode\(\) of deprecated class Firehed\\PhpLsp\\Domain\\TypeFactory\: sweep\: source\-context factory; resolution should go through TypeSource, construction primitives will migrate elsewhere$# ''' identifier: staticMethod.deprecatedClass - count: 5 - path: src/Resolution/MemberAccessDetector.php + count: 1 + path: src/Resolution/ParameterTyping.php - message: ''' @@ -299,33 +255,6 @@ parameters: count: 2 path: tests/Domain/TypeFactoryTest.php - - - message: ''' - #^Call to deprecated method fromAstNode\(\) of class Firehed\\PhpLsp\\Repository\\DefaultClassInfoFactory\: - sweep\: source\-context factory$# - ''' - identifier: method.deprecated - count: 37 - path: tests/Repository/DefaultClassInfoFactoryTest.php - - - - message: ''' - #^Call to deprecated method fromReflection\(\) of class Firehed\\PhpLsp\\Repository\\DefaultClassInfoFactory\: - sweep\: source\-context factory$# - ''' - identifier: method.deprecated - count: 15 - path: tests/Repository/DefaultClassInfoFactoryTest.php - - - - message: ''' - #^Call to deprecated method fromAstNode\(\) of class Firehed\\PhpLsp\\Repository\\DefaultClassInfoFactory\: - sweep\: source\-context factory$# - ''' - identifier: method.deprecated - count: 1 - path: tests/Resolution/AstTextAgreementTest.php - - message: ''' #^Call to method className\(\) of deprecated class Firehed\\PhpLsp\\Domain\\TypeFactory\: diff --git a/phpstan.neon b/phpstan.neon index 6933389f..1901a85e 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -36,12 +36,10 @@ parameters: - tests/* - namespace: 'Reflection*' - message: 'runtime reflection is confined: ReflectionNamespaceSource and the fromReflection factories' + message: 'runtime reflection is confined: ReflectionNamespaceSource, BuiltinBackend, and the fromReflection factories' allowIn: - - src/Knowledge/ReflectionSymbolInfoFactory.php + - src/Knowledge/BuiltinBackend.php - src/Index/ReflectionNamespaceSource.php - - src/Repository/ClassInfoFactory.php - - src/Repository/DefaultClassInfoFactory.php - src/Domain/TypeFactory.php - src/Domain/FunctionInfo.php - src/Domain/ParameterInfo.php @@ -156,9 +154,9 @@ parameters: - 'method_exists()' - 'property_exists()' - 'is_callable()' - message: 'asking the runtime whether a symbol exists, or what it holds, is a SymbolSource query (RFC 1 §4.2); ReflectionSymbolInfoFactory is the one place it may be the server''s own runtime' + message: 'asking the runtime whether a symbol exists, or what it holds, is a SymbolSource query (RFC 1 §4.2); BuiltinBackend is the one place it may be the server''s own runtime' allowIn: - - src/Knowledge/ReflectionSymbolInfoFactory.php + - src/Knowledge/BuiltinBackend.php - tests/* - function: diff --git a/src/Knowledge/BuiltinBackend.php b/src/Knowledge/BuiltinBackend.php index 994bb9cf..973d46ae 100644 --- a/src/Knowledge/BuiltinBackend.php +++ b/src/Knowledge/BuiltinBackend.php @@ -4,13 +4,37 @@ namespace Firehed\PhpLsp\Knowledge; +use Attribute; +use BackedEnum; +use Firehed\PhpLsp\Domain\ClassInfo; +use Firehed\PhpLsp\Domain\ClassKind; +use Firehed\PhpLsp\Domain\ClassName; +use Firehed\PhpLsp\Domain\ConstantInfo; +use Firehed\PhpLsp\Domain\ConstantName; +use Firehed\PhpLsp\Domain\EnumCaseInfo; +use Firehed\PhpLsp\Domain\EnumCaseName; +use Firehed\PhpLsp\Domain\FunctionInfo; +use Firehed\PhpLsp\Domain\MethodInfo; +use Firehed\PhpLsp\Domain\MethodName; use Firehed\PhpLsp\Domain\NameKind; +use Firehed\PhpLsp\Domain\ParameterInfo; +use Firehed\PhpLsp\Domain\PropertyInfo; +use Firehed\PhpLsp\Domain\PropertyName; use Firehed\PhpLsp\Domain\QualifiedName; use Firehed\PhpLsp\Domain\SymbolInfo; +use Firehed\PhpLsp\Domain\TypeFactory; +use Firehed\PhpLsp\Domain\Visibility; +use Firehed\PhpLsp\Index\InternalConstantSet; use Firehed\PhpLsp\Index\NamespaceCatalog; use Firehed\PhpLsp\Index\NamespaceContents; use Firehed\PhpLsp\Index\PrefixSearchable; use Firehed\PhpLsp\Index\Symbol; +use ReflectionClass; +use ReflectionException; +use ReflectionFunction; +use ReflectionMethod; +use ReflectionParameter; +use ReflectionProperty; /** * The lowest-precedence {@see SymbolBackend}: the symbols built into PHP and its @@ -27,14 +51,18 @@ * that do not resolve unqualified in the file's namespace, which is auto-import, * a separate concern. Functions and constants are searched through the reflection * enumeration ({@see PrefixSearchable}), which is bounded and already in memory. + * + * Symbol construction is inlined rather than delegated: the sole caller of the + * reflection-to-SymbolInfo build is this backend, so a separate class only + * duplicated the source-picking that {@see CompositeSymbolSource} already owns. */ final class BuiltinBackend implements SymbolBackend { public function __construct( - private readonly ReflectionSymbolInfoFactory $infoFactory, private readonly NamespaceCatalog $namespaces, private readonly SymbolCache $cache, private readonly PrefixSearchable $prefixSearch, + private readonly InternalConstantSet $constants = new InternalConstantSet(), ) { } @@ -48,7 +76,7 @@ public function lookup(QualifiedName $name, NameKind $kind): ?SymbolInfo return $this->cache->remember( $name, $kind, - fn(): ?SymbolInfo => $this->infoFactory->fromReflection($name, $kind), + fn(): ?SymbolInfo => $this->build($name, $kind), ); } @@ -59,4 +87,325 @@ public function search(string $prefix, NameKind $kind): array { return $this->prefixSearch->searchByPrefix($prefix, $kind); } + + private function build(QualifiedName $name, NameKind $kind): ?SymbolInfo + { + return match ($kind) { + NameKind::ClassLike => $this->classInfo($name), + NameKind::Constant => $this->constantInfo($name), + NameKind::Function_ => $this->functionInfo($name), + }; + } + + private function classInfo(QualifiedName $name): ?SymbolInfo + { + $fqn = $name->fullyQualifiedName(); + + // All three probes narrow the name to a `class-string`, which is why + // this kind cannot use the sibling's try/catch. + if (!class_exists($fqn) && !interface_exists($fqn) && !trait_exists($fqn)) { + return null; + } + + $rc = new ReflectionClass($fqn); + if (!$rc->isInternal()) { + return null; + } + + return $this->classInfoFromReflection($rc); + } + + /** + * @template T of object + * @param ReflectionClass $class + */ + private function classInfoFromReflection(ReflectionClass $class): ClassInfo + { + $className = TypeFactory::className($class->getName()); + $parentClass = $class->getParentClass(); + + return new ClassInfo( + name: $className, + kind: $this->determineKindFromReflection($class), + isAbstract: $class->isAbstract() && !$class->isInterface(), + isFinal: $class->isFinal(), + isReadonly: $class->isReadOnly(), + isAttribute: $class->getAttributes(Attribute::class) !== [], + parent: $parentClass !== false + ? TypeFactory::className($parentClass->getName()) + : null, + interfaces: $this->extractInterfaces($class), + // No built-in class uses traits; getTraitNames() would return [] anyway. + traits: [], + methods: $this->extractMethods($class, $className), + properties: $this->extractProperties($class, $className), + constants: $this->extractConstants($class, $className), + enumCases: $this->extractEnumCases($class, $className), + docblock: $class->getDocComment() !== false ? $class->getDocComment() : null, + file: $class->getFileName() !== false ? $class->getFileName() : null, + line: $class->getStartLine() !== false ? $class->getStartLine() : null, + ); + } + + private function constantInfo(QualifiedName $name): ?SymbolInfo + { + $fqn = $name->fullyQualifiedName(); + if (!$this->constants->contains($fqn)) { + return null; + } + + return new ConstantInfo( + name: new ConstantName($name->shortName), + visibility: Visibility::Public, + isFinal: true, + type: null, + docblock: null, + file: null, + line: null, + declaringClass: null, + ); + } + + /** + * @template T of object + * @param ReflectionClass $class + */ + private function determineKindFromReflection(ReflectionClass $class): ClassKind + { + // PHP 8.5 ships no built-in traits, so no Trait_ branch is needed here. + if ($class->isInterface()) { + return ClassKind::Interface_; + } + if ($class->isEnum()) { + return ClassKind::Enum_; + } + return ClassKind::Class_; + } + + /** + * @template T of object + * @param ReflectionClass $class + * @return array + */ + private function extractConstants(ReflectionClass $class, ClassName $className): array + { + $constants = []; + + foreach ($class->getReflectionConstants() as $constant) { + if ($constant->getDeclaringClass()->getName() !== $class->getName()) { + continue; + } + if ($constant->isEnumCase()) { + continue; + } + + $name = $constant->getName(); + $constants[$name] = new ConstantInfo( + name: new ConstantName($name), + // No built-in class ships non-public constants; hard-code Public. + visibility: Visibility::Public, + isFinal: $constant->isFinal(), + type: TypeFactory::fromReflection($constant->getType()), + docblock: $constant->getDocComment() !== false ? $constant->getDocComment() : null, + file: $class->getFileName() !== false ? $class->getFileName() : null, + line: null, + declaringClass: $className, + ); + } + + return $constants; + } + + /** + * @template T of object + * @param ReflectionClass $class + * @return array + */ + private function extractEnumCases(ReflectionClass $class, ClassName $className): array + { + if (!$class->isEnum()) { + return []; + } + + $cases = []; + + foreach ($class->getReflectionConstants() as $constant) { + $name = $constant->getName(); + $enumCase = $constant->getValue(); + $backingValue = $enumCase instanceof BackedEnum ? $enumCase->value : null; + + $cases[$name] = new EnumCaseInfo( + name: new EnumCaseName($name), + backingValue: $backingValue, + docblock: $constant->getDocComment() !== false ? $constant->getDocComment() : null, + file: $class->getFileName() !== false ? $class->getFileName() : null, + line: null, + declaringClass: $className, + ); + } + + return $cases; + } + + /** + * @template T of object + * @param ReflectionClass $class + * @return list + */ + private function extractInterfaces(ReflectionClass $class): array + { + $interfaces = []; + $parent = $class->getParentClass(); + $parentInterfaces = $parent !== false ? $parent->getInterfaceNames() : []; + + foreach ($class->getInterfaceNames() as $interfaceName) { + // Only include directly implemented interfaces, not inherited ones. + if (!in_array($interfaceName, $parentInterfaces, true)) { + $interfaces[] = TypeFactory::className($interfaceName); + } + } + + return $interfaces; + } + + /** + * @template T of object + * @param ReflectionClass $class + * @return array + */ + private function extractMethods(ReflectionClass $class, ClassName $className): array + { + $methods = []; + + foreach ($class->getMethods() as $method) { + if ($method->getDeclaringClass()->getName() !== $class->getName()) { + continue; + } + + $name = $method->getName(); + $methods[$name] = new MethodInfo( + name: new MethodName($name), + visibility: $this->visibilityFromReflectionMethod($method), + isStatic: $method->isStatic(), + isAbstract: $method->isAbstract(), + isFinal: $method->isFinal(), + parameters: $this->extractParameters($method), + returnType: TypeFactory::fromReflection($method->getReturnType()), + docblock: $method->getDocComment() !== false ? $method->getDocComment() : null, + file: $method->getFileName() !== false ? $method->getFileName() : null, + line: $method->getStartLine() !== false ? $method->getStartLine() : null, + declaringClass: $className, + ); + } + + return $methods; + } + + /** + * @return list + */ + private function extractParameters(ReflectionMethod $method): array + { + $params = []; + foreach ($method->getParameters() as $param) { + $params[] = $this->parameterFromReflection($param); + } + return $params; + } + + /** + * @template T of object + * @param ReflectionClass $class + * @return array + */ + private function extractProperties(ReflectionClass $class, ClassName $className): array + { + $properties = []; + + foreach ($class->getProperties() as $property) { + if ($property->getDeclaringClass()->getName() !== $class->getName()) { + continue; + } + + $name = $property->getName(); + $properties[$name] = new PropertyInfo( + name: new PropertyName($name), + visibility: $this->visibilityFromReflectionProperty($property), + isStatic: $property->isStatic(), + isReadonly: $property->isReadOnly(), + isPromoted: $property->isPromoted(), + type: TypeFactory::fromReflection($property->getType()), + docblock: $property->getDocComment() !== false ? $property->getDocComment() : null, + file: $class->getFileName() !== false ? $class->getFileName() : null, + line: null, + declaringClass: $className, + ); + } + + return $properties; + } + + private function functionInfo(QualifiedName $name): ?SymbolInfo + { + try { + $reflection = new ReflectionFunction($name->fullyQualifiedName()); + } catch (ReflectionException) { + return null; + } + + // Reflection also sees the server's own dependencies; enumeration filters + // those out, so lookup must too (RFC 1 §4.2). + return $reflection->isInternal() ? FunctionInfo::fromReflection($reflection) : null; + } + + private function parameterFromReflection(ReflectionParameter $param): ParameterInfo + { + $defaultValue = null; + if ($param->isDefaultValueAvailable()) { + $defaultValue = self::formatReflectionDefault($param->getDefaultValue()); + } + + return new ParameterInfo( + name: $param->getName(), + type: TypeFactory::fromReflection($param->getType()), + hasDefault: $param->isDefaultValueAvailable(), + defaultValue: $defaultValue, + position: $param->getPosition(), + isVariadic: $param->isVariadic(), + isPassedByReference: $param->isPassedByReference(), + ); + } + + private static function formatReflectionDefault(mixed $value): string + { + if ($value === null) { + return 'null'; + } + if ($value === []) { + return '[]'; + } + return var_export($value, true); + } + + private function visibilityFromReflectionMethod(ReflectionMethod $method): Visibility + { + if ($method->isPrivate()) { + return Visibility::Private; + } + if ($method->isProtected()) { + return Visibility::Protected; + } + return Visibility::Public; + } + + private function visibilityFromReflectionProperty(ReflectionProperty $property): Visibility + { + if ($property->isPrivate()) { + return Visibility::Private; + } + if ($property->isProtected()) { + return Visibility::Protected; + } + return Visibility::Public; + } } diff --git a/src/Knowledge/DeclarationSymbolInfoFactory.php b/src/Knowledge/DeclarationSymbolInfoFactory.php index 1d5748ce..eaefb19d 100644 --- a/src/Knowledge/DeclarationSymbolInfoFactory.php +++ b/src/Knowledge/DeclarationSymbolInfoFactory.php @@ -4,14 +4,40 @@ namespace Firehed\PhpLsp\Knowledge; +use Attribute; +use Firehed\PhpLsp\Domain\ClassInfo; +use Firehed\PhpLsp\Domain\ClassKind; +use Firehed\PhpLsp\Domain\ClassName; use Firehed\PhpLsp\Domain\ConstantInfo; +use Firehed\PhpLsp\Domain\ConstantName; use Firehed\PhpLsp\Domain\DeclaredSymbol; +use Firehed\PhpLsp\Domain\EnumCaseInfo; +use Firehed\PhpLsp\Domain\EnumCaseName; +use Firehed\PhpLsp\Domain\EnumImplicits; use Firehed\PhpLsp\Domain\FileUri; use Firehed\PhpLsp\Domain\FunctionInfo; +use Firehed\PhpLsp\Domain\LateBindingKeyword; +use Firehed\PhpLsp\Domain\MethodInfo; +use Firehed\PhpLsp\Domain\MethodName; use Firehed\PhpLsp\Domain\NameKind; +use Firehed\PhpLsp\Domain\ParameterInfo; +use Firehed\PhpLsp\Domain\PrimitiveType; +use Firehed\PhpLsp\Domain\PropertyInfo; +use Firehed\PhpLsp\Domain\PropertyName; use Firehed\PhpLsp\Domain\QualifiedName; use Firehed\PhpLsp\Domain\SymbolInfo; -use Firehed\PhpLsp\Repository\ClassInfoFactory; +use Firehed\PhpLsp\Domain\TraitAlias; +use Firehed\PhpLsp\Domain\TypeFactory; +use Firehed\PhpLsp\Domain\Visibility; +use InvalidArgumentException; +use PhpParser\Modifiers; +use PhpParser\Node; +use PhpParser\Node\Expr; +use PhpParser\Node\Expr\Variable; +use PhpParser\Node\Param; +use PhpParser\Node\Scalar; +use PhpParser\Node\Stmt; +use PhpParser\PrettyPrinter\Standard as PrettyPrinter; /** * The one place a {@see NameKind} picks a declaration list and a builder, which is @@ -25,11 +51,6 @@ */ final readonly class DeclarationSymbolInfoFactory { - public function __construct( - private ClassInfoFactory $classes, - ) { - } - /** * Every symbol the file declares, at any depth. Of duplicates the first wins — * the one PHP would define. @@ -42,15 +63,19 @@ public function allIn(FileDeclarations $declarations, string $filePath): array $seen = []; foreach ($declarations->classLikes as $declaration) { - $info = $this->classes->fromAstNode($declaration->node, FileUri::fromPath($filePath)); + $info = $this->classInfoFromNode($declaration->node, FileUri::fromPath($filePath)); self::collect($symbols, $seen, $declaration->name, NameKind::ClassLike, $info); } foreach ($declarations->functions as $declaration) { - $info = FunctionInfo::fromNode($declaration->node, $filePath); + $info = $this->functionInfoFromNode($declaration->node, $filePath); self::collect($symbols, $seen, $declaration->name, NameKind::Function_, $info); } foreach ($declarations->constants as $declaration) { - $info = ConstantInfo::fromGlobalDeclaration($declaration->node, $declaration->name->shortName, $filePath); + $info = $this->constantInfoFromGlobalDeclaration( + $declaration->node, + $declaration->name->shortName, + $filePath, + ); self::collect($symbols, $seen, $declaration->name, NameKind::Constant, $info); } @@ -74,6 +99,452 @@ public function fromDeclarations( return null; } + private function classInfoFromNode(Stmt\ClassLike $node, string $uri): ClassInfo + { + $className = $this->resolveClassName($node); + $filePath = FileUri::toPath($uri); + $traitUse = $this->extractTraitUse($node); + + return new ClassInfo( + name: $className, + kind: $this->determineKind($node), + isAbstract: $node instanceof Stmt\Class_ && $node->isAbstract(), + isFinal: $node instanceof Stmt\Class_ && $node->isFinal(), + isReadonly: $node instanceof Stmt\Class_ && $node->isReadonly(), + isAttribute: $this->isAttributeNode($node), + parent: $this->resolveParent($node), + interfaces: $this->extractInterfaces($node), + traits: $traitUse['traits'], + methods: $this->extractMethods($node, $className, $filePath), + properties: $this->extractProperties($node, $className, $filePath), + constants: $this->extractConstants($node, $className, $filePath), + enumCases: $this->extractEnumCases($node, $className, $filePath), + docblock: $node->getDocComment()?->getText(), + file: $filePath, + line: $node->getStartLine(), + traitExclusions: $traitUse['exclusions'], + traitAliases: $traitUse['aliases'], + ); + } + + /** + * @param Node\Const_|Expr\FuncCall $node + */ + private function constantInfoFromGlobalDeclaration( + Node\Const_|Expr\FuncCall $node, + string $shortName, + string $filePath, + ): ConstantInfo { + // php-parser attaches a doc comment to the outer statement — `Stmt\Const_` + // for a `const` declarator, `Stmt\Expression` for a `define()` call — so a + // declarator or expression asked directly for its comment reads null. + // Consult the parent first, then the node itself. + $parent = $node->getAttribute('parent'); + $doc = ($parent instanceof Node ? $parent->getDocComment() : null) ?? $node->getDocComment(); + + return new ConstantInfo( + name: new ConstantName($shortName), + visibility: Visibility::Public, + isFinal: true, + type: null, + docblock: $doc?->getText(), + file: $filePath, + line: $node->getStartLine(), + declaringClass: null, + ); + } + + private function determineKind(Stmt\ClassLike $node): ClassKind + { + return match (true) { + $node instanceof Stmt\Interface_ => ClassKind::Interface_, + $node instanceof Stmt\Trait_ => ClassKind::Trait_, + $node instanceof Stmt\Enum_ => ClassKind::Enum_, + default => ClassKind::Class_, + }; + } + + /** + * @return array + */ + private function extractConstants(Stmt\ClassLike $node, ClassName $className, string $filePath): array + { + $constants = []; + $parentClass = $this->resolveParent($node); + + foreach ($node->stmts as $stmt) { + if (!$stmt instanceof Stmt\ClassConst) { + continue; + } + + foreach ($stmt->consts as $const) { + $name = $const->name->toString(); + $constants[$name] = new ConstantInfo( + name: new ConstantName($name), + visibility: $this->visibilityFromFlags($stmt->flags), + isFinal: $stmt->isFinal(), + type: TypeFactory::fromNode($stmt->type, $className->fqn, $parentClass?->fqn), + docblock: $stmt->getDocComment()?->getText(), + file: $filePath, + line: $stmt->getStartLine(), + declaringClass: $className, + ); + } + } + + return $constants; + } + + /** + * @return array + */ + private function extractEnumCases(Stmt\ClassLike $node, ClassName $className, string $filePath): array + { + if (!$node instanceof Stmt\Enum_) { + return []; + } + + $cases = []; + + foreach ($node->stmts as $stmt) { + if (!$stmt instanceof Stmt\EnumCase) { + continue; + } + + $name = $stmt->name->toString(); + $cases[$name] = new EnumCaseInfo( + name: new EnumCaseName($name), + backingValue: $this->extractEnumCaseBackingValue($stmt), + docblock: $stmt->getDocComment()?->getText(), + file: $filePath, + line: $stmt->getStartLine(), + declaringClass: $className, + ); + } + + return $cases; + } + + private function extractEnumCaseBackingValue(Stmt\EnumCase $case): int|string|null + { + $expr = $case->expr; + if ($expr instanceof Scalar\Int_) { + return $expr->value; + } + if ($expr instanceof Scalar\String_) { + return $expr->value; + } + return null; + } + + /** + * @return list + */ + private function extractInterfaces(Stmt\ClassLike $node): array + { + $interfaces = []; + + if ($node instanceof Stmt\Class_ || $node instanceof Stmt\Enum_) { + foreach ($node->implements as $interface) { + $interfaces[] = $this->resolveNameToClassName($interface); + } + } + + if ($node instanceof Stmt\Interface_) { + foreach ($node->extends as $interface) { + $interfaces[] = $this->resolveNameToClassName($interface); + } + } + + if ($node instanceof Stmt\Enum_) { + $interfaces = array_merge($interfaces, EnumImplicits::interfaces($node->scalarType !== null)); + } + + return $interfaces; + } + + /** + * @return array + */ + private function extractMethods(Stmt\ClassLike $node, ClassName $className, string $filePath): array + { + $methods = []; + $parentClass = $this->resolveParent($node); + + foreach ($node->stmts as $stmt) { + if (!$stmt instanceof Stmt\ClassMethod) { + continue; + } + + $name = $stmt->name->toString(); + $methods[$name] = new MethodInfo( + name: new MethodName($name), + visibility: $this->visibilityFromFlags($stmt->flags), + isStatic: $stmt->isStatic(), + isAbstract: $stmt->isAbstract(), + isFinal: $stmt->isFinal(), + parameters: $this->extractParameters($stmt->params, $className, $parentClass), + returnType: TypeFactory::fromNode( + $stmt->returnType, + $className->fqn, + $parentClass?->fqn, + preserveLateBinding: true, + ), + docblock: $stmt->getDocComment()?->getText(), + file: $filePath, + line: $stmt->getStartLine(), + declaringClass: $className, + ); + } + + if ($node instanceof Stmt\Enum_) { + $methods = array_merge($methods, EnumImplicits::methods($className, $this->enumScalarType($node))); + } + + return $methods; + } + + /** + * @param array $params + * @return list + */ + private function extractParameters(array $params, ClassName $className, ?ClassName $parentClass): array + { + $result = []; + foreach ($params as $position => $param) { + $info = $this->parameterFromNode($param, $position, $className->fqn, $parentClass?->fqn); + if ($info !== null) { + $result[] = $info; + } + } + return $result; + } + + /** + * @return array + */ + private function extractProperties(Stmt\ClassLike $node, ClassName $className, string $filePath): array + { + $properties = []; + + if ($node instanceof Stmt\Enum_) { + $properties = EnumImplicits::properties($className, $this->enumScalarType($node)); + } + $parentClass = $this->resolveParent($node); + + foreach ($node->stmts as $stmt) { + if ($stmt instanceof Stmt\Property) { + foreach ($stmt->props as $prop) { + $name = $prop->name->toString(); + $properties[$name] = new PropertyInfo( + name: new PropertyName($name), + visibility: $this->visibilityFromFlags($stmt->flags), + isStatic: $stmt->isStatic(), + isReadonly: $stmt->isReadonly(), + isPromoted: false, + type: TypeFactory::fromNode($stmt->type, $className->fqn, $parentClass?->fqn), + docblock: $stmt->getDocComment()?->getText(), + file: $filePath, + line: $stmt->getStartLine(), + declaringClass: $className, + ); + } + } + + if ($stmt instanceof Stmt\ClassMethod && $stmt->name->toLowerString() === '__construct') { + foreach ($stmt->params as $param) { + if (!$this->isPromotedProperty($param)) { + continue; + } + if (!$param->var instanceof Variable || !is_string($param->var->name)) { + continue; + } + + $name = $param->var->name; + $properties[$name] = new PropertyInfo( + name: new PropertyName($name), + visibility: $this->visibilityFromFlags($param->flags), + isStatic: false, + isReadonly: ($param->flags & Modifiers::READONLY) !== 0, + isPromoted: true, + type: TypeFactory::fromNode($param->type, $className->fqn, $parentClass?->fqn), + docblock: $param->getDocComment()?->getText(), + file: $filePath, + line: $param->getStartLine(), + declaringClass: $className, + ); + } + } + } + + return $properties; + } + + /** + * @return array{traits: list, exclusions: array>, aliases: list} + */ + private function extractTraitUse(Stmt\ClassLike $node): array + { + $traits = []; + $exclusions = []; + $aliases = []; + + foreach ($node->stmts as $stmt) { + if (!$stmt instanceof Stmt\TraitUse) { + continue; + } + foreach ($stmt->traits as $trait) { + $traits[] = $this->resolveNameToClassName($trait); + } + foreach ($stmt->adaptations as $adaptation) { + if ($adaptation instanceof Stmt\TraitUseAdaptation\Precedence) { + $method = $adaptation->method->toString(); + foreach ($adaptation->insteadof as $loser) { + $exclusions[$this->resolveNameToClassName($loser)->fqn][] = $method; + } + continue; + } + if ($adaptation instanceof Stmt\TraitUseAdaptation\Alias) { + $aliases[] = new TraitAlias( + trait: $adaptation->trait !== null + ? $this->resolveNameToClassName($adaptation->trait) + : null, + method: $adaptation->method->toString(), + newName: $adaptation->newName?->toString(), + newVisibility: $adaptation->newModifier !== null + ? $this->visibilityFromFlags($adaptation->newModifier) + : null, + ); + } + } + } + + return ['traits' => $traits, 'exclusions' => $exclusions, 'aliases' => $aliases]; + } + + private function enumScalarType(Stmt\Enum_ $enum): ?PrimitiveType + { + if ($enum->scalarType === null) { + return null; + } + + return TypeFactory::primitive($enum->scalarType->toString()); + } + + private function functionInfoFromNode(Stmt\Function_ $node, string $filePath): FunctionInfo + { + $params = []; + foreach ($node->params as $position => $param) { + $paramInfo = $this->parameterFromNode($param, $position); + if ($paramInfo !== null) { + $params[] = $paramInfo; + } + } + + return new FunctionInfo( + name: $node->name->toString(), + parameters: $params, + returnType: TypeFactory::fromNode($node->returnType), + docblock: $node->getDocComment()?->getText(), + file: $filePath, + line: $node->getStartLine(), + ); + } + + /** + * A class is a PHP attribute when it is itself declared with `#[Attribute]`. + * Only classes can be attributes; interfaces, traits, and enums cannot. + */ + private function isAttributeNode(Stmt\ClassLike $node): bool + { + if (!$node instanceof Stmt\Class_) { + return false; + } + + foreach ($node->attrGroups as $group) { + foreach ($group->attrs as $attr) { + if ($this->resolveNameToClassName($attr->name)->fqn === Attribute::class) { + return true; + } + } + } + + return false; + } + + private function isPromotedProperty(Param $param): bool + { + return ($param->flags & Modifiers::VISIBILITY_MASK) !== 0; + } + + private function parameterFromNode( + Param $param, + int $position, + ?string $selfContext = null, + ?string $parentContext = null, + ): ?ParameterInfo { + if (!$param->var instanceof Variable || !is_string($param->var->name)) { + return null; + } + + $defaultValue = null; + if ($param->default !== null) { + $printer = new PrettyPrinter(); + $defaultValue = $printer->prettyPrintExpr($param->default); + } + + return new ParameterInfo( + name: $param->var->name, + type: TypeFactory::fromNode($param->type, $selfContext, $parentContext), + hasDefault: $param->default !== null, + defaultValue: $defaultValue, + position: $position, + isVariadic: $param->variadic, + isPassedByReference: $param->byRef, + ); + } + + private function resolveClassName(Stmt\ClassLike $node): ClassName + { + $fqn = LateBindingKeyword::Self->resolveIn($node); + // @codeCoverageIgnoreStart + if ($fqn === null) { + throw new InvalidArgumentException('Cannot create ClassInfo for anonymous class'); + } + // @codeCoverageIgnoreEnd + return TypeFactory::className($fqn); + } + + private function resolveNameToClassName(Node\Name $name): ClassName + { + // TreeAnnotator's NameResolver replaces class-context names with + // FullyQualified in-place (default replaceNodes mode), so a plain + // toString() reads the resolved FQN. + /** @var class-string $fqn */ + $fqn = $name->toString(); + return TypeFactory::className($fqn); + } + + private function resolveParent(Stmt\ClassLike $node): ?ClassName + { + if (!$node instanceof Stmt\Class_ || $node->extends === null) { + return null; + } + + return $this->resolveNameToClassName($node->extends); + } + + private function visibilityFromFlags(int $flags): Visibility + { + if (($flags & Modifiers::PRIVATE) !== 0) { + return Visibility::Private; + } + if (($flags & Modifiers::PROTECTED) !== 0) { + return Visibility::Protected; + } + return Visibility::Public; + } + /** * @param list $symbols * @param array $seen diff --git a/src/Knowledge/KnowledgeStack.php b/src/Knowledge/KnowledgeStack.php index 20b502f4..3e74355c 100644 --- a/src/Knowledge/KnowledgeStack.php +++ b/src/Knowledge/KnowledgeStack.php @@ -15,7 +15,6 @@ use Firehed\PhpLsp\Index\ReflectionNamespaceSource; use Firehed\PhpLsp\Parser\SourceFileReader; use Firehed\PhpLsp\Parser\SyntaxSource\SyntaxSource; -use Firehed\PhpLsp\Repository\DefaultClassInfoFactory; /** * Assembles the symbol-knowledge tier: the {@see SymbolSource} read composite over @@ -46,8 +45,7 @@ public static function forProject( SyntaxSource $parser, SourceFileReader $reader, ): self { - $classInfoFactory = new DefaultClassInfoFactory(); - $declarationInfoFactory = new DeclarationSymbolInfoFactory($classInfoFactory); + $declarationInfoFactory = new DeclarationSymbolInfoFactory(); [$workspaceMap, $vendorMap] = $autoloadMap->partitionByVendorDirectory($vendorDirectory); @@ -77,7 +75,6 @@ public static function forProject( $workspace, $vendor, new BuiltinBackend( - new ReflectionSymbolInfoFactory($classInfoFactory), new CachedNamespaceCatalog($reflectionSource, CacheFactory::inMemory()), new SymbolCache(CacheFactory::inMemory()), $reflectionSource, diff --git a/src/Knowledge/ReflectionSymbolInfoFactory.php b/src/Knowledge/ReflectionSymbolInfoFactory.php deleted file mode 100644 index dd974689..00000000 --- a/src/Knowledge/ReflectionSymbolInfoFactory.php +++ /dev/null @@ -1,87 +0,0 @@ - $this->classInfo($name), - NameKind::Constant => $this->constantInfo($name), - NameKind::Function_ => $this->functionInfo($name), - }; - } - - private function classInfo(QualifiedName $name): ?SymbolInfo - { - $fqn = $name->fullyQualifiedName(); - - // All three probes narrow the name to a `class-string`, which is why - // this kind cannot use the sibling's try/catch. - if (!class_exists($fqn) && !interface_exists($fqn) && !trait_exists($fqn)) { - return null; - } - - $rc = new ReflectionClass($fqn); - return $rc->isInternal() ? $this->classes->fromReflection($rc) : null; - } - - private function constantInfo(QualifiedName $name): ?SymbolInfo - { - $fqn = $name->fullyQualifiedName(); - if (!$this->constants->contains($fqn)) { - return null; - } - - return new ConstantInfo( - name: new ConstantName($name->shortName), - visibility: Visibility::Public, - isFinal: true, - type: null, - docblock: null, - file: null, - line: null, - declaringClass: null, - ); - } - - private function functionInfo(QualifiedName $name): ?SymbolInfo - { - try { - $reflection = new ReflectionFunction($name->fullyQualifiedName()); - } catch (ReflectionException) { - return null; - } - - // Reflection also sees the server's own dependencies; enumeration filters - // those out, so lookup must too (RFC 1 §4.2). - return $reflection->isInternal() ? FunctionInfo::fromReflection($reflection) : null; - } -} diff --git a/src/Repository/ClassInfoFactory.php b/src/Repository/ClassInfoFactory.php deleted file mode 100644 index 52849304..00000000 --- a/src/Repository/ClassInfoFactory.php +++ /dev/null @@ -1,34 +0,0 @@ - $class - * @deprecated sweep: source-context factory - */ - #[\Deprecated('sweep: source-context factory')] - public function fromReflection(ReflectionClass $class): ClassInfo; -} diff --git a/src/Repository/DefaultClassInfoFactory.php b/src/Repository/DefaultClassInfoFactory.php deleted file mode 100644 index 7d02dcfb..00000000 --- a/src/Repository/DefaultClassInfoFactory.php +++ /dev/null @@ -1,669 +0,0 @@ -resolveClassName($node); - $filePath = FileUri::toPath($uri); - $traitUse = $this->extractTraitUse($node); - - return new ClassInfo( - name: $className, - kind: $this->determineKind($node), - isAbstract: $node instanceof Stmt\Class_ && $node->isAbstract(), - isFinal: $node instanceof Stmt\Class_ && $node->isFinal(), - isReadonly: $node instanceof Stmt\Class_ && $node->isReadonly(), - isAttribute: $this->isAttributeNode($node), - parent: $this->resolveParent($node), - interfaces: $this->extractInterfaces($node), - traits: $traitUse['traits'], - methods: $this->extractMethods($node, $className, $filePath), - properties: $this->extractProperties($node, $className, $filePath), - constants: $this->extractConstants($node, $className, $filePath), - enumCases: $this->extractEnumCases($node, $className, $filePath), - docblock: $node->getDocComment()?->getText(), - file: $filePath, - line: $node->getStartLine(), - traitExclusions: $traitUse['exclusions'], - traitAliases: $traitUse['aliases'], - ); - } - - /** - * @template T of object - * @param ReflectionClass $class - */ - public function fromReflection(ReflectionClass $class): ClassInfo - { - $className = TypeFactory::className($class->getName()); - $parentClass = $class->getParentClass(); - - return new ClassInfo( - name: $className, - kind: $this->determineKindFromReflection($class), - isAbstract: $class->isAbstract() && !$class->isInterface(), - isFinal: $class->isFinal(), - isReadonly: $class->isReadOnly(), - isAttribute: $class->getAttributes(\Attribute::class) !== [], - parent: $parentClass !== false - ? TypeFactory::className($parentClass->getName()) - : null, - interfaces: $this->extractInterfacesFromReflection($class), - traits: $this->extractTraitsFromReflection($class), - methods: $this->extractMethodsFromReflection($class, $className), - properties: $this->extractPropertiesFromReflection($class, $className), - constants: $this->extractConstantsFromReflection($class, $className), - enumCases: $this->extractEnumCasesFromReflection($class, $className), - docblock: $class->getDocComment() !== false ? $class->getDocComment() : null, - file: $class->getFileName() !== false ? $class->getFileName() : null, - line: $class->getStartLine() !== false ? $class->getStartLine() : null, - ); - } - - private function resolveClassName(Stmt\ClassLike $node): ClassName - { - $fqn = LateBindingKeyword::Self->resolveIn($node); - if ($fqn === null) { - throw new \InvalidArgumentException('Cannot create ClassInfo for anonymous class'); - } - return TypeFactory::className($fqn); - } - - private function determineKind(Stmt\ClassLike $node): ClassKind - { - return match (true) { - $node instanceof Stmt\Interface_ => ClassKind::Interface_, - $node instanceof Stmt\Trait_ => ClassKind::Trait_, - $node instanceof Stmt\Enum_ => ClassKind::Enum_, - default => ClassKind::Class_, - }; - } - - /** - * @template T of object - * @param ReflectionClass $class - */ - private function determineKindFromReflection(ReflectionClass $class): ClassKind - { - if ($class->isInterface()) { - return ClassKind::Interface_; - } - if ($class->isTrait()) { - return ClassKind::Trait_; - } - if ($class->isEnum()) { - return ClassKind::Enum_; - } - return ClassKind::Class_; - } - - /** - * A class is a PHP attribute when it is itself declared with `#[Attribute]`. - * Only classes can be attributes; interfaces, traits, and enums cannot. - */ - private function isAttributeNode(Stmt\ClassLike $node): bool - { - if (!$node instanceof Stmt\Class_) { - return false; - } - - foreach ($node->attrGroups as $group) { - foreach ($group->attrs as $attr) { - if ($this->resolveNameToClassName($attr->name)->fqn === \Attribute::class) { - return true; - } - } - } - - return false; - } - - private function resolveParent(Stmt\ClassLike $node): ?ClassName - { - if (!$node instanceof Stmt\Class_ || $node->extends === null) { - return null; - } - - return $this->resolveNameToClassName($node->extends); - } - - /** - * @return list - */ - private function extractInterfaces(Stmt\ClassLike $node): array - { - $interfaces = []; - - if ($node instanceof Stmt\Class_ || $node instanceof Stmt\Enum_) { - foreach ($node->implements as $interface) { - $interfaces[] = $this->resolveNameToClassName($interface); - } - } - - if ($node instanceof Stmt\Interface_) { - foreach ($node->extends as $interface) { - $interfaces[] = $this->resolveNameToClassName($interface); - } - } - - if ($node instanceof Stmt\Enum_) { - $interfaces = array_merge($interfaces, EnumImplicits::interfaces($node->scalarType !== null)); - } - - return $interfaces; - } - - /** - * @return array{traits: list, exclusions: array>, aliases: list} - */ - private function extractTraitUse(Stmt\ClassLike $node): array - { - $traits = []; - $exclusions = []; - $aliases = []; - - foreach ($node->stmts as $stmt) { - if (!$stmt instanceof Stmt\TraitUse) { - continue; - } - foreach ($stmt->traits as $trait) { - $traits[] = $this->resolveNameToClassName($trait); - } - foreach ($stmt->adaptations as $adaptation) { - if ($adaptation instanceof Stmt\TraitUseAdaptation\Precedence) { - $method = $adaptation->method->toString(); - foreach ($adaptation->insteadof as $loser) { - $exclusions[$this->resolveNameToClassName($loser)->fqn][] = $method; - } - continue; - } - if ($adaptation instanceof Stmt\TraitUseAdaptation\Alias) { - $aliases[] = new TraitAlias( - trait: $adaptation->trait !== null - ? $this->resolveNameToClassName($adaptation->trait) - : null, - method: $adaptation->method->toString(), - newName: $adaptation->newName?->toString(), - newVisibility: $adaptation->newModifier !== null - ? $this->visibilityFromFlags($adaptation->newModifier) - : null, - ); - } - } - } - - return ['traits' => $traits, 'exclusions' => $exclusions, 'aliases' => $aliases]; - } - - private function resolveNameToClassName(\PhpParser\Node\Name $name): ClassName - { - $resolved = $name->getAttribute('resolvedName'); - /** @var class-string */ - $fqn = $resolved instanceof \PhpParser\Node\Name\FullyQualified - ? $resolved->toString() - : $name->toString(); - return TypeFactory::className($fqn); - } - - /** - * @return array - */ - private function extractMethods(Stmt\ClassLike $node, ClassName $className, string $filePath): array - { - $methods = []; - $parentClass = $this->resolveParent($node); - - foreach ($node->stmts as $stmt) { - if (!$stmt instanceof Stmt\ClassMethod) { - continue; - } - - $name = $stmt->name->toString(); - $methods[$name] = new MethodInfo( - name: new MethodName($name), - visibility: $this->visibilityFromFlags($stmt->flags), - isStatic: $stmt->isStatic(), - isAbstract: $stmt->isAbstract(), - isFinal: $stmt->isFinal(), - parameters: $this->extractParameters($stmt->params, $className, $parentClass), - returnType: TypeFactory::fromNode( - $stmt->returnType, - $className->fqn, - $parentClass?->fqn, - preserveLateBinding: true, - ), - docblock: $stmt->getDocComment()?->getText(), - file: $filePath, - line: $stmt->getStartLine(), - declaringClass: $className, - ); - } - - if ($node instanceof Stmt\Enum_) { - $methods = array_merge($methods, EnumImplicits::methods($className, $this->enumScalarType($node))); - } - - return $methods; - } - - private function enumScalarType(Stmt\Enum_ $enum): ?PrimitiveType - { - if ($enum->scalarType === null) { - return null; - } - - return TypeFactory::primitive($enum->scalarType->toString()); - } - - /** - * @param array $params - * @return list - */ - private function extractParameters(array $params, ClassName $className, ?ClassName $parentClass): array - { - $result = []; - foreach ($params as $position => $param) { - $info = ParameterInfo::fromNode($param, $position, $className->fqn, $parentClass?->fqn); - if ($info !== null) { - $result[] = $info; - } - } - return $result; - } - - /** - * @return array - */ - private function extractProperties(Stmt\ClassLike $node, ClassName $className, string $filePath): array - { - $properties = []; - - if ($node instanceof Stmt\Enum_) { - $properties = EnumImplicits::properties($className, $this->enumScalarType($node)); - } - $parentClass = $this->resolveParent($node); - - foreach ($node->stmts as $stmt) { - if ($stmt instanceof Stmt\Property) { - foreach ($stmt->props as $prop) { - $name = $prop->name->toString(); - $properties[$name] = new PropertyInfo( - name: new PropertyName($name), - visibility: $this->visibilityFromFlags($stmt->flags), - isStatic: $stmt->isStatic(), - isReadonly: $stmt->isReadonly(), - isPromoted: false, - type: TypeFactory::fromNode($stmt->type, $className->fqn, $parentClass?->fqn), - docblock: $stmt->getDocComment()?->getText(), - file: $filePath, - line: $stmt->getStartLine(), - declaringClass: $className, - ); - } - } - - if ($stmt instanceof Stmt\ClassMethod && $stmt->name->toLowerString() === '__construct') { - foreach ($stmt->params as $param) { - if (!$this->isPromotedProperty($param)) { - continue; - } - if (!$param->var instanceof Variable || !is_string($param->var->name)) { - continue; - } - - $name = $param->var->name; - $properties[$name] = new PropertyInfo( - name: new PropertyName($name), - visibility: $this->visibilityFromFlags($param->flags), - isStatic: false, - isReadonly: ($param->flags & Modifiers::READONLY) !== 0, - isPromoted: true, - type: TypeFactory::fromNode($param->type, $className->fqn, $parentClass?->fqn), - docblock: $param->getDocComment()?->getText(), - file: $filePath, - line: $param->getStartLine(), - declaringClass: $className, - ); - } - } - } - - return $properties; - } - - private function isPromotedProperty(Param $param): bool - { - return ($param->flags & Modifiers::VISIBILITY_MASK) !== 0; - } - - /** - * @return array - */ - private function extractConstants(Stmt\ClassLike $node, ClassName $className, string $filePath): array - { - $constants = []; - $parentClass = $this->resolveParent($node); - - foreach ($node->stmts as $stmt) { - if (!$stmt instanceof Stmt\ClassConst) { - continue; - } - - foreach ($stmt->consts as $const) { - $name = $const->name->toString(); - $constants[$name] = new ConstantInfo( - name: new ConstantName($name), - visibility: $this->visibilityFromFlags($stmt->flags), - isFinal: $stmt->isFinal(), - type: TypeFactory::fromNode($stmt->type, $className->fqn, $parentClass?->fqn), - docblock: $stmt->getDocComment()?->getText(), - file: $filePath, - line: $stmt->getStartLine(), - declaringClass: $className, - ); - } - } - - return $constants; - } - - /** - * @return array - */ - private function extractEnumCases(Stmt\ClassLike $node, ClassName $className, string $filePath): array - { - if (!$node instanceof Stmt\Enum_) { - return []; - } - - $cases = []; - - foreach ($node->stmts as $stmt) { - if (!$stmt instanceof Stmt\EnumCase) { - continue; - } - - $name = $stmt->name->toString(); - $cases[$name] = new EnumCaseInfo( - name: new EnumCaseName($name), - backingValue: $this->extractEnumCaseBackingValue($stmt), - docblock: $stmt->getDocComment()?->getText(), - file: $filePath, - line: $stmt->getStartLine(), - declaringClass: $className, - ); - } - - return $cases; - } - - private function extractEnumCaseBackingValue(Stmt\EnumCase $case): int|string|null - { - $expr = $case->expr; - if ($expr instanceof \PhpParser\Node\Scalar\Int_) { - return $expr->value; - } - if ($expr instanceof \PhpParser\Node\Scalar\String_) { - return $expr->value; - } - return null; - } - - /** - * @template T of object - * @param ReflectionClass $class - * @return list - */ - private function extractInterfacesFromReflection(ReflectionClass $class): array - { - $interfaces = []; - $parent = $class->getParentClass(); - $parentInterfaces = $parent !== false ? $parent->getInterfaceNames() : []; - - foreach ($class->getInterfaceNames() as $interfaceName) { - // Only include directly implemented interfaces, not inherited ones - if (!in_array($interfaceName, $parentInterfaces, true)) { - $interfaces[] = TypeFactory::className($interfaceName); - } - } - - return $interfaces; - } - - /** - * @template T of object - * @param ReflectionClass $class - * @return list - */ - private function extractTraitsFromReflection(ReflectionClass $class): array - { - $traits = []; - - foreach ($class->getTraitNames() as $traitName) { - $traits[] = TypeFactory::className($traitName); - } - - return $traits; - } - - /** - * @template T of object - * @param ReflectionClass $class - * @return array - */ - private function extractMethodsFromReflection(ReflectionClass $class, ClassName $className): array - { - $methods = []; - - foreach ($class->getMethods() as $method) { - if ($method->getDeclaringClass()->getName() !== $class->getName()) { - continue; - } - - $name = $method->getName(); - $methods[$name] = new MethodInfo( - name: new MethodName($name), - visibility: $this->visibilityFromReflectionMethod($method), - isStatic: $method->isStatic(), - isAbstract: $method->isAbstract(), - isFinal: $method->isFinal(), - parameters: $this->extractParametersFromReflection($method), - returnType: TypeFactory::fromReflection($method->getReturnType()), - docblock: $method->getDocComment() !== false ? $method->getDocComment() : null, - file: $method->getFileName() !== false ? $method->getFileName() : null, - line: $method->getStartLine() !== false ? $method->getStartLine() : null, - declaringClass: $className, - ); - } - - return $methods; - } - - /** - * @return list - */ - private function extractParametersFromReflection(ReflectionMethod $method): array - { - return array_map( - ParameterInfo::fromReflection(...), - $method->getParameters(), - ); - } - - /** - * @template T of object - * @param ReflectionClass $class - * @return array - */ - private function extractPropertiesFromReflection(ReflectionClass $class, ClassName $className): array - { - $properties = []; - - foreach ($class->getProperties() as $property) { - if ($property->getDeclaringClass()->getName() !== $class->getName()) { - continue; - } - - $name = $property->getName(); - $properties[$name] = new PropertyInfo( - name: new PropertyName($name), - visibility: $this->visibilityFromReflectionProperty($property), - isStatic: $property->isStatic(), - isReadonly: $property->isReadOnly(), - isPromoted: $property->isPromoted(), - type: TypeFactory::fromReflection($property->getType()), - docblock: $property->getDocComment() !== false ? $property->getDocComment() : null, - file: $class->getFileName() !== false ? $class->getFileName() : null, - line: null, - declaringClass: $className, - ); - } - - return $properties; - } - - /** - * @template T of object - * @param ReflectionClass $class - * @return array - */ - private function extractConstantsFromReflection(ReflectionClass $class, ClassName $className): array - { - $constants = []; - - foreach ($class->getReflectionConstants() as $constant) { - if ($constant->getDeclaringClass()->getName() !== $class->getName()) { - continue; - } - if ($constant->isEnumCase()) { - continue; - } - - $name = $constant->getName(); - $constants[$name] = new ConstantInfo( - name: new ConstantName($name), - visibility: $this->visibilityFromReflectionConstant($constant), - isFinal: $constant->isFinal(), - type: TypeFactory::fromReflection($constant->getType()), - docblock: $constant->getDocComment() !== false ? $constant->getDocComment() : null, - file: $class->getFileName() !== false ? $class->getFileName() : null, - line: null, - declaringClass: $className, - ); - } - - return $constants; - } - - /** - * @template T of object - * @param ReflectionClass $class - * @return array - */ - private function extractEnumCasesFromReflection(ReflectionClass $class, ClassName $className): array - { - if (!$class->isEnum()) { - return []; - } - - $cases = []; - - foreach ($class->getReflectionConstants() as $constant) { - if (!$constant->isEnumCase()) { - continue; - } - - $name = $constant->getName(); - $enumCase = $constant->getValue(); - $backingValue = $enumCase instanceof \BackedEnum ? $enumCase->value : null; - - $cases[$name] = new EnumCaseInfo( - name: new EnumCaseName($name), - backingValue: $backingValue, - docblock: $constant->getDocComment() !== false ? $constant->getDocComment() : null, - file: $class->getFileName() !== false ? $class->getFileName() : null, - line: null, - declaringClass: $className, - ); - } - - return $cases; - } - - private function visibilityFromFlags(int $flags): Visibility - { - if (($flags & Modifiers::PRIVATE) !== 0) { - return Visibility::Private; - } - if (($flags & Modifiers::PROTECTED) !== 0) { - return Visibility::Protected; - } - return Visibility::Public; - } - - private function visibilityFromReflectionMethod(ReflectionMethod $method): Visibility - { - if ($method->isPrivate()) { - return Visibility::Private; - } - if ($method->isProtected()) { - return Visibility::Protected; - } - return Visibility::Public; - } - - private function visibilityFromReflectionProperty(ReflectionProperty $property): Visibility - { - if ($property->isPrivate()) { - return Visibility::Private; - } - if ($property->isProtected()) { - return Visibility::Protected; - } - return Visibility::Public; - } - - private function visibilityFromReflectionConstant(ReflectionClassConstant $constant): Visibility - { - if ($constant->isPrivate()) { - return Visibility::Private; - } - if ($constant->isProtected()) { - return Visibility::Protected; - } - return Visibility::Public; - } -} diff --git a/tests/Architecture/KindBranchRule.php b/tests/Architecture/KindBranchRule.php index be5163ee..4aea5917 100644 --- a/tests/Architecture/KindBranchRule.php +++ b/tests/Architecture/KindBranchRule.php @@ -60,8 +60,7 @@ final class KindBranchRule implements Rule 'src/Domain/MemberKind.php', 'src/Domain/ClassInfo.php', 'src/Resolution/NameContext.php', - 'src/Repository/DefaultClassInfoFactory.php', - 'src/Knowledge/ReflectionSymbolInfoFactory.php', + 'src/Knowledge/BuiltinBackend.php', 'src/Knowledge/DeclarationSymbolInfoFactory.php', 'src/Completion/CompletionItemFactory.php', ]; diff --git a/tests/Architecture/KindInspectionRule.php b/tests/Architecture/KindInspectionRule.php index a09c9c5d..46db6004 100644 --- a/tests/Architecture/KindInspectionRule.php +++ b/tests/Architecture/KindInspectionRule.php @@ -21,7 +21,8 @@ * Allowed locations: * - Type implementations (internal operations) * - TypeFactory (construction) - * - Metadata factories (DefaultClassInfoFactory, ReflectionSymbolInfoFactory) + * - Metadata factory (DeclarationSymbolInfoFactory) + * - BuiltinBackend (inlined reflection-to-SymbolInfo build) * - Classifiers (CompletionItemFactory - maps symbol to LSP kind) * - Tests * @@ -72,9 +73,7 @@ final class KindInspectionRule implements Rule 'src/Domain/IntersectionType.php', 'src/Domain/PrimitiveType.php', 'src/Domain/LateStaticType.php', - 'src/Repository/DefaultClassInfoFactory.php', 'src/Knowledge/CompositeSymbolSource.php', - 'src/Knowledge/ReflectionSymbolInfoFactory.php', ]; public function getNodeType(): string diff --git a/tests/Fixtures/src/Hover/ResolveNewFallbacks.php b/tests/Fixtures/src/Hover/ResolveNewFallbacks.php new file mode 100644 index 00000000..67a3b8a8 --- /dev/null +++ b/tests/Fixtures/src/Hover/ResolveNewFallbacks.php @@ -0,0 +1,41 @@ +foo(); //hover:variable_new + } + + public function newWithAnonymousClass(): void + { + (new class { + public function foo(): void + { + } + })->foo(); //hover:anon_new + } + + public function newWithUnknownClass(): void + { + (new NoSuchClassInWorkspace())->foo(); //hover:unknown_new + } +} + +// `self` at global scope has no enclosing class-like, so +// resolveClassNameInContext returns null. +function newSelfAtGlobalScope(): void +{ + (new self())->foo(); //hover:global_self_new +} diff --git a/tests/Fixtures/src/IncompleteCode/BrokenParameters.php b/tests/Fixtures/src/IncompleteCode/BrokenParameters.php new file mode 100644 index 00000000..6cafa98c --- /dev/null +++ b/tests/Fixtures/src/IncompleteCode/BrokenParameters.php @@ -0,0 +1,15 @@ + + */ + public static function loadedClassLikes(): iterable + { + yield 'class' => [\ArrayObject::class]; + yield 'interface' => [\Countable::class]; + yield 'enum' => [\Random\IntervalBoundary::class]; + // PHP has no built-in traits as of 8.5; if a future version adds one, + // it should be added here. + // yield 'trait' => [...]; + } + + #[DataProvider('loadedClassLikes')] + public function testLookupBuildsClassInfoForEveryClassLikeFlavour(string $fqn): void + { + $info = self::classLikeIn($this->backend(self::createStub(NamespaceCatalog::class)), $fqn); + + self::assertNotNull($info, 'a class-like flavour reflection can describe must resolve'); + self::assertSame($fqn, $info->name->fqn, 'the reflected class-like must be returned'); + } + + public function testLookupIgnoresClassLikesOnlyTheServerHasLoaded(): void + { + self::assertNull( + self::classLikeIn($this->backend(self::createStub(NamespaceCatalog::class)), self::class), + 'a userland class loaded in the server process is not a built-in', + ); + } + + /** + * @return iterable + */ + public static function absentNames(): iterable + { + // The kind selects which reflection is consulted, so a name that exists in + // one of PHP's symbol namespaces is not answered for another. + yield 'a function asked for as a class' => ['str_contains', NameKind::ClassLike]; + yield 'a class asked for as a function' => [\ArrayObject::class, NameKind::Function_]; + } + + #[DataProvider('absentNames')] + public function testLookupReturnsNullWhenReflectionCannotDescribeTheNameForThatKind( + string $fqn, + NameKind $kind, + ): void { + $backend = $this->backend(self::createStub(NamespaceCatalog::class)); + self::assertNull( + $backend->lookup(QualifiedName::fromFullyQualified($fqn), $kind), + 'a name reflection cannot load for this kind is absent (RFC 1 §5.3)', + ); + } + + public function testLookupResolvesABuiltinConstant(): void + { + $backend = $this->backend(self::createStub(NamespaceCatalog::class)); + + $info = $backend->lookup(QualifiedName::fromFullyQualified('PHP_INT_MAX'), NameKind::Constant); + + self::assertInstanceOf( + ConstantInfo::class, + $info, + 'a built-in constant must resolve to ConstantInfo', + ); + } + + public function testLookupDoesNotResolveAUserConstant(): void + { + // Define a "user" constant that will be filtered out. + if (!defined('TEST_USER_CONSTANT')) { + define('TEST_USER_CONSTANT', 'value'); + } + + $backend = $this->backend(self::createStub(NamespaceCatalog::class)); + + self::assertNull( + $backend->lookup(QualifiedName::fromFullyQualified('TEST_USER_CONSTANT'), NameKind::Constant), + 'a user-defined constant is not a built-in, so it must not resolve', + ); + } + + public function testClassInfoCarriesBasicMetadataForAPlainClass(): void + { + $info = self::classLikeIn($this->backend(self::createStub(NamespaceCatalog::class)), \stdClass::class); + + self::assertInstanceOf(ClassInfo::class, $info); + self::assertSame(\stdClass::class, $info->name->fqn); + self::assertSame(ClassKind::Class_, $info->kind); + } + + public function testClassInfoCapturesTheParentClass(): void + { + $info = self::classLikeIn( + $this->backend(self::createStub(NamespaceCatalog::class)), + \RuntimeException::class, + ); + + self::assertInstanceOf(ClassInfo::class, $info); + self::assertSame(\Exception::class, $info->parent?->fqn); + } + + public function testClassInfoReportsInterfaceKindForABuiltinInterface(): void + { + $info = self::classLikeIn($this->backend(self::createStub(NamespaceCatalog::class)), \Iterator::class); + + self::assertInstanceOf(ClassInfo::class, $info); + self::assertSame(ClassKind::Interface_, $info->kind); + } + + public function testClassInfoReportsEnumKindAndEnumCases(): void + { + $info = self::classLikeIn( + $this->backend(self::createStub(NamespaceCatalog::class)), + \Random\IntervalBoundary::class, + ); + + self::assertInstanceOf(ClassInfo::class, $info); + self::assertSame(ClassKind::Enum_, $info->kind); + self::assertNotEmpty($info->enumCases, 'built-in enum cases must be extracted'); + } + + public function testClassInfoDetectsTheBuiltinAttributeClass(): void + { + $info = self::classLikeIn($this->backend(self::createStub(NamespaceCatalog::class)), \Attribute::class); + + self::assertInstanceOf(ClassInfo::class, $info); + self::assertTrue($info->isAttribute, 'the built-in Attribute class is itself an attribute'); + } + + public function testPlainClassIsNotMarkedAsAttribute(): void + { + $info = self::classLikeIn($this->backend(self::createStub(NamespaceCatalog::class)), \stdClass::class); + + self::assertInstanceOf(ClassInfo::class, $info); + self::assertFalse($info->isAttribute); + } + + public function testClassInfoCarriesMethodsPropertiesAndInterfaces(): void + { + $info = self::classLikeIn($this->backend(self::createStub(NamespaceCatalog::class)), \ArrayObject::class); + + self::assertInstanceOf(ClassInfo::class, $info); + self::assertNotEmpty($info->methods, 'a built-in class must report its methods'); + self::assertNotEmpty($info->interfaces, 'ArrayObject implements several built-in interfaces'); + } + + public function testClassInfoCarriesConstants(): void + { + $info = self::classLikeIn($this->backend(self::createStub(NamespaceCatalog::class)), \ArrayObject::class); + + self::assertInstanceOf(ClassInfo::class, $info); + self::assertArrayHasKey('STD_PROP_LIST', $info->constants); + } + + public function testInheritedConstantsAreFilteredFromDeclaringClass(): void + { + $info = self::classLikeIn( + $this->backend(self::createStub(NamespaceCatalog::class)), + \RecursiveDirectoryIterator::class, + ); + + self::assertInstanceOf(ClassInfo::class, $info); + self::assertSame( + [], + $info->constants, + 'the class declares no constants; the inherited FilesystemIterator constants must be filtered out', + ); + } + + public function testNullDefaultParameterFormats(): void + { + $info = self::functionIn($this->backend(self::createStub(NamespaceCatalog::class)), 'str_replace'); + + self::assertNotNull($info, 'str_replace must resolve so its parameters can be inspected'); + $paramsByName = []; + foreach ($info->parameters as $param) { + $paramsByName[$param->name] = $param; + } + + self::assertArrayHasKey('count', $paramsByName, 'str_replace declares $count with a null default'); + self::assertTrue($paramsByName['count']->hasDefault); + self::assertSame( + 'null', + $paramsByName['count']->defaultValue, + 'a null default must be formatted as the literal string "null" (formatReflectionDefault Null branch)', + ); + } + + public function testExceptionPropertyVisibilitiesAreMapped(): void + { + $info = self::classLikeIn($this->backend(self::createStub(NamespaceCatalog::class)), \Exception::class); + + self::assertInstanceOf(ClassInfo::class, $info); + self::assertSame( + Visibility::Protected, + $info->properties['message']->visibility, + 'Exception::$message is protected; the visibility mapper must cover the Protected branch', + ); + self::assertSame( + Visibility::Private, + $info->properties['string']->visibility, + 'Exception::$string is private; the visibility mapper must cover the Private branch', + ); + } + + public function testProtectedMethodVisibilityIsMapped(): void + { + $info = self::classLikeIn($this->backend(self::createStub(NamespaceCatalog::class)), \SplHeap::class); + + self::assertInstanceOf(ClassInfo::class, $info); + self::assertSame( + Visibility::Protected, + $info->methods['compare']->visibility, + 'SplHeap::compare is protected; the method visibility mapper must cover the Protected branch', + ); + } + + public function testPrivateMethodVisibilityIsMapped(): void + { + $info = self::classLikeIn($this->backend(self::createStub(NamespaceCatalog::class)), \Exception::class); + + self::assertInstanceOf(ClassInfo::class, $info); + self::assertSame( + Visibility::Private, + $info->methods['__clone']->visibility, + 'Exception::__clone is private; the method visibility mapper must cover the Private branch', + ); + } + public function testChildrenOfForwardsToTheReflectionCatalog(): void { $expected = new NamespaceContents(['Random'], []); diff --git a/tests/Knowledge/DeclarationSymbolInfoFactoryTest.php b/tests/Knowledge/DeclarationSymbolInfoFactoryTest.php index 36b19eb9..d6d8ce4c 100644 --- a/tests/Knowledge/DeclarationSymbolInfoFactoryTest.php +++ b/tests/Knowledge/DeclarationSymbolInfoFactoryTest.php @@ -10,10 +10,10 @@ use Firehed\PhpLsp\Domain\NameKind; use Firehed\PhpLsp\Domain\QualifiedName; use Firehed\PhpLsp\Domain\SymbolInfo; +use Firehed\PhpLsp\Domain\Visibility; use Firehed\PhpLsp\Knowledge\DeclarationScanner; use Firehed\PhpLsp\Knowledge\DeclarationSymbolInfoFactory; use Firehed\PhpLsp\Knowledge\FileDeclarations; -use Firehed\PhpLsp\Repository\DefaultClassInfoFactory; use Firehed\PhpLsp\Tests\LoadsFixturesTrait; use Firehed\PhpLsp\Tests\Parser\ProductionSyntaxSource; use PHPUnit\Framework\Attributes\DataProvider; @@ -36,7 +36,7 @@ final class DeclarationSymbolInfoFactoryTest extends TestCase protected function setUp(): void { - $this->factory = new DeclarationSymbolInfoFactory(new DefaultClassInfoFactory()); + $this->factory = new DeclarationSymbolInfoFactory(); $this->path = $this->fixturePath(self::FIXTURE); $production = ProductionSyntaxSource::create(); @@ -166,6 +166,117 @@ public function testAllInKeepsTheFirstOfDuplicateDeclarations(): void ); } + public function testExtractsInsteadofExclusions(): void + { + $info = $this->buildClassInfoFromFixture( + 'src/Hierarchy/TraitAdaptationUser.php', + 'Fixtures\Hierarchy\TraitAdaptationUser', + ); + + self::assertSame( + ['Fixtures\Hierarchy\ConflictingTraitB' => ['conflictMethod']], + $info->traitExclusions, + 'an insteadof adaptation must record the losing trait and the excluded method (RFC 1 §5.6)', + ); + } + + public function testExtractsAliasAdaptations(): void + { + $info = $this->buildClassInfoFromFixture( + 'src/Hierarchy/TraitAdaptationUser.php', + 'Fixtures\Hierarchy\TraitAdaptationUser', + ); + + $aliasesByNewName = []; + foreach ($info->traitAliases as $alias) { + $aliasesByNewName[$alias->newName ?? '(visibility-only)'] = $alias; + } + + self::assertArrayHasKey( + 'conflictMethodFromB', + $aliasesByNewName, + 'a rename `as` adaptation must record its new name', + ); + self::assertSame( + 'conflictMethod', + $aliasesByNewName['conflictMethodFromB']->method, + 'the alias must carry the original method name', + ); + self::assertNull( + $aliasesByNewName['conflictMethodFromB']->newVisibility, + 'a rename-only alias does not change visibility', + ); + + self::assertArrayHasKey( + 'protectedOnlyInB', + $aliasesByNewName, + 'an alias renaming and re-scoping must be recorded', + ); + self::assertSame( + Visibility::Protected, + $aliasesByNewName['protectedOnlyInB']->newVisibility, + 'the new visibility flag must be mapped through visibilityFromFlags', + ); + } + + public function testPromotedPropertyWithMalformedVarNodeIsSkipped(): void + { + // php-parser recovers `private $)` by attaching an Error node as the + // parameter's var. The extractor must skip it rather than crash on + // reading a non-string name (RFC 1 §9 tolerance for broken input). + $info = $this->buildClassInfoFromFixture( + 'src/IncompleteCode/BrokenParameters.php', + 'Fixtures\IncompleteCode\BrokenPromotedProperty', + ); + + self::assertSame( + [], + $info->properties, + 'a promoted-property Param whose var is not a Variable must not become a PropertyInfo', + ); + } + + public function testFunctionParameterWithMalformedVarNodeIsSkipped(): void + { + // Same shape as the promoted-property fixture, in a free-standing + // function's parameter list. + $path = $this->fixturePath('src/IncompleteCode/BrokenParameters.php'); + $production = ProductionSyntaxSource::create(); + $document = $production->reader->read($path); + self::assertNotNull($document); + $declarations = (new DeclarationScanner())->scan($production->source->parse($document)); + + $info = $this->factory->fromDeclarations( + $declarations, + QualifiedName::fromFullyQualified('Fixtures\IncompleteCode\brokenFreeStandingParam'), + NameKind::Function_, + $path, + ); + + self::assertInstanceOf(FunctionInfo::class, $info); + self::assertSame( + [], + $info->parameters, + 'a function Param whose var is not a Variable must not become a ParameterInfo', + ); + } + + public function testAliasWithoutSourceTraitLeavesTraitNull(): void + { + $info = $this->buildClassInfoFromFixture( + 'src/Hierarchy/TraitNamelessAliasUser.php', + 'Fixtures\Hierarchy\TraitNamelessAliasUser', + ); + + self::assertCount(1, $info->traitAliases, 'the fixture declares exactly one alias'); + self::assertNull( + $info->traitAliases[0]->trait, + 'an `as` clause that names no source trait leaves the alias trait null', + ); + self::assertSame('onlyInA', $info->traitAliases[0]->method); + self::assertSame('renamedOnlyInA', $info->traitAliases[0]->newName); + } + public function testLookupAgreesWithTheFullScan(): void { // RFC 1 §5.1: a derived verb must not fork from the one it derives from. @@ -187,4 +298,22 @@ private function build(string $fqn, NameKind $kind): ?SymbolInfo $this->path, ); } + + private function buildClassInfoFromFixture(string $fixturePath, string $fqn): ClassInfo + { + $path = $this->fixturePath($fixturePath); + $production = ProductionSyntaxSource::create(); + $document = $production->reader->read($path); + self::assertNotNull($document, "the fixture $fixturePath must be readable"); + $declarations = (new DeclarationScanner())->scan($production->source->parse($document)); + + $info = $this->factory->fromDeclarations( + $declarations, + QualifiedName::fromFullyQualified($fqn), + NameKind::ClassLike, + $path, + ); + self::assertInstanceOf(ClassInfo::class, $info, "the fixture must declare $fqn as a class-like"); + return $info; + } } diff --git a/tests/Knowledge/DocumentSymbolSinkTest.php b/tests/Knowledge/DocumentSymbolSinkTest.php index c84f734d..f1ec87a6 100644 --- a/tests/Knowledge/DocumentSymbolSinkTest.php +++ b/tests/Knowledge/DocumentSymbolSinkTest.php @@ -13,7 +13,6 @@ use Firehed\PhpLsp\Parser\ParseMetrics; use Firehed\PhpLsp\Parser\SyntaxSource\PhpParserSyntaxSource; use Firehed\PhpLsp\Parser\TreeAnnotator; -use Firehed\PhpLsp\Repository\DefaultClassInfoFactory; use Firehed\PhpLsp\Tests\LoadsFixturesTrait; use Firehed\PhpLsp\Tests\Parser\ProductionSyntaxSource; use PHPUnit\Framework\Attributes\DataProvider; @@ -38,10 +37,9 @@ protected function setUp(): void { $parser = ProductionSyntaxSource::create()->source; $this->backend = new OpenDocumentBackend(); - $classInfoFactory = new DefaultClassInfoFactory(); $this->sink = new DocumentSymbolSink( $this->backend, - new DeclarationSymbolInfoFactory($classInfoFactory), + new DeclarationSymbolInfoFactory(), $parser, new DeclarationScanner(), ); @@ -294,11 +292,10 @@ public static function classLikeFixtures(): array private function sinkWithOnDiskBackends(Invalidatable ...$onDiskBackends): DocumentSymbolSink { $parser = ProductionSyntaxSource::create()->source; - $classInfoFactory = new DefaultClassInfoFactory(); return new DocumentSymbolSink( $this->backend, - new DeclarationSymbolInfoFactory($classInfoFactory), + new DeclarationSymbolInfoFactory(), $parser, new DeclarationScanner(), array_values($onDiskBackends), diff --git a/tests/Knowledge/FilesystemBackendTest.php b/tests/Knowledge/FilesystemBackendTest.php index 655701b4..386793e2 100644 --- a/tests/Knowledge/FilesystemBackendTest.php +++ b/tests/Knowledge/FilesystemBackendTest.php @@ -25,7 +25,6 @@ use Firehed\PhpLsp\Knowledge\SymbolLocator; use Firehed\PhpLsp\Parser\SourceFileReader; use Firehed\PhpLsp\Parser\SyntaxSource\MemoizingSyntaxSource; -use Firehed\PhpLsp\Repository\DefaultClassInfoFactory; use Firehed\PhpLsp\Tests\Parser\ProductionSyntaxSource; use PHPUnit\Framework\TestCase; @@ -51,7 +50,7 @@ protected function setUp(): void $production = ProductionSyntaxSource::create(); $this->parser = $production->source; $this->reader = $production->reader; - $this->infoFactory = new DeclarationSymbolInfoFactory(new DefaultClassInfoFactory()); + $this->infoFactory = new DeclarationSymbolInfoFactory(); } public function testLookupClassLikeResolvesAndParsesAFixtureClass(): void diff --git a/tests/Knowledge/ReflectionSymbolInfoFactoryTest.php b/tests/Knowledge/ReflectionSymbolInfoFactoryTest.php deleted file mode 100644 index 36bb5cff..00000000 --- a/tests/Knowledge/ReflectionSymbolInfoFactoryTest.php +++ /dev/null @@ -1,128 +0,0 @@ -factory = new ReflectionSymbolInfoFactory(new DefaultClassInfoFactory()); - } - - /** - * @return iterable - */ - public static function loadedClassLikes(): iterable - { - yield 'class' => [\ArrayObject::class]; - yield 'interface' => [\Countable::class]; - yield 'enum' => [\Random\IntervalBoundary::class]; - // PHP has no built-in traits as of 8.5; if a future version adds one, - // it should be added here. - // yield 'trait' => [...]; - } - - #[DataProvider('loadedClassLikes')] - public function testBuildsClassInfoForEveryClassLikeFlavour(string $fqn): void - { - $info = $this->build($fqn, NameKind::ClassLike); - - self::assertInstanceOf(ClassInfo::class, $info, 'a class-like must build ClassInfo'); - self::assertSame($fqn, $info->name->fqn, 'the reflected class-like must be returned'); - } - - public function testBuildsFunctionInfoForAnInternalFunction(): void - { - $info = $this->build('str_contains', NameKind::Function_); - - self::assertInstanceOf(FunctionInfo::class, $info, 'a function must build FunctionInfo'); - self::assertCount(2, $info->parameters, 'the reflected signature must be carried, not just the name'); - } - - public function testIgnoresFunctionsOnlyTheServerHasLoaded(): void - { - require_once dirname(__DIR__) . '/Domain/Fixtures/documented_function.php'; - - self::assertNull( - $this->build('testDocumentedFunction', NameKind::Function_), - 'a userland function loaded in the server process is not a built-in', - ); - } - - public function testIgnoresClassLikesOnlyTheServerHasLoaded(): void - { - self::assertNull( - $this->build(self::class, NameKind::ClassLike), - 'a userland class loaded in the server process is not a built-in', - ); - } - - /** - * @return iterable - */ - public static function absentNames(): iterable - { - yield 'class-like' => ['No\Such\Builtin', NameKind::ClassLike]; - yield 'function' => ['no_such_builtin', NameKind::Function_]; - // The kind selects which reflection is consulted, so a name that exists in - // one of PHP's symbol namespaces is not answered for another. - yield 'a function asked for as a class' => ['str_contains', NameKind::ClassLike]; - yield 'a class asked for as a function' => [\ArrayObject::class, NameKind::Function_]; - } - - #[DataProvider('absentNames')] - public function testReturnsNullWhenReflectionCannotDescribeTheName(string $fqn, NameKind $kind): void - { - self::assertNull( - $this->build($fqn, $kind), - 'a name reflection cannot load for this kind is absent (RFC 1 §5.3)', - ); - } - - public function testBuiltinConstantIsResolved(): void - { - $info = $this->build('PHP_INT_MAX', NameKind::Constant); - - self::assertInstanceOf( - \Firehed\PhpLsp\Domain\ConstantInfo::class, - $info, - 'a built-in constant must resolve to ConstantInfo', - ); - } - - public function testUserConstantIsNotResolved(): void - { - // Define a "user" constant that will be filtered out - if (!defined('TEST_USER_CONSTANT')) { - define('TEST_USER_CONSTANT', 'value'); - } - - self::assertNull( - $this->build('TEST_USER_CONSTANT', NameKind::Constant), - 'a user-defined constant is not a built-in, so reflection must not resolve it', - ); - } - - private function build(string $fqn, NameKind $kind): ?SymbolInfo - { - return $this->factory->fromReflection(QualifiedName::fromFullyQualified($fqn), $kind); - } -} diff --git a/tests/Parity/BuiltinFunctionParityTest.php b/tests/Parity/BuiltinFunctionParityTest.php index e0e392d5..b62facc0 100644 --- a/tests/Parity/BuiltinFunctionParityTest.php +++ b/tests/Parity/BuiltinFunctionParityTest.php @@ -12,9 +12,7 @@ use Firehed\PhpLsp\Index\ReflectionNamespaceSource; use Firehed\PhpLsp\Knowledge\BuiltinBackend; use Firehed\PhpLsp\Knowledge\NamespaceName; -use Firehed\PhpLsp\Knowledge\ReflectionSymbolInfoFactory; use Firehed\PhpLsp\Knowledge\SymbolCache; -use Firehed\PhpLsp\Repository\DefaultClassInfoFactory; use PHPUnit\Framework\TestCase; /** @@ -55,7 +53,6 @@ protected function setUp(): void // precedence backend, so the oracle measures the shipped configuration. $reflectionSource = new ReflectionNamespaceSource(); $this->backend = new BuiltinBackend( - new ReflectionSymbolInfoFactory(new DefaultClassInfoFactory()), new CachedNamespaceCatalog($reflectionSource, CacheFactory::inMemory()), new SymbolCache(CacheFactory::inMemory()), $reflectionSource, diff --git a/tests/Parity/PrefixSearchParityTest.php b/tests/Parity/PrefixSearchParityTest.php index 80789457..dce6d42b 100644 --- a/tests/Parity/PrefixSearchParityTest.php +++ b/tests/Parity/PrefixSearchParityTest.php @@ -11,7 +11,6 @@ use Firehed\PhpLsp\Knowledge\DeclarationSymbolInfoFactory; use Firehed\PhpLsp\Knowledge\DocumentSymbolSink; use Firehed\PhpLsp\Knowledge\OpenDocumentBackend; -use Firehed\PhpLsp\Repository\DefaultClassInfoFactory; use Firehed\PhpLsp\Tests\Parser\ProductionSyntaxSource; use PHPUnit\Framework\TestCase; @@ -53,7 +52,7 @@ protected function setUp(): void $this->backend = new OpenDocumentBackend(); $this->sink = new DocumentSymbolSink( $this->backend, - new DeclarationSymbolInfoFactory(new DefaultClassInfoFactory()), + new DeclarationSymbolInfoFactory(), $parser, new DeclarationScanner(), ); diff --git a/tests/Parity/WritePathParityTest.php b/tests/Parity/WritePathParityTest.php index f0b39bb1..cd615093 100644 --- a/tests/Parity/WritePathParityTest.php +++ b/tests/Parity/WritePathParityTest.php @@ -14,7 +14,6 @@ use Firehed\PhpLsp\Parser\ParseMetrics; use Firehed\PhpLsp\Parser\SyntaxSource\PhpParserSyntaxSource; use Firehed\PhpLsp\Parser\TreeAnnotator; -use Firehed\PhpLsp\Repository\DefaultClassInfoFactory; use Firehed\PhpLsp\Tests\Parser\ProductionSyntaxSource; use PHPUnit\Framework\TestCase; @@ -58,7 +57,7 @@ protected function setUp(): void $this->backend = new OpenDocumentBackend(); $this->sink = new DocumentSymbolSink( $this->backend, - new DeclarationSymbolInfoFactory(new DefaultClassInfoFactory()), + new DeclarationSymbolInfoFactory(), $parser, new DeclarationScanner(), ); diff --git a/tests/Parity/goldens/completion-broken-first-open.json b/tests/Parity/goldens/completion-broken-first-open.json index 28b7971d..7b8f7c20 100644 --- a/tests/Parity/goldens/completion-broken-first-open.json +++ b/tests/Parity/goldens/completion-broken-first-open.json @@ -268,6 +268,10 @@ "label": "BrokenInheritance", "kind": 7 }, + { + "label": "BrokenParameters", + "kind": 7 + }, { "label": "ChainedAccess", "kind": 7 diff --git a/tests/Repository/DefaultClassInfoFactoryTest.php b/tests/Repository/DefaultClassInfoFactoryTest.php deleted file mode 100644 index 00b5b792..00000000 --- a/tests/Repository/DefaultClassInfoFactoryTest.php +++ /dev/null @@ -1,696 +0,0 @@ -factory = new DefaultClassInfoFactory(); - } - - public function testFromAstNodeExtractsClassName(): void - { - $node = $this->parseClassFromFixture('src/Domain/User.php'); - - $info = $this->factory->fromAstNode($node, 'file:///test.php'); - - self::assertSame('Fixtures\\Domain\\User', $info->name->fqn); - self::assertSame(ClassKind::Class_, $info->kind); - } - - public function testFromAstNodeExtractsClassNameWithoutNameResolver(): void - { - $node = $this->parseClassWithoutNameResolverFromFixture('TypeInference/GlobalFunction.php'); - - $info = $this->factory->fromAstNode($node, 'file:///test.php'); - - self::assertSame('GlobalConfig', $info->name->fqn); - } - - public function testFromAstNodeExtractsInterface(): void - { - $node = $this->parseClassFromFixture('src/Domain/Entity.php'); - - $info = $this->factory->fromAstNode($node, 'file:///test.php'); - - self::assertSame(ClassKind::Interface_, $info->kind); - } - - public function testFromAstNodeExtractsTrait(): void - { - $node = $this->parseClassFromFixture('src/Traits/HasTimestamps.php'); - - $info = $this->factory->fromAstNode($node, 'file:///test.php'); - - self::assertSame(ClassKind::Trait_, $info->kind); - } - - public function testFromAstNodeExtractsEnum(): void - { - $node = $this->parseClassFromFixture('src/Enum/Status.php'); - - $info = $this->factory->fromAstNode($node, 'file:///test.php'); - - self::assertSame(ClassKind::Enum_, $info->kind); - self::assertGreaterThanOrEqual(1, count($info->enumCases)); - self::assertArrayHasKey('Active', $info->enumCases); - } - - public function testFromAstNodeExtractsParentClass(): void - { - $node = $this->parseClassFromFixture('src/Inheritance/ParentClass.php'); - - $info = $this->factory->fromAstNode($node, 'file:///test.php'); - - self::assertNotNull($info->parent); - self::assertSame('Fixtures\\Inheritance\\Grandparent', $info->parent->fqn); - } - - public function testFromAstNodeExtractsImportedParentClass(): void - { - $node = $this->parseClassFromFixture('src/Utility/ImportedExtends.php'); - - $info = $this->factory->fromAstNode($node, 'file:///test.php'); - - self::assertNotNull($info->parent); - self::assertSame('Fixtures\\Inheritance\\ParentClass', $info->parent->fqn); - } - - public function testFromAstNodeExtractsAbstractClass(): void - { - $node = $this->parseClassFromFixture('src/Utility/ClassModifiers.php', 'AbstractBase'); - - $info = $this->factory->fromAstNode($node, 'file:///test.php'); - - self::assertTrue($info->isAbstract); - } - - public function testFromAstNodeExtractsFinalClass(): void - { - $node = $this->parseClassFromFixture('src/Utility/ClassModifiers.php', 'SealedClass'); - - $info = $this->factory->fromAstNode($node, 'file:///test.php'); - - self::assertTrue($info->isFinal); - } - - public function testFromAstNodeExtractsReadonlyClass(): void - { - $node = $this->parseClassFromFixture('src/Utility/ClassModifiers.php', 'ImmutableClass'); - - $info = $this->factory->fromAstNode($node, 'file:///test.php'); - - self::assertTrue($info->isReadonly); - } - - public function testFromAstNodeDetectsAttributeClass(): void - { - $node = $this->parseClassFromFixture('src/Attributes/Route.php'); - - $info = $this->factory->fromAstNode($node, 'file:///test.php'); - - self::assertTrue($info->isAttribute, 'A class declared #[Attribute] is an attribute'); - } - - public function testFromAstNodeMarksPlainClassAsNotAttribute(): void - { - $node = $this->parseClassFromFixture('src/Domain/User.php'); - - $info = $this->factory->fromAstNode($node, 'file:///test.php'); - - self::assertFalse($info->isAttribute, 'A class without #[Attribute] is not an attribute'); - } - - public function testFromAstNodeMarksClassUsingAnAttributeAsNotAttribute(): void - { - // A class that carries a class-level attribute is not itself an attribute - // unless that attribute is #[Attribute]. - $node = $this->parseClassFromFixture('src/Attributes/UsesAttribute.php'); - - $info = $this->factory->fromAstNode($node, 'file:///test.php'); - - self::assertFalse($info->isAttribute, 'Using an attribute does not make a class an attribute'); - } - - public function testFromAstNodeMarksInterfaceAsNotAttribute(): void - { - // Only classes can be attributes; interfaces cannot. - $node = $this->parseClassFromFixture('src/Domain/Entity.php'); - - $info = $this->factory->fromAstNode($node, 'file:///test.php'); - - self::assertFalse($info->isAttribute, 'An interface cannot be an attribute'); - } - - public function testFromAstNodeMarksEnumAsNotAttribute(): void - { - // Only classes can be attributes; enums cannot. - $node = $this->parseClassFromFixture('src/Enum/Status.php'); - - $info = $this->factory->fromAstNode($node, 'file:///test.php'); - - self::assertFalse($info->isAttribute, 'An enum cannot be an attribute'); - } - - public function testFromReflectionDetectsAttributeClass(): void - { - $reflection = new ReflectionClass(\Attribute::class); - - $info = $this->factory->fromReflection($reflection); - - self::assertTrue($info->isAttribute, 'The built-in Attribute class is itself an attribute'); - } - - public function testFromReflectionMarksPlainClassAsNotAttribute(): void - { - $reflection = new ReflectionClass(\stdClass::class); - - $info = $this->factory->fromReflection($reflection); - - self::assertFalse($info->isAttribute, 'A class without #[Attribute] is not an attribute'); - } - - public function testFromAstNodeExtractsInterfaces(): void - { - $node = $this->parseClassFromFixture('src/Domain/User.php'); - - $info = $this->factory->fromAstNode($node, 'file:///test.php'); - - self::assertCount(2, $info->interfaces); - self::assertSame('Fixtures\\Domain\\Entity', $info->interfaces[0]->fqn); - self::assertSame('Fixtures\\Domain\\Person', $info->interfaces[1]->fqn); - } - - public function testFromAstNodeExtractsInterfaceExtends(): void - { - $node = $this->parseClassFromFixture('src/Repository/Repository.php'); - - $info = $this->factory->fromAstNode($node, 'file:///test.php'); - - self::assertSame(ClassKind::Interface_, $info->kind); - self::assertCount(1, $info->interfaces); - self::assertSame('Countable', $info->interfaces[0]->fqn); - } - - public function testFromAstNodeExtractsEnumImplements(): void - { - $node = $this->parseClassFromFixture('src/Enum/SerializableStatus.php'); - - $info = $this->factory->fromAstNode($node, 'file:///test.php'); - - self::assertSame(ClassKind::Enum_, $info->kind); - $fqns = array_map(fn($n) => $n->fqn, $info->interfaces); - self::assertContains('JsonSerializable', $fqns, 'the written interface is captured'); - self::assertContains('UnitEnum', $fqns, 'every enum implicitly implements UnitEnum'); - self::assertContains('BackedEnum', $fqns, 'a backed enum implicitly implements BackedEnum'); - self::assertCount(2, $info->enumCases); - self::assertArrayHasKey('Active', $info->enumCases); - self::assertSame('active', $info->enumCases['Active']->backingValue); - } - - public function testFromAstNodeAddsImplicitUnitEnumInterfaceToPureEnum(): void - { - $node = $this->parseClassFromFixture('src/Enum/Status.php'); - - $info = $this->factory->fromAstNode($node, 'file:///test.php'); - - $fqns = array_map(fn($n) => $n->fqn, $info->interfaces); - self::assertContains('UnitEnum', $fqns); - self::assertNotContains('BackedEnum', $fqns, 'a pure enum does not implement BackedEnum'); - } - - public function testFromAstNodeExtractsIntBackedEnumCases(): void - { - $node = $this->parseClassFromFixture('src/Enum/Priority.php'); - - $info = $this->factory->fromAstNode($node, 'file:///test.php'); - - self::assertGreaterThanOrEqual(2, count($info->enumCases)); - self::assertArrayHasKey('Low', $info->enumCases); - self::assertArrayHasKey('High', $info->enumCases); - self::assertSame(1, $info->enumCases['Low']->backingValue); - self::assertSame(10, $info->enumCases['High']->backingValue); - } - - public function testFromAstNodeExtractsPureEnumCasesWithNullBackingValue(): void - { - $node = $this->parseClassFromFixture('src/Enum/Status.php'); - - $info = $this->factory->fromAstNode($node, 'file:///test.php'); - - self::assertGreaterThanOrEqual(2, count($info->enumCases)); - self::assertNull($info->enumCases['Active']->backingValue); - self::assertNull($info->enumCases['Inactive']->backingValue); - } - - public function testFromAstNodeSynthesizesEnumBuiltinMethods(): void - { - $node = $this->parseClassFromFixture('src/Enum/Status.php'); - - $info = $this->factory->fromAstNode($node, 'file:///test.php'); - - self::assertArrayHasKey('cases', $info->methods); - self::assertTrue($info->methods['cases']->isStatic); - self::assertSame('array', $info->methods['cases']->returnType?->format()); - } - - public function testFromAstNodeSynthesizesBackedEnumMethods(): void - { - $node = $this->parseClassFromFixture('src/Enum/Priority.php'); - - $info = $this->factory->fromAstNode($node, 'file:///test.php'); - - self::assertArrayHasKey('cases', $info->methods); - self::assertArrayHasKey('from', $info->methods); - self::assertArrayHasKey('tryFrom', $info->methods); - - self::assertTrue($info->methods['from']->isStatic); - self::assertSame('Fixtures\\Enum\\Priority', $info->methods['from']->returnType?->format()); - self::assertCount(1, $info->methods['from']->parameters); - self::assertSame('int', $info->methods['from']->parameters[0]->type?->format()); - - self::assertSame('?Fixtures\\Enum\\Priority', $info->methods['tryFrom']->returnType?->format()); - } - - public function testFromAstNodeExtractsTraits(): void - { - $node = $this->parseClassFromFixture('src/Repository/ClassInfoPatterns.php', 'ClassInfoPatterns'); - - $info = $this->factory->fromAstNode($node, 'file:///test.php'); - - self::assertCount(1, $info->traits); - self::assertSame('Fixtures\\Repository\\ExampleTrait', $info->traits[0]->fqn); - } - - public function testFromAstNodeExtractsTraitInsteadOfExclusions(): void - { - $node = $this->parseClassFromFixture('src/Hierarchy/TraitAdaptationUser.php'); - - $info = $this->factory->fromAstNode($node, 'file:///test.php'); - - self::assertSame( - ['Fixtures\\Hierarchy\\ConflictingTraitB' => ['conflictMethod']], - $info->traitExclusions, - 'insteadof marks the losing trait\'s method as excluded from this class', - ); - } - - public function testFromAstNodeExtractsTraitAliases(): void - { - $node = $this->parseClassFromFixture('src/Hierarchy/TraitAdaptationUser.php'); - - $info = $this->factory->fromAstNode($node, 'file:///test.php'); - - self::assertCount(2, $info->traitAliases, 'both `as` clauses are captured'); - - $rename = $info->traitAliases[0]; - self::assertSame('Fixtures\\Hierarchy\\ConflictingTraitB', $rename->trait?->fqn); - self::assertSame('conflictMethod', $rename->method); - self::assertSame('conflictMethodFromB', $rename->newName); - self::assertNull($rename->newVisibility); - - $visibilityAndRename = $info->traitAliases[1]; - self::assertSame('Fixtures\\Hierarchy\\ConflictingTraitB', $visibilityAndRename->trait?->fqn); - self::assertSame('onlyInB', $visibilityAndRename->method); - self::assertSame('protectedOnlyInB', $visibilityAndRename->newName); - self::assertSame(Visibility::Protected, $visibilityAndRename->newVisibility); - } - - public function testFromAstNodeHasNoTraitAdaptationsWhenNoneDeclared(): void - { - $node = $this->parseClassFromFixture('src/Repository/ClassInfoPatterns.php', 'ClassInfoPatterns'); - - $info = $this->factory->fromAstNode($node, 'file:///test.php'); - - self::assertSame([], $info->traitExclusions); - self::assertSame([], $info->traitAliases); - } - - public function testFromAstNodeExtractsMethods(): void - { - $node = $this->parseClassFromFixture('src/Repository/ClassInfoPatterns.php', 'ClassInfoPatterns'); - - $info = $this->factory->fromAstNode($node, 'file:///test.php'); - - self::assertArrayHasKey('publicMethod', $info->methods); - self::assertArrayHasKey('protectedMethod', $info->methods); - self::assertArrayHasKey('privateStaticMethod', $info->methods); - - self::assertSame(Visibility::Public, $info->methods['publicMethod']->visibility); - self::assertSame(Visibility::Protected, $info->methods['protectedMethod']->visibility); - self::assertSame(Visibility::Private, $info->methods['privateStaticMethod']->visibility); - self::assertTrue($info->methods['privateStaticMethod']->isStatic); - self::assertSame('void', $info->methods['publicMethod']->returnType?->format()); - self::assertSame('string', $info->methods['protectedMethod']->returnType?->format()); - } - - public function testFromAstNodePreservesLateStaticSelfReturnType(): void - { - $node = $this->parseClassFromFixture('src/Repository/ClassInfoPatterns.php', 'ClassInfoPatterns'); - - $info = $this->factory->fromAstNode($node, 'file:///test.php'); - - self::assertArrayHasKey('createSelf', $info->methods); - $returnType = $info->methods['createSelf']->returnType; - self::assertNotNull($returnType); - // Late-binding types preserve the keyword for display - self::assertSame('?self', $returnType->format()); - // But still resolve to the declaring class for lookups - $classNames = $returnType->getResolvableClassNames(); - self::assertCount(1, $classNames); - self::assertSame('Fixtures\\Repository\\ClassInfoPatterns', $classNames[0]->fqn); - } - - public function testFromAstNodePreservesLateStaticStaticReturnType(): void - { - $node = $this->parseClassFromFixture('src/Repository/ClassInfoPatterns.php', 'ClassInfoPatterns'); - - $info = $this->factory->fromAstNode($node, 'file:///test.php'); - - self::assertArrayHasKey('buildStatic', $info->methods); - $returnType = $info->methods['buildStatic']->returnType; - self::assertNotNull($returnType); - // Late-binding types preserve the keyword for display - self::assertSame('static', $returnType->format()); - // But still resolve to the declaring class for lookups - $classNames = $returnType->getResolvableClassNames(); - self::assertCount(1, $classNames); - self::assertSame('Fixtures\\Repository\\ClassInfoPatterns', $classNames[0]->fqn); - } - - public function testFromAstNodeExtractsMethodParameters(): void - { - $node = $this->parseClassFromFixture('src/Repository/ClassInfoPatterns.php', 'ClassInfoPatterns'); - - $info = $this->factory->fromAstNode($node, 'file:///test.php'); - $method = $info->methods['withParams']; - - self::assertCount(3, $method->parameters); - self::assertSame('name', $method->parameters[0]->name); - self::assertSame('string', $method->parameters[0]->type?->format()); - self::assertFalse($method->parameters[0]->hasDefault); - self::assertFalse($method->parameters[0]->isVariadic); - - self::assertSame('count', $method->parameters[1]->name); - self::assertTrue($method->parameters[1]->hasDefault); - - self::assertSame('items', $method->parameters[2]->name); - self::assertTrue($method->parameters[2]->isVariadic); - } - - public function testFromAstNodeExtractsProperties(): void - { - $node = $this->parseClassFromFixture('src/Repository/ClassInfoPatterns.php', 'ClassInfoPatterns'); - - $info = $this->factory->fromAstNode($node, 'file:///test.php'); - - self::assertArrayHasKey('publicProp', $info->properties); - self::assertSame(Visibility::Public, $info->properties['publicProp']->visibility); - self::assertSame('string', $info->properties['publicProp']->type?->format()); - self::assertFalse($info->properties['publicProp']->isPromoted); - - self::assertTrue($info->properties['privateStaticProp']->isStatic); - self::assertTrue($info->properties['readonlyProp']->isReadonly); - } - - public function testFromAstNodeExtractsPromotedProperties(): void - { - $node = $this->parseClassFromFixture('src/Repository/ClassInfoPatterns.php', 'ClassInfoPatterns'); - - $info = $this->factory->fromAstNode($node, 'file:///test.php'); - - self::assertArrayHasKey('name', $info->properties); - self::assertArrayHasKey('id', $info->properties); - - self::assertTrue($info->properties['name']->isPromoted); - self::assertSame(Visibility::Public, $info->properties['name']->visibility); - - self::assertTrue($info->properties['id']->isPromoted); - self::assertTrue($info->properties['id']->isReadonly); - self::assertSame(Visibility::Private, $info->properties['id']->visibility); - } - - public function testFromAstNodeExtractsConstants(): void - { - $node = $this->parseClassFromFixture('src/Repository/ClassInfoPatterns.php', 'ClassInfoPatterns'); - - $info = $this->factory->fromAstNode($node, 'file:///test.php'); - - self::assertArrayHasKey('PUBLIC_CONST', $info->constants); - self::assertSame(Visibility::Public, $info->constants['PUBLIC_CONST']->visibility); - self::assertSame('string', $info->constants['PUBLIC_CONST']->type?->format()); - - self::assertSame(Visibility::Protected, $info->constants['PROTECTED_CONST']->visibility); - self::assertSame(Visibility::Private, $info->constants['PRIVATE_CONST']->visibility); - self::assertNull($info->constants['PRIVATE_CONST']->type); - } - - public function testFromAstNodeExtractsDocblock(): void - { - $node = $this->parseClassFromFixture('src/Domain/User.php'); - - $info = $this->factory->fromAstNode($node, 'file:///test.php'); - - self::assertStringContainsString('Represents a system user', $info->docblock ?? ''); - } - - public function testFromAstNodeExtractsFileInfo(): void - { - $node = $this->parseClassFromFixture('src/Domain/User.php'); - - $info = $this->factory->fromAstNode($node, 'file:///path/to/test.php'); - - self::assertSame('/path/to/test.php', $info->file); - self::assertNotNull($info->line); - } - - public function testFromAstNodeThrowsForAnonymousClass(): void - { - $code = $this->loadFixture('src/Utility/AnonymousClassScope.php'); - $parser = (new ParserFactory())->createForNewestSupportedVersion(); - $ast = $parser->parse($code); - assert($ast !== null); - - $node = $this->extractAnonymousClassNode($ast); - - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('anonymous class'); - - $this->factory->fromAstNode($node, 'file:///test.php'); - } - - public function testFromReflectionExtractsBasicInfo(): void - { - $reflection = new ReflectionClass(\stdClass::class); - - $info = $this->factory->fromReflection($reflection); - - self::assertSame(\stdClass::class, $info->name->fqn); - self::assertSame(ClassKind::Class_, $info->kind); - } - - public function testFromReflectionCapturesParentClass(): void - { - $reflection = new ReflectionClass(\RuntimeException::class); - - $info = $this->factory->fromReflection($reflection); - - self::assertSame( - \Exception::class, - $info->parent?->fqn, - 'Reflection-derived ClassInfo should carry the parent class', - ); - } - - public function testFromReflectionExtractsInterface(): void - { - $reflection = new ReflectionClass(\Iterator::class); - - $info = $this->factory->fromReflection($reflection); - - self::assertSame(ClassKind::Interface_, $info->kind); - } - - public function testFromReflectionExtractsTrait(): void - { - $reflection = new ReflectionClass(TestTrait::class); - - $info = $this->factory->fromReflection($reflection); - - self::assertSame(ClassKind::Trait_, $info->kind); - } - - public function testFromReflectionExtractsEnum(): void - { - $reflection = new ReflectionClass(TestEnum::class); - - $info = $this->factory->fromReflection($reflection); - - self::assertSame(ClassKind::Enum_, $info->kind); - self::assertCount(2, $info->enumCases); - self::assertArrayHasKey('Foo', $info->enumCases); - self::assertArrayHasKey('Bar', $info->enumCases); - self::assertNull($info->enumCases['Foo']->backingValue); - } - - public function testFromReflectionExtractsBackedEnum(): void - { - $reflection = new ReflectionClass(TestBackedEnum::class); - - $info = $this->factory->fromReflection($reflection); - - self::assertSame(ClassKind::Enum_, $info->kind); - self::assertCount(2, $info->enumCases); - self::assertSame(1, $info->enumCases['Low']->backingValue); - self::assertSame(10, $info->enumCases['High']->backingValue); - } - - public function testFromReflectionExtractsEnumBuiltinMethods(): void - { - $reflection = new ReflectionClass(TestBackedEnum::class); - - $info = $this->factory->fromReflection($reflection); - - self::assertArrayHasKey('cases', $info->methods); - self::assertArrayHasKey('from', $info->methods); - self::assertArrayHasKey('tryFrom', $info->methods); - self::assertTrue($info->methods['cases']->isStatic); - } - - public function testFromReflectionExtractsMethods(): void - { - $reflection = new ReflectionClass(TestClass::class); - - $info = $this->factory->fromReflection($reflection); - - self::assertArrayHasKey('publicMethod', $info->methods); - self::assertSame(Visibility::Public, $info->methods['publicMethod']->visibility); - self::assertSame('void', $info->methods['publicMethod']->returnType?->format()); - } - - public function testFromReflectionExtractsProperties(): void - { - $reflection = new ReflectionClass(TestClass::class); - - $info = $this->factory->fromReflection($reflection); - - self::assertArrayHasKey('publicProp', $info->properties); - self::assertSame(Visibility::Public, $info->properties['publicProp']->visibility); - self::assertSame('string', $info->properties['publicProp']->type?->format()); - } - - public function testFromReflectionExtractsConstants(): void - { - $reflection = new ReflectionClass(TestClass::class); - - $info = $this->factory->fromReflection($reflection); - - self::assertArrayHasKey('TEST_CONST', $info->constants); - self::assertSame(Visibility::Public, $info->constants['TEST_CONST']->visibility); - } - - public function testFromReflectionExtractsTypedConstants(): void - { - $reflection = new ReflectionClass(TestClass::class); - - $info = $this->factory->fromReflection($reflection); - - self::assertArrayHasKey('TYPED_CONST', $info->constants); - self::assertSame('string', $info->constants['TYPED_CONST']->type?->format()); - } - - public function testFromReflectionExtractsInterfaces(): void - { - $reflection = new ReflectionClass(ClassWithInterface::class); - - $info = $this->factory->fromReflection($reflection); - - self::assertCount(1, $info->interfaces); - self::assertSame(\Countable::class, $info->interfaces[0]->fqn); - } - - public function testFromReflectionExtractsTraits(): void - { - $reflection = new ReflectionClass(TestClass::class); - - $info = $this->factory->fromReflection($reflection); - - self::assertCount(1, $info->traits); - self::assertSame(TestTrait::class, $info->traits[0]->fqn); - } - - private function parseClassFromFixture(string $fixturePath, ?string $className = null): Stmt\ClassLike - { - $code = $this->loadFixture($fixturePath); - return $this->parseClassInternal($code, $className, useNameResolver: true); - } - - private function parseClassWithoutNameResolverFromFixture( - string $fixturePath, - ?string $className = null, - ): Stmt\ClassLike { - $code = $this->loadFixture($fixturePath); - return $this->parseClassInternal($code, $className, useNameResolver: false); - } - - private function parseClassInternal(string $code, ?string $className, bool $useNameResolver): Stmt\ClassLike - { - $parser = (new ParserFactory())->createForNewestSupportedVersion(); - $ast = $parser->parse($code); - assert($ast !== null); - - if ($useNameResolver) { - $traverser = new \PhpParser\NodeTraverser(); - $traverser->addVisitor(new \PhpParser\NodeVisitor\NameResolver()); - $ast = $traverser->traverse($ast); - } - - foreach ($ast as $stmt) { - if ($stmt instanceof Stmt\Namespace_) { - foreach ($stmt->stmts as $nsStmt) { - if ($nsStmt instanceof Stmt\ClassLike) { - if ($className === null || $nsStmt->name?->toString() === $className) { - return $nsStmt; - } - } - } - } - if ($stmt instanceof Stmt\ClassLike) { - if ($className === null || $stmt->name?->toString() === $className) { - return $stmt; - } - } - } - - throw new \RuntimeException('No class found in code'); - } - - /** - * @param array<\PhpParser\Node\Stmt> $ast - */ - private function extractAnonymousClassNode(array $ast): Stmt\Class_ - { - $finder = new \PhpParser\NodeFinder(); - $class = $finder->findFirst($ast, fn($node) => $node instanceof Stmt\Class_ && $node->name === null); - assert($class instanceof Stmt\Class_); - return $class; - } -} diff --git a/tests/Resolution/AstTextAgreementTest.php b/tests/Resolution/AstTextAgreementTest.php index bc9cc1c7..31b28a57 100644 --- a/tests/Resolution/AstTextAgreementTest.php +++ b/tests/Resolution/AstTextAgreementTest.php @@ -6,10 +6,12 @@ use Firehed\PhpLsp\Document\TextDocument; use Firehed\PhpLsp\Domain\ClassKind; +use Firehed\PhpLsp\Domain\NameKind; +use Firehed\PhpLsp\Knowledge\DeclarationScanner; +use Firehed\PhpLsp\Knowledge\DeclarationSymbolInfoFactory; use Firehed\PhpLsp\Parser\SyntaxSource\CursorTextSyntaxSource; use Firehed\PhpLsp\Parser\SyntaxSource\MemoizingSyntaxSource; use Firehed\PhpLsp\Parser\SyntaxSource\SkeletonSyntaxSource; -use Firehed\PhpLsp\Repository\DefaultClassInfoFactory; use Firehed\PhpLsp\Tests\LoadsFixturesTrait; use Firehed\PhpLsp\Tests\Parser\ProductionSyntaxSource; use PhpParser\Node; @@ -432,8 +434,8 @@ public static function producerAgreementFixtures(): array * The shape both producers must agree on: namespace, imports, class-like * names and kinds, and every member declaration's name, visibility, and * static-ness or readonly-ness — read through the same - * {@see DeclarationScanner} and {@see DefaultClassInfoFactory} both sides - * feed into. Line numbers and byte spans are producer-specific and + * {@see DeclarationScanner} and {@see DeclarationSymbolInfoFactory} both + * sides feed into. Line numbers and byte spans are producer-specific and * deliberately not compared. * * @param array $tree @@ -496,13 +498,17 @@ private static function importsOf(array $stmts): array */ private static function classLikesOf(array $stmts): array { - $factory = new DefaultClassInfoFactory(); + $factory = new DeclarationSymbolInfoFactory(); + $declarations = (new DeclarationScanner())->scan($stmts); $out = []; - foreach ($stmts as $stmt) { - if (!$stmt instanceof Stmt\ClassLike || $stmt->name === null) { - continue; - } - $info = $factory->fromAstNode($stmt, 'file:///stub.php'); + foreach ($declarations->classLikes as $declaration) { + $info = $factory->fromDeclarations( + $declarations, + $declaration->name, + NameKind::ClassLike, + '/stub.php', + ); + assert($info instanceof \Firehed\PhpLsp\Domain\ClassInfo); $out[] = [ 'name' => $info->name->fqn, 'kind' => match ($info->kind) { diff --git a/tests/Resolution/SymbolResolverTest.php b/tests/Resolution/SymbolResolverTest.php index 41e908f8..0fd378a5 100644 --- a/tests/Resolution/SymbolResolverTest.php +++ b/tests/Resolution/SymbolResolverTest.php @@ -94,6 +94,33 @@ public function testResolveAtPositionReturnsNullWhenNoNodeFound(): void self::assertNull($result); } + /** + * @return iterable + */ + public static function resolveNewFallbacks(): iterable + { + yield '(new $var)->foo — class node is not a Name' => ['variable_new']; + yield '(new class {})->foo — class node is Stmt\\Class_' => ['anon_new']; + yield '(new NoSuchClass)->foo — name resolves but class is not indexed' => ['unknown_new']; + yield '(new self)->foo at global scope — resolveClassNameInContext returns null' + => ['global_self_new']; + } + + #[DataProvider('resolveNewFallbacks')] + public function testResolveNewFallbacksReturnNull(string $marker): void + { + $cursor = $this->openFixtureAtHoverMarker('src/Hover/ResolveNewFallbacks.php', $marker); + $document = $this->documents->get($cursor['uri']); + assert($document !== null); + + $result = $this->resolver->resolveAtPosition($document, $cursor['line'], $cursor['character']); + + self::assertNull( + $result, + 'a method call whose receiver `new` cannot resolve to a class must not resolve', + ); + } + public function testResolvesInstanceMethodCall(): void { $cursor = $this->openFixtureAtHoverMarker('src/Domain/User.php', 'setName');