//! TRACES: FR-UI-4 //! Interaction documentation, extracted from the code that implements it. //! //! # Why this exists at all //! //! FR-UI-4 says a gesture with no visible counterpart is a feature only its //! author knows about. The application has grown a real gesture vocabulary — //! hold to select, arm a range and tap its end, pinch to resize the grid, drag //! a selection onto a collection — and every one of them was documented only in //! the comment beside the `TouchArea` that implements it. Excellent comments, //! and unreachable by anyone who is not reading the source. //! //! Writing them out a second time, in a hand-kept help page, is the failure //! this file is built to avoid. Two descriptions of one gesture drift, and the //! one that drifts is always the prose: the code is exercised every time //! somebody uses the application and the page is exercised never. A help screen //! that confidently describes a double-tap the grid stopped honouring six //! months ago is worse than no help screen. //! //! So the comment beside the implementation is the only copy. This scans it out //! into two artefacts — `docs/gestures.md` for a reader, and a generated Rust //! table the application itself displays — and CI fails if either has drifted //! from the source. The same discipline, and the same gate, the requirements //! matrix already runs under. //! //! # Why it lives in the traceability crate //! //! It is the same operation on the same input: walk the source tree, pull //! structured tags out of comments, render, and fail if the committed artefact //! no longer matches. Everything but the tag vocabulary is shared — the file //! walk, the extension list, the exclusions — and a second crate would have had //! to duplicate all of it to add one parser. //! //! # The tag //! //! ```text //! // GESTURE: Select a range //! // where: Library grid //! // touch: "Select to…", then tap the last photograph //! // pointer: Shift-click //! // why: A finger sweep runs out at the edge of the screen, and the //! // ranges that hurt are longer than a screenful. //! ``` //! //! Works in `.rs` and `.slint` alike, because both comment with `//` and the //! gestures live in both — the arbitration in Rust, the affordance in Slint. //! //! **The tag opens its comment or it is not a tag.** A line that merely //! mentions it — a sentence about the vocabulary, a worked example in a doc //! comment like the one above — is describing the mechanism rather than //! declaring a gesture, and position is the only thing that tells the two //! apart. //! //! A block runs from that line to the first line that is not a comment, or to a //! comment line that is empty after its marker. A line reading //! `key: value` sets a field; any other line inside the block continues the //! field before it, which is what lets `why` run to a sentence. An unrecognised //! key is an error rather than a silently dropped line: `pointr:` should not //! quietly cost a gesture its desktop half. //! //! # `manual:`, the section that shows it //! //! Optional. It names a heading of `docs/manual/README.md` by its anchor — //! `manual: looking-closer`, with or without the `#` — and the help sheet then //! offers "See it", opening the bundled manual there, where a picture shows //! the move being made. The anchor must exist: [`check_manual`] fails the scan //! when the manual has no such heading, so renaming a section cannot leave a //! link on the sheet that opens the manual at the top. use std::collections::BTreeMap; use serde::Serialize; /// One documented interaction. #[derive(Debug, Clone, Serialize, PartialEq, Eq)] pub struct Gesture { /// What it does, in the user's terms — "Select a range", not "arm ranging". pub title: String, /// The place it applies, and the heading it is grouped under. pub section: String, /// What a finger does. `None` where the gesture is pointer-only. pub touch: Option, /// What a mouse or trackpad does. pub pointer: Option, /// The keyboard route, where there is one. pub keys: Option, /// Why it works this way. Shown in the document, not in the application: /// a user consulting the help sheet wants the gesture, and a reader of the /// document wants the argument. pub why: Option, /// The manual section that shows it, as a heading anchor without `#`. pub manual: Option, pub file: String, pub line: usize, } impl Gesture { /// Every way in, longest-lived first. /// /// Touch leads because that is the modality the gesture vocabulary was /// grown for: ctrl and shift already documented themselves by existing on /// every other application, and the hold-and-tap sequences did not. pub fn routes(&self) -> Vec<(&'static str, &str)> { let mut out = Vec::new(); if let Some(t) = &self.touch { out.push(("Touch", t.as_str())); } if let Some(p) = &self.pointer { out.push(("Pointer", p.as_str())); } if let Some(k) = &self.keys { out.push(("Keyboard", k.as_str())); } out } } /// A key that may appear in a gesture block. const KEYS: &[&str] = &["where", "touch", "pointer", "keys", "why", "manual"]; /// Something wrong with a tag, reported rather than dropped. #[derive(Debug, Clone, PartialEq, Eq)] pub struct GestureProblem { pub file: String, pub line: usize, pub what: String, } impl std::fmt::Display for GestureProblem { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}:{}: {}", self.file, self.line, self.what) } } /// Extract every gesture block from one file's text. /// /// Returns what parsed and what did not. Both, deliberately: a malformed tag /// that merely vanished would leave a gesture undocumented and everything /// looking fine, which is the failure mode this whole file exists to prevent. pub fn extract_from_text(text: &str, path: &str) -> (Vec, Vec) { let lines: Vec<&str> = text.lines().collect(); let mut out = Vec::new(); let mut problems = Vec::new(); let mut i = 0; while i < lines.len() { // **The tag must open its comment**, not merely appear somewhere in // one. Prose that mentions it — "generated from the `GESTURE:` comments // beside the code" — is talking *about* the vocabulary, not declaring a // member of it, and the two are only distinguishable by position. The // gate caught exactly that sentence in `dr-ui`'s own module list, which // is the right outcome for a typo and the wrong one for a description. // // This also excludes a tag inside a string literal, which has code in // front of it and so does not open a comment at all. let Some(body) = comment_body(lines[i]) else { i += 1; continue; }; let Some(title) = body.trim_start().strip_prefix("GESTURE:") else { i += 1; continue; }; let start = i; let title = title.trim().to_string(); let mut fields: BTreeMap = BTreeMap::new(); let mut last: Option = None; i += 1; while i < lines.len() { let Some(body) = comment_body(lines[i]) else { break; }; if body.trim().is_empty() { break; } // The next gesture ends this one. Without this, two blocks written // back to back — which is how a screen's gestures naturally get // listed — had the first swallow the second's title as a // continuation of its last field, and the second vanished. if body.contains("GESTURE:") { break; } match split_key(body) { Some((key, value)) => { if !KEYS.contains(&key.as_str()) { problems.push(GestureProblem { file: path.to_string(), line: i + 1, what: format!( "unknown gesture key `{key}` (expected one of {})", KEYS.join(", ") ), }); } fields.insert(key.clone(), value.trim().to_string()); last = Some(key); } // A continuation of the field above it, joined with a single // space: the source wraps at the column the file wraps at, and // those line breaks are not part of the sentence. None => match &last { Some(key) => { let entry = fields.entry(key.clone()).or_default(); if !entry.is_empty() { entry.push(' '); } entry.push_str(body.trim()); } None => break, }, } i += 1; } let take = |k: &str| fields.get(k).filter(|v| !v.is_empty()).cloned(); let g = Gesture { title: title.clone(), section: take("where").unwrap_or_default(), touch: take("touch"), pointer: take("pointer"), keys: take("keys"), why: take("why"), manual: take("manual").map(|m| m.trim_start_matches('#').to_string()), file: path.to_string(), line: start + 1, }; // Validated here rather than at the gate, so the problem names the line // that caused it. if g.title.is_empty() { problems.push(GestureProblem { file: path.to_string(), line: start + 1, what: "gesture has no title".into(), }); } if g.section.is_empty() { problems.push(GestureProblem { file: path.to_string(), line: start + 1, what: format!("gesture `{}` has no `where:`", g.title), }); } if g.routes().is_empty() { problems.push(GestureProblem { file: path.to_string(), line: start + 1, what: format!( "gesture `{}` says how to do it in no modality — needs at least one of \ touch, pointer or keys", g.title ), }); } out.push(g); } (out, problems) } /// The text of a comment line, marker stripped. `None` for anything else. /// /// `///` and `//!` are stripped to the same thing: a gesture block written as a /// doc comment on the function that implements it is the natural place for one, /// and it should not need a different tag. fn comment_body(line: &str) -> Option<&str> { let t = line.trim_start(); let rest = t.strip_prefix("//")?; Some( rest.strip_prefix('!') .or_else(|| rest.strip_prefix('/')) .unwrap_or(rest), ) } /// Split ` key: value` into its halves. /// /// The key must be a bare word against the left of the comment body, which is /// what keeps a colon inside prose — "so: the run is resolved by the catalog" — /// from being read as a new field. A continuation is indented past its key, and /// the key it continues is a single word; requiring both is enough to tell them /// apart without counting spaces. fn split_key(body: &str) -> Option<(String, String)> { let (head, tail) = body.split_once(':')?; let key = head.trim(); if key.is_empty() || !key.chars().all(|c| c.is_ascii_alphabetic()) { return None; } // A key sits at the start of the comment body, under a shallow indent. A // continuation line is pushed further right to line up under its value. let indent = body.len() - body.trim_start().len(); if indent > 5 { return None; } Some((key.to_ascii_lowercase(), tail.to_string())) } /// Every `manual:` that names a section the manual does not have. /// /// `anchors` is [`crate::manual::anchors`] of the README — the ids the bundled /// page gives its headings — so this and the page cannot disagree about what /// exists. pub fn check_manual(gestures: &[Gesture], anchors: &[String]) -> Vec { gestures .iter() .filter_map(|g| { let m = g.manual.as_ref()?; (!anchors.iter().any(|a| a == m)).then(|| GestureProblem { file: g.file.clone(), line: g.line, what: format!( "gesture `{}` names manual section `#{m}`, which docs/manual/README.md \ has no heading for", g.title ), }) }) .collect() } /// Group gestures under their `where:`, keeping the order the sections were /// first seen so the document reads in the order a user meets them. pub fn by_section(gestures: &[Gesture]) -> Vec<(String, Vec<&Gesture>)> { let mut order: Vec = Vec::new(); let mut map: BTreeMap> = BTreeMap::new(); for g in gestures { if !map.contains_key(&g.section) { order.push(g.section.clone()); } map.entry(g.section.clone()).or_default().push(g); } order .into_iter() .map(|s| { let v = map.remove(&s).unwrap_or_default(); (s, v) }) .collect() } // ── rendering ───────────────────────────────────────────────────────────── /// The reader's artefact: `docs/gestures.md`. /// /// Carries `why` where the application's sheet does not. Someone reading the /// document is deciding whether a gesture is right; someone holding the tablet /// has already decided and wants the move. pub fn render_markdown(gestures: &[Gesture]) -> String { let mut m = String::new(); m.push_str("# How the application is driven\n\n"); m.push_str("\n"); m.push_str("\n\n"); m.push_str( "Every entry here is extracted from the comment beside the code that implements it, \ so this file cannot describe a gesture the application does not have. Add one by \ writing a `GESTURE:` block next to the implementation; there is nowhere else to \ write it.\n\n", ); let places = by_section(gestures).len(); m.push_str(&format!( "{} gesture{}, in {} place{}.\n", gestures.len(), if gestures.len() == 1 { "" } else { "s" }, places, if places == 1 { "" } else { "s" }, )); for (section, list) in by_section(gestures) { m.push_str(&format!("\n## {section}\n")); for g in list { m.push_str(&format!("\n### {}\n\n", g.title)); for (modality, how) in g.routes() { let how = if modality == "Keyboard" { crate::chord::render_keys(how, true) } else { how.to_string() }; m.push_str(&format!("- **{modality}** — {how}\n")); } if let Some(anchor) = &g.manual { m.push_str(&format!( "- **See it** — [in the manual](manual/README.md#{anchor})\n" )); } if let Some(why) = &g.why { m.push_str(&format!("\n{why}\n")); } m.push_str(&format!("\n`{}:{}`\n", g.file, g.line)); } } m } /// The application's artefact: a Rust table it displays. /// /// Generated and *committed*, not built in a `build.rs`. Two reasons. A /// generated file that only exists in `OUT_DIR` cannot be read in review, and /// this one is user-facing text that should be reviewed like any other. And the /// scanner walks the whole source tree, which is not something to do on every /// incremental build of the crate being scanned. /// /// `why` is deliberately not carried across. It is the argument for the design /// and belongs in the document; putting it on a phone-sized help sheet would /// bury the one line the user opened the sheet to read. pub fn render_rust(gestures: &[Gesture]) -> String { let mut r = String::new(); r.push_str("// @generated by `cargo run -p traceability -- gestures`. Do not edit.\n"); r.push_str("//\n"); r.push_str("// TRACES: FR-UI-4\n"); r.push_str("//! The gesture vocabulary, as the application states it.\n"); r.push_str("//!\n"); r.push_str("//! Extracted from the tagged comments beside the code that implements each\n"); r.push_str("//! one, so the help sheet cannot describe a gesture the application does not\n"); r.push_str("//! have. Edit the comment beside the implementation, then regenerate with\n"); r.push_str("//! `cargo run -p traceability -- gestures`.\n\n"); r.push_str("/// One documented interaction, as drawn.\n"); r.push_str("#[derive(Debug, Clone, Copy, PartialEq, Eq)]\n"); r.push_str("pub struct Gesture {\n"); r.push_str(" pub title: &'static str,\n"); r.push_str(" /// The place it applies, and the heading it is grouped under.\n"); r.push_str(" pub section: &'static str,\n"); r.push_str(" /// Empty where the gesture has no counterpart in that modality.\n"); r.push_str(" pub touch: &'static str,\n"); r.push_str(" pub pointer: &'static str,\n"); r.push_str(" pub keys: &'static str,\n"); r.push_str(" /// The manual section that shows it, a heading anchor; empty for none.\n"); r.push_str(" pub manual: &'static str,\n"); r.push_str("}\n\n"); r.push_str("/// Every gesture, in the order the sections were first seen in the source.\n"); r.push_str("pub const GESTURES: &[Gesture] = &[\n"); for (_, list) in by_section(gestures) { for g in list { r.push_str(" Gesture {\n"); r.push_str(&format!(" title: {},\n", quote(&g.title))); r.push_str(&format!(" section: {},\n", quote(&g.section))); r.push_str(&format!( " touch: {},\n", quote(g.touch.as_deref().unwrap_or("")) )); r.push_str(&format!( " pointer: {},\n", quote(g.pointer.as_deref().unwrap_or("")) )); r.push_str(&format!( " keys: {},\n", quote(&crate::chord::render_keys( g.keys.as_deref().unwrap_or(""), false )) )); r.push_str(&format!( " manual: {},\n", quote(g.manual.as_deref().unwrap_or("")) )); r.push_str(" },\n"); } } r.push_str("];\n"); r } /// A Rust string literal for arbitrary gesture text. /// /// The text is prose written by hand and routinely contains quotation marks — /// `"Select to…", then tap the last photograph` — so escaping is not optional. fn quote(s: &str) -> String { let mut out = String::with_capacity(s.len() + 2); out.push('"'); for c in s.chars() { match c { '"' => out.push_str("\\\""), '\\' => out.push_str("\\\\"), _ => out.push(c), } } out.push('"'); out } #[cfg(test)] mod tests { use super::*; /// The shape every real tag is written in. const SAMPLE: &str = "\ // GESTURE: Select a range // where: Library grid // touch: \"Select to…\", then tap the last photograph // pointer: Shift-click // why: A finger sweep runs out at the edge of the // screen. property ranging: false; "; #[test] fn a_block_parses_every_field() { let (g, p) = extract_from_text(SAMPLE, "library.slint"); assert!(p.is_empty(), "{p:?}"); assert_eq!(g.len(), 1); assert_eq!(g[0].title, "Select a range"); assert_eq!(g[0].section, "Library grid"); assert_eq!( g[0].touch.as_deref(), Some("\"Select to…\", then tap the last photograph") ); assert_eq!(g[0].pointer.as_deref(), Some("Shift-click")); assert_eq!(g[0].line, 1); } /// The source wraps where the file wraps; the sentence does not. #[test] fn a_continuation_joins_with_one_space() { let (g, _) = extract_from_text(SAMPLE, "x.slint"); assert_eq!( g[0].why.as_deref(), Some("A finger sweep runs out at the edge of the screen.") ); } /// A colon in prose is not a new field, or `why` could never contain one. #[test] fn a_colon_inside_a_continuation_is_not_a_key() { let text = "\ // GESTURE: Hold to select // where: Library grid // touch: Press and hold // why: The platform convention: every gallery on the device // opens a selection this way. "; let (g, p) = extract_from_text(text, "x.rs"); assert!(p.is_empty(), "{p:?}"); assert_eq!( g[0].why.as_deref(), Some( "The platform convention: every gallery on the device opens a selection this way." ) ); } /// A typo must not quietly cost a gesture its desktop half. #[test] fn an_unknown_key_is_reported() { let text = "\ // GESTURE: Open a photograph // where: Library grid // touch: Tap it // pointr: Click it "; let (_, p) = extract_from_text(text, "x.rs"); assert_eq!(p.len(), 1, "{p:?}"); assert!(p[0].what.contains("pointr"), "{}", p[0].what); } /// A gesture nobody can perform is a documentation bug, not a gesture. #[test] fn a_gesture_with_no_modality_is_reported() { let text = "// GESTURE: Do a thing\n// where: Somewhere\n"; let (_, p) = extract_from_text(text, "x.rs"); assert!(p.iter().any(|x| x.what.contains("no modality")), "{p:?}"); } #[test] fn a_gesture_with_no_place_is_reported() { let text = "// GESTURE: Do a thing\n// touch: Tap\n"; let (_, p) = extract_from_text(text, "x.rs"); assert!(p.iter().any(|x| x.what.contains("`where:`")), "{p:?}"); } /// The tag inside this crate's own doc comments and test fixtures must not /// become a gesture in the user's help sheet. #[test] fn a_tag_inside_a_string_literal_is_not_a_gesture() { let text = "let s = \"GESTURE: not a real one\";\n"; let (g, p) = extract_from_text(text, "x.rs"); assert!(g.is_empty(), "{g:?}"); assert!(p.is_empty()); } /// Prose *about* the vocabulary is not a member of it. The gate caught this /// exact sentence in `dr-ui`'s module list, where a comment explaining /// where the generated table comes from named the tag in passing. #[test] fn a_sentence_that_mentions_the_tag_is_not_a_gesture() { let text = "\ // Generated from the `GESTURE:` comments beside the code that implements // each one — see `tools/traceability`. mod gesture_book; "; let (g, p) = extract_from_text(text, "lib.rs"); assert!(g.is_empty(), "{g:?}"); assert!(p.is_empty(), "{p:?}"); } /// A blank comment line ends the block, so ordinary prose beneath a gesture /// is not swallowed into its `why`. #[test] fn a_blank_comment_line_ends_the_block() { let text = "\ // GESTURE: Tap to open // where: Library grid // touch: Tap a photograph // // This paragraph is about something else entirely. "; let (g, _) = extract_from_text(text, "x.rs"); assert_eq!(g[0].touch.as_deref(), Some("Tap a photograph")); assert!(g[0].why.is_none(), "{:?}", g[0].why); } /// A doc comment is the natural place for a gesture on the function that /// implements it, and must not need a different tag. #[test] fn a_doc_comment_carries_a_gesture_too() { let text = "\ /// GESTURE: Pinch the grid /// where: Library grid /// touch: Pinch to resize the thumbnails pub fn zoom() {} "; let (g, p) = extract_from_text(text, "x.rs"); assert!(p.is_empty(), "{p:?}"); assert_eq!(g[0].title, "Pinch the grid"); } /// Two blocks back to back, which is how a screen's gestures get listed. #[test] fn one_block_does_not_swallow_the_next() { let text = "\ // GESTURE: First // where: Grid // touch: Tap // GESTURE: Second // where: Grid // touch: Hold "; let (g, p) = extract_from_text(text, "x.rs"); assert!(p.is_empty(), "{p:?}"); assert_eq!(g.len(), 2, "{g:?}"); assert_eq!(g[0].touch.as_deref(), Some("Tap")); assert_eq!(g[1].title, "Second"); } /// `manual:` is read, with or without its `#`, and a section the manual /// does not have is a problem rather than a link to the top of the page. #[test] fn a_manual_anchor_must_name_a_section_the_manual_has() { let text = "\ // GESTURE: Zoom // where: Develop // touch: Pinch // manual: #looking-closer // GESTURE: Pan // where: Develop // touch: Drag // manual: looking-further "; let (g, p) = extract_from_text(text, "x.slint"); assert!(p.is_empty(), "{p:?}"); assert_eq!(g[0].manual.as_deref(), Some("looking-closer")); let anchors = crate::manual::anchors("## Looking closer\n"); let problems = check_manual(&g, &anchors); assert_eq!(problems.len(), 1, "{problems:?}"); assert!(problems[0].what.contains("looking-further")); assert_eq!(problems[0].line, 5); } #[test] fn a_manual_anchor_is_linked_from_the_document_and_carried_in_the_table() { let text = "// GESTURE: Zoom\n// where: Develop\n// touch: Pinch\n// manual: looking-closer\n"; let (g, _) = extract_from_text(text, "x.slint"); assert!(render_markdown(&g).contains("(manual/README.md#looking-closer)")); assert!(render_rust(&g).contains("manual: \"looking-closer\",")); } #[test] fn sections_keep_the_order_they_were_first_seen() { let text = "\ // GESTURE: B // where: Second // touch: x // GESTURE: A // where: First // touch: x // GESTURE: C // where: Second // touch: x "; let (g, _) = extract_from_text(text, "x.rs"); let grouped = by_section(&g); assert_eq!(grouped[0].0, "Second"); assert_eq!(grouped[0].1.len(), 2); assert_eq!(grouped[1].0, "First"); } }