From 58f0f8ca136a06b104357cadd514a3a7553f30d6 Mon Sep 17 00:00:00 2001 From: Caleb White Date: Sat, 11 Jul 2026 19:50:33 -0500 Subject: [PATCH 1/2] feat: Laravel string key completion, hover, and diagnostics Add autocompletion, hover, and invalid-key diagnostics for route names, config keys, view names, and translation keys. Completion: - route('|') / to_route('|') -> route names from routes/*.php - config('|') / Config::get('|') -> config keys from config/*.php - view('|') / View::make('|') -> view templates from resources/views/ - __('|') / trans('|') / Lang::get('|') -> translation keys from lang/ - Route::resource() / apiResource() with ->only() / ->except() - Route::group([], __DIR__ . '/sub.php') file includes with prefix - Container attributes (#[Config], #[Database], #[Cache], #[Log], #[Storage], #[Auth]) with FQN-verified imports - Facade methods (Auth::guard(), DB::connection(), Cache::store(), Log::channel(), Storage::disk()) and auth() helper - TextEdit-based so dots don't break the completion popup Hover: - Shows key kind (Route/Config/View/Trans), the key value, and the file where it's defined Diagnostics: - Warns on unknown route names, config keys, view names, and translation keys (e.g. Unknown route: 'dashbaord') - Only flags plain string literals, not dynamic/interpolated keys Also adds to_route() to extraction spans for go-to-def/references. --- docs/CHANGELOG.md | 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 | 87 ++- src/lib.rs | 33 + src/parser/ast_update.rs | 4 + src/symbol_map/extraction.rs | 119 ++- src/virtual_members/laravel/mod.rs | 5 +- src/virtual_members/laravel/route_names.rs | 519 +++++++++++++- src/virtual_members/laravel/trans_keys.rs | 61 +- 13 files changed, 1765 insertions(+), 133 deletions(-) create mode 100644 src/completion/laravel_string_keys.rs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index c48aaeef..024c83cc 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -9,6 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **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. - **Macro hover shows origin and inferred return types.** Hovering on a macro method call now displays a "macro" indicator instead of the generic "virtual" label, distinguishing `::macro()` registrations from `@method`/`@mixin` synthesized members. When the closure has no explicit return type hint, the return type is inferred from the closure body and shown with an "(inferred)" annotation. Bare `$this` / `self` / `static` returns preserve their keyword form, and method chains like `$this->transform(...)` use the last method's declared return type directly, preserving `$this`, `static`, and generic parameters that the general resolver would flatten to a bare class name. Regular (non-macro) methods with inferred return types also show the "(inferred)" annotation on hover. Contributed by @calebdw. - **Return type mismatch diagnostics (`type_mismatch_return`).** Functions and methods with a declared return type are now checked against their `return` statements. Incompatible return values are flagged as errors. Void functions returning a value and bare `return;` in non-void functions are also flagged. Generators (functions using `yield`) are skipped. Uses the same conservative `is_type_compatible` policy as argument type checking to avoid false positives. Contributed by @calebdw. - **Property type assignment diagnostics (`type_mismatch_property`).** Assignments to typed properties (`$this->prop = expr` and `self::$prop = expr`) are checked against the declared property type. Incompatible values are flagged as errors. Only plain `=` assignments are checked; compound operators (`+=`, `.=`, etc.) are skipped. Untyped and `mixed` properties are not flagged. Contributed by @calebdw. diff --git a/docs/todo/laravel.md b/docs/todo/laravel.md index 1567fed3..0c9ed4d6 100644 --- a/docs/todo/laravel.md +++ b/docs/todo/laravel.md @@ -589,55 +589,6 @@ The scanners already enumerate every valid key for go-to-definition. The items below mostly wire that existing enumeration into three more LSP endpoints rather than building new analysis. -#### L14. Diagnostics for Laravel string keys - -**Impact: High · Effort: Medium** - -No Laravel string key is currently validated. A typo in `route('dashbaord')`, -`config('app.naem')`, `env('DB_CONNCTION')`, `__('auth.failedd')`, or -`view('layouts.ap')` produces no warning — the bug surfaces only at runtime. -This is the single highest-value gap, because it catches a class of error -that PHP itself never reports. - -Emit a warning when a string argument in a recognised context resolves to no -declaration. The candidate set is the same one the go-to-definition scanners -already build, so the diagnostic is "key not in the collected set." Each -construct reuses its existing scanner: - -- `route('name')` → not in collected `->name()` declarations. -- `config('a.b.c')` → not in flattened `config/*.php` keys. -- `env('KEY')` → not in `.env` / `.env.example`. -- `__()`/`trans()`/`trans_choice()` → not in `lang/**` keys. -- `view('a.b')` → no matching file under `resources/views/`. - -Pair each with a quick-fix where cheap: "create missing view file," -"add missing key to `.env` (copy from `.env.example`)" (mirrors the -extension's two quick-fixes). Guard against false positives from genuinely -dynamic keys (e.g. `config($key)` with a variable, or -`route("admin.$section")` interpolation) by only flagging plain string -literals. - -#### L15. Completion for Laravel string keys - -**Impact: High · Effort: Medium** - -Completion exists only for Eloquent relations/columns. Extend it to offer -route names, config keys (dot-notation drill-down), env var names, -translation keys, and view names inside the corresponding string contexts. -The candidate lists are exactly the declaration sets the go-to scanners -already produce; the work is detecting the string-literal cursor context -(the symbol-map already records these as `LaravelStringKey`) and returning -the collected keys as completion items. - -#### L16. Hover for Laravel string keys - -**Impact: Medium · Effort: Low-Medium** - -`SymbolKind::LaravelStringKey` currently returns `None` from hover -(`hover/mod.rs`). Show the resolved target: the config/env/translation -*value*, the route's URI + action, or the view's file path. The data is -already loaded by the go-to scanners; this is formatting it as hover markdown. - #### L17. Additional string contexts without booting **Impact: Medium · Effort: Medium** diff --git a/src/completion/handler.rs b/src/completion/handler.rs index e0b791bd..98daccd7 100644 --- a/src/completion/handler.rs +++ b/src/completion/handler.rs @@ -359,7 +359,6 @@ impl Backend { let string_ctx = crate::completion::comment_position::classify_string_context(&content, position); use crate::completion::comment_position::StringContext; - // ── Array shape key completion ─────────────────────────── // Runs before `InStringLiteral` suppression because in // normal code `$arr['` puts the scanner inside a @@ -373,6 +372,24 @@ impl Backend { return Ok(Some(response)); } + // ── Laravel string key completion (route/config/view/trans) ── + // Inside `route('|')`, `config('|')`, `view('|')`, `__('|')`, + // etc., offer matching key names from the project. + // NB: `is_laravel` is extracted to a `let` so the read lock + // on `resolved_class_cache` is dropped before calling + // `try_laravel_string_key_completion`, which may trigger + // `ensure_workspace_indexed` → `update_ast` → write lock. + let is_laravel = self.resolved_class_cache.read().is_laravel(); + if is_laravel + && matches!( + string_ctx, + StringContext::InStringLiteral | StringContext::NotInString + ) + && let Some(response) = self.try_laravel_string_key_completion(&content, position) + { + return Ok(Some(response)); + } + // ── Eloquent relation/column string completion ────────── // Like array shape completion, this triggers inside string // literals where the cursor is in a method argument position diff --git a/src/completion/laravel_string_keys.rs b/src/completion/laravel_string_keys.rs new file mode 100644 index 00000000..5528cd37 --- /dev/null +++ b/src/completion/laravel_string_keys.rs @@ -0,0 +1,796 @@ +//! Laravel string key completion. +//! +//! Offers autocompletion for route names, config keys, view names, and +//! translation keys inside their respective helper calls: +//! +//! - `route('|')` / `to_route('|')` → route names +//! - `config('|')` / `Config::get('|')` → config keys +//! - `view('|')` / `View::make('|')` → view names +//! - `__('|')` / `trans('|')` / `Lang::get('|')` → translation keys + +use tower_lsp::lsp_types::*; + +use crate::Backend; +use crate::symbol_map::LaravelStringKind; +use crate::util::position_to_offset; + +// ─── Context ──────────────────────────────────────────────────────────────── + +struct LaravelStringKeyContext { + kind: LaravelStringKind, + prefix: String, + /// Byte offset of the string content start (right after the opening quote). + content_start_offset: usize, + /// When set, the key is a sub-key under this config path prefix. + /// For example, `#[Database('mysql')]` sets this to `"database.connections."` + /// so completion filters to `database.connections.*` keys and strips the + /// prefix, showing just `mysql`, `sqlite`, etc. + config_sub_prefix: Option<&'static str>, +} + +// ─── Detection ────────────────────────────────────────────────────────────── + +/// Detect if the cursor is inside the first string argument of a Laravel +/// helper function. Returns the key kind and the prefix typed so far. +fn detect_laravel_string_key_context( + content: &str, + position: Position, +) -> Option { + let cursor_offset = position_to_offset(content, position) as usize; + let bytes = content.as_bytes(); + + if cursor_offset == 0 || cursor_offset > bytes.len() { + return None; + } + + // ── Find the opening quote before the cursor ──────────────────── + let mut quote_pos = None; + let mut i = cursor_offset; + while i > 0 { + i -= 1; + let ch = bytes[i]; + if ch == b'\'' || ch == b'"' { + let mut bs = 0; + let mut j = i; + while j > 0 && bytes[j - 1] == b'\\' { + bs += 1; + j -= 1; + } + if bs % 2 == 0 { + quote_pos = Some(i); + break; + } + } + if ch == b'\n' { + return None; + } + } + let quote_pos = quote_pos?; + let prefix = content[quote_pos + 1..cursor_offset].to_string(); + + // ── Before the quote, expect `(` (first argument) ─────────────── + let before_quote = content[..quote_pos].trim_end(); + if !before_quote.ends_with('(') { + return None; + } + let before_paren = before_quote[..before_quote.len() - 1].trim_end(); + + // ── Extract the function/method name ──────────────────────────── + let bp_bytes = before_paren.as_bytes(); + let name_end = bp_bytes.len(); + let mut name_start = name_end; + while name_start > 0 + && (bp_bytes[name_start - 1].is_ascii_alphanumeric() || bp_bytes[name_start - 1] == b'_') + { + name_start -= 1; + } + if name_start == name_end { + return None; + } + let func_name = &before_paren[name_start..name_end]; + + // ── Check for static method syntax (Config::get, etc.) ────────── + let before_name = &before_paren[..name_start]; + let is_static = before_name.trim_end().ends_with("::"); + + // Check for instance method call (->route() or ?->route()) + let trimmed_before = before_name.trim_end(); + let is_instance_method = trimmed_before.ends_with("->") || trimmed_before.ends_with("?->"); + + // Check for PHP attribute syntax: #[Config('key')] or + // #[\Illuminate\Container\Attributes\Config('key')]. + // Strip trailing `\Identifier` segments to handle FQN attributes, + // then check for `#[`. Never search the entire file prefix — + // an unrelated attribute (e.g. `#[Override]`) would false-positive. + let is_attribute = { + let mut s = trimmed_before; + loop { + let stripped = s.trim_end_matches(|c: char| c.is_ascii_alphanumeric() || c == '_'); + if stripped.len() < s.len() && stripped.ends_with('\\') { + s = &stripped[..stripped.len() - 1]; + } else { + s = stripped; + break; + } + } + s.ends_with("#[") || s.ends_with("#") + }; + + // ── Map container attributes to config sub-prefixes ──────────── + let (kind, config_sub_prefix) = if is_attribute { + // Resolve the attribute to its Laravel FQN. When the name is + // fully qualified (contains `\`), match the FQN directly. + // When it's a short name, verify the file imports it from + // `Illuminate\Container\Attributes\`. + const ATTR_NS: &str = "Illuminate\\Container\\Attributes\\"; + + // Reconstruct the full attribute class name by scanning backwards + // past namespace separators. `func_name` only captured the last + // segment (e.g. `Config`), but the FQN parts (if any) are in + // `before_name` (e.g. `#[\Illuminate\Container\Attributes\`). + let full_attr_name = { + let bn = before_name.trim_end().trim_end_matches('\\'); + // Check for `#[` or `#[\` prefix — extract everything after `#[` + if let Some(idx) = bn.rfind("#[") { + let after_hash = &bn[idx + 2..].trim_start_matches('\\'); + if after_hash.is_empty() { + func_name.to_string() + } else { + format!("{}\\{}", after_hash, func_name) + } + } else { + func_name.to_string() + } + }; + let attr_class = full_attr_name.trim_start_matches('\\'); + let short = attr_class.rsplit('\\').next().unwrap_or(attr_class); + + let is_fqn = attr_class.contains('\\'); + let fqn_matches = |expected_short: &str| -> bool { + if is_fqn { + attr_class == format!("{}{}", ATTR_NS, expected_short) + } else if short == expected_short { + // Verify the import exists in the file. + content.contains(&format!("use {}{};", ATTR_NS, expected_short)) + || content.contains(&format!("use {}{{", ATTR_NS)) + } else { + false + } + }; + + if fqn_matches("Config") { + (Some(LaravelStringKind::Config), None) + } else if fqn_matches("Database") || fqn_matches("DB") { + ( + Some(LaravelStringKind::Config), + Some("database.connections."), + ) + } else if fqn_matches("Cache") { + (Some(LaravelStringKind::Config), Some("cache.stores.")) + } else if fqn_matches("Log") { + (Some(LaravelStringKind::Config), Some("logging.channels.")) + } else if fqn_matches("Storage") { + (Some(LaravelStringKind::Config), Some("filesystems.disks.")) + } else if fqn_matches("Auth") || fqn_matches("Authenticated") { + (Some(LaravelStringKind::Config), Some("auth.guards.")) + } else { + (None, None) + } + } else if is_static { + let before_colons = &trimmed_before[..trimmed_before.len() - 2].trim_end(); + let bc_bytes = before_colons.as_bytes(); + let mut cls_start = bc_bytes.len(); + while cls_start > 0 + && (bc_bytes[cls_start - 1].is_ascii_alphanumeric() + || bc_bytes[cls_start - 1] == b'_' + || bc_bytes[cls_start - 1] == b'\\') + { + cls_start -= 1; + } + let class_name = &before_colons[cls_start..]; + let short = class_name.rsplit('\\').next().unwrap_or(class_name); + let fn_lower = func_name.to_ascii_lowercase(); + + match (short.to_ascii_lowercase().as_str(), fn_lower.as_str()) { + ( + "config", + "get" | "set" | "has" | "boolean" | "array" | "collection" | "prepend" | "push", + ) => (Some(LaravelStringKind::Config), None), + ("view", "make" | "exists") => (Some(LaravelStringKind::View), None), + ("lang", "get" | "has" | "choice") => (Some(LaravelStringKind::Trans), None), + // Facade methods that accept config sub-keys: + ("auth", "guard") => (Some(LaravelStringKind::Config), Some("auth.guards.")), + ("db", "connection") => ( + Some(LaravelStringKind::Config), + Some("database.connections."), + ), + ("cache", "store") => (Some(LaravelStringKind::Config), Some("cache.stores.")), + ("log", "channel") => (Some(LaravelStringKind::Config), Some("logging.channels.")), + ("storage", "disk") => (Some(LaravelStringKind::Config), Some("filesystems.disks.")), + _ => (None, None), + } + } else if is_instance_method { + let k = match func_name.to_ascii_lowercase().as_str() { + "route" => Some(LaravelStringKind::Route), + _ => None, + }; + (k, None) + } else { + match func_name.to_ascii_lowercase().as_str() { + "route" | "to_route" => (Some(LaravelStringKind::Route), None), + "config" => (Some(LaravelStringKind::Config), None), + "view" | "blade_view_directive" => (Some(LaravelStringKind::View), None), + "__" | "trans" | "trans_choice" => (Some(LaravelStringKind::Trans), None), + // auth('guard') helper accepts a guard name + "auth" => (Some(LaravelStringKind::Config), Some("auth.guards.")), + _ => (None, None), + } + }; + + let kind = kind?; + + Some(LaravelStringKeyContext { + kind, + prefix, + content_start_offset: quote_pos + 1, + config_sub_prefix, + }) +} + +// ─── Enumeration ──────────────────────────────────────────────────────────── + +impl Backend { + /// Enumerate all view names by scanning `resources/views/` file URIs. + fn enumerate_all_view_names(&self) -> Vec { + let snapshot = self.user_file_symbol_maps(); + let mut names = Vec::new(); + + for (file_uri, _) in snapshot { + // Match resources/views/**/*.blade.php or *.php + let Some(rel) = extract_view_relative_path(&file_uri) else { + continue; + }; + names.push(rel); + } + names.sort(); + names.dedup(); + names + } + + /// Enumerate all config keys by scanning `config/` files. + fn enumerate_all_config_keys(&self) -> Vec { + use crate::virtual_members::laravel::{ + collect_laravel_config_declarations, laravel_config_prefix_from_uri, + }; + + let snapshot = self.user_file_symbol_maps(); + let mut keys = Vec::new(); + + for (file_uri, _) in &snapshot { + let Some(prefix) = laravel_config_prefix_from_uri(file_uri) else { + continue; + }; + let Some(content) = self.get_file_content(file_uri) else { + continue; + }; + let decls = collect_laravel_config_declarations(&content, &prefix); + for d in decls { + keys.push(d.key); + } + } + keys.sort(); + keys.dedup(); + keys + } + + /// Enumerate all translation keys by scanning `lang/` files. + /// + /// Supports both PHP array files (`lang/en/messages.php` → `messages.key`) + /// and JSON translation files (`lang/en.json` → raw key strings). + fn enumerate_all_trans_keys(&self) -> Vec { + let snapshot = self.user_file_symbol_maps(); + let mut keys = Vec::new(); + + for (file_uri, _) in &snapshot { + if !(file_uri.contains("/lang/") || file_uri.contains("/resources/lang/")) { + continue; + } + if !file_uri.ends_with(".php") { + continue; + } + let Some(stem) = extract_lang_file_stem(file_uri) else { + continue; + }; + let Some(content) = self.get_file_content(file_uri) else { + continue; + }; + let decls = + crate::virtual_members::laravel::collect_trans_declarations(&content, &stem); + for d in decls { + keys.push(d.key); + } + } + + // Also collect keys from JSON translation files on disk. + collect_json_trans_keys(self, &mut keys); + + keys.sort(); + keys.dedup(); + keys + } + + pub(crate) fn cached_route_names(&self) -> Vec { + { + let cache = self.laravel_string_key_cache.read(); + if let Some(ref names) = cache.route_names { + return names.clone(); + } + } + let names = crate::virtual_members::laravel::enumerate_all_route_names(self); + self.laravel_string_key_cache.write().route_names = Some(names.clone()); + names + } + + pub(crate) fn cached_config_keys(&self) -> Vec { + { + let cache = self.laravel_string_key_cache.read(); + if let Some(ref keys) = cache.config_keys { + return keys.clone(); + } + } + let keys = self.enumerate_all_config_keys(); + self.laravel_string_key_cache.write().config_keys = Some(keys.clone()); + keys + } + + pub(crate) fn cached_view_names(&self) -> Vec { + { + let cache = self.laravel_string_key_cache.read(); + if let Some(ref names) = cache.view_names { + return names.clone(); + } + } + let names = self.enumerate_all_view_names(); + self.laravel_string_key_cache.write().view_names = Some(names.clone()); + names + } + + pub(crate) fn cached_trans_keys(&self) -> Vec { + { + let cache = self.laravel_string_key_cache.read(); + if let Some(ref keys) = cache.trans_keys { + return keys.clone(); + } + } + let keys = self.enumerate_all_trans_keys(); + self.laravel_string_key_cache.write().trans_keys = Some(keys.clone()); + keys + } +} + +/// Extract the dot-notated view name from a file URI. +/// +/// `file:///path/resources/views/users/profile.blade.php` → `"users.profile"` +pub(crate) fn extract_view_relative_path(uri: &str) -> Option { + let marker = "/resources/views/"; + let idx = uri.find(marker)?; + let rel = &uri[idx + marker.len()..]; + let name = rel + .strip_suffix(".blade.php") + .or_else(|| rel.strip_suffix(".php"))?; + if name.is_empty() { + return None; + } + Some(name.replace('/', ".")) +} + +/// Extract the file stem from a lang file URI for use as the translation +/// key prefix. +/// +/// `file:///path/lang/en/messages.php` → `"messages"` +fn extract_lang_file_stem(uri: &str) -> Option { + let file = uri.rsplit('/').next()?; + let stem = file.strip_suffix(".php")?; + if stem.is_empty() { + return None; + } + Some(stem.to_string()) +} + +/// Scan the workspace for `lang/*.json` files and collect their top-level +/// keys into `out`. Laravel's JSON translations are flat +/// `{ "Some phrase": "Translated phrase" }` objects where the key is used +/// directly in `__('Some phrase')`. +/// +/// We scan the filesystem because JSON files are not PHP and therefore do +/// not appear in `user_file_symbol_maps()`. +fn collect_json_trans_keys(backend: &crate::Backend, out: &mut Vec) { + let root = match backend.workspace_root.read().clone() { + Some(r) => r, + None => return, + }; + for sub in &["lang", "resources/lang"] { + let dir = root.join(sub); + let Ok(entries) = std::fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().is_some_and(|e| e == "json") + && let Ok(content) = std::fs::read_to_string(&path) + && let Ok(map) = + serde_json::from_str::>(&content) + { + for k in map.keys() { + out.push(k.clone()); + } + } + } + } +} + +// ─── Completion ───────────────────────────────────────────────────────────── + +impl Backend { + /// Try Laravel string key completion. + /// + /// Detects the cursor inside the first string argument of `route()`, + /// `config()`, `view()`, `__()`, etc. and offers matching key names. + pub(crate) fn try_laravel_string_key_completion( + &self, + content: &str, + position: Position, + ) -> Option { + let ctx = detect_laravel_string_key_context(content, position)?; + + let mut candidates = match ctx.kind { + LaravelStringKind::Route => self.cached_route_names(), + LaravelStringKind::Config => self.cached_config_keys(), + LaravelStringKind::View => self.cached_view_names(), + LaravelStringKind::Trans => self.cached_trans_keys(), + }; + + // For config-backed attributes like #[Database('mysql')], filter + // to sub-keys under the relevant config prefix and strip it so + // the user sees just the connection/store/channel name. + if let Some(sub_prefix) = ctx.config_sub_prefix { + candidates = candidates + .into_iter() + .filter_map(|key| { + key.strip_prefix(sub_prefix).and_then(|rest| { + // Only show direct children (no dots = leaf key). + if rest.contains('.') { + None + } else { + Some(rest.to_string()) + } + }) + }) + .collect(); + candidates.sort(); + candidates.dedup(); + } + + // Build the TextEdit range: from the start of the string content + // (right after the opening quote) to the current cursor position. + // This replaces the entire typed prefix with the selected name, + // so dots in the name don't break the editor's word-based filter. + let start_pos = crate::util::offset_to_position(content, ctx.content_start_offset); + let edit_range = Range { + start: start_pos, + end: position, + }; + + let prefix_lower = ctx.prefix.to_lowercase(); + let items: Vec = candidates + .into_iter() + .filter(|name| { + if prefix_lower.is_empty() { + true + } else { + name.to_lowercase().starts_with(&prefix_lower) + } + }) + .enumerate() + .map(|(i, name)| { + let kind = match ctx.kind { + LaravelStringKind::Route => CompletionItemKind::VALUE, + LaravelStringKind::Config => CompletionItemKind::PROPERTY, + LaravelStringKind::View => CompletionItemKind::FILE, + LaravelStringKind::Trans => CompletionItemKind::TEXT, + }; + CompletionItem { + label: name.clone(), + kind: Some(kind), + sort_text: Some(format!("{:05}", i)), + filter_text: Some(name.clone()), + text_edit: Some(CompletionTextEdit::Edit(TextEdit { + range: edit_range, + new_text: name, + })), + ..Default::default() + } + }) + .collect(); + + if items.is_empty() { + None + } else { + Some(CompletionResponse::Array(items)) + } + } +} + +// ─── Tests ────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use tower_lsp::lsp_types::Position; + + #[test] + fn detects_route_call() { + let content = " 'home')->name('home');\n\ + Route::get('/about', fn() => 'about')->name('about');\n"; + backend.open_files.write().insert( + route_uri.to_string(), + std::sync::Arc::new(route_content.to_string()), + ); + backend.update_ast(route_uri, route_content); + + let test_content = " = items.iter().map(|i| i.label.as_str()).collect(); + assert!( + labels.contains(&"home"), + "completion should include 'home', got: {:?}", + labels + ); + assert!( + labels.contains(&"about"), + "completion should include 'about', got: {:?}", + labels + ); + } + } +} diff --git a/src/completion/mod.rs b/src/completion/mod.rs index 9b0fe95c..9618d67a 100644 --- a/src/completion/mod.rs +++ b/src/completion/mod.rs @@ -80,6 +80,7 @@ pub(crate) mod call_resolution; pub(crate) mod eloquent_string; pub(crate) mod handler; pub(crate) mod laravel_route_controller; +pub(crate) mod laravel_string_keys; pub mod named_args; pub(crate) mod resolve; pub(crate) mod resolver; diff --git a/src/diagnostics/mod.rs b/src/diagnostics/mod.rs index 62d6fe51..35d8fe34 100644 --- a/src/diagnostics/mod.rs +++ b/src/diagnostics/mod.rs @@ -295,6 +295,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), + )); + } + } } /// Collect all type mismatch diagnostics: argument types, return @@ -3078,4 +3204,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 6ab44403..64f5bc1b 100644 --- a/src/hover/mod.rs +++ b/src/hover/mod.rs @@ -865,14 +865,97 @@ impl Backend { } } - SymbolKind::LaravelStringKey { .. } - | SymbolKind::LaravelMacroString { .. } + SymbolKind::LaravelStringKey { kind, key } => self.hover_laravel_string_key(kind, key), + + SymbolKind::LaravelMacroString { .. } | SymbolKind::Keyword | SymbolKind::CastType | SymbolKind::Comment => None, } } + /// Build hover content for a Laravel string key (route name, config + /// key, view name, or translation key). + fn hover_laravel_string_key( + &self, + kind: &crate::symbol_map::LaravelStringKind, + key: &str, + ) -> Option { + use crate::symbol_map::LaravelStringKind; + + let (label, detail) = match kind { + LaravelStringKind::Route => { + // Try to resolve the route to show where it's defined. + let locations = + crate::virtual_members::laravel::resolve_laravel_string_key(self, kind, key); + let detail = if let Some(loc) = locations.first() { + let path = loc.uri.path(); + let short_path = path + .rsplit("/routes/") + .next() + .map(|p| format!("routes/{}", p)) + .unwrap_or_else(|| path.to_string()); + format!("Defined in `{}`", short_path) + } else { + "Route name".to_string() + }; + ("Route", detail) + } + LaravelStringKind::Config => { + // Try to resolve the config key to show its value. + let locations = + crate::virtual_members::laravel::resolve_laravel_string_key(self, kind, key); + let detail = if let Some(loc) = locations.first() { + let path = loc.uri.path(); + let short_path = path + .rsplit("/config/") + .next() + .map(|p| format!("config/{}", p)) + .unwrap_or_else(|| path.to_string()); + format!("Defined in `{}`", short_path) + } else { + "Config key".to_string() + }; + ("Config", detail) + } + LaravelStringKind::View => { + // Show the resolved file path. + let locations = + crate::virtual_members::laravel::resolve_laravel_string_key(self, kind, key); + let detail = if let Some(loc) = locations.first() { + let path = loc.uri.path(); + let short_path = path + .rsplit("/resources/views/") + .next() + .map(|p| format!("resources/views/{}", p)) + .unwrap_or_else(|| path.to_string()); + format!("`{}`", short_path) + } else { + "View template".to_string() + }; + ("View", detail) + } + LaravelStringKind::Trans => { + let locations = + crate::virtual_members::laravel::resolve_laravel_string_key(self, kind, key); + let detail = if let Some(loc) = locations.first() { + let path = loc.uri.path(); + let short_path = path + .rsplit("/lang/") + .next() + .map(|p| format!("lang/{}", p)) + .unwrap_or_else(|| path.to_string()); + format!("Defined in `{}`", short_path) + } else { + "Translation key".to_string() + }; + ("Trans", detail) + } + }; + + Some(make_hover(format!("**{}** `{}`\n\n{}", label, key, detail))) + } + /// Look up a global constant by name, returning its value if found. /// /// Searches in order: diff --git a/src/lib.rs b/src/lib.rs index 32b4ce14..1b2a8f56 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -233,6 +233,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, @@ -494,6 +519,11 @@ pub struct Backend { /// The outer option is `None` until startup discovery completes; the inner /// option is `None` when discovery found no project override. pub(crate) laravel_date_class: 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 @@ -916,6 +946,7 @@ impl Backend { laravel_has_macros: Arc::new(std::sync::atomic::AtomicBool::new(false)), laravel_macro_seeds: Arc::new(RwLock::new(HashMap::new())), laravel_date_class: Arc::new(RwLock::new(None)), + 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())), @@ -1012,6 +1043,7 @@ impl Backend { laravel_has_macros: Arc::new(std::sync::atomic::AtomicBool::new(false)), laravel_macro_seeds: Arc::new(RwLock::new(HashMap::new())), laravel_date_class: Arc::new(RwLock::new(None)), + 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())), @@ -1607,6 +1639,7 @@ impl Backend { laravel_has_macros: Arc::clone(&self.laravel_has_macros), laravel_macro_seeds: Arc::clone(&self.laravel_macro_seeds), laravel_date_class: Arc::clone(&self.laravel_date_class), + 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 198fe97f..b0f47c5b 100644 --- a/src/parser/ast_update.rs +++ b/src/parser/ast_update.rs @@ -80,6 +80,10 @@ impl Backend { content.to_string() }; + self.laravel_string_key_cache + .write() + .invalidate_for_uri(uri); + // The mago-syntax parser contains `unreachable!()` and `.expect()` // calls that can panic on malformed PHP (e.g. partially-written // heredocs/nowdocs, which are common while editing). Wrap the diff --git a/src/symbol_map/extraction.rs b/src/symbol_map/extraction.rs index 78da57b1..dd7a82b4 100644 --- a/src/symbol_map/extraction.rs +++ b/src/symbol_map/extraction.rs @@ -80,6 +80,9 @@ struct ExtractionCtx<'a> { /// Stack of block-end offsets for each conditional nesting level. /// The top of the stack is the end of the innermost conditional block. cond_block_end_stack: Vec, + /// Whether the file imports from `Illuminate\Container\Attributes\` + /// (checked once lazily, cached for all attribute inspections). + has_laravel_container_attrs: Option, } // ─── Keyword helper ───────────────────────────────────────────────────────── @@ -151,6 +154,7 @@ pub(crate) fn extract_symbol_map(program: &Program<'_>, content: &str) -> Symbol untyped_closure_sites: Vec::new(), cond_nesting_depth: 0, cond_block_end_stack: Vec::new(), + has_laravel_container_attrs: None, }; for stmt in program.statements.iter() { @@ -1047,6 +1051,23 @@ fn extract_from_attribute_lists<'a>( &mut ctx.untyped_closure_sites, ); } + + // Laravel container attributes: #[Config('key')], + // #[Database('conn')], #[Cache('store')], etc. → + // emit a LaravelStringKey::Config span so hover, + // go-to-definition, and diagnostics work on the key. + // + // FQN attributes match directly. Short names require + // the file to import from the Illuminate namespace; + // that check is cached once per file to avoid repeated + // linear scans. + if let Some(kind) = resolve_laravel_container_attr( + class_name, + &mut ctx.has_laravel_container_attrs, + ctx.content, + ) { + try_emit_laravel_string_span(kind, arg_list, ctx.content, &mut ctx.spans); + } } } } @@ -1956,38 +1977,32 @@ fn extract_from_expression<'a>( is_definition: false, }, }); - if name_clean.eq_ignore_ascii_case("config") { - try_emit_laravel_string_span( - crate::symbol_map::LaravelStringKind::Config, - &func_call.argument_list, - ctx.content, - &mut ctx.spans, - ); - } - if name_clean.eq_ignore_ascii_case("view") + // Detect Laravel helper calls and emit a + // LaravelStringKey span for the first string arg. + // Uses if-else to short-circuit (most function calls + // won't match) and avoids to_ascii_lowercase() heap + // allocations. + let laravel_kind = if name_clean.eq_ignore_ascii_case("config") { + Some(crate::symbol_map::LaravelStringKind::Config) + } else if name_clean.eq_ignore_ascii_case("view") || name_clean.eq_ignore_ascii_case("blade_view_directive") { + Some(crate::symbol_map::LaravelStringKind::View) + } else if name_clean.eq_ignore_ascii_case("route") + || name_clean.eq_ignore_ascii_case("to_route") + { + Some(crate::symbol_map::LaravelStringKind::Route) + } else if name_clean.eq_ignore_ascii_case("__") + || name_clean.eq_ignore_ascii_case("trans") + || name_clean.eq_ignore_ascii_case("trans_choice") + { + Some(crate::symbol_map::LaravelStringKind::Trans) + } else { + None + }; + if let Some(kind) = laravel_kind { try_emit_laravel_string_span( - crate::symbol_map::LaravelStringKind::View, - &func_call.argument_list, - ctx.content, - &mut ctx.spans, - ); - } - if name_clean.eq_ignore_ascii_case("route") { - try_emit_laravel_string_span( - crate::symbol_map::LaravelStringKind::Route, - &func_call.argument_list, - ctx.content, - &mut ctx.spans, - ); - } - if matches!( - name_clean.to_ascii_lowercase().as_str(), - "__" | "trans" | "trans_choice" - ) { - try_emit_laravel_string_span( - crate::symbol_map::LaravelStringKind::Trans, + kind, &func_call.argument_list, ctx.content, &mut ctx.spans, @@ -3448,6 +3463,52 @@ fn is_assert_instanceof(expr: &Expression<'_>) -> bool { false } +/// Check whether an attribute class name refers to a Laravel container +/// attribute (`Config`, `Database`, `Cache`, `Log`, `Storage`, `Auth`, +/// `Authenticated`). Returns the corresponding [`LaravelStringKind`] if +/// so — always `Config` since all container attributes resolve to config +/// sub-keys. +/// +/// FQN names (containing `\`) are matched directly against +/// `Illuminate\Container\Attributes\*`. Short names require the file to +/// import from that namespace; the result of that check is cached in +/// `import_cache` to avoid repeated linear scans of the file content. +const LARAVEL_CONTAINER_ATTR_NS: &str = "Illuminate\\Container\\Attributes\\"; +const LARAVEL_CONTAINER_ATTR_NAMES: &[&str] = &[ + "Config", + "Database", + "DB", + "Cache", + "Log", + "Storage", + "Auth", + "Authenticated", +]; + +fn resolve_laravel_container_attr( + class_name: &str, + import_cache: &mut Option, + content: &str, +) -> Option { + if class_name.contains('\\') { + let stripped = class_name.strip_prefix(LARAVEL_CONTAINER_ATTR_NS)?; + if LARAVEL_CONTAINER_ATTR_NAMES.contains(&stripped) { + return Some(crate::symbol_map::LaravelStringKind::Config); + } + return None; + } + if !LARAVEL_CONTAINER_ATTR_NAMES.contains(&class_name) { + return None; + } + let has_import = *import_cache + .get_or_insert_with(|| content.contains("use Illuminate\\Container\\Attributes\\")); + if has_import { + Some(crate::symbol_map::LaravelStringKind::Config) + } else { + None + } +} + /// If the first argument of `argument_list` is a non-empty, non-interpolated /// string literal, push a [`SymbolKind::LaravelStringKey`] span covering the /// string content (inside the quotes) onto `spans`. diff --git a/src/virtual_members/laravel/mod.rs b/src/virtual_members/laravel/mod.rs index cce6dc63..92a711c8 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; @@ -107,6 +108,8 @@ pub(crate) use macros::{ inject_macros, parse_installed_providers, parse_provider_class_list, parse_provider_referenced_classes, }; +pub(crate) use route_names::enumerate_all_route_names; +pub(crate) use trans_keys::collect_trans_declarations; /// Unified go-to-definition entry point for all Laravel string-key spans. /// diff --git a/src/virtual_members/laravel/route_names.rs b/src/virtual_members/laravel/route_names.rs index edc48ea9..2bd15de2 100644 --- a/src/virtual_members/laravel/route_names.rs +++ b/src/virtual_members/laravel/route_names.rs @@ -8,6 +8,109 @@ use crate::util::offset_to_position; use super::helpers::extract_string_literal; +/// Conventional route name suffixes generated by `Route::resource()`. +const RESOURCE_SUFFIXES: &[&str] = &[ + "index", "create", "store", "show", "edit", "update", "destroy", +]; + +/// Conventional route name suffixes generated by `Route::apiResource()`. +const API_RESOURCE_SUFFIXES: &[&str] = &["index", "store", "show", "update", "destroy"]; + +/// Derive the route name base from a resource URI string. +/// +/// Laravel converts `/` to `.` and uses the result as the name prefix. +/// For example, `"photo-comments"` → `"photo-comments"`, +/// `"photos/comments"` → `"photos.comments"`. +fn resource_name_base(uri: &str) -> String { + uri.trim_matches('/').replace('/', ".") +} + +/// Build the filtered list of resource route suffixes, respecting +/// `->only()` and `->except()` modifiers. +fn filtered_resource_suffixes( + is_api: bool, + only: &[String], + except: &[String], +) -> Vec<&'static str> { + let base = if is_api { + API_RESOURCE_SUFFIXES + } else { + RESOURCE_SUFFIXES + }; + base.iter() + .copied() + .filter(|s| { + if !only.is_empty() { + only.iter().any(|o| o == s) + } else if !except.is_empty() { + !except.iter().any(|e| e == s) + } else { + true + } + }) + .collect() +} + +/// Extract string values from `->only()`/`->except()` arguments. +/// +/// Handles both array form `->only(['index', 'show'])` and individual +/// args `->except('create', 'edit')`. +fn extract_string_list_from_args<'a>(args: &'a ArgumentList<'a>, content: &'a str) -> Vec { + let mut values = Vec::new(); + for arg in args.arguments.iter() { + match arg.value() { + // Array form: ['index', 'show'] + Expression::Array(arr) => { + for el in arr.elements.iter() { + if let ArrayElement::Value(v) = el + && let Some((val, _, _)) = extract_string_literal(v.value, content) + { + values.push(val.to_string()); + } + } + } + Expression::LegacyArray(arr) => { + for el in arr.elements.iter() { + if let ArrayElement::Value(v) = el + && let Some((val, _, _)) = extract_string_literal(v.value, content) + { + values.push(val.to_string()); + } + } + } + // Individual string arg: 'create' + other => { + if let Some((val, _, _)) = extract_string_literal(other, content) { + values.push(val.to_string()); + } + } + } + } + values +} + +/// Check if a static method call is `Route::resource()` or `Route::apiResource()` +/// and extract the resource name and whether it's an API resource. +fn extract_resource_info<'a>( + sc: &'a StaticMethodCall<'a>, + content: &'a str, +) -> Option<(&'a str, usize, bool)> { + let ClassLikeMemberSelector::Identifier(ident) = &sc.method else { + return None; + }; + let method_lower = ident.value.to_ascii_lowercase(); + let is_api = if method_lower == b"resource" { + false + } else if method_lower == b"apiresource" { + true + } else { + return None; + }; + let first_arg = sc.argument_list.arguments.iter().next()?; + let (res_name, start, _) = extract_string_literal(first_arg.value(), content)?; + Some((res_name, start, is_api)) +} + /// Resolve `route('name')` to the `->name('name')` declaration in `routes/`. /// /// Supports both explicit full-name declarations: @@ -20,7 +123,6 @@ pub(crate) fn resolve_route_definitions(backend: &Backend, name: &str) -> Vec Vec Vec { +fn scan_route_file( + content: &str, + target: &str, + uri: &Url, + file_dir: Option<&std::path::Path>, +) -> Vec { let arena = Bump::new(); let file_id = FileId::new(b"input.php"); let program = mago_syntax::parser::parse_file_content(&arena, file_id, content.as_bytes()); let mut results = Vec::new(); for stmt in program.statements.iter() { - results.extend(scan_stmt(stmt, content, "", target, uri)); + results.extend(scan_stmt(stmt, content, "", target, uri, file_dir)); } results } @@ -55,12 +166,13 @@ fn scan_stmt<'a>( prefix: &str, target: &str, uri: &Url, + file_dir: Option<&std::path::Path>, ) -> Vec { match stmt { - Statement::Expression(e) => scan_expr(e.expression, content, prefix, target, uri), + Statement::Expression(e) => scan_expr(e.expression, content, prefix, target, uri, file_dir), Statement::Return(r) => r .value - .map(|v| scan_expr(v, content, prefix, target, uri)) + .map(|v| scan_expr(v, content, prefix, target, uri, file_dir)) .unwrap_or_default(), _ => Vec::new(), } @@ -79,13 +191,14 @@ fn scan_expr<'a>( prefix: &str, target: &str, uri: &Url, + file_dir: Option<&std::path::Path>, ) -> Vec { match expr { // ── Fluent instance-method chain: ->group() / ->name() / other ────── Expression::Call(Call::Method(mc)) => { let mut results = Vec::new(); let ClassLikeMemberSelector::Identifier(ident) = &mc.method else { - return scan_expr(mc.object, content, prefix, target, uri); + return scan_expr(mc.object, content, prefix, target, uri, file_dir); }; let method = ident.value.to_ascii_lowercase(); @@ -99,6 +212,7 @@ fn scan_expr<'a>( &new_prefix, target, uri, + file_dir, )); } } else if method == b"name" { @@ -114,41 +228,91 @@ fn scan_expr<'a>( )); } } - results.extend(scan_expr(mc.object, content, prefix, target, uri)); + results.extend(scan_expr(mc.object, content, prefix, target, uri, file_dir)); + } else if method == b"only" || method == b"except" { + // ->only(['index', 'show']) or ->except(['create', 'edit']) + // on a Route::resource() / Route::apiResource() call. + if let Expression::Call(Call::StaticMethod(sc)) = mc.object + && let Some((res_name, start, is_api)) = extract_resource_info(sc, content) + { + let filter = extract_string_list_from_args(&mc.argument_list, content); + let (only, except) = if method == b"only" { + (filter, Vec::new()) + } else { + (Vec::new(), filter) + }; + let suffixes = filtered_resource_suffixes(is_api, &only, &except); + let base = resource_name_base(res_name); + for suffix in suffixes { + let full = format!("{prefix}{base}.{suffix}"); + if full == target { + return vec![crate::definition::point_location( + uri.clone(), + offset_to_position(content, start), + )]; + } + } + return Vec::new(); + } + results.extend(scan_expr(mc.object, content, prefix, target, uri, file_dir)); } else { - results.extend(scan_expr(mc.object, content, prefix, target, uri)); + results.extend(scan_expr(mc.object, content, prefix, target, uri, file_dir)); } results } - // ── Direct static call: Route::group([options,] fn(){…}) ──────────── - // - // This fires for `Route::group(['as'=>'admin.', …], fn(){…})` where - // there is no preceding fluent chain to carry the name prefix. + // ── Direct static call: Route::group / Route::resource / Route::apiResource Expression::Call(Call::StaticMethod(sc)) => { let ClassLikeMemberSelector::Identifier(ident) = &sc.method else { return Vec::new(); }; - if !ident.value.eq_ignore_ascii_case(b"group") { - return Vec::new(); - } - let mut results = Vec::new(); - // Extract name prefix from 'as' => '...' in an array argument. - let array_prefix = extract_as_prefix_from_args( - sc.argument_list.arguments.iter().map(|a| a.value()), - content, - ); - let new_prefix = format!("{prefix}{array_prefix}"); - for arg in sc.argument_list.arguments.iter() { - results.extend(scan_group_body( - arg.value(), + let method_lower = ident.value.to_ascii_lowercase(); + + if method_lower == b"group" { + let mut results = Vec::new(); + let array_prefix = extract_as_prefix_from_args( + sc.argument_list.arguments.iter().map(|a| a.value()), content, - &new_prefix, - target, - uri, - )); + ); + let new_prefix = format!("{prefix}{array_prefix}"); + for arg in sc.argument_list.arguments.iter() { + results.extend(scan_group_body( + arg.value(), + content, + &new_prefix, + target, + uri, + file_dir, + )); + } + results + } else if method_lower == b"resource" || method_lower == b"apiresource" { + // Route::resource('presses', Controller::class) generates + // conventional named routes like presses.index, presses.show, etc. + let suffixes = if method_lower == b"resource" { + RESOURCE_SUFFIXES + } else { + API_RESOURCE_SUFFIXES + }; + if let Some(first_arg) = sc.argument_list.arguments.iter().next() + && let Some((res_name, start, _)) = + extract_string_literal(first_arg.value(), content) + { + let base = resource_name_base(res_name); + for suffix in suffixes { + let full = format!("{prefix}{base}.{suffix}"); + if full == target { + return vec![crate::definition::point_location( + uri.clone(), + offset_to_position(content, start), + )]; + } + } + } + Vec::new() + } else { + Vec::new() } - results } _ => Vec::new(), @@ -156,23 +320,310 @@ fn scan_expr<'a>( } /// Walk the argument that was passed to `->group()`. +/// +/// Handles closures, arrow functions, and `__DIR__ . '/sub.php'` file +/// includes so that go-to-definition works on route names defined in +/// included sub-files. fn scan_group_body<'a>( expr: &Expression<'a>, content: &str, prefix: &str, target: &str, uri: &Url, + file_dir: Option<&std::path::Path>, ) -> Vec { match expr { Expression::Closure(closure) => { let mut results = Vec::new(); for stmt in closure.body.statements.iter() { - results.extend(scan_stmt(stmt, content, prefix, target, uri)); + results.extend(scan_stmt(stmt, content, prefix, target, uri, file_dir)); } results } - Expression::ArrowFunction(af) => scan_expr(af.expression, content, prefix, target, uri), - _ => Vec::new(), + Expression::ArrowFunction(af) => { + scan_expr(af.expression, content, prefix, target, uri, file_dir) + } + _ => { + // Follow `Route::group([], __DIR__ . '/sub.php')` file includes. + // Parse the included file and scan it with the current prefix + // so routes inherit the parent group's name prefix. + // (Do NOT call `scan_route_file` — it resets the prefix to "".) + if let Some(dir) = file_dir + && let Some(rel_path) = extract_dir_concat_path(expr, content) + { + let included = dir.join(rel_path.trim_start_matches('/')); + if let Ok(ref included_content) = std::fs::read_to_string(&included) { + let sub_uri = Url::from_file_path(&included).unwrap_or_else(|_| uri.clone()); + let sub_dir = included.parent().map(|d| d.to_path_buf()); + let arena = Bump::new(); + let fid = FileId::new(b"included.php"); + let prog = mago_syntax::parser::parse_file_content( + &arena, + fid, + included_content.as_bytes(), + ); + let mut results = Vec::new(); + for stmt in prog.statements.iter() { + results.extend(scan_stmt( + stmt, + included_content, + prefix, + target, + &sub_uri, + sub_dir.as_deref(), + )); + } + return results; + } + } + Vec::new() + } + } +} + +/// Collect all route names defined in `routes/` files. +/// +/// Returns a sorted, deduplicated list of fully-qualified route names +/// (e.g. `["admin.dashboard", "admin.users.index", "home"]`). +/// +/// Follows `Route::group([], __DIR__ . '/sub.php')` file includes so +/// that routes in sub-files inherit the parent group's name prefix. +pub(crate) fn enumerate_all_route_names(backend: &Backend) -> Vec { + let mut names = Vec::new(); + let snapshot = backend.user_file_symbol_maps(); + + for (file_uri, _) in snapshot { + if !file_uri.contains("/routes/") { + continue; + } + // Skip sub-directory route files at the top level — they are + // included by parent files via `Route::group([], __DIR__ . '/sub.php')` + // and should only be scanned in that context to inherit the + // correct name prefix. A "sub-directory" file is one where the + // path after the last `/routes/` segment contains another `/`. + if let Some(after) = file_uri.rsplit("/routes/").next() + && after.contains('/') + { + continue; + } + let Some(content) = backend.get_file_content(&file_uri) else { + continue; + }; + let file_dir = Url::parse(&file_uri) + .ok() + .and_then(|u| u.to_file_path().ok()) + .and_then(|p| p.parent().map(|d| d.to_path_buf())); + collect_all_names_from_file(&content, file_dir.as_deref(), &mut names); + } + names.sort(); + names.dedup(); + names +} + +/// Parse a single route file and collect every `->name('...')` value, +/// accounting for group prefixes and file includes. +fn collect_all_names_from_file( + content: &str, + file_dir: Option<&std::path::Path>, + out: &mut Vec, +) { + let arena = Bump::new(); + let file_id = FileId::new(b"input.php"); + let program = mago_syntax::parser::parse_file_content(&arena, file_id, content.as_bytes()); + for stmt in program.statements.iter() { + collect_names_from_stmt(stmt, content, "", file_dir, out); + } +} + +fn collect_names_from_stmt<'a>( + stmt: &Statement<'a>, + content: &str, + prefix: &str, + file_dir: Option<&std::path::Path>, + out: &mut Vec, +) { + match stmt { + Statement::Expression(e) => { + collect_names_from_expr(e.expression, content, prefix, file_dir, out); + } + Statement::Return(r) => { + if let Some(v) = r.value { + collect_names_from_expr(v, content, prefix, file_dir, out); + } + } + _ => {} + } +} + +fn collect_names_from_expr<'a>( + expr: &Expression<'a>, + content: &str, + prefix: &str, + file_dir: Option<&std::path::Path>, + out: &mut Vec, +) { + match expr { + Expression::Call(Call::Method(mc)) => { + let ClassLikeMemberSelector::Identifier(ident) = &mc.method else { + collect_names_from_expr(mc.object, content, prefix, file_dir, out); + return; + }; + let method = ident.value.to_ascii_lowercase(); + + if method == b"group" { + let chain_prefix = chain_name_prefix(mc.object, content); + let new_prefix = format!("{prefix}{chain_prefix}"); + for arg in mc.argument_list.arguments.iter() { + collect_names_from_group_body(arg.value(), content, &new_prefix, file_dir, out); + } + } else if method == b"name" { + if let Some(first_arg) = mc.argument_list.arguments.iter().next() + && let Some((name_val, _, _)) = + extract_string_literal(first_arg.value(), content) + { + let full = format!("{prefix}{name_val}"); + // Only collect leaf names (non-prefix names that don't end with '.'). + if !full.is_empty() && !full.ends_with('.') { + out.push(full); + } + } + collect_names_from_expr(mc.object, content, prefix, file_dir, out); + } else if method == b"only" || method == b"except" { + // ->only() / ->except() on Route::resource() / apiResource() + if let Expression::Call(Call::StaticMethod(sc)) = mc.object + && let Some((res_name, _, is_api)) = extract_resource_info(sc, content) + { + let filter = extract_string_list_from_args(&mc.argument_list, content); + let (only, except) = if method == b"only" { + (filter, Vec::new()) + } else { + (Vec::new(), filter) + }; + let suffixes = filtered_resource_suffixes(is_api, &only, &except); + let base = resource_name_base(res_name); + for suffix in suffixes { + let full = format!("{prefix}{base}.{suffix}"); + out.push(full); + } + return; + } + collect_names_from_expr(mc.object, content, prefix, file_dir, out); + } else { + collect_names_from_expr(mc.object, content, prefix, file_dir, out); + } + } + Expression::Call(Call::StaticMethod(sc)) => { + let ClassLikeMemberSelector::Identifier(ident) = &sc.method else { + return; + }; + let method_lower = ident.value.to_ascii_lowercase(); + + if method_lower == b"group" { + let array_prefix = extract_as_prefix_from_args( + sc.argument_list.arguments.iter().map(|a| a.value()), + content, + ); + let new_prefix = format!("{prefix}{array_prefix}"); + for arg in sc.argument_list.arguments.iter() { + collect_names_from_group_body(arg.value(), content, &new_prefix, file_dir, out); + } + } else if method_lower == b"resource" || method_lower == b"apiresource" { + // Route::resource('presses', Controller::class) without only/except + // generates all conventional route names. + if let Some((res_name, _, is_api)) = extract_resource_info(sc, content) { + let suffixes = filtered_resource_suffixes(is_api, &[], &[]); + let base = resource_name_base(res_name); + for suffix in suffixes { + let full = format!("{prefix}{base}.{suffix}"); + out.push(full); + } + } + } + } + _ => {} + } +} + +fn collect_names_from_group_body<'a>( + expr: &Expression<'a>, + content: &str, + prefix: &str, + file_dir: Option<&std::path::Path>, + out: &mut Vec, +) { + match expr { + Expression::Closure(closure) => { + for stmt in closure.body.statements.iter() { + collect_names_from_stmt(stmt, content, prefix, file_dir, out); + } + } + Expression::ArrowFunction(af) => { + collect_names_from_expr(af.expression, content, prefix, file_dir, out); + } + _ => { + // Handle `Route::group([], __DIR__ . '/sub.php')` file includes. + // The expression is a binary concatenation: `__DIR__ . '/path'`. + if let Some(dir) = file_dir + && let Some(rel_path) = extract_dir_concat_path(expr, content) + { + let included = dir.join(rel_path.trim_start_matches('/')); + if let Ok(included_content) = std::fs::read_to_string(&included) { + let sub_dir = included.parent().map(|d| d.to_path_buf()); + // Scan the included file with the current prefix so + // routes inherit the parent group's name (e.g. + // "eaglesys::"). Do NOT scan with empty prefix — + // that produces unprefixed names that are incorrect. + let arena2 = Bump::new(); + let fid2 = FileId::new(b"included.php"); + let prog2 = mago_syntax::parser::parse_file_content( + &arena2, + fid2, + included_content.as_bytes(), + ); + for stmt in prog2.statements.iter() { + collect_names_from_stmt( + stmt, + &included_content, + prefix, + sub_dir.as_deref(), + out, + ); + } + } + } + } + } +} + +/// Try to extract a relative path from a `__DIR__ . '/path.php'` expression. +/// +/// Returns the string literal portion (e.g. `"/ems/accounting.php"`). +fn extract_dir_concat_path<'a>(expr: &Expression<'a>, content: &'a str) -> Option<&'a str> { + let Expression::Binary(bin) = expr else { + return None; + }; + // Check LHS is __DIR__ + let is_dir = matches!( + bin.lhs, + Expression::MagicConstant(MagicConstant::Directory { .. }) + ); + if !is_dir { + return None; + } + // RHS should be a string literal + let Expression::Literal(literal::Literal::String(s)) = bin.rhs else { + return None; + }; + if let Some(value) = s.value { + Some(crate::atom::bytes_to_str(value)) + } else { + let start = s.span.start.offset as usize + 1; + let end = s.span.end.offset as usize - 1; + if start < end && end <= content.len() { + Some(&content[start..end]) + } else { + None + } } } diff --git a/src/virtual_members/laravel/trans_keys.rs b/src/virtual_members/laravel/trans_keys.rs index 782ed6e5..77236c31 100644 --- a/src/virtual_members/laravel/trans_keys.rs +++ b/src/virtual_members/laravel/trans_keys.rs @@ -7,28 +7,32 @@ use crate::Backend; use crate::atom::bytes_to_str; /// Resolve `__('file.key')` / `trans('file.key')` / `Lang::get('file.key')` to the -/// matching keys inside all matching `lang/{locale}/file.php` translation files. +/// matching keys inside all matching `lang/{locale}/file.php` translation files, +/// or inside `lang/{locale}.json` JSON translation files. +/// +/// For PHP files the key format is `file_stem.nested.key` (first segment = file, +/// rest = array path). For JSON files the key is looked up directly as a +/// top-level object key (Laravel's JSON translations are flat). /// -/// The key format is `file_stem.nested.key` (first segment = file, rest = array path). /// Falls back to the top of the file when the exact key cannot be located. pub(crate) fn resolve_trans_definitions(backend: &Backend, key: &str) -> Vec { - let Some(file_stem) = key.split('.').next() else { - return Vec::new(); - }; - let snapshot = backend.user_file_symbol_maps(); let mut results = Vec::new(); + + // PHP file-based translations: first segment is the file stem. + let file_stem = key.split('.').next().unwrap_or(key); let target_suffix = format!("/{file_stem}.php"); - for (file_uri, _) in snapshot { - // Check if the file is in a lang directory and matches the stem. - if (file_uri.contains("/lang/") || file_uri.contains("/resources/lang/")) - && file_uri.ends_with(&target_suffix) - { - let Ok(uri) = Url::parse(&file_uri) else { + for (file_uri, _) in &snapshot { + if !(file_uri.contains("/lang/") || file_uri.contains("/resources/lang/")) { + continue; + } + + if file_uri.ends_with(&target_suffix) { + let Ok(uri) = Url::parse(file_uri) else { continue; }; - let Some(content) = backend.get_file_content(&file_uri) else { + let Some(content) = backend.get_file_content(file_uri) else { continue; }; @@ -43,18 +47,41 @@ pub(crate) fn resolve_trans_definitions(backend: &Backend, key: &str) -> Vec>(&content) + && map.contains_key(key) + && let Ok(uri) = Url::from_file_path(&path) + { + results.push(crate::definition::point_location(uri, Position::new(0, 0))); + } + } + } + } + results } // ─── Declaration extractor (mirrors config_keys logic) ─────────────────────── #[derive(Debug)] -struct TransKeyMatch { - key: String, - start: usize, +pub(crate) struct TransKeyMatch { + pub key: String, + pub start: usize, } -fn collect_trans_declarations(content: &str, file_stem: &str) -> Vec { +pub(crate) fn collect_trans_declarations(content: &str, file_stem: &str) -> Vec { let arena = Bump::new(); let file_id = FileId::new(b"input.php"); let program = mago_syntax::parser::parse_file_content(&arena, file_id, content.as_bytes()); From 79b6a3e5026c21232a17220e188bcd5cb1684f1c Mon Sep 17 00:00:00 2001 From: Caleb White Date: Fri, 17 Jul 2026 16:35:32 -0500 Subject: [PATCH 2/2] feat: discover package resources from service providers Scan service providers for mergeConfigFrom(), loadViewsFrom(), loadTranslationsFrom(), loadJsonTranslationsFrom(), and loadRoutesFrom() calls. Resolve __DIR__-relative paths to the actual package files on disk and feed the results into the existing string key infrastructure. This enables completion, go-to-definition, and hover for config keys, view templates, translation keys, and named routes that are registered by installed packages rather than defined in the app itself. For example, config("horizon.environments") now completes and jumps to the key in vendor/laravel/horizon/config/ horizon.php, and view("horizon::layout") resolves to the package view directory. The scanner reuses the same provider list and one-level-deep helper class traversal already used by macro discovery. Discovered resources are cached on Backend and invalidate the string key caches when populated. Key changes: - New provider_resources.rs with ProviderResource, ProviderResources, and extract_provider_resources() - extract_dir_concat_path() moved from route_names.rs to helpers.rs for shared use - build_provider_resources() in server.rs walks providers and their referenced classes - enumerate_all_{config_keys,view_names,trans_keys}() and enumerate_all_route_names() extended to include package resources - resolve_{config_key,view,trans,route}_definitions() extended to handle namespaced package keys (ns::key, ns.key) --- docs/CHANGELOG.md | 1 + src/completion/laravel_string_keys.rs | 114 ++++++++++++++++- src/lib.rs | 8 ++ src/server.rs | 51 ++++++++ src/virtual_members/laravel/config_keys.rs | 18 ++- src/virtual_members/laravel/helpers.rs | 33 +++++ src/virtual_members/laravel/mod.rs | 2 + .../laravel/provider_resources.rs | 121 ++++++++++++++++++ src/virtual_members/laravel/route_names.rs | 50 +++----- src/virtual_members/laravel/trans_keys.rs | 43 ++++++- src/virtual_members/laravel/view_names.rs | 21 ++- 11 files changed, 421 insertions(+), 41 deletions(-) create mode 100644 src/virtual_members/laravel/provider_resources.rs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 024c83cc..7f3e49d0 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Package resource discovery from service providers.** Config keys, view templates, translation keys, and named routes registered by installed packages are now discovered automatically. The scanner reads `mergeConfigFrom()`, `loadViewsFrom()`, `loadTranslationsFrom()`, `loadJsonTranslationsFrom()`, and `loadRoutesFrom()` calls in service providers (and their one-level-deep helper classes), resolves `__DIR__`-relative paths to the actual package files on disk, and feeds the results into the existing string key infrastructure. Completion now offers package config keys (e.g. `config('horizon.environments')`), namespaced view templates (`view('horizon::layout')`), namespaced translation keys (`trans('package::file.key')`), and package-defined named routes. Go-to-definition on any of these jumps to the exact key or file in the vendor package. Contributed by @calebdw. - **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. diff --git a/src/completion/laravel_string_keys.rs b/src/completion/laravel_string_keys.rs index 5528cd37..ae344bc0 100644 --- a/src/completion/laravel_string_keys.rs +++ b/src/completion/laravel_string_keys.rs @@ -246,18 +246,23 @@ impl Backend { let mut names = Vec::new(); for (file_uri, _) in snapshot { - // Match resources/views/**/*.blade.php or *.php let Some(rel) = extract_view_relative_path(&file_uri) else { continue; }; names.push(rel); } + + for res in &self.laravel_provider_resources.read().view_dirs { + collect_namespaced_view_names(&res.path, &res.namespace, &mut names); + } + names.sort(); names.dedup(); names } - /// Enumerate all config keys by scanning `config/` files. + /// Enumerate all config keys by scanning `config/` files and + /// package config files discovered from service providers. fn enumerate_all_config_keys(&self) -> Vec { use crate::virtual_members::laravel::{ collect_laravel_config_declarations, laravel_config_prefix_from_uri, @@ -278,15 +283,27 @@ impl Backend { keys.push(d.key); } } + + for res in &self.laravel_provider_resources.read().config_files { + if let Ok(content) = std::fs::read_to_string(&res.path) { + let decls = collect_laravel_config_declarations(&content, &res.namespace); + for d in decls { + keys.push(d.key); + } + } + } + keys.sort(); keys.dedup(); keys } - /// Enumerate all translation keys by scanning `lang/` files. + /// Enumerate all translation keys by scanning `lang/` files and + /// package translation directories discovered from service providers. /// /// Supports both PHP array files (`lang/en/messages.php` → `messages.key`) /// and JSON translation files (`lang/en.json` → raw key strings). + /// Package translations use `namespace::file.key` syntax. fn enumerate_all_trans_keys(&self) -> Vec { let snapshot = self.user_file_symbol_maps(); let mut keys = Vec::new(); @@ -311,9 +328,12 @@ impl Backend { } } - // Also collect keys from JSON translation files on disk. collect_json_trans_keys(self, &mut keys); + for res in &self.laravel_provider_resources.read().trans_dirs { + collect_namespaced_trans_keys(&res.path, &res.namespace, &mut keys); + } + keys.sort(); keys.dedup(); keys @@ -429,6 +449,92 @@ fn collect_json_trans_keys(backend: &crate::Backend, out: &mut Vec) { } } +/// Recursively scan a package view directory and collect view names +/// in `namespace::dot.notation` format. +fn collect_namespaced_view_names(dir: &std::path::Path, namespace: &str, out: &mut Vec) { + collect_view_names_recursive(dir, dir, namespace, out); +} + +fn collect_view_names_recursive( + base: &std::path::Path, + dir: &std::path::Path, + namespace: &str, + out: &mut Vec, +) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_view_names_recursive(base, &path, namespace, out); + } else if let Some(rel) = path.strip_prefix(base).ok().and_then(|r| r.to_str()) { + let name = rel + .strip_suffix(".blade.php") + .or_else(|| rel.strip_suffix(".php")); + if let Some(name) = name { + let dotted = name.replace([std::path::MAIN_SEPARATOR, '/'], "."); + out.push(format!("{namespace}::{dotted}")); + } + } + } +} + +/// Scan a package translation directory and collect keys in +/// `namespace::file.key` format (PHP files) or `namespace::raw_key` +/// (JSON files with empty namespace). +fn collect_namespaced_trans_keys(dir: &std::path::Path, namespace: &str, out: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + collect_namespaced_trans_from_locale_dir(&path, namespace, out); + } else if path.extension().is_some_and(|e| e == "json") + && namespace.is_empty() + && let Ok(content) = std::fs::read_to_string(&path) + && let Ok(map) = + serde_json::from_str::>(&content) + { + for k in map.keys() { + out.push(k.clone()); + } + } + } +} + +fn collect_namespaced_trans_from_locale_dir( + dir: &std::path::Path, + namespace: &str, + out: &mut Vec, +) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let path = entry.path(); + if !path.extension().is_some_and(|e| e == "php") { + continue; + } + let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else { + continue; + }; + let Ok(content) = std::fs::read_to_string(&path) else { + continue; + }; + let prefix = if namespace.is_empty() { + stem.to_string() + } else { + format!("{namespace}::{stem}") + }; + let decls = crate::virtual_members::laravel::collect_trans_declarations(&content, &prefix); + for d in decls { + out.push(d.key); + } + } +} + // ─── Completion ───────────────────────────────────────────────────────────── impl Backend { diff --git a/src/lib.rs b/src/lib.rs index 1b2a8f56..a765aaf5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -523,6 +523,7 @@ pub struct Backend { /// view names, translation keys). `None` = not yet computed. /// Invalidated when a file in `routes/`, `config/`, `resources/views/`, /// or `lang/` is updated. + pub(crate) laravel_provider_resources: Arc>, pub(crate) laravel_string_key_cache: Arc>, /// Per-target member completion cache. /// @@ -946,6 +947,9 @@ impl Backend { laravel_has_macros: Arc::new(std::sync::atomic::AtomicBool::new(false)), laravel_macro_seeds: Arc::new(RwLock::new(HashMap::new())), laravel_date_class: Arc::new(RwLock::new(None)), + laravel_provider_resources: Arc::new(RwLock::new( + virtual_members::laravel::ProviderResources::default(), + )), laravel_string_key_cache: Arc::new(RwLock::new(LaravelStringKeyCache::default())), member_completion_cache: Arc::new(Mutex::new(HashMap::new())), method_store: Arc::new(RwLock::new(HashMap::new())), @@ -1043,6 +1047,9 @@ impl Backend { laravel_has_macros: Arc::new(std::sync::atomic::AtomicBool::new(false)), laravel_macro_seeds: Arc::new(RwLock::new(HashMap::new())), laravel_date_class: Arc::new(RwLock::new(None)), + laravel_provider_resources: Arc::new(RwLock::new( + virtual_members::laravel::ProviderResources::default(), + )), laravel_string_key_cache: Arc::new(RwLock::new(LaravelStringKeyCache::default())), member_completion_cache: Arc::new(Mutex::new(HashMap::new())), method_store: Arc::new(RwLock::new(HashMap::new())), @@ -1639,6 +1646,7 @@ impl Backend { laravel_has_macros: Arc::clone(&self.laravel_has_macros), laravel_macro_seeds: Arc::clone(&self.laravel_macro_seeds), laravel_date_class: Arc::clone(&self.laravel_date_class), + laravel_provider_resources: Arc::clone(&self.laravel_provider_resources), laravel_string_key_cache: Arc::clone(&self.laravel_string_key_cache), member_completion_cache: Arc::clone(&self.member_completion_cache), method_store: Arc::clone(&self.method_store), diff --git a/src/server.rs b/src/server.rs index a69353a8..1cb7166a 100644 --- a/src/server.rs +++ b/src/server.rs @@ -510,6 +510,7 @@ impl LanguageServer for Backend { if self.resolved_class_cache.read().is_laravel() { self.build_laravel_date_class(); self.build_laravel_macro_index(); + self.build_provider_resources(); } // Mark initialization as complete so that diagnostic workers @@ -2195,6 +2196,56 @@ impl Backend { .any(|rel| crate::util::path_to_uri(&root.join(rel)) == uri) } + fn build_provider_resources(&self) { + let mut resources = crate::virtual_members::laravel::ProviderResources::default(); + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + + for fqn in self.laravel_provider_fqns() { + let Some(uri) = self.resolve_class_uri(&fqn) else { + continue; + }; + if !seen.insert(uri.clone()) { + continue; + } + let Some(content) = self.get_file_content(&uri) else { + continue; + }; + let file_dir = tower_lsp::lsp_types::Url::parse(&uri) + .ok() + .and_then(|u| u.to_file_path().ok()) + .and_then(|p| p.parent().map(|d| d.to_path_buf())); + let Some(file_dir) = file_dir else { + continue; + }; + + resources.merge(crate::virtual_members::laravel::extract_provider_resources( + &content, &file_dir, + )); + } + + let config_count = resources.config_files.len(); + let view_count = resources.view_dirs.len(); + let trans_count = resources.trans_dirs.len(); + let route_count = resources.route_files.len(); + *self.laravel_provider_resources.write() = resources; + + if config_count + view_count + trans_count + route_count > 0 { + let mut cache = self.laravel_string_key_cache.write(); + cache.config_keys = None; + cache.view_names = None; + cache.trans_keys = None; + cache.route_names = None; + } + + tracing::info!( + "PHPantom: discovered {} package config files, {} view dirs, {} translation dirs, {} route files from service providers", + config_count, + view_count, + trans_count, + route_count, + ); + } + fn infer_laravel_macro_return_types( &self, regs: &mut [crate::virtual_members::laravel::MacroRegistration], diff --git a/src/virtual_members/laravel/config_keys.rs b/src/virtual_members/laravel/config_keys.rs index e73fe09b..0869720a 100644 --- a/src/virtual_members/laravel/config_keys.rs +++ b/src/virtual_members/laravel/config_keys.rs @@ -233,7 +233,23 @@ pub(crate) fn resolve_config_key_declaration(backend: &Backend, key: &str) -> Op return Some(crate::definition::point_location(target_uri, pos)); } - // Key not found exactly in this file, but this is the matching file stem. + return Some(crate::definition::point_location( + target_uri, + Position::new(0, 0), + )); + } + } + + let first_part = parts.first()?; + for res in &backend.laravel_provider_resources.read().config_files { + if res.namespace == *first_part && res.path.is_file() { + let target_uri = Url::from_file_path(&res.path).ok()?; + let target_content = std::fs::read_to_string(&res.path).ok()?; + let declarations = collect_laravel_config_declarations(&target_content, &res.namespace); + if let Some(decl) = declarations.into_iter().find(|d| d.key == key) { + let pos = crate::util::offset_to_position(&target_content, decl.start); + return Some(crate::definition::point_location(target_uri, pos)); + } return Some(crate::definition::point_location( target_uri, Position::new(0, 0), diff --git a/src/virtual_members/laravel/helpers.rs b/src/virtual_members/laravel/helpers.rs index 6b6592d3..807230b0 100644 --- a/src/virtual_members/laravel/helpers.rs +++ b/src/virtual_members/laravel/helpers.rs @@ -630,6 +630,39 @@ fn walk_array_el_depth( ControlFlow::Continue(()) } +/// Try to extract a relative path from a `__DIR__ . '/path.php'` expression. +/// +/// Returns the string literal portion (e.g. `"/ems/accounting.php"`). +pub(crate) fn extract_dir_concat_path<'a>( + expr: &Expression<'a>, + content: &'a str, +) -> Option<&'a str> { + let Expression::Binary(bin) = expr else { + return None; + }; + let is_dir = matches!( + bin.lhs, + Expression::MagicConstant(MagicConstant::Directory { .. }) + ); + if !is_dir { + return None; + } + let Expression::Literal(literal::Literal::String(s)) = bin.rhs else { + return None; + }; + if let Some(value) = s.value { + Some(crate::atom::bytes_to_str(value)) + } else { + let start = s.span.start.offset as usize + 1; + let end = s.span.end.offset as usize - 1; + if start < end && end <= content.len() { + Some(&content[start..end]) + } else { + None + } + } +} + #[cfg(test)] #[path = "helpers_tests.rs"] mod tests; diff --git a/src/virtual_members/laravel/mod.rs b/src/virtual_members/laravel/mod.rs index 92a711c8..19791ac5 100644 --- a/src/virtual_members/laravel/mod.rs +++ b/src/virtual_members/laravel/mod.rs @@ -87,6 +87,7 @@ mod factory; mod helpers; mod macros; pub(crate) mod patches; +mod provider_resources; mod relationships; mod route_names; mod scopes; @@ -108,6 +109,7 @@ pub(crate) use macros::{ inject_macros, parse_installed_providers, parse_provider_class_list, parse_provider_referenced_classes, }; +pub(crate) use provider_resources::{ProviderResources, extract_provider_resources}; pub(crate) use route_names::enumerate_all_route_names; pub(crate) use trans_keys::collect_trans_declarations; diff --git a/src/virtual_members/laravel/provider_resources.rs b/src/virtual_members/laravel/provider_resources.rs new file mode 100644 index 00000000..7438c47d --- /dev/null +++ b/src/virtual_members/laravel/provider_resources.rs @@ -0,0 +1,121 @@ +use std::ops::ControlFlow; +use std::path::{Path, PathBuf}; + +use mago_syntax::ast::*; + +#[derive(Debug, Clone)] +pub(crate) struct ProviderResource { + pub path: PathBuf, + pub namespace: String, +} + +#[derive(Debug, Clone, Default)] +pub(crate) struct ProviderResources { + pub config_files: Vec, + pub view_dirs: Vec, + pub trans_dirs: Vec, + pub route_files: Vec, +} + +impl ProviderResources { + pub fn merge(&mut self, other: ProviderResources) { + self.config_files.extend(other.config_files); + self.view_dirs.extend(other.view_dirs); + self.trans_dirs.extend(other.trans_dirs); + self.route_files.extend(other.route_files); + } +} + +pub(crate) fn extract_provider_resources(content: &str, file_dir: &Path) -> ProviderResources { + let mut resources = ProviderResources::default(); + + super::helpers::walk_all_php_expressions(content, &mut |expr| { + let Expression::Call(Call::Method(mc)) = expr else { + return ControlFlow::Continue(()); + }; + + let ClassLikeMemberSelector::Identifier(ident) = &mc.method else { + return ControlFlow::Continue(()); + }; + + if !is_this_expr(mc.object) { + return ControlFlow::Continue(()); + } + + let method_lower = ident.value.to_ascii_lowercase(); + let args: Vec<_> = mc.argument_list.arguments.iter().collect(); + + if method_lower == b"mergeconfigfrom" && args.len() >= 2 { + if let Some(path) = resolve_path_arg(args[0].value(), content, file_dir) + && let Some((ns, _, _)) = + super::helpers::extract_string_literal(args[1].value(), content) + { + resources.config_files.push(ProviderResource { + path, + namespace: ns.to_string(), + }); + } + } else if method_lower == b"loadviewsfrom" && args.len() >= 2 { + if let Some(path) = resolve_path_arg(args[0].value(), content, file_dir) + && let Some((ns, _, _)) = + super::helpers::extract_string_literal(args[1].value(), content) + { + resources.view_dirs.push(ProviderResource { + path, + namespace: ns.to_string(), + }); + } + } else if method_lower == b"loadtranslationsfrom" && args.len() >= 2 { + if let Some(path) = resolve_path_arg(args[0].value(), content, file_dir) + && let Some((ns, _, _)) = + super::helpers::extract_string_literal(args[1].value(), content) + { + resources.trans_dirs.push(ProviderResource { + path, + namespace: ns.to_string(), + }); + } + } else if method_lower == b"loadjsontranslationsfrom" && !args.is_empty() { + if let Some(path) = resolve_path_arg(args[0].value(), content, file_dir) { + resources.trans_dirs.push(ProviderResource { + path, + namespace: String::new(), + }); + } + } else if method_lower == b"loadroutesfrom" + && !args.is_empty() + && let Some(path) = resolve_path_arg(args[0].value(), content, file_dir) + { + resources.route_files.push(path); + } + + ControlFlow::Continue(()) + }); + + resources +} + +fn is_this_expr(expr: &Expression<'_>) -> bool { + matches!( + expr, + Expression::Variable(Variable::Direct(dv)) if dv.name == b"this" + ) +} + +fn resolve_path_arg(expr: &Expression<'_>, content: &str, file_dir: &Path) -> Option { + if let Some(rel) = super::helpers::extract_dir_concat_path(expr, content) { + let resolved = file_dir.join(rel.trim_start_matches('/')); + return resolved.canonicalize().ok().or(Some(resolved)); + } + + if let Some((val, _, _)) = super::helpers::extract_string_literal(expr, content) { + if val.starts_with('/') { + let p = PathBuf::from(val); + return p.canonicalize().ok().or(Some(p)); + } + let resolved = file_dir.join(val); + return resolved.canonicalize().ok().or(Some(resolved)); + } + + None +} diff --git a/src/virtual_members/laravel/route_names.rs b/src/virtual_members/laravel/route_names.rs index 2bd15de2..d635802e 100644 --- a/src/virtual_members/laravel/route_names.rs +++ b/src/virtual_members/laravel/route_names.rs @@ -138,6 +138,16 @@ pub(crate) fn resolve_route_definitions(backend: &Backend, name: &str) -> Vec Vec { .and_then(|p| p.parent().map(|d| d.to_path_buf())); collect_all_names_from_file(&content, file_dir.as_deref(), &mut names); } + + for route_path in &backend.laravel_provider_resources.read().route_files { + if let Ok(content) = std::fs::read_to_string(route_path) { + let file_dir = route_path.parent(); + collect_all_names_from_file(&content, file_dir, &mut names); + } + } + names.sort(); names.dedup(); names @@ -595,36 +613,6 @@ fn collect_names_from_group_body<'a>( } } -/// Try to extract a relative path from a `__DIR__ . '/path.php'` expression. -/// -/// Returns the string literal portion (e.g. `"/ems/accounting.php"`). -fn extract_dir_concat_path<'a>(expr: &Expression<'a>, content: &'a str) -> Option<&'a str> { - let Expression::Binary(bin) = expr else { - return None; - }; - // Check LHS is __DIR__ - let is_dir = matches!( - bin.lhs, - Expression::MagicConstant(MagicConstant::Directory { .. }) - ); - if !is_dir { - return None; - } - // RHS should be a string literal - let Expression::Literal(literal::Literal::String(s)) = bin.rhs else { - return None; - }; - if let Some(value) = s.value { - Some(crate::atom::bytes_to_str(value)) - } else { - let start = s.span.start.offset as usize + 1; - let end = s.span.end.offset as usize - 1; - if start < end && end <= content.len() { - Some(&content[start..end]) - } else { - None - } - } -} +use super::helpers::extract_dir_concat_path; pub(crate) use super::helpers::{chain_name_prefix, extract_as_prefix_from_args}; diff --git a/src/virtual_members/laravel/trans_keys.rs b/src/virtual_members/laravel/trans_keys.rs index 77236c31..4bc839dc 100644 --- a/src/virtual_members/laravel/trans_keys.rs +++ b/src/virtual_members/laravel/trans_keys.rs @@ -16,10 +16,47 @@ use crate::atom::bytes_to_str; /// /// Falls back to the top of the file when the exact key cannot be located. pub(crate) fn resolve_trans_definitions(backend: &Backend, key: &str) -> Vec { - let snapshot = backend.user_file_symbol_maps(); let mut results = Vec::new(); - // PHP file-based translations: first segment is the file stem. + if let Some((namespace, rest)) = key.split_once("::") { + let file_stem = rest.split('.').next().unwrap_or(rest); + for res in &backend.laravel_provider_resources.read().trans_dirs { + if res.namespace != namespace { + continue; + } + let Ok(entries) = std::fs::read_dir(&res.path) else { + continue; + }; + for entry in entries.flatten() { + let locale_dir = entry.path(); + if !locale_dir.is_dir() { + continue; + } + let candidate = locale_dir.join(format!("{file_stem}.php")); + if !candidate.is_file() { + continue; + } + let Ok(content) = std::fs::read_to_string(&candidate) else { + continue; + }; + let Ok(uri) = Url::from_file_path(&candidate) else { + continue; + }; + let prefix = format!("{namespace}::{file_stem}"); + let declarations = collect_trans_declarations(&content, &prefix); + if let Some(decl) = declarations.into_iter().find(|d| d.key == key) { + let pos = crate::util::offset_to_position(&content, decl.start); + results.push(crate::definition::point_location(uri, pos)); + continue; + } + results.push(crate::definition::point_location(uri, Position::new(0, 0))); + } + } + return results; + } + + let snapshot = backend.user_file_symbol_maps(); + let file_stem = key.split('.').next().unwrap_or(key); let target_suffix = format!("/{file_stem}.php"); @@ -43,12 +80,10 @@ pub(crate) fn resolve_trans_definitions(backend: &Backend, key: &str) -> Vec Vec { + let mut results = Vec::new(); + + if let Some((namespace, view_name)) = name.split_once("::") { + let rel = view_name.replace('.', "/"); + for res in &backend.laravel_provider_resources.read().view_dirs { + if res.namespace != namespace { + continue; + } + for suffix in &[".blade.php", ".php"] { + let candidate = res.path.join(format!("{rel}{suffix}")); + if candidate.is_file() + && let Ok(uri) = Url::from_file_path(&candidate) + { + results.push(crate::definition::point_location(uri, Position::new(0, 0))); + } + } + } + return results; + } + let rel = name.replace('.', "/"); let target_suffixes = [ format!("/resources/views/{}.blade.php", rel), format!("/resources/views/{}.php", rel), ]; - let mut results = Vec::new(); let snapshot = backend.user_file_symbol_maps(); for (file_uri, _) in snapshot {