//! TRACES: FR-UI-5 | FR-DEV-16 | FR-UI-4 //! Every key the application answers is in the gesture book, and every key the //! book names is answered. //! //! # The failure this closes //! //! The gesture book ([`crate::gestures`]) is generated from `GESTURE:` tags, //! so it cannot describe a gesture nobody tagged — but nothing made anyone tag //! one. Arrow keys walked the grid, `P` picked, `Delete` trashed and `F2` //! renamed for months with no line in the book, and a tag could just as well //! name a key whose handler had been deleted. The tags were trusted in both //! directions, and they were right about half the keys. //! //! # Why the handlers compare a canonical string, and not a Rust keymap //! //! There were two ways to make the bound keys machine-readable. //! //! **A keymap table in Rust**, which both dispatches and is read by the //! generator. It is the tidier data structure and the wrong home for the //! dispatch. The handlers' state lives in Slint: the F chord's held flag, which //! sheet is open, which adjustment group is on screen, whether a text field has //! focus. Moving the decision to Rust means either mirroring all of that into //! Rust or passing it across on every key, and a key handler has to answer //! `accept` or `reject` synchronously — which is the part that decides whether //! Escape reaches the shell. And the table would be a callback every window //! must remember to install: forget it once, in a test harness or on Android, //! and every key in the application is silently dead. //! //! **A static extractor over the Slint handlers**, which is what this is — but //! over a shape designed to be extracted, not over whatever a handler happened //! to be written as. Reading `event.text == "z"` and inferring its modifiers //! from the `if` around it is the fragile version: `event.modifiers.shift` //! appears as a guard, as a negated guard, as an argument, and in a nested //! `if` that splits Ctrl+E from Ctrl+Shift+E. So the handlers no longer read //! `event.text` at all. They compare one canonical string: //! //! ```text //! if (Keys.chord(event) == "Ctrl+Shift+Z") { … } //! ``` //! //! `Keys.chord` (`ui/dr-ui/ui/keys.slint`) folds the text and every modifier //! into the spelling [`crate::chord`] defines, so the literal *is* the whole //! binding — key and modifiers — and the handler dispatches on exactly the //! string this module reads. There is nothing to infer. //! //! What keeps that honest: //! //! 1. `event.text` may appear in no `.slint` file but `keys.slint`. A handler //! that went back to reading it would be a binding this cannot see. //! 2. Every literal compared against `Keys.chord(…)` must already be //! canonical. `"ctrl+z"` would parse, and would never match. //! 3. The names `keys.slint` can produce must be exactly [`crate::chord::NAMED`]: //! the Slint half of the vocabulary cannot grow or lose a key on its own. //! 4. A handler that binds anything carries a `// KEYMAP: ` comment //! above it, naming the `where:` its keys are documented under. That is how //! a key in the develop handler is held to the Develop section while a tag //! for it sits in `controls.slint`, beside the control it resets. //! //! Then the two directions, on (place, canonical chord): //! //! - **(a)** a key some handler binds that no `GESTURE:` tag in that place //! names; //! - **(b)** a key some tag names that no handler in that place binds. //! //! A tag's `keys:` names its keys between backticks — see //! [`crate::chord::chords_in`] — so the field stays a sentence for a person. use std::collections::{BTreeMap, BTreeSet}; use crate::chord::{self, Chord}; use crate::gestures::{Gesture, GestureProblem}; /// The file that owns the raw key text, and the only one allowed to read it. pub const KEYS_FILE: &str = "keys.slint"; /// One key a handler answers. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Binding { /// The `KEYMAP:` of the handler — a gesture section. pub section: String, /// Canonical chord. pub chord: String, pub file: String, pub line: usize, } fn problem(file: &str, line: usize, what: String) -> GestureProblem { GestureProblem { file: file.to_string(), line, what, } } /// Every binding in one `.slint` file, and whatever breaks the rules above. pub fn extract_bindings(text: &str, path: &str) -> (Vec, Vec) { let mut out = Vec::new(); let mut problems = Vec::new(); let is_keys_file = path.ends_with(KEYS_FILE); let code = code_only(text); let lines: Vec<&str> = text.lines().collect(); let code_lines: Vec<&str> = code.lines().collect(); if !is_keys_file { for (i, l) in code_lines.iter().enumerate() { if l.contains("event.text") { problems.push(problem( path, i + 1, "reads `event.text` — compare `Keys.chord(event)` against a canonical \ chord instead, or the key checker cannot see this binding" .into(), )); } } } for (i, l) in code_lines.iter().enumerate() { let is_handler = (l.contains("key-pressed(") || l.contains("key-released(")) && l.contains("=>"); if !is_handler { continue; } let section = keymap_above(&lines, i); let body_end = block_end(&code_lines, i); let mut found_any = false; for (j, cl) in code_lines.iter().enumerate().take(body_end + 1).skip(i) { for lit in compared_literals(cl, lines[j]) { found_any = true; match Chord::parse(&lit) { Ok(c) if c.canonical() == lit => { if let Some(s) = §ion { out.push(Binding { section: s.clone(), chord: lit, file: path.to_string(), line: j + 1, }); } } Ok(c) => problems.push(problem( path, j + 1, format!( "compares against \"{lit}\", which `Keys.chord` never produces — \ write \"{}\"", c.canonical() ), )), Err(e) => problems.push(problem(path, j + 1, format!("\"{lit}\": {e}"))), } } } if found_any && section.is_none() { problems.push(problem( path, i + 1, "a key handler with bindings and no `// KEYMAP: ` comment above it, \ so its keys cannot be held to a place in the gesture book" .into(), )); } } (out, problems) } /// The `KEYMAP:` in the run of comment lines directly above line `at`. fn keymap_above(lines: &[&str], at: usize) -> Option { let mut i = at; while i > 0 { i -= 1; let t = lines[i].trim_start(); let Some(body) = t.strip_prefix("//") else { break; }; if let Some(rest) = body.trim_start().strip_prefix("KEYMAP:") { return Some(rest.trim().to_string()); } } None } /// The line on which the brace block opened on line `at` closes. /// /// Works on text with comments and strings already blanked, so braces inside /// either do not count. fn block_end(code_lines: &[&str], at: usize) -> usize { let mut depth = 0i32; let mut opened = false; for (i, l) in code_lines.iter().enumerate().skip(at) { for ch in l.chars() { match ch { '{' => { depth += 1; opened = true; } '}' => depth -= 1, _ => {} } if opened && depth == 0 { return i; } } } code_lines.len().saturating_sub(1) } /// The literals compared against `Keys.chord(…)` on one line. /// /// `code` is the line with strings blanked, used to find the call; `raw` is /// the original, used to read the literal. The two have the same byte offsets. fn compared_literals(code: &str, raw: &str) -> Vec { let mut out = Vec::new(); let mut from = 0; while let Some(at) = code[from..].find("Keys.chord(") { let start = from + at; let Some(close) = code[start..].find(')') else { break; }; let after = start + close + 1; from = after; let rest = code[after..].trim_start(); let Some(op_rest) = rest.strip_prefix("==").or_else(|| rest.strip_prefix("!=")) else { continue; }; let lit_at = code.len() - op_rest.trim_start().len(); if let Some(lit) = read_literal(&raw[lit_at..]) { out.push(lit); } } out } /// A Slint string literal at the start of `s`, unescaped. fn read_literal(s: &str) -> Option { let mut chars = s.chars(); if chars.next()? != '"' { return None; } let mut out = String::new(); while let Some(c) = chars.next() { match c { '"' => return Some(out), '\\' => match chars.next()? { 'n' => out.push('\n'), 't' => out.push('\t'), other => out.push(other), }, c => out.push(c), } } None } /// The text with every comment and the inside of every string literal /// replaced by spaces, keeping byte offsets and line breaks. fn code_only(text: &str) -> String { let mut out = String::with_capacity(text.len()); let mut in_str = false; let mut in_line_comment = false; let mut in_block_comment = false; let mut escape = false; let bytes: Vec = text.chars().collect(); let mut i = 0; let blank = |c: char, out: &mut String| { if c == '\n' { out.push('\n'); } else { for _ in 0..c.len_utf8() { out.push(' '); } } }; while i < bytes.len() { let c = bytes[i]; let next = bytes.get(i + 1).copied(); if in_line_comment { if c == '\n' { in_line_comment = false; } blank(c, &mut out); } else if in_block_comment { if c == '*' && next == Some('/') { in_block_comment = false; out.push_str(" "); i += 2; continue; } blank(c, &mut out); } else if in_str { if escape { escape = false; blank(c, &mut out); } else if c == '\\' { escape = true; blank(c, &mut out); } else if c == '"' { in_str = false; out.push('"'); } else { blank(c, &mut out); } } else if c == '/' && next == Some('/') { in_line_comment = true; out.push_str(" "); i += 2; continue; } else if c == '/' && next == Some('*') { in_block_comment = true; out.push_str(" "); i += 2; continue; } else { if c == '"' { in_str = true; } out.push(c); } i += 1; } out } /// The named keys `keys.slint`'s `named` function can return. /// /// Read as the literals of its `return "…";` lines, between `function named` /// and the next function. pub fn slint_named_keys(text: &str) -> BTreeSet { let mut out = BTreeSet::new(); let mut inside = false; for l in text.lines() { let t = l.trim_start(); if t.starts_with("//") { continue; } if t.contains("function ") { inside = t.contains("function named("); continue; } if !inside { continue; } if let Some(rest) = t.split("return ").nth(1) { if let Some(lit) = read_literal(rest.trim_start()) { if !lit.is_empty() { out.insert(lit); } } } } out } /// Hold the Slint half of the vocabulary to the Rust half. pub fn check_vocabulary(slint_names: &BTreeSet, path: &str) -> Vec { let rust: BTreeSet = chord::NAMED.iter().map(|n| n.name.to_string()).collect(); let mut problems = Vec::new(); for missing in rust.difference(slint_names) { problems.push(problem( path, 1, format!("`Keys.named` never returns \"{missing}\", which chord.rs names"), )); } for extra in slint_names.difference(&rust) { problems.push(problem( path, 1, format!("`Keys.named` returns \"{extra}\", which chord.rs does not know"), )); } problems } /// Both directions: bound but undocumented, documented but unbound. pub fn cross_check(gestures: &[Gesture], bindings: &[Binding]) -> Vec { let mut problems = Vec::new(); // (section, chord) → where the tag naming it is. let mut documented: BTreeMap<(String, String), (String, usize, String)> = BTreeMap::new(); for g in gestures { let Some(keys) = &g.keys else { continue }; match chord::chords_in(keys) { Ok(chords) => { for c in chords { documented .entry((g.section.clone(), c.canonical())) .or_insert_with(|| (g.file.clone(), g.line, g.title.clone())); } } Err(e) => problems.push(problem( &g.file, g.line, format!("gesture `{}`: `keys:` {e}", g.title), )), } } let mut bound: BTreeMap<(String, String), (String, usize)> = BTreeMap::new(); for b in bindings { bound .entry((b.section.clone(), b.chord.clone())) .or_insert_with(|| (b.file.clone(), b.line)); } for ((section, chord), (file, line)) in &bound { if !documented.contains_key(&(section.clone(), chord.clone())) { problems.push(problem( file, *line, format!( "`{chord}` is bound in {section} but no GESTURE tag with \ `where: {section}` names it in its `keys:`" ), )); } } for ((section, chord), (file, line, title)) in &documented { if !bound.contains_key(&(section.clone(), chord.clone())) { problems.push(problem( file, *line, format!( "gesture `{title}` names `{chord}`, but no key handler marked \ `KEYMAP: {section}` binds it" ), )); } } problems } #[cfg(test)] mod tests { use super::*; use crate::gestures::extract_from_text; /// A handler in the shape the application writes, and a tag for each key. const HANDLER: &str = r#" FocusScope { // KEYMAP: Grid key-pressed(event) => { if (Keys.chord(event) == "Ctrl+Z") { undo(); return accept; } if (Keys.chord(event) == "Left" || Keys.chord(event) == "Shift+Left") { step(-1); return accept; } return reject; } } "#; const TAGS: &str = " // GESTURE: Undo // where: Grid // pointer: Click Undo // keys: `Ctrl+Z` // GESTURE: Step back // where: Grid // pointer: Click the previous frame // keys: `Left`, or `Shift+Left` to take the selection with you "; fn run(handler: &str, tags: &str) -> Vec { let (b, mut p) = extract_bindings(handler, "grid.slint"); let (g, gp) = extract_from_text(tags, "tags.slint"); p.extend(gp); p.extend(cross_check(&g, &b)); p } #[test] fn documented_keys_pass() { let p = run(HANDLER, TAGS); assert!(p.is_empty(), "{p:?}"); } #[test] fn a_bound_key_with_no_tag_fails() { let handler = HANDLER.replace( "return reject;", "if (Keys.chord(event) == \"Delete\") { trash(); return accept; }\n return reject;", ); let p = run(&handler, TAGS); assert_eq!(p.len(), 1, "{p:?}"); assert!( p[0].what.contains("`Delete` is bound in Grid"), "{}", p[0].what ); } #[test] fn a_tagged_key_no_handler_binds_fails() { let tags = TAGS.replace("`Ctrl+Z`", "`Ctrl+Z` or `Ctrl+Y`"); let p = run(HANDLER, &tags); assert_eq!(p.len(), 1, "{p:?}"); assert!(p[0].what.contains("names `Ctrl+Y`"), "{}", p[0].what); } /// A key documented in one place does not cover the same key in another. #[test] fn a_key_documented_elsewhere_does_not_count() { let tags = TAGS.replace( "where: Grid\n// pointer: Click Undo", "where: Develop\n// pointer: Click Undo", ); let p = run(HANDLER, &tags); assert_eq!(p.len(), 2, "{p:?}"); } /// Spelling differences between the tag and the handler are not drift. #[test] fn the_tag_may_spell_a_key_any_accepted_way() { let tags = TAGS .replace("`Ctrl+Z`", "`control+z`") .replace("`Left`", "`←`"); let p = run(HANDLER, &tags); assert!(p.is_empty(), "{p:?}"); } #[test] fn reading_event_text_is_refused() { let handler = HANDLER.replace( "return reject;", "if (event.text == \"q\") { quit(); }\n return reject;", ); let p = run(&handler, TAGS); assert!(p.iter().any(|x| x.what.contains("event.text")), "{p:?}"); } /// A comment mentioning it is not a read. #[test] fn event_text_in_a_comment_is_fine() { let handler = HANDLER.replace( "return reject;", "// not event.text any more\n return reject;", ); assert!(run(&handler, TAGS).is_empty()); } #[test] fn a_literal_that_is_not_canonical_is_refused() { let handler = HANDLER.replace("\"Ctrl+Z\"", "\"ctrl+z\""); let p = run(&handler, TAGS); assert!( p.iter().any(|x| x.what.contains("write \"Ctrl+Z\"")), "{p:?}" ); } #[test] fn a_handler_without_keymap_is_refused() { let handler = HANDLER.replace("// KEYMAP: Grid", "// the grid's keys"); let p = run(&handler, TAGS); assert!(p.iter().any(|x| x.what.contains("KEYMAP")), "{p:?}"); } /// The handler ends where its braces do: a comparison after it belongs to /// nothing, and a `}` in a string does not end it early. #[test] fn the_block_is_found_by_its_braces() { let handler = r#" // KEYMAP: Grid key-pressed(event) => { if (label == "}") { } if (Keys.chord(event) == "Ctrl+Z") { return accept; } return reject; } function other() { if (Keys.chord(e) == "Q") {} } "#; let (b, p) = extract_bindings(handler, "x.slint"); assert!(p.is_empty(), "{p:?}"); assert_eq!(b.len(), 1, "{b:?}"); assert_eq!(b[0].chord, "Ctrl+Z"); } #[test] fn a_backslash_literal_is_unescaped() { let handler = "// KEYMAP: Grid\nkey-pressed(event) => {\n if (Keys.chord(event) == \"\\\\\") {}\n}\n"; let (b, p) = extract_bindings(handler, "x.slint"); assert!(p.is_empty(), "{p:?}"); assert_eq!(b[0].chord, "\\"); } #[test] fn the_slint_vocabulary_is_read_from_named() { let slint = r#" export global Keys { pure function named(text: string) -> string { if (text == Key.LeftArrow) { return "Left"; } return ""; } public pure function chord(event: KeyEvent) -> string { return "Nope"; } } "#; let names = slint_named_keys(slint); assert_eq!(names.into_iter().collect::>(), ["Left"]); let p = check_vocabulary(&slint_named_keys(slint), "keys.slint"); assert!(p.iter().any(|x| x.what.contains("\"Right\"")), "{p:?}"); } }