//! Walker entry point (ADR-0024 §architecture). //! //! The walker is the single source of truth for the migrated //! commands. Phase A wires the parse consumer; completion + //! highlighting still flow through the chumsky path until //! Phase D / F. //! //! Routing rule (ADR-0024 §migration): the input's first //! identifier-shape token decides whether the walker owns this //! command. If it matches a registered entry word, the walker //! takes over end-to-end (success or failure). Otherwise, the //! router falls through to the chumsky parser, which still //! carries every non-migrated command's grammar through Phase F. pub mod context; pub mod driver; pub mod highlight; pub mod lex_helpers; pub mod outcome; use crate::dsl::command::Command; use crate::dsl::grammar; use crate::dsl::walker::context::WalkContext; use crate::dsl::walker::driver::{FailureKind, NodeWalkResult, walk_node}; use crate::dsl::walker::lex_helpers::{consume_ident, skip_whitespace}; use crate::dsl::walker::outcome::{ Expectation, MatchedPath, WalkBound, WalkOutcome, WalkResult, }; pub use context::ColumnInfo; pub use highlight::highlight_runs; /// Resolve the hint-panel mode at the end of `source` /// (ADR-0024 §HintMode-per-node, §Phase D §typed-value-slots). /// /// Schemaless variant. Surfaces: /// - `HintMode::ProseOnly("hint.value_literal_slot")` at generic /// value-literal positions (all five forms in the expected /// set), and /// - `HintMode::ForceProse("hint.ambient_typing_name")` at /// `NewName` ident slots. /// /// Schema-aware callers should use `hint_mode_at_input_with_schema` /// instead — that variant narrows the prose to the column's /// user-facing type at typed value slots (e.g. "Type a date /// as 'YYYY-MM-DD'" at a date column). #[must_use] pub fn hint_mode_at_input(source: &str) -> Option { hint_mode_at_input_inner(source, None) } /// Schema-aware hint-mode resolution (ADR-0024 §Phase D). /// /// Uses the same schema reference the walker drives parse-time /// dispatch from. When the walker enters a `Node::TypedValueSlot` /// at the cursor position, the catalog prose narrows to the /// column's user-facing type (e.g. `hint.value_slot_int` at an /// int column). #[must_use] pub fn hint_mode_at_input_with_schema( source: &str, schema: &crate::completion::SchemaCache, ) -> Option { hint_mode_at_input_inner(source, Some(schema)) } fn hint_mode_at_input_inner( source: &str, schema: Option<&crate::completion::SchemaCache>, ) -> Option { use crate::dsl::grammar::{HintMode, IdentSource}; use crate::dsl::walker::outcome::Expectation; // Hint mode is only meaningful at *required* slot positions // (Incomplete / Mismatch outcomes). For complete commands // (Match), `tail_expected` may carry optional-suffix // expectations — completion surfaces those as Tab // candidates, but the hint resolver should stay silent so // we don't push prose like "Type a name" at the end of a // valid command. let (expected, pending_value_type) = expected_for_hint_with_ctx(source, schema); if expected.is_empty() { return None; } // Typed value slot at the cursor: the walker tagged // ctx.pending_value_type on entry to the slot but did not // clear it (no inner literal matched). Emit per-type prose. if let Some(ty) = pending_value_type { return Some(HintMode::ProseOnly(catalog_key_for_value_type(ty))); } // Value-literal slot signature: all five forms present. let has_word = |w: &str| { expected .iter() .any(|e| matches!(e, Expectation::Word(x) if *x == w)) }; let value_literal_slot = has_word("null") && has_word("true") && has_word("false") && expected.iter().any(|e| matches!(e, Expectation::NumberLit)) && expected.iter().any(|e| matches!(e, Expectation::StringLit)); if value_literal_slot { // Fallback prose: lists every literal form with format // examples. Fires when the walker can't resolve a column // type at the cursor (schemaless caller, missing table, // unknown column). return Some(HintMode::ProseOnly("hint.value_literal_slot")); } // NewName ident slot: user invents a name. let new_name_slot = expected.iter().any(|e| { matches!( e, Expectation::Ident { source: IdentSource::NewName, .. } ) }); if new_name_slot { // The "Type a name" prose key is selected by the // ambient_hint dispatch — the `ForceProse` key here is // a stable identifier the resolver maps to one of the // two variants (`hint.ambient_typing_name` / // `hint.ambient_typing_name_then`) depending on whether // a next-token probe yields content. return Some(HintMode::ForceProse("hint.ambient_typing_name")); } None } const fn catalog_key_for_value_type(ty: crate::dsl::types::Type) -> &'static str { use crate::dsl::types::Type; match ty { Type::Int => "hint.value_slot_int", Type::Real => "hint.value_slot_real", Type::Decimal => "hint.value_slot_decimal", Type::Bool => "hint.value_slot_bool", Type::Text => "hint.value_slot_text", Type::Date => "hint.value_slot_date", Type::DateTime => "hint.value_slot_datetime", Type::Blob => "hint.value_slot_blob", Type::Serial => "hint.value_slot_serial", Type::ShortId => "hint.value_slot_shortid", } } /// What the grammar would accept at the end of `source` /// (ADR-0024 §architecture, Phase F walker-driven completion). /// /// Empty / whitespace-only input yields every command-entry word /// as `Expectation::Word(primary)`. Otherwise the walker is /// driven to `EndOfInput`; if the input completes a command, /// the result is empty; if it fails or is incomplete, the /// walker's expected-set surfaces verbatim — `Ident { source, /// role }` carries its `IdentSource` (so the completion engine /// can schema-look-up without a string round-trip), `Word` / /// `Literal` carry their primary literal, etc. /// /// Inputs whose first token is not a registered entry word /// fall back to listing every entry word — matches the /// synthetic "unknown command" expectation set the parser /// produces. #[must_use] pub fn expected_at_input(source: &str) -> Vec { use crate::dsl::grammar::REGISTRY; if source.trim().is_empty() { return REGISTRY .iter() .map(|c| outcome::Expectation::Word(c.entry.primary)) .collect(); } let mut ctx = context::WalkContext::new(); let (result, _cmd) = walk(source, outcome::WalkBound::EndOfInput, &mut ctx); let Some(result) = result else { // Walker didn't engage (unknown entry word): the // completion engine should still surface the available // entry words so the user can recover. return REGISTRY .iter() .map(|c| outcome::Expectation::Word(c.entry.primary)) .collect(); }; match result.outcome { // On Match, surface the outer-shape's skipped-Optional // expectations so the completion engine can offer // optional-suffix candidates at the end of a valid // command (`save` → `as`, etc.). outcome::WalkOutcome::Match { .. } => result.tail_expected, outcome::WalkOutcome::Incomplete { expected, .. } | outcome::WalkOutcome::Mismatch { expected, .. } => expected, outcome::WalkOutcome::ValidationFailed { .. } => Vec::new(), } } /// Strict-required expected set at the end of `source`, plus /// the walker's `pending_value_type` at the cursor. /// /// Like `expected_at_input` but returns empty on /// `WalkOutcome::Match` — optional-suffix continuations are not /// surfaced. Used by the hint resolver to distinguish "must /// type more" from "could continue", and to dispatch per-type /// prose when the cursor is inside a typed value slot. fn expected_for_hint_with_ctx( source: &str, schema: Option<&crate::completion::SchemaCache>, ) -> (Vec, Option) { use crate::dsl::grammar::REGISTRY; if source.trim().is_empty() { let expected = REGISTRY .iter() .map(|c| outcome::Expectation::Word(c.entry.primary)) .collect(); return (expected, None); } let mut ctx = schema.map_or_else(context::WalkContext::new, |s| { context::WalkContext::with_schema(s) }); let (result, _cmd) = walk(source, outcome::WalkBound::EndOfInput, &mut ctx); let Some(result) = result else { let expected = REGISTRY .iter() .map(|c| outcome::Expectation::Word(c.entry.primary)) .collect(); return (expected, None); }; let expected = match result.outcome { outcome::WalkOutcome::Match { .. } | outcome::WalkOutcome::ValidationFailed { .. } => { Vec::new() } outcome::WalkOutcome::Incomplete { expected, .. } | outcome::WalkOutcome::Mismatch { expected, .. } => expected, }; (expected, ctx.pending_value_type) } /// Public walk entry. `bound` is `EndOfInput` for parse; /// `Position(cursor)` for completion / hint (Phase A: not yet /// wired). /// /// Returns: /// - `(Some(WalkResult), Some(Command))` on full match — the /// AST builder produced a typed Command. /// - `(Some(WalkResult), None)` on failure where the walker /// committed (matched the entry word). Caller surfaces the /// walker's error. /// - `(None, None)` when the entry word doesn't match any /// registered command — the router falls through to chumsky. pub fn walk<'a>( source: &str, bound: WalkBound, ctx: &mut WalkContext<'a>, ) -> (Option, Option) { // Phase A only consumes EndOfInput; Position would slice // the source, which is the same operation. let effective_source: &str = match bound { WalkBound::EndOfInput => source, WalkBound::Position(end) => &source[..end.min(source.len())], }; let start = skip_whitespace(effective_source, 0); if start >= effective_source.len() { return (None, None); } // Identify the command by its entry word. If the first // identifier-shape token isn't a registered entry, the // walker yields to chumsky. let Some((kw_start, kw_end)) = consume_ident(effective_source, start) else { return (None, None); }; let entry_text = &effective_source[kw_start..kw_end]; let Some((command_idx, command_node)) = grammar::command_for_entry_word(entry_text) else { return (None, None); }; let mut path = MatchedPath::new(); let mut per_byte = Vec::new(); // Record the entry-word match. path.push(crate::dsl::walker::outcome::MatchedItem { kind: crate::dsl::walker::outcome::MatchedKind::Word(command_node.entry.primary), text: entry_text.to_string(), span: (kw_start, kw_end), }); per_byte.push(crate::dsl::walker::outcome::ByteClass { start: kw_start, end: kw_end, class: grammar::HighlightClass::Keyword, }); let mut tail_expected: Vec = Vec::new(); let outcome = match walk_node( effective_source, kw_end, &command_node.shape, ctx, &mut path, &mut per_byte, ) { NodeWalkResult::Matched { end, skipped } => { // Carry the outer shape's skipped-Optional // expectations into WalkResult so completion can // surface optional-suffix candidates (`save` → // `as`). Empty for shapes with no trailing // optionals. tail_expected = skipped; NodeWalkResult::Matched { end, skipped: Vec::new(), } } other => other, }; let outcome = match outcome { NodeWalkResult::Matched { end, .. } => { let trailing = skip_whitespace(effective_source, end); if trailing < effective_source.len() { // The shape matched but the user kept typing. // Don't merge skipped-Optional expectations // into the trailing-input error: the completion // engine reads `expected` to decide what to // suggest, and adding "what could have come // before this trailing token" would suggest // candidates the user has already passed. WalkOutcome::Mismatch { position: trailing, expected: vec![Expectation::EndOfInput], } } else { WalkOutcome::Match { command_idx } } } NodeWalkResult::NoMatch { position, expected } => { // The shape required content the user hasn't typed. // (Optional/empty-Seq shapes always return Matched // even when skipped, so reaching NoMatch here means // the command really wanted something more.) let post = skip_whitespace(effective_source, position); if post >= effective_source.len() { WalkOutcome::Incomplete { position: post, expected } } else { WalkOutcome::Mismatch { position: post, expected } } } NodeWalkResult::Incomplete { position, expected } => { WalkOutcome::Incomplete { position, expected } } NodeWalkResult::Failed { position, kind } => match kind { FailureKind::Mismatch { expected } => { WalkOutcome::Mismatch { position, expected } } FailureKind::Validation(error) => { WalkOutcome::ValidationFailed { position, error } } }, }; // 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: final_outcome, matched_path: path, per_byte_class: per_byte, tail_expected, }; (Some(result), cmd) } #[cfg(test)] mod tests { //! Walker behaviour tests — Phase A (ADR-0024 §migration). //! //! These cover every app-lifecycle command the walker now //! owns. Each input is paired with its expected `Command` //! output (the differential-against-chumsky check //! materialised as hand-curated expectations — same role //! the differential test scaffolding plays per ADR-0024 //! §test-discipline). //! //! The handoff document lists these tests as "walker- //! specific tests for trie-only features" — they pin down //! the walker's contract for the migrated commands so //! Phase B-F migrations can refactor without regression. use crate::dsl::command::{AppCommand, Command, MessagesValue, ModeValue}; use crate::dsl::parser::parse_command; fn parse(input: &str) -> Result { parse_command(input) } // ---- Bare no-arg commands --------------------------------- #[test] fn walker_parses_quit() { assert_eq!(parse("quit").unwrap(), Command::App(AppCommand::Quit)); } #[test] fn walker_parses_help() { assert_eq!(parse("help").unwrap(), Command::App(AppCommand::Help)); } #[test] fn walker_parses_rebuild() { assert_eq!(parse("rebuild").unwrap(), Command::App(AppCommand::Rebuild)); } #[test] fn walker_parses_new() { assert_eq!(parse("new").unwrap(), Command::App(AppCommand::New)); } #[test] fn walker_parses_load() { assert_eq!(parse("load").unwrap(), Command::App(AppCommand::Load)); } // ---- Save / save as --------------------------------------- #[test] fn walker_parses_save() { assert_eq!(parse("save").unwrap(), Command::App(AppCommand::Save)); } #[test] fn walker_parses_save_as() { assert_eq!(parse("save as").unwrap(), Command::App(AppCommand::SaveAs)); } #[test] fn walker_save_keywords_case_insensitive() { assert_eq!(parse("SAVE").unwrap(), Command::App(AppCommand::Save)); assert_eq!(parse("Save AS").unwrap(), Command::App(AppCommand::SaveAs)); } // ---- Mode ------------------------------------------------- #[test] fn walker_parses_mode_simple() { assert_eq!( parse("mode simple").unwrap(), Command::App(AppCommand::Mode { value: ModeValue::Simple, }) ); } #[test] fn walker_parses_mode_advanced() { assert_eq!( parse("mode advanced").unwrap(), Command::App(AppCommand::Mode { value: ModeValue::Advanced, }) ); } #[test] fn walker_mode_unknown_value_emits_friendly_error() { let err = parse("mode foo").unwrap_err(); match err { crate::dsl::ParseError::Invalid { message, .. } => { // The catalog wording for `mode.unknown` carries // the user's value verbatim. assert!(message.contains("foo"), "got: {message}"); } other => panic!("expected Invalid, got {other:?}"), } } // ---- Messages --------------------------------------------- #[test] fn walker_parses_messages_bare() { assert_eq!( parse("messages").unwrap(), Command::App(AppCommand::Messages { value: None }) ); } #[test] fn walker_parses_messages_short() { assert_eq!( parse("messages short").unwrap(), Command::App(AppCommand::Messages { value: Some(MessagesValue::Short), }) ); } #[test] fn walker_parses_messages_verbose() { assert_eq!( parse("messages verbose").unwrap(), Command::App(AppCommand::Messages { value: Some(MessagesValue::Verbose), }) ); } #[test] fn walker_messages_unknown_value_emits_friendly_error() { let err = parse("messages bogus").unwrap_err(); match err { crate::dsl::ParseError::Invalid { message, .. } => { assert!(message.contains("bogus"), "got: {message}"); } other => panic!("expected Invalid, got {other:?}"), } } // ---- Export ----------------------------------------------- #[test] fn walker_parses_export_bare() { assert_eq!( parse("export").unwrap(), Command::App(AppCommand::Export { path: None }) ); } #[test] fn walker_parses_export_with_path() { assert_eq!( parse("export backups/MyExport.zip").unwrap(), Command::App(AppCommand::Export { path: Some("backups/MyExport.zip".to_string()), }) ); } #[test] fn walker_export_trims_trailing_whitespace() { // Pre-migration the source-slice helper trimmed; the // walker treats " " after `export` as zero BarePath // matches and produces the bare form. assert_eq!( parse("export ").unwrap(), Command::App(AppCommand::Export { path: None }) ); } // ---- Import ----------------------------------------------- #[test] fn walker_parses_import_bare() { assert_eq!( parse("import").unwrap(), Command::App(AppCommand::Import { path: String::new(), target: None, }) ); } #[test] fn walker_parses_import_with_path() { assert_eq!( parse("import some/file.zip").unwrap(), Command::App(AppCommand::Import { path: "some/file.zip".to_string(), target: None, }) ); } #[test] fn walker_parses_import_with_path_and_target() { assert_eq!( parse("import some/file.zip as MyImported").unwrap(), Command::App(AppCommand::Import { path: "some/file.zip".to_string(), target: Some("MyImported".to_string()), }) ); } #[test] fn walker_import_keeps_as_inside_path() { // The lexer-free walker terminates `BarePath` at the // first whitespace byte. `path/asfile.zip` is one // token; the `as` *inside* it stays part of the path. assert_eq!( parse("import path/asfile.zip").unwrap(), Command::App(AppCommand::Import { path: "path/asfile.zip".to_string(), target: None, }) ); } #[test] fn walker_import_trailing_as_without_target_errors() { // Phase B Optional-backtracking: when the user types // `import foo.zip as ` and stops, the inner Optional // `(as )` partial-matches `as` then runs out // of input → backtracks (matches chumsky's `or_not` // semantics). The walker reports a successful parse of // `import foo.zip` followed by trailing `as ` → a // structural Mismatch with expected=`end of input`. // The friendly "import: empty target after `as`" // wording is no longer produced by the walker, but the // integration test // (`import_with_empty_target_after_as_errors`) still // passes because the rendered `import_usage` template // line in the dispatch output contains both "import" // and "target". let err = parse("import foo.zip as ").unwrap_err(); match err { crate::dsl::ParseError::Invalid { message, .. } => { assert!( message.contains("import"), "expected `import` in 'after ``' framing; got: {message}" ); } other => panic!("expected Invalid, got {other:?}"), } } // ---- Routing fall-through --------------------------------- #[test] fn walker_does_not_engage_for_non_app_keywords() { // The router falls through to the chumsky path. The // existing chumsky parser produces this Command. assert!(matches!( parse("drop table Customers").unwrap(), Command::DropTable { .. } )); } #[test] fn walker_does_not_engage_for_unknown_first_token() { // Not an entry word — chumsky yields its usual // unknown-command error. assert!(parse("frobulate").is_err()); } // ---- Trailing-garbage detection --------------------------- #[test] fn walker_quit_with_trailing_garbage_errors() { assert!(parse("quit nonsense").is_err()); } #[test] fn walker_save_with_trailing_garbage_errors() { assert!(parse("save Customers").is_err()); } // ---- Whitespace tolerance --------------------------------- #[test] fn walker_tolerates_leading_and_internal_whitespace() { assert_eq!(parse(" quit ").unwrap(), Command::App(AppCommand::Quit)); assert_eq!( parse("save as").unwrap(), Command::App(AppCommand::SaveAs) ); assert_eq!( parse("mode\tadvanced").unwrap(), Command::App(AppCommand::Mode { value: ModeValue::Advanced, }) ); } // ========================================================= // 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:?}"), } } // ========================================================= // Phase C — create table. // ========================================================= use crate::dsl::command::ColumnSpec; fn col(name: &str, ty: Type) -> ColumnSpec { ColumnSpec { name: name.to_string(), ty, } } #[test] fn walker_parses_create_table_with_pk_default_id_serial() { assert_eq!( parse("create table Customers with pk").unwrap(), Command::CreateTable { name: "Customers".to_string(), columns: vec![col("id", Type::Serial)], primary_key: vec!["id".to_string()], } ); } #[test] fn walker_parses_create_table_named_typed_pk() { assert_eq!( parse("create table Customers with pk email:text").unwrap(), Command::CreateTable { name: "Customers".to_string(), columns: vec![col("email", Type::Text)], primary_key: vec!["email".to_string()], } ); } #[test] fn walker_parses_create_table_compound_pk() { assert_eq!( parse("create table OrderLines with pk order_id:int,product_id:int").unwrap(), 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 walker_create_table_pk_tolerates_whitespace_around_punct() { assert_eq!( parse("create table T with pk id : serial").unwrap(), Command::CreateTable { name: "T".to_string(), columns: vec![col("id", Type::Serial)], primary_key: vec!["id".to_string()], } ); assert_eq!( parse("create table T with pk a : int , b : int").unwrap(), 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 walker_bare_create_table_errors_with_with_pk_hint() { let err = parse("create table Customers").unwrap_err(); match err { crate::dsl::ParseError::Invalid { message, .. } => { assert!( message.contains("with pk"), "error should mention `with pk`:\n{message}" ); } other => panic!("expected Invalid, got {other:?}"), } } #[test] fn walker_create_table_keywords_are_case_insensitive() { assert_eq!( parse("CREATE TABLE Customers WITH PK email:TEXT").unwrap(), Command::CreateTable { name: "Customers".to_string(), columns: vec![col("email", Type::Text)], primary_key: vec!["email".to_string()], } ); } // ========================================================= // Phase D — data commands (show, insert, update, delete). // ========================================================= use crate::dsl::value::Value; use crate::dsl::command::RowFilter; #[test] fn walker_parses_show_data() { assert_eq!( parse("show data Customers").unwrap(), Command::ShowData { name: "Customers".to_string() } ); } #[test] fn walker_parses_show_table() { assert_eq!( parse("show table Customers").unwrap(), Command::ShowTable { name: "Customers".to_string() } ); } #[test] fn walker_parses_insert_with_explicit_column_list() { assert_eq!( parse("insert into Customers (Email, Name) values ('a@b.c', 'Alice')").unwrap(), Command::Insert { table: "Customers".to_string(), columns: Some(vec!["Email".to_string(), "Name".to_string()]), values: vec![Value::Text("a@b.c".to_string()), Value::Text("Alice".to_string())], } ); } #[test] fn walker_parses_insert_with_values_keyword_only() { assert_eq!( parse("insert into Customers values (1, 'Alice', null)").unwrap(), Command::Insert { table: "Customers".to_string(), columns: None, values: vec![ Value::Number("1".to_string()), Value::Text("Alice".to_string()), Value::Null, ], } ); } #[test] fn walker_parses_insert_short_form_without_column_list() { assert_eq!( parse("insert into Customers (1, 'Alice', true)").unwrap(), Command::Insert { table: "Customers".to_string(), columns: None, values: vec![ Value::Number("1".to_string()), Value::Text("Alice".to_string()), Value::Bool(true), ], } ); } #[test] fn walker_parses_insert_supports_negative_numbers() { assert_eq!( parse("insert into T values (-5)").unwrap(), Command::Insert { table: "T".to_string(), columns: None, values: vec![Value::Number("-5".to_string())], } ); } #[test] fn walker_parses_update_with_where() { assert_eq!( parse("update Customers set Email='new@b.c' where id=1").unwrap(), Command::Update { table: "Customers".to_string(), assignments: vec![("Email".to_string(), Value::Text("new@b.c".to_string()))], filter: RowFilter::Where { column: "id".to_string(), value: Value::Number("1".to_string()), }, } ); } #[test] fn walker_parses_update_with_multiple_assignments() { assert_eq!( parse("update Customers set Email='a@b.c', Name='Alice' where id=1").unwrap(), Command::Update { table: "Customers".to_string(), assignments: vec![ ("Email".to_string(), Value::Text("a@b.c".to_string())), ("Name".to_string(), Value::Text("Alice".to_string())), ], filter: RowFilter::Where { column: "id".to_string(), value: Value::Number("1".to_string()), }, } ); } #[test] fn walker_parses_update_with_all_rows_flag() { assert_eq!( parse("update Customers set Active=true --all-rows").unwrap(), Command::Update { table: "Customers".to_string(), assignments: vec![("Active".to_string(), Value::Bool(true))], filter: RowFilter::AllRows, } ); } #[test] fn walker_parses_delete_with_where() { assert_eq!( parse("delete from Customers where id=42").unwrap(), Command::Delete { table: "Customers".to_string(), filter: RowFilter::Where { column: "id".to_string(), value: Value::Number("42".to_string()), }, } ); } #[test] fn walker_parses_delete_with_all_rows() { assert_eq!( parse("delete from Customers --all-rows").unwrap(), Command::Delete { table: "Customers".to_string(), filter: RowFilter::AllRows, } ); } #[test] fn walker_delete_without_where_or_flag_errors() { assert!(parse("delete from Customers").is_err()); } #[test] fn walker_update_without_where_or_flag_errors() { assert!(parse("update Customers set Email='x'").is_err()); } // ========================================================= // Phase E — replay. // ========================================================= #[test] fn walker_parses_replay_with_bare_relative_path() { assert_eq!( parse("replay history.log").unwrap(), Command::Replay { path: "history.log".to_string(), } ); } #[test] fn walker_parses_replay_with_bare_absolute_path() { assert_eq!( parse("replay /tmp/seed.commands").unwrap(), Command::Replay { path: "/tmp/seed.commands".to_string(), } ); } #[test] fn walker_parses_replay_with_quoted_path_supports_whitespace() { // Phase A's path-bearing UX change: paths with spaces use // the quoted form. assert_eq!( parse("replay 'my project/seed.commands'").unwrap(), Command::Replay { path: "my project/seed.commands".to_string(), } ); } #[test] fn walker_parses_replay_with_quoted_path_supports_escaped_quote() { assert_eq!( parse("replay 'O''Brien.commands'").unwrap(), Command::Replay { path: "O'Brien.commands".to_string(), } ); } #[test] fn walker_replay_keyword_case_insensitive() { assert_eq!( parse("REPLAY foo.txt").unwrap(), Command::Replay { path: "foo.txt".to_string(), } ); } #[test] fn walker_replay_without_path_errors() { assert!(parse("replay").is_err()); } #[test] fn walker_replay_with_empty_quoted_path_parses_as_empty() { // Parser layer accepts; runtime rejects empty paths // before any I/O. Mirrors the chumsky-side contract // (parser.rs `replay_with_empty_quoted_path_errors`). assert_eq!( parse("replay ''").unwrap(), Command::Replay { path: String::new(), } ); } // ========================================================= // hint_mode_at_input (ADR-0024 §HintMode-per-node) // ========================================================= use crate::dsl::grammar::HintMode; use super::hint_mode_at_input; #[test] fn hint_mode_value_literal_slot_after_insert_open_paren() { // `insert into T (` expects a value-literal or column // ident at the inner position. After `values (` it's // strictly value-literals — the signature triggers // ProseOnly. match hint_mode_at_input("insert into T values (") { Some(HintMode::ProseOnly("hint.value_literal_slot")) => {} other => panic!("expected ProseOnly value_literal_slot, got {other:?}"), } } #[test] fn hint_mode_value_literal_slot_after_update_set_assign() { match hint_mode_at_input("update T set col=") { Some(HintMode::ProseOnly("hint.value_literal_slot")) => {} other => panic!("expected ProseOnly value_literal_slot, got {other:?}"), } } #[test] fn hint_mode_value_literal_slot_in_where_clause() { match hint_mode_at_input("delete from T where col=") { Some(HintMode::ProseOnly("hint.value_literal_slot")) => {} other => panic!("expected ProseOnly value_literal_slot, got {other:?}"), } } #[test] fn hint_mode_new_name_slot_for_create_table() { // `create table ` expects a NewName ident. match hint_mode_at_input("create table ") { Some(HintMode::ForceProse("hint.ambient_typing_name")) => {} other => panic!("expected ForceProse typing_name, got {other:?}"), } } #[test] fn hint_mode_new_name_slot_for_add_column_name() { // `add column T: ` expects a NewName ident. match hint_mode_at_input("add column to table T: ") { Some(HintMode::ForceProse("hint.ambient_typing_name")) => {} other => panic!("expected ForceProse typing_name, got {other:?}"), } } #[test] fn hint_mode_none_for_keyword_position() { // Entry-keyword position: no HintMode override applies. assert!(hint_mode_at_input("").is_none()); assert!(hint_mode_at_input("cr").is_none()); } #[test] fn hint_mode_none_for_complete_command() { // Valid complete command: no expected, no override. assert!(hint_mode_at_input("create table T with pk").is_none()); } #[test] fn hint_mode_none_at_schema_ident_slot() { // `show data ` expects a table-name ident from the // schema — schema-listable slot, not a HintMode case. assert!(hint_mode_at_input("show data ").is_none()); } // ========================================================= // Phase D full — schema-aware value typing. // ========================================================= use crate::completion::{SchemaCache, TableColumn}; use crate::dsl::parser::parse_command_with_schema; fn schema_with(table: &str, columns: &[(&str, Type)]) -> SchemaCache { let cols: Vec = columns .iter() .map(|(n, t)| TableColumn { name: (*n).to_string(), user_type: *t, }) .collect(); let mut cache = SchemaCache::default(); cache.tables.push(table.to_string()); for c in &cols { cache.columns.push(c.name.clone()); } cache.table_columns.insert(table.to_string(), cols); cache } #[test] fn phase_d_insert_with_schema_accepts_typed_values_per_column() { let schema = schema_with( "Customers", &[("id", Type::Serial), ("Name", Type::Text), ("Active", Type::Bool)], ); // 3 columns: int, text, bool. Each value matches its slot. let cmd = parse_command_with_schema( "insert into Customers values (1, 'Alice', true)", &schema, ) .expect("parse"); match cmd { Command::Insert { table, values, .. } => { assert_eq!(table, "Customers"); assert_eq!(values.len(), 3); } other => panic!("expected Insert, got {other:?}"), } } #[test] fn phase_d_insert_rejects_decimal_in_int_column() { // The schema has `id` as Int. `3.14` is a Number with a // decimal — the typed `int_slot` validator rejects. let schema = schema_with("T", &[("id", Type::Int)]); let err = parse_command_with_schema("insert into T values (3.14)", &schema) .expect_err("should reject"); match err { crate::dsl::ParseError::Invalid { message, .. } => { assert!( message.contains("integer") || message.contains("3.14"), "got: {message}" ); } other => panic!("expected Invalid, got {other:?}"), } } #[test] fn phase_d_insert_accepts_null_at_any_column() { // null is the absence sentinel; every typed slot // accepts it. let schema = schema_with( "T", &[("a", Type::Int), ("b", Type::Text), ("c", Type::Bool)], ); let cmd = parse_command_with_schema( "insert into T values (null, null, null)", &schema, ) .expect("parse"); match cmd { Command::Insert { values, .. } => { assert!(values.iter().all(|v| matches!(v, Value::Null))); } other => panic!("expected Insert, got {other:?}"), } } #[test] fn phase_d_insert_falls_back_when_table_not_in_schema() { // The schema is empty; the walker can't resolve column // info for `Customers`. The DynamicSubgrammar falls // back to the schemaless generic value-literal list and // accepts mixed-shape values as it did pre-Phase-D. let schema = SchemaCache::default(); let cmd = parse_command_with_schema( "insert into Customers values (1, 'Alice')", &schema, ) .expect("parse — fallback path"); match cmd { Command::Insert { values, .. } => assert_eq!(values.len(), 2), other => panic!("expected Insert, got {other:?}"), } } #[test] fn phase_d_schemaless_parse_command_still_works() { // The pre-Phase-D `parse_command(input)` signature // passes no schema; the DynamicSubgrammar falls back to // the schemaless value-literal list. let cmd = parse("insert into T values (1, 'Alice', null)").expect("parse"); match cmd { Command::Insert { values, .. } => assert_eq!(values.len(), 3), other => panic!("expected Insert, got {other:?}"), } } #[test] fn phase_d_insert_accepts_bool_value_for_bool_column() { let schema = schema_with("T", &[("flag", Type::Bool)]); let cmd = parse_command_with_schema("insert into T values (false)", &schema) .expect("parse"); match cmd { Command::Insert { values, .. } => { assert_eq!(values, vec![Value::Bool(false)]); } other => panic!("expected Insert, got {other:?}"), } } #[test] fn phase_d_update_accepts_text_value_for_text_column() { let schema = schema_with( "Customers", &[("id", Type::Int), ("Email", Type::Text)], ); let cmd = parse_command_with_schema( "update Customers set Email='new@b.c' where id=1", &schema, ) .expect("parse"); match cmd { Command::Update { assignments, .. } => { assert_eq!(assignments.len(), 1); assert_eq!(assignments[0].0, "Email"); } other => panic!("expected Update, got {other:?}"), } } #[test] fn phase_d_update_rejects_decimal_in_int_set_column() { // Email is text; Score is int. Assigning `3.14` to Score // hits the int_slot validator. let schema = schema_with( "T", &[("id", Type::Int), ("Score", Type::Int)], ); let err = parse_command_with_schema( "update T set Score=3.14 where id=1", &schema, ) .expect_err("should reject"); match err { crate::dsl::ParseError::Invalid { message, .. } => { assert!( message.contains("integer") || message.contains("3.14"), "got: {message}" ); } other => panic!("expected Invalid, got {other:?}"), } } #[test] fn phase_d_delete_where_uses_typed_column_value() { // `where id=1` — id is Int; `1` matches the int_slot. let schema = schema_with("T", &[("id", Type::Int), ("Name", Type::Text)]); let cmd = parse_command_with_schema("delete from T where id=1", &schema) .expect("parse"); match cmd { Command::Delete { .. } => {} other => panic!("expected Delete, got {other:?}"), } } #[test] fn phase_d_delete_where_rejects_decimal_at_int_column() { // `where id=3.14` — id is Int; the typed slot rejects. let schema = schema_with("T", &[("id", Type::Int)]); let err = parse_command_with_schema("delete from T where id=3.14", &schema) .expect_err("should reject"); match err { crate::dsl::ParseError::Invalid { message, .. } => { assert!( message.contains("integer") || message.contains("3.14"), "got: {message}" ); } other => panic!("expected Invalid, got {other:?}"), } } // ---- Typed-slot HintMode (Phase D + HintMode dispatch) ---- use crate::dsl::walker::hint_mode_at_input_with_schema; #[test] fn typed_hint_at_insert_first_value_position_for_int_column() { let schema = schema_with( "Customers", &[("id", Type::Int), ("Name", Type::Text)], ); match hint_mode_at_input_with_schema("insert into Customers values (", &schema) { Some(HintMode::ProseOnly("hint.value_slot_int")) => {} other => panic!("expected ProseOnly value_slot_int, got {other:?}"), } } #[test] fn typed_hint_at_insert_second_value_position_for_text_column() { let schema = schema_with( "Customers", &[("id", Type::Int), ("Name", Type::Text)], ); match hint_mode_at_input_with_schema("insert into Customers values (1, ", &schema) { Some(HintMode::ProseOnly("hint.value_slot_text")) => {} other => panic!("expected ProseOnly value_slot_text, got {other:?}"), } } #[test] fn typed_hint_at_update_set_value_uses_column_type() { let schema = schema_with( "Customers", &[("id", Type::Int), ("Email", Type::Text)], ); match hint_mode_at_input_with_schema("update Customers set Email=", &schema) { Some(HintMode::ProseOnly("hint.value_slot_text")) => {} other => panic!("expected ProseOnly value_slot_text, got {other:?}"), } } #[test] fn typed_hint_at_update_set_value_for_int_column() { let schema = schema_with( "Customers", &[("id", Type::Int), ("Score", Type::Int)], ); match hint_mode_at_input_with_schema("update Customers set Score=", &schema) { Some(HintMode::ProseOnly("hint.value_slot_int")) => {} other => panic!("expected ProseOnly value_slot_int, got {other:?}"), } } #[test] fn typed_hint_at_where_value_uses_column_type() { let schema = schema_with("Events", &[("ts", Type::DateTime)]); match hint_mode_at_input_with_schema("delete from Events where ts=", &schema) { Some(HintMode::ProseOnly("hint.value_slot_datetime")) => {} other => panic!("expected ProseOnly value_slot_datetime, got {other:?}"), } } #[test] fn typed_hint_falls_back_to_generic_when_schema_missing() { // Empty schema: walker can't resolve column types. let schema = SchemaCache::default(); match hint_mode_at_input_with_schema("insert into T values (", &schema) { Some(HintMode::ProseOnly("hint.value_literal_slot")) => {} other => panic!("expected generic ProseOnly, got {other:?}"), } } #[test] fn typed_hint_not_emitted_after_complete_value() { // `insert into T values (1` — the int slot just MATCHED // (`1` is a valid int). Pending_value_type was cleared on // the successful match. No hint at this position // (between values). let schema = schema_with("T", &[("id", Type::Int)]); // Walker is now waiting for `,` or `)`. No HintMode. let mode = hint_mode_at_input_with_schema("insert into T values (1", &schema); // The current position isn't a typed slot; expected is // `,` / `)`. No HintMode fires. assert!(mode.is_none(), "got {mode:?}"); } #[test] fn typed_hint_for_each_type_routes_to_correct_catalog_key() { // Confirm each Type maps to its expected catalog key // via insert at a single-column table. for (ty, key) in [ (Type::Int, "hint.value_slot_int"), (Type::Real, "hint.value_slot_real"), (Type::Decimal, "hint.value_slot_decimal"), (Type::Bool, "hint.value_slot_bool"), (Type::Text, "hint.value_slot_text"), (Type::Date, "hint.value_slot_date"), (Type::DateTime, "hint.value_slot_datetime"), (Type::Blob, "hint.value_slot_blob"), (Type::Serial, "hint.value_slot_serial"), (Type::ShortId, "hint.value_slot_shortid"), ] { let schema = schema_with("T", &[("c", ty)]); let mode = hint_mode_at_input_with_schema("insert into T values (", &schema); assert!( matches!(mode, Some(HintMode::ProseOnly(k)) if k == key), "expected ProseOnly({key}) for type {ty:?}, got {mode:?}", ); } } #[test] fn phase_d_update_multi_assignment_uses_per_column_types() { let schema = schema_with( "Customers", &[ ("id", Type::Int), ("Name", Type::Text), ("Score", Type::Int), ], ); // `Score=42` (int slot) and `Name='Alice'` (text slot) // — each value slot dispatches on the column whose // ident matched immediately before. let cmd = parse_command_with_schema( "update Customers set Score=42, Name='Alice' where id=1", &schema, ) .expect("parse"); match cmd { Command::Update { assignments, .. } => { assert_eq!(assignments.len(), 2); assert_eq!(assignments[0].0, "Score"); assert_eq!(assignments[1].0, "Name"); } other => panic!("expected Update, got {other:?}"), } } }