11071ae164
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.
261 lines
8.9 KiB
Rust
261 lines
8.9 KiB
Rust
//! 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",
|
|
],
|
|
);
|
|
}
|
|
}
|