Files
rdbms-playground/src/dsl/parser.rs
T
claude@clouddev1 50b3542050 ADR-0024 Phase A: walker framework + app-lifecycle commands
Stand up the unified-grammar tree walker alongside the existing
chumsky parser and migrate the eleven app-lifecycle commands
(quit, help, rebuild, save / save as, new, load, export, import,
mode, messages) end-to-end. The router in parse_tokens consults
the walker first; non-migrated commands still fall through to
chumsky.

Scope:
- src/dsl/grammar/{mod,app}.rs: Node enum (13 kinds), Word /
  IdentSource / HintMode / HighlightClass / ValidationError /
  CommandNode types, REGISTRY of the eleven app commands.
- src/dsl/walker/{mod,driver,context,outcome,lex_helpers}.rs:
  scannerless byte-level walker, per-node-kind dispatch with
  Choice/Seq/Optional backtracking, WalkContext (Phase B-D
  schema fields stubbed), WalkOutcome with Match/Incomplete/
  Mismatch/ValidationFailed.
- src/dsl/parser.rs: try_walker_route() runs first in
  parse_tokens; bridge converts WalkOutcome to ParseError
  preserving catalog wording (mode.unknown / messages.unknown
  surface verbatim via friendly::translate). Legacy
  try_parse_app_path_command deleted; chumsky's bare-keyword
  app branches remain unreachable until Phase F sweep.

Walker design choices worth noting:
- mode <value> / messages <value> use Choice(Word, Word, Ident)
  so known keywords appear in the expected-set; the trailing
  Ident catch-all funnels unknown values into the friendly
  validator that always errors with the catalog wording.
- save / save as is one CommandNode (Optional(Word("as"))) -
  closes the round-5 "save Tab can't offer as" limitation
  structurally.
- Path-bearing UX shipped per ADR-0024: BarePath terminates at
  whitespace; paths with spaces use the (not-yet-wired) quoted
  form. Existing tests pass on the new shape.

Tests:
- 28 new walker-specific tests in dsl::walker::tests covering
  every app-lifecycle command, friendly-error wording for
  mode/messages unknown values, trailing-garbage detection,
  whitespace tolerance, and routing fall-through.
- Total: 805 passed, 0 failed, 1 ignored (was 777 / 1).
- cargo clippy --all-targets -- -D warnings clean.
2026-05-15 06:39:29 +00:00

2050 lines
67 KiB
Rust

//! DSL parser (ADR-0020 + ADR-0021).
//!
//! Two-phase: a lexer (`crate::dsl::lexer`) produces a span-tagged
//! token stream, and chumsky combinators over `&[Token]` build the
//! `Command` AST. Keyword identity is exact via the `Keyword` enum
//! from `crate::dsl::keyword`; alternative-aggregation across
//! `choice` is chumsky-native (the load-bearing fix that motivated
//! ADR-0020).
//!
//! Errors from chumsky are mapped to the local [`ParseError`] type
//! so callers do not depend on chumsky's API surface.
use chumsky::error::{RichPattern, RichReason};
use chumsky::prelude::*;
use crate::dsl::action::ReferentialAction;
use crate::dsl::command::{
AppCommand, ChangeColumnMode, ColumnSpec, Command, MessagesValue, ModeValue,
RelationshipSelector, RowFilter,
};
use crate::dsl::ident_slot::IdentSlot;
use crate::dsl::keyword::{Keyword, Punct};
use crate::dsl::lexer::{LexError, Token, TokenKind, lex};
use crate::dsl::types::Type;
use crate::dsl::value::Value;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParseError {
Invalid {
message: String,
position: usize,
/// True when the parse failed because more input was
/// expected — i.e. a structural failure with no
/// next-token to point at. Used by the input renderer
/// (ADR-0022 §4) to distinguish "incomplete but
/// plausible" from "definite error" mid-typing.
///
/// Custom errors raised by `try_map` are conservatively
/// classified as `at_eof = true` because we cannot, at
/// this layer, tell apart "tables need at least one
/// column" (incomplete: more input would help) from
/// "--force-conversion and --dont-convert are mutually
/// exclusive" (definite: user must remove a token).
/// Erring on `true` means custom-error inputs do not
/// get a live error overlay; the parse error still
/// fires on submit. A future refinement may carry an
/// explicit `is_definite` tag through custom errors.
at_eof: bool,
/// Human-rendered names of patterns the parser was
/// looking for at the failure point: `\`create\``,
/// `identifier`, etc. Same forms `humanise()` uses
/// inside the `message` sentence, but as discrete
/// items so callers (the hint panel, ADR-0022 §6)
/// can render them in their own framing. Empty for
/// custom errors (which have no expected-set
/// framing).
expected: Vec<String>,
},
Empty,
}
impl std::fmt::Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Invalid { message, .. } => f.write_str(&crate::t!(
"parse.error_wrapper",
detail = message,
)),
Self::Empty => f.write_str(&crate::t!("parse.empty")),
}
}
}
impl std::error::Error for ParseError {}
impl ParseError {
#[must_use]
pub const fn position(&self) -> Option<usize> {
match self {
Self::Invalid { position, .. } => Some(*position),
Self::Empty => None,
}
}
#[must_use]
pub const fn at_eof(&self) -> bool {
match self {
Self::Invalid { at_eof, .. } => *at_eof,
Self::Empty => true,
}
}
}
/// Parse a single DSL command end-to-end.
pub fn parse_command(input: &str) -> Result<Command, ParseError> {
if input.trim().is_empty() {
return Err(ParseError::Empty);
}
let tokens = lex(input);
parse_tokens(&tokens, input)
}
/// Parse a token slice into a `Command`. The `source` argument is
/// kept in scope so the `replay` bare-path special case
/// (ADR-0020 §6) can source-slice its argument.
///
/// Public so future I3 (tab completion) and I4 (syntax
/// highlighting) work can re-enter the parser at this layer
/// without having to re-lex.
pub fn parse_tokens(tokens: &[Token], source: &str) -> Result<Command, ParseError> {
if tokens.is_empty() {
return Err(ParseError::Empty);
}
// ADR-0024 Phase A: the unified-grammar walker owns the
// app-lifecycle commands (quit, help, rebuild, save / save
// as, new, load, export, import, mode, messages). The
// walker engages on input whose first identifier-shape
// token matches a registered entry word; otherwise the
// router falls through to the legacy chumsky path below.
if let Some(result) = try_walker_route(source) {
return result;
}
if let Some(result) = try_parse_replay_with_bare_path(tokens, source) {
return result;
}
match command_parser().parse(tokens).into_result() {
Ok(cmd) => Ok(cmd),
Err(errs) => Err(into_parse_error(&errs, tokens, source)),
}
}
/// Walker route (ADR-0024 §migration Phase A). Returns `None`
/// when the walker doesn't engage (input doesn't start with a
/// migrated entry keyword); the router falls through to the
/// chumsky path for non-migrated commands.
fn try_walker_route(source: &str) -> Option<Result<Command, ParseError>> {
use crate::dsl::walker::{self, outcome::WalkBound};
let mut ctx = walker::context::WalkContext::new();
let (result, command) = walker::walk(source, WalkBound::EndOfInput, &mut ctx);
let result = result?;
Some(walker_outcome_to_parse_result(result, command))
}
fn walker_outcome_to_parse_result(
result: crate::dsl::walker::outcome::WalkResult,
command: Option<Command>,
) -> Result<Command, ParseError> {
use crate::dsl::walker::outcome::WalkOutcome;
match result.outcome {
WalkOutcome::Match { .. } => command.ok_or_else(|| ParseError::Invalid {
message: crate::t!(
"parse.error_wrapper",
detail = String::from("AST builder failed")
),
position: 0,
at_eof: false,
expected: Vec::new(),
}),
WalkOutcome::Incomplete { position, expected } => Err(ParseError::Invalid {
message: format_walker_error(true, &expected, None),
position,
at_eof: true,
expected: expected.iter().map(format_expectation).collect(),
}),
WalkOutcome::Mismatch { position, expected } => Err(ParseError::Invalid {
message: format_walker_error(false, &expected, Some(position)),
position,
at_eof: false,
expected: expected.iter().map(format_expectation).collect(),
}),
WalkOutcome::ValidationFailed { position, error } => {
// Runtime catalog lookup: walker carries the catalog
// key + args at `Node::Ident` validators (e.g.,
// `mode.unknown`). The `t!` macro requires a literal
// key, so we call `friendly::translate` directly.
let arg_refs: Vec<(&str, &dyn std::fmt::Display)> = error
.args
.iter()
.map(|(k, v)| (*k, v as &dyn std::fmt::Display))
.collect();
let message = crate::friendly::translate(error.message_key, &arg_refs);
Err(ParseError::Invalid {
message,
position,
at_eof: false,
expected: Vec::new(),
})
}
}
}
fn format_expectation(e: &crate::dsl::walker::outcome::Expectation) -> String {
use crate::dsl::walker::outcome::Expectation;
match e {
Expectation::Word(w) => format!("`{w}`"),
Expectation::Ident { role } => (*role).to_string(),
Expectation::Punct(c) => format!("`{c}`"),
Expectation::NumberLit => "number".to_string(),
Expectation::StringLit => "string literal".to_string(),
Expectation::BlobLit => "blob literal".to_string(),
Expectation::Flag(name) => format!("`--{name}`"),
Expectation::BarePath => "path".to_string(),
Expectation::EndOfInput => "end of input".to_string(),
}
}
fn format_walker_error(
at_eof: bool,
expected: &[crate::dsl::walker::outcome::Expectation],
_position: Option<usize>,
) -> String {
let parts: Vec<String> = expected.iter().map(format_expectation).collect();
let joined = oxford_join(&parts);
if at_eof {
if joined.is_empty() {
crate::t!("parse.empty")
} else {
format!("expected {joined}")
}
} else if joined.is_empty() {
"unexpected input".to_string()
} else {
format!("expected {joined}")
}
}
fn oxford_join(items: &[String]) -> String {
match items.len() {
0 => String::new(),
1 => items[0].clone(),
2 => format!("{} or {}", items[0], items[1]),
_ => {
let last = items.len() - 1;
let head = items[..last].join(", ");
format!("{}, or {}", head, items[last])
}
}
}
/// `replay` source-slice special case (ADR-0020 §6).
///
/// `replay <bare-path>` lets the user write paths containing
/// `/`, `.`, `~`, etc. — characters that the lexer would either
/// classify as `Punct` or as `Error(UnknownChar)`. To keep the
/// existing UX working, we detect `replay` followed by anything
/// other than a `StringLiteral` and source-slice the rest of
/// the input as the path. The quoted form (`replay '<path>'`)
/// goes through the regular chumsky path.
fn try_parse_replay_with_bare_path(
tokens: &[Token],
source: &str,
) -> Option<Result<Command, ParseError>> {
let first = tokens.first()?;
if !matches!(first.kind, TokenKind::Keyword(Keyword::Replay)) {
return None;
}
if matches!(
tokens.get(1).map(|t| &t.kind),
Some(TokenKind::StringLiteral(_))
) {
// Quoted form — chumsky handles it (and rejects any
// trailing garbage).
return None;
}
let after_replay = first.span.1;
let rest = source[after_replay..].trim();
if rest.is_empty() {
// `replay` with nothing after — produce the same shape
// of error chumsky would (positioned where the path
// should have started).
return Some(Err(ParseError::Invalid {
message: crate::t!("parse.custom.replay_path_expected"),
position: after_replay,
at_eof: true,
expected: vec!["path".to_string()],
}));
}
Some(Ok(Command::Replay {
path: rest.to_string(),
}))
}
// ADR-0024 Phase A removed `try_parse_app_path_command`: the
// walker (`crate::dsl::walker`) now owns export / import end-to-
// end (including their path arguments via `BarePath`). The
// chumsky-side bare-keyword branches in `command_parser`
// (`export_no_arg`, `import_no_arg`) are unreachable in practice
// but stay declared until Phase F sweeps the chumsky path.
// =========================================================
// Token-aware combinator helpers (ADR-0020 §5)
// =========================================================
/// Match a specific keyword token.
fn kw<'a>(
target: Keyword,
) -> impl Parser<'a, &'a [Token], (), extra::Err<Rich<'a, Token>>> + Clone {
select_ref! {
Token { kind: TokenKind::Keyword(k), .. } if *k == target => ()
}
.labelled(format!("`{}`", target.as_str()))
.as_context()
}
/// Match a specific punctuation token.
fn punct<'a>(
target: Punct,
) -> impl Parser<'a, &'a [Token], (), extra::Err<Rich<'a, Token>>> + Clone {
select_ref! {
Token { kind: TokenKind::Punct(p), .. } if *p == target => ()
}
.labelled(format!("`{}`", target.as_char()))
.as_context()
}
/// Match any identifier token, returning its name. Internal —
/// command parsers must use `ident_ctx(slot)` so the
/// completion engine knows what kind of identifier each
/// position expects (ADR-0022 §8). Bare `ident_inner()` calls
/// outside this module would skip the slot annotation. The
/// label is applied by `ident_ctx` (one per call site) — none
/// here.
fn ident_inner<'a>()
-> impl Parser<'a, &'a [Token], String, extra::Err<Rich<'a, Token>>> + Clone {
select_ref! {
Token { kind: TokenKind::Identifier(s), .. } => s.clone()
}
}
/// Tag-and-parse an identifier slot. The slot's user-facing
/// label (`IdentSlot::expected_label`) replaces the generic
/// "identifier" in the parser's expected-set machinery, so
/// the error message reads "expected table name" /
/// "expected column name" / "expected relationship name" /
/// "expected identifier" depending on the call site
/// (ADR-0022 stage 8c). The completion engine reverses the
/// mapping via `IdentSlot::from_expected_label` to know what
/// schema list to consult.
fn ident_ctx<'a>(
slot: crate::dsl::ident_slot::IdentSlot,
) -> impl Parser<'a, &'a [Token], String, extra::Err<Rich<'a, Token>>> + Clone {
ident_inner().labelled(slot.expected_label()).as_context()
}
/// Match a number-literal token, returning a `Value::Number`.
fn number_literal<'a>()
-> impl Parser<'a, &'a [Token], Value, extra::Err<Rich<'a, Token>>> + Clone {
select_ref! {
Token { kind: TokenKind::Number(s), .. } => Value::Number(s.clone())
}
.labelled("number")
.as_context()
}
/// Match a string-literal token, returning a `Value::Text`.
fn string_literal<'a>()
-> impl Parser<'a, &'a [Token], Value, extra::Err<Rich<'a, Token>>> + Clone {
select_ref! {
Token { kind: TokenKind::StringLiteral(s), .. } => Value::Text(s.clone())
}
.labelled("string literal")
.as_context()
}
/// Match a string-literal token, returning the raw payload
/// (used by the quoted-replay path).
fn string_payload<'a>()
-> impl Parser<'a, &'a [Token], String, extra::Err<Rich<'a, Token>>> + Clone {
select_ref! {
Token { kind: TokenKind::StringLiteral(s), .. } => s.clone()
}
.labelled("path")
.as_context()
}
/// Match a flag token whose payload equals `name` (the part
/// after `--`).
fn flag<'a>(
name: &'static str,
) -> impl Parser<'a, &'a [Token], (), extra::Err<Rich<'a, Token>>> + Clone {
select_ref! {
Token { kind: TokenKind::Flag(s), .. } if s == name => ()
}
.labelled(format!("`--{name}`"))
.as_context()
}
/// Match an identifier and parse it as a `Type`. Surfaces the
/// existing "unknown type 'X' (expected one of: …)" message
/// (ADR-0020 §4) — keyword-shape errors aggregate naturally,
/// content errors keep their hand-written voice.
///
/// Labelled "type" so the structural-error wording reads as
/// "next: type" rather than the unhelpful "something else"
/// the unlabelled `select_ref!` would otherwise produce.
fn type_keyword<'a>()
-> impl Parser<'a, &'a [Token], Type, extra::Err<Rich<'a, Token>>> + Clone {
// Label is applied to the select-ref alone (before
// try_map) so the unknown-type custom error from try_map
// still surfaces — labelled() on the whole chain would
// replace it with "expected type" and lose the
// "unknown type 'X' (expected one of: …)" wording.
select_ref! {
Token { kind: TokenKind::Identifier(s), .. } = e => (s.clone(), e.span())
}
.labelled("type")
.try_map(|(name, span): (String, SimpleSpan), _| {
name.parse::<Type>()
.map_err(|err| Rich::custom(span, err.to_string()))
})
}
// =========================================================
// Top-level command parser
// =========================================================
fn command_parser<'a>()
-> impl Parser<'a, &'a [Token], Command, extra::Err<Rich<'a, Token>>> + Clone {
let create_table = kw(Keyword::Create)
.ignore_then(kw(Keyword::Table))
.ignore_then(ident_ctx(IdentSlot::NewName))
.then(with_pk_clause())
.try_map(|(name, pk_specs), span| {
if pk_specs.is_empty() {
return Err(Rich::custom(
span,
crate::t!("parse.custom.create_table_needs_pk"),
));
}
let columns: Vec<ColumnSpec> = pk_specs
.iter()
.map(|(n, t)| ColumnSpec {
name: n.clone(),
ty: *t,
})
.collect();
let primary_key = pk_specs.into_iter().map(|(n, _)| n).collect();
Ok(Command::CreateTable {
name,
columns,
primary_key,
})
});
let drop_table = kw(Keyword::Drop)
.ignore_then(kw(Keyword::Table))
.ignore_then(ident_ctx(IdentSlot::TableName))
.map(|name| Command::DropTable { name });
// `add column [to] [table] <T>: <col> (<type>)`. Both
// prepositions independently optional — bare identifiers
// accepted in the unambiguous position.
let add_column = kw(Keyword::Add)
.ignore_then(kw(Keyword::Column))
.ignore_then(kw(Keyword::To).or_not())
.ignore_then(kw(Keyword::Table).or_not())
.ignore_then(ident_ctx(IdentSlot::TableName))
.then_ignore(punct(Punct::Colon))
.then(ident_ctx(IdentSlot::NewName))
.then_ignore(punct(Punct::OpenParen))
.then(type_keyword())
.then_ignore(punct(Punct::CloseParen))
.map(|((table, column), ty)| Command::AddColumn { table, column, ty });
let drop_column = kw(Keyword::Drop)
.ignore_then(kw(Keyword::Column))
.ignore_then(kw(Keyword::From).or_not())
.ignore_then(kw(Keyword::Table).or_not())
.ignore_then(ident_ctx(IdentSlot::TableName))
.then_ignore(punct(Punct::Colon))
.then(ident_ctx(IdentSlot::Column))
.map(|(table, column)| Command::DropColumn { table, column });
let rename_column = kw(Keyword::Rename)
.ignore_then(kw(Keyword::Column))
.ignore_then(kw(Keyword::In).or_not())
.ignore_then(kw(Keyword::Table).or_not())
.ignore_then(ident_ctx(IdentSlot::TableName))
.then_ignore(punct(Punct::Colon))
.then(ident_ctx(IdentSlot::Column))
.then_ignore(kw(Keyword::To))
.then(ident_ctx(IdentSlot::NewName))
.map(|((table, old), new)| Command::RenameColumn { table, old, new });
let change_column = kw(Keyword::Change)
.ignore_then(kw(Keyword::Column))
.ignore_then(kw(Keyword::In).or_not())
.ignore_then(kw(Keyword::Table).or_not())
.ignore_then(ident_ctx(IdentSlot::TableName))
.then_ignore(punct(Punct::Colon))
.then(ident_ctx(IdentSlot::Column))
.then_ignore(punct(Punct::OpenParen))
.then(type_keyword())
.then_ignore(punct(Punct::CloseParen))
.then(change_column_flags())
.map(|(((table, column), ty), mode)| Command::ChangeColumnType {
table,
column,
ty,
mode,
});
let add_relationship = add_relationship_parser();
let drop_relationship = drop_relationship_parser();
let show_data = kw(Keyword::Show)
.ignore_then(kw(Keyword::Data))
.ignore_then(ident_ctx(IdentSlot::TableName))
.map(|name| Command::ShowData { name });
let show_table = kw(Keyword::Show)
.ignore_then(kw(Keyword::Table))
.ignore_then(ident_ctx(IdentSlot::TableName))
.map(|name| Command::ShowTable { name });
let insert_cmd = insert_parser();
let update_cmd = update_parser();
let delete_cmd = delete_parser();
// The bare-path replay form is intercepted before chumsky
// sees the tokens (ADR-0020 §6); only the quoted form
// arrives here.
let replay = kw(Keyword::Replay)
.ignore_then(string_payload())
.map(|path| Command::Replay { path });
// ---- App-lifecycle commands -----------------------------
// No-arg variants and the keyword-value variants. Path-
// bearing variants (`export <path>`, `import <zip> [as
// <target>]`) are handled by `try_parse_app_path_command`
// BEFORE chumsky runs; the bare-keyword forms below
// surface the `Path: None` / no-source variants for
// empty-prompt completion + usage rendering.
let quit_cmd = kw(Keyword::Quit).map(|()| Command::App(AppCommand::Quit));
let help_cmd = kw(Keyword::Help).map(|()| Command::App(AppCommand::Help));
let rebuild_cmd =
kw(Keyword::Rebuild).map(|()| Command::App(AppCommand::Rebuild));
// `save as` must be tried before bare `save` (more specific).
let save_as_cmd = kw(Keyword::Save)
.then_ignore(kw(Keyword::As))
.map(|()| Command::App(AppCommand::SaveAs));
let save_cmd = kw(Keyword::Save).map(|()| Command::App(AppCommand::Save));
let new_cmd = kw(Keyword::New).map(|()| Command::App(AppCommand::New));
let load_cmd = kw(Keyword::Load).map(|()| Command::App(AppCommand::Load));
let export_no_arg =
kw(Keyword::Export).map(|()| Command::App(AppCommand::Export { path: None }));
let import_no_arg = kw(Keyword::Import).map(|()| {
Command::App(AppCommand::Import {
path: String::new(),
target: None,
})
});
// `mode <value>` and `messages [<value>]` accept either the
// known keyword forms or any identifier — the identifier
// branch funnels through `try_map` into a friendly
// `mode.unknown` / `messages.unknown` error rather than the
// generic structural-error wording. Mirrors the type-name
// pattern in `type_keyword` (ADR-0020 §4).
let known_mode = choice((
kw(Keyword::Simple).to(ModeValue::Simple),
kw(Keyword::Advanced).to(ModeValue::Advanced),
));
let unknown_mode = ident_inner().try_map(|s, span| {
Err::<ModeValue, _>(Rich::custom(
span,
crate::t!("mode.unknown", value = s),
))
});
let mode_cmd = kw(Keyword::Mode)
.ignore_then(choice((known_mode, unknown_mode)))
.map(|value| Command::App(AppCommand::Mode { value }));
let known_messages = choice((
kw(Keyword::Short).to(MessagesValue::Short),
kw(Keyword::Verbose).to(MessagesValue::Verbose),
));
let unknown_messages = ident_inner().try_map(|s, span| {
Err::<MessagesValue, _>(Rich::custom(
span,
crate::t!("messages.unknown", value = s),
))
});
let messages_cmd = kw(Keyword::Messages)
.ignore_then(choice((known_messages, unknown_messages)).or_not())
.map(|value| Command::App(AppCommand::Messages { value }));
choice((
create_table,
// `drop column` and `drop relationship` come before
// `drop table` because both are more specific —
// chumsky's `choice` tries each in order.
drop_column,
drop_relationship,
drop_table,
add_column,
add_relationship,
rename_column,
change_column,
show_data,
show_table,
insert_cmd,
update_cmd,
delete_cmd,
replay,
// App commands. `save as` before bare `save`; everything
// else order-agnostic.
quit_cmd,
help_cmd,
rebuild_cmd,
save_as_cmd,
save_cmd,
new_cmd,
load_cmd,
export_no_arg,
import_no_arg,
mode_cmd,
messages_cmd,
))
.then_ignore(end())
}
// =========================================================
// Per-command sub-parsers
// =========================================================
fn insert_parser<'a>()
-> impl Parser<'a, &'a [Token], Command, extra::Err<Rich<'a, Token>>> + Clone {
let column_list = punct(Punct::OpenParen)
.ignore_then(
ident_ctx(IdentSlot::Column)
.separated_by(punct(Punct::Comma))
.at_least(1)
.collect::<Vec<_>>(),
)
.then_ignore(punct(Punct::CloseParen));
let value_list = punct(Punct::OpenParen)
.ignore_then(
value_literal()
.separated_by(punct(Punct::Comma))
.at_least(1)
.collect::<Vec<_>>(),
)
.then_ignore(punct(Punct::CloseParen));
let with_columns_and_values = column_list
.clone()
.then_ignore(kw(Keyword::Values))
.then(value_list.clone())
.map(|(cols, vals)| (Some(cols), vals));
let with_values_keyword_only = kw(Keyword::Values)
.ignore_then(value_list.clone())
.map(|vals| (None, vals));
let bare_value_list = value_list.map(|vals| (None, vals));
kw(Keyword::Insert)
.ignore_then(kw(Keyword::Into))
.ignore_then(ident_ctx(IdentSlot::TableName))
.then(choice((
with_columns_and_values,
with_values_keyword_only,
bare_value_list,
)))
.map(|(table, (columns, values))| Command::Insert {
table,
columns,
values,
})
}
fn update_parser<'a>()
-> impl Parser<'a, &'a [Token], Command, extra::Err<Rich<'a, Token>>> + Clone {
let assignment = ident_ctx(IdentSlot::Column)
.then_ignore(punct(Punct::Equals))
.then(value_literal());
let assignments = assignment
.separated_by(punct(Punct::Comma))
.at_least(1)
.collect::<Vec<_>>();
kw(Keyword::Update)
.ignore_then(ident_ctx(IdentSlot::TableName))
.then_ignore(kw(Keyword::Set))
.then(assignments)
.then(filter_clause())
.map(|((table, assignments), filter)| Command::Update {
table,
assignments,
filter,
})
}
fn delete_parser<'a>()
-> impl Parser<'a, &'a [Token], Command, extra::Err<Rich<'a, Token>>> + Clone {
kw(Keyword::Delete)
.ignore_then(kw(Keyword::From))
.ignore_then(ident_ctx(IdentSlot::TableName))
.then(filter_clause())
.map(|(table, filter)| Command::Delete { table, filter })
}
fn filter_clause<'a>()
-> impl Parser<'a, &'a [Token], RowFilter, extra::Err<Rich<'a, Token>>> + Clone {
let where_clause = kw(Keyword::Where)
.ignore_then(ident_ctx(IdentSlot::Column))
.then_ignore(punct(Punct::Equals))
.then(value_literal())
.map(|(column, value)| RowFilter::Where { column, value });
let all_rows = flag("all-rows").to(RowFilter::AllRows);
// No `.labelled()` wrap here: chumsky's expected-set then
// surfaces the constituent options (`` `where` ``,
// `` `--all-rows` ``) individually instead of collapsing
// them to a single descriptive label. The completion
// engine needs the constituents to offer Tab candidates
// (ADR-0022 §8); the resulting error prose ("expected `,`,
// `where`, or `--all-rows`") reads cleanly enough without
// hand-wrapping.
where_clause.or(all_rows)
}
fn value_literal<'a>()
-> impl Parser<'a, &'a [Token], Value, extra::Err<Rich<'a, Token>>> + Clone {
choice((
kw(Keyword::Null).to(Value::Null),
kw(Keyword::True).to(Value::Bool(true)),
kw(Keyword::False).to(Value::Bool(false)),
number_literal(),
string_literal(),
))
}
fn add_relationship_parser<'a>()
-> impl Parser<'a, &'a [Token], Command, extra::Err<Rich<'a, Token>>> + Clone {
// `1:n` lexes as Number("1"), Punct(Colon), Identifier("n").
let one_token = select_ref! {
Token { kind: TokenKind::Number(s), .. } if s == "1" => ()
}
.labelled("`1`")
.as_context();
let n_ident = select_ref! {
Token { kind: TokenKind::Identifier(s), .. } if s.eq_ignore_ascii_case("n") => ()
}
.labelled("`n`")
.as_context();
let one_to_n = one_token
.ignore_then(punct(Punct::Colon))
.ignore_then(n_ident);
let optional_name = kw(Keyword::As).ignore_then(ident_ctx(IdentSlot::NewName)).or_not();
kw(Keyword::Add)
.ignore_then(one_to_n)
.ignore_then(kw(Keyword::Relationship))
.ignore_then(optional_name)
.then_ignore(kw(Keyword::From))
.then(qualified_column())
.then_ignore(kw(Keyword::To))
.then(qualified_column())
.then(referential_clauses())
.then(create_fk_flag())
.map(
|((((name, parent), child), (on_delete, on_update)), create_fk)| {
Command::AddRelationship {
name,
parent_table: parent.0,
parent_column: parent.1,
child_table: child.0,
child_column: child.1,
on_delete,
on_update,
create_fk,
}
},
)
}
fn drop_relationship_parser<'a>()
-> impl Parser<'a, &'a [Token], Command, extra::Err<Rich<'a, Token>>> + Clone {
let endpoints_form = kw(Keyword::From)
.ignore_then(qualified_column())
.then_ignore(kw(Keyword::To))
.then(qualified_column())
.map(|(parent, child)| RelationshipSelector::Endpoints {
parent_table: parent.0,
parent_column: parent.1,
child_table: child.0,
child_column: child.1,
});
let named_form = ident_ctx(IdentSlot::RelationshipName)
.map(|name| RelationshipSelector::Named { name });
kw(Keyword::Drop)
.ignore_then(kw(Keyword::Relationship))
.ignore_then(choice((endpoints_form, named_form)))
.map(|selector| Command::DropRelationship { selector })
}
fn qualified_column<'a>()
-> impl Parser<'a, &'a [Token], (String, String), extra::Err<Rich<'a, Token>>> + Clone {
ident_ctx(IdentSlot::TableName)
.then_ignore(punct(Punct::Dot))
.then(ident_ctx(IdentSlot::Column))
}
fn referential_clauses<'a>() -> impl Parser<
'a,
&'a [Token],
(ReferentialAction, ReferentialAction),
extra::Err<Rich<'a, Token>>,
> + Clone {
let target = kw(Keyword::Delete)
.to(ReferentialActionTarget::Delete)
.or(kw(Keyword::Update).to(ReferentialActionTarget::Update));
let clause = kw(Keyword::On)
.ignore_then(target)
.then(action_keyword())
.map(|(t, a)| (t, a));
clause
.repeated()
.at_most(2)
.collect::<Vec<_>>()
.try_map(|clauses, span| {
let mut on_delete = None;
let mut on_update = None;
for (target, action) in clauses {
let slot = match target {
ReferentialActionTarget::Delete => &mut on_delete,
ReferentialActionTarget::Update => &mut on_update,
};
if slot.is_some() {
return Err(Rich::custom(
span,
crate::t!(
"parse.custom.on_action_specified_twice",
target = target,
),
));
}
*slot = Some(action);
}
Ok((
on_delete.unwrap_or_else(ReferentialAction::default_action),
on_update.unwrap_or_else(ReferentialAction::default_action),
))
})
}
#[derive(Debug, Clone, Copy)]
enum ReferentialActionTarget {
Delete,
Update,
}
impl std::fmt::Display for ReferentialActionTarget {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Delete => "delete",
Self::Update => "update",
})
}
}
fn action_keyword<'a>()
-> impl Parser<'a, &'a [Token], ReferentialAction, extra::Err<Rich<'a, Token>>> + Clone {
choice((
kw(Keyword::Set)
.ignore_then(kw(Keyword::Null))
.to(ReferentialAction::SetNull),
kw(Keyword::No)
.ignore_then(kw(Keyword::Action))
.to(ReferentialAction::NoAction),
kw(Keyword::Cascade).to(ReferentialAction::Cascade),
kw(Keyword::Restrict).to(ReferentialAction::Restrict),
))
}
fn create_fk_flag<'a>()
-> impl Parser<'a, &'a [Token], bool, extra::Err<Rich<'a, Token>>> + Clone {
flag("create-fk").or_not().map(|opt| opt.is_some())
}
fn change_column_flags<'a>()
-> impl Parser<'a, &'a [Token], ChangeColumnMode, extra::Err<Rich<'a, Token>>> + Clone {
let force = flag("force-conversion").to(ChangeColumnMode::ForceConversion);
let dont = flag("dont-convert").to(ChangeColumnMode::DontConvert);
choice((force, dont))
.repeated()
.collect::<Vec<_>>()
.try_map(|flags, span| match flags.as_slice() {
[] => Ok(ChangeColumnMode::Default),
[single] => Ok(*single),
_ => Err(Rich::custom(
span,
crate::t!("parse.custom.change_column_flags_exclusive"),
)),
})
}
fn with_pk_clause<'a>()
-> impl Parser<'a, &'a [Token], Vec<(String, Type)>, extra::Err<Rich<'a, Token>>> + Clone {
// Each PK spec names a NEW column inside the table being
// created. `with_pk_clause` is reached only inside
// `create_table`, where the surrounding context is
// building a new schema entity from scratch.
let single = ident_ctx(IdentSlot::NewName)
.then_ignore(punct(Punct::Colon))
.then(type_keyword())
.map(|(name, ty)| (name, ty));
let spec_list = single
.clone()
.separated_by(punct(Punct::Comma))
.at_least(1)
.collect::<Vec<_>>();
kw(Keyword::With)
.ignore_then(kw(Keyword::Pk))
.ignore_then(spec_list.or_not())
.map(|maybe_specs| {
// `with pk` alone defaults to a serial id PK.
maybe_specs.unwrap_or_else(|| vec![("id".to_string(), Type::Serial)])
})
.or_not()
.map(Option::unwrap_or_default)
}
// =========================================================
// Error humanisation
// =========================================================
fn into_parse_error(errs: &[Rich<'_, Token>], tokens: &[Token], source: &str) -> ParseError {
// Prefer custom-reason errors over chumsky's structural
// ones — those carry our hand-tuned messages from `try_map`
// (e.g. "unknown type 'varchar' (expected one of: ...)").
let chosen = errs
.iter()
.find(|e| matches!(e.reason(), RichReason::Custom(_)))
.unwrap_or_else(|| errs.first().expect("parser failure with no error"));
let chumsky_span = chosen.span();
let position = source_position_at(tokens, chumsky_span.start, source);
let message = humanise(chosen, tokens, source);
let (at_eof, expected) = match chosen.reason() {
// Structural failures know whether they ran out of
// input — `found = None` ⇔ EOF — and carry the
// expected-pattern set chumsky was looking for.
RichReason::ExpectedFound { expected, found } => {
(found.is_none(), describe_expected(expected))
}
// Custom errors: see the docstring on
// `ParseError::Invalid::at_eof` for why we err on the
// side of `true` (no live overlay; on-submit error
// still fires). Custom errors have no expected-set.
RichReason::Custom(_) => (true, Vec::new()),
};
ParseError::Invalid {
message,
position,
at_eof,
expected,
}
}
/// Render a chumsky expected-pattern set into the same
/// human-readable forms `humanise()` uses, but as discrete
/// items rather than an oxford-joined string. Stable order
/// (sorted, deduplicated) so callers don't have to.
fn describe_expected(expected: &[RichPattern<'_, Token>]) -> Vec<String> {
let has_concrete = expected.iter().any(|p| {
matches!(
p,
RichPattern::Token(_)
| RichPattern::Identifier(_)
| RichPattern::Label(_)
| RichPattern::EndOfInput
)
});
let mut items: Vec<String> = expected
.iter()
.filter(|p| {
!(has_concrete && matches!(p, RichPattern::Any | RichPattern::SomethingElse))
})
.map(describe_pattern)
.collect();
// Dedup preserving first occurrence (which reflects
// chumsky's traversal order — typically source order for
// `or_not` / `choice` chains). Empirically this gives a
// grammar-natural ordering: `to` before `table` in
// `add column [to] [table] …`, which alphabetical
// (table, to) would invert.
let mut seen = std::collections::HashSet::new();
items.retain(|s| seen.insert(s.clone()));
items
}
/// Translate a chumsky token-slice index into a byte position
/// in the original source. If the index points past the last
/// token (an end-of-input failure), use the last token's end
/// or, if there are no tokens, the source length.
fn source_position_at(tokens: &[Token], slice_index: usize, source: &str) -> usize {
if slice_index < tokens.len() {
tokens[slice_index].span.0
} else {
tokens.last().map_or(source.len(), |t| t.span.1)
}
}
fn humanise(err: &Rich<'_, Token>, tokens: &[Token], source: &str) -> String {
if let RichReason::Custom(msg) = err.reason() {
return msg.clone();
}
let RichReason::ExpectedFound { expected, found } = err.reason() else {
unreachable!("RichReason has only two variants today");
};
// `found` is the offending token (or None at end of input).
let found_str = found.as_ref().map_or_else(
|| "end of input".to_string(),
|maybe_ref| describe_token(maybe_ref),
);
let described = describe_expected(expected);
let expected_str = oxford_or(&described);
let chumsky_span_start = err.span().start;
let consumed = consumed_context(tokens, chumsky_span_start, source);
if expected.is_empty() {
if consumed.is_empty() {
format!("unexpected {found_str}")
} else {
format!("after `{consumed}`, unexpected {found_str}")
}
} else if consumed.is_empty() {
format!("expected {expected_str}, found {found_str}")
} else {
format!("after `{consumed}`, expected {expected_str}, found {found_str}")
}
}
fn describe_pattern(p: &RichPattern<'_, Token>) -> String {
match p {
RichPattern::Token(t) => describe_token(t),
RichPattern::Identifier(s) => format!("`{s}`"),
RichPattern::Label(s) => s.to_string(),
RichPattern::Any => "any token".to_string(),
RichPattern::SomethingElse => "something else".to_string(),
RichPattern::EndOfInput => "end of input".to_string(),
// RichPattern is non_exhaustive; cover the catch-all.
_ => "<other>".to_string(),
}
}
fn describe_token(t: &Token) -> String {
match &t.kind {
TokenKind::Keyword(k) => format!("`{}`", k.as_str()),
TokenKind::Identifier(s) => format!("`{s}`"),
TokenKind::Number(s) => format!("`{s}`"),
TokenKind::StringLiteral(_) => "string literal".to_string(),
TokenKind::Punct(p) => format!("`{}`", p.as_char()),
TokenKind::Flag(s) => format!("`--{s}`"),
TokenKind::Error(LexError::UnknownChar(c)) => {
format!("unrecognised character `{c}`")
}
TokenKind::Error(LexError::UnterminatedString) => {
"unterminated string literal".to_string()
}
TokenKind::Error(LexError::BadFlag) => "malformed flag (bare `--`)".to_string(),
}
}
/// "A, B, or C" / "A or B" / "A".
fn oxford_or(items: &[String]) -> String {
match items {
[] => String::new(),
[a] => a.clone(),
[a, b] => format!("{a} or {b}"),
rest => {
let (last, head) = rest.split_last().expect("len >= 3");
format!("{}, or {last}", head.join(", "))
}
}
}
/// Source slice covering all tokens before the failure point,
/// trimmed to a sensible length.
fn consumed_context(tokens: &[Token], chumsky_span_start: usize, source: &str) -> String {
if chumsky_span_start == 0 {
return String::new();
}
let last_consumed_index = chumsky_span_start - 1;
let Some(last_token) = tokens.get(last_consumed_index) else {
return String::new();
};
let prefix = source[..last_token.span.1].trim();
if prefix.is_empty() {
return String::new();
}
const MAX: usize = 40;
if prefix.chars().count() <= MAX {
prefix.to_string()
} else {
let tail: String = prefix
.chars()
.rev()
.take(MAX)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
format!("{tail}")
}
}
// =========================================================
// Tests
// =========================================================
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
fn ok(input: &str) -> Command {
parse_command(input).unwrap_or_else(|e| panic!("expected ok for {input:?}, got {e:?}"))
}
fn err(input: &str) -> ParseError {
parse_command(input).expect_err("expected parse error")
}
fn err_message(input: &str) -> String {
match err(input) {
ParseError::Invalid { message, .. } => message,
ParseError::Empty => panic!("unexpected empty error"),
}
}
#[test]
fn structural_error_for_show_data_without_arg() {
// ADR-0022 stage 8c: `ident_ctx(IdentSlot::TableName)`
// labels the expected slot with "table name" so the
// error reads as the more specific "expected table
// name" rather than the generic "expected identifier".
let msg = err_message("show data");
assert!(msg.contains("after `show data`"), "{msg}");
assert!(msg.contains("expected table name"), "{msg}");
assert!(msg.contains("found end of input"), "{msg}");
}
#[test]
fn structural_error_for_change_column_with_swapped_args() {
let msg = err_message("change column Rich in Customers: Rich (text)");
assert!(msg.contains("after `change column Rich`"), "{msg}");
assert!(msg.contains("expected `:`"), "{msg}");
}
fn col(name: &str, ty: Type) -> ColumnSpec {
ColumnSpec {
name: name.to_string(),
ty,
}
}
#[test]
fn bare_create_table_errors_with_helpful_message() {
let e = err("create table Customers");
match e {
ParseError::Invalid { message, .. } => {
assert!(
message.contains("with pk"),
"error should mention `with pk`:\n{message}"
);
}
ParseError::Empty => panic!("unexpected empty error"),
}
}
#[test]
fn create_table_with_pk_default_is_id_serial() {
assert_eq!(
ok("create table Customers with pk"),
Command::CreateTable {
name: "Customers".to_string(),
columns: vec![col("id", Type::Serial)],
primary_key: vec!["id".to_string()],
}
);
}
#[test]
fn create_table_with_named_typed_pk() {
assert_eq!(
ok("create table Customers with pk email:text"),
Command::CreateTable {
name: "Customers".to_string(),
columns: vec![col("email", Type::Text)],
primary_key: vec!["email".to_string()],
}
);
}
#[test]
fn create_table_with_compound_pk() {
assert_eq!(
ok("create table OrderLines with pk order_id:int,product_id:int"),
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 create_table_pk_accepts_any_user_type() {
for ty in Type::all() {
let input = format!("create table T with pk col:{}", ty.keyword());
let cmd = ok(&input);
if let Command::CreateTable {
columns,
primary_key,
..
} = cmd
{
assert_eq!(columns[0].ty, *ty);
assert_eq!(primary_key, vec!["col".to_string()]);
} else {
panic!("expected CreateTable for {input}");
}
}
}
#[test]
fn create_table_pk_tolerates_whitespace() {
assert_eq!(
ok("create table T with pk id : serial"),
Command::CreateTable {
name: "T".to_string(),
columns: vec![col("id", Type::Serial)],
primary_key: vec!["id".to_string()],
}
);
assert_eq!(
ok("create table T with pk a : int , b : int"),
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 create_table_keywords_are_case_insensitive() {
assert_eq!(
ok("CREATE TABLE Customers WITH PK email:TEXT"),
Command::CreateTable {
name: "Customers".to_string(),
columns: vec![col("email", Type::Text)],
primary_key: vec!["email".to_string()],
}
);
}
#[test]
fn drop_column_simple() {
assert_eq!(
ok("drop column from table Customers: Email"),
Command::DropColumn {
table: "Customers".to_string(),
column: "Email".to_string(),
}
);
}
#[test]
fn drop_column_accepts_bare_identifiers() {
assert_eq!(
ok("drop column Customers: Email"),
Command::DropColumn {
table: "Customers".to_string(),
column: "Email".to_string(),
}
);
assert_eq!(
ok("drop column from Customers: Email"),
Command::DropColumn {
table: "Customers".to_string(),
column: "Email".to_string(),
}
);
assert_eq!(
ok("drop column table Customers: Email"),
Command::DropColumn {
table: "Customers".to_string(),
column: "Email".to_string(),
}
);
}
#[test]
fn rename_column_simple() {
assert_eq!(
ok("rename column in table Customers: OldName to NewName"),
Command::RenameColumn {
table: "Customers".to_string(),
old: "OldName".to_string(),
new: "NewName".to_string(),
}
);
}
#[test]
fn rename_column_accepts_bare_identifiers() {
assert_eq!(
ok("rename column Customers: A to B"),
Command::RenameColumn {
table: "Customers".to_string(),
old: "A".to_string(),
new: "B".to_string(),
}
);
assert_eq!(
ok("rename column in Customers: A to B"),
Command::RenameColumn {
table: "Customers".to_string(),
old: "A".to_string(),
new: "B".to_string(),
}
);
assert_eq!(
ok("rename column table Customers: A to B"),
Command::RenameColumn {
table: "Customers".to_string(),
old: "A".to_string(),
new: "B".to_string(),
}
);
}
#[test]
fn change_column_simple() {
assert_eq!(
ok("change column in table Customers: Score (int)"),
Command::ChangeColumnType {
table: "Customers".to_string(),
column: "Score".to_string(),
ty: Type::Int,
mode: ChangeColumnMode::Default,
}
);
}
#[test]
fn change_column_accepts_bare_identifiers() {
assert_eq!(
ok("change column Customers: Score (real)"),
Command::ChangeColumnType {
table: "Customers".to_string(),
column: "Score".to_string(),
ty: Type::Real,
mode: ChangeColumnMode::Default,
}
);
}
#[test]
fn change_column_keywords_are_case_insensitive() {
assert_eq!(
ok("CHANGE COLUMN IN TABLE Customers: Score (TEXT)"),
Command::ChangeColumnType {
table: "Customers".to_string(),
column: "Score".to_string(),
ty: Type::Text,
mode: ChangeColumnMode::Default,
}
);
}
#[test]
fn change_column_with_force_conversion_flag() {
assert_eq!(
ok("change column Customers: Score (int) --force-conversion"),
Command::ChangeColumnType {
table: "Customers".to_string(),
column: "Score".to_string(),
ty: Type::Int,
mode: ChangeColumnMode::ForceConversion,
}
);
}
#[test]
fn change_column_with_dont_convert_flag() {
assert_eq!(
ok("change column Customers: Score (int) --dont-convert"),
Command::ChangeColumnType {
table: "Customers".to_string(),
column: "Score".to_string(),
ty: Type::Int,
mode: ChangeColumnMode::DontConvert,
}
);
}
#[test]
fn change_column_rejects_both_flags() {
let e = err("change column Customers: Score (int) --force-conversion --dont-convert");
match e {
ParseError::Invalid { message, .. } => {
assert!(
message.contains("--force-conversion") && message.contains("--dont-convert"),
"expected both flag names in error: {message}"
);
assert!(
message.contains("mutually exclusive") || message.contains("pick one"),
"{message}"
);
}
ParseError::Empty => panic!("unexpected empty error"),
}
}
#[test]
fn change_column_rejects_both_flags_in_either_order() {
let e = err("change column T: c (int) --dont-convert --force-conversion");
match e {
ParseError::Invalid { message, .. } => {
assert!(message.contains("mutually exclusive"), "{message}");
}
ParseError::Empty => panic!("unexpected empty error"),
}
}
#[test]
fn drop_table_simple() {
assert_eq!(
ok("drop table Customers"),
Command::DropTable {
name: "Customers".to_string()
}
);
}
#[test]
fn add_column_simple() {
assert_eq!(
ok("add column to table Customers: Name (text)"),
Command::AddColumn {
table: "Customers".to_string(),
column: "Name".to_string(),
ty: Type::Text,
}
);
}
#[test]
fn add_column_with_each_supported_type() {
for ty in Type::all() {
let input = format!("add column to table T: C ({})", ty.keyword());
assert_eq!(
ok(&input),
Command::AddColumn {
table: "T".to_string(),
column: "C".to_string(),
ty: *ty,
}
);
}
}
#[test]
fn add_column_accepts_bare_table_name() {
assert_eq!(
ok("add column Customers: Name (text)"),
Command::AddColumn {
table: "Customers".to_string(),
column: "Name".to_string(),
ty: Type::Text,
}
);
}
#[test]
fn add_column_accepts_to_alone() {
assert_eq!(
ok("add column to Customers: Name (text)"),
Command::AddColumn {
table: "Customers".to_string(),
column: "Name".to_string(),
ty: Type::Text,
}
);
}
#[test]
fn add_column_accepts_table_alone() {
assert_eq!(
ok("add column table Customers: Name (text)"),
Command::AddColumn {
table: "Customers".to_string(),
column: "Name".to_string(),
ty: Type::Text,
}
);
}
#[test]
fn add_column_tolerates_whitespace_around_punctuation() {
assert_eq!(
ok("add column to table T:Name(text)"),
Command::AddColumn {
table: "T".to_string(),
column: "Name".to_string(),
ty: Type::Text,
}
);
}
#[test]
fn empty_input_is_an_explicit_empty_error() {
assert_eq!(parse_command(""), Err(ParseError::Empty));
assert_eq!(parse_command(" "), Err(ParseError::Empty));
}
#[test]
fn unknown_command_errors() {
let e = err("frobulate Customers");
assert!(matches!(e, ParseError::Invalid { .. }), "got {e:?}");
}
#[test]
fn unknown_type_errors_with_alternatives_listed() {
let e = err("add column to table T: Name (varchar)");
match e {
ParseError::Invalid { message, .. } => {
assert!(
message.contains("varchar"),
"error should mention the bad type: {message}"
);
assert!(
message.contains("expected one of"),
"error should list valid alternatives: {message}"
);
assert!(
message.contains("text") && message.contains("shortid"),
"error should name the alternatives: {message}"
);
}
ParseError::Empty => panic!("unexpected empty error"),
}
}
#[test]
fn unknown_pk_type_errors_with_alternatives_listed() {
let e = err("create table T with pk id:varchar");
match e {
ParseError::Invalid { message, .. } => {
assert!(message.contains("varchar"), "{message}");
assert!(message.contains("expected one of"), "{message}");
}
ParseError::Empty => panic!("unexpected empty error"),
}
}
#[test]
fn trailing_garbage_errors() {
let e = err("create table Customers with pk and pickles");
assert!(matches!(e, ParseError::Invalid { .. }), "got {e:?}");
}
#[test]
fn identifier_must_start_with_letter_or_underscore() {
let e = err("create table 1Customers with pk");
assert!(matches!(e, ParseError::Invalid { .. }), "got {e:?}");
}
fn rel(
name: Option<&str>,
parent: (&str, &str),
child: (&str, &str),
on_delete: ReferentialAction,
on_update: ReferentialAction,
create_fk: bool,
) -> Command {
Command::AddRelationship {
name: name.map(String::from),
parent_table: parent.0.to_string(),
parent_column: parent.1.to_string(),
child_table: child.0.to_string(),
child_column: child.1.to_string(),
on_delete,
on_update,
create_fk,
}
}
#[test]
fn add_relationship_minimal() {
assert_eq!(
ok("add 1:n relationship from Customers.Id to Orders.CustId"),
rel(
None,
("Customers", "Id"),
("Orders", "CustId"),
ReferentialAction::NoAction,
ReferentialAction::NoAction,
false,
)
);
}
#[test]
fn add_relationship_with_name() {
assert_eq!(
ok("add 1:n relationship as cust_orders from Customers.Id to Orders.CustId"),
rel(
Some("cust_orders"),
("Customers", "Id"),
("Orders", "CustId"),
ReferentialAction::NoAction,
ReferentialAction::NoAction,
false,
)
);
}
#[test]
fn add_relationship_with_on_delete() {
assert_eq!(
ok("add 1:n relationship from Customers.Id to Orders.CustId on delete cascade"),
rel(
None,
("Customers", "Id"),
("Orders", "CustId"),
ReferentialAction::Cascade,
ReferentialAction::NoAction,
false,
)
);
}
#[test]
fn add_relationship_with_on_delete_set_null() {
assert_eq!(
ok("add 1:n relationship from Customers.Id to Orders.CustId on delete set null"),
rel(
None,
("Customers", "Id"),
("Orders", "CustId"),
ReferentialAction::SetNull,
ReferentialAction::NoAction,
false,
)
);
}
#[test]
fn add_relationship_with_both_actions_in_either_order() {
let expected = rel(
None,
("Customers", "Id"),
("Orders", "CustId"),
ReferentialAction::Cascade,
ReferentialAction::SetNull,
false,
);
assert_eq!(
ok("add 1:n relationship from Customers.Id to Orders.CustId on delete cascade on update set null"),
expected
);
assert_eq!(
ok("add 1:n relationship from Customers.Id to Orders.CustId on update set null on delete cascade"),
expected
);
}
#[test]
fn add_relationship_repeated_clause_errors() {
let e =
err("add 1:n relationship from C.id to O.cid on delete cascade on delete restrict");
match e {
ParseError::Invalid { message, .. } => {
assert!(message.contains("specified twice"), "{message}");
}
ParseError::Empty => panic!("unexpected empty error"),
}
}
#[test]
fn add_relationship_with_create_fk_flag() {
assert_eq!(
ok("add 1:n relationship from Customers.Id to Orders.CustId --create-fk"),
rel(
None,
("Customers", "Id"),
("Orders", "CustId"),
ReferentialAction::NoAction,
ReferentialAction::NoAction,
true,
)
);
}
#[test]
fn add_relationship_with_name_actions_and_flag() {
assert_eq!(
ok("add 1:n relationship as cust_orders from Customers.Id to Orders.CustId on delete cascade on update no action --create-fk"),
rel(
Some("cust_orders"),
("Customers", "Id"),
("Orders", "CustId"),
ReferentialAction::Cascade,
ReferentialAction::NoAction,
true,
)
);
}
#[test]
fn add_relationship_keywords_are_case_insensitive() {
assert_eq!(
ok("ADD 1:N RELATIONSHIP FROM Customers.Id TO Orders.CustId ON DELETE CASCADE"),
rel(
None,
("Customers", "Id"),
("Orders", "CustId"),
ReferentialAction::Cascade,
ReferentialAction::NoAction,
false,
)
);
}
#[test]
fn add_relationship_unknown_action_errors() {
let e = err("add 1:n relationship from C.id to O.cid on delete obliterate");
assert!(matches!(e, ParseError::Invalid { .. }), "got {e:?}");
}
#[test]
fn insert_with_explicit_column_list() {
assert_eq!(
ok("insert into Customers (Name, Email) values ('Alice', 'a@b.com')"),
Command::Insert {
table: "Customers".to_string(),
columns: Some(vec!["Name".to_string(), "Email".to_string()]),
values: vec![
Value::Text("Alice".to_string()),
Value::Text("a@b.com".to_string()),
],
}
);
}
#[test]
fn insert_short_form_omitting_values_keyword() {
assert_eq!(
ok("insert into Customers ('Alice')"),
Command::Insert {
table: "Customers".to_string(),
columns: None,
values: vec![Value::Text("Alice".to_string())],
}
);
}
#[test]
fn insert_short_form_without_column_list() {
assert_eq!(
ok("insert into Customers values ('Alice', 'a@b.com')"),
Command::Insert {
table: "Customers".to_string(),
columns: None,
values: vec![
Value::Text("Alice".to_string()),
Value::Text("a@b.com".to_string()),
],
}
);
}
#[test]
fn insert_accepts_mixed_value_kinds() {
assert_eq!(
ok("insert into T values (1, 3.14, 'hi', true, null)"),
Command::Insert {
table: "T".to_string(),
columns: None,
values: vec![
Value::Number("1".to_string()),
Value::Number("3.14".to_string()),
Value::Text("hi".to_string()),
Value::Bool(true),
Value::Null,
],
}
);
}
#[test]
fn insert_supports_negative_numbers() {
assert_eq!(
ok("insert into T values (-5, -3.14)"),
Command::Insert {
table: "T".to_string(),
columns: None,
values: vec![
Value::Number("-5".to_string()),
Value::Number("-3.14".to_string()),
],
}
);
}
#[test]
fn string_literal_supports_escaped_single_quote() {
assert_eq!(
ok("insert into T values ('don''t panic')"),
Command::Insert {
table: "T".to_string(),
columns: None,
values: vec![Value::Text("don't panic".to_string())],
}
);
}
#[test]
fn update_with_where() {
assert_eq!(
ok("update Customers set Name='Alice' where id=1"),
Command::Update {
table: "Customers".to_string(),
assignments: vec![("Name".to_string(), Value::Text("Alice".to_string()))],
filter: RowFilter::Where {
column: "id".to_string(),
value: Value::Number("1".to_string()),
},
}
);
}
#[test]
fn update_with_multiple_assignments() {
assert_eq!(
ok("update Customers set Name='Alice', Email='a@b.com' where id=1"),
Command::Update {
table: "Customers".to_string(),
assignments: vec![
("Name".to_string(), Value::Text("Alice".to_string())),
("Email".to_string(), Value::Text("a@b.com".to_string())),
],
filter: RowFilter::Where {
column: "id".to_string(),
value: Value::Number("1".to_string()),
},
}
);
}
#[test]
fn update_with_all_rows_flag() {
assert_eq!(
ok("update Customers set Active=false --all-rows"),
Command::Update {
table: "Customers".to_string(),
assignments: vec![("Active".to_string(), Value::Bool(false))],
filter: RowFilter::AllRows,
}
);
}
#[test]
fn update_without_where_or_flag_errors() {
let e = err("update Customers set Active=false");
assert!(matches!(e, ParseError::Invalid { .. }), "got {e:?}");
}
#[test]
fn delete_with_where() {
assert_eq!(
ok("delete from Customers where id=1"),
Command::Delete {
table: "Customers".to_string(),
filter: RowFilter::Where {
column: "id".to_string(),
value: Value::Number("1".to_string()),
},
}
);
}
#[test]
fn delete_with_all_rows_flag() {
assert_eq!(
ok("delete from Customers --all-rows"),
Command::Delete {
table: "Customers".to_string(),
filter: RowFilter::AllRows,
}
);
}
#[test]
fn delete_without_where_or_flag_errors() {
let e = err("delete from Customers");
assert!(matches!(e, ParseError::Invalid { .. }), "got {e:?}");
}
#[test]
fn show_data_command() {
assert_eq!(
ok("show data Customers"),
Command::ShowData {
name: "Customers".to_string()
}
);
}
#[test]
fn drop_relationship_by_name() {
assert_eq!(
ok("drop relationship cust_orders"),
Command::DropRelationship {
selector: RelationshipSelector::Named {
name: "cust_orders".to_string()
}
}
);
}
#[test]
fn show_table_simple() {
assert_eq!(
ok("show table Customers"),
Command::ShowTable {
name: "Customers".to_string()
}
);
}
#[test]
fn drop_relationship_by_endpoints() {
assert_eq!(
ok("drop relationship from Customers.Id to Orders.CustId"),
Command::DropRelationship {
selector: RelationshipSelector::Endpoints {
parent_table: "Customers".to_string(),
parent_column: "Id".to_string(),
child_table: "Orders".to_string(),
child_column: "CustId".to_string(),
}
}
);
}
#[test]
fn identifier_allows_underscores_and_digits_after_start() {
assert_eq!(
ok("create table customer_v2 with pk"),
Command::CreateTable {
name: "customer_v2".to_string(),
columns: vec![col("id", Type::Serial)],
primary_key: vec!["id".to_string()],
}
);
}
// --- replay <path> ---
#[test]
fn replay_with_bare_relative_path() {
assert_eq!(
ok("replay history.log"),
Command::Replay {
path: "history.log".to_string(),
}
);
}
#[test]
fn replay_with_bare_absolute_path() {
assert_eq!(
ok("replay /tmp/seed.commands"),
Command::Replay {
path: "/tmp/seed.commands".to_string(),
}
);
}
#[test]
fn replay_with_quoted_path_supports_whitespace() {
assert_eq!(
ok("replay 'my project/seed.commands'"),
Command::Replay {
path: "my project/seed.commands".to_string(),
}
);
}
#[test]
fn replay_with_quoted_path_supports_escaped_quote() {
assert_eq!(
ok("replay 'O''Brien.commands'"),
Command::Replay {
path: "O'Brien.commands".to_string(),
}
);
}
#[test]
fn replay_keyword_is_case_insensitive() {
assert_eq!(
ok("REPLAY foo.txt"),
Command::Replay {
path: "foo.txt".to_string(),
}
);
}
#[test]
fn replay_without_path_errors() {
let e = err("replay");
assert!(matches!(e, ParseError::Invalid { .. }), "got {e:?}");
}
#[test]
fn replay_with_empty_quoted_path_errors() {
// The quoted-path form of `replay` goes through chumsky.
// An empty quoted path `''` lexes as a StringLiteral with
// an empty payload, which the parser accepts as
// syntactically valid; the runtime rejects an empty path
// before any I/O. Test pinned to the runtime layer rather
// than the parser layer to match the new architecture.
// (The pre-tokenizer parser caught this at parse time via
// `path_literal`'s try_map; under the lexer split, that
// check moves down a layer.)
match parse_command("replay ''") {
Ok(Command::Replay { path }) => assert_eq!(path, ""),
other => panic!("expected Replay with empty path, got {other:?}"),
}
}
}