walker: Node::ScopedSubgrammar variant + scope-frame stack (ADR-0032 §10.2)

Sub-phase 2b checkpoint 1 — adds the foundation for SQL SELECT
lexical-scope discipline without changing existing walker
semantics.

New types in `dsl::walker::context`:

- `TableBinding` — one FROM-source binding with table name,
  optional alias, and schema-resolved columns (§10.1).
- `CteBinding` + `CteColumn` — a CTE definition visible from
  inside its body (WITH RECURSIVE self-reference) and from the
  outer scope after harvest (§10.3).
- `ScopeFrame` — `from_scope`, `cte_bindings`, and
  `projection_aliases` for one lexical scope. Default-empty;
  the fields will be populated by later 2b checkpoints.

`WalkContext` gains `from_scope_stack: Vec<ScopeFrame>`,
initialised with one bottom frame in both `new()` and
`with_schema()`. The bottom frame is the implicit top-level
scope DSL paths and top-level SQL statements operate in;
`Node::ScopedSubgrammar` entries push and pop additional frames
on top. `current_table` / `current_table_columns` remain as
direct fields for this checkpoint — converting them to derived
helpers is a later 2b step.

New grammar-tree variant:

- `Node::ScopedSubgrammar(&'static Self)` — like `Subgrammar`,
  but pushes a fresh `ScopeFrame` on entry and pops it on exit
  (ADR-0032 §10.2). Shares `subgrammar_depth` with the plain
  Subgrammar variant so the MAX_SUBGRAMMAR_DEPTH = 64 cap fires
  uniformly across both — §9's "no new walker capability for
  grammar recursion" claim holds. DSL Expr (ADR-0026) and
  sql_expr.rs ladder (ADR-0031) recursion continue to use the
  plain Subgrammar variant and never push a scope.

Driver gains a parallel `walk_scoped_subgrammar` arm; the
push/pop is unconditional so a speculatively-walked branch a
later Choice rolls back leaves the stack clean.

Test coverage in `driver.rs`:

- A recursive ScopedSubgrammar test grammar walks correctly
  through depths 0-3.
- The depth cap fires the same `expression_too_deep` friendly
  validation error as for plain Subgrammar.
- The bottom frame invariant: `WalkContext::new` seeds exactly
  one frame, and after a walk the stack is restored.

No grammar tree references the new variant yet — the rewire of
sql_select.rs CTE bodies and the sql_expr.rs additive
extensions for §5/§6 are the next 2b checkpoint. Test totals:
1330 baseline + 3 = 1333 passing, 0 failed, 1 ignored. Clippy
clean.
This commit is contained in:
claude@clouddev1
2026-05-20 11:34:53 +00:00
parent 8d293358a0
commit 4f89106a63
3 changed files with 236 additions and 4 deletions
+120
View File
@@ -214,6 +214,9 @@ fn walk_node_inner(
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
@@ -941,6 +944,42 @@ fn walk_subgrammar(
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(),
}),
};
}
ctx.from_scope_stack
.push(crate::dsl::walker::context::ScopeFrame::default());
let result = walk_node(source, pos, inner, ctx, path, per_byte);
ctx.from_scope_stack.pop();
ctx.subgrammar_depth = saved_depth;
result
}
fn merge_expected(dst: &mut Vec<Expectation>, src: Vec<Expectation>) {
for e in src {
if !dst.contains(&e) {
@@ -1074,4 +1113,85 @@ mod tests {
"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",
);
}
}