From 5313925515653acb388b9bb042e176511f48a111 Mon Sep 17 00:00:00 2001 From: Shuvro Roy Date: Sun, 16 Aug 2026 21:04:52 +0600 Subject: [PATCH 1/5] chore: implement Laravel named resource navigation and completion --- docs/ARCHITECTURE.md | 3 +- docs/CHANGELOG.md | 3 + docs/todo.md | 2 - docs/todo/laravel.md | 49 - examples/laravel/app/Demo.php | 44 + .../app/Providers/DemoServiceProvider.php | 7 + examples/laravel/config/broadcasting.php | 11 + examples/laravel/config/cache.php | 11 + examples/laravel/config/filesystems.php | 15 + examples/laravel/config/logging.php | 17 + examples/laravel/config/mail.php | 12 + examples/laravel/config/queue.php | 13 + src/analyse/run.rs | 11 +- src/backend/file_access.rs | 26 +- src/blade/call_site_inference.rs | 13 +- src/blade/typed_receiver.rs | 730 +++++++- src/completion/handler/mod.rs | 8 +- src/completion/laravel_string_keys.rs | 1241 +++++++++---- src/completion/laravel_string_keys/context.rs | 768 ++++++++ src/definition/resolve.rs | 10 +- src/diagnostics/mod.rs | 284 ++- src/hover/mod.rs | 28 + src/indexing/scan.rs | 65 +- src/indexing/watch.rs | 494 +++++- src/laravel_string_index.rs | 953 ++++++++++ src/lib.rs | 30 +- src/mem_audit.rs | 77 +- src/parser/ast_update.rs | 178 +- src/reference_index.rs | 79 +- src/references/dispatch.rs | 27 +- src/server.rs | 373 +++- src/symbol_map/extraction/class_like.rs | 71 +- .../extraction/expressions/calls.rs | 132 +- src/symbol_map/extraction/laravel.rs | 527 +++++- src/symbol_map/extraction/mod.rs | 54 +- src/symbol_map/laravel_resources.rs | 714 ++++++++ .../laravel_resources/receiver_types.rs | 446 +++++ src/symbol_map/mod.rs | 117 +- src/symbol_map/tests.rs | 563 +++++- src/virtual_members/laravel/config_keys.rs | 914 ++++++++-- src/virtual_members/laravel/config_values.rs | 4 +- src/virtual_members/laravel/mod.rs | 8 +- src/virtual_members/laravel/string_keys.rs | 571 +++++- src/workspace_env.rs | 2 +- .../laravel_named_resource_types.rs | 1580 +++++++++++++++++ tests/integration/laravel_named_resources.rs | 1355 ++++++++++++++ .../integration/laravel_storage_disk_names.rs | 380 ++++ tests/integration/main.rs | 3 + 48 files changed, 12089 insertions(+), 934 deletions(-) create mode 100644 examples/laravel/config/broadcasting.php create mode 100644 examples/laravel/config/cache.php create mode 100644 examples/laravel/config/logging.php create mode 100644 examples/laravel/config/mail.php create mode 100644 examples/laravel/config/queue.php create mode 100644 src/completion/laravel_string_keys/context.rs create mode 100644 src/laravel_string_index.rs create mode 100644 src/symbol_map/laravel_resources.rs create mode 100644 src/symbol_map/laravel_resources/receiver_types.rs create mode 100644 tests/integration/laravel_named_resource_types.rs create mode 100644 tests/integration/laravel_named_resources.rs create mode 100644 tests/integration/laravel_storage_disk_names.rs diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0e0472074..2084eab89 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -235,7 +235,8 @@ The symbol map also stores: - **Variable definition sites** (`var_defs`): records every assignment, parameter, foreach binding, catch variable, static/global declaration, and destructuring site with its byte offset, scope boundary, and `effective_from` offset (for assignments, this is the end of the statement so the RHS sees the previous definition). Go-to-definition for `$var` finds the most recent definition before the cursor within the enclosing scope via binary search. - **Scope boundaries** (`scopes`): function, method, closure, and arrow function body ranges. Used by `find_enclosing_scope` to determine which scope the cursor is in. - **Template parameter definitions** (`template_defs`): `@template` tag locations so that template parameter names (e.g. `TKey`, `TModel`) that appear in docblock types can be resolved to their declaration site. -- **Candidate render sites** (`view_receiver_sites`): the view names a method call spells when only the receiver's *type* decides whether it renders — a constructor-injected `Factory $views` behind `$this->views->make('page')`, a mailable held in a local. Extraction runs before the file's classes are resolved and cannot type the receiver, so it records the candidates and `blade/typed_receiver.rs` confirms them lazily through the shared type engine, once per file. Consumers of view keys (the call-site diagnostics, call-site inference, `lookup_symbol_map`, find-references) read the confirmed spans alongside the map's own `LaravelStringKey` spans. The reference candidate index takes the *unconfirmed* candidates, since a file has to be findable before it can be asked. +- **Type-dependent Laravel string sites** (`view_receiver_sites`, `resource_receiver_sites`): a method or property can name a view, database/queue/broadcast connection, or queue only after its receiver or enclosing class is typed. Extraction runs before the file's classes are resolved, so it records compact candidates and `blade/typed_receiver.rs` confirms them lazily through the shared type engine, once per symbol-map generation. Consumers read those confirmed spans beside the map's direct `LaravelStringKey` spans. The reference index stores one coarse entry for each unconfirmed resource candidate, so a file remains findable without indexing the same site as every family it might become. +- **Source-defined Laravel names** (`laravel_source_strings`): literal rate-limiter registrations, runtime config-resource definitions, and confirmed queue names are indexed incrementally per URI. Replacing or deleting a file removes only that file's contribution; completion and diagnostics query the sorted index instead of rescanning the workspace. ### Tier 2: Stored Byte Offsets (cross-file jumps) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index d967cdb63..bf0b6fdf1 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Laravel named resources are editor-aware.** Configured service names now complete in calls, typed methods, container attributes, and middleware; hover identifies the resource family, Ctrl+Click opens the declaration, references bridge direct config access, and misspellings are diagnosed. Source-defined rate limiters and free-form queue names get the same editor support without treating open-ended names as errors. Contributed by @shuvroroy. +- **Storage disk names are navigable wherever Laravel accepts one.** `Storage::disk()`, `fake()`, `persistentFake()`, `forgetDisk()`, and the `#[Storage]` container attribute now complete from `config/filesystems.php`; hover identifies the backing entry, Ctrl+Click opens it, and find-references links every use. Array values passed to `forgetDisk()` work individually. Calls that require a configured disk report misspellings, while test fakes and cache eviction keep accepting the ad-hoc names Laravel allows at runtime. Contributed by @shuvroroy. - **A standalone function shows how many times it is used.** The reference count that sits above a class, method, property, and constant was missing from every function declared outside a class, and a file holding nothing but functions (a helpers file, most of a procedural codebase) got no counts at all, because the walk that draws them starts at the file's classes. Functions are counted now too, and Ctrl+Click on a function's own name at its declaration offers the list of its usages instead of doing nothing, which is how the same click already behaved on a class or a method. Contributed by @petrovo-as. - **PHPantom can run in the browser.** The whole type engine now compiles to WebAssembly, so a web editor can have PHPantom's completion, hover, go-to-definition, symbol highlighting and rename without a server to talk to and without a round-trip per keystroke. The module speaks ordinary LSP JSON-RPC over four exported functions, so a browser LSP client can be pointed at it through a thin transport, and it needs no filesystem: the PHP standard library stubs are compiled in and open documents live in memory. This is what the [PHPStan playground](https://phpstan.org/try) is built on. Every release ships a prebuilt module, so a host can pin a version rather than build its own. See [wasm.md](wasm.md) for the host interface. Contributed by @ondrejmirtes. - **A path helper opens the file it names.** `base_path('routes/web.php')`, `app_path()`, `config_path()`, `database_path()`, `lang_path()`, `public_path()`, `resource_path()`, and `storage_path()` each anchor their argument to a conventional directory, but the argument was still just a string: no link, no completion, and a typo showed up only at runtime. The argument is a clickable link now and go-to-definition follows it, and typing one completes a segment at a time from the directory the path has reached so far, directories first so the next segment follows on. `lang_path()` respects the `resources/lang` directory an application upgraded from Laravel 8 still has. A directory completes but is not a link, since an editor cannot open a folder as a document. Contributed by @shuvroroy (#334). @@ -126,6 +128,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Laravel discovery handles macOS path aliases consistently.** Temporary and workspace paths may be exposed as `/var` while their canonical spelling is `/private/var`; vendor pruning and Blade view refreshes now treat those spellings as the same location, so vendor files stay excluded and newly created or deleted templates refresh completion correctly. Contributed by @shuvroroy. - **Editing a service provider takes effect immediately.** What a Laravel service provider registers was read once, when the project was first indexed, and never again. A container binding written afterwards did not resolve, hover, or navigate until the editor was restarted, and the same went for the view directories, translation directories, route files, config files, and Blade component namespaces a provider registers. Saving or editing a provider now re-reads it, and adding one to `bootstrap/providers.php` (or `config/app.php`) picks it up as well. A key that two providers bind still ends up with whichever of them the container itself would let win. - **A request accessor written with named arguments keeps its key.** `$request->file(key: 'photos')` and `$request->header(default: 'x')` read the named argument as whichever positional slot it happened to land in, so a keyed `file()` call resolved as though it named no field at all and a default-only `header()` call resolved as though its default text were the key. `header()`, `query()`, `cookie()`, `input()`, `post()`, and `file()` now bind a named argument to the parameter it actually names, including on an app's own `FormRequest` subclass, which never redeclares the accessor itself. - **Blade partial variables stay typed during CLI analysis.** `phpantom_lsp analyze` now discovers view and include callers while running without the editor's reference index, and direct variables passed in template data retain nearby `@var` overrides. Contributed by @shuvroroy (#337). diff --git a/docs/todo.md b/docs/todo.md index 7be13c694..5b11f9f1a 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -160,10 +160,8 @@ unlikely to move the needle for most users. | L24 | [Translation depth: JSON lang files, locales, placeholders](todo/laravel.md#l24-translation-depth-json-lang-files-locales-placeholders) | Medium-High | Medium-High | | L46 | [`->can()` on a user model the receiver does not name](todo/laravel.md#l46-can-on-a-user-model-the-receiver-does-not-name) | Medium-High | Medium-High | | L30 | [Eloquent attribute-array key completion](todo/laravel.md#l30-eloquent-attribute-array-key-completion) | Medium | Medium | -| L32 | [Config-backed named-resource strings](todo/laravel.md#l32-config-backed-named-resource-strings) (log channels, cache stores, guards, connections, rate limiters) | Medium | Medium | | L49 | [Unguarded Eloquent mass assignment diagnostic](todo/laravel.md#l49-unguarded-eloquent-mass-assignment-diagnostic) | Medium | Medium | | L17 | [Additional string contexts without booting](todo/laravel.md#l17-additional-string-contexts-without-booting) (middleware, assets, validation, Inertia) | Medium | Medium-High | -| L25 | [Storage disk name strings](todo/laravel.md#l25-storage-disk-name-strings) | Low-Medium | Low | | L31 | [String-key rename, highlight, and semantic tokens](todo/laravel.md#l31-string-key-rename-highlight-and-semantic-tokens) | Low-Medium | Medium | | L42 | [Morph alias completion in array positions](todo/laravel.md#l42-morph-alias-completion-in-array-positions) | Low-Medium | Medium | | L3 | `$dates` array (deprecated) | Low-Medium | Medium | diff --git a/docs/todo/laravel.md b/docs/todo/laravel.md index 7d307a01b..49c9c4dc3 100644 --- a/docs/todo/laravel.md +++ b/docs/todo/laravel.md @@ -582,18 +582,6 @@ still partially lack: empty string as the value). No fix when the group file itself doesn't exist yet; that case still just diagnoses. -#### L25. Storage disk name strings - -**Impact: Low-Medium · Complexity: Low** - -`Storage::disk('...')` and the `#[Storage]` container attribute already -complete against `filesystems.disks.*`, navigate to the disk's entry in -`config/filesystems.php`, and flag an unknown disk. `Storage::fake()`, -`persistentFake()`, and `forgetDisk()` still name a disk with none of -that: their return type is patched to `FilesystemAdapter`, but the -disk-name argument itself gets no completion, go-to-definition, or -diagnostic. - #### L27. Legacy `Controller@method` action strings **Impact: Low · Complexity: Low** @@ -650,43 +638,6 @@ updates the `->name()` declaration and all usages; highlight and semantic tokens reuse the existing spans. Renaming a view name implies moving the Blade file — defer that one until the rest is in place. -#### L32. Config-backed named-resource strings - -**Impact: Medium · Complexity: Medium** - -L25 (storage disks) is one instance of a general pattern: a method -argument names an entry under a known config subtree, and the config -scanner already parses those files. Auth guards (`auth('...')`, -`Auth::guard()`, `->middleware('auth:web')`), cache stores -(`Cache::store()`), log channels (`Log::channel()`), and storage disks -(L25) already complete against their config subtree — but all of them -route through the generic `LaravelStringKind::Config` kind rather than -a dedicated one, so they get completion plus the shared config -diagnostics/go-to-definition and nothing family-specific (a "cache -store" hovers with the same generic wording as any other config key). -`Log::stack()` (array values) isn't recognized at all. Generalize into -a declarative table of `(trigger context, config path)` pairs so each -new family is one table row, and cover the rest of the family in one -pass: - -- **Database connections** — `DB::connection()`, `->connection()` / - `$connection` on models and jobs → `database.connections.*`. -- **Queue connections and queues** — `Queue::connection()`, - `->onConnection()` → `queue.connections.*`; `->onQueue()` names are - free-form (completion from literals seen elsewhere, no diagnostic). -- **Mailers** — `Mail::mailer()` → `mail.mailers.*`. -- **Broadcast connections** — `Broadcast::connection()` → - `broadcasting.connections.*`. -- **Rate limiter names** — not config-backed: registered via - `RateLimiter::for('name', …)` in providers. Scan literal - registrations (same shape as the macro scanner) and validate - `throttle:name` middleware parameters and `new RateLimited('name')` - against the set. - -Each family gets the full string-kind treatment for free once wired -as a `LaravelStringKey`: completion, go-to-definition (jump to the -config entry), hover, diagnostics, and references. - #### L39. Unused view and translation key detection **Impact: Low · Complexity: Medium** diff --git a/examples/laravel/app/Demo.php b/examples/laravel/app/Demo.php index a96dc8eaf..4db0b45ed 100644 --- a/examples/laravel/app/Demo.php +++ b/examples/laravel/app/Demo.php @@ -26,17 +26,25 @@ use Database\Factories\EditorialFactory; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Http\Request; +use Illuminate\Notifications\Messages\BroadcastMessage; +use Illuminate\Queue\Middleware\RateLimited; use Carbon\CarbonImmutable; use Illuminate\Support\Collection; use Illuminate\Support\Facades\App; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Auth; +use Illuminate\Support\Facades\Broadcast; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Config; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Gate; use Illuminate\Support\Facades\Lang; +use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Mail; +use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Redis; use Illuminate\Support\Facades\Response; +use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Schedule; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\View; @@ -834,6 +842,39 @@ public function laravelConfig(): void } + // ── Config-backed named resources and source-defined names ───────── + + public function namedLaravelResources(): void + { + // Each string completes from its own config subtree. Hover names the + // resource family, Ctrl+Click opens that entry, and references include + // both resource calls and direct config() access to the same entry. + auth('admin'); + Auth::guard('admin'); + Cache::store('memory'); + Log::channel('daily'); + Log::stack(['daily', 'stderr']); + Storage::disk('pantry'); + DB::connection('mysql'); + Queue::connection('redis'); + Mail::mailer('transactional'); + Broadcast::connection('internal'); + config('cache.stores.memory'); + + // Receiver types disambiguate connection strings. Queue names remain + // free-form, so PHPantom learns them from literals without diagnosing + // a name it has not seen before. + (new BroadcastMessage([])) + ->onConnection('redis') + ->onQueue('mail'); + + // The limiter is registered in DemoServiceProvider. Its name resolves + // from both middleware parameters and queue middleware constructors. + Route::middleware(['auth:admin', 'throttle:uploads']); + new RateLimited('uploads'); + } + + // ── Cache::remember() — closure return type binding ───────────────── public function cacheRemember(): void @@ -1089,8 +1130,11 @@ public function storageFake(): void { // fake() declares the Filesystem contract but always builds a // FilesystemAdapter, so the adapter-only assertion helpers resolve. + // Disk names complete from config/filesystems.php and navigate back + // to their entries anywhere the Storage facade accepts one. Storage::fake('avatars')->assertExists('me.png'); Storage::persistentFake('logs')->assertMissing('old.log'); + Storage::forgetDisk(['avatars', 'logs']); } diff --git a/examples/laravel/app/Providers/DemoServiceProvider.php b/examples/laravel/app/Providers/DemoServiceProvider.php index aa126caf8..3f065b554 100644 --- a/examples/laravel/app/Providers/DemoServiceProvider.php +++ b/examples/laravel/app/Providers/DemoServiceProvider.php @@ -14,12 +14,14 @@ use App\Support\PlainOven; use App\View\Composers\SidebarComposer; use Carbon\CarbonImmutable; +use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Contracts\Foundation\Application; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Filesystem\FilesystemAdapter; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\Gate; +use Illuminate\Support\Facades\RateLimiter; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\View; use Illuminate\View\View as ViewInstance; @@ -93,6 +95,11 @@ public function boot(): void // those of an App\Policies\BlogPostPolicy that does not exist. Gate::policy(BlogPost::class, PublishingPolicy::class); + // Named limiters are source-defined rather than config-backed. + // PHPantom indexes this registration so the name completes and + // navigates from route or queue middleware. + RateLimiter::for('uploads', fn () => Limit::perMinute(60)); + // A macro registered here becomes a real method on Collection: // it autocompletes, hovers with this signature, and type-checks. Collection::macro('sumField', function (string $field): float { diff --git a/examples/laravel/config/broadcasting.php b/examples/laravel/config/broadcasting.php new file mode 100644 index 000000000..4ebcac64f --- /dev/null +++ b/examples/laravel/config/broadcasting.php @@ -0,0 +1,11 @@ + 'internal', + + 'connections' => [ + 'internal' => [ + 'driver' => 'log', + ], + ], +]; diff --git a/examples/laravel/config/cache.php b/examples/laravel/config/cache.php new file mode 100644 index 000000000..800e566ca --- /dev/null +++ b/examples/laravel/config/cache.php @@ -0,0 +1,11 @@ + 'memory', + + 'stores' => [ + 'memory' => [ + 'driver' => 'array', + ], + ], +]; diff --git a/examples/laravel/config/filesystems.php b/examples/laravel/config/filesystems.php index e57200260..3392ce03b 100644 --- a/examples/laravel/config/filesystems.php +++ b/examples/laravel/config/filesystems.php @@ -6,6 +6,21 @@ 'disks' => [ + 'avatars' => [ + 'driver' => 'local', + 'root' => 'storage/app/avatars', + ], + + 'logs' => [ + 'driver' => 'local', + 'root' => 'storage/app/logs', + ], + + 's3' => [ + 'driver' => 's3', + 'bucket' => 'demo', + ], + // A disk whose driver the framework does not ship. It is built by the // `Storage::extend('pantry', ...)` registration in DemoServiceProvider, // and PHPantom reads that closure's return type rather than giving up diff --git a/examples/laravel/config/logging.php b/examples/laravel/config/logging.php new file mode 100644 index 000000000..c56bf225e --- /dev/null +++ b/examples/laravel/config/logging.php @@ -0,0 +1,17 @@ + 'daily', + + 'channels' => [ + 'daily' => [ + 'driver' => 'daily', + 'path' => storage_path('logs/laravel.log'), + 'level' => 'debug', + ], + 'stderr' => [ + 'driver' => 'errorlog', + 'level' => 'debug', + ], + ], +]; diff --git a/examples/laravel/config/mail.php b/examples/laravel/config/mail.php new file mode 100644 index 000000000..03daaa4ee --- /dev/null +++ b/examples/laravel/config/mail.php @@ -0,0 +1,12 @@ + 'transactional', + + 'mailers' => [ + 'transactional' => [ + 'transport' => 'log', + 'channel' => 'daily', + ], + ], +]; diff --git a/examples/laravel/config/queue.php b/examples/laravel/config/queue.php new file mode 100644 index 000000000..be39ce8da --- /dev/null +++ b/examples/laravel/config/queue.php @@ -0,0 +1,13 @@ + 'redis', + + 'connections' => [ + 'redis' => [ + 'driver' => 'redis', + 'connection' => 'default', + 'queue' => 'default', + ], + ], +]; diff --git a/src/analyse/run.rs b/src/analyse/run.rs index b6567d819..d78705474 100644 --- a/src/analyse/run.rs +++ b/src/analyse/run.rs @@ -700,7 +700,16 @@ pub(crate) fn discover_user_files( source_dirs.sort(); source_dirs.dedup(); - let vendor_dirs: Vec = backend.workspace.vendor_dir_paths.lock().clone(); + // The walker compares canonical entry paths below. Canonicalize the + // registered roots once as well so path aliases such as macOS's `/var` + // -> `/private/var` do not let vendor files through. + let vendor_dirs = backend.workspace.vendor_dir_paths.lock().clone(); + let mut vendor_dirs: Vec = vendor_dirs + .into_iter() + .map(|path| path.canonicalize().unwrap_or(path)) + .collect(); + vendor_dirs.sort_unstable(); + vendor_dirs.dedup(); // When an explicit path filter points outside all PSR-4 source // directories (e.g. into vendor/), walk the filter path directly diff --git a/src/backend/file_access.rs b/src/backend/file_access.rs index dd75d8082..7191d586f 100644 --- a/src/backend/file_access.rs +++ b/src/backend/file_access.rs @@ -308,13 +308,28 @@ impl Backend { self.symbol_maps.read().get(uri).cloned() } - /// Remove a file's entries from every per-URI map populated while it - /// was open (`uri_classes_index`, `symbol_maps`, `file_imports`, + /// Remove a file's transient entries from every per-URI map populated + /// while it was open (`uri_classes_index`, `symbol_maps`, `file_imports`, /// `resolved_names`, `file_namespaces`, `parse_errors`), plus the /// reference index. /// - /// Called from `did_close` to clean up state when a file is closed. + /// Source-defined Laravel names survive because closing an on-disk file + /// does not remove its declarations from the project. A true filesystem + /// removal must use + /// [`clear_file_maps_and_source_strings`](Self::clear_file_maps_and_source_strings). pub(crate) fn clear_file_maps(&self, uri: &str) { + self.clear_file_maps_inner(uri, false); + } + + /// Remove all per-file maps, including source-defined Laravel names. + /// + /// This is the destructive counterpart used when the underlying file was + /// actually deleted rather than merely closed in the editor. + pub(crate) fn clear_file_maps_and_source_strings(&self, uri: &str) { + self.clear_file_maps_inner(uri, true); + } + + fn clear_file_maps_inner(&self, uri: &str, remove_source_strings: bool) { // uri_classes_index is redundant with fqn_class_index once indexing // is complete — GTD falls back to fqn_uri_index + parse_and_cache_file // when the uri_classes_index entry is missing. @@ -322,6 +337,11 @@ impl Backend { self.symbol_maps.write().remove(uri); self.evict_typed_receiver_view_spans(uri); self.evict_reference_index_uri(uri); + // Removing the map before advancing the source-name generation keeps + // a lazy queue scan from observing the old map under the new value. + if remove_source_strings { + self.laravel_source_strings.write().remove(uri); + } self.file_imports.write().remove(uri); self.resolved_names.write().remove(uri); self.file_namespaces.write().remove(uri); diff --git a/src/blade/call_site_inference.rs b/src/blade/call_site_inference.rs index 35b0e69f5..1d68a6917 100644 --- a/src/blade/call_site_inference.rs +++ b/src/blade/call_site_inference.rs @@ -887,10 +887,21 @@ impl Backend { let Ok(url) = tower_lsp::lsp_types::Url::parse(uri) else { return Vec::new(); }; - let Ok(path) = url.to_file_path() else { + let Ok(mut path) = url.to_file_path() else { return Vec::new(); }; + // Roots are canonicalized below, so normalize the file side once as + // well. macOS exposes the same temporary directory through `/var` + // and `/private/var`; comparing only one canonical side makes every + // template under that alias appear to sit outside its view root. + path = path.canonicalize().unwrap_or_else(|_| { + path.parent() + .and_then(|parent| parent.canonicalize().ok()) + .and_then(|parent| path.file_name().map(|name| parent.join(name))) + .unwrap_or(path) + }); + let mut names = Vec::new(); let mut push_name = |rel: &std::path::Path, namespace: &str| { let rel_str = rel.to_string_lossy(); diff --git a/src/blade/typed_receiver.rs b/src/blade/typed_receiver.rs index f5074c677..d33f3f186 100644 --- a/src/blade/typed_receiver.rs +++ b/src/blade/typed_receiver.rs @@ -1,4 +1,4 @@ -//! Render sites whose receiver only a *type* settles. +//! Laravel string sites whose receiver only a *type* settles. //! //! The symbol map decides what is a view name syntactically, so the //! render sites it records as [`crate::symbol_map::SymbolKind::LaravelStringKey`] @@ -12,16 +12,16 @@ //! [`crate::symbol_map::extract_symbol_map`] cannot tell: it runs during //! `update_ast`, before the file's own classes are resolved, and a forward //! walk per method call would be paid on every keystroke. It records the -//! candidate sites instead (see [`ViewReceiverSite`]), and this module +//! candidate sites instead (see [`ViewReceiverSite`] and +//! [`LaravelResourceReceiverSite`]), and this module //! answers them lazily — once per file, cached until the file is re-parsed //! — by asking the shared type engine what the receiver is. //! -//! The answer is a set of extra view spans, indistinguishable from the ones -//! the symbol map records, that every consumer of view keys reads alongside -//! the map's own. +//! The answer is a set of extra string-key spans, indistinguishable from the +//! ones the symbol map records, that every consumer reads alongside the map. use std::collections::HashMap; -use std::sync::Arc; +use std::sync::{Arc, Weak}; use mago_span::HasSpan; use mago_syntax::cst::literal::Literal; @@ -29,21 +29,98 @@ use mago_syntax::cst::*; use crate::Backend; use crate::parser::with_parsed_program; -use crate::symbol_map::{SymbolMap, SymbolSpan, ViewReceiverSite}; +use crate::symbol_map::{ + LaravelResourceReceiverRule, LaravelResourceReceiverSite, SymbolMap, SymbolSpan, + ViewReceiverSite, +}; use crate::type_engine::resolver::{Loaders, VarResolutionCtx}; use crate::types::ClassInfo; -/// One file's confirmed extra view spans, and the source length they were -/// derived from. +/// One file's typed-receiver cache entry. /// -/// The length is the same staleness signal [`SymbolMap::matches_source`] -/// uses: an insertion or removal since the spans were computed moves every -/// offset in them, so they have to be recomputed rather than reused. -pub(crate) type TypedReceiverSpans = (u32, Arc>); +/// A weak reference identifies the exact symbol-map allocation without +/// keeping a replaced map alive. Its allocation also prevents an address +/// from being reused while the cache still refers to it. +pub(crate) struct TypedReceiverSpans { + /// The exact map this entry belongs to. + pub(crate) symbol_map: Weak, + /// Whether resolution is running, complete, or was invalidated by an edit. + pub(crate) state: TypedReceiverSpansState, +} + +/// Publication state for one typed-receiver cache entry. +pub(crate) enum TypedReceiverSpansState { + /// Resolution is in progress. The token lets concurrent readers share the + /// computation generation and detects a cache clear while they resolve. + Pending(Arc<()>), + /// Confirmed spans for the entry's exact symbol map. + Ready(Arc>), + /// The map was evicted but may still be visible until its replacement is + /// committed. Readers must not recompute or publish against it. + Invalidated, +} + +impl TypedReceiverSpans { + fn belongs_to(&self, map: &Arc) -> bool { + std::ptr::eq(self.symbol_map.as_ptr(), Arc::as_ptr(map)) + } +} + +enum TypedReceiverCacheLookup { + Ready(Arc>), + Pending(Arc<()>), + Invalidated, + Missing, +} + +/// Read one entry through its exact map identity. Keeping this state-machine +/// projection in one place makes the read-fast and write-after-miss paths +/// obey identical publication rules. +fn lookup_cached_spans( + entry: Option<&TypedReceiverSpans>, + map: &Arc, +) -> TypedReceiverCacheLookup { + let Some(entry) = entry.filter(|entry| entry.belongs_to(map)) else { + return TypedReceiverCacheLookup::Missing; + }; + match &entry.state { + TypedReceiverSpansState::Ready(spans) => TypedReceiverCacheLookup::Ready(Arc::clone(spans)), + TypedReceiverSpansState::Pending(token) => { + TypedReceiverCacheLookup::Pending(Arc::clone(token)) + } + TypedReceiverSpansState::Invalidated => TypedReceiverCacheLookup::Invalidated, + } +} + +/// Claim the pending generation while the cache's write lock is held. A +/// concurrent reader may have filled the entry since the read-fast lookup; +/// in that case its state wins unchanged. +fn claim_pending_generation( + cache: &mut HashMap, + uri: &str, + map: &Arc, +) -> TypedReceiverCacheLookup { + match lookup_cached_spans(cache.get(uri), map) { + TypedReceiverCacheLookup::Missing => { + let token = Arc::new(()); + let pending = TypedReceiverSpans { + symbol_map: Arc::downgrade(map), + state: TypedReceiverSpansState::Pending(Arc::clone(&token)), + }; + if let Some(entry) = cache.get_mut(uri) { + *entry = pending; + } else { + cache.insert(uri.to_string(), pending); + } + TypedReceiverCacheLookup::Pending(token) + } + cached => cached, + } +} impl Backend { - /// The view-name spans of `uri` whose render site is recognised by the - /// receiver's type rather than its spelling. + /// The Laravel string spans of `uri` whose call/property is recognised by + /// resolved type rather than spelling alone. /// /// Empty — without any work at all — for a file whose symbol map /// recorded no candidate sites, which is every file that does not call @@ -57,14 +134,36 @@ impl Backend { uri: &str, map: &SymbolMap, ) -> Arc> { - if map.view_receiver_sites.is_empty() { + if map.view_receiver_sites.is_empty() && map.resource_receiver_sites.is_empty() { return empty_spans(); } - if let Some((source_len, spans)) = self.typed_receiver_view_spans_cache.read().get(uri) - && *source_len == map.source_len - { - return Arc::clone(spans); - } + let pending_token = { + // Pair the cache lookup/insertion with a live map lookup. A map + // replacement cannot become visible between those two checks. + let maps = self.symbol_maps.read(); + let Some(current_map) = maps.get(uri) else { + return empty_spans(); + }; + if !std::ptr::eq(current_map.as_ref(), map) { + return empty_spans(); + } + + let mut cached = { + let cache = self.typed_receiver_view_spans_cache.read(); + lookup_cached_spans(cache.get(uri), current_map) + }; + loop { + match cached { + TypedReceiverCacheLookup::Ready(spans) => return spans, + TypedReceiverCacheLookup::Invalidated => return empty_spans(), + TypedReceiverCacheLookup::Pending(token) => break token, + TypedReceiverCacheLookup::Missing => { + let mut cache = self.typed_receiver_view_spans_cache.write(); + cached = claim_pending_generation(&mut cache, uri, current_map); + } + } + } + }; let Some(content) = self.effective_content(uri) else { return empty_spans(); @@ -75,23 +174,88 @@ impl Backend { return empty_spans(); } - let spans = - Arc::new(self.confirm_view_receiver_sites(uri, &content, &map.view_receiver_sites)); - self.typed_receiver_view_spans_cache - .write() - .insert(uri.to_string(), (map.source_len, Arc::clone(&spans))); + let has_queue_name_candidates = map + .resource_receiver_sites + .iter() + .any(|site| site.rule == LaravelResourceReceiverRule::QueueName); + let spans = Arc::new(self.confirm_receiver_sites( + uri, + &content, + &map.view_receiver_sites, + &map.resource_receiver_sites, + )); + + // Hold the map read lock through both publications. An edit may evict + // the pending entry while resolution runs; only the same pending + // generation against the same still-current map may publish. + let maps = self.symbol_maps.read(); + let Some(current_map) = maps.get(uri) else { + return empty_spans(); + }; + if !std::ptr::eq(current_map.as_ref(), map) { + return empty_spans(); + } + let mut cache = self.typed_receiver_view_spans_cache.write(); + match cache.get(uri) { + Some(entry) if entry.belongs_to(current_map) => match &entry.state { + TypedReceiverSpansState::Pending(token) if Arc::ptr_eq(token, &pending_token) => {} + TypedReceiverSpansState::Ready(existing) => return Arc::clone(existing), + TypedReceiverSpansState::Pending(_) | TypedReceiverSpansState::Invalidated => { + return empty_spans(); + } + }, + _ => return empty_spans(), + } + + if has_queue_name_candidates { + self.laravel_source_strings + .write() + .set_typed_spans(uri, &spans); + } + if let Some(entry) = cache.get_mut(uri) { + *entry = TypedReceiverSpans { + symbol_map: Arc::downgrade(current_map), + state: TypedReceiverSpansState::Ready(Arc::clone(&spans)), + }; + } spans } - /// Drop the confirmed spans of one file, so the next reader recomputes - /// them against the file as it now reads. + /// Invalidate one file's confirmed spans. Readers holding its old map get + /// no answer; the first reader of the replacement map recomputes them. pub(crate) fn evict_typed_receiver_view_spans(&self, uri: &str) { - // Files with no candidate sites never get an entry, so the common - // case is a miss on a map that is almost always empty. - if self.typed_receiver_view_spans_cache.read().is_empty() { + let current_map = { + let maps = self.symbol_maps.read(); + maps.get(uri) + .filter(|map| { + !map.view_receiver_sites.is_empty() || !map.resource_receiver_sites.is_empty() + }) + .cloned() + }; + let Some(current_map) = current_map else { + // This is the path for almost every parsed file. Avoid taking the + // global cache's exclusive lock when this URI never had an entry. + if !self + .typed_receiver_view_spans_cache + .read() + .contains_key(uri) + { + return; + } + self.typed_receiver_view_spans_cache.write().remove(uri); return; + }; + + let mut cache = self.typed_receiver_view_spans_cache.write(); + let invalidated = TypedReceiverSpans { + symbol_map: Arc::downgrade(¤t_map), + state: TypedReceiverSpansState::Invalidated, + }; + if let Some(entry) = cache.get_mut(uri) { + *entry = invalidated; + } else { + cache.insert(uri.to_string(), invalidated); } - self.typed_receiver_view_spans_cache.write().remove(uri); } /// The source every offset in a file's symbol map indexes: a Blade @@ -103,32 +267,70 @@ impl Backend { self.get_file_content(uri) } - /// Resolve the receiver of every candidate site and keep the spans of - /// the ones that turn out to be renders. - fn confirm_view_receiver_sites( + /// Resolve every candidate receiver/enclosing class and keep the spans + /// whose Laravel meaning the type confirms. + fn confirm_receiver_sites( &self, uri: &str, content: &str, - sites: &[ViewReceiverSite], + view_sites: &[ViewReceiverSite], + resource_sites: &[LaravelResourceReceiverSite], ) -> Vec { - let by_offset: HashMap = - sites.iter().map(|site| (site.start, site)).collect(); + let call_resource_count = resource_sites + .iter() + .filter(|site| site.rule != LaravelResourceReceiverRule::ConnectionProperty) + .count(); + let mut sites_by_offset = HashMap::>::with_capacity( + view_sites.len() + call_resource_count, + ); + for site in view_sites { + sites_by_offset.entry(site.start).or_default().view = Some(site); + } + for site in resource_sites + .iter() + .filter(|site| site.rule != LaravelResourceReceiverRule::ConnectionProperty) + { + sites_by_offset.entry(site.start).or_default().resource = Some(site); + } let file_ctx = self.file_context(uri); let class_loader = self.class_loader(&file_ctx); + let mut confirmed = Vec::new(); + for site in resource_sites + .iter() + .filter(|site| site.rule == LaravelResourceReceiverRule::ConnectionProperty) + { + let Some(class) = + crate::class_lookup::find_class_at_offset(&file_ctx.classes, site.start) + else { + continue; + }; + if let Some(kind) = crate::symbol_map::laravel_resources::classify_connection_property( + class, + &class_loader, + ) { + confirmed.push(site.to_span(kind)); + } + } + if sites_by_offset.is_empty() { + confirmed.sort_by_key(|span| span.start); + return confirmed; + } + let function_loader = self.function_loader(&file_ctx); let function_loader_cl = |name: &str, offset: u32| function_loader(name, offset); with_parsed_program(content, "blade_typed_receiver", |program, content| { let mut calls: Vec> = Vec::new(); - let walker = ReceiverCallWalker { sites: &by_offset }; + let walker = ReceiverCallWalker { + sites: &sites_by_offset, + }; let mut ctx = CollectCtx { calls: &mut calls }; for stmt in program.statements.iter() { mago_syntax::walker::Walker::walk_statement(&walker, stmt, &mut ctx); } let default_class = ClassInfo::default(); - let mut confirmed = Vec::new(); for call in calls { let offset = call.receiver.span().start.offset; let current_class = @@ -161,15 +363,31 @@ impl Backend { continue; }; for site in call.sites { - if crate::class_lookup::is_subtype_of_named( - &ty, - site.receiver.fqn(), - &class_loader, - ) { - confirmed.push(site.to_span()); + match site { + ReceiverSite::View(site) => { + if crate::class_lookup::is_subtype_of_named( + &ty, + site.receiver.fqn(), + &class_loader, + ) { + confirmed.push(site.to_span()); + } + } + ReceiverSite::Resource(site) => { + if let Some(kind) = + crate::symbol_map::laravel_resources::classify_receiver_type( + site.rule, + &ty, + &class_loader, + ) + { + confirmed.push(site.to_span(kind)); + } + } } } } + confirmed.sort_by_key(|span| span.start); confirmed }) @@ -183,11 +401,22 @@ fn empty_spans() -> Arc> { Arc::clone(EMPTY.get_or_init(|| Arc::new(Vec::new()))) } -/// One method call that names at least one candidate template, paired with -/// the receiver whose type decides whether it renders. +/// One method call that owns at least one type-dependent Laravel string. struct ReceiverCall<'ast, 'arena, 'sites> { receiver: &'ast Expression<'arena>, - sites: Vec<&'sites ViewReceiverSite>, + sites: Vec>, +} + +#[derive(Clone, Copy)] +enum ReceiverSite<'sites> { + View(&'sites ViewReceiverSite), + Resource(&'sites LaravelResourceReceiverSite), +} + +#[derive(Clone, Copy, Default)] +struct ReceiverCandidates<'sites> { + view: Option<&'sites ViewReceiverSite>, + resource: Option<&'sites LaravelResourceReceiverSite>, } struct CollectCtx<'w, 'ast, 'arena, 'sites> { @@ -200,30 +429,36 @@ struct CollectCtx<'w, 'ast, 'arena, 'sites> { /// which is unique in the file, so a call owns exactly the candidates its /// own argument list spells. struct ReceiverCallWalker<'a, 'sites> { - sites: &'a HashMap, + sites: &'a HashMap>, } impl<'sites> ReceiverCallWalker<'_, 'sites> { /// The candidates one argument holds: the string itself, or the entries /// of the array a `first(['a', 'b'])` names. - fn matching_sites(&self, expr: &Expression<'_>, out: &mut Vec<&'sites ViewReceiverSite>) { + fn matching_sites(&self, expr: &Expression<'_>, matches: &mut Vec>) { match expr { Expression::Literal(Literal::String(s)) => { - if let Some(site) = self.sites.get(&(s.span.start.offset + 1)) { - out.push(site); + let start = s.span.start.offset + 1; + if let Some(candidates) = self.sites.get(&start) { + if let Some(site) = candidates.view { + matches.push(ReceiverSite::View(site)); + } + if let Some(site) = candidates.resource { + matches.push(ReceiverSite::Resource(site)); + } } } Expression::Array(array) => { for element in array.elements.iter() { if let ArrayElement::Value(value) = element { - self.matching_sites(value.value, out); + self.matching_sites(value.value, matches); } } } Expression::LegacyArray(array) => { for element in array.elements.iter() { if let ArrayElement::Value(value) = element { - self.matching_sites(value.value, out); + self.matching_sites(value.value, matches); } } } @@ -252,4 +487,389 @@ impl<'ast, 'arena, 'w, 'sites> }); } } + + fn walk_in_null_safe_method_call( + &self, + node: &'ast NullSafeMethodCall<'arena>, + ctx: &mut CollectCtx<'w, 'ast, 'arena, 'sites>, + ) { + let mut sites = Vec::new(); + for argument in node.argument_list.arguments.iter() { + self.matching_sites(argument.value(), &mut sites); + } + if !sites.is_empty() { + ctx.calls.push(ReceiverCall { + receiver: node.object, + sites, + }); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const CONTENT: &str = "onQueue('critical');"; + + fn queue_name_map() -> Arc { + let start = CONTENT.find("critical").unwrap() as u32; + Arc::new(SymbolMap { + resource_receiver_sites: vec![LaravelResourceReceiverSite { + start, + end: start + "critical".len() as u32, + key: "critical".to_string(), + rule: LaravelResourceReceiverRule::QueueName, + }], + source_len: CONTENT.len() as u32, + ..Default::default() + }) + } + + #[test] + fn cache_identity_is_the_exact_symbol_map_allocation() { + let first = Arc::new(SymbolMap::default()); + let replacement = Arc::new(SymbolMap::default()); + let entry = TypedReceiverSpans { + symbol_map: Arc::downgrade(&first), + state: TypedReceiverSpansState::Pending(Arc::new(())), + }; + + assert!(entry.belongs_to(&first)); + assert!(!entry.belongs_to(&replacement)); + drop(first); + assert!(entry.symbol_map.upgrade().is_none()); + } + + #[test] + fn cache_lookup_preserves_every_publication_state() { + let map = Arc::new(SymbolMap::default()); + let replacement = Arc::new(SymbolMap::default()); + let ready_spans = Arc::new(Vec::new()); + let ready = TypedReceiverSpans { + symbol_map: Arc::downgrade(&map), + state: TypedReceiverSpansState::Ready(Arc::clone(&ready_spans)), + }; + assert!(matches!( + lookup_cached_spans(Some(&ready), &map), + TypedReceiverCacheLookup::Ready(found) if Arc::ptr_eq(&found, &ready_spans) + )); + + let pending_token = Arc::new(()); + let pending = TypedReceiverSpans { + symbol_map: Arc::downgrade(&map), + state: TypedReceiverSpansState::Pending(Arc::clone(&pending_token)), + }; + assert!(matches!( + lookup_cached_spans(Some(&pending), &map), + TypedReceiverCacheLookup::Pending(found) if Arc::ptr_eq(&found, &pending_token) + )); + + let invalidated = TypedReceiverSpans { + symbol_map: Arc::downgrade(&map), + state: TypedReceiverSpansState::Invalidated, + }; + assert!(matches!( + lookup_cached_spans(Some(&invalidated), &map), + TypedReceiverCacheLookup::Invalidated + )); + assert!(matches!( + lookup_cached_spans(Some(&invalidated), &replacement), + TypedReceiverCacheLookup::Missing + )); + assert!(matches!( + lookup_cached_spans(None, &map), + TypedReceiverCacheLookup::Missing + )); + } + + #[test] + fn pending_claim_reuses_or_replaces_the_lock_protected_state() { + let uri = "phpantom-test://typed-claim.php"; + let map = Arc::new(SymbolMap::default()); + let replacement = Arc::new(SymbolMap::default()); + let mut cache = HashMap::new(); + + assert!(matches!( + claim_pending_generation(&mut cache, uri, &map), + TypedReceiverCacheLookup::Pending(_) + )); + assert!(matches!( + claim_pending_generation(&mut cache, uri, &map), + TypedReceiverCacheLookup::Pending(_) + )); + + let ready_spans = Arc::new(Vec::new()); + cache.get_mut(uri).unwrap().state = + TypedReceiverSpansState::Ready(Arc::clone(&ready_spans)); + assert!(matches!( + claim_pending_generation(&mut cache, uri, &map), + TypedReceiverCacheLookup::Ready(found) if Arc::ptr_eq(&found, &ready_spans) + )); + + cache.get_mut(uri).unwrap().state = TypedReceiverSpansState::Invalidated; + assert!(matches!( + claim_pending_generation(&mut cache, uri, &map), + TypedReceiverCacheLookup::Invalidated + )); + + assert!(matches!( + claim_pending_generation(&mut cache, uri, &replacement), + TypedReceiverCacheLookup::Pending(_) + )); + assert!(cache.get(uri).unwrap().belongs_to(&replacement)); + } + + #[test] + fn candidate_free_eviction_only_removes_an_existing_entry() { + let backend = Backend::new_test(); + let uri = "phpantom-test://typed-no-candidates.php"; + let map = Arc::new(SymbolMap::default()); + backend + .symbol_maps + .write() + .insert(uri.to_string(), Arc::clone(&map)); + + backend.evict_typed_receiver_view_spans(uri); + assert!(backend.typed_receiver_view_spans_cache.read().is_empty()); + + backend.typed_receiver_view_spans_cache.write().insert( + uri.to_string(), + TypedReceiverSpans { + symbol_map: Arc::downgrade(&map), + state: TypedReceiverSpansState::Ready(Arc::new(Vec::new())), + }, + ); + backend.evict_typed_receiver_view_spans(uri); + assert!(backend.typed_receiver_view_spans_cache.read().is_empty()); + } + + #[test] + fn typed_cache_rejects_missing_replaced_and_invalidated_maps() { + let backend = Backend::new_test(); + let uri = "phpantom-test://typed-cache.php"; + let requested = queue_name_map(); + + assert!( + backend + .typed_receiver_view_spans_for(uri, requested.as_ref()) + .is_empty() + ); + + let replacement = queue_name_map(); + backend + .symbol_maps + .write() + .insert(uri.to_string(), Arc::clone(&replacement)); + assert!( + backend + .typed_receiver_view_spans_for(uri, requested.as_ref()) + .is_empty() + ); + + backend.evict_typed_receiver_view_spans(uri); + assert!(matches!( + backend + .typed_receiver_view_spans_cache + .read() + .get(uri) + .map(|entry| &entry.state), + Some(TypedReceiverSpansState::Invalidated) + )); + assert!( + backend + .typed_receiver_view_spans_for(uri, replacement.as_ref()) + .is_empty() + ); + } + + #[test] + fn an_existing_pending_generation_is_resolved_and_published() { + let backend = Backend::new_test(); + let uri = "phpantom-test://typed-pending.php"; + let map = queue_name_map(); + backend + .open_files + .write() + .insert(uri.to_string(), Arc::new(CONTENT.to_string())); + backend + .symbol_maps + .write() + .insert(uri.to_string(), Arc::clone(&map)); + backend.typed_receiver_view_spans_cache.write().insert( + uri.to_string(), + TypedReceiverSpans { + symbol_map: Arc::downgrade(&map), + state: TypedReceiverSpansState::Pending(Arc::new(())), + }, + ); + + assert!( + backend + .typed_receiver_view_spans_for(uri, map.as_ref()) + .is_empty() + ); + assert!(matches!( + backend + .typed_receiver_view_spans_cache + .read() + .get(uri) + .map(|entry| &entry.state), + Some(TypedReceiverSpansState::Ready(_)) + )); + } + + #[test] + fn property_sites_without_an_enclosing_class_and_legacy_arrays_are_ignored() { + let backend = Backend::new_test(); + let property_site = LaravelResourceReceiverSite { + start: 1, + end: 6, + key: "mysql".to_string(), + rule: LaravelResourceReceiverRule::ConnectionProperty, + }; + assert!( + backend + .confirm_receiver_sites( + "phpantom-test://orphan-property.php", + "", + &[], + &[property_site], + ) + .is_empty() + ); + + let content = "first(array('legacy.view'));"; + let start = content.find("legacy.view").unwrap() as u32; + let view_site = ViewReceiverSite { + start, + end: start + "legacy.view".len() as u32, + key: "legacy.view".to_string(), + is_optional: true, + receiver: crate::symbol_map::ViewReceiverClass::Factory, + }; + assert!( + backend + .confirm_receiver_sites( + "phpantom-test://legacy-view.php", + content, + &[view_site], + &[], + ) + .is_empty() + ); + } + + #[derive(Clone, Copy)] + enum PublicationMutation { + RemoveMap, + ReplaceMap, + PublishReady, + ReplacePending, + RemoveEntry, + } + + fn resolve_while_mutating_publication(mutation: PublicationMutation) -> usize { + let backend = Backend::new_test(); + let uri = "phpantom-test://typed-publication-race.php"; + let map = queue_name_map(); + backend + .open_files + .write() + .insert(uri.to_string(), Arc::new(CONTENT.to_string())); + backend + .symbol_maps + .write() + .insert(uri.to_string(), Arc::clone(&map)); + + // Resolution reads this index after installing its pending generation. + // Holding the write lock gives the test a deterministic, production + // synchronization point at which to model an edit or concurrent reader. + let class_index = backend.symbols.uri_classes_index.write(); + let worker_backend = backend.clone_for_blocking(); + let worker_map = Arc::clone(&map); + let worker_uri = uri.to_string(); + let worker = std::thread::spawn(move || { + worker_backend.typed_receiver_view_spans_for(&worker_uri, worker_map.as_ref()) + }); + + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + let is_pending = backend + .typed_receiver_view_spans_cache + .read() + .get(uri) + .is_some_and(|entry| matches!(&entry.state, TypedReceiverSpansState::Pending(_))); + if is_pending { + break; + } + assert!( + std::time::Instant::now() < deadline, + "typed-receiver worker did not publish its pending generation" + ); + std::thread::yield_now(); + } + + match mutation { + PublicationMutation::RemoveMap => { + backend.symbol_maps.write().remove(uri); + } + PublicationMutation::ReplaceMap => { + backend + .symbol_maps + .write() + .insert(uri.to_string(), queue_name_map()); + } + PublicationMutation::PublishReady => { + let ready = Arc::new(vec![ + map.resource_receiver_sites[0] + .to_span(crate::symbol_map::LaravelStringKind::QueueName), + ]); + backend + .typed_receiver_view_spans_cache + .write() + .get_mut(uri) + .unwrap() + .state = TypedReceiverSpansState::Ready(ready); + } + PublicationMutation::ReplacePending => { + backend + .typed_receiver_view_spans_cache + .write() + .get_mut(uri) + .unwrap() + .state = TypedReceiverSpansState::Pending(Arc::new(())); + } + PublicationMutation::RemoveEntry => { + backend.typed_receiver_view_spans_cache.write().remove(uri); + } + } + + drop(class_index); + worker.join().unwrap().len() + } + + #[test] + fn stale_resolutions_cannot_publish_over_newer_maps_or_generations() { + assert_eq!( + resolve_while_mutating_publication(PublicationMutation::RemoveMap), + 0 + ); + assert_eq!( + resolve_while_mutating_publication(PublicationMutation::ReplaceMap), + 0 + ); + assert_eq!( + resolve_while_mutating_publication(PublicationMutation::PublishReady), + 1 + ); + assert_eq!( + resolve_while_mutating_publication(PublicationMutation::ReplacePending), + 0 + ); + assert_eq!( + resolve_while_mutating_publication(PublicationMutation::RemoveEntry), + 0 + ); + } } diff --git a/src/completion/handler/mod.rs b/src/completion/handler/mod.rs index 853fb6489..b4c60c9e3 100644 --- a/src/completion/handler/mod.rs +++ b/src/completion/handler/mod.rs @@ -372,8 +372,12 @@ impl Backend { string_ctx, StringContext::InStringLiteral | StringContext::NotInString ) - && let Some(response) = - self.try_laravel_string_key_completion(&content, position) + && let Some(response) = self.try_laravel_string_key_completion_in_file( + uri.as_str(), + &content, + position, + &ctx, + ) { return Ok(Some(response)); } diff --git a/src/completion/laravel_string_keys.rs b/src/completion/laravel_string_keys.rs index fe69933c7..5f3192a8d 100644 --- a/src/completion/laravel_string_keys.rs +++ b/src/completion/laravel_string_keys.rs @@ -1,317 +1,82 @@ //! Laravel string key completion. //! -//! Offers autocompletion for route names, config keys, view names, and -//! translation keys inside their respective helper calls: +//! Offers autocompletion for Laravel's string-addressed resources inside +//! their respective helper, facade, attribute, and typed-receiver calls: //! //! - `route('|')` / `to_route('|')` → route names //! - `config('|')` / `Config::get('|')` → config keys //! - `view('|')` / `View::make('|')` → view names //! - `__('|')` / `trans('|')` / `Lang::get('|')` → translation keys +//! - `Cache::store('|')` / `Storage::disk('|')` → configured resource names use std::collections::HashMap; use tower_lsp::lsp_types::*; use crate::Backend; -use crate::symbol_map::LaravelStringKind; +#[cfg(test)] +use crate::symbol_map::LaravelConfigResource; +use crate::symbol_map::{LaravelResourceReceiverRule, LaravelStringKind}; use crate::text_position::position_to_offset; +use crate::type_engine::resolver::{ResolutionCtx, resolve_target_classes}; +use crate::types::{AccessKind, FileContext}; -// ─── 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>, -} +mod context; -// ─── Detection ────────────────────────────────────────────────────────────── +use context::detect_laravel_string_key_context_inner; +#[cfg(test)] +use context::{ + callable_before_scalar_argument, chain_contains_laravel_facade, + detect_laravel_string_key_context, enclosing_call_open_paren, is_connection_property_value, + is_laravel_facade, rate_limited_constructor_class, string_literal_is_array_key, +}; -/// 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(); +// ─── Enumeration ──────────────────────────────────────────────────────────── - if cursor_offset == 0 || cursor_offset > bytes.len() { - return None; - } +type ConfigMetadata = (std::sync::Arc>, std::sync::Arc>); - // ── 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(); +#[inline] +fn config_metadata_snapshot(cache: &crate::LaravelStringKeyCache) -> Option { + Some(( + std::sync::Arc::clone(cache.config_keys.as_ref()?), + std::sync::Arc::clone(cache.config_open_prefixes.as_ref()?), + )) +} - // ── 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; +/// Publish a completed scan only when no invalidation happened while it was +/// running. A stale scan is discarded and rebuilt against the new generation. +fn publish_config_metadata( + cache: &mut crate::LaravelStringKeyCache, + generation: u64, + metadata: &ConfigMetadata, +) -> bool { + if cache.config_generation != generation { + return false; } - 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(); + cache.config_keys = Some(std::sync::Arc::clone(&metadata.0)); + cache.config_open_prefixes = Some(std::sync::Arc::clone(&metadata.1)); + true +} - 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.")), - // Artisan command names. - ("artisan", "call" | "queue") => (Some(LaravelStringKind::Command), None), - ("schedule", "command") => (Some(LaravelStringKind::Command), None), - // Eloquent morph aliases. - ("relation", "getmorphedmodel") => (Some(LaravelStringKind::MorphAlias), None), - ("model", "getactualclassnameformorph") => (Some(LaravelStringKind::MorphAlias), None), - // Authorization abilities checked through the Gate facade. - ( - "gate", - "allows" | "denies" | "check" | "any" | "none" | "authorize" | "inspect" | "has" - | "define", - ) => (Some(LaravelStringKind::GateAbility), None), - _ => (None, None), - } - } else if is_instance_method { - // Whether the receiver is `$this` (used to scope command-running - // methods, whose names are too generic to match on any object). - let receiver_is_this = { - let recv = trimmed_before - .trim_end_matches("?->") - .trim_end_matches("->") - .trim_end(); - recv.ends_with("$this") - }; - // Whether the receiver plainly reads as the authenticated user, - // which is what makes `->can('…')` an authorization check rather - // than a same-named method on an unrelated object. Mirrors the - // symbol-map rule that decides which `can()` calls get a span. - let receiver_is_user_like = { - let recv = trimmed_before - .trim_end_matches("?->") - .trim_end_matches("->") - .trim_end(); - let tail = recv - .rsplit(|c: char| !(c.is_ascii_alphanumeric() || c == '_')) - .next() - .unwrap_or(""); - tail.to_ascii_lowercase().ends_with("user") || recv.ends_with("user()") - }; - // A chain that starts at the `Gate` facade - // (`Gate::forUser($user)->allows('…')`) or at a route registration - // (`Route::get(…)->can('…')`) is an authorization check whatever the - // rest of the chain looks like. Only the text back to the start of - // the statement is searched — `trimmed_before` is the whole file - // prefix, and an unrelated `Gate::` far above would false-positive. - let chain_text = &trimmed_before[trimmed_before - .rfind(['\n', ';', '{', '}']) - .map_or(0, |idx| idx + 1)..]; - let chain_starts_at_gate = chain_text.contains("Gate::"); - let chain_starts_at_route = chain_text.contains("Route::"); - let k = match func_name.to_ascii_lowercase().as_str() { - "route" => Some(LaravelStringKind::Route), - // `$this->call('cmd')` / `$this->callSilently('cmd')` inside a - // console command run another Artisan command. Restricted to a - // `$this` receiver because `->call()` is a common method name. - "call" | "callsilently" if receiver_is_this => Some(LaravelStringKind::Command), - // `$this->authorize('update', $post)` in a controller. - "authorize" if receiver_is_this || chain_starts_at_gate => { - Some(LaravelStringKind::GateAbility) - } - // `$user->can('update', $post)`. - "can" | "cannot" | "canany" - if receiver_is_user_like || chain_starts_at_route || chain_starts_at_gate => - { - Some(LaravelStringKind::GateAbility) - } - "allows" | "denies" | "check" | "any" | "none" | "inspect" | "has" - if chain_starts_at_gate => - { - Some(LaravelStringKind::GateAbility) - } - _ => 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" | "blade_each_directive" => { - (Some(LaravelStringKind::View), None) - } - "__" | "trans" | "trans_choice" => (Some(LaravelStringKind::Trans), None), - // The Blade preprocessor lowers `@can`/`@cannot`/`@canany` to - // this call, so completion inside the directive works too. - "blade_can_directive" => (Some(LaravelStringKind::GateAbility), None), - // auth('guard') helper accepts a guard name - "auth" => (Some(LaravelStringKind::Config), Some("auth.guards.")), - _ => (None, None), +/// Keep a receiver-derived resource kind only when every possible receiver +/// resolves to the same family. Mixed or unresolved unions must not offer +/// names from an arbitrary branch. +#[inline] +fn unanimous_resource_kind( + kinds: impl IntoIterator>, +) -> Option { + let mut confirmed = None; + for kind in kinds { + let kind = kind?; + match confirmed { + Some(existing) if existing != kind => return None, + None => confirmed = Some(kind), + _ => {} } - }; - - let kind = kind?; - - Some(LaravelStringKeyContext { - kind, - prefix, - content_start_offset: quote_pos + 1, - config_sub_prefix, - }) + } + confirmed } -// ─── Enumeration ──────────────────────────────────────────────────────────── - impl Backend { /// The configured Blade view root directories. /// @@ -327,15 +92,16 @@ impl Backend { } } - /// Enumerate all config keys by scanning `config/` files and - /// package config files discovered from service providers. - fn enumerate_all_config_keys(&self) -> Vec { + /// Enumerate all config keys and runtime-open subtrees by scanning + /// `config/` files and package config files discovered from providers. + fn enumerate_all_config_metadata(&self) -> (Vec, Vec) { use crate::virtual_members::laravel::{ - collect_laravel_config_declarations, laravel_config_prefix_from_uri, + laravel_config_prefix_from_uri, scan_laravel_config_file, }; let snapshot = self.user_file_symbol_maps(); let mut keys = Vec::new(); + let mut open_prefixes = Vec::new(); for (file_uri, _) in &snapshot { let Some(prefix) = laravel_config_prefix_from_uri(file_uri) else { @@ -344,18 +110,27 @@ impl Backend { let Some(content) = self.get_file_content(file_uri) else { continue; }; - let decls = collect_laravel_config_declarations(&content, &prefix); - for d in decls { + let scan = scan_laravel_config_file(&content, &prefix); + for d in scan.declarations { keys.push(d.key); } + open_prefixes.extend(scan.open_prefixes); } - 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 { + let provider_configs = self + .laravel_provider_resources + .read() + .config_files + .iter() + .map(|resource| (resource.path.clone(), resource.namespace.clone())) + .collect::>(); + for (path, namespace) in provider_configs { + if let Some((_, content)) = self.laravel_config_file_content(&path) { + let scan = scan_laravel_config_file(&content, &namespace); + for d in scan.declarations { keys.push(d.key); } + open_prefixes.extend(scan.open_prefixes); } } @@ -373,11 +148,12 @@ impl Backend { continue; }; let prefix = stem.to_string(); - if let Ok(content) = std::fs::read_to_string(&path) { - let decls = collect_laravel_config_declarations(&content, &prefix); - for d in decls { + if let Some((_, content)) = self.laravel_config_file_content(&path) { + let scan = scan_laravel_config_file(&content, &prefix); + for d in scan.declarations { keys.push(d.key); } + open_prefixes.extend(scan.open_prefixes); } } } @@ -385,7 +161,9 @@ impl Backend { keys.sort(); keys.dedup(); - keys + open_prefixes.sort(); + open_prefixes.dedup(); + (keys, open_prefixes) } /// Enumerate all translation keys by scanning `lang/` files and @@ -518,13 +296,55 @@ impl Backend { .collect() } - pub(crate) fn cached_config_keys(&self) -> Vec { - self.cached_laravel_enumeration( - &self.laravel_string_key_build_locks.config_keys, - |cache| cache.config_keys.clone(), - |cache, keys| cache.config_keys = Some(keys), - || self.enumerate_all_config_keys(), - ) + /// The sorted config keys and runtime-open subtrees from one shared scan. + pub(crate) fn cached_config_metadata( + &self, + ) -> (std::sync::Arc>, std::sync::Arc>) { + self.cached_config_metadata_with(|| self.enumerate_all_config_metadata()) + } + + /// Cache a config scan, retrying it when an invalidation overtakes the + /// scan before publication. + fn cached_config_metadata_with( + &self, + scan: impl FnMut() -> (Vec, Vec), + ) -> ConfigMetadata { + self.cached_config_metadata_with_snapshot(config_metadata_snapshot, scan) + } + + fn cached_config_metadata_with_snapshot( + &self, + mut snapshot: impl FnMut(&crate::LaravelStringKeyCache) -> Option, + mut scan: impl FnMut() -> (Vec, Vec), + ) -> ConfigMetadata { + loop { + if let Some(metadata) = snapshot(&self.laravel_string_key_cache.read()) { + return metadata; + } + + let _build_guard = self.laravel_string_key_build_locks.config_keys.lock(); + let generation = { + let cache = self.laravel_string_key_cache.read(); + if let Some(metadata) = snapshot(&cache) { + return metadata; + } + cache.config_generation + }; + + let (keys, open_prefixes) = scan(); + let metadata = ( + std::sync::Arc::new(keys), + std::sync::Arc::new(open_prefixes), + ); + let mut cache = self.laravel_string_key_cache.write(); + if publish_config_metadata(&mut cache, generation, &metadata) { + return metadata; + } + } + } + + pub(crate) fn cached_config_keys(&self) -> std::sync::Arc> { + self.cached_config_metadata().0 } pub(crate) fn cached_view_names(&self) -> Vec { @@ -767,7 +587,9 @@ fn collect_namespaced_trans_shapes_from_locale_dir( /// names is what it should look like. fn string_key_item_kind(kind: &LaravelStringKind) -> CompletionItemKind { match kind { - LaravelStringKind::Config => CompletionItemKind::PROPERTY, + LaravelStringKind::Config | LaravelStringKind::ConfigResource(_) => { + CompletionItemKind::PROPERTY + } LaravelStringKind::View => CompletionItemKind::FILE, LaravelStringKind::Trans => CompletionItemKind::TEXT, LaravelStringKind::MorphAlias => CompletionItemKind::ENUM_MEMBER, @@ -776,7 +598,32 @@ fn string_key_item_kind(kind: &LaravelStringKind) -> CompletionItemKind { | LaravelStringKind::Command | LaravelStringKind::Section | LaravelStringKind::Stack - | LaravelStringKind::ContainerBinding => CompletionItemKind::VALUE, + | LaravelStringKind::ContainerBinding + | LaravelStringKind::RateLimiter + | LaravelStringKind::QueueName => CompletionItemKind::VALUE, + } +} + +fn merge_sorted_unique(left: Vec, right: Vec) -> Vec { + let mut left = left.into_iter().peekable(); + let mut right = right.into_iter().peekable(); + let mut merged = Vec::with_capacity(left.size_hint().0 + right.size_hint().0); + loop { + match (left.peek(), right.peek()) { + (Some(a), Some(b)) if a == b => { + merged.push(left.next().expect("peeked value")); + right.next(); + } + (Some(a), Some(b)) if a < b => { + merged.push(left.next().expect("peeked value")); + } + (Some(_), Some(_)) => { + merged.push(right.next().expect("peeked value")); + } + (Some(_), None) => merged.push(left.next().expect("peeked value")), + (None, Some(_)) => merged.push(right.next().expect("peeked value")), + (None, None) => return merged, + } } } @@ -795,7 +642,26 @@ impl Backend { fn string_key_candidates(&self, kind: &LaravelStringKind) -> Vec { match kind { LaravelStringKind::Route => self.cached_route_names(), - LaravelStringKind::Config => self.cached_config_keys(), + LaravelStringKind::Config => self.cached_config_keys().as_ref().clone(), + LaravelStringKind::ConfigResource(resource) => { + let prefix = + crate::symbol_map::laravel_resources::descriptor(*resource).config_prefix; + let keys = self.cached_config_keys(); + let first = keys.partition_point(|key| key.as_str() < prefix); + let configured = keys[first..] + .iter() + .take_while(|key| key.starts_with(prefix)) + .filter_map(|key| { + let short = key.strip_prefix(prefix)?; + (!short.is_empty() && !short.contains('.')).then(|| short.to_string()) + }) + .collect(); + let runtime = self + .laravel_source_strings + .read() + .runtime_config_resource_names(*resource); + merge_sorted_unique(configured, runtime) + } LaravelStringKind::View => self.cached_view_names(), LaravelStringKind::Trans => self.cached_trans_keys(), LaravelStringKind::Command => self.laravel_commands.read().all_names(), @@ -805,46 +671,93 @@ impl Backend { aliases } LaravelStringKind::GateAbility => self.cached_gate_abilities(), + LaravelStringKind::RateLimiter => { + self.laravel_source_strings.read().rate_limiter_names() + } + LaravelStringKind::QueueName => self.cached_queue_names(), LaravelStringKind::Section | LaravelStringKind::Stack | LaravelStringKind::ContainerBinding => Vec::new(), } } + /// Confirm every syntactic `onQueue()` candidate at most once per + /// workspace generation, then read the small incremental name index. + fn cached_queue_names(&self) -> Vec { + loop { + let generation = { + let index = self.laravel_source_strings.read(); + if index.queue_names_are_complete() { + return index.queue_names(); + } + index.queue_name_generation() + }; + for (uri, map) in self.user_file_symbol_maps() { + if map + .resource_receiver_sites + .iter() + .any(|site| site.rule == LaravelResourceReceiverRule::QueueName) + { + self.typed_receiver_view_spans_for(&uri, &map); + } + } + let mut index = self.laravel_source_strings.write(); + if index.mark_queue_names_complete(generation) { + return index.queue_names(); + } + } + } + /// Try Laravel string key completion. /// /// Detects the cursor inside the first string argument of `route()`, /// `config()`, `view()`, `__()`, etc. and offers matching key names. + #[cfg(test)] pub(crate) fn try_laravel_string_key_completion( &self, content: &str, position: Position, ) -> Option { - let ctx = detect_laravel_string_key_context(content, position)?; + self.try_laravel_string_key_completion_inner(content, position, None) + } - let mut candidates = self.string_key_candidates(&ctx.kind); + /// The live-request form, which can confirm calls whose receiver type or + /// enclosing class decides which named-resource family they address. + pub(crate) fn try_laravel_string_key_completion_in_file( + &self, + uri: &str, + content: &str, + position: Position, + file_ctx: &FileContext, + ) -> Option { + self.try_laravel_string_key_completion_inner(content, position, Some((uri, file_ctx))) + } - // 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(); + fn try_laravel_string_key_completion_inner( + &self, + content: &str, + position: Position, + file_ctx: Option<(&str, &FileContext)>, + ) -> Option { + let mut ctx = detect_laravel_string_key_context_inner( + content, + position, + file_ctx.and_then(|(_, context)| context.resolved_names.as_deref()), + )?; + if let Some(rule) = ctx.receiver_rule { + let (uri, file_ctx) = file_ctx?; + ctx.kind = self.confirm_completion_resource_kind( + uri, + content, + position, + file_ctx, + rule, + ctx.receiver_subject.as_deref(), + )?; } + let candidates = self.string_key_candidates(&ctx.kind); + // 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, @@ -855,30 +768,26 @@ impl Backend { end: position, }; - let prefix_lower = ctx.prefix.to_lowercase(); + let item_kind = string_key_item_kind(&ctx.kind); + let prefix = ctx.prefix.as_bytes(); let items: Vec = candidates .into_iter() .filter(|name| { - if prefix_lower.is_empty() { - true - } else { - name.to_lowercase().starts_with(&prefix_lower) - } + name.as_bytes() + .get(..prefix.len()) + .is_some_and(|start| start.eq_ignore_ascii_case(prefix)) }) .enumerate() - .map(|(i, name)| { - let kind = string_key_item_kind(&ctx.kind); - 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() - } + .map(|(i, name)| CompletionItem { + label: name.clone(), + kind: Some(item_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(); @@ -888,6 +797,58 @@ impl Backend { Some(CompletionResponse::Array(items)) } } + + fn confirm_completion_resource_kind( + &self, + uri: &str, + content: &str, + position: Position, + file_ctx: &FileContext, + rule: LaravelResourceReceiverRule, + subject: Option<&str>, + ) -> Option { + let cursor_offset = position_to_offset(content, position); + let current_class = + crate::class_lookup::find_class_at_offset(&file_ctx.classes, cursor_offset); + let class_loader = self.class_loader(file_ctx); + + if rule == LaravelResourceReceiverRule::ConnectionProperty { + return crate::symbol_map::laravel_resources::classify_connection_property( + current_class?, + &class_loader, + ); + } + + let function_loader = self.function_loader(file_ctx); + let laravel_macro_this_resolver = self.laravel_macro_this_resolver(&class_loader); + let rctx = ResolutionCtx { + current_class, + all_classes: &file_ctx.classes, + content, + cursor_offset, + class_loader: &class_loader, + backend: Some(self), + laravel_macro_this_resolver: Some(&laravel_macro_this_resolver), + resolved_class_cache: Some(&self.resolved_class_cache), + function_loader: Some(&function_loader), + scope_var_resolver: None, + is_in_static_method: self + .symbol_map_for(uri) + .is_some_and(|map| map.is_in_static_method(cursor_offset)), + preserve_static: false, + }; + unanimous_resource_kind( + resolve_target_classes(subject?, AccessKind::Arrow, &rctx) + .into_iter() + .map(|resolved_type| { + crate::symbol_map::laravel_resources::classify_receiver_type( + rule, + &resolved_type.type_string, + &class_loader, + ) + }), + ) + } } // ─── Tests ────────────────────────────────────────────────────────────────── @@ -897,6 +858,13 @@ mod tests { use super::*; use tower_lsp::lsp_types::Position; + fn completion_response_items(response: CompletionResponse) -> Vec { + match response { + CompletionResponse::Array(items) => items, + CompletionResponse::List(list) => list.items, + } + } + /// Three kinds are recorded as spans but completed from somewhere else, /// or not at all, so they must offer nothing here rather than an empty /// list dressed up as the answer. @@ -921,6 +889,10 @@ mod tests { use tower_lsp::lsp_types::CompletionItemKind; for (kind, expected) in [ (LaravelStringKind::Config, CompletionItemKind::PROPERTY), + ( + LaravelStringKind::ConfigResource(LaravelConfigResource::CacheStore), + CompletionItemKind::PROPERTY, + ), (LaravelStringKind::View, CompletionItemKind::FILE), (LaravelStringKind::Trans, CompletionItemKind::TEXT), ( @@ -936,6 +908,8 @@ mod tests { LaravelStringKind::ContainerBinding, CompletionItemKind::VALUE, ), + (LaravelStringKind::RateLimiter, CompletionItemKind::VALUE), + (LaravelStringKind::QueueName, CompletionItemKind::VALUE), ] { assert_eq!(string_key_item_kind(&kind), expected, "for {kind:?}"); } @@ -951,6 +925,19 @@ mod tests { let ctx = ctx.expect("should detect route() context"); assert!(matches!(ctx.kind, LaravelStringKind::Route)); assert_eq!(ctx.prefix, "user."); + assert_eq!( + ctx.prefix.as_ptr(), + content[content.find("user.").unwrap()..].as_ptr(), + "detection should borrow the typed prefix instead of allocating it" + ); + } + + #[test] + fn rejects_a_cursor_before_any_string_content() { + assert!( + detect_laravel_string_key_context(" null);\n"; + let line_text = content.lines().nth(1).unwrap(); + let col = line_text.find("archive").unwrap() as u32 + 3; + assert!( + detect_laravel_string_key_context(content, Position::new(1, col)).is_none(), + "Storage::extend() names a driver, not a disk" + ); + } + + #[test] + fn detects_each_forget_disk_array_value() { + for content in [ + " 'daily'"#; + assert!(string_literal_is_array_key( + escaped_key, + "'key".len(), + b'\'' + )); + assert!(!string_literal_is_array_key("'key\n", "'key".len(), b'\'')); + assert!(!string_literal_is_array_key("'key", "'key".len(), b'\'')); + + let before_value = r#"Storage::fake(config: ['message' => 'it\'s', 'factory' => wrap(fn () => new class {})], disk:"#; + assert_eq!( + callable_before_scalar_argument(before_value), + Some(("Storage::fake", Some("disk"))) + ); + assert!(callable_before_scalar_argument("Storage::disk(:").is_none()); + assert!(enclosing_call_open_paren("completed(); orphan").is_none()); + assert!(enclosing_call_open_paren("orphan").is_none()); + } + + #[test] + fn facade_chain_scanning_handles_spacing_and_multiple_static_calls() { + assert!(is_laravel_facade("Gate", "Gate")); + assert!(is_laravel_facade( + "\\Illuminate\\Support\\Facades\\Gate", + "Gate" + )); + assert!(!is_laravel_facade("App\\Gate", "Gate")); + + assert!(chain_contains_laravel_facade( + "Other::make()-> Gate \t::forUser($user)", + 0, + None, + "Gate" + )); + assert!(!chain_contains_laravel_facade( + "Other::make()", + 0, + None, + "Gate" + )); + assert!(!chain_contains_laravel_facade("::", 0, None, "Gate")); + } + + #[test] + fn rejects_arrays_for_scalar_storage_methods() { + let content = "route('name')", LaravelStringKind::Route), + ] { + let content = format!("middleware('auth:we')", + "Route::get('/', $handler)->middleware(['auth:we'])", + ] { + let content = format!("connection('name')", + LaravelResourceReceiverRule::ConnectionMethod, + ), + ( + "$manager->connection(connection: 'name')", + LaravelResourceReceiverRule::ConnectionMethod, + ), + ( + "$job->onConnection('name')", + LaravelResourceReceiverRule::QueueableConnection, + ), + ( + "$job?->onQueue('name')", + LaravelResourceReceiverRule::QueueName, + ), + ( + "protected $connection = 'name'", + LaravelResourceReceiverRule::ConnectionProperty, + ), + ] { + let content = format!(" = 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 - ); + let items = completion_response_items(response.expect("route completion response")); + let labels: Vec<&str> = 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 + ); + } + + #[test] + fn completion_item_test_helper_accepts_list_responses() { + let response = CompletionResponse::List(CompletionList { + is_incomplete: false, + items: vec![CompletionItem::default()], + }); + assert_eq!(completion_response_items(response).len(), 1); + } + + #[test] + fn sorted_config_candidates_merge_without_duplicates() { + let names = |values: &[&str]| values.iter().map(|value| (*value).to_string()).collect(); + assert_eq!( + merge_sorted_unique( + names(&["alpha", "charlie"]), + names(&["alpha", "bravo", "delta"]), + ), + names(&["alpha", "bravo", "charlie", "delta"]) + ); + assert_eq!( + merge_sorted_unique(names(&["alpha"]), Vec::new()), + names(&["alpha"]) + ); + assert_eq!( + merge_sorted_unique(Vec::new(), names(&["bravo"])), + names(&["bravo"]) + ); + + let backend = crate::Backend::new_test(); + { + let mut cache = backend.laravel_string_key_cache.write(); + cache.config_keys = Some(std::sync::Arc::new(names(&["app.name", "cache.default"]))); + cache.config_open_prefixes = Some(std::sync::Arc::new(Vec::new())); } + assert_eq!( + backend.string_key_candidates(&LaravelStringKind::Config), + names(&["app.name", "cache.default"]) + ); + } + + #[test] + fn config_metadata_publication_rejects_stale_scans() { + let keys = std::sync::Arc::new(vec!["cache.default".to_string()]); + let open_prefixes = std::sync::Arc::new(vec!["services.runtime.".to_string()]); + let metadata = ( + std::sync::Arc::clone(&keys), + std::sync::Arc::clone(&open_prefixes), + ); + let mut cache = crate::LaravelStringKeyCache::default(); + + assert!(config_metadata_snapshot(&cache).is_none()); + cache.config_keys = Some(std::sync::Arc::clone(&keys)); + assert!( + config_metadata_snapshot(&cache).is_none(), + "a partially published pair must remain a cache miss" + ); + + cache.config_keys = None; + cache.config_generation = 2; + assert!(!publish_config_metadata(&mut cache, 1, &metadata)); + assert!(cache.config_keys.is_none()); + assert!(cache.config_open_prefixes.is_none()); + + assert!(publish_config_metadata(&mut cache, 2, &metadata)); + let (published_keys, published_prefixes) = + config_metadata_snapshot(&cache).expect("matching generations publish atomically"); + assert!(std::sync::Arc::ptr_eq(&published_keys, &keys)); + assert!(std::sync::Arc::ptr_eq(&published_prefixes, &open_prefixes)); + } + + #[test] + fn config_metadata_cache_retries_a_scan_overtaken_by_invalidation() { + let backend = crate::Backend::new_test(); + let scans = std::cell::Cell::new(0); + let (keys, open_prefixes) = backend.cached_config_metadata_with(|| { + let scan = scans.get(); + scans.set(scan + 1); + if scan == 0 { + backend.laravel_string_key_cache.write().config_generation += 1; + } + ( + vec!["cache.default".to_string()], + vec!["services.runtime.".to_string()], + ) + }); + + assert_eq!(scans.get(), 2, "the stale first scan must be rebuilt"); + assert_eq!(keys.as_slice(), &["cache.default"]); + assert_eq!(open_prefixes.as_slice(), &["services.runtime."]); + } + + #[test] + fn config_metadata_cache_reuses_a_build_completed_before_lock_acquisition() { + let backend = crate::Backend::new_test(); + let expected_keys = std::sync::Arc::new(vec!["cache.default".to_string()]); + let expected_prefixes = std::sync::Arc::new(vec!["services.runtime.".to_string()]); + { + let mut cache = backend.laravel_string_key_cache.write(); + cache.config_keys = Some(std::sync::Arc::clone(&expected_keys)); + cache.config_open_prefixes = Some(std::sync::Arc::clone(&expected_prefixes)); + } + + let snapshots = std::cell::Cell::new(0); + let (keys, prefixes) = backend.cached_config_metadata_with_snapshot( + |cache| { + let snapshot = snapshots.get(); + snapshots.set(snapshot + 1); + (snapshot > 0) + .then(|| config_metadata_snapshot(cache)) + .flatten() + }, + std::default::Default::default, + ); + + assert_eq!(snapshots.get(), 2); + assert!(std::sync::Arc::ptr_eq(&keys, &expected_keys)); + assert!(std::sync::Arc::ptr_eq(&prefixes, &expected_prefixes)); + } + + #[test] + fn receiver_unions_require_one_resource_family() { + let cache = LaravelStringKind::ConfigResource(LaravelConfigResource::CacheStore); + let database = LaravelStringKind::ConfigResource(LaravelConfigResource::DatabaseConnection); + + assert_eq!(unanimous_resource_kind([]), None); + assert_eq!(unanimous_resource_kind([Some(cache)]), Some(cache)); + assert_eq!( + unanimous_resource_kind([Some(cache), Some(cache)]), + Some(cache) + ); + assert_eq!(unanimous_resource_kind([Some(cache), None]), None); + assert_eq!(unanimous_resource_kind([Some(cache), Some(database)]), None); } /// Concurrent first callers must share one build, not run one each: @@ -1341,6 +1845,7 @@ final class UserPermissionController extends BaseController\n\ let backend = crate::Backend::new_test(); let builds = AtomicUsize::new(0); let build_lock = parking_lot::Mutex::new(()); + let start = std::sync::Barrier::new(17); let results: Vec<_> = std::thread::scope(|scope| { let handles: Vec<_> = (0..16) @@ -1348,22 +1853,22 @@ final class UserPermissionController extends BaseController\n\ let backend = &backend; let builds = &builds; let build_lock = &build_lock; + let start = &start; scope.spawn(move || { + start.wait(); backend.cached_laravel_enumeration( build_lock, |cache| cache.view_names.clone(), |cache, names| cache.view_names = Some(names), || { builds.fetch_add(1, Ordering::SeqCst); - // Long enough that an unguarded - // check-then-fill has every thread miss. - std::thread::sleep(std::time::Duration::from_millis(50)); vec!["home".to_string()] }, ) }) }) .collect(); + start.wait(); handles.into_iter().map(|h| h.join().unwrap()).collect() }); diff --git a/src/completion/laravel_string_keys/context.rs b/src/completion/laravel_string_keys/context.rs new file mode 100644 index 000000000..82a380305 --- /dev/null +++ b/src/completion/laravel_string_keys/context.rs @@ -0,0 +1,768 @@ +//! Syntactic detection for Laravel string-key completion contexts. + +use tower_lsp::lsp_types::Position; + +use crate::symbol_map::{LaravelConfigResource, LaravelResourceReceiverRule, LaravelStringKind}; +use crate::text_position::position_to_offset; + +// ─── Context ──────────────────────────────────────────────────────────────── + +/// A recognized Laravel string-key expression and the fragment to complete. +pub(super) struct LaravelStringKeyContext<'a> { + /// The resource family addressed by the expression. + pub(super) kind: LaravelStringKind, + /// The fragment between the opening quote and cursor. + pub(super) prefix: &'a str, + /// Byte offset of the string content start (right after the opening quote). + pub(super) content_start_offset: usize, + /// A resource call/property whose Laravel meaning requires type + /// confirmation. `kind` is replaced with the confirmed family before + /// candidates are enumerated. + pub(super) receiver_rule: Option, + /// Textual receiver of a type-dependent method call. Empty for a + /// `$connection` property, whose enclosing class is the subject. + pub(super) receiver_subject: Option, +} + +#[inline] +fn is_unescaped(bytes: &[u8], index: usize) -> bool { + let mut before = index; + while before > 0 && bytes[before - 1] == b'\\' { + before -= 1; + } + (index - before).is_multiple_of(2) +} + +/// Find the callable text before an array literal that is itself the first +/// argument of a call. `before_quote` ends immediately before the current +/// string literal, somewhere inside that array. +fn callable_before_array_argument(before_quote: &str) -> Option<(&str, Option<&str>)> { + let bytes = before_quote.as_bytes(); + let mut bracket_depth = 0usize; + let mut paren_depth = 0usize; + let mut brace_depth = 0usize; + let mut string_quote = None; + let mut i = bytes.len(); + + while i > 0 { + i -= 1; + let byte = bytes[i]; + + if let Some(quote) = string_quote { + if byte == quote && is_unescaped(bytes, i) { + string_quote = None; + } + continue; + } + + match byte { + b'\'' | b'"' => string_quote = Some(byte), + b']' if paren_depth == 0 && brace_depth == 0 => bracket_depth += 1, + b'[' if paren_depth == 0 && brace_depth == 0 && bracket_depth == 0 => { + let before_array = before_quote[..i].trim_end(); + return callable_before_scalar_argument(before_array); + } + b'[' if paren_depth == 0 && brace_depth == 0 => bracket_depth -= 1, + b')' => paren_depth += 1, + b'(' if paren_depth > 0 => paren_depth -= 1, + b'(' if bracket_depth == 0 && brace_depth == 0 => { + let before_open = before_quote[..i].trim_end(); + let mut token_start = before_open.len(); + let token_bytes = before_open.as_bytes(); + while token_start > 0 + && (token_bytes[token_start - 1].is_ascii_alphanumeric() + || token_bytes[token_start - 1] == b'_') + { + token_start -= 1; + } + if before_open[token_start..].eq_ignore_ascii_case("array") { + return callable_before_scalar_argument(before_open[..token_start].trim_end()); + } + return None; + } + b'}' => brace_depth += 1, + b'{' if brace_depth > 0 => brace_depth -= 1, + b'{' if bracket_depth == 0 && paren_depth == 0 => return None, + b';' if bracket_depth == 0 && paren_depth == 0 && brace_depth == 0 => return None, + _ => {} + } + } + + None +} + +/// Whether the current literal is the key side of an associative array +/// element. Resource-array triggers (for example `Log::stack`) name values, +/// never their bookkeeping keys. +pub(super) fn string_literal_is_array_key(content: &str, cursor: usize, quote: u8) -> bool { + let bytes = content.as_bytes(); + let mut index = cursor; + while index < bytes.len() { + if bytes[index] == quote && is_unescaped(bytes, index) { + return content[index + 1..].trim_start().starts_with("=>"); + } + if bytes[index] == b'\n' { + return false; + } + index += 1; + } + false +} + +/// Callable text before a scalar first argument, including PHP's named-arg +/// spelling (`method(name: '…')`). +pub(super) fn callable_before_scalar_argument(before_value: &str) -> Option<(&str, Option<&str>)> { + let before_value = before_value.trim_end(); + if let Some(callable) = before_value.strip_suffix('(') { + return Some((callable.trim_end(), None)); + } + + let colon = before_value.rfind(':')?; + let label = before_value[colon + 1..].trim(); + if !label.is_empty() { + return None; + } + let before_label = before_value[..colon].trim_end(); + let label_start = before_label + .rfind(|ch: char| !(ch.is_ascii_alphanumeric() || ch == '_')) + .map_or(0, |index| index + 1); + if label_start == before_label.len() { + return None; + } + let argument = &before_label[label_start..]; + let before_argument = before_label[..label_start].trim_end(); + let open_paren = enclosing_call_open_paren(before_argument)?; + Some((before_argument[..open_paren].trim_end(), Some(argument))) +} + +/// Find the unmatched call parenthesis immediately enclosing a named +/// argument. This accepts reordered arguments while ignoring delimiters in +/// earlier nested expressions and strings. +pub(super) fn enclosing_call_open_paren(content: &str) -> Option { + let bytes = content.as_bytes(); + let mut parens = 0usize; + let mut brackets = 0usize; + let mut braces = 0usize; + let mut quote = None; + let mut index = bytes.len(); + while index > 0 { + index -= 1; + let byte = bytes[index]; + if let Some(active_quote) = quote { + if byte == active_quote && is_unescaped(bytes, index) { + quote = None; + } + continue; + } + match byte { + b'\'' | b'"' => quote = Some(byte), + b')' => parens += 1, + b'(' if parens > 0 => parens -= 1, + b'(' if brackets == 0 && braces == 0 => return Some(index), + b']' => brackets += 1, + b'[' if brackets > 0 => brackets -= 1, + b'}' => braces += 1, + b'{' if braces > 0 => braces -= 1, + b';' if parens == 0 && brackets == 0 && braces == 0 => return None, + _ => {} + } + } + None +} + +/// Whether a literal is initializing an instance `$connection` property. +pub(super) fn is_connection_property_value(before_value: &str) -> bool { + let Some(before_equals) = before_value.strip_suffix('=') else { + return false; + }; + let before_equals = before_equals.trim_end(); + // A promoted parameter owns only the text since the enclosing `(` or + // preceding comma. Looking at the whole method declaration would mistake + // the method's own visibility for parameter promotion. + let statement_start = before_equals + .rfind([';', '{', '}', '(', ')', ',']) + .map_or(0, |index| index + 1); + let declaration = before_equals[statement_start..].trim_start(); + let Some(variable) = declaration.split_whitespace().next_back() else { + return false; + }; + if !variable.eq_ignore_ascii_case("$connection") { + return false; + } + if declaration + .split_whitespace() + .any(|token| token.eq_ignore_ascii_case("static")) + { + return false; + } + declaration.split_whitespace().any(|token| { + token.eq_ignore_ascii_case("public") + || token.eq_ignore_ascii_case("protected") + || token.eq_ignore_ascii_case("private") + || token.eq_ignore_ascii_case("var") + }) +} + +#[inline] +fn matches_ignore_ascii_case(value: &str, candidates: &[&str]) -> bool { + candidates + .iter() + .any(|candidate| value.eq_ignore_ascii_case(candidate)) +} + +#[inline] +fn ends_with_ignore_ascii_case(value: &str, suffix: &str) -> bool { + let value = value.as_bytes(); + let suffix = suffix.as_bytes(); + value.len() >= suffix.len() && value[value.len() - suffix.len()..].eq_ignore_ascii_case(suffix) +} + +/// Interpret the payload of Laravel's parameterised middleware aliases. +/// Returns the kind, the fragment being replaced, and that fragment's byte +/// offset from the opening quote. +fn middleware_completion_context(prefix: &str) -> Option<(LaravelStringKind, &str, usize)> { + let colon = prefix.find(':')?; + let alias = &prefix[..=colon]; + let payload = &prefix[colon + 1..]; + + if let Some(resource) = crate::symbol_map::laravel_resources::middleware_resource(alias) { + let raw_current = payload + .rsplit_once(',') + .map_or(payload, |(_, current)| current); + let current = raw_current.trim_start(); + let start = prefix.len().saturating_sub(raw_current.len()); + return Some((LaravelStringKind::ConfigResource(resource), current, start)); + } + + if alias.eq_ignore_ascii_case("throttle:") { + // Everything after a comma is a decay/attempt parameter. A single + // numeric token is ambiguous because Laravel checks registered names + // before treating it as an inline limit; candidate filtering settles + // it without offering unrelated limiter names. + let current = payload.trim_start(); + if current.contains(',') { + return None; + } + let start = prefix.len().saturating_sub(payload.len()); + return Some((LaravelStringKind::RateLimiter, current, start)); + } + + if alias.eq_ignore_ascii_case("can:") && !payload.contains(',') { + let current = payload.trim_start(); + let start = prefix.len().saturating_sub(payload.len()); + return Some((LaravelStringKind::GateAbility, current, start)); + } + + None +} + +fn instance_receiver_subject(content: &str, access_end: usize) -> Option { + let position = crate::text_position::offset_to_position(content, access_end); + crate::completion::target::extract_completion_target(content, position) + .map(|target| target.subject) +} + +/// Whether `callable` is the class token of a `new` expression for one of +/// Laravel's queue rate-limiter middleware classes. +pub(super) fn rate_limited_constructor_class(callable: &str) -> Option<(usize, &str)> { + let bytes = callable.as_bytes(); + let mut class_start = bytes.len(); + while class_start > 0 + && (bytes[class_start - 1].is_ascii_alphanumeric() + || matches!(bytes[class_start - 1], b'_' | b'\\')) + { + class_start -= 1; + } + let class = callable[class_start..].trim_start_matches('\\'); + let before_class = callable[..class_start].trim_end(); + let before_new = before_class.strip_suffix("new")?; + if before_new + .chars() + .next_back() + .is_some_and(|ch| !ch.is_whitespace() && !matches!(ch, ';' | '{' | '}' | '(' | '=' | ',')) + { + return None; + } + Some((class_start, class)) +} + +fn is_rate_limited_class(class: &str) -> bool { + matches!( + class.trim_start_matches('\\'), + "RateLimited" + | "RateLimitedWithRedis" + | "Illuminate\\Queue\\Middleware\\RateLimited" + | "Illuminate\\Queue\\Middleware\\RateLimitedWithRedis" + ) +} + +#[inline] +fn semantic_class_name<'a>( + written: &'a str, + offset: usize, + resolved_names: Option<&'a crate::names::OwnedResolvedNames>, +) -> &'a str { + resolved_names + .and_then(|names| names.get(offset as u32)) + .unwrap_or(written) +} + +/// Whether a semantic class name identifies the requested Laravel facade. +#[inline] +pub(super) fn is_laravel_facade(class: &str, facade: &str) -> bool { + let class = class.trim_start_matches('\\'); + class.rsplit_once('\\').map_or_else( + || class.eq_ignore_ascii_case(facade), + |(namespace, short)| { + namespace.eq_ignore_ascii_case("Illuminate\\Support\\Facades") + && short.eq_ignore_ascii_case(facade) + }, + ) +} + +/// Whether the current method chain contains a static call rooted at one +/// Laravel facade. Production completion resolves each written class token, +/// so imported aliases work while namespace-local homonyms stay ordinary. +pub(super) fn chain_contains_laravel_facade( + chain: &str, + chain_offset: usize, + resolved_names: Option<&crate::names::OwnedResolvedNames>, + facade: &str, +) -> bool { + let bytes = chain.as_bytes(); + let mut search_start = 0usize; + while let Some(relative) = chain[search_start..].find("::") { + let colons = search_start + relative; + let mut class_end = colons; + while class_end > 0 && bytes[class_end - 1].is_ascii_whitespace() { + class_end -= 1; + } + let mut class_start = class_end; + while class_start > 0 + && (bytes[class_start - 1].is_ascii_alphanumeric() + || matches!(bytes[class_start - 1], b'_' | b'\\')) + { + class_start -= 1; + } + if class_start < class_end { + let written = &chain[class_start..class_end]; + let semantic = semantic_class_name(written, chain_offset + class_start, resolved_names); + if is_laravel_facade(semantic, facade) { + return true; + } + } + search_start = colons + 2; + } + false +} + +#[inline] +fn is_laravel_container_attribute(class: &str) -> bool { + let class = class.trim_start_matches('\\'); + class.rsplit_once('\\').is_some_and(|(namespace, _)| { + namespace.eq_ignore_ascii_case("Illuminate\\Container\\Attributes") + }) +} + +// ─── 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. +#[cfg(test)] +pub(super) fn detect_laravel_string_key_context( + content: &str, + position: Position, +) -> Option> { + detect_laravel_string_key_context_inner(content, position, None) +} + +/// Detect a Laravel string-key context using resolved names when available. +pub(super) fn detect_laravel_string_key_context_inner<'a>( + content: &'a str, + position: Position, + resolved_names: Option<&'a crate::names::OwnedResolvedNames>, +) -> Option> { + let cursor_offset = position_to_offset(content, position) as usize; + let bytes = content.as_bytes(); + + if cursor_offset == 0 { + 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'"') && is_unescaped(bytes, i) { + quote_pos = Some(i); + break; + } + if ch == b'\n' { + return None; + } + } + let quote_pos = quote_pos?; + let prefix = &content[quote_pos + 1..cursor_offset]; + + // ── Locate the call whose first argument owns this string ─────── + let before_quote = content[..quote_pos].trim_end(); + if is_connection_property_value(before_quote) { + return Some(LaravelStringKeyContext { + kind: LaravelStringKind::ConfigResource(LaravelConfigResource::DatabaseConnection), + prefix, + content_start_offset: quote_pos + 1, + receiver_rule: Some(LaravelResourceReceiverRule::ConnectionProperty), + receiver_subject: None, + }); + } + let (before_paren, named_argument, in_array_argument) = + if let Some((before_paren, argument)) = callable_before_array_argument(before_quote) { + (before_paren, argument, true) + } else { + let (before_paren, argument) = callable_before_scalar_argument(before_quote)?; + (before_paren, argument, false) + }; + if in_array_argument && string_literal_is_array_key(content, cursor_offset, bytes[quote_pos]) { + return None; + } + + // ── 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')]. + // Check only the callable token after the nearest `#[`: every byte there + // must be part of one class name. An unrelated attribute farther up the + // file therefore cannot turn an ordinary call into an attribute context. + let is_attribute = before_paren.rfind("#[").is_some_and(|start| { + let name = before_paren[start + 2..].trim_start_matches('\\'); + !name.is_empty() + && name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'\\') + }); + + let mut completion_prefix = prefix; + let mut completion_start = quote_pos + 1; + let mut receiver_rule = None; + let mut receiver_subject = None; + + let kind = if is_attribute { + const ATTR_NS: &str = "Illuminate\\Container\\Attributes\\"; + let start = before_paren.rfind("#[")? + 2; + let attr_class = before_paren[start..].trim_start_matches('\\'); + let semantic_class = semantic_class_name(attr_class, start, resolved_names); + let short = semantic_class.rsplit('\\').next().unwrap_or(semantic_class); + let is_laravel_attribute = if resolved_names.is_some() { + is_laravel_container_attribute(semantic_class) + } else if attr_class.contains('\\') { + attr_class + .strip_prefix(ATTR_NS) + .is_some_and(|rest| rest == short) + } else { + content.contains("use Illuminate\\Container\\Attributes\\") + }; + if !is_laravel_attribute || in_array_argument { + return None; + } + if short.eq_ignore_ascii_case("Config") { + if named_argument.is_some_and(|argument| !argument.eq_ignore_ascii_case("key")) { + return None; + } + Some(LaravelStringKind::Config) + } else { + crate::symbol_map::laravel_resources::attribute_trigger(short) + .filter(|trigger| { + named_argument + .is_none_or(|argument| argument.eq_ignore_ascii_case(trigger.argument)) + }) + .map(|trigger| LaravelStringKind::ConfigResource(trigger.kind)) + } + } 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 semantic_class = semantic_class_name(class_name, cls_start, resolved_names); + let short = semantic_class.rsplit('\\').next().unwrap_or(semantic_class); + if let Some(trigger) = + crate::symbol_map::laravel_resources::static_method_trigger(semantic_class, func_name) + { + if named_argument + .is_some_and(|argument| !argument.eq_ignore_ascii_case(trigger.argument)) + || (in_array_argument && !trigger.shape.accepts_array()) + || (!in_array_argument && !trigger.shape.accepts_scalar()) + { + return None; + } + Some(LaravelStringKind::ConfigResource(trigger.kind)) + } else { + if is_laravel_facade(semantic_class, "Route") + && func_name.eq_ignore_ascii_case("middleware") + && named_argument.is_none_or(|argument| argument.eq_ignore_ascii_case("middleware")) + { + let (middleware_kind, middleware_prefix, relative_start) = + middleware_completion_context(completion_prefix)?; + completion_prefix = middleware_prefix; + completion_start += relative_start; + return Some(LaravelStringKeyContext { + kind: middleware_kind, + prefix: completion_prefix, + content_start_offset: completion_start, + receiver_rule: None, + receiver_subject: None, + }); + } + if in_array_argument { + return None; + } + if is_laravel_facade(semantic_class, "Config") + && matches_ignore_ascii_case( + func_name, + &[ + "get", + "set", + "has", + "boolean", + "array", + "collection", + "prepend", + "push", + ], + ) + { + Some(LaravelStringKind::Config) + } else if short.eq_ignore_ascii_case("View") + && matches_ignore_ascii_case(func_name, &["make", "exists"]) + { + Some(LaravelStringKind::View) + } else if short.eq_ignore_ascii_case("Lang") + && matches_ignore_ascii_case(func_name, &["get", "has", "choice"]) + { + Some(LaravelStringKind::Trans) + } else if (short.eq_ignore_ascii_case("Artisan") + && matches_ignore_ascii_case(func_name, &["call", "queue"])) + || (short.eq_ignore_ascii_case("Schedule") + && func_name.eq_ignore_ascii_case("command")) + { + Some(LaravelStringKind::Command) + } else if (short.eq_ignore_ascii_case("Relation") + && func_name.eq_ignore_ascii_case("getMorphedModel")) + || (short.eq_ignore_ascii_case("Model") + && func_name.eq_ignore_ascii_case("getActualClassNameForMorph")) + { + Some(LaravelStringKind::MorphAlias) + } else if short.eq_ignore_ascii_case("Gate") + && matches_ignore_ascii_case( + func_name, + &[ + "allows", + "denies", + "check", + "any", + "none", + "authorize", + "inspect", + "has", + "define", + ], + ) + { + Some(LaravelStringKind::GateAbility) + } else if is_laravel_facade(semantic_class, "RateLimiter") + && func_name.eq_ignore_ascii_case("for") + && named_argument.is_none_or(|argument| argument.eq_ignore_ascii_case("name")) + { + Some(LaravelStringKind::RateLimiter) + } else { + None + } + } + } else if is_instance_method { + let is_middleware = func_name.eq_ignore_ascii_case("middleware"); + let is_call = matches_ignore_ascii_case(func_name, &["call", "callSilently"]); + let is_authorize = func_name.eq_ignore_ascii_case("authorize"); + let is_can = matches_ignore_ascii_case(func_name, &["can", "cannot", "canAny"]); + let is_gate_check = matches_ignore_ascii_case( + func_name, + &["allows", "denies", "check", "any", "none", "inspect", "has"], + ); + let receiver = trimmed_before + .trim_end_matches("?->") + .trim_end_matches("->") + .trim_end(); + // These generic method names acquire Laravel meaning on `$this` only + // in their controller/command contexts. + let receiver_is_this = + (is_middleware || is_call || is_authorize) && receiver.ends_with("$this"); + // Whether the receiver plainly reads as the authenticated user, + // which is what makes `->can('…')` an authorization check rather + // than a same-named method on an unrelated object. Mirrors the + // symbol-map rule that decides which `can()` calls get a span. + let receiver_is_user_like = is_can && { + let tail = receiver + .rsplit(|c: char| !(c.is_ascii_alphanumeric() || c == '_')) + .next() + .unwrap_or(""); + ends_with_ignore_ascii_case(tail, "user") + || ends_with_ignore_ascii_case(receiver, "user()") + }; + // A chain that starts at the `Gate` facade + // (`Gate::forUser($user)->allows('…')`) or at a route registration + // (`Route::get(…)->can('…')`) is an authorization check whatever the + // rest of the chain looks like. Only the text back to the start of + // the statement is searched — `trimmed_before` is the whole file + // prefix, and an unrelated `Gate::` far above would false-positive. + let needs_gate_chain = is_authorize || is_can || is_gate_check; + let needs_route_chain = is_middleware || is_can; + let (chain_starts_at_gate, chain_starts_at_route) = if needs_gate_chain || needs_route_chain + { + let chain_start = trimmed_before + .rfind(['\n', ';', '{', '}']) + .map_or(0, |idx| idx + 1); + let chain_text = &trimmed_before[chain_start..]; + ( + needs_gate_chain + && chain_contains_laravel_facade( + chain_text, + chain_start, + resolved_names, + "Gate", + ), + needs_route_chain + && chain_contains_laravel_facade( + chain_text, + chain_start, + resolved_names, + "Route", + ), + ) + } else { + (false, false) + }; + if is_middleware && (receiver_is_this || chain_starts_at_route) { + let (middleware_kind, middleware_prefix, relative_start) = + middleware_completion_context(completion_prefix)?; + completion_prefix = middleware_prefix; + completion_start += relative_start; + return Some(LaravelStringKeyContext { + kind: middleware_kind, + prefix: completion_prefix, + content_start_offset: completion_start, + receiver_rule: None, + receiver_subject: None, + }); + } + if in_array_argument { + return None; + } + + if func_name.eq_ignore_ascii_case("connection") + && named_argument.is_none_or(|argument| { + argument.eq_ignore_ascii_case("name") || argument.eq_ignore_ascii_case("connection") + }) + { + receiver_rule = Some(LaravelResourceReceiverRule::ConnectionMethod); + receiver_subject = instance_receiver_subject(content, trimmed_before.len()); + Some(LaravelStringKind::ConfigResource( + LaravelConfigResource::DatabaseConnection, + )) + } else if let Some(trigger) = crate::symbol_map::laravel_resources::instance_method_trigger( + func_name, + ) + .filter(|trigger| { + named_argument.is_none_or(|argument| argument.eq_ignore_ascii_case(trigger.argument)) + }) { + receiver_rule = Some(LaravelResourceReceiverRule::QueueableConnection); + receiver_subject = instance_receiver_subject(content, trimmed_before.len()); + Some(LaravelStringKind::ConfigResource(trigger.kind)) + } else if func_name.eq_ignore_ascii_case("onQueue") + && named_argument.is_none_or(|argument| argument.eq_ignore_ascii_case("queue")) + { + receiver_rule = Some(LaravelResourceReceiverRule::QueueName); + receiver_subject = instance_receiver_subject(content, trimmed_before.len()); + Some(LaravelStringKind::QueueName) + } else if func_name.eq_ignore_ascii_case("route") { + Some(LaravelStringKind::Route) + } else if is_call && receiver_is_this { + Some(LaravelStringKind::Command) + } else if (is_authorize && (receiver_is_this || chain_starts_at_gate)) + || (is_can && (receiver_is_user_like || chain_starts_at_route || chain_starts_at_gate)) + || (is_gate_check && chain_starts_at_gate) + { + Some(LaravelStringKind::GateAbility) + } else { + None + } + } else { + if in_array_argument { + return None; + } + if rate_limited_constructor_class(before_paren).is_some_and(|(offset, class)| { + is_rate_limited_class(semantic_class_name(class, offset, resolved_names)) + }) && named_argument.is_none_or(|argument| argument.eq_ignore_ascii_case("limiterName")) + { + Some(LaravelStringKind::RateLimiter) + } else if let Some(trigger) = + crate::symbol_map::laravel_resources::function_trigger(func_name) + { + (trigger.shape.accepts_scalar() + && named_argument + .is_none_or(|argument| argument.eq_ignore_ascii_case(trigger.argument))) + .then_some(LaravelStringKind::ConfigResource(trigger.kind)) + } else if matches_ignore_ascii_case(func_name, &["route", "to_route"]) { + Some(LaravelStringKind::Route) + } else if func_name.eq_ignore_ascii_case("config") { + Some(LaravelStringKind::Config) + } else if matches_ignore_ascii_case( + func_name, + &["view", "blade_view_directive", "blade_each_directive"], + ) { + Some(LaravelStringKind::View) + } else if matches_ignore_ascii_case(func_name, &["__", "trans", "trans_choice"]) { + Some(LaravelStringKind::Trans) + } else if func_name.eq_ignore_ascii_case("blade_can_directive") { + Some(LaravelStringKind::GateAbility) + } else { + None + } + }; + + let kind = kind?; + + Some(LaravelStringKeyContext { + kind, + prefix: completion_prefix, + content_start_offset: completion_start, + receiver_rule, + receiver_subject, + }) +} diff --git a/src/definition/resolve.rs b/src/definition/resolve.rs index d3f1e5652..4f1388bae 100644 --- a/src/definition/resolve.rs +++ b/src/definition/resolve.rs @@ -98,13 +98,17 @@ impl Backend { if let Some(span) = map.lookup(offset) { return Some(span.clone()); } - // A view name behind a typed receiver is a gap in the map — the - // indexer could not tell it was one — so the cursor lands in what - // reads as a plain string literal until the receiver is typed. + // A Laravel string behind a typed receiver (or a connection property + // classified by its enclosing class) is a gap in the direct map until + // the shared type pass confirms it. if !map .view_receiver_sites .iter() .any(|site| offset >= site.start && offset < site.end) + && !map + .resource_receiver_sites + .iter() + .any(|site| offset >= site.start && offset < site.end) { return None; } diff --git a/src/diagnostics/mod.rs b/src/diagnostics/mod.rs index cc4157290..98987cbcd 100644 --- a/src/diagnostics/mod.rs +++ b/src/diagnostics/mod.rs @@ -274,11 +274,50 @@ pub(crate) type SlowDiagnosticObserver<'a> = enum CheckedStringKind { Route, Config, + ConfigResource(crate::symbol_map::LaravelConfigResource), View, Trans, Command, MorphAlias, GateAbility, + RateLimiter, +} + +fn sorted_config_keys_contain(keys: &[String], key: &str) -> bool { + keys.binary_search_by(|candidate| candidate.as_str().cmp(key)) + .is_ok() +} + +fn sorted_config_keys_have_prefix(keys: &[String], prefix: &str) -> bool { + let index = keys.partition_point(|candidate| candidate.as_str() < prefix); + keys.get(index) + .is_some_and(|candidate| candidate.starts_with(prefix)) +} + +fn sorted_config_keys_contain_key_or_child(keys: &[String], key: &str) -> bool { + let index = keys.partition_point(|candidate| candidate.as_str() < key); + keys.get(index).is_some_and(|candidate| { + candidate == key + || candidate + .strip_prefix(key) + .is_some_and(|suffix| suffix.starts_with('.')) + }) +} + +fn config_key_is_in_open_subtree(open_prefixes: &[String], key: &str) -> bool { + let mut prefix = key; + loop { + if open_prefixes + .binary_search_by(|candidate| candidate.as_str().cmp(prefix)) + .is_ok() + { + return true; + } + let Some(separator) = prefix.rfind('.') else { + return false; + }; + prefix = &prefix[..separator]; + } } // ── Shared helpers ────────────────────────────────────────────────────────── @@ -622,77 +661,89 @@ impl Backend { let mut has_command = false; let mut has_morph_alias = false; let mut has_gate_ability = false; - let key_spans: Vec<(CheckedStringKind, String, u32, u32)> = { - let Some(symbol_map) = self.symbol_maps.read().get(uri).cloned() else { - return; - }; - let extra = self.typed_receiver_view_spans_for(uri, &symbol_map); - symbol_map - .spans - .iter() - .chain(extra.iter()) - .filter_map(|span| { - if let SymbolKind::LaravelStringKey { - kind, - key, - is_write, - is_optional, - } = &span.kind - { - // A write declares the key it names, so there is - // nothing to check it against, and an optional key - // is one the call is written to do without: an - // `@includeFirst` candidate that names nothing is - // why the directive takes a list at all. - if *is_write || *is_optional { - return None; - } - let checked = match kind { - LaravelStringKind::Route => { - has_route = true; - CheckedStringKind::Route - } - LaravelStringKind::Config => { - has_config = true; - CheckedStringKind::Config - } - LaravelStringKind::View => { - has_view = true; - CheckedStringKind::View - } - LaravelStringKind::Trans => { - has_trans = true; - CheckedStringKind::Trans - } - LaravelStringKind::Command => { - has_command = true; - CheckedStringKind::Command - } - LaravelStringKind::MorphAlias => { - has_morph_alias = true; - CheckedStringKind::MorphAlias - } - LaravelStringKind::GateAbility => { - has_gate_ability = true; - CheckedStringKind::GateAbility - } - // A section or stack name is judged against the - // templates that render the one it is written - // in, which the Blade pass below has and this - // one does not. And anything at all can be bound - // at runtime, so an unrecognised container key - // proves nothing. - LaravelStringKind::Section - | LaravelStringKind::Stack - | LaravelStringKind::ContainerBinding => return None, - }; - Some((checked, key.clone(), span.start, span.end)) - } else { - None - } - }) - .collect() + let mut has_rate_limiter = false; + let Some(symbol_map) = self.symbol_maps.read().get(uri).cloned() else { + return; }; + // Queue names deliberately have no closed-set diagnostic. Avoid the + // shared type-resolution pass when they are the only typed receiver + // candidates in this file. + let needs_typed_diagnostic_spans = !symbol_map.view_receiver_sites.is_empty() + || symbol_map + .resource_receiver_sites + .iter() + .any(|site| site.rule != crate::symbol_map::LaravelResourceReceiverRule::QueueName); + let extra = needs_typed_diagnostic_spans + .then(|| self.typed_receiver_view_spans_for(uri, &symbol_map)); + // The map and optional typed-span Arc outlive this list, so borrow key + // text rather than allocating a String for every diagnostic candidate. + let mut key_spans: Vec<(CheckedStringKind, &str, u32, u32)> = Vec::new(); + for span in symbol_map + .spans + .iter() + .chain(extra.iter().flat_map(|spans| spans.iter())) + { + let SymbolKind::LaravelStringKey { + kind, + key, + is_write, + is_optional, + } = &span.kind + else { + continue; + }; + // A write declares the key it names, so there is nothing to check + // it against. An optional key is one the call is written to do + // without, such as an `@includeFirst` candidate. + if *is_write || *is_optional { + continue; + } + let checked = match kind { + LaravelStringKind::Route => { + has_route = true; + CheckedStringKind::Route + } + LaravelStringKind::Config => { + has_config = true; + CheckedStringKind::Config + } + LaravelStringKind::ConfigResource(resource) => { + has_config = true; + CheckedStringKind::ConfigResource(*resource) + } + LaravelStringKind::View => { + has_view = true; + CheckedStringKind::View + } + LaravelStringKind::Trans => { + has_trans = true; + CheckedStringKind::Trans + } + LaravelStringKind::Command => { + has_command = true; + CheckedStringKind::Command + } + LaravelStringKind::MorphAlias => { + has_morph_alias = true; + CheckedStringKind::MorphAlias + } + LaravelStringKind::GateAbility => { + has_gate_ability = true; + CheckedStringKind::GateAbility + } + LaravelStringKind::RateLimiter => { + has_rate_limiter = true; + CheckedStringKind::RateLimiter + } + // A section or stack name is judged against its render tree, + // and runtime container/queue names are open-ended. + LaravelStringKind::Section + | LaravelStringKind::Stack + | LaravelStringKind::ContainerBinding + | LaravelStringKind::QueueName => continue, + }; + key_spans.push((checked, key, span.start, span.end)); + } if !has_route && !has_config @@ -701,6 +752,7 @@ impl Backend { && !has_command && !has_morph_alias && !has_gate_ability + && !has_rate_limiter { return; } @@ -716,19 +768,14 @@ impl Backend { } else { HashSet::new() }; - let config_keys: HashSet = if has_config { - self.cached_config_keys().into_iter().collect() + let (config_keys, config_open_prefixes) = if has_config { + self.cached_config_metadata() } else { - HashSet::new() + ( + std::sync::Arc::new(Vec::new()), + std::sync::Arc::new(Vec::new()), + ) }; - // The config files we managed to enumerate keys from, by name. A - // key whose root segment names none of them lives in a file we - // cannot see (a library whose config is supplied by the host - // application), so nothing about it is knowable. - let config_roots: HashSet<&str> = config_keys - .iter() - .map(|key| key.split('.').next().unwrap_or(key.as_str())) - .collect(); let view_keys: HashSet = if has_view { self.cached_view_names().into_iter().collect() } else { @@ -790,8 +837,14 @@ impl Backend { } else { HashSet::new() }; + // One read guard serves all config-resource and limiter checks in this + // file. This avoids cloning the limiter set and avoids a lock/unlock + // pair for every string occurrence. + let source_strings = + (has_config || has_rate_limiter).then(|| self.laravel_source_strings.read()); for (kind, key, start, end) in &key_spans { + let key = *key; let (valid, label, code) = match kind { // An ability is judged against the model the check names, so // it reports which model rather than the shared @@ -834,17 +887,47 @@ impl Backend { // An unknown root means the file never reached us, so // the key cannot be wrong as far as we can tell, while // a typo inside a file we did read is still caught. - if !config_roots.contains(key.split('.').next().unwrap_or(key.as_str())) { + let root = key.split('.').next().unwrap_or(key); + if !sorted_config_keys_contain_key_or_child(&config_keys, root) { + continue; + } + if config_key_is_in_open_subtree(&config_open_prefixes, key) { continue; } // 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))); + let runtime_resource_exists = + crate::symbol_map::laravel_resources::resource_from_config_key(key) + .is_some_and(|(resource, name)| { + source_strings.as_ref().is_some_and(|index| { + index.has_runtime_config_resource(resource, name) + }) + }); + let valid = runtime_resource_exists + || sorted_config_keys_contain_key_or_child(&config_keys, key); (valid, "config key", "invalid_laravel_config") } + CheckedStringKind::ConfigResource(resource) => { + let descriptor = crate::symbol_map::laravel_resources::descriptor(*resource); + // A missing subtree means discovery found no declaration + // source for this family, not that every runtime-provided + // name is invalid. + if !sorted_config_keys_have_prefix(&config_keys, descriptor.config_prefix) { + continue; + } + let full_key = crate::symbol_map::laravel_resources::config_key(*resource, key); + if config_key_is_in_open_subtree(&config_open_prefixes, &full_key) { + continue; + } + ( + sorted_config_keys_contain(&config_keys, &full_key) + || source_strings.as_ref().is_some_and(|index| { + index.has_runtime_config_resource(*resource, key) + }), + descriptor.label, + descriptor.diagnostic_code, + ) + } CheckedStringKind::View => { (view_keys.contains(key), "view", "invalid_laravel_view") } @@ -887,6 +970,19 @@ impl Backend { "invalid_laravel_morph_alias", ) } + CheckedStringKind::RateLimiter => { + let index = source_strings + .as_ref() + .expect("rate limiter spans acquire the source-name index"); + if !index.has_rate_limiters() || index.rate_limiter_space_is_open() { + continue; + } + ( + index.has_rate_limiter(key), + "rate limiter", + "invalid_laravel_rate_limiter", + ) + } }; if !valid && let Some(range) = @@ -1712,6 +1808,22 @@ pub(crate) fn offset_range_to_lsp_range( mod tests { use super::*; + #[test] + fn missing_symbol_map_has_no_laravel_string_diagnostics() { + let backend = Backend::new_test(); + let mut diagnostics = Vec::new(); + + // A file may close after diagnostics are scheduled but before the + // slow Laravel pass reaches it. + backend.collect_invalid_laravel_string_key_diagnostics( + "file:///closed.php", + " { + let descriptor = crate::symbol_map::laravel_resources::descriptor(*resource); + let locations = crate::virtual_members::laravel::resolve_laravel_string_key( + self, kind, key, uri, + ); + let detail = locations + .first() + .and_then(|location| self.workspace_relative_path(location.uri.as_str())) + .map_or_else( + || format!("Laravel {}", descriptor.label), + |path| format!("Defined in `{path}`"), + ); + (descriptor.hover_label, detail) + } LaravelStringKind::View => { let locations = crate::virtual_members::laravel::resolve_laravel_string_key( self, kind, key, uri, @@ -674,6 +688,20 @@ impl Backend { }; ("Container", detail) } + LaravelStringKind::RateLimiter => { + let locations = crate::virtual_members::laravel::resolve_laravel_string_key( + self, kind, key, uri, + ); + let detail = locations + .first() + .and_then(|location| self.workspace_relative_path(location.uri.as_str())) + .map_or_else( + || "Application rate limiter".to_string(), + |path| format!("Registered in `{path}`"), + ); + ("Rate limiter", detail) + } + LaravelStringKind::QueueName => ("Queue", "Application-defined queue name".to_string()), }; Some(make_hover(format!("**{}** `{}`\n\n{}", label, key, detail))) diff --git a/src/indexing/scan.rs b/src/indexing/scan.rs index d0a2dd75c..6bc33c824 100644 --- a/src/indexing/scan.rs +++ b/src/indexing/scan.rs @@ -41,10 +41,22 @@ impl Backend { /// Register a vendor directory path and its URI prefix for /// vendor-file detection. pub(crate) fn add_vendor_dir(&self, vendor_path: &std::path::Path) { - // Store the absolute path for filesystem-level skip logic. + // Keep both filesystem spellings. Walkers normally yield the raw + // workspace spelling, while Composer package discovery canonicalizes + // its files; on macOS those can be `/var` and `/private/var` for the + // same vendor tree. Caching both here keeps the hot lookup paths free + // of filesystem calls. { let mut paths = self.workspace.vendor_dir_paths.lock(); - paths.push(vendor_path.to_path_buf()); + let mut insert = |path: PathBuf| { + if !paths.contains(&path) { + paths.push(path); + } + }; + insert(vendor_path.to_path_buf()); + if let Ok(canonical) = vendor_path.canonicalize() { + insert(canonical); + } } // Store URI prefixes for URI-level skip logic (diagnostics, find // references, rename). Keep both raw and canonical forms so macOS @@ -81,6 +93,10 @@ impl Backend { *self.workspace.psr4_mappings.write() = mappings; let vendor_path = root.join(&vendor_dir); + // `vendor/` may not have existed during initialization (a fresh + // clone before `composer install`). Register it again now so the + // shared vendor filters cache both raw and canonical spellings. + self.add_vendor_dir(&vendor_path); // Rebuild vendor classmap, tracking dependency provenance so // completion ranking stays accurate after a composer change. @@ -629,3 +645,48 @@ impl Backend { } } } + +#[cfg(all(test, unix))] +mod tests { + use super::*; + + #[test] + fn vendor_registration_caches_raw_and_canonical_path_spellings() { + use std::os::unix::fs::symlink; + + let dir = tempfile::tempdir().expect("tempdir"); + let canonical_vendor = dir.path().join("packages"); + std::fs::create_dir(&canonical_vendor).expect("create package directory"); + let aliased_vendor = dir.path().join("vendor"); + symlink(&canonical_vendor, &aliased_vendor).expect("create vendor alias"); + + let backend = Backend::new_test(); + backend.add_vendor_dir(&aliased_vendor); + backend.add_vendor_dir(&aliased_vendor); + + let paths = backend.workspace.vendor_dir_paths.lock(); + assert_eq!(paths.len(), 2, "repeat registration must stay deduplicated"); + assert!(paths.contains(&aliased_vendor)); + assert!(paths.contains(&canonical_vendor.canonicalize().unwrap())); + } + + #[test] + fn composer_rescan_registers_a_vendor_directory_created_after_startup() { + use std::os::unix::fs::symlink; + + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write(dir.path().join("composer.json"), "{}").expect("write composer.json"); + let canonical_vendor = dir.path().join("packages"); + std::fs::create_dir(&canonical_vendor).expect("create package directory"); + let aliased_vendor = dir.path().join("vendor"); + symlink(&canonical_vendor, &aliased_vendor).expect("create vendor alias"); + + let backend = Backend::new_test(); + assert!(backend.workspace.vendor_dir_paths.lock().is_empty()); + backend.rescan_composer_indexes(dir.path()); + + let paths = backend.workspace.vendor_dir_paths.lock(); + assert!(paths.contains(&aliased_vendor)); + assert!(paths.contains(&canonical_vendor.canonicalize().unwrap())); + } +} diff --git a/src/indexing/watch.rs b/src/indexing/watch.rs index 45fdc3773..68bf2ea91 100644 --- a/src/indexing/watch.rs +++ b/src/indexing/watch.rs @@ -3,12 +3,76 @@ //! Applies a `workspace/didChangeWatchedFiles` batch to the symbol //! indexes on a blocking thread. -use std::path::PathBuf; +use std::borrow::Cow; +use std::path::{Path, PathBuf}; use tower_lsp::lsp_types::*; use crate::Backend; +struct PendingPhpChange { + uri: String, + path: PathBuf, + typ: FileChangeType, + loaded: bool, +} + +struct LaravelPhpRefresh { + change_index: usize, + content: String, +} + +/// Compare a watched path with a path saved by discovery. The raw spelling +/// is the overwhelmingly common fast path; the fallback covers macOS' alias +/// paths and a deleted file whose parent still exists. +fn paths_refer_to_same_file(left: &Path, right: &Path) -> bool { + if left == right { + return true; + } + comparable_path(left) + .is_some_and(|left| comparable_path(right).is_some_and(|right| left == right)) +} + +fn comparable_path(path: &Path) -> Option { + path.canonicalize().ok().or_else(|| { + let parent = path.parent()?.canonicalize().ok()?; + Some(parent.join(path.file_name()?)) + }) +} + +fn relative_path<'a>(path: &'a Path, directory: &Path) -> Option> { + path.strip_prefix(directory) + .map(Cow::Borrowed) + .ok() + .or_else(|| { + let path = comparable_path(path)?; + let directory = comparable_path(directory)?; + path.strip_prefix(directory) + .map(|relative| Cow::Owned(relative.to_path_buf())) + .ok() + }) +} + +fn contains_ascii_case_insensitive(haystack: &[u8], needle: &[u8]) -> bool { + memchr::memmem::find(haystack, needle).is_some() + || haystack + .windows(needle.len()) + .any(|window| window.eq_ignore_ascii_case(needle)) +} + +/// Whether a PHP file can contribute one of the source-defined resource +/// names. Imports keep the original `Config` / `RateLimiter` basename even +/// when the call uses an alias, so these cheap token pairs avoid a full parse +/// for ordinary watched-file noise without losing aliased registrations. +fn has_laravel_source_name_tokens(content: &str) -> bool { + let bytes = content.as_bytes(); + contains_ascii_case_insensitive(bytes, b"onQueue") + || (contains_ascii_case_insensitive(bytes, b"Config") + && contains_ascii_case_insensitive(bytes, b"set")) + || (contains_ascii_case_insensitive(bytes, b"RateLimiter") + && contains_ascii_case_insensitive(bytes, b"for")) +} + impl Backend { /// Apply a `workspace/didChangeWatchedFiles` batch to the indexes. /// @@ -25,11 +89,14 @@ impl Backend { /// and re-scanning every reported file from disk would do thousands of /// wasted syscalls on every refocus. /// - /// So a plain content change is only acted on for files we have actually - /// parsed (whose cached details would otherwise go stale). Created and + /// So a plain content change is normally only acted on for files we have + /// actually parsed (whose cached details would otherwise go stale). + /// Laravel config files are the narrow exception because their workspace + /// index must stay current before any class lookup opens them. Created and /// deleted files are always handled: a creation makes a new class - /// discoverable, and a deletion must purge a now-dangling entry, both of - /// which matter even for files we never loaded. + /// discoverable (and newly-created project files are cheaply probed for + /// source-name registrations), while a deletion must purge a now-dangling + /// entry. Both matter even for files we never loaded. pub(crate) fn apply_watched_file_changes( &self, params: &DidChangeWatchedFilesParams, @@ -38,7 +105,7 @@ impl Backend { let mut composer_changed = false; let mut schema_full_rebuild = false; let mut migration_changes: Vec<(PathBuf, FileChangeType)> = Vec::new(); - let mut php_changes: Vec<(String, PathBuf, FileChangeType)> = Vec::new(); + let mut php_candidates: Vec = Vec::new(); let mut migration_discovery = crate::virtual_members::laravel::database_schema::MigrationDiscovery::default(); let is_laravel = self.resolved_class_cache.read().is_laravel(); @@ -98,19 +165,108 @@ impl Backend { continue; }; - if change.typ == FileChangeType::CHANGED { + let loaded = if change.typ == FileChangeType::CHANGED { // `parsed_uris` records the editor URI for open files and // the canonical `file://` URI for lazily loaded ones; // check both spellings. let canonical_uri = crate::util::path_to_uri(&file_path); - let loaded = - parsed.contains(&uri_str) || parsed.contains(canonical_uri.as_str()); - if !loaded { - continue; - } - } + parsed.contains(&uri_str) || parsed.contains(canonical_uri.as_str()) + } else { + false + }; + + php_candidates.push(PendingPhpChange { + uri: uri_str, + path: file_path, + typ: change.typ, + loaded, + }); + } + } + + let provider_config_paths = if is_laravel && !php_candidates.is_empty() { + self.laravel_provider_resources + .read() + .config_files + .iter() + .map(|resource| resource.path.clone()) + .collect::>() + } else { + Vec::new() + }; + let vendor_paths = if is_laravel && !php_candidates.is_empty() { + self.workspace.vendor_dir_paths.lock().clone() + } else { + Vec::new() + }; + let mut php_changes: Vec<(String, PathBuf, FileChangeType)> = Vec::new(); + let mut laravel_refreshes: Vec = Vec::new(); + let mut config_invalidations: Vec<(usize, bool)> = Vec::new(); - php_changes.push((uri_str, file_path, change.typ)); + for candidate in php_candidates { + let is_provider_config = is_laravel + && provider_config_paths + .iter() + .filter(|path| path.file_name() == candidate.path.file_name()) + .any(|path| paths_refer_to_same_file(&candidate.path, path)); + let relative = relative_path(&candidate.path, root); + let is_conventional_config = relative + .as_deref() + .is_some_and(|relative| relative.starts_with("config")); + let is_in_conventional_vendor = relative + .as_deref() + .is_some_and(|relative| relative.starts_with("vendor")); + let is_config = is_laravel && (is_provider_config || is_conventional_config); + let is_project_source = is_laravel + && relative.is_some() + && !is_in_conventional_vendor + && !vendor_paths + .iter() + .any(|path| relative_path(&candidate.path, path).is_some()); + // A loaded dependency may already contribute source names and + // therefore needs a replacement after the destructive batch + // withdrawal. Newly-created dependency trees stay on the cheap + // discovery-only path unless the exact file is a registered + // config resource. + let should_probe_source = + candidate.loaded || (is_project_source && candidate.typ == FileChangeType::CREATED); + let content = + if candidate.typ != FileChangeType::DELETED && (is_config || should_probe_source) { + std::fs::read_to_string(&candidate.path).ok() + } else { + None + }; + let has_source_names = content + .as_deref() + .is_some_and(has_laravel_source_name_tokens); + + // Preserve the refocus optimisation for untouched, unparsed PHP: + // only config resources and files that can define Laravel names + // bypass the normal loaded-file gate. + if candidate.typ == FileChangeType::CHANGED + && !candidate.loaded + && !is_config + && !has_source_names + { + continue; + } + + let change_index = php_changes.len(); + php_changes.push((candidate.uri, candidate.path, candidate.typ)); + + if candidate.typ == FileChangeType::DELETED || (is_config && content.is_none()) { + if is_config { + config_invalidations.push((change_index, is_provider_config)); + } + continue; + } + if (is_config || has_source_names) + && let Some(content) = content + { + laravel_refreshes.push(LaravelPhpRefresh { + change_index, + content, + }); } } @@ -137,6 +293,28 @@ impl Backend { *self.storage_disk_type_cache.write() = None; *self.laravel_aliases.write() = None; self.member_completion_cache.lock().clear(); + + // `reindex_files_batch` deliberately removes stale per-file + // source names. Rebuild only the files whose lexical gate says + // they can contribute one, plus config resources whose cache + // generation must advance even when their PHP has no such token. + for refresh in laravel_refreshes { + let uri = &php_changes[refresh.change_index].0; + self.update_ast(uri, &refresh.content); + if let Some(map) = self.symbol_map_for(uri) + && map.resource_receiver_sites.iter().any(|site| { + site.rule == crate::symbol_map::LaravelResourceReceiverRule::QueueName + }) + { + self.typed_receiver_view_spans_for(uri, &map); + } + } + if !config_invalidations.is_empty() { + let mut cache = self.laravel_string_key_cache.write(); + for (change_index, is_provider_config) in config_invalidations { + cache.invalidate_for_uri(&php_changes[change_index].0, "", is_provider_config); + } + } } if composer_changed { @@ -163,6 +341,62 @@ impl Backend { mod tests { use super::*; + fn watched_change(path: &Path, typ: FileChangeType) -> DidChangeWatchedFilesParams { + DidChangeWatchedFilesParams { + changes: vec![FileEvent { + uri: Url::from_file_path(path).unwrap(), + typ, + }], + } + } + + #[test] + fn path_comparisons_handle_lexical_aliases_and_deleted_files() { + let dir = tempfile::tempdir().unwrap(); + let detour = dir.path().join("detour"); + std::fs::create_dir(&detour).unwrap(); + let file = dir.path().join("resource.php"); + std::fs::write(&file, " null);" + )); + assert!(!has_laravel_source_name_tokens( + " ['redis' => []]];").unwrap(); + + let backend = Backend::new_test(); + backend.resolved_class_cache.write().set_laravel(true); + { + let mut cache = backend.laravel_string_key_cache.write(); + cache.config_generation = 7; + cache.config_keys = Some(std::sync::Arc::new(vec!["old.key".to_string()])); + } + + assert!(backend.apply_watched_file_changes( + &watched_change(&config, FileChangeType::CHANGED), + dir.path(), + )); + let cache = backend.laravel_string_key_cache.read(); + assert_eq!(cache.config_generation, 8); + assert!(cache.config_keys.is_none()); + } + + #[test] + fn registered_config_file_outside_config_is_refreshed_and_deleted() { + let dir = tempfile::tempdir().unwrap(); + let config = dir.path().join("package/resources/settings.php"); + std::fs::create_dir_all(config.parent().unwrap()).unwrap(); + std::fs::write(&config, " ['tenant' => []]];").unwrap(); + + let backend = Backend::new_test(); + backend.resolved_class_cache.write().set_laravel(true); + backend + .laravel_provider_resources + .write() + .config_files + .push(crate::virtual_members::laravel::ProviderResource { + path: config.clone(), + namespace: "package".to_string(), + }); + { + let mut cache = backend.laravel_string_key_cache.write(); + cache.config_generation = 12; + cache.config_keys = Some(std::sync::Arc::new(vec!["old.key".to_string()])); + } + + assert!(backend.apply_watched_file_changes( + &watched_change(&config, FileChangeType::CHANGED), + dir.path(), + )); + { + let cache = backend.laravel_string_key_cache.read(); + assert_eq!(cache.config_generation, 13); + assert!(cache.config_keys.is_none()); + } + + { + let mut cache = backend.laravel_string_key_cache.write(); + cache.config_keys = Some(std::sync::Arc::new(vec!["package.stores".to_string()])); + } + std::fs::remove_file(&config).unwrap(); + assert!(backend.apply_watched_file_changes( + &watched_change(&config, FileChangeType::DELETED), + dir.path(), + )); + let cache = backend.laravel_string_key_cache.read(); + assert_eq!(cache.config_generation, 14); + assert!(cache.config_keys.is_none()); + } + + #[test] + fn watched_aliased_runtime_registrations_replace_source_definitions() { + let dir = tempfile::tempdir().unwrap(); + let source = dir.path().join("app/Providers/ResourceProvider.php"); + std::fs::create_dir_all(source.parent().unwrap()).unwrap(); + std::fs::write( + &source, + r#" null); +"#, + ) + .unwrap(); + + let backend = Backend::new_test(); + backend.resolved_class_cache.write().set_laravel(true); + let uri = crate::util::path_to_uri(&source); + + assert!(backend.apply_watched_file_changes( + &watched_change(&source, FileChangeType::CREATED), + dir.path(), + )); + { + let names = backend.laravel_source_strings.read(); + assert_eq!(names.rate_limiter_definitions("api").len(), 1); + assert_eq!( + names + .runtime_config_resource_definitions( + crate::symbol_map::LaravelConfigResource::CacheStore, + "tenant", + ) + .len(), + 1 + ); + } + assert!(backend.symbol_map_for(&uri).is_some()); + + std::fs::write(&source, "onQueue('{queue}'); }} + public function onQueue(string $queue): self {{ return $this; }} + }} +}} +"# + ) + }; + std::fs::write(&source, source_for("high")).unwrap(); + + let backend = Backend::new_test(); + backend.resolved_class_cache.write().set_laravel(true); + assert!(backend.apply_watched_file_changes( + &watched_change(&source, FileChangeType::CREATED), + dir.path(), + )); + assert_eq!( + backend + .laravel_source_strings + .read() + .queue_name_definitions("high") + .len(), + 1 + ); + + std::fs::write(&source, source_for("low")).unwrap(); + assert!(backend.apply_watched_file_changes( + &watched_change(&source, FileChangeType::CHANGED), + dir.path(), + )); + let names = backend.laravel_source_strings.read(); + assert!(names.queue_name_definitions("high").is_empty()); + assert_eq!(names.queue_name_definitions("low").len(), 1); + } + + #[test] + fn created_vendor_source_is_not_fully_parsed_for_laravel_tokens() { + let dir = tempfile::tempdir().unwrap(); + let vendor = dir.path().join("vendor"); + let source = vendor.join("package/src/Provider.php"); + std::fs::create_dir_all(source.parent().unwrap()).unwrap(); + std::fs::write( + &source, + " null); class Provider {}\n", + ) + .unwrap(); + + let backend = Backend::new_test(); + backend.resolved_class_cache.write().set_laravel(true); + backend.add_vendor_dir(&vendor); + let uri = crate::util::path_to_uri(&source); + + assert!(backend.apply_watched_file_changes( + &watched_change(&source, FileChangeType::CREATED), + dir.path(), + )); + assert!(backend.symbol_map_for(&uri).is_none()); + assert!( + backend + .laravel_source_strings + .read() + .rate_limiter_definitions("vendor-api") + .is_empty() + ); + } + + #[test] + fn unparsed_changed_source_keeps_the_refocus_fast_path() { + let dir = tempfile::tempdir().unwrap(); + let source = dir.path().join("app/Providers/UnloadedProvider.php"); + std::fs::create_dir_all(source.parent().unwrap()).unwrap(); + std::fs::write( + &source, + " null); class UnloadedProvider {}\n", + ) + .unwrap(); + + let backend = Backend::new_test(); + backend.resolved_class_cache.write().set_laravel(true); + let uri = crate::util::path_to_uri(&source); + + assert!(!backend.apply_watched_file_changes( + &watched_change(&source, FileChangeType::CHANGED), + dir.path(), + )); + assert!(backend.symbol_map_for(&uri).is_none()); + assert!( + backend + .laravel_source_strings + .read() + .rate_limiter_definitions("unloaded") + .is_empty() + ); + } + /// Deleting one of two files that declare the same class hands the /// name to the surviving file. The purge used to drop every index /// entry the deleted file owned, so a class shipped in two variants diff --git a/src/laravel_string_index.rs b/src/laravel_string_index.rs new file mode 100644 index 000000000..cc2c50d94 --- /dev/null +++ b/src/laravel_string_index.rs @@ -0,0 +1,953 @@ +//! Incremental names defined by Laravel string literals in application code. + +use std::collections::{BTreeMap, HashMap}; +use std::sync::Arc; + +use crate::symbol_map::{ + LaravelConfigResource, LaravelStringKind, SymbolKind, SymbolMap, SymbolSpan, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +struct NamedSpan { + name: String, + start: u32, + end: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ConfigResourceSpan { + kind: LaravelConfigResource, + name: String, + start: u32, + end: u32, +} + +/// One exact source occurrence indexed as a Laravel string declaration. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct LaravelSourceStringDefinition { + pub(crate) uri: Arc, + pub(crate) start: u32, + pub(crate) end: u32, +} + +/// The source-defined names that can be read directly from one symbol map. +/// +/// Building this value clones and sorts the discovered names, so callers do +/// that work before taking the shared source-index write lock. +#[derive(Default)] +pub(crate) struct LaravelSourceStringContributions { + rate_limiters: Vec, + config_resources: Vec, + dynamic_rate_limiter: bool, + has_queue_candidates: bool, +} + +impl LaravelSourceStringContributions { + /// Extract, sort, and deduplicate one map's direct contributions. + pub(crate) fn from_symbol_map(map: &SymbolMap) -> Self { + let mut rate_limiters = Vec::new(); + let mut config_resources = Vec::new(); + for span in &map.spans { + match &span.kind { + SymbolKind::LaravelStringKey { + kind: LaravelStringKind::RateLimiter, + key, + is_write: true, + .. + } => rate_limiters.push(NamedSpan { + name: key.clone(), + start: span.start, + end: span.end, + }), + SymbolKind::LaravelStringKey { + kind: LaravelStringKind::Config, + key, + is_write: true, + .. + } => { + if let Some((kind, name)) = + crate::symbol_map::laravel_resources::resource_from_config_key(key) + { + config_resources.push(ConfigResourceSpan { + kind, + name: name.to_string(), + start: span.start, + end: span.end, + }); + } + } + _ => {} + } + } + rate_limiters.sort_unstable_by(|left, right| { + (&left.name, left.start, left.end).cmp(&(&right.name, right.start, right.end)) + }); + rate_limiters.dedup(); + config_resources.sort_unstable_by(|left, right| { + (resource_order(left.kind), &left.name, left.start, left.end).cmp(&( + resource_order(right.kind), + &right.name, + right.start, + right.end, + )) + }); + config_resources.dedup(); + + Self { + rate_limiters, + config_resources, + dynamic_rate_limiter: map.has_dynamic_rate_limiter, + has_queue_candidates: map + .resource_receiver_sites + .iter() + .any(|site| site.rule == crate::symbol_map::LaravelResourceReceiverRule::QueueName), + } + } + + fn is_empty(&self) -> bool { + self.rate_limiters.is_empty() + && self.config_resources.is_empty() + && !self.dynamic_rate_limiter + && !self.has_queue_candidates + } +} + +#[derive(Default)] +struct UriNames { + rate_limiters: Vec, + config_resources: Vec, + queue_names: Vec, + dynamic_rate_limiter: bool, + has_queue_candidates: bool, +} + +#[derive(Default)] +struct ProviderUriNames { + rate_limiters: Vec, + dynamic_rate_limiter: bool, +} + +/// Small incrementally-maintained index for source-defined names that have no +/// config/file declaration to enumerate. +#[derive(Default)] +pub(crate) struct LaravelSourceStringIndex { + by_uri: HashMap, + provider_by_uri: HashMap, + rate_limiters: BTreeMap>, + queue_names: BTreeMap>, + config_resources: + HashMap>>, + dynamic_rate_limiter_files: u32, + generation: u64, + queue_names_generation: Option, +} + +impl LaravelSourceStringIndex { + /// Approximate owned heap bytes for the optional memory-audit build. + #[cfg(feature = "mem-audit")] + pub(crate) fn audit_heap(&self) -> usize { + let mut bytes = self.by_uri.capacity() * std::mem::size_of::<(String, UriNames)>(); + for (uri, names) in &self.by_uri { + bytes += uri.capacity(); + bytes += names.rate_limiters.capacity() * std::mem::size_of::(); + bytes += names.config_resources.capacity() * std::mem::size_of::(); + bytes += names.queue_names.capacity() * std::mem::size_of::(); + bytes += names + .rate_limiters + .iter() + .chain(&names.queue_names) + .map(|span| span.name.capacity()) + .sum::(); + bytes += names + .config_resources + .iter() + .map(|span| span.name.capacity()) + .sum::(); + } + bytes += + self.provider_by_uri.capacity() * std::mem::size_of::<(String, ProviderUriNames)>(); + for (uri, names) in &self.provider_by_uri { + bytes += uri.capacity(); + bytes += names.rate_limiters.capacity() * std::mem::size_of::(); + bytes += names + .rate_limiters + .iter() + .map(|span| span.name.capacity()) + .sum::(); + } + for map in [&self.rate_limiters, &self.queue_names] { + bytes += definition_map_heap(map); + } + bytes += self.config_resources.capacity() + * std::mem::size_of::<( + LaravelConfigResource, + BTreeMap>, + )>(); + for map in self.config_resources.values() { + bytes += definition_map_heap(map); + } + bytes + } + + /// Replace the direct symbol-map contributions of one file. Confirmed + /// type-dependent names are cleared until that file's lazy type pass runs. + pub(crate) fn set_symbol_map_contributions( + &mut self, + uri: &str, + contributions: LaravelSourceStringContributions, + ) { + if self.by_uri.get(uri).is_some_and(|current| { + !current.has_queue_candidates + && current.queue_names.is_empty() + && !contributions.has_queue_candidates + && current.rate_limiters == contributions.rate_limiters + && current.config_resources == contributions.config_resources + && current.dynamic_rate_limiter == contributions.dynamic_rate_limiter + }) { + return; + } + let invalidates_queue_names = contributions.has_queue_candidates + || self + .by_uri + .get(uri) + .is_some_and(|names| names.has_queue_candidates || !names.queue_names.is_empty()); + self.remove_direct_contributions(uri); + if invalidates_queue_names { + self.generation = self.generation.wrapping_add(1); + self.queue_names_generation = None; + } + add_named_spans(&mut self.rate_limiters, uri, &contributions.rate_limiters); + add_config_resource_spans( + &mut self.config_resources, + uri, + &contributions.config_resources, + ); + if contributions.dynamic_rate_limiter { + self.dynamic_rate_limiter_files += 1; + } + if !contributions.is_empty() { + self.by_uri.insert( + uri.to_string(), + UriNames { + rate_limiters: contributions.rate_limiters, + config_resources: contributions.config_resources, + queue_names: Vec::new(), + dynamic_rate_limiter: contributions.dynamic_rate_limiter, + has_queue_candidates: contributions.has_queue_candidates, + }, + ); + } + } + + /// Atomically replace the registered service-provider layer. + /// + /// Provider files can live outside the ordinary workspace map set, so + /// their rate-limiter registrations have a separate lifecycle. Keeping + /// that layer separate also means rebuilding the provider set cannot + /// erase an application file's direct contribution at the same URI. + pub(crate) fn replace_provider_contributions( + &mut self, + contributions: Vec<(String, LaravelSourceStringContributions)>, + ) { + for (uri, names) in self.provider_by_uri.drain() { + remove_named_spans(&mut self.rate_limiters, &uri, &names.rate_limiters); + if names.dynamic_rate_limiter { + self.dynamic_rate_limiter_files = self.dynamic_rate_limiter_files.saturating_sub(1); + } + } + + self.provider_by_uri.reserve(contributions.len()); + for (uri, contribution) in contributions { + if contribution.rate_limiters.is_empty() && !contribution.dynamic_rate_limiter { + continue; + } + add_named_spans(&mut self.rate_limiters, &uri, &contribution.rate_limiters); + if contribution.dynamic_rate_limiter { + self.dynamic_rate_limiter_files += 1; + } + self.provider_by_uri.insert( + uri, + ProviderUriNames { + rate_limiters: contribution.rate_limiters, + dynamic_rate_limiter: contribution.dynamic_rate_limiter, + }, + ); + } + } + + /// Replace one registered provider's rate-limiter registrations after an + /// editor update without rebuilding any unrelated provider contribution. + pub(crate) fn set_provider_contributions( + &mut self, + uri: &str, + contribution: LaravelSourceStringContributions, + ) { + self.remove_provider_contributions(uri); + if contribution.rate_limiters.is_empty() && !contribution.dynamic_rate_limiter { + return; + } + add_named_spans(&mut self.rate_limiters, uri, &contribution.rate_limiters); + if contribution.dynamic_rate_limiter { + self.dynamic_rate_limiter_files += 1; + } + self.provider_by_uri.insert( + uri.to_string(), + ProviderUriNames { + rate_limiters: contribution.rate_limiters, + dynamic_rate_limiter: contribution.dynamic_rate_limiter, + }, + ); + } + + /// Replace one file's queue names after typed receiver confirmation. + pub(crate) fn set_typed_spans(&mut self, uri: &str, spans: &[SymbolSpan]) { + let mut names = spans + .iter() + .filter_map(|span| match &span.kind { + SymbolKind::LaravelStringKey { + kind: LaravelStringKind::QueueName, + key, + .. + } => Some(NamedSpan { + name: key.clone(), + start: span.start, + end: span.end, + }), + _ => None, + }) + .collect::>(); + names.sort_unstable_by(|left, right| { + (&left.name, left.start, left.end).cmp(&(&right.name, right.start, right.end)) + }); + names.dedup(); + + let Some(entry) = self.by_uri.get_mut(uri) else { + if names.is_empty() { + return; + } + add_named_spans(&mut self.queue_names, uri, &names); + self.by_uri.insert( + uri.to_string(), + UriNames { + queue_names: names, + ..UriNames::default() + }, + ); + return; + }; + if entry.queue_names == names { + return; + } + remove_named_spans(&mut self.queue_names, uri, &entry.queue_names); + add_named_spans(&mut self.queue_names, uri, &names); + entry.queue_names = names; + if entry.rate_limiters.is_empty() + && entry.config_resources.is_empty() + && entry.queue_names.is_empty() + && !entry.dynamic_rate_limiter + && !entry.has_queue_candidates + { + self.by_uri.remove(uri); + } + } + + /// Remove every contribution of one file. + pub(crate) fn remove(&mut self, uri: &str) { + let invalidates_queue_names = self + .by_uri + .get(uri) + .is_some_and(|names| names.has_queue_candidates || !names.queue_names.is_empty()); + self.remove_direct_contributions(uri); + self.remove_provider_contributions(uri); + if invalidates_queue_names { + // A candidate with no materialized name can still have a typed + // scan in flight, so removing it must retire that scan too. + self.generation = self.generation.wrapping_add(1); + self.queue_names_generation = None; + } + } + + fn remove_direct_contributions(&mut self, uri: &str) -> bool { + let Some(old) = self.by_uri.remove(uri) else { + return false; + }; + remove_named_spans(&mut self.rate_limiters, uri, &old.rate_limiters); + remove_named_spans(&mut self.queue_names, uri, &old.queue_names); + for span in old.config_resources { + let remove_family = + self.config_resources + .get_mut(&span.kind) + .is_some_and(|definitions| { + remove_definition(definitions, &span.name, uri, span.start, span.end); + definitions.is_empty() + }); + if remove_family { + self.config_resources.remove(&span.kind); + } + } + if old.dynamic_rate_limiter { + self.dynamic_rate_limiter_files = self.dynamic_rate_limiter_files.saturating_sub(1); + } + true + } + + fn remove_provider_contributions(&mut self, uri: &str) -> bool { + let Some(old) = self.provider_by_uri.remove(uri) else { + return false; + }; + remove_named_spans(&mut self.rate_limiters, uri, &old.rate_limiters); + if old.dynamic_rate_limiter { + self.dynamic_rate_limiter_files = self.dynamic_rate_limiter_files.saturating_sub(1); + } + true + } + + pub(crate) fn rate_limiter_names(&self) -> Vec { + self.rate_limiters.keys().cloned().collect() + } + + /// Whether at least one statically enumerable rate limiter is registered. + pub(crate) fn has_rate_limiters(&self) -> bool { + !self.rate_limiters.is_empty() + } + + /// Whether `name` has at least one statically enumerable registration. + pub(crate) fn has_rate_limiter(&self, name: &str) -> bool { + self.rate_limiters.contains_key(name) + } + + /// Every exact registration literal for `name`, in stable source order. + pub(crate) fn rate_limiter_definitions( + &self, + name: &str, + ) -> Vec { + cloned_unique_definitions(self.rate_limiters.get(name).map(Vec::as_slice)) + } + + pub(crate) fn queue_names(&self) -> Vec { + self.queue_names.keys().cloned().collect() + } + + /// Every confirmed queue-name occurrence for `name`. + pub(crate) fn queue_name_definitions(&self, name: &str) -> Vec { + cloned_unique_definitions(self.queue_names.get(name).map(Vec::as_slice)) + } + + /// Runtime-defined direct config-resource children for one family. + pub(crate) fn runtime_config_resource_names(&self, kind: LaravelConfigResource) -> Vec { + self.config_resources + .get(&kind) + .map(|resources| resources.keys().cloned().collect()) + .unwrap_or_default() + } + + /// Whether application code directly defines this resource at runtime. + pub(crate) fn has_runtime_config_resource( + &self, + kind: LaravelConfigResource, + name: &str, + ) -> bool { + self.config_resources + .get(&kind) + .is_some_and(|resources| resources.contains_key(name)) + } + + /// Exact `Config::set()` literals that declare one runtime resource. + pub(crate) fn runtime_config_resource_definitions( + &self, + kind: LaravelConfigResource, + name: &str, + ) -> Vec { + cloned_unique_definitions( + self.config_resources + .get(&kind) + .and_then(|resources| resources.get(name)) + .map(Vec::as_slice), + ) + } + + pub(crate) fn rate_limiter_space_is_open(&self) -> bool { + self.dynamic_rate_limiter_files != 0 + } + + pub(crate) fn queue_name_generation(&self) -> u64 { + self.generation + } + + pub(crate) fn queue_names_are_complete(&self) -> bool { + self.queue_names_generation == Some(self.generation) + } + + /// Mark the typed scan complete only if no symbol-map publication raced + /// it. Returns whether the caller's snapshot is still current. + pub(crate) fn mark_queue_names_complete(&mut self, generation: u64) -> bool { + if self.generation != generation { + // The exact-map typed cache prevents an old map from publishing + // after its replacement, while publishing the new symbol map + // removes that URI's old queue spans. Keep the still-current + // files' entries: a retry can reuse their Ready typed results, + // which intentionally do not republish into this index. + self.queue_names_generation = None; + return false; + } + self.queue_names_generation = Some(generation); + true + } +} + +fn add_named_spans( + definitions: &mut BTreeMap>, + uri: &str, + spans: &[NamedSpan], +) { + if spans.is_empty() { + return; + } + let uri: Arc = Arc::from(uri); + for span in spans { + add_definition(definitions, &span.name, &uri, span.start, span.end); + } +} + +fn add_config_resource_spans( + definitions: &mut HashMap< + LaravelConfigResource, + BTreeMap>, + >, + uri: &str, + spans: &[ConfigResourceSpan], +) { + if spans.is_empty() { + return; + } + let uri: Arc = Arc::from(uri); + for span in spans { + add_definition( + definitions.entry(span.kind).or_default(), + &span.name, + &uri, + span.start, + span.end, + ); + } +} + +fn remove_named_spans( + definitions: &mut BTreeMap>, + uri: &str, + spans: &[NamedSpan], +) { + for span in spans { + remove_definition(definitions, &span.name, uri, span.start, span.end); + } +} + +fn add_definition( + definitions: &mut BTreeMap>, + name: &str, + uri: &Arc, + start: u32, + end: u32, +) { + definitions + .entry(name.to_string()) + .or_default() + .push(LaravelSourceStringDefinition { + uri: Arc::clone(uri), + start, + end, + }); +} + +fn remove_definition( + definitions: &mut BTreeMap>, + name: &str, + uri: &str, + start: u32, + end: u32, +) { + let remove_name = definitions.get_mut(name).is_some_and(|locations| { + if let Some(index) = locations.iter().position(|location| { + location.uri.as_ref() == uri && location.start == start && location.end == end + }) { + locations.swap_remove(index); + } + locations.is_empty() + }); + if remove_name { + definitions.remove(name); + } +} + +fn cloned_unique_definitions( + definitions: Option<&[LaravelSourceStringDefinition]>, +) -> Vec { + let Some(definitions) = definitions else { + return Vec::new(); + }; + let mut result = definitions.to_vec(); + result.sort_unstable(); + result.dedup(); + result +} + +const fn resource_order(kind: LaravelConfigResource) -> u8 { + match kind { + LaravelConfigResource::AuthGuard => 0, + LaravelConfigResource::CacheStore => 1, + LaravelConfigResource::LogChannel => 2, + LaravelConfigResource::StorageDisk => 3, + LaravelConfigResource::DatabaseConnection => 4, + LaravelConfigResource::QueueConnection => 5, + LaravelConfigResource::Mailer => 6, + LaravelConfigResource::BroadcastConnection => 7, + } +} + +#[cfg(feature = "mem-audit")] +fn definition_map_heap( + definitions: &BTreeMap>, +) -> usize { + let mut bytes = definitions.len() + * (std::mem::size_of::<(String, Vec)>() + + 3 * std::mem::size_of::()); + for (name, locations) in definitions { + bytes += name.capacity(); + bytes += locations.capacity() * std::mem::size_of::(); + bytes += locations + .iter() + .map(|location| location.uri.len()) + .sum::(); + } + bytes +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::symbol_map::{SymbolSpan, extract_symbol_map}; + + fn map(source: &str) -> SymbolMap { + crate::parser::with_parsed_program(source, "source_string_index", |program, source| { + extract_symbol_map(program, source) + }) + } + + fn set_map(index: &mut LaravelSourceStringIndex, uri: &str, source: &str) { + let map = map(source); + let contributions = LaravelSourceStringContributions::from_symbol_map(&map); + index.set_symbol_map_contributions(uri, contributions); + } + + #[test] + fn replacing_and_removing_a_uri_keeps_name_counts_exact() { + let mut index = LaravelSourceStringIndex::default(); + set_map( + &mut index, + "file:///one.php", + " null);", + ); + set_map( + &mut index, + "file:///one.php", + " null);", + ); + set_map( + &mut index, + "file:///two.php", + " null); RateLimiter::for('mail', fn () => null);", + ); + assert_eq!(index.rate_limiter_names(), ["api", "mail"]); + assert!(index.has_rate_limiters()); + assert!(index.has_rate_limiter("api")); + assert!(!index.has_rate_limiter("missing")); + + set_map(&mut index, "file:///two.php", " null); RateLimiter::for('api', fn () => null);"; + let parsed = map(source); + index.set_symbol_map_contributions( + uri, + LaravelSourceStringContributions::from_symbol_map(&parsed), + ); + + let definitions = index.rate_limiter_definitions("api"); + assert_eq!(definitions.len(), 2); + assert!( + definitions + .iter() + .all(|definition| definition.uri.as_ref() == uri) + ); + assert!(definitions[0].start < definitions[1].start); + + index.replace_provider_contributions(vec![( + uri.to_string(), + LaravelSourceStringContributions::from_symbol_map(&parsed), + )]); + assert_eq!(index.rate_limiter_definitions("api"), definitions); + + index.replace_provider_contributions(Vec::new()); + assert_eq!(index.rate_limiter_definitions("api"), definitions); + } + + #[test] + fn provider_layer_replaces_names_and_dynamic_openness_atomically() { + let mut index = LaravelSourceStringIndex::default(); + let literal = map(" null);"); + let dynamic = map(" null);"); + + index.replace_provider_contributions(vec![( + "file:///vendor/provider.php".to_string(), + LaravelSourceStringContributions::from_symbol_map(&literal), + )]); + assert_eq!(index.rate_limiter_names(), ["vendor-api"]); + assert_eq!(index.rate_limiter_definitions("vendor-api").len(), 1); + assert!(!index.rate_limiter_space_is_open()); + + index.replace_provider_contributions(vec![( + "file:///vendor/provider.php".to_string(), + LaravelSourceStringContributions::from_symbol_map(&dynamic), + )]); + assert!(index.rate_limiter_names().is_empty()); + assert!(index.rate_limiter_space_is_open()); + + index.replace_provider_contributions(Vec::new()); + assert!(!index.rate_limiter_space_is_open()); + } + + #[test] + fn typed_queue_names_replace_without_touching_rate_limiters() { + let mut index = LaravelSourceStringIndex::default(); + set_map( + &mut index, + "file:///job.php", + " null);", + ); + let queue_span = |key: &str| SymbolSpan { + start: 1, + end: 1 + key.len() as u32, + kind: SymbolKind::LaravelStringKey { + kind: LaravelStringKind::QueueName, + key: key.to_string(), + is_write: false, + is_optional: true, + }, + }; + index.set_typed_spans("file:///job.php", &[queue_span("high")]); + assert_eq!(index.queue_names(), ["high"]); + assert_eq!( + index.queue_name_definitions("high"), + [LaravelSourceStringDefinition { + uri: Arc::from("file:///job.php"), + start: 1, + end: 5, + }] + ); + index.set_typed_spans("file:///job.php", &[queue_span("high")]); + assert_eq!(index.queue_names(), ["high"]); + index.set_typed_spans("file:///job.php", &[queue_span("low")]); + assert_eq!(index.queue_names(), ["low"]); + assert_eq!(index.rate_limiter_names(), ["api"]); + } + + #[test] + fn one_provider_can_be_replaced_without_rebuilding_the_provider_layer() { + let mut index = LaravelSourceStringIndex::default(); + let uri = "file:///vendor/provider.php"; + let contribution = LaravelSourceStringContributions::from_symbol_map(&map( + " null); RateLimiter::for($name, fn () => null);", + )); + + index.set_provider_contributions(uri, contribution); + assert_eq!(index.rate_limiter_names(), ["vendor-api"]); + assert!(index.rate_limiter_space_is_open()); + + index.set_provider_contributions(uri, LaravelSourceStringContributions::default()); + assert!(index.rate_limiter_names().is_empty()); + assert!(!index.rate_limiter_space_is_open()); + assert!(index.provider_by_uri.is_empty()); + } + + #[test] + fn direct_config_writes_define_only_direct_resource_children() { + let mut index = LaravelSourceStringIndex::default(); + set_map( + &mut index, + "file:///bootstrap.php", + " null);", + ); + assert!(index.rate_limiter_space_is_open()); + set_map(&mut index, "file:///provider.php", "onQueue('high');", + ); + let generation = index.queue_name_generation(); + assert!(index.mark_queue_names_complete(generation)); + assert!(index.queue_names_are_complete()); + + set_map(&mut index, "file:///two.php", "onQueue('low');"); + assert!(!index.queue_names_are_complete()); + assert!(!index.mark_queue_names_complete(generation)); + assert!(index.mark_queue_names_complete(index.queue_name_generation())); + } + + #[test] + fn generation_retry_preserves_names_from_still_current_files() { + let mut index = LaravelSourceStringIndex::default(); + set_map( + &mut index, + "file:///job.php", + "onQueue('stale');", + ); + let stale_generation = index.queue_name_generation(); + + set_map( + &mut index, + "file:///other.php", + "onQueue('other');", + ); + let stale_queue_span = SymbolSpan { + start: 1, + end: 5, + kind: SymbolKind::LaravelStringKey { + kind: LaravelStringKind::QueueName, + key: "stale".to_string(), + is_write: false, + is_optional: true, + }, + }; + index.set_typed_spans("file:///job.php", &[stale_queue_span]); + assert_eq!(index.queue_names(), ["stale"]); + + assert!(!index.mark_queue_names_complete(stale_generation)); + assert_eq!(index.queue_names(), ["stale"]); + assert!(index.by_uri.contains_key("file:///job.php")); + } + + #[test] + fn files_without_source_names_do_not_consume_per_uri_storage() { + let mut index = LaravelSourceStringIndex::default(); + let original_generation = index.queue_name_generation(); + assert!(index.mark_queue_names_complete(original_generation)); + + set_map(&mut index, "file:///plain.php", "onQueue('high');", + ); + let generation = index.queue_name_generation(); + + index.remove("file:///plain.php"); + + assert_ne!(index.queue_name_generation(), generation); + assert!(!index.mark_queue_names_complete(generation)); + assert!(index.by_uri.is_empty()); + } + + #[test] + fn empty_typed_results_do_not_create_or_retain_empty_entries() { + let mut index = LaravelSourceStringIndex::default(); + index.set_typed_spans("file:///job.php", &[]); + assert!(index.by_uri.is_empty()); + + let queue_span = SymbolSpan { + start: 1, + end: 5, + kind: SymbolKind::LaravelStringKey { + kind: LaravelStringKind::QueueName, + key: "high".to_string(), + is_write: false, + is_optional: true, + }, + }; + index.set_typed_spans("file:///job.php", std::slice::from_ref(&queue_span)); + index.set_typed_spans("file:///other-job.php", &[queue_span]); + assert_eq!(index.queue_names(), ["high"]); + + index.set_typed_spans("file:///job.php", &[]); + assert!(!index.by_uri.contains_key("file:///job.php")); + assert_eq!(index.queue_names(), ["high"]); + + index.set_typed_spans("file:///other-job.php", &[]); + assert!(index.by_uri.is_empty()); + assert!(index.queue_names().is_empty()); + } + + #[test] + fn every_config_resource_has_a_stable_sort_order() { + assert_eq!(resource_order(LaravelConfigResource::AuthGuard), 0); + assert_eq!(resource_order(LaravelConfigResource::CacheStore), 1); + assert_eq!(resource_order(LaravelConfigResource::LogChannel), 2); + assert_eq!(resource_order(LaravelConfigResource::StorageDisk), 3); + assert_eq!(resource_order(LaravelConfigResource::DatabaseConnection), 4); + assert_eq!(resource_order(LaravelConfigResource::QueueConnection), 5); + assert_eq!(resource_order(LaravelConfigResource::Mailer), 6); + assert_eq!( + resource_order(LaravelConfigResource::BroadcastConnection), + 7 + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index f7d15a1eb..3a61609df 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -244,6 +244,7 @@ mod hover; mod indexing; pub(crate) mod inheritance; mod inlay_hints; +mod laravel_string_index; /// LSP JSON-RPC dispatch for the wasm build, which has no tower-lsp transport. /// Kept free of any target-specific code so the marshalling in `wasm_wasi` is /// the only thing a future non-WASI wasm target would have to replace. @@ -336,7 +337,12 @@ pub(crate) struct LaravelStringKeyCache { /// `Arc` because both the name list and the parameter names of one route /// are read from it, and cloning the whole table per read would be waste. pub routes: Option>>, - pub config_keys: Option>, + pub config_keys: Option>>, + /// Config subtrees whose child names are supplied by runtime expressions. + /// Kept parallel to `config_keys` and filled under the same build lock. + pub config_open_prefixes: Option>>, + /// Revision used to reject a config scan that finishes after invalidation. + pub config_generation: u64, pub view_names: Option>, pub trans_keys: Option>, /// Every translation key mapped to whether it names a group (nested @@ -399,7 +405,7 @@ pub(crate) struct LaravelStringKeyBuildLocks { } impl LaravelStringKeyCache { - fn invalidate_for_uri(&mut self, uri: &str, content: &str) { + fn invalidate_for_uri(&mut self, uri: &str, content: &str, is_provider_config: bool) { // Abilities come from `Gate::define()` calls and from the methods of // every policy class, so an edit to either invalidates the set. The // token is `Gate::` rather than `Gate` so an unrelated `Gateway` does @@ -412,8 +418,10 @@ impl LaravelStringKeyCache { if uri.contains("/routes/") { self.routes = None; } - if uri.contains("/config/") { + if uri.contains("/config/") || is_provider_config { + self.config_generation = self.config_generation.wrapping_add(1); self.config_keys = None; + self.config_open_prefixes = None; self.config_trees = None; } // View roots are configurable via `config/view.php`, so a Blade @@ -514,14 +522,14 @@ pub struct Backend { /// (caught by `catch_unwind`), a single "Parse failed" entry is /// stored instead. pub(crate) parse_errors: Arc>>>, - /// Per-URI locks for background `didChange` parses. + /// Per-URI locks for editor-buffer lifecycle updates and parses. /// /// `didChange` handlers offload parsing to blocking tasks. Without a /// per-file lock, an older parse can finish after a newer edit and publish /// stale symbol state. These locks serialize parse commits per URI; the /// handler also verifies that the captured text is still current before /// updating shared maps. - pub(crate) did_change_parse_locks: Arc>>>>, + pub(crate) did_change_parse_locks: Arc>>>>, /// Coalescing state for expensive whole-file requests. See /// [`WholeFileCoalesce`] for why this exists. pub(crate) whole_file_coalesce: Arc, @@ -730,6 +738,9 @@ pub struct Backend { /// Invalidated when a file in `routes/`, `config/`, `resources/views/`, /// or `lang/` is updated. pub(crate) laravel_string_key_cache: Arc>, + /// Incremental source registrations for rate-limiters and free-form queue + /// names. Updated per parsed URI, so completion never rescans the workspace. + pub(crate) laravel_source_strings: Arc>, /// Compute-once guards for `laravel_string_key_cache`; see /// [`LaravelStringKeyBuildLocks`]. pub(crate) laravel_string_key_build_locks: Arc, @@ -1060,6 +1071,9 @@ impl Backend { virtual_members::laravel::ProviderScans::default(), )), laravel_string_key_cache: Arc::new(RwLock::new(LaravelStringKeyCache::default())), + laravel_source_strings: Arc::new(RwLock::new( + laravel_string_index::LaravelSourceStringIndex::default(), + )), laravel_string_key_build_locks: Arc::new(LaravelStringKeyBuildLocks::default()), schema_index: Arc::new(RwLock::new( virtual_members::laravel::database_schema::SchemaIndex::default(), @@ -1163,6 +1177,9 @@ impl Backend { virtual_members::laravel::ProviderScans::default(), )), laravel_string_key_cache: Arc::new(RwLock::new(LaravelStringKeyCache::default())), + laravel_source_strings: Arc::new(RwLock::new( + laravel_string_index::LaravelSourceStringIndex::default(), + )), laravel_string_key_build_locks: Arc::new(LaravelStringKeyBuildLocks::default()), schema_index: Arc::new(RwLock::new( virtual_members::laravel::database_schema::SchemaIndex::default(), @@ -1694,7 +1711,7 @@ impl Backend { vec![uri_str.as_str(), canonical_uri.as_str()] }; for uri in spellings { - self.clear_file_maps(uri); + self.clear_file_maps_and_source_strings(uri); self.symbols.uri_classes_index.write().remove(uri); self.parsed_uris.write().remove(uri); // The global_functions/global_defines entries for these URIs @@ -1798,6 +1815,7 @@ impl Backend { laravel_provider_resources: Arc::clone(&self.laravel_provider_resources), laravel_provider_scans: Arc::clone(&self.laravel_provider_scans), laravel_string_key_cache: Arc::clone(&self.laravel_string_key_cache), + laravel_source_strings: Arc::clone(&self.laravel_source_strings), laravel_string_key_build_locks: Arc::clone(&self.laravel_string_key_build_locks), schema_index: Arc::clone(&self.schema_index), member_completion_cache: Arc::clone(&self.member_completion_cache), diff --git a/src/mem_audit.rs b/src/mem_audit.rs index 5c1c4c10e..479258381 100644 --- a/src/mem_audit.rs +++ b/src/mem_audit.rs @@ -196,7 +196,9 @@ thread_local! { } fn variant_name(t: &PhpType) -> &'static str { - match t.kind() { + match t.raw_kind() { + TypeKind::Benevolent(_) => "Benevolent", + TypeKind::ListShape(_) => "ListShape", TypeKind::Named(_) => "Named", TypeKind::StaticType(_) => "StaticType", TypeKind::ThisType(_) => "ThisType", @@ -246,7 +248,11 @@ fn ty(t: &PhpType) -> Sz { // The node's own `Arc` allocation: control block plus the enum. z.add(ARC + size_of::()); - match t.kind() { + match t.raw_kind() { + TypeKind::Benevolent(b) | TypeKind::ListShape(b) => { + z.slot(1); + z += ty(b); + } TypeKind::Named(_) | TypeKind::StaticType(_) | TypeKind::ThisType(_) => {} TypeKind::Nullable(b) | TypeKind::Array(b) | TypeKind::KeyOf(b) | TypeKind::ValueOf(b) => { z.slot(1); @@ -1319,6 +1325,13 @@ pub(crate) fn report(backend: &Backend, runner_content_bytes: usize) { for site in &sm.view_receiver_sites { sym.add(site.key.capacity()); } + sym.add( + sm.resource_receiver_sites.capacity() + * size_of::(), + ); + for site in &sm.resource_receiver_sites { + sym.add(site.key.capacity()); + } } } eprintln!( @@ -1436,11 +1449,14 @@ pub(crate) fn report(backend: &Backend, runner_content_bytes: usize) { let mut laravel_keys = Sz::default(); { let c = backend.laravel_string_key_cache.read(); - for v in [&c.config_keys, &c.view_names, &c.trans_keys] - .into_iter() - .flatten() - { - laravel_keys += vs(v); + if let Some(keys) = &c.config_keys { + laravel_keys += vs(keys); + } + if let Some(prefixes) = &c.config_open_prefixes { + laravel_keys += vs(prefixes); + } + for values in [&c.view_names, &c.trans_keys].into_iter().flatten() { + laravel_keys += vs(values); } if let Some(routes) = &c.routes { laravel_keys @@ -1482,6 +1498,41 @@ pub(crate) fn report(backend: &Backend, runner_content_bytes: usize) { } } } + laravel_keys.add(backend.laravel_source_strings.read().audit_heap()); + let mut typed_receiver_cache = Sz::default(); + let mut n_typed_receiver_spans = 0usize; + let n_typed_receiver_entries; + { + use crate::blade::typed_receiver::TypedReceiverSpansState; + + let cache = backend.typed_receiver_view_spans_cache.read(); + n_typed_receiver_entries = cache.len(); + typed_receiver_cache += map_buckets::< + String, + crate::blade::typed_receiver::TypedReceiverSpans, + >(cache.capacity()); + for (uri, entry) in cache.iter() { + typed_receiver_cache.add(uri.capacity()); + // A dead weak reference still keeps the Arc allocation itself + // resident, although the SymbolMap's owned fields are gone. + if entry.symbol_map.strong_count() == 0 { + typed_receiver_cache.add(ARC + size_of::()); + } + match &entry.state { + TypedReceiverSpansState::Pending(_) => typed_receiver_cache.add(ARC), + TypedReceiverSpansState::Ready(spans) => { + typed_receiver_cache.add(ARC + VEC_HDR); + typed_receiver_cache + .add(spans.capacity() * size_of::()); + n_typed_receiver_spans += spans.len(); + for span in spans.iter() { + typed_receiver_cache.add(span.kind.audit_heap()); + } + } + TypedReceiverSpansState::Invalidated => {} + } + } + } let mut blade = Sz::default(); let n_blade; { @@ -1509,9 +1560,12 @@ pub(crate) fn report(backend: &Backend, runner_content_bytes: usize) { } } eprintln!( - "── stub/autoload/uri-globals/negative caches {:.1} MB | laravel string keys {:.1} MB | blade virtual content: {} files {:.1} MB", + "── stub/autoload/uri-globals/negative caches {:.1} MB | laravel string keys {:.1} MB | typed receiver cache: {} files, {} spans, {:.1} MB | blade virtual content: {} files {:.1} MB", mb(misc.bytes), mb(laravel_keys.bytes), + n_typed_receiver_entries, + n_typed_receiver_spans, + mb(typed_receiver_cache.bytes), n_blade, mb(blade.bytes), ); @@ -1553,6 +1607,7 @@ pub(crate) fn report(backend: &Backend, runner_content_bytes: usize) { + open.bytes + misc.bytes + laravel_keys.bytes + + typed_receiver_cache.bytes + blade.bytes + runner_content_bytes + ustr::total_allocated(); @@ -1617,6 +1672,12 @@ pub(crate) fn report(backend: &Backend, runner_content_bytes: usize) { probe("laravel_string_key_cache", &mut || { *backend.laravel_string_key_cache.write() = Default::default() }); + probe("laravel_source_strings", &mut || { + *backend.laravel_source_strings.write() = Default::default() + }); + probe("typed_receiver_view_spans_cache", &mut || { + backend.typed_receiver_view_spans_cache.write().clear() + }); probe("member_completion_cache", &mut || { backend.member_completion_cache.lock().clear() }); diff --git a/src/parser/ast_update.rs b/src/parser/ast_update.rs index 3c3ae26fe..4da54c138 100644 --- a/src/parser/ast_update.rs +++ b/src/parser/ast_update.rs @@ -15,7 +15,7 @@ use crate::atom::{Atom, atom, bytes_to_str}; use crate::ci_map::CiMap; use crate::names::OwnedResolvedNames; use crate::php_type::PhpType; -use crate::symbol_map::{SymbolMap, extract_symbol_map}; +use crate::symbol_map::{SymbolMap, extract_symbol_map_with_resolved_names}; use crate::types::{ ClassInfo, DefineInfo, DocblockMembers, FunctionInfo, MethodInfo, NamespaceSpan, TypeAliasDef, }; @@ -255,9 +255,10 @@ impl Backend { content.to_string() }; + let is_provider_config = self.is_provider_config_uri(uri); self.laravel_string_key_cache .write() - .invalidate_for_uri(uri, content); + .invalidate_for_uri(uri, content, is_provider_config); self.refresh_blade_discovery(uri); self.refresh_blade_block_index(uri, content); @@ -267,9 +268,10 @@ impl Backend { // entire parse + extraction in `catch_unwind` so a parser panic // doesn't crash the LSP server and produce a zombie process. // - // On panic the file is simply skipped — no maps are updated, and - // the user gets stale (but not missing) completions until the - // file is saved in a parseable state. + // On panic the structural maps are left untouched, so ordinary + // completions can keep using their last coherent snapshot. Source- + // registered Laravel names are withdrawn below because their latest + // contribution could not be parsed. let content_owned = content_to_parse; let uri_owned = uri.to_string(); @@ -308,9 +310,14 @@ impl Backend { // no-op for every file that is not a registered service provider. self.refresh_laravel_provider_resources(uri, content); + self.finish_ast_update(uri, result) + } + + fn finish_ast_update(&self, uri: &str, result: Option) -> bool { match result { Some(changed) => changed, None => { + self.laravel_source_strings.write().remove(uri); // Parser panicked — store a single "Parse failed" error // so the syntax-error diagnostic collector can report it. self.parse_errors.write().insert( @@ -413,6 +420,12 @@ impl Backend { drop(open_uris); if !failures.is_empty() { + { + let mut index = self.laravel_source_strings.write(); + for (uri, _) in &failures { + index.remove(uri); + } + } let mut parse_errors = self.parse_errors.write(); for (uri, errors) in failures { parse_errors.insert(uri, errors); @@ -821,7 +834,11 @@ impl Backend { // Build the precomputed symbol map while the AST is still alive. // This must happen before the `Program` (and its arena) are dropped. - let symbol_map = Arc::new(extract_symbol_map(program, content)); + let symbol_map = Arc::new(extract_symbol_map_with_resolved_names( + program, + content, + &owned_resolved, + )); // For files without any explicit namespace blocks, synthesize a // single span covering the entire file with the detected namespace @@ -1317,12 +1334,24 @@ impl Backend { } } + // Extracting direct Laravel names sorts, deduplicates, and clones + // their strings. Keep that work outside the small shared-index lock. + let source_string_contributions = prepared + .iter() + .map(|update| { + crate::laravel_string_index::LaravelSourceStringContributions::from_symbol_map( + &update.symbol_map, + ) + }) + .collect::>(); let reference_items: Vec<(String, Arc)> = prepared .iter() .map(|update| (update.uri.clone(), Arc::clone(&update.symbol_map))) .collect(); - self.reindex_references_for_symbol_maps_batch(reference_items); + // Publish every new map before advancing the source-name generation. + // A queue-name reader that observes the new generation is therefore + // guaranteed to scan at least these maps, never their predecessors. { let mut symbol_maps = self.symbol_maps.write(); for update in prepared { @@ -1330,6 +1359,15 @@ impl Backend { } } + { + let mut index = self.laravel_source_strings.write(); + for ((uri, _), contributions) in reference_items.iter().zip(source_string_contributions) + { + index.set_symbol_map_contributions(uri, contributions); + } + } + self.reindex_references_for_symbol_maps_batch(reference_items); + changed } @@ -1900,11 +1938,137 @@ impl Backend { } } +impl Backend { + /// Whether a URI is one of the arbitrary config paths registered by a + /// service provider. These files need not live below a `config/` + /// directory, so the conventional path check cannot identify them. + fn is_provider_config_uri(&self, uri: &str) -> bool { + let resources = self.laravel_provider_resources.read(); + if resources.config_files.is_empty() { + return false; + } + let Some(edited_path) = tower_lsp::lsp_types::Url::parse(uri) + .ok() + .and_then(|url| url.to_file_path().ok()) + else { + return false; + }; + + let edited_file_name = edited_path.file_name(); + let mut candidates = Vec::new(); + for resource in &resources.config_files { + if resource.path == edited_path { + return true; + } + if resource.path.file_name() == edited_file_name { + candidates.push(resource.path.clone()); + } + } + drop(resources); + if candidates.is_empty() { + return false; + } + let Ok(edited_path) = edited_path.canonicalize() else { + return false; + }; + candidates.into_iter().any(|candidate| { + candidate + .canonicalize() + .is_ok_and(|path| path == edited_path) + }) + } +} + #[cfg(test)] mod tests { use super::*; use crate::Backend; + #[test] + fn a_failed_parse_withdraws_its_laravel_source_contributions() { + let backend = Backend::new_test(); + let uri = "file:///app/Providers/AppServiceProvider.php"; + backend.update_ast( + uri, + r#" null); +"#, + ); + assert_eq!( + backend.laravel_source_strings.read().rate_limiter_names(), + ["api"] + ); + + assert!(!backend.finish_ast_update(uri, None)); + assert!( + backend + .laravel_source_strings + .read() + .rate_limiter_names() + .is_empty() + ); + assert_eq!( + backend.parse_errors.read().get(uri).unwrap(), + &vec![("Parse failed (internal error)".to_string(), 0, 0)] + ); + } + + #[test] + fn provider_config_outside_config_directory_invalidates_config_cache() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("package/resources/settings.php"); + std::fs::create_dir_all(path.parent().unwrap()).expect("create resources directory"); + std::fs::write(&path, " []];").expect("write provider config"); + + let backend = Backend::new_test(); + backend + .laravel_provider_resources + .write() + .config_files + .push(crate::virtual_members::laravel::ProviderResource { + path: path.clone(), + namespace: "cache".to_string(), + }); + { + let mut cache = backend.laravel_string_key_cache.write(); + cache.config_generation = 41; + cache.config_keys = Some(Arc::new(vec!["cache.stores.old".to_string()])); + cache.config_open_prefixes = Some(Arc::new(Vec::new())); + } + + let uri = crate::util::path_to_uri(&path); + backend.update_ast(&uri, " ['new' => []]];"); + + let cache = backend.laravel_string_key_cache.read(); + assert_eq!(cache.config_generation, 42); + assert!(cache.config_keys.is_none()); + assert!(cache.config_open_prefixes.is_none()); + } + + #[test] + fn an_absent_same_named_path_is_not_a_registered_provider_config() { + let dir = tempfile::tempdir().expect("tempdir"); + let registered = dir.path().join("package/resources/settings.php"); + std::fs::create_dir_all(registered.parent().unwrap()).expect("create resources directory"); + std::fs::write(®istered, " s.capacity(), Self::Member { name, .. } => name.capacity(), - Self::LaravelString { key, .. } => key.capacity(), + Self::LaravelString { key, .. } | Self::LaravelResourceCandidate { key } => { + key.capacity() + } } } } @@ -316,6 +324,14 @@ impl Backend { false, )); } + for site in &symbol_map.resource_receiver_sites { + entries.push(( + ReferenceIndexKey::LaravelResourceCandidate { + key: site.key.clone(), + }, + false, + )); + } if let Some(classes) = self.symbols.uri_classes_index.read().get(uri).cloned() { for class in classes { @@ -411,13 +427,37 @@ impl Backend { )] } SymbolKind::LaravelStringKey { kind, key, .. } => { - vec![( + let mut keys = vec![( ReferenceIndexKey::LaravelString { - kind: kind.clone(), + kind: *kind, key: key.to_string(), }, true, - )] + )]; + match kind { + LaravelStringKind::ConfigResource(resource) => keys.push(( + ReferenceIndexKey::LaravelString { + kind: LaravelStringKind::Config, + key: crate::symbol_map::laravel_resources::config_key(*resource, key), + }, + false, + )), + LaravelStringKind::Config => { + if let Some((resource, short)) = + crate::symbol_map::laravel_resources::resource_from_config_key(key) + { + keys.push(( + ReferenceIndexKey::LaravelString { + kind: LaravelStringKind::ConfigResource(resource), + key: short.to_string(), + }, + false, + )); + } + } + _ => {} + } + keys } _ => Vec::new(), } @@ -587,7 +627,9 @@ mod tests { use super::*; use crate::Backend; - use crate::symbol_map::{SymbolMap, SymbolSpan}; + use crate::symbol_map::{ + LaravelResourceReceiverRule, LaravelResourceReceiverSite, SymbolMap, SymbolSpan, + }; #[test] fn candidate_lookup_is_disabled_until_workspace_is_indexed() { @@ -662,6 +704,33 @@ mod tests { ); } + #[test] + fn typed_laravel_resource_site_has_one_coarse_index_entry() { + let backend = Backend::new_test(); + let map = SymbolMap { + resource_receiver_sites: vec![LaravelResourceReceiverSite { + start: 10, + end: 15, + key: "redis".to_string(), + rule: LaravelResourceReceiverRule::ConnectionMethod, + }], + ..SymbolMap::default() + }; + + let entries = + backend.reference_entries_for_symbol_map("file:///project/app/Consumer.php", &map); + assert_eq!(entries.len(), 1); + assert_eq!( + entries[0], + ( + ReferenceIndexKey::LaravelResourceCandidate { + key: "redis".to_string(), + }, + false, + ) + ); + } + #[test] fn reference_index_evicts_candidates_when_file_maps_clear() { let backend = Backend::new_test(); diff --git a/src/references/dispatch.rs b/src/references/dispatch.rs index 00be5c351..2330ef1bd 100644 --- a/src/references/dispatch.rs +++ b/src/references/dispatch.rs @@ -342,18 +342,25 @@ impl Backend { SymbolKind::NamespaceDeclaration { .. } => Vec::new(), SymbolKind::LaravelStringKey { kind, key, .. } => { - let snapshot = if include_declaration - && matches!(kind, crate::symbol_map::LaravelStringKind::Config) - { - self.user_file_symbol_maps() + // Config declarations are resolved separately from their + // config files below. Usage discovery can therefore stay on + // the exact reference-index slice even when declarations are + // requested, rather than reading every project file. + let typed_key = if *kind == crate::symbol_map::LaravelStringKind::Config { + crate::symbol_map::laravel_resources::resource_from_config_key(key) + .map_or(key.as_str(), |(_, short)| short) } else { - self.user_file_symbol_maps_for_reference_keys(&[ - ReferenceIndexKey::LaravelString { - kind: kind.clone(), - key: key.to_string(), - }, - ]) + key.as_str() }; + let snapshot = self.user_file_symbol_maps_for_reference_keys(&[ + ReferenceIndexKey::LaravelString { + kind: *kind, + key: key.to_string(), + }, + ReferenceIndexKey::LaravelResourceCandidate { + key: typed_key.to_string(), + }, + ]); laravel::find_laravel_string_key_references( self, kind, diff --git a/src/server.rs b/src/server.rs index 78266d606..44ca9d2c9 100644 --- a/src/server.rs +++ b/src/server.rs @@ -70,6 +70,16 @@ where .and_then(|inner| inner.ok()) } +fn uri_parse_lock(backend: &Backend, uri: &str) -> Arc> { + let mut locks = backend.did_change_parse_locks.lock(); + if let Some(lock) = locks.get(uri) { + return Arc::clone(lock); + } + let lock = Arc::new(tokio::sync::Mutex::new(())); + locks.insert(uri.to_owned(), Arc::clone(&lock)); + lock +} + #[tower_lsp::async_trait] impl LanguageServer for Backend { async fn initialize(&self, params: InitializeParams) -> Result { @@ -646,18 +656,23 @@ impl LanguageServer for Backend { let uri = doc.uri.to_string(); let text = Arc::new(doc.text); + let parse_lock = uri_parse_lock(self, &uri); + let parse_guard = Arc::clone(&parse_lock).lock_owned().await; + + self.open_files + .write() + .insert(uri.clone(), Arc::clone(&text)); + // Track files opened with languageId "blade" so they get // Blade preprocessing even without a .blade.php extension. if doc.language_id == "blade" && !crate::blade::is_blade_file(&uri) { self.blade_uris.write().insert(uri.clone()); } - self.open_files - .write() - .insert(uri.clone(), Arc::clone(&text)); - // Parse and update AST map, use map, and namespace map self.update_ast(&uri, &text); + drop(parse_guard); + drop(parse_lock); // Opening a Blade template is the discrete point where its // call-site variable inference runs (update_ast itself only @@ -744,6 +759,15 @@ impl LanguageServer for Backend { // keystroke; `update_ast` already tolerates stale maps when // incomplete code cannot be parsed. if self.sync_ast_updates { + let _parse_guard = uri_parse_lock(self, &uri).lock_owned().await; + let is_latest_text = self + .open_files + .read() + .get(&uri) + .is_some_and(|current| Arc::ptr_eq(current, &text)); + if !is_latest_text { + return; + } self.update_ast(&uri, &text); self.schedule_diagnostics(uri.clone()); } else { @@ -751,16 +775,9 @@ impl LanguageServer for Backend { tokio::spawn(async move { let refresh_backend = backend.clone_for_blocking(); let uri_for_diagnostics = uri.clone(); + let parse_guard = uri_parse_lock(&backend, &uri).lock_owned().await; let result = tokio::task::spawn_blocking(move || { - let parse_lock = { - let mut locks = backend.did_change_parse_locks.lock(); - Arc::clone( - locks - .entry(uri.clone()) - .or_insert_with(|| Arc::new(parking_lot::Mutex::new(()))), - ) - }; - let _parse_guard = parse_lock.lock(); + let _parse_guard = parse_guard; let is_latest_text = backend .open_files .read() @@ -818,30 +835,99 @@ impl LanguageServer for Backend { async fn did_close(&self, params: DidCloseTextDocumentParams) { let uri = params.text_document.uri.to_string(); - self.open_files.write().remove(&uri); - self.did_change_parse_locks.lock().remove(&uri); - self.clear_declaration_baseline(&uri); + let parse_lock = uri_parse_lock(self, &uri); + // Remove the buffer before the first await so a later didOpen queues + // behind this close instead of being mistaken for the file we close. + let closed_buffer = self.open_files.write().remove(&uri); + let parse_guard = Arc::clone(&parse_lock).lock_owned().await; + let close_backend = self.clone_for_blocking(); + let close_uri = uri.clone(); + let close_applied = match tokio::task::spawn_blocking(move || { + // Serialise with didOpen and every didChange parse for this URI. + // A didChange handler may have published its buffer before its + // queued parse acquired the guard, so withdraw that buffer too. + let _parse_guard = parse_guard; + let closed_buffer = close_backend + .open_files + .write() + .remove(&close_uri) + .or(closed_buffer); + let disk_content = close_backend.get_file_content_arc(&close_uri); + let Some(disk_content) = disk_content else { + close_backend.clear_file_maps_and_source_strings(&close_uri); + return true; + }; + + let saved_state_is_current = closed_buffer + .as_deref() + .is_some_and(|buffer| buffer.as_str() == disk_content.as_str()) + && close_backend + .symbol_map_for(&close_uri) + .is_some_and(|map| map.matches_source(&disk_content)); + if !saved_state_is_current { + // Restore every index and Laravel side effect through the same + // pipeline as an ordinary edit. The loaded disk source remains + // authoritative while this guard is held. + close_backend.update_ast(&close_uri, &disk_content); + } + + // Queue names are type-dependent and may not have been requested + // while the file was open. Materialize them before dropping the + // disk map; direct Config/RateLimiter contributions were published + // by update_ast (or already match on the clean-close fast path). + if let Some(map) = close_backend.symbol_map_for(&close_uri) { + close_backend.typed_receiver_view_spans_for(&close_uri, &map); + } + close_backend.clear_file_maps(&close_uri); + true + }) + .await + { + Ok(applied) => applied, + Err(err) => { + tracing::error!("PHPantom: didClose source refresh failed: {}", err); + false + } + }; + if !close_applied { + return; + } - // Drop coalescing state for this file so the maps don't grow unbounded - // across an editing session. - let suffix = format!("\u{0}{uri}"); + // didOpen may have been waiting for the per-URI guard. In that case it + // owns a strong reference and/or has already republished the buffer, + // so keep both its lock entry and its freshly opened state. { + let open_files = self.open_files.read(); + if open_files.contains_key(&uri) { + return; + } + let mut locks = self.did_change_parse_locks.lock(); + let can_remove = locks.get(&uri).is_some_and(|stored| { + Arc::ptr_eq(stored, &parse_lock) && Arc::strong_count(&parse_lock) == 2 + }); + if can_remove { + locks.remove(&uri); + } + drop(locks); + self.clear_declaration_baseline(&uri); + + // Drop coalescing state for this file so the maps don't grow + // unbounded across an editing session. + let suffix = format!("\u{0}{uri}"); let coalesce = &self.whole_file_coalesce; coalesce.latest.lock().retain(|k, _| !k.ends_with(&suffix)); coalesce.locks.lock().retain(|k, _| !k.ends_with(&suffix)); coalesce.last.lock().retain(|k, _| !k.ends_with(&suffix)); - } - // Clean up Blade preprocessor state for the closed file. - if self.is_blade_file(&uri) { - self.blade_virtual_content.write().remove(&uri); - self.blade_source_maps.write().remove(&uri); - self.blade_uris.write().remove(&uri); - self.blade_injected_vars.write().remove(&uri); + // Clean up Blade preprocessor state for the closed file. + if self.is_blade_file(&uri) { + self.blade_virtual_content.write().remove(&uri); + self.blade_source_maps.write().remove(&uri); + self.blade_uris.write().remove(&uri); + self.blade_injected_vars.write().remove(&uri); + } } - self.clear_file_maps(&uri); - // Clear diagnostics so stale warnings don't linger after the file is closed self.clear_diagnostics_for_file(&uri).await; @@ -854,6 +940,7 @@ impl LanguageServer for Backend { if let Some(text) = params.text { let text = Arc::new(text); + let _parse_guard = uri_parse_lock(self, &uri).lock_owned().await; self.open_files .write() .insert(uri.clone(), Arc::clone(&text)); @@ -1726,6 +1813,40 @@ fn wrap_locations(locations: Vec) -> Option { mod tests { use super::*; + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn asynchronous_changes_publish_under_the_uri_parse_lock() { + let mut backend = Backend::new_test(); + backend.sync_ast_updates = false; + let url = Url::parse("file:///async-change.php").unwrap(); + let uri = url.to_string(); + backend + .open_files + .write() + .insert(uri.clone(), Arc::new("app->singleton('sentry', fn (): HubAdapter => new HubAdapter()); + } +} +"#; + let old = crate::virtual_members::laravel::extract_provider_resources( + content, + std::path::Path::new("/project/app/Providers/AppServiceProvider.php"), + std::path::Path::new("/project"), + Default::default(), + Default::default(), + ); + assert!(!old.bindings.is_empty()); + *backend.laravel_provider_resources.write() = old; + *backend.laravel_aliases.write() = Some(Default::default()); + + backend.publish_provider_resources(Default::default()); + + assert!(backend.laravel_aliases.read().is_none()); + } + + #[test] + fn provider_rate_limiters_use_semantic_facade_aliases() { + let backend = Backend::new_test(); + let uri = "file:///vendor/acme/src/AcmeServiceProvider.php"; + let content = r#" null); +"#; + let contribution = backend.provider_rate_limiter_contributions(uri, content); + backend + .laravel_source_strings + .write() + .set_provider_contributions(uri, contribution); + + let index = backend.laravel_source_strings.read(); + assert_eq!(index.rate_limiter_names(), ["vendor-api"]); + let definitions = index.rate_limiter_definitions("vendor-api"); + assert_eq!(definitions.len(), 1); + assert_eq!(definitions[0].uri.as_ref(), uri); + } + + #[test] + fn provider_rate_limiters_reject_application_homonyms_and_track_dynamic_names() { + let backend = Backend::new_test(); + let uri = "file:///app/Providers/AppServiceProvider.php"; + let local = r#" null); +"#; + let contribution = backend.provider_rate_limiter_contributions(uri, local); + backend + .laravel_source_strings + .write() + .set_provider_contributions(uri, contribution); + assert!( + backend + .laravel_source_strings + .read() + .rate_limiter_names() + .is_empty() + ); + + let dynamic = r#" null); +"#; + let contribution = backend.provider_rate_limiter_contributions(uri, dynamic); + backend + .laravel_source_strings + .write() + .set_provider_contributions(uri, contribution); + assert!( + backend + .laravel_source_strings + .read() + .rate_limiter_space_is_open() + ); + } } // ─── Self-scan helpers ────────────────────────────────────────────────────── @@ -2012,6 +2244,7 @@ impl Backend { let mut index = crate::virtual_members::laravel::LaravelMacroIndex::default(); let mut drivers = crate::virtual_members::laravel::LaravelStorageDriverIndex::default(); + let mut rate_limiters = Vec::new(); let mut candidate_uris: std::collections::HashSet = std::collections::HashSet::new(); let mut provider_uris: Vec = Vec::new(); @@ -2104,6 +2337,10 @@ impl Backend { seeds.insert(uri.clone(), Vec::new()); continue; }; + rate_limiters.push(( + uri.clone(), + self.provider_rate_limiter_contributions(uri, &content), + )); scan_content(&mut index, &mut mixin_uris, uri.clone(), &content); scan_storage_drivers(&mut drivers, uri, &content); @@ -2133,6 +2370,9 @@ impl Backend { drivers.rebuild(); self.store_laravel_storage_drivers(drivers); + self.laravel_source_strings + .write() + .replace_provider_contributions(rate_limiters); index.rebuild(); let has_macros = !index.is_empty(); @@ -2634,6 +2874,14 @@ impl Backend { if self.laravel_date_seed_uris.read().contains(uri) { self.build_laravel_date_class(); } + let is_registered_provider = !self.is_laravel_provider_list_uri(uri) + && self.laravel_macro_seeds.read().contains_key(uri); + if is_registered_provider { + let contribution = self.provider_rate_limiter_contributions(uri, content); + self.laravel_source_strings + .write() + .set_provider_contributions(uri, contribution); + } // A `Macroable::mixin()` registration pulls its macros from another // file and records that file as a dependency. Because those macros are // keyed under the registration site (not the mixin class) and the mixin @@ -2713,6 +2961,40 @@ impl Backend { .any(|rel| crate::util::path_to_uri(&root.join(rel)) == uri) } + /// Parse the rare registered-provider file that can define rate limiters. + /// The byte gate keeps ordinary provider scans allocation-free; matching + /// files use the same semantic name resolver as normal workspace parsing + /// so imported facade aliases work and application homonyms do not. + fn provider_rate_limiter_contributions( + &self, + uri: &str, + content: &str, + ) -> crate::laravel_string_index::LaravelSourceStringContributions { + const TOKEN: &[u8] = b"RateLimiter"; + if !content + .as_bytes() + .windows(TOKEN.len()) + .any(|window| window.eq_ignore_ascii_case(TOKEN)) + { + return Default::default(); + } + + crate::util::catch_panic_unwind_safe("provider_rate_limiters", uri, None, || { + let arena = mago_allocator::LocalArena::new(); + let file_id = mago_database::file::FileId::new(b"provider.php"); + let program = + mago_syntax::parser::parse_file_content(&arena, file_id, content.as_bytes()); + let resolver = mago_names::resolver::NameResolver::new(&arena); + let resolved = resolver.resolve(program); + let resolved = crate::names::OwnedResolvedNames::from_resolved(&resolved); + let map = crate::symbol_map::extract_symbol_map_with_resolved_names( + program, content, &resolved, + ); + crate::laravel_string_index::LaravelSourceStringContributions::from_symbol_map(&map) + }) + .unwrap_or_default() + } + pub(crate) fn build_provider_resources(&self) { let mut scans = crate::virtual_members::laravel::ProviderScans::default(); let mut seen: std::collections::HashSet = std::collections::HashSet::new(); @@ -2812,33 +3094,48 @@ impl Backend { &self, resources: crate::virtual_members::laravel::ProviderResources, ) { - let has_string_key_sources = resources.config_files.len() - + resources.view_dirs.len() - + resources.trans_dirs.len() - + resources.route_files.len() - + resources.class_component_namespaces.len() - > 0; - let has_bindings = !resources.bindings.is_empty(); - *self.laravel_provider_resources.write() = resources; + let has_string_key_sources = + |resources: &crate::virtual_members::laravel::ProviderResources| { + !resources.config_files.is_empty() + || !resources.view_dirs.is_empty() + || !resources.trans_dirs.is_empty() + || !resources.route_files.is_empty() + || !resources.class_component_namespaces.is_empty() + || !resources.anonymous_component_namespaces.is_empty() + || !resources.anonymous_component_paths.is_empty() + || resources.custom_translation_loader + }; + let (invalidate_string_keys, invalidate_aliases) = { + let mut current = self.laravel_provider_resources.write(); + let invalidate_string_keys = + has_string_key_sources(¤t) || has_string_key_sources(&resources); + let invalidate_aliases = !current.bindings.is_empty() || !resources.bindings.is_empty(); + *current = resources; + (invalidate_string_keys, invalidate_aliases) + }; // The shared and composed template variables are resolved from these // registrations, so the previous scan's set is stale whether or not // any other resource count moved. self.laravel_string_key_cache.write().shared_view_vars = None; - if has_string_key_sources { + if invalidate_string_keys { let mut cache = self.laravel_string_key_cache.write(); + cache.config_generation = cache.config_generation.wrapping_add(1); cache.config_keys = None; + cache.config_open_prefixes = None; cache.config_trees = None; cache.view_names = None; cache.trans_keys = None; + cache.trans_key_shapes = None; cache.routes = None; cache.blade_discovery = None; + cache.blade_blocks = None; } // The provider bindings overlay the core container alias table, which // an earlier resolution may already have built without them. - if has_bindings { + if invalidate_aliases { *self.laravel_aliases.write() = None; self.clear_class_not_found_cache(); } diff --git a/src/symbol_map/extraction/class_like.rs b/src/symbol_map/extraction/class_like.rs index e7bd5e322..df863cb5d 100644 --- a/src/symbol_map/extraction/class_like.rs +++ b/src/symbol_map/extraction/class_like.rs @@ -312,17 +312,36 @@ pub(super) fn extract_from_attribute_lists<'a>( // 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, + let semantic_class_name = ctx + .resolved_name_at(attr.name.span().start.offset) + .unwrap_or(class_name); + if let Some(attribute) = resolve_laravel_container_attr( + semantic_class_name, + ctx.resolved_names.is_none(), &mut ctx.has_laravel_container_attrs, ctx.content, ) { - try_emit_laravel_string_span_partial( - kind, - arg_list, - ctx.content, - &mut ctx.spans, - ); + match attribute { + LaravelContainerAttribute::Resource(trigger) => { + try_emit_laravel_config_resource_span_partial_for_parameter( + trigger.kind, + trigger.access, + arg_list, + trigger.argument, + ctx.content, + &mut ctx.spans, + ); + } + LaravelContainerAttribute::Config => { + try_emit_laravel_string_span_partial_for_parameter( + crate::symbol_map::LaravelStringKind::Config, + arg_list, + "key", + ctx.content, + &mut ctx.spans, + ); + } + } } // PHPUnit coverage attributes: #[CoversMethod(Foo::class, @@ -562,6 +581,8 @@ pub(super) fn extract_from_trait_precedence_adaptation<'a>( pub(super) fn extract_from_method<'a>(method: &'a Method<'a>, ctx: &mut ExtractionCtx<'a>) { // Method name — declaration site span for find-references and rename. let is_static = method.modifiers.iter().any(|m| m.is_static()); + let extracts_promoted_connection = + !is_static && method.name.value.eq_ignore_ascii_case(b"__construct"); ctx.spans.push(SymbolSpan { start: method.name.span.start.offset, end: method.name.span.end.offset, @@ -668,6 +689,10 @@ pub(super) fn extract_from_method<'a>(method: &'a Method<'a>, ctx: &mut Extracti let s = bytes_to_str(param.variable.name); s.strip_prefix('$').unwrap_or(s).to_string() }; + let promoted_connection = extracts_promoted_connection + && param.is_promoted_property() + && !param.modifiers.iter().any(|modifier| modifier.is_static()) + && name.eq_ignore_ascii_case("connection"); let param_offset = param.variable.span.start.offset; // Emit a Variable span so the symbol map covers the parameter // token itself (needed for GTD-from-parameter-to-type-hint). @@ -688,6 +713,14 @@ pub(super) fn extract_from_method<'a>(method: &'a Method<'a>, ctx: &mut Extracti block_end: ctx.cond_block_end_stack.last().copied().unwrap_or(u32::MAX), }); if let Some(ref default) = param.default_value { + if promoted_connection { + record_laravel_resource_receiver_expr( + crate::symbol_map::LaravelResourceReceiverRule::ConnectionProperty, + default.value, + ctx.content, + &mut ctx.resource_receiver_sites, + ); + } extract_from_expression(default.value, ctx, method_scope_start); } } @@ -743,6 +776,10 @@ pub(super) fn extract_inline_docblock( } pub(super) fn extract_from_property<'a>(property: &Property<'a>, ctx: &mut ExtractionCtx<'a>) { + let property_is_static = property + .modifiers() + .iter() + .any(|modifier| modifier.is_static()); match property { Property::Plain(plain) => extract_from_attribute_lists(&plain.attribute_lists, ctx, 0), Property::Hooked(hooked) => extract_from_attribute_lists(&hooked.attribute_lists, ctx, 0), @@ -770,6 +807,7 @@ pub(super) fn extract_from_property<'a>(property: &Property<'a>, ctx: &mut Extra s.strip_prefix('$').unwrap_or(s).to_string() }; let var_offset = var.span.start.offset; + let is_connection = !property_is_static && name.eq_ignore_ascii_case("connection"); ctx.spans.push(SymbolSpan { start: var_offset, end: var.span.end.offset, @@ -790,6 +828,14 @@ pub(super) fn extract_from_property<'a>(property: &Property<'a>, ctx: &mut Extra // references like `Foo::class` in property defaults // produce navigable spans. if let PropertyItem::Concrete(concrete) = item { + if is_connection { + record_laravel_resource_receiver_expr( + crate::symbol_map::LaravelResourceReceiverRule::ConnectionProperty, + concrete.value, + ctx.content, + &mut ctx.resource_receiver_sites, + ); + } extract_from_expression(concrete.value, ctx, 0); } } @@ -801,6 +847,7 @@ pub(super) fn extract_from_property<'a>(property: &Property<'a>, ctx: &mut Extra s.strip_prefix('$').unwrap_or(s).to_string() }; let var_offset = var.span.start.offset; + let is_connection = !property_is_static && name.eq_ignore_ascii_case("connection"); ctx.spans.push(SymbolSpan { start: var_offset, end: var.span.end.offset, @@ -818,6 +865,14 @@ pub(super) fn extract_from_property<'a>(property: &Property<'a>, ctx: &mut Extra block_end: ctx.cond_block_end_stack.last().copied().unwrap_or(u32::MAX), }); if let PropertyItem::Concrete(concrete) = &hooked.item { + if is_connection { + record_laravel_resource_receiver_expr( + crate::symbol_map::LaravelResourceReceiverRule::ConnectionProperty, + concrete.value, + ctx.content, + &mut ctx.resource_receiver_sites, + ); + } extract_from_expression(concrete.value, ctx, 0); } } diff --git a/src/symbol_map/extraction/expressions/calls.rs b/src/symbol_map/extraction/expressions/calls.rs index b2cd68e2c..52a4b3e4c 100644 --- a/src/symbol_map/extraction/expressions/calls.rs +++ b/src/symbol_map/extraction/expressions/calls.rs @@ -54,11 +54,34 @@ pub(super) fn extract_instantiation_expr<'a>( // `Content` is too plain a class name to key on alone, so the short // spelling is only read inside a mailable. let clean_class = strip_fqn_prefix(&class_text); + let semantic_class = ctx + .resolved_name_at(inst.class.span().start.offset) + .map(strip_fqn_prefix) + .unwrap_or(clean_class); if clean_class.eq_ignore_ascii_case("Illuminate\\Mail\\Mailables\\Content") || (ctx.in_mailable && clean_class.eq_ignore_ascii_case("Content")) { try_emit_mailable_content_view_spans(args, ctx.content, &mut ctx.spans); } + if matches_laravel_class( + semantic_class, + "Illuminate\\Queue\\Middleware", + "RateLimited", + ) || matches_laravel_class( + semantic_class, + "Illuminate\\Queue\\Middleware", + "RateLimitedWithRedis", + ) { + try_emit_laravel_string_span_with_access_for_parameter( + crate::symbol_map::LaravelStringKind::RateLimiter, + false, + false, + args, + "limiterName", + ctx.content, + &mut ctx.spans, + ); + } if !class_text.is_empty() { emit_call_site( format!("new {}", class_text), @@ -179,6 +202,19 @@ fn extract_call<'a>( &mut ctx.spans, ); } + if let Some(trigger) = + crate::symbol_map::laravel_resources::function_trigger(name_clean) + { + try_emit_laravel_config_resource_span_for_parameter( + trigger.kind, + trigger.shape, + trigger.access, + &func_call.argument_list, + trigger.argument, + ctx.content, + &mut ctx.spans, + ); + } // The Blade preprocessor lowers `@can`/`@cannot`/`@canany` // to this call so the ability string is extracted here // like any other authorization check. @@ -232,6 +268,7 @@ fn extract_call<'a>( &mut ctx.spans, ); } + record_typed_laravel_resource_call(&member_name, &method_call.argument_list, ctx); // `$this->app->singleton('payments', …)` registers a container // key and `app()->make('payments')` asks for one. Gated on the // receiver: `make` and `bind` say nothing on their own. @@ -413,6 +450,7 @@ fn extract_call<'a>( &mut ctx.spans, ); } + record_typed_laravel_resource_call(&member_name, &method_call.argument_list, ctx); // Use `->` so resolve_callable handles it the same // as regular method calls. emit_call_site( @@ -482,8 +520,11 @@ fn extract_call<'a>( }, }); let clean_subject = strip_fqn_prefix(&subject_text); - if (clean_subject.eq_ignore_ascii_case("Config") - || clean_subject.eq_ignore_ascii_case("Illuminate\\Support\\Facades\\Config")) + let semantic_subject = ctx + .resolved_name_at(class_span.start.offset) + .map(strip_fqn_prefix) + .unwrap_or(clean_subject); + if matches_laravel_facade(semantic_subject, "Config") && is_config_repository_method(&member_name) { try_emit_laravel_config_key_span( @@ -493,6 +534,38 @@ fn extract_call<'a>( &mut ctx.spans, ); } + if let Some(trigger) = crate::symbol_map::laravel_resources::static_method_trigger( + semantic_subject, + &member_name, + ) { + try_emit_laravel_config_resource_span_for_parameter( + trigger.kind, + trigger.shape, + trigger.access, + &static_call.argument_list, + trigger.argument, + ctx.content, + &mut ctx.spans, + ); + } + if matches_laravel_facade(semantic_subject, "RateLimiter") + && member_name.eq_ignore_ascii_case("for") + { + match argument_expr_for_parameter(&static_call.argument_list, 0, "name") { + Some(argument @ Expression::Literal(literal::Literal::String(_))) => { + push_laravel_string_span( + crate::symbol_map::LaravelStringKind::RateLimiter, + true, + false, + argument, + ctx.content, + &mut ctx.spans, + ); + } + Some(_) => ctx.has_dynamic_rate_limiter = true, + None => {} + } + } // The `View` facade proxies the view factory, so every // factory method that names a template does so here too. if clean_subject.eq_ignore_ascii_case("View") @@ -580,7 +653,7 @@ fn extract_call<'a>( // parameter of a route registration. if is_gate_facade(clean_subject) { emit_gate_facade_ability_spans(&member_name, &static_call.argument_list, ctx); - } else if clean_subject.eq_ignore_ascii_case("Route") + } else if matches_laravel_facade(semantic_subject, "Route") && member_name.eq_ignore_ascii_case("middleware") { try_emit_can_middleware_spans( @@ -639,6 +712,49 @@ fn extract_call<'a>( // ─── Authorization gate abilities ─────────────────────────────────────────── +/// Record a resource name whose family depends on the instance receiver's +/// resolved type. Ordinary and null-safe calls share this hot name check. +fn record_typed_laravel_resource_call<'a>( + member_name: &str, + argument_list: &'a ArgumentList<'a>, + ctx: &mut ExtractionCtx<'a>, +) { + let resource_rule = if member_name.eq_ignore_ascii_case("connection") { + Some(( + crate::symbol_map::LaravelResourceReceiverRule::ConnectionMethod, + "connection", + Some("name"), + )) + } else if let Some(trigger) = + crate::symbol_map::laravel_resources::instance_method_trigger(member_name) + && trigger.kind == crate::symbol_map::LaravelConfigResource::QueueConnection + { + Some(( + crate::symbol_map::LaravelResourceReceiverRule::QueueableConnection, + trigger.argument, + None, + )) + } else if member_name.eq_ignore_ascii_case("onQueue") { + Some(( + crate::symbol_map::LaravelResourceReceiverRule::QueueName, + "queue", + None, + )) + } else { + None + }; + if let Some((rule, parameter, alternate_parameter)) = resource_rule { + record_laravel_resource_receiver_site( + rule, + argument_list, + parameter, + alternate_parameter, + ctx.content, + &mut ctx.resource_receiver_sites, + ); + } +} + /// Emit ability spans for a `Gate::(…)` static call. /// /// `define()` declares the ability it names, so its span is marked as a write @@ -741,14 +857,16 @@ fn emit_gate_ability_spans_for_method<'a>( return; } - if is_middleware && (receiver_is_this || chain_roots_at_route_facade(object)) { + if is_middleware + && (receiver_is_this || chain_roots_at_route_facade(object, ctx.resolved_names)) + { try_emit_can_middleware_spans(argument_list, ctx.content, &mut ctx.spans); return; } // A route registration's `->can('update', 'post')` names a route // parameter, not a model, in its second argument. - if is_can && chain_roots_at_route_facade(object) { + if is_can && chain_roots_at_route_facade(object, ctx.resolved_names) { try_emit_gate_ability_spans( argument_list, 0, @@ -764,9 +882,9 @@ fn emit_gate_ability_spans_for_method<'a>( let is_check = if is_can { receiver_is_user_like(object) } else if member_name.eq_ignore_ascii_case("authorize") { - receiver_is_this || chain_roots_at_gate(object) + receiver_is_this || chain_roots_at_gate(object, ctx.resolved_names) } else { - chain_roots_at_gate(object) + chain_roots_at_gate(object, ctx.resolved_names) }; if !is_check { return; diff --git a/src/symbol_map/extraction/laravel.rs b/src/symbol_map/extraction/laravel.rs index ad5731299..f62f38dd5 100644 --- a/src/symbol_map/extraction/laravel.rs +++ b/src/symbol_map/extraction/laravel.rs @@ -6,23 +6,38 @@ use super::*; /// Namespace prefix for Laravel's container-injection attributes. pub(super) const LARAVEL_CONTAINER_ATTR_NS: &str = "Illuminate\\Container\\Attributes\\"; -/// Short (non-FQN) names of the container-injection attributes. -pub(super) const LARAVEL_CONTAINER_ATTR_NAMES: &[&str] = &[ - "Config", - "Database", - "DB", - "Cache", - "Log", - "Storage", - "Auth", - "Authenticated", -]; + +/// Whether a semantically resolved class is either the global short name or +/// the exact class inside `namespace`. The global spelling preserves support +/// for Laravel's runtime aliases; any other namespace is rejected. +pub(super) fn matches_laravel_class(class_name: &str, namespace: &str, short: &str) -> bool { + let class_name = class_name.trim_start_matches('\\'); + if class_name.eq_ignore_ascii_case(short) { + return true; + } + class_name + .rsplit_once('\\') + .is_some_and(|(actual_namespace, actual_short)| { + actual_namespace.eq_ignore_ascii_case(namespace) + && actual_short.eq_ignore_ascii_case(short) + }) +} + +/// Whether a semantically resolved class names one Laravel facade. +pub(super) fn matches_laravel_facade(class_name: &str, short: &str) -> bool { + matches_laravel_class(class_name, "Illuminate\\Support\\Facades", short) +} + +/// Laravel-specific payload carried by a contextual attribute. +pub(super) enum LaravelContainerAttribute { + Config, + Resource(crate::symbol_map::laravel_resources::ResourceTriggerMatch), +} /// 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. +/// `Authenticated`). Returns the corresponding generic or resource-specific +/// [`LaravelContainerAttribute`] payload. /// /// FQN names (containing `\`) are matched directly against /// `Illuminate\Container\Attributes\*`. Short names require the file to @@ -30,26 +45,34 @@ pub(super) const LARAVEL_CONTAINER_ATTR_NAMES: &[&str] = &[ /// `import_cache` to avoid repeated linear scans of the file content. pub(super) fn resolve_laravel_container_attr( class_name: &str, + allow_short_import_heuristic: bool, 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); +) -> Option { + let short = if class_name.contains('\\') { + let class_name = class_name.trim_start_matches('\\'); + let prefix = class_name.get(..LARAVEL_CONTAINER_ATTR_NS.len())?; + if !prefix.eq_ignore_ascii_case(LARAVEL_CONTAINER_ATTR_NS) { + return None; } - 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) + class_name.get(LARAVEL_CONTAINER_ATTR_NS.len()..)? } else { - None + if !allow_short_import_heuristic { + return None; + } + let has_import = *import_cache + .get_or_insert_with(|| content.contains("use Illuminate\\Container\\Attributes\\")); + if !has_import { + return None; + } + class_name + }; + + if short.eq_ignore_ascii_case("Config") { + return Some(LaravelContainerAttribute::Config); } + crate::symbol_map::laravel_resources::attribute_trigger(short) + .map(LaravelContainerAttribute::Resource) } /// If the first argument of `argument_list` is a non-empty, non-interpolated @@ -69,6 +92,23 @@ pub(super) fn try_emit_laravel_string_span( emit_laravel_string_span(kind, false, 0, argument_list, content, spans); } +/// Emit a literal selected by its PHP parameter name, falling back to its +/// positional slot when the call does not use named arguments. +pub(super) fn try_emit_laravel_string_span_with_access_for_parameter( + kind: crate::symbol_map::LaravelStringKind, + is_write: bool, + is_optional: bool, + argument_list: &ArgumentList<'_>, + parameter: &str, + content: &str, + spans: &mut Vec, +) { + let Some(expr) = argument_expr_for_parameter(argument_list, 0, parameter) else { + return; + }; + push_laravel_string_span(kind, is_write, is_optional, expr, content, spans); +} + /// The [`try_emit_laravel_string_span`] variant for a helper that does not /// lead with its key: `Route::view('/about', 'pages.about')` names the URI /// first and the template second. @@ -82,6 +122,97 @@ pub(super) fn try_emit_laravel_string_span_at( emit_laravel_string_span(kind, false, index, argument_list, content, spans); } +/// Emit a config-backed resource name from a parameter selected by name or +/// positional slot. +pub(super) fn try_emit_laravel_config_resource_span_for_parameter( + resource: crate::symbol_map::LaravelConfigResource, + shape: crate::symbol_map::laravel_resources::ResourceArgumentShape, + access: crate::symbol_map::laravel_resources::ResourceAccess, + argument_list: &ArgumentList<'_>, + parameter: &str, + content: &str, + spans: &mut Vec, +) { + let Some(expr) = argument_expr_for_parameter(argument_list, 0, parameter) else { + return; + }; + let elements = match expr { + Expression::Array(array) if shape.accepts_array() => Some(&array.elements), + Expression::LegacyArray(array) if shape.accepts_array() => Some(&array.elements), + _ => None, + }; + + if let Some(elements) = elements { + for element in elements.iter() { + let value = match element { + ArrayElement::KeyValue(value) => value.value, + ArrayElement::Value(value) => value.value, + ArrayElement::Variadic(_) | ArrayElement::Missing(_) => continue, + }; + push_laravel_string_span( + crate::symbol_map::LaravelStringKind::ConfigResource(resource), + access.is_write(), + access.is_optional(), + value, + content, + spans, + ); + } + } else if shape.accepts_scalar() { + push_laravel_string_span( + crate::symbol_map::LaravelStringKind::ConfigResource(resource), + access.is_write(), + access.is_optional(), + expr, + content, + spans, + ); + } +} + +/// Record a type-dependent Laravel resource name from a call's first argument. +pub(super) fn record_laravel_resource_receiver_site( + rule: crate::symbol_map::LaravelResourceReceiverRule, + argument_list: &ArgumentList<'_>, + parameter: &str, + alternate_parameter: Option<&str>, + content: &str, + sites: &mut Vec, +) { + let expr = argument_expr_for_parameter(argument_list, 0, parameter).or_else(|| { + alternate_parameter + .and_then(|alternate| argument_expr_for_parameter(argument_list, 0, alternate)) + }); + let Some(expr) = expr else { + return; + }; + record_laravel_resource_receiver_expr(rule, expr, content, sites); +} + +/// Record a type-dependent Laravel resource name from one literal expression. +pub(super) fn record_laravel_resource_receiver_expr( + rule: crate::symbol_map::LaravelResourceReceiverRule, + expr: &Expression<'_>, + content: &str, + sites: &mut Vec, +) { + let Expression::Literal(literal::Literal::String(string)) = expr else { + return; + }; + let start = string.span.start.offset + 1; + let end = string.span.end.offset - 1; + if start >= end || end as usize > content.len() { + return; + } + let key = &content[start as usize..end as usize]; + sites.push(crate::symbol_map::LaravelResourceReceiverSite { + start, + end, + key: key.to_string(), + rule, + }); +} + /// Emit the section- or stack-name span for one of the marker calls the /// Blade preprocessor lowers `@yield`, `@section`, `@stack`, `@push` and /// their helpers to, when `name` is one of them. @@ -123,11 +254,12 @@ pub(super) fn try_emit_laravel_config_key_span( content: &str, spans: &mut Vec, ) { - emit_laravel_string_span( + try_emit_laravel_string_span_with_access_for_parameter( crate::symbol_map::LaravelStringKind::Config, member_name.eq_ignore_ascii_case("set"), - 0, + false, argument_list, + "key", content, spans, ); @@ -148,7 +280,7 @@ fn emit_laravel_string_span( } /// Emit the span for one string-literal expression that names a key. -fn push_laravel_string_span( +pub(super) fn push_laravel_string_span( kind: crate::symbol_map::LaravelStringKind, is_write: bool, is_optional: bool, @@ -190,11 +322,13 @@ fn push_laravel_string_span( inner_end = inner_start + name_len as u32; } + let key = normalised_key(kind, key); + spans.push(SymbolSpan { start: inner_start, end: inner_end, kind: SymbolKind::LaravelStringKey { - key: normalised_key(kind.clone(), key), + key, kind, is_write, is_optional, @@ -229,7 +363,7 @@ pub(super) fn try_emit_laravel_view_span_at( // working as intended rather than a typo. for element in elements.iter() { if let ArrayElement::Value(value) = element { - push_laravel_string_span(kind.clone(), false, true, value.value, content, spans); + push_laravel_string_span(kind, false, true, value.value, content, spans); } } } @@ -715,8 +849,11 @@ const FACADE_CHAIN_DEPTH: usize = 4; /// Whether an instance-method chain roots at the `Gate` facade, as /// `Gate::forUser($user)->allows('update', $post)` does. -pub(super) fn chain_roots_at_gate(expr: &Expression<'_>) -> bool { - chain_roots_at_facade(expr, FACADE_CHAIN_DEPTH, &|name| is_gate_facade(name)) +pub(super) fn chain_roots_at_gate( + expr: &Expression<'_>, + resolved_names: Option<&crate::names::OwnedResolvedNames>, +) -> bool { + chain_roots_at_facade(expr, FACADE_CHAIN_DEPTH, resolved_names, "Gate") } /// Whether an instance-method chain roots at the `Route` facade, as @@ -724,28 +861,36 @@ pub(super) fn chain_roots_at_gate(expr: &Expression<'_>) -> bool { /// /// The `can()` there names an ability, but its second argument is a *route /// parameter* name rather than a model, so no model subject is recorded. -pub(super) fn chain_roots_at_route_facade(expr: &Expression<'_>) -> bool { - chain_roots_at_facade(expr, FACADE_CHAIN_DEPTH, &|name| { - name.eq_ignore_ascii_case("Route") - }) +pub(super) fn chain_roots_at_route_facade( + expr: &Expression<'_>, + resolved_names: Option<&crate::names::OwnedResolvedNames>, +) -> bool { + chain_roots_at_facade(expr, FACADE_CHAIN_DEPTH, resolved_names, "Route") } /// Walk at most `depth` links down a method chain looking for a static call -/// whose class satisfies `is_facade`. +/// whose semantically resolved class names `facade`. fn chain_roots_at_facade( expr: &Expression<'_>, depth: usize, - is_facade: &dyn Fn(&str) -> bool, + resolved_names: Option<&crate::names::OwnedResolvedNames>, + facade: &str, ) -> bool { if depth == 0 { return false; } match expr { Expression::Call(Call::Method(mc)) => { - chain_roots_at_facade(mc.object, depth - 1, is_facade) + chain_roots_at_facade(mc.object, depth - 1, resolved_names, facade) } Expression::Call(Call::StaticMethod(sc)) => { - is_facade(strip_fqn_prefix(&expr_to_subject_text(sc.class))) + if let Some(resolved_names) = resolved_names { + resolved_names + .get(sc.class.span().start.offset) + .is_some_and(|name| matches_laravel_facade(name, facade)) + } else { + matches_laravel_facade(strip_fqn_prefix(&expr_to_subject_text(sc.class)), facade) + } } _ => false, } @@ -921,39 +1066,48 @@ fn gate_model_subject( } } -/// Emit gate-ability spans for the `can:ability,Model` entries of a -/// `middleware(...)` argument, which Laravel accepts as a single string or an -/// array of them. +/// Emit navigable parameters embedded in Laravel middleware strings. +/// +/// Handles `can:ability,Model`, `auth:guard[,guard]`, and a named +/// `throttle:limiter`; Laravel accepts each as one string or an array value. pub(super) fn try_emit_can_middleware_spans( argument_list: &ArgumentList<'_>, content: &str, spans: &mut Vec, ) { - let Some(first_arg) = argument_list.arguments.iter().next() else { + let Some(middleware) = argument_expr_for_parameter(argument_list, 0, "middleware") else { return; }; - match first_arg.value() { + match middleware { Expression::Array(array) => { for element in array.elements.iter() { - if let ArrayElement::Value(value) = element { - push_can_middleware_span(value.value, content, spans); - } + let value = match element { + ArrayElement::KeyValue(value) => value.value, + ArrayElement::Value(value) => value.value, + ArrayElement::Variadic(_) | ArrayElement::Missing(_) => continue, + }; + push_middleware_parameter_spans(value, content, spans); } } Expression::LegacyArray(array) => { for element in array.elements.iter() { - if let ArrayElement::Value(value) = element { - push_can_middleware_span(value.value, content, spans); - } + let value = match element { + ArrayElement::KeyValue(value) => value.value, + ArrayElement::Value(value) => value.value, + ArrayElement::Variadic(_) | ArrayElement::Missing(_) => continue, + }; + push_middleware_parameter_spans(value, content, spans); } } - expr => push_can_middleware_span(expr, content, spans), + expr => push_middleware_parameter_spans(expr, content, spans), } } -/// Push a gate-ability span covering just the ability part of a -/// `'can:update,post'` middleware string. -fn push_can_middleware_span(expr: &Expression<'_>, content: &str, spans: &mut Vec) { +fn push_middleware_parameter_spans( + expr: &Expression<'_>, + content: &str, + spans: &mut Vec, +) { let Expression::Literal(literal::Literal::String(s)) = expr else { return; }; @@ -963,22 +1117,69 @@ fn push_can_middleware_span(expr: &Expression<'_>, content: &str, spans: &mut Ve return; } let text = &content[inner_start as usize..inner_end as usize]; - let Some(rest) = text.strip_prefix("can:") else { + let Some((alias, parameters)) = text.split_once(':') else { return; }; - let ability = rest.split(',').next().unwrap_or(rest); - if ability.is_empty() { + let parameter_start = inner_start + alias.len() as u32 + 1; + + if alias.eq_ignore_ascii_case("can") { + let ability = parameters.split(',').next().unwrap_or(parameters); + push_embedded_string_span( + crate::symbol_map::LaravelStringKind::GateAbility, + ability, + parameter_start, + false, + spans, + ); + } else if alias.eq_ignore_ascii_case("auth") { + let mut offset = parameter_start; + for guard in parameters.split(',') { + push_embedded_string_span( + crate::symbol_map::LaravelStringKind::ConfigResource( + crate::symbol_map::LaravelConfigResource::AuthGuard, + ), + guard, + offset, + false, + spans, + ); + offset += guard.len() as u32 + 1; + } + } else if alias.eq_ignore_ascii_case("throttle") { + // A comma supplies the legacy decay argument. A lone numeric value is + // ambiguous: it can be an inline limit or a registered limiter name, + // so keep it navigable without diagnosing it. Other single values, + // including digit-prefixed names such as `2fa`, are ordinary names. + if !parameters.contains(',') { + push_embedded_string_span( + crate::symbol_map::LaravelStringKind::RateLimiter, + parameters, + parameter_start, + parameters.as_bytes().iter().all(u8::is_ascii_digit), + spans, + ); + } + } +} + +fn push_embedded_string_span( + kind: crate::symbol_map::LaravelStringKind, + key: &str, + start: u32, + is_optional: bool, + spans: &mut Vec, +) { + if key.is_empty() { return; } - let start = inner_start + "can:".len() as u32; spans.push(SymbolSpan { start, - end: start + ability.len() as u32, + end: start + key.len() as u32, kind: SymbolKind::LaravelStringKey { - kind: crate::symbol_map::LaravelStringKind::GateAbility, - key: ability.to_string(), + kind, + key: key.to_string(), is_write: false, - is_optional: false, + is_optional, }, }); } @@ -1018,24 +1219,17 @@ pub(super) fn try_emit_command_own_param_span( }); } -/// 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`. -/// -/// Called by the `config()` function-call extractor and the -/// `Config::get()` / `Config::set()` static-call extractor so that -/// find-references and go-to-definition for Laravel config keys can use -/// the pre-built symbol map instead of re-parsing every file on demand. -pub(super) fn try_emit_laravel_string_span_partial( +/// Partial-argument-list counterpart of named parameter selection. +pub(super) fn try_emit_laravel_string_span_partial_for_parameter( kind: crate::symbol_map::LaravelStringKind, argument_list: &PartialArgumentList<'_>, + parameter: &str, content: &str, spans: &mut Vec, ) { - let Some(first_arg) = argument_list.arguments.iter().next() else { - return; - }; - let Some(Expression::Literal(literal::Literal::String(s))) = first_arg.value() else { + let Some(Expression::Literal(literal::Literal::String(s))) = + partial_argument_expr_for_parameter(argument_list, 0, parameter) + else { return; }; let inner_start = s.span.start.offset + 1; @@ -1057,7 +1251,7 @@ pub(super) fn try_emit_laravel_string_span_partial( start: inner_start, end: inner_end, kind: SymbolKind::LaravelStringKey { - key: normalised_key(kind.clone(), key), + key: normalised_key(kind, key), kind, is_write: false, is_optional: false, @@ -1065,6 +1259,76 @@ pub(super) fn try_emit_laravel_string_span_partial( }); } +/// Attribute counterpart of named parameter selection for config resources. +pub(super) fn try_emit_laravel_config_resource_span_partial_for_parameter( + resource: crate::symbol_map::LaravelConfigResource, + access: crate::symbol_map::laravel_resources::ResourceAccess, + argument_list: &PartialArgumentList<'_>, + parameter: &str, + content: &str, + spans: &mut Vec, +) { + let Some(expr) = partial_argument_expr_for_parameter(argument_list, 0, parameter) else { + return; + }; + push_laravel_string_span( + crate::symbol_map::LaravelStringKind::ConfigResource(resource), + access.is_write(), + access.is_optional(), + expr, + content, + spans, + ); +} + +/// Select a call argument by its declared parameter name, or by positional +/// index when the call uses positional syntax. +pub(super) fn argument_expr_for_parameter<'a>( + argument_list: &ArgumentList<'a>, + positional_index: usize, + parameter: &str, +) -> Option<&'a Expression<'a>> { + for argument in argument_list.arguments.iter() { + if let Argument::Named(named) = argument + && bytes_to_str(named.name.value).eq_ignore_ascii_case(parameter) + { + return Some(named.value); + } + } + + argument_list + .arguments + .iter() + .filter_map(|argument| match argument { + Argument::Positional(positional) => Some(positional.value), + Argument::Named(_) => None, + }) + .nth(positional_index) +} + +fn partial_argument_expr_for_parameter<'a>( + argument_list: &PartialArgumentList<'a>, + positional_index: usize, + parameter: &str, +) -> Option<&'a Expression<'a>> { + for argument in argument_list.arguments.iter() { + if let PartialArgument::Named(named) = argument + && bytes_to_str(named.name.value).eq_ignore_ascii_case(parameter) + { + return Some(named.value); + } + } + + argument_list + .arguments + .iter() + .filter_map(|argument| match argument { + PartialArgument::Positional(positional) => Some(positional.value), + _ => None, + }) + .nth(positional_index) +} + /// If `argument_list` starts with a plain, non-empty string literal, push a /// [`SymbolKind::LaravelMacroString`] span covering the string content. pub(super) fn try_emit_laravel_macro_string_span( @@ -1539,3 +1803,104 @@ pub(super) fn laravel_route_scan_expr( _ => {} } } + +#[cfg(test)] +mod tests { + use super::*; + + fn extract(content: &str) -> crate::symbol_map::SymbolMap { + let arena = mago_allocator::LocalArena::new(); + let file_id = mago_database::file::FileId::new(b"laravel-extraction.php"); + let program = mago_syntax::parser::parse_file_content(&arena, file_id, content.as_bytes()); + super::super::extract_symbol_map(program, content) + } + + #[test] + fn short_container_attributes_require_the_laravel_import() { + let mut import_cache = None; + assert!( + resolve_laravel_container_attr( + "Storage", + true, + &mut import_cache, + " $this->connection; + } +} + +$dynamic = 'runtime'; +$job->onQueue($dynamic); +Storage::disk(foo: 'ignored'); + +$spread = []; +Route::middleware([ + 'primary' => 'auth:web', + ...$spread, +]); +Route::middleware(array( + 'primary' => 'auth:admin', + ...$spread, +)); +Route::middleware('throttle:60,1'); +"#; + let map = extract(content); + + assert!(map.resource_receiver_sites.iter().any(|site| { + site.key == "redis" + && site.rule == crate::symbol_map::LaravelResourceReceiverRule::ConnectionProperty + })); + assert!( + map.resource_receiver_sites + .iter() + .all(|site| site.key != "runtime") + ); + + let laravel_keys: Vec<_> = map + .spans + .iter() + .filter_map(|span| match &span.kind { + SymbolKind::LaravelStringKey { key, kind, .. } => Some((key.as_str(), *kind)), + _ => None, + }) + .collect(); + assert!(laravel_keys.contains(&( + "archive", + crate::symbol_map::LaravelStringKind::ConfigResource( + crate::symbol_map::LaravelConfigResource::StorageDisk, + ), + ))); + assert!(laravel_keys.contains(&( + "web", + crate::symbol_map::LaravelStringKind::ConfigResource( + crate::symbol_map::LaravelConfigResource::AuthGuard, + ), + ))); + assert!(laravel_keys.contains(&( + "admin", + crate::symbol_map::LaravelStringKind::ConfigResource( + crate::symbol_map::LaravelConfigResource::AuthGuard, + ), + ))); + assert!(laravel_keys.iter().all(|(key, _)| *key != "60")); + } +} diff --git a/src/symbol_map/extraction/mod.rs b/src/symbol_map/extraction/mod.rs index 0621a2a46..9f01fd8a7 100644 --- a/src/symbol_map/extraction/mod.rs +++ b/src/symbol_map/extraction/mod.rs @@ -12,11 +12,12 @@ use super::docblock::{ extract_docblock_symbols_covering, get_docblock_text_with_offset, is_navigable_type, }; use super::{ - CallSite, ClassRefContext, DocblockMemberRef, SelfStaticParentKind, SubjectText, SymbolKind, - SymbolMap, SymbolSpan, TemplateParamDef, UntypedClosureSite, VarDefKind, VarDefSite, - ViewReceiverClass, ViewReceiverSite, + CallSite, ClassRefContext, DocblockMemberRef, LaravelResourceReceiverSite, + SelfStaticParentKind, SubjectText, SymbolKind, SymbolMap, SymbolSpan, TemplateParamDef, + UntypedClosureSite, VarDefKind, VarDefSite, ViewReceiverClass, ViewReceiverSite, }; use crate::atom::{bytes_to_str, literal_bytes_to_str}; +use crate::names::OwnedResolvedNames; use crate::util::strip_fqn_prefix; // ─── Extraction context ───────────────────────────────────────────────────── @@ -70,14 +71,25 @@ struct ExtractionCtx<'a> { trivias: &'a [Trivia<'a>], /// The full source text of the file being extracted. content: &'a str, + /// Semantic names resolved by `mago-names` for this file. Production + /// indexing supplies this so Laravel-specific syntax never guesses from + /// an alias or a namespace-local homonym. Syntax-only unit tests leave it + /// absent and retain the legacy textual fallback. + resolved_names: Option<&'a OwnedResolvedNames>, /// Closures and arrow functions passed as arguments to callable-typed /// parameters, used by inlay hints. untyped_closure_sites: Vec, /// Render sites whose receiver only a type settles, left for /// `Backend::typed_receiver_view_spans` to confirm. view_receiver_sites: Vec, + /// Resource-name sites whose receiver/enclosing class is type-dependent. + resource_receiver_sites: Vec, /// The model argument of each authorization check that named one. gate_subjects: Vec, + /// Whether this file registers a rate limiter under a name that cannot be + /// recovered statically. One such registration makes the known-name set + /// open, so unknown-name diagnostics must stand down project-wide. + has_dynamic_rate_limiter: bool, /// Current conditional nesting depth (if/else, switch, while, for, etc.). /// Incremented when entering a conditional block, decremented when leaving. cond_nesting_depth: u16, @@ -106,6 +118,14 @@ struct ExtractionCtx<'a> { covers_default_class: Option, } +impl<'a> ExtractionCtx<'a> { + /// Return the semantic name attached to the identifier beginning at + /// `offset`, when the production name-resolution pass supplied one. + fn resolved_name_at(&self, offset: u32) -> Option<&'a str> { + self.resolved_names.and_then(|names| names.get(offset)) + } +} + mod class_like; mod expressions; mod keywords; @@ -145,7 +165,29 @@ fn descend_unhandled<'a>(node: Node<'a, 'a>, ctx: &mut ExtractionCtx<'a>, scope_ /// /// Walks every statement recursively and emits [`SymbolSpan`] entries for /// every navigable symbol occurrence. +#[cfg(test)] pub(crate) fn extract_symbol_map(program: &Program<'_>, content: &str) -> SymbolMap { + extract_symbol_map_inner(program, content, None) +} + +/// Build a [`SymbolMap`] using the semantic names produced by `mago-names`. +/// +/// Laravel facade, middleware, and contextual-attribute extraction uses these +/// names to recognise imports and aliases without mistaking a same-named +/// application class for a framework class. +pub(crate) fn extract_symbol_map_with_resolved_names( + program: &Program<'_>, + content: &str, + resolved_names: &OwnedResolvedNames, +) -> SymbolMap { + extract_symbol_map_inner(program, content, Some(resolved_names)) +} + +fn extract_symbol_map_inner( + program: &Program<'_>, + content: &str, + resolved_names: Option<&OwnedResolvedNames>, +) -> SymbolMap { let mut ctx = ExtractionCtx { spans: Vec::new(), var_defs: Vec::new(), @@ -163,9 +205,12 @@ pub(crate) fn extract_symbol_map(program: &Program<'_>, content: &str) -> Symbol instance_method_scopes: Vec::new(), trivias: program.trivia.as_slice(), content, + resolved_names, untyped_closure_sites: Vec::new(), view_receiver_sites: Vec::new(), + resource_receiver_sites: Vec::new(), gate_subjects: Vec::new(), + has_dynamic_rate_limiter: false, cond_nesting_depth: 0, cond_block_end_stack: Vec::new(), has_laravel_container_attrs: None, @@ -261,6 +306,7 @@ pub(crate) fn extract_symbol_map(program: &Program<'_>, content: &str) -> Symbol ctx.switch_scopes.sort_by_key(|s| s.0); ctx.static_method_scopes.sort_by_key(|s| s.0); ctx.view_receiver_sites.sort_by_key(|s| s.start); + ctx.resource_receiver_sites.sort_by_key(|s| s.start); let mut member_access_indices: crate::atom::AtomMap> = crate::atom::AtomMap::default(); @@ -291,7 +337,9 @@ pub(crate) fn extract_symbol_map(program: &Program<'_>, content: &str) -> Symbol instance_method_scopes: ctx.instance_method_scopes, untyped_closure_sites: ctx.untyped_closure_sites, view_receiver_sites: ctx.view_receiver_sites, + resource_receiver_sites: ctx.resource_receiver_sites, gate_subjects: ctx.gate_subjects, + has_dynamic_rate_limiter: ctx.has_dynamic_rate_limiter, source_len: u32::try_from(content.len()).unwrap_or(u32::MAX), } } diff --git a/src/symbol_map/laravel_resources.rs b/src/symbol_map/laravel_resources.rs new file mode 100644 index 000000000..1a7d838fc --- /dev/null +++ b/src/symbol_map/laravel_resources.rs @@ -0,0 +1,714 @@ +//! Declarative Laravel config-resource families and their string triggers. + +use super::LaravelConfigResource; +#[cfg(test)] +use super::{LaravelResourceReceiverRule, LaravelStringKind}; + +mod receiver_types; + +pub(crate) use receiver_types::{classify_connection_property, classify_receiver_type}; + +/// How a trigger accepts resource names in its selected argument. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ResourceArgumentShape { + /// One scalar string literal. + Scalar, + /// A scalar string or the string values of an array. + ScalarOrArray, + /// Only the string values of an array. + Array, +} + +impl ResourceArgumentShape { + /// Whether completion may trigger inside an array element. + pub(crate) const fn accepts_array(self) -> bool { + matches!(self, Self::ScalarOrArray | Self::Array) + } + + /// Whether a scalar literal is a valid argument shape. + pub(crate) const fn accepts_scalar(self) -> bool { + matches!(self, Self::Scalar | Self::ScalarOrArray) + } +} + +/// Whether a resource-name occurrence reads, defines, or optionally removes +/// the resource it names. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ResourceAccess { + Read, + Write, + OptionalRead, +} + +impl ResourceAccess { + pub(crate) const fn is_write(self) -> bool { + matches!(self, Self::Write) + } + + pub(crate) const fn is_optional(self) -> bool { + matches!(self, Self::OptionalRead) + } +} + +/// One syntactic place that accepts a member of a config-resource family. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ConfigResourceTrigger { + Function { + name: &'static str, + argument: &'static str, + shape: ResourceArgumentShape, + access: ResourceAccess, + }, + StaticMethod { + facade: &'static str, + method: &'static str, + argument: &'static str, + shape: ResourceArgumentShape, + access: ResourceAccess, + }, + InstanceMethod { + method: &'static str, + argument: &'static str, + shape: ResourceArgumentShape, + access: ResourceAccess, + }, + Attribute { + name: &'static str, + argument: &'static str, + }, + Middleware { + prefix: &'static str, + }, +} + +/// One named-resource family. Adding another config-backed family is a table +/// row rather than separate completion and symbol-extraction branches. +#[derive(Debug)] +pub(crate) struct ConfigResourceDescriptor { + pub kind: LaravelConfigResource, + pub config_prefix: &'static str, + pub label: &'static str, + pub hover_label: &'static str, + pub diagnostic_code: &'static str, + pub triggers: &'static [ConfigResourceTrigger], +} + +/// The semantic payload shared by completion and symbol extraction after a +/// declarative trigger matches. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ResourceTriggerMatch { + pub kind: LaravelConfigResource, + pub argument: &'static str, + pub shape: ResourceArgumentShape, + pub access: ResourceAccess, +} + +use ConfigResourceTrigger::{Attribute, Function, InstanceMethod, Middleware, StaticMethod}; +use ResourceAccess::{OptionalRead, Read, Write}; +use ResourceArgumentShape::{Array, Scalar, ScalarOrArray}; + +/// Every config-backed Laravel string family PHPantom understands. +pub(crate) const CONFIG_RESOURCES: &[ConfigResourceDescriptor] = &[ + ConfigResourceDescriptor { + kind: LaravelConfigResource::AuthGuard, + config_prefix: "auth.guards.", + label: "auth guard", + hover_label: "Auth guard", + diagnostic_code: "invalid_laravel_auth_guard", + triggers: &[ + Function { + name: "auth", + argument: "guard", + shape: Scalar, + access: Read, + }, + StaticMethod { + facade: "Auth", + method: "guard", + argument: "name", + shape: Scalar, + access: Read, + }, + Attribute { + name: "Auth", + argument: "guard", + }, + Attribute { + name: "Authenticated", + argument: "guard", + }, + Middleware { prefix: "auth:" }, + ], + }, + ConfigResourceDescriptor { + kind: LaravelConfigResource::CacheStore, + config_prefix: "cache.stores.", + label: "cache store", + hover_label: "Cache store", + diagnostic_code: "invalid_laravel_cache_store", + triggers: &[ + StaticMethod { + facade: "Cache", + method: "store", + argument: "name", + shape: Scalar, + access: Read, + }, + Attribute { + name: "Cache", + argument: "store", + }, + ], + }, + ConfigResourceDescriptor { + kind: LaravelConfigResource::LogChannel, + config_prefix: "logging.channels.", + label: "log channel", + hover_label: "Log channel", + diagnostic_code: "invalid_laravel_log_channel", + triggers: &[ + StaticMethod { + facade: "Log", + method: "channel", + argument: "channel", + shape: Scalar, + access: Read, + }, + StaticMethod { + facade: "Log", + method: "stack", + argument: "channels", + shape: Array, + access: Read, + }, + Attribute { + name: "Log", + argument: "channel", + }, + ], + }, + ConfigResourceDescriptor { + kind: LaravelConfigResource::StorageDisk, + config_prefix: "filesystems.disks.", + label: "storage disk", + hover_label: "Storage disk", + diagnostic_code: "invalid_laravel_storage_disk", + triggers: &[ + StaticMethod { + facade: "Storage", + method: "disk", + argument: "name", + shape: Scalar, + access: Read, + }, + StaticMethod { + facade: "Storage", + method: "fake", + argument: "disk", + shape: Scalar, + access: Write, + }, + StaticMethod { + facade: "Storage", + method: "persistentFake", + argument: "disk", + shape: Scalar, + access: Write, + }, + StaticMethod { + facade: "Storage", + method: "forgetDisk", + argument: "disk", + shape: ScalarOrArray, + access: OptionalRead, + }, + Attribute { + name: "Storage", + argument: "disk", + }, + ], + }, + ConfigResourceDescriptor { + kind: LaravelConfigResource::DatabaseConnection, + config_prefix: "database.connections.", + label: "database connection", + hover_label: "Database connection", + diagnostic_code: "invalid_laravel_database_connection", + triggers: &[ + StaticMethod { + facade: "DB", + method: "connection", + argument: "name", + shape: Scalar, + access: Read, + }, + Attribute { + name: "Database", + argument: "connection", + }, + Attribute { + name: "DB", + argument: "connection", + }, + ], + }, + ConfigResourceDescriptor { + kind: LaravelConfigResource::QueueConnection, + config_prefix: "queue.connections.", + label: "queue connection", + hover_label: "Queue connection", + diagnostic_code: "invalid_laravel_queue_connection", + triggers: &[ + StaticMethod { + facade: "Queue", + method: "connection", + argument: "name", + shape: Scalar, + access: Read, + }, + InstanceMethod { + method: "onConnection", + argument: "connection", + shape: Scalar, + access: Read, + }, + ], + }, + ConfigResourceDescriptor { + kind: LaravelConfigResource::Mailer, + config_prefix: "mail.mailers.", + label: "mailer", + hover_label: "Mailer", + diagnostic_code: "invalid_laravel_mailer", + triggers: &[StaticMethod { + facade: "Mail", + method: "mailer", + argument: "name", + shape: Scalar, + access: Read, + }], + }, + ConfigResourceDescriptor { + kind: LaravelConfigResource::BroadcastConnection, + config_prefix: "broadcasting.connections.", + label: "broadcast connection", + hover_label: "Broadcast connection", + diagnostic_code: "invalid_laravel_broadcast_connection", + triggers: &[StaticMethod { + facade: "Broadcast", + method: "connection", + argument: "name", + shape: Scalar, + access: Read, + }], + }, +]; + +pub(crate) fn descriptor(kind: LaravelConfigResource) -> &'static ConfigResourceDescriptor { + match kind { + LaravelConfigResource::AuthGuard => &CONFIG_RESOURCES[0], + LaravelConfigResource::CacheStore => &CONFIG_RESOURCES[1], + LaravelConfigResource::LogChannel => &CONFIG_RESOURCES[2], + LaravelConfigResource::StorageDisk => &CONFIG_RESOURCES[3], + LaravelConfigResource::DatabaseConnection => &CONFIG_RESOURCES[4], + LaravelConfigResource::QueueConnection => &CONFIG_RESOURCES[5], + LaravelConfigResource::Mailer => &CONFIG_RESOURCES[6], + LaravelConfigResource::BroadcastConnection => &CONFIG_RESOURCES[7], + } +} + +/// Build the dot key used by Laravel's config index for one short resource +/// name. This allocation is paid only at a config boundary, never per stored +/// symbol span. +pub(crate) fn config_key(kind: LaravelConfigResource, short_name: &str) -> String { + let prefix = descriptor(kind).config_prefix; + let mut key = String::with_capacity(prefix.len() + short_name.len()); + key.push_str(prefix); + key.push_str(short_name); + key +} + +/// Whether `full_key` is the config address of `short_name` in `kind`. +pub(crate) fn matches_config_key( + kind: LaravelConfigResource, + short_name: &str, + full_key: &str, +) -> bool { + full_key + .strip_prefix(descriptor(kind).config_prefix) + .is_some_and(|rest| rest == short_name) +} + +/// Interpret a generic config key as a direct resource child. +pub(crate) fn resource_from_config_key(full_key: &str) -> Option<(LaravelConfigResource, &str)> { + let root = full_key.split_once('.')?.0; + let kind = config_root_resource(root)?; + let short = full_key.strip_prefix(descriptor(kind).config_prefix)?; + (!short.is_empty() && !short.contains('.')).then_some((kind, short)) +} + +pub(crate) fn function_trigger(name: &str) -> Option { + let resource = descriptor(function_resource(name)?); + function_trigger_from(resource, name) +} + +fn function_trigger_from( + resource: &ConfigResourceDescriptor, + name: &str, +) -> Option { + resource.triggers.iter().find_map(|trigger| match trigger { + Function { + name: expected, + argument, + shape, + access, + } if name.eq_ignore_ascii_case(expected) => Some(ResourceTriggerMatch { + kind: resource.kind, + argument, + shape: *shape, + access: *access, + }), + _ => None, + }) +} + +pub(crate) fn static_method_trigger(receiver: &str, method: &str) -> Option { + let receiver = receiver.trim_start_matches('\\'); + let short = if let Some((namespace, short)) = receiver.rsplit_once('\\') { + if !namespace.eq_ignore_ascii_case("Illuminate\\Support\\Facades") { + return None; + } + short + } else { + receiver + }; + let resource = descriptor(static_facade_resource(short)?); + resource.triggers.iter().find_map(|trigger| match trigger { + StaticMethod { + facade, + method: expected, + argument, + shape, + access, + } if short.eq_ignore_ascii_case(facade) && method.eq_ignore_ascii_case(expected) => { + Some(ResourceTriggerMatch { + kind: resource.kind, + argument, + shape: *shape, + access: *access, + }) + } + _ => None, + }) +} + +pub(crate) fn instance_method_trigger(method: &str) -> Option { + let resource = descriptor(instance_method_resource(method)?); + resource.triggers.iter().find_map(|trigger| match trigger { + InstanceMethod { + method: expected, + argument, + shape, + access, + } if method.eq_ignore_ascii_case(expected) => Some(ResourceTriggerMatch { + kind: resource.kind, + argument, + shape: *shape, + access: *access, + }), + _ => None, + }) +} + +pub(crate) fn attribute_trigger(name: &str) -> Option { + let short = name.rsplit('\\').next().unwrap_or(name); + let resource = descriptor(attribute_resource_kind(short)?); + resource.triggers.iter().find_map(|trigger| match trigger { + Attribute { name, argument } if short.eq_ignore_ascii_case(name) => { + Some(ResourceTriggerMatch { + kind: resource.kind, + argument, + shape: ResourceArgumentShape::Scalar, + access: ResourceAccess::Read, + }) + } + _ => None, + }) +} + +#[cfg(test)] +pub(crate) fn attribute_resource(name: &str) -> Option { + attribute_trigger(name).map(|trigger| trigger.kind) +} + +pub(crate) fn middleware_resource(prefix: &str) -> Option { + let resource = descriptor(middleware_resource_kind(prefix)?); + resource.triggers.iter().find_map(|trigger| match trigger { + Middleware { prefix: expected } if prefix.eq_ignore_ascii_case(expected) => { + Some(resource.kind) + } + _ => None, + }) +} + +// These compact indexes keep extraction and completion's negative path from +// walking every descriptor for every PHP call. CONFIG_RESOURCES remains the +// source of trigger metadata; the exhaustive test below rejects an index that +// falls out of sync when a table row is added or moved. +fn config_root_resource(root: &str) -> Option { + use LaravelConfigResource::*; + match root.len() { + 4 if root == "auth" => Some(AuthGuard), + 4 if root == "mail" => Some(Mailer), + 5 if root == "cache" => Some(CacheStore), + 5 if root == "queue" => Some(QueueConnection), + 7 if root == "logging" => Some(LogChannel), + 8 if root == "database" => Some(DatabaseConnection), + 11 if root == "filesystems" => Some(StorageDisk), + 12 if root == "broadcasting" => Some(BroadcastConnection), + _ => None, + } +} + +fn function_resource(name: &str) -> Option { + (name.len() == 4 && name.eq_ignore_ascii_case("auth")) + .then_some(LaravelConfigResource::AuthGuard) +} + +fn static_facade_resource(name: &str) -> Option { + use LaravelConfigResource::*; + match name.len() { + 2 if name.eq_ignore_ascii_case("DB") => Some(DatabaseConnection), + 3 if name.eq_ignore_ascii_case("Log") => Some(LogChannel), + 4 if name.eq_ignore_ascii_case("Auth") => Some(AuthGuard), + 4 if name.eq_ignore_ascii_case("Mail") => Some(Mailer), + 5 if name.eq_ignore_ascii_case("Cache") => Some(CacheStore), + 5 if name.eq_ignore_ascii_case("Queue") => Some(QueueConnection), + 7 if name.eq_ignore_ascii_case("Storage") => Some(StorageDisk), + 9 if name.eq_ignore_ascii_case("Broadcast") => Some(BroadcastConnection), + _ => None, + } +} + +fn instance_method_resource(method: &str) -> Option { + (method.len() == 12 && method.eq_ignore_ascii_case("onConnection")) + .then_some(LaravelConfigResource::QueueConnection) +} + +fn attribute_resource_kind(name: &str) -> Option { + use LaravelConfigResource::*; + match name.len() { + 2 if name.eq_ignore_ascii_case("DB") => Some(DatabaseConnection), + 3 if name.eq_ignore_ascii_case("Log") => Some(LogChannel), + 4 if name.eq_ignore_ascii_case("Auth") => Some(AuthGuard), + 5 if name.eq_ignore_ascii_case("Cache") => Some(CacheStore), + 7 if name.eq_ignore_ascii_case("Storage") => Some(StorageDisk), + 8 if name.eq_ignore_ascii_case("Database") => Some(DatabaseConnection), + 13 if name.eq_ignore_ascii_case("Authenticated") => Some(AuthGuard), + _ => None, + } +} + +fn middleware_resource_kind(prefix: &str) -> Option { + (prefix.len() == 5 && prefix.eq_ignore_ascii_case("auth:")) + .then_some(LaravelConfigResource::AuthGuard) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_resource_has_one_unique_prefix_and_descriptor() { + for (index, resource) in CONFIG_RESOURCES.iter().enumerate() { + assert!(resource.config_prefix.ends_with('.')); + assert_eq!(descriptor(resource.kind).kind, resource.kind); + assert!(CONFIG_RESOURCES[..index].iter().all(|seen| { + seen.kind != resource.kind && seen.config_prefix != resource.config_prefix + })); + } + } + + #[test] + fn fast_indexes_cover_every_declarative_trigger() { + for resource in CONFIG_RESOURCES { + let root = resource.config_prefix.split('.').next().unwrap(); + assert_eq!(config_root_resource(root), Some(resource.kind)); + + for trigger in resource.triggers { + match trigger { + Function { + name, + argument, + shape, + access, + } => assert_eq!( + function_trigger(name), + Some(ResourceTriggerMatch { + kind: resource.kind, + argument, + shape: *shape, + access: *access, + }) + ), + StaticMethod { + facade, + method, + argument, + shape, + access, + } => { + let expected = Some(ResourceTriggerMatch { + kind: resource.kind, + argument, + shape: *shape, + access: *access, + }); + assert_eq!(static_method_trigger(facade, method), expected); + let fqn = format!("Illuminate\\Support\\Facades\\{facade}"); + assert_eq!(static_method_trigger(&fqn, method), expected); + } + InstanceMethod { + method, + argument, + shape, + access, + } => assert_eq!( + instance_method_trigger(method), + Some(ResourceTriggerMatch { + kind: resource.kind, + argument, + shape: *shape, + access: *access, + }) + ), + Attribute { name, argument } => assert_eq!( + attribute_trigger(name), + Some(ResourceTriggerMatch { + kind: resource.kind, + argument, + shape: ResourceArgumentShape::Scalar, + access: ResourceAccess::Read, + }) + ), + Middleware { prefix } => { + assert_eq!(middleware_resource(prefix), Some(resource.kind)); + } + } + } + } + } + + #[test] + fn trigger_lookups_are_case_insensitive_and_context_specific() { + assert_eq!( + function_trigger("AUTH").map(|found| found.kind), + Some(LaravelConfigResource::AuthGuard) + ); + assert_eq!( + static_method_trigger("Illuminate\\Support\\Facades\\Log", "STACK").map(|found| ( + found.kind, + found.shape, + found.argument + )), + Some(( + LaravelConfigResource::LogChannel, + ResourceArgumentShape::Array, + "channels", + )) + ); + assert_eq!( + instance_method_trigger("onConnection").map(|found| found.kind), + Some(LaravelConfigResource::QueueConnection) + ); + assert_eq!( + attribute_resource("Authenticated"), + Some(LaravelConfigResource::AuthGuard) + ); + assert!(static_method_trigger("Queue", "mailer").is_none()); + assert!(function_trigger("guard").is_none()); + assert!( + function_trigger_from(descriptor(LaravelConfigResource::AuthGuard), "guard").is_none() + ); + assert!(instance_method_trigger("connection").is_none()); + assert!(attribute_trigger("UnknownAttribute").is_none()); + assert!(static_method_trigger("Unknown", "connection").is_none()); + assert!(static_method_trigger("Acme\\Log", "stack").is_none()); + assert_eq!( + static_method_trigger("ILLUMINATE\\SUPPORT\\FACADES\\CACHE", "STORE") + .map(|found| found.kind), + Some(LaravelConfigResource::CacheStore) + ); + assert_eq!( + middleware_resource("AUTH:"), + Some(LaravelConfigResource::AuthGuard) + ); + assert!(middleware_resource("throttle:").is_none()); + } + + #[test] + fn canonical_config_conversion_is_exact_and_symmetric() { + for resource in CONFIG_RESOURCES { + let full = config_key(resource.kind, "named"); + assert_eq!( + resource_from_config_key(&full), + Some((resource.kind, "named")) + ); + assert!(matches_config_key(resource.kind, "named", &full)); + assert!(!matches_config_key(resource.kind, "other", &full)); + + let nested = format!("{full}.option"); + assert!(resource_from_config_key(&nested).is_none()); + } + assert!(resource_from_config_key("app.name").is_none()); + } + + #[test] + fn storage_access_modes_and_log_shape_come_from_the_table() { + for (method, expected_access) in [ + ("disk", ResourceAccess::Read), + ("fake", ResourceAccess::Write), + ("persistentFake", ResourceAccess::Write), + ("forgetDisk", ResourceAccess::OptionalRead), + ] { + assert_eq!( + static_method_trigger("Storage", method).map(|found| found.access), + Some(expected_access) + ); + } + assert_eq!( + static_method_trigger("Log", "stack").map(|found| found.shape), + Some(ResourceArgumentShape::Array) + ); + } + + #[test] + fn receiver_rules_expose_every_possible_reference_family() { + use LaravelConfigResource::{BroadcastConnection, DatabaseConnection, QueueConnection}; + use LaravelResourceReceiverRule::{ + ConnectionMethod, ConnectionProperty, QueueName, QueueableConnection, + }; + use LaravelStringKind::{ConfigResource, QueueName as QueueNameKind}; + + assert_eq!( + ConnectionMethod.candidate_kinds(), + &[ + ConfigResource(DatabaseConnection), + ConfigResource(QueueConnection), + ConfigResource(BroadcastConnection), + ] + ); + assert_eq!( + QueueableConnection.candidate_kinds(), + &[ConfigResource(QueueConnection)] + ); + assert_eq!(QueueName.candidate_kinds(), &[QueueNameKind]); + assert_eq!( + ConnectionProperty.candidate_kinds(), + &[ + ConfigResource(DatabaseConnection), + ConfigResource(QueueConnection), + ] + ); + } +} diff --git a/src/symbol_map/laravel_resources/receiver_types.rs b/src/symbol_map/laravel_resources/receiver_types.rs new file mode 100644 index 000000000..be0f99174 --- /dev/null +++ b/src/symbol_map/laravel_resources/receiver_types.rs @@ -0,0 +1,446 @@ +//! Type-driven classification for Laravel named-resource receivers. + +use std::collections::VecDeque; +use std::sync::Arc; + +use crate::atom::{Atom, AtomSet}; +use crate::php_type::{PhpType, TypeKind}; +use crate::symbol_map::{LaravelConfigResource, LaravelResourceReceiverRule, LaravelStringKind}; +use crate::types::{ClassInfo, MAX_INHERITANCE_DEPTH}; + +const SHOULD_QUEUE: &str = "Illuminate\\Contracts\\Queue\\ShouldQueue"; +const QUEUEABLE_TRAIT: &str = "Illuminate\\Bus\\Queueable"; + +/// Classify a type-dependent resource call without guessing from its method +/// name. The same function serves lazy symbol spans and live completion. +pub(crate) fn classify_receiver_type( + rule: LaravelResourceReceiverRule, + ty: &PhpType, + class_loader: &dyn Fn(&str) -> Option>, +) -> Option { + match rule { + LaravelResourceReceiverRule::ConnectionMethod => { + let resource = classify_connection_receiver(ty, class_loader)?; + Some(LaravelStringKind::ConfigResource(resource)) + } + LaravelResourceReceiverRule::QueueableConnection => { + type_is_queueable(ty, class_loader, false).then_some(LaravelStringKind::ConfigResource( + LaravelConfigResource::QueueConnection, + )) + } + LaravelResourceReceiverRule::QueueName => { + type_is_queueable(ty, class_loader, true).then_some(LaravelStringKind::QueueName) + } + LaravelResourceReceiverRule::ConnectionProperty => None, + } +} + +fn classify_connection_receiver( + ty: &PhpType, + class_loader: &dyn Fn(&str) -> Option>, +) -> Option { + use LaravelConfigResource::{BroadcastConnection, DatabaseConnection, QueueConnection}; + + match ty.kind() { + TypeKind::Nullable(inner) => return classify_connection_receiver(inner, class_loader), + TypeKind::Union(members) => { + let mut resource = None; + for member in members.iter().filter(|member| !member.is_null()) { + let candidate = classify_connection_receiver(member, class_loader)?; + if resource.is_some_and(|known| known != candidate) { + return None; + } + resource = Some(candidate); + } + return resource; + } + _ => {} + } + + if crate::class_lookup::is_subtype_of_named( + ty, + "Illuminate\\Database\\ConnectionResolverInterface", + class_loader, + ) || crate::class_lookup::is_subtype_of_named( + ty, + "Illuminate\\Database\\Eloquent\\Factories\\Factory", + class_loader, + ) { + Some(DatabaseConnection) + } else if crate::class_lookup::is_subtype_of_named( + ty, + "Illuminate\\Contracts\\Queue\\Factory", + class_loader, + ) { + Some(QueueConnection) + } else if crate::class_lookup::is_subtype_of_named( + ty, + "Illuminate\\Contracts\\Broadcasting\\Factory", + class_loader, + ) { + Some(BroadcastConnection) + } else { + None + } +} + +/// Classify a `$connection` property by the class that declares it. +pub(crate) fn classify_connection_property( + class: &ClassInfo, + class_loader: &dyn Fn(&str) -> Option>, +) -> Option { + if crate::virtual_members::laravel::extends_eloquent_model(class, class_loader) { + Some(LaravelStringKind::ConfigResource( + LaravelConfigResource::DatabaseConnection, + )) + } else if class_is_queueable(class, class_loader) { + Some(LaravelStringKind::ConfigResource( + LaravelConfigResource::QueueConnection, + )) + } else { + None + } +} + +fn type_is_queueable( + ty: &PhpType, + class_loader: &dyn Fn(&str) -> Option>, + allow_mailer: bool, +) -> bool { + match ty.kind() { + TypeKind::Nullable(inner) => { + return type_is_queueable(inner, class_loader, allow_mailer); + } + TypeKind::Union(members) => { + let mut non_null = members.iter().filter(|member| !member.is_null()); + let Some(first) = non_null.next() else { + return false; + }; + return type_is_queueable(first, class_loader, allow_mailer) + && non_null.all(|member| type_is_queueable(member, class_loader, allow_mailer)); + } + _ => {} + } + + // A resolved named type is the overwhelmingly common case. Walking its + // graph once avoids constructing a target PhpType and then traversing the + // same ancestry again for the Queueable-trait fallback. + if let Some(name) = ty.base_name() { + if name.eq_ignore_ascii_case(SHOULD_QUEUE) { + return true; + } + let Some(class) = class_loader(name) else { + return false; + }; + let fqn = class.fqn(); + return is_builtin_queueable(fqn.as_str(), allow_mailer) + || class_is_queueable(&class, class_loader); + } + + // Intersections have no single base name. The shared subtype engine + // correctly accepts one that explicitly carries ShouldQueue. + crate::class_lookup::is_subtype_of_named(ty, SHOULD_QUEUE, class_loader) +} + +fn is_builtin_queueable(fqn: &str, allow_mailer: bool) -> bool { + [ + "Illuminate\\Bus\\PendingBatch", + "Illuminate\\Events\\QueuedClosure", + "Illuminate\\Foundation\\Bus\\PendingDispatch", + "Illuminate\\Foundation\\Bus\\PendingChain", + ] + .iter() + .any(|expected| fqn.eq_ignore_ascii_case(expected)) + || (allow_mailer && fqn.eq_ignore_ascii_case("Illuminate\\Mail\\Mailer")) +} + +#[derive(Clone, Copy)] +enum QueueableEdge { + Interface(Atom), + Trait(Atom), + Parent(Atom), +} + +impl QueueableEdge { + fn name(self) -> Atom { + match self { + Self::Interface(name) | Self::Trait(name) | Self::Parent(name) => name, + } + } +} + +/// Traverse interfaces, traits, and parents breadth-first. The visited set +/// makes diamond graphs and cycles linear in the number of distinct classes; +/// breadth-first order ensures a shared node is first seen at its shallowest +/// depth, preserving the inheritance-depth bound. +fn class_is_queueable( + class: &ClassInfo, + class_loader: &dyn Fn(&str) -> Option>, +) -> bool { + if class_has_queueable_marker(class) { + return true; + } + + let edge_count = class.interfaces.len() + + class.used_traits.len() + + usize::from(class.parent_class.is_some()); + if edge_count == 0 { + return false; + } + + let mut pending = VecDeque::with_capacity(edge_count); + push_edges(class, 1, &mut pending); + let mut visited = AtomSet::default(); + visited.insert(class.fqn()); + + while let Some((edge, depth)) = pending.pop_front() { + let name = edge.name(); + // Edges are only enqueued below the depth limit, so every item here + // is valid and the hot loop only needs the cycle/diamond check. + if !visited.insert(name) { + continue; + } + let Some(next) = class_loader(name.as_str()) else { + continue; + }; + if class_has_queueable_marker(&next) { + return true; + } + if depth < MAX_INHERITANCE_DEPTH { + push_edges(&next, depth + 1, &mut pending); + } + } + false +} + +fn class_has_queueable_marker(class: &ClassInfo) -> bool { + class + .interfaces + .iter() + .any(|name| name.eq_ignore_ascii_case(SHOULD_QUEUE)) + || class + .used_traits + .iter() + .any(|name| name.eq_ignore_ascii_case(QUEUEABLE_TRAIT)) +} + +fn push_edges(class: &ClassInfo, depth: u32, pending: &mut VecDeque<(QueueableEdge, u32)>) { + pending.extend( + class + .interfaces + .iter() + .copied() + .map(|name| (QueueableEdge::Interface(name), depth)), + ); + pending.extend( + class + .used_traits + .iter() + .copied() + .map(|name| (QueueableEdge::Trait(name), depth)), + ); + if let Some(parent) = class.parent_class { + pending.push_back((QueueableEdge::Parent(parent), depth)); + } +} + +#[cfg(test)] +mod tests { + use std::cell::Cell; + + use super::*; + use crate::test_fixtures::make_class; + + fn implementing(name: &str, interface: &str) -> Arc { + let mut class = make_class(name); + class.interfaces.push(crate::atom::atom(interface)); + Arc::new(class) + } + + fn named(name: &str) -> PhpType { + PhpType::named(crate::atom::atom(name)) + } + + #[test] + fn connection_receiver_unions_require_one_consistent_resource_family() { + let first_database = implementing( + "App\\FirstDatabaseResolver", + "Illuminate\\Database\\ConnectionResolverInterface", + ); + let second_database = implementing( + "App\\SecondDatabaseResolver", + "Illuminate\\Database\\ConnectionResolverInterface", + ); + let queue = implementing("App\\QueueFactory", "Illuminate\\Contracts\\Queue\\Factory"); + let loader = |name: &str| match name { + "App\\FirstDatabaseResolver" => Some(Arc::clone(&first_database)), + "App\\SecondDatabaseResolver" => Some(Arc::clone(&second_database)), + "App\\QueueFactory" => Some(Arc::clone(&queue)), + _ => None, + }; + + assert_eq!( + classify_receiver_type( + LaravelResourceReceiverRule::ConnectionMethod, + &PhpType::union(vec![ + named("App\\FirstDatabaseResolver"), + named("App\\SecondDatabaseResolver"), + ]), + &loader, + ), + Some(LaravelStringKind::ConfigResource( + LaravelConfigResource::DatabaseConnection, + )) + ); + assert_eq!( + classify_receiver_type( + LaravelResourceReceiverRule::ConnectionMethod, + &PhpType::union(vec![ + named("App\\FirstDatabaseResolver"), + named("App\\QueueFactory"), + ]), + &loader, + ), + None + ); + assert_eq!( + classify_receiver_type( + LaravelResourceReceiverRule::ConnectionMethod, + &PhpType::union(vec![ + named("App\\FirstDatabaseResolver"), + named("App\\MissingResolver"), + ]), + &loader, + ), + None + ); + assert_eq!( + classify_receiver_type( + LaravelResourceReceiverRule::ConnectionProperty, + &named("App\\FirstDatabaseResolver"), + &loader, + ), + None + ); + } + + #[test] + fn queueable_types_unwrap_null_without_weakening_union_classification() { + let first = implementing("App\\FirstJob", SHOULD_QUEUE); + let second = implementing("App\\SecondJob", SHOULD_QUEUE); + let ordinary = Arc::new(make_class("App\\Ordinary")); + let pending = Arc::new(make_class("Illuminate\\Foundation\\Bus\\PendingDispatch")); + let loader = |name: &str| match name { + "App\\FirstJob" => Some(Arc::clone(&first)), + "App\\SecondJob" => Some(Arc::clone(&second)), + "App\\Ordinary" => Some(Arc::clone(&ordinary)), + "Illuminate\\Foundation\\Bus\\PendingDispatch" => Some(Arc::clone(&pending)), + _ => None, + }; + + assert!(type_is_queueable( + &PhpType::nullable(named("App\\FirstJob")), + &loader, + false, + )); + assert!(type_is_queueable( + &PhpType::union(vec![named("App\\FirstJob"), PhpType::null()]), + &loader, + false, + )); + assert!(type_is_queueable( + &PhpType::union(vec![named("App\\FirstJob"), named("App\\SecondJob")]), + &loader, + false, + )); + assert!(!type_is_queueable( + &PhpType::union(vec![named("App\\FirstJob"), named("App\\Ordinary")]), + &loader, + false, + )); + assert!(type_is_queueable( + &PhpType::nullable(named("Illuminate\\Foundation\\Bus\\PendingDispatch")), + &loader, + false, + )); + assert!(type_is_queueable(&named(SHOULD_QUEUE), &loader, false)); + assert!(!type_is_queueable(&named("App\\Missing"), &loader, false)); + let all_null: PhpType = TypeKind::Union(vec![PhpType::null()].into()).into(); + assert!(!type_is_queueable(&all_null, &loader, false)); + assert!(type_is_queueable( + &PhpType::intersection(vec![named("App\\Ordinary"), named(SHOULD_QUEUE)]), + &loader, + false, + )); + } + + #[test] + fn queueable_graphs_follow_every_edge_once() { + fn with_trait(name: &str, trait_name: &str) -> Arc { + let mut class = make_class(name); + class.used_traits.push(crate::atom::atom(trait_name)); + Arc::new(class) + } + + let marker = implementing("App\\QueueMarker", SHOULD_QUEUE); + let interface_job = implementing("App\\InterfaceJob", "App\\QueueMarker"); + let nested_trait = with_trait("App\\NestedQueueable", QUEUEABLE_TRAIT); + let trait_job = with_trait("App\\TraitJob", "App\\NestedQueueable"); + let direct_trait_job = with_trait("App\\DirectTraitJob", QUEUEABLE_TRAIT); + let unknown_edges = { + let mut class = make_class("App\\UnknownEdges"); + class + .interfaces + .push(crate::atom::atom("App\\MissingInterface")); + class + .used_traits + .push(crate::atom::atom("App\\MissingTrait")); + Arc::new(class) + }; + let cycle = implementing("App\\Cycle", "App\\Cycle"); + let mut child = make_class("App\\ChildJob"); + child.parent_class = Some(crate::atom::atom("App\\InterfaceJob")); + let loader = |name: &str| match name { + "App\\QueueMarker" => Some(Arc::clone(&marker)), + "App\\InterfaceJob" => Some(Arc::clone(&interface_job)), + "App\\NestedQueueable" => Some(Arc::clone(&nested_trait)), + "App\\Cycle" => Some(Arc::clone(&cycle)), + _ => None, + }; + + assert!(class_is_queueable(&interface_job, &loader)); + assert!(class_is_queueable(&trait_job, &loader)); + assert!(class_is_queueable(&direct_trait_job, &loader)); + assert!(class_is_queueable(&child, &loader)); + assert!(!class_is_queueable(&unknown_edges, &loader)); + assert!(!class_is_queueable(&cycle, &loader)); + assert!(!class_is_queueable( + &make_class("App\\NoHierarchy"), + &loader + )); + } + + #[test] + fn diamond_graphs_load_each_distinct_class_once() { + let shared = Arc::new(make_class("App\\Shared")); + let left = implementing("App\\Left", "App\\Shared"); + let right = implementing("App\\Right", "App\\Shared"); + let mut root = make_class("App\\Root"); + root.interfaces.push(crate::atom::atom("App\\Left")); + root.interfaces.push(crate::atom::atom("App\\Right")); + root.interfaces.push(crate::atom::atom("App\\Missing")); + let shared_loads = Cell::new(0usize); + let loader = |name: &str| match name { + "App\\Left" => Some(Arc::clone(&left)), + "App\\Right" => Some(Arc::clone(&right)), + "App\\Shared" => { + shared_loads.set(shared_loads.get() + 1); + Some(Arc::clone(&shared)) + } + _ => None, + }; + + assert!(!class_is_queueable(&root, &loader)); + assert_eq!(shared_loads.get(), 1); + } +} diff --git a/src/symbol_map/mod.rs b/src/symbol_map/mod.rs index 1a77cef01..c9eca2145 100644 --- a/src/symbol_map/mod.rs +++ b/src/symbol_map/mod.rs @@ -28,11 +28,14 @@ pub(crate) mod docblock; mod extraction; +pub(crate) mod laravel_resources; use crate::atom::Atom; use crate::php_type::PhpType; +#[cfg(test)] pub(crate) use extraction::extract_symbol_map; +pub(crate) use extraction::extract_symbol_map_with_resolved_names; // ─── Data structures ──────────────────────────────────────────────────────── @@ -390,8 +393,9 @@ pub(crate) enum SymbolKind { is_write: bool, /// Whether the call tolerates the key naming nothing, as one /// candidate of an `@includeFirst(['custom.header', 'partials.header'])` - /// does: the directive renders whichever exists, so a candidate that - /// does not is the point rather than a mistake. + /// or a disk passed to `Storage::forgetDisk()` does. The operation is + /// explicitly allowed to proceed without a declaration, so absence is + /// not a typo. is_optional: bool, }, @@ -445,16 +449,40 @@ impl SymbolKind { } } +/// A Laravel resource whose short name is declared under a config subtree. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(crate) enum LaravelConfigResource { + /// A guard under `auth.guards`. + AuthGuard, + /// A cache store under `cache.stores`. + CacheStore, + /// A logging channel under `logging.channels`. + LogChannel, + /// A filesystem disk under `filesystems.disks`. + StorageDisk, + /// A database connection under `database.connections`. + DatabaseConnection, + /// A queue connection under `queue.connections`. + QueueConnection, + /// A configured mailer under `mail.mailers`. + Mailer, + /// A broadcast connection under `broadcasting.connections`. + BroadcastConnection, +} + /// Identifies the category of a [`SymbolKind::LaravelStringKey`] span. /// /// Adding a new Laravel navigation feature only requires adding a variant /// here and updating the extraction and dispatch paths — the exhaustive /// match arms in `highlight`, `hover`, `rename`, `semantic_tokens`, and /// `type_definition` do not need to change. -#[derive(Debug, Clone, PartialEq, Eq, Hash)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub(crate) enum LaravelStringKind { /// A `config('dot.key')` or `Config::get('dot.key')` call. Config, + /// A short name whose declaration is a direct child of a known config + /// subtree, such as a storage disk or database connection. + ConfigResource(LaravelConfigResource), /// A `view('name')` or `View::make('name')` call. View, /// A `route('name')` call. @@ -488,6 +516,18 @@ pub(crate) enum LaravelStringKind { /// `$this->app->make('payments')` asks the container for, and the one a /// service provider registers it under. ContainerBinding, + /// A source-registered rate limiter used by `throttle:name` middleware + /// and queue rate-limiting middleware. + RateLimiter, + /// An application-defined queue name passed to `onQueue()`. + QueueName, +} + +impl LaravelStringKind { + /// Whether this key resolves through Laravel's config index. + pub(crate) fn is_config_backed(self) -> bool { + matches!(self, Self::Config | Self::ConfigResource(_)) + } } /// The model a gate check names, recorded alongside its ability span. @@ -576,6 +616,70 @@ impl ViewReceiverSite { } } +/// A Laravel string call/property whose resource family is determined by the +/// enclosing class or the receiver's resolved type. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum LaravelResourceReceiverRule { + /// `->connection()` may name a database, queue, or broadcast connection. + ConnectionMethod, + /// `->onConnection()` names a queue connection on queueable objects. + QueueableConnection, + /// `->onQueue()` names an application-defined queue on queueable objects. + QueueName, + /// A class `$connection` default names a database connection on models + /// and a queue connection on queueable jobs. + ConnectionProperty, +} + +impl LaravelResourceReceiverRule { + /// Coarse reference-index keys this unresolved site may contribute under. + pub(crate) fn candidate_kinds(self) -> &'static [LaravelStringKind] { + use LaravelConfigResource::{BroadcastConnection, DatabaseConnection, QueueConnection}; + use LaravelStringKind::{ConfigResource, QueueName}; + match self { + Self::ConnectionMethod => &[ + ConfigResource(DatabaseConnection), + ConfigResource(QueueConnection), + ConfigResource(BroadcastConnection), + ], + Self::QueueableConnection => &[ConfigResource(QueueConnection)], + Self::QueueName => &[QueueName], + Self::ConnectionProperty => &[ + ConfigResource(DatabaseConnection), + ConfigResource(QueueConnection), + ], + } + } +} + +/// One unresolved config-resource/queue-name literal, stored until the shared +/// type engine can classify its receiver without guessing from a method name. +#[derive(Debug, Clone)] +pub(crate) struct LaravelResourceReceiverSite { + pub start: u32, + pub end: u32, + pub key: String, + pub rule: LaravelResourceReceiverRule, +} + +impl LaravelResourceReceiverSite { + pub(crate) fn to_span(&self, kind: LaravelStringKind) -> SymbolSpan { + SymbolSpan { + start: self.start, + end: self.end, + kind: SymbolKind::LaravelStringKey { + key: self.key.clone(), + kind, + is_write: false, + // Queue names are deliberately open; the diagnostic dispatcher + // ignores their kind, but preserving the semantics here makes + // the span safe for any future generic validator. + is_optional: kind == LaravelStringKind::QueueName, + }, + } + } +} + // ─── Template parameter definition site structures ────────────────────────── /// A `@template` parameter definition site discovered during docblock extraction. @@ -862,10 +966,17 @@ pub(crate) struct SymbolMap { /// a mailable through something other than the spellings /// [`SymbolKind::LaravelStringKey`] is emitted for. pub view_receiver_sites: Vec, + /// Config-resource and queue-name literals whose receiver/enclosing class + /// must be resolved before their Laravel string kind is known. + pub resource_receiver_sites: Vec, /// The model argument of each gate check that named one, keyed back to /// its ability span by [`GateSubject::ability_start`]. Empty for every /// file that performs no authorization checks. pub gate_subjects: Vec, + /// Whether a `RateLimiter::for()` call in this file registers a name that + /// is not a plain literal. This keeps rate-limiter diagnostics conservative + /// when the project's complete name set cannot be enumerated. + pub has_dynamic_rate_limiter: bool, /// Byte length of the source text this map was extracted from, or /// `u32::MAX` for a file larger than 4 GiB (whose offsets do not fit /// in the `u32` fields above anyway). diff --git a/src/symbol_map/tests.rs b/src/symbol_map/tests.rs index c647f3475..57aa0682c 100644 --- a/src/symbol_map/tests.rs +++ b/src/symbol_map/tests.rs @@ -1,5 +1,5 @@ use super::docblock::is_navigable_type; -use super::extraction::extract_symbol_map; +use super::extraction::{extract_symbol_map, extract_symbol_map_with_resolved_names}; use super::*; // ── SymbolMap::lookup tests ───────────────────────────────────────── @@ -137,6 +137,16 @@ fn parse_and_extract(php: &str) -> SymbolMap { extract_symbol_map(program, php) } +fn parse_and_extract_semantic(php: &str) -> SymbolMap { + let arena = mago_allocator::LocalArena::new(); + let file_id = mago_database::file::FileId::new(b"test.php"); + let program = mago_syntax::parser::parse_file_content(&arena, file_id, php.as_bytes()); + let resolver = mago_names::resolver::NameResolver::new(&arena); + let resolved = resolver.resolve(program); + let owned = crate::names::OwnedResolvedNames::from_resolved(&resolved); + extract_symbol_map_with_resolved_names(program, php, &owned) +} + #[test] fn class_declaration_produces_class_declaration() { let php = " Vec<(String, bool, bool)> { + map.spans + .iter() + .filter_map(|span| match &span.kind { + SymbolKind::LaravelStringKey { + kind: LaravelStringKind::ConfigResource(span_resource), + key, + is_write, + is_optional, + .. + } if *span_resource == resource => Some((key.clone(), *is_write, *is_optional)), + _ => None, + }) + .collect() +} + +#[test] +fn every_storage_disk_method_records_the_short_resource_name() { + for (method, is_write, is_optional) in [ + ("disk", false, false), + ("fake", true, false), + ("persistentFake", true, false), + ("forgetDisk", false, true), + ] { + let php = format!(" null)", + "OtherStorage::fake('archive')", + ] { + let map = parse_and_extract(&format!(" + { + Some((span, *kind, key.as_str())) + } + _ => None, + }) + .collect::>(); + keys.sort_by_key(|(span, _, _)| span.start); + assert_eq!( + keys.iter() + .map(|(_, kind, key)| (*kind, *key)) + .collect::>(), + vec![ + (LaravelStringKind::RateLimiter, "api"), + (LaravelStringKind::GateAbility, "update"), + ] + ); + for (span, _, key) in keys { + assert_eq!(&php[span.start as usize..span.end as usize], key); + } +} + +#[test] +fn throttle_middleware_keeps_numeric_names_optional_but_digit_prefixed_names_required() { + let php = r#" Some((key.as_str(), *is_optional)), + _ => None, + }) + .collect::>(); + assert_eq!(limiters, vec![("2fa", false), ("60", true)]); +} + +#[test] +fn rate_limiter_registrations_reads_and_dynamic_names_are_distinguished() { + let php = " null); new RateLimited('api'); new \\Illuminate\\Queue\\Middleware\\RateLimitedWithRedis('api'); RateLimiter::for($dynamic, fn () => null); RateLimiter::for(); new RateLimited();"; + let map = parse_and_extract(php); + let occurrences = map + .spans + .iter() + .filter_map(|span| match &span.kind { + SymbolKind::LaravelStringKey { + kind: LaravelStringKind::RateLimiter, + key, + is_write, + .. + } => Some((key.as_str(), *is_write)), + _ => None, + }) + .collect::>(); + assert_eq!( + occurrences, + vec![("api", true), ("api", false), ("api", false)] + ); + assert!(map.has_dynamic_rate_limiter); +} + +#[test] +fn type_dependent_calls_and_connection_properties_are_candidates_not_guesses() { + let php = "connection(options: [], connection: 'mysql'); $job->onConnection(delay: 1, connection: 'sqs'); $job?->onQueue(delay: 1, queue: 'high'); } } class StaticConnection { protected static $connection = 'ignored'; }"; + let map = parse_and_extract(php); + assert!( + map.spans.iter().all(|span| !matches!( + span.kind, + SymbolKind::LaravelStringKey { + kind: LaravelStringKind::ConfigResource(_) | LaravelStringKind::QueueName, + .. + } + )), + "receiver types are unavailable during syntax extraction" + ); + assert_eq!(map.resource_receiver_sites.len(), 4); + assert_eq!( + map.resource_receiver_sites + .iter() + .map(|site| (site.rule, site.key.as_str())) + .collect::>(), + vec![ + (LaravelResourceReceiverRule::ConnectionProperty, "redis"), + (LaravelResourceReceiverRule::ConnectionMethod, "mysql"), + (LaravelResourceReceiverRule::QueueableConnection, "sqs"), + (LaravelResourceReceiverRule::QueueName, "high"), + ] + ); +} + +#[test] +fn unrelated_namespaced_facades_do_not_create_resource_spans() { + let php = ">(), + expected, + "{resource:?}" + ); + } +} + +#[test] +fn semantic_container_attribute_aliases_select_named_parameters_and_reject_homonyms() { + let php = r#" null, name: 'api'); +new LaravelRateLimited(limiterName: 'api'); +new RedisRateLimited(connection: 'redis', limiterName: 'api'); +Route::middleware(['auth:bad', 'throttle:bad']); +RateLimiter::for('bad', fn () => null); +new RateLimited(limiterName: 'bad'); +"#; + let map = parse_and_extract_semantic(php); + + assert_eq!( + resource_keys(&map, LaravelConfigResource::AuthGuard) + .iter() + .map(|(key, _, _)| key.as_str()) + .collect::>(), + vec!["web", "admin"] + ); + let occurrences = map + .spans + .iter() + .filter_map(|span| match &span.kind { + SymbolKind::LaravelStringKey { + kind: LaravelStringKind::RateLimiter, + key, + is_write, + .. + } => Some((key.as_str(), *is_write)), + _ => None, + }) + .collect::>(); + assert_eq!( + occurrences, + vec![ + ("api", false), + ("api", true), + ("api", false), + ("api", false) + ] + ); + assert!(!map.has_dynamic_rate_limiter); +} + +#[test] +fn semantic_config_facade_alias_uses_the_named_key_and_rejects_a_local_config_class() { + let php = r#" Some(key.as_str()), + _ => None, + }) + .collect::>(); + assert_eq!(keys, vec!["app.name"]); +} + // ── Container binding key spans ───────────────────────────────────── /// Every `ContainerBinding` key the map records, with whether the call @@ -4699,6 +5218,47 @@ fn a_gate_chain_is_followed_to_its_root_within_the_depth_bound() { } } +#[test] +fn semantic_facade_chains_accept_aliases_and_fqns_but_reject_local_homonyms() { + let php = r#"allows('gate-alias'); +\Illuminate\Support\Facades\Gate::forUser($user)->allows('gate-fqn'); +Gate::forUser($user)->allows('gate-local'); +LaravelRoute::get('/alias', $action)->can('route-alias', 'post'); +\Illuminate\Support\Facades\Route::get('/fqn', $action)->can('route-fqn', 'post'); +Route::get('/local', $action)->can('route-local', 'post'); +LaravelRoute::get('/middleware', $action)->middleware( + options: ['ignored'], + middleware: ['auth:web', 'can:middleware-alias'], +); +Route::get('/local-middleware', $action)->middleware( + options: ['ignored'], + middleware: ['auth:bad', 'can:middleware-local'], +); +"#; + let map = parse_and_extract_semantic(php); + + assert_eq!( + ability_keys(&map), + vec![ + "gate-alias", + "gate-fqn", + "route-alias", + "route-fqn", + "middleware-alias", + ] + ); + assert_eq!( + resource_keys(&map, LaravelConfigResource::AuthGuard), + vec![("web".to_string(), false, false)] + ); +} + /// `$this->authorizeForUser($user, 'ability', $model)` puts the user first, /// shifting the ability and the model along by one. #[test] @@ -4768,6 +5328,7 @@ fn other_middleware_records_no_ability() { "", "''", "'auth'", + "'signed:argument'", "$middleware", "'can:'", "['can:update', $x]", diff --git a/src/virtual_members/laravel/config_keys.rs b/src/virtual_members/laravel/config_keys.rs index 2d34b2158..ad95366b0 100644 --- a/src/virtual_members/laravel/config_keys.rs +++ b/src/virtual_members/laravel/config_keys.rs @@ -6,9 +6,8 @@ use mago_syntax::cst::*; use tower_lsp::lsp_types::{Location, Position, Url}; use crate::Backend; -use crate::atom::bytes_to_str; use crate::references::push_unique_location; -use crate::symbol_map::{SymbolKind, SymbolMap}; +use crate::symbol_map::{LaravelStringKind, SymbolKind, SymbolMap, SymbolSpan}; use crate::text_position::offset_to_position; #[derive(Debug)] @@ -18,6 +17,14 @@ pub(crate) struct ConfigKeyMatch { pub end: usize, } +#[derive(Debug)] +pub(crate) struct ConfigFileScan { + pub declarations: Vec, + /// Dot prefixes whose child keys are supplied by an expression that + /// cannot be enumerated statically (spread, variable, conditional, etc.). + pub open_prefixes: Vec, +} + /// Try to determine the dot-notated configuration prefix for a given file URI. /// /// For example, `file:///path/to/project/config/app.php` returns `Some("app")`. @@ -25,29 +32,14 @@ pub(crate) struct ConfigKeyMatch { pub(crate) fn laravel_config_prefix_from_uri(uri: &str) -> Option { let parsed = Url::parse(uri).ok()?; let path = parsed.path(); - let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); // Match the nearest `config` directory to the file path. This avoids // false negatives when an ancestor directory is also named `config`. - let config_idx = segments.iter().rposition(|seg| *seg == "config")?; - let file = segments.last()?; - if !file.ends_with(".php") { + let (_, relative) = path.rsplit_once("/config/")?; + let stem = relative.strip_suffix(".php")?; + if stem.is_empty() || stem.ends_with('/') { return None; } - - let prefix_segments = &segments[config_idx + 1..]; - if prefix_segments.is_empty() { - return None; - } - - let mut stem_segments: Vec = prefix_segments.iter().map(|s| s.to_string()).collect(); - let last = stem_segments.last_mut()?; - *last = last.strip_suffix(".php")?.to_string(); - - if last.is_empty() { - return None; - } - - Some(stem_segments.join(".")) + Some(stem.replace('/', ".")) } /// Collect Laravel config declaration keys from a `config/*.php` file. @@ -58,12 +50,19 @@ pub(crate) fn collect_laravel_config_declarations( content: &str, prefix: &str, ) -> Vec { + scan_laravel_config_file(content, prefix).declarations +} + +/// Parse one config file once, collecting both known declaration keys and +/// subtrees whose runtime children are unknowable. +pub(crate) fn scan_laravel_config_file(content: &str, prefix: &str) -> ConfigFileScan { let arena = LocalArena::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 out = Vec::new(); + let mut open_prefixes = Vec::new(); - let mut returned_var_name: Option = None; + let mut returned_var_name: Option<&[u8]> = None; let mut return_expr: Option<&Expression<'_>> = None; for stmt in program.statements.iter() { @@ -71,7 +70,7 @@ pub(crate) fn collect_laravel_config_declarations( if let Some(val) = ret.value { match val { Expression::Variable(Variable::Direct(dv)) => { - returned_var_name = Some(bytes_to_str(dv.name).to_string()); + returned_var_name = Some(dv.name); } _ => { return_expr = Some(val); @@ -82,21 +81,41 @@ pub(crate) fn collect_laravel_config_declarations( } } + let mut path = String::new(); if let Some(expr) = return_expr { - collect_expr_declarations(expr, content, prefix, &[], &mut out); + collect_expr_declarations( + expr, + content, + prefix, + &mut path, + &mut out, + &mut open_prefixes, + ); } else if let Some(var_name) = returned_var_name { for stmt in program.statements.iter() { if let Statement::Expression(expr_stmt) = stmt && let Expression::Assignment(assign) = expr_stmt.expression && let Expression::Variable(Variable::Direct(dv)) = assign.lhs - && dv.name == var_name.as_bytes() + && dv.name == var_name { - collect_expr_declarations(assign.rhs, content, prefix, &[], &mut out); + collect_expr_declarations( + assign.rhs, + content, + prefix, + &mut path, + &mut out, + &mut open_prefixes, + ); } } } - out + open_prefixes.sort(); + open_prefixes.dedup(); + ConfigFileScan { + declarations: out, + open_prefixes, + } } // ─── Declaration walker ─────────────────────────────────────────────────────── @@ -105,33 +124,51 @@ fn collect_expr_declarations( expr: &Expression<'_>, content: &str, prefix: &str, - path: &[String], + path: &mut String, out: &mut Vec, + open_prefixes: &mut Vec, ) { match expr { Expression::Array(arr) => { - collect_array_declarations(arr.elements.iter(), content, prefix, path, out); + collect_array_declarations( + arr.elements.iter(), + content, + prefix, + path, + out, + open_prefixes, + ); } Expression::LegacyArray(arr) => { - collect_array_declarations(arr.elements.iter(), content, prefix, path, out); + collect_array_declarations( + arr.elements.iter(), + content, + prefix, + path, + out, + open_prefixes, + ); } Expression::Parenthesized(p) => { - collect_expr_declarations(p.expression, content, prefix, path, out); + collect_expr_declarations(p.expression, content, prefix, path, out, open_prefixes); } - Expression::Call(Call::Function(fc)) => { - if let Expression::Identifier(ident) = fc.function - && ident.value().eq_ignore_ascii_case(b"array_merge") - { - for arg in fc.argument_list.arguments.iter() { - let arg_expr = match arg { - Argument::Positional(pos) => pos.value, - Argument::Named(named) => named.value, - }; - collect_expr_declarations(arg_expr, content, prefix, path, out); - } + Expression::Call(Call::Function(fc)) + if matches!(fc.function, Expression::Identifier(ident) + if ident.value().eq_ignore_ascii_case(b"array_merge")) => + { + for arg in fc.argument_list.arguments.iter() { + let arg_expr = match arg { + Argument::Positional(pos) => pos.value, + Argument::Named(named) => named.value, + }; + collect_expr_declarations(arg_expr, content, prefix, path, out, open_prefixes); } } - _ => {} + // Literal values cannot contribute child config keys. Everything + // else may evaluate to an array at runtime, so its subtree remains + // open rather than producing diagnostics from incomplete evidence. + Expression::Literal(_) | Expression::CompositeString(_) | Expression::MagicConstant(_) => {} + _ => open_prefixes.push(config_path(prefix, path)), } } @@ -139,29 +176,47 @@ fn collect_array_declarations<'a>( elements: impl Iterator>, content: &str, prefix: &str, - path: &[String], + path: &mut String, out: &mut Vec, + open_prefixes: &mut Vec, ) { for element in elements { let ArrayElement::KeyValue(kv) = element else { + if !matches!(element, ArrayElement::Missing(_)) { + open_prefixes.push(config_path(prefix, path)); + } continue; }; let (key_text, key_start, key_end) = match super::helpers::extract_string_literal(kv.key, content) { Some(k) => k, - None => continue, + None => { + open_prefixes.push(config_path(prefix, path)); + continue; + } }; - let mut full_path = path.to_vec(); - full_path.push(key_text.to_string()); - let dot_key = format!("{prefix}.{}", full_path.join(".")); + let previous_len = path.len(); + if previous_len != 0 { + path.push('.'); + } + path.push_str(key_text); out.push(ConfigKeyMatch { - key: dot_key, + key: config_path(prefix, path), start: key_start, end: key_end, }); - collect_expr_declarations(kv.value, content, prefix, &full_path, out); + collect_expr_declarations(kv.value, content, prefix, path, out, open_prefixes); + path.truncate(previous_len); + } +} + +fn config_path(prefix: &str, path: &str) -> String { + if path.is_empty() { + prefix.to_string() + } else { + format!("{prefix}.{path}") } } @@ -179,25 +234,35 @@ pub(crate) fn find_config_references( include_declaration: bool, ) -> Option> { // Fast path: cursor is on a usage site — symbol map already has the key. - let target_key = if let Some(sym) = backend.lookup_symbol_at_position(uri, content, position) { + let (target_kind, target_key) = if let Some(sym) = + backend.lookup_symbol_at_position(uri, content, position) + { match sym.kind { - SymbolKind::LaravelStringKey { key, .. } => key, + SymbolKind::LaravelStringKey { kind, key, .. } if kind.is_config_backed() => { + (kind, key) + } _ => return None, } } else { // Fallback: cursor is on a declaration key inside config/*.php. // This re-parses the current (single) config file — acceptable. - let prefix = laravel_config_prefix_from_uri(uri)?; + let prefix = config_prefix_for_uri(backend, uri)?; let cursor_offset = crate::text_position::position_to_offset(content, position) as usize; - collect_laravel_config_declarations(content, &prefix) + let key = collect_laravel_config_declarations(content, &prefix) .into_iter() .find(|d| cursor_offset >= d.start && cursor_offset <= d.end) - .map(|d| d.key)? + .map(|d| d.key)?; + (LaravelStringKind::Config, key) }; let snapshot = backend.user_file_symbol_maps(); - let locations = - find_all_config_references(backend, &target_key, &snapshot, include_declaration); + let locations = find_all_config_references( + backend, + &target_kind, + &target_key, + &snapshot, + include_declaration, + ); if locations.is_empty() { return None; @@ -210,56 +275,215 @@ pub(crate) fn find_config_references( /// [`SymbolKind::LaravelStringKey`] span with `kind == Config` at the cursor — /// no file re-parse is needed for the usage side. pub(crate) fn resolve_config_key_declaration(backend: &Backend, key: &str) -> Option { - let parts: Vec<&str> = key.split('.').collect(); + resolve_config_key_declaration_inner(backend, key, true) +} + +/// Resolve only an exact config declaration, without falling back to the +/// start of the file that owns the key's root. +pub(crate) fn resolve_config_key_declaration_exact( + backend: &Backend, + key: &str, +) -> Option { + resolve_config_key_declaration_inner(backend, key, false) +} + +fn resolve_config_key_declaration_inner( + backend: &Backend, + key: &str, + allow_file_fallback: bool, +) -> Option { let root = backend.workspace.workspace_root.read().clone()?; let config_dir = root.join("config"); + let mut fallback = None; + let mut relative_path = std::path::PathBuf::new(); + let mut stem = String::with_capacity(key.len()); - for i in 1..=parts.len() { - let (file_parts, _) = parts.split_at(i); - let rel_path = file_parts.join("/"); - let config_path = config_dir.join(format!("{}.php", rel_path)); - - if config_path.is_file() { - let target_uri = Url::from_file_path(&config_path).ok()?; - let target_uri_string = target_uri.to_string(); - let target_content = backend - .get_file_content(&target_uri_string) - .or_else(|| std::fs::read_to_string(&config_path).ok())?; - - let stem = file_parts.join("."); - let declarations = collect_laravel_config_declarations(&target_content, &stem); - if let Some(decl) = declarations.into_iter().find(|d| d.key == key) { - let pos = crate::text_position::offset_to_position(&target_content, decl.start); - return Some(crate::definition::point_location(target_uri, pos)); - } + for part in key.split('.') { + relative_path.push(part); + if !stem.is_empty() { + stem.push('.'); + } + stem.push_str(part); + let config_path = config_dir.join(&relative_path).with_extension("php"); + + if !config_path.is_file() { + continue; + } + let Some((target_uri, target_content)) = backend.laravel_config_file_content(&config_path) + else { + continue; + }; + fallback.get_or_insert_with(|| { + crate::definition::point_location(target_uri.clone(), Position::new(0, 0)) + }); + if let Some(location) = exact_config_declaration(&target_uri, &target_content, &stem, key) { + return Some(location); + } + } + + let provider_configs = backend + .laravel_provider_resources + .read() + .config_files + .iter() + .map(|resource| (resource.path.clone(), resource.namespace.clone())) + .collect::>(); + for (path, namespace) in provider_configs { + if !config_key_belongs_to_namespace(key, &namespace) || !path.is_file() { + continue; + } + let Some((target_uri, target_content)) = backend.laravel_config_file_content(&path) else { + continue; + }; + fallback.get_or_insert_with(|| { + crate::definition::point_location(target_uri.clone(), Position::new(0, 0)) + }); + if let Some(location) = + exact_config_declaration(&target_uri, &target_content, &namespace, key) + { + return Some(location); + } + } - return Some(crate::definition::point_location( - target_uri, - Position::new(0, 0), - )); + if let Some((path, prefix)) = framework_config_source(&root, key) + && let Some((target_uri, target_content)) = backend.laravel_config_file_content(&path) + { + fallback.get_or_insert_with(|| { + crate::definition::point_location(target_uri.clone(), Position::new(0, 0)) + }); + if let Some(location) = exact_config_declaration(&target_uri, &target_content, prefix, key) + { + return Some(location); } } - 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::text_position::offset_to_position(&target_content, decl.start); - return Some(crate::definition::point_location(target_uri, pos)); + if allow_file_fallback { fallback } else { None } +} + +impl Backend { + /// Read one config source, preferring any open URI spelling of the file. + /// + /// Provider registrations may retain a lexical path containing `..` or a + /// symlink while editors open its canonical spelling. Exact URI probes + /// keep the common path constant-time; the filename-filtered fallback + /// handles the uncommon inverse alias without canonicalizing unrelated + /// buffers. + pub(crate) fn laravel_config_file_content( + &self, + path: &std::path::Path, + ) -> Option<(Url, Arc)> { + let raw_uri = Url::from_file_path(path).ok()?; + let alias_candidates = { + let open_files = self.open_files.read(); + if let Some(content) = open_files.get(raw_uri.as_str()) { + return Some((raw_uri, Arc::clone(content))); + } + if open_files.is_empty() { + return std::fs::read_to_string(path) + .ok() + .map(|content| (raw_uri, Arc::new(content))); + } + + let file_name = path.file_name(); + open_files + .iter() + .filter_map(|(uri, content)| { + let parsed_uri = Url::parse(uri).ok()?; + let open_path = parsed_uri.to_file_path().ok()?; + (open_path.file_name() == file_name).then_some(( + parsed_uri, + open_path, + Arc::clone(content), + )) + }) + .collect::>() + }; + + if alias_candidates.is_empty() { + return std::fs::read_to_string(path) + .ok() + .map(|content| (raw_uri, Arc::new(content))); + } + let canonical_path = path.canonicalize().ok(); + for (uri, open_path, content) in alias_candidates { + if open_path == path + || canonical_path.as_ref().is_some_and(|canonical| { + open_path + .canonicalize() + .is_ok_and(|open_canonical| open_canonical == *canonical) + }) + { + return Some((uri, content)); } - return Some(crate::definition::point_location( - target_uri, - Position::new(0, 0), - )); } + + std::fs::read_to_string(path) + .ok() + .map(|content| (raw_uri, Arc::new(content))) } +} +fn config_prefix_for_uri(backend: &Backend, uri: &str) -> Option { + if let Some(prefix) = laravel_config_prefix_from_uri(uri) { + return Some(prefix); + } + let edited_path = Url::parse(uri).ok()?.to_file_path().ok()?; + let candidates = { + let resources = backend.laravel_provider_resources.read(); + let mut candidates = Vec::new(); + for resource in &resources.config_files { + if edited_path == resource.path { + return Some(resource.namespace.clone()); + } + if edited_path.file_name() == resource.path.file_name() { + candidates.push((resource.path.clone(), resource.namespace.clone())); + } + } + candidates + }; + if candidates.is_empty() { + return None; + } + let edited_path = edited_path.canonicalize().ok()?; + for (candidate, namespace) in candidates { + if candidate + .canonicalize() + .is_ok_and(|path| path == edited_path) + { + return Some(namespace); + } + } None } +fn exact_config_declaration(uri: &Url, content: &str, prefix: &str, key: &str) -> Option { + let declaration = collect_laravel_config_declarations(content, prefix) + .into_iter() + .find(|declaration| declaration.key == key)?; + Some(crate::definition::point_location( + uri.clone(), + offset_to_position(content, declaration.start), + )) +} + +fn config_key_belongs_to_namespace(key: &str, namespace: &str) -> bool { + key == namespace + || key + .strip_prefix(namespace) + .is_some_and(|suffix| suffix.starts_with('.')) +} + +fn framework_config_source<'a>( + root: &std::path::Path, + key: &'a str, +) -> Option<(std::path::PathBuf, &'a str)> { + let prefix = key.split('.').next()?; + let path = root + .join("vendor/laravel/framework/config") + .join(format!("{prefix}.php")); + path.is_file().then_some((path, prefix)) +} + /// Find all references for a Laravel config key across the project. /// /// Iterates pre-built [`SymbolKind::LaravelStringKey`] spans for usages @@ -268,30 +492,46 @@ pub(crate) fn resolve_config_key_declaration(backend: &Backend, key: &str) -> Op /// set is small (typically < 20 files) and each parse is cheap. pub(crate) fn find_all_config_references( backend: &Backend, + target_kind: &LaravelStringKind, target_key: &str, snapshot: &[(String, Arc)], include_declaration: bool, ) -> Vec { + if !target_kind.is_config_backed() { + return Vec::new(); + } let mut locations = Vec::new(); // Usages: walk pre-built symbol spans — no file re-parse needed. for (file_uri, symbol_map) in snapshot { - let parsed_uri = match Url::parse(file_uri) { - Ok(u) => u, - Err(_) => continue, + let has_candidate = symbol_map.resource_receiver_sites.iter().any(|site| { + site.rule + .candidate_kinds() + .iter() + .any(|candidate| config_keys_match(target_kind, target_key, candidate, &site.key)) + }); + let extra = + has_candidate.then(|| backend.typed_receiver_view_spans_for(file_uri, symbol_map)); + if !symbol_map + .spans + .iter() + .chain(extra.iter().flat_map(|spans| spans.iter())) + .any(|span| config_span_matches(span, target_kind, target_key)) + { + continue; + } + let Ok(parsed_uri) = Url::parse(file_uri) else { + continue; }; - let file_content = match backend.get_file_content_arc(file_uri) { - Some(c) => c, - None => continue, + let Some(file_content) = backend.get_file_content_arc(file_uri) else { + continue; }; - for span in &symbol_map.spans { - if let SymbolKind::LaravelStringKey { - kind: crate::symbol_map::LaravelStringKind::Config, - key, - .. - } = &span.kind - && key == target_key - { + for span in symbol_map + .spans + .iter() + .chain(extra.iter().flat_map(|spans| spans.iter())) + { + if config_span_matches(span, target_kind, target_key) { let start = offset_to_position(&file_content, span.start as usize); let end = offset_to_position(&file_content, span.end as usize); push_unique_location(&mut locations, &parsed_uri, start, end); @@ -301,26 +541,76 @@ pub(crate) fn find_all_config_references( // Declarations: keys in config/*.php (small set, AST walk acceptable). if include_declaration { - for (file_uri, _) in snapshot { - let prefix = match laravel_config_prefix_from_uri(file_uri) { - Some(p) => p, - None => continue, - }; - let parsed_uri = match Url::parse(file_uri) { - Ok(u) => u, - Err(_) => continue, - }; - let file_content = match backend.get_file_content_arc(file_uri) { - Some(c) => c, - None => continue, - }; - for decl in collect_laravel_config_declarations(&file_content, &prefix) { - if decl.key != target_key { + let declaration_key = canonical_config_key(target_kind, target_key); + if let Some(root) = backend.workspace.workspace_root.read().clone() { + let config_dir = root.join("config"); + let mut app_configs = Vec::new(); + collect_php_files(&config_dir, &mut app_configs); + app_configs.sort_unstable(); + for path in app_configs { + let relative = path + .strip_prefix(&config_dir) + .expect("collected config file must remain below its scan root"); + let prefix = config_prefix_from_relative_path(relative); + let Some((parsed_uri, file_content)) = backend.laravel_config_file_content(&path) + else { continue; + }; + for decl in collect_laravel_config_declarations(&file_content, &prefix) { + if decl.key == declaration_key { + push_unique_location( + &mut locations, + &parsed_uri, + offset_to_position(&file_content, decl.start), + offset_to_position(&file_content, decl.end), + ); + } + } + } + } + + // Package providers can merge config from outside the application's + // own `config/` directory. Those files are vendor-filtered from the + // symbol-map snapshot but remain real declaration destinations. + let provider_configs = backend + .laravel_provider_resources + .read() + .config_files + .iter() + .map(|resource| (resource.path.clone(), resource.namespace.clone())) + .collect::>(); + for (path, namespace) in provider_configs { + let Some((parsed_uri, content)) = backend.laravel_config_file_content(&path) else { + continue; + }; + for declaration in collect_laravel_config_declarations(&content, &namespace) { + if declaration.key == declaration_key { + push_unique_location( + &mut locations, + &parsed_uri, + offset_to_position(&content, declaration.start), + offset_to_position(&content, declaration.end), + ); + } + } + } + + // Laravel's unpublished defaults are completion candidates too, so + // their declarations participate in references exactly like app and + // package config entries. + if let Some(root) = backend.workspace.workspace_root.read().clone() + && let Some((path, prefix)) = framework_config_source(&root, &declaration_key) + && let Some((parsed_uri, content)) = backend.laravel_config_file_content(&path) + { + for declaration in collect_laravel_config_declarations(&content, prefix) { + if declaration.key == declaration_key { + push_unique_location( + &mut locations, + &parsed_uri, + offset_to_position(&content, declaration.start), + offset_to_position(&content, declaration.end), + ); } - let start = offset_to_position(&file_content, decl.start); - let end = offset_to_position(&file_content, decl.end); - push_unique_location(&mut locations, &parsed_uri, start, end); } } } @@ -328,6 +618,75 @@ pub(crate) fn find_all_config_references( locations } +fn config_prefix_from_relative_path(relative: &std::path::Path) -> String { + let stem = relative.with_extension(""); + let mut prefix = String::new(); + for component in stem.components() { + if !prefix.is_empty() { + prefix.push('.'); + } + prefix.push_str(&component.as_os_str().to_string_lossy()); + } + prefix +} + +fn config_span_matches( + span: &SymbolSpan, + target_kind: &LaravelStringKind, + target_key: &str, +) -> bool { + matches!( + &span.kind, + SymbolKind::LaravelStringKey { kind, key, .. } + if config_keys_match(target_kind, target_key, kind, key) + ) +} + +fn collect_php_files(directory: &std::path::Path, files: &mut Vec) { + let Ok(entries) = std::fs::read_dir(directory) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_php_files(&path, files); + } else if path.extension().is_some_and(|extension| extension == "php") { + files.push(path); + } + } +} + +fn config_keys_match( + left_kind: &LaravelStringKind, + left_key: &str, + right_kind: &LaravelStringKind, + right_key: &str, +) -> bool { + match (left_kind, right_kind) { + (LaravelStringKind::Config, LaravelStringKind::Config) => left_key == right_key, + (LaravelStringKind::ConfigResource(left), LaravelStringKind::ConfigResource(right)) => { + left == right && left_key == right_key + } + (LaravelStringKind::Config, LaravelStringKind::ConfigResource(resource)) => { + crate::symbol_map::laravel_resources::matches_config_key(*resource, right_key, left_key) + } + (LaravelStringKind::ConfigResource(resource), LaravelStringKind::Config) => { + crate::symbol_map::laravel_resources::matches_config_key(*resource, left_key, right_key) + } + _ => false, + } +} + +fn canonical_config_key(kind: &LaravelStringKind, key: &str) -> String { + match kind { + LaravelStringKind::Config => key.to_string(), + LaravelStringKind::ConfigResource(resource) => { + crate::symbol_map::laravel_resources::config_key(*resource, key) + } + _ => String::new(), + } +} + /// Fallback for "go to definition" on a key inside config/*.php. /// /// Since array keys are not indexed in the symbol map, the generic @@ -335,12 +694,12 @@ pub(crate) fn find_all_config_references( /// if the cursor is on a known config key, and if so, returns a Location /// pointing to the same file (enabling Find All References for that key). pub(crate) fn resolve_config_key_definition_fallback( - _backend: &Backend, + backend: &Backend, uri: &str, content: &str, position: Position, ) -> Option { - let prefix = laravel_config_prefix_from_uri(uri)?; + let prefix = config_prefix_for_uri(backend, uri)?; let cursor_offset = crate::text_position::position_to_offset(content, position) as usize; let decls = collect_laravel_config_declarations(content, &prefix); let match_ = decls @@ -394,6 +753,10 @@ mod tests { laravel_config_prefix_from_uri("file:///project/config/mail/transport.php"), Some("mail.transport".to_string()) ); + assert_eq!( + config_prefix_from_relative_path(std::path::Path::new("mail/transport.php")), + "mail.transport" + ); } #[test] @@ -406,6 +769,18 @@ mod tests { ); } + #[test] + fn config_prefix_from_uri_rejects_empty_file_stems() { + assert_eq!( + laravel_config_prefix_from_uri("file:///project/config/.php"), + None + ); + assert_eq!( + laravel_config_prefix_from_uri("file:///project/config/nested/.php"), + None + ); + } + #[test] fn test_collect_declarations_variable_return() { let content = " array_merge([ + 'array' => ['driver' => 'array'], + ], $packageStores), + 'default' => 'array', +];"; + + let scan = scan_laravel_config_file(content, "cache"); + let keys = scan + .declarations + .iter() + .map(|declaration| declaration.key.as_str()) + .collect::>(); + + assert!(keys.contains(&"cache.stores.array")); + assert!(keys.contains(&"cache.default")); + assert_eq!(scan.open_prefixes, ["cache.stores"]); + } + + #[test] + fn scan_handles_legacy_arrays_named_merge_arguments_and_dynamic_elements() { + let content = r#" array('leaf' => true))), + second: [ + 'mixed' => [...$spread, $dynamicKey => [], 'unkeyed'], + ], +);"#; + + let scan = scan_laravel_config_file(content, "app"); + let keys = scan + .declarations + .iter() + .map(|declaration| declaration.key.as_str()) + .collect::>(); + + assert!(keys.contains(&"app.legacy")); + assert!(keys.contains(&"app.legacy.leaf")); + assert!(keys.contains(&"app.mixed")); + assert_eq!(scan.open_prefixes, ["app.mixed"]); + assert_eq!(config_path("app", ""), "app"); + } + + #[test] + fn config_usage_reference_lookup_accepts_config_backed_symbol_kinds() { + let backend = Backend::new_test(); + let uri = "file:///config-usage.php"; + let content = Arc::new(" 'array'];\n").unwrap(); + std::fs::write(&unrelated_provider, " true];\n").unwrap(); + + let backend = Backend::new_test(); + *backend.workspace.workspace_root.write() = Some(dir.path().to_path_buf()); + backend + .laravel_provider_resources + .write() + .config_files + .push(crate::virtual_members::laravel::ProviderResource { + path: unrelated_provider.clone(), + namespace: "package".to_string(), + }); + + let provider_location = + resolve_config_key_declaration_inner(&backend, "package.value", false).unwrap(); + assert_eq!( + provider_location.uri, + Url::from_file_path(&unrelated_provider).unwrap() + ); + let provider_fallback = + resolve_config_key_declaration_inner(&backend, "package.missing", true).unwrap(); + assert_eq!( + provider_fallback.uri, + Url::from_file_path(&unrelated_provider).unwrap() + ); + assert_eq!(provider_fallback.range.start, Position::new(0, 0)); + + let location = + resolve_config_key_declaration_inner(&backend, "cache.missing", true).unwrap(); + assert_eq!(location.uri, Url::from_file_path(framework_config).unwrap()); + assert_eq!(location.range.start, Position::new(0, 0)); + } + + #[test] + fn config_content_ignores_an_unrelated_open_file_with_the_same_name() { + let dir = tempfile::tempdir().unwrap(); + let target = dir.path().join("package/config/settings.php"); + let unrelated = dir.path().join("other/config/settings.php"); + std::fs::create_dir_all(target.parent().unwrap()).unwrap(); + std::fs::create_dir_all(unrelated.parent().unwrap()).unwrap(); + std::fs::write(&target, " 'disk'];\n").unwrap(); + std::fs::write(&unrelated, " 'buffer'];\n".to_string()), + ); + + let (uri, content) = backend.laravel_config_file_content(&target).unwrap(); + assert_eq!(uri, Url::from_file_path(target).unwrap()); + assert!(content.contains("'disk'")); + } + + #[test] + fn unreadable_config_sources_are_ignored_at_every_precedence_layer() { + let dir = tempfile::tempdir().unwrap(); + let app_config = dir.path().join("config/cache.php"); + let empty_stem_config = dir.path().join("config/.php"); + let provider_config = dir.path().join("package/settings.php"); + std::fs::create_dir_all(app_config.parent().unwrap()).unwrap(); + std::fs::create_dir_all(provider_config.parent().unwrap()).unwrap(); + std::fs::write(&app_config, [0xff_u8, 0xfe]).unwrap(); + std::fs::write(&empty_stem_config, " true];").unwrap(); + std::fs::write(&provider_config, [0xff_u8, 0xfe]).unwrap(); + + let backend = Backend::new_test(); + *backend.workspace.workspace_root.write() = Some(dir.path().to_path_buf()); + backend + .laravel_provider_resources + .write() + .config_files + .push(crate::virtual_members::laravel::ProviderResource { + path: provider_config, + namespace: "package".to_string(), + }); + + assert!(resolve_config_key_declaration_inner(&backend, "cache.default", false).is_none()); + assert!(resolve_config_key_declaration_inner(&backend, "package.value", false).is_none()); + assert!( + find_all_config_references( + &backend, + &LaravelStringKind::Config, + "cache.default", + &[], + true, + ) + .is_empty() + ); + } + + #[test] + fn provider_config_prefix_matches_exact_and_canonical_paths() { + let dir = tempfile::tempdir().unwrap(); + let target_dir = dir.path().join("package/resources"); + let detour = target_dir.join("detour"); + let target = target_dir.join("settings.php"); + std::fs::create_dir_all(&detour).unwrap(); + std::fs::write(&target, " { backend.blade_block_definitions(uri, crate::blade::blocks::BlockKind::Stack, key) } - LaravelStringKind::Config => resolve_config_key_declaration(backend, key) - .into_iter() - .collect(), + LaravelStringKind::Config => { + if let Some((resource, name)) = + crate::symbol_map::laravel_resources::resource_from_config_key(key) + { + if let Some(location) = resolve_config_key_declaration_exact(backend, key) { + vec![location] + } else { + source_definition_locations( + backend, + backend + .laravel_source_strings + .read() + .runtime_config_resource_definitions(resource, name), + ) + } + } else { + resolve_config_key_declaration(backend, key) + .into_iter() + .collect() + } + } + LaravelStringKind::ConfigResource(resource) => { + let config_key = crate::symbol_map::laravel_resources::config_key(*resource, key); + if let Some(location) = resolve_config_key_declaration_exact(backend, &config_key) { + vec![location] + } else { + source_definition_locations( + backend, + backend + .laravel_source_strings + .read() + .runtime_config_resource_definitions(*resource, key), + ) + } + } LaravelStringKind::View => view_names::resolve_view_definitions(backend, key), LaravelStringKind::Route => route_names::resolve_route_definitions(backend, key), LaravelStringKind::Trans => trans_keys::resolve_trans_definitions(backend, key), @@ -48,6 +83,14 @@ pub(crate) fn resolve_laravel_string_key( LaravelStringKind::MorphAlias => resolve_morph_alias_definitions(backend, key), LaravelStringKind::GateAbility => resolve_gate_ability_definitions(backend, key), LaravelStringKind::ContainerBinding => resolve_container_binding_definitions(backend, key), + LaravelStringKind::RateLimiter => source_definition_locations( + backend, + backend + .laravel_source_strings + .read() + .rate_limiter_definitions(key), + ), + LaravelStringKind::QueueName => resolve_queue_name_definitions(backend, key), } } @@ -210,8 +253,27 @@ pub(crate) fn find_laravel_string_key_references( ) -> Vec { use crate::symbol_map::LaravelStringKind; let mut locations = match kind { - LaravelStringKind::Config => { - find_all_config_references(backend, key, snapshot, include_declaration) + LaravelStringKind::Config | LaravelStringKind::ConfigResource(_) => { + let mut locations = + find_all_config_references(backend, kind, key, snapshot, include_declaration); + let declarations = runtime_config_definition_locations(backend, kind, key); + if include_declaration { + for declaration in declarations { + crate::references::push_unique_location( + &mut locations, + &declaration.uri, + declaration.range.start, + declaration.range.end, + ); + } + } else if !declarations.is_empty() { + // A generic `config('…')` lookup and its `Config::set('…')` + // declaration share the same symbol kind, so the indexed + // usage scan sees both. Honour ReferenceContext by removing + // the exact write locations when declarations were excluded. + locations.retain(|location| !declarations.contains(location)); + } + locations } // Two unrelated pages that both fill `content` fill two different // sections, so the span index's project-wide answer is the wrong @@ -236,12 +298,14 @@ pub(crate) fn find_laravel_string_key_references( | LaravelStringKind::Command | LaravelStringKind::MorphAlias | LaravelStringKind::GateAbility - | LaravelStringKind::ContainerBinding => { - find_string_key_usages(kind, key, backend, snapshot) + | LaravelStringKind::ContainerBinding + | LaravelStringKind::RateLimiter + | LaravelStringKind::QueueName => { + find_string_key_usages(kind, key, backend, snapshot, include_declaration) } }; - if include_declaration && kind != &LaravelStringKind::Config { + if include_declaration && !kind.is_config_backed() { for decl in resolve_laravel_string_key(backend, kind, key, uri) { crate::references::push_unique_location( &mut locations, @@ -255,6 +319,31 @@ pub(crate) fn find_laravel_string_key_references( locations } +/// Runtime config-resource declarations are indexed independently of symbol +/// maps so they remain navigable after their source file is closed. Both the +/// full config key and its resource-specific spelling address the same entry. +fn runtime_config_definition_locations( + backend: &crate::Backend, + kind: &crate::symbol_map::LaravelStringKind, + key: &str, +) -> Vec { + let resource = match kind { + crate::symbol_map::LaravelStringKind::Config => { + crate::symbol_map::laravel_resources::resource_from_config_key(key) + } + crate::symbol_map::LaravelStringKind::ConfigResource(resource) => Some((*resource, key)), + _ => None, + }; + let Some((resource, name)) = resource else { + return Vec::new(); + }; + let definitions = backend + .laravel_source_strings + .read() + .runtime_config_resource_definitions(resource, name); + source_definition_locations(backend, definitions) +} + /// Scan pre-built [`crate::symbol_map::SymbolMap`] spans for all call sites /// matching `kind` + `key` — zero file re-parses, O(total spans) memory walk. fn find_string_key_usages( @@ -262,9 +351,9 @@ fn find_string_key_usages( key: &str, backend: &crate::Backend, snapshot: &[(String, std::sync::Arc)], + include_declaration: bool, ) -> Vec { use crate::references::push_unique_location; - use crate::symbol_map::SymbolKind; use crate::text_position::offset_to_position; use tower_lsp::lsp_types::Url; @@ -277,27 +366,21 @@ fn find_string_key_usages( let has_candidate = symbol_map .view_receiver_sites .iter() - .any(|site| *kind == crate::symbol_map::LaravelStringKind::View && site.key == key); - let extra = if has_candidate { - backend.typed_receiver_view_spans_for(file_uri, symbol_map) - } else { - std::sync::Arc::new(Vec::new()) - }; + .any(|site| *kind == crate::symbol_map::LaravelStringKind::View && site.key == key) + || symbol_map + .resource_receiver_sites + .iter() + .any(|site| site.key == key && site.rule.candidate_kinds().contains(kind)); + let extra = + has_candidate.then(|| backend.typed_receiver_view_spans_for(file_uri, symbol_map)); // First pass: check if this file even has ANY LaravelStringKey matches. // This avoids reading file content from disk for thousands of unrelated files. - let has_match = symbol_map.spans.iter().chain(extra.iter()).any(|span| { - if let SymbolKind::LaravelStringKey { - kind: span_kind, - key: span_key, - .. - } = &span.kind - { - span_kind == kind && span_key == key - } else { - false - } - }); + let has_match = symbol_map + .spans + .iter() + .chain(extra.iter().flat_map(|spans| spans.iter())) + .any(|span| string_key_span_matches(span, kind, key, include_declaration)); if !has_match { continue; @@ -309,15 +392,12 @@ fn find_string_key_usages( let Some(content) = backend.get_file_content_arc(file_uri) else { continue; }; - for span in symbol_map.spans.iter().chain(extra.iter()) { - if let SymbolKind::LaravelStringKey { - kind: span_kind, - key: span_key, - .. - } = &span.kind - && span_kind == kind - && span_key == key - { + for span in symbol_map + .spans + .iter() + .chain(extra.iter().flat_map(|spans| spans.iter())) + { + if string_key_span_matches(span, kind, key, include_declaration) { let start = offset_to_position(&content, span.start as usize); let end = offset_to_position(&content, span.end as usize); push_unique_location(&mut locations, &parsed_uri, start, end); @@ -326,3 +406,420 @@ fn find_string_key_usages( } locations } + +fn string_key_span_matches( + span: &crate::symbol_map::SymbolSpan, + kind: &crate::symbol_map::LaravelStringKind, + key: &str, + include_declaration: bool, +) -> bool { + matches!( + &span.kind, + crate::symbol_map::SymbolKind::LaravelStringKey { + kind: span_kind, + key: span_key, + is_write, + .. + } if span_kind == kind + && span_key == key + && (include_declaration + || *kind != crate::symbol_map::LaravelStringKind::RateLimiter + || !is_write) + ) +} + +/// Materialize the lazy typed queue-name index once per workspace generation, +/// then resolve this and subsequent names with one ordered-map lookup. +fn resolve_queue_name_definitions(backend: &crate::Backend, key: &str) -> Vec { + resolve_queue_name_definitions_with(backend, key, |backend| { + for (uri, map) in backend.user_file_symbol_maps() { + if map + .resource_receiver_sites + .iter() + .any(|site| site.rule == crate::symbol_map::LaravelResourceReceiverRule::QueueName) + { + backend.typed_receiver_view_spans_for(&uri, &map); + } + } + }) +} + +fn resolve_queue_name_definitions_with( + backend: &crate::Backend, + key: &str, + mut materialize_candidates: impl FnMut(&crate::Backend), +) -> Vec { + loop { + let generation = { + let index = backend.laravel_source_strings.read(); + if index.queue_names_are_complete() { + let definitions = index.queue_name_definitions(key); + drop(index); + return source_definition_locations(backend, definitions); + } + index.queue_name_generation() + }; + materialize_candidates(backend); + let mut index = backend.laravel_source_strings.write(); + let definitions = complete_queue_name_scan(&mut index, generation, key); + drop(index); + if let Some(definitions) = definitions { + return source_definition_locations(backend, definitions); + } + } +} + +/// Atomically publish a typed queue scan and read its result. A generation +/// change rejects the whole attempt so the caller can rescan current maps. +fn complete_queue_name_scan( + index: &mut crate::laravel_string_index::LaravelSourceStringIndex, + generation: u64, + key: &str, +) -> Option> { + index + .mark_queue_names_complete(generation) + .then(|| index.queue_name_definitions(key)) +} + +fn source_definition_locations( + backend: &crate::Backend, + definitions: Vec, +) -> Vec { + let mut locations = Vec::with_capacity(definitions.len()); + append_source_definitions(backend, &mut locations, definitions); + locations +} + +fn append_source_definitions( + backend: &crate::Backend, + locations: &mut Vec, + definitions: Vec, +) { + use crate::references::push_unique_location; + use crate::text_position::offset_to_position; + use tower_lsp::lsp_types::Url; + + let mut current_uri: Option> = None; + let mut current_source: Option<(Url, std::sync::Arc)> = None; + for definition in definitions { + if current_uri.as_deref() != Some(definition.uri.as_ref()) { + current_source = Url::parse(&definition.uri) + .ok() + .zip(backend.get_file_content_arc(&definition.uri)); + current_uri = Some(std::sync::Arc::clone(&definition.uri)); + } + let Some((uri, content)) = ¤t_source else { + continue; + }; + push_unique_location( + locations, + uri, + offset_to_position(content, definition.start as usize), + offset_to_position(content, definition.end as usize), + ); + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + use crate::symbol_map::{ + LaravelConfigResource, LaravelStringKind, SymbolMap, extract_symbol_map, + }; + + fn map(source: &str) -> Arc { + Arc::new(crate::parser::with_parsed_program( + source, + "laravel_string_key_references", + extract_symbol_map, + )) + } + + #[test] + fn full_config_resource_keys_prefer_files_then_runtime_writes() { + let dir = tempfile::tempdir().unwrap(); + let config = dir.path().join("config/cache.php"); + let runtime = dir.path().join("bootstrap/resources.php"); + std::fs::create_dir_all(config.parent().unwrap()).unwrap(); + std::fs::create_dir_all(runtime.parent().unwrap()).unwrap(); + std::fs::write( + &config, + " ['redis' => ['driver' => 'redis']]];\n", + ) + .unwrap(); + let runtime_source = " null); Route::middleware('throttle:api');\n"; + std::fs::write(&source_path, source).unwrap(); + let uri = crate::util::path_to_uri(&source_path); + let snapshot = [(uri.to_string(), map(source))]; + + let locations = find_string_key_usages( + &LaravelStringKind::RateLimiter, + "api", + &crate::Backend::new_test(), + &snapshot, + false, + ); + + assert_eq!(locations.len(), 1); + assert!( + find_string_key_usages( + &LaravelStringKind::RateLimiter, + "api", + &crate::Backend::new_test(), + &[( + "file:///missing-registration-only.php".to_string(), + map(" null);"), + )], + false, + ) + .is_empty() + ); + } + + #[test] + fn duplicate_source_definitions_in_one_file_all_resolve() { + let dir = tempfile::tempdir().unwrap(); + let source_path = dir.path().join("app/Providers/RateLimitProvider.php"); + let source = " null); RateLimiter::for('api', fn () => null);\n"; + std::fs::create_dir_all(source_path.parent().unwrap()).unwrap(); + std::fs::write(&source_path, source).unwrap(); + + let backend = crate::Backend::new_test(); + let uri = crate::util::path_to_uri(&source_path); + backend.update_ast(uri.as_str(), source); + + let locations = resolve_laravel_string_key( + &backend, + &LaravelStringKind::RateLimiter, + "api", + uri.as_str(), + ); + assert_eq!(locations.len(), 2); + assert_eq!(locations[0].range.start.line, locations[1].range.start.line); + assert!(locations[0].range.start.character < locations[1].range.start.character); + } + + #[test] + fn queue_definition_scan_materializes_candidate_files_once() { + let dir = tempfile::tempdir().unwrap(); + let source_path = dir.path().join("app/Job.php"); + let source = "onQueue('high');\n"; + std::fs::create_dir_all(source_path.parent().unwrap()).unwrap(); + std::fs::write(&source_path, source).unwrap(); + + let backend = crate::Backend::new_test(); + *backend.workspace.workspace_root.write() = Some(dir.path().to_path_buf()); + let uri = crate::util::path_to_uri(&source_path); + backend.update_ast(uri.as_str(), source); + + assert!( + resolve_laravel_string_key( + &backend, + &LaravelStringKind::QueueName, + "high", + uri.as_str(), + ) + .is_empty() + ); + assert!( + backend + .laravel_source_strings + .read() + .queue_names_are_complete() + ); + assert!( + resolve_laravel_string_key( + &backend, + &LaravelStringKind::QueueName, + "high", + uri.as_str(), + ) + .is_empty() + ); + } + + #[test] + fn queue_scan_publication_rejects_stale_generations() { + let backend = crate::Backend::new_test(); + let mut scans = 0; + let locations = resolve_queue_name_definitions_with(&backend, "high", |backend| { + scans += 1; + if scans == 1 { + let mut candidate_map = SymbolMap::default(); + candidate_map.resource_receiver_sites.push( + crate::symbol_map::LaravelResourceReceiverSite { + start: 1, + end: 5, + key: "high".to_string(), + rule: crate::symbol_map::LaravelResourceReceiverRule::QueueName, + }, + ); + backend + .laravel_source_strings + .write() + .set_symbol_map_contributions( + "file:///job.php", + crate::laravel_string_index::LaravelSourceStringContributions::from_symbol_map( + &candidate_map, + ), + ); + } + }); + + assert!(locations.is_empty()); + assert_eq!(scans, 2, "a stale publication must retry the scan"); + assert!( + backend + .laravel_source_strings + .read() + .queue_names_are_complete() + ); + } + + #[test] + fn runtime_definition_and_location_helpers_reject_unusable_inputs() { + let backend = crate::Backend::new_test(); + assert!( + runtime_config_definition_locations(&backend, &LaravelStringKind::RateLimiter, "api") + .is_empty() + ); + + let mut locations = Vec::new(); + append_source_definitions( + &backend, + &mut locations, + vec![ + crate::laravel_string_index::LaravelSourceStringDefinition { + uri: Arc::from("not a URI"), + start: 0, + end: 1, + }, + crate::laravel_string_index::LaravelSourceStringDefinition { + uri: Arc::from("file:///missing-source.php"), + start: 0, + end: 1, + }, + ], + ); + assert!(locations.is_empty()); + + assert!( + resolve_laravel_string_key( + &backend, + &LaravelStringKind::ConfigResource(LaravelConfigResource::CacheStore), + "missing", + "file:///usage.php", + ) + .is_empty() + ); + } +} diff --git a/src/workspace_env.rs b/src/workspace_env.rs index 2efd55c51..71379e939 100644 --- a/src/workspace_env.rs +++ b/src/workspace_env.rs @@ -27,7 +27,7 @@ pub(crate) struct WorkspaceEnv { pub(crate) psr4_mappings: Arc>>, /// `file://` URI prefixes for all known vendor directories. pub(crate) vendor_uri_prefixes: Mutex>, - /// Absolute paths of all known vendor directories. + /// Absolute raw and canonical paths of all known vendor directories. pub(crate) vendor_dir_paths: Mutex>, /// Canonical vendor package roots paired with completion provenance. pub(crate) vendor_package_origin_roots: diff --git a/tests/integration/laravel_named_resource_types.rs b/tests/integration/laravel_named_resource_types.rs new file mode 100644 index 000000000..f85864182 --- /dev/null +++ b/tests/integration/laravel_named_resource_types.rs @@ -0,0 +1,1580 @@ +//! End-to-end coverage for source-defined and type-dependent Laravel names. +//! +//! Rate limiter and queue names are application vocabulary rather than config +//! keys. Connection strings are more subtle: the same `connection()` method +//! can name a database, queue, or broadcast connection, while `$connection` +//! means a database on an Eloquent model and a queue backend on a queued job. +//! These tests keep the receiver types explicit and include ordinary classes +//! with identical method/property names to guard against lexical matching. + +use crate::common::create_psr4_workspace; +use phpantom_lsp::Backend; +use tower_lsp::LanguageServer; +use tower_lsp::lsp_types::*; + +const COMPOSER_JSON: &str = r#"{ + "require": { "laravel/framework": "^12.0" }, + "autoload": { + "psr-4": { + "App\\": "app/", + "Illuminate\\": "vendor/illuminate/" + } + } +}"#; + +const DATABASE_CONFIG: &str = r#" 'mysql', + 'connections' => [ + 'mysql' => ['driver' => 'mysql'], + 'analytics-db' => ['driver' => 'pgsql'], + ], +]; +"#; + +const QUEUE_CONFIG: &str = r#" 'sync', + 'connections' => [ + 'sync' => ['driver' => 'sync'], + 'redis-queue' => ['driver' => 'redis'], + ], +]; +"#; + +const BROADCASTING_CONFIG: &str = r#" 'reverb', + 'connections' => [ + 'reverb' => ['driver' => 'reverb'], + 'pusher-broadcast' => ['driver' => 'pusher'], + ], +]; +"#; + +const RATE_LIMITER_FACADE: &str = r#" null); + RateLimiter::for('uploads', static fn () => null); + RateLimiter::for('2fa', static fn () => null); + } +} +"#; + +const ORDINARY_CONNECTOR: &str = r#"connection = $name ?? ''; + return $this; + } + + public function onQueue(?string $name = null): static + { + return $this; + } +} +"#; + +fn base_files() -> Vec<(&'static str, &'static str)> { + vec![ + ("config/database.php", DATABASE_CONFIG), + ("config/queue.php", QUEUE_CONFIG), + ("config/broadcasting.php", BROADCASTING_CONFIG), + ( + "vendor/illuminate/Support/Facades/RateLimiter.php", + RATE_LIMITER_FACADE, + ), + ("vendor/illuminate/Support/Facades/Route.php", ROUTE_FACADE), + ( + "vendor/illuminate/Queue/Middleware/RateLimited.php", + RATE_LIMITED, + ), + ( + "vendor/illuminate/Queue/Middleware/RateLimitedWithRedis.php", + RATE_LIMITED_WITH_REDIS, + ), + ( + "vendor/illuminate/Database/ConnectionResolverInterface.php", + DATABASE_RESOLVER, + ), + ( + "vendor/illuminate/Contracts/Queue/Factory.php", + QUEUE_FACTORY, + ), + ( + "vendor/illuminate/Contracts/Broadcasting/Factory.php", + BROADCAST_FACTORY, + ), + ( + "vendor/illuminate/Contracts/Queue/ShouldQueue.php", + SHOULD_QUEUE, + ), + ("vendor/illuminate/Bus/Queueable.php", QUEUEABLE), + ( + "vendor/illuminate/Database/Eloquent/Model.php", + ELOQUENT_MODEL, + ), + ( + "app/Providers/RouteServiceProvider.php", + RATE_LIMITER_PROVIDER, + ), + ("app/Support/OrdinaryConnector.php", ORDINARY_CONNECTOR), + ] +} + +async fn workspace(extra: &[(&str, &str)], focus_path: &str) -> (Backend, tempfile::TempDir, Url) { + let mut files = extra.to_vec(); + files.extend(base_files()); + let (backend, dir) = create_psr4_workspace(COMPOSER_JSON, &files); + backend.initialized(InitializedParams {}).await; + + // Explicitly opening each fixture makes source-defined name discovery + // deterministic without relying on a background workspace scan winning a + // race with the first completion request. + for (path, content) in &files { + let uri = Url::from_file_path(dir.path().join(path)).unwrap(); + backend + .did_open(DidOpenTextDocumentParams { + text_document: TextDocumentItem { + uri, + language_id: "php".to_string(), + version: 1, + text: (*content).to_string(), + }, + }) + .await; + } + + let uri = Url::from_file_path(dir.path().join(focus_path)).unwrap(); + (backend, dir, uri) +} + +fn position_after(content: &str, needle: &str) -> Position { + let offset = content + .find(needle) + .unwrap_or_else(|| panic!("missing `{needle}`")) + + needle.len(); + let before = &content[..offset]; + Position::new( + before.bytes().filter(|byte| *byte == b'\n').count() as u32, + before + .rsplit_once('\n') + .map_or(before.len(), |(_, tail)| tail.len()) as u32, + ) +} + +async fn completion_labels(backend: &Backend, uri: &Url, position: Position) -> Vec { + let response = backend + .completion(CompletionParams { + text_document_position: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri: uri.clone() }, + position, + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + context: None, + }) + .await + .expect("completion request should succeed"); + + match response { + Some(CompletionResponse::Array(items)) => { + items.into_iter().map(|item| item.label).collect() + } + Some(CompletionResponse::List(list)) => { + list.items.into_iter().map(|item| item.label).collect() + } + None => Vec::new(), + } +} + +async fn definition_locations(backend: &Backend, uri: &Url, position: Position) -> Vec { + let response = backend + .goto_definition(GotoDefinitionParams { + text_document_position_params: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri: uri.clone() }, + position, + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + }) + .await + .expect("definition request should succeed"); + + match response { + Some(GotoDefinitionResponse::Scalar(location)) => vec![location], + Some(GotoDefinitionResponse::Array(locations)) => locations, + Some(GotoDefinitionResponse::Link(links)) => links + .into_iter() + .map(|link| Location::new(link.target_uri, link.target_selection_range)) + .collect(), + None => Vec::new(), + } +} + +fn hover_text(backend: &Backend, uri: &Url, content: &str, position: Position) -> Option { + let hover = backend.handle_hover(uri.as_str(), content, position)?; + match hover.contents { + HoverContents::Markup(markup) => Some(markup.value), + HoverContents::Scalar(MarkedString::String(value)) => Some(value), + HoverContents::Scalar(MarkedString::LanguageString(value)) => Some(value.value), + HoverContents::Array(values) => Some( + values + .into_iter() + .map(|value| match value { + MarkedString::String(value) => value, + MarkedString::LanguageString(value) => value.value, + }) + .collect::>() + .join("\n"), + ), + } +} + +fn diagnostics_with_code<'a>(diagnostics: &'a [Diagnostic], code: &str) -> Vec<&'a Diagnostic> { + diagnostics + .iter() + .filter(|diagnostic| { + matches!( + &diagnostic.code, + Some(NumberOrString::String(found)) if found == code + ) + }) + .collect() +} + +#[tokio::test] +async fn registered_rate_limiters_complete_in_every_read_and_write_context() { + let consumer = r#" null); + Route::middleware('throttle:a'); + Route::middleware('throttle:2'); + new RateLimited('u'); + new RateLimitedWithRedis('a'); + } +} +"#; + let (backend, _dir, uri) = + workspace(&[("app/Consumer.php", consumer)], "app/Consumer.php").await; + + let registration = completion_labels( + &backend, + &uri, + position_after(consumer, "RateLimiter::for('"), + ) + .await; + assert_eq!(registration, ["2fa", "api", "uploads"]); + + let middleware = completion_labels( + &backend, + &uri, + position_after(consumer, "Route::middleware('throttle:a"), + ) + .await; + assert_eq!(middleware, ["api"]); + + let digit_prefixed = completion_labels( + &backend, + &uri, + position_after(consumer, "Route::middleware('throttle:2"), + ) + .await; + assert_eq!(digit_prefixed, ["2fa"]); + + let object = completion_labels( + &backend, + &uri, + position_after(consumer, "new RateLimited('u"), + ) + .await; + assert_eq!(object, ["uploads"]); + + let redis_object = completion_labels( + &backend, + &uri, + position_after(consumer, "new RateLimitedWithRedis('a"), + ) + .await; + assert_eq!(redis_object, ["api"]); +} + +#[tokio::test] +async fn rate_limiter_navigation_hover_and_references_share_the_registration() { + let consumer = r#" null); + } +} +"#; + let consumer = r#"onQueue('critical'); + $this->onQueue('emails'); + } +} +"#; + +const ALTERNATE_JOB: &str = r#"onQueue('bulk'); + } +} +"#; + +#[tokio::test] +async fn queue_names_complete_and_reference_only_on_queueable_receivers() { + let consumer = r#"onQueue(''); + $job->onQueue('emails'); + $job->onQueue('brand-new'); + $ordinary->onQueue(''); + $ordinary->onQueue('emails'); + $ordinary->onQueue('ordinary-only'); + } +} +"#; + let (backend, _dir, uri) = workspace( + &[ + ("app/Jobs/NamedJob.php", NAMED_JOB), + ("app/Consumer.php", consumer), + ], + "app/Consumer.php", + ) + .await; + + let queueable = + completion_labels(&backend, &uri, position_after(consumer, "$job->onQueue('")).await; + assert!( + queueable.contains(&"critical".to_string()), + "got: {queueable:?}" + ); + assert!( + queueable.contains(&"emails".to_string()), + "got: {queueable:?}" + ); + assert!( + !queueable.contains(&"ordinary-only".to_string()), + "an ordinary same-named method cannot declare queue vocabulary: {queueable:?}" + ); + + let ordinary = completion_labels( + &backend, + &uri, + position_after(consumer, "$ordinary->onQueue('"), + ) + .await; + assert!( + !ordinary.contains(&"critical".to_string()) && !ordinary.contains(&"emails".to_string()), + "an ordinary receiver must not get Laravel queue completion: {ordinary:?}" + ); + + let mut diagnostics = Vec::new(); + backend.collect_slow_diagnostics(uri.as_str(), consumer, &mut diagnostics); + assert!( + diagnostics + .iter() + .all(|diagnostic| !diagnostic.message.contains("brand-new")), + "queue names are intentionally open and should never be diagnosed: {diagnostics:#?}" + ); + + let references = backend + .find_references( + uri.as_str(), + consumer, + position_after(consumer, "$job->onQueue('ema"), + true, + ) + .expect("a queue name should have references"); + assert_eq!( + references.len(), + 2, + "the job declaration and typed consumer should link, ordinary call should not: {references:#?}" + ); + assert_eq!( + references + .iter() + .filter(|location| location.uri == uri) + .count(), + 1 + ); + + let hover = hover_text( + &backend, + &uri, + consumer, + position_after(consumer, "$job->onQueue('ema"), + ) + .expect("a confirmed queue name should hover"); + assert!(hover.contains("**Queue** `emails`"), "got: {hover}"); +} + +#[tokio::test] +async fn on_connection_is_a_queue_resource_only_for_queueable_receivers() { + let consumer = r#"onConnection(''); + $job->onConnection('redis-queue'); + $job->onConnection('missing-job-connection'); + $ordinary->onConnection(''); + $ordinary->onConnection('missing-job-connection'); + } +} +"#; + let (backend, _dir, uri) = workspace( + &[ + ("app/Jobs/NamedJob.php", NAMED_JOB), + ("app/Consumer.php", consumer), + ], + "app/Consumer.php", + ) + .await; + + let queueable = completion_labels( + &backend, + &uri, + position_after(consumer, "$job->onConnection('"), + ) + .await; + assert_eq!(queueable, ["redis-queue", "sync"]); + + let ordinary = completion_labels( + &backend, + &uri, + position_after(consumer, "$ordinary->onConnection('"), + ) + .await; + assert!( + !ordinary.contains(&"redis-queue".to_string()) && !ordinary.contains(&"sync".to_string()), + "an ordinary same-named method must not get queue connections: {ordinary:?}" + ); + + let definitions = definition_locations( + &backend, + &uri, + position_after(consumer, "$job->onConnection('redis"), + ) + .await; + assert!( + definitions + .iter() + .any(|location| location.uri.path().ends_with("/config/queue.php")), + "typed onConnection() should resolve through queue config: {definitions:#?}" + ); + + let mut diagnostics = Vec::new(); + backend.collect_slow_diagnostics(uri.as_str(), consumer, &mut diagnostics); + let invalid = diagnostics_with_code(&diagnostics, "invalid_laravel_queue_connection"); + assert_eq!( + invalid.len(), + 1, + "only the queueable receiver should be checked: {invalid:#?}" + ); + assert!(invalid[0].message.contains("missing-job-connection")); +} + +#[tokio::test] +async fn nullable_and_union_queueable_receivers_keep_every_resource_feature_in_sync() { + let consumer = r#"onQueue(''); + $nullable?->onQueue('emails'); + $nullable?->onConnection(''); + $nullable?->onConnection('redis-queue'); + $nullable?->onConnection('missing-nullable-connection'); + $nullableUnion?->onConnection(''); + $queueableUnion->onQueue(''); + $mixedUnion->onQueue(''); + $mixedUnion->onConnection('missing-mixed-connection'); + } +} +"#; + let (backend, _dir, uri) = workspace( + &[ + ("app/Jobs/NamedJob.php", NAMED_JOB), + ("app/Jobs/AlternateJob.php", ALTERNATE_JOB), + ("app/Consumer.php", consumer), + ], + "app/Consumer.php", + ) + .await; + + let nullable_queue = completion_labels( + &backend, + &uri, + position_after(consumer, "$nullable?->onQueue('"), + ) + .await; + assert!(nullable_queue.contains(&"critical".to_string())); + assert!(nullable_queue.contains(&"emails".to_string())); + + let nullable_connection = completion_labels( + &backend, + &uri, + position_after(consumer, "$nullable?->onConnection('"), + ) + .await; + assert_eq!(nullable_connection, ["redis-queue", "sync"]); + + let nullable_union_connection = completion_labels( + &backend, + &uri, + position_after(consumer, "$nullableUnion?->onConnection('"), + ) + .await; + assert_eq!(nullable_union_connection, ["redis-queue", "sync"]); + + let queueable_union = completion_labels( + &backend, + &uri, + position_after(consumer, "$queueableUnion->onQueue('"), + ) + .await; + assert!(queueable_union.contains(&"critical".to_string())); + assert!(queueable_union.contains(&"bulk".to_string())); + + let mixed_union = completion_labels( + &backend, + &uri, + position_after(consumer, "$mixedUnion->onQueue('"), + ) + .await; + assert!( + !mixed_union.contains(&"critical".to_string()) + && !mixed_union.contains(&"bulk".to_string()), + "a partly ordinary union must not be guessed as queueable: {mixed_union:?}" + ); + + let queue_position = position_after(consumer, "$nullable?->onQueue('ema"); + let hover = hover_text(&backend, &uri, consumer, queue_position) + .expect("a nullable queueable receiver should produce queue-name hover"); + assert!(hover.contains("**Queue** `emails`"), "got: {hover}"); + let references = backend + .find_references(uri.as_str(), consumer, queue_position, true) + .expect("a nullable queueable receiver should produce queue-name references"); + assert_eq!( + references.len(), + 2, + "the job declaration and nullable use should link: {references:#?}" + ); + + let definitions = definition_locations( + &backend, + &uri, + position_after(consumer, "$nullable?->onConnection('redis"), + ) + .await; + assert!( + definitions + .iter() + .any(|location| location.uri.path().ends_with("/config/queue.php")), + "a nullable queueable receiver should navigate to queue config: {definitions:#?}" + ); + + let mut diagnostics = Vec::new(); + backend.collect_slow_diagnostics(uri.as_str(), consumer, &mut diagnostics); + let invalid = diagnostics_with_code(&diagnostics, "invalid_laravel_queue_connection"); + assert_eq!( + invalid.len(), + 1, + "the mixed union must stay unclassified: {invalid:#?}" + ); + assert!(invalid[0].message.contains("missing-nullable-connection")); +} + +#[tokio::test] +async fn connection_method_uses_the_receivers_database_queue_or_broadcast_family() { + let consumer = r#"connection(''); + $database->connection('analytics-db'); + $database->connection('missing-database'); + $nullableDatabase?->connection(''); + $nullableDatabase?->connection('analytics-db'); + $queue->connection(''); + $queue->connection('redis-queue'); + $queue->connection('missing-queue'); + $broadcast->connection(''); + $broadcast->connection('pusher-broadcast'); + $broadcast->connection('missing-broadcast'); + $ordinary->connection(''); + $ordinary->connection('missing-database'); + } +} +"#; + let (backend, _dir, uri) = + workspace(&[("app/Consumer.php", consumer)], "app/Consumer.php").await; + + let database = completion_labels( + &backend, + &uri, + position_after(consumer, "$database->connection('"), + ) + .await; + assert_eq!(database, ["analytics-db", "mysql"]); + + let nullable_database = completion_labels( + &backend, + &uri, + position_after(consumer, "$nullableDatabase?->connection('"), + ) + .await; + assert_eq!(nullable_database, ["analytics-db", "mysql"]); + + let queue = completion_labels( + &backend, + &uri, + position_after(consumer, "$queue->connection('"), + ) + .await; + assert_eq!(queue, ["redis-queue", "sync"]); + + let broadcast = completion_labels( + &backend, + &uri, + position_after(consumer, "$broadcast->connection('"), + ) + .await; + assert_eq!(broadcast, ["pusher-broadcast", "reverb"]); + + let ordinary = completion_labels( + &backend, + &uri, + position_after(consumer, "$ordinary->connection('"), + ) + .await; + assert!( + !ordinary.contains(&"analytics-db".to_string()) + && !ordinary.contains(&"redis-queue".to_string()) + && !ordinary.contains(&"pusher-broadcast".to_string()), + "an ordinary connection() method must stay ordinary: {ordinary:?}" + ); + + for (needle, config_file, hover_label) in [ + ( + "$database->connection('analytics", + "database.php", + "Database connection", + ), + ( + "$nullableDatabase?->connection('analytics", + "database.php", + "Database connection", + ), + ("$queue->connection('redis", "queue.php", "Queue connection"), + ( + "$broadcast->connection('pusher", + "broadcasting.php", + "Broadcast connection", + ), + ] { + let position = position_after(consumer, needle); + let definitions = definition_locations(&backend, &uri, position).await; + assert!( + definitions.iter().any(|location| { + location + .uri + .path() + .ends_with(&format!("/config/{config_file}")) + }), + "`{needle}` should resolve through {config_file}: {definitions:#?}" + ); + let hover = hover_text(&backend, &uri, consumer, position) + .unwrap_or_else(|| panic!("`{needle}` should have resource hover")); + assert!(hover.contains(hover_label), "got: {hover}"); + } + + let mut diagnostics = Vec::new(); + backend.collect_slow_diagnostics(uri.as_str(), consumer, &mut diagnostics); + for (code, missing) in [ + ("invalid_laravel_database_connection", "missing-database"), + ("invalid_laravel_queue_connection", "missing-queue"), + ("invalid_laravel_broadcast_connection", "missing-broadcast"), + ] { + let invalid = diagnostics_with_code(&diagnostics, code); + assert_eq!(invalid.len(), 1, "{code}: {invalid:#?}"); + assert!(invalid[0].message.contains(missing), "{code}: {invalid:#?}"); + } +} + +const BASE_RECORD: &str = r#"onQueue(''); + } +} +"#; + let (backend, dir, consumer_uri) = workspace( + &[ + ("app/Jobs/NamedJob.php", NAMED_JOB), + ("app/Jobs/AlternateJob.php", ALTERNATE_JOB), + ("app/QueueConsumer.php", consumer), + ], + "app/QueueConsumer.php", + ) + .await; + let completion_position = position_after(consumer, "$job->onQueue('"); + + let initial = completion_labels(&backend, &consumer_uri, completion_position).await; + assert!(initial.contains(&"emails".to_string()), "got: {initial:?}"); + assert!(initial.contains(&"bulk".to_string()), "got: {initial:?}"); + + let alternate_uri = Url::from_file_path(dir.path().join("app/Jobs/AlternateJob.php")).unwrap(); + backend + .did_close(DidCloseTextDocumentParams { + text_document: TextDocumentIdentifier { uri: alternate_uri }, + }) + .await; + let after_clean_close = completion_labels(&backend, &consumer_uri, completion_position).await; + assert!( + after_clean_close.contains(&"bulk".to_string()), + "an unchanged saved queue name must survive didClose: {after_clean_close:?}" + ); + + let named_uri = Url::from_file_path(dir.path().join("app/Jobs/NamedJob.php")).unwrap(); + let unsaved = NAMED_JOB.replace("'emails'", "'unsaved-queue-with-a-longer-name'"); + backend + .did_change(DidChangeTextDocumentParams { + text_document: VersionedTextDocumentIdentifier { + uri: named_uri.clone(), + version: 2, + }, + content_changes: vec![TextDocumentContentChangeEvent { + range: None, + range_length: None, + text: unsaved, + }], + }) + .await; + let dirty = completion_labels(&backend, &consumer_uri, completion_position).await; + assert!( + dirty.contains(&"unsaved-queue-with-a-longer-name".to_string()), + "got: {dirty:?}" + ); + assert!(!dirty.contains(&"emails".to_string()), "got: {dirty:?}"); + + backend + .did_close(DidCloseTextDocumentParams { + text_document: TextDocumentIdentifier { uri: named_uri }, + }) + .await; + let restored = completion_labels(&backend, &consumer_uri, completion_position).await; + assert!( + restored.contains(&"emails".to_string()), + "got: {restored:?}" + ); + assert!( + !restored.contains(&"unsaved-queue-with-a-longer-name".to_string()), + "an unsaved queue name must not survive didClose: {restored:?}" + ); +} diff --git a/tests/integration/laravel_named_resources.rs b/tests/integration/laravel_named_resources.rs new file mode 100644 index 000000000..d01c2262a --- /dev/null +++ b/tests/integration/laravel_named_resources.rs @@ -0,0 +1,1355 @@ +//! End-to-end coverage for Laravel's config-backed named resources. + +use std::fs; + +use crate::common::create_psr4_workspace; +use phpantom_lsp::Backend; +use tower_lsp::LanguageServer; +use tower_lsp::lsp_types::*; + +const COMPOSER_JSON: &str = r#"{ + "require": { "laravel/framework": "^12.0" }, + "autoload": { "psr-4": { "App\\": "app/" } } +}"#; + +const AUTH_CONFIG: &str = r#" [ + 'web' => ['driver' => 'session'], + 'admin' => ['driver' => 'session'], + ], +]; +"#; + +const CACHE_CONFIG: &str = r#" [ + 'array' => ['driver' => 'array'], + 'redis' => ['driver' => 'redis'], + ], +]; +"#; + +const LOGGING_CONFIG: &str = r#" [ + 'daily' => ['driver' => 'daily'], + 'slack' => ['driver' => 'slack'], + ], +]; +"#; + +const FILESYSTEMS_CONFIG: &str = r#" [ + 'local' => ['driver' => 'local'], + 'archive' => ['driver' => 'local'], + ], +]; +"#; + +const DATABASE_CONFIG: &str = r#" [ + 'mysql' => ['driver' => 'mysql'], + 'sqlite' => ['driver' => 'sqlite'], + ], +]; +"#; + +const QUEUE_CONFIG: &str = r#" [ + 'sync' => ['driver' => 'sync'], + 'redis' => ['driver' => 'redis'], + ], +]; +"#; + +const MAIL_CONFIG: &str = r#" [ + 'smtp' => ['transport' => 'smtp'], + 'log' => ['transport' => 'log'], + ], +]; +"#; + +const BROADCASTING_CONFIG: &str = r#" [ + 'reverb' => ['driver' => 'reverb'], + 'log' => ['driver' => 'log'], + ], +]; +"#; + +fn workspace_files(source: &str) -> Vec<(&str, &str)> { + vec![ + ("config/auth.php", AUTH_CONFIG), + ("config/cache.php", CACHE_CONFIG), + ("config/logging.php", LOGGING_CONFIG), + ("config/filesystems.php", FILESYSTEMS_CONFIG), + ("config/database.php", DATABASE_CONFIG), + ("config/queue.php", QUEUE_CONFIG), + ("config/mail.php", MAIL_CONFIG), + ("config/broadcasting.php", BROADCASTING_CONFIG), + ("app/NamedResourceConsumer.php", source), + ] +} + +fn position_at_offset(content: &str, offset: usize) -> Position { + let before = &content[..offset]; + let line = before.bytes().filter(|byte| *byte == b'\n').count() as u32; + let character = before + .rsplit_once('\n') + .map_or(before.len(), |(_, tail)| tail.len()) as u32; + Position::new(line, character) +} + +fn position_after(content: &str, unique_prefix: &str) -> Position { + let offset = content + .find(unique_prefix) + .unwrap_or_else(|| panic!("missing `{unique_prefix}`")) + + unique_prefix.len(); + position_at_offset(content, offset) +} + +fn position_of_config_key(content: &str, key: &str) -> Position { + let marker = format!("'{key}' =>"); + let offset = content + .find(&marker) + .unwrap_or_else(|| panic!("missing config declaration `{marker}`")) + + 1; + position_at_offset(content, offset) +} + +fn text_in_range(content: &str, range: Range) -> &str { + let start_line = content + .split_inclusive('\n') + .take(range.start.line as usize) + .map(str::len) + .sum::(); + let end_line = content + .split_inclusive('\n') + .take(range.end.line as usize) + .map(str::len) + .sum::(); + &content[start_line + range.start.character as usize..end_line + range.end.character as usize] +} + +async fn open_workspace(source: &str) -> (Backend, tempfile::TempDir, Url) { + let files = workspace_files(source); + let (backend, dir) = create_psr4_workspace(COMPOSER_JSON, &files); + backend.initialized(InitializedParams {}).await; + + let uri = Url::from_file_path(dir.path().join("app/NamedResourceConsumer.php")).unwrap(); + backend + .did_open(DidOpenTextDocumentParams { + text_document: TextDocumentItem { + uri: uri.clone(), + language_id: "php".to_string(), + version: 1, + text: source.to_string(), + }, + }) + .await; + + (backend, dir, uri) +} + +async fn completion_items(backend: &Backend, uri: &Url, position: Position) -> Vec { + let response = backend + .completion(CompletionParams { + text_document_position: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri: uri.clone() }, + position, + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + context: None, + }) + .await + .expect("completion request should succeed"); + + match response { + Some(CompletionResponse::Array(items)) => items, + Some(CompletionResponse::List(list)) => list.items, + None => Vec::new(), + } +} + +fn definition_location(response: GotoDefinitionResponse) -> Location { + match response { + GotoDefinitionResponse::Scalar(location) => location, + GotoDefinitionResponse::Array(mut locations) => locations.remove(0), + GotoDefinitionResponse::Link(mut links) => { + let link = links.remove(0); + Location::new(link.target_uri, link.target_selection_range) + } + } +} + +fn hover_text(hover: Hover) -> String { + match hover.contents { + HoverContents::Scalar(MarkedString::String(text)) => text, + HoverContents::Scalar(MarkedString::LanguageString(text)) => text.value, + HoverContents::Array(parts) => parts + .into_iter() + .map(|part| match part { + MarkedString::String(text) => text, + MarkedString::LanguageString(text) => text.value, + }) + .collect::>() + .join("\n"), + HoverContents::Markup(markup) => markup.value, + } +} + +#[tokio::test] +async fn every_named_resource_context_completes_direct_config_children() { + let source = r#" 'daily']); +\Vendor\Cache::store(''); +Cache::store('array', ''); +"#; + let (backend, _dir, uri) = open_workspace(source).await; + + let cases: &[(&str, &[&str])] = &[ + ("auth('", &["admin", "web"]), + ("Auth::guard('w", &["web"]), + ("Route::middleware('auth:a", &["admin"]), + ("Route::middleware('auth:web, a", &["admin"]), + ("Cache::store('", &["array", "redis"]), + ("Log::channel('", &["daily", "slack"]), + ("Log::stack(['daily', 's", &["slack"]), + ("Log::stack(array('daily', 's", &["slack"]), + ("Storage::disk('", &["archive", "local"]), + ("DB::connection('", &["mysql", "sqlite"]), + ("Queue::connection('", &["redis", "sync"]), + ("Mail::mailer('", &["log", "smtp"]), + ("Broadcast::connection('", &["log", "reverb"]), + ("Attributes\\Auth('a", &["admin"]), + ("Attributes\\Authenticated('w", &["web"]), + ("Attributes\\Cache('a", &["array"]), + ("Attributes\\Log('s", &["slack"]), + ("Attributes\\Storage('l", &["local"]), + ("Attributes\\Database('s", &["sqlite"]), + ("Attributes\\DB('m", &["mysql"]), + ]; + + for (prefix, expected) in cases { + let items = completion_items(&backend, &uri, position_after(source, prefix)).await; + let labels = items + .iter() + .map(|item| item.label.as_str()) + .collect::>(); + assert_eq!(labels, *expected, "completion at `{prefix}`"); + } + + let middleware_position = position_after(source, "Route::middleware('auth:web, a"); + let middleware_items = completion_items(&backend, &uri, middleware_position).await; + let Some(CompletionTextEdit::Edit(edit)) = &middleware_items[0].text_edit else { + panic!("middleware completion should replace only its guard payload"); + }; + assert_eq!(text_in_range(source, edit.range), " a"); + assert_eq!(edit.new_text, "admin"); + + for prefix in [ + "Log::stack('", + "Log::stack(['s", + "\\Vendor\\Cache::store('", + "Cache::store('array', '", + ] { + assert!( + completion_items(&backend, &uri, position_after(source, prefix)) + .await + .is_empty(), + "`{prefix}` is not a named-resource argument" + ); + } +} + +#[tokio::test] +async fn completion_resolves_aliases_named_arguments_and_local_homonyms() { + let source = r#" null)->middleware('auth:a'); +\Illuminate\Support\Facades\Route::get('/fqn', fn () => null)->middleware('auth:w'); + +#[CacheAttribute(memo: true, store: 'r')] +class Target {} + +Cache::store(''); +Route::middleware('auth:a'); +Route::get('/local', fn () => null)->middleware('auth:a'); +#[Cache('')] +class LocalAttributeTarget {} +LaravelCache::store(store: ''); + +class Controller +{ + public function boot(): void + { + $this->middleware(options: [], middleware: 'auth:a'); + } +} +"#; + let (backend, _dir, uri) = open_workspace(source).await; + + for (prefix, expected) in [ + ("LaravelCache::store(name: '", vec!["array", "redis"]), + ("disk: 'a", vec!["archive"]), + ("middleware: 'auth:a", vec!["admin"]), + ("Facades\\Route::middleware('auth:w", vec!["web"]), + ( + "'/aliased', fn () => null)->middleware('auth:a", + vec!["admin"], + ), + ("'/fqn', fn () => null)->middleware('auth:w", vec!["web"]), + ("options: [], middleware: 'auth:a", vec!["admin"]), + ("store: 'r", vec!["redis"]), + ] { + let labels = completion_items(&backend, &uri, position_after(source, prefix)) + .await + .into_iter() + .map(|item| item.label) + .collect::>(); + assert_eq!(labels, expected, "completion at `{prefix}`"); + } + + for prefix in [ + "\nCache::store('", + "\nRoute::middleware('auth:a", + "'/local', fn () => null)->middleware('auth:a", + "#[Cache('", + "LaravelCache::store(store: '", + ] { + assert!( + completion_items(&backend, &uri, position_after(source, prefix)) + .await + .is_empty(), + "local homonym or wrong named parameter at `{prefix}` must stay ordinary" + ); + } +} + +#[tokio::test] +async fn every_resource_spelling_navigates_and_hovers_as_its_family() { + let source = r#" 'missing-log']); +Storage::disk('local'); +Storage::disk('missing-disk'); +Storage::fake('testing-only'); +Storage::persistentFake('persistent-testing-only'); +Storage::forgetDisk(['already-forgotten']); +DB::connection('mysql'); +DB::connection('missing-database'); +Queue::connection('sync'); +Queue::connection('missing-queue'); +Mail::mailer('smtp'); +Mail::mailer('missing-mailer'); +Broadcast::connection('reverb'); +Broadcast::connection('missing-broadcast'); + +Log::stack('missing-scalar-shape'); +\Vendor\Cache::store('missing-vendor-cache'); +Cache::store('array', 'missing-second-argument'); +"#; + let (backend, _dir, uri) = open_workspace(source).await; + let mut diagnostics = Vec::new(); + backend.collect_slow_diagnostics(uri.as_str(), source, &mut diagnostics); + + let mut actual = diagnostics + .iter() + .filter_map(|diagnostic| { + let Some(NumberOrString::String(code)) = &diagnostic.code else { + return None; + }; + if code.starts_with("invalid_laravel_") { + Some(( + code.as_str(), + text_in_range(source, diagnostic.range), + diagnostic.message.as_str(), + )) + } else { + None + } + }) + .collect::>(); + actual.sort_unstable_by(|left, right| left.0.cmp(right.0)); + + let mut expected = vec![ + ("invalid_laravel_auth_guard", "missing-guard"), + ("invalid_laravel_broadcast_connection", "missing-broadcast"), + ("invalid_laravel_cache_store", "missing-cache"), + ("invalid_laravel_database_connection", "missing-database"), + ("invalid_laravel_log_channel", "missing-log"), + ("invalid_laravel_mailer", "missing-mailer"), + ("invalid_laravel_queue_connection", "missing-queue"), + ("invalid_laravel_storage_disk", "missing-disk"), + ]; + expected.sort_unstable_by(|left, right| left.0.cmp(right.0)); + + assert_eq!(actual.len(), expected.len(), "diagnostics: {actual:#?}"); + for ((code, range_text, message), (expected_code, expected_key)) in + actual.into_iter().zip(expected) + { + assert_eq!(code, expected_code); + assert_eq!(range_text, expected_key, "range for `{code}`"); + assert!( + message.contains(expected_key), + "message for `{code}` should name `{expected_key}`: {message}" + ); + } +} + +#[tokio::test] +async fn generic_config_and_resource_spellings_share_symmetric_references() { + let source = r#">(); + referenced_text.sort(); + let mut expected = vec![short.to_string(), short.to_string(), full.to_string()]; + expected.sort(); + assert_eq!( + referenced_text, expected, + "references should cover exact literal payloads for `{full}`" + ); + } +} + +#[tokio::test] +async fn runtime_config_merges_open_only_their_own_diagnostic_subtree() { + let cache_config = r#" array_merge([ + 'array' => ['driver' => 'array'], + ], $packageStores), + 'default' => 'array', +]; +"#; + let source = r#">(); + assert_eq!(labels, ["array"]); + + let mut diagnostics = Vec::new(); + backend.collect_slow_diagnostics(uri.as_str(), source, &mut diagnostics); + let invalid = diagnostics + .iter() + .filter(|diagnostic| { + matches!(&diagnostic.code, Some(NumberOrString::String(code)) + if code.starts_with("invalid_laravel_")) + }) + .collect::>(); + + assert_eq!(invalid.len(), 1, "diagnostics: {invalid:#?}"); + assert_eq!( + invalid[0].code, + Some(NumberOrString::String("invalid_laravel_config".to_string())) + ); + assert_eq!(text_in_range(source, invalid[0].range), "cache.missing"); +} + +#[tokio::test] +async fn literal_runtime_config_writes_declare_named_resources() { + let source = r#" 'array']); +Cache::store('tenant'); +config('cache.stores.tenant'); +Cache::store('missing-runtime-store'); +"#; + let (backend, _dir, uri) = open_workspace(source).await; + + let labels = completion_items(&backend, &uri, position_after(source, "Cache::store('t")) + .await + .into_iter() + .map(|item| item.label) + .collect::>(); + assert_eq!(labels, ["tenant"]); + + let position = position_after(source, "Cache::store('ten"); + let response = backend + .goto_definition(GotoDefinitionParams { + text_document_position_params: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri: uri.clone() }, + position, + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + }) + .await + .expect("definition request should succeed") + .expect("runtime resource should have a definition"); + let location = definition_location(response); + assert_eq!(location.uri, uri); + assert_eq!(text_in_range(source, location.range), "cache.stores.tenant"); + + let references_without_declaration = backend + .find_references(uri.as_str(), source, position, false) + .expect("runtime resource should have usage references"); + assert_eq!( + references_without_declaration.len(), + 2, + "references: {references_without_declaration:#?}" + ); + assert!( + references_without_declaration + .iter() + .all(|location| location.range.start.line != 4) + ); + + let references = backend + .find_references(uri.as_str(), source, position, true) + .expect("runtime resource should have references"); + assert_eq!(references.len(), 3, "references: {references:#?}"); + + let mut diagnostics = Vec::new(); + backend.collect_slow_diagnostics(uri.as_str(), source, &mut diagnostics); + let invalid = diagnostics + .iter() + .filter(|diagnostic| { + matches!(&diagnostic.code, Some(NumberOrString::String(code)) + if code == "invalid_laravel_cache_store") + }) + .collect::>(); + assert_eq!(invalid.len(), 1, "diagnostics: {invalid:#?}"); + assert_eq!( + text_in_range(source, invalid[0].range), + "missing-runtime-store" + ); +} + +#[tokio::test] +async fn closing_a_dirty_runtime_config_file_restores_saved_declarations() { + let saved = r#" 'array']); +"#; + let unsaved = r#" 'array']); +"#; + let consumer = r#">(); + assert!( + dirty_labels + .iter() + .any(|label| label == "unsaved-with-a-different-offset") + ); + assert!(!dirty_labels.iter().any(|label| label == "saved")); + + backend + .did_close(DidCloseTextDocumentParams { + text_document: TextDocumentIdentifier { + uri: declaration_uri.clone(), + }, + }) + .await; + + let labels = completion_items( + &backend, + &consumer_uri, + position_after(consumer, "Cache::store('"), + ) + .await + .into_iter() + .map(|item| item.label) + .collect::>(); + assert!(labels.iter().any(|label| label == "saved")); + assert!( + !labels + .iter() + .any(|label| label == "unsaved-with-a-different-offset") + ); + + for position in [ + position_after(consumer, "Cache::store('sav"), + position_after(consumer, "config('cache.stores.sav"), + ] { + let usages = backend + .find_references(consumer_uri.as_str(), consumer, position, false) + .expect("saved runtime resource should have a usage"); + assert_eq!(usages.len(), 2, "usage references: {usages:#?}"); + assert!(usages.iter().all(|location| location.uri == consumer_uri)); + + let references = backend + .find_references(consumer_uri.as_str(), consumer, position, true) + .expect("saved runtime resource should have a declaration"); + assert_eq!(references.len(), 3, "references: {references:#?}"); + let declaration = references + .iter() + .find(|location| location.uri == declaration_uri) + .expect("the saved Config::set literal should be the declaration"); + assert_eq!( + text_in_range(saved, declaration.range), + "cache.stores.saved" + ); + } +} + +#[tokio::test] +async fn provider_config_features_follow_the_open_buffer_and_restore_disk_on_close() { + let provider = r#"mergeConfigFrom(__DIR__ . '/../../resources/settings.php', 'cache'); + } +} +"#; + let saved = r#" [ + 'saved-store' => ['driver' => 'array'], + ], + 'value' => 'saved', +]; +"#; + let unsaved = r#" [ + 'buffer-store' => ['driver' => 'array'], + ], + 'value' => 123, +]; +"#; + let consumer = r#">(); + assert!(labels.iter().any(|label| label == "buffer-store")); + assert!(!labels.iter().any(|label| label == "saved-store")); + + let buffer_position = position_after(consumer, "Cache::store('buffer"); + let response = backend + .goto_definition(GotoDefinitionParams { + text_document_position_params: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { + uri: consumer_uri.clone(), + }, + position: buffer_position, + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + }) + .await + .expect("definition request should succeed") + .expect("the buffered provider declaration should resolve"); + let location = definition_location(response); + assert_eq!(location.uri, config_uri); + assert_eq!( + location.range.start, + position_of_config_key(unsaved, "buffer-store") + ); + let hover = backend + .handle_hover(consumer_uri.as_str(), consumer, buffer_position) + .expect("a provider-backed resource should have hover"); + let text = hover_text(hover); + assert!(text.contains("**Cache store** `buffer-store`"), "{text}"); + assert!(text.contains("resources/settings.php"), "{text}"); + + let references = backend + .find_references(consumer_uri.as_str(), consumer, buffer_position, true) + .expect("the buffered provider key should have references"); + assert_eq!(references.len(), 3, "references: {references:#?}"); + let declaration = references + .iter() + .find(|reference| reference.uri == config_uri) + .expect("references should include the buffered declaration"); + assert_eq!(text_in_range(unsaved, declaration.range), "buffer-store"); + + let declaration_position = position_of_config_key(unsaved, "buffer-store"); + let declaration_references = backend + .find_references(config_uri.as_str(), unsaved, declaration_position, true) + .expect("references should work from a provider config declaration"); + assert_eq!(declaration_references.len(), 3); + + let response = backend + .goto_definition(GotoDefinitionParams { + text_document_position_params: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { + uri: config_uri.clone(), + }, + position: declaration_position, + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + }) + .await + .expect("definition request should succeed") + .expect("a provider config declaration should resolve to itself"); + let location = definition_location(response); + assert_eq!(location.uri, config_uri); + assert_eq!(location.range.start, declaration_position); + + let mut diagnostics = Vec::new(); + backend.collect_slow_diagnostics(consumer_uri.as_str(), consumer, &mut diagnostics); + let invalid_cache = diagnostics + .iter() + .filter(|diagnostic| { + matches!(&diagnostic.code, Some(NumberOrString::String(code)) + if code == "invalid_laravel_cache_store") + }) + .collect::>(); + assert_eq!(invalid_cache.len(), 1, "diagnostics: {diagnostics:#?}"); + assert_eq!( + text_in_range(consumer, invalid_cache[0].range), + "saved-store" + ); + + let value_offset = consumer.rfind("$value;").expect("value use") + 2; + let hover = backend + .hover(HoverParams { + text_document_position_params: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { + uri: consumer_uri.clone(), + }, + position: position_at_offset(consumer, value_offset), + }, + work_done_progress_params: WorkDoneProgressParams::default(), + }) + .await + .expect("hover request should succeed") + .expect("config return value should have a type"); + assert!(hover_text(hover).contains("int")); + + backend + .did_close(DidCloseTextDocumentParams { + text_document: TextDocumentIdentifier { + uri: config_uri.clone(), + }, + }) + .await; + + let labels = completion_items( + &backend, + &consumer_uri, + position_after(consumer, "Cache::store('saved"), + ) + .await + .into_iter() + .map(|item| item.label) + .collect::>(); + assert!(labels.iter().any(|label| label == "saved-store")); + assert!(!labels.iter().any(|label| label == "buffer-store")); + + let saved_position = position_after(consumer, "Cache::store('saved"); + let response = backend + .goto_definition(GotoDefinitionParams { + text_document_position_params: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { + uri: consumer_uri.clone(), + }, + position: saved_position, + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + }) + .await + .expect("definition request should succeed") + .expect("the saved provider declaration should be restored"); + let location = definition_location(response); + assert_eq!( + location.uri.to_file_path().unwrap().canonicalize().unwrap(), + config_uri.to_file_path().unwrap().canonicalize().unwrap() + ); + assert_eq!( + location.range.start, + position_of_config_key(saved, "saved-store") + ); + + diagnostics.clear(); + backend.collect_slow_diagnostics(consumer_uri.as_str(), consumer, &mut diagnostics); + let invalid_cache = diagnostics + .iter() + .filter(|diagnostic| { + matches!(&diagnostic.code, Some(NumberOrString::String(code)) + if code == "invalid_laravel_cache_store") + }) + .collect::>(); + assert_eq!(invalid_cache.len(), 1, "diagnostics: {diagnostics:#?}"); + assert_eq!( + text_in_range(consumer, invalid_cache[0].range), + "buffer-store" + ); + + let hover = backend + .hover(HoverParams { + text_document_position_params: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri: consumer_uri }, + position: position_at_offset(consumer, value_offset), + }, + work_done_progress_params: WorkDoneProgressParams::default(), + }) + .await + .expect("hover request should succeed") + .expect("saved config return value should have a type"); + assert!(hover_text(hover).contains("string")); +} diff --git a/tests/integration/laravel_storage_disk_names.rs b/tests/integration/laravel_storage_disk_names.rs new file mode 100644 index 000000000..08b9aafd6 --- /dev/null +++ b/tests/integration/laravel_storage_disk_names.rs @@ -0,0 +1,380 @@ +//! End-to-end coverage for config-backed Laravel storage disk names. + +use crate::common::create_psr4_workspace; +use phpantom_lsp::Backend; +use tower_lsp::LanguageServer; +use tower_lsp::lsp_types::*; + +const COMPOSER_JSON: &str = r#"{ + "require": { "laravel/framework": "^12.0" }, + "autoload": { "psr-4": { "App\\": "app/" } } +}"#; + +const FILESYSTEMS_CONFIG: &str = r#" 'local', + 'disks' => [ + 'local' => ['driver' => 'local'], + 'archive' => ['driver' => 'local'], + 'backup' => ['driver' => 's3'], + ], +]; +"#; + +fn position_after(content: &str, unique_prefix: &str) -> Position { + let offset = content + .find(unique_prefix) + .unwrap_or_else(|| panic!("missing `{unique_prefix}`")) + + unique_prefix.len(); + let before = &content[..offset]; + let line = before.bytes().filter(|byte| *byte == b'\n').count() as u32; + let character = before + .rsplit_once('\n') + .map_or(before.len(), |(_, tail)| tail.len()) as u32; + Position::new(line, character) +} + +async fn open_workspace(source: &str) -> (Backend, tempfile::TempDir, Url) { + let (backend, dir) = create_psr4_workspace( + COMPOSER_JSON, + &[ + ("config/filesystems.php", FILESYSTEMS_CONFIG), + ("app/DiskConsumer.php", source), + ], + ); + backend.initialized(InitializedParams {}).await; + + let uri = Url::from_file_path(dir.path().join("app/DiskConsumer.php")).unwrap(); + backend + .did_open(DidOpenTextDocumentParams { + text_document: TextDocumentItem { + uri: uri.clone(), + language_id: "php".to_string(), + version: 1, + text: source.to_string(), + }, + }) + .await; + + (backend, dir, uri) +} + +async fn completion_labels(backend: &Backend, uri: &Url, position: Position) -> Vec { + let response = backend + .completion(CompletionParams { + text_document_position: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri: uri.clone() }, + position, + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + context: None, + }) + .await + .expect("completion request should succeed"); + + match response { + Some(CompletionResponse::Array(items)) => { + items.into_iter().map(|item| item.label).collect() + } + Some(CompletionResponse::List(list)) => { + list.items.into_iter().map(|item| item.label).collect() + } + None => Vec::new(), + } +} + +fn definition_location(response: GotoDefinitionResponse) -> Location { + match response { + GotoDefinitionResponse::Scalar(location) => location, + GotoDefinitionResponse::Array(mut locations) => locations.remove(0), + GotoDefinitionResponse::Link(mut links) => { + let link = links.remove(0); + Location::new(link.target_uri, link.target_selection_range) + } + } +} + +#[tokio::test] +async fn every_storage_disk_context_completes_direct_config_children() { + let source = r#" = diagnostics + .iter() + .filter(|diagnostic| { + matches!( + &diagnostic.code, + Some(NumberOrString::String(code)) if code == "invalid_laravel_storage_disk" + ) + }) + .collect(); + assert_eq!(invalid_disks.len(), 1, "got: {invalid_disks:#?}"); + assert!(invalid_disks[0].message.contains("storage disk: 'missing'")); +} + +#[tokio::test] +async fn storage_call_shapes_share_references_with_the_config_declaration() { + let source = r#" [ + 'framework-local' => ['driver' => 'local'], + ], +]; +"#; + let app_config = r#" [ + 'application' => ['driver' => 'local'], + ], +]; +"#; + let source = r#" Date: Sun, 16 Aug 2026 21:15:20 +0600 Subject: [PATCH 2/5] chore: add Laravel named resource examples to Demo file --- examples/laravel/app/Demo.php | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/examples/laravel/app/Demo.php b/examples/laravel/app/Demo.php index 4db0b45ed..f2f0b7d28 100644 --- a/examples/laravel/app/Demo.php +++ b/examples/laravel/app/Demo.php @@ -24,6 +24,15 @@ use Database\Factories\AnnotatedPostFactory; use Database\Factories\BlogAuthorFactory; use Database\Factories\EditorialFactory; +use Illuminate\Container\Attributes\Auth as InjectAuth; +use Illuminate\Container\Attributes\Authenticated as InjectAuthenticated; +use Illuminate\Container\Attributes\Cache as InjectCache; +use Illuminate\Container\Attributes\Database as InjectDatabase; +use Illuminate\Container\Attributes\Log as InjectLog; +use Illuminate\Container\Attributes\Storage as InjectStorage; +use Illuminate\Contracts\Broadcasting\Factory as BroadcastFactory; +use Illuminate\Contracts\Queue\Factory as QueueFactory; +use Illuminate\Database\ConnectionResolverInterface; use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Http\Request; use Illuminate\Notifications\Messages\BroadcastMessage; @@ -844,6 +853,19 @@ public function laravelConfig(): void // ── Config-backed named resources and source-defined names ───────── + public function injectedNamedResources( + #[InjectAuth(guard: 'admin')] mixed $guard, + #[InjectAuthenticated(guard: 'admin')] mixed $user, + #[InjectCache(memo: true, store: 'memory')] mixed $cache, + #[InjectLog(channel: 'daily')] mixed $logger, + #[InjectStorage(disk: 'pantry')] mixed $disk, + #[InjectDatabase(connection: 'mysql')] mixed $database, + ): void + { + // Contextual-attribute arguments complete and navigate against the + // same family-specific config entries as their facade counterparts. + } + public function namedLaravelResources(): void { // Each string completes from its own config subtree. Hover names the @@ -874,6 +896,19 @@ public function namedLaravelResources(): void new RateLimited('uploads'); } + public function typedNamedResourceConnections( + ConnectionResolverInterface $database, + QueueFactory $queues, + BroadcastFactory $broadcasts, + ): void + { + // All three contracts expose connection(), so the receiver type picks + // the database, queue, or broadcasting config subtree respectively. + $database->connection('mysql'); + $queues->connection('redis'); + $broadcasts->connection('internal'); + } + // ── Cache::remember() — closure return type binding ───────────────── From 959a58e9f9abe90c3ec3a25ef3ff4db822af8ffa Mon Sep 17 00:00:00 2001 From: Shuvro Roy Date: Sun, 16 Aug 2026 21:21:55 +0600 Subject: [PATCH 3/5] chore: update changelog with PR references --- docs/CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index bf0b6fdf1..b99a22715 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -9,8 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **Laravel named resources are editor-aware.** Configured service names now complete in calls, typed methods, container attributes, and middleware; hover identifies the resource family, Ctrl+Click opens the declaration, references bridge direct config access, and misspellings are diagnosed. Source-defined rate limiters and free-form queue names get the same editor support without treating open-ended names as errors. Contributed by @shuvroroy. -- **Storage disk names are navigable wherever Laravel accepts one.** `Storage::disk()`, `fake()`, `persistentFake()`, `forgetDisk()`, and the `#[Storage]` container attribute now complete from `config/filesystems.php`; hover identifies the backing entry, Ctrl+Click opens it, and find-references links every use. Array values passed to `forgetDisk()` work individually. Calls that require a configured disk report misspellings, while test fakes and cache eviction keep accepting the ad-hoc names Laravel allows at runtime. Contributed by @shuvroroy. +- **Laravel named resources are editor-aware.** Configured service names now complete in calls, typed methods, container attributes, and middleware; hover identifies the resource family, Ctrl+Click opens the declaration, references bridge direct config access, and misspellings are diagnosed. Source-defined rate limiters and free-form queue names get the same editor support without treating open-ended names as errors. Contributed by @shuvroroy (#368). +- **Storage disk names are navigable wherever Laravel accepts one.** `Storage::disk()`, `fake()`, `persistentFake()`, `forgetDisk()`, and the `#[Storage]` container attribute now complete from `config/filesystems.php`; hover identifies the backing entry, Ctrl+Click opens it, and find-references links every use. Array values passed to `forgetDisk()` work individually. Calls that require a configured disk report misspellings, while test fakes and cache eviction keep accepting the ad-hoc names Laravel allows at runtime. Contributed by @shuvroroy (#368). - **A standalone function shows how many times it is used.** The reference count that sits above a class, method, property, and constant was missing from every function declared outside a class, and a file holding nothing but functions (a helpers file, most of a procedural codebase) got no counts at all, because the walk that draws them starts at the file's classes. Functions are counted now too, and Ctrl+Click on a function's own name at its declaration offers the list of its usages instead of doing nothing, which is how the same click already behaved on a class or a method. Contributed by @petrovo-as. - **PHPantom can run in the browser.** The whole type engine now compiles to WebAssembly, so a web editor can have PHPantom's completion, hover, go-to-definition, symbol highlighting and rename without a server to talk to and without a round-trip per keystroke. The module speaks ordinary LSP JSON-RPC over four exported functions, so a browser LSP client can be pointed at it through a thin transport, and it needs no filesystem: the PHP standard library stubs are compiled in and open documents live in memory. This is what the [PHPStan playground](https://phpstan.org/try) is built on. Every release ships a prebuilt module, so a host can pin a version rather than build its own. See [wasm.md](wasm.md) for the host interface. Contributed by @ondrejmirtes. - **A path helper opens the file it names.** `base_path('routes/web.php')`, `app_path()`, `config_path()`, `database_path()`, `lang_path()`, `public_path()`, `resource_path()`, and `storage_path()` each anchor their argument to a conventional directory, but the argument was still just a string: no link, no completion, and a typo showed up only at runtime. The argument is a clickable link now and go-to-definition follows it, and typing one completes a segment at a time from the directory the path has reached so far, directories first so the next segment follows on. `lang_path()` respects the `resources/lang` directory an application upgraded from Laravel 8 still has. A directory completes but is not a link, since an editor cannot open a folder as a document. Contributed by @shuvroroy (#334). @@ -128,7 +128,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- **Laravel discovery handles macOS path aliases consistently.** Temporary and workspace paths may be exposed as `/var` while their canonical spelling is `/private/var`; vendor pruning and Blade view refreshes now treat those spellings as the same location, so vendor files stay excluded and newly created or deleted templates refresh completion correctly. Contributed by @shuvroroy. +- **Laravel discovery handles macOS path aliases consistently.** Temporary and workspace paths may be exposed as `/var` while their canonical spelling is `/private/var`; vendor pruning and Blade view refreshes now treat those spellings as the same location, so vendor files stay excluded and newly created or deleted templates refresh completion correctly. Contributed by @shuvroroy (#368). - **Editing a service provider takes effect immediately.** What a Laravel service provider registers was read once, when the project was first indexed, and never again. A container binding written afterwards did not resolve, hover, or navigate until the editor was restarted, and the same went for the view directories, translation directories, route files, config files, and Blade component namespaces a provider registers. Saving or editing a provider now re-reads it, and adding one to `bootstrap/providers.php` (or `config/app.php`) picks it up as well. A key that two providers bind still ends up with whichever of them the container itself would let win. - **A request accessor written with named arguments keeps its key.** `$request->file(key: 'photos')` and `$request->header(default: 'x')` read the named argument as whichever positional slot it happened to land in, so a keyed `file()` call resolved as though it named no field at all and a default-only `header()` call resolved as though its default text were the key. `header()`, `query()`, `cookie()`, `input()`, `post()`, and `file()` now bind a named argument to the parameter it actually names, including on an app's own `FormRequest` subclass, which never redeclares the accessor itself. - **Blade partial variables stay typed during CLI analysis.** `phpantom_lsp analyze` now discovers view and include callers while running without the editor's reference index, and direct variables passed in template data retain nearby `@var` overrides. Contributed by @shuvroroy (#337). From 1e86268a83ec4bb53d3cc8662e0a63a2a876c45b Mon Sep 17 00:00:00 2001 From: Shuvro Roy Date: Sun, 16 Aug 2026 21:43:32 +0600 Subject: [PATCH 4/5] chore: add test for aliased provider config path invalidation --- src/parser/ast_update.rs | 44 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/parser/ast_update.rs b/src/parser/ast_update.rs index 4da54c138..a0d79e306 100644 --- a/src/parser/ast_update.rs +++ b/src/parser/ast_update.rs @@ -2046,6 +2046,50 @@ RateLimiter::for('api', fn () => null); assert!(cache.config_open_prefixes.is_none()); } + #[test] + fn an_aliased_provider_config_path_invalidates_config_cache() { + let dir = tempfile::tempdir().expect("tempdir"); + let package = dir.path().join("package"); + let path = package.join("resources/settings.php"); + std::fs::create_dir_all(path.parent().unwrap()).expect("create resources directory"); + std::fs::create_dir_all(package.join("alias")).expect("create alias directory"); + std::fs::write(&path, " []];").expect("write provider config"); + + // Service-provider paths and editor URIs can use different lexical + // spellings for the same file. The exact-path fast path must fall + // through to canonical comparison in that case. + let registered = package.join("alias/../resources/settings.php"); + assert_ne!(registered, path); + assert_eq!( + registered.canonicalize().expect("canonical provider path"), + path.canonicalize().expect("canonical editor path") + ); + + let backend = Backend::new_test(); + backend + .laravel_provider_resources + .write() + .config_files + .push(crate::virtual_members::laravel::ProviderResource { + path: registered, + namespace: "cache".to_string(), + }); + { + let mut cache = backend.laravel_string_key_cache.write(); + cache.config_generation = 17; + cache.config_keys = Some(Arc::new(vec!["cache.stores.old".to_string()])); + cache.config_open_prefixes = Some(Arc::new(Vec::new())); + } + + let uri = crate::util::path_to_uri(&path); + backend.update_ast(&uri, " ['new' => []]];"); + + let cache = backend.laravel_string_key_cache.read(); + assert_eq!(cache.config_generation, 18); + assert!(cache.config_keys.is_none()); + assert!(cache.config_open_prefixes.is_none()); + } + #[test] fn an_absent_same_named_path_is_not_a_registered_provider_config() { let dir = tempfile::tempdir().expect("tempdir"); From a607f5d1816135ac9be8fec1fb4399715c20c43b Mon Sep 17 00:00:00 2001 From: Shuvro Roy Date: Sun, 16 Aug 2026 21:49:21 +0600 Subject: [PATCH 5/5] chore: add test for Laravel config resolution via symlinks --- src/virtual_members/laravel/config_keys.rs | 29 ++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/virtual_members/laravel/config_keys.rs b/src/virtual_members/laravel/config_keys.rs index ad95366b0..357a75b5d 100644 --- a/src/virtual_members/laravel/config_keys.rs +++ b/src/virtual_members/laravel/config_keys.rs @@ -939,6 +939,35 @@ return array_merge( assert!(content.contains("'disk'")); } + #[cfg(unix)] + #[test] + fn config_content_uses_an_open_buffer_through_a_symlink_alias() { + use std::os::unix::fs::symlink; + + let dir = tempfile::tempdir().unwrap(); + let real_dir = dir.path().join("package/resources"); + let linked_dir = dir.path().join("linked-resources"); + let target = real_dir.join("settings.php"); + std::fs::create_dir_all(&real_dir).unwrap(); + std::fs::write(&target, " 'disk'];\n").unwrap(); + symlink(&real_dir, &linked_dir).unwrap(); + + let backend = Backend::new_test(); + let target_uri = Url::from_file_path(&target).unwrap(); + let buffered = Arc::new(" 'buffer'];\n".to_string()); + backend + .open_files + .write() + .insert(target_uri.to_string(), Arc::clone(&buffered)); + + let aliased_path = linked_dir.join("settings.php"); + let (uri, content) = backend + .laravel_config_file_content(&aliased_path) + .expect("the canonical path should find the open buffer"); + assert_eq!(uri, target_uri); + assert!(Arc::ptr_eq(&content, &buffered)); + } + #[test] fn unreadable_config_sources_are_ignored_at_every_precedence_layer() { let dir = tempfile::tempdir().unwrap();