From 40c13de904facdcad7d0a889403c420dcef1d87e Mon Sep 17 00:00:00 2001 From: Sergei Zharinov Date: Sun, 7 Dec 2025 21:50:22 -0300 Subject: [PATCH 1/7] fix: Add non-consuming left recursion check --- crates/plotnik-lib/src/query/link_tests.rs | 22 +- crates/plotnik-lib/src/query/recursion.rs | 210 +++++++++++++++++- .../plotnik-lib/src/query/recursion_tests.rs | 99 ++++++++- 3 files changed, 316 insertions(+), 15 deletions(-) diff --git a/crates/plotnik-lib/src/query/link_tests.rs b/crates/plotnik-lib/src/query/link_tests.rs index a0d2271b..9032bbfe 100644 --- a/crates/plotnik-lib/src/query/link_tests.rs +++ b/crates/plotnik-lib/src/query/link_tests.rs @@ -928,6 +928,13 @@ fn ref_followed_recursive_with_invalid_type() { | ---- field `name` on `function_declaration` | help: valid types for `name`: `identifier` + + error: infinite recursion: cycle `Foo` → `Foo` has no escape path + | + 1 | Foo = [(number) (Foo)] + | ^^^ + | | + | `Foo` references itself "); } @@ -941,7 +948,15 @@ fn ref_followed_recursive_valid() { let mut query = Query::try_from(input).unwrap(); query.link(&plotnik_langs::javascript()); - assert!(query.is_valid()); + assert!(!query.is_valid()); + insta::assert_snapshot!(query.dump_diagnostics(), @r" + error: infinite recursion: cycle `Foo` → `Foo` has no escape path + | + 1 | Foo = [(identifier) (Foo)] + | ^^^ + | | + | `Foo` references itself + "); } #[test] @@ -957,6 +972,11 @@ fn ref_followed_mutual_recursion() { assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" + error: infinite recursion: cycle `Foo` → `Foo` has no escape path + | + 1 | Foo = [(number) (Bar)] + | ^ + error: node type `number` is not valid for this field | 1 | Foo = [(number) (Bar)] diff --git a/crates/plotnik-lib/src/query/recursion.rs b/crates/plotnik-lib/src/query/recursion.rs index 4fd49625..e6124723 100644 --- a/crates/plotnik-lib/src/query/recursion.rs +++ b/crates/plotnik-lib/src/query/recursion.rs @@ -1,7 +1,8 @@ //! Escape path analysis for recursive definitions. //! //! Detects patterns that can never match because they require -//! infinitely nested structures (recursion with no escape path). +//! infinitely nested structures (recursion with no escape path), +//! or infinite runtime loops where the cursor never advances (left recursion). use indexmap::{IndexMap, IndexSet}; use rowan::TextRange; @@ -17,6 +18,8 @@ impl Query<'_> { for scc in sccs { let scc_set: IndexSet<&str> = scc.iter().map(|s| s.as_str()).collect(); + // 1. Check for infinite tree structure (Escape Analysis) + // Existing logic: at least one definition must have a non-recursive path. let has_escape = scc.iter().any(|name| { self.symbol_table .get(name.as_str()) @@ -24,16 +27,22 @@ impl Query<'_> { .unwrap_or(true) }); - if has_escape { + if !has_escape { + let chain = if scc.len() == 1 { + self.build_self_ref_chain(&scc[0]) + } else { + self.build_cycle_chain(&scc) + }; + self.emit_recursion_error(&scc[0], &scc, chain); continue; } - let chain = if scc.len() == 1 { - self.build_self_ref_chain(&scc[0]) - } else { - self.build_cycle_chain(&scc) - }; - self.emit_recursion_error(&scc[0], &scc, chain); + // 2. Check for infinite loops (Guarded Recursion Analysis) + // Ensure every recursive cycle consumes at least one node. + if let Some(cycle) = self.find_unguarded_cycle(&scc, &scc_set) { + let chain = self.build_unguarded_chain(&cycle); + self.emit_recursion_error(&cycle[0], &cycle, chain); + } } } @@ -118,6 +127,64 @@ impl Query<'_> { .collect() } + fn find_unguarded_cycle( + &self, + scc: &[String], + scc_set: &IndexSet<&str>, + ) -> Option> { + // Build dependency graph for unguarded calls within the SCC + let mut adj = IndexMap::new(); + for name in scc { + if let Some(body) = self.symbol_table.get(name.as_str()) { + let mut refs = IndexSet::new(); + collect_unguarded_refs(body, scc_set, &mut refs); + adj.insert(name.clone(), refs); + } + } + + // Detect cycle + let mut visited = IndexSet::new(); + let mut stack = IndexSet::new(); + + for start_node in scc { + if Self::detect_cycle(start_node, &adj, &mut visited, &mut stack) { + let last = stack.last().unwrap().clone(); + let index = stack.get_index_of(&last).unwrap(); + return Some(stack.iter().skip(index).cloned().collect()); + } + } + + None + } + + fn detect_cycle( + node: &String, + adj: &IndexMap>, + visited: &mut IndexSet, + stack: &mut IndexSet, + ) -> bool { + if stack.contains(node) { + return true; + } + if visited.contains(node) { + return false; + } + + visited.insert(node.clone()); + stack.insert(node.clone()); + + if let Some(neighbors) = adj.get(node) { + for neighbor in neighbors { + if Self::detect_cycle(neighbor, adj, visited, stack) { + return true; + } + } + } + + stack.pop(); + false + } + fn find_def_by_name(&self, name: &str) -> Option { self.ast .defs() @@ -130,6 +197,12 @@ impl Query<'_> { find_ref_in_expr(&body, to) } + fn find_unguarded_reference_location(&self, from: &str, to: &str) -> Option { + let def = self.find_def_by_name(from)?; + let body = def.body()?; + find_unguarded_ref_in_expr(&body, to) + } + fn build_self_ref_chain(&self, name: &str) -> Vec<(TextRange, String)> { self.find_reference_location(name, name) .map(|range| vec![(range, format!("`{}` references itself", name))]) @@ -137,6 +210,8 @@ impl Query<'_> { } fn build_cycle_chain(&self, scc: &[String]) -> Vec<(TextRange, String)> { + // Since Tarjan's sccs are not guaranteed to be ordered as a cycle, + // we need to find the cycle path explicitly. let scc_set: IndexSet<&str> = scc.iter().map(|s| s.as_str()).collect(); let mut visited = IndexSet::new(); let mut path = Vec::new(); @@ -189,6 +264,39 @@ impl Query<'_> { .collect() } + fn build_unguarded_chain(&self, cycle: &[String]) -> Vec<(TextRange, String)> { + if cycle.len() == 1 { + return self + .find_unguarded_reference_location(&cycle[0], &cycle[0]) + .map(|range| vec![(range, format!("`{}` references itself", cycle[0]))]) + .unwrap_or_default(); + } + self.build_chain_generic(cycle, |from, to| { + self.find_unguarded_reference_location(from, to) + }) + } + + fn build_chain_generic(&self, path_nodes: &[String], find_loc: F) -> Vec<(TextRange, String)> + where + F: Fn(&str, &str) -> Option, + { + path_nodes + .iter() + .enumerate() + .filter_map(|(i, from)| { + let to = &path_nodes[(i + 1) % path_nodes.len()]; + find_loc(from, to).map(|range| { + let msg = if i == path_nodes.len() - 1 { + format!("`{}` references `{}` (completing cycle)", from, to) + } else { + format!("`{}` references `{}`", from, to) + }; + (range, msg) + }) + }) + .collect() + } + fn emit_recursion_error( &mut self, primary_name: &str, @@ -250,6 +358,24 @@ fn expr_has_escape(expr: &Expr, scc: &IndexSet<&str>) -> bool { } } +fn expr_guarantees_consumption(expr: &Expr) -> bool { + match expr { + Expr::NamedNode(_) | Expr::AnonymousNode(_) => true, + Expr::Ref(_) => false, + Expr::AltExpr(_) => expr.children().iter().all(expr_guarantees_consumption), + Expr::SeqExpr(_) => expr.children().iter().any(expr_guarantees_consumption), + Expr::QuantifiedExpr(q) => { + !q.is_optional() + && q.inner() + .map(|i| expr_guarantees_consumption(&i)) + .unwrap_or(false) + } + Expr::CapturedExpr(_) | Expr::FieldExpr(_) => { + expr.children().iter().all(expr_guarantees_consumption) + } + } +} + fn collect_refs(expr: &Expr) -> IndexSet { let mut refs = IndexSet::new(); collect_refs_into(expr, &mut refs); @@ -268,6 +394,42 @@ fn collect_refs_into(expr: &Expr, refs: &mut IndexSet) { } } +fn collect_unguarded_refs(expr: &Expr, scc: &IndexSet<&str>, refs: &mut IndexSet) { + match expr { + Expr::Ref(r) => { + if let Some(name) = r.name().filter(|n| scc.contains(n.text())) { + refs.insert(name.text().to_string()); + } + } + Expr::NamedNode(_) | Expr::AnonymousNode(_) => { + // Consumes input, so guards recursion. Do not collect refs inside. + } + Expr::AltExpr(_) => { + for c in expr.children() { + collect_unguarded_refs(&c, scc, refs); + } + } + Expr::SeqExpr(_) => { + for c in expr.children() { + collect_unguarded_refs(&c, scc, refs); + if expr_guarantees_consumption(&c) { + break; + } + } + } + Expr::QuantifiedExpr(q) => { + if let Some(inner) = q.inner() { + collect_unguarded_refs(&inner, scc, refs); + } + } + Expr::CapturedExpr(_) | Expr::FieldExpr(_) => { + for c in expr.children() { + collect_unguarded_refs(&c, scc, refs); + } + } + } +} + fn find_ref_in_expr(expr: &Expr, target: &str) -> Option { if let Expr::Ref(r) = expr { let name_token = r.name()?; @@ -280,3 +442,35 @@ fn find_ref_in_expr(expr: &Expr, target: &str) -> Option { .iter() .find_map(|child| find_ref_in_expr(child, target)) } + +fn find_unguarded_ref_in_expr(expr: &Expr, target: &str) -> Option { + match expr { + Expr::Ref(r) => r + .name() + .filter(|n| n.text() == target) + .map(|n| n.text_range()), + Expr::NamedNode(_) | Expr::AnonymousNode(_) => None, + Expr::AltExpr(_) => expr + .children() + .iter() + .find_map(|c| find_unguarded_ref_in_expr(c, target)), + Expr::SeqExpr(_) => { + for c in expr.children() { + if let Some(range) = find_unguarded_ref_in_expr(&c, target) { + return Some(range); + } + if expr_guarantees_consumption(&c) { + return None; + } + } + None + } + Expr::QuantifiedExpr(q) => q + .inner() + .and_then(|i| find_unguarded_ref_in_expr(&i, target)), + Expr::CapturedExpr(_) | Expr::FieldExpr(_) => expr + .children() + .iter() + .find_map(|c| find_unguarded_ref_in_expr(c, target)), + } +} diff --git a/crates/plotnik-lib/src/query/recursion_tests.rs b/crates/plotnik-lib/src/query/recursion_tests.rs index 81fadca3..f7592185 100644 --- a/crates/plotnik-lib/src/query/recursion_tests.rs +++ b/crates/plotnik-lib/src/query/recursion_tests.rs @@ -42,7 +42,16 @@ fn no_escape_via_plus() { fn escape_via_empty_tree() { let query = Query::try_from("E = [(call) (E)]").unwrap(); - assert!(query.is_valid()); + assert!(!query.is_valid()); + + insta::assert_snapshot!(query.dump_diagnostics(), @r" + error: infinite recursion: cycle `E` → `E` has no escape path + | + 1 | E = [(call) (E)] + | ^ + | | + | `E` references itself + "); } #[test] @@ -343,39 +352,80 @@ fn entry_point_uses_recursive_def() { #[test] fn direct_self_ref_in_alternation() { + // Left-recursion: E calls E without consuming anything. + // Has escape path (x), but recursive path is unguarded. let query = Query::try_from("E = [(E) (x)]").unwrap(); - assert!(query.is_valid()); + assert!(!query.is_valid()); + + insta::assert_snapshot!(query.dump_diagnostics(), @r" + error: infinite recursion: cycle `E` → `E` has no escape path + | + 1 | E = [(E) (x)] + | ^ + | | + | `E` references itself + "); } #[test] fn escape_via_literal_string() { + // Left-recursion: A calls A without consuming. let input = indoc! {r#" - A = [(A) "escape"] + A = [(A) 'escape'] "#}; let query = Query::try_from(input).unwrap(); - assert!(query.is_valid()); + assert!(!query.is_valid()); + + insta::assert_snapshot!(query.dump_diagnostics(), @r" + error: infinite recursion: cycle `A` → `A` has no escape path + | + 1 | A = [(A) 'escape'] + | ^ + | | + | `A` references itself + "); } #[test] fn escape_via_wildcard() { + // Left-recursion let input = indoc! {r#" A = [(A) _] "#}; let query = Query::try_from(input).unwrap(); - assert!(query.is_valid()); + assert!(!query.is_valid()); + + insta::assert_snapshot!(query.dump_diagnostics(), @r" + error: infinite recursion: cycle `A` → `A` has no escape path + | + 1 | A = [(A) _] + | ^ + | | + | `A` references itself + "); } #[test] fn escape_via_childless_tree() { + // Left-recursion let input = indoc! {r#" A = [(A) (leaf)] "#}; let query = Query::try_from(input).unwrap(); - assert!(query.is_valid()); + assert!(!query.is_valid()); + + insta::assert_snapshot!(query.dump_diagnostics(), @r" + error: infinite recursion: cycle `A` → `A` has no escape path + | + 1 | A = [(A) (leaf)] + | ^ + | | + | `A` references itself + "); } #[test] @@ -426,3 +476,40 @@ fn ref_in_quantifier_plus_no_escape() { assert!(!query.is_valid()); } + +#[test] +fn unguarded_recursion_simple() { + let input = indoc! {r#" + A = [(A) (foo)] + "#}; + let query = Query::try_from(input).unwrap(); + + assert!(!query.is_valid()); + + insta::assert_snapshot!(query.dump_diagnostics(), @r" + error: infinite recursion: cycle `A` → `A` has no escape path + | + 1 | A = [(A) (foo)] + | ^ + | | + | `A` references itself + "); +} + +#[test] +fn unguarded_mutual_recursion() { + let input = indoc! {r#" + A = [(B) (x)] + B = (A) + "#}; + let query = Query::try_from(input).unwrap(); + + assert!(!query.is_valid()); + + insta::assert_snapshot!(query.dump_diagnostics(), @r" + error: infinite recursion: cycle `A` → `A` has no escape path + | + 1 | A = [(B) (x)] + | ^ + "); +} From eb2cffcb6708b50755bc80e93e0295381d8be414 Mon Sep 17 00:00:00 2001 From: Sergei Zharinov Date: Sun, 7 Dec 2025 21:54:45 -0300 Subject: [PATCH 2/7] Update recursion_tests.rs --- .../plotnik-lib/src/query/recursion_tests.rs | 71 +++++++++---------- 1 file changed, 33 insertions(+), 38 deletions(-) diff --git a/crates/plotnik-lib/src/query/recursion_tests.rs b/crates/plotnik-lib/src/query/recursion_tests.rs index f7592185..d80843e9 100644 --- a/crates/plotnik-lib/src/query/recursion_tests.rs +++ b/crates/plotnik-lib/src/query/recursion_tests.rs @@ -2,28 +2,28 @@ use crate::Query; use indoc::indoc; #[test] -fn escape_via_alternation() { +fn valid_recursion_with_alternation_base_case() { let query = Query::try_from("E = [(x) (call (E))]").unwrap(); assert!(query.is_valid()); } #[test] -fn escape_via_optional() { +fn valid_recursion_with_optional() { let query = Query::try_from("E = (call (E)?)").unwrap(); assert!(query.is_valid()); } #[test] -fn escape_via_star() { +fn valid_recursion_with_star() { let query = Query::try_from("E = (call (E)*)").unwrap(); assert!(query.is_valid()); } #[test] -fn no_escape_via_plus() { +fn invalid_recursion_with_plus() { let query = Query::try_from("E = (call (E)+)").unwrap(); assert!(!query.is_valid()); @@ -39,7 +39,7 @@ fn no_escape_via_plus() { } #[test] -fn escape_via_empty_tree() { +fn invalid_unguarded_recursion_in_alternation() { let query = Query::try_from("E = [(call) (E)]").unwrap(); assert!(!query.is_valid()); @@ -55,14 +55,14 @@ fn escape_via_empty_tree() { } #[test] -fn lazy_quantifiers_same_as_greedy() { +fn validity_of_lazy_quantifiers_matches_greedy() { assert!(Query::try_from("E = (call (E)??)").unwrap().is_valid()); assert!(Query::try_from("E = (call (E)*?)").unwrap().is_valid()); assert!(!Query::try_from("E = (call (E)+?)").unwrap().is_valid()); } #[test] -fn recursion_in_tree_child() { +fn invalid_mandatory_recursion_in_tree_child() { let query = Query::try_from("E = (call (E))").unwrap(); assert!(!query.is_valid()); @@ -78,7 +78,7 @@ fn recursion_in_tree_child() { } #[test] -fn recursion_in_field() { +fn invalid_mandatory_recursion_in_field() { let query = Query::try_from("E = (call body: (E))").unwrap(); assert!(!query.is_valid()); @@ -94,7 +94,7 @@ fn recursion_in_field() { } #[test] -fn recursion_in_capture() { +fn invalid_mandatory_recursion_in_capture() { let query = Query::try_from("E = (call (E) @inner)").unwrap(); assert!(!query.is_valid()); @@ -110,7 +110,7 @@ fn recursion_in_capture() { } #[test] -fn recursion_in_sequence() { +fn invalid_mandatory_recursion_in_sequence() { let query = Query::try_from("E = (call {(a) (E)})").unwrap(); assert!(!query.is_valid()); @@ -126,14 +126,14 @@ fn recursion_in_sequence() { } #[test] -fn recursion_through_multiple_children() { +fn valid_recursion_with_base_case_and_descent() { let query = Query::try_from("E = [(x) (call (a) (E))]").unwrap(); assert!(query.is_valid()); } #[test] -fn mutual_recursion_no_escape() { +fn invalid_mutual_recursion_without_base_case() { let input = indoc! {r#" A = (foo (B)) B = (bar (A)) @@ -155,7 +155,7 @@ fn mutual_recursion_no_escape() { } #[test] -fn mutual_recursion_one_has_escape() { +fn valid_mutual_recursion_with_base_case() { let input = indoc! {r#" A = [(x) (foo (B))] B = (bar (A)) @@ -166,7 +166,7 @@ fn mutual_recursion_one_has_escape() { } #[test] -fn three_way_cycle_no_escape() { +fn invalid_three_way_mutual_recursion() { let input = indoc! {r#" A = (a (B)) B = (b (C)) @@ -191,7 +191,7 @@ fn three_way_cycle_no_escape() { } #[test] -fn three_way_cycle_one_has_escape() { +fn valid_three_way_mutual_recursion_with_base_case() { let input = indoc! {r#" A = [(x) (a (B))] B = (b (C)) @@ -203,7 +203,7 @@ fn three_way_cycle_one_has_escape() { } #[test] -fn diamond_dependency() { +fn invalid_diamond_dependency_recursion() { let input = indoc! {r#" A = (a [(B) (C)]) B = (b (D)) @@ -230,7 +230,7 @@ fn diamond_dependency() { } #[test] -fn cycle_ref_in_field() { +fn invalid_mutual_recursion_via_field() { let input = indoc! {r#" A = (foo body: (B)) B = (bar (A)) @@ -252,7 +252,7 @@ fn cycle_ref_in_field() { } #[test] -fn cycle_ref_in_capture() { +fn invalid_mutual_recursion_via_capture() { let input = indoc! {r#" A = (foo (B) @cap) B = (bar (A)) @@ -274,7 +274,7 @@ fn cycle_ref_in_capture() { } #[test] -fn cycle_ref_in_sequence() { +fn invalid_mutual_recursion_via_sequence() { let input = indoc! {r#" A = (foo {(x) (B)}) B = (bar (A)) @@ -296,7 +296,7 @@ fn cycle_ref_in_sequence() { } #[test] -fn cycle_with_quantifier_escape() { +fn valid_mutual_recursion_with_optional_quantifier() { let input = indoc! {r#" A = (foo (B)?) B = (bar (A)) @@ -307,7 +307,7 @@ fn cycle_with_quantifier_escape() { } #[test] -fn cycle_with_plus_no_escape() { +fn invalid_mutual_recursion_with_plus_quantifier() { let input = indoc! {r#" A = (foo (B)+) B = (bar (A)) @@ -329,7 +329,7 @@ fn cycle_with_plus_no_escape() { } #[test] -fn non_recursive_reference() { +fn valid_non_recursive_reference() { let input = indoc! {r#" Leaf = (identifier) Tree = (call (Leaf)) @@ -340,7 +340,7 @@ fn non_recursive_reference() { } #[test] -fn entry_point_uses_recursive_def() { +fn valid_entry_point_using_recursive_def() { let input = indoc! {r#" E = [(x) (call (E))] (program (E)) @@ -351,9 +351,7 @@ fn entry_point_uses_recursive_def() { } #[test] -fn direct_self_ref_in_alternation() { - // Left-recursion: E calls E without consuming anything. - // Has escape path (x), but recursive path is unguarded. +fn invalid_direct_left_recursion_in_alternation() { let query = Query::try_from("E = [(E) (x)]").unwrap(); assert!(!query.is_valid()); @@ -369,8 +367,7 @@ fn direct_self_ref_in_alternation() { } #[test] -fn escape_via_literal_string() { - // Left-recursion: A calls A without consuming. +fn invalid_unguarded_left_recursion_branch() { let input = indoc! {r#" A = [(A) 'escape'] "#}; @@ -389,8 +386,7 @@ fn escape_via_literal_string() { } #[test] -fn escape_via_wildcard() { - // Left-recursion +fn invalid_unguarded_left_recursion_with_wildcard_alt() { let input = indoc! {r#" A = [(A) _] "#}; @@ -409,8 +405,7 @@ fn escape_via_wildcard() { } #[test] -fn escape_via_childless_tree() { - // Left-recursion +fn invalid_unguarded_left_recursion_with_tree_alt() { let input = indoc! {r#" A = [(A) (leaf)] "#}; @@ -429,7 +424,7 @@ fn escape_via_childless_tree() { } #[test] -fn escape_via_anchor() { +fn valid_recursion_guarded_by_anchor() { let input = indoc! {r#" A = (foo . [(A) (x)]) "#}; @@ -439,7 +434,7 @@ fn escape_via_anchor() { } #[test] -fn no_escape_tree_all_recursive() { +fn invalid_mandatory_recursion_direct_child() { let input = indoc! {r#" A = (foo (A)) "#}; @@ -458,7 +453,7 @@ fn no_escape_tree_all_recursive() { } #[test] -fn escape_in_capture_inner() { +fn valid_recursion_with_capture_base_case() { let input = indoc! {r#" A = [(x)@cap (foo (A))] "#}; @@ -468,7 +463,7 @@ fn escape_in_capture_inner() { } #[test] -fn ref_in_quantifier_plus_no_escape() { +fn invalid_mandatory_recursion_nested_plus() { let input = indoc! {r#" A = (foo (A)+) "#}; @@ -478,7 +473,7 @@ fn ref_in_quantifier_plus_no_escape() { } #[test] -fn unguarded_recursion_simple() { +fn invalid_simple_unguarded_recursion() { let input = indoc! {r#" A = [(A) (foo)] "#}; @@ -497,7 +492,7 @@ fn unguarded_recursion_simple() { } #[test] -fn unguarded_mutual_recursion() { +fn invalid_unguarded_mutual_recursion_chain() { let input = indoc! {r#" A = [(B) (x)] B = (A) From d84268c9338da985e44e53b0922cb77b16a0583e Mon Sep 17 00:00:00 2001 From: Sergei Zharinov Date: Sun, 7 Dec 2025 22:09:47 -0300 Subject: [PATCH 3/7] Update recursion_tests.rs --- .../plotnik-lib/src/query/recursion_tests.rs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/crates/plotnik-lib/src/query/recursion_tests.rs b/crates/plotnik-lib/src/query/recursion_tests.rs index d80843e9..c7f17d8c 100644 --- a/crates/plotnik-lib/src/query/recursion_tests.rs +++ b/crates/plotnik-lib/src/query/recursion_tests.rs @@ -366,6 +366,22 @@ fn invalid_direct_left_recursion_in_alternation() { "); } +#[test] +fn invalid_direct_left_recursion_in_tagged_alternation() { + let query = Query::try_from("E = [Left: (E) Right: (x)]").unwrap(); + + assert!(!query.is_valid()); + + insta::assert_snapshot!(query.dump_diagnostics(), @r" + error: infinite recursion: cycle `E` → `E` has no escape path + | + 1 | E = [Left: (E) Right: (x)] + | ^ + | | + | `E` references itself + "); +} + #[test] fn invalid_unguarded_left_recursion_branch() { let input = indoc! {r#" @@ -389,7 +405,7 @@ fn invalid_unguarded_left_recursion_branch() { fn invalid_unguarded_left_recursion_with_wildcard_alt() { let input = indoc! {r#" A = [(A) _] - "#}; + "#}; let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); From 15257fd4b6f43cbbdc4e4afb1c90cda9f7320962 Mon Sep 17 00:00:00 2001 From: Sergei Zharinov Date: Sun, 7 Dec 2025 22:27:44 -0300 Subject: [PATCH 4/7] Add direct recursion diagnostic --- crates/plotnik-lib/src/diagnostics/message.rs | 3 ++ crates/plotnik-lib/src/query/recursion.rs | 31 +++++++++++++++++- .../plotnik-lib/src/query/recursion_tests.rs | 32 ++++++++++++++----- 3 files changed, 57 insertions(+), 9 deletions(-) diff --git a/crates/plotnik-lib/src/diagnostics/message.rs b/crates/plotnik-lib/src/diagnostics/message.rs index dfad769a..bb0e5ff6 100644 --- a/crates/plotnik-lib/src/diagnostics/message.rs +++ b/crates/plotnik-lib/src/diagnostics/message.rs @@ -58,6 +58,7 @@ pub enum DiagnosticKind { UndefinedReference, MixedAltBranches, RecursionNoEscape, + DirectRecursion, FieldSequenceValue, // Link pass - grammar validation @@ -162,6 +163,7 @@ impl DiagnosticKind { Self::UndefinedReference => "undefined reference", Self::MixedAltBranches => "cannot mix labeled and unlabeled branches", Self::RecursionNoEscape => "infinite recursion detected", + Self::DirectRecursion => "direct recursion: query will stuck without matching anything", Self::FieldSequenceValue => "field must match exactly one node", // Link pass - grammar validation @@ -200,6 +202,7 @@ impl DiagnosticKind { // Recursion with cycle path Self::RecursionNoEscape => "infinite recursion: {}".to_string(), + Self::DirectRecursion => "direct recursion: {}".to_string(), // Alternation mixing Self::MixedAltBranches => "cannot mix labeled and unlabeled branches: {}".to_string(), diff --git a/crates/plotnik-lib/src/query/recursion.rs b/crates/plotnik-lib/src/query/recursion.rs index e6124723..72638be4 100644 --- a/crates/plotnik-lib/src/query/recursion.rs +++ b/crates/plotnik-lib/src/query/recursion.rs @@ -41,7 +41,7 @@ impl Query<'_> { // Ensure every recursive cycle consumes at least one node. if let Some(cycle) = self.find_unguarded_cycle(&scc, &scc_set) { let chain = self.build_unguarded_chain(&cycle); - self.emit_recursion_error(&cycle[0], &cycle, chain); + self.emit_direct_recursion_error(&cycle[0], &cycle, chain); } } } @@ -325,6 +325,35 @@ impl Query<'_> { builder = builder.related_to(rel_msg, rel_range); } + builder.emit(); + } + fn emit_direct_recursion_error( + &mut self, + primary_name: &str, + scc: &[String], + related: Vec<(TextRange, String)>, + ) { + let cycle_str = if scc.len() == 1 { + format!("`{}` → `{}`", primary_name, primary_name) + } else { + let mut cycle: Vec<_> = scc.iter().map(|s| format!("`{}`", s)).collect(); + cycle.push(format!("`{}`", scc[0])); + cycle.join(" → ") + }; + + let range = related + .first() + .map(|(r, _)| *r) + .unwrap_or_else(|| TextRange::empty(0.into())); + + let mut builder = self + .recursion_diagnostics + .report(DiagnosticKind::DirectRecursion, range); + + for (rel_range, rel_msg) in related { + builder = builder.related_to(rel_msg, rel_range); + } + builder.emit(); } } diff --git a/crates/plotnik-lib/src/query/recursion_tests.rs b/crates/plotnik-lib/src/query/recursion_tests.rs index c7f17d8c..99fc0722 100644 --- a/crates/plotnik-lib/src/query/recursion_tests.rs +++ b/crates/plotnik-lib/src/query/recursion_tests.rs @@ -45,7 +45,7 @@ fn invalid_unguarded_recursion_in_alternation() { assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: infinite recursion: cycle `E` → `E` has no escape path + error: direct recursion: query will stuck without matching anything | 1 | E = [(call) (E)] | ^ @@ -357,7 +357,7 @@ fn invalid_direct_left_recursion_in_alternation() { assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: infinite recursion: cycle `E` → `E` has no escape path + error: direct recursion: query will stuck without matching anything | 1 | E = [(E) (x)] | ^ @@ -366,6 +366,22 @@ fn invalid_direct_left_recursion_in_alternation() { "); } +#[test] +fn invalid_direct_right_recursion_in_alternation() { + let query = Query::try_from("E = [(x) (E)]").unwrap(); + + assert!(!query.is_valid()); + + insta::assert_snapshot!(query.dump_diagnostics(), @r" + error: direct recursion: query will stuck without matching anything + | + 1 | E = [(x) (E)] + | ^ + | | + | `E` references itself + "); +} + #[test] fn invalid_direct_left_recursion_in_tagged_alternation() { let query = Query::try_from("E = [Left: (E) Right: (x)]").unwrap(); @@ -373,7 +389,7 @@ fn invalid_direct_left_recursion_in_tagged_alternation() { assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: infinite recursion: cycle `E` → `E` has no escape path + error: direct recursion: query will stuck without matching anything | 1 | E = [Left: (E) Right: (x)] | ^ @@ -392,7 +408,7 @@ fn invalid_unguarded_left_recursion_branch() { assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: infinite recursion: cycle `A` → `A` has no escape path + error: direct recursion: query will stuck without matching anything | 1 | A = [(A) 'escape'] | ^ @@ -411,7 +427,7 @@ fn invalid_unguarded_left_recursion_with_wildcard_alt() { assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: infinite recursion: cycle `A` → `A` has no escape path + error: direct recursion: query will stuck without matching anything | 1 | A = [(A) _] | ^ @@ -430,7 +446,7 @@ fn invalid_unguarded_left_recursion_with_tree_alt() { assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: infinite recursion: cycle `A` → `A` has no escape path + error: direct recursion: query will stuck without matching anything | 1 | A = [(A) (leaf)] | ^ @@ -498,7 +514,7 @@ fn invalid_simple_unguarded_recursion() { assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: infinite recursion: cycle `A` → `A` has no escape path + error: direct recursion: query will stuck without matching anything | 1 | A = [(A) (foo)] | ^ @@ -518,7 +534,7 @@ fn invalid_unguarded_mutual_recursion_chain() { assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: infinite recursion: cycle `A` → `A` has no escape path + error: direct recursion: query will stuck without matching anything | 1 | A = [(B) (x)] | ^ From d0a5cfce5e40a98d8ea30d82e9f6e6331b33ad82 Mon Sep 17 00:00:00 2001 From: Sergei Zharinov Date: Sun, 7 Dec 2025 22:39:07 -0300 Subject: [PATCH 5/7] Improve report --- crates/plotnik-lib/src/query/recursion.rs | 32 ++++--- .../plotnik-lib/src/query/recursion_tests.rs | 90 +++++++++++-------- 2 files changed, 76 insertions(+), 46 deletions(-) diff --git a/crates/plotnik-lib/src/query/recursion.rs b/crates/plotnik-lib/src/query/recursion.rs index 72638be4..05170d7c 100644 --- a/crates/plotnik-lib/src/query/recursion.rs +++ b/crates/plotnik-lib/src/query/recursion.rs @@ -147,9 +147,8 @@ impl Query<'_> { let mut stack = IndexSet::new(); for start_node in scc { - if Self::detect_cycle(start_node, &adj, &mut visited, &mut stack) { - let last = stack.last().unwrap().clone(); - let index = stack.get_index_of(&last).unwrap(); + if let Some(target) = Self::detect_cycle(start_node, &adj, &mut visited, &mut stack) { + let index = stack.get_index_of(&target).unwrap(); return Some(stack.iter().skip(index).cloned().collect()); } } @@ -162,12 +161,12 @@ impl Query<'_> { adj: &IndexMap>, visited: &mut IndexSet, stack: &mut IndexSet, - ) -> bool { + ) -> Option { if stack.contains(node) { - return true; + return Some(node.clone()); } if visited.contains(node) { - return false; + return None; } visited.insert(node.clone()); @@ -175,14 +174,14 @@ impl Query<'_> { if let Some(neighbors) = adj.get(node) { for neighbor in neighbors { - if Self::detect_cycle(neighbor, adj, visited, stack) { - return true; + if let Some(target) = Self::detect_cycle(neighbor, adj, visited, stack) { + return Some(target); } } } stack.pop(); - false + None } fn find_def_by_name(&self, name: &str) -> Option { @@ -346,14 +345,27 @@ impl Query<'_> { .map(|(r, _)| *r) .unwrap_or_else(|| TextRange::empty(0.into())); + let def_range = self + .find_def_by_name(primary_name) + .and_then(|def| def.name()) + .map(|n| n.text_range()); + let mut builder = self .recursion_diagnostics - .report(DiagnosticKind::DirectRecursion, range); + .report(DiagnosticKind::DirectRecursion, range) + .message(format!( + "cycle {} will stuck without matching anything", + cycle_str + )); for (rel_range, rel_msg) in related { builder = builder.related_to(rel_msg, rel_range); } + if let Some(range) = def_range { + builder = builder.related_to(format!("`{}` is defined here", primary_name), range); + } + builder.emit(); } } diff --git a/crates/plotnik-lib/src/query/recursion_tests.rs b/crates/plotnik-lib/src/query/recursion_tests.rs index 99fc0722..d33421d5 100644 --- a/crates/plotnik-lib/src/query/recursion_tests.rs +++ b/crates/plotnik-lib/src/query/recursion_tests.rs @@ -45,12 +45,13 @@ fn invalid_unguarded_recursion_in_alternation() { assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: direct recursion: query will stuck without matching anything + error: direct recursion: cycle `E` → `E` will stuck without matching anything | 1 | E = [(call) (E)] - | ^ - | | - | `E` references itself + | - ^ + | | | + | | `E` references itself + | `E` is defined here "); } @@ -357,12 +358,13 @@ fn invalid_direct_left_recursion_in_alternation() { assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: direct recursion: query will stuck without matching anything + error: direct recursion: cycle `E` → `E` will stuck without matching anything | 1 | E = [(E) (x)] - | ^ - | | - | `E` references itself + | - ^ + | | | + | | `E` references itself + | `E` is defined here "); } @@ -373,12 +375,13 @@ fn invalid_direct_right_recursion_in_alternation() { assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: direct recursion: query will stuck without matching anything + error: direct recursion: cycle `E` → `E` will stuck without matching anything | 1 | E = [(x) (E)] - | ^ - | | - | `E` references itself + | - ^ + | | | + | | `E` references itself + | `E` is defined here "); } @@ -389,12 +392,13 @@ fn invalid_direct_left_recursion_in_tagged_alternation() { assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: direct recursion: query will stuck without matching anything + error: direct recursion: cycle `E` → `E` will stuck without matching anything | 1 | E = [Left: (E) Right: (x)] - | ^ - | | - | `E` references itself + | - ^ + | | | + | | `E` references itself + | `E` is defined here "); } @@ -408,12 +412,13 @@ fn invalid_unguarded_left_recursion_branch() { assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: direct recursion: query will stuck without matching anything + error: direct recursion: cycle `A` → `A` will stuck without matching anything | 1 | A = [(A) 'escape'] - | ^ - | | - | `A` references itself + | - ^ + | | | + | | `A` references itself + | `A` is defined here "); } @@ -427,12 +432,13 @@ fn invalid_unguarded_left_recursion_with_wildcard_alt() { assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: direct recursion: query will stuck without matching anything + error: direct recursion: cycle `A` → `A` will stuck without matching anything | 1 | A = [(A) _] - | ^ - | | - | `A` references itself + | - ^ + | | | + | | `A` references itself + | `A` is defined here "); } @@ -446,12 +452,13 @@ fn invalid_unguarded_left_recursion_with_tree_alt() { assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: direct recursion: query will stuck without matching anything + error: direct recursion: cycle `A` → `A` will stuck without matching anything | 1 | A = [(A) (leaf)] - | ^ - | | - | `A` references itself + | - ^ + | | | + | | `A` references itself + | `A` is defined here "); } @@ -507,19 +514,25 @@ fn invalid_mandatory_recursion_nested_plus() { #[test] fn invalid_simple_unguarded_recursion() { let input = indoc! {r#" - A = [(A) (foo)] + A = [ + (foo) + (A) + ] "#}; let query = Query::try_from(input).unwrap(); assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: direct recursion: query will stuck without matching anything + error: direct recursion: cycle `A` → `A` will stuck without matching anything | - 1 | A = [(A) (foo)] - | ^ - | | - | `A` references itself + 1 | A = [ + | - `A` is defined here + 2 | (foo) + 3 | (A) + | ^ + | | + | `A` references itself "); } @@ -534,9 +547,14 @@ fn invalid_unguarded_mutual_recursion_chain() { assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: direct recursion: query will stuck without matching anything + error: direct recursion: cycle `B` → `A` → `B` will stuck without matching anything | 1 | A = [(B) (x)] - | ^ + | - `A` references `B` (completing cycle) + 2 | B = (A) + | - ^ + | | | + | | `B` references `A` + | `B` is defined here "); } From 1f8aa6fe388b179885ee94341c0fc42880653fb0 Mon Sep 17 00:00:00 2001 From: Sergei Zharinov Date: Sun, 7 Dec 2025 22:47:07 -0300 Subject: [PATCH 6/7] Fix --- crates/plotnik-lib/src/query/recursion.rs | 23 +++- .../plotnik-lib/src/query/recursion_tests.rs | 101 +++++++++--------- 2 files changed, 68 insertions(+), 56 deletions(-) diff --git a/crates/plotnik-lib/src/query/recursion.rs b/crates/plotnik-lib/src/query/recursion.rs index 05170d7c..1aa8d4f0 100644 --- a/crates/plotnik-lib/src/query/recursion.rs +++ b/crates/plotnik-lib/src/query/recursion.rs @@ -315,6 +315,14 @@ impl Query<'_> { .map(|(r, _)| *r) .unwrap_or_else(|| TextRange::empty(0.into())); + let def_range = if scc.len() > 1 { + self.find_def_by_name(primary_name) + .and_then(|def| def.name()) + .map(|n| n.text_range()) + } else { + None + }; + let mut builder = self .recursion_diagnostics .report(DiagnosticKind::RecursionNoEscape, range) @@ -324,6 +332,10 @@ impl Query<'_> { builder = builder.related_to(rel_msg, rel_range); } + if let Some(range) = def_range { + builder = builder.related_to(format!("`{}` is defined here", primary_name), range); + } + builder.emit(); } fn emit_direct_recursion_error( @@ -345,10 +357,13 @@ impl Query<'_> { .map(|(r, _)| *r) .unwrap_or_else(|| TextRange::empty(0.into())); - let def_range = self - .find_def_by_name(primary_name) - .and_then(|def| def.name()) - .map(|n| n.text_range()); + let def_range = if scc.len() > 1 { + self.find_def_by_name(primary_name) + .and_then(|def| def.name()) + .map(|n| n.text_range()) + } else { + None + }; let mut builder = self .recursion_diagnostics diff --git a/crates/plotnik-lib/src/query/recursion_tests.rs b/crates/plotnik-lib/src/query/recursion_tests.rs index d33421d5..b74eb427 100644 --- a/crates/plotnik-lib/src/query/recursion_tests.rs +++ b/crates/plotnik-lib/src/query/recursion_tests.rs @@ -48,10 +48,9 @@ fn invalid_unguarded_recursion_in_alternation() { error: direct recursion: cycle `E` → `E` will stuck without matching anything | 1 | E = [(call) (E)] - | - ^ - | | | - | | `E` references itself - | `E` is defined here + | ^ + | | + | `E` references itself "); } @@ -149,9 +148,10 @@ fn invalid_mutual_recursion_without_base_case() { 1 | A = (foo (B)) | - `A` references `B` (completing cycle) 2 | B = (bar (A)) - | ^ - | | - | `B` references `A` + | - ^ + | | | + | | `B` references `A` + | `B` is defined here "); } @@ -185,9 +185,10 @@ fn invalid_three_way_mutual_recursion() { 2 | B = (b (C)) | - `B` references `C` (completing cycle) 3 | C = (c (A)) - | ^ - | | - | `C` references `A` + | - ^ + | | | + | | `C` references `A` + | `C` is defined here "); } @@ -222,9 +223,10 @@ fn invalid_diamond_dependency_recursion() { | - `A` references `C` (completing cycle) 2 | B = (b (D)) 3 | C = (c (D)) - | ^ - | | - | `C` references `D` + | - ^ + | | | + | | `C` references `D` + | `C` is defined here 4 | D = (d (A)) | - `D` references `A` "); @@ -246,9 +248,10 @@ fn invalid_mutual_recursion_via_field() { 1 | A = (foo body: (B)) | - `A` references `B` (completing cycle) 2 | B = (bar (A)) - | ^ - | | - | `B` references `A` + | - ^ + | | | + | | `B` references `A` + | `B` is defined here "); } @@ -268,9 +271,10 @@ fn invalid_mutual_recursion_via_capture() { 1 | A = (foo (B) @cap) | - `A` references `B` (completing cycle) 2 | B = (bar (A)) - | ^ - | | - | `B` references `A` + | - ^ + | | | + | | `B` references `A` + | `B` is defined here "); } @@ -290,9 +294,10 @@ fn invalid_mutual_recursion_via_sequence() { 1 | A = (foo {(x) (B)}) | - `A` references `B` (completing cycle) 2 | B = (bar (A)) - | ^ - | | - | `B` references `A` + | - ^ + | | | + | | `B` references `A` + | `B` is defined here "); } @@ -323,9 +328,10 @@ fn invalid_mutual_recursion_with_plus_quantifier() { 1 | A = (foo (B)+) | - `A` references `B` (completing cycle) 2 | B = (bar (A)) - | ^ - | | - | `B` references `A` + | - ^ + | | | + | | `B` references `A` + | `B` is defined here "); } @@ -361,10 +367,9 @@ fn invalid_direct_left_recursion_in_alternation() { error: direct recursion: cycle `E` → `E` will stuck without matching anything | 1 | E = [(E) (x)] - | - ^ - | | | - | | `E` references itself - | `E` is defined here + | ^ + | | + | `E` references itself "); } @@ -378,10 +383,9 @@ fn invalid_direct_right_recursion_in_alternation() { error: direct recursion: cycle `E` → `E` will stuck without matching anything | 1 | E = [(x) (E)] - | - ^ - | | | - | | `E` references itself - | `E` is defined here + | ^ + | | + | `E` references itself "); } @@ -395,10 +399,9 @@ fn invalid_direct_left_recursion_in_tagged_alternation() { error: direct recursion: cycle `E` → `E` will stuck without matching anything | 1 | E = [Left: (E) Right: (x)] - | - ^ - | | | - | | `E` references itself - | `E` is defined here + | ^ + | | + | `E` references itself "); } @@ -415,10 +418,9 @@ fn invalid_unguarded_left_recursion_branch() { error: direct recursion: cycle `A` → `A` will stuck without matching anything | 1 | A = [(A) 'escape'] - | - ^ - | | | - | | `A` references itself - | `A` is defined here + | ^ + | | + | `A` references itself "); } @@ -435,10 +437,9 @@ fn invalid_unguarded_left_recursion_with_wildcard_alt() { error: direct recursion: cycle `A` → `A` will stuck without matching anything | 1 | A = [(A) _] - | - ^ - | | | - | | `A` references itself - | `A` is defined here + | ^ + | | + | `A` references itself "); } @@ -455,10 +456,9 @@ fn invalid_unguarded_left_recursion_with_tree_alt() { error: direct recursion: cycle `A` → `A` will stuck without matching anything | 1 | A = [(A) (leaf)] - | - ^ - | | | - | | `A` references itself - | `A` is defined here + | ^ + | | + | `A` references itself "); } @@ -526,9 +526,6 @@ fn invalid_simple_unguarded_recursion() { insta::assert_snapshot!(query.dump_diagnostics(), @r" error: direct recursion: cycle `A` → `A` will stuck without matching anything | - 1 | A = [ - | - `A` is defined here - 2 | (foo) 3 | (A) | ^ | | From 2f5a42f888aec788b6cfadc462859290ca320855 Mon Sep 17 00:00:00 2001 From: Sergei Zharinov Date: Sun, 7 Dec 2025 22:52:06 -0300 Subject: [PATCH 7/7] Fix snapshots --- crates/plotnik-lib/src/query/link_tests.rs | 19 ++++++++++++------- .../src/query/symbol_table_tests.rs | 7 ++++--- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/crates/plotnik-lib/src/query/link_tests.rs b/crates/plotnik-lib/src/query/link_tests.rs index 9032bbfe..9c9d340b 100644 --- a/crates/plotnik-lib/src/query/link_tests.rs +++ b/crates/plotnik-lib/src/query/link_tests.rs @@ -929,7 +929,7 @@ fn ref_followed_recursive_with_invalid_type() { | help: valid types for `name`: `identifier` - error: infinite recursion: cycle `Foo` → `Foo` has no escape path + error: direct recursion: cycle `Foo` → `Foo` will stuck without matching anything | 1 | Foo = [(number) (Foo)] | ^^^ @@ -950,7 +950,7 @@ fn ref_followed_recursive_valid() { assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: infinite recursion: cycle `Foo` → `Foo` has no escape path + error: direct recursion: cycle `Foo` → `Foo` will stuck without matching anything | 1 | Foo = [(identifier) (Foo)] | ^^^ @@ -972,11 +972,6 @@ fn ref_followed_mutual_recursion() { assert!(!query.is_valid()); insta::assert_snapshot!(query.dump_diagnostics(), @r" - error: infinite recursion: cycle `Foo` → `Foo` has no escape path - | - 1 | Foo = [(number) (Bar)] - | ^ - error: node type `number` is not valid for this field | 1 | Foo = [(number) (Bar)] @@ -995,6 +990,16 @@ fn ref_followed_mutual_recursion() { | ---- field `name` on `function_declaration` | help: valid types for `name`: `identifier` + + error: direct recursion: cycle `Bar` → `Foo` → `Bar` will stuck without matching anything + | + 1 | Foo = [(number) (Bar)] + | --- `Foo` references `Bar` (completing cycle) + 2 | Bar = [(string) (Foo)] + | --- ^^^ + | | | + | | `Bar` references `Foo` + | `Bar` is defined here "); } diff --git a/crates/plotnik-lib/src/query/symbol_table_tests.rs b/crates/plotnik-lib/src/query/symbol_table_tests.rs index d127c3a0..33e081a7 100644 --- a/crates/plotnik-lib/src/query/symbol_table_tests.rs +++ b/crates/plotnik-lib/src/query/symbol_table_tests.rs @@ -83,9 +83,10 @@ fn mutual_recursion() { 1 | A = (foo (B)) | - `A` references `B` (completing cycle) 2 | B = (bar (A)) - | ^ - | | - | `B` references `A` + | - ^ + | | | + | | `B` references `A` + | `B` is defined here "); }