Fail CI when a key is bound but not in the gesture book, or listed but not bound

The gesture book is generated from GESTURE tags, so it could not describe a
gesture nobody tagged, but nothing made anyone tag one. The arrow keys, Enter,
P, X, U, Delete, F1 and F2 all worked in the grid with no line in the help
sheet, and a tag could name a key whose handler had gone.

Key handlers now compare one canonical string, Keys.chord(event) == "Ctrl+Z",
instead of reading event.text and the modifiers themselves. keys.slint folds
the key and its modifiers into that spelling, so the literal in the handler is
the whole binding and the checker reads exactly what the handler dispatches
on. Each handler carries a KEYMAP comment naming the gesture-book section its
keys belong to, and a tag's keys field names its keys between backticks.
gestures-check now fails when a handler binds a key no tag in that section
names, when a tag names a key no handler there binds, when any .slint file
other than keys.slint reads event.text, when a compared literal is not
canonical, and when keys.slint's named keys drift from the Rust list.

Spellings are normalised in one place, chord.rs: Ctrl+z, Control+Z and
LeftArrow all mean what the handler's "Ctrl+Z" and "Left" mean. Shift and Alt
count only for letters and named keys, because on the French layout every
digit needs shift and a 6 has to be a 6 however it was typed.

A Rust keymap that both dispatched and was read by the generator was the
alternative. It would have moved the handlers' decisions away from the Slint
state they depend on, and a window that forgot to install it would have had
no working keys at all.

The keys that were already bound and undocumented are now tagged.
This commit is contained in:
2026-09-24 23:42:25 -04:00
parent 150e53e878
commit 9d1e31ffbb
15 changed files with 1696 additions and 258 deletions
+605
View File
@@ -0,0 +1,605 @@
//! 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: <where>` 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<Binding>, Vec<GestureProblem>) {
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) = &section {
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: <where>` 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<String> {
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<String> {
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<String> {
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<char> = 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<String> {
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<String>, path: &str) -> Vec<GestureProblem> {
let rust: BTreeSet<String> = 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<GestureProblem> {
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<GestureProblem> {
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::<Vec<_>>(), ["Left"]);
let p = check_vocabulary(&slint_named_keys(slint), "keys.slint");
assert!(p.iter().any(|x| x.what.contains("\"Right\"")), "{p:?}");
}
}