diff --git a/src/dsl/grammar/ddl.rs b/src/dsl/grammar/ddl.rs new file mode 100644 index 0000000..d456bc4 --- /dev/null +++ b/src/dsl/grammar/ddl.rs @@ -0,0 +1,587 @@ +//! DDL command nodes (ADR-0024 §migration Phase B). +//! +//! Five commands at four entry words: `drop` (drop table / +//! drop column / drop relationship), `add` (add column / +//! add 1:n relationship), `rename` (rename column), `change` +//! (change column). The chumsky-side declarations stay +//! reachable for any input the walker doesn't engage on, but +//! for these entry words the walker is authoritative. +//! +//! Each shape is laid out inline so per-use-site `role` +//! annotations carry meaning end-to-end (e.g., +//! `parent_table` vs `child_table` for the endpoints clause). + +use crate::dsl::action::ReferentialAction; +use crate::dsl::command::{ChangeColumnMode, Command, RelationshipSelector}; +use crate::dsl::grammar::{ + CommandNode, IdentSource, Node, ValidationError, Word, + shared::{REFERENTIAL_CLAUSES, TYPE_SLOT}, +}; +use crate::dsl::walker::outcome::{MatchedKind, MatchedPath}; + +// ================================================================= +// Building blocks +// ================================================================= + +const TABLE_NAME_NEW: Node = Node::Ident { + source: IdentSource::NewName, + role: "table_name", + validator: None, + highlight_override: None, +}; + +const TABLE_NAME_EXISTING: Node = Node::Ident { + source: IdentSource::Tables, + role: "table_name", + validator: None, + highlight_override: None, +}; + +const COLUMN_NAME: Node = Node::Ident { + source: IdentSource::Columns, + role: "column_name", + validator: None, + highlight_override: None, +}; + +const COLUMN_NAME_NEW: Node = Node::Ident { + source: IdentSource::NewName, + role: "column_name", + validator: None, + highlight_override: None, +}; + +const RELATIONSHIP_NAME: Node = Node::Ident { + source: IdentSource::Relationships, + role: "relationship_name", + validator: None, + highlight_override: None, +}; + +const RELATIONSHIP_NAME_NEW: Node = Node::Ident { + source: IdentSource::NewName, + role: "relationship_name", + validator: None, + highlight_override: None, +}; + +// `[to]` and `[table]` connectives. +const TO_OPT: Node = Node::Optional(&Node::Word(Word::keyword("to"))); +const FROM_OPT: Node = Node::Optional(&Node::Word(Word::keyword("from"))); +const IN_OPT: Node = Node::Optional(&Node::Word(Word::keyword("in"))); +const TABLE_OPT: Node = Node::Optional(&Node::Word(Word::keyword("table"))); + +// ================================================================= +// drop_table — `drop table ` +// ================================================================= + +const DROP_TABLE_NODES: &[Node] = &[ + Node::Word(Word::keyword("table")), + TABLE_NAME_EXISTING, +]; +const DROP_TABLE: Node = Node::Seq(DROP_TABLE_NODES); + +// ================================================================= +// drop_column — `drop column [from] [table] : ` +// ================================================================= + +const DROP_COLUMN_NODES: &[Node] = &[ + Node::Word(Word::keyword("column")), + FROM_OPT, + TABLE_OPT, + TABLE_NAME_EXISTING, + Node::Punct(':'), + COLUMN_NAME, +]; +const DROP_COLUMN: Node = Node::Seq(DROP_COLUMN_NODES); + +// ================================================================= +// drop_relationship — `drop relationship (endpoints | name)` +// ================================================================= + +const DR_PARENT_NODES: &[Node] = &[ + Node::Ident { + source: IdentSource::Tables, + role: "parent_table", + validator: None, + highlight_override: None, + }, + Node::Punct('.'), + Node::Ident { + source: IdentSource::Columns, + role: "parent_column", + validator: None, + highlight_override: None, + }, +]; +const DR_PARENT: Node = Node::Seq(DR_PARENT_NODES); + +const DR_CHILD_NODES: &[Node] = &[ + Node::Ident { + source: IdentSource::Tables, + role: "child_table", + validator: None, + highlight_override: None, + }, + Node::Punct('.'), + Node::Ident { + source: IdentSource::Columns, + role: "child_column", + validator: None, + highlight_override: None, + }, +]; +const DR_CHILD: Node = Node::Seq(DR_CHILD_NODES); + +const DR_ENDPOINTS_NODES: &[Node] = &[ + Node::Word(Word::keyword("from")), + DR_PARENT, + Node::Word(Word::keyword("to")), + DR_CHILD, +]; +const DR_ENDPOINTS: Node = Node::Seq(DR_ENDPOINTS_NODES); + +const DR_SELECTOR_CHOICES: &[Node] = &[DR_ENDPOINTS, RELATIONSHIP_NAME]; +const DR_SELECTOR: Node = Node::Choice(DR_SELECTOR_CHOICES); + +const DROP_RELATIONSHIP_NODES: &[Node] = &[ + Node::Word(Word::keyword("relationship")), + DR_SELECTOR, +]; +const DROP_RELATIONSHIP: Node = Node::Seq(DROP_RELATIONSHIP_NODES); + +// ================================================================= +// drop entry — `drop (table|column|relationship) ...` +// ================================================================= + +const DROP_CHOICES: &[Node] = &[DROP_COLUMN, DROP_RELATIONSHIP, DROP_TABLE]; +const DROP_SHAPE: Node = Node::Choice(DROP_CHOICES); + +// ================================================================= +// add_column — `add column [to] [table] : ( )` +// ================================================================= + +const ADD_COLUMN_NODES: &[Node] = &[ + Node::Word(Word::keyword("column")), + TO_OPT, + TABLE_OPT, + TABLE_NAME_EXISTING, + Node::Punct(':'), + COLUMN_NAME_NEW, + Node::Punct('('), + TYPE_SLOT, + Node::Punct(')'), +]; +const ADD_COLUMN: Node = Node::Seq(ADD_COLUMN_NODES); + +// ================================================================= +// add_relationship — `add 1:n relationship [as ] +// from . to . +// [on delete ] [on update ] +// [--create-fk]` +// ================================================================= + +const AR_PARENT_NODES: &[Node] = &[ + Node::Ident { + source: IdentSource::Tables, + role: "parent_table", + validator: None, + highlight_override: None, + }, + Node::Punct('.'), + Node::Ident { + source: IdentSource::Columns, + role: "parent_column", + validator: None, + highlight_override: None, + }, +]; +const AR_PARENT: Node = Node::Seq(AR_PARENT_NODES); + +const AR_CHILD_NODES: &[Node] = &[ + Node::Ident { + source: IdentSource::Tables, + role: "child_table", + validator: None, + highlight_override: None, + }, + Node::Punct('.'), + Node::Ident { + source: IdentSource::Columns, + role: "child_column", + validator: None, + highlight_override: None, + }, +]; +const AR_CHILD: Node = Node::Seq(AR_CHILD_NODES); + +const AR_AS_NAME_NODES: &[Node] = &[ + Node::Word(Word::keyword("as")), + RELATIONSHIP_NAME_NEW, +]; +const AR_AS_NAME_OPT: Node = Node::Optional(&Node::Seq(AR_AS_NAME_NODES)); + +const AR_CREATE_FK_OPT: Node = Node::Optional(&Node::Flag("create-fk")); + +const ADD_RELATIONSHIP_NODES: &[Node] = &[ + Node::Literal("1"), + Node::Punct(':'), + Node::Word(Word::keyword("n")), + Node::Word(Word::keyword("relationship")), + AR_AS_NAME_OPT, + Node::Word(Word::keyword("from")), + AR_PARENT, + Node::Word(Word::keyword("to")), + AR_CHILD, + REFERENTIAL_CLAUSES, + AR_CREATE_FK_OPT, +]; +const ADD_RELATIONSHIP: Node = Node::Seq(ADD_RELATIONSHIP_NODES); + +// ================================================================= +// add entry — `add (column|1:n relationship) …` +// ================================================================= + +const ADD_CHOICES: &[Node] = &[ADD_COLUMN, ADD_RELATIONSHIP]; +const ADD_SHAPE: Node = Node::Choice(ADD_CHOICES); + +// ================================================================= +// rename_column — `rename column [in] [table] : to ` +// ================================================================= + +const RENAME_COLUMN_NODES: &[Node] = &[ + Node::Word(Word::keyword("column")), + IN_OPT, + TABLE_OPT, + TABLE_NAME_EXISTING, + Node::Punct(':'), + COLUMN_NAME, + Node::Word(Word::keyword("to")), + Node::Ident { + source: IdentSource::NewName, + role: "new_column_name", + validator: None, + highlight_override: None, + }, +]; +const RENAME_COLUMN: Node = Node::Seq(RENAME_COLUMN_NODES); + +// ================================================================= +// change_column — `change column [in] [table] : +// ( ) [--force-conversion | --dont-convert]` +// ================================================================= + +const CHANGE_FLAG_CHOICES: &[Node] = &[ + Node::Flag("force-conversion"), + Node::Flag("dont-convert"), +]; +const CHANGE_FLAG_OPT: Node = Node::Repeated { + inner: &Node::Choice(CHANGE_FLAG_CHOICES), + separator: None, + min: 0, +}; + +const CHANGE_COLUMN_NODES: &[Node] = &[ + Node::Word(Word::keyword("column")), + IN_OPT, + TABLE_OPT, + TABLE_NAME_EXISTING, + Node::Punct(':'), + COLUMN_NAME, + Node::Punct('('), + TYPE_SLOT, + Node::Punct(')'), + CHANGE_FLAG_OPT, +]; +const CHANGE_COLUMN: Node = Node::Seq(CHANGE_COLUMN_NODES); + +// ================================================================= +// AST builders +// ================================================================= + +/// First ident whose role matches. +fn ident<'a>(path: &'a MatchedPath, role: &str) -> Option<&'a str> { + path.items.iter().find_map(|i| match &i.kind { + MatchedKind::Ident { role: r } if *r == role => Some(i.text.as_str()), + _ => None, + }) +} + +fn require_ident(path: &MatchedPath, role: &'static str) -> Result { + ident(path, role) + .map(str::to_string) + .ok_or_else(|| ValidationError { + message_key: "parse.error_wrapper", + args: vec![("detail", format!("missing {role}"))], + }) +} + +fn parse_action(words: &[&'static str]) -> ReferentialAction { + // `set null`, `no action`, `cascade`, `restrict`. + if words.contains(&"set") && words.contains(&"null") { + ReferentialAction::SetNull + } else if words.contains(&"no") && words.contains(&"action") { + ReferentialAction::NoAction + } else if words.contains(&"cascade") { + ReferentialAction::Cascade + } else if words.contains(&"restrict") { + ReferentialAction::Restrict + } else { + ReferentialAction::default_action() + } +} + +fn build_drop(path: &MatchedPath) -> Result { + // Discriminate by the second word matched (the entry was + // `drop`, the next Word is `table` / `column` / `relationship`). + let sub = path + .items + .iter() + .filter_map(|i| match &i.kind { + MatchedKind::Word(w) => Some(*w), + _ => None, + }) + .nth(1); + match sub { + Some("table") => Ok(Command::DropTable { + name: require_ident(path, "table_name")?, + }), + Some("column") => Ok(Command::DropColumn { + table: require_ident(path, "table_name")?, + column: require_ident(path, "column_name")?, + }), + Some("relationship") => { + // Endpoints form has `from` as the third Word. + let has_from = path + .items + .iter() + .any(|i| matches!(&i.kind, MatchedKind::Word("from"))); + if has_from { + Ok(Command::DropRelationship { + selector: RelationshipSelector::Endpoints { + parent_table: require_ident(path, "parent_table")?, + parent_column: require_ident(path, "parent_column")?, + child_table: require_ident(path, "child_table")?, + child_column: require_ident(path, "child_column")?, + }, + }) + } else { + Ok(Command::DropRelationship { + selector: RelationshipSelector::Named { + name: require_ident(path, "relationship_name")?, + }, + }) + } + } + _ => Err(ValidationError { + message_key: "parse.error_wrapper", + args: vec![("detail", "unknown drop subcommand".to_string())], + }), + } +} + +fn build_add(path: &MatchedPath) -> Result { + // Second matched Word distinguishes column vs the `1:n + // relationship` form. The `1` literal counts as a Word + // (the walker records Literal matches as MatchedKind::Word + // for AST-builder uniformity). + let second_word = path + .items + .iter() + .filter_map(|i| match &i.kind { + MatchedKind::Word(w) => Some(*w), + _ => None, + }) + .nth(1); + match second_word { + Some("column") => { + let ty_text = require_ident(path, "type")?; + let ty = ty_text + .parse::() + .map_err(|_| ValidationError { + message_key: "parse.error_wrapper", + args: vec![("detail", "unknown type".to_string())], + })?; + Ok(Command::AddColumn { + table: require_ident(path, "table_name")?, + column: require_ident(path, "column_name")?, + ty, + }) + } + Some("1") => build_add_relationship(path), + _ => Err(ValidationError { + message_key: "parse.error_wrapper", + args: vec![("detail", "unknown add subcommand".to_string())], + }), + } +} + +fn build_add_relationship(path: &MatchedPath) -> Result { + // Collect all referential-clause actions in matched order + // and validate at-most-2 + not-repeated. The `on ` sequence shows up as a run of Word + // matches in the path between `to ` and end. + // + // Strategy: walk through Word items in order; whenever we + // see `on`, the next Word is the target, then the action + // word(s) follow until the next `on`, `--create-fk`, or + // end-of-path. + let words: Vec<&'static str> = path + .items + .iter() + .filter_map(|i| match &i.kind { + MatchedKind::Word(w) => Some(*w), + _ => None, + }) + .collect(); + + let mut on_delete: Option = None; + let mut on_update: Option = None; + let mut i = 0; + while i < words.len() { + if words[i] == "on" && i + 1 < words.len() { + let target = words[i + 1]; + // Action runs from i+2 until the next `on` or end. + let action_start = i + 2; + let mut action_end = action_start; + while action_end < words.len() && words[action_end] != "on" { + action_end += 1; + } + let action = parse_action(&words[action_start..action_end]); + let slot = match target { + "delete" => &mut on_delete, + "update" => &mut on_update, + _ => { + i = action_end; + continue; + } + }; + if slot.is_some() { + return Err(ValidationError { + message_key: "parse.custom.on_action_specified_twice", + args: vec![("target", target.to_string())], + }); + } + *slot = Some(action); + i = action_end; + } else { + i += 1; + } + } + + let create_fk = path + .items + .iter() + .any(|i| matches!(&i.kind, MatchedKind::Flag("create-fk"))); + + Ok(Command::AddRelationship { + name: ident(path, "relationship_name").map(str::to_string), + parent_table: require_ident(path, "parent_table")?, + parent_column: require_ident(path, "parent_column")?, + child_table: require_ident(path, "child_table")?, + child_column: require_ident(path, "child_column")?, + on_delete: on_delete.unwrap_or_else(ReferentialAction::default_action), + on_update: on_update.unwrap_or_else(ReferentialAction::default_action), + create_fk, + }) +} + +fn build_rename_column(path: &MatchedPath) -> Result { + Ok(Command::RenameColumn { + table: require_ident(path, "table_name")?, + old: require_ident(path, "column_name")?, + new: require_ident(path, "new_column_name")?, + }) +} + +fn build_change_column(path: &MatchedPath) -> Result { + let ty_text = require_ident(path, "type")?; + let ty = ty_text + .parse::() + .map_err(|_| ValidationError { + message_key: "parse.error_wrapper", + args: vec![("detail", "unknown type".to_string())], + })?; + + // Flags: at most one of --force-conversion / --dont-convert. + let flags: Vec<&'static str> = path + .items + .iter() + .filter_map(|i| match &i.kind { + MatchedKind::Flag(n) => Some(*n), + _ => None, + }) + .collect(); + let mode = match flags.as_slice() { + [] => ChangeColumnMode::Default, + [one] => match *one { + "force-conversion" => ChangeColumnMode::ForceConversion, + "dont-convert" => ChangeColumnMode::DontConvert, + _ => ChangeColumnMode::Default, + }, + _ => { + // Two or more flags — mutual exclusion fires + // whether they're the same flag twice or both + // mutually-exclusive flags appear. Wording mirrors + // the chumsky parser's `change_column_flags_exclusive`. + return Err(ValidationError { + message_key: "parse.custom.change_column_flags_exclusive", + args: vec![], + }); + } + }; + + Ok(Command::ChangeColumnType { + table: require_ident(path, "table_name")?, + column: require_ident(path, "column_name")?, + ty, + mode, + }) +} + +// ================================================================= +// CommandNodes +// ================================================================= + +pub static DROP: CommandNode = CommandNode { + entry: Word::keyword("drop"), + shape: DROP_SHAPE, + ast_builder: build_drop, + help_id: Some("ddl.drop"), + usage_id: Some("parse.usage.drop"), + hint_mode: None, +}; + +pub static ADD: CommandNode = CommandNode { + entry: Word::keyword("add"), + shape: ADD_SHAPE, + ast_builder: build_add, + help_id: Some("ddl.add"), + usage_id: Some("parse.usage.add"), + hint_mode: None, +}; + +pub static RENAME: CommandNode = CommandNode { + entry: Word::keyword("rename"), + shape: RENAME_COLUMN, + ast_builder: build_rename_column, + help_id: Some("ddl.rename"), + usage_id: Some("parse.usage.rename_column"), + hint_mode: None, +}; + +pub static CHANGE: CommandNode = CommandNode { + entry: Word::keyword("change"), + shape: CHANGE_COLUMN, + ast_builder: build_change_column, + help_id: Some("ddl.change"), + usage_id: Some("parse.usage.change_column"), + hint_mode: None, +}; + +// `TABLE_NAME_NEW` is currently unused (Phase C will bring +// it back when `create table` migrates). Keeping the +// declaration here keeps the per-source-of-truth convention +// consistent. +#[allow(dead_code)] +const _UNUSED: Node = TABLE_NAME_NEW; diff --git a/src/dsl/grammar/mod.rs b/src/dsl/grammar/mod.rs index 3f21b57..37921fa 100644 --- a/src/dsl/grammar/mod.rs +++ b/src/dsl/grammar/mod.rs @@ -23,6 +23,8 @@ //! value slots in Phase D. pub mod app; +pub mod ddl; +pub mod shared; use crate::dsl::command::Command; use crate::dsl::walker::context::WalkContext; @@ -125,6 +127,10 @@ impl Word { /// on mismatch. pub type IdentValidator = fn(matched: &str) -> Result<(), ValidationError>; +/// Content-level validator for a `NumberLit` slot. Same shape +/// as `IdentValidator`; surfaces as `ValidationFailed` on Err. +pub type NumberValidator = fn(matched: &str) -> Result<(), ValidationError>; + #[derive(Debug, Clone, PartialEq, Eq)] pub struct ValidationError { pub message_key: &'static str, @@ -160,13 +166,25 @@ pub enum Node { #[allow(dead_code)] highlight_override: Option, }, - #[allow(dead_code)] - NumberLit, + /// A number literal. The optional `validator` runs against + /// the matched text (used by Phase D value slots to enforce + /// per-type integer/decimal rules). + NumberLit { + validator: Option, + }, + /// A literal byte sequence at this position — matches + /// bytes verbatim (whitespace-skipped) with a lookahead so + /// `1` doesn't half-match `12` and `n` doesn't half-match + /// `name`. Used by Phase B's `add 1:n …` for the literal + /// `1`. Surfaces in the expected-set as `` `` ``, + /// matching chumsky's labelled-token rendering. + Literal(&'static str), #[allow(dead_code)] StringLit, #[allow(dead_code)] BlobLit, - #[allow(dead_code)] + /// A `--name` flag. Walker matches the flag shape and + /// asserts the name matches the expected literal. Flag(&'static str), /// A non-whitespace run consumed verbatim from source. Per /// ADR-0024's path-bearing-commands UX change, paths with @@ -231,6 +249,10 @@ pub static REGISTRY: &[&CommandNode] = &[ &app::IMPORT, &app::MODE, &app::MESSAGES, + &ddl::DROP, + &ddl::ADD, + &ddl::RENAME, + &ddl::CHANGE, ]; /// Look up a `CommandNode` by entry word, case-insensitively. diff --git a/src/dsl/grammar/shared.rs b/src/dsl/grammar/shared.rs new file mode 100644 index 0000000..2de6a31 --- /dev/null +++ b/src/dsl/grammar/shared.rs @@ -0,0 +1,120 @@ +//! Shared sub-grammars for the DDL/DML migrations +//! (ADR-0024 §architecture, §sub-grammars). +//! +//! Phase B uses these for relationship endpoints and referential +//! actions; Phase D extends with `where_clause`, +//! `column_value_list`, and the typed value slots. + +use crate::dsl::grammar::{IdentSource, IdentValidator, Node, ValidationError, Word}; +use crate::dsl::types::Type; +use std::str::FromStr; + +// --- Type-name validator ------------------------------------------ + +/// Reject any identifier that isn't a known user-facing type name. +/// +/// Mirrors the chumsky-side `Type::from_str` + `UnknownType` +/// flow — surfaces the same `parse.custom.unknown_type` catalog +/// wording with `{found}` and `{expected}` args. +pub fn validate_type_name(value: &str) -> Result<(), ValidationError> { + if Type::from_str(value).is_ok() { + Ok(()) + } else { + let expected = Type::all() + .iter() + .map(|t| t.keyword()) + .collect::>() + .join(", "); + Err(ValidationError { + message_key: "parse.custom.unknown_type", + args: vec![ + ("found", value.to_string()), + ("expected", expected), + ], + }) + } +} + +pub const TYPE_VALIDATOR: IdentValidator = validate_type_name; + +// --- Type-slot leaf ----------------------------------------------- + +/// `Ident` slot for a column type. Validation runs after a +/// successful identifier-shape match. +pub const TYPE_SLOT: Node = Node::Ident { + source: IdentSource::Types, + role: "type", + validator: Some(TYPE_VALIDATOR), + highlight_override: None, +}; + +// --- Qualified column reference (`.`) -------------- + +const QUALIFIED_COLUMN_NODES: &[Node] = &[ + Node::Ident { + source: IdentSource::Tables, + role: "table_name", + validator: None, + highlight_override: None, + }, + Node::Punct('.'), + Node::Ident { + source: IdentSource::Columns, + role: "column_name", + validator: None, + highlight_override: None, + }, +]; +pub const QUALIFIED_COLUMN: Node = Node::Seq(QUALIFIED_COLUMN_NODES); + +// --- Relationship-endpoint clauses (`from . to .`) ---- + +const RELATIONSHIP_ENDPOINTS_NODES: &[Node] = &[ + Node::Word(Word::keyword("from")), + QUALIFIED_COLUMN, + Node::Word(Word::keyword("to")), + QUALIFIED_COLUMN, +]; +pub const RELATIONSHIP_ENDPOINTS: Node = Node::Seq(RELATIONSHIP_ENDPOINTS_NODES); + +// --- Referential action (`cascade`, `restrict`, `set null`, +// `no action`) ----------------------------------------------- + +const ACTION_SET_NULL: &[Node] = &[ + Node::Word(Word::keyword("set")), + Node::Word(Word::keyword("null")), +]; +const ACTION_NO_ACTION: &[Node] = &[ + Node::Word(Word::keyword("no")), + Node::Word(Word::keyword("action")), +]; +const ACTION_CHOICES: &[Node] = &[ + Node::Word(Word::keyword("cascade")), + Node::Word(Word::keyword("restrict")), + Node::Seq(ACTION_SET_NULL), + Node::Seq(ACTION_NO_ACTION), +]; +pub const ACTION_KEYWORD: Node = Node::Choice(ACTION_CHOICES); + +// --- A single `on ` clause ---------------- + +const ON_TARGET_CHOICES: &[Node] = &[ + Node::Word(Word::keyword("delete")), + Node::Word(Word::keyword("update")), +]; + +const ON_CLAUSE_NODES: &[Node] = &[ + Node::Word(Word::keyword("on")), + Node::Choice(ON_TARGET_CHOICES), + ACTION_KEYWORD, +]; +pub const ON_CLAUSE: Node = Node::Seq(ON_CLAUSE_NODES); + +/// Repeated `on ` clauses (0..2 occurrences). +/// Validation of "specified twice" + max=2 lives in the +/// command's AST builder. +pub const REFERENTIAL_CLAUSES: Node = Node::Repeated { + inner: &ON_CLAUSE, + separator: None, + min: 0, +}; diff --git a/src/dsl/parser.rs b/src/dsl/parser.rs index cc8ddb8..0b4447d 100644 --- a/src/dsl/parser.rs +++ b/src/dsl/parser.rs @@ -138,10 +138,11 @@ fn try_walker_route(source: &str) -> Option> { 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)) + 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 { @@ -157,13 +158,13 @@ fn walker_outcome_to_parse_result( expected: Vec::new(), }), WalkOutcome::Incomplete { position, expected } => Err(ParseError::Invalid { - message: format_walker_error(true, &expected, None), + 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(false, &expected, Some(position)), + message: format_walker_error(source, position, false, &expected), position, at_eof: false, expected: expected.iter().map(format_expectation).collect(), @@ -190,10 +191,21 @@ fn walker_outcome_to_parse_result( } fn format_expectation(e: &crate::dsl::walker::outcome::Expectation) -> String { + use crate::dsl::grammar::IdentSource; use crate::dsl::walker::outcome::Expectation; match e { - Expectation::Word(w) => format!("`{w}`"), - Expectation::Ident { role } => (*role).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::Types => "type".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(), @@ -205,22 +217,39 @@ fn format_expectation(e: &crate::dsl::walker::outcome::Expectation) -> String { } fn format_walker_error( + source: &str, + position: usize, 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); + + // 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 { + } else if prefix.is_empty() { format!("expected {joined}") + } else { + format!("{prefix}expected {joined}") } } else if joined.is_empty() { "unexpected input".to_string() - } else { + } else if prefix.is_empty() { format!("expected {joined}") + } else { + format!("{prefix}expected {joined}") } } diff --git a/src/dsl/walker/driver.rs b/src/dsl/walker/driver.rs index e9920c0..e05a955 100644 --- a/src/dsl/walker/driver.rs +++ b/src/dsl/walker/driver.rs @@ -23,7 +23,9 @@ use crate::dsl::grammar::{HighlightClass, Node, ValidationError}; use crate::dsl::walker::context::WalkContext; -use crate::dsl::walker::lex_helpers::{consume_bare_path, consume_ident, skip_whitespace}; +use crate::dsl::walker::lex_helpers::{ + consume_bare_path, consume_flag, consume_ident, consume_number_literal, skip_whitespace, +}; use crate::dsl::walker::outcome::{ ByteClass, Expectation, MatchedItem, MatchedKind, MatchedPath, }; @@ -32,6 +34,13 @@ use crate::dsl::walker::outcome::{ pub enum NodeWalkResult { Matched { end: usize, + /// Expectations contributed by Optional children that + /// skipped (matched zero terminals). Walker callers + /// merge these into the next failure's expected set so + /// completion sees the full "what could have appeared + /// here" union, not just the strictly-required next + /// terminal. + skipped: Vec, }, /// Did not engage at this position. Caller decides whether /// this is benign (Optional, Choice fallthrough) or a hard @@ -52,6 +61,13 @@ pub enum NodeWalkResult { }, } +const fn matched(end: usize) -> NodeWalkResult { + NodeWalkResult::Matched { + end, + skipped: Vec::new(), + } +} + #[derive(Debug, Clone)] pub enum FailureKind { Mismatch { expected: Vec }, @@ -76,22 +92,25 @@ pub fn walk_node( validator, highlight_override: _, } => walk_ident(source, pos, *src, role, *validator, path, per_byte), - Node::NumberLit - | Node::StringLit - | Node::BlobLit - | Node::Flag(_) - | Node::Repeated { .. } - | Node::DynamicSubgrammar(_) => { - // Phase A: not exercised by app-lifecycle commands. - // Reaching this branch means a Phase B+ grammar got - // declared without the walker support landing yet — - // surface as a hard failure so the test suite catches - // it loudly instead of silently mis-parsing. + Node::NumberLit { validator } => walk_number_lit(source, pos, *validator, path, per_byte), + Node::Literal(literal) => walk_literal(source, pos, literal, path, per_byte), + Node::StringLit | Node::BlobLit | Node::DynamicSubgrammar(_) => { + // Phase A-B: not exercised yet. Reaching this branch + // means a Phase D+ grammar got declared without the + // walker support landing — surface as a hard failure + // so tests catch it loudly rather than silently + // mis-parsing. NodeWalkResult::Failed { position: pos, kind: FailureKind::Mismatch { expected: vec![] }, } } + Node::Flag(name) => walk_flag(source, pos, name, path, per_byte), + Node::Repeated { + inner, + separator, + min, + } => walk_repeated(source, pos, inner, *separator, *min, ctx, path, per_byte), Node::BarePath => walk_bare_path(source, pos, path, per_byte), Node::Choice(children) => walk_choice(source, pos, children, ctx, path, per_byte), Node::Seq(children) => walk_seq(source, pos, children, ctx, path, per_byte), @@ -127,7 +146,7 @@ fn walk_word( end, class: HighlightClass::Keyword, }); - NodeWalkResult::Matched { end } + NodeWalkResult::Matched { end, skipped: Vec::new() } } else { NodeWalkResult::NoMatch { position, @@ -155,9 +174,7 @@ fn walk_punct( end: position + 1, class: HighlightClass::Punct, }); - NodeWalkResult::Matched { - end: position + 1, - } + matched(position + 1) } else { NodeWalkResult::NoMatch { position, @@ -169,7 +186,7 @@ fn walk_punct( fn walk_ident( source: &str, position: usize, - _src: crate::dsl::grammar::IdentSource, + src: crate::dsl::grammar::IdentSource, role: &'static str, validator: Option, path: &mut MatchedPath, @@ -178,7 +195,7 @@ fn walk_ident( let Some((start, end)) = consume_ident(source, position) else { return NodeWalkResult::NoMatch { position, - expected: vec![Expectation::Ident { role }], + expected: vec![Expectation::Ident { role, source: src }], }; }; let text = source[start..end].to_string(); @@ -200,7 +217,197 @@ fn walk_ident( end, class: HighlightClass::Identifier, }); - NodeWalkResult::Matched { end } + NodeWalkResult::Matched { end, skipped: Vec::new() } +} + +fn walk_literal( + source: &str, + position: usize, + literal: &'static str, + path: &mut MatchedPath, + per_byte: &mut Vec, +) -> NodeWalkResult { + let bytes = source.as_bytes(); + let lit_bytes = literal.as_bytes(); + if position + lit_bytes.len() > bytes.len() { + return NodeWalkResult::NoMatch { + position, + expected: vec![Expectation::Literal(literal)], + }; + } + if &bytes[position..position + lit_bytes.len()] != lit_bytes { + return NodeWalkResult::NoMatch { + position, + expected: vec![Expectation::Literal(literal)], + }; + } + // Lookahead: if the literal is a single digit / alphabetic + // run, the next byte must not extend it (so `1` doesn't + // half-match `12`). + let end = position + lit_bytes.len(); + let last = lit_bytes[lit_bytes.len() - 1]; + let last_is_word = last.is_ascii_alphanumeric() || last == b'_'; + if last_is_word && end < bytes.len() { + let next = bytes[end]; + if next.is_ascii_alphanumeric() || next == b'_' { + return NodeWalkResult::NoMatch { + position, + expected: vec![Expectation::Literal(literal)], + }; + } + } + // Highlight class follows the literal's shape: digits get + // Number; letters get Keyword; mixed defaults to Keyword. + let class = if lit_bytes.iter().all(|b| b.is_ascii_digit()) { + HighlightClass::Number + } else { + HighlightClass::Keyword + }; + path.push(MatchedItem { + kind: MatchedKind::Word(literal), + text: literal.to_string(), + span: (position, end), + }); + per_byte.push(ByteClass { + start: position, + end, + class, + }); + NodeWalkResult::Matched { end, skipped: Vec::new() } +} + +fn walk_number_lit( + source: &str, + position: usize, + validator: Option, + path: &mut MatchedPath, + per_byte: &mut Vec, +) -> NodeWalkResult { + let Some((start, end)) = consume_number_literal(source, position) else { + return NodeWalkResult::NoMatch { + position, + expected: vec![Expectation::NumberLit], + }; + }; + let text = source[start..end].to_string(); + if let Some(v) = validator + && let Err(err) = v(&text) + { + return NodeWalkResult::Failed { + position: start, + kind: FailureKind::Validation(err), + }; + } + path.push(MatchedItem { + kind: MatchedKind::NumberLit, + text, + span: (start, end), + }); + per_byte.push(ByteClass { + start, + end, + class: HighlightClass::Number, + }); + NodeWalkResult::Matched { end, skipped: Vec::new() } +} + +fn walk_flag( + source: &str, + position: usize, + name: &'static str, + path: &mut MatchedPath, + per_byte: &mut Vec, +) -> NodeWalkResult { + let Some((start, end)) = consume_flag(source, position) else { + return NodeWalkResult::NoMatch { + position, + expected: vec![Expectation::Flag(name)], + }; + }; + // `consume_flag` guarantees `start..end` covers `--`. + let body = &source[start + 2..end]; + if body != name { + return NodeWalkResult::NoMatch { + position, + expected: vec![Expectation::Flag(name)], + }; + } + path.push(MatchedItem { + kind: MatchedKind::Flag(name), + text: source[start..end].to_string(), + span: (start, end), + }); + per_byte.push(ByteClass { + start, + end, + class: HighlightClass::Flag, + }); + NodeWalkResult::Matched { end, skipped: Vec::new() } +} + +#[allow(clippy::too_many_arguments)] +fn walk_repeated( + source: &str, + position: usize, + inner: &Node, + separator: Option<&Node>, + min: usize, + ctx: &mut WalkContext, + path: &mut MatchedPath, + per_byte: &mut Vec, +) -> NodeWalkResult { + let mut cur = position; + let mut count = 0_usize; + let mut last_expected: Option> = None; + loop { + let saved_path_len = path.items.len(); + let saved_byte_len = per_byte.len(); + let result = if count == 0 { + walk_node(source, cur, inner, ctx, path, per_byte) + } else if let Some(sep) = separator { + let sep_saved_path = path.items.len(); + let sep_saved_byte = per_byte.len(); + match walk_node(source, cur, sep, ctx, path, per_byte) { + NodeWalkResult::Matched { end, .. } => { + walk_node(source, end, inner, ctx, path, per_byte) + } + NodeWalkResult::NoMatch { .. } => { + path.items.truncate(sep_saved_path); + per_byte.truncate(sep_saved_byte); + break; + } + other => return other, + } + } else { + walk_node(source, cur, inner, ctx, path, per_byte) + }; + match result { + NodeWalkResult::Matched { end, .. } => { + cur = end; + count += 1; + } + NodeWalkResult::NoMatch { expected, .. } => { + path.items.truncate(saved_path_len); + per_byte.truncate(saved_byte_len); + last_expected = Some(expected); + break; + } + other => return other, + } + } + if count < min { + return NodeWalkResult::NoMatch { + position: cur, + expected: last_expected.unwrap_or_default(), + }; + } + // The "could continue with another inner" expectations + // become this Repeated's `skipped` set so the caller's + // expected-set surfaces them at completion time. + NodeWalkResult::Matched { + end: cur, + skipped: last_expected.unwrap_or_default(), + } } fn walk_bare_path( @@ -226,7 +433,7 @@ fn walk_bare_path( end, class: HighlightClass::String, }); - NodeWalkResult::Matched { end } + NodeWalkResult::Matched { end, skipped: Vec::new() } } fn walk_choice( @@ -242,13 +449,12 @@ fn walk_choice( let saved_path_len = path.items.len(); let saved_byte_len = per_byte.len(); match walk_node(source, position, child, ctx, path, per_byte) { - NodeWalkResult::Matched { end } => return NodeWalkResult::Matched { end }, + m @ NodeWalkResult::Matched { .. } => return m, NodeWalkResult::NoMatch { expected, .. } => { path.items.truncate(saved_path_len); per_byte.truncate(saved_byte_len); merge_expected(&mut all_expected, expected); } - // Once a choice branch commits, propagate its outcome. other => return other, } } @@ -268,28 +474,67 @@ fn walk_seq( ) -> NodeWalkResult { let mut cur = position; let mut idx = 0; + // Carries expectations from skipped-Optional children so + // that a NoMatch on a later child reports the union of "you + // could have typed any of these" — making the completion + // engine see optional connectives that haven't been typed. + let mut pending_skipped: Vec = Vec::new(); for child in children { match walk_node(source, cur, child, ctx, path, per_byte) { - NodeWalkResult::Matched { end } => { + NodeWalkResult::Matched { end, skipped } => { + if end == cur { + // Child matched zero terminals (Optional skipped, + // empty Repeated, empty Seq). Accumulate its + // would-be expectations into pending. + for e in skipped { + if !pending_skipped.contains(&e) { + pending_skipped.push(e); + } + } + } else { + // Child consumed terminals — the "missing optional" + // window closed; reset the pending list. + pending_skipped.clear(); + pending_skipped.extend(skipped); + } cur = end; idx += 1; } - NodeWalkResult::NoMatch { position, expected } => { + NodeWalkResult::NoMatch { + position, + mut expected, + } => { + // Merge pending skipped-optional expectations with this + // child's expected set. + for e in std::mem::take(&mut pending_skipped) { + if !expected.contains(&e) { + expected.push(e); + } + } if idx == 0 { - // Seq didn't even start. return NodeWalkResult::NoMatch { position, expected }; } - // Mid-shape: did we run out of input or hit a - // wrong token? let post_ws = skip_whitespace(source, position); - let kind = if post_ws >= source.len() { - return NodeWalkResult::Incomplete { position: post_ws, expected }; - } else { - FailureKind::Mismatch { expected } + if post_ws >= source.len() { + return NodeWalkResult::Incomplete { + position: post_ws, + expected, + }; + } + return NodeWalkResult::Failed { + position: post_ws, + kind: FailureKind::Mismatch { expected }, }; - return NodeWalkResult::Failed { position: post_ws, kind }; } - NodeWalkResult::Incomplete { position, expected } => { + NodeWalkResult::Incomplete { + position, + mut expected, + } => { + for e in std::mem::take(&mut pending_skipped) { + if !expected.contains(&e) { + expected.push(e); + } + } return NodeWalkResult::Incomplete { position, expected }; } NodeWalkResult::Failed { position, kind } => { @@ -297,7 +542,10 @@ fn walk_seq( } } } - NodeWalkResult::Matched { end: cur } + NodeWalkResult::Matched { + end: cur, + skipped: pending_skipped, + } } fn walk_optional( @@ -311,11 +559,16 @@ fn walk_optional( let saved_path_len = path.items.len(); let saved_byte_len = per_byte.len(); match walk_node(source, position, child, ctx, path, per_byte) { - NodeWalkResult::Matched { end } => NodeWalkResult::Matched { end }, - NodeWalkResult::NoMatch { .. } => { + m @ NodeWalkResult::Matched { .. } => m, + NodeWalkResult::NoMatch { expected, .. } => { + // Skip the optional but carry the inner's expectations + // so the caller's expected-set sees them. path.items.truncate(saved_path_len); per_byte.truncate(saved_byte_len); - NodeWalkResult::Matched { end: position } + NodeWalkResult::Matched { + end: position, + skipped: expected, + } } other => other, } diff --git a/src/dsl/walker/lex_helpers.rs b/src/dsl/walker/lex_helpers.rs index 446c389..9b24b61 100644 --- a/src/dsl/walker/lex_helpers.rs +++ b/src/dsl/walker/lex_helpers.rs @@ -97,3 +97,92 @@ pub fn match_punct(source: &str, position: usize, ch: char) -> Option { None } } + +/// Number literal: optional leading `-` (when adjacent to a digit), +/// then 1+ digits, optional `.` + 1+ digits. +/// +/// Mirrors `dsl::lexer::lex_number`. Used by Phase B's `add 1:n +/// relationship` form (where the literal `1` lexes as a Number) +/// and by Phase D's value-literal slots. +pub fn consume_number_literal(source: &str, start: usize) -> Option<(usize, usize)> { + let bytes = source.as_bytes(); + if start >= bytes.len() { + return None; + } + let mut i = start; + let leading_minus = bytes[i] == b'-' + && i + 1 < bytes.len() + && bytes[i + 1].is_ascii_digit(); + if leading_minus { + i += 1; + } + if i >= bytes.len() || !bytes[i].is_ascii_digit() { + return None; + } + while i < bytes.len() && bytes[i].is_ascii_digit() { + i += 1; + } + if i < bytes.len() && bytes[i] == b'.' { + let after = i + 1; + if after < bytes.len() && bytes[after].is_ascii_digit() { + i = after; + while i < bytes.len() && bytes[i].is_ascii_digit() { + i += 1; + } + } + } + Some((start, i)) +} + +/// Flag token: `--name` where name is alphanumeric / `-` / `_`, +/// at least one character. Returns the span (including `--`) on +/// match. The caller checks `name` against an expected value. +pub fn consume_flag(source: &str, start: usize) -> Option<(usize, usize)> { + let bytes = source.as_bytes(); + if start + 2 > bytes.len() || &bytes[start..start + 2] != b"--" { + return None; + } + let mut i = start + 2; + while i < bytes.len() { + let b = bytes[i]; + if b.is_ascii_alphanumeric() || b == b'-' || b == b'_' { + i += 1; + } else { + break; + } + } + if i == start + 2 { + return None; + } + Some((start, i)) +} + +/// Single-quoted string literal with `''` escape (mirrors +/// `dsl::lexer::lex_string`). +/// +/// Returns `(start, end)` where end is past the closing quote, +/// plus the unescaped content. `None` when the literal is +/// unterminated or the position isn't at a `'`. +#[allow(dead_code)] +pub fn consume_string_literal(source: &str, start: usize) -> Option<((usize, usize), String)> { + let bytes = source.as_bytes(); + if start >= bytes.len() || bytes[start] != b'\'' { + return None; + } + let mut content = String::new(); + let mut i = start + 1; + while i < bytes.len() { + if bytes[i] == b'\'' { + if bytes.get(i + 1) == Some(&b'\'') { + content.push('\''); + i += 2; + continue; + } + return Some(((start, i + 1), content)); + } + let ch = source[i..].chars().next()?; + content.push(ch); + i += ch.len_utf8(); + } + None +} diff --git a/src/dsl/walker/mod.rs b/src/dsl/walker/mod.rs index 5bed77f..0091b59 100644 --- a/src/dsl/walker/mod.rs +++ b/src/dsl/walker/mod.rs @@ -92,7 +92,7 @@ pub fn walk( &mut path, &mut per_byte, ) { - NodeWalkResult::Matched { end } => { + NodeWalkResult::Matched { end, .. } => { let trailing = skip_whitespace(effective_source, end); if trailing < effective_source.len() { WalkOutcome::Mismatch { @@ -128,14 +128,29 @@ pub fn walk( }, }; - let cmd = if matches!(outcome, WalkOutcome::Match { .. }) { - (command_node.ast_builder)(&path).ok() - } else { - None + // Apply the AST builder. A validation error here surfaces + // as a `ValidationFailed` outcome (so the bridge can render + // the catalog wording correctly) rather than as a generic + // "AST builder failed" fallback. + let (final_outcome, cmd) = match outcome { + WalkOutcome::Match { .. } => match (command_node.ast_builder)(&path) { + Ok(c) => (outcome, Some(c)), + Err(error) => ( + WalkOutcome::ValidationFailed { + position: path + .items + .last() + .map_or(kw_start, |i| i.span.0), + error, + }, + None, + ), + }, + other => (other, None), }; let result = WalkResult { - outcome, + outcome: final_outcome, matched_path: path, per_byte_class: per_byte, }; @@ -369,13 +384,28 @@ mod tests { fn walker_import_trailing_as_without_target_errors() { let err = parse("import foo.zip as ").unwrap_err(); match err { - crate::dsl::ParseError::Invalid { message, expected, .. } => { + crate::dsl::ParseError::Invalid { + message, expected, .. + } => { // Phase A: the friendly `project.import_empty_target` // wording moves out of the parser; the walker's - // structural error names the `target` slot. + // structural error names the slot via its + // user-facing label. NewName slots render as + // "identifier" — matching `IdentSlot::expected_label` + // — so the existing completion engine's round- + // trip still works. The integration test + // (`import_with_empty_target_after_as_errors`) + // continues to pass because the rendered + // `import_usage` template line in the output + // contains both "import" and "target". assert!( - message.contains("target") || expected.iter().any(|e| e == "target"), - "expected mention of target slot; got message={message:?}, expected={expected:?}" + message.contains("identifier") + || expected.iter().any(|e| e == "identifier"), + "expected identifier-slot wording; got message={message:?}, expected={expected:?}" + ); + assert!( + message.contains("import"), + "expected `import` in 'after ``' framing; got: {message}" ); } other => panic!("expected Invalid, got {other:?}"), @@ -429,4 +459,202 @@ mod tests { }) ); } + + // ========================================================= + // Phase B — DDL commands. + // ========================================================= + + use crate::dsl::action::ReferentialAction; + use crate::dsl::command::{ChangeColumnMode, RelationshipSelector}; + use crate::dsl::types::Type; + + #[test] + fn walker_parses_drop_table() { + assert_eq!( + parse("drop table Customers").unwrap(), + Command::DropTable { + name: "Customers".to_string(), + } + ); + } + + #[test] + fn walker_parses_drop_column_with_optional_connectives() { + let want = Command::DropColumn { + table: "Customers".to_string(), + column: "Email".to_string(), + }; + assert_eq!(parse("drop column Customers: Email").unwrap(), want); + assert_eq!(parse("drop column from Customers: Email").unwrap(), want); + assert_eq!(parse("drop column from table Customers: Email").unwrap(), want); + assert_eq!(parse("drop column table Customers: Email").unwrap(), want); + } + + #[test] + fn walker_parses_drop_relationship_named() { + assert_eq!( + parse("drop relationship Orders_to_Customers").unwrap(), + Command::DropRelationship { + selector: RelationshipSelector::Named { + name: "Orders_to_Customers".to_string(), + }, + } + ); + } + + #[test] + fn walker_parses_drop_relationship_endpoints() { + assert_eq!( + parse("drop relationship from Customers.id to Orders.customer_id").unwrap(), + Command::DropRelationship { + selector: RelationshipSelector::Endpoints { + parent_table: "Customers".to_string(), + parent_column: "id".to_string(), + child_table: "Orders".to_string(), + child_column: "customer_id".to_string(), + }, + } + ); + } + + #[test] + fn walker_parses_add_column() { + assert_eq!( + parse("add column Customers: Email (text)").unwrap(), + Command::AddColumn { + table: "Customers".to_string(), + column: "Email".to_string(), + ty: Type::Text, + } + ); + } + + #[test] + fn walker_add_column_unknown_type_errors_with_friendly_wording() { + let err = parse("add column Customers: Email (varchar)").unwrap_err(); + match err { + crate::dsl::ParseError::Invalid { message, .. } => { + assert!(message.contains("varchar"), "got: {message}"); + } + other => panic!("expected Invalid, got {other:?}"), + } + } + + #[test] + fn walker_parses_rename_column() { + assert_eq!( + parse("rename column Customers: Email to ContactEmail").unwrap(), + Command::RenameColumn { + table: "Customers".to_string(), + old: "Email".to_string(), + new: "ContactEmail".to_string(), + } + ); + } + + #[test] + fn walker_parses_change_column() { + assert_eq!( + parse("change column Customers: Email (text)").unwrap(), + Command::ChangeColumnType { + table: "Customers".to_string(), + column: "Email".to_string(), + ty: Type::Text, + mode: ChangeColumnMode::Default, + } + ); + } + + #[test] + fn walker_parses_change_column_with_force_conversion_flag() { + assert_eq!( + parse("change column Customers: Email (int) --force-conversion").unwrap(), + Command::ChangeColumnType { + table: "Customers".to_string(), + column: "Email".to_string(), + ty: Type::Int, + mode: ChangeColumnMode::ForceConversion, + } + ); + } + + #[test] + fn walker_change_column_rejects_both_flags() { + let err = parse("change column Customers: Email (int) --force-conversion --dont-convert") + .unwrap_err(); + match err { + crate::dsl::ParseError::Invalid { message, .. } => { + assert!(message.contains("mutually exclusive"), "got: {message}"); + } + other => panic!("expected Invalid, got {other:?}"), + } + } + + #[test] + fn walker_parses_add_relationship_minimal() { + assert_eq!( + parse("add 1:n relationship from Customers.id to Orders.customer_id").unwrap(), + Command::AddRelationship { + name: None, + parent_table: "Customers".to_string(), + parent_column: "id".to_string(), + child_table: "Orders".to_string(), + child_column: "customer_id".to_string(), + on_delete: ReferentialAction::default_action(), + on_update: ReferentialAction::default_action(), + create_fk: false, + } + ); + } + + #[test] + fn walker_parses_add_relationship_with_name_and_actions_and_flag() { + assert_eq!( + parse( + "add 1:n relationship as cust_orders from Customers.id to Orders.customer_id \ + on delete cascade on update set null --create-fk" + ) + .unwrap(), + Command::AddRelationship { + name: Some("cust_orders".to_string()), + parent_table: "Customers".to_string(), + parent_column: "id".to_string(), + child_table: "Orders".to_string(), + child_column: "customer_id".to_string(), + on_delete: ReferentialAction::Cascade, + on_update: ReferentialAction::SetNull, + create_fk: true, + } + ); + } + + #[test] + fn walker_add_relationship_repeated_clause_errors() { + let err = parse( + "add 1:n relationship from Customers.id to Orders.customer_id \ + on delete cascade on delete restrict", + ) + .unwrap_err(); + match err { + crate::dsl::ParseError::Invalid { message, .. } => { + assert!( + message.contains("delete") && message.contains("twice"), + "got: {message}" + ); + } + other => panic!("expected Invalid, got {other:?}"), + } + } + + // ---- Routing fall-through still works for non-DDL ---- + + #[test] + fn walker_does_not_engage_for_show_data() { + // `show` isn't migrated yet (Phase D); router falls + // through to chumsky. + assert!(matches!( + parse("show data Customers").unwrap(), + Command::ShowData { .. } + )); + } } diff --git a/src/dsl/walker/outcome.rs b/src/dsl/walker/outcome.rs index 23413ca..b5e0934 100644 --- a/src/dsl/walker/outcome.rs +++ b/src/dsl/walker/outcome.rs @@ -11,7 +11,7 @@ //! tests; completion + highlighting still flow through the //! chumsky path until Phase D / F. -use crate::dsl::grammar::{HighlightClass, ValidationError}; +use crate::dsl::grammar::{HighlightClass, IdentSource, ValidationError}; /// How far into the input the walker should consume. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -31,10 +31,20 @@ pub enum WalkBound { /// only what the router needs to render a parse error. #[derive(Debug, Clone, PartialEq, Eq)] pub enum Expectation { - /// The walker expected one of these literal keywords. + /// The walker expected this literal keyword. Word(&'static str), - /// The walker expected an identifier of the given role. - Ident { role: &'static str }, + /// The walker expected this verbatim literal byte sequence + /// (used today for the `1` in `add 1:n …`). + Literal(&'static str), + /// The walker expected an identifier slot. `source` drives + /// the user-facing expected-label rendering ("table name", + /// "column name", …) so the existing completion engine's + /// `IdentSlot::from_expected_label` round-trip still works. + /// `role` is the walker-internal slot tag. + Ident { + role: &'static str, + source: IdentSource, + }, /// The walker expected this exact punctuation character. Punct(char), /// The walker expected a number literal.