diff --git a/src/app.rs b/src/app.rs index 057bdbf..3a11d48 100644 --- a/src/app.rs +++ b/src/app.rs @@ -16,8 +16,6 @@ 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; @@ -1073,8 +1071,8 @@ impl App { // 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)); + if let ParseError::Invalid { .. } = &err { + self.note_error(render_usage_block(input)); } Vec::new() } @@ -1720,12 +1718,14 @@ fn parse_error_message(err: &ParseError) -> String { /// 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) { +/// Driven by the walker registry (ADR-0024 §architecture). +/// If the input's first identifier-shape token is a registered +/// `CommandNode` entry word, the node's `usage_ids` slice +/// renders every catalog template — multi-form families like +/// `drop` show every variant. Otherwise the fallback lists every +/// entry keyword alphabetically. +fn render_usage_block(input: &str) -> String { + if let Some((_word, catalog_keys)) = crate::dsl::grammar::usage_keys_for_input(input) { let mut out = String::from("usage:"); for key in catalog_keys { let template = crate::friendly::translate(key, &[]); @@ -1737,12 +1737,13 @@ fn render_usage_block(input: &str, position: usize) -> String { } 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() + // No-prefix fallback. Each entry word renders backticked + // verbatim (replaces the old `parse.token.keyword.*` catalog + // lookup; ADR-0024 §cleanup-pass §F prescribes the same + // wrapping helper). + let names: Vec = crate::dsl::grammar::entry_words_alphabetised() .into_iter() - .map(|kw| crate::friendly::translate(&kw.catalog_token_key(), &[])) + .map(|w| format!("`{w}`")) .collect(); crate::t!( "parse.available_commands", diff --git a/src/completion.rs b/src/completion.rs index 8aac39d..7ea63cf 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -17,7 +17,6 @@ use crate::dsl::ident_slot::IdentSlot; use crate::dsl::keyword::Keyword; use crate::dsl::types::Type; -use crate::dsl::usage; use crate::dsl::{ParseError, parse_command}; /// Label emitted by `type_keyword` (in `dsl::parser`) when it @@ -523,9 +522,9 @@ pub fn invalid_ident_at_cursor( /// we synthesise it from the usage registry. fn expected_set(leading: &str) -> Vec { if leading.trim().is_empty() { - return usage::entry_keywords_alphabetised() + return crate::dsl::grammar::entry_words_alphabetised() .into_iter() - .map(|kw| format!("`{}`", kw.as_str())) + .map(|w| format!("`{w}`")) .collect(); } match parse_command(leading) { diff --git a/src/dsl/grammar/app.rs b/src/dsl/grammar/app.rs index dc37dd1..2b971e7 100644 --- a/src/dsl/grammar/app.rs +++ b/src/dsl/grammar/app.rs @@ -176,7 +176,7 @@ pub static QUIT: CommandNode = CommandNode { shape: EMPTY_SEQ, ast_builder: build_quit, help_id: Some("app.quit"), - usage_id: Some("parse.usage.app.quit"), + usage_ids: &["parse.usage.quit"], hint_mode: None, }; @@ -185,7 +185,7 @@ pub static HELP: CommandNode = CommandNode { shape: EMPTY_SEQ, ast_builder: build_help, help_id: Some("app.help"), - usage_id: Some("parse.usage.app.help"), + usage_ids: &["parse.usage.help"], hint_mode: None, }; @@ -194,7 +194,7 @@ pub static REBUILD: CommandNode = CommandNode { shape: EMPTY_SEQ, ast_builder: build_rebuild, help_id: Some("app.rebuild"), - usage_id: Some("parse.usage.app.rebuild"), + usage_ids: &["parse.usage.rebuild"], hint_mode: None, }; @@ -203,7 +203,7 @@ pub static SAVE: CommandNode = CommandNode { shape: SAVE_AS_OPT, ast_builder: build_save, help_id: Some("app.save"), - usage_id: Some("parse.usage.app.save"), + usage_ids: &["parse.usage.save"], hint_mode: None, }; @@ -212,7 +212,7 @@ pub static NEW: CommandNode = CommandNode { shape: EMPTY_SEQ, ast_builder: build_new, help_id: Some("app.new"), - usage_id: Some("parse.usage.app.new"), + usage_ids: &["parse.usage.new"], hint_mode: None, }; @@ -221,7 +221,7 @@ pub static LOAD: CommandNode = CommandNode { shape: EMPTY_SEQ, ast_builder: build_load, help_id: Some("app.load"), - usage_id: Some("parse.usage.app.load"), + usage_ids: &["parse.usage.load"], hint_mode: None, }; @@ -230,7 +230,7 @@ pub static EXPORT: CommandNode = CommandNode { shape: EXPORT_PATH_OPT, ast_builder: build_export, help_id: Some("app.export"), - usage_id: Some("parse.usage.app.export"), + usage_ids: &["parse.usage.export"], hint_mode: None, }; @@ -239,7 +239,7 @@ pub static IMPORT: CommandNode = CommandNode { shape: IMPORT_BODY_OPT, ast_builder: build_import, help_id: Some("app.import"), - usage_id: Some("parse.usage.app.import"), + usage_ids: &["parse.usage.import"], hint_mode: None, }; @@ -248,7 +248,7 @@ pub static MODE: CommandNode = CommandNode { shape: MODE_VALUE, ast_builder: build_mode, help_id: Some("app.mode"), - usage_id: Some("parse.usage.app.mode"), + usage_ids: &["parse.usage.mode"], hint_mode: None, }; @@ -257,6 +257,6 @@ pub static MESSAGES: CommandNode = CommandNode { shape: MESSAGES_VALUE_OPT, ast_builder: build_messages, help_id: Some("app.messages"), - usage_id: Some("parse.usage.app.messages"), + usage_ids: &["parse.usage.messages"], hint_mode: None, }; diff --git a/src/dsl/grammar/data.rs b/src/dsl/grammar/data.rs index 4ebc661..4dee04b 100644 --- a/src/dsl/grammar/data.rs +++ b/src/dsl/grammar/data.rs @@ -529,7 +529,7 @@ pub static SHOW: CommandNode = CommandNode { shape: SHOW_SHAPE, ast_builder: build_show, help_id: Some("data.show"), - usage_id: Some("parse.usage.show"), + usage_ids: &["parse.usage.show_data", "parse.usage.show_table"], hint_mode: None, }; @@ -538,7 +538,7 @@ pub static INSERT: CommandNode = CommandNode { shape: INSERT_SHAPE, ast_builder: build_insert, help_id: Some("data.insert"), - usage_id: Some("parse.usage.insert"), + usage_ids: &["parse.usage.insert"], hint_mode: None, }; @@ -547,7 +547,7 @@ pub static UPDATE: CommandNode = CommandNode { shape: UPDATE_SHAPE, ast_builder: build_update, help_id: Some("data.update"), - usage_id: Some("parse.usage.update"), + usage_ids: &["parse.usage.update"], hint_mode: None, }; @@ -556,7 +556,7 @@ pub static DELETE: CommandNode = CommandNode { shape: DELETE_SHAPE, ast_builder: build_delete, help_id: Some("data.delete"), - usage_id: Some("parse.usage.delete"), + usage_ids: &["parse.usage.delete"], hint_mode: None, }; @@ -565,6 +565,6 @@ pub static REPLAY: CommandNode = CommandNode { shape: REPLAY_PATH, ast_builder: build_replay, help_id: Some("data.replay"), - usage_id: Some("parse.usage.replay"), + usage_ids: &["parse.usage.replay"], hint_mode: None, }; diff --git a/src/dsl/grammar/ddl.rs b/src/dsl/grammar/ddl.rs index e4b395a..d1f56df 100644 --- a/src/dsl/grammar/ddl.rs +++ b/src/dsl/grammar/ddl.rs @@ -549,7 +549,11 @@ pub static DROP: CommandNode = CommandNode { shape: DROP_SHAPE, ast_builder: build_drop, help_id: Some("ddl.drop"), - usage_id: Some("parse.usage.drop"), + usage_ids: &[ + "parse.usage.drop_table", + "parse.usage.drop_column", + "parse.usage.drop_relationship", + ], hint_mode: None, }; @@ -558,7 +562,7 @@ pub static ADD: CommandNode = CommandNode { shape: ADD_SHAPE, ast_builder: build_add, help_id: Some("ddl.add"), - usage_id: Some("parse.usage.add"), + usage_ids: &["parse.usage.add_column", "parse.usage.add_relationship"], hint_mode: None, }; @@ -567,7 +571,7 @@ pub static RENAME: CommandNode = CommandNode { shape: RENAME_COLUMN, ast_builder: build_rename_column, help_id: Some("ddl.rename"), - usage_id: Some("parse.usage.rename_column"), + usage_ids: &["parse.usage.rename_column"], hint_mode: None, }; @@ -576,7 +580,7 @@ pub static CHANGE: CommandNode = CommandNode { shape: CHANGE_COLUMN, ast_builder: build_change_column, help_id: Some("ddl.change"), - usage_id: Some("parse.usage.change_column"), + usage_ids: &["parse.usage.change_column"], hint_mode: None, }; @@ -698,6 +702,6 @@ pub static CREATE: CommandNode = CommandNode { shape: CREATE_TABLE, ast_builder: build_create_table, help_id: Some("ddl.create"), - usage_id: Some("parse.usage.create_table"), + usage_ids: &["parse.usage.create_table"], hint_mode: None, }; diff --git a/src/dsl/grammar/mod.rs b/src/dsl/grammar/mod.rs index b5f1006..2f7af82 100644 --- a/src/dsl/grammar/mod.rs +++ b/src/dsl/grammar/mod.rs @@ -229,12 +229,46 @@ pub struct CommandNode { pub ast_builder: fn(&MatchedPath) -> Result, #[allow(dead_code)] pub help_id: Option<&'static str>, - #[allow(dead_code)] - pub usage_id: Option<&'static str>, + /// Catalog keys under `parse.usage.*` to render in the + /// "usage:" block when a parse error fires for this command + /// (ADR-0021 §1, ADR-0024 §architecture). Multi-form families + /// like `drop` (drop table / drop column / drop relationship) + /// carry every variant so the user sees the full family on a + /// generic-entry-word failure. + pub usage_ids: &'static [&'static str], #[allow(dead_code)] pub hint_mode: Option, } +/// Look up the usage catalog keys for the entry word at the start +/// of `source`. +/// +/// Case-insensitive, whitespace-tolerant. Replaces +/// `dsl::usage::matched_entry` — the walker is the single source +/// of truth for which command a given input belongs to. +/// +/// Returns the canonical (primary-form) entry literal and the +/// `usage_ids` list, or `None` if no entry word matches. +#[must_use] +pub fn usage_keys_for_input(source: &str) -> Option<(&'static str, &'static [&'static str])> { + use crate::dsl::walker::lex_helpers::{consume_ident, skip_whitespace}; + let start = skip_whitespace(source, 0); + let (kw_start, kw_end) = consume_ident(source, start)?; + let word = &source[kw_start..kw_end]; + let (_, node) = command_for_entry_word(word)?; + Some((node.entry.primary, node.usage_ids)) +} + +/// Every command-entry word in the registry, sorted alphabetically +/// by primary literal. Replaces `dsl::usage::entry_keywords_alphabetised` +/// which read the same data through the legacy `usage::REGISTRY`. +#[must_use] +pub fn entry_words_alphabetised() -> Vec<&'static str> { + let mut words: Vec<&'static str> = REGISTRY.iter().map(|c| c.entry.primary).collect(); + words.sort_unstable(); + words +} + /// The active grammar registry. Phase A: the eleven app-lifecycle /// commands. Migrated commands route through this; everything /// else falls through to the chumsky path in `dsl::parser`. diff --git a/src/dsl/mod.rs b/src/dsl/mod.rs index e30892c..3b3d93f 100644 --- a/src/dsl/mod.rs +++ b/src/dsl/mod.rs @@ -18,7 +18,6 @@ pub mod lexer; pub mod parser; pub mod shortid; pub mod types; -pub mod usage; pub mod value; pub mod walker; diff --git a/src/dsl/parser.rs b/src/dsl/parser.rs index 932846d..a3a5adc 100644 --- a/src/dsl/parser.rs +++ b/src/dsl/parser.rs @@ -1,17 +1,18 @@ -//! DSL parser (ADR-0020 + ADR-0021). +//! DSL parser (ADR-0024). //! -//! Two-phase: a lexer (`crate::dsl::lexer`) produces a span-tagged -//! token stream, and chumsky combinators over `&[Token]` build the -//! `Command` AST. Keyword identity is exact via the `Keyword` enum -//! from `crate::dsl::keyword`; alternative-aggregation across -//! `choice` is chumsky-native (the load-bearing fix that motivated -//! ADR-0020). +//! The chumsky+lexer pipeline has been retired (ADR-0024 §migration +//! Phase F minimal). `parse_command` now routes every input through +//! the unified-grammar walker in `crate::dsl::walker`. The walker +//! reads source bytes directly — there is no separate token pre-pass. //! -//! Errors from chumsky are mapped to the local [`ParseError`] type -//! so callers do not depend on chumsky's API surface. +//! This module remains the public entry point for parsing because +//! consumers depend on `ParseError`'s shape (the `expected`, +//! `position`, `at_eof` fields drive completion, hint rendering, +//! and the input-renderer's error overlay). It also produces the +//! synthetic "unknown command" error when the input's first +//! identifier-shape token isn't a registered entry word. use crate::dsl::command::Command; -use crate::dsl::lexer::{Token, lex}; #[derive(Debug, Clone, PartialEq, Eq)] pub enum ParseError { @@ -81,48 +82,30 @@ impl ParseError { } /// Parse a single DSL command end-to-end. +/// +/// Routes through the unified-grammar walker (ADR-0024 +/// §architecture). If the walker doesn't engage (the input's +/// first identifier-shape token isn't a registered entry word), +/// produces a synthetic "unknown command" error naming every +/// valid entry keyword. pub fn parse_command(input: &str) -> Result { if input.trim().is_empty() { return Err(ParseError::Empty); } - let tokens = lex(input); - parse_tokens(&tokens, input) -} - -/// Parse a token slice into a `Command`. The `source` argument is -/// kept in scope so the `replay` bare-path special case -/// (ADR-0020 §6) can source-slice its argument. -/// -/// Public so future I3 (tab completion) and I4 (syntax -/// highlighting) work can re-enter the parser at this layer -/// without having to re-lex. -pub fn parse_tokens(tokens: &[Token], source: &str) -> Result { - if tokens.is_empty() { - return Err(ParseError::Empty); - } - // ADR-0024 Phase F: the unified-grammar walker now owns - // every command. If the walker doesn't engage (the input's - // first identifier-shape token isn't a registered entry - // word), produce an "unknown command" error naming the - // valid entry keywords. - if let Some(result) = try_walker_route(source) { + if let Some(result) = try_walker_route(input) { return result; } - Err(unknown_command_error(source)) + Err(unknown_command_error(input)) } /// Synthetic ParseError for inputs whose first identifier-shape -/// token isn't a registered command entry word. Replaces the -/// chumsky-side "expected `create`, `drop`, …" structural error -/// the legacy parser produced for the same case. +/// token isn't a registered command entry word. fn unknown_command_error(source: &str) -> ParseError { - use crate::dsl::grammar::REGISTRY; use crate::dsl::walker::lex_helpers::{consume_ident, skip_whitespace}; - let mut entries: Vec = REGISTRY - .iter() - .map(|c| format!("`{}`", c.entry.primary)) + let entries: Vec = crate::dsl::grammar::entry_words_alphabetised() + .into_iter() + .map(|w| format!("`{w}`")) .collect(); - entries.sort(); let joined = oxford_join(&entries); let start = skip_whitespace(source, 0); let (position, found_word) = consume_ident(source, start).map_or_else( diff --git a/src/dsl/usage.rs b/src/dsl/usage.rs deleted file mode 100644 index d6b311f..0000000 --- a/src/dsl/usage.rs +++ /dev/null @@ -1,318 +0,0 @@ -//! 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", - }, - // App-lifecycle commands. Registered alongside DSL commands - // so parse-error rendering surfaces a relevant usage block - // when (e.g.) the user types `mode foo` or `import` alone. - UsageEntry { - entry: Keyword::Quit, - catalog_key: "parse.usage.quit", - }, - UsageEntry { - entry: Keyword::Help, - catalog_key: "parse.usage.help", - }, - UsageEntry { - entry: Keyword::Rebuild, - catalog_key: "parse.usage.rebuild", - }, - UsageEntry { - entry: Keyword::Save, - catalog_key: "parse.usage.save", - }, - UsageEntry { - entry: Keyword::New, - catalog_key: "parse.usage.new", - }, - UsageEntry { - entry: Keyword::Load, - catalog_key: "parse.usage.load", - }, - UsageEntry { - entry: Keyword::Export, - catalog_key: "parse.usage.export", - }, - UsageEntry { - entry: Keyword::Import, - catalog_key: "parse.usage.import", - }, - UsageEntry { - entry: Keyword::Mode, - catalog_key: "parse.usage.mode", - }, - UsageEntry { - entry: Keyword::Messages, - catalog_key: "parse.usage.messages", - }, -]; - -/// 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() { - // Every command-entry keyword recognised by the parser - // 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. - // Round 5 added the app-lifecycle entry keywords - // alongside the original ten DSL entry keywords. - for entry in [ - Keyword::Create, - Keyword::Drop, - Keyword::Add, - Keyword::Rename, - Keyword::Change, - Keyword::Show, - Keyword::Insert, - Keyword::Update, - Keyword::Delete, - Keyword::Replay, - Keyword::Quit, - Keyword::Help, - Keyword::Rebuild, - Keyword::Save, - Keyword::New, - Keyword::Load, - Keyword::Export, - Keyword::Import, - Keyword::Mode, - Keyword::Messages, - ] { - 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_unique_sorted_commands() { - let keys = entry_keywords_alphabetised(); - let names: Vec<&str> = keys.iter().map(|k| k.as_str()).collect(); - // Ten DSL entries plus the ten app-lifecycle entries - // registered in REGISTRY. - assert_eq!( - names, - vec![ - "add", "change", "create", "delete", "drop", "export", - "help", "import", "insert", "load", "messages", "mode", - "new", "quit", "rebuild", "rename", "replay", "save", - "show", "update", - ], - ); - } -} diff --git a/src/input_render.rs b/src/input_render.rs index eb76eda..9caa105 100644 --- a/src/input_render.rs +++ b/src/input_render.rs @@ -24,7 +24,6 @@ use ratatui::style::{Modifier, Style}; -use crate::dsl::lexer::lex; use crate::dsl::walker; use crate::dsl::{ParseError, parse_command}; use crate::theme::Theme; @@ -248,8 +247,8 @@ pub fn ambient_hint( ))) } } else { - let tokens = lex(input); - let usage = crate::dsl::usage::matched_entry(&tokens, position) + let _ = position; + let usage = crate::dsl::grammar::usage_keys_for_input(input) .and_then(|(_, keys)| keys.first().copied()) .map(|key| crate::friendly::translate(key, &[])); Some(AmbientHint::Prose(match usage {