From 11071ae164c5129b13f897266a8d57ae50acf0c5 Mon Sep 17 00:00:00 2001 From: "claude@clouddev1" Date: Sun, 10 May 2026 14:41:32 +0000 Subject: [PATCH] ADR-0021 implementation: per-command usage templates in parse errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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.` 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. --- src/app.rs | 108 ++++++++++--- src/dsl/mod.rs | 1 + src/dsl/usage.rs | 260 ++++++++++++++++++++++++++++++++ src/friendly/keys.rs | 107 +++++++++++++ src/friendly/strings/en-US.yaml | 89 +++++++++++ tests/parse_error_pedagogy.rs | 230 ++++++++++++++++++++++++++++ 6 files changed, 776 insertions(+), 19 deletions(-) create mode 100644 src/dsl/usage.rs create mode 100644 tests/parse_error_pedagogy.rs diff --git a/src/app.rs b/src/app.rs index 85ff562..e1d6ddf 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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 = 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::>() + .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] diff --git a/src/dsl/mod.rs b/src/dsl/mod.rs index 18d942f..83708bd 100644 --- a/src/dsl/mod.rs +++ b/src/dsl/mod.rs @@ -16,6 +16,7 @@ pub mod lexer; pub mod parser; pub mod shortid; pub mod types; +pub mod usage; pub mod value; pub use action::ReferentialAction; diff --git a/src/dsl/usage.rs b/src/dsl/usage.rs new file mode 100644 index 0000000..409aaf1 --- /dev/null +++ b/src/dsl/usage.rs @@ -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 { + let mut seen = std::collections::HashSet::new(); + let mut out: Vec = 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", + ], + ); + } +} diff --git a/src/friendly/keys.rs b/src/friendly/keys.rs index 20024eb..69c020f 100644 --- a/src/friendly/keys.rs +++ b/src/friendly/keys.rs @@ -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.` entry; every `Punct` + /// variant must have a `parse.token.punct.` 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 = 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. /// diff --git a/src/friendly/strings/en-US.yaml b/src/friendly/strings/en-US.yaml index 50b6985..502c590 100644 --- a/src/friendly/strings/en-US.yaml +++ b/src/friendly/strings/en-US.yaml @@ -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 with pk [:[, ...]]" + drop_table: "drop table " + drop_column: "drop column [from] [table] : " + drop_relationship: |- + drop relationship + drop relationship from .to .+ add_column: "add column [to] [table]
: ()" + add_relationship: |- + add 1:n relationship [as ] + from .to .+ [on delete ] [on update ] + [--create-fk] + rename_column: "rename column [in] [table]
: to " + change_column: |- + change column [in] [table]
: () + [--force-conversion | --dont-convert] + show_data: "show data
" + show_table: "show table
" + insert: "insert into
[([, ...])] [values] ([, ...])" + update: "update
set =[, ...] (where = | --all-rows)" + delete: "delete from
(where = | --all-rows)" + replay: "replay | replay ''" + # 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: diff --git a/tests/parse_error_pedagogy.rs b/tests/parse_error_pedagogy.rs new file mode 100644 index 0000000..ad20496 --- /dev/null +++ b/tests/parse_error_pedagogy.rs @@ -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 { + 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 { + 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
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
")), + "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:?}", + ); +}