Skip to main content

shuyi_cli/
check_text.rs

1//! `check-text`: counts the player-facing text still written as string
2//! literals in the screens' source ([`CheckText::dir`]), instead of asked
3//! for by key with `ctx.text("screen.thing")` from the game's language
4//! file. It fails when the count is above [`CheckText::max_literals`].
5//!
6//! Like `check-keys` it is a line-based scanner, not a parser, built on
7//! the same lexer. A string literal counts when it holds a word (letters
8//! outside `{…}` placeholders) and is
9//!
10//! - an argument of a call that puts text on the screen (`print…`,
11//!   `draw…`, `MenuItem::new`, `MenuItem::disabled`, `with_suffix`,
12//!   `help_line`), directly or inside a `format!` there; or
13//! - prose, wherever it stands: it has a capital letter or a space
14//!   (`format!("Turn {n}")` kept in a variable, `"out of range".to_owned()`,
15//!   a `&str` constant). Cue names, storage keys and screen names
16//!   (`"menu_tick"`) don't; or
17//! - one lowercase word (`"acted"`, `"info"`) that is the value of a
18//!   `let`, a `match` arm or an `if`, or stands in a tuple or an array: a
19//!   label. As a constant, a call's own argument (`Some("heal")`), a
20//!   pattern (`"foot" =>`) or a value's name (`("count", &n)`) it is an id.
21//!
22//! The arguments of `text(…)`, `text_with(…)`, `MenuItem::keyed(…)` and
23//! `Label::key(…)` are keys and value names, never text; those of an assertion, a panic or `expect` are never shown,
24//! and neither is an attribute's (`#[error("…")]`). Tests, the test
25//! harness, the snapshots' text form and the debug screens are skipped.
26//! A literal the rules get wrong is let through with a
27//! `// check-text: not player text` comment, with the reason, on its line
28//! or the line above its item.
29//!
30//! It also fails on a screen that reads a `name` field itself: the data's
31//! names are English, and a screen shows one through the game's helpers
32//! ([`CheckText::words_file`]), which give it in the player's language. Reading `.name` (not calling `.name()`) on anything
33//! but `self` counts; a view's own field is let through with a
34//! `// check-text: not a data name` comment.
35
36use std::cmp::Ordering;
37use std::fmt::Write as _;
38use std::fs;
39use std::path::Path;
40
41use crate::check_keys::{
42    Line, exempt_lines, files_under, has_ext, is_ident, is_test_file, lex, mark_item, relative,
43};
44use crate::project::CheckText;
45
46/// The comment that lets the next item (or its own line) through.
47pub const MARKER: &str = "check-text: not player text";
48
49/// The comment that lets a `.name` read on its line (or the item below
50/// it) through.
51pub const NAME_MARKER: &str = "check-text: not a data name";
52
53/// One literal found.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct Hit {
56    /// Repo-relative path of its file.
57    pub file: String,
58    /// Its 1-based line.
59    pub line: usize,
60    /// The literal's text.
61    pub text: String,
62}
63
64/// Scans the project at `root` and returns every literal found, by file
65/// then line, and every file that couldn't be read.
66pub fn run(rules: &CheckText, root: &Path) -> (Vec<Hit>, Vec<String>) {
67    let mut errors = Vec::new();
68    let mut hits = Vec::new();
69    for file in files_under(&root.join(&rules.dir), &mut errors) {
70        let rel = relative(root, &file);
71        if !has_ext(&file, "rs") || is_skipped(rules, &rel) {
72            continue;
73        }
74        match fs::read_to_string(&file) {
75            Ok(source) => hits.extend(scan(&rel, &source)),
76            Err(e) => errors.push(format!("{rel}: cannot read: {e}")),
77        }
78    }
79    (hits, errors)
80}
81
82/// Scans the project at `root` and returns what is wrong, one line each:
83/// every `.name` field read found ([`scan_names`]), by file then line, and
84/// every file that couldn't be read. Empty when the screens read none.
85pub fn run_names(rules: &CheckText, root: &Path) -> Vec<String> {
86    let mut errors = Vec::new();
87    let mut found = Vec::new();
88    for file in files_under(&root.join(&rules.dir), &mut errors) {
89        let rel = relative(root, &file);
90        if !has_ext(&file, "rs") || is_skipped(rules, &rel) || rel == rules.words_file {
91            continue;
92        }
93        let error = |hit: &Hit| name_error(&rules.name_hint, hit);
94        match fs::read_to_string(&file) {
95            Ok(source) => found.extend(scan_names(&rel, &source).iter().map(error)),
96            Err(e) => errors.push(format!("{rel}: cannot read: {e}")),
97        }
98    }
99    found.extend(errors);
100    found
101}
102
103/// What the `.name` read `hit` is told.
104fn name_error(told: &str, hit: &Hit) -> String {
105    format!(
106        "{}:{}: `{}` is read here — {told}",
107        hit.file, hit.line, hit.text
108    )
109}
110
111/// The `.name` field reads of one Rust file (`rel` is its repo-relative
112/// path, for the report): `.name` not followed by `(`, on anything but
113/// `self`, outside tests and lines let through by [`NAME_MARKER`]. A hit's
114/// text is what was read (`unit.name`).
115pub fn scan_names(rel: &str, source: &str) -> Vec<Hit> {
116    const FIELD: &str = ".name";
117    let lines = lex(source);
118    let exempt = exempt_lines(&lines, Some(NAME_MARKER));
119    let mut hits = Vec::new();
120    for (i, line) in lines.iter().enumerate() {
121        if exempt.get(i).copied().unwrap_or(false) {
122            continue;
123        }
124        for (at, _) in line.code.match_indices(FIELD) {
125            let after = line.code[at + FIELD.len()..].chars().next();
126            if after.is_some_and(|c| is_ident(c) || c == '(') {
127                continue;
128            }
129            let before = &line.code[..at];
130            let start = before
131                .char_indices()
132                .rev()
133                .take_while(|&(_, c)| is_ident(c))
134                .last()
135                .map_or(at, |(start, _)| start);
136            let receiver = &before[start..];
137            if receiver != "self" {
138                hits.push(Hit {
139                    file: rel.to_owned(),
140                    line: i + 1,
141                    text: format!("{receiver}{FIELD}"),
142                });
143            }
144        }
145    }
146    hits
147}
148
149/// Whether the file `rel` is left out: tests and the files the rules
150/// skip (a path ending in `/` skips a folder).
151fn is_skipped(rules: &CheckText, rel: &str) -> bool {
152    is_test_file(rel, &rules.test_support)
153        || rules.skip.iter().any(|skip| {
154            if skip.ends_with('/') {
155                rel.starts_with(skip.as_str())
156            } else {
157                rel == skip
158            }
159        })
160}
161
162/// What an open bracket belongs to.
163#[derive(Clone, Copy, PartialEq, Eq)]
164enum Bracket {
165    /// A call that puts its text on the screen.
166    Sink,
167    /// `text(…)` or `text_with(…)`: keys and value names.
168    Key,
169    /// A call whose text no player reads: an assertion, a panic, a log.
170    Quiet,
171    /// Any other call or macro (`format!(…)`, `Some(…)`).
172    Call,
173    /// Anything else: a tuple, an array, a block.
174    Other,
175}
176
177impl Bracket {
178    /// Whether it says what the literals inside it are, however deep.
179    fn decides(self) -> bool {
180        matches!(self, Bracket::Sink | Bracket::Key | Bracket::Quiet)
181    }
182}
183
184/// The calls and macros whose text no player reads, by name; anything
185/// starting with `assert` or `debug_assert` is one too.
186const QUIET: [&str; 15] = [
187    "panic",
188    "unreachable",
189    "unimplemented",
190    "todo",
191    "expect",
192    "expect_err",
193    "println",
194    "eprintln",
195    "dbg",
196    // `Ctx::log(…)`: the log is English, never translated.
197    "log",
198    // Attributes: `#[allow(…, reason = "…")]`.
199    "allow",
200    "warn",
201    "deny",
202    "cfg",
203    "cfg_attr",
204];
205
206/// What the call `path(` is (`path` as written: `MenuItem::new`, `print`).
207fn bracket_of(path: &str) -> Bracket {
208    let name = path.rsplit("::").next().unwrap_or(path);
209    if name == "text"
210        || name == "text_with"
211        || path.ends_with("MenuItem::keyed")
212        || path.ends_with("Label::key")
213    {
214        Bracket::Key
215    } else if name.starts_with("print") && name != "println"
216        || name.starts_with("draw")
217        || name == "help_line"
218        || name == "with_suffix"
219        || path.ends_with("MenuItem::new")
220        || path.ends_with("MenuItem::disabled")
221    {
222        Bracket::Sink
223    } else if QUIET.contains(&name)
224        || name.starts_with("assert")
225        || name.starts_with("debug_assert")
226    {
227        Bracket::Quiet
228    } else if name.is_empty() {
229        Bracket::Other
230    } else {
231        Bracket::Call
232    }
233}
234
235/// Whether `text` holds a word: a letter outside `{…}` placeholders.
236fn has_word(text: &str) -> bool {
237    let mut depth = 0_u32;
238    for c in text.chars() {
239        match c {
240            '{' => depth += 1,
241            '}' => depth = depth.saturating_sub(1),
242            c if depth == 0 && c.is_alphabetic() => return true,
243            _ => {}
244        }
245    }
246    false
247}
248
249/// Whether `text` looks like something a player reads rather than an id:
250/// it has a capital letter or a space.
251fn is_prose(text: &str) -> bool {
252    text.chars().any(|c| c.is_uppercase() || c == ' ')
253}
254
255/// Whether `text` is one plain lowercase word (`acted`, `info`): a label
256/// as likely as an id, told apart by where it stands.
257fn is_bare_word(text: &str) -> bool {
258    !text.is_empty() && text.chars().all(char::is_lowercase)
259}
260
261/// Whether the literal that opens at byte `open` of `code` names
262/// something rather than says it, by what follows it: a pattern
263/// (`"foot" => …`, `"a" | "b"`), or the name of a value in a list made
264/// for `text_with` (`("count", &n)`).
265fn is_name(code: &str, open: usize) -> bool {
266    let rest = code.get(open + 1..).unwrap_or("");
267    let after = rest.find('"').and_then(|close| rest.get(close + 1..));
268    after.is_some_and(|after| {
269        let after = after.trim_start();
270        after.starts_with("=>") || after.starts_with('|') || after.starts_with(", &")
271    })
272}
273
274/// For each line, whether it is part of a `const` or `static` item of
275/// `&str`s.
276fn const_lines(lines: &[Line]) -> Vec<bool> {
277    let mut inside = vec![false; lines.len()];
278    for (i, line) in lines.iter().enumerate() {
279        let code = line.code.trim_start();
280        let code = code.strip_prefix("pub ").unwrap_or(code);
281        let code = match code.strip_prefix("pub(") {
282            Some(rest) => rest.split_once(") ").map_or(code, |(_, rest)| rest),
283            None => code,
284        };
285        let item = code.starts_with("const ") || code.starts_with("static ");
286        let of_str = code.contains("&str") || code.contains("&'static str");
287        if item && of_str {
288            mark_item(lines, i, &mut inside);
289        }
290    }
291    inside
292}
293
294/// Whether the literal `text` counts where it stands: `open` are the
295/// brackets around it (innermost last), `in_const` whether it is in a
296/// `&str` constant, `name` whether it is a pattern or a value's name
297/// ([`is_name`]).
298fn counts(text: &str, open: &[Bracket], in_const: bool, name: bool) -> bool {
299    if !has_word(text) {
300        return false;
301    }
302    // The innermost call that says what its arguments are.
303    match open.iter().rev().find(|b| b.decides()) {
304        Some(Bracket::Sink) => return true,
305        Some(_) => return false,
306        None => {}
307    }
308    if is_prose(text) {
309        return true;
310    }
311    // One lowercase word is an id as a constant, a call's argument, a
312    // pattern or a value's name (`Some("heal")`, `"foot" =>`), and a
313    // label anywhere else: a `let`, a `match` arm, a tuple.
314    let argument = open.last() == Some(&Bracket::Call);
315    is_bare_word(text) && !in_const && !argument && !name
316}
317
318/// Scans one Rust file (`rel` is its repo-relative path, for the report).
319pub fn scan(rel: &str, source: &str) -> Vec<Hit> {
320    let lines = lex(source);
321    let exempt = exempt_lines(&lines, Some(MARKER));
322    let consts = const_lines(&lines);
323    let mut hits = Vec::new();
324    let mut open: Vec<Bracket> = Vec::new();
325    let mut in_string = false;
326    for (i, line) in lines.iter().enumerate() {
327        let skipped = exempt.get(i).copied().unwrap_or(false);
328        let in_const = consts.get(i).copied().unwrap_or(false);
329        // An attribute's text is for a tool: `#[error("…")]`, `#[serde(…)]`.
330        let skipped = skipped || line.code.trim_start().starts_with("#[");
331        let mut strings = line.strings.iter();
332        let mut path = String::new();
333        for (at, c) in line.code.char_indices() {
334            if c == '"' {
335                in_string = !in_string;
336                path.clear();
337                if !in_string {
338                    continue;
339                }
340                let Some(text) = strings.next() else {
341                    continue;
342                };
343                let name = is_name(&line.code, at);
344                if !skipped && counts(text, &open, in_const, name) {
345                    hits.push(Hit {
346                        file: rel.to_owned(),
347                        line: i + 1,
348                        text: text.clone(),
349                    });
350                }
351                continue;
352            }
353            match c {
354                '(' => open.push(bracket_of(&path)),
355                '[' | '{' => open.push(Bracket::Other),
356                ')' | ']' | '}' => {
357                    open.pop();
358                }
359                _ => {}
360            }
361            // A macro's `!` stays part of its name's place: `format!(`.
362            if is_ident(c) || c == ':' {
363                path.push(c);
364            } else if c != '!' {
365                path.clear();
366            }
367        }
368    }
369    hits
370}
371
372/// The report: the count in the folder `dir` against `max`, then the
373/// literals by file.
374pub fn report(dir: &str, hits: &[Hit], max: usize) -> String {
375    let mut out = format!(
376        "check-text: {} player-facing string literal(s) in {dir} (at most {max} allowed)\n",
377        hits.len()
378    );
379    let mut file = "";
380    for hit in hits {
381        if hit.file != file {
382            file = &hit.file;
383            let count = hits.iter().filter(|h| h.file == file).count();
384            // Writing to a `String` can't fail.
385            let _ = writeln!(out, "{file}: {count}");
386        }
387        let _ = writeln!(out, "  {}: {:?}", hit.line, hit.text);
388    }
389    out
390}
391
392/// What the count means against `max`: an error when above it, a note
393/// when below it (the number in the project file should come down too).
394/// `hint` says what to do about a literal.
395pub fn verdict(hint: &str, count: usize, max: usize) -> Result<Option<String>, String> {
396    match count.cmp(&max) {
397        Ordering::Greater => Err(format!(
398            "check-text: {count} literals, over the {max} allowed — {hint}"
399        )),
400        Ordering::Less => Ok(Some(format!(
401            "check-text: {count} literals, under the {max} allowed: lower \
402             `check_text.max_literals` in shuyi.ron to {count}"
403        ))),
404        Ordering::Equal => Ok(None),
405    }
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411
412    const SCREEN: &str = "crates/ui/src/screens/foo.rs";
413
414    const HINT: &str = "put the text in the language file";
415    const NAME_HINT: &str = "show the name through the words helpers";
416
417    /// A game's rules, laid out as Shuyi's first game's are.
418    fn rules() -> CheckText {
419        let ui = |file: &str| format!("crates/ui/src/{file}");
420        CheckText {
421            dir: "crates/ui/src".to_owned(),
422            max_literals: 0,
423            words_file: ui("words.rs"),
424            skip: vec![ui("debug.rs"), ui("debug/"), ui("snapshot.rs")],
425            test_support: vec![ui("harness.rs")],
426            hint: HINT.to_owned(),
427            name_hint: NAME_HINT.to_owned(),
428        }
429    }
430
431    /// The `.name` reads found in `source`, as `line: text`.
432    fn names(source: &str) -> Vec<String> {
433        scan_names(SCREEN, source)
434            .iter()
435            .map(|h| format!("{}: {}", h.line, h.text))
436            .collect()
437    }
438
439    #[test]
440    fn a_name_field_read_is_found_and_a_call_is_not() {
441        let source = "fn draw(&self, unit: &Unit, class: &ClassDef) {
442    print(&unit.name);
443    let n = (class.name.len(), state.unit(id).name.clone());
444    let fine = (screen.name(), self.name.len(), unit.names, unit.name_w, \"x.name\");
445    let label = def.name; // check-text: not a data name
446    // check-text: not a data name
447    let row = Row {
448        text: page.name.clone(),
449    };
450    print(table.items[0].name)
451}
452
453#[cfg(test)]
454mod tests {
455    fn t() { unit.name = String::new(); }
456}
457";
458        assert_eq!(
459            names(source),
460            ["2: unit.name", "3: class.name", "3: .name", "10: .name"]
461        );
462        let hits = scan_names(SCREEN, source);
463        assert_eq!((hits[0].file.as_str(), hits[0].line), (SCREEN, 2));
464    }
465
466    #[test]
467    fn a_name_read_is_told_where_it_is_and_what_to_do() {
468        let hits = scan_names(SCREEN, "fn f() { a.name }\n");
469        assert_eq!(
470            name_error(NAME_HINT, &hits[0]),
471            format!("{SCREEN}:1: `a.name` is read here — {NAME_HINT}")
472        );
473    }
474
475    #[test]
476    fn name_reads_are_looked_for_in_screens_but_not_tests_debug_or_the_helpers() {
477        let root = std::env::temp_dir().join(format!("shuyi-check-names-{}", std::process::id()));
478        let _ = fs::remove_dir_all(&root);
479        let src = root.join("crates/ui/src");
480        fs::create_dir_all(src.join("screens")).unwrap();
481        fs::create_dir_all(src.join("debug")).unwrap();
482        let read = "fn f(u: &Unit) -> &str { &u.name }\n";
483        for file in [
484            "screens/foo.rs",
485            "screens/tests.rs",
486            "debug.rs",
487            "debug/menu.rs",
488            "harness.rs",
489            "words.rs",
490            "screens/notes.txt",
491        ] {
492            fs::write(src.join(file), read).unwrap();
493        }
494        let found = run_names(&rules(), &root);
495        let _ = fs::remove_dir_all(&root);
496        assert_eq!(
497            found,
498            [format!(
499                "crates/ui/src/screens/foo.rs:1: `u.name` is read here — {NAME_HINT}"
500            )]
501        );
502        // A missing tree is reported, not passed over.
503        assert_eq!(run_names(&rules(), &root).len(), 1);
504    }
505
506    /// The literals found in `source`, as `line: text`.
507    fn found(source: &str) -> Vec<String> {
508        scan(SCREEN, source)
509            .iter()
510            .map(|h| format!("{}: {}", h.line, h.text))
511            .collect()
512    }
513
514    #[test]
515    fn literals_passed_to_print_and_the_text_widgets_count() {
516        let source = "\
517fn draw(&self, ctx: &Ctx, buf: &mut GlyphBuffer) {
518    buf.print(1, 2, \"Hello\", fg, bg);
519    buf.print_fg(1, 3, \"again\", fg);
520    print_centred(buf, 4, &format!(\"Turn {n} of {max}\"), fg, bg);
521    let items = vec![MenuItem::new(\"Retry\"), MenuItem::disabled(\"quit\")];
522    let item = MenuItem::new(label).with_suffix(\"(broken)\", UiColor::HpLow);
523    let help = help_line(&[(Some(key), \"select\")]);
524    draw_dialog(buf, \"Notes\", &lines);
525}
526";
527        assert_eq!(
528            found(source),
529            [
530                "2: Hello",
531                "3: again",
532                "4: Turn {n} of {max}",
533                "5: Retry",
534                "5: quit",
535                "6: (broken)",
536                "7: select",
537                "8: Notes",
538            ]
539        );
540        let hits = scan(SCREEN, source);
541        assert_eq!(hits[0].file, SCREEN);
542        assert_eq!(hits[0].line, 2);
543    }
544
545    /// Square brackets and blocks inside a call don't end it, and those
546    /// after it don't reopen it.
547    #[test]
548    fn brackets_and_blocks_are_matched() {
549        let source = "\
550fn draw(&self, buf: &mut GlyphBuffer) {
551    buf.print(xs[0], rows[1], \"After an index\", fg, bg);
552    draw_rows(buf, |b| { b.height() }, \"After a block\");
553    let label = [other(\"not_shown\")];
554    if wide { buf.print(0, 0, \"In a block\", fg, bg); }
555    let after = other(\"nor_this\");
556}
557";
558        assert_eq!(
559            found(source),
560            ["2: After an index", "3: After a block", "5: In a block"]
561        );
562    }
563
564    #[test]
565    fn text_by_key_and_other_calls_do_not_count() {
566        let source = "\
567fn draw(&self, ctx: &Ctx, buf: &mut GlyphBuffer) {
568    print_centred(buf, 4, ctx.text(\"title.new_game\"), fg, bg);
569    buf.print(1, 2, &ctx.text_with(\"battle.turn\", &[(\"count\", &n)]), fg, bg);
570    let label = ctx.text(\"title.quit\");
571    ctx.audio.play_sound(\"menu_tick\");
572    let s = format!(\"{a}/{b} {c:>3}\");
573    buf.print(1, 2, &format!(\"{}/{}\", a, b), fg, bg);
574    buf.print(1, 2, \" · \", fg, bg);
575    buf.print(1, 2, \"\", fg, bg);
576    let x = ctx.text_with(\"save.slot\", &[(\"slot\", &format!(\"No {n}\"))]);
577}
578";
579        assert_eq!(found(source), Vec::<String>::new());
580    }
581
582    /// Ticket 0243: text made first and printed later.
583    #[test]
584    fn a_format_kept_or_returned_counts() {
585        let source = "\
586fn turn_text(turn: u32, limit: Option<u32>) -> String {
587    match limit {
588        Some(limit) => format!(\"Turn {turn}/{limit}\"),
589        None => format!(\"Turn {turn}\"),
590    }
591}
592fn draw(&self, buf: &mut GlyphBuffer) {
593    let ask = format!(\"End turn with {ready} units ready?\");
594    let hold = Some(format!(\"hold {}\", key));
595    let cost = format!(\"{n} dur\");
596    lines.push((format!(\"{who} waited\"), color));
597}
598";
599        assert_eq!(
600            found(source),
601            [
602                "3: Turn {turn}/{limit}",
603                "4: Turn {turn}",
604                "8: End turn with {ready} units ready?",
605                "9: hold {}",
606                "10: {n} dur",
607                "11: {who} waited",
608            ]
609        );
610    }
611
612    /// Ticket 0243: prose counts wherever it stands.
613    #[test]
614    fn prose_outside_a_call_counts() {
615        let source = "\
616fn reason(error: &Error) -> String {
617    match error {
618        Error::Range => \"out of range\".to_owned(),
619        Error::Broken => String::from(\"Weapon broken\"),
620    }
621}
622fn label(self) -> &'static str {
623    if self.mounted { \"Mounted\" } else { \"On foot\" }
624}
625";
626        assert_eq!(
627            found(source),
628            [
629                "3: out of range",
630                "4: Weapon broken",
631                "8: Mounted",
632                "8: On foot"
633            ]
634        );
635    }
636
637    /// Ticket 0243: a help bar's labels kept in a variable, and `match`
638    /// arms and `if`s that give a word.
639    #[test]
640    fn a_lowercase_word_is_a_label_unless_it_is_an_argument_a_pattern_or_a_name() {
641        let source = "\
642fn help(&self, km: HelpKeys) -> String {
643    let info = (Some(key), \"info\");
644    let label = \"attack\";
645    let status = if unit.acted { \"acted\" } else { \"ready\" };
646    let verb = match stat {
647        StatKind::Mov => \"pins\",
648        _ => \"lowers\",
649    };
650    let flags = [(mods.double, \"double\"), (mods.single, \"single\")];
651    help_line(&[info])
652}
653fn ids(&self, movement: &str) -> Option<&'static str> {
654    let cue = Some(\"heal\");
655    let id = ItemId::new(\"potion\");
656    let step = match movement {
657        \"foot\" => \"step_walk\",
658        \"horse\" | \"wyvern\" => \"step_ride\",
659        _ => return None,
660    };
661    let values = [(\"count\", &n as &dyn Display), (\"name\", &name)];
662    let line = ctx.text_with(\"battle.line\", &values);
663    cue
664}
665const SCREEN: &str = \"battle\";
666";
667        assert_eq!(
668            found(source),
669            [
670                "2: info",
671                "3: attack",
672                "4: acted",
673                "4: ready",
674                "6: pins",
675                "7: lowers",
676                "9: double",
677                "9: single",
678            ]
679        );
680    }
681
682    #[test]
683    fn asserts_panics_and_attributes_are_never_shown() {
684        let source = "\
685#[derive(thiserror::Error)]
686pub enum BindError {
687    #[error(\"{0} is a fixed key\")]
688    Reserved(Chord),
689}
690#[serde(rename = \"PlayerKeys\")]
691struct File;
692fn f(x: Option<u8>) -> u8 {
693    assert!(x.is_some(), \"No value here\");
694    debug_assert_eq!(a, b, \"They differ\");
695    let y = x.expect(\"There is one\");
696    if y > 9 { panic!(\"Too big: {y}\"); }
697    if y > 8 { unreachable!(\"Never eight\"); }
698    eprintln!(\"Loaded {y}\");
699    #[allow(clippy::x, reason = \"A reason\")]
700    let z = format!(\"Shown {y}\");
701    y
702}
703";
704        assert_eq!(found(source), ["16: Shown {y}"]);
705    }
706
707    #[test]
708    fn a_line_of_the_log_is_never_shown() {
709        assert!(QUIET.contains(&"log"));
710        let source = "fn update(&mut self, ctx: &mut Ctx) {
711    ctx.log(LogLevel::Warn, LogCategory::Game, \"Lost the save\");
712    self.log(LogLevel::Info, LogCategory::Game, format!(\"Moved to {x}\"));
713    let shown = format!(\"Shown {x}\");
714}
715";
716        assert_eq!(found(source), ["4: Shown {x}"]);
717    }
718
719    #[test]
720    fn what_follows_a_literal_tells_a_name() {
721        for code in [
722            "    \"foot\" => 1,",
723            "    \"a\" | \"b\" => 2,",
724            "[(\"count\", &n)]",
725            "(\"count\" , &n)",
726        ] {
727            let open = code.find('"').unwrap();
728            assert!(is_name(code, open), "{code}");
729        }
730        for code in [
731            "(key, \"info\")",
732            "(\"info\", key)",
733            "let a = \"x\";",
734            "f(\"x\")",
735            "\"never closed",
736            "\"last\"",
737        ] {
738            let open = code.find('"').unwrap();
739            assert!(!is_name(code, open), "{code}");
740        }
741        // The second literal of a line is looked at from its own quote.
742        let code = "(\"count\", &n, \"word\")";
743        assert!(!is_name(code, code.rfind("\"word").unwrap()));
744        assert!(is_bare_word("acted"));
745        for text in ["", "Acted", "two words", "menu_tick", "a1", "x.y"] {
746            assert!(!is_bare_word(text), "{text}");
747        }
748    }
749
750    #[test]
751    fn prose_constants_count_and_ids_do_not() {
752        let source = "\
753pub const TITLE: &str = \"Visions of Shuyi\";
754const QUIT: &str = \"Quit\";
755pub(crate) const NOTE: &'static str = \"no saves yet\";
756static NAMES: [&str; 2] = [
757    \"New Game\",
758    \"load\",
759];
760const CUE: &str = \"menu_tick\";
761pub const LAYOUT_KEY: &str = \"layout\";
762const ROW: i32 = 9;
763const SEPARATOR: &str = \" · \";
764let local = \"Not A Const\";
765";
766        assert_eq!(
767            found(source),
768            [
769                "1: Visions of Shuyi",
770                "2: Quit",
771                "3: no saves yet",
772                "5: New Game",
773                "12: Not A Const",
774            ]
775        );
776    }
777
778    #[test]
779    fn a_literal_over_several_lines_counts_once_on_its_first_line() {
780        let source = "\
781fn f(buf: &mut GlyphBuffer) {
782    buf.print(
783        1,
784        2,
785        \"one \\
786         two\",
787        fg,
788    );
789    buf.print(1, 2, \"after\", fg, bg);
790}
791";
792        assert_eq!(found(source), ["5: one two", "9: after"]);
793    }
794
795    #[test]
796    fn tests_and_marked_items_are_skipped() {
797        let source = "\
798// check-text: not player text
799const LOG: &str = \"Could not save\";
800const SHOWN: &str = \"Could not load\";
801fn f(buf: &mut GlyphBuffer) {
802    buf.print(1, 2, \"dev only\", fg, bg); // check-text: not player text
803}
804
805#[cfg(test)]
806mod tests {
807    const WANT: &str = \"New Game\";
808
809    #[test]
810    fn t() {
811        buf.print(1, 2, \"Hello\", fg, bg);
812    }
813}
814";
815        assert_eq!(found(source), ["3: Could not load"]);
816    }
817
818    #[test]
819    fn words_are_letters_outside_placeholders() {
820        for text in ["a", "{n} left", "é", "Turn {n}"] {
821            assert!(has_word(text), "{text}");
822        }
823        for text in ["", " · ", "{}/{}", "{name}", "{a}{b} 12", "}{x}"] {
824            assert!(!has_word(text), "{text}");
825        }
826        assert!(is_prose("New"));
827        assert!(is_prose("no saves"));
828        assert!(!is_prose("menu_tick"));
829    }
830
831    #[test]
832    fn calls_are_told_apart_by_name() {
833        for sink in [
834            "print",
835            "print_fg",
836            "print_centred",
837            "draw_tip",
838            "map_menu::draw_dialog_at",
839            "help_line",
840            "with_suffix",
841            "MenuItem::new",
842            "widgets::MenuItem::disabled",
843        ] {
844            assert!(bracket_of(sink) == Bracket::Sink, "{sink}");
845        }
846        for key in [
847            "text",
848            "text_with",
849            "Ctx::text",
850            "MenuItem::keyed",
851            "text::Label::key",
852        ] {
853            assert!(bracket_of(key) == Bracket::Key, "{key}");
854        }
855        for quiet in [
856            "panic",
857            "unreachable",
858            "todo",
859            "unimplemented",
860            "expect",
861            "expect_err",
862            "println",
863            "eprintln",
864            "dbg",
865            "allow",
866            "warn",
867            "deny",
868            "cfg",
869            "cfg_attr",
870            "assert",
871            "assert_eq",
872            "debug_assert",
873            "debug_assert_ne",
874            "std::panic",
875        ] {
876            assert!(bracket_of(quiet) == Bracket::Quiet, "{quiet}");
877        }
878        for call in ["new", "Menu::new", "format", "context", "reprint", "Some"] {
879            assert!(bracket_of(call) == Bracket::Call, "{call}");
880        }
881        assert!(bracket_of("") == Bracket::Other);
882        assert!(Bracket::Sink.decides() && Bracket::Key.decides() && Bracket::Quiet.decides());
883        assert!(!Bracket::Call.decides() && !Bracket::Other.decides());
884    }
885
886    #[test]
887    fn a_skipped_file_is_that_file_and_a_skipped_folder_all_below_it() {
888        let rules = rules();
889        for skipped in [
890            "crates/ui/src/debug.rs",
891            "crates/ui/src/debug/menu.rs",
892            "crates/ui/src/snapshot.rs",
893            "crates/ui/src/harness.rs",
894            "crates/ui/src/screens/tests.rs",
895        ] {
896            assert!(is_skipped(&rules, skipped), "{skipped}");
897        }
898        for scanned in [
899            "crates/ui/src/debug.rs.rs",
900            "crates/ui/src/debugger.rs",
901            "crates/ui/src/snapshot.rs/view.rs",
902            "crates/ui/src/screens/title.rs",
903        ] {
904            assert!(!is_skipped(&rules, scanned), "{scanned}");
905        }
906    }
907
908    #[test]
909    fn the_report_lists_the_literals_by_file() {
910        let hit = |file: &str, line, text: &str| Hit {
911            file: file.to_owned(),
912            line,
913            text: text.to_owned(),
914        };
915        let hits = [
916            hit("a.rs", 3, "One"),
917            hit("a.rs", 9, "Two"),
918            hit("b.rs", 1, "x y"),
919        ];
920        assert_eq!(
921            report("crates/ui/src", &hits, 5),
922            "check-text: 3 player-facing string literal(s) in crates/ui/src (at most 5 allowed)\n\
923             a.rs: 2\n  3: \"One\"\n  9: \"Two\"\nb.rs: 1\n  1: \"x y\"\n"
924        );
925    }
926
927    #[test]
928    fn one_literal_too_many_fails() {
929        let before = "fn f(b: &mut GlyphBuffer) {\n    b.print(0, 0, \"Old\", fg, bg);\n}\n";
930        let max = scan(SCREEN, before).len();
931        assert_eq!(max, 1);
932        assert_eq!(verdict(HINT, max, max), Ok(None));
933        let after = format!("{before}const NEW: &str = \"New Game\";\n");
934        let count = scan(SCREEN, &after).len();
935        assert_eq!(count, 2);
936        let error = verdict(HINT, count, max).unwrap_err();
937        assert!(error.contains("2 literals, over the 1 allowed"), "{error}");
938        assert!(error.contains(HINT), "{error}");
939        // Fewer than allowed passes, with a note to lower the number.
940        let note = verdict(HINT, 0, max).unwrap().unwrap();
941        assert!(note.contains("lower `check_text.max_literals`"), "{note}");
942        assert!(note.contains("to 0"), "{note}");
943    }
944
945    #[test]
946    fn run_scans_ui_sources_but_not_tests_or_debug_screens() {
947        let root = std::env::temp_dir().join(format!("shuyi-check-text-{}", std::process::id()));
948        let _ = fs::remove_dir_all(&root);
949        let src = root.join("crates/ui/src");
950        fs::create_dir_all(src.join("screens")).unwrap();
951        fs::create_dir_all(src.join("debug")).unwrap();
952        let literal = "const A: &str = \"Some Text\";\n";
953        for file in [
954            "screens/foo.rs",
955            "screens/tests.rs",
956            "debug.rs",
957            "debug/menu.rs",
958            "harness.rs",
959            "snapshot.rs",
960            "screens/notes.txt",
961        ] {
962            fs::write(src.join(file), literal).unwrap();
963        }
964        let (hits, errors) = run(&rules(), &root);
965        let _ = fs::remove_dir_all(&root);
966        assert_eq!(errors, Vec::<String>::new());
967        assert_eq!(hits.len(), 1, "{hits:?}");
968        assert_eq!(hits[0].file, "crates/ui/src/screens/foo.rs");
969        // A missing tree is reported, not passed over.
970        let (hits, errors) = run(&rules(), &root);
971        assert!(hits.is_empty(), "{hits:?}");
972        assert_eq!(errors.len(), 1, "{errors:?}");
973    }
974}