feat(hint): H2 Phase A — hint command + F1 keybinding skeleton (ADR-0053)

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
This commit is contained in:
claude@clouddev1
2026-06-15 10:36:51 +00:00
parent 9868442889
commit 050b36391e
12 changed files with 550 additions and 32 deletions
+6
View File
@@ -552,6 +552,11 @@ pub enum AppCommand {
Help {
topic: Option<String>,
},
/// 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",
+25
View File
@@ -177,6 +177,9 @@ const fn build_rebuild(_path: &MatchedPath, _source: &str) -> Result<Command, Va
const fn build_undo(_path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
Ok(Command::App(AppCommand::Undo))
}
const fn build_hint(_path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
Ok(Command::App(AppCommand::Hint))
}
const fn build_redo(_path: &MatchedPath, _source: &str) -> Result<Command, ValidationError> {
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"],};
+13
View File
@@ -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: &[],
};
+11
View File
@@ -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"],
};
+68 -31
View File
@@ -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.<id>`) 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),
+2
View File
@@ -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: &[],
};