ADR-0021 implementation: per-command usage templates in parse errors

New `dsl::usage` module: registry pairing each command's
entry-keyword with a `parse.usage.*` catalog key.
`matched_entry()` resolves the entry keyword from the
consumed token prefix; multi-entry families (add, drop,
show) return all matching keys.

Catalog: new `parse.usage.<command>` keys (one per command),
`parse.token.{keyword,punct,...}` vocabulary (one per
Keyword/Punct variant + token-class labels + LexError
kinds), and `parse.available_commands` for the no-prefix
fallback. Catalog grows ~60 entries.

Validator: extended KEYS_AND_PLACEHOLDERS; new completeness
test asserts every Keyword and Punct variant has its
`parse.token.*` entry.

`app::dispatch_dsl` rewritten to compose three blocks per
ADR-0021 §2: caret + structural/custom error + usage block
(or available-commands fallback per §5). Caret math fixed
to use original-input byte position rather than
trimmed-input position (the lexer no longer trims before
lexing). Three pre-existing app tests adjusted to look
across all error lines instead of `output.back()` (the
usage block is now the last line).

`dsl::usage::matched_entry` uses `<=` rather than `<` for
position comparison so custom errors raised by `try_map`
(whose span starts at the first consumed token) still
resolve to the entry keyword.

Tests: 668 passing, 0 failing, 1 ignored (650 baseline →
+18: 8 usage + 1 token-vocab completeness + 9 new
integration tests in tests/parse_error_pedagogy.rs
covering create/add/drop/show/frobulate/update/insert
cases). Clippy clean.
This commit is contained in:
claude@clouddev1
2026-05-10 14:41:32 +00:00
parent fdaf7e3e0e
commit 11071ae164
6 changed files with 776 additions and 19 deletions
+107
View File
@@ -122,9 +122,82 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("help.cli_banner", &[]),
("help.in_app_body", &[]),
// ---- Parse error rendering ----
("parse.available_commands", &["commands"]),
("parse.caret", &["padding"]),
("parse.empty", &[]),
("parse.error", &["detail"]),
// Per-command usage templates (ADR-0021 §1). One key per
// command. Multi-entry families (`add`, `drop`, `show`)
// each have multiple keys. Templates are pure prose with
// no placeholders — the renderer prepends "usage: " in
// code, not the catalog, because spacing is alignment-
// sensitive in the multi-entry case.
("parse.usage.add_column", &[]),
("parse.usage.add_relationship", &[]),
("parse.usage.change_column", &[]),
("parse.usage.create_table", &[]),
("parse.usage.delete", &[]),
("parse.usage.drop_column", &[]),
("parse.usage.drop_relationship", &[]),
("parse.usage.drop_table", &[]),
("parse.usage.insert", &[]),
("parse.usage.rename_column", &[]),
("parse.usage.replay", &[]),
("parse.usage.show_data", &[]),
("parse.usage.show_table", &[]),
("parse.usage.update", &[]),
// Single-token vocabulary (ADR-0021 §4). One per Keyword
// variant (declared by `Keyword::ALL`), one per Punct
// variant, one per token-class label, one per LexError
// kind. The per-Keyword and per-Punct entries are also
// validated against the enums by
// `keyword_and_punct_have_complete_token_vocabulary`.
("parse.token.end_of_input", &[]),
("parse.token.error.bad_flag", &[]),
("parse.token.error.unknown_char", &["found"]),
("parse.token.error.unterminated_string", &[]),
("parse.token.flag", &[]),
("parse.token.identifier", &[]),
("parse.token.keyword.action", &[]),
("parse.token.keyword.add", &[]),
("parse.token.keyword.as", &[]),
("parse.token.keyword.cascade", &[]),
("parse.token.keyword.change", &[]),
("parse.token.keyword.column", &[]),
("parse.token.keyword.create", &[]),
("parse.token.keyword.data", &[]),
("parse.token.keyword.delete", &[]),
("parse.token.keyword.drop", &[]),
("parse.token.keyword.false", &[]),
("parse.token.keyword.from", &[]),
("parse.token.keyword.in", &[]),
("parse.token.keyword.insert", &[]),
("parse.token.keyword.into", &[]),
("parse.token.keyword.no", &[]),
("parse.token.keyword.null", &[]),
("parse.token.keyword.on", &[]),
("parse.token.keyword.pk", &[]),
("parse.token.keyword.relationship", &[]),
("parse.token.keyword.rename", &[]),
("parse.token.keyword.replay", &[]),
("parse.token.keyword.restrict", &[]),
("parse.token.keyword.set", &[]),
("parse.token.keyword.show", &[]),
("parse.token.keyword.table", &[]),
("parse.token.keyword.to", &[]),
("parse.token.keyword.true", &[]),
("parse.token.keyword.update", &[]),
("parse.token.keyword.values", &[]),
("parse.token.keyword.where", &[]),
("parse.token.keyword.with", &[]),
("parse.token.number", &[]),
("parse.token.punct.close_paren", &[]),
("parse.token.punct.colon", &[]),
("parse.token.punct.comma", &[]),
("parse.token.punct.dot", &[]),
("parse.token.punct.equals", &[]),
("parse.token.punct.open_paren", &[]),
("parse.token.string_literal", &[]),
// ---- Project lifecycle event notes ----
("project.export_failed", &["error"]),
("project.export_ok", &["path"]),
@@ -232,9 +305,43 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
#[cfg(test)]
mod tests {
use super::KEYS_AND_PLACEHOLDERS;
use crate::dsl::keyword::{Keyword, Punct};
use crate::friendly::format::catalog;
use std::collections::HashSet;
/// Every `Keyword` variant must have a
/// `parse.token.keyword.<name>` entry; every `Punct`
/// variant must have a `parse.token.punct.<name>` entry.
/// Catches the case where a keyword or punct is added to
/// the macro but not to the catalog (ADR-0021 §7).
#[test]
fn keyword_and_punct_have_complete_token_vocabulary() {
let declared: HashSet<&str> =
KEYS_AND_PLACEHOLDERS.iter().map(|(k, _)| *k).collect();
let mut missing: Vec<String> = Vec::new();
for &(kw, _) in Keyword::ALL {
let key = kw.catalog_token_key();
if !declared.contains(key.as_str()) {
missing.push(format!(
"Keyword::{kw:?} ⇒ catalog key `{key}` not declared in keys.rs"
));
}
}
for &(p, _, _) in Punct::ALL {
let key = p.catalog_token_key();
if !declared.contains(key.as_str()) {
missing.push(format!(
"Punct::{p:?} ⇒ catalog key `{key}` not declared in keys.rs"
));
}
}
assert!(
missing.is_empty(),
"token vocabulary incomplete:\n {}",
missing.join("\n "),
);
}
/// Walks `KEYS_AND_PLACEHOLDERS` and verifies every entry
/// matches the catalog. ADR-0019 §8.6.
///
+89
View File
@@ -263,6 +263,95 @@ parse:
# Default for the `ParseError::Empty` variant — surfaces as
# `{detail}` inside the wrapper.
empty: "empty input"
# No-prefix fallback (ADR-0021 §5): when the parse fails
# before any keyword is consumed, the renderer lists every
# command-entry keyword instead of attempting a per-command
# usage block. `{commands}` is an oxford-joined list of
# command-keyword renderings (each from
# `parse.token.keyword.*`).
available_commands: "available commands: {commands}"
# 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 `[...]`
# marks optional parts; angle-bracket `<...>` marks
# placeholders. ADR-0009's surface conventions apply.
usage:
create_table: "create table <Name> with pk [<col>:<type>[, ...]]"
drop_table: "drop table <Name>"
drop_column: "drop column [from] [table] <Table>: <Name>"
drop_relationship: |-
drop relationship <Name>
drop relationship from <Parent>.<col> to <Child>.<col>
add_column: "add column [to] [table] <Table>: <Name> (<Type>)"
add_relationship: |-
add 1:n relationship [as <Name>]
from <Parent>.<col> to <Child>.<col>
[on delete <action>] [on update <action>]
[--create-fk]
rename_column: "rename column [in] [table] <Table>: <Old> to <New>"
change_column: |-
change column [in] [table] <Table>: <Name> (<Type>)
[--force-conversion | --dont-convert]
show_data: "show data <Table>"
show_table: "show table <Table>"
insert: "insert into <Table> [(<col>[, ...])] [values] (<value>[, ...])"
update: "update <Table> set <col>=<value>[, ...] (where <col>=<value> | --all-rows)"
delete: "delete from <Table> (where <col>=<value> | --all-rows)"
replay: "replay <path> | replay '<path with spaces>'"
# Single-token vocabulary the renderer uses to translate
# chumsky's expected-set patterns. One key per Keyword variant
# (validated against `Keyword::ALL`), one per Punct variant,
# one per token-class label, one per LexError kind.
token:
keyword:
create: "`create`"
drop: "`drop`"
add: "`add`"
rename: "`rename`"
change: "`change`"
show: "`show`"
insert: "`insert`"
update: "`update`"
delete: "`delete`"
replay: "`replay`"
table: "`table`"
column: "`column`"
data: "`data`"
relationship: "`relationship`"
pk: "`pk`"
with: "`with`"
from: "`from`"
to: "`to`"
into: "`into`"
as: "`as`"
in: "`in`"
on: "`on`"
set: "`set`"
where: "`where`"
values: "`values`"
"null": "`null`"
"true": "`true`"
"false": "`false`"
cascade: "`cascade`"
restrict: "`restrict`"
action: "`action`"
"no": "`no`"
punct:
colon: "`:`"
open_paren: "`(`"
close_paren: "`)`"
comma: "`,`"
equals: "`=`"
dot: "`.`"
identifier: "identifier"
number: "number"
string_literal: "string literal"
flag: "flag (--name)"
end_of_input: "end of input"
error:
unterminated_string: "unterminated string literal"
unknown_char: "unrecognised character `{found}`"
bad_flag: "malformed flag (bare `--`)"
# ---- Project lifecycle event notes -----------------------------------
project: