ADR-0024 Phase F (full) step 2: usage via CommandNode.usage_ids

Migrates parse-error usage-block rendering from the legacy
`dsl::usage::matched_entry` (which scanned a `Vec<Token>` for the
first matched Keyword) to walker-side lookup driven by each
`CommandNode`'s `usage_ids` slice.

`CommandNode.usage_id: Option<&'static str>` becomes
`usage_ids: &'static [&'static str]`. Multi-form families
(`drop`, `add`, `show`) carry every variant — `drop` lists
table/column/relationship templates; `add` lists column /
relationship; `show` lists data / table. The single-shape
commands carry their single catalog key.

App-lifecycle CommandNodes had pointed at non-existent
`parse.usage.app.*` keys (never noticed because the field was
unused); they now point at the real catalog entries
(`parse.usage.quit`, `parse.usage.help`, …).

New helpers in `dsl::grammar`:
- `usage_keys_for_input(source) -> Option<(entry_word, usage_ids)>`
  resolves the first identifier-shape token to a CommandNode and
  returns its usage_ids list. Used by `app::render_usage_block`
  and `input_render::ambient_hint`.
- `entry_words_alphabetised() -> Vec<&'static str>` replaces
  `dsl::usage::entry_keywords_alphabetised`.

`dsl::usage` is deleted. The "available commands:" fallback in
`render_usage_block` now formats entry words as `` `<word>` ``
directly (matching the `parse.token.keyword.*` catalog renders);
the per-keyword catalog wrappers will collapse in the next step
(ADR-0024 §cleanup-pass §F).

`parse_command` and `parse_tokens` slim down:
- `parse_command(input)` no longer pre-lexes — the walker scans
  source bytes directly.
- `parse_tokens` (internal-only `pub` for "future I3/I4 work")
  is removed; its body folded into `parse_command`.
- `unknown_command_error` reads the walker registry directly.

Touched modules also drop their `crate::dsl::lexer::lex` and
`crate::dsl::usage` imports: `app.rs`, `input_render.rs`,
`completion.rs`.

Tests: 852 passing, 0 failing, 1 ignored (down from 860 because
the 8 `dsl::usage::tests::*` tests are gone with the module).
This commit is contained in:
claude@clouddev1
2026-05-15 08:27:16 +00:00
parent 7bdd3987e1
commit a41400e532
10 changed files with 103 additions and 402 deletions
+16 -15
View File
@@ -16,8 +16,6 @@ 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;
@@ -1073,8 +1071,8 @@ impl App {
// ADR-0021 §2: append the usage block (if a // ADR-0021 §2: append the usage block (if a
// known command-entry keyword was consumed) or // known command-entry keyword was consumed) or
// the available-commands fallback (§5). // the available-commands fallback (§5).
if let ParseError::Invalid { position, .. } = &err { if let ParseError::Invalid { .. } = &err {
self.note_error(render_usage_block(input, *position)); self.note_error(render_usage_block(input));
} }
Vec::new() Vec::new()
} }
@@ -1720,12 +1718,14 @@ fn parse_error_message(err: &ParseError) -> String {
/// command-entry keyword was consumed, otherwise an /// command-entry keyword was consumed, otherwise an
/// "available commands:" fallback (§5). /// "available commands:" fallback (§5).
/// ///
/// `position` is a byte offset into the original input /// Driven by the walker registry (ADR-0024 §architecture).
/// identifying where the parser stopped — same value the /// If the input's first identifier-shape token is a registered
/// caret uses. /// `CommandNode` entry word, the node's `usage_ids` slice
fn render_usage_block(input: &str, position: usize) -> String { /// renders every catalog template — multi-form families like
let tokens = lex(input); /// `drop` show every variant. Otherwise the fallback lists every
if let Some((_kw, catalog_keys)) = usage::matched_entry(&tokens, position) { /// 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:"); let mut out = String::from("usage:");
for key in catalog_keys { for key in catalog_keys {
let template = crate::friendly::translate(key, &[]); let template = crate::friendly::translate(key, &[]);
@@ -1737,12 +1737,13 @@ fn render_usage_block(input: &str, position: usize) -> String {
} }
return out; return out;
} }
// No-prefix fallback. Render every command-entry keyword via // No-prefix fallback. Each entry word renders backticked
// its `parse.token.keyword.*` catalog key, plain // verbatim (replaces the old `parse.token.keyword.*` catalog
// comma-joined. // lookup; ADR-0024 §cleanup-pass §F prescribes the same
let names: Vec<String> = usage::entry_keywords_alphabetised() // wrapping helper).
let names: Vec<String> = crate::dsl::grammar::entry_words_alphabetised()
.into_iter() .into_iter()
.map(|kw| crate::friendly::translate(&kw.catalog_token_key(), &[])) .map(|w| format!("`{w}`"))
.collect(); .collect();
crate::t!( crate::t!(
"parse.available_commands", "parse.available_commands",
+2 -3
View File
@@ -17,7 +17,6 @@
use crate::dsl::ident_slot::IdentSlot; use crate::dsl::ident_slot::IdentSlot;
use crate::dsl::keyword::Keyword; use crate::dsl::keyword::Keyword;
use crate::dsl::types::Type; use crate::dsl::types::Type;
use crate::dsl::usage;
use crate::dsl::{ParseError, parse_command}; use crate::dsl::{ParseError, parse_command};
/// Label emitted by `type_keyword` (in `dsl::parser`) when it /// 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. /// we synthesise it from the usage registry.
fn expected_set(leading: &str) -> Vec<String> { fn expected_set(leading: &str) -> Vec<String> {
if leading.trim().is_empty() { if leading.trim().is_empty() {
return usage::entry_keywords_alphabetised() return crate::dsl::grammar::entry_words_alphabetised()
.into_iter() .into_iter()
.map(|kw| format!("`{}`", kw.as_str())) .map(|w| format!("`{w}`"))
.collect(); .collect();
} }
match parse_command(leading) { match parse_command(leading) {
+10 -10
View File
@@ -176,7 +176,7 @@ pub static QUIT: CommandNode = CommandNode {
shape: EMPTY_SEQ, shape: EMPTY_SEQ,
ast_builder: build_quit, ast_builder: build_quit,
help_id: Some("app.quit"), help_id: Some("app.quit"),
usage_id: Some("parse.usage.app.quit"), usage_ids: &["parse.usage.quit"],
hint_mode: None, hint_mode: None,
}; };
@@ -185,7 +185,7 @@ pub static HELP: CommandNode = CommandNode {
shape: EMPTY_SEQ, shape: EMPTY_SEQ,
ast_builder: build_help, ast_builder: build_help,
help_id: Some("app.help"), help_id: Some("app.help"),
usage_id: Some("parse.usage.app.help"), usage_ids: &["parse.usage.help"],
hint_mode: None, hint_mode: None,
}; };
@@ -194,7 +194,7 @@ pub static REBUILD: CommandNode = CommandNode {
shape: EMPTY_SEQ, shape: EMPTY_SEQ,
ast_builder: build_rebuild, ast_builder: build_rebuild,
help_id: Some("app.rebuild"), help_id: Some("app.rebuild"),
usage_id: Some("parse.usage.app.rebuild"), usage_ids: &["parse.usage.rebuild"],
hint_mode: None, hint_mode: None,
}; };
@@ -203,7 +203,7 @@ pub static SAVE: CommandNode = CommandNode {
shape: SAVE_AS_OPT, shape: SAVE_AS_OPT,
ast_builder: build_save, ast_builder: build_save,
help_id: Some("app.save"), help_id: Some("app.save"),
usage_id: Some("parse.usage.app.save"), usage_ids: &["parse.usage.save"],
hint_mode: None, hint_mode: None,
}; };
@@ -212,7 +212,7 @@ pub static NEW: CommandNode = CommandNode {
shape: EMPTY_SEQ, shape: EMPTY_SEQ,
ast_builder: build_new, ast_builder: build_new,
help_id: Some("app.new"), help_id: Some("app.new"),
usage_id: Some("parse.usage.app.new"), usage_ids: &["parse.usage.new"],
hint_mode: None, hint_mode: None,
}; };
@@ -221,7 +221,7 @@ pub static LOAD: CommandNode = CommandNode {
shape: EMPTY_SEQ, shape: EMPTY_SEQ,
ast_builder: build_load, ast_builder: build_load,
help_id: Some("app.load"), help_id: Some("app.load"),
usage_id: Some("parse.usage.app.load"), usage_ids: &["parse.usage.load"],
hint_mode: None, hint_mode: None,
}; };
@@ -230,7 +230,7 @@ pub static EXPORT: CommandNode = CommandNode {
shape: EXPORT_PATH_OPT, shape: EXPORT_PATH_OPT,
ast_builder: build_export, ast_builder: build_export,
help_id: Some("app.export"), help_id: Some("app.export"),
usage_id: Some("parse.usage.app.export"), usage_ids: &["parse.usage.export"],
hint_mode: None, hint_mode: None,
}; };
@@ -239,7 +239,7 @@ pub static IMPORT: CommandNode = CommandNode {
shape: IMPORT_BODY_OPT, shape: IMPORT_BODY_OPT,
ast_builder: build_import, ast_builder: build_import,
help_id: Some("app.import"), help_id: Some("app.import"),
usage_id: Some("parse.usage.app.import"), usage_ids: &["parse.usage.import"],
hint_mode: None, hint_mode: None,
}; };
@@ -248,7 +248,7 @@ pub static MODE: CommandNode = CommandNode {
shape: MODE_VALUE, shape: MODE_VALUE,
ast_builder: build_mode, ast_builder: build_mode,
help_id: Some("app.mode"), help_id: Some("app.mode"),
usage_id: Some("parse.usage.app.mode"), usage_ids: &["parse.usage.mode"],
hint_mode: None, hint_mode: None,
}; };
@@ -257,6 +257,6 @@ pub static MESSAGES: CommandNode = CommandNode {
shape: MESSAGES_VALUE_OPT, shape: MESSAGES_VALUE_OPT,
ast_builder: build_messages, ast_builder: build_messages,
help_id: Some("app.messages"), help_id: Some("app.messages"),
usage_id: Some("parse.usage.app.messages"), usage_ids: &["parse.usage.messages"],
hint_mode: None, hint_mode: None,
}; };
+5 -5
View File
@@ -529,7 +529,7 @@ pub static SHOW: CommandNode = CommandNode {
shape: SHOW_SHAPE, shape: SHOW_SHAPE,
ast_builder: build_show, ast_builder: build_show,
help_id: Some("data.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, hint_mode: None,
}; };
@@ -538,7 +538,7 @@ pub static INSERT: CommandNode = CommandNode {
shape: INSERT_SHAPE, shape: INSERT_SHAPE,
ast_builder: build_insert, ast_builder: build_insert,
help_id: Some("data.insert"), help_id: Some("data.insert"),
usage_id: Some("parse.usage.insert"), usage_ids: &["parse.usage.insert"],
hint_mode: None, hint_mode: None,
}; };
@@ -547,7 +547,7 @@ pub static UPDATE: CommandNode = CommandNode {
shape: UPDATE_SHAPE, shape: UPDATE_SHAPE,
ast_builder: build_update, ast_builder: build_update,
help_id: Some("data.update"), help_id: Some("data.update"),
usage_id: Some("parse.usage.update"), usage_ids: &["parse.usage.update"],
hint_mode: None, hint_mode: None,
}; };
@@ -556,7 +556,7 @@ pub static DELETE: CommandNode = CommandNode {
shape: DELETE_SHAPE, shape: DELETE_SHAPE,
ast_builder: build_delete, ast_builder: build_delete,
help_id: Some("data.delete"), help_id: Some("data.delete"),
usage_id: Some("parse.usage.delete"), usage_ids: &["parse.usage.delete"],
hint_mode: None, hint_mode: None,
}; };
@@ -565,6 +565,6 @@ pub static REPLAY: CommandNode = CommandNode {
shape: REPLAY_PATH, shape: REPLAY_PATH,
ast_builder: build_replay, ast_builder: build_replay,
help_id: Some("data.replay"), help_id: Some("data.replay"),
usage_id: Some("parse.usage.replay"), usage_ids: &["parse.usage.replay"],
hint_mode: None, hint_mode: None,
}; };
+9 -5
View File
@@ -549,7 +549,11 @@ pub static DROP: CommandNode = CommandNode {
shape: DROP_SHAPE, shape: DROP_SHAPE,
ast_builder: build_drop, ast_builder: build_drop,
help_id: Some("ddl.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, hint_mode: None,
}; };
@@ -558,7 +562,7 @@ pub static ADD: CommandNode = CommandNode {
shape: ADD_SHAPE, shape: ADD_SHAPE,
ast_builder: build_add, ast_builder: build_add,
help_id: Some("ddl.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, hint_mode: None,
}; };
@@ -567,7 +571,7 @@ pub static RENAME: CommandNode = CommandNode {
shape: RENAME_COLUMN, shape: RENAME_COLUMN,
ast_builder: build_rename_column, ast_builder: build_rename_column,
help_id: Some("ddl.rename"), help_id: Some("ddl.rename"),
usage_id: Some("parse.usage.rename_column"), usage_ids: &["parse.usage.rename_column"],
hint_mode: None, hint_mode: None,
}; };
@@ -576,7 +580,7 @@ pub static CHANGE: CommandNode = CommandNode {
shape: CHANGE_COLUMN, shape: CHANGE_COLUMN,
ast_builder: build_change_column, ast_builder: build_change_column,
help_id: Some("ddl.change"), help_id: Some("ddl.change"),
usage_id: Some("parse.usage.change_column"), usage_ids: &["parse.usage.change_column"],
hint_mode: None, hint_mode: None,
}; };
@@ -698,6 +702,6 @@ pub static CREATE: CommandNode = CommandNode {
shape: CREATE_TABLE, shape: CREATE_TABLE,
ast_builder: build_create_table, ast_builder: build_create_table,
help_id: Some("ddl.create"), help_id: Some("ddl.create"),
usage_id: Some("parse.usage.create_table"), usage_ids: &["parse.usage.create_table"],
hint_mode: None, hint_mode: None,
}; };
+36 -2
View File
@@ -229,12 +229,46 @@ pub struct CommandNode {
pub ast_builder: fn(&MatchedPath) -> Result<Command, ValidationError>, pub ast_builder: fn(&MatchedPath) -> Result<Command, ValidationError>,
#[allow(dead_code)] #[allow(dead_code)]
pub help_id: Option<&'static str>, pub help_id: Option<&'static str>,
#[allow(dead_code)] /// Catalog keys under `parse.usage.*` to render in the
pub usage_id: Option<&'static str>, /// "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)] #[allow(dead_code)]
pub hint_mode: Option<HintMode>, pub hint_mode: Option<HintMode>,
} }
/// 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 /// The active grammar registry. Phase A: the eleven app-lifecycle
/// commands. Migrated commands route through this; everything /// commands. Migrated commands route through this; everything
/// else falls through to the chumsky path in `dsl::parser`. /// else falls through to the chumsky path in `dsl::parser`.
-1
View File
@@ -18,7 +18,6 @@ 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 mod walker; pub mod walker;
+23 -40
View File
@@ -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 //! The chumsky+lexer pipeline has been retired (ADR-0024 §migration
//! token stream, and chumsky combinators over `&[Token]` build the //! Phase F minimal). `parse_command` now routes every input through
//! `Command` AST. Keyword identity is exact via the `Keyword` enum //! the unified-grammar walker in `crate::dsl::walker`. The walker
//! from `crate::dsl::keyword`; alternative-aggregation across //! reads source bytes directly — there is no separate token pre-pass.
//! `choice` is chumsky-native (the load-bearing fix that motivated
//! ADR-0020).
//! //!
//! Errors from chumsky are mapped to the local [`ParseError`] type //! This module remains the public entry point for parsing because
//! so callers do not depend on chumsky's API surface. //! 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::command::Command;
use crate::dsl::lexer::{Token, lex};
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParseError { pub enum ParseError {
@@ -81,48 +82,30 @@ impl ParseError {
} }
/// Parse a single DSL command end-to-end. /// 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<Command, ParseError> { pub fn parse_command(input: &str) -> Result<Command, ParseError> {
if input.trim().is_empty() { if input.trim().is_empty() {
return Err(ParseError::Empty); return Err(ParseError::Empty);
} }
let tokens = lex(input); if let Some(result) = try_walker_route(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<Command, ParseError> {
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) {
return result; return result;
} }
Err(unknown_command_error(source)) Err(unknown_command_error(input))
} }
/// Synthetic ParseError for inputs whose first identifier-shape /// Synthetic ParseError for inputs whose first identifier-shape
/// token isn't a registered command entry word. Replaces the /// token isn't a registered command entry word.
/// chumsky-side "expected `create`, `drop`, …" structural error
/// the legacy parser produced for the same case.
fn unknown_command_error(source: &str) -> ParseError { fn unknown_command_error(source: &str) -> ParseError {
use crate::dsl::grammar::REGISTRY;
use crate::dsl::walker::lex_helpers::{consume_ident, skip_whitespace}; use crate::dsl::walker::lex_helpers::{consume_ident, skip_whitespace};
let mut entries: Vec<String> = REGISTRY let entries: Vec<String> = crate::dsl::grammar::entry_words_alphabetised()
.iter() .into_iter()
.map(|c| format!("`{}`", c.entry.primary)) .map(|w| format!("`{w}`"))
.collect(); .collect();
entries.sort();
let joined = oxford_join(&entries); let joined = oxford_join(&entries);
let start = skip_whitespace(source, 0); let start = skip_whitespace(source, 0);
let (position, found_word) = consume_ident(source, start).map_or_else( let (position, found_word) = consume_ident(source, start).map_or_else(
-318
View File
@@ -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<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() {
// 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",
],
);
}
}
+2 -3
View File
@@ -24,7 +24,6 @@
use ratatui::style::{Modifier, Style}; use ratatui::style::{Modifier, Style};
use crate::dsl::lexer::lex;
use crate::dsl::walker; use crate::dsl::walker;
use crate::dsl::{ParseError, parse_command}; use crate::dsl::{ParseError, parse_command};
use crate::theme::Theme; use crate::theme::Theme;
@@ -248,8 +247,8 @@ pub fn ambient_hint(
))) )))
} }
} else { } else {
let tokens = lex(input); let _ = position;
let usage = crate::dsl::usage::matched_entry(&tokens, position) let usage = crate::dsl::grammar::usage_keys_for_input(input)
.and_then(|(_, keys)| keys.first().copied()) .and_then(|(_, keys)| keys.first().copied())
.map(|key| crate::friendly::translate(key, &[])); .map(|key| crate::friendly::translate(key, &[]));
Some(AmbientHint::Prose(match usage { Some(AmbientHint::Prose(match usage {