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:
+10
-10
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
+36
-2
@@ -229,12 +229,46 @@ pub struct CommandNode {
|
||||
pub ast_builder: fn(&MatchedPath) -> Result<Command, ValidationError>,
|
||||
#[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<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
|
||||
/// commands. Migrated commands route through this; everything
|
||||
/// else falls through to the chumsky path in `dsl::parser`.
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
+23
-40
@@ -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<Command, ParseError> {
|
||||
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<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) {
|
||||
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<String> = REGISTRY
|
||||
.iter()
|
||||
.map(|c| format!("`{}`", c.entry.primary))
|
||||
let entries: Vec<String> = 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(
|
||||
|
||||
@@ -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",
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user