Skip to main content

shuyi_cli/
check_keys.rs

1//! `check-keys`: fails when game code or player text names a key directly
2//! instead of going through an `Action` and the player's keymap.
3//! Controller buttons are treated like keys.
4//!
5//! It scans the non-test Rust in the project's source folders
6//! ([`CheckKeys::rust`]) for key and button types (`Key::`, `Chord::`,
7//! `Button::`) and macroquad key reads (`KeyCode`, `is_key_down`, …)
8//! outside the files that make up the key pipeline, and scans string
9//! literals there for key names shown to the player (`"f select"`,
10//! `"press f"`, `"[F]"`, `"Space"`, `"arrows"`, …). Text in the asset
11//! folders (`.ron` strings: tips, item and skill descriptions; `.dlg`
12//! dialogue) is prose, so there only an instruction to press a key
13//! (`press f`, `hold Shift`), a `Shift+` chord, `WASD` or `[F]` counts;
14//! "Escape!" or "fires arrows" as plain words are fine.
15//!
16//! Two lists of keys are read. `KEY_WORDS` holds the bare words a help bar
17//! shows alone. After a press verb, any name the keymap knows (`Key` and
18//! `Button` in `shuyi-assets`: `Tab`, `Start`, `PageUp`, `DpadUp`, …) counts
19//! too, in both scans, so a key added to the keymap is found with no change
20//! here.
21//!
22//! It is a line-based scanner, not a parser: comments are ignored, and a
23//! `#[cfg(test)]` item (usually `mod tests { … }`) is skipped by bracket
24//! counting from the attribute to the end of the item.
25
26use std::fs;
27use std::path::Path;
28
29use shuyi_assets::keymap::{Button, Key};
30
31use crate::project::CheckKeys;
32
33/// The comment that allows the next item (or its own line) to name keys, for
34/// drawing a picture of a keyboard. Only honoured in
35/// [`CheckKeys::picture_files`].
36pub const MARKER: &str = "check-keys: keyboard picture";
37
38/// Key and controller-button types: only the key pipeline names them.
39const KEY_TYPES: [&str; 3] = ["Key::", "Chord::", "Button::"];
40/// macroquad's keyboard: only `app/src/keys.rs` reads it.
41const 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];
49/// Key names that must never appear in player text as whole words: the bare
50/// words a help bar shows alone. The other names the keymap knows (`Tab`,
51/// `Start`, `Up`, …) are ordinary words in a label, so they count only after
52/// a press verb ([`is_keymap_name`]).
53const KEY_WORDS: [&str; 8] = [
54    "Space", "Esc", "Escape", "Enter", "Shift", "arrows", "WASD", "wasd",
55];
56
57/// Runs the scan over the project at `root` and returns every error
58/// (empty = clean).
59pub 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
88/// Whether `file` has extension `ext` (any case).
89pub(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
94/// Reads `file` and appends what `scan` finds in it, or the read error.
95fn 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
107/// Every file under `dir`, recursively, sorted so output is stable.
108pub 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
132/// `file` relative to `root`, with `/` separators.
133pub 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
138/// Test-only Rust files: `tests.rs`, `*_tests.rs`, anything in a `tests/`
139/// directory, and the files in `support` (compiled only for tests).
140pub(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
148/// Scans one Rust file (`rel` is its project-relative path, for messages
149/// and the allow-lists).
150pub 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
200/// Scans one RON data file's string literals with the `prose_names_key`
201/// rule: they are player-facing prose (tips, item and skill descriptions),
202/// where "fires arrows" or "Shift the odds" are just words.
203pub 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
215/// Scans one `.dlg` dialogue script: every line but `#` comments, with the
216/// `prose_names_key` rule like `.ron` text.
217pub 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
230/// Verbs that, followed by a key, tell the player to press it.
231const PRESS_VERBS: [&str; 4] = ["press", "hit", "tap", "hold"];
232
233/// Why prose `text` names a key, if it does: only an instruction to press
234/// one ("press f", "hold Shift", "hit Space."), a `Shift+` chord, `WASD`, or
235/// a bracketed letter (`[F]`). Bare key words are allowed, and so is the
236/// article in "Press a key…" and "hold a button".
237fn 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
251/// Whether `word` is a name the keymap knows, of two or more ASCII letters
252/// and digits starting with a letter (`Tab`, `Start`, `PageUp`). It matches
253/// exactly as the keymap writes it, so "up" and "on" stay prose; the
254/// numpad's punctuation names and the single characters are left to the
255/// callers.
256fn 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
263/// Why `words` tell the player to press a key, if they do: a press verb
264/// followed by a letter, a bare [`KEY_WORDS`] word, a `Shift+` chord, a
265/// function key or a keymap name. A single letter counts only with
266/// `letters`: Rust strings say "hold a brace", and [`names_key`] has its own
267/// rule for "press f". The article in "Press a key…" and "hold a
268/// button" is not the `a` key.
269fn 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
296/// Whether `key` is `F1`..`F12` (either case).
297fn 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
304/// Whether `text` has a single letter in brackets, like `[F]`.
305fn 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
311/// Why `text` names a key to the player, if it does.
312fn 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        // "press f", "Press F".
323        if first.eq_ignore_ascii_case("press") && letter(second) {
324            return Some(format!("names the key \"{second}\""));
325        }
326        // Help-bar hints: "f select", "d back". Keys print as lowercase
327        // letters; "a" is a word, and a capital is a name ("Theme B").
328        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    // "Press Tab", "hold Start": a keymap name after a press verb.
334    if let Some(why) = press_instruction(&words, false) {
335        return Some(why);
336    }
337    // "[F]", "[f]".
338    bracketed_letter(text).then(|| "names a key in brackets".to_owned())
339}
340
341/// Whether `text` contains `word` with no identifier character either side.
342fn 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
350/// Whether `code` contains `pattern` not glued to a longer identifier (so
351/// `RawKey::` isn't `Key::`, and `is_key_down_x` isn't `is_key_down`).
352fn 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
365/// For each line, whether it is skipped: inside a `#[cfg(test)]` item, or
366/// covered by a comment holding `marker` (if one is given).
367pub(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
378/// Marks the item starting at line `start` through the line where its
379/// brackets balance and it ends with `;`, `}` or `,`. A line that is only a
380/// comment or `#[…]` attributes never ends an item, so an attribute or a
381/// marker comment on its own line covers the item below it, and a marker
382/// after code on a line covers that line's item.
383pub(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/// One source line split into its parts.
404#[derive(Debug, Default, Clone, PartialEq, Eq)]
405pub(crate) struct Line {
406    /// The code, with comments removed and string/char literal contents
407    /// blanked (the quotes stay).
408    pub(crate) code: String,
409    /// The text of every comment on the line.
410    pub(crate) comment: String,
411    /// Contents of the string literals that start on this line.
412    pub(crate) strings: Vec<String>,
413}
414
415/// Lexer state between characters.
416#[derive(Clone, Copy)]
417enum State {
418    Code,
419    LineComment,
420    /// Nesting depth.
421    BlockComment(u32),
422    /// A `"…"` string, with escapes.
423    Str,
424    /// An `r#"…"#` string with this many `#`s.
425    RawStr(usize),
426}
427
428/// Splits Rust (or RON) source into [`Line`]s. Good enough for this scan:
429/// handles `//` and nested `/* */` comments, `"…"` strings with escapes,
430/// raw strings, and tells char literals from lifetimes.
431pub(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    // Every step uses at least one char, so this bound is never reached; it
442    // only stops a broken step from looping forever.
443    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
464/// [`lex`]'s working state.
465struct Lexer {
466    chars: Vec<char>,
467    /// Lines so far; the last is the current one.
468    lines: Vec<Line>,
469    state: State,
470    /// The literal being read, and the line index it started on.
471    literal: String,
472    literal_line: usize,
473    /// After a `\` line continuation in a string: skip leading whitespace.
474    skip_ws: bool,
475}
476
477impl Lexer {
478    fn at(&self, i: usize) -> Option<char> {
479        self.chars.get(i).copied()
480    }
481
482    /// The current line (`lines` is never empty).
483    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    /// Starts a string literal in `state`.
498    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    /// Ends the string literal, filing it under the line it started on.
506    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    /// Handles `c` at `i` in code; returns how many chars it used.
516    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        // r"…", r#"…"#, br"…" (the `b` is plain code before it).
531        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            // '\n', '\'', '\u{…}' or 'x': a char literal. Otherwise a
546            // lifetime or label.
547            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    /// Handles `c` at `i` inside a block comment `depth` deep.
564    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    /// Handles `c` at `i` inside a `"…"` string.
583    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            // Keep the escaped character; a `\` line continuation swallows
590            // the newline and the next line's leading spaces, as in Rust.
591            '\\' => 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    /// Handles `c` at `i` inside a raw string closed by `"` and `hashes` `#`s.
618    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    /// A game's rules, laid out as Shuyi's first game's are.
634    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        // Only the engine's keys.rs may read macroquad's keyboard.
717        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        // Ticket 0219: buttons are treated like keys.
728        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        // The pipeline, and where the Playdate reads its buttons.
740        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        // Not just any file whose name starts the same way, nor the game's
749        // binary.
750        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        // Other things called button are not controller buttons.
754        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        // Elsewhere the marker is itself an error and allows nothing.
852        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        // Only the instruction to press a key; key words as prose pass.
913        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        // "a key" and "a button" are not the `a` key; `a` alone is, and so
921        // is the A button.
922        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        // An unterminated string at end of input just stops.
1090        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        // `abr"…"` is identifier `abr` then an ordinary string, unlike `br"…"`.
1106        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        // Neither the keymap file nor a file that isn't RON is player text.
1127        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        // Missing crates/{app,content,playdate}/src are reported too.
1136        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}