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
+89 -19
View File
@@ -16,6 +16,8 @@ use crate::db::{
AddColumnResult, CascadeEffect, ChangeColumnTypeResult, DataResult, DeleteResult, AddColumnResult, CascadeEffect, ChangeColumnTypeResult, DataResult, DeleteResult,
InsertResult, TableDescription, UpdateResult, InsertResult, TableDescription, UpdateResult,
}; };
use crate::dsl::lexer::lex;
use crate::dsl::usage;
use crate::dsl::{Command, ParseError, parse_command}; use crate::dsl::{Command, ParseError, parse_command};
use crate::event::AppEvent; use crate::event::AppEvent;
use crate::mode::Mode; use crate::mode::Mode;
@@ -822,10 +824,17 @@ impl App {
// {input}"). A translator changing that prefix // {input}"). A translator changing that prefix
// must update this width too — the constraint is // must update this width too — the constraint is
// captured in the catalog comment block. // 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 { if let ParseError::Invalid { position, .. } = &err {
let prefix = "running: "; let prefix = "running: ";
let trimmed_offset = leading_trim_offset(input); let chars_before = input
let pad = prefix.chars().count() + trimmed_offset + position; .get(..*position)
.map_or(*position, |s| s.chars().count());
let pad = prefix.chars().count() + chars_before;
self.note_error(crate::t!( self.note_error(crate::t!(
"parse.caret", "parse.caret",
padding = " ".repeat(pad) padding = " ".repeat(pad)
@@ -835,6 +844,12 @@ impl App {
"parse.error", "parse.error",
detail = parse_error_message(&err) 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() Vec::new()
} }
} }
@@ -1503,12 +1518,39 @@ fn parse_error_message(err: &ParseError) -> String {
} }
} }
/// Number of leading whitespace characters in `s`. The parser /// Compose the third block of a parse-error rendering
/// trims its input before parsing, so a position returned by the /// (ADR-0021 §2): "usage: …" when at least one
/// parser is relative to the trimmed string. The caret needs the /// command-entry keyword was consumed, otherwise an
/// pre-trim offset to align under the user's literal input. /// "available commands:" fallback (§5).
fn leading_trim_offset(s: &str) -> usize { ///
s.chars().take_while(|c| c.is_whitespace()).count() /// `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 { fn render_cascade_effect(effect: &CascadeEffect) -> String {
@@ -1554,6 +1596,17 @@ mod tests {
app.update(key(KeyCode::Enter)) 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 { fn sample_description(name: &str) -> TableDescription {
TableDescription { TableDescription {
name: name.to_string(), name: name.to_string(),
@@ -1616,12 +1669,17 @@ mod tests {
type_str(&mut app, "create table Customers"); type_str(&mut app, "create table Customers");
let actions = submit(&mut app); let actions = submit(&mut app);
assert!(actions.is_empty()); assert!(actions.is_empty());
let last = app.output.back().unwrap(); // Parse-error rendering is now multi-line (ADR-0021):
assert_eq!(last.kind, OutputKind::Error); // 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!( assert!(
last.text.contains("with pk"), mentions_with_pk,
"error should mention `with pk`: {}", "no error line mentions `with pk`; output:\n{}",
last.text error_lines(&app),
); );
} }
@@ -1631,9 +1689,15 @@ mod tests {
type_str(&mut app, "frobulate widgets"); type_str(&mut app, "frobulate widgets");
let actions = submit(&mut app); let actions = submit(&mut app);
assert!(actions.is_empty()); assert!(actions.is_empty());
let last = app.output.back().unwrap(); let has_parse_error = app
assert_eq!(last.kind, OutputKind::Error); .output
assert!(last.text.starts_with("parse error")); .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] #[test]
@@ -2086,9 +2150,15 @@ mod tests {
type_str(&mut app, "add column to table T: c (varchar)"); type_str(&mut app, "add column to table T: c (varchar)");
let actions = submit(&mut app); let actions = submit(&mut app);
assert!(actions.is_empty()); assert!(actions.is_empty());
let last = app.output.back().unwrap(); let mentions_varchar = app
assert_eq!(last.kind, OutputKind::Error); .output
assert!(last.text.contains("varchar")); .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] #[test]
+1
View File
@@ -16,6 +16,7 @@ pub mod lexer;
pub mod parser; pub mod parser;
pub mod shortid; pub mod shortid;
pub mod types; pub mod types;
pub mod usage;
pub mod value; pub mod value;
pub use action::ReferentialAction; pub use action::ReferentialAction;
+260
View File
@@ -0,0 +1,260 @@
//! Per-command usage template registry (ADR-0021 §1).
//!
//! Each registered entry pairs a `Keyword` (the command's entry
//! token) with a catalog key under `parse.usage.*`. The renderer
//! in `app.rs::dispatch_dsl` looks up matching entries when a
//! parse error has consumed at least one keyword token; entries
//! whose `entry` matches the consumed keyword are rendered as
//! the "usage:" block.
//!
//! For `add` and `drop` (multi-entry families), every matching
//! entry renders — the user gets the full family of options,
//! which is the most pedagogically useful behaviour at the
//! moment of confusion.
//!
//! Adding a new command means: (1) the parser combinator,
//! (2) one entry in `REGISTRY`, (3) one YAML key under
//! `parse.usage.*` in `src/friendly/strings/en-US.yaml`. The
//! catalog validator catches a missing YAML entry; a per-command
//! unit test (`every_command_has_a_registry_entry`) catches a
//! missing registry entry.
use crate::dsl::keyword::Keyword;
use crate::dsl::lexer::{Token, TokenKind};
#[derive(Debug, Clone, Copy)]
pub struct UsageEntry {
/// First keyword that distinguishes this command. Used as
/// the registry-lookup key.
pub entry: Keyword,
/// Catalog key under `parse.usage.*` (ADR-0021 §1). The
/// renderer translates this through the catalog at render
/// time.
pub catalog_key: &'static str,
}
/// One `UsageEntry` per command. Multi-entry families (`add`,
/// `drop`, `show`) appear multiple times.
pub const REGISTRY: &[UsageEntry] = &[
UsageEntry {
entry: Keyword::Create,
catalog_key: "parse.usage.create_table",
},
UsageEntry {
entry: Keyword::Drop,
catalog_key: "parse.usage.drop_table",
},
UsageEntry {
entry: Keyword::Drop,
catalog_key: "parse.usage.drop_column",
},
UsageEntry {
entry: Keyword::Drop,
catalog_key: "parse.usage.drop_relationship",
},
UsageEntry {
entry: Keyword::Add,
catalog_key: "parse.usage.add_column",
},
UsageEntry {
entry: Keyword::Add,
catalog_key: "parse.usage.add_relationship",
},
UsageEntry {
entry: Keyword::Rename,
catalog_key: "parse.usage.rename_column",
},
UsageEntry {
entry: Keyword::Change,
catalog_key: "parse.usage.change_column",
},
UsageEntry {
entry: Keyword::Show,
catalog_key: "parse.usage.show_data",
},
UsageEntry {
entry: Keyword::Show,
catalog_key: "parse.usage.show_table",
},
UsageEntry {
entry: Keyword::Insert,
catalog_key: "parse.usage.insert",
},
UsageEntry {
entry: Keyword::Update,
catalog_key: "parse.usage.update",
},
UsageEntry {
entry: Keyword::Delete,
catalog_key: "parse.usage.delete",
},
UsageEntry {
entry: Keyword::Replay,
catalog_key: "parse.usage.replay",
},
];
/// Find the entry-keyword whose grammar to illustrate.
///
/// `failure_position` is a byte offset in the source pointing
/// at where the parser stopped. Returns the keyword and the
/// catalog keys for every matching usage entry, or `None` if no
/// keyword was consumed before the failure — in which case the
/// caller falls back to the available-commands list per
/// ADR-0021 §5.
#[must_use]
pub fn matched_entry(
tokens: &[Token],
failure_position: usize,
) -> Option<(Keyword, Vec<&'static str>)> {
// Tokens covered by the failure span: their start byte is at
// or before `failure_position`. `<=` (rather than `<`) lets
// custom errors raised by `try_map` — whose span starts at
// the first consumed token — find that first token as the
// entry keyword. Structural errors (whose span points at the
// unexpected token) still find the entry keyword consumed
// before that point.
let entry = tokens
.iter()
.take_while(|t| t.span.0 <= failure_position)
.find_map(|t| match &t.kind {
TokenKind::Keyword(kw) => Some(*kw),
_ => None,
})?;
let matches: Vec<&'static str> = REGISTRY
.iter()
.filter(|e| e.entry == entry)
.map(|e| e.catalog_key)
.collect();
if matches.is_empty() {
None
} else {
Some((entry, matches))
}
}
/// The full set of command-entry keywords, alphabetised by their
/// canonical literal. Used by the "available commands:" fallback
/// (ADR-0021 §5) when no keyword was consumed.
#[must_use]
pub fn entry_keywords_alphabetised() -> Vec<Keyword> {
let mut seen = std::collections::HashSet::new();
let mut out: Vec<Keyword> = REGISTRY
.iter()
.filter_map(|e| if seen.insert(e.entry) { Some(e.entry) } else { None })
.collect();
out.sort_by_key(|k| k.as_str());
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dsl::lexer::lex;
use pretty_assertions::assert_eq;
#[test]
fn every_command_has_a_registry_entry() {
// The parser recognises ten command-entry keywords
// (ADR-0009 + ADR-0006 + ADR-0014). Each MUST be
// represented in the registry — otherwise a parse error
// for that command renders no usage block and the H1a
// pedagogy gap reopens for that family.
for entry in [
Keyword::Create,
Keyword::Drop,
Keyword::Add,
Keyword::Rename,
Keyword::Change,
Keyword::Show,
Keyword::Insert,
Keyword::Update,
Keyword::Delete,
Keyword::Replay,
] {
assert!(
REGISTRY.iter().any(|e| e.entry == entry),
"no usage entry for `{}`",
entry.as_str(),
);
}
}
#[test]
fn matched_entry_returns_none_when_no_keyword_consumed() {
let tokens = lex("frobulate Customers");
assert!(matched_entry(&tokens, 0).is_none());
}
#[test]
fn matched_entry_finds_entry_when_failure_position_equals_first_token_start() {
// Custom errors raised by `try_map` carry the matched
// span — whose `start` is the first consumed token's
// byte offset. For `create table Customers` (incomplete,
// raises the "tables need at least one column" custom
// error), failure position == first token start == 0.
// The entry keyword must still resolve.
let tokens = lex("create table Customers");
assert_eq!(tokens.first().unwrap().span.0, 0);
let (kw, keys) = matched_entry(&tokens, 0).expect("should match Create");
assert_eq!(kw, Keyword::Create);
assert_eq!(keys, vec!["parse.usage.create_table"]);
}
#[test]
fn matched_entry_finds_single_entry_command() {
let tokens = lex("create");
let pos = tokens.last().expect("non-empty").span.1;
let (kw, keys) = matched_entry(&tokens, pos).expect("should match");
assert_eq!(kw, Keyword::Create);
assert_eq!(keys, vec!["parse.usage.create_table"]);
}
#[test]
fn matched_entry_returns_all_family_members_for_add() {
let tokens = lex("add");
let pos = tokens.last().expect("non-empty").span.1;
let (kw, keys) = matched_entry(&tokens, pos).expect("should match");
assert_eq!(kw, Keyword::Add);
// Order matches REGISTRY declaration order. Both add-*
// commands surface.
assert!(keys.contains(&"parse.usage.add_column"));
assert!(keys.contains(&"parse.usage.add_relationship"));
}
#[test]
fn matched_entry_returns_all_family_members_for_drop() {
let tokens = lex("drop");
let pos = tokens.last().expect("non-empty").span.1;
let (kw, keys) = matched_entry(&tokens, pos).expect("should match");
assert_eq!(kw, Keyword::Drop);
assert!(keys.contains(&"parse.usage.drop_table"));
assert!(keys.contains(&"parse.usage.drop_column"));
assert!(keys.contains(&"parse.usage.drop_relationship"));
}
#[test]
fn matched_entry_resolves_to_first_keyword_for_partial_command() {
// `update Customers set` consumed all three tokens; the
// entry keyword is `update` (the first), not `set` (the
// last).
let tokens = lex("update Customers set");
let pos = tokens.last().expect("non-empty").span.1;
let (kw, keys) = matched_entry(&tokens, pos).expect("should match");
assert_eq!(kw, Keyword::Update);
assert_eq!(keys, vec!["parse.usage.update"]);
}
#[test]
fn entry_keywords_alphabetised_returns_ten_unique_sorted_commands() {
let keys = entry_keywords_alphabetised();
let names: Vec<&str> = keys.iter().map(|k| k.as_str()).collect();
assert_eq!(
names,
vec![
"add", "change", "create", "delete", "drop", "insert",
"rename", "replay", "show", "update",
],
);
}
}
+107
View File
@@ -122,9 +122,82 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
("help.cli_banner", &[]), ("help.cli_banner", &[]),
("help.in_app_body", &[]), ("help.in_app_body", &[]),
// ---- Parse error rendering ---- // ---- Parse error rendering ----
("parse.available_commands", &["commands"]),
("parse.caret", &["padding"]), ("parse.caret", &["padding"]),
("parse.empty", &[]), ("parse.empty", &[]),
("parse.error", &["detail"]), ("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 lifecycle event notes ----
("project.export_failed", &["error"]), ("project.export_failed", &["error"]),
("project.export_ok", &["path"]), ("project.export_ok", &["path"]),
@@ -232,9 +305,43 @@ pub const KEYS_AND_PLACEHOLDERS: &[(&str, &[&str])] = &[
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::KEYS_AND_PLACEHOLDERS; use super::KEYS_AND_PLACEHOLDERS;
use crate::dsl::keyword::{Keyword, Punct};
use crate::friendly::format::catalog; use crate::friendly::format::catalog;
use std::collections::HashSet; 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 /// Walks `KEYS_AND_PLACEHOLDERS` and verifies every entry
/// matches the catalog. ADR-0019 §8.6. /// matches the catalog. ADR-0019 §8.6.
/// ///
+89
View File
@@ -263,6 +263,95 @@ parse:
# Default for the `ParseError::Empty` variant — surfaces as # Default for the `ParseError::Empty` variant — surfaces as
# `{detail}` inside the wrapper. # `{detail}` inside the wrapper.
empty: "empty input" 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 lifecycle event notes -----------------------------------
project: project:
+230
View File
@@ -0,0 +1,230 @@
//! Tier-3 integration tests for ADR-0021 (per-command usage in
//! parse errors). Drives synthetic crossterm events through
//! `App::update` and asserts on the rendered output lines.
//!
//! Each test exercises the full input → parse → error-render
//! chain. The unit tests in `dsl::usage::tests` cover the
//! registry logic in isolation; these tests pin the user-visible
//! composition (caret + structural error + usage block, or the
//! available-commands fallback).
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use rdbms_playground::action::Action;
use rdbms_playground::app::{App, OutputKind};
use rdbms_playground::event::AppEvent;
const fn key(code: KeyCode) -> AppEvent {
AppEvent::Key(KeyEvent {
code,
modifiers: KeyModifiers::NONE,
kind: KeyEventKind::Press,
state: crossterm::event::KeyEventState::NONE,
})
}
fn type_str(app: &mut App, s: &str) {
for c in s.chars() {
app.update(key(KeyCode::Char(c)));
}
}
fn submit(app: &mut App) -> Vec<Action> {
app.update(key(KeyCode::Enter))
}
/// Run `input` through the app and return every error-kind
/// output line. Asserts the submission produced no actions
/// (i.e. the parse failed).
fn error_lines_for(input: &str) -> Vec<String> {
let mut app = App::new();
type_str(&mut app, input);
let actions = submit(&mut app);
assert!(
actions.is_empty(),
"expected parse failure (no actions) for {input:?}, got {actions:?}",
);
app.output
.iter()
.filter(|l| l.kind == OutputKind::Error)
.map(|l| l.text.clone())
.collect()
}
fn dump(input: &str, lines: &[String]) -> String {
format!(
"INPUT: {input:?}\nERROR LINES:\n{}",
lines.join("\n"),
)
}
#[test]
fn create_alone_renders_create_table_usage() {
let lines = error_lines_for("create");
let dump_msg = dump("create", &lines);
assert!(
lines.iter().any(|l| l.starts_with("parse error")),
"{dump_msg}",
);
assert!(
lines.iter().any(|l| l == "usage:"),
"missing usage: header\n{dump_msg}",
);
assert!(
lines.iter().any(|l| l.contains("create table") && l.contains("with pk")),
"missing create_table usage template\n{dump_msg}",
);
}
#[test]
fn add_alone_renders_both_add_family_usages() {
let lines = error_lines_for("add");
let dump_msg = dump("add", &lines);
// Aggregation across `choice` (ADR-0020): the structural
// error line lists both add-family entries.
assert!(
lines.iter().any(|l| {
l.starts_with("parse error")
&& l.contains("`1`")
&& l.contains("`column`")
}),
"expected aggregated `1` or `column` in structural error\n{dump_msg}",
);
// Usage block (ADR-0021): both add-* templates surface.
assert!(
lines.iter().any(|l| l.contains("add column")),
"missing add_column usage\n{dump_msg}",
);
assert!(
lines.iter().any(|l| l.contains("add 1:n relationship")),
"missing add_relationship usage\n{dump_msg}",
);
}
#[test]
fn drop_alone_renders_all_three_drop_family_usages() {
let lines = error_lines_for("drop");
let dump_msg = dump("drop", &lines);
assert!(
lines.iter().any(|l| l.contains("drop table")),
"missing drop_table usage\n{dump_msg}",
);
assert!(
lines.iter().any(|l| l.contains("drop column")),
"missing drop_column usage\n{dump_msg}",
);
assert!(
lines.iter().any(|l| l.contains("drop relationship")),
"missing drop_relationship usage\n{dump_msg}",
);
}
#[test]
fn show_alone_renders_both_show_family_usages() {
let lines = error_lines_for("show");
let dump_msg = dump("show", &lines);
assert!(
lines.iter().any(|l| l.contains("show data")),
"missing show_data usage\n{dump_msg}",
);
assert!(
lines.iter().any(|l| l.contains("show table")),
"missing show_table usage\n{dump_msg}",
);
}
#[test]
fn unknown_command_falls_back_to_available_commands_list() {
let lines = error_lines_for("frobulate Customers");
let dump_msg = dump("frobulate Customers", &lines);
// No "usage:" header — the no-prefix fallback path renders
// the available-commands list instead.
assert!(
lines.iter().all(|l| l != "usage:"),
"should not render usage: header for unknown command\n{dump_msg}",
);
let available = lines
.iter()
.find(|l| l.starts_with("available commands:"))
.unwrap_or_else(|| panic!("missing available commands line\n{dump_msg}"));
// The list must include all ten command-entry keywords.
for cmd in [
"add", "change", "create", "delete", "drop", "insert",
"rename", "replay", "show", "update",
] {
assert!(
available.contains(&format!("`{cmd}`")),
"available commands missing `{cmd}`: {available}",
);
}
}
#[test]
fn update_partial_renders_update_usage_template() {
// `update Customers set Active=false` parses through to
// end-of-input; the missing `where` / `--all-rows` clause
// triggers the structural error. The entry keyword is
// `update`, so the update usage template is shown.
let lines = error_lines_for("update Customers set Active=false");
let dump_msg = dump("update Customers set Active=false", &lines);
assert!(
lines.iter().any(|l| l.contains("update <Table> set")),
"missing update usage template\n{dump_msg}",
);
}
#[test]
fn create_table_without_pk_renders_create_table_usage() {
// The custom `try_map` error fires after `create table
// Customers` is fully consumed; failure position points at
// the start of the matched range, but matched_entry's `<=`
// condition still resolves the entry keyword.
let lines = error_lines_for("create table Customers");
let dump_msg = dump("create table Customers", &lines);
// Custom error wording (not just structural) is preserved.
assert!(
lines
.iter()
.any(|l| l.starts_with("parse error") && l.contains("with pk")),
"missing custom-error wording about with pk\n{dump_msg}",
);
// And the usage template surfaces as well.
assert!(
lines
.iter()
.any(|l| l.contains("create table") && l.contains("with pk")),
"missing create_table usage template\n{dump_msg}",
);
}
#[test]
fn insert_partial_renders_insert_usage_template() {
// `insert into T` needs either column-list or value-list to
// follow. Parser reports a structural error; usage template
// surfaces.
let lines = error_lines_for("insert into T");
let dump_msg = dump("insert into T", &lines);
assert!(
lines.iter().any(|l| l.contains("insert into <Table>")),
"missing insert usage template\n{dump_msg}",
);
}
#[test]
fn caret_aligns_under_offending_token() {
// The caret line is whitespace + `^`. After the "running: "
// prefix (9 chars) plus the byte offset of the failure
// position, the `^` should sit directly under the
// offending character. For `frobulate Customers`, the
// failure is at position 0, so the caret is at column 9.
let lines = error_lines_for("frobulate Customers");
let caret = lines
.iter()
.find(|l| l.trim_start_matches(' ').starts_with('^'))
.expect("missing caret line");
let leading_spaces = caret.chars().take_while(|c| *c == ' ').count();
assert_eq!(
leading_spaces, 9,
"caret should sit at column 9 (under `f` of `frobulate` after the `running: ` prefix); got {leading_spaces} spaces in {caret:?}",
);
}