From 17172fdcfd54923798b1df09f66304af2f0e7eac Mon Sep 17 00:00:00 2001 From: Sergei Zharinov Date: Mon, 5 Jan 2026 23:19:59 -0300 Subject: [PATCH] fix: type verification panic for uncaptured recursive tagged alternations --- .../src/analyze/type_check/infer.rs | 30 ++++++++-- .../src/analyze/type_check/tests.rs | 36 +++++++++++ crates/plotnik-lib/src/compile/expressions.rs | 33 +++++++--- crates/plotnik-lib/src/emit/mod.rs | 10 +++- ...codegen_tests__captures_deeply_nested.snap | 17 +++--- ...__codegen_tests__captures_nested_flat.snap | 12 ++-- ...emit__codegen_tests__recursion_simple.snap | 11 ++-- ...sts__recursion_with_structured_result.snap | 11 ++-- ...engine_tests__quantifier_struct_array.snap | 24 ++++---- ..._engine_tests__recursion_member_chain.snap | 60 +++++++++++-------- ...on_call_searches_for_field_constraint.snap | 52 ++++++++-------- 11 files changed, 196 insertions(+), 100 deletions(-) diff --git a/crates/plotnik-lib/src/analyze/type_check/infer.rs b/crates/plotnik-lib/src/analyze/type_check/infer.rs index 0b159d79..ab216f4d 100644 --- a/crates/plotnik-lib/src/analyze/type_check/infer.rs +++ b/crates/plotnik-lib/src/analyze/type_check/infer.rs @@ -135,22 +135,40 @@ impl<'a, 'd> InferenceVisitor<'a, 'd> { let name = name_tok.text(); let name_sym = self.interner.intern(name); - // Recursive refs are opaque boundaries - they match but don't bubble captures. - // The Ref type is created when a recursive ref is captured (in infer_captured_expr). + let Some(body) = self.symbol_table.get(name) else { + return TermInfo::void(); + }; + + // Recursive refs are opaque boundaries - they don't bubble captures. + // For tagged alternations, return Scalar(Ref) since they always produce Enum output. + // For other definitions, return Void to avoid type errors in untagged alternation contexts. if let Some(def_id) = self.ctx.get_def_id_sym(name_sym) && self.ctx.is_recursive(def_id) { + if self.body_produces_enum(body) { + let ref_type = self.ctx.intern_type(TypeShape::Ref(def_id)); + return TermInfo::new(Arity::One, TypeFlow::Scalar(ref_type)); + } return TermInfo::new(Arity::One, TypeFlow::Void); } - let Some(body) = self.symbol_table.get(name) else { - return TermInfo::void(); - }; - // Non-recursive refs are transparent self.infer_expr(body) } + /// Check if an expression body will produce an Enum type (Scalar flow). + /// + /// This is a syntactic check for tagged alternations at the root of a definition. + /// Tagged alternations always produce Enum types, making them safe to reference + /// as Scalar(Ref) in uncaptured contexts. + fn body_produces_enum(&self, body: &Expr) -> bool { + if let Expr::AltExpr(alt) = body { + matches!(alt.kind(), AltKind::Tagged | AltKind::Mixed) + } else { + false + } + } + /// Sequence: Arity aggregation, strict field merging, and output propagation. fn infer_seq_expr(&mut self, seq: &SeqExpr) -> TermInfo { let children: Vec<_> = seq.children().collect(); diff --git a/crates/plotnik-lib/src/analyze/type_check/tests.rs b/crates/plotnik-lib/src/analyze/type_check/tests.rs index 16d9e2e4..96fe4094 100644 --- a/crates/plotnik-lib/src/analyze/type_check/tests.rs +++ b/crates/plotnik-lib/src/analyze/type_check/tests.rs @@ -734,6 +734,42 @@ fn recursive_type_in_quantified_context() { "); } +#[test] +fn recursive_type_uncaptured_propagates() { + // Regression test: Q = (Rec) should inherit Rec's enum type, not infer as void. + // The recursive definition Rec is a tagged alternation, so its type propagates + // through the uncaptured reference. + let input = indoc! {r#" + Rec = [A: (program (expression_statement (Rec) @inner)) B: (identifier) @id] + Q = (Rec) + "#}; + + let res = Query::expect_valid_types(input); + + // Q should have type Rec (aliased to the enum) + insta::assert_snapshot!(res, @r#" + export interface Node { + kind: string; + text: string; + span: [number, number]; + } + + export interface RecA { + $tag: "A"; + $data: { inner: Rec }; + } + + export interface RecB { + $tag: "B"; + $data: { id: Node }; + } + + export type Rec = RecA | RecB; + + export type Q = Rec; + "#); +} + #[test] fn scalar_propagates_through_named_node() { let input = indoc! {r#" diff --git a/crates/plotnik-lib/src/compile/expressions.rs b/crates/plotnik-lib/src/compile/expressions.rs index 97fba557..d9c4a21f 100644 --- a/crates/plotnik-lib/src/compile/expressions.rs +++ b/crates/plotnik-lib/src/compile/expressions.rs @@ -85,8 +85,10 @@ impl Compiler<'_> { .and_then(|i| i.as_expr()) .is_some_and(is_skippable_quantifier); - // With items: nav → items → Up → exit + // With items: nav → items → Up → [post_effects] → exit // If first item is skippable: skip path → exit (bypass Up), match path → Up → exit + let final_exit = self.emit_post_effects_exit(exit, capture.post); + let up_label = self.fresh_label(); let skip_exit = first_is_skippable.then_some(exit); let items_entry = self.compile_seq_items_inner( @@ -106,10 +108,10 @@ impl Compiler<'_> { pre_effects: vec![], neg_fields: vec![], post_effects: vec![], - successors: vec![exit], + successors: vec![final_exit], })); - // Emit entry instruction into the node (capture effects go here at match time) + // Emit entry instruction into the node (only pre_effects here) self.instructions.push(Instruction::Match(MatchIR { label: entry, nav, @@ -117,7 +119,7 @@ impl Compiler<'_> { node_field: None, pre_effects: capture.pre, neg_fields, - post_effects: capture.post, + post_effects: vec![], successors: vec![items_entry], })); @@ -151,7 +153,9 @@ impl Compiler<'_> { up_nav: Nav, capture: CaptureEffects, ) -> Label { - // up_check: Match(up_nav) → exit + let final_exit = self.emit_post_effects_exit(exit, capture.post); + + // up_check: Match(up_nav) → final_exit let up_check = self.fresh_label(); self.instructions.push(Instruction::Match(MatchIR { label: up_check, @@ -161,7 +165,7 @@ impl Compiler<'_> { pre_effects: vec![], neg_fields: vec![], post_effects: vec![], - successors: vec![exit], + successors: vec![final_exit], })); // body: items with StayExact navigation → up_check @@ -193,7 +197,7 @@ impl Compiler<'_> { let down_wildcard = self.fresh_label(); self.emit_wildcard_nav(down_wildcard, Nav::Down, try_label); - // entry: match parent node → down_wildcard + // entry: match parent node → down_wildcard (only pre_effects here) self.instructions.push(Instruction::Match(MatchIR { label: entry, nav, @@ -201,13 +205,26 @@ impl Compiler<'_> { node_field: None, pre_effects: capture.pre, neg_fields, - post_effects: capture.post, + post_effects: vec![], successors: vec![down_wildcard], })); entry } + /// Emit post-effects on an epsilon step after the exit label. + /// + /// Post-effects (like EndEnum) must execute AFTER children complete, not after + /// matching the parent node. This helper creates an epsilon step for the effects + /// when needed, or returns the original exit if no effects. + fn emit_post_effects_exit(&mut self, exit: Label, post: Vec) -> Label { + if post.is_empty() { + exit + } else { + self.emit_effects_epsilon(exit, post, CaptureEffects::default()) + } + } + /// Compile an anonymous node with capture effects. pub(super) fn compile_anonymous_node_inner( &mut self, diff --git a/crates/plotnik-lib/src/emit/mod.rs b/crates/plotnik-lib/src/emit/mod.rs index f0930881..36daf192 100644 --- a/crates/plotnik-lib/src/emit/mod.rs +++ b/crates/plotnik-lib/src/emit/mod.rs @@ -458,7 +458,13 @@ impl TypeTableBuilder { } /// Resolve a query TypeId to bytecode QTypeId. - fn resolve_type(&self, type_id: TypeId, type_ctx: &TypeContext) -> Result { + /// + /// Handles Ref types by following the reference chain to the actual type. + pub fn resolve_type( + &self, + type_id: TypeId, + type_ctx: &TypeContext, + ) -> Result { // Check if already mapped if let Some(&bc_id) = self.mapping.get(&type_id) { return Ok(bc_id); @@ -804,7 +810,7 @@ fn emit_inner( for (def_id, type_id) in type_ctx.iter_def_types() { let name_sym = type_ctx.def_name_sym(def_id); let name = strings.get_or_intern(name_sym, interner)?; - let result_type = types.get(type_id).expect("all def types should be mapped"); + let result_type = types.resolve_type(type_id, type_ctx)?; // Get actual target from compiled result let target = compile_result diff --git a/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__codegen_tests__captures_deeply_nested.snap b/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__codegen_tests__captures_deeply_nested.snap index e56b5c7b..202ed61c 100644 --- a/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__codegen_tests__captures_deeply_nested.snap +++ b/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__codegen_tests__captures_deeply_nested.snap @@ -48,11 +48,14 @@ _ObjWrap: Test: 06 ε 07 - 07 ! (a) [Node Set(M0)] 09 - 09 ▽ (b) [Node Set(M1)] 11 - 11 ▽ (c) [Node Set(M2)] 13 - 13 ▽ (d) [Node Set(M3)] 15 + 07 ! (a) 08 + 08 ▽ (b) 09 + 09 ▽ (c) 10 + 10 ▽ (d) [Node Set(M3)] 12 + 12 △ 13 + 13 ε [Node Set(M2)] 15 15 △ 16 - 16 △ 17 - 17 △ 18 - 18 ▶ + 16 ε [Node Set(M1)] 18 + 18 △ 19 + 19 ε [Node Set(M0)] 21 + 21 ▶ diff --git a/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__codegen_tests__captures_nested_flat.snap b/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__codegen_tests__captures_nested_flat.snap index 8dc88f94..43a7714b 100644 --- a/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__codegen_tests__captures_nested_flat.snap +++ b/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__codegen_tests__captures_nested_flat.snap @@ -42,9 +42,11 @@ _ObjWrap: Test: 06 ε 07 - 07 ! (a) [Node Set(M0)] 09 - 09 ▽ (b) [Node Set(M1)] 11 - 11 ▽ (c) [Node Set(M2)] 13 - 13 △ 14 + 07 ! (a) 08 + 08 ▽ (b) 09 + 09 ▽ (c) [Node Set(M2)] 11 + 11 △ 12 + 12 ε [Node Set(M1)] 14 14 △ 15 - 15 ▶ + 15 ε [Node Set(M0)] 17 + 17 ▶ diff --git a/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__codegen_tests__recursion_simple.snap b/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__codegen_tests__recursion_simple.snap index b18c7772..b7c9a7b8 100644 --- a/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__codegen_tests__recursion_simple.snap +++ b/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__codegen_tests__recursion_simple.snap @@ -56,11 +56,12 @@ _ObjWrap: Expr: 06 ε 07 - 07 ε 13, 19 + 07 ε 13, 21 09 ε [Set(M2)] 11 - 11 △ 12 + 11 △ 16 12 ▶ 13 ! [Enum(M3)] (number) [Text Set(M0) EndEnum] 12 - 16 ▷ arguments: (Expr) 06 : 09 - 17 ▽ function: (identifier) [Node Set(M1)] 16 - 19 ! [Enum(M4)] (call_expression) [EndEnum] 17 + 16 ε [EndEnum] 12 + 18 ▷ arguments: (Expr) 06 : 09 + 19 ▽ function: (identifier) [Node Set(M1)] 18 + 21 ! [Enum(M4)] (call_expression) 19 diff --git a/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__codegen_tests__recursion_with_structured_result.snap b/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__codegen_tests__recursion_with_structured_result.snap index 27296d13..b89c7614 100644 --- a/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__codegen_tests__recursion_with_structured_result.snap +++ b/crates/plotnik-lib/src/emit/snapshots/plotnik_lib__emit__codegen_tests__recursion_with_structured_result.snap @@ -65,7 +65,7 @@ _ObjWrap: Expr: 06 ε 07 - 07 ε 20, 26 + 07 ε 20, 28 Test: 09 ε 10 @@ -75,9 +75,10 @@ Test: 14 △ 15 15 ▶ 16 ε [Set(M2)] 18 - 18 △ 19 + 18 △ 23 19 ▶ 20 ! [Enum(M3)] (number) [Text Set(M0) EndEnum] 19 - 23 ▷ arguments: (Expr) 06 : 16 - 24 ▽ function: (identifier) [Node Set(M1)] 23 - 26 ! [Enum(M4)] (call_expression) [EndEnum] 24 + 23 ε [EndEnum] 19 + 25 ▷ arguments: (Expr) 06 : 16 + 26 ▽ function: (identifier) [Node Set(M1)] 25 + 28 ! [Enum(M4)] (call_expression) 26 diff --git a/crates/plotnik-lib/src/engine/snapshots/plotnik_lib__engine__engine_tests__quantifier_struct_array.snap b/crates/plotnik-lib/src/engine/snapshots/plotnik_lib__engine__engine_tests__quantifier_struct_array.snap index 37609f1c..b0e58ebd 100644 --- a/crates/plotnik-lib/src/engine/snapshots/plotnik_lib__engine__engine_tests__quantifier_struct_array.snap +++ b/crates/plotnik-lib/src/engine/snapshots/plotnik_lib__engine__engine_tests__quantifier_struct_array.snap @@ -8,16 +8,16 @@ let a, b, c { "decls": [ { - "decl": { - "kind": "variable_declarator", + "name": { + "kind": "identifier", "text": "a", "span": [ 4, 5 ] }, - "name": { - "kind": "identifier", + "decl": { + "kind": "variable_declarator", "text": "a", "span": [ 4, @@ -26,16 +26,16 @@ let a, b, c } }, { - "decl": { - "kind": "variable_declarator", + "name": { + "kind": "identifier", "text": "b", "span": [ 7, 8 ] }, - "name": { - "kind": "identifier", + "decl": { + "kind": "variable_declarator", "text": "b", "span": [ 7, @@ -44,16 +44,16 @@ let a, b, c } }, { - "decl": { - "kind": "variable_declarator", + "name": { + "kind": "identifier", "text": "c", "span": [ 10, 11 ] }, - "name": { - "kind": "identifier", + "decl": { + "kind": "variable_declarator", "text": "c", "span": [ 10, diff --git a/crates/plotnik-lib/src/engine/snapshots/plotnik_lib__engine__engine_tests__recursion_member_chain.snap b/crates/plotnik-lib/src/engine/snapshots/plotnik_lib__engine__engine_tests__recursion_member_chain.snap index 1a7d06e2..eb0ff868 100644 --- a/crates/plotnik-lib/src/engine/snapshots/plotnik_lib__engine__engine_tests__recursion_member_chain.snap +++ b/crates/plotnik-lib/src/engine/snapshots/plotnik_lib__engine__engine_tests__recursion_member_chain.snap @@ -7,35 +7,43 @@ Q = (program (expression_statement (Chain) @chain)) a.b.c --- { - "base": { - "$tag": "Base", + "chain": { + "$tag": "Access", "$data": { - "name": { - "kind": "identifier", - "text": "a", + "base": { + "$tag": "Access", + "$data": { + "base": { + "$tag": "Base", + "$data": { + "name": { + "kind": "identifier", + "text": "a", + "span": [ + 0, + 1 + ] + } + } + }, + "prop": { + "kind": "property_identifier", + "text": "b", + "span": [ + 2, + 3 + ] + } + } + }, + "prop": { + "kind": "property_identifier", + "text": "c", "span": [ - 0, - 1 + 4, + 5 ] } } - }, - "prop": { - "kind": "property_identifier", - "text": "b", - "span": [ - 2, - 3 - ] - }, - "base": null, - "prop": { - "kind": "property_identifier", - "text": "c", - "span": [ - 4, - 5 - ] - }, - "chain": null + } } diff --git a/crates/plotnik-lib/src/engine/snapshots/plotnik_lib__engine__engine_tests__regression_call_searches_for_field_constraint.snap b/crates/plotnik-lib/src/engine/snapshots/plotnik_lib__engine__engine_tests__regression_call_searches_for_field_constraint.snap index 8f28bd42..88cd4d3a 100644 --- a/crates/plotnik-lib/src/engine/snapshots/plotnik_lib__engine__engine_tests__regression_call_searches_for_field_constraint.snap +++ b/crates/plotnik-lib/src/engine/snapshots/plotnik_lib__engine__engine_tests__regression_call_searches_for_field_constraint.snap @@ -7,31 +7,35 @@ Q = (program (expression_statement (Expr) @expr)) 1 + 2 --- { - "left": { - "$tag": "Lit", + "expr": { + "$tag": "Binary", "$data": { - "val": { - "kind": "number", - "text": "1", - "span": [ - 0, - 1 - ] + "left": { + "$tag": "Lit", + "$data": { + "val": { + "kind": "number", + "text": "1", + "span": [ + 0, + 1 + ] + } + } + }, + "right": { + "$tag": "Lit", + "$data": { + "val": { + "kind": "number", + "text": "2", + "span": [ + 4, + 5 + ] + } + } } } - }, - "right": { - "$tag": "Lit", - "$data": { - "val": { - "kind": "number", - "text": "2", - "span": [ - 4, - 5 - ] - } - } - }, - "expr": null + } }