Files
rdbms-playground/src/dsl/walker/driver.rs
T
claude@clouddev1 208da81108 feat(hint): clause-concept hints layered on F1 (issue #37)
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).
2026-06-23 13:24:54 +00:00

2292 lines
84 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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"));
}
}