diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4f82728..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` for `xphp.showReferences` (no-op server- - side; both clients dispatch `editor.action.showReferences` directly) - `semanticTokensProvider` (full file; standard LSP-spec token legend including `typeParameter`) - Pull-mode `diagnosticProvider` diff --git a/README.md b/README.md index e50d9f7..7901663 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). @@ -37,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. --- @@ -94,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/composer.json b/composer.json index 880ed94..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.1.0" + "xphp-lang/xphp": "^v0.2.0" }, "require-dev": { "phpunit/phpunit": "^13.0" diff --git a/composer.lock b/composer.lock index 6641300..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": "0fedef0b016eb60d0d633d35b8e188a3", + "content-hash": "b218c60354ec016b8246b0e1c535dcdf", "packages": [ { "name": "amphp/amp", @@ -2760,16 +2760,16 @@ }, { "name": "xphp-lang/xphp", - "version": "v0.1.0", + "version": "v0.2.0", "source": { "type": "git", "url": "https://github.com/xphp-lang/xphp.git", - "reference": "cd45ad04e194e954264a53b030ae35a32a380fcc" + "reference": "85ff0909c61ac02ba273eceaef35d594238e8fcf" }, "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/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-01T20:27:49+00:00" + "time": "2026-06-11T22:34:01+00:00" } ], "packages-dev": [ diff --git a/docs/features/index.md b/docs/features/index.md index c073189..9d0e6d4 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 @@ -151,6 +154,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 @@ -158,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. --- @@ -180,6 +198,12 @@ 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`). +- 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 @@ -187,7 +211,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 @@ -214,15 +238,20 @@ 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. --- ## 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 @@ -236,11 +265,25 @@ 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. +### Default type arguments (no false missing-arg) + +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 Fires when two files declare the same generic class / interface / @@ -257,7 +300,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 @@ -266,6 +309,18 @@ at compile time. Inference is intentionally narrow (literals, avoid false positives on arguments whose type would require flow 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. + --- ## Find @@ -276,9 +331,12 @@ 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. + 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 @@ -342,7 +400,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 @@ -370,7 +428,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 a7e2d1a..530937a 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -62,6 +62,21 @@ 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). 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. 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. 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 @@ -121,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. @@ -171,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. diff --git a/features/edit/bound_fixes.feature b/features/edit/bound_fixes.feature index 2a31f71..b6dac2d 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,9 +42,90 @@ 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 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 + + 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/find/completion.feature b/features/find/completion.feature index c88f420..4b9b861 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,37 @@ Feature: Completion """ {} + """ + And the file at "/Models.xphp" contains the following lines: + """ + (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 @@ -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/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..1e6e141 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" @@ -30,3 +30,76 @@ Feature: Hover And the hover contents contain "`T`" And the hover contents contain "App\Box" And the hover contents contain "Stringable" + + Scenario: Hover over a type parameter shows a composite bound + Given the file at "/pair.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" + + 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" + + 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 f34338c..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: """ (); + $users = new Collection::(); $first = $users->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/features/understand/semantic_tokens.feature b/features/understand/semantic_tokens.feature index a2d4afc..c40fd88 100644 --- a/features/understand/semantic_tokens.feature +++ b/features/understand/semantic_tokens.feature @@ -16,6 +16,29 @@ 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 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/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/features/validate/arg-types.feature b/features/validate/arg-types.feature index 1c4bbd8..e86f3c2 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 @@ -139,3 +139,128 @@ 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 + + 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" + + 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 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..1e86281 100644 --- a/features/validate/diagnostics.feature +++ b/features/validate/diagnostics.feature @@ -68,13 +68,30 @@ Feature: Diagnostics """ (); + $x = new Box::(); """ And the FQN index has been warmed on initialize When I analyze "/Use.xphp" for 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: """ @@ -106,7 +123,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/infection.json5 b/infection.json5 index 9a354e5..3b2c93d 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,29 @@ }, "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 + // 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 +255,51 @@ }, "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 + // 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. + "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 +361,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 +444,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 +514,22 @@ }, "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 + // 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 +558,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 +580,48 @@ // 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 + // 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 + // 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 + // 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 +707,40 @@ // 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 + // = 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 +839,28 @@ // 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 + // 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 +920,23 @@ // 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 + // 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 +988,21 @@ // '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 + // 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 +1059,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 +1095,44 @@ // 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 + // 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 + // 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 +1278,23 @@ // 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 + // 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", @@ -966,6 +1303,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 @@ -1016,6 +1367,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 +1384,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 +1421,63 @@ // 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": [ + // 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 + // 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 @@ -1114,6 +1540,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 @@ -1149,6 +1580,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 @@ -1258,6 +1694,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 +1877,36 @@ }, "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); + // 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 + // 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 +2083,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 +2259,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 +2343,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 +2493,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 @@ -2050,6 +2558,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 @@ -2090,6 +2612,29 @@ }, "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 + // 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/src/Analyzer/CallArgumentChecker.php b/src/Analyzer/CallArgumentChecker.php index 2154bd0..fd9854f 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,118 @@ 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 []; } + // 0.2.x lets a call site OMIT trailing args that have a declared + // 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); - if (count($names) !== count($args)) { - return []; + + // 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)) { + $substitution = []; + foreach ($names as $paramName) { + $substitution[$paramName] = new TypeRef($paramName, [], false, true); + } + return $substitution; } + $args = $this->padArgsWithDefaults($params, $args); + $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 +1005,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 +1039,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/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/Analyzer/WorkspaceAnalyzer.php b/src/Analyzer/WorkspaceAnalyzer.php index 2fabd9c..d3fbcfb 100644 --- a/src/Analyzer/WorkspaceAnalyzer.php +++ b/src/Analyzer/WorkspaceAnalyzer.php @@ -10,7 +10,10 @@ 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\BoundUnion; use XPHP\Transpiler\Monomorphize\Registry; use XPHP\Transpiler\Monomorphize\TypeHierarchy; use XPHP\Transpiler\Monomorphize\XphpSourceParser; @@ -296,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) { @@ -359,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, @@ -397,11 +425,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,17 +440,21 @@ public function buildBoundFixData( return null; } - $bound = ltrim((string) $typeParams[$index]->boundFqn, '\\'); + $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 ($hierarchy->isSubtype($candidateFqn, $bound) === true) { + if (BoundExprView::isSatisfiedBy($candidateFqn, $bound, $isSubtype)) { $short = strrpos($candidateFqn, '\\') !== false ? substr($candidateFqn, strrpos($candidateFqn, '\\') + 1) : $candidateFqn; @@ -431,17 +465,38 @@ 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) { + // 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; + } + } + } + return [ 'kind' => 'bound', 'param' => $typeParams[$index]->name, - 'bound' => $bound, + '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, $bound), + 'implementsInserts' => $implementsInserts, ]; } @@ -455,19 +510,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/src/Handler/SemanticTokens/AstVisitor.php b/src/Handler/SemanticTokens/AstVisitor.php index 2dd8064..d0bee7b 100644 --- a/src/Handler/SemanticTokens/AstVisitor.php +++ b/src/Handler/SemanticTokens/AstVisitor.php @@ -168,7 +168,36 @@ 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), 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) + ) { + // Anonymous generic closure / arrow declaration clause: + // `fn(…)`, `function(…)` -- the `<` follows the + // `fn` / `function` keyword (no name between). + $genericDepth = 1; + } elseif ($lastSignificantTokenId === T_DOUBLE_COLON) { + // Call-site turbofish: `Foo::`, `static::`, + // `$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) { @@ -295,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. @@ -366,6 +440,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; @@ -429,7 +520,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/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/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..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 @@ -119,7 +127,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 +156,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/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/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 9d75a0e..51ddb8a 100644 --- a/src/Handler/XphpHoverHandler.php +++ b/src/Handler/XphpHoverHandler.php @@ -20,11 +20,12 @@ 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; use XPHP\Transpiler\Monomorphize\TypeParam; use XPHP\Transpiler\Monomorphize\TypeRef; +use XPHP\Transpiler\Monomorphize\Variance; use XPHP\Transpiler\Monomorphize\XphpSourceParser; /** @@ -108,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, @@ -204,13 +205,16 @@ 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) : ''; + [$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, ); } @@ -219,6 +223,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 */ @@ -315,30 +335,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/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/src/LspDispatcherFactory.php b/src/LspDispatcherFactory.php index e5f94ae..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( @@ -369,7 +376,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), @@ -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/src/Reflection/FqnIndex.php b/src/Reflection/FqnIndex.php index a32f203..83a59d4 100644 --- a/src/Reflection/FqnIndex.php +++ b/src/Reflection/FqnIndex.php @@ -21,7 +21,9 @@ use SplFileInfo; use Throwable; 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; @@ -545,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> */ @@ -1589,7 +1642,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 === []) { @@ -1613,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/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/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/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/Analyzer/CallArgumentCheckerTest.php b/test/Analyzer/CallArgumentCheckerTest.php index a12a993..c8921e0 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, ]); @@ -126,6 +126,208 @@ 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 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 + // 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([ 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/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/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 diff --git a/test/Analyzer/WorkspaceAnalyzerTest.php b/test/Analyzer/WorkspaceAnalyzerTest.php index 87cb3c2..c3fe8ef 100644 --- a/test/Analyzer/WorkspaceAnalyzerTest.php +++ b/test/Analyzer/WorkspaceAnalyzerTest.php @@ -27,7 +27,7 @@ class Box '/Use.xphp' => <<<'PHP' (); + $x = new Box::(); PHP, ]); @@ -46,6 +46,160 @@ 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 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([ @@ -60,7 +214,7 @@ class Box '/Use.xphp' => <<<'PHP' (); + $x = new Box::(); PHP, ]); @@ -141,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([ @@ -163,7 +351,7 @@ public function __toString(): string { return ''; } '/Use.xphp' => <<<'PHP' (); + $x = new Box::(); PHP, ]); @@ -219,7 +407,7 @@ public function testHierarchyAstsEnrichBoundCheckWithoutBeingWalked(): void (new Tag('hi')); + $x = new Box::(new Tag('hi')); PHP, ]); $hierarchyAsts = $this->parseAstOnly([ @@ -270,7 +458,7 @@ public function __toString(): string { return ''; } (new Tag()); + $x = new Box::(new Tag()); PHP, ]); $hierarchyAsts = $this->parseAstOnly([ @@ -316,7 +504,7 @@ class User {} // no \Stringable (new User()); + $bad = new Box::(new User()); PHP, ]); $hierarchyAsts = $this->parseAstOnly([ diff --git a/test/Behat/EditContext.php b/test/Behat/EditContext.php index 724c34d..3feacfa 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 */ @@ -377,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/Diagnostics/XphpDiagnosticsProviderTest.php b/test/Diagnostics/XphpDiagnosticsProviderTest.php index 6880b30..1402848 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); @@ -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 @@ -302,7 +319,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); @@ -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); } } @@ -368,7 +385,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 +445,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 +481,7 @@ class Box { public T $item; } $useDoc = $this->openDoc($workspace, '/Use.xphp', <<<'XPHP' (); + $x = new Box::(); XPHP); $diagnostics = $this->lint($workspace, $useDoc); @@ -494,7 +511,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 +539,7 @@ class Box { public T $item; } $this->openDoc($workspace, '/Use.xphp', <<<'XPHP' (); + $x = new Box::(); XPHP); $token = (new CancellationTokenSource())->getToken(); @@ -547,7 +564,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/SemanticTokens/AstVisitorTest.php b/test/Handler/SemanticTokens/AstVisitorTest.php index f3a3ea9..1ac88f0 100644 --- a/test/Handler/SemanticTokens/AstVisitorTest.php +++ b/test/Handler/SemanticTokens/AstVisitorTest.php @@ -240,21 +240,167 @@ 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 `::`; the lowercase scalar `int` inside is typeParameter + // (the `::` makes the clause unambiguous against `<` comparison). + $source = "(\$x);"; + $specs = $this->collect($source); + $this->assertTokenSubstring($specs, $source, 'int', '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 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 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 + // type-arg clause opens. + $source = "collect($source); + $typeParamSpecs = array_filter($specs, fn (TokenSpec $s) => $s->type === 'typeParameter'); + self::assertEmpty($typeParamSpecs); + } + + public function testTurbofishWithLowercaseScalarFirstArgOpensClause(): void + { + // `Box::()` -- 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); + $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 + { + // 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. @@ -285,14 +431,55 @@ 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 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. @@ -482,15 +669,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; } } diff --git a/test/Handler/TurbofishScannerTest.php b/test/Handler/TurbofishScannerTest.php new file mode 100644 index 0000000..752c884 --- /dev/null +++ b/test/Handler/TurbofishScannerTest.php @@ -0,0 +1,260 @@ + '', '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)); + } + + 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'), $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')); + } + + 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 + { + $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)); + } + + 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 df233b0..cb2cb7a 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,17 +185,26 @@ 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)); } + + 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::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/Handler/XphpCompletionHandlerTest.php b/test/Handler/XphpCompletionHandlerTest.php index 8585adf..15b8134 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)); @@ -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 @@ -424,7 +486,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..80b7560 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); @@ -59,6 +59,99 @@ 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 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' + > + { + 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' @@ -306,7 +399,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 +420,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 +436,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 +489,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 +508,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 +530,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 +552,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 +578,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 +602,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 +616,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/Handler/XphpSignatureHelpHandlerTest.php b/test/Handler/XphpSignatureHelpHandlerTest.php index 3485471..76129b7 100644 --- a/test/Handler/XphpSignatureHelpHandlerTest.php +++ b/test/Handler/XphpSignatureHelpHandlerTest.php @@ -106,6 +106,70 @@ public function testStaticCallShowsMethodSignature(): void self::assertStringContainsString('$kind', $help->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/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, + ), ); } } 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 diff --git a/test/Resolver/BoundErrorCodeActionProviderTest.php b/test/Resolver/BoundErrorCodeActionProviderTest.php index fae6bfd..66ec48c 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); @@ -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 @@ -63,7 +84,7 @@ public function testWorkspaceClassConcreteOffersBothFixes(): void '/Stringy.xphp' => self::STRINGY, '/Box.xphp' => self::BOX, '/Money.xphp' => " "();\n", + '/Use.xphp' => "();\n", ]); $titles = self::titles($actions); @@ -84,17 +105,86 @@ 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'); $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); } + 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 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)); + } +} diff --git a/test/Resolver/GenericResolverTest.php b/test/Resolver/GenericResolverTest.php index 178a925..9297a81 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); @@ -225,9 +225,73 @@ 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())`. + // 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 +305,7 @@ function identity(T $x): T { return $x; } (new User()); + $u = identity::(new User()); XPHP); $resolver = $this->resolver($workspace); @@ -269,7 +333,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 +393,7 @@ public function first(): ?T { return null; } (); + $users = new Collection::(); $user = $users->first(); XPHP); @@ -547,7 +611,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 +619,7 @@ public function items(): Collection { return new Collection(); } (); + $repo = new Repository::(); $user = $repo->items()->first(); XPHP); @@ -576,10 +640,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 +654,7 @@ public function first(): ?T { return null; } (); + $w = new Wrap::(); $u = $w->inner()->items()->first(); XPHP); @@ -615,7 +679,7 @@ public function testClosureCapturesOuterBinding(): void (); + $users = new Collection::(); $fn = function () use ($users) { $first = $users->first(); }; @@ -644,7 +708,7 @@ public function testClosureWithoutUseClauseCannotSeeOuterBinding(): void (); + $users = new Collection::(); $fn = function () { $first = $users->first(); }; @@ -671,7 +735,7 @@ public function testClosureParamSeededAlongsideCapture(): void (); + $outer = new Collection::(); $fn = function (Collection $param) use ($outer) { $a = $param->first(); $b = $outer->first(); @@ -704,7 +768,7 @@ public function testRebuildsBindingsOnDocumentVersionBump(): void (); + $users = new Collection::(); $user = $users->first(); XPHP); @@ -830,7 +894,7 @@ class Tag {} (new Tag()); + $v = new StringableBox::(new Tag()); $item = $v->item; XPHP)); @@ -859,7 +923,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->'));