Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/plotnik-lib/src/analyze/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,5 @@ pub use link::LinkOutput;
pub use recursion::validate_recursion;
pub use symbol_table::{SymbolTable, UNNAMED_DEF};
pub use type_check::{TypeContext, infer_types, primary_def_name};
pub use validation::{validate_alt_kinds, validate_anchors};
pub use validation::{validate_alt_kinds, validate_anchors, validate_empty_constructs};
pub use visitor::{Visitor, walk_expr};
57 changes: 57 additions & 0 deletions crates/plotnik-lib/src/analyze/validation/empty_constructs.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
//! Semantic validation for empty constructs.
//!
//! Bans empty trees `()`, empty sequences `{}`, and empty alternations `[]`.

use crate::SourceId;
use crate::analyze::visitor::{Visitor, walk_alt_expr, walk_named_node, walk_seq_expr};
use crate::diagnostics::{DiagnosticKind, Diagnostics};
use crate::parser::{AltExpr, NamedNode, Root, SeqExpr};

pub fn validate_empty_constructs(source_id: SourceId, ast: &Root, diag: &mut Diagnostics) {
let mut visitor = EmptyConstructsValidator { diag, source_id };
visitor.visit(ast);
}

struct EmptyConstructsValidator<'a> {
diag: &'a mut Diagnostics,
source_id: SourceId,
}

impl Visitor for EmptyConstructsValidator<'_> {
fn visit_named_node(&mut self, node: &NamedNode) {
// Check for truly empty tree: no child nodes at all in CST (only tokens like parens)
// This excludes invalid content like predicates which create Error nodes
if node.as_cst().children().next().is_none() && node.node_type().is_none() {
self.diag
.report(self.source_id, DiagnosticKind::EmptyTree, node.text_range())
.emit();
}
walk_named_node(self, node);
}

fn visit_seq_expr(&mut self, seq: &SeqExpr) {
if seq.children().next().is_none() {
self.diag
.report(
self.source_id,
DiagnosticKind::EmptySequence,
seq.text_range(),
)
.emit();
}
walk_seq_expr(self, seq);
}

fn visit_alt_expr(&mut self, alt: &AltExpr) {
if alt.branches().next().is_none() {
self.diag
.report(
self.source_id,
DiagnosticKind::EmptyAlternation,
alt.text_range(),
)
.emit();
}
walk_alt_expr(self, alt);
}
}
209 changes: 209 additions & 0 deletions crates/plotnik-lib/src/analyze/validation/empty_constructs_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
use crate::Query;

#[test]
fn empty_tree() {
let input = "Q = ()";

let res = Query::expect_invalid(input);

insta::assert_snapshot!(res, @r"
error: empty `()` is not allowed
|
1 | Q = ()
| ^^
|
help: use `(_)` to match any named node, or `_` for any node
");
}

#[test]
fn empty_tree_with_whitespace() {
let input = "Q = ( )";

let res = Query::expect_invalid(input);

insta::assert_snapshot!(res, @r"
error: empty `()` is not allowed
|
1 | Q = ( )
| ^^^^^
|
help: use `(_)` to match any named node, or `_` for any node
");
}

#[test]
fn empty_tree_with_comment() {
let input = "Q = ( /* comment */ )";

let res = Query::expect_invalid(input);

insta::assert_snapshot!(res, @r"
error: empty `()` is not allowed
|
1 | Q = ( /* comment */ )
| ^^^^^^^^^^^^^^^^^
|
help: use `(_)` to match any named node, or `_` for any node
");
}

#[test]
fn empty_sequence() {
let input = "Q = {}";

let res = Query::expect_invalid(input);

insta::assert_snapshot!(res, @r"
error: empty `{}` is not allowed
|
1 | Q = {}
| ^^
|
help: sequences must contain at least one expression
");
}

#[test]
fn empty_sequence_with_whitespace() {
let input = "Q = { }";

let res = Query::expect_invalid(input);

insta::assert_snapshot!(res, @r"
error: empty `{}` is not allowed
|
1 | Q = { }
| ^^^^^
|
help: sequences must contain at least one expression
");
}

#[test]
fn empty_sequence_with_comment() {
let input = "Q = { /* comment */ }";

let res = Query::expect_invalid(input);

insta::assert_snapshot!(res, @r"
error: empty `{}` is not allowed
|
1 | Q = { /* comment */ }
| ^^^^^^^^^^^^^^^^^
|
help: sequences must contain at least one expression
");
}

#[test]
fn empty_alternation() {
let input = "Q = []";

let res = Query::expect_invalid(input);

insta::assert_snapshot!(res, @r"
error: empty `[]` is not allowed
|
1 | Q = []
| ^^
|
help: alternations must contain at least one branch
");
}

#[test]
fn empty_alternation_with_whitespace() {
let input = "Q = [ ]";

let res = Query::expect_invalid(input);

insta::assert_snapshot!(res, @r"
error: empty `[]` is not allowed
|
1 | Q = [ ]
| ^^^^^
|
help: alternations must contain at least one branch
");
}

#[test]
fn empty_alternation_with_comment() {
let input = "Q = [ /* comment */ ]";

let res = Query::expect_invalid(input);

insta::assert_snapshot!(res, @r"
error: empty `[]` is not allowed
|
1 | Q = [ /* comment */ ]
| ^^^^^^^^^^^^^^^^^
|
help: alternations must contain at least one branch
");
}

#[test]
fn nested_empty_sequence() {
let input = "Q = (foo {})";

let res = Query::expect_invalid(input);

insta::assert_snapshot!(res, @r"
error: empty `{}` is not allowed
|
1 | Q = (foo {})
| ^^
|
help: sequences must contain at least one expression
");
}

#[test]
fn nested_empty_alternation() {
let input = "Q = (foo [])";

let res = Query::expect_invalid(input);

insta::assert_snapshot!(res, @r"
error: empty `[]` is not allowed
|
1 | Q = (foo [])
| ^^
|
help: alternations must contain at least one branch
");
}

#[test]
fn non_empty_sequence_valid() {
let input = "Q = {(a) (b)}";

let res = Query::expect_valid_ast(input);

insta::assert_snapshot!(res, @r"
Root
Def Q
Seq
NamedNode a
NamedNode b
");
}

#[test]
fn non_empty_alternation_valid() {
let input = "Q = [(a) (b)]";

let res = Query::expect_valid_ast(input);

insta::assert_snapshot!(res, @r"
Root
Def Q
Alt
Branch
NamedNode a
Branch
NamedNode b
");
}
5 changes: 5 additions & 0 deletions crates/plotnik-lib/src/analyze/validation/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,19 @@
//! Validates semantic constraints that aren't captured by parsing or type checking:
//! - Alternation kind consistency (alt_kinds)
//! - Anchor placement rules (anchors)
//! - Empty constructs (empty_constructs)

pub mod alt_kinds;
pub mod anchors;
pub mod empty_constructs;

#[cfg(test)]
mod alt_kinds_tests;
#[cfg(test)]
mod anchors_tests;
#[cfg(test)]
mod empty_constructs_tests;

pub use alt_kinds::validate_alt_kinds;
pub use anchors::validate_anchors;
pub use empty_constructs::validate_empty_constructs;
6 changes: 6 additions & 0 deletions crates/plotnik-lib/src/diagnostics/message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ pub enum DiagnosticKind {
// User wrote something that doesn't belong
EmptyTree,
EmptyAnonymousNode,
EmptySequence,
EmptyAlternation,
BareIdentifier,
InvalidSeparator,
AnchorInAlternation,
Expand Down Expand Up @@ -146,6 +148,8 @@ impl DiagnosticKind {
Self::ExpectedFieldName => Some("e.g., `-value`"),
Self::EmptyTree => Some("use `(_)` to match any named node, or `_` for any node"),
Self::EmptyAnonymousNode => Some("use a valid anonymous node or remove it"),
Self::EmptySequence => Some("sequences must contain at least one expression"),
Self::EmptyAlternation => Some("alternations must contain at least one branch"),
Self::TreeSitterSequenceSyntax => Some("use `{...}` for sequences"),
Self::NegationSyntaxDeprecated => Some("use `-field` instead of `!field`"),
Self::MixedAltBranches => {
Expand Down Expand Up @@ -185,6 +189,8 @@ impl DiagnosticKind {
// Invalid syntax
Self::EmptyTree => "empty `()` is not allowed",
Self::EmptyAnonymousNode => "empty anonymous node",
Self::EmptySequence => "empty `{}` is not allowed",
Self::EmptyAlternation => "empty `[]` is not allowed",
Self::BareIdentifier => "bare identifier is not valid",
Self::InvalidSeparator => "unexpected separator",
Self::AnchorInAlternation => "anchors cannot appear directly in alternations",
Expand Down
20 changes: 0 additions & 20 deletions crates/plotnik-lib/src/engine/engine_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -529,23 +529,3 @@ fn wildcard_named_skips_anonymous() {
fn wildcard_bare_matches_anonymous() {
snap!("Q = (program (return_statement _ @x))", "return 42");
}

/// Empty captured sequence should produce empty struct, not null or panic.
/// Regression test for: type inference treating `{ }` as Node instead of empty struct.
#[test]
fn regression_empty_captured_sequence() {
snap!(
"Q = (program (expression_statement (identifier) @id { } @empty))",
"x"
);
}

/// Optional empty captured sequence should produce empty struct when matched.
/// Regression test for: `{ }? @maybe` producing null instead of `{}`.
#[test]
fn regression_optional_empty_captured_sequence() {
snap!(
"Q = (program (expression_statement (identifier) @id { }? @maybe))",
"x"
);
}
5 changes: 1 addition & 4 deletions crates/plotnik-lib/src/parser/grammar/structures.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,8 @@ impl Parser<'_, '_> {

match self.current() {
SyntaxKind::ParenClose => {
// Empty tree `()` - validation phase will report EmptyTree error
self.start_node_at(checkpoint, SyntaxKind::Tree);
self.diagnostics
.report(self.source_id, DiagnosticKind::EmptyTree, open_paren_span)
.emit();
// Fall through to close
}
SyntaxKind::Underscore => {
self.start_node_at(checkpoint, SyntaxKind::Tree);
Expand Down
21 changes: 10 additions & 11 deletions crates/plotnik-lib/src/parser/tests/grammar/sequences_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,17 +107,16 @@ fn empty_sequence() {
Q = {}
"#};

let res = Query::expect_valid_cst(input);

insta::assert_snapshot!(res, @r#"
Root
Def
Id "Q"
Equals "="
Seq
BraceOpen "{"
BraceClose "}"
"#);
let res = Query::expect_invalid(input);

insta::assert_snapshot!(res, @r"
error: empty `{}` is not allowed
|
1 | Q = {}
| ^^
|
help: sequences must contain at least one expression
");
}

#[test]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ fn empty_parens() {
error: empty `()` is not allowed
|
1 | ()
| ^
| ^^
|
help: use `(_)` to match any named node, or `_` for any node
");
Expand Down
Loading