50b3542050
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.
2050 lines
67 KiB
Rust
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:?}"),
|
|
}
|
|
}
|
|
}
|