ADR-0024 Phase E: replay end-to-end
Migrate `replay <path>` to the walker. Shape is Choice(StringLit, BarePath); the StringLit branch handles the quoted form (with the existing `''` escape), and BarePath handles the unquoted form. Per ADR-0024's path-bearing UX change (already shipped for import / export in Phase A), bare `replay` paths terminate at the first whitespace byte. Paths with spaces require the quoted form. The legacy `try_parse_replay_with_bare_path` source-slice helper in dsl/parser.rs is removed; the chumsky-side replay branch in command_parser stays declared but unreachable until Phase F sweeps the chumsky path. Tests: - 7 new walker-specific tests for replay: bare relative path, bare absolute path, quoted with whitespace, quoted with escaped quote, case-insensitive keyword, missing-path error, empty-quoted-path parses to empty (runtime layer rejects). - Total: 844 passed, 0 failed, 1 ignored (was 838 / 1). - cargo clippy --all-targets -- -D warnings clean.
This commit is contained in:
@@ -490,6 +490,36 @@ fn build_delete(path: &MatchedPath) -> Result<Command, ValidationError> {
|
|||||||
Ok(Command::Delete { table, filter })
|
Ok(Command::Delete { table, filter })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// replay — `replay <bare-path>` | `replay '<path>'`
|
||||||
|
// =================================================================
|
||||||
|
//
|
||||||
|
// Phase E (ADR-0024 §migration). The chumsky-side
|
||||||
|
// `try_parse_replay_with_bare_path` source-slice helper is
|
||||||
|
// retired here: walker BarePath consumes the unquoted form
|
||||||
|
// (terminating at whitespace per the path-bearing UX change),
|
||||||
|
// and StringLit consumes the quoted form. Paths with spaces
|
||||||
|
// must use the quoted form — same UX that `import` / `export`
|
||||||
|
// adopted in Phase A.
|
||||||
|
|
||||||
|
const REPLAY_PATH_CHOICES: &[Node] = &[Node::StringLit, Node::BarePath];
|
||||||
|
const REPLAY_PATH: Node = Node::Choice(REPLAY_PATH_CHOICES);
|
||||||
|
|
||||||
|
fn build_replay(path: &MatchedPath) -> Result<Command, ValidationError> {
|
||||||
|
let payload = path
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.find_map(|i| match &i.kind {
|
||||||
|
MatchedKind::StringLit | MatchedKind::BarePath => Some(i.text.clone()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.ok_or_else(|| ValidationError {
|
||||||
|
message_key: "parse.error_wrapper",
|
||||||
|
args: vec![("detail", "missing path".to_string())],
|
||||||
|
})?;
|
||||||
|
Ok(Command::Replay { path: payload })
|
||||||
|
}
|
||||||
|
|
||||||
// =================================================================
|
// =================================================================
|
||||||
// CommandNodes
|
// CommandNodes
|
||||||
// =================================================================
|
// =================================================================
|
||||||
@@ -529,3 +559,12 @@ pub static DELETE: CommandNode = CommandNode {
|
|||||||
usage_id: Some("parse.usage.delete"),
|
usage_id: Some("parse.usage.delete"),
|
||||||
hint_mode: None,
|
hint_mode: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pub static REPLAY: CommandNode = CommandNode {
|
||||||
|
entry: Word::keyword("replay"),
|
||||||
|
shape: REPLAY_PATH,
|
||||||
|
ast_builder: build_replay,
|
||||||
|
help_id: Some("data.replay"),
|
||||||
|
usage_id: Some("parse.usage.replay"),
|
||||||
|
hint_mode: None,
|
||||||
|
};
|
||||||
|
|||||||
@@ -259,6 +259,7 @@ pub static REGISTRY: &[&CommandNode] = &[
|
|||||||
&data::INSERT,
|
&data::INSERT,
|
||||||
&data::UPDATE,
|
&data::UPDATE,
|
||||||
&data::DELETE,
|
&data::DELETE,
|
||||||
|
&data::REPLAY,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Look up a `CommandNode` by entry word, case-insensitively.
|
/// Look up a `CommandNode` by entry word, case-insensitively.
|
||||||
|
|||||||
+5
-45
@@ -120,9 +120,6 @@ pub fn parse_tokens(tokens: &[Token], source: &str) -> Result<Command, ParseErro
|
|||||||
if let Some(result) = try_walker_route(source) {
|
if let Some(result) = try_walker_route(source) {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
if let Some(result) = try_parse_replay_with_bare_path(tokens, source) {
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
match command_parser().parse(tokens).into_result() {
|
match command_parser().parse(tokens).into_result() {
|
||||||
Ok(cmd) => Ok(cmd),
|
Ok(cmd) => Ok(cmd),
|
||||||
Err(errs) => Err(into_parse_error(&errs, tokens, source)),
|
Err(errs) => Err(into_parse_error(&errs, tokens, source)),
|
||||||
@@ -272,48 +269,11 @@ fn oxford_join(items: &[String]) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `replay` source-slice special case (ADR-0020 §6).
|
// ADR-0024 Phase E removed `try_parse_replay_with_bare_path`:
|
||||||
///
|
// the walker now owns `replay` end-to-end via
|
||||||
/// `replay <bare-path>` lets the user write paths containing
|
// `Choice(StringLit, BarePath)`. The chumsky-side replay
|
||||||
/// `/`, `.`, `~`, etc. — characters that the lexer would either
|
// branch in `command_parser` is unreachable until Phase F
|
||||||
/// classify as `Punct` or as `Error(UnknownChar)`. To keep the
|
// sweeps the chumsky path.
|
||||||
/// existing UX working, we detect `replay` followed by anything
|
|
||||||
/// other than a `StringLiteral` and source-slice the rest of
|
|
||||||
/// the input as the path. The quoted form (`replay '<path>'`)
|
|
||||||
/// goes through the regular chumsky path.
|
|
||||||
fn try_parse_replay_with_bare_path(
|
|
||||||
tokens: &[Token],
|
|
||||||
source: &str,
|
|
||||||
) -> Option<Result<Command, ParseError>> {
|
|
||||||
let first = tokens.first()?;
|
|
||||||
if !matches!(first.kind, TokenKind::Keyword(Keyword::Replay)) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
if matches!(
|
|
||||||
tokens.get(1).map(|t| &t.kind),
|
|
||||||
Some(TokenKind::StringLiteral(_))
|
|
||||||
) {
|
|
||||||
// Quoted form — chumsky handles it (and rejects any
|
|
||||||
// trailing garbage).
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let after_replay = first.span.1;
|
|
||||||
let rest = source[after_replay..].trim();
|
|
||||||
if rest.is_empty() {
|
|
||||||
// `replay` with nothing after — produce the same shape
|
|
||||||
// of error chumsky would (positioned where the path
|
|
||||||
// should have started).
|
|
||||||
return Some(Err(ParseError::Invalid {
|
|
||||||
message: crate::t!("parse.custom.replay_path_expected"),
|
|
||||||
position: after_replay,
|
|
||||||
at_eof: true,
|
|
||||||
expected: vec!["path".to_string()],
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
Some(Ok(Command::Replay {
|
|
||||||
path: rest.to_string(),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ADR-0024 Phase A removed `try_parse_app_path_command`: the
|
// ADR-0024 Phase A removed `try_parse_app_path_command`: the
|
||||||
// walker (`crate::dsl::walker`) now owns export / import end-to-
|
// walker (`crate::dsl::walker`) now owns export / import end-to-
|
||||||
|
|||||||
+70
-6
@@ -907,13 +907,77 @@ mod tests {
|
|||||||
assert!(parse("update Customers set Email='x'").is_err());
|
assert!(parse("update Customers set Email='x'").is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================================
|
||||||
|
// Phase E — replay.
|
||||||
|
// =========================================================
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn walker_does_not_engage_for_replay() {
|
fn walker_parses_replay_with_bare_relative_path() {
|
||||||
// `replay` isn't migrated yet (Phase E); router falls
|
assert_eq!(
|
||||||
// through to chumsky.
|
|
||||||
assert!(matches!(
|
|
||||||
parse("replay history.log").unwrap(),
|
parse("replay history.log").unwrap(),
|
||||||
Command::Replay { .. }
|
Command::Replay {
|
||||||
));
|
path: "history.log".to_string(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn walker_parses_replay_with_bare_absolute_path() {
|
||||||
|
assert_eq!(
|
||||||
|
parse("replay /tmp/seed.commands").unwrap(),
|
||||||
|
Command::Replay {
|
||||||
|
path: "/tmp/seed.commands".to_string(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn walker_parses_replay_with_quoted_path_supports_whitespace() {
|
||||||
|
// Phase A's path-bearing UX change: paths with spaces use
|
||||||
|
// the quoted form.
|
||||||
|
assert_eq!(
|
||||||
|
parse("replay 'my project/seed.commands'").unwrap(),
|
||||||
|
Command::Replay {
|
||||||
|
path: "my project/seed.commands".to_string(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn walker_parses_replay_with_quoted_path_supports_escaped_quote() {
|
||||||
|
assert_eq!(
|
||||||
|
parse("replay 'O''Brien.commands'").unwrap(),
|
||||||
|
Command::Replay {
|
||||||
|
path: "O'Brien.commands".to_string(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn walker_replay_keyword_case_insensitive() {
|
||||||
|
assert_eq!(
|
||||||
|
parse("REPLAY foo.txt").unwrap(),
|
||||||
|
Command::Replay {
|
||||||
|
path: "foo.txt".to_string(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn walker_replay_without_path_errors() {
|
||||||
|
assert!(parse("replay").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn walker_replay_with_empty_quoted_path_parses_as_empty() {
|
||||||
|
// Parser layer accepts; runtime rejects empty paths
|
||||||
|
// before any I/O. Mirrors the chumsky-side contract
|
||||||
|
// (parser.rs `replay_with_empty_quoted_path_errors`).
|
||||||
|
assert_eq!(
|
||||||
|
parse("replay ''").unwrap(),
|
||||||
|
Command::Replay {
|
||||||
|
path: String::new(),
|
||||||
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user