feat(hint): H2 Phase B — per-form keying + the three exemplars (ADR-0053)

The first exemplar (`add 1:n relationship`) showed per-node keying is
too coarse for multi-form commands, so revise the mechanism to per-form.

- CommandNode `hint_id: Option<&str>` -> `hint_ids: &[&str]` (mirrors
  usage_ids); hint_key_for_input_in_mode reuses a factored-out
  pick_form_key (shared digit/m:n/suffix form disambiguation with
  usage_key_for_input_in_mode)
- wire INSERT + ADD (all four forms) with hint_ids
- author the three approved exemplars: hint.cmd.insert,
  hint.cmd.add_relationship, hint.err.foreign_key.child_side
  (what/example/concept) + keys.rs registration
- revise ADR-0053 D3 to per-form; record clause-concept hints as a
  deferred extension (issue #37); update README + plan
- +5 tests; 2488 pass / 1 ignored, clippy clean
This commit is contained in:
claude@clouddev1
2026-06-15 12:18:41 +00:00
parent 050b36391e
commit 4a5fd1b5c1
11 changed files with 292 additions and 109 deletions
+80 -29
View File
@@ -530,15 +530,18 @@ 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 key stems (`hint.cmd.<id>`) for this command's
/// **tier-3** contextual hints (ADR-0053 / H2), **one per form**,
/// mirroring `usage_ids`. A single-form command carries one; a
/// multi-form command (`add`, `drop`, `show`, `create`) carries
/// one per form so a live-input hint can be specific to the form
/// being typed (`hint.cmd.add_relationship`, not a shared `add`
/// block). `hint_key_for_input_in_mode` disambiguates by the form
/// word, reusing `usage_key_for_input_in_mode`'s logic. Empty
/// until a form's tier-3 block is authored (the surface falls back
/// to tier-2 ambient/error text). Distinct from `help_id` (which is
/// `None` on advanced-SQL forms purely to dedup the `help` list).
pub hint_ids: &'static [&'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
@@ -602,20 +605,30 @@ pub fn usage_keys_for_input_in_mode(
Some((entry, keys))
}
/// The tier-3 `hint_id` of the command form `source` is currently
/// typing, in `mode` (H2 / ADR-0053).
/// The single tier-3 hint key (`hint.cmd.<id>` stem) for 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).
/// Mirrors [`usage_key_for_input_in_mode`]: the union of the
/// mode-selected nodes' `hint_ids`, disambiguated to the typed form by
/// [`pick_form_key`] — so `add 1:n relationship` resolves to the
/// relationship hint, and an advanced-SQL form resolves to its own
/// (not its simple sibling's). `None` if no entry word matches or the
/// form has no tier-3 block yet (the caller falls back to tier-2).
#[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)
pub fn hint_key_for_input_in_mode(source: &str, mode: crate::mode::Mode) -> Option<&'static str> {
let nodes = selected_nodes_for_input_in_mode(source, mode);
if nodes.is_empty() {
return None;
}
let mut keys: Vec<&'static str> = Vec::new();
for (_, node, _) in &nodes {
for k in node.hint_ids {
if !keys.contains(k) {
keys.push(*k);
}
}
}
pick_form_key(source, &keys)
}
/// Shared mode-aware command-form selection for the entry word at the
@@ -694,14 +707,24 @@ pub fn usage_key_for_input_in_mode(
source: &str,
mode: crate::mode::Mode,
) -> Option<&'static str> {
use crate::dsl::walker::lex_helpers::{consume_ident, skip_whitespace};
let (_entry, keys) = usage_keys_for_input_in_mode(source, mode)?;
pick_form_key(source, &keys)
}
/// From the form word after the entry keyword, pick the single `keys`
/// entry for the form `source` names.
///
/// A single-entry list resolves to its one key; a multi-form list
/// disambiguates by the form word (`add 1:n relationship` → the
/// `…relationship` key, `create m:n …` → the `…m2n` key, else the
/// identifier form word matched against each key's suffix). Shared by
/// the usage-template and tier-3-hint single-key lookups so they agree.
fn pick_form_key<'a>(source: &str, keys: &[&'a str]) -> Option<&'a str> {
use crate::dsl::walker::lex_helpers::{consume_ident, skip_whitespace};
let first = *keys.first()?;
if keys.len() == 1 {
return Some(first);
}
// Multi-form: the form is named by the token right after
// the entry keyword.
let start = skip_whitespace(source, 0);
let (_, entry_end) = consume_ident(source, start)?;
let after = skip_whitespace(source, entry_end);
@@ -710,14 +733,12 @@ pub fn usage_key_for_input_in_mode(
return keys.iter().copied().find(|k| k.ends_with("relationship"));
}
// The `create m:n relationship` form (ADR-0045) opens with `m:n`
// — a letter, so the digit branch misses it, and its usage key ends
// `…create_m2n` (not `relationship`).
// — a letter, so the digit branch misses it; its key ends `…m2n`.
if source[after..].get(..3).is_some_and(|s| s.eq_ignore_ascii_case("m:n")) {
return keys.iter().copied().find(|k| k.ends_with("m2n"));
}
// Otherwise the form word is an identifier — `column`,
// `index`, `table`, `relationship` — matched against the
// usage key's suffix.
// Otherwise the form word is an identifier — `column`, `index`,
// `table`, `relationship` — matched against each key's suffix.
let (s, e) = consume_ident(source, after)?;
let form = source[s..e].to_ascii_lowercase();
keys.iter().copied().find(|k| k.ends_with(form.as_str()))
@@ -873,6 +894,36 @@ pub fn commands_for_entry_word(
.collect()
}
#[cfg(test)]
mod hint_key_tests {
use super::hint_key_for_input_in_mode;
use crate::mode::Mode;
/// Per-form hint keying (ADR-0053 D3): a multi-form command
/// resolves the *typed* form, not the node — `add 1:n
/// relationship` → the relationship hint, `add column` → the
/// (as-yet-unauthored) column hint, never the wrong form.
#[test]
fn hint_key_resolves_the_typed_form() {
assert_eq!(
hint_key_for_input_in_mode("add 1:n relationship from A.x to B.y", Mode::Simple),
Some("add_relationship")
);
assert_eq!(
hint_key_for_input_in_mode("add column Note text to T", Mode::Simple),
Some("add_column")
);
assert_eq!(
hint_key_for_input_in_mode("insert into T values (1)", Mode::Simple),
Some("insert")
);
// A node with no hint_ids yet → None (tier-2 fallback).
assert_eq!(hint_key_for_input_in_mode("drop table T", Mode::Simple), None);
// Unknown entry word → None.
assert_eq!(hint_key_for_input_in_mode("zzz", Mode::Simple), None);
}
}
#[cfg(test)]
mod usage_key_tests {
use super::usage_key_for_input;