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:
claude@clouddev1
2026-05-15 06:39:29 +00:00
parent 3e1ff83f26
commit 50b3542050
9 changed files with 1696 additions and 60 deletions
+122 -60
View File
@@ -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)