From b122985615abae97b248b4349ab09ebf0f638a40 Mon Sep 17 00:00:00 2001 From: Caleb White Date: Sat, 11 Jul 2026 19:50:33 -0500 Subject: [PATCH] feat: Laravel string key completion, hover, and diagnostics Add autocompletion, hover, and invalid-key diagnostics for route names, config keys, view names, and translation keys. Completion: - route('|') / to_route('|') -> route names from routes/*.php - config('|') / Config::get('|') -> config keys from config/*.php - view('|') / View::make('|') -> view templates from resources/views/ - __('|') / trans('|') / Lang::get('|') -> translation keys from lang/ - Route::resource() / apiResource() with ->only() / ->except() - Route::group([], __DIR__ . '/sub.php') file includes with prefix - Container attributes (#[Config], #[Database], #[Cache], #[Log], #[Storage], #[Auth]) with FQN-verified imports - Facade methods (Auth::guard(), DB::connection(), Cache::store(), Log::channel(), Storage::disk()) and auth() helper - TextEdit-based so dots don't break the completion popup Hover: - Shows key kind (Route/Config/View/Trans), the key value, and the file where it's defined Diagnostics: - Warns on unknown route names, config keys, view names, and translation keys (e.g. Unknown route: 'dashbaord') - Only flags plain string literals, not dynamic/interpolated keys Also adds to_route() to extraction spans for go-to-def/references. --- docs/CHANGELOG.md | 3 + docs/todo/laravel.md | 49 -- src/completion/handler.rs | 19 +- src/completion/laravel_string_keys.rs | 796 +++++++++++++++++++++ src/completion/mod.rs | 1 + src/diagnostics/mod.rs | 202 ++++++ src/hover/mod.rs | 89 ++- src/lib.rs | 33 + src/parser/ast_update.rs | 4 + src/symbol_map/extraction.rs | 119 ++- src/virtual_members/laravel/mod.rs | 5 +- src/virtual_members/laravel/route_names.rs | 519 +++++++++++++- src/virtual_members/laravel/trans_keys.rs | 61 +- 13 files changed, 1765 insertions(+), 135 deletions(-) create mode 100644 src/completion/laravel_string_keys.rs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 31d7d1fc..9edf5588 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -31,6 +31,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Container string aliases and global facades resolve.** `resolve('blade.compiler')` and `app('cache')` resolve to the concrete class Laravel binds the string to, so member access on the result completes, navigates, and type-checks. Bare global facade aliases such as `\App` and `\DB` resolve to their facade class without an explicit import. Both alias tables are read by parsing the framework the project actually has installed (never a version-specific list baked into PHPantom), so a name that only a service provider registers stays unresolved rather than being guessed. A project class whose short name collides with a facade alias (e.g. an app's own `Request` in the current namespace) still wins, since the alias table is only consulted after namespace-aware resolution misses. - **`model-property` pseudo-type recognition.** The Larastan `model-property` type no longer triggers "unknown class" diagnostics. It is treated as a string subtype. - **`compact()` strings are linked to local variables.** A string argument to `compact('user')` is now treated as a reference to the matching local variable. Renaming the variable updates the string (and renaming from the string updates the variable and its other uses), find-references includes the string, and go-to-definition on the string jumps to the variable's assignment. Contributed by @calebdw in https://github.com/PHPantom-dev/phpantom_lsp/pull/159. +- **Laravel string key completion (route, config, view, trans).** Typing inside the first string argument of `route()`, `to_route()`, `config()`, `Config::get()`, `view()`, `View::make()`, `__()`, `trans()`, `Lang::get()`, and related helpers now offers autocompletion from the project's actual route names, config keys, view templates, and translation keys. Route names are collected from `routes/*.php` (including group prefixes and `Route::group([], __DIR__ . '/sub.php')` file includes), `Route::resource()` and `Route::apiResource()` generate conventional named routes (`index`, `create`, `store`, `show`, `edit`, `update`, `destroy`) respecting `->only()` and `->except()` modifiers, config keys from `config/*.php` array declarations, view names from `resources/views/` file paths, and translation keys from `lang/` files. Go-to-definition on route names also follows file includes and resolves resource routes. Laravel container attributes (`#[Config('key')]`, `#[Database('conn')]`, `#[Cache('store')]`, `#[Log('channel')]`, `#[Storage('disk')]`, `#[Auth('guard')]`) offer completion from the relevant config sub-keys (e.g. `#[Database('')]` shows database connection names from `config/database.php`). Facade methods like `Auth::guard()`, `DB::connection()`, `Cache::store()`, `Log::channel()`, `Storage::disk()`, and the `auth()` helper also complete from their respective config sub-keys. Contributed by @calebdw. +- **Hover for Laravel string keys.** Hovering over a route name, config key, view name, or translation key string now shows the key kind, the key value, and where it's defined (e.g. `routes/web/ems.php`, `config/app.php`). Contributed by @calebdw. +- **Diagnostics for invalid Laravel string keys.** A typo in `route('dashbaord')`, `config('app.naem')`, `view('layouts.ap')`, or `__('auth.failedd')` now produces a warning: `Unknown route: 'dashbaord'`. Only plain string literals are flagged; dynamic keys with variables are ignored. Contributed by @calebdw. - **Imported and same-namespace symbols rank first in completion.** Classes, functions, and constants that are already imported via a `use` statement or live in the same namespace now always appear above non-imported symbols in the completion list, regardless of dependency provenance. Previously a non-imported project class could outrank an already-imported vendor class, forcing users to scroll past irrelevant results. Contributed by @calebdw. - **Laravel route controller method navigation and completion.** Method-name strings inside `Route::controller(X::class)->group(fn(){โ€ฆ})` closures now resolve as references to the controller's methods. Go-to-definition, find-references, rename, hover, and diagnostics all work on the action string (e.g. `Route::patch('cancel', 'cancel')` resolves `'cancel'` to `WorkItemController::cancel()`). Autocompletion inside the action string offers the controller's methods. Handles `->controller()` anywhere in the fluent chain, chained route calls (`->name()`, etc.), and nested groups where an inner `->controller()` shadows the outer one. Contributed by @calebdw. - **Package provenance displayed in hover.** Hovering over a class, method, property, constant, or function now shows a colored badge indicating where the symbol comes from: ๐ŸŸข for direct Composer dependencies (e.g. `laravel/framework`), ๐ŸŸ  for transitive dependencies with an italic *(transitive)* marker, and ๐ŸŸฃ for PHP core/extension symbols. Project-local symbols show no badge. The package name is resolved from `vendor/composer/installed.json`. Closes #228. Contributed by @calebdw. diff --git a/docs/todo/laravel.md b/docs/todo/laravel.md index 0980abbc..4f85185e 100644 --- a/docs/todo/laravel.md +++ b/docs/todo/laravel.md @@ -589,55 +589,6 @@ The scanners already enumerate every valid key for go-to-definition. The items below mostly wire that existing enumeration into three more LSP endpoints rather than building new analysis. -#### L14. Diagnostics for Laravel string keys - -**Impact: High ยท Effort: Medium** - -No Laravel string key is currently validated. A typo in `route('dashbaord')`, -`config('app.naem')`, `env('DB_CONNCTION')`, `__('auth.failedd')`, or -`view('layouts.ap')` produces no warning โ€” the bug surfaces only at runtime. -This is the single highest-value gap, because it catches a class of error -that PHP itself never reports. - -Emit a warning when a string argument in a recognised context resolves to no -declaration. The candidate set is the same one the go-to-definition scanners -already build, so the diagnostic is "key not in the collected set." Each -construct reuses its existing scanner: - -- `route('name')` โ†’ not in collected `->name()` declarations. -- `config('a.b.c')` โ†’ not in flattened `config/*.php` keys. -- `env('KEY')` โ†’ not in `.env` / `.env.example`. -- `__()`/`trans()`/`trans_choice()` โ†’ not in `lang/**` keys. -- `view('a.b')` โ†’ no matching file under `resources/views/`. - -Pair each with a quick-fix where cheap: "create missing view file," -"add missing key to `.env` (copy from `.env.example`)" (mirrors the -extension's two quick-fixes). Guard against false positives from genuinely -dynamic keys (e.g. `config($key)` with a variable, or -`route("admin.$section")` interpolation) by only flagging plain string -literals. - -#### L15. Completion for Laravel string keys - -**Impact: High ยท Effort: Medium** - -Completion exists only for Eloquent relations/columns. Extend it to offer -route names, config keys (dot-notation drill-down), env var names, -translation keys, and view names inside the corresponding string contexts. -The candidate lists are exactly the declaration sets the go-to scanners -already produce; the work is detecting the string-literal cursor context -(the symbol-map already records these as `LaravelStringKey`) and returning -the collected keys as completion items. - -#### L16. Hover for Laravel string keys - -**Impact: Medium ยท Effort: Low-Medium** - -`SymbolKind::LaravelStringKey` currently returns `None` from hover -(`hover/mod.rs`). Show the resolved target: the config/env/translation -*value*, the route's URI + action, or the view's file path. The data is -already loaded by the go-to scanners; this is formatting it as hover markdown. - #### L17. Additional string contexts without booting **Impact: Medium ยท Effort: Medium** diff --git a/src/completion/handler.rs b/src/completion/handler.rs index 8bab77b4..e6ef870c 100644 --- a/src/completion/handler.rs +++ b/src/completion/handler.rs @@ -359,7 +359,6 @@ impl Backend { let string_ctx = crate::completion::comment_position::classify_string_context(&content, position); use crate::completion::comment_position::StringContext; - // โ”€โ”€ Array shape key completion โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // Runs before `InStringLiteral` suppression because in // normal code `$arr['` puts the scanner inside a @@ -373,6 +372,24 @@ impl Backend { return Ok(Some(response)); } + // โ”€โ”€ Laravel string key completion (route/config/view/trans) โ”€โ”€ + // Inside `route('|')`, `config('|')`, `view('|')`, `__('|')`, + // etc., offer matching key names from the project. + // NB: `is_laravel` is extracted to a `let` so the read lock + // on `resolved_class_cache` is dropped before calling + // `try_laravel_string_key_completion`, which may trigger + // `ensure_workspace_indexed` โ†’ `update_ast` โ†’ write lock. + let is_laravel = self.resolved_class_cache.read().is_laravel(); + if is_laravel + && matches!( + string_ctx, + StringContext::InStringLiteral | StringContext::NotInString + ) + && let Some(response) = self.try_laravel_string_key_completion(&content, position) + { + return Ok(Some(response)); + } + // โ”€โ”€ Eloquent relation/column string completion โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ // Like array shape completion, this triggers inside string // literals where the cursor is in a method argument position diff --git a/src/completion/laravel_string_keys.rs b/src/completion/laravel_string_keys.rs new file mode 100644 index 00000000..5528cd37 --- /dev/null +++ b/src/completion/laravel_string_keys.rs @@ -0,0 +1,796 @@ +//! Laravel string key completion. +//! +//! Offers autocompletion for route names, config keys, view names, and +//! translation keys inside their respective helper calls: +//! +//! - `route('|')` / `to_route('|')` โ†’ route names +//! - `config('|')` / `Config::get('|')` โ†’ config keys +//! - `view('|')` / `View::make('|')` โ†’ view names +//! - `__('|')` / `trans('|')` / `Lang::get('|')` โ†’ translation keys + +use tower_lsp::lsp_types::*; + +use crate::Backend; +use crate::symbol_map::LaravelStringKind; +use crate::util::position_to_offset; + +// โ”€โ”€โ”€ Context โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +struct LaravelStringKeyContext { + kind: LaravelStringKind, + prefix: String, + /// Byte offset of the string content start (right after the opening quote). + content_start_offset: usize, + /// When set, the key is a sub-key under this config path prefix. + /// For example, `#[Database('mysql')]` sets this to `"database.connections."` + /// so completion filters to `database.connections.*` keys and strips the + /// prefix, showing just `mysql`, `sqlite`, etc. + config_sub_prefix: Option<&'static str>, +} + +// โ”€โ”€โ”€ Detection โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +/// Detect if the cursor is inside the first string argument of a Laravel +/// helper function. Returns the key kind and the prefix typed so far. +fn detect_laravel_string_key_context( + content: &str, + position: Position, +) -> Option { + let cursor_offset = position_to_offset(content, position) as usize; + let bytes = content.as_bytes(); + + if cursor_offset == 0 || cursor_offset > bytes.len() { + return None; + } + + // โ”€โ”€ Find the opening quote before the cursor โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + let mut quote_pos = None; + let mut i = cursor_offset; + while i > 0 { + i -= 1; + let ch = bytes[i]; + if ch == b'\'' || ch == b'"' { + let mut bs = 0; + let mut j = i; + while j > 0 && bytes[j - 1] == b'\\' { + bs += 1; + j -= 1; + } + if bs % 2 == 0 { + quote_pos = Some(i); + break; + } + } + if ch == b'\n' { + return None; + } + } + let quote_pos = quote_pos?; + let prefix = content[quote_pos + 1..cursor_offset].to_string(); + + // โ”€โ”€ Before the quote, expect `(` (first argument) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + let before_quote = content[..quote_pos].trim_end(); + if !before_quote.ends_with('(') { + return None; + } + let before_paren = before_quote[..before_quote.len() - 1].trim_end(); + + // โ”€โ”€ Extract the function/method name โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + let bp_bytes = before_paren.as_bytes(); + let name_end = bp_bytes.len(); + let mut name_start = name_end; + while name_start > 0 + && (bp_bytes[name_start - 1].is_ascii_alphanumeric() || bp_bytes[name_start - 1] == b'_') + { + name_start -= 1; + } + if name_start == name_end { + return None; + } + let func_name = &before_paren[name_start..name_end]; + + // โ”€โ”€ Check for static method syntax (Config::get, etc.) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + let before_name = &before_paren[..name_start]; + let is_static = before_name.trim_end().ends_with("::"); + + // Check for instance method call (->route() or ?->route()) + let trimmed_before = before_name.trim_end(); + let is_instance_method = trimmed_before.ends_with("->") || trimmed_before.ends_with("?->"); + + // Check for PHP attribute syntax: #[Config('key')] or + // #[\Illuminate\Container\Attributes\Config('key')]. + // Strip trailing `\Identifier` segments to handle FQN attributes, + // then check for `#[`. Never search the entire file prefix โ€” + // an unrelated attribute (e.g. `#[Override]`) would false-positive. + let is_attribute = { + let mut s = trimmed_before; + loop { + let stripped = s.trim_end_matches(|c: char| c.is_ascii_alphanumeric() || c == '_'); + if stripped.len() < s.len() && stripped.ends_with('\\') { + s = &stripped[..stripped.len() - 1]; + } else { + s = stripped; + break; + } + } + s.ends_with("#[") || s.ends_with("#") + }; + + // โ”€โ”€ Map container attributes to config sub-prefixes โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + let (kind, config_sub_prefix) = if is_attribute { + // Resolve the attribute to its Laravel FQN. When the name is + // fully qualified (contains `\`), match the FQN directly. + // When it's a short name, verify the file imports it from + // `Illuminate\Container\Attributes\`. + const ATTR_NS: &str = "Illuminate\\Container\\Attributes\\"; + + // Reconstruct the full attribute class name by scanning backwards + // past namespace separators. `func_name` only captured the last + // segment (e.g. `Config`), but the FQN parts (if any) are in + // `before_name` (e.g. `#[\Illuminate\Container\Attributes\`). + let full_attr_name = { + let bn = before_name.trim_end().trim_end_matches('\\'); + // Check for `#[` or `#[\` prefix โ€” extract everything after `#[` + if let Some(idx) = bn.rfind("#[") { + let after_hash = &bn[idx + 2..].trim_start_matches('\\'); + if after_hash.is_empty() { + func_name.to_string() + } else { + format!("{}\\{}", after_hash, func_name) + } + } else { + func_name.to_string() + } + }; + let attr_class = full_attr_name.trim_start_matches('\\'); + let short = attr_class.rsplit('\\').next().unwrap_or(attr_class); + + let is_fqn = attr_class.contains('\\'); + let fqn_matches = |expected_short: &str| -> bool { + if is_fqn { + attr_class == format!("{}{}", ATTR_NS, expected_short) + } else if short == expected_short { + // Verify the import exists in the file. + content.contains(&format!("use {}{};", ATTR_NS, expected_short)) + || content.contains(&format!("use {}{{", ATTR_NS)) + } else { + false + } + }; + + if fqn_matches("Config") { + (Some(LaravelStringKind::Config), None) + } else if fqn_matches("Database") || fqn_matches("DB") { + ( + Some(LaravelStringKind::Config), + Some("database.connections."), + ) + } else if fqn_matches("Cache") { + (Some(LaravelStringKind::Config), Some("cache.stores.")) + } else if fqn_matches("Log") { + (Some(LaravelStringKind::Config), Some("logging.channels.")) + } else if fqn_matches("Storage") { + (Some(LaravelStringKind::Config), Some("filesystems.disks.")) + } else if fqn_matches("Auth") || fqn_matches("Authenticated") { + (Some(LaravelStringKind::Config), Some("auth.guards.")) + } else { + (None, None) + } + } else if is_static { + let before_colons = &trimmed_before[..trimmed_before.len() - 2].trim_end(); + let bc_bytes = before_colons.as_bytes(); + let mut cls_start = bc_bytes.len(); + while cls_start > 0 + && (bc_bytes[cls_start - 1].is_ascii_alphanumeric() + || bc_bytes[cls_start - 1] == b'_' + || bc_bytes[cls_start - 1] == b'\\') + { + cls_start -= 1; + } + let class_name = &before_colons[cls_start..]; + let short = class_name.rsplit('\\').next().unwrap_or(class_name); + let fn_lower = func_name.to_ascii_lowercase(); + + match (short.to_ascii_lowercase().as_str(), fn_lower.as_str()) { + ( + "config", + "get" | "set" | "has" | "boolean" | "array" | "collection" | "prepend" | "push", + ) => (Some(LaravelStringKind::Config), None), + ("view", "make" | "exists") => (Some(LaravelStringKind::View), None), + ("lang", "get" | "has" | "choice") => (Some(LaravelStringKind::Trans), None), + // Facade methods that accept config sub-keys: + ("auth", "guard") => (Some(LaravelStringKind::Config), Some("auth.guards.")), + ("db", "connection") => ( + Some(LaravelStringKind::Config), + Some("database.connections."), + ), + ("cache", "store") => (Some(LaravelStringKind::Config), Some("cache.stores.")), + ("log", "channel") => (Some(LaravelStringKind::Config), Some("logging.channels.")), + ("storage", "disk") => (Some(LaravelStringKind::Config), Some("filesystems.disks.")), + _ => (None, None), + } + } else if is_instance_method { + let k = match func_name.to_ascii_lowercase().as_str() { + "route" => Some(LaravelStringKind::Route), + _ => None, + }; + (k, None) + } else { + match func_name.to_ascii_lowercase().as_str() { + "route" | "to_route" => (Some(LaravelStringKind::Route), None), + "config" => (Some(LaravelStringKind::Config), None), + "view" | "blade_view_directive" => (Some(LaravelStringKind::View), None), + "__" | "trans" | "trans_choice" => (Some(LaravelStringKind::Trans), None), + // auth('guard') helper accepts a guard name + "auth" => (Some(LaravelStringKind::Config), Some("auth.guards.")), + _ => (None, None), + } + }; + + let kind = kind?; + + Some(LaravelStringKeyContext { + kind, + prefix, + content_start_offset: quote_pos + 1, + config_sub_prefix, + }) +} + +// โ”€โ”€โ”€ Enumeration โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +impl Backend { + /// Enumerate all view names by scanning `resources/views/` file URIs. + fn enumerate_all_view_names(&self) -> Vec { + let snapshot = self.user_file_symbol_maps(); + let mut names = Vec::new(); + + for (file_uri, _) in snapshot { + // Match resources/views/**/*.blade.php or *.php + let Some(rel) = extract_view_relative_path(&file_uri) else { + continue; + }; + names.push(rel); + } + names.sort(); + names.dedup(); + names + } + + /// Enumerate all config keys by scanning `config/` files. + fn enumerate_all_config_keys(&self) -> Vec { + use crate::virtual_members::laravel::{ + collect_laravel_config_declarations, laravel_config_prefix_from_uri, + }; + + let snapshot = self.user_file_symbol_maps(); + let mut keys = Vec::new(); + + for (file_uri, _) in &snapshot { + let Some(prefix) = laravel_config_prefix_from_uri(file_uri) else { + continue; + }; + let Some(content) = self.get_file_content(file_uri) else { + continue; + }; + let decls = collect_laravel_config_declarations(&content, &prefix); + for d in decls { + keys.push(d.key); + } + } + keys.sort(); + keys.dedup(); + keys + } + + /// Enumerate all translation keys by scanning `lang/` files. + /// + /// Supports both PHP array files (`lang/en/messages.php` โ†’ `messages.key`) + /// and JSON translation files (`lang/en.json` โ†’ raw key strings). + fn enumerate_all_trans_keys(&self) -> Vec { + let snapshot = self.user_file_symbol_maps(); + let mut keys = Vec::new(); + + for (file_uri, _) in &snapshot { + if !(file_uri.contains("/lang/") || file_uri.contains("/resources/lang/")) { + continue; + } + if !file_uri.ends_with(".php") { + continue; + } + let Some(stem) = extract_lang_file_stem(file_uri) else { + continue; + }; + let Some(content) = self.get_file_content(file_uri) else { + continue; + }; + let decls = + crate::virtual_members::laravel::collect_trans_declarations(&content, &stem); + for d in decls { + keys.push(d.key); + } + } + + // Also collect keys from JSON translation files on disk. + collect_json_trans_keys(self, &mut keys); + + keys.sort(); + keys.dedup(); + keys + } + + pub(crate) fn cached_route_names(&self) -> Vec { + { + let cache = self.laravel_string_key_cache.read(); + if let Some(ref names) = cache.route_names { + return names.clone(); + } + } + let names = crate::virtual_members::laravel::enumerate_all_route_names(self); + self.laravel_string_key_cache.write().route_names = Some(names.clone()); + names + } + + pub(crate) fn cached_config_keys(&self) -> Vec { + { + let cache = self.laravel_string_key_cache.read(); + if let Some(ref keys) = cache.config_keys { + return keys.clone(); + } + } + let keys = self.enumerate_all_config_keys(); + self.laravel_string_key_cache.write().config_keys = Some(keys.clone()); + keys + } + + pub(crate) fn cached_view_names(&self) -> Vec { + { + let cache = self.laravel_string_key_cache.read(); + if let Some(ref names) = cache.view_names { + return names.clone(); + } + } + let names = self.enumerate_all_view_names(); + self.laravel_string_key_cache.write().view_names = Some(names.clone()); + names + } + + pub(crate) fn cached_trans_keys(&self) -> Vec { + { + let cache = self.laravel_string_key_cache.read(); + if let Some(ref keys) = cache.trans_keys { + return keys.clone(); + } + } + let keys = self.enumerate_all_trans_keys(); + self.laravel_string_key_cache.write().trans_keys = Some(keys.clone()); + keys + } +} + +/// Extract the dot-notated view name from a file URI. +/// +/// `file:///path/resources/views/users/profile.blade.php` โ†’ `"users.profile"` +pub(crate) fn extract_view_relative_path(uri: &str) -> Option { + let marker = "/resources/views/"; + let idx = uri.find(marker)?; + let rel = &uri[idx + marker.len()..]; + let name = rel + .strip_suffix(".blade.php") + .or_else(|| rel.strip_suffix(".php"))?; + if name.is_empty() { + return None; + } + Some(name.replace('/', ".")) +} + +/// Extract the file stem from a lang file URI for use as the translation +/// key prefix. +/// +/// `file:///path/lang/en/messages.php` โ†’ `"messages"` +fn extract_lang_file_stem(uri: &str) -> Option { + let file = uri.rsplit('/').next()?; + let stem = file.strip_suffix(".php")?; + if stem.is_empty() { + return None; + } + Some(stem.to_string()) +} + +/// Scan the workspace for `lang/*.json` files and collect their top-level +/// keys into `out`. Laravel's JSON translations are flat +/// `{ "Some phrase": "Translated phrase" }` objects where the key is used +/// directly in `__('Some phrase')`. +/// +/// We scan the filesystem because JSON files are not PHP and therefore do +/// not appear in `user_file_symbol_maps()`. +fn collect_json_trans_keys(backend: &crate::Backend, out: &mut Vec) { + let root = match backend.workspace_root.read().clone() { + Some(r) => r, + None => return, + }; + for sub in &["lang", "resources/lang"] { + let dir = root.join(sub); + let Ok(entries) = std::fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().is_some_and(|e| e == "json") + && let Ok(content) = std::fs::read_to_string(&path) + && let Ok(map) = + serde_json::from_str::>(&content) + { + for k in map.keys() { + out.push(k.clone()); + } + } + } + } +} + +// โ”€โ”€โ”€ Completion โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +impl Backend { + /// Try Laravel string key completion. + /// + /// Detects the cursor inside the first string argument of `route()`, + /// `config()`, `view()`, `__()`, etc. and offers matching key names. + pub(crate) fn try_laravel_string_key_completion( + &self, + content: &str, + position: Position, + ) -> Option { + let ctx = detect_laravel_string_key_context(content, position)?; + + let mut candidates = match ctx.kind { + LaravelStringKind::Route => self.cached_route_names(), + LaravelStringKind::Config => self.cached_config_keys(), + LaravelStringKind::View => self.cached_view_names(), + LaravelStringKind::Trans => self.cached_trans_keys(), + }; + + // For config-backed attributes like #[Database('mysql')], filter + // to sub-keys under the relevant config prefix and strip it so + // the user sees just the connection/store/channel name. + if let Some(sub_prefix) = ctx.config_sub_prefix { + candidates = candidates + .into_iter() + .filter_map(|key| { + key.strip_prefix(sub_prefix).and_then(|rest| { + // Only show direct children (no dots = leaf key). + if rest.contains('.') { + None + } else { + Some(rest.to_string()) + } + }) + }) + .collect(); + candidates.sort(); + candidates.dedup(); + } + + // Build the TextEdit range: from the start of the string content + // (right after the opening quote) to the current cursor position. + // This replaces the entire typed prefix with the selected name, + // so dots in the name don't break the editor's word-based filter. + let start_pos = crate::util::offset_to_position(content, ctx.content_start_offset); + let edit_range = Range { + start: start_pos, + end: position, + }; + + let prefix_lower = ctx.prefix.to_lowercase(); + let items: Vec = candidates + .into_iter() + .filter(|name| { + if prefix_lower.is_empty() { + true + } else { + name.to_lowercase().starts_with(&prefix_lower) + } + }) + .enumerate() + .map(|(i, name)| { + let kind = match ctx.kind { + LaravelStringKind::Route => CompletionItemKind::VALUE, + LaravelStringKind::Config => CompletionItemKind::PROPERTY, + LaravelStringKind::View => CompletionItemKind::FILE, + LaravelStringKind::Trans => CompletionItemKind::TEXT, + }; + CompletionItem { + label: name.clone(), + kind: Some(kind), + sort_text: Some(format!("{:05}", i)), + filter_text: Some(name.clone()), + text_edit: Some(CompletionTextEdit::Edit(TextEdit { + range: edit_range, + new_text: name, + })), + ..Default::default() + } + }) + .collect(); + + if items.is_empty() { + None + } else { + Some(CompletionResponse::Array(items)) + } + } +} + +// โ”€โ”€โ”€ Tests โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +#[cfg(test)] +mod tests { + use super::*; + use tower_lsp::lsp_types::Position; + + #[test] + fn detects_route_call() { + let content = " 'home')->name('home');\n\ + Route::get('/about', fn() => 'about')->name('about');\n"; + backend.open_files.write().insert( + route_uri.to_string(), + std::sync::Arc::new(route_content.to_string()), + ); + backend.update_ast(route_uri, route_content); + + let test_content = " = items.iter().map(|i| i.label.as_str()).collect(); + assert!( + labels.contains(&"home"), + "completion should include 'home', got: {:?}", + labels + ); + assert!( + labels.contains(&"about"), + "completion should include 'about', got: {:?}", + labels + ); + } + } +} diff --git a/src/completion/mod.rs b/src/completion/mod.rs index 9b0fe95c..9618d67a 100644 --- a/src/completion/mod.rs +++ b/src/completion/mod.rs @@ -80,6 +80,7 @@ pub(crate) mod call_resolution; pub(crate) mod eloquent_string; pub(crate) mod handler; pub(crate) mod laravel_route_controller; +pub(crate) mod laravel_string_keys; pub mod named_args; pub(crate) mod resolve; pub(crate) mod resolver; diff --git a/src/diagnostics/mod.rs b/src/diagnostics/mod.rs index f747e3f3..b3963fe9 100644 --- a/src/diagnostics/mod.rs +++ b/src/diagnostics/mod.rs @@ -279,6 +279,132 @@ impl Backend { self.collect_deprecated_diagnostics(uri_str, content, out); self.collect_undefined_variable_diagnostics(uri_str, content, out); self.collect_invalid_class_kind_diagnostics(uri_str, content, out); + let is_laravel = self.resolved_class_cache.read().is_laravel(); + if is_laravel { + self.collect_invalid_laravel_string_key_diagnostics(uri_str, content, out); + } + } + + /// Emit a warning for each `LaravelStringKey` span whose key does + /// not resolve to any declaration (typo in route name, config key, + /// view name, or translation key). + fn collect_invalid_laravel_string_key_diagnostics( + &self, + uri: &str, + content: &str, + out: &mut Vec, + ) { + use crate::symbol_map::{LaravelStringKind, SymbolKind}; + use std::collections::HashSet; + + // Extract the LaravelStringKey spans we need and determine which + // kinds are present, then DROP the read lock before calling + // enumeration functions. Those functions call + // `user_file_symbol_maps()` โ†’ `ensure_workspace_indexed()` โ†’ + // `parse_files_parallel()` โ†’ `update_ast()` which acquires a + // WRITE lock on `symbol_maps`. Holding a read lock here while + // that write is attempted would deadlock. + let mut has_route = false; + let mut has_config = false; + let mut has_view = false; + let mut has_trans = false; + let key_spans: Vec<(LaravelStringKind, String, u32, u32)> = { + let maps = self.symbol_maps.read(); + let Some(symbol_map) = maps.get(uri) else { + return; + }; + symbol_map + .spans + .iter() + .filter_map(|span| { + if let SymbolKind::LaravelStringKey { kind, key } = &span.kind { + match kind { + LaravelStringKind::Route => has_route = true, + LaravelStringKind::Config => has_config = true, + LaravelStringKind::View => has_view = true, + LaravelStringKind::Trans => has_trans = true, + } + Some((kind.clone(), key.clone(), span.start, span.end)) + } else { + None + } + }) + .collect() + // `maps` read lock is dropped here. + }; + + if !has_route && !has_config && !has_view && !has_trans { + return; + } + + // Enumerate valid keys once per kind (lazy), using the cached + // enumerations. Safe to call now that the `symbol_maps` read + // lock has been released. + let route_keys: HashSet = if has_route { + self.cached_route_names().into_iter().collect() + } else { + HashSet::new() + }; + let config_keys: HashSet = if has_config { + self.cached_config_keys().into_iter().collect() + } else { + HashSet::new() + }; + let view_keys: HashSet = if has_view { + self.cached_view_names().into_iter().collect() + } else { + HashSet::new() + }; + let trans_keys: HashSet = if has_trans { + self.cached_trans_keys().into_iter().collect() + } else { + HashSet::new() + }; + + for (kind, key, start, end) in &key_spans { + let (valid, label, code) = match kind { + LaravelStringKind::Route => { + (route_keys.contains(key), "route", "invalid_laravel_route") + } + LaravelStringKind::Config => { + // Config keys may be partial prefixes (e.g. `config('app')`) + // which are valid even without a direct match. + let valid = config_keys.contains(key) + || config_keys + .iter() + .any(|k| k.starts_with(&format!("{}.", key))); + (valid, "config key", "invalid_laravel_config") + } + LaravelStringKind::View => { + (view_keys.contains(key), "view", "invalid_laravel_view") + } + LaravelStringKind::Trans => { + // When no translation files are found at all, skip trans + // diagnostics entirely. This avoids false positives in + // non-Laravel projects (WordPress, GetText) that also use + // `__()` or `trans()` as function names. + if trans_keys.is_empty() { + continue; + } + let valid = trans_keys.contains(key) + || trans_keys + .iter() + .any(|k| k.starts_with(&format!("{}.", key))); + (valid, "translation key", "invalid_laravel_trans") + } + }; + if !valid + && let Some(range) = + offset_range_to_lsp_range(content, *start as usize, *end as usize) + { + out.push(helpers::make_diagnostic( + range, + DiagnosticSeverity::WARNING, + code, + format!("Unknown {}: '{}'", label, key), + )); + } + } } } @@ -3058,4 +3184,80 @@ mod tests { "pull must not compute diagnostics inline on the request path" ); } + + /// Regression test: `collect_invalid_laravel_string_key_diagnostics` + /// must not hold a `symbol_maps` read lock while calling enumeration + /// functions that reach `ensure_workspace_indexed()` โ†’ + /// `parse_files_parallel()` โ†’ `update_ast()` โ†’ `symbol_maps.write()`. + /// + /// Before the fix, this deadlocked because the read lock was held + /// for the entire function body. The fix extracts the needed spans + /// into an owned `Vec` and drops the lock before enumerating keys. + /// + /// This test exercises the exact code path with a file containing a + /// `config()` call. A 5-second timeout catches the deadlock as a + /// test failure instead of an infinite hang. + /// Regression test: `collect_invalid_laravel_string_key_diagnostics` + /// must not hold a `symbol_maps` read lock while calling enumeration + /// functions that reach `ensure_workspace_indexed()` โ†’ + /// `parse_files_parallel()` โ†’ `update_ast()` โ†’ `symbol_maps.write()`. + /// + /// Before the fix, this deadlocked because the read lock was held + /// for the entire function body. The fix extracts the needed spans + /// into an owned `Vec` and drops the lock before enumerating keys. + /// + /// To trigger the deadlock path, we create a workspace with an + /// unindexed PHP file so `ensure_workspace_indexed` must parse it + /// (acquiring a write lock). A 5-second timeout catches the + /// deadlock as a test failure instead of an infinite hang. + #[test] + fn laravel_string_key_diagnostics_no_deadlock() { + // Set up a temp workspace with an unindexed PHP file so that + // ensure_workspace_indexed() will call parse_files_parallel() + // which needs a write lock on symbol_maps. + let tmp = std::env::temp_dir().join("phpantom_deadlock_test"); + let _ = std::fs::create_dir_all(&tmp); + let unindexed_file = tmp.join("Unindexed.php"); + std::fs::write(&unindexed_file, " { /* success โ€” no deadlock */ } + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { + panic!( + "collect_slow_diagnostics deadlocked: symbol_maps read lock \ + was likely held while enumeration functions tried to write" + ); + } + Err(e) => panic!("collect_slow_diagnostics failed: {:?}", e), + } + + // Clean up. + let _ = std::fs::remove_dir_all(&tmp); + } } diff --git a/src/hover/mod.rs b/src/hover/mod.rs index 57962a91..04c90ac3 100644 --- a/src/hover/mod.rs +++ b/src/hover/mod.rs @@ -847,13 +847,94 @@ impl Backend { } } - SymbolKind::LaravelStringKey { .. } - | SymbolKind::Keyword - | SymbolKind::CastType - | SymbolKind::Comment => None, + SymbolKind::LaravelStringKey { kind, key } => self.hover_laravel_string_key(kind, key), + + SymbolKind::Keyword | SymbolKind::CastType | SymbolKind::Comment => None, } } + /// Build hover content for a Laravel string key (route name, config + /// key, view name, or translation key). + fn hover_laravel_string_key( + &self, + kind: &crate::symbol_map::LaravelStringKind, + key: &str, + ) -> Option { + use crate::symbol_map::LaravelStringKind; + + let (label, detail) = match kind { + LaravelStringKind::Route => { + // Try to resolve the route to show where it's defined. + let locations = + crate::virtual_members::laravel::resolve_laravel_string_key(self, kind, key); + let detail = if let Some(loc) = locations.first() { + let path = loc.uri.path(); + let short_path = path + .rsplit("/routes/") + .next() + .map(|p| format!("routes/{}", p)) + .unwrap_or_else(|| path.to_string()); + format!("Defined in `{}`", short_path) + } else { + "Route name".to_string() + }; + ("Route", detail) + } + LaravelStringKind::Config => { + // Try to resolve the config key to show its value. + let locations = + crate::virtual_members::laravel::resolve_laravel_string_key(self, kind, key); + let detail = if let Some(loc) = locations.first() { + let path = loc.uri.path(); + let short_path = path + .rsplit("/config/") + .next() + .map(|p| format!("config/{}", p)) + .unwrap_or_else(|| path.to_string()); + format!("Defined in `{}`", short_path) + } else { + "Config key".to_string() + }; + ("Config", detail) + } + LaravelStringKind::View => { + // Show the resolved file path. + let locations = + crate::virtual_members::laravel::resolve_laravel_string_key(self, kind, key); + let detail = if let Some(loc) = locations.first() { + let path = loc.uri.path(); + let short_path = path + .rsplit("/resources/views/") + .next() + .map(|p| format!("resources/views/{}", p)) + .unwrap_or_else(|| path.to_string()); + format!("`{}`", short_path) + } else { + "View template".to_string() + }; + ("View", detail) + } + LaravelStringKind::Trans => { + let locations = + crate::virtual_members::laravel::resolve_laravel_string_key(self, kind, key); + let detail = if let Some(loc) = locations.first() { + let path = loc.uri.path(); + let short_path = path + .rsplit("/lang/") + .next() + .map(|p| format!("lang/{}", p)) + .unwrap_or_else(|| path.to_string()); + format!("Defined in `{}`", short_path) + } else { + "Translation key".to_string() + }; + ("Trans", detail) + } + }; + + Some(make_hover(format!("**{}** `{}`\n\n{}", label, key, detail))) + } + /// Look up a global constant by name, returning its value if found. /// /// Searches in order: diff --git a/src/lib.rs b/src/lib.rs index 1fe3e69e..ec53c9ab 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -210,6 +210,31 @@ pub use virtual_members::resolve_class_fully; /// `collect_deprecated_diagnostics`, `collect_unused_import_diagnostics`, /// `collect_unknown_class_diagnostics`, /// `collect_unknown_member_diagnostics` (includes unresolved-member-access logic) +#[derive(Default)] +pub(crate) struct LaravelStringKeyCache { + pub route_names: Option>, + pub config_keys: Option>, + pub view_names: Option>, + pub trans_keys: Option>, +} + +impl LaravelStringKeyCache { + fn invalidate_for_uri(&mut self, uri: &str) { + if uri.contains("/routes/") { + self.route_names = None; + } + if uri.contains("/config/") { + self.config_keys = None; + } + if uri.contains("/resources/views/") { + self.view_names = None; + } + if uri.contains("/lang/") || uri.contains("/resources/lang/") { + self.trans_keys = None; + } + } +} + pub struct Backend { pub(crate) name: String, pub(crate) version: String, @@ -459,6 +484,11 @@ pub struct Backend { /// when the index holds at least one macro, so the hot class-load path /// skips the lock entirely for the common (no-macro) case. pub(crate) laravel_has_macros: Arc, + /// Cached Laravel string key enumerations (route names, config keys, + /// view names, translation keys). `None` = not yet computed. + /// Invalidated when a file in `routes/`, `config/`, `resources/views/`, + /// or `lang/` is updated. + pub(crate) laravel_string_key_cache: Arc>, /// Per-target member completion cache. /// /// Typing `$model->wh...` triggers a completion request for each @@ -879,6 +909,7 @@ impl Backend { virtual_members::laravel::LaravelMacroIndex::default(), )), laravel_has_macros: Arc::new(std::sync::atomic::AtomicBool::new(false)), + laravel_string_key_cache: Arc::new(RwLock::new(LaravelStringKeyCache::default())), member_completion_cache: Arc::new(Mutex::new(HashMap::new())), method_store: Arc::new(RwLock::new(HashMap::new())), gti_index: Arc::new(RwLock::new(HashMap::new())), @@ -973,6 +1004,7 @@ impl Backend { virtual_members::laravel::LaravelMacroIndex::default(), )), laravel_has_macros: Arc::new(std::sync::atomic::AtomicBool::new(false)), + laravel_string_key_cache: Arc::new(RwLock::new(LaravelStringKeyCache::default())), member_completion_cache: Arc::new(Mutex::new(HashMap::new())), method_store: Arc::new(RwLock::new(HashMap::new())), gti_index: Arc::new(RwLock::new(HashMap::new())), @@ -1566,6 +1598,7 @@ impl Backend { laravel_aliases: Arc::clone(&self.laravel_aliases), laravel_macros: Arc::clone(&self.laravel_macros), laravel_has_macros: Arc::clone(&self.laravel_has_macros), + laravel_string_key_cache: Arc::clone(&self.laravel_string_key_cache), member_completion_cache: Arc::clone(&self.member_completion_cache), method_store: Arc::clone(&self.method_store), gti_index: Arc::clone(&self.gti_index), diff --git a/src/parser/ast_update.rs b/src/parser/ast_update.rs index 5a5d4624..cb3815d0 100644 --- a/src/parser/ast_update.rs +++ b/src/parser/ast_update.rs @@ -80,6 +80,10 @@ impl Backend { content.to_string() }; + self.laravel_string_key_cache + .write() + .invalidate_for_uri(uri); + // The mago-syntax parser contains `unreachable!()` and `.expect()` // calls that can panic on malformed PHP (e.g. partially-written // heredocs/nowdocs, which are common while editing). Wrap the diff --git a/src/symbol_map/extraction.rs b/src/symbol_map/extraction.rs index 2f390732..db6683a4 100644 --- a/src/symbol_map/extraction.rs +++ b/src/symbol_map/extraction.rs @@ -80,6 +80,9 @@ struct ExtractionCtx<'a> { /// Stack of block-end offsets for each conditional nesting level. /// The top of the stack is the end of the innermost conditional block. cond_block_end_stack: Vec, + /// Whether the file imports from `Illuminate\Container\Attributes\` + /// (checked once lazily, cached for all attribute inspections). + has_laravel_container_attrs: Option, } // โ”€โ”€โ”€ Keyword helper โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ @@ -151,6 +154,7 @@ pub(crate) fn extract_symbol_map(program: &Program<'_>, content: &str) -> Symbol untyped_closure_sites: Vec::new(), cond_nesting_depth: 0, cond_block_end_stack: Vec::new(), + has_laravel_container_attrs: None, }; for stmt in program.statements.iter() { @@ -1035,6 +1039,23 @@ fn extract_from_attribute_lists<'a>( &mut ctx.untyped_closure_sites, ); } + + // Laravel container attributes: #[Config('key')], + // #[Database('conn')], #[Cache('store')], etc. โ†’ + // emit a LaravelStringKey::Config span so hover, + // go-to-definition, and diagnostics work on the key. + // + // FQN attributes match directly. Short names require + // the file to import from the Illuminate namespace; + // that check is cached once per file to avoid repeated + // linear scans. + if let Some(kind) = resolve_laravel_container_attr( + class_name, + &mut ctx.has_laravel_container_attrs, + ctx.content, + ) { + try_emit_laravel_string_span(kind, arg_list, ctx.content, &mut ctx.spans); + } } } } @@ -1944,38 +1965,32 @@ fn extract_from_expression<'a>( is_definition: false, }, }); - if name_clean.eq_ignore_ascii_case("config") { - try_emit_laravel_string_span( - crate::symbol_map::LaravelStringKind::Config, - &func_call.argument_list, - ctx.content, - &mut ctx.spans, - ); - } - if name_clean.eq_ignore_ascii_case("view") + // Detect Laravel helper calls and emit a + // LaravelStringKey span for the first string arg. + // Uses if-else to short-circuit (most function calls + // won't match) and avoids to_ascii_lowercase() heap + // allocations. + let laravel_kind = if name_clean.eq_ignore_ascii_case("config") { + Some(crate::symbol_map::LaravelStringKind::Config) + } else if name_clean.eq_ignore_ascii_case("view") || name_clean.eq_ignore_ascii_case("blade_view_directive") { + Some(crate::symbol_map::LaravelStringKind::View) + } else if name_clean.eq_ignore_ascii_case("route") + || name_clean.eq_ignore_ascii_case("to_route") + { + Some(crate::symbol_map::LaravelStringKind::Route) + } else if name_clean.eq_ignore_ascii_case("__") + || name_clean.eq_ignore_ascii_case("trans") + || name_clean.eq_ignore_ascii_case("trans_choice") + { + Some(crate::symbol_map::LaravelStringKind::Trans) + } else { + None + }; + if let Some(kind) = laravel_kind { try_emit_laravel_string_span( - crate::symbol_map::LaravelStringKind::View, - &func_call.argument_list, - ctx.content, - &mut ctx.spans, - ); - } - if name_clean.eq_ignore_ascii_case("route") { - try_emit_laravel_string_span( - crate::symbol_map::LaravelStringKind::Route, - &func_call.argument_list, - ctx.content, - &mut ctx.spans, - ); - } - if matches!( - name_clean.to_ascii_lowercase().as_str(), - "__" | "trans" | "trans_choice" - ) { - try_emit_laravel_string_span( - crate::symbol_map::LaravelStringKind::Trans, + kind, &func_call.argument_list, ctx.content, &mut ctx.spans, @@ -3422,6 +3437,52 @@ fn is_assert_instanceof(expr: &Expression<'_>) -> bool { false } +/// Check whether an attribute class name refers to a Laravel container +/// attribute (`Config`, `Database`, `Cache`, `Log`, `Storage`, `Auth`, +/// `Authenticated`). Returns the corresponding [`LaravelStringKind`] if +/// so โ€” always `Config` since all container attributes resolve to config +/// sub-keys. +/// +/// FQN names (containing `\`) are matched directly against +/// `Illuminate\Container\Attributes\*`. Short names require the file to +/// import from that namespace; the result of that check is cached in +/// `import_cache` to avoid repeated linear scans of the file content. +const LARAVEL_CONTAINER_ATTR_NS: &str = "Illuminate\\Container\\Attributes\\"; +const LARAVEL_CONTAINER_ATTR_NAMES: &[&str] = &[ + "Config", + "Database", + "DB", + "Cache", + "Log", + "Storage", + "Auth", + "Authenticated", +]; + +fn resolve_laravel_container_attr( + class_name: &str, + import_cache: &mut Option, + content: &str, +) -> Option { + if class_name.contains('\\') { + let stripped = class_name.strip_prefix(LARAVEL_CONTAINER_ATTR_NS)?; + if LARAVEL_CONTAINER_ATTR_NAMES.contains(&stripped) { + return Some(crate::symbol_map::LaravelStringKind::Config); + } + return None; + } + if !LARAVEL_CONTAINER_ATTR_NAMES.contains(&class_name) { + return None; + } + let has_import = *import_cache + .get_or_insert_with(|| content.contains("use Illuminate\\Container\\Attributes\\")); + if has_import { + Some(crate::symbol_map::LaravelStringKind::Config) + } else { + None + } +} + /// If the first argument of `argument_list` is a non-empty, non-interpolated /// string literal, push a [`SymbolKind::LaravelStringKey`] span covering the /// string content (inside the quotes) onto `spans`. diff --git a/src/virtual_members/laravel/mod.rs b/src/virtual_members/laravel/mod.rs index 5c47bed9..1c6e2546 100644 --- a/src/virtual_members/laravel/mod.rs +++ b/src/virtual_members/laravel/mod.rs @@ -98,7 +98,8 @@ pub(crate) use aliases::LaravelAliases; pub(crate) use auth::{GUARD_FQN, REQUEST_FQN, patch_auth_user_class, resolve_auth_user_type}; pub(crate) use config_keys::find_config_references; pub(crate) use config_keys::{ - find_all_config_references, resolve_config_key_declaration, + collect_laravel_config_declarations, find_all_config_references, + laravel_config_prefix_from_uri, resolve_config_key_declaration, resolve_config_key_definition_fallback, }; pub(crate) use env_vars::resolve_env_definition; @@ -106,6 +107,8 @@ pub(crate) use macros::{ LaravelMacroIndex, extract_macro_registrations, inject_macros, parse_installed_providers, parse_provider_class_list, }; +pub(crate) use route_names::enumerate_all_route_names; +pub(crate) use trans_keys::collect_trans_declarations; /// Unified go-to-definition entry point for all Laravel string-key spans. /// diff --git a/src/virtual_members/laravel/route_names.rs b/src/virtual_members/laravel/route_names.rs index edc48ea9..2bd15de2 100644 --- a/src/virtual_members/laravel/route_names.rs +++ b/src/virtual_members/laravel/route_names.rs @@ -8,6 +8,109 @@ use crate::util::offset_to_position; use super::helpers::extract_string_literal; +/// Conventional route name suffixes generated by `Route::resource()`. +const RESOURCE_SUFFIXES: &[&str] = &[ + "index", "create", "store", "show", "edit", "update", "destroy", +]; + +/// Conventional route name suffixes generated by `Route::apiResource()`. +const API_RESOURCE_SUFFIXES: &[&str] = &["index", "store", "show", "update", "destroy"]; + +/// Derive the route name base from a resource URI string. +/// +/// Laravel converts `/` to `.` and uses the result as the name prefix. +/// For example, `"photo-comments"` โ†’ `"photo-comments"`, +/// `"photos/comments"` โ†’ `"photos.comments"`. +fn resource_name_base(uri: &str) -> String { + uri.trim_matches('/').replace('/', ".") +} + +/// Build the filtered list of resource route suffixes, respecting +/// `->only()` and `->except()` modifiers. +fn filtered_resource_suffixes( + is_api: bool, + only: &[String], + except: &[String], +) -> Vec<&'static str> { + let base = if is_api { + API_RESOURCE_SUFFIXES + } else { + RESOURCE_SUFFIXES + }; + base.iter() + .copied() + .filter(|s| { + if !only.is_empty() { + only.iter().any(|o| o == s) + } else if !except.is_empty() { + !except.iter().any(|e| e == s) + } else { + true + } + }) + .collect() +} + +/// Extract string values from `->only()`/`->except()` arguments. +/// +/// Handles both array form `->only(['index', 'show'])` and individual +/// args `->except('create', 'edit')`. +fn extract_string_list_from_args<'a>(args: &'a ArgumentList<'a>, content: &'a str) -> Vec { + let mut values = Vec::new(); + for arg in args.arguments.iter() { + match arg.value() { + // Array form: ['index', 'show'] + Expression::Array(arr) => { + for el in arr.elements.iter() { + if let ArrayElement::Value(v) = el + && let Some((val, _, _)) = extract_string_literal(v.value, content) + { + values.push(val.to_string()); + } + } + } + Expression::LegacyArray(arr) => { + for el in arr.elements.iter() { + if let ArrayElement::Value(v) = el + && let Some((val, _, _)) = extract_string_literal(v.value, content) + { + values.push(val.to_string()); + } + } + } + // Individual string arg: 'create' + other => { + if let Some((val, _, _)) = extract_string_literal(other, content) { + values.push(val.to_string()); + } + } + } + } + values +} + +/// Check if a static method call is `Route::resource()` or `Route::apiResource()` +/// and extract the resource name and whether it's an API resource. +fn extract_resource_info<'a>( + sc: &'a StaticMethodCall<'a>, + content: &'a str, +) -> Option<(&'a str, usize, bool)> { + let ClassLikeMemberSelector::Identifier(ident) = &sc.method else { + return None; + }; + let method_lower = ident.value.to_ascii_lowercase(); + let is_api = if method_lower == b"resource" { + false + } else if method_lower == b"apiresource" { + true + } else { + return None; + }; + let first_arg = sc.argument_list.arguments.iter().next()?; + let (res_name, start, _) = extract_string_literal(first_arg.value(), content)?; + Some((res_name, start, is_api)) +} + /// Resolve `route('name')` to the `->name('name')` declaration in `routes/`. /// /// Supports both explicit full-name declarations: @@ -20,7 +123,6 @@ pub(crate) fn resolve_route_definitions(backend: &Backend, name: &str) -> Vec Vec Vec { +fn scan_route_file( + content: &str, + target: &str, + uri: &Url, + file_dir: Option<&std::path::Path>, +) -> Vec { let arena = Bump::new(); let file_id = FileId::new(b"input.php"); let program = mago_syntax::parser::parse_file_content(&arena, file_id, content.as_bytes()); let mut results = Vec::new(); for stmt in program.statements.iter() { - results.extend(scan_stmt(stmt, content, "", target, uri)); + results.extend(scan_stmt(stmt, content, "", target, uri, file_dir)); } results } @@ -55,12 +166,13 @@ fn scan_stmt<'a>( prefix: &str, target: &str, uri: &Url, + file_dir: Option<&std::path::Path>, ) -> Vec { match stmt { - Statement::Expression(e) => scan_expr(e.expression, content, prefix, target, uri), + Statement::Expression(e) => scan_expr(e.expression, content, prefix, target, uri, file_dir), Statement::Return(r) => r .value - .map(|v| scan_expr(v, content, prefix, target, uri)) + .map(|v| scan_expr(v, content, prefix, target, uri, file_dir)) .unwrap_or_default(), _ => Vec::new(), } @@ -79,13 +191,14 @@ fn scan_expr<'a>( prefix: &str, target: &str, uri: &Url, + file_dir: Option<&std::path::Path>, ) -> Vec { match expr { // โ”€โ”€ Fluent instance-method chain: ->group() / ->name() / other โ”€โ”€โ”€โ”€โ”€โ”€ Expression::Call(Call::Method(mc)) => { let mut results = Vec::new(); let ClassLikeMemberSelector::Identifier(ident) = &mc.method else { - return scan_expr(mc.object, content, prefix, target, uri); + return scan_expr(mc.object, content, prefix, target, uri, file_dir); }; let method = ident.value.to_ascii_lowercase(); @@ -99,6 +212,7 @@ fn scan_expr<'a>( &new_prefix, target, uri, + file_dir, )); } } else if method == b"name" { @@ -114,41 +228,91 @@ fn scan_expr<'a>( )); } } - results.extend(scan_expr(mc.object, content, prefix, target, uri)); + results.extend(scan_expr(mc.object, content, prefix, target, uri, file_dir)); + } else if method == b"only" || method == b"except" { + // ->only(['index', 'show']) or ->except(['create', 'edit']) + // on a Route::resource() / Route::apiResource() call. + if let Expression::Call(Call::StaticMethod(sc)) = mc.object + && let Some((res_name, start, is_api)) = extract_resource_info(sc, content) + { + let filter = extract_string_list_from_args(&mc.argument_list, content); + let (only, except) = if method == b"only" { + (filter, Vec::new()) + } else { + (Vec::new(), filter) + }; + let suffixes = filtered_resource_suffixes(is_api, &only, &except); + let base = resource_name_base(res_name); + for suffix in suffixes { + let full = format!("{prefix}{base}.{suffix}"); + if full == target { + return vec![crate::definition::point_location( + uri.clone(), + offset_to_position(content, start), + )]; + } + } + return Vec::new(); + } + results.extend(scan_expr(mc.object, content, prefix, target, uri, file_dir)); } else { - results.extend(scan_expr(mc.object, content, prefix, target, uri)); + results.extend(scan_expr(mc.object, content, prefix, target, uri, file_dir)); } results } - // โ”€โ”€ Direct static call: Route::group([options,] fn(){โ€ฆ}) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ - // - // This fires for `Route::group(['as'=>'admin.', โ€ฆ], fn(){โ€ฆ})` where - // there is no preceding fluent chain to carry the name prefix. + // โ”€โ”€ Direct static call: Route::group / Route::resource / Route::apiResource Expression::Call(Call::StaticMethod(sc)) => { let ClassLikeMemberSelector::Identifier(ident) = &sc.method else { return Vec::new(); }; - if !ident.value.eq_ignore_ascii_case(b"group") { - return Vec::new(); - } - let mut results = Vec::new(); - // Extract name prefix from 'as' => '...' in an array argument. - let array_prefix = extract_as_prefix_from_args( - sc.argument_list.arguments.iter().map(|a| a.value()), - content, - ); - let new_prefix = format!("{prefix}{array_prefix}"); - for arg in sc.argument_list.arguments.iter() { - results.extend(scan_group_body( - arg.value(), + let method_lower = ident.value.to_ascii_lowercase(); + + if method_lower == b"group" { + let mut results = Vec::new(); + let array_prefix = extract_as_prefix_from_args( + sc.argument_list.arguments.iter().map(|a| a.value()), content, - &new_prefix, - target, - uri, - )); + ); + let new_prefix = format!("{prefix}{array_prefix}"); + for arg in sc.argument_list.arguments.iter() { + results.extend(scan_group_body( + arg.value(), + content, + &new_prefix, + target, + uri, + file_dir, + )); + } + results + } else if method_lower == b"resource" || method_lower == b"apiresource" { + // Route::resource('presses', Controller::class) generates + // conventional named routes like presses.index, presses.show, etc. + let suffixes = if method_lower == b"resource" { + RESOURCE_SUFFIXES + } else { + API_RESOURCE_SUFFIXES + }; + if let Some(first_arg) = sc.argument_list.arguments.iter().next() + && let Some((res_name, start, _)) = + extract_string_literal(first_arg.value(), content) + { + let base = resource_name_base(res_name); + for suffix in suffixes { + let full = format!("{prefix}{base}.{suffix}"); + if full == target { + return vec![crate::definition::point_location( + uri.clone(), + offset_to_position(content, start), + )]; + } + } + } + Vec::new() + } else { + Vec::new() } - results } _ => Vec::new(), @@ -156,23 +320,310 @@ fn scan_expr<'a>( } /// Walk the argument that was passed to `->group()`. +/// +/// Handles closures, arrow functions, and `__DIR__ . '/sub.php'` file +/// includes so that go-to-definition works on route names defined in +/// included sub-files. fn scan_group_body<'a>( expr: &Expression<'a>, content: &str, prefix: &str, target: &str, uri: &Url, + file_dir: Option<&std::path::Path>, ) -> Vec { match expr { Expression::Closure(closure) => { let mut results = Vec::new(); for stmt in closure.body.statements.iter() { - results.extend(scan_stmt(stmt, content, prefix, target, uri)); + results.extend(scan_stmt(stmt, content, prefix, target, uri, file_dir)); } results } - Expression::ArrowFunction(af) => scan_expr(af.expression, content, prefix, target, uri), - _ => Vec::new(), + Expression::ArrowFunction(af) => { + scan_expr(af.expression, content, prefix, target, uri, file_dir) + } + _ => { + // Follow `Route::group([], __DIR__ . '/sub.php')` file includes. + // Parse the included file and scan it with the current prefix + // so routes inherit the parent group's name prefix. + // (Do NOT call `scan_route_file` โ€” it resets the prefix to "".) + if let Some(dir) = file_dir + && let Some(rel_path) = extract_dir_concat_path(expr, content) + { + let included = dir.join(rel_path.trim_start_matches('/')); + if let Ok(ref included_content) = std::fs::read_to_string(&included) { + let sub_uri = Url::from_file_path(&included).unwrap_or_else(|_| uri.clone()); + let sub_dir = included.parent().map(|d| d.to_path_buf()); + let arena = Bump::new(); + let fid = FileId::new(b"included.php"); + let prog = mago_syntax::parser::parse_file_content( + &arena, + fid, + included_content.as_bytes(), + ); + let mut results = Vec::new(); + for stmt in prog.statements.iter() { + results.extend(scan_stmt( + stmt, + included_content, + prefix, + target, + &sub_uri, + sub_dir.as_deref(), + )); + } + return results; + } + } + Vec::new() + } + } +} + +/// Collect all route names defined in `routes/` files. +/// +/// Returns a sorted, deduplicated list of fully-qualified route names +/// (e.g. `["admin.dashboard", "admin.users.index", "home"]`). +/// +/// Follows `Route::group([], __DIR__ . '/sub.php')` file includes so +/// that routes in sub-files inherit the parent group's name prefix. +pub(crate) fn enumerate_all_route_names(backend: &Backend) -> Vec { + let mut names = Vec::new(); + let snapshot = backend.user_file_symbol_maps(); + + for (file_uri, _) in snapshot { + if !file_uri.contains("/routes/") { + continue; + } + // Skip sub-directory route files at the top level โ€” they are + // included by parent files via `Route::group([], __DIR__ . '/sub.php')` + // and should only be scanned in that context to inherit the + // correct name prefix. A "sub-directory" file is one where the + // path after the last `/routes/` segment contains another `/`. + if let Some(after) = file_uri.rsplit("/routes/").next() + && after.contains('/') + { + continue; + } + let Some(content) = backend.get_file_content(&file_uri) else { + continue; + }; + let file_dir = Url::parse(&file_uri) + .ok() + .and_then(|u| u.to_file_path().ok()) + .and_then(|p| p.parent().map(|d| d.to_path_buf())); + collect_all_names_from_file(&content, file_dir.as_deref(), &mut names); + } + names.sort(); + names.dedup(); + names +} + +/// Parse a single route file and collect every `->name('...')` value, +/// accounting for group prefixes and file includes. +fn collect_all_names_from_file( + content: &str, + file_dir: Option<&std::path::Path>, + out: &mut Vec, +) { + let arena = Bump::new(); + let file_id = FileId::new(b"input.php"); + let program = mago_syntax::parser::parse_file_content(&arena, file_id, content.as_bytes()); + for stmt in program.statements.iter() { + collect_names_from_stmt(stmt, content, "", file_dir, out); + } +} + +fn collect_names_from_stmt<'a>( + stmt: &Statement<'a>, + content: &str, + prefix: &str, + file_dir: Option<&std::path::Path>, + out: &mut Vec, +) { + match stmt { + Statement::Expression(e) => { + collect_names_from_expr(e.expression, content, prefix, file_dir, out); + } + Statement::Return(r) => { + if let Some(v) = r.value { + collect_names_from_expr(v, content, prefix, file_dir, out); + } + } + _ => {} + } +} + +fn collect_names_from_expr<'a>( + expr: &Expression<'a>, + content: &str, + prefix: &str, + file_dir: Option<&std::path::Path>, + out: &mut Vec, +) { + match expr { + Expression::Call(Call::Method(mc)) => { + let ClassLikeMemberSelector::Identifier(ident) = &mc.method else { + collect_names_from_expr(mc.object, content, prefix, file_dir, out); + return; + }; + let method = ident.value.to_ascii_lowercase(); + + if method == b"group" { + let chain_prefix = chain_name_prefix(mc.object, content); + let new_prefix = format!("{prefix}{chain_prefix}"); + for arg in mc.argument_list.arguments.iter() { + collect_names_from_group_body(arg.value(), content, &new_prefix, file_dir, out); + } + } else if method == b"name" { + if let Some(first_arg) = mc.argument_list.arguments.iter().next() + && let Some((name_val, _, _)) = + extract_string_literal(first_arg.value(), content) + { + let full = format!("{prefix}{name_val}"); + // Only collect leaf names (non-prefix names that don't end with '.'). + if !full.is_empty() && !full.ends_with('.') { + out.push(full); + } + } + collect_names_from_expr(mc.object, content, prefix, file_dir, out); + } else if method == b"only" || method == b"except" { + // ->only() / ->except() on Route::resource() / apiResource() + if let Expression::Call(Call::StaticMethod(sc)) = mc.object + && let Some((res_name, _, is_api)) = extract_resource_info(sc, content) + { + let filter = extract_string_list_from_args(&mc.argument_list, content); + let (only, except) = if method == b"only" { + (filter, Vec::new()) + } else { + (Vec::new(), filter) + }; + let suffixes = filtered_resource_suffixes(is_api, &only, &except); + let base = resource_name_base(res_name); + for suffix in suffixes { + let full = format!("{prefix}{base}.{suffix}"); + out.push(full); + } + return; + } + collect_names_from_expr(mc.object, content, prefix, file_dir, out); + } else { + collect_names_from_expr(mc.object, content, prefix, file_dir, out); + } + } + Expression::Call(Call::StaticMethod(sc)) => { + let ClassLikeMemberSelector::Identifier(ident) = &sc.method else { + return; + }; + let method_lower = ident.value.to_ascii_lowercase(); + + if method_lower == b"group" { + let array_prefix = extract_as_prefix_from_args( + sc.argument_list.arguments.iter().map(|a| a.value()), + content, + ); + let new_prefix = format!("{prefix}{array_prefix}"); + for arg in sc.argument_list.arguments.iter() { + collect_names_from_group_body(arg.value(), content, &new_prefix, file_dir, out); + } + } else if method_lower == b"resource" || method_lower == b"apiresource" { + // Route::resource('presses', Controller::class) without only/except + // generates all conventional route names. + if let Some((res_name, _, is_api)) = extract_resource_info(sc, content) { + let suffixes = filtered_resource_suffixes(is_api, &[], &[]); + let base = resource_name_base(res_name); + for suffix in suffixes { + let full = format!("{prefix}{base}.{suffix}"); + out.push(full); + } + } + } + } + _ => {} + } +} + +fn collect_names_from_group_body<'a>( + expr: &Expression<'a>, + content: &str, + prefix: &str, + file_dir: Option<&std::path::Path>, + out: &mut Vec, +) { + match expr { + Expression::Closure(closure) => { + for stmt in closure.body.statements.iter() { + collect_names_from_stmt(stmt, content, prefix, file_dir, out); + } + } + Expression::ArrowFunction(af) => { + collect_names_from_expr(af.expression, content, prefix, file_dir, out); + } + _ => { + // Handle `Route::group([], __DIR__ . '/sub.php')` file includes. + // The expression is a binary concatenation: `__DIR__ . '/path'`. + if let Some(dir) = file_dir + && let Some(rel_path) = extract_dir_concat_path(expr, content) + { + let included = dir.join(rel_path.trim_start_matches('/')); + if let Ok(included_content) = std::fs::read_to_string(&included) { + let sub_dir = included.parent().map(|d| d.to_path_buf()); + // Scan the included file with the current prefix so + // routes inherit the parent group's name (e.g. + // "eaglesys::"). Do NOT scan with empty prefix โ€” + // that produces unprefixed names that are incorrect. + let arena2 = Bump::new(); + let fid2 = FileId::new(b"included.php"); + let prog2 = mago_syntax::parser::parse_file_content( + &arena2, + fid2, + included_content.as_bytes(), + ); + for stmt in prog2.statements.iter() { + collect_names_from_stmt( + stmt, + &included_content, + prefix, + sub_dir.as_deref(), + out, + ); + } + } + } + } + } +} + +/// Try to extract a relative path from a `__DIR__ . '/path.php'` expression. +/// +/// Returns the string literal portion (e.g. `"/ems/accounting.php"`). +fn extract_dir_concat_path<'a>(expr: &Expression<'a>, content: &'a str) -> Option<&'a str> { + let Expression::Binary(bin) = expr else { + return None; + }; + // Check LHS is __DIR__ + let is_dir = matches!( + bin.lhs, + Expression::MagicConstant(MagicConstant::Directory { .. }) + ); + if !is_dir { + return None; + } + // RHS should be a string literal + let Expression::Literal(literal::Literal::String(s)) = bin.rhs else { + return None; + }; + if let Some(value) = s.value { + Some(crate::atom::bytes_to_str(value)) + } else { + let start = s.span.start.offset as usize + 1; + let end = s.span.end.offset as usize - 1; + if start < end && end <= content.len() { + Some(&content[start..end]) + } else { + None + } } } diff --git a/src/virtual_members/laravel/trans_keys.rs b/src/virtual_members/laravel/trans_keys.rs index 782ed6e5..77236c31 100644 --- a/src/virtual_members/laravel/trans_keys.rs +++ b/src/virtual_members/laravel/trans_keys.rs @@ -7,28 +7,32 @@ use crate::Backend; use crate::atom::bytes_to_str; /// Resolve `__('file.key')` / `trans('file.key')` / `Lang::get('file.key')` to the -/// matching keys inside all matching `lang/{locale}/file.php` translation files. +/// matching keys inside all matching `lang/{locale}/file.php` translation files, +/// or inside `lang/{locale}.json` JSON translation files. +/// +/// For PHP files the key format is `file_stem.nested.key` (first segment = file, +/// rest = array path). For JSON files the key is looked up directly as a +/// top-level object key (Laravel's JSON translations are flat). /// -/// The key format is `file_stem.nested.key` (first segment = file, rest = array path). /// Falls back to the top of the file when the exact key cannot be located. pub(crate) fn resolve_trans_definitions(backend: &Backend, key: &str) -> Vec { - let Some(file_stem) = key.split('.').next() else { - return Vec::new(); - }; - let snapshot = backend.user_file_symbol_maps(); let mut results = Vec::new(); + + // PHP file-based translations: first segment is the file stem. + let file_stem = key.split('.').next().unwrap_or(key); let target_suffix = format!("/{file_stem}.php"); - for (file_uri, _) in snapshot { - // Check if the file is in a lang directory and matches the stem. - if (file_uri.contains("/lang/") || file_uri.contains("/resources/lang/")) - && file_uri.ends_with(&target_suffix) - { - let Ok(uri) = Url::parse(&file_uri) else { + for (file_uri, _) in &snapshot { + if !(file_uri.contains("/lang/") || file_uri.contains("/resources/lang/")) { + continue; + } + + if file_uri.ends_with(&target_suffix) { + let Ok(uri) = Url::parse(file_uri) else { continue; }; - let Some(content) = backend.get_file_content(&file_uri) else { + let Some(content) = backend.get_file_content(file_uri) else { continue; }; @@ -43,18 +47,41 @@ pub(crate) fn resolve_trans_definitions(backend: &Backend, key: &str) -> Vec>(&content) + && map.contains_key(key) + && let Ok(uri) = Url::from_file_path(&path) + { + results.push(crate::definition::point_location(uri, Position::new(0, 0))); + } + } + } + } + results } // โ”€โ”€โ”€ Declaration extractor (mirrors config_keys logic) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ #[derive(Debug)] -struct TransKeyMatch { - key: String, - start: usize, +pub(crate) struct TransKeyMatch { + pub key: String, + pub start: usize, } -fn collect_trans_declarations(content: &str, file_stem: &str) -> Vec { +pub(crate) fn collect_trans_declarations(content: &str, file_stem: &str) -> Vec { let arena = Bump::new(); let file_id = FileId::new(b"input.php"); let program = mago_syntax::parser::parse_file_content(&arena, file_id, content.as_bytes());