diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 27786adad..278eca7c7 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. - **Directories reached through a symlink are indexed with the rest of the project.** A project that keeps its framework or a shared library outside the repository and links it into the tree (`kdhelp -> ../kdhelp`, say) now resolves the symbols there like any other project code. Indexed paths keep the symlink spelling, so a file reached through the link is the same file the editor opened, and a tree is indexed once however many links lead to it. Changes another tool writes inside a linked directory are picked up as well on editors that can watch a path outside the project; where they cannot, such a change needs a window reload, while a file open in the editor always re-parses as it is edited. Contributed by @liudashuang. Closes #383. - **UUID and ULID model keys resolve as strings.** Models using Laravel's `HasUuids` or `HasUlids` traits now expose their primary keys as `string` in completion, hover, and type checking, including traits inherited from parent models or composed through other traits. Custom primary-key names are respected, and a model whose `uniqueIds()` generates a different column keeps its integer key. 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. diff --git a/docs/todo.md b/docs/todo.md index a18c69c9c..3502fef0c 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -162,7 +162,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 57570c63e..8e2e9d11d 100644 --- a/docs/todo/laravel.md +++ b/docs/todo/laravel.md @@ -563,41 +563,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. - `Translator::get()` also consults the JSON catalogue *first*, even for a - dotted key, but go-to-definition and hover list the group file ahead of - it, and a package's `loadJsonTranslationsFrom()` directory is known to - the diagnostic but never navigated to. The ignored tests - `laravel_translation_keys::a_json_phrase_lands_on_its_own_line`, - `a_json_line_wins_over_a_group_file_line_for_the_same_key`, and - `a_package_json_phrase_reaches_its_catalogue` cover all three. -- **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 68d2f1d31..2b94941f1 100644 --- a/examples/laravel/app/Demo.php +++ b/examples/laravel/app/Demo.php @@ -687,6 +687,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 60eb89568..f50105076 100644 --- a/examples/laravel/assertions.php +++ b/examples/laravel/assertions.php @@ -1478,6 +1478,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' +); + // ─── UUID and ULID primary keys ───────────────────────────────────────────── $uuidOrder = new \App\Models\BakeryOrder(); 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 c1d62f5e4..b8b62c327 100644 --- a/src/backend/file_access.rs +++ b/src/backend/file_access.rs @@ -373,6 +373,9 @@ impl Backend { /// Called from `did_close` to clean up state when a file the workspace /// index does not cover 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/backend/laravel/provider_resources.rs b/src/backend/laravel/provider_resources.rs index e3e453ed0..4bb3c0d25 100644 --- a/src/backend/laravel/provider_resources.rs +++ b/src/backend/laravel/provider_resources.rs @@ -130,6 +130,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 @@ -141,8 +142,6 @@ impl Backend { cache.config_keys = None; cache.config_trees = None; cache.view_names = None; - cache.trans_keys = None; - cache.trans_key_shapes = None; cache.routes = None; cache.blade_discovery = None; } 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 230b66949..b9dd7cb86 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; @@ -244,6 +245,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 d345c21d1..cc9dd0515 100644 --- a/src/completion/handler/mod.rs +++ b/src/completion/handler/mod.rs @@ -362,6 +362,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/enumerate.rs b/src/completion/laravel_string_keys/enumerate.rs index 6a634ea17..f65fe2985 100644 --- a/src/completion/laravel_string_keys/enumerate.rs +++ b/src/completion/laravel_string_keys/enumerate.rs @@ -5,8 +5,6 @@ //! memoized in the backend's string-key cache and rebuilt only when a file //! that feeds them changes. -use std::collections::HashMap; - use crate::Backend; impl Backend { @@ -23,103 +21,6 @@ impl Backend { 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. - /// - /// Covers PHP array files (`lang/en/messages.php` → `messages.key`), - /// JSON translation files (`lang/en.json` → raw key strings), and - /// package translation directories discovered from service providers - /// (`namespace::file.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. - /// - /// The project's lang files are discovered with a direct disk walk - /// rather than through `user_file_symbol_maps`, for the same reason - /// as [`for_each_config_source`](Self::for_each_config_source): - /// `__()` return types are resolved through the shared loaders, which - /// run inside the workspace index, so ensuring the index here would - /// re-enter its lock. Files open in the editor but not yet on disk are - /// taken from the already-parsed snapshot, without blocking. - fn enumerate_all_trans_key_shapes(&self) -> HashMap { - use crate::virtual_members::laravel::published_trans_dirs; - - let mut shapes = HashMap::new(); - let root = self.workspace.workspace_root.read().clone(); - if let Some(root) = &root { - self.collect_app_trans_key_shapes(root, &mut shapes); - collect_json_trans_key_shapes(root, &mut shapes); - } - - let resources = self.laravel_provider_resources.read(); - let mut published: Vec<&str> = Vec::new(); - for res in &resources.trans_dirs { - collect_namespaced_trans_key_shapes(&res.path, &res.namespace, &mut shapes); - // The empty namespace is `loadJsonTranslationsFrom()`, which has - // no published overrides. - if let Some(root) = &root - && !res.namespace.is_empty() - && !published.contains(&res.namespace.as_str()) - { - published.push(&res.namespace); - for dir in published_trans_dirs(root, &res.namespace) { - collect_namespaced_trans_key_shapes(&dir, &res.namespace, &mut shapes); - } - } - } - - shapes - } - - /// Record the keys of the application's own group files, the - /// `lang//.php` files under the project root, into `out`. - fn collect_app_trans_key_shapes( - &self, - root: &std::path::Path, - out: &mut HashMap, - ) { - use crate::virtual_members::laravel::app_lang_group; - - let root_uri = crate::util::path_to_uri(root); - let mut lang_uris: Vec = Vec::new(); - let vendor_dir_paths = self.workspace.vendor_dir_paths.lock().clone(); - let filters = self.index_filters(); - for path in crate::classmap_scanner::collect_php_files_gitignore( - root, - &vendor_dir_paths, - &filters, - Some(self.followed_links()), - ) { - let uri = crate::util::path_to_uri(&path); - if app_lang_group(&root_uri, &uri).is_some() { - lang_uris.push(uri); - } - } - for (uri, _) in self.user_file_symbol_maps_nonblocking() { - if app_lang_group(&root_uri, &uri).is_some() && !lang_uris.contains(&uri) { - lang_uris.push(uri); - } - } - - for file_uri in &lang_uris { - let Some(group) = app_lang_group(&root_uri, file_uri) else { - continue; - }; - let Some(content) = self.get_file_content_arc(file_uri) else { - continue; - }; - let decls = - crate::virtual_members::laravel::collect_trans_declarations(&content, group); - for d in decls { - mark_trans_shape(out, d.key, d.is_group); - } - } - } - /// Read one slot of [`LaravelStringKeyCache`], building it under /// `build_lock` when empty. /// @@ -197,118 +98,8 @@ impl Backend { ) } - /// Every translation key, sorted. + /// Every translation key, sorted and shared with the translation catalog. pub(crate) fn cached_trans_keys(&self) -> std::sync::Arc<[String]> { - self.cached_laravel_enumeration( - &self.laravel_string_key_build_locks.trans_keys, - |cache| cache.trans_keys.clone(), - |cache, keys| cache.trans_keys = Some(keys), - || { - let mut keys: Vec = - self.cached_trans_key_shapes().keys().cloned().collect(); - keys.sort(); - keys.into() - }, - ) - } - - /// 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()), - ) - } -} - -/// 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; -} - -/// Record the top-level keys of the workspace's `lang/*.json` files into -/// `out`. A JSON translation is a flat phrase-to-line map, so every key -/// is a scalar, never a group. -fn collect_json_trans_key_shapes(root: &std::path::Path, out: &mut HashMap) { - crate::virtual_members::laravel::for_each_json_lang_file(root, |_, map| { - for k in map.keys() { - mark_trans_shape(out, k.clone(), false); - } - }); -} - -/// Scan a package translation directory and record keys in -/// `namespace::file.key` format (PHP files) or `namespace::raw_key` -/// (JSON files with empty namespace). -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); - } - } - } -} - -/// Record the groups of one locale directory, where `subdir` is the path -/// below the locale reached so far: a group may name a subdirectory -/// (`admin/users`), as it may in the application's own `lang/`. -fn collect_namespaced_trans_shapes_from_locale_dir( - dir: &std::path::Path, - subdir: &str, - namespace: &str, - out: &mut HashMap, -) { - let Ok(entries) = std::fs::read_dir(dir) else { - return; - }; - for entry in entries.flatten() { - let path = entry.path(); - let Some(name) = path.file_name().and_then(|s| s.to_str()) else { - continue; - }; - // Not `path.is_dir()`, which follows a symlink back up the tree. - if entry.file_type().is_ok_and(|t| t.is_dir()) { - let nested = format!("{subdir}{name}/"); - collect_namespaced_trans_shapes_from_locale_dir(&path, &nested, namespace, out); - continue; - } - let Some(stem) = name.strip_suffix(".php") else { - continue; - }; - let Ok(content) = std::fs::read_to_string(&path) else { - continue; - }; - let prefix = if namespace.is_empty() { - format!("{subdir}{stem}") - } else { - format!("{namespace}::{subdir}{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); - } + std::sync::Arc::clone(&self.cached_translations().keys) } } 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 57a4057b6..0011a35b1 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; @@ -719,23 +720,7 @@ impl Backend { }; ("View", detail) } - LaravelStringKind::Trans => { - let detail = match self.resolved_key_location(kind, key, uri, "lang") { - // 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. - Some((location, short_path)) => { - match crate::virtual_members::laravel::trans_line(self, key, &location) { - Some(line) => { - format!("{}\n\nDefined in `{}`", inline_code(&line), short_path) - } - None => format!("Defined in `{short_path}`"), - } - } - None => "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 6c5aa0d10..686f2ec7b 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(); @@ -71,6 +72,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 changes.iter() { let path_str = change.uri.path(); if path_str.ends_with("/composer.json") || path_str.ends_with("/composer.lock") { @@ -130,6 +132,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; @@ -194,6 +209,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() @@ -201,6 +217,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); @@ -405,6 +425,7 @@ impl Backend { ]); if is_laravel { patterns.extend([ + ("**/*.json".to_string(), watch_all), ("**/*.sql".to_string(), watch_all), ("**/config/database.php".to_string(), watch_all), ]); @@ -1223,7 +1244,8 @@ mod tests { let params = DidChangeWatchedFilesParams { changes: vec![FileEvent { - uri: Url::from_file_path(real.join("src/Help.php")).unwrap(), + uri: Url::from_file_path(real.join("src/Help.php").canonicalize().unwrap()) + .unwrap(), typ: FileChangeType::CREATED, }], }; diff --git a/src/lib.rs b/src/lib.rs index 12fd04191..812d29d24 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -377,13 +377,8 @@ pub(crate) struct LaravelStringKeyCache { /// against them, which would otherwise re-read and re-parse the config /// file once per template. pub view_roots: Option>>, - /// Every translation key, sorted. - 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 @@ -435,8 +430,7 @@ pub(crate) struct LaravelStringKeyBuildLocks { pub config_keys: parking_lot::Mutex<()>, pub view_names: parking_lot::Mutex<()>, pub view_roots: 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<()>, @@ -497,9 +491,13 @@ impl LaravelStringKeyCache { { self.view_roots = 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 57b11cfb2..6e5141308 100644 --- a/src/references/dispatch.rs +++ b/src/references/dispatch.rs @@ -124,6 +124,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 and routes/*.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 fb5a6cb72..b5b8fb181 100644 --- a/src/references/mod.rs +++ b/src/references/mod.rs @@ -130,6 +130,8 @@ impl Backend { .map(|content| String::clone(&content)) } + /// 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_php_arc(uri) diff --git a/src/symbol_map/extraction/laravel.rs b/src/symbol_map/extraction/laravel.rs index 804f97a94..6551aee98 100644 --- a/src/symbol_map/extraction/laravel.rs +++ b/src/symbol_map/extraction/laravel.rs @@ -117,6 +117,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); } @@ -467,6 +473,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 917e9336d..1d025133e 100644 --- a/src/virtual_members/laravel/mod.rs +++ b/src/virtual_members/laravel/mod.rs @@ -137,6 +137,8 @@ mod route_names; mod scopes; mod storage; mod string_keys; +mod trans_catalog; +mod trans_json; mod trans_keys; mod unique_ids; pub(crate) mod validated_shape; @@ -191,10 +193,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::{ - app_lang_group, collect_trans_declarations, for_each_json_lang_file, published_trans_dirs, - 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, is_view_facade}; 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..8c1e9c904 --- /dev/null +++ b/src/virtual_members/laravel/trans_catalog.rs @@ -0,0 +1,296 @@ +//! 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::{app_lang_group, collect_trans_declarations, published_trans_dirs}; + +/// 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, + is_override: bool, +} + +/// 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 { + /// Sorted keys shared by completion and diagnostics without copying the catalog. + pub keys: Arc<[String]>, + /// 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, + group: Option<&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; + }; + let stem = group.unwrap_or(stem); + 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(); + let is_override = !namespace.is_empty() + && uri + .path() + .split_once("/lang/vendor/") + .is_some_and(|(_, path)| { + path.strip_prefix(namespace) + .is_some_and(|rest| rest.starts_with('/')) + }); + self.files.push(TranslationFile { + uri, + locale: locale.to_string(), + group, + is_override, + }); + 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()); + self.insert_locale_files(backend, path, locale, namespace, ""); + } + + fn insert_locale_files( + &mut self, + backend: &Backend, + path: &Path, + locale: &str, + namespace: &str, + subdir: &str, + ) { + let Ok(files) = std::fs::read_dir(path) else { + return; + }; + for file in files.flatten() { + let path = file.path(); + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + // Do not recurse through directory symlinks back into the locale tree. + if file.file_type().is_ok_and(|kind| kind.is_dir()) { + self.insert_locale_files( + backend, + &path, + locale, + namespace, + &format!("{subdir}{name}/"), + ); + } else if let Some(stem) = name.strip_suffix(".php") { + self.insert_file( + backend, + &path, + locale, + namespace, + Some(&format!("{subdir}{stem}")), + ); + } + } + } +} + +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(); + let workspace_root = self.workspace.workspace_root.read().clone(); + if let Some(root) = &workspace_root { + let overrides: Vec<_> = roots + .iter() + .filter(|resource| !resource.namespace.is_empty()) + .flat_map(|resource| { + published_trans_dirs(root, &resource.namespace).map(|path| ProviderResource { + path, + namespace: resource.namespace.clone(), + }) + }) + .collect(); + roots.extend(overrides); + 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, "", None); + } + } + } + // A new language file can be open before it exists on disk. The + // nonblocking snapshot is safe while the workspace index resolves types. + if let Some(root) = workspace_root { + let root_uri = crate::util::path_to_uri(&root); + for (uri, _) in self.user_file_symbol_maps_nonblocking() { + let Some(group) = app_lang_group(&root_uri, &uri) else { + continue; + }; + if catalog.files.iter().any(|file| file.uri.as_str() == uri) { + continue; + } + let Ok(parsed_uri) = Url::parse(&uri) else { + continue; + }; + let Ok(path) = parsed_uri.to_file_path() else { + continue; + }; + let Some((_, locale)) = + uri[..uri.len() - group.len() - ".php".len() - 1].rsplit_once('/') + else { + continue; + }; + catalog.locales.insert(locale.to_string()); + catalog.insert_file(self, &path, locale, "", Some(group)); + } + } + 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.group.is_some(), !a.is_override, &a.uri).cmp(&( + &b.locale, + b.group.is_some(), + !b.is_override, + &b.uri, + )) + }); + } + catalog.keys = catalog.entries.keys().cloned().collect(); + 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..5996db8b7 --- /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", "", None); + 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 a669f42d5..05b1d5a7b 100644 --- a/src/virtual_members/laravel/trans_keys.rs +++ b/src/virtual_members/laravel/trans_keys.rs @@ -1,6 +1,7 @@ use mago_allocator::LocalArena; use mago_database::file::FileId; -use tower_lsp::lsp_types::{Location, Position, Url}; +use mago_syntax::cst::{Expression, Literal}; +use tower_lsp::lsp_types::Location; use crate::Backend; use crate::php_type::PhpType; @@ -15,9 +16,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()), } } @@ -51,118 +52,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(); - let root = backend.workspace.workspace_root.read().clone(); - - if let Some((namespace, rest)) = key.split_once("::") { - let file_stem = rest.split('.').next().unwrap_or(rest); - let prefix = format!("{namespace}::{file_stem}"); - let resources = backend.laravel_provider_resources.read(); - if !resources - .trans_dirs - .iter() - .any(|res| res.namespace == namespace) - { - return results; - } - // A published override replaces the package's line, so it comes - // first and is the one hover quotes. It only has to declare the - // keys it changes, so a file that lacks the key is no definition. - if let Some(root) = &root { - for dir in published_trans_dirs(root, namespace) { - push_group_definitions(&dir, file_stem, &prefix, key, false, &mut results); - } - } - for res in &resources.trans_dirs { - if res.namespace == namespace { - push_group_definitions(&res.path, file_stem, &prefix, key, true, &mut results); - } - } - return results; - } - - let snapshot = backend.user_file_symbol_maps(); - - let file_stem = key.split('.').next().unwrap_or(key); - let root_uri = root.as_deref().map(crate::util::path_to_uri); - - for (file_uri, _) in &snapshot { - if root_uri - .as_deref() - .and_then(|root_uri| app_lang_group(root_uri, file_uri)) - != Some(file_stem) - { - continue; - } - 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) = root { - for_each_json_lang_file(&root, |path, map| { - if map.contains_key(key) - && let Ok(uri) = Url::from_file_path(path) - { - results.push(crate::definition::point_location(uri, Position::new(0, 0))); - } - }); - } - - results -} - -/// Push a location for `key` in each `/.php` under `dir`, -/// a directory of locale subdirectories. A file that exists but does not -/// declare the key is reached at its top when `fallback_to_top` is set. -fn push_group_definitions( - dir: &std::path::Path, - file_stem: &str, - prefix: &str, - key: &str, - fallback_to_top: bool, - results: &mut Vec, -) { - let Ok(entries) = std::fs::read_dir(dir) else { - return; - }; - 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 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)); - } else if fallback_to_top { - results.push(crate::definition::point_location(uri, Position::new(0, 0))); - } - } + backend.translation_definitions(key) } /// The application's translation directories, relative to the project root. @@ -204,44 +96,14 @@ pub(crate) fn published_trans_dirs( .map(move |dir| root.join(dir).join("vendor").join(namespace)) } -/// 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())?; - 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(), - } -} - // ─── Declaration extractor (mirrors config_keys logic) ─────────────────────── #[derive(Debug)] 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, @@ -255,48 +117,27 @@ pub(crate) fn collect_trans_declarations(content: &str, file_stem: &str) -> Vec< let program = mago_syntax::parser::parse_file_content(&arena, file_id, content.as_bytes()); let mut out = Vec::new(); for expr in super::array_file::returned_exprs(program) { - super::array_file::for_each_entry(expr, content, &mut |path, start, _end, value| { + super::array_file::for_each_entry(expr, content, &mut |path, start, end, value| { out.push(TransKeyMatch { key: super::array_file::dotted_key(file_stem, path), start, + end, // A group is recognized exactly when there is more beneath // it to flatten. is_group: super::array_file::is_array_expr(value), - value: super::helpers::extract_string_literal(value, content) - .map(|(text, _, _)| text.to_string()), + value: match value { + Expression::Literal(Literal::String(string)) => string + .value + .and_then(crate::atom::literal_bytes_to_str) + .map(str::to_string), + _ => None, + }, }); }); } out } -/// Call `visit` with each `lang/*.json` and `resources/lang/*.json` file -/// under `root` and its top-level map. -/// -/// Laravel's JSON translations are flat `{ "Some phrase": "Translated" }` -/// objects whose keys are used directly in `__('Some phrase')`. They are -/// not PHP, so they never appear in the symbol maps and are read from disk. -pub(crate) fn for_each_json_lang_file( - root: &std::path::Path, - mut visit: impl FnMut(&std::path::Path, &serde_json::Map), -) { - for sub in APP_LANG_DIRS { - let Ok(entries) = std::fs::read_dir(root.join(sub)) 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) - { - visit(&path, &map); - } - } - } -} - #[cfg(test)] #[path = "trans_keys_tests.rs"] mod tests; 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 4d124b85e..d96221065 100644 --- a/tests/integration/laravel_string_key_call_sites.rs +++ b/tests/integration/laravel_string_key_call_sites.rs @@ -371,14 +371,14 @@ async fn translation_hover_shows_the_translated_line() { let leaf = markup_hover_at(&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 = markup_hover_at(&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 = "