From fcfcd0e828f214f7d7282e9fc39bd972426934de Mon Sep 17 00:00:00 2001 From: Shuvro Roy Date: Sun, 16 Aug 2026 23:03:26 +0600 Subject: [PATCH 1/5] fix: normalize filesystem aliases during discovery --- docs/CHANGELOG.md | 1 + src/analyse/run.rs | 42 ++++++++++++++++++++- src/blade/call_site_inference.rs | 45 +++++++++++++++++++++- src/indexing/scan.rs | 65 +++++++++++++++++++++++++++++++- src/workspace_env.rs | 2 +- 5 files changed, 150 insertions(+), 5 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 5ddc2034b..009261ae5 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -127,6 +127,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - **A non-Laravel project's own `config()`, `route()`, `view()`, `__()`, or `trans()` function no longer hovers, navigates, or renames as a Laravel string key.** Completion and diagnostics already stood down on a project with no Laravel dependency, but hover, go-to-definition, find-references, and rename did not, so a home-grown micro-framework (or WordPress's `__()`/`_e()` gettext helpers) that happened to declare one of those names got a fabricated "Route name" or "Config key" tooltip, a working-looking jump to a `config/*.php` file that has nothing to do with the call, and a rename that rewrote both. All four now stand down the same way completion and diagnostics do. +- **Filesystem aliases remain consistent during discovery.** Projects opened through a path alias such as macOS's `/var` no longer let canonically discovered `/private/var` dependencies leak into project analysis, diagnostics, or references, and Blade templates still resolve to their view names. Composer rescans also refresh the canonical vendor path when `vendor/` was created after the workspace opened. 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/src/analyse/run.rs b/src/analyse/run.rs index b6567d819..103adedd0 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 @@ -915,6 +924,37 @@ mod tests { ); } + #[cfg(unix)] + #[test] + fn discover_user_files_normalizes_aliased_vendor_roots() { + use std::os::unix::fs::symlink; + + let dir = tempfile::tempdir().expect("failed to create temp dir"); + let real_root = dir.path().join("real-project"); + let linked_root = dir.path().join("linked-project"); + std::fs::create_dir_all(real_root.join("app")).unwrap(); + std::fs::create_dir_all(real_root.join("vendor/pkg")).unwrap(); + std::fs::write(real_root.join("app/Main.php"), ">>, /// `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: From ca5461294f0b1ccb1db4b4090078e9a3066e3721 Mon Sep 17 00:00:00 2001 From: Shuvro Roy Date: Sun, 16 Aug 2026 23:24:05 +0600 Subject: [PATCH 2/5] feat: add Laravel storage disk name intelligence --- docs/CHANGELOG.md | 1 + docs/todo.md | 1 - docs/todo/laravel.md | 21 +- examples/laravel/app/Demo.php | 13 +- examples/laravel/config/filesystems.php | 15 + src/completion/laravel_string_keys.rs | 705 ++++++++++++++++-- src/symbol_map/extraction/class_like.rs | 25 +- .../extraction/expressions/calls.rs | 7 + src/symbol_map/extraction/laravel.rs | 296 +++++++- src/symbol_map/tests.rs | 227 +++++- .../integration/laravel_storage_disk_names.rs | 371 +++++++++ tests/integration/main.rs | 1 + 12 files changed, 1581 insertions(+), 102 deletions(-) create mode 100644 tests/integration/laravel_storage_disk_names.rs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 009261ae5..9b038e44d 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **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 shows the config key, Ctrl+Click opens its declaration, and find-references links every use. Calls that require a configured disk report misspellings, while test fakes and disk eviction keep accepting the ad-hoc names Laravel permits 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). diff --git a/docs/todo.md b/docs/todo.md index 7be13c694..ea150982e 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -163,7 +163,6 @@ unlikely to move the needle for most users. | 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..fbf490908 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** @@ -654,12 +642,13 @@ moving the Blade file — defer that one until the rest is in place. **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('...')`, +Storage disks are 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 +(`Storage::disk()`, test fakes, disk eviction, and `#[Storage]`) 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 diff --git a/examples/laravel/app/Demo.php b/examples/laravel/app/Demo.php index a96dc8eaf..a76c67d3d 100644 --- a/examples/laravel/app/Demo.php +++ b/examples/laravel/app/Demo.php @@ -1085,12 +1085,17 @@ public function mixinModel(): void // ── Storage::fake() resolves to the concrete adapter ──────────────── - public function storageFake(): void + public function storageFake( + #[\Illuminate\Container\Attributes\Storage(disk: 'avatars')] mixed $avatarsDisk, + ): void { // fake() declares the Filesystem contract but always builds a // FilesystemAdapter, so the adapter-only assertion helpers resolve. - Storage::fake('avatars')->assertExists('me.png'); - Storage::persistentFake('logs')->assertMissing('old.log'); + // Disk names complete from config/filesystems.php, hover as their full + // config keys, and navigate back to their declarations. + Storage::fake(disk: 'avatars')->assertExists('me.png'); + Storage::persistentFake(disk: 'logs')->assertMissing('old.log'); + Storage::forgetDisk(disk: ['avatars', 'logs']); } @@ -1102,7 +1107,7 @@ public function storageDisk(): void // disk config/filesystems.php configures ('local', 's3') builds a // FilesystemAdapter, so adapter-only methods like download() // resolve on every configured disk, not just a faked one. - Storage::disk('s3')->download('report.pdf'); + Storage::disk(name: 's3')->download('report.pdf'); Storage::cloud()->assertExists('logo.png'); // The 'pantry' disk uses a driver the framework does not ship. Its 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/src/completion/laravel_string_keys.rs b/src/completion/laravel_string_keys.rs index fe69933c7..84812bd4d 100644 --- a/src/completion/laravel_string_keys.rs +++ b/src/completion/laravel_string_keys.rs @@ -30,10 +30,286 @@ struct LaravelStringKeyContext { config_sub_prefix: Option<&'static str>, } +#[derive(Clone, Copy, PartialEq, Eq)] +enum StringArgumentShape { + Scalar, + ArrayValue, +} + +struct StringArgumentContext<'a> { + callable: &'a str, + named_argument: Option<&'a str>, + shape: StringArgumentShape, +} + +#[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 unmatched call parenthesis enclosing a named argument. +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 +} + +/// Return the callable before a scalar first argument or a named argument. +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(':')?; + if !before_value[colon + 1..].trim().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))) +} + +/// Return the callable owning an array that directly contains this literal. +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 index = bytes.len(); + + while index > 0 { + index -= 1; + let byte = bytes[index]; + if let Some(quote) = string_quote { + if byte == quote && is_unescaped(bytes, index) { + 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 => { + return callable_before_scalar_argument(before_quote[..index].trim_end()); + } + 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[..index].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 +} + +/// Resource arrays name values; an associative key is bookkeeping, not a disk. +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 +} + +fn string_argument_context<'a>( + content: &'a str, + before_quote: &'a str, + cursor: usize, + quote: u8, +) -> Option> { + if let Some((callable, named_argument)) = callable_before_array_argument(before_quote) { + if string_literal_is_array_key(content, cursor, quote) { + return None; + } + return Some(StringArgumentContext { + callable, + named_argument, + shape: StringArgumentShape::ArrayValue, + }); + } + + let (callable, named_argument) = callable_before_scalar_argument(before_quote)?; + Some(StringArgumentContext { + callable, + named_argument, + shape: StringArgumentShape::Scalar, + }) +} + +fn imported_item_is_storage( + item: &str, + group_prefix: Option<&str>, + expected_fqn: &str, + referenced_name: &str, +) -> Option { + let mut words = item.split_whitespace(); + let imported_name = words.next()?; + let alias = match words.next() { + Some(as_keyword) if as_keyword.eq_ignore_ascii_case("as") => Some(words.next()?), + Some(_) => return None, + None => None, + }; + if words.next().is_some() { + return None; + } + + let imported_name = imported_name.trim_start_matches('\\'); + let class_matches = if let Some(prefix) = group_prefix { + let expected_prefix = expected_fqn + .strip_suffix("Storage") + .unwrap_or(expected_fqn) + .trim_end_matches('\\'); + prefix + .trim_start_matches('\\') + .trim_end_matches('\\') + .eq_ignore_ascii_case(expected_prefix) + && imported_name.eq_ignore_ascii_case("Storage") + } else { + imported_name.eq_ignore_ascii_case(expected_fqn) + }; + let local_name = + alias.unwrap_or_else(|| imported_name.rsplit('\\').next().unwrap_or(imported_name)); + local_name + .eq_ignore_ascii_case(referenced_name) + .then_some(class_matches) +} + +/// Whether a class spelling resolves to Laravel's Storage facade. +fn is_storage_facade_reference(content: &str, class_name: &str) -> bool { + const STORAGE_FACADE: &str = "Illuminate\\Support\\Facades\\Storage"; + + let is_root_qualified = class_name.starts_with('\\'); + let class_name = class_name.trim_start_matches('\\'); + if class_name.contains('\\') { + return class_name.eq_ignore_ascii_case(STORAGE_FACADE); + } + + for statement in content.split(';') { + let mut line_offset = 0usize; + let mut clause = None; + for line in statement.split_inclusive('\n') { + let trimmed = line.trim_start(); + if trimmed + .get(..4) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("use ")) + { + let leading = line.len() - trimmed.len(); + clause = Some(statement[line_offset + leading + 4..].trim()); + break; + } + line_offset += line.len(); + } + let Some(clause) = clause else { + continue; + }; + + if let Some(open) = clause.find('{') { + let Some(close) = clause.rfind('}') else { + continue; + }; + let prefix = clause[..open].trim(); + if let Some(matches) = clause[open + 1..close].split(',').find_map(|item| { + imported_item_is_storage(item, Some(prefix), STORAGE_FACADE, class_name) + }) { + return matches; + } + } else if let Some(matches) = clause + .split(',') + .find_map(|item| imported_item_is_storage(item, None, STORAGE_FACADE, class_name)) + { + return matches; + } + } + + class_name.eq_ignore_ascii_case("Storage") + && (is_root_qualified || !source_has_namespace(content)) +} + +fn source_has_namespace(content: &str) -> bool { + content.lines().any(|line| { + let mut line = line.trim_start(); + if let Some(rest) = line.strip_prefix(" 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'\'' || ch == b'"') && is_unescaped(bytes, i) { + quote_pos = Some(i); + break; } if ch == b'\n' { return None; @@ -70,12 +338,10 @@ fn detect_laravel_string_key_context( let quote_pos = quote_pos?; let prefix = content[quote_pos + 1..cursor_offset].to_string(); - // ── Before the quote, expect `(` (first argument) ─────────────── + // ── Locate the call argument that owns this string ───────────── 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(); + let argument = string_argument_context(content, before_quote, cursor_offset, bytes[quote_pos])?; + let before_paren = argument.callable; // ── Extract the function/method name ──────────────────────────── let bp_bytes = before_paren.as_bytes(); @@ -101,25 +367,20 @@ fn detect_laravel_string_key_context( // 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("#") - }; + // Everything between the nearest `#[` and this final class-name segment + // must itself be a class-name prefix. This recognizes FQN attributes + // without letting an unrelated attribute earlier in the file match. + let is_attribute = trimmed_before.rfind("#[").is_some_and(|start| { + trimmed_before[start + 2..] + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'\\') + }); // ── Map container attributes to config sub-prefixes ──────────── let (kind, config_sub_prefix) = if is_attribute { + if argument.shape != StringArgumentShape::Scalar { + return None; + } // 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 @@ -160,6 +421,15 @@ fn detect_laravel_string_key_context( } }; + if argument.named_argument.is_some() + && (!fqn_matches("Storage") + || argument + .named_argument + .is_some_and(|name| !name.eq_ignore_ascii_case("disk"))) + { + return None; + } + if fqn_matches("Config") { (Some(LaravelStringKind::Config), None) } else if fqn_matches("Database") || fqn_matches("DB") { @@ -191,39 +461,73 @@ fn detect_laravel_string_key_context( } let class_name = &before_colons[cls_start..]; let short = class_name.rsplit('\\').next().unwrap_or(class_name); - let fn_lower = func_name.to_ascii_lowercase(); - match (short.to_ascii_lowercase().as_str(), fn_lower.as_str()) { - ( - "config", - "get" | "set" | "has" | "boolean" | "array" | "collection" | "prepend" | "push", - ) => (Some(LaravelStringKind::Config), None), - ("view", "make" | "exists") => (Some(LaravelStringKind::View), None), - ("lang", "get" | "has" | "choice") => (Some(LaravelStringKind::Trans), None), - // Facade methods that accept config sub-keys: - ("auth", "guard") => (Some(LaravelStringKind::Config), Some("auth.guards.")), - ("db", "connection") => ( - Some(LaravelStringKind::Config), - Some("database.connections."), - ), - ("cache", "store") => (Some(LaravelStringKind::Config), Some("cache.stores.")), - ("log", "channel") => (Some(LaravelStringKind::Config), Some("logging.channels.")), - ("storage", "disk") => (Some(LaravelStringKind::Config), Some("filesystems.disks.")), - // 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), + let storage_argument = if is_storage_facade_reference(content, class_name) { + if func_name.eq_ignore_ascii_case("disk") { + Some(("name", false)) + } else if func_name.eq_ignore_ascii_case("fake") + || func_name.eq_ignore_ascii_case("persistentFake") + { + Some(("disk", false)) + } else if func_name.eq_ignore_ascii_case("forgetDisk") { + Some(("disk", true)) + } else { + None + } + } else { + None + }; + + if let Some((expected_name, accepts_array)) = storage_argument { + if argument + .named_argument + .is_some_and(|name| !name.eq_ignore_ascii_case(expected_name)) + || (argument.shape == StringArgumentShape::ArrayValue && !accepts_array) + { + return None; + } + (Some(LaravelStringKind::Config), Some("filesystems.disks.")) + } else if argument.named_argument.is_some() || argument.shape != StringArgumentShape::Scalar + { + (None, None) + } else { + let fn_lower = func_name.to_ascii_lowercase(); + match (short.to_ascii_lowercase().as_str(), fn_lower.as_str()) { + ( + "config", + "get" | "set" | "has" | "boolean" | "array" | "collection" | "prepend" | "push", + ) => (Some(LaravelStringKind::Config), None), + ("view", "make" | "exists") => (Some(LaravelStringKind::View), None), + ("lang", "get" | "has" | "choice") => (Some(LaravelStringKind::Trans), None), + // Facade methods that accept config sub-keys: + ("auth", "guard") => (Some(LaravelStringKind::Config), Some("auth.guards.")), + ("db", "connection") => ( + Some(LaravelStringKind::Config), + Some("database.connections."), + ), + ("cache", "store") => (Some(LaravelStringKind::Config), Some("cache.stores.")), + ("log", "channel") => (Some(LaravelStringKind::Config), Some("logging.channels.")), + // 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 { + if argument.named_argument.is_some() || argument.shape != StringArgumentShape::Scalar { + return None; + } // 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 = { @@ -284,6 +588,9 @@ fn detect_laravel_string_key_context( }; (k, None) } else { + if argument.named_argument.is_some() || argument.shape != StringArgumentShape::Scalar { + return None; + } match func_name.to_ascii_lowercase().as_str() { "route" | "to_route" => (Some(LaravelStringKind::Route), None), "config" => (Some(LaravelStringKind::Config), None), @@ -813,8 +1120,8 @@ impl Backend { /// Try Laravel string key completion. /// - /// Detects the cursor inside the first string argument of `route()`, - /// `config()`, `view()`, `__()`, etc. and offers matching key names. + /// Detects the cursor inside a supported string argument of `route()`, + /// `config()`, `Storage::forgetDisk()`, etc. and offers matching names. pub(crate) fn try_laravel_string_key_completion( &self, content: &str, @@ -897,6 +1204,25 @@ mod tests { use super::*; use tower_lsp::lsp_types::Position; + fn detect_at_end(content: &str, value: &str) -> Option { + let cursor = content.rfind(value)? + value.len(); + detect_laravel_string_key_context( + content, + crate::text_position::offset_to_position(content, cursor), + ) + } + + fn storage_source(expression: &str) -> String { + format!(" 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. @@ -1129,6 +1455,253 @@ mod tests { assert_eq!(ctx.prefix, "app."); } + #[test] + fn storage_argument_scanning_preserves_existing_static_contexts() { + for (expression, expected_kind, expected_prefix) in [ + ( + "DB::connection('primary')", + LaravelStringKind::Config, + Some("database.connections."), + ), + ( + "Model::getActualClassNameForMorph('post')", + LaravelStringKind::MorphAlias, + None, + ), + ] { + let content = format!(">(), + vec!["archive"] + ); + assert_eq!( + items[0].text_edit, + Some(CompletionTextEdit::Edit(TextEdit { + range: Range::new( + crate::text_position::offset_to_position(content, cursor - 2), + position, + ), + new_text: "archive".to_string(), + })) + ); + } + + #[test] + fn completion_response_item_helper_accepts_list_responses() { + let items = completion_response_items(CompletionResponse::List(CompletionList { + is_incomplete: false, + items: vec![CompletionItem::default()], + })); + assert_eq!(items.len(), 1); + } + + #[test] + fn forget_disk_arrays_complete_values_but_not_associative_keys() { + let content = " 'archive']);\n"; + let key_cursor = content.find("alias").unwrap() + "alias".len(); + assert!( + detect_laravel_string_key_context( + content, + crate::text_position::offset_to_position(content, key_cursor), + ) + .is_none(), + "an associative key is not a disk name" + ); + + let value = detect_at_end(content, "archive").expect("the array value is a disk name"); + assert!(matches!(value.kind, LaravelStringKind::Config)); + assert_eq!(value.prefix, "archive"); + } + + #[test] + fn rejects_invalid_storage_argument_names_and_shapes() { + for expression in [ + "Storage::disk(['archive'])", + "Storage::fake(['archive'])", + "Storage::persistentFake(['archive'])", + "Storage::forgetDisk([['archive']])", + "Storage::disk(disk: 'archive')", + "Storage::fake(name: 'archive')", + "Storage::persistentFake(name: 'archive')", + "Storage::forgetDisk(name: 'archive')", + "#[\\Illuminate\\Container\\Attributes\\Storage(name: 'archive')] class C {}", + "#[\\Illuminate\\Container\\Attributes\\Storage(disk: ['archive'])] class C {}", + "Storage::extend('archive', fn () => null)", + "Config::get(['archive'])", + "route(name: 'archive')", + ] { + let content = storage_source(expression); + assert!( + detect_at_end(&content, "archive").is_none(), + "`{expression}` must not offer disk completion" + ); + } + } + + #[test] + fn storage_argument_scanners_handle_nested_and_malformed_php() { + let before_named = r#"Storage::fake(config: ['message' => 'it\'s', 'factory' => wrap(fn () => new class {})], disk:"#; + assert_eq!( + callable_before_scalar_argument(before_named), + Some(("Storage::fake", Some("disk"))) + ); + + let nested = r#" new class {}), 'it\'s', 'archive']);"#; + let ctx = detect_at_end(nested, "archive") + .expect("balanced nested expressions must not hide the outer array"); + assert_eq!(ctx.config_sub_prefix, Some("filesystems.disks.")); + + assert!(callable_before_scalar_argument("Storage::disk(:").is_none()); + assert!(callable_before_scalar_argument("Storage::disk(name: value").is_none()); + assert!(callable_before_scalar_argument("orphan name:").is_none()); + assert!(enclosing_call_open_paren("completed(); orphan").is_none()); + assert!(enclosing_call_open_paren("orphan").is_none()); + assert!(callable_before_array_argument("factory('archive").is_none()); + assert!(callable_before_array_argument("{ 'archive").is_none()); + assert!(callable_before_array_argument("broken; 'archive").is_none()); + assert!(callable_before_array_argument("orphan").is_none()); + + let escaped_key = r#"'key\'part' => 'archive'"#; + 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'\'')); + } + #[test] fn detects_view_call() { let content = "( // 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( + if let Some(attribute) = resolve_laravel_container_attr( class_name, &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::Config => { + try_emit_laravel_string_span_partial( + crate::symbol_map::LaravelStringKind::Config, + arg_list, + ctx.content, + &mut ctx.spans, + ); + } + LaravelContainerAttribute::StorageDisk => { + try_emit_laravel_storage_disk_span_partial( + arg_list, + ctx.content, + &mut ctx.spans, + ); + } + } } // PHPUnit coverage attributes: #[CoversMethod(Foo::class, diff --git a/src/symbol_map/extraction/expressions/calls.rs b/src/symbol_map/extraction/expressions/calls.rs index b2cd68e2c..9b9ce9350 100644 --- a/src/symbol_map/extraction/expressions/calls.rs +++ b/src/symbol_map/extraction/expressions/calls.rs @@ -493,6 +493,13 @@ fn extract_call<'a>( &mut ctx.spans, ); } + try_emit_laravel_storage_disk_spans( + &subject_text, + &member_name, + &static_call.argument_list, + ctx.content, + &mut ctx.spans, + ); // 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") diff --git a/src/symbol_map/extraction/laravel.rs b/src/symbol_map/extraction/laravel.rs index ad5731299..923900fd0 100644 --- a/src/symbol_map/extraction/laravel.rs +++ b/src/symbol_map/extraction/laravel.rs @@ -13,16 +13,91 @@ pub(super) const LARAVEL_CONTAINER_ATTR_NAMES: &[&str] = &[ "DB", "Cache", "Log", - "Storage", "Auth", "Authenticated", ]; +/// The Laravel-specific meaning of a container-injection attribute. +pub(super) enum LaravelContainerAttribute { + /// A full config key, as accepted by `#[Config]` and the pre-existing + /// generic handling for the other container attributes. + Config, + /// A disk name accepted by `#[Storage]`. + StorageDisk, +} + +/// Whether a written class name is the global `Storage` facade alias, its +/// exact Laravel class, or a directly imported alias. In a namespace the +/// short spelling is accepted only when that facade is explicitly imported, +/// preventing a local `Storage` class from being mistaken for Laravel's. +pub(super) fn matches_laravel_storage_facade(class_name: &str, content: &str) -> bool { + let is_root_qualified = class_name.starts_with('\\'); + let class_name = class_name.trim_start_matches('\\'); + if class_name.eq_ignore_ascii_case("Illuminate\\Support\\Facades\\Storage") { + return true; + } + if class_name.contains('\\') { + return false; + } + if imports_laravel_storage_facade_as(content, class_name) { + return true; + } + class_name.eq_ignore_ascii_case("Storage") + && (is_root_qualified || !source_has_namespace(content)) +} + +fn imports_laravel_storage_facade_as(content: &str, class_name: &str) -> bool { + const FACADE: &str = "Illuminate\\Support\\Facades\\Storage"; + for line in content.lines() { + let mut line = line.trim(); + if let Some(rest) = line.strip_prefix(" bool { + content.lines().any(|line| { + let mut line = line.trim_start(); + if let Some(rest) = line.strip_prefix(", content: &str, -) -> Option { +) -> Option { if class_name.contains('\\') { let stripped = class_name.strip_prefix(LARAVEL_CONTAINER_ATTR_NS)?; + if stripped == "Storage" { + return Some(LaravelContainerAttribute::StorageDisk); + } if LARAVEL_CONTAINER_ATTR_NAMES.contains(&stripped) { - return Some(crate::symbol_map::LaravelStringKind::Config); + return Some(LaravelContainerAttribute::Config); } return None; } + if class_name == "Storage" { + return content + .contains("use Illuminate\\Container\\Attributes\\Storage;") + .then_some(LaravelContainerAttribute::StorageDisk); + } 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) + Some(LaravelContainerAttribute::Config) } else { None } @@ -82,6 +165,142 @@ pub(super) fn try_emit_laravel_string_span_at( emit_laravel_string_span(kind, false, index, argument_list, content, spans); } +const STORAGE_DISK_CONFIG_PREFIX: &str = "filesystems.disks."; + +/// Emit config-key spans for the disk names accepted by Laravel's `Storage` +/// facade. Registration helpers are writes; `forgetDisk()` is an optional +/// read because forgetting an unconfigured disk is valid. +pub(super) fn try_emit_laravel_storage_disk_spans( + facade: &str, + member_name: &str, + argument_list: &ArgumentList<'_>, + content: &str, + spans: &mut Vec, +) { + let (parameter, accepts_array, is_write, is_optional) = + if member_name.eq_ignore_ascii_case("disk") { + ("name", false, false, false) + } else if member_name.eq_ignore_ascii_case("fake") + || member_name.eq_ignore_ascii_case("persistentFake") + { + ("disk", false, true, false) + } else if member_name.eq_ignore_ascii_case("forgetDisk") { + ("disk", true, false, true) + } else { + return; + }; + if !matches_laravel_storage_facade(facade, content) { + return; + } + + let Some(argument) = argument_expr_for_parameter(argument_list, parameter) else { + return; + }; + if accepts_array { + let elements = match argument { + Expression::Array(array) => Some(&array.elements), + Expression::LegacyArray(array) => Some(&array.elements), + _ => None, + }; + if let Some(elements) = elements { + for element in elements.iter() { + let value = match element { + ArrayElement::KeyValue(element) => element.value, + ArrayElement::Value(element) => element.value, + ArrayElement::Variadic(_) | ArrayElement::Missing(_) => continue, + }; + push_storage_disk_span(value, is_write, is_optional, content, spans); + } + return; + } + } + push_storage_disk_span(argument, is_write, is_optional, content, spans); +} + +/// Emit the disk name accepted by Laravel's `#[Storage]` container +/// attribute, selecting the `disk` argument even when named arguments are +/// reordered. +pub(super) fn try_emit_laravel_storage_disk_span_partial( + argument_list: &PartialArgumentList<'_>, + content: &str, + spans: &mut Vec, +) { + if let Some(argument) = partial_argument_expr_for_parameter(argument_list, "disk") { + push_storage_disk_span(argument, false, false, content, spans); + } +} + +fn argument_expr_for_parameter<'a>( + argument_list: &ArgumentList<'a>, + 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() + .find_map(|argument| match argument { + Argument::Positional(positional) => Some(positional.value), + Argument::Named(_) => None, + }) +} + +fn partial_argument_expr_for_parameter<'a>( + argument_list: &PartialArgumentList<'a>, + 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() + .find_map(|argument| match argument { + PartialArgument::Positional(positional) => Some(positional.value), + _ => None, + }) +} + +fn push_storage_disk_span( + expression: &Expression<'_>, + is_write: bool, + is_optional: bool, + content: &str, + spans: &mut Vec, +) { + let Expression::Literal(literal::Literal::String(string)) = expression 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 disk = &content[start as usize..end as usize]; + let mut key = String::with_capacity(STORAGE_DISK_CONFIG_PREFIX.len() + disk.len()); + key.push_str(STORAGE_DISK_CONFIG_PREFIX); + key.push_str(disk); + spans.push(SymbolSpan { + start, + end, + kind: SymbolKind::LaravelStringKey { + key, + kind: crate::symbol_map::LaravelStringKind::Config, + is_write, + is_optional, + }, + }); +} + /// 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. @@ -1539,3 +1758,66 @@ pub(super) fn laravel_route_scan_expr( _ => {} } } + +#[cfg(test)] +mod storage_disk_tests { + use super::*; + + #[test] + fn storage_facade_imports_require_a_valid_direct_import() { + let direct = "namespace App;\nuse Illuminate\\Support\\Facades\\Storage;"; + assert!(matches_laravel_storage_facade("Storage", direct)); + assert!(!matches_laravel_storage_facade("LaravelStorage", direct)); + + let aliased = + "namespace App;\nuse Illuminate\\Support\\Facades\\Storage as LaravelStorage;"; + assert!(matches_laravel_storage_facade("LaravelStorage", aliased)); + + let incomplete_alias = "namespace App;\nuse Illuminate\\Support\\Facades\\Storage as;"; + assert!(!matches_laravel_storage_facade( + "LaravelStorage", + incomplete_alias + )); + + let malformed = + "namespace App;\nuse Illuminate\\Support\\Facades\\Storage from LaravelStorage;"; + assert!(!matches_laravel_storage_facade("LaravelStorage", malformed)); + } + + #[test] + fn fully_qualified_container_attributes_keep_their_distinct_meanings() { + let mut import_cache = None; + assert!(matches!( + resolve_laravel_container_attr( + "Illuminate\\Container\\Attributes\\Storage", + &mut import_cache, + "" + ), + Some(LaravelContainerAttribute::StorageDisk) + )); + assert!(matches!( + resolve_laravel_container_attr( + "Illuminate\\Container\\Attributes\\Config", + &mut import_cache, + "" + ), + Some(LaravelContainerAttribute::Config) + )); + } + + #[test] + fn storage_attribute_without_a_disk_argument_emits_no_config_key() { + let php = r#" Vec<(String, bool, bool)> { + map.spans + .iter() + .filter_map(|span| match &span.kind { + SymbolKind::LaravelStringKey { + kind: LaravelStringKind::Config, + key, + is_write, + is_optional, + } if key.starts_with("filesystems.disks.") => { + Some((key.clone(), *is_write, *is_optional)) + } + _ => None, + }) + .collect() +} + +#[test] +fn every_storage_disk_method_records_a_canonical_config_key() { + for (method, is_write, is_optional) in [ + ("disk", false, false), + ("fake", true, false), + ("persistentFake", true, false), + ("forgetDisk", false, true), + ] { + let php = format!(" 'private'], disk: 'testing'); +Storage::persistentFake(config: [], disk: 'persistent'); +Storage::forgetDisk(disk: 'forgotten'); +"#; + assert_eq!( + storage_disk_keys(&parse_and_extract(php)), + vec![ + ("filesystems.disks.archive".to_string(), false, false), + ("filesystems.disks.testing".to_string(), true, false), + ("filesystems.disks.persistent".to_string(), true, false,), + ("filesystems.disks.forgotten".to_string(), false, true), + ] + ); +} + +#[test] +fn forget_disk_accepts_scalar_and_both_array_spellings() { + let php = r#" 'backup', $dynamic, ...$more]); +Storage::forgetDisk(array('legacy', 'label' => 'cold', $dynamic)); +"#; + assert_eq!( + storage_disk_keys(&parse_and_extract(php)), + vec![ + ("filesystems.disks.scalar".to_string(), false, true), + ("filesystems.disks.archive".to_string(), false, true), + ("filesystems.disks.backup".to_string(), false, true), + ("filesystems.disks.legacy".to_string(), false, true), + ("filesystems.disks.cold".to_string(), false, true), + ] + ); +} + +#[test] +fn fully_qualified_storage_facade_records_a_disk_key() { + let php = r#" null)", + "$storage->disk('archive')", + ] { + let map = parse_and_extract(&format!(" '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) + } + } +} + +fn sorted_locations(locations: Vec) -> Vec { + let mut locations = locations + .into_iter() + .map(|location| { + format!( + "{}:{}:{}", + location.uri, location.range.start.line, location.range.start.character + ) + }) + .collect::>(); + locations.sort_unstable(); + locations +} + +#[tokio::test] +async fn scalar_array_and_named_storage_arguments_complete_configured_disks() { + let source = r#" true]); +Storage::forgetDisk(array('ba' => true)); +#[\Illuminate\Container\Attributes\Storage(disk: ['a'])] +class DiskConsumer {} +"#; + let (backend, _dir, uri) = open_workspace(source).await; + + for prefix in [ + "Storage::disk(['a", + "Storage::fake(['a", + "Storage::persistentFake(['a", + "Storage::forgetDisk(config: 'a", + "Storage::forgetDisk(['ar", + "Storage::forgetDisk(array('ba", + "Attributes\\Storage(disk: ['a", + ] { + let labels = completion_labels(&backend, &uri, position_after(source, prefix)).await; + assert!( + labels + .iter() + .all(|label| !["archive", "backup", "local"].contains(&label.as_str())), + "invalid call shape at `{prefix}` offered storage disks: {labels:?}" + ); + } +} + +#[tokio::test] +async fn every_storage_context_navigates_and_hovers_as_its_full_config_key() { + let source = r#">(); + assert_eq!(invalid_config.len(), 2, "got: {invalid_config:#?}"); + + let messages = invalid_config + .iter() + .map(|diagnostic| diagnostic.message.as_str()) + .collect::>(); + assert!( + messages + .iter() + .any(|message| message.contains("filesystems.disks.missing-disk")) + ); + assert!( + messages + .iter() + .any(|message| message.contains("filesystems.disks.missing-attribute")) + ); + for optional_or_written in [ + "testing", + "persistent-testing", + "already-forgotten", + "forgotten-one", + "forgotten-two", + "forgotten-three", + ] { + assert!( + messages + .iter() + .all(|message| !message.contains(optional_or_written)), + "`{optional_or_written}` should not be diagnosed: {messages:?}" + ); + } +} + +#[tokio::test] +async fn storage_calls_generic_config_access_and_declaration_share_references_symmetrically() { + let source = r#" Date: Mon, 17 Aug 2026 05:47:28 +0600 Subject: [PATCH 3/5] feat: add Laravel config resource name intelligence Recognize auth guards, cache stores, log channels, database and queue connections, mailers, and broadcast connections across direct Laravel helpers, facades, contextual attributes, and route middleware. Drive completion, hover, navigation, diagnostics, and references from one descriptor table while preserving semantic alias and homonym handling. --- docs/ARCHITECTURE.md | 1 + docs/CHANGELOG.md | 1 + docs/todo.md | 5 +- docs/todo/laravel.md | 76 +- examples/laravel/app/Demo.php | 56 +- examples/laravel/assertions.php | 77 +- examples/laravel/config/broadcasting.php | 11 + examples/laravel/config/cache.php | 11 + examples/laravel/config/logging.php | 17 + examples/laravel/config/mail.php | 12 + examples/laravel/config/queue.php | 13 + src/completion/handler/mod.rs | 2 +- src/completion/laravel_string_keys.rs | 1531 +++++++++++++---- src/diagnostics/mod.rs | 231 ++- src/hover/mod.rs | 14 + src/indexing/scan.rs | 5 + src/lib.rs | 10 +- src/mem_audit.rs | 15 +- src/parser/ast_update.rs | 396 ++++- src/reference_index.rs | 136 +- src/references/dispatch.rs | 18 +- src/rename/prepare.rs | 57 + src/resolution.rs | 24 + src/symbol_map/extraction/class_like.rs | 20 +- .../extraction/expressions/calls.rs | 102 +- src/symbol_map/extraction/laravel.rs | 510 +++--- src/symbol_map/extraction/mod.rs | 64 + src/symbol_map/laravel_resources.rs | 929 ++++++++++ src/symbol_map/mod.rs | 225 ++- src/symbol_map/tests.rs | 399 ++++- src/virtual_members/laravel/config_keys.rs | 567 +++++- src/virtual_members/laravel/mod.rs | 4 +- src/virtual_members/laravel/string_keys.rs | 89 +- tests/integration/laravel_named_resources.rs | 1122 ++++++++++++ .../integration/laravel_storage_disk_names.rs | 20 +- .../laravel_string_key_non_laravel_gate.rs | 138 ++ tests/integration/main.rs | 1 + 37 files changed, 6068 insertions(+), 841 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/symbol_map/laravel_resources.rs create mode 100644 tests/integration/laravel_named_resources.rs diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 0e0472074..20d597227 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -236,6 +236,7 @@ The symbol map also stores: - **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. +- **Config-backed Laravel resource names**: one declarative table maps direct helpers, facades, contextual attributes, and middleware parameters to their config subtrees. A `SymbolSpan` whose Laravel string kind is `ConfigResource(...)` stores the short source name, while completion, navigation, diagnostics, and references derive the full dot key only at the config boundary. This keeps source ranges and reference identity exact without duplicating one trigger table across LSP features. ### Tier 2: Stored Byte Offsets (cross-file jumps) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 9b038e44d..1df505bf3 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **More configured Laravel service names are editor-aware.** Beyond storage disks, names passed through framework facades, contextual attributes, and route authentication middleware now complete from the matching config subtree; hover identifies the resource family, Ctrl+Click opens the exact declaration, references connect direct config access, and misspellings are reported with family-specific diagnostics. Channel arrays passed to `Log::stack()` are understood value by value. 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 shows the config key, Ctrl+Click opens its declaration, and find-references links every use. Calls that require a configured disk report misspellings, while test fakes and disk eviction keep accepting the ad-hoc names Laravel permits 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. diff --git a/docs/todo.md b/docs/todo.md index ea150982e..3d7ef90c6 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -160,7 +160,10 @@ 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 | +| L52 | [Typed Laravel connection names](todo/laravel.md#l52-typed-laravel-connection-names) | Medium | Medium | +| L54 | [Laravel rate limiter names](todo/laravel.md#l54-laravel-rate-limiter-names) | Medium | Medium | +| L53 | [Laravel queue names](todo/laravel.md#l53-laravel-queue-names) | Low-Medium | Medium | +| L55 | [Typed controller middleware names](todo/laravel.md#l55-typed-controller-middleware-names) | Low-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 | | L31 | [String-key rename, highlight, and semantic tokens](todo/laravel.md#l31-string-key-rename-highlight-and-semantic-tokens) | Low-Medium | Medium | diff --git a/docs/todo/laravel.md b/docs/todo/laravel.md index fbf490908..dc1a3d0d3 100644 --- a/docs/todo/laravel.md +++ b/docs/todo/laravel.md @@ -629,7 +629,7 @@ accessors, `@property` tags) — no database needed. **Impact: Low-Medium · Complexity: Medium** -References and go-to-definition already work for the four indexed +References and go-to-definition already work for indexed Laravel string kinds, but the rename, document-highlight, and semantic-token arms are explicit no-ops. Wiring them up exceeds the Laravel LSP (which has none of the three): renaming a translation key updates the lang @@ -638,43 +638,49 @@ 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 +#### L52. Typed Laravel connection names **Impact: Medium · Complexity: Medium** -Storage disks are 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 -(`Storage::disk()`, test fakes, disk eviction, and `#[Storage]`) 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. +The method name `->connection()` does not identify one config subtree: the +receiver may select `database.connections.*`, `queue.connections.*`, or +`broadcasting.connections.*`. Resolve it through the shared type engine, and +treat `->onConnection()` as a queue connection. A model's `$connection` is a +database connection, while the same property on a queueable job selects a +queue connection. Each confirmed literal should receive the same completion, +hover, navigation, diagnostics, and references as the direct facade spelling. + +#### L55. Typed controller middleware names + +**Impact: Low-Medium · Complexity: Medium** + +`$this->middleware('auth:admin')` names middleware only when `$this` is a +Laravel controller; an unrelated class may define the same method for a +different purpose. Confirm the enclosing class through the shared type engine +before completing or validating embedded authentication guards. Static and +fluent `Route::middleware()` calls remain syntactically unambiguous. + +#### L53. Laravel queue names + +**Impact: Low-Medium · Complexity: Medium** + +`->onQueue()` names are free-form rather than config-backed. Complete from +literals seen elsewhere in the project and connect those occurrences for +navigation and references, but do not diagnose a name merely because the +static index has not seen it. + +#### L54. Laravel rate limiter names + +**Impact: Medium · Complexity: Medium** + +Rate limiter names are registered through `RateLimiter::for('name', …)` in +service providers. Scan literal registrations using the same provider-aware +shape as the macro scanner, then complete, navigate, and validate +`throttle:name` middleware parameters and `new RateLimited('name')` against +the discovered set. Numeric inline limits such as `throttle:60,1` remain +values rather than named registrations. Keep the world open when no +registration source can be read so a partial index does not create false +diagnostics. #### L39. Unused view and translation key detection diff --git a/examples/laravel/app/Demo.php b/examples/laravel/app/Demo.php index a76c67d3d..061877005 100644 --- a/examples/laravel/app/Demo.php +++ b/examples/laravel/app/Demo.php @@ -24,6 +24,12 @@ 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\Database\Eloquent\Relations\Relation; use Illuminate\Http\Request; use Carbon\CarbonImmutable; @@ -31,12 +37,18 @@ 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 +846,46 @@ public function laravelConfig(): void } + // ── Config-backed Laravel resource names ─────────────────────────── + + public function injectedNamedResources( + #[InjectAuth(guard: 'admin')] mixed $guard, + #[InjectAuthenticated(guard: 'admin')] mixed $user, + #[InjectCache(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 + { + // Hover identifies each resource family, Ctrl+Click opens its config + // entry, and references include direct config() access to that entry. + auth('admin'); + Auth::guard('admin'); + Cache::store('memory'); + Log::channel('daily'); + Log::stack(['daily', 'stderr']); + Storage::disk('pantry'); + DB::connection('mysql'); + DB::connection('mysql::read'); + Queue::connection('redis'); + Mail::mailer('transactional'); + Broadcast::connection('internal'); + Route::middleware(['auth:admin']); + config('cache.stores.memory'); + + // Laravel supplies these null drivers at runtime even though no + // matching child needs to exist in cache.php or queue.php. + Cache::store('null'); + Queue::connection('null'); + } + + // ── Cache::remember() — closure return type binding ───────────────── public function cacheRemember(): void @@ -1091,8 +1143,8 @@ public function storageFake( { // fake() declares the Filesystem contract but always builds a // FilesystemAdapter, so the adapter-only assertion helpers resolve. - // Disk names complete from config/filesystems.php, hover as their full - // config keys, and navigate back to their declarations. + // Disk names complete from config/filesystems.php, hover with their + // resource family, and navigate back to their declarations. Storage::fake(disk: 'avatars')->assertExists('me.png'); Storage::persistentFake(disk: 'logs')->assertMissing('old.log'); Storage::forgetDisk(disk: ['avatars', 'logs']); diff --git a/examples/laravel/assertions.php b/examples/laravel/assertions.php index 990095cc4..c3f5b9407 100644 --- a/examples/laravel/assertions.php +++ b/examples/laravel/assertions.php @@ -6,7 +6,7 @@ * * These assertions verify that our assumptions about Laravel's runtime * behaviour are correct, so the LSP can model them accurately. - * Uses only reflection (no database or app boot required). + * Requires no external database or full application boot. */ require_once __DIR__ . '/vendor/autoload.php'; @@ -183,6 +183,81 @@ function assertMethodReturnType(string $class, string $method, string $expected) is_subclass_of(\App\Models\Administrator::class, \Illuminate\Contracts\Auth\Authenticatable::class) ); +// ─── Config-backed Laravel resource names ────────────────────────────────── + +$namedResourceConfigs = [ + 'auth.php' => ['guards', 'admin'], + 'cache.php' => ['stores', 'memory'], + 'logging.php' => ['channels', ['daily', 'stderr']], + 'filesystems.php' => ['disks', 'pantry'], + 'database.php' => ['connections', 'mysql'], + 'queue.php' => ['connections', 'redis'], + 'mail.php' => ['mailers', 'transactional'], + 'broadcasting.php' => ['connections', 'internal'], +]; +$previousContainer = \Illuminate\Container\Container::getInstance(); +$configContainer = new class extends \Illuminate\Container\Container { + public function storagePath(string $path = ''): string + { + return __DIR__ . '/storage' . ($path === '' ? '' : "/$path"); + } +}; +\Illuminate\Container\Container::setInstance($configContainer); +foreach ($namedResourceConfigs as $file => [$subtree, $names]) { + $config = require __DIR__ . "/config/$file"; + foreach ((array) $names as $name) { + check( + "$file declares $subtree.$name", + isset($config[$subtree]) && array_key_exists($name, $config[$subtree]) + ); + } +} +\Illuminate\Container\Container::setInstance($previousContainer); + +$databaseManager = (new ReflectionClass( + \Illuminate\Database\DatabaseManager::class +))->newInstanceWithoutConstructor(); +$parseConnectionName = new ReflectionMethod($databaseManager, 'parseConnectionName'); +foreach (['read', 'write', 'direct'] as $role) { + check( + "database role suffix ::$role selects the mysql config", + $parseConnectionName->invoke($databaseManager, "mysql::$role") === ['mysql', $role] + ); +} + +foreach ([ + \Illuminate\Cache\CacheManager::class, + \Illuminate\Queue\QueueManager::class, +] as $managerClass) { + $manager = (new ReflectionClass($managerClass))->newInstanceWithoutConstructor(); + $getConfig = new ReflectionMethod($manager, 'getConfig'); + check( + "$managerClass supplies the null driver without config", + $getConfig->invoke($manager, 'null') === ['driver' => 'null'] + ); +} + +$injectedResources = new ReflectionMethod(\App\Demo::class, 'injectedNamedResources'); +$attributeCases = [ + 'guard' => [\Illuminate\Container\Attributes\Auth::class, 'guard', 'admin'], + 'user' => [\Illuminate\Container\Attributes\Authenticated::class, 'guard', 'admin'], + 'cache' => [\Illuminate\Container\Attributes\Cache::class, 'store', 'memory'], + 'logger' => [\Illuminate\Container\Attributes\Log::class, 'channel', 'daily'], + 'disk' => [\Illuminate\Container\Attributes\Storage::class, 'disk', 'pantry'], + 'database' => [\Illuminate\Container\Attributes\Database::class, 'connection', 'mysql'], +]; +foreach ($injectedResources->getParameters() as $parameter) { + [$attributeClass, $property, $expected] = $attributeCases[$parameter->getName()]; + $attributes = $parameter->getAttributes($attributeClass); + check("{$parameter->getName()} has its contextual attribute", count($attributes) === 1); + if ($attributes !== []) { + check( + "{$parameter->getName()} contextual attribute selects $expected", + $attributes[0]->newInstance()->$property === $expected + ); + } +} + // ─── Paginator element types ───────────────────────────────────────────────── // paginate()/simplePaginate()/cursorPaginate() exist on the Eloquent Builder 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/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/completion/handler/mod.rs b/src/completion/handler/mod.rs index 853fb6489..825735e4a 100644 --- a/src/completion/handler/mod.rs +++ b/src/completion/handler/mod.rs @@ -373,7 +373,7 @@ impl Backend { StringContext::InStringLiteral | StringContext::NotInString ) && let Some(response) = - self.try_laravel_string_key_completion(&content, position) + self.try_laravel_string_key_completion_in_file(&content, position, &ctx) { return Ok(Some(response)); } diff --git a/src/completion/laravel_string_keys.rs b/src/completion/laravel_string_keys.rs index 84812bd4d..2017c00b0 100644 --- a/src/completion/laravel_string_keys.rs +++ b/src/completion/laravel_string_keys.rs @@ -13,14 +13,15 @@ use std::collections::HashMap; use tower_lsp::lsp_types::*; use crate::Backend; -use crate::symbol_map::LaravelStringKind; +use crate::symbol_map::{LaravelConfigResource, LaravelStringKind}; use crate::text_position::position_to_offset; +use crate::types::FileContext; // ─── Context ──────────────────────────────────────────────────────────────── -struct LaravelStringKeyContext { +struct LaravelStringKeyContext<'a> { kind: LaravelStringKind, - prefix: String, + prefix: &'a str, /// 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. @@ -51,6 +52,15 @@ fn is_unescaped(bytes: &[u8], index: usize) -> bool { (index - before).is_multiple_of(2) } +#[derive(Clone, Copy, PartialEq, Eq)] +enum PhpLexState { + Code, + SingleQuoted, + DoubleQuoted, + LineComment, + BlockComment, +} + /// Find the unmatched call parenthesis enclosing a named argument. fn enclosing_call_open_paren(content: &str) -> Option { let bytes = content.as_bytes(); @@ -170,16 +180,47 @@ fn string_literal_is_array_key(content: &str, cursor: usize, quote: u8) -> bool 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; + let after_literal = skip_php_trivia_forward(content, index + 1); + return content[after_literal..].starts_with("=>"); } index += 1; } false } +/// Skip whitespace and PHP comments without allocating or scanning beyond the +/// first real token. Comments are valid between an array key and its `=>`. +fn skip_php_trivia_forward(content: &str, mut index: usize) -> usize { + let bytes = content.as_bytes(); + loop { + while bytes.get(index).is_some_and(u8::is_ascii_whitespace) { + index += 1; + } + match bytes.get(index..index.saturating_add(2)) { + Some(b"//") => { + index += 2; + while bytes.get(index).is_some_and(|byte| *byte != b'\n') { + index += 1; + } + } + Some(b"/*") => { + index += 2; + while index < bytes.len() && bytes.get(index..index + 2) != Some(b"*/") { + index += 1; + } + index = (index + 2).min(bytes.len()); + } + _ if bytes.get(index) == Some(&b'#') => { + index += 1; + while bytes.get(index).is_some_and(|byte| *byte != b'\n') { + index += 1; + } + } + _ => return index, + } + } +} + fn string_argument_context<'a>( content: &'a str, before_quote: &'a str, @@ -205,12 +246,13 @@ fn string_argument_context<'a>( }) } -fn imported_item_is_storage( +fn imported_item_target( item: &str, group_prefix: Option<&str>, - expected_fqn: &str, + expected_namespace: &str, + candidates: &[&'static str], referenced_name: &str, -) -> Option { +) -> Option> { let mut words = item.split_whitespace(); let imported_name = words.next()?; let alias = match words.next() { @@ -223,75 +265,194 @@ fn imported_item_is_storage( } let imported_name = imported_name.trim_start_matches('\\'); - let class_matches = if let Some(prefix) = group_prefix { - let expected_prefix = expected_fqn - .strip_suffix("Storage") - .unwrap_or(expected_fqn) - .trim_end_matches('\\'); + let local_name = + alias.unwrap_or_else(|| imported_name.rsplit('\\').next().unwrap_or(imported_name)); + if !local_name.eq_ignore_ascii_case(referenced_name) { + return None; + } + + let target = if let Some(prefix) = group_prefix { prefix .trim_start_matches('\\') .trim_end_matches('\\') - .eq_ignore_ascii_case(expected_prefix) - && imported_name.eq_ignore_ascii_case("Storage") + .eq_ignore_ascii_case(expected_namespace) + .then(|| { + candidates + .iter() + .copied() + .find(|candidate| imported_name.eq_ignore_ascii_case(candidate)) + }) + .flatten() } else { - imported_name.eq_ignore_ascii_case(expected_fqn) + let (namespace, short) = imported_name + .rsplit_once('\\') + .unwrap_or(("", imported_name)); + namespace + .eq_ignore_ascii_case(expected_namespace) + .then(|| { + candidates + .iter() + .copied() + .find(|candidate| short.eq_ignore_ascii_case(candidate)) + }) + .flatten() }; - let local_name = - alias.unwrap_or_else(|| imported_name.rsplit('\\').next().unwrap_or(imported_name)); - local_name - .eq_ignore_ascii_case(referenced_name) - .then_some(class_matches) + Some(target) } -/// Whether a class spelling resolves to Laravel's Storage facade. -fn is_storage_facade_reference(content: &str, class_name: &str) -> bool { - const STORAGE_FACADE: &str = "Illuminate\\Support\\Facades\\Storage"; +/// Resolve one spelling against a small, fixed set of framework classes. +/// +/// `Some` identifies the matched short name. `None` covers both an unknown +/// spelling and an explicit import of an unrelated class under the same local +/// name, which is important for rejecting namespace-local facade homonyms. +#[derive(Clone, Copy)] +struct ResolvedClassReference<'a> { + written: &'a str, + semantic: &'a str, + semantic_is_authoritative: bool, +} - let is_root_qualified = class_name.starts_with('\\'); - let class_name = class_name.trim_start_matches('\\'); +fn resolve_known_class_reference( + content: &str, + reference: ResolvedClassReference<'_>, + expected_namespace: &str, + candidates: &[&'static str], + allow_root_alias: bool, + indexed_class_exists: Option<&dyn Fn(&str) -> bool>, +) -> Option<&'static str> { + let is_root_qualified = reference.written.starts_with('\\'); + let class_name = reference.semantic.trim_start_matches('\\'); if class_name.contains('\\') { - return class_name.eq_ignore_ascii_case(STORAGE_FACADE); + let (namespace, short) = class_name.rsplit_once('\\')?; + return namespace + .eq_ignore_ascii_case(expected_namespace) + .then(|| { + candidates + .iter() + .copied() + .find(|candidate| short.eq_ignore_ascii_case(candidate)) + }) + .flatten(); } - for statement in content.split(';') { - let mut line_offset = 0usize; - let mut clause = None; - for line in statement.split_inclusive('\n') { - let trimmed = line.trim_start(); - if trimmed - .get(..4) - .is_some_and(|prefix| prefix.eq_ignore_ascii_case("use ")) - { - let leading = line.len() - trimmed.len(); - clause = Some(statement[line_offset + leading + 4..].trim()); - break; - } - line_offset += line.len(); + if reference.semantic_is_authoritative { + if !allow_root_alias { + return None; } - let Some(clause) = clause else { - continue; - }; - - if let Some(open) = clause.find('{') { - let Some(close) = clause.rfind('}') else { + let candidate = candidates + .iter() + .copied() + .find(|candidate| class_name.eq_ignore_ascii_case(candidate))?; + return (!indexed_class_exists.is_some_and(|exists| exists(candidate))) + .then_some(candidate); + } + + if !is_root_qualified { + for statement in content.split(';') { + let mut line_offset = 0usize; + let mut clause = None; + for line in statement.split_inclusive('\n') { + let trimmed = line.trim_start(); + if trimmed + .get(..4) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case("use ")) + { + let leading = line.len() - trimmed.len(); + clause = Some(statement[line_offset + leading + 4..].trim()); + break; + } + line_offset += line.len(); + } + let Some(clause) = clause else { continue; }; - let prefix = clause[..open].trim(); - if let Some(matches) = clause[open + 1..close].split(',').find_map(|item| { - imported_item_is_storage(item, Some(prefix), STORAGE_FACADE, class_name) + + if let Some(open) = clause.find('{') { + let Some(close) = clause.rfind('}') else { + continue; + }; + let prefix = clause[..open].trim(); + if let Some(target) = clause[open + 1..close].split(',').find_map(|item| { + imported_item_target( + item, + Some(prefix), + expected_namespace, + candidates, + class_name, + ) + }) { + return target; + } + } else if let Some(target) = clause.split(',').find_map(|item| { + imported_item_target(item, None, expected_namespace, candidates, class_name) }) { - return matches; + return target; } - } else if let Some(matches) = clause - .split(',') - .find_map(|item| imported_item_is_storage(item, None, STORAGE_FACADE, class_name)) - { - return matches; } } - class_name.eq_ignore_ascii_case("Storage") - && (is_root_qualified || !source_has_namespace(content)) + if !allow_root_alias || (!is_root_qualified && source_has_namespace(content)) { + return None; + } + let candidate = candidates + .iter() + .copied() + .find(|candidate| class_name.eq_ignore_ascii_case(candidate))?; + (!indexed_class_exists.is_some_and(|exists| exists(candidate))).then_some(candidate) +} + +fn config_resource_static_trigger( + content: &str, + reference: ResolvedClassReference<'_>, + method: &str, + indexed_class_exists: Option<&dyn Fn(&str) -> bool>, +) -> Option { + if !crate::symbol_map::laravel_resources::static_method_may_trigger(method) { + return None; + } + + let facade = resolve_known_class_reference( + content, + reference, + "Illuminate\\Support\\Facades", + crate::symbol_map::laravel_resources::RESOURCE_FACADES, + true, + indexed_class_exists, + )?; + crate::symbol_map::laravel_resources::static_method_trigger(facade, method) +} + +fn config_resource_attribute_trigger( + content: &str, + reference: ResolvedClassReference<'_>, + indexed_class_exists: Option<&dyn Fn(&str) -> bool>, +) -> Option { + let attribute = resolve_known_class_reference( + content, + reference, + "Illuminate\\Container\\Attributes", + crate::symbol_map::laravel_resources::RESOURCE_ATTRIBUTES, + false, + indexed_class_exists, + )?; + crate::symbol_map::laravel_resources::attribute_trigger(attribute) +} + +fn is_laravel_facade_reference( + content: &str, + reference: ResolvedClassReference<'_>, + facade: &'static str, + indexed_class_exists: Option<&dyn Fn(&str) -> bool>, +) -> bool { + resolve_known_class_reference( + content, + reference, + "Illuminate\\Support\\Facades", + &[facade], + true, + indexed_class_exists, + ) + .is_some() } fn source_has_namespace(content: &str) -> bool { @@ -306,14 +467,440 @@ fn source_has_namespace(content: &str) -> bool { }) } +#[inline] +fn semantic_class_reference<'a>( + written: &'a str, + offset: usize, + resolved_names: Option<&'a crate::names::OwnedResolvedNames>, +) -> ResolvedClassReference<'a> { + match resolved_names.and_then(|names| names.get(offset as u32)) { + Some(name) => ResolvedClassReference { + written, + semantic: name, + semantic_is_authoritative: true, + }, + None => ResolvedClassReference { + written, + semantic: written, + semantic_is_authoritative: false, + }, + } +} + +/// Find the last syntactic PHP attribute opener before `end`. +/// Attribute-looking text inside strings and comments is deliberately ignored. +fn last_attribute_open_before(content: &str, end: usize) -> Option { + let bytes = content.as_bytes(); + let end = end.min(bytes.len()); + let mut state = PhpLexState::Code; + let mut last = None; + let mut index = 0usize; + + while index < end { + let byte = bytes[index]; + match state { + PhpLexState::Code => match byte { + b'\'' => { + state = PhpLexState::SingleQuoted; + index += 1; + } + b'"' => { + state = PhpLexState::DoubleQuoted; + index += 1; + } + b'/' if bytes.get(index + 1) == Some(&b'/') => { + state = PhpLexState::LineComment; + index += 2; + } + b'/' if bytes.get(index + 1) == Some(&b'*') => { + state = PhpLexState::BlockComment; + index += 2; + } + b'#' if bytes.get(index + 1) == Some(&b'[') => { + last = Some(index); + index += 2; + } + b'#' => { + state = PhpLexState::LineComment; + index += 1; + } + _ => index += 1, + }, + PhpLexState::SingleQuoted => { + if byte == b'\'' && is_unescaped(bytes, index) { + state = PhpLexState::Code; + } + index += 1; + } + PhpLexState::DoubleQuoted => { + if byte == b'"' && is_unescaped(bytes, index) { + state = PhpLexState::Code; + } + index += 1; + } + PhpLexState::LineComment => { + if byte == b'\n' || byte == b'\r' { + state = PhpLexState::Code; + } + index += 1; + } + PhpLexState::BlockComment => { + if byte == b'*' && bytes.get(index + 1) == Some(&b'/') { + state = PhpLexState::Code; + index += 2; + } else { + index += 1; + } + } + } + } + + last +} + +/// Start of the class name for the attribute call ending at `name_start`. +/// +/// Attribute groups may contain earlier attributes and arbitrary balanced +/// argument expressions. Only a class that starts a top-level group element +/// is accepted; a lookalike call nested inside another attribute is not. +fn attribute_class_start(before_paren: &str, name_start: usize) -> Option { + let bytes = before_paren.as_bytes(); + let mut class_start = name_start; + while class_start > 0 + && (bytes[class_start - 1].is_ascii_alphanumeric() + || matches!(bytes[class_start - 1], b'_' | b'\\')) + { + class_start -= 1; + } + + if !matches!( + before_paren[..class_start].trim_end().as_bytes().last(), + Some(b'[' | b',') + ) { + return None; + } + + let open = last_attribute_open_before(before_paren, class_start)?; + let between = &before_paren[open + 2..class_start]; + let bytes = between.as_bytes(); + let mut round = 0usize; + let mut square = 0usize; + let mut curly = 0usize; + let mut quote = None; + let mut element_start = 0usize; + + for (index, byte) in bytes.iter().copied().enumerate() { + if let Some(active) = quote { + if byte == active && is_unescaped(bytes, index) { + quote = None; + } + continue; + } + match byte { + b'\'' | b'"' => quote = Some(byte), + b'(' => round += 1, + b')' if round > 0 => round -= 1, + b'[' => square += 1, + b']' if square > 0 => square -= 1, + b']' => return None, + b'{' => curly += 1, + b'}' if curly > 0 => curly -= 1, + b',' if round == 0 && square == 0 && curly == 0 => element_start = index + 1, + _ => {} + } + } + + (quote.is_none() + && round == 0 + && square == 0 + && curly == 0 + && between[element_start..].trim().is_empty()) + .then_some(class_start) +} + +/// Find the top-level statement boundary before a fluent receiver chain. +/// Newlines are ordinary PHP whitespace, while semicolons and braces inside +/// balanced calls/closures belong to the receiver expression itself. +fn receiver_chain_start(prefix: &str) -> usize { + let bytes = prefix.as_bytes(); + let mut boundary = 0usize; + let mut round = 0usize; + let mut square = 0usize; + let mut state = PhpLexState::Code; + let mut index = 0usize; + + while index < bytes.len() { + let byte = bytes[index]; + match state { + PhpLexState::Code => { + if bytes + .get(index..index.saturating_add(5)) + .is_some_and(|tag| tag.eq_ignore_ascii_case(b"") { + boundary = index + 2; + index += 2; + continue; + } + match byte { + b'\'' => state = PhpLexState::SingleQuoted, + b'"' => state = PhpLexState::DoubleQuoted, + b'/' if bytes.get(index + 1) == Some(&b'/') => { + state = PhpLexState::LineComment; + index += 1; + } + b'/' if bytes.get(index + 1) == Some(&b'*') => { + state = PhpLexState::BlockComment; + index += 1; + } + b'#' if bytes.get(index + 1) != Some(&b'[') => { + state = PhpLexState::LineComment; + } + b'(' => round += 1, + b')' if round > 0 => round -= 1, + b'[' => square += 1, + b']' if square > 0 => square -= 1, + b';' | b'{' | b'}' if round == 0 && square == 0 => boundary = index + 1, + _ => {} + } + } + PhpLexState::SingleQuoted => { + if byte == b'\'' && is_unescaped(bytes, index) { + state = PhpLexState::Code; + } + } + PhpLexState::DoubleQuoted => { + if byte == b'"' && is_unescaped(bytes, index) { + state = PhpLexState::Code; + } + } + PhpLexState::LineComment => { + if byte == b'\n' || byte == b'\r' { + state = PhpLexState::Code; + } + } + PhpLexState::BlockComment => { + if byte == b'*' && bytes.get(index + 1) == Some(&b'/') { + state = PhpLexState::Code; + index += 1; + } + } + } + index += 1; + } + + boundary +} + +fn middleware_completion_context(prefix: &str) -> Option<(LaravelStringKind, &str, usize)> { + let colon = prefix.find(':')?; + let alias = &prefix[..=colon]; + if alias != "auth:" { + return None; + } + let resource = crate::symbol_map::laravel_resources::middleware_resource(alias)?; + + let payload = &prefix[colon + 1..]; + 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()); + Some((LaravelStringKind::ConfigResource(resource), current, start)) +} + +fn is_gate_check_method(method: &str) -> bool { + match method.len() { + 3 => method.eq_ignore_ascii_case("any") || method.eq_ignore_ascii_case("has"), + 4 => method.eq_ignore_ascii_case("none"), + 5 => method.eq_ignore_ascii_case("check"), + 6 => method.eq_ignore_ascii_case("allows") || method.eq_ignore_ascii_case("denies"), + 7 => method.eq_ignore_ascii_case("inspect"), + _ => false, + } +} + +fn chain_starts_at_laravel_facade( + content: &str, + chain: &str, + chain_offset: usize, + resolved_names: Option<&crate::names::OwnedResolvedNames>, + facade: &'static str, + indexed_class_exists: Option<&dyn Fn(&str) -> bool>, +) -> bool { + let bytes = chain.as_bytes(); + let mut class_start = 0usize; + while class_start < bytes.len() && bytes[class_start].is_ascii_whitespace() { + class_start += 1; + } + let mut class_end = class_start; + while class_end < bytes.len() + && (bytes[class_end].is_ascii_alphanumeric() || matches!(bytes[class_end], b'_' | b'\\')) + { + class_end += 1; + } + if class_start == class_end { + return false; + } + let mut colons = class_end; + while colons < bytes.len() && bytes[colons].is_ascii_whitespace() { + colons += 1; + } + if bytes.get(colons..colons + 2) != Some(b"::") { + return false; + } + + let written = &chain[class_start..class_end]; + let reference = semantic_class_reference(written, chain_offset + class_start, resolved_names); + is_laravel_facade_reference(content, reference, facade, indexed_class_exists) + && is_method_chain_suffix(&chain[colons + 2..]) +} + +/// Whether everything after a top-level `Facade::` token remains on the same +/// receiver spine. Nested arguments may contain arbitrary PHP; at top level +/// only identifiers, calls, and static/instance chain operators are valid. +fn is_method_chain_suffix(suffix: &str) -> bool { + let bytes = suffix.as_bytes(); + let mut round = 0usize; + let mut square = 0usize; + let mut curly = 0usize; + let mut state = PhpLexState::Code; + let mut instance_links = 0usize; + let mut index = 0usize; + while index < bytes.len() { + let byte = bytes[index]; + match state { + PhpLexState::SingleQuoted => { + if byte == b'\'' && is_unescaped(bytes, index) { + state = PhpLexState::Code; + } + index += 1; + continue; + } + PhpLexState::DoubleQuoted => { + if byte == b'"' && is_unescaped(bytes, index) { + state = PhpLexState::Code; + } + index += 1; + continue; + } + PhpLexState::LineComment => { + if byte == b'\n' || byte == b'\r' { + state = PhpLexState::Code; + } + index += 1; + continue; + } + PhpLexState::BlockComment => { + if byte == b'*' && bytes.get(index + 1) == Some(&b'/') { + state = PhpLexState::Code; + index += 2; + } else { + index += 1; + } + continue; + } + PhpLexState::Code => {} + } + if byte == b'/' && bytes.get(index + 1) == Some(&b'/') { + state = PhpLexState::LineComment; + index += 2; + continue; + } + if byte == b'/' && bytes.get(index + 1) == Some(&b'*') { + state = PhpLexState::BlockComment; + index += 2; + continue; + } + if byte == b'#' && bytes.get(index + 1) != Some(&b'[') { + state = PhpLexState::LineComment; + index += 1; + continue; + } + if round > 0 || square > 0 || curly > 0 { + match byte { + b'\'' => state = PhpLexState::SingleQuoted, + b'"' => state = PhpLexState::DoubleQuoted, + b'(' => round += 1, + b')' if round > 0 => round -= 1, + b'[' => square += 1, + b']' if square > 0 => square -= 1, + b'{' => curly += 1, + b'}' if curly > 0 => curly -= 1, + _ => {} + } + index += 1; + continue; + } + + match byte { + b if b.is_ascii_alphanumeric() || matches!(b, b'_' | b' ' | b'\t' | b'\r' | b'\n') => { + index += 1; + } + b'(' => { + round = 1; + index += 1; + } + b'[' => { + square = 1; + index += 1; + } + b'{' => { + curly = 1; + index += 1; + } + b':' if bytes.get(index + 1) == Some(&b':') => index += 2, + b'-' if bytes.get(index + 1) == Some(&b'>') => { + instance_links += 1; + if instance_links > crate::symbol_map::laravel_resources::FACADE_CHAIN_DEPTH { + return false; + } + index += 2; + } + b'?' if bytes.get(index + 1) == Some(&b'-') && bytes.get(index + 2) == Some(&b'>') => { + instance_links += 1; + if instance_links > crate::symbol_map::laravel_resources::FACADE_CHAIN_DEPTH { + return false; + } + index += 3; + } + _ => return false, + } + } + matches!(state, PhpLexState::Code | PhpLexState::LineComment) + && round == 0 + && square == 0 + && curly == 0 +} + // ─── Detection ────────────────────────────────────────────────────────────── /// Detect if the cursor is inside a supported string argument of a Laravel /// helper or facade call. Returns the key kind and the prefix typed so far. +#[cfg(test)] fn detect_laravel_string_key_context( content: &str, position: Position, -) -> Option { +) -> Option> { + detect_laravel_string_key_context_inner(content, position, None, None, None) +} + +fn detect_laravel_string_key_context_inner<'a>( + content: &'a str, + position: Position, + resolved_names: Option<&'a crate::names::OwnedResolvedNames>, + indexed_function_exists: Option<&dyn Fn(&str) -> bool>, + indexed_class_exists: Option<&dyn Fn(&str) -> bool>, +) -> Option> { let cursor_offset = position_to_offset(content, position) as usize; let bytes = content.as_bytes(); @@ -336,7 +923,8 @@ fn detect_laravel_string_key_context( } } let quote_pos = quote_pos?; - let prefix = content[quote_pos + 1..cursor_offset].to_string(); + let mut prefix = &content[quote_pos + 1..cursor_offset]; + let mut content_start_offset = quote_pos + 1; // ── Locate the call argument that owns this string ───────────── let before_quote = content[..quote_pos].trim_end(); @@ -365,88 +953,43 @@ fn detect_laravel_string_key_context( 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')]. - // Everything between the nearest `#[` and this final class-name segment - // must itself be a class-name prefix. This recognizes FQN attributes - // without letting an unrelated attribute earlier in the file match. - let is_attribute = trimmed_before.rfind("#[").is_some_and(|start| { - trimmed_before[start + 2..] - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'\\') - }); - - // ── Map container attributes to config sub-prefixes ──────────── - let (kind, config_sub_prefix) = if is_attribute { + // Check for PHP attribute syntax: #[Config('key')], grouped attributes, + // and fully-qualified container attributes. + let current_attribute_class_start = (!is_static && !is_instance_method) + .then(|| attribute_class_start(before_paren, name_start)) + .flatten(); + let is_attribute = current_attribute_class_start.is_some(); + + let kind = if is_attribute { if argument.shape != StringArgumentShape::Scalar { return None; } - // 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 argument.named_argument.is_some() - && (!fqn_matches("Storage") - || argument - .named_argument - .is_some_and(|name| !name.eq_ignore_ascii_case("disk"))) + let attr_start = current_attribute_class_start?; + let written_class = &before_paren[attr_start..]; + let reference = semantic_class_reference(written_class, attr_start, resolved_names); + if resolve_known_class_reference( + content, + reference, + "Illuminate\\Container\\Attributes", + &["Config"], + false, + indexed_class_exists, + ) + .is_some() { - return None; - } - - 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.")) + argument + .named_argument + .is_none_or(|name| name == "key") + .then_some(LaravelStringKind::Config) + } else if let Some(trigger) = + config_resource_attribute_trigger(content, reference, indexed_class_exists) + { + argument + .named_argument + .is_none_or(|name| name == trigger.argument) + .then_some(LaravelStringKind::ConfigResource(trigger.kind)) } else { - (None, None) + None } } else if is_static { let before_colons = &trimmed_before[..trimmed_before.len() - 2].trim_end(); @@ -459,98 +1002,88 @@ fn detect_laravel_string_key_context( { cls_start -= 1; } - let class_name = &before_colons[cls_start..]; - let short = class_name.rsplit('\\').next().unwrap_or(class_name); - - let storage_argument = if is_storage_facade_reference(content, class_name) { - if func_name.eq_ignore_ascii_case("disk") { - Some(("name", false)) - } else if func_name.eq_ignore_ascii_case("fake") - || func_name.eq_ignore_ascii_case("persistentFake") - { - Some(("disk", false)) - } else if func_name.eq_ignore_ascii_case("forgetDisk") { - Some(("disk", true)) - } else { - None - } - } else { - None - }; - - if let Some((expected_name, accepts_array)) = storage_argument { + let written_class = &before_colons[cls_start..]; + let reference = semantic_class_reference(written_class, cls_start, resolved_names); + // Preserve the pre-existing legacy facade behavior. New resource + // triggers above resolve semantic aliases exactly; feeding an + // unrelated `Vendor\Config as Foo` target into the legacy short-name + // table would otherwise misclassify `Foo::get()` as Laravel Config. + let short = written_class.rsplit('\\').next().unwrap_or(written_class); + + if let Some(trigger) = + config_resource_static_trigger(content, reference, func_name, indexed_class_exists) + { if argument .named_argument - .is_some_and(|name| !name.eq_ignore_ascii_case(expected_name)) - || (argument.shape == StringArgumentShape::ArrayValue && !accepts_array) + .is_some_and(|name| name != trigger.argument) + || (argument.shape == StringArgumentShape::ArrayValue + && !trigger.shape.accepts_array()) + || (argument.shape == StringArgumentShape::Scalar + && !trigger.shape.accepts_scalar()) { return None; } - (Some(LaravelStringKind::Config), Some("filesystems.disks.")) + Some(LaravelStringKind::ConfigResource(trigger.kind)) + } else if func_name.eq_ignore_ascii_case("middleware") + && argument + .named_argument + .is_none_or(|name| name == "middleware") + && is_laravel_facade_reference(content, reference, "Route", indexed_class_exists) + { + let (middleware_kind, middleware_prefix, relative_start) = + middleware_completion_context(prefix)?; + prefix = middleware_prefix; + content_start_offset += relative_start; + Some(middleware_kind) } else if argument.named_argument.is_some() || argument.shape != StringArgumentShape::Scalar { - (None, None) + None } else { let fn_lower = func_name.to_ascii_lowercase(); match (short.to_ascii_lowercase().as_str(), fn_lower.as_str()) { ( "config", "get" | "set" | "has" | "boolean" | "array" | "collection" | "prepend" | "push", - ) => (Some(LaravelStringKind::Config), None), - ("view", "make" | "exists") => (Some(LaravelStringKind::View), None), - ("lang", "get" | "has" | "choice") => (Some(LaravelStringKind::Trans), None), - // Facade methods that accept config sub-keys: - ("auth", "guard") => (Some(LaravelStringKind::Config), Some("auth.guards.")), - ("db", "connection") => ( - Some(LaravelStringKind::Config), - Some("database.connections."), - ), - ("cache", "store") => (Some(LaravelStringKind::Config), Some("cache.stores.")), - ("log", "channel") => (Some(LaravelStringKind::Config), Some("logging.channels.")), + ) => Some(LaravelStringKind::Config), + ("view", "make" | "exists") => Some(LaravelStringKind::View), + ("lang", "get" | "has" | "choice") => Some(LaravelStringKind::Trans), // Artisan command names. - ("artisan", "call" | "queue") => (Some(LaravelStringKind::Command), None), - ("schedule", "command") => (Some(LaravelStringKind::Command), None), + ("artisan", "call" | "queue") => Some(LaravelStringKind::Command), + ("schedule", "command") => Some(LaravelStringKind::Command), // Eloquent morph aliases. - ("relation", "getmorphedmodel") => (Some(LaravelStringKind::MorphAlias), None), - ("model", "getactualclassnameformorph") => { - (Some(LaravelStringKind::MorphAlias), None) - } + ("relation", "getmorphedmodel") => Some(LaravelStringKind::MorphAlias), + ("model", "getactualclassnameformorph") => Some(LaravelStringKind::MorphAlias), // Authorization abilities checked through the Gate facade. ( "gate", "allows" | "denies" | "check" | "any" | "none" | "authorize" | "inspect" | "has" | "define", - ) => (Some(LaravelStringKind::GateAbility), None), - _ => (None, None), + ) => Some(LaravelStringKind::GateAbility), + _ => None, } } } else if is_instance_method { - if argument.named_argument.is_some() || argument.shape != StringArgumentShape::Scalar { - return None; - } + let receiver = trimmed_before + .trim_end_matches("?->") + .trim_end_matches("->") + .trim_end(); // 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") - }; + let receiver_is_this = 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 = { - let recv = trimmed_before - .trim_end_matches("?->") - .trim_end_matches("->") - .trim_end(); - let tail = recv + let tail = receiver .rsplit(|c: char| !(c.is_ascii_alphanumeric() || c == '_')) .next() .unwrap_or(""); - tail.to_ascii_lowercase().ends_with("user") || recv.ends_with("user()") + tail.get(tail.len().saturating_sub(4)..) + .is_some_and(|suffix| suffix.eq_ignore_ascii_case("user")) + || receiver + .get(receiver.len().saturating_sub(6)..) + .is_some_and(|suffix| suffix.eq_ignore_ascii_case("user()")) }; // A chain that starts at the `Gate` facade // (`Gate::forUser($user)->allows('…')`) or at a route registration @@ -558,61 +1091,130 @@ fn detect_laravel_string_key_context( // 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) + let is_middleware = func_name.eq_ignore_ascii_case("middleware"); + let is_can = func_name.eq_ignore_ascii_case("can") + || func_name.eq_ignore_ascii_case("cannot") + || func_name.eq_ignore_ascii_case("canAny"); + let needs_route_root = is_middleware || is_can; + let needs_gate_root = is_can + || func_name.eq_ignore_ascii_case("authorize") + || is_gate_check_method(func_name); + let (chain_start, chain_text) = if needs_route_root || needs_gate_root { + let start = receiver_chain_start(trimmed_before); + (start, &trimmed_before[start..]) + } else { + (0, "") + }; + let chain_starts_at_gate = needs_gate_root + && chain_starts_at_laravel_facade( + content, + chain_text, + chain_start, + resolved_names, + "Gate", + indexed_class_exists, + ); + let chain_starts_at_route = needs_route_root + && chain_starts_at_laravel_facade( + content, + chain_text, + chain_start, + resolved_names, + "Route", + indexed_class_exists, + ); + + if is_middleware + && chain_starts_at_route + && argument + .named_argument + .is_none_or(|name| name == "middleware") + { + let (middleware_kind, middleware_prefix, relative_start) = + middleware_completion_context(prefix)?; + prefix = middleware_prefix; + content_start_offset += relative_start; + Some(middleware_kind) + } else { + if argument.named_argument.is_some() || argument.shape != StringArgumentShape::Scalar { + return None; } - // `$user->can('update', $post)`. - "can" | "cannot" | "canany" - if receiver_is_user_like || chain_starts_at_route || chain_starts_at_gate => + if func_name.eq_ignore_ascii_case("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. + } else if receiver_is_this + && (func_name.eq_ignore_ascii_case("call") + || func_name.eq_ignore_ascii_case("callSilently")) { - Some(LaravelStringKind::GateAbility) - } - "allows" | "denies" | "check" | "any" | "none" | "inspect" | "has" - if chain_starts_at_gate => + Some(LaravelStringKind::Command) + // `$this->authorize('update', $post)` in a controller. + } else if (func_name.eq_ignore_ascii_case("authorize") + && (receiver_is_this || chain_starts_at_gate)) + // `$user->can('update', $post)`. + || (is_can + && (receiver_is_user_like || chain_starts_at_route || chain_starts_at_gate)) + || (is_gate_check_method(func_name) && chain_starts_at_gate) { Some(LaravelStringKind::GateAbility) + } else { + None } - _ => None, - }; - (k, None) + } } else { - if argument.named_argument.is_some() || argument.shape != StringArgumentShape::Scalar { + if argument.shape != StringArgumentShape::Scalar { return None; } - 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) + let mut callable_start = name_start; + while callable_start > 0 + && (bp_bytes[callable_start - 1].is_ascii_alphanumeric() + || matches!(bp_bytes[callable_start - 1], b'_' | b'\\')) + { + callable_start -= 1; + } + let written_function = &before_paren[callable_start..name_end]; + if let Some(trigger) = crate::symbol_map::laravel_resources::auth_helper_trigger( + content, + written_function, + callable_start as u32, + resolved_names, + indexed_function_exists, + ) { + argument + .named_argument + .is_none_or(|name| name == trigger.argument) + .then_some(LaravelStringKind::ConfigResource(trigger.kind)) + } else if argument.named_argument.is_some() { + None + } else { + match func_name.to_ascii_lowercase().as_str() { + "route" | "to_route" => Some(LaravelStringKind::Route), + "config" => Some(LaravelStringKind::Config), + "view" | "blade_view_directive" | "blade_each_directive" => { + Some(LaravelStringKind::View) + } + "__" | "trans" | "trans_choice" => Some(LaravelStringKind::Trans), + // The Blade preprocessor lowers `@can`/`@cannot`/`@canany` to + // this call, so completion inside the directive works too. + "blade_can_directive" => Some(LaravelStringKind::GateAbility), + _ => 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), } }; let kind = kind?; + let config_sub_prefix = match &kind { + LaravelStringKind::ConfigResource(resource) => { + Some(crate::symbol_map::laravel_resources::descriptor(*resource).config_prefix) + } + _ => None, + }; Some(LaravelStringKeyContext { kind, prefix, - content_start_offset: quote_pos + 1, + content_start_offset, config_sub_prefix, }) } @@ -657,7 +1259,8 @@ impl Backend { } } - for res in &self.laravel_provider_resources.read().config_files { + let provider_configs = self.laravel_provider_resources.read().config_files.clone(); + for res in &provider_configs { if let Ok(content) = std::fs::read_to_string(&res.path) { let decls = collect_laravel_config_declarations(&content, &res.namespace); for d in decls { @@ -825,12 +1428,12 @@ impl Backend { .collect() } - pub(crate) fn cached_config_keys(&self) -> Vec { + pub(crate) fn cached_config_keys(&self) -> std::sync::Arc> { 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(), + || std::sync::Arc::new(self.enumerate_all_config_keys()), ) } @@ -881,7 +1484,7 @@ impl Backend { /// key prefix. /// /// `file:///path/lang/en/messages.php` → `"messages"` -fn extract_lang_file_stem(uri: &str) -> Option { +pub(super) fn extract_lang_file_stem(uri: &str) -> Option { let file = uri.rsplit('/').next()?; let stem = file.strip_suffix(".php")?; if stem.is_empty() { @@ -1074,7 +1677,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, @@ -1099,10 +1704,53 @@ impl Backend { /// valid, which ordinary class completion already offers, and the set of /// keys is open besides — a list of them would read as the whole answer /// when it is not. - fn string_key_candidates(&self, kind: &LaravelStringKind) -> Vec { + fn string_key_candidates( + &self, + kind: &LaravelStringKind, + config_sub_prefix: Option<&str>, + typed_prefix: &str, + ) -> 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 = config_sub_prefix.expect("config resources always have a prefix"); + let keys = self.cached_config_keys(); + let first = keys.partition_point(|key| key.as_str() < prefix); + let mut names: Vec = keys[first..] + .iter() + .take_while(|key| key.starts_with(prefix)) + .filter_map(|key| { + let name = key.strip_prefix(prefix)?; + (!name.contains('.')).then(|| name.to_string()) + }) + .collect(); + if crate::symbol_map::laravel_resources::is_implicit_resource_name( + *resource, "null", + ) && let Err(index) = names.binary_search_by(|name| name.as_str().cmp("null")) + { + names.insert(index, "null".to_string()); + } + if *resource == LaravelConfigResource::DatabaseConnection + && typed_prefix.contains("::") + { + let mut variants = Vec::with_capacity( + names.len() + * crate::symbol_map::laravel_resources::DATABASE_ROLE_SUFFIXES.len(), + ); + for name in names { + for suffix in crate::symbol_map::laravel_resources::DATABASE_ROLE_SUFFIXES { + let mut variant = String::with_capacity(name.len() + suffix.len()); + variant.push_str(&name); + variant.push_str(suffix); + variants.push(variant); + } + } + variants + } else { + names + } + } LaravelStringKind::View => self.cached_view_names(), LaravelStringKind::Trans => self.cached_trans_keys(), LaravelStringKind::Command => self.laravel_commands.read().all_names(), @@ -1122,35 +1770,51 @@ impl Backend { /// /// Detects the cursor inside a supported string argument of `route()`, /// `config()`, `Storage::forgetDisk()`, etc. and offers matching 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)?; - - let mut candidates = self.string_key_candidates(&ctx.kind); - - // 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(); - } + self.try_laravel_string_key_completion_inner(content, position, None, None, None) + } + + /// Live-request form of Laravel string-key completion. Resolved names + /// distinguish imported facade aliases from namespace-local homonyms. + pub(crate) fn try_laravel_string_key_completion_in_file( + &self, + content: &str, + position: Position, + file_ctx: &FileContext, + ) -> Option { + let indexed_function_exists = |name: &str| self.has_indexed_function(name); + let indexed_class_exists = |name: &str| self.has_indexed_class(name); + self.try_laravel_string_key_completion_inner( + content, + position, + file_ctx.resolved_names.as_deref(), + Some(&indexed_function_exists), + Some(&indexed_class_exists), + ) + } + + fn try_laravel_string_key_completion_inner( + &self, + content: &str, + position: Position, + resolved_names: Option<&crate::names::OwnedResolvedNames>, + indexed_function_exists: Option<&dyn Fn(&str) -> bool>, + indexed_class_exists: Option<&dyn Fn(&str) -> bool>, + ) -> Option { + let ctx = detect_laravel_string_key_context_inner( + content, + position, + resolved_names, + indexed_function_exists, + indexed_class_exists, + )?; + + let candidates = self.string_key_candidates(&ctx.kind, ctx.config_sub_prefix, ctx.prefix); // Build the TextEdit range: from the start of the string content // (right after the opening quote) to the current cursor position. @@ -1162,30 +1826,26 @@ impl Backend { end: position, }; - let prefix_lower = ctx.prefix.to_lowercase(); + let prefix = ctx.prefix.as_bytes(); + let item_kind = string_key_item_kind(&ctx.kind); 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(); @@ -1204,7 +1864,7 @@ mod tests { use super::*; use tower_lsp::lsp_types::Position; - fn detect_at_end(content: &str, value: &str) -> Option { + fn detect_at_end<'a>(content: &'a str, value: &str) -> Option> { let cursor = content.rfind(value)? + value.len(); detect_laravel_string_key_context( content, @@ -1235,18 +1895,70 @@ mod tests { LaravelStringKind::ContainerBinding, ] { assert!( - backend.string_key_candidates(&kind).is_empty(), + backend.string_key_candidates(&kind, None, "").is_empty(), "{kind:?} should offer no candidates" ); } } + #[test] + fn configured_resource_candidates_include_database_roles_and_null_drivers() { + let backend = crate::test_fixtures::make_backend(); + backend.laravel_string_key_cache.write().config_keys = Some(std::sync::Arc::new(vec![ + "cache.stores.redis".to_string(), + "database.connections.mysql".to_string(), + "queue.connections.sync".to_string(), + ])); + + assert_eq!( + backend.string_key_candidates(&LaravelStringKind::Config, None, ""), + [ + "cache.stores.redis", + "database.connections.mysql", + "queue.connections.sync", + ] + ); + assert_eq!( + backend.string_key_candidates( + &LaravelStringKind::ConfigResource(LaravelConfigResource::DatabaseConnection,), + Some("database.connections."), + "mysql::", + ), + ["mysql::read", "mysql::write", "mysql::direct"] + ); + for (resource, config_prefix, expected) in [ + ( + LaravelConfigResource::CacheStore, + "cache.stores.", + vec!["null", "redis"], + ), + ( + LaravelConfigResource::QueueConnection, + "queue.connections.", + vec!["null", "sync"], + ), + ] { + assert_eq!( + backend.string_key_candidates( + &LaravelStringKind::ConfigResource(resource), + Some(config_prefix), + "", + ), + expected, + ); + } + } + /// Whatever a key names decides the icon beside it. #[test] fn a_string_key_is_iconed_by_what_it_names() { 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), ( @@ -1279,6 +1991,14 @@ mod tests { assert_eq!(ctx.prefix, "user."); } + #[test] + fn detects_instance_route_call() { + let content = "route('user.');\n"; + let ctx = detect_at_end(content, "user.").expect("should detect ->route() context"); + assert!(matches!(ctx.kind, LaravelStringKind::Route)); + assert_eq!(ctx.prefix, "user."); + } + #[test] fn detects_to_route_call() { let content = "allows('upd'", "Gate::forUser($user)->authorize('upd'", "Gate::forUser($user)->has('upd'", + "Gate::forUser($user)->inspect('upd'", // A controller's own helper. "$this->authorize('upd'", // The user the check is about. @@ -1460,7 +2181,7 @@ mod tests { for (expression, expected_kind, expected_prefix) in [ ( "DB::connection('primary')", - LaravelStringKind::Config, + LaravelStringKind::ConfigResource(LaravelConfigResource::DatabaseConnection), Some("database.connections."), ), ( @@ -1497,7 +2218,10 @@ mod tests { let content = storage_source(expression); let ctx = detect_at_end(&content, "arch") .unwrap_or_else(|| panic!("should detect `{expression}` as a disk context")); - assert!(matches!(ctx.kind, LaravelStringKind::Config)); + assert!(matches!( + ctx.kind, + LaravelStringKind::ConfigResource(LaravelConfigResource::StorageDisk) + )); assert_eq!(ctx.prefix, "arch", "prefix for `{expression}`"); assert_eq!( ctx.config_sub_prefix, @@ -1513,18 +2237,173 @@ mod tests { let content = format!(" null)->", + " Route \n ::get('/', fn () => null)->", + ] { + let content = format!("", + "Route::class && factory()->", + "Acme\\Route::get()->", + "Factory::get()->Route::get()->", + "::get()->", + "Route->get()->", + ] { + let content = format!("name('x')\n ->", + "", + " null) // keep chaining\n ->", + "", + ] { + let start = receiver_chain_start(prefix); + assert_eq!(prefix[start..].trim_start().get(..5), Some("Route")); + } + let prefix = ""; + let start = receiver_chain_start(prefix); + assert_eq!(prefix[start..].trim_start(), "unrelated()->"); + + let prefix = ""; + let start = receiver_chain_start(prefix); + assert_eq!(prefix[start..].trim_start(), "Route::get('/')->"); + } + + #[test] + fn receiver_spine_suffix_accepts_nested_and_legacy_chain_shapes() { + for suffix in [ + "get([fn () => ['value']])[0]::next()?->tail", + "get([\"close ) ] }\"])->tail", + "get(){0}->tail", + "get(function () { return ['}']; })->tail", + "get('/', /* ) ] } */ fn () => null) // continue\n ->tail", + "get() # continue\n ->tail", + ] { + assert!(is_method_chain_suffix(suffix), "suffix: {suffix}"); + } + for suffix in [ + "get() + unrelated()", + "get('unterminated)", + "get()->'invalid'", + "get()->\"invalid\"", + "get([missing)", + "get()->a()->b()->c()->d()->target", + "get()?->a()?->b()?->c()?->d()?->target", + ] { + assert!(!is_method_chain_suffix(suffix), "suffix: {suffix}"); + } + } + #[test] fn storage_facade_resolution_accepts_imports_and_rejects_homonyms() { for content in [ " new class {}), 'it\'s', 'archive' )); assert!(!string_literal_is_array_key("'key\n", "'key".len(), b'\'')); assert!(!string_literal_is_array_key("'key", "'key".len(), b'\'')); + assert!(string_literal_is_array_key( + "'key\npart'\n => 'archive'", + "'key".len(), + b'\'', + )); + assert!(string_literal_is_array_key( + "'key' /* why */\n // still a key\n # also trivia\n => 'archive'", + "'key".len(), + b'\'', + )); + assert!(!string_literal_is_array_key( + "'value' /* unterminated", + "'value".len(), + b'\'', + )); } #[test] diff --git a/src/diagnostics/mod.rs b/src/diagnostics/mod.rs index cc4157290..3d55f2a10 100644 --- a/src/diagnostics/mod.rs +++ b/src/diagnostics/mod.rs @@ -274,6 +274,7 @@ pub(crate) type SlowDiagnosticObserver<'a> = enum CheckedStringKind { Route, Config, + ConfigResource(crate::symbol_map::LaravelConfigResource), View, Trans, Command, @@ -617,82 +618,86 @@ impl Backend { // that write is attempted would deadlock. let mut has_route = false; let mut has_config = false; + let mut has_config_resource = false; let mut has_view = false; let mut has_trans = false; 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 Some(symbol_map) = self.symbol_maps.read().get(uri).cloned() else { + return; }; + let extra = self.typed_receiver_view_spans_for(uri, &symbol_map); + let key_spans: Vec<(CheckedStringKind, &str, u32, u32)> = 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::ConfigResource(resource) => { + has_config = true; + has_config_resource = 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 + } + // 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.as_str(), span.start, span.end)) + } else { + None + } + }) + .collect(); if !has_route && !has_config @@ -716,11 +721,8 @@ impl Backend { } else { HashSet::new() }; - let config_keys: HashSet = if has_config { - self.cached_config_keys().into_iter().collect() - } else { - HashSet::new() - }; + let cached_config_keys = has_config.then(|| self.cached_config_keys()); + let config_keys = cached_config_keys.as_deref().map_or(&[][..], Vec::as_slice); // 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 @@ -729,6 +731,14 @@ impl Backend { .iter() .map(|key| key.split('.').next().unwrap_or(key.as_str())) .collect(); + let config_resource_mask = if has_config_resource { + config_keys.iter().fold(0, |mask, key| { + crate::symbol_map::laravel_resources::resource_from_config_key(key) + .map_or(mask, |(resource, _)| mask | resource.bit()) + }) + } else { + 0 + }; let view_keys: HashSet = if has_view { self.cached_view_names().into_iter().collect() } else { @@ -791,7 +801,7 @@ impl Backend { HashSet::new() }; - for (kind, key, start, end) in &key_spans { + for &(kind, key, start, end) in &key_spans { 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 @@ -800,12 +810,12 @@ impl Backend { if !gate_ability_space_is_open && !gate_abilities.is_empty() && let Some(message) = - self.gate_ability_problem(uri, content, key, *start, &gate_abilities) + self.gate_ability_problem(uri, content, key, start, &gate_abilities) && let Some(range) = self.offset_range_to_lsp_range( uri, content, - *start as usize, - *end as usize, + start as usize, + end as usize, ) { out.push(helpers::make_diagnostic( @@ -834,17 +844,46 @@ 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())) { + if !config_roots.contains(key.split('.').next().unwrap_or(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 candidate = config_keys + .get(config_keys.partition_point(|candidate| candidate.as_str() < key)); + let valid = candidate.is_some_and(|candidate| { + candidate == key + || candidate + .strip_prefix(key) + .is_some_and(|suffix| suffix.starts_with('.')) + }); (valid, "config key", "invalid_laravel_config") } + CheckedStringKind::ConfigResource(resource) => { + let descriptor = crate::symbol_map::laravel_resources::descriptor(resource); + if crate::symbol_map::laravel_resources::is_implicit_resource_name( + resource, key, + ) { + continue; + } + // An undiscovered subtree is an unknown vocabulary, not + // proof that every runtime-provided name is invalid. + if config_resource_mask & resource.bit() == 0 { + continue; + } + let prefix = descriptor.config_prefix; + let first = + config_keys.partition_point(|candidate| candidate.as_str() < prefix); + let valid = config_keys[first..] + .iter() + .take_while(|candidate| candidate.starts_with(prefix)) + .any(|candidate| { + crate::symbol_map::laravel_resources::matches_config_key( + resource, key, candidate, + ) + }); + (valid, descriptor.label, descriptor.diagnostic_code) + } CheckedStringKind::View => { (view_keys.contains(key), "view", "invalid_laravel_view") } @@ -857,9 +896,11 @@ impl Backend { continue; } let valid = trans_keys.contains(key) - || trans_keys - .iter() - .any(|k| k.starts_with(&format!("{}.", key))); + || trans_keys.iter().any(|candidate| { + candidate + .strip_prefix(key) + .is_some_and(|suffix| suffix.starts_with('.')) + }); (valid, "translation key", "invalid_laravel_trans") } CheckedStringKind::Command => { @@ -890,7 +931,7 @@ impl Backend { }; if !valid && let Some(range) = - self.offset_range_to_lsp_range(uri, content, *start as usize, *end as usize) + self.offset_range_to_lsp_range(uri, content, start as usize, end as usize) { out.push(helpers::make_diagnostic( range, @@ -1796,6 +1837,20 @@ mod tests { ); } + #[test] + fn laravel_string_key_diagnostics_ignore_an_unindexed_uri() { + let backend = crate::Backend::new_test(); + let mut out = Vec::new(); + + 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, diff --git a/src/indexing/scan.rs b/src/indexing/scan.rs index 6bc33c824..532264c46 100644 --- a/src/indexing/scan.rs +++ b/src/indexing/scan.rs @@ -190,6 +190,11 @@ impl Backend { self.clear_class_not_found_cache(); self.resolved_class_cache.write().clear(); self.member_completion_cache.lock().clear(); + + // Composer changes can introduce or remove functions/classes that + // shadow Laravel's runtime helper and facade aliases. Only maps with + // such candidates are revisited. + self.refresh_all_published_laravel_candidates(); } /// Scan autoload files for a single project root and populate the diff --git a/src/lib.rs b/src/lib.rs index f7d15a1eb..464f69785 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -336,7 +336,10 @@ 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>, + /// Config keys are shared by completion and the parallel diagnostic pass. + /// Keeping the immutable sorted list behind an `Arc` avoids cloning every + /// key on each resource-name keystroke or file diagnostic. + pub config_keys: Option>>, pub view_names: Option>, pub trans_keys: Option>, /// Every translation key mapped to whether it names a group (nested @@ -1737,6 +1740,11 @@ impl Backend { } } } + + // The refreshed discovery indexes may add or remove a namespace-local + // `auth()` or a real global class that shadows a Laravel facade alias. + // Re-evaluate only maps that recorded one of those dormant candidates. + self.refresh_all_published_laravel_candidates(); } /// Create a shallow clone of this `Backend` that shares every diff --git a/src/mem_audit.rs b/src/mem_audit.rs index 5c1c4c10e..84be0bbfc 100644 --- a/src/mem_audit.rs +++ b/src/mem_audit.rs @@ -1286,6 +1286,13 @@ pub(crate) fn report(backend: &Backend, runner_content_bytes: usize) { for sp in &sm.spans { sym.add(sp.kind.audit_heap()); } + sym.add( + sm.conditional_laravel_spans.capacity() + * size_of::(), + ); + for candidate in &sm.conditional_laravel_spans { + sym.add(candidate.audit_heap()); + } sym += map_buckets::>(sm.member_access_indices.capacity()); member_idx += map_buckets::>(sm.member_access_indices.capacity()); for v in sm.member_access_indices.values() { @@ -1436,10 +1443,10 @@ 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() - { + if let Some(keys) = &c.config_keys { + laravel_keys += vs(keys); + } + for v in [&c.view_names, &c.trans_keys].into_iter().flatten() { laravel_keys += vs(v); } if let Some(routes) = &c.routes { diff --git a/src/parser/ast_update.rs b/src/parser/ast_update.rs index 3c3ae26fe..3af0f05d7 100644 --- a/src/parser/ast_update.rs +++ b/src/parser/ast_update.rs @@ -7,7 +7,7 @@ /// helpers (`resolve_parent_class_names`, `resolve_name`) used to convert /// short class names to fully-qualified names. use std::cell::RefCell; -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::sync::Arc; use crate::ParseErrorEntry; @@ -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::{LaravelStringDependency, SymbolMap, extract_symbol_map_for_index}; use crate::types::{ ClassInfo, DefineInfo, DocblockMembers, FunctionInfo, MethodInfo, NamespaceSpan, TypeAliasDef, }; @@ -178,6 +178,66 @@ fn withdraw_function( } impl Backend { + /// Clone and reconcile only published maps whose dormant Laravel spans + /// depend on one of `affected`. Passing `None` is reserved for rare + /// workspace purges where several declaration indexes changed together. + fn refreshed_laravel_candidate_maps( + &self, + affected: Option<&HashSet>, + excluded_uris: &HashSet<&str>, + ) -> Vec<(String, Arc)> { + let candidates: Vec<(String, Arc)> = self + .symbol_maps + .read() + .iter() + .filter(|(uri, _)| !excluded_uris.contains(uri.as_str())) + .filter(|(_, map)| map.has_conditional_laravel_dependency(affected)) + .map(|(uri, map)| (uri.clone(), Arc::clone(map))) + .collect(); + let mut dependency_presence = HashMap::new(); + candidates + .into_iter() + .filter_map(|(uri, map)| { + let mut dependency_exists = |dependency| { + *dependency_presence + .entry(dependency) + .or_insert_with(|| match dependency { + LaravelStringDependency::Function(name) => { + self.has_indexed_function(name.as_str()) + } + LaravelStringDependency::Class(name) => { + self.has_indexed_class(name.as_str()) + } + }) + }; + if !map.conditional_laravel_spans_need_refresh(affected, &mut dependency_exists) { + return None; + } + let mut refreshed = map.as_ref().clone(); + let changed = + refreshed.refresh_conditional_laravel_spans(affected, dependency_exists); + debug_assert!(changed); + Some((uri, Arc::new(refreshed))) + }) + .collect() + } + + /// Publish refreshed conditional maps and keep their reference-index + /// entries in the same generation. Used after discovery-index rebuilds; + /// normal AST batches fold refreshed maps into their existing reindex. + pub(crate) fn refresh_all_published_laravel_candidates(&self) -> bool { + let refreshed = self.refreshed_laravel_candidate_maps(None, &HashSet::new()); + if refreshed.is_empty() { + return false; + } + self.reindex_references_for_symbol_maps_batch(refreshed.clone()); + let mut symbol_maps = self.symbol_maps.write(); + for (uri, map) in refreshed { + symbol_maps.insert(uri, map); + } + true + } + /// Drop every function declaration contributed by `uris`, handing each /// name to the next-lowest file that still declares it. /// @@ -821,7 +881,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_for_index( + program, + content, + &owned_resolved, + )); // For files without any explicit namespace blocks, synthesize a // single span covering the entire file with the detected namespace @@ -1303,9 +1367,9 @@ impl Backend { ); } - let changed = any_signature_changed || any_function_changed; + let structural_changed = any_signature_changed || any_function_changed; - if changed { + if structural_changed { self.member_completion_cache.lock().clear(); // A receiver's type is settled against the classes of the whole // workspace, so a signature change anywhere can turn a call that @@ -1317,14 +1381,74 @@ impl Backend { } } - let reference_items: Vec<(String, Arc)> = prepared + // The batch declarations are now visible. Activate its prospective + // Laravel spans against that final state, not the incomplete index + // that happened to exist while parallel workers parsed the files. + { + let mut dependency_presence = HashMap::new(); + for update in &mut prepared { + Arc::make_mut(&mut update.symbol_map).refresh_conditional_laravel_spans( + None, + |dependency| { + *dependency_presence + .entry(dependency) + .or_insert_with(|| match dependency { + LaravelStringDependency::Function(name) => { + self.has_indexed_function(name.as_str()) + } + LaravelStringDependency::Class(name) => { + self.has_indexed_class(name.as_str()) + } + }) + }, + ); + } + } + + // Only declaration names that can gate a candidate trigger a scan of + // the published maps. Function signatures and ordinary class edits + // therefore retain the existing O(batch) publication cost. + let mut affected_laravel_dependencies = HashSet::new(); + for fqn in all_old_fqns.iter().chain(&all_new_fqns) { + if let Some(dependency) = LaravelStringDependency::root_facade(fqn) { + affected_laravel_dependencies.insert(dependency); + } + } + for update in &prepared { + for fqn in update + .old_function_fqns + .iter() + .chain(&update.new_function_fqns) + { + if let Some(dependency) = LaravelStringDependency::namespaced_auth(fqn) { + affected_laravel_dependencies.insert(dependency); + } + } + } + let prepared_uris: HashSet<&str> = + prepared.iter().map(|update| update.uri.as_str()).collect(); + let refreshed_existing = if affected_laravel_dependencies.is_empty() { + Vec::new() + } else { + self.refreshed_laravel_candidate_maps( + Some(&affected_laravel_dependencies), + &prepared_uris, + ) + }; + let changed = structural_changed || !refreshed_existing.is_empty(); + + let mut reference_items: Vec<(String, Arc)> = prepared .iter() .map(|update| (update.uri.clone(), Arc::clone(&update.symbol_map))) .collect(); + reference_items.extend(refreshed_existing.iter().cloned()); self.reindex_references_for_symbol_maps_batch(reference_items); { let mut symbol_maps = self.symbol_maps.write(); + for (uri, map) in refreshed_existing { + symbol_maps.insert(uri, map); + } for update in prepared { symbol_maps.insert(update.uri, update.symbol_map); } @@ -1905,6 +2029,266 @@ mod tests { use super::*; use crate::Backend; + fn has_config_resource_span( + backend: &Backend, + uri: &str, + key: &str, + resource: crate::symbol_map::LaravelConfigResource, + ) -> bool { + backend.symbol_map_for(uri).is_some_and(|map| { + map.spans.iter().any(|span| { + matches!( + &span.kind, + crate::symbol_map::SymbolKind::LaravelStringKey { + kind: crate::symbol_map::LaravelStringKind::ConfigResource(found), + key: found_key, + .. + } if *found == resource && found_key == key + ) + }) + }) + } + + fn reference_index_has_config_resource( + backend: &Backend, + uri: &str, + key: &str, + resource: crate::symbol_map::LaravelConfigResource, + ) -> bool { + let index_key = crate::reference_index::laravel_string_reference_key( + crate::symbol_map::LaravelStringKind::ConfigResource(resource), + key, + ); + backend + .reference_index + .read() + .get(&index_key) + .is_some_and(|entries| entries.keys().any(|entry_uri| entry_uri.as_ref() == uri)) + } + + #[test] + fn namespaced_auth_candidates_follow_cross_file_function_lifecycle() { + use crate::symbol_map::LaravelConfigResource::AuthGuard; + + let backend = Backend::new_test(); + let consumer_uri = "file:///app/Consumer.php"; + let helper_uri = "file:///app/helpers.php"; + backend.update_ast(consumer_uri, "where('id')->middleware('auth:route');\n", + ); + assert!(has_config_resource_span( + &backend, + consumer_uri, + "redis", + CacheStore + )); + assert!(has_config_resource_span( + &backend, + consumer_uri, + "local", + StorageDisk + )); + assert!(has_config_resource_span( + &backend, + consumer_uri, + "route", + crate::symbol_map::LaravelConfigResource::AuthGuard + )); + + backend.update_ast( + class_uri, + "middleware('auth:web');\n $this->middleware('can:update,Post');\n }\n}\nRoute::get('/')->middleware('auth:admin');\nRoute::get('/')->a()->b()->c()->d()->middleware('auth:too-deep');\n", + ); + assert!(!has_config_resource_span(&backend, uri, "web", AuthGuard)); + assert!(has_config_resource_span(&backend, uri, "admin", AuthGuard)); + assert!(!has_config_resource_span( + &backend, uri, "too-deep", AuthGuard + )); + assert!(backend.symbol_map_for(uri).is_some_and(|map| { + map.spans.iter().any(|span| { + matches!( + &span.kind, + crate::symbol_map::SymbolKind::LaravelStringKey { + kind: crate::symbol_map::LaravelStringKind::GateAbility, + key, + .. + } if key == "update" + ) + }) + })); + } + /// Changing a function's parameter type should cause `update_ast` to /// return `true` (signature changed), triggering cross-file /// diagnostic invalidation. This is the exact scenario from diff --git a/src/reference_index.rs b/src/reference_index.rs index 387806380..68af1c3ca 100644 --- a/src/reference_index.rs +++ b/src/reference_index.rs @@ -33,6 +33,33 @@ pub(crate) enum ReferenceIndexKey { }, } +/// Build the one index identity shared by a Laravel string-key usage and its +/// config-backed aliases. +/// +/// Resource spans deliberately retain their short source text (for example, +/// `redis`), while the reference index stores the canonical config address +/// (`cache.stores.redis`). This keeps one entry per semantic identity without +/// changing the exact source range consumers return. +pub(crate) fn laravel_string_reference_key( + kind: LaravelStringKind, + key: &str, +) -> ReferenceIndexKey { + match kind { + LaravelStringKind::ConfigResource(resource) + if !crate::symbol_map::laravel_resources::is_implicit_resource_name(resource, key) => + { + ReferenceIndexKey::LaravelString { + kind: LaravelStringKind::Config, + key: crate::symbol_map::laravel_resources::config_key(resource, key), + } + } + _ => ReferenceIndexKey::LaravelString { + kind, + key: key.to_string(), + }, + } +} + impl ReferenceIndexKey { /// Memory-audit tooling: heap bytes held by the key's name string. #[cfg(feature = "mem-audit")] @@ -411,13 +438,7 @@ impl Backend { )] } SymbolKind::LaravelStringKey { kind, key, .. } => { - vec![( - ReferenceIndexKey::LaravelString { - kind: kind.clone(), - key: key.to_string(), - }, - true, - )] + vec![(laravel_string_reference_key(*kind, key), true)] } _ => Vec::new(), } @@ -587,7 +608,7 @@ mod tests { use super::*; use crate::Backend; - use crate::symbol_map::{SymbolMap, SymbolSpan}; + use crate::symbol_map::{LaravelConfigResource, SymbolMap, SymbolSpan}; #[test] fn candidate_lookup_is_disabled_until_workspace_is_indexed() { @@ -662,6 +683,89 @@ mod tests { ); } + #[test] + fn config_resources_and_generic_config_keys_share_one_canonical_candidate_key() { + let backend = Backend::new_test(); + backend.workspace_indexed.store(true, Ordering::Release); + let resource_uri = "file:///project/src/CacheConsumer.php"; + let config_uri = "file:///project/src/ConfigConsumer.php"; + let queue_uri = "file:///project/src/QueueConsumer.php"; + + backend.reindex_references_for_symbol_maps_batch(vec![ + ( + resource_uri.to_string(), + laravel_string_map( + LaravelStringKind::ConfigResource(LaravelConfigResource::CacheStore), + "redis", + ), + ), + ( + config_uri.to_string(), + laravel_string_map(LaravelStringKind::Config, "cache.stores.redis"), + ), + ( + queue_uri.to_string(), + laravel_string_map( + LaravelStringKind::ConfigResource(LaravelConfigResource::QueueConnection), + "redis", + ), + ), + ]); + + let resource_key = laravel_string_reference_key( + LaravelStringKind::ConfigResource(LaravelConfigResource::CacheStore), + "redis", + ); + let config_key = + laravel_string_reference_key(LaravelStringKind::Config, "cache.stores.redis"); + assert_eq!(resource_key, config_key); + assert_candidate_contains(&backend, config_key.clone(), resource_uri); + assert_candidate_contains(&backend, config_key, config_uri); + let queue_key = laravel_string_reference_key( + LaravelStringKind::ConfigResource(LaravelConfigResource::QueueConnection), + "redis", + ); + assert_candidate_contains(&backend, queue_key.clone(), queue_uri); + assert_candidate_not_contains(&backend, queue_key, resource_uri); + + let index = backend.reference_index.read(); + assert_eq!(index.by_key.len(), 2); + assert_eq!(index.uri_keys.get(resource_uri).map(Vec::len), Some(1)); + assert_eq!(index.uri_keys.get(config_uri).map(Vec::len), Some(1)); + assert_eq!(index.uri_keys.get(queue_uri).map(Vec::len), Some(1)); + } + + #[test] + fn non_config_laravel_string_keys_keep_their_original_identity() { + assert_eq!( + laravel_string_reference_key(LaravelStringKind::View, "dashboard"), + ReferenceIndexKey::LaravelString { + kind: LaravelStringKind::View, + key: "dashboard".to_string(), + } + ); + assert_eq!( + laravel_string_reference_key( + LaravelStringKind::ConfigResource(LaravelConfigResource::CacheStore), + "null", + ), + ReferenceIndexKey::LaravelString { + kind: LaravelStringKind::ConfigResource(LaravelConfigResource::CacheStore), + key: "null".to_string(), + } + ); + assert_eq!( + laravel_string_reference_key( + LaravelStringKind::ConfigResource(LaravelConfigResource::DatabaseConnection), + "mysql::read", + ), + ReferenceIndexKey::LaravelString { + kind: LaravelStringKind::Config, + key: "database.connections.mysql".to_string(), + } + ); + } + #[test] fn reference_index_evicts_candidates_when_file_maps_clear() { let backend = Backend::new_test(); @@ -892,4 +996,20 @@ mod tests { ..SymbolMap::default() }) } + + fn laravel_string_map(kind: LaravelStringKind, key: &str) -> Arc { + Arc::new(SymbolMap { + spans: vec![SymbolSpan { + start: 0, + end: key.len() as u32, + kind: SymbolKind::LaravelStringKey { + kind, + key: key.to_string(), + is_write: false, + is_optional: false, + }, + }], + ..SymbolMap::default() + }) + } } diff --git a/src/references/dispatch.rs b/src/references/dispatch.rs index 5f9f7f6f0..8d33175ef 100644 --- a/src/references/dispatch.rs +++ b/src/references/dispatch.rs @@ -346,18 +346,12 @@ impl Backend { if !self.resolved_class_cache.read().is_laravel() { return Vec::new(); } - let snapshot = if include_declaration - && matches!(kind, crate::symbol_map::LaravelStringKind::Config) - { - self.user_file_symbol_maps() - } else { - self.user_file_symbol_maps_for_reference_keys(&[ - ReferenceIndexKey::LaravelString { - kind: kind.clone(), - key: key.to_string(), - }, - ]) - }; + // Config resources and their generic config spelling share a + // canonical index identity. Declaration lookup is independent + // of this usage snapshot, so every request stays narrow. + let reference_key = + crate::reference_index::laravel_string_reference_key(*kind, key); + let snapshot = self.user_file_symbol_maps_for_reference_keys(&[reference_key]); laravel::find_laravel_string_key_references( self, kind, diff --git a/src/rename/prepare.rs b/src/rename/prepare.rs index cac280afa..29804b8dd 100644 --- a/src/rename/prepare.rs +++ b/src/rename/prepare.rs @@ -175,6 +175,14 @@ impl Backend { let class_rename_fqn = self.resolve_class_rename_fqn(&span.kind, uri, span.start); + // Direct resource APIs spell only the child name while generic config + // calls spell its full dotted key. Until Laravel string-key rename is + // implemented, applying one replacement to their shared reference set + // would corrupt one representation. + if is_config_resource_identity(&span.kind) { + return None; + } + // Find all references (including the declaration). let locations = self.find_references_for_rename(uri, content, position, true)?; @@ -426,3 +434,52 @@ impl Backend { } } } + +fn is_config_resource_identity(kind: &SymbolKind) -> bool { + let SymbolKind::LaravelStringKey { kind, key, .. } = kind else { + return false; + }; + match kind { + crate::symbol_map::LaravelStringKind::ConfigResource(_) => true, + crate::symbol_map::LaravelStringKind::Config => { + crate::symbol_map::laravel_resources::resource_from_config_key(key).is_some() + } + _ => false, + } +} + +#[cfg(test)] +mod config_resource_identity_tests { + use super::*; + use crate::symbol_map::{LaravelConfigResource, LaravelStringKind}; + + fn string_kind(kind: LaravelStringKind, key: &str) -> SymbolKind { + SymbolKind::LaravelStringKey { + kind, + key: key.to_string(), + is_write: false, + is_optional: false, + } + } + + #[test] + fn only_config_resource_identities_are_held_for_laravel_string_rename() { + assert!(is_config_resource_identity(&string_kind( + LaravelStringKind::ConfigResource(LaravelConfigResource::CacheStore), + "redis", + ))); + assert!(is_config_resource_identity(&string_kind( + LaravelStringKind::Config, + "cache.stores.redis", + ))); + assert!(!is_config_resource_identity(&string_kind( + LaravelStringKind::Config, + "app.name", + ))); + assert!(!is_config_resource_identity(&string_kind( + LaravelStringKind::View, + "dashboard", + ))); + assert!(!is_config_resource_identity(&SymbolKind::Keyword)); + } +} diff --git a/src/resolution.rs b/src/resolution.rs index bf51e11b2..8459908de 100644 --- a/src/resolution.rs +++ b/src/resolution.rs @@ -1254,6 +1254,30 @@ impl Backend { self.resolve_function_name_at(name, None, 0, file_use_map, file_namespace) } + /// Whether discovery or full parsing has seen this exact function FQN. + /// + /// This is the allocation-free membership form used when a caller only + /// needs to know whether PHP's namespace-local function shadows a global + /// fallback. It deliberately does not trigger a lazy parse. + pub(crate) fn has_indexed_function(&self, fqn: &str) -> bool { + self.symbols.global_functions.read().get(fqn).is_some() + || self + .symbols + .autoload_function_index + .read() + .get(fqn) + .is_some() + } + + /// Whether discovery or full parsing has seen this exact class FQN. + /// + /// Laravel's optional root facade aliases are used only when no real + /// global class owns the same name. This membership check does not lazy + /// load the class, keeping completion on that fallback allocation-free. + pub(crate) fn has_indexed_class(&self, fqn: &str) -> bool { + self.symbols.fqn_uri_index.read().get(fqn).is_some() + } + /// Resolve a function name, consulting mago-names' per-offset /// resolution for the authoritative fully-qualified name. /// diff --git a/src/symbol_map/extraction/class_like.rs b/src/symbol_map/extraction/class_like.rs index eaec779ea..fc7de261f 100644 --- a/src/symbol_map/extraction/class_like.rs +++ b/src/symbol_map/extraction/class_like.rs @@ -312,23 +312,31 @@ 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. + 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( - class_name, + semantic_class_name, + ctx.resolved_names.is_none(), &mut ctx.has_laravel_container_attrs, ctx.content, ) { match attribute { - LaravelContainerAttribute::Config => { - try_emit_laravel_string_span_partial( - crate::symbol_map::LaravelStringKind::Config, + 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::StorageDisk => { - try_emit_laravel_storage_disk_span_partial( + LaravelContainerAttribute::Config => { + try_emit_laravel_string_span_partial_for_parameter( + crate::symbol_map::LaravelStringKind::Config, arg_list, + "key", ctx.content, &mut ctx.spans, ); diff --git a/src/symbol_map/extraction/expressions/calls.rs b/src/symbol_map/extraction/expressions/calls.rs index 9b9ce9350..7c33371d2 100644 --- a/src/symbol_map/extraction/expressions/calls.rs +++ b/src/symbol_map/extraction/expressions/calls.rs @@ -179,6 +179,30 @@ fn extract_call<'a>( &mut ctx.spans, ); } + if let Some(trigger) = crate::symbol_map::laravel_resources::auth_helper_trigger( + ctx.content, + name_clean, + ident.span().start.offset, + ctx.resolved_names, + None, + ) { + let span_start = ctx.spans.len(); + 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, + ); + if let Some(dependency) = ctx + .resolved_name_at(ident.span().start.offset) + .and_then(crate::symbol_map::LaravelStringDependency::namespaced_auth) + { + ctx.defer_laravel_spans_since(span_start, dependency); + } + } // The Blade preprocessor lowers `@can`/`@cannot`/`@canany` // to this call so the ability string is extracted here // like any other authorization check. @@ -482,6 +506,11 @@ fn extract_call<'a>( }, }); let clean_subject = strip_fqn_prefix(&subject_text); + let semantic_subject = ctx + .resolved_name_at(class_span.start.offset) + .map(strip_fqn_prefix) + .unwrap_or(clean_subject); + let laravel_span_start = ctx.spans.len(); if (clean_subject.eq_ignore_ascii_case("Config") || clean_subject.eq_ignore_ascii_case("Illuminate\\Support\\Facades\\Config")) && is_config_repository_method(&member_name) @@ -493,13 +522,20 @@ fn extract_call<'a>( &mut ctx.spans, ); } - try_emit_laravel_storage_disk_spans( - &subject_text, + if let Some(trigger) = crate::symbol_map::laravel_resources::static_method_trigger( + semantic_subject, &member_name, - &static_call.argument_list, - ctx.content, - &mut ctx.spans, - ); + ) { + 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, + ); + } // 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") @@ -587,11 +623,12 @@ 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( &static_call.argument_list, + true, ctx.content, &mut ctx.spans, ); @@ -638,6 +675,11 @@ fn extract_call<'a>( &mut ctx.spans, ); } + if let Some(dependency) = ctx.resolved_names.and_then(|_| { + crate::symbol_map::LaravelStringDependency::root_facade(semantic_subject) + }) { + ctx.defer_laravel_spans_since(laravel_span_start, dependency); + } } extract_from_arguments(&static_call.argument_list.arguments, ctx, scope_start); } @@ -748,14 +790,27 @@ fn emit_gate_ability_spans_for_method<'a>( return; } - if is_middleware && (receiver_is_this || chain_roots_at_route_facade(object)) { - try_emit_can_middleware_spans(argument_list, ctx.content, &mut ctx.spans); + let route_dependency = if is_middleware || is_can { + ctx.resolved_names + .and_then(|names| route_facade_root_dependency(object, names)) + } else { + None + }; + let route_receiver = + (is_middleware || is_can) && chain_roots_at_route_facade(object, ctx.resolved_names); + if is_middleware && (receiver_is_this || route_receiver) { + let span_start = ctx.spans.len(); + try_emit_can_middleware_spans(argument_list, route_receiver, ctx.content, &mut ctx.spans); + if let Some(dependency) = route_dependency { + ctx.defer_laravel_spans_since(span_start, dependency); + } 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 && route_receiver { + let span_start = ctx.spans.len(); try_emit_gate_ability_spans( argument_list, 0, @@ -765,6 +820,9 @@ fn emit_gate_ability_spans_for_method<'a>( &mut ctx.spans, &mut ctx.gate_subjects, ); + if let Some(dependency) = route_dependency { + ctx.defer_laravel_spans_since(span_start, dependency); + } return; } @@ -870,3 +928,27 @@ pub(super) fn extract_partial_application_expr<'a>( } } } + +#[cfg(test)] +mod tests { + use crate::names::OwnedResolvedNames; + use crate::symbol_map::LaravelStringDependency; + + #[test] + fn route_can_span_waits_for_the_root_alias_shadow_check() { + let php = "can('update', 'post');\n"; + 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 names = OwnedResolvedNames::from_resolved(&resolver.resolve(program)); + + let map = crate::symbol_map::extraction::extract_symbol_map_for_index(program, php, &names); + + assert_eq!(map.conditional_laravel_spans.len(), 1); + assert_eq!( + Some(map.conditional_laravel_spans[0].dependency), + LaravelStringDependency::root_facade("Route") + ); + } +} diff --git a/src/symbol_map/extraction/laravel.rs b/src/symbol_map/extraction/laravel.rs index 923900fd0..f98e1c2fe 100644 --- a/src/symbol_map/extraction/laravel.rs +++ b/src/symbol_map/extraction/laravel.rs @@ -6,93 +6,31 @@ 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", - "Auth", - "Authenticated", -]; -/// The Laravel-specific meaning of a container-injection attribute. -pub(super) enum LaravelContainerAttribute { - /// A full config key, as accepted by `#[Config]` and the pre-existing - /// generic handling for the other container attributes. - Config, - /// A disk name accepted by `#[Storage]`. - StorageDisk, -} - -/// Whether a written class name is the global `Storage` facade alias, its -/// exact Laravel class, or a directly imported alias. In a namespace the -/// short spelling is accepted only when that facade is explicitly imported, -/// preventing a local `Storage` class from being mistaken for Laravel's. -pub(super) fn matches_laravel_storage_facade(class_name: &str, content: &str) -> bool { - let is_root_qualified = class_name.starts_with('\\'); +/// Whether a semantically resolved class is either Laravel's global runtime +/// alias or the exact class inside `namespace`. +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("Illuminate\\Support\\Facades\\Storage") { + if class_name.eq_ignore_ascii_case(short) { return true; } - if class_name.contains('\\') { - return false; - } - if imports_laravel_storage_facade_as(content, class_name) { - return true; - } - class_name.eq_ignore_ascii_case("Storage") - && (is_root_qualified || !source_has_namespace(content)) + 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) + }) } -fn imports_laravel_storage_facade_as(content: &str, class_name: &str) -> bool { - const FACADE: &str = "Illuminate\\Support\\Facades\\Storage"; - for line in content.lines() { - let mut line = line.trim(); - if let Some(rest) = line.strip_prefix(" bool { + matches_laravel_class(class_name, "Illuminate\\Support\\Facades", short) } -fn source_has_namespace(content: &str) -> bool { - content.lines().any(|line| { - let mut line = line.trim_start(); - if let Some(rest) = line.strip_prefix(" bool { /// `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 stripped == "Storage" { - return Some(LaravelContainerAttribute::StorageDisk); - } - if LARAVEL_CONTAINER_ATTR_NAMES.contains(&stripped) { - return Some(LaravelContainerAttribute::Config); + 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 class_name == "Storage" { - return content - .contains("use Illuminate\\Container\\Attributes\\Storage;") - .then_some(LaravelContainerAttribute::StorageDisk); - } - 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(LaravelContainerAttribute::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 @@ -165,68 +103,50 @@ pub(super) fn try_emit_laravel_string_span_at( emit_laravel_string_span(kind, false, index, argument_list, content, spans); } -const STORAGE_DISK_CONFIG_PREFIX: &str = "filesystems.disks."; - -/// Emit config-key spans for the disk names accepted by Laravel's `Storage` -/// facade. Registration helpers are writes; `forgetDisk()` is an optional -/// read because forgetting an unconfigured disk is valid. -pub(super) fn try_emit_laravel_storage_disk_spans( - facade: &str, - member_name: &str, +/// 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 (parameter, accepts_array, is_write, is_optional) = - if member_name.eq_ignore_ascii_case("disk") { - ("name", false, false, false) - } else if member_name.eq_ignore_ascii_case("fake") - || member_name.eq_ignore_ascii_case("persistentFake") - { - ("disk", false, true, false) - } else if member_name.eq_ignore_ascii_case("forgetDisk") { - ("disk", true, false, true) - } else { - return; - }; - if !matches_laravel_storage_facade(facade, content) { - return; - } - let Some(argument) = argument_expr_for_parameter(argument_list, parameter) else { return; }; - if accepts_array { - let elements = match argument { - Expression::Array(array) => Some(&array.elements), - Expression::LegacyArray(array) => Some(&array.elements), - _ => None, - }; - if let Some(elements) = elements { - for element in elements.iter() { - let value = match element { - ArrayElement::KeyValue(element) => element.value, - ArrayElement::Value(element) => element.value, - ArrayElement::Variadic(_) | ArrayElement::Missing(_) => continue, - }; - push_storage_disk_span(value, is_write, is_optional, content, spans); - } - return; + let elements = match argument { + 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(element) => element.value, + ArrayElement::Value(element) => element.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, + ); } - } - push_storage_disk_span(argument, is_write, is_optional, content, spans); -} - -/// Emit the disk name accepted by Laravel's `#[Storage]` container -/// attribute, selecting the `disk` argument even when named arguments are -/// reordered. -pub(super) fn try_emit_laravel_storage_disk_span_partial( - argument_list: &PartialArgumentList<'_>, - content: &str, - spans: &mut Vec, -) { - if let Some(argument) = partial_argument_expr_for_parameter(argument_list, "disk") { - push_storage_disk_span(argument, false, false, content, spans); + } else if shape.accepts_scalar() { + push_laravel_string_span( + crate::symbol_map::LaravelStringKind::ConfigResource(resource), + access.is_write(), + access.is_optional(), + argument, + content, + spans, + ); } } @@ -236,7 +156,7 @@ fn argument_expr_for_parameter<'a>( ) -> 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) + && bytes_to_str(named.name.value) == parameter { return Some(named.value); } @@ -256,7 +176,7 @@ fn partial_argument_expr_for_parameter<'a>( ) -> 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) + && bytes_to_str(named.name.value) == parameter { return Some(named.value); } @@ -270,37 +190,6 @@ fn partial_argument_expr_for_parameter<'a>( }) } -fn push_storage_disk_span( - expression: &Expression<'_>, - is_write: bool, - is_optional: bool, - content: &str, - spans: &mut Vec, -) { - let Expression::Literal(literal::Literal::String(string)) = expression 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 disk = &content[start as usize..end as usize]; - let mut key = String::with_capacity(STORAGE_DISK_CONFIG_PREFIX.len() + disk.len()); - key.push_str(STORAGE_DISK_CONFIG_PREFIX); - key.push_str(disk); - spans.push(SymbolSpan { - start, - end, - kind: SymbolKind::LaravelStringKey { - key, - kind: crate::symbol_map::LaravelStringKind::Config, - is_write, - is_optional, - }, - }); -} - /// 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. @@ -413,7 +302,7 @@ fn push_laravel_string_span( start: inner_start, end: inner_end, kind: SymbolKind::LaravelStringKey { - key: normalised_key(kind.clone(), key), + key: normalised_key(kind, key), kind, is_write, is_optional, @@ -448,7 +337,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); } } } @@ -922,20 +811,14 @@ pub(super) fn is_gate_facade(clean_subject: &str) -> bool { || clean_subject.eq_ignore_ascii_case("Illuminate\\Support\\Facades\\Gate") } -/// How far back down a method chain a facade root is looked for. -/// -/// The chains this recognises are short — `Gate::forUser($user)->allows(…)` is -/// one link, `Route::get(…)->name(…)->middleware(…)` a handful. The bound is -/// what keeps the cost linear: several of the method names that start this -/// search (`has`, `any`, `check`) are also ordinary query-builder methods, and -/// an unbounded walk would rescan the whole spine at every link of a long -/// Eloquent chain. -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)) + chain_roots_at_facade( + expr, + crate::symbol_map::laravel_resources::FACADE_CHAIN_DEPTH, + &|name| is_gate_facade(name), + ) } /// Whether an instance-method chain roots at the `Route` facade, as @@ -943,10 +826,74 @@ 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_route_facade_inner( + expr, + crate::symbol_map::laravel_resources::FACADE_CHAIN_DEPTH, + resolved_names, + ) +} + +/// Return the real global class that can suppress a fluent chain rooted at +/// Laravel's optional `Route` alias. Imported/FQN facade roots are +/// unambiguous and therefore have no dependency. +pub(super) fn route_facade_root_dependency( + expr: &Expression<'_>, + resolved_names: &crate::names::OwnedResolvedNames, +) -> Option { + route_facade_root_dependency_inner( + expr, + crate::symbol_map::laravel_resources::FACADE_CHAIN_DEPTH, + resolved_names, + ) +} + +fn route_facade_root_dependency_inner( + expr: &Expression<'_>, + depth: usize, + resolved_names: &crate::names::OwnedResolvedNames, +) -> Option { + if depth == 0 { + return None; + } + match expr { + Expression::Call(Call::Method(call)) => { + route_facade_root_dependency_inner(call.object, depth - 1, resolved_names) + } + Expression::Call(Call::StaticMethod(call)) => { + let name = resolved_names.get(call.class.span().start.offset)?; + crate::symbol_map::LaravelStringDependency::root_facade(name) + } + _ => None, + } +} + +fn chain_roots_at_route_facade_inner( + expr: &Expression<'_>, + depth: usize, + resolved_names: Option<&crate::names::OwnedResolvedNames>, +) -> bool { + if depth == 0 { + return false; + } + match expr { + Expression::Call(Call::Method(call)) => { + chain_roots_at_route_facade_inner(call.object, depth - 1, resolved_names) + } + Expression::Call(Call::StaticMethod(call)) => { + if let Some(names) = resolved_names { + names + .get(call.class.span().start.offset) + .is_some_and(|name| matches_laravel_facade(name, "Route")) + } else { + matches_laravel_facade(strip_fqn_prefix(&expr_to_subject_text(call.class)), "Route") + } + } + _ => false, + } } /// Walk at most `depth` links down a method chain looking for a static call @@ -1140,39 +1087,50 @@ 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` and, when `include_auth_guards` is true, +/// `auth:guard[,guard]`, each accepted as one string or as an array value. pub(super) fn try_emit_can_middleware_spans( argument_list: &ArgumentList<'_>, + include_auth_guards: bool, content: &str, spans: &mut Vec, ) { - let Some(first_arg) = argument_list.arguments.iter().next() else { + let Some(middleware) = argument_expr_for_parameter(argument_list, "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, include_auth_guards, 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, include_auth_guards, content, spans); } } - expr => push_can_middleware_span(expr, content, spans), + expr => push_middleware_parameter_spans(expr, include_auth_guards, 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<'_>, + include_auth_guards: bool, + content: &str, + spans: &mut Vec, +) { let Expression::Literal(literal::Literal::String(s)) = expr else { return; }; @@ -1182,20 +1140,52 @@ 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 == "can" { + let ability = parameters.split(',').next().unwrap_or(parameters); + push_embedded_string_span( + crate::symbol_map::LaravelStringKind::GateAbility, + ability, + parameter_start, + spans, + ); + } else if include_auth_guards + && crate::symbol_map::laravel_resources::middleware_resource(&text[..alias.len() + 1]) + == Some(crate::symbol_map::LaravelConfigResource::AuthGuard) + { + 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, + spans, + ); + offset += guard.len() as u32 + 1; + } + } +} + +fn push_embedded_string_span( + kind: crate::symbol_map::LaravelStringKind, + key: &str, + start: u32, + 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, }, @@ -1245,16 +1235,16 @@ pub(super) fn try_emit_command_own_param_span( /// `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( +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, parameter) + else { return; }; let inner_start = s.span.start.offset + 1; @@ -1276,7 +1266,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, @@ -1284,6 +1274,28 @@ 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, parameter) else { + return; + }; + push_laravel_string_span( + crate::symbol_map::LaravelStringKind::ConfigResource(resource), + access.is_write(), + access.is_optional(), + expr, + content, + spans, + ); +} + /// 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( @@ -1764,24 +1776,14 @@ mod storage_disk_tests { use super::*; #[test] - fn storage_facade_imports_require_a_valid_direct_import() { - let direct = "namespace App;\nuse Illuminate\\Support\\Facades\\Storage;"; - assert!(matches_laravel_storage_facade("Storage", direct)); - assert!(!matches_laravel_storage_facade("LaravelStorage", direct)); - - let aliased = - "namespace App;\nuse Illuminate\\Support\\Facades\\Storage as LaravelStorage;"; - assert!(matches_laravel_storage_facade("LaravelStorage", aliased)); - - let incomplete_alias = "namespace App;\nuse Illuminate\\Support\\Facades\\Storage as;"; - assert!(!matches_laravel_storage_facade( - "LaravelStorage", - incomplete_alias + fn facade_matching_accepts_only_runtime_aliases_and_laravel_facades() { + assert!(matches_laravel_facade("Storage", "Storage")); + assert!(matches_laravel_facade( + "Illuminate\\Support\\Facades\\Storage", + "Storage" )); - - let malformed = - "namespace App;\nuse Illuminate\\Support\\Facades\\Storage from LaravelStorage;"; - assert!(!matches_laravel_storage_facade("LaravelStorage", malformed)); + assert!(!matches_laravel_facade("App\\Storage", "Storage")); + assert!(!matches_laravel_facade("Acme\\Storage", "Storage")); } #[test] @@ -1790,19 +1792,25 @@ mod storage_disk_tests { assert!(matches!( resolve_laravel_container_attr( "Illuminate\\Container\\Attributes\\Storage", + false, &mut import_cache, "" ), - Some(LaravelContainerAttribute::StorageDisk) + Some(LaravelContainerAttribute::Resource(trigger)) + if trigger.kind == crate::symbol_map::LaravelConfigResource::StorageDisk )); assert!(matches!( resolve_laravel_container_attr( "Illuminate\\Container\\Attributes\\Config", + false, &mut import_cache, "" ), Some(LaravelContainerAttribute::Config) )); + assert!( + resolve_laravel_container_attr("App\\Storage", false, &mut import_cache, "").is_none() + ); } #[test] diff --git a/src/symbol_map/extraction/mod.rs b/src/symbol_map/extraction/mod.rs index 0621a2a46..2ed36dc4b 100644 --- a/src/symbol_map/extraction/mod.rs +++ b/src/symbol_map/extraction/mod.rs @@ -17,6 +17,7 @@ use super::{ 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,6 +71,13 @@ 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 does not guess from + /// an alias or a namespace-local homonym. Syntax-only tests omit it. + resolved_names: Option<&'a OwnedResolvedNames>, + /// Laravel strings whose meaning depends on a workspace symbol being + /// absent. They are activated after the batch publishes its declarations. + conditional_laravel_spans: Vec, /// Closures and arrow functions passed as arguments to callable-typed /// parameters, used by inlay hints. untyped_closure_sites: Vec, @@ -106,6 +114,28 @@ struct ExtractionCtx<'a> { covers_default_class: Option, } +impl<'a> ExtractionCtx<'a> { + fn resolved_name_at(&self, offset: u32) -> Option<&'a str> { + self.resolved_names.and_then(|names| names.get(offset)) + } + + /// Move Laravel string spans emitted since `start` into the dormant + /// candidate list. The tail is normally one span, so removing in place + /// avoids allocating a temporary vector on this already-rare path. + fn defer_laravel_spans_since( + &mut self, + start: usize, + dependency: super::LaravelStringDependency, + ) { + let spans = &mut self.spans; + let candidates = &mut self.conditional_laravel_spans; + for span in spans.drain(start..) { + debug_assert!(matches!(&span.kind, SymbolKind::LaravelStringKey { .. })); + candidates.push(super::ConditionalLaravelStringSpan::new(dependency, span)); + } + } +} + mod class_like; mod expressions; mod keywords; @@ -145,7 +175,38 @@ 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`. +#[cfg(test)] +pub(crate) fn extract_symbol_map_with_resolved_names( + program: &Program<'_>, + content: &str, + resolved_names: &OwnedResolvedNames, +) -> SymbolMap { + let mut map = extract_symbol_map_inner(program, content, Some(resolved_names)); + map.refresh_conditional_laravel_spans(None, |_| false); + map +} + +/// Build a semantic map while retaining workspace-dependent Laravel spans as +/// dormant candidates for the publication lifecycle to settle. +pub(crate) fn extract_symbol_map_for_index( + 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,6 +224,8 @@ pub(crate) fn extract_symbol_map(program: &Program<'_>, content: &str) -> Symbol instance_method_scopes: Vec::new(), trivias: program.trivia.as_slice(), content, + resolved_names, + conditional_laravel_spans: Vec::new(), untyped_closure_sites: Vec::new(), view_receiver_sites: Vec::new(), gate_subjects: Vec::new(), @@ -275,6 +338,7 @@ pub(crate) fn extract_symbol_map(program: &Program<'_>, content: &str) -> Symbol SymbolMap { spans: ctx.spans, + conditional_laravel_spans: ctx.conditional_laravel_spans, member_access_indices, var_defs: ctx.var_defs, scopes: ctx.scopes, diff --git a/src/symbol_map/laravel_resources.rs b/src/symbol_map/laravel_resources.rs new file mode 100644 index 000000000..1aeddc763 --- /dev/null +++ b/src/symbol_map/laravel_resources.rs @@ -0,0 +1,929 @@ +//! Declarative Laravel config-resource families and their string triggers. + +use super::LaravelConfigResource; +use crate::names::OwnedResolvedNames; + +/// Maximum instance-method links inspected before a facade root is considered +/// too far away. Shared by AST extraction and the live completion scanner so +/// the two paths classify the same receiver spines. +pub(crate) const FACADE_CHAIN_DEPTH: usize = 4; + +/// Short facade names accepted by direct config-resource triggers. Kept as a +/// compact static slice so live completion can resolve imports without +/// rebuilding the descriptor-derived set on every keystroke. +pub(crate) const RESOURCE_FACADES: &[&str] = &[ + "Auth", + "Broadcast", + "Cache", + "DB", + "Log", + "Mail", + "Queue", + "Storage", +]; + +/// Container attributes that select a config-backed resource. +pub(crate) const RESOURCE_ATTRIBUTES: &[&str] = &[ + "Auth", + "Authenticated", + "Cache", + "DB", + "Database", + "Log", + "Storage", +]; + +/// 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 the trigger accepts an array of names. + pub(crate) const fn accepts_array(self) -> bool { + matches!(self, Self::ScalarOrArray | Self::Array) + } + + /// Whether the trigger accepts one scalar name. + 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, + }, + Attribute { + name: &'static str, + argument: &'static str, + }, + Middleware { + prefix: &'static str, + }, +} + +/// One named-resource family. The table is authoritative metadata; compact +/// lookup matches below mirror its names to keep request-time dispatch O(1). +/// Exhaustive tests fail if the two representations drift. +#[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, Middleware, StaticMethod}; +use ResourceAccess::{OptionalRead, Read, Write}; +use ResourceArgumentShape::{Array, Scalar, ScalarOrArray}; + +/// Every direct, 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, + }], + }, + 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 child = configured_child_name(kind, short_name); + let mut key = String::with_capacity(prefix.len() + child.len()); + key.push_str(prefix); + key.push_str(child); + key +} + +/// The config-array child selected by a source spelling. +/// +/// Laravel database connections accept a role suffix while still reading the +/// base connection's configuration (`mysql::read`, `::write`, or `::direct`). +pub(crate) fn configured_child_name(kind: LaravelConfigResource, source_name: &str) -> &str { + if kind == LaravelConfigResource::DatabaseConnection { + for suffix in DATABASE_ROLE_SUFFIXES { + if let Some(base) = source_name.strip_suffix(suffix) { + return base; + } + } + } + source_name +} + +/// Database connection role suffixes recognized by Laravel's manager. +pub(crate) const DATABASE_ROLE_SUFFIXES: &[&str] = &["::read", "::write", "::direct"]; + +/// Runtime-provided names that are valid without a matching config child. +pub(crate) fn is_implicit_resource_name(kind: LaravelConfigResource, name: &str) -> bool { + name == "null" + && matches!( + kind, + LaravelConfigResource::CacheStore | LaravelConfigResource::QueueConnection + ) +} + +/// Whether two direct spellings select the same configured resource. +pub(crate) fn same_resource_name(kind: LaravelConfigResource, left: &str, right: &str) -> bool { + configured_child_name(kind, left) == configured_child_name(kind, right) +} + +/// 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 { + if is_implicit_resource_name(kind, short_name) { + return false; + } + full_key + .strip_prefix(descriptor(kind).config_prefix) + .is_some_and(|rest| rest == configured_child_name(kind, 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('.') && !is_implicit_resource_name(kind, short)) + .then_some((kind, short)) +} + +pub(crate) fn function_trigger(name: &str) -> Option { + trigger_match( + descriptor(LaravelConfigResource::AuthGuard), + |trigger| match trigger { + Function { + name: expected, + argument, + shape, + access, + } if name.eq_ignore_ascii_case(expected) => Some((*argument, *shape, *access)), + _ => None, + }, + ) +} + +/// Cheap method-name prefilter for the live completion path. +pub(crate) fn static_method_may_trigger(method: &str) -> bool { + match method.len() { + 4 => method.eq_ignore_ascii_case("disk") || method.eq_ignore_ascii_case("fake"), + 5 => { + method.eq_ignore_ascii_case("guard") + || method.eq_ignore_ascii_case("store") + || method.eq_ignore_ascii_case("stack") + } + 6 => method.eq_ignore_ascii_case("mailer"), + 7 => method.eq_ignore_ascii_case("channel"), + 10 => { + method.eq_ignore_ascii_case("connection") || method.eq_ignore_ascii_case("forgetDisk") + } + 14 => method.eq_ignore_ascii_case("persistentFake"), + _ => false, + } +} + +/// Resolve a written function call to Laravel's global `auth()` helper. +/// +/// PHP falls back to a global function only when the current namespace has no +/// same-named function. Semantic names settle aliases and same-file shadows; +/// the optional index membership check handles a shadow declared elsewhere. +pub(crate) fn auth_helper_trigger( + content: &str, + written_name: &str, + offset: u32, + resolved_names: Option<&OwnedResolvedNames>, + indexed_function_exists: Option<&dyn Fn(&str) -> bool>, +) -> Option { + if resolved_names + .and_then(|names| names.get(offset)) + .is_some_and(|resolved| resolved.eq_ignore_ascii_case("auth")) + { + return function_trigger("auth"); + } + if !written_name.eq_ignore_ascii_case("auth") { + return None; + } + function_trigger(written_name).filter(|_| { + matches_laravel_auth_helper(content, offset, resolved_names, indexed_function_exists) + }) +} + +fn matches_laravel_auth_helper( + content: &str, + offset: u32, + resolved_names: Option<&OwnedResolvedNames>, + indexed_function_exists: Option<&dyn Fn(&str) -> bool>, +) -> bool { + let Some(names) = resolved_names else { + return true; + }; + let Some(resolved) = names.get(offset) else { + return false; + }; + if resolved.eq_ignore_ascii_case("auth") { + return true; + } + if names.is_imported(offset) { + return false; + } + if names.iter().any(|(declaration_offset, name, _)| { + name.eq_ignore_ascii_case(resolved) + && is_named_function_declaration(content, declaration_offset) + }) { + return false; + } + !indexed_function_exists.is_some_and(|exists| exists(resolved)) +} + +fn is_named_function_declaration(content: &str, offset: u32) -> bool { + let Some(before_name) = content.as_bytes().get(..offset as usize) else { + return false; + }; + let mut end = skip_php_trivia_backwards(before_name, before_name.len()); + if end > 0 && before_name[end - 1] == b'&' { + end = skip_php_trivia_backwards(before_name, end - 1); + } + let start = end.saturating_sub("function".len()); + before_name[start..end].eq_ignore_ascii_case(b"function") + && (start == 0 + || !(before_name[start - 1].is_ascii_alphanumeric() || before_name[start - 1] == b'_')) +} + +fn skip_php_trivia_backwards(bytes: &[u8], mut end: usize) -> usize { + loop { + while end > 0 && bytes[end - 1].is_ascii_whitespace() { + end -= 1; + } + + if end >= 2 + && &bytes[end - 2..end] == b"*/" + && let Some(start) = bytes[..end - 2].windows(2).rposition(|pair| pair == b"/*") + { + end = start; + continue; + } + + let line_start = bytes[..end] + .iter() + .rposition(|byte| *byte == b'\n' || *byte == b'\r') + .map_or(0, |index| index + 1); + let line = &bytes[line_start..end]; + if let Some(comment) = line.windows(2).rposition(|pair| pair == b"//") { + end = line_start + comment; + continue; + } + if let Some(comment) = line.iter().rposition(|byte| *byte == b'#') { + end = line_start + comment; + continue; + } + + return end; + } +} + +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)?); + trigger_match(resource, |trigger| match trigger { + StaticMethod { + facade, + method: expected, + argument, + shape, + access, + } if short.eq_ignore_ascii_case(facade) && method.eq_ignore_ascii_case(expected) => { + Some((*argument, *shape, *access)) + } + _ => None, + }) +} + +pub(crate) fn attribute_trigger(name: &str) -> Option { + let name = name.trim_start_matches('\\'); + let short = if let Some((namespace, short)) = name.rsplit_once('\\') { + if !namespace.eq_ignore_ascii_case("Illuminate\\Container\\Attributes") { + return None; + } + short + } else { + name + }; + let resource = descriptor(attribute_resource(short)?); + trigger_match(resource, |trigger| match trigger { + Attribute { + name: expected, + argument, + } if short.eq_ignore_ascii_case(expected) => Some(( + *argument, + ResourceArgumentShape::Scalar, + ResourceAccess::Read, + )), + _ => None, + }) +} + +pub(crate) fn middleware_resource(prefix: &str) -> Option { + if prefix != "auth:" { + return None; + } + let resource = descriptor(LaravelConfigResource::AuthGuard); + resource.triggers.iter().find_map(|trigger| match trigger { + Middleware { prefix: expected } if prefix == *expected => Some(resource.kind), + _ => None, + }) +} + +fn trigger_match( + resource: &ConfigResourceDescriptor, + mut select: impl FnMut( + &ConfigResourceTrigger, + ) -> Option<(&'static str, ResourceArgumentShape, ResourceAccess)>, +) -> Option { + resource.triggers.iter().find_map(|trigger| { + let (argument, shape, access) = select(trigger)?; + Some(ResourceTriggerMatch { + kind: resource.kind, + argument, + shape, + access, + }) + }) +} + +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 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 attribute_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), + 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, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn resolved_names(content: &str) -> OwnedResolvedNames { + 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, content.as_bytes()); + let resolver = mago_names::resolver::NameResolver::new(&arena); + OwnedResolvedNames::from_resolved(&resolver.resolve(program)) + } + + #[test] + fn every_resource_has_one_unique_prefix_and_descriptor() { + let mut family_bits = 0_u8; + 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 + })); + assert_eq!(family_bits & resource.kind.bit(), 0); + family_bits |= resource.kind.bit(); + } + assert_eq!(family_bits, u8::MAX); + } + + #[test] + fn every_declarative_trigger_is_reachable_through_its_fast_lookup() { + 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 { + let (found, expected) = match trigger { + Function { + name, + argument, + shape, + access, + } => ( + function_trigger(name), + ResourceTriggerMatch { + kind: resource.kind, + argument, + shape: *shape, + access: *access, + }, + ), + StaticMethod { + facade, + method, + argument, + shape, + access, + } => { + assert!( + RESOURCE_FACADES + .iter() + .any(|candidate| candidate.eq_ignore_ascii_case(facade)) + ); + assert!(static_method_may_trigger(method)); + let expected = ResourceTriggerMatch { + kind: resource.kind, + argument, + shape: *shape, + access: *access, + }; + let short = static_method_trigger(facade, method); + let fqn = format!("Illuminate\\Support\\Facades\\{facade}"); + assert_eq!(static_method_trigger(&fqn, method), Some(expected)); + (short, expected) + } + Attribute { name, argument } => { + assert!( + RESOURCE_ATTRIBUTES + .iter() + .any(|candidate| candidate.eq_ignore_ascii_case(name)) + ); + ( + attribute_trigger(name), + ResourceTriggerMatch { + kind: resource.kind, + argument, + shape: ResourceArgumentShape::Scalar, + access: ResourceAccess::Read, + }, + ) + } + Middleware { prefix } => { + assert_eq!(middleware_resource(prefix), Some(resource.kind)); + continue; + } + }; + assert_eq!(found, Some(expected)); + } + } + } + + #[test] + fn trigger_metadata_preserves_argument_shapes_and_access_modes() { + for (method, 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(access) + ); + } + assert_eq!( + static_method_trigger("Log", "stack").map(|found| found.shape), + Some(ResourceArgumentShape::Array) + ); + assert_eq!( + static_method_trigger("Storage", "forgetDisk").map(|found| found.shape), + Some(ResourceArgumentShape::ScalarOrArray) + ); + } + + #[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\\CACHE", "STORE") + .map(|found| found.kind), + Some(LaravelConfigResource::CacheStore) + ); + assert_eq!( + static_method_trigger("\\Illuminate\\Support\\Facades\\DB", "connection") + .map(|found| found.kind), + Some(LaravelConfigResource::DatabaseConnection) + ); + assert_eq!( + attribute_trigger("ILLUMINATE\\CONTAINER\\ATTRIBUTES\\AUTHENTICATED") + .map(|found| found.kind), + Some(LaravelConfigResource::AuthGuard) + ); + assert_eq!( + attribute_trigger("\\Illuminate\\Container\\Attributes\\Log").map(|found| found.kind), + Some(LaravelConfigResource::LogChannel) + ); + assert!(middleware_resource("AUTH:").is_none()); + assert!(function_trigger("guard").is_none()); + assert!(static_method_trigger("Queue", "mailer").is_none()); + assert!(static_method_trigger("Acme\\Log", "stack").is_none()); + assert!(attribute_trigger("Acme\\Cache").is_none()); + assert!(attribute_trigger("Unknown").is_none()); + assert!(middleware_resource("throttle:").is_none()); + } + + #[test] + fn auth_helper_resolution_honours_aliases_and_namespace_shadows() { + let global = " u8 { + match self { + Self::AuthGuard => 1 << 0, + Self::CacheStore => 1 << 1, + Self::LogChannel => 1 << 2, + Self::StorageDisk => 1 << 3, + Self::DatabaseConnection => 1 << 4, + Self::QueueConnection => 1 << 5, + Self::Mailer => 1 << 6, + Self::BroadcastConnection => 1 << 7, + } + } +} + /// 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. @@ -490,6 +533,104 @@ pub(crate) enum LaravelStringKind { ContainerBinding, } +impl LaravelStringKind { + /// Whether this key resolves through Laravel's config index. + pub(crate) const fn is_config_backed(self) -> bool { + matches!(self, Self::Config | Self::ConfigResource(_)) + } +} + +/// A workspace symbol whose presence suppresses an otherwise-valid Laravel +/// runtime fallback. +/// +/// PHP first looks for a namespace-local function before falling back to a +/// global helper, and Laravel's root facade aliases only exist when no real +/// global class has claimed the same name. These dependencies are stored on +/// the handful of ambiguous string spans so edits to another file can toggle +/// the spans without reparsing either file or adding work to request paths. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub(crate) enum LaravelStringDependency { + Function(Atom), + Class(Atom), +} + +impl LaravelStringDependency { + /// The namespace-local `auth` function that may prevent PHP from reaching + /// Laravel's global helper. + pub(crate) fn namespaced_auth(name: &str) -> Option { + let (namespace, short) = name.rsplit_once('\\')?; + (!namespace.is_empty() && short.eq_ignore_ascii_case("auth")) + .then(|| Self::Function(crate::atom::ascii_lowercase_atom(name))) + } + + /// A global class name Laravel may otherwise provide as a runtime facade + /// alias. Fully-qualified facade classes are unambiguous and return none. + pub(crate) fn root_facade(name: &str) -> Option { + let name = name.trim_start_matches('\\'); + if name.contains('\\') { + return None; + } + let is_alias = crate::symbol_map::laravel_resources::RESOURCE_FACADES + .iter() + .any(|alias| name.eq_ignore_ascii_case(alias)) + || match name.len() { + 3 => name.eq_ignore_ascii_case("App"), + 4 => { + name.eq_ignore_ascii_case("Gate") + || name.eq_ignore_ascii_case("Lang") + || name.eq_ignore_ascii_case("View") + } + 5 => name.eq_ignore_ascii_case("Route"), + 6 => name.eq_ignore_ascii_case("Config"), + 7 => name.eq_ignore_ascii_case("Artisan"), + 8 => name.eq_ignore_ascii_case("Response") || name.eq_ignore_ascii_case("Schedule"), + _ => false, + }; + is_alias.then(|| Self::Class(crate::atom::ascii_lowercase_atom(name))) + } +} + +/// A Laravel string span whose meaning depends on a workspace symbol being +/// absent. The payload moves between this side table and [`SymbolMap::spans`] +/// as it becomes dormant/active, so an active candidate does not duplicate +/// its key string in memory. +#[derive(Debug, Clone)] +pub(crate) struct ConditionalLaravelStringSpan { + pub dependency: LaravelStringDependency, + start: u32, + end: u32, + dormant_span: Option, +} + +impl ConditionalLaravelStringSpan { + pub(crate) fn new(dependency: LaravelStringDependency, span: SymbolSpan) -> Self { + Self { + dependency, + start: span.start, + end: span.end, + dormant_span: Some(span), + } + } + + fn is_inserted(&self) -> bool { + self.dormant_span.is_none() + } + + /// Heap bytes owned by a dormant payload for the optional memory audit. + #[cfg(feature = "mem-audit")] + pub(crate) fn audit_heap(&self) -> usize { + self.dormant_span + .as_ref() + .map_or(0, |span| span.kind.audit_heap()) + } +} + +fn is_candidate_span(span: &SymbolSpan, candidate: &ConditionalLaravelStringSpan) -> bool { + span.start == candidate.start + && span.end == candidate.end + && matches!(&span.kind, SymbolKind::LaravelStringKey { .. }) +} + /// The model a gate check names, recorded alongside its ability span. /// /// Kept in a side table rather than inside [`SymbolKind::GateAbility`] so @@ -775,6 +916,9 @@ pub(crate) enum VarDefKind { #[derive(Debug, Clone, Default)] pub(crate) struct SymbolMap { pub spans: Vec, + /// Laravel string spans gated by cross-file function/class membership. + /// Empty for ordinary PHP files and never consulted by request handlers. + pub conditional_laravel_spans: Vec, /// Member-access span indices keyed by member name. /// /// This lets references/rename jump straight to relevant `->name` / @@ -879,6 +1023,85 @@ pub(crate) struct SymbolMap { } impl SymbolMap { + /// Whether this map has a dormant span gated by one of `affected`. + pub(crate) fn has_conditional_laravel_dependency( + &self, + affected: Option<&std::collections::HashSet>, + ) -> bool { + self.conditional_laravel_spans.iter().any(|candidate| { + affected.is_none_or(|dependencies| dependencies.contains(&candidate.dependency)) + }) + } + + /// Whether reconciling the selected dependencies would add or remove at + /// least one span. This lets the publication path avoid cloning an + /// already-correct map merely to discover that it is unchanged. + pub(crate) fn conditional_laravel_spans_need_refresh( + &self, + affected: Option<&std::collections::HashSet>, + mut dependency_exists: impl FnMut(LaravelStringDependency) -> bool, + ) -> bool { + self.conditional_laravel_spans.iter().any(|candidate| { + affected.is_none_or(|dependencies| dependencies.contains(&candidate.dependency)) + && candidate.is_inserted() == dependency_exists(candidate.dependency) + }) + } + + /// Reconcile dormant Laravel spans against the current workspace symbol + /// indexes. When `affected` is supplied, candidates with any other + /// dependency are skipped, keeping cross-file edits proportional to the + /// uncommon ambiguous call sites rather than to every span in a file. + pub(crate) fn refresh_conditional_laravel_spans( + &mut self, + affected: Option<&std::collections::HashSet>, + mut dependency_exists: impl FnMut(LaravelStringDependency) -> bool, + ) -> bool { + let mut changed = false; + let mut added = false; + for candidate in &mut self.conditional_laravel_spans { + if affected.is_some_and(|set| !set.contains(&candidate.dependency)) { + continue; + } + let should_insert = !dependency_exists(candidate.dependency); + if should_insert == candidate.is_inserted() { + continue; + } + + if should_insert { + self.spans.push( + candidate + .dormant_span + .take() + .expect("a dormant candidate must own its span"), + ); + added = true; + } else { + let existing = self + .spans + .iter() + .position(|span| is_candidate_span(span, candidate)) + .expect("an active candidate must be present in the symbol map"); + candidate.dormant_span = Some(self.spans.remove(existing)); + } + changed = true; + } + if added { + self.spans.sort_by_key(|span| span.start); + } + if changed { + self.member_access_indices.clear(); + for (index, span) in self.spans.iter().enumerate() { + if let SymbolKind::MemberAccess { member_name, .. } = &span.kind { + self.member_access_indices + .entry(*member_name) + .or_default() + .push(index); + } + } + } + changed + } + /// Whether this map's offsets are valid indices into `content`. /// /// A length match means no text was inserted or removed since the map diff --git a/src/symbol_map/tests.rs b/src/symbol_map/tests.rs index 5c47b85d1..71aefb9a7 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)> { .iter() .filter_map(|span| match &span.kind { SymbolKind::LaravelStringKey { - kind: LaravelStringKind::Config, + kind: LaravelStringKind::ConfigResource(LaravelConfigResource::StorageDisk), key, is_write, is_optional, - } if key.starts_with("filesystems.disks.") => { - Some((key.clone(), *is_write, *is_optional)) - } + } => Some(( + crate::symbol_map::laravel_resources::config_key( + LaravelConfigResource::StorageDisk, + key, + ), + *is_write, + *is_optional, + )), _ => None, }) .collect() @@ -4506,7 +4521,7 @@ use Illuminate\Support\Facades\Storage; Storage::disk('archive'); "#; assert_eq!( - storage_disk_keys(&parse_and_extract(imported)), + storage_disk_keys(&parse_and_extract_semantic(imported)), vec![("filesystems.disks.archive".to_string(), false, false,)] ); @@ -4516,7 +4531,7 @@ use Illuminate\Support\Facades\Storage as LaravelStorage; LaravelStorage::fake('testing'); "#; assert_eq!( - storage_disk_keys(&parse_and_extract(aliased)), + storage_disk_keys(&parse_and_extract_semantic(aliased)), vec![("filesystems.disks.testing".to_string(), true, false,)] ); @@ -4525,14 +4540,14 @@ namespace App; class Storage {} Storage::disk('local'); "#; - assert!(storage_disk_keys(&parse_and_extract(homonym)).is_empty()); + assert!(storage_disk_keys(&parse_and_extract_semantic(homonym)).is_empty()); let root_qualified = r#" 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_direct_resource_trigger_emits_its_short_name() { + for (php, resource, expected) in [ + ( + " 'stderr', $dynamic, ...$more]);", + " 'stderr', $dynamic));", + ] { + assert_eq!( + resource_keys(&parse_and_extract(php), LaravelConfigResource::LogChannel), + vec![ + ("daily".to_string(), false, false), + ("stderr".to_string(), false, false), + ] + ); + } + assert!( + resource_keys( + &parse_and_extract(" 'auth:api', + 'can:update,post', + 'AUTH:invalid', + 'CAN:invalid', + ], +); +"#; + let map = parse_and_extract(php); + assert_eq!( + resource_keys(&map, LaravelConfigResource::AuthGuard), + vec![ + ("web".to_string(), false, false), + ("admin".to_string(), false, false), + ("api".to_string(), false, false), + ] + ); + assert!(map.spans.iter().any(|span| matches!( + &span.kind, + SymbolKind::LaravelStringKey { + kind: LaravelStringKind::GateAbility, + key, + .. + } if key == "update" + ))); + for guard in ["web", "admin", "api"] { + let offset = php.find(guard).unwrap() as u32; + let span = map.lookup(offset).expect("guard should have a span"); + assert_eq!(&php[span.start as usize..span.end as usize], guard); + } +} + +#[test] +fn auth_middleware_skips_variadic_values_in_both_array_spellings() { + let php = r#" 'auth:api', ...$legacy)); +"#; + assert_eq!( + resource_keys(&parse_and_extract(php), LaravelConfigResource::AuthGuard,), + vec![ + ("web".to_string(), false, false), + ("api".to_string(), false, false), + ] + ); +} + +#[test] +fn a_route_middleware_chain_is_followed_only_within_the_depth_bound() { + for (chain, expected) in [ + ("Route::get('/')", true), + ("Route::get('/')->a()", true), + ("Route::get('/')->a()->b()->c()", true), + ("Route::get('/')->a()->b()->c()->d()", false), + ("$router->get('/')", false), + ] { + let php = format!("middleware('auth:web');\n"); + assert_eq!( + !resource_keys(&parse_and_extract(&php), LaravelConfigResource::AuthGuard,).is_empty(), + expected, + "{chain}" + ); + } +} + +#[test] +fn semantic_facade_aliases_cover_every_resource_and_reject_local_homonyms() { + let php = r#">(), + expected, + "{resource:?}" + ); + } +} + +#[test] +fn semantic_attribute_aliases_select_named_arguments_and_reject_homonyms() { + let php = r#"middleware(['auth:api']); +Route::middleware('auth:bad-static'); +Route::get('/local')->middleware('auth:bad-chain'); +"#; + assert_eq!( + resource_keys( + &parse_and_extract_semantic(php), + LaravelConfigResource::AuthGuard, + ), + vec![ + ("web".to_string(), false, false), + ("admin".to_string(), false, false), + ("api".to_string(), false, false), + ] + ); +} + +#[test] +fn semantic_auth_helper_preserves_global_fallback_and_rejects_shadows() { + let global_fallback = r#" 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 = parsed.path().rsplit_once("/config/")?.1; + let stem = relative.strip_suffix(".php")?; + if stem.is_empty() { 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. @@ -63,7 +48,7 @@ pub(crate) fn collect_laravel_config_declarations( let program = mago_syntax::parser::parse_file_content(&arena, file_id, content.as_bytes()); let mut out = Vec::new(); - let mut returned_var_name: Option = None; + let mut returned_var_name: Option<&str> = None; let mut return_expr: Option<&Expression<'_>> = None; for stmt in program.statements.iter() { @@ -71,7 +56,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(bytes_to_str(dv.name)); } _ => { return_expr = Some(val); @@ -82,16 +67,17 @@ pub(crate) fn collect_laravel_config_declarations( } } + let mut path = Vec::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); } 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() + && bytes_to_str(dv.name) == var_name { - collect_expr_declarations(assign.rhs, content, prefix, &[], &mut out); + collect_expr_declarations(assign.rhs, content, prefix, &mut path, &mut out); } } } @@ -101,11 +87,11 @@ pub(crate) fn collect_laravel_config_declarations( // ─── Declaration walker ─────────────────────────────────────────────────────── -fn collect_expr_declarations( +fn collect_expr_declarations<'content>( expr: &Expression<'_>, - content: &str, + content: &'content str, prefix: &str, - path: &[String], + path: &mut Vec<&'content str>, out: &mut Vec, ) { match expr { @@ -135,11 +121,11 @@ fn collect_expr_declarations( } } -fn collect_array_declarations<'a>( +fn collect_array_declarations<'a, 'content>( elements: impl Iterator>, - content: &str, + content: &'content str, prefix: &str, - path: &[String], + path: &mut Vec<&'content str>, out: &mut Vec, ) { for element in elements { @@ -152,16 +138,25 @@ fn collect_array_declarations<'a>( None => continue, }; - let mut full_path = path.to_vec(); - full_path.push(key_text.to_string()); - let dot_key = format!("{prefix}.{}", full_path.join(".")); + let capacity = prefix.len() + + path.iter().map(|segment| segment.len() + 1).sum::() + + key_text.len() + + 1; + let mut dot_key = String::with_capacity(capacity); + dot_key.push_str(prefix); + for segment in path.iter().copied().chain(std::iter::once(key_text)) { + dot_key.push('.'); + dot_key.push_str(segment); + } out.push(ConfigKeyMatch { key: dot_key, start: key_start, end: key_end, }); - collect_expr_declarations(kv.value, content, prefix, &full_path, out); + path.push(key_text); + collect_expr_declarations(kv.value, content, prefix, path, out); + path.pop(); } } @@ -179,9 +174,13 @@ 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 { @@ -189,15 +188,23 @@ pub(crate) fn find_config_references( // This re-parses the current (single) config file — acceptable. let prefix = laravel_config_prefix_from_uri(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 reference_key = + crate::reference_index::laravel_string_reference_key(target_kind, &target_key); + let snapshot = backend.user_file_symbol_maps_for_reference_keys(&[reference_key]); + let locations = find_all_config_references( + backend, + &target_kind, + &target_key, + &snapshot, + include_declaration, + ); if locations.is_empty() { return None; @@ -210,6 +217,25 @@ 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 { + resolve_config_key_declaration_inner(backend, key, true) +} + +/// Resolve an exact config entry without falling back to the owning file. +/// +/// Named resources use this path so a misspelled resource never jumps to +/// line zero of an otherwise-valid config file. +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 parts: Vec<&str> = key.split('.').collect(); let root = backend.workspace.workspace_root.read().clone()?; let config_dir = root.join("config"); @@ -229,30 +255,67 @@ pub(crate) fn resolve_config_key_declaration(backend: &Backend, key: &str) -> Op 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)); + return Some(config_declaration_location( + target_uri, + &target_content, + &decl, + )); } - return Some(crate::definition::point_location( - target_uri, - Position::new(0, 0), - )); + if allow_file_fallback { + return Some(crate::definition::point_location( + target_uri, + Position::new(0, 0), + )); + } } } let first_part = parts.first()?; - for res in &backend.laravel_provider_resources.read().config_files { + let provider_configs: Vec<_> = backend + .laravel_provider_resources + .read() + .config_files + .iter() + .filter(|resource| resource.namespace == *first_part) + .cloned() + .collect(); + for res in &provider_configs { 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)); + return Some(config_declaration_location( + target_uri, + &target_content, + &decl, + )); + } + if allow_file_fallback { + return Some(crate::definition::point_location( + target_uri, + Position::new(0, 0), + )); } - return Some(crate::definition::point_location( + } + } + + // Laravel's unpublished defaults are completion candidates, so an exact + // configured resource must navigate to the same framework declaration + // when the application and its providers do not override it. + let framework_path = root + .join("vendor/laravel/framework/config") + .join(format!("{first_part}.php")); + if framework_path.is_file() { + let target_uri = Url::from_file_path(&framework_path).ok()?; + let target_content = std::fs::read_to_string(&framework_path).ok()?; + let declarations = collect_laravel_config_declarations(&target_content, first_part); + if let Some(decl) = declarations.into_iter().find(|d| d.key == key) { + return Some(config_declaration_location( target_uri, - Position::new(0, 0), + &target_content, + &decl, )); } } @@ -260,18 +323,32 @@ pub(crate) fn resolve_config_key_declaration(backend: &Backend, key: &str) -> Op None } +fn config_declaration_location(uri: Url, content: &str, declaration: &ConfigKeyMatch) -> Location { + Location { + uri, + range: Range::new( + offset_to_position(content, declaration.start), + offset_to_position(content, declaration.end), + ), + } +} + /// Find all references for a Laravel config key across the project. /// /// Iterates pre-built [`SymbolKind::LaravelStringKey`] spans for usages /// (zero re-parses per file, same pattern as `find_member_references`). -/// Declaration lookup in `config/*.php` still uses an AST walk, but that -/// set is small (typically < 20 files) and each parse is cheap. +/// Declaration lookup parses only the config file that can own the canonical +/// key, independently of the usage-candidate snapshot. 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. @@ -285,13 +362,7 @@ pub(crate) fn find_all_config_references( None => continue, }; for span in &symbol_map.spans { - if let SymbolKind::LaravelStringKey { - kind: crate::symbol_map::LaravelStringKind::Config, - key, - .. - } = &span.kind - && key == target_key - { + 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,33 +372,67 @@ 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 { - continue; - } - 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); - } + let canonical_key = canonical_config_key(target_kind, target_key); + if let Some(declaration) = + resolve_config_key_declaration_exact(backend, canonical_key.as_ref()) + { + push_unique_location( + &mut locations, + &declaration.uri, + declaration.range.start, + declaration.range.end, + ); } } locations } +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 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 + && crate::symbol_map::laravel_resources::same_resource_name( + *left, 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<'a>(kind: &LaravelStringKind, key: &'a str) -> Cow<'a, str> { + match kind { + LaravelStringKind::ConfigResource(resource) => Cow::Owned( + crate::symbol_map::laravel_resources::config_key(*resource, key), + ), + _ => Cow::Borrowed(key), + } +} + /// Fallback for "go to definition" on a key inside config/*.php. /// /// Since array keys are not indexed in the symbol map, the generic @@ -433,4 +538,296 @@ return array_merge([ assert_eq!(decls[0].key, "app.name"); assert_eq!(decls[1].key, "app.env"); } + + #[test] + fn config_resource_keys_match_generic_config_keys_symmetrically() { + use crate::symbol_map::LaravelConfigResource::{CacheStore, StorageDisk}; + + let resource = LaravelStringKind::ConfigResource(CacheStore); + assert!(config_keys_match( + &resource, + "redis", + &LaravelStringKind::Config, + "cache.stores.redis", + )); + assert!(config_keys_match( + &LaravelStringKind::Config, + "cache.stores.redis", + &resource, + "redis", + )); + assert!(!config_keys_match( + &LaravelStringKind::ConfigResource(StorageDisk), + "redis", + &resource, + "redis", + )); + assert!(config_keys_match(&resource, "redis", &resource, "redis")); + assert!(!config_keys_match( + &resource, + "redis", + &LaravelStringKind::Config, + "cache.stores.redis.options", + )); + assert!(!config_keys_match( + &LaravelStringKind::View, + "redis", + &LaravelStringKind::Config, + "cache.stores.redis", + )); + let database = LaravelStringKind::ConfigResource( + crate::symbol_map::LaravelConfigResource::DatabaseConnection, + ); + assert!(config_keys_match( + &database, + "mysql::read", + &database, + "mysql::write", + )); + assert!(config_keys_match( + &database, + "mysql::direct", + &LaravelStringKind::Config, + "database.connections.mysql", + )); + assert!(!config_keys_match( + &LaravelStringKind::ConfigResource(CacheStore), + "null", + &LaravelStringKind::Config, + "cache.stores.null", + )); + } + + #[test] + fn config_reference_scan_links_short_and_canonical_spans() { + use crate::symbol_map::LaravelConfigResource::CacheStore; + + let dir = tempfile::tempdir().unwrap(); + let source_path = dir.path().join("usage.php"); + let source = "redis cache.stores.redis"; + std::fs::write(&source_path, source).unwrap(); + let uri = crate::util::path_to_uri(&source_path); + let backend = Backend::new_test(); + let map = Arc::new(SymbolMap { + spans: vec![ + SymbolSpan { + start: 0, + end: 5, + kind: SymbolKind::LaravelStringKey { + kind: LaravelStringKind::ConfigResource(CacheStore), + key: "redis".to_string(), + is_write: false, + is_optional: false, + }, + }, + SymbolSpan { + start: 6, + end: source.len() as u32, + kind: SymbolKind::LaravelStringKey { + kind: LaravelStringKind::Config, + key: "cache.stores.redis".to_string(), + is_write: false, + is_optional: false, + }, + }, + ], + ..SymbolMap::default() + }); + let snapshot = [(uri.to_string(), map)]; + + let resource = find_all_config_references( + &backend, + &LaravelStringKind::ConfigResource(CacheStore), + "redis", + &snapshot, + false, + ); + let generic = find_all_config_references( + &backend, + &LaravelStringKind::Config, + "cache.stores.redis", + &snapshot, + false, + ); + assert_eq!(resource, generic); + assert_eq!(resource.len(), 2); + assert_eq!( + resource[0].range, + Range::new(Position::new(0, 0), Position::new(0, 5)) + ); + assert_eq!( + resource[1].range, + Range::new(Position::new(0, 6), Position::new(0, source.len() as u32),) + ); + assert!( + find_all_config_references( + &backend, + &LaravelStringKind::View, + "redis", + &snapshot, + false, + ) + .is_empty() + ); + } + + #[test] + fn config_reference_entrypoint_accepts_a_resource_usage_span() { + use crate::symbol_map::LaravelConfigResource::CacheStore; + + let backend = Backend::new_test(); + backend + .workspace_indexed + .store(true, std::sync::atomic::Ordering::Release); + let uri = "file:///project/src/Consumer.php"; + let content = "redis"; + let map = Arc::new(SymbolMap { + spans: vec![SymbolSpan { + start: 0, + end: content.len() as u32, + kind: SymbolKind::LaravelStringKey { + kind: LaravelStringKind::ConfigResource(CacheStore), + key: content.to_string(), + is_write: false, + is_optional: false, + }, + }], + source_len: content.len() as u32, + ..SymbolMap::default() + }); + 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.reindex_references_for_symbol_maps_batch(vec![(uri.to_string(), map)]); + + let locations = find_config_references(&backend, uri, content, Position::new(0, 1), false) + .expect("resource usage should resolve its references"); + assert_eq!(locations.len(), 1); + assert_eq!(locations[0].uri.as_str(), uri); + } + + #[test] + fn exact_config_lookup_uses_app_then_framework_and_never_file_fallback() { + let dir = tempfile::tempdir().unwrap(); + let app_config = dir.path().join("config/cache.php"); + let stale_provider_config = dir.path().join("vendor/stale/config/cache.php"); + let provider_config = dir.path().join("vendor/package/config/cache.php"); + let second_provider_config = dir.path().join("vendor/other/config/cache.php"); + let framework_config = dir.path().join("vendor/laravel/framework/config/cache.php"); + std::fs::create_dir_all(app_config.parent().unwrap()).unwrap(); + std::fs::create_dir_all(provider_config.parent().unwrap()).unwrap(); + std::fs::create_dir_all(second_provider_config.parent().unwrap()).unwrap(); + std::fs::create_dir_all(framework_config.parent().unwrap()).unwrap(); + std::fs::write( + &app_config, + " ['tenant' => ['driver' => 'array']]];\n", + ) + .unwrap(); + std::fs::write( + &provider_config, + " ['package' => ['driver' => 'array']]];\n", + ) + .unwrap(); + std::fs::write( + &second_provider_config, + " ['second' => ['driver' => 'array']]];\n", + ) + .unwrap(); + std::fs::write( + &framework_config, + " ['redis' => ['driver' => 'redis']]];\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 + .extend([ + crate::virtual_members::laravel::ProviderResource { + path: stale_provider_config, + namespace: "cache".to_string(), + }, + crate::virtual_members::laravel::ProviderResource { + path: provider_config.clone(), + namespace: "cache".to_string(), + }, + crate::virtual_members::laravel::ProviderResource { + path: second_provider_config.clone(), + namespace: "cache".to_string(), + }, + ]); + + let app = resolve_config_key_declaration_exact(&backend, "cache.stores.tenant").unwrap(); + assert_eq!(app.uri, Url::from_file_path(app_config).unwrap()); + assert_ne!(app.range.start, app.range.end); + + let provider = + resolve_config_key_declaration_exact(&backend, "cache.stores.package").unwrap(); + assert_eq!(provider.uri, Url::from_file_path(provider_config).unwrap()); + + let second_provider = + resolve_config_key_declaration_exact(&backend, "cache.stores.second").unwrap(); + assert_eq!( + second_provider.uri, + Url::from_file_path(second_provider_config).unwrap() + ); + + let framework = + resolve_config_key_declaration_exact(&backend, "cache.stores.redis").unwrap(); + assert_eq!( + framework.uri, + Url::from_file_path(framework_config).unwrap() + ); + + assert!(resolve_config_key_declaration_exact(&backend, "cache.stores.missing").is_none()); + } + + #[test] + fn generic_config_lookup_can_fall_back_to_a_provider_file() { + let dir = tempfile::tempdir().unwrap(); + let provider_config = dir.path().join("vendor/package/resources/cache.php"); + let framework_config = dir.path().join("vendor/laravel/framework/config/cache.php"); + std::fs::create_dir_all(provider_config.parent().unwrap()).unwrap(); + std::fs::create_dir_all(framework_config.parent().unwrap()).unwrap(); + std::fs::write( + &provider_config, + " ['shared' => ['driver' => 'array']]];\n", + ) + .unwrap(); + std::fs::write( + &framework_config, + " ['shared' => ['driver' => 'redis']]];\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: provider_config.clone(), + namespace: "cache".to_string(), + }); + + let exact = resolve_config_key_declaration_exact(&backend, "cache.stores.shared").unwrap(); + assert_eq!(exact.uri, Url::from_file_path(&provider_config).unwrap()); + assert!(resolve_config_key_declaration_exact(&backend, "cache.stores.missing").is_none()); + + let fallback = resolve_config_key_declaration(&backend, "cache.stores.missing").unwrap(); + assert_eq!(fallback.uri, Url::from_file_path(provider_config).unwrap()); + assert_eq!( + fallback.range, + Range::new(Position::new(0, 0), Position::new(0, 0)) + ); + } } diff --git a/src/virtual_members/laravel/mod.rs b/src/virtual_members/laravel/mod.rs index 2d510abaf..9ab8a3050 100644 --- a/src/virtual_members/laravel/mod.rs +++ b/src/virtual_members/laravel/mod.rs @@ -150,7 +150,7 @@ pub(crate) use config_keys::find_config_references; pub(crate) use config_keys::{ collect_laravel_config_declarations, find_all_config_references, laravel_config_prefix_from_uri, resolve_config_key_declaration, - resolve_config_key_definition_fallback, + resolve_config_key_declaration_exact, resolve_config_key_definition_fallback, }; pub(crate) use const_eval::ClassContext; pub(crate) use env_vars::resolve_env_definition; @@ -171,6 +171,8 @@ pub(crate) use patches::STORAGE_FACADE_FQN; pub(crate) use path_helpers::{ collect_path_helper_links, is_path_helper, path_helper_base, resolve_path_helper_definition, }; +#[cfg(test)] +pub(crate) use provider_resources::ProviderResource; pub(crate) use provider_resources::{ ProviderIdentity, ProviderOrigin, ProviderResources, ProviderScan, ProviderScans, extract_provider_resources, diff --git a/src/virtual_members/laravel/string_keys.rs b/src/virtual_members/laravel/string_keys.rs index 8cd7e3588..263912a2c 100644 --- a/src/virtual_members/laravel/string_keys.rs +++ b/src/virtual_members/laravel/string_keys.rs @@ -6,7 +6,10 @@ //! these as [`crate::symbol_map::LaravelStringKey`] spans; this module turns //! a span (kind + key) into concrete definition/reference [`Location`]s. -use super::{find_all_config_references, resolve_config_key_declaration}; +use super::{ + find_all_config_references, resolve_config_key_declaration, + resolve_config_key_declaration_exact, +}; use super::{route_names, trans_keys, view_names}; use tower_lsp::lsp_types::Location; @@ -36,9 +39,23 @@ pub(crate) fn resolve_laravel_string_key( LaravelStringKind::Stack => { 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 crate::symbol_map::laravel_resources::resource_from_config_key(key).is_some() { + resolve_config_key_declaration_exact(backend, key) + .into_iter() + .collect() + } 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); + resolve_config_key_declaration_exact(backend, &config_key) + .into_iter() + .collect() + } 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), @@ -210,8 +227,8 @@ 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(_) => { + find_all_config_references(backend, kind, key, snapshot, include_declaration) } // Two unrelated pages that both fill `content` fill two different // sections, so the span index's project-wide answer is the wrong @@ -241,7 +258,7 @@ pub(crate) fn find_laravel_string_key_references( } }; - 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, @@ -326,3 +343,61 @@ fn find_string_key_usages( } locations } + +#[cfg(test)] +mod tests { + use super::*; + use crate::symbol_map::{LaravelConfigResource, LaravelStringKind}; + + #[test] + fn configured_resources_resolve_exact_entries_without_file_fallback() { + let dir = tempfile::tempdir().unwrap(); + let config = dir.path().join("config/cache.php"); + std::fs::create_dir_all(config.parent().unwrap()).unwrap(); + std::fs::write( + &config, + " ['redis' => ['driver' => 'redis']]];\n", + ) + .unwrap(); + + let backend = crate::Backend::new_test(); + *backend.workspace.workspace_root.write() = Some(dir.path().to_path_buf()); + let usage_uri = "file:///project/usage.php"; + let resource_kind = LaravelStringKind::ConfigResource(LaravelConfigResource::CacheStore); + + let short = resolve_laravel_string_key(&backend, &resource_kind, "redis", usage_uri); + let full = resolve_laravel_string_key( + &backend, + &LaravelStringKind::Config, + "cache.stores.redis", + usage_uri, + ); + assert_eq!(short, full); + assert_eq!(short.len(), 1); + + assert!( + resolve_laravel_string_key(&backend, &resource_kind, "missing", usage_uri).is_empty() + ); + assert!( + resolve_laravel_string_key( + &backend, + &LaravelStringKind::Config, + "cache.stores.missing", + usage_uri, + ) + .is_empty() + ); + + let generic = resolve_laravel_string_key( + &backend, + &LaravelStringKind::Config, + "cache.unlisted", + usage_uri, + ); + assert_eq!(generic.len(), 1); + assert_eq!( + generic[0].range.start, + tower_lsp::lsp_types::Position::new(0, 0) + ); + } +} diff --git a/tests/integration/laravel_named_resources.rs b/tests/integration/laravel_named_resources.rs new file mode 100644 index 000000000..d5498bedd --- /dev/null +++ b/tests/integration/laravel_named_resources.rs @@ -0,0 +1,1122 @@ +//! End-to-end coverage for direct Laravel config-backed resource 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 AUTH_CONFIG: &str = " ['web' => [], 'admin' => []]];\n"; +const CACHE_CONFIG: &str = " ['array' => [], 'redis' => []]];\n"; +const LOGGING_CONFIG: &str = " ['daily' => [], 'slack' => []]];\n"; +const FILESYSTEMS_CONFIG: &str = " ['local' => [], 'archive' => []]];\n"; +const DATABASE_CONFIG: &str = " ['mysql' => [], 'sqlite' => []]];\n"; +const QUEUE_CONFIG: &str = " ['sync' => [], 'redis' => []]];\n"; +const MAIL_CONFIG: &str = " ['smtp' => [], 'log' => []]];\n"; +const BROADCASTING_CONFIG: &str = + " ['reverb' => [], 'log' => []]];\n"; + +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]; + 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, + ) +} + +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 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 text_in_range(content: &str, range: Range) -> &str { + let start = content + .split_inclusive('\n') + .take(range.start.line as usize) + .map(str::len) + .sum::() + + range.start.character as usize; + let end = content + .split_inclusive('\n') + .take(range.end.line as usize) + .map(str::len) + .sum::() + + range.end.character as usize; + &content[start..end] +} + +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 hover_text(hover: Hover) -> String { + match hover.contents { + HoverContents::Markup(markup) => markup.value, + 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"), + } +} + +#[tokio::test] +async fn every_direct_resource_context_completes_only_direct_config_children() { + let source = r#">(), + *expected, + "completion at `{prefix}`" + ); + assert!( + items + .iter() + .all(|item| item.kind == Some(CompletionItemKind::PROPERTY)) + ); + } + + let middleware = completion_items( + &backend, + &uri, + position_after(source, "middleware('auth:web, a"), + ) + .await; + let Some(CompletionTextEdit::Edit(edit)) = &middleware[0].text_edit else { + panic!("middleware completion should carry an exact text edit"); + }; + assert_eq!(text_in_range(source, edit.range), " a"); + assert_eq!(edit.new_text, "admin"); +} + +#[tokio::test] +async fn completion_uses_semantic_aliases_and_rejects_wrong_shapes_and_homonyms() { + let source = r#"middleware('auth:a'); } +} + +LaravelCache::store(name: 'r'); +LaravelLog::stack(channels: ['s']); +LaravelRoute::middleware(middleware: 'auth:a'); +LaravelRoute::get('/', fn () => null)->middleware('auth:w'); +LaravelRoute::get('/')->name('home')->middleware('auth:w'); +LaravelRoute::get('/') + ->name('multiline') + ->middleware('auth:a'); +LaravelRoute::get('/', function () { return 1; })->middleware('auth:w'); +LaravelRoute::get('/', /* ) ] } */ fn () => null) // keep chaining + ->middleware('auth:w'); +LaravelRoute::get('/')->a()->b()->c()->d()->middleware('auth:w'); +factory(LaravelRoute::class)->middleware('auth:w'); +\Illuminate\Support\Facades\Cache::store('a'); +\Cache::store('r'); +\Route::middleware('auth:a'); +class AttributeTarget { + public function __construct( + #[CacheAttribute(store: 'r')] mixed $named, + #[\Deprecated('#['), CacheAttribute('r')] mixed $grouped, + #[ CacheAttribute('a')] mixed $spaced, + ) {} +} + +Cache::store('r'); +Route::middleware('auth:a'); +LaravelLog::stack('s'); +LaravelLog::stack(['key' => 'daily']); +LaravelCache::store(store: 'r'); +LaravelCache::store(NAME: 'r'); +LaravelCache::store('array', 'r'); +class LocalTarget { + public function __construct(#[Cache('r')] mixed $cache) {} +} +"#; + let (backend, _dir, uri) = open_workspace(source).await; + + for (prefix, expected) in [ + ("LaravelCache::store(name: 'r", vec!["redis"]), + ("LaravelLog::stack(channels: ['s", vec!["slack"]), + ("middleware(middleware: 'auth:a", vec!["admin"]), + ("get('/', fn () => null)->middleware('auth:w", vec!["web"]), + ("name('home')->middleware('auth:w", vec!["web"]), + ("name('multiline')\n ->middleware('auth:a", vec!["admin"]), + ("return 1; })->middleware('auth:w", vec!["web"]), + ("keep chaining\n ->middleware('auth:w", vec!["web"]), + ("Facades\\Cache::store('a", vec!["array"]), + ("\\Cache::store('r", vec!["redis"]), + ("\\Route::middleware('auth:a", vec!["admin"]), + ("CacheAttribute(store: 'r", vec!["redis"]), + ("Deprecated('#['), CacheAttribute('r", vec!["redis"]), + ("#[ CacheAttribute('a", vec!["array"]), + ] { + 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('r", + "\nRoute::middleware('auth:a", + "factory(LaravelRoute::class)->middleware('auth:w", + "a()->b()->c()->d()->middleware('auth:w", + "LaravelLog::stack('s", + "LaravelLog::stack(['key", + "LaravelCache::store(store: 'r", + "LaravelCache::store(NAME: 'r", + "LaravelCache::store('array', 'r", + "#[Cache('r", + "$this->middleware('auth:a", + ] { + let labels = completion_items(&backend, &uri, position_after(source, prefix)).await; + assert!( + labels + .iter() + .all(|item| !["admin", "array", "daily", "redis", "slack", "web"] + .contains(&item.label.as_str())), + "invalid context `{prefix}` offered resource names: {labels:?}" + ); + } +} + +#[tokio::test] +async fn auth_helper_completion_respects_php_namespace_fallback_and_function_homonyms() { + let global_fallback = r#">(); + assert_eq!(labels, ["admin", "web"]); + + let fully_qualified = r#">(); + assert_eq!(labels, ["admin", "web"]); + + let imported_global_alias = r#">(); + assert_eq!(labels, ["admin", "web"]); + + for source in [ + r#">(); + assert_eq!(labels, expected, "completion at `{prefix}`"); + } +} + +#[tokio::test] +async fn a_cross_file_namespaced_auth_helper_shadows_laravels_global_fallback() { + let source = r#">(); + 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; + let position = position_after(source, "auth('we"); + + let labels = completion_items(&backend, &uri, position) + .await + .into_iter() + .map(|item| item.label) + .collect::>(); + assert!( + labels + .iter() + .all(|label| label != "web" && label != "admin"), + "project auth() helper offered Laravel guards: {labels:?}" + ); + assert!( + backend + .handle_hover(uri.as_str(), source, position) + .is_none() + ); + assert!( + 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") + .is_none() + ); + assert!( + backend + .find_references(uri.as_str(), source, position, true) + .is_none() + ); +} + +#[tokio::test] +async fn real_global_classes_shadow_laravels_optional_facade_aliases() { + let source = r#">(); + 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; + + for prefix in ["\\Cache::store('red", "\\Route::middleware('auth:we"] { + let position = position_after(source, prefix); + let labels = completion_items(&backend, &uri, position).await; + assert!( + labels + .iter() + .all(|item| !["admin", "redis", "web"].contains(&item.label.as_str())), + "global facade homonym offered Laravel resources: {labels:?}" + ); + assert!( + backend + .handle_hover(uri.as_str(), source, position) + .is_none() + ); + assert!( + backend + .find_references(uri.as_str(), source, position, true) + .is_none() + ); + } + + let mut diagnostics = Vec::new(); + backend.collect_slow_diagnostics(uri.as_str(), source, &mut diagnostics); + assert!(diagnostics.iter().all(|diagnostic| { + !matches!( + &diagnostic.code, + Some(NumberOrString::String(code)) if code.starts_with("invalid_laravel_") + ) + })); +} + +#[tokio::test] +async fn family_hovers_and_diagnostics_use_specific_labels_codes_and_ranges() { + let source = r#" { + Some((code.as_str(), text_in_range(source, diagnostic.range))) + } + _ => None, + }) + .collect::>(); + + assert_eq!(invalid.len(), 8, "diagnostics: {invalid:#?}"); + + for (code, payload) in [ + ("invalid_laravel_auth_guard", " missing-auth"), + ("invalid_laravel_cache_store", "missing-cache"), + ("invalid_laravel_log_channel", "missing-log"), + ("invalid_laravel_storage_disk", "missing-disk"), + ("invalid_laravel_database_connection", "missing-database"), + ("invalid_laravel_queue_connection", "missing-queue"), + ("invalid_laravel_mailer", "missing-mailer"), + ("invalid_laravel_broadcast_connection", "missing-broadcast"), + ] { + assert_eq!( + invalid.iter().find_map(|(actual_code, actual_payload)| { + (*actual_code == code).then_some(actual_payload) + }), + Some(&payload), + "diagnostics: {invalid:#?}" + ); + } +} + +#[tokio::test] +async fn parameter_attributes_share_completion_navigation_diagnostics_and_references() { + let source = r#">(); + assert_eq!(labels, ["redis"]); + + let hover = backend + .handle_hover(uri.as_str(), source, valid) + .expect("parameter attribute should hover as a cache store"); + assert!(hover_text(hover).contains("**Cache store** `redis`")); + + let definition = backend + .goto_definition(GotoDefinitionParams { + text_document_position_params: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri: uri.clone() }, + position: valid, + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + }) + .await + .expect("definition request should succeed") + .expect("parameter attribute should navigate"); + let definition = definition_location(definition); + assert!(definition.uri.path().ends_with("/config/cache.php")); + assert_eq!( + definition.range.start, + position_of_config_key(CACHE_CONFIG, "redis") + ); + + let references = backend + .find_references(uri.as_str(), source, valid, true) + .expect("parameter attribute should share config 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() + .find(|diagnostic| { + matches!( + &diagnostic.code, + Some(NumberOrString::String(code)) if code == "invalid_laravel_cache_store" + ) + }) + .expect("invalid parameter attribute should be diagnosed"); + assert_eq!(text_in_range(source, invalid.range), "missing-store"); +} + +#[tokio::test] +async fn database_roles_and_runtime_null_drivers_follow_laravel_semantics() { + let source = r#">(); + assert_eq!(labels, ["mysql::read", "mysql::write", "mysql::direct"]); + + let read = position_after(source, "DB::connection('mysql::rea"); + let hover = backend + .handle_hover(uri.as_str(), source, read) + .expect("role-qualified database connection should hover"); + assert!(hover_text(hover).contains("**Database connection** `mysql::read`")); + let definition = backend + .goto_definition(GotoDefinitionParams { + text_document_position_params: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri: uri.clone() }, + position: read, + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + }) + .await + .expect("definition request should succeed") + .expect("role-qualified database connection should navigate"); + assert_eq!( + definition_location(definition).range.start, + position_of_config_key(DATABASE_CONFIG, "mysql") + ); + + let references = backend + .find_references(uri.as_str(), source, read, true) + .expect("all database roles should share one config identity"); + assert_eq!(references.len(), 5, "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.starts_with("invalid_laravel_") + ) + }) + .collect::>(); + assert_eq!(invalid.len(), 1, "diagnostics: {invalid:#?}"); + assert_eq!(text_in_range(source, invalid[0].range), "mysql::replica"); + + for marker in ["Cache::store('nul", "Queue::connection('nul"] { + let position = position_after(source, marker); + let references = backend + .find_references(uri.as_str(), source, position, true) + .expect("implicit null driver should retain direct references"); + assert_eq!(references.len(), 1, "references at `{marker}`"); + } +} + +#[tokio::test] +async fn generic_config_diagnostics_accept_only_exact_segment_prefixes() { + let source = " ['from' => 'team@example.com']];\n", + ), + ("app/NamedResourceConsumer.php", source), + ], + ); + 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; + + 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_config" + ) + }) + .collect::>(); + assert_eq!(invalid.len(), 1, "diagnostics: {invalid:#?}"); + assert_eq!(text_in_range(source, invalid[0].range), "app.ma"); +} + +#[tokio::test] +async fn an_undiscovered_resource_subtree_is_not_treated_as_a_closed_empty_set() { + let source = r#" 'array'];\n"), + ("app/NamedResourceConsumer.php", source), + ], + ); + 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; + + let mut diagnostics = Vec::new(); + backend.collect_slow_diagnostics(uri.as_str(), source, &mut diagnostics); + assert!(diagnostics.iter().all(|diagnostic| { + !matches!( + &diagnostic.code, + Some(NumberOrString::String(code)) if code == "invalid_laravel_cache_store" + ) + })); +} + +#[tokio::test] +async fn direct_and_generic_config_spellings_share_one_reference_set() { + let source = r#">(); - assert_eq!(invalid_config.len(), 2, "got: {invalid_config:#?}"); + assert_eq!(invalid_disks.len(), 2, "got: {invalid_disks:#?}"); - let messages = invalid_config + let messages = invalid_disks .iter() .map(|diagnostic| diagnostic.message.as_str()) .collect::>(); assert!( messages .iter() - .any(|message| message.contains("filesystems.disks.missing-disk")) + .any(|message| message.contains("storage disk: 'missing-disk'")) ); assert!( messages .iter() - .any(|message| message.contains("filesystems.disks.missing-attribute")) + .any(|message| message.contains("storage disk: 'missing-attribute'")) ); for optional_or_written in [ "testing", diff --git a/tests/integration/laravel_string_key_non_laravel_gate.rs b/tests/integration/laravel_string_key_non_laravel_gate.rs index df63a7769..da22b353a 100644 --- a/tests/integration/laravel_string_key_non_laravel_gate.rs +++ b/tests/integration/laravel_string_key_non_laravel_gate.rs @@ -28,6 +28,15 @@ return [ ]; "; +const FILESYSTEMS_CONFIG: &str = "\ + [ + 'archive' => ['driver' => 'local'], + ], +]; +"; + const CONSUMER: &str = "\ (phpantom_lsp::Backend, tempfile::TempDir, Url) { let (backend, dir) = create_psr4_workspace( composer_json, @@ -65,6 +85,45 @@ async fn setup(composer_json: &str) -> (phpantom_lsp::Backend, tempfile::TempDir (backend, dir, uri) } +async fn setup_storage_homonym() -> (phpantom_lsp::Backend, tempfile::TempDir, Url) { + let (backend, dir) = create_psr4_workspace( + COMPOSER_JSON_NON_LARAVEL, + &[ + ("config/filesystems.php", FILESYSTEMS_CONFIG), + ("src/StorageConsumer.php", STORAGE_HOMONYM_CONSUMER), + ], + ); + backend.initialized(InitializedParams {}).await; + + let uri = Url::from_file_path(dir.path().join("src/StorageConsumer.php")).unwrap(); + backend + .did_open(DidOpenTextDocumentParams { + text_document: TextDocumentItem { + uri: uri.clone(), + language_id: "php".to_string(), + version: 1, + text: STORAGE_HOMONYM_CONSUMER.to_string(), + }, + }) + .await; + + (backend, dir, uri) +} + +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]; + 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, + ) +} + /// Lands inside the `'app.name'` string literal on the first `config()` /// call (line 5, 0-based) in `CONSUMER`. const KEY_POSITION: Position = Position { @@ -72,6 +131,85 @@ const KEY_POSITION: Position = Position { character: 20, }; +#[tokio::test] +async fn non_laravel_direct_resource_homonym_has_no_laravel_editor_features() { + let (backend, _dir, uri) = setup_storage_homonym().await; + let position = position_after(STORAGE_HOMONYM_CONSUMER, "Storage::disk('arch"); + + let completion = 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 + .unwrap(); + let completion_items = match completion { + Some(CompletionResponse::Array(items)) => items, + Some(CompletionResponse::List(list)) => list.items, + None => Vec::new(), + }; + assert!( + completion_items.iter().all(|item| item.label != "archive"), + "a non-Laravel Storage homonym must not complete configured disks, got {completion_items:?}" + ); + + let hover = backend + .hover(HoverParams { + text_document_position_params: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri: uri.clone() }, + position, + }, + work_done_progress_params: WorkDoneProgressParams::default(), + }) + .await + .unwrap(); + assert!( + hover.is_none(), + "a non-Laravel Storage homonym must not hover as a storage disk, got {hover:?}" + ); + + let definition = 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 + .unwrap(); + assert!( + definition.is_none(), + "a non-Laravel Storage homonym must not jump to filesystems.php, got {definition:?}" + ); + + let references = backend + .references(ReferenceParams { + text_document_position: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri }, + position, + }, + work_done_progress_params: WorkDoneProgressParams::default(), + partial_result_params: PartialResultParams::default(), + context: ReferenceContext { + include_declaration: true, + }, + }) + .await + .unwrap() + .unwrap_or_default(); + assert!( + references.is_empty(), + "a non-Laravel Storage homonym must not fabricate disk references, got {references:?}" + ); +} + #[tokio::test] async fn non_laravel_project_hover_does_not_fabricate_config_key() { let (backend, _dir, uri) = setup(COMPOSER_JSON_NON_LARAVEL).await; diff --git a/tests/integration/main.rs b/tests/integration/main.rs index aa07f082c..b181a4455 100644 --- a/tests/integration/main.rs +++ b/tests/integration/main.rs @@ -165,6 +165,7 @@ mod laravel_gates; mod laravel_macro_facade; mod laravel_macros; mod laravel_morph_map; +mod laravel_named_resources; mod laravel_path_helpers; mod laravel_provider_refresh; mod laravel_references; From fea27da210d2819d2b6c4cf58a0102eede346c42 Mon Sep 17 00:00:00 2001 From: Shuvro Roy Date: Thu, 3 Sep 2026 23:24:55 +0600 Subject: [PATCH 4/5] test: cover Laravel static string key contexts --- src/completion/laravel_string_keys.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/completion/laravel_string_keys.rs b/src/completion/laravel_string_keys.rs index 8b7a45449..3f81e5e09 100644 --- a/src/completion/laravel_string_keys.rs +++ b/src/completion/laravel_string_keys.rs @@ -1555,6 +1555,23 @@ mod tests { assert_eq!(ctx.prefix, "app."); } + #[test] + fn detects_static_config_mutators_and_translation_queries() { + for method in ["prepend", "push"] { + let content = format!(" Date: Mon, 7 Sep 2026 11:11:35 +0600 Subject: [PATCH 5/5] chore: add tests for Laravel config keys and storage aliases --- src/virtual_members/laravel/config_keys.rs | 38 +++++ .../integration/laravel_storage_disk_names.rs | 146 ++++++++++++++++++ 2 files changed, 184 insertions(+) diff --git a/src/virtual_members/laravel/config_keys.rs b/src/virtual_members/laravel/config_keys.rs index 0312acbc8..a061afad3 100644 --- a/src/virtual_members/laravel/config_keys.rs +++ b/src/virtual_members/laravel/config_keys.rs @@ -588,6 +588,44 @@ mod tests { ); } + #[test] + fn vendor_runtime_writes_do_not_hide_application_writes_in_the_same_batch() { + let backend = Backend::new_test(); + backend.resolved_class_cache.write().set_laravel(true); + backend + .workspace + .vendor_uri_prefixes + .lock() + .push("file:///project/vendor/".to_string()); + let vendor_uri = "file:///project/vendor/package/Fixture.php"; + let app_uri = "file:///project/app/Fixture.php"; + let vendor = backend.parse_ast_index_update_for_index( + vendor_uri, + "