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.
629 lines
23 KiB
Rust
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
|
|
}
|