feat: H3 help <command> per-command detail + general reference

HELP node takes an optional single-word topic (BarePath);
AppCommand::Help { topic }. note_help_topic renders the help
block(s) of every command sharing that entry word (so `help
create` covers both create forms), plus `help types` and a
friendly "no help for X" pointer for unknown topics. Full help
gains a detail-hint footer. Catalogued help.detail_hint /
help.unknown_topic; parse-error matrix updated (help now takes a
topic, so the near-miss is the multi-word case). 9 integration
tests in tests/it/help_command.rs. Mark H3 [x].
This commit is contained in:
claude@clouddev1
2026-06-07 13:32:18 +00:00
parent 8dec784080
commit 757711f2bf
12 changed files with 247 additions and 26 deletions
+48 -2
View File
@@ -1321,8 +1321,11 @@ impl App {
use crate::dsl::{AppCommand, MessagesValue, ModeValue};
match cmd {
AppCommand::Quit => vec![Action::Quit],
AppCommand::Help => {
self.note_help();
AppCommand::Help { topic } => {
match &topic {
Some(t) => self.note_help_topic(t),
None => self.note_help(),
}
Vec::new()
}
AppCommand::Rebuild => vec![Action::PrepareRebuild],
@@ -2405,6 +2408,49 @@ impl App {
.lines()
.map(str::to_string),
);
// H3: point at the focused per-command form.
lines.push(crate::t!("help.detail_hint"));
for line in lines {
self.note_system(line);
}
}
/// Focused per-command help (H3): `help <topic>`, where `topic`
/// is a command entry word (`insert`, `create`, `show`, …) or
/// the special `types`. Renders the help block(s) of every
/// command sharing that entry word — so `help create` covers
/// both the DSL and SQL create forms — or a friendly pointer
/// back to `help` when nothing matches.
fn note_help_topic(&mut self, topic: &str) {
use crate::dsl::grammar::REGISTRY;
let topic = topic.trim();
// `help types` re-shows just the type reference.
if topic.eq_ignore_ascii_case("types") {
for line in crate::t!("help.types_reference").lines() {
self.note_system(line.to_string());
}
return;
}
let mut lines: Vec<String> = Vec::new();
for (command, _category) in REGISTRY {
let Some(help_id) = command.help_id else {
continue;
};
if command.entry.matches(topic) {
let key = format!("help.{help_id}");
let body = crate::friendly::translate(&key, &[]);
lines.extend(body.lines().map(str::to_string));
}
}
if lines.is_empty() {
// No command owns that entry word — name it and point
// back at the full list rather than failing silently.
self.note_system(crate::t!("help.unknown_topic", topic = topic));
return;
}
for line in lines {
self.note_system(line);
}
+9 -3
View File
@@ -492,8 +492,14 @@ pub enum Command {
pub enum AppCommand {
/// Exit cleanly. Accepts the `q` alias.
Quit,
/// Show in-app help. Body comes from `help.in_app_body`.
Help,
/// Show in-app help (H3). With no `topic`, the full command
/// list + types reference; with a `topic` (a command entry
/// word like `insert` / `create` / `show`, or `types`), the
/// focused detail for that command (or command group sharing
/// the entry word).
Help {
topic: Option<String>,
},
/// Rebuild `playground.db` from `project.yaml` + data/, with
/// confirmation modal.
Rebuild,
@@ -910,7 +916,7 @@ impl Command {
Self::SqlDelete { .. } => "delete from",
Self::App(app) => match app {
AppCommand::Quit => "quit",
AppCommand::Help => "help",
AppCommand::Help { .. } => "help",
AppCommand::Rebuild => "rebuild",
AppCommand::Save => "save",
AppCommand::SaveAs => "save as",
+15 -3
View File
@@ -80,6 +80,11 @@ const IMPORT_PATH_AND_TARGET: Node = Node::Seq(&[Node::BarePath, IMPORT_AS_TARGE
const EXPORT_PATH_OPT: Node = Node::Optional(&Node::BarePath);
const IMPORT_BODY_OPT: Node = Node::Optional(&IMPORT_PATH_AND_TARGET);
// `help [<topic>]` (H3): an optional single-word topic — a command
// entry word (`insert`, `create`, `show`, …) or `types`. Captured
// as a `BarePath` so any keyword-shaped word is accepted verbatim.
const HELP_TOPIC_OPT: Node = Node::Optional(&Node::BarePath);
// `mode <value>`: known keywords are surfaced as `Word` children
// so they appear in the walker's expected set (and feed the
// completion engine's keyword candidates). The trailing `Ident`
@@ -154,8 +159,15 @@ const fn build_quit(_path: &MatchedPath, _source: &str) -> Result<Command, Valid
Ok(Command::App(AppCommand::Quit))
}
const fn build_help(_path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
Ok(Command::App(AppCommand::Help))
fn build_help(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
// Optional single-word topic (a command entry word, or
// `types`) captured as a `BarePath` — `help insert`,
// `help create`, `help show`. Multi-word commands share an
// entry word, so `help create` covers every create form.
let topic = path
.find(|i| matches!(i.kind, MatchedKind::BarePath))
.map(|i| i.text.clone());
Ok(Command::App(AppCommand::Help { topic }))
}
const fn build_rebuild(_path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
@@ -255,7 +267,7 @@ pub static QUIT: CommandNode = CommandNode {
pub static HELP: CommandNode = CommandNode {
entry: Word::keyword("help"),
shape: EMPTY_SEQ,
shape: HELP_TOPIC_OPT,
ast_builder: build_help,
help_id: Some("app.help"),
usage_ids: &["parse.usage.help"],};
+4 -4
View File
@@ -3088,7 +3088,7 @@ mod tests {
#[test]
fn walker_parses_help() {
assert_eq!(parse("help").unwrap(), Command::App(AppCommand::Help));
assert_eq!(parse("help").unwrap(), Command::App(AppCommand::Help { topic: None }));
}
#[test]
@@ -6644,7 +6644,7 @@ mod dispatch_3a_tests {
// Distinct dummy commands so a test can tell which node a walk
// committed to (the outcome alone doesn't distinguish them).
fn dsl_builder(_: &MatchedPath, _: &str) -> Result<Command, ValidationError> {
Ok(Command::App(AppCommand::Help))
Ok(Command::App(AppCommand::Help { topic: None }))
}
fn sql_builder(_: &MatchedPath, _: &str) -> Result<Command, ValidationError> {
Ok(Command::App(AppCommand::Quit))
@@ -6729,7 +6729,7 @@ mod dispatch_3a_tests {
);
let (outcome, cmd) = dispatch("smk dsltail", Mode::Simple, &cands);
assert!(matches!(outcome, WalkOutcome::Match { .. }), "got {outcome:?}");
assert_eq!(cmd, Some(Command::App(AppCommand::Help)));
assert_eq!(cmd, Some(Command::App(AppCommand::Help { topic: None })));
}
// ---- Exit-gate case 2: Advanced + SQL input → SQL match ----
@@ -6805,7 +6805,7 @@ mod dispatch_3a_tests {
);
let (outcome, cmd) = dispatch("smk dsltail", Mode::Advanced, &cands);
assert!(matches!(outcome, WalkOutcome::Match { .. }), "got {outcome:?}");
assert_eq!(cmd, Some(Command::App(AppCommand::Help)));
assert_eq!(cmd, Some(Command::App(AppCommand::Help { topic: None })));
}
/// In advanced mode a non-shared DSL entry word (no Advanced
+1 -1
View File
@@ -1181,7 +1181,7 @@ mod tests {
// advanced` (verb + payload).
for app in [
AppCommand::Quit,
AppCommand::Help,
AppCommand::Help { topic: None },
AppCommand::Rebuild,
AppCommand::Save,
AppCommand::New,
+2
View File
@@ -174,6 +174,8 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("help.intro", &[]),
("help.dsl_section", &[]),
("help.types_reference", &[]),
("help.detail_hint", &[]),
("help.unknown_topic", &["topic"]),
("help.app.quit", &[]),
("help.app.help", &[]),
("help.app.rebuild", &[]),
+6 -1
View File
@@ -238,6 +238,10 @@ help:
# per line so scroll math stays accurate.
intro: "Supported commands:"
dsl_section: "DSL data commands (in simple mode):"
# H3: footer on the full `help` list, and the not-found note
# for `help <topic>`. `{topic}` is the word the user typed.
detail_hint: "Type `help <command>` for detail on one command (e.g. `help insert`), or `help types` for the type reference."
unknown_topic: "No help for `{topic}`. Type `help` for the full command list, or `help types` for the type reference."
# Per-command help, keyed by `CommandNode.help_id`. Block
# scalars (`|-`) so the column alignment survives — the
# double-quoted form trips a libyml scanner bug on long
@@ -248,6 +252,7 @@ help:
quit — exit the app
help: |-
help — show this command list
help <command> — detailed help for one command (e.g. `help insert`)
rebuild: |-
rebuild — rebuild the project database from project.yaml + data/ (with confirmation)
save: |-
@@ -582,7 +587,7 @@ parse:
# listing in `help.in_app_body` carries the user-facing
# description.
quit: "quit"
help: "help"
help: "help [<command>]"
rebuild: "rebuild"
save: "save | save as"
new: "new"