diff --git a/src/dsl/grammar/data.rs b/src/dsl/grammar/data.rs index 5891808..4ebc661 100644 --- a/src/dsl/grammar/data.rs +++ b/src/dsl/grammar/data.rs @@ -490,6 +490,36 @@ fn build_delete(path: &MatchedPath) -> Result { Ok(Command::Delete { table, filter }) } +// ================================================================= +// replay — `replay ` | `replay ''` +// ================================================================= +// +// 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 { + 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 // ================================================================= @@ -529,3 +559,12 @@ pub static DELETE: CommandNode = CommandNode { usage_id: Some("parse.usage.delete"), 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, +}; diff --git a/src/dsl/grammar/mod.rs b/src/dsl/grammar/mod.rs index b992195..9223529 100644 --- a/src/dsl/grammar/mod.rs +++ b/src/dsl/grammar/mod.rs @@ -259,6 +259,7 @@ pub static REGISTRY: &[&CommandNode] = &[ &data::INSERT, &data::UPDATE, &data::DELETE, + &data::REPLAY, ]; /// Look up a `CommandNode` by entry word, case-insensitively. diff --git a/src/dsl/parser.rs b/src/dsl/parser.rs index ef7ccad..deb5db5 100644 --- a/src/dsl/parser.rs +++ b/src/dsl/parser.rs @@ -120,9 +120,6 @@ pub fn parse_tokens(tokens: &[Token], source: &str) -> Result Ok(cmd), 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). -/// -/// `replay ` lets the user write paths containing -/// `/`, `.`, `~`, etc. — characters that the lexer would either -/// classify as `Punct` or as `Error(UnknownChar)`. To keep the -/// 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 ''`) -/// goes through the regular chumsky path. -fn try_parse_replay_with_bare_path( - tokens: &[Token], - source: &str, -) -> Option> { - 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 E removed `try_parse_replay_with_bare_path`: +// the walker now owns `replay` end-to-end via +// `Choice(StringLit, BarePath)`. The chumsky-side replay +// branch in `command_parser` is unreachable until Phase F +// sweeps the chumsky path. // ADR-0024 Phase A removed `try_parse_app_path_command`: the // walker (`crate::dsl::walker`) now owns export / import end-to- diff --git a/src/dsl/walker/mod.rs b/src/dsl/walker/mod.rs index b4a547d..8b7015c 100644 --- a/src/dsl/walker/mod.rs +++ b/src/dsl/walker/mod.rs @@ -907,13 +907,77 @@ mod tests { assert!(parse("update Customers set Email='x'").is_err()); } + // ========================================================= + // Phase E — replay. + // ========================================================= + #[test] - fn walker_does_not_engage_for_replay() { - // `replay` isn't migrated yet (Phase E); router falls - // through to chumsky. - assert!(matches!( + fn walker_parses_replay_with_bare_relative_path() { + assert_eq!( 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(), + } + ); } }