Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 24 additions & 6 deletions crates/plotnik-lib/src/analyze/type_check/infer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
36 changes: 36 additions & 0 deletions crates/plotnik-lib/src/analyze/type_check/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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#"
Expand Down
33 changes: 25 additions & 8 deletions crates/plotnik-lib/src/compile/expressions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -106,18 +108,18 @@ 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,
node_type,
node_field: None,
pre_effects: capture.pre,
neg_fields,
post_effects: capture.post,
post_effects: vec![],
successors: vec![items_entry],
}));

Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -193,21 +197,34 @@ 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,
node_type,
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<EffectIR>) -> 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,
Expand Down
10 changes: 8 additions & 2 deletions crates/plotnik-lib/src/emit/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -458,7 +458,13 @@ impl TypeTableBuilder {
}

/// Resolve a query TypeId to bytecode QTypeId.
fn resolve_type(&self, type_id: TypeId, type_ctx: &TypeContext) -> Result<QTypeId, EmitError> {
///
/// Handles Ref types by following the reference chain to the actual type.
pub fn resolve_type(
&self,
type_id: TypeId,
type_ctx: &TypeContext,
) -> Result<QTypeId, EmitError> {
// Check if already mapped
if let Some(&bc_id) = self.mapping.get(&type_id) {
return Ok(bc_id);
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ▶
Original file line number Diff line number Diff line change
Expand Up @@ -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 ▶
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ _ObjWrap:

Expr:
06 ε 07
07 ε 20, 26
07 ε 20, 28

Test:
09 ε 10
Expand All @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down
Loading