ADR-0024 Phase A: walker framework + app-lifecycle commands
Stand up the unified-grammar tree walker alongside the existing
chumsky parser and migrate the eleven app-lifecycle commands
(quit, help, rebuild, save / save as, new, load, export, import,
mode, messages) end-to-end. The router in parse_tokens consults
the walker first; non-migrated commands still fall through to
chumsky.
Scope:
- src/dsl/grammar/{mod,app}.rs: Node enum (13 kinds), Word /
IdentSource / HintMode / HighlightClass / ValidationError /
CommandNode types, REGISTRY of the eleven app commands.
- src/dsl/walker/{mod,driver,context,outcome,lex_helpers}.rs:
scannerless byte-level walker, per-node-kind dispatch with
Choice/Seq/Optional backtracking, WalkContext (Phase B-D
schema fields stubbed), WalkOutcome with Match/Incomplete/
Mismatch/ValidationFailed.
- src/dsl/parser.rs: try_walker_route() runs first in
parse_tokens; bridge converts WalkOutcome to ParseError
preserving catalog wording (mode.unknown / messages.unknown
surface verbatim via friendly::translate). Legacy
try_parse_app_path_command deleted; chumsky's bare-keyword
app branches remain unreachable until Phase F sweep.
Walker design choices worth noting:
- mode <value> / messages <value> use Choice(Word, Word, Ident)
so known keywords appear in the expected-set; the trailing
Ident catch-all funnels unknown values into the friendly
validator that always errors with the catalog wording.
- save / save as is one CommandNode (Optional(Word("as"))) -
closes the round-5 "save Tab can't offer as" limitation
structurally.
- Path-bearing UX shipped per ADR-0024: BarePath terminates at
whitespace; paths with spaces use the (not-yet-wired) quoted
form. Existing tests pass on the new shape.
Tests:
- 28 new walker-specific tests in dsl::walker::tests covering
every app-lifecycle command, friendly-error wording for
mode/messages unknown values, trailing-garbage detection,
whitespace tolerance, and routing fall-through.
- Total: 805 passed, 0 failed, 1 ignored (was 777 / 1).
- cargo clippy --all-targets -- -D warnings clean.
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
//! App-lifecycle command nodes (ADR-0024 §migration Phase A).
|
||||
//!
|
||||
//! Eleven commands: quit, help, rebuild, save (+ save as), new,
|
||||
//! load, export, import, mode, messages.
|
||||
//!
|
||||
//! Each block is one `CommandNode`: entry keyword, shape, AST
|
||||
//! builder, help / usage references. The ast_builders match
|
||||
//! against the `MatchedPath` items in declaration order.
|
||||
|
||||
use crate::dsl::command::{AppCommand, Command, MessagesValue, ModeValue};
|
||||
use crate::dsl::grammar::{
|
||||
CommandNode, IdentSource, IdentValidator, Node, ValidationError, Word,
|
||||
};
|
||||
use crate::dsl::walker::outcome::{MatchedKind, MatchedPath};
|
||||
|
||||
// --- Validators ----------------------------------------------------
|
||||
//
|
||||
// The catch-all `Ident` branches in `mode <value>` /
|
||||
// `messages <value>` exist solely to convert any out-of-set
|
||||
// identifier into a friendly `mode.unknown` / `messages.unknown`
|
||||
// catalog wording. The known values are `Word` siblings in the
|
||||
// same `Choice`, so they're never reached on the happy path —
|
||||
// these validators always fail.
|
||||
|
||||
fn validate_unknown_mode(value: &str) -> Result<(), ValidationError> {
|
||||
Err(ValidationError {
|
||||
message_key: "mode.unknown",
|
||||
args: vec![("value", value.to_string())],
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_unknown_messages(value: &str) -> Result<(), ValidationError> {
|
||||
Err(ValidationError {
|
||||
message_key: "messages.unknown",
|
||||
args: vec![("value", value.to_string())],
|
||||
})
|
||||
}
|
||||
|
||||
const UNKNOWN_MODE_VALIDATOR: IdentValidator = validate_unknown_mode;
|
||||
const UNKNOWN_MESSAGES_VALIDATOR: IdentValidator = validate_unknown_messages;
|
||||
|
||||
// --- Shapes (constants are referenced by Optional/Choice slices) --
|
||||
|
||||
const SAVE_AS_WORD: Node = Node::Word(Word::keyword("as"));
|
||||
|
||||
const IMPORT_AS_TARGET: Node = Node::Seq(&[
|
||||
Node::Word(Word::keyword("as")),
|
||||
Node::Ident {
|
||||
source: IdentSource::NewName,
|
||||
role: "target",
|
||||
validator: None,
|
||||
highlight_override: None,
|
||||
},
|
||||
]);
|
||||
const IMPORT_AS_TARGET_OPT: Node = Node::Optional(&IMPORT_AS_TARGET);
|
||||
|
||||
const IMPORT_PATH_AND_TARGET: Node = Node::Seq(&[Node::BarePath, IMPORT_AS_TARGET_OPT]);
|
||||
|
||||
const EXPORT_PATH_OPT: Node = Node::Optional(&Node::BarePath);
|
||||
const IMPORT_BODY_OPT: Node = Node::Optional(&IMPORT_PATH_AND_TARGET);
|
||||
|
||||
// `mode <value>`: known keywords are surfaced as `Word` children
|
||||
// so they appear in the walker's expected set (and feed the
|
||||
// completion engine's keyword candidates). The trailing `Ident`
|
||||
// child catches any other identifier shape and funnels it into
|
||||
// the friendly `mode.unknown` validator.
|
||||
const MODE_CHOICES: &[Node] = &[
|
||||
Node::Word(Word::keyword("simple")),
|
||||
Node::Word(Word::keyword("advanced")),
|
||||
Node::Ident {
|
||||
source: IdentSource::Free,
|
||||
role: "mode_value",
|
||||
validator: Some(UNKNOWN_MODE_VALIDATOR),
|
||||
highlight_override: None,
|
||||
},
|
||||
];
|
||||
const MODE_VALUE: Node = Node::Choice(MODE_CHOICES);
|
||||
|
||||
const MESSAGES_CHOICES: &[Node] = &[
|
||||
Node::Word(Word::keyword("short")),
|
||||
Node::Word(Word::keyword("verbose")),
|
||||
Node::Ident {
|
||||
source: IdentSource::Free,
|
||||
role: "messages_value",
|
||||
validator: Some(UNKNOWN_MESSAGES_VALIDATOR),
|
||||
highlight_override: None,
|
||||
},
|
||||
];
|
||||
const MESSAGES_VALUE: Node = Node::Choice(MESSAGES_CHOICES);
|
||||
const MESSAGES_VALUE_OPT: Node = Node::Optional(&MESSAGES_VALUE);
|
||||
|
||||
const EMPTY_SEQ: Node = Node::Seq(&[]);
|
||||
const SAVE_AS_OPT: Node = Node::Optional(&SAVE_AS_WORD);
|
||||
|
||||
// --- AST builders --------------------------------------------------
|
||||
|
||||
const fn build_quit(_path: &MatchedPath) -> Result<Command, ValidationError> {
|
||||
Ok(Command::App(AppCommand::Quit))
|
||||
}
|
||||
|
||||
const fn build_help(_path: &MatchedPath) -> Result<Command, ValidationError> {
|
||||
Ok(Command::App(AppCommand::Help))
|
||||
}
|
||||
|
||||
const fn build_rebuild(_path: &MatchedPath) -> Result<Command, ValidationError> {
|
||||
Ok(Command::App(AppCommand::Rebuild))
|
||||
}
|
||||
|
||||
fn build_save(path: &MatchedPath) -> Result<Command, ValidationError> {
|
||||
if path.contains_word("as") {
|
||||
Ok(Command::App(AppCommand::SaveAs))
|
||||
} else {
|
||||
Ok(Command::App(AppCommand::Save))
|
||||
}
|
||||
}
|
||||
|
||||
const fn build_new(_path: &MatchedPath) -> Result<Command, ValidationError> {
|
||||
Ok(Command::App(AppCommand::New))
|
||||
}
|
||||
|
||||
const fn build_load(_path: &MatchedPath) -> Result<Command, ValidationError> {
|
||||
Ok(Command::App(AppCommand::Load))
|
||||
}
|
||||
|
||||
fn build_export(path: &MatchedPath) -> Result<Command, ValidationError> {
|
||||
let bare = path
|
||||
.find(|i| matches!(i.kind, MatchedKind::BarePath))
|
||||
.map(|i| i.text.clone());
|
||||
Ok(Command::App(AppCommand::Export { path: bare }))
|
||||
}
|
||||
|
||||
fn build_import(path: &MatchedPath) -> Result<Command, ValidationError> {
|
||||
let bare_path = path
|
||||
.find(|i| matches!(i.kind, MatchedKind::BarePath))
|
||||
.map(|i| i.text.clone())
|
||||
.unwrap_or_default();
|
||||
let target = path
|
||||
.find(|i| matches!(&i.kind, MatchedKind::Ident { role } if *role == "target"))
|
||||
.map(|i| i.text.clone());
|
||||
Ok(Command::App(AppCommand::Import {
|
||||
path: bare_path,
|
||||
target,
|
||||
}))
|
||||
}
|
||||
|
||||
fn build_mode(path: &MatchedPath) -> Result<Command, ValidationError> {
|
||||
// The Choice surfaces the matched value as either a `Word`
|
||||
// (known) or an `Ident` (unknown). The unknown branch's
|
||||
// validator always errors, so reaching the AST builder
|
||||
// implies one of the Word branches matched.
|
||||
let value = if path.contains_word("simple") {
|
||||
ModeValue::Simple
|
||||
} else if path.contains_word("advanced") {
|
||||
ModeValue::Advanced
|
||||
} else {
|
||||
ModeValue::Simple
|
||||
};
|
||||
Ok(Command::App(AppCommand::Mode { value }))
|
||||
}
|
||||
|
||||
fn build_messages(path: &MatchedPath) -> Result<Command, ValidationError> {
|
||||
let value = if path.contains_word("short") {
|
||||
Some(MessagesValue::Short)
|
||||
} else if path.contains_word("verbose") {
|
||||
Some(MessagesValue::Verbose)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(Command::App(AppCommand::Messages { value }))
|
||||
}
|
||||
|
||||
// --- Command nodes -------------------------------------------------
|
||||
|
||||
pub static QUIT: CommandNode = CommandNode {
|
||||
entry: Word::keyword("quit"),
|
||||
shape: EMPTY_SEQ,
|
||||
ast_builder: build_quit,
|
||||
help_id: Some("app.quit"),
|
||||
usage_id: Some("parse.usage.app.quit"),
|
||||
hint_mode: None,
|
||||
};
|
||||
|
||||
pub static HELP: CommandNode = CommandNode {
|
||||
entry: Word::keyword("help"),
|
||||
shape: EMPTY_SEQ,
|
||||
ast_builder: build_help,
|
||||
help_id: Some("app.help"),
|
||||
usage_id: Some("parse.usage.app.help"),
|
||||
hint_mode: None,
|
||||
};
|
||||
|
||||
pub static REBUILD: CommandNode = CommandNode {
|
||||
entry: Word::keyword("rebuild"),
|
||||
shape: EMPTY_SEQ,
|
||||
ast_builder: build_rebuild,
|
||||
help_id: Some("app.rebuild"),
|
||||
usage_id: Some("parse.usage.app.rebuild"),
|
||||
hint_mode: None,
|
||||
};
|
||||
|
||||
pub static SAVE: CommandNode = CommandNode {
|
||||
entry: Word::keyword("save"),
|
||||
shape: SAVE_AS_OPT,
|
||||
ast_builder: build_save,
|
||||
help_id: Some("app.save"),
|
||||
usage_id: Some("parse.usage.app.save"),
|
||||
hint_mode: None,
|
||||
};
|
||||
|
||||
pub static NEW: CommandNode = CommandNode {
|
||||
entry: Word::keyword("new"),
|
||||
shape: EMPTY_SEQ,
|
||||
ast_builder: build_new,
|
||||
help_id: Some("app.new"),
|
||||
usage_id: Some("parse.usage.app.new"),
|
||||
hint_mode: None,
|
||||
};
|
||||
|
||||
pub static LOAD: CommandNode = CommandNode {
|
||||
entry: Word::keyword("load"),
|
||||
shape: EMPTY_SEQ,
|
||||
ast_builder: build_load,
|
||||
help_id: Some("app.load"),
|
||||
usage_id: Some("parse.usage.app.load"),
|
||||
hint_mode: None,
|
||||
};
|
||||
|
||||
pub static EXPORT: CommandNode = CommandNode {
|
||||
entry: Word::keyword("export"),
|
||||
shape: EXPORT_PATH_OPT,
|
||||
ast_builder: build_export,
|
||||
help_id: Some("app.export"),
|
||||
usage_id: Some("parse.usage.app.export"),
|
||||
hint_mode: None,
|
||||
};
|
||||
|
||||
pub static IMPORT: CommandNode = CommandNode {
|
||||
entry: Word::keyword("import"),
|
||||
shape: IMPORT_BODY_OPT,
|
||||
ast_builder: build_import,
|
||||
help_id: Some("app.import"),
|
||||
usage_id: Some("parse.usage.app.import"),
|
||||
hint_mode: None,
|
||||
};
|
||||
|
||||
pub static MODE: CommandNode = CommandNode {
|
||||
entry: Word::keyword("mode"),
|
||||
shape: MODE_VALUE,
|
||||
ast_builder: build_mode,
|
||||
help_id: Some("app.mode"),
|
||||
usage_id: Some("parse.usage.app.mode"),
|
||||
hint_mode: None,
|
||||
};
|
||||
|
||||
pub static MESSAGES: CommandNode = CommandNode {
|
||||
entry: Word::keyword("messages"),
|
||||
shape: MESSAGES_VALUE_OPT,
|
||||
ast_builder: build_messages,
|
||||
help_id: Some("app.messages"),
|
||||
usage_id: Some("parse.usage.app.messages"),
|
||||
hint_mode: None,
|
||||
};
|
||||
Reference in New Issue
Block a user