1use 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
46pub const MARKER: &str = "check-text: not player text";
48
49pub const NAME_MARKER: &str = "check-text: not a data name";
52
53#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct Hit {
56 pub file: String,
58 pub line: usize,
60 pub text: String,
62}
63
64pub 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
82pub 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
103fn name_error(told: &str, hit: &Hit) -> String {
105 format!(
106 "{}:{}: `{}` is read here — {told}",
107 hit.file, hit.line, hit.text
108 )
109}
110
111pub 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
149fn 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#[derive(Clone, Copy, PartialEq, Eq)]
164enum Bracket {
165 Sink,
167 Key,
169 Quiet,
171 Call,
173 Other,
175}
176
177impl Bracket {
178 fn decides(self) -> bool {
180 matches!(self, Bracket::Sink | Bracket::Key | Bracket::Quiet)
181 }
182}
183
184const QUIET: [&str; 15] = [
187 "panic",
188 "unreachable",
189 "unimplemented",
190 "todo",
191 "expect",
192 "expect_err",
193 "println",
194 "eprintln",
195 "dbg",
196 "log",
198 "allow",
200 "warn",
201 "deny",
202 "cfg",
203 "cfg_attr",
204];
205
206fn 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
235fn 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
249fn is_prose(text: &str) -> bool {
252 text.chars().any(|c| c.is_uppercase() || c == ' ')
253}
254
255fn is_bare_word(text: &str) -> bool {
258 !text.is_empty() && text.chars().all(char::is_lowercase)
259}
260
261fn 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
274fn 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
294fn counts(text: &str, open: &[Bracket], in_const: bool, name: bool) -> bool {
299 if !has_word(text) {
300 return false;
301 }
302 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 let argument = open.last() == Some(&Bracket::Call);
315 is_bare_word(text) && !in_const && !argument && !name
316}
317
318pub 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 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 if is_ident(c) || c == ':' {
363 path.push(c);
364 } else if c != '!' {
365 path.clear();
366 }
367 }
368 }
369 hits
370}
371
372pub 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 let _ = writeln!(out, "{file}: {count}");
386 }
387 let _ = writeln!(out, " {}: {:?}", hit.line, hit.text);
388 }
389 out
390}
391
392pub 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 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 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 assert_eq!(run_names(&rules(), &root).len(), 1);
504 }
505
506 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 #[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 #[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 #[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 #[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 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 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 let (hits, errors) = run(&rules(), &root);
971 assert!(hits.is_empty(), "{hits:?}");
972 assert_eq!(errors.len(), 1, "{errors:?}");
973 }
974}