//! DSL parser (ADR-0020 + ADR-0021). //! //! Two-phase: a lexer (`crate::dsl::lexer`) produces a span-tagged //! token stream, and chumsky combinators over `&[Token]` build the //! `Command` AST. Keyword identity is exact via the `Keyword` enum //! from `crate::dsl::keyword`; alternative-aggregation across //! `choice` is chumsky-native (the load-bearing fix that motivated //! ADR-0020). //! //! Errors from chumsky are mapped to the local [`ParseError`] type //! so callers do not depend on chumsky's API surface. use chumsky::error::{RichPattern, RichReason}; use chumsky::prelude::*; use crate::dsl::action::ReferentialAction; use crate::dsl::command::{ AppCommand, ChangeColumnMode, ColumnSpec, Command, MessagesValue, ModeValue, RelationshipSelector, RowFilter, }; use crate::dsl::ident_slot::IdentSlot; use crate::dsl::keyword::{Keyword, Punct}; use crate::dsl::lexer::{LexError, Token, TokenKind, lex}; use crate::dsl::types::Type; use crate::dsl::value::Value; #[derive(Debug, Clone, PartialEq, Eq)] pub enum ParseError { Invalid { message: String, position: usize, /// True when the parse failed because more input was /// expected — i.e. a structural failure with no /// next-token to point at. Used by the input renderer /// (ADR-0022 §4) to distinguish "incomplete but /// plausible" from "definite error" mid-typing. /// /// Custom errors raised by `try_map` are conservatively /// classified as `at_eof = true` because we cannot, at /// this layer, tell apart "tables need at least one /// column" (incomplete: more input would help) from /// "--force-conversion and --dont-convert are mutually /// exclusive" (definite: user must remove a token). /// Erring on `true` means custom-error inputs do not /// get a live error overlay; the parse error still /// fires on submit. A future refinement may carry an /// explicit `is_definite` tag through custom errors. at_eof: bool, /// Human-rendered names of patterns the parser was /// looking for at the failure point: `\`create\``, /// `identifier`, etc. Same forms `humanise()` uses /// inside the `message` sentence, but as discrete /// items so callers (the hint panel, ADR-0022 §6) /// can render them in their own framing. Empty for /// custom errors (which have no expected-set /// framing). expected: Vec, }, Empty, } impl std::fmt::Display for ParseError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Invalid { message, .. } => f.write_str(&crate::t!( "parse.error_wrapper", detail = message, )), Self::Empty => f.write_str(&crate::t!("parse.empty")), } } } impl std::error::Error for ParseError {} impl ParseError { #[must_use] pub const fn position(&self) -> Option { match self { Self::Invalid { position, .. } => Some(*position), Self::Empty => None, } } #[must_use] pub const fn at_eof(&self) -> bool { match self { Self::Invalid { at_eof, .. } => *at_eof, Self::Empty => true, } } } /// Parse a single DSL command end-to-end. pub fn parse_command(input: &str) -> Result { if input.trim().is_empty() { return Err(ParseError::Empty); } let tokens = lex(input); parse_tokens(&tokens, input) } /// Parse a token slice into a `Command`. The `source` argument is /// kept in scope so the `replay` bare-path special case /// (ADR-0020 §6) can source-slice its argument. /// /// Public so future I3 (tab completion) and I4 (syntax /// highlighting) work can re-enter the parser at this layer /// without having to re-lex. pub fn parse_tokens(tokens: &[Token], source: &str) -> Result { if tokens.is_empty() { return Err(ParseError::Empty); } // 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_replay_with_bare_path(tokens, source) { return result; } match command_parser().parse(tokens).into_result() { Ok(cmd) => Ok(cmd), Err(errs) => Err(into_parse_error(&errs, tokens, source)), } } /// 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> { 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, ) -> Result { 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, ) -> String { let parts: Vec = 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 ` 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 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) // ========================================================= /// Match a specific keyword token. fn kw<'a>( target: Keyword, ) -> impl Parser<'a, &'a [Token], (), extra::Err>> + Clone { select_ref! { Token { kind: TokenKind::Keyword(k), .. } if *k == target => () } .labelled(format!("`{}`", target.as_str())) .as_context() } /// Match a specific punctuation token. fn punct<'a>( target: Punct, ) -> impl Parser<'a, &'a [Token], (), extra::Err>> + Clone { select_ref! { Token { kind: TokenKind::Punct(p), .. } if *p == target => () } .labelled(format!("`{}`", target.as_char())) .as_context() } /// Match any identifier token, returning its name. Internal — /// command parsers must use `ident_ctx(slot)` so the /// completion engine knows what kind of identifier each /// position expects (ADR-0022 §8). Bare `ident_inner()` calls /// outside this module would skip the slot annotation. The /// label is applied by `ident_ctx` (one per call site) — none /// here. fn ident_inner<'a>() -> impl Parser<'a, &'a [Token], String, extra::Err>> + Clone { select_ref! { Token { kind: TokenKind::Identifier(s), .. } => s.clone() } } /// Tag-and-parse an identifier slot. The slot's user-facing /// label (`IdentSlot::expected_label`) replaces the generic /// "identifier" in the parser's expected-set machinery, so /// the error message reads "expected table name" / /// "expected column name" / "expected relationship name" / /// "expected identifier" depending on the call site /// (ADR-0022 stage 8c). The completion engine reverses the /// mapping via `IdentSlot::from_expected_label` to know what /// schema list to consult. fn ident_ctx<'a>( slot: crate::dsl::ident_slot::IdentSlot, ) -> impl Parser<'a, &'a [Token], String, extra::Err>> + Clone { ident_inner().labelled(slot.expected_label()).as_context() } /// Match a number-literal token, returning a `Value::Number`. fn number_literal<'a>() -> impl Parser<'a, &'a [Token], Value, extra::Err>> + Clone { select_ref! { Token { kind: TokenKind::Number(s), .. } => Value::Number(s.clone()) } .labelled("number") .as_context() } /// Match a string-literal token, returning a `Value::Text`. fn string_literal<'a>() -> impl Parser<'a, &'a [Token], Value, extra::Err>> + Clone { select_ref! { Token { kind: TokenKind::StringLiteral(s), .. } => Value::Text(s.clone()) } .labelled("string literal") .as_context() } /// Match a string-literal token, returning the raw payload /// (used by the quoted-replay path). fn string_payload<'a>() -> impl Parser<'a, &'a [Token], String, extra::Err>> + Clone { select_ref! { Token { kind: TokenKind::StringLiteral(s), .. } => s.clone() } .labelled("path") .as_context() } /// Match a flag token whose payload equals `name` (the part /// after `--`). fn flag<'a>( name: &'static str, ) -> impl Parser<'a, &'a [Token], (), extra::Err>> + Clone { select_ref! { Token { kind: TokenKind::Flag(s), .. } if s == name => () } .labelled(format!("`--{name}`")) .as_context() } /// Match an identifier and parse it as a `Type`. Surfaces the /// existing "unknown type 'X' (expected one of: …)" message /// (ADR-0020 §4) — keyword-shape errors aggregate naturally, /// content errors keep their hand-written voice. /// /// Labelled "type" so the structural-error wording reads as /// "next: type" rather than the unhelpful "something else" /// the unlabelled `select_ref!` would otherwise produce. fn type_keyword<'a>() -> impl Parser<'a, &'a [Token], Type, extra::Err>> + Clone { // Label is applied to the select-ref alone (before // try_map) so the unknown-type custom error from try_map // still surfaces — labelled() on the whole chain would // replace it with "expected type" and lose the // "unknown type 'X' (expected one of: …)" wording. select_ref! { Token { kind: TokenKind::Identifier(s), .. } = e => (s.clone(), e.span()) } .labelled("type") .try_map(|(name, span): (String, SimpleSpan), _| { name.parse::() .map_err(|err| Rich::custom(span, err.to_string())) }) } // ========================================================= // Top-level command parser // ========================================================= fn command_parser<'a>() -> impl Parser<'a, &'a [Token], Command, extra::Err>> + Clone { let create_table = kw(Keyword::Create) .ignore_then(kw(Keyword::Table)) .ignore_then(ident_ctx(IdentSlot::NewName)) .then(with_pk_clause()) .try_map(|(name, pk_specs), span| { if pk_specs.is_empty() { return Err(Rich::custom( span, crate::t!("parse.custom.create_table_needs_pk"), )); } let columns: Vec = pk_specs .iter() .map(|(n, t)| ColumnSpec { name: n.clone(), ty: *t, }) .collect(); let primary_key = pk_specs.into_iter().map(|(n, _)| n).collect(); Ok(Command::CreateTable { name, columns, primary_key, }) }); let drop_table = kw(Keyword::Drop) .ignore_then(kw(Keyword::Table)) .ignore_then(ident_ctx(IdentSlot::TableName)) .map(|name| Command::DropTable { name }); // `add column [to] [table] : ()`. Both // prepositions independently optional — bare identifiers // accepted in the unambiguous position. let add_column = kw(Keyword::Add) .ignore_then(kw(Keyword::Column)) .ignore_then(kw(Keyword::To).or_not()) .ignore_then(kw(Keyword::Table).or_not()) .ignore_then(ident_ctx(IdentSlot::TableName)) .then_ignore(punct(Punct::Colon)) .then(ident_ctx(IdentSlot::NewName)) .then_ignore(punct(Punct::OpenParen)) .then(type_keyword()) .then_ignore(punct(Punct::CloseParen)) .map(|((table, column), ty)| Command::AddColumn { table, column, ty }); let drop_column = kw(Keyword::Drop) .ignore_then(kw(Keyword::Column)) .ignore_then(kw(Keyword::From).or_not()) .ignore_then(kw(Keyword::Table).or_not()) .ignore_then(ident_ctx(IdentSlot::TableName)) .then_ignore(punct(Punct::Colon)) .then(ident_ctx(IdentSlot::Column)) .map(|(table, column)| Command::DropColumn { table, column }); let rename_column = kw(Keyword::Rename) .ignore_then(kw(Keyword::Column)) .ignore_then(kw(Keyword::In).or_not()) .ignore_then(kw(Keyword::Table).or_not()) .ignore_then(ident_ctx(IdentSlot::TableName)) .then_ignore(punct(Punct::Colon)) .then(ident_ctx(IdentSlot::Column)) .then_ignore(kw(Keyword::To)) .then(ident_ctx(IdentSlot::NewName)) .map(|((table, old), new)| Command::RenameColumn { table, old, new }); let change_column = kw(Keyword::Change) .ignore_then(kw(Keyword::Column)) .ignore_then(kw(Keyword::In).or_not()) .ignore_then(kw(Keyword::Table).or_not()) .ignore_then(ident_ctx(IdentSlot::TableName)) .then_ignore(punct(Punct::Colon)) .then(ident_ctx(IdentSlot::Column)) .then_ignore(punct(Punct::OpenParen)) .then(type_keyword()) .then_ignore(punct(Punct::CloseParen)) .then(change_column_flags()) .map(|(((table, column), ty), mode)| Command::ChangeColumnType { table, column, ty, mode, }); let add_relationship = add_relationship_parser(); let drop_relationship = drop_relationship_parser(); let show_data = kw(Keyword::Show) .ignore_then(kw(Keyword::Data)) .ignore_then(ident_ctx(IdentSlot::TableName)) .map(|name| Command::ShowData { name }); let show_table = kw(Keyword::Show) .ignore_then(kw(Keyword::Table)) .ignore_then(ident_ctx(IdentSlot::TableName)) .map(|name| Command::ShowTable { name }); let insert_cmd = insert_parser(); let update_cmd = update_parser(); let delete_cmd = delete_parser(); // The bare-path replay form is intercepted before chumsky // sees the tokens (ADR-0020 §6); only the quoted form // arrives here. let replay = kw(Keyword::Replay) .ignore_then(string_payload()) .map(|path| Command::Replay { path }); // ---- App-lifecycle commands ----------------------------- // No-arg variants and the keyword-value variants. Path- // bearing variants (`export `, `import [as // ]`) are handled by `try_parse_app_path_command` // BEFORE chumsky runs; the bare-keyword forms below // surface the `Path: None` / no-source variants for // empty-prompt completion + usage rendering. let quit_cmd = kw(Keyword::Quit).map(|()| Command::App(AppCommand::Quit)); let help_cmd = kw(Keyword::Help).map(|()| Command::App(AppCommand::Help)); let rebuild_cmd = kw(Keyword::Rebuild).map(|()| Command::App(AppCommand::Rebuild)); // `save as` must be tried before bare `save` (more specific). let save_as_cmd = kw(Keyword::Save) .then_ignore(kw(Keyword::As)) .map(|()| Command::App(AppCommand::SaveAs)); let save_cmd = kw(Keyword::Save).map(|()| Command::App(AppCommand::Save)); let new_cmd = kw(Keyword::New).map(|()| Command::App(AppCommand::New)); let load_cmd = kw(Keyword::Load).map(|()| Command::App(AppCommand::Load)); let export_no_arg = kw(Keyword::Export).map(|()| Command::App(AppCommand::Export { path: None })); let import_no_arg = kw(Keyword::Import).map(|()| { Command::App(AppCommand::Import { path: String::new(), target: None, }) }); // `mode ` and `messages []` accept either the // known keyword forms or any identifier — the identifier // branch funnels through `try_map` into a friendly // `mode.unknown` / `messages.unknown` error rather than the // generic structural-error wording. Mirrors the type-name // pattern in `type_keyword` (ADR-0020 §4). let known_mode = choice(( kw(Keyword::Simple).to(ModeValue::Simple), kw(Keyword::Advanced).to(ModeValue::Advanced), )); let unknown_mode = ident_inner().try_map(|s, span| { Err::(Rich::custom( span, crate::t!("mode.unknown", value = s), )) }); let mode_cmd = kw(Keyword::Mode) .ignore_then(choice((known_mode, unknown_mode))) .map(|value| Command::App(AppCommand::Mode { value })); let known_messages = choice(( kw(Keyword::Short).to(MessagesValue::Short), kw(Keyword::Verbose).to(MessagesValue::Verbose), )); let unknown_messages = ident_inner().try_map(|s, span| { Err::(Rich::custom( span, crate::t!("messages.unknown", value = s), )) }); let messages_cmd = kw(Keyword::Messages) .ignore_then(choice((known_messages, unknown_messages)).or_not()) .map(|value| Command::App(AppCommand::Messages { value })); choice(( create_table, // `drop column` and `drop relationship` come before // `drop table` because both are more specific — // chumsky's `choice` tries each in order. drop_column, drop_relationship, drop_table, add_column, add_relationship, rename_column, change_column, show_data, show_table, insert_cmd, update_cmd, delete_cmd, replay, // App commands. `save as` before bare `save`; everything // else order-agnostic. quit_cmd, help_cmd, rebuild_cmd, save_as_cmd, save_cmd, new_cmd, load_cmd, export_no_arg, import_no_arg, mode_cmd, messages_cmd, )) .then_ignore(end()) } // ========================================================= // Per-command sub-parsers // ========================================================= fn insert_parser<'a>() -> impl Parser<'a, &'a [Token], Command, extra::Err>> + Clone { let column_list = punct(Punct::OpenParen) .ignore_then( ident_ctx(IdentSlot::Column) .separated_by(punct(Punct::Comma)) .at_least(1) .collect::>(), ) .then_ignore(punct(Punct::CloseParen)); let value_list = punct(Punct::OpenParen) .ignore_then( value_literal() .separated_by(punct(Punct::Comma)) .at_least(1) .collect::>(), ) .then_ignore(punct(Punct::CloseParen)); let with_columns_and_values = column_list .clone() .then_ignore(kw(Keyword::Values)) .then(value_list.clone()) .map(|(cols, vals)| (Some(cols), vals)); let with_values_keyword_only = kw(Keyword::Values) .ignore_then(value_list.clone()) .map(|vals| (None, vals)); let bare_value_list = value_list.map(|vals| (None, vals)); kw(Keyword::Insert) .ignore_then(kw(Keyword::Into)) .ignore_then(ident_ctx(IdentSlot::TableName)) .then(choice(( with_columns_and_values, with_values_keyword_only, bare_value_list, ))) .map(|(table, (columns, values))| Command::Insert { table, columns, values, }) } fn update_parser<'a>() -> impl Parser<'a, &'a [Token], Command, extra::Err>> + Clone { let assignment = ident_ctx(IdentSlot::Column) .then_ignore(punct(Punct::Equals)) .then(value_literal()); let assignments = assignment .separated_by(punct(Punct::Comma)) .at_least(1) .collect::>(); kw(Keyword::Update) .ignore_then(ident_ctx(IdentSlot::TableName)) .then_ignore(kw(Keyword::Set)) .then(assignments) .then(filter_clause()) .map(|((table, assignments), filter)| Command::Update { table, assignments, filter, }) } fn delete_parser<'a>() -> impl Parser<'a, &'a [Token], Command, extra::Err>> + Clone { kw(Keyword::Delete) .ignore_then(kw(Keyword::From)) .ignore_then(ident_ctx(IdentSlot::TableName)) .then(filter_clause()) .map(|(table, filter)| Command::Delete { table, filter }) } fn filter_clause<'a>() -> impl Parser<'a, &'a [Token], RowFilter, extra::Err>> + Clone { let where_clause = kw(Keyword::Where) .ignore_then(ident_ctx(IdentSlot::Column)) .then_ignore(punct(Punct::Equals)) .then(value_literal()) .map(|(column, value)| RowFilter::Where { column, value }); let all_rows = flag("all-rows").to(RowFilter::AllRows); // No `.labelled()` wrap here: chumsky's expected-set then // surfaces the constituent options (`` `where` ``, // `` `--all-rows` ``) individually instead of collapsing // them to a single descriptive label. The completion // engine needs the constituents to offer Tab candidates // (ADR-0022 §8); the resulting error prose ("expected `,`, // `where`, or `--all-rows`") reads cleanly enough without // hand-wrapping. where_clause.or(all_rows) } fn value_literal<'a>() -> impl Parser<'a, &'a [Token], Value, extra::Err>> + Clone { choice(( kw(Keyword::Null).to(Value::Null), kw(Keyword::True).to(Value::Bool(true)), kw(Keyword::False).to(Value::Bool(false)), number_literal(), string_literal(), )) } fn add_relationship_parser<'a>() -> impl Parser<'a, &'a [Token], Command, extra::Err>> + Clone { // `1:n` lexes as Number("1"), Punct(Colon), Identifier("n"). let one_token = select_ref! { Token { kind: TokenKind::Number(s), .. } if s == "1" => () } .labelled("`1`") .as_context(); let n_ident = select_ref! { Token { kind: TokenKind::Identifier(s), .. } if s.eq_ignore_ascii_case("n") => () } .labelled("`n`") .as_context(); let one_to_n = one_token .ignore_then(punct(Punct::Colon)) .ignore_then(n_ident); let optional_name = kw(Keyword::As).ignore_then(ident_ctx(IdentSlot::NewName)).or_not(); kw(Keyword::Add) .ignore_then(one_to_n) .ignore_then(kw(Keyword::Relationship)) .ignore_then(optional_name) .then_ignore(kw(Keyword::From)) .then(qualified_column()) .then_ignore(kw(Keyword::To)) .then(qualified_column()) .then(referential_clauses()) .then(create_fk_flag()) .map( |((((name, parent), child), (on_delete, on_update)), create_fk)| { Command::AddRelationship { name, parent_table: parent.0, parent_column: parent.1, child_table: child.0, child_column: child.1, on_delete, on_update, create_fk, } }, ) } fn drop_relationship_parser<'a>() -> impl Parser<'a, &'a [Token], Command, extra::Err>> + Clone { let endpoints_form = kw(Keyword::From) .ignore_then(qualified_column()) .then_ignore(kw(Keyword::To)) .then(qualified_column()) .map(|(parent, child)| RelationshipSelector::Endpoints { parent_table: parent.0, parent_column: parent.1, child_table: child.0, child_column: child.1, }); let named_form = ident_ctx(IdentSlot::RelationshipName) .map(|name| RelationshipSelector::Named { name }); kw(Keyword::Drop) .ignore_then(kw(Keyword::Relationship)) .ignore_then(choice((endpoints_form, named_form))) .map(|selector| Command::DropRelationship { selector }) } fn qualified_column<'a>() -> impl Parser<'a, &'a [Token], (String, String), extra::Err>> + Clone { ident_ctx(IdentSlot::TableName) .then_ignore(punct(Punct::Dot)) .then(ident_ctx(IdentSlot::Column)) } fn referential_clauses<'a>() -> impl Parser< 'a, &'a [Token], (ReferentialAction, ReferentialAction), extra::Err>, > + Clone { let target = kw(Keyword::Delete) .to(ReferentialActionTarget::Delete) .or(kw(Keyword::Update).to(ReferentialActionTarget::Update)); let clause = kw(Keyword::On) .ignore_then(target) .then(action_keyword()) .map(|(t, a)| (t, a)); clause .repeated() .at_most(2) .collect::>() .try_map(|clauses, span| { let mut on_delete = None; let mut on_update = None; for (target, action) in clauses { let slot = match target { ReferentialActionTarget::Delete => &mut on_delete, ReferentialActionTarget::Update => &mut on_update, }; if slot.is_some() { return Err(Rich::custom( span, crate::t!( "parse.custom.on_action_specified_twice", target = target, ), )); } *slot = Some(action); } Ok(( on_delete.unwrap_or_else(ReferentialAction::default_action), on_update.unwrap_or_else(ReferentialAction::default_action), )) }) } #[derive(Debug, Clone, Copy)] enum ReferentialActionTarget { Delete, Update, } impl std::fmt::Display for ReferentialActionTarget { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(match self { Self::Delete => "delete", Self::Update => "update", }) } } fn action_keyword<'a>() -> impl Parser<'a, &'a [Token], ReferentialAction, extra::Err>> + Clone { choice(( kw(Keyword::Set) .ignore_then(kw(Keyword::Null)) .to(ReferentialAction::SetNull), kw(Keyword::No) .ignore_then(kw(Keyword::Action)) .to(ReferentialAction::NoAction), kw(Keyword::Cascade).to(ReferentialAction::Cascade), kw(Keyword::Restrict).to(ReferentialAction::Restrict), )) } fn create_fk_flag<'a>() -> impl Parser<'a, &'a [Token], bool, extra::Err>> + Clone { flag("create-fk").or_not().map(|opt| opt.is_some()) } fn change_column_flags<'a>() -> impl Parser<'a, &'a [Token], ChangeColumnMode, extra::Err>> + Clone { let force = flag("force-conversion").to(ChangeColumnMode::ForceConversion); let dont = flag("dont-convert").to(ChangeColumnMode::DontConvert); choice((force, dont)) .repeated() .collect::>() .try_map(|flags, span| match flags.as_slice() { [] => Ok(ChangeColumnMode::Default), [single] => Ok(*single), _ => Err(Rich::custom( span, crate::t!("parse.custom.change_column_flags_exclusive"), )), }) } fn with_pk_clause<'a>() -> impl Parser<'a, &'a [Token], Vec<(String, Type)>, extra::Err>> + Clone { // Each PK spec names a NEW column inside the table being // created. `with_pk_clause` is reached only inside // `create_table`, where the surrounding context is // building a new schema entity from scratch. let single = ident_ctx(IdentSlot::NewName) .then_ignore(punct(Punct::Colon)) .then(type_keyword()) .map(|(name, ty)| (name, ty)); let spec_list = single .clone() .separated_by(punct(Punct::Comma)) .at_least(1) .collect::>(); kw(Keyword::With) .ignore_then(kw(Keyword::Pk)) .ignore_then(spec_list.or_not()) .map(|maybe_specs| { // `with pk` alone defaults to a serial id PK. maybe_specs.unwrap_or_else(|| vec![("id".to_string(), Type::Serial)]) }) .or_not() .map(Option::unwrap_or_default) } // ========================================================= // Error humanisation // ========================================================= fn into_parse_error(errs: &[Rich<'_, Token>], tokens: &[Token], source: &str) -> ParseError { // Prefer custom-reason errors over chumsky's structural // ones — those carry our hand-tuned messages from `try_map` // (e.g. "unknown type 'varchar' (expected one of: ...)"). let chosen = errs .iter() .find(|e| matches!(e.reason(), RichReason::Custom(_))) .unwrap_or_else(|| errs.first().expect("parser failure with no error")); let chumsky_span = chosen.span(); let position = source_position_at(tokens, chumsky_span.start, source); let message = humanise(chosen, tokens, source); let (at_eof, expected) = match chosen.reason() { // Structural failures know whether they ran out of // input — `found = None` ⇔ EOF — and carry the // expected-pattern set chumsky was looking for. RichReason::ExpectedFound { expected, found } => { (found.is_none(), describe_expected(expected)) } // Custom errors: see the docstring on // `ParseError::Invalid::at_eof` for why we err on the // side of `true` (no live overlay; on-submit error // still fires). Custom errors have no expected-set. RichReason::Custom(_) => (true, Vec::new()), }; ParseError::Invalid { message, position, at_eof, expected, } } /// Render a chumsky expected-pattern set into the same /// human-readable forms `humanise()` uses, but as discrete /// items rather than an oxford-joined string. Stable order /// (sorted, deduplicated) so callers don't have to. fn describe_expected(expected: &[RichPattern<'_, Token>]) -> Vec { let has_concrete = expected.iter().any(|p| { matches!( p, RichPattern::Token(_) | RichPattern::Identifier(_) | RichPattern::Label(_) | RichPattern::EndOfInput ) }); let mut items: Vec = expected .iter() .filter(|p| { !(has_concrete && matches!(p, RichPattern::Any | RichPattern::SomethingElse)) }) .map(describe_pattern) .collect(); // Dedup preserving first occurrence (which reflects // chumsky's traversal order — typically source order for // `or_not` / `choice` chains). Empirically this gives a // grammar-natural ordering: `to` before `table` in // `add column [to] [table] …`, which alphabetical // (table, to) would invert. let mut seen = std::collections::HashSet::new(); items.retain(|s| seen.insert(s.clone())); items } /// Translate a chumsky token-slice index into a byte position /// in the original source. If the index points past the last /// token (an end-of-input failure), use the last token's end /// or, if there are no tokens, the source length. fn source_position_at(tokens: &[Token], slice_index: usize, source: &str) -> usize { if slice_index < tokens.len() { tokens[slice_index].span.0 } else { tokens.last().map_or(source.len(), |t| t.span.1) } } fn humanise(err: &Rich<'_, Token>, tokens: &[Token], source: &str) -> String { if let RichReason::Custom(msg) = err.reason() { return msg.clone(); } let RichReason::ExpectedFound { expected, found } = err.reason() else { unreachable!("RichReason has only two variants today"); }; // `found` is the offending token (or None at end of input). let found_str = found.as_ref().map_or_else( || "end of input".to_string(), |maybe_ref| describe_token(maybe_ref), ); let described = describe_expected(expected); let expected_str = oxford_or(&described); let chumsky_span_start = err.span().start; let consumed = consumed_context(tokens, chumsky_span_start, source); if expected.is_empty() { if consumed.is_empty() { format!("unexpected {found_str}") } else { format!("after `{consumed}`, unexpected {found_str}") } } else if consumed.is_empty() { format!("expected {expected_str}, found {found_str}") } else { format!("after `{consumed}`, expected {expected_str}, found {found_str}") } } fn describe_pattern(p: &RichPattern<'_, Token>) -> String { match p { RichPattern::Token(t) => describe_token(t), RichPattern::Identifier(s) => format!("`{s}`"), RichPattern::Label(s) => s.to_string(), RichPattern::Any => "any token".to_string(), RichPattern::SomethingElse => "something else".to_string(), RichPattern::EndOfInput => "end of input".to_string(), // RichPattern is non_exhaustive; cover the catch-all. _ => "".to_string(), } } fn describe_token(t: &Token) -> String { match &t.kind { TokenKind::Keyword(k) => format!("`{}`", k.as_str()), TokenKind::Identifier(s) => format!("`{s}`"), TokenKind::Number(s) => format!("`{s}`"), TokenKind::StringLiteral(_) => "string literal".to_string(), TokenKind::Punct(p) => format!("`{}`", p.as_char()), TokenKind::Flag(s) => format!("`--{s}`"), TokenKind::Error(LexError::UnknownChar(c)) => { format!("unrecognised character `{c}`") } TokenKind::Error(LexError::UnterminatedString) => { "unterminated string literal".to_string() } TokenKind::Error(LexError::BadFlag) => "malformed flag (bare `--`)".to_string(), } } /// "A, B, or C" / "A or B" / "A". fn oxford_or(items: &[String]) -> String { match items { [] => String::new(), [a] => a.clone(), [a, b] => format!("{a} or {b}"), rest => { let (last, head) = rest.split_last().expect("len >= 3"); format!("{}, or {last}", head.join(", ")) } } } /// Source slice covering all tokens before the failure point, /// trimmed to a sensible length. fn consumed_context(tokens: &[Token], chumsky_span_start: usize, source: &str) -> String { if chumsky_span_start == 0 { return String::new(); } let last_consumed_index = chumsky_span_start - 1; let Some(last_token) = tokens.get(last_consumed_index) else { return String::new(); }; let prefix = source[..last_token.span.1].trim(); if prefix.is_empty() { return String::new(); } const MAX: usize = 40; if prefix.chars().count() <= MAX { prefix.to_string() } else { let tail: String = prefix .chars() .rev() .take(MAX) .collect::>() .into_iter() .rev() .collect(); format!("…{tail}") } } // ========================================================= // Tests // ========================================================= #[cfg(test)] mod tests { use super::*; use pretty_assertions::assert_eq; fn ok(input: &str) -> Command { parse_command(input).unwrap_or_else(|e| panic!("expected ok for {input:?}, got {e:?}")) } fn err(input: &str) -> ParseError { parse_command(input).expect_err("expected parse error") } fn err_message(input: &str) -> String { match err(input) { ParseError::Invalid { message, .. } => message, ParseError::Empty => panic!("unexpected empty error"), } } #[test] fn structural_error_for_show_data_without_arg() { // ADR-0022 stage 8c: `ident_ctx(IdentSlot::TableName)` // labels the expected slot with "table name" so the // error reads as the more specific "expected table // name" rather than the generic "expected identifier". let msg = err_message("show data"); assert!(msg.contains("after `show data`"), "{msg}"); assert!(msg.contains("expected table name"), "{msg}"); assert!(msg.contains("found end of input"), "{msg}"); } #[test] fn structural_error_for_change_column_with_swapped_args() { let msg = err_message("change column Rich in Customers: Rich (text)"); assert!(msg.contains("after `change column Rich`"), "{msg}"); assert!(msg.contains("expected `:`"), "{msg}"); } fn col(name: &str, ty: Type) -> ColumnSpec { ColumnSpec { name: name.to_string(), ty, } } #[test] fn bare_create_table_errors_with_helpful_message() { let e = err("create table Customers"); match e { ParseError::Invalid { message, .. } => { assert!( message.contains("with pk"), "error should mention `with pk`:\n{message}" ); } ParseError::Empty => panic!("unexpected empty error"), } } #[test] fn create_table_with_pk_default_is_id_serial() { assert_eq!( ok("create table Customers with pk"), Command::CreateTable { name: "Customers".to_string(), columns: vec![col("id", Type::Serial)], primary_key: vec!["id".to_string()], } ); } #[test] fn create_table_with_named_typed_pk() { assert_eq!( ok("create table Customers with pk email:text"), Command::CreateTable { name: "Customers".to_string(), columns: vec![col("email", Type::Text)], primary_key: vec!["email".to_string()], } ); } #[test] fn create_table_with_compound_pk() { assert_eq!( ok("create table OrderLines with pk order_id:int,product_id:int"), Command::CreateTable { name: "OrderLines".to_string(), columns: vec![col("order_id", Type::Int), col("product_id", Type::Int),], primary_key: vec!["order_id".to_string(), "product_id".to_string()], } ); } #[test] fn create_table_pk_accepts_any_user_type() { for ty in Type::all() { let input = format!("create table T with pk col:{}", ty.keyword()); let cmd = ok(&input); if let Command::CreateTable { columns, primary_key, .. } = cmd { assert_eq!(columns[0].ty, *ty); assert_eq!(primary_key, vec!["col".to_string()]); } else { panic!("expected CreateTable for {input}"); } } } #[test] fn create_table_pk_tolerates_whitespace() { assert_eq!( ok("create table T with pk id : serial"), Command::CreateTable { name: "T".to_string(), columns: vec![col("id", Type::Serial)], primary_key: vec!["id".to_string()], } ); assert_eq!( ok("create table T with pk a : int , b : int"), Command::CreateTable { name: "T".to_string(), columns: vec![col("a", Type::Int), col("b", Type::Int)], primary_key: vec!["a".to_string(), "b".to_string()], } ); } #[test] fn create_table_keywords_are_case_insensitive() { assert_eq!( ok("CREATE TABLE Customers WITH PK email:TEXT"), Command::CreateTable { name: "Customers".to_string(), columns: vec![col("email", Type::Text)], primary_key: vec!["email".to_string()], } ); } #[test] fn drop_column_simple() { assert_eq!( ok("drop column from table Customers: Email"), Command::DropColumn { table: "Customers".to_string(), column: "Email".to_string(), } ); } #[test] fn drop_column_accepts_bare_identifiers() { assert_eq!( ok("drop column Customers: Email"), Command::DropColumn { table: "Customers".to_string(), column: "Email".to_string(), } ); assert_eq!( ok("drop column from Customers: Email"), Command::DropColumn { table: "Customers".to_string(), column: "Email".to_string(), } ); assert_eq!( ok("drop column table Customers: Email"), Command::DropColumn { table: "Customers".to_string(), column: "Email".to_string(), } ); } #[test] fn rename_column_simple() { assert_eq!( ok("rename column in table Customers: OldName to NewName"), Command::RenameColumn { table: "Customers".to_string(), old: "OldName".to_string(), new: "NewName".to_string(), } ); } #[test] fn rename_column_accepts_bare_identifiers() { assert_eq!( ok("rename column Customers: A to B"), Command::RenameColumn { table: "Customers".to_string(), old: "A".to_string(), new: "B".to_string(), } ); assert_eq!( ok("rename column in Customers: A to B"), Command::RenameColumn { table: "Customers".to_string(), old: "A".to_string(), new: "B".to_string(), } ); assert_eq!( ok("rename column table Customers: A to B"), Command::RenameColumn { table: "Customers".to_string(), old: "A".to_string(), new: "B".to_string(), } ); } #[test] fn change_column_simple() { assert_eq!( ok("change column in table Customers: Score (int)"), Command::ChangeColumnType { table: "Customers".to_string(), column: "Score".to_string(), ty: Type::Int, mode: ChangeColumnMode::Default, } ); } #[test] fn change_column_accepts_bare_identifiers() { assert_eq!( ok("change column Customers: Score (real)"), Command::ChangeColumnType { table: "Customers".to_string(), column: "Score".to_string(), ty: Type::Real, mode: ChangeColumnMode::Default, } ); } #[test] fn change_column_keywords_are_case_insensitive() { assert_eq!( ok("CHANGE COLUMN IN TABLE Customers: Score (TEXT)"), Command::ChangeColumnType { table: "Customers".to_string(), column: "Score".to_string(), ty: Type::Text, mode: ChangeColumnMode::Default, } ); } #[test] fn change_column_with_force_conversion_flag() { assert_eq!( ok("change column Customers: Score (int) --force-conversion"), Command::ChangeColumnType { table: "Customers".to_string(), column: "Score".to_string(), ty: Type::Int, mode: ChangeColumnMode::ForceConversion, } ); } #[test] fn change_column_with_dont_convert_flag() { assert_eq!( ok("change column Customers: Score (int) --dont-convert"), Command::ChangeColumnType { table: "Customers".to_string(), column: "Score".to_string(), ty: Type::Int, mode: ChangeColumnMode::DontConvert, } ); } #[test] fn change_column_rejects_both_flags() { let e = err("change column Customers: Score (int) --force-conversion --dont-convert"); match e { ParseError::Invalid { message, .. } => { assert!( message.contains("--force-conversion") && message.contains("--dont-convert"), "expected both flag names in error: {message}" ); assert!( message.contains("mutually exclusive") || message.contains("pick one"), "{message}" ); } ParseError::Empty => panic!("unexpected empty error"), } } #[test] fn change_column_rejects_both_flags_in_either_order() { let e = err("change column T: c (int) --dont-convert --force-conversion"); match e { ParseError::Invalid { message, .. } => { assert!(message.contains("mutually exclusive"), "{message}"); } ParseError::Empty => panic!("unexpected empty error"), } } #[test] fn drop_table_simple() { assert_eq!( ok("drop table Customers"), Command::DropTable { name: "Customers".to_string() } ); } #[test] fn add_column_simple() { assert_eq!( ok("add column to table Customers: Name (text)"), Command::AddColumn { table: "Customers".to_string(), column: "Name".to_string(), ty: Type::Text, } ); } #[test] fn add_column_with_each_supported_type() { for ty in Type::all() { let input = format!("add column to table T: C ({})", ty.keyword()); assert_eq!( ok(&input), Command::AddColumn { table: "T".to_string(), column: "C".to_string(), ty: *ty, } ); } } #[test] fn add_column_accepts_bare_table_name() { assert_eq!( ok("add column Customers: Name (text)"), Command::AddColumn { table: "Customers".to_string(), column: "Name".to_string(), ty: Type::Text, } ); } #[test] fn add_column_accepts_to_alone() { assert_eq!( ok("add column to Customers: Name (text)"), Command::AddColumn { table: "Customers".to_string(), column: "Name".to_string(), ty: Type::Text, } ); } #[test] fn add_column_accepts_table_alone() { assert_eq!( ok("add column table Customers: Name (text)"), Command::AddColumn { table: "Customers".to_string(), column: "Name".to_string(), ty: Type::Text, } ); } #[test] fn add_column_tolerates_whitespace_around_punctuation() { assert_eq!( ok("add column to table T:Name(text)"), Command::AddColumn { table: "T".to_string(), column: "Name".to_string(), ty: Type::Text, } ); } #[test] fn empty_input_is_an_explicit_empty_error() { assert_eq!(parse_command(""), Err(ParseError::Empty)); assert_eq!(parse_command(" "), Err(ParseError::Empty)); } #[test] fn unknown_command_errors() { let e = err("frobulate Customers"); assert!(matches!(e, ParseError::Invalid { .. }), "got {e:?}"); } #[test] fn unknown_type_errors_with_alternatives_listed() { let e = err("add column to table T: Name (varchar)"); match e { ParseError::Invalid { message, .. } => { assert!( message.contains("varchar"), "error should mention the bad type: {message}" ); assert!( message.contains("expected one of"), "error should list valid alternatives: {message}" ); assert!( message.contains("text") && message.contains("shortid"), "error should name the alternatives: {message}" ); } ParseError::Empty => panic!("unexpected empty error"), } } #[test] fn unknown_pk_type_errors_with_alternatives_listed() { let e = err("create table T with pk id:varchar"); match e { ParseError::Invalid { message, .. } => { assert!(message.contains("varchar"), "{message}"); assert!(message.contains("expected one of"), "{message}"); } ParseError::Empty => panic!("unexpected empty error"), } } #[test] fn trailing_garbage_errors() { let e = err("create table Customers with pk and pickles"); assert!(matches!(e, ParseError::Invalid { .. }), "got {e:?}"); } #[test] fn identifier_must_start_with_letter_or_underscore() { let e = err("create table 1Customers with pk"); assert!(matches!(e, ParseError::Invalid { .. }), "got {e:?}"); } fn rel( name: Option<&str>, parent: (&str, &str), child: (&str, &str), on_delete: ReferentialAction, on_update: ReferentialAction, create_fk: bool, ) -> Command { Command::AddRelationship { name: name.map(String::from), parent_table: parent.0.to_string(), parent_column: parent.1.to_string(), child_table: child.0.to_string(), child_column: child.1.to_string(), on_delete, on_update, create_fk, } } #[test] fn add_relationship_minimal() { assert_eq!( ok("add 1:n relationship from Customers.Id to Orders.CustId"), rel( None, ("Customers", "Id"), ("Orders", "CustId"), ReferentialAction::NoAction, ReferentialAction::NoAction, false, ) ); } #[test] fn add_relationship_with_name() { assert_eq!( ok("add 1:n relationship as cust_orders from Customers.Id to Orders.CustId"), rel( Some("cust_orders"), ("Customers", "Id"), ("Orders", "CustId"), ReferentialAction::NoAction, ReferentialAction::NoAction, false, ) ); } #[test] fn add_relationship_with_on_delete() { assert_eq!( ok("add 1:n relationship from Customers.Id to Orders.CustId on delete cascade"), rel( None, ("Customers", "Id"), ("Orders", "CustId"), ReferentialAction::Cascade, ReferentialAction::NoAction, false, ) ); } #[test] fn add_relationship_with_on_delete_set_null() { assert_eq!( ok("add 1:n relationship from Customers.Id to Orders.CustId on delete set null"), rel( None, ("Customers", "Id"), ("Orders", "CustId"), ReferentialAction::SetNull, ReferentialAction::NoAction, false, ) ); } #[test] fn add_relationship_with_both_actions_in_either_order() { let expected = rel( None, ("Customers", "Id"), ("Orders", "CustId"), ReferentialAction::Cascade, ReferentialAction::SetNull, false, ); assert_eq!( ok("add 1:n relationship from Customers.Id to Orders.CustId on delete cascade on update set null"), expected ); assert_eq!( ok("add 1:n relationship from Customers.Id to Orders.CustId on update set null on delete cascade"), expected ); } #[test] fn add_relationship_repeated_clause_errors() { let e = err("add 1:n relationship from C.id to O.cid on delete cascade on delete restrict"); match e { ParseError::Invalid { message, .. } => { assert!(message.contains("specified twice"), "{message}"); } ParseError::Empty => panic!("unexpected empty error"), } } #[test] fn add_relationship_with_create_fk_flag() { assert_eq!( ok("add 1:n relationship from Customers.Id to Orders.CustId --create-fk"), rel( None, ("Customers", "Id"), ("Orders", "CustId"), ReferentialAction::NoAction, ReferentialAction::NoAction, true, ) ); } #[test] fn add_relationship_with_name_actions_and_flag() { assert_eq!( ok("add 1:n relationship as cust_orders from Customers.Id to Orders.CustId on delete cascade on update no action --create-fk"), rel( Some("cust_orders"), ("Customers", "Id"), ("Orders", "CustId"), ReferentialAction::Cascade, ReferentialAction::NoAction, true, ) ); } #[test] fn add_relationship_keywords_are_case_insensitive() { assert_eq!( ok("ADD 1:N RELATIONSHIP FROM Customers.Id TO Orders.CustId ON DELETE CASCADE"), rel( None, ("Customers", "Id"), ("Orders", "CustId"), ReferentialAction::Cascade, ReferentialAction::NoAction, false, ) ); } #[test] fn add_relationship_unknown_action_errors() { let e = err("add 1:n relationship from C.id to O.cid on delete obliterate"); assert!(matches!(e, ParseError::Invalid { .. }), "got {e:?}"); } #[test] fn insert_with_explicit_column_list() { assert_eq!( ok("insert into Customers (Name, Email) values ('Alice', 'a@b.com')"), Command::Insert { table: "Customers".to_string(), columns: Some(vec!["Name".to_string(), "Email".to_string()]), values: vec![ Value::Text("Alice".to_string()), Value::Text("a@b.com".to_string()), ], } ); } #[test] fn insert_short_form_omitting_values_keyword() { assert_eq!( ok("insert into Customers ('Alice')"), Command::Insert { table: "Customers".to_string(), columns: None, values: vec![Value::Text("Alice".to_string())], } ); } #[test] fn insert_short_form_without_column_list() { assert_eq!( ok("insert into Customers values ('Alice', 'a@b.com')"), Command::Insert { table: "Customers".to_string(), columns: None, values: vec![ Value::Text("Alice".to_string()), Value::Text("a@b.com".to_string()), ], } ); } #[test] fn insert_accepts_mixed_value_kinds() { assert_eq!( ok("insert into T values (1, 3.14, 'hi', true, null)"), Command::Insert { table: "T".to_string(), columns: None, values: vec![ Value::Number("1".to_string()), Value::Number("3.14".to_string()), Value::Text("hi".to_string()), Value::Bool(true), Value::Null, ], } ); } #[test] fn insert_supports_negative_numbers() { assert_eq!( ok("insert into T values (-5, -3.14)"), Command::Insert { table: "T".to_string(), columns: None, values: vec![ Value::Number("-5".to_string()), Value::Number("-3.14".to_string()), ], } ); } #[test] fn string_literal_supports_escaped_single_quote() { assert_eq!( ok("insert into T values ('don''t panic')"), Command::Insert { table: "T".to_string(), columns: None, values: vec![Value::Text("don't panic".to_string())], } ); } #[test] fn update_with_where() { assert_eq!( ok("update Customers set Name='Alice' where id=1"), Command::Update { table: "Customers".to_string(), assignments: vec![("Name".to_string(), Value::Text("Alice".to_string()))], filter: RowFilter::Where { column: "id".to_string(), value: Value::Number("1".to_string()), }, } ); } #[test] fn update_with_multiple_assignments() { assert_eq!( ok("update Customers set Name='Alice', Email='a@b.com' where id=1"), Command::Update { table: "Customers".to_string(), assignments: vec![ ("Name".to_string(), Value::Text("Alice".to_string())), ("Email".to_string(), Value::Text("a@b.com".to_string())), ], filter: RowFilter::Where { column: "id".to_string(), value: Value::Number("1".to_string()), }, } ); } #[test] fn update_with_all_rows_flag() { assert_eq!( ok("update Customers set Active=false --all-rows"), Command::Update { table: "Customers".to_string(), assignments: vec![("Active".to_string(), Value::Bool(false))], filter: RowFilter::AllRows, } ); } #[test] fn update_without_where_or_flag_errors() { let e = err("update Customers set Active=false"); assert!(matches!(e, ParseError::Invalid { .. }), "got {e:?}"); } #[test] fn delete_with_where() { assert_eq!( ok("delete from Customers where id=1"), Command::Delete { table: "Customers".to_string(), filter: RowFilter::Where { column: "id".to_string(), value: Value::Number("1".to_string()), }, } ); } #[test] fn delete_with_all_rows_flag() { assert_eq!( ok("delete from Customers --all-rows"), Command::Delete { table: "Customers".to_string(), filter: RowFilter::AllRows, } ); } #[test] fn delete_without_where_or_flag_errors() { let e = err("delete from Customers"); assert!(matches!(e, ParseError::Invalid { .. }), "got {e:?}"); } #[test] fn show_data_command() { assert_eq!( ok("show data Customers"), Command::ShowData { name: "Customers".to_string() } ); } #[test] fn drop_relationship_by_name() { assert_eq!( ok("drop relationship cust_orders"), Command::DropRelationship { selector: RelationshipSelector::Named { name: "cust_orders".to_string() } } ); } #[test] fn show_table_simple() { assert_eq!( ok("show table Customers"), Command::ShowTable { name: "Customers".to_string() } ); } #[test] fn drop_relationship_by_endpoints() { assert_eq!( ok("drop relationship from Customers.Id to Orders.CustId"), Command::DropRelationship { selector: RelationshipSelector::Endpoints { parent_table: "Customers".to_string(), parent_column: "Id".to_string(), child_table: "Orders".to_string(), child_column: "CustId".to_string(), } } ); } #[test] fn identifier_allows_underscores_and_digits_after_start() { assert_eq!( ok("create table customer_v2 with pk"), Command::CreateTable { name: "customer_v2".to_string(), columns: vec![col("id", Type::Serial)], primary_key: vec!["id".to_string()], } ); } // --- replay --- #[test] fn replay_with_bare_relative_path() { assert_eq!( ok("replay history.log"), Command::Replay { path: "history.log".to_string(), } ); } #[test] fn replay_with_bare_absolute_path() { assert_eq!( ok("replay /tmp/seed.commands"), Command::Replay { path: "/tmp/seed.commands".to_string(), } ); } #[test] fn replay_with_quoted_path_supports_whitespace() { assert_eq!( ok("replay 'my project/seed.commands'"), Command::Replay { path: "my project/seed.commands".to_string(), } ); } #[test] fn replay_with_quoted_path_supports_escaped_quote() { assert_eq!( ok("replay 'O''Brien.commands'"), Command::Replay { path: "O'Brien.commands".to_string(), } ); } #[test] fn replay_keyword_is_case_insensitive() { assert_eq!( ok("REPLAY foo.txt"), Command::Replay { path: "foo.txt".to_string(), } ); } #[test] fn replay_without_path_errors() { let e = err("replay"); assert!(matches!(e, ParseError::Invalid { .. }), "got {e:?}"); } #[test] fn replay_with_empty_quoted_path_errors() { // The quoted-path form of `replay` goes through chumsky. // An empty quoted path `''` lexes as a StringLiteral with // an empty payload, which the parser accepts as // syntactically valid; the runtime rejects an empty path // before any I/O. Test pinned to the runtime layer rather // than the parser layer to match the new architecture. // (The pre-tokenizer parser caught this at parse time via // `path_literal`'s try_map; under the lexer split, that // check moves down a layer.) match parse_command("replay ''") { Ok(Command::Replay { path }) => assert_eq!(path, ""), other => panic!("expected Replay with empty path, got {other:?}"), } } }