Skip to content
Merged
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
26 changes: 26 additions & 0 deletions laravel-lsp/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,32 @@ fn find_project_root_upward(start: &Path) -> Option<PathBuf> {
tentative
}

/// Read a single value out of a project's `.env`.
///
/// The one hardened `.env` reader in this codebase. The pattern is
/// deliberately horizontal (`[ \t]*`, `[^'"\n]*`) so a blank value
/// (`KEY=\n`) captures the empty string rather than swallowing the next
/// line — a naive multi-line-tolerant regex here previously leaked one
/// variable's value into another's, and any second copy of this logic risks
/// reintroducing that. Callers that need an env value go through this.
///
/// Returns `None` when the file is unreadable, the key is absent, or the value
/// is empty.
pub fn read_env_value(project_root: &Path, key: &str) -> Option<String> {
let env_path = resolve_worktree_fallback(project_root, ".env");
let content = std::fs::read_to_string(&env_path).ok()?;
let pattern = format!(
r#"(?m)^{}[ \t]*=[ \t]*['"]?([^'"\n]*)['"]?"#,
regex::escape(key)
);
regex::Regex::new(&pattern)
.ok()?
.captures(&content)
.and_then(|caps| caps.get(1))
.map(|m| m.as_str().trim().to_string())
.filter(|s| !s.is_empty())
}

/// Resolve a directory's real git "common dir" — the directory holding the
/// repository's shared object database and refs.
///
Expand Down
33 changes: 3 additions & 30 deletions laravel-lsp/src/database.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1417,36 +1417,9 @@ impl DatabaseSchemaProvider {
/// whatever line followed), which was sent as the literal password
/// to MySQL and rejected as bad credentials.
fn resolve_env(&self, key: &str) -> Option<String> {
let env_path = crate::config::resolve_worktree_fallback(&self.project_root, ".env");
let content = match std::fs::read_to_string(&env_path) {
Ok(c) => c,
Err(e) => {
debug!("🗄️ resolve_env({}): Failed to read .env: {}", key, e);
return None;
}
};

// Pattern: KEY=value or KEY="value" or KEY='value', all on one
// line. `[ \t]*` stays horizontal so a blank value (`KEY=\n`)
// captures the empty string, not the next line.
let pattern = format!(
r#"(?m)^{}[ \t]*=[ \t]*['"]?([^'"\n]*)['"]?"#,
regex::escape(key)
);
let regex = match Regex::new(&pattern) {
Ok(r) => r,
Err(e) => {
debug!("🗄️ resolve_env({}): Invalid regex: {}", key, e);
return None;
}
};

let result = regex
.captures(&content)
.and_then(|caps| caps.get(1))
.map(|m| m.as_str().trim().to_string())
.filter(|s| !s.is_empty());

// Delegates to the single hardened reader in `config` — see its doc
// comment for why this logic must not be duplicated.
let result = crate::config::read_env_value(&self.project_root, key);
debug!("🗄️ resolve_env({}): {:?}", key, result);
result
}
Expand Down
91 changes: 70 additions & 21 deletions laravel-lsp/src/hover.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,13 @@ pub const FILE_NOT_FOUND_TRAILER: &str = "*(file not found)*";
/// the production string.
pub const TRANSLATION_NOT_FOUND_TRAILER: &str = "*(translation not found for default locale)*";

/// Trailer for the multi-locale card: the key resolved in none of the locales
/// the project actually defines. Distinct from
/// [`TRANSLATION_NOT_FOUND_TRAILER`], which still says "default locale" because
/// the single-locale [`translation_card`] path really did only consult one.
pub const TRANSLATION_NOT_FOUND_ANY_LOCALE_TRAILER: &str =
"*(translation not found in any locale)*";

/// Anything the cursor might be hovering. Pattern variants come straight from
/// the Salsa position index; the Blade-variable variant is extracted by line
/// scanning, and only matters in `.blade.php` files.
Expand Down Expand Up @@ -101,6 +108,11 @@ pub struct HoverContent<'a> {
pub code: Option<CodeBlock<'a>>,
/// Italic tag lines (`@param`, `@return`, `@throws`).
pub tags: &'a [String],
/// One line per item in a repeated section — the multi-locale translation
/// card's `**de** — “…” · [link]` rows. Rendered as a single block with
/// markdown hard breaks between rows, so N entries render as N adjacent
/// lines rather than N paragraphs.
pub lines: &'a [String],
/// Pre-built markdown link string for the source location (e.g.
/// `[app/Models/User.php:42](file:///abs/path)`). Rendered verbatim
/// — no `at` prefix, no surrounding backticks.
Expand Down Expand Up @@ -150,6 +162,11 @@ pub fn render(content: &HoverContent<'_>) -> String {
};
sections.push(block);
}
if !content.lines.is_empty() {
// Two trailing spaces is markdown's hard line break — a bare "\n"
// would let adjacent rows run together into one paragraph.
sections.push(content.lines.join(" \n"));
}
if !content.tags.is_empty() {
let tag_lines = content
.tags
Expand Down Expand Up @@ -205,32 +222,64 @@ pub fn translation_card(
})
}

/// Multi-locale variant of [`translation_card`]: one value line per locale
/// that defines the key, each with its own source link, so a `de` + `en`
/// catalogue shows both translations at once. Locales that don't define the
/// key are skipped; when none does, the not-found trailer renders instead.
/// Multi-locale variant of [`translation_card`]: one line per locale that
/// defines the key, each carrying its own source link inline, so a `de` + `en`
/// catalogue shows both translations at once.
///
/// Each entry is a locale paired with `Some((value, source_link))` when that
/// locale defines the key, or `None` when it doesn't. Value and link travel
/// together because they cannot occur apart: a value only exists because a
/// file was read to produce it, and that file's path is what the link is built
/// from. Pairing them in the type keeps "resolved but unlinkable" — a state the
/// resolver cannot produce — out of the shape entirely.
///
/// Three renderings, chosen by how many locales actually *resolve* the key —
/// not how many locale directories exist, since a project can define a dozen
/// locales and have only one carry this key:
///
/// - **None** — the not-found trailer, naming that no locale had it.
/// - **Exactly one** — collapses to [`translation_card`]'s dense single-block
/// form. Most projects ship one locale, and stacking a lone value under a
/// locale heading would cost a line to say nothing.
/// - **More than one** — a line per locale:
///
/// ```text
/// `failed_title`
///
/// **de** — “Analyse fehlgeschlagen” · [lang/de/contract.php](file://…)
/// **en** — “Analysis failed” · [lang/en/contract.php](file://…)
/// ```
pub fn translation_card_locales(
key: &str,
entries: &[(String, Option<String>, Option<String>)],
entries: &[(String, Option<(String, String)>)],
) -> String {
let mut body = format!("`{}`", leaf_segment(key));
let mut any = false;
for (locale, value, source_link) in entries {
let Some(value) = value else {
continue;
};
any = true;
body.push_str(&format!("\n\n**{locale}** — “{value}”"));
if let Some(link) = source_link {
body.push_str("\n\n");
body.push_str(link);
let detail = format!("`{}`", leaf_segment(key));
let resolved: Vec<(&String, &(String, String))> = entries
.iter()
.filter_map(|(locale, hit)| hit.as_ref().map(|hit| (locale, hit)))
.collect();

match resolved.as_slice() {
[] => render(&HoverContent {
detail: Some(&detail),
trailer: Some(TRANSLATION_NOT_FOUND_ANY_LOCALE_TRAILER),
..Default::default()
}),
[(locale, (value, link))] => translation_card(key, locale, Some(value), Some(link)),
_ => {
let lines: Vec<String> = resolved
.iter()
// Curly quotes delimit the value so it can't be mistaken for
// the key or a path — same rule as `translation_card`.
.map(|(locale, (value, link))| format!("**{locale}** — “{value}” · {link}"))
.collect();
render(&HoverContent {
detail: Some(&detail),
lines: &lines,
..Default::default()
})
}
}
if !any {
body.push_str("\n\n");
body.push_str(TRANSLATION_NOT_FOUND_TRAILER);
}
body
}

/// The leaf of a translation key: the last `.`-segment, after dropping any
Expand Down
104 changes: 92 additions & 12 deletions laravel-lsp/src/hover/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ fn render_full_section_set_in_order() {
"@param mixed $x".to_string(),
"@return Response".to_string(),
];
let lines = vec!["**de** — “eins”".to_string(), "**en** — “one”".to_string()];
let out = render(&HoverContent {
header: Some("App\\Foo::bar"),
detail: Some("Some detail line"),
Expand All @@ -71,6 +72,7 @@ fn render_full_section_set_in_order() {
content: "public function bar()",
}),
tags: &tags,
lines: &lines,
source_link: Some("[app/Foo.php:10](file:///abs/Foo.php#L10)"),
trailer: None,
});
Expand All @@ -85,6 +87,9 @@ fn render_full_section_set_in_order() {
public function bar()\n\
```\n\
\n\
**de** — “eins” \n\
**en** — “one”\n\
\n\
*@param mixed $x*\n\
\n\
*@return Response*\n\
Expand Down Expand Up @@ -728,37 +733,112 @@ fn translation_card_without_value_shows_not_found_trailer() {
}

#[test]
fn translation_card_locales_renders_every_defined_locale() {
fn translation_card_locales_renders_one_line_per_locale_with_inline_links() {
let card = translation_card_locales(
"legal::contract.prefill.failed_title",
&[
(
"de".to_string(),
Some("Analyse fehlgeschlagen".to_string()),
Some("[lang/de/contract.php](file:///x)".to_string()),
Some((
"Analyse fehlgeschlagen".to_string(),
"[lang/de/contract.php](file:///x)".to_string(),
)),
),
(
"en".to_string(),
Some("Analysis failed".to_string()),
Some("[lang/en/contract.php](file:///y)".to_string()),
Some((
"Analysis failed".to_string(),
"[lang/en/contract.php](file:///y)".to_string(),
)),
),
(
"fr".to_string(),
Some((
"Analyse échouée".to_string(),
"[lang/fr/contract.php](file:///z)".to_string(),
)),
),
],
);

assert!(card.starts_with("`failed_title`"));
assert!(card.contains("**de** — “Analyse fehlgeschlagen”"));
assert!(card.contains("**en** — “Analysis failed”"));
assert!(card.contains("[lang/de/contract.php](file:///x)"));
assert!(card.contains("**de** — “Analyse fehlgeschlagen” · [lang/de/contract.php](file:///x)"));
assert!(card.contains("**en** — “Analysis failed” · [lang/en/contract.php](file:///y)"));
assert!(card.contains("**fr** — “Analyse échouée” · [lang/fr/contract.php](file:///z)"));

// Three locales → three adjacent lines in one block, links inline. A
// paragraph-delimited render would put a blank line between every row and
// between each row and its link.
let block = card.split("\n\n").nth(1).expect("a locale block");
assert_eq!(
block.lines().count(),
3,
"expected 3 locale lines, got:\n{block}"
);
}

#[test]
fn translation_card_locales_skips_missing_and_falls_back_to_trailer() {
fn translation_card_locales_collapses_when_only_one_locale_resolves() {
// Two locales discovered, one defines the key: the card must be the dense
// single-block form, not a one-row list.
let card = translation_card_locales(
"messages.welcome",
&[
("de".to_string(), None, None),
("en".to_string(), None, None),
("de".to_string(), None),
(
"en".to_string(),
Some((
"Welcome".to_string(),
"[lang/en/messages.php](file:///y)".to_string(),
)),
),
],
);
assert!(card.contains(TRANSLATION_NOT_FOUND_TRAILER));

assert_eq!(
card,
translation_card(
"messages.welcome",
"en",
Some("Welcome"),
Some("[lang/en/messages.php](file:///y)")
),
"a single resolving locale must render identically to translation_card"
);
assert!(
!card.contains("**en**"),
"no locale heading in the dense form"
);
}

#[test]
fn translation_card_locales_uses_the_any_locale_trailer_when_none_resolve() {
let card = translation_card_locales(
"messages.welcome",
&[("de".to_string(), None), ("en".to_string(), None)],
);
assert_eq!(
card,
format!("`welcome`\n\n{TRANSLATION_NOT_FOUND_ANY_LOCALE_TRAILER}")
);
assert!(!card.contains("**de**"));
}

/// The two trailers say different things and must keep saying them: the
/// single-locale card really did consult only one locale, the multi-locale
/// card consulted every locale the project defines.
#[test]
fn the_two_not_found_trailers_have_distinct_wording() {
assert_eq!(
TRANSLATION_NOT_FOUND_TRAILER,
"*(translation not found for default locale)*"
);
assert_eq!(
TRANSLATION_NOT_FOUND_ANY_LOCALE_TRAILER,
"*(translation not found in any locale)*"
);
assert_ne!(
TRANSLATION_NOT_FOUND_TRAILER,
TRANSLATION_NOT_FOUND_ANY_LOCALE_TRAILER
);
}
Loading