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
12 changes: 12 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,15 @@ The `.` anchor adapts to what it's anchoring:

Rule: anchor is as strict as its strictest operand.

**Placement**: Boundary anchors require parent node context:

```
(parent . (first)) ; ✓ valid
(parent (last) .) ; ✓ valid
{(a) . (b)} ; ✓ interior anchor OK
{. (a)} ; ✗ boundary without parent
```

## Anti-patterns

```
Expand All @@ -93,6 +102,9 @@ Rule: anchor is as strict as its strictest operand.

; WRONG: predicates (unsupported)
(id) @x (#eq? @x "foo")

; WRONG: boundary anchors without parent node
{. (a)} ; use (parent {. (a)})
```

## Type System Rules
Expand Down
5 changes: 5 additions & 0 deletions crates/plotnik-lib/src/diagnostics/message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ pub enum DiagnosticKind {
RecursionNoEscape,
DirectRecursion,
FieldSequenceValue,
AnchorWithoutContext,

// Type inference errors
IncompatibleTypes,
Expand Down Expand Up @@ -147,6 +148,9 @@ impl DiagnosticKind {
Self::DirectRecursion => {
Some("recursive references must consume input before recursing")
}
Self::AnchorWithoutContext => {
Some("wrap in a named node: `(parent . (child))`")
}
_ => None,
}
}
Expand Down Expand Up @@ -201,6 +205,7 @@ impl DiagnosticKind {
Self::RecursionNoEscape => "infinite recursion: no escape path",
Self::DirectRecursion => "infinite recursion: cycle consumes no input",
Self::FieldSequenceValue => "field cannot match a sequence",
Self::AnchorWithoutContext => "boundary anchor requires parent node context",

// Type inference
Self::IncompatibleTypes => "incompatible types",
Expand Down
37 changes: 21 additions & 16 deletions crates/plotnik-lib/src/parser/tests/grammar/anchors_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -159,8 +159,9 @@ fn anchor_multiple_adjacent() {

#[test]
fn anchor_in_sequence() {
// Boundary anchors in sequences require parent node context
let input = indoc! {r#"
Q = {. (first) (second) .}
Q = (parent {. (first) (second) .})
"#};

let res = Query::expect_valid_cst(input);
Expand All @@ -170,20 +171,24 @@ fn anchor_in_sequence() {
Def
Id "Q"
Equals "="
Seq
BraceOpen "{"
Anchor
Dot "."
Tree
ParenOpen "("
Id "first"
ParenClose ")"
Tree
ParenOpen "("
Id "second"
ParenClose ")"
Anchor
Dot "."
BraceClose "}"
Tree
ParenOpen "("
Id "parent"
Seq
BraceOpen "{"
Anchor
Dot "."
Tree
ParenOpen "("
Id "first"
ParenClose ")"
Tree
ParenOpen "("
Id "second"
ParenClose ")"
Anchor
Dot "."
BraceClose "}"
ParenClose ")"
"#);
}
37 changes: 21 additions & 16 deletions crates/plotnik-lib/src/parser/tests/grammar/sequences_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -272,8 +272,9 @@ fn sequence_comma_separated_expression() {

#[test]
fn sequence_with_anchor() {
// Boundary anchors require parent node context
let input = indoc! {r#"
Q = {. (first) (second) .}
Q = (parent {. (first) (second) .})
"#};

let res = Query::expect_valid_cst(input);
Expand All @@ -283,20 +284,24 @@ fn sequence_with_anchor() {
Def
Id "Q"
Equals "="
Seq
BraceOpen "{"
Anchor
Dot "."
Tree
ParenOpen "("
Id "first"
ParenClose ")"
Tree
ParenOpen "("
Id "second"
ParenClose ")"
Anchor
Dot "."
BraceClose "}"
Tree
ParenOpen "("
Id "parent"
Seq
BraceOpen "{"
Anchor
Dot "."
Tree
ParenOpen "("
Id "first"
ParenClose ")"
Tree
ParenOpen "("
Id "second"
ParenClose ")"
Anchor
Dot "."
BraceClose "}"
ParenClose ")"
"#);
}
73 changes: 73 additions & 0 deletions crates/plotnik-lib/src/query/anchors.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
//! Semantic validation for anchor placement.
//!
//! Anchors require context to be meaningful:
//! - **Boundary anchors** (at start/end of sequence) need parent named node context
//! - **Interior anchors** (between items) are always valid
//!
//! This validation ensures anchors are placed where they can be meaningfully compiled.

use super::visitor::{Visitor, walk_named_node, walk_seq_expr};
use crate::SourceId;
use crate::diagnostics::{DiagnosticKind, Diagnostics};
use crate::parser::ast::{NamedNode, Root, SeqExpr, SeqItem};

pub fn validate_anchors(source_id: SourceId, ast: &Root, diag: &mut Diagnostics) {
let mut visitor = AnchorValidator {
diag,
source_id,
in_named_node: false,
};
visitor.visit(ast);
}

struct AnchorValidator<'a> {
diag: &'a mut Diagnostics,
source_id: SourceId,
in_named_node: bool,
}

impl Visitor for AnchorValidator<'_> {
fn visit_named_node(&mut self, node: &NamedNode) {
let prev = self.in_named_node;
self.in_named_node = true;

// Check for anchors in the named node's items
self.check_items(node.items());

// Anchors inside named node children are always valid
// (the node provides first/last/adjacent context)
walk_named_node(self, node);

self.in_named_node = prev;
}

fn visit_seq_expr(&mut self, seq: &SeqExpr) {
// Check for boundary anchors without context
self.check_items(seq.items());

walk_seq_expr(self, seq);
}
}

impl AnchorValidator<'_> {
fn check_items(&mut self, items: impl Iterator<Item = SeqItem>) {
let items: Vec<_> = items.collect();
let len = items.len();

for (i, item) in items.iter().enumerate() {
if let SeqItem::Anchor(anchor) = item {
let is_boundary = i == 0 || i == len - 1;

if is_boundary && !self.in_named_node {
self.diag
.report(
self.source_id,
DiagnosticKind::AnchorWithoutContext,
anchor.text_range(),
)
.emit();
}
}
}
}
}
171 changes: 171 additions & 0 deletions crates/plotnik-lib/src/query/anchors_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
use crate::Query;

#[test]
fn interior_anchor_always_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 anchor_inside_named_node_first() {
let input = "Q = (parent . (first))";

let res = Query::expect_valid_ast(input);

insta::assert_snapshot!(res, @r"
Root
Def Q
NamedNode parent
.
NamedNode first
");
}

#[test]
fn anchor_inside_named_node_last() {
let input = "Q = (parent (last) .)";

let res = Query::expect_valid_ast(input);

insta::assert_snapshot!(res, @r"
Root
Def Q
NamedNode parent
NamedNode last
.
");
}

#[test]
fn anchor_inside_named_node_both() {
let input = "Q = (parent . (first) (second) .)";

let res = Query::expect_valid_ast(input);

insta::assert_snapshot!(res, @r"
Root
Def Q
NamedNode parent
.
NamedNode first
NamedNode second
.
");
}

#[test]
fn anchor_in_seq_inside_named_node() {
let input = "Q = (parent {. (first)})";

let res = Query::expect_valid_ast(input);

insta::assert_snapshot!(res, @r"
Root
Def Q
NamedNode parent
Seq
.
NamedNode first
");
}

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

let res = Query::expect_invalid(input);

insta::assert_snapshot!(res, @r"
error: boundary anchor requires parent node context
|
1 | Q = {. (a)}
| ^
|
help: wrap in a named node: `(parent . (child))`
");
}

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

let res = Query::expect_invalid(input);

insta::assert_snapshot!(res, @r"
error: boundary anchor requires parent node context
|
1 | Q = {(a) .}
| ^
|
help: wrap in a named node: `(parent . (child))`
");
}

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

let res = Query::expect_invalid(input);

insta::assert_snapshot!(res, @r"
error: boundary anchor requires parent node context
|
1 | Q = {. (a) .}
| ^
|
help: wrap in a named node: `(parent . (child))`

error: boundary anchor requires parent node context
|
1 | Q = {. (a) .}
| ^
|
help: wrap in a named node: `(parent . (child))`
");
}

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

let res = Query::expect_valid_ast(input);

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

#[test]
fn nested_named_node_provides_context() {
let input = "Q = (outer (inner . (first)))";

let res = Query::expect_valid_ast(input);

insta::assert_snapshot!(res, @r"
Root
Def Q
NamedNode outer
NamedNode inner
.
NamedNode first
");
}
Loading