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:
+122
-60
@@ -111,10 +111,16 @@ pub fn parse_tokens(tokens: &[Token], source: &str) -> Result<Command, ParseErro
|
||||
if tokens.is_empty() {
|
||||
return Err(ParseError::Empty);
|
||||
}
|
||||
if let Some(result) = try_parse_replay_with_bare_path(tokens, source) {
|
||||
// ADR-0024 Phase A: the unified-grammar walker owns the
|
||||
// app-lifecycle commands (quit, help, rebuild, save / save
|
||||
// as, new, load, export, import, mode, messages). The
|
||||
// walker engages on input whose first identifier-shape
|
||||
// token matches a registered entry word; otherwise the
|
||||
// router falls through to the legacy chumsky path below.
|
||||
if let Some(result) = try_walker_route(source) {
|
||||
return result;
|
||||
}
|
||||
if let Some(result) = try_parse_app_path_command(tokens, source) {
|
||||
if let Some(result) = try_parse_replay_with_bare_path(tokens, source) {
|
||||
return result;
|
||||
}
|
||||
match command_parser().parse(tokens).into_result() {
|
||||
@@ -123,6 +129,114 @@ pub fn parse_tokens(tokens: &[Token], source: &str) -> Result<Command, ParseErro
|
||||
}
|
||||
}
|
||||
|
||||
/// Walker route (ADR-0024 §migration Phase A). Returns `None`
|
||||
/// when the walker doesn't engage (input doesn't start with a
|
||||
/// migrated entry keyword); the router falls through to the
|
||||
/// chumsky path for non-migrated commands.
|
||||
fn try_walker_route(source: &str) -> Option<Result<Command, ParseError>> {
|
||||
use crate::dsl::walker::{self, outcome::WalkBound};
|
||||
let mut ctx = walker::context::WalkContext::new();
|
||||
let (result, command) = walker::walk(source, WalkBound::EndOfInput, &mut ctx);
|
||||
let result = result?;
|
||||
Some(walker_outcome_to_parse_result(result, command))
|
||||
}
|
||||
|
||||
fn walker_outcome_to_parse_result(
|
||||
result: crate::dsl::walker::outcome::WalkResult,
|
||||
command: Option<Command>,
|
||||
) -> Result<Command, ParseError> {
|
||||
use crate::dsl::walker::outcome::WalkOutcome;
|
||||
match result.outcome {
|
||||
WalkOutcome::Match { .. } => command.ok_or_else(|| ParseError::Invalid {
|
||||
message: crate::t!(
|
||||
"parse.error_wrapper",
|
||||
detail = String::from("AST builder failed")
|
||||
),
|
||||
position: 0,
|
||||
at_eof: false,
|
||||
expected: Vec::new(),
|
||||
}),
|
||||
WalkOutcome::Incomplete { position, expected } => Err(ParseError::Invalid {
|
||||
message: format_walker_error(true, &expected, None),
|
||||
position,
|
||||
at_eof: true,
|
||||
expected: expected.iter().map(format_expectation).collect(),
|
||||
}),
|
||||
WalkOutcome::Mismatch { position, expected } => Err(ParseError::Invalid {
|
||||
message: format_walker_error(false, &expected, Some(position)),
|
||||
position,
|
||||
at_eof: false,
|
||||
expected: expected.iter().map(format_expectation).collect(),
|
||||
}),
|
||||
WalkOutcome::ValidationFailed { position, error } => {
|
||||
// Runtime catalog lookup: walker carries the catalog
|
||||
// key + args at `Node::Ident` validators (e.g.,
|
||||
// `mode.unknown`). The `t!` macro requires a literal
|
||||
// key, so we call `friendly::translate` directly.
|
||||
let arg_refs: Vec<(&str, &dyn std::fmt::Display)> = error
|
||||
.args
|
||||
.iter()
|
||||
.map(|(k, v)| (*k, v as &dyn std::fmt::Display))
|
||||
.collect();
|
||||
let message = crate::friendly::translate(error.message_key, &arg_refs);
|
||||
Err(ParseError::Invalid {
|
||||
message,
|
||||
position,
|
||||
at_eof: false,
|
||||
expected: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn format_expectation(e: &crate::dsl::walker::outcome::Expectation) -> String {
|
||||
use crate::dsl::walker::outcome::Expectation;
|
||||
match e {
|
||||
Expectation::Word(w) => format!("`{w}`"),
|
||||
Expectation::Ident { role } => (*role).to_string(),
|
||||
Expectation::Punct(c) => format!("`{c}`"),
|
||||
Expectation::NumberLit => "number".to_string(),
|
||||
Expectation::StringLit => "string literal".to_string(),
|
||||
Expectation::BlobLit => "blob literal".to_string(),
|
||||
Expectation::Flag(name) => format!("`--{name}`"),
|
||||
Expectation::BarePath => "path".to_string(),
|
||||
Expectation::EndOfInput => "end of input".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn format_walker_error(
|
||||
at_eof: bool,
|
||||
expected: &[crate::dsl::walker::outcome::Expectation],
|
||||
_position: Option<usize>,
|
||||
) -> String {
|
||||
let parts: Vec<String> = expected.iter().map(format_expectation).collect();
|
||||
let joined = oxford_join(&parts);
|
||||
if at_eof {
|
||||
if joined.is_empty() {
|
||||
crate::t!("parse.empty")
|
||||
} else {
|
||||
format!("expected {joined}")
|
||||
}
|
||||
} else if joined.is_empty() {
|
||||
"unexpected input".to_string()
|
||||
} else {
|
||||
format!("expected {joined}")
|
||||
}
|
||||
}
|
||||
|
||||
fn oxford_join(items: &[String]) -> String {
|
||||
match items.len() {
|
||||
0 => String::new(),
|
||||
1 => items[0].clone(),
|
||||
2 => format!("{} or {}", items[0], items[1]),
|
||||
_ => {
|
||||
let last = items.len() - 1;
|
||||
let head = items[..last].join(", ");
|
||||
format!("{}, or {}", head, items[last])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `replay` source-slice special case (ADR-0020 §6).
|
||||
///
|
||||
/// `replay <bare-path>` lets the user write paths containing
|
||||
@@ -166,64 +280,12 @@ fn try_parse_replay_with_bare_path(
|
||||
}))
|
||||
}
|
||||
|
||||
/// `export <path>` / `import <path> [as <target>]` source-slice
|
||||
/// special case. Same rationale as `try_parse_replay_with_bare_path`
|
||||
/// — bare paths contain `/`, `.`, `~` which the lexer would either
|
||||
/// split into separate tokens or refuse outright.
|
||||
///
|
||||
/// Returns `None` for the bare-keyword forms (`export`, `import`
|
||||
/// alone), letting the regular chumsky path handle them and
|
||||
/// surface the no-arg `Command::App(...)` variant.
|
||||
fn try_parse_app_path_command(
|
||||
tokens: &[Token],
|
||||
source: &str,
|
||||
) -> Option<Result<Command, ParseError>> {
|
||||
use crate::dsl::command::AppCommand;
|
||||
let first = tokens.first()?;
|
||||
let kw = match &first.kind {
|
||||
TokenKind::Keyword(Keyword::Export) => Keyword::Export,
|
||||
TokenKind::Keyword(Keyword::Import) => Keyword::Import,
|
||||
_ => return None,
|
||||
};
|
||||
let after = first.span.1;
|
||||
let rest = source[after..].trim();
|
||||
if rest.is_empty() {
|
||||
return None;
|
||||
}
|
||||
match kw {
|
||||
Keyword::Export => Some(Ok(Command::App(AppCommand::Export {
|
||||
path: Some(rest.to_string()),
|
||||
}))),
|
||||
Keyword::Import => {
|
||||
// Trailing `as` with no target is a recognised user
|
||||
// mistake — surface the usage hint as a parse error
|
||||
// (catalog wording stays in sync with the existing
|
||||
// dispatch-time error).
|
||||
if rest == "as" || rest.ends_with(" as") {
|
||||
return Some(Err(ParseError::Invalid {
|
||||
message: crate::t!("project.import_empty_target"),
|
||||
position: after + rest.len(),
|
||||
at_eof: true,
|
||||
expected: Vec::new(),
|
||||
}));
|
||||
}
|
||||
let (path, target) = match rest.split_once(" as ") {
|
||||
Some((p, t)) => (p.trim().to_string(), Some(t.trim().to_string())),
|
||||
None => (rest.to_string(), None),
|
||||
};
|
||||
if path.is_empty() {
|
||||
return Some(Err(ParseError::Invalid {
|
||||
message: crate::t!("project.import_usage"),
|
||||
position: after,
|
||||
at_eof: true,
|
||||
expected: vec!["path".to_string()],
|
||||
}));
|
||||
}
|
||||
Some(Ok(Command::App(AppCommand::Import { path, target })))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
// ADR-0024 Phase A removed `try_parse_app_path_command`: the
|
||||
// walker (`crate::dsl::walker`) now owns export / import end-to-
|
||||
// end (including their path arguments via `BarePath`). The
|
||||
// chumsky-side bare-keyword branches in `command_parser`
|
||||
// (`export_no_arg`, `import_no_arg`) are unreachable in practice
|
||||
// but stay declared until Phase F sweeps the chumsky path.
|
||||
|
||||
// =========================================================
|
||||
// Token-aware combinator helpers (ADR-0020 §5)
|
||||
|
||||
Reference in New Issue
Block a user