82955679ca
The Phase D commit landed parse-time validation but not the
user-facing payoff — per-column-type hints. Typing
`insert into Customers values (` rightfully expected a hint
like "Type an integer (e.g. 42, -7) or null" at an int column.
This commit closes that gap.
End-to-end:
**`Node::TypedValueSlot { ty, inner }`** (new variant in
`src/dsl/grammar/mod.rs`):
- Walker walks `inner` to consume the literal but tags
`WalkContext::pending_value_type = Some(ty)` on entry, then
clears it on a successful inner match. Positions BETWEEN
slots (`insert into T values (1` mid-input) thus don't carry
a stale hint type.
**Typed slot factories wrapped in `TypedValueSlot`**
(`src/dsl/grammar/shared.rs`):
- `INT_SLOT`, `REAL_SLOT`, `DECIMAL_SLOT`, `BOOL_SLOT`,
`TEXT_SLOT`, `DATE_SLOT`, `DATETIME_SLOT`, `BLOB_SLOT`,
`SERIAL_SLOT`, `SHORTID_SLOT` — each pairs an inner literal
Choice with its `Type` so the walker can tag context.
- `slot_for_type(ty)` dispatches to the appropriate constant.
- Bug fix: `ShortId` previously dispatched to `INT_SLOT` (a
pre-Phase-D holdover from the chumsky-side generic
fallback). `shortid` columns store base58 text (ADR-0011
fk_target_type shortid → text); the corrected slot accepts
`StringLit` or `null`.
**Schema-aware hint resolver** (`src/dsl/walker/mod.rs`):
- `hint_mode_at_input_with_schema(source, &SchemaCache) ->
Option<HintMode>` is the new public entry point. Reads
`pending_value_type` from the walker's WalkContext and
emits `HintMode::ProseOnly("hint.value_slot_<type>")` —
one per Type.
- The schemaless `hint_mode_at_input(source)` falls back to
the generic `hint.value_literal_slot` at value-literal slots
(no per-type narrowing without a schema).
- `catalog_key_for_value_type(ty)` is the type → key
dispatcher.
**Catalog entries** (`src/friendly/strings/en-US.yaml`,
`src/friendly/keys.rs`):
- 10 new `hint.value_slot_<type>` keys with per-type prose:
- int/serial → "Type an integer (e.g. 42, -7) or null"
- real/decimal → "Type a number (e.g. 3.14, -0.5) or null"
- bool → "Type true, false, or null"
- text → "Type a quoted string (e.g. 'Alice') or null"
- date → "Type a quoted date as 'YYYY-MM-DD' or null"
- datetime → "Type a quoted datetime as 'YYYY-MM-DD
HH:MM:SS' or null"
- blob → "Type a quoted blob literal or null"
- shortid → "Type a quoted shortid (or omit to auto-generate)
or null"
**Ambient-hint dispatch** (`src/input_render.rs::ambient_hint`):
- Passes the SchemaCache through to
`hint_mode_at_input_with_schema`, so the live hint panel
surfaces per-column-type prose as the user types into a
value slot.
Tests:
- 8 walker-side tests cover insert / update / where typed-slot
hint dispatch, mid-value no-stale-hint behaviour, and a
full-coverage routing matrix for every `Type` variant.
- 4 input_render integration tests cover the end-to-end
ambient_hint path: insert first/second value, update set
value, and the schemaless fallback to generic prose.
Tests: 842 passing, 0 failing, 1 ignored. Clippy clean.
For the user: typing `insert into Customers values (` against
a Customers table whose first column is `id:int` now shows
"Type an integer (e.g. 42, -7) or null" in the hint panel,
replacing the previous generic value-literal prose. After
typing `1, `, the panel updates to whatever the second column
requires — "Type a quoted string (e.g. 'Alice') or null"
for text, "Type a quoted date as 'YYYY-MM-DD'" for date, etc.
1623 lines
55 KiB
Rust
1623 lines
55 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;
|
|
use crate::dsl::grammar;
|
|
use crate::dsl::walker::context::WalkContext;
|
|
use crate::dsl::walker::driver::{FailureKind, NodeWalkResult, walk_node};
|
|
use crate::dsl::walker::lex_helpers::{consume_ident, skip_whitespace};
|
|
use crate::dsl::walker::outcome::{
|
|
Expectation, MatchedPath, WalkBound, WalkOutcome, WalkResult,
|
|
};
|
|
|
|
pub use context::ColumnInfo;
|
|
pub use highlight::highlight_runs;
|
|
|
|
/// Resolve the hint-panel mode at the end of `source`
|
|
/// (ADR-0024 §HintMode-per-node, §Phase D §typed-value-slots).
|
|
///
|
|
/// Schemaless variant. Surfaces:
|
|
/// - `HintMode::ProseOnly("hint.value_literal_slot")` at generic
|
|
/// value-literal positions (all five forms in the expected
|
|
/// set), and
|
|
/// - `HintMode::ForceProse("hint.ambient_typing_name")` at
|
|
/// `NewName` ident slots.
|
|
///
|
|
/// Schema-aware callers should use `hint_mode_at_input_with_schema`
|
|
/// instead — that variant narrows the prose to the column's
|
|
/// user-facing type at typed value slots (e.g. "Type a date
|
|
/// as 'YYYY-MM-DD'" at a date column).
|
|
#[must_use]
|
|
pub fn hint_mode_at_input(source: &str) -> Option<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))
|
|
}
|
|
|
|
fn hint_mode_at_input_inner(
|
|
source: &str,
|
|
schema: Option<&crate::completion::SchemaCache>,
|
|
) -> Option<crate::dsl::grammar::HintMode> {
|
|
use crate::dsl::grammar::{HintMode, IdentSource};
|
|
use crate::dsl::walker::outcome::Expectation;
|
|
|
|
// Hint mode is only meaningful at *required* slot positions
|
|
// (Incomplete / Mismatch outcomes). For complete commands
|
|
// (Match), `tail_expected` may carry optional-suffix
|
|
// expectations — completion surfaces those as Tab
|
|
// candidates, but the hint resolver should stay silent so
|
|
// we don't push prose like "Type a name" at the end of a
|
|
// valid command.
|
|
let (expected, pending_value_type) = expected_for_hint_with_ctx(source, schema);
|
|
if expected.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
// Typed value slot at the cursor: the walker tagged
|
|
// ctx.pending_value_type on entry to the slot but did not
|
|
// clear it (no inner literal matched). Emit per-type prose.
|
|
if let Some(ty) = pending_value_type {
|
|
return Some(HintMode::ProseOnly(catalog_key_for_value_type(ty)));
|
|
}
|
|
|
|
// Value-literal slot signature: all five forms present.
|
|
let has_word = |w: &str| {
|
|
expected
|
|
.iter()
|
|
.any(|e| matches!(e, Expectation::Word(x) if *x == w))
|
|
};
|
|
let value_literal_slot = has_word("null")
|
|
&& has_word("true")
|
|
&& has_word("false")
|
|
&& expected.iter().any(|e| matches!(e, Expectation::NumberLit))
|
|
&& expected.iter().any(|e| matches!(e, Expectation::StringLit));
|
|
if value_literal_slot {
|
|
// Fallback prose: lists every literal form with format
|
|
// examples. Fires when the walker can't resolve a column
|
|
// type at the cursor (schemaless caller, missing table,
|
|
// unknown column).
|
|
return Some(HintMode::ProseOnly("hint.value_literal_slot"));
|
|
}
|
|
|
|
// NewName ident slot: user invents a name.
|
|
let new_name_slot = expected.iter().any(|e| {
|
|
matches!(
|
|
e,
|
|
Expectation::Ident {
|
|
source: IdentSource::NewName,
|
|
..
|
|
}
|
|
)
|
|
});
|
|
if new_name_slot {
|
|
// The "Type a name" prose key is selected by the
|
|
// ambient_hint dispatch — the `ForceProse` key here is
|
|
// a stable identifier the resolver maps to one of the
|
|
// two variants (`hint.ambient_typing_name` /
|
|
// `hint.ambient_typing_name_then`) depending on whether
|
|
// a next-token probe yields content.
|
|
return Some(HintMode::ForceProse("hint.ambient_typing_name"));
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
const fn catalog_key_for_value_type(ty: crate::dsl::types::Type) -> &'static str {
|
|
use crate::dsl::types::Type;
|
|
match ty {
|
|
Type::Int => "hint.value_slot_int",
|
|
Type::Real => "hint.value_slot_real",
|
|
Type::Decimal => "hint.value_slot_decimal",
|
|
Type::Bool => "hint.value_slot_bool",
|
|
Type::Text => "hint.value_slot_text",
|
|
Type::Date => "hint.value_slot_date",
|
|
Type::DateTime => "hint.value_slot_datetime",
|
|
Type::Blob => "hint.value_slot_blob",
|
|
Type::Serial => "hint.value_slot_serial",
|
|
Type::ShortId => "hint.value_slot_shortid",
|
|
}
|
|
}
|
|
|
|
/// What the grammar would accept at the end of `source`
|
|
/// (ADR-0024 §architecture, Phase F walker-driven completion).
|
|
///
|
|
/// Empty / whitespace-only input yields every command-entry word
|
|
/// as `Expectation::Word(primary)`. Otherwise the walker is
|
|
/// driven to `EndOfInput`; if the input completes a command,
|
|
/// the result is empty; if it fails or is incomplete, the
|
|
/// walker's expected-set surfaces verbatim — `Ident { source,
|
|
/// role }` carries its `IdentSource` (so the completion engine
|
|
/// can schema-look-up without a string round-trip), `Word` /
|
|
/// `Literal` carry their primary literal, etc.
|
|
///
|
|
/// Inputs whose first token is not a registered entry word
|
|
/// fall back to listing every entry word — matches the
|
|
/// synthetic "unknown command" expectation set the parser
|
|
/// produces.
|
|
#[must_use]
|
|
pub fn expected_at_input(source: &str) -> Vec<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,
|
|
outcome::WalkOutcome::ValidationFailed { .. } => Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// Strict-required expected set at the end of `source`, plus
|
|
/// the walker's `pending_value_type` at the cursor.
|
|
///
|
|
/// Like `expected_at_input` but returns empty on
|
|
/// `WalkOutcome::Match` — optional-suffix continuations are not
|
|
/// surfaced. Used by the hint resolver to distinguish "must
|
|
/// type more" from "could continue", and to dispatch per-type
|
|
/// prose when the cursor is inside a typed value slot.
|
|
fn expected_for_hint_with_ctx(
|
|
source: &str,
|
|
schema: Option<&crate::completion::SchemaCache>,
|
|
) -> (Vec<outcome::Expectation>, Option<crate::dsl::types::Type>) {
|
|
use crate::dsl::grammar::REGISTRY;
|
|
|
|
if source.trim().is_empty() {
|
|
let expected = REGISTRY
|
|
.iter()
|
|
.map(|c| outcome::Expectation::Word(c.entry.primary))
|
|
.collect();
|
|
return (expected, None);
|
|
}
|
|
let mut ctx = schema.map_or_else(context::WalkContext::new, |s| {
|
|
context::WalkContext::with_schema(s)
|
|
});
|
|
let (result, _cmd) = walk(source, outcome::WalkBound::EndOfInput, &mut ctx);
|
|
let Some(result) = result else {
|
|
let expected = REGISTRY
|
|
.iter()
|
|
.map(|c| outcome::Expectation::Word(c.entry.primary))
|
|
.collect();
|
|
return (expected, None);
|
|
};
|
|
let expected = match result.outcome {
|
|
outcome::WalkOutcome::Match { .. } | outcome::WalkOutcome::ValidationFailed { .. } => {
|
|
Vec::new()
|
|
}
|
|
outcome::WalkOutcome::Incomplete { expected, .. }
|
|
| outcome::WalkOutcome::Mismatch { expected, .. } => expected,
|
|
};
|
|
(expected, ctx.pending_value_type)
|
|
}
|
|
|
|
/// Public walk entry. `bound` is `EndOfInput` for parse;
|
|
/// `Position(cursor)` for completion / hint (Phase A: not yet
|
|
/// wired).
|
|
///
|
|
/// Returns:
|
|
/// - `(Some(WalkResult), Some(Command))` on full match — the
|
|
/// AST builder produced a typed Command.
|
|
/// - `(Some(WalkResult), None)` on failure where the walker
|
|
/// committed (matched the entry word). Caller surfaces the
|
|
/// walker's error.
|
|
/// - `(None, None)` when the entry word doesn't match any
|
|
/// registered command — the router falls through to chumsky.
|
|
pub fn walk<'a>(
|
|
source: &str,
|
|
bound: WalkBound,
|
|
ctx: &mut WalkContext<'a>,
|
|
) -> (Option<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),
|
|
};
|
|
|
|
let result = WalkResult {
|
|
outcome: final_outcome,
|
|
matched_path: path,
|
|
per_byte_class: per_byte,
|
|
tail_expected,
|
|
};
|
|
(Some(result), cmd)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
//! Walker behaviour tests — Phase A (ADR-0024 §migration).
|
|
//!
|
|
//! These cover every app-lifecycle command the walker now
|
|
//! owns. Each input is paired with its expected `Command`
|
|
//! output (the differential-against-chumsky check
|
|
//! materialised as hand-curated expectations — same role
|
|
//! the differential test scaffolding plays per ADR-0024
|
|
//! §test-discipline).
|
|
//!
|
|
//! The handoff document lists these tests as "walker-
|
|
//! specific tests for trie-only features" — they pin down
|
|
//! the walker's contract for the migrated commands so
|
|
//! Phase B-F migrations can refactor without regression.
|
|
use crate::dsl::command::{AppCommand, Command, MessagesValue, ModeValue};
|
|
use crate::dsl::parser::parse_command;
|
|
|
|
fn parse(input: &str) -> Result<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(),
|
|
};
|
|
assert_eq!(parse("drop column Customers: Email").unwrap(), want);
|
|
assert_eq!(parse("drop column from Customers: Email").unwrap(), want);
|
|
assert_eq!(parse("drop column from table Customers: Email").unwrap(), want);
|
|
assert_eq!(parse("drop column table Customers: Email").unwrap(), want);
|
|
}
|
|
|
|
#[test]
|
|
fn walker_parses_drop_relationship_named() {
|
|
assert_eq!(
|
|
parse("drop relationship Orders_to_Customers").unwrap(),
|
|
Command::DropRelationship {
|
|
selector: RelationshipSelector::Named {
|
|
name: "Orders_to_Customers".to_string(),
|
|
},
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn walker_parses_drop_relationship_endpoints() {
|
|
assert_eq!(
|
|
parse("drop relationship from Customers.id to Orders.customer_id").unwrap(),
|
|
Command::DropRelationship {
|
|
selector: RelationshipSelector::Endpoints {
|
|
parent_table: "Customers".to_string(),
|
|
parent_column: "id".to_string(),
|
|
child_table: "Orders".to_string(),
|
|
child_column: "customer_id".to_string(),
|
|
},
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn walker_parses_add_column() {
|
|
assert_eq!(
|
|
parse("add column Customers: Email (text)").unwrap(),
|
|
Command::AddColumn {
|
|
table: "Customers".to_string(),
|
|
column: "Email".to_string(),
|
|
ty: Type::Text,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn walker_add_column_unknown_type_errors_with_friendly_wording() {
|
|
let err = parse("add column Customers: Email (varchar)").unwrap_err();
|
|
match err {
|
|
crate::dsl::ParseError::Invalid { message, .. } => {
|
|
assert!(message.contains("varchar"), "got: {message}");
|
|
}
|
|
other => panic!("expected Invalid, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn walker_parses_rename_column() {
|
|
assert_eq!(
|
|
parse("rename column Customers: Email to ContactEmail").unwrap(),
|
|
Command::RenameColumn {
|
|
table: "Customers".to_string(),
|
|
old: "Email".to_string(),
|
|
new: "ContactEmail".to_string(),
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn walker_parses_change_column() {
|
|
assert_eq!(
|
|
parse("change column Customers: Email (text)").unwrap(),
|
|
Command::ChangeColumnType {
|
|
table: "Customers".to_string(),
|
|
column: "Email".to_string(),
|
|
ty: Type::Text,
|
|
mode: ChangeColumnMode::Default,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn walker_parses_change_column_with_force_conversion_flag() {
|
|
assert_eq!(
|
|
parse("change column Customers: Email (int) --force-conversion").unwrap(),
|
|
Command::ChangeColumnType {
|
|
table: "Customers".to_string(),
|
|
column: "Email".to_string(),
|
|
ty: Type::Int,
|
|
mode: ChangeColumnMode::ForceConversion,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn walker_change_column_rejects_both_flags() {
|
|
let err = parse("change column Customers: Email (int) --force-conversion --dont-convert")
|
|
.unwrap_err();
|
|
match err {
|
|
crate::dsl::ParseError::Invalid { message, .. } => {
|
|
assert!(message.contains("mutually exclusive"), "got: {message}");
|
|
}
|
|
other => panic!("expected Invalid, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn walker_parses_add_relationship_minimal() {
|
|
assert_eq!(
|
|
parse("add 1:n relationship from Customers.id to Orders.customer_id").unwrap(),
|
|
Command::AddRelationship {
|
|
name: None,
|
|
parent_table: "Customers".to_string(),
|
|
parent_column: "id".to_string(),
|
|
child_table: "Orders".to_string(),
|
|
child_column: "customer_id".to_string(),
|
|
on_delete: ReferentialAction::default_action(),
|
|
on_update: ReferentialAction::default_action(),
|
|
create_fk: false,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn walker_parses_add_relationship_with_name_and_actions_and_flag() {
|
|
assert_eq!(
|
|
parse(
|
|
"add 1:n relationship as cust_orders from Customers.id to Orders.customer_id \
|
|
on delete cascade on update set null --create-fk"
|
|
)
|
|
.unwrap(),
|
|
Command::AddRelationship {
|
|
name: Some("cust_orders".to_string()),
|
|
parent_table: "Customers".to_string(),
|
|
parent_column: "id".to_string(),
|
|
child_table: "Orders".to_string(),
|
|
child_column: "customer_id".to_string(),
|
|
on_delete: ReferentialAction::Cascade,
|
|
on_update: ReferentialAction::SetNull,
|
|
create_fk: true,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn walker_add_relationship_repeated_clause_errors() {
|
|
let err = parse(
|
|
"add 1:n relationship from Customers.id to Orders.customer_id \
|
|
on delete cascade on delete restrict",
|
|
)
|
|
.unwrap_err();
|
|
match err {
|
|
crate::dsl::ParseError::Invalid { message, .. } => {
|
|
assert!(
|
|
message.contains("delete") && message.contains("twice"),
|
|
"got: {message}"
|
|
);
|
|
}
|
|
other => panic!("expected Invalid, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
// =========================================================
|
|
// Phase C — create table.
|
|
// =========================================================
|
|
|
|
use crate::dsl::command::ColumnSpec;
|
|
|
|
fn col(name: &str, ty: Type) -> ColumnSpec {
|
|
ColumnSpec {
|
|
name: name.to_string(),
|
|
ty,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn walker_parses_create_table_with_pk_default_id_serial() {
|
|
assert_eq!(
|
|
parse("create table Customers with pk").unwrap(),
|
|
Command::CreateTable {
|
|
name: "Customers".to_string(),
|
|
columns: vec![col("id", Type::Serial)],
|
|
primary_key: vec!["id".to_string()],
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn walker_parses_create_table_named_typed_pk() {
|
|
assert_eq!(
|
|
parse("create table Customers with pk email:text").unwrap(),
|
|
Command::CreateTable {
|
|
name: "Customers".to_string(),
|
|
columns: vec![col("email", Type::Text)],
|
|
primary_key: vec!["email".to_string()],
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn walker_parses_create_table_compound_pk() {
|
|
assert_eq!(
|
|
parse("create table OrderLines with pk order_id:int,product_id:int").unwrap(),
|
|
Command::CreateTable {
|
|
name: "OrderLines".to_string(),
|
|
columns: vec![col("order_id", Type::Int), col("product_id", Type::Int)],
|
|
primary_key: vec!["order_id".to_string(), "product_id".to_string()],
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn walker_create_table_pk_tolerates_whitespace_around_punct() {
|
|
assert_eq!(
|
|
parse("create table T with pk id : serial").unwrap(),
|
|
Command::CreateTable {
|
|
name: "T".to_string(),
|
|
columns: vec![col("id", Type::Serial)],
|
|
primary_key: vec!["id".to_string()],
|
|
}
|
|
);
|
|
assert_eq!(
|
|
parse("create table T with pk a : int , b : int").unwrap(),
|
|
Command::CreateTable {
|
|
name: "T".to_string(),
|
|
columns: vec![col("a", Type::Int), col("b", Type::Int)],
|
|
primary_key: vec!["a".to_string(), "b".to_string()],
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn walker_bare_create_table_errors_with_with_pk_hint() {
|
|
let err = parse("create table Customers").unwrap_err();
|
|
match err {
|
|
crate::dsl::ParseError::Invalid { message, .. } => {
|
|
assert!(
|
|
message.contains("with pk"),
|
|
"error should mention `with pk`:\n{message}"
|
|
);
|
|
}
|
|
other => panic!("expected Invalid, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn walker_create_table_keywords_are_case_insensitive() {
|
|
assert_eq!(
|
|
parse("CREATE TABLE Customers WITH PK email:TEXT").unwrap(),
|
|
Command::CreateTable {
|
|
name: "Customers".to_string(),
|
|
columns: vec![col("email", Type::Text)],
|
|
primary_key: vec!["email".to_string()],
|
|
}
|
|
);
|
|
}
|
|
|
|
// =========================================================
|
|
// Phase D — data commands (show, insert, update, delete).
|
|
// =========================================================
|
|
|
|
use crate::dsl::value::Value;
|
|
use crate::dsl::command::RowFilter;
|
|
|
|
#[test]
|
|
fn walker_parses_show_data() {
|
|
assert_eq!(
|
|
parse("show data Customers").unwrap(),
|
|
Command::ShowData {
|
|
name: "Customers".to_string()
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn walker_parses_show_table() {
|
|
assert_eq!(
|
|
parse("show table Customers").unwrap(),
|
|
Command::ShowTable {
|
|
name: "Customers".to_string()
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn walker_parses_insert_with_explicit_column_list() {
|
|
assert_eq!(
|
|
parse("insert into Customers (Email, Name) values ('a@b.c', 'Alice')").unwrap(),
|
|
Command::Insert {
|
|
table: "Customers".to_string(),
|
|
columns: Some(vec!["Email".to_string(), "Name".to_string()]),
|
|
values: vec![Value::Text("a@b.c".to_string()), Value::Text("Alice".to_string())],
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn walker_parses_insert_with_values_keyword_only() {
|
|
assert_eq!(
|
|
parse("insert into Customers values (1, 'Alice', null)").unwrap(),
|
|
Command::Insert {
|
|
table: "Customers".to_string(),
|
|
columns: None,
|
|
values: vec![
|
|
Value::Number("1".to_string()),
|
|
Value::Text("Alice".to_string()),
|
|
Value::Null,
|
|
],
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn walker_parses_insert_short_form_without_column_list() {
|
|
assert_eq!(
|
|
parse("insert into Customers (1, 'Alice', true)").unwrap(),
|
|
Command::Insert {
|
|
table: "Customers".to_string(),
|
|
columns: None,
|
|
values: vec![
|
|
Value::Number("1".to_string()),
|
|
Value::Text("Alice".to_string()),
|
|
Value::Bool(true),
|
|
],
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn walker_parses_insert_supports_negative_numbers() {
|
|
assert_eq!(
|
|
parse("insert into T values (-5)").unwrap(),
|
|
Command::Insert {
|
|
table: "T".to_string(),
|
|
columns: None,
|
|
values: vec![Value::Number("-5".to_string())],
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn walker_parses_update_with_where() {
|
|
assert_eq!(
|
|
parse("update Customers set Email='new@b.c' where id=1").unwrap(),
|
|
Command::Update {
|
|
table: "Customers".to_string(),
|
|
assignments: vec![("Email".to_string(), Value::Text("new@b.c".to_string()))],
|
|
filter: RowFilter::Where {
|
|
column: "id".to_string(),
|
|
value: Value::Number("1".to_string()),
|
|
},
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn walker_parses_update_with_multiple_assignments() {
|
|
assert_eq!(
|
|
parse("update Customers set Email='a@b.c', Name='Alice' where id=1").unwrap(),
|
|
Command::Update {
|
|
table: "Customers".to_string(),
|
|
assignments: vec![
|
|
("Email".to_string(), Value::Text("a@b.c".to_string())),
|
|
("Name".to_string(), Value::Text("Alice".to_string())),
|
|
],
|
|
filter: RowFilter::Where {
|
|
column: "id".to_string(),
|
|
value: Value::Number("1".to_string()),
|
|
},
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn walker_parses_update_with_all_rows_flag() {
|
|
assert_eq!(
|
|
parse("update Customers set Active=true --all-rows").unwrap(),
|
|
Command::Update {
|
|
table: "Customers".to_string(),
|
|
assignments: vec![("Active".to_string(), Value::Bool(true))],
|
|
filter: RowFilter::AllRows,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn walker_parses_delete_with_where() {
|
|
assert_eq!(
|
|
parse("delete from Customers where id=42").unwrap(),
|
|
Command::Delete {
|
|
table: "Customers".to_string(),
|
|
filter: RowFilter::Where {
|
|
column: "id".to_string(),
|
|
value: Value::Number("42".to_string()),
|
|
},
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn walker_parses_delete_with_all_rows() {
|
|
assert_eq!(
|
|
parse("delete from Customers --all-rows").unwrap(),
|
|
Command::Delete {
|
|
table: "Customers".to_string(),
|
|
filter: RowFilter::AllRows,
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn walker_delete_without_where_or_flag_errors() {
|
|
assert!(parse("delete from Customers").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn walker_update_without_where_or_flag_errors() {
|
|
assert!(parse("update Customers set Email='x'").is_err());
|
|
}
|
|
|
|
// =========================================================
|
|
// Phase E — replay.
|
|
// =========================================================
|
|
|
|
#[test]
|
|
fn walker_parses_replay_with_bare_relative_path() {
|
|
assert_eq!(
|
|
parse("replay history.log").unwrap(),
|
|
Command::Replay {
|
|
path: "history.log".to_string(),
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn walker_parses_replay_with_bare_absolute_path() {
|
|
assert_eq!(
|
|
parse("replay /tmp/seed.commands").unwrap(),
|
|
Command::Replay {
|
|
path: "/tmp/seed.commands".to_string(),
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn walker_parses_replay_with_quoted_path_supports_whitespace() {
|
|
// Phase A's path-bearing UX change: paths with spaces use
|
|
// the quoted form.
|
|
assert_eq!(
|
|
parse("replay 'my project/seed.commands'").unwrap(),
|
|
Command::Replay {
|
|
path: "my project/seed.commands".to_string(),
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn walker_parses_replay_with_quoted_path_supports_escaped_quote() {
|
|
assert_eq!(
|
|
parse("replay 'O''Brien.commands'").unwrap(),
|
|
Command::Replay {
|
|
path: "O'Brien.commands".to_string(),
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn walker_replay_keyword_case_insensitive() {
|
|
assert_eq!(
|
|
parse("REPLAY foo.txt").unwrap(),
|
|
Command::Replay {
|
|
path: "foo.txt".to_string(),
|
|
}
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn walker_replay_without_path_errors() {
|
|
assert!(parse("replay").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn walker_replay_with_empty_quoted_path_parses_as_empty() {
|
|
// Parser layer accepts; runtime rejects empty paths
|
|
// before any I/O. Mirrors the chumsky-side contract
|
|
// (parser.rs `replay_with_empty_quoted_path_errors`).
|
|
assert_eq!(
|
|
parse("replay ''").unwrap(),
|
|
Command::Replay {
|
|
path: String::new(),
|
|
}
|
|
);
|
|
}
|
|
|
|
// =========================================================
|
|
// hint_mode_at_input (ADR-0024 §HintMode-per-node)
|
|
// =========================================================
|
|
|
|
use crate::dsl::grammar::HintMode;
|
|
use super::hint_mode_at_input;
|
|
|
|
#[test]
|
|
fn hint_mode_value_literal_slot_after_insert_open_paren() {
|
|
// `insert into T (` expects a value-literal or column
|
|
// ident at the inner position. After `values (` it's
|
|
// strictly value-literals — the signature triggers
|
|
// ProseOnly.
|
|
match hint_mode_at_input("insert into T values (") {
|
|
Some(HintMode::ProseOnly("hint.value_literal_slot")) => {}
|
|
other => panic!("expected ProseOnly value_literal_slot, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn hint_mode_value_literal_slot_after_update_set_assign() {
|
|
match hint_mode_at_input("update T set col=") {
|
|
Some(HintMode::ProseOnly("hint.value_literal_slot")) => {}
|
|
other => panic!("expected ProseOnly value_literal_slot, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn hint_mode_value_literal_slot_in_where_clause() {
|
|
match hint_mode_at_input("delete from T where col=") {
|
|
Some(HintMode::ProseOnly("hint.value_literal_slot")) => {}
|
|
other => panic!("expected ProseOnly value_literal_slot, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn hint_mode_new_name_slot_for_create_table() {
|
|
// `create table ` expects a NewName ident.
|
|
match hint_mode_at_input("create table ") {
|
|
Some(HintMode::ForceProse("hint.ambient_typing_name")) => {}
|
|
other => panic!("expected ForceProse typing_name, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn hint_mode_new_name_slot_for_add_column_name() {
|
|
// `add column T: ` expects a NewName ident.
|
|
match hint_mode_at_input("add column to table T: ") {
|
|
Some(HintMode::ForceProse("hint.ambient_typing_name")) => {}
|
|
other => panic!("expected ForceProse typing_name, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn hint_mode_none_for_keyword_position() {
|
|
// Entry-keyword position: no HintMode override applies.
|
|
assert!(hint_mode_at_input("").is_none());
|
|
assert!(hint_mode_at_input("cr").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn hint_mode_none_for_complete_command() {
|
|
// Valid complete command: no expected, no override.
|
|
assert!(hint_mode_at_input("create table T with pk").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn hint_mode_none_at_schema_ident_slot() {
|
|
// `show data ` expects a table-name ident from the
|
|
// schema — schema-listable slot, not a HintMode case.
|
|
assert!(hint_mode_at_input("show data ").is_none());
|
|
}
|
|
|
|
// =========================================================
|
|
// Phase D full — schema-aware value typing.
|
|
// =========================================================
|
|
|
|
use crate::completion::{SchemaCache, TableColumn};
|
|
use crate::dsl::parser::parse_command_with_schema;
|
|
|
|
fn schema_with(table: &str, columns: &[(&str, Type)]) -> SchemaCache {
|
|
let cols: Vec<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() {
|
|
let schema = schema_with(
|
|
"Customers",
|
|
&[("id", Type::Serial), ("Name", Type::Text), ("Active", Type::Bool)],
|
|
);
|
|
// 3 columns: int, text, bool. Each value matches its slot.
|
|
let cmd = parse_command_with_schema(
|
|
"insert into Customers values (1, 'Alice', true)",
|
|
&schema,
|
|
)
|
|
.expect("parse");
|
|
match cmd {
|
|
Command::Insert { table, values, .. } => {
|
|
assert_eq!(table, "Customers");
|
|
assert_eq!(values.len(), 3);
|
|
}
|
|
other => panic!("expected Insert, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn phase_d_insert_rejects_decimal_in_int_column() {
|
|
// The schema has `id` as Int. `3.14` is a Number with a
|
|
// decimal — the typed `int_slot` validator rejects.
|
|
let schema = schema_with("T", &[("id", Type::Int)]);
|
|
let err = parse_command_with_schema("insert into T values (3.14)", &schema)
|
|
.expect_err("should reject");
|
|
match err {
|
|
crate::dsl::ParseError::Invalid { message, .. } => {
|
|
assert!(
|
|
message.contains("integer") || message.contains("3.14"),
|
|
"got: {message}"
|
|
);
|
|
}
|
|
other => panic!("expected Invalid, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn phase_d_insert_accepts_null_at_any_column() {
|
|
// null is the absence sentinel; every typed slot
|
|
// accepts it.
|
|
let schema = schema_with(
|
|
"T",
|
|
&[("a", Type::Int), ("b", Type::Text), ("c", Type::Bool)],
|
|
);
|
|
let cmd = parse_command_with_schema(
|
|
"insert into T values (null, null, null)",
|
|
&schema,
|
|
)
|
|
.expect("parse");
|
|
match cmd {
|
|
Command::Insert { values, .. } => {
|
|
assert!(values.iter().all(|v| matches!(v, Value::Null)));
|
|
}
|
|
other => panic!("expected Insert, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn phase_d_insert_falls_back_when_table_not_in_schema() {
|
|
// The schema is empty; the walker can't resolve column
|
|
// info for `Customers`. The DynamicSubgrammar falls
|
|
// back to the schemaless generic value-literal list and
|
|
// accepts mixed-shape values as it did pre-Phase-D.
|
|
let schema = SchemaCache::default();
|
|
let cmd = parse_command_with_schema(
|
|
"insert into Customers values (1, 'Alice')",
|
|
&schema,
|
|
)
|
|
.expect("parse — fallback path");
|
|
match cmd {
|
|
Command::Insert { values, .. } => assert_eq!(values.len(), 2),
|
|
other => panic!("expected Insert, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn phase_d_schemaless_parse_command_still_works() {
|
|
// The pre-Phase-D `parse_command(input)` signature
|
|
// passes no schema; the DynamicSubgrammar falls back to
|
|
// the schemaless value-literal list.
|
|
let cmd = parse("insert into T values (1, 'Alice', null)").expect("parse");
|
|
match cmd {
|
|
Command::Insert { values, .. } => assert_eq!(values.len(), 3),
|
|
other => panic!("expected Insert, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn phase_d_insert_accepts_bool_value_for_bool_column() {
|
|
let schema = schema_with("T", &[("flag", Type::Bool)]);
|
|
let cmd = parse_command_with_schema("insert into T values (false)", &schema)
|
|
.expect("parse");
|
|
match cmd {
|
|
Command::Insert { values, .. } => {
|
|
assert_eq!(values, vec![Value::Bool(false)]);
|
|
}
|
|
other => panic!("expected Insert, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn phase_d_update_accepts_text_value_for_text_column() {
|
|
let schema = schema_with(
|
|
"Customers",
|
|
&[("id", Type::Int), ("Email", Type::Text)],
|
|
);
|
|
let cmd = parse_command_with_schema(
|
|
"update Customers set Email='new@b.c' where id=1",
|
|
&schema,
|
|
)
|
|
.expect("parse");
|
|
match cmd {
|
|
Command::Update { assignments, .. } => {
|
|
assert_eq!(assignments.len(), 1);
|
|
assert_eq!(assignments[0].0, "Email");
|
|
}
|
|
other => panic!("expected Update, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn phase_d_update_rejects_decimal_in_int_set_column() {
|
|
// Email is text; Score is int. Assigning `3.14` to Score
|
|
// hits the int_slot validator.
|
|
let schema = schema_with(
|
|
"T",
|
|
&[("id", Type::Int), ("Score", Type::Int)],
|
|
);
|
|
let err = parse_command_with_schema(
|
|
"update T set Score=3.14 where id=1",
|
|
&schema,
|
|
)
|
|
.expect_err("should reject");
|
|
match err {
|
|
crate::dsl::ParseError::Invalid { message, .. } => {
|
|
assert!(
|
|
message.contains("integer") || message.contains("3.14"),
|
|
"got: {message}"
|
|
);
|
|
}
|
|
other => panic!("expected Invalid, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn phase_d_delete_where_uses_typed_column_value() {
|
|
// `where id=1` — id is Int; `1` matches the int_slot.
|
|
let schema = schema_with("T", &[("id", Type::Int), ("Name", Type::Text)]);
|
|
let cmd = parse_command_with_schema("delete from T where id=1", &schema)
|
|
.expect("parse");
|
|
match cmd {
|
|
Command::Delete { .. } => {}
|
|
other => panic!("expected Delete, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn phase_d_delete_where_rejects_decimal_at_int_column() {
|
|
// `where id=3.14` — id is Int; the typed slot rejects.
|
|
let schema = schema_with("T", &[("id", Type::Int)]);
|
|
let err = parse_command_with_schema("delete from T where id=3.14", &schema)
|
|
.expect_err("should reject");
|
|
match err {
|
|
crate::dsl::ParseError::Invalid { message, .. } => {
|
|
assert!(
|
|
message.contains("integer") || message.contains("3.14"),
|
|
"got: {message}"
|
|
);
|
|
}
|
|
other => panic!("expected Invalid, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
// ---- Typed-slot HintMode (Phase D + HintMode dispatch) ----
|
|
|
|
use crate::dsl::walker::hint_mode_at_input_with_schema;
|
|
|
|
#[test]
|
|
fn typed_hint_at_insert_first_value_position_for_int_column() {
|
|
let schema = schema_with(
|
|
"Customers",
|
|
&[("id", Type::Int), ("Name", Type::Text)],
|
|
);
|
|
match hint_mode_at_input_with_schema("insert into Customers values (", &schema) {
|
|
Some(HintMode::ProseOnly("hint.value_slot_int")) => {}
|
|
other => panic!("expected ProseOnly value_slot_int, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn typed_hint_at_insert_second_value_position_for_text_column() {
|
|
let schema = schema_with(
|
|
"Customers",
|
|
&[("id", Type::Int), ("Name", Type::Text)],
|
|
);
|
|
match hint_mode_at_input_with_schema("insert into Customers values (1, ", &schema) {
|
|
Some(HintMode::ProseOnly("hint.value_slot_text")) => {}
|
|
other => panic!("expected ProseOnly value_slot_text, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn typed_hint_at_update_set_value_uses_column_type() {
|
|
let schema = schema_with(
|
|
"Customers",
|
|
&[("id", Type::Int), ("Email", Type::Text)],
|
|
);
|
|
match hint_mode_at_input_with_schema("update Customers set Email=", &schema) {
|
|
Some(HintMode::ProseOnly("hint.value_slot_text")) => {}
|
|
other => panic!("expected ProseOnly value_slot_text, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn typed_hint_at_update_set_value_for_int_column() {
|
|
let schema = schema_with(
|
|
"Customers",
|
|
&[("id", Type::Int), ("Score", Type::Int)],
|
|
);
|
|
match hint_mode_at_input_with_schema("update Customers set Score=", &schema) {
|
|
Some(HintMode::ProseOnly("hint.value_slot_int")) => {}
|
|
other => panic!("expected ProseOnly value_slot_int, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn typed_hint_at_where_value_uses_column_type() {
|
|
let schema = schema_with("Events", &[("ts", Type::DateTime)]);
|
|
match hint_mode_at_input_with_schema("delete from Events where ts=", &schema) {
|
|
Some(HintMode::ProseOnly("hint.value_slot_datetime")) => {}
|
|
other => panic!("expected ProseOnly value_slot_datetime, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn typed_hint_falls_back_to_generic_when_schema_missing() {
|
|
// Empty schema: walker can't resolve column types.
|
|
let schema = SchemaCache::default();
|
|
match hint_mode_at_input_with_schema("insert into T values (", &schema) {
|
|
Some(HintMode::ProseOnly("hint.value_literal_slot")) => {}
|
|
other => panic!("expected generic ProseOnly, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn typed_hint_not_emitted_after_complete_value() {
|
|
// `insert into T values (1` — the int slot just MATCHED
|
|
// (`1` is a valid int). Pending_value_type was cleared on
|
|
// the successful match. No hint at this position
|
|
// (between values).
|
|
let schema = schema_with("T", &[("id", Type::Int)]);
|
|
// Walker is now waiting for `,` or `)`. No HintMode.
|
|
let mode = hint_mode_at_input_with_schema("insert into T values (1", &schema);
|
|
// The current position isn't a typed slot; expected is
|
|
// `,` / `)`. No HintMode fires.
|
|
assert!(mode.is_none(), "got {mode:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn typed_hint_for_each_type_routes_to_correct_catalog_key() {
|
|
// Confirm each Type maps to its expected catalog key
|
|
// via insert at a single-column table.
|
|
for (ty, key) in [
|
|
(Type::Int, "hint.value_slot_int"),
|
|
(Type::Real, "hint.value_slot_real"),
|
|
(Type::Decimal, "hint.value_slot_decimal"),
|
|
(Type::Bool, "hint.value_slot_bool"),
|
|
(Type::Text, "hint.value_slot_text"),
|
|
(Type::Date, "hint.value_slot_date"),
|
|
(Type::DateTime, "hint.value_slot_datetime"),
|
|
(Type::Blob, "hint.value_slot_blob"),
|
|
(Type::Serial, "hint.value_slot_serial"),
|
|
(Type::ShortId, "hint.value_slot_shortid"),
|
|
] {
|
|
let schema = schema_with("T", &[("c", ty)]);
|
|
let mode = hint_mode_at_input_with_schema("insert into T values (", &schema);
|
|
assert!(
|
|
matches!(mode, Some(HintMode::ProseOnly(k)) if k == key),
|
|
"expected ProseOnly({key}) for type {ty:?}, got {mode:?}",
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn phase_d_update_multi_assignment_uses_per_column_types() {
|
|
let schema = schema_with(
|
|
"Customers",
|
|
&[
|
|
("id", Type::Int),
|
|
("Name", Type::Text),
|
|
("Score", Type::Int),
|
|
],
|
|
);
|
|
// `Score=42` (int slot) and `Name='Alice'` (text slot)
|
|
// — each value slot dispatches on the column whose
|
|
// ident matched immediately before.
|
|
let cmd = parse_command_with_schema(
|
|
"update Customers set Score=42, Name='Alice' where id=1",
|
|
&schema,
|
|
)
|
|
.expect("parse");
|
|
match cmd {
|
|
Command::Update { assignments, .. } => {
|
|
assert_eq!(assignments.len(), 2);
|
|
assert_eq!(assignments[0].0, "Score");
|
|
assert_eq!(assignments[1].0, "Name");
|
|
}
|
|
other => panic!("expected Update, got {other:?}"),
|
|
}
|
|
}
|
|
}
|