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:
+16
-15
@@ -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
@@ -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
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
@@ -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`.
|
||||||
|
|||||||
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user