//! 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", ], ); } }