use std::collections::HashMap;
use std::path::{Path, PathBuf};
use php_ast::{ArrayElement, ExprKind, StmtKind};
use tower_lsp_server::ls_types::{
CompletionItem, CompletionItemKind, Location, Position, Range, Uri,
};
use crate::analysis::diagnostics::parse_document_no_diags;
use crate::document::ast::{SourceView, offset_to_position};
#[derive(Debug, Default, Clone)]
pub struct TranslationIndex {
keys: HashMap<String, Location>,
}
impl TranslationIndex {
pub fn get(&self, key: &str) -> Option<&Location> {
self.keys.get(key)
}
pub fn keys(&self) -> impl Iterator<Item = &str> {
self.keys.keys().map(String::as_str)
}
pub fn key_at(&self, uri: &Uri, position: Position) -> Option<&str> {
crate::laravel::location_lookup::key_at(&self.keys, uri, position)
}
pub(super) fn load(root: &Path) -> Self {
let mut keys = HashMap::new();
for lang_root in [root.join("lang"), root.join("resources").join("lang")] {
load_lang_root(&lang_root, &mut keys);
}
Self { keys }
}
}
fn load_lang_root(lang_root: &Path, out: &mut HashMap<String, Location>) {
let Ok(entries) = std::fs::read_dir(lang_root) else {
return;
};
let mut locale_dirs: Vec<PathBuf> = Vec::new();
let mut json_files: Vec<PathBuf> = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if entry.file_type().is_ok_and(|t| t.is_dir()) {
locale_dirs.push(path);
} else if path.extension().is_some_and(|e| e == "json") {
json_files.push(path);
}
}
locale_dirs.sort_by_key(|p| {
let name = p
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_string();
(name != "en", name)
});
for dir in &locale_dirs {
load_locale_php_files(dir, out);
}
json_files.sort_by(|a, b| a.file_name().cmp(&b.file_name()));
for file in &json_files {
load_locale_json_file(file, out);
}
}
fn load_locale_php_files(locale_dir: &Path, out: &mut HashMap<String, Location>) {
let Ok(entries) = std::fs::read_dir(locale_dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_none_or(|e| e != "php") {
continue;
}
let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
continue;
};
let Ok(text) = std::fs::read_to_string(&path) else {
continue;
};
let Some(uri) = Uri::from_file_path(&path) else {
continue;
};
let doc = parse_document_no_diags(&text);
let sv = doc.view();
for stmt in doc.program().stmts.iter() {
if let StmtKind::Return(Some(expr)) = &stmt.kind
&& let ExprKind::Array(elements) = &expr.kind
{
collect_array_keys(elements, sv, &uri, stem, out);
}
}
}
}
fn collect_array_keys(
elements: &[ArrayElement<'_, '_>],
sv: SourceView<'_>,
uri: &Uri,
prefix: &str,
out: &mut HashMap<String, Location>,
) {
for el in elements {
let Some(key_expr) = &el.key else { continue };
let ExprKind::String(key) = &key_expr.kind else {
continue;
};
let dotted = format!("{prefix}.{key}");
let range = Range {
start: sv.position_of(key_expr.span.start + 1),
end: sv.position_of(key_expr.span.end - 1),
};
out.entry(dotted.clone()).or_insert_with(|| Location {
uri: uri.clone(),
range,
});
if let ExprKind::Array(nested) = &el.value.kind {
collect_array_keys(nested, sv, uri, &dotted, out);
}
}
}
fn load_locale_json_file(path: &Path, out: &mut HashMap<String, Location>) {
let Ok(text) = std::fs::read_to_string(path) else {
return;
};
let Ok(json) = serde_json::from_str::<serde_json::Value>(&text) else {
return;
};
let Some(map) = json.as_object() else {
return;
};
let Some(uri) = Uri::from_file_path(path) else {
return;
};
for key in map.keys() {
if out.contains_key(key.as_str()) {
continue;
}
if let Some(range) = find_json_key_range(&text, key) {
out.insert(
key.clone(),
Location {
uri: uri.clone(),
range,
},
);
}
}
}
fn find_json_key_range(text: &str, key: &str) -> Option<Range> {
let escaped = key.replace('\\', "\\\\").replace('"', "\\\"");
let needle = format!("\"{escaped}\"");
let quote_byte = text.find(&needle)?;
let byte_start = (quote_byte + 1) as u32;
let byte_end = byte_start + escaped.len() as u32;
let line_starts = line_starts_of(text);
Some(Range {
start: offset_to_position(text, &line_starts, byte_start),
end: offset_to_position(text, &line_starts, byte_end),
})
}
fn line_starts_of(text: &str) -> Vec<u32> {
std::iter::once(0u32)
.chain(text.match_indices('\n').map(|(i, _)| i as u32 + 1))
.collect()
}
pub(crate) fn translation_completions(
index: &TranslationIndex,
prefix: &str,
) -> Vec<CompletionItem> {
let mut items: Vec<CompletionItem> = index
.keys()
.filter(|key| key.starts_with(prefix))
.map(|key| CompletionItem {
label: key.to_string(),
kind: Some(CompletionItemKind::TEXT),
insert_text: Some(key.to_string()),
..Default::default()
})
.collect();
items.sort_by(|a, b| a.label.cmp(&b.label));
items
}
#[cfg(test)]
mod tests {
use super::*;
fn write_php_lang(root: &Path, lang_dir: &str, locale: &str, name: &str, contents: &str) {
let dir = root.join(lang_dir).join(locale);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(name), contents).unwrap();
}
#[test]
fn indexes_nested_php_keys_under_lang() {
let tmp = tempfile::tempdir().unwrap();
write_php_lang(
tmp.path(),
"lang",
"en",
"auth.php",
"<?php\nreturn [\n 'failed' => 'These credentials do not match.',\n];\n",
);
let idx = TranslationIndex::load(tmp.path());
assert!(idx.get("auth.failed").is_some());
}
#[test]
fn prefers_en_locale_on_key_collision() {
let tmp = tempfile::tempdir().unwrap();
write_php_lang(
tmp.path(),
"lang",
"es",
"auth.php",
"<?php\nreturn ['failed' => 'Spanish'];\n",
);
write_php_lang(
tmp.path(),
"lang",
"en",
"auth.php",
"<?php\nreturn ['failed' => 'English'];\n",
);
let idx = TranslationIndex::load(tmp.path());
let loc = idx.get("auth.failed").unwrap();
assert!(loc.uri.as_str().contains("/en/"));
}
#[test]
fn prefers_root_lang_over_resources_lang() {
let tmp = tempfile::tempdir().unwrap();
write_php_lang(
tmp.path(),
"resources/lang",
"en",
"auth.php",
"<?php\nreturn ['failed' => 'Legacy'];\n",
);
write_php_lang(
tmp.path(),
"lang",
"en",
"auth.php",
"<?php\nreturn ['failed' => 'Current'];\n",
);
let idx = TranslationIndex::load(tmp.path());
let loc = idx.get("auth.failed").unwrap();
assert!(!loc.uri.as_str().contains("resources"));
}
#[test]
fn indexes_json_literal_translation_keys() {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join("lang")).unwrap();
std::fs::write(
tmp.path().join("lang").join("es.json"),
r#"{"I love programming.": "Me encanta programar."}"#,
)
.unwrap();
let idx = TranslationIndex::load(tmp.path());
let loc = idx.get("I love programming.").unwrap();
assert!(loc.uri.as_str().ends_with("es.json"));
assert_eq!(loc.range.start.line, 0);
}
#[test]
fn missing_lang_dirs_yield_empty_index() {
let tmp = tempfile::tempdir().unwrap();
let idx = TranslationIndex::load(tmp.path());
assert_eq!(idx.keys().count(), 0);
}
#[test]
fn translation_completions_filters_by_prefix_and_sorts() {
let tmp = tempfile::tempdir().unwrap();
write_php_lang(
tmp.path(),
"lang",
"en",
"auth.php",
"<?php\nreturn [\n 'failed' => 'x',\n 'throttle' => 'y',\n];\n",
);
let idx = TranslationIndex::load(tmp.path());
let items = translation_completions(&idx, "auth.");
let labels: Vec<&str> = items.iter().map(|i| i.label.as_str()).collect();
assert_eq!(labels, vec!["auth.failed", "auth.throttle"]);
}
}