Skip to content

Commit 1fe5c71

Browse files
Words a reader sees that no table can translate are found, and fewer of them are left
Under i18n.literals, a text splicing more than a name, or a value shown by a name that does not say it is words, was silently shown as written in every language. I05 now warns of both where they are shown. A path with a fallback, `{item?.name ?? ""}`, is keyed by its name, and a string an action writes to a value named as words is looked up like a declared one. A file name or a code word is not taken for prose.
1 parent e2ba439 commit 1fe5c71

8 files changed

Lines changed: 250 additions & 3 deletions

File tree

‎AGENTS.md‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2591,7 +2591,11 @@ the file has it in any language, falling back to `items.other` and then
25912591
`items` — the static paint and the live page by the same rules. A key's
25922592
forms come from one language. Under `i18n.literals`, text with one number
25932593
in it (`Text("{files} files")`) picks its form by that number:
2594-
`"{files} files.one"`, `"{files} files.other"` … in each table. `format` and `ago` speak the locale too.
2594+
`"{files} files.one"`, `"{files} files.other"` … in each table. Under
2595+
literals a text is keyed by the names it splices (`{item?.name ?? ""}` is
2596+
`{name}`); a `derived`, `state` or action-written value is looked up only
2597+
when its name says it is words (`countText`, `emptyLabel`, `saidNote`);
2598+
I05 warns of shown words that are neither. `format` and `ago` speak the locale too.
25952599
`wf translations` is the table: what each locale lacks, what nothing names.
25962600

25972601
RTL locales (automatic `dir="rtl"`, from the first paint when the default
@@ -3008,6 +3012,7 @@ And what the type checker finds:
30083012
| `I01` | A literal `t("key")` that no locale's translations have, so the page shows the key |
30093013
| `I02` | A message and its call that disagree: a value passed the message does not use, or a placeholder the call does not pass |
30103014
| `I04` | `setLocale("xx")` naming a locale `i18n.locales` does not list |
3015+
| `I05` | Under `i18n.literals`, shown words no table can translate: text splicing more than a name, or a value shown by a name that does not say it is words |
30113016
| `V06` | A `$token` the resolved theme does not declare, so the property falls back to nothing |
30123017

30133018
An endpoint a service does not have is `T06`, and an argument it does not

‎md-docs/40-diagnostics.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1689,6 +1689,15 @@ sees `nav.blog`. **Fix:** translate it, or remove it from the others.
16891689
`setLocale("fr")` when `i18n.locales` is `["en", "ar"]`. **Fix:** add the
16901690
locale and its file, or name one that exists.
16911691

1692+
### I05 — Words a reader sees that are not translated
1693+
1694+
Warning, under `"literals": true`. `Text("{plural(n, "file")} found")`
1695+
splices a call, so no table can key it; `derived said = "{n} errors"`
1696+
shown as `Text(said)` is not looked up, because only a value whose name
1697+
says it is words (`saidText`, `errorsLabel`) is. A reader in Arabic sees
1698+
both in English. **Fix:** splice names, `derived filesText = "{n} files"`
1699+
then `Text("{filesText} found")`, and name what is words as words.
1700+
16921701
## Accessibility
16931702

16941703
Warnings. What makes a page unusable with a screen reader, a keyboard or a voice.

‎site/public/search-index.json‎

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

‎site/src/pages/guide/40-diagnostics.wf‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1273,6 +1273,10 @@ page P(path: "/", title: "T", description: "D") {
12731273

12741274
Markdown("`setLocale(\"fr\")` when `i18n.locales` is `[\"en\", \"ar\"]`. **Fix:** add the locale and its file, or name one that exists.")
12751275

1276+
Heading("I05 — Words a reader sees that are not translated", id: "i05-words-a-reader-sees-that-are-not-translated").h3
1277+
1278+
Markdown("Warning, under `\"literals\": true`. `Text(\"\{plural(n, \"file\")\} found\")` splices a call, so no table can key it; `derived said = \"\{n\} errors\"` shown as `Text(said)` is not looked up, because only a value whose name says it is words (`saidText`, `errorsLabel`) is. A reader in Arabic sees both in English. **Fix:** splice names, `derived filesText = \"\{n\} files\"` then `Text(\"\{filesText\} found\")`, and name what is words as words.")
1279+
12761280
Heading("Accessibility", id: "accessibility").h2
12771281

12781282
Markdown("Warnings. What makes a page unusable with a screen reader, a keyboard or a voice.")

‎src/diagnostics/codes.rs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,12 @@ pub static CODES: &[CodeInfo] = &[
532532
"A locale the project does not have",
533533
"`setLocale(\"xx\")` names a locale `i18n.locales` does not list.",
534534
),
535+
code(
536+
"I05",
537+
Warning,
538+
"Words a reader sees that are not translated",
539+
"Under `i18n.literals`, text that splices more than a name, or a value shown by a name that does not say it is words, is never looked up: every reader sees it as written.",
540+
),
535541
// ── A: accessibility ─────────────────────────────────────────────
536542
code(
537543
"A01",

‎src/i18n/literals.rs‎

Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,10 +66,30 @@ fn name_of(e: &Expr) -> Option<String> {
6666
name_of(base)?;
6767
Some(n.clone())
6868
}
69+
// A path with what it falls back to: `{item?.name ?? ""}`.
70+
Expr::BinaryOp(left, crate::parser::ast::BinOp::NullCoalesce, right)
71+
if matches!(**right, Expr::StringLiteral(_) | Expr::NumberLiteral(_)) =>
72+
{
73+
name_of(left)
74+
}
6975
_ => None,
7076
}
7177
}
7278

79+
/// Whether a piece of text is prose a reader reads, not a name or a code
80+
/// word: `Close`, `about `, `used in`, but not `rtl`, `main` or
81+
/// `webfluent.app.json`.
82+
fn prose(t: &str) -> bool {
83+
let letters = t.chars().filter(|c| c.is_alphabetic()).count();
84+
let capital = t
85+
.trim_start()
86+
.chars()
87+
.next()
88+
.is_some_and(|c| c.is_uppercase())
89+
&& t.chars().any(|c| c.is_lowercase());
90+
letters >= 1 && (t.contains(char::is_whitespace) || capital || !t.is_ascii())
91+
}
92+
7393
/// The key a string is looked up by, and what fills each placeholder:
7494
/// `"Problems"`, or `"{count} missing"` with `count`. None for a string
7595
/// that is not words, or that splices more than a plain path.
@@ -242,6 +262,15 @@ fn each_text(program: &mut Program, f: &mut dyn FnMut(&mut Expr, usize, Span)) {
242262
}
243263
each_field(&mut d.value, &mut |e| f(e, i, span));
244264
}
265+
// `wrote = "Wrote {n} changes."` in an action, to a value
266+
// whose name says it is words.
267+
StatementKind::Assignment(a) => {
268+
if target_name(&a.target).is_some_and(text_name) {
269+
each_shown(&mut a.value, &mut |e| f(e, i, span));
270+
}
271+
each_field(&mut a.value, &mut |e| f(e, i, span));
272+
}
273+
StatementKind::Return(Some(e)) => each_field(e, &mut |x| f(x, i, span)),
245274
StatementKind::UIElement(ui) => {
246275
for a in &mut ui.args {
247276
match a {
@@ -271,6 +300,127 @@ fn each_text(program: &mut Program, f: &mut dyn FnMut(&mut Expr, usize, Span)) {
271300
}
272301
}
273302

303+
/// The name an assignment writes: `wrote`, or `note` of `self.note`.
304+
fn target_name(e: &Expr) -> Option<&str> {
305+
match e {
306+
Expr::Identifier(n) => Some(n),
307+
Expr::PropertyAccess(_, n) | Expr::OptionalProperty(_, n) => Some(n),
308+
_ => None,
309+
}
310+
}
311+
312+
/// Whether `e` holds a string with words in it anywhere.
313+
fn holds_words(e: &Expr) -> bool {
314+
let mut found = false;
315+
e.clone().walk_mut(&mut |x| match x {
316+
Expr::StringLiteral(t) if prose(t) => found = true,
317+
Expr::InterpolatedString(parts) => {
318+
found |= parts
319+
.iter()
320+
.any(|p| matches!(p, StringPart::Literal(t) if prose(t)))
321+
}
322+
_ => {}
323+
});
324+
found
325+
}
326+
327+
/// Why words a reader sees are not translated.
328+
#[derive(Debug, Clone, PartialEq)]
329+
pub enum Untranslated {
330+
/// Text that splices more than a plain path: `{plural(n, "file")}`.
331+
Splices(String),
332+
/// A value shown by its name, whose name does not say it is words, so
333+
/// the strings it holds are not looked up: `said`.
334+
Unnamed(String),
335+
}
336+
337+
/// What a reader sees that no table translates, with where it is shown.
338+
pub fn untranslated(
339+
program: &Program,
340+
file_of: &dyn Fn(usize) -> String,
341+
) -> Vec<(String, Span, Untranslated)> {
342+
// The values each body declares or writes words into, whose names do
343+
// not say they are words; a store's by `Store.member` as well.
344+
fn wordy(body: &[Statement], out: &mut std::collections::BTreeSet<String>) {
345+
for s in body {
346+
match &s.kind {
347+
StatementKind::State(d) if !text_name(&d.name) && holds_words(&d.value) => {
348+
out.insert(d.name.clone());
349+
}
350+
StatementKind::Derived(d) if !text_name(&d.name) && holds_words(&d.value) => {
351+
out.insert(d.name.clone());
352+
}
353+
StatementKind::Assignment(a) => {
354+
if let Some(n) = target_name(&a.target) {
355+
if !text_name(n) && holds_words(&a.value) {
356+
out.insert(n.to_string());
357+
}
358+
}
359+
}
360+
_ => {}
361+
}
362+
let mut kind = s.kind.clone();
363+
for b in kind.bodies_mut() {
364+
wordy(b, out);
365+
}
366+
}
367+
}
368+
let mut copy = program.clone();
369+
let mut by_body: std::collections::HashMap<usize, std::collections::BTreeSet<String>> =
370+
std::collections::HashMap::new();
371+
let mut stores = std::collections::BTreeSet::new();
372+
for (i, body) in bodies(&mut copy) {
373+
let mut names = std::collections::BTreeSet::new();
374+
wordy(body, &mut names);
375+
by_body.insert(i, names);
376+
}
377+
for (i, d) in program.declarations.iter().enumerate() {
378+
if let Declaration::Store(st) = d {
379+
for n in by_body.get(&i).into_iter().flatten() {
380+
stores.insert(format!("{}.{n}", st.name));
381+
}
382+
}
383+
}
384+
let unnamed = |x: &Expr, i: usize| -> Option<String> {
385+
match x {
386+
Expr::Identifier(n) if by_body.get(&i).is_some_and(|s| s.contains(n)) => {
387+
Some(n.clone())
388+
}
389+
Expr::PropertyAccess(base, m) | Expr::OptionalProperty(base, m) => match &**base {
390+
Expr::Identifier(st) if stores.contains(&format!("{st}.{m}")) => {
391+
Some(format!("{st}.{m}"))
392+
}
393+
_ => None,
394+
},
395+
_ => None,
396+
}
397+
};
398+
let mut out = Vec::new();
399+
each_text(&mut copy, &mut |e, i, span| {
400+
let why = match e {
401+
Expr::InterpolatedString(parts) => {
402+
let words = parts
403+
.iter()
404+
.any(|p| matches!(p, StringPart::Literal(t) if prose(t)));
405+
parts.iter().find_map(|p| match p {
406+
StringPart::Expression(x) if words && name_of(x).is_none() => {
407+
Some(Untranslated::Splices(crate::print::expr(x)))
408+
}
409+
StringPart::Expression(x) => unnamed(x, i).map(Untranslated::Unnamed),
410+
_ => None,
411+
})
412+
}
413+
other => unnamed(other, i).map(Untranslated::Unnamed),
414+
};
415+
if let Some(why) = why {
416+
out.push((file_of(i), span, why));
417+
}
418+
});
419+
out.sort_by(|a, b| (&a.0, a.1.line, a.1.col).cmp(&(&b.0, b.1.line, b.1.col)));
420+
out.dedup();
421+
out
422+
}
423+
274424
/// Every string a reader sees, as the `t()` call it stands for.
275425
pub fn translate(program: &mut Program) {
276426
each_text(program, &mut |e, _, _| {
@@ -380,6 +530,56 @@ mod tests {
380530
assert_eq!(k, "{name} and {name2}");
381531
}
382532

533+
#[test]
534+
fn a_path_with_its_fallback_is_named_by_the_path() {
535+
let (key, args) = key_of(&expr("\"Delete {item?.name ?? \\\"\\\"}?\"")).unwrap();
536+
assert_eq!(key, "Delete {name}?");
537+
assert_eq!(crate::print::expr(&args[0].1), "item?.name ?? \"\"");
538+
assert!(
539+
key_of(&expr("\"{a ?? b} left\"")).is_none(),
540+
"a fallback that is not a value"
541+
);
542+
}
543+
544+
#[test]
545+
fn words_written_in_an_action_to_a_value_named_as_words_are_looked_up() {
546+
let mut p = page(
547+
"store S {\n state wroteNote = \"\"\n state kind = \"\"\n action save(n: Number) {\n wroteNote = \"Wrote {n} changes.\"\n kind = \"Kept as it is\"\n }\n}\n",
548+
);
549+
translate(&mut p);
550+
let Declaration::Store(st) = &p.declarations[0] else {
551+
panic!()
552+
};
553+
let out = format!("{:?}", st.body);
554+
assert!(out.contains("Wrote {n} changes."), "{out}");
555+
assert!(
556+
out.contains("FunctionCall(\"t\", [StringLiteral(\"Wrote {n} changes.\")"),
557+
"{out}"
558+
);
559+
assert!(
560+
!out.contains("FunctionCall(\"t\", [StringLiteral(\"Kept as it is\")"),
561+
"{out}"
562+
);
563+
}
564+
565+
#[test]
566+
fn words_no_table_can_translate_are_found_where_they_are_shown() {
567+
let p = page(
568+
"page P(path: \"/\") {\n state n = 2\n derived said = \"{n} errors\"\n derived saidText = \"{n} errors\"\n derived dir = if n > 1 { \"rtl\" } else { \"ltr\" }\n Text(said)\n Text(saidText)\n Text(dir)\n Text(\"Found {n + 1} more\")\n Text(\"{n + 1}:{n}\")\n}\n",
569+
);
570+
let found: Vec<(u32, Untranslated)> = untranslated(&p, &|_| "src/pages/p.wf".into())
571+
.into_iter()
572+
.map(|(_, span, why)| (span.line, why))
573+
.collect();
574+
assert_eq!(
575+
found,
576+
[
577+
(6, Untranslated::Unnamed("said".into())),
578+
(9, Untranslated::Splices("n + 1".into())),
579+
]
580+
);
581+
}
582+
383583
#[test]
384584
fn the_branches_a_text_chooses_between_are_each_looked_up() {
385585
let mut p = page(

‎src/linter/project.rs‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,29 @@ pub fn translations(
458458
);
459459
}
460460
}
461+
// I05: words a reader sees that no table can translate.
462+
if config.literals {
463+
use crate::i18n::literals::Untranslated;
464+
for (file, span, why) in crate::i18n::literals::untranslated(program, file_of) {
465+
let (message, hint) = match why {
466+
Untranslated::Splices(x) => (
467+
format!("this text is not translated: it splices `{x}`, which is more than a name"),
468+
"Give it a name — a `derived` — and splice that name; the table keys a text by the names it splices".to_string(),
469+
),
470+
Untranslated::Unnamed(n) => {
471+
let last = n.rsplit('.').next().unwrap_or(&n).to_string();
472+
(
473+
format!("`{n}` is shown, but its words are not translated: only a value whose name says it is words is looked up"),
474+
format!("Rename it `{last}Text`, or end its name in Label, Title, Word, Note, Message …"),
475+
)
476+
}
477+
};
478+
out.push(
479+
Diagnostic::coded("I05", message, &file, span.line as usize, span.col as usize)
480+
.with_hint(hint),
481+
);
482+
}
483+
}
461484
out
462485
}
463486

‎tests/docs_parse.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1173,7 +1173,7 @@ fn every_diagnostic_example_draws_its_code() {
11731173
/// program — its config, its scripts, its output mode — can show.
11741174
const EXAMPLE_EXEMPT: &[&str] = &[
11751175
"E003", "E005", "E108", "E109", "E110", "E111", "E112", "E115", "E901", "E902", "D05", "D01",
1176-
"D04", "I01", "I02", "I03", "I04", "A13", "V02",
1176+
"D04", "I01", "I02", "I03", "I04", "I05", "A13", "V02",
11771177
];
11781178

11791179
/// The anchor a heading gets on the site and on GitHub, as the generator

0 commit comments

Comments
 (0)