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
86 changes: 77 additions & 9 deletions crates/plotnik-lib/src/query/type_check/infer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,12 @@ impl<'a, 'd> InferenceVisitor<'a, 'd> {
}

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

let captured_type = self.determine_captured_type(&inner, &inner_info, annotation_type);
let field_info = if is_optional {
FieldInfo::optional(captured_type)
// Determine if we need to merge bubbling fields with the capture.
// Only applies when inner has Bubble flow AND doesn't create a scope boundary.
// Sequences and alternations create scopes; named nodes/refs don't.
let should_merge_fields =
matches!(&inner_info.flow, TypeFlow::Bubble(_)) && !Self::inner_creates_scope(&inner);

if should_merge_fields {
// Named node/ref/etc with bubbling fields: capture adds a field,
// inner fields bubble up alongside.
let captured_type = self.determine_non_scope_captured_type(&inner, annotation_type);
let field_info = if is_optional {
FieldInfo::optional(captured_type)
} else {
FieldInfo::required(captured_type)
};

// Merge capture field with inner's bubbling fields
let TypeFlow::Bubble(type_id) = &inner_info.flow else {
unreachable!()
};
let mut fields = self
.ctx
.get_struct_fields(*type_id)
.cloned()
.unwrap_or_default();
fields.insert(capture_name, field_info);

TermInfo::new(
inner_info.arity,
TypeFlow::Bubble(self.ctx.intern_struct(fields)),
)
} else {
FieldInfo::required(captured_type)
};
// All other cases: scope-creating captures, scalar flows, void flows.
// Inner becomes the captured type (if applicable).
let captured_type = self.determine_captured_type(&inner, &inner_info, annotation_type);
let field_info = if is_optional {
FieldInfo::optional(captured_type)
} else {
FieldInfo::required(captured_type)
};

TermInfo::new(
inner_info.arity,
TypeFlow::Bubble(self.ctx.intern_single_field(capture_name, field_info)),
)
}
}

TermInfo::new(
inner_info.arity,
TypeFlow::Bubble(self.ctx.intern_single_field(capture_name, field_info)),
)
/// Determines if an expression creates a scope boundary when captured.
fn inner_creates_scope(inner: &Expr) -> bool {
match inner {
Expr::SeqExpr(_) | Expr::AltExpr(_) => true,
Expr::QuantifiedExpr(q) => {
// Look through quantifier to the actual expression
q.inner()
.map(|i| Self::inner_creates_scope(&i))
.unwrap_or(false)
}
_ => false,
}
}

/// Determines captured type for non-scope-creating expressions.
fn determine_non_scope_captured_type(
&mut self,
inner: &Expr,
annotation: Option<TypeId>,
) -> TypeId {
if let Some(ref_type) = self.get_recursive_ref_type(inner) {
annotation.unwrap_or(ref_type)
} else {
annotation.unwrap_or(TYPE_NODE)
}
}

/// Resolves explicit type annotation like `@foo: string`.
Expand Down
29 changes: 29 additions & 0 deletions crates/plotnik-lib/src/query/type_check/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,35 @@ fn named_node_multiple_field_captures() {
");
}

#[test]
fn named_node_captured_with_internal_captures() {
// Capturing a named node does NOT create a scope boundary.
// Internal captures bubble up alongside the outer capture.
let input = indoc! {r#"
Q = (function
name: (identifier) @name :: string
body: (block) @body
) @func :: FunctionInfo
"#};

let res = Query::expect_valid_types(input);

insta::assert_snapshot!(res, @r"
export interface Node {
kind: string;
text: string;
}

export type FunctionInfo = Node;

export interface Q {
body: Node;
func: FunctionInfo;
name: string;
}
");
}

#[test]
fn nested_named_node_captures() {
let input = indoc! {r#"
Expand Down