//! DSL parser (ADR-0024). //! //! The chumsky+lexer pipeline has been retired (ADR-0024 §migration //! Phase F minimal). `parse_command` now routes every input through //! the unified-grammar walker in `crate::dsl::walker`. The walker //! reads source bytes directly — there is no separate token pre-pass. //! //! This module remains the public entry point for parsing because //! consumers depend on `ParseError`'s shape (the `expected`, //! `position`, `at_eof` fields drive completion, hint rendering, //! and the input-renderer's error overlay). It also produces the //! synthetic "unknown command" error when the input's first //! identifier-shape token isn't a registered entry word. use tracing::trace; use crate::dsl::command::Command; use crate::mode::Mode; #[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. /// /// Routes through the unified-grammar walker (ADR-0024 /// §architecture). If the walker doesn't engage (the input's /// first identifier-shape token isn't a registered entry word), /// produces a synthetic "unknown command" error naming every /// valid entry keyword. /// /// Schemaless variant: schema-aware nodes /// (`Ident { source: Tables }` with `writes_table` enabled, /// `DynamicSubgrammar`) fall back to schema-unaware behaviour. /// Use `parse_command_with_schema` to enable typed value slots /// (ADR-0024 §Phase D). /// /// Defaults to **advanced**-mode grammar (the full surface) — /// callers that need simple-mode gating (ADR-0030 §2) use /// [`parse_command_in_mode`] or /// [`parse_command_with_schema_in_mode`] instead. pub fn parse_command(input: &str) -> Result { parse_command_inner(input, None, Mode::Advanced) } /// Schema-aware parse entry point (ADR-0024 §Phase D). /// /// Threads a `SchemaCache` reference through `WalkContext` so /// the walker can populate `current_table` / `current_column` /// from existing entities and `DynamicSubgrammar` factories /// can unfold per-column typed value slots. /// /// Defaults to **advanced**-mode grammar; for simple-mode /// gating use [`parse_command_with_schema_in_mode`]. pub fn parse_command_with_schema( input: &str, schema: &crate::completion::SchemaCache, ) -> Result { parse_command_inner(input, Some(schema), Mode::Advanced) } /// Schemaless, mode-aware parse (ADR-0030 §2). In `Mode::Simple` /// the walker gates SQL-only commands and produces the /// "this is SQL" hint instead of executing them. pub fn parse_command_in_mode( input: &str, mode: Mode, ) -> Result { parse_command_inner(input, None, mode) } /// Schema-aware, mode-aware parse. /// /// Combines ADR-0024 §Phase D (schema-aware typed slots) with /// ADR-0030 §2 (mode-gated SQL grammar). The execution path /// (`App::dispatch_dsl`) and the live overlay / completion / /// highlight call sites use this so simple-mode users do not /// see advanced-mode SQL surfaced. pub fn parse_command_with_schema_in_mode( input: &str, schema: &crate::completion::SchemaCache, mode: Mode, ) -> Result { parse_command_inner(input, Some(schema), mode) } fn parse_command_inner( input: &str, schema: Option<&crate::completion::SchemaCache>, mode: Mode, ) -> Result { // `trace`, not `debug`: parsing is a hot path — the live overlay / // completion (completion.rs) re-parse per keystroke, probing // candidates in a loop, so a per-parse `debug` line would flood. The // executed-command story lives at `debug` in db.rs (one per submit). trace!( len = input.len(), mode = ?mode, schema_aware = schema.is_some(), "parse: begin" ); if input.trim().is_empty() { trace!("parse: empty input"); return Err(ParseError::Empty); } let result = try_walker_route(input, schema, mode).unwrap_or_else(|| Err(unknown_command_error(input))); match &result { Ok(cmd) => trace!(command = cmd.verb(), "parse: ok"), Err(e) => trace!(error = %e, "parse: rejected"), } result } /// Synthetic ParseError for inputs whose first identifier-shape /// token isn't a registered command entry word. fn unknown_command_error(source: &str) -> ParseError { use crate::dsl::walker::lex_helpers::{consume_ident, skip_whitespace}; let entries: Vec = crate::dsl::grammar::entry_words_alphabetised() .into_iter() .map(|w| format!("`{w}`")) .collect(); let joined = oxford_join(&entries); let start = skip_whitespace(source, 0); let (position, found_word) = consume_ident(source, start).map_or_else( || (start, None), |(s, e)| (s, Some(&source[s..e])), ); let message = found_word.map_or_else( || format!("expected one of {joined}"), |w| format!("expected one of {joined}, found `{w}`"), ); ParseError::Invalid { message, position, at_eof: false, expected: entries, } } /// Walker route. Returns `None` when the walker doesn't engage /// (input doesn't start with a registered entry keyword); the /// router falls through to the synthetic "unknown command" /// error. fn try_walker_route( source: &str, schema: Option<&crate::completion::SchemaCache>, mode: Mode, ) -> Option> { use crate::dsl::walker::{self, outcome::WalkBound}; let mut ctx = schema.map_or_else(walker::context::WalkContext::new, |s| { walker::context::WalkContext::with_schema(s) }); ctx.mode = mode; let (result, command) = walker::walk(source, WalkBound::EndOfInput, &mut ctx); let result = result?; Some(walker_outcome_to_parse_result(source, result, command)) } fn walker_outcome_to_parse_result( source: &str, 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(source, position, true, &expected), position, at_eof: true, expected: expected.iter().map(format_expectation).collect(), }), WalkOutcome::Mismatch { position, expected } => Err(ParseError::Invalid { message: format_walker_error(source, position, false, &expected), 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); // Mirror the chumsky-side custom-error convention // (parser.rs `into_parse_error`): treat validation // errors as `at_eof = true` so the input renderer // classifies them as IncompleteAtEof rather than a // mid-input definite error. Live overlay is // suppressed; the on-submit error still fires. Err(ParseError::Invalid { message, position, at_eof: true, expected: Vec::new(), }) } } } fn format_expectation(e: &crate::dsl::walker::outcome::Expectation) -> String { use crate::dsl::grammar::IdentSource; use crate::dsl::walker::outcome::Expectation; match e { // ADR-0042 G1: the bare `1` that opens `add 1:n // relationship …` is the project's only `Literal("1")` // (grammar `ddl.rs`); on its own in an expected-set it is // cryptic — a learner cannot know it begins a // relationship. Render it as the named construct in error // wording. This is render-only: completion/hints read the // raw `Expectation::Literal("1")` directly (offering the // literal `1` to type), so the candidate surface is // unchanged. Expectation::Literal("1") => "`1:n relationship`".to_string(), Expectation::Word(w) | Expectation::Literal(w) => format!("`{w}`"), Expectation::Ident { source, .. } => match source { // Match `IdentSlot::expected_label` outputs so the // completion engine's round-trip (via // `IdentSlot::from_expected_label`) still resolves // the schema-cache lookup for these slots. IdentSource::Tables => "table name".to_string(), IdentSource::Columns => "column name".to_string(), IdentSource::Relationships => "relationship name".to_string(), IdentSource::Indexes => "index name".to_string(), IdentSource::Types => "type".to_string(), IdentSource::Generators => "generator name".to_string(), IdentSource::NewName | IdentSource::Free => "identifier".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(), } } /// ADR-0042 G2: a projection start (`select |`, or the projection /// position inside a subquery / CTE body) expects the full /// expression first-set — 14 alternatives — plus the SELECT /// quantifiers `distinct` and `all`. Those two quantifiers are /// jointly expectable *only* at a projection start, so their joint /// presence is a precise signature for collapsing the noisy list /// into one gloss. Render-only: this fires inside /// `format_walker_error` (the error message), not in the expected /// set the completion/hint layer consumes. fn is_select_projection_start(expected: &[crate::dsl::walker::outcome::Expectation]) -> bool { use crate::dsl::walker::outcome::Expectation; let has_word = |w: &str| { expected .iter() .any(|e| matches!(e, Expectation::Word(x) if x.eq_ignore_ascii_case(w))) }; has_word("distinct") && has_word("all") } /// ADR-0042 §3: detect the `… CROSS JOIN on …` mistake. A /// CROSS JOIN takes no `ON` clause; the grammar rejects the `on`, /// but the bare structural error ("expected end of input") doesn't /// teach why. `on` is unexpected at this position *only* when the /// most recent join is a CROSS join — every other join flavour /// requires `on`, so there `on` would be in the expected set, not a /// failure. Detection: the failing token is the keyword `on`, and /// the last `join` word in the consumed prefix is immediately /// preceded by `cross`. Render-only; no grammar change. fn is_cross_join_on(source: &str, position: usize) -> bool { let rest = source[position.min(source.len())..].trim_start(); let next_is_on = { let mut chars = rest.chars(); let starts_on = rest.len() >= 2 && rest[..2].eq_ignore_ascii_case("on"); let boundary = chars .nth(2) .is_none_or(|c| !c.is_ascii_alphanumeric() && c != '_'); starts_on && boundary }; if !next_is_on { return false; } let consumed = &source[..position.min(source.len())]; let words: Vec<&str> = consumed .split(|c: char| !c.is_ascii_alphanumeric() && c != '_') .filter(|w| !w.is_empty()) .collect(); match words.iter().rposition(|w| w.eq_ignore_ascii_case("join")) { Some(i) if i > 0 => words[i - 1].eq_ignore_ascii_case("cross"), _ => false, } } fn format_walker_error( source: &str, position: usize, at_eof: bool, expected: &[crate::dsl::walker::outcome::Expectation], ) -> String { if is_cross_join_on(source, position) { let consumed = source[..position.min(source.len())].trim_end(); let prefix = if consumed.is_empty() { String::new() } else { format!("after `{consumed}`, ") }; return format!("{prefix}{}", crate::t!("parse.cross_join_no_on")); } let joined = if is_select_projection_start(expected) { crate::t!("parse.expect.select_projection") } else { let parts: Vec = expected.iter().map(format_expectation).collect(); oxford_join(&parts) }; // Mirror the chumsky-side wording: "after ``, // expected …" when the parser already consumed something // before the failure point. The `` text trims // trailing whitespace and is rendered between backticks. let consumed = source[..position.min(source.len())].trim_end(); let prefix = if consumed.is_empty() { String::new() } else { format!("after `{consumed}`, ") }; if at_eof { if joined.is_empty() { crate::t!("parse.empty") } else if prefix.is_empty() { format!("expected {joined}, found end of input") } else { format!("{prefix}expected {joined}, found end of input") } } else if joined.is_empty() { "unexpected input".to_string() } else if prefix.is_empty() { format!("expected {joined}") } else { format!("{prefix}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]) } } } // ADR-0024 Phase F: the chumsky-side `command_parser` and its // per-command sub-parsers (replay, export/import, mode/messages, // the DDL family, data commands) are deleted. The unified-grammar // walker in `crate::dsl::walker` is the sole parse path. // `try_parse_replay_with_bare_path` and `try_parse_app_path_command` // — the source-slice helpers that handled bare paths before the // walker existed — are also gone; `BarePath` in the walker // supersedes them. // ========================================================= // Tests // ========================================================= #[cfg(test)] mod tests { use super::*; use crate::dsl::action::ReferentialAction; use crate::dsl::command::{ ChangeColumnMode, ColumnSpec, IndexSelector, RelationshipSelector, RowFilter, }; use crate::dsl::types::Type; use crate::dsl::value::Value; use pretty_assertions::assert_eq; // These helpers parse in **Simple mode** — the DSL surface // (ADR-0003). The tests in this module exercise the DSL // grammar (`insert`/`update`/`delete` Forms A/B/C, the // `--all-rows` rail, DDL, app commands), all of which are // canonical in Simple mode. Since sub-phase 3j made // `insert`/`update`/`delete` shared entry words (ADR-0033 §2, // Amendment 3), parsing these in Advanced mode would route the // overlap to the SQL command variants; the SQL surface is // covered by `tests/sql_*.rs` instead. No SQL-only command // (`select`/`with`) is tested through these helpers. fn ok(input: &str) -> Command { parse_command_in_mode(input, Mode::Simple) .unwrap_or_else(|e| panic!("expected ok for {input:?}, got {e:?}")) } fn err(input: &str) -> ParseError { parse_command_in_mode(input, Mode::Simple).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::new(name, 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(), cascade: false, } ); } #[test] fn drop_column_accepts_bare_identifiers() { assert_eq!( ok("drop column Customers: Email"), Command::DropColumn { table: "Customers".to_string(), column: "Email".to_string(), cascade: false, } ); assert_eq!( ok("drop column from Customers: Email"), Command::DropColumn { table: "Customers".to_string(), column: "Email".to_string(), cascade: false, } ); assert_eq!( ok("drop column table Customers: Email"), Command::DropColumn { table: "Customers".to_string(), column: "Email".to_string(), cascade: false, } ); } #[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, not_null: false, unique: false, default: None, check: None, } ); } #[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, not_null: false, unique: false, default: None, check: None, } ); } } #[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, not_null: false, unique: false, default: None, check: None, } ); } #[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, not_null: false, unique: false, default: None, check: None, } ); } #[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, not_null: false, unique: false, default: None, check: None, } ); } #[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, not_null: false, unique: false, default: None, check: None, } ); } #[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_columns: vec![parent.1.to_string()], child_table: child.0.to_string(), child_columns: vec![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::eq("id", 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::eq("id", 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::eq("id", 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:?}"); } // ===================================================== // Sub-phase 3j — shared-entry-word dispatch (ADR-0033 §2, // Amendment 1 / Amendment 3). // // `insert` / `update` / `delete` are *shared* entry words: a // `Simple` DSL node and an `Advanced` SQL node both register // under each. A command's identity is the outcome of the // mode-rooted grammar path: // - Advanced mode tries the SQL shape first and falls back to // the DSL shape only when the SQL shape *structurally* can't // match (e.g. the DSL-only `--all-rows` flag). A content // rejection (a `__rdbms_*` target) on the SQL shape is // surfaced, never masked by the DSL fallback. // - Simple mode commits the DSL shape; it points the user at // advanced mode ("this is SQL") only when the input is // SQL-only (the DSL shape structurally mismatches and the SQL // shape matches — e.g. a `returning` tail). A DSL command // that is merely incomplete or has a bad value still commits // the DSL node so the user sees DSL completion / DSL errors. // The §6/§7 parity guarantees mean the two variants execute to // identical effects for an overlapping input. // ===================================================== #[test] fn advanced_ambiguous_insert_routes_to_sql() { assert!(matches!( parse_command_in_mode("insert into Orders values (1, 2)", Mode::Advanced), Ok(Command::SqlInsert { .. }) )); } #[test] fn advanced_ambiguous_update_routes_to_sql() { assert!(matches!( parse_command_in_mode( "update Orders set total = 0 where id = 1", Mode::Advanced, ), Ok(Command::SqlUpdate { .. }) )); } #[test] fn advanced_ambiguous_delete_routes_to_sql() { assert!(matches!( parse_command_in_mode("delete from Orders where id = 1", Mode::Advanced), Ok(Command::SqlDelete { .. }) )); } #[test] fn advanced_dsl_only_delete_falls_back_to_dsl() { // `--all-rows` is DSL-only; the SQL DELETE shape can't consume // the trailing flag, so dispatch falls back to the DSL node. assert_eq!( parse_command_in_mode("delete from Orders --all-rows", Mode::Advanced).unwrap(), Command::Delete { table: "Orders".to_string(), filter: RowFilter::AllRows, }, ); } #[test] fn simple_mode_data_commands_reject_internal_tables() { // ADR-0030 §6 ("every table-source slot") / `/runda` finding // B: the DSL data-command target slots reject `__rdbms_*` // internal tables in simple mode too — matching the SQL // grammar. Without this, simple-mode DML could read/write the // internal metadata tables while advanced-mode SQL rejected // them. for input in [ "insert into __rdbms_playground_columns values (1)", "update __rdbms_playground_columns set x = 1 where id = 1", "delete from __rdbms_playground_columns where id = 1", "show data __rdbms_playground_columns", "show table __rdbms_playground_relationships", ] { assert!( parse_command_in_mode(input, Mode::Simple).is_err(), "internal table must be rejected in simple mode: {input:?}", ); } } #[test] fn advanced_internal_table_insert_is_rejected_not_fallen_back() { // The SQL insert's `reject_internal_table` rail must surface // even though the DSL insert node lacks it: a content // rejection commits the SQL candidate rather than falling // through to the DSL node that would accept it. assert!( parse_command_in_mode( "insert into __rdbms_playground_columns values (1)", Mode::Advanced, ) .is_err(), ); } #[test] fn simple_dsl_delete_stays_dsl() { assert_eq!( parse_command_in_mode("delete from Orders where id = 1", Mode::Simple).unwrap(), Command::Delete { table: "Orders".to_string(), filter: RowFilter::eq("id", Value::Number("1".to_string())), }, ); } #[test] fn simple_sql_only_entry_word_points_at_advanced_mode() { // A SQL-only *entry word* (`select`) has no DSL form, so // simple mode emits the "this is SQL" hint at the parse level // (ADR-0030 §2). match parse_command_in_mode("select Name from Orders", Mode::Simple) { Err(ParseError::Invalid { message, .. }) => assert!( message.contains("advanced"), "expected the this-is-SQL hint, got: {message}", ), other => panic!("expected the this-is-SQL hint, got {other:?}"), } } #[test] fn simple_shared_word_with_sql_construct_is_a_dsl_parse_error() { // `returning` is SQL-only, but `delete` is a *shared* entry // word, so simple mode commits the DSL shape and surfaces a // DSL parse error (ADR-0033 Amendment 3). The "(valid as SQL // in advanced mode)" pointer is added at the hint layer // (input_render), not in the parsed command/error here. assert!(matches!( parse_command_in_mode( "delete from Orders where id = 1 returning *", Mode::Simple, ), Err(ParseError::Invalid { .. }) )); } #[test] fn show_data_command() { assert_eq!( ok("show data Customers"), Command::ShowData { name: "Customers".to_string(), filter: None, limit: None, } ); } #[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(), } } ); } // --- add index / drop index (ADR-0025) --- #[test] fn add_index_named() { assert_eq!( ok("add index as idx_email on Customers (Email)"), Command::AddIndex { name: Some("idx_email".to_string()), table: "Customers".to_string(), columns: vec!["Email".to_string()], } ); } #[test] fn add_index_unnamed() { assert_eq!( ok("add index on Customers (Email)"), Command::AddIndex { name: None, table: "Customers".to_string(), columns: vec!["Email".to_string()], } ); } #[test] fn add_index_composite_columns() { assert_eq!( ok("add index on Orders (CustId, Date)"), Command::AddIndex { name: None, table: "Orders".to_string(), columns: vec!["CustId".to_string(), "Date".to_string()], } ); } #[test] fn drop_index_by_name() { assert_eq!( ok("drop index idx_email"), Command::DropIndex { selector: IndexSelector::Named { name: "idx_email".to_string(), }, } ); } #[test] fn drop_index_by_columns() { assert_eq!( ok("drop index on Customers (Email)"), Command::DropIndex { selector: IndexSelector::Columns { table: "Customers".to_string(), columns: vec!["Email".to_string()], }, } ); } #[test] fn drop_column_cascade_flag() { assert_eq!( ok("drop column Customers: Email --cascade"), Command::DropColumn { table: "Customers".to_string(), column: "Email".to_string(), cascade: true, } ); } #[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:?}"), } } }