Add a hint.concept.* tier-3 layer surfaced when the cursor sits inside a recognized clause (referential actions, 1:n/m:n cardinality, primary key, unique, check, foreign key), layered beneath the per-form block. New Node::Concept grammar wrapper records clause byte-spans; concept_topic_at_cursor resolves the innermost containing span. Examples are mode-keyed so they stay syntax-correct in both simple and advanced mode. Draft ADR (number at merge).
2292 lines
84 KiB
Rust
2292 lines
84 KiB
Rust
//! Per-node-kind walk dispatch (ADR-0024 §architecture).
|
||
//!
|
||
//! `walk_node` is the recursive workhorse that the public
|
||
//! `walk()` entry calls into for a `CommandNode`'s `shape`. It
|
||
//! tries to match `node` starting at `position`, mutating
|
||
//! `path` (matched terminals collected in declaration order) and
|
||
//! `per_byte` (highlight class assignments) as it goes.
|
||
//!
|
||
//! The return value distinguishes four cases:
|
||
//!
|
||
//! - `Matched { end }` — full match, walker consumed up to `end`.
|
||
//! - `NoMatch { … }` — node didn't engage at this position. For
|
||
//! `Optional` and `Choice` callers this is benign (try the
|
||
//! next branch / skip the optional); for `Seq` it's only
|
||
//! benign on the first child.
|
||
//! - `Incomplete { … }` — node committed (consumed at least one
|
||
//! terminal) but ran out of input. Surfaces as
|
||
//! `WalkOutcome::Incomplete` at the top level.
|
||
//! - `Failed { … }` — node committed and a content validator
|
||
//! rejected the value, or a hard structural failure occurred
|
||
//! mid-shape. Surfaces as `WalkOutcome::Mismatch` or
|
||
//! `WalkOutcome::ValidationFailed` at the top level.
|
||
|
||
use std::collections::HashMap;
|
||
use std::sync::{LazyLock, Mutex};
|
||
|
||
use crate::completion::TableColumn;
|
||
use crate::dsl::grammar::{HighlightClass, Node, ValidationError};
|
||
use crate::dsl::walker::context::WalkContext;
|
||
use crate::dsl::walker::lex_helpers::{
|
||
consume_bare_path, consume_flag, consume_ident, consume_number_literal, consume_string_literal,
|
||
skip_whitespace,
|
||
};
|
||
use crate::dsl::walker::outcome::{ByteClass, Expectation, MatchedItem, MatchedKind, MatchedPath};
|
||
|
||
/// Maximum nesting of `Node::Subgrammar` frames (ADR-0026 §1).
|
||
///
|
||
/// The stratified WHERE-expression grammar descends one
|
||
/// `Subgrammar` hop per precedence tier, plus a tier-stack per
|
||
/// parenthesised group, so this bounds real expression nesting
|
||
/// many parentheses deep — far past any hand-written filter.
|
||
/// Its purpose is purely a stack-overflow guard: input nested
|
||
/// past the cap (`((((…))))`) fails with a friendly
|
||
/// `expression_too_deep` error instead of recursing until the
|
||
/// process stack is exhausted.
|
||
pub const MAX_SUBGRAMMAR_DEPTH: usize = 64;
|
||
|
||
/// Memo cache for `Node::DynamicSubgrammar` resolution.
|
||
///
|
||
/// A factory builds a `Node` from the active `WalkContext`; the
|
||
/// resolved Node's combinator children are `&'static`, so it
|
||
/// must be `Box::leak`ed. Leaking per walk grows unbounded
|
||
/// under per-keystroke completion. Memoizing on the schema
|
||
/// state the factory reads means each *distinct* value-list
|
||
/// shape leaks exactly once — the total leak is bounded by the
|
||
/// number of distinct (table-columns × form) combinations, not
|
||
/// by keystroke count (handoff-13 §memoization).
|
||
#[derive(PartialEq, Eq, Hash)]
|
||
struct DynamicKey {
|
||
/// The factory's function-pointer address. Distinguishes
|
||
/// `column_value_list` from `current_column_value`.
|
||
factory: usize,
|
||
/// Every `WalkContext` field a dynamic factory may read.
|
||
/// A superset of any single factory's true dependencies —
|
||
/// sound (never returns a wrong shape), at worst slightly
|
||
/// over-keyed (an extra leak when an unread field differs).
|
||
current_table_columns: Option<Vec<TableColumn>>,
|
||
current_column: Option<TableColumn>,
|
||
user_listed_columns: Option<Vec<String>>,
|
||
}
|
||
|
||
static DYNAMIC_CACHE: LazyLock<Mutex<HashMap<DynamicKey, &'static Node>>> =
|
||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||
|
||
/// Resolve a `DynamicSubgrammar` factory to a `&'static Node`,
|
||
/// reusing a previously-leaked Node when the factory's inputs
|
||
/// match a cached entry.
|
||
fn resolve_dynamic(factory: fn(&WalkContext) -> Node, ctx: &WalkContext) -> &'static Node {
|
||
let key = DynamicKey {
|
||
factory: factory as usize,
|
||
current_table_columns: ctx.current_table_columns.clone(),
|
||
current_column: ctx.current_column.clone(),
|
||
user_listed_columns: ctx.user_listed_columns.clone(),
|
||
};
|
||
let mut cache = DYNAMIC_CACHE
|
||
.lock()
|
||
.expect("dynamic-subgrammar cache mutex poisoned");
|
||
if let Some(&node) = cache.get(&key) {
|
||
return node;
|
||
}
|
||
let resolved: &'static Node = Box::leak(Box::new(factory(ctx)));
|
||
cache.insert(key, resolved);
|
||
resolved
|
||
}
|
||
|
||
#[derive(Debug, Clone)]
|
||
pub enum NodeWalkResult {
|
||
Matched {
|
||
end: usize,
|
||
/// Expectations contributed by Optional children that
|
||
/// skipped (matched zero terminals). Walker callers
|
||
/// merge these into the next failure's expected set so
|
||
/// completion sees the full "what could have appeared
|
||
/// here" union, not just the strictly-required next
|
||
/// terminal.
|
||
skipped: Vec<Expectation>,
|
||
},
|
||
/// Did not engage at this position. Caller decides whether
|
||
/// this is benign (Optional, Choice fallthrough) or a hard
|
||
/// failure (Seq mid-shape).
|
||
NoMatch {
|
||
position: usize,
|
||
expected: Vec<Expectation>,
|
||
},
|
||
/// Committed and ran out of input.
|
||
Incomplete {
|
||
position: usize,
|
||
expected: Vec<Expectation>,
|
||
},
|
||
/// Committed and hit a hard mismatch or validator failure.
|
||
Failed { position: usize, kind: FailureKind },
|
||
}
|
||
|
||
const fn matched(end: usize) -> NodeWalkResult {
|
||
NodeWalkResult::Matched {
|
||
end,
|
||
skipped: Vec::new(),
|
||
}
|
||
}
|
||
|
||
#[derive(Debug, Clone)]
|
||
pub enum FailureKind {
|
||
Mismatch { expected: Vec<Expectation> },
|
||
Validation(ValidationError),
|
||
}
|
||
|
||
pub fn walk_node(
|
||
source: &str,
|
||
position: usize,
|
||
node: &Node,
|
||
ctx: &mut WalkContext,
|
||
path: &mut MatchedPath,
|
||
per_byte: &mut Vec<ByteClass>,
|
||
) -> NodeWalkResult {
|
||
let pos = skip_whitespace(source, position);
|
||
let result = walk_node_inner(source, pos, node, ctx, path, per_byte);
|
||
// ADR-0024 §HintMode-per-node: `pending_hint_mode` records
|
||
// the Hinted slot the cursor is currently inside. Any
|
||
// successful match means the cursor advanced past whatever
|
||
// slot was pending — clear it. This also undoes the leak
|
||
// where a failed `Hinted` branch of a `Choice` sets the
|
||
// mode and the `Choice` then matches via a different
|
||
// branch: that branch's match clears the stale mode.
|
||
if matches!(result, NodeWalkResult::Matched { .. }) {
|
||
ctx.pending_hint_mode = None;
|
||
}
|
||
result
|
||
}
|
||
|
||
fn walk_node_inner(
|
||
source: &str,
|
||
pos: usize,
|
||
node: &Node,
|
||
ctx: &mut WalkContext,
|
||
path: &mut MatchedPath,
|
||
per_byte: &mut Vec<ByteClass>,
|
||
) -> NodeWalkResult {
|
||
match node {
|
||
Node::Word(word) => walk_word(source, pos, word, path, per_byte),
|
||
Node::Punct(ch) => walk_punct(source, pos, *ch, path, per_byte),
|
||
Node::Ident {
|
||
source: src,
|
||
role,
|
||
validator,
|
||
highlight_override,
|
||
writes_table,
|
||
writes_column,
|
||
writes_user_listed_column,
|
||
writes_table_alias,
|
||
writes_cte_name,
|
||
writes_projection_alias,
|
||
} => walk_ident(
|
||
source,
|
||
pos,
|
||
*src,
|
||
role,
|
||
*validator,
|
||
*highlight_override,
|
||
*writes_table,
|
||
*writes_column,
|
||
*writes_user_listed_column,
|
||
*writes_table_alias,
|
||
*writes_cte_name,
|
||
*writes_projection_alias,
|
||
ctx,
|
||
path,
|
||
per_byte,
|
||
),
|
||
Node::NumberLit { validator } => walk_number_lit(source, pos, *validator, path, per_byte),
|
||
Node::Literal(literal) => walk_literal(source, pos, literal, path, per_byte),
|
||
Node::StringLit => walk_string_lit(source, pos, path, per_byte),
|
||
Node::BlobLit => {
|
||
// BlobLit terminals are declared but no current grammar
|
||
// node uses them. Reaching this branch means a future
|
||
// grammar declared a BlobLit without walker support
|
||
// landing — surface as a hard failure so tests catch
|
||
// it loudly rather than silently mis-parsing.
|
||
NodeWalkResult::Failed {
|
||
position: pos,
|
||
kind: FailureKind::Mismatch { expected: vec![] },
|
||
}
|
||
}
|
||
Node::Subgrammar(inner) => walk_subgrammar(source, pos, inner, ctx, path, per_byte),
|
||
Node::ScopedSubgrammar(inner) => {
|
||
walk_scoped_subgrammar(source, pos, inner, ctx, path, per_byte)
|
||
}
|
||
Node::DynamicSubgrammar(factory) => {
|
||
// ADR-0024 §sub-grammars: resolve the inner Node at
|
||
// walk time from the active `WalkContext`, then walk
|
||
// it. The resolved Node's combinator children are
|
||
// `&'static`, so a runtime-built Node has to be
|
||
// leaked. `resolve_dynamic` memoizes on the schema
|
||
// state the factory reads, so each *distinct*
|
||
// value-list shape leaks once — the total leak is
|
||
// bounded by schema size, not by keystroke count
|
||
// (handoff-13 §memoization).
|
||
let resolved = resolve_dynamic(*factory, ctx);
|
||
walk_node(source, pos, resolved, ctx, path, per_byte)
|
||
}
|
||
Node::Lookahead(factory) => {
|
||
// ADR-0024 §Phase D Form-C type-awareness: the
|
||
// factory peeks the source at `pos` (e.g. to tell a
|
||
// Form A column list from a Form C value list) and
|
||
// returns the shape to walk. Not memoized — the
|
||
// result depends on the source — but the factory
|
||
// returns a small node (a Repeated, or a thin
|
||
// DynamicSubgrammar wrapper that delegates to the
|
||
// memoized `column_value_list`), so the per-walk
|
||
// leak is a few bytes, not a whole typed tree.
|
||
let resolved: &'static Node = Box::leak(Box::new(factory(ctx, source, pos)));
|
||
walk_node(source, pos, resolved, ctx, path, per_byte)
|
||
}
|
||
Node::SetColumn(col) => {
|
||
// ADR-0036 Phase 3b: zero-width — establish the active
|
||
// column for the value position that follows, exactly as an
|
||
// `Ident { writes_column: true }` would (current_column for
|
||
// the typed slot's dispatch; pending_value_column for the
|
||
// hint's "for `col`:" framing), but without consuming a
|
||
// column identifier (VALUES positions are positional). The
|
||
// following `SET_VALUE` slot reads `current_column`.
|
||
let col: &crate::completion::TableColumn = col;
|
||
ctx.current_column = Some(col.clone());
|
||
ctx.pending_value_column = Some(col.name.clone());
|
||
NodeWalkResult::Matched {
|
||
end: pos,
|
||
skipped: Vec::new(),
|
||
}
|
||
}
|
||
Node::TypedValueSlot {
|
||
ty,
|
||
column_name,
|
||
inner,
|
||
} => {
|
||
// ADR-0024 §Phase D §typed-value-slots. Tag the
|
||
// pending column type so the hint resolver can emit
|
||
// per-type prose at empty prefix. If a column name
|
||
// is embedded (insert column_value_list path), tag
|
||
// that too so the hint can mention the column by
|
||
// name. Clear on successful inner match — positions
|
||
// BETWEEN typed slots (post-comma, between values)
|
||
// don't carry stale hint state.
|
||
ctx.pending_value_type = Some(*ty);
|
||
if let Some(name) = column_name {
|
||
ctx.pending_value_column = Some((*name).to_string());
|
||
}
|
||
let result = walk_node(source, pos, inner, ctx, path, per_byte);
|
||
if matches!(result, NodeWalkResult::Matched { .. }) {
|
||
ctx.pending_value_type = None;
|
||
ctx.pending_value_column = None;
|
||
}
|
||
result
|
||
}
|
||
Node::Hinted { mode, inner } => {
|
||
// ADR-0024 §HintMode-per-node. Record the grammar's
|
||
// declared hint mode so the hint resolver can read
|
||
// it directly. The `walk_node` wrapper clears it on
|
||
// any successful match (the cursor moved past the
|
||
// slot), so a Hinted slot whose inner fails at EOF
|
||
// leaves the mode set for the resolver to read.
|
||
ctx.pending_hint_mode = Some(*mode);
|
||
walk_node(source, pos, inner, ctx, path, per_byte)
|
||
}
|
||
Node::Concept { topic, inner } => {
|
||
// Issue #37 / ADR clause-concept-hints D1. Walk the
|
||
// inner clause, then record its covered byte span in
|
||
// `ctx.concept_spans` iff the inner *committed* — i.e.
|
||
// matched, ran out mid-clause (Incomplete), or hit a
|
||
// hard failure after engaging (Failed). On `NoMatch`
|
||
// the node never engaged (e.g. a `Choice` tried this
|
||
// branch and it didn't apply), so record nothing — no
|
||
// stale span. `start` is the post-whitespace position
|
||
// the walk wrapper already resolved.
|
||
let result = walk_node(source, pos, inner, ctx, path, per_byte);
|
||
let end = match &result {
|
||
NodeWalkResult::Matched { end, .. } => Some(*end),
|
||
NodeWalkResult::Incomplete { position, .. }
|
||
| NodeWalkResult::Failed { position, .. } => Some(*position),
|
||
NodeWalkResult::NoMatch { .. } => None,
|
||
};
|
||
if let Some(end) = end {
|
||
ctx.concept_spans
|
||
.push(crate::dsl::walker::context::ConceptSpan {
|
||
topic,
|
||
start: pos,
|
||
end,
|
||
});
|
||
}
|
||
result
|
||
}
|
||
Node::Flag(name) => walk_flag(source, pos, name, path, per_byte),
|
||
Node::Repeated {
|
||
inner,
|
||
separator,
|
||
min,
|
||
} => walk_repeated(source, pos, inner, *separator, *min, ctx, path, per_byte),
|
||
Node::BarePath => walk_bare_path(source, pos, path, per_byte),
|
||
Node::Choice(children) => walk_choice(source, pos, children, ctx, path, per_byte),
|
||
Node::Seq(children) => walk_seq(source, pos, children, ctx, path, per_byte),
|
||
Node::Optional(child) => walk_optional(source, pos, child, ctx, path, per_byte),
|
||
}
|
||
}
|
||
|
||
fn walk_word(
|
||
source: &str,
|
||
position: usize,
|
||
word: &crate::dsl::grammar::Word,
|
||
path: &mut MatchedPath,
|
||
per_byte: &mut Vec<ByteClass>,
|
||
) -> NodeWalkResult {
|
||
// First scan an identifier-shape token at `position`; if
|
||
// none, we definitely don't have this keyword. If one, check
|
||
// it against the word's primary + aliases.
|
||
let Some((start, end)) = consume_ident(source, position) else {
|
||
return NodeWalkResult::NoMatch {
|
||
position,
|
||
expected: vec![Expectation::Word(word.primary)],
|
||
};
|
||
};
|
||
let candidate = &source[start..end];
|
||
if word.matches(candidate) {
|
||
path.push(MatchedItem {
|
||
kind: MatchedKind::Word(word.primary),
|
||
text: candidate.to_string(),
|
||
span: (start, end),
|
||
});
|
||
per_byte.push(ByteClass {
|
||
start,
|
||
end,
|
||
// A keyword may opt into a non-default colour via
|
||
// `Word::type_keyword` (e.g. `double precision`, ADR-0022
|
||
// Amendment 4). Plain keywords leave it `None`.
|
||
class: word.highlight_override.unwrap_or(HighlightClass::Keyword),
|
||
});
|
||
NodeWalkResult::Matched {
|
||
end,
|
||
skipped: Vec::new(),
|
||
}
|
||
} else {
|
||
NodeWalkResult::NoMatch {
|
||
position,
|
||
expected: vec![Expectation::Word(word.primary)],
|
||
}
|
||
}
|
||
}
|
||
|
||
fn walk_punct(
|
||
source: &str,
|
||
position: usize,
|
||
ch: char,
|
||
path: &mut MatchedPath,
|
||
per_byte: &mut Vec<ByteClass>,
|
||
) -> NodeWalkResult {
|
||
let bytes = source.as_bytes();
|
||
if position < bytes.len() && bytes[position] == ch as u8 {
|
||
// ADR-0033 Amendment 4: a `-` does not match when it begins an
|
||
// adjacent `--`. The playground supports no `--` line comment, and
|
||
// `--` is the DSL flag marker (e.g. `--all-rows`); treating `--`
|
||
// as two minus operators silently mis-parsed `set x = 42
|
||
// --all-rows` as arithmetic over phantom columns `all`/`rows`.
|
||
// Refusing here makes the SQL expression stop, so the SQL shape
|
||
// fails and dispatch falls back to the DSL flag. Spaced `- -3` is
|
||
// unaffected (the dashes are not adjacent). `Node::Punct('-')` is
|
||
// used only by the SQL expression grammar, so this is scoped to it.
|
||
if ch == '-' && bytes.get(position + 1) == Some(&b'-') {
|
||
return NodeWalkResult::NoMatch {
|
||
position,
|
||
expected: vec![Expectation::Punct(ch)],
|
||
};
|
||
}
|
||
path.push(MatchedItem {
|
||
kind: MatchedKind::Punct(ch),
|
||
text: ch.to_string(),
|
||
span: (position, position + 1),
|
||
});
|
||
per_byte.push(ByteClass {
|
||
start: position,
|
||
end: position + 1,
|
||
class: HighlightClass::Punct,
|
||
});
|
||
matched(position + 1)
|
||
} else {
|
||
NodeWalkResult::NoMatch {
|
||
position,
|
||
expected: vec![Expectation::Punct(ch)],
|
||
}
|
||
}
|
||
}
|
||
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn walk_ident(
|
||
source: &str,
|
||
position: usize,
|
||
src: crate::dsl::grammar::IdentSource,
|
||
role: &'static str,
|
||
validator: Option<crate::dsl::grammar::IdentValidator>,
|
||
highlight_override: Option<crate::dsl::grammar::HighlightClass>,
|
||
writes_table: bool,
|
||
writes_column: bool,
|
||
writes_user_listed_column: bool,
|
||
writes_table_alias: bool,
|
||
writes_cte_name: bool,
|
||
writes_projection_alias: bool,
|
||
ctx: &mut WalkContext,
|
||
path: &mut MatchedPath,
|
||
per_byte: &mut Vec<ByteClass>,
|
||
) -> NodeWalkResult {
|
||
let Some((start, end)) = consume_ident(source, position) else {
|
||
return NodeWalkResult::NoMatch {
|
||
position,
|
||
expected: vec![Expectation::Ident { role, source: src }],
|
||
};
|
||
};
|
||
let text = source[start..end].to_string();
|
||
if let Some(v) = validator
|
||
&& let Err(err) = v(&text)
|
||
{
|
||
return NodeWalkResult::Failed {
|
||
position: start,
|
||
kind: FailureKind::Validation(err),
|
||
};
|
||
}
|
||
// ADR-0024 §Phase D / ADR-0032 §10.1: schema-aware writes.
|
||
// When the ident is a `Tables` source with `writes_table`,
|
||
// resolve the matched name against the schema cache and:
|
||
// 1. populate `current_table` / `current_table_columns`
|
||
// (preserved for DSL paths that read those fields
|
||
// directly);
|
||
// 2. push a `TableBinding` onto the top `ScopeFrame`'s
|
||
// `from_scope` (ADR-0032 §10.1 — for SQL multi-table
|
||
// contexts).
|
||
if writes_table && matches!(src, crate::dsl::grammar::IdentSource::Tables) {
|
||
let resolved_columns: Vec<crate::completion::TableColumn> = ctx
|
||
.schema
|
||
.and_then(|s| s.columns_for_table(&text).map(<[_]>::to_vec))
|
||
.unwrap_or_default();
|
||
ctx.current_table = Some(text.clone());
|
||
ctx.current_table_columns = if resolved_columns.is_empty() {
|
||
None
|
||
} else {
|
||
Some(resolved_columns.clone())
|
||
};
|
||
if let Some(frame) = ctx.from_scope_stack.last_mut() {
|
||
frame
|
||
.from_scope
|
||
.push(crate::dsl::walker::context::TableBinding {
|
||
table: text.clone(),
|
||
alias: None,
|
||
columns: resolved_columns,
|
||
});
|
||
}
|
||
}
|
||
// ADR-0032 §10.1: the optional `[ AS ] alias` slot on a
|
||
// `from_clause` / `join_clause` table source. The flag is
|
||
// expected on `IdentSource::NewName` slots; the just-pushed
|
||
// binding (the most recent entry in the top frame's
|
||
// `from_scope`) gets its alias set.
|
||
if writes_table_alias
|
||
&& let Some(frame) = ctx.from_scope_stack.last_mut()
|
||
&& let Some(binding) = frame.from_scope.last_mut()
|
||
{
|
||
binding.alias = Some(text.clone());
|
||
}
|
||
// ADR-0032 §10.3 stage 1 + stage 2: push a placeholder
|
||
// CteBinding into the top (outer) frame before the body's
|
||
// ScopedSubgrammar pushes its own frame. The body can
|
||
// self-reference the CTE name as a table source (WITH
|
||
// RECURSIVE), and downstream CTE-name validators see the
|
||
// binding. Then arm `pending_cte_harvest` so the next
|
||
// ScopedSubgrammar (which is structurally guaranteed to be
|
||
// the CTE body — no intervening scoped subgrammar in CTE
|
||
// syntax) runs the harvest at body-frame exit.
|
||
if writes_cte_name && let Some(frame) = ctx.from_scope_stack.last_mut() {
|
||
frame
|
||
.cte_bindings
|
||
.push(crate::dsl::walker::context::CteBinding {
|
||
name: text.clone(),
|
||
columns: Vec::new(),
|
||
});
|
||
let placeholder_index = frame.cte_bindings.len() - 1;
|
||
ctx.pending_cte_harvest = Some(crate::dsl::walker::context::PendingCteHarvest {
|
||
placeholder_index,
|
||
col_list: Vec::new(),
|
||
cte_name: text.clone(),
|
||
cte_name_span: (start, end),
|
||
});
|
||
}
|
||
// ADR-0032 §10.3: the optional `(c1, c2, …)` rename list
|
||
// between the cte name and `AS`. Each `cte_column` ident
|
||
// appends to the pending harvest's col_list; the harvest
|
||
// applies them as positional renames on the derived
|
||
// columns.
|
||
if role == "cte_column"
|
||
&& let Some(pending) = ctx.pending_cte_harvest.as_mut()
|
||
{
|
||
pending.col_list.push(text.clone());
|
||
}
|
||
// ADR-0032 §10.4: projection-list alias accumulator for
|
||
// ORDER BY completion candidates.
|
||
if writes_projection_alias && let Some(frame) = ctx.from_scope_stack.last_mut() {
|
||
frame.projection_aliases.push(text.clone());
|
||
}
|
||
if writes_column && matches!(src, crate::dsl::grammar::IdentSource::Columns) {
|
||
ctx.current_column = ctx.current_table_columns.as_ref().and_then(|cols| {
|
||
cols.iter()
|
||
.find(|c| c.name.eq_ignore_ascii_case(&text))
|
||
.cloned()
|
||
});
|
||
// Surface the column name to the hint resolver too —
|
||
// this is the `update <T> set <col>=` / `where <col>=`
|
||
// path. The matching column's canonical name (from the
|
||
// schema) wins over the user's spelling so the hint
|
||
// mirrors what's in the schema.
|
||
ctx.pending_value_column = ctx
|
||
.current_column
|
||
.as_ref()
|
||
.map(|c| c.name.clone())
|
||
.or_else(|| Some(text.clone()));
|
||
}
|
||
if writes_user_listed_column && matches!(src, crate::dsl::grammar::IdentSource::Columns) {
|
||
// Form A: `insert into <T> (col1, col2, …)`. Append the
|
||
// matched column name to user_listed_columns so the
|
||
// inner `values (…)` slot list mirrors the user's
|
||
// explicit selection. Schema-canonical name wins over
|
||
// user's spelling so downstream lookups (typed slot
|
||
// dispatch, hint rendering) are consistent.
|
||
let canonical = ctx
|
||
.current_table_columns
|
||
.as_ref()
|
||
.and_then(|cols| {
|
||
cols.iter()
|
||
.find(|c| c.name.eq_ignore_ascii_case(&text))
|
||
.map(|c| c.name.clone())
|
||
})
|
||
.unwrap_or_else(|| text.clone());
|
||
ctx.user_listed_columns
|
||
.get_or_insert_with(Vec::new)
|
||
.push(canonical);
|
||
}
|
||
path.push(MatchedItem {
|
||
kind: MatchedKind::Ident { role, source: src },
|
||
text,
|
||
span: (start, end),
|
||
});
|
||
per_byte.push(ByteClass {
|
||
start,
|
||
end,
|
||
// A type slot (and any future slot that wants a non-default
|
||
// colour) overrides the otherwise-uniform Identifier class
|
||
// (issue #8 / ADR-0022 Amendment 4).
|
||
class: highlight_override.unwrap_or(HighlightClass::Identifier),
|
||
});
|
||
NodeWalkResult::Matched {
|
||
end,
|
||
skipped: Vec::new(),
|
||
}
|
||
}
|
||
|
||
fn walk_string_lit(
|
||
source: &str,
|
||
position: usize,
|
||
path: &mut MatchedPath,
|
||
per_byte: &mut Vec<ByteClass>,
|
||
) -> NodeWalkResult {
|
||
let Some(((start, end), content)) = consume_string_literal(source, position) else {
|
||
return NodeWalkResult::NoMatch {
|
||
position,
|
||
expected: vec![Expectation::StringLit],
|
||
};
|
||
};
|
||
path.push(MatchedItem {
|
||
kind: MatchedKind::StringLit,
|
||
text: content,
|
||
span: (start, end),
|
||
});
|
||
per_byte.push(ByteClass {
|
||
start,
|
||
end,
|
||
class: HighlightClass::String,
|
||
});
|
||
NodeWalkResult::Matched {
|
||
end,
|
||
skipped: Vec::new(),
|
||
}
|
||
}
|
||
|
||
fn walk_literal(
|
||
source: &str,
|
||
position: usize,
|
||
literal: &'static str,
|
||
path: &mut MatchedPath,
|
||
per_byte: &mut Vec<ByteClass>,
|
||
) -> NodeWalkResult {
|
||
let bytes = source.as_bytes();
|
||
let lit_bytes = literal.as_bytes();
|
||
if position + lit_bytes.len() > bytes.len() {
|
||
return NodeWalkResult::NoMatch {
|
||
position,
|
||
expected: vec![Expectation::Literal(literal)],
|
||
};
|
||
}
|
||
if &bytes[position..position + lit_bytes.len()] != lit_bytes {
|
||
return NodeWalkResult::NoMatch {
|
||
position,
|
||
expected: vec![Expectation::Literal(literal)],
|
||
};
|
||
}
|
||
// Lookahead: if the literal is a single digit / alphabetic
|
||
// run, the next byte must not extend it (so `1` doesn't
|
||
// half-match `12`).
|
||
let end = position + lit_bytes.len();
|
||
let last = lit_bytes[lit_bytes.len() - 1];
|
||
let last_is_word = last.is_ascii_alphanumeric() || last == b'_';
|
||
if last_is_word && end < bytes.len() {
|
||
let next = bytes[end];
|
||
if next.is_ascii_alphanumeric() || next == b'_' {
|
||
return NodeWalkResult::NoMatch {
|
||
position,
|
||
expected: vec![Expectation::Literal(literal)],
|
||
};
|
||
}
|
||
}
|
||
// Highlight class follows the literal's shape: digits get
|
||
// Number; letters get Keyword; mixed defaults to Keyword.
|
||
let class = if lit_bytes.iter().all(|b| b.is_ascii_digit()) {
|
||
HighlightClass::Number
|
||
} else {
|
||
HighlightClass::Keyword
|
||
};
|
||
path.push(MatchedItem {
|
||
kind: MatchedKind::Word(literal),
|
||
text: literal.to_string(),
|
||
span: (position, end),
|
||
});
|
||
per_byte.push(ByteClass {
|
||
start: position,
|
||
end,
|
||
class,
|
||
});
|
||
NodeWalkResult::Matched {
|
||
end,
|
||
skipped: Vec::new(),
|
||
}
|
||
}
|
||
|
||
fn walk_number_lit(
|
||
source: &str,
|
||
position: usize,
|
||
validator: Option<crate::dsl::grammar::NumberValidator>,
|
||
path: &mut MatchedPath,
|
||
per_byte: &mut Vec<ByteClass>,
|
||
) -> NodeWalkResult {
|
||
let Some((start, end)) = consume_number_literal(source, position) else {
|
||
return NodeWalkResult::NoMatch {
|
||
position,
|
||
expected: vec![Expectation::NumberLit],
|
||
};
|
||
};
|
||
let text = source[start..end].to_string();
|
||
if let Some(v) = validator
|
||
&& let Err(err) = v(&text)
|
||
{
|
||
return NodeWalkResult::Failed {
|
||
position: start,
|
||
kind: FailureKind::Validation(err),
|
||
};
|
||
}
|
||
path.push(MatchedItem {
|
||
kind: MatchedKind::NumberLit,
|
||
text,
|
||
span: (start, end),
|
||
});
|
||
per_byte.push(ByteClass {
|
||
start,
|
||
end,
|
||
class: HighlightClass::Number,
|
||
});
|
||
NodeWalkResult::Matched {
|
||
end,
|
||
skipped: Vec::new(),
|
||
}
|
||
}
|
||
|
||
fn walk_flag(
|
||
source: &str,
|
||
position: usize,
|
||
name: &'static str,
|
||
path: &mut MatchedPath,
|
||
per_byte: &mut Vec<ByteClass>,
|
||
) -> NodeWalkResult {
|
||
let Some((start, end)) = consume_flag(source, position) else {
|
||
return NodeWalkResult::NoMatch {
|
||
position,
|
||
expected: vec![Expectation::Flag(name)],
|
||
};
|
||
};
|
||
// `consume_flag` guarantees `start..end` covers `--<body>`.
|
||
let body = &source[start + 2..end];
|
||
if body != name {
|
||
return NodeWalkResult::NoMatch {
|
||
position,
|
||
expected: vec![Expectation::Flag(name)],
|
||
};
|
||
}
|
||
path.push(MatchedItem {
|
||
kind: MatchedKind::Flag(name),
|
||
text: source[start..end].to_string(),
|
||
span: (start, end),
|
||
});
|
||
per_byte.push(ByteClass {
|
||
start,
|
||
end,
|
||
class: HighlightClass::Flag,
|
||
});
|
||
NodeWalkResult::Matched {
|
||
end,
|
||
skipped: Vec::new(),
|
||
}
|
||
}
|
||
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn walk_repeated(
|
||
source: &str,
|
||
position: usize,
|
||
inner: &Node,
|
||
separator: Option<&Node>,
|
||
min: usize,
|
||
ctx: &mut WalkContext,
|
||
path: &mut MatchedPath,
|
||
per_byte: &mut Vec<ByteClass>,
|
||
) -> NodeWalkResult {
|
||
let mut cur = position;
|
||
let mut count = 0_usize;
|
||
let mut last_expected: Option<Vec<Expectation>> = None;
|
||
// Trailing-optional expectations carried by the most recently
|
||
// matched item — e.g. `asc`/`desc` after an ORDER BY sort
|
||
// item, or a projection's `as` alias. Surfaced when the list
|
||
// ends cleanly at an item boundary so completion still offers
|
||
// the optional suffix the user could type next (handoff 31 —
|
||
// the `desc` follow-up to F5). The separator itself is
|
||
// deliberately NOT surfaced.
|
||
let mut last_item_skipped: Vec<Expectation> = Vec::new();
|
||
// Set when the loop stops because the separator did not match
|
||
// at an item boundary (a clean end of list), as opposed to an
|
||
// inner mismatch past an already-consumed separator. Only at a
|
||
// clean boundary are the last item's trailing optionals valid
|
||
// continuations at the cursor.
|
||
let mut ended_at_item_boundary = false;
|
||
loop {
|
||
let saved_path_len = path.items.len();
|
||
let saved_byte_len = per_byte.len();
|
||
// Track whether the separator successfully consumed
|
||
// before the inner attempt. Used below to distinguish
|
||
// "user typed `,` then stopped at EOF — mid-typing the
|
||
// next item" from "list naturally ended at the inner
|
||
// boundary".
|
||
let mut sep_consumed_to: Option<usize> = None;
|
||
let result = if count == 0 {
|
||
walk_node(source, cur, inner, ctx, path, per_byte)
|
||
} else if let Some(sep) = separator {
|
||
let sep_saved_path = path.items.len();
|
||
let sep_saved_byte = per_byte.len();
|
||
match walk_node(source, cur, sep, ctx, path, per_byte) {
|
||
NodeWalkResult::Matched { end, .. } => {
|
||
sep_consumed_to = Some(end);
|
||
walk_node(source, end, inner, ctx, path, per_byte)
|
||
}
|
||
NodeWalkResult::NoMatch { .. } => {
|
||
path.items.truncate(sep_saved_path);
|
||
per_byte.truncate(sep_saved_byte);
|
||
ended_at_item_boundary = true;
|
||
break;
|
||
}
|
||
other => return other,
|
||
}
|
||
} else {
|
||
walk_node(source, cur, inner, ctx, path, per_byte)
|
||
};
|
||
match result {
|
||
NodeWalkResult::Matched { end, skipped } => {
|
||
cur = end;
|
||
count += 1;
|
||
last_item_skipped = skipped;
|
||
}
|
||
NodeWalkResult::NoMatch {
|
||
expected,
|
||
position: inner_pos,
|
||
} => {
|
||
// Mid-typing-the-next-item recovery: if the
|
||
// separator just consumed and the inner failed
|
||
// at EOF, the user is partway through typing the
|
||
// next item — propagate as Incomplete so the
|
||
// outer walker classifies the input as
|
||
// mid-typing rather than rolling the separator
|
||
// back and producing a structural Mismatch at
|
||
// the separator position.
|
||
//
|
||
// Without this branch, `insert into T (a, ` at
|
||
// EOF would roll back the `,`, then the outer
|
||
// `(`-list expected `)` at `cur`, see the
|
||
// separator instead, and report a definite
|
||
// error at the separator. Real users hit this
|
||
// every time they type a comma and pause.
|
||
if let Some(post_sep) = sep_consumed_to {
|
||
let post_ws = skip_whitespace(source, post_sep);
|
||
if post_ws >= source.len() {
|
||
return NodeWalkResult::Incomplete {
|
||
position: inner_pos,
|
||
expected,
|
||
};
|
||
}
|
||
}
|
||
path.items.truncate(saved_path_len);
|
||
per_byte.truncate(saved_byte_len);
|
||
last_expected = Some(expected);
|
||
break;
|
||
}
|
||
other => return other,
|
||
}
|
||
}
|
||
if count < min {
|
||
return NodeWalkResult::NoMatch {
|
||
position: cur,
|
||
expected: last_expected.unwrap_or_default(),
|
||
};
|
||
}
|
||
// The "could continue" expectations become this Repeated's
|
||
// `skipped` set so the caller's expected-set surfaces them at
|
||
// completion time. When the list ended cleanly at an item
|
||
// boundary, that is the last item's trailing optionals (e.g.
|
||
// `asc`/`desc`); otherwise it is whatever the final inner
|
||
// attempt expected.
|
||
let skipped = if ended_at_item_boundary {
|
||
last_item_skipped
|
||
} else {
|
||
last_expected.unwrap_or_default()
|
||
};
|
||
NodeWalkResult::Matched { end: cur, skipped }
|
||
}
|
||
|
||
fn walk_bare_path(
|
||
source: &str,
|
||
position: usize,
|
||
path: &mut MatchedPath,
|
||
per_byte: &mut Vec<ByteClass>,
|
||
) -> NodeWalkResult {
|
||
let Some((start, end)) = consume_bare_path(source, position) else {
|
||
return NodeWalkResult::NoMatch {
|
||
position,
|
||
expected: vec![Expectation::BarePath],
|
||
};
|
||
};
|
||
let text = source[start..end].to_string();
|
||
path.push(MatchedItem {
|
||
kind: MatchedKind::BarePath,
|
||
text,
|
||
span: (start, end),
|
||
});
|
||
per_byte.push(ByteClass {
|
||
start,
|
||
end,
|
||
class: HighlightClass::String,
|
||
});
|
||
NodeWalkResult::Matched {
|
||
end,
|
||
skipped: Vec::new(),
|
||
}
|
||
}
|
||
|
||
fn walk_choice(
|
||
source: &str,
|
||
position: usize,
|
||
children: &[Node],
|
||
ctx: &mut WalkContext,
|
||
path: &mut MatchedPath,
|
||
per_byte: &mut Vec<ByteClass>,
|
||
) -> NodeWalkResult {
|
||
let mut all_expected: Vec<Expectation> = Vec::new();
|
||
for child in children {
|
||
let saved_path_len = path.items.len();
|
||
let saved_byte_len = per_byte.len();
|
||
match walk_node(source, position, child, ctx, path, per_byte) {
|
||
m @ NodeWalkResult::Matched { .. } => return m,
|
||
NodeWalkResult::NoMatch { expected, .. } => {
|
||
path.items.truncate(saved_path_len);
|
||
per_byte.truncate(saved_byte_len);
|
||
merge_expected(&mut all_expected, expected);
|
||
}
|
||
other => return other,
|
||
}
|
||
}
|
||
NodeWalkResult::NoMatch {
|
||
position,
|
||
expected: all_expected,
|
||
}
|
||
}
|
||
|
||
fn walk_seq(
|
||
source: &str,
|
||
position: usize,
|
||
children: &[Node],
|
||
ctx: &mut WalkContext,
|
||
path: &mut MatchedPath,
|
||
per_byte: &mut Vec<ByteClass>,
|
||
) -> NodeWalkResult {
|
||
let mut cur = position;
|
||
let mut idx = 0;
|
||
// Carries expectations from skipped-Optional children so
|
||
// that a NoMatch on a later child reports the union of "you
|
||
// could have typed any of these" — making the completion
|
||
// engine see optional connectives that haven't been typed.
|
||
let mut pending_skipped: Vec<Expectation> = Vec::new();
|
||
for child in children {
|
||
let path_before = path.items.len();
|
||
match walk_node(source, cur, child, ctx, path, per_byte) {
|
||
NodeWalkResult::Matched { end, skipped } => {
|
||
if end == cur {
|
||
// Child matched zero terminals (Optional skipped,
|
||
// empty Repeated, empty Seq). Accumulate its
|
||
// would-be expectations into pending.
|
||
for e in skipped {
|
||
if !pending_skipped.contains(&e) {
|
||
pending_skipped.push(e);
|
||
}
|
||
}
|
||
} else {
|
||
// Child consumed terminals — the "missing optional"
|
||
// window closed; reset the pending list.
|
||
pending_skipped.clear();
|
||
pending_skipped.extend(skipped);
|
||
}
|
||
cur = end;
|
||
idx += 1;
|
||
}
|
||
NodeWalkResult::NoMatch {
|
||
position,
|
||
mut expected,
|
||
} => {
|
||
// Merge pending skipped-optional expectations with this
|
||
// child's expected set.
|
||
for e in std::mem::take(&mut pending_skipped) {
|
||
if !expected.contains(&e) {
|
||
expected.push(e);
|
||
}
|
||
}
|
||
if idx == 0 {
|
||
return NodeWalkResult::NoMatch { position, expected };
|
||
}
|
||
let post_ws = skip_whitespace(source, position);
|
||
if post_ws >= source.len() {
|
||
return NodeWalkResult::Incomplete {
|
||
position: post_ws,
|
||
expected,
|
||
};
|
||
}
|
||
return NodeWalkResult::Failed {
|
||
position: post_ws,
|
||
kind: FailureKind::Mismatch { expected },
|
||
};
|
||
}
|
||
NodeWalkResult::Incomplete {
|
||
position,
|
||
mut expected,
|
||
} => {
|
||
// Only merge the skipped-Optional expectations when
|
||
// the Incomplete-producing child consumed nothing
|
||
// (path didn't grow): the cursor still sits at the
|
||
// optional boundary, so those optionals are genuine
|
||
// alternatives. If the child committed terminals
|
||
// (e.g. `order by` consumed, now awaiting a sort
|
||
// item) the cursor has moved *past* the skipped
|
||
// optionals — clauses positioned before this child
|
||
// are no longer valid continuations, so dropping
|
||
// `pending_skipped` keeps them out of the expected
|
||
// set (handoff 30 §3.3, F5).
|
||
let child_consumed = path.items.len() > path_before;
|
||
if !child_consumed {
|
||
for e in std::mem::take(&mut pending_skipped) {
|
||
if !expected.contains(&e) {
|
||
expected.push(e);
|
||
}
|
||
}
|
||
}
|
||
return NodeWalkResult::Incomplete { position, expected };
|
||
}
|
||
NodeWalkResult::Failed { position, kind } => {
|
||
return NodeWalkResult::Failed { position, kind };
|
||
}
|
||
}
|
||
}
|
||
NodeWalkResult::Matched {
|
||
end: cur,
|
||
skipped: pending_skipped,
|
||
}
|
||
}
|
||
|
||
/// Issue #26: when an `Optional` is skipped (its inner didn't engage),
|
||
/// stash any `IntroProse` hint the inner left in `pending_hint_mode`
|
||
/// into the surviving slot before it is cleared by this empty match.
|
||
/// `position` is where the optional was skipped — the resolver compares
|
||
/// it to the cursor so the hint only shows while the cursor sits at that
|
||
/// optional, not after a later clause consumes input past it. Only
|
||
/// `IntroProse` is carried (it is the "introduce an optional position"
|
||
/// mode); `ProseOnly` / `ForceProse` mark active slots and reach the
|
||
/// resolver through the normal `pending_hint_mode` path.
|
||
const fn capture_skipped_intro_hint(ctx: &mut WalkContext, position: usize) {
|
||
if let Some(crate::dsl::grammar::HintMode::IntroProse(key)) = ctx.pending_hint_mode {
|
||
ctx.surviving_intro_hint = Some((key, position));
|
||
}
|
||
}
|
||
|
||
fn walk_optional(
|
||
source: &str,
|
||
position: usize,
|
||
child: &Node,
|
||
ctx: &mut WalkContext,
|
||
path: &mut MatchedPath,
|
||
per_byte: &mut Vec<ByteClass>,
|
||
) -> NodeWalkResult {
|
||
let saved_path_len = path.items.len();
|
||
let saved_byte_len = per_byte.len();
|
||
let result = walk_node(source, position, child, ctx, path, per_byte);
|
||
let inner_committed = path.items.len() > saved_path_len;
|
||
match result {
|
||
m @ NodeWalkResult::Matched { .. } => m,
|
||
NodeWalkResult::NoMatch { expected, .. } => {
|
||
// Inner didn't engage at all — skip the Optional
|
||
// but carry the inner's expectations so the caller's
|
||
// expected-set sees them.
|
||
capture_skipped_intro_hint(ctx, position);
|
||
path.items.truncate(saved_path_len);
|
||
per_byte.truncate(saved_byte_len);
|
||
NodeWalkResult::Matched {
|
||
end: position,
|
||
skipped: expected,
|
||
}
|
||
}
|
||
NodeWalkResult::Incomplete {
|
||
position: p,
|
||
expected,
|
||
} if !inner_committed => {
|
||
// Inner reported Incomplete without consuming
|
||
// anything — same as NoMatch from the user's
|
||
// perspective. Roll back and skip.
|
||
capture_skipped_intro_hint(ctx, position);
|
||
path.items.truncate(saved_path_len);
|
||
per_byte.truncate(saved_byte_len);
|
||
let _ = p;
|
||
NodeWalkResult::Matched {
|
||
end: position,
|
||
skipped: expected,
|
||
}
|
||
}
|
||
NodeWalkResult::Failed {
|
||
kind: FailureKind::Mismatch { expected },
|
||
..
|
||
} if !inner_committed => {
|
||
// Inner reported Mismatch without consuming
|
||
// anything — roll back and skip.
|
||
path.items.truncate(saved_path_len);
|
||
per_byte.truncate(saved_byte_len);
|
||
NodeWalkResult::Matched {
|
||
end: position,
|
||
skipped: expected,
|
||
}
|
||
}
|
||
// Inner committed (consumed at least one terminal) but
|
||
// then ran out / hit a mismatch. Propagate the failure
|
||
// up — the user is mid-typing the optional's content and
|
||
// we'd lose their intent by rolling back. (Pre-fix
|
||
// behavior matched chumsky's `or_not` rollback, but
|
||
// that conflates "Form A in progress" with "Form C with
|
||
// trailing junk" — see e.g. `insert into T (a, b, c)
|
||
// values (1, 2, 3` losing the `values (…)` partial.)
|
||
// Validation failures already propagate as a separate
|
||
// branch below.
|
||
propagated @ (NodeWalkResult::Incomplete { .. } | NodeWalkResult::Failed { .. }) => {
|
||
propagated
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Walk a `&'static Node` reference once (ADR-0026 §2).
|
||
///
|
||
/// The reference indirection is what lets a named `static`
|
||
/// grammar fragment recurse: `Seq` / `Choice` embed children by
|
||
/// value and so cannot close a cycle, but a `Subgrammar` node
|
||
/// holding a `&'static Node` can point back into an enclosing
|
||
/// fragment. The stratified WHERE-expression grammar's
|
||
/// `( or_expr )` branch and `not_expr` self-reference both
|
||
/// recurse this way.
|
||
///
|
||
/// `WalkContext::subgrammar_depth` counts active frames. Past
|
||
/// `MAX_SUBGRAMMAR_DEPTH` the walk fails with a friendly
|
||
/// `expression_too_deep` validation error rather than
|
||
/// overflowing the process stack. The depth is saved on entry
|
||
/// and restored on exit unconditionally, so a speculatively-
|
||
/// walked branch that a `Choice` later rolls back leaves the
|
||
/// counter clean.
|
||
fn walk_subgrammar(
|
||
source: &str,
|
||
pos: usize,
|
||
inner: &'static Node,
|
||
ctx: &mut WalkContext,
|
||
path: &mut MatchedPath,
|
||
per_byte: &mut Vec<ByteClass>,
|
||
) -> NodeWalkResult {
|
||
let saved_depth = ctx.subgrammar_depth;
|
||
ctx.subgrammar_depth += 1;
|
||
if ctx.subgrammar_depth > MAX_SUBGRAMMAR_DEPTH {
|
||
ctx.subgrammar_depth = saved_depth;
|
||
return NodeWalkResult::Failed {
|
||
position: pos,
|
||
kind: FailureKind::Validation(ValidationError {
|
||
message_key: "parse.custom.expression_too_deep",
|
||
args: Vec::new(),
|
||
}),
|
||
};
|
||
}
|
||
let result = walk_node(source, pos, inner, ctx, path, per_byte);
|
||
ctx.subgrammar_depth = saved_depth;
|
||
result
|
||
}
|
||
|
||
/// Walk a `ScopedSubgrammar` reference once (ADR-0032 §10.2).
|
||
///
|
||
/// Pushes a fresh `ScopeFrame` onto `from_scope_stack` on
|
||
/// entry and pops it back on exit. The push/pop is
|
||
/// unconditional — a speculatively-walked branch that a
|
||
/// `Choice` later rolls back leaves the stack clean. Shares
|
||
/// the `subgrammar_depth` counter with the plain `Subgrammar`
|
||
/// variant so the depth cap fires uniformly.
|
||
fn walk_scoped_subgrammar(
|
||
source: &str,
|
||
pos: usize,
|
||
inner: &'static Node,
|
||
ctx: &mut WalkContext,
|
||
path: &mut MatchedPath,
|
||
per_byte: &mut Vec<ByteClass>,
|
||
) -> NodeWalkResult {
|
||
let saved_depth = ctx.subgrammar_depth;
|
||
ctx.subgrammar_depth += 1;
|
||
if ctx.subgrammar_depth > MAX_SUBGRAMMAR_DEPTH {
|
||
ctx.subgrammar_depth = saved_depth;
|
||
return NodeWalkResult::Failed {
|
||
position: pos,
|
||
kind: FailureKind::Validation(ValidationError {
|
||
message_key: "parse.custom.expression_too_deep",
|
||
args: Vec::new(),
|
||
}),
|
||
};
|
||
}
|
||
// ADR-0032 §10.3 stage 2 — pick up a pending CTE harvest
|
||
// request armed by the immediately-preceding cte_name ident.
|
||
// Clear unconditionally: a non-matching body must not leave
|
||
// stale state for a later unrelated ScopedSubgrammar.
|
||
let pending_cte = ctx.pending_cte_harvest.take();
|
||
ctx.from_scope_stack
|
||
.push(crate::dsl::walker::context::ScopeFrame::default());
|
||
let result = walk_node(source, pos, inner, ctx, path, per_byte);
|
||
|
||
// Harvest happens only on a fully-matched body. Speculative
|
||
// walks that NoMatch / Incomplete / Fail leave the placeholder
|
||
// empty (the outer-frame state is also discarded in the
|
||
// speculative path, so this is correct).
|
||
if let (Some(req), NodeWalkResult::Matched { end, .. }) = (pending_cte, &result) {
|
||
run_cte_harvest(ctx, path, source, pos, *end, &req);
|
||
}
|
||
|
||
ctx.from_scope_stack.pop();
|
||
ctx.subgrammar_depth = saved_depth;
|
||
result
|
||
}
|
||
|
||
/// Run the §10.3 stage-2 harvest after a CTE body's
|
||
/// `ScopedSubgrammar` matched, while the body's frame is still
|
||
/// on top of `from_scope_stack`.
|
||
///
|
||
/// Reads the body's projection items out of the matched path's
|
||
/// byte range, classifies each via the six derivation rules,
|
||
/// applies any `(col-list)` positional rename, and writes the
|
||
/// derived columns into the placeholder `CteBinding` in the
|
||
/// outer (now `len - 2`) frame.
|
||
fn run_cte_harvest(
|
||
ctx: &mut WalkContext,
|
||
path: &MatchedPath,
|
||
_source: &str,
|
||
body_start: usize,
|
||
body_end: usize,
|
||
req: &crate::dsl::walker::context::PendingCteHarvest,
|
||
) {
|
||
use crate::dsl::walker::context::{CteColumn, ScopeFrame};
|
||
use crate::dsl::walker::outcome::{MatchedItem, MatchedKind};
|
||
|
||
// The body's frame is at the top of the stack while the
|
||
// harvest runs. Need this for from_scope lookups in the
|
||
// derivation rules.
|
||
let body_frame: &ScopeFrame = match ctx.from_scope_stack.last() {
|
||
Some(f) => f,
|
||
None => return,
|
||
};
|
||
|
||
// Compute body_depth = paren-balance over path items strictly
|
||
// before body_start. The `(` immediately preceding the body
|
||
// is at the outer depth and increments to the body's depth;
|
||
// body_start is INSIDE that paren.
|
||
let mut prefix_depth: i32 = 0;
|
||
for item in &path.items {
|
||
if item.span.0 >= body_start {
|
||
break;
|
||
}
|
||
match item.kind {
|
||
MatchedKind::Punct('(') => prefix_depth += 1,
|
||
MatchedKind::Punct(')') => prefix_depth -= 1,
|
||
_ => {}
|
||
}
|
||
}
|
||
let body_depth = prefix_depth;
|
||
|
||
// The path items strictly inside the body byte range.
|
||
let body_items: Vec<&MatchedItem> = path
|
||
.items
|
||
.iter()
|
||
.filter(|i| i.span.0 >= body_start && i.span.1 <= body_end)
|
||
.collect();
|
||
|
||
// Track depth within the body. First leg's projection list
|
||
// begins at the first body-depth SELECT and ends at the
|
||
// first body-depth FROM/WHERE/etc OR set-op keyword OR end.
|
||
let mut depth = body_depth;
|
||
let mut select_idx: Option<usize> = None;
|
||
let mut end_idx: usize = body_items.len();
|
||
for (i, item) in body_items.iter().enumerate() {
|
||
let cur = depth;
|
||
match item.kind {
|
||
MatchedKind::Punct('(') => depth += 1,
|
||
MatchedKind::Punct(')') => depth -= 1,
|
||
_ => {}
|
||
}
|
||
if cur != body_depth {
|
||
continue;
|
||
}
|
||
match item.kind {
|
||
MatchedKind::Word("select") if select_idx.is_none() => {
|
||
select_idx = Some(i + 1); // start of projection list
|
||
}
|
||
MatchedKind::Word(
|
||
"from" | "where" | "group" | "having" | "order" | "limit" | "offset" | "union"
|
||
| "intersect" | "except",
|
||
) if select_idx.is_some() => {
|
||
end_idx = i;
|
||
break;
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
let Some(start_idx) = select_idx else {
|
||
return;
|
||
};
|
||
if start_idx >= end_idx {
|
||
return;
|
||
}
|
||
|
||
// Split the projection-list slice into individual items by
|
||
// commas at body_depth.
|
||
let mut item_slices: Vec<&[&MatchedItem]> = Vec::new();
|
||
let mut depth_scan = body_depth;
|
||
let mut slice_start = start_idx;
|
||
for i in start_idx..end_idx {
|
||
let cur = depth_scan;
|
||
match body_items[i].kind {
|
||
MatchedKind::Punct('(') => depth_scan += 1,
|
||
MatchedKind::Punct(')') => depth_scan -= 1,
|
||
MatchedKind::Punct(',') if cur == body_depth => {
|
||
item_slices.push(&body_items[slice_start..i]);
|
||
slice_start = i + 1;
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
if slice_start < end_idx {
|
||
item_slices.push(&body_items[slice_start..end_idx]);
|
||
}
|
||
|
||
// Classify each projection item per ADR-0032 §10.3.
|
||
let mut derived: Vec<CteColumn> = Vec::new();
|
||
for slice in item_slices {
|
||
classify_projection_item(slice, body_frame, &ctx.from_scope_stack, &mut derived);
|
||
}
|
||
|
||
// Apply (c1, c2, …) positional rename if provided. Types
|
||
// are preserved; names overridden by the col_list. Arity
|
||
// mismatch is emitted as `diagnostic.cte_arity_mismatch`
|
||
// on the cte_name span before any padding/truncation so
|
||
// the diagnostic carries the *true* derived count.
|
||
if !req.col_list.is_empty() {
|
||
let declared = req.col_list.len();
|
||
let actual = derived.len();
|
||
if declared != actual {
|
||
use crate::dsl::walker::outcome::{Diagnostic, Severity};
|
||
ctx.pending_diagnostics.push(Diagnostic {
|
||
severity: Severity::Error,
|
||
span: req.cte_name_span,
|
||
message: crate::friendly::translate(
|
||
"diagnostic.cte_arity_mismatch",
|
||
&[
|
||
("cte", &req.cte_name as &dyn std::fmt::Display),
|
||
("declared", &declared as &dyn std::fmt::Display),
|
||
("actual", &actual as &dyn std::fmt::Display),
|
||
],
|
||
),
|
||
});
|
||
}
|
||
for (i, name) in req.col_list.iter().enumerate() {
|
||
if let Some(col) = derived.get_mut(i) {
|
||
col.name = Some(name.clone());
|
||
} else {
|
||
// col_list has MORE entries than derived items —
|
||
// synthesize a typeless slot with the declared
|
||
// name so qualified-prefix completion still
|
||
// surfaces it.
|
||
derived.push(CteColumn {
|
||
name: Some(name.clone()),
|
||
type_: None,
|
||
});
|
||
}
|
||
}
|
||
// Truncate any extras when derived > declared, so the
|
||
// CTE's externally visible arity matches the col-list
|
||
// declaration. (The diagnostic above already captured
|
||
// the original derived count.)
|
||
if derived.len() > declared {
|
||
derived.truncate(declared);
|
||
}
|
||
}
|
||
|
||
// Write into the outer frame's placeholder.
|
||
let stack_len = ctx.from_scope_stack.len();
|
||
if stack_len >= 2
|
||
&& let Some(outer) = ctx.from_scope_stack.get_mut(stack_len - 2)
|
||
&& let Some(placeholder) = outer.cte_bindings.get_mut(req.placeholder_index)
|
||
{
|
||
placeholder.columns = derived;
|
||
}
|
||
}
|
||
|
||
/// Classify one projection item by examining its leading
|
||
/// terminals and append its derived CteColumn(s) to `out`. The
|
||
/// six rules of ADR-0032 §10.3.
|
||
fn classify_projection_item(
|
||
slice: &[&crate::dsl::walker::outcome::MatchedItem],
|
||
body_frame: &crate::dsl::walker::context::ScopeFrame,
|
||
scope_stack: &[crate::dsl::walker::context::ScopeFrame],
|
||
out: &mut Vec<crate::dsl::walker::context::CteColumn>,
|
||
) {
|
||
use crate::dsl::grammar::IdentSource;
|
||
use crate::dsl::walker::context::CteColumn;
|
||
use crate::dsl::walker::outcome::MatchedKind;
|
||
|
||
// Strip an optional trailing `[AS] alias` from the slice so
|
||
// shape detection can examine just the expression part.
|
||
let (expr_slice, alias) = strip_trailing_alias(slice);
|
||
|
||
// Rule 1: `*` — every column from body_frame.from_scope.
|
||
// When a binding represents a CTE reference (its columns are
|
||
// empty because it wasn't a base-table lookup), resolve
|
||
// through to the in-scope CteBinding so nested CTEs project
|
||
// correctly.
|
||
if expr_slice.len() == 1 && matches!(expr_slice[0].kind, MatchedKind::Punct('*')) {
|
||
for binding in &body_frame.from_scope {
|
||
for col in expand_binding(binding, scope_stack) {
|
||
out.push(col);
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Rule 2: `t.*` — every column from binding `t`.
|
||
if expr_slice.len() == 3
|
||
&& matches!(
|
||
expr_slice[0].kind,
|
||
MatchedKind::Ident {
|
||
role: "qualified_star_qualifier",
|
||
..
|
||
}
|
||
)
|
||
&& matches!(expr_slice[1].kind, MatchedKind::Punct('.'))
|
||
&& matches!(expr_slice[2].kind, MatchedKind::Punct('*'))
|
||
{
|
||
let qual = &expr_slice[0].text;
|
||
if let Some(binding) = body_frame.from_scope.iter().find(|b| {
|
||
b.alias
|
||
.as_deref()
|
||
.is_some_and(|a| a.eq_ignore_ascii_case(qual))
|
||
|| b.table.eq_ignore_ascii_case(qual)
|
||
}) {
|
||
for col in expand_binding(binding, scope_stack) {
|
||
out.push(col);
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Rule 3: bare `col` — a single sql_expr_ident terminal.
|
||
if expr_slice.len() == 1
|
||
&& matches!(
|
||
expr_slice[0].kind,
|
||
MatchedKind::Ident {
|
||
source: IdentSource::Columns,
|
||
role: "sql_expr_ident",
|
||
}
|
||
)
|
||
{
|
||
let col_text = &expr_slice[0].text;
|
||
let resolved_type = resolve_bare_column_type_in_frame(body_frame, scope_stack, col_text);
|
||
let name = alias.unwrap_or_else(|| col_text.clone());
|
||
out.push(CteColumn {
|
||
name: Some(name),
|
||
type_: resolved_type,
|
||
});
|
||
return;
|
||
}
|
||
|
||
// Rule 4: qualified `t.col` — three-token shape with the
|
||
// sql_expr_qualified_ref role on the tail ident.
|
||
if expr_slice.len() == 3
|
||
&& matches!(
|
||
expr_slice[0].kind,
|
||
MatchedKind::Ident {
|
||
source: IdentSource::Columns,
|
||
role: "sql_expr_ident",
|
||
}
|
||
)
|
||
&& matches!(expr_slice[1].kind, MatchedKind::Punct('.'))
|
||
&& matches!(
|
||
expr_slice[2].kind,
|
||
MatchedKind::Ident {
|
||
source: IdentSource::Columns,
|
||
role: "sql_expr_qualified_ref",
|
||
}
|
||
)
|
||
{
|
||
let qual = &expr_slice[0].text;
|
||
let col_text = &expr_slice[2].text;
|
||
let resolved_type = resolve_qualified_column_type(body_frame, scope_stack, qual, col_text);
|
||
let name = alias.unwrap_or_else(|| col_text.clone());
|
||
out.push(CteColumn {
|
||
name: Some(name),
|
||
type_: resolved_type,
|
||
});
|
||
return;
|
||
}
|
||
|
||
// Rule 5 / 6: computed expression — name = alias if present,
|
||
// else None. Type = None either way (ADR-0032 Amendment 1).
|
||
out.push(CteColumn {
|
||
name: alias,
|
||
type_: None,
|
||
});
|
||
}
|
||
|
||
/// Peel a trailing `[AS] <ident>` off the projection-item slice
|
||
/// if present. Returns (expr_slice_without_alias, Some(alias))
|
||
/// or (slice, None) if no alias is detected.
|
||
fn strip_trailing_alias<'a>(
|
||
slice: &'a [&'a crate::dsl::walker::outcome::MatchedItem],
|
||
) -> (
|
||
&'a [&'a crate::dsl::walker::outcome::MatchedItem],
|
||
Option<String>,
|
||
) {
|
||
use crate::dsl::grammar::IdentSource;
|
||
use crate::dsl::walker::outcome::MatchedKind;
|
||
|
||
if slice.is_empty() {
|
||
return (slice, None);
|
||
}
|
||
let last = slice[slice.len() - 1];
|
||
if matches!(
|
||
last.kind,
|
||
MatchedKind::Ident {
|
||
source: IdentSource::NewName,
|
||
role: "projection_alias",
|
||
}
|
||
) {
|
||
// Optional preceding `AS` keyword.
|
||
if slice.len() >= 2 && matches!(slice[slice.len() - 2].kind, MatchedKind::Word("as")) {
|
||
return (&slice[..slice.len() - 2], Some(last.text.clone()));
|
||
}
|
||
return (&slice[..slice.len() - 1], Some(last.text.clone()));
|
||
}
|
||
(slice, None)
|
||
}
|
||
|
||
fn resolve_bare_column_type_in_frame(
|
||
frame: &crate::dsl::walker::context::ScopeFrame,
|
||
scope_stack: &[crate::dsl::walker::context::ScopeFrame],
|
||
column: &str,
|
||
) -> Option<crate::dsl::types::Type> {
|
||
let mut found = None;
|
||
for binding in &frame.from_scope {
|
||
for col in expand_binding(binding, scope_stack) {
|
||
if col
|
||
.name
|
||
.as_deref()
|
||
.is_some_and(|n| n.eq_ignore_ascii_case(column))
|
||
{
|
||
if found.is_some() {
|
||
return None; // ambiguous — no type
|
||
}
|
||
found = col.type_;
|
||
}
|
||
}
|
||
}
|
||
found
|
||
}
|
||
|
||
fn resolve_qualified_column_type(
|
||
frame: &crate::dsl::walker::context::ScopeFrame,
|
||
scope_stack: &[crate::dsl::walker::context::ScopeFrame],
|
||
qualifier: &str,
|
||
column: &str,
|
||
) -> Option<crate::dsl::types::Type> {
|
||
let binding = frame.from_scope.iter().find(|b| {
|
||
b.alias
|
||
.as_deref()
|
||
.is_some_and(|a| a.eq_ignore_ascii_case(qualifier))
|
||
|| b.table.eq_ignore_ascii_case(qualifier)
|
||
})?;
|
||
expand_binding(binding, scope_stack)
|
||
.into_iter()
|
||
.find(|c| {
|
||
c.name
|
||
.as_deref()
|
||
.is_some_and(|n| n.eq_ignore_ascii_case(column))
|
||
})
|
||
.and_then(|c| c.type_)
|
||
}
|
||
|
||
/// Resolve a `TableBinding` to its column list as `CteColumn`s.
|
||
///
|
||
/// Base-table bindings carry typed `TableColumn`s populated from
|
||
/// the schema cache — convert them directly. CTE-source bindings
|
||
/// (the binding's `columns` is empty because the FROM name
|
||
/// didn't match a base table) look up the matching `CteBinding`
|
||
/// in any in-scope frame and return its `columns` verbatim.
|
||
///
|
||
/// This is the bridge that lets a nested CTE's outer harvest see
|
||
/// the inner CTE's derived columns: the body's `FROM inner`
|
||
/// produces an empty-columns binding, but `expand_binding`
|
||
/// resolves it through the inner CteBinding (which has its
|
||
/// derived columns by the time the outer harvest runs, because
|
||
/// the inner body's harvest fires on inner-body exit, before the
|
||
/// outer body exits).
|
||
///
|
||
/// A self-reference inside a `WITH RECURSIVE` body sees the
|
||
/// placeholder (empty columns) and the resolution returns empty
|
||
/// — that's correct, since the harvest only fires on the
|
||
/// non-recursive (first) leg per §10.3.
|
||
fn expand_binding(
|
||
binding: &crate::dsl::walker::context::TableBinding,
|
||
scope_stack: &[crate::dsl::walker::context::ScopeFrame],
|
||
) -> Vec<crate::dsl::walker::context::CteColumn> {
|
||
use crate::dsl::walker::context::CteColumn;
|
||
|
||
if !binding.columns.is_empty() {
|
||
return binding
|
||
.columns
|
||
.iter()
|
||
.map(|c| CteColumn {
|
||
name: Some(c.name.clone()),
|
||
type_: Some(c.user_type),
|
||
})
|
||
.collect();
|
||
}
|
||
for frame in scope_stack.iter().rev() {
|
||
if let Some(cte) = frame
|
||
.cte_bindings
|
||
.iter()
|
||
.find(|c| c.name.eq_ignore_ascii_case(&binding.table))
|
||
{
|
||
return cte.columns.clone();
|
||
}
|
||
}
|
||
Vec::new()
|
||
}
|
||
|
||
fn merge_expected(dst: &mut Vec<Expectation>, src: Vec<Expectation>) {
|
||
for e in src {
|
||
if !dst.contains(&e) {
|
||
dst.push(e);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::{
|
||
DYNAMIC_CACHE, FailureKind, MAX_SUBGRAMMAR_DEPTH, NodeWalkResult, resolve_dynamic,
|
||
walk_node,
|
||
};
|
||
use crate::dsl::grammar::{Node, Word};
|
||
use crate::dsl::walker::context::WalkContext;
|
||
use crate::dsl::walker::outcome::MatchedPath;
|
||
|
||
// Recursive test grammar for the `Subgrammar` node
|
||
// (ADR-0026 §2): `x` | `( <self> )`. `NESTED_GROUP` reaches
|
||
// back to `NESTED` through `Subgrammar(&NESTED)` — the cycle
|
||
// a by-value `Seq` slice could not express.
|
||
static NESTED_GROUP: &[Node] = &[
|
||
Node::Punct('('),
|
||
Node::Subgrammar(&NESTED),
|
||
Node::Punct(')'),
|
||
];
|
||
static NESTED_CHOICES: &[Node] = &[Node::Seq(NESTED_GROUP), Node::Word(Word::keyword("x"))];
|
||
static NESTED: Node = Node::Choice(NESTED_CHOICES);
|
||
|
||
fn walk_nested(input: &str) -> NodeWalkResult {
|
||
let mut ctx = WalkContext::new();
|
||
let mut path = MatchedPath::new();
|
||
let mut per_byte = Vec::new();
|
||
let result = walk_node(input, 0, &NESTED, &mut ctx, &mut path, &mut per_byte);
|
||
assert_eq!(
|
||
ctx.subgrammar_depth, 0,
|
||
"subgrammar_depth must be restored to 0 after the walk",
|
||
);
|
||
result
|
||
}
|
||
|
||
#[test]
|
||
fn subgrammar_walks_a_recursive_grammar() {
|
||
for input in ["x", "(x)", "((x))", "(((x)))"] {
|
||
assert!(
|
||
matches!(walk_nested(input), NodeWalkResult::Matched { .. }),
|
||
"{input:?} should match the recursive Subgrammar grammar",
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn subgrammar_depth_cap_allows_exactly_the_limit() {
|
||
let input = format!(
|
||
"{}x{}",
|
||
"(".repeat(MAX_SUBGRAMMAR_DEPTH),
|
||
")".repeat(MAX_SUBGRAMMAR_DEPTH),
|
||
);
|
||
assert!(
|
||
matches!(walk_nested(&input), NodeWalkResult::Matched { .. }),
|
||
"exactly MAX_SUBGRAMMAR_DEPTH nested groups should still walk",
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn subgrammar_depth_cap_rejects_pathological_nesting() {
|
||
let over = MAX_SUBGRAMMAR_DEPTH + 1;
|
||
let input = format!("{}x{}", "(".repeat(over), ")".repeat(over));
|
||
match walk_nested(&input) {
|
||
NodeWalkResult::Failed {
|
||
kind: FailureKind::Validation(err),
|
||
..
|
||
} => assert_eq!(err.message_key, "parse.custom.expression_too_deep"),
|
||
other => {
|
||
panic!("expected an expression_too_deep failure, got {other:?}")
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Trivial factory — ignores the context. The memo behaviour
|
||
/// is keyed on the context, not the factory's output, so a
|
||
/// constant factory is enough to exercise the cache.
|
||
fn const_factory(_ctx: &WalkContext) -> Node {
|
||
Node::Word(Word::keyword("memo_probe"))
|
||
}
|
||
|
||
#[test]
|
||
fn resolve_dynamic_memoizes_identical_context() {
|
||
let ctx = WalkContext::new();
|
||
let first = resolve_dynamic(const_factory, &ctx);
|
||
let second = resolve_dynamic(const_factory, &ctx);
|
||
// Same factory + same context → the leaked Node is
|
||
// reused (pointer identity), so repeated per-keystroke
|
||
// walks against an unchanged schema leak only once.
|
||
assert!(
|
||
std::ptr::eq(first, second),
|
||
"identical context should hit the memo cache",
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn resolve_dynamic_distinct_context_does_not_share() {
|
||
let ctx_a = WalkContext::new();
|
||
let mut ctx_b = WalkContext::new();
|
||
ctx_b.user_listed_columns = Some(vec!["Col".to_string()]);
|
||
let a = resolve_dynamic(const_factory, &ctx_a);
|
||
let b = resolve_dynamic(const_factory, &ctx_b);
|
||
// Different context state → different cache key → a
|
||
// separate entry (and a separate one-time leak).
|
||
assert!(
|
||
!std::ptr::eq(a, b),
|
||
"distinct context must not collide in the memo cache",
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn resolve_dynamic_cache_is_populated() {
|
||
let ctx = WalkContext::new();
|
||
let _ = resolve_dynamic(const_factory, &ctx);
|
||
let populated = !DYNAMIC_CACHE.lock().expect("cache lock").is_empty();
|
||
assert!(populated, "resolve_dynamic should populate the memo cache",);
|
||
}
|
||
|
||
// ---- ScopedSubgrammar (ADR-0032 §10.2) -----------------------
|
||
|
||
// Recursive test grammar parallel to NESTED, but using
|
||
// `ScopedSubgrammar` for the recursion — exercises the
|
||
// push/pop discipline. Same shape (`x` | `( <self> )`),
|
||
// different recursion variant.
|
||
static SCOPED_NESTED_GROUP: &[Node] = &[
|
||
Node::Punct('('),
|
||
Node::ScopedSubgrammar(&SCOPED_NESTED),
|
||
Node::Punct(')'),
|
||
];
|
||
static SCOPED_NESTED_CHOICES: &[Node] = &[
|
||
Node::Seq(SCOPED_NESTED_GROUP),
|
||
Node::Word(Word::keyword("x")),
|
||
];
|
||
static SCOPED_NESTED: Node = Node::Choice(SCOPED_NESTED_CHOICES);
|
||
|
||
fn walk_scoped_nested(input: &str) -> (NodeWalkResult, usize) {
|
||
let mut ctx = WalkContext::new();
|
||
let mut path = MatchedPath::new();
|
||
let mut per_byte = Vec::new();
|
||
let baseline_frames = ctx.from_scope_stack.len();
|
||
let result = walk_node(input, 0, &SCOPED_NESTED, &mut ctx, &mut path, &mut per_byte);
|
||
assert_eq!(
|
||
ctx.subgrammar_depth, 0,
|
||
"subgrammar_depth must be restored to 0 after the walk",
|
||
);
|
||
assert_eq!(
|
||
ctx.from_scope_stack.len(),
|
||
baseline_frames,
|
||
"from_scope_stack must be restored after the walk",
|
||
);
|
||
(result, baseline_frames)
|
||
}
|
||
|
||
#[test]
|
||
fn scoped_subgrammar_walks_a_recursive_grammar() {
|
||
for input in ["x", "(x)", "((x))", "(((x)))"] {
|
||
let (result, _) = walk_scoped_nested(input);
|
||
assert!(
|
||
matches!(result, NodeWalkResult::Matched { .. }),
|
||
"{input:?} should match the recursive ScopedSubgrammar grammar",
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn scoped_subgrammar_shares_depth_cap_with_subgrammar() {
|
||
// Over the cap with ScopedSubgrammar recursion fails the
|
||
// same way as the Subgrammar test above — both variants
|
||
// share `WalkContext::subgrammar_depth`.
|
||
let over = MAX_SUBGRAMMAR_DEPTH + 1;
|
||
let input = format!("{}x{}", "(".repeat(over), ")".repeat(over));
|
||
match walk_scoped_nested(&input).0 {
|
||
NodeWalkResult::Failed {
|
||
kind: FailureKind::Validation(err),
|
||
..
|
||
} => assert_eq!(err.message_key, "parse.custom.expression_too_deep"),
|
||
other => {
|
||
panic!("expected expression_too_deep on pathological scoped nesting, got {other:?}",)
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn scoped_subgrammar_baseline_frame_is_always_present() {
|
||
let ctx = WalkContext::new();
|
||
assert_eq!(
|
||
ctx.from_scope_stack.len(),
|
||
1,
|
||
"WalkContext::new should seed exactly one bottom frame",
|
||
);
|
||
}
|
||
|
||
// ---- from_scope binding population (ADR-0032 §10.1) ----
|
||
|
||
/// Walk a top-level SQL SELECT and return the bottom frame's
|
||
/// `from_scope` after the walk completes. Used to verify that
|
||
/// `writes_table` / `writes_table_alias` populate bindings.
|
||
fn from_scope_after_walk(input: &str) -> Vec<crate::dsl::walker::context::TableBinding> {
|
||
let mut ctx = WalkContext::new();
|
||
let mut path = MatchedPath::new();
|
||
let mut per_byte = Vec::new();
|
||
let result = walk_node(
|
||
input,
|
||
0,
|
||
&crate::dsl::grammar::sql_select::SQL_SELECT_STATEMENT,
|
||
&mut ctx,
|
||
&mut path,
|
||
&mut per_byte,
|
||
);
|
||
assert!(
|
||
matches!(result, NodeWalkResult::Matched { .. }),
|
||
"{input:?} should match: got {result:?}"
|
||
);
|
||
// The bottom frame survives the walk; any ScopedSubgrammar
|
||
// frames have been popped by now.
|
||
ctx.from_scope_stack[0].from_scope.clone()
|
||
}
|
||
|
||
#[test]
|
||
fn single_from_table_pushes_one_binding() {
|
||
let bindings = from_scope_after_walk("select * from users");
|
||
assert_eq!(bindings.len(), 1);
|
||
assert_eq!(bindings[0].table, "users");
|
||
assert_eq!(bindings[0].alias, None);
|
||
}
|
||
|
||
#[test]
|
||
fn as_alias_on_from_table_is_captured() {
|
||
let bindings = from_scope_after_walk("select * from users as u");
|
||
assert_eq!(bindings.len(), 1);
|
||
assert_eq!(bindings[0].table, "users");
|
||
assert_eq!(bindings[0].alias, Some("u".to_string()));
|
||
}
|
||
|
||
#[test]
|
||
fn bare_alias_on_from_table_is_captured() {
|
||
let bindings = from_scope_after_walk("select * from users u");
|
||
assert_eq!(bindings.len(), 1);
|
||
assert_eq!(bindings[0].table, "users");
|
||
assert_eq!(bindings[0].alias, Some("u".to_string()));
|
||
}
|
||
|
||
#[test]
|
||
fn join_pushes_a_second_binding() {
|
||
let bindings = from_scope_after_walk("select * from a join b on x = y");
|
||
assert_eq!(bindings.len(), 2);
|
||
assert_eq!(bindings[0].table, "a");
|
||
assert_eq!(bindings[1].table, "b");
|
||
}
|
||
|
||
#[test]
|
||
fn join_with_aliases() {
|
||
let bindings = from_scope_after_walk("select * from a as x join b as y on x.id = y.id");
|
||
assert_eq!(bindings.len(), 2);
|
||
assert_eq!(bindings[0].table, "a");
|
||
assert_eq!(bindings[0].alias, Some("x".to_string()));
|
||
assert_eq!(bindings[1].table, "b");
|
||
assert_eq!(bindings[1].alias, Some("y".to_string()));
|
||
}
|
||
|
||
#[test]
|
||
fn three_way_join_pushes_three_bindings() {
|
||
let bindings =
|
||
from_scope_after_walk("select * from a join b on x = y left join c on y = z");
|
||
assert_eq!(bindings.len(), 3);
|
||
assert_eq!(bindings[0].table, "a");
|
||
assert_eq!(bindings[1].table, "b");
|
||
assert_eq!(bindings[2].table, "c");
|
||
}
|
||
|
||
#[test]
|
||
fn subquery_bindings_do_not_leak_to_outer_scope() {
|
||
// The inner `(SELECT id FROM inner_t)` pushes its
|
||
// binding into the inner scope frame; on exit, the frame
|
||
// pops and the inner binding is gone. The outer scope's
|
||
// from_scope still contains only `outer_t`.
|
||
let bindings =
|
||
from_scope_after_walk("select * from outer_t where id in (select id from inner_t)");
|
||
assert_eq!(bindings.len(), 1);
|
||
assert_eq!(bindings[0].table, "outer_t");
|
||
}
|
||
|
||
#[test]
|
||
fn cte_body_bindings_do_not_leak_to_outer_scope() {
|
||
// The CTE body's `from base_table` pushes into the CTE
|
||
// body's scope frame; on body-frame exit, the inner
|
||
// binding goes away. The outer scope contains only
|
||
// the CTE-name reference `cte_x`.
|
||
let bindings =
|
||
from_scope_after_walk("with cte_x as (select * from base_table) select * from cte_x");
|
||
assert_eq!(bindings.len(), 1);
|
||
assert_eq!(bindings[0].table, "cte_x");
|
||
}
|
||
|
||
#[test]
|
||
fn from_scope_empty_for_select_without_from() {
|
||
let bindings = from_scope_after_walk("select 1");
|
||
assert!(bindings.is_empty());
|
||
}
|
||
|
||
// ---- cte_bindings & projection_aliases (ADR-0032 §10.3 / §10.4) ----
|
||
|
||
/// Walk a top-level SELECT and return the bottom frame's
|
||
/// `cte_bindings` and `projection_aliases` after the walk.
|
||
fn frame_state_after_walk(
|
||
input: &str,
|
||
) -> (Vec<crate::dsl::walker::context::CteBinding>, Vec<String>) {
|
||
let mut ctx = WalkContext::new();
|
||
let mut path = MatchedPath::new();
|
||
let mut per_byte = Vec::new();
|
||
let result = walk_node(
|
||
input,
|
||
0,
|
||
&crate::dsl::grammar::sql_select::SQL_SELECT_STATEMENT,
|
||
&mut ctx,
|
||
&mut path,
|
||
&mut per_byte,
|
||
);
|
||
assert!(
|
||
matches!(result, NodeWalkResult::Matched { .. }),
|
||
"{input:?} should match: got {result:?}"
|
||
);
|
||
let bottom = &ctx.from_scope_stack[0];
|
||
(
|
||
bottom.cte_bindings.clone(),
|
||
bottom.projection_aliases.clone(),
|
||
)
|
||
}
|
||
|
||
#[test]
|
||
fn cte_name_pushes_placeholder_binding() {
|
||
let (ctes, _) = frame_state_after_walk("with cte_x as (select 1) select * from cte_x");
|
||
assert_eq!(ctes.len(), 1);
|
||
assert_eq!(ctes[0].name, "cte_x");
|
||
// §10.3 stage-2 harvest produces one CteColumn per
|
||
// projection item. `SELECT 1` is a computed expression
|
||
// without an alias → `CteColumn { name: None, type_:
|
||
// None }`.
|
||
assert_eq!(ctes[0].columns.len(), 1);
|
||
assert!(ctes[0].columns[0].name.is_none());
|
||
assert!(ctes[0].columns[0].type_.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn multiple_ctes_push_in_order() {
|
||
let (ctes, _) =
|
||
frame_state_after_walk("with a as (select 1), b as (select 2) select * from b");
|
||
assert_eq!(ctes.len(), 2);
|
||
assert_eq!(ctes[0].name, "a");
|
||
assert_eq!(ctes[1].name, "b");
|
||
}
|
||
|
||
#[test]
|
||
fn recursive_cte_name_visible_in_body() {
|
||
// The CTE name `r` is pushed BEFORE the body's
|
||
// ScopedSubgrammar enters, so the body's `from r`
|
||
// reference is structurally valid (parses).
|
||
let (ctes, _) = frame_state_after_walk(
|
||
"with recursive r as (select 1 union all select 2 from r) select * from r",
|
||
);
|
||
assert_eq!(ctes.len(), 1);
|
||
assert_eq!(ctes[0].name, "r");
|
||
}
|
||
|
||
#[test]
|
||
fn projection_aliases_captured_via_as_form() {
|
||
let (_, aliases) = frame_state_after_walk("select a as alpha, b as beta from t");
|
||
assert_eq!(aliases, vec!["alpha".to_string(), "beta".to_string()]);
|
||
}
|
||
|
||
#[test]
|
||
fn projection_aliases_captured_via_bare_form() {
|
||
let (_, aliases) = frame_state_after_walk("select a alpha, b beta from t");
|
||
assert_eq!(aliases, vec!["alpha".to_string(), "beta".to_string()]);
|
||
}
|
||
|
||
#[test]
|
||
fn projection_aliases_mixed_forms() {
|
||
let (_, aliases) =
|
||
frame_state_after_walk("select a as alpha, b beta, c, d as delta from t");
|
||
assert_eq!(
|
||
aliases,
|
||
vec!["alpha".to_string(), "beta".to_string(), "delta".to_string()]
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn projection_aliases_empty_when_no_aliases() {
|
||
let (_, aliases) = frame_state_after_walk("select a, b from t");
|
||
assert!(aliases.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn cte_body_aliases_do_not_leak_to_outer_scope() {
|
||
// The body's projection_aliases live in the body's
|
||
// scope frame, which pops on exit. The outer frame's
|
||
// projection_aliases only carries the outer SELECT's
|
||
// own aliases.
|
||
let (_, aliases) = frame_state_after_walk(
|
||
"with x as (select a as inner_a from t) select b as outer_b from x",
|
||
);
|
||
assert_eq!(aliases, vec!["outer_b".to_string()]);
|
||
}
|
||
|
||
// ---- §10.3 stage-2 CTE column-derivation harvest ----
|
||
|
||
/// Schema-aware walk variant — returns the outer frame's
|
||
/// `cte_bindings` after walking the input.
|
||
fn cte_bindings_after_walk_with_schema(
|
||
input: &str,
|
||
schema: &crate::completion::SchemaCache,
|
||
) -> Vec<crate::dsl::walker::context::CteBinding> {
|
||
let mut ctx = WalkContext::with_schema(schema);
|
||
ctx.mode = crate::mode::Mode::Advanced;
|
||
let mut path = MatchedPath::new();
|
||
let mut per_byte = Vec::new();
|
||
let result = walk_node(
|
||
input,
|
||
0,
|
||
&crate::dsl::grammar::sql_select::SQL_SELECT_STATEMENT,
|
||
&mut ctx,
|
||
&mut path,
|
||
&mut per_byte,
|
||
);
|
||
assert!(
|
||
matches!(result, NodeWalkResult::Matched { .. }),
|
||
"{input:?} should match: got {result:?}"
|
||
);
|
||
ctx.from_scope_stack[0].cte_bindings.clone()
|
||
}
|
||
|
||
fn schema_users() -> crate::completion::SchemaCache {
|
||
use crate::completion::{SchemaCache, TableColumn};
|
||
use crate::dsl::types::Type;
|
||
let mut s = SchemaCache::default();
|
||
s.tables.push("users".to_string());
|
||
s.columns.push("id".to_string());
|
||
s.columns.push("name".to_string());
|
||
s.columns.push("age".to_string());
|
||
s.table_columns.insert(
|
||
"users".to_string(),
|
||
vec![
|
||
TableColumn {
|
||
name: "id".to_string(),
|
||
user_type: Type::Int,
|
||
not_null: false,
|
||
has_default: false,
|
||
},
|
||
TableColumn {
|
||
name: "name".to_string(),
|
||
user_type: Type::Text,
|
||
not_null: false,
|
||
has_default: false,
|
||
},
|
||
TableColumn {
|
||
name: "age".to_string(),
|
||
user_type: Type::Int,
|
||
not_null: false,
|
||
has_default: false,
|
||
},
|
||
],
|
||
);
|
||
s
|
||
}
|
||
|
||
#[test]
|
||
fn cte_harvest_star_expands_from_scope() {
|
||
// Rule 1: `SELECT *` body — derived columns = every
|
||
// column from the body frame's from_scope, with types.
|
||
let schema = schema_users();
|
||
let ctes = cte_bindings_after_walk_with_schema(
|
||
"with x as (select * from users) select * from x",
|
||
&schema,
|
||
);
|
||
assert_eq!(ctes.len(), 1);
|
||
assert_eq!(ctes[0].columns.len(), 3);
|
||
assert_eq!(ctes[0].columns[0].name.as_deref(), Some("id"));
|
||
assert_eq!(ctes[0].columns[0].type_, Some(crate::dsl::types::Type::Int),);
|
||
assert_eq!(ctes[0].columns[1].name.as_deref(), Some("name"));
|
||
assert_eq!(
|
||
ctes[0].columns[1].type_,
|
||
Some(crate::dsl::types::Type::Text),
|
||
);
|
||
assert_eq!(ctes[0].columns[2].name.as_deref(), Some("age"));
|
||
}
|
||
|
||
#[test]
|
||
fn cte_harvest_qualified_star_expands_one_binding() {
|
||
// Rule 2: `t.*` — every column from binding `t`.
|
||
let schema = schema_users();
|
||
let ctes = cte_bindings_after_walk_with_schema(
|
||
"with x as (select u.* from users u) select * from x",
|
||
&schema,
|
||
);
|
||
assert_eq!(ctes.len(), 1);
|
||
assert_eq!(ctes[0].columns.len(), 3);
|
||
assert_eq!(ctes[0].columns[0].name.as_deref(), Some("id"));
|
||
}
|
||
|
||
#[test]
|
||
fn cte_harvest_bare_ref_with_alias() {
|
||
// Rule 5 variant: `col AS alias` — name = alias, type
|
||
// preserved from the source column.
|
||
let schema = schema_users();
|
||
let ctes = cte_bindings_after_walk_with_schema(
|
||
"with x as (select name as label from users) select * from x",
|
||
&schema,
|
||
);
|
||
assert_eq!(ctes[0].columns.len(), 1);
|
||
assert_eq!(ctes[0].columns[0].name.as_deref(), Some("label"));
|
||
assert_eq!(
|
||
ctes[0].columns[0].type_,
|
||
Some(crate::dsl::types::Type::Text),
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn cte_harvest_bare_ref_without_alias_uses_column_name() {
|
||
// Rule 3: bare `col` — name = column name, type from
|
||
// source column.
|
||
let schema = schema_users();
|
||
let ctes = cte_bindings_after_walk_with_schema(
|
||
"with x as (select age from users) select * from x",
|
||
&schema,
|
||
);
|
||
assert_eq!(ctes[0].columns.len(), 1);
|
||
assert_eq!(ctes[0].columns[0].name.as_deref(), Some("age"));
|
||
assert_eq!(ctes[0].columns[0].type_, Some(crate::dsl::types::Type::Int),);
|
||
}
|
||
|
||
#[test]
|
||
fn cte_harvest_qualified_ref() {
|
||
// Rule 4: `t.col` — name = column, type from binding.
|
||
let schema = schema_users();
|
||
let ctes = cte_bindings_after_walk_with_schema(
|
||
"with x as (select u.name from users u) select * from x",
|
||
&schema,
|
||
);
|
||
assert_eq!(ctes[0].columns.len(), 1);
|
||
assert_eq!(ctes[0].columns[0].name.as_deref(), Some("name"));
|
||
assert_eq!(
|
||
ctes[0].columns[0].type_,
|
||
Some(crate::dsl::types::Type::Text),
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn cte_harvest_computed_no_alias_is_unnamed() {
|
||
// Rule 6: computed expression without alias → name =
|
||
// None, type = None.
|
||
let schema = schema_users();
|
||
let ctes = cte_bindings_after_walk_with_schema(
|
||
"with x as (select age + 1 from users) select * from x",
|
||
&schema,
|
||
);
|
||
assert_eq!(ctes[0].columns.len(), 1);
|
||
assert!(ctes[0].columns[0].name.is_none());
|
||
assert!(ctes[0].columns[0].type_.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn cte_harvest_computed_with_alias() {
|
||
// Rule 5: computed expression with alias → name =
|
||
// alias, type = None (Amendment 1).
|
||
let schema = schema_users();
|
||
let ctes = cte_bindings_after_walk_with_schema(
|
||
"with x as (select age + 1 as years from users) select * from x",
|
||
&schema,
|
||
);
|
||
assert_eq!(ctes[0].columns.len(), 1);
|
||
assert_eq!(ctes[0].columns[0].name.as_deref(), Some("years"));
|
||
assert!(ctes[0].columns[0].type_.is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn cte_harvest_compound_takes_first_leg() {
|
||
// For UNION / INTERSECT / EXCEPT bodies, columns come
|
||
// from the first leg per ADR-0032 §10.3.
|
||
let schema = schema_users();
|
||
let ctes = cte_bindings_after_walk_with_schema(
|
||
"with x as (select id from users union select age from users) select * from x",
|
||
&schema,
|
||
);
|
||
// First leg: `select id from users` → one column `id`,
|
||
// type Int. Second leg ignored.
|
||
assert_eq!(ctes[0].columns.len(), 1);
|
||
assert_eq!(ctes[0].columns[0].name.as_deref(), Some("id"));
|
||
}
|
||
|
||
#[test]
|
||
fn cte_harvest_recursive_uses_non_recursive_leg() {
|
||
// WITH RECURSIVE — the first (non-recursive) leg
|
||
// dictates columns. The recursive leg self-references
|
||
// the CTE name; we don't try to introspect.
|
||
let schema = schema_users();
|
||
let ctes = cte_bindings_after_walk_with_schema(
|
||
"with recursive r as (select id from users union all select id from r) select * from r",
|
||
&schema,
|
||
);
|
||
assert_eq!(ctes[0].columns.len(), 1);
|
||
assert_eq!(ctes[0].columns[0].name.as_deref(), Some("id"));
|
||
}
|
||
|
||
#[test]
|
||
fn cte_harvest_nested_with_in_cte_body() {
|
||
// Nested WITH inside a CTE body now parses (ADR-0032
|
||
// §10.3 — inner subqueries may declare their own CTEs).
|
||
// The outer CTE's body has its own scope and its own
|
||
// CTE inside it. The outer's `*` projects from its
|
||
// body's FROM, which references the inner CTE; the
|
||
// inner CTE's columns flow through `expand_binding`.
|
||
let schema = schema_users();
|
||
let ctes = cte_bindings_after_walk_with_schema(
|
||
"with outer_cte as (with inner_cte as (select id, name from users) select * from inner_cte) select * from outer_cte",
|
||
&schema,
|
||
);
|
||
let outer = ctes
|
||
.iter()
|
||
.find(|c| c.name == "outer_cte")
|
||
.expect("outer_cte binding");
|
||
assert_eq!(outer.columns.len(), 2);
|
||
assert_eq!(outer.columns[0].name.as_deref(), Some("id"));
|
||
assert_eq!(outer.columns[0].type_, Some(crate::dsl::types::Type::Int),);
|
||
assert_eq!(outer.columns[1].name.as_deref(), Some("name"));
|
||
assert_eq!(outer.columns[1].type_, Some(crate::dsl::types::Type::Text),);
|
||
}
|
||
|
||
#[test]
|
||
fn cte_harvest_sibling_b_sees_a_columns() {
|
||
// Sibling CTEs at the same level. When `b`'s body
|
||
// walks, the outer scope's cte_bindings already
|
||
// contains `a` (with harvested columns) and `b`'s
|
||
// placeholder. `b`'s `FROM a` produces an empty-columns
|
||
// TableBinding which `expand_binding` resolves through
|
||
// the in-scope `a` CteBinding. So `*` in `b`'s body
|
||
// expands to `a`'s columns.
|
||
let schema = schema_users();
|
||
let ctes = cte_bindings_after_walk_with_schema(
|
||
"with a as (select id, name from users), b as (select * from a) select * from b",
|
||
&schema,
|
||
);
|
||
let b = ctes.iter().find(|c| c.name == "b").expect("b binding");
|
||
assert_eq!(b.columns.len(), 2);
|
||
assert_eq!(b.columns[0].name.as_deref(), Some("id"));
|
||
assert_eq!(b.columns[0].type_, Some(crate::dsl::types::Type::Int),);
|
||
assert_eq!(b.columns[1].name.as_deref(), Some("name"));
|
||
assert_eq!(b.columns[1].type_, Some(crate::dsl::types::Type::Text),);
|
||
}
|
||
|
||
#[test]
|
||
fn cte_harvest_col_list_renames_positionally() {
|
||
// `WITH x(a, b, c) AS (SELECT * FROM users)` —
|
||
// positional rename overrides derived names; types
|
||
// preserved.
|
||
let schema = schema_users();
|
||
let ctes = cte_bindings_after_walk_with_schema(
|
||
"with x (a, b, c) as (select * from users) select * from x",
|
||
&schema,
|
||
);
|
||
assert_eq!(ctes[0].columns.len(), 3);
|
||
assert_eq!(ctes[0].columns[0].name.as_deref(), Some("a"));
|
||
assert_eq!(ctes[0].columns[0].type_, Some(crate::dsl::types::Type::Int),);
|
||
assert_eq!(ctes[0].columns[1].name.as_deref(), Some("b"));
|
||
assert_eq!(
|
||
ctes[0].columns[1].type_,
|
||
Some(crate::dsl::types::Type::Text),
|
||
);
|
||
assert_eq!(ctes[0].columns[2].name.as_deref(), Some("c"));
|
||
}
|
||
}
|