From 050b36391e2a863efba12ac170e6e3ac4c53ecd5 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Mon, 15 Jun 2026 10:36:51 +0000 Subject: [PATCH] =?UTF-8?q?feat(hint):=20H2=20Phase=20A=20=E2=80=94=20`hin?= =?UTF-8?q?t`=20command=20+=20F1=20keybinding=20skeleton=20(ADR-0053)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mechanism for the contextual hint, with tier-2 fallback; the tier-3 corpus lands in later phases. - new CommandNode `hint_id` field (all None for now) - AppCommand::Hint + HINT grammar node + REGISTRY + dispatch - F1 read-only overlay in handle_key (buffer/cursor/memo untouched) - note_hint* renderers; hint_id_for_input_in_mode (shared selection helper refactored out of usage_keys_for_input_in_mode) - last_error_hint_key + friendly::error_hint_class classifier - catalogue: help.app.hint / parse.usage.hint / hint.getting_started - +12 tests; 2483 pass / 1 ignored, clippy clean --- src/app.rs | 261 ++++++++++++++++++++++++++++++++ src/dsl/command.rs | 6 + src/dsl/grammar/app.rs | 25 +++ src/dsl/grammar/data.rs | 13 ++ src/dsl/grammar/ddl.rs | 11 ++ src/dsl/grammar/mod.rs | 99 ++++++++---- src/dsl/walker/mod.rs | 2 + src/friendly/keys.rs | 3 + src/friendly/mod.rs | 2 +- src/friendly/strings/en-US.yaml | 6 + src/friendly/translate.rs | 153 +++++++++++++++++++ tests/typing_surface/mod.rs | 1 + 12 files changed, 550 insertions(+), 32 deletions(-) diff --git a/src/app.rs b/src/app.rs index b6853c6..e8a645d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -271,6 +271,13 @@ pub struct App { pub nav_focus: NavFocus, pub output: VecDeque, pub hint: Option, + /// Catalog class key of the most recent runtime error (H2 / + /// ADR-0053 D5), e.g. `foreign_key.child_side`. Set when a + /// friendly error is rendered, cleared on the next successful + /// command. The submitted `hint` command and empty-input F1 use + /// it to render that error's tier-3 `hint.err.` block. + /// `None` → no recent error → the "getting started" pointer. + pub last_error_hint_key: Option, /// The validity indicator's currently-visible verdict /// (ADR-0027). `None` means the indicator shows nothing — /// the input is clean, or it is hidden mid-typing while the @@ -521,6 +528,7 @@ pub const fn demo_badge_label(key: &KeyEvent) -> Option<&'static str> { match (key.code, key.modifiers) { (KeyCode::Tab, _) => Some("[TAB]"), (KeyCode::BackTab, _) => Some("[SHIFT-TAB]"), + (KeyCode::F(1), _) => Some("[F1]"), (KeyCode::Enter, _) => Some("[ENTER]"), (KeyCode::Esc, _) => Some("[ESC]"), (KeyCode::Up, _) => Some("[UP]"), @@ -557,6 +565,7 @@ impl App { nav_focus: NavFocus::Input, output: VecDeque::with_capacity(OUTPUT_CAPACITY), hint: None, + last_error_hint_key: None, input_indicator: None, tables: Vec::new(), relationships: Vec::new(), @@ -1208,6 +1217,21 @@ impl App { return self.handle_nav_key(key); } + // H2 / ADR-0053: F1 is a read-only contextual-hint overlay — + // it emits into the output journal and must NOT touch the input + // buffer, cursor, or the completion memo, so it sits ahead of + // the memo-clearing completion match below. Non-empty input → + // a hint for the command being typed; empty input → expand on + // the most recent error (or a getting-started pointer). + if key.code == KeyCode::F(1) { + if self.input.trim().is_empty() { + self.note_hint_for_recent_error(); + } else { + self.note_hint_for_input(); + } + return Vec::new(); + } + // ADR-0022 stage 8 — non-modal completion. Tab / // Shift-Tab cycle; Esc / Backspace undo the whole // last-Tab insertion in one keystroke while the memo @@ -1774,6 +1798,13 @@ impl App { // recallable. The canonical (un-prefixed) text is what reaches // the journal via `ExecuteDsl.source`. let is_app = matches!(&parsed, Ok(Command::App(_))); + // H2 / ADR-0053 D5: a new *DSL* command supersedes the previous + // runtime error for `hint`. App commands (incl. `hint` itself) + // and parse errors leave it intact, so `hint` still expands the + // last real error after, say, a `help` in between. + if matches!(&parsed, Ok(cmd) if !matches!(cmd, Command::App(_))) { + self.last_error_hint_key = None; + } let advanced = submission_mode.is_advanced() && !is_app; let ring_line = if advanced { format!(": {effective_input}") @@ -1814,6 +1845,13 @@ impl App { } Vec::new() } + // H2 / ADR-0053: a submitted `hint` acts on the most recent + // runtime error (the buffer is empty post-submit). The + // live-input surface is the F1 keybinding (handle_key). + AppCommand::Hint => { + self.note_hint_for_recent_error(); + Vec::new() + } AppCommand::Rebuild => vec![Action::PrepareRebuild], AppCommand::Save => self.handle_save_command(false), AppCommand::SaveAs => self.handle_save_command(true), @@ -2422,6 +2460,10 @@ impl App { // runtime built before posting the event. let ctx = self.build_translate_context(command, facts); let rendered = crate::friendly::translate_error(&error, &ctx).render(); + // H2 / ADR-0053 D5: remember this error's tier-3 class so a + // following `hint` (or empty-input F1) can expand on it. + self.last_error_hint_key = + crate::friendly::error_hint_class(&error, &ctx).map(String::from); warn!( verb = command.verb(), error = %rendered, @@ -3091,6 +3133,94 @@ impl App { } } + // ── H2 / ADR-0053: contextual `hint` ──────────────────────── + // Phase A wires the two surfaces (F1 → live input; the `hint` + // command → most recent error) plus the tier-2 fallback. The + // tier-3 corpus (`hint.cmd.*` / `hint.err.*`) is authored in later + // phases; until a block exists, `emit_tier3_block` returns false + // and the surface degrades to the ambient prose / getting-started + // pointer — never blank. + + /// F1 with a non-empty buffer: a tier-3 hint for the command form + /// being typed, else the tier-2 ambient prose (ADR-0053 D2). + /// Read-only — callers guarantee the buffer/cursor/memo are left + /// untouched. + fn note_hint_for_input(&mut self) { + // `feedback_view` strips the `:` one-shot sigil and + // `effective_mode` reflects the one-shot advanced surface, so + // the hint matches the command the user is actually typing. + let (view, cursor, _off) = self.feedback_view(); + let probe = view.to_string(); + let mode = self.effective_mode().as_mode(); + if let Some(id) = crate::dsl::grammar::hint_id_for_input_in_mode(&probe, mode) + && self.emit_tier3_block(&format!("hint.cmd.{id}")) + { + return; + } + // Tier-2 fallback: surface the ambient prose as a persistent + // line (computed exactly as the live panel does). + let ambient = crate::input_render::ambient_hint_in_mode( + &probe, + cursor, + self.last_completion.as_ref(), + &self.schema_cache, + mode, + ); + match ambient { + Some(crate::input_render::AmbientHint::Prose(text)) => { + self.push_category_three_prose(text); + } + Some(crate::input_render::AmbientHint::Candidates { items, .. }) => { + let names = items + .iter() + .map(|c| c.text.clone()) + .collect::>() + .join(", "); + self.push_category_three_prose(crate::t!("hint.ambient_expected", expected = names)); + } + None => self.note_getting_started(), + } + } + + /// The `hint` command (and empty-input F1): expand on the most + /// recent runtime error, else point the user at how to start + /// (ADR-0053 D2/D5). + fn note_hint_for_recent_error(&mut self) { + if let Some(class) = self.last_error_hint_key.clone() + && self.emit_tier3_block(&format!("hint.err.{class}")) + { + return; + } + self.note_getting_started(); + } + + fn note_getting_started(&mut self) { + self.note_system(crate::t!("hint.getting_started")); + } + + /// Render a tier-3 block (`.what` / `.example` / `.concept`) + /// when it has been authored; returns `false` if the `what` part is + /// absent so the caller can fall back to tier 2. `what` is + /// mandatory, `example`/`concept` optional (ADR-0053 D3). Styling + /// polish (the framed block) lands with the corpus. + fn emit_tier3_block(&mut self, stem: &str) -> bool { + let cat = crate::friendly::catalog(); + if cat.get(&format!("{stem}.what")).is_none() { + return false; + } + self.note_system(crate::friendly::translate(&format!("{stem}.what"), &[])); + if cat.get(&format!("{stem}.example")).is_some() { + self.note_system(crate::friendly::translate(&format!("{stem}.example"), &[])); + } + if cat.get(&format!("{stem}.concept")).is_some() { + self.push_category_three_prose(crate::friendly::translate( + &format!("{stem}.concept"), + &[], + )); + } + true + } + fn note_system(&mut self, text: impl Into) { self.push_multiline(text.into(), OutputKind::System); } @@ -5539,6 +5669,137 @@ mod tests { assert!(last.text.contains("Ghost"), "{}", last.text); } + // ── H2 / ADR-0053: contextual `hint` (Phase A skeleton) ────── + + fn f1(app: &mut App) -> Vec { + app.update(key(KeyCode::F(1))) + } + + fn no_such_table_failure() -> AppEvent { + AppEvent::DslFailed { + command: Command::DropTable { + name: "Ghost".to_string(), + }, + error: crate::db::DbError::Sqlite { + message: "no such table: Ghost".to_string(), + kind: crate::db::SqliteErrorKind::NoSuchTable, + }, + facts: crate::friendly::FailureContext::default(), + source: String::new(), + advanced: false, + } + } + + #[test] + fn hint_command_parses_to_app_hint() { + use crate::dsl::{parse_command, AppCommand, Command}; + assert!(matches!( + parse_command("hint"), + Ok(Command::App(AppCommand::Hint)) + )); + } + + #[test] + fn hint_command_with_no_recent_error_shows_getting_started() { + let mut app = App::new(); + type_str(&mut app, "hint"); + submit(&mut app); + assert!(output_contains(&app, "press F1"), "{}", error_lines(&app)); + } + + #[test] + fn f1_on_empty_input_with_no_error_shows_getting_started() { + let mut app = App::new(); + let before = app.output.len(); + f1(&mut app); + assert!(app.output.len() > before, "F1 must emit something"); + assert!(output_contains(&app, "press F1")); + } + + #[test] + fn f1_is_a_read_only_overlay() { + let mut app = App::new(); + type_str(&mut app, "insert into T"); + let input = app.input.clone(); + let cursor = app.input_cursor; + let before = app.output.len(); + f1(&mut app); + assert_eq!(app.input, input, "F1 must not change the buffer"); + assert_eq!(app.input_cursor, cursor, "F1 must not move the cursor"); + assert!(app.output.len() > before, "F1 emits a hint line"); + } + + #[test] + fn f1_preserves_the_completion_memo() { + let mut app = App::new(); + type_str(&mut app, "show "); + app.update(key(KeyCode::Tab)); + assert!(app.last_completion.is_some(), "precondition: Tab sets the memo"); + let input = app.input.clone(); + f1(&mut app); + assert!(app.last_completion.is_some(), "F1 must not clear the memo"); + assert_eq!(app.input, input, "F1 must not change the buffer"); + } + + #[test] + fn dsl_failure_sets_hint_class_and_a_later_dsl_command_clears_it() { + let mut app = App::new(); + app.update(no_such_table_failure()); + assert_eq!(app.last_error_hint_key.as_deref(), Some("not_found")); + // A new DSL command supersedes the previous error. + type_str(&mut app, "drop table Ghost"); + submit(&mut app); + assert_eq!(app.last_error_hint_key, None); + } + + #[test] + fn app_command_does_not_clear_the_hint_class() { + let mut app = App::new(); + app.update(no_such_table_failure()); + assert_eq!(app.last_error_hint_key.as_deref(), Some("not_found")); + // `help` (an app command) leaves the last error intact, so a + // following `hint` still expands on it. + type_str(&mut app, "help"); + submit(&mut app); + assert_eq!( + app.last_error_hint_key.as_deref(), + Some("not_found"), + "an app command must not clear the last error's hint class" + ); + } + + #[test] + fn hint_after_error_emits_a_hint_without_panicking() { + // Phase A: no tier-3 `hint.err.*` content exists yet, so the + // error path falls back to the getting-started pointer. (Phase C + // replaces this with the real error block.) + let mut app = App::new(); + app.update(no_such_table_failure()); + let before = app.output.len(); + type_str(&mut app, "hint"); + submit(&mut app); + assert!(app.output.len() > before, "hint must emit something"); + } + + #[test] + fn help_list_includes_hint() { + let mut app = App::new(); + type_str(&mut app, "help"); + submit(&mut app); + assert!( + output_contains(&app, "explain the most recent error"), + "help list must advertise the hint command" + ); + } + + #[test] + fn help_hint_describes_the_hint_command() { + let mut app = App::new(); + type_str(&mut app, "help hint"); + submit(&mut app); + assert!(output_contains(&app, "explain the most recent error")); + } + #[test] fn messages_command_toggles_verbosity_and_reports() { let mut app = App::new(); diff --git a/src/dsl/command.rs b/src/dsl/command.rs index 99304a3..66d1933 100644 --- a/src/dsl/command.rs +++ b/src/dsl/command.rs @@ -552,6 +552,11 @@ pub enum AppCommand { Help { topic: Option, }, + /// Show a contextual tier-3 hint (H2 / ADR-0053). No argument: + /// when submitted, it expands on the most recent runtime error + /// (the buffer is empty post-submit). The live-input surface is + /// the F1 keybinding, handled in `App::handle_key`, not here. + Hint, /// Rebuild `playground.db` from `project.yaml` + data/, with /// confirmation modal. Rebuild, @@ -1013,6 +1018,7 @@ impl Command { Self::App(app) => match app { AppCommand::Quit => "quit", AppCommand::Help { .. } => "help", + AppCommand::Hint => "hint", 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 8bc4361..75dfada 100644 --- a/src/dsl/grammar/app.rs +++ b/src/dsl/grammar/app.rs @@ -177,6 +177,9 @@ const fn build_rebuild(_path: &MatchedPath, _source: &str) -> Result Result { Ok(Command::App(AppCommand::Undo)) } +const fn build_hint(_path: &MatchedPath, _source: &str) -> Result { + Ok(Command::App(AppCommand::Hint)) +} const fn build_redo(_path: &MatchedPath, _source: &str) -> Result { Ok(Command::App(AppCommand::Redo)) @@ -263,6 +266,7 @@ pub static QUIT: CommandNode = CommandNode { shape: EMPTY_SEQ, ast_builder: build_quit, help_id: Some("app.quit"), + hint_id: None, usage_ids: &["parse.usage.quit"],}; pub static HELP: CommandNode = CommandNode { @@ -270,13 +274,24 @@ pub static HELP: CommandNode = CommandNode { shape: HELP_TOPIC_OPT, ast_builder: build_help, help_id: Some("app.help"), + hint_id: None, usage_ids: &["parse.usage.help"],}; +pub static HINT: CommandNode = CommandNode { + entry: Word::keyword("hint"), + shape: EMPTY_SEQ, + ast_builder: build_hint, + help_id: Some("app.hint"), + // hint_id assigned in Phase C with the tier-3 corpus (ADR-0053). + hint_id: None, + usage_ids: &["parse.usage.hint"],}; + pub static REBUILD: CommandNode = CommandNode { entry: Word::keyword("rebuild"), shape: EMPTY_SEQ, ast_builder: build_rebuild, help_id: Some("app.rebuild"), + hint_id: None, usage_ids: &["parse.usage.rebuild"],}; pub static SAVE: CommandNode = CommandNode { @@ -284,6 +299,7 @@ pub static SAVE: CommandNode = CommandNode { shape: SAVE_AS_OPT, ast_builder: build_save, help_id: Some("app.save"), + hint_id: None, usage_ids: &["parse.usage.save"],}; pub static NEW: CommandNode = CommandNode { @@ -291,6 +307,7 @@ pub static NEW: CommandNode = CommandNode { shape: EMPTY_SEQ, ast_builder: build_new, help_id: Some("app.new"), + hint_id: None, usage_ids: &["parse.usage.new"],}; pub static LOAD: CommandNode = CommandNode { @@ -298,6 +315,7 @@ pub static LOAD: CommandNode = CommandNode { shape: EMPTY_SEQ, ast_builder: build_load, help_id: Some("app.load"), + hint_id: None, usage_ids: &["parse.usage.load"],}; pub static EXPORT: CommandNode = CommandNode { @@ -305,6 +323,7 @@ pub static EXPORT: CommandNode = CommandNode { shape: EXPORT_PATH_OPT, ast_builder: build_export, help_id: Some("app.export"), + hint_id: None, usage_ids: &["parse.usage.export"],}; pub static IMPORT: CommandNode = CommandNode { @@ -312,6 +331,7 @@ pub static IMPORT: CommandNode = CommandNode { shape: IMPORT_BODY_OPT, ast_builder: build_import, help_id: Some("app.import"), + hint_id: None, usage_ids: &["parse.usage.import"],}; pub static MODE: CommandNode = CommandNode { @@ -319,6 +339,7 @@ pub static MODE: CommandNode = CommandNode { shape: MODE_VALUE, ast_builder: build_mode, help_id: Some("app.mode"), + hint_id: None, usage_ids: &["parse.usage.mode"],}; pub static MESSAGES: CommandNode = CommandNode { @@ -326,6 +347,7 @@ pub static MESSAGES: CommandNode = CommandNode { shape: MESSAGES_VALUE_OPT, ast_builder: build_messages, help_id: Some("app.messages"), + hint_id: None, usage_ids: &["parse.usage.messages"],}; pub static UNDO: CommandNode = CommandNode { @@ -333,6 +355,7 @@ pub static UNDO: CommandNode = CommandNode { shape: EMPTY_SEQ, ast_builder: build_undo, help_id: Some("app.undo"), + hint_id: None, usage_ids: &["parse.usage.undo"],}; pub static REDO: CommandNode = CommandNode { @@ -340,6 +363,7 @@ pub static REDO: CommandNode = CommandNode { shape: EMPTY_SEQ, ast_builder: build_redo, help_id: Some("app.redo"), + hint_id: None, usage_ids: &["parse.usage.redo"],}; pub static COPY: CommandNode = CommandNode { @@ -347,4 +371,5 @@ pub static COPY: CommandNode = CommandNode { shape: COPY_VALUE_OPT, ast_builder: build_copy, help_id: Some("app.copy"), + hint_id: None, usage_ids: &["parse.usage.copy"],}; diff --git a/src/dsl/grammar/data.rs b/src/dsl/grammar/data.rs index 3f14b93..f76cebe 100644 --- a/src/dsl/grammar/data.rs +++ b/src/dsl/grammar/data.rs @@ -1790,6 +1790,7 @@ pub static SHOW: CommandNode = CommandNode { shape: SHOW_SHAPE, ast_builder: build_show, help_id: Some("data.show"), + hint_id: None, usage_ids: &[ "parse.usage.show_data", "parse.usage.show_table", @@ -1805,6 +1806,7 @@ pub static SEED: CommandNode = CommandNode { shape: SEED_SHAPE, ast_builder: build_seed, help_id: Some("data.seed"), + hint_id: None, usage_ids: &["parse.usage.seed"], }; @@ -1813,6 +1815,7 @@ pub static INSERT: CommandNode = CommandNode { shape: INSERT_SHAPE, ast_builder: build_insert, help_id: Some("data.insert"), + hint_id: None, usage_ids: &["parse.usage.insert"],}; pub static UPDATE: CommandNode = CommandNode { @@ -1820,6 +1823,7 @@ pub static UPDATE: CommandNode = CommandNode { shape: UPDATE_SHAPE, ast_builder: build_update, help_id: Some("data.update"), + hint_id: None, usage_ids: &["parse.usage.update"],}; pub static DELETE: CommandNode = CommandNode { @@ -1827,6 +1831,7 @@ pub static DELETE: CommandNode = CommandNode { shape: DELETE_SHAPE, ast_builder: build_delete, help_id: Some("data.delete"), + hint_id: None, usage_ids: &["parse.usage.delete"],}; pub static REPLAY: CommandNode = CommandNode { @@ -1834,6 +1839,7 @@ pub static REPLAY: CommandNode = CommandNode { shape: REPLAY_PATH, ast_builder: build_replay, help_id: Some("data.replay"), + hint_id: None, usage_ids: &["parse.usage.replay"],}; pub static EXPLAIN: CommandNode = CommandNode { @@ -1841,6 +1847,7 @@ pub static EXPLAIN: CommandNode = CommandNode { shape: EXPLAIN_SHAPE, ast_builder: build_explain, help_id: Some("data.explain"), + hint_id: None, usage_ids: &["parse.usage.explain"],}; /// `explain` over advanced-mode SQL (ADR-0039). @@ -1860,6 +1867,7 @@ pub static EXPLAIN_SQL: CommandNode = CommandNode { // too). Mirrors the `SQL_INSERT`/`SQL_UPDATE`/`SQL_DELETE` // precedent; otherwise `note_help` would print `explain` twice. help_id: None, + hint_id: None, usage_ids: &[],}; /// SQL `SELECT` (ADR-0030 §6, ADR-0031, ADR-0032). @@ -1875,6 +1883,7 @@ pub static SELECT: CommandNode = CommandNode { shape: Node::Subgrammar(&sql_select::SQL_SELECT_TAIL), ast_builder: build_select, help_id: None, + hint_id: None, usage_ids: &["parse.usage.select"],}; /// `WITH …` top-level statement (ADR-0032 §4 / sub-phase 2c). @@ -1889,6 +1898,7 @@ pub static WITH: CommandNode = CommandNode { shape: Node::Subgrammar(&sql_select::SQL_WITH_TAIL), ast_builder: build_select, help_id: None, + hint_id: None, usage_ids: &["parse.usage.with"],}; /// SQL `INSERT` — the `Advanced`-category node of the shared @@ -1906,6 +1916,7 @@ pub static SQL_INSERT: CommandNode = CommandNode { shape: Node::Subgrammar(&sql_insert::SQL_INSERT_SHAPE), ast_builder: build_sql_insert, help_id: None, + hint_id: None, usage_ids: &[], }; @@ -1919,6 +1930,7 @@ pub static SQL_UPDATE: CommandNode = CommandNode { shape: Node::Subgrammar(&sql_update::SQL_UPDATE_SHAPE), ast_builder: build_sql_update, help_id: None, + hint_id: None, usage_ids: &[], }; @@ -1934,6 +1946,7 @@ pub static SQL_DELETE: CommandNode = CommandNode { shape: Node::Subgrammar(&sql_delete::SQL_DELETE_SHAPE), ast_builder: build_sql_delete, help_id: None, + hint_id: None, usage_ids: &[], }; diff --git a/src/dsl/grammar/ddl.rs b/src/dsl/grammar/ddl.rs index 0167093..e6189d9 100644 --- a/src/dsl/grammar/ddl.rs +++ b/src/dsl/grammar/ddl.rs @@ -968,6 +968,7 @@ pub static DROP: CommandNode = CommandNode { shape: DROP_SHAPE, ast_builder: build_drop, help_id: Some("ddl.drop"), + hint_id: None, usage_ids: &[ "parse.usage.drop_table", "parse.usage.drop_column", @@ -981,6 +982,7 @@ pub static ADD: CommandNode = CommandNode { shape: ADD_SHAPE, ast_builder: build_add, help_id: Some("ddl.add"), + hint_id: None, usage_ids: &[ "parse.usage.add_column", "parse.usage.add_relationship", @@ -993,6 +995,7 @@ pub static RENAME: CommandNode = CommandNode { shape: RENAME_COLUMN, ast_builder: build_rename_column, help_id: Some("ddl.rename"), + hint_id: None, usage_ids: &["parse.usage.rename_column"],}; pub static CHANGE: CommandNode = CommandNode { @@ -1000,6 +1003,7 @@ pub static CHANGE: CommandNode = CommandNode { shape: CHANGE_COLUMN, ast_builder: build_change_column, help_id: Some("ddl.change"), + hint_id: None, usage_ids: &["parse.usage.change_column"],}; // ================================================================= @@ -1360,6 +1364,7 @@ pub static CREATE: CommandNode = CommandNode { shape: CREATE_TABLE, ast_builder: build_create_table, help_id: Some("ddl.create"), + hint_id: None, usage_ids: &["parse.usage.create_table"],}; // ================================================================= @@ -1428,6 +1433,7 @@ pub static CREATE_M2N: CommandNode = CommandNode { shape: CREATE_M2N_SHAPE, ast_builder: build_create_m2n, help_id: Some("ddl.create_m2n"), + hint_id: None, usage_ids: &["parse.usage.create_m2n"], }; @@ -1858,6 +1864,7 @@ pub static SQL_CREATE_TABLE: CommandNode = CommandNode { shape: Node::Subgrammar(&super::sql_create_table::SQL_CREATE_TABLE_SHAPE), ast_builder: build_sql_create_table, help_id: Some("ddl.sql_create_table"), + hint_id: None, usage_ids: &["parse.usage.sql_create_table"], }; @@ -1877,6 +1884,7 @@ pub static SQL_DROP_TABLE: CommandNode = CommandNode { shape: SQL_DROP_TABLE_SHAPE, ast_builder: build_sql_drop_table, help_id: Some("ddl.sql_drop_table"), + hint_id: None, usage_ids: &["parse.usage.sql_drop_table"], }; @@ -1896,6 +1904,7 @@ pub static SQL_DROP_INDEX: CommandNode = CommandNode { shape: SQL_DROP_INDEX_SHAPE, ast_builder: build_sql_drop_index, help_id: Some("ddl.sql_drop_index"), + hint_id: None, usage_ids: &["parse.usage.sql_drop_index"], }; @@ -1977,6 +1986,7 @@ pub static SQL_CREATE_INDEX: CommandNode = CommandNode { shape: SQL_CREATE_INDEX_SHAPE, ast_builder: build_sql_create_index, help_id: Some("ddl.sql_create_index"), + hint_id: None, usage_ids: &["parse.usage.sql_create_index"], }; @@ -2535,6 +2545,7 @@ pub static SQL_ALTER_TABLE: CommandNode = CommandNode { shape: SQL_ALTER_TABLE_SHAPE, ast_builder: build_sql_alter_table, help_id: Some("ddl.sql_alter_table"), + hint_id: None, usage_ids: &["parse.usage.sql_alter_table"], }; diff --git a/src/dsl/grammar/mod.rs b/src/dsl/grammar/mod.rs index f06cf3f..772d2b1 100644 --- a/src/dsl/grammar/mod.rs +++ b/src/dsl/grammar/mod.rs @@ -530,6 +530,15 @@ pub struct CommandNode { /// so a newly-registered command appears in `help` /// automatically (ADR-0024 §help_id). pub help_id: Option<&'static str>, + /// Catalog key stem (`hint.cmd.`) for this command form's + /// **tier-3** contextual hint (ADR-0053 / H2). Unlike `help_id` + /// — which is `None` on advanced-SQL forms purely to dedup the + /// `help` list — `hint_id` is 1:1 with command *forms*, so each + /// advanced-SQL form carries its own id and renders SQL-syntax + /// content distinct from its simple-DSL sibling. `None` until a + /// form's tier-3 block is authored (the surface falls back to + /// tier-2 ambient/error text). + pub hint_id: Option<&'static str>, /// Catalog keys under `parse.usage.*` to render in the /// "usage:" block when a parse error fires for this command /// (ADR-0021 §1, ADR-0024 §architecture). Multi-form families @@ -574,32 +583,69 @@ pub fn usage_keys_for_input_in_mode( source: &str, mode: crate::mode::Mode, ) -> Option<(&'static str, Vec<&'static str>)> { + let pick = selected_nodes_for_input_in_mode(source, mode); + if pick.is_empty() { + return None; + } + let mut keys: Vec<&'static str> = Vec::new(); + for (_, node, _) in &pick { + for k in node.usage_ids { + if !keys.contains(k) { + keys.push(*k); + } + } + } + if keys.is_empty() { + return None; + } + let entry = pick[0].1.entry.primary; + Some((entry, keys)) +} + +/// The tier-3 `hint_id` of the command form `source` is currently +/// typing, in `mode` (H2 / ADR-0053). +/// +/// Reuses the same mode-aware +/// selection as [`usage_keys_for_input_in_mode`] and returns the +/// **mode-primary** node's `hint_id` — so an advanced-SQL form +/// resolves to its *own* id, not its simple-DSL sibling's. `None` if +/// no entry word matches, or the chosen form has no tier-3 block yet +/// (the caller then falls back to tier-2 ambient text). +#[must_use] +pub fn hint_id_for_input_in_mode(source: &str, mode: crate::mode::Mode) -> Option<&'static str> { + selected_nodes_for_input_in_mode(source, mode) + .first() + .and_then(|(_, node, _)| node.hint_id) +} + +/// Shared mode-aware command-form selection for the entry word at the +/// start of `source`. +/// +/// Extracted so the usage-key and hint-id lookups agree on which form +/// the user is typing. +/// +/// Advanced mode: every candidate form is reachable — the SQL nodes +/// are primary, and the DSL nodes remain valid via fallback (verified: +/// `create table … with pk` and `drop column …` both run in advanced +/// mode). Mode-primary (Advanced) first, so a hint never hides input +/// that works. Simple mode: only the DSL forms — the SQL-only forms +/// hit the "this is SQL" rail and are not reachable. (ADR-0042 G3.) +/// Degenerate guard: an advanced-only word in simple mode leaves the +/// selection empty; fall back to all candidates. +fn selected_nodes_for_input_in_mode( + source: &str, + mode: crate::mode::Mode, +) -> Vec<(usize, &'static CommandNode, CommandCategory)> { use crate::dsl::walker::lex_helpers::{consume_ident, skip_whitespace}; let start = skip_whitespace(source, 0); - let (kw_start, kw_end) = consume_ident(source, start)?; + let Some((kw_start, kw_end)) = consume_ident(source, start) else { + return Vec::new(); + }; let word = &source[kw_start..kw_end]; let candidates = commands_for_entry_word(word); if candidates.is_empty() { - return None; + return Vec::new(); } - let union = |nodes: &[(usize, &'static CommandNode, CommandCategory)]| -> Vec<&'static str> { - let mut keys: Vec<&'static str> = Vec::new(); - for (_, node, _) in nodes { - for k in node.usage_ids { - if !keys.contains(k) { - keys.push(*k); - } - } - } - keys - }; - // Advanced mode: every candidate form is reachable — the SQL - // nodes are primary, and the DSL nodes remain valid via fallback - // (verified: `create table … with pk` and `drop column …` both - // run in advanced mode). Show them all, mode-primary (Advanced) - // first, so the usage hint never hides input that works. Simple - // mode: only the DSL forms — the SQL-only forms hit the "this is - // SQL" rail and are not reachable. (ADR-0042 G3.) let selected: Vec<(usize, &'static CommandNode, CommandCategory)> = if mode == crate::mode::Mode::Advanced { let mut v: Vec<_> = candidates @@ -621,17 +667,7 @@ pub fn usage_keys_for_input_in_mode( .filter(|(_, _, c)| *c == CommandCategory::Simple) .collect() }; - // Degenerate guard: an advanced-only word in simple mode (not - // normally reachable — it hits the SQL rail first) leaves - // `selected` empty; fall back to all candidates so a usage block - // still renders rather than the available-commands fallback. - let pick = if selected.is_empty() { candidates } else { selected }; - let keys = union(&pick); - if keys.is_empty() { - return None; - } - let entry = pick[0].1.entry.primary; - Some((entry, keys)) + if selected.is_empty() { candidates } else { selected } } /// The single usage template most relevant to `source`, when @@ -712,6 +748,7 @@ pub fn entry_words_alphabetised() -> Vec<&'static str> { pub static REGISTRY: &[(&CommandNode, CommandCategory)] = &[ (&app::QUIT, CommandCategory::Simple), (&app::HELP, CommandCategory::Simple), + (&app::HINT, CommandCategory::Simple), (&app::REBUILD, CommandCategory::Simple), (&app::SAVE, CommandCategory::Simple), (&app::NEW, CommandCategory::Simple), diff --git a/src/dsl/walker/mod.rs b/src/dsl/walker/mod.rs index 575ec48..2ee5f17 100644 --- a/src/dsl/walker/mod.rs +++ b/src/dsl/walker/mod.rs @@ -6910,6 +6910,7 @@ mod dispatch_3a_tests { shape: Node::Word(Word::keyword("dsltail")), ast_builder: dsl_builder, help_id: None, + hint_id: None, usage_ids: &[], }; static SMOKE_SQL: CommandNode = CommandNode { @@ -6917,6 +6918,7 @@ mod dispatch_3a_tests { shape: Node::Word(Word::keyword("sqltail")), ast_builder: sql_builder, help_id: None, + hint_id: None, usage_ids: &[], }; diff --git a/src/friendly/keys.rs b/src/friendly/keys.rs index a6c6ae8..e8ff14d 100644 --- a/src/friendly/keys.rs +++ b/src/friendly/keys.rs @@ -180,6 +180,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[ ("help.unknown_topic", &["topic"]), ("help.app.quit", &[]), ("help.app.help", &[]), + ("help.app.hint", &[]), ("help.app.rebuild", &[]), ("help.app.save", &[]), ("help.app.new", &[]), @@ -222,6 +223,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[ &["message", "usage"], ), ("hint.ambient_expected", &["expected"]), + ("hint.getting_started", &[]), ( "hint.ambient_invalid_ident", &["kind", "found"], @@ -299,6 +301,7 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[ ("parse.usage.rename_column", &[]), ("parse.usage.export", &[]), ("parse.usage.help", &[]), + ("parse.usage.hint", &[]), ("parse.usage.import", &[]), ("parse.usage.copy", &[]), ("parse.usage.load", &[]), diff --git a/src/friendly/mod.rs b/src/friendly/mod.rs index 06a6f0f..4c571f6 100644 --- a/src/friendly/mod.rs +++ b/src/friendly/mod.rs @@ -35,7 +35,7 @@ pub mod translate; pub use error::{DiagnosticTable, FriendlyError}; pub use format::{catalog, Catalog}; -pub use translate::{FailureContext, Operation, TranslateContext, Verbosity}; +pub use translate::{error_hint_class, FailureContext, Operation, TranslateContext, Verbosity}; // `translate::translate` and `format::translate` are different // callables — the former is the structured DbError → FriendlyError diff --git a/src/friendly/strings/en-US.yaml b/src/friendly/strings/en-US.yaml index c8e45f1..d9581e8 100644 --- a/src/friendly/strings/en-US.yaml +++ b/src/friendly/strings/en-US.yaml @@ -256,6 +256,8 @@ help: help: |- help — show this command list help — detailed help for one command (e.g. `help insert`) + hint: |- + hint — explain the most recent error (press F1 for a hint on what you're typing) rebuild: |- rebuild — rebuild the project database from project.yaml + data/ (with confirmation) save: |- @@ -386,6 +388,9 @@ hint: ambient_complete: "Submit with Enter" ambient_expected: "Next: {expected}" ambient_error_with_usage: "{message} — usage: {usage}" + # H2 / ADR-0053: shown by `hint` / F1 when there is nothing specific + # to expand on (no recent error, empty input). + getting_started: "Start typing a command and press F1 for a hint, or type `help` for the full command list." # Invalid identifier in a schema slot (ADR-0022 stage 8e # + the user's #5). Voice mirrors ADR-0019's "no such # {kind}" wording for consistency with engine errors. @@ -617,6 +622,7 @@ parse: # description. quit: "quit" help: "help []" + hint: "hint" rebuild: "rebuild" save: "save | save as" new: "new" diff --git a/src/friendly/translate.rs b/src/friendly/translate.rs index 74cdb00..aecf487 100644 --- a/src/friendly/translate.rs +++ b/src/friendly/translate.rs @@ -253,6 +253,73 @@ pub fn translate(error: &DbError, ctx: &TranslateContext) -> FriendlyError { fe } +/// The tier-3 hint class (`hint.err.`) for an error. +/// +/// The same classification [`translate`] performs, surfaced as a +/// stable key for the contextual `hint` (H2 / ADR-0053 D5). Returns +/// `None` for internal / fatal errors that carry no learner-facing +/// hint (persistence, IO, worker-gone). +/// +/// **Keep in sync with [`translate`] / `translate_sqlite` / +/// `translate_constraint` / `translate_foreign_key`** — the unit tests +/// below pin each class. +#[must_use] +pub fn error_hint_class(error: &DbError, ctx: &TranslateContext) -> Option<&'static str> { + match error { + DbError::Sqlite { message, kind } => sqlite_hint_class(message, *kind, ctx), + DbError::Unsupported(_) | DbError::InvalidValue(_) => Some("invalid_value"), + DbError::PersistenceFatal { .. } + | DbError::RebuildRowFailed { .. } + | DbError::Io(_) + | DbError::WorkerGone => None, + } +} + +fn sqlite_hint_class( + message: &str, + kind: SqliteErrorKind, + ctx: &TranslateContext, +) -> Option<&'static str> { + if matches!(ctx.operation, Some(Operation::ChangeColumnType)) { + return Some("type_mismatch"); + } + Some(match kind { + SqliteErrorKind::NoSuchTable | SqliteErrorKind::NoSuchColumn => "not_found", + SqliteErrorKind::AlreadyExists => "already_exists", + SqliteErrorKind::UniqueViolation => constraint_hint_class(message, ctx), + SqliteErrorKind::Other => "generic", + }) +} + +fn constraint_hint_class(message: &str, ctx: &TranslateContext) -> &'static str { + let lower = message.to_ascii_lowercase(); + if lower.contains("unique constraint failed") { + "unique" + } else if lower.contains("foreign key constraint failed") { + fk_hint_class(ctx) + } else if lower.contains("not null constraint failed") { + "not_null" + } else if lower.contains("check constraint failed") { + "check" + } else { + "generic" + } +} + +const fn fk_hint_class(ctx: &TranslateContext) -> &'static str { + // Mirrors `translate_foreign_key`'s side disambiguation. + if ctx.parent_table.is_some() { + return "foreign_key.child_side"; + } + if ctx.child_table.is_some() { + return "foreign_key.parent_side"; + } + match ctx.operation { + Some(Operation::Delete) => "foreign_key.parent_side", + _ => "foreign_key.child_side", + } +} + fn translate_sqlite( message: &str, kind: SqliteErrorKind, @@ -798,6 +865,92 @@ mod tests { } } + // ── H2 / ADR-0053: error → tier-3 hint class ──────────────── + + #[test] + fn hint_class_maps_runtime_error_kinds() { + use crate::db::{DbError, SqliteErrorKind}; + let sqlite = |kind, msg: &str| DbError::Sqlite { + message: msg.to_string(), + kind, + }; + let d = TranslateContext::default; + assert_eq!( + error_hint_class(&sqlite(SqliteErrorKind::NoSuchTable, "no such table: X"), &d()), + Some("not_found") + ); + assert_eq!( + error_hint_class(&sqlite(SqliteErrorKind::NoSuchColumn, "no such column: X"), &d()), + Some("not_found") + ); + assert_eq!( + error_hint_class(&sqlite(SqliteErrorKind::AlreadyExists, "already exists"), &d()), + Some("already_exists") + ); + assert_eq!( + error_hint_class(&sqlite(SqliteErrorKind::Other, "boom"), &d()), + Some("generic") + ); + // Constraint-violation message splitting. + let cv = |msg: &str| sqlite(SqliteErrorKind::UniqueViolation, msg); + assert_eq!( + error_hint_class(&cv("UNIQUE constraint failed: T.c"), &d()), + Some("unique") + ); + assert_eq!( + error_hint_class(&cv("NOT NULL constraint failed: T.c"), &d()), + Some("not_null") + ); + assert_eq!( + error_hint_class(&cv("CHECK constraint failed: T"), &d()), + Some("check") + ); + // change-column op routes any engine error to type_mismatch. + assert_eq!( + error_hint_class( + &sqlite(SqliteErrorKind::Other, "x"), + &ctx_with(Operation::ChangeColumnType) + ), + Some("type_mismatch") + ); + // App-level refusals and internal/fatal errors. + assert_eq!( + error_hint_class(&DbError::InvalidValue("bad".to_string()), &d()), + Some("invalid_value") + ); + assert_eq!(error_hint_class(&DbError::WorkerGone, &d()), None); + } + + #[test] + fn hint_class_resolves_foreign_key_sides() { + use crate::db::{DbError, SqliteErrorKind}; + let fk = || DbError::Sqlite { + message: "FOREIGN KEY constraint failed".to_string(), + kind: SqliteErrorKind::UniqueViolation, + }; + // Enrichment: parent_table populated → child-side. + let ctx = TranslateContext { + parent_table: Some("Parent".to_string()), + ..TranslateContext::default() + }; + assert_eq!(error_hint_class(&fk(), &ctx), Some("foreign_key.child_side")); + // child_table populated → parent-side. + let ctx = TranslateContext { + child_table: Some("Child".to_string()), + ..TranslateContext::default() + }; + assert_eq!(error_hint_class(&fk(), &ctx), Some("foreign_key.parent_side")); + // No enrichment: operation is the tiebreaker. + assert_eq!( + error_hint_class(&fk(), &ctx_with(Operation::Delete)), + Some("foreign_key.parent_side") + ); + assert_eq!( + error_hint_class(&fk(), &ctx_with(Operation::Insert)), + Some("foreign_key.child_side") + ); + } + fn sqlite(message: &str, kind: SqliteErrorKind) -> DbError { DbError::Sqlite { message: message.to_string(), diff --git a/tests/typing_surface/mod.rs b/tests/typing_surface/mod.rs index 53bef3b..31e7229 100644 --- a/tests/typing_surface/mod.rs +++ b/tests/typing_surface/mod.rs @@ -250,6 +250,7 @@ fn command_kind_label(cmd: &rdbms_playground::dsl::Command) -> String { App(app) => match app { AppCommand::Quit => "App(Quit)".into(), AppCommand::Help { .. } => "App(Help)".into(), + AppCommand::Hint => "App(Hint)".into(), AppCommand::Rebuild => "App(Rebuild)".into(), AppCommand::Save => "App(Save)".into(), AppCommand::SaveAs => "App(SaveAs)".into(),