diff --git a/config/services.php b/config/services.php index 08a14a4d..a0395808 100644 --- a/config/services.php +++ b/config/services.php @@ -20,6 +20,7 @@ Completion\CompositeCompletionSource::class, Completion\CompletionSourceInterface::class => Completion\CompositeCompletionSource::class, Completion\KeywordCandidates::class, + Completion\MagicMethodCandidates::class, Completion\MemberCandidates::class, Completion\NamedArgumentCandidates::class, Completion\SymbolCandidates::class, diff --git a/docs/features/completion.md b/docs/features/completion.md index 3f5127b2..1f474c91 100644 --- a/docs/features/completion.md +++ b/docs/features/completion.md @@ -21,6 +21,7 @@ This document tracks the current state of code completion in php-lsp. | Attribute arguments | `#[Route(` | Constructor named arguments, like a normal call (an attribute is a constructor call on its class); signature help shows the constructor | ✅ Working | | `instanceof` right-hand side | `$x instanceof Ba` | Class-likes valid as a type (classes, interfaces, enums) from imports + workspace index; traits excluded (a trait `instanceof` check is always false), as are scalar type keywords and functions | ✅ Working | | Namespace navigation | `new \Ps`, `catch (\E`, `function f(\Ps` | Vendor/built-in class-likes and child namespaces from the catalog, walked one segment at a time. Works on absolute (`\`-rooted) names and on relative prefixes resolved through a `use` import or the current namespace (`use App\Model\Env;` or being inside `App\Model` → `new Env\R`) | ✅ Working | +| Magic method declaration | `public function __to` | Magic methods the class-like may still declare, inserted with the signature the PHP manual gives them (plain text). Filtered by the written modifiers (`static` offers only `__callStatic`/`__set_state`; non-public only `__construct`/`__destruct`/`__clone`), by kind (an enum allows only `__call`, `__callStatic`, `__invoke`), and by what the class-like already declares. Offered once a name is typed; not in anonymous classes (#867) | ✅ Working | | `use` import statement | `use Psr\Lo` | Namespaces and class-likes (any kind) from the catalog, walked one segment at a time. The name is **absolute** — resolved from the global namespace regardless of the file's own namespace or imports — so it navigates the same tree as `new \Ps`. Applies to top-level imports only; a `use` in a class body is a trait application (see below). `use function` / `use const` are not yet offered (#239, #317) | ✅ Working | All of the above work identically in class methods, free functions, and @@ -127,7 +128,8 @@ CompositeCompletionSource ├── VariableCandidates → Expected::$variables ├── KeywordCandidates → Expected::$keywords (a KeywordGroup) ├── BuiltinTypeCandidates → Expected::$builtinTypes (a TypeHintContext) -└── SymbolCandidates → Expected::$names (kinds, ClassCandidateFilter, name context) +├── SymbolCandidates → Expected::$names (kinds, ClassCandidateFilter, name context) +└── MagicMethodCandidates → Expected::$declaredMethod (the method whose name is being written) ``` `ExpectedAtPosition` decides what a position admits from the structure `PositionLocator` reads there, the same structure hover, definition, and signature help read. diff --git a/src/Completion/CompletionItemFormatter.php b/src/Completion/CompletionItemFormatter.php index 4f9e34ac..7c985d6a 100644 --- a/src/Completion/CompletionItemFormatter.php +++ b/src/Completion/CompletionItemFormatter.php @@ -151,6 +151,21 @@ public function forKeyword(string $keyword): array ]; } + /** + * Plain text, never a snippet: snippet syntax would read the signature's + * `$name` as a variable ([LSP] Snippet Syntax). + * + * @return CompletionItem + */ + public function forMagicMethod(MagicMethod $method): array + { + return [ + 'label' => $method->value, + 'kind' => CompletionItemKind::Method->value, + 'insertText' => $method->signature(), + ]; + } + /** * @return CompletionItem */ diff --git a/src/Completion/CompositeCompletionSource.php b/src/Completion/CompositeCompletionSource.php index 3d668c4f..64861450 100644 --- a/src/Completion/CompositeCompletionSource.php +++ b/src/Completion/CompositeCompletionSource.php @@ -23,6 +23,7 @@ public function __construct( private readonly KeywordCandidates $keywords, private readonly BuiltinTypeCandidates $builtinTypes, private readonly SymbolCandidates $names, + private readonly MagicMethodCandidates $magicMethods, ) { } @@ -38,6 +39,7 @@ public function find(CompletionRequest $request): array $expected->keywords === null ? [] : $this->keywordItems($expected->keywords, $prefix), $expected->builtinTypes === null ? [] : $this->builtinTypes->find($expected->builtinTypes, $prefix), $expected->names === null ? [] : $this->names->find($request, $expected->names, $prefix), + $expected->declaredMethod === null ? [] : $this->magicMethods->find($expected->declaredMethod, $prefix), )); } diff --git a/src/Completion/MagicMethod.php b/src/Completion/MagicMethod.php new file mode 100644 index 00000000..83d502f6 --- /dev/null +++ b/src/Completion/MagicMethod.php @@ -0,0 +1,75 @@ + '__call(string $name, array $arguments): mixed', + self::CallStatic => '__callStatic(string $name, array $arguments): mixed', + self::Clone => '__clone(): void', + self::Construct => '__construct()', + self::DebugInfo => '__debugInfo(): array', + self::Destruct => '__destruct()', + self::Get => '__get(string $name): mixed', + self::Invoke => '__invoke(): mixed', + self::Isset => '__isset(string $name): bool', + self::Serialize => '__serialize(): array', + self::Set => '__set(string $name, mixed $value): void', + self::SetState => '__set_state(array $properties): object', + self::Sleep => '__sleep(): array', + self::ToString => '__toString(): string', + self::Unserialize => '__unserialize(array $data): void', + self::Unset => '__unset(string $name): void', + self::Wakeup => '__wakeup(): void', + }; + } +} diff --git a/src/Completion/MagicMethodCandidates.php b/src/Completion/MagicMethodCandidates.php new file mode 100644 index 00000000..05dde013 --- /dev/null +++ b/src/Completion/MagicMethodCandidates.php @@ -0,0 +1,56 @@ + + */ + public function find(MethodName $declared, string $prefix): array + { + $class = $this->symbols->lookupClassLike($declared->owner); + if ($class === null) { + return []; + } + $declaring = $class->methods[$declared->name] ?? null; + if ($declaring === null) { + return []; + } + + $others = array_diff_key($class->methods, [$declared->name => true]); + $declaredElsewhere = array_map(MemberKind::Method->normalize(...), array_keys($others)); + + $items = []; + foreach (MagicMethod::cases() as $method) { + if ( + $method->declarableAs($class->kind, $declaring->isStatic, $declaring->visibility) + && !in_array(MemberKind::Method->normalize($method->value), $declaredElsewhere, true) + && PrefixMatcher::matches($method->value, $prefix) + ) { + $items[] = $this->itemFormatter->forMagicMethod($method); + } + } + + return $items; + } +} diff --git a/src/Resolution/Expected.php b/src/Resolution/Expected.php index 7ede541d..ead5bba1 100644 --- a/src/Resolution/Expected.php +++ b/src/Resolution/Expected.php @@ -5,6 +5,7 @@ namespace Firehed\PhpLsp\Resolution; use Firehed\PhpLsp\Domain\KeywordGroup; +use Firehed\PhpLsp\Domain\MethodName; use Firehed\PhpLsp\Domain\TypeHintContext; /** @@ -15,6 +16,8 @@ { /** * @param string $prefix What is written at the position, up to it + * @param ?MethodName $declaredMethod The method whose name is written at + * the position, as the class-like declaring it names it */ public function __construct( public string $prefix, @@ -24,6 +27,7 @@ public function __construct( public ?KeywordGroup $keywords = null, public ?TypeHintContext $builtinTypes = null, public ?NameQuery $names = null, + public ?MethodName $declaredMethod = null, ) { } } diff --git a/src/Resolution/Position/ExpectedAtPosition.php b/src/Resolution/Position/ExpectedAtPosition.php index cef4d7f8..2e7e0948 100644 --- a/src/Resolution/Position/ExpectedAtPosition.php +++ b/src/Resolution/Position/ExpectedAtPosition.php @@ -8,12 +8,14 @@ use Firehed\PhpLsp\Domain\ByteOffset; use Firehed\PhpLsp\Domain\ClassCandidateFilter; use Firehed\PhpLsp\Domain\KeywordGroup; +use Firehed\PhpLsp\Domain\MethodName; use Firehed\PhpLsp\Domain\NameKind; use Firehed\PhpLsp\Domain\TypeHintContext; use Firehed\PhpLsp\Resolution\Expected; use Firehed\PhpLsp\Resolution\ExpressionResolverInterface; use Firehed\PhpLsp\Resolution\MemberAccessContext; use Firehed\PhpLsp\Resolution\NameQuery; +use PhpParser\Node\Identifier; final class ExpectedAtPosition implements ExpectedAtPositionInterface { @@ -31,6 +33,7 @@ public function expectedAt(TextDocument $document, ByteOffset $offset): Expected return match ($located->slot) { Slot::NotCode, Slot::DeclaredName => new Expected($prefix), + Slot::MethodName => new Expected($prefix, declaredMethod: self::declaredMethod($located)), Slot::InterpolatedString, Slot::Variable => new Expected($prefix, variables: true), Slot::Member => new Expected($prefix, members: $this->memberAccess($located)), Slot::ArgumentName => new Expected($prefix, argumentNames: $this->calls->callAt($document, $offset)), @@ -102,6 +105,17 @@ private function memberAccess(Located $located): ?MemberAccessContext return $target === null ? null : new MemberAccessContext($target, $prefix); } + /** + * The method whose name is written at the position; null in an anonymous + * class, which has no name to own it. + */ + private static function declaredMethod(Located $located): ?MethodName + { + $self = $located->scope->getSelfName(); + assert($located->written instanceof Identifier, 'a method\'s name is written'); + return $self === null ? null : new MethodName($self, $located->written->toString()); + } + private static function type(Located $located, TypeHintContext $context): Expected { return new Expected( diff --git a/src/Resolution/Position/PositionLocator.php b/src/Resolution/Position/PositionLocator.php index 9d4f825c..e0d783e7 100644 --- a/src/Resolution/Position/PositionLocator.php +++ b/src/Resolution/Position/PositionLocator.php @@ -306,8 +306,11 @@ private static function classifyName(Identifier|Name|Expr\Error $name, mixed $pa ) { return [Slot::ClassMember, null]; } + if ($parent instanceof Stmt\ClassMethod && $parent->name === $name) { + return [Slot::MethodName, null]; + } if ( - ($parent instanceof Stmt\ClassMethod || $parent instanceof Stmt\Function_ + ($parent instanceof Stmt\Function_ || $parent instanceof Stmt\ClassLike || $parent instanceof Node\Const_ || $parent instanceof Stmt\EnumCase || $parent instanceof Node\PropertyItem) && $parent->name === $name diff --git a/src/Resolution/Position/Slot.php b/src/Resolution/Position/Slot.php index 9d0d0e21..37486ec1 100644 --- a/src/Resolution/Position/Slot.php +++ b/src/Resolution/Position/Slot.php @@ -24,8 +24,10 @@ enum Slot case StaticReceiver; /** An argument's name, written with its colon. */ case ArgumentName; - /** The name a declaration gives, a parameter's included. */ + /** The name a declaration other than a method's gives, a parameter's included. */ case DeclaredName; + /** The name a method declaration gives. */ + case MethodName; /** Where a class member may start, its modifiers included. */ case ClassMember; /** After a class member's modifiers, where more modifiers, a type, or what is declared may follow. */ diff --git a/src/Resolution/Position/SymbolAtPosition.php b/src/Resolution/Position/SymbolAtPosition.php index 7edff036..aa93430a 100644 --- a/src/Resolution/Position/SymbolAtPosition.php +++ b/src/Resolution/Position/SymbolAtPosition.php @@ -33,7 +33,7 @@ public function symbolAt(TextDocument $document, ByteOffset $offset): ?ResolvedS $parent = $written?->getAttribute('parent'); return match ($located->slot) { - Slot::NotCode, Slot::InterpolatedString, Slot::ClassMember => null, + Slot::NotCode, Slot::InterpolatedString, Slot::ClassMember, Slot::MethodName => null, Slot::Variable => $this->variable($written, $located->parsed), Slot::Member => $this->member($located), Slot::CalledName => $this->calledBy($written, $located->parsed), diff --git a/tests/BuildsSymbolInfoTrait.php b/tests/BuildsSymbolInfoTrait.php index 68f137db..252d71f2 100644 --- a/tests/BuildsSymbolInfoTrait.php +++ b/tests/BuildsSymbolInfoTrait.php @@ -64,6 +64,7 @@ private static function declaredFunction(string $fqn, ?string $file = null): Dec /** * @param list $interfaces + * @param array $methods */ private static function classInfo( string $fqn, @@ -74,6 +75,7 @@ private static function classInfo( bool $isAbstract = false, bool $isFinal = false, bool $isAttribute = false, + array $methods = [], ): ClassInfo { return new ClassInfo( self::className($fqn), @@ -85,7 +87,7 @@ private static function classInfo( parent: $parent === null ? null : self::className($parent), interfaces: array_map(self::className(...), $interfaces), traits: [], - methods: [], + methods: $methods, properties: [], constants: [], enumCases: [], diff --git a/tests/Completion/CompletionItemFormatterTest.php b/tests/Completion/CompletionItemFormatterTest.php index 0fc8f3c2..a2d0293f 100644 --- a/tests/Completion/CompletionItemFormatterTest.php +++ b/tests/Completion/CompletionItemFormatterTest.php @@ -7,6 +7,7 @@ use Firehed\PhpLsp\Completion\CompletionItemFormatter; use Firehed\PhpLsp\Completion\CompletionItemKind; use Firehed\PhpLsp\Completion\InsertTextFormat; +use Firehed\PhpLsp\Completion\MagicMethod; use Firehed\PhpLsp\Domain\ClasslikeName; use Firehed\PhpLsp\Domain\ClasslikeType; use Firehed\PhpLsp\Domain\EnumCaseInfo; @@ -69,6 +70,19 @@ public function testKeywordIsJustItsLabel(): void ); } + public function testMagicMethodInsertsItsSignature(): void + { + self::assertSame( + [ + 'label' => '__get', + 'kind' => CompletionItemKind::Method->value, + 'insertText' => '__get(string $name): mixed', + ], + self::formatter()->forMagicMethod(MagicMethod::Get), + 'a declaration takes its signature as plain text; a snippet would read `$name` as a variable', + ); + } + public function testVariableShowsItsSignature(): void { $variable = new ResolvedVariable( diff --git a/tests/Completion/CompositeCompletionSourceTest.php b/tests/Completion/CompositeCompletionSourceTest.php index 0479c7d2..2caf58ac 100644 --- a/tests/Completion/CompositeCompletionSourceTest.php +++ b/tests/Completion/CompositeCompletionSourceTest.php @@ -8,6 +8,7 @@ use Firehed\PhpLsp\Completion\CompletionItemFormatter; use Firehed\PhpLsp\Completion\CompositeCompletionSource; use Firehed\PhpLsp\Completion\KeywordCandidates; +use Firehed\PhpLsp\Completion\MagicMethodCandidates; use Firehed\PhpLsp\Completion\MemberCandidates; use Firehed\PhpLsp\Completion\NamedArgumentCandidates; use Firehed\PhpLsp\Completion\SymbolCandidates; @@ -16,6 +17,7 @@ use Firehed\PhpLsp\Domain\ClasslikeName; use Firehed\PhpLsp\Domain\ClasslikeType; use Firehed\PhpLsp\Domain\KeywordGroup; +use Firehed\PhpLsp\Domain\MethodName; use Firehed\PhpLsp\Domain\NameKind; use Firehed\PhpLsp\Domain\NamespaceContents; use Firehed\PhpLsp\Domain\PrimitiveType; @@ -75,6 +77,14 @@ public function testEachThingThePositionAdmitsIsOfferedByItsSourceInOneList(): v ); } + public function testADeclaredMethodIsOfferedTheMagicMethodsItMayBe(): void + { + $items = self::source(new Expected('__to', declaredMethod: new MethodName(self::owner(), '__to'))) + ->find(self::requestAfter('__to')); + + self::assertSame(['__toString'], array_column($items, 'label'), 'the magic method the prefix matches'); + } + public function testAPositionThatAdmitsNothingOffersNothing(): void { self::assertSame([], self::source(new Expected(''))->find(self::requestAfter('')), 'no source is asked'); @@ -103,6 +113,9 @@ private static function source(Expected $expected): CompositeCompletionSource $symbols = self::createStub(SymbolSourceInterface::class); $symbols->method('childrenOf')->willReturn(new NamespaceContents()); $symbols->method('search')->willReturn([QualifiedName::fromFullyQualified('Node')]); + $symbols->method('lookupClassLike')->willReturn( + self::classInfo('Widget', methods: ['__to' => self::methodInfo(self::owner(), '__to')]), + ); $session = self::capabilitiesProvider(); $formatter = new CompletionItemFormatter(new ResolvedSymbolPresenter()); @@ -114,6 +127,7 @@ private static function source(Expected $expected): CompositeCompletionSource new KeywordCandidates($formatter), new BuiltinTypeCandidates($formatter), new SymbolCandidates($symbols, $capabilities, $session, $formatter), + new MagicMethodCandidates($symbols, $formatter), ); } diff --git a/tests/Completion/MagicMethodCandidatesTest.php b/tests/Completion/MagicMethodCandidatesTest.php new file mode 100644 index 00000000..a0d513e8 --- /dev/null +++ b/tests/Completion/MagicMethodCandidatesTest.php @@ -0,0 +1,97 @@ +}> + */ + public static function declarations(): iterable + { + yield 'what the class has not declared, in any case' => ['MagicMethodClass', '__', [ + '__call', '__clone', '__debugInfo', '__destruct', '__get', '__invoke', '__isset', '__serialize', + '__set', '__sleep', '__unserialize', '__unset', '__wakeup', + ]]; + yield 'what the prefix matches' => ['MagicMethodClass', '__se', ['__serialize', '__set']]; + yield 'a static method' => ['MagicMethodStatic', '__', ['__callStatic', '__set_state']]; + yield 'a private method' => ['MagicMethodPrivate', '__', ['__clone', '__construct', '__destruct']]; + yield 'an enum\'s method' => ['MagicMethodEnum', '__', ['__call', '__invoke']]; + yield 'a method with no visibility written is public' => ['MagicMethodTrait', '__', [ + '__call', '__clone', '__construct', '__debugInfo', '__destruct', '__get', '__invoke', '__isset', + '__serialize', '__set', '__sleep', '__toString', '__unserialize', '__unset', '__wakeup', + ]]; + } + + /** + * @param list $expected + */ + #[DataProvider('declarations')] + public function testOffersWhatTheDeclarationMayStillBe(string $class, string $prefix, array $expected): void + { + self::assertSame( + $expected, + $this->labels(new MethodName(self::classLike($class), '__'), $prefix), + 'a magic method the declaration\'s class-like and modifiers allow, not yet declared, matching the prefix', + ); + } + + public function testAnUnknownClassLikeOffersNothing(): void + { + self::assertSame( + [], + $this->labels(new MethodName(self::classLike('Unknown'), '__'), '__'), + 'with no class-like, nothing says what the declaration may be', + ); + } + + public function testAMethodTheClassLikeDoesNotDeclareOffersNothing(): void + { + self::assertSame( + [], + $this->labels(new MethodName(self::classLike('MagicMethodClass'), 'missing'), '__'), + 'with no declaration, nothing says which modifiers it has', + ); + } + + /** + * @return list + */ + private function labels(MethodName $declared, string $prefix): array + { + $symbols = new OpenDocumentBackend(ProductionSyntaxSource::create()->declarations); + $document = new TextDocument('file:///' . self::FIXTURE, 'php', 1, $this->loadFixture(self::FIXTURE)); + $symbols->openDocument($document); + $candidates = new MagicMethodCandidates($symbols, new CompletionItemFormatter(new ResolvedSymbolPresenter())); + + return array_column($candidates->find($declared, $prefix), 'label'); + } + + private static function classLike(string $class): ClasslikeName + { + return ClasslikeName::fromFullyQualified('Fixtures\MultiClass\\' . $class); + } +} diff --git a/tests/Completion/MagicMethodTest.php b/tests/Completion/MagicMethodTest.php new file mode 100644 index 00000000..9d3ced78 --- /dev/null +++ b/tests/Completion/MagicMethodTest.php @@ -0,0 +1,88 @@ +}> + */ + public static function declarations(): iterable + { + $instance = [ + MagicMethod::Call, + MagicMethod::Clone, + MagicMethod::Construct, + MagicMethod::DebugInfo, + MagicMethod::Destruct, + MagicMethod::Get, + MagicMethod::Invoke, + MagicMethod::Isset, + MagicMethod::Serialize, + MagicMethod::Set, + MagicMethod::Sleep, + MagicMethod::ToString, + MagicMethod::Unserialize, + MagicMethod::Unset, + MagicMethod::Wakeup, + ]; + $static = [MagicMethod::CallStatic, MagicMethod::SetState]; + $nonPublic = [MagicMethod::Clone, MagicMethod::Construct, MagicMethod::Destruct]; + + foreach ([ClassKind::Class_, ClassKind::Interface_, ClassKind::Trait_] as $kind) { + yield "{$kind->name}, public" => [$kind, false, Visibility::Public, $instance]; + yield "{$kind->name}, public static" => [$kind, true, Visibility::Public, $static]; + yield "{$kind->name}, protected" => [$kind, false, Visibility::Protected, $nonPublic]; + yield "{$kind->name}, private" => [$kind, false, Visibility::Private, $nonPublic]; + yield "{$kind->name}, private static" => [$kind, true, Visibility::Private, []]; + } + yield 'enum, public' => [ClassKind::Enum_, false, Visibility::Public, [MagicMethod::Call, MagicMethod::Invoke]]; + yield 'enum, public static' => [ClassKind::Enum_, true, Visibility::Public, [MagicMethod::CallStatic]]; + yield 'enum, private' => [ClassKind::Enum_, false, Visibility::Private, []]; + } + + /** + * @param list $expected + */ + #[DataProvider('declarations')] + public function testOnlyWhatPhpAllowsIsDeclarable( + ClassKind $kind, + bool $static, + Visibility $visibility, + array $expected, + ): void { + $declarable = array_values(array_filter( + MagicMethod::cases(), + static fn (MagicMethod $method): bool => $method->declarableAs($kind, $static, $visibility), + )); + + self::assertSame( + $expected, + $declarable, + 'enums allow only __call, __callStatic, and __invoke; two are static; three may be non-public', + ); + } + + public function testEachSignatureDeclaresItsMethod(): void + { + foreach (MagicMethod::cases() as $method) { + self::assertStringStartsWith($method->value . '(', $method->signature(), 'a signature names its method'); + } + } + + public function testAConstructorOrDestructorDeclaresNoReturnType(): void + { + self::assertSame('__construct()', MagicMethod::Construct->signature(), 'a return type is a fatal error'); + self::assertSame('__destruct()', MagicMethod::Destruct->signature(), 'a return type is a fatal error'); + } +} diff --git a/tests/EndToEnd/scripts/completion-magic-methods.php b/tests/EndToEnd/scripts/completion-magic-methods.php new file mode 100644 index 00000000..8da14a77 --- /dev/null +++ b/tests/EndToEnd/scripts/completion-magic-methods.php @@ -0,0 +1,42 @@ + new Session\Script( + project: 'tests/Fixtures', + steps: [ + new Step\Open($file), + new Step\Type($file, new Marker\CursorMarker($marker), $typed), + new Step\Complete($file, new Marker\CursorMarker($marker), expect: array_values($expect)), + ], +); + +return [ + 'a method inserts the signature PHP gives it' => $typeThenComplete( + 'src/Completion/Keywords.php', + 'class_body', + 'public function __to', + new Expectation\Inserts('__toString', '__toString(): string'), + ), + 'a static method is offered only static magic methods' => $typeThenComplete( + 'src/Completion/Keywords.php', + 'class_body', + 'public static function __', + new Expectation\Offers('__callStatic', '__set_state'), + new Expectation\Withholds('__call', '__construct'), + ), + 'an enum is offered only the magic methods it may declare' => $typeThenComplete( + 'MultiClass/MagicMethods.php', + 'enum_body', + 'public function __', + new Expectation\Offers('__call', '__invoke'), + new Expectation\Withholds('__construct', '__toString'), + ), +]; diff --git a/tests/Fixtures/MultiClass/MagicMethods.php b/tests/Fixtures/MultiClass/MagicMethods.php new file mode 100644 index 00000000..16c4b90e --- /dev/null +++ b/tests/Fixtures/MultiClass/MagicMethods.php @@ -0,0 +1,72 @@ +, ClassCandidateFilter, bool}, + * declaredMethod: ?string, * } */ #[CoversClass(ExpectedAtPosition::class)] @@ -62,6 +63,8 @@ final class ExpectedAtPositionTest extends TestCase private const string CHAINS = 'src/Completion/ChainCompletion.php'; + private const string MAGIC_METHODS = 'MultiClass/MagicMethods.php'; + private const string MULTI_CLASS = 'MultiClass/MultiClass.php'; private const string PROCEDURAL = 'src/Mixed/ProceduralWithClass.php'; @@ -87,7 +90,21 @@ public static function positions(): iterable 'src/Position/Positions.php', 'in_interpolated', self::admits('ite', variables: true), ]; yield 'a variable' => [$c . 'Variables.php', 'local_prefix', self::admits('$l', variables: true)]; - yield 'a declared name' => ['src/Position/Symbols.php', 'declared_method', self::admits('method')]; + yield 'a method\'s name' => [ + 'src/Position/Symbols.php', 'declared_method', self::admits( + 'method', + declaredMethod: 'Fixtures\Position\Symbols::method', + ), + ]; + yield 'a method\'s name, partly written' => [ + self::MAGIC_METHODS, + 'instance', + self::admits('__', declaredMethod: 'Fixtures\MultiClass\MagicMethodClass::__'), + ]; + yield 'a method\'s name in an anonymous class' => [self::MAGIC_METHODS, 'anonymous', self::admits('__')]; + yield 'a function\'s name' => [self::MAGIC_METHODS, 'function_name', self::admits('notAMethod')]; + yield 'a constant\'s name' => [self::MAGIC_METHODS, 'constant_name', self::admits('NOT_A_METHOD')]; + yield 'a property\'s name' => [self::MAGIC_METHODS, 'property_name', self::admits('$notAMethod')]; yield 'a class body with nothing written' => [$c . 'Keywords.php', 'class_body', self::admits('')]; yield 'a word in a class body' => [ 'Position/ClassBodyWord.php', 'class_body_word', self::admits('p', keywords: KeywordGroup::ClassBody), @@ -628,6 +645,7 @@ private static function admits( ?KeywordGroup $keywords = null, ?TypeHintContext $builtinTypes = null, ?array $names = null, + ?string $declaredMethod = null, ): array { return [ 'prefix' => $prefix, @@ -637,6 +655,7 @@ private static function admits( 'keywords' => $keywords, 'builtinTypes' => $builtinTypes, 'names' => $names, + 'declaredMethod' => $declaredMethod, ]; } @@ -646,6 +665,7 @@ private static function admits( private static function describe(Expected $expected): array { $names = $expected->names; + $method = $expected->declaredMethod; return self::admits( $expected->prefix, $expected->members !== null, @@ -654,6 +674,7 @@ private static function describe(Expected $expected): array $expected->keywords, $expected->builtinTypes, $names === null ? null : [$names->kinds, $names->filter, $names->absolute], + $method === null ? null : $method->owner->qualifiedName->fullyQualifiedName() . '::' . $method->name, ); } diff --git a/tests/Resolution/Position/PositionLocatorTest.php b/tests/Resolution/Position/PositionLocatorTest.php index f037cf00..1c7ba0e7 100644 --- a/tests/Resolution/Position/PositionLocatorTest.php +++ b/tests/Resolution/Position/PositionLocatorTest.php @@ -160,7 +160,10 @@ public static function wordEnds(): iterable $p = 'src/Position/Positions.php'; yield 'a class before ::' => [$p, 'User::create', Slot::StaticReceiver, 'User', false]; yield 'an argument name' => [$p, '(first: 1', Slot::ArgumentName, 'first', true]; - yield 'a declared name' => [$p, 'function method', Slot::DeclaredName, 'method', false]; + yield 'a method\'s name' => [$p, 'function method', Slot::MethodName, 'method', false]; + yield 'a function\'s name' => [ + 'MultiClass/MagicMethods.php', 'function notAMethod', Slot::DeclaredName, 'notAMethod', false, + ]; yield 'a called function' => [$p, 'printf(', Slot::CalledName, 'printf', false]; yield 'the start of a function\'s header' => [$p, 'public function method', Slot::Expression, '', false]; yield 'its first keyword being typed' => [$p, 'public function method', Slot::Expression, 'pub', false];