Skip to content

Commit c0c1919

Browse files
committed
fix: bubble captures from named nodes without creating scope
1 parent 19022f5 commit c0c1919

2 files changed

Lines changed: 106 additions & 9 deletions

File tree

crates/plotnik-lib/src/query/type_check/infer.rs

Lines changed: 77 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,12 @@ impl<'a, 'd> InferenceVisitor<'a, 'd> {
260260
}
261261

262262
/// Captured expression: wraps inner's flow into a field.
263+
///
264+
/// Scope creation rules:
265+
/// - Sequences `{...} @x` and alternations `[...] @x` create new scopes.
266+
/// Inner fields become the captured type's fields.
267+
/// - Other expressions (named nodes, refs) don't create scopes.
268+
/// Inner fields bubble up alongside the capture field.
263269
fn infer_captured_expr(&mut self, cap: &CapturedExpr) -> TermInfo {
264270
let Some(name_tok) = cap.name() else {
265271
// Recover gracefully
@@ -284,17 +290,79 @@ impl<'a, 'd> InferenceVisitor<'a, 'd> {
284290
// Determine how inner flow relates to capture (e.g., ? makes field optional)
285291
let (inner_info, is_optional) = self.resolve_capture_inner(&inner);
286292

287-
let captured_type = self.determine_captured_type(&inner, &inner_info, annotation_type);
288-
let field_info = if is_optional {
289-
FieldInfo::optional(captured_type)
293+
// Determine if we need to merge bubbling fields with the capture.
294+
// Only applies when inner has Bubble flow AND doesn't create a scope boundary.
295+
// Sequences and alternations create scopes; named nodes/refs don't.
296+
let should_merge_fields =
297+
matches!(&inner_info.flow, TypeFlow::Bubble(_)) && !Self::inner_creates_scope(&inner);
298+
299+
if should_merge_fields {
300+
// Named node/ref/etc with bubbling fields: capture adds a field,
301+
// inner fields bubble up alongside.
302+
let captured_type = self.determine_non_scope_captured_type(&inner, annotation_type);
303+
let field_info = if is_optional {
304+
FieldInfo::optional(captured_type)
305+
} else {
306+
FieldInfo::required(captured_type)
307+
};
308+
309+
// Merge capture field with inner's bubbling fields
310+
let TypeFlow::Bubble(type_id) = &inner_info.flow else {
311+
unreachable!()
312+
};
313+
let mut fields = self
314+
.ctx
315+
.get_struct_fields(*type_id)
316+
.cloned()
317+
.unwrap_or_default();
318+
fields.insert(capture_name, field_info);
319+
320+
TermInfo::new(
321+
inner_info.arity,
322+
TypeFlow::Bubble(self.ctx.intern_struct(fields)),
323+
)
290324
} else {
291-
FieldInfo::required(captured_type)
292-
};
325+
// All other cases: scope-creating captures, scalar flows, void flows.
326+
// Inner becomes the captured type (if applicable).
327+
let captured_type = self.determine_captured_type(&inner, &inner_info, annotation_type);
328+
let field_info = if is_optional {
329+
FieldInfo::optional(captured_type)
330+
} else {
331+
FieldInfo::required(captured_type)
332+
};
333+
334+
TermInfo::new(
335+
inner_info.arity,
336+
TypeFlow::Bubble(self.ctx.intern_single_field(capture_name, field_info)),
337+
)
338+
}
339+
}
293340

294-
TermInfo::new(
295-
inner_info.arity,
296-
TypeFlow::Bubble(self.ctx.intern_single_field(capture_name, field_info)),
297-
)
341+
/// Determines if an expression creates a scope boundary when captured.
342+
fn inner_creates_scope(inner: &Expr) -> bool {
343+
match inner {
344+
Expr::SeqExpr(_) | Expr::AltExpr(_) => true,
345+
Expr::QuantifiedExpr(q) => {
346+
// Look through quantifier to the actual expression
347+
q.inner()
348+
.map(|i| Self::inner_creates_scope(&i))
349+
.unwrap_or(false)
350+
}
351+
_ => false,
352+
}
353+
}
354+
355+
/// Determines captured type for non-scope-creating expressions.
356+
fn determine_non_scope_captured_type(
357+
&mut self,
358+
inner: &Expr,
359+
annotation: Option<TypeId>,
360+
) -> TypeId {
361+
if let Some(ref_type) = self.get_recursive_ref_type(inner) {
362+
annotation.unwrap_or(ref_type)
363+
} else {
364+
annotation.unwrap_or(TYPE_NODE)
365+
}
298366
}
299367

300368
/// Resolves explicit type annotation like `@foo: string`.

crates/plotnik-lib/src/query/type_check/tests.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,35 @@ fn named_node_multiple_field_captures() {
153153
");
154154
}
155155

156+
#[test]
157+
fn named_node_captured_with_internal_captures() {
158+
// Capturing a named node does NOT create a scope boundary.
159+
// Internal captures bubble up alongside the outer capture.
160+
let input = indoc! {r#"
161+
Q = (function
162+
name: (identifier) @name :: string
163+
body: (block) @body
164+
) @func :: FunctionInfo
165+
"#};
166+
167+
let res = Query::expect_valid_types(input);
168+
169+
insta::assert_snapshot!(res, @r"
170+
export interface Node {
171+
kind: string;
172+
text: string;
173+
}
174+
175+
export type FunctionInfo = Node;
176+
177+
export interface Q {
178+
body: Node;
179+
func: FunctionInfo;
180+
name: string;
181+
}
182+
");
183+
}
184+
156185
#[test]
157186
fn nested_named_node_captures() {
158187
let input = indoc! {r#"

0 commit comments

Comments
 (0)