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
12 changes: 11 additions & 1 deletion crates/plotnik-lib/src/analyze/type_check/infer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -267,14 +267,24 @@ impl<'a, 'd> InferenceVisitor<'a, 'd> {
/// - 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 {
// Suppressive captures don't contribute to output type
if cap.is_suppressive() {
// Still infer inner for structural validation, but don't create fields
return cap
.inner()
.map(|i| self.infer_expr(&i))
.map(|info| TermInfo::new(info.arity, TypeFlow::Void))
.unwrap_or_else(TermInfo::void);
}

let Some(name_tok) = cap.name() else {
// Recover gracefully
return cap
.inner()
.map(|i| self.infer_expr(&i))
.unwrap_or_else(TermInfo::void);
};
let capture_name = self.interner.intern(name_tok.text());
let capture_name = self.interner.intern(&name_tok.text()[1..]); // Strip @ prefix

let annotation_type = self.resolve_annotation(cap);
let Some(inner) = cap.inner() else {
Expand Down
4 changes: 4 additions & 0 deletions crates/plotnik-lib/src/bytecode/effects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ pub enum EffectOpcode {
Text = 9,
Clear = 10,
Null = 11,
SuppressBegin = 12,
SuppressEnd = 13,
}

impl EffectOpcode {
Expand All @@ -32,6 +34,8 @@ impl EffectOpcode {
9 => Self::Text,
10 => Self::Clear,
11 => Self::Null,
12 => Self::SuppressBegin,
13 => Self::SuppressEnd,
_ => panic!("invalid effect opcode: {v}"),
}
}
Expand Down
8 changes: 7 additions & 1 deletion crates/plotnik-lib/src/bytecode/format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,8 @@ pub mod trace {

/// Effect: data capture or structure.
pub const EFFECT: Symbol = Symbol::new(" ", "⬥", " ");
/// Effect: suppressed (inside @_ capture).
pub const EFFECT_SUPPRESSED: Symbol = Symbol::new(" ", "⬦", " ");

/// Call: entering definition.
pub const CALL: Symbol = Symbol::new(" ", "▶", " ");
Expand Down Expand Up @@ -275,7 +277,9 @@ impl LineBuilder {
///
/// Ensures at least 2 spaces between content and successors.
pub fn pad_successors(&self, base: String, successors: &str) -> String {
let padding = cols::TOTAL_WIDTH.saturating_sub(base.chars().count()).max(2);
let padding = cols::TOTAL_WIDTH
.saturating_sub(base.chars().count())
.max(2);
format!("{base}{:padding$}{successors}", "")
}
}
Expand All @@ -299,6 +303,8 @@ pub fn format_effect(effect: &EffectOp) -> String {
EffectOpcode::Text => "Text".to_string(),
EffectOpcode::Clear => "Clear".to_string(),
EffectOpcode::Null => "Null".to_string(),
EffectOpcode::SuppressBegin => "SuppressBegin".to_string(),
EffectOpcode::SuppressEnd => "SuppressEnd".to_string(),
}
}

Expand Down
4 changes: 2 additions & 2 deletions crates/plotnik-lib/src/compile/capture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ impl Compiler<'_> {
// Always look up in the current scope - bubble captures don't create new scopes,
// so all fields (including nested bubble captures) reference the same root struct.
if let Some(name_token) = cap.name() {
let capture_name = name_token.text();
let capture_name = &name_token.text()[1..]; // Strip @ prefix
let member_ref = self.lookup_member_in_scope(capture_name);
if let Some(member_ref) = member_ref {
effects.push(EffectIR::with_member(EffectOpcode::Set, member_ref));
Expand Down Expand Up @@ -132,7 +132,7 @@ impl Compiler<'_> {
if let Expr::CapturedExpr(cap) = expr
&& let Some(name) = cap.name()
{
names.insert(name.text().to_string());
names.insert(name.text()[1..].to_string()); // Strip @ prefix
}
for child in expr.children() {
collect(&child, names);
Expand Down
55 changes: 41 additions & 14 deletions crates/plotnik-lib/src/compile/expressions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@

use std::num::NonZeroU16;

use crate::bytecode::ir::{CallIR, Instruction, Label, MatchIR};
use crate::bytecode::Nav;
use crate::bytecode::ir::{CallIR, EffectIR, Instruction, Label, MatchIR};
use crate::bytecode::{EffectOpcode, Nav};
use crate::parser::ast::{self, Expr};

use super::capture::CaptureEffects;
Expand Down Expand Up @@ -164,18 +164,7 @@ impl Compiler<'_> {
let return_addr = if capture.post.is_empty() {
exit
} else {
let effects_label = self.fresh_label();
self.instructions.push(Instruction::Match(MatchIR {
label: effects_label,
nav: Nav::Stay,
node_type: None,
node_field: None,
pre_effects: vec![],
neg_fields: vec![],
post_effects: capture.post,
successors: vec![exit],
}));
effects_label
self.emit_effects_epsilon(exit, capture.post, CaptureEffects::default())
};

// Emit Call instruction with caller-provided navigation and field constraint.
Expand Down Expand Up @@ -281,13 +270,19 @@ impl Compiler<'_> {
/// - Struct: Obj epsilon → inner_pattern[Node/Text, Set] → EndObj epsilon → exit
/// - Array: Arr epsilon → quantifier (with Push on body) → EndArr+Set epsilon → exit
/// - Ref: Call → Set epsilon → exit (structured result needs epsilon)
/// - Suppressive: SuppressBegin → inner → SuppressEnd → outer_effects → exit
pub(super) fn compile_captured_inner(
&mut self,
cap: &ast::CapturedExpr,
exit: Label,
nav_override: Option<Nav>,
outer_capture: CaptureEffects,
) -> Label {
// Handle suppressive captures: wrap inner with SuppressBegin/End
if cap.is_suppressive() {
return self.compile_suppressive_capture(cap, exit, nav_override, outer_capture);
}

let inner = cap.inner();
let inner_info = inner.as_ref().and_then(|i| self.type_ctx.get_term_info(i));
let inner_is_bubble = inner_info.as_ref().is_some_and(|info| info.flow.is_bubble());
Expand Down Expand Up @@ -337,6 +332,38 @@ impl Compiler<'_> {
self.compile_expr_inner(&inner, exit, nav_override, CaptureEffects { post: combined })
}

/// Compile a suppressive capture (@_ or @_name).
///
/// Suppressive captures match structurally but don't emit effects.
/// Flow: SuppressBegin → inner → SuppressEnd → outer_effects → exit
fn compile_suppressive_capture(
&mut self,
cap: &ast::CapturedExpr,
exit: Label,
nav_override: Option<Nav>,
outer_capture: CaptureEffects,
) -> Label {
let Some(inner) = cap.inner() else {
// Bare @_ with no inner - just pass through outer effects
if outer_capture.post.is_empty() {
return exit;
}
return self.emit_effects_epsilon(exit, vec![], outer_capture);
};

// SuppressEnd + outer capture effects → exit
let suppress_end = vec![EffectIR::simple(EffectOpcode::SuppressEnd, 0)];
let end_label = self.emit_effects_epsilon(exit, suppress_end, outer_capture);

// Compile inner → end_label (inner gets NO capture effects)
let inner_entry =
self.compile_expr_inner(&inner, end_label, nav_override, CaptureEffects::default());

// SuppressBegin → inner_entry
let suppress_begin = vec![EffectIR::simple(EffectOpcode::SuppressBegin, 0)];
self.emit_effects_epsilon(inner_entry, suppress_begin, CaptureEffects::default())
}

/// Resolve an anonymous node's literal text to its node type ID.
///
/// In linked mode, returns the grammar NodeTypeId for the literal.
Expand Down
2 changes: 2 additions & 0 deletions crates/plotnik-lib/src/engine/checkpoint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ pub struct Checkpoint {
/// When a Call navigates and the callee fails, we need to try the next
/// sibling. This policy determines how to advance.
pub skip_policy: Option<SkipPolicy>,
/// Suppression depth at checkpoint.
pub suppress_depth: u16,
}

/// Stack of checkpoints with O(1) max_frame_ref tracking.
Expand Down
57 changes: 57 additions & 0 deletions crates/plotnik-lib/src/engine/engine_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -416,3 +416,60 @@ fn regression_enum_tag_with_definition_refs() {
entry: "Q"
);
}

// ============================================================================
// 10. SUPPRESSIVE CAPTURES
// ============================================================================

/// Suppressive capture (@_) matches structurally but doesn't produce output.
#[test]
fn suppressive_capture_anonymous() {
snap!(
"Q = (program (lexical_declaration (variable_declarator name: (identifier) @_)))",
"let x = 1"
);
}

/// Named suppressive capture (@_name) also suppresses output.
#[test]
fn suppressive_capture_named() {
snap!(
"Q = (program (lexical_declaration (variable_declarator name: (identifier) @_name value: (number) @value)))",
"let x = 42"
);
}

/// Suppressive capture with sibling regular capture - only regular capture appears.
#[test]
fn suppressive_capture_with_regular_sibling() {
snap!(
"Q = (program (lexical_declaration (variable_declarator name: (identifier) @_ value: (number) @value)))",
"let x = 42"
);
}

/// Suppressive capture suppresses inner captures from definitions.
#[test]
fn suppressive_capture_suppresses_inner() {
snap!(
indoc! {r#"
Expr = (binary_expression left: (number) @left right: (number) @right)
Q = (program (expression_statement (Expr) @_))
"#},
"1 + 2",
entry: "Q"
);
}

/// Suppressive capture with wrap pattern: { inner } @_ wraps and suppresses.
#[test]
fn suppressive_capture_with_wrap() {
snap!(
indoc! {r#"
Expr = (binary_expression left: (number) @left right: (number) @right)
Q = (program (expression_statement {(Expr) @_} @expr))
"#},
"1 + 2",
entry: "Q"
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
source: crates/plotnik-lib/src/engine/engine_tests.rs
---
Q = (program (lexical_declaration (variable_declarator name: (identifier) @_)))
---
let x = 1
---
null
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
source: crates/plotnik-lib/src/engine/engine_tests.rs
---
Q = (program (lexical_declaration (variable_declarator name: (identifier) @_name value: (number) @value)))
---
let x = 42
---
{
"value": {
"kind": "number",
"text": "42",
"span": [
8,
10
]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
source: crates/plotnik-lib/src/engine/engine_tests.rs
---
Expr = (binary_expression left: (number) @left right: (number) @right)
Q = (program (expression_statement (Expr) @_))
---
1 + 2
---
null
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
source: crates/plotnik-lib/src/engine/engine_tests.rs
---
Q = (program (lexical_declaration (variable_declarator name: (identifier) @_ value: (number) @value)))
---
let x = 42
---
{
"value": {
"kind": "number",
"text": "42",
"span": [
8,
10
]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
source: crates/plotnik-lib/src/engine/engine_tests.rs
---
Expr = (binary_expression left: (number) @left right: (number) @right)
Q = (program (expression_statement {(Expr) @_} @expr))
---
1 + 2
---
{
"expr": {
"kind": "binary_expression",
"text": "1 + 2",
"span": [
0,
5
]
}
}
Loading