Files
rdbms-playground/src/dsl/walker/mod.rs
T
claude@clouddev1 73c74701c2 walker: expression WARNING diagnostics (ADR-0027 step C, folds ADR-0026 §7)
Type-mismatched comparisons and `= NULL` / `!= NULL` in a
WHERE expression now yield WARNING diagnostics — the command
still parses and runs (the ADR-0026 §7 permissive posture is
unchanged), but the validity indicator can flag it before
submission.

Computed post-walk from the built command's `Expr` against
the table's column types: a Compare / Between / In with a
column operand and a non-null literal whose type the column
cannot hold, or a Compare with `=` / `!=` against NULL. New
catalog keys `diagnostic.type_mismatch` / `diagnostic.eq_null`.

This is ADR-0026's deferred step 5, folded into ADR-0027's
diagnostics-severity model as the user requested.
2026-05-19 07:21:30 +00:00

2392 lines
83 KiB
Rust

//! 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<crate::dsl::grammar::HintMode> {
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<crate::dsl::grammar::HintMode> {
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<String>,
/// Auto-generated columns (serial / shortid) that Form B
/// `insert into <T> 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<String>,
}
/// 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<HintResolution> {
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<String>>,
table_columns: Option<&Vec<crate::completion::TableColumn>>,
pending_column: Option<&str>,
) -> Vec<String> {
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<String> = 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<crate::dsl::grammar::HintMode> {
// 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<outcome::Expectation>,
/// 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<Vec<crate::completion::TableColumn>>,
/// 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<crate::dsl::grammar::HintMode>,
}
/// 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<outcome::Severity> {
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<outcome::Diagnostic> {
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<String> = 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<outcome::Diagnostic> {
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<outcome::Diagnostic>,
) {
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<outcome::Diagnostic>,
) {
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<String> {
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<outcome::Expectation> {
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<outcome::Expectation>,
pending_value_type: Option<crate::dsl::types::Type>,
pending_value_column: Option<String>,
/// The grammar-declared `HintMode` at the cursor's slot
/// (`Node::Hinted` annotation, ADR-0024 §HintMode-per-node).
pending_hint_mode: Option<crate::dsl::grammar::HintMode>,
current_table_columns: Option<Vec<crate::completion::TableColumn>>,
/// `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<Vec<String>>,
}
fn expected_for_hint_snapshot(
source: &str,
schema: Option<&crate::completion::SchemaCache>,
) -> HintWalkSnapshot {
use crate::dsl::grammar::REGISTRY;
let entry_words = || -> Vec<outcome::Expectation> {
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<WalkResult>, Option<Command>) {
// 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<Expectation> = 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<Command, crate::dsl::ParseError> {
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 <target>)` 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 `<prefix>`' 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 <n>`.
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<TableColumn> = 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 <T> 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:?}"),
}
}
}