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
93 changes: 93 additions & 0 deletions crates/plotnik-lib/src/compile/expressions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,15 @@ impl Compiler<'_> {
Nav::Up(1)
};

// Trailing anchor requires skip-retry pattern for backtracking.
// When the anchor check fails (matched node is not last), we need to
// retry with the next sibling until we find one that IS last.
if has_trailing_anchor {
return self.compile_named_node_with_trailing_anchor(
entry, exit, nav, node_type, neg_fields, &items, up_nav, capture,
);
}

// Check if first item is skippable - its skip path should bypass the Up.
// When a zero-match quantifier (? or *) is the first child with Down navigation,
// the skip path never descends, so executing Up would ascend too far.
Expand Down Expand Up @@ -113,6 +122,90 @@ impl Compiler<'_> {
entry
}

/// Compile a named node with trailing anchor using skip-retry pattern.
///
/// Structure:
/// ```text
/// entry: Match(nav, node_type) → down_wildcard
/// down_wildcard: Match(Down, wildcard) → try
/// try: epsilon → [body, retry_nav]
/// body: items (StayExact) → up_check
/// up_check: Match(up_nav, None) → exit
/// retry_nav: Match(Next, wildcard) → try
/// ```
///
/// When items match but the trailing anchor check fails, we backtrack to `try`,
/// which falls through to `retry_nav`, advances to next sibling, and retries.
/// Only when siblings are exhausted does backtracking propagate to the caller.
#[allow(clippy::too_many_arguments)]
fn compile_named_node_with_trailing_anchor(
&mut self,
entry: Label,
exit: Label,
nav: Nav,
node_type: Option<NonZeroU16>,
neg_fields: Vec<u16>,
items: &[ast::SeqItem],
up_nav: Nav,
capture: CaptureEffects,
) -> Label {
// up_check: Match(up_nav) → exit
let up_check = self.fresh_label();
self.instructions.push(Instruction::Match(MatchIR {
label: up_check,
nav: up_nav,
node_type: None,
node_field: None,
pre_effects: vec![],
neg_fields: vec![],
post_effects: vec![],
successors: vec![exit],
}));

// body: items with StayExact navigation → up_check
// Items are compiled with StayExact because the skip-retry loop handles
// advancement; the body should match at the current position only.
let body = self.compile_seq_items_inner(
items,
up_check,
true,
Some(Nav::StayExact), // First item uses StayExact (we're already at position)
CaptureEffects::default(),
None,
);

// Build skip-retry structure:
// try: epsilon → [body, retry_nav]
// retry_nav: Match(Next, wildcard) → try
let try_label = self.fresh_label();
let retry_nav = self.fresh_label();

// retry_nav: advance to next sibling and loop back
self.emit_wildcard_nav(retry_nav, Nav::Next, try_label);

// try: branch to body (prefer) or retry (fallback)
// Greedy: try body first, then retry on failure
self.emit_branch_epsilon_at(try_label, body, retry_nav, true);

// down_wildcard: navigate to first child → try
let down_wildcard = self.fresh_label();
self.emit_wildcard_nav(down_wildcard, Nav::Down, try_label);

// entry: match parent node → down_wildcard
self.instructions.push(Instruction::Match(MatchIR {
label: entry,
nav,
node_type,
node_field: None,
pre_effects: vec![],
neg_fields,
post_effects: capture.post,
successors: vec![down_wildcard],
}));

entry
}

/// Compile an anonymous node with capture effects.
pub(super) fn compile_anonymous_node_inner(
&mut self,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ _ObjWrap:
Test:
06 ε 07
07 ! (parent) 08
08 ▽ (last) 09
09 !△ 10
10 ▶
08 ▽ 13
09 ▶
10 !△ 09
11 ! (last) 10
12 ▷ 13
13 ε 11, 12
38 changes: 38 additions & 0 deletions crates/plotnik-lib/src/engine/engine_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,44 @@ fn anchor_adjacency() {
);
}

/// Trailing anchor requires backtracking when first match isn't last.
/// The skip-retry pattern retries with next sibling until finding the last one.
#[test]
fn anchor_last_child() {
snap!(
"Q = (program (function_declaration) @last .)",
"function first() {} function second() {}"
);
}

/// Trailing anchor with only one sibling - trivial case.
#[test]
fn anchor_last_child_single() {
snap!(
"Q = (program (function_declaration) @only .)",
"function only() {}"
);
}

/// Trailing anchor skips trivia (comments) when checking last position.
#[test]
fn anchor_last_child_with_trivia() {
snap!(
"Q = (program (function_declaration) @last .)",
"function first() {} function second() {} /* trailing comment */"
);
}

/// Multi-item sequence with trailing anchor.
/// Should find (b, c) not (a, b) because c must be last.
#[test]
fn anchor_last_child_multi_item() {
snap!(
"Q = (program {(function_declaration) @a (function_declaration) @b .})",
"function a() {} function b() {} function c() {}"
);
}

// ============================================================================
// 6. FIELDS
// ============================================================================
Expand Down
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 (function_declaration) @last .)
---
function first() {} function second() {}
---
{
"last": {
"kind": "function_declaration",
"text": "function second() {}",
"span": [
20,
40
]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
source: crates/plotnik-lib/src/engine/engine_tests.rs
---
Q = (program {(function_declaration) @a (function_declaration) @b .})
---
function a() {} function b() {} function c() {}
---
{
"a": {
"kind": "function_declaration",
"text": "function b() {}",
"span": [
16,
31
]
},
"b": {
"kind": "function_declaration",
"text": "function c() {}",
"span": [
32,
47
]
}
}
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 (function_declaration) @only .)
---
function only() {}
---
{
"only": {
"kind": "function_declaration",
"text": "function only() {}",
"span": [
0,
18
]
}
}
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 (function_declaration) @last .)
---
function first() {} function second() {} /* trailing comment */
---
{
"last": {
"kind": "function_declaration",
"text": "function second() {} /* trailing comment */",
"span": [
20,
63
]
}
}