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
22 changes: 19 additions & 3 deletions crates/plotnik-lib/src/compile/capture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,16 @@ impl Compiler<'_> {
// Check if inner is a scope-creating expression (SeqExpr/AltExpr) that produces
// a structured type (Struct/Enum). Named nodes with bubble captures don't count -
// they still need Node because we're capturing the matched node, not the struct.
let creates_structured_scope = inner.is_some_and(|i| {
inner_creates_scope(i)
//
// For FieldExpr, look through to the value. The parser treats `field: expr @cap` as
// `(field: expr) @cap` so that quantifiers work on fields (e.g., `decorator: (x)*`
// for repeating fields). This means captures wrap the FieldExpr, but the value
// determines whether it produces a structured type. See `parse_expr_no_suffix`.
let creates_structured_scope = inner.and_then(unwrap_field_value).is_some_and(|ei| {
inner_creates_scope(&ei)
&& self
.type_ctx
.get_term_info(i)
.get_term_info(&ei)
.and_then(|info| info.flow.type_id())
.and_then(|id| self.type_ctx.get_type(id))
.is_some_and(|shape| matches!(shape, TypeShape::Struct(_) | TypeShape::Enum(_)))
Expand Down Expand Up @@ -148,6 +153,17 @@ impl Compiler<'_> {
}
}

/// Unwrap FieldExpr to get its value, pass through other expressions.
///
/// Used when checking properties of a captured expression - captures on fields
/// like `field: [A: B:] @cap` wrap the FieldExpr, but we need to inspect the value.
fn unwrap_field_value(expr: &Expr) -> Option<Expr> {
match expr {
Expr::FieldExpr(f) => f.value(),
other => Some(other.clone()),
}
}

/// Check if inner needs struct wrapper for array iterations.
///
/// Returns true when inner is a scope-creating expression (sequence/alternation)
Expand Down
20 changes: 20 additions & 0 deletions crates/plotnik-lib/src/compile/expressions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -453,6 +453,26 @@ impl Compiler<'_> {
};
}

// Handle scope-creating scalar expressions (tagged enums)
// Enum produces its own value via EndEnum - capture effects go AFTER, not inside
let inner_is_scope_creating_scalar = !inner_is_bubble
&& inner_creates_scope
&& inner_info
.as_ref()
.and_then(|info| info.flow.type_id())
.and_then(|id| self.type_ctx.get_type(id))
.is_some_and(|shape| matches!(shape, TypeShape::Enum(_)));

if inner_is_scope_creating_scalar {
let set_step = self.emit_effects_epsilon(exit, capture_effects, outer_capture);
return self.compile_expr_inner(
&inner,
set_step,
nav_override,
CaptureEffects::default(),
);
}

// Array: Arr → quantifier (with Push) → EndArr+capture → exit
// Check if inner is a * or + quantifier - these produce arrays regardless of arity
let inner_is_array = is_star_or_plus_quantifier(Some(&inner));
Expand Down
10 changes: 10 additions & 0 deletions crates/plotnik-lib/src/emit/codegen_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,16 @@ fn alternations_no_internal_captures() {
"#});
}

#[test]
fn alternations_tagged_in_field_constraint() {
// Regression test: captured tagged alternation as field value should not emit Node effect.
// The capture `@kind` applies to the field expression, but the value determines
// whether it's a structured scope (enum in this case).
snap!(indoc! {r#"
Test = (foo field: [A: (x) @a B: (y)] @kind)
"#});
}

// ============================================================================
// 7. ANCHORS
// ============================================================================
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,12 @@ _ObjWrap:

Test:
06 ε 07
07 ε 14, 20
07 ε 16, 22
09 ▶
10 ε [EndEnum Set(M4)] 09
12 ! (identifier) [Node Set(M0)] 10
14 ε [Enum(M2)] 12
16 ε [EndEnum Set(M4)] 09
18 ! (number) [Node Set(M1)] 16
20 ε [Enum(M3)] 18
10 ε [Set(M4)] 09
12 ε [EndEnum] 10
14 ! (identifier) [Node Set(M0)] 12
16 ε [Enum(M2)] 14
18 ε [EndEnum] 10
20 ! (number) [Node Set(M1)] 18
22 ε [Enum(M3)] 20
Original file line number Diff line number Diff line change
Expand Up @@ -53,26 +53,27 @@ Test:
06 ε 07
07 ! (object) 08
08 ε [Arr] 10
10 ε 43, 20
10 ε 45, 20
12 ε [EndArr Set(M5)] 14
14 △ 19
15 ε [EndObj Push] 17
17 ε 49, 12
17 ε 51, 12
19 ▶
20 ε [EndArr Set(M5)] 19
22 ε [EndEnum Set(M4)] 15
24 ! (pair) [Node Set(M0)] 22
26 ε [Enum(M2)] 24
28 ε [EndEnum Set(M4)] 15
30 ! (shorthand_property_identifier) [Node Set(M1)] 28
32 ε [Enum(M3)] 30
34 ε 26, 32
36 ε [Obj] 34
38 ▷ 41
39 ε 38, 20
41 ε 36, 39
43 ▽ 41
44 ▷ 47
45 ε 44, 12
47 ε 36, 45
49 ▷ 47
22 ε [Set(M4)] 15
24 ε [EndEnum] 22
26 ! (pair) [Node Set(M0)] 24
28 ε [Enum(M2)] 26
30 ε [EndEnum] 22
32 ! (shorthand_property_identifier) [Node Set(M1)] 30
34 ε [Enum(M3)] 32
36 ε 28, 34
38 ε [Obj] 36
40 ▷ 43
41 ε 40, 20
43 ε 38, 41
45 ▽ 43
46 ▷ 49
47 ε 46, 12
49 ε 38, 47
51 ▷ 49
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
---
source: crates/plotnik-lib/src/emit/codegen_tests.rs
---
Test = (foo field: [A: (x) @a B: (y)] @kind)
---
[flags]
linked = false

[strings]
S0 "Beauty will save the world"
S1 "a"
S2 "A"
S3 "B"
S4 "kind"
S5 "Test"
S6 "foo"
S7 "field"
S8 "x"
S9 "y"

[type_defs]
T0 = <Void>
T1 = <Node>
T2 = Struct M0:1 ; { a }
T3 = Enum M1:2 ; A | B
T4 = Struct M3:1 ; { kind }

[type_members]
M0: S1 → T1 ; a: <Node>
M1: S2 → T2 ; A: T2
M2: S3 → T0 ; B: <Void>
M3: S4 → T3 ; kind: T3

[type_names]
N0: S5 → T4 ; Test

[entrypoints]
Test = 06 :: T4

[transitions]
_ObjWrap:
00 ε [Obj] 02
02 Trampoline 03
03 ε [EndObj] 05
05 ▶

Test:
06 ε 07
07 ! (foo) 08
08 ▽ field: _ 09
09 ε 16, 21
11 ▶
12 ε [EndEnum Set(M3)] 23
14 ! (x) [Node Set(M0)] 12
16 ε [Enum(M1)] 14
18 ε [EndEnum Set(M3)] 23
20 ! (y) 18
21 ε [Enum(M2)] 20
23 △ 11
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,12 @@ Inner:

Test:
10 ε 11
11 ε 17, 23
11 ε 19, 25
13 ▶
14 ε [EndEnum Set(M4)] 13
16 ! (Inner) 06 : 14
17 ε [Enum(M2)] 16
19 ε [EndEnum Set(M4)] 13
21 ! (number) [Node Set(M1)] 19
23 ε [Enum(M3)] 21
14 ε [Set(M4)] 13
16 ε [EndEnum] 14
18 ! (Inner) 06 : 16
19 ε [Enum(M2)] 18
21 ε [EndEnum] 14
23 ! (number) [Node Set(M1)] 21
25 ε [Enum(M3)] 23
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,12 @@ _ObjWrap:

Test:
06 ε 07
07 ε 14, 20
07 ε 16, 22
09 ▶
10 ε [EndEnum Set(M4)] 09
12 ! (identifier) [Node Set(M0)] 10
14 ε [Enum(M2)] 12
16 ε [EndEnum Set(M4)] 09
18 ! (number) [Node Set(M1)] 16
20 ε [Enum(M3)] 18
10 ε [Set(M4)] 09
12 ε [EndEnum] 10
14 ! (identifier) [Node Set(M0)] 12
16 ε [Enum(M2)] 14
18 ε [EndEnum] 10
20 ! (number) [Node Set(M1)] 18
22 ε [Enum(M3)] 20
9 changes: 8 additions & 1 deletion crates/plotnik-lib/src/parser/grammar/expressions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,14 @@ impl Parser<'_, '_> {
}

/// Parse expression without applying quantifier/capture suffix.
/// Used for field values so that `field: (x)*` parses as `(field: (x))*`.
///
/// Used for field values so that suffixes apply to the whole field constraint:
/// - `field: (x)*` parses as `(field: (x))*` — repeat the field (e.g., decorators)
/// - `field: (x) @cap` parses as `(field: (x)) @cap` — capture the field expression
///
/// For captures on structured values (enums/structs), the compilation handles this
/// by looking through FieldExpr to determine the actual value type. See
/// `build_capture_effects` in compile/capture.rs.
pub(crate) fn parse_expr_no_suffix(&mut self) {
self.parse_expr_inner(false)
}
Expand Down
11 changes: 11 additions & 0 deletions docs/lang-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,17 @@ Output type:
{ target: string, value: Node }
```

### Quantifiers and Captures on Fields

Quantifiers and captures after a field value apply to the entire field constraint, not just the value:

```
decorator: (decorator)* @decorators ; repeats the whole field
value: [A: (x) B: (y)] @kind ; captures the field (containing the alternation)
```

This allows repeating fields (useful for things like decorators in JavaScript). The capture still correctly produces the value's type—for alternations, you get the tagged union, not a raw node.

### Negated Fields

Assert a field is absent with `-`:
Expand Down