A keys row can only be looked up, and an ex row can name no key (v29m)

HelpLine::Keys carries a KeyRow whose fields are private to a module
whose only constructor looks the keys up; a builder that types one is a
compile error. ex! asserts at compile time that every word after the
command name is a placeholder, so ':q  or press Q' does not build.
':add-items <cat> a b c ...' is now ':add-items <cat> <item> ...'.
Renderers read the row through accessors (src/ui/help.rs, render.rs).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
This commit is contained in:
Edward Langley
2026-09-22 21:43:18 -07:00
parent e6d7d13e90
commit f8345ea8c6
4 changed files with 121 additions and 38 deletions
+111 -31
View File
@@ -304,12 +304,9 @@ pub enum HelpLine {
Heading(&'static str),
Text(&'static str),
Accent(&'static str),
/// A keys-and-description row whose keys came off the keymap, so it
/// cannot go stale against the bindings it documents.
Keys {
keys: Vec<String>,
description: &'static str,
},
/// A keys-and-description row whose keys came off the keymap. A [`KeyRow`]
/// has no public constructor, so no builder can type one (improvise-v29m).
Keys(KeyRow),
/// An ex-command, which the keymap does not own. Built only by `ex!`, whose
/// colon is what stops a key being typed here (improvise-wk18).
Literal {
@@ -326,8 +323,47 @@ pub struct HelpPage {
pub lines: Vec<HelpLine>,
}
/// A keys row, looked up in `mode`. An unbound command yields no keys and the
/// row is dropped, so the page never advertises a key that does not exist.
pub use key_row::KeyRow;
/// The one door to a keys row: its fields are private to this module, and its
/// only constructor looks the keys up.
mod key_row {
use super::{ModeKey, key_label, keys_for, prefixed_labels};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct KeyRow {
keys: Vec<String>,
description: &'static str,
}
impl KeyRow {
pub fn keys(&self) -> &[String] {
&self.keys
}
pub fn description(&self) -> &'static str {
self.description
}
/// An unbound command yields no keys and no row, so the page never
/// advertises a key that does not exist.
pub(super) fn looked_up(
mode: &ModeKey,
command: &str,
args: Option<&[String]>,
description: &'static str,
) -> Option<Self> {
let mut keys: Vec<String> = keys_for(mode, command, args)
.iter()
.map(|(c, m)| key_label(c, *m))
.collect();
keys.extend(prefixed_labels(mode, command, args));
(!keys.is_empty()).then_some(Self { keys, description })
}
}
}
/// A keys row, looked up in `mode`.
fn keys(
mode: &ModeKey,
command: &'static str,
@@ -335,18 +371,7 @@ fn keys(
description: &'static str,
) -> Option<HelpLine> {
let args: Option<Vec<String>> = args.map(|a| a.iter().map(|s| (*s).to_string()).collect());
let mut labels: Vec<String> = keys_for(mode, command, args.as_deref())
.iter()
.map(|(c, m)| key_label(c, *m))
.collect();
labels.extend(prefixed_labels(mode, command, args.as_deref()));
match labels.is_empty() {
true => None,
false => Some(HelpLine::Keys {
keys: labels,
description,
}),
}
KeyRow::looked_up(mode, command, args.as_deref(), description).map(HelpLine::Keys)
}
/// `gg`, `yy`, `ZZ`: a prefix key and the key after it, both off the keymap.
@@ -368,22 +393,56 @@ fn prefixed_labels(mode: &ModeKey, command: &str, args: Option<&[String]>) -> Ve
type KeysRow<'a> =
&'a dyn Fn(&ModeKey, &'static str, Option<&[&str]>, &'static str) -> Option<HelpLine>;
/// An ex-command row. The leading colon is the macro's, not the caller's.
/// An ex-command row. The colon is the macro's, and every word after the
/// command's name must be a placeholder, checked when the page compiles.
macro_rules! ex {
($text:literal, $description:expr $(,)?) => {
($text:literal, $description:expr $(,)?) => {{
const _: () = assert!(ex_usage_is_placeholders($text));
HelpLine::Literal {
keys: concat!(":", $text),
description: $description,
dim: false,
}
};
(dim $text:literal, $description:expr $(,)?) => {
}};
(dim $text:literal, $description:expr $(,)?) => {{
const _: () = assert!(ex_usage_is_placeholders($text));
HelpLine::Literal {
keys: concat!(" :", $text),
description: $description,
dim: true,
}
};
}};
}
/// After the name, every word opens with `<`, `[` or `"`, or is `...`: so an
/// ex row cannot also advertise a key ("q or press Q"). Quotes span spaces.
const fn ex_usage_is_placeholders(text: &str) -> bool {
let b = text.as_bytes();
let mut i = 0;
while i < b.len() && b[i] != b' ' {
i += 1;
}
loop {
while i < b.len() && b[i] == b' ' {
i += 1;
}
if i == b.len() {
return true;
}
let start = i;
let mut quoted = false;
while i < b.len() && (quoted || b[i] != b' ') {
if b[i] == b'"' {
quoted = !quoted;
}
i += 1;
}
let ellipsis =
i - start == 3 && b[start] == b'.' && b[start + 1] == b'.' && b[start + 2] == b'.';
if !(ellipsis || b[start] == b'<' || b[start] == b'[' || b[start] == b'"') {
return false;
}
}
}
/// The five pages, in order. `HELP_PAGE_COUNT` is their count and the bound
@@ -830,7 +889,7 @@ fn commands(keys: KeysRow) -> Vec<HelpLine> {
Some(ex!("add-cat <name>", "Add a new category")),
Some(ex!("add-item <cat> <item>", "Add one item to a category",)),
Some(ex!(
"add-items <cat> a b c ...",
"add-items <cat> <item> ...",
"Add multiple items at once",
)),
Some(ex!("formula <cat> \"<Name> = <expr>\"", "Add a formula",)),
@@ -919,8 +978,12 @@ mod tests {
for page in &pages {
assert!(!page.title.is_empty());
for line in &page.lines {
if let HelpLine::Keys { keys, description } = line {
assert!(!keys.is_empty(), "{description} resolved to no keys");
if let HelpLine::Keys(row) = line {
assert!(
!row.keys().is_empty(),
"{} resolved to no keys",
row.description()
);
rows += 1;
}
}
@@ -950,6 +1013,23 @@ mod tests {
);
}
/// improvise-v29m: `ex!` refuses a usage with a bare word in it at compile
/// time; this pins the rule it asks. See `context/test-evidence/protocol.md`.
#[test]
fn an_ex_row_can_name_placeholders_and_nothing_else() {
for usage in [
"w [path]",
"q!",
"formula <cat> \"<Name> = <expr>\"",
"add-items <cat> <item> ...",
] {
assert!(ex_usage_is_placeholders(usage), "{usage} is refused");
}
for usage in ["q or press Q", "add-items <cat> a b c", "help ?"] {
assert!(!ex_usage_is_placeholders(usage), "{usage} is accepted");
}
}
/// A row whose lookup finds no key is dropped silently, so a broken lookup
/// looks like a shorter page. See `context/test-evidence/protocol.md`.
#[test]
@@ -1039,7 +1119,7 @@ mod tests {
.into_iter()
.flat_map(|page| page.lines)
.filter_map(|line| match line {
HelpLine::Keys { keys, description } => Some((keys, description)),
HelpLine::Keys(row) => Some((row.keys().to_vec(), row.description())),
_ => None,
})
.collect()
@@ -1107,8 +1187,8 @@ mod tests {
println!("\n=== {} ===", page.title);
for line in &page.lines {
match line {
HelpLine::Keys { keys, description } => {
println!(" {:<28}{description}", keys.join(" "))
HelpLine::Keys(row) => {
println!(" {:<28}{}", row.keys().join(" "), row.description())
}
HelpLine::Literal {
keys, description, ..
+4 -1
View File
@@ -2395,7 +2395,10 @@ mod tests {
pressed += 1;
}
}
assert!(pressed >= 14, "only {pressed} keys pressed; the walk went blind");
assert!(
pressed >= 14,
"only {pressed} keys pressed; the walk went blind"
);
}
/// improvise-rmvx. `step` checks `declined` first, so a name answered
+3 -3
View File
@@ -418,9 +418,9 @@ impl RenderHelpRow {
L::Heading(t) => prose("heading", t),
L::Text(t) => prose("text", t),
L::Accent(t) => prose("accent", t),
L::Keys { keys, description } => Self {
keys: keys.join(" "),
description: description.to_string(),
L::Keys(row) => Self {
keys: row.keys().join(" "),
description: row.description().to_string(),
kind: "keys",
},
L::Literal {
+3 -3
View File
@@ -69,9 +69,9 @@ fn page_content(page: usize) -> Vec<Row> {
HelpLine::Heading(text) => plain(text, s.heading),
HelpLine::Text(text) => plain(text, s.normal),
HelpLine::Accent(text) => plain(text, s.accent),
HelpLine::Keys { keys, description } => Row {
key: format!(" {}", keys.join(" ")),
desc: description,
HelpLine::Keys(row) => Row {
key: format!(" {}", row.keys().join(" ")),
desc: row.description(),
style: s.key,
},
HelpLine::Literal {