diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 3ac2a63eb..b01961c3e 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Laravel 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. + ### Changed #### Performance and memory @@ -20,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **Global Laravel translation paths are recognised.** Translations registered with `loadTranslationsFrom($path)` now support completion, navigation, hover, and key diagnostics, including when the namespace is explicitly `null`. Analysis also finds translation files when `--project-root` is a relative path. Contributed by @shuvroroy. - **Alpine and Vue `:attr` bindings in Blade.** A `:name="…"` attribute on a plain HTML tag is no longer parsed as PHP, so it stops producing syntax errors. Only `` component tags evaluate bound attributes. - **laravel-ide-helper files are skipped.** `_ide_helper.php` and `_ide_helper_models.php` are no longer indexed, since PHPantom resolves facades and models natively and their stand-in classes only competed with the real ones. List them with a leading `!` in `[indexing] exclude` to index them anyway. - **PHP_CodeSniffer only runs on projects that use it.** A `vendor/bin/phpcs` installed by some other package no longer produces coding-standard warnings. PHPCS diagnostics now need `squizlabs/php_codesniffer` in `require-dev`, a PHPCS ruleset file, or a `[phpcs] standard` in `.phpantom.toml`. diff --git a/docs/todo.md b/docs/todo.md index bf47ba91f..6a8f21204 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -37,7 +37,6 @@ contributor even though it's short. | | Clear [refactoring gate](todo/refactor.md) | — | — | | C14 | [`examples/php` has no demo for an assignment inside a `match` arm or ternary branch](todo/completion.md#c14-examplesphp-has-no-demo-for-an-assignment-inside-a-match-arm-or-ternary-branch) | Low | Low | | A47 | [Member actions are missing when the range starts in the indentation](todo/actions.md#a47-member-actions-are-missing-when-the-range-starts-in-the-indentation) | Medium | Low-Medium | -| L55 | [JSON translation key navigation lands on line 0, not the key](todo/laravel.md#l55-json-translation-key-navigation-lands-on-line-0-not-the-key) | Medium-High | Medium-High | | P64 | [A file with one very large scope copies it at every branch](todo/performance.md#p64-a-file-with-one-very-large-scope-copies-it-at-every-branch) | Medium | Medium-High | | BL1 | [Blade-aware code actions](todo/blade.md#bl1-blade-aware-code-actions) | Medium | Medium-High | | P68 | [Body return-type inference re-parses the declaring file every time](todo/performance.md#p68-body-return-type-inference-re-parses-the-declaring-file-every-time) | Medium | Medium-High | @@ -167,7 +166,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: locales, placeholders, hover, quick-fix](todo/laravel.md#l24-translation-depth-locales-placeholders-hover-quick-fix) | Medium | Medium | | 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 | | L56 | [Typed Laravel connection names](todo/laravel.md#l56-typed-laravel-connection-names) | Medium | Medium | diff --git a/docs/todo/laravel.md b/docs/todo/laravel.md index 1eedfe832..f5f4f97f6 100644 --- a/docs/todo/laravel.md +++ b/docs/todo/laravel.md @@ -563,51 +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. -#### L55. JSON translation key navigation lands on line 0, not the key - -**Impact: Medium-High · Complexity: Medium-High** - -`lang/{locale}.json` (the "translation string as key" style) completes -and resolves go-to-definition, but three related navigation gaps remain: - -- 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()` consults the JSON catalogue *first*, even for a - dotted key, but go-to-definition and hover list the group file ahead - of it. -- 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; un-ignore -them once this lands. - -#### L24. Translation depth: locales, placeholders, hover, quick-fix - -**Impact: Medium · Complexity: Medium** - -Statically recoverable translation features the Laravel LSP has and we -still partially lack: - -- **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 7c886a2ca..bdab4d940 100644 --- a/examples/laravel/app/Demo.php +++ b/examples/laravel/app/Demo.php @@ -761,6 +761,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); @@ -768,6 +776,11 @@ public function laravelNavigation(): void Lang::has('validation.required'); Lang::hasForLocale('validation.required', 'en'); + // DemoServiceProvider registers resources/bakery-lang with the + // one-argument loadTranslationsFrom() form. Try completing this key + // or Ctrl+Click to reach its declaration there. + __('bakery.greeting', ['name' => 'Ada']); + // The framework declares string|array|null for all three helpers, // because a key may name a whole group and the keyless form hands // its own null back. The key at the call site settles which of diff --git a/examples/laravel/app/Providers/DemoServiceProvider.php b/examples/laravel/app/Providers/DemoServiceProvider.php index 2b5a92b02..7f8ed78da 100644 --- a/examples/laravel/app/Providers/DemoServiceProvider.php +++ b/examples/laravel/app/Providers/DemoServiceProvider.php @@ -72,6 +72,10 @@ public function register(): void public function boot(): void { + // Omitting the namespace adds ordinary PHP and JSON translation keys + // from this directory, just like those under lang/. + $this->loadTranslationsFrom(resource_path('bakery-lang')); + // The morph map replaces the model FQCN with a short alias in every // polymorphic `*_type` column. PHPantom reads the registration from // here, so the alias strings elsewhere in the project hover with the diff --git a/examples/laravel/assertions.php b/examples/laravel/assertions.php index b97efa23d..2aff9d52f 100644 --- a/examples/laravel/assertions.php +++ b/examples/laravel/assertions.php @@ -1620,6 +1620,38 @@ 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'); +$translationContainer = new \Illuminate\Container\Container(); +$translationContainer->instance('translator', $translationDemo); +$translationProvider = new \App\Providers\DemoServiceProvider($translationContainer); +$loadTranslations = new ReflectionMethod($translationProvider, 'loadTranslationsFrom'); +$loadTranslations->invoke($translationProvider, __DIR__ . '/resources/bakery-lang'); + +check( + 'loadTranslationsFrom() without a namespace registers PHP translation keys', + $translationDemo->get('bakery.greeting', ['name' => 'Ada']) === 'Welcome to the bakery, Ada!' +); + +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/bakery-lang/en/bakery.php b/examples/laravel/resources/bakery-lang/en/bakery.php new file mode 100644 index 000000000..027aefbd2 --- /dev/null +++ b/examples/laravel/resources/bakery-lang/en/bakery.php @@ -0,0 +1,5 @@ + 'Welcome to the bakery, :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 343426d88..3b96223cd 100644 --- a/src/backend/file_access.rs +++ b/src/backend/file_access.rs @@ -444,6 +444,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 1afb0eb1b..19d8c99de 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 b6c76b605..6798ecfd2 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 e6a5dc9d3..d731ba28a 100644 --- a/src/hover/mod.rs +++ b/src/hover/mod.rs @@ -17,6 +17,7 @@ mod class; pub(crate) mod constants; mod formatting; +mod laravel_trans; mod member; mod see_refs; mod templates; @@ -733,23 +734,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 bec6e783f..874bb0ef1 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), ]); diff --git a/src/lib.rs b/src/lib.rs index 2745fc02a..22f772a07 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -379,13 +379,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 @@ -437,8 +432,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<()>, @@ -499,9 +493,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 0186f6929..defaf475b 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 98b8fbe64..76c6a649f 100644 --- a/src/references/mod.rs +++ b/src/references/mod.rs @@ -131,6 +131,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 ffbe4459d..986ab707c 100644 --- a/src/symbol_map/extraction/laravel.rs +++ b/src/symbol_map/extraction/laravel.rs @@ -114,6 +114,12 @@ pub(super) fn try_emit_laravel_string_span( content: &str, spans: &mut Vec, ) { + if kind == crate::symbol_map::LaravelStringKind::Trans { + if let Some(key) = argument_expr_for_parameter(argument_list, "key") { + push_laravel_string_span(kind, false, false, key, content, spans); + } + return; + } emit_laravel_string_span(kind, false, 0, argument_list, content, spans); } diff --git a/src/virtual_members/laravel/mod.rs b/src/virtual_members/laravel/mod.rs index 131864afd..c4292426a 100644 --- a/src/virtual_members/laravel/mod.rs +++ b/src/virtual_members/laravel/mod.rs @@ -138,6 +138,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; @@ -193,10 +195,9 @@ pub(crate) use storage::{ FILESYSTEM_MANAGER_FQN, LaravelStorageDriverIndex, StorageDriverRegistration, extract_storage_driver_registrations, patch_storage_disk_type, }; -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/provider_resources.rs b/src/virtual_members/laravel/provider_resources.rs index 30fec13a4..76ca6fcd8 100644 --- a/src/virtual_members/laravel/provider_resources.rs +++ b/src/virtual_members/laravel/provider_resources.rs @@ -684,25 +684,33 @@ pub(crate) fn extract_provider_resources( let args: Vec<_> = mc.argument_list.arguments.iter().collect(); - // The namespaced `load*From(path, namespace)` registrations differ - // only in which list they land in. `loadViewsFrom` is handled - // separately below: unlike the other two, it has a published-package - // override to check for first. - let namespaced: Option<&mut Vec> = match method_lower.as_slice() { + // Config registrations require a key, while translations can omit + // their namespace to add a global path. Views also check for a + // published-package override, so they are handled separately below. + let registrations: Option<&mut Vec> = match method_lower.as_slice() { b"mergeconfigfrom" => Some(&mut resources.config_files), b"loadtranslationsfrom" => Some(&mut resources.trans_dirs), _ => None, }; - if let Some(target) = namespaced { - if args.len() >= 2 + if let Some(target) = registrations { + let namespace = match args.get(1).map(|arg| arg.value()) { + None | Some(Expression::Literal(Literal::Null(_))) + if method_lower == b"loadtranslationsfrom" => + { + Some("") + } + Some(arg) => super::helpers::extract_string_literal(arg, content) + .map(|(namespace, _, _)| namespace), + None => None, + }; + if !args.is_empty() + && let Some(namespace) = namespace && let Some(path) = resolve_path_arg(args[0].value(), content, file_dir, workspace_root, program) - && let Some((ns, _, _)) = - super::helpers::extract_string_literal(args[1].value(), content) { target.push(ProviderResource { path, - namespace: ns.to_string(), + namespace: namespace.to_string(), }); } } else if method_lower == b"loadviewsfrom" { @@ -1792,6 +1800,33 @@ mod tests { assert_eq!(resources.config_files[0].namespace, "livewire"); } + #[test] + fn config_registrations_require_a_key() { + let content = "mergeConfigFrom();\n\ + $this->mergeConfigFrom(base_path('config/incomplete.php'));\n\ + $this->mergeConfigFrom(base_path('config/null.php'), null);\n\ + $this->mergeConfigFrom(base_path('config/bakery.php'), 'bakery');\n\ + }\n\ + }\n"; + let resources = extract_provider_resources( + content, + Path::new("/ws/app/Providers/AppServiceProvider.php"), + Path::new("/ws"), + ClassContext::default(), + Default::default(), + ); + assert_eq!( + resources.config_files, + vec![ProviderResource { + path: PathBuf::from("/ws/config/bakery.php"), + namespace: "bakery".to_string(), + }] + ); + } + #[test] fn detects_a_database_backed_translation_loader() { // An application that keeps its strings in a database still builds a diff --git a/src/virtual_members/laravel/trans_catalog.rs b/src/virtual_members/laravel/trans_catalog.rs new file mode 100644 index 000000000..3c41bfb3c --- /dev/null +++ b/src/virtual_members/laravel/trans_catalog.rs @@ -0,0 +1,323 @@ +//! 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_locale_and_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_entry( + &mut self, + backend: &Backend, + path: &Path, + locale: &str, + namespace: &str, + subdir: &str, + is_directory: bool, + ) { + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + return; + }; + if is_directory { + 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}")), + ); + } + } + + 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() { + // Do not recurse through directory symlinks back into the locale tree. + self.insert_locale_entry( + backend, + &file.path(), + locale, + namespace, + subdir, + file.file_type().is_ok_and(|kind| kind.is_dir()), + ); + } + } + + fn insert_open_group(&mut self, backend: &Backend, uri: &str, locale: &str, group: &str) { + if self.files.iter().any(|file| file.uri.as_str() == uri) { + return; + } + let Ok(parsed_uri) = Url::parse(uri) else { + return; + }; + let Ok(path) = parsed_uri.to_file_path() else { + return; + }; + self.locales.insert(locale.to_string()); + self.insert_file(backend, &path, locale, "", Some(group)); + } +} + +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(), + }), + ); + } + // CLI roots may be relative; normalize before deduplication and URI creation. + for root in &mut roots { + if root.path.is_relative() + && let Ok(path) = std::path::absolute(&root.path) + { + root.path = path; + } + } + 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((locale, group)) = app_lang_locale_and_group(&root_uri, &uri) else { + continue; + }; + catalog.insert_open_group(self, &uri, locale, 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..5e90f916a --- /dev/null +++ b/src/virtual_members/laravel/trans_catalog_tests.rs @@ -0,0 +1,308 @@ +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_resolves_relative_workspace_roots_and_absolute_providers() { + let backend = make_backend(); + let cwd = std::env::current_dir().unwrap(); + let dir = tempfile::tempdir_in(&cwd).unwrap(); + *backend.workspace.workspace_root.write() = + Some(dir.path().strip_prefix(&cwd).unwrap().to_path_buf()); + for directory in ["lang/en", "package/en"] { + std::fs::create_dir_all(dir.path().join(directory)).unwrap(); + } + for (path, content) in [ + ("lang/en/messages.php", " 'Hello'];"), + ("lang/en.json", r#"{"Welcome":"Welcome home"}"#), + ( + "package/en/provider.php", + " 'Registered'];", + ), + ] { + std::fs::write(dir.path().join(path), content).unwrap(); + } + backend + .laravel_provider_resources + .write() + .trans_dirs + .extend(["package", "lang"].map(|path| ProviderResource { + path: dir.path().join(path), + namespace: String::new(), + })); + + let catalog = backend.cached_translations(); + for (key, path, value) in [ + ("messages.hello", "lang/en/messages.php", "Hello"), + ("Welcome", "lang/en.json", "Welcome home"), + ( + "provider.registered", + "package/en/provider.php", + "Registered", + ), + ] { + let uri = Url::from_file_path(dir.path().join(path)).unwrap(); + let found = backend.translation_definitions(key); + assert_eq!(found.len(), 1, "{key}: got {found:?}"); + assert_eq!(found[0].uri, uri, "{key}"); + assert_eq!(catalog.entries[key][0].value.as_deref(), Some(value)); + assert!(catalog.contains_uri(uri.as_str()), "{key}: {uri}"); + } +} + +#[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(" 'no'];", + ) + .unwrap(); + std::fs::write( + locale.join("messages.php"), + " 'Saved'];", + ) + .unwrap(); + + let mut catalog = TranslationCatalog::default(); + catalog.insert_locale(&backend, &locale, ""); + + assert_eq!(catalog.files.len(), 1); + assert_eq!(catalog.entries.len(), 1); + assert_eq!( + catalog.entries["messages.saved"][0].value.as_deref(), + Some("Saved") + ); +} diff --git a/src/virtual_members/laravel/trans_json.rs b/src/virtual_members/laravel/trans_json.rs new file mode 100644 index 000000000..9b65b4f46 --- /dev/null +++ b/src/virtual_members/laravel/trans_json.rs @@ -0,0 +1,109 @@ +//! JSON translation declarations with their source positions. + +use tower_lsp::lsp_types::{Location, Position, Url}; + +use crate::Backend; +use crate::symbol_map::LaravelStringKind; +use crate::text_position::position_to_offset; + +use super::trans_keys::TransKeyMatch; + +/// Parse a flat JSON translation object while retaining the byte range of +/// each key's source spelling, including escapes. Invalid documents yield +/// no declarations; values are decoded by serde rather than a text scanner. +pub(super) fn collect_json_trans_declarations(content: &str) -> Vec { + parse_declarations(content).unwrap_or_default() +} + +fn parse_declarations(content: &str) -> Option> { + let mut input = content + .trim_start_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, + 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..e36ed02f2 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. @@ -170,15 +62,18 @@ fn push_group_definitions( /// exists; both are read so a project part-way through the move resolves. const APP_LANG_DIRS: [&str; 2] = ["lang", "resources/lang"]; -/// The group an application translation file holds, or `None` when `uri` is -/// not one. +/// The locale and group an application translation file holds, or `None` +/// when `uri` is not one. /// /// `FileLoader` reads `//.php`, and a group may name a /// subdirectory: `lang/en/admin/users.php` is the `admin/users` group. /// `/vendor/` holds published package overrides, which are only read /// for their namespace, and a `lang/` directory anywhere else in the project /// (a package's own) is not the application's. -pub(crate) fn app_lang_group<'a>(root_uri: &str, uri: &'a str) -> Option<&'a str> { +pub(crate) fn app_lang_locale_and_group<'a>( + root_uri: &str, + uri: &'a str, +) -> Option<(&'a str, &'a str)> { let rel = uri .strip_prefix(root_uri.trim_end_matches('/'))? .strip_prefix('/')?; @@ -189,7 +84,9 @@ pub(crate) fn app_lang_group<'a>(root_uri: &str, uri: &'a str) -> Option<&'a str if locale == "vendor" { return None; } - file.strip_suffix(".php").filter(|group| !group.is_empty()) + file.strip_suffix(".php") + .filter(|group| !group.is_empty()) + .map(|group| (locale, group)) } /// The directories an application publishes a package's translations into, @@ -204,44 +101,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 +122,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/src/virtual_members/laravel/trans_keys_tests.rs b/src/virtual_members/laravel/trans_keys_tests.rs index d94e9ff3d..f2c64c0fd 100644 --- a/src/virtual_members/laravel/trans_keys_tests.rs +++ b/src/virtual_members/laravel/trans_keys_tests.rs @@ -39,23 +39,26 @@ fn translation_types_resolve_while_the_workspace_index_is_held() { } #[test] -fn only_the_applications_own_group_files_are_groups() { - use super::app_lang_group; +fn application_translation_files_report_their_locale_and_group() { + use super::app_lang_locale_and_group; let root = "file:///app"; for (uri, group) in [ - ("file:///app/lang/en/messages.php", Some("messages")), + ("file:///app/lang/en/messages.php", Some(("en", "messages"))), ( "file:///app/resources/lang/en/validation.php", - Some("validation"), + Some(("en", "validation")), + ), + ( + "file:///app/lang/en/admin/users.php", + Some(("en", "admin/users")), ), - ("file:///app/lang/en/admin/users.php", Some("admin/users")), ("file:///app/lang/vendor/billing/en/invoice.php", None), ("file:///app/packages/billing/lang/en/invoice.php", None), ("file:///app/lang/en.php", None), ("file:///app/language/en/messages.php", None), ("file:///other/lang/en/messages.php", None), ] { - assert_eq!(app_lang_group(root, uri), group, "{uri}"); + assert_eq!(app_lang_locale_and_group(root, uri), group, "{uri}"); } } 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..06f207cfe --- /dev/null +++ b/tests/integration/laravel_translation_depth.rs @@ -0,0 +1,336 @@ +use crate::common::{ + create_psr4_workspace, definition_locations, goto_definition_at, lsp_pos_to_offset, + markup_hover_at, 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, + ) +} + +#[tokio::test] +async fn new_php_translation_buffers_resolve_before_their_first_save() { + for (path, group, locale) in [ + ("lang/en/messages.php", "messages", "en"), + ("resources/lang/fr/admin/users.php", "admin/users", "fr"), + ] { + let source = format!(" 'Existing'];"; + let translation = " 'Saved from buffer',\n];\n"; + let (backend, dir) = create_psr4_workspace( + COMPOSER, + &[ + ("src/usage.php", &source), + ("lang/en/existing.php", existing), + ], + ); + let source_uri = Url::from_file_path(dir.path().join("src/usage.php")).unwrap(); + let existing_uri = Url::from_file_path(dir.path().join("lang/en/existing.php")).unwrap(); + let translation_path = dir.path().join(path); + let translation_uri = Url::from_file_path(&translation_path).unwrap(); + open_php(&backend, &source_uri, &source).await; + open_php(&backend, &existing_uri, existing).await; + + assert!( + definition_locations(goto_definition_at(&backend, &source_uri, 1, 5).await).is_empty() + ); + open_php(&backend, &translation_uri, translation).await; + assert!(!translation_path.exists()); + assert_eq!( + definition_locations(goto_definition_at(&backend, &source_uri, 1, 5).await), + vec![Location::new( + translation_uri.clone(), + Range::new(Position::new(2, 5), Position::new(2, 10)), + )], + "{path}" + ); + let hover = markup_hover_at(&backend, &source_uri, 1, 5).await; + assert!( + hover.contains(&format!("`{locale}`: `Saved from buffer`")), + "{hover}" + ); + let existing_definitions = + definition_locations(goto_definition_at(&backend, &source_uri, 2, 5).await); + assert_eq!(existing_definitions.len(), 1); + assert_eq!(existing_definitions[0].uri, existing_uri); + + backend + .did_close(DidCloseTextDocumentParams { + text_document: TextDocumentIdentifier { + uri: translation_uri, + }, + }) + .await; + assert!( + definition_locations(goto_definition_at(&backend, &source_uri, 1, 5).await).is_empty() + ); + } +} + +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 = "loadTranslationsFrom($path{namespace_argument}); + }} +}}\n" + ); + let (backend, _dir, uri, content) = workspace( + PACKAGE_COMPOSER, + &[ + ("bootstrap/providers.php", PACKAGE_PROVIDER_LIST), + ("packages/billing/src/BillingServiceProvider.php", &provider), + ("packages/billing/lang/en/invoice.php", BILLING_INVOICE), + ( + "packages/billing/lang/en.json", + "{\"Pay now\": \"Pay your invoice\"}", + ), + ("lang/en/auth.php", AUTH_EN), + ], + "__('invoice.total');\n __('Pay now');\n __('invoice.nope');", + ) + .await; + + for (key, suffix, expected) in [ + ( + "invoice.total", + "/packages/billing/lang/en/invoice.php", + "`Total`", + ), + ( + "Pay now", + "/packages/billing/lang/en.json", + "`Pay your invoice`", + ), + ] { + let found = definitions_at(&backend, &uri, &content, key).await; + assert!( + location_in(&found, suffix).is_some(), + "{namespace_argument:?}: {key}: got {found:?}" + ); + let text = hover_on(&backend, &uri, &content, key).await; + assert!( + text.contains(expected), + "{namespace_argument:?}: {key}: got {text}" + ); + } + + for (prefix, expected) in [("__('invoice.", "invoice.total"), ("__('Pay", "Pay now")] { + let position = position_after(&content, prefix); + let labels = + complete_labels_at_opened(&backend, &uri, position.line, position.character).await; + assert!( + labels.iter().any(|label| label == expected), + "{namespace_argument:?}: expected {expected}, got {labels:?}" + ); + } + + let messages = trans_diagnostics(&backend, &uri, &content); + assert_eq!( + messages.len(), + 1, + "{namespace_argument:?}: got {messages:?}" + ); + assert!(messages[0].contains("'invoice.nope'"), "got {messages:?}"); + } +} + +/// A namespace expression that cannot be evaluated is not a global registration. +#[tokio::test] +async fn an_unresolved_provider_namespace_does_not_register_global_translations() { + let provider = BILLING_PROVIDER.replace("'billing'", "$this->translationNamespace()"); + let (backend, _dir, uri, content) = workspace( + PACKAGE_COMPOSER, + &[ + ("bootstrap/providers.php", PACKAGE_PROVIDER_LIST), + ("packages/billing/src/BillingServiceProvider.php", &provider), + ("packages/billing/lang/en/invoice.php", BILLING_INVOICE), + ( + "packages/billing/lang/en.json", + "{\"Pay now\": \"Pay your invoice\"}", + ), + ("lang/en/auth.php", AUTH_EN), + ], + "__('invoice.total');\n __('Pay now');", + ) + .await; + + for key in ["invoice.total", "Pay now"] { + let found = definitions_at(&backend, &uri, &content, key).await; + assert!(found.is_empty(), "{key}: got {found:?}"); + } + let messages = trans_diagnostics(&backend, &uri, &content); + assert_eq!(messages.len(), 2, "got {messages:?}"); +} + /// An application provider registering `lang/app` through `lang_path()`. const LANG_PATH_PROVIDER: &str = "\