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
335 // --- Boolean combinators ---
336 /// Logical AND over a list of predicates. Empty list is vacuously true.
337 And(Vec<Predicate>),
338 /// Logical OR over a list of predicates. Empty list is vacuously false.
339 Or(Vec<Predicate>),
340 /// Logical NOT of a single predicate.
341 Not(Box<Predicate>),
342}
343
344impl Predicate {
345 /// Returns `true` if this predicate (or any nested predicate through
346 /// boolean combinators) references a [`PredicateValue::Subquery`].
347 ///
348 /// The executor uses this hint to decide whether to allocate subquery
349 /// evaluation scratch space up front.
350 #[must_use]
351 pub fn has_subquery(&self) -> bool {
352 match self {
353 Predicate::HasCaller
354 | Predicate::HasCallee
355 | Predicate::IsUnused
356 | Predicate::InFile(_)
357 | Predicate::InScope(_)
358 | Predicate::MatchesName(_) => false,
359
360 Predicate::Callers(v)
361 | Predicate::Callees(v)
362 | Predicate::Imports(v)
363 | Predicate::Exports(v)
364 | Predicate::References(v)
365 | Predicate::Implements(v) => v.is_subquery(),
366
367 Predicate::And(list) | Predicate::Or(list) => list.iter().any(Predicate::has_subquery),
368 Predicate::Not(inner) => inner.has_subquery(),
369 }
370 }
371}
372
373/// Right-hand side of a relation predicate.
374///
375/// Mirrors `sqry-core::query::types::Value`'s String / Regex / Subquery
376/// arms that actually flow into the six relation handlers. The IR variant
377/// is deliberately narrower than the general-purpose [`Value`] — numeric
378/// and boolean values never appear on the right of a relation predicate,
379/// so they are omitted here.
380///
381/// [`Value`]: sqry_core::query::types::Value
382#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
383#[serde(rename_all = "snake_case")]
384pub enum PredicateValue {
385 /// Literal string pattern — exact match, glob, prefix, suffix, or
386 /// contains. Maps to `Operator::Equal` in `graph_eval`.
387 Pattern(StringPattern),
388 /// Regex pattern. Maps to `Operator::Regex` in `graph_eval`.
389 ///
390 /// Only [`Predicate::References`] uses this in the current text
391 /// syntax (`references ~= /foo.*/i`), but any relation predicate
392 /// may accept it at the IR level.
393 Regex(RegexPattern),
394 /// Nested subquery: `callers:(kind:function AND async:true)`.
395 /// Evaluated as a distinct [`PlanNode`] before the outer relation
396 /// predicate runs.
397 Subquery(Box<PlanNode>),
398}
399
400impl PredicateValue {
401 /// Returns `true` if this value is a [`PredicateValue::Subquery`].
402 #[must_use]
403 pub const fn is_subquery(&self) -> bool {
404 matches!(self, PredicateValue::Subquery(_))
405 }
406
407 /// Returns the inner [`PlanNode`] if this value is a subquery.
408 #[must_use]
409 pub fn as_subquery(&self) -> Option<&PlanNode> {
410 match self {
411 PredicateValue::Subquery(plan) => Some(plan),
412 _ => None,
413 }
414 }
415}
416
417/// Symbol-name matching pattern.
418///
419/// The [`MatchMode`] discriminator selects between exact, glob, prefix,
420/// suffix, and contains semantics. The raw pattern is kept as a `String`
421/// so the executor can compile it lazily.
422///
423/// # Why not `Arc<str>`?
424///
425/// The IR is a data description — compact and easy to clone. Patterns are
426/// small (typically under 64 bytes), and cloning the IR is rare (once per
427/// plan submission). The `Arc<str>` savings do not justify the added
428/// complexity at this layer.
429#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
430pub struct StringPattern {
431 /// The raw pattern string.
432 pub raw: String,
433 /// How the pattern should be interpreted by the executor.
434 pub mode: MatchMode,
435 /// Whether matching is case-insensitive. Defaults to `false` (case
436 /// sensitive) to match `graph_eval` semantics.
437 #[serde(default)]
438 pub case_insensitive: bool,
439}
440
441impl StringPattern {
442 /// Creates an exact-match pattern (case-sensitive).
443 #[must_use]
444 pub fn exact(raw: impl Into<String>) -> Self {
445 Self {
446 raw: raw.into(),
447 mode: MatchMode::Exact,
448 case_insensitive: false,
449 }
450 }
451
452 /// Creates a glob pattern (case-sensitive).
453 #[must_use]
454 pub fn glob(raw: impl Into<String>) -> Self {
455 Self {
456 raw: raw.into(),
457 mode: MatchMode::Glob,
458 case_insensitive: false,
459 }
460 }
461
462 /// Creates a prefix-match pattern (case-sensitive).
463 #[must_use]
464 pub fn prefix(raw: impl Into<String>) -> Self {
465 Self {
466 raw: raw.into(),
467 mode: MatchMode::Prefix,
468 case_insensitive: false,
469 }
470 }
471
472 /// Creates a suffix-match pattern (case-sensitive).
473 #[must_use]
474 pub fn suffix(raw: impl Into<String>) -> Self {
475 Self {
476 raw: raw.into(),
477 mode: MatchMode::Suffix,
478 case_insensitive: false,
479 }
480 }
481
482 /// Creates a substring-contains pattern (case-sensitive).
483 #[must_use]
484 pub fn contains(raw: impl Into<String>) -> Self {
485 Self {
486 raw: raw.into(),
487 mode: MatchMode::Contains,
488 case_insensitive: false,
489 }
490 }
491
492 /// Returns a case-insensitive copy of this pattern.
493 #[must_use]
494 pub fn case_insensitive(mut self) -> Self {
495 self.case_insensitive = true;
496 self
497 }
498}
499
500/// Match semantics for a [`StringPattern`].
501#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
502#[serde(rename_all = "snake_case")]
503pub enum MatchMode {
504 /// Exact string equality.
505 Exact,
506 /// Shell-style glob matching (`*`, `?`, `[abc]`).
507 Glob,
508 /// Prefix match (`str.starts_with(raw)`).
509 Prefix,
510 /// Suffix match (`str.ends_with(raw)`).
511 Suffix,
512 /// Substring match (`str.contains(raw)`).
513 Contains,
514}
515
516/// File-path matching pattern. Always interpreted as a shell-style glob,
517/// matching `graph_eval`'s `Operator::Equal` semantics on path fields.
518///
519/// The raw glob is kept as a `String`; the executor compiles the glob
520/// lazily using `globset` or the project's existing glob helpers.
521#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
522pub struct PathPattern {
523 /// The raw glob string, e.g. `src/**/*.rs`.
524 pub glob: String,
525}
526
527impl PathPattern {
528 /// Creates a new path glob pattern.
529 #[must_use]
530 pub fn new(glob: impl Into<String>) -> Self {
531 Self { glob: glob.into() }
532 }
533
534 /// Returns the raw glob string.
535 #[inline]
536 #[must_use]
537 pub fn as_str(&self) -> &str {
538 &self.glob
539 }
540}
541
542impl<S: Into<String>> From<S> for PathPattern {
543 fn from(s: S) -> Self {
544 Self::new(s)
545 }
546}
547
548/// Regex pattern with compilation flags.
549///
550/// Mirrors `sqry-core::query::types::RegexValue` so that `graph_eval`
551/// `Operator::Regex` semantics round-trip losslessly through the IR.
552/// Compilation is deferred to the executor — a failed regex compile is
553/// reported as a query error, not a plan-construction error.
554#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
555pub struct RegexPattern {
556 /// The regex source string.
557 pub pattern: String,
558 /// Flags that influence compilation.
559 #[serde(default)]
560 pub flags: RegexFlags,
561}
562
563impl RegexPattern {
564 /// Creates a new regex pattern with default flags.
565 #[must_use]
566 pub fn new(pattern: impl Into<String>) -> Self {
567 Self {
568 pattern: pattern.into(),
569 flags: RegexFlags::default(),
570 }
571 }
572
573 /// Creates a new regex pattern with the given flags.
574 #[must_use]
575 pub fn with_flags(pattern: impl Into<String>, flags: RegexFlags) -> Self {
576 Self {
577 pattern: pattern.into(),
578 flags,
579 }
580 }
581}
582
583/// Regex compilation flags, mirroring
584/// `sqry-core::query::types::RegexFlags`.
585///
586/// # Serialization
587///
588/// All three fields default to `false`; the `#[serde(default)]` attribute
589/// keeps the JSON encoding compact when the flags are at their defaults.
590#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
591pub struct RegexFlags {
592 /// Case-insensitive matching (regex flag `i`).
593 #[serde(default)]
594 pub case_insensitive: bool,
595 /// Multiline mode: `^` and `$` match line boundaries (regex flag `m`).
596 #[serde(default)]
597 pub multiline: bool,
598 /// Dot matches newlines (regex flag `s`).
599 #[serde(default)]
600 pub dot_all: bool,
601}
602
603// ============================================================================
604// Inline unit tests
605// ============================================================================
606
607#[cfg(test)]
608mod tests {
609 use super::*;
610
611 #[test]
612 fn query_plan_wraps_root() {
613 let root = PlanNode::NodeScan {
614 kind: Some(NodeKind::Function),
615 visibility: None,
616 name_pattern: None,
617 };
618 let plan = QueryPlan::new(root.clone());
619 assert_eq!(plan.root(), &root);
620 }
621
622 #[test]
623 fn operator_count_single_node() {
624 let scan = PlanNode::NodeScan {
625 kind: None,
626 visibility: None,
627 name_pattern: None,
628 };
629 assert_eq!(scan.operator_count(), 1);
630 }
631
632 #[test]
633 fn operator_count_nested_setop_and_chain() {
634 let scan = PlanNode::NodeScan {
635 kind: None,
636 visibility: None,
637 name_pattern: None,
638 };
639 let traverse = PlanNode::EdgeTraversal {
640 direction: Direction::Forward,
641 edge_kind: None,
642 max_depth: 1,
643 };
644 let set = PlanNode::SetOp {
645 op: SetOperation::Union,
646 left: Box::new(scan.clone()),
647 right: Box::new(scan.clone()),
648 };
649 let chain = PlanNode::Chain {
650 steps: vec![set.clone(), traverse],
651 };
652 // chain(1) + setop(1) + scan(1) + scan(1) + traverse(1) = 5
653 assert_eq!(chain.operator_count(), 5);
654 assert_eq!(set.operator_count(), 3);
655 }
656
657 #[test]
658 fn context_free_variants() {
659 let scan = PlanNode::NodeScan {
660 kind: None,
661 visibility: None,
662 name_pattern: None,
663 };
664 let setop = PlanNode::SetOp {
665 op: SetOperation::Intersect,
666 left: Box::new(scan.clone()),
667 right: Box::new(scan.clone()),
668 };
669 let traverse = PlanNode::EdgeTraversal {
670 direction: Direction::Reverse,
671 edge_kind: None,
672 max_depth: 1,
673 };
674 let filter = PlanNode::Filter {
675 predicate: Predicate::HasCaller,
676 };
677
678 assert!(scan.is_context_free());
679 assert!(setop.is_context_free());
680 assert!(!traverse.is_context_free());
681 assert!(!filter.is_context_free());
682 }
683
684 #[test]
685 fn direction_invert_is_involution() {
686 assert_eq!(Direction::Forward.invert(), Direction::Reverse);
687 assert_eq!(Direction::Reverse.invert(), Direction::Forward);
688 assert_eq!(Direction::Both.invert(), Direction::Both);
689 for d in [Direction::Forward, Direction::Reverse, Direction::Both] {
690 assert_eq!(d.invert().invert(), d);
691 }
692 }
693
694 #[test]
695 fn predicate_has_subquery_detects_nested_calls() {
696 let leaf = Predicate::HasCaller;
697 assert!(!leaf.has_subquery());
698
699 let attr = Predicate::InFile(PathPattern::new("src/api/**"));
700 assert!(!attr.has_subquery());
701
702 let sub = Predicate::Callers(PredicateValue::Subquery(Box::new(PlanNode::NodeScan {
703 kind: Some(NodeKind::Method),
704 visibility: None,
705 name_pattern: None,
706 })));
707 assert!(sub.has_subquery());
708
709 let pattern = Predicate::Callers(PredicateValue::Pattern(StringPattern::exact("foo")));
710 assert!(!pattern.has_subquery());
711
712 let nested_in_and = Predicate::And(vec![leaf.clone(), sub.clone()]);
713 assert!(nested_in_and.has_subquery());
714
715 let nested_in_not = Predicate::Not(Box::new(sub));
716 assert!(nested_in_not.has_subquery());
717
718 let and_no_sub = Predicate::And(vec![leaf.clone(), attr.clone()]);
719 assert!(!and_no_sub.has_subquery());
720 }
721
722 #[test]
723 fn predicate_value_is_subquery() {
724 let plan = PlanNode::NodeScan {
725 kind: None,
726 visibility: None,
727 name_pattern: None,
728 };
729 let sub = PredicateValue::Subquery(Box::new(plan.clone()));
730 let pat = PredicateValue::Pattern(StringPattern::exact("foo"));
731 let re = PredicateValue::Regex(RegexPattern::new("^foo$"));
732
733 assert!(sub.is_subquery());
734 assert!(!pat.is_subquery());
735 assert!(!re.is_subquery());
736
737 assert_eq!(sub.as_subquery(), Some(&plan));
738 assert_eq!(pat.as_subquery(), None);
739 assert_eq!(re.as_subquery(), None);
740 }
741
742 #[test]
743 fn string_pattern_builders_preserve_raw() {
744 let raw = "parse_*";
745 assert_eq!(StringPattern::exact(raw).raw, raw);
746 assert_eq!(StringPattern::glob(raw).mode, MatchMode::Glob);
747 assert_eq!(StringPattern::prefix(raw).mode, MatchMode::Prefix);
748 assert_eq!(StringPattern::suffix(raw).mode, MatchMode::Suffix);
749 assert_eq!(StringPattern::contains(raw).mode, MatchMode::Contains);
750 }
751
752 #[test]
753 fn string_pattern_case_insensitive_toggle() {
754 let p = StringPattern::exact("Foo").case_insensitive();
755 assert!(p.case_insensitive);
756 }
757
758 #[test]
759 fn path_pattern_from_str_and_as_str() {
760 let p: PathPattern = "src/**/*.rs".into();
761 assert_eq!(p.as_str(), "src/**/*.rs");
762
763 let p2 = PathPattern::new(String::from("docs/**"));
764 assert_eq!(p2.as_str(), "docs/**");
765 }
766
767 #[test]
768 fn regex_pattern_default_flags_are_false() {
769 let r = RegexPattern::new("^foo$");
770 assert_eq!(r.pattern, "^foo$");
771 assert!(!r.flags.case_insensitive);
772 assert!(!r.flags.multiline);
773 assert!(!r.flags.dot_all);
774 }
775
776 #[test]
777 fn regex_pattern_with_flags() {
778 let flags = RegexFlags {
779 case_insensitive: true,
780 multiline: true,
781 dot_all: false,
782 };
783 let r = RegexPattern::with_flags("foo", flags);
784 assert!(r.flags.case_insensitive);
785 assert!(r.flags.multiline);
786 assert!(!r.flags.dot_all);
787 }
788}