diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index fb39f163b..532e78fb2 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 +- **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/examples/laravel/app/Demo.php b/examples/laravel/app/Demo.php index 59ed52562..85d211954 100644 --- a/examples/laravel/app/Demo.php +++ b/examples/laravel/app/Demo.php @@ -668,6 +668,14 @@ 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']); + + // 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/examples/laravel/assertions.php b/examples/laravel/assertions.php index 9e5178ff1..9eac3976c 100644 --- a/examples/laravel/assertions.php +++ b/examples/laravel/assertions.php @@ -1468,6 +1468,27 @@ public function toArray(): array \Illuminate\Container\Container::setInstance($previousContainer); +// ─── Translation resources ───────────────────────────────────────────────── + +$translationLoader = new \Illuminate\Translation\FileLoader( + new \Illuminate\Filesystem\Filesystem(), + [__DIR__ . '/lang', __DIR__ . '/resources/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' +); + +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/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/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/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/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..5a01b3602 --- /dev/null +++ b/src/code_actions/insert_translation_key_tests.rs @@ -0,0 +1,119 @@ +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()); +} + +#[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/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/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_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/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}"); + } +} + +#[test] +fn translation_argument_completion_handles_nested_calls_and_unknown_replacements() { + assert_eq!( + labels(" foo()]);"), + ["count", "name"] + ); + assert_eq!( + labels(" 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/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/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/references/mod.rs b/src/references/mod.rs index 7977c48b0..3d8d64435 100644 --- a/src/references/mod.rs +++ b/src/references/mod.rs @@ -133,7 +133,9 @@ impl Backend { self.get_file_content(uri) } - pub(super) fn reference_file_content_arc(&self, uri: &str) -> 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/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/symbol_map/extraction/laravel.rs b/src/symbol_map/extraction/laravel.rs index 86a2ee50f..35b9a52ff 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); } @@ -481,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/mod.rs b/src/virtual_members/laravel/mod.rs index 48a7eeef3..9154a10ef 100644 --- a/src/virtual_members/laravel/mod.rs +++ b/src/virtual_members/laravel/mod.rs @@ -133,6 +133,8 @@ mod route_names; mod scopes; mod storage; mod string_keys; +mod trans_catalog; +mod trans_json; mod trans_keys; pub(crate) mod validated_shape; pub(crate) mod validation_rules; @@ -185,7 +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_keys::{collect_trans_declarations, trans_line, unresolved_trans_type}; +pub(crate) use trans_catalog::TranslationCatalog; +pub(crate) use trans_json::find_json_trans_references; +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/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 new file mode 100644 index 000000000..80d16b2ad --- /dev/null +++ b/src/virtual_members/laravel/trans_catalog.rs @@ -0,0 +1,203 @@ +//! 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); + } + } + } + + 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 { + /// 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() { + 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()) + { + 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..805570047 --- /dev/null +++ b/src/virtual_members/laravel/trans_catalog_tests.rs @@ -0,0 +1,213 @@ +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()); +} + +#[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 { + parse_declarations(content).unwrap_or_default() +} + +fn parse_declarations(content: &str) -> Option> { + 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_matches([' ', '\t', '\r', '\n']) + .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_matches([' ', '\t', '\r', '\n']) + .strip_prefix(':')? + .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_matches([' ', '\t', '\r', '\n']); + 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_matches([' ', '\t', '\r', '\n']) + .is_empty() + .then_some(out); + } + input = input + .strip_prefix(',')? + .trim_start_matches([' ', '\t', '\r', '\n']); + } +} + +/// 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..65036eb78 --- /dev/null +++ b/src/virtual_members/laravel/trans_json_tests.rs @@ -0,0 +1,131 @@ +use super::*; +use crate::test_fixtures::make_backend; +use tower_lsp::lsp_types::Range; + +#[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", + "\u{a0}{}", + "{\u{a0}\"key\":1}", + "{\"key\":1}\u{a0}", + "{}\u{a0}", + "{\"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!(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 75c2568f2..1265c81fa 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; 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,132 +53,9 @@ 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 = Vec::new(); - - if let Some((namespace, rest)) = key.split_once("::") { - let file_stem = rest.split('.').next().unwrap_or(rest); - for res in &backend.laravel_provider_resources.read().trans_dirs { - if res.namespace != namespace { - continue; - } - let Ok(entries) = std::fs::read_dir(&res.path) else { - continue; - }; - for entry in entries.flatten() { - let locale_dir = entry.path(); - if !locale_dir.is_dir() { - continue; - } - let candidate = locale_dir.join(format!("{file_stem}.php")); - if !candidate.is_file() { - continue; - } - let Ok(content) = std::fs::read_to_string(&candidate) else { - continue; - }; - let Ok(uri) = Url::from_file_path(&candidate) else { - continue; - }; - let prefix = format!("{namespace}::{file_stem}"); - let declarations = collect_trans_declarations(&content, &prefix); - if let Some(decl) = declarations.into_iter().find(|d| d.key == key) { - let pos = crate::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))); - } - } - - if let Some(root) = backend.workspace.workspace_root.read().clone() { - 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) - && map.contains_key(key) - && let Ok(uri) = Url::from_file_path(&path) - { - results.push(crate::definition::point_location(uri, Position::new(0, 0))); - } - } - } - } - - results -} - -/// 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`. -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)? - .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(), - } + backend.translation_definitions(key) } // ─── Declaration extractor (mirrors config_keys logic) ─────────────────────── @@ -187,6 +64,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,27 +160,35 @@ 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; }; + 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(".")); 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()), + 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/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/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 new file mode 100644 index 000000000..b1e21fa32 --- /dev/null +++ b/tests/integration/laravel_translation_depth.rs @@ -0,0 +1,274 @@ +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 = "'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}" + ); + } +} + +#[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() + ); +} + +#[tokio::test] +async fn json_translation_references_decode_php_and_blade_string_escapes() { + let php = "