diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index c48aaeef..7f3e49d0 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -9,6 +9,10 @@ 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. - **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..ae344bc0 --- /dev/null +++ b/src/completion/laravel_string_keys.rs @@ -0,0 +1,902 @@ +//! 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 { + 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 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, + }; + + 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); + } + } + + 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 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(); + + 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); + } + } + + 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 + } + + 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()); + } + } + } + } +} + +/// 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 { + /// 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..a765aaf5 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,12 @@ 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_provider_resources: Arc>, + pub(crate) laravel_string_key_cache: Arc>, /// Per-target member completion cache. /// /// Typing `$model->wh...` triggers a completion request for each @@ -916,6 +947,10 @@ 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())), gti_index: Arc::new(RwLock::new(HashMap::new())), @@ -1012,6 +1047,10 @@ 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())), gti_index: Arc::new(RwLock::new(HashMap::new())), @@ -1607,6 +1646,8 @@ 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), 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/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/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/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 cce6dc63..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; @@ -98,7 +99,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 +109,9 @@ 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; /// Unified go-to-definition entry point for all Laravel string-key spans. /// 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 edc48ea9..d635802e 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 +176,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 +201,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 +222,7 @@ fn scan_expr<'a>( &new_prefix, target, uri, + file_dir, )); } } else if method == b"name" { @@ -114,41 +238,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,24 +330,289 @@ 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); + } + + 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 +} + +/// 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, + ); + } + } + } + } + } +} + +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 782ed6e5..4bc839dc 100644 --- a/src/virtual_members/laravel/trans_keys.rs +++ b/src/virtual_members/laravel/trans_keys.rs @@ -7,28 +7,69 @@ 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 mut results = Vec::new(); + + 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 mut results = Vec::new(); + + 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; }; @@ -39,22 +80,43 @@ 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()); diff --git a/src/virtual_members/laravel/view_names.rs b/src/virtual_members/laravel/view_names.rs index dd707b0b..8a9b3535 100644 --- a/src/virtual_members/laravel/view_names.rs +++ b/src/virtual_members/laravel/view_names.rs @@ -7,13 +7,32 @@ use crate::Backend; /// Converts dot-notation to a file path under `resources/views/`: /// `'components.button'` → `resources/views/components/button.blade.php` pub(crate) fn resolve_view_definitions(backend: &Backend, name: &str) -> 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 {