//! 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 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, }; #[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, } => walk_ident( source, pos, *src, role, *validator, *writes_table, *writes_column, *writes_user_listed_column, 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::DynamicSubgrammar(factory) => { // ADR-0024 §sub-grammars: resolve the inner Node at // walk time using the active `WalkContext`, then // recursively walk it. `Box::leak` per-walk gives the // inner static-slice fields (Choice/Seq) the lifetime // they require; the leak is bounded by command-shape // complexity per walk. let resolved: &'static Node = Box::leak(Box::new(factory(ctx))); walk_node(source, pos, resolved, ctx, path, per_byte) } 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::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, class: 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 { 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, writes_table: bool, writes_column: bool, writes_user_listed_column: 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: schema-aware writes. When the ident is // a Tables source with `writes_table`, resolve the matched // name against the schema cache and populate current_table / // current_table_columns so subsequent dynamic sub-grammars // can read them. `writes_column` resolves against the // already-populated `current_table_columns`. if writes_table && matches!(src, crate::dsl::grammar::IdentSource::Tables) { ctx.current_table = Some(text.clone()); ctx.current_table_columns = ctx .schema .and_then(|s| s.columns_for_table(&text).map(<[_]>::to_vec)); } 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 }, text, span: (start, end), }); per_byte.push(ByteClass { start, end, class: 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; 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); break; } other => return other, } } else { walk_node(source, cur, inner, ctx, path, per_byte) }; match result { NodeWalkResult::Matched { end, .. } => { cur = end; count += 1; } 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 with another inner" expectations // become this Repeated's `skipped` set so the caller's // expected-set surfaces them at completion time. NodeWalkResult::Matched { end: cur, skipped: last_expected.unwrap_or_default(), } } 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 { 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, } => { 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, } } 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. 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. 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 } } } fn merge_expected(dst: &mut Vec, src: Vec) { for e in src { if !dst.contains(&e) { dst.push(e); } } }