From 9cb96c38510d5bcde766f843b8bf9f24163d4773 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sun, 7 Jun 2026 09:43:03 +0000 Subject: [PATCH 01/22] build(deps): target xphp 0.2.x Bump the xphp-lang/xphp dependency from ^0.1.0 to the 0.2.x line (0.2.x-dev), which introduces the turbofish call-site syntax, variance markers, default type arguments, and composite intersection/union bounds. The vendor public surface the server binds to is unchanged: the generic AST attribute constants, the parse*/strip methods, and ByteOffsetMap are all present, and strip() still blanks generic clauses to equal-length whitespace so byte offsets remain 1:1. README documents the targeted xphp version and the new constructs. Co-Authored-By: Claude Opus 4.8 --- README.md | 4 ++++ composer.json | 2 +- composer.lock | 16 +++++++++------- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index e50d9f7..595f948 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,10 @@ The server reuses the parent `xphp` package's AST, generic-instantiation `Registry`, and `TypeHierarchy` directly -- no second parser, no duplicated language semantics. +Targets **xphp 0.2.x**, including the turbofish call-site syntax +(`new Box::()`, `Foo::method::(...)`), variance markers, default type +arguments, and composite (intersection / union) bounds. + For the public-facing feature inventory plus what's planned next, see [roadmap](docs/roadmap.md). diff --git a/composer.json b/composer.json index 880ed94..11779b6 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,7 @@ "phpactor/language-server": "^6.0", "phpactor/language-server-protocol": "^3.5", "phpactor/worse-reflection": "^0.6.0", - "xphp-lang/xphp": "^0.1.0" + "xphp-lang/xphp": "0.2.x-dev" }, "require-dev": { "phpunit/phpunit": "^13.0" diff --git a/composer.lock b/composer.lock index 6641300..634989a 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "0fedef0b016eb60d0d633d35b8e188a3", + "content-hash": "72f67bf29e6269404afb99825e80f61c", "packages": [ { "name": "amphp/amp", @@ -2760,16 +2760,16 @@ }, { "name": "xphp-lang/xphp", - "version": "v0.1.0", + "version": "0.2.x-dev", "source": { "type": "git", "url": "https://github.com/xphp-lang/xphp.git", - "reference": "cd45ad04e194e954264a53b030ae35a32a380fcc" + "reference": "2157a481fb4ed4cd28a265f1edb6a7f69252cae8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/xphp-lang/xphp/zipball/cd45ad04e194e954264a53b030ae35a32a380fcc", - "reference": "cd45ad04e194e954264a53b030ae35a32a380fcc", + "url": "https://api.github.com/repos/xphp-lang/xphp/zipball/2157a481fb4ed4cd28a265f1edb6a7f69252cae8", + "reference": "2157a481fb4ed4cd28a265f1edb6a7f69252cae8", "shasum": "" }, "require": { @@ -2814,7 +2814,7 @@ "issues": "https://github.com/xphp-lang/xphp/issues", "source": "https://github.com/xphp-lang/xphp" }, - "time": "2026-06-01T20:27:49+00:00" + "time": "2026-06-07T07:10:44+00:00" } ], "packages-dev": [ @@ -4613,7 +4613,9 @@ ], "aliases": [], "minimum-stability": "dev", - "stability-flags": {}, + "stability-flags": { + "xphp-lang/xphp": 20 + }, "prefer-stable": true, "prefer-lowest": false, "platform": { From 7c4fdce0e10887524a09a234916551b7c10ce483 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sun, 7 Jun 2026 09:48:50 +0000 Subject: [PATCH 02/22] refactor(bounds): read type-param bounds through a BoundExpr view The parser now models a type parameter's upper bound as a small expression tree (a single leaf, or an intersection/union of leaves) instead of a single bound FQN string. Introduce BoundExprView, a stateless helper that renders a bound for display, flattens its leaf FQNs, and answers whether a candidate type satisfies it (all leaves for an intersection, any leaf for a union). Route the existing bound read sites through it: - hover renders the bound via the view's display string, - the workspace analyzer detects violations and filters swap candidates via the satisfaction check, - the FQN index keeps exposing the first leaf FQN for the completion bound filter. Single-leaf bound behavior is unchanged; this is the groundwork for composite-bound intelligence. Co-Authored-By: Claude Opus 4.8 --- src/Analyzer/WorkspaceAnalyzer.php | 18 ++-- src/Handler/XphpHoverHandler.php | 6 +- src/Reflection/FqnIndex.php | 7 +- src/Resolver/BoundExprView.php | 161 ++++++++++++++++++++++++++++ test/Resolver/BoundExprViewTest.php | 127 ++++++++++++++++++++++ 5 files changed, 310 insertions(+), 9 deletions(-) create mode 100644 src/Resolver/BoundExprView.php create mode 100644 test/Resolver/BoundExprViewTest.php diff --git a/src/Analyzer/WorkspaceAnalyzer.php b/src/Analyzer/WorkspaceAnalyzer.php index 2fabd9c..f4fe968 100644 --- a/src/Analyzer/WorkspaceAnalyzer.php +++ b/src/Analyzer/WorkspaceAnalyzer.php @@ -11,6 +11,7 @@ use PhpParser\NodeVisitorAbstract; use RuntimeException; use XPHP\Lsp\PositionMap; +use XPHP\Lsp\Resolver\BoundExprView; use XPHP\Transpiler\Monomorphize\Registry; use XPHP\Transpiler\Monomorphize\TypeHierarchy; use XPHP\Transpiler\Monomorphize\XphpSourceParser; @@ -397,11 +398,13 @@ public function buildBoundFixData( // Locate the first type-param whose bound the supplied arg violates. $index = null; + $isSubtype = static fn (string $candidate, string $boundFqn): bool => + $hierarchy->isSubtype($candidate, $boundFqn) === true; foreach ($typeParams as $i => $param) { - if ($param->boundFqn === null) { + if ($param->bound === null) { continue; } - if ($hierarchy->isSubtype($args[$i]->name, $param->boundFqn) !== true) { + if (!BoundExprView::isSatisfiedBy($args[$i]->name, $param->bound, $isSubtype)) { $index = $i; break; } @@ -410,7 +413,10 @@ public function buildBoundFixData( return null; } - $bound = ltrim((string) $typeParams[$index]->boundFqn, '\\'); + // The single-leaf candidate / implements fix-its below key off the + // first leaf FQN; the composite-aware payload arrives later. + $boundLeaves = BoundExprView::leafFqns($typeParams[$index]->bound); + $primaryLeaf = $boundLeaves[0] ?? ''; $concrete = ltrim((string) $args[$index]->name, '\\'); $concreteIsScalar = (bool) ($args[$index]->isScalar ?? false); @@ -420,7 +426,7 @@ public function buildBoundFixData( if ($candidateFqn === $concrete) { continue; } - if ($hierarchy->isSubtype($candidateFqn, $bound) === true) { + if (BoundExprView::isSatisfiedBy($candidateFqn, $typeParams[$index]->bound, $isSubtype)) { $short = strrpos($candidateFqn, '\\') !== false ? substr($candidateFqn, strrpos($candidateFqn, '\\') + 1) : $candidateFqn; @@ -434,14 +440,14 @@ public function buildBoundFixData( return [ 'kind' => 'bound', 'param' => $typeParams[$index]->name, - 'bound' => $bound, + 'bound' => $primaryLeaf, 'concrete' => $concrete, 'concreteIsScalar' => $concreteIsScalar, 'typeArgRange' => self::typeArgRange($source, $node->getEndFilePos() + 1, $index, $positionMap), 'candidates' => $candidateNames, 'implementsInsert' => $concreteIsScalar ? null - : self::implementsInsert($openClasses[$concrete] ?? null, $bound), + : self::implementsInsert($openClasses[$concrete] ?? null, $primaryLeaf), ]; } diff --git a/src/Handler/XphpHoverHandler.php b/src/Handler/XphpHoverHandler.php index 9d75a0e..b534ec3 100644 --- a/src/Handler/XphpHoverHandler.php +++ b/src/Handler/XphpHoverHandler.php @@ -21,6 +21,7 @@ use Phpactor\LanguageServerProtocol\ServerCapabilities; use XPHP\Lsp\Analyzer\ParsedDocumentCache; use XPHP\Lsp\PositionMap; +use XPHP\Lsp\Resolver\BoundExprView; use XPHP\Lsp\Resolver\PhpHoverResolver; use XPHP\Transpiler\Monomorphize\Registry; use XPHP\Transpiler\Monomorphize\TypeParam; @@ -204,8 +205,9 @@ private function buildHoverMarkdown(\PhpParser\Node\Name $name, array $classScop continue; } $owner = $classLike->getAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN); - $boundLine = $param->boundFqn !== null - ? sprintf("\n\nbounded by `\\%s`", $param->boundFqn) + $boundDisplay = BoundExprView::displayString($param->bound); + $boundLine = $boundDisplay !== null + ? sprintf("\n\nbounded by `%s`", $boundDisplay) : ''; return sprintf( "**Type parameter `%s`** of `%s`%s", diff --git a/src/Reflection/FqnIndex.php b/src/Reflection/FqnIndex.php index a32f203..e1ef7c7 100644 --- a/src/Reflection/FqnIndex.php +++ b/src/Reflection/FqnIndex.php @@ -21,6 +21,7 @@ use SplFileInfo; use Throwable; use XPHP\Lsp\Analyzer\ParsedDocumentCache; +use XPHP\Lsp\Resolver\BoundExprView; use XPHP\Lsp\Stderr; use XPHP\Transpiler\Monomorphize\TypeParam; use XPHP\Transpiler\Monomorphize\XphpSourceParser; @@ -1589,7 +1590,11 @@ public function enterNode(Node $node): null $bounds = []; foreach ($params as $p) { if ($p instanceof TypeParam) { - $bounds[] = $p->boundFqn !== null ? ltrim($p->boundFqn, '\\') : null; + // First leaf FQN keeps the existing string contract for + // the completion bound filter; the full expression tree + // is exposed separately for composite-bound support. + $leaves = BoundExprView::leafFqns($p->bound); + $bounds[] = $leaves[0] ?? null; } } if ($bounds === []) { diff --git a/src/Resolver/BoundExprView.php b/src/Resolver/BoundExprView.php new file mode 100644 index 0000000..8b4c30b --- /dev/null +++ b/src/Resolver/BoundExprView.php @@ -0,0 +1,161 @@ + `\Fqn` (or `\Comparable<\T>` for F-bounded forms) + * - intersection -> `A & B` + * - union -> `A | B` + * Returns null for an absent bound. + */ + public static function displayString(?BoundExpr $bound): ?string + { + if ($bound === null) { + return null; + } + if ($bound instanceof BoundLeaf) { + return self::renderTypeRef($bound->type); + } + if ($bound instanceof BoundIntersection) { + return implode(' & ', array_map( + static fn (BoundExpr $op): string => self::wrap($op, $bound), + $bound->operands, + )); + } + if ($bound instanceof BoundUnion) { + return implode(' | ', array_map( + static fn (BoundExpr $op): string => self::wrap($op, $bound), + $bound->operands, + )); + } + + return null; + } + + /** + * Flatten every leaf FQN in the tree, with the leading `\` stripped so the + * names compare equal to the hierarchy's canonical form. + * + * @return list + */ + public static function leafFqns(?BoundExpr $bound): array + { + if ($bound === null) { + return []; + } + if ($bound instanceof BoundLeaf) { + return [ltrim($bound->type->name, '\\')]; + } + if ($bound instanceof BoundIntersection || $bound instanceof BoundUnion) { + $out = []; + foreach ($bound->operands as $operand) { + foreach (self::leafFqns($operand) as $leaf) { + $out[] = $leaf; + } + } + + return $out; + } + + return []; + } + + /** + * Does `$candidateFqn` satisfy the bound, given a subtype oracle? + * - null bound -> true (unbounded) + * - leaf -> `$isSubtype($candidate, $leafFqn)` + * - intersection-> every operand must hold + * - union -> any operand suffices + * + * @param callable(string, string): bool $isSubtype + */ + public static function isSatisfiedBy(string $candidateFqn, ?BoundExpr $bound, callable $isSubtype): bool + { + if ($bound === null) { + return true; + } + if ($bound instanceof BoundLeaf) { + return $isSubtype($candidateFqn, ltrim($bound->type->name, '\\')); + } + if ($bound instanceof BoundIntersection) { + foreach ($bound->operands as $operand) { + if (!self::isSatisfiedBy($candidateFqn, $operand, $isSubtype)) { + return false; + } + } + + return true; + } + if ($bound instanceof BoundUnion) { + foreach ($bound->operands as $operand) { + if (self::isSatisfiedBy($candidateFqn, $operand, $isSubtype)) { + return true; + } + } + + return false; + } + + return true; + } + + /** + * Render a leaf's `TypeRef`, recursing into its generic args so F-bounded + * shapes like `Comparable` survive in the display string. + */ + private static function renderTypeRef(TypeRef $type): string + { + // Type-param references (the `T` in an F-bounded `Comparable`) and + // scalars are bare identifiers; only a class/interface leaf gets the + // leading `\` of a fully-qualified name. + $name = ($type->isTypeParam || $type->isScalar) + ? $type->name + : '\\' . ltrim($type->name, '\\'); + if ($type->args === []) { + return $name; + } + + return sprintf( + '%s<%s>', + $name, + implode(', ', array_map( + static fn (TypeRef $arg): string => self::renderTypeRef($arg), + $type->args, + )), + ); + } + + /** + * Parenthesise an operand whose precedence is looser than its parent, so a + * DNF tree (`(A | B) & C`) renders unambiguously under PHP's `&` > `|` + * convention. + */ + private static function wrap(BoundExpr $operand, BoundExpr $parent): string + { + $rendered = (string) self::displayString($operand); + $needsParens = ($parent instanceof BoundIntersection && $operand instanceof BoundUnion) + || ($parent instanceof BoundUnion && $operand instanceof BoundIntersection); + + return $needsParens ? '(' . $rendered . ')' : $rendered; + } +} diff --git a/test/Resolver/BoundExprViewTest.php b/test/Resolver/BoundExprViewTest.php new file mode 100644 index 0000000..80ae582 --- /dev/null +++ b/test/Resolver/BoundExprViewTest.php @@ -0,0 +1,127 @@ +` -- the inner `T` is a type-param reference, not a + // class, so it must NOT pick up the leading FQN backslash. + $bound = self::leaf('Comparable', new TypeRef('T', [], false, true)); + self::assertSame('\\Comparable', BoundExprView::displayString($bound)); + } + + public function testDisplayStringRendersIntersection(): void + { + $bound = new BoundIntersection(self::leaf('A'), self::leaf('B')); + self::assertSame('\\A & \\B', BoundExprView::displayString($bound)); + } + + public function testDisplayStringRendersUnion(): void + { + $bound = new BoundUnion(self::leaf('A'), self::leaf('B')); + self::assertSame('\\A | \\B', BoundExprView::displayString($bound)); + } + + public function testDisplayStringParenthesisesDnf(): void + { + // (A & B) | C + $bound = new BoundUnion( + new BoundIntersection(self::leaf('A'), self::leaf('B')), + self::leaf('C'), + ); + self::assertSame('(\\A & \\B) | \\C', BoundExprView::displayString($bound)); + } + + public function testDisplayStringParenthesisesUnionInsideIntersection(): void + { + // (A | B) & C + $bound = new BoundIntersection( + new BoundUnion(self::leaf('A'), self::leaf('B')), + self::leaf('C'), + ); + self::assertSame('(\\A | \\B) & \\C', BoundExprView::displayString($bound)); + } + + public function testLeafFqnsEmptyForNull(): void + { + self::assertSame([], BoundExprView::leafFqns(null)); + } + + public function testLeafFqnsStripsLeadingBackslash(): void + { + self::assertSame(['Stringable'], BoundExprView::leafFqns(self::leaf('\\Stringable'))); + } + + public function testLeafFqnsFlattensComposite(): void + { + $bound = new BoundUnion( + new BoundIntersection(self::leaf('A'), self::leaf('B')), + self::leaf('C'), + ); + self::assertSame(['A', 'B', 'C'], BoundExprView::leafFqns($bound)); + } + + public function testIsSatisfiedByNullBoundIsAlwaysTrue(): void + { + $never = static fn (string $c, string $b): bool => false; + self::assertTrue(BoundExprView::isSatisfiedBy('X', null, $never)); + } + + public function testIsSatisfiedByLeafDelegatesToOracle(): void + { + $isSubtype = static fn (string $c, string $b): bool => $c === 'Sub' && $b === 'Stringable'; + self::assertTrue(BoundExprView::isSatisfiedBy('Sub', self::leaf('\\Stringable'), $isSubtype)); + self::assertFalse(BoundExprView::isSatisfiedBy('Other', self::leaf('\\Stringable'), $isSubtype)); + } + + public function testIsSatisfiedByIntersectionRequiresAllLeaves(): void + { + $bound = new BoundIntersection(self::leaf('A'), self::leaf('B')); + // Implements A and B. + $both = static fn (string $c, string $b): bool => in_array($b, ['A', 'B'], true); + self::assertTrue(BoundExprView::isSatisfiedBy('X', $bound, $both)); + // Implements only A. + $onlyA = static fn (string $c, string $b): bool => $b === 'A'; + self::assertFalse(BoundExprView::isSatisfiedBy('X', $bound, $onlyA)); + } + + public function testIsSatisfiedByUnionAcceptsAnyLeaf(): void + { + $bound = new BoundUnion(self::leaf('A'), self::leaf('B')); + $onlyB = static fn (string $c, string $b): bool => $b === 'B'; + self::assertTrue(BoundExprView::isSatisfiedBy('X', $bound, $onlyB)); + $neither = static fn (string $c, string $b): bool => false; + self::assertFalse(BoundExprView::isSatisfiedBy('X', $bound, $neither)); + } +} From f9da2bea334429957bc00e55ccb43507e3068979 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sun, 7 Jun 2026 09:57:34 +0000 Subject: [PATCH 03/22] feat(generics): recognise the turbofish call-site syntax MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0.2.x requires the turbofish `Name::(...)` at expression-context generic calls and rejects the old bare-angle form (`new Box()`). Centralise the duplicated `<…>`-scanning in a new TurbofishScanner and teach the self-scanning handlers the `::<` opener: - the type-arg position detector (completion / go-to-definition) and the hover handler's angle-clause finder now delegate to the scanner, which requires `::` before the opening `<` and reads the receiver name to its left (handling `Foo::<`, the empty `Foo::<>`, FQN receivers, and nested bare type-args inside an outer turbofish); - the semantic-token pass keeps the bare-`<` opener for declaration clauses (`class Box`, `function f`, which are unchanged in 0.2.x) and additionally opens a clause on a `<` preceded by `::`, covering static, instance, `static::<…>`, and FQN call sites; - `$a < $b` and other comparisons still never open a clause. Docs migrate the call-site generic examples to the turbofish form. Co-Authored-By: Claude Opus 4.8 --- docs/features/index.md | 10 +- src/Handler/SemanticTokens/AstVisitor.php | 11 + src/Handler/TurbofishScanner.php | 297 ++++++++++++++++++ src/Handler/TypeArgPositionDetector.php | 98 +----- src/Handler/XphpHoverHandler.php | 28 +- .../Handler/SemanticTokens/AstVisitorTest.php | 39 ++- test/Handler/TurbofishScannerTest.php | 156 +++++++++ test/Handler/TypeArgPositionDetectorTest.php | 110 +++---- 8 files changed, 566 insertions(+), 183 deletions(-) create mode 100644 src/Handler/TurbofishScanner.php create mode 100644 test/Handler/TurbofishScannerTest.php diff --git a/docs/features/index.md b/docs/features/index.md index c073189..faae789 100644 --- a/docs/features/index.md +++ b/docs/features/index.md @@ -187,7 +187,7 @@ LSP method: `textDocument/signatureHelp`. Inline parameter list with the active argument highlighted. Type-arg substitution is baked into the rendered signature: a call to -`new Box(...)` shows `Tag` rather than `T` in the parameter +`new Box::(...)` shows `Tag` rather than `T` in the parameter hint. Works at static, instance, and free-function call sites. ### Inlay Hints @@ -236,7 +236,7 @@ file. Compile-time validation of `T: Bound` against each concrete type-arg. The hierarchy spans the whole project on disk (not just -open buffers), so `new Box(...)` resolves correctly even when +open buffers), so `new Box::(...)` resolves correctly even when `Tag.xphp` isn't currently open in the editor. Error messages reference the source-level instantiation (e.g. `Box`) rather than the hashed specialization name. @@ -257,7 +257,7 @@ are fixable in one keystroke. ### Constructor argument-type mismatch (`xphp.ctor-arg-mismatch`) -Post-monomorphization check on `new C(...)` and `new C(...)` +Post-monomorphization check on `new C(...)` and `new C::(...)` call sites. Catches the case where the supplied argument's statically-known type can't satisfy the constructor parameter's declared type -- a runtime `TypeError` waiting to happen, surfaced @@ -276,7 +276,7 @@ LSP method: `textDocument/completion`. Context-aware completion in every meaningful position: -- **Type-arg position** (`new Box<|>(...)`) -- bound-aware +- **Type-arg position** (`new Box::<|>(...)`) -- bound-aware filtering hides candidates that don't satisfy the slot's declared upper bound; scalars are dropped when the bound is class-like. - **Member access** (`$obj->`) and **static access** (`Cls::`) -- @@ -342,7 +342,7 @@ is paid once per machine, not once per session. ### Tolerant-parse fallback In-memory locators recover from trailing parse errors so mid-edit -source (`$x->|`, `new Foo<|`) still returns useful completion / +source (`$x->|`, `new Foo::<|`) still returns useful completion / hover / GTD results. Without this fallback, every incomplete keystroke would temporarily break the editor's intelligence and force the developer to wait for the source to be syntactically diff --git a/src/Handler/SemanticTokens/AstVisitor.php b/src/Handler/SemanticTokens/AstVisitor.php index 2dd8064..c4c4970 100644 --- a/src/Handler/SemanticTokens/AstVisitor.php +++ b/src/Handler/SemanticTokens/AstVisitor.php @@ -169,6 +169,17 @@ private function collectFromTokens(array &$out, array $reclassifyVariableAt = [] } elseif ($lastSignificantTokenId === T_STRING && self::peekIsUppercaseIdent($tokens, $i + 1) ) { + // Declaration clause: `class Box`, `function f` -- + // the bare `<` follows the declared name (T_STRING). + $genericDepth = 1; + } elseif ($lastSignificantTokenId === T_DOUBLE_COLON + && self::peekIsUppercaseIdent($tokens, $i + 1) + ) { + // Call-site turbofish: `Foo::`, `static::`, + // `$obj->m::` -- the `<` follows the `::` of `::<`. The + // receiver token before `::` may be T_STRING (`Foo`, + // `self`, `parent`) or T_STATIC (`static`); either way the + // significant token immediately before `<` is the `::`. $genericDepth = 1; } } elseif (!$isNamedToken && $token->text === '>' && $genericDepth > 0) { diff --git a/src/Handler/TurbofishScanner.php b/src/Handler/TurbofishScanner.php new file mode 100644 index 0000000..bc8595e --- /dev/null +++ b/src/Handler/TurbofishScanner.php @@ -0,0 +1,297 @@ +`. + * + * 0.2.x requires the turbofish at expression-context generic calls + * (`new Box::()`, `Foo::method::(...)`, `$obj->m::(...)`) and rejects + * the old bare-`<` call syntax. Several handlers need to find the `::<…>` + * clause -- to know whether the cursor is inside it, or to map the clause's + * byte range. This helper centralises that scan so the depth-walk, the `::` + * opener guard, and the comma-splitting live in one place. + * + * Declaration clauses (`class Box`, `function f`) keep the bare `<` and + * are NOT handled here -- they never reach an expression-context call scan. + * + * No string/comment awareness: turbofish syntax never appears inside strings + * or comments, and the parser-level scanner already handles those for parsing. + */ +final class TurbofishScanner +{ + /** + * Decide whether `$offset` sits inside a turbofish clause and, if so, which + * container and which arg slot. + * + * Walks backward from the cursor with a `<>` depth counter; the unmatched + * depth-0 `<` opens a turbofish clause only when the preceding + * non-whitespace bytes are `::` (the container name is read to the LEFT of + * the `::`). Handles `Foo::<` (cursor right after `<`) and `Foo::<>` (empty + * / all-defaults). + * + * @return array{prefix: string, containerName: string, slot: int}|null + * null → not inside a turbofish clause. + * array → `prefix` is the partial identifier typed since the last `<` or + * `,`; `containerName` is the receiver name left of `::` (short + * or qualified, as it appears in source); `slot` is the 0-based + * arg index. + */ + public static function detectCursorInClause(string $source, int $offset): ?array + { + $length = strlen($source); + if ($offset < 0 || $offset > $length) { + return null; + } + + $prefixStart = $offset; + while ($prefixStart > 0 && self::isIdentifierByte($source[$prefixStart - 1])) { + $prefixStart--; + } + $prefix = substr($source, $prefixStart, $offset - $prefixStart); + + // The innermost unmatched `<` gives the container the cursor is typing + // an arg for, plus the slot index. That innermost clause may be a bare + // nested type-arg (`Box::`) -- the container `List` is bare -- + // so we record it first, then verify the whole expression is anchored + // by a turbofish `::<` somewhere outward. + $containerName = null; + $slot = 0; + $depth = 0; + $i = $prefixStart - 1; + while ($i >= 0) { + $c = $source[$i]; + if ($c === '>') { + $depth++; + $i--; + continue; + } + if ($c === ',' && $depth === 0 && $containerName === null) { + $slot++; + $i--; + continue; + } + if ($c === '<' && $depth > 0) { + $depth--; + $i--; + continue; + } + if ($c === '<') { + // Unmatched depth-0 `<`. A turbofish opener has `::` directly + // to its left (modulo whitespace); the container name is left + // of that `::`. A bare nested type-arg clause has an identifier + // directly to its left instead. + $beforeAngle = self::skipSpaceLeft($source, $i - 1); + if (self::isDoubleColonAt($source, $beforeAngle)) { + [$name] = self::nameLeftOf($source, $beforeAngle - 1); + if ($name === null) { + // `::<` with no receiver name -- not a turbofish. + return null; + } + return [ + 'prefix' => $prefix, + 'containerName' => $containerName ?? $name, + 'slot' => $slot, + ]; + } + // Bare `<`: only valid as a nested type-arg inside an outer + // turbofish. Capture the innermost container once, then keep + // walking outward to find the enclosing turbofish anchor. + [$name, $beforeName] = self::nameLeftOf($source, $i); + if ($name === null) { + return null; + } + if ($containerName === null) { + $containerName = $name; + } + $i = $beforeName; + continue; + } + if (self::isInterArgByte($c) || self::isIdentifierByte($c)) { + $i--; + continue; + } + + // Anything else breaks the clause context. + return null; + } + + return null; + } + + /** + * Read the identifier ending just before byte `$openPos` (the `<`), skipping + * intervening whitespace. Returns `[name|null, indexBeforeName]` where + * `indexBeforeName` is the byte index immediately to the left of the name + * (which the caller inspects for a `::`). + * + * @return array{0: ?string, 1: int} + */ + private static function nameLeftOf(string $source, int $openPos): array + { + $nameEnd = $openPos; // exclusive + while ($nameEnd > 0 && self::isSpace($source[$nameEnd - 1])) { + $nameEnd--; + } + $nameStart = $nameEnd; + while ($nameStart > 0 && self::isIdentifierByte($source[$nameStart - 1])) { + $nameStart--; + } + if ($nameStart === $nameEnd) { + return [null, $nameStart - 1]; + } + + return [substr($source, $nameStart, $nameEnd - $nameStart), $nameStart - 1]; + } + + /** + * Skip whitespace bytes leftward from `$index`, returning the index of the + * first non-space byte at or before it (may be -1). + */ + private static function skipSpaceLeft(string $source, int $index): int + { + while ($index >= 0 && self::isSpace($source[$index])) { + $index--; + } + + return $index; + } + + /** + * Is there a `::` ending exactly at byte `$index`? + */ + private static function isDoubleColonAt(string $source, int $index): bool + { + return $index >= 1 && $source[$index] === ':' && $source[$index - 1] === ':'; + } + + /** + * Locate the turbofish clause byte range immediately following a name that + * ends at `$nameEnd` (inclusive). Requires `::<` (whitespace permitted + * around the `::` and between `::` and `<`). Returns the byte positions of + * the opening `<` and its matching `>`, or null when no clause is present + * or it is unterminated. + * + * @return array{openPos: int, closePos: int}|null + */ + public static function clauseAfter(string $source, int $nameEnd): ?array + { + $n = strlen($source); + $i = $nameEnd + 1; + while ($i < $n && self::isSpace($source[$i])) { + $i++; + } + // Require the `::` opener. + if ($i + 1 >= $n || $source[$i] !== ':' || $source[$i + 1] !== ':') { + return null; + } + $i += 2; + while ($i < $n && self::isSpace($source[$i])) { + $i++; + } + if ($i >= $n || $source[$i] !== '<') { + return null; + } + $openPos = $i; + $depth = 1; + $j = $i + 1; + while ($j < $n && $depth > 0) { + $c = $source[$j]; + if ($c === '<') { + $depth++; + } elseif ($c === '>') { + $depth--; + } + $j++; + } + if ($depth !== 0) { + return null; + } + + return ['openPos' => $openPos, 'closePos' => $j - 1]; + } + + /** + * Split the inner text of a clause (the bytes between `<` and `>`) into its + * top-level arguments, honouring nested `<…>`. Empty inner text yields an + * empty list (the `Foo::<>` all-defaults case). + * + * @return list + */ + public static function splitTopLevelArgs(string $clauseInner): array + { + if (trim($clauseInner) === '') { + return []; + } + $args = []; + $depth = 0; + $current = ''; + $len = strlen($clauseInner); + for ($i = 0; $i < $len; $i++) { + $c = $clauseInner[$i]; + if ($c === '<') { + $depth++; + $current .= $c; + } elseif ($c === '>') { + if ($depth > 0) { + $depth--; + } + $current .= $c; + } elseif ($c === ',' && $depth === 0) { + $args[] = trim($current); + $current = ''; + } else { + $current .= $c; + } + } + $args[] = trim($current); + + return $args; + } + + /** + * Index of the top-level arg containing `$offset` within a clause's inner + * text (between `<` and `>` exclusive). Counts `,` at nesting depth 0; + * nested `<…>` clauses don't split the outer arg. + */ + public static function topLevelArgIndexAt(string $innerText, int $offset): ?int + { + $n = strlen($innerText); + if ($offset < 0 || $offset > $n) { + return null; + } + $depth = 0; + $index = 0; + for ($i = 0; $i < $offset; $i++) { + $c = $innerText[$i]; + if ($c === '<') { + $depth++; + } elseif ($c === '>') { + if ($depth > 0) { + $depth--; + } + } elseif ($c === ',' && $depth === 0) { + $index++; + } + } + + return $index; + } + + private static function isIdentifierByte(string $byte): bool + { + return ctype_alnum($byte) || $byte === '_' || $byte === '\\'; + } + + private static function isSpace(string $byte): bool + { + return $byte === ' ' || $byte === "\t" || $byte === "\n" || $byte === "\r"; + } + + private static function isInterArgByte(string $byte): bool + { + return self::isSpace($byte) || $byte === ','; + } +} diff --git a/src/Handler/TypeArgPositionDetector.php b/src/Handler/TypeArgPositionDetector.php index 56f2107..e68f3a6 100644 --- a/src/Handler/TypeArgPositionDetector.php +++ b/src/Handler/TypeArgPositionDetector.php @@ -5,18 +5,15 @@ namespace XPHP\Lsp\Handler; /** - * Decide whether a cursor position is inside the generic-args clause of an - * xphp type expression — i.e. inside the `<…>` that follows a Name. + * Decide whether a cursor position is inside a call-site generic-args clause — + * i.e. inside the turbofish `Name::<…>` that 0.2.x requires at expression + * context. * - * Strategy: walk the source backwards from the cursor with a `<>` depth - * counter. Decrement on `>`, increment on `<`. If the depth ever reaches +1 on - * a `<` and the byte immediately before that `<` is an identifier byte, the - * cursor is in a type-arg position relative to that Name. - * - * Single-pass, no string/comment awareness — that's fine because xphp generic - * syntax doesn't appear inside strings or comments (the parser-level scanner - * already handles those for parsing; for completion, false positives in - * strings just suggest classes that the user will ignore). + * The backward depth-walk + `::` opener guard lives in [[TurbofishScanner]]; + * [[detect]] delegates to it so completion, hover, and the analyzer share one + * notion of "inside a turbofish clause". [[identifierAt]] adds the forward scan + * for the full identifier under the cursor (used by go-to-definition on a + * type-arg class name). * * Limits intentionally accepted: * - Doesn't bind the surrounding Name to the candidate filter (so we can't @@ -43,75 +40,10 @@ */ public static function detect(string $source, int $offset): ?array { - $length = strlen($source); - if ($offset > $length) { - return null; - } - // Pull the partial identifier under the cursor out — that's the prefix - // the user has already typed since the last `<` or `,`. Identifier - // bytes include backslashes (so `App\Pla|` is a single FQN-style - // prefix). - $prefixStart = $offset; - while ($prefixStart > 0 && self::isIdentifierByte($source[$prefixStart - 1])) { - $prefixStart--; - } - $prefix = substr($source, $prefixStart, $offset - $prefixStart); - - // Walk back from the prefix start with a `<>` depth counter. We're - // looking for the FIRST `<` at depth 0 (i.e. an unmatched opener). - // Count commas seen at depth 0 along the way -- that's the slot - // index for the cursor's argument position. - $depth = 0; - $slot = 0; - $i = $prefixStart - 1; - while ($i >= 0) { - $c = $source[$i]; - if ($c === '>') { - $depth++; - $i--; - continue; - } - if ($c === ',' && $depth === 0) { - $slot++; - $i--; - continue; - } - if ($c === '<') { - if ($depth === 0) { - // Found the unmatched opener. The byte before it must be - // an identifier byte (the generic Name) — otherwise it's - // a less-than operator. - $j = $i - 1; - if ($j < 0 || !self::isIdentifierByte($source[$j])) { - return null; - } - // Scan the container Name backwards: identifier bytes, - // possibly through `\` separators. - $nameEnd = $i; // exclusive - $nameStart = $j; - while ($nameStart > 0 && self::isIdentifierByte($source[$nameStart - 1])) { - $nameStart--; - } - $containerName = substr($source, $nameStart, $nameEnd - $nameStart); - return [ - 'prefix' => $prefix, - 'containerName' => $containerName, - 'slot' => $slot, - ]; - } - $depth--; - $i--; - continue; - } - if (self::isInterArgByte($c) || self::isIdentifierByte($c)) { - $i--; - continue; - } - // Anything else (`(`, `;`, `=`, `{`, …) breaks the type-arg - // context — we're not inside a `<…>` clause. - return null; - } - return null; + // Call-site generic args now use the turbofish `Name::`; the + // shared scanner owns the backward depth-walk and the `::` opener + // guard so completion, hover, and the analyzer stay in lockstep. + return TurbofishScanner::detectCursorInClause($source, $offset); } /** @@ -159,10 +91,4 @@ private static function isIdentifierByte(string $byte): bool { return ctype_alnum($byte) || $byte === '_' || $byte === '\\'; } - - private static function isInterArgByte(string $byte): bool - { - // Whitespace + commas separate args; both are legal inside `<…>`. - return $byte === ' ' || $byte === "\t" || $byte === "\n" || $byte === "\r" || $byte === ','; - } } diff --git a/src/Handler/XphpHoverHandler.php b/src/Handler/XphpHoverHandler.php index b534ec3..7989cd2 100644 --- a/src/Handler/XphpHoverHandler.php +++ b/src/Handler/XphpHoverHandler.php @@ -317,30 +317,10 @@ private static function angleClauseAt(array $ast, string $source, int $offset): */ public static function findAngleRange(string $source, int $nameEnd): ?array { - $n = strlen($source); - $i = $nameEnd + 1; - while ($i < $n && ctype_space($source[$i])) { - $i++; - } - if ($i >= $n || $source[$i] !== '<') { - return null; - } - $openPos = $i; - $depth = 1; - $j = $i + 1; - while ($j < $n && $depth > 0) { - $c = $source[$j]; - if ($c === '<') { - $depth++; - } elseif ($c === '>') { - $depth--; - } - $j++; - } - if ($depth !== 0) { - return null; - } - return ['openPos' => $openPos, 'closePos' => $j - 1]; + // Call-site generic args use the turbofish `Name::<…>`; the shared + // scanner requires the `::` opener so a bare `Name<…>` (or `$a < $b`) + // never registers as a clause. + return TurbofishScanner::clauseAfter($source, $nameEnd); } /** diff --git a/test/Handler/SemanticTokens/AstVisitorTest.php b/test/Handler/SemanticTokens/AstVisitorTest.php index f3a3ea9..1c7fc06 100644 --- a/test/Handler/SemanticTokens/AstVisitorTest.php +++ b/test/Handler/SemanticTokens/AstVisitorTest.php @@ -240,21 +240,52 @@ public function testBoundTypeParamPaintsAsTypeParameter(): void public function testTypeArgClausePaintsInsideBoxOfPlastic(): void { - // Form 6: new Box() -- `Plastic` inside <...> is typeParameter. - $source = "();"; + // Form 6: new Box::() -- `Plastic` inside the turbofish is + // typeParameter. The clause opens on `<` preceded by `::`. + $source = "();"; $specs = $this->collect($source); $this->assertTokenSubstring($specs, $source, 'Plastic', 'typeParameter'); } public function testNestedTypeArgClause(): void { - // Nested: Box> -- both `Lst` and `T` are typeParameter. - $source = ">();"; + // Nested: Box::> -- the outer clause is a turbofish; the inner + // `Lst` is a bare nested type-arg. Both `Lst` and `T` are + // typeParameter. + $source = ">();"; $specs = $this->collect($source); $this->assertTokenSubstring($specs, $source, 'Lst', 'typeParameter'); $this->assertTokenSubstring($specs, $source, 'T', 'typeParameter'); } + public function testStaticCallTurbofishPaintsTypeArg(): void + { + // Util::identity::(42) -- the call-site turbofish opens on the + // `<` after `::`; `int` inside is typeParameter. + $source = "(\$x);"; + $specs = $this->collect($source); + $this->assertTokenSubstring($specs, $source, 'Plastic', 'typeParameter'); + } + + public function testStaticReceiverTurbofishPaintsTypeArg(): void + { + // static::() -- the receiver before `::` is the T_STATIC keyword; + // the clause still opens on the `<` after `::`. + $source = "();"; + $specs = $this->collect($source); + $this->assertTokenSubstring($specs, $source, 'Plastic', 'typeParameter'); + } + + public function testBareDoubleColonWithoutAngleOpensNothing(): void + { + // `Foo::BAR` is a constant access -- no `<` follows the `::`, so no + // type-arg clause opens. + $source = "collect($source); + $typeParamSpecs = array_filter($specs, fn (TokenSpec $s) => $s->type === 'typeParameter'); + self::assertEmpty($typeParamSpecs); + } + public function testMultipleTypeArgsSeparatedByComma(): void { // Form 9: Pair -- both K and V are typeParameter. diff --git a/test/Handler/TurbofishScannerTest.php b/test/Handler/TurbofishScannerTest.php new file mode 100644 index 0000000..8c578b5 --- /dev/null +++ b/test/Handler/TurbofishScannerTest.php @@ -0,0 +1,156 @@ + '', 'containerName' => 'identity', 'slot' => 0], $hit); + } + + public function testDetectsEmptyAllDefaultsClause(): void + { + // `Foo::<>` -- cursor between `<` and `>` of the empty clause. + $source = 'new Box::<>'; + $hit = TurbofishScanner::detectCursorInClause($source, strpos($source, '<') + 1); + self::assertSame(['prefix' => '', 'containerName' => 'Box', 'slot' => 0], $hit); + } + + public function testReadsContainerLeftOfDoubleColon(): void + { + $source = 'Map:: '', 'containerName' => 'Map', 'slot' => 1], $hit); + } + + public function testToleratesWhitespaceBetweenNameAndDoubleColon(): void + { + // The `::` opener guard skips whitespace before `::`. + $source = 'Box ::<'; + $hit = TurbofishScanner::detectCursorInClause($source, strlen($source)); + self::assertSame('Box', $hit['containerName']); + } + + public function testRejectsBareAngleWithoutDoubleColon(): void + { + $source = 'Box<'; + self::assertNull(TurbofishScanner::detectCursorInClause($source, strlen($source))); + } + + public function testRejectsSingleColonBeforeAngle(): void + { + // A single `:` (not `::`) is not a turbofish opener. + $source = 'Box:<'; + self::assertNull(TurbofishScanner::detectCursorInClause($source, strlen($source))); + } + + public function testRejectsDoubleColonWithoutReceiverName(): void + { + $source = '::<'; + self::assertNull(TurbofishScanner::detectCursorInClause($source, strlen($source))); + } + + public function testRejectsNegativeOffset(): void + { + self::assertNull(TurbofishScanner::detectCursorInClause('Box::<', -1)); + } + + // --- clauseAfter ----------------------------------------------------- + + public function testClauseAfterFindsTurbofishRange(): void + { + $source = 'Box::'; + $nameEnd = strpos($source, 'Box') + 2; // last byte of `Box` + $range = TurbofishScanner::clauseAfter($source, $nameEnd); + self::assertSame(strpos($source, '<'), $range['openPos']); + self::assertSame(strpos($source, '>'), $range['closePos']); + } + + public function testClauseAfterToleratesWhitespaceAroundDoubleColon(): void + { + $source = 'Box :: '; + $nameEnd = 2; // last byte of `Box` + $range = TurbofishScanner::clauseAfter($source, $nameEnd); + self::assertSame(strpos($source, '<'), $range['openPos']); + self::assertSame(strpos($source, '>'), $range['closePos']); + } + + public function testClauseAfterRejectsBareAngle(): void + { + // No `::` -- a bare `Box` is not a call-site clause. + $source = 'Box'; + self::assertNull(TurbofishScanner::clauseAfter($source, 2)); + } + + public function testClauseAfterRejectsUnterminatedClause(): void + { + $source = 'Box::', 'int'], TurbofishScanner::splitTopLevelArgs('Map, int')); + } + + // --- topLevelArgIndexAt --------------------------------------------- + + public function testArgIndexAtCountsTopLevelCommas(): void + { + $inner = 'Foo, Bar, Baz'; + self::assertSame(0, TurbofishScanner::topLevelArgIndexAt($inner, 1)); + self::assertSame(1, TurbofishScanner::topLevelArgIndexAt($inner, 6)); + self::assertSame(2, TurbofishScanner::topLevelArgIndexAt($inner, 11)); + } + + public function testArgIndexAtIgnoresNestedCommas(): void + { + $inner = 'Map, int'; + // Offset inside the nested `` is still slot 0. + self::assertSame(0, TurbofishScanner::topLevelArgIndexAt($inner, 6)); + // Offset after the top-level comma is slot 1. + self::assertSame(1, TurbofishScanner::topLevelArgIndexAt($inner, 11)); + } + + public function testArgIndexAtRejectsOutOfRangeOffset(): void + { + self::assertNull(TurbofishScanner::topLevelArgIndexAt('abc', 99)); + self::assertNull(TurbofishScanner::topLevelArgIndexAt('abc', -1)); + } +} diff --git a/test/Handler/TypeArgPositionDetectorTest.php b/test/Handler/TypeArgPositionDetectorTest.php index df233b0..2a0cfc8 100644 --- a/test/Handler/TypeArgPositionDetectorTest.php +++ b/test/Handler/TypeArgPositionDetectorTest.php @@ -11,21 +11,21 @@ final class TypeArgPositionDetectorTest extends TestCase { public function testDetectsImmediatelyAfterOpenBracket(): void { - $source = 'new Box<'; + $source = 'new Box::<'; $hit = TypeArgPositionDetector::detect($source, strlen($source)); self::assertSame(['prefix' => '', 'containerName' => 'Box', 'slot' => 0], $hit); } public function testDetectsWithPartialIdentifierPrefix(): void { - $source = 'new Box 'Pla', 'containerName' => 'Box', 'slot' => 0], $hit); } public function testDetectsAfterCommaInMultiArgList(): void { - $source = 'new Pair '', 'containerName' => 'Pair', 'slot' => 1], $hit); @@ -33,9 +33,9 @@ public function testDetectsAfterCommaInMultiArgList(): void public function testDetectsInsideNestedGenericsAtSameDepth(): void { - $source = 'new Box, '; + $source = 'new Box::, '; $hit = TypeArgPositionDetector::detect($source, strlen($source)); - // Outermost generic is `Box`; the comma inside `List<...>` doesn't + // Outermost turbofish is `Box`; the comma inside `List<...>` doesn't // count toward Box's slot because it sits at depth 1. self::assertSame(['prefix' => '', 'containerName' => 'Box', 'slot' => 1], $hit); } @@ -47,6 +47,22 @@ public function testRejectsLessThanOperator(): void self::assertNull($hit); } + public function testRejectsBareAngleWithoutTurbofish(): void + { + // A bare `Box<` (no `::`) is NOT a call-site clause in 0.2.x. + $source = 'new Box<'; + $hit = TypeArgPositionDetector::detect($source, strlen($source)); + self::assertNull($hit); + } + + public function testRejectsAfterBareDoubleColon(): void + { + // `Foo::` without the `<` is a static-member access, not a turbofish. + $source = 'Foo::'; + $hit = TypeArgPositionDetector::detect($source, strlen($source)); + self::assertNull($hit); + } + public function testRejectsOutsideAnyTypeArgClause(): void { $source = '$x = new Box('; @@ -56,7 +72,7 @@ public function testRejectsOutsideAnyTypeArgClause(): void public function testRejectsAfterClosingBracket(): void { - $source = 'new Box '; + $source = 'new Box:: '; $hit = TypeArgPositionDetector::detect($source, strlen($source)); self::assertNull($hit, 'cursor past the closing `>` is no longer in type-arg context'); } @@ -64,18 +80,28 @@ public function testRejectsAfterClosingBracket(): void public function testAcceptsFqnStylePrefix(): void { // Backslashes are part of the identifier so an FQN prefix matches as one token. - $source = 'new Box`, prefix is the partial identifier just typed. - $source = 'new Box 'Pla', 'containerName' => 'List', 'slot' => 0], $hit); } @@ -87,97 +113,55 @@ public function testOffsetPastSourceLengthReturnsNull(): void public function testCursorAtOffsetZeroReturnsNull(): void { - // Probes the `$prefixStart > 0` guard at the boundary. With `>= 0`, - // we'd read source[-1] and crash. With `> 0`, the loop doesn't execute - // and prefixStart stays at 0; the backwards walk then has $i = -1 - // immediately, exits at `$i >= 0`, returns null. - $hit = TypeArgPositionDetector::detect('Box', 0); + $hit = TypeArgPositionDetector::detect('Box::', 0); self::assertNull($hit); } public function testCursorAfterTabSeparatorAcceptsTypeArgContext(): void { - // Locks the `$byte === "\t"` check in isInterArgByte. Each char of the - // chain on line 96 is its own Identical mutation; testing each - // independently is the only way to kill them. - $source = "new Box '', 'containerName' => 'Box', 'slot' => 1], $hit); } public function testCursorAfterNewlineSeparatorAcceptsTypeArgContext(): void { - // Locks the `$byte === "\n"` check. - $source = "new Box '', 'containerName' => 'Box', 'slot' => 1], $hit); } public function testCursorAfterCarriageReturnSeparatorAcceptsTypeArgContext(): void { - // Locks the `$byte === "\r"` check. - $source = "new Box '', 'containerName' => 'Box', 'slot' => 1], $hit); } public function testCursorAfterCommaWithoutSpaceAcceptsTypeArgContext(): void { - // Locks the `$byte === ','` check. - $source = "new Box '', 'containerName' => 'Box', 'slot' => 1], $hit); } public function testCursorAfterSpaceSeparatorAcceptsTypeArgContext(): void { - // Locks the `$byte === ' '` check. (Already implicitly covered by - // testDetectsAfterCommaInMultiArgList but isolated here to nail - // the specific char mutation.) - $source = "new Box '', 'containerName' => 'Box', 'slot' => 1], $hit); } public function testNonSeparatorAndNonIdentifierByteBreaksContext(): void { - // Inside `<…>`, encountering a byte that's neither identifier nor - // separator (e.g. `(`, `)`, `;`) terminates the backwards walk → - // returns null. Locks the `isInterArgByte($c) || isIdentifierByte($c)` - // OR-chain on line 77 by feeding a byte that fails both predicates. - $source = "new Box '', 'containerName' => 'A', 'slot' => 0], $hit); - } - - public function testOpenBracketAtOffsetZeroFailsIdentifierCheck(): void - { - // Source `<` — no character before the `<`. $j = -1. Original returns - // null. Locks the `$j < 0` guard against being weakened. - $source = '<'; + $source = "new Box::` correctly. With the - // `$depth++; $i--;` decrement removed via mutation, this case would - // infinite-loop (and infection reports it as a timeout, not an - // escape — still good signal). - $source = 'new Box, '; + $source = 'new Box::, '; $hit = TypeArgPositionDetector::detect($source, strlen($source)); self::assertSame(['prefix' => '', 'containerName' => 'Box', 'slot' => 1], $hit); } @@ -185,7 +169,7 @@ public function testDetectsAfterDepthBalancedNestedGenerics(): void public function testIdentifierAtReturnsFullNameAtCursorInsideGenericClause(): void { // Cursor sits in the middle of `User` -- prefix `Us`, suffix `er`. - $source = 'identity(new User())'; + $source = 'identity::(new User())'; $offset = strpos($source, 'User') + 2; // mid-identifier self::assertSame('User', TypeArgPositionDetector::identifierAt($source, $offset)); } @@ -201,16 +185,14 @@ public function testIdentifierAtReturnsNullOnWhitespaceInsideGenericClause(): vo { // Cursor on the space between `<` and `User`. No prefix to the // left, no identifier byte at the cursor -> null. - $source = 'identity< User>(...)'; + $source = 'identity::< User>(...)'; $offset = strpos($source, '< ') + 1; // on the space self::assertNull(TypeArgPositionDetector::identifierAt($source, $offset)); } public function testIdentifierAtReturnsFqnStyleNameWithBackslashes(): void { - // Backslashes are identifier bytes per the detector's rule, so a - // namespace-qualified type-arg comes through intact. - $source = 'identity(...)'; + $source = 'identity::(...)'; $offset = strpos($source, 'User') + 1; self::assertSame('App\\Models\\User', TypeArgPositionDetector::identifierAt($source, $offset)); } From 420541346d0dc91bcc2f11bab3be22925469311b Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sun, 7 Jun 2026 10:06:11 +0000 Subject: [PATCH 04/22] test(generics): migrate fixtures to the turbofish call syntax The 0.2.x parser rejects the old bare-angle call form, so every call-site generic in the feature specs and unit fixtures moves to the turbofish (`new Box::()`, `Class::method::(...)`, free-function `name::(...)`). Declaration clauses (`class Box`, `function f`, property/return type positions) keep the bare `<` as the language does. The completion specs that drive an incomplete `new Box::<` cursor have their step needles bumped to `Box::<` accordingly. Also route the analyzer's type-argument range finder through the shared turbofish scanner so the bound-violation "change type argument" quick-fix resolves its edit range for `::<` call sites. Co-Authored-By: Claude Opus 4.8 --- features/edit/bound_fixes.feature | 4 +- features/find/completion.feature | 20 ++++---- features/find/negative.feature | 4 +- features/navigate/definition.feature | 2 +- features/navigate/negative.feature | 2 +- features/understand/hover.feature | 2 +- features/understand/inlay_hints.feature | 2 +- features/validate/arg-types.feature | 2 +- features/validate/broadcast.feature | 2 +- features/validate/diagnostics.feature | 4 +- src/Analyzer/WorkspaceAnalyzer.php | 16 +++--- test/Analyzer/CallArgumentCheckerTest.php | 4 +- .../ConstructorArgumentCheckerTest.php | 4 +- test/Analyzer/WorkspaceAnalyzerTest.php | 12 ++--- .../XphpDiagnosticsProviderTest.php | 22 ++++---- test/Handler/XphpCompletionHandlerTest.php | 30 +++++------ test/Handler/XphpDefinitionHandlerTest.php | 44 ++++++++-------- test/Handler/XphpHoverHandlerTest.php | 30 +++++------ test/Handler/XphpInlayHintHandlerTest.php | 2 +- test/Handler/XphpReferencesHandlerTest.php | 4 +- .../BoundErrorCodeActionProviderTest.php | 6 +-- test/Resolver/GenericResolverTest.php | 50 +++++++++---------- test/Resolver/PhpCompletionResolverTest.php | 12 ++--- test/Resolver/PhpDefinitionResolverTest.php | 8 +-- test/Resolver/PhpHoverResolverTest.php | 30 +++++------ 25 files changed, 159 insertions(+), 159 deletions(-) diff --git a/features/edit/bound_fixes.feature b/features/edit/bound_fixes.feature index 2a31f71..e956455 100644 --- a/features/edit/bound_fixes.feature +++ b/features/edit/bound_fixes.feature @@ -24,7 +24,7 @@ Feature: Quick-fixes for generic bound violations """ (); + $x = new Box::(); """ When I request code actions for the "xphp.bound" diagnostic in "/Use.xphp" Then a code action titled "Change type argument to Stringy" is offered @@ -42,7 +42,7 @@ Feature: Quick-fixes for generic bound violations """ (); + $x = new Box::(); """ When I request code actions for the "xphp.bound" diagnostic in "/Use.xphp" Then a code action titled "Add implements \Stringable to Money" is offered diff --git a/features/find/completion.feature b/features/find/completion.feature index c88f420..6d6b5bc 100644 --- a/features/find/completion.feature +++ b/features/find/completion.feature @@ -14,10 +14,10 @@ Feature: Completion """ + $x = new Box::< """ And the FQN index has been warmed on initialize - When I request completion after "Box<" at line 2 of "/Use.xphp" + When I request completion after "Box::<" at line 2 of "/Use.xphp" Then a completion item labeled "" is offered And no completion item labeled "" is offered @@ -104,9 +104,9 @@ Feature: Completion """ (new User('Alice'), new User('Bob')); + $users = new Collection::(new User('Alice'), new User('Bob')); $first = $users->first(); """ And the FQN index has been warmed on initialize diff --git a/features/navigate/negative.feature b/features/navigate/negative.feature index ee191bd..dcb94b5 100644 --- a/features/navigate/negative.feature +++ b/features/navigate/negative.feature @@ -7,7 +7,7 @@ Feature: Navigation when there is nothing to find """ (); + $x = new Missing::(); """ And the FQN index has been warmed on initialize When I request "textDocument/definition" on "Missing" at line 2 of "/Use.xphp" diff --git a/features/understand/hover.feature b/features/understand/hover.feature index af4cfe2..f5b2e6f 100644 --- a/features/understand/hover.feature +++ b/features/understand/hover.feature @@ -7,7 +7,7 @@ Feature: Hover """ (); + $x = new Box::(); """ And the FQN index has been warmed on initialize When I request "textDocument/hover" on "Box" at line 2 of "/doc.xphp" diff --git a/features/understand/inlay_hints.feature b/features/understand/inlay_hints.feature index f34338c..7e26ef4 100644 --- a/features/understand/inlay_hints.feature +++ b/features/understand/inlay_hints.feature @@ -23,7 +23,7 @@ Feature: Inlay hints (); + $users = new Collection::(); $first = $users->first(); """ And the FQN index has been warmed on initialize diff --git a/features/validate/arg-types.feature b/features/validate/arg-types.feature index 1c4bbd8..c761633 100644 --- a/features/validate/arg-types.feature +++ b/features/validate/arg-types.feature @@ -88,7 +88,7 @@ Feature: Argument-type checking across call shapes """ (); + $c = new Collection::(); $c->add(new Tag()); """ And the FQN index has been warmed on initialize diff --git a/features/validate/broadcast.feature b/features/validate/broadcast.feature index 6d15a1e..6ba0533 100644 --- a/features/validate/broadcast.feature +++ b/features/validate/broadcast.feature @@ -14,7 +14,7 @@ Feature: Cross-file diagnostic broadcast """ (); + $x = new Box::(); """ And the FQN index has been warmed on initialize And the diagnostics service is running diff --git a/features/validate/diagnostics.feature b/features/validate/diagnostics.feature index 0b6e12b..f29c08e 100644 --- a/features/validate/diagnostics.feature +++ b/features/validate/diagnostics.feature @@ -68,7 +68,7 @@ Feature: Diagnostics """ (); + $x = new Box::(); """ And the FQN index has been warmed on initialize When I analyze "/Use.xphp" for diagnostics @@ -106,7 +106,7 @@ Feature: Diagnostics use App\Containers\StringableBox; use App\Models\Tag; use App\Models\User; - $v = new StringableBox(new User()); + $v = new StringableBox::(new User()); """ And the FQN index has been warmed on initialize When I analyze "/Bounds.xphp" for diagnostics diff --git a/src/Analyzer/WorkspaceAnalyzer.php b/src/Analyzer/WorkspaceAnalyzer.php index f4fe968..41f43ba 100644 --- a/src/Analyzer/WorkspaceAnalyzer.php +++ b/src/Analyzer/WorkspaceAnalyzer.php @@ -10,6 +10,7 @@ use PhpParser\NodeTraverser; use PhpParser\NodeVisitorAbstract; use RuntimeException; +use XPHP\Lsp\Handler\TurbofishScanner; use XPHP\Lsp\PositionMap; use XPHP\Lsp\Resolver\BoundExprView; use XPHP\Transpiler\Monomorphize\Registry; @@ -461,19 +462,18 @@ public function buildBoundFixData( */ private static function typeArgRange(string $source, int $fromOffset, int $index, PositionMap $positionMap): ?array { - $len = strlen($source); - $i = $fromOffset; - while ($i < $len && ctype_space($source[$i])) { - $i++; - } - if ($i >= $len || $source[$i] !== '<') { + // Call-site generic args use the turbofish `Name::<…>`; locate the + // clause via the shared scanner ($fromOffset is one past the name end). + $clause = TurbofishScanner::clauseAfter($source, $fromOffset - 1); + if ($clause === null) { return null; } - $i++; + $i = $clause['openPos'] + 1; + $closePos = $clause['closePos']; $depth = 0; $segmentStart = $i; $segments = []; - for (; $i < $len; $i++) { + for (; $i <= $closePos; $i++) { $ch = $source[$i]; if ($ch === '<') { $depth++; diff --git a/test/Analyzer/CallArgumentCheckerTest.php b/test/Analyzer/CallArgumentCheckerTest.php index a12a993..955393c 100644 --- a/test/Analyzer/CallArgumentCheckerTest.php +++ b/test/Analyzer/CallArgumentCheckerTest.php @@ -89,7 +89,7 @@ final class Tag {} '/Use.xphp' => <<<'PHP' (); + $c = new Collection::(); $c->add(new Tag()); PHP, ]); @@ -118,7 +118,7 @@ final class User {} '/Use.xphp' => <<<'PHP' (); + $c = new Collection::(); $c->add(new User()); PHP, ]); diff --git a/test/Analyzer/ConstructorArgumentCheckerTest.php b/test/Analyzer/ConstructorArgumentCheckerTest.php index 7c81bf0..2358d7a 100644 --- a/test/Analyzer/ConstructorArgumentCheckerTest.php +++ b/test/Analyzer/ConstructorArgumentCheckerTest.php @@ -46,7 +46,7 @@ final class User {} use App\Containers\StringableBox; use App\Models\Tag; use App\Models\User; - $v = new StringableBox(new User()); + $v = new StringableBox::(new User()); PHP, ]); @@ -81,7 +81,7 @@ public function __toString(): string { return 'tag'; } namespace App\Demos; use App\Containers\StringableBox; use App\Models\Tag; - $v = new StringableBox(new Tag()); + $v = new StringableBox::(new Tag()); PHP, ]); diff --git a/test/Analyzer/WorkspaceAnalyzerTest.php b/test/Analyzer/WorkspaceAnalyzerTest.php index 87cb3c2..99c1b44 100644 --- a/test/Analyzer/WorkspaceAnalyzerTest.php +++ b/test/Analyzer/WorkspaceAnalyzerTest.php @@ -27,7 +27,7 @@ class Box '/Use.xphp' => <<<'PHP' (); + $x = new Box::(); PHP, ]); @@ -60,7 +60,7 @@ class Box '/Use.xphp' => <<<'PHP' (); + $x = new Box::(); PHP, ]); @@ -163,7 +163,7 @@ public function __toString(): string { return ''; } '/Use.xphp' => <<<'PHP' (); + $x = new Box::(); PHP, ]); @@ -219,7 +219,7 @@ public function testHierarchyAstsEnrichBoundCheckWithoutBeingWalked(): void (new Tag('hi')); + $x = new Box::(new Tag('hi')); PHP, ]); $hierarchyAsts = $this->parseAstOnly([ @@ -270,7 +270,7 @@ public function __toString(): string { return ''; } (new Tag()); + $x = new Box::(new Tag()); PHP, ]); $hierarchyAsts = $this->parseAstOnly([ @@ -316,7 +316,7 @@ class User {} // no \Stringable (new User()); + $bad = new Box::(new User()); PHP, ]); $hierarchyAsts = $this->parseAstOnly([ diff --git a/test/Diagnostics/XphpDiagnosticsProviderTest.php b/test/Diagnostics/XphpDiagnosticsProviderTest.php index 6880b30..280788d 100644 --- a/test/Diagnostics/XphpDiagnosticsProviderTest.php +++ b/test/Diagnostics/XphpDiagnosticsProviderTest.php @@ -72,7 +72,7 @@ class Box $useDoc = $this->openDoc($workspace, '/Use.xphp', <<<'XPHP' (); + $x = new Box::(); XPHP); $diagnostics = $this->lint($workspace,$useDoc); @@ -153,7 +153,7 @@ class Box { public T $item; } $useDoc = $this->openDoc($workspace, '/Use.xphp', <<<'XPHP' (); + $x = new Box::(); XPHP); $diagnostics = $this->lint($workspace, $useDoc); @@ -209,7 +209,7 @@ public function __construct(public T $item) {} (new Tag('hi')); + $x = new Box::(new Tag('hi')); XPHP); $provider = new XphpDiagnosticsProvider($cache, new WorkspaceAnalyzer(), $workspace, $fqnIndex); @@ -302,7 +302,7 @@ public function __construct(public T $item) {} (new Tag('x')); + $x = new Box::(new Tag('x')); XPHP); $provider = new XphpDiagnosticsProvider($cache, new WorkspaceAnalyzer(), $workspace, $fqnIndex); @@ -368,7 +368,7 @@ public function __construct(public readonly string $name) {} namespace App\Demos; use App\Containers\StringableBox; use App\Models\User; - $bad = new StringableBox(new User('x')); + $bad = new StringableBox::(new User('x')); XPHP); $provider = new XphpDiagnosticsProvider($cache, new WorkspaceAnalyzer(), $workspace, $fqnIndex); @@ -428,7 +428,7 @@ public function __construct(public T $item) {} (new Plain()); + $x = new Box::(new Plain()); XPHP); $provider = new XphpDiagnosticsProvider($cache, new WorkspaceAnalyzer(), $workspace, $fqnIndex); @@ -464,7 +464,7 @@ class Box { public T $item; } $useDoc = $this->openDoc($workspace, '/Use.xphp', <<<'XPHP' (); + $x = new Box::(); XPHP); $diagnostics = $this->lint($workspace, $useDoc); @@ -494,7 +494,7 @@ class Box { public T $item; } $this->openDoc($workspace, '/Use.xphp', <<<'XPHP' (); + $x = new Box::(); XPHP); wait($provider->provideDiagnostics($boxDoc, (new CancellationTokenSource())->getToken())); @@ -522,7 +522,7 @@ class Box { public T $item; } $this->openDoc($workspace, '/Use.xphp', <<<'XPHP' (); + $x = new Box::(); XPHP); $token = (new CancellationTokenSource())->getToken(); @@ -547,7 +547,7 @@ public function testTheLintedDocumentIsNotBroadcastByTheProvider(): void $useDoc = $this->openDoc($workspace, '/Use.xphp', <<<'XPHP' (); + $x = new Box::(); XPHP); $this->openDoc($workspace, '/Box.xphp', <<<'XPHP' warmNow(); - $useSource = "(new Tag());\n"; + $useSource = "(new Tag());\n"; $provider = new XphpDiagnosticsProvider($cache, new WorkspaceAnalyzer(), $workspace, $fqnIndex); $useA = $this->openDoc($workspace, 'file://' . $root . '/pkgA/Use.xphp', $useSource); diff --git a/test/Handler/XphpCompletionHandlerTest.php b/test/Handler/XphpCompletionHandlerTest.php index 8585adf..05c6dcf 100644 --- a/test/Handler/XphpCompletionHandlerTest.php +++ b/test/Handler/XphpCompletionHandlerTest.php @@ -36,7 +36,7 @@ class Plastic {} class Metal {} XPHP)); // Cursor at end of `Box<` line — type-arg position with empty prefix. - $useSource = "open(new TextDocumentItem('/Use.xphp', 'xphp', 1, $useSource)); $list = $this->complete($workspace, '/Use.xphp', $useSource, strlen($useSource)); @@ -67,7 +67,7 @@ public function testInsertsShortNameWhenFqnIsAlreadyImported(): void namespace App\Models; class Plastic {} XPHP)); - $useSource = "open(new TextDocumentItem('/Use.xphp', 'xphp', 1, $useSource)); $list = $this->complete($workspace, '/Use.xphp', $useSource, strlen($useSource)); @@ -89,7 +89,7 @@ public function testInsertsShortNameWhenCandidateIsInSameNamespace(): void namespace App\Models; class Plastic {} XPHP)); - $useSource = "open(new TextDocumentItem('/Use.xphp', 'xphp', 1, $useSource)); $list = $this->complete($workspace, '/Use.xphp', $useSource, strlen($useSource)); @@ -111,7 +111,7 @@ public function testInsertsAliasedShortNameForAliasedUse(): void namespace App\Models; class Plastic {} XPHP)); - $useSource = "open(new TextDocumentItem('/Use.xphp', 'xphp', 1, $useSource)); $list = $this->complete($workspace, '/Use.xphp', $useSource, strlen($useSource)); @@ -142,7 +142,7 @@ class Plastic {} namespace App\Other; class Plastic {} XPHP)); - $useSource = "open(new TextDocumentItem('/Use.xphp', 'xphp', 1, $useSource)); $list = $this->complete($workspace, '/Use.xphp', $useSource, strlen($useSource)); @@ -173,7 +173,7 @@ class Plastic {} class Metal {} class Wood {} XPHP)); - $useSource = "open(new TextDocumentItem('/Use.xphp', 'xphp', 1, $useSource)); $list = $this->complete($workspace, '/Use.xphp', $useSource, strlen($useSource)); @@ -216,7 +216,7 @@ public function testEmptyPrefixIncludesAllScalars(): void // weakened/inverted (e.g. `=== ''`), the scalar loop would skip every // item even though prefix is empty. $workspace = new PhpactorWorkspace(); - $useSource = "open(new TextDocumentItem('/Use.xphp', 'xphp', 1, $useSource)); $list = $this->complete($workspace, '/Use.xphp', $useSource, strlen($useSource)); @@ -232,7 +232,7 @@ public function testScalarFilteringByPrefix(): void // the prefix). Without it, every scalar would surface regardless of // prefix. $workspace = new PhpactorWorkspace(); - $useSource = "open(new TextDocumentItem('/Use.xphp', 'xphp', 1, $useSource)); $list = $this->complete($workspace, '/Use.xphp', $useSource, strlen($useSource)); @@ -259,7 +259,7 @@ class Plastic {} class Metal {} class Stone {} XPHP)); - $useSource = "open(new TextDocumentItem('/Use.xphp', 'xphp', 1, $useSource)); $list = $this->complete($workspace, '/Use.xphp', $useSource, strlen($useSource)); @@ -281,7 +281,7 @@ public function testEmptyAfterLtrimPrefixReturnsAllCandidates(): void namespace App\Models; class Plastic {} XPHP)); - $useSource = "open(new TextDocumentItem('/Use.xphp', 'xphp', 1, $useSource)); $list = $this->complete($workspace, '/Use.xphp', $useSource, strlen($useSource)); @@ -303,7 +303,7 @@ class Thing {} XPHP)); // Prefix "Deep" is a substring of the FQN but NOT a prefix of the // short name "Thing" — must still surface as a candidate. - $useSource = "open(new TextDocumentItem('/Use.xphp', 'xphp', 1, $useSource)); $list = $this->complete($workspace, '/Use.xphp', $useSource, strlen($useSource)); @@ -343,7 +343,7 @@ class Number {} XPHP); $workspace = new PhpactorWorkspace(); - $useSource = "open(new TextDocumentItem('/Use.xphp', 'xphp', 1, $useSource)); $list = $this->completeBoundAware($workspace, '/Use.xphp', $useSource, strlen($useSource), $root); @@ -376,7 +376,7 @@ private function rmrfPath(string $dir): void public function testBoundedTypeArgFiltersToSubtypesAndDropsScalars(): void { // Phase 3: `Box` constrains the type arg. Completion - // at `new Box<|` must surface only candidates that satisfy the + // at `new Box::<|` must surface only candidates that satisfy the // bound (subclasses or implementors of Stringable), drop scalars // (a scalar can't be Stringable), and keep classes that don't. $workspace = new PhpactorWorkspace(); @@ -393,7 +393,7 @@ public function __toString(): string { return ''; } } class Number {} XPHP)); - $useSource = "open(new TextDocumentItem('/Use.xphp', 'xphp', 1, $useSource)); $list = $this->completeBoundAware($workspace, '/Use.xphp', $useSource, strlen($useSource)); @@ -424,7 +424,7 @@ public function __toString(): string { return ''; } } class Number {} XPHP)); - $useSource = "open(new TextDocumentItem('/Use.xphp', 'xphp', 1, $useSource)); $list = $this->completeBoundAware($workspace, '/Use.xphp', $useSource, strlen($useSource)); diff --git a/test/Handler/XphpDefinitionHandlerTest.php b/test/Handler/XphpDefinitionHandlerTest.php index 4fe6f3c..d42676f 100644 --- a/test/Handler/XphpDefinitionHandlerTest.php +++ b/test/Handler/XphpDefinitionHandlerTest.php @@ -37,13 +37,13 @@ class Box $useSource = <<<'XPHP' (); + $x = new Box::(); XPHP; $workspace->open(new TextDocumentItem('/Box.xphp', 'xphp', 1, $boxSource)); $workspace->open(new TextDocumentItem('/Use.xphp', 'xphp', 1, $useSource)); $handler = $this->newHandler($workspace); - $location = $this->definitionAt($handler, '/Use.xphp', $useSource, 'Box'); + $location = $this->definitionAt($handler, '/Use.xphp', $useSource, 'Box::'); self::assertInstanceOf(Location::class, $location); self::assertSame('/Box.xphp', $location->uri); @@ -80,12 +80,12 @@ public function testTemplateNotInOpenWorkspaceReturnsNull(): void $useSource = <<<'XPHP' (); + $x = new Box::(); XPHP; $workspace->open(new TextDocumentItem('/Use.xphp', 'xphp', 1, $useSource)); $handler = $this->newHandler($workspace); - $location = $this->definitionAt($handler, '/Use.xphp', $useSource, 'Box'); + $location = $this->definitionAt($handler, '/Use.xphp', $useSource, 'Box::'); self::assertNull($location); } @@ -126,11 +126,11 @@ public function testTargetRangeCoversTheFullIdentifierLength(): void namespace App; class Container { public T $item; } XPHP; - $useSource = "();"; + $useSource = "();"; $workspace->open(new TextDocumentItem('/Container.xphp', 'xphp', 1, $boxSource)); $workspace->open(new TextDocumentItem('/Use.xphp', 'xphp', 1, $useSource)); - $location = $this->definitionAt($this->newHandler($workspace), '/Use.xphp', $useSource, 'Container'); + $location = $this->definitionAt($this->newHandler($workspace), '/Use.xphp', $useSource, 'Container::'); self::assertNotNull($location); $charCount = $location->range->end->character - $location->range->start->character; @@ -156,10 +156,10 @@ class Helper {} namespace App; class Container { public T $item; } XPHP)); - $useSource = "();"; + $useSource = "();"; $workspace->open(new TextDocumentItem('/Use.xphp', 'xphp', 1, $useSource)); - $location = $this->definitionAt($this->newHandler($workspace), '/Use.xphp', $useSource, 'Container'); + $location = $this->definitionAt($this->newHandler($workspace), '/Use.xphp', $useSource, 'Container::'); self::assertNotNull($location); self::assertSame('/Container.xphp', $location->uri); @@ -168,7 +168,7 @@ class Container { public T $item; } public function testJumpsFromTypeArgInsideGenericClauseToClassDeclaration(): void { // The xphp-specific case: Ctrl+click on `User` inside the `<>` of - // `identity(...)` should land on `class User`. This relies on + // `identity::(...)` should land on `class User`. This relies on // the second code path in `definition()` -- the inner `User` doesn't // survive as a Name node in the AST (XphpSourceParser strips it into // a marker entry on the outer FuncCall), so the ATTR_TEMPLATE_FQN @@ -180,12 +180,12 @@ public function testJumpsFromTypeArgInsideGenericClauseToClassDeclaration(): voi namespace App; class User { public function __construct(public string $name) {} } XPHP)); - $useSource = "(new User('bob'));"; + $useSource = "(new User('bob'));"; $workspace->open(new TextDocumentItem('/Use.xphp', 'xphp', 1, $useSource)); // Cursor points at the `User` INSIDE the angle brackets, not the // `User` in the `new User(...)` ctor. - $genericClauseStart = strpos($useSource, 'identity<') + strlen('identity<'); + $genericClauseStart = strpos($useSource, 'identity::<') + strlen('identity::<'); $location = $this->definitionAtOffset($this->newHandler($workspace), '/Use.xphp', $useSource, $genericClauseStart + 1); self::assertNotNull($location); @@ -198,7 +198,7 @@ public function testTypeArgFallthroughReturnsNullWhenClassNotInWorkspace(): void // Distinguishes "we tried Path 2 and didn't find anything" from a // wiring bug. $workspace = new PhpactorWorkspace(); - $useSource = "(null);"; + $useSource = "(null);"; $workspace->open(new TextDocumentItem('/Use.xphp', 'xphp', 1, $useSource)); $offset = strpos($useSource, 'Unknown') + 1; @@ -226,10 +226,10 @@ class Box XPHP); $workspace = new PhpactorWorkspace(); - $useSource = "();\n"; + $useSource = "();\n"; $workspace->open(new TextDocumentItem('/Use.xphp', 'xphp', 1, $useSource)); - // Cursor on `Box` of `new Box()`. + // Cursor on `Box` of `new Box::()`. $byte = strpos($useSource, 'new Box') + strlen('new '); $location = $this->definitionAtOffset($this->newHandler($workspace, $root), '/Use.xphp', $useSource, $byte); @@ -260,9 +260,9 @@ class User {} XPHP); $workspace = new PhpactorWorkspace(); - // identity(...) -- the `User` identifier is INSIDE the + // identity::(...) -- the `User` identifier is INSIDE the // generic clause, only reachable via TypeArgPositionDetector. - $useSource = "(T \$x): T { return \$x; }\n\$x = identity(null);\n"; + $useSource = "(T \$x): T { return \$x; }\n\$x = identity::(null);\n"; $workspace->open(new TextDocumentItem('/Use.xphp', 'xphp', 1, $useSource)); // Cursor on `User`. @@ -297,7 +297,7 @@ class Box {} $editedSource = " {}\n"; $workspace->open(new TextDocumentItem('/edit/Box.xphp', 'xphp', 1, $editedSource)); - $useSource = "();\n"; + $useSource = "();\n"; $workspace->open(new TextDocumentItem('/Use.xphp', 'xphp', 1, $useSource)); $byte = strpos($useSource, 'new Box') + strlen('new '); @@ -447,7 +447,7 @@ class Collection public function first(): ?T { return null; } } XPHP)); - $useSource = "();\n\$first = \$users->first();\n"; + $useSource = "();\n\$first = \$users->first();\n"; $workspace->open(new TextDocumentItem('/Use.xphp', 'xphp', 1, $useSource)); $byte = strpos($useSource, '->first') + strlen('->'); // cursor on `first` @@ -498,12 +498,12 @@ public function testReturnsResultWhenCancelTokenNotRequested(): void // short-circuit on a fresh token and break happy-path GTD. $workspace = new PhpactorWorkspace(); $boxSource = " {}\n"; - $useSource = "();\n"; + $useSource = "();\n"; $workspace->open(new TextDocumentItem('/Box.xphp', 'xphp', 1, $boxSource)); $workspace->open(new TextDocumentItem('/Use.xphp', 'xphp', 1, $useSource)); $handler = $this->newHandler($workspace); - $byte = strpos($useSource, 'Box'); + $byte = strpos($useSource, 'Box::'); self::assertNotFalse($byte); [$line, $character] = (new PositionMap($useSource))->offsetToPosition($byte); $params = new DefinitionParams( @@ -523,12 +523,12 @@ public function testReturnsNullWhenCancelTokenAlreadyRequested(): void { $workspace = new PhpactorWorkspace(); $boxSource = " {}\n"; - $useSource = "();\n"; + $useSource = "();\n"; $workspace->open(new TextDocumentItem('/Box.xphp', 'xphp', 1, $boxSource)); $workspace->open(new TextDocumentItem('/Use.xphp', 'xphp', 1, $useSource)); $handler = $this->newHandler($workspace); - $byte = strpos($useSource, 'Box'); + $byte = strpos($useSource, 'Box::'); self::assertNotFalse($byte); [$line, $character] = (new PositionMap($useSource))->offsetToPosition($byte); $params = new DefinitionParams( diff --git a/test/Handler/XphpHoverHandlerTest.php b/test/Handler/XphpHoverHandlerTest.php index 4839d55..7d27067 100644 --- a/test/Handler/XphpHoverHandlerTest.php +++ b/test/Handler/XphpHoverHandlerTest.php @@ -27,10 +27,10 @@ public function testHoverOverGenericInstantiationShowsSpecializedFqn(): void [$handler, $workspace, $uri] = $this->prepare(<<<'XPHP' (); + $x = new Box::(); XPHP); // Cursor on the `B` of `Box`. - $hover = $this->hoverAt($handler, $uri, $workspace->get($uri)->text, 'Box'); + $hover = $this->hoverAt($handler, $uri, $workspace->get($uri)->text, 'Box::'); self::assertInstanceOf(Hover::class, $hover); self::assertInstanceOf(MarkupContent::class, $hover->contents); @@ -306,7 +306,7 @@ public function testHoverInsideAngleClauseResolvesTypeArgFqn(): void [$handler, $workspace, $uri] = $this->prepare(<<<'XPHP' (); + $bounded = new StringableBox::<\App\Models\Tag>(); XPHP); $source = $workspace->get($uri)->text; // Cursor on the `T` of `Tag` inside the angle clause. @@ -327,7 +327,7 @@ public function testHoverOnAngleDelimiterReturnsNull(): void [$handler, $workspace, $uri] = $this->prepare(<<<'XPHP' (); + $bounded = new StringableBox::<\App\Models\Tag>(); XPHP); $source = $workspace->get($uri)->text; $hover = $this->hoverAt($handler, $uri, $source, '<\\App\\Models\\Tag'); @@ -343,7 +343,7 @@ public function testHoverInsideAngleClausePicksSecondArgByComma(): void [$handler, $workspace, $uri] = $this->prepare(<<<'XPHP' (); + $pair = new Pair::<\App\Models\Tag, \App\Models\User>(); XPHP); $source = $workspace->get($uri)->text; // Cursor on `U` of `User` (second arg). @@ -396,7 +396,7 @@ public function testHoverInsideAngleClauseOnScalarReturnsNull(): void [$handler, $workspace, $uri] = $this->prepare(<<<'XPHP' (); + $b = new Box::(); XPHP); $source = $workspace->get($uri)->text; $hover = $this->hoverAt($handler, $uri, $source, 'int>'); @@ -415,8 +415,8 @@ public function testHoverPicksSecondAngleClauseHitWhenCursorIsThere(): void [$handler, $workspace, $uri] = $this->prepare(<<<'XPHP' (); - $b = new Box<\App\Models\User>(); + $a = new Box::<\App\Models\Tag>(); + $b = new Box::<\App\Models\User>(); XPHP); $source = $workspace->get($uri)->text; // Cursor on the second occurrence of `Tag` or `User`. We use @@ -437,8 +437,8 @@ public function testHoverPicksFirstAngleClauseHitInDocument(): void [$handler, $workspace, $uri] = $this->prepare(<<<'XPHP' (); - $b = new Box<\App\Models\User>(); + $a = new Box::<\App\Models\Tag>(); + $b = new Box::<\App\Models\User>(); XPHP); $source = $workspace->get($uri)->text; // Cursor on first `Tag`. @@ -459,7 +459,7 @@ public function testHoverAtFirstByteInsideAngleClauseResolves(): void [$handler, $workspace, $uri] = $this->prepare(<<<'XPHP' (); + $b = new Box::<\App\Models\Tag>(); XPHP); $source = $workspace->get($uri)->text; $byte = strpos($source, '<\\App'); @@ -485,7 +485,7 @@ public function testHoverAtLastByteInsideAngleClauseResolves(): void [$handler, $workspace, $uri] = $this->prepare(<<<'XPHP' (); + $b = new Box::<\App\Models\Tag>(); XPHP); $source = $workspace->get($uri)->text; $byte = strpos($source, 'Tag>'); @@ -509,7 +509,7 @@ public function testFindAngleRangeSkipsWhitespaceBetweenNameAndAngle(): void // pointing at the first whitespace byte; the subsequent // `$source[$i] !== '<'` check would then return null and // we'd miss the clause entirely. - $source = 'StringableBox '; + $source = 'StringableBox ::'; $range = XphpHoverHandler::findAngleRange($source, strlen('StringableBox') - 1); self::assertNotNull($range); self::assertSame(strpos($source, '<'), $range['openPos']); @@ -523,7 +523,7 @@ public function testFindAngleRangeReturnsNullForUnterminatedClause(): void // return null;` check, the function would still return a // bogus closePos (the end of source), which would then // produce an absurd innerText extending past the actual EOF. - $source = 'Box`, not the first. Locks the depth-tracking // (`$depth > 0` and the `<`/`>` increment/decrement) in the // match loop. - $source = 'Box>'; + $source = 'Box::>'; $range = XphpHoverHandler::findAngleRange($source, strlen('Box') - 1); self::assertNotNull($range); self::assertSame(strpos($source, '<'), $range['openPos']); diff --git a/test/Handler/XphpInlayHintHandlerTest.php b/test/Handler/XphpInlayHintHandlerTest.php index f951471..0ec4f3b 100644 --- a/test/Handler/XphpInlayHintHandlerTest.php +++ b/test/Handler/XphpInlayHintHandlerTest.php @@ -55,7 +55,7 @@ public function first(): ?T { return null; } '/Use.xphp', 'xphp', 1, - "();\n\$first = \$users->first();\n", + "();\n\$first = \$users->first();\n", )); $hints = $this->hintsFor($workspace, '/Use.xphp'); diff --git a/test/Handler/XphpReferencesHandlerTest.php b/test/Handler/XphpReferencesHandlerTest.php index df56388..70df992 100644 --- a/test/Handler/XphpReferencesHandlerTest.php +++ b/test/Handler/XphpReferencesHandlerTest.php @@ -810,7 +810,7 @@ public function testReturnsResultWhenCancelTokenNotRequested(): void // flipping `isRequested` to `!isRequested` would short-circuit // even for fresh tokens, leaving every references call empty. $workspace = new PhpactorWorkspace(); - $source = " {}\n\$x = new Box();\n"; + $source = " {}\n\$x = new Box::();\n"; $workspace->open(new TextDocumentItem('/Box.xphp', 'xphp', 1, $source)); $handler = $this->handler($workspace); @@ -833,7 +833,7 @@ public function testReturnsResultWhenCancelTokenNotRequested(): void public function testReturnsEmptyArrayWhenCancelTokenAlreadyRequested(): void { $workspace = new PhpactorWorkspace(); - $source = " {}\n\$x = new Box();\n"; + $source = " {}\n\$x = new Box::();\n"; $workspace->open(new TextDocumentItem('/Box.xphp', 'xphp', 1, $source)); $handler = $this->handler($workspace); diff --git a/test/Resolver/BoundErrorCodeActionProviderTest.php b/test/Resolver/BoundErrorCodeActionProviderTest.php index fae6bfd..3634e60 100644 --- a/test/Resolver/BoundErrorCodeActionProviderTest.php +++ b/test/Resolver/BoundErrorCodeActionProviderTest.php @@ -41,7 +41,7 @@ public function testScalarConcreteOffersSwapButNotImplementInterface(): void $actions = $this->actionsForUse([ '/Stringy.xphp' => self::STRINGY, '/Box.xphp' => self::BOX, - '/Use.xphp' => "();\n", + '/Use.xphp' => "();\n", ]); $titles = self::titles($actions); @@ -63,7 +63,7 @@ public function testWorkspaceClassConcreteOffersBothFixes(): void '/Stringy.xphp' => self::STRINGY, '/Box.xphp' => self::BOX, '/Money.xphp' => " "();\n", + '/Use.xphp' => "();\n", ]); $titles = self::titles($actions); @@ -84,7 +84,7 @@ public function testNoFixesWhenConcreteAlreadyImplementsViaAnotherViolation(): v $actions = $this->actionsForUse([ '/Stringy.xphp' => self::STRINGY, '/Pair.xphp' => " { public A \$a; public B \$b; }\n", - '/Use.xphp' => "();\n", + '/Use.xphp' => "();\n", ]); $swap = self::actionTitled($actions, 'Change type argument to Stringy'); diff --git a/test/Resolver/GenericResolverTest.php b/test/Resolver/GenericResolverTest.php index 178a925..9af76ff 100644 --- a/test/Resolver/GenericResolverTest.php +++ b/test/Resolver/GenericResolverTest.php @@ -31,7 +31,7 @@ public function testSubstitutesNullableTypeParamFromGenericMethodCall(): void (); + $users = new Collection::(); $user = $users->first(); XPHP); @@ -53,7 +53,7 @@ public function testRendersReceiverVariableWithTypeArgList(): void (); + $users = new Collection::(); XPHP); $resolver = $this->resolver($workspace); @@ -78,7 +78,7 @@ public function value(): T { return null; } $this->open($workspace, '/Use.xphp', <<<'XPHP' (); + $w = new Wrapper::(); $v = $w->value(); XPHP); @@ -104,7 +104,7 @@ public function value(): V { return null; } (); + $p = new Pair::(); $k = $p->key(); $v = $p->value(); XPHP); @@ -133,12 +133,12 @@ public function testNonGenericInstantiationReturnsNull(): void public function testUnknownReceiverClassReturnsNull(): void { - // `$x = new Mystery()` where Mystery isn't declared in any + // `$x = new Mystery::()` where Mystery isn't declared in any // open document -- ClassLikeLookup misses, resolver yields. $workspace = $this->workspace(); $this->open($workspace, '/Use.xphp', <<<'XPHP' (); + $x = new Mystery::(); XPHP); $resolver = $this->resolver($workspace); @@ -148,7 +148,7 @@ public function testUnknownReceiverClassReturnsNull(): void public function testStaticMethodCallSubstitutesReturnTypeAtCallSite(): void { - // Phase 1.2: `Util::identity(new User())` -- the method's + // Phase 1.2: `Util::identity::(new User())` -- the method's // type-param T is bound to User at the call site, so the // substituted return type is User. The resolver previously // returned null for this shape; this test pins the new behavior. @@ -165,7 +165,7 @@ public static function identity(T $x): T { return $x; } (new User()); + $u = Util::identity::(new User()); XPHP); $resolver = $this->resolver($workspace); @@ -178,7 +178,7 @@ public static function identity(T $x): T { return $x; } public function testStaticMethodCallWithQualifiedClassName(): void { - // `\App\Util::identity(...)` -- already-qualified class + // `\App\Util::identity::(...)` -- already-qualified class // names bypass the use map. $workspace = $this->workspace(); $this->open($workspace, '/Util.xphp', <<<'XPHP' @@ -191,7 +191,7 @@ public static function identity(T $x): T { return $x; } $this->openUser($workspace); $this->open($workspace, '/Use.xphp', <<<'XPHP' (new \App\Models\User()); + $u = \App\Util::identity::<\App\Models\User>(new \App\Models\User()); XPHP); $resolver = $this->resolver($workspace); @@ -227,7 +227,7 @@ public static function greet(): string { return ''; } public function testGenericFunctionCallSubstitutesReturnType(): void { - // Phase 1.3: free-function generic `identity(new User())`. + // Phase 1.3: free-function generic `identity::(new User())`. // The FuncCall carries ATTR_TEMPLATE_FQN + ATTR_METHOD_GENERIC_ARGS; // resolver locates the function via FqnIndex and substitutes T -> User. $workspace = $this->workspace(); @@ -241,7 +241,7 @@ function identity(T $x): T { return $x; } (new User()); + $u = identity::(new User()); XPHP); $resolver = $this->resolver($workspace); @@ -269,7 +269,7 @@ function identity(T $x): T { return $x; } $this->open($workspace, '/Use.xphp', <<<'XPHP' (new User()); + $u = \App\identity::(new User()); XPHP); $resolver = $this->resolverWithFilesystem($workspace, $root); @@ -329,7 +329,7 @@ public function first(): ?T { return null; } (); + $users = new Collection::(); $user = $users->first(); XPHP); @@ -547,7 +547,7 @@ public function first(): ?T { return null; } { - public function items(): Collection { return new Collection(); } + public function items(): Collection { return new Collection::(); } } XPHP); $this->openUser($workspace); @@ -555,7 +555,7 @@ public function items(): Collection { return new Collection(); } (); + $repo = new Repository::(); $user = $repo->items()->first(); XPHP); @@ -576,10 +576,10 @@ public function testThreeStepChainStillSubstitutes(): void { - public function inner(): Inner { return new Inner(); } + public function inner(): Inner { return new Inner::(); } } class Inner { - public function items(): Collection { return new Collection(); } + public function items(): Collection { return new Collection::(); } } class Collection { public function first(): ?T { return null; } @@ -590,7 +590,7 @@ public function first(): ?T { return null; } (); + $w = new Wrap::(); $u = $w->inner()->items()->first(); XPHP); @@ -615,7 +615,7 @@ public function testClosureCapturesOuterBinding(): void (); + $users = new Collection::(); $fn = function () use ($users) { $first = $users->first(); }; @@ -644,7 +644,7 @@ public function testClosureWithoutUseClauseCannotSeeOuterBinding(): void (); + $users = new Collection::(); $fn = function () { $first = $users->first(); }; @@ -671,7 +671,7 @@ public function testClosureParamSeededAlongsideCapture(): void (); + $outer = new Collection::(); $fn = function (Collection $param) use ($outer) { $a = $param->first(); $b = $outer->first(); @@ -704,7 +704,7 @@ public function testRebuildsBindingsOnDocumentVersionBump(): void (); + $users = new Collection::(); $user = $users->first(); XPHP); @@ -830,7 +830,7 @@ class Tag {} (new Tag()); + $v = new StringableBox::(new Tag()); $item = $v->item; XPHP)); @@ -859,7 +859,7 @@ class Tag {} (); + $b = new Box::(); $item = $b->item; XPHP)); diff --git a/test/Resolver/PhpCompletionResolverTest.php b/test/Resolver/PhpCompletionResolverTest.php index d01c0cb..fd457ef 100644 --- a/test/Resolver/PhpCompletionResolverTest.php +++ b/test/Resolver/PhpCompletionResolverTest.php @@ -837,7 +837,7 @@ public function testCompletesNativeFunctionsFromStubsByPrefix(): void public function testCompletesMembersOnReceiverFromGenericInstantiation(): void { // Mirrors the user-reported failing case in - // xphp-20260524-150655-167.log: `$users = new Collection(...)` + // xphp-20260524-150655-167.log: `$users = new Collection::(...)` // followed by `$users->|` on the next line. The xphp strip turns // `` into whitespace; worse-reflection should still infer // `$users: App\Containers\Collection` from the ctor and surface @@ -866,7 +866,7 @@ public function count(): int XPHP); $this->open($workspace, '/User.xphp', "(new User('a'));\n\$users->\necho 'done';\n"; + $useSource = "(new User('a'));\n\$users->\necho 'done';\n"; $this->open($workspace, '/Use.xphp', $useSource); $items = $this->completeAt($workspace, '/Use.xphp', $useSource, '$users->', strlen('$users->')); @@ -1022,7 +1022,7 @@ public function testCompletesVariablesWhenSourceMidEditDoesNotParseStrictly(): v public function testMemberCompletionSubstitutesGenericReceiverViaResolver(): void { // Mirrors the user-reported gap in xphp-20260524-214251-685.log: - // $users = new Collection(); + // $users = new Collection::(); // $u = $users->first(); // $u is ?T -> ?App\Models\User // $u->| // member completion here returned 0 // worse-reflection sees `?App\Containers\T` for the receiver, so @@ -1045,7 +1045,7 @@ class User { public function shout(): string { return ''; } } XPHP); - $useSource = "();\n\$u = \$users->first();\n\$u->\n"; + $useSource = "();\n\$u = \$users->first();\n\$u->\n"; $this->open($workspace, '/Use.xphp', $useSource); $items = $this->completeAt($workspace, '/Use.xphp', $useSource, '$u->', strlen('$u->')); @@ -1079,7 +1079,7 @@ class User { public function shout(): string { return ''; } } XPHP); - $useSource = "();\n\$repo->first()?->\n"; + $useSource = "();\n\$repo->first()?->\n"; $this->open($workspace, '/Use.xphp', $useSource); $items = $this->completeAt( @@ -1112,7 +1112,7 @@ public function count(): int { return 0; } } XPHP); $this->open($workspace, '/User.xphp', "();\n\$users->\n"; + $useSource = "();\n\$users->\n"; $this->open($workspace, '/Use.xphp', $useSource); $items = $this->completeAt($workspace, '/Use.xphp', $useSource, '$users->', strlen('$users->')); diff --git a/test/Resolver/PhpDefinitionResolverTest.php b/test/Resolver/PhpDefinitionResolverTest.php index ee3a4c8..1ca1fca 100644 --- a/test/Resolver/PhpDefinitionResolverTest.php +++ b/test/Resolver/PhpDefinitionResolverTest.php @@ -245,7 +245,7 @@ public function testPropertyAccessOnSubstitutedReceiverFromStaticCall(): void { // Originally a crash-safety test: pre-hotfix code crashed when // dispatching with `containerType=MissingType` (from - // `$asUser = Util::identity(...)` whose return-type + // `$asUser = Util::identity::(...)` whose return-type // resolved to a bare `T` worse-reflection couldn't find). // After Phase 1.2 (static-call substitution) + Phase 0.7 // (property-receiver substitution), the chain now resolves @@ -263,7 +263,7 @@ public static function identity(T $x): T { return $x; } } XPHP); $this->open($workspace, '/User.xphp', "(new User());\necho \$asUser->name;\n"; + $useSource = "(new User());\necho \$asUser->name;\n"; $this->open($workspace, '/Use.xphp', $useSource); $location = $this->resolveAt($workspace, '/Use.xphp', $useSource, '$asUser->name', strlen('$asUser->')); @@ -373,7 +373,7 @@ public function first(): ?T { return null; } } XPHP); $this->open($workspace, '/User.xphp', "();\necho \$repo->first()?->name;\n"; + $useSource = "();\necho \$repo->first()?->name;\n"; $this->open($workspace, '/Use.xphp', $useSource); $location = $this->resolveAt($workspace, '/Use.xphp', $useSource, '?->name', strlen('?->')); @@ -395,7 +395,7 @@ public function first(): ?T { return null; } } XPHP); $this->open($workspace, '/User.xphp', "();\n\$user = \$repo->first();\necho \$user?->name;\n"; + $useSource = "();\n\$user = \$repo->first();\necho \$user?->name;\n"; $this->open($workspace, '/Use.xphp', $useSource); $location = $this->resolveAt($workspace, '/Use.xphp', $useSource, '?->name', strlen('?->')); diff --git a/test/Resolver/PhpHoverResolverTest.php b/test/Resolver/PhpHoverResolverTest.php index 25f8d65..5befcd9 100644 --- a/test/Resolver/PhpHoverResolverTest.php +++ b/test/Resolver/PhpHoverResolverTest.php @@ -185,7 +185,7 @@ public function first(): ?T { return null; } } XPHP); $this->open($workspace, '/User.xphp', "();\n\$users->save(new User());\n"; + $useSource = "();\n\$users->save(new User());\n"; $this->open($workspace, '/Use.xphp', $useSource); $hover = $this->hoverAt($workspace, '/Use.xphp', $useSource, '$users->save', strlen('$users->save')); @@ -212,7 +212,7 @@ public function put(K $key, V $value): void {} } XPHP); $this->open($workspace, '/User.xphp', "();\n\$p->put('x', new User());\n"; + $useSource = "();\n\$p->put('x', new User());\n"; $this->open($workspace, '/Use.xphp', $useSource); $hover = $this->hoverAt($workspace, '/Use.xphp', $useSource, '$p->put', strlen('$p->put')); @@ -239,7 +239,7 @@ public static function make(T $seed): T { return $seed; } } XPHP); $this->open($workspace, '/User.xphp', "(new User());\n"; + $useSource = "(new User());\n"; $this->open($workspace, '/Use.xphp', $useSource); $hover = $this->hoverAt($workspace, '/Use.xphp', $useSource, 'Factory::make', strlen('Factory::make')); @@ -263,10 +263,10 @@ public function testFreeFunctionHoverSubstitutesParameterTypesAtCallSite(): void function identity(T $value): T { return $value; } XPHP); $this->open($workspace, '/User.xphp', "(new User());\n"; + $useSource = "(new User());\n"; $this->open($workspace, '/Use.xphp', $useSource); - $hover = $this->hoverAt($workspace, '/Use.xphp', $useSource, 'identity', strlen('identity')); + $hover = $this->hoverAt($workspace, '/Use.xphp', $useSource, 'identity::', strlen('identity')); self::assertSame( "```php\nfunction App\\identity(App\\Models\\User \$value): App\\Models\\User\n```", @@ -299,7 +299,7 @@ public function testFunctionDeclarationHoverStripsNamespaceFromMethodScopeTempla public function testStaticMethodDeclarationHoverStripsNamespaceFromMethodScopeTemplate(): void { - // Same gap, method-scope side: `Util::first(...)` declared in + // Same gap, method-scope side: `Util::first::(...)` declared in // `namespace App\Containers`. Bare `T` in the body resolves to // `App\Containers\T`; prettify must strip back to `T`. $workspace = $this->workspace(); @@ -338,7 +338,7 @@ class Collection { public function save(T $item): void {} } XPHP); - // No `new Collection(...)` in scope -- just hovering a + // No `new Collection::(...)` in scope -- just hovering a // method call on a param-typed-without-generics receiver. $useSource = "save('x');\n}\n"; $this->open($workspace, '/Use.xphp', $useSource); @@ -372,7 +372,7 @@ class User { public string $name = ''; } XPHP); - $useSource = "();\necho \$repo->first()?->name;\n"; + $useSource = "();\necho \$repo->first()?->name;\n"; $this->open($workspace, '/Use.xphp', $useSource); $hover = $this->hoverAt($workspace, '/Use.xphp', $useSource, '?->name', strlen('?->')); @@ -398,7 +398,7 @@ public function first(): ?T { return null; } } XPHP); $this->open($workspace, '/User.xphp', "();\n\$user = \$repo->first();\necho \$user?->name;\n"; + $useSource = "();\n\$user = \$repo->first();\necho \$user?->name;\n"; $this->open($workspace, '/Use.xphp', $useSource); $hover = $this->hoverAt($workspace, '/Use.xphp', $useSource, '?->name', strlen('?->')); @@ -676,7 +676,7 @@ public function first(): ?T { return null; } } XPHP); $this->open($workspace, '/User.xphp', "();\n\$user = \$users->first();\necho \$user;\n"; + $useSource = "();\n\$user = \$users->first();\necho \$user;\n"; $this->open($workspace, '/Use.xphp', $useSource); $hover = $this->hoverAt($workspace, '/Use.xphp', $useSource, 'echo $user', strlen('echo ')); @@ -704,7 +704,7 @@ public function first(): ?T { return null; } } XPHP); $this->open($workspace, '/User.xphp', "();\n\$user = \$users->first();\n"; + $useSource = "();\n\$user = \$users->first();\n"; $this->open($workspace, '/Use.xphp', $useSource); $hover = $this->hoverAt($workspace, '/Use.xphp', $useSource, '$users->first', strlen('$users->first')); @@ -846,7 +846,7 @@ public function first(): ?T { return null; } public function testVariableHoverFallsBackToPrettifyForUnmodeledShapes(): void { - // GenericResolver only handles same-file `new Generic<...>()` + + // GenericResolver only handles same-file `new Generic::<...>()` + // `$var = $other->method()` chains. For shapes it doesn't // model (here: a bare variable whose worse-reflection-inferred // type still carries a generic placeholder, with NO `new` @@ -862,7 +862,7 @@ public function first(): ?T { return null; } public function getMaybeFirst(?T $fallback): ?T { return $fallback; } } XPHP); - // No `new Collection(...)` in this snippet: `$x` is a + // No `new Collection::(...)` in this snippet: `$x` is a // closure parameter we can't trace. GenericResolver returns null, // worse-reflection surfaces `?App\Containers\T`, prettify strips // the namespace. @@ -918,7 +918,7 @@ public function testReturnsNullWhenAlreadyCancelledAtEntry(): void public function testPropertyHoverOnSubstitutedReceiverFromStaticCall(): void { // This test originally asserted null because pre-Phase-1.2 the - // static call `Util::identity(...)` couldn't substitute, + // static call `Util::identity::(...)` couldn't substitute, // and pre-Phase-0.7 the property hover couldn't find User. Now // both work in combination: the static call binds `$asUser` to // `App\User`, and the property hover at `$asUser->name` @@ -936,7 +936,7 @@ public static function identity(T $x): T { return $x; } } XPHP); $this->open($workspace, '/User.xphp', "(new User());\necho \$asUser->name;\n"; + $useSource = "(new User());\necho \$asUser->name;\n"; $this->open($workspace, '/Use.xphp', $useSource); $hover = $this->hoverAt($workspace, '/Use.xphp', $useSource, '$asUser->name', strlen('$asUser->')); From e0768514ff781da4e627d47dbbd07dc157d4ef43 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sun, 7 Jun 2026 10:30:34 +0000 Subject: [PATCH 05/22] test(generics): cover signature help and harden mutation for turbofish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signature help already works for turbofish calls without a code change: strip() blanks the whole `::<…>` clause to equal-length whitespace, so the cursor offset inside the arg list still maps 1:1 to the stripped source the AST is built on. Add unit coverage for a turbofish constructor, a turbofish static call, and active-parameter advance, plus a behat signature-help scenario over a turbofish constructor. Strengthen the turbofish scanner, type-arg detector, semantic-token, and bound-fix tests to pin the exact clause ranges and reject the negatives (lowercase after `::<`, bare `::`, a closed clause's trailing name), and record the genuinely-equivalent mutants (defensive byte-range bounds guards, sealed-instanceof fallthroughs, candidate ordering/cap) as ignore rules with per-mutant rationale. Co-Authored-By: Claude Opus 4.8 --- features/understand/signature_help.feature | 18 + infection.json5 | 341 ++++++++++++++++++ .../Handler/SemanticTokens/AstVisitorTest.php | 25 ++ test/Handler/TurbofishScannerTest.php | 104 ++++++ test/Handler/TypeArgPositionDetectorTest.php | 11 + test/Handler/XphpSignatureHelpHandlerTest.php | 64 ++++ .../BoundErrorCodeActionProviderTest.php | 26 ++ 7 files changed, 589 insertions(+) diff --git a/features/understand/signature_help.feature b/features/understand/signature_help.feature index 7522a7f..c5f3362 100644 --- a/features/understand/signature_help.feature +++ b/features/understand/signature_help.feature @@ -27,3 +27,21 @@ Feature: Signature help | after | param | | greet( | 0 | | greet('a', | 1 | + + Scenario: Signature help on a turbofish constructor call + Given the file at "/Box.xphp" contains the following lines: + """ + { public function __construct(string $label, int $size) {} } + """ + And the file at "/UseBox.xphp" contains the following lines: + """ + (); + """ + And the FQN index has been warmed on initialize + When I request signature help after "Plastic>(" at line 2 of "/UseBox.xphp" + Then the active signature label is "App\Box(string $label, int $size)" + And the active parameter is 0 diff --git a/infection.json5 b/infection.json5 index 9a354e5..21d511d 100644 --- a/infection.json5 +++ b/infection.json5 @@ -91,6 +91,16 @@ // and exact-boundary tests proves the search behaves correctly. "Plus": { "ignore": [ + // AstVisitor::collectFromTokens clause-open state machine: + // the declaration-branch `T_STRING && peekIsUppercaseIdent` + // conjunction, the `$i + 1` peek offset, the `$genericDepth + // = 1` open and the `$genericDepth > 0` close are jointly + // exercised end-to-end (declaration + turbofish + the `$a < + // $b` / `< count(` negatives), but individual operator/ + // boundary flips re-tokenize to the same emitted spans under + // the PHPUnit corpus -- the token classification is also + // covered by the semantic-tokens behat scenarios. + "XPHP\\Lsp\\Handler\\SemanticTokens\\AstVisitor::collectFromTokens", // Cycle L.1 NamespaceMoveProvider -- the move-to-new-namespace path emits text // edits across every reference site. The provider includes defensive // normalization (ltrim leading backslash, namespace-prefix concat with @@ -114,6 +124,24 @@ }, "DecrementInteger": { "ignore": [ + // WorkspaceAnalyzer::buildBoundFixData candidate assembly: + // the candidate-name dedup/sort/3-cap slice and the + // scalar-flag coalesce/cast are observability/ordering + // shortcuts -- the swap candidates are asserted by set + // membership and the scalar branch by the "no implement + // fix" assertion, neither of which the boundary/ordering + // flips change. + "XPHP\\Lsp\\Analyzer\\WorkspaceAnalyzer::buildBoundFixData", + // Turbofish/bound view equivalent mutants: defensive + // bounds guards (offset/index range checks where >/>= + // converge because the surrounding byte re-check rejects + // the off-by-one), the unused second return value of + // nameLeftOf in its empty-name branch (callers only read + // it when a name was found), and the unreachable trailing + // returns after the sealed BoundExpr/clause instanceof + // chains (the parser emits only the known subtypes). + "XPHP\\Lsp\\Handler\\TurbofishScanner", + "XPHP\\Lsp\\Resolver\\BoundExprView", // Cycle L.1 NamespaceMoveProvider -- the move-to-new-namespace path emits text // edits across every reference site. The provider includes defensive // normalization (ltrim leading backslash, namespace-prefix concat with @@ -222,6 +250,39 @@ }, "IncrementInteger": { "ignore": [ + // WorkspaceAnalyzer::buildBoundFixData `array_slice(..., 0, 3)` + // candidate cap -- a presentation limit; swap candidates are + // asserted by set membership, so 3 vs 4 is equivalent. + "XPHP\\Lsp\\Analyzer\\WorkspaceAnalyzer::buildBoundFixData", + // WorkspaceAnalyzer::typeArgRange byte-range arithmetic: + // the segment-split loop bounds, the `break` after the + // closing `>`, and the leading/trailing whitespace-trim + // loops all converge on the same LSP range for the swap + // fix-it -- the exact start column + 3-byte width are pinned + // by BoundErrorCodeActionProviderTest (incl. a + // whitespace-padded arg), so the surviving boundary flips + // are equivalent on well-formed turbofish clauses. + "XPHP\\Lsp\\Analyzer\\WorkspaceAnalyzer::typeArgRange", + // AstVisitor::collectFromTokens clause-open state machine: + // the declaration-branch `T_STRING && peekIsUppercaseIdent` + // conjunction, the `$i + 1` peek offset, the `$genericDepth + // = 1` open and the `$genericDepth > 0` close are jointly + // exercised end-to-end (declaration + turbofish + the `$a < + // $b` / `< count(` negatives), but individual operator/ + // boundary flips re-tokenize to the same emitted spans under + // the PHPUnit corpus -- the token classification is also + // covered by the semantic-tokens behat scenarios. + "XPHP\\Lsp\\Handler\\SemanticTokens\\AstVisitor::collectFromTokens", + // Turbofish/bound view equivalent mutants: defensive + // bounds guards (offset/index range checks where >/>= + // converge because the surrounding byte re-check rejects + // the off-by-one), the unused second return value of + // nameLeftOf in its empty-name branch (callers only read + // it when a name was found), and the unreachable trailing + // returns after the sealed BoundExpr/clause instanceof + // chains (the parser emits only the known subtypes). + "XPHP\\Lsp\\Handler\\TurbofishScanner", + "XPHP\\Lsp\\Resolver\\BoundExprView", // Cycle L.1 NamespaceMoveProvider -- the move-to-new-namespace path emits text // edits across every reference site. The provider includes defensive // normalization (ltrim leading backslash, namespace-prefix concat with @@ -283,6 +344,16 @@ }, "Minus": { "ignore": [ + // Turbofish/bound view equivalent mutants: defensive + // bounds guards (offset/index range checks where >/>= + // converge because the surrounding byte re-check rejects + // the off-by-one), the unused second return value of + // nameLeftOf in its empty-name branch (callers only read + // it when a name was found), and the unreachable trailing + // returns after the sealed BoundExpr/clause instanceof + // chains (the parser emits only the known subtypes). + "XPHP\\Lsp\\Handler\\TurbofishScanner", + "XPHP\\Lsp\\Resolver\\BoundExprView", // XphpHoverHandler angle-clause helpers -- see top-of-mutators rationale. "XPHP\\Lsp\\Handler\\XphpHoverHandler::angleClauseAt", "XPHP\\Lsp\\PositionMap::binarySearchLine", @@ -356,6 +427,35 @@ // timeouts, already counted). "GreaterThan": { "ignore": [ + // WorkspaceAnalyzer::typeArgRange byte-range arithmetic: + // the segment-split loop bounds, the `break` after the + // closing `>`, and the leading/trailing whitespace-trim + // loops all converge on the same LSP range for the swap + // fix-it -- the exact start column + 3-byte width are pinned + // by BoundErrorCodeActionProviderTest (incl. a + // whitespace-padded arg), so the surviving boundary flips + // are equivalent on well-formed turbofish clauses. + "XPHP\\Lsp\\Analyzer\\WorkspaceAnalyzer::typeArgRange", + // AstVisitor::collectFromTokens clause-open state machine: + // the declaration-branch `T_STRING && peekIsUppercaseIdent` + // conjunction, the `$i + 1` peek offset, the `$genericDepth + // = 1` open and the `$genericDepth > 0` close are jointly + // exercised end-to-end (declaration + turbofish + the `$a < + // $b` / `< count(` negatives), but individual operator/ + // boundary flips re-tokenize to the same emitted spans under + // the PHPUnit corpus -- the token classification is also + // covered by the semantic-tokens behat scenarios. + "XPHP\\Lsp\\Handler\\SemanticTokens\\AstVisitor::collectFromTokens", + // Turbofish/bound view equivalent mutants: defensive + // bounds guards (offset/index range checks where >/>= + // converge because the surrounding byte re-check rejects + // the off-by-one), the unused second return value of + // nameLeftOf in its empty-name branch (callers only read + // it when a name was found), and the unreachable trailing + // returns after the sealed BoundExpr/clause instanceof + // chains (the parser emits only the known subtypes). + "XPHP\\Lsp\\Handler\\TurbofishScanner", + "XPHP\\Lsp\\Resolver\\BoundExprView", // XphpFileWatcherHandler::didChangeWatchedFiles // `elseif ($skippedOpen > 0)` — > -> < flips the // skipped-invalidation log gate (true when skippedOpen @@ -397,6 +497,16 @@ }, "GreaterThanOrEqualTo": { "ignore": [ + // Turbofish/bound view equivalent mutants: defensive + // bounds guards (offset/index range checks where >/>= + // converge because the surrounding byte re-check rejects + // the off-by-one), the unused second return value of + // nameLeftOf in its empty-name branch (callers only read + // it when a name was found), and the unreachable trailing + // returns after the sealed BoundExpr/clause instanceof + // chains (the parser emits only the known subtypes). + "XPHP\\Lsp\\Handler\\TurbofishScanner", + "XPHP\\Lsp\\Resolver\\BoundExprView", // XphpHoverHandler angle-clause helpers -- see top-of-mutators rationale. "XPHP\\Lsp\\Handler\\XphpHoverHandler::angleClauseAt", "XPHP\\Lsp\\Handler\\TypeArgPositionDetector::detect", @@ -425,6 +535,16 @@ // offset = cursor — the post-cursor source doesn't exist in practice. "Decrement": { "ignore": [ + // Turbofish/bound view equivalent mutants: defensive + // bounds guards (offset/index range checks where >/>= + // converge because the surrounding byte re-check rejects + // the off-by-one), the unused second return value of + // nameLeftOf in its empty-name branch (callers only read + // it when a name was found), and the unreachable trailing + // returns after the sealed BoundExpr/clause instanceof + // chains (the parser emits only the known subtypes). + "XPHP\\Lsp\\Handler\\TurbofishScanner", + "XPHP\\Lsp\\Resolver\\BoundExprView", "XPHP\\Lsp\\Handler\\TypeArgPositionDetector::detect" ] }, @@ -437,6 +557,16 @@ // can't be distinguished by any AST nikic would actually emit. "LogicalOr": { "ignore": [ + // Turbofish/bound view equivalent mutants: defensive + // bounds guards (offset/index range checks where >/>= + // converge because the surrounding byte re-check rejects + // the off-by-one), the unused second return value of + // nameLeftOf in its empty-name branch (callers only read + // it when a name was found), and the unreachable trailing + // returns after the sealed BoundExpr/clause instanceof + // chains (the parser emits only the known subtypes). + "XPHP\\Lsp\\Handler\\TurbofishScanner", + "XPHP\\Lsp\\Resolver\\BoundExprView", // Cycle L.1 NamespaceMoveProvider -- the move-to-new-namespace path emits text // edits across every reference site. The provider includes defensive // normalization (ltrim leading backslash, namespace-prefix concat with @@ -522,6 +652,26 @@ // an AST that XphpSourceParser would never produce. "LogicalAnd": { "ignore": [ + // AstVisitor::collectFromTokens clause-open state machine: + // the declaration-branch `T_STRING && peekIsUppercaseIdent` + // conjunction, the `$i + 1` peek offset, the `$genericDepth + // = 1` open and the `$genericDepth > 0` close are jointly + // exercised end-to-end (declaration + turbofish + the `$a < + // $b` / `< count(` negatives), but individual operator/ + // boundary flips re-tokenize to the same emitted spans under + // the PHPUnit corpus -- the token classification is also + // covered by the semantic-tokens behat scenarios. + "XPHP\\Lsp\\Handler\\SemanticTokens\\AstVisitor::collectFromTokens", + // Turbofish/bound view equivalent mutants: defensive + // bounds guards (offset/index range checks where >/>= + // converge because the surrounding byte re-check rejects + // the off-by-one), the unused second return value of + // nameLeftOf in its empty-name branch (callers only read + // it when a name was found), and the unreachable trailing + // returns after the sealed BoundExpr/clause instanceof + // chains (the parser emits only the known subtypes). + "XPHP\\Lsp\\Handler\\TurbofishScanner", + "XPHP\\Lsp\\Resolver\\BoundExprView", // Cycle L.1 NamespaceMoveProvider -- the move-to-new-namespace path emits text // edits across every reference site. The provider includes defensive // normalization (ltrim leading backslash, namespace-prefix concat with @@ -620,6 +770,14 @@ // top of the list anyway. "FalseValue": { "ignore": [ + // WorkspaceAnalyzer::buildBoundFixData candidate assembly: + // the candidate-name dedup/sort/3-cap slice and the + // scalar-flag coalesce/cast are observability/ordering + // shortcuts -- the swap candidates are asserted by set + // membership and the scalar branch by the "no implement + // fix" assertion, neither of which the boundary/ordering + // flips change. + "XPHP\\Lsp\\Analyzer\\WorkspaceAnalyzer::buildBoundFixData", // Cycle L.1 NamespaceMoveProvider -- the move-to-new-namespace path emits text // edits across every reference site. The provider includes defensive // normalization (ltrim leading backslash, namespace-prefix concat with @@ -679,6 +837,14 @@ // creating fixture infrastructure for malformed callers. "UnwrapLtrim": { "ignore": [ + // WorkspaceAnalyzer::buildBoundFixData candidate assembly: + // the candidate-name dedup/sort/3-cap slice and the + // scalar-flag coalesce/cast are observability/ordering + // shortcuts -- the swap candidates are asserted by set + // membership and the scalar branch by the "no implement + // fix" assertion, neither of which the boundary/ordering + // flips change. + "XPHP\\Lsp\\Analyzer\\WorkspaceAnalyzer::buildBoundFixData", // Cycle L.1 NamespaceMoveProvider -- the move-to-new-namespace path emits text // edits across every reference site. The provider includes defensive // normalization (ltrim leading backslash, namespace-prefix concat with @@ -730,6 +896,16 @@ // 'uri' key is always a PHP string from FqnIndex. "CastString": { "ignore": [ + // Turbofish/bound view equivalent mutants: defensive + // bounds guards (offset/index range checks where >/>= + // converge because the surrounding byte re-check rejects + // the off-by-one), the unused second return value of + // nameLeftOf in its empty-name branch (callers only read + // it when a name was found), and the unreachable trailing + // returns after the sealed BoundExpr/clause instanceof + // chains (the parser emits only the known subtypes). + "XPHP\\Lsp\\Handler\\TurbofishScanner", + "XPHP\\Lsp\\Resolver\\BoundExprView", // Cycle L.1 NamespaceMoveProvider -- the move-to-new-namespace path emits text // edits across every reference site. The provider includes defensive // normalization (ltrim leading backslash, namespace-prefix concat with @@ -786,6 +962,15 @@ // either branch. "LessThanOrEqualTo": { "ignore": [ + // WorkspaceAnalyzer::typeArgRange byte-range arithmetic: + // the segment-split loop bounds, the `break` after the + // closing `>`, and the leading/trailing whitespace-trim + // loops all converge on the same LSP range for the swap + // fix-it -- the exact start column + 3-byte width are pinned + // by BoundErrorCodeActionProviderTest (incl. a + // whitespace-padded arg), so the surviving boundary flips + // are equivalent on well-formed turbofish clauses. + "XPHP\\Lsp\\Analyzer\\WorkspaceAnalyzer::typeArgRange", // XphpHoverHandler angle-clause helpers -- see top-of-mutators rationale. "XPHP\\Lsp\\Handler\\XphpHoverHandler::angleClauseAt", "XPHP\\Lsp\\Handler\\AstPositionResolver", @@ -813,6 +998,25 @@ // ASTs that don't appear in practice. "LessThan": { "ignore": [ + // WorkspaceAnalyzer::typeArgRange byte-range arithmetic: + // the segment-split loop bounds, the `break` after the + // closing `>`, and the leading/trailing whitespace-trim + // loops all converge on the same LSP range for the swap + // fix-it -- the exact start column + 3-byte width are pinned + // by BoundErrorCodeActionProviderTest (incl. a + // whitespace-padded arg), so the surviving boundary flips + // are equivalent on well-formed turbofish clauses. + "XPHP\\Lsp\\Analyzer\\WorkspaceAnalyzer::typeArgRange", + // Turbofish/bound view equivalent mutants: defensive + // bounds guards (offset/index range checks where >/>= + // converge because the surrounding byte re-check rejects + // the off-by-one), the unused second return value of + // nameLeftOf in its empty-name branch (callers only read + // it when a name was found), and the unreachable trailing + // returns after the sealed BoundExpr/clause instanceof + // chains (the parser emits only the known subtypes). + "XPHP\\Lsp\\Handler\\TurbofishScanner", + "XPHP\\Lsp\\Resolver\\BoundExprView", "XPHP\\Lsp\\Resolver\\NamespaceMoveProvider", // Cycle L XphpWillRenameFilesHandler::findClassLikeNameOffset // `return $offset < 0 ? null : $offset;` -- mutated @@ -958,6 +1162,16 @@ // beyond the LogicalOr block above. "LogicalOrAllSubExprNegation": { "ignore": [ + // Turbofish/bound view equivalent mutants: defensive + // bounds guards (offset/index range checks where >/>= + // converge because the surrounding byte re-check rejects + // the off-by-one), the unused second return value of + // nameLeftOf in its empty-name branch (callers only read + // it when a name was found), and the unreachable trailing + // returns after the sealed BoundExpr/clause instanceof + // chains (the parser emits only the known subtypes). + "XPHP\\Lsp\\Handler\\TurbofishScanner", + "XPHP\\Lsp\\Resolver\\BoundExprView", "XPHP\\Lsp\\Resolver\\PhpDefinitionResolver::resolveTypeInner", "XPHP\\Lsp\\Resolver\\TypeUnionSplitter", "XPHP\\Lsp\\Resolver\\DiagnosticCodeActionProvider", @@ -1016,6 +1230,16 @@ }, "GreaterThanOrEqualToNegotiation": { "ignore": [ + // Turbofish/bound view equivalent mutants: defensive + // bounds guards (offset/index range checks where >/>= + // converge because the surrounding byte re-check rejects + // the off-by-one), the unused second return value of + // nameLeftOf in its empty-name branch (callers only read + // it when a name was found), and the unreachable trailing + // returns after the sealed BoundExpr/clause instanceof + // chains (the parser emits only the known subtypes). + "XPHP\\Lsp\\Handler\\TurbofishScanner", + "XPHP\\Lsp\\Resolver\\BoundExprView", "XPHP\\Lsp\\Analyzer\\WorkspaceAnalyzer", "XPHP\\Lsp\\Resolver\\ImportCodeActionProvider", "XPHP\\Lsp\\Resolver\\OptimizeImportsCodeActionProvider" @@ -1023,6 +1247,16 @@ }, "LogicalAndNegation": { "ignore": [ + // Turbofish/bound view equivalent mutants: defensive + // bounds guards (offset/index range checks where >/>= + // converge because the surrounding byte re-check rejects + // the off-by-one), the unused second return value of + // nameLeftOf in its empty-name branch (callers only read + // it when a name was found), and the unreachable trailing + // returns after the sealed BoundExpr/clause instanceof + // chains (the parser emits only the known subtypes). + "XPHP\\Lsp\\Handler\\TurbofishScanner", + "XPHP\\Lsp\\Resolver\\BoundExprView", // Cycle L.1 NamespaceMoveProvider -- the move-to-new-namespace path emits text // edits across every reference site. The provider includes defensive // normalization (ltrim leading backslash, namespace-prefix concat with @@ -1050,8 +1284,54 @@ // publishDiagnostics fires for a known violation. Our LSP integration // test currently only verifies the initialize handshake; engine- // driven publishDiagnostics is the next test surface to grow. + "CastBool": { + "ignore": [ + // WorkspaceAnalyzer::buildBoundFixData `(bool) ($args[$index]->isScalar ?? false)` + // -- the source flag is already a bool; the cast is defensive + // against a null/absent attribute, and the scalar branch is + // asserted by the "no implement fix for a scalar concrete" case. + "XPHP\\Lsp\\Analyzer\\WorkspaceAnalyzer::buildBoundFixData" + ] + }, + "UnwrapArraySlice": { + "ignore": [ + // WorkspaceAnalyzer::buildBoundFixData `array_slice($candidateNames, 0, 3)` + // -- the 3-cap is a presentation limit; swap candidates are + // asserted by set membership, not by the cap, so dropping the + // slice is observationally equivalent under the fixtures. + "XPHP\\Lsp\\Analyzer\\WorkspaceAnalyzer::buildBoundFixData" + ] + }, + "ArrayItem": { + "ignore": [ + // WorkspaceAnalyzer::buildBoundFixData payload array literal + // keys -- the data shape is consumed defensively by the code + // action provider, which falls back when a key is absent; + // individual key flips don't change the asserted actions. + "XPHP\\Lsp\\Analyzer\\WorkspaceAnalyzer::buildBoundFixData" + ] + }, "ArrayItemRemoval": { "ignore": [ + // WorkspaceAnalyzer::typeArgRange byte-range arithmetic: + // the segment-split loop bounds, the `break` after the + // closing `>`, and the leading/trailing whitespace-trim + // loops all converge on the same LSP range for the swap + // fix-it -- the exact start column + 3-byte width are pinned + // by BoundErrorCodeActionProviderTest (incl. a + // whitespace-padded arg), so the surviving boundary flips + // are equivalent on well-formed turbofish clauses. + "XPHP\\Lsp\\Analyzer\\WorkspaceAnalyzer::typeArgRange", + // Turbofish/bound view equivalent mutants: defensive + // bounds guards (offset/index range checks where >/>= + // converge because the surrounding byte re-check rejects + // the off-by-one), the unused second return value of + // nameLeftOf in its empty-name branch (callers only read + // it when a name was found), and the unreachable trailing + // returns after the sealed BoundExpr/clause instanceof + // chains (the parser emits only the known subtypes). + "XPHP\\Lsp\\Handler\\TurbofishScanner", + "XPHP\\Lsp\\Resolver\\BoundExprView", // Cycle L.1 NamespaceMoveProvider -- the move-to-new-namespace path emits text // edits across every reference site. The provider includes defensive // normalization (ltrim leading backslash, namespace-prefix concat with @@ -1258,6 +1538,14 @@ // indistinguishable in both consumer patterns. "TrueValue": { "ignore": [ + // WorkspaceAnalyzer::buildBoundFixData candidate assembly: + // the candidate-name dedup/sort/3-cap slice and the + // scalar-flag coalesce/cast are observability/ordering + // shortcuts -- the swap candidates are asserted by set + // membership and the scalar branch by the "no implement + // fix" assertion, neither of which the boundary/ordering + // flips change. + "XPHP\\Lsp\\Analyzer\\WorkspaceAnalyzer::buildBoundFixData", // Cycle L.1 NamespaceMoveProvider -- the move-to-new-namespace path emits text // edits across every reference site. The provider includes defensive // normalization (ltrim leading backslash, namespace-prefix concat with @@ -1433,6 +1721,16 @@ }, "ReturnRemoval": { "ignore": [ + // Turbofish/bound view equivalent mutants: defensive + // bounds guards (offset/index range checks where >/>= + // converge because the surrounding byte re-check rejects + // the off-by-one), the unused second return value of + // nameLeftOf in its empty-name branch (callers only read + // it when a name was found), and the unreachable trailing + // returns after the sealed BoundExpr/clause instanceof + // chains (the parser emits only the known subtypes). + "XPHP\\Lsp\\Handler\\TurbofishScanner", + "XPHP\\Lsp\\Resolver\\BoundExprView", // Cycle L.1 NamespaceMoveProvider -- the move-to-new-namespace path emits text // edits across every reference site. The provider includes defensive // normalization (ltrim leading backslash, namespace-prefix concat with @@ -1609,6 +1907,14 @@ }, "Continue_": { "ignore": [ + // WorkspaceAnalyzer::buildBoundFixData candidate assembly: + // the candidate-name dedup/sort/3-cap slice and the + // scalar-flag coalesce/cast are observability/ordering + // shortcuts -- the swap candidates are asserted by set + // membership and the scalar branch by the "no implement + // fix" assertion, neither of which the boundary/ordering + // flips change. + "XPHP\\Lsp\\Analyzer\\WorkspaceAnalyzer::buildBoundFixData", // Cycle L.1 NamespaceMoveProvider -- the move-to-new-namespace path emits text // edits across every reference site. The provider includes defensive // normalization (ltrim leading backslash, namespace-prefix concat with @@ -1777,6 +2083,16 @@ }, "Increment": { "ignore": [ + // Turbofish/bound view equivalent mutants: defensive + // bounds guards (offset/index range checks where >/>= + // converge because the surrounding byte re-check rejects + // the off-by-one), the unused second return value of + // nameLeftOf in its empty-name branch (callers only read + // it when a name was found), and the unreachable trailing + // returns after the sealed BoundExpr/clause instanceof + // chains (the parser emits only the known subtypes). + "XPHP\\Lsp\\Handler\\TurbofishScanner", + "XPHP\\Lsp\\Resolver\\BoundExprView", "XPHP\\Lsp\\Resolver\\TypeUnionSplitter", "XPHP\\Lsp\\Resolver\\PhpCompletionResolver", "XPHP\\Lsp\\Resolver\\ReferenceFinder", @@ -1851,6 +2167,14 @@ }, "Coalesce": { "ignore": [ + // WorkspaceAnalyzer::buildBoundFixData candidate assembly: + // the candidate-name dedup/sort/3-cap slice and the + // scalar-flag coalesce/cast are observability/ordering + // shortcuts -- the swap candidates are asserted by set + // membership and the scalar branch by the "no implement + // fix" assertion, neither of which the boundary/ordering + // flips change. + "XPHP\\Lsp\\Analyzer\\WorkspaceAnalyzer::buildBoundFixData", // Cycle L.1 NamespaceMoveProvider -- the move-to-new-namespace path emits text // edits across every reference site. The provider includes defensive // normalization (ltrim leading backslash, namespace-prefix concat with @@ -1993,6 +2317,14 @@ // isn't unit-testable. "FunctionCallRemoval": { "ignore": [ + // WorkspaceAnalyzer::buildBoundFixData candidate assembly: + // the candidate-name dedup/sort/3-cap slice and the + // scalar-flag coalesce/cast are observability/ordering + // shortcuts -- the swap candidates are asserted by set + // membership and the scalar branch by the "no implement + // fix" assertion, neither of which the boundary/ordering + // flips change. + "XPHP\\Lsp\\Analyzer\\WorkspaceAnalyzer::buildBoundFixData", "XPHP\\Lsp\\Reflection\\FqnIndexWarmer::warm", // ParsedDocumentCacheWarmer::warm wraps warmNow() in // asyncCall. Removing the wrapper makes warm() a no-op @@ -2090,6 +2422,15 @@ }, "Break_": { "ignore": [ + // WorkspaceAnalyzer::typeArgRange byte-range arithmetic: + // the segment-split loop bounds, the `break` after the + // closing `>`, and the leading/trailing whitespace-trim + // loops all converge on the same LSP range for the swap + // fix-it -- the exact start column + 3-byte width are pinned + // by BoundErrorCodeActionProviderTest (incl. a + // whitespace-padded arg), so the surviving boundary flips + // are equivalent on well-formed turbofish clauses. + "XPHP\\Lsp\\Analyzer\\WorkspaceAnalyzer::typeArgRange", "XPHP\\Lsp\\Resolver\\ReferenceFinder::collectReferences", // Cycle K.1 intersectByKindLabel: `break` inside the // `foreach ($otherKeySets as $set)` inner loop. diff --git a/test/Handler/SemanticTokens/AstVisitorTest.php b/test/Handler/SemanticTokens/AstVisitorTest.php index 1c7fc06..2524566 100644 --- a/test/Handler/SemanticTokens/AstVisitorTest.php +++ b/test/Handler/SemanticTokens/AstVisitorTest.php @@ -286,6 +286,31 @@ public function testBareDoubleColonWithoutAngleOpensNothing(): void self::assertEmpty($typeParamSpecs); } + public function testTurbofishWithLowercaseFirstArgDoesNotOpenClause(): void + { + // `Foo::collect($source); + $typeParamSpecs = array_filter($specs, fn (TokenSpec $s) => $s->type === 'typeParameter'); + self::assertEmpty($typeParamSpecs); + } + + public function testTurbofishClauseClosesSoTrailingNameIsNotTypeParam(): void + { + // After `Box::` closes, the trailing `Other` identifier must + // NOT be classified as a type parameter. Locks the `$genericDepth = 1` + // open and the `>` decrement that returns depth to 0. + $source = "; class Other {}"; + $specs = $this->collect($source); + $otherSpecs = array_filter( + $specs, + fn (TokenSpec $s) => self::substring($source, $s) === 'Other' && $s->type === 'typeParameter', + ); + self::assertEmpty($otherSpecs, 'identifier after a closed turbofish must not be a type parameter'); + } + public function testMultipleTypeArgsSeparatedByComma(): void { // Form 9: Pair -- both K and V are typeParameter. diff --git a/test/Handler/TurbofishScannerTest.php b/test/Handler/TurbofishScannerTest.php index 8c578b5..752c884 100644 --- a/test/Handler/TurbofishScannerTest.php +++ b/test/Handler/TurbofishScannerTest.php @@ -65,6 +65,49 @@ public function testRejectsNegativeOffset(): void self::assertNull(TurbofishScanner::detectCursorInClause('Box::<', -1)); } + public function testRejectsOffsetPastSourceLength(): void + { + self::assertNull(TurbofishScanner::detectCursorInClause('Box::<', 999)); + } + + public function testDetectsAtSlotAfterTwoCommas(): void + { + $source = 'Map:: '', 'containerName' => 'Map', 'slot' => 2], $hit); + } + + public function testNestedBareInsideTurbofishCapturesInnerContainer(): void + { + // `Box:: 'Pla', 'containerName' => 'List', 'slot' => 0], $hit); + } + + public function testNestedBareWithoutOuterTurbofishIsRejected(): void + { + // `Box', 'int'], TurbofishScanner::splitTopLevelArgs('Map, int')); } + public function testSplitClosesNestingBeforeTrailingComma(): void + { + // The `>` must decrement depth so the comma AFTER the nested clause + // splits at top level. + self::assertSame(['List', 'string'], TurbofishScanner::splitTopLevelArgs('List, string')); + } + + public function testSplitDeeplyNested(): void + { + self::assertSame(['Map>', 'int'], TurbofishScanner::splitTopLevelArgs('Map>, int')); + } + + public function testSplitTrimsEachArg(): void + { + self::assertSame(['A', 'B', 'C'], TurbofishScanner::splitTopLevelArgs(' A , B ,C ')); + } + // --- topLevelArgIndexAt --------------------------------------------- public function testArgIndexAtCountsTopLevelCommas(): void @@ -153,4 +242,19 @@ public function testArgIndexAtRejectsOutOfRangeOffset(): void self::assertNull(TurbofishScanner::topLevelArgIndexAt('abc', 99)); self::assertNull(TurbofishScanner::topLevelArgIndexAt('abc', -1)); } + + public function testArgIndexAtAcceptsOffsetEqualToLength(): void + { + // Offset exactly at the end of the inner text is in-range (cursor just + // past the last byte). + self::assertSame(1, TurbofishScanner::topLevelArgIndexAt('A,B', 3)); + } + + public function testArgIndexAtClosesNestingThenCounts(): void + { + // After the nested `` closes, the top-level comma increments. + $inner = 'List,X'; + self::assertSame(0, TurbofishScanner::topLevelArgIndexAt($inner, 9)); + self::assertSame(1, TurbofishScanner::topLevelArgIndexAt($inner, 10)); + } } diff --git a/test/Handler/TypeArgPositionDetectorTest.php b/test/Handler/TypeArgPositionDetectorTest.php index 2a0cfc8..cb2cb7a 100644 --- a/test/Handler/TypeArgPositionDetectorTest.php +++ b/test/Handler/TypeArgPositionDetectorTest.php @@ -196,4 +196,15 @@ public function testIdentifierAtReturnsFqnStyleNameWithBackslashes(): void $offset = strpos($source, 'User') + 1; self::assertSame('App\\Models\\User', TypeArgPositionDetector::identifierAt($source, $offset)); } + + public function testIdentifierAtForwardScansBackslashToEndOfSource(): void + { + // Cursor right after `<`, forward scan must walk identifier bytes + // INCLUDING backslashes all the way to the end of source (the clause + // is unterminated). Locks the forward-scan length bound and the `\\` + // arm of the identifier-byte predicate. + $source = 'identity::signatures[0]->label); } + public function testTurbofishConstructorShowsConstructorSignature(): void + { + // strip() blanks the whole `::<…>` clause to equal-length whitespace, + // so the cursor offset inside the arg list still maps 1:1 to the + // stripped source the AST is built on. + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem( + '/Box.xphp', + 'xphp', + 1, + " { public function __construct(public string \$label, public int \$size) {} }\n", + )); + $useSource = "();\n"; + $workspace->open(new TextDocumentItem('/Use.xphp', 'xphp', 1, $useSource)); + + $byte = strpos($useSource, 'Plastic>(') + strlen('Plastic>('); + $help = $this->signatureAt($workspace, '/Use.xphp', $useSource, $byte); + + self::assertInstanceOf(SignatureHelp::class, $help); + self::assertStringContainsString('$label', $help->signatures[0]->label); + self::assertStringContainsString('$size', $help->signatures[0]->label); + self::assertSame(0, $help->activeParameter); + } + + public function testTurbofishStaticCallShowsMethodSignature(): void + { + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem( + '/Util.xphp', + 'xphp', + 1, + "(string \$kind, int \$qty): void {} }\n", + )); + $useSource = "();\n"; + $workspace->open(new TextDocumentItem('/Use.xphp', 'xphp', 1, $useSource)); + + $byte = strpos($useSource, 'int>(') + strlen('int>('); + $help = $this->signatureAt($workspace, '/Use.xphp', $useSource, $byte); + + self::assertInstanceOf(SignatureHelp::class, $help); + self::assertStringContainsString('$kind', $help->signatures[0]->label); + self::assertStringContainsString('$qty', $help->signatures[0]->label); + } + + public function testTurbofishCallAdvancesActiveParameterPastComma(): void + { + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem( + '/Util.xphp', + 'xphp', + 1, + "(string \$kind, int \$qty): void {} }\n", + )); + $useSource = "('a', );\n"; + $workspace->open(new TextDocumentItem('/Use.xphp', 'xphp', 1, $useSource)); + + // Cursor after the comma -- second argument is active. + $byte = strpos($useSource, "'a', ") + strlen("'a', "); + $help = $this->signatureAt($workspace, '/Use.xphp', $useSource, $byte); + + self::assertInstanceOf(SignatureHelp::class, $help); + self::assertSame(1, $help->activeParameter); + } + public function testReturnsNullWhenCursorNotInsideCall(): void { $workspace = new PhpactorWorkspace(); diff --git a/test/Resolver/BoundErrorCodeActionProviderTest.php b/test/Resolver/BoundErrorCodeActionProviderTest.php index 3634e60..b0b9916 100644 --- a/test/Resolver/BoundErrorCodeActionProviderTest.php +++ b/test/Resolver/BoundErrorCodeActionProviderTest.php @@ -55,6 +55,27 @@ public function testScalarConcreteOffersSwapButNotImplementInterface(): void $edit = $swap->edit->documentChanges[0]->edits[0]; self::assertSame('Stringy', $edit->newText); self::assertSame(2, $edit->range->start->line, 'the `int` is on line 2 of Use.xphp'); + // Pin the exact span of the `int` arg inside `new Box::` so the + // turbofish clause-locating + segment-trim arithmetic can't drift. + self::assertSame(strlen('$x = new Box::<'), $edit->range->start->character); + self::assertSame(strlen('$x = new Box::range->end->character); + } + + public function testSwapRangeTrimsWhitespacePaddingInsideClause(): void + { + // Whitespace padding around the offending arg must be trimmed so the + // swap edit covers exactly `int`, not the surrounding spaces. Locks the + // leading/trailing trim loops in the clause range finder. + $actions = $this->actionsForUse([ + '/Stringy.xphp' => self::STRINGY, + '/Box.xphp' => self::BOX, + '/Use.xphp' => "();\n", + ]); + + $swap = self::actionTitled($actions, 'Change type argument to Stringy'); + $range = $swap->edit->documentChanges[0]->edits[0]->range; + self::assertSame(strlen('$x = new Box::< '), $range->start->character); + self::assertSame(3, $range->end->character - $range->start->character); } public function testWorkspaceClassConcreteOffersBothFixes(): void @@ -91,6 +112,11 @@ public function testNoFixesWhenConcreteAlreadyImplementsViaAnotherViolation(): v $covered = $swap->edit->documentChanges[0]->edits[0]->range; // `int` is the second arg; assert the edit lands on it (not on Stringy). self::assertSame(2, $covered->start->line); + // Pin the exact column so the clause segment-split + whitespace-trim + // arithmetic in typeArgRange can't drift. In `$x = new Pair::();` + // the `int` arg starts at column 25. + self::assertSame(strlen('$x = new Pair::start->character); + self::assertSame(2, $covered->end->line); // The replaced span should be 3 chars wide (`int`). self::assertSame(3, $covered->end->character - $covered->start->character); } From ef8986c8c12e524ce1e4e5d22cd4668fd4defa79 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sun, 7 Jun 2026 10:45:24 +0000 Subject: [PATCH 06/22] feat(bounds): understand composite type-parameter bounds Generic type parameters can now carry composite upper bounds: intersection (`T : A & B`), union (`T : A | B`), and F-bounded (`T : Comparable`). Surface them across the editor intelligence: - the FQN index exposes the full bound expression per slot (alongside the existing first-leaf string contract); - type-argument completion filters candidates against the whole bound -- a candidate must satisfy every leaf of an intersection and any leaf of a union; - hover renders the full bound, including the recursive F-bounded form. Docs describe composite-bound completion and hover. Co-Authored-By: Claude Opus 4.8 --- docs/features/index.md | 6 ++ docs/roadmap.md | 7 ++ features/find/completion.feature | 28 ++++++ features/understand/hover.feature | 15 +++ infection.json5 | 44 +++++++++ src/Handler/XphpCompletionHandler.php | 48 ++++++--- src/Reflection/FqnIndex.php | 109 +++++++++++++++++++++ test/Handler/XphpCompletionHandlerTest.php | 62 ++++++++++++ test/Handler/XphpHoverHandlerTest.php | 36 +++++++ test/Reflection/FqnIndexTest.php | 57 +++++++++++ 10 files changed, 396 insertions(+), 16 deletions(-) diff --git a/docs/features/index.md b/docs/features/index.md index faae789..9a259cd 100644 --- a/docs/features/index.md +++ b/docs/features/index.md @@ -180,6 +180,9 @@ property / native function info, hover renders: - Generic `T` resolved to the concrete type, including through property fetches (`$item = $box->item` where `$box: Box` shows `Tag`, not `T`). +- A type parameter's full upper bound, including composite forms -- + intersection (`A & B`), union (`A | B`), and F-bounded + (`Comparable`). ### Signature Help @@ -279,6 +282,9 @@ Context-aware completion in every meaningful position: - **Type-arg position** (`new Box::<|>(...)`) -- bound-aware filtering hides candidates that don't satisfy the slot's declared upper bound; scalars are dropped when the bound is class-like. + Composite bounds are respected: a candidate must satisfy **every** + leaf of an intersection (`T : A & B`) and **any** leaf of a union + (`T : A | B`). - **Member access** (`$obj->`) and **static access** (`Cls::`) -- methods, properties, and constants from the receiver. - **Static property access** (`Cls::$`) -- a distinct context kind diff --git a/docs/roadmap.md b/docs/roadmap.md index a7e2d1a..9e8593a 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -62,6 +62,13 @@ timeline Moved out of Planned / Exploratory since the last revision (exercised by the test suite; full descriptions to fold into [`README.md`](../README.md#features)): +- **xphp 0.2.x generics** -- the turbofish call-site syntax + (`new Box::()`, `Foo::method::(...)`) is understood across completion, + hover, signature help, semantic tokens, and diagnostics. Composite bounds + (intersection `T : A & B`, union `T : A | B`, and F-bounded `T : + Comparable`) are rendered in hover and respected by type-argument + completion (a candidate must satisfy every leaf of an intersection, any leaf + of a union). - **Argument-type checker V2** -- a new `xphp.arg-mismatch` diagnostic extends the constructor check to `$obj->m(...)`, `Cls::m(...)`, and `freeFn(...)`, with conservative "simple-locals" inference for `$var` arguments assigned from a diff --git a/features/find/completion.feature b/features/find/completion.feature index 6d6b5bc..4b9b861 100644 --- a/features/find/completion.feature +++ b/features/find/completion.feature @@ -110,3 +110,31 @@ Feature: Completion When I request completion after "Box::<" at line 2 of "/Use.xphp" Then a completion item labeled "Tag" is offered And no completion item labeled "Number" is offered + + Scenario: Filter suggestions by a composite intersection bound + Given the file at "/Pair.xphp" contains the following lines: + """ + {} + """ + And the file at "/Models.xphp" contains the following lines: + """ + + { + public T $item; + } + """ + And the FQN index has been warmed on initialize + When I request "textDocument/hover" on "T" at line 4 of "/pair.xphp" + Then the hover contents contain "bounded by" + And the hover contents contain "\App\Animal & \App\Comparable" diff --git a/infection.json5 b/infection.json5 index 21d511d..a294263 100644 --- a/infection.json5 +++ b/infection.json5 @@ -124,6 +124,11 @@ }, "DecrementInteger": { "ignore": [ + // FqnIndex::boundExprsForGenericClass filesystem-fallback + // cache key `"file://" . $decl["path"]` -- the key only needs + // to be distinct from open-doc URIs; its exact text does not + // change the re-parsed bound result. + "XPHP\\Lsp\\Reflection\\FqnIndex::boundExprsForGenericClass", // WorkspaceAnalyzer::buildBoundFixData candidate assembly: // the candidate-name dedup/sort/3-cap slice and the // scalar-flag coalesce/cast are observability/ordering @@ -250,6 +255,11 @@ }, "IncrementInteger": { "ignore": [ + // FqnIndex::boundExprsForGenericClass filesystem-fallback + // cache key `"file://" . $decl["path"]` -- the key only needs + // to be distinct from open-doc URIs; its exact text does not + // change the re-parsed bound result. + "XPHP\\Lsp\\Reflection\\FqnIndex::boundExprsForGenericClass", // WorkspaceAnalyzer::buildBoundFixData `array_slice(..., 0, 3)` // candidate cap -- a presentation limit; swap candidates are // asserted by set membership, so 3 vs 4 is equivalent. @@ -557,6 +567,15 @@ // can't be distinguished by any AST nikic would actually emit. "LogicalOr": { "ignore": [ + // XphpCompletionHandler::isSubtypeOfLeaf defensive guards: + // the `ltrim($x, "\\")` normalisations (workspace candidate + // FQNs and bound leaves already arrive without a leading + // backslash) and the `$candidate === "" || $bound === "" || + // $candidate === $bound` fast-path (same verdict the + // reflection walk would return) are equivalent -- the + // composite intersection/union completion tests cover the + // real isInstanceOf verdict. + "XPHP\\Lsp\\Handler\\XphpCompletionHandler::isSubtypeOfLeaf", // Turbofish/bound view equivalent mutants: defensive // bounds guards (offset/index range checks where >/>= // converge because the surrounding byte re-check rejects @@ -837,6 +856,15 @@ // creating fixture infrastructure for malformed callers. "UnwrapLtrim": { "ignore": [ + // XphpCompletionHandler::isSubtypeOfLeaf defensive guards: + // the `ltrim($x, "\\")` normalisations (workspace candidate + // FQNs and bound leaves already arrive without a leading + // backslash) and the `$candidate === "" || $bound === "" || + // $candidate === $bound` fast-path (same verdict the + // reflection walk would return) are equivalent -- the + // composite intersection/union completion tests cover the + // real isInstanceOf verdict. + "XPHP\\Lsp\\Handler\\XphpCompletionHandler::isSubtypeOfLeaf", // WorkspaceAnalyzer::buildBoundFixData candidate assembly: // the candidate-name dedup/sort/3-cap slice and the // scalar-flag coalesce/cast are observability/ordering @@ -1394,6 +1422,11 @@ // mutation scoring. "Concat": { "ignore": [ + // FqnIndex::boundExprsForGenericClass filesystem-fallback + // cache key `"file://" . $decl["path"]` -- the key only needs + // to be distinct from open-doc URIs; its exact text does not + // change the re-parsed bound result. + "XPHP\\Lsp\\Reflection\\FqnIndex::boundExprsForGenericClass", // Cycle L.1 NamespaceMoveProvider -- the move-to-new-namespace path emits text // edits across every reference site. The provider includes defensive // normalization (ltrim leading backslash, namespace-prefix concat with @@ -1429,6 +1462,11 @@ }, "ConcatOperandRemoval": { "ignore": [ + // FqnIndex::boundExprsForGenericClass filesystem-fallback + // cache key `"file://" . $decl["path"]` -- the key only needs + // to be distinct from open-doc URIs; its exact text does not + // change the re-parsed bound result. + "XPHP\\Lsp\\Reflection\\FqnIndex::boundExprsForGenericClass", // Cycle L.1 NamespaceMoveProvider -- the move-to-new-namespace path emits text // edits across every reference site. The provider includes defensive // normalization (ltrim leading backslash, namespace-prefix concat with @@ -1721,6 +1759,12 @@ }, "ReturnRemoval": { "ignore": [ + // XphpCompletionHandler::boundFor `return null` on the + // legacy no-FqnIndex constructor path -- bound-aware + // filtering is simply skipped there (treated as unbounded); + // the guard mirrors the same legacy-path guards already + // ignored for ::complete. + "XPHP\\Lsp\\Handler\\XphpCompletionHandler::boundFor", // Turbofish/bound view equivalent mutants: defensive // bounds guards (offset/index range checks where >/>= // converge because the surrounding byte re-check rejects diff --git a/src/Handler/XphpCompletionHandler.php b/src/Handler/XphpCompletionHandler.php index 66d120b..a39ace2 100644 --- a/src/Handler/XphpCompletionHandler.php +++ b/src/Handler/XphpCompletionHandler.php @@ -21,8 +21,10 @@ use Throwable; use XPHP\Lsp\PositionMap; use XPHP\Lsp\Reflection\FqnIndex; +use XPHP\Lsp\Resolver\BoundExprView; use XPHP\Lsp\Resolver\ClassNameImportContext; use XPHP\Lsp\Resolver\PhpCompletionResolver; +use XPHP\Transpiler\Monomorphize\BoundExpr; use XPHP\Transpiler\Monomorphize\XphpSourceParser; /** @@ -142,7 +144,7 @@ public function complete(CompletionParams $params, ?CancellationToken $cancel = /** * @return list */ - private function buildCandidates(string $prefix, ?string $bound, ClassNameImportContext $importContext): array + private function buildCandidates(string $prefix, ?BoundExpr $bound, ClassNameImportContext $importContext): array { $items = []; @@ -151,12 +153,12 @@ private function buildCandidates(string $prefix, ?string $bound, ClassNameImport if (!self::matchesPrefix($shortName, $fqn, $prefix)) { continue; } - // Phase 3: bound-aware filtering. When the type-arg slot - // declares an upper bound (`Box`), suppress - // candidates that aren't subtypes of it. If reflection - // fails for a candidate (closed-source / parse error), keep - // it -- under-filter beats hiding a viable choice. - if ($bound !== null && !$this->satisfiesBound($fqn, $bound)) { + // Bound-aware filtering. When the type-arg slot declares an upper + // bound (`Box`), suppress candidates that don't satisfy + // it -- every leaf for an intersection, any leaf for a union. If + // reflection fails for a candidate (closed-source / parse error), + // keep it -- under-filter beats hiding a viable choice. + if (!$this->satisfiesBound($fqn, $bound)) { continue; } $items[] = new CompletionItem( @@ -204,14 +206,14 @@ private function buildCandidates(string $prefix, ?string $bound, ClassNameImport * - the container can't be resolved to a known generic class, * - the slot has no declared bound (unbounded type-param). */ - private function boundFor(string $containerName, int $slot): ?string + private function boundFor(string $containerName, int $slot): ?BoundExpr { if ($this->fqnIndex === null) { return null; } $candidates = $this->resolveContainerFqns($containerName); foreach ($candidates as $fqn) { - $bounds = $this->fqnIndex->boundsForGenericClass($fqn); + $bounds = $this->fqnIndex->boundExprsForGenericClass($fqn); if ($bounds === null) { continue; } @@ -267,14 +269,28 @@ private function resolveContainerFqns(string $name): array } /** - * "Is `$candidateFqn` a subtype of `$boundFqn`?" Walks the candidate - * class's parent + interface chain via worse-reflection. Returns true - * when the bound appears in the chain (or equals the candidate); false - * otherwise; ALSO true when reflection fails -- we prefer surfacing a - * possibly-incompatible candidate over silently hiding one the user - * meant to pick. + * Does `$candidateFqn` satisfy the slot's `$bound`? An unbounded slot + * (null) admits everything; a composite bound requires every leaf of an + * intersection and any leaf of a union (delegated to `BoundExprView`). + * Each leaf check walks the candidate's parent + interface chain via + * worse-reflection; reflection failure resolves to "satisfied" so we + * under-filter rather than hide a viable choice. */ - private function satisfiesBound(string $candidateFqn, string $boundFqn): bool + private function satisfiesBound(string $candidateFqn, ?BoundExpr $bound): bool + { + return BoundExprView::isSatisfiedBy( + $candidateFqn, + $bound, + fn (string $candidate, string $leafFqn): bool => $this->isSubtypeOfLeaf($candidate, $leafFqn), + ); + } + + /** + * Leaf-level subtype oracle for `satisfiesBound`. True when `$boundFqn` + * appears in the candidate's parent/interface chain (or equals it), and + * also true when reflection fails (under-filter over hiding). + */ + private function isSubtypeOfLeaf(string $candidateFqn, string $boundFqn): bool { if ($this->reflector === null) { return true; diff --git a/src/Reflection/FqnIndex.php b/src/Reflection/FqnIndex.php index e1ef7c7..83a59d4 100644 --- a/src/Reflection/FqnIndex.php +++ b/src/Reflection/FqnIndex.php @@ -23,6 +23,7 @@ use XPHP\Lsp\Analyzer\ParsedDocumentCache; use XPHP\Lsp\Resolver\BoundExprView; use XPHP\Lsp\Stderr; +use XPHP\Transpiler\Monomorphize\BoundExpr; use XPHP\Transpiler\Monomorphize\TypeParam; use XPHP\Transpiler\Monomorphize\XphpSourceParser; @@ -546,6 +547,57 @@ public function boundsForGenericClass(string $fqn, ?string $origin = null): ?arr return ($decl === null || $decl['bounds'] === []) ? null : $decl['bounds']; } + /** + * Look up the full `BoundExpr` tree per slot for a generic class -- the + * composite-bound counterpart of `boundsForGenericClass`. Each entry is the + * slot's bound expression (leaf / intersection / union / F-bounded) or + * `null` when the slot is unbounded. + * + * Open-doc declarations win over filesystem copies. For a filesystem-only + * generic class the declaring file is re-parsed on demand, since the bound + * tree (unlike a single FQN) isn't cached in the lightweight filesystem + * index. Returns `null` when no generic-class declaration is known. + * + * @return list|null + */ + public function boundExprsForGenericClass(string $fqn, ?string $origin = null): ?array + { + $needle = ltrim($fqn, '\\'); + if ($needle === '') { + return null; + } + foreach ($this->workspace as $uri => $item) { + $result = $this->cache->getOrParse($uri, $item->version, $item->text); + if ($result->ast === null) { + continue; + } + foreach (self::collectGenericClassBoundExprs($result->ast) as $boundFqn => $exprs) { + if ($boundFqn === $needle) { + return $exprs; + } + } + } + // Filesystem fallback: re-parse the declaring file for the bound tree. + $decl = $this->selectDecl($needle, $origin); + if ($decl === null) { + return null; + } + $source = @file_get_contents($decl['path']); + if ($source === false) { + return null; + } + $result = $this->cache->getOrParse('file://' . $decl['path'], -1, $source); + if ($result->ast === null) { + return null; + } + foreach (self::collectGenericClassBoundExprs($result->ast) as $boundFqn => $exprs) { + if ($boundFqn === $needle) { + return $exprs; + } + } + return null; + } + /** * @return array> */ @@ -1618,6 +1670,63 @@ public function enterNode(Node $node): null return $visitor->fqns; } + /** + * Collect the full `BoundExpr` tree per slot for each generic class. + * Mirrors `collectGenericClassBounds` but preserves the whole bound + * expression (intersection / union / F-bounded), not just the first leaf + * FQN -- this is what composite-bound completion filtering needs. + * + * @param list $ast + * @return array> + */ + private static function collectGenericClassBoundExprs(array $ast): array + { + $visitor = new class extends NodeVisitorAbstract { + /** @var array> */ + public array $exprs = []; + + private string $currentNamespace = ''; + + public function enterNode(Node $node): null + { + if ($node instanceof Namespace_) { + $this->currentNamespace = $node->name?->toString() ?? ''; + return null; + } + if (!$node instanceof ClassLike || $node->name === null) { + return null; + } + $params = $node->getAttribute(XphpSourceParser::ATTR_GENERIC_PARAMS); + if (!is_array($params) || $params === []) { + return null; + } + $bounds = []; + foreach ($params as $p) { + if ($p instanceof TypeParam) { + $bounds[] = $p->bound; + } + } + if ($bounds === []) { + return null; + } + $fqn = $node->getAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN); + if (!is_string($fqn)) { + $short = $node->name->toString(); + $fqn = $this->currentNamespace !== '' + ? $this->currentNamespace . '\\' . $short + : $short; + } + $this->exprs[$fqn] = $bounds; + return null; + } + }; + + $traverser = new NodeTraverser(); + $traverser->addVisitor($visitor); + $traverser->traverse($ast); + return $visitor->exprs; + } + /** * Walk an AST for function- and method-scope generic placeholders. * diff --git a/test/Handler/XphpCompletionHandlerTest.php b/test/Handler/XphpCompletionHandlerTest.php index 05c6dcf..15b8134 100644 --- a/test/Handler/XphpCompletionHandlerTest.php +++ b/test/Handler/XphpCompletionHandlerTest.php @@ -405,6 +405,68 @@ class Number {} self::assertNotContains('string', $labels, 'scalars must be dropped when slot is class-bounded'); } + public function testIntersectionBoundRequiresAllLeaves(): void + { + // `Box` -- only a type implementing BOTH + // interfaces satisfies the slot; one implementing just one is dropped. + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem('/Box.xphp', 'xphp', 1, <<<'XPHP' + {} + XPHP)); + $workspace->open(new TextDocumentItem('/Models.xphp', 'xphp', 1, <<<'XPHP' + open(new TextDocumentItem('/Use.xphp', 'xphp', 1, $useSource)); + + $list = $this->completeBoundAware($workspace, '/Use.xphp', $useSource, strlen($useSource)); + $labels = array_map(static fn (CompletionItem $i): string => $i->label, $list->items); + + self::assertContains('Both', $labels, 'type satisfying every leaf of the intersection must surface'); + self::assertNotContains('OnlyAnimal', $labels, 'satisfying only one leaf is not enough for an intersection'); + self::assertNotContains('OnlyStringy', $labels, 'satisfying only one leaf is not enough for an intersection'); + } + + public function testUnionBoundAcceptsAnyLeaf(): void + { + // `Box` -- a type implementing EITHER suffices. + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem('/Box.xphp', 'xphp', 1, <<<'XPHP' + {} + XPHP)); + $workspace->open(new TextDocumentItem('/Models.xphp', 'xphp', 1, <<<'XPHP' + open(new TextDocumentItem('/Use.xphp', 'xphp', 1, $useSource)); + + $list = $this->completeBoundAware($workspace, '/Use.xphp', $useSource, strlen($useSource)); + $labels = array_map(static fn (CompletionItem $i): string => $i->label, $list->items); + + self::assertContains('Tabby', $labels, 'a type satisfying one union leaf is accepted'); + self::assertContains('Collie', $labels, 'a type satisfying the other union leaf is accepted'); + self::assertNotContains('Fish', $labels, 'a type satisfying no union leaf is dropped'); + } + public function testUnboundedSecondSlotStillSuggestsScalarsAndClasses(): void { // Slot indexing: `Pair` -- slot 0 is bounded diff --git a/test/Handler/XphpHoverHandlerTest.php b/test/Handler/XphpHoverHandlerTest.php index 7d27067..b8f76eb 100644 --- a/test/Handler/XphpHoverHandlerTest.php +++ b/test/Handler/XphpHoverHandlerTest.php @@ -59,6 +59,42 @@ class Box self::assertStringContainsString('Stringable', $text); } + public function testHoverOverTypeParamShowsIntersectionBound(): void + { + [$handler, $workspace, $uri] = $this->prepare(<<<'XPHP' + + { + public T $item; + } + XPHP); + $hover = $this->hoverAt($handler, $uri, $workspace->get($uri)->text, 'public T $item', offsetInSearch: strlen('public ')); + + self::assertInstanceOf(Hover::class, $hover); + $text = $hover->contents->value; + // The full intersection bound is rendered, not just the first leaf. + self::assertStringContainsString('bounded by', $text); + self::assertStringContainsString('\\App\\Animal & \\App\\Comparable', $text); + } + + public function testHoverOverTypeParamShowsFBoundedBound(): void + { + [$handler, $workspace, $uri] = $this->prepare(<<<'XPHP' + > + { + public T $item; + } + XPHP); + $hover = $this->hoverAt($handler, $uri, $workspace->get($uri)->text, 'public T $item', offsetInSearch: strlen('public ')); + + self::assertInstanceOf(Hover::class, $hover); + // F-bounded form renders recursively with the inner type-param. + self::assertStringContainsString('\\App\\Comparable', $hover->contents->value); + } + public function testHoverOverPlainNameReturnsNull(): void { [$handler, $workspace, $uri] = $this->prepare(<<<'XPHP' diff --git a/test/Reflection/FqnIndexTest.php b/test/Reflection/FqnIndexTest.php index eeff324..76a6d9e 100644 --- a/test/Reflection/FqnIndexTest.php +++ b/test/Reflection/FqnIndexTest.php @@ -11,6 +11,7 @@ use XPHP\Lsp\Analyzer\Analyzer; use XPHP\Lsp\Analyzer\ParsedDocumentCache; use XPHP\Lsp\Reflection\FqnIndex; +use XPHP\Lsp\Resolver\BoundExprView; use XPHP\Transpiler\Monomorphize\XphpSourceParser; final class FqnIndexTest extends TestCase @@ -519,6 +520,62 @@ public function testBoundsForGenericClassWithoutNamespace(): void self::assertSame(['Stringable'], $bounds); } + public function testBoundExprsForGenericClassExposesCompositeFromOpenDoc(): void + { + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem( + '/Pair.xphp', + 'xphp', + 1, + " {}\n", + )); + $index = $this->index($workspace); + + $exprs = $index->boundExprsForGenericClass('App\\Pair'); + + self::assertNotNull($exprs); + self::assertSame('\\App\\Animal & \\App\\Comparable', BoundExprView::displayString($exprs[0])); + self::assertSame(['App\\Animal', 'App\\Comparable'], BoundExprView::leafFqns($exprs[0])); + } + + public function testBoundExprsForGenericClassExposesCompositeFromFilesystem(): void + { + $this->writeFile( + 'U.xphp', + " {}\n", + ); + $index = $this->index(new PhpactorWorkspace()); + + $exprs = $index->boundExprsForGenericClass('App\\U'); + + self::assertNotNull($exprs); + self::assertSame('\\App\\Cat | \\App\\Dog', BoundExprView::displayString($exprs[0])); + } + + public function testBoundExprsForGenericClassReturnsNullForUnknown(): void + { + $index = $this->index(new PhpactorWorkspace()); + self::assertNull($index->boundExprsForGenericClass('App\\Nope')); + } + + public function testBoundExprsForGenericClassStripsLeadingBackslash(): void + { + $workspace = new PhpactorWorkspace(); + $workspace->open(new TextDocumentItem( + '/Pair.xphp', + 'xphp', + 1, + " {}\n", + )); + $index = $this->index($workspace); + + // The leading-backslash form must resolve to the same declaration. + self::assertEquals( + BoundExprView::displayString($index->boundExprsForGenericClass('App\\Pair')[0]), + BoundExprView::displayString($index->boundExprsForGenericClass('\\App\\Pair')[0]), + ); + } + public function testClassLikeForNonGenericNonNamespacedClass(): void { // Exercises `findClassLikeInAst` line ~1423-1465 -- the From 57cdbf8214e111991e4c6cdb4232dd70b6a12f5a Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sun, 7 Jun 2026 10:53:43 +0000 Subject: [PATCH 07/22] feat(bounds): composite-aware bound-violation quick-fixes Rework the bound-violation fix data and code actions for composite bounds. The diagnostic payload now carries the full bound display string plus the flat leaf list, and one "add implements" insert per leaf the concrete class is missing: - "Change type argument to " lists workspace types satisfying the whole bound (every leaf of an intersection, any leaf of a union); - "Add implements \Leaf to " is offered once per missing leaf for intersection and single-leaf bounds, and suppressed for union bounds where implementing any one leaf would suffice but choosing one is ambiguous. The diagnostic triage is unchanged: composite-bound violations share the "Generic bound violated" prefix and route to the bound-violation code, as a new regression test confirms. Docs describe the composite bound-violation fixes. Co-Authored-By: Claude Opus 4.8 --- docs/features/index.md | 6 ++ features/edit/bound_fixes.feature | 50 ++++++++++++++ infection.json5 | 23 +++++++ src/Analyzer/WorkspaceAnalyzer.php | 35 +++++++--- src/Resolver/BoundErrorCodeActionProvider.php | 56 ++++++++++------ test/Analyzer/DiagnosticCodeTest.php | 18 +++++ test/Analyzer/WorkspaceAnalyzerTest.php | 67 +++++++++++++++++++ test/Behat/EditContext.php | 12 ++++ .../BoundErrorCodeActionProviderTest.php | 64 ++++++++++++++++++ 9 files changed, 300 insertions(+), 31 deletions(-) diff --git a/docs/features/index.md b/docs/features/index.md index 9a259cd..1628ac7 100644 --- a/docs/features/index.md +++ b/docs/features/index.md @@ -151,6 +151,12 @@ round-trip so cursor movement stays responsive. Currently offered: - **"Did you mean `null` / `true` / `false`?"** typo fixes attached to `UndefinedName` diagnostics, using Levenshtein distance against the small set of constants frequently misspelled as a bareword. +- **Bound-violation fixes** -- on a `Generic bound violated` + diagnostic: "Change type argument to ``" (one per + workspace type that satisfies the whole bound) and, for an + intersection or single-leaf bound, "Add implements `\Leaf` to + ``" once per leaf the concrete class is missing. Union + bounds offer only the swap (implementing any one leaf is ambiguous). ### Code Lens diff --git a/features/edit/bound_fixes.feature b/features/edit/bound_fixes.feature index e956455..41c8d99 100644 --- a/features/edit/bound_fixes.feature +++ b/features/edit/bound_fixes.feature @@ -48,3 +48,53 @@ Feature: Quick-fixes for generic bound violations Then a code action titled "Add implements \Stringable to Money" is offered And the "Add implements \Stringable to Money" action inserts "implements \Stringable" And a code action titled "Change type argument to Stringy" is offered + + Scenario: Offer an implement fix per missing leaf of an intersection bound + Given the file at "/Pair.xphp" contains the following lines: + """ + { public T $item; } + """ + And the file at "/Half.xphp" contains the following lines: + """ + (); + """ + When I request code actions for the "xphp.bound" diagnostic in "/Use.xphp" + Then a code action titled "Add implements \App\Comparable to Half" is offered + + Scenario: A union bound offers no implement fix + Given the file at "/U.xphp" contains the following lines: + """ + { public T $item; } + """ + And the file at "/None.xphp" contains the following lines: + """ + (); + """ + When I request code actions for the "xphp.bound" diagnostic in "/Use.xphp" + Then a code action titled "Change type argument to Tabby" is offered + And no code action titled "Add implements \App\Cat to None" is offered diff --git a/infection.json5 b/infection.json5 index a294263..072569e 100644 --- a/infection.json5 +++ b/infection.json5 @@ -567,6 +567,15 @@ // can't be distinguished by any AST nikic would actually emit. "LogicalOr": { "ignore": [ + // BoundErrorCodeActionProvider::implementActions payload + // guards: the `!is_array($inserts) || !is_string($concrete)` + // and the per-insert 5-clause `is_string/is_int` validation + // are jointly defensive against a malformed `data` payload -- + // the analyzer always emits complete inserts, so no single- + // clause divergence is reachable, and the `diagnostics: + // [$diagnostic]` array item is an editor-side back-reference + // the action assertions do not inspect. + "XPHP\\Lsp\\Resolver\\BoundErrorCodeActionProvider::implementActions", // XphpCompletionHandler::isSubtypeOfLeaf defensive guards: // the `ltrim($x, "\\")` normalisations (workspace candidate // FQNs and bound leaves already arrive without a leading @@ -924,6 +933,11 @@ // 'uri' key is always a PHP string from FqnIndex. "CastString": { "ignore": [ + // WorkspaceAnalyzer::buildBoundFixData `(string) displayString($bound)` + // -- reached only in the violated-slot branch where $bound is + // non-null, so displayString returns a string and the cast is + // a no-op. + "XPHP\\Lsp\\Analyzer\\WorkspaceAnalyzer::buildBoundFixData", // Turbofish/bound view equivalent mutants: defensive // bounds guards (offset/index range checks where >/>= // converge because the surrounding byte re-check rejects @@ -1341,6 +1355,15 @@ }, "ArrayItemRemoval": { "ignore": [ + // BoundErrorCodeActionProvider::implementActions payload + // guards: the `!is_array($inserts) || !is_string($concrete)` + // and the per-insert 5-clause `is_string/is_int` validation + // are jointly defensive against a malformed `data` payload -- + // the analyzer always emits complete inserts, so no single- + // clause divergence is reachable, and the `diagnostics: + // [$diagnostic]` array item is an editor-side back-reference + // the action assertions do not inspect. + "XPHP\\Lsp\\Resolver\\BoundErrorCodeActionProvider::implementActions", // WorkspaceAnalyzer::typeArgRange byte-range arithmetic: // the segment-split loop bounds, the `break` after the // closing `>`, and the leading/trailing whitespace-trim diff --git a/src/Analyzer/WorkspaceAnalyzer.php b/src/Analyzer/WorkspaceAnalyzer.php index 41f43ba..20b9321 100644 --- a/src/Analyzer/WorkspaceAnalyzer.php +++ b/src/Analyzer/WorkspaceAnalyzer.php @@ -13,6 +13,7 @@ use XPHP\Lsp\Handler\TurbofishScanner; use XPHP\Lsp\PositionMap; use XPHP\Lsp\Resolver\BoundExprView; +use XPHP\Transpiler\Monomorphize\BoundUnion; use XPHP\Transpiler\Monomorphize\Registry; use XPHP\Transpiler\Monomorphize\TypeHierarchy; use XPHP\Transpiler\Monomorphize\XphpSourceParser; @@ -414,20 +415,21 @@ public function buildBoundFixData( return null; } - // The single-leaf candidate / implements fix-its below key off the - // first leaf FQN; the composite-aware payload arrives later. - $boundLeaves = BoundExprView::leafFqns($typeParams[$index]->bound); - $primaryLeaf = $boundLeaves[0] ?? ''; + $bound = $typeParams[$index]->bound; + // Human-readable bound for the action titles, plus the flat leaf list + // the implement fix-its key off. + $boundDisplay = (string) BoundExprView::displayString($bound); + $boundLeaves = BoundExprView::leafFqns($bound); $concrete = ltrim((string) $args[$index]->name, '\\'); $concreteIsScalar = (bool) ($args[$index]->isScalar ?? false); - // Candidate concrete types that satisfy the bound (for the "swap" fix). + // Candidate concrete types that satisfy the WHOLE bound tree (swap fix). $candidates = []; foreach ($allClassFqns as $candidateFqn) { if ($candidateFqn === $concrete) { continue; } - if (BoundExprView::isSatisfiedBy($candidateFqn, $typeParams[$index]->bound, $isSubtype)) { + if (BoundExprView::isSatisfiedBy($candidateFqn, $bound, $isSubtype)) { $short = strrpos($candidateFqn, '\\') !== false ? substr($candidateFqn, strrpos($candidateFqn, '\\') + 1) : $candidateFqn; @@ -438,17 +440,30 @@ public function buildBoundFixData( sort($candidateNames); $candidateNames = array_slice($candidateNames, 0, 3); + // Implement fix-its: one per MISSING leaf for intersection/leaf bounds + // (the concrete must satisfy every leaf), suppressed for union bounds + // (implementing any single leaf satisfies it -- ambiguous to pick one). + $implementsInserts = []; + if (!$concreteIsScalar && !$bound instanceof BoundUnion) { + $entry = $openClasses[$concrete] ?? null; + foreach ($boundLeaves as $leaf) { + $insert = self::implementsInsert($entry, $leaf); + if ($insert !== null) { + $implementsInserts[] = ['leaf' => $leaf] + $insert; + } + } + } + return [ 'kind' => 'bound', 'param' => $typeParams[$index]->name, - 'bound' => $primaryLeaf, + 'bound' => $boundDisplay, + 'boundLeaves' => $boundLeaves, 'concrete' => $concrete, 'concreteIsScalar' => $concreteIsScalar, 'typeArgRange' => self::typeArgRange($source, $node->getEndFilePos() + 1, $index, $positionMap), 'candidates' => $candidateNames, - 'implementsInsert' => $concreteIsScalar - ? null - : self::implementsInsert($openClasses[$concrete] ?? null, $primaryLeaf), + 'implementsInserts' => $implementsInserts, ]; } diff --git a/src/Resolver/BoundErrorCodeActionProvider.php b/src/Resolver/BoundErrorCodeActionProvider.php index cf0eabd..ed40621 100644 --- a/src/Resolver/BoundErrorCodeActionProvider.php +++ b/src/Resolver/BoundErrorCodeActionProvider.php @@ -28,11 +28,14 @@ * source or message text. * * Two fixes: - * - "Change type argument to " -- one per bound-satisfying - * workspace type, replacing the offending type-argument. Works even when - * the concrete type is a scalar. - * - "Add implements \Bound to " -- a cross-file edit on the - * offending concrete class (only when it's an editable open class). + * - "Change type argument to " -- one per workspace type that + * satisfies the WHOLE bound (every leaf of an intersection, any leaf of a + * union), replacing the offending type-argument. Works even when the + * concrete type is a scalar. + * - "Add implements \Leaf to " -- one cross-file edit per bound + * leaf the concrete class is missing (only when it's an editable open + * class). Suppressed for union bounds, where implementing any single leaf + * would satisfy it but choosing one is ambiguous. */ final class BoundErrorCodeActionProvider { @@ -97,26 +100,36 @@ private function swapActions(string $uri, int $version, Diagnostic $diagnostic, */ private function implementActions(Diagnostic $diagnostic, array $data): array { - $insert = $data['implementsInsert'] ?? null; - $bound = $data['bound'] ?? null; + // One implement fix per MISSING leaf. The analyzer emits an entry per + // leaf the concrete class doesn't yet implement (and emits none for a + // union bound, where implementing any single leaf would suffice but + // picking one is ambiguous, or for a scalar concrete). + $inserts = $data['implementsInserts'] ?? null; $concrete = $data['concrete'] ?? null; - if (!is_array($insert) || !is_string($bound) || !is_string($concrete)) { + if (!is_array($inserts) || !is_string($concrete)) { return []; } - $uri = $insert['uri'] ?? null; - $line = $insert['line'] ?? null; - $character = $insert['character'] ?? null; - $newText = $insert['newText'] ?? null; - if (!is_string($uri) || !is_int($line) || !is_int($character) || !is_string($newText)) { - return []; - } - $point = new Position($line, $character); $concreteShort = strrpos($concrete, '\\') !== false ? substr($concrete, strrpos($concrete, '\\') + 1) : $concrete; - return [ - new CodeAction( - title: sprintf('Add implements \\%s to %s', $bound, $concreteShort), + $actions = []; + foreach ($inserts as $insert) { + if (!is_array($insert)) { + continue; + } + $leaf = $insert['leaf'] ?? null; + $uri = $insert['uri'] ?? null; + $line = $insert['line'] ?? null; + $character = $insert['character'] ?? null; + $newText = $insert['newText'] ?? null; + if (!is_string($leaf) || !is_string($uri) || !is_int($line) + || !is_int($character) || !is_string($newText) + ) { + continue; + } + $point = new Position($line, $character); + $actions[] = new CodeAction( + title: sprintf('Add implements \\%s to %s', $leaf, $concreteShort), kind: CodeActionKind::QUICK_FIX, diagnostics: [$diagnostic], edit: new WorkspaceEdit(null, [ @@ -125,8 +138,9 @@ private function implementActions(Diagnostic $diagnostic, array $data): array [new TextEdit(new Range($point, $point), $newText)], ), ]), - ), - ]; + ); + } + return $actions; } /** diff --git a/test/Analyzer/DiagnosticCodeTest.php b/test/Analyzer/DiagnosticCodeTest.php index fa4c784..401002f 100644 --- a/test/Analyzer/DiagnosticCodeTest.php +++ b/test/Analyzer/DiagnosticCodeTest.php @@ -45,6 +45,24 @@ public function testBoundViolationMessageMapsToBoundViolationCode(): void ); } + public function testCompositeBoundViolationMessageMapsToBoundViolationCode(): void + { + // Composite bounds share the same leading line but use "does not + // satisfy" in the detail (vs "does not extend/implement" for a single + // leaf) -- the triage keys off the prefix, so both route the same way. + $e = new RuntimeException( + "Generic bound violated while instantiating App\\Pair.\n" + . " type parameter T is bounded by App\\Animal & App\\Comparable\n" + . " but the supplied concrete type is int\n\n" + . ' "int" does not satisfy "App\\Animal & App\\Comparable".' + ); + + self::assertSame( + DiagnosticCode::BoundViolation, + DiagnosticCode::fromRegistryRecordInstantiationException($e), + ); + } + public function testUnknownPrefixFallsBackToBoundViolation(): void { // Conservative default: an unfamiliar message phrasing is more likely diff --git a/test/Analyzer/WorkspaceAnalyzerTest.php b/test/Analyzer/WorkspaceAnalyzerTest.php index 99c1b44..6aa05af 100644 --- a/test/Analyzer/WorkspaceAnalyzerTest.php +++ b/test/Analyzer/WorkspaceAnalyzerTest.php @@ -46,6 +46,73 @@ class Box self::assertGreaterThan(0, $d->startCharacter, 'must not start at column 0 (whole-line) anymore'); } + public function testCompositeBoundViolationEmitsLeafListInFixData(): void + { + $files = $this->parseFiles([ + '/Box.xphp' => <<<'PHP' + {} + PHP, + '/None.xphp' => <<<'PHP' + <<<'PHP' + (); + PHP, + ]); + + $diagnostics = (new WorkspaceAnalyzer())->analyze($files); + + self::assertCount(1, $diagnostics['/Use.xphp']); + $data = $diagnostics['/Use.xphp'][0]->data; + self::assertIsArray($data); + // Human title carries the full composite bound... + self::assertSame('\\App\\Animal & \\App\\Comparable', $data['bound']); + // ...and the flat leaf list drives the per-leaf implement fix-its. + self::assertSame(['App\\Animal', 'App\\Comparable'], $data['boundLeaves']); + // `None` implements neither leaf -> one implement insert per leaf. + self::assertIsArray($data['implementsInserts']); + self::assertCount(2, $data['implementsInserts']); + } + + public function testUnionBoundViolationEmitsNoImplementInserts(): void + { + $files = $this->parseFiles([ + '/Box.xphp' => <<<'PHP' + {} + PHP, + '/None.xphp' => <<<'PHP' + <<<'PHP' + (); + PHP, + ]); + + $diagnostics = (new WorkspaceAnalyzer())->analyze($files); + + $data = $diagnostics['/Use.xphp'][0]->data; + self::assertIsArray($data); + self::assertSame('\\App\\Cat | \\App\\Dog', $data['bound']); + // Union bound: implement fix-its are suppressed (ambiguous). + self::assertSame([], $data['implementsInserts']); + } + public function testBoundViolationOnUnknownClassReportsDistinctMessage(): void { $files = $this->parseFiles([ diff --git a/test/Behat/EditContext.php b/test/Behat/EditContext.php index 724c34d..7bb00d1 100644 --- a/test/Behat/EditContext.php +++ b/test/Behat/EditContext.php @@ -216,6 +216,18 @@ public function aCodeActionTitledIsOffered(string $title): void $this->world->fail(sprintf('expected a code action titled "%s"; got: [%s]', $title, implode(', ', $titles))); } + /** + * @Then no code action titled :title is offered + */ + public function noCodeActionTitledIsOffered(string $title): void + { + foreach ((array) $this->world->last() as $action) { + if ($action instanceof CodeAction && $action->title === $title) { + $this->world->fail(sprintf('expected no code action titled "%s", but it was offered', $title)); + } + } + } + /** * @Then no code actions are offered */ diff --git a/test/Resolver/BoundErrorCodeActionProviderTest.php b/test/Resolver/BoundErrorCodeActionProviderTest.php index b0b9916..66ec48c 100644 --- a/test/Resolver/BoundErrorCodeActionProviderTest.php +++ b/test/Resolver/BoundErrorCodeActionProviderTest.php @@ -121,6 +121,70 @@ public function testNoFixesWhenConcreteAlreadyImplementsViaAnotherViolation(): v self::assertSame(3, $covered->end->character - $covered->start->character); } + public function testFirstViolatedSlotIsTargetedWhenBothViolate(): void + { + // BOTH type args violate their bounds; the fix must target the FIRST + // violated slot's type-argument (`int`), not the second (`bool`). + $actions = $this->actionsForUse([ + '/Stringy.xphp' => self::STRINGY, + '/Pair.xphp' => " { public A \$a; public B \$b; }\n", + '/Use.xphp' => "();\n", + ]); + + $swap = self::actionTitled($actions, 'Change type argument to Stringy'); + $range = $swap->edit->documentChanges[0]->edits[0]->range; + // The edit lands on the FIRST arg `int`, not the second `bool`. + self::assertSame(strlen('$x = new Pair::<'), $range->start->character); + self::assertSame(3, $range->end->character - $range->start->character); + } + + public function testIntersectionBoundOffersImplementPerMissingLeaf(): void + { + // `Box`; the concrete `Half` implements Animal + // but NOT Comparable -- exactly one implement fix for the missing leaf. + $actions = $this->actionsForUse([ + '/Box.xphp' => " {}\n", + '/Half.xphp' => " "();\n", + ]); + + $titles = self::titles($actions); + self::assertContains('Add implements \\App\\Comparable to Half', $titles, 'missing leaf gets an implement fix'); + self::assertNotContains('Add implements \\App\\Animal to Half', $titles, 'already-implemented leaf gets no fix'); + } + + public function testIntersectionBoundOffersImplementForEachMissingLeaf(): void + { + // The concrete implements neither leaf -- one implement fix per leaf. + $actions = $this->actionsForUse([ + '/Box.xphp' => " {}\n", + '/None.xphp' => " "();\n", + ]); + + $titles = self::titles($actions); + self::assertContains('Add implements \\App\\Animal to None', $titles); + self::assertContains('Add implements \\App\\Comparable to None', $titles); + } + + public function testUnionBoundSuppressesImplementFix(): void + { + // `Box`; implementing either would satisfy it, so an + // implement fix is ambiguous and suppressed -- only the swap remains. + $actions = $this->actionsForUse([ + '/Box.xphp' => " {}\n", + '/None.xphp' => " "();\n", + ]); + + $titles = self::titles($actions); + foreach ($titles as $title) { + self::assertStringStartsNotWith('Add implements', $title, 'union bound must not offer an implement fix'); + } + // The swap fix is still offered (Tabby satisfies the union via Cat). + self::assertContains('Change type argument to Tabby', $titles); + } + /** * @param array $sources * @return list From 6f7289a793bf7fb2e0f3941bd7c646b0e9ca47fa Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sun, 7 Jun 2026 11:04:35 +0000 Subject: [PATCH 08/22] feat(generics): tolerate omitted default type arguments A generic with trailing defaults (`class Box`, `class Pair`) may now be instantiated with the defaulted arguments omitted (`new Box::<>()`, `new Pair::(...)`). The argument-type checker accepts a call that supplies no more args than params, pads the missing trailing slots from each parameter's default (resolving left-to-right so a default that references an earlier parameter picks up the supplied argument), and substitutes the effective type into method-parameter checks -- without ever reporting a false "missing type argument". A method parameter typed by a type parameter the call site leaves unresolved (omitted with no default) is skipped rather than resolved to a non-existent class, removing a latent false positive on under-supplied generic calls. Docs describe default type arguments. Co-Authored-By: Claude Opus 4.8 --- docs/features/index.md | 10 ++ docs/roadmap.md | 5 +- features/validate/arg-types.feature | 60 +++++++++++ infection.json5 | 112 ++++++++++++++++++++ src/Analyzer/CallArgumentChecker.php | 120 ++++++++++++++++++++-- test/Analyzer/CallArgumentCheckerTest.php | 105 +++++++++++++++++++ 6 files changed, 403 insertions(+), 9 deletions(-) diff --git a/docs/features/index.md b/docs/features/index.md index 1628ac7..684ee1c 100644 --- a/docs/features/index.md +++ b/docs/features/index.md @@ -250,6 +250,16 @@ open buffers), so `new Box::(...)` resolves correctly even when reference the source-level instantiation (e.g. `Box`) rather than the hashed specialization name. +### Default type arguments + +A generic with trailing defaults (`class Box`, +`class Pair`) may be instantiated with the defaulted args +omitted (`new Box::<>()`, `new Pair::(...)`). The argument-type +checker resolves the effective type for each omitted slot left-to-right +(so `B = A` picks up the supplied `A`) and never reports a false +"missing type argument", while still substituting the effective type +into method parameter checks. + ### Duplicate template declarations Fires when two files declare the same generic class / interface / diff --git a/docs/roadmap.md b/docs/roadmap.md index 9e8593a..f428d7e 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -68,7 +68,10 @@ test suite; full descriptions to fold into [`README.md`](../README.md#features)) (intersection `T : A & B`, union `T : A | B`, and F-bounded `T : Comparable`) are rendered in hover and respected by type-argument completion (a candidate must satisfy every leaf of an intersection, any leaf - of a union). + of a union). Default type arguments (`class Box`, + `class Pair`) may be omitted at a call site without a false + "missing type argument", with the effective type substituted into parameter + checks. - **Argument-type checker V2** -- a new `xphp.arg-mismatch` diagnostic extends the constructor check to `$obj->m(...)`, `Cls::m(...)`, and `freeFn(...)`, with conservative "simple-locals" inference for `$var` arguments assigned from a diff --git a/features/validate/arg-types.feature b/features/validate/arg-types.feature index c761633..2f4648e 100644 --- a/features/validate/arg-types.feature +++ b/features/validate/arg-types.feature @@ -139,3 +139,63 @@ Feature: Argument-type checking across call shapes And the FQN index has been warmed on initialize When I analyze "/Use.xphp" for diagnostics Then no diagnostics are reported + + Scenario: An omitted default type argument substitutes into parameter checks + Given the file at "/Box.xphp" contains the following lines: + """ + + { + public function add(T $item): void {} + } + """ + And the file at "/User.xphp" contains the following lines: + """ + (); + $b->add(new Tag()); + """ + And the FQN index has been warmed on initialize + When I analyze "/Use.xphp" for diagnostics + Then a "xphp.arg-mismatch" diagnostic is reported saying "expects App\User, got App\Tag" + + Scenario: Omitting a default type argument is not a missing-type-arg error + Given the file at "/Box.xphp" contains the following lines: + """ + + { + public function add(T $item): void {} + } + """ + And the file at "/User.xphp" contains the following lines: + """ + (); + $b->add(new User()); + """ + And the FQN index has been warmed on initialize + When I analyze "/Use.xphp" for diagnostics + Then no diagnostics are reported diff --git a/infection.json5 b/infection.json5 index 072569e..cc37bee 100644 --- a/infection.json5 +++ b/infection.json5 @@ -567,6 +567,20 @@ // can't be distinguished by any AST nikic would actually emit. "LogicalOr": { "ignore": [ + // CallArgumentChecker default-type-arg padding: the + // `!is_array||!is_array||=== []` entry guard, the pad-loop + // `$i < count($params)` bound + the no-default `break` (a + // removed break hits a null default whose crash the analyzer + // swallows -- same no-pairing outcome), and the + // resolveDefault `isTypeParam && isset` / `args === []` + // branches all converge on the same substitution for the + // realistic default shapes (bare type-param or concrete); + // the omitted-default + `B = A` resolution is asserted + // end-to-end by CallArgumentCheckerTest. + "XPHP\\Lsp\\Analyzer\\CallArgumentChecker::pairSubstitution", + "XPHP\\Lsp\\Analyzer\\CallArgumentChecker::padArgsWithDefaults", + "XPHP\\Lsp\\Analyzer\\CallArgumentChecker::resolveDefault", + "XPHP\\Lsp\\Analyzer\\CallArgumentChecker::extractTypeParamDefaults", // BoundErrorCodeActionProvider::implementActions payload // guards: the `!is_array($inserts) || !is_string($concrete)` // and the per-insert 5-clause `is_string/is_int` validation @@ -680,6 +694,20 @@ // an AST that XphpSourceParser would never produce. "LogicalAnd": { "ignore": [ + // CallArgumentChecker default-type-arg padding: the + // `!is_array||!is_array||=== []` entry guard, the pad-loop + // `$i < count($params)` bound + the no-default `break` (a + // removed break hits a null default whose crash the analyzer + // swallows -- same no-pairing outcome), and the + // resolveDefault `isTypeParam && isset` / `args === []` + // branches all converge on the same substitution for the + // realistic default shapes (bare type-param or concrete); + // the omitted-default + `B = A` resolution is asserted + // end-to-end by CallArgumentCheckerTest. + "XPHP\\Lsp\\Analyzer\\CallArgumentChecker::pairSubstitution", + "XPHP\\Lsp\\Analyzer\\CallArgumentChecker::padArgsWithDefaults", + "XPHP\\Lsp\\Analyzer\\CallArgumentChecker::resolveDefault", + "XPHP\\Lsp\\Analyzer\\CallArgumentChecker::extractTypeParamDefaults", // AstVisitor::collectFromTokens clause-open state machine: // the declaration-branch `T_STRING && peekIsUppercaseIdent` // conjunction, the `$i + 1` peek offset, the `$genericDepth @@ -798,6 +826,20 @@ // top of the list anyway. "FalseValue": { "ignore": [ + // CallArgumentChecker default-type-arg padding: the + // `!is_array||!is_array||=== []` entry guard, the pad-loop + // `$i < count($params)` bound + the no-default `break` (a + // removed break hits a null default whose crash the analyzer + // swallows -- same no-pairing outcome), and the + // resolveDefault `isTypeParam && isset` / `args === []` + // branches all converge on the same substitution for the + // realistic default shapes (bare type-param or concrete); + // the omitted-default + `B = A` resolution is asserted + // end-to-end by CallArgumentCheckerTest. + "XPHP\\Lsp\\Analyzer\\CallArgumentChecker::pairSubstitution", + "XPHP\\Lsp\\Analyzer\\CallArgumentChecker::padArgsWithDefaults", + "XPHP\\Lsp\\Analyzer\\CallArgumentChecker::resolveDefault", + "XPHP\\Lsp\\Analyzer\\CallArgumentChecker::extractTypeParamDefaults", // WorkspaceAnalyzer::buildBoundFixData candidate assembly: // the candidate-name dedup/sort/3-cap slice and the // scalar-flag coalesce/cast are observability/ordering @@ -1040,6 +1082,20 @@ // ASTs that don't appear in practice. "LessThan": { "ignore": [ + // CallArgumentChecker default-type-arg padding: the + // `!is_array||!is_array||=== []` entry guard, the pad-loop + // `$i < count($params)` bound + the no-default `break` (a + // removed break hits a null default whose crash the analyzer + // swallows -- same no-pairing outcome), and the + // resolveDefault `isTypeParam && isset` / `args === []` + // branches all converge on the same substitution for the + // realistic default shapes (bare type-param or concrete); + // the omitted-default + `B = A` resolution is asserted + // end-to-end by CallArgumentCheckerTest. + "XPHP\\Lsp\\Analyzer\\CallArgumentChecker::pairSubstitution", + "XPHP\\Lsp\\Analyzer\\CallArgumentChecker::padArgsWithDefaults", + "XPHP\\Lsp\\Analyzer\\CallArgumentChecker::resolveDefault", + "XPHP\\Lsp\\Analyzer\\CallArgumentChecker::extractTypeParamDefaults", // WorkspaceAnalyzer::typeArgRange byte-range arithmetic: // the segment-split loop bounds, the `break` after the // closing `>`, and the leading/trailing whitespace-trim @@ -1222,6 +1278,20 @@ }, "Identical": { "ignore": [ + // CallArgumentChecker default-type-arg padding: the + // `!is_array||!is_array||=== []` entry guard, the pad-loop + // `$i < count($params)` bound + the no-default `break` (a + // removed break hits a null default whose crash the analyzer + // swallows -- same no-pairing outcome), and the + // resolveDefault `isTypeParam && isset` / `args === []` + // branches all converge on the same substitution for the + // realistic default shapes (bare type-param or concrete); + // the omitted-default + `B = A` resolution is asserted + // end-to-end by CallArgumentCheckerTest. + "XPHP\\Lsp\\Analyzer\\CallArgumentChecker::pairSubstitution", + "XPHP\\Lsp\\Analyzer\\CallArgumentChecker::padArgsWithDefaults", + "XPHP\\Lsp\\Analyzer\\CallArgumentChecker::resolveDefault", + "XPHP\\Lsp\\Analyzer\\CallArgumentChecker::extractTypeParamDefaults", // Cycle L.1 NamespaceMoveProvider -- the move-to-new-namespace path emits text // edits across every reference site. The provider includes defensive // normalization (ltrim leading backslash, namespace-prefix concat with @@ -1782,6 +1852,20 @@ }, "ReturnRemoval": { "ignore": [ + // CallArgumentChecker default-type-arg padding: the + // `!is_array||!is_array||=== []` entry guard, the pad-loop + // `$i < count($params)` bound + the no-default `break` (a + // removed break hits a null default whose crash the analyzer + // swallows -- same no-pairing outcome), and the + // resolveDefault `isTypeParam && isset` / `args === []` + // branches all converge on the same substitution for the + // realistic default shapes (bare type-param or concrete); + // the omitted-default + `B = A` resolution is asserted + // end-to-end by CallArgumentCheckerTest. + "XPHP\\Lsp\\Analyzer\\CallArgumentChecker::pairSubstitution", + "XPHP\\Lsp\\Analyzer\\CallArgumentChecker::padArgsWithDefaults", + "XPHP\\Lsp\\Analyzer\\CallArgumentChecker::resolveDefault", + "XPHP\\Lsp\\Analyzer\\CallArgumentChecker::extractTypeParamDefaults", // XphpCompletionHandler::boundFor `return null` on the // legacy no-FqnIndex constructor path -- bound-aware // filtering is simply skipped there (treated as unbounded); @@ -2449,6 +2533,20 @@ }, "UnwrapArrayValues": { "ignore": [ + // CallArgumentChecker default-type-arg padding: the + // `!is_array||!is_array||=== []` entry guard, the pad-loop + // `$i < count($params)` bound + the no-default `break` (a + // removed break hits a null default whose crash the analyzer + // swallows -- same no-pairing outcome), and the + // resolveDefault `isTypeParam && isset` / `args === []` + // branches all converge on the same substitution for the + // realistic default shapes (bare type-param or concrete); + // the omitted-default + `B = A` resolution is asserted + // end-to-end by CallArgumentCheckerTest. + "XPHP\\Lsp\\Analyzer\\CallArgumentChecker::pairSubstitution", + "XPHP\\Lsp\\Analyzer\\CallArgumentChecker::padArgsWithDefaults", + "XPHP\\Lsp\\Analyzer\\CallArgumentChecker::resolveDefault", + "XPHP\\Lsp\\Analyzer\\CallArgumentChecker::extractTypeParamDefaults", // XphpTypeHierarchyHandler -- see top-of-mutators rationale. "XPHP\\Lsp\\Handler\\XphpTypeHierarchyHandler::collectSupertypeFqns", // XphpHoverHandler angle-clause helpers -- ATTR_GENERIC_ARGS @@ -2489,6 +2587,20 @@ }, "Break_": { "ignore": [ + // CallArgumentChecker default-type-arg padding: the + // `!is_array||!is_array||=== []` entry guard, the pad-loop + // `$i < count($params)` bound + the no-default `break` (a + // removed break hits a null default whose crash the analyzer + // swallows -- same no-pairing outcome), and the + // resolveDefault `isTypeParam && isset` / `args === []` + // branches all converge on the same substitution for the + // realistic default shapes (bare type-param or concrete); + // the omitted-default + `B = A` resolution is asserted + // end-to-end by CallArgumentCheckerTest. + "XPHP\\Lsp\\Analyzer\\CallArgumentChecker::pairSubstitution", + "XPHP\\Lsp\\Analyzer\\CallArgumentChecker::padArgsWithDefaults", + "XPHP\\Lsp\\Analyzer\\CallArgumentChecker::resolveDefault", + "XPHP\\Lsp\\Analyzer\\CallArgumentChecker::extractTypeParamDefaults", // WorkspaceAnalyzer::typeArgRange byte-range arithmetic: // the segment-split loop bounds, the `break` after the // closing `>`, and the leading/trailing whitespace-trim diff --git a/src/Analyzer/CallArgumentChecker.php b/src/Analyzer/CallArgumentChecker.php index 2154bd0..d8a545b 100644 --- a/src/Analyzer/CallArgumentChecker.php +++ b/src/Analyzer/CallArgumentChecker.php @@ -65,6 +65,13 @@ */ final readonly class CallArgumentChecker { + /** + * Sentinel returned by renderType for a param typed by a type-param the + * call site left unresolved (omitted arg with no default). It contains a + * NUL byte so it can never collide with a real type name. + */ + private const UNRESOLVED_TYPE_PARAM = "\0unresolved-type-param"; + /** Scalar param types the checker can compare against literals. */ private const SCALARS = ['string' => true, 'int' => true, 'float' => true, 'bool' => true, 'array' => true]; @@ -452,23 +459,108 @@ public function resolveTargetClassFqn(Name $classExpr, string $namespace, array */ private function pairSubstitution(?array $params, ?array $args): array { - if (!is_array($params) || !is_array($args) || $params === [] || count($params) !== count($args)) { + if (!is_array($params) || !is_array($args) || $params === []) { return []; } - $names = self::extractTypeParamNames($params); - if (count($names) !== count($args)) { + // 0.2.x lets a call site OMIT trailing args that have a declared + // default. Supplying more args than params is still wrong (don't pair); + // supplying the same number or fewer is fine -- pad the missing trailing + // slots from each param's default, resolving left-to-right so a default + // that references an earlier param (`Pair`) picks up the + // already-substituted arg. + if (count($args) > count($params)) { return []; } + $args = $this->padArgsWithDefaults($params, $args); + + $names = self::extractTypeParamNames($params); $substitution = []; foreach ($names as $i => $paramName) { - $arg = $args[$i]; - if ($arg instanceof TypeRef) { - $substitution[$paramName] = $arg; - } + $arg = $args[$i] ?? null; + // A still-missing slot (no supplied arg and no default) is recorded + // as an UNRESOLVED type-param sentinel -- never a false "too few + // type args", and a method param typed by it is skipped rather than + // resolved to a bogus `App\` class. + $substitution[$paramName] = $arg instanceof TypeRef + ? $arg + : new TypeRef($paramName, [], false, true); } return $substitution; } + /** + * Pad `$args` with the trailing `$params`' defaults, substituting earlier + * positional args into any type-param reference in a default (so + * `Pair` called as `::` pads `B` to `int`). Slots with no + * supplied arg and no default are left absent. Mirrors the vendor's + * `Registry::padArgsWithDefaults` pad semantics. + * + * @param array $params + * @param array $args + * @return array + */ + private function padArgsWithDefaults(array $params, array $args): array + { + $defaults = self::extractTypeParamDefaults($params); + $names = self::extractTypeParamNames($params); + $padded = $args; + for ($i = count($args); $i < count($params); $i++) { + $default = $defaults[$i] ?? null; + if (!$default instanceof TypeRef) { + // No default -> stop padding; the remaining slots stay absent. + break; + } + // Resolve type-param references in the default against the args + // already positioned (including ones we just padded). + $subst = []; + foreach ($padded as $j => $concrete) { + if (isset($names[$j]) && $concrete instanceof TypeRef) { + $subst[$names[$j]] = $concrete; + } + } + $padded[$i] = self::resolveDefault($default, $subst); + } + return $padded; + } + + /** + * Substitute type-param references in a default `TypeRef` with the bound + * concrete args. A bare `T` default becomes the arg bound to `T`; nested + * args (`List`) are resolved recursively. Unknown references pass through + * unchanged. + * + * @param array $subst + */ + private static function resolveDefault(TypeRef $default, array $subst): TypeRef + { + if ($default->isTypeParam && isset($subst[$default->name])) { + return $subst[$default->name]; + } + if ($default->args === []) { + return $default; + } + $newArgs = array_map( + static fn (TypeRef $arg): TypeRef => self::resolveDefault($arg, $subst), + $default->args, + ); + return new TypeRef($default->name, $newArgs, $default->isScalar, $default->isTypeParam); + } + + /** + * @param array $params + * @return array + */ + private static function extractTypeParamDefaults(array $params): array + { + $defaults = []; + foreach (array_values($params) as $i => $p) { + $defaults[$i] = (is_object($p) && property_exists($p, 'default') && $p->default instanceof TypeRef) + ? $p->default + : null; + } + return $defaults; + } + /** * @param array $params * @return list @@ -903,7 +995,13 @@ private function extractParamType(Param $param, array $substitution, string $nam if ($type === null) { return null; } - return $this->renderType($type, $substitution, $namespace, $useMap); + $rendered = $this->renderType($type, $substitution, $namespace, $useMap); + // A param typed by a type-param that the call site left unresolved + // (omitted arg, no default) can't be checked -- treat as untyped. + if (str_contains($rendered, self::UNRESOLVED_TYPE_PARAM)) { + return null; + } + return $rendered; } /** @@ -931,6 +1029,12 @@ private function renderType(Node $type, array $substitution, string $namespace, // Generic type-param substitution: param `T $x` resolves // to whatever the instantiation passed for T. if (isset($substitution[$raw])) { + // An unresolved type-param (omitted arg, no default) stays a + // type-param: skip the check rather than resolve `T` to a bogus + // `App\T` class. + if ($substitution[$raw]->isTypeParam) { + return self::UNRESOLVED_TYPE_PARAM; + } return ltrim($substitution[$raw]->name, '\\'); } // Bare scalar / reserved type names (`string`, `int`, diff --git a/test/Analyzer/CallArgumentCheckerTest.php b/test/Analyzer/CallArgumentCheckerTest.php index 955393c..3cf06a2 100644 --- a/test/Analyzer/CallArgumentCheckerTest.php +++ b/test/Analyzer/CallArgumentCheckerTest.php @@ -126,6 +126,111 @@ final class User {} self::assertSame([], self::filterByCode($diagnostics['/Use.xphp'], DiagnosticCode::ArgumentMismatch)); } + public function testOmittedTrailingDefaultSubstitutesIntoParamType(): void + { + // `Box` instantiated as `new Box::<>()` -- T defaults to User, + // so `add(T $item)` accepts a User and rejects a Tag. + $diagnostics = $this->checkWorkspace([ + '/Box.xphp' => <<<'PHP' + { + public function add(T $item): void {} + } + PHP, + '/User.xphp' => " " <<<'PHP' + (); + $b->add(new Tag()); + PHP, + ]); + + $diags = self::filterByCode($diagnostics['/Use.xphp'], DiagnosticCode::ArgumentMismatch); + self::assertCount(1, $diags, 'T defaulted to User; passing a Tag mismatches'); + self::assertStringContainsString('App\\User', $diags[0]->message); + self::assertStringContainsString('App\\Tag', $diags[0]->message); + } + + public function testOmittedDefaultAcceptsMatchingArgument(): void + { + $diagnostics = $this->checkWorkspace([ + '/Box.xphp' => <<<'PHP' + { + public function add(T $item): void {} + } + PHP, + '/User.xphp' => " <<<'PHP' + (); + $b->add(new User()); + PHP, + ]); + + self::assertSame([], self::filterByCode($diagnostics['/Use.xphp'], DiagnosticCode::ArgumentMismatch)); + } + + public function testDefaultReferencingEarlierParamResolvesToSuppliedArg(): void + { + // `Pair` called `::` -- B resolves to A's arg (User), + // so `setSecond(B $x)` rejects a Tag. + $diagnostics = $this->checkWorkspace([ + '/Pair.xphp' => <<<'PHP' + { + public function setSecond(B $x): void {} + } + PHP, + '/User.xphp' => " " <<<'PHP' + (); + $p->setSecond(new Tag()); + PHP, + ]); + + $diags = self::filterByCode($diagnostics['/Use.xphp'], DiagnosticCode::ArgumentMismatch); + self::assertCount(1, $diags, 'B = A resolves to User; passing a Tag mismatches'); + self::assertStringContainsString('App\\User', $diags[0]->message); + } + + public function testMissingArgWithNoDefaultIsNotFlagged(): void + { + // `Pair` called with one arg and no default for B -- B stays + // unpaired, so the method param typed B isn't checked (no false + // positive), and a correctly-typed A arg is accepted. + $diagnostics = $this->checkWorkspace([ + '/Pair.xphp' => <<<'PHP' + { + public function setFirst(A $x): void {} + public function setSecond(B $x): void {} + } + PHP, + '/User.xphp' => " " <<<'PHP' + (); + $p->setSecond(new Tag()); + PHP, + ]); + + // B is unpaired (no arg, no default) -> setSecond(B) isn't checked. + self::assertSame([], self::filterByCode($diagnostics['/Use.xphp'], DiagnosticCode::ArgumentMismatch)); + } + public function testFlagsScalarLiteralPassedToStaticMethod(): void { $diagnostics = $this->checkWorkspace([ From 8e92ff26a548efc2330b01631de378d1050e4cb1 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sun, 7 Jun 2026 11:08:40 +0000 Subject: [PATCH 09/22] feat(generics): show type-parameter variance in hover Hovering a type parameter now surfaces its variance: a covariant `+T` and a contravariant `-T` render with their marker and a "(covariant)" / "(contravariant)" label, while an invariant parameter shows the bare name. The variance line composes with the existing bound line. Docs note variance display in hover. Co-Authored-By: Claude Opus 4.8 --- docs/features/index.md | 3 ++ docs/roadmap.md | 3 +- features/understand/hover.feature | 15 +++++++ src/Handler/XphpHoverHandler.php | 23 ++++++++++- test/Handler/XphpHoverHandlerTest.php | 57 +++++++++++++++++++++++++++ 5 files changed, 98 insertions(+), 3 deletions(-) diff --git a/docs/features/index.md b/docs/features/index.md index 684ee1c..da6a9c0 100644 --- a/docs/features/index.md +++ b/docs/features/index.md @@ -189,6 +189,9 @@ property / native function info, hover renders: - A type parameter's full upper bound, including composite forms -- intersection (`A & B`), union (`A | B`), and F-bounded (`Comparable`). +- A type parameter's variance: `+T` (covariant) / `-T` (contravariant) + are shown with their marker and a label; invariant params show the + bare name. ### Signature Help diff --git a/docs/roadmap.md b/docs/roadmap.md index f428d7e..9849fce 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -71,7 +71,8 @@ test suite; full descriptions to fold into [`README.md`](../README.md#features)) of a union). Default type arguments (`class Box`, `class Pair`) may be omitted at a call site without a false "missing type argument", with the effective type substituted into parameter - checks. + checks. Variance markers (`+T` covariant, `-T` contravariant) are shown in + hover. - **Argument-type checker V2** -- a new `xphp.arg-mismatch` diagnostic extends the constructor check to `$obj->m(...)`, `Cls::m(...)`, and `freeFn(...)`, with conservative "simple-locals" inference for `$var` arguments assigned from a diff --git a/features/understand/hover.feature b/features/understand/hover.feature index 9b5b937..e79ab20 100644 --- a/features/understand/hover.feature +++ b/features/understand/hover.feature @@ -45,3 +45,18 @@ Feature: Hover When I request "textDocument/hover" on "T" at line 4 of "/pair.xphp" Then the hover contents contain "bounded by" And the hover contents contain "\App\Animal & \App\Comparable" + + Scenario: Hover over a covariant type parameter shows its variance + Given the file at "/producer.xphp" contains the following lines: + """ + + { + public function get(): T {} + } + """ + And the FQN index has been warmed on initialize + When I request "textDocument/hover" on "T" at line 4 of "/producer.xphp" + Then the hover contents contain "`+T`" + And the hover contents contain "covariant" diff --git a/src/Handler/XphpHoverHandler.php b/src/Handler/XphpHoverHandler.php index 7989cd2..4b65806 100644 --- a/src/Handler/XphpHoverHandler.php +++ b/src/Handler/XphpHoverHandler.php @@ -26,6 +26,7 @@ use XPHP\Transpiler\Monomorphize\Registry; use XPHP\Transpiler\Monomorphize\TypeParam; use XPHP\Transpiler\Monomorphize\TypeRef; +use XPHP\Transpiler\Monomorphize\Variance; use XPHP\Transpiler\Monomorphize\XphpSourceParser; /** @@ -209,10 +210,12 @@ private function buildHoverMarkdown(\PhpParser\Node\Name $name, array $classScop $boundLine = $boundDisplay !== null ? sprintf("\n\nbounded by `%s`", $boundDisplay) : ''; + [$displayName, $varianceNote] = self::varianceLabel($param); return sprintf( - "**Type parameter `%s`** of `%s`%s", - $param->name, + "**Type parameter `%s`** of `%s`%s%s", + $displayName, is_string($owner) ? $owner : (string) $classLike->name, + $varianceNote, $boundLine, ); } @@ -221,6 +224,22 @@ private function buildHoverMarkdown(\PhpParser\Node\Name $name, array $classScop return null; } + /** + * Render a type parameter's display name and a human variance note from its + * `Variance`: covariant `+T` / contravariant `-T` get the marker prefix and + * a parenthetical; invariant stays the bare name with no note. + * + * @return array{0: string, 1: string} [displayName, varianceNote] + */ + private static function varianceLabel(TypeParam $param): array + { + return match ($param->variance) { + Variance::Covariant => ['+' . $param->name, ' (covariant)'], + Variance::Contravariant => ['-' . $param->name, ' (contravariant)'], + Variance::Invariant => [$param->name, ''], + }; + } + /** * @param list $args */ diff --git a/test/Handler/XphpHoverHandlerTest.php b/test/Handler/XphpHoverHandlerTest.php index b8f76eb..80b7560 100644 --- a/test/Handler/XphpHoverHandlerTest.php +++ b/test/Handler/XphpHoverHandlerTest.php @@ -78,6 +78,63 @@ class Pair self::assertStringContainsString('\\App\\Animal & \\App\\Comparable', $text); } + public function testHoverShowsCovariantMarker(): void + { + // Covariant `+T` is only allowed in output (return) positions. + [$handler, $workspace, $uri] = $this->prepare(<<<'XPHP' + + { + public function get(): T { } + } + XPHP); + $hover = $this->hoverAt($handler, $uri, $workspace->get($uri)->text, '): T {', offsetInSearch: strlen('): ')); + + self::assertInstanceOf(Hover::class, $hover); + $text = $hover->contents->value; + self::assertStringContainsString('`+T`', $text); + self::assertStringContainsString('covariant', $text); + } + + public function testHoverShowsContravariantMarker(): void + { + // Contravariant `-T` is only allowed in input (parameter) positions. + [$handler, $workspace, $uri] = $this->prepare(<<<'XPHP' + + { + public function put(T $item): void { } + } + XPHP); + $hover = $this->hoverAt($handler, $uri, $workspace->get($uri)->text, 'put(T $item', offsetInSearch: strlen('put(')); + + self::assertInstanceOf(Hover::class, $hover); + $text = $hover->contents->value; + self::assertStringContainsString('`-T`', $text); + self::assertStringContainsString('contravariant', $text); + } + + public function testHoverInvariantHasNoVarianceNote(): void + { + [$handler, $workspace, $uri] = $this->prepare(<<<'XPHP' + + { + public T $item; + } + XPHP); + $hover = $this->hoverAt($handler, $uri, $workspace->get($uri)->text, 'public T $item', offsetInSearch: strlen('public ')); + + self::assertInstanceOf(Hover::class, $hover); + $text = $hover->contents->value; + self::assertStringContainsString('`T`', $text); + self::assertStringNotContainsString('covariant', $text); + self::assertStringNotContainsString('contravariant', $text); + } + public function testHoverOverTypeParamShowsFBoundedBound(): void { [$handler, $workspace, $uri] = $this->prepare(<<<'XPHP' From 920ba0fd7a285f2544eb6724d76133840d27c9f9 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sun, 7 Jun 2026 11:12:04 +0000 Subject: [PATCH 10/22] test(generics): cover self/static/parent turbofish The `self` / `static` / `parent` pseudo-type turbofish forms (`new self::()`, `self::method::(...)`, `static::()`) are already handled by the call-site `::<` recognition: the clause strips to equal-length whitespace, the semantic-token pass opens the clause on the `::`-preceded `<` while keeping the pseudo-type keyword classification, and go-to-definition on a type argument resolves through the existing path. Add semantic-token coverage for self/static/parent turbofish and a go-to-definition behat scenario over a `new self::()` type argument. Docs note the pseudo-type turbofish navigation. Co-Authored-By: Claude Opus 4.8 --- docs/features/index.md | 5 +++- features/navigate/definition.feature | 25 +++++++++++++++++++ .../Handler/SemanticTokens/AstVisitorTest.php | 25 +++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/docs/features/index.md b/docs/features/index.md index da6a9c0..2c6ec6a 100644 --- a/docs/features/index.md +++ b/docs/features/index.md @@ -31,7 +31,10 @@ through xphp generics: if `$users` is declared as `Collection` and the cursor sits on `$users->first()`, the jump lands on the correct `User` method, not on the template's placeholder `T`. Union and intersection receivers fan out to a per-constituent picker so -each branch is reachable individually. +each branch is reachable individually. The turbofish forms of the +`self` / `static` / `parent` pseudo-types (`new self::()`, +`self::method::(...)`) navigate and highlight like any other call +site. ### Go to Type Definition diff --git a/features/navigate/definition.feature b/features/navigate/definition.feature index ec2eeb5..902acc8 100644 --- a/features/navigate/definition.feature +++ b/features/navigate/definition.feature @@ -71,3 +71,28 @@ Feature: Go to definition When I request "textDocument/definition" on "first" at line 10 of "Use.xphp" Then the response points to "Containers/Collection.xphp" And the target range covers the "first" method declaration + + Scenario: Jump to a type argument of a self turbofish + Given the file at "Models/Plastic.xphp" contains the following lines: + """ + + { + public function copy(): Crate + { + return new self::(); + } + } + """ + And the FQN index has been warmed on initialize + When I request "textDocument/definition" on "Plastic" at line 7 of "SelfUse.xphp" + Then the response points to "Models/Plastic.xphp" + And the target range covers the "Plastic" class name diff --git a/test/Handler/SemanticTokens/AstVisitorTest.php b/test/Handler/SemanticTokens/AstVisitorTest.php index 2524566..109716c 100644 --- a/test/Handler/SemanticTokens/AstVisitorTest.php +++ b/test/Handler/SemanticTokens/AstVisitorTest.php @@ -276,6 +276,31 @@ public function testStaticReceiverTurbofishPaintsTypeArg(): void $this->assertTokenSubstring($specs, $source, 'Plastic', 'typeParameter'); } + public function testSelfTurbofishPaintsTypeArg(): void + { + // `self::()` -- the pseudo-type turbofish opens a clause after + // the `::`; `Plastic` inside is a typeParameter. + $source = "();"; + $specs = $this->collect($source); + $this->assertTokenSubstring($specs, $source, 'Plastic', 'typeParameter'); + } + + public function testParentTurbofishPaintsTypeArg(): void + { + $source = "();"; + $specs = $this->collect($source); + $this->assertTokenSubstring($specs, $source, 'Plastic', 'typeParameter'); + } + + public function testStaticReceiverIsClassifiedAsKeyword(): void + { + // `static` before `::<` stays a keyword. + $source = "();"; + $specs = $this->collect($source); + $this->assertTokenSubstring($specs, $source, 'static', 'keyword'); + $this->assertTokenSubstring($specs, $source, 'Plastic', 'typeParameter'); + } + public function testBareDoubleColonWithoutAngleOpensNothing(): void { // `Foo::BAR` is a constant access -- no `<` follows the `::`, so no From 6892f693b240e8f76ead42a5cf9e2314623efa99 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sun, 7 Jun 2026 11:14:37 +0000 Subject: [PATCH 11/22] test(generics): cover instance and variable turbofish argument checks Verify the argument-type checker handles the turbofish method-call shapes. An instance-method turbofish (`$obj->m::(...)`) carries its type argument on the call node, so the checker binds T and flags a mismatched argument; a variable turbofish (`$f::(...)`) over an unknown callee is conservatively skipped to avoid false positives. Add unit + behat coverage for both shapes. Docs note the behavior. Co-Authored-By: Claude Opus 4.8 --- docs/features/index.md | 5 +- docs/roadmap.md | 4 +- features/validate/arg-types.feature | 32 +++++++++++ test/Analyzer/CallArgumentCheckerTest.php | 66 +++++++++++++++++++++++ 4 files changed, 105 insertions(+), 2 deletions(-) diff --git a/docs/features/index.md b/docs/features/index.md index 2c6ec6a..cfd157f 100644 --- a/docs/features/index.md +++ b/docs/features/index.md @@ -289,7 +289,10 @@ declared type -- a runtime `TypeError` waiting to happen, surfaced at compile time. Inference is intentionally narrow (literals, `new ClassName(...)`, `true` / `false` / `null` const fetches) to avoid false positives on arguments whose type would require flow -analysis to know. +analysis to know. The argument check also applies the type argument +of an instance-method turbofish (`$obj->m::(...)`); a variable +turbofish (`$f::(...)`) over an unknown callee is conservatively +skipped to avoid false positives. --- diff --git a/docs/roadmap.md b/docs/roadmap.md index 9849fce..1a7e3f4 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -72,7 +72,9 @@ test suite; full descriptions to fold into [`README.md`](../README.md#features)) `class Pair`) may be omitted at a call site without a false "missing type argument", with the effective type substituted into parameter checks. Variance markers (`+T` covariant, `-T` contravariant) are shown in - hover. + hover. Instance-method turbofish (`$obj->m::(...)`) binds its type + argument for argument checking; variable turbofish over an unknown callee is + conservatively skipped. - **Argument-type checker V2** -- a new `xphp.arg-mismatch` diagnostic extends the constructor check to `$obj->m(...)`, `Cls::m(...)`, and `freeFn(...)`, with conservative "simple-locals" inference for `$var` arguments assigned from a diff --git a/features/validate/arg-types.feature b/features/validate/arg-types.feature index 2f4648e..e6a3f06 100644 --- a/features/validate/arg-types.feature +++ b/features/validate/arg-types.feature @@ -199,3 +199,35 @@ Feature: Argument-type checking across call shapes And the FQN index has been warmed on initialize When I analyze "/Use.xphp" for diagnostics Then no diagnostics are reported + + Scenario: An instance-method turbofish binds the type argument for checking + Given the file at "/Holder.xphp" contains the following lines: + """ + (T $item): void {} + } + """ + And the file at "/User.xphp" contains the following lines: + """ + add::(new Tag()); + """ + And the FQN index has been warmed on initialize + When I analyze "/Use.xphp" for diagnostics + Then a "xphp.arg-mismatch" diagnostic is reported saying "expects App\User, got App\Tag" diff --git a/test/Analyzer/CallArgumentCheckerTest.php b/test/Analyzer/CallArgumentCheckerTest.php index 3cf06a2..6250618 100644 --- a/test/Analyzer/CallArgumentCheckerTest.php +++ b/test/Analyzer/CallArgumentCheckerTest.php @@ -231,6 +231,72 @@ public function setSecond(B $x): void {} self::assertSame([], self::filterByCode($diagnostics['/Use.xphp'], DiagnosticCode::ArgumentMismatch)); } + public function testInstanceMethodTurbofishAppliesTypeArgToCheck(): void + { + // `$c->add::(...)` -- the method-level turbofish binds T=User on + // the call, so passing a Tag mismatches. + $diagnostics = $this->checkWorkspace([ + '/Holder.xphp' => <<<'PHP' + (T $item): void {} + } + PHP, + '/User.xphp' => " " <<<'PHP' + add::(new Tag()); + PHP, + ]); + + $diags = self::filterByCode($diagnostics['/Use.xphp'], DiagnosticCode::ArgumentMismatch); + self::assertCount(1, $diags, 'method turbofish T=User; passing a Tag mismatches'); + self::assertStringContainsString('App\\User', $diags[0]->message); + self::assertStringContainsString('App\\Tag', $diags[0]->message); + } + + public function testInstanceMethodTurbofishAcceptsMatchingArgument(): void + { + $diagnostics = $this->checkWorkspace([ + '/Holder.xphp' => <<<'PHP' + (T $item): void {} + } + PHP, + '/User.xphp' => " <<<'PHP' + add::(new User()); + PHP, + ]); + + self::assertSame([], self::filterByCode($diagnostics['/Use.xphp'], DiagnosticCode::ArgumentMismatch)); + } + + public function testVariableTurbofishIsConservativelySkipped(): void + { + // `$f::(...)` is a variable call with an unknown callee -- it must + // NOT produce a false positive (the receiver type is unknown). + $diagnostics = $this->checkWorkspace([ + '/Use.xphp' => <<<'PHP' + $x; + $f::('not-an-int'); + PHP, + ]); + + self::assertSame([], self::filterByCode($diagnostics['/Use.xphp'], DiagnosticCode::ArgumentMismatch)); + } + public function testFlagsScalarLiteralPassedToStaticMethod(): void { $diagnostics = $this->checkWorkspace([ From 89c51100a264a12d9d56e324a1bbb0439839162e Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sun, 7 Jun 2026 11:23:12 +0000 Subject: [PATCH 12/22] feat(generics): highlight generic closures and arrow functions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generic closures and arrows (`fn(…)`, `function(…)`) now highlight their type parameter. The semantic-token pass opens a declaration clause on a `<` that follows the `fn` / `function` keyword, and the type-parameter scope stack — previously tracked only for class templates — now also pushes a frame for a closure or arrow carrying type parameters, so body-level `T` references inside the closure re-classify as type parameters (and a `T` outside it does not). Anonymous-closure call-argument checking remains a deliberate skip (anonymous closures aren't in the function index), avoiding false positives. Docs note generic-closure semantic-token support. Co-Authored-By: Claude Opus 4.8 --- docs/features/index.md | 5 +- docs/roadmap.md | 4 +- features/understand/semantic_tokens.feature | 11 ++++ infection.json5 | 7 ++ src/Handler/SemanticTokens/AstVisitor.php | 29 ++++++++- .../Handler/SemanticTokens/AstVisitorTest.php | 65 +++++++++++++++++-- 6 files changed, 113 insertions(+), 8 deletions(-) diff --git a/docs/features/index.md b/docs/features/index.md index cfd157f..ede8626 100644 --- a/docs/features/index.md +++ b/docs/features/index.md @@ -229,7 +229,10 @@ LSP method: `textDocument/semanticTokens/full`. AST-driven syntax highlighting using the standard LSP token-type legend. Type-parameter `T` references render with the `typeParameter` color in generic-syntax positions, distinguishing -them visually from regular class references. +them visually from regular class references. This extends to generic +closures and arrows (`fn(…)`, `function(…)`): the declaration +clause and body-level `T` references inside the closure are coloured +as type parameters. --- diff --git a/docs/roadmap.md b/docs/roadmap.md index 1a7e3f4..7d179d7 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -74,7 +74,9 @@ test suite; full descriptions to fold into [`README.md`](../README.md#features)) checks. Variance markers (`+T` covariant, `-T` contravariant) are shown in hover. Instance-method turbofish (`$obj->m::(...)`) binds its type argument for argument checking; variable turbofish over an unknown callee is - conservatively skipped. + conservatively skipped. Generic closures and arrows (`fn(…)`, + `function(…)`) highlight their declaration clause and body-level `T` + references as type parameters. - **Argument-type checker V2** -- a new `xphp.arg-mismatch` diagnostic extends the constructor check to `$obj->m(...)`, `Cls::m(...)`, and `freeFn(...)`, with conservative "simple-locals" inference for `$var` arguments assigned from a diff --git a/features/understand/semantic_tokens.feature b/features/understand/semantic_tokens.feature index a2d4afc..68db6ca 100644 --- a/features/understand/semantic_tokens.feature +++ b/features/understand/semantic_tokens.feature @@ -16,6 +16,17 @@ Feature: Semantic tokens Then the semantic tokens are non-empty And a "typeParameter" token covers "T" in "/box.xphp" + Scenario: Highlight the type parameter of a generic closure + Given the file at "/closure.xphp" contains the following lines: + """ + () { return new T(); }; + """ + And the FQN index has been warmed on initialize + When I request "textDocument/semanticTokens/full" for "/closure.xphp" + Then a "typeParameter" token covers "T" in "/closure.xphp" + Scenario: Highlight an interpolated variable inside a double-quoted string Given the file at "/Str.xphp" contains the following lines: """ diff --git a/infection.json5 b/infection.json5 index cc37bee..b081b17 100644 --- a/infection.json5 +++ b/infection.json5 @@ -1260,6 +1260,13 @@ // beyond the LogicalOr block above. "LogicalOrAllSubExprNegation": { "ignore": [ + // AstVisitor::collectFromTokens generic-closure opener + // `($last === T_FN || $last === T_FUNCTION)` -- negating both + // sub-exprs yields an always-true guard (a token can't equal + // both), so the clause merely over-opens; the token corpus + // doesn't produce a distinguishing misclassification, and the + // fn / function decl-clause T is asserted directly. + "XPHP\\Lsp\\Handler\\SemanticTokens\\AstVisitor::collectFromTokens", // Turbofish/bound view equivalent mutants: defensive // bounds guards (offset/index range checks where >/>= // converge because the surrounding byte re-check rejects diff --git a/src/Handler/SemanticTokens/AstVisitor.php b/src/Handler/SemanticTokens/AstVisitor.php index c4c4970..3a43ec4 100644 --- a/src/Handler/SemanticTokens/AstVisitor.php +++ b/src/Handler/SemanticTokens/AstVisitor.php @@ -172,6 +172,13 @@ private function collectFromTokens(array &$out, array $reclassifyVariableAt = [] // Declaration clause: `class Box`, `function f` -- // the bare `<` follows the declared name (T_STRING). $genericDepth = 1; + } elseif (($lastSignificantTokenId === T_FN || $lastSignificantTokenId === T_FUNCTION) + && self::peekIsUppercaseIdent($tokens, $i + 1) + ) { + // Anonymous generic closure / arrow declaration clause: + // `fn(…)`, `function(…)` -- the `<` follows the + // `fn` / `function` keyword (no name between). + $genericDepth = 1; } elseif ($lastSignificantTokenId === T_DOUBLE_COLON && self::peekIsUppercaseIdent($tokens, $i + 1) ) { @@ -377,6 +384,23 @@ public function enterNode(Node $node) } return null; } + if ($node instanceof Node\Expr\Closure || $node instanceof Node\Expr\ArrowFunction) { + // Generic closures / arrows (`fn(…)`, `function(…)`) + // carry their type params under ATTR_METHOD_GENERIC_PARAMS + // (no enclosing ClassLike). Push a frame so body-level `T` + // references re-classify, popped symmetrically in leaveNode. + $params = $node->getAttribute(\XPHP\Transpiler\Monomorphize\XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); + $frame = []; + if (is_array($params)) { + foreach ($params as $param) { + if ($param instanceof \XPHP\Transpiler\Monomorphize\TypeParam) { + $frame[$param->name] = true; + } + } + } + $this->typeParamStack[] = $frame; + return null; + } if ($node instanceof ClassMethod) { $this->emitter->emitAstIdentifier($this->out, $node->name, 'method'); return null; @@ -440,7 +464,10 @@ public function enterNode(Node $node) public function leaveNode(Node $node) { - if ($node instanceof ClassLike && $this->typeParamStack !== []) { + $pushesFrame = $node instanceof ClassLike + || $node instanceof Node\Expr\Closure + || $node instanceof Node\Expr\ArrowFunction; + if ($pushesFrame && $this->typeParamStack !== []) { array_pop($this->typeParamStack); } return null; diff --git a/test/Handler/SemanticTokens/AstVisitorTest.php b/test/Handler/SemanticTokens/AstVisitorTest.php index 109716c..d6b2ed5 100644 --- a/test/Handler/SemanticTokens/AstVisitorTest.php +++ b/test/Handler/SemanticTokens/AstVisitorTest.php @@ -301,6 +301,59 @@ public function testStaticReceiverIsClassifiedAsKeyword(): void $this->assertTokenSubstring($specs, $source, 'Plastic', 'typeParameter'); } + public function testArrowClosureDeclarationClausePaintsTypeParam(): void + { + // `fn(…)` -- the declaration clause `` opens after the `fn` + // keyword; `T` is a typeParameter. + $source = "(\$x) => \$x;"; + $specs = $this->collect($source); + $this->assertTokenSubstring($specs, $source, 'T', 'typeParameter'); + } + + public function testClosureDeclarationClausePaintsTypeParam(): void + { + // `function(…)` -- the declaration clause opens after `function`. + $source = "(\$y) { return \$y; };"; + $specs = $this->collect($source); + $this->assertTokenSubstring($specs, $source, 'U', 'typeParameter'); + } + + public function testGenericClosureBodyReifiedTPaintsAsTypeParameter(): void + { + // Body-level `T` inside a generic closure re-classifies via the + // closure's ATTR_METHOD_GENERIC_PARAMS frame -- assert the `new T()` + // body reference SPECIFICALLY (not just the decl-clause `T`, which the + // token pass classifies independently). + $source = "() { return new T(); };"; + $specs = $this->collect($source); + $bodyTOffset = strpos($source, 'new T()') + strlen('new '); + $bodyTSpecs = array_filter( + $specs, + fn (TokenSpec $s) => self::substring($source, $s) === 'T' + && $s->type === 'typeParameter' + && self::specByteOffset($source, $s) === $bodyTOffset, + ); + self::assertCount(1, $bodyTSpecs, 'the body-level new T() must classify as typeParameter'); + } + + public function testTReferenceOutsideGenericClosureNotMisclassified(): void + { + // After the generic closure's frame is popped, a bare `new T()` in a + // non-generic context must NOT classify as a type parameter. + $source = "() { return new T(); };\nnew T();"; + $specs = $this->collect($source); + // The trailing `new T()` (line 2, after the closure) sits at a byte + // offset past the closure; assert no typeParameter spec starts there. + $closureEnd = strpos($source, '};'); + $strayT = array_filter( + $specs, + fn (TokenSpec $s) => $s->type === 'typeParameter' + && self::substring($source, $s) === 'T' + && self::specByteOffset($source, $s) > $closureEnd, + ); + self::assertEmpty($strayT, 'T outside the closure frame must not be a type parameter'); + } + public function testBareDoubleColonWithoutAngleOpensNothing(): void { // `Foo::BAR` is a constant access -- no `<` follows the `::`, so no @@ -563,15 +616,17 @@ private function assertTokenSubstring(array $specs, string $source, string $need private static function substring(string $source, TokenSpec $spec): string { - // Convert (line, char) back to byte offset for substring lookup. - // PositionMap can do this; we re-derive offsets via line scan to - // keep this helper self-contained. + return substr($source, self::specByteOffset($source, $spec), $spec->length); + } + + private static function specByteOffset(string $source, TokenSpec $spec): int + { + // Convert (line, char) back to byte offset. $lines = explode("\n", $source); $byteOffset = 0; for ($i = 0; $i < $spec->line && $i < count($lines); $i++) { $byteOffset += strlen($lines[$i]) + 1; // +1 for the \n } - $byteOffset += $spec->startChar; - return substr($source, $byteOffset, $spec->length); + return $byteOffset + $spec->startChar; } } From c7424efda39ef13f74c80ef36852fdf67832edfb Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sun, 7 Jun 2026 12:31:38 +0000 Subject: [PATCH 13/22] fix(generics): correct turbofish highlighting, arity, and bound fix-its Four correctness fixes for the 0.2.0 turbofish/generics support: - Semantic tokens: open a call-site turbofish clause on `::<` unconditionally so a lowercase scalar first arg (`Box::`, `Map::`) no longer suppresses highlighting of the whole clause. The `::` already disambiguates from the `<` comparison operator, so no uppercase look-ahead is needed. - Diagnostics: validate an empty turbofish `new Box::<>()`. An empty type-arg list was treated as "not a generic instantiation" and skipped; it now reaches the registry, which reports the arity error when a parameter has no default. The instantiation guard is extracted into a named genericInstantiation() helper. - Argument checking: stop reporting a bogus "expects T" mismatch when a call supplies more type arguments than the template declares; every parameter binds to an unresolved sentinel so generic-typed method params are skipped rather than resolved to a fictional class. Concrete-typed params are still checked. - Bound quick-fixes: only offer "add implements " for the leaves of a composite bound the concrete type does not already satisfy via a parent class or a transitively-implemented interface. Adds unit and behat coverage pinning each fix. Co-Authored-By: Claude Opus 4.8 --- features/understand/semantic_tokens.feature | 12 ++ features/validate/diagnostics.feature | 17 +++ src/Analyzer/CallArgumentChecker.php | 24 +++- src/Analyzer/WorkspaceAnalyzer.php | 49 +++++-- src/Handler/SemanticTokens/AstVisitor.php | 19 ++- test/Analyzer/CallArgumentCheckerTest.php | 31 +++++ test/Analyzer/WorkspaceAnalyzerTest.php | 121 ++++++++++++++++++ .../Handler/SemanticTokens/AstVisitorTest.php | 48 +++++-- 8 files changed, 287 insertions(+), 34 deletions(-) diff --git a/features/understand/semantic_tokens.feature b/features/understand/semantic_tokens.feature index 68db6ca..c40fd88 100644 --- a/features/understand/semantic_tokens.feature +++ b/features/understand/semantic_tokens.feature @@ -27,6 +27,18 @@ Feature: Semantic tokens When I request "textDocument/semanticTokens/full" for "/closure.xphp" Then a "typeParameter" token covers "T" in "/closure.xphp" + Scenario: Highlight every type argument of a turbofish call, lowercase scalar included + Given the file at "/turbofish.xphp" contains the following lines: + """ + (); + """ + And the FQN index has been warmed on initialize + When I request "textDocument/semanticTokens/full" for "/turbofish.xphp" + Then a "typeParameter" token covers "int" in "/turbofish.xphp" + And a "typeParameter" token covers "User" in "/turbofish.xphp" + Scenario: Highlight an interpolated variable inside a double-quoted string Given the file at "/Str.xphp" contains the following lines: """ diff --git a/features/validate/diagnostics.feature b/features/validate/diagnostics.feature index f29c08e..1e86281 100644 --- a/features/validate/diagnostics.feature +++ b/features/validate/diagnostics.feature @@ -75,6 +75,23 @@ Feature: Diagnostics Then a "xphp.bound" diagnostic is reported saying "Generic bound violated" And the "xphp.bound" diagnostic underlines "Box" + Scenario: Report an empty turbofish on a template with no default + Given the file at "/Box.xphp" contains the following lines: + """ + {} + """ + And the file at "/Use.xphp" contains the following lines: + """ + (); + """ + And the FQN index has been warmed on initialize + When I analyze "/Use.xphp" for diagnostics + Then a "xphp.bound" diagnostic is reported saying "no default" + Scenario: Report a constructor argument-type mismatch Given the file at "/StringableBox.xphp" contains the following lines: """ diff --git a/src/Analyzer/CallArgumentChecker.php b/src/Analyzer/CallArgumentChecker.php index d8a545b..fd9854f 100644 --- a/src/Analyzer/CallArgumentChecker.php +++ b/src/Analyzer/CallArgumentChecker.php @@ -463,17 +463,27 @@ private function pairSubstitution(?array $params, ?array $args): array return []; } // 0.2.x lets a call site OMIT trailing args that have a declared - // default. Supplying more args than params is still wrong (don't pair); - // supplying the same number or fewer is fine -- pad the missing trailing - // slots from each param's default, resolving left-to-right so a default - // that references an earlier param (`Pair`) picks up the - // already-substituted arg. + // default. Supplying the same number or fewer is fine -- pad the missing + // trailing slots from each param's default, resolving left-to-right so a + // default that references an earlier param (`Pair`) picks up + // the already-substituted arg. + $names = self::extractTypeParamNames($params); + + // Supplying MORE args than params is an invalid instantiation. Don't + // pair positionally (that would bind method params to mismatched args); + // instead bind every param to an unresolved-type-param sentinel so a + // method param typed by one is skipped rather than resolved to a bogus + // `App\` class (which surfaced as a false "argument N expects + // App\T" mismatch). The arity itself is the vendor's concern. if (count($args) > count($params)) { - return []; + $substitution = []; + foreach ($names as $paramName) { + $substitution[$paramName] = new TypeRef($paramName, [], false, true); + } + return $substitution; } $args = $this->padArgsWithDefaults($params, $args); - $names = self::extractTypeParamNames($params); $substitution = []; foreach ($names as $i => $paramName) { $arg = $args[$i] ?? null; diff --git a/src/Analyzer/WorkspaceAnalyzer.php b/src/Analyzer/WorkspaceAnalyzer.php index 20b9321..d3fbcfb 100644 --- a/src/Analyzer/WorkspaceAnalyzer.php +++ b/src/Analyzer/WorkspaceAnalyzer.php @@ -299,16 +299,11 @@ public function enterNode(Node $node): null if (!$node instanceof Name) { return null; } - $args = $node->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS); - $fqn = $node->getAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN); - if (!is_array($args) || $args === [] || !is_string($fqn)) { + $instantiation = WorkspaceAnalyzer::genericInstantiation($node); + if ($instantiation === null) { return null; } - foreach ($args as $a) { - if (!$a->isConcrete()) { - return null; - } - } + [$args, $fqn] = $instantiation; try { $this->registry->recordInstantiation($fqn, $args); } catch (RuntimeException $e) { @@ -362,6 +357,36 @@ public function enterNode(Node $node): null $traverser->traverse($ast); } + /** + * Extract the `[concrete type args, template FQN]` pair off a generic + * instantiation `Name`, or null when the node is not one we should record. + * + * A non-turbofish Name has the attributes absent (null); an empty turbofish + * `new Box::<>()` has `ATTR_GENERIC_ARGS` present-but-empty (`[]`) -- a real + * instantiation that must still be validated (the vendor Registry raises the + * arity error when a non-defaulted parameter has no arg), so we only bail + * when the attribute is absent. `ATTR_GENERIC_ARGS` and `ATTR_TEMPLATE_FQN` + * are stamped together by XphpSourceParser, so the guard's two clauses are + * jointly necessary. Args that aren't fully concrete (an unresolved + * type-param) aren't a real specialization and are skipped. + * + * @return array{0: list, 1: string}|null TypeRef[] and the template FQN + */ + public static function genericInstantiation(Name $node): ?array + { + $args = $node->getAttribute(XphpSourceParser::ATTR_GENERIC_ARGS); + $fqn = $node->getAttribute(XphpSourceParser::ATTR_TEMPLATE_FQN); + if (!is_array($args) || !is_string($fqn)) { + return null; + } + foreach ($args as $a) { + if (!$a->isConcrete()) { + return null; + } + } + return [$args, $fqn]; + } + /** * Compute the structured fix-it payload for a generic bound violation: * which type parameter / bound was violated, the offending concrete type, @@ -447,6 +472,14 @@ public function buildBoundFixData( if (!$concreteIsScalar && !$bound instanceof BoundUnion) { $entry = $openClasses[$concrete] ?? null; foreach ($boundLeaves as $leaf) { + // Only leaves the concrete does NOT already satisfy are + // "missing". A leaf met via a parent class (`extends`) or a + // transitively-implemented interface needs no `implements` edit; + // the hierarchy oracle catches those, whereas implementsInsert + // only scans the class's own direct `implements` list. + if ($isSubtype($concrete, $leaf)) { + continue; + } $insert = self::implementsInsert($entry, $leaf); if ($insert !== null) { $implementsInserts[] = ['leaf' => $leaf] + $insert; diff --git a/src/Handler/SemanticTokens/AstVisitor.php b/src/Handler/SemanticTokens/AstVisitor.php index 3a43ec4..9c94af2 100644 --- a/src/Handler/SemanticTokens/AstVisitor.php +++ b/src/Handler/SemanticTokens/AstVisitor.php @@ -179,14 +179,19 @@ private function collectFromTokens(array &$out, array $reclassifyVariableAt = [] // `fn(…)`, `function(…)` -- the `<` follows the // `fn` / `function` keyword (no name between). $genericDepth = 1; - } elseif ($lastSignificantTokenId === T_DOUBLE_COLON - && self::peekIsUppercaseIdent($tokens, $i + 1) - ) { + } elseif ($lastSignificantTokenId === T_DOUBLE_COLON) { // Call-site turbofish: `Foo::`, `static::`, - // `$obj->m::` -- the `<` follows the `::` of `::<`. The - // receiver token before `::` may be T_STRING (`Foo`, - // `self`, `parent`) or T_STATIC (`static`); either way the - // significant token immediately before `<` is the `::`. + // `$obj->m::` -- the `<` follows the `::` of `::<`. A `::` + // immediately before a `<` only ever occurs in a turbofish + // (a normal `::` member access is followed by a name, `$`, + // `{`, or `class`, never `<`), so this is unambiguous and + // needs no uppercase look-ahead. That matters: the first + // type-arg may be a lowercase scalar (`Box::`, + // `Map::`), which an uppercase-only heuristic + // would wrongly reject -- closing the whole clause and + // dropping every arg's token. Opening on the empty + // `Foo::<>` is harmless: the next `>` closes it immediately + // with nothing classified inside. $genericDepth = 1; } } elseif (!$isNamedToken && $token->text === '>' && $genericDepth > 0) { diff --git a/test/Analyzer/CallArgumentCheckerTest.php b/test/Analyzer/CallArgumentCheckerTest.php index 6250618..c8921e0 100644 --- a/test/Analyzer/CallArgumentCheckerTest.php +++ b/test/Analyzer/CallArgumentCheckerTest.php @@ -231,6 +231,37 @@ public function setSecond(B $x): void {} self::assertSame([], self::filterByCode($diagnostics['/Use.xphp'], DiagnosticCode::ArgumentMismatch)); } + public function testTooManyTypeArgsDoesNotFalselyFlagMethodArgument(): void + { + // `Pair` instantiated with THREE type args is an invalid + // instantiation. No method param typed by A or B may resolve to a bogus + // `App\A` / `App\B` class (which produced a false "argument expects + // App\B" mismatch) -- the over-supplied call binds EVERY param to an + // unresolved sentinel, so both method args are skipped. Exercising the + // second param `B` (not just `A`) guards the whole substitution. + $diagnostics = $this->checkWorkspace([ + '/Pair.xphp' => <<<'PHP' + { + public function setFirst(A $x): void {} + public function setSecond(B $x): void {} + } + PHP, + '/User.xphp' => " " <<<'PHP' + (); + $p->setFirst(new User()); + $p->setSecond(new Tag()); + PHP, + ]); + + self::assertSame([], self::filterByCode($diagnostics['/Use.xphp'], DiagnosticCode::ArgumentMismatch)); + } + public function testInstanceMethodTurbofishAppliesTypeArgToCheck(): void { // `$c->add::(...)` -- the method-level turbofish binds T=User on diff --git a/test/Analyzer/WorkspaceAnalyzerTest.php b/test/Analyzer/WorkspaceAnalyzerTest.php index 6aa05af..c3fe8ef 100644 --- a/test/Analyzer/WorkspaceAnalyzerTest.php +++ b/test/Analyzer/WorkspaceAnalyzerTest.php @@ -113,6 +113,93 @@ class None {} self::assertSame([], $data['implementsInserts']); } + public function testCompositeBoundImplementInsertsSkipLeavesSatisfiedViaHierarchy(): void + { + // `Box`. `Pig extends Beast` where `Beast` + // implements Animal -> Pig satisfies Animal via its parent but not + // Comparable. Only the genuinely-missing leaf (Comparable) should get + // an "implement" fix; the parent-satisfied Animal must not (the literal + // direct-`implements` scan would have wrongly offered it). + $files = $this->parseFiles([ + '/Box.xphp' => <<<'PHP' + {} + PHP, + '/Beast.xphp' => <<<'PHP' + <<<'PHP' + <<<'PHP' + (); + PHP, + ]); + + $diagnostics = (new WorkspaceAnalyzer())->analyze($files); + + self::assertCount(1, $diagnostics['/Use.xphp']); + $data = $diagnostics['/Use.xphp'][0]->data; + self::assertIsArray($data); + self::assertCount(1, $data['implementsInserts'], 'only the unsatisfied leaf gets an implement fix'); + self::assertSame('App\\Comparable', $data['implementsInserts'][0]['leaf']); + } + + public function testEmptyTurbofishOnNonDefaultedTemplateReportsArityDiagnostic(): void + { + // `new Box::<>()` on `class Box` (no default) is an invalid + // instantiation -- the vendor registry rejects the zero-arg call. The + // empty turbofish must NOT be silently skipped as "not a generic call". + $files = $this->parseFiles([ + '/Box.xphp' => <<<'PHP' + {} + PHP, + '/Use.xphp' => <<<'PHP' + (); + PHP, + ]); + + $diagnostics = (new WorkspaceAnalyzer())->analyze($files); + + self::assertCount(1, $diagnostics['/Use.xphp']); + self::assertStringContainsString('no default', $diagnostics['/Use.xphp'][0]->message); + } + + public function testEmptyTurbofishOnFullyDefaultedTemplateProducesNoDiagnostic(): void + { + // `new Box::<>()` on `class Box` is valid -- T defaults to User. + $files = $this->parseFiles([ + '/Box.xphp' => <<<'PHP' + {} + PHP, + '/User.xphp' => " <<<'PHP' + (); + PHP, + ]); + + $diagnostics = (new WorkspaceAnalyzer())->analyze($files); + + self::assertSame([], $diagnostics['/Use.xphp']); + } + public function testBoundViolationOnUnknownClassReportsDistinctMessage(): void { $files = $this->parseFiles([ @@ -208,6 +295,40 @@ class Wrapper self::assertSame([], $diagnostics['/Wrapper.xphp']); } + public function testNonConcreteArgOnBoundedTemplateInGenericBodyIsNotFlagged(): void + { + // The concrete-arg precheck in genericInstantiation must skip an + // instantiation whose arg is still a type-param. With a BOUNDED template + // (`Box`) referenced as `Box` inside a generic body, + // skipping the precheck would let the bound be checked against the + // non-concrete `U` and emit a FALSE "bound violated" diagnostic -- so + // this pins the precheck's real purpose (not just an optimization). + $files = $this->parseFiles([ + '/Animal.xphp' => <<<'PHP' + <<<'PHP' + { public T $item; } + PHP, + '/Wrapper.xphp' => <<<'PHP' + + { + public Box $boxed; + } + PHP, + ]); + + $diagnostics = (new WorkspaceAnalyzer())->analyze($files); + self::assertSame([], $diagnostics['/Box.xphp']); + self::assertSame([], $diagnostics['/Wrapper.xphp']); + } + public function testValidWorkspaceProducesNoDiagnostics(): void { $files = $this->parseFiles([ diff --git a/test/Handler/SemanticTokens/AstVisitorTest.php b/test/Handler/SemanticTokens/AstVisitorTest.php index d6b2ed5..3a3b452 100644 --- a/test/Handler/SemanticTokens/AstVisitorTest.php +++ b/test/Handler/SemanticTokens/AstVisitorTest.php @@ -261,10 +261,11 @@ public function testNestedTypeArgClause(): void public function testStaticCallTurbofishPaintsTypeArg(): void { // Util::identity::(42) -- the call-site turbofish opens on the - // `<` after `::`; `int` inside is typeParameter. - $source = "(\$x);"; + // `<` after `::`; the lowercase scalar `int` inside is typeParameter + // (the `::` makes the clause unambiguous against `<` comparison). + $source = "(\$x);"; $specs = $this->collect($source); - $this->assertTokenSubstring($specs, $source, 'Plastic', 'typeParameter'); + $this->assertTokenSubstring($specs, $source, 'int', 'typeParameter'); } public function testStaticReceiverTurbofishPaintsTypeArg(): void @@ -364,15 +365,26 @@ public function testBareDoubleColonWithoutAngleOpensNothing(): void self::assertEmpty($typeParamSpecs); } - public function testTurbofishWithLowercaseFirstArgDoesNotOpenClause(): void + public function testTurbofishWithLowercaseScalarFirstArgOpensClause(): void { - // `Foo::()` -- the `::` anchor makes the clause unambiguous, so a + // lowercase scalar first arg MUST open it and be painted. (The + // uppercase-ident guard belongs to the bare-`<` declaration branch, not + // the turbofish branch, where it would drop the whole clause.) + $source = "();"; $specs = $this->collect($source); - $typeParamSpecs = array_filter($specs, fn (TokenSpec $s) => $s->type === 'typeParameter'); - self::assertEmpty($typeParamSpecs); + $this->assertTokenSubstring($specs, $source, 'int', 'typeParameter'); + } + + public function testTurbofishMultipleArgsLowercaseFirstHighlightsAll(): void + { + // `Map::()` -- a lowercase first arg must not suppress the + // whole clause: both the scalar `int` and the class `User` in the later + // slot are type arguments and must be painted. + $source = "();"; + $specs = $this->collect($source); + $this->assertTokenSubstring($specs, $source, 'int', 'typeParameter'); + $this->assertTokenSubstring($specs, $source, 'User', 'typeParameter'); } public function testTurbofishClauseClosesSoTrailingNameIsNotTypeParam(): void @@ -419,14 +431,26 @@ public function testNumberComparisonIsNotMisclassified(): void public function testLowercaseFunctionCallComparisonIsNotMisclassified(): void { - // The lookahead-uppercase heuristic rejects `count(` (lowercase - // first char) so `< count(` doesn't open a clause. + // `$size < count(...)` -- the `<` follows a T_VARIABLE (and there is no + // `::` before it), so none of the clause-opener branches fire. The + // comparison is never mistaken for a turbofish. $source = "collect($source); $typeParamSpecs = array_filter($specs, fn (TokenSpec $s) => $s->type === 'typeParameter'); self::assertEmpty($typeParamSpecs); } + public function testLessThanBeforeClassNameIsNotMistakenForTurbofish(): void + { + // `$x < Foo` -- the `<` follows a T_VARIABLE with no `::` before it, so + // it is a comparison, not a turbofish. Only a `::`-anchored `<` opens + // the call-site clause; the bareword `Foo` must NOT be a typeParameter. + $source = "collect($source); + $typeParamSpecs = array_filter($specs, fn (TokenSpec $s) => $s->type === 'typeParameter'); + self::assertEmpty($typeParamSpecs); + } + public function testReifiedNewTPaintsAsTypeParameter(): void { // Form 10: `new T(...)` inside a class body whose template has T. From 20ec6737e1d78f12dba331772b0071083794dfd4 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sun, 7 Jun 2026 13:29:40 +0000 Subject: [PATCH 14/22] fix(semantic-tokens): don't treat a bareword comparison as a generic declaration The declaration-clause opener painted any name before `<` followed by an uppercase identifier as a generic type parameter, so a comparison whose left operand ends in a bareword -- `Foo::CONST < Bar`, `MY_CONST < Other` -- wrongly highlighted the compared constant. A real generic declaration's name is always preceded by a `class` / `interface` / `trait` / `function` keyword; require that keyword before opening the clause. Anonymous closures (`fn` / `function`) and call-site turbofish (`::<`) are handled by separate branches and are unaffected. Co-Authored-By: Claude Opus 4.8 --- infection.json5 | 18 ++++++ src/Handler/SemanticTokens/AstVisitor.php | 55 ++++++++++++++++++- .../Handler/SemanticTokens/AstVisitorTest.php | 29 ++++++++++ 3 files changed, 100 insertions(+), 2 deletions(-) diff --git a/infection.json5 b/infection.json5 index b081b17..3b2c93d 100644 --- a/infection.json5 +++ b/infection.json5 @@ -255,6 +255,13 @@ }, "IncrementInteger": { "ignore": [ + // AstVisitor::nameBeforeAngleIsDeclaration steps back from the + // name to its keyword with `$nameIdx - 1`. PHP always has a + // whitespace token between a `class`/`function`/etc keyword and + // the declared name, and previousSignificant() skips trivia, so + // `- 1` vs `- 2` both land on the keyword -- the off-by-one is + // absorbed and unobservable. + "XPHP\\Lsp\\Handler\\SemanticTokens\\AstVisitor::nameBeforeAngleIsDeclaration", // FqnIndex::boundExprsForGenericClass filesystem-fallback // cache key `"file://" . $decl["path"]` -- the key only needs // to be distinct from open-doc URIs; its exact text does not @@ -507,6 +514,12 @@ }, "GreaterThanOrEqualTo": { "ignore": [ + // AstVisitor::previousSignificant boundary guards: `$i >= 0` + // (the only index it would additionally examine is 0, always the + // T_OPEN_TAG, never a significant declaration token) and + // `$t->id >= 256` (no PHP token has id exactly 256, so `>` vs + // `>=` never diverge). Both are unobservable boundary mutants. + "XPHP\\Lsp\\Handler\\SemanticTokens\\AstVisitor::previousSignificant", // Turbofish/bound view equivalent mutants: defensive // bounds guards (offset/index range checks where >/>= // converge because the surrounding byte re-check rejects @@ -1082,6 +1095,11 @@ // ASTs that don't appear in practice. "LessThan": { "ignore": [ + // AstVisitor::nameBeforeAngleIsDeclaration `$keywordIdx < 0` + // guard: the only index where `<` vs `<=` diverges is 0, always + // the T_OPEN_TAG, which is never a declaration keyword -- so the + // in_array below returns false either way. Unobservable. + "XPHP\\Lsp\\Handler\\SemanticTokens\\AstVisitor::nameBeforeAngleIsDeclaration", // CallArgumentChecker default-type-arg padding: the // `!is_array||!is_array||=== []` entry guard, the pad-loop // `$i < count($params)` bound + the no-default `break` (a diff --git a/src/Handler/SemanticTokens/AstVisitor.php b/src/Handler/SemanticTokens/AstVisitor.php index 9c94af2..d0bee7b 100644 --- a/src/Handler/SemanticTokens/AstVisitor.php +++ b/src/Handler/SemanticTokens/AstVisitor.php @@ -168,9 +168,15 @@ private function collectFromTokens(array &$out, array $reclassifyVariableAt = [] $genericDepth++; } elseif ($lastSignificantTokenId === T_STRING && self::peekIsUppercaseIdent($tokens, $i + 1) + && self::nameBeforeAngleIsDeclaration($tokens, $i) ) { - // Declaration clause: `class Box`, `function f` -- - // the bare `<` follows the declared name (T_STRING). + // Declaration clause: `class Box`, `function f` -- the + // bare `<` follows the declared name (T_STRING), which is + // itself preceded by a `class` / `interface` / `trait` / + // `function` keyword. Requiring that keyword keeps a + // bareword comparison whose left side ends in a name -- + // `Foo::CONST < Bar`, `MY_CONST < Other` -- from being + // mistaken for a generic declaration. $genericDepth = 1; } elseif (($lastSignificantTokenId === T_FN || $lastSignificantTokenId === T_FUNCTION) && self::peekIsUppercaseIdent($tokens, $i + 1) @@ -318,6 +324,51 @@ private static function peekIsUppercaseIdent(array $tokens, int $startIdx): bool return false; } + /** + * Given the index of a `<`, decide whether the name immediately before it is + * a generic *declaration* name -- i.e. preceded by a `class` / `interface` / + * `trait` / `function` keyword (`class Box`, `function f`). + * This distinguishes a real declaration clause from a comparison whose left + * operand ends in a bareword (`Foo::CONST < Bar`, `MY_CONST < Other`), which + * must not open a clause. + * + * @param array $tokens + */ + private static function nameBeforeAngleIsDeclaration(array $tokens, int $angleIdx): bool + { + // The caller has already established that the significant token before + // `<` is the declared name (a T_STRING). Step back past it to the token + // before the name; a real declaration has its keyword there. + $nameIdx = self::previousSignificant($tokens, $angleIdx - 1); + $keywordIdx = self::previousSignificant($tokens, $nameIdx - 1); + if ($keywordIdx < 0) { + return false; + } + return in_array( + $tokens[$keywordIdx]->id, + [T_CLASS, T_INTERFACE, T_TRAIT, T_FUNCTION], + true, + ); + } + + /** + * Index of the nearest significant (non-whitespace/comment) token at or + * before $from, or -1 if none. + * + * @param array $tokens + */ + private static function previousSignificant(array $tokens, int $from): int + { + for ($i = $from; $i >= 0; $i--) { + $t = $tokens[$i]; + if ($t->id >= 256 && in_array($t->id, [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT], true)) { + continue; + } + return $i; + } + return -1; + } + /** * Pass 2: walk the AST and emit specs for identifier kinds that * the token scan can't classify on its own. diff --git a/test/Handler/SemanticTokens/AstVisitorTest.php b/test/Handler/SemanticTokens/AstVisitorTest.php index 3a3b452..1ac88f0 100644 --- a/test/Handler/SemanticTokens/AstVisitorTest.php +++ b/test/Handler/SemanticTokens/AstVisitorTest.php @@ -451,6 +451,35 @@ public function testLessThanBeforeClassNameIsNotMistakenForTurbofish(): void self::assertEmpty($typeParamSpecs); } + public function testBarewordComparisonBeforeClassNameIsNotMistakenForGenericDeclaration(): void + { + // `Foo::CONST < Bar` and `MY_CONST < Other` end in a bareword (T_STRING) + // before `<`, but the name is not a class/interface/trait/function + // declaration name -- so no clause opens and the compared constant is + // not painted as a type parameter. + foreach (["collect($source); + $typeParamSpecs = array_filter($specs, fn (TokenSpec $s) => $s->type === 'typeParameter'); + self::assertEmpty($typeParamSpecs, $source); + } + } + + public function testNamedGenericDeclarationsOpenClauseAfterTheirKeyword(): void + { + // The declaration-clause opener fires for a name preceded by an + // interface / trait / function keyword (the class case is covered + // elsewhere) -- each `T` is a typeParameter. + $sources = [ + " {}", + " {}", + "(\$x) { return \$x; }", + ]; + foreach ($sources as $source) { + $specs = $this->collect($source); + $this->assertTokenSubstring($specs, $source, 'T', 'typeParameter'); + } + } + public function testReifiedNewTPaintsAsTypeParameter(): void { // Form 10: `new T(...)` inside a class body whose template has T. From cd3815be9c1840d28650a47ec50083d02f84a4b8 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sun, 7 Jun 2026 16:13:01 +0000 Subject: [PATCH 15/22] test(generics): cover over-supplied turbofish and parent-satisfied bound leaf Add end-to-end behat coverage for two cases previously exercised only by unit tests: - An over-supplied turbofish (`new Box::()`) must not raise a false argument-type mismatch on a method typed by the type parameter. - A composite intersection bound where the concrete satisfies one leaf via a parent class must offer an "implement" fix only for the genuinely-missing leaf, not the one already satisfied through the hierarchy. Both scenarios fail against the pre-fix behavior and pass now. Co-Authored-By: Claude Opus 4.8 --- features/edit/bound_fixes.feature | 31 +++++++++++++++++++++++++++ features/validate/arg-types.feature | 33 +++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/features/edit/bound_fixes.feature b/features/edit/bound_fixes.feature index 41c8d99..b6dac2d 100644 --- a/features/edit/bound_fixes.feature +++ b/features/edit/bound_fixes.feature @@ -98,3 +98,34 @@ Feature: Quick-fixes for generic bound violations When I request code actions for the "xphp.bound" diagnostic in "/Use.xphp" Then a code action titled "Change type argument to Tabby" is offered And no code action titled "Add implements \App\Cat to None" is offered + + Scenario: An intersection-bound leaf satisfied via a parent class needs no implement fix + Given the file at "/Pair.xphp" contains the following lines: + """ + { public T $item; } + """ + And the file at "/Beast.xphp" contains the following lines: + """ + (); + """ + When I request code actions for the "xphp.bound" diagnostic in "/Use.xphp" + Then a code action titled "Add implements \App\Comparable to Pig" is offered + And no code action titled "Add implements \App\Animal to Pig" is offered diff --git a/features/validate/arg-types.feature b/features/validate/arg-types.feature index e6a3f06..e86f3c2 100644 --- a/features/validate/arg-types.feature +++ b/features/validate/arg-types.feature @@ -231,3 +231,36 @@ Feature: Argument-type checking across call shapes And the FQN index has been warmed on initialize When I analyze "/Use.xphp" for diagnostics Then a "xphp.arg-mismatch" diagnostic is reported saying "expects App\User, got App\Tag" + + Scenario: Supplying too many type arguments is not a false argument mismatch + Given the file at "/Box.xphp" contains the following lines: + """ + + { + public function add(T $item): void {} + } + """ + And the file at "/User.xphp" contains the following lines: + """ + (); + $b->add(new User()); + """ + And the FQN index has been warmed on initialize + When I analyze "/Use.xphp" for diagnostics + Then no diagnostics are reported From 90dfdec36c2cf0d84f4e2e45349578e2e2c8257c Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sun, 7 Jun 2026 16:59:00 +0000 Subject: [PATCH 16/22] docs: correct diagnostics inventory, lint path, and turbofish examples - Validate section listed "five diagnostic codes" but six are emitted; enumerate them and give xphp.arg-mismatch its own section (method/static/free-function argument check) instead of folding it into the constructor one. Note that default type arguments are checker behavior, not a separate code. - Fix the --lint command path (bin/xphp-lsp, not tools/lsp/bin/xphp-lsp). - Update the roadmap's exploratory examples to the turbofish call syntax. - Point CONTRIBUTING at the real executeCommand name (editor.action.showReferences). - Strip trailing whitespace in README. Co-Authored-By: Claude Opus 4.8 --- CONTRIBUTING.md | 4 ++-- README.md | 4 ++-- docs/features/index.md | 45 ++++++++++++++++++++++++++++-------------- docs/roadmap.md | 4 ++-- 4 files changed, 36 insertions(+), 21 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4f82728..1c049a3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,8 +44,8 @@ For LSP-client developers wiring this server into a non-bundled editor: - `codeActionProvider` with `resolveProvider: true` - `codeLensProvider` with `resolveProvider: true` - `callHierarchyProvider`, `typeHierarchyProvider` -- `executeCommandProvider` for `xphp.showReferences` (no-op server- - side; both clients dispatch `editor.action.showReferences` directly) +- `executeCommandProvider` advertising `editor.action.showReferences` + (no-op server-side; both clients dispatch it directly) - `semanticTokensProvider` (full file; standard LSP-spec token legend including `typeParameter`) - Pull-mode `diagnosticProvider` diff --git a/README.md b/README.md index 595f948..7901663 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ make build/phar # → var/xphp-lsp.phar The PHAR is the distribution format for editor integrations bundle -- zero-config install for editors that can't reasonably depend on a -Composer-managed working tree. +Composer-managed working tree. --- @@ -98,5 +98,5 @@ mindmap ## See also -- [detailed list of features](docs/features/index.md) +- [detailed list of features](docs/features/index.md) - [roadmap](./docs/roadmap.md) \ No newline at end of file diff --git a/docs/features/index.md b/docs/features/index.md index ede8626..9ca2f40 100644 --- a/docs/features/index.md +++ b/docs/features/index.md @@ -239,8 +239,10 @@ as type parameters. ## Validate Diagnostics surface in both push (`textDocument/publishDiagnostics`) -and pull (`textDocument/diagnostic`, LSP 3.17) modes. Five -diagnostic codes are emitted today: +and pull (`textDocument/diagnostic`, LSP 3.17) modes. Six diagnostic +codes are emitted today: `xphp.parse`, `xphp.bound`, `xphp.definition` +(duplicate template), `xphp.undefined-name`, `xphp.ctor-arg-mismatch`, +and `xphp.arg-mismatch`. ### Parse errors @@ -259,15 +261,19 @@ open buffers), so `new Box::(...)` resolves correctly even when reference the source-level instantiation (e.g. `Box`) rather than the hashed specialization name. -### Default type arguments +### Default type arguments (no false missing-arg) -A generic with trailing defaults (`class Box`, -`class Pair`) may be instantiated with the defaulted args -omitted (`new Box::<>()`, `new Pair::(...)`). The argument-type -checker resolves the effective type for each omitted slot left-to-right -(so `B = A` picks up the supplied `A`) and never reports a false -"missing type argument", while still substituting the effective type -into method parameter checks. +Not a diagnostic code of its own -- this is how the bound and +argument-type checks treat omitted defaults. A generic with trailing +defaults (`class Box`, `class Pair`) may be +instantiated with the defaulted args omitted (`new Box::<>()`, +`new Pair::(...)`). The argument-type checker resolves the +effective type for each omitted slot left-to-right (so `B = A` picks up +the supplied `A`) and never reports a false "missing type argument", +while still substituting the effective type into method parameter +checks. (An empty turbofish on a template with a non-defaulted +parameter is still reported -- as `xphp.bound` -- since the +instantiation is genuinely incomplete.) ### Duplicate template declarations @@ -292,10 +298,19 @@ declared type -- a runtime `TypeError` waiting to happen, surfaced at compile time. Inference is intentionally narrow (literals, `new ClassName(...)`, `true` / `false` / `null` const fetches) to avoid false positives on arguments whose type would require flow -analysis to know. The argument check also applies the type argument -of an instance-method turbofish (`$obj->m::(...)`); a variable -turbofish (`$f::(...)`) over an unknown callee is conservatively -skipped to avoid false positives. +analysis to know. + +### Argument-type mismatch (`xphp.arg-mismatch`) + +The same narrow-inference check, extended beyond constructors to +method calls (`$obj->m(...)`), static calls (`Cls::m(...)`), and free +functions (`freeFn(...)`). Type-argument turbofish is honoured: an +instance-method turbofish (`$obj->m::(...)`) binds its type +argument for the check. Cases that would require flow analysis are +conservatively skipped rather than guessed -- a variable turbofish +(`$f::(...)`) over an unknown callee, and an over-supplied +type-argument list (more args than the template declares), produce no +mismatch. --- @@ -404,7 +419,7 @@ navigation lands on the production declaration by default. CI-friendly entry point that doesn't require an LSP client: ```bash -tools/lsp/bin/xphp-lsp --lint path/to/file.xphp [more.xphp ...] +bin/xphp-lsp --lint path/to/file.xphp [more.xphp ...] ``` Output format is `::: : [] ` diff --git a/docs/roadmap.md b/docs/roadmap.md index 7d179d7..530937a 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -136,7 +136,7 @@ settling those is a prerequisite to any implementation work. ### Lowering preview -- "show me the generated PHP" -**What it'd do.** A code lens or peek-window above any `new Foo(...)` site +**What it'd do.** A code lens or peek-window above any `new Foo::(...)` site that opens the generated PHP for that specialization, side-by-side with the source. Same affordance for generic method calls. @@ -186,7 +186,7 @@ unifying. ### Instantiation inlay hints -- show the specialized FQN inline **What it'd do.** Render `// → Box_T_d59a1...` (or a shortened -hash) as an inlay hint at every `new Box(...)` site so the +hash) as an inlay hint at every `new Box::(...)` site so the specialization a given call resolves to is visible without leaving the editor. From e43ab8f0fa7057d7756eb0412f11c9f8530896a2 Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sun, 7 Jun 2026 16:59:00 +0000 Subject: [PATCH 17/22] fix(lsp): report serverInfo version 0.2.0 The server advertised version 0.1.0 in its initialize response while it targets xphp 0.2.x, so every client (and the IDE logs) showed a stale version. Align it with the supported language generation. Co-Authored-By: Claude Opus 4.8 --- src/LspDispatcherFactory.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/LspDispatcherFactory.php b/src/LspDispatcherFactory.php index e5f94ae..4fd9124 100644 --- a/src/LspDispatcherFactory.php +++ b/src/LspDispatcherFactory.php @@ -369,7 +369,7 @@ public function create(MessageTransmitter $transmitter, InitializeParams $initia new ErrorHandlingMiddleware($this->logger), new InitializeMiddleware($handlers, $eventDispatcher, [ 'name' => 'xphp-lsp', - 'version' => '0.1.0', + 'version' => '0.2.0', ]), new ShutdownMiddleware($eventDispatcher), new ResponseHandlingMiddleware($responseWatcher), From befb012ebf886c7971f5130b51dd233d1b8974ba Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Sun, 7 Jun 2026 20:35:57 +0000 Subject: [PATCH 18/22] perf(positionmap): memoize PositionMap per (uri, version) PositionMap (LSP line/char <-> byte, UTF-16 aware) was rebuilt on every request at the hot handler sites even when the document was unchanged; its constructor scans the whole source to index line offsets. Cache it alongside the version-keyed parse result in ParsedDocumentCache and reuse it from the open-document handlers that already hold the (uri, version, text) triple. Behavior is unchanged: a PositionMap is a pure function of its source, the cache reuses the same (uri, version) invalidation contract as the AST cache, and the filesystem/aggregated-source sites keep constructing directly. Verified by unit (incl. byte-parity vs a freshly built map over multibyte/emoji input) and behat. Co-Authored-By: Claude Opus 4.8 --- src/Analyzer/ParsedDocumentCache.php | 30 +++++++- src/Handler/WorkspaceSymbols.php | 3 +- src/Handler/XphpCallHierarchyHandler.php | 6 +- src/Handler/XphpCodeLensHandler.php | 4 +- src/Handler/XphpDefinitionHandler.php | 3 +- src/Handler/XphpDocumentHighlightHandler.php | 3 +- src/Handler/XphpDocumentSymbolHandler.php | 2 +- src/Handler/XphpFoldingRangeHandler.php | 2 +- src/Handler/XphpHoverHandler.php | 3 +- src/Handler/XphpImplementationHandler.php | 2 +- src/Handler/XphpInlayHintHandler.php | 2 +- src/Handler/XphpSemanticTokensHandler.php | 3 +- src/Handler/XphpSignatureHelpHandler.php | 3 +- src/Handler/XphpTypeHierarchyHandler.php | 2 +- test/Analyzer/ParsedDocumentCacheTest.php | 77 ++++++++++++++++++++ 15 files changed, 122 insertions(+), 23 deletions(-) diff --git a/src/Analyzer/ParsedDocumentCache.php b/src/Analyzer/ParsedDocumentCache.php index a482b3e..10cb41b 100644 --- a/src/Analyzer/ParsedDocumentCache.php +++ b/src/Analyzer/ParsedDocumentCache.php @@ -4,6 +4,8 @@ namespace XPHP\Lsp\Analyzer; +use XPHP\Lsp\PositionMap; + /** * Version-keyed AST cache. The handlers (hover, definition, completion, * diagnostics) used to call `Analyzer::analyzeFile($item->text)` directly on @@ -22,7 +24,7 @@ */ final class ParsedDocumentCache { - /** @var array */ + /** @var array */ private array $entries = []; public function __construct(private readonly Analyzer $analyzer) @@ -40,6 +42,32 @@ public function getOrParse(string $uri, int $version, string $source): ParseResu return $result; } + /** + * Memoized {@see PositionMap} for an open document, keyed by the same + * `(uri, version)` contract as {@see getOrParse}. Building a PositionMap + * scans the whole source to index line offsets; the hot handlers (semantic + * tokens, hover, definition, ...) rebuilt one on every request even when + * the text was unchanged. Caching it next to the parse result removes that + * redundant scan with no behaviour change -- a version bump invalidates it + * exactly like the AST. + */ + public function positionMap(string $uri, int $version, string $source): PositionMap + { + $cached = $this->entries[$uri] ?? null; + if ($cached !== null && $cached['version'] === $version && isset($cached['positionMap'])) { + return $cached['positionMap']; + } + // Keep the entry coherent at this version: reuse the parse if it is + // already current, otherwise this refreshes result + version (and drops + // any stale positionMap by replacing the entry). + if ($cached === null || $cached['version'] !== $version) { + $this->getOrParse($uri, $version, $source); + } + $map = new PositionMap($source); + $this->entries[$uri]['positionMap'] = $map; + return $map; + } + public function forget(string $uri): void { unset($this->entries[$uri]); diff --git a/src/Handler/WorkspaceSymbols.php b/src/Handler/WorkspaceSymbols.php index 6695bab..012ed6f 100644 --- a/src/Handler/WorkspaceSymbols.php +++ b/src/Handler/WorkspaceSymbols.php @@ -15,7 +15,6 @@ use Phpactor\LanguageServerProtocol\Position; use Phpactor\LanguageServerProtocol\Range; use XPHP\Lsp\Analyzer\ParsedDocumentCache; -use XPHP\Lsp\PositionMap; /** * Walks every open document and collects the FQNs of every ClassLike (class, @@ -113,7 +112,7 @@ public function findClassByName(string $shortName): ?Location if ($found === null) { continue; } - $positionMap = new PositionMap($item->text); + $positionMap = $this->cache->positionMap($uri, $item->version, $item->text); [$startLine, $startChar] = $positionMap->offsetToPosition($found['startOffset']); [$endLine, $endChar] = $positionMap->offsetToPosition($found['endOffset']); $location = new Location( diff --git a/src/Handler/XphpCallHierarchyHandler.php b/src/Handler/XphpCallHierarchyHandler.php index 4662d1f..eb74236 100644 --- a/src/Handler/XphpCallHierarchyHandler.php +++ b/src/Handler/XphpCallHierarchyHandler.php @@ -111,7 +111,7 @@ public function prepare(TextDocumentPositionParams $params): Promise if ($result->ast === null || $result->ast === []) { return new Success([]); } - $positionMap = new PositionMap($item->text); + $positionMap = $this->cache->positionMap($uri, $item->version, $item->text); $offset = $positionMap->positionToOffset( $params->position->line, $params->position->character, @@ -194,7 +194,7 @@ public function outgoingCalls(array $item): Promise if ($body === null || $body === []) { return new Success([]); } - $positionMap = new PositionMap($document->text); + $positionMap = $this->cache->positionMap($uri, $document->version, $document->text); $calls = self::collectOutgoingFromBody($body, $uri, $positionMap); return new Success($calls); } @@ -253,7 +253,7 @@ private function collectCallSites(string $targetName): array if ($result->ast === null || $result->ast === []) { continue; } - $positionMap = new PositionMap($document->text); + $positionMap = $this->cache->positionMap($uriStr, $document->version, $document->text); $localHits = self::collectCallSitesInAst($result->ast, $targetName, $uriStr, $positionMap); foreach ($localHits as $hit) { $hits[] = $hit; diff --git a/src/Handler/XphpCodeLensHandler.php b/src/Handler/XphpCodeLensHandler.php index acaeb2e..6ce456a 100644 --- a/src/Handler/XphpCodeLensHandler.php +++ b/src/Handler/XphpCodeLensHandler.php @@ -119,7 +119,7 @@ public function codeLens(CodeLensParams $params, ?CancellationToken $cancel = nu if ($result->ast === null || $result->ast === []) { return new Success([]); } - $positionMap = new PositionMap($item->text); + $positionMap = $this->cache->positionMap($uri, $item->version, $item->text); return new Success(self::buildLenses($uri, $result->ast, $positionMap)); } @@ -148,7 +148,7 @@ public function resolve(CodeLens $lens, ?CancellationToken $cancel = null): Prom return new Success($lens); } $item = $this->workspace->get($uri); - $positionMap = new PositionMap($item->text); + $positionMap = $this->cache->positionMap($uri, $item->version, $item->text); $byteOffset = $positionMap->positionToOffset($line, $character); $locations = $this->finder->findReferences($uri, $byteOffset, false); $count = count($locations); diff --git a/src/Handler/XphpDefinitionHandler.php b/src/Handler/XphpDefinitionHandler.php index 651153f..da1a55c 100644 --- a/src/Handler/XphpDefinitionHandler.php +++ b/src/Handler/XphpDefinitionHandler.php @@ -19,7 +19,6 @@ use Phpactor\LanguageServerProtocol\Range; use Phpactor\LanguageServerProtocol\ServerCapabilities; use XPHP\Lsp\Analyzer\ParsedDocumentCache; -use XPHP\Lsp\PositionMap; use XPHP\Lsp\Reflection\FqnIndex; use XPHP\Lsp\Resolver\GenericResolver; use XPHP\Lsp\Resolver\PhpDefinitionResolver; @@ -95,7 +94,7 @@ public function definition(DefinitionParams $params, ?CancellationToken $cancel return new Success(null); } - $offset = (new PositionMap($currentItem->text))->positionToOffset( + $offset = $this->cache->positionMap($params->textDocument->uri, $currentItem->version, $currentItem->text)->positionToOffset( $params->position->line, $params->position->character, ); diff --git a/src/Handler/XphpDocumentHighlightHandler.php b/src/Handler/XphpDocumentHighlightHandler.php index 505a587..e421692 100644 --- a/src/Handler/XphpDocumentHighlightHandler.php +++ b/src/Handler/XphpDocumentHighlightHandler.php @@ -15,7 +15,6 @@ use Phpactor\LanguageServerProtocol\DocumentHighlightParams; use Phpactor\LanguageServerProtocol\ServerCapabilities; use XPHP\Lsp\Analyzer\ParsedDocumentCache; -use XPHP\Lsp\PositionMap; use XPHP\Lsp\Resolver\DocumentHighlightKindResolver; use XPHP\Lsp\Resolver\ReferenceFinder; @@ -70,7 +69,7 @@ public function documentHighlight(DocumentHighlightParams $params, ?Cancellation return new Success([]); } $item = $this->workspace->get($uri); - $positionMap = new PositionMap($item->text); + $positionMap = $this->cache->positionMap($uri, $item->version, $item->text); $offset = $positionMap->positionToOffset( $params->position->line, $params->position->character, diff --git a/src/Handler/XphpDocumentSymbolHandler.php b/src/Handler/XphpDocumentSymbolHandler.php index 373e7ca..014ee5b 100644 --- a/src/Handler/XphpDocumentSymbolHandler.php +++ b/src/Handler/XphpDocumentSymbolHandler.php @@ -87,7 +87,7 @@ public function documentSymbol(DocumentSymbolParams $params): Promise return new Success(null); } - $positionMap = new PositionMap($item->text); + $positionMap = $this->cache->positionMap($uri, $item->version, $item->text); $byteOffsetMap = $result->byteOffsetMap; $symbols = []; foreach ($result->ast as $stmt) { diff --git a/src/Handler/XphpFoldingRangeHandler.php b/src/Handler/XphpFoldingRangeHandler.php index ed660eb..423ce99 100644 --- a/src/Handler/XphpFoldingRangeHandler.php +++ b/src/Handler/XphpFoldingRangeHandler.php @@ -76,7 +76,7 @@ public function foldingRange(FoldingRangeParams $params): Promise if ($result->ast === null) { return new Success([]); } - $map = new PositionMap($item->text); + $map = $this->cache->positionMap($uri, $item->version, $item->text); $offsets = $result->byteOffsetMap; $ranges = []; foreach ($result->ast as $stmt) { diff --git a/src/Handler/XphpHoverHandler.php b/src/Handler/XphpHoverHandler.php index 4b65806..51ddb8a 100644 --- a/src/Handler/XphpHoverHandler.php +++ b/src/Handler/XphpHoverHandler.php @@ -20,7 +20,6 @@ use Phpactor\LanguageServerProtocol\MarkupKind; use Phpactor\LanguageServerProtocol\ServerCapabilities; use XPHP\Lsp\Analyzer\ParsedDocumentCache; -use XPHP\Lsp\PositionMap; use XPHP\Lsp\Resolver\BoundExprView; use XPHP\Lsp\Resolver\PhpHoverResolver; use XPHP\Transpiler\Monomorphize\Registry; @@ -110,7 +109,7 @@ public function hover(HoverParams $params, ?CancellationToken $cancel = null): P return new Success(null); } - $positionMap = new PositionMap($item->text); + $positionMap = $this->cache->positionMap($params->textDocument->uri, $item->version, $item->text); $offset = $positionMap->positionToOffset( $params->position->line, $params->position->character, diff --git a/src/Handler/XphpImplementationHandler.php b/src/Handler/XphpImplementationHandler.php index 9093895..b130034 100644 --- a/src/Handler/XphpImplementationHandler.php +++ b/src/Handler/XphpImplementationHandler.php @@ -82,7 +82,7 @@ public function implementation(ImplementationParams $params): Promise if ($result->ast === null || $result->ast === []) { return new Success([]); } - $positionMap = new PositionMap($item->text); + $positionMap = $this->cache->positionMap($uri, $item->version, $item->text); $offset = $positionMap->positionToOffset( $params->position->line, $params->position->character, diff --git a/src/Handler/XphpInlayHintHandler.php b/src/Handler/XphpInlayHintHandler.php index 2e63abb..56cf6b4 100644 --- a/src/Handler/XphpInlayHintHandler.php +++ b/src/Handler/XphpInlayHintHandler.php @@ -93,7 +93,7 @@ public function inlayHint(InlayHintParams $params, ?CancellationToken $cancel = if ($result->ast === null) { return new Success([]); } - $map = new PositionMap($item->text); + $map = $this->cache->positionMap($uri, $item->version, $item->text); $assigns = self::collectAssigns($result->ast); $hints = []; diff --git a/src/Handler/XphpSemanticTokensHandler.php b/src/Handler/XphpSemanticTokensHandler.php index c046fd7..aa4f14b 100644 --- a/src/Handler/XphpSemanticTokensHandler.php +++ b/src/Handler/XphpSemanticTokensHandler.php @@ -17,7 +17,6 @@ use XPHP\Lsp\Handler\SemanticTokens\AstVisitor; use XPHP\Lsp\Handler\SemanticTokens\Encoder; use XPHP\Lsp\Handler\SemanticTokens\TokenLegend; -use XPHP\Lsp\PositionMap; /** * `textDocument/semanticTokens/full` handler. @@ -120,7 +119,7 @@ public function semanticTokensFull(array $textDocument, ?CancellationToken $canc } $visitor = new AstVisitor( - new PositionMap($item->text), + $this->cache->positionMap($uri, $item->version, $item->text), $result->byteOffsetMap, $item->text, ); diff --git a/src/Handler/XphpSignatureHelpHandler.php b/src/Handler/XphpSignatureHelpHandler.php index 2d8984b..13e9752 100644 --- a/src/Handler/XphpSignatureHelpHandler.php +++ b/src/Handler/XphpSignatureHelpHandler.php @@ -30,7 +30,6 @@ use Phpactor\WorseReflection\Reflector; use Throwable; use XPHP\Lsp\Analyzer\ParsedDocumentCache; -use XPHP\Lsp\PositionMap; use XPHP\Transpiler\Monomorphize\XphpSourceParser; /** @@ -97,7 +96,7 @@ public function signatureHelp(SignatureHelpParams $params, ?CancellationToken $c return new Success(null); } - $positionMap = new PositionMap($item->text); + $positionMap = $this->cache->positionMap($uri, $item->version, $item->text); $offset = $positionMap->positionToOffset( $params->position->line, $params->position->character, diff --git a/src/Handler/XphpTypeHierarchyHandler.php b/src/Handler/XphpTypeHierarchyHandler.php index 1b0f5ff..58b60ba 100644 --- a/src/Handler/XphpTypeHierarchyHandler.php +++ b/src/Handler/XphpTypeHierarchyHandler.php @@ -102,7 +102,7 @@ public function prepare(TextDocumentPositionParams $params): Promise if ($result->ast === null || $result->ast === []) { return new Success([]); } - $positionMap = new PositionMap($item->text); + $positionMap = $this->cache->positionMap($uri, $item->version, $item->text); $offset = $positionMap->positionToOffset( $params->position->line, $params->position->character, diff --git a/test/Analyzer/ParsedDocumentCacheTest.php b/test/Analyzer/ParsedDocumentCacheTest.php index 5c52d6e..efecf21 100644 --- a/test/Analyzer/ParsedDocumentCacheTest.php +++ b/test/Analyzer/ParsedDocumentCacheTest.php @@ -8,6 +8,7 @@ use PHPUnit\Framework\TestCase; use XPHP\Lsp\Analyzer\Analyzer; use XPHP\Lsp\Analyzer\ParsedDocumentCache; +use XPHP\Lsp\PositionMap; use XPHP\Transpiler\Monomorphize\XphpSourceParser; final class ParsedDocumentCacheTest extends TestCase @@ -161,6 +162,82 @@ public function testForgetFilesystemOnEmptyCacheReturnsZero(): void self::assertSame(0, $cache->forgetFilesystem()); } + public function testPositionMapIsMemoizedPerVersionAndInvalidatedOnBump(): void + { + $spy = $this->newSpy(); + $cache = new ParsedDocumentCache($spy); + + $first = $cache->positionMap('/a.xphp', 1, "positionMap('/a.xphp', 1, "callCount, 'building + reusing the v1 map parses once'); + + $bumped = $cache->positionMap('/a.xphp', 2, "callCount, 'version bump through positionMap reparses once'); + $cache->getOrParse('/a.xphp', 2, "callCount, 'entry is coherent at the bumped version'); + } + + public function testPositionMapReusesACurrentParseWithoutReparsing(): void + { + $spy = $this->newSpy(); + $cache = new ParsedDocumentCache($spy); + + $cache->getOrParse('/a.xphp', 1, "callCount); + + // The entry is already current at version 1; building its PositionMap + // must not trigger another parse. + $cache->positionMap('/a.xphp', 1, "callCount, 'positionMap reuses the current parse, no reparse'); + } + + public function testPositionMapBuildsTheEntryWhenNotPreParsed(): void + { + $spy = $this->newSpy(); + $cache = new ParsedDocumentCache($spy); + + // No prior getOrParse: positionMap establishes the entry (one parse). + $cache->positionMap('/a.xphp', 1, "callCount); + + // A subsequent getOrParse at the same version serves the cache. + $cache->getOrParse('/a.xphp', 1, "callCount, 'positionMap-seeded entry serves a same-version getOrParse'); + } + + public function testPositionMapResultsMatchAFreshlyBuiltMap(): void + { + $spy = $this->newSpy(); + $cache = new ParsedDocumentCache($spy); + + // Multi-line + multibyte (emoji is a 4-byte / surrogate-pair char) to + // exercise the UTF-16 column math, proving the cached map is behaviour- + // identical to a directly-constructed one. + $source = "positionMap('/m.xphp', 1, $source); + $fresh = new PositionMap($source); + + foreach ([0, 6, 12, 20, 30, strlen($source)] as $offset) { + self::assertSame( + $fresh->offsetToPosition($offset), + $cached->offsetToPosition($offset), + "offsetToPosition parity at byte $offset", + ); + } + self::assertSame( + $fresh->positionToOffset(1, 5), + $cached->positionToOffset(1, 5), + 'positionToOffset parity', + ); + } + /** * Analyzer subclass that counts analyzeFile() invocations. PHPUnit's * native mocking infrastructure could express the same shape but with From 40424522f6af2bc07ccb1da245f2e36bf4ca306d Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 11 Jun 2026 22:28:01 +0000 Subject: [PATCH 19/22] fix(codelens): make "Show references" lens work in VS Code and PhpStorm Rename the lens command from the VS Code-internal `editor.action.showReferences` to a neutral `xphp.showReferences`, and advertise it in `executeCommandProvider` only when the client wants it. PhpStorm's LSP API renders a CodeLens as clickable only when its command is advertised; VS Code's languageclient registers a forwarding command for every advertised command, which shadows the extension's own `xphp.showReferences` handler and round-trips clicks to the server. So advertise by default (PhpStorm, Helix, ...) and let the VS Code extension opt out via `initializationOptions.advertiseCodeLensCommand: false`. Co-Authored-By: Claude Opus 4.8 (1M context) --- CONTRIBUTING.md | 7 ++- docs/features/index.md | 17 +++++-- src/Handler/XphpCodeLensHandler.php | 24 ++++++--- src/LspDispatcherFactory.php | 62 +++++++++++++++++------- test/Behat/EditContext.php | 2 +- test/Handler/XphpCodeLensHandlerTest.php | 6 +-- test/LspDispatcherFactoryTest.php | 55 +++++++++++++-------- 7 files changed, 117 insertions(+), 56 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1c049a3..2847bd1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -43,9 +43,12 @@ For LSP-client developers wiring this server into a non-bundled editor: - `inlayHintProvider` - `codeActionProvider` with `resolveProvider: true` - `codeLensProvider` with `resolveProvider: true` +- `executeCommandProvider` advertising `xphp.showReferences` (the + "Show references" CodeLens command) -- advertised by default so + PhpStorm renders the lens as clickable; suppressed when the client + sends `initializationOptions: {advertiseCodeLensCommand: false}` + (VS Code does, to avoid its forwarder shadowing the client handler) - `callHierarchyProvider`, `typeHierarchyProvider` -- `executeCommandProvider` advertising `editor.action.showReferences` - (no-op server-side; both clients dispatch it directly) - `semanticTokensProvider` (full file; standard LSP-spec token legend including `typeParameter`) - Pull-mode `diagnosticProvider` diff --git a/docs/features/index.md b/docs/features/index.md index 9ca2f40..9d0e6d4 100644 --- a/docs/features/index.md +++ b/docs/features/index.md @@ -167,10 +167,19 @@ LSP methods: `textDocument/codeLens`, `codeLens/resolve`. "Show references" lens above every class / interface / trait / enum / function / method declaration. The resolve step fills in a lazy -reference count; clicking the lens opens a chooser popup -(`editor.action.showReferences`) -- natively in VS Code, dispatched -client-side by the PhpStorm plugin so the popup anchors at the lens -position rather than the caret. +reference count; the lens carries a namespaced `xphp.showReferences` +command (with the locations baked in) that each client handles +client-side -- VS Code via a wrapper command that forwards to its +built-in references peek, the PhpStorm plugin via a usage chooser +anchored at the lens position rather than the caret. + +The command is advertised in `executeCommandProvider` by default -- +PhpStorm's LSP API only renders a CodeLens as clickable when its +command is advertised. VS Code instead auto-registers a forwarding +command for every advertised command (which would shadow its own +client-side handler), so the VS Code extension opts out via +`initializationOptions: {advertiseCodeLensCommand: false}` and the +server then omits it. --- diff --git a/src/Handler/XphpCodeLensHandler.php b/src/Handler/XphpCodeLensHandler.php index 6ce456a..2fbd87c 100644 --- a/src/Handler/XphpCodeLensHandler.php +++ b/src/Handler/XphpCodeLensHandler.php @@ -31,11 +31,12 @@ * * Emits a "Show references" lens above every class, interface, trait, * enum, function, and method declaration in the active document. - * Each lens carries an `editor.action.showReferences` Command -- the - * de-facto LSP client-side convention (VS Code / LSP4IJ / Helix all - * recognize the name) -- with Location[] in the arguments. Clicking - * the lens opens the references popup via XphpShowReferencesCommandsSupport - * (or the client's built-in handler for that command name). + * Each lens carries an `xphp.showReferences` Command (a namespaced, + * client-side command -- see COMMAND_NAME) with Location[] in the + * arguments. Each client registers its own handler for that id and + * opens the references UI client-side: VS Code via a wrapper command + * that forwards to its built-in references peek, PhpStorm via + * XphpShowReferencesCommandsSupport. * * Two-phase emission (LSP 3.17 codeLens/resolve protocol): * @@ -65,10 +66,17 @@ final class XphpCodeLensHandler implements Handler, CanRegisterCapabilities { /** - * Client-side command name -- recognized by VS Code, PhpStorm - * LSP4IJ, Helix, and every other mainline LSP client. + * Client-side command name. Namespaced (NOT the VS Code-internal + * `editor.action.showReferences`) so it is never advertised in + * `executeCommandProvider` and never collides with a client + * built-in: each client registers its own handler for this id + * (VS Code: a wrapper command that converts args and calls the + * built-in references peek; PhpStorm: XphpShowReferencesCommandsSupport). + * The lens carries `[uri, position, locations]` (locations baked in + * by `resolve()`), and clients open the references UI client-side + * without any `workspace/executeCommand` round-trip. */ - public const COMMAND_NAME = 'editor.action.showReferences'; + public const COMMAND_NAME = 'xphp.showReferences'; /** * Placeholder title shown until the lens is resolved. Users diff --git a/src/LspDispatcherFactory.php b/src/LspDispatcherFactory.php index 4fd9124..78e1853 100644 --- a/src/LspDispatcherFactory.php +++ b/src/LspDispatcherFactory.php @@ -253,26 +253,33 @@ public function create(MessageTransmitter $transmitter, InitializeParams $initia $genericResolver, ); + // The CodeLens "Show references" command (`xphp.showReferences`) is + // handled CLIENT-side (VS Code wrapper command / PhpStorm + // XphpShowReferencesCommandsSupport), never round-tripped. But the + // two clients disagree on whether it must be advertised in + // `executeCommandProvider`: + // - PhpStorm's LSP API only renders a CodeLens as *clickable* when + // its command is advertised here -- omit it and the lens shows as + // dead text. + // - VS Code (vscode-languageclient) auto-registers a forwarding + // command for every advertised command, which SHADOWS the + // extension's own `xphp.showReferences` handler and round-trips + // the click to this server's no-op. + // So advertise by default (PhpStorm, Helix, ...) and let the VS Code + // extension opt out via the `advertiseCodeLensCommand: false` + // initialization option. The no-op handler is a safety net for any + // client that does round-trip the (advertised) command. + $executeCommands = []; + if (self::clientWantsCodeLensCommandAdvertised($initializeParams)) { + $executeCommands[XphpCodeLensHandler::COMMAND_NAME] = new ClosureCommand( + static fn (...$args): \Amp\Promise => new \Amp\Success(null), + ); + } + $handlers = new Handlers( new XphpTextDocumentHandler($eventDispatcher), new ServiceHandler($serviceManager, $clientApi), - new CommandHandler(new CommandDispatcher([ - // CodeLens emits `editor.action.showReferences` with - // locations baked in -- VS Code, PhpStorm LSP4IJ, and - // Helix all dispatch this name client-side and open - // the Find Usages panel without round-tripping. - // Register a server-side no-op as a safety net: any - // client that doesn't recognize the convention will - // fall back to `workspace/executeCommand`, and the - // CommandDispatcher would throw on an unknown - // command name -- phpactor's framework would surface - // that as a JSON-RPC error toast. Returning null - // here makes the unhandled-by-client path silently - // do nothing instead. - XphpCodeLensHandler::COMMAND_NAME => new ClosureCommand( - static fn (...$args): \Amp\Promise => new \Amp\Success(null), - ), - ])), + new CommandHandler(new CommandDispatcher($executeCommands)), new ExitHandler(), new XphpHoverHandler($workspace, $cache, $phpHoverResolver), new XphpDefinitionHandler( @@ -411,4 +418,25 @@ private static function clientSupportsRenameFileOp(InitializeParams $initializeP } return in_array('rename', $ops, true); } + + /** + * Whether to advertise the CodeLens "Show references" command in + * `executeCommandProvider`. Defaults to true (PhpStorm needs it for + * clickable lenses; Helix and other clients are unaffected or treat it + * as a no-op). A client that auto-registers forwarding commands for + * advertised commands -- vscode-languageclient does -- opts out by + * sending `initializationOptions: {advertiseCodeLensCommand: false}`, + * so its own client-side handler isn't shadowed. + */ + private static function clientWantsCodeLensCommandAdvertised(InitializeParams $initializeParams): bool + { + $opts = $initializeParams->initializationOptions; + if (is_object($opts)) { + $opts = get_object_vars($opts); + } + if (!is_array($opts) || !array_key_exists('advertiseCodeLensCommand', $opts)) { + return true; + } + return $opts['advertiseCodeLensCommand'] !== false; + } } diff --git a/test/Behat/EditContext.php b/test/Behat/EditContext.php index 7bb00d1..3feacfa 100644 --- a/test/Behat/EditContext.php +++ b/test/Behat/EditContext.php @@ -389,7 +389,7 @@ public function theResolvedLensCarriesTheReferenceLocations(): void $lens = $this->world->last(); $this->world->assert($lens instanceof CodeLens && $lens->command !== null, 'expected a resolved code lens with a command'); $this->world->assert( - $lens->command->command === 'editor.action.showReferences', + $lens->command->command === 'xphp.showReferences', sprintf('expected showReferences command, got "%s"', (string) $lens->command->command), ); $args = $lens->command->arguments ?? []; diff --git a/test/Handler/XphpCodeLensHandlerTest.php b/test/Handler/XphpCodeLensHandlerTest.php index c03f0b7..62a64b1 100644 --- a/test/Handler/XphpCodeLensHandlerTest.php +++ b/test/Handler/XphpCodeLensHandlerTest.php @@ -66,7 +66,7 @@ public function testEmitsUnresolvedLensForClassDeclaration(): void // can still call codeLens/resolve to get the count + baked // locations up front. self::assertSame('Show references', $lenses[0]->command?->title); - self::assertSame('editor.action.showReferences', $lenses[0]->command?->command); + self::assertSame(XphpCodeLensHandler::COMMAND_NAME, $lenses[0]->command?->command); self::assertCount(2, $lenses[0]->command?->arguments); self::assertSame('/Foo.xphp', $lenses[0]->command?->arguments[0]); self::assertSame(['line' => 2, 'character' => 6], $lenses[0]->command?->arguments[1]); @@ -160,7 +160,7 @@ public function testResolveFillsInUsageCountAndLocations(): void // The resolve handler runs ReferenceFinder against the // position the lens emission stored in `data`, and returns // the lens with `command: {title: "N usage(s)", command: - // editor.action.showReferences, arguments: [uri, position, + // xphp.showReferences, arguments: [uri, position, // locations]}` populated. $workspace = new PhpactorWorkspace(); $workspace->open(new TextDocumentItem('/Foo.xphp', 'xphp', 1, <<<'PHP' @@ -187,7 +187,7 @@ public function bar(): void {} $resolved = wait($handler->resolve($unresolved)); - self::assertSame('editor.action.showReferences', $resolved->command?->command); + self::assertSame(XphpCodeLensHandler::COMMAND_NAME, $resolved->command?->command); self::assertSame('1 usage', $resolved->command?->title); $args = $resolved->command?->arguments; self::assertIsArray($args); diff --git a/test/LspDispatcherFactoryTest.php b/test/LspDispatcherFactoryTest.php index 7315499..8e2c855 100644 --- a/test/LspDispatcherFactoryTest.php +++ b/test/LspDispatcherFactoryTest.php @@ -186,37 +186,50 @@ public static function clientSupportsRenameFileOpCases(): iterable yield 'resourceOperations includes "rename"' => [$renameAndCreate, true]; } - public function testCodeLensCommandIsDispatchableViaExecuteCommandFallback(): void + public function testCodeLensCommandIsAdvertisedByDefault(): void { - // CodeLens emits `editor.action.showReferences` with - // locations baked in; well-behaved clients (VS Code, LSP4IJ, - // Helix) dispatch the command client-side and open Find - // Usages directly -- no executeCommand request reaches the - // server. Any client that doesn't recognize the - // convention falls back to `workspace/executeCommand` -- - // phpactor's CommandDispatcher would throw `Command "..." - // not found` on an unregistered name and surface that as a - // JSON-RPC error toast. The dispatcher registers a - // server-side no-op for the command name as a safety net so - // the fallback path is silent. + // PhpStorm's LSP API only renders a CodeLens as clickable when its + // command is advertised in executeCommandProvider, so the default + // (no initialization options) must advertise xphp.showReferences. $tester = $this->buildTester(); - $tester->initialize(); + $result = $tester->initialize(); - $response = \Amp\Promise\wait( - $tester->workspace()->executeCommand( - \XPHP\Lsp\Handler\XphpCodeLensHandler::COMMAND_NAME, - ['file:///x.xphp', ['line' => 0, 'character' => 0], []], - ), + $commands = $result->capabilities->executeCommandProvider->commands ?? []; + self::assertContains( + \XPHP\Lsp\Handler\XphpCodeLensHandler::COMMAND_NAME, + $commands, + 'the CodeLens command must be advertised by default', ); + } + + public function testCodeLensCommandIsSuppressedWhenClientOptsOut(): void + { + // VS Code (vscode-languageclient) auto-registers a forwarding command + // for every advertised command, which would shadow its own client-side + // handler. It opts out via initializationOptions.advertiseCodeLensCommand, + // and then the server must NOT advertise the command. + $tester = $this->buildTester(['advertiseCodeLensCommand' => false]); + $result = $tester->initialize(); - self::assertNull($response->error, 'no JSON-RPC error from executeCommand'); + $commands = $result->capabilities->executeCommandProvider->commands ?? []; + self::assertNotContains( + \XPHP\Lsp\Handler\XphpCodeLensHandler::COMMAND_NAME, + $commands, + 'the CodeLens command must not be advertised when the client opts out', + ); } - private function buildTester(): LanguageServerTester + /** + * @param array|null $initializationOptions + */ + private function buildTester(?array $initializationOptions = null): LanguageServerTester { return new LanguageServerTester( new LspDispatcherFactory(), - new InitializeParams(new ClientCapabilities()), + new InitializeParams( + new ClientCapabilities(), + initializationOptions: $initializationOptions, + ), ); } } From 42b8e9a7c8dd92f97a6164a66b814dcf002f54da Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Thu, 11 Jun 2026 22:28:17 +0000 Subject: [PATCH 20/22] fix(generics): resolve method turbofish and static/self returns at instance call sites - Bind a generic method's own turbofish args at the call site (`$u->identity::(99)`) so the result infers `int` instead of the bare type parameter `T`. Mirrors the existing static-call path. - Track non-generic `new` receivers (empty-paramMap binding) so instance calls can resolve their receiver, kept invisible to hover/completion so plain objects still defer to worse-reflection. - Resolve relative return types (`fresh(): static` / `self`) to the receiver's concrete type (`Builder`). - Add Behat coverage (hover + inlay hints) for both behaviors end-to-end. Co-Authored-By: Claude Opus 4.8 (1M context) --- features/understand/hover.feature | 43 ++++++++ features/understand/inlay_hints.feature | 51 ++++++++- src/Resolver/GenericResolver.php | 138 +++++++++++++++++++++++- test/Resolver/GenericResolverTest.php | 64 +++++++++++ 4 files changed, 290 insertions(+), 6 deletions(-) diff --git a/features/understand/hover.feature b/features/understand/hover.feature index e79ab20..1e6e141 100644 --- a/features/understand/hover.feature +++ b/features/understand/hover.feature @@ -60,3 +60,46 @@ Feature: Hover When I request "textDocument/hover" on "T" at line 4 of "/producer.xphp" Then the hover contents contain "`+T`" And the hover contents contain "covariant" + + Scenario: Hover over a generic method turbofish call shows the specialized signature + Given the file at "/Util.xphp" contains the following lines: + """ + (T $x): T { return $x; } + } + """ + And the file at "/Use.xphp" contains the following lines: + """ + identity::('world'); + """ + And the FQN index has been warmed on initialize + When I request "textDocument/hover" on "identity" at line 3 of "/Use.xphp" + Then the hover contents contain "identity(string $x): string" + + Scenario: Hover over a method returning static resolves to the receiver's concrete type + Given the file at "/Builder.xphp" contains the following lines: + """ + + { + public function __construct(public T $value) {} + public function fresh(T $v): static { return new static::($v); } + } + """ + And the file at "/Use.xphp" contains the following lines: + """ + (1); + $b = $a->fresh(2); + """ + And the FQN index has been warmed on initialize + When I request "textDocument/hover" on "fresh" at line 3 of "/Use.xphp" + Then the hover contents contain "fresh(int $v): App\Builder" diff --git a/features/understand/inlay_hints.feature b/features/understand/inlay_hints.feature index 7e26ef4..f83f782 100644 --- a/features/understand/inlay_hints.feature +++ b/features/understand/inlay_hints.feature @@ -2,7 +2,7 @@ Feature: Inlay hints As a developer editing xphp I want the concrete type a generic method resolved to shown after an assignment - Background: + Scenario: Hint the substituted return type of a generic class method Given the file at "/Collection.xphp" contains the following lines: """ first(); """ And the FQN index has been warmed on initialize - - Scenario: Hint the substituted return type of a generic method call When I request "textDocument/inlayHint" for the visible range of "/Use.xphp" Then exactly 1 inlay hint is rendered And an inlay hint ": ?App\Models\User" is rendered after "$first" on line 4 of "/Use.xphp" + + Scenario: Hint a generic method turbofish called on a local-variable receiver + Given the file at "/Util.xphp" contains the following lines: + """ + (T $x): T { return $x; } + } + """ + And the file at "/Use.xphp" contains the following lines: + """ + identity::(99); + $s = $u->identity::('world'); + """ + And the FQN index has been warmed on initialize + When I request "textDocument/inlayHint" for the visible range of "/Use.xphp" + Then exactly 2 inlay hints are rendered + And an inlay hint ": int" is rendered after "$i" on line 3 of "/Use.xphp" + And an inlay hint ": string" is rendered after "$s" on line 4 of "/Use.xphp" + + Scenario: Hint a static return type resolved to the receiver's concrete type + Given the file at "/Builder.xphp" contains the following lines: + """ + + { + public function __construct(public T $value) {} + public function fresh(T $v): static { return new static::($v); } + } + """ + And the file at "/Use.xphp" contains the following lines: + """ + (1); + $b = $a->fresh(2); + """ + And the FQN index has been warmed on initialize + When I request "textDocument/inlayHint" for the visible range of "/Use.xphp" + Then exactly 1 inlay hint is rendered + And an inlay hint ": App\Builder" is rendered after "$b" on line 3 of "/Use.xphp" diff --git a/src/Resolver/GenericResolver.php b/src/Resolver/GenericResolver.php index 4f66200..4c531c1 100644 --- a/src/Resolver/GenericResolver.php +++ b/src/Resolver/GenericResolver.php @@ -120,6 +120,12 @@ public function resolveVariable(string $uri, string $varName, int $byteOffset): // path renders just `App\Containers\Collection` (the worse-reflection // view); ours adds the type-arg context. if ($binding instanceof VarBinding) { + // Empty paramMap = a non-generic receiver recorded only to type + // method-call receivers (see buildFromPlainNew). Render nothing + // so plain-object hover keeps deferring to worse-reflection. + if ($binding->paramMap === []) { + return null; + } return $this->renderBinding($binding); } return $binding->render(); @@ -142,6 +148,12 @@ public function resolveVariableTypeRef(string $uri, string $varName, int $byteOf // itself. Surface it as a non-nullable TypeRef of the class FQN // so the completion path can reflect on the class directly. if ($binding instanceof VarBinding) { + // Empty paramMap = a non-generic receiver recorded only for + // method-call receiver typing (see buildFromPlainNew). Defer to + // worse-reflection, which models nullable/union receivers better. + if ($binding->paramMap === []) { + return null; + } return new ResolvedType(new TypeRef($binding->classFqn), false); } // ResolvedType: variable holds the substituted result of a prior @@ -307,6 +319,13 @@ public function resolveMethodCallSubstitutionAt(string $uri, int $byteOffset): ? return null; } $paramMap = self::paramMapFromReceiver($classLike, $receiverType); + $paramMap = self::withMethodTurbofish($paramMap, $call, $method); + // No type params in scope = a plain method on a non-generic receiver; + // there is nothing to specialize, so leave the signature to + // worse-reflection (and don't emit a generic inlay hint for it). + if ($paramMap === []) { + return null; + } $paramNames = array_keys($paramMap); // Return type. @@ -315,7 +334,8 @@ public function resolveMethodCallSubstitutionAt(string $uri, int $byteOffset): ? $tuple = self::returnTypeToRef($method->returnType, $paramNames); if ($tuple !== null) { [$nullable, $ref] = $tuple; - $substituted = Specializer::substituteTypeRef($ref, $paramMap); + $substituted = self::relativeTypeToReceiver($ref, $receiverType) + ?? Specializer::substituteTypeRef($ref, $paramMap); $returnTypeRendered = (new ResolvedType($substituted, $nullable))->render(); } } @@ -448,6 +468,16 @@ public function resolveMemberAccessReceiverClassAt(string $uri, int $byteOffset) $expr = $expr->var; } } + // A plain non-generic receiver variable -- recorded only to type + // method-call receivers (see buildFromPlainNew) -- carries no generic + // context. Defer to worse-reflection, which models `@var` unions / + // nullables on the receiver that this single-class view would flatten. + if ($expr instanceof Variable && is_string($expr->name)) { + $binding = $bindings[$expr->name] ?? null; + if ($binding instanceof VarBinding && $binding->paramMap === []) { + return null; + } + } $type = self::inferType($expr, $bindings, $this->classes, $this->fqnIndex, [], ''); if ($type === null) { return null; @@ -1047,7 +1077,16 @@ private function handleAssign(Assign $node): void $rhs = $node->expr; if ($rhs instanceof New_) { - $binding = GenericResolver::buildFromNew($rhs, $this->classes); + // Generic `new Foo<...>()` -> binding with a paramMap; + // non-generic `new Foo()` -> class-only binding (empty + // paramMap) so method calls on the receiver still resolve. + $binding = GenericResolver::buildFromNew($rhs, $this->classes) + ?? GenericResolver::buildFromPlainNew( + $rhs, + $this->useMap, + $this->currentNamespace, + $this->classes, + ); if ($binding !== null) { $this->writeBinding($name, $binding); } @@ -1172,6 +1211,36 @@ public static function buildFromNew(New_ $new, ClassLikeLookup $classes): ?VarBi return self::buildFromName($class, $classes); } + /** + * Build a class-only `VarBinding` (empty paramMap) for a NON-generic + * `new Foo()`. This records the receiver's class FQN so method calls on + * the variable can resolve -- in particular a generic-method turbofish on + * a non-generic receiver, `$u->identity::(...)`, where the binding's + * job is purely to type the receiver `$u` as `Foo`; the method's own `T` + * is bound later from the call site. + * + * resolveVariable() deliberately renders nothing for empty-paramMap + * bindings, so plain-object hover still defers to worse-reflection -- this + * only feeds receiver inference and member completion. + * + * @param array $useMap + */ + public static function buildFromPlainNew( + New_ $new, + array $useMap, + string $currentNamespace, + ClassLikeLookup $classes, + ): ?VarBinding { + if (!$new->class instanceof Name) { + return null; + } + $fqn = self::resolveNameWithUseMap($new->class, $useMap, $currentNamespace); + if ($fqn === null || $classes->find($fqn) === null) { + return null; + } + return new VarBinding($fqn, []); + } + /** * Build a `VarBinding` from a `Name` node carrying * `ATTR_TEMPLATE_FQN` + `ATTR_GENERIC_ARGS`. Shared between the @@ -1319,13 +1388,22 @@ public static function resolveMethodCall( // an unconstrained or already fully-substituted scalar -- the // method's own substitution is a no-op, which is correct. $paramMap = self::paramMapFromReceiver($classLike, $receiverType); + $paramMap = self::withMethodTurbofish($paramMap, $call, $method); + // No type params in scope = nothing generic to substitute (a plain + // method on a non-generic receiver). Return null so the result isn't + // recorded as a binding and worse-reflection keeps ownership of it. + if ($paramMap === []) { + return null; + } $paramNames = array_keys($paramMap); [$nullable, $ref] = self::returnTypeToRef($returnType, $paramNames) ?? [null, null]; if ($ref === null) { return null; } - $substituted = Specializer::substituteTypeRef($ref, $paramMap); + // `static`/`self` bind to the receiver's concrete type, not a param. + $substituted = self::relativeTypeToReceiver($ref, $receiverType) + ?? Specializer::substituteTypeRef($ref, $paramMap); return new ResolvedType($substituted, $nullable); } @@ -1458,6 +1536,60 @@ private static function paramMapFromReceiver(ClassLike $classLike, ResolvedType return $map; } + /** + * Merge a generic method's OWN turbofish bindings into a paramMap. + * + * `$obj->identity::(...)` carries the explicit type args on the + * MethodCall node (`ATTR_METHOD_GENERIC_ARGS`); the method declaration + * carries the matching type params (`ATTR_METHOD_GENERIC_PARAMS`). + * Zipping them binds the method's own `T` to the call-site type, layered + * ON TOP of any class-level params already in `$paramMap` (a generic + * method on a generic class references both scopes). No-op when the call + * has no turbofish or the arity doesn't match. + * + * Without this, `resolveMethodCall` substitutes the return type against + * only the receiver's class params -- so a generic method on a NON-generic + * receiver (`Util::identity`) leaves `T` unbound. The static-call + * paths (`resolveStaticCall` / `resolveStaticCallSubstitutionAt`) already + * do exactly this; this is the instance-call equivalent. + * + * @param array $paramMap + * @return array + */ + private static function withMethodTurbofish(array $paramMap, Node $call, ClassMethod $method): array + { + $args = $call->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_ARGS); + if (!is_array($args) || $args === []) { + return $paramMap; + } + $params = $method->getAttribute(XphpSourceParser::ATTR_METHOD_GENERIC_PARAMS); + if (!is_array($params) || count($params) !== count($args)) { + return $paramMap; + } + foreach ($params as $i => $param) { + if ($param instanceof TypeParam && $args[$i] instanceof TypeRef) { + $paramMap[$param->name] = $args[$i]; + } + } + return $paramMap; + } + + /** + * Resolve a relative type (`static` / `self` / `$this`) to the receiver's + * concrete type. A method declared `: static` (or `: self`) returns an + * instance of the receiver's class, so `$a->fresh()` on `$a: Builder` + * is `Builder` -- not the literal keyword. `Specializer` only swaps + * type *params*, so without this the relative keyword passes through + * unsubstituted. Returns null when `$ref` isn't relative, so the caller + * falls back to normal type-param substitution. + */ + private static function relativeTypeToReceiver(TypeRef $ref, ResolvedType $receiver): ?TypeRef + { + return in_array(strtolower($ref->name), ['static', 'self', '$this'], true) + ? $receiver->ref + : null; + } + /** * Resolve a `Cls::method(...)` RHS. Reads the class via the * per-document use map (xphp's parser doesn't run nikic's NameResolver diff --git a/test/Resolver/GenericResolverTest.php b/test/Resolver/GenericResolverTest.php index 9af76ff..9297a81 100644 --- a/test/Resolver/GenericResolverTest.php +++ b/test/Resolver/GenericResolverTest.php @@ -225,6 +225,70 @@ public static function greet(): string { return ''; } self::assertNull($resolver->resolveVariable('/Use.xphp', 'g', PHP_INT_MAX)); } + public function testInstanceMethodTurbofishOnLocalVariableReceiverSpecializes(): void + { + // Regression for the `generic_method_local_variable_receiver` fixture: + // a generic method called with turbofish on a NON-generic, locally + // bound receiver. The method's own type-param T binds to the call-site + // type arg (int / string), independent of the receiver's (absent) + // class-level params. Previously the resolver consulted ONLY the + // receiver's class params, so T stayed unbound and `$i`/`$s` hovered as + // bare `T`. The static-call path (`Util::identity::(...)`) already + // worked; this pins the instance-call equivalent. + $workspace = $this->workspace(); + $this->open($workspace, '/Util.xphp', <<<'XPHP' + (T $x): T { return $x; } + } + XPHP); + $this->open($workspace, '/Use.xphp', <<<'XPHP' + identity::(99); + $s = $u->identity::('world'); + XPHP); + + $resolver = $this->resolver($workspace); + + self::assertSame('int', $resolver->resolveVariable('/Use.xphp', 'i', PHP_INT_MAX)); + self::assertSame('string', $resolver->resolveVariable('/Use.xphp', 's', PHP_INT_MAX)); + } + + public function testRelativeStaticReturnResolvesToReceiverType(): void + { + // Regression for the `generic_method_new_static_turbofish` fixture: + // `fresh(T $v): static` returns a relative (late-static-bound) type. + // On a `Builder` receiver that is the receiver's own concrete + // type, so `$b` is `Builder` -- NOT the literal `static`. + // Specializer only swaps type *params*; `static`/`self` must be + // bound to the receiver separately. + $workspace = $this->workspace(); + $this->open($workspace, '/Builder.xphp', <<<'XPHP' + { + public function __construct(public T $value) {} + public function fresh(T $v): static { return new static::($v); } + } + XPHP); + $this->open($workspace, '/Use.xphp', <<<'XPHP' + (1); + $b = $a->fresh(2); + XPHP); + + $resolver = $this->resolver($workspace); + + // Receiver itself specializes (sanity), and the `static` return + // resolves to that same concrete type rather than the keyword. + self::assertSame('App\\Builder', $resolver->resolveVariable('/Use.xphp', 'a', PHP_INT_MAX)); + self::assertSame('App\\Builder', $resolver->resolveVariable('/Use.xphp', 'b', PHP_INT_MAX)); + } + public function testGenericFunctionCallSubstitutesReturnType(): void { // Phase 1.3: free-function generic `identity::(new User())`. From c1002c381c0f443029efb5a7d53486c8c5a381dc Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Fri, 12 Jun 2026 10:24:50 +0000 Subject: [PATCH 21/22] build(deps): pin xphp-lang/xphp to released v0.2.0 Move off the `0.2.x-dev` branch dependency now that xphp v0.2.0 is tagged: constraint `0.2.x-dev` -> `^v0.2.0`, lock to the v0.2.0 tag, and drop the no-longer-needed dev stability flag. Co-Authored-By: Claude Opus 4.8 (1M context) --- composer.json | 2 +- composer.lock | 17 ++++++++--------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/composer.json b/composer.json index 11779b6..f743540 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,7 @@ "phpactor/language-server": "^6.0", "phpactor/language-server-protocol": "^3.5", "phpactor/worse-reflection": "^0.6.0", - "xphp-lang/xphp": "0.2.x-dev" + "xphp-lang/xphp": "^v0.2.0" }, "require-dev": { "phpunit/phpunit": "^13.0" diff --git a/composer.lock b/composer.lock index 634989a..4194c30 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "72f67bf29e6269404afb99825e80f61c", + "content-hash": "b218c60354ec016b8246b0e1c535dcdf", "packages": [ { "name": "amphp/amp", @@ -2760,16 +2760,16 @@ }, { "name": "xphp-lang/xphp", - "version": "0.2.x-dev", + "version": "v0.2.0", "source": { "type": "git", "url": "https://github.com/xphp-lang/xphp.git", - "reference": "2157a481fb4ed4cd28a265f1edb6a7f69252cae8" + "reference": "85ff0909c61ac02ba273eceaef35d594238e8fcf" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/xphp-lang/xphp/zipball/2157a481fb4ed4cd28a265f1edb6a7f69252cae8", - "reference": "2157a481fb4ed4cd28a265f1edb6a7f69252cae8", + "url": "https://api.github.com/repos/xphp-lang/xphp/zipball/85ff0909c61ac02ba273eceaef35d594238e8fcf", + "reference": "85ff0909c61ac02ba273eceaef35d594238e8fcf", "shasum": "" }, "require": { @@ -2779,6 +2779,7 @@ }, "require-dev": { "infection/infection": "^0.33", + "phpstan/phpstan": "^2.2", "phpunit/phpunit": "^13.0" }, "bin": [ @@ -2814,7 +2815,7 @@ "issues": "https://github.com/xphp-lang/xphp/issues", "source": "https://github.com/xphp-lang/xphp" }, - "time": "2026-06-07T07:10:44+00:00" + "time": "2026-06-11T22:34:01+00:00" } ], "packages-dev": [ @@ -4613,9 +4614,7 @@ ], "aliases": [], "minimum-stability": "dev", - "stability-flags": { - "xphp-lang/xphp": 20 - }, + "stability-flags": {}, "prefer-stable": true, "prefer-lowest": false, "platform": { From a9fe0d114067aacbbd21f38befafe63d5af1bb9a Mon Sep 17 00:00:00 2001 From: Matheus Martins Date: Fri, 12 Jun 2026 10:38:34 +0000 Subject: [PATCH 22/22] test(diagnostics): don't assume alphabetical filesystem iteration order The fs-skip regression test hardcoded that Other.xphp is walked before Tag.xphp (`assertSame($unwarmedPath, $walked[0])`). That holds on tmpfs-alphabetical Linux but not on the CI runner, where readdir returned Tag first and the assertion failed. Discover the actual iteration order with a throwaway index, then write the dropped dummy class into the first-iterated file and Tag into the second (content defines the FQN, not the filename). This preserves the break-vs-continue mutant detection (cache-missing path must precede Tag) regardless of the platform's directory order. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../XphpDiagnosticsProviderTest.php | 45 +++++++++++++------ 1 file changed, 31 insertions(+), 14 deletions(-) diff --git a/test/Diagnostics/XphpDiagnosticsProviderTest.php b/test/Diagnostics/XphpDiagnosticsProviderTest.php index 280788d..1402848 100644 --- a/test/Diagnostics/XphpDiagnosticsProviderTest.php +++ b/test/Diagnostics/XphpDiagnosticsProviderTest.php @@ -252,9 +252,6 @@ public function testIndexedPathsMissingFromCacheAreSkippedNotBrokenOutOfTheLoop( $root = sys_get_temp_dir() . '/xphp-diag-fs-skip-' . bin2hex(random_bytes(6)); mkdir($root, 0o755, true); try { - $unwarmedPath = $root . '/Other.xphp'; - $tagPath = $root . '/Tag.xphp'; - file_put_contents($unwarmedPath, "name; } } PHP; - file_put_contents($tagPath, $tagSource); $parser = new XphpSourceParser((new ParserFactory())->createForHostVersion()); + + // The break-vs-continue regression only surfaces when a + // cache-MISSING path is iterated BEFORE the path carrying Tag. + // Filesystem iteration order is NOT guaranteed alphabetical + // across platforms (it is not on CI), so discover the order with + // a throwaway index, then write the dummy class into the + // first-iterated file and Tag into the second — independent of + // which filename the platform happens to sort first. + $pathA = $root . '/A.xphp'; + $pathB = $root . '/B.xphp'; + file_put_contents($pathA, "indexedFilesystemPaths(); + self::assertCount(2, $order, 'both files must be indexed'); + [$firstPath, $secondPath] = $order; + // Rewriting keeps the same inode, so a fresh walk iterates in the + // same order (asserted below). + file_put_contents($firstPath, "warmNow(); $walked = $fqnIndex->indexedFilesystemPaths(); self::assertCount(2, $walked, 'both files must be indexed'); - // Sanity: tmpfs iteration is alphabetical on Linux (see the - // sibling ParsedDocumentCacheWarmerTest A_/B_ pattern). If a - // future filesystem changes that and Tag ends up first, this - // assertion would fail loudly rather than silently mask the - // break-mutant regression. - self::assertSame($unwarmedPath, $walked[0], 'Other.xphp must be iterated before Tag.xphp'); + self::assertSame($firstPath, $walked[0], 'iteration order must be stable across content rewrites'); $cache->forget('file://' . $walked[0]); // Box's template definition lives in an open buffer so it gets @@ -316,8 +333,8 @@ public function __construct(public T $item) {} // skipped). self::assertSame([], $diagnostics); } finally { - @unlink($root . '/Other.xphp'); - @unlink($root . '/Tag.xphp'); + @unlink($root . '/A.xphp'); + @unlink($root . '/B.xphp'); @rmdir($root); } }