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
+15 -10
View File
@@ -696,17 +696,22 @@ since ADR-0027.)
there, so the payoff is small. there, so the payoff is small.
- [ ] **H2** `hint` provides contextual help for the current - [ ] **H2** `hint` provides contextual help for the current
input or the most recent error. input or the most recent error.
- [/] **H3** `help` provides general reference and per-command - [x] **H3** `help` provides general reference and per-command
help. help.
*(Partial, verified 2026-06-07: the `help` command *(Done 2026-06-07: the **general reference** is `help` (no arg) —
(`app.rs:2370` `note_help`) lists all currently-supported intro + the full command list (REGISTRY × `help_id`, so new
commands by iterating the REGISTRY + translating each commands appear automatically) + the type reference + a footer
`CommandNode.help_id`, plus an intro and a types reference, so a pointing at the focused form. **Per-command help** is `help
new command appears automatically. **Missing:** `help <command>` <command>` (H3's new piece): the HELP node took an optional
per-command detail — the HELP node's shape is `EMPTY_SEQ`, so it single-word topic (`BarePath`), `AppCommand::Help { topic }`,
takes no argument — and a fuller "general reference". These two and `note_help_topic` renders the block(s) of every command
are the remaining pieces; the `help_id` hook is already there sharing that entry word — so `help create` covers both create
to build per-command detail on.)* forms — plus `help types` for the type reference and a friendly
"no help for `X`" pointer for an unknown topic. Help/usage
strings catalogued + key-registered; 9 integration tests
(`tests/it/help_command.rs`). A richer *narrative* overview
(modes, the `:` escape, syntax conventions) is reference-docs
scope, tracked under **DOC1** — not part of H3.)*
## CLI ## CLI
+48 -2
View File
@@ -1321,8 +1321,11 @@ impl App {
use crate::dsl::{AppCommand, MessagesValue, ModeValue}; use crate::dsl::{AppCommand, MessagesValue, ModeValue};
match cmd { match cmd {
AppCommand::Quit => vec![Action::Quit], AppCommand::Quit => vec![Action::Quit],
AppCommand::Help => { AppCommand::Help { topic } => {
self.note_help(); match &topic {
Some(t) => self.note_help_topic(t),
None => self.note_help(),
}
Vec::new() Vec::new()
} }
AppCommand::Rebuild => vec![Action::PrepareRebuild], AppCommand::Rebuild => vec![Action::PrepareRebuild],
@@ -2405,6 +2408,49 @@ impl App {
.lines() .lines()
.map(str::to_string), .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 { for line in lines {
self.note_system(line); self.note_system(line);
} }
+9 -3
View File
@@ -492,8 +492,14 @@ pub enum Command {
pub enum AppCommand { pub enum AppCommand {
/// Exit cleanly. Accepts the `q` alias. /// Exit cleanly. Accepts the `q` alias.
Quit, Quit,
/// Show in-app help. Body comes from `help.in_app_body`. /// Show in-app help (H3). With no `topic`, the full command
Help, /// 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 /// Rebuild `playground.db` from `project.yaml` + data/, with
/// confirmation modal. /// confirmation modal.
Rebuild, Rebuild,
@@ -910,7 +916,7 @@ impl Command {
Self::SqlDelete { .. } => "delete from", Self::SqlDelete { .. } => "delete from",
Self::App(app) => match app { Self::App(app) => match app {
AppCommand::Quit => "quit", AppCommand::Quit => "quit",
AppCommand::Help => "help", AppCommand::Help { .. } => "help",
AppCommand::Rebuild => "rebuild", AppCommand::Rebuild => "rebuild",
AppCommand::Save => "save", AppCommand::Save => "save",
AppCommand::SaveAs => "save as", 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 EXPORT_PATH_OPT: Node = Node::Optional(&Node::BarePath);
const IMPORT_BODY_OPT: Node = Node::Optional(&IMPORT_PATH_AND_TARGET); 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 // `mode <value>`: known keywords are surfaced as `Word` children
// so they appear in the walker's expected set (and feed the // so they appear in the walker's expected set (and feed the
// completion engine's keyword candidates). The trailing `Ident` // 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)) Ok(Command::App(AppCommand::Quit))
} }
const fn build_help(_path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> { fn build_help(path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
Ok(Command::App(AppCommand::Help)) // 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> { 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 { pub static HELP: CommandNode = CommandNode {
entry: Word::keyword("help"), entry: Word::keyword("help"),
shape: EMPTY_SEQ, shape: HELP_TOPIC_OPT,
ast_builder: build_help, ast_builder: build_help,
help_id: Some("app.help"), help_id: Some("app.help"),
usage_ids: &["parse.usage.help"],}; usage_ids: &["parse.usage.help"],};
+4 -4
View File
@@ -3088,7 +3088,7 @@ mod tests {
#[test] #[test]
fn walker_parses_help() { 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] #[test]
@@ -6644,7 +6644,7 @@ mod dispatch_3a_tests {
// Distinct dummy commands so a test can tell which node a walk // Distinct dummy commands so a test can tell which node a walk
// committed to (the outcome alone doesn't distinguish them). // committed to (the outcome alone doesn't distinguish them).
fn dsl_builder(_: &MatchedPath, _: &str) -> Result<Command, ValidationError> { 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> { fn sql_builder(_: &MatchedPath, _: &str) -> Result<Command, ValidationError> {
Ok(Command::App(AppCommand::Quit)) Ok(Command::App(AppCommand::Quit))
@@ -6729,7 +6729,7 @@ mod dispatch_3a_tests {
); );
let (outcome, cmd) = dispatch("smk dsltail", Mode::Simple, &cands); let (outcome, cmd) = dispatch("smk dsltail", Mode::Simple, &cands);
assert!(matches!(outcome, WalkOutcome::Match { .. }), "got {outcome:?}"); 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 ---- // ---- 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); let (outcome, cmd) = dispatch("smk dsltail", Mode::Advanced, &cands);
assert!(matches!(outcome, WalkOutcome::Match { .. }), "got {outcome:?}"); 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 /// In advanced mode a non-shared DSL entry word (no Advanced
+1 -1
View File
@@ -1181,7 +1181,7 @@ mod tests {
// advanced` (verb + payload). // advanced` (verb + payload).
for app in [ for app in [
AppCommand::Quit, AppCommand::Quit,
AppCommand::Help, AppCommand::Help { topic: None },
AppCommand::Rebuild, AppCommand::Rebuild,
AppCommand::Save, AppCommand::Save,
AppCommand::New, AppCommand::New,
+2
View File
@@ -174,6 +174,8 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("help.intro", &[]), ("help.intro", &[]),
("help.dsl_section", &[]), ("help.dsl_section", &[]),
("help.types_reference", &[]), ("help.types_reference", &[]),
("help.detail_hint", &[]),
("help.unknown_topic", &["topic"]),
("help.app.quit", &[]), ("help.app.quit", &[]),
("help.app.help", &[]), ("help.app.help", &[]),
("help.app.rebuild", &[]), ("help.app.rebuild", &[]),
+6 -1
View File
@@ -238,6 +238,10 @@ help:
# per line so scroll math stays accurate. # per line so scroll math stays accurate.
intro: "Supported commands:" intro: "Supported commands:"
dsl_section: "DSL data commands (in simple mode):" 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 # Per-command help, keyed by `CommandNode.help_id`. Block
# scalars (`|-`) so the column alignment survives — the # scalars (`|-`) so the column alignment survives — the
# double-quoted form trips a libyml scanner bug on long # double-quoted form trips a libyml scanner bug on long
@@ -248,6 +252,7 @@ help:
quit — exit the app quit — exit the app
help: |- help: |-
help — show this command list help — show this command list
help <command> — detailed help for one command (e.g. `help insert`)
rebuild: |- rebuild: |-
rebuild — rebuild the project database from project.yaml + data/ (with confirmation) rebuild — rebuild the project database from project.yaml + data/ (with confirmation)
save: |- save: |-
@@ -582,7 +587,7 @@ parse:
# listing in `help.in_app_body` carries the user-facing # listing in `help.in_app_body` carries the user-facing
# description. # description.
quit: "quit" quit: "quit"
help: "help" help: "help [<command>]"
rebuild: "rebuild" rebuild: "rebuild"
save: "save | save as" save: "save | save as"
new: "new" new: "new"
+141
View File
@@ -0,0 +1,141 @@
//! Integration tests for `help` and `help <command>` (H3).
//!
//! Covers:
//! - Parse layer: `help` → `Help { topic: None }`; `help insert`
//! → `Help { topic: Some("insert") }`.
//! - App behaviour: the full `help` ends with the detail hint;
//! `help <command>` renders that command's block (and every
//! form sharing the entry word); `help types` renders the type
//! reference; an unknown topic gets a friendly pointer back.
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use rdbms_playground::app::App;
use rdbms_playground::dsl::{parse_command, AppCommand, Command};
use rdbms_playground::event::AppEvent;
const fn key(code: KeyCode) -> AppEvent {
AppEvent::Key(KeyEvent {
code,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
state: crossterm::event::KeyEventState::NONE,
})
}
fn type_str(app: &mut App, s: &str) {
for c in s.chars() {
app.update(key(KeyCode::Char(c)));
}
}
/// Submit `input` to a fresh app and collect all output text.
fn output_for(input: &str) -> Vec<String> {
let mut app = App::new();
type_str(&mut app, input);
app.update(key(KeyCode::Enter));
app.output.iter().map(|l| l.text.clone()).collect()
}
// =================================================================
// Parse layer
// =================================================================
#[test]
fn bare_help_parses_with_no_topic() {
assert_eq!(
parse_command("help").expect("parses"),
Command::App(AppCommand::Help { topic: None }),
);
}
#[test]
fn help_with_topic_captures_the_word() {
assert_eq!(
parse_command("help insert").expect("parses"),
Command::App(AppCommand::Help {
topic: Some("insert".to_string())
}),
);
}
#[test]
fn help_topic_is_a_single_word_multi_word_is_a_parse_error() {
// Entry-word topics cover multi-word commands (`help create`),
// so a second word is trailing junk, not a longer topic.
assert!(parse_command("help foo bar").is_err());
}
// =================================================================
// App behaviour
// =================================================================
#[test]
fn full_help_lists_commands_and_ends_with_the_detail_hint() {
let out = output_for("help");
assert!(
out.iter().any(|l| l == "Supported commands:"),
"intro present: {out:?}",
);
assert!(
out.iter().any(|l| l.contains("help <command>")),
"detail-hint footer present: {out:?}",
);
}
#[test]
fn help_insert_renders_the_insert_block() {
let out = output_for("help insert");
assert!(
out.iter().any(|l| l.contains("insert into")),
"insert help shown: {out:?}",
);
// Focused: it must NOT dump the whole list — the intro header
// belongs to the full `help` only.
assert!(
!out.iter().any(|l| l == "Supported commands:"),
"focused help omits the full-list intro: {out:?}",
);
}
#[test]
fn help_create_covers_every_form_sharing_the_entry_word() {
// `create` is the entry word for both the DSL `create table`
// and the advanced SQL `CREATE TABLE` — `help create` shows
// both blocks.
let out = output_for("help create");
let joined = out.join("\n");
assert!(
joined.contains("create table"),
"DSL create form shown: {out:?}",
);
assert!(
joined.to_lowercase().matches("create").count() >= 2,
"more than one create form shown: {out:?}",
);
}
#[test]
fn help_types_renders_the_type_reference() {
let out = output_for("help types");
let joined = out.join("\n").to_lowercase();
// The type reference names the playground types.
assert!(
joined.contains("serial") || joined.contains("shortid"),
"type reference shown: {out:?}",
);
}
#[test]
fn help_unknown_topic_points_back_to_the_full_list() {
let out = output_for("help wibble");
assert!(
out.iter()
.any(|l| l.contains("No help for") && l.contains("wibble")),
"names the unknown topic: {out:?}",
);
assert!(
out.iter().any(|l| l.contains("Type `help`")),
"points back at the full list: {out:?}",
);
}
+1
View File
@@ -11,6 +11,7 @@ mod case_insensitive_names;
mod column_op_guards; mod column_op_guards;
mod engine_vocabulary_audit; mod engine_vocabulary_audit;
mod friendly_enrichment; mod friendly_enrichment;
mod help_command;
mod iteration2_persistence; mod iteration2_persistence;
mod iteration3_rebuild; mod iteration3_rebuild;
mod iteration4a_rebuild_command; mod iteration4a_rebuild_command;
+4 -1
View File
@@ -72,7 +72,10 @@ fn near_miss_matrix_simple_mode() {
// trailing junk with "expected end of input" + their usage // trailing junk with "expected end of input" + their usage
// (audited 2026-06-05); locked here as regression insurance. // (audited 2026-06-05); locked here as regression insurance.
("quit now", &["after `quit`, expected end of input", " quit"]), ("quit now", &["after `quit`, expected end of input", " quit"]),
("help foo", &["after `help`, expected end of input", " help"]), // `help` now takes an optional single-word topic (H3), so
// `help foo` parses (topic lookup); only a *multi-word*
// topic is the near-miss that rejects trailing junk.
("help foo bar", &["after `help foo`, expected end of input", "help [<command>]"]),
("rebuild now", &["after `rebuild`, expected end of input", " rebuild"]), ("rebuild now", &["after `rebuild`, expected end of input", " rebuild"]),
("new foo", &["after `new`, expected end of input", " new"]), ("new foo", &["after `new`, expected end of input", " new"]),
("load foo", &["after `load`, expected end of input", " load"]), ("load foo", &["after `load`, expected end of input", " load"]),
+1 -1
View File
@@ -246,7 +246,7 @@ fn command_kind_label(cmd: &rdbms_playground::dsl::Command) -> String {
SqlDelete { .. } => "SqlDelete".into(), SqlDelete { .. } => "SqlDelete".into(),
App(app) => match app { App(app) => match app {
AppCommand::Quit => "App(Quit)".into(), AppCommand::Quit => "App(Quit)".into(),
AppCommand::Help => "App(Help)".into(), AppCommand::Help { .. } => "App(Help)".into(),
AppCommand::Rebuild => "App(Rebuild)".into(), AppCommand::Rebuild => "App(Rebuild)".into(),
AppCommand::Save => "App(Save)".into(), AppCommand::Save => "App(Save)".into(),
AppCommand::SaveAs => "App(SaveAs)".into(), AppCommand::SaveAs => "App(SaveAs)".into(),