From 45d43363faeabe62d247d71affe762a260348aaa Mon Sep 17 00:00:00 2001 From: Shuvro Roy Date: Wed, 9 Sep 2026 20:50:29 +0600 Subject: [PATCH 1/6] fix(laravel): locate JSON translation keys and their references Retain exact key ranges when decoding JSON translations, including escaped keys and provider paths. Use the shared reference index for requests from JSON declarations and read unsaved buffers during definition lookup. Add navigation regressions and a JSON translation example with a runtime assertion. --- docs/CHANGELOG.md | 1 + examples/laravel/app/Demo.php | 3 + examples/laravel/assertions.php | 12 ++ examples/laravel/lang/en.json | 4 + src/references/dispatch.rs | 12 ++ src/virtual_members/laravel/mod.rs | 2 + src/virtual_members/laravel/trans_json.rs | 143 ++++++++++++++++++ .../laravel/trans_json_tests.rs | 126 +++++++++++++++ src/virtual_members/laravel/trans_keys.rs | 28 +--- .../integration/laravel_translation_depth.rs | 134 ++++++++++++++++ tests/integration/main.rs | 1 + 11 files changed, 443 insertions(+), 23 deletions(-) create mode 100644 examples/laravel/lang/en.json create mode 100644 src/virtual_members/laravel/trans_json.rs create mode 100644 src/virtual_members/laravel/trans_json_tests.rs create mode 100644 tests/integration/laravel_translation_depth.rs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index fb39f163b..ce74c015f 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 +- **JSON translation keys support precise navigation.** Laravel translation keys now navigate to their exact location in JSON language files, and find-references works from their declarations as well as their call sites. Contributed by @shuvroroy. - **Formatting from the command line.** `phpantom_lsp format` formats every PHP file and Blade template in a project with the same formatter the editor runs on save, and `phpantom_lsp format --check` reports the files that are not formatted and exits non-zero without writing anything, so a CI job can require that a pull request ran the formatter. A run honours whatever the project already formats with, a Laravel Pint, php-cs-fixer, or PHP_CodeSniffer it depends on, and the built-in formatter otherwise, exactly as the editor resolves it, and opens with a line naming what it resolved so a CI log records which formatter enforced the result. Templates whose indentation is output rather than layout are left alone and never fail a check, and formatting turned off in `.phpantom.toml` is reported as such rather than passing as a project where every file happens to be formatted. Paths can be named to restrict the run, `--format github` annotates the pull request diff, and `--format json` is shaped like the object `analyze` and `fix` emit. - **Storage disk names are navigable wherever Laravel accepts one.** `Storage::disk()`, `fake()`, `persistentFake()`, `forgetDisk()`, and the `#[Storage]` container attribute now complete from `config/filesystems.php`; hover shows the config key, Ctrl+Click opens its declaration, and find-references links every use. Calls that require a configured disk report misspellings, while test fakes and disk eviction keep accepting the ad-hoc names Laravel permits at runtime. Contributed by @shuvroroy. - **Class and namespace moves from the command line.** `phpantom_lsp move FROM TO` moves one class or a whole namespace and updates declarations, imports, references, and PSR-4 paths across the project. Both sides can be fully-qualified names or Composer PSR-4 file/directory paths, and `--dry-run --format json` provides a validation-only form for scripts and coding agents. A destination that would overwrite an existing class or file is refused before any changes are made. A move into a namespace no PSR-4 mapping covers is called out rather than reported as a plain success, since the files cannot follow the declarations there and the autoloader stops finding them. A class installed by Composer is refused outright, the same way renaming one in the editor is. Contributed by @calebdw. diff --git a/examples/laravel/app/Demo.php b/examples/laravel/app/Demo.php index 59ed52562..8b0f75af1 100644 --- a/examples/laravel/app/Demo.php +++ b/examples/laravel/app/Demo.php @@ -668,6 +668,9 @@ public function laravelNavigation(): void request()->routeIs('bakeries.*'); // Translation Keys + // Ctrl+Click a JSON key to reach its exact declaration; find + // references from lang/en.json to return to its call sites. + __('Fresh bread for :name', ['name' => 'Ada']); __('messages.welcome'); trans('auth.failed'); trans_choice('messages.notifications', 5); diff --git a/examples/laravel/assertions.php b/examples/laravel/assertions.php index 9e5178ff1..b29ab20d6 100644 --- a/examples/laravel/assertions.php +++ b/examples/laravel/assertions.php @@ -1468,6 +1468,18 @@ public function toArray(): array \Illuminate\Container\Container::setInstance($previousContainer); +// ─── Translation resources ───────────────────────────────────────────────── + +$translationLoader = new \Illuminate\Translation\FileLoader( + new \Illuminate\Filesystem\Filesystem(), + __DIR__ . '/lang' +); +$translationDemo = new \Illuminate\Translation\Translator($translationLoader, 'en'); +check( + 'JSON translation keys resolve with replacements', + $translationDemo->get('Fresh bread for :name', ['name' => 'Ada']) === 'Fresh bread for Ada' +); + // ─── Summary ──────────────────────────────────────────────────────────────── echo "\n"; diff --git a/examples/laravel/lang/en.json b/examples/laravel/lang/en.json new file mode 100644 index 000000000..d6abda6d4 --- /dev/null +++ b/examples/laravel/lang/en.json @@ -0,0 +1,4 @@ +{ + "The bakery is open": "The bakery is open", + "Fresh bread for :name": "Fresh bread for :name" +} diff --git a/src/references/dispatch.rs b/src/references/dispatch.rs index 3c4e8ae8c..97e63b189 100644 --- a/src/references/dispatch.rs +++ b/src/references/dispatch.rs @@ -137,6 +137,18 @@ impl Backend { } } + if self.resolved_class_cache.read().is_laravel() + && let Some(locations) = laravel::find_json_trans_references( + self, + uri, + content, + position, + include_declaration, + ) + { + return Some(locations); + } + // Fallback for declaration sites in config/*.php let start_laravel = std::time::Instant::now(); if self.resolved_class_cache.read().is_laravel() diff --git a/src/virtual_members/laravel/mod.rs b/src/virtual_members/laravel/mod.rs index 48a7eeef3..104766768 100644 --- a/src/virtual_members/laravel/mod.rs +++ b/src/virtual_members/laravel/mod.rs @@ -133,6 +133,7 @@ mod route_names; mod scopes; mod storage; mod string_keys; +mod trans_json; mod trans_keys; pub(crate) mod validated_shape; pub(crate) mod validation_rules; @@ -185,6 +186,7 @@ pub(crate) use storage::{ extract_storage_driver_registrations, is_storage_facade_name, patch_storage_disk_type, storage_facade_local_names, }; +pub(crate) use trans_json::find_json_trans_references; pub(crate) use trans_keys::{collect_trans_declarations, trans_line, unresolved_trans_type}; pub(crate) use validation_rules::{safe_call_receiver_variable, safe_source_variable}; pub(crate) use view_data::{SharedViewVar, composer_class_vars}; diff --git a/src/virtual_members/laravel/trans_json.rs b/src/virtual_members/laravel/trans_json.rs new file mode 100644 index 000000000..d3d4fd34a --- /dev/null +++ b/src/virtual_members/laravel/trans_json.rs @@ -0,0 +1,143 @@ +//! JSON translation declarations with their source positions. + +use tower_lsp::lsp_types::{Location, Position, Range, Url}; + +use crate::Backend; +use crate::symbol_map::LaravelStringKind; +use crate::text_position::position_to_offset; + +use super::trans_keys::TransKeyMatch; + +/// Parse a flat JSON translation object while retaining the byte range of +/// each key's source spelling, including escapes. Invalid documents yield +/// no declarations; values are decoded by serde rather than a text scanner. +pub(super) fn collect_json_trans_declarations(content: &str) -> Vec { + parse_declarations(content).unwrap_or_default() +} + +fn parse_declarations(content: &str) -> Option> { + let mut input = content.trim_start().strip_prefix('{')?.trim_start(); + let mut out = Vec::new(); + if let Some(rest) = input.strip_prefix('}') { + return rest.trim().is_empty().then_some(out); + } + loop { + let start = content.len() - input.len() + 1; + let mut keys = serde_json::Deserializer::from_str(input).into_iter::(); + let key = keys.next()?.ok()?; + let consumed = keys.byte_offset(); + let end = start + consumed - 2; + input = input[consumed..] + .trim_start() + .strip_prefix(':')? + .trim_start(); + let mut values = serde_json::Deserializer::from_str(input).into_iter::(); + let value = values.next()?.ok()?; + input = input[values.byte_offset()..].trim_start(); + out.push(TransKeyMatch { + key, + start, + end, + is_group: false, + value: value.as_str().map(str::to_string), + }); + if let Some(rest) = input.strip_prefix('}') { + return rest.trim().is_empty().then_some(out); + } + input = input.strip_prefix(',')?.trim_start(); + } +} + +/// Locate a key in application and provider-registered JSON language files. +pub(super) fn json_definitions(backend: &Backend, key: &str) -> Vec { + let mut directories: Vec<_> = backend + .laravel_provider_resources + .read() + .trans_dirs + .iter() + .filter(|resource| resource.namespace.is_empty()) + .map(|resource| resource.path.clone()) + .collect(); + if let Some(root) = backend.workspace.workspace_root.read().as_ref() { + directories.extend([root.join("lang"), root.join("resources/lang")]); + } + directories.sort(); + directories.dedup(); + let mut locations = Vec::new(); + for directory in directories { + let Ok(entries) = std::fs::read_dir(directory) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path + .extension() + .is_some_and(|extension| extension == "json") + && let Ok(uri) = Url::from_file_path(&path) + && let Some(content) = backend.get_file_content(uri.as_str()) + && let Some(declaration) = collect_json_trans_declarations(&content) + .into_iter() + .rev() + .find(|declaration| declaration.key == key) + { + locations.push(Location::new( + uri, + Range::new( + crate::text_position::offset_to_position(&content, declaration.start), + crate::text_position::offset_to_position(&content, declaration.end), + ), + )); + } + } + } + locations +} + +/// Find uses of the JSON translation key under the cursor through the +/// same PHP/Blade reference index as a translation helper call. +pub(crate) fn find_json_trans_references( + backend: &Backend, + uri: &str, + content: &str, + position: Position, + include_declaration: bool, +) -> Option> { + if !uri.ends_with(".json") { + return None; + } + let path = Url::parse(uri).ok()?.to_file_path().ok()?; + let parent = path.parent()?; + if !parent.ends_with("lang") + && !backend + .laravel_provider_resources + .read() + .trans_dirs + .iter() + .any(|dir| dir.namespace.is_empty() && dir.path == parent) + { + return None; + } + let offset = position_to_offset(content, position) as usize; + let declaration = collect_json_trans_declarations(content) + .into_iter() + .find(|declaration| declaration.start <= offset && offset <= declaration.end)?; + let kind = LaravelStringKind::Trans; + let snapshot = backend.user_file_symbol_maps_for_reference_keys(&[ + crate::reference_index::ReferenceIndexKey::LaravelString { + kind: kind.clone(), + key: declaration.key.clone(), + }, + ]); + Some(super::string_keys::find_laravel_string_key_references( + backend, + &kind, + &declaration.key, + uri, + &snapshot, + include_declaration, + )) +} + +#[cfg(test)] +#[path = "trans_json_tests.rs"] +mod tests; diff --git a/src/virtual_members/laravel/trans_json_tests.rs b/src/virtual_members/laravel/trans_json_tests.rs new file mode 100644 index 000000000..9f4bb9a69 --- /dev/null +++ b/src/virtual_members/laravel/trans_json_tests.rs @@ -0,0 +1,126 @@ +use super::*; +use crate::test_fixtures::make_backend; + +#[test] +fn json_translation_declarations_preserve_source_ranges_and_values() { + let content = r#"{ + "Greeting 😀": "Bonjour :name", + "Quoted \"key\"": "Ligne\nSuivante", + "Unicode \u00e9": "Café", + "nested": {"ignored": true}, + "empty": null +}"#; + let declarations = collect_json_trans_declarations(content); + assert_eq!(declarations.len(), 5); + for (declaration, expected_key, expected_source, value) in [ + ( + &declarations[0], + "Greeting 😀", + "Greeting 😀", + Some("Bonjour :name"), + ), + ( + &declarations[1], + "Quoted \"key\"", + r#"Quoted \"key\""#, + Some("Ligne\nSuivante"), + ), + ( + &declarations[2], + "Unicode é", + r"Unicode \u00e9", + Some("Café"), + ), + (&declarations[3], "nested", "nested", None), + (&declarations[4], "empty", "empty", None), + ] { + assert_eq!(declaration.key, expected_key); + assert_eq!( + &content[declaration.start..declaration.end], + expected_source + ); + assert_eq!(declaration.value.as_deref(), value); + assert!(!declaration.is_group); + } +} + +#[test] +fn json_translation_declarations_reject_invalid_documents() { + for content in [ + "", + "[]", + "null", + "{", + "{1: 2}", + "{\"a\"}", + "{\"a\":}", + "{\"a\":\"bad\\escape\"}", + "{\"a\":1 \"b\":2}", + "{\"a\":1,}", + "{\"a\":1} trailing", + "{} trailing", + "{\"a\":1", + ] { + assert!( + collect_json_trans_declarations(content).is_empty(), + "{content}" + ); + } + assert!(collect_json_trans_declarations(" \n{ } \r\n").is_empty()); + assert_eq!( + collect_json_trans_declarations("{\"a\":1,\"a\":2}").len(), + 2 + ); +} + +#[test] +fn json_translation_references_ignore_values_and_unrelated_files() { + let backend = make_backend(); + for uri in [ + "file:///project/lang/en.php", + "file:///project/config/en.json", + "invalid.json", + "https://example.test/lang/en.json", + ] { + assert!( + find_json_trans_references(&backend, uri, "{}", Position::new(0, 0), true).is_none() + ); + } + assert!( + find_json_trans_references( + &backend, + "file:///project/lang/en.json", + r#"{"key":"value"}"#, + Position::new(0, 10), + true, + ) + .is_none() + ); +} + +#[test] +fn json_translation_provider_paths_and_duplicate_keys_resolve() { + let backend = make_backend(); + let dir = tempfile::tempdir().unwrap(); + let content = "{\"key\":\"first\",\n \"key\":\"last\"}"; + let path = dir.path().join("fr.json"); + std::fs::write(&path, content).unwrap(); + std::fs::write(dir.path().join("ignore.txt"), "{}").unwrap(); + std::fs::write(dir.path().join("invalid.json"), "{").unwrap(); + let resource = super::super::provider_resources::ProviderResource { + path: dir.path().to_path_buf(), + namespace: String::new(), + }; + backend.laravel_provider_resources.write().trans_dirs = vec![resource.clone(), resource]; + let uri = Url::from_file_path(path).unwrap(); + let locations = + find_json_trans_references(&backend, uri.as_str(), content, Position::new(0, 2), true) + .unwrap(); + assert_eq!(locations.len(), 1); + assert_eq!(locations[0].uri, uri); + assert_eq!( + locations[0].range, + Range::new(Position::new(1, 2), Position::new(1, 5)) + ); + assert!(json_definitions(&backend, "missing").is_empty()); +} diff --git a/src/virtual_members/laravel/trans_keys.rs b/src/virtual_members/laravel/trans_keys.rs index 75c2568f2..9e83fbea6 100644 --- a/src/virtual_members/laravel/trans_keys.rs +++ b/src/virtual_members/laravel/trans_keys.rs @@ -55,7 +55,7 @@ pub(crate) fn unresolved_trans_type() -> PhpType { /// /// 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 mut results = Vec::new(); + let mut results = super::trans_json::json_definitions(backend, key); if let Some((namespace, rest)) = key.split_once("::") { let file_stem = rest.split('.').next().unwrap_or(rest); @@ -123,27 +123,6 @@ 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 } @@ -187,6 +166,8 @@ fn trans_file_prefix(key: &str) -> String { pub(crate) struct TransKeyMatch { pub key: String, pub start: usize, + /// Byte offset immediately after the key's source text, before its quote. + pub end: usize, /// Whether the key's value is itself a nested array (a translation /// group) rather than a scalar string entry. pub is_group: bool, @@ -281,7 +262,7 @@ fn collect_array<'a>( let ArrayElement::KeyValue(kv) = element else { continue; }; - let Some((key_text, key_start, _)) = + let Some((key_text, key_start, key_end)) = super::helpers::extract_string_literal(kv.key, content) else { continue; @@ -293,6 +274,7 @@ fn collect_array<'a>( out.push(TransKeyMatch { key: dot_key, start: key_start, + end: key_end, is_group: value_is_group(kv.value), value: super::helpers::extract_string_literal(kv.value, content) .map(|(text, _, _)| text.to_string()), diff --git a/tests/integration/laravel_translation_depth.rs b/tests/integration/laravel_translation_depth.rs new file mode 100644 index 000000000..dfdd64300 --- /dev/null +++ b/tests/integration/laravel_translation_depth.rs @@ -0,0 +1,134 @@ +use crate::common::{create_psr4_workspace, lsp_pos_to_offset, open_document, open_php}; +use tower_lsp::LanguageServer; +use tower_lsp::lsp_types::*; + +const COMPOSER: &str = r#"{ + "require": {"laravel/framework": "^13.0"}, + "autoload": {"psr-4": {"App\\": "src/"}} +}"#; + +fn position(content: &str, needle: &str) -> Position { + let offset = content.find(needle).unwrap(); + let before = &content[..offset]; + Position::new( + before.bytes().filter(|b| *b == b'\n').count() as u32, + before.rsplit('\n').next().unwrap().encode_utf16().count() as u32, + ) +} + +async fn references( + backend: &phpantom_lsp::Backend, + uri: &Url, + at: Position, + include_declaration: bool, +) -> Vec { + backend + .references(ReferenceParams { + text_document_position: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri: uri.clone() }, + position: at, + }, + context: ReferenceContext { + include_declaration, + }, + work_done_progress_params: Default::default(), + partial_result_params: Default::default(), + }) + .await + .unwrap() + .unwrap_or_default() +} + +#[tokio::test] +async fn json_translation_definitions_and_references_use_exact_key_ranges() { + let php = " Date: Wed, 9 Sep 2026 21:01:56 +0600 Subject: [PATCH 2/6] perf(laravel): share cached translation declarations across features --- src/backend/file_access.rs | 3 + src/completion/laravel_string_keys.rs | 308 +----------------- src/indexing/watch.rs | 24 ++ src/lib.rs | 21 +- src/server.rs | 2 +- src/virtual_members/laravel/mod.rs | 4 +- src/virtual_members/laravel/trans_catalog.rs | 199 +++++++++++ .../laravel/trans_catalog_tests.rs | 169 ++++++++++ src/virtual_members/laravel/trans_json.rs | 47 +-- .../laravel/trans_json_tests.rs | 3 +- src/virtual_members/laravel/trans_keys.rs | 118 +------ 11 files changed, 427 insertions(+), 471 deletions(-) create mode 100644 src/virtual_members/laravel/trans_catalog.rs create mode 100644 src/virtual_members/laravel/trans_catalog_tests.rs diff --git a/src/backend/file_access.rs b/src/backend/file_access.rs index dcfaaaf96..2187b05b5 100644 --- a/src/backend/file_access.rs +++ b/src/backend/file_access.rs @@ -318,6 +318,9 @@ impl Backend { /// /// Called from `did_close` to clean up state when a file is closed. pub(crate) fn clear_file_maps(&self, uri: &str) { + self.laravel_string_key_cache + .write() + .invalidate_for_uri(uri, ""); // uri_classes_index is redundant with fqn_class_index once indexing // is complete — GTD falls back to fqn_uri_index + parse_and_cache_file // when the uri_classes_index entry is missing. diff --git a/src/completion/laravel_string_keys.rs b/src/completion/laravel_string_keys.rs index 3591878be..43b675d08 100644 --- a/src/completion/laravel_string_keys.rs +++ b/src/completion/laravel_string_keys.rs @@ -14,8 +14,6 @@ //! symbol map decides the same question for a *complete* file, and the two //! are kept in step by hand. -use std::collections::HashMap; - use tower_lsp::lsp_types::*; use crate::Backend; @@ -639,89 +637,6 @@ impl Backend { 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 - } - - /// Enumerate every translation key alongside whether it names a - /// translation group (a nested array) rather than a scalar string - /// entry, merging the flag across every locale and file that - /// declares the key. - /// - /// A key that is a group in *any* locale is recorded as a group even - /// if another locale happens to declare it as a scalar — the return - /// type narrowing this feeds is only safe when every locale agrees - /// the entry is scalar. - fn enumerate_all_trans_key_shapes(&self) -> HashMap { - let snapshot = self.user_file_symbol_maps(); - let mut shapes = HashMap::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 { - mark_trans_shape(&mut shapes, d.key, d.is_group); - } - } - - collect_json_trans_key_shapes(self, &mut shapes); - - for res in &self.laravel_provider_resources.read().trans_dirs { - collect_namespaced_trans_key_shapes(&res.path, &res.namespace, &mut shapes); - } - - shapes - } - /// Read one slot of [`LaravelStringKeyCache`], building it under /// `build_lock` when empty. /// @@ -801,216 +716,7 @@ impl Backend { } pub(crate) fn cached_trans_keys(&self) -> Vec { - self.cached_laravel_enumeration( - &self.laravel_string_key_build_locks.trans_keys, - |cache| cache.trans_keys.clone(), - |cache, keys| cache.trans_keys = Some(keys), - || self.enumerate_all_trans_keys(), - ) - } - - /// Every translation key mapped to whether it names a group (nested - /// array) rather than a scalar entry. Used to narrow the return type - /// of `__()`/`trans()`/`Lang::get()` at call sites whose key argument - /// is a literal. - pub(crate) fn cached_trans_key_shapes(&self) -> std::sync::Arc> { - self.cached_laravel_enumeration( - &self.laravel_string_key_build_locks.trans_key_shapes, - |cache| cache.trans_key_shapes.clone(), - |cache, shapes| cache.trans_key_shapes = Some(shapes), - || std::sync::Arc::new(self.enumerate_all_trans_key_shapes()), - ) - } -} - -/// 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.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()); - } - } - } - } -} - -/// 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); - } - } -} - -/// Record a key's group/scalar shape, OR-ing into any flag already -/// recorded for the same key from another locale or file. -fn mark_trans_shape(shapes: &mut HashMap, key: String, is_group: bool) { - let existing = shapes.entry(key).or_insert(false); - *existing = *existing || is_group; -} - -/// The shape counterpart of [`collect_json_trans_keys`]: every JSON -/// translation key is a scalar phrase, never a group. -fn collect_json_trans_key_shapes(backend: &crate::Backend, out: &mut HashMap) { - let root = match backend.workspace.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() { - mark_trans_shape(out, k.clone(), false); - } - } - } - } -} - -/// The shape counterpart of [`collect_namespaced_trans_keys`]. -fn collect_namespaced_trans_key_shapes( - dir: &std::path::Path, - namespace: &str, - out: &mut HashMap, -) { - 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_shapes_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() { - mark_trans_shape(out, k.clone(), false); - } - } - } -} - -fn collect_namespaced_trans_shapes_from_locale_dir( - dir: &std::path::Path, - namespace: &str, - out: &mut HashMap, -) { - 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 { - mark_trans_shape(out, d.key, d.is_group); - } + self.cached_translations().entries.keys().cloned().collect() } } @@ -1811,18 +1517,6 @@ Storage::forgetDisk([['nested'], wrap(fn () => new class {}), 'it\'s', 'archive' assert!(ctx.is_none(), "Non-Laravel function should not match"); } - #[test] - fn lang_file_stem_extraction() { - assert_eq!( - extract_lang_file_stem("file:///app/lang/en/messages.php"), - Some("messages".to_string()) - ); - assert_eq!( - extract_lang_file_stem("file:///app/resources/lang/en/validation.php"), - Some("validation".to_string()) - ); - } - #[test] fn detects_config_attribute_with_import() { let content = diff --git a/src/indexing/watch.rs b/src/indexing/watch.rs index 3e87c0cf9..0de30a81a 100644 --- a/src/indexing/watch.rs +++ b/src/indexing/watch.rs @@ -55,6 +55,7 @@ impl Backend { root: &std::path::Path, ) -> bool { let mut composer_changed = false; + let mut translations_changed = false; let mut config_changed = false; let mut schema_full_rebuild = false; let mut migration_changes: Vec<(PathBuf, FileChangeType)> = Vec::new(); @@ -70,6 +71,7 @@ impl Backend { let indexed = self.symbol_maps.read(); let laravel_config = self.config().laravel; let filters = self.index_filters(); + let translations = self.laravel_string_key_cache.read().translations.clone(); for change in ¶ms.changes { let path_str = change.uri.path(); if path_str.ends_with("/composer.json") || path_str.ends_with("/composer.lock") { @@ -114,6 +116,19 @@ impl Backend { continue; } let uri_str = change.uri.to_string(); + if is_laravel + && !open.contains_key(&uri_str) + && (path_str.contains("/lang/") + || translations + .as_ref() + .is_some_and(|catalog| catalog.contains_uri(&uri_str))) + && change + .uri + .to_file_path() + .is_ok_and(|path| !filters.is_excluded_path(&path, false)) + { + translations_changed = true; + } if crate::resource_navigation::is_resource_document(path_str) { if open.contains_key(&uri_str) { continue; @@ -178,6 +193,7 @@ impl Backend { if php_changes.is_empty() && resource_changes.is_empty() && !composer_changed + && !translations_changed && !config_changed && !schema_full_rebuild && migration_changes.is_empty() @@ -185,6 +201,10 @@ impl Backend { return false; } + if translations_changed { + self.laravel_string_key_cache.write().translations = None; + } + if config_changed { tracing::info!("PHPantom: .phpantom.toml changed, reloading configuration"); self.reload_config(root); @@ -346,6 +366,10 @@ impl Backend { ]); if is_laravel { watchers.extend([ + FileSystemWatcher { + glob_pattern: GlobPattern::String("**/*.json".to_string()), + kind: None, + }, FileSystemWatcher { glob_pattern: GlobPattern::String("**/*.sql".to_string()), kind: Some(WatchKind::Create | WatchKind::Change | WatchKind::Delete), diff --git a/src/lib.rs b/src/lib.rs index d7857dc05..7aaab2216 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -351,12 +351,8 @@ pub(crate) struct LaravelStringKeyCache { pub routes: Option>, pub config_keys: Option>, pub view_names: Option>, - pub trans_keys: Option>, - /// Every translation key mapped to whether it names a group (nested - /// array) rather than a scalar entry. Shared behind an `Arc` for the - /// same reason as `routes`: consumers look up one key per call and - /// cloning the whole map per lookup would be waste. - pub trans_key_shapes: Option>>, + /// Shared translation declarations, values, locales, and file locations. + pub translations: Option>, /// The Blade templates and component classes the project ships, keyed /// by the names Laravel addresses them under. Shared behind an `Arc` /// because consumers look up a single name in one of its three maps and @@ -402,8 +398,7 @@ pub(crate) struct LaravelStringKeyBuildLocks { pub routes: parking_lot::Mutex<()>, pub config_keys: parking_lot::Mutex<()>, pub view_names: parking_lot::Mutex<()>, - pub trans_keys: parking_lot::Mutex<()>, - pub trans_key_shapes: parking_lot::Mutex<()>, + pub translations: parking_lot::Mutex<()>, pub config_trees: parking_lot::Mutex<()>, pub blade_discovery: parking_lot::Mutex<()>, pub blade_blocks: parking_lot::Mutex<()>, @@ -451,9 +446,13 @@ impl LaravelStringKeyCache { { self.view_names = None; } - if uri.contains("/lang/") || uri.contains("/resources/lang/") { - self.trans_keys = None; - self.trans_key_shapes = None; + if uri.contains("/lang/") + || self + .translations + .as_ref() + .is_some_and(|catalog| catalog.contains_uri(uri)) + { + self.translations = None; } } } diff --git a/src/server.rs b/src/server.rs index 3c334a483..c3e2b3996 100644 --- a/src/server.rs +++ b/src/server.rs @@ -3278,6 +3278,7 @@ impl Backend { let directives_changed = *self.blade_custom_directives.read() != directives; *self.blade_custom_directives.write() = directives; *self.laravel_provider_resources.write() = resources; + self.laravel_string_key_cache.write().translations = None; // The shared and composed template variables are resolved from these // registrations, so the previous scan's set is stale whether or not @@ -3289,7 +3290,6 @@ impl Backend { cache.config_keys = None; cache.config_trees = None; cache.view_names = None; - cache.trans_keys = None; cache.routes = None; cache.blade_discovery = None; } diff --git a/src/virtual_members/laravel/mod.rs b/src/virtual_members/laravel/mod.rs index 104766768..2512e89dc 100644 --- a/src/virtual_members/laravel/mod.rs +++ b/src/virtual_members/laravel/mod.rs @@ -133,6 +133,7 @@ mod route_names; mod scopes; mod storage; mod string_keys; +mod trans_catalog; mod trans_json; mod trans_keys; pub(crate) mod validated_shape; @@ -186,8 +187,9 @@ pub(crate) use storage::{ extract_storage_driver_registrations, is_storage_facade_name, patch_storage_disk_type, storage_facade_local_names, }; +pub(crate) use trans_catalog::TranslationCatalog; pub(crate) use trans_json::find_json_trans_references; -pub(crate) use trans_keys::{collect_trans_declarations, trans_line, unresolved_trans_type}; +pub(crate) use trans_keys::{trans_line, unresolved_trans_type}; pub(crate) use validation_rules::{safe_call_receiver_variable, safe_source_variable}; pub(crate) use view_data::{SharedViewVar, composer_class_vars}; pub(crate) use view_names::canonical_view_name; diff --git a/src/virtual_members/laravel/trans_catalog.rs b/src/virtual_members/laravel/trans_catalog.rs new file mode 100644 index 000000000..53933d9f2 --- /dev/null +++ b/src/virtual_members/laravel/trans_catalog.rs @@ -0,0 +1,199 @@ +//! Shared, lazy translation declarations for editor features. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; +use std::sync::Arc; + +use tower_lsp::lsp_types::{Location, Range, Url}; + +use crate::Backend; +use crate::text_position::LineIndex; + +use super::provider_resources::ProviderResource; +use super::trans_json::collect_json_trans_declarations; +use super::trans_keys::collect_trans_declarations; + +/// A language file shared by all the keys it declares. +pub(crate) struct TranslationFile { + /// The URI shared by this file's declarations. + pub uri: Url, + /// The locale derived from the containing directory or JSON filename. + pub locale: String, + /// PHP group name, with its package namespace; JSON files have no group. + pub group: Option, +} + +/// One locale's declaration of a translation key. +pub(crate) struct TranslationEntry { + /// Index of the declaring file in [`TranslationCatalog::files`]. + pub file: usize, + /// The key's source range in UTF-16 coordinates. + pub range: Range, + /// The literal translation, when its value is statically known. + pub value: Option, + /// Whether the key describes an array of translations. + pub is_group: bool, +} + +/// Translation keys, values, and locations parsed once per resource update. +/// Files and key strings are shared across locales instead of duplicating +/// paths for every entry. The ordered maps also keep completion stable. +#[derive(Default)] +pub(crate) struct TranslationCatalog { + /// The definitions of each key, ordered by locale and file URI. + pub entries: BTreeMap>, + /// All readable PHP and JSON language files, including empty groups. + pub files: Vec, + /// Locales declared by directories or JSON filenames. + pub locales: BTreeSet, + roots: Vec, +} + +impl TranslationCatalog { + /// Whether an edit falls below one of the catalog's translation roots. + pub(crate) fn contains_uri(&self, uri: &str) -> bool { + self.roots.iter().any(|root| uri.starts_with(root)) + } + + fn insert_file(&mut self, backend: &Backend, path: &Path, locale: &str, namespace: &str) { + let Ok(uri) = Url::from_file_path(path) else { + return; + }; + let Some(content) = backend.get_file_content(uri.as_str()) else { + return; + }; + let group = if path.extension().is_some_and(|ext| ext == "php") { + let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else { + return; + }; + Some(if namespace.is_empty() { + stem.to_string() + } else { + format!("{namespace}::{stem}") + }) + } else { + None + }; + let declarations = match &group { + Some(group) => collect_trans_declarations(&content, group), + None => collect_json_trans_declarations(&content), + }; + let file = self.files.len(); + self.files.push(TranslationFile { + uri, + locale: locale.to_string(), + group, + }); + let lines = LineIndex::new(&content); + for declaration in declarations { + let entry = TranslationEntry { + file, + range: Range::new( + lines.position(declaration.start), + lines.position(declaration.end), + ), + value: declaration.value, + is_group: declaration.is_group, + }; + let entries = self.entries.entry(declaration.key).or_default(); + // PHP arrays and JSON objects both keep the last duplicate key. + if let Some(previous) = entries.iter_mut().find(|entry| entry.file == file) { + *previous = entry; + } else { + entries.push(entry); + } + } + } +} + +impl Backend { + /// Read the shared translation catalog, building it only on a cache miss. + pub(crate) fn cached_translations(&self) -> Arc { + self.cached_laravel_enumeration( + &self.laravel_string_key_build_locks.translations, + |cache| cache.translations.clone(), + |cache, translations| cache.translations = Some(translations), + || Arc::new(self.build_translation_catalog()), + ) + } + + fn build_translation_catalog(&self) -> TranslationCatalog { + let mut roots = self.laravel_provider_resources.read().trans_dirs.clone(); + if let Some(root) = self.workspace.workspace_root.read().as_ref() { + roots.extend( + ["lang", "resources/lang"].map(|directory| ProviderResource { + path: root.join(directory), + namespace: String::new(), + }), + ); + } + roots.sort_by(|a, b| (&a.path, &a.namespace).cmp(&(&b.path, &b.namespace))); + roots.dedup(); + let mut catalog = TranslationCatalog::default(); + for root in roots { + if let Ok(uri) = Url::from_directory_path(&root.path) { + catalog.roots.push(uri.to_string()); + } + let Ok(entries) = std::fs::read_dir(&root.path) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() { + let Some(locale) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + if locale == "vendor" { + continue; + } + catalog.locales.insert(locale.to_string()); + let Ok(files) = std::fs::read_dir(&path) else { + continue; + }; + for file in files.flatten() { + let path = file.path(); + if path.extension().is_some_and(|ext| ext == "php") { + catalog.insert_file(self, &path, locale, &root.namespace); + } + } + } else if root.namespace.is_empty() + && path.extension().is_some_and(|ext| ext == "json") + && let Some(locale) = path.file_stem().and_then(|name| name.to_str()) + { + catalog.locales.insert(locale.to_string()); + catalog.insert_file(self, &path, locale, ""); + } + } + } + for entries in catalog.entries.values_mut() { + entries.sort_by(|a, b| { + let a = &catalog.files[a.file]; + let b = &catalog.files[b.file]; + (&a.locale, &a.uri).cmp(&(&b.locale, &b.uri)) + }); + } + catalog + } + + /// Resolve actual declarations, or the file of a whole PHP group. + pub(crate) fn translation_definitions(&self, key: &str) -> Vec { + let catalog = self.cached_translations(); + if let Some(entries) = catalog.entries.get(key) { + entries + .iter() + .map(|entry| Location::new(catalog.files[entry.file].uri.clone(), entry.range)) + .collect() + } else { + catalog + .files + .iter() + .filter(|file| file.group.as_deref() == Some(key)) + .map(|file| Location::new(file.uri.clone(), Range::default())) + .collect() + } + } +} + +#[cfg(test)] +#[path = "trans_catalog_tests.rs"] +mod tests; diff --git a/src/virtual_members/laravel/trans_catalog_tests.rs b/src/virtual_members/laravel/trans_catalog_tests.rs new file mode 100644 index 000000000..6389575f1 --- /dev/null +++ b/src/virtual_members/laravel/trans_catalog_tests.rs @@ -0,0 +1,169 @@ +use super::*; +use crate::test_fixtures::make_backend; +use tower_lsp::lsp_types::{DidChangeWatchedFilesParams, FileChangeType, FileEvent}; + +#[test] +fn translation_catalog_merges_roots_locales_groups_and_providers() { + let backend = make_backend(); + let dir = tempfile::tempdir().unwrap(); + *backend.workspace.workspace_root.write() = Some(dir.path().to_path_buf()); + for directory in [ + "lang/en", + "resources/lang/fr", + "lang/es", + "lang/vendor", + "package/de", + ] { + std::fs::create_dir_all(dir.path().join(directory)).unwrap(); + } + for (path, content) in [ + ( + "lang/en/messages.php", + " 'Hello', 'group' => ['child' => 'yes']];", + ), + ( + "resources/lang/fr/messages.php", + " 'Bonjour', 'group' => 'Groupe'];", + ), + ("lang/en.json", r#"{"Hello":"first", "Hello":"last"}"#), + ("resources/lang/it.json", r#"{"Hello":"Ciao"}"#), + ("lang/en/ignore.txt", "ignore"), + ("lang/vendor/ignored.php", "'no'];"), + ("package/de/mail.php", "'Gesendet'];"), + ("package/ignored.json", r#"{"ignored":"ignored"}"#), + ] { + std::fs::write(dir.path().join(path), content).unwrap(); + } + backend + .laravel_provider_resources + .write() + .trans_dirs + .push(ProviderResource { + path: dir.path().join("package"), + namespace: "shop".to_string(), + }); + let catalog = backend.cached_translations(); + assert_eq!( + catalog + .locales + .iter() + .map(String::as_str) + .collect::>(), + ["de", "en", "es", "fr", "it"] + ); + assert_eq!(catalog.entries["Hello"][0].value.as_deref(), Some("last")); + assert_eq!(catalog.entries["messages.hello"].len(), 2); + assert_eq!( + catalog.entries["shop::mail.sent"][0].value.as_deref(), + Some("Gesendet") + ); + assert!(!catalog.entries.contains_key("ignored")); + assert_eq!(backend.translation_definitions("messages").len(), 2); + assert!( + backend + .translation_definitions("messages.missing") + .is_empty() + ); + assert_eq!( + backend.resolve_trans_type("messages.hello").unwrap(), + crate::php_type::PhpType::string() + ); + assert_ne!( + backend.resolve_trans_type("messages.group").unwrap(), + crate::php_type::PhpType::string() + ); + assert_eq!(backend.cached_trans_keys().len(), catalog.entries.len()); + assert!(Arc::ptr_eq(&catalog, &backend.cached_translations())); + assert!( + catalog.contains_uri( + Url::from_file_path(dir.path().join("package/de/mail.php")) + .unwrap() + .as_str() + ) + ); +} + +#[test] +fn translation_catalog_refreshes_buffers_close_and_watched_files() { + let backend = make_backend(); + backend.resolved_class_cache.write().set_laravel(true); + let dir = tempfile::tempdir().unwrap(); + *backend.workspace.workspace_root.write() = Some(dir.path().to_path_buf()); + std::fs::create_dir_all(dir.path().join("resources/lang/en")).unwrap(); + let path = dir.path().join("resources/lang/en/messages.php"); + let uri = Url::from_file_path(&path).unwrap(); + std::fs::write(&path, "'disk'];").unwrap(); + let catalog = backend.cached_translations(); + backend + .laravel_string_key_cache + .write() + .invalidate_for_uri("file:///project/other.php", "'buffer'];".to_string()), + ); + backend + .laravel_string_key_cache + .write() + .invalidate_for_uri(uri.as_str(), ""); + assert_eq!( + backend.cached_translations().entries["messages.key"][0] + .value + .as_deref(), + Some("buffer") + ); + backend.open_files.write().remove(uri.as_str()); + backend.clear_file_maps(uri.as_str()); + assert_eq!( + backend.cached_translations().entries["messages.key"][0] + .value + .as_deref(), + Some("disk") + ); + std::fs::write(&path, "'new'];").unwrap(); + assert!(backend.apply_watched_file_changes( + &DidChangeWatchedFilesParams { + changes: vec![FileEvent { + uri, + typ: FileChangeType::CHANGED + }], + }, + dir.path() + )); + assert!( + backend + .cached_translations() + .entries + .contains_key("messages.new") + ); + let json = dir.path().join("resources/lang/fr.json"); + std::fs::write(&json, r#"{"Bonjour":"Salut"}"#).unwrap(); + assert!(backend.apply_watched_file_changes( + &DidChangeWatchedFilesParams { + changes: vec![FileEvent { + uri: Url::from_file_path(json).unwrap(), + typ: FileChangeType::CREATED + }], + }, + dir.path() + )); + assert!( + backend + .cached_translations() + .entries + .contains_key("Bonjour") + ); +} + +#[test] +fn translation_catalog_handles_missing_and_unreadable_files() { + let backend = make_backend(); + assert!(backend.cached_translations().entries.is_empty()); + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("lang/en/bad.php")).unwrap(); + std::fs::write(dir.path().join("lang/en/invalid.php"), [0xff]).unwrap(); + *backend.workspace.workspace_root.write() = Some(dir.path().to_path_buf()); + backend.laravel_string_key_cache.write().translations = None; + assert!(backend.cached_translations().entries.is_empty()); +} diff --git a/src/virtual_members/laravel/trans_json.rs b/src/virtual_members/laravel/trans_json.rs index d3d4fd34a..ced423c08 100644 --- a/src/virtual_members/laravel/trans_json.rs +++ b/src/virtual_members/laravel/trans_json.rs @@ -1,6 +1,6 @@ //! JSON translation declarations with their source positions. -use tower_lsp::lsp_types::{Location, Position, Range, Url}; +use tower_lsp::lsp_types::{Location, Position, Url}; use crate::Backend; use crate::symbol_map::LaravelStringKind; @@ -48,51 +48,6 @@ fn parse_declarations(content: &str) -> Option> { } } -/// Locate a key in application and provider-registered JSON language files. -pub(super) fn json_definitions(backend: &Backend, key: &str) -> Vec { - let mut directories: Vec<_> = backend - .laravel_provider_resources - .read() - .trans_dirs - .iter() - .filter(|resource| resource.namespace.is_empty()) - .map(|resource| resource.path.clone()) - .collect(); - if let Some(root) = backend.workspace.workspace_root.read().as_ref() { - directories.extend([root.join("lang"), root.join("resources/lang")]); - } - directories.sort(); - directories.dedup(); - let mut locations = Vec::new(); - for directory in directories { - let Ok(entries) = std::fs::read_dir(directory) else { - continue; - }; - for entry in entries.flatten() { - let path = entry.path(); - if path - .extension() - .is_some_and(|extension| extension == "json") - && let Ok(uri) = Url::from_file_path(&path) - && let Some(content) = backend.get_file_content(uri.as_str()) - && let Some(declaration) = collect_json_trans_declarations(&content) - .into_iter() - .rev() - .find(|declaration| declaration.key == key) - { - locations.push(Location::new( - uri, - Range::new( - crate::text_position::offset_to_position(&content, declaration.start), - crate::text_position::offset_to_position(&content, declaration.end), - ), - )); - } - } - } - locations -} - /// Find uses of the JSON translation key under the cursor through the /// same PHP/Blade reference index as a translation helper call. pub(crate) fn find_json_trans_references( diff --git a/src/virtual_members/laravel/trans_json_tests.rs b/src/virtual_members/laravel/trans_json_tests.rs index 9f4bb9a69..9507566da 100644 --- a/src/virtual_members/laravel/trans_json_tests.rs +++ b/src/virtual_members/laravel/trans_json_tests.rs @@ -1,5 +1,6 @@ use super::*; use crate::test_fixtures::make_backend; +use tower_lsp::lsp_types::Range; #[test] fn json_translation_declarations_preserve_source_ranges_and_values() { @@ -122,5 +123,5 @@ fn json_translation_provider_paths_and_duplicate_keys_resolve() { locations[0].range, Range::new(Position::new(1, 2), Position::new(1, 5)) ); - assert!(json_definitions(&backend, "missing").is_empty()); + assert!(backend.translation_definitions("missing").is_empty()); } diff --git a/src/virtual_members/laravel/trans_keys.rs b/src/virtual_members/laravel/trans_keys.rs index 9e83fbea6..f31ec2dc0 100644 --- a/src/virtual_members/laravel/trans_keys.rs +++ b/src/virtual_members/laravel/trans_keys.rs @@ -1,7 +1,7 @@ use mago_allocator::LocalArena; use mago_database::file::FileId; use mago_syntax::cst::*; -use tower_lsp::lsp_types::{Location, Position, Url}; +use tower_lsp::lsp_types::{Location, Url}; use crate::Backend; use crate::atom::bytes_to_str; @@ -17,9 +17,9 @@ impl Backend { /// of lines beneath it. A key the indexed translations do not cover /// falls back to [`unresolved_trans_type`]. pub(crate) fn resolve_trans_type(&self, key: &str) -> Option { - match self.cached_trans_key_shapes().get(key) { - Some(false) => Some(PhpType::string()), - Some(true) => Some(trans_group_type()), + match self.cached_translations().entries.get(key) { + Some(entries) if entries.iter().any(|entry| entry.is_group) => Some(trans_group_type()), + Some(_) => Some(PhpType::string()), None => Some(unresolved_trans_type()), } } @@ -53,111 +53,21 @@ pub(crate) fn unresolved_trans_type() -> PhpType { /// rest = array path). For JSON files the key is looked up directly as a /// top-level object key (Laravel's JSON translations are flat). /// -/// Falls back to the top of the file when the exact key cannot be located. +/// Whole PHP groups resolve to the start of their file. pub(crate) fn resolve_trans_definitions(backend: &Backend, key: &str) -> Vec { - let mut results = super::trans_json::json_definitions(backend, key); - - 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::text_position::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"); - - 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 { - continue; - }; - - let declarations = collect_trans_declarations(&content, file_stem); - if let Some(decl) = declarations.into_iter().find(|d| d.key == key) { - let pos = crate::text_position::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))); - } - } - - results + backend.translation_definitions(key) } -/// The line a translation key resolves to inside the file that declares it. -/// -/// The file is the one [`resolve_trans_definitions`] settled on, so hover -/// quotes the string from the same locale it names, and a group (which has -/// no single line) resolves to `None`. +/// The literal translation in a particular declaring file. pub(crate) fn trans_line(backend: &Backend, key: &str, file_uri: &Url) -> Option { - let path = file_uri.path(); - if path.ends_with(".json") { - let content = std::fs::read_to_string(file_uri.to_file_path().ok()?).ok()?; - let map = - serde_json::from_str::>(&content).ok()?; - return map.get(key)?.as_str().map(str::to_string); - } - let content = backend - .get_file_content(file_uri.as_str()) - .or_else(|| std::fs::read_to_string(file_uri.to_file_path().ok()?).ok())?; - collect_trans_declarations(&content, &trans_file_prefix(key)) - .into_iter() - .find(|decl| decl.key == key)? + let catalog = backend.cached_translations(); + catalog + .entries + .get(key)? + .iter() + .find(|entry| catalog.files[entry.file].uri == *file_uri)? .value -} - -/// The prefix [`collect_trans_declarations`] flattens a file's keys under, -/// derived from the key being looked up: the first dotted segment, or -/// `namespace::file` for a package translation. -fn trans_file_prefix(key: &str) -> String { - match key.split_once("::") { - Some((namespace, rest)) => { - format!("{namespace}::{}", rest.split('.').next().unwrap_or(rest)) - } - None => key.split('.').next().unwrap_or(key).to_string(), - } + .clone() } // ─── Declaration extractor (mirrors config_keys logic) ─────────────────────── From fc6200b501f3869dfbd85f107e5960d58daf8b0c Mon Sep 17 00:00:00 2001 From: Shuvro Roy Date: Wed, 9 Sep 2026 21:09:21 +0600 Subject: [PATCH 3/6] feat(laravel): complete translation locales and placeholders --- docs/CHANGELOG.md | 2 + examples/laravel/app/Demo.php | 3 + src/completion/handler/mod.rs | 7 + src/completion/laravel_translation_args.rs | 268 ++++++++++++++++++ .../laravel_translation_args_tests.rs | 146 ++++++++++ src/completion/mod.rs | 1 + .../integration/laravel_translation_depth.rs | 57 ++++ 7 files changed, 484 insertions(+) create mode 100644 src/completion/laravel_translation_args.rs create mode 100644 src/completion/laravel_translation_args_tests.rs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index ce74c015f..8d6c17244 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Laravel translation arguments complete from the project's language files.** Locale arguments and replacement-array keys now suggest the available locales and translation placeholders, including named arguments and files under `lang/` and `resources/lang/`. Contributed by @shuvroroy. + - **JSON translation keys support precise navigation.** Laravel translation keys now navigate to their exact location in JSON language files, and find-references works from their declarations as well as their call sites. Contributed by @shuvroroy. - **Formatting from the command line.** `phpantom_lsp format` formats every PHP file and Blade template in a project with the same formatter the editor runs on save, and `phpantom_lsp format --check` reports the files that are not formatted and exits non-zero without writing anything, so a CI job can require that a pull request ran the formatter. A run honours whatever the project already formats with, a Laravel Pint, php-cs-fixer, or PHP_CodeSniffer it depends on, and the built-in formatter otherwise, exactly as the editor resolves it, and opens with a line naming what it resolved so a CI log records which formatter enforced the result. Templates whose indentation is output rather than layout are left alone and never fail a check, and formatting turned off in `.phpantom.toml` is reported as such rather than passing as a project where every file happens to be formatted. Paths can be named to restrict the run, `--format github` annotates the pull request diff, and `--format json` is shaped like the object `analyze` and `fix` emit. - **Storage disk names are navigable wherever Laravel accepts one.** `Storage::disk()`, `fake()`, `persistentFake()`, `forgetDisk()`, and the `#[Storage]` container attribute now complete from `config/filesystems.php`; hover shows the config key, Ctrl+Click opens its declaration, and find-references links every use. Calls that require a configured disk report misspellings, while test fakes and disk eviction keep accepting the ad-hoc names Laravel permits at runtime. Contributed by @shuvroroy. diff --git a/examples/laravel/app/Demo.php b/examples/laravel/app/Demo.php index 8b0f75af1..c263f89b5 100644 --- a/examples/laravel/app/Demo.php +++ b/examples/laravel/app/Demo.php @@ -671,6 +671,9 @@ public function laravelNavigation(): void // Ctrl+Click a JSON key to reach its exact declaration; find // references from lang/en.json to return to its call sites. __('Fresh bread for :name', ['name' => 'Ada']); + + // Try: complete the locale argument or a replacement-array key. + __('Fresh bread for :name', replace: ['name' => 'Ada'], locale: 'en'); __('messages.welcome'); trans('auth.failed'); trans_choice('messages.notifications', 5); diff --git a/src/completion/handler/mod.rs b/src/completion/handler/mod.rs index 853fb6489..d8375656b 100644 --- a/src/completion/handler/mod.rs +++ b/src/completion/handler/mod.rs @@ -367,6 +367,13 @@ impl Backend { // `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 + && let Some(code) = code_ctx.as_ref() + && let Some(response) = + self.try_translation_argument_completion(&content, position, code, &ctx) + { + return Ok(Some(response)); + } if is_laravel && matches!( string_ctx, diff --git a/src/completion/laravel_translation_args.rs b/src/completion/laravel_translation_args.rs new file mode 100644 index 000000000..85b276603 --- /dev/null +++ b/src/completion/laravel_translation_args.rs @@ -0,0 +1,268 @@ +//! Locale and replacement-key completion in translation calls. + +use std::collections::BTreeSet; + +use mago_span::HasSpan; +use mago_syntax::cst::*; +use mago_syntax::walker::Walker; +use tower_lsp::lsp_types::*; + +use crate::Backend; +use crate::atom::bytes_to_str; +use crate::completion::source::code_context::{CodeContext, OpenBracket}; +use crate::completion::source::helpers::{split_trailing_ident, trailing_class_name}; +use crate::text_position::{offset_to_position, position_to_offset}; +use crate::types::FileContext; + +struct TranslationCall { + locale: usize, + replace: Option, +} + +fn translation_call( + content: &str, + paren: &OpenBracket, + ctx: &FileContext, +) -> Option { + let (name, _) = split_trailing_ident(&content[..paren.code_before]); + let facade = if let Some(operator) = paren.callee_operator { + if !operator.is_static { + return None; + } + let receiver = trailing_class_name(&content[..operator.code_before]); + let resolved = + ctx.resolve_name_at(receiver, (operator.code_before - receiver.len()) as u32); + if !resolved + .trim_start_matches('\\') + .eq_ignore_ascii_case("Illuminate\\Support\\Facades\\Lang") + && !(receiver + .trim_start_matches('\\') + .eq_ignore_ascii_case("Lang") + && !ctx.use_map.contains_key("Lang")) + { + return None; + } + true + } else { + let function = trailing_class_name(&content[..paren.code_before]); + if function.trim_start_matches('\\').contains('\\') { + return None; + } + false + }; + match (facade, name.to_ascii_lowercase().as_str()) { + (false, "__" | "trans") | (true, "get") => Some(TranslationCall { + locale: 2, + replace: Some(1), + }), + (false, "trans_choice") | (true, "choice") => Some(TranslationCall { + locale: 3, + replace: Some(2), + }), + (true, "has" | "hasforlocale") => Some(TranslationCall { + locale: 1, + replace: None, + }), + _ => None, + } +} + +#[derive(Default)] +struct Arguments { + parameter: Option, + key: Option, + used: BTreeSet, + string_end: usize, +} + +struct ArgumentVisitor<'a> { + paren: usize, + quote: usize, + call: &'a TranslationCall, +} + +impl<'a> Walker<'a, 'a, Option> for ArgumentVisitor<'_> { + fn walk_in_argument_list(&self, list: &'a ArgumentList<'a>, out: &mut Option) { + if list.left_parenthesis.start.offset as usize != self.paren { + return; + } + let mut result = Arguments::default(); + let mut positional = 0; + for argument in list.arguments.iter() { + let parameter = match argument { + Argument::Positional(_) => { + let index = positional; + positional += 1; + Some(index) + } + Argument::Named(named) => match named.name.value { + b"key" => Some(0), + b"locale" => Some(self.call.locale), + b"replace" => self.call.replace, + _ => None, + }, + }; + let value = argument.value(); + if parameter == Some(0) { + result.key = literal(value).map(str::to_string); + } + let span = value.span(); + if span.start.offset as usize <= self.quote && self.quote < span.end.offset as usize { + result.parameter = parameter; + if let Expression::Literal(Literal::String(string)) = value { + result.string_end = string.span.end.offset as usize - 1; + } + } + if parameter.is_some() && parameter == self.call.replace { + let elements = match value { + Expression::Array(array) => array.elements.as_slice(), + _ => continue, + }; + for element in elements { + let key = match element { + ArrayElement::KeyValue(entry) => entry.key, + ArrayElement::Value(entry) => entry.value, + _ => continue, + }; + let span = key.span(); + if span.start.offset as usize == self.quote { + result.string_end = span.end.offset as usize - 1; + } else if let Some(key) = literal(key) { + result.used.insert(key.to_string()); + } + } + } + } + *out = Some(result); + } +} + +fn literal<'a>(value: &'a Expression<'_>) -> Option<&'a str> { + if let Expression::Literal(Literal::String(string)) = value { + string.value.map(bytes_to_str) + } else { + None + } +} + +fn arguments( + content: &str, + paren: usize, + quote: usize, + call: &TranslationCall, +) -> Option { + crate::parser::with_parsed_program(content, "translation_arguments", |program, _| { + let mut result = None; + ArgumentVisitor { paren, quote, call }.walk_program(program, &mut result); + result + }) +} + +fn placeholders(value: &str, out: &mut BTreeSet) { + for (offset, _) in value.match_indices(':') { + let rest = &value[offset + 1..]; + let length = rest + .find(|c: char| !c.is_alphanumeric() && c != '_') + .unwrap_or(rest.len()); + if length > 0 { + out.insert(rest[..length].to_lowercase()); + } + } +} + +impl Backend { + /// Complete a locale argument or the keys of a translation replacement array. + pub(crate) fn try_translation_argument_completion( + &self, + content: &str, + position: Position, + code: &CodeContext<'_>, + ctx: &FileContext, + ) -> Option { + let (quote, quote_char) = code.open_string?; + let paren = code.enclosing_paren()?; + let call = translation_call(content, paren, ctx)?; + let array = code.nested_pair(b'[', b'(').is_some(); + if array { + if !matches!(code.last_code_byte(), Some(b'[' | b',')) { + return None; + } + } else if code.open_brackets.last()?.offset != paren.offset + || !matches!(code.last_code_byte(), Some(b'(' | b',' | b':')) + { + return None; + } + let cursor = position_to_offset(content, position) as usize; + let args = arguments(content, paren.offset, quote, &call) + .filter(|args| args.parameter.is_some()) + .or_else(|| { + // Reuse a complete call even when surrounding syntax is broken. + // Otherwise close only the prefix the cursor has reached. + let close = + crate::text_scan::find_matching_forward(content, paren.offset, b'(', b')'); + let mut fragment = String::from(" Option> { + let backend = make_backend(); + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("lang/en")).unwrap(); + std::fs::create_dir_all(dir.path().join("resources/lang/fr")).unwrap(); + std::fs::write( + dir.path().join("lang/en/messages.php"), + " 'Hello :name :NAME :Name :count', 'dynamic' => env('KEY')];", + ) + .unwrap(); + std::fs::write( + dir.path().join("resources/lang/fr.json"), + r#"{"Welcome":"Bonjour :name et :ami"}"#, + ) + .unwrap(); + std::fs::write(dir.path().join("lang/de.json"), "{}").unwrap(); + *backend.workspace.workspace_root.write() = Some(dir.path().to_path_buf()); + let cursor = source.find('|').unwrap(); + let content = source.replacen('|', "", 1); + let code = code_context_at(&content, cursor)?; + let ctx = FileContext { + use_map: backend.parse_use_statements(&content), + classes: Vec::new(), + namespace: None, + namespace_spans: None, + resolved_names: None, + }; + match backend.try_translation_argument_completion( + &content, + offset_to_position(&content, cursor), + &code, + &ctx, + )? { + CompletionResponse::Array(items) => Some(items), + _ => panic!("expected array"), + } +} + +fn labels(source: &str) -> Vec { + complete(source) + .unwrap_or_else(|| panic!("no completion: {source}")) + .into_iter() + .map(|item| item.label) + .collect() +} + +#[test] +fn translation_argument_locales_positional_named_and_incomplete() { + for call in [ + "__('messages.hello', [], '|')", + "trans('messages.hello', [], '|')", + "trans_choice('messages.hello', 2, [], '|')", + "Lang::get('messages.hello', [], '|')", + "Lang::choice('messages.hello', 2, [], '|')", + "Lang::hasForLocale('messages.hello', '|')", + "Lang::has('messages.hello', '|')", + "__(locale: '|', key: 'messages.hello')", + "trans_choice(locale: '|', number: 2, key: 'messages.hello')", + "Lang::hasForLocale(locale: '|', key: 'messages.hello')", + "__('messages.hello', [], '|", + "__(locale: '|", + "\\__('x', locale: '|')", + "\\Illuminate\\Support\\Facades\\Lang::get('x', locale: '|')", + ] { + assert_eq!( + labels(&format!("::new() + ); +} + +#[test] +fn translation_argument_placeholders_follow_bound_replacement_array() { + for call in [ + "__('messages.hello', ['|'])", + "trans('messages.hello', ['|'])", + "trans_choice('messages.hello', 2, ['|'])", + "Lang::get('messages.hello', ['|'])", + "Lang::choice('messages.hello', 2, ['|'])", + "__(replace: ['|'], key: 'messages.hello')", + "__('messages.hello', [ /* don't ( */ '|'])", + "__('messages.hello', ['|", + ] { + assert_eq!( + labels(&format!(" ['x'], '|' => 1]);"), + ["count"] + ); + assert_eq!( + labels(" 1, 'count' => 2]);"), + ["name"] + ); + assert_eq!(labels(" 1]);").unwrap(); + assert_eq!(items.len(), 1); + let Some(CompletionTextEdit::Edit(edit)) = &items[0].text_edit else { + panic!("edit") + }; + assert_eq!(edit.new_text, "name"); + assert_eq!(edit.range.end.character - edit.range.start.character, 3); +} + +#[test] +fn translation_argument_completion_ignores_other_calls_and_array_values() { + for source in [ + "get(locale: '|');", + " '|']);", + " ['|']]);", + "'x' . '|']);", + ] { + assert!(complete(source).is_none(), "{source}"); + } +} diff --git a/src/completion/mod.rs b/src/completion/mod.rs index 4d97ec7e1..f545ea283 100644 --- a/src/completion/mod.rs +++ b/src/completion/mod.rs @@ -83,6 +83,7 @@ pub(crate) mod laravel_request_keys; pub(crate) mod laravel_route_controller; pub(crate) mod laravel_route_params; pub(crate) mod laravel_string_keys; +pub(crate) mod laravel_translation_args; pub mod named_args; pub(crate) mod resolve; pub(crate) mod target; diff --git a/tests/integration/laravel_translation_depth.rs b/tests/integration/laravel_translation_depth.rs index dfdd64300..a0d9043fd 100644 --- a/tests/integration/laravel_translation_depth.rs +++ b/tests/integration/laravel_translation_depth.rs @@ -132,3 +132,60 @@ async fn json_translation_navigation_reads_unsaved_escaped_keys() { r#"Say \"hello\""# ); } + +#[tokio::test] +async fn translation_argument_completion_uses_both_language_folders() { + let (backend, dir) = create_psr4_workspace( + COMPOSER, + &[ + ( + "lang/en/messages.php", + "'Hello :name :count'];", + ), + ("resources/lang/fr.json", r#"{"Welcome":"Bonjour :ami"}"#), + ("src/usage.php", "'Ada', '|' => 1]);", + vec!["count"], + ), + (" items, + CompletionResponse::List(list) => list.items, + }; + assert_eq!( + items + .iter() + .map(|item| item.label.as_str()) + .collect::>(), + expected, + "{source}" + ); + } +} From 8a1f4fe48127ff2b84344cd935f0092e8cdb81ef Mon Sep 17 00:00:00 2001 From: Shuvro Roy Date: Wed, 9 Sep 2026 21:13:34 +0600 Subject: [PATCH 4/6] feat(laravel): show translation values and links for every locale --- docs/CHANGELOG.md | 2 + examples/laravel/app/Demo.php | 3 +- examples/laravel/assertions.php | 11 +++- examples/laravel/resources/lang/fr.json | 4 ++ src/hover/laravel_trans.rs | 63 +++++++++++++++++++ src/hover/laravel_trans_tests.rs | 55 ++++++++++++++++ src/hover/mod.rs | 27 +------- src/symbol_map/extraction/laravel.rs | 6 ++ src/virtual_members/laravel/mod.rs | 2 +- src/virtual_members/laravel/trans_keys.rs | 22 +++---- .../laravel_string_key_call_sites.rs | 4 +- .../integration/laravel_translation_depth.rs | 51 +++++++++++++++ 12 files changed, 205 insertions(+), 45 deletions(-) create mode 100644 examples/laravel/resources/lang/fr.json create mode 100644 src/hover/laravel_trans.rs create mode 100644 src/hover/laravel_trans_tests.rs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 8d6c17244..d08a5a332 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Translation hover shows values across locales.** Each value links to its declaration, including JSON translations and translations under `resources/lang/`. Contributed by @shuvroroy. + - **Laravel translation arguments complete from the project's language files.** Locale arguments and replacement-array keys now suggest the available locales and translation placeholders, including named arguments and files under `lang/` and `resources/lang/`. Contributed by @shuvroroy. - **JSON translation keys support precise navigation.** Laravel translation keys now navigate to their exact location in JSON language files, and find-references works from their declarations as well as their call sites. Contributed by @shuvroroy. diff --git a/examples/laravel/app/Demo.php b/examples/laravel/app/Demo.php index c263f89b5..4f081309e 100644 --- a/examples/laravel/app/Demo.php +++ b/examples/laravel/app/Demo.php @@ -673,7 +673,8 @@ public function laravelNavigation(): void __('Fresh bread for :name', ['name' => 'Ada']); // Try: complete the locale argument or a replacement-array key. - __('Fresh bread for :name', replace: ['name' => 'Ada'], locale: 'en'); + // Hover shows the English and French values with links to both files. + __('Fresh bread for :name', replace: ['name' => 'Ada'], locale: 'fr'); __('messages.welcome'); trans('auth.failed'); trans_choice('messages.notifications', 5); diff --git a/examples/laravel/assertions.php b/examples/laravel/assertions.php index b29ab20d6..9eac3976c 100644 --- a/examples/laravel/assertions.php +++ b/examples/laravel/assertions.php @@ -1472,7 +1472,7 @@ public function toArray(): array $translationLoader = new \Illuminate\Translation\FileLoader( new \Illuminate\Filesystem\Filesystem(), - __DIR__ . '/lang' + [__DIR__ . '/lang', __DIR__ . '/resources/lang'] ); $translationDemo = new \Illuminate\Translation\Translator($translationLoader, 'en'); check( @@ -1480,6 +1480,15 @@ public function toArray(): array $translationDemo->get('Fresh bread for :name', ['name' => 'Ada']) === 'Fresh bread for Ada' ); +check( + 'Locale and named replacements resolve in resources/lang', + $translationDemo->get( + locale: 'fr', + replace: ['name' => 'Ada'], + key: 'Fresh bread for :name' + ) === 'Du pain frais pour Ada' +); + // ─── Summary ──────────────────────────────────────────────────────────────── echo "\n"; diff --git a/examples/laravel/resources/lang/fr.json b/examples/laravel/resources/lang/fr.json new file mode 100644 index 000000000..29584388c --- /dev/null +++ b/examples/laravel/resources/lang/fr.json @@ -0,0 +1,4 @@ +{ + "The bakery is open": "La boulangerie est ouverte", + "Fresh bread for :name": "Du pain frais pour :name" +} diff --git a/src/hover/laravel_trans.rs b/src/hover/laravel_trans.rs new file mode 100644 index 000000000..e8a15f028 --- /dev/null +++ b/src/hover/laravel_trans.rs @@ -0,0 +1,63 @@ +//! Translation hover across all statically known locales. + +use crate::Backend; + +impl Backend { + /// Describe each locale's translation and link to its exact declaration. + pub(super) fn translation_hover_detail(&self, key: &str) -> String { + let catalog = self.cached_translations(); + let mut parts = Vec::new(); + if let Some(entries) = catalog.entries.get(key) { + for entry in entries { + let file = &catalog.files[entry.file]; + parts.push(locale_detail( + &file.locale, + &file.uri, + entry.range.start.line, + entry.value.as_deref(), + )); + } + } else { + for file in catalog + .files + .iter() + .filter(|file| file.group.as_deref() == Some(key)) + { + parts.push(locale_detail(&file.locale, &file.uri, 0, None)); + } + } + if parts.is_empty() { + "Translation key".to_string() + } else { + parts.join("\n\n") + } + } +} + +fn locale_detail( + locale: &str, + uri: &tower_lsp::lsp_types::Url, + line: u32, + value: Option<&str>, +) -> String { + let path = uri.path(); + let short_path = path + .find("/resources/lang/") + .or_else(|| path.find("/lang/")) + .map_or(path, |offset| &path[offset + 1..]); + let mut detail = super::inline_code(locale); + if let Some(value) = value { + detail.push_str(&format!(": {}", super::inline_code(value))); + } + detail.push_str(&format!( + "\n\nDefined in [{}](<{}#L{}>)", + super::inline_code(short_path), + uri, + line + 1 + )); + detail +} + +#[cfg(test)] +#[path = "laravel_trans_tests.rs"] +mod tests; diff --git a/src/hover/laravel_trans_tests.rs b/src/hover/laravel_trans_tests.rs new file mode 100644 index 000000000..03e714858 --- /dev/null +++ b/src/hover/laravel_trans_tests.rs @@ -0,0 +1,55 @@ +use super::*; +use crate::test_fixtures::make_backend; +use tower_lsp::lsp_types::Url; + +#[test] +fn translation_hover_lists_locales_values_and_links_from_both_roots() { + let backend = make_backend(); + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join("lang/en")).unwrap(); + std::fs::create_dir_all(dir.path().join("resources/lang/fr")).unwrap(); + std::fs::write( + dir.path().join("lang/en/messages.php"), + "'Hello :name', 'group'=>['child'=>'text'], 'empty'=>''];", + ) + .unwrap(); + std::fs::write( + dir.path().join("resources/lang/fr/messages.php"), + "'Bonjour :name'];", + ) + .unwrap(); + *backend.workspace.workspace_root.write() = Some(dir.path().to_path_buf()); + let detail = backend.translation_hover_detail("messages.hello"); + assert!(detail.contains("`en`: `Hello :name`"), "{detail}"); + assert!(detail.contains("`fr`: `Bonjour :name`")); + assert!(detail.contains("[\u{60}lang/en/messages.php\u{60}]")); + assert!(detail.contains("[\u{60}resources/lang/fr/messages.php\u{60}]")); + assert!(detail.contains("messages.php#L2>")); + assert!(detail.contains("messages.php#L3>")); + assert!(detail.find("`en`").unwrap() < detail.find("`fr`").unwrap()); + let group = backend.translation_hover_detail("messages"); + assert!(group.contains("`en`")); + assert!(group.contains("`fr`")); + assert!( + backend + .translation_hover_detail("messages.group") + .contains("Defined in") + ); + assert_eq!( + backend.translation_hover_detail("missing"), + "Translation key" + ); + assert!( + backend + .translation_hover_detail("messages.empty") + .contains("`en`:") + ); +} + +#[test] +fn translation_hover_links_custom_paths_and_escapes_markdown_values() { + let uri = Url::parse("file:///vendor/package/translations/en.json").unwrap(); + let detail = locale_detail("en", &uri, 5, Some("Hello `name`")); + assert!(detail.contains("`` Hello `name` ``")); + assert!(detail.contains("[\u{60}/vendor/package/translations/en.json\u{60}]()")); +} diff --git a/src/hover/mod.rs b/src/hover/mod.rs index 27ef35da1..2578a0208 100644 --- a/src/hover/mod.rs +++ b/src/hover/mod.rs @@ -17,6 +17,7 @@ mod class; mod constants; mod formatting; +mod laravel_trans; mod member; mod see_refs; mod templates; @@ -621,31 +622,7 @@ impl Backend { }; ("View", detail) } - LaravelStringKind::Trans => { - let locations = crate::virtual_members::laravel::resolve_laravel_string_key( - self, kind, key, uri, - ); - 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()); - // The line as written: a `:placeholder` is left in - // place, since what it stands for is decided by the - // call site rather than by the translation. - match crate::virtual_members::laravel::trans_line(self, key, &loc.uri) { - Some(line) => { - format!("{}\n\nDefined in `{}`", inline_code(&line), short_path) - } - None => format!("Defined in `{}`", short_path), - } - } else { - "Translation key".to_string() - }; - ("Trans", detail) - } + LaravelStringKind::Trans => ("Trans", self.translation_hover_detail(key)), LaravelStringKind::Command => { let index = self.laravel_commands.read(); let detail = if let Some(entry) = index.get(key) { diff --git a/src/symbol_map/extraction/laravel.rs b/src/symbol_map/extraction/laravel.rs index 86a2ee50f..4d5c04123 100644 --- a/src/symbol_map/extraction/laravel.rs +++ b/src/symbol_map/extraction/laravel.rs @@ -116,6 +116,12 @@ pub(super) fn try_emit_laravel_string_span( content: &str, spans: &mut Vec, ) { + if kind == crate::symbol_map::LaravelStringKind::Trans { + if let Some(key) = argument_expr_for_parameter(argument_list, "key") { + push_laravel_string_span(kind, false, false, key, content, spans); + } + return; + } emit_laravel_string_span(kind, false, 0, argument_list, content, spans); } diff --git a/src/virtual_members/laravel/mod.rs b/src/virtual_members/laravel/mod.rs index 2512e89dc..9154a10ef 100644 --- a/src/virtual_members/laravel/mod.rs +++ b/src/virtual_members/laravel/mod.rs @@ -189,7 +189,7 @@ pub(crate) use storage::{ }; pub(crate) use trans_catalog::TranslationCatalog; pub(crate) use trans_json::find_json_trans_references; -pub(crate) use trans_keys::{trans_line, unresolved_trans_type}; +pub(crate) use trans_keys::unresolved_trans_type; pub(crate) use validation_rules::{safe_call_receiver_variable, safe_source_variable}; pub(crate) use view_data::{SharedViewVar, composer_class_vars}; pub(crate) use view_names::canonical_view_name; diff --git a/src/virtual_members/laravel/trans_keys.rs b/src/virtual_members/laravel/trans_keys.rs index f31ec2dc0..479e7c8e5 100644 --- a/src/virtual_members/laravel/trans_keys.rs +++ b/src/virtual_members/laravel/trans_keys.rs @@ -1,7 +1,7 @@ use mago_allocator::LocalArena; use mago_database::file::FileId; use mago_syntax::cst::*; -use tower_lsp::lsp_types::{Location, Url}; +use tower_lsp::lsp_types::Location; use crate::Backend; use crate::atom::bytes_to_str; @@ -58,18 +58,6 @@ pub(crate) fn resolve_trans_definitions(backend: &Backend, key: &str) -> Vec Option { - let catalog = backend.cached_translations(); - catalog - .entries - .get(key)? - .iter() - .find(|entry| catalog.files[entry.file].uri == *file_uri)? - .value - .clone() -} - // ─── Declaration extractor (mirrors config_keys logic) ─────────────────────── #[derive(Debug)] @@ -186,8 +174,12 @@ fn collect_array<'a>( start: key_start, end: key_end, is_group: value_is_group(kv.value), - value: super::helpers::extract_string_literal(kv.value, content) - .map(|(text, _, _)| text.to_string()), + value: match kv.value { + Expression::Literal(Literal::String(string)) => { + string.value.map(|value| bytes_to_str(value).to_string()) + } + _ => None, + }, }); collect_expr(kv.value, content, prefix, &full_path, out); diff --git a/tests/integration/laravel_string_key_call_sites.rs b/tests/integration/laravel_string_key_call_sites.rs index 0d79c23e7..40ff069ab 100644 --- a/tests/integration/laravel_string_key_call_sites.rs +++ b/tests/integration/laravel_string_key_call_sites.rs @@ -402,14 +402,14 @@ async fn translation_hover_shows_the_translated_line() { let leaf = hover_text(&backend, &uri, 4, 16).await; assert!( - leaf.contains("`Explore :name`") && leaf.contains("Defined in `lang/en/boards.php`"), + leaf.contains("`Explore :name`") && leaf.contains("Defined in [`lang/en/boards.php`]"), "got {leaf}" ); // A group has no single line, so the hover keeps naming only the file. let group = hover_text(&backend, &uri, 5, 16).await; assert!( - group.contains("Defined in `lang/en/boards.php`"), + group.contains("Defined in [`lang/en/boards.php`]"), "got {group}" ); } diff --git a/tests/integration/laravel_translation_depth.rs b/tests/integration/laravel_translation_depth.rs index a0d9043fd..feb27d26c 100644 --- a/tests/integration/laravel_translation_depth.rs +++ b/tests/integration/laravel_translation_depth.rs @@ -189,3 +189,54 @@ async fn translation_argument_completion_uses_both_language_folders() { ); } } + +#[tokio::test] +async fn translation_hover_and_references_bind_named_keys_and_show_all_locales() { + let source = "")); + assert!(markup.value.contains("/resources/lang/fr.json#L3>")); + assert_eq!( + references(&backend, &uri, position(source, "Welcome"), false) + .await + .len(), + 2 + ); + assert!( + references(&backend, &uri, position(source, "en'"), false) + .await + .is_empty() + ); +} From ca400ca4523242f09830a313b1275a9790903121 Mon Sep 17 00:00:00 2001 From: Shuvro Roy Date: Wed, 9 Sep 2026 21:19:16 +0600 Subject: [PATCH 5/6] feat(laravel): insert missing translation keys into existing groups --- docs/CHANGELOG.md | 2 + examples/laravel/app/Demo.php | 1 + src/code_actions/insert_translation_key.rs | 231 ++++++++++++++++++ .../insert_translation_key_tests.rs | 83 +++++++ src/code_actions/mod.rs | 2 + .../code_action_insert_translation_key.rs | 122 +++++++++ tests/integration/main.rs | 1 + 7 files changed, 442 insertions(+) create mode 100644 src/code_actions/insert_translation_key.rs create mode 100644 src/code_actions/insert_translation_key_tests.rs create mode 100644 tests/integration/code_action_insert_translation_key.rs diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index d08a5a332..f4a4ac6dd 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Missing translations can be added with a quick fix.** An unknown translation key offers an empty entry in each existing locale's PHP group file, preserving nested keys and neighbouring entries. Contributed by @shuvroroy. + - **Translation hover shows values across locales.** Each value links to its declaration, including JSON translations and translations under `resources/lang/`. Contributed by @shuvroroy. - **Laravel translation arguments complete from the project's language files.** Locale arguments and replacement-array keys now suggest the available locales and translation placeholders, including named arguments and files under `lang/` and `resources/lang/`. Contributed by @shuvroroy. diff --git a/examples/laravel/app/Demo.php b/examples/laravel/app/Demo.php index 4f081309e..85d211954 100644 --- a/examples/laravel/app/Demo.php +++ b/examples/laravel/app/Demo.php @@ -675,6 +675,7 @@ public function laravelNavigation(): void // Try: complete the locale argument or a replacement-array key. // Hover shows the English and French values with links to both files. __('Fresh bread for :name', replace: ['name' => 'Ada'], locale: 'fr'); + // Try: change this to 'messages.new_key' and apply the insertion quick fix. __('messages.welcome'); trans('auth.failed'); trans_choice('messages.notifications', 5); diff --git a/src/code_actions/insert_translation_key.rs b/src/code_actions/insert_translation_key.rs new file mode 100644 index 000000000..7b7f0fbe6 --- /dev/null +++ b/src/code_actions/insert_translation_key.rs @@ -0,0 +1,231 @@ +//! Add a missing translation to an existing PHP language group. + +use mago_span::HasSpan; +use mago_syntax::cst::*; +use tower_lsp::lsp_types::*; + +use crate::Backend; +use crate::atom::bytes_to_str; +use crate::symbol_map::{LaravelStringKind, SymbolKind}; +use crate::text_position::{offset_to_position, ranges_overlap}; + +impl Backend { + /// Offer one insertion per existing locale file for an unknown translation. + pub(crate) fn collect_insert_translation_key_actions( + &self, + uri: &str, + content: &str, + params: &CodeActionParams, + out: &mut Vec, + ) { + let diagnostics: Vec<_> = params.context.diagnostics.iter().filter(|diagnostic| { + matches!(&diagnostic.code, Some(NumberOrString::String(code)) if code == "invalid_laravel_trans") + && ranges_overlap(&diagnostic.range, ¶ms.range) + }).collect(); + if diagnostics.is_empty() { + return; + } + let Some(symbol_map) = self.symbol_maps.read().get(uri).cloned() else { + return; + }; + let catalog = self.cached_translations(); + for span in &symbol_map.spans { + let SymbolKind::LaravelStringKey { + kind: LaravelStringKind::Trans, + key, + is_write: false, + .. + } = &span.kind + else { + continue; + }; + let range = Range::new( + offset_to_position(content, span.start as usize), + offset_to_position(content, span.end as usize), + ); + let Some(diagnostic) = diagnostics + .iter() + .find(|diagnostic| ranges_overlap(&range, &diagnostic.range)) + else { + continue; + }; + if catalog.entries.contains_key(key) { + continue; + } + let Some((group, path)) = key.split_once('.') else { + continue; + }; + if path.split('.').any(str::is_empty) { + continue; + } + for file in catalog + .files + .iter() + .filter(|file| file.group.as_deref() == Some(group)) + { + let Some(source) = self.get_file_content(file.uri.as_str()) else { + continue; + }; + let Some(edits) = insertion_edits(&source, path) else { + continue; + }; + out.push(CodeActionOrCommand::CodeAction(CodeAction { + title: format!("Insert translation '{}' ({})", key, file.locale), + kind: Some(CodeActionKind::QUICKFIX), + diagnostics: Some(vec![(*diagnostic).clone()]), + edit: Some(super::helpers::single_file_edit(file.uri.clone(), edits)), + ..Default::default() + })); + } + } + } +} + +fn insertion_edits(content: &str, path: &str) -> Option> { + crate::parser::with_parsed_program(content, "insert_translation_key", |program, _| { + if !program.errors.is_empty() { + return None; + } + let returned = program + .statements + .iter() + .find_map(|statement| match statement { + Statement::Return(ret) => ret.value, + _ => None, + })?; + insert_into_array(content, returned, &path.split('.').collect::>()) + }) +} + +fn insert_into_array( + content: &str, + expression: &Expression<'_>, + path: &[&str], +) -> Option> { + let (elements, open, close) = match expression { + Expression::Array(array) => ( + &array.elements, + array.left_bracket.end.offset as usize, + array.right_bracket.start.offset as usize, + ), + Expression::LegacyArray(array) => ( + &array.elements, + array.left_parenthesis.end.offset as usize, + array.right_parenthesis.start.offset as usize, + ), + Expression::Parenthesized(parenthesized) => { + return insert_into_array(content, parenthesized.expression, path); + } + _ => return None, + }; + for element in elements.iter().rev() { + let ArrayElement::KeyValue(entry) = element else { + return None; + }; + let Expression::Literal(Literal::String(key)) = entry.key else { + return None; + }; + if key.value.map(bytes_to_str)? == path[0] { + return if path.len() > 1 { + insert_into_array(content, entry.value, &path[1..]) + } else { + None + }; + } + } + let newline = if content.contains("\r\n") { + "\r\n" + } else { + "\n" + }; + let multiline = content[open..close].contains('\n'); + let close_line = content[..close].rfind('\n').map_or(0, |offset| offset + 1); + let close_indent = &content[close_line..close]; + let own_line = close_indent + .bytes() + .all(|byte| byte == b' ' || byte == b'\t'); + let indent = elements + .first() + .map(|element| indentation(content, element.span().start.offset as usize)) + .filter(|indent| !indent.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| format!("{} ", indentation(content, close))); + let entry = nested_entry(path); + let mut edits = Vec::new(); + let last_end = elements + .last() + .map(|element| element.span().end.offset as usize); + if let Some(last_end) = last_end + && !elements.has_trailing_token() + { + edits.push(TextEdit { + range: Range::new( + offset_to_position(content, last_end), + offset_to_position(content, last_end), + ), + new_text: ",".to_string(), + }); + } + let (offset, text) = if multiline && own_line { + (close_line, format!("{indent}{entry},{newline}")) + } else if multiline { + ( + close, + format!( + "{newline}{indent}{entry},{newline}{}", + indentation(content, open) + ), + ) + } else { + ( + close, + format!( + "{}{entry}{}", + if last_end.is_some() { " " } else { "" }, + if elements.has_trailing_token() { + "," + } else { + "" + } + ), + ) + }; + edits.push(TextEdit { + range: Range::new( + offset_to_position(content, offset), + offset_to_position(content, offset), + ), + new_text: text, + }); + Some(edits) +} + +fn indentation(content: &str, offset: usize) -> &str { + let start = content[..offset].rfind('\n').map_or(0, |index| index + 1); + let line = &content[start..offset]; + &line[..line + .bytes() + .take_while(|byte| *byte == b' ' || *byte == b'\t') + .count()] +} + +fn nested_entry(path: &[&str]) -> String { + let mut entry = String::new(); + for (index, key) in path.iter().enumerate() { + if index > 0 { + entry.push('['); + } + entry.push('\''); + entry.push_str(&key.replace('\\', "\\\\").replace('\'', "\\'")); + entry.push_str("' => "); + } + entry.push_str("''"); + for _ in 1..path.len() { + entry.push(']'); + } + entry +} + +#[cfg(test)] +#[path = "insert_translation_key_tests.rs"] +mod tests; diff --git a/src/code_actions/insert_translation_key_tests.rs b/src/code_actions/insert_translation_key_tests.rs new file mode 100644 index 000000000..482e21b79 --- /dev/null +++ b/src/code_actions/insert_translation_key_tests.rs @@ -0,0 +1,83 @@ +use super::*; +use crate::text_position::position_to_offset; + +fn apply(content: &str, path: &str) -> String { + let mut edits = insertion_edits(content, path).unwrap_or_else(|| panic!("no edits: {content}")); + edits.sort_by_key(|edit| edit.range.start); + let mut result = content.to_string(); + for edit in edits.into_iter().rev() { + let start = position_to_offset(content, edit.range.start) as usize; + let end = position_to_offset(content, edit.range.end) as usize; + result.replace_range(start..end, &edit.new_text); + } + crate::parser::with_parsed_program(&result, "verify_translation_edit", |program, _| { + assert!(program.errors.is_empty(), "{result}: {:?}", program.errors); + }); + assert!( + insertion_edits(&result, path).is_none(), + "must not insert a duplicate" + ); + result +} + +#[test] +fn translation_insertion_preserves_siblings_comments_and_layout() { + for (source, expected) in [ + (" ''];"), + ( + " 'yes'];", + " 'yes', 'new' => ''];", + ), + ( + " 'yes',];", + " 'yes', 'new' => '',];", + ), + ( + " 'yes' // keep me\n];", + " 'yes', // keep me\n 'new' => '',\n];", + ), + ( + " 'yes',\r\n];", + " 'yes',\r\n\t'new' => '',\r\n];", + ), + (" '',\n];"), + (" '');"), + (" '']);"), + ] { + assert_eq!(apply(source, "new"), expected); + } + let multiline = apply(" 'yes'];", "new"); + assert!(multiline.contains("'old' => 'yes',\n 'new' => '',\n]")); +} + +#[test] +fn translation_insertion_adds_nested_keys_without_overwriting_existing_values() { + let result = apply( + " ['existing' => 'yes']];", + "checkout.address.label", + ); + assert_eq!( + result, + " ['existing' => 'yes', 'address' => ['label' => '']]];" + ); + assert_eq!( + apply(" ['back\\\\slash' => '']];" + ); + for source in [ + " 'scalar'];", + " []];", + " 'first', 'checkout' => 'last'];", + " 'value'];", + " 'value'];", + ] { + assert!(insertion_edits(source, "checkout").is_none(), "{source}"); + } + assert!(insertion_edits(" 'scalar'];", "checkout.title").is_none()); +} diff --git a/src/code_actions/mod.rs b/src/code_actions/mod.rs index 255975db2..9dfe4d4bb 100644 --- a/src/code_actions/mod.rs +++ b/src/code_actions/mod.rs @@ -109,6 +109,7 @@ mod generate_property_hooks; pub(crate) mod implement_methods; mod import_class; mod inline_variable; +mod insert_translation_key; mod mago; mod naming; pub(crate) mod phpstan; @@ -242,6 +243,7 @@ impl Backend { // ── Create missing view ───────────────────────────────────────── self.collect_create_missing_view_actions(uri, content, params, &mut actions); + self.collect_insert_translation_key_actions(uri, content, params, &mut actions); // Every collector plans its edits against the PHP a template lowers // to; the editor applies them to the template itself. diff --git a/tests/integration/code_action_insert_translation_key.rs b/tests/integration/code_action_insert_translation_key.rs new file mode 100644 index 000000000..87b0a25c1 --- /dev/null +++ b/tests/integration/code_action_insert_translation_key.rs @@ -0,0 +1,122 @@ +use crate::common::{create_psr4_workspace, lsp_pos_to_offset, open_php}; +use tower_lsp::lsp_types::*; + +const COMPOSER: &str = + r#"{"require":{"laravel/framework":"^13.0"},"autoload":{"psr-4":{"App\\":"src/"}}}"#; + +fn translation_diagnostics( + backend: &phpantom_lsp::Backend, + uri: &Url, + content: &str, +) -> Vec { + let mut diagnostics = Vec::new(); + backend.collect_slow_diagnostics(uri.as_str(), content, &mut diagnostics); + diagnostics.into_iter().filter(|diagnostic| matches!(&diagnostic.code, Some(NumberOrString::String(code)) if code == "invalid_laravel_trans")).collect() +} + +fn actions( + backend: &phpantom_lsp::Backend, + uri: &Url, + content: &str, + diagnostics: Vec, +) -> Vec { + backend + .handle_code_action( + uri.as_str(), + content, + &CodeActionParams { + text_document: TextDocumentIdentifier { uri: uri.clone() }, + range: Range::new(Position::new(0, 0), Position::new(100, 0)), + context: CodeActionContext { + diagnostics, + only: Some(vec![CodeActionKind::QUICKFIX]), + trigger_kind: None, + }, + work_done_progress_params: Default::default(), + partial_result_params: Default::default(), + }, + ) + .into_iter() + .filter_map(|action| match action { + CodeActionOrCommand::CodeAction(action) + if action.title.starts_with("Insert translation") => + { + Some(action) + } + _ => None, + }) + .collect() +} + +#[tokio::test] +async fn translation_insertion_quick_fix_edits_existing_groups_in_both_roots() { + let source = " [\n 'existing' => 'Keep me', // comment\n ],\n];\n"; + let (backend, dir) = create_psr4_workspace( + COMPOSER, + &[ + ("src/usage.php", source), + ("lang/en/messages.php", lang), + ("resources/lang/fr/messages.php", lang), + ], + ); + let uri = Url::from_file_path(dir.path().join("src/usage.php")).unwrap(); + open_php(&backend, &uri, source).await; + let diagnostics = translation_diagnostics(&backend, &uri, source); + assert_eq!(diagnostics.len(), 1); + let fixes = actions(&backend, &uri, source, diagnostics.clone()); + assert_eq!(fixes.len(), 2); + for fix in fixes { + assert_eq!(fix.kind, Some(CodeActionKind::QUICKFIX)); + assert_eq!(fix.diagnostics, Some(diagnostics.clone())); + let edit = fix.edit.unwrap(); + assert!(edit.document_changes.is_none(), "must not create files"); + let changes = edit.changes.unwrap(); + assert_eq!(changes.len(), 1); + let (file_uri, mut edits) = changes.into_iter().next().unwrap(); + assert!( + file_uri.path().ends_with("lang/en/messages.php") + || file_uri.path().ends_with("resources/lang/fr/messages.php") + ); + edits.sort_by_key(|edit| edit.range.start); + let mut updated = lang.to_string(); + for edit in edits.into_iter().rev() { + updated.replace_range( + lsp_pos_to_offset(lang, edit.range.start)..lsp_pos_to_offset(lang, edit.range.end), + &edit.new_text, + ); + } + assert!(updated.contains("'existing' => 'Keep me', // comment")); + assert!(updated.contains(" 'new' => '',\n")); + open_php(&backend, &file_uri, &updated).await; + assert!(translation_diagnostics(&backend, &uri, source).is_empty()); + assert!( + actions(&backend, &uri, source, diagnostics.clone()).is_empty(), + "stale diagnostic must not duplicate a key" + ); + } +} + +#[tokio::test] +async fn translation_insertion_quick_fix_requires_a_diagnostic_and_existing_safe_group() { + let source = "'existing'];", + ), + ], + ); + let uri = Url::from_file_path(dir.path().join("src/usage.php")).unwrap(); + open_php(&backend, &uri, source).await; + assert!(actions(&backend, &uri, source, vec![]).is_empty()); + let diagnostics = translation_diagnostics(&backend, &uri, source); + assert_eq!(diagnostics.len(), 5); + let fixes = actions(&backend, &uri, source, diagnostics); + assert_eq!(fixes.len(), 1); + assert!(fixes[0].title.contains("messages.new")); + assert!(!dir.path().join("resources/lang/en/absent.php").exists()); +} diff --git a/tests/integration/main.rs b/tests/integration/main.rs index b15175bd8..dc935a978 100644 --- a/tests/integration/main.rs +++ b/tests/integration/main.rs @@ -29,6 +29,7 @@ mod code_action_extract_interface; mod code_action_fix_return_type; mod code_action_generate_constructor; mod code_action_implement_methods; +mod code_action_insert_translation_key; mod code_action_promote_constructor_param; mod code_action_remove_unreachable; mod code_action_remove_unused_return_type; From ac9681ae77a1eafd838e10bca1889c877cf6358d Mon Sep 17 00:00:00 2001 From: Shuvro Roy Date: Wed, 9 Sep 2026 21:43:30 +0600 Subject: [PATCH 6/6] fix(laravel): preserve escaped translation keys and Blade references Decode PHP translation literals before matching JSON and PHP declarations, retain source ranges, and reject non-JSON whitespace. Read Blade reference positions in the symbol map coordinate space before translating them back to the template. Cover stale diagnostics, removed files, invalid paths, nested calls, and unknown replacement arrays. Complete the translation-depth documentation and backlog cleanup. Validation: 14,785 Rust tests passed; all 693 added executable production Rust lines covered. Clippy, formatting, doctests, PHP syntax checks, and demo runtime assertions passed. --- docs/CHANGELOG.md | 8 +--- docs/todo.md | 1 - docs/todo/laravel.md | 28 ------------ .../insert_translation_key_tests.rs | 36 +++++++++++++++ .../laravel_translation_args_tests.rs | 16 +++++++ src/references/mod.rs | 4 +- src/symbol_map/extraction/laravel.rs | 6 +++ src/virtual_members/laravel/string_keys.rs | 2 +- src/virtual_members/laravel/trans_catalog.rs | 36 ++++++++------- .../laravel/trans_catalog_tests.rs | 44 +++++++++++++++++++ src/virtual_members/laravel/trans_json.rs | 25 ++++++++--- .../laravel/trans_json_tests.rs | 4 ++ src/virtual_members/laravel/trans_keys.rs | 15 ++++--- .../integration/laravel_translation_depth.rs | 32 ++++++++++++++ 14 files changed, 190 insertions(+), 67 deletions(-) diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index f4a4ac6dd..532e78fb2 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -9,13 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **Missing translations can be added with a quick fix.** An unknown translation key offers an empty entry in each existing locale's PHP group file, preserving nested keys and neighbouring entries. Contributed by @shuvroroy. - -- **Translation hover shows values across locales.** Each value links to its declaration, including JSON translations and translations under `resources/lang/`. Contributed by @shuvroroy. - -- **Laravel translation arguments complete from the project's language files.** Locale arguments and replacement-array keys now suggest the available locales and translation placeholders, including named arguments and files under `lang/` and `resources/lang/`. Contributed by @shuvroroy. - -- **JSON translation keys support precise navigation.** Laravel translation keys now navigate to their exact location in JSON language files, and find-references works from their declarations as well as their call sites. Contributed by @shuvroroy. +- **Laravel translations are understood across locales.** JSON and PHP language files under `lang/` and `resources/lang/` share navigation, locale and replacement-key completion, and hover with links to each locale's value. Missing keys offer an insertion quick fix when their PHP group file already exists. Contributed by @shuvroroy. - **Formatting from the command line.** `phpantom_lsp format` formats every PHP file and Blade template in a project with the same formatter the editor runs on save, and `phpantom_lsp format --check` reports the files that are not formatted and exits non-zero without writing anything, so a CI job can require that a pull request ran the formatter. A run honours whatever the project already formats with, a Laravel Pint, php-cs-fixer, or PHP_CodeSniffer it depends on, and the built-in formatter otherwise, exactly as the editor resolves it, and opens with a line naming what it resolved so a CI log records which formatter enforced the result. Templates whose indentation is output rather than layout are left alone and never fail a check, and formatting turned off in `.phpantom.toml` is reported as such rather than passing as a project where every file happens to be formatted. Paths can be named to restrict the run, `--format github` annotates the pull request diff, and `--format json` is shaped like the object `analyze` and `fix` emit. - **Storage disk names are navigable wherever Laravel accepts one.** `Storage::disk()`, `fake()`, `persistentFake()`, `forgetDisk()`, and the `#[Storage]` container attribute now complete from `config/filesystems.php`; hover shows the config key, Ctrl+Click opens its declaration, and find-references links every use. Calls that require a configured disk report misspellings, while test fakes and disk eviction keep accepting the ad-hoc names Laravel permits at runtime. Contributed by @shuvroroy. - **Class and namespace moves from the command line.** `phpantom_lsp move FROM TO` moves one class or a whole namespace and updates declarations, imports, references, and PSR-4 paths across the project. Both sides can be fully-qualified names or Composer PSR-4 file/directory paths, and `--dry-run --format json` provides a validation-only form for scripts and coding agents. A destination that would overwrite an existing class or file is refused before any changes are made. A move into a namespace no PSR-4 mapping covers is called out rather than reported as a plain success, since the files cannot follow the declarations there and the autoloader stops finding them. A class installed by Composer is refused outright, the same way renaming one in the editor is. Contributed by @calebdw. diff --git a/docs/todo.md b/docs/todo.md index 7bf4d3012..3d105dc36 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -155,7 +155,6 @@ unlikely to move the needle for most users. | S4 | Named argument awareness in active parameter | Low-Medium | Medium | | S5 | Language construct signature help and hover | Low | Medium | | | **[Laravel](todo/laravel.md)** | | | -| L24 | [Translation depth: JSON lang files, locales, placeholders](todo/laravel.md#l24-translation-depth-json-lang-files-locales-placeholders) | Medium-High | Medium-High | | L46 | [`->can()` on a user model the receiver does not name](todo/laravel.md#l46-can-on-a-user-model-the-receiver-does-not-name) | Medium-High | Medium-High | | L30 | [Eloquent attribute-array key completion](todo/laravel.md#l30-eloquent-attribute-array-key-completion) | Medium | Medium | | L53 | [Collection key types from the column for `keyBy` / `groupBy` / `pluck`](todo/laravel.md#l53-collection-key-types-from-the-column-for-keyby-groupby-pluck) | Medium | Medium | diff --git a/docs/todo/laravel.md b/docs/todo/laravel.md index 1bb754d25..7c2e6b255 100644 --- a/docs/todo/laravel.md +++ b/docs/todo/laravel.md @@ -588,34 +588,6 @@ requires the live container. These genuinely cannot be resolved without booting, and a snapshot of them is the "true for one boot" half-truth we are choosing not to ship. -#### L24. Translation depth: JSON lang files, locales, placeholders - -**Impact: Medium-High · Complexity: Medium-High** - -Statically recoverable translation features the Laravel LSP has and we -still partially lack: - -- **JSON lang files.** `lang/{locale}.json` (the "translation string as - key" style) now completes and resolves go-to-definition, but the - definition always lands on the top of the file rather than the key's - actual line, and find-references does not cover JSON keys at all. -- **Locale argument completion.** The `$locale` parameter of `__()`, - `trans()`, `trans_choice()`, `Lang::get()/choice()/hasForLocale()` - (positional or named) completes from the locale set derived from - `lang/*/` directories and `lang/*.json` files. -- **Placeholder parameter completion.** The `:name` placeholders parsed - from the translation value complete as keys of the replacement array - (`__('welcome', ['name' => …])`). -- **Multi-locale hover.** Hover already shows a translation key's value - for the resolved locale; show the value per locale (with a link to - each file) instead of just the one. -- **Insert missing key quick-fix.** When the unknown-translation-key - diagnostic fires on a `group.item` key whose `lang/{locale}/group.php` - array file already exists, offer a quick-fix that inserts the missing - `'item' => '...'` entry (existing keys as siblings for placement, - empty string as the value). No fix when the group file itself doesn't - exist yet; that case still just diagnoses. - #### L27. Legacy `Controller@method` action strings **Impact: Low · Complexity: Low** diff --git a/src/code_actions/insert_translation_key_tests.rs b/src/code_actions/insert_translation_key_tests.rs index 482e21b79..5a01b3602 100644 --- a/src/code_actions/insert_translation_key_tests.rs +++ b/src/code_actions/insert_translation_key_tests.rs @@ -81,3 +81,39 @@ fn translation_insertion_adds_nested_keys_without_overwriting_existing_values() } assert!(insertion_edits(" 'scalar'];", "checkout.title").is_none()); } + +#[test] +fn translation_insertion_ignores_stale_documents_ranges_and_deleted_files() { + let backend = crate::test_fixtures::make_backend(); + let dir = tempfile::tempdir().unwrap(); + let uri = Url::from_file_path(dir.path().join("usage.php")).unwrap(); + let source = "'text'];").unwrap(); + *backend.workspace.workspace_root.write() = Some(dir.path().to_path_buf()); + assert!(!backend.cached_translations().files.is_empty()); + std::fs::remove_file(path).unwrap(); + backend.collect_insert_translation_key_actions(uri.as_str(), source, ¶ms, &mut out); + assert!(out.is_empty()); +} diff --git a/src/completion/laravel_translation_args_tests.rs b/src/completion/laravel_translation_args_tests.rs index 455157c65..e0b8959c7 100644 --- a/src/completion/laravel_translation_args_tests.rs +++ b/src/completion/laravel_translation_args_tests.rs @@ -144,3 +144,19 @@ fn translation_argument_completion_ignores_other_calls_and_array_values() { assert!(complete(source).is_none(), "{source}"); } } + +#[test] +fn translation_argument_completion_handles_nested_calls_and_unknown_replacements() { + assert_eq!( + labels(" foo()]);"), + ["count", "name"] + ); + assert_eq!( + labels(" Option> { + /// Read content in the coordinate space used by the file's symbol map. + /// Blade maps describe generated PHP; locations are translated for the client later. + pub(crate) fn reference_file_content_arc(&self, uri: &str) -> Option> { if self.is_blade_file(uri) && let Some(content) = self.blade_virtual_content.read().get(uri) { diff --git a/src/symbol_map/extraction/laravel.rs b/src/symbol_map/extraction/laravel.rs index 4d5c04123..35b9a52ff 100644 --- a/src/symbol_map/extraction/laravel.rs +++ b/src/symbol_map/extraction/laravel.rs @@ -487,6 +487,12 @@ fn push_laravel_string_span( return; }; + if kind == crate::symbol_map::LaravelStringKind::Trans + && let Expression::Literal(Literal::String(string)) = expr + { + key = string.value.map(bytes_to_str).unwrap_or(key); + } + if kind == crate::symbol_map::LaravelStringKind::Config && !key.contains('.') { // Require at least one dot: bare keys like 'app' are not valid config paths. return; diff --git a/src/virtual_members/laravel/string_keys.rs b/src/virtual_members/laravel/string_keys.rs index 974b31422..60279070e 100644 --- a/src/virtual_members/laravel/string_keys.rs +++ b/src/virtual_members/laravel/string_keys.rs @@ -306,7 +306,7 @@ fn find_string_key_usages( let Ok(parsed_uri) = Url::parse(file_uri) else { continue; }; - let Some(content) = backend.get_file_content_arc(file_uri) else { + let Some(content) = backend.reference_file_content_arc(file_uri) else { continue; }; for span in symbol_map.spans.iter().chain(extra.iter()) { diff --git a/src/virtual_members/laravel/trans_catalog.rs b/src/virtual_members/laravel/trans_catalog.rs index 53933d9f2..80d16b2ad 100644 --- a/src/virtual_members/laravel/trans_catalog.rs +++ b/src/virtual_members/laravel/trans_catalog.rs @@ -104,6 +104,25 @@ impl TranslationCatalog { } } } + + fn insert_locale(&mut self, backend: &Backend, path: &Path, namespace: &str) { + let Some(locale) = path.file_name().and_then(|name| name.to_str()) else { + return; + }; + if locale == "vendor" { + return; + } + self.locales.insert(locale.to_string()); + let Ok(files) = std::fs::read_dir(path) else { + return; + }; + for file in files.flatten() { + let path = file.path(); + if path.extension().is_some_and(|ext| ext == "php") { + self.insert_file(backend, &path, locale, namespace); + } + } + } } impl Backend { @@ -140,22 +159,7 @@ impl Backend { for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { - let Some(locale) = path.file_name().and_then(|name| name.to_str()) else { - continue; - }; - if locale == "vendor" { - continue; - } - catalog.locales.insert(locale.to_string()); - let Ok(files) = std::fs::read_dir(&path) else { - continue; - }; - for file in files.flatten() { - let path = file.path(); - if path.extension().is_some_and(|ext| ext == "php") { - catalog.insert_file(self, &path, locale, &root.namespace); - } - } + catalog.insert_locale(self, &path, &root.namespace); } else if root.namespace.is_empty() && path.extension().is_some_and(|ext| ext == "json") && let Some(locale) = path.file_stem().and_then(|name| name.to_str()) diff --git a/src/virtual_members/laravel/trans_catalog_tests.rs b/src/virtual_members/laravel/trans_catalog_tests.rs index 6389575f1..805570047 100644 --- a/src/virtual_members/laravel/trans_catalog_tests.rs +++ b/src/virtual_members/laravel/trans_catalog_tests.rs @@ -167,3 +167,47 @@ fn translation_catalog_handles_missing_and_unreadable_files() { backend.laravel_string_key_cache.write().translations = None; assert!(backend.cached_translations().entries.is_empty()); } + +#[test] +fn translation_catalog_decodes_php_keys_and_preserves_their_raw_ranges() { + let backend = make_backend(); + let dir = tempfile::tempdir().unwrap(); + *backend.workspace.workspace_root.write() = Some(dir.path().to_path_buf()); + std::fs::create_dir_all(dir.path().join("lang/en")).unwrap(); + std::fs::write( + dir.path().join("lang/en/messages.php"), + " 'It\\'s :name'];", + ) + .unwrap(); + let catalog = backend.cached_translations(); + let entry = &catalog.entries["messages.it's"][0]; + assert_eq!(entry.value.as_deref(), Some("It's :name")); + assert_eq!(entry.range.end.character - entry.range.start.character, 5); +} + +#[cfg(unix)] +#[test] +fn translation_catalog_handles_invalid_and_removed_paths() { + use std::os::unix::ffi::OsStringExt; + let backend = make_backend(); + let dir = tempfile::tempdir().unwrap(); + let mut catalog = TranslationCatalog::default(); + catalog.insert_file(&backend, Path::new("relative.php"), "en", ""); + assert!(catalog.files.is_empty()); + let invalid = dir.path().join(std::ffi::OsString::from_vec(vec![0xff])); + catalog.insert_locale(&backend, &invalid, ""); + assert!(catalog.locales.is_empty()); + // Editor URIs can describe paths the host filesystem cannot create. + let invalid_file = dir.path().join(std::ffi::OsString::from_vec(vec![ + 0xff, b'.', b'p', b'h', b'p', + ])); + let uri = Url::from_file_path(&invalid_file).unwrap(); + backend + .open_files + .write() + .insert(uri.to_string(), Arc::new(" Vec Option> { - let mut input = content.trim_start().strip_prefix('{')?.trim_start(); + let mut input = content + .trim_start_matches([' ', '\t', '\r', '\n']) + .strip_prefix('{')? + .trim_start_matches([' ', '\t', '\r', '\n']); let mut out = Vec::new(); if let Some(rest) = input.strip_prefix('}') { - return rest.trim().is_empty().then_some(out); + return rest + .trim_matches([' ', '\t', '\r', '\n']) + .is_empty() + .then_some(out); } loop { let start = content.len() - input.len() + 1; @@ -28,12 +34,12 @@ fn parse_declarations(content: &str) -> Option> { let consumed = keys.byte_offset(); let end = start + consumed - 2; input = input[consumed..] - .trim_start() + .trim_start_matches([' ', '\t', '\r', '\n']) .strip_prefix(':')? - .trim_start(); + .trim_start_matches([' ', '\t', '\r', '\n']); let mut values = serde_json::Deserializer::from_str(input).into_iter::(); let value = values.next()?.ok()?; - input = input[values.byte_offset()..].trim_start(); + input = input[values.byte_offset()..].trim_start_matches([' ', '\t', '\r', '\n']); out.push(TransKeyMatch { key, start, @@ -42,9 +48,14 @@ fn parse_declarations(content: &str) -> Option> { value: value.as_str().map(str::to_string), }); if let Some(rest) = input.strip_prefix('}') { - return rest.trim().is_empty().then_some(out); + return rest + .trim_matches([' ', '\t', '\r', '\n']) + .is_empty() + .then_some(out); } - input = input.strip_prefix(',')?.trim_start(); + input = input + .strip_prefix(',')? + .trim_start_matches([' ', '\t', '\r', '\n']); } } diff --git a/src/virtual_members/laravel/trans_json_tests.rs b/src/virtual_members/laravel/trans_json_tests.rs index 9507566da..65036eb78 100644 --- a/src/virtual_members/laravel/trans_json_tests.rs +++ b/src/virtual_members/laravel/trans_json_tests.rs @@ -60,6 +60,10 @@ fn json_translation_declarations_reject_invalid_documents() { "{\"a\":1,}", "{\"a\":1} trailing", "{} trailing", + "\u{a0}{}", + "{\u{a0}\"key\":1}", + "{\"key\":1}\u{a0}", + "{}\u{a0}", "{\"a\":1", ] { assert!( diff --git a/src/virtual_members/laravel/trans_keys.rs b/src/virtual_members/laravel/trans_keys.rs index 479e7c8e5..1265c81fa 100644 --- a/src/virtual_members/laravel/trans_keys.rs +++ b/src/virtual_members/laravel/trans_keys.rs @@ -166,6 +166,7 @@ fn collect_array<'a>( continue; }; + let key_text = literal_value(kv.key).unwrap_or(key_text); let mut full_path = path.to_vec(); full_path.push(key_text.to_string()); let dot_key = format!("{prefix}.{}", full_path.join(".")); @@ -174,18 +175,20 @@ fn collect_array<'a>( start: key_start, end: key_end, is_group: value_is_group(kv.value), - value: match kv.value { - Expression::Literal(Literal::String(string)) => { - string.value.map(|value| bytes_to_str(value).to_string()) - } - _ => None, - }, + value: literal_value(kv.value).map(str::to_string), }); collect_expr(kv.value, content, prefix, &full_path, out); } } +fn literal_value<'a>(expression: &'a Expression<'_>) -> Option<&'a str> { + match expression { + Expression::Literal(Literal::String(string)) => string.value.map(bytes_to_str), + _ => None, + } +} + /// Whether a translation entry's value expression is a nested array /// (a translation group) rather than a scalar string entry. Mirrors the /// shapes [`collect_expr`] recurses into, so a group is recognized exactly diff --git a/tests/integration/laravel_translation_depth.rs b/tests/integration/laravel_translation_depth.rs index feb27d26c..b1e21fa32 100644 --- a/tests/integration/laravel_translation_depth.rs +++ b/tests/integration/laravel_translation_depth.rs @@ -240,3 +240,35 @@ async fn translation_hover_and_references_bind_named_keys_and_show_all_locales() .is_empty() ); } + +#[tokio::test] +async fn json_translation_references_decode_php_and_blade_string_escapes() { + let php = "