parser: structural error rendering, source echo, and caret pointer

The old humanise() fell back to chumsky's terse Display for non-
custom errors and appended "(near `X`)", which on top of an
already-cryptic "found 'i' expected ':'" turned the message into
a puzzle. Now humanise() reads the structured RichReason, lists
expected RichPatterns in plain prose, and prefixes the message
with the consumed context.

Before:  parse error: found 'i' expected ':' (near `i`)
After:   parse error: after `change column Rich`, expected `:`,
         found `i`

dispatch_dsl additionally echoes the source line on parse failure
(matching the success path's "running: ...") and prints a `^` caret
under the failure position, so the user can see what got submitted
and where the parser broke without re-reading from scratch.

Known limit: keyword_ci's custom-error mismatches don't aggregate
across choice alternatives, so messages like "expected DATA or
TABLE" (bison-equivalent) aren't yet possible. That's a structural
fix to the keyword matcher, deferred to a future parser-affordances
ADR.

Tests: +2 structural-error regression tests.
This commit is contained in:
claude@clouddev1
2026-05-08 13:21:39 +00:00
parent 00947b928c
commit 7dfa718c6e
2 changed files with 169 additions and 11 deletions
+28
View File
@@ -717,6 +717,22 @@ impl App {
}
Err(ParseError::Empty) => Vec::new(),
Err(err) => {
// Echo the source line so the user can see what
// got submitted (and copy-paste it back to fix).
self.push_output(OutputLine {
text: format!("running: {input}"),
kind: OutputKind::Echo,
mode_at_submission: submission_mode,
});
// Caret pointer at the failure position, when we
// have one. Aligned to the "running: " prefix so
// the caret sits under the offending character.
if let ParseError::Invalid { position, .. } = &err {
let prefix = "running: ";
let trimmed_offset = leading_trim_offset(input);
let pad = prefix.chars().count() + trimmed_offset + position;
self.note_error(format!("{}^", " ".repeat(pad)));
}
self.note_error(format!("parse error: {}", parse_error_message(&err)));
Vec::new()
}
@@ -820,6 +836,9 @@ impl App {
// Wrap the command portion in quotes so the message
// reads cleanly: "...failed: <reason>" rather than the
// command running into "failed: ..." with no break.
// `note_error` splits on newlines internally — refusal
// diagnostics from `change column …` (ADR-0017 §7) flow
// through as a multi-line bordered table.
self.note_error(format!(
"\"{} {}\" failed: {error}",
command.verb(),
@@ -1154,6 +1173,7 @@ impl App {
" drop column [from] [table] <T>: <col>",
" rename column [in] [table] <T>: <old> to <new>",
" change column [in] [table] <T>: <col> (<newtype>)",
" [--force-conversion | --dont-convert]",
" add 1:n relationship [as <name>] from <P>.<col> to <C>.<col>",
" [on delete <action>] [on update <action>] [--create-fk]",
" drop relationship <name>",
@@ -1251,6 +1271,14 @@ fn parse_error_message(err: &ParseError) -> String {
}
}
/// Number of leading whitespace characters in `s`. The parser
/// trims its input before parsing, so a position returned by the
/// parser is relative to the trimmed string. The caret needs the
/// pre-trim offset to align under the user's literal input.
fn leading_trim_offset(s: &str) -> usize {
s.chars().take_while(|c| c.is_whitespace()).count()
}
fn render_cascade_effect(effect: &CascadeEffect) -> String {
use crate::dsl::ReferentialAction;
let what = match effect.action {