Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **Laravel translations are understood across locales.** JSON and PHP language files under `lang/` and `resources/lang/` share navigation, locale and replacement-key completion, and hover with links to each locale's value. Missing keys offer an insertion quick fix when their PHP group file already exists. Contributed by @shuvroroy.
- **`phpantom_lsp init` asks what to set.** On an interactive terminal, `init` (and `init --global`) now walks through the settings projects customize most often, PHP version, indexing strategy, a few diagnostic toggles, and semantic token mode, and writes only the answers that differ from the defaults, so a starter `.phpantom.toml` no longer has to be hand-edited for the common cases. Piping stdin or passing `--yes` skips the prompts and writes the same minimal schema-only file `init` always produced.
- **A model operator inside a `Closure(…)` parameter types the closure.** `@param (Closure(builder-of<static>): mixed)|null $query` — the shape a repository or a `randomOrFactory()`-style helper is written with — left the closure's argument untyped, so nothing inside the closure body completed, hovered, or navigated, while the `Closure(TFactory)` beside it worked. Closure-parameter inference discarded any hyphenated type it did not recognise, and the model operators were on the wrong side of that list; they are now resolved to the class they name first, with `static` already bound to the model the call was made on, so the closure receives that model's builder, collection, or factory. Contributed by @calebdw.
- **A `view-string` parameter names a Blade template, and is treated as one.** The Laravel PHPStan extensions' `view-string` is the subset of `string` that names a template, and a parameter or return type declaring it now reads as exactly that: it behaves as a `string` everywhere a string is expected, so passing one around, returning it, or handing it a runtime value is unremarkable, while a *literal* written at such an argument is checked against the templates the project actually ships and reported as an unknown view if nothing answers to it — the same check, and the same message, a bad `view('…')` name already gets. Typing inside one of those arguments completes the project's templates, which a call the extension cannot recognise by name (a repository's own `render()` method, say) previously offered nothing for. A name is only reported when it can be proven wrong: a variable or a concatenation may hold anything at runtime, a project the view scan found no templates in says nothing about any name, and a `package::view` in a namespace no service provider registered is not enumerable, so all three are left alone. Contributed by @calebdw.
Expand Down
2 changes: 0 additions & 2 deletions docs/todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ contributor even though it's short.
| P63 | [Every diagnostic converts its offsets by counting from the top of the file](todo/performance.md#p63-every-diagnostic-converts-its-offsets-by-counting-from-the-top-of-the-file) | High | Low |
| 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 |
| | **Release 0.11.0** | | |
Expand Down Expand Up @@ -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 |
Expand Down
45 changes: 0 additions & 45 deletions docs/todo/laravel.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand Down
8 changes: 8 additions & 0 deletions examples/laravel/app/Demo.php
Original file line number Diff line number Diff line change
Expand Up @@ -739,6 +739,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);
Expand Down
21 changes: 21 additions & 0 deletions examples/laravel/assertions.php
Original file line number Diff line number Diff line change
Expand Up @@ -1620,6 +1620,27 @@ public function toArray(): array

\Illuminate\Container\Container::setInstance($previousContainer);

// ─── Translation resources ─────────────────────────────────────────────────

$translationLoader = new \Illuminate\Translation\FileLoader(
new \Illuminate\Filesystem\Filesystem(),
[__DIR__ . '/lang', __DIR__ . '/resources/lang']
);
$translationDemo = new \Illuminate\Translation\Translator($translationLoader, 'en');
check(
'JSON translation keys resolve with replacements',
$translationDemo->get('Fresh bread for :name', ['name' => 'Ada']) === 'Fresh bread for Ada'
);

check(
'Locale and named replacements resolve in resources/lang',
$translationDemo->get(
locale: 'fr',
replace: ['name' => 'Ada'],
key: 'Fresh bread for :name'
) === 'Du pain frais pour Ada'
);

// ─── UUID and ULID primary keys ─────────────────────────────────────────────

$uuidOrder = new \App\Models\BakeryOrder();
Expand Down
4 changes: 4 additions & 0 deletions examples/laravel/lang/en.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"The bakery is open": "The bakery is open",
"Fresh bread for :name": "Fresh bread for :name"
}
4 changes: 4 additions & 0 deletions examples/laravel/resources/lang/fr.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"The bakery is open": "La boulangerie est ouverte",
"Fresh bread for :name": "Du pain frais pour :name"
}
3 changes: 3 additions & 0 deletions src/backend/file_access.rs
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,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.
Expand Down
3 changes: 1 addition & 2 deletions src/backend/laravel/provider_resources.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
}
Expand Down
231 changes: 231 additions & 0 deletions src/code_actions/insert_translation_key.rs
Original file line number Diff line number Diff line change
@@ -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<CodeActionOrCommand>,
) {
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, &params.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<Vec<TextEdit>> {
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::<Vec<_>>())
})
}

fn insert_into_array(
content: &str,
expression: &Expression<'_>,
path: &[&str],
) -> Option<Vec<TextEdit>> {
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;
Loading
Loading