Skip to content

Commit 75718bc

Browse files
committed
fix: empty captured sequences produce empty struct instead of null
1 parent f07603f commit 75718bc

10 files changed

Lines changed: 127 additions & 24 deletions

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

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ use crate::analyze::visitor::{Visitor, walk_alt_expr, walk_def, walk_named_node,
2222
use crate::diagnostics::{DiagnosticKind, Diagnostics};
2323
use crate::parser::ast::{
2424
AltExpr, AltKind, AnonymousNode, CapturedExpr, Def, Expr, FieldExpr, NamedNode, QuantifiedExpr,
25-
Ref, Root, SeqExpr,
25+
Ref, Root, SeqExpr, is_truly_empty_scope,
2626
};
2727
use crate::parser::cst::SyntaxKind;
2828
use crate::query::source_map::SourceId;
@@ -458,8 +458,24 @@ impl<'a, 'd> InferenceVisitor<'a, 'd> {
458458
) -> TypeId {
459459
match &inner_info.flow {
460460
TypeFlow::Void => {
461-
let base_type = self.get_recursive_ref_type(inner).unwrap_or(TYPE_NODE);
462-
self.annotation_to_alias(annotation, base_type)
461+
// Truly empty sequences/alternations produce empty struct.
462+
// E.g., `{ } @x` has type `{ x: {} }`.
463+
// Non-empty sequences with void flow (e.g., suppressed captures)
464+
// still produce Node for the capture.
465+
if is_truly_empty_scope(inner) {
466+
let empty_struct = self.ctx.intern_struct(BTreeMap::new());
467+
match annotation {
468+
Some(AnnotationKind::String) => TYPE_STRING,
469+
Some(AnnotationKind::TypeName(name)) => {
470+
self.ctx.set_type_name(empty_struct, name);
471+
empty_struct
472+
}
473+
None => empty_struct,
474+
}
475+
} else {
476+
let base_type = self.get_recursive_ref_type(inner).unwrap_or(TYPE_NODE);
477+
self.annotation_to_alias(annotation, base_type)
478+
}
463479
}
464480
TypeFlow::Scalar(type_id) => {
465481
// For array types with annotation, replace the element type

crates/plotnik-lib/src/compile/capture.rs

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use crate::bytecode::ir::EffectIR;
1111
use crate::parser::ast::{self, Expr};
1212

1313
use super::Compiler;
14-
use super::navigation::{inner_creates_scope, is_star_or_plus_quantifier};
14+
use super::navigation::{inner_creates_scope, is_star_or_plus_quantifier, is_truly_empty_scope};
1515

1616
/// Capture effects to attach to the innermost match instruction.
1717
///
@@ -42,21 +42,29 @@ impl Compiler<'_> {
4242
let is_array = is_star_or_plus_quantifier(inner);
4343

4444
// Check if inner is a scope-creating expression (SeqExpr/AltExpr) that produces
45-
// a structured type (Struct/Enum). Named nodes with bubble captures don't count -
46-
// they still need Node because we're capturing the matched node, not the struct.
45+
// a structured type (Struct/Enum) or truly empty struct. Named nodes with bubble
46+
// captures don't count - they still need Node because we're capturing the matched
47+
// node, not the struct.
4748
//
4849
// For FieldExpr, look through to the value. The parser treats `field: expr @cap` as
4950
// `(field: expr) @cap` so that quantifiers work on fields (e.g., `decorator: (x)*`
5051
// for repeating fields). This means captures wrap the FieldExpr, but the value
5152
// determines whether it produces a structured type. See `parse_expr_no_suffix`.
5253
let creates_structured_scope = inner.and_then(unwrap_field_value).is_some_and(|ei| {
53-
inner_creates_scope(&ei)
54-
&& self
55-
.type_ctx
56-
.get_term_info(&ei)
57-
.and_then(|info| info.flow.type_id())
58-
.and_then(|id| self.type_ctx.get_type(id))
59-
.is_some_and(|shape| matches!(shape, TypeShape::Struct(_) | TypeShape::Enum(_)))
54+
// Truly empty scopes (like `{ }`) produce empty struct
55+
if is_truly_empty_scope(&ei) {
56+
return true;
57+
}
58+
if !inner_creates_scope(&ei) {
59+
return false;
60+
}
61+
let Some(info) = self.type_ctx.get_term_info(&ei) else {
62+
return false;
63+
};
64+
info.flow
65+
.type_id()
66+
.and_then(|id| self.type_ctx.get_type(id))
67+
.is_some_and(|shape| matches!(shape, TypeShape::Struct(_) | TypeShape::Enum(_)))
6068
});
6169

6270
if !is_structured_ref && !creates_structured_scope && !is_array {

crates/plotnik-lib/src/compile/expressions.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ use crate::parser::ast::{self, Expr};
1818
use super::Compiler;
1919
use super::capture::CaptureEffects;
2020
use super::navigation::{
21-
check_trailing_anchor, inner_creates_scope, is_skippable_quantifier, is_star_or_plus_quantifier,
21+
check_trailing_anchor, inner_creates_scope, is_skippable_quantifier,
22+
is_star_or_plus_quantifier, is_truly_empty_scope,
2223
};
2324

2425
impl Compiler<'_> {
@@ -429,7 +430,11 @@ impl Compiler<'_> {
429430
};
430431

431432
// Struct scope: Obj → inner → EndObj+capture → exit
432-
if inner_is_bubble {
433+
// Also handle truly empty scopes (e.g., `{ } @x` produces empty struct)
434+
let inner_is_truly_empty_scope = is_truly_empty_scope(&inner);
435+
let needs_struct_scope = inner_is_bubble || inner_is_truly_empty_scope;
436+
437+
if needs_struct_scope {
433438
return if inner_creates_scope {
434439
// Sequence/alternation: capture effects after EndObj (value is the struct)
435440
self.compile_struct_scope(

crates/plotnik-lib/src/compile/navigation.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
use crate::bytecode::Nav;
77
use crate::parser::ast::{Expr, SeqItem};
88

9+
// Re-export from parser for compile module consumers
10+
pub use crate::parser::is_truly_empty_scope;
11+
912
/// Check if an expression is anonymous (string literal or wildcard).
1013
pub fn expr_is_anonymous(expr: Option<&Expr>) -> bool {
1114
matches!(expr, Some(Expr::AnonymousNode(_)))

crates/plotnik-lib/src/engine/engine_tests.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -547,3 +547,23 @@ fn wildcard_named_skips_anonymous() {
547547
fn wildcard_bare_matches_anonymous() {
548548
snap!("Q = (program (return_statement _ @x))", "return 42");
549549
}
550+
551+
/// Empty captured sequence should produce empty struct, not null or panic.
552+
/// Regression test for: type inference treating `{ }` as Node instead of empty struct.
553+
#[test]
554+
fn regression_empty_captured_sequence() {
555+
snap!(
556+
"Q = (program (expression_statement (identifier) @id { } @empty))",
557+
"x"
558+
);
559+
}
560+
561+
/// Optional empty captured sequence should produce empty struct when matched.
562+
/// Regression test for: `{ }? @maybe` producing null instead of `{}`.
563+
#[test]
564+
fn regression_optional_empty_captured_sequence() {
565+
snap!(
566+
"Q = (program (expression_statement (identifier) @id { }? @maybe))",
567+
"x"
568+
);
569+
}

crates/plotnik-lib/src/engine/materializer.rs

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -145,18 +145,21 @@ impl<'t> Materializer<'t> for ValueMaterializer<'_> {
145145
}
146146
RuntimeEffect::EndObj => {
147147
if let Some(Builder::Object(fields)) = stack.pop() {
148-
// Preserve pending if this object is empty and pending has a value.
149-
// This allows the preamble's Obj/EndObj to work correctly for
150-
// entrypoints that return non-struct values (enums, scalars).
151-
// For void-returning queries (no pending, empty object), return null.
152148
if !fields.is_empty() {
149+
// Non-empty object: always produce the object value
153150
pending = Some(Value::Object(fields));
154151
} else if pending.is_none() {
155-
// Empty object with no pending = void result → null
156-
// (This is the preamble's empty wrapper for void-returning queries)
157-
pending = None;
152+
// Empty object with no pending value:
153+
// - If nested (stack.len() > 1): produce empty object {}
154+
// This handles captured empty sequences like `{ } @x`
155+
// Note: stack always has at least the result_builder, so we check > 1
156+
// - If at root (stack.len() <= 1): void result → null
157+
if stack.len() > 1 {
158+
pending = Some(Value::Object(vec![]));
159+
}
160+
// else: pending stays None (void result)
158161
}
159-
// else: non-empty pending, keep it (passthrough for enums, etc.)
162+
// else: pending has a value, keep it (passthrough for enums, suppressive, etc.)
160163
}
161164
}
162165
RuntimeEffect::Enum(idx) => {
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
source: crates/plotnik-lib/src/engine/engine_tests.rs
3+
---
4+
Q = (program (expression_statement (identifier) @id { } @empty))
5+
---
6+
x
7+
---
8+
{
9+
"id": {
10+
"kind": "identifier",
11+
"text": "x",
12+
"span": [
13+
0,
14+
1
15+
]
16+
},
17+
"empty": {}
18+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
source: crates/plotnik-lib/src/engine/engine_tests.rs
3+
---
4+
Q = (program (expression_statement (identifier) @id { }? @maybe))
5+
---
6+
x
7+
---
8+
{
9+
"id": {
10+
"kind": "identifier",
11+
"text": "x",
12+
"span": [
13+
0,
14+
1
15+
]
16+
},
17+
"maybe": {}
18+
}

crates/plotnik-lib/src/parser/ast.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -469,3 +469,14 @@ impl NegatedField {
469469
.find(|t| t.kind() == SyntaxKind::Id)
470470
}
471471
}
472+
473+
/// Checks if expression is a truly empty scope (sequence/alternation with no children).
474+
/// Used to distinguish `{ } @x` (empty struct) from `{(expr) @_} @x` (Node capture).
475+
pub fn is_truly_empty_scope(inner: &Expr) -> bool {
476+
match inner {
477+
Expr::SeqExpr(seq) => seq.children().next().is_none(),
478+
Expr::AltExpr(alt) => alt.branches().next().is_none(),
479+
Expr::QuantifiedExpr(q) => q.inner().is_some_and(|i| is_truly_empty_scope(&i)),
480+
_ => false,
481+
}
482+
}

crates/plotnik-lib/src/parser/mod.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@ pub use cst::{SyntaxKind, SyntaxNode, SyntaxToken};
4242

4343
pub use ast::{
4444
AltExpr, AltKind, Anchor, AnonymousNode, Branch, CapturedExpr, Def, Expr, FieldExpr, NamedNode,
45-
NegatedField, QuantifiedExpr, Ref, Root, SeqExpr, SeqItem, Type, token_src,
45+
NegatedField, QuantifiedExpr, Ref, Root, SeqExpr, SeqItem, Type, is_truly_empty_scope,
46+
token_src,
4647
};
4748

4849
pub use core::{ParseResult, Parser};

0 commit comments

Comments
 (0)