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:
+89
-19
@@ -16,6 +16,8 @@ use crate::db::{
|
||||
AddColumnResult, CascadeEffect, ChangeColumnTypeResult, DataResult, DeleteResult,
|
||||
InsertResult, TableDescription, UpdateResult,
|
||||
};
|
||||
use crate::dsl::lexer::lex;
|
||||
use crate::dsl::usage;
|
||||
use crate::dsl::{Command, ParseError, parse_command};
|
||||
use crate::event::AppEvent;
|
||||
use crate::mode::Mode;
|
||||
@@ -822,10 +824,17 @@ impl App {
|
||||
// {input}"). A translator changing that prefix
|
||||
// must update this width too — the constraint is
|
||||
// captured in the catalog comment block.
|
||||
//
|
||||
// ADR-0020: positions returned by `parse_command`
|
||||
// are byte offsets into the *original* input
|
||||
// (the lexer doesn't trim before lexing). We
|
||||
// convert to a character count for caret padding.
|
||||
if let ParseError::Invalid { position, .. } = &err {
|
||||
let prefix = "running: ";
|
||||
let trimmed_offset = leading_trim_offset(input);
|
||||
let pad = prefix.chars().count() + trimmed_offset + position;
|
||||
let chars_before = input
|
||||
.get(..*position)
|
||||
.map_or(*position, |s| s.chars().count());
|
||||
let pad = prefix.chars().count() + chars_before;
|
||||
self.note_error(crate::t!(
|
||||
"parse.caret",
|
||||
padding = " ".repeat(pad)
|
||||
@@ -835,6 +844,12 @@ impl App {
|
||||
"parse.error",
|
||||
detail = parse_error_message(&err)
|
||||
));
|
||||
// ADR-0021 §2: append the usage block (if a
|
||||
// known command-entry keyword was consumed) or
|
||||
// the available-commands fallback (§5).
|
||||
if let ParseError::Invalid { position, .. } = &err {
|
||||
self.note_error(render_usage_block(input, *position));
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
@@ -1503,12 +1518,39 @@ 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()
|
||||
/// Compose the third block of a parse-error rendering
|
||||
/// (ADR-0021 §2): "usage: …" when at least one
|
||||
/// command-entry keyword was consumed, otherwise an
|
||||
/// "available commands:" fallback (§5).
|
||||
///
|
||||
/// `position` is a byte offset into the original input
|
||||
/// identifying where the parser stopped — same value the
|
||||
/// caret uses.
|
||||
fn render_usage_block(input: &str, position: usize) -> String {
|
||||
let tokens = lex(input);
|
||||
if let Some((_kw, catalog_keys)) = usage::matched_entry(&tokens, position) {
|
||||
let mut out = String::from("usage:");
|
||||
for key in catalog_keys {
|
||||
let template = crate::friendly::translate(key, &[]);
|
||||
for line in template.lines() {
|
||||
out.push('\n');
|
||||
out.push_str(" ");
|
||||
out.push_str(line);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
// No-prefix fallback. Render every command-entry keyword via
|
||||
// its `parse.token.keyword.*` catalog key, plain
|
||||
// comma-joined.
|
||||
let names: Vec<String> = usage::entry_keywords_alphabetised()
|
||||
.into_iter()
|
||||
.map(|kw| crate::friendly::translate(&kw.catalog_token_key(), &[]))
|
||||
.collect();
|
||||
crate::t!(
|
||||
"parse.available_commands",
|
||||
commands = names.join(", ")
|
||||
)
|
||||
}
|
||||
|
||||
fn render_cascade_effect(effect: &CascadeEffect) -> String {
|
||||
@@ -1554,6 +1596,17 @@ mod tests {
|
||||
app.update(key(KeyCode::Enter))
|
||||
}
|
||||
|
||||
/// Render every error-kind output line, one per line, for
|
||||
/// failed-assertion error messages.
|
||||
fn error_lines(app: &App) -> String {
|
||||
app.output
|
||||
.iter()
|
||||
.filter(|l| l.kind == OutputKind::Error)
|
||||
.map(|l| l.text.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn sample_description(name: &str) -> TableDescription {
|
||||
TableDescription {
|
||||
name: name.to_string(),
|
||||
@@ -1616,12 +1669,17 @@ mod tests {
|
||||
type_str(&mut app, "create table Customers");
|
||||
let actions = submit(&mut app);
|
||||
assert!(actions.is_empty());
|
||||
let last = app.output.back().unwrap();
|
||||
assert_eq!(last.kind, OutputKind::Error);
|
||||
// Parse-error rendering is now multi-line (ADR-0021):
|
||||
// caret + "parse error: …" + "usage: …" — the test
|
||||
// checks that some error line mentions `with pk`.
|
||||
let mentions_with_pk = app
|
||||
.output
|
||||
.iter()
|
||||
.any(|l| l.kind == OutputKind::Error && l.text.contains("with pk"));
|
||||
assert!(
|
||||
last.text.contains("with pk"),
|
||||
"error should mention `with pk`: {}",
|
||||
last.text
|
||||
mentions_with_pk,
|
||||
"no error line mentions `with pk`; output:\n{}",
|
||||
error_lines(&app),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1631,9 +1689,15 @@ mod tests {
|
||||
type_str(&mut app, "frobulate widgets");
|
||||
let actions = submit(&mut app);
|
||||
assert!(actions.is_empty());
|
||||
let last = app.output.back().unwrap();
|
||||
assert_eq!(last.kind, OutputKind::Error);
|
||||
assert!(last.text.starts_with("parse error"));
|
||||
let has_parse_error = app
|
||||
.output
|
||||
.iter()
|
||||
.any(|l| l.kind == OutputKind::Error && l.text.starts_with("parse error"));
|
||||
assert!(
|
||||
has_parse_error,
|
||||
"no error line starts with `parse error`; output:\n{}",
|
||||
error_lines(&app),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2086,9 +2150,15 @@ mod tests {
|
||||
type_str(&mut app, "add column to table T: c (varchar)");
|
||||
let actions = submit(&mut app);
|
||||
assert!(actions.is_empty());
|
||||
let last = app.output.back().unwrap();
|
||||
assert_eq!(last.kind, OutputKind::Error);
|
||||
assert!(last.text.contains("varchar"));
|
||||
let mentions_varchar = app
|
||||
.output
|
||||
.iter()
|
||||
.any(|l| l.kind == OutputKind::Error && l.text.contains("varchar"));
|
||||
assert!(
|
||||
mentions_varchar,
|
||||
"no error line mentions `varchar`; output:\n{}",
|
||||
error_lines(&app),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user