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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions config/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion docs/features/completion.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
15 changes: 15 additions & 0 deletions src/Completion/CompletionItemFormatter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
2 changes: 2 additions & 0 deletions src/Completion/CompositeCompletionSource.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ public function __construct(
private readonly KeywordCandidates $keywords,
private readonly BuiltinTypeCandidates $builtinTypes,
private readonly SymbolCandidates $names,
private readonly MagicMethodCandidates $magicMethods,
) {
}

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

Expand Down
75 changes: 75 additions & 0 deletions src/Completion/MagicMethod.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<?php

declare(strict_types=1);

namespace Firehed\PhpLsp\Completion;

use Firehed\PhpLsp\Domain\ClassKind;
use Firehed\PhpLsp\Domain\Visibility;

/**
* The methods PHP gives a meaning to, per the manual's "Magic Methods".
*/
enum MagicMethod: string
{
case Call = '__call';
case CallStatic = '__callStatic';
case Clone = '__clone';
case Construct = '__construct';
case DebugInfo = '__debugInfo';
case Destruct = '__destruct';
case Get = '__get';
case Invoke = '__invoke';
case Isset = '__isset';
case Serialize = '__serialize';
case Set = '__set';
case SetState = '__set_state';
case Sleep = '__sleep';
case ToString = '__toString';
case Unserialize = '__unserialize';
case Unset = '__unset';
case Wakeup = '__wakeup';

/**
* Whether a method declared with these modifiers in this kind of
* class-like may be this one. An enum allows only three magic methods;
* the rest must be public unless they construct, destroy, or clone.
*/
public function declarableAs(ClassKind $kind, bool $static, Visibility $visibility): bool
{
$allowedInEnum = in_array($this, [self::Call, self::CallStatic, self::Invoke], true);
$isStatic = in_array($this, [self::CallStatic, self::SetState], true);
$mayBeNonPublic = in_array($this, [self::Clone, self::Construct, self::Destruct], true);

return ($kind !== ClassKind::Enum_ || $allowedInEnum)
&& $static === $isStatic
&& ($visibility === Visibility::Public || $mayBeNonPublic);
}

/**
* The name and the signature the manual gives it. What a constructor or
* `__invoke` takes is the author's choice, so they take nothing here.
*/
public function signature(): string
{
return match ($this) {
self::Call => '__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',
};
}
}
56 changes: 56 additions & 0 deletions src/Completion/MagicMethodCandidates.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

declare(strict_types=1);

namespace Firehed\PhpLsp\Completion;

use Firehed\PhpLsp\Domain\MemberKind;
use Firehed\PhpLsp\Domain\MethodName;
use Firehed\PhpLsp\Domain\PrefixMatcher;
use Firehed\PhpLsp\Knowledge\SymbolSourceInterface;

/**
* The magic methods a method being declared may be: those its class-like and
* modifiers allow, less those the class-like already declares.
*
* @phpstan-import-type CompletionItem from CompletionItemFormatter
*/
final class MagicMethodCandidates
{
public function __construct(
private readonly SymbolSourceInterface $symbols,
private readonly CompletionItemFormatter $itemFormatter,
) {
}

/**
* @return list<CompletionItem>
*/
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;
}
}
4 changes: 4 additions & 0 deletions src/Resolution/Expected.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace Firehed\PhpLsp\Resolution;

use Firehed\PhpLsp\Domain\KeywordGroup;
use Firehed\PhpLsp\Domain\MethodName;
use Firehed\PhpLsp\Domain\TypeHintContext;

/**
Expand All @@ -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,
Expand All @@ -24,6 +27,7 @@ public function __construct(
public ?KeywordGroup $keywords = null,
public ?TypeHintContext $builtinTypes = null,
public ?NameQuery $names = null,
public ?MethodName $declaredMethod = null,
) {
}
}
14 changes: 14 additions & 0 deletions src/Resolution/Position/ExpectedAtPosition.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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)),
Expand Down Expand Up @@ -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(
Expand Down
5 changes: 4 additions & 1 deletion src/Resolution/Position/PositionLocator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion src/Resolution/Position/Slot.php
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
2 changes: 1 addition & 1 deletion src/Resolution/Position/SymbolAtPosition.php
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
4 changes: 3 additions & 1 deletion tests/BuildsSymbolInfoTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ private static function declaredFunction(string $fqn, ?string $file = null): Dec

/**
* @param list<string> $interfaces
* @param array<string, MethodInfo> $methods
*/
private static function classInfo(
string $fqn,
Expand All @@ -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),
Expand All @@ -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: [],
Expand Down
14 changes: 14 additions & 0 deletions tests/Completion/CompletionItemFormatterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand Down
14 changes: 14 additions & 0 deletions tests/Completion/CompositeCompletionSourceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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());

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

Expand Down
Loading
Loading