From 757711f2bf90e5cc911acf1787a15748389b5c88 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Sun, 7 Jun 2026 13:32:18 +0000 Subject: [PATCH] feat: H3 help 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]. --- docs/requirements.md | 25 +++--- src/app.rs | 50 ++++++++++- src/dsl/command.rs | 12 ++- src/dsl/grammar/app.rs | 18 +++- src/dsl/walker/mod.rs | 8 +- src/echo.rs | 2 +- src/friendly/keys.rs | 2 + src/friendly/strings/en-US.yaml | 7 +- tests/it/help_command.rs | 141 +++++++++++++++++++++++++++++++ tests/it/main.rs | 1 + tests/it/parse_error_pedagogy.rs | 5 +- tests/typing_surface/mod.rs | 2 +- 12 files changed, 247 insertions(+), 26 deletions(-) create mode 100644 tests/it/help_command.rs diff --git a/docs/requirements.md b/docs/requirements.md index 32d92e0..4f9868a 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -696,17 +696,22 @@ since ADR-0027.) there, so the payoff is small. - [ ] **H2** `hint` provides contextual help for the current input or the most recent error. -- [/] **H3** `help` provides general reference and per-command +- [x] **H3** `help` provides general reference and per-command help. - *(Partial, verified 2026-06-07: the `help` command - (`app.rs:2370` `note_help`) lists all currently-supported - commands by iterating the REGISTRY + translating each - `CommandNode.help_id`, plus an intro and a types reference, so a - new command appears automatically. **Missing:** `help ` - per-command detail — the HELP node's shape is `EMPTY_SEQ`, so it - takes no argument — and a fuller "general reference". These two - are the remaining pieces; the `help_id` hook is already there - to build per-command detail on.)* + *(Done 2026-06-07: the **general reference** is `help` (no arg) — + intro + the full command list (REGISTRY × `help_id`, so new + commands appear automatically) + the type reference + a footer + pointing at the focused form. **Per-command help** is `help + ` (H3's new piece): the HELP node took an optional + single-word topic (`BarePath`), `AppCommand::Help { topic }`, + and `note_help_topic` renders the block(s) of every command + sharing that entry word — so `help create` covers both create + 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 diff --git a/src/app.rs b/src/app.rs index 425b91d..f077c2f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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 `, 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 = 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); } diff --git a/src/dsl/command.rs b/src/dsl/command.rs index 60c8815..0a1fc4e 100644 --- a/src/dsl/command.rs +++ b/src/dsl/command.rs @@ -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, + }, /// 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", diff --git a/src/dsl/grammar/app.rs b/src/dsl/grammar/app.rs index a40254a..8bc4361 100644 --- a/src/dsl/grammar/app.rs +++ b/src/dsl/grammar/app.rs @@ -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 []` (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 `: 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 Result { - Ok(Command::App(AppCommand::Help)) +fn build_help(path: &MatchedPath, _source: &str) -> Result { + // 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 { @@ -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"],}; diff --git a/src/dsl/walker/mod.rs b/src/dsl/walker/mod.rs index bf6d34e..e8dcd05 100644 --- a/src/dsl/walker/mod.rs +++ b/src/dsl/walker/mod.rs @@ -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 { - Ok(Command::App(AppCommand::Help)) + Ok(Command::App(AppCommand::Help { topic: None })) } fn sql_builder(_: &MatchedPath, _: &str) -> Result { 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 diff --git a/src/echo.rs b/src/echo.rs index 8bd6adb..81d5eff 100644 --- a/src/echo.rs +++ b/src/echo.rs @@ -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, diff --git a/src/friendly/keys.rs b/src/friendly/keys.rs index 13c9b9f..bd23075 100644 --- a/src/friendly/keys.rs +++ b/src/friendly/keys.rs @@ -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", &[]), diff --git a/src/friendly/strings/en-US.yaml b/src/friendly/strings/en-US.yaml index 9366f7f..194d0f2 100644 --- a/src/friendly/strings/en-US.yaml +++ b/src/friendly/strings/en-US.yaml @@ -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}` is the word the user typed. + detail_hint: "Type `help ` 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 — 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 []" rebuild: "rebuild" save: "save | save as" new: "new" diff --git a/tests/it/help_command.rs b/tests/it/help_command.rs new file mode 100644 index 0000000..b455f60 --- /dev/null +++ b/tests/it/help_command.rs @@ -0,0 +1,141 @@ +//! Integration tests for `help` and `help ` (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 ` 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 { + 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 ")), + "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:?}", + ); +} diff --git a/tests/it/main.rs b/tests/it/main.rs index 754daa2..ba3384f 100644 --- a/tests/it/main.rs +++ b/tests/it/main.rs @@ -11,6 +11,7 @@ mod case_insensitive_names; mod column_op_guards; mod engine_vocabulary_audit; mod friendly_enrichment; +mod help_command; mod iteration2_persistence; mod iteration3_rebuild; mod iteration4a_rebuild_command; diff --git a/tests/it/parse_error_pedagogy.rs b/tests/it/parse_error_pedagogy.rs index 8f04c3e..240aa71 100644 --- a/tests/it/parse_error_pedagogy.rs +++ b/tests/it/parse_error_pedagogy.rs @@ -72,7 +72,10 @@ fn near_miss_matrix_simple_mode() { // trailing junk with "expected end of input" + their usage // (audited 2026-06-05); locked here as regression insurance. ("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 []"]), ("rebuild now", &["after `rebuild`, expected end of input", " rebuild"]), ("new foo", &["after `new`, expected end of input", " new"]), ("load foo", &["after `load`, expected end of input", " load"]), diff --git a/tests/typing_surface/mod.rs b/tests/typing_surface/mod.rs index 55a57d2..ff9369e 100644 --- a/tests/typing_surface/mod.rs +++ b/tests/typing_surface/mod.rs @@ -246,7 +246,7 @@ fn command_kind_label(cmd: &rdbms_playground::dsl::Command) -> String { SqlDelete { .. } => "SqlDelete".into(), App(app) => match app { AppCommand::Quit => "App(Quit)".into(), - AppCommand::Help => "App(Help)".into(), + AppCommand::Help { .. } => "App(Help)".into(), AppCommand::Rebuild => "App(Rebuild)".into(), AppCommand::Save => "App(Save)".into(), AppCommand::SaveAs => "App(SaveAs)".into(),