Files
rdbms-playground/src/dsl/grammar/shared.rs
T
claude@clouddev1 41b7e9a049 style: format the whole tree with cargo fmt (stock defaults, #35)
One-time, mechanical reformat — no functional changes. The tree was not
rustfmt-clean (~1800 hunks across ~100 files); this brings it to stock
`cargo fmt` defaults so a `cargo fmt --check` CI gate can follow.
Behaviour-preserving: 2509 pass / 0 fail / 1 ignored (unchanged baseline),
clippy clean. A .git-blame-ignore-revs entry follows so `git blame`
skips this commit.
2026-06-17 21:39:19 +00:00

629 lines
23 KiB
Rust

//! Shared sub-grammars for the DDL/DML migrations
//! (ADR-0024 §architecture, §sub-grammars).
//!
//! Phase B uses these for relationship endpoints and referential
//! actions; Phase D extends with `where_clause`,
//! `column_value_list`, and the typed value slots.
use crate::completion::TableColumn;
use crate::dsl::grammar::{
HighlightClass, HintMode, IdentSource, IdentValidator, Node, NumberValidator, ValidationError,
Word,
};
use crate::dsl::types::Type;
use crate::dsl::walker::context::WalkContext;
use std::str::FromStr;
// --- Type-name validator ------------------------------------------
/// Reject any identifier that isn't a known user-facing type name.
///
/// Mirrors the chumsky-side `Type::from_str` + `UnknownType`
/// flow — surfaces the same `parse.custom.unknown_type` catalog
/// wording with `{found}` and `{expected}` args.
pub fn validate_type_name(value: &str) -> Result<(), ValidationError> {
if Type::from_str(value).is_ok() {
Ok(())
} else {
let expected = Type::all()
.iter()
.map(|t| t.keyword())
.collect::<Vec<_>>()
.join(", ");
Err(ValidationError {
message_key: "parse.custom.unknown_type",
args: vec![("found", value.to_string()), ("expected", expected)],
})
}
}
pub const TYPE_VALIDATOR: IdentValidator = validate_type_name;
// --- Type-slot leaf -----------------------------------------------
/// `Ident` slot for a column type. Validation runs after a
/// successful identifier-shape match.
pub const TYPE_SLOT: Node = Node::Ident {
source: IdentSource::Types,
role: "type",
validator: Some(TYPE_VALIDATOR),
highlight_override: Some(HighlightClass::Type),
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
};
// --- Qualified column reference (`<Table>.<Column>`) --------------
const QUALIFIED_COLUMN_NODES: &[Node] = &[
Node::Ident {
source: IdentSource::Tables,
role: "table_name",
validator: None,
highlight_override: None,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
},
Node::Punct('.'),
Node::Ident {
source: IdentSource::Columns,
role: "column_name",
validator: None,
highlight_override: None,
writes_table: false,
writes_column: false,
writes_user_listed_column: false,
writes_table_alias: false,
writes_cte_name: false,
writes_projection_alias: false,
},
];
pub const QUALIFIED_COLUMN: Node = Node::Seq(QUALIFIED_COLUMN_NODES);
// --- Relationship-endpoint clauses (`from <T>.<c> to <T>.<c>`) ----
const RELATIONSHIP_ENDPOINTS_NODES: &[Node] = &[
Node::Word(Word::keyword("from")),
QUALIFIED_COLUMN,
Node::Word(Word::keyword("to")),
QUALIFIED_COLUMN,
];
pub const RELATIONSHIP_ENDPOINTS: Node = Node::Seq(RELATIONSHIP_ENDPOINTS_NODES);
// --- Referential action (`cascade`, `restrict`, `set null`,
// `no action`) -----------------------------------------------
const ACTION_SET_NULL: &[Node] = &[
Node::Word(Word::keyword("set")),
Node::Word(Word::keyword("null")),
];
const ACTION_NO_ACTION: &[Node] = &[
Node::Word(Word::keyword("no")),
Node::Word(Word::keyword("action")),
];
const ACTION_CHOICES: &[Node] = &[
Node::Word(Word::keyword("cascade")),
Node::Word(Word::keyword("restrict")),
Node::Seq(ACTION_SET_NULL),
Node::Seq(ACTION_NO_ACTION),
];
pub const ACTION_KEYWORD: Node = Node::Choice(ACTION_CHOICES);
// --- A single `on <delete|update> <action>` clause ----------------
const ON_TARGET_CHOICES: &[Node] = &[
Node::Word(Word::keyword("delete")),
Node::Word(Word::keyword("update")),
];
const ON_CLAUSE_NODES: &[Node] = &[
Node::Word(Word::keyword("on")),
Node::Choice(ON_TARGET_CHOICES),
ACTION_KEYWORD,
];
pub const ON_CLAUSE: Node = Node::Seq(ON_CLAUSE_NODES);
/// Repeated `on <target> <action>` clauses (0..2 occurrences).
/// Validation of "specified twice" + max=2 lives in the
/// command's AST builder.
pub const REFERENTIAL_CLAUSES: Node = Node::Repeated {
inner: &ON_CLAUSE,
separator: None,
min: 0,
};
// =================================================================
// Typed value slots (ADR-0024 §Phase D, §typed-value-slots)
// =================================================================
//
// Each `<ty>_slot()` factory returns a `Node` that accepts either
// `null` or a literal of the corresponding shape, with an
// optional content validator that rejects mis-typed values at
// parse time with localised catalog wording. Per-type prose
// hints attach via `Choice` HintMode — but Phase D's first
// landing keeps `Default` everywhere; the dispatch-by-column-type
// covers the central design claim, and per-type prose can layer
// on later without grammar surface changes.
fn validate_integer_only(value: &str) -> Result<(), ValidationError> {
// The lexer-side number consumer accepts integers and
// fractional forms (e.g. `3.14`). For int / serial / shortid
// columns reject any literal that carries a decimal point.
if value.contains('.') {
Err(ValidationError {
message_key: "parse.custom.bind_type_mismatch",
args: vec![
("found", value.to_string()),
("expected", "integer".to_string()),
],
})
} else {
Ok(())
}
}
const INTEGER_ONLY_VALIDATOR: NumberValidator = validate_integer_only;
fn validate_decimal_string(value: &str) -> Result<(), ValidationError> {
if value.parse::<f64>().is_ok() {
Ok(())
} else {
Err(ValidationError {
message_key: "parse.custom.bind_type_mismatch",
args: vec![
("found", value.to_string()),
("expected", "number".to_string()),
],
})
}
}
const DECIMAL_VALIDATOR: NumberValidator = validate_decimal_string;
// Bare `null` keyword — used as the trailing branch of every
// typed value slot so a column always accepts the absence sentinel.
const NULL_WORD: Node = Node::Word(Word::keyword("null"));
const INT_SLOT_CHOICES: &[Node] = &[
Node::NumberLit {
validator: Some(INTEGER_ONLY_VALIDATOR),
},
NULL_WORD,
];
const INT_SLOT_INNER: Node = Node::Choice(INT_SLOT_CHOICES);
const INT_SLOT: Node = Node::TypedValueSlot {
ty: Type::Int,
column_name: None,
inner: &INT_SLOT_INNER,
};
const SERIAL_SLOT: Node = Node::TypedValueSlot {
ty: Type::Serial,
column_name: None,
inner: &INT_SLOT_INNER,
};
const REAL_SLOT_CHOICES: &[Node] = &[Node::NumberLit { validator: None }, NULL_WORD];
const REAL_SLOT_INNER: Node = Node::Choice(REAL_SLOT_CHOICES);
const REAL_SLOT: Node = Node::TypedValueSlot {
ty: Type::Real,
column_name: None,
inner: &REAL_SLOT_INNER,
};
const DECIMAL_SLOT_CHOICES: &[Node] = &[
Node::NumberLit {
validator: Some(DECIMAL_VALIDATOR),
},
NULL_WORD,
];
const DECIMAL_SLOT_INNER: Node = Node::Choice(DECIMAL_SLOT_CHOICES);
const DECIMAL_SLOT: Node = Node::TypedValueSlot {
ty: Type::Decimal,
column_name: None,
inner: &DECIMAL_SLOT_INNER,
};
const BOOL_SLOT_CHOICES: &[Node] = &[
Node::Word(Word::keyword("true")),
Node::Word(Word::keyword("false")),
NULL_WORD,
];
const BOOL_SLOT_INNER: Node = Node::Choice(BOOL_SLOT_CHOICES);
const BOOL_SLOT: Node = Node::TypedValueSlot {
ty: Type::Bool,
column_name: None,
inner: &BOOL_SLOT_INNER,
};
const TEXT_SLOT_CHOICES: &[Node] = &[Node::StringLit, NULL_WORD];
const TEXT_SLOT_INNER: Node = Node::Choice(TEXT_SLOT_CHOICES);
const TEXT_SLOT: Node = Node::TypedValueSlot {
ty: Type::Text,
column_name: None,
inner: &TEXT_SLOT_INNER,
};
// Date / datetime share the StringLit-or-null shape with text
// but get distinct catalog-prose entries so the hint surfaces
// the YYYY-MM-DD / YYYY-MM-DD HH:MM:SS format examples.
const DATE_SLOT: Node = Node::TypedValueSlot {
ty: Type::Date,
column_name: None,
inner: &TEXT_SLOT_INNER,
};
const DATETIME_SLOT: Node = Node::TypedValueSlot {
ty: Type::DateTime,
column_name: None,
inner: &TEXT_SLOT_INNER,
};
const BLOB_SLOT: Node = Node::TypedValueSlot {
ty: Type::Blob,
column_name: None,
inner: &TEXT_SLOT_INNER,
};
// shortid columns store base58 text (ADR-0011 fk_target_type
// shortid → text); the slot accepts a quoted-text literal or
// null.
const SHORTID_SLOT: Node = Node::TypedValueSlot {
ty: Type::ShortId,
column_name: None,
inner: &TEXT_SLOT_INNER,
};
/// Dispatch a value slot per user-facing type
/// (ADR-0024 §slot_for_type).
///
/// Returns the same node every time for a given Type — fine to
/// call from within a `DynamicSubgrammar` factory. The
/// returned slot does not carry a column name; callers that
/// have one (e.g. `column_value_list` building per-column
/// slots) should use [`slot_for_column`] instead.
#[must_use]
pub const fn slot_for_type(ty: Type) -> Node {
match ty {
Type::Int => INT_SLOT,
Type::Serial => SERIAL_SLOT,
Type::ShortId => SHORTID_SLOT,
Type::Real => REAL_SLOT,
Type::Decimal => DECIMAL_SLOT,
Type::Bool => BOOL_SLOT,
Type::Text => TEXT_SLOT,
Type::Date => DATE_SLOT,
Type::DateTime => DATETIME_SLOT,
Type::Blob => BLOB_SLOT,
}
}
/// Look up just the inner Choice (no `TypedValueSlot` wrapper)
/// for a given user-facing type. Used by `slot_for_column` to
/// rebuild a TypedValueSlot with an embedded column name.
const fn slot_inner_for_type(ty: Type) -> &'static Node {
match ty {
Type::Int | Type::Serial => &INT_SLOT_INNER,
Type::Real => &REAL_SLOT_INNER,
Type::Decimal => &DECIMAL_SLOT_INNER,
Type::Bool => &BOOL_SLOT_INNER,
Type::Text | Type::Date | Type::DateTime | Type::Blob | Type::ShortId => &TEXT_SLOT_INNER,
}
}
/// Build a typed value slot with an embedded column name
/// (ADR-0024 §Phase D §typed-value-slots). Used by
/// `column_value_list` to attach each column's name so the
/// hint resolver can render "for `<column>`:" prefixes.
///
/// The walker writes the (leaked) name into
/// `WalkContext::pending_value_column` on entry to the slot
/// and clears it on successful inner match.
#[must_use]
fn slot_for_column(ty: Type, name: &str) -> Node {
// `Box::leak`: column names from the schema cache need a
// `&'static str`-compatible lifetime to plug into the
// static Node enum. The leak is per dynamic walk (factory
// invocation), bounded by the column count — consistent
// with the `DynamicSubgrammar` Box::leak in the walker
// driver.
let leaked: &'static str = Box::leak(name.to_string().into_boxed_str());
Node::TypedValueSlot {
ty,
column_name: Some(leaked),
inner: slot_inner_for_type(ty),
}
}
// =================================================================
// Dynamic sub-grammar: column_value_list
// =================================================================
/// Fallback when no schema-resolved column list is available
/// (schemaless parse, missing table, empty schema cache).
/// Mirrors the pre-Phase-D `value_literal` Choice.
const FALLBACK_VALUE_LITERAL_CHOICES: &[Node] = &[
Node::Word(Word::keyword("null")),
Node::Word(Word::keyword("true")),
Node::Word(Word::keyword("false")),
Node::NumberLit { validator: None },
Node::StringLit,
];
const FALLBACK_VALUE_LITERAL_INNER: Node = Node::Choice(FALLBACK_VALUE_LITERAL_CHOICES);
/// The schemaless value-literal slot. The `ProseOnly` HintMode
/// (ADR-0024 §HintMode-per-node) tells the hint resolver to
/// surface the generic "Type a value: number, 'text', …" prose
/// here rather than the misleading `null`/`true`/`false`
/// candidate trio.
/// The schemaless value-literal slot. `pub(crate)` so the
/// `default <literal>` column constraint (ADR-0029) can reuse
/// it from `grammar::ddl`.
pub(crate) const FALLBACK_VALUE_LITERAL: Node = Node::Hinted {
mode: HintMode::ProseOnly("hint.value_literal_slot"),
inner: &FALLBACK_VALUE_LITERAL_INNER,
};
/// The type-blind value list. `pub(crate)` so the insert value-list
/// arity gate (`data.rs`, issue #17) can route a wrong-count tuple here
/// — exactly as the advanced grammar's `tuple_value_list` does — so the
/// tuple still structurally matches and the per-tuple arity diagnostic
/// (ADR-0033 §8.1) fires instead of a bare "expected `,`/`)`".
pub(crate) const FALLBACK_VALUE_LIST: Node = Node::Repeated {
inner: &FALLBACK_VALUE_LITERAL,
separator: Some(&Node::Punct(',')),
min: 1,
};
/// The columns an insert value tuple maps onto (ADR-0024 §Phase D).
///
/// Mirrors `db::do_insert`'s `user_cols` logic. `None` when the walker
/// is schemaless, the table is unknown, or — for Form B/C — every column
/// is auto-generated (callers fall back to the type-blind value list).
///
/// - **Form A** (`user_listed_columns` set): the listed columns, in the
/// user's order; names the schema doesn't know are dropped.
/// - **Form B/C** (no column list): the table's non-auto-generated
/// columns, in declaration order. `serial` / `shortid` are skipped
/// because the simple-mode dispatch auto-fills them (ADR-0018 §3).
///
/// This is the single source of truth shared by [`column_value_list`]
/// (which builds the typed slots) and the `data.rs` arity gate (which
/// counts them) so the two never disagree (issue #17).
pub fn insert_target_columns<'c>(ctx: &'c WalkContext<'_>) -> Option<Vec<&'c TableColumn>> {
let table_cols = ctx.current_table_columns.as_ref()?;
if table_cols.is_empty() {
return None;
}
let cols: Vec<&TableColumn> = ctx.user_listed_columns.as_ref().map_or_else(
|| {
table_cols
.iter()
.filter(|c| !matches!(c.user_type, Type::Serial | Type::ShortId))
.collect()
},
|user_listed| {
user_listed
.iter()
.filter_map(|name| {
table_cols
.iter()
.find(|c| c.name.eq_ignore_ascii_case(name))
})
.collect()
},
);
if cols.is_empty() { None } else { Some(cols) }
}
/// Count the value positions in a `VALUES`/insert tuple whose contents
/// begin at `pos` (just past the opening `(`), and whether the tuple is
/// *closed* (a depth-0 `)` was reached) vs still being typed (scan hit
/// end-of-input first). Depth-aware: commas nested in a function call /
/// subquery (paren depth ≥ 1) or inside a string literal are not
/// separators. Returns `(0, _)` for an empty tuple `()`.
///
/// Shared by the advanced grammar's `tuple_value_list` (`sql_insert.rs`)
/// and the simple-mode DSL insert arity gate (`data.rs`) so both modes
/// count tuple values identically (issue #17).
pub(crate) fn count_tuple_values(source: &str, pos: usize) -> (usize, bool) {
let bytes = source.as_bytes();
let mut i = pos;
let mut depth: i32 = 0;
let mut commas = 0usize;
let mut seen_value = false;
let mut closed = false;
while i < bytes.len() {
match bytes[i] {
b'\'' => {
// Skip a single-quoted string literal (`''` escape).
i += 1;
seen_value = true;
while i < bytes.len() {
if bytes[i] == b'\'' {
if bytes.get(i + 1) == Some(&b'\'') {
i += 2;
continue;
}
i += 1;
break;
}
i += 1;
}
continue;
}
b'(' => {
depth += 1;
seen_value = true;
}
b')' => {
if depth == 0 {
closed = true;
break; // tuple close
}
depth -= 1;
}
b',' if depth == 0 => commas += 1,
b if !b.is_ascii_whitespace() => seen_value = true,
_ => {}
}
i += 1;
}
(if seen_value { commas + 1 } else { 0 }, closed)
}
/// Value slot keyed on `WalkContext::current_column`.
///
/// Picks the typed slot for the column whose name was most
/// recently matched by an `Ident { source: Columns,
/// writes_column: true }` node (ADR-0024 §Phase D). Fallback
/// when no current_column is resolved: the schemaless
/// value-literal choice.
pub fn current_column_value(ctx: &WalkContext) -> Node {
ctx.current_column
.as_ref()
.map_or(FALLBACK_VALUE_LITERAL, |col| slot_for_type(col.user_type))
}
/// Comma-separated list of typed value slots, one per column.
///
/// Reads `current_table_columns` from the WalkContext (ADR-0024
/// §Phase D §column_value_list). When the schema cache holds
/// no entry for the current table — or the walker is
/// schemaless — falls back to the schema-unaware
/// `Repeated(VALUE_LITERAL, ',', 1)` shape so existing
/// callers/tests continue to work.
pub fn column_value_list(ctx: &WalkContext) -> Node {
// Target columns per the shared insert mapping (Form A = listed,
// Form B/C = non-auto-generated). `None` → schemaless / unknown
// table / all-auto-generated → the type-blind fallback list.
let Some(target_cols) = insert_target_columns(ctx) else {
return FALLBACK_VALUE_LIST;
};
// Build a Seq of typed slots interleaved with commas. Each
// slot embeds its column name so the hint resolver can
// mention the column by name ("for `Email`: Type a quoted
// string …").
let mut children: Vec<Node> = Vec::with_capacity(target_cols.len() * 2);
for (i, col) in target_cols.iter().enumerate() {
if i > 0 {
children.push(Node::Punct(','));
}
children.push(slot_for_column(col.user_type, &col.name));
}
Node::Seq(Box::leak(children.into_boxed_slice()))
}
// =================================================================
// Advanced-mode SQL `SET col = <rhs>` value slot (ADR-0036 Phase 3a)
// =================================================================
//
// A SQL UPDATE / UPSERT `SET col = <rhs>` value position routes a
// *lone literal* (a string / number / `null` / `true` / `false` that
// fills the whole position up to the next `,` / `where` / `returning`
// / `;` / end-of-input) to the column-typed slot — so the learner gets
// the same per-column hint + numeric-shape highlight the DSL gives —
// and routes anything else (an expression: arithmetic, a literal-
// prefixed form like `1 + 2`, a function call, a scalar subquery, a
// column / `excluded` reference) to the full `sql_expr` grammar,
// unchanged (ADR-0030 §4).
//
// Discrimination is by *lookahead*, NOT `Choice([typed_slot,
// sql_expr])`. A naive Choice would let the typed slot greedily match
// the leading literal of `1 + 2` and commit, leaving `+ 2` dangling —
// the walker's `Choice` is first-match-wins with no cross-branch
// backtrack — turning a valid expression into a parse error. The
// lookahead peeks the whole value position first, so a literal routes
// to the typed slot only when it is the *entire* value (ADR-0036
// Amendment 1).
/// Lookahead factory for a SQL `SET col = <rhs>` value position
/// (ADR-0036 Phase 3a). Routes a lone literal to the column-typed slot
/// (live hint + highlight) and everything else to `sql_expr`.
fn set_value_node(_ctx: &WalkContext, source: &str, pos: usize) -> Node {
if set_rhs_is_lone_literal(source, pos) {
// The column type was resolved into `current_column` by the
// preceding `SET` column ident (`writes_column: true`), so the
// typed slot drives the per-column hint + highlight.
Node::DynamicSubgrammar(current_column_value)
} else {
// An expression — the engine evaluates it; keep the full
// `sql_expr` surface. Returned from a `fn` (not a `const`) so
// the reference doesn't enter the sql_expr ⇄ sql_select
// const-evaluation cycle (see the matching note in `data.rs`).
Node::Subgrammar(&crate::dsl::grammar::sql_expr::SQL_OR_EXPR)
}
}
/// The SQL `SET col = <rhs>` value slot (ADR-0036 Phase 3a). Shared by
/// `sql_update`'s assignment list and the `sql_insert` UPSERT
/// `DO UPDATE SET`.
pub const SET_VALUE: Node = Node::Lookahead(set_value_node);
/// True when the `SET` RHS starting at `pos` is empty, a partial string
/// still being typed, or exactly one complete literal token that fills
/// the value position (the next token is a position boundary). Such
/// positions route to the column-typed slot so its hint/highlight fire;
/// everything else is an expression and routes to `sql_expr`.
///
/// An empty RHS counts as lone-literal so the typed-slot hint shows
/// while the cursor sits right after `=`. A signed number (`-5`) counts
/// too: `consume_number_literal` — the same consumer the slot's
/// `NumberLit` uses — folds the leading sign into the literal, so the
/// slot can match it.
fn set_rhs_is_lone_literal(source: &str, pos: usize) -> bool {
use crate::dsl::walker::lex_helpers::{
consume_ident, consume_number_literal, consume_string_literal, skip_whitespace,
};
let p = skip_whitespace(source, pos);
if p >= source.len() {
return true; // empty RHS — show the typed-slot hint
}
// A string literal — single-quoted (SQL strings). Complete (a
// boundary must follow) or a partial one still being typed (route
// to the slot so the hint persists while typing).
if source.as_bytes()[p] == b'\'' {
return match consume_string_literal(source, p) {
Some(((_, end), _)) => next_is_set_boundary(source, end),
None => true, // unterminated string, mid-typing
};
}
// A number literal (incl. a leading sign) that fills the position.
if let Some((_, end)) = consume_number_literal(source, p) {
return next_is_set_boundary(source, end);
}
// `null` / `true` / `false` filling the position.
if let Some((s, e)) = consume_ident(source, p) {
let word = &source[s..e];
if word.eq_ignore_ascii_case("null")
|| word.eq_ignore_ascii_case("true")
|| word.eq_ignore_ascii_case("false")
{
return next_is_set_boundary(source, e);
}
return false; // any other identifier → column ref / function → expression
}
false // punctuation / sign-then-space / `(` → expression
}
/// True when the next non-whitespace token after `pos` ends a `SET`
/// value position: `,` (next assignment), `)` / `;` / end-of-input, or
/// a `where` / `returning` clause keyword.
fn next_is_set_boundary(source: &str, pos: usize) -> bool {
use crate::dsl::walker::lex_helpers::{consume_ident, skip_whitespace};
let p = skip_whitespace(source, pos);
let Some(b) = source.as_bytes().get(p) else {
return true; // end of input
};
if *b == b',' || *b == b';' || *b == b')' {
return true;
}
if let Some((s, e)) = consume_ident(source, p) {
let word = &source[s..e];
return word.eq_ignore_ascii_case("where") || word.eq_ignore_ascii_case("returning");
}
false
}