sqry_db/planner/ir.rs
1//! Query plan intermediate representation.
2//!
3//! The IR is **snapshot-independent**: no `StringId`, `NodeId`, or other
4//! interner-bound handles appear anywhere. This means a single plan can be
5//! compiled once and evaluated against any compatible graph snapshot, and two
6//! plans constructed independently but with identical semantics hash to the
7//! same value — a property the fuser in [`super::fuse`] relies on.
8//!
9//! # Types in this module
10//!
11//! | Type | Role |
12//! |------|------|
13//! | [`QueryPlan`] | Top-level wrapper produced by [`super::compile::QueryBuilder`] (DB10). |
14//! | [`PlanNode`] | Recursive operator tree. Every node produces a node-set. |
15//! | [`Direction`] | Edge traversal direction (outgoing/incoming/both). |
16//! | [`SetOperation`] | Pairwise set algebra (union/intersect/difference). |
17//! | [`Predicate`] | Filter condition — existence checks, relation predicates, boolean combinators. |
18//! | [`PredicateValue`] | Right-hand side of a relation predicate: pattern, regex, or nested subquery. |
19//! | [`StringPattern`] | Name-matching pattern with [`MatchMode`] discriminator. |
20//! | [`PathPattern`] | File-path glob matching, always glob-based. |
21//! | [`RegexPattern`] | Regex source + compilation flags. |
22//! | [`MatchMode`] | Exact / Glob / Prefix / Suffix / Contains modes for [`StringPattern`]. |
23//!
24//! # Design Notes
25//!
26//! ## Edge kind filtering
27//!
28//! [`PlanNode::EdgeTraversal`] carries `edge_kind: Option<EdgeKind>`, matching
29//! the spec (§3). Because [`EdgeKind`] contains metadata fields that are
30//! irrelevant to traversal (e.g., `Calls { argument_count, is_async }`), the
31//! [`QueryBuilder`] is expected to construct [`EdgeKind`] values with zeroed
32//! metadata (e.g., `EdgeKind::Calls { argument_count: 0, is_async: false }`)
33//! so that plan hashing remains stable for the fuser. The executor matches
34//! by `std::mem::discriminant`, matching the pattern used by
35//! [`crate::queries::ReachabilityQuery`].
36//!
37//! ## Scope filtering
38//!
39//! [`Predicate::InScope`] re-uses [`ScopeKind`] from the binding plane
40//! (`sqry-core::graph::unified::bind::scope::arena::ScopeKind`). The existing
41//! enum is already `Copy`, `Hash`, `Eq`, `Serialize`, `Deserialize`, so no
42//! wrapper type is introduced.
43//!
44//! ## Subqueries
45//!
46//! [`PredicateValue::Subquery`] is boxed to break the `Predicate`→`PlanNode`
47//! recursion cycle. Subqueries are evaluated on-demand by the executor,
48//! and their results are cached through [`crate::QueryDb::get`].
49//!
50//! ## Serialization
51//!
52//! Every IR type derives `Serialize` and `Deserialize`. This is required so
53//! that plans can be fingerprinted for the fuser, embedded in structured log
54//! output, and (in DB22) persisted in `.sqry/graph/derived.sqry` as part of
55//! hot-cache keys.
56//!
57//! [`EdgeKind`]: sqry_core::graph::unified::edge::kind::EdgeKind
58//! [`ScopeKind`]: sqry_core::graph::unified::bind::scope::arena::ScopeKind
59//! [`QueryBuilder`]: super::compile::QueryBuilder
60
61use serde::{Deserialize, Serialize};
62
63use sqry_core::graph::unified::bind::scope::arena::ScopeKind;
64use sqry_core::graph::unified::edge::kind::EdgeKind;
65use sqry_core::graph::unified::node::kind::NodeKind;
66use sqry_core::schema::Visibility;
67
68/// Top-level query plan produced by the compiler.
69///
70/// A plan is a rooted tree of [`PlanNode`] operators. The executor walks the
71/// tree from the root, evaluating each node's output into a node-set and
72/// feeding it forward through [`PlanNode::Chain`] and [`PlanNode::SetOp`]
73/// combinators.
74///
75/// The thin wrapper exists so that:
76///
77/// - the fuser in [`super::fuse`] can operate on a list of `QueryPlan`s
78/// without confusion about which `PlanNode` is the root,
79/// - plan-level metadata can be added later (e.g., execution hints, limits)
80/// without touching every [`PlanNode`] variant.
81#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
82pub struct QueryPlan {
83 /// The root operator of the plan tree.
84 pub root: PlanNode,
85}
86
87impl QueryPlan {
88 /// Creates a new plan wrapping the given root operator.
89 #[must_use]
90 pub fn new(root: PlanNode) -> Self {
91 Self { root }
92 }
93
94 /// Returns a reference to the plan's root operator.
95 #[inline]
96 #[must_use]
97 pub fn root(&self) -> &PlanNode {
98 &self.root
99 }
100
101 /// Returns the total number of [`PlanNode`] operators in the tree,
102 /// counting every node exactly once.
103 ///
104 /// Useful for plan-complexity metrics, fusion heuristics, and tests.
105 #[must_use]
106 pub fn operator_count(&self) -> usize {
107 self.root.operator_count()
108 }
109}
110
111/// Operator tree for a structural query.
112///
113/// Every variant produces a node-set. Consumers treat the resulting
114/// `Vec<NodeId>` as a sorted, deduplicated sequence.
115///
116/// # Variant semantics
117///
118/// - [`PlanNode::NodeScan`] scans the graph and emits every node matching the
119/// supplied filters. This is the only variant without an input set.
120/// - [`PlanNode::EdgeTraversal`] requires an input set (supplied by the
121/// enclosing [`PlanNode::Chain`]) and follows edges from those nodes.
122/// - [`PlanNode::Filter`] narrows its input set by evaluating the predicate.
123/// - [`PlanNode::SetOp`] evaluates two sub-plans independently and combines
124/// the results. Neither side inherits an input set from an enclosing chain.
125/// - [`PlanNode::Chain`] threads the output of each step into the next step's
126/// input. The first step must be context-free (a `NodeScan` or `SetOp`).
127#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
128#[serde(rename_all = "snake_case")]
129pub enum PlanNode {
130 /// Scan all nodes matching the given filters.
131 ///
132 /// Each filter is an optional narrowing condition; `None` means
133 /// "accept any value". `kind = None`, `visibility = None`,
134 /// `name_pattern = None` yields a full node scan.
135 NodeScan {
136 /// Optional kind filter (Function, Method, Class, ...).
137 kind: Option<NodeKind>,
138 /// Optional visibility filter (Public, Private).
139 visibility: Option<Visibility>,
140 /// Optional symbol-name pattern filter.
141 name_pattern: Option<StringPattern>,
142 },
143 /// Follow edges outward from the input set.
144 ///
145 /// `edge_kind = None` matches any edge kind. `max_depth = 1` performs a
146 /// single hop; values greater than one perform a bounded BFS.
147 EdgeTraversal {
148 /// Direction in which to follow edges.
149 direction: Direction,
150 /// Optional edge-kind filter. See module docs for how the executor
151 /// compares variants by discriminant.
152 edge_kind: Option<EdgeKind>,
153 /// Maximum hops to follow. Must be `>= 1` to produce any output.
154 max_depth: u32,
155 },
156 /// Narrow the input set with a predicate.
157 Filter {
158 /// The predicate to evaluate for each input node.
159 predicate: Predicate,
160 },
161 /// Combine two independently-evaluated sub-plans with a set operation.
162 SetOp {
163 /// The set operation: Union, Intersect, or Difference.
164 op: SetOperation,
165 /// Left-hand operand sub-plan.
166 left: Box<PlanNode>,
167 /// Right-hand operand sub-plan.
168 right: Box<PlanNode>,
169 },
170 /// Pipeline a sequence of steps, threading each step's output into the
171 /// next step's input.
172 ///
173 /// The first step must be context-free (a [`PlanNode::NodeScan`] or
174 /// [`PlanNode::SetOp`]); subsequent steps are applied to the running set.
175 Chain {
176 /// Steps to evaluate in order. An empty vec yields the empty set.
177 steps: Vec<PlanNode>,
178 },
179}
180
181impl PlanNode {
182 /// Returns the total number of operators in this subtree.
183 ///
184 /// Counts this node plus all descendants through
185 /// [`PlanNode::SetOp::left`], [`PlanNode::SetOp::right`], and
186 /// [`PlanNode::Chain::steps`]. [`PlanNode::Filter`]'s predicate tree
187 /// is *not* counted here — predicate trees have their own complexity.
188 #[must_use]
189 pub fn operator_count(&self) -> usize {
190 match self {
191 PlanNode::NodeScan { .. }
192 | PlanNode::EdgeTraversal { .. }
193 | PlanNode::Filter { .. } => 1,
194 PlanNode::SetOp { left, right, .. } => {
195 1 + left.operator_count() + right.operator_count()
196 }
197 PlanNode::Chain { steps } => {
198 1 + steps.iter().map(PlanNode::operator_count).sum::<usize>()
199 }
200 }
201 }
202
203 /// Returns `true` if this node has no input dependency (can be evaluated
204 /// in isolation).
205 ///
206 /// Used by the compiler to validate [`PlanNode::Chain::steps`] — the
207 /// first step must be context-free.
208 #[must_use]
209 pub fn is_context_free(&self) -> bool {
210 matches!(self, PlanNode::NodeScan { .. } | PlanNode::SetOp { .. })
211 }
212}
213
214/// Direction of an edge traversal relative to the input node set.
215///
216/// # Semantics
217///
218/// - [`Direction::Forward`] — follow outgoing edges (`source -> target`).
219/// `callees:X` is a forward traversal of `Calls` edges from `X`.
220/// - [`Direction::Reverse`] — follow incoming edges (`target -> source`).
221/// `callers:X` is a reverse traversal of `Calls` edges into `X`.
222/// - [`Direction::Both`] — follow edges in both directions. Useful for
223/// symmetric relations and for impact analysis that should include both
224/// callers and callees.
225#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
226#[serde(rename_all = "snake_case")]
227pub enum Direction {
228 /// Follow outgoing edges (`source -> target`).
229 Forward,
230 /// Follow incoming edges (`target -> source`).
231 Reverse,
232 /// Follow edges in both directions.
233 Both,
234}
235
236impl Direction {
237 /// Returns the inverse direction.
238 ///
239 /// `Forward <-> Reverse`, `Both` is its own inverse.
240 #[must_use]
241 pub const fn invert(self) -> Self {
242 match self {
243 Direction::Forward => Direction::Reverse,
244 Direction::Reverse => Direction::Forward,
245 Direction::Both => Direction::Both,
246 }
247 }
248}
249
250/// Set algebra on two node-set results.
251///
252/// All three operators are commutative in the output set, but order is
253/// preserved in the IR so that the executor can stream the smaller side
254/// first in the fuser pass (DB11).
255#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
256#[serde(rename_all = "snake_case")]
257pub enum SetOperation {
258 /// Union: `left ∪ right`.
259 Union,
260 /// Intersection: `left ∩ right`.
261 Intersect,
262 /// Difference: `left \ right` (elements in left but not right).
263 Difference,
264}
265
266/// Filter condition applied to a node-set.
267///
268/// Predicates fall into four groups:
269///
270/// 1. **Existence checks**: [`Predicate::HasCaller`], [`Predicate::HasCallee`],
271/// [`Predicate::IsUnused`]. Cheap — no value or subquery traversal needed.
272/// 2. **Value-bearing relation predicates**: [`Predicate::Callers`] through
273/// [`Predicate::Implements`]. These accept a [`PredicateValue`] on the
274/// right-hand side — a literal pattern, a regex, or a nested subquery.
275/// They map 1:1 to the six relation handlers in
276/// `sqry-core::query::executor::graph_eval` (`match_callers`,
277/// `match_callees`, `match_imports`, `match_exports`, `match_references`,
278/// `match_implements`) and their `_subquery` variants.
279/// 3. **Attribute filters**: [`Predicate::InFile`], [`Predicate::InScope`],
280/// [`Predicate::MatchesName`].
281/// 4. **Boolean combinators**: [`Predicate::And`], [`Predicate::Or`],
282/// [`Predicate::Not`].
283///
284/// # Semantic alignment with `graph_eval`
285///
286/// The six relation variants exactly mirror the value-bearing operators in
287/// `sqry-core::query::types::Value`:
288///
289/// | This IR | `graph_eval` handler | Text syntax |
290/// |---------|----------------------|-------------|
291/// | `Callers(v)` | `match_callers` / `match_callers_subquery` | `callers:v` |
292/// | `Callees(v)` | `match_callees` / `match_callees_subquery` | `callees:v` |
293/// | `Imports(v)` | `match_imports` / `match_imports_subquery` | `imports:v` |
294/// | `Exports(v)` | `match_exports` / `match_exports_subquery` | `exports:v` |
295/// | `References(v)` | `match_references` / `match_references_subquery` | `references:v` (supports `~=` regex) |
296/// | `Implements(v)` | `match_implements` / `match_implements_subquery` | `impl:v` / `implements:v` |
297///
298/// The text frontend in DB13 must accept both `impl:` and `implements:`
299/// aliases for [`Predicate::Implements`] (spec §M8).
300#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
301#[serde(rename_all = "snake_case")]
302pub enum Predicate {
303 // --- Existence checks ---
304 /// True iff the node has at least one incoming `Calls` edge.
305 HasCaller,
306 /// True iff the node has at least one outgoing `Calls` edge.
307 HasCallee,
308 /// True iff the node is not reachable from any entry point.
309 IsUnused,
310
311 // --- Value-bearing relation predicates ---
312 /// `callers:<value>`: node's callers match the pattern / subquery.
313 Callers(PredicateValue),
314 /// `callees:<value>`: node's callees match the pattern / subquery.
315 Callees(PredicateValue),
316 /// `imports:<value>`: node's imports match the pattern / subquery.
317 Imports(PredicateValue),
318 /// `exports:<value>`: node's exports match the pattern / subquery.
319 Exports(PredicateValue),
320 /// `references:<value>` (also supports `~=` regex via
321 /// [`PredicateValue::Regex`]).
322 References(PredicateValue),
323 /// `impl:<value>` / `implements:<value>`: matches nodes implementing
324 /// the referenced trait / interface.
325 Implements(PredicateValue),
326
327 // --- Attribute filters ---
328 /// `in:<path-glob>`: true iff the node's file path matches the glob.
329 InFile(PathPattern),
330 /// `scope:<kind>`: true iff the node's enclosing scope kind matches.
331 InScope(ScopeKind),
332 /// `name:<pattern>`: true iff the node's name matches the pattern.
333 MatchesName(StringPattern),
334 /// `returns:<TypeName>`: true iff the node (a function or method) has at
335 /// least one outgoing
336 /// [`EdgeKind::TypeOf { context: Some(TypeOfContext::Return), .. }`][crate::queries]
337 /// edge whose target node's interned name matches `TypeName` exactly.
338 ///
339 /// # Semantics
340 ///
341 /// The match is evaluated **edge-based, not signature-text-based**: the
342 /// executor walks `TypeOf` edges with `TypeOfContext::Return` from the
343 /// candidate node, resolves the target node's primary name through the
344 /// snapshot string interner, and compares with byte-exact equality
345 /// (case-sensitive). Substring, glob, and regex variants are deliberately
346 /// out of scope for this predicate; a future `returns~:` token (regex
347 /// form) will lower to a separate IR variant rather than overload this
348 /// one — the same shape used today for [`Predicate::References`] vs the
349 /// `references ~= /…/` regex form.
350 ///
351 /// # Why a bare `String` instead of [`StringPattern`]?
352 ///
353 /// [`StringPattern`] auto-detects glob meta-characters and promotes to
354 /// [`MatchMode::Glob`]; modelling the predicate value with `String`
355 /// keeps the contract narrow — `returns:Foo*` is a parse error in
356 /// users' minds, not a hidden glob expansion. The single-mode shape
357 /// also keeps cache keys monomorphic which simplifies any future
358 /// derived-query backing for this predicate.
359 Returns(String),
360
361 // --- Boolean combinators ---
362 /// Logical AND over a list of predicates. Empty list is vacuously true.
363 And(Vec<Predicate>),
364 /// Logical OR over a list of predicates. Empty list is vacuously false.
365 Or(Vec<Predicate>),
366 /// Logical NOT of a single predicate.
367 Not(Box<Predicate>),
368}
369
370impl Predicate {
371 /// Returns `true` if this predicate (or any nested predicate through
372 /// boolean combinators) references a [`PredicateValue::Subquery`].
373 ///
374 /// The executor uses this hint to decide whether to allocate subquery
375 /// evaluation scratch space up front.
376 #[must_use]
377 pub fn has_subquery(&self) -> bool {
378 match self {
379 Predicate::HasCaller
380 | Predicate::HasCallee
381 | Predicate::IsUnused
382 | Predicate::InFile(_)
383 | Predicate::InScope(_)
384 | Predicate::MatchesName(_)
385 | Predicate::Returns(_) => false,
386
387 Predicate::Callers(v)
388 | Predicate::Callees(v)
389 | Predicate::Imports(v)
390 | Predicate::Exports(v)
391 | Predicate::References(v)
392 | Predicate::Implements(v) => v.is_subquery(),
393
394 Predicate::And(list) | Predicate::Or(list) => list.iter().any(Predicate::has_subquery),
395 Predicate::Not(inner) => inner.has_subquery(),
396 }
397 }
398}
399
400/// Right-hand side of a relation predicate.
401///
402/// Mirrors `sqry-core::query::types::Value`'s String / Regex / Subquery
403/// arms that actually flow into the six relation handlers. The IR variant
404/// is deliberately narrower than the general-purpose [`Value`] — numeric
405/// and boolean values never appear on the right of a relation predicate,
406/// so they are omitted here.
407///
408/// [`Value`]: sqry_core::query::types::Value
409#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
410#[serde(rename_all = "snake_case")]
411pub enum PredicateValue {
412 /// Literal string pattern — exact match, glob, prefix, suffix, or
413 /// contains. Maps to `Operator::Equal` in `graph_eval`.
414 Pattern(StringPattern),
415 /// Regex pattern. Maps to `Operator::Regex` in `graph_eval`.
416 ///
417 /// Only [`Predicate::References`] uses this in the current text
418 /// syntax (`references ~= /foo.*/i`), but any relation predicate
419 /// may accept it at the IR level.
420 Regex(RegexPattern),
421 /// Nested subquery: `callers:(kind:function AND async:true)`.
422 /// Evaluated as a distinct [`PlanNode`] before the outer relation
423 /// predicate runs.
424 Subquery(Box<PlanNode>),
425}
426
427impl PredicateValue {
428 /// Returns `true` if this value is a [`PredicateValue::Subquery`].
429 #[must_use]
430 pub const fn is_subquery(&self) -> bool {
431 matches!(self, PredicateValue::Subquery(_))
432 }
433
434 /// Returns the inner [`PlanNode`] if this value is a subquery.
435 #[must_use]
436 pub fn as_subquery(&self) -> Option<&PlanNode> {
437 match self {
438 PredicateValue::Subquery(plan) => Some(plan),
439 _ => None,
440 }
441 }
442}
443
444/// Symbol-name matching pattern.
445///
446/// The [`MatchMode`] discriminator selects between exact, glob, prefix,
447/// suffix, and contains semantics. The raw pattern is kept as a `String`
448/// so the executor can compile it lazily.
449///
450/// # Why not `Arc<str>`?
451///
452/// The IR is a data description — compact and easy to clone. Patterns are
453/// small (typically under 64 bytes), and cloning the IR is rare (once per
454/// plan submission). The `Arc<str>` savings do not justify the added
455/// complexity at this layer.
456#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
457pub struct StringPattern {
458 /// The raw pattern string.
459 pub raw: String,
460 /// How the pattern should be interpreted by the executor.
461 pub mode: MatchMode,
462 /// Whether matching is case-insensitive. Defaults to `false` (case
463 /// sensitive) to match `graph_eval` semantics.
464 #[serde(default)]
465 pub case_insensitive: bool,
466}
467
468impl StringPattern {
469 /// Creates an exact-match pattern (case-sensitive).
470 #[must_use]
471 pub fn exact(raw: impl Into<String>) -> Self {
472 Self {
473 raw: raw.into(),
474 mode: MatchMode::Exact,
475 case_insensitive: false,
476 }
477 }
478
479 /// Creates a glob pattern (case-sensitive).
480 #[must_use]
481 pub fn glob(raw: impl Into<String>) -> Self {
482 Self {
483 raw: raw.into(),
484 mode: MatchMode::Glob,
485 case_insensitive: false,
486 }
487 }
488
489 /// Creates a prefix-match pattern (case-sensitive).
490 #[must_use]
491 pub fn prefix(raw: impl Into<String>) -> Self {
492 Self {
493 raw: raw.into(),
494 mode: MatchMode::Prefix,
495 case_insensitive: false,
496 }
497 }
498
499 /// Creates a suffix-match pattern (case-sensitive).
500 #[must_use]
501 pub fn suffix(raw: impl Into<String>) -> Self {
502 Self {
503 raw: raw.into(),
504 mode: MatchMode::Suffix,
505 case_insensitive: false,
506 }
507 }
508
509 /// Creates a substring-contains pattern (case-sensitive).
510 #[must_use]
511 pub fn contains(raw: impl Into<String>) -> Self {
512 Self {
513 raw: raw.into(),
514 mode: MatchMode::Contains,
515 case_insensitive: false,
516 }
517 }
518
519 /// Returns a case-insensitive copy of this pattern.
520 #[must_use]
521 pub fn case_insensitive(mut self) -> Self {
522 self.case_insensitive = true;
523 self
524 }
525}
526
527/// Match semantics for a [`StringPattern`].
528#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
529#[serde(rename_all = "snake_case")]
530pub enum MatchMode {
531 /// Exact string equality.
532 Exact,
533 /// Shell-style glob matching (`*`, `?`, `[abc]`).
534 Glob,
535 /// Prefix match (`str.starts_with(raw)`).
536 Prefix,
537 /// Suffix match (`str.ends_with(raw)`).
538 Suffix,
539 /// Substring match (`str.contains(raw)`).
540 Contains,
541}
542
543/// File-path matching pattern. Always interpreted as a shell-style glob,
544/// matching `graph_eval`'s `Operator::Equal` semantics on path fields.
545///
546/// The raw glob is kept as a `String`; the executor compiles the glob
547/// lazily using `globset` or the project's existing glob helpers.
548#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
549pub struct PathPattern {
550 /// The raw glob string, e.g. `src/**/*.rs`.
551 pub glob: String,
552}
553
554impl PathPattern {
555 /// Creates a new path glob pattern.
556 #[must_use]
557 pub fn new(glob: impl Into<String>) -> Self {
558 Self { glob: glob.into() }
559 }
560
561 /// Returns the raw glob string.
562 #[inline]
563 #[must_use]
564 pub fn as_str(&self) -> &str {
565 &self.glob
566 }
567}
568
569impl<S: Into<String>> From<S> for PathPattern {
570 fn from(s: S) -> Self {
571 Self::new(s)
572 }
573}
574
575/// Regex pattern with compilation flags.
576///
577/// Mirrors `sqry-core::query::types::RegexValue` so that `graph_eval`
578/// `Operator::Regex` semantics round-trip losslessly through the IR.
579/// Compilation is deferred to the executor — a failed regex compile is
580/// reported as a query error, not a plan-construction error.
581#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
582pub struct RegexPattern {
583 /// The regex source string.
584 pub pattern: String,
585 /// Flags that influence compilation.
586 #[serde(default)]
587 pub flags: RegexFlags,
588}
589
590impl RegexPattern {
591 /// Creates a new regex pattern with default flags.
592 #[must_use]
593 pub fn new(pattern: impl Into<String>) -> Self {
594 Self {
595 pattern: pattern.into(),
596 flags: RegexFlags::default(),
597 }
598 }
599
600 /// Creates a new regex pattern with the given flags.
601 #[must_use]
602 pub fn with_flags(pattern: impl Into<String>, flags: RegexFlags) -> Self {
603 Self {
604 pattern: pattern.into(),
605 flags,
606 }
607 }
608}
609
610/// Regex compilation flags, mirroring
611/// `sqry-core::query::types::RegexFlags`.
612///
613/// # Serialization
614///
615/// All three fields default to `false`; the `#[serde(default)]` attribute
616/// keeps the JSON encoding compact when the flags are at their defaults.
617#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
618pub struct RegexFlags {
619 /// Case-insensitive matching (regex flag `i`).
620 #[serde(default)]
621 pub case_insensitive: bool,
622 /// Multiline mode: `^` and `$` match line boundaries (regex flag `m`).
623 #[serde(default)]
624 pub multiline: bool,
625 /// Dot matches newlines (regex flag `s`).
626 #[serde(default)]
627 pub dot_all: bool,
628}
629
630// ============================================================================
631// Inline unit tests
632// ============================================================================
633
634#[cfg(test)]
635mod tests {
636 use super::*;
637
638 #[test]
639 fn query_plan_wraps_root() {
640 let root = PlanNode::NodeScan {
641 kind: Some(NodeKind::Function),
642 visibility: None,
643 name_pattern: None,
644 };
645 let plan = QueryPlan::new(root.clone());
646 assert_eq!(plan.root(), &root);
647 }
648
649 #[test]
650 fn operator_count_single_node() {
651 let scan = PlanNode::NodeScan {
652 kind: None,
653 visibility: None,
654 name_pattern: None,
655 };
656 assert_eq!(scan.operator_count(), 1);
657 }
658
659 #[test]
660 fn operator_count_nested_setop_and_chain() {
661 let scan = PlanNode::NodeScan {
662 kind: None,
663 visibility: None,
664 name_pattern: None,
665 };
666 let traverse = PlanNode::EdgeTraversal {
667 direction: Direction::Forward,
668 edge_kind: None,
669 max_depth: 1,
670 };
671 let set = PlanNode::SetOp {
672 op: SetOperation::Union,
673 left: Box::new(scan.clone()),
674 right: Box::new(scan.clone()),
675 };
676 let chain = PlanNode::Chain {
677 steps: vec![set.clone(), traverse],
678 };
679 // chain(1) + setop(1) + scan(1) + scan(1) + traverse(1) = 5
680 assert_eq!(chain.operator_count(), 5);
681 assert_eq!(set.operator_count(), 3);
682 }
683
684 #[test]
685 fn context_free_variants() {
686 let scan = PlanNode::NodeScan {
687 kind: None,
688 visibility: None,
689 name_pattern: None,
690 };
691 let setop = PlanNode::SetOp {
692 op: SetOperation::Intersect,
693 left: Box::new(scan.clone()),
694 right: Box::new(scan.clone()),
695 };
696 let traverse = PlanNode::EdgeTraversal {
697 direction: Direction::Reverse,
698 edge_kind: None,
699 max_depth: 1,
700 };
701 let filter = PlanNode::Filter {
702 predicate: Predicate::HasCaller,
703 };
704
705 assert!(scan.is_context_free());
706 assert!(setop.is_context_free());
707 assert!(!traverse.is_context_free());
708 assert!(!filter.is_context_free());
709 }
710
711 #[test]
712 fn direction_invert_is_involution() {
713 assert_eq!(Direction::Forward.invert(), Direction::Reverse);
714 assert_eq!(Direction::Reverse.invert(), Direction::Forward);
715 assert_eq!(Direction::Both.invert(), Direction::Both);
716 for d in [Direction::Forward, Direction::Reverse, Direction::Both] {
717 assert_eq!(d.invert().invert(), d);
718 }
719 }
720
721 #[test]
722 fn predicate_has_subquery_detects_nested_calls() {
723 let leaf = Predicate::HasCaller;
724 assert!(!leaf.has_subquery());
725
726 let attr = Predicate::InFile(PathPattern::new("src/api/**"));
727 assert!(!attr.has_subquery());
728
729 let sub = Predicate::Callers(PredicateValue::Subquery(Box::new(PlanNode::NodeScan {
730 kind: Some(NodeKind::Method),
731 visibility: None,
732 name_pattern: None,
733 })));
734 assert!(sub.has_subquery());
735
736 let pattern = Predicate::Callers(PredicateValue::Pattern(StringPattern::exact("foo")));
737 assert!(!pattern.has_subquery());
738
739 let nested_in_and = Predicate::And(vec![leaf.clone(), sub.clone()]);
740 assert!(nested_in_and.has_subquery());
741
742 let nested_in_not = Predicate::Not(Box::new(sub));
743 assert!(nested_in_not.has_subquery());
744
745 let and_no_sub = Predicate::And(vec![leaf.clone(), attr.clone()]);
746 assert!(!and_no_sub.has_subquery());
747 }
748
749 #[test]
750 fn predicate_value_is_subquery() {
751 let plan = PlanNode::NodeScan {
752 kind: None,
753 visibility: None,
754 name_pattern: None,
755 };
756 let sub = PredicateValue::Subquery(Box::new(plan.clone()));
757 let pat = PredicateValue::Pattern(StringPattern::exact("foo"));
758 let re = PredicateValue::Regex(RegexPattern::new("^foo$"));
759
760 assert!(sub.is_subquery());
761 assert!(!pat.is_subquery());
762 assert!(!re.is_subquery());
763
764 assert_eq!(sub.as_subquery(), Some(&plan));
765 assert_eq!(pat.as_subquery(), None);
766 assert_eq!(re.as_subquery(), None);
767 }
768
769 #[test]
770 fn string_pattern_builders_preserve_raw() {
771 let raw = "parse_*";
772 assert_eq!(StringPattern::exact(raw).raw, raw);
773 assert_eq!(StringPattern::glob(raw).mode, MatchMode::Glob);
774 assert_eq!(StringPattern::prefix(raw).mode, MatchMode::Prefix);
775 assert_eq!(StringPattern::suffix(raw).mode, MatchMode::Suffix);
776 assert_eq!(StringPattern::contains(raw).mode, MatchMode::Contains);
777 }
778
779 #[test]
780 fn string_pattern_case_insensitive_toggle() {
781 let p = StringPattern::exact("Foo").case_insensitive();
782 assert!(p.case_insensitive);
783 }
784
785 #[test]
786 fn path_pattern_from_str_and_as_str() {
787 let p: PathPattern = "src/**/*.rs".into();
788 assert_eq!(p.as_str(), "src/**/*.rs");
789
790 let p2 = PathPattern::new(String::from("docs/**"));
791 assert_eq!(p2.as_str(), "docs/**");
792 }
793
794 #[test]
795 fn regex_pattern_default_flags_are_false() {
796 let r = RegexPattern::new("^foo$");
797 assert_eq!(r.pattern, "^foo$");
798 assert!(!r.flags.case_insensitive);
799 assert!(!r.flags.multiline);
800 assert!(!r.flags.dot_all);
801 }
802
803 #[test]
804 fn regex_pattern_with_flags() {
805 let flags = RegexFlags {
806 case_insensitive: true,
807 multiline: true,
808 dot_all: false,
809 };
810 let r = RegexPattern::with_flags("foo", flags);
811 assert!(r.flags.case_insensitive);
812 assert!(r.flags.multiline);
813 assert!(!r.flags.dot_all);
814 }
815}