Skip to content

Commit 9a94836

Browse files
committed
yeast: Add per-rule .repeated() flag to opt into iterative matching
Previously, after a rule fired the engine would always re-try that same rule on the result root. A rule whose output matched its own query (intentionally or by accident) would loop until the global MAX_REWRITE_DEPTH safety net kicked in. Make the default behavior fire-once-per-node: after a rule fires on node N, the engine no longer tries that same rule on the result root. Other rules and child traversal are unaffected. Rules that intentionally rewrite iteratively can opt into the old behavior via the new Rule::repeated() builder method. Add two regression tests using a self-swapping assignment rule: - with .repeated(), the swap loops and trips the depth limit - without it (default), the swap fires once and terminates
1 parent a0a0e9e commit 9a94836

3 files changed

Lines changed: 107 additions & 4 deletions

File tree

shared/yeast/doc/yeast.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,22 @@ rule matches, the node is kept and its children are processed recursively.
6161
A rule can replace one node with zero nodes (deletion), one node (rewriting),
6262
or multiple nodes (expansion).
6363

64+
By default a rule fires **at most once on a given node**: after firing, the
65+
engine will not re-try that same rule on the result root. Other rules may
66+
still fire on the result, and the rule may still fire on different nodes
67+
(including the result's children). To opt into iterative behaviour — when a
68+
rule's output is intentionally re-matched by the same rule — call
69+
`.repeated()` on the constructed `Rule`:
70+
71+
```rust
72+
let r = yeast::rule!((foo ...) => (foo ...)).repeated();
73+
```
74+
75+
Without `.repeated()`, a rule whose output happens to match its own query
76+
simply fires once and stops. With `.repeated()`, the rule is allowed to
77+
re-match indefinitely; the runner still enforces a global rewrite-depth
78+
limit (currently 100) as a safety net against accidental cycles.
79+
6480
## Query language
6581

6682
Queries use a syntax inspired by

shared/yeast/src/lib.rs

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -471,11 +471,29 @@ pub type Transform = Box<
471471
pub struct Rule {
472472
query: QueryNode,
473473
transform: Transform,
474+
/// If true, after this rule fires on a node the engine will try to
475+
/// re-apply this same rule on the result root. Defaults to false:
476+
/// each rule fires at most once on a given node, which prevents
477+
/// accidental loops where a rule's output matches its own query.
478+
repeated: bool,
474479
}
475480

476481
impl Rule {
477482
pub fn new(query: QueryNode, transform: Transform) -> Self {
478-
Self { query, transform }
483+
Self {
484+
query,
485+
transform,
486+
repeated: false,
487+
}
488+
}
489+
490+
/// Mark this rule as allowed to fire multiple times on the same node.
491+
/// Use when the rule is intentionally iterative (its output may match
492+
/// its own query). Without this, a rule fires at most once per node;
493+
/// other rules can still fire on the result.
494+
pub fn repeated(mut self) -> Self {
495+
self.repeated = true;
496+
self
479497
}
480498

481499
fn try_rule(
@@ -537,7 +555,7 @@ fn apply_rules(
537555
fresh: &tree_builder::FreshScope,
538556
) -> Result<Vec<Id>, String> {
539557
let index = RuleIndex::new(rules);
540-
apply_rules_inner(&index, ast, id, fresh, 0)
558+
apply_rules_inner(&index, ast, id, fresh, 0, None)
541559
}
542560

543561
fn apply_rules_inner(
@@ -546,6 +564,7 @@ fn apply_rules_inner(
546564
id: Id,
547565
fresh: &tree_builder::FreshScope,
548566
rewrite_depth: usize,
567+
skip_rule: Option<*const Rule>,
549568
) -> Result<Vec<Id>, String> {
550569
if rewrite_depth > MAX_REWRITE_DEPTH {
551570
return Err(format!(
@@ -556,7 +575,16 @@ fn apply_rules_inner(
556575

557576
let node_kind = ast.get_node(id).map(|n| n.kind()).unwrap_or("");
558577
for rule in index.rules_for_kind(node_kind) {
578+
let rule_ptr = *rule as *const Rule;
579+
if Some(rule_ptr) == skip_rule {
580+
continue;
581+
}
559582
if let Some(result_node) = rule.try_rule(ast, id, fresh)? {
583+
// For non-repeated rules, suppress further application of *this*
584+
// rule on the result root, so a rule whose output matches its own
585+
// query doesn't loop. Other rules and child traversal are
586+
// unaffected.
587+
let next_skip = if rule.repeated { None } else { Some(rule_ptr) };
560588
let mut results = Vec::new();
561589
for node in result_node {
562590
results.extend(apply_rules_inner(
@@ -565,6 +593,7 @@ fn apply_rules_inner(
565593
node,
566594
fresh,
567595
rewrite_depth + 1,
596+
next_skip,
568597
)?);
569598
}
570599
return Ok(results);
@@ -579,13 +608,14 @@ fn apply_rules_inner(
579608
.collect();
580609

581610
// recursively descend into all the fields
582-
// Child traversal does not increment rewrite depth
611+
// Child traversal does not increment rewrite depth and starts fresh
612+
// (no rule is skipped on child subtrees).
583613
let mut changed = false;
584614
let mut new_fields = BTreeMap::new();
585615
for (field_id, children) in field_entries {
586616
let mut new_children = Vec::new();
587617
for child_id in children {
588-
let result = apply_rules_inner(index, ast, child_id, fresh, rewrite_depth)?;
618+
let result = apply_rules_inner(index, ast, child_id, fresh, rewrite_depth, None)?;
589619
if result.len() != 1 || result[0] != child_id {
590620
changed = true;
591621
}

shared/yeast/tests/test.rs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,18 @@ fn run_and_dump(input: &str, rules: Vec<Rule>) -> String {
2222
dump_ast(&ast, ast.get_root(), input)
2323
}
2424

25+
/// Helper: like `run_and_dump`, but returns the runner error (if any)
26+
/// instead of unwrapping.
27+
fn run_and_get_error(input: &str, rules: Vec<Rule>) -> String {
28+
let lang: tree_sitter::Language = tree_sitter_ruby::LANGUAGE.into();
29+
let schema =
30+
yeast::node_types_yaml::schema_from_yaml_with_language(OUTPUT_SCHEMA_YAML, &lang).unwrap();
31+
let runner = Runner::with_schema(lang, &schema, &rules);
32+
runner
33+
.run(input)
34+
.expect_err("expected runner to return an error")
35+
}
36+
2537
/// Assert that a dump equals the expected string, treating the expected
2638
/// string as an indented multiline literal: leading/trailing blank lines
2739
/// are stripped, and the common leading indentation is removed from every
@@ -382,6 +394,51 @@ fn test_chained_rules_output_only_kind() {
382394
);
383395
}
384396

397+
// A rule that swaps `assignment.left` and `assignment.right`. Each
398+
// application produces another `assignment` whose query the rule
399+
// matches again, so without the once-per-node default it would loop.
400+
fn swap_assignment_rule() -> Rule {
401+
yeast::rule!(
402+
(assignment
403+
left: (_) @left
404+
right: (_) @right
405+
)
406+
=>
407+
(assignment
408+
left: {right}
409+
right: {left}
410+
)
411+
)
412+
}
413+
414+
#[test]
415+
fn test_repeated_rule_hits_depth_limit() {
416+
// With `.repeated()` the rule is allowed to fire on its own output,
417+
// which cycles forever and trips the rewrite-depth safety net.
418+
let err = run_and_get_error("x = 1", vec![swap_assignment_rule().repeated()]);
419+
assert!(
420+
err.contains("exceeded maximum rewrite depth"),
421+
"expected depth-limit error, got: {err}"
422+
);
423+
}
424+
425+
#[test]
426+
fn test_default_rule_fires_at_most_once_per_node() {
427+
// Without `.repeated()` (the default), a rule fires at most once on a
428+
// given node. The swap therefore happens exactly once and the desugaring
429+
// terminates cleanly.
430+
let dump = run_and_dump("x = 1", vec![swap_assignment_rule()]);
431+
assert_dump_eq(
432+
&dump,
433+
r#"
434+
program
435+
assignment
436+
left: integer "1"
437+
right: identifier "x"
438+
"#,
439+
);
440+
}
441+
385442
// ---- Cursor tests ----
386443

387444
#[test]

0 commit comments

Comments
 (0)