//! 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>, current_column: Option, user_listed_columns: Option>, } static DYNAMIC_CACHE: LazyLock>> = 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, }, /// 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, }, /// Committed and ran out of input. Incomplete { position: usize, expected: Vec, }, /// 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 }, Validation(ValidationError), } pub fn walk_node( source: &str, position: usize, node: &Node, ctx: &mut WalkContext, path: &mut MatchedPath, per_byte: &mut Vec, ) -> 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, ) -> 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, ) -> 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, ) -> 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, highlight_override: Option, 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, ) -> 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 = 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 set =` / `where =` // 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 (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, ) -> 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, ) -> 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, path: &mut MatchedPath, per_byte: &mut Vec, ) -> 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, ) -> 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 `--`. 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, ) -> NodeWalkResult { let mut cur = position; let mut count = 0_usize; let mut last_expected: Option> = 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 = 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 = 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, ) -> 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, ) -> NodeWalkResult { let mut all_expected: Vec = 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, ) -> 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 = 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, ) -> 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, ) -> 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, ) -> 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 = 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 = 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, ) { 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] ` 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, ) { 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 { 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 { 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 { 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, src: Vec) { 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` | `( )`. `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` | `( )`), // 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 { 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, Vec) { 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 { 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")); } }