Skip to content

Commit 594546c

Browse files
committed
feat: Add predicate support for node text filtering
1 parent 62d6aff commit 594546c

42 files changed

Lines changed: 1379 additions & 133 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎AGENTS.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@
3030
| `[...]` | Alternation (first match wins) |
3131
| `Name = ...` | Named definition (entrypoint) |
3232
| `(Name)` | Use named expression |
33+
| `(node == "x")` | String predicate (== != ^= $= *=) |
34+
| `(node =~ /x/)` | Regex predicate (=~ !~) |
3335

3436
## Data Model Rules
3537

@@ -99,8 +101,8 @@ Rule: anchor is as strict as its strictest operand.
99101
; WRONG: dot capture syntax
100102
@function.name ; use @function_name
101103
102-
; WRONG: predicates (unsupported)
103-
(id) @x (#eq? @x "foo")
104+
; WRONG: tree-sitter predicate syntax
105+
(id) @x (#eq? @x "foo") ; use (id == "foo") @x
104106
105107
; WRONG: boundary anchors without parent node
106108
{. (a)} ; use (parent {. (a)})

‎Cargo.lock‎

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎crates/plotnik-cli/src/commands/exec.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ pub fn run(args: ExecArgs) {
3535
color: args.color,
3636
});
3737

38-
let vm = VM::builder(&tree).trivia_types(trivia_types).build();
38+
let vm = VM::builder(&source_code, &tree).trivia_types(trivia_types).build();
3939
let effects = match vm.execute(&module, 0, &entrypoint) {
4040
Ok(effects) => effects,
4141
Err(RuntimeError::NoMatch) => {

‎crates/plotnik-cli/src/commands/trace.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ pub fn run(args: TraceArgs) {
3939
color: args.color,
4040
});
4141

42-
let vm = VM::builder(&tree)
42+
let vm = VM::builder(&source_code, &tree)
4343
.trivia_types(trivia_types)
4444
.exec_fuel(args.fuel)
4545
.build();

‎crates/plotnik-lib/Cargo.toml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ crc32fast = "1.4"
2525
memmap2 = "0.9"
2626
plotnik-core.workspace = true
2727
plotnik-langs = { workspace = true, optional = true }
28+
regex-automata = { version = "0.4", features = ["dfa-build", "dfa-search"] }
29+
regex-syntax = "0.8"
2830

2931
[features]
3032
default = ["plotnik-langs"]

‎crates/plotnik-lib/src/analyze/link.rs‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,21 @@ impl<'a, 'q> Linker<'a, 'q> {
188188
Expr::NamedNode(node) => {
189189
let child_ctx = self.make_node_context(node);
190190

191+
// Predicates are only valid on leaf nodes (grammar check)
192+
if let Some(pred) = node.predicate()
193+
&& let Some(ctx) = &child_ctx
194+
&& (!self.lang.valid_child_types(ctx.parent_id).is_empty()
195+
|| !self.lang.fields_for_node_type(ctx.parent_id).is_empty())
196+
{
197+
self.diagnostics
198+
.report(
199+
self.source_id,
200+
DiagnosticKind::PredicateOnNonLeaf,
201+
pred.as_cst().text_range(),
202+
)
203+
.emit();
204+
}
205+
191206
for child in node.children() {
192207
if let Expr::FieldExpr(f) = &child {
193208
self.validate_field_expr(f, child_ctx.as_ref(), visited);

‎crates/plotnik-lib/src/analyze/link_tests.rs‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,26 @@
11
use crate::Query;
22
use indoc::indoc;
33

4+
#[test]
5+
fn predicate_on_non_leaf() {
6+
let input = r"Q = (function_declaration == 'foo')";
7+
8+
let res = Query::expect_invalid_linking(input);
9+
10+
insta::assert_snapshot!(res, @r"
11+
error: predicates match text content, but this node can contain children
12+
|
13+
1 | Q = (function_declaration == 'foo')
14+
| ^^^^^^^^
15+
");
16+
}
17+
18+
#[test]
19+
fn predicate_on_leaf_valid() {
20+
let input = r#"Q = (identifier == "foo")"#;
21+
Query::expect_valid_linking(input);
22+
}
23+
424
#[test]
525
fn valid_query_with_field() {
626
let input = indoc! {r#"

‎crates/plotnik-lib/src/analyze/mod.rs‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,5 +32,7 @@ pub use link::LinkOutput;
3232
pub use recursion::validate_recursion;
3333
pub use symbol_table::{SymbolTable, UNNAMED_DEF};
3434
pub use type_check::{TypeContext, infer_types, primary_def_name};
35-
pub use validation::{validate_alt_kinds, validate_anchors, validate_empty_constructs};
35+
pub use validation::{
36+
validate_alt_kinds, validate_anchors, validate_empty_constructs, validate_predicates,
37+
};
3638
pub use visitor::{Visitor, walk_expr};

‎crates/plotnik-lib/src/analyze/validation/mod.rs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,23 @@
44
//! - Alternation kind consistency (alt_kinds)
55
//! - Anchor placement rules (anchors)
66
//! - Empty constructs (empty_constructs)
7+
//! - Predicate regex patterns (predicates)
78
89
pub mod alt_kinds;
910
pub mod anchors;
1011
pub mod empty_constructs;
12+
pub mod predicates;
1113

1214
#[cfg(test)]
1315
mod alt_kinds_tests;
1416
#[cfg(test)]
1517
mod anchors_tests;
1618
#[cfg(test)]
1719
mod empty_constructs_tests;
20+
#[cfg(test)]
21+
mod predicates_tests;
1822

1923
pub use alt_kinds::validate_alt_kinds;
2024
pub use anchors::validate_anchors;
2125
pub use empty_constructs::validate_empty_constructs;
26+
pub use predicates::validate_predicates;
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
//! Predicate validation.
2+
//!
3+
//! Validates regex patterns in predicates for unsupported features:
4+
//! - Backreferences (`\1`)
5+
//! - Lookahead/lookbehind (`(?=...)`, `(?!...)`, etc.)
6+
//! - Named captures (`(?P<name>...)`)
7+
8+
use regex_syntax::ast::{self, visit, Ast, GroupKind, Visitor as RegexVisitor};
9+
use rowan::TextRange;
10+
11+
use crate::SourceId;
12+
use crate::analyze::visitor::{Visitor, walk_named_node};
13+
use crate::diagnostics::{DiagnosticKind, Diagnostics};
14+
use crate::parser::{NamedNode, Root};
15+
16+
pub fn validate_predicates(
17+
source_id: SourceId,
18+
source: &str,
19+
ast: &Root,
20+
diag: &mut Diagnostics,
21+
) {
22+
let mut validator = PredicateValidator {
23+
diag,
24+
source_id,
25+
source,
26+
};
27+
validator.visit(ast);
28+
}
29+
30+
struct PredicateValidator<'q, 'd> {
31+
diag: &'d mut Diagnostics,
32+
source_id: SourceId,
33+
source: &'q str,
34+
}
35+
36+
impl Visitor for PredicateValidator<'_, '_> {
37+
fn visit_named_node(&mut self, node: &NamedNode) {
38+
// Validate regex syntax if this is a regex predicate
39+
if let Some(pred) = node.predicate()
40+
&& let Some(op) = pred.operator()
41+
&& op.is_regex_op()
42+
&& let Some(regex) = pred.regex()
43+
{
44+
self.validate_regex(regex.pattern(self.source), regex.text_range());
45+
}
46+
walk_named_node(self, node);
47+
}
48+
}
49+
50+
impl PredicateValidator<'_, '_> {
51+
fn validate_regex(&mut self, pattern: &str, regex_range: TextRange) {
52+
// Reject empty regex patterns
53+
if pattern.is_empty() {
54+
self.diag
55+
.report(self.source_id, DiagnosticKind::EmptyRegex, regex_range)
56+
.emit();
57+
return;
58+
}
59+
60+
// Parse with octal disabled so \1-\9 are backreferences, not octal
61+
let parser_result = ast::parse::ParserBuilder::new()
62+
.octal(false)
63+
.build()
64+
.parse(pattern);
65+
66+
let parsed_ast = match parser_result {
67+
Ok(ast) => ast,
68+
Err(e) => {
69+
let span = self.map_regex_span(e.span(), regex_range);
70+
let report = match e.kind() {
71+
ast::ErrorKind::UnsupportedBackreference => {
72+
self.diag.report(self.source_id, DiagnosticKind::RegexBackreference, span)
73+
}
74+
ast::ErrorKind::UnsupportedLookAround => {
75+
// Skip the opening `(` - point at `?=` / `?!` / `?<=` / `?<!`
76+
use rowan::TextSize;
77+
let adjusted = TextRange::new(span.start() + TextSize::from(1u32), span.end());
78+
self.diag.report(self.source_id, DiagnosticKind::RegexLookaround, adjusted)
79+
}
80+
_ => self
81+
.diag
82+
.report(self.source_id, DiagnosticKind::RegexSyntaxError, span)
83+
.message(format!("{}", e.kind())),
84+
};
85+
report.emit();
86+
return;
87+
}
88+
};
89+
90+
// Walk AST to find named captures
91+
let detector = NamedCaptureDetector {
92+
named_captures: Vec::new(),
93+
};
94+
let detector = visit(&parsed_ast, detector).unwrap();
95+
96+
for capture_span in detector.named_captures {
97+
let span = self.map_regex_span(&capture_span, regex_range);
98+
self.diag
99+
.report(self.source_id, DiagnosticKind::RegexNamedCapture, span)
100+
.emit();
101+
}
102+
}
103+
104+
/// Map a span within the regex pattern to a span in the query source.
105+
fn map_regex_span(&self, regex_span: &ast::Span, regex_range: TextRange) -> TextRange {
106+
// regex_range includes the `/` delimiters, so content starts at +1
107+
let content_start = u32::from(regex_range.start()) + 1;
108+
let start = content_start + regex_span.start.offset as u32;
109+
let end = content_start + regex_span.end.offset as u32;
110+
TextRange::new(start.into(), end.into())
111+
}
112+
}
113+
114+
struct NamedCaptureDetector {
115+
named_captures: Vec<ast::Span>,
116+
}
117+
118+
impl RegexVisitor for NamedCaptureDetector {
119+
type Output = Self;
120+
type Err = std::convert::Infallible;
121+
122+
fn finish(self) -> Result<Self::Output, Self::Err> {
123+
Ok(self)
124+
}
125+
126+
fn visit_pre(&mut self, ast: &Ast) -> Result<(), Self::Err> {
127+
if let Ast::Group(group) = ast
128+
&& let GroupKind::CaptureName { name, .. } = &group.kind
129+
{
130+
// Span for `?P<name>` (skip opening paren, include closing `>`)
131+
let start = ast::Position::new(group.span.start.offset + 1, group.span.start.line, group.span.start.column + 1);
132+
let end = ast::Position::new(name.span.end.offset + 1, name.span.end.line, name.span.end.column + 1);
133+
self.named_captures.push(ast::Span::new(start, end));
134+
}
135+
Ok(())
136+
}
137+
}

0 commit comments

Comments
 (0)