From 44b74e6b48e9441b66deeb6e0d3ae2ced86d31a2 Mon Sep 17 00:00:00 2001 From: Sergei Zharinov Date: Wed, 3 Dec 2025 20:02:55 -0300 Subject: [PATCH] refactor: Extract all tests into dedicated files --- crates/plotnik-lib/src/ast/mod.rs | 2 + crates/plotnik-lib/src/ast/parser/error.rs | 226 ------------ .../plotnik-lib/src/ast/parser/error_tests.rs | 222 ++++++++++++ crates/plotnik-lib/src/ast/parser/mod.rs | 3 + .../parser/tests/json_serialization_tests.rs | 49 +++ .../plotnik-lib/src/ast/parser/tests/mod.rs | 54 +-- .../src/ast/parser/tests/validation/naming.rs | 86 +++++ crates/plotnik-lib/src/ast/syntax_kind.rs | 91 ----- .../plotnik-lib/src/ast/syntax_kind_tests.rs | 86 +++++ crates/plotnik-lib/src/query/alt_kind.rs | 118 ------ .../plotnik-lib/src/query/alt_kind_tests.rs | 114 ++++++ crates/plotnik-lib/src/query/errors.rs | 98 ----- crates/plotnik-lib/src/query/errors_tests.rs | 93 +++++ crates/plotnik-lib/src/query/mod.rs | 45 +-- crates/plotnik-lib/src/query/mod_tests.rs | 29 ++ crates/plotnik-lib/src/query/named_defs.rs | 290 --------------- .../plotnik-lib/src/query/named_defs_tests.rs | 286 +++++++++++++++ crates/plotnik-lib/src/query/printer.rs | 282 --------------- crates/plotnik-lib/src/query/printer_tests.rs | 277 +++++++++++++++ crates/plotnik-lib/src/query/ref_cycles.rs | 335 ------------------ .../plotnik-lib/src/query/ref_cycles_tests.rs | 331 +++++++++++++++++ 21 files changed, 1591 insertions(+), 1526 deletions(-) create mode 100644 crates/plotnik-lib/src/ast/parser/error_tests.rs create mode 100644 crates/plotnik-lib/src/ast/parser/tests/json_serialization_tests.rs create mode 100644 crates/plotnik-lib/src/ast/parser/tests/validation/naming.rs create mode 100644 crates/plotnik-lib/src/ast/syntax_kind_tests.rs create mode 100644 crates/plotnik-lib/src/query/alt_kind_tests.rs create mode 100644 crates/plotnik-lib/src/query/errors_tests.rs create mode 100644 crates/plotnik-lib/src/query/mod_tests.rs create mode 100644 crates/plotnik-lib/src/query/named_defs_tests.rs create mode 100644 crates/plotnik-lib/src/query/printer_tests.rs create mode 100644 crates/plotnik-lib/src/query/ref_cycles_tests.rs diff --git a/crates/plotnik-lib/src/ast/mod.rs b/crates/plotnik-lib/src/ast/mod.rs index 4fe04815..40acecf0 100644 --- a/crates/plotnik-lib/src/ast/mod.rs +++ b/crates/plotnik-lib/src/ast/mod.rs @@ -9,6 +9,8 @@ pub mod syntax_kind; mod lexer_tests; #[cfg(test)] mod nodes_tests; +#[cfg(test)] +mod syntax_kind_tests; pub use syntax_kind::{SyntaxKind, SyntaxNode, SyntaxToken}; diff --git a/crates/plotnik-lib/src/ast/parser/error.rs b/crates/plotnik-lib/src/ast/parser/error.rs index f7380c63..73135d9e 100644 --- a/crates/plotnik-lib/src/ast/parser/error.rs +++ b/crates/plotnik-lib/src/ast/parser/error.rs @@ -311,229 +311,3 @@ pub type SyntaxError = Diagnostic; pub fn render_errors(source: &str, errors: &[Diagnostic], path: Option<&str>) -> String { render_diagnostics(source, errors, path, RenderOptions::plain()) } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn severity_display() { - insta::assert_snapshot!(format!("{}", Severity::Error), @"error"); - insta::assert_snapshot!(format!("{}", Severity::Warning), @"warning"); - } - - #[test] - fn error_stage_display() { - insta::assert_snapshot!(format!("{}", ErrorStage::Parse), @"parse"); - insta::assert_snapshot!(format!("{}", ErrorStage::Validate), @"validate"); - insta::assert_snapshot!(format!("{}", ErrorStage::Resolve), @"resolve"); - insta::assert_snapshot!(format!("{}", ErrorStage::Escape), @"escape"); - } - - #[test] - fn diagnostic_warning_constructors() { - let warn = Diagnostic::warning(TextRange::empty(0.into()), "test warning"); - assert!(warn.is_warning()); - assert!(!warn.is_error()); - - let warn_at = Diagnostic::warning_at(5.into(), "warning at offset"); - assert!(warn_at.is_warning()); - assert_eq!(warn_at.range.start(), 5.into()); - } - - #[test] - fn diagnostic_error_at_constructor() { - let err = Diagnostic::error_at(7.into(), "error at offset"); - assert!(err.is_error()); - assert!(!err.is_warning()); - assert_eq!(err.range.start(), 7.into()); - assert_eq!(err.range.end(), 7.into()); - } - - #[test] - fn diagnostic_builders() { - let diag = Diagnostic::error(TextRange::empty(0.into()), "test") - .with_stage(ErrorStage::Resolve) - .with_fix(Fix::new("replacement", "description")) - .with_related(RelatedInfo::new(TextRange::empty(10.into()), "related")); - - assert_eq!(diag.stage, ErrorStage::Resolve); - assert!(diag.fix.is_some()); - assert_eq!(diag.related.len(), 1); - - let diag2 = Diagnostic::error(TextRange::empty(0.into()), "test").with_related_many(vec![ - RelatedInfo::new(TextRange::empty(1.into()), "first"), - RelatedInfo::new(TextRange::empty(2.into()), "second"), - ]); - assert_eq!(diag2.related.len(), 2); - } - - #[test] - fn diagnostic_display() { - let diag = Diagnostic::error(TextRange::new(5.into(), 10.into()), "test message"); - insta::assert_snapshot!(format!("{}", diag), @"error at 5..10: test message"); - - let diag_with_fix = Diagnostic::error(TextRange::empty(0.into()), "msg") - .with_fix(Fix::new("fix", "fix description")); - insta::assert_snapshot!(format!("{}", diag_with_fix), @"error at 0..0: msg (fix: fix description)"); - - let diag_with_related = Diagnostic::error(TextRange::empty(0.into()), "msg") - .with_related(RelatedInfo::new(TextRange::new(1.into(), 2.into()), "rel")); - insta::assert_snapshot!(format!("{}", diag_with_related), @"error at 0..0: msg (related: rel at 1..2)"); - } - - #[test] - fn render_options_constructors() { - let plain = RenderOptions::plain(); - assert!(!plain.colored); - - let colored = RenderOptions::colored(); - assert!(colored.colored); - - let default = RenderOptions::default(); - assert!(default.colored); - } - - #[test] - fn render_diagnostics_colored() { - let diag = Diagnostic::error(TextRange::new(0.into(), 5.into()), "test"); - let result = render_diagnostics("hello", &[diag], None, RenderOptions::colored()); - // Colored output contains ANSI escape codes - assert!(result.contains("test")); - assert!(result.contains('\x1b')); - } - - #[test] - fn render_diagnostics_empty() { - let result = render_diagnostics("source", &[], None, RenderOptions::plain()); - assert!(result.is_empty()); - } - - #[test] - fn render_diagnostics_with_path() { - let diag = Diagnostic::error(TextRange::new(0.into(), 5.into()), "test error"); - let result = render_diagnostics( - "hello world", - &[diag], - Some("test.pql"), - RenderOptions::plain(), - ); - insta::assert_snapshot!(result, @r" - error: test error - --> test.pql:1:1 - | - 1 | hello world - | ^^^^^ test error - "); - } - - #[test] - fn render_diagnostics_zero_width_span() { - let diag = Diagnostic::error(TextRange::empty(0.into()), "zero width error"); - let result = render_diagnostics("hello", &[diag], None, RenderOptions::plain()); - insta::assert_snapshot!(result, @r" - error: zero width error - | - 1 | hello - | ^ zero width error - "); - } - - #[test] - fn render_diagnostics_with_related() { - let diag = Diagnostic::error(TextRange::new(0.into(), 5.into()), "primary").with_related( - RelatedInfo::new(TextRange::new(6.into(), 10.into()), "related info"), - ); - let result = render_diagnostics("hello world!", &[diag], None, RenderOptions::plain()); - insta::assert_snapshot!(result, @r" - error: primary - | - 1 | hello world! - | ^^^^^ ---- related info - | | - | primary - "); - } - - #[test] - fn render_diagnostics_related_zero_width() { - let diag = Diagnostic::error(TextRange::new(0.into(), 5.into()), "primary").with_related( - RelatedInfo::new(TextRange::empty(6.into()), "zero width related"), - ); - let result = render_diagnostics("hello world!", &[diag], None, RenderOptions::plain()); - insta::assert_snapshot!(result, @r" - error: primary - | - 1 | hello world! - | ^^^^^ - zero width related - | | - | primary - "); - } - - #[test] - fn render_diagnostics_with_fix() { - let diag = Diagnostic::error(TextRange::new(0.into(), 5.into()), "fixable") - .with_fix(Fix::new("fixed", "apply this fix")); - let result = render_diagnostics("hello world", &[diag], None, RenderOptions::plain()); - insta::assert_snapshot!(result, @r" - error: fixable - | - 1 | hello world - | ^^^^^ fixable - | - help: apply this fix - | - 1 - hello world - 1 + fixed world - | - "); - } - - #[test] - fn render_diagnostics_multiple() { - let diag1 = Diagnostic::error(TextRange::new(0.into(), 5.into()), "first error"); - let diag2 = Diagnostic::error(TextRange::new(6.into(), 10.into()), "second error"); - let result = render_diagnostics( - "hello world!", - &[diag1, diag2], - None, - RenderOptions::plain(), - ); - insta::assert_snapshot!(result, @r" - error: first error - | - 1 | hello world! - | ^^^^^ first error - error: second error - | - 1 | hello world! - | ^^^^ second error - "); - } - - #[test] - fn render_diagnostics_warning() { - let diag = Diagnostic::warning(TextRange::new(0.into(), 5.into()), "a warning"); - let result = render_diagnostics("hello", &[diag], None, RenderOptions::plain()); - insta::assert_snapshot!(result, @r" - warning: a warning - | - 1 | hello - | ^^^^^ a warning - "); - } - - #[test] - fn render_errors_wrapper() { - let diag = Diagnostic::error(TextRange::new(0.into(), 3.into()), "test"); - let result = render_errors("abc", &[diag], Some("file.pql")); - insta::assert_snapshot!(result, @r" - error: test - --> file.pql:1:1 - | - 1 | abc - | ^^^ test - "); - } -} diff --git a/crates/plotnik-lib/src/ast/parser/error_tests.rs b/crates/plotnik-lib/src/ast/parser/error_tests.rs new file mode 100644 index 00000000..8b13ac32 --- /dev/null +++ b/crates/plotnik-lib/src/ast/parser/error_tests.rs @@ -0,0 +1,222 @@ +use super::*; +use rowan::TextRange; + +#[test] +fn severity_display() { + insta::assert_snapshot!(format!("{}", Severity::Error), @"error"); + insta::assert_snapshot!(format!("{}", Severity::Warning), @"warning"); +} + +#[test] +fn error_stage_display() { + insta::assert_snapshot!(format!("{}", ErrorStage::Parse), @"parse"); + insta::assert_snapshot!(format!("{}", ErrorStage::Validate), @"validate"); + insta::assert_snapshot!(format!("{}", ErrorStage::Resolve), @"resolve"); + insta::assert_snapshot!(format!("{}", ErrorStage::Escape), @"escape"); +} + +#[test] +fn diagnostic_warning_constructors() { + let warn = Diagnostic::warning(TextRange::empty(0.into()), "test warning"); + assert!(warn.is_warning()); + assert!(!warn.is_error()); + + let warn_at = Diagnostic::warning_at(5.into(), "warning at offset"); + assert!(warn_at.is_warning()); + assert_eq!(warn_at.range.start(), 5.into()); +} + +#[test] +fn diagnostic_error_at_constructor() { + let err = Diagnostic::error_at(7.into(), "error at offset"); + assert!(err.is_error()); + assert!(!err.is_warning()); + assert_eq!(err.range.start(), 7.into()); + assert_eq!(err.range.end(), 7.into()); +} + +#[test] +fn diagnostic_builders() { + let diag = Diagnostic::error(TextRange::empty(0.into()), "test") + .with_stage(ErrorStage::Resolve) + .with_fix(Fix::new("replacement", "description")) + .with_related(RelatedInfo::new(TextRange::empty(10.into()), "related")); + + assert_eq!(diag.stage, ErrorStage::Resolve); + assert!(diag.fix.is_some()); + assert_eq!(diag.related.len(), 1); + + let diag2 = Diagnostic::error(TextRange::empty(0.into()), "test").with_related_many(vec![ + RelatedInfo::new(TextRange::empty(1.into()), "first"), + RelatedInfo::new(TextRange::empty(2.into()), "second"), + ]); + assert_eq!(diag2.related.len(), 2); +} + +#[test] +fn diagnostic_display() { + let diag = Diagnostic::error(TextRange::new(5.into(), 10.into()), "test message"); + insta::assert_snapshot!(format!("{}", diag), @"error at 5..10: test message"); + + let diag_with_fix = Diagnostic::error(TextRange::empty(0.into()), "msg") + .with_fix(Fix::new("fix", "fix description")); + insta::assert_snapshot!(format!("{}", diag_with_fix), @"error at 0..0: msg (fix: fix description)"); + + let diag_with_related = Diagnostic::error(TextRange::empty(0.into()), "msg") + .with_related(RelatedInfo::new(TextRange::new(1.into(), 2.into()), "rel")); + insta::assert_snapshot!(format!("{}", diag_with_related), @"error at 0..0: msg (related: rel at 1..2)"); +} + +#[test] +fn render_options_constructors() { + let plain = RenderOptions::plain(); + assert!(!plain.colored); + + let colored = RenderOptions::colored(); + assert!(colored.colored); + + let default = RenderOptions::default(); + assert!(default.colored); +} + +#[test] +fn render_diagnostics_colored() { + let diag = Diagnostic::error(TextRange::new(0.into(), 5.into()), "test"); + let result = render_diagnostics("hello", &[diag], None, RenderOptions::colored()); + assert!(result.contains("test")); + assert!(result.contains('\x1b')); +} + +#[test] +fn render_diagnostics_empty() { + let result = render_diagnostics("source", &[], None, RenderOptions::plain()); + assert!(result.is_empty()); +} + +#[test] +fn render_diagnostics_with_path() { + let diag = Diagnostic::error(TextRange::new(0.into(), 5.into()), "test error"); + let result = render_diagnostics( + "hello world", + &[diag], + Some("test.pql"), + RenderOptions::plain(), + ); + insta::assert_snapshot!(result, @r" + error: test error + --> test.pql:1:1 + | + 1 | hello world + | ^^^^^ test error + "); +} + +#[test] +fn render_diagnostics_zero_width_span() { + let diag = Diagnostic::error(TextRange::empty(0.into()), "zero width error"); + let result = render_diagnostics("hello", &[diag], None, RenderOptions::plain()); + insta::assert_snapshot!(result, @r" + error: zero width error + | + 1 | hello + | ^ zero width error + "); +} + +#[test] +fn render_diagnostics_with_related() { + let diag = Diagnostic::error(TextRange::new(0.into(), 5.into()), "primary").with_related( + RelatedInfo::new(TextRange::new(6.into(), 10.into()), "related info"), + ); + let result = render_diagnostics("hello world!", &[diag], None, RenderOptions::plain()); + insta::assert_snapshot!(result, @r" + error: primary + | + 1 | hello world! + | ^^^^^ ---- related info + | | + | primary + "); +} + +#[test] +fn render_diagnostics_related_zero_width() { + let diag = Diagnostic::error(TextRange::new(0.into(), 5.into()), "primary").with_related( + RelatedInfo::new(TextRange::empty(6.into()), "zero width related"), + ); + let result = render_diagnostics("hello world!", &[diag], None, RenderOptions::plain()); + insta::assert_snapshot!(result, @r" + error: primary + | + 1 | hello world! + | ^^^^^ - zero width related + | | + | primary + "); +} + +#[test] +fn render_diagnostics_with_fix() { + let diag = Diagnostic::error(TextRange::new(0.into(), 5.into()), "fixable") + .with_fix(Fix::new("fixed", "apply this fix")); + let result = render_diagnostics("hello world", &[diag], None, RenderOptions::plain()); + insta::assert_snapshot!(result, @r" + error: fixable + | + 1 | hello world + | ^^^^^ fixable + | + help: apply this fix + | + 1 - hello world + 1 + fixed world + | + "); +} + +#[test] +fn render_diagnostics_multiple() { + let diag1 = Diagnostic::error(TextRange::new(0.into(), 5.into()), "first error"); + let diag2 = Diagnostic::error(TextRange::new(6.into(), 10.into()), "second error"); + let result = render_diagnostics( + "hello world!", + &[diag1, diag2], + None, + RenderOptions::plain(), + ); + insta::assert_snapshot!(result, @r" + error: first error + | + 1 | hello world! + | ^^^^^ first error + error: second error + | + 1 | hello world! + | ^^^^ second error + "); +} + +#[test] +fn render_diagnostics_warning() { + let diag = Diagnostic::warning(TextRange::new(0.into(), 5.into()), "a warning"); + let result = render_diagnostics("hello", &[diag], None, RenderOptions::plain()); + insta::assert_snapshot!(result, @r" + warning: a warning + | + 1 | hello + | ^^^^^ a warning + "); +} + +#[test] +fn render_errors_wrapper() { + let diag = Diagnostic::error(TextRange::new(0.into(), 3.into()), "test"); + let result = render_errors("abc", &[diag], Some("file.pql")); + insta::assert_snapshot!(result, @r" + error: test + --> file.pql:1:1 + | + 1 | abc + | ^^^ test + "); +} diff --git a/crates/plotnik-lib/src/ast/parser/mod.rs b/crates/plotnik-lib/src/ast/parser/mod.rs index 2085a294..eb3caab4 100644 --- a/crates/plotnik-lib/src/ast/parser/mod.rs +++ b/crates/plotnik-lib/src/ast/parser/mod.rs @@ -55,6 +55,9 @@ use super::lexer::lex; use super::syntax_kind::SyntaxNode; use crate::Result; +#[cfg(test)] +mod error_tests; + /// Parse result containing the green tree and any errors. /// /// The tree is always complete—errors are recorded separately and also diff --git a/crates/plotnik-lib/src/ast/parser/tests/json_serialization_tests.rs b/crates/plotnik-lib/src/ast/parser/tests/json_serialization_tests.rs new file mode 100644 index 00000000..edc34ef5 --- /dev/null +++ b/crates/plotnik-lib/src/ast/parser/tests/json_serialization_tests.rs @@ -0,0 +1,49 @@ +use crate::ast::parser::parse; + +#[test] +fn error_json_serialization() { + let input = "(identifier) @foo.bar"; + let result = parse(input).unwrap(); + let errors = result.errors(); + + assert_eq!(errors.len(), 1); + let json = serde_json::to_string_pretty(&errors[0]).unwrap(); + + insta::assert_snapshot!(json, @r#" + { + "severity": "error", + "stage": "parse", + "range": { + "start": 14, + "end": 21 + }, + "message": "capture names cannot contain dots", + "fix": { + "replacement": "foo_bar", + "description": "captures become struct fields; use @foo_bar instead" + } + } + "#); +} + +#[test] +fn error_json_serialization_no_fix() { + let input = "(identifier) @"; + let result = parse(input).unwrap(); + let errors = result.errors(); + + assert_eq!(errors.len(), 1); + let json = serde_json::to_string_pretty(&errors[0]).unwrap(); + + insta::assert_snapshot!(json, @r#" + { + "severity": "error", + "stage": "parse", + "range": { + "start": 14, + "end": 14 + }, + "message": "expected capture name after '@'" + } + "#); +} diff --git a/crates/plotnik-lib/src/ast/parser/tests/mod.rs b/crates/plotnik-lib/src/ast/parser/tests/mod.rs index 39f4f702..96015bd3 100644 --- a/crates/plotnik-lib/src/ast/parser/tests/mod.rs +++ b/crates/plotnik-lib/src/ast/parser/tests/mod.rs @@ -1,55 +1,3 @@ mod grammar; +mod json_serialization_tests; mod recovery; - -// JSON serialization tests for the error API -mod json_serialization { - use crate::ast::parser::parse; - - #[test] - fn error_json_serialization() { - let input = "(identifier) @foo.bar"; - let result = parse(input).unwrap(); - let errors = result.errors(); - - assert_eq!(errors.len(), 1); - let json = serde_json::to_string_pretty(&errors[0]).unwrap(); - - insta::assert_snapshot!(json, @r#" - { - "severity": "error", - "stage": "parse", - "range": { - "start": 14, - "end": 21 - }, - "message": "capture names cannot contain dots", - "fix": { - "replacement": "foo_bar", - "description": "captures become struct fields; use @foo_bar instead" - } - } - "#); - } - - #[test] - fn error_json_serialization_no_fix() { - let input = "(identifier) @"; - let result = parse(input).unwrap(); - let errors = result.errors(); - - assert_eq!(errors.len(), 1); - let json = serde_json::to_string_pretty(&errors[0]).unwrap(); - - insta::assert_snapshot!(json, @r#" - { - "severity": "error", - "stage": "parse", - "range": { - "start": 14, - "end": 14 - }, - "message": "expected capture name after '@'" - } - "#); - } -} diff --git a/crates/plotnik-lib/src/ast/parser/tests/validation/naming.rs b/crates/plotnik-lib/src/ast/parser/tests/validation/naming.rs new file mode 100644 index 00000000..60274e92 --- /dev/null +++ b/crates/plotnik-lib/src/ast/parser/tests/validation/naming.rs @@ -0,0 +1,86 @@ +//! Naming validation tests: capture names, definition names, branch labels, field names, type names. + +use crate::Query; +use indoc::indoc; + +// ============================================================================= +// Capture names +// ============================================================================= + +#[test] +fn capture_dotted_error() { + let input = indoc! {r#" + (identifier) @foo.bar + "#}; + + let query = Query::new(input).unwrap(); + assert!(!query.is_valid()); + insta::assert_snapshot!(query.dump_errors(), @""); +} + +#[test] +fn capture_dotted_multiple_parts() { + let input = indoc! {r#" + (identifier) @a.b.c.d + "#}; + + let query = Query::new(input).unwrap(); + assert!(!query.is_valid()); + insta::assert_snapshot!(query.dump_errors(), @""); +} + +#[test] +fn capture_dotted_followed_by_field() { + let input = indoc! {r#" + (call + (identifier) @foo.bar + name: (identifier)) + "#}; + + let query = Query::new(input).unwrap(); + assert!(!query.is_valid()); + insta::assert_snapshot!(query.dump_errors(), @""); +} + +#[test] +fn capture_hyphenated_error() { + let input = indoc! {r#" + (identifier) @foo-bar + "#}; + + let query = Query::new(input).unwrap(); + assert!(!query.is_valid()); + insta::assert_snapshot!(query.dump_errors(), @""); +} + +#[test] +fn capture_hyphenated_multiple() { + let input = indoc! {r#" + (identifier) @a-b-c-d + "#}; + + let query = Query::new(input).unwrap(); + assert!(!query.is_valid()); + insta::assert_snapshot!(query.dump_errors(), @""); +} + +#[test] +fn capture_mixed_dots_and_hyphens() { + let input = indoc! {r#" + (identifier) @foo.bar-baz + "#}; + + let query = Query::new(input).unwrap(); + assert!(!query.is_valid()); + insta::assert_snapshot!(query.dump_errors(), @""); +} + +#[test] +fn capture_name_pascal_case_error() { + let input = indoc! {r#" + (identifier) @MyCapture + "#}; + + let query = Query::new(input).unwrap(); + assert!(!query.is_valid()); + insta::assert_snapshot!(query.dump_errors(), @""); \ No newline at end of file diff --git a/crates/plotnik-lib/src/ast/syntax_kind.rs b/crates/plotnik-lib/src/ast/syntax_kind.rs index e4f76e82..6f50bc8a 100644 --- a/crates/plotnik-lib/src/ast/syntax_kind.rs +++ b/crates/plotnik-lib/src/ast/syntax_kind.rs @@ -395,94 +395,3 @@ pub mod token_sets { pub const SEQ_RECOVERY: TokenSet = TokenSet::new(&[BraceClose, ParenClose, BracketClose]); } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_token_set_contains() { - let set = TokenSet::new(&[ParenOpen, ParenClose, Star]); - assert!(set.contains(ParenOpen)); - assert!(set.contains(ParenClose)); - assert!(set.contains(Star)); - assert!(!set.contains(Plus)); - assert!(!set.contains(Colon)); - } - - #[test] - fn test_token_set_union() { - let a = TokenSet::new(&[ParenOpen, ParenClose]); - let b = TokenSet::new(&[Star, Plus]); - let c = a.union(b); - assert!(c.contains(ParenOpen)); - assert!(c.contains(ParenClose)); - assert!(c.contains(Star)); - assert!(c.contains(Plus)); - assert!(!c.contains(Colon)); - } - - #[test] - fn test_token_set_single() { - let set = TokenSet::single(Colon); - assert!(set.contains(Colon)); - assert!(!set.contains(ParenOpen)); - } - - #[test] - fn test_is_trivia() { - assert!(Whitespace.is_trivia()); - assert!(Newline.is_trivia()); - assert!(LineComment.is_trivia()); - assert!(BlockComment.is_trivia()); - assert!(!ParenOpen.is_trivia()); - assert!(!Error.is_trivia()); - } - - #[test] - fn test_syntax_kind_count_under_64() { - // Ensure we don't exceed TokenSet capacity - assert!( - (__LAST as u16) < 64, - "SyntaxKind has {} variants, exceeds TokenSet capacity of 64", - __LAST as u16 - ); - } - - #[test] - fn test_is_error() { - assert!(Error.is_error()); - assert!(XMLGarbage.is_error()); - assert!(Garbage.is_error()); - assert!(Predicate.is_error()); - assert!(!ParenOpen.is_error()); - assert!(!Id.is_error()); - assert!(!Whitespace.is_error()); - } - - #[test] - fn test_token_set_debug() { - let set = TokenSet::new(&[ParenOpen, Star, Plus]); - let debug_str = format!("{:?}", set); - assert!(debug_str.contains("ParenOpen")); - assert!(debug_str.contains("Star")); - assert!(debug_str.contains("Plus")); - } - - #[test] - fn test_token_set_empty_debug() { - let set = TokenSet::EMPTY; - let debug_str = format!("{:?}", set); - assert_eq!(debug_str, "{}"); - } - - #[test] - fn test_qlang_roundtrip() { - use rowan::Language; - for kind in [ParenOpen, ParenClose, Star, Plus, Id, Error, Whitespace] { - let raw = QLang::kind_to_raw(kind); - let back = QLang::kind_from_raw(raw); - assert_eq!(kind, back); - } - } -} diff --git a/crates/plotnik-lib/src/ast/syntax_kind_tests.rs b/crates/plotnik-lib/src/ast/syntax_kind_tests.rs new file mode 100644 index 00000000..90b32f5e --- /dev/null +++ b/crates/plotnik-lib/src/ast/syntax_kind_tests.rs @@ -0,0 +1,86 @@ +use crate::ast::syntax_kind::{QLang, SyntaxKind::*, TokenSet}; +use rowan::Language; + +#[test] +fn test_token_set_contains() { + let set = TokenSet::new(&[ParenOpen, ParenClose, Star]); + assert!(set.contains(ParenOpen)); + assert!(set.contains(ParenClose)); + assert!(set.contains(Star)); + assert!(!set.contains(Plus)); + assert!(!set.contains(Colon)); +} + +#[test] +fn test_token_set_union() { + let a = TokenSet::new(&[ParenOpen, ParenClose]); + let b = TokenSet::new(&[Star, Plus]); + let c = a.union(b); + assert!(c.contains(ParenOpen)); + assert!(c.contains(ParenClose)); + assert!(c.contains(Star)); + assert!(c.contains(Plus)); + assert!(!c.contains(Colon)); +} + +#[test] +fn test_token_set_single() { + let set = TokenSet::single(Colon); + assert!(set.contains(Colon)); + assert!(!set.contains(ParenOpen)); +} + +#[test] +fn test_is_trivia() { + assert!(Whitespace.is_trivia()); + assert!(Newline.is_trivia()); + assert!(LineComment.is_trivia()); + assert!(BlockComment.is_trivia()); + assert!(!ParenOpen.is_trivia()); + assert!(!Error.is_trivia()); +} + +#[test] +fn test_syntax_kind_count_under_64() { + assert!( + (__LAST as u16) < 64, + "SyntaxKind has {} variants, exceeds TokenSet capacity of 64", + __LAST as u16 + ); +} + +#[test] +fn test_is_error() { + assert!(Error.is_error()); + assert!(XMLGarbage.is_error()); + assert!(Garbage.is_error()); + assert!(Predicate.is_error()); + assert!(!ParenOpen.is_error()); + assert!(!Id.is_error()); + assert!(!Whitespace.is_error()); +} + +#[test] +fn test_token_set_debug() { + let set = TokenSet::new(&[ParenOpen, Star, Plus]); + let debug_str = format!("{:?}", set); + assert!(debug_str.contains("ParenOpen")); + assert!(debug_str.contains("Star")); + assert!(debug_str.contains("Plus")); +} + +#[test] +fn test_token_set_empty_debug() { + let set = TokenSet::EMPTY; + let debug_str = format!("{:?}", set); + assert_eq!(debug_str, "{}"); +} + +#[test] +fn test_qlang_roundtrip() { + for kind in [ParenOpen, ParenClose, Star, Plus, Id, Error, Whitespace] { + let raw = QLang::kind_to_raw(kind); + let back = QLang::kind_from_raw(raw); + assert_eq!(kind, back); + } +} diff --git a/crates/plotnik-lib/src/query/alt_kind.rs b/crates/plotnik-lib/src/query/alt_kind.rs index 06540a79..a8089728 100644 --- a/crates/plotnik-lib/src/query/alt_kind.rs +++ b/crates/plotnik-lib/src/query/alt_kind.rs @@ -115,121 +115,3 @@ fn check_mixed_alternation(alt: &Alt, errors: &mut Vec) { fn branch_range(branch: &Branch) -> TextRange { branch.syntax().text_range() } - -#[cfg(test)] -mod tests { - use crate::Query; - - #[test] - fn tagged_alternation_valid() { - let query = Query::new("[A: (a) B: (b)]").unwrap(); - assert!(query.is_valid()); - insta::assert_snapshot!(query.dump_ast(), @r" - Root - Def - Alt - Branch A: - Tree a - Branch B: - Tree b - "); - } - - #[test] - fn untagged_alternation_valid() { - let query = Query::new("[(a) (b)]").unwrap(); - assert!(query.is_valid()); - insta::assert_snapshot!(query.dump_ast(), @r" - Root - Def - Alt - Branch - Tree a - Branch - Tree b - "); - } - - #[test] - fn mixed_alternation_tagged_first() { - let query = Query::new("[A: (a) (b)]").unwrap(); - assert!(!query.is_valid()); - insta::assert_snapshot!(query.dump_errors(), @r" - error: mixed tagged and untagged branches in alternation - | - 1 | [A: (a) (b)] - | - ^^^ mixed tagged and untagged branches in alternation - | | - | tagged branch here - "); - } - - #[test] - fn mixed_alternation_untagged_first() { - let query = Query::new( - r#" - [ - (a) - B: (b) - ] - "#, - ) - .unwrap(); - assert!(!query.is_valid()); - insta::assert_snapshot!(query.dump_errors(), @r" - error: mixed tagged and untagged branches in alternation - | - 3 | (a) - | ^^^ mixed tagged and untagged branches in alternation - 4 | B: (b) - | - tagged branch here - "); - } - - #[test] - fn nested_mixed_alternation() { - let query = Query::new("(call [A: (a) (b)])").unwrap(); - assert!(!query.is_valid()); - insta::assert_snapshot!(query.dump_errors(), @r" - error: mixed tagged and untagged branches in alternation - | - 1 | (call [A: (a) (b)]) - | - ^^^ mixed tagged and untagged branches in alternation - | | - | tagged branch here - "); - } - - #[test] - fn multiple_mixed_alternations() { - let query = Query::new("(foo [A: (a) (b)] [C: (c) (d)])").unwrap(); - assert!(!query.is_valid()); - insta::assert_snapshot!(query.dump_errors(), @r" - error: mixed tagged and untagged branches in alternation - | - 1 | (foo [A: (a) (b)] [C: (c) (d)]) - | - ^^^ mixed tagged and untagged branches in alternation - | | - | tagged branch here - error: mixed tagged and untagged branches in alternation - | - 1 | (foo [A: (a) (b)] [C: (c) (d)]) - | - ^^^ mixed tagged and untagged branches in alternation - | | - | tagged branch here - "); - } - - #[test] - fn single_branch_no_error() { - let query = Query::new("[A: (a)]").unwrap(); - assert!(query.is_valid()); - insta::assert_snapshot!(query.dump_ast(), @r" - Root - Def - Alt - Branch A: - Tree a - "); - } -} diff --git a/crates/plotnik-lib/src/query/alt_kind_tests.rs b/crates/plotnik-lib/src/query/alt_kind_tests.rs new file mode 100644 index 00000000..22842b41 --- /dev/null +++ b/crates/plotnik-lib/src/query/alt_kind_tests.rs @@ -0,0 +1,114 @@ +use crate::Query; + +#[test] +fn tagged_alternation_valid() { + let query = Query::new("[A: (a) B: (b)]").unwrap(); + assert!(query.is_valid()); + insta::assert_snapshot!(query.dump_ast(), @r" + Root + Def + Alt + Branch A: + Tree a + Branch B: + Tree b + "); +} + +#[test] +fn untagged_alternation_valid() { + let query = Query::new("[(a) (b)]").unwrap(); + assert!(query.is_valid()); + insta::assert_snapshot!(query.dump_ast(), @r" + Root + Def + Alt + Branch + Tree a + Branch + Tree b + "); +} + +#[test] +fn mixed_alternation_tagged_first() { + let query = Query::new("[A: (a) (b)]").unwrap(); + assert!(!query.is_valid()); + insta::assert_snapshot!(query.dump_errors(), @r" + error: mixed tagged and untagged branches in alternation + | + 1 | [A: (a) (b)] + | - ^^^ mixed tagged and untagged branches in alternation + | | + | tagged branch here + "); +} + +#[test] +fn mixed_alternation_untagged_first() { + let query = Query::new( + r#" + [ + (a) + B: (b) + ] + "#, + ) + .unwrap(); + assert!(!query.is_valid()); + insta::assert_snapshot!(query.dump_errors(), @r" + error: mixed tagged and untagged branches in alternation + | + 3 | (a) + | ^^^ mixed tagged and untagged branches in alternation + 4 | B: (b) + | - tagged branch here + "); +} + +#[test] +fn nested_mixed_alternation() { + let query = Query::new("(call [A: (a) (b)])").unwrap(); + assert!(!query.is_valid()); + insta::assert_snapshot!(query.dump_errors(), @r" + error: mixed tagged and untagged branches in alternation + | + 1 | (call [A: (a) (b)]) + | - ^^^ mixed tagged and untagged branches in alternation + | | + | tagged branch here + "); +} + +#[test] +fn multiple_mixed_alternations() { + let query = Query::new("(foo [A: (a) (b)] [C: (c) (d)])").unwrap(); + assert!(!query.is_valid()); + insta::assert_snapshot!(query.dump_errors(), @r" + error: mixed tagged and untagged branches in alternation + | + 1 | (foo [A: (a) (b)] [C: (c) (d)]) + | - ^^^ mixed tagged and untagged branches in alternation + | | + | tagged branch here + error: mixed tagged and untagged branches in alternation + | + 1 | (foo [A: (a) (b)] [C: (c) (d)]) + | - ^^^ mixed tagged and untagged branches in alternation + | | + | tagged branch here + "); +} + +#[test] +fn single_branch_no_error() { + let query = Query::new("[A: (a)]").unwrap(); + assert!(query.is_valid()); + insta::assert_snapshot!(query.dump_ast(), @r" + Root + Def + Alt + Branch A: + Tree a + "); +} diff --git a/crates/plotnik-lib/src/query/errors.rs b/crates/plotnik-lib/src/query/errors.rs index 5c047948..f9669fff 100644 --- a/crates/plotnik-lib/src/query/errors.rs +++ b/crates/plotnik-lib/src/query/errors.rs @@ -85,101 +85,3 @@ impl Query<'_> { .collect() } } - -#[cfg(test)] -mod tests { - use crate::ast::{ErrorStage, RenderOptions, Severity}; - use crate::query::Query; - - #[test] - fn diagnostics_alias() { - let q = Query::new("(valid)").unwrap(); - assert_eq!(q.diagnostics().len(), q.errors().len()); - } - - #[test] - fn error_stage_filtering() { - let q = Query::new("(unclosed").unwrap(); - assert!(q.has_parse_errors()); - assert!(!q.has_resolve_errors()); - assert!(!q.has_escape_errors()); - assert_eq!(q.errors_for_stage(ErrorStage::Parse).len(), 1); - - let q = Query::new("(call (Undefined))").unwrap(); - assert!(!q.has_parse_errors()); - assert!(q.has_resolve_errors()); - assert!(!q.has_escape_errors()); - assert_eq!(q.errors_for_stage(ErrorStage::Resolve).len(), 1); - - let q = Query::new("[A: (a) (b)]").unwrap(); - assert!(!q.has_parse_errors()); - assert!(q.has_validate_errors()); - assert!(!q.has_resolve_errors()); - assert!(!q.has_escape_errors()); - assert_eq!(q.errors_for_stage(ErrorStage::Validate).len(), 1); - - let q = Query::new("Expr = (call (Expr))").unwrap(); - assert!(!q.has_parse_errors()); - assert!(!q.has_validate_errors()); - assert!(!q.has_resolve_errors()); - assert!(q.has_escape_errors()); - assert_eq!(q.errors_for_stage(ErrorStage::Escape).len(), 1); - - let q = Query::new("Expr = (call (Expr)) (unclosed").unwrap(); - assert!(q.has_parse_errors()); - assert!(!q.has_resolve_errors()); - assert!(q.has_escape_errors()); - } - - #[test] - fn is_valid_ignores_warnings() { - // Currently all diagnostics are errors, so this just tests the basic case - let q = Query::new("(valid)").unwrap(); - assert!(q.is_valid()); - assert!(!q.has_errors()); - assert!(!q.has_warnings()); - assert_eq!(q.error_count(), 0); - assert_eq!(q.warning_count(), 0); - } - - #[test] - fn error_and_warning_counts() { - let q = Query::new("(unclosed").unwrap(); - assert!(q.has_errors()); - assert!(!q.has_warnings()); - assert_eq!(q.error_count(), 1); - assert_eq!(q.warning_count(), 0); - } - - #[test] - fn errors_only_and_warnings_only() { - let q = Query::new("(unclosed").unwrap(); - let errors = q.errors_only(); - let warnings = q.warnings_only(); - assert_eq!(errors.len(), 1); - assert!(warnings.is_empty()); - } - - #[test] - fn render_diagnostics_method() { - let q = Query::new("(unclosed").unwrap(); - let rendered = q.render_diagnostics(RenderOptions::plain()); - insta::assert_snapshot!(rendered, @r" - error: unclosed tree; expected ')' - | - 1 | (unclosed - | - ^ unclosed tree; expected ')' - | | - | tree started here - "); - } - - #[test] - fn filter_by_severity() { - let q = Query::new("(unclosed").unwrap(); - let errors = q.filter_by_severity(Severity::Error); - let warnings = q.filter_by_severity(Severity::Warning); - assert_eq!(errors.len(), 1); - assert!(warnings.is_empty()); - } -} diff --git a/crates/plotnik-lib/src/query/errors_tests.rs b/crates/plotnik-lib/src/query/errors_tests.rs new file mode 100644 index 00000000..2aef77ac --- /dev/null +++ b/crates/plotnik-lib/src/query/errors_tests.rs @@ -0,0 +1,93 @@ +use super::Query; +use crate::ast::{ErrorStage, RenderOptions, Severity}; + +#[test] +fn diagnostics_alias() { + let q = Query::new("(valid)").unwrap(); + assert_eq!(q.diagnostics().len(), q.errors().len()); +} + +#[test] +fn error_stage_filtering() { + let q = Query::new("(unclosed").unwrap(); + assert!(q.has_parse_errors()); + assert!(!q.has_resolve_errors()); + assert!(!q.has_escape_errors()); + assert_eq!(q.errors_for_stage(ErrorStage::Parse).len(), 1); + + let q = Query::new("(call (Undefined))").unwrap(); + assert!(!q.has_parse_errors()); + assert!(q.has_resolve_errors()); + assert!(!q.has_escape_errors()); + assert_eq!(q.errors_for_stage(ErrorStage::Resolve).len(), 1); + + let q = Query::new("[A: (a) (b)]").unwrap(); + assert!(!q.has_parse_errors()); + assert!(q.has_validate_errors()); + assert!(!q.has_resolve_errors()); + assert!(!q.has_escape_errors()); + assert_eq!(q.errors_for_stage(ErrorStage::Validate).len(), 1); + + let q = Query::new("Expr = (call (Expr))").unwrap(); + assert!(!q.has_parse_errors()); + assert!(!q.has_validate_errors()); + assert!(!q.has_resolve_errors()); + assert!(q.has_escape_errors()); + assert_eq!(q.errors_for_stage(ErrorStage::Escape).len(), 1); + + let q = Query::new("Expr = (call (Expr)) (unclosed").unwrap(); + assert!(q.has_parse_errors()); + assert!(!q.has_resolve_errors()); + assert!(q.has_escape_errors()); +} + +#[test] +fn is_valid_ignores_warnings() { + let q = Query::new("(valid)").unwrap(); + assert!(q.is_valid()); + assert!(!q.has_errors()); + assert!(!q.has_warnings()); + assert_eq!(q.error_count(), 0); + assert_eq!(q.warning_count(), 0); +} + +#[test] +fn error_and_warning_counts() { + let q = Query::new("(unclosed").unwrap(); + assert!(q.has_errors()); + assert!(!q.has_warnings()); + assert_eq!(q.error_count(), 1); + assert_eq!(q.warning_count(), 0); +} + +#[test] +fn errors_only_and_warnings_only() { + let q = Query::new("(unclosed").unwrap(); + let errors = q.errors_only(); + let warnings = q.warnings_only(); + assert_eq!(errors.len(), 1); + assert!(warnings.is_empty()); +} + +#[test] +fn render_diagnostics_method() { + let q = Query::new("(unclosed").unwrap(); + let rendered = q.render_diagnostics(RenderOptions::plain()); + insta::assert_snapshot!(rendered, @r" + error: unclosed tree; expected ')' + | + 1 | (unclosed + | - ^ unclosed tree; expected ')' + | | + | tree started here + "); +} + +#[test] +fn filter_by_severity() { + let q = Query::new("(unclosed").unwrap(); + let errors = q.filter_by_severity(Severity::Error); + let warnings = q.filter_by_severity(Severity::Warning); + assert_eq!(errors.len(), 1); + assert!(warnings.is_empty()); +} diff --git a/crates/plotnik-lib/src/query/mod.rs b/crates/plotnik-lib/src/query/mod.rs index ac55d9b8..a7e08cf6 100644 --- a/crates/plotnik-lib/src/query/mod.rs +++ b/crates/plotnik-lib/src/query/mod.rs @@ -11,6 +11,18 @@ pub mod named_defs; pub mod ref_cycles; pub mod shape_cardinalities; +#[cfg(test)] +mod alt_kind_tests; +#[cfg(test)] +mod errors_tests; +#[cfg(test)] +mod mod_tests; +#[cfg(test)] +mod named_defs_tests; +#[cfg(test)] +mod printer_tests; +#[cfg(test)] +mod ref_cycles_tests; #[cfg(test)] mod shape_cardinalities_tests; @@ -164,36 +176,3 @@ impl<'a> Query<'a> { .unwrap_or(ShapeCardinality::One) } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn valid_query() { - let q = Query::new("Expr = (expression)").unwrap(); - assert!(q.is_valid()); - assert!(q.symbols().get("Expr").is_some()); - } - - #[test] - fn parse_error() { - let q = Query::new("(unclosed").unwrap(); - assert!(!q.is_valid()); - assert!(q.dump_errors().contains("expected")); - } - - #[test] - fn resolution_error() { - let q = Query::new("(call (Undefined))").unwrap(); - assert!(!q.is_valid()); - assert!(q.dump_errors().contains("undefined reference")); - } - - #[test] - fn combined_errors() { - let q = Query::new("(call (Undefined) extra)").unwrap(); - assert!(!q.is_valid()); - assert!(!q.errors().is_empty()); - } -} diff --git a/crates/plotnik-lib/src/query/mod_tests.rs b/crates/plotnik-lib/src/query/mod_tests.rs new file mode 100644 index 00000000..4718f1bd --- /dev/null +++ b/crates/plotnik-lib/src/query/mod_tests.rs @@ -0,0 +1,29 @@ +use super::*; + +#[test] +fn valid_query() { + let q = Query::new("Expr = (expression)").unwrap(); + assert!(q.is_valid()); + assert!(q.symbols().get("Expr").is_some()); +} + +#[test] +fn parse_error() { + let q = Query::new("(unclosed").unwrap(); + assert!(!q.is_valid()); + assert!(q.dump_errors().contains("expected")); +} + +#[test] +fn resolution_error() { + let q = Query::new("(call (Undefined))").unwrap(); + assert!(!q.is_valid()); + assert!(q.dump_errors().contains("undefined reference")); +} + +#[test] +fn combined_errors() { + let q = Query::new("(call (Undefined) extra)").unwrap(); + assert!(!q.is_valid()); + assert!(!q.errors().is_empty()); +} diff --git a/crates/plotnik-lib/src/query/named_defs.rs b/crates/plotnik-lib/src/query/named_defs.rs index 3dc4c061..2d8353c4 100644 --- a/crates/plotnik-lib/src/query/named_defs.rs +++ b/crates/plotnik-lib/src/query/named_defs.rs @@ -196,293 +196,3 @@ fn check_ref_reference(r: &Ref, symbols: &SymbolTable, errors: &mut Vec { QueryPrinter::new(self) } } - -#[cfg(test)] -mod tests { - use crate::Query; - use indoc::indoc; - - #[test] - fn printer_with_spans() { - let q = Query::new("(call)").unwrap(); - insta::assert_snapshot!(q.printer().with_spans(true).dump(), @r" - Root [0..6] - Def [0..6] - Tree [0..6] call - "); - } - - #[test] - fn printer_with_cardinalities() { - let q = Query::new("(call)").unwrap(); - insta::assert_snapshot!(q.printer().with_cardinalities(true).dump(), @r" - Root¹ - Def¹ - Tree¹ call - "); - } - - #[test] - fn printer_cst_with_trivia() { - let q = Query::new("(a) (b)").unwrap(); - insta::assert_snapshot!(q.printer().raw(true).with_trivia(true).dump(), @r#" - Root - Def - Tree - ParenOpen "(" - Id "a" - ParenClose ")" - Whitespace " " - Def - Tree - ParenOpen "(" - Id "b" - ParenClose ")" - "#); - } - - #[test] - fn printer_alt_branches() { - let input = indoc! {r#" - [A: (a) B: (b)] - "#}; - let q = Query::new(input).unwrap(); - insta::assert_snapshot!(q.printer().dump(), @r" - Root - Def - Alt - Branch A: - Tree a - Branch B: - Tree b - "); - } - - #[test] - fn printer_capture_with_type() { - let q = Query::new("(call)@x :: T").unwrap(); - insta::assert_snapshot!(q.printer().dump(), @r" - Root - Def - Capture @x :: T - Tree call - "); - } - - #[test] - fn printer_quantifiers() { - let q = Query::new("(a)* (b)+ (c)?").unwrap(); - insta::assert_snapshot!(q.printer().dump(), @r" - Root - Def - Quantifier * - Tree a - Def - Quantifier + - Tree b - Def - Quantifier ? - Tree c - "); - } - - #[test] - fn printer_field() { - let q = Query::new("(call name: (id))").unwrap(); - insta::assert_snapshot!(q.printer().dump(), @r" - Root - Def - Tree call - Field name: - Tree id - "); - } - - #[test] - fn printer_negated_field() { - let q = Query::new("(call !name)").unwrap(); - insta::assert_snapshot!(q.printer().dump(), @r" - Root - Def - Tree call - NegatedField !name - "); - } - - #[test] - fn printer_wildcard_and_anchor() { - let q = Query::new("(call _ . (arg))").unwrap(); - insta::assert_snapshot!(q.printer().dump(), @r" - Root - Def - Tree call - Wildcard - Anchor - Tree arg - "); - } - - #[test] - fn printer_string_literal() { - let q = Query::new(r#"(call "foo")"#).unwrap(); - insta::assert_snapshot!(q.printer().dump(), @r#" - Root - Def - Tree call - Str "foo" - "#); - } - - #[test] - fn printer_ref() { - let input = indoc! {r#" - Expr = (call) - (func (Expr)) - "#}; - let q = Query::new(input).unwrap(); - insta::assert_snapshot!(q.printer().dump(), @r" - Root - Def Expr - Tree call - Def - Tree func - Ref Expr - "); - } - - #[test] - fn printer_symbols_with_cardinalities() { - let input = indoc! {r#" - A = (a) - B = {(b) (c)} - (entry (A) (B)) - "#}; - let q = Query::new(input).unwrap(); - insta::assert_snapshot!(q.printer().only_symbols(true).with_cardinalities(true).dump(), @r" - A¹ - B⁺ - "); - } - - #[test] - fn printer_symbols_with_refs() { - let input = indoc! {r#" - A = (a) - B = (b (A)) - (entry (B)) - "#}; - let q = Query::new(input).unwrap(); - insta::assert_snapshot!(q.printer().only_symbols(true).dump(), @r" - A - B - A - "); - } - - #[test] - fn printer_symbols_cycle() { - let input = indoc! {r#" - A = [(a) (B)] - B = [(b) (A)] - (entry (A)) - "#}; - let q = Query::new(input).unwrap(); - insta::assert_snapshot!(q.printer().only_symbols(true).dump(), @r" - A - B - A (cycle) - B - A - B (cycle) - "); - } - - #[test] - fn printer_symbols_undefined_ref() { - let input = "(call (Undefined))"; - let q = Query::new(input).unwrap(); - insta::assert_snapshot!(q.printer().only_symbols(true).dump(), @""); - } - - #[test] - fn printer_symbols_broken_ref() { - // A defined symbol that references an undefined name - let input = "A = (foo (Undefined))"; - let q = Query::new(input).unwrap(); - insta::assert_snapshot!(q.printer().only_symbols(true).dump(), @r" - A - Undefined? - "); - } - - #[test] - fn printer_spans_comprehensive() { - let input = indoc! {r#" - Foo = (call name: (id) !bar) - [(a) (b)] - "#}; - let q = Query::new(input).unwrap(); - insta::assert_snapshot!(q.printer().with_spans(true).dump(), @r" - Root [0..39] - Def [0..28] Foo - Tree [6..28] call - Field [12..22] name: - Tree [18..22] id - NegatedField [23..27] !bar - Def [29..38] - Alt [29..38] - Branch [30..33] - Tree [30..33] a - Branch [34..37] - Tree [34..37] b - "); - } - - #[test] - fn printer_spans_seq() { - let q = Query::new("{(a) (b)}").unwrap(); - insta::assert_snapshot!(q.printer().with_spans(true).dump(), @r" - Root [0..9] - Def [0..9] - Seq [0..9] - Tree [1..4] a - Tree [5..8] b - "); - } - - #[test] - fn printer_spans_quantifiers() { - let q = Query::new("(a)* (b)+").unwrap(); - insta::assert_snapshot!(q.printer().with_spans(true).dump(), @r" - Root [0..9] - Def [0..4] - Quantifier [0..4] * - Tree [0..3] a - Def [5..9] - Quantifier [5..9] + - Tree [5..8] b - "); - } - - #[test] - fn printer_spans_alt_branches() { - let q = Query::new("[A: (a) B: (b)]").unwrap(); - insta::assert_snapshot!(q.printer().with_spans(true).dump(), @r" - Root [0..15] - Def [0..15] - Alt [0..15] - Branch [1..7] A: - Tree [4..7] a - Branch [8..14] B: - Tree [11..14] b - "); - } -} diff --git a/crates/plotnik-lib/src/query/printer_tests.rs b/crates/plotnik-lib/src/query/printer_tests.rs new file mode 100644 index 00000000..3ebd0ff3 --- /dev/null +++ b/crates/plotnik-lib/src/query/printer_tests.rs @@ -0,0 +1,277 @@ +use crate::Query; +use indoc::indoc; + +#[test] +fn printer_with_spans() { + let q = Query::new("(call)").unwrap(); + insta::assert_snapshot!(q.printer().with_spans(true).dump(), @r" + Root [0..6] + Def [0..6] + Tree [0..6] call + "); +} + +#[test] +fn printer_with_cardinalities() { + let q = Query::new("(call)").unwrap(); + insta::assert_snapshot!(q.printer().with_cardinalities(true).dump(), @r" + Root¹ + Def¹ + Tree¹ call + "); +} + +#[test] +fn printer_cst_with_trivia() { + let q = Query::new("(a) (b)").unwrap(); + insta::assert_snapshot!(q.printer().raw(true).with_trivia(true).dump(), @r#" + Root + Def + Tree + ParenOpen "(" + Id "a" + ParenClose ")" + Whitespace " " + Def + Tree + ParenOpen "(" + Id "b" + ParenClose ")" + "#); +} + +#[test] +fn printer_alt_branches() { + let input = indoc! {r#" + [A: (a) B: (b)] + "#}; + let q = Query::new(input).unwrap(); + insta::assert_snapshot!(q.printer().dump(), @r" + Root + Def + Alt + Branch A: + Tree a + Branch B: + Tree b + "); +} + +#[test] +fn printer_capture_with_type() { + let q = Query::new("(call)@x :: T").unwrap(); + insta::assert_snapshot!(q.printer().dump(), @r" + Root + Def + Capture @x :: T + Tree call + "); +} + +#[test] +fn printer_quantifiers() { + let q = Query::new("(a)* (b)+ (c)?").unwrap(); + insta::assert_snapshot!(q.printer().dump(), @r" + Root + Def + Quantifier * + Tree a + Def + Quantifier + + Tree b + Def + Quantifier ? + Tree c + "); +} + +#[test] +fn printer_field() { + let q = Query::new("(call name: (id))").unwrap(); + insta::assert_snapshot!(q.printer().dump(), @r" + Root + Def + Tree call + Field name: + Tree id + "); +} + +#[test] +fn printer_negated_field() { + let q = Query::new("(call !name)").unwrap(); + insta::assert_snapshot!(q.printer().dump(), @r" + Root + Def + Tree call + NegatedField !name + "); +} + +#[test] +fn printer_wildcard_and_anchor() { + let q = Query::new("(call _ . (arg))").unwrap(); + insta::assert_snapshot!(q.printer().dump(), @r" + Root + Def + Tree call + Wildcard + Anchor + Tree arg + "); +} + +#[test] +fn printer_string_literal() { + let q = Query::new(r#"(call "foo")"#).unwrap(); + insta::assert_snapshot!(q.printer().dump(), @r#" + Root + Def + Tree call + Str "foo" + "#); +} + +#[test] +fn printer_ref() { + let input = indoc! {r#" + Expr = (call) + (func (Expr)) + "#}; + let q = Query::new(input).unwrap(); + insta::assert_snapshot!(q.printer().dump(), @r" + Root + Def Expr + Tree call + Def + Tree func + Ref Expr + "); +} + +#[test] +fn printer_symbols_with_cardinalities() { + let input = indoc! {r#" + A = (a) + B = {(b) (c)} + (entry (A) (B)) + "#}; + let q = Query::new(input).unwrap(); + insta::assert_snapshot!(q.printer().only_symbols(true).with_cardinalities(true).dump(), @r" + A¹ + B⁺ + "); +} + +#[test] +fn printer_symbols_with_refs() { + let input = indoc! {r#" + A = (a) + B = (b (A)) + (entry (B)) + "#}; + let q = Query::new(input).unwrap(); + insta::assert_snapshot!(q.printer().only_symbols(true).dump(), @r" + A + B + A + "); +} + +#[test] +fn printer_symbols_cycle() { + let input = indoc! {r#" + A = [(a) (B)] + B = [(b) (A)] + (entry (A)) + "#}; + let q = Query::new(input).unwrap(); + insta::assert_snapshot!(q.printer().only_symbols(true).dump(), @r" + A + B + A (cycle) + B + A + B (cycle) + "); +} + +#[test] +fn printer_symbols_undefined_ref() { + let input = "(call (Undefined))"; + let q = Query::new(input).unwrap(); + insta::assert_snapshot!(q.printer().only_symbols(true).dump(), @""); +} + +#[test] +fn printer_symbols_broken_ref() { + let input = "A = (foo (Undefined))"; + let q = Query::new(input).unwrap(); + insta::assert_snapshot!(q.printer().only_symbols(true).dump(), @r" + A + Undefined? + "); +} + +#[test] +fn printer_spans_comprehensive() { + let input = indoc! {r#" + Foo = (call name: (id) !bar) + [(a) (b)] + "#}; + let q = Query::new(input).unwrap(); + insta::assert_snapshot!(q.printer().with_spans(true).dump(), @r" + Root [0..39] + Def [0..28] Foo + Tree [6..28] call + Field [12..22] name: + Tree [18..22] id + NegatedField [23..27] !bar + Def [29..38] + Alt [29..38] + Branch [30..33] + Tree [30..33] a + Branch [34..37] + Tree [34..37] b + "); +} + +#[test] +fn printer_spans_seq() { + let q = Query::new("{(a) (b)}").unwrap(); + insta::assert_snapshot!(q.printer().with_spans(true).dump(), @r" + Root [0..9] + Def [0..9] + Seq [0..9] + Tree [1..4] a + Tree [5..8] b + "); +} + +#[test] +fn printer_spans_quantifiers() { + let q = Query::new("(a)* (b)+").unwrap(); + insta::assert_snapshot!(q.printer().with_spans(true).dump(), @r" + Root [0..9] + Def [0..4] + Quantifier [0..4] * + Tree [0..3] a + Def [5..9] + Quantifier [5..9] + + Tree [5..8] b + "); +} + +#[test] +fn printer_spans_alt_branches() { + let q = Query::new("[A: (a) B: (b)]").unwrap(); + insta::assert_snapshot!(q.printer().with_spans(true).dump(), @r" + Root [0..15] + Def [0..15] + Alt [0..15] + Branch [1..7] A: + Tree [4..7] a + Branch [8..14] B: + Tree [11..14] b + "); +} diff --git a/crates/plotnik-lib/src/query/ref_cycles.rs b/crates/plotnik-lib/src/query/ref_cycles.rs index 59152a50..84af54bf 100644 --- a/crates/plotnik-lib/src/query/ref_cycles.rs +++ b/crates/plotnik-lib/src/query/ref_cycles.rs @@ -317,338 +317,3 @@ fn make_error(primary_name: &str, scc: &[String], related: Vec) -> .with_related_many(related) .with_stage(ErrorStage::Escape) } - -#[cfg(test)] -mod tests { - use crate::Query; - use indoc::indoc; - - #[test] - fn escape_via_alternation() { - let query = Query::new("E = [(x) (call (E))]").unwrap(); - assert!(query.is_valid()); - } - - #[test] - fn escape_via_optional() { - let query = Query::new("E = (call (E)?)").unwrap(); - assert!(query.is_valid()); - } - - #[test] - fn escape_via_star() { - let query = Query::new("E = (call (E)*)").unwrap(); - assert!(query.is_valid()); - } - - #[test] - fn no_escape_via_plus() { - let query = Query::new("E = (call (E)+)").unwrap(); - assert!(!query.is_valid()); - insta::assert_snapshot!(query.dump_errors(), @r" - error: recursive pattern can never match: cycle `E` → `E` has no escape path - | - 1 | E = (call (E)+) - | ^ - | | - | recursive pattern can never match: cycle `E` → `E` has no escape path - | `E` references itself - "); - } - - #[test] - fn escape_via_empty_tree() { - let query = Query::new("E = [(call) (E)]").unwrap(); - assert!(query.is_valid()); - } - - #[test] - fn lazy_quantifiers_same_as_greedy() { - assert!(Query::new("E = (call (E)??)").unwrap().is_valid()); - assert!(Query::new("E = (call (E)*?)").unwrap().is_valid()); - assert!(!Query::new("E = (call (E)+?)").unwrap().is_valid()); - } - - #[test] - fn recursion_in_tree_child() { - let query = Query::new("E = (call (E))").unwrap(); - assert!(!query.is_valid()); - insta::assert_snapshot!(query.dump_errors(), @r" - error: recursive pattern can never match: cycle `E` → `E` has no escape path - | - 1 | E = (call (E)) - | ^ - | | - | recursive pattern can never match: cycle `E` → `E` has no escape path - | `E` references itself - "); - } - - #[test] - fn recursion_in_field() { - let query = Query::new("E = (call body: (E))").unwrap(); - assert!(!query.is_valid()); - assert!(query.dump_errors().contains("recursive pattern")); - } - - #[test] - fn recursion_in_capture() { - let query = Query::new("E = (call (E) @inner)").unwrap(); - assert!(!query.is_valid()); - assert!(query.dump_errors().contains("recursive pattern")); - } - - #[test] - fn recursion_in_sequence() { - let query = Query::new("E = (call {(a) (E)})").unwrap(); - assert!(!query.is_valid()); - assert!(query.dump_errors().contains("recursive pattern")); - } - - #[test] - fn recursion_through_multiple_children() { - let query = Query::new("E = [(x) (call (a) (E))]").unwrap(); - assert!(query.is_valid()); - } - - #[test] - fn mutual_recursion_no_escape() { - let input = indoc! {r#" - A = (foo (B)) - B = (bar (A)) - "#}; - let query = Query::new(input).unwrap(); - assert!(!query.is_valid()); - insta::assert_snapshot!(query.dump_errors(), @r" - error: recursive pattern can never match: cycle `B` → `A` → `B` has no escape path - | - 1 | A = (foo (B)) - | - `A` references `B` (completing cycle) - 2 | B = (bar (A)) - | ^ - | | - | recursive pattern can never match: cycle `B` → `A` → `B` has no escape path - | `B` references `A` - "); - } - - #[test] - fn mutual_recursion_one_has_escape() { - let input = indoc! {r#" - A = [(x) (foo (B))] - B = (bar (A)) - "#}; - let query = Query::new(input).unwrap(); - assert!(query.is_valid()); - } - - #[test] - fn three_way_cycle_no_escape() { - let input = indoc! {r#" - A = (a (B)) - B = (b (C)) - C = (c (A)) - "#}; - let query = Query::new(input).unwrap(); - assert!(!query.is_valid()); - assert!(query.dump_errors().contains("recursive pattern")); - } - - #[test] - fn three_way_cycle_one_has_escape() { - let input = indoc! {r#" - A = [(x) (a (B))] - B = (b (C)) - C = (c (A)) - "#}; - let query = Query::new(input).unwrap(); - assert!(query.is_valid()); - } - - #[test] - fn diamond_dependency() { - let input = indoc! {r#" - A = (a [(B) (C)]) - B = (b (D)) - C = (c (D)) - D = (d (A)) - "#}; - let query = Query::new(input).unwrap(); - assert!(!query.is_valid()); - assert!(query.dump_errors().contains("recursive pattern")); - } - - #[test] - fn cycle_ref_in_field() { - let input = indoc! {r#" - A = (foo body: (B)) - B = (bar (A)) - "#}; - let query = Query::new(input).unwrap(); - assert!(!query.is_valid()); - insta::assert_snapshot!(query.dump_errors(), @r" - error: recursive pattern can never match: cycle `B` → `A` → `B` has no escape path - | - 1 | A = (foo body: (B)) - | - `A` references `B` (completing cycle) - 2 | B = (bar (A)) - | ^ - | | - | recursive pattern can never match: cycle `B` → `A` → `B` has no escape path - | `B` references `A` - "); - } - - #[test] - fn cycle_ref_in_capture() { - let input = indoc! {r#" - A = (foo (B) @cap) - B = (bar (A)) - "#}; - let query = Query::new(input).unwrap(); - assert!(!query.is_valid()); - insta::assert_snapshot!(query.dump_errors(), @r" - error: recursive pattern can never match: cycle `B` → `A` → `B` has no escape path - | - 1 | A = (foo (B) @cap) - | - `A` references `B` (completing cycle) - 2 | B = (bar (A)) - | ^ - | | - | recursive pattern can never match: cycle `B` → `A` → `B` has no escape path - | `B` references `A` - "); - } - - #[test] - fn cycle_ref_in_sequence() { - let input = indoc! {r#" - A = (foo {(x) (B)}) - B = (bar (A)) - "#}; - let query = Query::new(input).unwrap(); - assert!(!query.is_valid()); - insta::assert_snapshot!(query.dump_errors(), @r" - error: recursive pattern can never match: cycle `B` → `A` → `B` has no escape path - | - 1 | A = (foo {(x) (B)}) - | - `A` references `B` (completing cycle) - 2 | B = (bar (A)) - | ^ - | | - | recursive pattern can never match: cycle `B` → `A` → `B` has no escape path - | `B` references `A` - "); - } - - #[test] - fn cycle_with_quantifier_escape() { - let input = indoc! {r#" - A = (foo (B)?) - B = (bar (A)) - "#}; - let query = Query::new(input).unwrap(); - assert!(query.is_valid()); - } - - #[test] - fn cycle_with_plus_no_escape() { - let input = indoc! {r#" - A = (foo (B)+) - B = (bar (A)) - "#}; - let query = Query::new(input).unwrap(); - assert!(!query.is_valid()); - assert!(query.dump_errors().contains("recursive pattern")); - } - - #[test] - fn non_recursive_reference() { - let input = indoc! {r#" - Leaf = (identifier) - Tree = (call (Leaf)) - "#}; - let query = Query::new(input).unwrap(); - assert!(query.is_valid()); - } - - #[test] - fn entry_point_uses_recursive_def() { - let input = indoc! {r#" - E = [(x) (call (E))] - (program (E)) - "#}; - let query = Query::new(input).unwrap(); - assert!(query.is_valid()); - } - - #[test] - fn direct_self_ref_in_alternation() { - let query = Query::new("E = [(E) (x)]").unwrap(); - assert!(query.is_valid()); - } - - #[test] - fn escape_via_literal_string() { - let input = indoc! {r#" - A = [(A) "escape"] - "#}; - let query = Query::new(input).unwrap(); - assert!(query.is_valid()); - } - - #[test] - fn escape_via_wildcard() { - let input = indoc! {r#" - A = [(A) _] - "#}; - let query = Query::new(input).unwrap(); - assert!(query.is_valid()); - } - - #[test] - fn escape_via_childless_tree() { - let input = indoc! {r#" - A = [(A) (leaf)] - "#}; - let query = Query::new(input).unwrap(); - assert!(query.is_valid()); - } - - #[test] - fn escape_via_anchor() { - let input = indoc! {r#" - A = (foo . [(A) (x)]) - "#}; - let query = Query::new(input).unwrap(); - assert!(query.is_valid()); - } - - #[test] - fn no_escape_tree_all_recursive() { - let input = indoc! {r#" - A = (foo (A)) - "#}; - let query = Query::new(input).unwrap(); - assert!(!query.is_valid()); - assert!(query.dump_errors().contains("recursive pattern")); - } - - #[test] - fn escape_in_capture_inner() { - let input = indoc! {r#" - A = [(x)@cap (foo (A))] - "#}; - let query = Query::new(input).unwrap(); - assert!(query.is_valid()); - } - - #[test] - fn ref_in_quantifier_plus_no_escape() { - let input = indoc! {r#" - A = (foo (A)+) - "#}; - let query = Query::new(input).unwrap(); - assert!(!query.is_valid()); - } -} diff --git a/crates/plotnik-lib/src/query/ref_cycles_tests.rs b/crates/plotnik-lib/src/query/ref_cycles_tests.rs new file mode 100644 index 00000000..a4c145eb --- /dev/null +++ b/crates/plotnik-lib/src/query/ref_cycles_tests.rs @@ -0,0 +1,331 @@ +use crate::Query; +use indoc::indoc; + +#[test] +fn escape_via_alternation() { + let query = Query::new("E = [(x) (call (E))]").unwrap(); + assert!(query.is_valid()); +} + +#[test] +fn escape_via_optional() { + let query = Query::new("E = (call (E)?)").unwrap(); + assert!(query.is_valid()); +} + +#[test] +fn escape_via_star() { + let query = Query::new("E = (call (E)*)").unwrap(); + assert!(query.is_valid()); +} + +#[test] +fn no_escape_via_plus() { + let query = Query::new("E = (call (E)+)").unwrap(); + assert!(!query.is_valid()); + insta::assert_snapshot!(query.dump_errors(), @r" + error: recursive pattern can never match: cycle `E` → `E` has no escape path + | + 1 | E = (call (E)+) + | ^ + | | + | recursive pattern can never match: cycle `E` → `E` has no escape path + | `E` references itself + "); +} + +#[test] +fn escape_via_empty_tree() { + let query = Query::new("E = [(call) (E)]").unwrap(); + assert!(query.is_valid()); +} + +#[test] +fn lazy_quantifiers_same_as_greedy() { + assert!(Query::new("E = (call (E)??)").unwrap().is_valid()); + assert!(Query::new("E = (call (E)*?)").unwrap().is_valid()); + assert!(!Query::new("E = (call (E)+?)").unwrap().is_valid()); +} + +#[test] +fn recursion_in_tree_child() { + let query = Query::new("E = (call (E))").unwrap(); + assert!(!query.is_valid()); + insta::assert_snapshot!(query.dump_errors(), @r" + error: recursive pattern can never match: cycle `E` → `E` has no escape path + | + 1 | E = (call (E)) + | ^ + | | + | recursive pattern can never match: cycle `E` → `E` has no escape path + | `E` references itself + "); +} + +#[test] +fn recursion_in_field() { + let query = Query::new("E = (call body: (E))").unwrap(); + assert!(!query.is_valid()); + assert!(query.dump_errors().contains("recursive pattern")); +} + +#[test] +fn recursion_in_capture() { + let query = Query::new("E = (call (E) @inner)").unwrap(); + assert!(!query.is_valid()); + assert!(query.dump_errors().contains("recursive pattern")); +} + +#[test] +fn recursion_in_sequence() { + let query = Query::new("E = (call {(a) (E)})").unwrap(); + assert!(!query.is_valid()); + assert!(query.dump_errors().contains("recursive pattern")); +} + +#[test] +fn recursion_through_multiple_children() { + let query = Query::new("E = [(x) (call (a) (E))]").unwrap(); + assert!(query.is_valid()); +} + +#[test] +fn mutual_recursion_no_escape() { + let input = indoc! {r#" + A = (foo (B)) + B = (bar (A)) + "#}; + let query = Query::new(input).unwrap(); + assert!(!query.is_valid()); + insta::assert_snapshot!(query.dump_errors(), @r" + error: recursive pattern can never match: cycle `B` → `A` → `B` has no escape path + | + 1 | A = (foo (B)) + | - `A` references `B` (completing cycle) + 2 | B = (bar (A)) + | ^ + | | + | recursive pattern can never match: cycle `B` → `A` → `B` has no escape path + | `B` references `A` + "); +} + +#[test] +fn mutual_recursion_one_has_escape() { + let input = indoc! {r#" + A = [(x) (foo (B))] + B = (bar (A)) + "#}; + let query = Query::new(input).unwrap(); + assert!(query.is_valid()); +} + +#[test] +fn three_way_cycle_no_escape() { + let input = indoc! {r#" + A = (a (B)) + B = (b (C)) + C = (c (A)) + "#}; + let query = Query::new(input).unwrap(); + assert!(!query.is_valid()); + assert!(query.dump_errors().contains("recursive pattern")); +} + +#[test] +fn three_way_cycle_one_has_escape() { + let input = indoc! {r#" + A = [(x) (a (B))] + B = (b (C)) + C = (c (A)) + "#}; + let query = Query::new(input).unwrap(); + assert!(query.is_valid()); +} + +#[test] +fn diamond_dependency() { + let input = indoc! {r#" + A = (a [(B) (C)]) + B = (b (D)) + C = (c (D)) + D = (d (A)) + "#}; + let query = Query::new(input).unwrap(); + assert!(!query.is_valid()); + assert!(query.dump_errors().contains("recursive pattern")); +} + +#[test] +fn cycle_ref_in_field() { + let input = indoc! {r#" + A = (foo body: (B)) + B = (bar (A)) + "#}; + let query = Query::new(input).unwrap(); + assert!(!query.is_valid()); + insta::assert_snapshot!(query.dump_errors(), @r" + error: recursive pattern can never match: cycle `B` → `A` → `B` has no escape path + | + 1 | A = (foo body: (B)) + | - `A` references `B` (completing cycle) + 2 | B = (bar (A)) + | ^ + | | + | recursive pattern can never match: cycle `B` → `A` → `B` has no escape path + | `B` references `A` + "); +} + +#[test] +fn cycle_ref_in_capture() { + let input = indoc! {r#" + A = (foo (B) @cap) + B = (bar (A)) + "#}; + let query = Query::new(input).unwrap(); + assert!(!query.is_valid()); + insta::assert_snapshot!(query.dump_errors(), @r" + error: recursive pattern can never match: cycle `B` → `A` → `B` has no escape path + | + 1 | A = (foo (B) @cap) + | - `A` references `B` (completing cycle) + 2 | B = (bar (A)) + | ^ + | | + | recursive pattern can never match: cycle `B` → `A` → `B` has no escape path + | `B` references `A` + "); +} + +#[test] +fn cycle_ref_in_sequence() { + let input = indoc! {r#" + A = (foo {(x) (B)}) + B = (bar (A)) + "#}; + let query = Query::new(input).unwrap(); + assert!(!query.is_valid()); + insta::assert_snapshot!(query.dump_errors(), @r" + error: recursive pattern can never match: cycle `B` → `A` → `B` has no escape path + | + 1 | A = (foo {(x) (B)}) + | - `A` references `B` (completing cycle) + 2 | B = (bar (A)) + | ^ + | | + | recursive pattern can never match: cycle `B` → `A` → `B` has no escape path + | `B` references `A` + "); +} + +#[test] +fn cycle_with_quantifier_escape() { + let input = indoc! {r#" + A = (foo (B)?) + B = (bar (A)) + "#}; + let query = Query::new(input).unwrap(); + assert!(query.is_valid()); +} + +#[test] +fn cycle_with_plus_no_escape() { + let input = indoc! {r#" + A = (foo (B)+) + B = (bar (A)) + "#}; + let query = Query::new(input).unwrap(); + assert!(!query.is_valid()); + assert!(query.dump_errors().contains("recursive pattern")); +} + +#[test] +fn non_recursive_reference() { + let input = indoc! {r#" + Leaf = (identifier) + Tree = (call (Leaf)) + "#}; + let query = Query::new(input).unwrap(); + assert!(query.is_valid()); +} + +#[test] +fn entry_point_uses_recursive_def() { + let input = indoc! {r#" + E = [(x) (call (E))] + (program (E)) + "#}; + let query = Query::new(input).unwrap(); + assert!(query.is_valid()); +} + +#[test] +fn direct_self_ref_in_alternation() { + let query = Query::new("E = [(E) (x)]").unwrap(); + assert!(query.is_valid()); +} + +#[test] +fn escape_via_literal_string() { + let input = indoc! {r#" + A = [(A) "escape"] + "#}; + let query = Query::new(input).unwrap(); + assert!(query.is_valid()); +} + +#[test] +fn escape_via_wildcard() { + let input = indoc! {r#" + A = [(A) _] + "#}; + let query = Query::new(input).unwrap(); + assert!(query.is_valid()); +} + +#[test] +fn escape_via_childless_tree() { + let input = indoc! {r#" + A = [(A) (leaf)] + "#}; + let query = Query::new(input).unwrap(); + assert!(query.is_valid()); +} + +#[test] +fn escape_via_anchor() { + let input = indoc! {r#" + A = (foo . [(A) (x)]) + "#}; + let query = Query::new(input).unwrap(); + assert!(query.is_valid()); +} + +#[test] +fn no_escape_tree_all_recursive() { + let input = indoc! {r#" + A = (foo (A)) + "#}; + let query = Query::new(input).unwrap(); + assert!(!query.is_valid()); + assert!(query.dump_errors().contains("recursive pattern")); +} + +#[test] +fn escape_in_capture_inner() { + let input = indoc! {r#" + A = [(x)@cap (foo (A))] + "#}; + let query = Query::new(input).unwrap(); + assert!(query.is_valid()); +} + +#[test] +fn ref_in_quantifier_plus_no_escape() { + let input = indoc! {r#" + A = (foo (A)+) + "#}; + let query = Query::new(input).unwrap(); + assert!(!query.is_valid()); +}