1use std::fs;
27use std::path::Path;
28
29use shuyi_assets::keymap::{Button, Key};
30
31use crate::project::CheckKeys;
32
33pub const MARKER: &str = "check-keys: keyboard picture";
37
38const KEY_TYPES: [&str; 3] = ["Key::", "Chord::", "Button::"];
40const PLATFORM_READS: [&str; 6] = [
42 "KeyCode",
43 "is_key_down",
44 "is_key_pressed",
45 "get_keys_pressed",
46 "get_char_pressed",
47 "get_last_key_pressed",
48];
49const KEY_WORDS: [&str; 8] = [
54 "Space", "Esc", "Escape", "Enter", "Shift", "arrows", "WASD", "wasd",
55];
56
57pub fn run(rules: &CheckKeys, root: &Path) -> Vec<String> {
60 let mut errors = Vec::new();
61 for dir in &rules.rust {
62 for file in files_under(&root.join(dir), &mut errors) {
63 let rel = relative(root, &file);
64 if has_ext(&file, "rs") && !is_test_file(&rel, &rules.test_support) {
65 read_and(&file, &rel, &mut errors, |rel, source| {
66 scan_rust(rules, rel, source)
67 });
68 }
69 }
70 }
71 for assets in &rules.assets {
72 for file in files_under(&root.join(assets), &mut errors) {
73 let rel = relative(root, &file);
74 if has_ext(&file, "dlg") {
75 read_and(&file, &rel, &mut errors, |rel, source| {
76 scan_dlg(&rules.hint, rel, source)
77 });
78 } else if has_ext(&file, "ron") && !rules.not_text.contains(&rel) {
79 read_and(&file, &rel, &mut errors, |rel, source| {
80 scan_ron(&rules.hint, rel, source)
81 });
82 }
83 }
84 }
85 errors
86}
87
88pub(crate) fn has_ext(file: &Path, ext: &str) -> bool {
90 file.extension()
91 .is_some_and(|e| e.eq_ignore_ascii_case(ext))
92}
93
94fn read_and(
96 file: &Path,
97 rel: &str,
98 errors: &mut Vec<String>,
99 scan: impl Fn(&str, &str) -> Vec<String>,
100) {
101 match fs::read_to_string(file) {
102 Ok(source) => errors.extend(scan(rel, &source)),
103 Err(e) => errors.push(format!("{rel}: cannot read: {e}")),
104 }
105}
106
107pub fn files_under(dir: &Path, errors: &mut Vec<String>) -> Vec<std::path::PathBuf> {
109 let mut out = Vec::new();
110 let mut stack = vec![dir.to_path_buf()];
111 while let Some(d) = stack.pop() {
112 let entries = match fs::read_dir(&d) {
113 Ok(entries) => entries,
114 Err(e) => {
115 errors.push(format!("{}: cannot read directory: {e}", d.display()));
116 continue;
117 }
118 };
119 for entry in entries.flatten() {
120 let path = entry.path();
121 if path.is_dir() {
122 stack.push(path);
123 } else {
124 out.push(path);
125 }
126 }
127 }
128 out.sort();
129 out
130}
131
132pub fn relative(root: &Path, file: &Path) -> String {
134 let rel = file.strip_prefix(root).unwrap_or(file);
135 rel.to_string_lossy().replace('\\', "/")
136}
137
138pub(crate) fn is_test_file(rel: &str, support: &[String]) -> bool {
141 let name = rel.rsplit('/').next().unwrap_or(rel);
142 name == "tests.rs"
143 || name.ends_with("_tests.rs")
144 || rel.split('/').any(|part| part == "tests")
145 || support.iter().any(|file| file == rel)
146}
147
148pub fn scan_rust(rules: &CheckKeys, rel: &str, source: &str) -> Vec<String> {
151 let lines = lex(source);
152 let listed = |files: &[String]| files.iter().any(|file| file == rel);
153 let key_file = listed(&rules.key_files);
154 let picture_file = listed(&rules.picture_files);
155 let hint = &rules.hint;
156 let platform_file = &rules.platform_file;
157 let exempt = exempt_lines(&lines, picture_file.then_some(MARKER));
158 let mut errors = Vec::new();
159 for (i, line) in lines.iter().enumerate() {
160 let n = i + 1;
161 if line.comment.contains(MARKER) && !picture_file {
162 errors.push(format!(
163 "{rel}:{n}: `// {MARKER}` is only allowed in {}",
164 rules.picture_files.join(", ")
165 ));
166 }
167 if exempt.get(i).copied().unwrap_or(false) {
168 continue;
169 }
170 if !key_file {
171 for pattern in KEY_TYPES {
172 if contains_token(&line.code, pattern) {
173 errors.push(format!(
174 "{rel}:{n}: names a key or button with `{pattern}` — {hint}"
175 ));
176 }
177 }
178 }
179 if rel != platform_file {
180 for pattern in PLATFORM_READS {
181 if contains_token(&line.code, pattern) {
182 errors.push(format!(
183 "{rel}:{n}: reads the keyboard with `{pattern}` outside {platform_file} — \
184 {hint}"
185 ));
186 }
187 }
188 }
189 if !key_file {
190 for text in &line.strings {
191 if let Some(why) = names_key(text) {
192 errors.push(format!("{rel}:{n}: text {why}: {text:?} — {hint}"));
193 }
194 }
195 }
196 }
197 errors
198}
199
200pub fn scan_ron(hint: &str, rel: &str, source: &str) -> Vec<String> {
204 let mut errors = Vec::new();
205 for (i, line) in lex(source).iter().enumerate() {
206 for text in &line.strings {
207 if let Some(why) = prose_names_key(text) {
208 errors.push(format!("{rel}:{}: text {why}: {text:?} — {hint}", i + 1));
209 }
210 }
211 }
212 errors
213}
214
215pub fn scan_dlg(hint: &str, rel: &str, source: &str) -> Vec<String> {
218 let mut errors = Vec::new();
219 for (i, line) in source.lines().enumerate() {
220 if line.trim_start().starts_with('#') {
221 continue;
222 }
223 if let Some(why) = prose_names_key(line) {
224 errors.push(format!("{rel}:{}: text {why}: {line:?} — {hint}", i + 1));
225 }
226 }
227 errors
228}
229
230const PRESS_VERBS: [&str; 4] = ["press", "hit", "tap", "hold"];
232
233fn prose_names_key(text: &str) -> Option<String> {
238 let words: Vec<&str> = text.split_whitespace().collect();
239 if let Some(why) = press_instruction(&words, true) {
240 return Some(why);
241 }
242 if text.contains("Shift+") {
243 return Some("names a Shift+ chord".to_owned());
244 }
245 if contains_word(text, "WASD") || contains_word(text, "wasd") {
246 return Some("names the key \"WASD\"".to_owned());
247 }
248 bracketed_letter(text).then(|| "names a key in brackets".to_owned())
249}
250
251fn is_keymap_name(word: &str) -> bool {
257 word.len() >= 2
258 && word.starts_with(|c: char| c.is_ascii_alphabetic())
259 && word.bytes().all(|b| b.is_ascii_alphanumeric())
260 && (Key::from_name(word).is_some() || Button::from_name(word).is_some())
261}
262
263fn press_instruction(words: &[&str], letters: bool) -> Option<String> {
270 for (i, pair) in words.windows(2).enumerate() {
271 let [verb, key] = pair else { continue };
272 let next = words.get(i + 2).copied().unwrap_or_default();
273 let next = next.trim_matches(|c: char| !c.is_alphanumeric());
274 let noun = ["key", "button"]
275 .iter()
276 .any(|n| n.eq_ignore_ascii_case(next));
277 if *key == "a" && noun {
278 continue;
279 }
280 let key = key.trim_end_matches(|c: char| c.is_ascii_punctuation() && c != '+');
281 let is_key = letters && key.len() == 1 && key.bytes().all(|b| b.is_ascii_alphabetic())
282 || KEY_WORDS.iter().any(|w| w.eq_ignore_ascii_case(key))
283 || key.starts_with("Shift+")
284 || is_function_key(key)
285 || is_keymap_name(key);
286 if PRESS_VERBS.iter().any(|v| v.eq_ignore_ascii_case(verb)) && is_key {
287 return Some(format!(
288 "tells the player to {} \"{key}\"",
289 verb.to_lowercase()
290 ));
291 }
292 }
293 None
294}
295
296fn is_function_key(key: &str) -> bool {
298 let Some(n) = key.strip_prefix(['F', 'f']) else {
299 return false;
300 };
301 n.parse::<u8>().is_ok_and(|n| (1..=12).contains(&n))
302}
303
304fn bracketed_letter(text: &str) -> bool {
306 text.as_bytes()
307 .windows(3)
308 .any(|w| w[0] == b'[' && w[1].is_ascii_alphabetic() && w[2] == b']')
309}
310
311fn names_key(text: &str) -> Option<String> {
313 if let Some(word) = KEY_WORDS.iter().find(|w| contains_word(text, w)) {
314 return Some(format!("names the key \"{word}\""));
315 }
316 let words: Vec<&str> = text
317 .split(|c: char| c.is_whitespace() || c == '·')
318 .collect();
319 let letter = |w: &str| w.len() == 1 && w.bytes().all(|b| b.is_ascii_alphabetic());
320 for pair in words.windows(2) {
321 let [first, second] = pair else { continue };
322 if first.eq_ignore_ascii_case("press") && letter(second) {
324 return Some(format!("names the key \"{second}\""));
325 }
326 let lower = |w: &str| w.bytes().next().is_some_and(|b| b.is_ascii_lowercase());
329 if letter(first) && lower(first) && *first != "a" && lower(second) {
330 return Some(format!("names the key \"{first}\""));
331 }
332 }
333 if let Some(why) = press_instruction(&words, false) {
335 return Some(why);
336 }
337 bracketed_letter(text).then(|| "names a key in brackets".to_owned())
339}
340
341fn contains_word(text: &str, word: &str) -> bool {
343 text.match_indices(word).any(|(at, _)| {
344 let before = text[..at].chars().next_back();
345 let after = text[at + word.len()..].chars().next();
346 !before.is_some_and(is_ident) && !after.is_some_and(is_ident)
347 })
348}
349
350fn contains_token(code: &str, pattern: &str) -> bool {
353 code.match_indices(pattern).any(|(at, _)| {
354 let before = code[..at].chars().next_back();
355 let after = code[at + pattern.len()..].chars().next();
356 let ends_in_ident = pattern.chars().next_back().is_some_and(is_ident);
357 !(before.is_some_and(is_ident) || ends_in_ident && after.is_some_and(is_ident))
358 })
359}
360
361pub(crate) fn is_ident(c: char) -> bool {
362 c.is_alphanumeric() || c == '_'
363}
364
365pub(crate) fn exempt_lines(lines: &[Line], marker: Option<&str>) -> Vec<bool> {
368 let mut exempt = vec![false; lines.len()];
369 for (i, line) in lines.iter().enumerate() {
370 let allowed = marker.is_some_and(|m| line.comment.contains(m));
371 if allowed || line.code.contains("#[cfg(test)]") {
372 mark_item(lines, i, &mut exempt);
373 }
374 }
375 exempt
376}
377
378pub(crate) fn mark_item(lines: &[Line], start: usize, exempt: &mut [bool]) {
384 let mut depth: i64 = 0;
385 for (i, line) in lines.iter().enumerate().skip(start) {
386 if let Some(e) = exempt.get_mut(i) {
387 *e = true;
388 }
389 let code = line.code.trim();
390 for c in code.chars() {
391 match c {
392 '(' | '[' | '{' => depth += 1,
393 ')' | ']' | '}' => depth -= 1,
394 _ => {}
395 }
396 }
397 if depth <= 0 && code.ends_with([';', '}', ',']) {
398 return;
399 }
400 }
401}
402
403#[derive(Debug, Default, Clone, PartialEq, Eq)]
405pub(crate) struct Line {
406 pub(crate) code: String,
409 pub(crate) comment: String,
411 pub(crate) strings: Vec<String>,
413}
414
415#[derive(Clone, Copy)]
417enum State {
418 Code,
419 LineComment,
420 BlockComment(u32),
422 Str,
424 RawStr(usize),
426}
427
428pub(crate) fn lex(source: &str) -> Vec<Line> {
432 let mut lexer = Lexer {
433 chars: source.chars().collect(),
434 lines: vec![Line::default()],
435 state: State::Code,
436 literal: String::new(),
437 literal_line: 0,
438 skip_ws: false,
439 };
440 let mut i = 0;
441 for _ in 0..=lexer.chars.len() {
444 let Some(c) = lexer.at(i) else { break };
445 i += if c == '\n' {
446 lexer.newline();
447 1
448 } else {
449 match lexer.state {
450 State::Code => lexer.code(i, c),
451 State::LineComment => {
452 lexer.line().comment.push(c);
453 1
454 }
455 State::BlockComment(depth) => lexer.block_comment(i, c, depth),
456 State::Str => lexer.string(i, c),
457 State::RawStr(hashes) => lexer.raw_string(i, c, hashes),
458 }
459 };
460 }
461 lexer.lines
462}
463
464struct Lexer {
466 chars: Vec<char>,
467 lines: Vec<Line>,
469 state: State,
470 literal: String,
472 literal_line: usize,
473 skip_ws: bool,
475}
476
477impl Lexer {
478 fn at(&self, i: usize) -> Option<char> {
479 self.chars.get(i).copied()
480 }
481
482 fn line(&mut self) -> &mut Line {
484 let last = self.lines.len() - 1;
485 &mut self.lines[last]
486 }
487
488 fn newline(&mut self) {
489 match self.state {
490 State::LineComment => self.state = State::Code,
491 State::Str | State::RawStr(_) if !self.skip_ws => self.literal.push('\n'),
492 _ => {}
493 }
494 self.lines.push(Line::default());
495 }
496
497 fn open(&mut self, state: State) {
499 self.line().code.push('"');
500 self.state = state;
501 self.literal.clear();
502 self.literal_line = self.lines.len() - 1;
503 }
504
505 fn close(&mut self) {
507 self.line().code.push('"');
508 self.state = State::Code;
509 let text = std::mem::take(&mut self.literal);
510 if let Some(line) = self.lines.get_mut(self.literal_line) {
511 line.strings.push(text);
512 }
513 }
514
515 fn code(&mut self, i: usize, c: char) -> usize {
517 let next = self.at(i + 1);
518 if c == '/' && next == Some('/') {
519 self.state = State::LineComment;
520 return 2;
521 }
522 if c == '/' && next == Some('*') {
523 self.state = State::BlockComment(1);
524 return 2;
525 }
526 if c == '"' {
527 self.open(State::Str);
528 return 1;
529 }
530 let before = |k: usize| i.checked_sub(k).and_then(|j| self.at(j));
532 let raw_start = !before(1).is_some_and(is_ident)
533 || before(1) == Some('b') && !before(2).is_some_and(is_ident);
534 if c == 'r' && raw_start {
535 let hashes = self
536 .chars
537 .get(i + 1..)
538 .map_or(0, |rest| rest.iter().take_while(|&&h| h == '#').count());
539 if self.at(i + 1 + hashes) == Some('"') {
540 self.open(State::RawStr(hashes));
541 return 2 + hashes;
542 }
543 }
544 if c == '\'' {
545 let end = if next == Some('\\') {
548 (i + 3..self.chars.len()).find(|&j| self.at(j) == Some('\''))
549 } else if self.at(i + 2) == Some('\'') {
550 Some(i + 2)
551 } else {
552 None
553 };
554 if let Some(end) = end {
555 self.line().code.push_str("''");
556 return end + 1 - i;
557 }
558 }
559 self.line().code.push(c);
560 1
561 }
562
563 fn block_comment(&mut self, i: usize, c: char, depth: u32) -> usize {
565 let next = self.at(i + 1);
566 if c == '*' && next == Some('/') {
567 self.state = if depth == 1 {
568 State::Code
569 } else {
570 State::BlockComment(depth - 1)
571 };
572 2
573 } else if c == '/' && next == Some('*') {
574 self.state = State::BlockComment(depth + 1);
575 2
576 } else {
577 self.line().comment.push(c);
578 1
579 }
580 }
581
582 fn string(&mut self, i: usize, c: char) -> usize {
584 if self.skip_ws && c.is_whitespace() {
585 return 1;
586 }
587 self.skip_ws = false;
588 match c {
589 '\\' => match self.at(i + 1) {
592 Some('\n') => {
593 self.skip_ws = true;
594 1
595 }
596 Some(e) => {
597 self.literal.push(match e {
598 'n' => '\n',
599 't' => '\t',
600 other => other,
601 });
602 2
603 }
604 None => 1,
605 },
606 '"' => {
607 self.close();
608 1
609 }
610 _ => {
611 self.literal.push(c);
612 1
613 }
614 }
615 }
616
617 fn raw_string(&mut self, i: usize, c: char, hashes: usize) -> usize {
619 if c == '"' && (1..=hashes).all(|k| self.at(i + k) == Some('#')) {
620 self.close();
621 1 + hashes
622 } else {
623 self.literal.push(c);
624 1
625 }
626 }
627}
628
629#[cfg(test)]
630mod tests {
631 use super::*;
632
633 pub(crate) fn rules() -> CheckKeys {
635 let strings = |items: &[&str]| items.iter().map(|s| (*s).to_owned()).collect();
636 CheckKeys {
637 rust: strings(&[
638 "crates/ui/src",
639 "crates/app/src",
640 "crates/content/src",
641 "crates/playdate/src",
642 ]),
643 assets: strings(&["story", "test-files"]),
644 not_text: strings(&["story/data/keymap.ron"]),
645 key_files: strings(&[
646 "crates/content/src/keymap.rs",
647 "crates/ui/src/input.rs",
648 "crates/playdate/src/pads.rs",
649 ]),
650 platform_file: "crates/app/src/keys.rs".to_owned(),
651 test_support: strings(&["crates/ui/src/harness.rs"]),
652 picture_files: strings(&[
653 "crates/ui/src/screens/layout_picker.rs",
654 "crates/ui/src/screens/layout_picker/glyph.rs",
655 ]),
656 hint: "use an Action and `widgets::help::key_name`; see the keyboard-input skill"
657 .to_owned(),
658 }
659 }
660
661 fn rust(rel: &str, src: &str) -> Vec<String> {
662 scan_rust(&rules(), rel, src)
663 }
664
665 const SCREEN: &str = "crates/ui/src/screens/foo.rs";
666
667 const HINT: &str = "use an Action";
668
669 #[test]
670 fn key_in_screen_code_is_an_error_naming_file_line_and_fix() {
671 let errs = rust(SCREEN, "fn f(k: Key) -> bool {\n k == Key::F\n}\n");
672 assert_eq!(errs.len(), 1, "{errs:?}");
673 assert!(
674 errs[0].starts_with("crates/ui/src/screens/foo.rs:2: "),
675 "{}",
676 errs[0]
677 );
678 assert!(errs[0].contains("`Key::`"), "{}", errs[0]);
679 assert!(
680 errs[0].contains(
681 "use an Action and `widgets::help::key_name`; see the keyboard-input skill"
682 ),
683 "{}",
684 errs[0]
685 );
686 }
687
688 #[test]
689 fn press_f_in_help_text_is_an_error() {
690 let errs = rust(
691 SCREEN,
692 "fn help() -> &'static str {\n \"press f to go on\"\n}\n",
693 );
694 assert_eq!(errs.len(), 1, "{errs:?}");
695 assert!(errs[0].starts_with("crates/ui/src/screens/foo.rs:2: text names the key \"f\""));
696 }
697
698 #[test]
699 fn chords_and_macroquad_reads_are_errors() {
700 let src = "let a = Chord::plain(k);\nlet b = is_key_pressed(KeyCode::F);\n";
701 let errs = rust("crates/app/src/main.rs", src);
702 assert_eq!(errs.len(), 3, "{errs:?}");
703 assert!(errs[0].contains("`Chord::`"));
704 assert!(errs[1].contains("`KeyCode`"));
705 assert!(errs[2].contains("`is_key_pressed`"));
706 }
707
708 #[test]
709 fn key_pipeline_files_are_allowed() {
710 let src = "let a = Chord::plain(Key::F);\nlet s = \"Space\";\n";
711 assert_eq!(rust("crates/ui/src/input.rs", src), Vec::<String>::new());
712 assert_eq!(
713 rust("crates/content/src/keymap.rs", src),
714 Vec::<String>::new()
715 );
716 let read = "let d = is_key_down(KeyCode::A);\n";
718 assert_eq!(rust(&rules().platform_file, read), Vec::<String>::new());
719 assert_eq!(rust("crates/ui/src/input.rs", read).len(), 2);
720 let errs = rust("crates/app/src/main.rs", read);
721 assert_eq!(errs.len(), 2, "{errs:?}");
722 assert!(errs[1].contains("outside crates/app/src/keys.rs"));
723 }
724
725 #[test]
726 fn controller_buttons_are_named_only_in_the_key_pipeline() {
727 let src = "if b == Button::South {\n confirm();\n}\n";
729 let errs = rust(SCREEN, src);
730 assert_eq!(errs.len(), 1, "{errs:?}");
731 assert!(
732 errs[0].starts_with(
733 "crates/ui/src/screens/foo.rs:1: names a key or button with `Button::`"
734 ),
735 "{}",
736 errs[0]
737 );
738 assert_eq!(rust("crates/app/src/main.rs", src).len(), 1);
739 let pipeline = "let a = (gilrs::Button::South, Button::South, Key::F);\n";
741 for file in [
742 "crates/content/src/keymap.rs",
743 "crates/ui/src/input.rs",
744 "crates/playdate/src/pads.rs",
745 ] {
746 assert_eq!(rust(file, pipeline), Vec::<String>::new(), "{file}");
747 }
748 assert_eq!(rust("crates/app/src/pads.rs", pipeline).len(), 2);
751 assert_eq!(rust("crates/app/src/pads/native.rs", pipeline).len(), 2);
752 assert_eq!(rust("crates/playdate/src/pd/entry.rs", pipeline).len(), 2);
753 let other = "let m = MouseButton::Left;\nlet b = Button;\nfn f(b: Button) {}\n";
755 assert_eq!(rust(SCREEN, other), Vec::<String>::new());
756 }
757
758 #[test]
759 fn test_files_are_skipped() {
760 let support = rules().test_support;
761 let is_test_file = |support: &[String], rel: &str| is_test_file(rel, support);
762 assert!(is_test_file(
763 &support,
764 "crates/ui/src/screens/battle/tests.rs"
765 ));
766 assert!(is_test_file(
767 &support,
768 "crates/ui/src/screens/battle/mode_tests.rs"
769 ));
770 assert!(is_test_file(&support, "crates/ui/src/tests/helpers.rs"));
771 assert!(is_test_file(&support, "crates/ui/src/harness.rs"));
772 assert!(!is_test_file(
773 &support,
774 "crates/ui/src/screens/battle/mod.rs"
775 ));
776 assert!(!is_test_file(&support, "crates/ui/src/contests.rs"));
777 }
778
779 #[test]
780 fn cfg_test_modules_are_skipped() {
781 let src = "\
782fn real() {}
783
784#[cfg(test)]
785mod tests {
786 use super::*;
787
788 #[test]
789 fn f() {
790 tap(Key::F);
791 assert_eq!(help(), \"f select\");
792 }
793}
794
795fn after() { let _ = Key::D; }
796";
797 let errs = rust(SCREEN, src);
798 assert_eq!(errs.len(), 1, "{errs:?}");
799 assert!(
800 errs[0].starts_with("crates/ui/src/screens/foo.rs:14: "),
801 "{}",
802 errs[0]
803 );
804 }
805
806 #[test]
807 fn cfg_test_single_items_and_extra_attributes_are_skipped() {
808 let src = "\
809#[cfg(test)]
810use game_content::Key;
811#[cfg(test)]
812#[allow(dead_code)]
813const K: Key = Key::F;
814#[cfg(test)]
815fn helper(
816 k: Key,
817) -> Chord {
818 Chord::plain(k)
819}
820const L: Key = Key::J;
821";
822 let errs = rust(SCREEN, src);
823 assert_eq!(errs.len(), 1, "{errs:?}");
824 assert!(
825 errs[0].starts_with("crates/ui/src/screens/foo.rs:12: "),
826 "{}",
827 errs[0]
828 );
829 }
830
831 #[test]
832 fn marker_allows_the_next_item_in_the_picture_file_only() {
833 let src = "\
834// check-keys: keyboard picture
835const ROW: [Key; 2] = [
836 Key::Q,
837 Key::W,
838];
839const UP: Key = Key::Up; // check-keys: keyboard picture
840const DOWN: Key = Key::Down;
841";
842 for picture in &rules().picture_files {
843 let errs = rust(picture, src);
844 assert_eq!(errs.len(), 1, "{picture}: {errs:?}");
845 assert!(
846 errs[0].starts_with(&format!("{picture}:7: ")),
847 "{}",
848 errs[0]
849 );
850 }
851 let errs = rust(SCREEN, src);
853 let markers = errs
854 .iter()
855 .filter(|e| e.contains("is only allowed in"))
856 .count();
857 assert_eq!(markers, 2, "{errs:?}");
858 assert_eq!(errs.len(), 2 + 4, "{errs:?}");
859 }
860
861 #[test]
862 fn comments_and_similar_identifiers_are_not_hits() {
863 let src = "\
864/// Press the Confirm key (`Key::F` by default) to go on.
865// Space is the End turn key.
866/* Key::D, \"press d\" */
867let a = RawKey::Down;
868let b = my_is_key_down(x);
869let c = LAYOUT_KEY;
870let d = is_key_down_fast(x);
871";
872 assert_eq!(rust(SCREEN, src), Vec::<String>::new());
873 }
874
875 #[test]
876 fn strings_that_name_keys() {
877 for text in [
878 "f select · d back",
879 "arrows move",
880 "wasd move",
881 "Space end turn",
882 "Press F to continue",
883 "hit [F]",
884 "Shift+Space auto-end",
885 "Esc",
886 "Enter to confirm",
887 "Coming soon — press d to go back",
888 ] {
889 assert!(names_key(text).is_some(), "{text}");
890 }
891 for text in [
892 "E ",
893 "a spell",
894 "Battle Theme B for RPG",
895 "I think so",
896 "Iron Sword",
897 "Weapons",
898 "{Confirm} select · {Cancel} back",
899 "{Cursor} move",
900 "spacesuit",
901 "Entering the keep",
902 "x",
903 ] {
904 assert_eq!(names_key(text), None, "{text}");
905 }
906 }
907
908 #[test]
909 fn ron_strings_and_dlg_lines_are_scanned() {
910 let ron = "(\n // press f here is a comment\n text: \"Press {Confirm} to go on\",\n bad: \"Press f to go on\",\n bow: \"Fires arrows. Escape is harder.\",\n art: \"Shift the odds: Enter a stance.\",\n)";
911 let errs = scan_ron(HINT, "story/data/tips.ron", ron);
912 assert_eq!(errs.len(), 1, "{errs:?}");
914 assert!(
915 errs[0].starts_with("story/data/tips.ron:4: "),
916 "{}",
917 errs[0]
918 );
919
920 for ok in ["Press a key…", "Hold a button.", "PRESS a KEY…"] {
923 assert_eq!(
924 scan_ron(HINT, "x.ron", &format!("[\"{ok}\"]")),
925 [""; 0],
926 "{ok}"
927 );
928 }
929 for bad in [
930 "Press a",
931 "press a to attack",
932 "Press a.",
933 "Press A button",
934 "Press a keyhole",
935 "Press b key",
936 ] {
937 let errs = scan_ron(HINT, "x.ron", &format!("[\"{bad}\"]"));
938 assert_eq!(errs.len(), 1, "{bad}: {errs:?}");
939 }
940
941 let dlg =
942 "# press f is a comment\n@scene s\nlord: Press Space to end your turn.\nlord: Fine.\n";
943 let errs = scan_dlg(HINT, "story/dialogue/x.dlg", dlg);
944 assert_eq!(errs.len(), 1, "{errs:?}");
945 assert!(
946 errs[0].starts_with("story/dialogue/x.dlg:3: "),
947 "{}",
948 errs[0]
949 );
950 }
951
952 #[test]
953 fn dialogue_prose_may_use_key_words_as_words() {
954 for line in [
955 "knight: Escape while you can!",
956 "bowman: A rain of arrows, then we Enter the keep.",
957 "> The wind began to Shift.",
958 "lord: Space enough for all of us.",
959 "lord: I think so. Hold fast!",
960 "lord: Press on, press forward.",
961 "lord: Plan B, then.",
962 "test_hero[happy]: Better late than never.",
963 "lord: Hit it with an F1 car? No.",
964 ] {
965 assert_eq!(prose_names_key(line), None, "{line}");
966 }
967 }
968
969 #[test]
970 fn dialogue_telling_the_player_to_press_a_key_is_an_error() {
971 for line in [
972 "lord: Press f to attack.",
973 "lord: press F!",
974 "lord: Hit Space to end your turn.",
975 "lord: hold shift, then tap Enter.",
976 "lord: Tap Esc to go back.",
977 "lord: press Shift+Space.",
978 "lord: Shift+Space ends turns for you.",
979 "lord: Move with WASD.",
980 "lord: Use [F] to confirm.",
981 "lord: Press F2 for the debug view.",
982 "lord: Hold arrows to scroll.",
983 ] {
984 assert!(prose_names_key(line).is_some(), "{line}");
985 }
986 assert_eq!(
987 prose_names_key("lord: Hit Space.").as_deref(),
988 Some("tells the player to hit \"Space\"")
989 );
990 }
991
992 #[test]
993 fn a_keymap_name_after_a_press_verb_is_a_key() {
994 for line in [
995 "lord: Press Tab to switch.",
996 "lord: press Start!",
997 "lord: Hold Backspace to clear.",
998 "lord: Tap DpadUp.",
999 ] {
1000 assert!(prose_names_key(line).is_some(), "{line}");
1001 }
1002 assert_eq!(
1003 prose_names_key("lord: Press Tab to switch.").as_deref(),
1004 Some("tells the player to press \"Tab\"")
1005 );
1006 assert!(names_key("Press Tab to switch").is_some());
1007 assert_eq!(
1008 names_key("Press Tab to switch").as_deref(),
1009 Some("tells the player to press \"Tab\"")
1010 );
1011 }
1012
1013 #[test]
1014 fn keymap_names_as_plain_words_stay_prose() {
1015 for line in [
1016 "lord: Hold up!",
1017 "lord: Hold down the fort.",
1018 "lord: Press on, press forward.",
1019 "lord: We hold the North.",
1020 ] {
1021 assert_eq!(prose_names_key(line), None, "{line}");
1022 }
1023 for text in ["Start game", "Select a file", "End turn"] {
1024 assert_eq!(names_key(text), None, "{text}");
1025 }
1026 }
1027
1028 #[test]
1029 fn every_keymap_name_is_found_after_a_press_verb() {
1030 let names = Key::ALL
1031 .iter()
1032 .map(|k| k.name())
1033 .chain(Button::ALL.iter().map(|b| b.name()))
1034 .filter(|n| {
1035 n.len() >= 2
1036 && n.starts_with(|c: char| c.is_ascii_alphabetic())
1037 && n.bytes().all(|b| b.is_ascii_alphanumeric())
1038 });
1039 let mut count = 0;
1040 for name in names {
1041 count += 1;
1042 let text = format!("press {name}");
1043 assert!(prose_names_key(&text).is_some(), "{text}");
1044 assert!(names_key(&text).is_some(), "{text}");
1045 }
1046 assert!(count > 40, "{count}");
1047 }
1048
1049 #[test]
1050 fn dlg_press_start_is_one_hit_on_its_line() {
1051 let errs = scan_dlg(HINT, "x.dlg", "@scene s\nlord: Press Start to begin.\n");
1052 assert_eq!(errs.len(), 1, "{errs:?}");
1053 assert!(errs[0].starts_with("x.dlg:2: "), "{}", errs[0]);
1054 assert!(
1055 errs[0].contains("tells the player to press \"Start\""),
1056 "{}",
1057 errs[0]
1058 );
1059 }
1060
1061 #[test]
1062 fn function_keys() {
1063 assert!(is_function_key("F1"));
1064 assert!(is_function_key("f12"));
1065 assert!(!is_function_key("F0"));
1066 assert!(!is_function_key("F13"));
1067 assert!(!is_function_key("Fx"));
1068 assert!(!is_function_key("G1"));
1069 }
1070
1071 #[test]
1072 fn lexer_handles_escapes_raw_strings_chars_and_lifetimes() {
1073 let src = "let a: &'static str = \"a \\\"q\\\" b\";\nlet c = '\"';\nlet r = r#\"x \"y\" z\"#; // note\nlet n = '\\n';\nlet s = \"one \\\n two\";\n";
1074 let lines = lex(src);
1075 assert_eq!(lines[0].strings, vec!["a \"q\" b".to_owned()]);
1076 assert_eq!(lines[0].code, "let a: &'static str = \"\";");
1077 assert_eq!(lines[1].strings, Vec::<String>::new());
1078 assert_eq!(lines[1].code, "let c = '';");
1079 assert_eq!(lines[2].strings, vec!["x \"y\" z".to_owned()]);
1080 assert_eq!(lines[2].comment, " note");
1081 assert_eq!(lines[3].code, "let n = '';");
1082 assert_eq!(lines[4].strings, vec!["one two".to_owned()]);
1083 }
1084
1085 #[test]
1086 fn lexer_handles_tabs_byte_raw_strings_and_a_trailing_backslash() {
1087 let lines = lex(r#"let t = "a\tb"; let b = br"c\d"; let e = "x\"#);
1088 assert_eq!(lines[0].strings, vec!["a\tb".to_owned(), r"c\d".to_owned()]);
1089 assert_eq!(lines.len(), 1);
1091 }
1092
1093 #[test]
1094 fn lexer_keeps_newlines_in_strings_and_slashes_in_code() {
1095 let lines = lex("let s = \"a\nb\"; let d = a / b * c; let k = Key::F;");
1096 assert_eq!(lines[0].strings, vec!["a\nb".to_owned()]);
1097 assert_eq!(lines[1].code, "\"; let d = a / b * c; let k = Key::F;");
1098 let lines = lex("x = 'a'; y = \"s\";");
1099 assert_eq!(lines[0].code, "x = ''; y = \"\";");
1100 assert_eq!(lines[0].strings, vec!["s".to_owned()]);
1101 }
1102
1103 #[test]
1104 fn r_ending_an_identifier_does_not_start_a_raw_string() {
1105 let lines = lex(r#"abr"a\"b"; br"c\";"#);
1107 assert_eq!(lines[0].strings, vec!["a\"b".to_owned(), r"c\".to_owned()]);
1108 }
1109
1110 #[test]
1111 fn nested_block_comments_close() {
1112 let lines = lex("/* a /* b */ Key::F */ let x = Key::G;");
1113 assert_eq!(lines[0].code.trim(), "let x = Key::G;");
1114 }
1115
1116 #[test]
1117 fn run_reports_hits_in_a_repo_tree() {
1118 let root = std::env::temp_dir().join(format!("shuyi-check-keys-{}", std::process::id()));
1119 let _ = fs::remove_dir_all(&root);
1120 let screens = root.join("crates/ui/src/screens");
1121 fs::create_dir_all(&screens).unwrap();
1122 fs::create_dir_all(root.join("story/data")).unwrap();
1123 fs::write(screens.join("foo.rs"), "fn f() { g(Key::F); }\n").unwrap();
1124 fs::write(screens.join("tests.rs"), "fn f() { g(Key::F); }\n").unwrap();
1125 fs::write(root.join("story/data/tips.ron"), "[\"press f\"]\n").unwrap();
1126 fs::write(root.join("story/data/keymap.ron"), "(\"press f\")\n").unwrap();
1128 fs::write(root.join("story/data/notes.md"), "\"press f\"\n").unwrap();
1129 fs::create_dir_all(root.join("test-files/dialogue")).unwrap();
1130 let script = "@scene s\nlord: Press Space to end your turn.\n";
1131 fs::write(root.join("test-files/dialogue/x.dlg"), script).unwrap();
1132 let rules = rules();
1133 let errs = run(&rules, &root);
1134 let _ = fs::remove_dir_all(&root);
1135 let hits: Vec<&String> = errs.iter().filter(|e| e.contains(&rules.hint)).collect();
1137 assert_eq!(hits.len(), 3, "{errs:?}");
1138 assert!(
1139 hits[0].starts_with("crates/ui/src/screens/foo.rs:1: "),
1140 "{}",
1141 hits[0]
1142 );
1143 assert!(
1144 hits[1].starts_with("story/data/tips.ron:1: "),
1145 "{}",
1146 hits[1]
1147 );
1148 assert!(
1149 hits[2].starts_with("test-files/dialogue/x.dlg:2: "),
1150 "{}",
1151 hits[2]
1152 );
1153 assert_eq!(errs.len(), 6, "{errs:?}");
1154 }
1155}