From 2242502f124e785bcd160bb6e540f2bf165422e6 Mon Sep 17 00:00:00 2001 From: Caleb White Date: Sat, 11 Jul 2026 19:50:33 -0500 Subject: [PATCH 1/2] feat: Laravel string key completion, hover, and diagnostics Add autocompletion, hover, and invalid-key diagnostics for route names, config keys, view names, and translation keys. Completion: - route('|') / to_route('|') -> route names from routes/*.php - config('|') / Config::get('|') -> config keys from config/*.php - view('|') / View::make('|') -> view templates from resources/views/ - __('|') / trans('|') / Lang::get('|') -> translation keys from lang/ - Route::resource() / apiResource() with ->only() / ->except() - Route::group([], __DIR__ . '/sub.php') file includes with prefix - Container attributes (#[Config], #[Database], #[Cache], #[Log], #[Storage], #[Auth]) with FQN-verified imports - Facade methods (Auth::guard(), DB::connection(), Cache::store(), Log::channel(), Storage::disk()) and auth() helper - TextEdit-based so dots don't break the completion popup Hover: - Shows key kind (Route/Config/View/Trans), the key value, and the file where it's defined Diagnostics: - Warns on unknown route names, config keys, view names, and translation keys (e.g. Unknown route: 'dashbaord') - Only flags plain string literals, not dynamic/interpolated keys Also adds to_route() to extraction spans for go-to-def/references. --- docs/CHANGELOG.md | 121 ++-- docs/todo/laravel.md | 49 -- src/completion/handler.rs | 19 +- src/completion/laravel_string_keys.rs | 796 +++++++++++++++++++++ src/completion/mod.rs | 1 + src/diagnostics/mod.rs | 202 ++++++ src/hover/mod.rs | 87 ++- src/lib.rs | 33 + src/parser/ast_update.rs | 4 + src/symbol_map/extraction.rs | 119 ++- src/virtual_members/laravel/mod.rs | 5 +- src/virtual_members/laravel/route_names.rs | 519 +++++++++++++- src/virtual_members/laravel/trans_keys.rs | 61 +- 13 files changed, 1824 insertions(+), 192 deletions(-) create mode 100644 src/completion/laravel_string_keys.rs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 34d9a2b7..0c0f8f8e 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -13,13 +13,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **PSR-4 mismatch diagnostics and rename-based moves.** Files now warn when the declared namespace or primary class name does not match the PSR-4 path or filename, with quick fixes to correct them. Renaming a class from its declaration now opens the full FQCN so you can move it between namespaces in one step, and renaming a namespace can rewrite multiple segments at once while moving PSR-4 directories and updating references across the project. Contributed by @calebdw. - **Case-sensitive autoloading diagnostic.** A class reference whose casing differs from the class's actual declaration is now flagged, with a quick fix to correct it. This catches the bug where code loads on a case-insensitive filesystem (macOS, Windows) but fails with a class-not-found error on Linux, because PSR-4 maps the name to a file path and path lookups are case-sensitive there. It covers `use` imports and inline references to autoloaded classes; built-in classes and same-file references, which never reach the autoloader, are left alone. - **Completion candidates ranked by dependency provenance.** Class, function, and constant completions are now sorted by origin tier: project code first, then core/stub symbols, then explicit Composer dependencies (`require` / `require-dev`), then transitive vendor dependencies last. The provenance is inferred from `composer.json` and `installed.json` during indexing. Contributed by @calebdw. -- **`update` command.** A new `phpantom_lsp update` subcommand downloads the latest release from GitHub and replaces the current binary. Supports `--check` (dry run, exit code 1 if update available) and `--no-confirm` (for CI). Handles `.tar.gz` (Unix) and `.zip` (Windows) archives across all 6 supported platforms. Contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/194. -- **`array_map` infers the output element type from its callback.** The result of `array_map` now reflects what the callback actually returns instead of assuming the input element type is preserved. An explicit return type hint is honoured, including scalars like `string` or `int`, so `array_map(fn(Item $item): string => $item->id, $items)` produces `list` rather than `list`. When the callback has no return type hint, the type is inferred from its body expression, so `array_map(fn($item) => $item->id, $items)` over a `list` also produces `list`. Fixes #147. (contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/195) -- **Static methods complete on instance access.** Member completion after `->` now offers a class's static methods alongside its instance methods, since PHP lets you call a static method through an instance (`$obj->make()`). Static properties remain excluded, as they are only reachable via `::`. Contributed by @calebdw in https://github.com/AJenbo/phpantom_lsp/pull/174. +- **`update` command.** A new `phpantom_lsp update` subcommand downloads the latest release from GitHub and replaces the current binary. Supports `--check` (dry run, exit code 1 if update available) and `--no-confirm` (for CI). Handles `.tar.gz` (Unix) and `.zip` (Windows) archives across all 6 supported platforms. Contributed by @calebdw in . +- **`array_map` infers the output element type from its callback.** The result of `array_map` now reflects what the callback actually returns instead of assuming the input element type is preserved. An explicit return type hint is honoured, including scalars like `string` or `int`, so `array_map(fn(Item $item): string => $item->id, $items)` produces `list` rather than `list`. When the callback has no return type hint, the type is inferred from its body expression, so `array_map(fn($item) => $item->id, $items)` over a `list` also produces `list`. Fixes #147. (contributed by @calebdw in ) +- **Static methods complete on instance access.** Member completion after `->` now offers a class's static methods alongside its instance methods, since PHP lets you call a static method through an instance (`$obj->make()`). Static properties remain excluded, as they are only reachable via `::`. Contributed by @calebdw in . - **Array-callable navigation.** Method-name strings in array callables — `[Controller::class, 'method']` and `[$object, 'method']` — now resolve like a real member reference. This makes go-to-definition, find-references, and rename work on Laravel controller actions such as `Route::get('/', [IndexPageController::class, 'indexPage'])`. - **Array-callable method completion.** Typing inside the method-name string of an array callable (`[Controller::class, '|']`) now offers method name completions from the resolved class, including inherited and trait methods. Works with `Class::class` constants, `$this`, and typed variables. (thanks @calebdw) -- **Convert arrow function to closure.** A new `refactor.rewrite` code action converts arrow functions to anonymous closures (`fn($x) => $x * 2` to `function($x) { return $x * 2; }`). Variables from the outer scope are automatically captured via a `use()` clause. Preserves `static` and return type hints. Contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/191. -- **`@phpstan-sealed` tag support.** The `@phpstan-sealed FooClass|BarClass` PHPDoc tag is now recognized. Class names in the tag are treated as type references, preventing false "unused import" diagnostics. Docblock completion also offers the tag. (contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/190) +- **Convert arrow function to closure.** A new `refactor.rewrite` code action converts arrow functions to anonymous closures (`fn($x) => $x * 2` to `function($x) { return $x * 2; }`). Variables from the outer scope are automatically captured via a `use()` clause. Preserves `static` and return type hints. Contributed by @calebdw in . +- **`@phpstan-sealed` tag support.** The `@phpstan-sealed FooClass|BarClass` PHPDoc tag is now recognized. Class names in the tag are treated as type references, preventing false "unused import" diagnostics. Docblock completion also offers the tag. (contributed by @calebdw in ) - **Magic methods complete when implemented.** Magic methods declared on a class (`__invoke`, `__toString`, `__call`, and the rest) are now offered in member completion, so explicit calls like `$x->__invoke()` autocomplete and support go-to-definition. They are sorted below the regular methods so they never appear at the top of the list. - **Staleness detection and auto-refresh.** The class index, function index, and constant index now stay fresh automatically. When PHP files are created or deleted outside the editor (e.g. `git checkout`, code generation), the indices update without a restart, and edits made outside the editor are reflected the next time the file is used. When `composer.json` or `composer.lock` changes (e.g. after `composer install`), vendor packages are rescanned automatically. - **`#[ArrayShape]` attribute support.** Functions and methods annotated with `#[ArrayShape(["key" => "type", ...])]` (used by ~84 phpstorm-stubs entries) now produce array shape key completions, hover type info, and correct type resolution. Affects commonly used functions like `parse_url`, `stat`, `pathinfo`, `gc_status`, `getimagesize`, and `session_get_cookie_params`. @@ -27,18 +27,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Convert switch to match.** A new `refactor.rewrite` code action converts `switch` statements to `match` expressions when all arms are single-expression returns or assignments to the same variable. Handles fall-through cases (merged with commas), trailing `break` removal, and `throw` arms. Requires PHP >= 8.0. - **Extract interface.** A new `refactor.extract` code action generates an interface from a concrete class. All public method signatures (excluding the constructor) are extracted into a new `{ClassName}Interface.php` file in the same directory, and the class is updated with `implements {ClassName}Interface`. Class-level and method-level `@template` tags are preserved when referenced by extracted methods. - **`@template` on `@method` tags.** Virtual methods declared via `@method` PHPDoc tags can now define their own template parameters using the `` syntax (e.g. `@method TVal get(TVal $default)`). Template inference at call sites works the same as for real methods. -- **Laravel custom Eloquent builder support.** Models using the `#[UseEloquentBuilder]` attribute now have their custom builder's methods forwarded as static methods on the model. `query()`, `newQuery()`, and `newModelQuery()` return the custom builder type with correct generic model substitution. Contributed by @MingJen in https://github.com/AJenbo/phpantom_lsp/pull/118. +- **Laravel custom Eloquent builder support.** Models using the `#[UseEloquentBuilder]` attribute now have their custom builder's methods forwarded as static methods on the model. `query()`, `newQuery()`, and `newModelQuery()` return the custom builder type with correct generic model substitution. Contributed by @MingJen in . - **Eloquent relation and column string completion.** Typing inside string arguments to `with()`, `load()`, `whereHas()`, and other Eloquent methods that accept relation names now offers relationship method names as completions, with dot-notation traversal for nested relations. Similarly, `where()`, `orderBy()`, `select()`, `pluck()`, and other column-accepting methods offer model column names (from `$casts`, `$fillable`, `@property` tags, timestamps, etc.). - **Authenticated user resolves to the configured model.** `$request->user()`, `auth()->user()`, and `Auth::user()` now resolve to the Eloquent model declared in `config/auth.php` instead of only the bare `Authenticatable` contract, so completion, hover, and member access work on the concrete model (`$request->user()->email`). Naming a guard selects that guard's model, so `auth('admin')->user()`, `Auth::guard('admin')->user()`, and `$request->user('admin')` resolve to the model configured for the `admin` guard rather than the default one. The config is read statically: only the literal default of `env('AUTH_MODEL', User::class)` is used, never the runtime environment. When a guard, provider, or model could vary at runtime, the result widens to a union of every candidate, and the floor is raised from the abstract contract to the project's own classes that implement it, so a single-model app resolves to just that model while a multi-model app offers each. Members that exist on some candidate resolve; genuinely unknown members still report. - **Laravel macros are recognized as real methods.** A method registered with `SomeClass::macro('name', fn (...) => ...)`, whether in your own service providers or in an installed package's, now appears in completion on that class, shows the closure's parameters and return type on hover and in signature help, and resolves for member access and chaining. Go-to-definition on a macro call jumps to its `::macro(...)` registration site, landing on the first character of the macro name string. Both instance (`$collection->name()`) and static (`SomeClass::name()`) calls work. A macro registered through a facade (`View::macro('extends', ...)`) also attaches to the concrete class the facade resolves to, so an instance call on that class (`$factory->extends()`) resolves as well as the static facade call. Discovery now follows provider-rooted helper classes in both app code and installed packages, whether the provider references the helper through a static call, `Foo::class`, or `new Foo()`, and also recognizes typed variable registrations like `Builder $query` followed by `$query->macro(...)`, including inside callbacks such as `function (Builder $builder) { $builder->macro(...); }`. Find-references and rename now link the registration string with macro call sites in both directions, including chained collection-style calls such as `->pluck(...)->macroName()`, and workspace symbol maps are warmed in the background so repeated workspace-wide rename/reference requests avoid reparsing unopened files. - **Container string aliases and global facades resolve.** `resolve('blade.compiler')` and `app('cache')` resolve to the concrete class Laravel binds the string to, so member access on the result completes, navigates, and type-checks, whether the call is chained directly or its result is first assigned to a variable. Bare global facade aliases such as `\App` and `\DB` resolve to their facade class without an explicit import. Both alias tables are read by parsing the framework the project actually has installed (never a version-specific list baked into PHPantom), so a name that only a service provider registers stays unresolved rather than being guessed. A project class whose short name collides with a facade alias (e.g. an app's own `Request` in the current namespace) still wins, since the alias table is only consulted after namespace-aware resolution misses. - **`model-property` pseudo-type recognition.** The Larastan `model-property` type no longer triggers "unknown class" diagnostics. It is treated as a string subtype. -- **`compact()` strings are linked to local variables.** A string argument to `compact('user')` is now treated as a reference to the matching local variable. Renaming the variable updates the string (and renaming from the string updates the variable and its other uses), find-references includes the string, and go-to-definition on the string jumps to the variable's assignment. Contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/159. +- **`compact()` strings are linked to local variables.** A string argument to `compact('user')` is now treated as a reference to the matching local variable. Renaming the variable updates the string (and renaming from the string updates the variable and its other uses), find-references includes the string, and go-to-definition on the string jumps to the variable's assignment. Contributed by @calebdw in . +- **Laravel string key completion (route, config, view, trans).** Typing inside the first string argument of `route()`, `to_route()`, `config()`, `Config::get()`, `view()`, `View::make()`, `__()`, `trans()`, `Lang::get()`, and related helpers now offers autocompletion from the project's actual route names, config keys, view templates, and translation keys. Route names are collected from `routes/*.php` (including group prefixes and `Route::group([], __DIR__ . '/sub.php')` file includes), `Route::resource()` and `Route::apiResource()` generate conventional named routes (`index`, `create`, `store`, `show`, `edit`, `update`, `destroy`) respecting `->only()` and `->except()` modifiers, config keys from `config/*.php` array declarations, view names from `resources/views/` file paths, and translation keys from `lang/` files. Go-to-definition on route names also follows file includes and resolves resource routes. Laravel container attributes (`#[Config('key')]`, `#[Database('conn')]`, `#[Cache('store')]`, `#[Log('channel')]`, `#[Storage('disk')]`, `#[Auth('guard')]`) offer completion from the relevant config sub-keys (e.g. `#[Database('')]` shows database connection names from `config/database.php`). Facade methods like `Auth::guard()`, `DB::connection()`, `Cache::store()`, `Log::channel()`, `Storage::disk()`, and the `auth()` helper also complete from their respective config sub-keys. Contributed by @calebdw. +- **Hover for Laravel string keys.** Hovering over a route name, config key, view name, or translation key string now shows the key kind, the key value, and where it's defined (e.g. `routes/web/ems.php`, `config/app.php`). Contributed by @calebdw. +- **Diagnostics for invalid Laravel string keys.** A typo in `route('dashbaord')`, `config('app.naem')`, `view('layouts.ap')`, or `__('auth.failedd')` now produces a warning: `Unknown route: 'dashbaord'`. Only plain string literals are flagged; dynamic keys with variables are ignored. Contributed by @calebdw. - **Imported and same-namespace symbols rank first in completion.** Classes, functions, and constants that are already imported via a `use` statement or live in the same namespace now always appear above non-imported symbols in the completion list, regardless of dependency provenance. Previously a non-imported project class could outrank an already-imported vendor class, forcing users to scroll past irrelevant results. Contributed by @calebdw. - **Laravel route controller method navigation and completion.** Method-name strings inside `Route::controller(X::class)->group(fn(){…})` closures now resolve as references to the controller's methods. Go-to-definition, find-references, rename, hover, and diagnostics all work on the action string (e.g. `Route::patch('cancel', 'cancel')` resolves `'cancel'` to `WorkItemController::cancel()`). Autocompletion inside the action string offers the controller's methods. Handles `->controller()` anywhere in the fluent chain, chained route calls (`->name()`, etc.), and nested groups where an inner `->controller()` shadows the outer one. Contributed by @calebdw. - **Package provenance displayed in hover.** Hovering over a class, method, property, constant, or function now shows a colored badge indicating where the symbol comes from: 🟢 for direct Composer dependencies (e.g. `laravel/framework`), 🟠 for transitive dependencies with an italic *(transitive)* marker, and 🟣 for PHP core/extension symbols. Project-local symbols show no badge. The package name is resolved from `vendor/composer/installed.json`. Closes #228. Contributed by @calebdw. - **Diagnostic ignore rules in `.phpantom.toml`.** A new `[[diagnostics.ignore]]` config section suppresses matching diagnostics project-wide, similar to PHPStan's `ignoreErrors`. Each rule can constrain by file path (glob), message (regex), and/or diagnostic code, so a project can silence known-noisy paths (test fixtures, vendored code with unavailable stubs) without editor-only `@phpantom-ignore` comments scattered through the codebase. -- **Built-in formatter respects `mago.toml`.** When formatting falls back to the embedded formatter, a `mago.toml` at the workspace root is now honoured, applying its `[formatter]` preset and settings instead of the PER-CS 2.0 defaults. Contributed by @enwi in https://github.com/PHPantom-dev/phpantom_lsp/pull/233. +- **Built-in formatter respects `mago.toml`.** When formatting falls back to the embedded formatter, a `mago.toml` at the workspace root is now honoured, applying its `[formatter]` preset and settings instead of the PER-CS 2.0 defaults. Contributed by @enwi in . - **Rename updates `$param` in conditional return types.** Renaming a function parameter now also renames references to that parameter inside PHPDoc conditional return type annotations (`@return ($param is true ? T : U)`), including nested conditionals. Previously the `@param` tag and function body were updated but the `@return` conditional was left stale. Contributed by @calebdw. - **`@param-closure-this` support in hover, go-to-definition, and go-to-type-definition.** Hovering on `$this` inside a closure whose enclosing call site declares `@param-closure-this` now shows the overridden type instead of the lexically enclosing class. Go-to-definition and go-to-type-definition on `$this` likewise jump to the overridden class declaration. Previously only completion resolved the override. Contributed by @calebdw. - **Path-repository packages included in PSR-4 mappings.** Local Composer packages installed via path repositories (e.g. `internachi/modular` modules) are now discovered from `vendor/composer/installed.json` and their PSR-4 autoload entries are included in the project's namespace mappings. This fixes macro scanning, class resolution, and future namespace validation for modular Laravel projects where application code lives outside the root `composer.json`'s own PSR-4 directories. Only packages whose files live outside `vendor/` (symlinked in from a module directory such as `app-modules/`) count as project source; a path repository that resolves back inside `vendor/` is treated as an ordinary dependency, so it is indexed for type resolution but not analyzed as your own code. Contributed by @calebdw. @@ -152,29 +155,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **External formatters no longer corrupt the connection.** Running `php-cs-fixer` or PHP_CodeSniffer for document formatting could kill the language server: the tool inherited the editor's input channel and consumed bytes meant for the server, dropping the connection and forcing a restart. Formatters now run with their input isolated. A timeout also names the tool that was too slow (`php-cs-fixer timed out after 10000ms`) so the culprit is clear, and the timeout can be raised with `[formatting] timeout` in `.phpantom.toml`. Fixes #149. - **"Go to Declaration or Usages" from a declaration now lists usages.** Invoking go-to-definition while the cursor is on a class, interface, trait, enum, or member declaration returns the symbol's usages instead of the declaration's own location. Editors that navigate straight to the definition result (such as PHPStorm) previously did nothing but move the cursor onto the name; they now jump to the usage or show the usage list. Fixes #125. - **Generator closures now propagate template params through `make()`-style methods.** When a closure containing `yield` expressions is passed to a method with a union param type like `iterable|(Closure(): Generator)`, the yielded value and key types are inferred from the closure body (casts, literals) and used to bind the method's template parameters. This fixes `LazyCollection::make(function() { yield (string) $x; })` resolving as `LazyCollection` instead of `LazyCollection`. Contributed by @calebdw. -- **Foreach key type now inferred from `implements_generics`.** When iterating over a class like `Finder` that implements `IteratorAggregate`, the foreach key variable (`$filePath`) was falling back to `int|string` because only the type's own generic parameters were checked. The key binding now falls through to the class's `implements_generics` and `extends_generics`, mirroring the existing value-type fallback. Contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/216. -- **`Generic[]` docblock types now correctly resolve to arrays.** The `[]` array suffix was dropped when it followed a generic type (e.g. `ReflectionAttribute[]`), a brace-delimited shape (e.g. `array{id: int}[]`), or a parenthesized group. The type tokenizer now consumes trailing `[]` suffixes before splitting on union/intersection operators, so these types parse correctly. Contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/215. +- **Foreach key type now inferred from `implements_generics`.** When iterating over a class like `Finder` that implements `IteratorAggregate`, the foreach key variable (`$filePath`) was falling back to `int|string` because only the type's own generic parameters were checked. The key binding now falls through to the class's `implements_generics` and `extends_generics`, mirroring the existing value-type fallback. Contributed by @calebdw in . +- **`Generic[]` docblock types now correctly resolve to arrays.** The `[]` array suffix was dropped when it followed a generic type (e.g. `ReflectionAttribute[]`), a brace-delimited shape (e.g. `array{id: int}[]`), or a parenthesized group. The type tokenizer now consumes trailing `[]` suffixes before splitting on union/intersection operators, so these types parse correctly. Contributed by @calebdw in . - **Edited functions and constants no longer go stale.** Deleting or renaming a standalone function, or changing a `define()`/`const` value, is now reflected immediately in completion, hover, and go-to-definition. Previously a removed function kept being offered and jumped to a stale location, and editing a constant's value kept showing the old value, for the rest of the editing session. - **Reloaded files no longer leave ghost classes.** When a file loaded outside the editor (a vendor file, a bundled stub, or a file re-opened after being closed) is parsed again after its contents changed, a class that was renamed or removed no longer keeps resolving from its old definition. Go-to-implementation and type hierarchy stop listing classes that no longer extend a parent, and completion no longer surfaces the deleted class. - **Integer literals now satisfy integer range parameter types.** Argument diagnostics now treat literal integers as valid for `int` and `int` constraints when the value falls within the declared bounds. This fixes false positives like `usleep(10_000)` against `int<0, max>` and Laravel-style calls such as `repeatEvery(1)` against `int<1, 59>`. Contributed by @calebdw. -- **`+=` on arrays now infers `array` instead of `int|float`.** The compound assignment operator `+=` is overloaded in PHP: it performs array union when both operands are arrays, but PHPantom was unconditionally treating it as numeric addition. The binary `+` operator already handled this correctly; the `+=` path now mirrors that logic. Contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/214. -- **Diagnostics now update after function signature changes.** Editing a standalone function's parameter or return type in one file (e.g. changing `bar(null $x)` to `bar(string $x)`) did not refresh diagnostics in other open files that call that function, so stale errors persisted until the editor was restarted. The server now tracks function signature changes (not just class signatures) and refreshes affected open files on save, without flashing false errors into unrelated buffers during editing. Same-file diagnostics continue to update on every keystroke. A `textDocument/didSave` handler was also added as a reliable refresh point for editors like Neovim. Fixes #123. Contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/196. -- **External tool diagnostics (PHPStan, PHPCS, Mago) now run on save only.** Previously these expensive tools were scheduled on every keystroke with a debounce timer, which could block save-triggered runs and delay results by seconds. They now fire immediately when a file is opened or saved, with no debounce. External tool workers also send `workspace/diagnostic/refresh` in pull mode so editors see results without requiring a `didChange` event. Contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/196. -- **`@phpstan-ignore` with reasons now suppresses diagnostics immediately.** Adding a `@phpstan-ignore return.type (reason)` comment did not clear the cached PHPStan diagnostic until PHPStan re-ran (~10 seconds). The stale diagnostic filter treated everything between `@phpstan-ignore` and `*/` as the identifier list, so the parenthesized reason text caused the match to fail. The parser now strips `(reason)` from each comma-separated entry before matching, correctly handling per-identifier reasons, multiple identifiers, and reason text containing commas. Contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/196. -- **Literal type matching in argument diagnostics.** String, integer, and float literal arguments now match PHPDoc literal-union parameter types. For example, `orderBy('id', 'desc')` no longer produces a bogus error when the parameter is typed as `'asc'|'desc'`. Conversely, passing a provably wrong literal (e.g. `'invalid'` to `'asc'|'desc'`, or `'hello'` to `numeric-string`) is now correctly flagged. Fixes #180. Contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/191. -- **String indexed assignment no longer widens type to array.** Bracket-indexed assignment on a string variable (`$str[0] = 'z'`) no longer changes the variable's type from `string` to `array`. In PHP this operation modifies the string in-place, so the type is now correctly preserved. Contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/209. -- **`mixed` no longer behaves like a scalar in array access and member diagnostics.** Accessing a key on an `array` parameter (e.g. `$body['key']`) was incorrectly returning an empty type because `mixed` was treated like a scalar and skipped by the element-type extractor. This caused ternary expressions like `true ? $body['key'] : null` to resolve as `null` instead of `mixed|null`, producing false type-mismatch diagnostics. The same misclassification could also surface unverifiable-member warnings on values typed as `mixed`. Contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/210. -- **`@see self::member()` references in class docblocks now navigate correctly.** Docblock `@see` tags already supported `ClassName::member()` references, but `self::member()` was being dropped during docblock symbol extraction, so go-to-definition could not follow it. Class docblocks can now refer to their own methods and members with `self::...` just like normal PHP code. Contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/212. -- **Grouped imports resolve correctly across diagnostics and navigation.** Grouped `use` imports now work the same whether they are written on one line or split across multiple lines. Previously, multiline grouped imports could produce false unknown-class diagnostics because only single-line import declarations were skipped by the diagnostic walker, and go-to-definition on a class name inside a grouped `use` declaration could fail because the grouped item was recorded without its namespace prefix. Imported names inside grouped `use` declarations are now handled correctly for both unknown-class diagnostics and go-to-definition. Contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/213. -- **Laravel `Conditionable::when()` template inference no longer falls back to missing `null` defaults.** Method template binding now avoids inferring template parameters from omitted `null` defaults except in the few cases where defaults are actually meaningful for template resolution. This fixes false `type_mismatch_argument` diagnostics on calls like `when($request->integer(...), fn ($q, $id) => ...)`, where the callback parameter type was being collapsed to `null` instead of the concrete argument type. Contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/205. -- **`declare(strict_types=1)` detection.** The LSP now reads the `declare(strict_types=1)` directive from the calling file and tightens argument type checking accordingly. Under strict types, implicit coercions that PHP normally allows in function calls, such as int/float to string and numeric-string to int/float, are flagged as type errors. The int-to-float exception is preserved, concatenation is unaffected, and literal numeric forms now retain their kind during checking instead of being flattened to plain strings. Contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/193. -- **Type Hierarchy works in more clients.** The `textDocument/prepareTypeHierarchy` capability was registered without registration options, so some clients (notably Zed) did not reliably expose the Type Hierarchy action. The dynamic registration now carries proper `TypeHierarchyRegistrationOptions` with a PHP document selector, so those clients recognise that the feature applies to PHP files. Contributed by @sidux in https://github.com/PHPantom-dev/phpantom_lsp/pull/179. +- **`+=` on arrays now infers `array` instead of `int|float`.** The compound assignment operator `+=` is overloaded in PHP: it performs array union when both operands are arrays, but PHPantom was unconditionally treating it as numeric addition. The binary `+` operator already handled this correctly; the `+=` path now mirrors that logic. Contributed by @calebdw in . +- **Diagnostics now update after function signature changes.** Editing a standalone function's parameter or return type in one file (e.g. changing `bar(null $x)` to `bar(string $x)`) did not refresh diagnostics in other open files that call that function, so stale errors persisted until the editor was restarted. The server now tracks function signature changes (not just class signatures) and refreshes affected open files on save, without flashing false errors into unrelated buffers during editing. Same-file diagnostics continue to update on every keystroke. A `textDocument/didSave` handler was also added as a reliable refresh point for editors like Neovim. Fixes #123. Contributed by @calebdw in . +- **External tool diagnostics (PHPStan, PHPCS, Mago) now run on save only.** Previously these expensive tools were scheduled on every keystroke with a debounce timer, which could block save-triggered runs and delay results by seconds. They now fire immediately when a file is opened or saved, with no debounce. External tool workers also send `workspace/diagnostic/refresh` in pull mode so editors see results without requiring a `didChange` event. Contributed by @calebdw in . +- **`@phpstan-ignore` with reasons now suppresses diagnostics immediately.** Adding a `@phpstan-ignore return.type (reason)` comment did not clear the cached PHPStan diagnostic until PHPStan re-ran (~10 seconds). The stale diagnostic filter treated everything between `@phpstan-ignore` and `*/` as the identifier list, so the parenthesized reason text caused the match to fail. The parser now strips `(reason)` from each comma-separated entry before matching, correctly handling per-identifier reasons, multiple identifiers, and reason text containing commas. Contributed by @calebdw in . +- **Literal type matching in argument diagnostics.** String, integer, and float literal arguments now match PHPDoc literal-union parameter types. For example, `orderBy('id', 'desc')` no longer produces a bogus error when the parameter is typed as `'asc'|'desc'`. Conversely, passing a provably wrong literal (e.g. `'invalid'` to `'asc'|'desc'`, or `'hello'` to `numeric-string`) is now correctly flagged. Fixes #180. Contributed by @calebdw in . +- **String indexed assignment no longer widens type to array.** Bracket-indexed assignment on a string variable (`$str[0] = 'z'`) no longer changes the variable's type from `string` to `array`. In PHP this operation modifies the string in-place, so the type is now correctly preserved. Contributed by @calebdw in . +- **`mixed` no longer behaves like a scalar in array access and member diagnostics.** Accessing a key on an `array` parameter (e.g. `$body['key']`) was incorrectly returning an empty type because `mixed` was treated like a scalar and skipped by the element-type extractor. This caused ternary expressions like `true ? $body['key'] : null` to resolve as `null` instead of `mixed|null`, producing false type-mismatch diagnostics. The same misclassification could also surface unverifiable-member warnings on values typed as `mixed`. Contributed by @calebdw in . +- **`@see self::member()` references in class docblocks now navigate correctly.** Docblock `@see` tags already supported `ClassName::member()` references, but `self::member()` was being dropped during docblock symbol extraction, so go-to-definition could not follow it. Class docblocks can now refer to their own methods and members with `self::...` just like normal PHP code. Contributed by @calebdw in . +- **Grouped imports resolve correctly across diagnostics and navigation.** Grouped `use` imports now work the same whether they are written on one line or split across multiple lines. Previously, multiline grouped imports could produce false unknown-class diagnostics because only single-line import declarations were skipped by the diagnostic walker, and go-to-definition on a class name inside a grouped `use` declaration could fail because the grouped item was recorded without its namespace prefix. Imported names inside grouped `use` declarations are now handled correctly for both unknown-class diagnostics and go-to-definition. Contributed by @calebdw in . +- **Laravel `Conditionable::when()` template inference no longer falls back to missing `null` defaults.** Method template binding now avoids inferring template parameters from omitted `null` defaults except in the few cases where defaults are actually meaningful for template resolution. This fixes false `type_mismatch_argument` diagnostics on calls like `when($request->integer(...), fn ($q, $id) => ...)`, where the callback parameter type was being collapsed to `null` instead of the concrete argument type. Contributed by @calebdw in . +- **`declare(strict_types=1)` detection.** The LSP now reads the `declare(strict_types=1)` directive from the calling file and tightens argument type checking accordingly. Under strict types, implicit coercions that PHP normally allows in function calls, such as int/float to string and numeric-string to int/float, are flagged as type errors. The int-to-float exception is preserved, concatenation is unaffected, and literal numeric forms now retain their kind during checking instead of being flattened to plain strings. Contributed by @calebdw in . +- **Type Hierarchy works in more clients.** The `textDocument/prepareTypeHierarchy` capability was registered without registration options, so some clients (notably Zed) did not reliably expose the Type Hierarchy action. The dynamic registration now carries proper `TypeHierarchyRegistrationOptions` with a PHP document selector, so those clients recognise that the feature applies to PHP files. Contributed by @sidux in . - **Extract method generates correct code for more selections.** A variable that the selection reads before it first assigns (for example a parameter the extracted code both consults and updates) is now passed in as an argument as well as returned, instead of being left undefined inside the new method. And an early `return` whose value references a variable defined inside the selection is now kept inside the extracted method and propagated to the caller, instead of being copied to the call site where that variable does not exist. - **The editor stays responsive during fast typing in large files.** Editors send a burst of requests on every keystroke (completion, a documentation lookup for each suggestion, diagnostics, code lens, semantic highlighting, and more). The server processed only a few at a time, so during continuous typing the burst backed up until the server stopped answering anything at all, including the completion the user was waiting on, and it only recovered after a restart. Now the burst is processed concurrently and every expensive request runs off the main loop: diagnostics (which re-analyze the whole file on each edit) compute in the background instead of on the request that asked for them, so a diagnostic pull returns immediately and never blocks the threads that deliver completion and hover, and repeated whole-file requests (semantic highlighting, code lens, the document outline, folding, document links) are collapsed so a fast typist's superseded requests no longer pile up and monopolize the CPU. Completion and other requests keep coming back while you type. - **Typing in a large file no longer pegs the CPU and stalls completion.** Semantic highlighting recomputed every token's position by rescanning the file from the beginning, so a large file took many seconds at full CPU to highlight. Editors request highlighting on every keystroke, so this ran continuously while typing and starved completion, hover, and other requests until they appeared to hang. Highlighting a large file is now effectively instant, and the same speedup applies to the document outline and code folding, which used the same per-position rescan. - **The first use of a global helper function no longer stalls.** Functions defined in Composer "files" autoload entries and guarded by `if (! function_exists(...))` (such as Laravel's `app()`, `session()`, and `route()`) were parsed on demand the first time one was used, which meant the first completion, hover, or go-to-definition involving such a helper blocked while the server parsed every autoload file in turn. These files are now parsed up front during indexing, so the first lookup is instant. -- **Framework global helpers loaded outside Composer autoload are now indexed.** Some frameworks ship their global function aliases in a `*_global.php` file that sits beside an autoloaded helper file but is pulled in by the framework's own bootstrap rather than Composer's `files` autoload, so it never appears in the autoload manifest. CakePHP is the canonical case: helpers like `__()`, `h()`, and `env()` live in such a sibling and were reported as unknown functions on every call. These sibling helper files are now indexed too, so the globals resolve. Contributed by @dereuromark in https://github.com/AJenbo/phpantom_lsp/pull/175. -- **Classes defined inside conditional blocks are now fully resolved.** A class declared inside an `if`/`else` version guard (the Doctrine `ServiceEntityRepository` pattern, where a base class is defined differently per ORM version) was previously discovered by name only, so its parent and `@extends` generics were dropped. Such classes now carry their full inheritance, so member completion, hover, go-to-definition, and generic type resolution work both on them and inside their own methods. When the same class name appears in more than one branch, the first declaration wins. Contributed by @MrSrsen in https://github.com/PHPantom-dev/phpantom_lsp/pull/154. +- **Framework global helpers loaded outside Composer autoload are now indexed.** Some frameworks ship their global function aliases in a `*_global.php` file that sits beside an autoloaded helper file but is pulled in by the framework's own bootstrap rather than Composer's `files` autoload, so it never appears in the autoload manifest. CakePHP is the canonical case: helpers like `__()`, `h()`, and `env()` live in such a sibling and were reported as unknown functions on every call. These sibling helper files are now indexed too, so the globals resolve. Contributed by @dereuromark in . +- **Classes defined inside conditional blocks are now fully resolved.** A class declared inside an `if`/`else` version guard (the Doctrine `ServiceEntityRepository` pattern, where a base class is defined differently per ORM version) was previously discovered by name only, so its parent and `@extends` generics were dropped. Such classes now carry their full inheritance, so member completion, hover, go-to-definition, and generic type resolution work both on them and inside their own methods. When the same class name appears in more than one branch, the first declaration wins. Contributed by @MrSrsen in . - **Editing a base class stays responsive in large projects.** Changing a class that many others extend used to invalidate the resolved-class cache by rescanning every cached class on each edit, which briefly stalled large projects with deep class hierarchies. Invalidation now touches only the classes that actually depend on the edited one. - **Completion latency stays flat during sustained fast typing.** Concurrent requests resolving the same classes contended on a single lock guarding the resolved-class cache, so completion latency crept upward for as long as a typing burst continued. The cache now allows parallel reads, so the many lookups in flight at once no longer serialize behind one another, and when a request first loads a vendor class the work to record it in the shared index is prepared before the index is locked, so other requests no longer wait on it. - **The server no longer freezes and stops responding.** Editors cancel in-flight requests constantly (every cursor move supersedes the previous hover and highlight), and a burst of cancellations, such as when the editor regains focus after being in the background, could wedge the server so that it went completely silent and had to be restarted. Cancelled requests are now handled cleanly. @@ -183,7 +186,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **A rare internal parser error no longer permanently breaks a file.** If analysis of a file hit an unexpected internal error, that file could become unresolvable for the rest of the session, with completion, hover, and go-to-definition silently returning nothing and each attempt stalling briefly. Such errors are now contained and the file recovers the next time it is used. - **Inherited members no longer briefly flagged as unknown after opening a project.** A method or property inherited from a vendor base class (for example the base methods of a framework controller) could be reported as an unknown member right after a file opened, even though hover resolved it correctly, and the error went away when the file was closed and reopened. Such members now resolve as soon as indexing finishes. - **Named arguments are matched to parameters by name.** Calls that pass arguments by name (`f(c: 3)`) are now bound to the parameters they actually target instead of by their position in the call. Conditional return types resolve correctly when the deciding argument is passed by name out of order, a "missing required argument" error is now reported when a named argument fills an optional parameter but leaves a required one unsupplied, and pass-by-reference type inference seeds the right variable. -- **Argument-count false positives.** Extra arguments to a class with no constructor are no longer flagged (PHP accepts them), and namespaced calls to overloaded built-ins written with a leading backslash (`\mt_rand()`) are no longer measured against the wrong minimum. Immediately invoking the callable returned by a function or method (`makeHandler($a, $b)($request)`) now checks the inner call's arguments against the returned callable's own signature instead of the outer call's, fixing both false argument-count errors and wrong inlay hint parameter names on the invocation. Contributed by @calebdw in https://github.com/AJenbo/phpantom_lsp/pull/191. +- **Argument-count false positives.** Extra arguments to a class with no constructor are no longer flagged (PHP accepts them), and namespaced calls to overloaded built-ins written with a leading backslash (`\mt_rand()`) are no longer measured against the wrong minimum. Immediately invoking the callable returned by a function or method (`makeHandler($a, $b)($request)`) now checks the inner call's arguments against the returned callable's own signature instead of the outer call's, fixing both false argument-count errors and wrong inlay hint parameter names on the invocation. Contributed by @calebdw in . - **`@var` annotations no longer leak between functions.** A `/** @var T $x */` annotation in one function used to suppress "undefined variable" warnings for that name everywhere in the file; it is now scoped to the function it appears in. - **PDO fetch methods reflect the fetch mode.** `PDOStatement::fetch()` and `fetchAll()` now resolve to the type produced by the fetch-mode constant passed to them, so `fetch(PDO::FETCH_OBJ)` is an object, `fetch(PDO::FETCH_ASSOC)` is an associative array, and iterating `fetchAll(PDO::FETCH_OBJ)` yields objects. More generally, conditional return types keyed on a class constant (`@return ($mode is Foo::BAR ? ... : ...)`) are now evaluated at the call site. - **Type resolution through chained and untyped access.** Null-safe call chains such as `$a->b?->c()` resolve through the full receiver. Array access on a value of unknown type resolves to `mixed`, so `$x = $arr['key'] ?? 5` no longer produces spurious type errors. `foreach` element types resolve through interfaces that reach a known iterable several hops away. Nested array-shape narrowing (`$a["x"]["y"]`) no longer targets the wrong key. @@ -191,14 +194,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`@method` tags override inherited methods of the same name.** A `@method` annotation on a class now takes precedence over a method inherited from a more distant ancestor. The common repository pattern, where a base repository declares `@method Entity|null findOneBy(...)` while its vendor parent returns a generic `object`, now resolves to the concrete entity type, so members accessed on the result are no longer flagged as unverifiable. - **`??=` keeps the resolved type.** After `$x ??= new Foo()`, the variable resolves to `Foo` (or the union of its existing non-null type and the assigned value), so property and method access on `$x` is no longer reported as unresolvable. - **Generics with fewer arguments than parameters.** `@extends Collection` against `Collection` now binds `User` to the value parameter, so inherited element types resolve correctly. -- **Nullable generic return types resolve through inheritance.** A method whose native return hint is nullable (`object|null`) and whose docblock returns a template (`@return ?T`) now resolves to the bound type, so a repository's `find()` returns `Entity|null` instead of the bare `object|null`. Contributed by @MrSrsen in https://github.com/PHPantom-dev/phpantom_lsp/pull/152. +- **Nullable generic return types resolve through inheritance.** A method whose native return hint is nullable (`object|null`) and whose docblock returns a template (`@return ?T`) now resolves to the bound type, so a repository's `find()` returns `Entity|null` instead of the bare `object|null`. Contributed by @MrSrsen in . - **Conditional `is null` return types** resolve consistently regardless of how the call site is parsed, and an explicitly passed `null` now selects the null branch. - **Go-to-definition, rename, and highlight accuracy.** References in `@see` tags to qualified names like `App\Foo::bar()` now land on the correct location, and renaming a property selects the whole `$name` instead of `$nam`. -- **`@phpstan-require-extends` and `@phpstan-require-implements` navigation.** Class and interface names in these trait constraint tags now support go-to-definition and hover, and an import used only by such a tag is no longer flagged as unused. Contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/172. -- **Renaming variables captured by nested closures and arrow functions.** Renaming or finding references to a variable used inside deeply nested arrow functions (`fn () => fn () => $var`) or closures with `use ($var)` now updates every occurrence, whether the rename is triggered on the declaration or from deep inside the nesting. Contributed by @calebdw in https://github.com/AJenbo/phpantom_lsp/pull/145. -- **Variables inside dynamic property accesses are tracked.** A variable used as a dynamic property selector (`$message->{$attribute}`) now counts as a use, so it is no longer wrongly reported as unused, find-references includes it, and renaming the variable updates the selector along with its other occurrences. Contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/174. -- **Member rename stays scoped to the declaration it targets.** Renaming a method or property no longer touches same-named members on unrelated classes. A private method rename updates only that method and its real usages, calls on a receiver whose type cannot be resolved are left alone, renaming one implementation of an interface no longer renames sibling implementations, and renaming a child override stays on the child branch. Renaming a parent or interface declaration still updates the inherited overrides and implemented usages. Contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/160. -- **Find references on a constructor lists every call site.** Finding references to a `__construct` declaration now reports the `new ClassName(...)` instantiations, `#[ClassName(...)]` attribute usages, and explicit delegation calls written as `parent::__construct()`, `self::__construct()`, or `Class::__construct()`, including for subclasses that inherit the constructor (and excluding subclasses that override it). Attribute classes that are never written as `new` are now found. Contributed by @RemcoSmitsDev in https://github.com/AJenbo/phpantom_lsp/pull/155. +- **`@phpstan-require-extends` and `@phpstan-require-implements` navigation.** Class and interface names in these trait constraint tags now support go-to-definition and hover, and an import used only by such a tag is no longer flagged as unused. Contributed by @calebdw in . +- **Renaming variables captured by nested closures and arrow functions.** Renaming or finding references to a variable used inside deeply nested arrow functions (`fn () => fn () => $var`) or closures with `use ($var)` now updates every occurrence, whether the rename is triggered on the declaration or from deep inside the nesting. Contributed by @calebdw in . +- **Variables inside dynamic property accesses are tracked.** A variable used as a dynamic property selector (`$message->{$attribute}`) now counts as a use, so it is no longer wrongly reported as unused, find-references includes it, and renaming the variable updates the selector along with its other occurrences. Contributed by @calebdw in . +- **Member rename stays scoped to the declaration it targets.** Renaming a method or property no longer touches same-named members on unrelated classes. A private method rename updates only that method and its real usages, calls on a receiver whose type cannot be resolved are left alone, renaming one implementation of an interface no longer renames sibling implementations, and renaming a child override stays on the child branch. Renaming a parent or interface declaration still updates the inherited overrides and implemented usages. Contributed by @calebdw in . +- **Find references on a constructor lists every call site.** Finding references to a `__construct` declaration now reports the `new ClassName(...)` instantiations, `#[ClassName(...)]` attribute usages, and explicit delegation calls written as `parent::__construct()`, `self::__construct()`, or `Class::__construct()`, including for subclasses that inherit the constructor (and excluding subclasses that override it). Attribute classes that are never written as `new` are now found. Contributed by @RemcoSmitsDev in . - **Positions on lines with multibyte characters.** Signature help, go-to-definition on virtual properties, named-argument completion, unused-import removal, and the `@phpstan-ignore` quickfix placed cursors and edits at the wrong column on lines containing multibyte characters; they now use the correct UTF-16 columns. Type strings containing `*` wildcards or variance annotations are also no longer mangled. - **Unused-import hint location.** When two imports share a name prefix (`use App\Foo;` and `use App\FooBar;`), the "unused import" dimming now lands on the correct statement. - **Document outline ranges.** Methods, properties, constants, and functions in the outline and breadcrumbs now report a range covering the whole declaration, with the name nested inside, as editors expect for folding and breadcrumb extent. @@ -214,7 +217,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Analysis deadlock.** Lazily-parsed vendor files acquired two internal locks in the opposite order from the editor's file-change handler, causing a deadlock when both ran concurrently. - **External tool diagnostics on large files.** PHPStan, Mago, and PHPCS diagnostics no longer time out on files that produce a large report. Their output is now read while the tool is still running, so a report bigger than the operating system's pipe buffer can no longer stall the tool and force a timeout. - **Promote to constructor property.** Promoting a parameter whose property is declared together with others on one line (`private int $a, $b;`) no longer deletes the sibling properties. The action is now offered only when the property is declared on its own. -- **`get_defined_vars()` counts as using every variable in scope.** A function or method that calls `get_defined_vars()` (for example to build a debug dump) no longer reports its local variables as unused, since the call reads all of them. Variables local to a nested closure or arrow function are still checked. Contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/158. +- **`get_defined_vars()` counts as using every variable in scope.** A function or method that calls `get_defined_vars()` (for example to build a debug dump) no longer reports its local variables as unused, since the call reads all of them. Variables local to a nested closure or arrow function are still checked. Contributed by @calebdw in . - **Integer literals now satisfy named refined-int parameter types.** A literal like `1` passed to a `positive-int` or `non-negative-int` parameter no longer produces a false `type_mismatch_argument`, matching the existing behaviour for `int` ranges. Passing a literal that genuinely violates the refinement (e.g. `0` to `positive-int`, or a negative literal to `non-negative-int`) is now correctly flagged. `non-zero-int` and `callable-string`-family PHPDoc types, which previously failed to parse and were silently ignored by these checks, are now recognized as well. - **PHPStan's `__benevolent` wrapper type is recognized.** A docblock type like `@var __benevolent` now resolves as its inner type instead of reporting a false "class not found" on the wrapper. - **Indexing an object implementing `ArrayAccess` resolves through `offsetGet`.** `$obj[$key]` on a class implementing `ArrayAccess` now resolves to the value type declared in a generic annotation (`@implements ArrayAccess`), falling back to `offsetGet()`'s own declared return type when no annotation is present, mirroring how `foreach` already fell back to `Iterator::current()`. This also fixes a class's own `@template` parameter resolving to its declared bound instead of leaking through as an unrelated type name when referenced directly in that same class's `@implements`/`@extends` annotations. @@ -225,16 +228,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- **Improved LSP responsiveness.** File parsing (`update_ast`) and diagnostics now run in background tasks, preventing interactive requests (completion, hover) from being blocked by full-file parses during typing. Contributed by @MingJen in https://github.com/AJenbo/phpantom_lsp/pull/118. -- **Member completion caching.** Unfiltered member lists are cached per-target to speed up subsequent completions during keyword entry. Contributed by @MingJen in https://github.com/AJenbo/phpantom_lsp/pull/118. -- **Laravel startup performance.** Common Laravel builder classes are warmed in the background at startup to eliminate the first-access penalty on Eloquent completions. Contributed by @MingJen in https://github.com/AJenbo/phpantom_lsp/pull/118. +- **Improved LSP responsiveness.** File parsing (`update_ast`) and diagnostics now run in background tasks, preventing interactive requests (completion, hover) from being blocked by full-file parses during typing. Contributed by @MingJen in . +- **Member completion caching.** Unfiltered member lists are cached per-target to speed up subsequent completions during keyword entry. Contributed by @MingJen in . +- **Laravel startup performance.** Common Laravel builder classes are warmed in the background at startup to eliminate the first-access penalty on Eloquent completions. Contributed by @MingJen in . - **Faster code actions.** Requesting code actions (the lightbulb menu) now parses the file once and shares the result across every refactoring, instead of re-parsing it for each one. ## [0.8.0] - 2026-05-14 ### Added -- **Blade template support.** Completion, hover, go-to-definition, diagnostics, semantic tokens, and inlay hints work inside `.blade.php` files. Contributed by @MingJen in https://github.com/AJenbo/phpantom_lsp/pull/100. +- **Blade template support.** Completion, hover, go-to-definition, diagnostics, semantic tokens, and inlay hints work inside `.blade.php` files. Contributed by @MingJen in . - **Blade keyword highlighting.** Blade directives, echo delimiters, PHP keywords, cast types, comments, and PHPDoc tags inside `.blade.php` files now receive semantic tokens for proper syntax coloring. - **Blade view directive navigation.** Go-to-definition works on view names inside Blade directives (`@include`, `@extends`, `@includeIf`, `@includeWhen`, `@includeUnless`, `@includeFirst`, `@component`, `@each`), jumping to the referenced template file. - **Replace FQCN with import.** A refactoring code action on any fully-qualified class name (`\Foo\Bar`) inserts a `use` statement and replaces all occurrences of the same FQCN throughout the file with the short name. Detects existing imports and short-name conflicts. A separate "Replace all FQCNs with imports" action appears when the file contains multiple distinct FQCNs, replacing all of them at once (skipping those with import conflicts). @@ -254,9 +257,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Import all missing classes.** A bulk code action that imports every unresolved class name in the file at once. Ambiguous names are left for manual resolution. - **Context-aware import candidate filtering.** Import class actions now filter candidates by syntactic context (only interfaces after `implements`, only traits after `use`, etc.). - **Convert to instance variable.** A code action that promotes a local variable inside a method to a class property, rewriting all references to `$this->prop` (or `self::$prop` in static methods). -- **Laravel view, route, and translation key navigation.** Go to Definition works for Blade view names (`view('...')`), route names (`route('...')`), and translation keys (`__('...')`, `trans(...)`, `Lang::get(...)`). Contributed by @MingJen in https://github.com/AJenbo/phpantom_lsp/pull/101. -- **Laravel config and env key navigation.** Go to Definition and Find All References work for config keys and env variables (`config('app.name')`, `env('APP_KEY')`). Contributed by @MingJen in https://github.com/AJenbo/phpantom_lsp/pull/93. -- **Untyped property type inference from constructor.** Properties without type declarations are resolved by inspecting the constructor body for assignments and promoted parameter defaults. Contributed by @lucasacoutinho in https://github.com/AJenbo/phpantom_lsp/pull/81. +- **Laravel view, route, and translation key navigation.** Go to Definition works for Blade view names (`view('...')`), route names (`route('...')`), and translation keys (`__('...')`, `trans(...)`, `Lang::get(...)`). Contributed by @MingJen in . +- **Laravel config and env key navigation.** Go to Definition and Find All References work for config keys and env variables (`config('app.name')`, `env('APP_KEY')`). Contributed by @MingJen in . +- **Untyped property type inference from constructor.** Properties without type declarations are resolved by inspecting the constructor body for assignments and promoted parameter defaults. Contributed by @lucasacoutinho in . - **Binary expression type inference.** Hover and variable resolution now show result types for all binary operators (`int + int` → `int`, `int + float` → `float`, `int / int` → `int|float`). Compound assignments update the variable's type accordingly. - **Nested array shape inference from multi-level key assignments.** Assignments like `$b['a']['b'] = 'x'` now produce a nested array shape type (`array{a: array{b: string}}`), enabling array key completion for incrementally built arrays. - **Loop type propagation.** Variables assigned late in loop bodies are now visible from the start on subsequent iterations. @@ -265,11 +268,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Machine-readable CLI output.** Both `analyze` and `fix` accept a `--format` flag with `table`, `github`, and `json` options. When `GITHUB_ACTIONS` is set, table output automatically includes GitHub annotations. - **Magic property diagnostics.** New `report-magic-properties` option under `[diagnostics]` in `.phpantom.toml`. When enabled, classes with `__get` that also have virtual properties (from `@property` docblock tags, Laravel Eloquent column inference, or other providers) will flag unknown property access instead of silently allowing it. - **Inline diagnostic suppression.** `// @phpantom-ignore code` on the same line or the line above suppresses the specified diagnostic. Multiple codes can be comma-separated. A bare `// @phpantom-ignore` suppresses all diagnostics on the target line. -- **Find references and rename for PHPDoc virtual members.** `@property`, `@property-read`, `@property-write`, and `@method` declarations in docblocks are now included in find-references and rename results alongside their runtime usages, including when the subject has a nullable or union type (e.g. `Foo|null` from `->first()`). Contributed by @AbyssWaIker in https://github.com/AJenbo/phpantom_lsp/pull/115. +- **Find references and rename for PHPDoc virtual members.** `@property`, `@property-read`, `@property-write`, and `@method` declarations in docblocks are now included in find-references and rename results alongside their runtime usages, including when the subject has a nullable or union type (e.g. `Foo|null` from `->first()`). Contributed by @AbyssWaIker in . ### Changed -- **Find References performance and freshness.** Project-wide Find References now avoids more unnecessary file work while still returning references through aliased class and function imports, and it refreshes newly added workspace PHP files on later searches. Contributed by @MingJen in https://github.com/AJenbo/phpantom_lsp/pull/116. +- **Find References performance and freshness.** Project-wide Find References now avoids more unnecessary file work while still returning references through aliased class and function imports, and it refreshes newly added workspace PHP files on later searches. Contributed by @MingJen in . - **Incremental text sync.** The server now uses incremental document sync, receiving only changed ranges from the editor instead of the full file content on every keystroke. - **LSP responsiveness.** Hover, go-to-definition, signature help, code actions, rename, and other handlers now run on background threads. Slow requests no longer block other requests or cancellations. - **Faster analysis.** Analysis time cut significantly on large projects. @@ -404,25 +407,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`@psalm-return`, `@psalm-param`, and `@psalm-var` tag support.** Psalm-prefixed docblock tags are now recognized alongside their PHPStan equivalents for return types, parameter types, variable types, conditional return types, template parameter bindings, and semantic token highlighting. - **Refactoring code actions.** Extract function, extract method, extract variable, extract constant, inline variable, promote constructor parameter, generate constructor (traditional and promoted), generate getter/setter, and generate property hooks (PHP 8.4+). Deferred computation ensures the lightbulb menu appears instantly; edit generation only runs when the user picks an action. - **PHPStan quickfixes.** Automated fixes for a wide range of PHPStan diagnostics: update or remove mismatched `@return`/`@param`/`@var` tags, remove unused return type union members, fix unsafe `new static()` (add `@phpstan-consistent-constructor`, `final` class, or `final` constructor), add or remove `#[Override]`, add `#[\ReturnTypeWillChange]`, fix void return mismatches, add inferred iterable return types, remove unreachable statements, remove always-true `assert()` calls, fix overriding member visibility, fix vendor-prefixed class names, and simplify ternary expressions to `??` or `?->`. All quickfixes eagerly clear their diagnostic on apply. -- **`fix` CLI subcommand.** `phpantom_lsp fix` applies automated code fixes across a project. Specify rules with `--rule` (multiple allowed) or omit to run all preferred fixers. `--dry-run` reports what would change without writing files. The first shipped rule, `unused_import`, removes unused `use` statements project-wide, collapsing blank lines left behind by removals (contributed by @calebdw in https://github.com/AJenbo/phpantom_lsp/pull/54). Supports path filtering and single-file mode. -- **Keyword completions.** Context-aware PHP keyword suggestions filtered by scope (e.g. `return` only inside functions, `break` only inside loops, member keywords inside class bodies, enum backing types after `enum Name:`). Contributed by @ryangjchandler in https://github.com/AJenbo/phpantom_lsp/pull/43. +- **`fix` CLI subcommand.** `phpantom_lsp fix` applies automated code fixes across a project. Specify rules with `--rule` (multiple allowed) or omit to run all preferred fixers. `--dry-run` reports what would change without writing files. The first shipped rule, `unused_import`, removes unused `use` statements project-wide, collapsing blank lines left behind by removals (contributed by @calebdw in ). Supports path filtering and single-file mode. +- **Keyword completions.** Context-aware PHP keyword suggestions filtered by scope (e.g. `return` only inside functions, `break` only inside loops, member keywords inside class bodies, enum backing types after `enum Name:`). Contributed by @ryangjchandler in . - **Attribute completion.** Typing inside `#[…]` offers only classes decorated with `#[\Attribute]`, filtered by the target declaration kind. - **Eloquent model enhancements.** Timestamp properties (`created_at`, `updated_at`) are automatically typed as `Carbon` with support for `$timestamps = false` and custom column constants. Legacy `$dates` arrays produce typed virtual properties. `$appends` entries produce virtual properties. `where{PropertyName}()` dynamic methods are synthesized from all known columns (including `@property` annotations) on both the model and the Builder. `whereHas`/`whereDoesntHave` closure parameters resolve to `Builder` by traversing relationship methods, with dot-notation chain support. `Conditionable::when()`/`unless()` chains preserve type information. - **Type-guard narrowing.** `is_array()`, `is_string()`, `is_int()`, `is_float()`, `is_bool()`, `is_object()`, `is_numeric()`, and `is_callable()` narrow union types inside `if`/`else`/`elseif` bodies and after guard clauses, preserving generic element types through narrowing. - **Array value type tracking.** Arrays built incrementally with variable keys inside loops now carry element types through `foreach` iteration, bracket access, and null-coalescing. Foreach over generic arrays with non-class element types (array shapes, scalars) now preserves the full element type. - **Inherited docblock type propagation.** When a child class overrides a method without providing its own `@return` or `@param` docblock, the ancestor's richer types flow through automatically. Applies to return types, parameter types (matched by position), property type hints, and descriptions. - **Bidirectional template inference from closures.** Templates appearing in callable parameter signatures are now inferred from both the closure's return type and its parameter types. Positional matching is supported, and return-type bindings take priority when the same template appears in both positions. -- **Drupal project support.** Drupal projects are detected via `composer.json`. Drupal-specific directories and PHP extensions (`.module`, `.install`, `.theme`, `.profile`, `.inc`, `.engine`) are recognized and indexed. Contributed by @syntlyx in https://github.com/AJenbo/phpantom_lsp/pull/52. -- **Completion and signature help for `new self`, `new static`, and `new parent`.** Constructor parameter snippets and signature help inside the parentheses. Contributed by @RemcoSmitsDev in https://github.com/AJenbo/phpantom_lsp/pull/51. -- **Hover on parameter variables at their definition site.** Hovering on a function or method parameter now shows its resolved type, using the `@param` docblock type when it is richer than the native hint. Contributed by @RemcoSmitsDev in https://github.com/AJenbo/phpantom_lsp/pull/68. +- **Drupal project support.** Drupal projects are detected via `composer.json`. Drupal-specific directories and PHP extensions (`.module`, `.install`, `.theme`, `.profile`, `.inc`, `.engine`) are recognized and indexed. Contributed by @syntlyx in . +- **Completion and signature help for `new self`, `new static`, and `new parent`.** Constructor parameter snippets and signature help inside the parentheses. Contributed by @RemcoSmitsDev in . +- **Hover on parameter variables at their definition site.** Hovering on a function or method parameter now shows its resolved type, using the `@param` docblock type when it is richer than the native hint. Contributed by @RemcoSmitsDev in . - **Array element type extraction from property generics.** Bracket access on properties annotated with generic array or collection types (e.g. `$this->cache[$key]->`) now resolves the element type correctly through nested chains, string-literal keys, and method chains after the bracket. -- **`@phpstan-assert-if-true $this` narrowing.** Instance methods annotated with `@phpstan-assert-if-true` or `@phpstan-assert-if-false` targeting `$this` now narrow the receiver variable in the corresponding branch. Contributed by @syntlyx in https://github.com/AJenbo/phpantom_lsp/pull/52. -- **Namespace completion from file path.** When creating a new PHP file, typing `namespace ` suggests the correct namespace inferred from the file's location and the project's PSR-4 autoload mappings. The most specific mapping is preselected so you can accept it with a single keypress. When multiple PSR-4 roots match the same directory, all candidates appear ranked by specificity (longest match first). +- **`@phpstan-assert-if-true $this` narrowing.** Instance methods annotated with `@phpstan-assert-if-true` or `@phpstan-assert-if-false` targeting `$this` now narrow the receiver variable in the corresponding branch. Contributed by @syntlyx in . +- **Namespace completion from file path.** When creating a new PHP file, typing `namespace` suggests the correct namespace inferred from the file's location and the project's PSR-4 autoload mappings. The most specific mapping is preselected so you can accept it with a single keypress. When multiple PSR-4 roots match the same directory, all candidates appear ranked by specificity (longest match first). - **Standalone `@var` docblock for untyped closure parameters.** When a closure parameter lacks a type hint and no assignment follows, a `@var` block above the usage is now picked up as the variable's type. -- **`--stdio` CLI flag.** Accepted (and ignored) for compatibility with LSP client wrappers that pass `--stdio` by default. Contributed by @markkimsal in https://github.com/AJenbo/phpantom_lsp/pull/67. +- **`--stdio` CLI flag.** Accepted (and ignored) for compatibility with LSP client wrappers that pass `--stdio` by default. Contributed by @markkimsal in . - **`--tcp` CLI flag.** `phpantom_lsp --tcp 9257` starts the server listening on a TCP port instead of stdin/stdout. Useful for debugging or connecting from IDE plugins that prefer a network transport over spawning a child process. Accepts a full address (`127.0.0.1:9257`) or just a port number. The server accepts one connection and exits when the client disconnects. -- **Zed extension setup instructions.** Contributed by @daronspence in https://github.com/AJenbo/phpantom_lsp/pull/47. -- **SETUP.md improvements.** Contributed by @mattsches in https://github.com/AJenbo/phpantom_lsp/pull/61. +- **Zed extension setup instructions.** Contributed by @daronspence in . +- **SETUP.md improvements.** Contributed by @mattsches in . - **Method-level template parameters resolve inside method bodies.** `@template T of Builder` with `@param T $query` now resolves `$query` to the template bound inside the method body, providing completions from the bound class. - **Undefined variable diagnostic.** Variable reads that have no prior definition (assignment, parameter, foreach binding, catch variable, `global`, `static`, `use()` clause, or destructuring) in the same scope are flagged as errors. Writes must appear before the read in source order, catching use-before-assign bugs, while assignments inside branches (if/else, switch, try/catch) still count to avoid false positives. Suppressed for superglobals, `isset()`/`empty()` guards, `compact()` references, `extract()` calls, variable variables (`$$`), `@` error suppression, and `@var` annotations. Static property accesses (`self::$prop`, `static::$prop`, `parent::$prop`) are excluded. Variables passed to by-reference parameters are recognized as definitions: 40+ built-in PHP functions are covered (regex, cURL, OpenSSL, sockets, DNS, etc.), and user-defined functions, static methods, and constructors with `&$param` parameters are detected automatically from their signatures. Scoping is tracked through arbitrary nesting of closures, arrow functions, and catch blocks. Top-level code outside functions is skipped. - **By-reference parameter type inference for method, static, and constructor calls.** When a variable is passed to a by-reference parameter with a type hint (e.g. `function foo(Baz &$bar)`), the variable acquires that type after the call. Previously this only worked for standalone function calls. Now it also works for `$this->method()`, static method calls, and constructor calls. @@ -435,7 +438,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Faster startup.** Stub loading during initialization is significantly faster. - **More accurate generics resolution.** Type substitution and resolution for complex nested generic types is more correct, particularly for unions, intersections, array shapes, and deeply nested generic arguments. - **More accurate type predicates.** `NULL`, `Null`, and case variants of `null` are now handled consistently throughout type checking, matching PHP's case-insensitive treatment of type keywords. -- **Go-to-definition at declaration sites returns the symbol's own location.** Class, member, and variable declaration names now return their own location instead of nothing, so editors that detect "definition == cursor" can automatically fall back to Find References. Contributed by @lucasacoutinho in https://github.com/AJenbo/phpantom_lsp/pull/76. +- **Go-to-definition at declaration sites returns the symbol's own location.** Class, member, and variable declaration names now return their own location instead of nothing, so editors that detect "definition == cursor" can automatically fall back to Find References. Contributed by @lucasacoutinho in . ### Fixed @@ -445,7 +448,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Return types now carry class info through the resolution pipeline.** Method and function return types that name a class (e.g. `Collection`) now populate the resolved class info eagerly, so downstream consumers (hover, narrowing, completion) no longer need a second resolution pass. - **Generic parameters preserved on resolved types.** Catch clause variables, pass-by-reference parameters, closure parameters, and constructor calls now thread the original type hint (including generic parameters) through the resolution pipeline instead of discarding it. - **Type-guard narrowing no longer drops class info on unions.** Narrowing a union like `Foobar|string|int` with `is_string()`/`is_int()` in elseif chains now correctly preserves class info for the remaining class member. -- **False-positive undefined-variable diagnostic on static property access.** `self::$prop`, `static::$prop`, and `ClassName::$prop` no longer trigger an undefined variable warning. Dynamic forms (`self::$$prop`, `self::${expr}`) still correctly flag undefined variables used in the expression. Contributed by @lucasacoutinho in https://github.com/AJenbo/phpantom_lsp/pull/75. +- **False-positive undefined-variable diagnostic on static property access.** `self::$prop`, `static::$prop`, and `ClassName::$prop` no longer trigger an undefined variable warning. Dynamic forms (`self::$$prop`, `self::${expr}`) still correctly flag undefined variables used in the expression. Contributed by @lucasacoutinho in . - **Case-insensitive `self`, `static`, and `parent` resolution.** `SELF::method()`, `Static::create()`, `PARENT::foo()`, and other non-lowercase spellings now resolve correctly. Previously only the exact lowercase forms were recognized. - **Property type resolution in call arguments.** When a method argument is `$this->prop` and the property has a generic, nullable, or union type, the full type structure is now preserved. Previously only the base class name was extracted, discarding generics and union components. - **Update docblock enrichment comparison.** The "Update docblock" code action now uses structural type comparison instead of string equality when deciding whether a `@param` type needs enrichment. Types that are semantically equivalent but formatted differently (e.g. `\App\User` vs `App\User`) no longer trigger spurious updates. Body-based `@return` enrichment now correctly detects when an existing `@return` tag already has type structure, instead of always proposing a replacement. @@ -464,7 +467,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`@var` docblock annotations no longer leak across class and method boundaries.** A `@var` annotation for a same-named variable in a different class no longer bleeds into the current scope. - **Inline `@var` cast no longer overrides the variable type on the RHS of the same assignment.** `/** @var array */ $data = $data->toArray()` no longer resolves the RHS `$data` using the cast type. - **Foreach over union types containing arrays now resolves the element type.** A parameter typed `User|array` iterated with `foreach` now correctly yields `User` as the loop variable type. Previously the element type extraction did not look inside union members, producing no completions. -- **`@param` docblock overrides ignored when the native type hint resolves.** When a parameter has both a native type hint and a more specific `@param` override, the docblock type now takes effect. Contributed by @calebdw in https://github.com/AJenbo/phpantom_lsp/pull/55. +- **`@param` docblock overrides ignored when the native type hint resolves.** When a parameter has both a native type hint and a more specific `@param` override, the docblock type now takes effect. Contributed by @calebdw in . - **Variable reassignment inside `try`/`catch`/`finally` blocks now tracked.** Subsequent accesses within the same block resolve against the reassigned type instead of the original. - **Self-referential variable reassignments in nested loops no longer produce false "type could not be resolved" diagnostics.** Recursive resolution that hits the depth limit no longer poisons the cache for later lookups. - **`instanceof` narrowing with unresolvable target class.** When the target class cannot be loaded, the variable's type is treated as unknown instead of keeping the un-narrowed type, eliminating false positives for members on the narrowed subclass. @@ -544,8 +547,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Progress indicators.** Go to Implementation and Find References now show a progress indicator in the editor while scanning. - **Phar archive class resolution.** Classes inside `.phar` archives (e.g. PHPStan's `phpstan.phar`) are now discovered and indexed automatically. No PHP runtime needed. Only uncompressed phars are supported (the format used by PHPStan and most other phar-distributed tools). - **PSR-0 autoload support.** Packages that use the legacy PSR-0 autoloading standard are now discovered automatically. -- **Global config.** Settings from a global `.phpantom.toml` in the user's config directory (typically `~/.config/phpantom_lsp/.phpantom.toml`) are now loaded as defaults. Project-level configs take precedence. Contributed by @calebdw in https://github.com/AJenbo/phpantom_lsp/pull/39. -- **Config schema.** A JSON schema for `.phpantom.toml` is now bundled, enabling autocompletion and validation in editors that support TOML schemas. Contributed by @calebdw in https://github.com/AJenbo/phpantom_lsp/pull/38. +- **Global config.** Settings from a global `.phpantom.toml` in the user's config directory (typically `~/.config/phpantom_lsp/.phpantom.toml`) are now loaded as defaults. Project-level configs take precedence. Contributed by @calebdw in . +- **Config schema.** A JSON schema for `.phpantom.toml` is now bundled, enabling autocompletion and validation in editors that support TOML schemas. Contributed by @calebdw in . ### Changed @@ -647,7 +650,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **`iterator_to_array()` element type.** Resolves the element type from the iterator's generic annotation. - **Enum case properties.** `$case->name` and `$case->value` resolve on enum case variables. - **Inline `@var` on promoted constructor properties.** Overrides the native type hint, matching existing `@param` support. -- **`--version` and `--help` CLI flags.** Contributed by @calebdw in https://github.com/AJenbo/phpantom_lsp/pull/7. +- **`--version` and `--help` CLI flags.** Contributed by @calebdw in . ### Changed @@ -658,8 +661,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Parallel workspace indexing.** File parsing, PSR-4 scanning, and vendor scanning run across all CPU cores. `.gitignore` rules are respected. - **Two-phase diagnostic publishing.** Cheap diagnostics (unused imports, deprecation) publish immediately; expensive diagnostics (unknown classes/members/functions) arrive in a second pass. - **Merged classmap + self-scan pipeline.** Composer classmaps and self-scanning work together instead of being mutually exclusive. Stale classmaps are supplemented automatically. -- **Automatic stub fetching.** The build script downloads phpstorm-stubs automatically when missing. Composer is no longer needed to build PHPantom. Contributed by @calebdw in https://github.com/AJenbo/phpantom_lsp/pull/16. -- **Feature comparison table corrected.** Phactor capabilities updated in the README. Contributed by @dantleech in https://github.com/AJenbo/phpantom_lsp/pull/10. +- **Automatic stub fetching.** The build script downloads phpstorm-stubs automatically when missing. Composer is no longer needed to build PHPantom. Contributed by @calebdw in . +- **Feature comparison table corrected.** Phactor capabilities updated in the README. Contributed by @dantleech in . ### Fixed diff --git a/docs/todo/laravel.md b/docs/todo/laravel.md index 0980abbc..4f85185e 100644 --- a/docs/todo/laravel.md +++ b/docs/todo/laravel.md @@ -589,55 +589,6 @@ The scanners already enumerate every valid key for go-to-definition. The items below mostly wire that existing enumeration into three more LSP endpoints rather than building new analysis. -#### L14. Diagnostics for Laravel string keys - -**Impact: High · Effort: Medium** - -No Laravel string key is currently validated. A typo in `route('dashbaord')`, -`config('app.naem')`, `env('DB_CONNCTION')`, `__('auth.failedd')`, or -`view('layouts.ap')` produces no warning — the bug surfaces only at runtime. -This is the single highest-value gap, because it catches a class of error -that PHP itself never reports. - -Emit a warning when a string argument in a recognised context resolves to no -declaration. The candidate set is the same one the go-to-definition scanners -already build, so the diagnostic is "key not in the collected set." Each -construct reuses its existing scanner: - -- `route('name')` → not in collected `->name()` declarations. -- `config('a.b.c')` → not in flattened `config/*.php` keys. -- `env('KEY')` → not in `.env` / `.env.example`. -- `__()`/`trans()`/`trans_choice()` → not in `lang/**` keys. -- `view('a.b')` → no matching file under `resources/views/`. - -Pair each with a quick-fix where cheap: "create missing view file," -"add missing key to `.env` (copy from `.env.example`)" (mirrors the -extension's two quick-fixes). Guard against false positives from genuinely -dynamic keys (e.g. `config($key)` with a variable, or -`route("admin.$section")` interpolation) by only flagging plain string -literals. - -#### L15. Completion for Laravel string keys - -**Impact: High · Effort: Medium** - -Completion exists only for Eloquent relations/columns. Extend it to offer -route names, config keys (dot-notation drill-down), env var names, -translation keys, and view names inside the corresponding string contexts. -The candidate lists are exactly the declaration sets the go-to scanners -already produce; the work is detecting the string-literal cursor context -(the symbol-map already records these as `LaravelStringKey`) and returning -the collected keys as completion items. - -#### L16. Hover for Laravel string keys - -**Impact: Medium · Effort: Low-Medium** - -`SymbolKind::LaravelStringKey` currently returns `None` from hover -(`hover/mod.rs`). Show the resolved target: the config/env/translation -*value*, the route's URI + action, or the view's file path. The data is -already loaded by the go-to scanners; this is formatting it as hover markdown. - #### L17. Additional string contexts without booting **Impact: Medium · Effort: Medium** diff --git a/src/completion/handler.rs b/src/completion/handler.rs index e0b791bd..98daccd7 100644 --- a/src/completion/handler.rs +++ b/src/completion/handler.rs @@ -359,7 +359,6 @@ impl Backend { let string_ctx = crate::completion::comment_position::classify_string_context(&content, position); use crate::completion::comment_position::StringContext; - // ── Array shape key completion ─────────────────────────── // Runs before `InStringLiteral` suppression because in // normal code `$arr['` puts the scanner inside a @@ -373,6 +372,24 @@ impl Backend { return Ok(Some(response)); } + // ── Laravel string key completion (route/config/view/trans) ── + // Inside `route('|')`, `config('|')`, `view('|')`, `__('|')`, + // etc., offer matching key names from the project. + // NB: `is_laravel` is extracted to a `let` so the read lock + // on `resolved_class_cache` is dropped before calling + // `try_laravel_string_key_completion`, which may trigger + // `ensure_workspace_indexed` → `update_ast` → write lock. + let is_laravel = self.resolved_class_cache.read().is_laravel(); + if is_laravel + && matches!( + string_ctx, + StringContext::InStringLiteral | StringContext::NotInString + ) + && let Some(response) = self.try_laravel_string_key_completion(&content, position) + { + return Ok(Some(response)); + } + // ── Eloquent relation/column string completion ────────── // Like array shape completion, this triggers inside string // literals where the cursor is in a method argument position diff --git a/src/completion/laravel_string_keys.rs b/src/completion/laravel_string_keys.rs new file mode 100644 index 00000000..5528cd37 --- /dev/null +++ b/src/completion/laravel_string_keys.rs @@ -0,0 +1,796 @@ +//! Laravel string key completion. +//! +//! Offers autocompletion for route names, config keys, view names, and +//! translation keys inside their respective helper calls: +//! +//! - `route('|')` / `to_route('|')` → route names +//! - `config('|')` / `Config::get('|')` → config keys +//! - `view('|')` / `View::make('|')` → view names +//! - `__('|')` / `trans('|')` / `Lang::get('|')` → translation keys + +use tower_lsp::lsp_types::*; + +use crate::Backend; +use crate::symbol_map::LaravelStringKind; +use crate::util::position_to_offset; + +// ─── Context ──────────────────────────────────────────────────────────────── + +struct LaravelStringKeyContext { + kind: LaravelStringKind, + prefix: String, + /// Byte offset of the string content start (right after the opening quote). + content_start_offset: usize, + /// When set, the key is a sub-key under this config path prefix. + /// For example, `#[Database('mysql')]` sets this to `"database.connections."` + /// so completion filters to `database.connections.*` keys and strips the + /// prefix, showing just `mysql`, `sqlite`, etc. + config_sub_prefix: Option<&'static str>, +} + +// ─── Detection ────────────────────────────────────────────────────────────── + +/// Detect if the cursor is inside the first string argument of a Laravel +/// helper function. Returns the key kind and the prefix typed so far. +fn detect_laravel_string_key_context( + content: &str, + position: Position, +) -> Option { + let cursor_offset = position_to_offset(content, position) as usize; + let bytes = content.as_bytes(); + + if cursor_offset == 0 || cursor_offset > bytes.len() { + return None; + } + + // ── Find the opening quote before the cursor ──────────────────── + let mut quote_pos = None; + let mut i = cursor_offset; + while i > 0 { + i -= 1; + let ch = bytes[i]; + if ch == b'\'' || ch == b'"' { + let mut bs = 0; + let mut j = i; + while j > 0 && bytes[j - 1] == b'\\' { + bs += 1; + j -= 1; + } + if bs % 2 == 0 { + quote_pos = Some(i); + break; + } + } + if ch == b'\n' { + return None; + } + } + let quote_pos = quote_pos?; + let prefix = content[quote_pos + 1..cursor_offset].to_string(); + + // ── Before the quote, expect `(` (first argument) ─────────────── + let before_quote = content[..quote_pos].trim_end(); + if !before_quote.ends_with('(') { + return None; + } + let before_paren = before_quote[..before_quote.len() - 1].trim_end(); + + // ── Extract the function/method name ──────────────────────────── + let bp_bytes = before_paren.as_bytes(); + let name_end = bp_bytes.len(); + let mut name_start = name_end; + while name_start > 0 + && (bp_bytes[name_start - 1].is_ascii_alphanumeric() || bp_bytes[name_start - 1] == b'_') + { + name_start -= 1; + } + if name_start == name_end { + return None; + } + let func_name = &before_paren[name_start..name_end]; + + // ── Check for static method syntax (Config::get, etc.) ────────── + let before_name = &before_paren[..name_start]; + let is_static = before_name.trim_end().ends_with("::"); + + // Check for instance method call (->route() or ?->route()) + let trimmed_before = before_name.trim_end(); + let is_instance_method = trimmed_before.ends_with("->") || trimmed_before.ends_with("?->"); + + // Check for PHP attribute syntax: #[Config('key')] or + // #[\Illuminate\Container\Attributes\Config('key')]. + // Strip trailing `\Identifier` segments to handle FQN attributes, + // then check for `#[`. Never search the entire file prefix — + // an unrelated attribute (e.g. `#[Override]`) would false-positive. + let is_attribute = { + let mut s = trimmed_before; + loop { + let stripped = s.trim_end_matches(|c: char| c.is_ascii_alphanumeric() || c == '_'); + if stripped.len() < s.len() && stripped.ends_with('\\') { + s = &stripped[..stripped.len() - 1]; + } else { + s = stripped; + break; + } + } + s.ends_with("#[") || s.ends_with("#") + }; + + // ── Map container attributes to config sub-prefixes ──────────── + let (kind, config_sub_prefix) = if is_attribute { + // Resolve the attribute to its Laravel FQN. When the name is + // fully qualified (contains `\`), match the FQN directly. + // When it's a short name, verify the file imports it from + // `Illuminate\Container\Attributes\`. + const ATTR_NS: &str = "Illuminate\\Container\\Attributes\\"; + + // Reconstruct the full attribute class name by scanning backwards + // past namespace separators. `func_name` only captured the last + // segment (e.g. `Config`), but the FQN parts (if any) are in + // `before_name` (e.g. `#[\Illuminate\Container\Attributes\`). + let full_attr_name = { + let bn = before_name.trim_end().trim_end_matches('\\'); + // Check for `#[` or `#[\` prefix — extract everything after `#[` + if let Some(idx) = bn.rfind("#[") { + let after_hash = &bn[idx + 2..].trim_start_matches('\\'); + if after_hash.is_empty() { + func_name.to_string() + } else { + format!("{}\\{}", after_hash, func_name) + } + } else { + func_name.to_string() + } + }; + let attr_class = full_attr_name.trim_start_matches('\\'); + let short = attr_class.rsplit('\\').next().unwrap_or(attr_class); + + let is_fqn = attr_class.contains('\\'); + let fqn_matches = |expected_short: &str| -> bool { + if is_fqn { + attr_class == format!("{}{}", ATTR_NS, expected_short) + } else if short == expected_short { + // Verify the import exists in the file. + content.contains(&format!("use {}{};", ATTR_NS, expected_short)) + || content.contains(&format!("use {}{{", ATTR_NS)) + } else { + false + } + }; + + if fqn_matches("Config") { + (Some(LaravelStringKind::Config), None) + } else if fqn_matches("Database") || fqn_matches("DB") { + ( + Some(LaravelStringKind::Config), + Some("database.connections."), + ) + } else if fqn_matches("Cache") { + (Some(LaravelStringKind::Config), Some("cache.stores.")) + } else if fqn_matches("Log") { + (Some(LaravelStringKind::Config), Some("logging.channels.")) + } else if fqn_matches("Storage") { + (Some(LaravelStringKind::Config), Some("filesystems.disks.")) + } else if fqn_matches("Auth") || fqn_matches("Authenticated") { + (Some(LaravelStringKind::Config), Some("auth.guards.")) + } else { + (None, None) + } + } else if is_static { + let before_colons = &trimmed_before[..trimmed_before.len() - 2].trim_end(); + let bc_bytes = before_colons.as_bytes(); + let mut cls_start = bc_bytes.len(); + while cls_start > 0 + && (bc_bytes[cls_start - 1].is_ascii_alphanumeric() + || bc_bytes[cls_start - 1] == b'_' + || bc_bytes[cls_start - 1] == b'\\') + { + cls_start -= 1; + } + let class_name = &before_colons[cls_start..]; + let short = class_name.rsplit('\\').next().unwrap_or(class_name); + let fn_lower = func_name.to_ascii_lowercase(); + + match (short.to_ascii_lowercase().as_str(), fn_lower.as_str()) { + ( + "config", + "get" | "set" | "has" | "boolean" | "array" | "collection" | "prepend" | "push", + ) => (Some(LaravelStringKind::Config), None), + ("view", "make" | "exists") => (Some(LaravelStringKind::View), None), + ("lang", "get" | "has" | "choice") => (Some(LaravelStringKind::Trans), None), + // Facade methods that accept config sub-keys: + ("auth", "guard") => (Some(LaravelStringKind::Config), Some("auth.guards.")), + ("db", "connection") => ( + Some(LaravelStringKind::Config), + Some("database.connections."), + ), + ("cache", "store") => (Some(LaravelStringKind::Config), Some("cache.stores.")), + ("log", "channel") => (Some(LaravelStringKind::Config), Some("logging.channels.")), + ("storage", "disk") => (Some(LaravelStringKind::Config), Some("filesystems.disks.")), + _ => (None, None), + } + } else if is_instance_method { + let k = match func_name.to_ascii_lowercase().as_str() { + "route" => Some(LaravelStringKind::Route), + _ => None, + }; + (k, None) + } else { + match func_name.to_ascii_lowercase().as_str() { + "route" | "to_route" => (Some(LaravelStringKind::Route), None), + "config" => (Some(LaravelStringKind::Config), None), + "view" | "blade_view_directive" => (Some(LaravelStringKind::View), None), + "__" | "trans" | "trans_choice" => (Some(LaravelStringKind::Trans), None), + // auth('guard') helper accepts a guard name + "auth" => (Some(LaravelStringKind::Config), Some("auth.guards.")), + _ => (None, None), + } + }; + + let kind = kind?; + + Some(LaravelStringKeyContext { + kind, + prefix, + content_start_offset: quote_pos + 1, + config_sub_prefix, + }) +} + +// ─── Enumeration ──────────────────────────────────────────────────────────── + +impl Backend { + /// Enumerate all view names by scanning `resources/views/` file URIs. + fn enumerate_all_view_names(&self) -> Vec { + let snapshot = self.user_file_symbol_maps(); + let mut names = Vec::new(); + + for (file_uri, _) in snapshot { + // Match resources/views/**/*.blade.php or *.php + let Some(rel) = extract_view_relative_path(&file_uri) else { + continue; + }; + names.push(rel); + } + names.sort(); + names.dedup(); + names + } + + /// Enumerate all config keys by scanning `config/` files. + fn enumerate_all_config_keys(&self) -> Vec { + use crate::virtual_members::laravel::{ + collect_laravel_config_declarations, laravel_config_prefix_from_uri, + }; + + let snapshot = self.user_file_symbol_maps(); + let mut keys = Vec::new(); + + for (file_uri, _) in &snapshot { + let Some(prefix) = laravel_config_prefix_from_uri(file_uri) else { + continue; + }; + let Some(content) = self.get_file_content(file_uri) else { + continue; + }; + let decls = collect_laravel_config_declarations(&content, &prefix); + for d in decls { + keys.push(d.key); + } + } + keys.sort(); + keys.dedup(); + keys + } + + /// Enumerate all translation keys by scanning `lang/` files. + /// + /// Supports both PHP array files (`lang/en/messages.php` → `messages.key`) + /// and JSON translation files (`lang/en.json` → raw key strings). + fn enumerate_all_trans_keys(&self) -> Vec { + let snapshot = self.user_file_symbol_maps(); + let mut keys = Vec::new(); + + for (file_uri, _) in &snapshot { + if !(file_uri.contains("/lang/") || file_uri.contains("/resources/lang/")) { + continue; + } + if !file_uri.ends_with(".php") { + continue; + } + let Some(stem) = extract_lang_file_stem(file_uri) else { + continue; + }; + let Some(content) = self.get_file_content(file_uri) else { + continue; + }; + let decls = + crate::virtual_members::laravel::collect_trans_declarations(&content, &stem); + for d in decls { + keys.push(d.key); + } + } + + // Also collect keys from JSON translation files on disk. + collect_json_trans_keys(self, &mut keys); + + keys.sort(); + keys.dedup(); + keys + } + + pub(crate) fn cached_route_names(&self) -> Vec { + { + let cache = self.laravel_string_key_cache.read(); + if let Some(ref names) = cache.route_names { + return names.clone(); + } + } + let names = crate::virtual_members::laravel::enumerate_all_route_names(self); + self.laravel_string_key_cache.write().route_names = Some(names.clone()); + names + } + + pub(crate) fn cached_config_keys(&self) -> Vec { + { + let cache = self.laravel_string_key_cache.read(); + if let Some(ref keys) = cache.config_keys { + return keys.clone(); + } + } + let keys = self.enumerate_all_config_keys(); + self.laravel_string_key_cache.write().config_keys = Some(keys.clone()); + keys + } + + pub(crate) fn cached_view_names(&self) -> Vec { + { + let cache = self.laravel_string_key_cache.read(); + if let Some(ref names) = cache.view_names { + return names.clone(); + } + } + let names = self.enumerate_all_view_names(); + self.laravel_string_key_cache.write().view_names = Some(names.clone()); + names + } + + pub(crate) fn cached_trans_keys(&self) -> Vec { + { + let cache = self.laravel_string_key_cache.read(); + if let Some(ref keys) = cache.trans_keys { + return keys.clone(); + } + } + let keys = self.enumerate_all_trans_keys(); + self.laravel_string_key_cache.write().trans_keys = Some(keys.clone()); + keys + } +} + +/// Extract the dot-notated view name from a file URI. +/// +/// `file:///path/resources/views/users/profile.blade.php` → `"users.profile"` +pub(crate) fn extract_view_relative_path(uri: &str) -> Option { + let marker = "/resources/views/"; + let idx = uri.find(marker)?; + let rel = &uri[idx + marker.len()..]; + let name = rel + .strip_suffix(".blade.php") + .or_else(|| rel.strip_suffix(".php"))?; + if name.is_empty() { + return None; + } + Some(name.replace('/', ".")) +} + +/// Extract the file stem from a lang file URI for use as the translation +/// key prefix. +/// +/// `file:///path/lang/en/messages.php` → `"messages"` +fn extract_lang_file_stem(uri: &str) -> Option { + let file = uri.rsplit('/').next()?; + let stem = file.strip_suffix(".php")?; + if stem.is_empty() { + return None; + } + Some(stem.to_string()) +} + +/// Scan the workspace for `lang/*.json` files and collect their top-level +/// keys into `out`. Laravel's JSON translations are flat +/// `{ "Some phrase": "Translated phrase" }` objects where the key is used +/// directly in `__('Some phrase')`. +/// +/// We scan the filesystem because JSON files are not PHP and therefore do +/// not appear in `user_file_symbol_maps()`. +fn collect_json_trans_keys(backend: &crate::Backend, out: &mut Vec) { + let root = match backend.workspace_root.read().clone() { + Some(r) => r, + None => return, + }; + for sub in &["lang", "resources/lang"] { + let dir = root.join(sub); + let Ok(entries) = std::fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().is_some_and(|e| e == "json") + && let Ok(content) = std::fs::read_to_string(&path) + && let Ok(map) = + serde_json::from_str::>(&content) + { + for k in map.keys() { + out.push(k.clone()); + } + } + } + } +} + +// ─── Completion ───────────────────────────────────────────────────────────── + +impl Backend { + /// Try Laravel string key completion. + /// + /// Detects the cursor inside the first string argument of `route()`, + /// `config()`, `view()`, `__()`, etc. and offers matching key names. + pub(crate) fn try_laravel_string_key_completion( + &self, + content: &str, + position: Position, + ) -> Option { + let ctx = detect_laravel_string_key_context(content, position)?; + + let mut candidates = match ctx.kind { + LaravelStringKind::Route => self.cached_route_names(), + LaravelStringKind::Config => self.cached_config_keys(), + LaravelStringKind::View => self.cached_view_names(), + LaravelStringKind::Trans => self.cached_trans_keys(), + }; + + // For config-backed attributes like #[Database('mysql')], filter + // to sub-keys under the relevant config prefix and strip it so + // the user sees just the connection/store/channel name. + if let Some(sub_prefix) = ctx.config_sub_prefix { + candidates = candidates + .into_iter() + .filter_map(|key| { + key.strip_prefix(sub_prefix).and_then(|rest| { + // Only show direct children (no dots = leaf key). + if rest.contains('.') { + None + } else { + Some(rest.to_string()) + } + }) + }) + .collect(); + candidates.sort(); + candidates.dedup(); + } + + // Build the TextEdit range: from the start of the string content + // (right after the opening quote) to the current cursor position. + // This replaces the entire typed prefix with the selected name, + // so dots in the name don't break the editor's word-based filter. + let start_pos = crate::util::offset_to_position(content, ctx.content_start_offset); + let edit_range = Range { + start: start_pos, + end: position, + }; + + let prefix_lower = ctx.prefix.to_lowercase(); + let items: Vec = candidates + .into_iter() + .filter(|name| { + if prefix_lower.is_empty() { + true + } else { + name.to_lowercase().starts_with(&prefix_lower) + } + }) + .enumerate() + .map(|(i, name)| { + let kind = match ctx.kind { + LaravelStringKind::Route => CompletionItemKind::VALUE, + LaravelStringKind::Config => CompletionItemKind::PROPERTY, + LaravelStringKind::View => CompletionItemKind::FILE, + LaravelStringKind::Trans => CompletionItemKind::TEXT, + }; + CompletionItem { + label: name.clone(), + kind: Some(kind), + sort_text: Some(format!("{:05}", i)), + filter_text: Some(name.clone()), + text_edit: Some(CompletionTextEdit::Edit(TextEdit { + range: edit_range, + new_text: name, + })), + ..Default::default() + } + }) + .collect(); + + if items.is_empty() { + None + } else { + Some(CompletionResponse::Array(items)) + } + } +} + +// ─── Tests ────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use tower_lsp::lsp_types::Position; + + #[test] + fn detects_route_call() { + let content = " 'home')->name('home');\n\ + Route::get('/about', fn() => 'about')->name('about');\n"; + backend.open_files.write().insert( + route_uri.to_string(), + std::sync::Arc::new(route_content.to_string()), + ); + backend.update_ast(route_uri, route_content); + + let test_content = " = items.iter().map(|i| i.label.as_str()).collect(); + assert!( + labels.contains(&"home"), + "completion should include 'home', got: {:?}", + labels + ); + assert!( + labels.contains(&"about"), + "completion should include 'about', got: {:?}", + labels + ); + } + } +} diff --git a/src/completion/mod.rs b/src/completion/mod.rs index 9b0fe95c..9618d67a 100644 --- a/src/completion/mod.rs +++ b/src/completion/mod.rs @@ -80,6 +80,7 @@ pub(crate) mod call_resolution; pub(crate) mod eloquent_string; pub(crate) mod handler; pub(crate) mod laravel_route_controller; +pub(crate) mod laravel_string_keys; pub mod named_args; pub(crate) mod resolve; pub(crate) mod resolver; diff --git a/src/diagnostics/mod.rs b/src/diagnostics/mod.rs index 63ab6d96..93e92983 100644 --- a/src/diagnostics/mod.rs +++ b/src/diagnostics/mod.rs @@ -291,6 +291,132 @@ impl Backend { self.collect_deprecated_diagnostics(uri_str, content, out); self.collect_undefined_variable_diagnostics(uri_str, content, out); self.collect_invalid_class_kind_diagnostics(uri_str, content, out); + let is_laravel = self.resolved_class_cache.read().is_laravel(); + if is_laravel { + self.collect_invalid_laravel_string_key_diagnostics(uri_str, content, out); + } + } + + /// Emit a warning for each `LaravelStringKey` span whose key does + /// not resolve to any declaration (typo in route name, config key, + /// view name, or translation key). + fn collect_invalid_laravel_string_key_diagnostics( + &self, + uri: &str, + content: &str, + out: &mut Vec, + ) { + use crate::symbol_map::{LaravelStringKind, SymbolKind}; + use std::collections::HashSet; + + // Extract the LaravelStringKey spans we need and determine which + // kinds are present, then DROP the read lock before calling + // enumeration functions. Those functions call + // `user_file_symbol_maps()` → `ensure_workspace_indexed()` → + // `parse_files_parallel()` → `update_ast()` which acquires a + // WRITE lock on `symbol_maps`. Holding a read lock here while + // that write is attempted would deadlock. + let mut has_route = false; + let mut has_config = false; + let mut has_view = false; + let mut has_trans = false; + let key_spans: Vec<(LaravelStringKind, String, u32, u32)> = { + let maps = self.symbol_maps.read(); + let Some(symbol_map) = maps.get(uri) else { + return; + }; + symbol_map + .spans + .iter() + .filter_map(|span| { + if let SymbolKind::LaravelStringKey { kind, key } = &span.kind { + match kind { + LaravelStringKind::Route => has_route = true, + LaravelStringKind::Config => has_config = true, + LaravelStringKind::View => has_view = true, + LaravelStringKind::Trans => has_trans = true, + } + Some((kind.clone(), key.clone(), span.start, span.end)) + } else { + None + } + }) + .collect() + // `maps` read lock is dropped here. + }; + + if !has_route && !has_config && !has_view && !has_trans { + return; + } + + // Enumerate valid keys once per kind (lazy), using the cached + // enumerations. Safe to call now that the `symbol_maps` read + // lock has been released. + let route_keys: HashSet = if has_route { + self.cached_route_names().into_iter().collect() + } else { + HashSet::new() + }; + let config_keys: HashSet = if has_config { + self.cached_config_keys().into_iter().collect() + } else { + HashSet::new() + }; + let view_keys: HashSet = if has_view { + self.cached_view_names().into_iter().collect() + } else { + HashSet::new() + }; + let trans_keys: HashSet = if has_trans { + self.cached_trans_keys().into_iter().collect() + } else { + HashSet::new() + }; + + for (kind, key, start, end) in &key_spans { + let (valid, label, code) = match kind { + LaravelStringKind::Route => { + (route_keys.contains(key), "route", "invalid_laravel_route") + } + LaravelStringKind::Config => { + // Config keys may be partial prefixes (e.g. `config('app')`) + // which are valid even without a direct match. + let valid = config_keys.contains(key) + || config_keys + .iter() + .any(|k| k.starts_with(&format!("{}.", key))); + (valid, "config key", "invalid_laravel_config") + } + LaravelStringKind::View => { + (view_keys.contains(key), "view", "invalid_laravel_view") + } + LaravelStringKind::Trans => { + // When no translation files are found at all, skip trans + // diagnostics entirely. This avoids false positives in + // non-Laravel projects (WordPress, GetText) that also use + // `__()` or `trans()` as function names. + if trans_keys.is_empty() { + continue; + } + let valid = trans_keys.contains(key) + || trans_keys + .iter() + .any(|k| k.starts_with(&format!("{}.", key))); + (valid, "translation key", "invalid_laravel_trans") + } + }; + if !valid + && let Some(range) = + offset_range_to_lsp_range(content, *start as usize, *end as usize) + { + out.push(helpers::make_diagnostic( + range, + DiagnosticSeverity::WARNING, + code, + format!("Unknown {}: '{}'", label, key), + )); + } + } } } @@ -3057,4 +3183,80 @@ mod tests { "pull must not compute diagnostics inline on the request path" ); } + + /// Regression test: `collect_invalid_laravel_string_key_diagnostics` + /// must not hold a `symbol_maps` read lock while calling enumeration + /// functions that reach `ensure_workspace_indexed()` → + /// `parse_files_parallel()` → `update_ast()` → `symbol_maps.write()`. + /// + /// Before the fix, this deadlocked because the read lock was held + /// for the entire function body. The fix extracts the needed spans + /// into an owned `Vec` and drops the lock before enumerating keys. + /// + /// This test exercises the exact code path with a file containing a + /// `config()` call. A 5-second timeout catches the deadlock as a + /// test failure instead of an infinite hang. + /// Regression test: `collect_invalid_laravel_string_key_diagnostics` + /// must not hold a `symbol_maps` read lock while calling enumeration + /// functions that reach `ensure_workspace_indexed()` → + /// `parse_files_parallel()` → `update_ast()` → `symbol_maps.write()`. + /// + /// Before the fix, this deadlocked because the read lock was held + /// for the entire function body. The fix extracts the needed spans + /// into an owned `Vec` and drops the lock before enumerating keys. + /// + /// To trigger the deadlock path, we create a workspace with an + /// unindexed PHP file so `ensure_workspace_indexed` must parse it + /// (acquiring a write lock). A 5-second timeout catches the + /// deadlock as a test failure instead of an infinite hang. + #[test] + fn laravel_string_key_diagnostics_no_deadlock() { + // Set up a temp workspace with an unindexed PHP file so that + // ensure_workspace_indexed() will call parse_files_parallel() + // which needs a write lock on symbol_maps. + let tmp = std::env::temp_dir().join("phpantom_deadlock_test"); + let _ = std::fs::create_dir_all(&tmp); + let unindexed_file = tmp.join("Unindexed.php"); + std::fs::write(&unindexed_file, " { /* success — no deadlock */ } + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { + panic!( + "collect_slow_diagnostics deadlocked: symbol_maps read lock \ + was likely held while enumeration functions tried to write" + ); + } + Err(e) => panic!("collect_slow_diagnostics failed: {:?}", e), + } + + // Clean up. + let _ = std::fs::remove_dir_all(&tmp); + } } diff --git a/src/hover/mod.rs b/src/hover/mod.rs index fcdb08ab..8d1d02c6 100644 --- a/src/hover/mod.rs +++ b/src/hover/mod.rs @@ -854,14 +854,97 @@ impl Backend { } } - SymbolKind::LaravelStringKey { .. } - | SymbolKind::LaravelMacroString { .. } + SymbolKind::LaravelStringKey { kind, key } => self.hover_laravel_string_key(kind, key), + + SymbolKind::LaravelMacroString { .. } | SymbolKind::Keyword | SymbolKind::CastType | SymbolKind::Comment => None, } } + /// Build hover content for a Laravel string key (route name, config + /// key, view name, or translation key). + fn hover_laravel_string_key( + &self, + kind: &crate::symbol_map::LaravelStringKind, + key: &str, + ) -> Option { + use crate::symbol_map::LaravelStringKind; + + let (label, detail) = match kind { + LaravelStringKind::Route => { + // Try to resolve the route to show where it's defined. + let locations = + crate::virtual_members::laravel::resolve_laravel_string_key(self, kind, key); + let detail = if let Some(loc) = locations.first() { + let path = loc.uri.path(); + let short_path = path + .rsplit("/routes/") + .next() + .map(|p| format!("routes/{}", p)) + .unwrap_or_else(|| path.to_string()); + format!("Defined in `{}`", short_path) + } else { + "Route name".to_string() + }; + ("Route", detail) + } + LaravelStringKind::Config => { + // Try to resolve the config key to show its value. + let locations = + crate::virtual_members::laravel::resolve_laravel_string_key(self, kind, key); + let detail = if let Some(loc) = locations.first() { + let path = loc.uri.path(); + let short_path = path + .rsplit("/config/") + .next() + .map(|p| format!("config/{}", p)) + .unwrap_or_else(|| path.to_string()); + format!("Defined in `{}`", short_path) + } else { + "Config key".to_string() + }; + ("Config", detail) + } + LaravelStringKind::View => { + // Show the resolved file path. + let locations = + crate::virtual_members::laravel::resolve_laravel_string_key(self, kind, key); + let detail = if let Some(loc) = locations.first() { + let path = loc.uri.path(); + let short_path = path + .rsplit("/resources/views/") + .next() + .map(|p| format!("resources/views/{}", p)) + .unwrap_or_else(|| path.to_string()); + format!("`{}`", short_path) + } else { + "View template".to_string() + }; + ("View", detail) + } + LaravelStringKind::Trans => { + let locations = + crate::virtual_members::laravel::resolve_laravel_string_key(self, kind, key); + let detail = if let Some(loc) = locations.first() { + let path = loc.uri.path(); + let short_path = path + .rsplit("/lang/") + .next() + .map(|p| format!("lang/{}", p)) + .unwrap_or_else(|| path.to_string()); + format!("Defined in `{}`", short_path) + } else { + "Translation key".to_string() + }; + ("Trans", detail) + } + }; + + Some(make_hover(format!("**{}** `{}`\n\n{}", label, key, detail))) + } + /// Look up a global constant by name, returning its value if found. /// /// Searches in order: diff --git a/src/lib.rs b/src/lib.rs index adb9f085..d93dbd52 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -213,6 +213,31 @@ pub use virtual_members::resolve_class_fully; /// `collect_deprecated_diagnostics`, `collect_unused_import_diagnostics`, /// `collect_unknown_class_diagnostics`, /// `collect_unknown_member_diagnostics` (includes unresolved-member-access logic) +#[derive(Default)] +pub(crate) struct LaravelStringKeyCache { + pub route_names: Option>, + pub config_keys: Option>, + pub view_names: Option>, + pub trans_keys: Option>, +} + +impl LaravelStringKeyCache { + fn invalidate_for_uri(&mut self, uri: &str) { + if uri.contains("/routes/") { + self.route_names = None; + } + if uri.contains("/config/") { + self.config_keys = None; + } + if uri.contains("/resources/views/") { + self.view_names = None; + } + if uri.contains("/lang/") || uri.contains("/resources/lang/") { + self.trans_keys = None; + } + } +} + pub struct Backend { pub(crate) name: String, pub(crate) version: String, @@ -469,6 +494,11 @@ pub struct Backend { /// references triggers a full index rebuild; every other edit takes the /// cheap single-file refresh path. pub(crate) laravel_macro_seeds: Arc>>>, + /// Cached Laravel string key enumerations (route names, config keys, + /// view names, translation keys). `None` = not yet computed. + /// Invalidated when a file in `routes/`, `config/`, `resources/views/`, + /// or `lang/` is updated. + pub(crate) laravel_string_key_cache: Arc>, /// Per-target member completion cache. /// /// Typing `$model->wh...` triggers a completion request for each @@ -890,6 +920,7 @@ impl Backend { )), laravel_has_macros: Arc::new(std::sync::atomic::AtomicBool::new(false)), laravel_macro_seeds: Arc::new(RwLock::new(HashMap::new())), + laravel_string_key_cache: Arc::new(RwLock::new(LaravelStringKeyCache::default())), member_completion_cache: Arc::new(Mutex::new(HashMap::new())), method_store: Arc::new(RwLock::new(HashMap::new())), gti_index: Arc::new(RwLock::new(HashMap::new())), @@ -985,6 +1016,7 @@ impl Backend { )), laravel_has_macros: Arc::new(std::sync::atomic::AtomicBool::new(false)), laravel_macro_seeds: Arc::new(RwLock::new(HashMap::new())), + laravel_string_key_cache: Arc::new(RwLock::new(LaravelStringKeyCache::default())), member_completion_cache: Arc::new(Mutex::new(HashMap::new())), method_store: Arc::new(RwLock::new(HashMap::new())), gti_index: Arc::new(RwLock::new(HashMap::new())), @@ -1579,6 +1611,7 @@ impl Backend { laravel_macros: Arc::clone(&self.laravel_macros), laravel_has_macros: Arc::clone(&self.laravel_has_macros), laravel_macro_seeds: Arc::clone(&self.laravel_macro_seeds), + laravel_string_key_cache: Arc::clone(&self.laravel_string_key_cache), member_completion_cache: Arc::clone(&self.member_completion_cache), method_store: Arc::clone(&self.method_store), gti_index: Arc::clone(&self.gti_index), diff --git a/src/parser/ast_update.rs b/src/parser/ast_update.rs index 2f9ea124..e7354987 100644 --- a/src/parser/ast_update.rs +++ b/src/parser/ast_update.rs @@ -80,6 +80,10 @@ impl Backend { content.to_string() }; + self.laravel_string_key_cache + .write() + .invalidate_for_uri(uri); + // The mago-syntax parser contains `unreachable!()` and `.expect()` // calls that can panic on malformed PHP (e.g. partially-written // heredocs/nowdocs, which are common while editing). Wrap the diff --git a/src/symbol_map/extraction.rs b/src/symbol_map/extraction.rs index 78da57b1..dd7a82b4 100644 --- a/src/symbol_map/extraction.rs +++ b/src/symbol_map/extraction.rs @@ -80,6 +80,9 @@ struct ExtractionCtx<'a> { /// Stack of block-end offsets for each conditional nesting level. /// The top of the stack is the end of the innermost conditional block. cond_block_end_stack: Vec, + /// Whether the file imports from `Illuminate\Container\Attributes\` + /// (checked once lazily, cached for all attribute inspections). + has_laravel_container_attrs: Option, } // ─── Keyword helper ───────────────────────────────────────────────────────── @@ -151,6 +154,7 @@ pub(crate) fn extract_symbol_map(program: &Program<'_>, content: &str) -> Symbol untyped_closure_sites: Vec::new(), cond_nesting_depth: 0, cond_block_end_stack: Vec::new(), + has_laravel_container_attrs: None, }; for stmt in program.statements.iter() { @@ -1047,6 +1051,23 @@ fn extract_from_attribute_lists<'a>( &mut ctx.untyped_closure_sites, ); } + + // Laravel container attributes: #[Config('key')], + // #[Database('conn')], #[Cache('store')], etc. → + // emit a LaravelStringKey::Config span so hover, + // go-to-definition, and diagnostics work on the key. + // + // FQN attributes match directly. Short names require + // the file to import from the Illuminate namespace; + // that check is cached once per file to avoid repeated + // linear scans. + if let Some(kind) = resolve_laravel_container_attr( + class_name, + &mut ctx.has_laravel_container_attrs, + ctx.content, + ) { + try_emit_laravel_string_span(kind, arg_list, ctx.content, &mut ctx.spans); + } } } } @@ -1956,38 +1977,32 @@ fn extract_from_expression<'a>( is_definition: false, }, }); - if name_clean.eq_ignore_ascii_case("config") { - try_emit_laravel_string_span( - crate::symbol_map::LaravelStringKind::Config, - &func_call.argument_list, - ctx.content, - &mut ctx.spans, - ); - } - if name_clean.eq_ignore_ascii_case("view") + // Detect Laravel helper calls and emit a + // LaravelStringKey span for the first string arg. + // Uses if-else to short-circuit (most function calls + // won't match) and avoids to_ascii_lowercase() heap + // allocations. + let laravel_kind = if name_clean.eq_ignore_ascii_case("config") { + Some(crate::symbol_map::LaravelStringKind::Config) + } else if name_clean.eq_ignore_ascii_case("view") || name_clean.eq_ignore_ascii_case("blade_view_directive") { + Some(crate::symbol_map::LaravelStringKind::View) + } else if name_clean.eq_ignore_ascii_case("route") + || name_clean.eq_ignore_ascii_case("to_route") + { + Some(crate::symbol_map::LaravelStringKind::Route) + } else if name_clean.eq_ignore_ascii_case("__") + || name_clean.eq_ignore_ascii_case("trans") + || name_clean.eq_ignore_ascii_case("trans_choice") + { + Some(crate::symbol_map::LaravelStringKind::Trans) + } else { + None + }; + if let Some(kind) = laravel_kind { try_emit_laravel_string_span( - crate::symbol_map::LaravelStringKind::View, - &func_call.argument_list, - ctx.content, - &mut ctx.spans, - ); - } - if name_clean.eq_ignore_ascii_case("route") { - try_emit_laravel_string_span( - crate::symbol_map::LaravelStringKind::Route, - &func_call.argument_list, - ctx.content, - &mut ctx.spans, - ); - } - if matches!( - name_clean.to_ascii_lowercase().as_str(), - "__" | "trans" | "trans_choice" - ) { - try_emit_laravel_string_span( - crate::symbol_map::LaravelStringKind::Trans, + kind, &func_call.argument_list, ctx.content, &mut ctx.spans, @@ -3448,6 +3463,52 @@ fn is_assert_instanceof(expr: &Expression<'_>) -> bool { false } +/// Check whether an attribute class name refers to a Laravel container +/// attribute (`Config`, `Database`, `Cache`, `Log`, `Storage`, `Auth`, +/// `Authenticated`). Returns the corresponding [`LaravelStringKind`] if +/// so — always `Config` since all container attributes resolve to config +/// sub-keys. +/// +/// FQN names (containing `\`) are matched directly against +/// `Illuminate\Container\Attributes\*`. Short names require the file to +/// import from that namespace; the result of that check is cached in +/// `import_cache` to avoid repeated linear scans of the file content. +const LARAVEL_CONTAINER_ATTR_NS: &str = "Illuminate\\Container\\Attributes\\"; +const LARAVEL_CONTAINER_ATTR_NAMES: &[&str] = &[ + "Config", + "Database", + "DB", + "Cache", + "Log", + "Storage", + "Auth", + "Authenticated", +]; + +fn resolve_laravel_container_attr( + class_name: &str, + import_cache: &mut Option, + content: &str, +) -> Option { + if class_name.contains('\\') { + let stripped = class_name.strip_prefix(LARAVEL_CONTAINER_ATTR_NS)?; + if LARAVEL_CONTAINER_ATTR_NAMES.contains(&stripped) { + return Some(crate::symbol_map::LaravelStringKind::Config); + } + return None; + } + if !LARAVEL_CONTAINER_ATTR_NAMES.contains(&class_name) { + return None; + } + let has_import = *import_cache + .get_or_insert_with(|| content.contains("use Illuminate\\Container\\Attributes\\")); + if has_import { + Some(crate::symbol_map::LaravelStringKind::Config) + } else { + None + } +} + /// If the first argument of `argument_list` is a non-empty, non-interpolated /// string literal, push a [`SymbolKind::LaravelStringKey`] span covering the /// string content (inside the quotes) onto `spans`. diff --git a/src/virtual_members/laravel/mod.rs b/src/virtual_members/laravel/mod.rs index 36457420..0e554140 100644 --- a/src/virtual_members/laravel/mod.rs +++ b/src/virtual_members/laravel/mod.rs @@ -98,7 +98,8 @@ pub(crate) use aliases::LaravelAliases; pub(crate) use auth::{GUARD_FQN, REQUEST_FQN, patch_auth_user_class, resolve_auth_user_type}; pub(crate) use config_keys::find_config_references; pub(crate) use config_keys::{ - find_all_config_references, resolve_config_key_declaration, + collect_laravel_config_declarations, find_all_config_references, + laravel_config_prefix_from_uri, resolve_config_key_declaration, resolve_config_key_definition_fallback, }; pub(crate) use env_vars::resolve_env_definition; @@ -106,6 +107,8 @@ pub(crate) use macros::{ LaravelMacroIndex, MacroRegistration, extract_macro_registrations, inject_macros, parse_installed_providers, parse_provider_class_list, parse_provider_referenced_classes, }; +pub(crate) use route_names::enumerate_all_route_names; +pub(crate) use trans_keys::collect_trans_declarations; /// Unified go-to-definition entry point for all Laravel string-key spans. /// diff --git a/src/virtual_members/laravel/route_names.rs b/src/virtual_members/laravel/route_names.rs index edc48ea9..2bd15de2 100644 --- a/src/virtual_members/laravel/route_names.rs +++ b/src/virtual_members/laravel/route_names.rs @@ -8,6 +8,109 @@ use crate::util::offset_to_position; use super::helpers::extract_string_literal; +/// Conventional route name suffixes generated by `Route::resource()`. +const RESOURCE_SUFFIXES: &[&str] = &[ + "index", "create", "store", "show", "edit", "update", "destroy", +]; + +/// Conventional route name suffixes generated by `Route::apiResource()`. +const API_RESOURCE_SUFFIXES: &[&str] = &["index", "store", "show", "update", "destroy"]; + +/// Derive the route name base from a resource URI string. +/// +/// Laravel converts `/` to `.` and uses the result as the name prefix. +/// For example, `"photo-comments"` → `"photo-comments"`, +/// `"photos/comments"` → `"photos.comments"`. +fn resource_name_base(uri: &str) -> String { + uri.trim_matches('/').replace('/', ".") +} + +/// Build the filtered list of resource route suffixes, respecting +/// `->only()` and `->except()` modifiers. +fn filtered_resource_suffixes( + is_api: bool, + only: &[String], + except: &[String], +) -> Vec<&'static str> { + let base = if is_api { + API_RESOURCE_SUFFIXES + } else { + RESOURCE_SUFFIXES + }; + base.iter() + .copied() + .filter(|s| { + if !only.is_empty() { + only.iter().any(|o| o == s) + } else if !except.is_empty() { + !except.iter().any(|e| e == s) + } else { + true + } + }) + .collect() +} + +/// Extract string values from `->only()`/`->except()` arguments. +/// +/// Handles both array form `->only(['index', 'show'])` and individual +/// args `->except('create', 'edit')`. +fn extract_string_list_from_args<'a>(args: &'a ArgumentList<'a>, content: &'a str) -> Vec { + let mut values = Vec::new(); + for arg in args.arguments.iter() { + match arg.value() { + // Array form: ['index', 'show'] + Expression::Array(arr) => { + for el in arr.elements.iter() { + if let ArrayElement::Value(v) = el + && let Some((val, _, _)) = extract_string_literal(v.value, content) + { + values.push(val.to_string()); + } + } + } + Expression::LegacyArray(arr) => { + for el in arr.elements.iter() { + if let ArrayElement::Value(v) = el + && let Some((val, _, _)) = extract_string_literal(v.value, content) + { + values.push(val.to_string()); + } + } + } + // Individual string arg: 'create' + other => { + if let Some((val, _, _)) = extract_string_literal(other, content) { + values.push(val.to_string()); + } + } + } + } + values +} + +/// Check if a static method call is `Route::resource()` or `Route::apiResource()` +/// and extract the resource name and whether it's an API resource. +fn extract_resource_info<'a>( + sc: &'a StaticMethodCall<'a>, + content: &'a str, +) -> Option<(&'a str, usize, bool)> { + let ClassLikeMemberSelector::Identifier(ident) = &sc.method else { + return None; + }; + let method_lower = ident.value.to_ascii_lowercase(); + let is_api = if method_lower == b"resource" { + false + } else if method_lower == b"apiresource" { + true + } else { + return None; + }; + let first_arg = sc.argument_list.arguments.iter().next()?; + let (res_name, start, _) = extract_string_literal(first_arg.value(), content)?; + Some((res_name, start, is_api)) +} + /// Resolve `route('name')` to the `->name('name')` declaration in `routes/`. /// /// Supports both explicit full-name declarations: @@ -20,7 +123,6 @@ pub(crate) fn resolve_route_definitions(backend: &Backend, name: &str) -> Vec Vec Vec { +fn scan_route_file( + content: &str, + target: &str, + uri: &Url, + file_dir: Option<&std::path::Path>, +) -> Vec { let arena = Bump::new(); let file_id = FileId::new(b"input.php"); let program = mago_syntax::parser::parse_file_content(&arena, file_id, content.as_bytes()); let mut results = Vec::new(); for stmt in program.statements.iter() { - results.extend(scan_stmt(stmt, content, "", target, uri)); + results.extend(scan_stmt(stmt, content, "", target, uri, file_dir)); } results } @@ -55,12 +166,13 @@ fn scan_stmt<'a>( prefix: &str, target: &str, uri: &Url, + file_dir: Option<&std::path::Path>, ) -> Vec { match stmt { - Statement::Expression(e) => scan_expr(e.expression, content, prefix, target, uri), + Statement::Expression(e) => scan_expr(e.expression, content, prefix, target, uri, file_dir), Statement::Return(r) => r .value - .map(|v| scan_expr(v, content, prefix, target, uri)) + .map(|v| scan_expr(v, content, prefix, target, uri, file_dir)) .unwrap_or_default(), _ => Vec::new(), } @@ -79,13 +191,14 @@ fn scan_expr<'a>( prefix: &str, target: &str, uri: &Url, + file_dir: Option<&std::path::Path>, ) -> Vec { match expr { // ── Fluent instance-method chain: ->group() / ->name() / other ────── Expression::Call(Call::Method(mc)) => { let mut results = Vec::new(); let ClassLikeMemberSelector::Identifier(ident) = &mc.method else { - return scan_expr(mc.object, content, prefix, target, uri); + return scan_expr(mc.object, content, prefix, target, uri, file_dir); }; let method = ident.value.to_ascii_lowercase(); @@ -99,6 +212,7 @@ fn scan_expr<'a>( &new_prefix, target, uri, + file_dir, )); } } else if method == b"name" { @@ -114,41 +228,91 @@ fn scan_expr<'a>( )); } } - results.extend(scan_expr(mc.object, content, prefix, target, uri)); + results.extend(scan_expr(mc.object, content, prefix, target, uri, file_dir)); + } else if method == b"only" || method == b"except" { + // ->only(['index', 'show']) or ->except(['create', 'edit']) + // on a Route::resource() / Route::apiResource() call. + if let Expression::Call(Call::StaticMethod(sc)) = mc.object + && let Some((res_name, start, is_api)) = extract_resource_info(sc, content) + { + let filter = extract_string_list_from_args(&mc.argument_list, content); + let (only, except) = if method == b"only" { + (filter, Vec::new()) + } else { + (Vec::new(), filter) + }; + let suffixes = filtered_resource_suffixes(is_api, &only, &except); + let base = resource_name_base(res_name); + for suffix in suffixes { + let full = format!("{prefix}{base}.{suffix}"); + if full == target { + return vec![crate::definition::point_location( + uri.clone(), + offset_to_position(content, start), + )]; + } + } + return Vec::new(); + } + results.extend(scan_expr(mc.object, content, prefix, target, uri, file_dir)); } else { - results.extend(scan_expr(mc.object, content, prefix, target, uri)); + results.extend(scan_expr(mc.object, content, prefix, target, uri, file_dir)); } results } - // ── Direct static call: Route::group([options,] fn(){…}) ──────────── - // - // This fires for `Route::group(['as'=>'admin.', …], fn(){…})` where - // there is no preceding fluent chain to carry the name prefix. + // ── Direct static call: Route::group / Route::resource / Route::apiResource Expression::Call(Call::StaticMethod(sc)) => { let ClassLikeMemberSelector::Identifier(ident) = &sc.method else { return Vec::new(); }; - if !ident.value.eq_ignore_ascii_case(b"group") { - return Vec::new(); - } - let mut results = Vec::new(); - // Extract name prefix from 'as' => '...' in an array argument. - let array_prefix = extract_as_prefix_from_args( - sc.argument_list.arguments.iter().map(|a| a.value()), - content, - ); - let new_prefix = format!("{prefix}{array_prefix}"); - for arg in sc.argument_list.arguments.iter() { - results.extend(scan_group_body( - arg.value(), + let method_lower = ident.value.to_ascii_lowercase(); + + if method_lower == b"group" { + let mut results = Vec::new(); + let array_prefix = extract_as_prefix_from_args( + sc.argument_list.arguments.iter().map(|a| a.value()), content, - &new_prefix, - target, - uri, - )); + ); + let new_prefix = format!("{prefix}{array_prefix}"); + for arg in sc.argument_list.arguments.iter() { + results.extend(scan_group_body( + arg.value(), + content, + &new_prefix, + target, + uri, + file_dir, + )); + } + results + } else if method_lower == b"resource" || method_lower == b"apiresource" { + // Route::resource('presses', Controller::class) generates + // conventional named routes like presses.index, presses.show, etc. + let suffixes = if method_lower == b"resource" { + RESOURCE_SUFFIXES + } else { + API_RESOURCE_SUFFIXES + }; + if let Some(first_arg) = sc.argument_list.arguments.iter().next() + && let Some((res_name, start, _)) = + extract_string_literal(first_arg.value(), content) + { + let base = resource_name_base(res_name); + for suffix in suffixes { + let full = format!("{prefix}{base}.{suffix}"); + if full == target { + return vec![crate::definition::point_location( + uri.clone(), + offset_to_position(content, start), + )]; + } + } + } + Vec::new() + } else { + Vec::new() } - results } _ => Vec::new(), @@ -156,23 +320,310 @@ fn scan_expr<'a>( } /// Walk the argument that was passed to `->group()`. +/// +/// Handles closures, arrow functions, and `__DIR__ . '/sub.php'` file +/// includes so that go-to-definition works on route names defined in +/// included sub-files. fn scan_group_body<'a>( expr: &Expression<'a>, content: &str, prefix: &str, target: &str, uri: &Url, + file_dir: Option<&std::path::Path>, ) -> Vec { match expr { Expression::Closure(closure) => { let mut results = Vec::new(); for stmt in closure.body.statements.iter() { - results.extend(scan_stmt(stmt, content, prefix, target, uri)); + results.extend(scan_stmt(stmt, content, prefix, target, uri, file_dir)); } results } - Expression::ArrowFunction(af) => scan_expr(af.expression, content, prefix, target, uri), - _ => Vec::new(), + Expression::ArrowFunction(af) => { + scan_expr(af.expression, content, prefix, target, uri, file_dir) + } + _ => { + // Follow `Route::group([], __DIR__ . '/sub.php')` file includes. + // Parse the included file and scan it with the current prefix + // so routes inherit the parent group's name prefix. + // (Do NOT call `scan_route_file` — it resets the prefix to "".) + if let Some(dir) = file_dir + && let Some(rel_path) = extract_dir_concat_path(expr, content) + { + let included = dir.join(rel_path.trim_start_matches('/')); + if let Ok(ref included_content) = std::fs::read_to_string(&included) { + let sub_uri = Url::from_file_path(&included).unwrap_or_else(|_| uri.clone()); + let sub_dir = included.parent().map(|d| d.to_path_buf()); + let arena = Bump::new(); + let fid = FileId::new(b"included.php"); + let prog = mago_syntax::parser::parse_file_content( + &arena, + fid, + included_content.as_bytes(), + ); + let mut results = Vec::new(); + for stmt in prog.statements.iter() { + results.extend(scan_stmt( + stmt, + included_content, + prefix, + target, + &sub_uri, + sub_dir.as_deref(), + )); + } + return results; + } + } + Vec::new() + } + } +} + +/// Collect all route names defined in `routes/` files. +/// +/// Returns a sorted, deduplicated list of fully-qualified route names +/// (e.g. `["admin.dashboard", "admin.users.index", "home"]`). +/// +/// Follows `Route::group([], __DIR__ . '/sub.php')` file includes so +/// that routes in sub-files inherit the parent group's name prefix. +pub(crate) fn enumerate_all_route_names(backend: &Backend) -> Vec { + let mut names = Vec::new(); + let snapshot = backend.user_file_symbol_maps(); + + for (file_uri, _) in snapshot { + if !file_uri.contains("/routes/") { + continue; + } + // Skip sub-directory route files at the top level — they are + // included by parent files via `Route::group([], __DIR__ . '/sub.php')` + // and should only be scanned in that context to inherit the + // correct name prefix. A "sub-directory" file is one where the + // path after the last `/routes/` segment contains another `/`. + if let Some(after) = file_uri.rsplit("/routes/").next() + && after.contains('/') + { + continue; + } + let Some(content) = backend.get_file_content(&file_uri) else { + continue; + }; + let file_dir = Url::parse(&file_uri) + .ok() + .and_then(|u| u.to_file_path().ok()) + .and_then(|p| p.parent().map(|d| d.to_path_buf())); + collect_all_names_from_file(&content, file_dir.as_deref(), &mut names); + } + names.sort(); + names.dedup(); + names +} + +/// Parse a single route file and collect every `->name('...')` value, +/// accounting for group prefixes and file includes. +fn collect_all_names_from_file( + content: &str, + file_dir: Option<&std::path::Path>, + out: &mut Vec, +) { + let arena = Bump::new(); + let file_id = FileId::new(b"input.php"); + let program = mago_syntax::parser::parse_file_content(&arena, file_id, content.as_bytes()); + for stmt in program.statements.iter() { + collect_names_from_stmt(stmt, content, "", file_dir, out); + } +} + +fn collect_names_from_stmt<'a>( + stmt: &Statement<'a>, + content: &str, + prefix: &str, + file_dir: Option<&std::path::Path>, + out: &mut Vec, +) { + match stmt { + Statement::Expression(e) => { + collect_names_from_expr(e.expression, content, prefix, file_dir, out); + } + Statement::Return(r) => { + if let Some(v) = r.value { + collect_names_from_expr(v, content, prefix, file_dir, out); + } + } + _ => {} + } +} + +fn collect_names_from_expr<'a>( + expr: &Expression<'a>, + content: &str, + prefix: &str, + file_dir: Option<&std::path::Path>, + out: &mut Vec, +) { + match expr { + Expression::Call(Call::Method(mc)) => { + let ClassLikeMemberSelector::Identifier(ident) = &mc.method else { + collect_names_from_expr(mc.object, content, prefix, file_dir, out); + return; + }; + let method = ident.value.to_ascii_lowercase(); + + if method == b"group" { + let chain_prefix = chain_name_prefix(mc.object, content); + let new_prefix = format!("{prefix}{chain_prefix}"); + for arg in mc.argument_list.arguments.iter() { + collect_names_from_group_body(arg.value(), content, &new_prefix, file_dir, out); + } + } else if method == b"name" { + if let Some(first_arg) = mc.argument_list.arguments.iter().next() + && let Some((name_val, _, _)) = + extract_string_literal(first_arg.value(), content) + { + let full = format!("{prefix}{name_val}"); + // Only collect leaf names (non-prefix names that don't end with '.'). + if !full.is_empty() && !full.ends_with('.') { + out.push(full); + } + } + collect_names_from_expr(mc.object, content, prefix, file_dir, out); + } else if method == b"only" || method == b"except" { + // ->only() / ->except() on Route::resource() / apiResource() + if let Expression::Call(Call::StaticMethod(sc)) = mc.object + && let Some((res_name, _, is_api)) = extract_resource_info(sc, content) + { + let filter = extract_string_list_from_args(&mc.argument_list, content); + let (only, except) = if method == b"only" { + (filter, Vec::new()) + } else { + (Vec::new(), filter) + }; + let suffixes = filtered_resource_suffixes(is_api, &only, &except); + let base = resource_name_base(res_name); + for suffix in suffixes { + let full = format!("{prefix}{base}.{suffix}"); + out.push(full); + } + return; + } + collect_names_from_expr(mc.object, content, prefix, file_dir, out); + } else { + collect_names_from_expr(mc.object, content, prefix, file_dir, out); + } + } + Expression::Call(Call::StaticMethod(sc)) => { + let ClassLikeMemberSelector::Identifier(ident) = &sc.method else { + return; + }; + let method_lower = ident.value.to_ascii_lowercase(); + + if method_lower == b"group" { + let array_prefix = extract_as_prefix_from_args( + sc.argument_list.arguments.iter().map(|a| a.value()), + content, + ); + let new_prefix = format!("{prefix}{array_prefix}"); + for arg in sc.argument_list.arguments.iter() { + collect_names_from_group_body(arg.value(), content, &new_prefix, file_dir, out); + } + } else if method_lower == b"resource" || method_lower == b"apiresource" { + // Route::resource('presses', Controller::class) without only/except + // generates all conventional route names. + if let Some((res_name, _, is_api)) = extract_resource_info(sc, content) { + let suffixes = filtered_resource_suffixes(is_api, &[], &[]); + let base = resource_name_base(res_name); + for suffix in suffixes { + let full = format!("{prefix}{base}.{suffix}"); + out.push(full); + } + } + } + } + _ => {} + } +} + +fn collect_names_from_group_body<'a>( + expr: &Expression<'a>, + content: &str, + prefix: &str, + file_dir: Option<&std::path::Path>, + out: &mut Vec, +) { + match expr { + Expression::Closure(closure) => { + for stmt in closure.body.statements.iter() { + collect_names_from_stmt(stmt, content, prefix, file_dir, out); + } + } + Expression::ArrowFunction(af) => { + collect_names_from_expr(af.expression, content, prefix, file_dir, out); + } + _ => { + // Handle `Route::group([], __DIR__ . '/sub.php')` file includes. + // The expression is a binary concatenation: `__DIR__ . '/path'`. + if let Some(dir) = file_dir + && let Some(rel_path) = extract_dir_concat_path(expr, content) + { + let included = dir.join(rel_path.trim_start_matches('/')); + if let Ok(included_content) = std::fs::read_to_string(&included) { + let sub_dir = included.parent().map(|d| d.to_path_buf()); + // Scan the included file with the current prefix so + // routes inherit the parent group's name (e.g. + // "eaglesys::"). Do NOT scan with empty prefix — + // that produces unprefixed names that are incorrect. + let arena2 = Bump::new(); + let fid2 = FileId::new(b"included.php"); + let prog2 = mago_syntax::parser::parse_file_content( + &arena2, + fid2, + included_content.as_bytes(), + ); + for stmt in prog2.statements.iter() { + collect_names_from_stmt( + stmt, + &included_content, + prefix, + sub_dir.as_deref(), + out, + ); + } + } + } + } + } +} + +/// Try to extract a relative path from a `__DIR__ . '/path.php'` expression. +/// +/// Returns the string literal portion (e.g. `"/ems/accounting.php"`). +fn extract_dir_concat_path<'a>(expr: &Expression<'a>, content: &'a str) -> Option<&'a str> { + let Expression::Binary(bin) = expr else { + return None; + }; + // Check LHS is __DIR__ + let is_dir = matches!( + bin.lhs, + Expression::MagicConstant(MagicConstant::Directory { .. }) + ); + if !is_dir { + return None; + } + // RHS should be a string literal + let Expression::Literal(literal::Literal::String(s)) = bin.rhs else { + return None; + }; + if let Some(value) = s.value { + Some(crate::atom::bytes_to_str(value)) + } else { + let start = s.span.start.offset as usize + 1; + let end = s.span.end.offset as usize - 1; + if start < end && end <= content.len() { + Some(&content[start..end]) + } else { + None + } } } diff --git a/src/virtual_members/laravel/trans_keys.rs b/src/virtual_members/laravel/trans_keys.rs index 782ed6e5..77236c31 100644 --- a/src/virtual_members/laravel/trans_keys.rs +++ b/src/virtual_members/laravel/trans_keys.rs @@ -7,28 +7,32 @@ use crate::Backend; use crate::atom::bytes_to_str; /// Resolve `__('file.key')` / `trans('file.key')` / `Lang::get('file.key')` to the -/// matching keys inside all matching `lang/{locale}/file.php` translation files. +/// matching keys inside all matching `lang/{locale}/file.php` translation files, +/// or inside `lang/{locale}.json` JSON translation files. +/// +/// For PHP files the key format is `file_stem.nested.key` (first segment = file, +/// rest = array path). For JSON files the key is looked up directly as a +/// top-level object key (Laravel's JSON translations are flat). /// -/// The key format is `file_stem.nested.key` (first segment = file, rest = array path). /// Falls back to the top of the file when the exact key cannot be located. pub(crate) fn resolve_trans_definitions(backend: &Backend, key: &str) -> Vec { - let Some(file_stem) = key.split('.').next() else { - return Vec::new(); - }; - let snapshot = backend.user_file_symbol_maps(); let mut results = Vec::new(); + + // PHP file-based translations: first segment is the file stem. + let file_stem = key.split('.').next().unwrap_or(key); let target_suffix = format!("/{file_stem}.php"); - for (file_uri, _) in snapshot { - // Check if the file is in a lang directory and matches the stem. - if (file_uri.contains("/lang/") || file_uri.contains("/resources/lang/")) - && file_uri.ends_with(&target_suffix) - { - let Ok(uri) = Url::parse(&file_uri) else { + for (file_uri, _) in &snapshot { + if !(file_uri.contains("/lang/") || file_uri.contains("/resources/lang/")) { + continue; + } + + if file_uri.ends_with(&target_suffix) { + let Ok(uri) = Url::parse(file_uri) else { continue; }; - let Some(content) = backend.get_file_content(&file_uri) else { + let Some(content) = backend.get_file_content(file_uri) else { continue; }; @@ -43,18 +47,41 @@ pub(crate) fn resolve_trans_definitions(backend: &Backend, key: &str) -> Vec>(&content) + && map.contains_key(key) + && let Ok(uri) = Url::from_file_path(&path) + { + results.push(crate::definition::point_location(uri, Position::new(0, 0))); + } + } + } + } + results } // ─── Declaration extractor (mirrors config_keys logic) ─────────────────────── #[derive(Debug)] -struct TransKeyMatch { - key: String, - start: usize, +pub(crate) struct TransKeyMatch { + pub key: String, + pub start: usize, } -fn collect_trans_declarations(content: &str, file_stem: &str) -> Vec { +pub(crate) fn collect_trans_declarations(content: &str, file_stem: &str) -> Vec { let arena = Bump::new(); let file_id = FileId::new(b"input.php"); let program = mago_syntax::parser::parse_file_content(&arena, file_id, content.as_bytes()); From 2cd7f6df74382ea123d32cb094d4a858eb24bff2 Mon Sep 17 00:00:00 2001 From: Caleb White Date: Fri, 17 Jul 2026 16:35:32 -0500 Subject: [PATCH 2/2] feat: discover package resources from service providers Scan service providers for mergeConfigFrom(), loadViewsFrom(), loadTranslationsFrom(), loadJsonTranslationsFrom(), and loadRoutesFrom() calls. Resolve __DIR__-relative paths to the actual package files on disk and feed the results into the existing string key infrastructure. This enables completion, go-to-definition, and hover for config keys, view templates, translation keys, and named routes that are registered by installed packages rather than defined in the app itself. For example, config("horizon.environments") now completes and jumps to the key in vendor/laravel/horizon/config/ horizon.php, and view("horizon::layout") resolves to the package view directory. The scanner reuses the same provider list and one-level-deep helper class traversal already used by macro discovery. Discovered resources are cached on Backend and invalidate the string key caches when populated. Key changes: - New provider_resources.rs with ProviderResource, ProviderResources, and extract_provider_resources() - extract_dir_concat_path() moved from route_names.rs to helpers.rs for shared use - build_provider_resources() in server.rs walks providers and their referenced classes - enumerate_all_{config_keys,view_names,trans_keys}() and enumerate_all_route_names() extended to include package resources - resolve_{config_key,view,trans,route}_definitions() extended to handle namespaced package keys (ns::key, ns.key) --- docs/CHANGELOG.md | 1 + src/completion/laravel_string_keys.rs | 114 ++++++++++++++++- src/lib.rs | 8 ++ src/server.rs | 51 ++++++++ src/virtual_members/laravel/config_keys.rs | 18 ++- src/virtual_members/laravel/helpers.rs | 33 +++++ src/virtual_members/laravel/mod.rs | 2 + .../laravel/provider_resources.rs | 121 ++++++++++++++++++ src/virtual_members/laravel/route_names.rs | 50 +++----- src/virtual_members/laravel/trans_keys.rs | 43 ++++++- src/virtual_members/laravel/view_names.rs | 21 ++- 11 files changed, 421 insertions(+), 41 deletions(-) create mode 100644 src/virtual_members/laravel/provider_resources.rs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 0c0f8f8e..4ce8aa8a 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Package resource discovery from service providers.** Config keys, view templates, translation keys, and named routes registered by installed packages are now discovered automatically. The scanner reads `mergeConfigFrom()`, `loadViewsFrom()`, `loadTranslationsFrom()`, `loadJsonTranslationsFrom()`, and `loadRoutesFrom()` calls in service providers (and their one-level-deep helper classes), resolves `__DIR__`-relative paths to the actual package files on disk, and feeds the results into the existing string key infrastructure. Completion now offers package config keys (e.g. `config('horizon.environments')`), namespaced view templates (`view('horizon::layout')`), namespaced translation keys (`trans('package::file.key')`), and package-defined named routes. Go-to-definition on any of these jumps to the exact key or file in the vendor package. Contributed by @calebdw. - **Macro hover shows origin and inferred return types.** Hovering on a macro method call now displays a "macro" indicator instead of the generic "virtual" label, distinguishing `::macro()` registrations from `@method`/`@mixin` synthesized members. When the closure has no explicit return type hint, the return type is inferred from the closure body and shown with an "(inferred)" annotation. Bare `$this` / `self` / `static` returns preserve their keyword form, and method chains like `$this->transform(...)` use the last method's declared return type directly — preserving `$this`, `static`, and generic parameters that the general resolver would flatten to a bare class name. Regular (non-macro) methods with inferred return types also show the "(inferred)" annotation on hover. A new `preserve_static` flag on the resolution context controls whether self-references resolve to their keyword form or the concrete class name. Contributed by @calebdw. - **PSR-4 mismatch diagnostics and rename-based moves.** Files now warn when the declared namespace or primary class name does not match the PSR-4 path or filename, with quick fixes to correct them. Renaming a class from its declaration now opens the full FQCN so you can move it between namespaces in one step, and renaming a namespace can rewrite multiple segments at once while moving PSR-4 directories and updating references across the project. Contributed by @calebdw. - **Case-sensitive autoloading diagnostic.** A class reference whose casing differs from the class's actual declaration is now flagged, with a quick fix to correct it. This catches the bug where code loads on a case-insensitive filesystem (macOS, Windows) but fails with a class-not-found error on Linux, because PSR-4 maps the name to a file path and path lookups are case-sensitive there. It covers `use` imports and inline references to autoloaded classes; built-in classes and same-file references, which never reach the autoloader, are left alone. diff --git a/src/completion/laravel_string_keys.rs b/src/completion/laravel_string_keys.rs index 5528cd37..ae344bc0 100644 --- a/src/completion/laravel_string_keys.rs +++ b/src/completion/laravel_string_keys.rs @@ -246,18 +246,23 @@ impl Backend { let mut names = Vec::new(); for (file_uri, _) in snapshot { - // Match resources/views/**/*.blade.php or *.php let Some(rel) = extract_view_relative_path(&file_uri) else { continue; }; names.push(rel); } + + for res in &self.laravel_provider_resources.read().view_dirs { + collect_namespaced_view_names(&res.path, &res.namespace, &mut names); + } + names.sort(); names.dedup(); names } - /// Enumerate all config keys by scanning `config/` files. + /// Enumerate all config keys by scanning `config/` files and + /// package config files discovered from service providers. fn enumerate_all_config_keys(&self) -> Vec { use crate::virtual_members::laravel::{ collect_laravel_config_declarations, laravel_config_prefix_from_uri, @@ -278,15 +283,27 @@ impl Backend { keys.push(d.key); } } + + for res in &self.laravel_provider_resources.read().config_files { + if let Ok(content) = std::fs::read_to_string(&res.path) { + let decls = collect_laravel_config_declarations(&content, &res.namespace); + for d in decls { + keys.push(d.key); + } + } + } + keys.sort(); keys.dedup(); keys } - /// Enumerate all translation keys by scanning `lang/` files. + /// Enumerate all translation keys by scanning `lang/` files and + /// package translation directories discovered from service providers. /// /// Supports both PHP array files (`lang/en/messages.php` → `messages.key`) /// and JSON translation files (`lang/en.json` → raw key strings). + /// Package translations use `namespace::file.key` syntax. fn enumerate_all_trans_keys(&self) -> Vec { let snapshot = self.user_file_symbol_maps(); let mut keys = Vec::new(); @@ -311,9 +328,12 @@ impl Backend { } } - // Also collect keys from JSON translation files on disk. collect_json_trans_keys(self, &mut keys); + for res in &self.laravel_provider_resources.read().trans_dirs { + collect_namespaced_trans_keys(&res.path, &res.namespace, &mut keys); + } + keys.sort(); keys.dedup(); keys @@ -429,6 +449,92 @@ fn collect_json_trans_keys(backend: &crate::Backend, out: &mut Vec) { } } +/// Recursively scan a package view directory and collect view names +/// in `namespace::dot.notation` format. +fn collect_namespaced_view_names(dir: &std::path::Path, namespace: &str, out: &mut Vec) { + collect_view_names_recursive(dir, dir, namespace, out); +} + +fn collect_view_names_recursive( + base: &std::path::Path, + dir: &std::path::Path, + namespace: &str, + out: &mut Vec, +) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_view_names_recursive(base, &path, namespace, out); + } else if let Some(rel) = path.strip_prefix(base).ok().and_then(|r| r.to_str()) { + let name = rel + .strip_suffix(".blade.php") + .or_else(|| rel.strip_suffix(".php")); + if let Some(name) = name { + let dotted = name.replace([std::path::MAIN_SEPARATOR, '/'], "."); + out.push(format!("{namespace}::{dotted}")); + } + } + } +} + +/// Scan a package translation directory and collect keys in +/// `namespace::file.key` format (PHP files) or `namespace::raw_key` +/// (JSON files with empty namespace). +fn collect_namespaced_trans_keys(dir: &std::path::Path, namespace: &str, out: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_namespaced_trans_from_locale_dir(&path, namespace, out); + } else if path.extension().is_some_and(|e| e == "json") + && namespace.is_empty() + && let Ok(content) = std::fs::read_to_string(&path) + && let Ok(map) = + serde_json::from_str::>(&content) + { + for k in map.keys() { + out.push(k.clone()); + } + } + } +} + +fn collect_namespaced_trans_from_locale_dir( + dir: &std::path::Path, + namespace: &str, + out: &mut Vec, +) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if !path.extension().is_some_and(|e| e == "php") { + continue; + } + let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else { + continue; + }; + let Ok(content) = std::fs::read_to_string(&path) else { + continue; + }; + let prefix = if namespace.is_empty() { + stem.to_string() + } else { + format!("{namespace}::{stem}") + }; + let decls = crate::virtual_members::laravel::collect_trans_declarations(&content, &prefix); + for d in decls { + out.push(d.key); + } + } +} + // ─── Completion ───────────────────────────────────────────────────────────── impl Backend { diff --git a/src/lib.rs b/src/lib.rs index d93dbd52..028fb9a7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -498,6 +498,7 @@ pub struct Backend { /// view names, translation keys). `None` = not yet computed. /// Invalidated when a file in `routes/`, `config/`, `resources/views/`, /// or `lang/` is updated. + pub(crate) laravel_provider_resources: Arc>, pub(crate) laravel_string_key_cache: Arc>, /// Per-target member completion cache. /// @@ -920,6 +921,9 @@ impl Backend { )), laravel_has_macros: Arc::new(std::sync::atomic::AtomicBool::new(false)), laravel_macro_seeds: Arc::new(RwLock::new(HashMap::new())), + laravel_provider_resources: Arc::new(RwLock::new( + virtual_members::laravel::ProviderResources::default(), + )), laravel_string_key_cache: Arc::new(RwLock::new(LaravelStringKeyCache::default())), member_completion_cache: Arc::new(Mutex::new(HashMap::new())), method_store: Arc::new(RwLock::new(HashMap::new())), @@ -1016,6 +1020,9 @@ impl Backend { )), laravel_has_macros: Arc::new(std::sync::atomic::AtomicBool::new(false)), laravel_macro_seeds: Arc::new(RwLock::new(HashMap::new())), + laravel_provider_resources: Arc::new(RwLock::new( + virtual_members::laravel::ProviderResources::default(), + )), laravel_string_key_cache: Arc::new(RwLock::new(LaravelStringKeyCache::default())), member_completion_cache: Arc::new(Mutex::new(HashMap::new())), method_store: Arc::new(RwLock::new(HashMap::new())), @@ -1611,6 +1618,7 @@ impl Backend { laravel_macros: Arc::clone(&self.laravel_macros), laravel_has_macros: Arc::clone(&self.laravel_has_macros), laravel_macro_seeds: Arc::clone(&self.laravel_macro_seeds), + laravel_provider_resources: Arc::clone(&self.laravel_provider_resources), laravel_string_key_cache: Arc::clone(&self.laravel_string_key_cache), member_completion_cache: Arc::clone(&self.member_completion_cache), method_store: Arc::clone(&self.method_store), diff --git a/src/server.rs b/src/server.rs index ca6e6500..8d5c22cd 100644 --- a/src/server.rs +++ b/src/server.rs @@ -509,6 +509,7 @@ impl LanguageServer for Backend { // by a stale merge. if self.resolved_class_cache.read().is_laravel() { self.build_laravel_macro_index(); + self.build_provider_resources(); } // Mark initialization as complete so that diagnostic workers @@ -2157,6 +2158,56 @@ impl Backend { .any(|rel| crate::util::path_to_uri(&root.join(rel)) == uri) } + fn build_provider_resources(&self) { + let mut resources = crate::virtual_members::laravel::ProviderResources::default(); + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + + for fqn in self.laravel_provider_fqns() { + let Some(uri) = self.resolve_class_uri(&fqn) else { + continue; + }; + if !seen.insert(uri.clone()) { + continue; + } + let Some(content) = self.get_file_content(&uri) else { + continue; + }; + let file_dir = tower_lsp::lsp_types::Url::parse(&uri) + .ok() + .and_then(|u| u.to_file_path().ok()) + .and_then(|p| p.parent().map(|d| d.to_path_buf())); + let Some(file_dir) = file_dir else { + continue; + }; + + resources.merge(crate::virtual_members::laravel::extract_provider_resources( + &content, &file_dir, + )); + } + + let config_count = resources.config_files.len(); + let view_count = resources.view_dirs.len(); + let trans_count = resources.trans_dirs.len(); + let route_count = resources.route_files.len(); + *self.laravel_provider_resources.write() = resources; + + if config_count + view_count + trans_count + route_count > 0 { + let mut cache = self.laravel_string_key_cache.write(); + cache.config_keys = None; + cache.view_names = None; + cache.trans_keys = None; + cache.route_names = None; + } + + tracing::info!( + "PHPantom: discovered {} package config files, {} view dirs, {} translation dirs, {} route files from service providers", + config_count, + view_count, + trans_count, + route_count, + ); + } + fn infer_laravel_macro_return_types( &self, regs: &mut [crate::virtual_members::laravel::MacroRegistration], diff --git a/src/virtual_members/laravel/config_keys.rs b/src/virtual_members/laravel/config_keys.rs index e73fe09b..0869720a 100644 --- a/src/virtual_members/laravel/config_keys.rs +++ b/src/virtual_members/laravel/config_keys.rs @@ -233,7 +233,23 @@ pub(crate) fn resolve_config_key_declaration(backend: &Backend, key: &str) -> Op return Some(crate::definition::point_location(target_uri, pos)); } - // Key not found exactly in this file, but this is the matching file stem. + return Some(crate::definition::point_location( + target_uri, + Position::new(0, 0), + )); + } + } + + let first_part = parts.first()?; + for res in &backend.laravel_provider_resources.read().config_files { + if res.namespace == *first_part && res.path.is_file() { + let target_uri = Url::from_file_path(&res.path).ok()?; + let target_content = std::fs::read_to_string(&res.path).ok()?; + let declarations = collect_laravel_config_declarations(&target_content, &res.namespace); + if let Some(decl) = declarations.into_iter().find(|d| d.key == key) { + let pos = crate::util::offset_to_position(&target_content, decl.start); + return Some(crate::definition::point_location(target_uri, pos)); + } return Some(crate::definition::point_location( target_uri, Position::new(0, 0), diff --git a/src/virtual_members/laravel/helpers.rs b/src/virtual_members/laravel/helpers.rs index 6b6592d3..807230b0 100644 --- a/src/virtual_members/laravel/helpers.rs +++ b/src/virtual_members/laravel/helpers.rs @@ -630,6 +630,39 @@ fn walk_array_el_depth( ControlFlow::Continue(()) } +/// Try to extract a relative path from a `__DIR__ . '/path.php'` expression. +/// +/// Returns the string literal portion (e.g. `"/ems/accounting.php"`). +pub(crate) fn extract_dir_concat_path<'a>( + expr: &Expression<'a>, + content: &'a str, +) -> Option<&'a str> { + let Expression::Binary(bin) = expr else { + return None; + }; + let is_dir = matches!( + bin.lhs, + Expression::MagicConstant(MagicConstant::Directory { .. }) + ); + if !is_dir { + return None; + } + let Expression::Literal(literal::Literal::String(s)) = bin.rhs else { + return None; + }; + if let Some(value) = s.value { + Some(crate::atom::bytes_to_str(value)) + } else { + let start = s.span.start.offset as usize + 1; + let end = s.span.end.offset as usize - 1; + if start < end && end <= content.len() { + Some(&content[start..end]) + } else { + None + } + } +} + #[cfg(test)] #[path = "helpers_tests.rs"] mod tests; diff --git a/src/virtual_members/laravel/mod.rs b/src/virtual_members/laravel/mod.rs index 0e554140..921bc1b2 100644 --- a/src/virtual_members/laravel/mod.rs +++ b/src/virtual_members/laravel/mod.rs @@ -87,6 +87,7 @@ mod factory; mod helpers; mod macros; pub(crate) mod patches; +mod provider_resources; mod relationships; mod route_names; mod scopes; @@ -107,6 +108,7 @@ pub(crate) use macros::{ LaravelMacroIndex, MacroRegistration, extract_macro_registrations, inject_macros, parse_installed_providers, parse_provider_class_list, parse_provider_referenced_classes, }; +pub(crate) use provider_resources::{ProviderResources, extract_provider_resources}; pub(crate) use route_names::enumerate_all_route_names; pub(crate) use trans_keys::collect_trans_declarations; diff --git a/src/virtual_members/laravel/provider_resources.rs b/src/virtual_members/laravel/provider_resources.rs new file mode 100644 index 00000000..7438c47d --- /dev/null +++ b/src/virtual_members/laravel/provider_resources.rs @@ -0,0 +1,121 @@ +use std::ops::ControlFlow; +use std::path::{Path, PathBuf}; + +use mago_syntax::ast::*; + +#[derive(Debug, Clone)] +pub(crate) struct ProviderResource { + pub path: PathBuf, + pub namespace: String, +} + +#[derive(Debug, Clone, Default)] +pub(crate) struct ProviderResources { + pub config_files: Vec, + pub view_dirs: Vec, + pub trans_dirs: Vec, + pub route_files: Vec, +} + +impl ProviderResources { + pub fn merge(&mut self, other: ProviderResources) { + self.config_files.extend(other.config_files); + self.view_dirs.extend(other.view_dirs); + self.trans_dirs.extend(other.trans_dirs); + self.route_files.extend(other.route_files); + } +} + +pub(crate) fn extract_provider_resources(content: &str, file_dir: &Path) -> ProviderResources { + let mut resources = ProviderResources::default(); + + super::helpers::walk_all_php_expressions(content, &mut |expr| { + let Expression::Call(Call::Method(mc)) = expr else { + return ControlFlow::Continue(()); + }; + + let ClassLikeMemberSelector::Identifier(ident) = &mc.method else { + return ControlFlow::Continue(()); + }; + + if !is_this_expr(mc.object) { + return ControlFlow::Continue(()); + } + + let method_lower = ident.value.to_ascii_lowercase(); + let args: Vec<_> = mc.argument_list.arguments.iter().collect(); + + if method_lower == b"mergeconfigfrom" && args.len() >= 2 { + if let Some(path) = resolve_path_arg(args[0].value(), content, file_dir) + && let Some((ns, _, _)) = + super::helpers::extract_string_literal(args[1].value(), content) + { + resources.config_files.push(ProviderResource { + path, + namespace: ns.to_string(), + }); + } + } else if method_lower == b"loadviewsfrom" && args.len() >= 2 { + if let Some(path) = resolve_path_arg(args[0].value(), content, file_dir) + && let Some((ns, _, _)) = + super::helpers::extract_string_literal(args[1].value(), content) + { + resources.view_dirs.push(ProviderResource { + path, + namespace: ns.to_string(), + }); + } + } else if method_lower == b"loadtranslationsfrom" && args.len() >= 2 { + if let Some(path) = resolve_path_arg(args[0].value(), content, file_dir) + && let Some((ns, _, _)) = + super::helpers::extract_string_literal(args[1].value(), content) + { + resources.trans_dirs.push(ProviderResource { + path, + namespace: ns.to_string(), + }); + } + } else if method_lower == b"loadjsontranslationsfrom" && !args.is_empty() { + if let Some(path) = resolve_path_arg(args[0].value(), content, file_dir) { + resources.trans_dirs.push(ProviderResource { + path, + namespace: String::new(), + }); + } + } else if method_lower == b"loadroutesfrom" + && !args.is_empty() + && let Some(path) = resolve_path_arg(args[0].value(), content, file_dir) + { + resources.route_files.push(path); + } + + ControlFlow::Continue(()) + }); + + resources +} + +fn is_this_expr(expr: &Expression<'_>) -> bool { + matches!( + expr, + Expression::Variable(Variable::Direct(dv)) if dv.name == b"this" + ) +} + +fn resolve_path_arg(expr: &Expression<'_>, content: &str, file_dir: &Path) -> Option { + if let Some(rel) = super::helpers::extract_dir_concat_path(expr, content) { + let resolved = file_dir.join(rel.trim_start_matches('/')); + return resolved.canonicalize().ok().or(Some(resolved)); + } + + if let Some((val, _, _)) = super::helpers::extract_string_literal(expr, content) { + if val.starts_with('/') { + let p = PathBuf::from(val); + return p.canonicalize().ok().or(Some(p)); + } + let resolved = file_dir.join(val); + return resolved.canonicalize().ok().or(Some(resolved)); + } + + None +} diff --git a/src/virtual_members/laravel/route_names.rs b/src/virtual_members/laravel/route_names.rs index 2bd15de2..d635802e 100644 --- a/src/virtual_members/laravel/route_names.rs +++ b/src/virtual_members/laravel/route_names.rs @@ -138,6 +138,16 @@ pub(crate) fn resolve_route_definitions(backend: &Backend, name: &str) -> Vec Vec { .and_then(|p| p.parent().map(|d| d.to_path_buf())); collect_all_names_from_file(&content, file_dir.as_deref(), &mut names); } + + for route_path in &backend.laravel_provider_resources.read().route_files { + if let Ok(content) = std::fs::read_to_string(route_path) { + let file_dir = route_path.parent(); + collect_all_names_from_file(&content, file_dir, &mut names); + } + } + names.sort(); names.dedup(); names @@ -595,36 +613,6 @@ fn collect_names_from_group_body<'a>( } } -/// Try to extract a relative path from a `__DIR__ . '/path.php'` expression. -/// -/// Returns the string literal portion (e.g. `"/ems/accounting.php"`). -fn extract_dir_concat_path<'a>(expr: &Expression<'a>, content: &'a str) -> Option<&'a str> { - let Expression::Binary(bin) = expr else { - return None; - }; - // Check LHS is __DIR__ - let is_dir = matches!( - bin.lhs, - Expression::MagicConstant(MagicConstant::Directory { .. }) - ); - if !is_dir { - return None; - } - // RHS should be a string literal - let Expression::Literal(literal::Literal::String(s)) = bin.rhs else { - return None; - }; - if let Some(value) = s.value { - Some(crate::atom::bytes_to_str(value)) - } else { - let start = s.span.start.offset as usize + 1; - let end = s.span.end.offset as usize - 1; - if start < end && end <= content.len() { - Some(&content[start..end]) - } else { - None - } - } -} +use super::helpers::extract_dir_concat_path; pub(crate) use super::helpers::{chain_name_prefix, extract_as_prefix_from_args}; diff --git a/src/virtual_members/laravel/trans_keys.rs b/src/virtual_members/laravel/trans_keys.rs index 77236c31..4bc839dc 100644 --- a/src/virtual_members/laravel/trans_keys.rs +++ b/src/virtual_members/laravel/trans_keys.rs @@ -16,10 +16,47 @@ use crate::atom::bytes_to_str; /// /// Falls back to the top of the file when the exact key cannot be located. pub(crate) fn resolve_trans_definitions(backend: &Backend, key: &str) -> Vec { - let snapshot = backend.user_file_symbol_maps(); let mut results = Vec::new(); - // PHP file-based translations: first segment is the file stem. + if let Some((namespace, rest)) = key.split_once("::") { + let file_stem = rest.split('.').next().unwrap_or(rest); + for res in &backend.laravel_provider_resources.read().trans_dirs { + if res.namespace != namespace { + continue; + } + let Ok(entries) = std::fs::read_dir(&res.path) else { + continue; + }; + for entry in entries.flatten() { + let locale_dir = entry.path(); + if !locale_dir.is_dir() { + continue; + } + let candidate = locale_dir.join(format!("{file_stem}.php")); + if !candidate.is_file() { + continue; + } + let Ok(content) = std::fs::read_to_string(&candidate) else { + continue; + }; + let Ok(uri) = Url::from_file_path(&candidate) else { + continue; + }; + let prefix = format!("{namespace}::{file_stem}"); + let declarations = collect_trans_declarations(&content, &prefix); + if let Some(decl) = declarations.into_iter().find(|d| d.key == key) { + let pos = crate::util::offset_to_position(&content, decl.start); + results.push(crate::definition::point_location(uri, pos)); + continue; + } + results.push(crate::definition::point_location(uri, Position::new(0, 0))); + } + } + return results; + } + + let snapshot = backend.user_file_symbol_maps(); + let file_stem = key.split('.').next().unwrap_or(key); let target_suffix = format!("/{file_stem}.php"); @@ -43,12 +80,10 @@ pub(crate) fn resolve_trans_definitions(backend: &Backend, key: &str) -> Vec Vec { + let mut results = Vec::new(); + + if let Some((namespace, view_name)) = name.split_once("::") { + let rel = view_name.replace('.', "/"); + for res in &backend.laravel_provider_resources.read().view_dirs { + if res.namespace != namespace { + continue; + } + for suffix in &[".blade.php", ".php"] { + let candidate = res.path.join(format!("{rel}{suffix}")); + if candidate.is_file() + && let Ok(uri) = Url::from_file_path(&candidate) + { + results.push(crate::definition::point_location(uri, Position::new(0, 0))); + } + } + } + return results; + } + let rel = name.replace('.', "/"); let target_suffixes = [ format!("/resources/views/{}.blade.php", rel), format!("/resources/views/{}.php", rel), ]; - let mut results = Vec::new(); let snapshot = backend.user_file_symbol_maps(); for (file_uri, _) in snapshot {