feat: H1a parse-error gaps G2–G4 + advanced near-miss matrix (ADR-0042)

Close the three remaining ADR-0042 triage gaps, each test-first, and
lock the advanced-mode near-miss matrix.

G2 — bare `select` dumped the 14-item expression first-set. Collapse
it to "a projection: `*`, a column, or an expression" in the error
message only (parser::format_walker_error), detected by the joint
`distinct`+`all` quantifier signature unique to a projection start.
Render-only: completion/hints still expand the full set (typing-surface
matrix unchanged).

G3 — the usage block was mode-blind: advanced `create table` showed the
DSL `create table … with pk …` template. usage_key(s)_for_input gain
mode-aware `_in_mode` variants selecting candidates by CommandCategory;
render_usage_block and the typing-time ambient usage thread the
submission mode. Advanced `create` now shows both SQL forms. A fallback
covers shared SQL nodes (insert/update/delete) that declare no
usage_ids of their own — without it they regressed to the
available-commands fallback (caught by the new advanced matrix).

G4 — `with` borrowed `select`'s usage template; give it its own
parse.usage.with CTE template.

Tests: new near_miss_matrix_advanced_mode (12 SQL-surface cases incl.
the available-commands regression guard) + per-gap tests; removed the
temporary baseline_dump. Full suite green (lib 1578 / it 386 /
typing_surface_matrix 192); clippy clean.
This commit is contained in:
claude@clouddev1
2026-06-05 14:57:20 +00:00
parent 10f8c2a95c
commit 649fdcb38e
8 changed files with 259 additions and 93 deletions
+8 -6
View File
@@ -1521,7 +1521,7 @@ impl App {
for note in notes {
self.note_error(note);
}
self.note_error(render_usage_block(input));
self.note_error(render_usage_block(input, mode));
return vec![Action::JournalFailure {
source: input.to_string(),
}];
@@ -1601,7 +1601,7 @@ impl App {
// known command-entry keyword was consumed) or
// the available-commands fallback (§5).
if let ParseError::Invalid { .. } = &err {
self.note_error(render_usage_block(input));
self.note_error(render_usage_block(input, mode));
}
// ADR-0034 §1/§2: a submitted line that failed to
// parse is journalled `err` so it is recallable
@@ -2557,16 +2557,18 @@ fn parse_error_message(err: &ParseError) -> String {
/// renders every catalog template — multi-form families like
/// `drop` show every variant. Otherwise the fallback lists every
/// entry keyword alphabetically.
fn render_usage_block(input: &str) -> String {
fn render_usage_block(input: &str, mode: Mode) -> String {
// A multi-form command that has committed to a form
// (`add index …`) shows only that form's usage; a bare
// multi-form entry word (`add`) shows the whole family.
// Mode-aware (ADR-0042 G3): in advanced mode a shared entry
// word shows its SQL forms, not the DSL templates.
let catalog_keys: Vec<&'static str> =
crate::dsl::grammar::usage_key_for_input(input)
crate::dsl::grammar::usage_key_for_input_in_mode(input, mode)
.map(|key| vec![key])
.or_else(|| {
crate::dsl::grammar::usage_keys_for_input(input)
.map(|(_word, all)| all.to_vec())
crate::dsl::grammar::usage_keys_for_input_in_mode(input, mode)
.map(|(_word, all)| all)
})
.unwrap_or_default();
if !catalog_keys.is_empty() {
+1 -1
View File
@@ -1445,7 +1445,7 @@ pub static WITH: CommandNode = CommandNode {
shape: Node::Subgrammar(&sql_select::SQL_WITH_TAIL),
ast_builder: build_select,
help_id: None,
usage_ids: &["parse.usage.select"],};
usage_ids: &["parse.usage.with"],};
/// SQL `INSERT` — the `Advanced`-category node of the shared
/// `insert` entry word (ADR-0033 §2, Amendment 1, sub-phase 3j).
+75 -4
View File
@@ -533,13 +533,73 @@ pub struct CommandNode {
/// Returns the canonical (primary-form) entry literal and the
/// `usage_ids` list, or `None` if no entry word matches.
#[must_use]
pub fn usage_keys_for_input(source: &str) -> Option<(&'static str, &'static [&'static str])> {
pub fn usage_keys_for_input(source: &str) -> Option<(&'static str, Vec<&'static str>)> {
usage_keys_for_input_in_mode(source, crate::mode::Mode::Simple)
}
/// Mode-aware variant of [`usage_keys_for_input`] (ADR-0042 G3).
///
/// A shared entry word (`create`, `drop`, `insert`, …) registers a
/// `Simple` DSL node *and* one or more `Advanced` SQL nodes. The
/// usage block must reflect the surface the user is actually typing:
/// the SQL forms in `Advanced` mode, the DSL forms in `Simple` mode
/// — otherwise advanced-mode `create` shows the DSL `create table …
/// with pk …` template, which is not valid SQL.
///
/// Selection prefers candidates whose [`CommandCategory`] matches
/// the mode; if the entry word has none in that category (an
/// app-lifecycle command is `Simple`-only yet usable in both modes),
/// every candidate is used. The returned keys are the union of the
/// selected nodes' `usage_ids`, de-duplicated in registry order — so
/// advanced `create` shows both `sql_create_table` and
/// `sql_create_index`.
#[must_use]
pub fn usage_keys_for_input_in_mode(
source: &str,
mode: crate::mode::Mode,
) -> Option<(&'static str, Vec<&'static str>)> {
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 word = &source[kw_start..kw_end];
let (_, node) = command_for_entry_word(word)?;
Some((node.entry.primary, node.usage_ids))
let candidates = commands_for_entry_word(word);
if candidates.is_empty() {
return None;
}
let want = if mode == crate::mode::Mode::Advanced {
CommandCategory::Advanced
} else {
CommandCategory::Simple
};
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
};
let matched: Vec<(usize, &'static CommandNode, CommandCategory)> =
candidates.iter().copied().filter(|(_, _, cat)| *cat == want).collect();
// Prefer the mode-matching nodes' usage. But a shared SQL node
// (`SQL_INSERT` / `SQL_UPDATE` / `SQL_DELETE`) declares no
// `usage_ids` of its own — it reuses the DSL template. When the
// mode-preferred set yields no usage keys, fall back to every
// candidate so the entry word still shows a usage block rather
// than the available-commands fallback (regression-locked by
// the advanced near-miss matrix).
let mut keys = union(&matched);
if keys.is_empty() {
keys = union(&candidates);
}
if keys.is_empty() {
return None;
}
let entry = candidates[0].1.entry.primary;
Some((entry, keys))
}
/// The single usage template most relevant to `source`, when
@@ -555,8 +615,19 @@ pub fn usage_keys_for_input(source: &str) -> Option<(&'static str, &'static [&'s
/// show the whole family or nothing.
#[must_use]
pub fn usage_key_for_input(source: &str) -> Option<&'static str> {
usage_key_for_input_in_mode(source, crate::mode::Mode::Simple)
}
/// Mode-aware variant of [`usage_key_for_input`] (ADR-0042 G3) —
/// disambiguates the single most-relevant usage key from the
/// mode-selected key set.
#[must_use]
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(source)?;
let (_entry, keys) = usage_keys_for_input_in_mode(source, mode)?;
let first = *keys.first()?;
if keys.len() == 1 {
return Some(first);
+25 -2
View File
@@ -296,14 +296,37 @@ fn format_expectation(e: &crate::dsl::walker::outcome::Expectation) -> String {
}
}
/// ADR-0042 G2: a projection start (`select |`, or the projection
/// position inside a subquery / CTE body) expects the full
/// expression first-set — 14 alternatives — plus the SELECT
/// quantifiers `distinct` and `all`. Those two quantifiers are
/// jointly expectable *only* at a projection start, so their joint
/// presence is a precise signature for collapsing the noisy list
/// into one gloss. Render-only: this fires inside
/// `format_walker_error` (the error message), not in the expected
/// set the completion/hint layer consumes.
fn is_select_projection_start(expected: &[crate::dsl::walker::outcome::Expectation]) -> bool {
use crate::dsl::walker::outcome::Expectation;
let has_word = |w: &str| {
expected
.iter()
.any(|e| matches!(e, Expectation::Word(x) if x.eq_ignore_ascii_case(w)))
};
has_word("distinct") && has_word("all")
}
fn format_walker_error(
source: &str,
position: usize,
at_eof: bool,
expected: &[crate::dsl::walker::outcome::Expectation],
) -> String {
let parts: Vec<String> = expected.iter().map(format_expectation).collect();
let joined = oxford_join(&parts);
let joined = if is_select_projection_start(expected) {
crate::t!("parse.expect.select_projection")
} else {
let parts: Vec<String> = expected.iter().map(format_expectation).collect();
oxford_join(&parts)
};
// Mirror the chumsky-side wording: "after `<consumed>`,
// expected …" when the parser already consumed something
+2
View File
@@ -307,6 +307,8 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("parse.usage.show_data", &[]),
("parse.usage.show_table", &[]),
("parse.usage.update", &[]),
("parse.usage.with", &[]),
("parse.expect.select_projection", &[]),
// ---- Project lifecycle event notes ----
("project.export_failed", &["error"]),
("project.export_ok", &["path"]),
+13
View File
@@ -491,6 +491,15 @@ parse:
# command-keyword renderings (each from
# `parse.token.keyword.*`).
available_commands: "available commands: {commands}"
# ADR-0042 G2: collapse the SELECT projection-start expression
# first-set (14 expression-starters plus the `distinct`/`all`
# quantifiers) into one learner-sized gloss in the error
# message. The detector keys on `distinct` AND `all` being
# jointly expectable, which only happens at a projection start —
# so the raw set is replaced *in the error line only*;
# completion/hints still expand the full first-set.
expect:
select_projection: "a projection: `*`, a column, or an expression"
# Per-command usage templates (ADR-0021 §1). Rendered under a
# "usage:" prefix when a parse fails after consuming a
# known command-entry keyword. The bracket convention `[...]`
@@ -550,6 +559,10 @@ parse:
replay: "replay <path> | replay '<path with spaces>'"
# SQL `SELECT` (advanced mode; ADR-0030 / ADR-0031).
select: "select (* | <expr>[ as <alias>][, ...]) from <Table> [where <expr>] [order by <expr>[ asc|desc][, ...]] [limit <n>]"
# SQL `WITH` / CTE (advanced mode; ADR-0032). G4 (ADR-0042):
# its own template — `with` previously borrowed `select`'s,
# which never showed the CTE shape.
with: "with [recursive] <Name> [(<col>[, ...])] as (<query>)[, ...] select ..."
# App-lifecycle commands (per ADR-0003, surfaced through
# the parser so they participate in usage templates +
# completion). Templates here describe the surface
+3 -1
View File
@@ -866,7 +866,9 @@ fn ambient_hint_core_in_mode(
// The form the user has committed to drives the
// usage template — `add index …` shows the
// `add index` usage, not the first `add` form.
let usage = crate::dsl::grammar::usage_key_for_input(input)
// Mode-aware (ADR-0042 G3): advanced-mode shared
// entry words show their SQL form, not the DSL one.
let usage = crate::dsl::grammar::usage_key_for_input_in_mode(input, mode)
.map(|key| crate::friendly::translate(key, &[]));
Some(AmbientHint::Prose(match usage {
Some(u) => crate::t!(