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
+10 -10
View File
@@ -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,
};
+5 -5
View File
@@ -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,
};
+9 -5
View File
@@ -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
View File
@@ -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`.
-1
View File
@@ -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
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
//! 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(
-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",
],
);
}
}