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.
- **Formatting from the command line.** `phpantom_lsp format` formats every PHP file and Blade template in a project with the same formatter the editor runs on save, and `phpantom_lsp format --check` reports the files that are not formatted and exits non-zero without writing anything, so a CI job can require that a pull request ran the formatter. A run honours whatever the project already formats with, a Laravel Pint, php-cs-fixer, or PHP_CodeSniffer it depends on, and the built-in formatter otherwise, exactly as the editor resolves it, and opens with a line naming what it resolved so a CI log records which formatter enforced the result. Templates whose indentation is output rather than layout are left alone and never fail a check, and formatting turned off in `.phpantom.toml` is reported as such rather than passing as a project where every file happens to be formatted. Paths can be named to restrict the run, `--format github` annotates the pull request diff, and `--format json` is shaped like the object `analyze` and `fix` emit.
- **Storage disk names are navigable wherever Laravel accepts one.** `Storage::disk()`, `fake()`, `persistentFake()`, `forgetDisk()`, and the `#[Storage]` container attribute now complete from `config/filesystems.php`; hover shows the config key, Ctrl+Click opens its declaration, and find-references links every use. Calls that require a configured disk report misspellings, while test fakes and disk eviction keep accepting the ad-hoc names Laravel permits at runtime. Contributed by @shuvroroy.
- **Class and namespace moves from the command line.** `phpantom_lsp move FROM TO` moves one class or a whole namespace and updates declarations, imports, references, and PSR-4 paths across the project. Both sides can be fully-qualified names or Composer PSR-4 file/directory paths, and `--dry-run --format json` provides a validation-only form for scripts and coding agents. A destination that would overwrite an existing class or file is refused before any changes are made. A move into a namespace no PSR-4 mapping covers is called out rather than reported as a plain success, since the files cannot follow the declarations there and the autoloader stops finding them. A class installed by Composer is refused outright, the same way renaming one in the editor is. Contributed by @calebdw.
Expand Down
1 change: 0 additions & 1 deletion docs/todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,6 @@ unlikely to move the needle for most users.
| S4 | Named argument awareness in active parameter | Low-Medium | Medium |
| S5 | Language construct signature help and hover | Low | Medium |
| | **[Laravel](todo/laravel.md)** | | |
| L24 | [Translation depth: JSON lang files, locales, placeholders](todo/laravel.md#l24-translation-depth-json-lang-files-locales-placeholders) | Medium-High | Medium-High |
| L46 | [`->can()` on a user model the receiver does not name](todo/laravel.md#l46-can-on-a-user-model-the-receiver-does-not-name) | Medium-High | Medium-High |
| L30 | [Eloquent attribute-array key completion](todo/laravel.md#l30-eloquent-attribute-array-key-completion) | Medium | Medium |
| L53 | [Collection key types from the column for `keyBy` / `groupBy` / `pluck`](todo/laravel.md#l53-collection-key-types-from-the-column-for-keyby-groupby-pluck) | Medium | Medium |
Expand Down
28 changes: 0 additions & 28 deletions docs/todo/laravel.md
Original file line number Diff line number Diff line change
Expand Up @@ -588,34 +588,6 @@ requires the live container. These genuinely cannot be resolved without
booting, and a snapshot of them is the "true for one boot" half-truth we are
choosing not to ship.

#### L24. Translation depth: JSON lang files, locales, placeholders

**Impact: Medium-High · Complexity: Medium-High**

Statically recoverable translation features the Laravel LSP has and we
still partially lack:

- **JSON lang files.** `lang/{locale}.json` (the "translation string as
key" style) now completes and resolves go-to-definition, but the
definition always lands on the top of the file rather than the key's
actual line, and find-references does not cover JSON keys at all.
- **Locale argument completion.** The `$locale` parameter of `__()`,
`trans()`, `trans_choice()`, `Lang::get()/choice()/hasForLocale()`
(positional or named) completes from the locale set derived from
`lang/*/` directories and `lang/*.json` files.
- **Placeholder parameter completion.** The `:name` placeholders parsed
from the translation value complete as keys of the replacement array
(`__('welcome', ['name' => …])`).
- **Multi-locale hover.** Hover already shows a translation key's value
for the resolved locale; show the value per locale (with a link to
each file) instead of just the one.
- **Insert missing key quick-fix.** When the unknown-translation-key
diagnostic fires on a `group.item` key whose `lang/{locale}/group.php`
array file already exists, offer a quick-fix that inserts the missing
`'item' => '...'` entry (existing keys as siblings for placement,
empty string as the value). No fix when the group file itself doesn't
exist yet; that case still just diagnoses.

#### L27. Legacy `Controller@method` action strings

**Impact: Low · Complexity: Low**
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 @@ -668,6 +668,14 @@ public function laravelNavigation(): void
request()->routeIs('bakeries.*');

// Translation Keys
// Ctrl+Click a JSON key to reach its exact declaration; find
// references from lang/en.json to return to its call sites.
__('Fresh bread for :name', ['name' => 'Ada']);

// Try: complete the locale argument or a replacement-array key.
// Hover shows the English and French values with links to both files.
__('Fresh bread for :name', replace: ['name' => 'Ada'], locale: 'fr');
// Try: change this to 'messages.new_key' and apply the insertion quick fix.
__('messages.welcome');
trans('auth.failed');
trans_choice('messages.notifications', 5);
Expand Down
21 changes: 21 additions & 0 deletions examples/laravel/assertions.php
Original file line number Diff line number Diff line change
Expand Up @@ -1468,6 +1468,27 @@ public function toArray(): array

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

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

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

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

// ─── Summary ────────────────────────────────────────────────────────────────

echo "\n";
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 @@ -318,6 +318,9 @@ impl Backend {
///
/// Called from `did_close` to clean up state when a file is closed.
pub(crate) fn clear_file_maps(&self, uri: &str) {
self.laravel_string_key_cache
.write()
.invalidate_for_uri(uri, "");
// uri_classes_index is redundant with fqn_class_index once indexing
// is complete — GTD falls back to fqn_uri_index + parse_and_cache_file
// when the uri_classes_index entry is missing.
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