//! 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, CompareOp, Expr, Operand, Predicate, RowFilter, }; 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; pub use outcome::{Diagnostic, Severity}; /// 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)) } /// Resolution of the hint-panel mode at the cursor, plus the /// column name (if known) the cursor's value slot is keyed on. /// /// Returned by [`hint_resolution_at_input`]. The renderer /// composes per-column prose ("for `Email`: Type a quoted /// string …") when `column` is `Some`. #[derive(Debug, Clone, PartialEq, Eq)] pub struct HintResolution { pub mode: crate::dsl::grammar::HintMode, pub column: Option, /// Auto-generated columns (serial / shortid) that Form B /// `insert into values (…)` silently skips from the /// value list (ADR-0018 §3). Populated *only* at the first /// value slot of a Form B insert whose table has such /// columns — empty everywhere else. The renderer appends a /// pedagogical note pointing the user at Form A so the /// skipped column is discoverable without reading help /// (handoff-12 §2.2). pub form_b_autogen_skipped: Vec, } /// Single-walk hint resolver (ADR-0024 §Phase D §typed-value-slots). /// /// Walks `source` against `schema`, then reports both the /// resolved `HintMode` and the walker's `pending_value_column` /// (if any). Returns `None` when no HintMode applies. #[must_use] pub fn hint_resolution_at_input( source: &str, schema: Option<&crate::completion::SchemaCache>, ) -> Option { use crate::dsl::grammar::HintMode; let snap = expected_for_hint_snapshot(source, schema); // Empty expected set means the command is already complete // (`WalkOutcome::Match`) — no slot to hint at. if snap.expected.is_empty() { return None; } // Typed value slot: the walker tagged `pending_value_type` // on entry to a `Node::TypedValueSlot`. Per-column-type // prose, narrowed by the column's user-facing type, plus // the Form B auto-gen pedagogical note. if let Some(ty) = snap.pending_value_type { return Some(HintResolution { mode: HintMode::ProseOnly(catalog_key_for_value_type(ty)), form_b_autogen_skipped: form_b_autogen_skipped( source, snap.user_listed_columns.as_ref(), snap.current_table_columns.as_ref(), snap.pending_value_column.as_deref(), ), column: snap.pending_value_column, }); } // Node-attached HintMode (ADR-0024 §HintMode-per-node): the // grammar declares the mode at the slot via `Node::Hinted`; // the walker recorded it in `pending_hint_mode`. The hint // resolver reads it directly — no signature-matching on the // shape of the expected set. `ProseOnly` covers the // value-literal fallback slot; `ForceProse` covers `NewName` // ident slots ("Type a name"). match snap.pending_hint_mode { Some(mode @ (HintMode::ProseOnly(_) | HintMode::ForceProse(_))) => { Some(HintResolution { mode, column: None, form_b_autogen_skipped: Vec::new(), }) } Some(HintMode::SuppressProse | HintMode::Default) | None => None, } } /// Auto-generated columns a Form B insert skips from its value /// list — but only when the cursor sits at the *first* value /// slot, so the pedagogical note fires once per command rather /// than at every comma. /// /// Returns empty unless: the command is an `insert`; no explicit /// column list was given (Form B — `user_listed` is `None`); the /// table has serial / shortid columns; and `pending_column` is /// the first non-auto-generated column (the first slot). fn form_b_autogen_skipped( source: &str, user_listed: Option<&Vec>, table_columns: Option<&Vec>, pending_column: Option<&str>, ) -> Vec { use crate::dsl::types::Type; // Form A (explicit column list) and non-insert commands // (`update T set …` value slots also leave user_listed // None) are excluded — the note is insert-Form-B only. if user_listed.is_some() { return Vec::new(); } if !source.trim_start().to_ascii_lowercase().starts_with("insert") { return Vec::new(); } let Some(cols) = table_columns else { return Vec::new(); }; let is_auto = |t: Type| matches!(t, Type::Serial | Type::ShortId); let skipped: Vec = cols .iter() .filter(|c| is_auto(c.user_type)) .map(|c| c.name.clone()) .collect(); if skipped.is_empty() { return Vec::new(); } // Fire only at the first value slot — i.e. when the slot's // column is the first non-auto-generated column. let first_non_auto = cols.iter().find(|c| !is_auto(c.user_type)); match (first_non_auto, pending_column) { (Some(first), Some(pending)) if first.name == pending => skipped, _ => Vec::new(), } } fn hint_mode_at_input_inner( source: &str, schema: Option<&crate::completion::SchemaCache>, ) -> Option { // Single source of truth: `hint_resolution_at_input` already // resolves the slot's HintMode (typed-value-slot per-type // prose, or the node-attached `Node::Hinted` annotation). // This thin wrapper just drops the resolution's column / // skip detail for callers that only need the mode. hint_resolution_at_input(source, schema).map(|r| r.mode) } 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", } } /// Completion-engine probe (ADR-0024 §Phase D §column-narrowing). /// /// Runs a single schema-aware walk and returns the structured /// pieces the completion engine needs: the expected set plus /// the table-context snapshot the engine reads to narrow /// column candidates to the active table. #[derive(Debug, Clone)] pub struct CompletionProbe { pub expected: Vec, /// Columns of `current_table` resolved at the cursor (set /// by an `Ident { source: Tables, writes_table: true }` /// earlier in the walk). `None` when the walker is /// schemaless or the table didn't resolve. pub current_table_columns: Option>, /// The grammar-declared `HintMode` at the cursor's slot /// (`Node::Hinted`), if any. A `ProseOnly` slot tells the /// completion engine to suppress its keyword candidates — /// the node-attached signal that supersedes the /// expected-set signature heuristic where the grammar /// explicitly marks a slot prose-only (e.g. the /// WHERE-expression operand, which also accepts a column /// reference — ADR-0026 §8). pub pending_hint_mode: Option, } /// Run a schema-aware walk and report the completion-engine's /// view (ADR-0024 §Phase D §column-narrowing). #[must_use] pub fn completion_probe( source: &str, schema: &crate::completion::SchemaCache, ) -> CompletionProbe { use crate::dsl::grammar::REGISTRY; if source.trim().is_empty() { return CompletionProbe { expected: REGISTRY .iter() .map(|c| outcome::Expectation::Word(c.entry.primary)) .collect(), current_table_columns: None, pending_hint_mode: None, }; } let mut ctx = context::WalkContext::with_schema(schema); let (result, _cmd) = walk(source, outcome::WalkBound::EndOfInput, &mut ctx); let Some(result) = result else { return CompletionProbe { expected: REGISTRY .iter() .map(|c| outcome::Expectation::Word(c.entry.primary)) .collect(), current_table_columns: None, pending_hint_mode: None, }; }; let expected = match result.outcome { outcome::WalkOutcome::Match { .. } => result.tail_expected, outcome::WalkOutcome::Incomplete { expected, .. } | outcome::WalkOutcome::Mismatch { expected, .. } => expected, // Validation failure path: the walker matched the // structural shape but the AST builder rejected (e.g. // Form C with column-shaped items). The walker still // captured the skipped-Optional expectations before the // validation fired — surface those so the user gets // useful Tab candidates even at a validation-flagged // position. outcome::WalkOutcome::ValidationFailed { .. } => result.tail_expected, }; CompletionProbe { expected, current_table_columns: ctx.current_table_columns, pending_hint_mode: ctx.pending_hint_mode, } } /// The validity-indicator verdict for `source` (ADR-0027 §3). /// /// `None` — the input would run clean (the indicator shows /// nothing); empty / whitespace-only input is also `None`. /// `Some(Error)` — pressing Enter now fails (a structural /// parse failure, or a schema-existence diagnostic). /// `Some(Warning)` — it runs, but is very likely not intended /// (the ADR-0026 expression flags). /// /// The verdict is the highest severity across the parse /// outcome and the `diagnostics` set (ADR-0027 §2). #[must_use] pub fn input_verdict( source: &str, schema: Option<&crate::completion::SchemaCache>, ) -> Option { use outcome::Severity; if source.trim().is_empty() { return None; } let mut ctx = schema.map_or_else( context::WalkContext::new, context::WalkContext::with_schema, ); let (result, _cmd) = walk(source, outcome::WalkBound::EndOfInput, &mut ctx); let Some(result) = result else { // The first token is not a registered command word — // typing this and pressing Enter fails. return Some(Severity::Error); }; let outcome_severity = match result.outcome { outcome::WalkOutcome::Match { .. } => None, _ => Some(Severity::Error), }; let diag_severity = result.diagnostics.iter().map(|d| d.severity).max(); outcome_severity.into_iter().chain(diag_severity).max() } /// Schema-existence diagnostics (ADR-0027 §2). /// /// A matched `IdentSource::Tables` token whose name is not in /// the schema — or a `Columns` token absent from the table in /// scope — is an ERROR: the command parses but would fail at /// execution. Runs only on a structural `Match`. /// /// Column scope is resolved by a single left-to-right pass: /// every command places its table ident before the columns /// that belong to it (a qualified `T.c` puts `T` immediately /// before `c`), so the most recent valid `Tables` ident is the /// table a subsequent `Columns` ident is checked against. An /// unknown table clears the scope, so its columns are not /// cascaded into a second diagnostic. fn schema_existence_diagnostics( path: &MatchedPath, schema: Option<&crate::completion::SchemaCache>, ) -> Vec { use crate::dsl::grammar::IdentSource; use outcome::{Diagnostic, MatchedKind, Severity}; let Some(schema) = schema else { return Vec::new(); }; let mut diagnostics = Vec::new(); let mut current_table: Option = None; for item in &path.items { let MatchedKind::Ident { source, .. } = item.kind else { continue; }; match source { IdentSource::Tables => { if schema_has_table(schema, &item.text) { current_table = Some(item.text.clone()); } else { current_table = None; diagnostics.push(Diagnostic { severity: Severity::Error, span: item.span, message: crate::friendly::translate( "diagnostic.unknown_table", &[("name", &item.text as &dyn std::fmt::Display)], ), }); } } IdentSource::Columns => { if let Some(table) = current_table.as_deref() && !schema_has_column(schema, table, &item.text) { diagnostics.push(Diagnostic { severity: Severity::Error, span: item.span, message: crate::friendly::translate( "diagnostic.unknown_column", &[ ("name", &item.text as &dyn std::fmt::Display), ("table", &table as &dyn std::fmt::Display), ], ), }); } } // Invented names (`NewName`), closed sets (`Types`), // and the other entity kinds are not schema-checked // here (ADR-0027 §2 scopes the check to tables and // columns). IdentSource::NewName | IdentSource::Relationships | IdentSource::Indexes | IdentSource::Types | IdentSource::Free => {} } } diagnostics } fn schema_has_table(schema: &crate::completion::SchemaCache, name: &str) -> bool { schema.tables.iter().any(|t| t.eq_ignore_ascii_case(name)) } fn schema_has_column( schema: &crate::completion::SchemaCache, table: &str, column: &str, ) -> bool { schema .columns_for_table(table) .is_some_and(|cols| cols.iter().any(|c| c.name.eq_ignore_ascii_case(column))) } /// The WHERE expression of a filter command, if it has one. const fn command_where_expr(command: &Command) -> Option<&Expr> { match command { Command::Update { filter: RowFilter::Where(expr), .. } | Command::Delete { filter: RowFilter::Where(expr), .. } | Command::ShowData { filter: Some(expr), .. } => Some(expr), _ => None, } } /// A coarse span covering the WHERE clause — from the `where` /// keyword to the end of input. The validity indicator reads /// only the severity; the span is for the (eventual) /// highlight overlay. fn where_clause_span(path: &MatchedPath, source_len: usize) -> (usize, usize) { path.items .iter() .find(|i| matches!(&i.kind, outcome::MatchedKind::Word("where"))) .map_or((0, source_len), |w| (w.span.0, source_len)) } /// WARNING diagnostics for a WHERE expression (ADR-0026 §7): /// a type-mismatched comparison, or `= NULL` / `!= NULL`. /// Both are valid and runnable — the warning is advisory. fn expr_warnings( expr: &Expr, columns: &[crate::completion::TableColumn], span: (usize, usize), ) -> Vec { let mut out = Vec::new(); collect_expr_warnings(expr, columns, span, &mut out); out } fn collect_expr_warnings( expr: &Expr, columns: &[crate::completion::TableColumn], span: (usize, usize), out: &mut Vec, ) { match expr { Expr::Or(terms) | Expr::And(terms) => { for term in terms { collect_expr_warnings(term, columns, span, out); } } Expr::Not(inner) => collect_expr_warnings(inner, columns, span, out), Expr::Predicate(predicate) => { predicate_warnings(predicate, columns, span, out); } } } fn predicate_warnings( predicate: &Predicate, columns: &[crate::completion::TableColumn], span: (usize, usize), out: &mut Vec, ) { use outcome::{Diagnostic, Severity}; let warn = |message: String| Diagnostic { severity: Severity::Warning, span, message, }; match predicate { Predicate::Compare { left, op, right } => { // `= NULL` / `!= NULL`: valid syntax that is never // true — the user almost certainly means IS NULL. if matches!(op, CompareOp::Eq | CompareOp::NotEq) && (is_null_literal(left) || is_null_literal(right)) { out.push(warn(crate::friendly::translate( "diagnostic.eq_null", &[], ))); } else if let Some(message) = pair_type_mismatch(left, right, columns) { out.push(warn(message)); } } Predicate::Between { target, low, high, .. } => { for bound in [low, high] { if let Some(message) = pair_type_mismatch(target, bound, columns) { out.push(warn(message)); } } } Predicate::In { target, items, .. } => { for item in items { if let Some(message) = pair_type_mismatch(target, item, columns) { out.push(warn(message)); } } } // `LIKE` is inherently a text-pattern test; flagging a // non-text target is a future model extension. // `IS [NOT] NULL` is the *correct* null test — never // flagged. Predicate::Like { .. } | Predicate::IsNull { .. } => {} } } const fn is_null_literal(operand: &Operand) -> bool { matches!(operand, Operand::Literal(crate::dsl::value::Value::Null)) } /// If one operand is a known column and the other a non-null /// literal whose type the column cannot hold, the message for /// a type-mismatch WARNING; otherwise `None` (column-to-column, /// literal-to-literal, an unknown column — already an ERROR — /// or a compatible pair). fn pair_type_mismatch( a: &Operand, b: &Operand, columns: &[crate::completion::TableColumn], ) -> Option { let (column, literal) = match (a, b) { (Operand::Column(c), Operand::Literal(v)) | (Operand::Literal(v), Operand::Column(c)) => (c, v), _ => return None, }; // `null` fits any column; `= NULL` is flagged separately. if matches!(literal, crate::dsl::value::Value::Null) { return None; } let ty = columns .iter() .find(|tc| tc.name.eq_ignore_ascii_case(column))? .user_type; if literal.bind_for_column(column, ty).is_ok() { return None; } Some(crate::friendly::translate( "diagnostic.type_mismatch", &[ ("column", column as &dyn std::fmt::Display), ("type", &ty.keyword() as &dyn std::fmt::Display), ], )) } /// 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, // Validation failure path: the walker matched the // structural shape but the AST builder rejected (e.g. // Form C with column-shaped items). The walker still // captured the skipped-Optional expectations before the // validation fired — surface those so the user gets // useful Tab candidates even at a validation-flagged // position. outcome::WalkOutcome::ValidationFailed { .. } => result.tail_expected, } } /// 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. /// Post-walk snapshot the hint resolver needs: the strict /// expected set plus the `WalkContext` fields that survive the /// walk and feed per-column / pedagogical prose. struct HintWalkSnapshot { expected: Vec, pending_value_type: Option, pending_value_column: Option, /// The grammar-declared `HintMode` at the cursor's slot /// (`Node::Hinted` annotation, ADR-0024 §HintMode-per-node). pending_hint_mode: Option, current_table_columns: Option>, /// `Some` when the input used Form A's explicit column list. /// `None` for Form B (`insert into T values …`) and for /// every non-insert command. user_listed_columns: Option>, } fn expected_for_hint_snapshot( source: &str, schema: Option<&crate::completion::SchemaCache>, ) -> HintWalkSnapshot { use crate::dsl::grammar::REGISTRY; let entry_words = || -> Vec { REGISTRY .iter() .map(|c| outcome::Expectation::Word(c.entry.primary)) .collect() }; let empty_snapshot = || HintWalkSnapshot { expected: entry_words(), pending_value_type: None, pending_value_column: None, pending_hint_mode: None, current_table_columns: None, user_listed_columns: None, }; if source.trim().is_empty() { return empty_snapshot(); } 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 { return empty_snapshot(); }; let expected = match result.outcome { outcome::WalkOutcome::Match { .. } | outcome::WalkOutcome::ValidationFailed { .. } => { Vec::new() } outcome::WalkOutcome::Incomplete { expected, .. } | outcome::WalkOutcome::Mismatch { expected, .. } => expected, }; HintWalkSnapshot { expected, pending_value_type: ctx.pending_value_type, pending_value_column: ctx.pending_value_column, pending_hint_mode: ctx.pending_hint_mode, current_table_columns: ctx.current_table_columns, user_listed_columns: ctx.user_listed_columns, } } /// 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), }; // Schema-existence diagnostics (ADR-0027 §2) layer on top // of a structurally-valid parse; a parse that already // failed gets its ERROR verdict from `outcome`. let mut diagnostics = if matches!(final_outcome, WalkOutcome::Match { .. }) { schema_existence_diagnostics(&path, ctx.schema) } else { Vec::new() }; // Expression WARNING diagnostics — type-mismatched // comparisons and `= NULL` (ADR-0026 §7, surfaced through // ADR-0027's model). Only a successfully-built command has // a `where` expression to inspect. if let Some(command) = &cmd && let Some(expr) = command_where_expr(command) { let columns = ctx.current_table_columns.as_deref().unwrap_or(&[]); diagnostics.extend(expr_warnings( expr, columns, where_clause_span(&path, effective_source.len()), )); } let result = WalkResult { outcome: final_outcome, matched_path: path, per_byte_class: per_byte, tail_expected, diagnostics, }; (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(), cascade: false, }; 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(), filter: None, limit: None, } ); } #[test] fn walker_parses_show_table() { assert_eq!( parse("show table Customers").unwrap(), Command::ShowTable { name: "Customers".to_string() } ); } #[test] fn walker_parses_show_data_with_where_and_limit() { // ADR-0026 §5: `show data` gains an optional `where` // and an optional `limit `. match parse("show data Customers where id=1 limit 10").unwrap() { Command::ShowData { name, filter: Some(_), limit: Some(10), } => assert_eq!(name, "Customers"), other => panic!("expected ShowData with filter + limit, got {other:?}"), } } #[test] fn walker_parses_show_data_with_limit_only() { assert!(matches!( parse("show data Customers limit 5").unwrap(), Command::ShowData { filter: None, limit: Some(5), .. } )); } #[test] fn walker_parses_update_with_complex_where() { // The WHERE is a full boolean expression, not a single // equality (ADR-0026). match parse("update T set Active=true where Age>30 and Name like 'A%'") .unwrap() { Command::Update { filter: RowFilter::Where(crate::dsl::Expr::And(terms)), .. } => assert_eq!(terms.len(), 2, "two AND-ed predicates"), other => panic!("expected Update with And-expression filter, got {other:?}"), } } #[test] fn walker_parses_delete_with_or_where() { assert!(matches!( parse("delete from T where id=1 or id=2").unwrap(), Command::Delete { filter: RowFilter::Where(crate::dsl::Expr::Or(_)), .. } )); } // ---- input_verdict (ADR-0027 §3) -------------------------- #[test] fn input_verdict_clean_command_is_none() { assert_eq!(super::input_verdict("quit", None), None); assert_eq!(super::input_verdict("show table Customers", None), None); } #[test] fn input_verdict_empty_input_is_none() { assert_eq!(super::input_verdict("", None), None); assert_eq!(super::input_verdict(" ", None), None); } #[test] fn input_verdict_incomplete_command_is_error() { assert_eq!( super::input_verdict("create table", None), Some(super::Severity::Error), ); } #[test] fn input_verdict_unknown_command_is_error() { assert_eq!( super::input_verdict("frobnicate the gizmo", None), Some(super::Severity::Error), ); } #[test] fn input_verdict_mismatched_token_is_error() { // `quit` takes no argument — trailing junk fails. assert_eq!( super::input_verdict("quit now", None), Some(super::Severity::Error), ); } #[test] fn input_verdict_unknown_table_is_error() { // The command parses, but the table does not exist — // an ERROR diagnostic (ADR-0027 §2). let schema = schema_with("Customers", &[("id", Type::Int)]); assert_eq!( super::input_verdict("show data NoSuchTable", Some(&schema)), Some(super::Severity::Error), ); } #[test] fn input_verdict_unknown_column_is_error() { let schema = schema_with("Customers", &[("id", Type::Int), ("Name", Type::Text)]); assert_eq!( super::input_verdict( "show data Customers where NoSuchCol = 1", Some(&schema), ), Some(super::Severity::Error), ); } #[test] fn input_verdict_known_table_and_column_is_clean() { let schema = schema_with("Customers", &[("id", Type::Int), ("Name", Type::Text)]); assert_eq!( super::input_verdict( "show data Customers where id = 1", Some(&schema), ), None, ); } #[test] fn input_verdict_type_mismatch_is_warning() { // `Age` is int; comparing it with a text literal runs, // but is flagged (ADR-0026 §7). let schema = schema_with("Customers", &[("id", Type::Int), ("Age", Type::Int)]); assert_eq!( super::input_verdict( "delete from Customers where Age = 'hello'", Some(&schema), ), Some(super::Severity::Warning), ); } #[test] fn input_verdict_eq_null_is_warning() { let schema = schema_with("Customers", &[("id", Type::Int), ("Name", Type::Text)]); assert_eq!( super::input_verdict( "delete from Customers where Name = null", Some(&schema), ), Some(super::Severity::Warning), ); } #[test] fn input_verdict_compatible_comparison_is_clean() { let schema = schema_with("Customers", &[("id", Type::Int), ("Name", Type::Text)]); assert_eq!( super::input_verdict( "delete from Customers where id = 5", Some(&schema), ), None, ); } #[test] fn input_verdict_error_outranks_warning() { // An unknown column (ERROR) alongside `= NULL` // (WARNING) — the indicator shows the higher severity. let schema = schema_with("Customers", &[("id", Type::Int)]); assert_eq!( super::input_verdict( "delete from Customers where NoSuchCol = null", Some(&schema), ), Some(super::Severity::Error), ); } #[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::eq("id", 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::eq("id", 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::eq("id", 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() { // Form B: the grammar dispatches one slot per // non-auto-generated column — the serial `id` is // skipped because the dispatch path (`db::do_insert`) // auto-fills it (ADR-0018 §3). let schema = schema_with( "Customers", &[("id", Type::Serial), ("Name", Type::Text), ("Active", Type::Bool)], ); // 2 user-typed values: Name (text), Active (bool). let cmd = parse_command_with_schema( "insert into Customers values ('Alice', true)", &schema, ) .expect("parse"); match cmd { Command::Insert { table, values, .. } => { assert_eq!(table, "Customers"); assert_eq!(values.len(), 2); } other => panic!("expected Insert, got {other:?}"), } } #[test] fn phase_d_insert_form_b_skips_serial_column() { // Form B: `insert into values (…)` excludes // auto-generated columns from the value list. Supplying // a value for the serial column is a count mismatch. let schema = schema_with( "Customers", &[("id", Type::Serial), ("Name", Type::Text)], ); // Two values where Form B expects one (Name only): let err = parse_command_with_schema( "insert into Customers values (1, 'Alice')", &schema, ) .expect_err("Form B should reject user-supplied serial"); match err { crate::dsl::ParseError::Invalid { .. } => {} other => panic!("expected Invalid, got {other:?}"), } } #[test] fn phase_d_insert_form_a_accepts_serial_when_listed() { // Form A: user explicitly lists `id`. The dispatch path // accepts user-supplied serial values when they're in // the explicit column list; the grammar mirrors that. let schema = schema_with( "Customers", &[("id", Type::Serial), ("Name", Type::Text)], ); let cmd = parse_command_with_schema( "insert into Customers (id, Name) values (1, 'Alice')", &schema, ) .expect("parse"); match cmd { Command::Insert { columns, values, .. } => { assert_eq!(columns.as_deref(), Some(&["id".to_string(), "Name".to_string()][..])); assert_eq!(values.len(), 2); } other => panic!("expected Insert, got {other:?}"), } } #[test] fn phase_d_insert_form_a_filters_to_user_listed_columns() { // Form A: listing only Name should accept exactly one // value (for Name), even though the table has more // columns. let schema = schema_with( "Customers", &[("id", Type::Serial), ("Name", Type::Text), ("Active", Type::Bool)], ); let cmd = parse_command_with_schema( "insert into Customers (Name) values ('Alice')", &schema, ) .expect("parse"); match cmd { Command::Insert { columns, values, .. } => { assert_eq!(columns.as_deref(), Some(&["Name".to_string()][..])); assert_eq!(values.len(), 1); } 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_permits_decimal_at_int_column() { // ADR-0026 §7: a type-mismatched WHERE comparison is // flagged in the editor but never blocks. `id` is Int // and `3.14` is not — yet the command still parses and // would run (this relaxes the pre-ADR-0026 rejection). let schema = schema_with("T", &[("id", Type::Int)]); let cmd = parse_command_with_schema("delete from T where id=3.14", &schema) .expect("type-mismatched WHERE comparisons are permissive"); assert!(matches!(cmd, crate::dsl::Command::Delete { .. }), "got {cmd:?}"); } // ---- 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_user_settable_type_routes_via_form_b() { // Form B (`insert into T values (…)`) excludes auto- // generated columns from the value list — so only the // user-settable types appear at this position. 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"), ] { 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 typed_hint_for_auto_generated_types_routes_via_form_a() { // Serial / shortid columns can be set by the user only // in Form A (`insert into T (col) values (…)`) — Form B // skips them because the dispatch path auto-fills. for (ty, key) in [ (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 (c) values (", &schema); assert!( matches!(mode, Some(HintMode::ProseOnly(k)) if k == key), "expected ProseOnly({key}) for type {ty:?}, got {mode:?}", ); } } #[test] fn typed_hint_form_b_skips_serial_column_to_generic_or_text_neighbor() { // A serial-only table in Form B has nothing for the user // to type — column_value_list returns the schemaless // fallback, so the hint at the first value position is // the generic value-literal prose. let schema = schema_with("T", &[("id", Type::Serial)]); let mode = hint_mode_at_input_with_schema("insert into T values (", &schema); assert!( matches!(mode, Some(HintMode::ProseOnly("hint.value_literal_slot"))), "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:?}"), } } }