Skip to main content

sqry_core/query/executor/
graph_eval.rs

1//! Graph-native predicate evaluation.
2//!
3//! This module provides graph-native predicate evaluation for queries against
4//! `CodeGraph`, bypassing the legacy index path.
5//!
6//! # Design (v6 - CodeGraph-Native Query Executor)
7//!
8//! Key semantics preserved from the legacy index path:
9//! - `name:` uses `segments_match` for qualified name suffix matching
10//! - `kind:`, `lang:`, `visibility:`, `scope.*` are **case-sensitive**
11//! - `imports:` per-node — matches a node iff its own outgoing `Imports`
12//!   edge target/alias/wildcard matches, or it is an `Import` node whose
13//!   text matches. Aligned with `sqry-db::queries::ImportsQuery` per the
14//!   Phase N "Unified Surface Contract" (planner-IR is canonical, all
15//!   transports mirror it). The previous file-scoped semantic was retired
16//!   in DB15 to remove the cross-engine divergence flagged by Codex's
17//!   DB14 review.
18//! - `references:` includes `References` + `Calls` + `Imports` + `FfiCall` edges
19//! - `callers:` checks OUTGOING edges (find nodes that call X)
20//! - `callees:` checks INCOMING edges (find nodes called by X)
21//! - Relation predicates use `segments_match` for qualified names
22//!
23//! # Limitations (v1)
24//!
25//! The following predicates are **NOT SUPPORTED** in graph backend v1:
26//! - `returns:` - requires signature parsing/metadata not in `NodeEntry`
27//! - Plugin fields - requires metadata `HashMap` not in `NodeEntry`
28//! - Numeric operators - requires metadata values
29//!
30//! **Supported boolean predicates**: `async:true`, `static:true`
31
32use crate::graph::unified::FileId;
33use crate::graph::unified::concurrent::CodeGraph;
34use crate::graph::unified::edge::{EdgeKind, StoreEdgeRef};
35use crate::graph::unified::node::{NodeId, NodeKind};
36use crate::graph::unified::resolution::{
37    canonicalize_graph_qualified_name, display_graph_qualified_name,
38};
39use crate::graph::unified::storage::arena::NodeEntry;
40use crate::plugin::PluginManager;
41use crate::query::name_matching::segments_match;
42use crate::query::regex_cache::{CompiledRegex, get_or_compile_regex};
43use crate::query::types::{Condition, Expr, JoinEdgeKind, JoinExpr, Operator, Value};
44use anyhow::{Result, anyhow};
45use std::collections::{HashMap, HashSet};
46use std::path::Path;
47
48/// Try `re.is_match(text)`, logging a warning and returning `false` if the
49/// regex engine hits its backtrack limit.  This prevents silent failures
50/// while keeping the predicate-evaluation chain infallible.
51fn regex_is_match(re: &CompiledRegex, text: &str) -> bool {
52    match re.is_match(text) {
53        Ok(b) => b,
54        Err(e) => {
55            log::warn!("regex match aborted (backtrack limit?): {e}");
56            false
57        }
58    }
59}
60use std::sync::Arc;
61
62/// Cache of precomputed subquery result sets, keyed by `(span.start, span.end)`.
63///
64/// Subquery expressions from the AST are evaluated once against all graph nodes
65/// and their results cached here. During per-node evaluation, matchers look up
66/// the cache instead of re-evaluating the full graph. This reduces subquery cost
67/// from O(N^2) to O(N) where N is the number of graph nodes.
68type SubqueryCache = HashMap<(usize, usize), Arc<HashSet<NodeId>>>;
69
70/// Context for graph-native predicate evaluation.
71///
72/// Encapsulates all dependencies needed for evaluating predicates against
73/// a `CodeGraph`. The context includes precomputed caches for performance.
74pub struct GraphEvalContext<'a> {
75    /// Reference to the code graph
76    pub graph: &'a CodeGraph,
77    /// Plugin manager for detecting plugin fields
78    pub plugin_manager: &'a PluginManager,
79    /// Workspace root for relative path resolution in `path:` predicates
80    pub workspace_root: Option<&'a Path>,
81    /// If true, disable parallel execution
82    pub disable_parallel: bool,
83    /// Precomputed subquery result sets, keyed by `(span.start, span.end)`.
84    /// Populated by `precompute_subqueries()` before the per-node evaluation loop.
85    pub subquery_cache: SubqueryCache,
86}
87
88impl<'a> GraphEvalContext<'a> {
89    /// Creates a new evaluation context.
90    #[must_use]
91    pub fn new(graph: &'a CodeGraph, plugin_manager: &'a PluginManager) -> Self {
92        Self {
93            graph,
94            plugin_manager,
95            workspace_root: None,
96            disable_parallel: false,
97            subquery_cache: HashMap::new(),
98        }
99    }
100
101    /// Sets the workspace root for relative path resolution.
102    #[must_use]
103    pub fn with_workspace_root(mut self, root: &'a Path) -> Self {
104        self.workspace_root = Some(root);
105        self
106    }
107
108    /// Disables parallel execution.
109    #[must_use]
110    pub fn with_parallel_disabled(mut self, disabled: bool) -> Self {
111        self.disable_parallel = disabled;
112        self
113    }
114
115    /// Precomputes all subquery result sets from the expression tree.
116    ///
117    /// This must be called before `evaluate_all` to avoid O(N^2) behavior
118    /// where each subquery is re-evaluated for every candidate node.
119    ///
120    /// # Errors
121    ///
122    /// Returns an error if subquery evaluation fails.
123    pub fn precompute_subqueries(&mut self, expr: &Expr) -> Result<()> {
124        let mut subquery_exprs = Vec::new();
125        collect_subquery_exprs(expr, &mut subquery_exprs);
126
127        for (span_key, inner_expr) in subquery_exprs {
128            if !self.subquery_cache.contains_key(&span_key) {
129                let result_set = evaluate_subquery(self, inner_expr)?;
130                self.subquery_cache.insert(span_key, Arc::new(result_set));
131            }
132        }
133        Ok(())
134    }
135}
136
137/// Collects all `Value::Subquery(inner)` expressions from the AST for precomputation.
138///
139/// Returns `(span_key, &Expr)` pairs where `span_key` is `(span.start, span.end)`.
140///
141/// Uses **post-order** traversal: nested (inner) subqueries appear before their
142/// enclosing (outer) subqueries. This ensures `precompute_subqueries()` evaluates
143/// dependencies first, so outer subquery evaluation can find inner results in the cache.
144fn collect_subquery_exprs<'a>(expr: &'a Expr, out: &mut Vec<((usize, usize), &'a Expr)>) {
145    match expr {
146        Expr::Condition(cond) => {
147            if let Value::Subquery(inner) = &cond.value {
148                // Post-order: recurse into nested subqueries FIRST
149                collect_subquery_exprs(inner, out);
150                // Then record this (outer) subquery
151                out.push(((cond.span.start, cond.span.end), inner));
152            }
153        }
154        Expr::And(operands) | Expr::Or(operands) => {
155            for op in operands {
156                collect_subquery_exprs(op, out);
157            }
158        }
159        Expr::Not(inner) => collect_subquery_exprs(inner, out),
160        Expr::Join(join) => {
161            collect_subquery_exprs(&join.left, out);
162            collect_subquery_exprs(&join.right, out);
163        }
164    }
165}
166
167/// Evaluates query against all nodes, returning matching `NodeIds`.
168///
169/// # Errors
170///
171/// Returns an error if predicate evaluation fails (e.g., unsupported predicates).
172pub fn evaluate_all(ctx: &mut GraphEvalContext, expr: &Expr) -> Result<Vec<NodeId>> {
173    // Precompute all subquery result sets before the per-node evaluation loop.
174    // This is critical for performance: without precomputation, each subquery
175    // matcher would re-evaluate the full graph for every candidate node (O(N^2)).
176    ctx.precompute_subqueries(expr)?;
177
178    let arena = ctx.graph.nodes();
179
180    // Create recursion guard
181    let recursion_limits = crate::config::RecursionLimits::load_or_default()?;
182    let expr_depth = recursion_limits.effective_expr_depth()?;
183    let mut guard = crate::query::security::RecursionGuard::new(expr_depth)?;
184
185    if ctx.disable_parallel {
186        // Sequential evaluation
187        let mut matches = Vec::new();
188        for (id, entry) in arena.iter() {
189            // Skip Phase 4c-prime unified-away losers — they remain in
190            // the arena as inert duplicates so CSR row_ptr sizing stays
191            // stable, but publish-visible query evaluation must not
192            // surface them (Gate 0d iter-1 blocker). See
193            // `NodeEntry::is_unified_loser`.
194            if entry.is_unified_loser() {
195                continue;
196            }
197            if evaluate_node(ctx, id, expr, &mut guard)? {
198                matches.push(id);
199            }
200        }
201        Ok(matches)
202    } else {
203        // Parallel evaluation - each thread needs its own guard
204        use rayon::prelude::*;
205
206        let node_ids: Vec<_> = arena
207            .iter()
208            .filter(|(_id, entry)| !entry.is_unified_loser())
209            .map(|(id, _)| id)
210            .collect();
211        let results: Vec<Result<Option<NodeId>>> = node_ids
212            .into_par_iter()
213            .map(|id| {
214                let mut thread_guard = crate::query::security::RecursionGuard::new(expr_depth)?;
215                evaluate_node(ctx, id, expr, &mut thread_guard)
216                    .map(|m| if m { Some(id) } else { None })
217            })
218            .collect();
219
220        // Check for any errors and collect matches
221        let mut matches = Vec::new();
222        for result in results {
223            if let Some(id) = result? {
224                matches.push(id);
225            }
226        }
227        Ok(matches)
228    }
229}
230
231/// Evaluates a single node against an expression.
232///
233/// # Errors
234///
235/// Returns an error for unsupported predicates or if recursion depth exceeds the guard's limit.
236pub fn evaluate_node(
237    ctx: &GraphEvalContext,
238    node_id: NodeId,
239    expr: &Expr,
240    guard: &mut crate::query::security::RecursionGuard,
241) -> Result<bool> {
242    guard.enter()?;
243
244    let result = match expr {
245        Expr::Condition(cond) => evaluate_condition(ctx, node_id, cond),
246        Expr::And(operands) => {
247            for operand in operands {
248                if !evaluate_node(ctx, node_id, operand, guard)? {
249                    guard.exit();
250                    return Ok(false);
251                }
252            }
253            Ok(true)
254        }
255        Expr::Or(operands) => {
256            for operand in operands {
257                if evaluate_node(ctx, node_id, operand, guard)? {
258                    guard.exit();
259                    return Ok(true);
260                }
261            }
262            Ok(false)
263        }
264        Expr::Not(inner) => Ok(!evaluate_node(ctx, node_id, inner, guard)?),
265        Expr::Join(_) => {
266            // Join expressions are evaluated at a higher level (execute_join),
267            // not per-node. If we reach here, it's a programming error.
268            Err(anyhow::anyhow!(
269                "Join expressions cannot be evaluated per-node; use execute_join instead"
270            ))
271        }
272    };
273
274    guard.exit();
275    result
276}
277
278fn evaluate_condition(ctx: &GraphEvalContext, node_id: NodeId, cond: &Condition) -> Result<bool> {
279    let Some(entry) = ctx.graph.nodes().get(node_id) else {
280        return Ok(false);
281    };
282
283    match cond.field.as_str() {
284        "kind" => Ok(match_kind(ctx, entry, &cond.operator, &cond.value)),
285        "name" => Ok(match_name(ctx, entry, &cond.operator, &cond.value)),
286        "path" => Ok(match_path(ctx, entry, &cond.operator, &cond.value)),
287        "lang" | "language" => Ok(match_lang(ctx, entry, &cond.operator, &cond.value)),
288        "visibility" => Ok(match_visibility(ctx, entry, &cond.operator, &cond.value)),
289        "async" => Ok(match_async(entry, &cond.operator, &cond.value)),
290        "static" => Ok(match_static(entry, &cond.operator, &cond.value)),
291        "callers" => {
292            if matches!(cond.value, Value::Subquery(_)) {
293                let key = (cond.span.start, cond.span.end);
294                let cached = ctx.subquery_cache.get(&key).cloned();
295                match_callers_subquery(ctx, node_id, cached.as_deref())
296            } else {
297                Ok(match_callers(ctx, node_id, &cond.value))
298            }
299        }
300        "callees" => {
301            if matches!(cond.value, Value::Subquery(_)) {
302                let key = (cond.span.start, cond.span.end);
303                let cached = ctx.subquery_cache.get(&key).cloned();
304                match_callees_subquery(ctx, node_id, cached.as_deref())
305            } else {
306                Ok(match_callees(ctx, node_id, &cond.value))
307            }
308        }
309        "imports" => {
310            if matches!(cond.value, Value::Subquery(_)) {
311                let key = (cond.span.start, cond.span.end);
312                let cached = ctx.subquery_cache.get(&key).cloned();
313                match_imports_subquery(ctx, node_id, cached.as_deref())
314            } else {
315                Ok(match_imports(ctx, node_id, &cond.value))
316            }
317        }
318        "exports" => {
319            if matches!(cond.value, Value::Subquery(_)) {
320                let key = (cond.span.start, cond.span.end);
321                let cached = ctx.subquery_cache.get(&key).cloned();
322                match_exports_subquery(ctx, node_id, cached.as_deref())
323            } else {
324                Ok(match_exports(ctx, node_id, &cond.value))
325            }
326        }
327        "references" => {
328            if matches!(cond.value, Value::Subquery(_)) {
329                let key = (cond.span.start, cond.span.end);
330                let cached = ctx.subquery_cache.get(&key).cloned();
331                match_references_subquery(ctx, node_id, cached.as_deref())
332            } else {
333                Ok(match_references(ctx, node_id, &cond.operator, &cond.value))
334            }
335        }
336        "impl" | "implements" => {
337            if matches!(cond.value, Value::Subquery(_)) {
338                let key = (cond.span.start, cond.span.end);
339                let cached = ctx.subquery_cache.get(&key).cloned();
340                match_implements_subquery(ctx, node_id, cached.as_deref())
341            } else {
342                Ok(match_implements(ctx, node_id, &cond.value))
343            }
344        }
345        field if field.starts_with("scope.") => Ok(match_scope(
346            ctx,
347            node_id,
348            field,
349            &cond.operator,
350            &cond.value,
351        )),
352        "returns" => Ok(match_returns(ctx, entry, &cond.operator, &cond.value)),
353        field if is_plugin_field(ctx, field) => Err(anyhow!(
354            "Plugin field '{field}' requires metadata not available in graph backend"
355        )),
356        _ => Ok(false), // Unknown field
357    }
358}
359
360/// Checks if a field is a plugin-specific field.
361fn is_plugin_field(ctx: &GraphEvalContext, field: &str) -> bool {
362    // Check plugin registry for field descriptors
363    let is_registered_field = ctx
364        .plugin_manager
365        .plugins()
366        .iter()
367        .flat_map(|plugin| plugin.fields().iter())
368        .any(|descriptor| descriptor.name == field);
369
370    if is_registered_field {
371        return true;
372    }
373
374    // Fallback static list (for when registry not fully wired)
375    // Note: "async" and "static" are now handled natively via `NodeEntry` flags
376    matches!(
377        field,
378        "abstract" | "final" | "generic" | "parameters" | "arity"
379    )
380}
381
382// ============================================================================
383// Kind predicate (with regex + synonyms)
384// ============================================================================
385
386/// Maps synonyms to canonical kind names (case-sensitive).
387///
388/// Per v6 spec, kind: comparisons are case-sensitive.
389/// Only exact matches for known synonyms are normalized.
390fn normalize_kind(kind: &str) -> &str {
391    match kind {
392        // Rust/Go synonyms (case-sensitive)
393        "trait" => "interface", // Rust trait = interface
394        "impl" => "implementation",
395        // Property synonyms
396        "field" => "property",
397        // Module synonyms
398        "namespace" => "module",
399        // Component synonyms
400        "element" => "component",
401        // CSS/Style synonyms
402        "style" => "style_rule",
403        "at_rule" => "style_at_rule",
404        "css_var" | "custom_property" => "style_variable",
405        // No lowercasing - return as-is for case-sensitive comparison
406        _ => kind,
407    }
408}
409
410fn match_kind(
411    _ctx: &GraphEvalContext,
412    entry: &NodeEntry,
413    operator: &Operator,
414    value: &Value,
415) -> bool {
416    let actual = entry.kind.as_str();
417
418    match (operator, value) {
419        (Operator::Equal, Value::String(expected)) => {
420            let normalized_expected = normalize_kind(expected);
421            let normalized_actual = normalize_kind(actual);
422            normalized_actual == normalized_expected
423        }
424        (Operator::Regex, Value::Regex(regex_val)) => get_or_compile_regex(
425            &regex_val.pattern,
426            regex_val.flags.case_insensitive,
427            regex_val.flags.multiline,
428            regex_val.flags.dot_all,
429        )
430        .map(|re| regex_is_match(&re, actual))
431        .unwrap_or(false),
432        _ => false,
433    }
434}
435
436// ============================================================================
437// Name predicate (EXACT match for equality, regex supported)
438// ============================================================================
439
440fn match_name(
441    ctx: &GraphEvalContext,
442    entry: &NodeEntry,
443    operator: &Operator,
444    value: &Value,
445) -> bool {
446    match (operator, value) {
447        // Use segments_match for qualified name matching:
448        // - "connect" matches "database::connect" (suffix match)
449        // - "foo::bar" matches "baz::foo::bar" (suffix match)
450        // - "database::connect" matches "database::connect" (exact match)
451        (Operator::Equal, Value::String(expected)) => {
452            entry_query_texts(ctx.graph, entry).iter().any(|candidate| {
453                language_aware_segments_match(ctx.graph, entry.file, candidate, expected)
454            })
455        }
456        (Operator::Regex, Value::Regex(regex_val)) => get_or_compile_regex(
457            &regex_val.pattern,
458            regex_val.flags.case_insensitive,
459            regex_val.flags.multiline,
460            regex_val.flags.dot_all,
461        )
462        .map(|re| {
463            entry_query_texts(ctx.graph, entry)
464                .iter()
465                .any(|candidate| regex_is_match(&re, candidate))
466        })
467        .unwrap_or(false),
468        _ => false,
469    }
470}
471
472// ============================================================================
473// Path predicate (glob + regex)
474// ============================================================================
475
476/// Check if a pattern is relative (doesn't start with `/`).
477fn is_relative_pattern(pattern: &str) -> bool {
478    !pattern.starts_with('/')
479}
480
481fn match_path(
482    ctx: &GraphEvalContext,
483    entry: &NodeEntry,
484    operator: &Operator,
485    value: &Value,
486) -> bool {
487    let Some(file_path) = ctx.graph.files().resolve(entry.file) else {
488        return false;
489    };
490
491    match (operator, value) {
492        (Operator::Equal, Value::String(pattern)) => {
493            // Only strip workspace_root for RELATIVE patterns (parity with legacy index)
494            let match_path = if is_relative_pattern(pattern) {
495                if let Some(root) = ctx.workspace_root {
496                    file_path
497                        .strip_prefix(root)
498                        .map_or_else(|_| file_path.to_path_buf(), std::path::Path::to_path_buf)
499                } else {
500                    file_path.to_path_buf()
501                }
502            } else {
503                // Absolute pattern: match against full path
504                file_path.to_path_buf()
505            };
506            globset::Glob::new(pattern)
507                .map(|g| g.compile_matcher().is_match(&match_path))
508                .unwrap_or(false)
509        }
510        (Operator::Regex, Value::Regex(regex_val)) => {
511            // For regex, always use full path (matches legacy index behavior)
512            get_or_compile_regex(
513                &regex_val.pattern,
514                regex_val.flags.case_insensitive,
515                regex_val.flags.multiline,
516                regex_val.flags.dot_all,
517            )
518            .map(|re| regex_is_match(&re, file_path.to_string_lossy().as_ref()))
519            .unwrap_or(false)
520        }
521        _ => false,
522    }
523}
524
525// ============================================================================
526// Language predicate (from FILE REGISTRY, not NodeEntry)
527// ============================================================================
528
529/// Convert Language enum to canonical string for parity with legacy index.
530///
531/// Legacy index uses canonical names like "javascript", "typescript", etc.
532/// `Language::Display` uses short forms like "js", "ts".
533/// This function provides the canonical mapping for query parity.
534fn language_to_canonical(lang: crate::graph::node::Language) -> &'static str {
535    use crate::graph::node::Language;
536    match lang {
537        Language::C => "c",
538        Language::Cpp => "cpp",
539        Language::CSharp => "csharp",
540        Language::Css => "css",
541        Language::JavaScript => "javascript",
542        Language::Python => "python",
543        Language::TypeScript => "typescript",
544        Language::Rust => "rust",
545        Language::Go => "go",
546        Language::Java => "java",
547        Language::Ruby => "ruby",
548        Language::Php => "php",
549        Language::Swift => "swift",
550        Language::Kotlin => "kotlin",
551        Language::Scala => "scala",
552        Language::Sql => "sql",
553        Language::Dart => "dart",
554        Language::Lua => "lua",
555        Language::Perl => "perl",
556        Language::Shell => "shell",
557        Language::Groovy => "groovy",
558        Language::Elixir => "elixir",
559        Language::R => "r",
560        Language::Haskell => "haskell",
561        Language::Html => "html",
562        Language::Svelte => "svelte",
563        Language::Vue => "vue",
564        Language::Zig => "zig",
565        Language::Terraform => "terraform",
566        Language::Puppet => "puppet",
567        Language::Pulumi => "pulumi",
568        Language::Http => "http",
569        Language::Plsql => "plsql",
570        Language::Apex => "apex",
571        Language::Abap => "abap",
572        Language::ServiceNow => "servicenow",
573        Language::Json => "json",
574    }
575}
576
577fn match_lang(
578    ctx: &GraphEvalContext,
579    entry: &NodeEntry,
580    operator: &Operator,
581    value: &Value,
582) -> bool {
583    // Get language from file registry - NodeEntry has no language field
584    let Some(lang) = ctx.graph.files().language_for_file(entry.file) else {
585        return false;
586    };
587
588    // Use canonical language names for parity with legacy index
589    let actual = language_to_canonical(lang);
590
591    // Support both equality and regex operators
592    match (operator, value) {
593        (Operator::Equal, Value::String(expected)) => actual == expected,
594        (Operator::Regex, Value::Regex(rv)) => get_or_compile_regex(
595            &rv.pattern,
596            rv.flags.case_insensitive,
597            rv.flags.multiline,
598            rv.flags.dot_all,
599        )
600        .map(|re| regex_is_match(&re, actual))
601        .unwrap_or(false),
602        _ => false,
603    }
604}
605
606// ============================================================================
607// Visibility predicate
608// ============================================================================
609
610fn match_visibility(
611    ctx: &GraphEvalContext,
612    entry: &NodeEntry,
613    operator: &Operator,
614    value: &Value,
615) -> bool {
616    let Some(expected) = value.as_string() else {
617        return false;
618    };
619
620    let normalized_expected = if expected == "pub" {
621        "public"
622    } else {
623        expected
624    };
625
626    let Some(vis_id) = entry.visibility else {
627        // No visibility = private by default
628        return match operator {
629            Operator::Equal => normalized_expected == "private",
630            _ => false,
631        };
632    };
633
634    let Some(actual) = ctx.graph.strings().resolve(vis_id) else {
635        return false;
636    };
637    let normalized_actual = if actual.as_ref().starts_with("pub") {
638        "public"
639    } else {
640        actual.as_ref()
641    };
642
643    // Case-sensitive comparison
644    match operator {
645        Operator::Equal => normalized_actual == normalized_expected,
646        _ => false,
647    }
648}
649
650// ============================================================================
651// Returns predicate
652// ============================================================================
653
654/// Match `returns:TypeName` predicate.
655///
656/// Checks the `signature` field on `NodeEntry` for return type matching.
657/// Uses substring matching to support both:
658/// - Exact: `returns:Optional<User>` matches signature containing "Optional<User>"
659/// - Partial: `returns:Optional` matches any signature containing "Optional"
660fn match_returns(
661    ctx: &GraphEvalContext,
662    entry: &NodeEntry,
663    operator: &Operator,
664    value: &Value,
665) -> bool {
666    let Some(expected) = value.as_string() else {
667        return false;
668    };
669
670    // Only functions and methods have return types
671    if !matches!(entry.kind, NodeKind::Function | NodeKind::Method) {
672        return false;
673    }
674
675    let Some(sig_id) = entry.signature else {
676        // No signature means no return type info
677        return false;
678    };
679
680    let Some(signature) = ctx.graph.strings().resolve(sig_id) else {
681        return false;
682    };
683
684    // The signature typically contains the return type.
685    // Use contains for substring matching to support partial matches.
686    match operator {
687        Operator::Equal => signature.contains(expected),
688        _ => false,
689    }
690}
691
692// ============================================================================
693// Boolean predicates (async, static)
694// ============================================================================
695
696/// Match `async:true` or `async:false` predicate.
697///
698/// Checks the `is_async` flag on `NodeEntry`.
699/// Handles both Boolean values (from parser) and String values ("true"/"false").
700fn match_async(entry: &NodeEntry, operator: &Operator, value: &Value) -> bool {
701    let expected = value_to_bool(value);
702    let Some(expected) = expected else {
703        return false;
704    };
705
706    match operator {
707        Operator::Equal => entry.is_async == expected,
708        _ => false,
709    }
710}
711
712/// Match `static:true` or `static:false` predicate.
713///
714/// Checks the `is_static` flag on `NodeEntry`.
715/// Handles both Boolean values (from parser) and String values ("true"/"false").
716fn match_static(entry: &NodeEntry, operator: &Operator, value: &Value) -> bool {
717    let expected = value_to_bool(value);
718    let Some(expected) = expected else {
719        return false;
720    };
721
722    match operator {
723        Operator::Equal => entry.is_static == expected,
724        _ => false,
725    }
726}
727
728/// Convert a Value to a boolean.
729///
730/// Handles:
731/// - `Value::Boolean(b)` → `Some(b)`
732/// - `Value::String("true"|"yes"|"1")` → `Some(true)`
733/// - `Value::String("false"|"no"|"0")` → `Some(false)`
734/// - Other values → `None`
735fn value_to_bool(value: &Value) -> Option<bool> {
736    match value {
737        Value::Boolean(b) => Some(*b),
738        Value::String(s) => match s.to_lowercase().as_str() {
739            "true" | "yes" | "1" => Some(true),
740            "false" | "no" | "0" => Some(false),
741            _ => None,
742        },
743        _ => None,
744    }
745}
746
747// ============================================================================
748// Relation predicates (CORRECTED DIRECTION)
749// ============================================================================
750
751/// `callers:X` - find symbols that CALL X
752///
753/// When evaluating node Y: does Y call X? → Check Y's OUTGOING edges
754///
755/// For dynamically-typed languages (Lua, Python, Ruby, etc.), method calls like
756/// `target:method()` create edges to `receiver::method` where `receiver` is the
757/// variable name, not the class name. To handle this, when querying for a qualified
758/// name like `Class::method`, we also match call edges to `X::method` where X is
759/// any receiver, as long as a symbol `Class::method` exists in the graph.
760fn match_callers(ctx: &GraphEvalContext, node_id: NodeId, value: &Value) -> bool {
761    let Some(target_name) = value.as_string() else {
762        return false;
763    };
764
765    // Extract the method name part for fallback matching in dynamic languages
766    // e.g., "Player::takeDamage" -> "takeDamage"
767    let method_part = extract_method_name(target_name);
768
769    // Check OUTGOING Calls edges from this node
770    for edge in ctx.graph.edges().edges_from(node_id) {
771        if let EdgeKind::Calls { .. } = &edge.kind
772            && let Some(target_entry) = ctx.graph.nodes().get(edge.target)
773        {
774            let callee_names = entry_query_texts(ctx.graph, target_entry);
775
776            if callee_names.iter().any(|callee_name| {
777                language_aware_segments_match(
778                    ctx.graph,
779                    target_entry.file,
780                    callee_name,
781                    target_name,
782                )
783            }) {
784                return true;
785            }
786
787            // For dynamic languages: if target_name is qualified (e.g., "Player::takeDamage")
788            // and callee is also qualified (e.g., "target::takeDamage"), match on method part
789            // This handles cases where the receiver type isn't known at call time
790            if let Some(method) = &method_part
791                && callee_names
792                    .iter()
793                    .filter_map(|callee_name| extract_method_name(callee_name))
794                    .any(|callee_method| method == &callee_method)
795            {
796                return true;
797            }
798        }
799    }
800    false
801}
802
803/// Extract the method name from a qualified name.
804/// e.g., "`Player::takeDamage`" -> Some("takeDamage")
805/// e.g., "takeDamage" -> None (no separator, not qualified)
806#[must_use]
807pub fn extract_method_name(qualified: &str) -> Option<String> {
808    // Look for separator from the end
809    for sep in ["::", ".", "#", ":", "/"] {
810        if let Some(pos) = qualified.rfind(sep) {
811            let method = &qualified[pos + sep.len()..];
812            if !method.is_empty() {
813                return Some(method.to_string());
814            }
815        }
816    }
817    None
818}
819
820/// `callees:X` - find symbols that ARE CALLED BY X
821///
822/// When evaluating node Y: does X call Y? → Check Y's INCOMING edges for source=X
823fn match_callees(ctx: &GraphEvalContext, node_id: NodeId, value: &Value) -> bool {
824    let Some(caller_name) = value.as_string() else {
825        return false;
826    };
827
828    // Check INCOMING Calls edges to this node
829    for edge in ctx.graph.edges().edges_to(node_id) {
830        if let EdgeKind::Calls { .. } = &edge.kind
831            && let Some(source_entry) = ctx.graph.nodes().get(edge.source)
832            && entry_query_texts(ctx.graph, source_entry)
833                .iter()
834                .any(|source_name| {
835                    language_aware_segments_match(
836                        ctx.graph,
837                        source_entry.file,
838                        source_name,
839                        caller_name,
840                    )
841                })
842        {
843            return true;
844        }
845    }
846    false
847}
848
849/// `imports:X` — per-node match.
850///
851/// A node matches iff:
852/// 1. It is itself an `Import` node whose name (or alias text) matches `X`, or
853/// 2. It has at least one outgoing `Imports` edge whose target name, alias,
854///    or wildcard flag matches `X` (see [`import_edge_matches`]).
855///
856/// This is the planner-canonical semantic per
857/// `docs/development/phase-n-structural-semantics/02_DESIGN.md` §6 — every
858/// transport (planner, MCP, CLI, LSP) shares it. The previous file-scoped
859/// behavior ("every node in a file that imports X matches") was retired in
860/// DB15 to remove the cross-engine divergence flagged by Codex's DB14
861/// review. Matches the set returned by
862/// [`sqry_db::queries::ImportsQuery`](https://docs.rs/sqry-db) for the same
863/// key.
864fn match_imports(ctx: &GraphEvalContext, node_id: NodeId, value: &Value) -> bool {
865    let Some(target_module) = value.as_string() else {
866        return false;
867    };
868
869    let Some(entry) = ctx.graph.nodes().get(node_id) else {
870        return false;
871    };
872
873    if entry.kind == NodeKind::Import && import_entry_matches(ctx.graph, entry, target_module) {
874        return true;
875    }
876
877    for edge in ctx.graph.edges().edges_from(node_id) {
878        if import_edge_matches(ctx.graph, &edge, target_module) {
879            return true;
880        }
881    }
882    false
883}
884
885/// Returns `true` when the given `Imports` edge imports something matching
886/// `target_module`, checking the target node text, the edge's alias, and the
887/// wildcard flag. Shared with [`crate::graph::unified::concurrent::GraphSnapshot`]
888/// consumers (including `sqry-db::queries::relation`) that need the same
889/// semantics as the graph-native `imports:` predicate.
890#[must_use]
891pub fn import_edge_matches<G: crate::graph::unified::concurrent::GraphAccess>(
892    graph: &G,
893    edge: &StoreEdgeRef,
894    target_module: &str,
895) -> bool {
896    let EdgeKind::Imports { alias, is_wildcard } = &edge.kind else {
897        return false;
898    };
899
900    // Check target node text across simple, canonical, and native-display forms.
901    let target_match = graph
902        .nodes()
903        .get(edge.target)
904        .is_some_and(|entry| import_entry_matches(graph, entry, target_module));
905
906    // Check alias
907    let alias_match = alias
908        .and_then(|sid| graph.strings().resolve(sid))
909        .is_some_and(|alias_str| {
910            graph.nodes().get(edge.source).is_some_and(|entry| {
911                import_text_matches(graph, entry.file, alias_str.as_ref(), target_module)
912            })
913        });
914
915    // Check wildcard
916    let wildcard_match = *is_wildcard && target_module == "*";
917
918    target_match || alias_match || wildcard_match
919}
920
921/// Substring-based text match for `imports:` semantics, with language-aware
922/// canonicalization fallback when the file's language maps the input module
923/// path into graph-internal `::` form.
924#[must_use]
925pub fn import_text_matches<G: crate::graph::unified::concurrent::GraphAccess>(
926    graph: &G,
927    file_id: FileId,
928    candidate: &str,
929    target_module: &str,
930) -> bool {
931    if candidate.contains(target_module) {
932        return true;
933    }
934
935    graph
936        .files()
937        .language_for_file(file_id)
938        .is_some_and(|language| {
939            let canonical_target = canonicalize_graph_qualified_name(language, target_module);
940            canonical_target != target_module && candidate.contains(&canonical_target)
941        })
942}
943
944/// Matches an Import/candidate node entry against a target module using
945/// the shared `imports:` substring + canonicalization semantics.
946#[must_use]
947pub fn import_entry_matches<G: crate::graph::unified::concurrent::GraphAccess>(
948    graph: &G,
949    entry: &NodeEntry,
950    target_module: &str,
951) -> bool {
952    entry_query_texts(graph, entry)
953        .iter()
954        .any(|candidate| import_text_matches(graph, entry.file, candidate, target_module))
955}
956
957/// Segment-aware name equality with a language-specific canonicalization
958/// fallback. First tries a direct [`segments_match`]; if that fails, the
959/// file's language (if any) is consulted to canonicalize `expected` into
960/// graph-internal `::` form before retrying.
961#[must_use]
962pub fn language_aware_segments_match<G: crate::graph::unified::concurrent::GraphAccess>(
963    graph: &G,
964    file_id: FileId,
965    candidate: &str,
966    expected: &str,
967) -> bool {
968    if segments_match(candidate, expected) {
969        return true;
970    }
971
972    graph
973        .files()
974        .language_for_file(file_id)
975        .is_some_and(|language| {
976            let canonical_expected = canonicalize_graph_qualified_name(language, expected);
977            canonical_expected != expected && segments_match(candidate, &canonical_expected)
978        })
979}
980
981fn push_unique_query_text(texts: &mut Vec<String>, candidate: impl Into<String>) {
982    let candidate = candidate.into();
983    if !texts.iter().any(|existing| existing == &candidate) {
984        texts.push(candidate);
985    }
986}
987
988/// Collects every string form that can satisfy a name query for the given
989/// node entry: the interned name, the qualified name, and (when a language
990/// is recorded) the display-qualified name produced by
991/// [`display_graph_qualified_name`]. Duplicates are dropped so that relation
992/// matchers do not re-check equivalent forms.
993#[must_use]
994pub fn entry_query_texts<G: crate::graph::unified::concurrent::GraphAccess>(
995    graph: &G,
996    entry: &NodeEntry,
997) -> Vec<String> {
998    let mut texts = Vec::with_capacity(3);
999
1000    if let Some(name) = graph.strings().resolve(entry.name) {
1001        push_unique_query_text(&mut texts, name.to_string());
1002    }
1003
1004    if let Some(qualified) = entry
1005        .qualified_name
1006        .and_then(|qualified_name_id| graph.strings().resolve(qualified_name_id))
1007    {
1008        push_unique_query_text(&mut texts, qualified.to_string());
1009
1010        if let Some(language) = graph.files().language_for_file(entry.file) {
1011            push_unique_query_text(
1012                &mut texts,
1013                display_graph_qualified_name(
1014                    language,
1015                    qualified.as_ref(),
1016                    entry.kind,
1017                    entry.is_static,
1018                ),
1019            );
1020        }
1021    }
1022
1023    texts
1024}
1025
1026/// `exports:X` - find symbols that export X
1027///
1028/// File scope filtering: Only considers export edges where both source and
1029/// target are in the same file as the node being evaluated. This prevents
1030/// re-export edges from causing symbols in other files to match.
1031fn match_exports(ctx: &GraphEvalContext, node_id: NodeId, value: &Value) -> bool {
1032    let Some(target_name) = value.as_string() else {
1033        return false;
1034    };
1035
1036    let Some(entry) = ctx.graph.nodes().get(node_id) else {
1037        return false;
1038    };
1039    let node_file = entry.file;
1040
1041    if !entry_query_texts(ctx.graph, entry).iter().any(|candidate| {
1042        language_aware_segments_match(ctx.graph, entry.file, candidate, target_name)
1043    }) {
1044        return false;
1045    }
1046
1047    let edges = ctx.graph.edges();
1048
1049    // Check OUTGOING export edges: this node exports something
1050    for edge in edges.edges_from(node_id) {
1051        if let EdgeKind::Exports { .. } = &edge.kind {
1052            // File scope filtering: export edge must be in same file
1053            if let Some(target_entry) = ctx.graph.nodes().get(edge.target)
1054                && target_entry.file == node_file
1055            {
1056                return true;
1057            }
1058        }
1059    }
1060
1061    // Check INCOMING export edges: something exports this node
1062    for edge in edges.edges_to(node_id) {
1063        if let EdgeKind::Exports { .. } = &edge.kind {
1064            // File scope filtering: export edge source must be in same file
1065            if let Some(source_entry) = ctx.graph.nodes().get(edge.source)
1066                && source_entry.file == node_file
1067            {
1068                return true;
1069            }
1070        }
1071    }
1072
1073    false
1074}
1075
1076/// `references:X` - find symbols named X that have references TO them.
1077///
1078/// Current behavior: references include `Calls`, `Imports`, `FfiCall`, and `References` edges.
1079fn match_references(
1080    ctx: &GraphEvalContext,
1081    node_id: NodeId,
1082    operator: &Operator,
1083    value: &Value,
1084) -> bool {
1085    // First check if the symbol name matches the target
1086    let Some(entry) = ctx.graph.nodes().get(node_id) else {
1087        return false;
1088    };
1089
1090    let name_matches = match (operator, value) {
1091        (Operator::Equal, Value::String(target)) => entry_query_texts(ctx.graph, entry)
1092            .iter()
1093            .any(|candidate| candidate == target || candidate.ends_with(&format!("::{target}"))),
1094        (Operator::Regex, Value::Regex(rv)) => get_or_compile_regex(
1095            &rv.pattern,
1096            rv.flags.case_insensitive,
1097            rv.flags.multiline,
1098            rv.flags.dot_all,
1099        )
1100        .map(|re| {
1101            entry_query_texts(ctx.graph, entry)
1102                .iter()
1103                .any(|candidate| regex_is_match(&re, candidate))
1104        })
1105        .unwrap_or(false),
1106        _ => false,
1107    };
1108
1109    if !name_matches {
1110        return false;
1111    }
1112
1113    // Check if the symbol has references TO it (incoming edges)
1114    // Current behavior: References, Calls, Imports, AND FfiCall all count as references
1115    for edge in ctx.graph.edges().edges_to(node_id) {
1116        let is_reference = matches!(
1117            &edge.kind,
1118            EdgeKind::References
1119                | EdgeKind::Calls { .. }
1120                | EdgeKind::Imports { .. }
1121                | EdgeKind::FfiCall { .. }
1122        );
1123        if is_reference {
1124            return true;
1125        }
1126    }
1127
1128    false
1129}
1130
1131/// `implements:X` - find symbols that implement interface/trait X
1132fn match_implements(ctx: &GraphEvalContext, node_id: NodeId, value: &Value) -> bool {
1133    let Some(trait_name) = value.as_string() else {
1134        return false;
1135    };
1136
1137    for edge in ctx.graph.edges().edges_from(node_id) {
1138        if let EdgeKind::Implements = &edge.kind
1139            && let Some(target_entry) = ctx.graph.nodes().get(edge.target)
1140            && entry_query_texts(ctx.graph, target_entry)
1141                .iter()
1142                .any(|name| {
1143                    language_aware_segments_match(ctx.graph, target_entry.file, name, trait_name)
1144                })
1145        {
1146            return true;
1147        }
1148    }
1149    false
1150}
1151
1152// ============================================================================
1153// Scope predicates (with regex support)
1154// ============================================================================
1155
1156/// Convert `NodeKind` to scope type string for predicate matching.
1157///
1158/// This mapping provides parity with legacy index scope predicates:
1159/// - Trait → interface
1160/// - Test → function
1161/// - Service → class
1162/// - etc.
1163fn node_kind_to_scope_type(kind: NodeKind) -> &'static str {
1164    match kind {
1165        NodeKind::Function | NodeKind::Test => "function",
1166        NodeKind::Method => "method",
1167        NodeKind::Class | NodeKind::Service => "class",
1168        NodeKind::Interface | NodeKind::Trait => "interface",
1169        NodeKind::Struct => "struct",
1170        NodeKind::Enum => "enum",
1171        NodeKind::Module => "module",
1172        NodeKind::Macro => "macro",
1173        NodeKind::Component => "component",
1174        NodeKind::Resource | NodeKind::Endpoint => "resource",
1175        // Non-container types
1176        NodeKind::Variable => "variable",
1177        NodeKind::Constant => "constant",
1178        NodeKind::Type => "type",
1179        NodeKind::EnumVariant => "enumvariant",
1180        NodeKind::Import => "import",
1181        NodeKind::Export => "export",
1182        NodeKind::CallSite => "callsite",
1183        NodeKind::Parameter => "parameter",
1184        NodeKind::Property => "property",
1185        NodeKind::StyleRule => "style_rule",
1186        NodeKind::StyleAtRule => "style_at_rule",
1187        NodeKind::StyleVariable => "style_variable",
1188        NodeKind::Lifetime => "lifetime",
1189        NodeKind::TypeParameter => "type_parameter",
1190        NodeKind::Annotation => "annotation",
1191        NodeKind::AnnotationValue => "annotation_value",
1192        NodeKind::LambdaTarget => "lambda_target",
1193        NodeKind::JavaModule => "java_module",
1194        NodeKind::EnumConstant => "enum_constant",
1195        NodeKind::Other => "other",
1196    }
1197}
1198
1199fn match_scope(
1200    ctx: &GraphEvalContext,
1201    node_id: NodeId,
1202    field: &str,
1203    operator: &Operator,
1204    value: &Value,
1205) -> bool {
1206    let scope_part = field.strip_prefix("scope.").unwrap_or("");
1207    match scope_part {
1208        "type" => match_scope_type(ctx, node_id, operator, value),
1209        "name" => match_scope_name(ctx, node_id, operator, value),
1210        "parent" => match_scope_parent_name(ctx, node_id, operator, value),
1211        "ancestor" => match_scope_ancestor_name(ctx, node_id, operator, value),
1212        _ => false,
1213    }
1214}
1215
1216fn match_scope_type(
1217    ctx: &GraphEvalContext,
1218    node_id: NodeId,
1219    operator: &Operator,
1220    value: &Value,
1221) -> bool {
1222    for edge in ctx.graph.edges().edges_to(node_id) {
1223        if let EdgeKind::Contains = &edge.kind
1224            && let Some(parent) = ctx.graph.nodes().get(edge.source)
1225        {
1226            // Use mapped scope type for parity with legacy index
1227            let scope_type = node_kind_to_scope_type(parent.kind);
1228            return match (operator, value) {
1229                // Case-sensitive comparison
1230                (Operator::Equal, Value::String(exp)) => scope_type == exp,
1231                (Operator::Regex, Value::Regex(rv)) => get_or_compile_regex(
1232                    &rv.pattern,
1233                    rv.flags.case_insensitive,
1234                    rv.flags.multiline,
1235                    rv.flags.dot_all,
1236                )
1237                .map(|re| regex_is_match(&re, scope_type))
1238                .unwrap_or(false),
1239                _ => false,
1240            };
1241        }
1242    }
1243    false
1244}
1245
1246fn match_scope_name(
1247    ctx: &GraphEvalContext,
1248    node_id: NodeId,
1249    operator: &Operator,
1250    value: &Value,
1251) -> bool {
1252    for edge in ctx.graph.edges().edges_to(node_id) {
1253        if let EdgeKind::Contains = &edge.kind
1254            && let Some(parent) = ctx.graph.nodes().get(edge.source)
1255            && let Some(name) = ctx.graph.strings().resolve(parent.name)
1256        {
1257            return match (operator, value) {
1258                // Use segments_match for qualified name suffix matching
1259                (Operator::Equal, Value::String(exp)) => segments_match(&name, exp),
1260                (Operator::Regex, Value::Regex(rv)) => get_or_compile_regex(
1261                    &rv.pattern,
1262                    rv.flags.case_insensitive,
1263                    rv.flags.multiline,
1264                    rv.flags.dot_all,
1265                )
1266                .map(|re| regex_is_match(&re, &name))
1267                .unwrap_or(false),
1268                _ => false,
1269            };
1270        }
1271    }
1272    false
1273}
1274
1275/// `scope.parent:X` - find nodes with immediate parent NAMED X.
1276///
1277/// Uses `segments_match` for qualified name suffix matching (same as name: predicate).
1278fn match_scope_parent_name(
1279    ctx: &GraphEvalContext,
1280    node_id: NodeId,
1281    operator: &Operator,
1282    value: &Value,
1283) -> bool {
1284    for edge in ctx.graph.edges().edges_to(node_id) {
1285        if let EdgeKind::Contains = &edge.kind
1286            && let Some(parent) = ctx.graph.nodes().get(edge.source)
1287            && let Some(name) = ctx.graph.strings().resolve(parent.name)
1288        {
1289            return match (operator, value) {
1290                // Use segments_match for qualified name suffix matching
1291                (Operator::Equal, Value::String(exp)) => segments_match(&name, exp),
1292                (Operator::Regex, Value::Regex(rv)) => get_or_compile_regex(
1293                    &rv.pattern,
1294                    rv.flags.case_insensitive,
1295                    rv.flags.multiline,
1296                    rv.flags.dot_all,
1297                )
1298                .map(|re| regex_is_match(&re, &name))
1299                .unwrap_or(false),
1300                _ => false,
1301            };
1302        }
1303    }
1304    false
1305}
1306
1307/// `scope.ancestor:X` - find nodes with any ancestor NAMED X.
1308///
1309/// CYCLE PROTECTION: Uses visited set to prevent infinite loops.
1310/// Uses `segments_match` for qualified name suffix matching.
1311fn match_scope_ancestor_name(
1312    ctx: &GraphEvalContext,
1313    node_id: NodeId,
1314    operator: &Operator,
1315    value: &Value,
1316) -> bool {
1317    let mut current = node_id;
1318    let mut visited = HashSet::new();
1319    visited.insert(node_id);
1320
1321    loop {
1322        let mut found_parent = false;
1323        for edge in ctx.graph.edges().edges_to(current) {
1324            if let EdgeKind::Contains = &edge.kind {
1325                // CYCLE PROTECTION: Skip if we've already visited this node
1326                if visited.contains(&edge.source) {
1327                    continue;
1328                }
1329                visited.insert(edge.source);
1330
1331                found_parent = true;
1332                current = edge.source;
1333                if let Some(parent) = ctx.graph.nodes().get(current)
1334                    && let Some(name) = ctx.graph.strings().resolve(parent.name)
1335                {
1336                    let matches = match (operator, value) {
1337                        // Use segments_match for qualified name suffix matching
1338                        (Operator::Equal, Value::String(exp)) => segments_match(&name, exp),
1339                        (Operator::Regex, Value::Regex(rv)) => get_or_compile_regex(
1340                            &rv.pattern,
1341                            rv.flags.case_insensitive,
1342                            rv.flags.multiline,
1343                            rv.flags.dot_all,
1344                        )
1345                        .map(|re| regex_is_match(&re, &name))
1346                        .unwrap_or(false),
1347                        _ => false,
1348                    };
1349                    if matches {
1350                        return true;
1351                    }
1352                }
1353                break;
1354            }
1355        }
1356        if !found_parent {
1357            break;
1358        }
1359    }
1360    false
1361}
1362
1363// ============================================================================
1364// Subquery evaluation
1365// ============================================================================
1366
1367/// Evaluate an expression against all nodes and return the set of matching node IDs.
1368///
1369/// Used for subquery evaluation: `callers:(kind:function AND async:true)`.
1370///
1371/// # Errors
1372///
1373/// Returns an error if predicate evaluation fails.
1374pub fn evaluate_subquery(ctx: &GraphEvalContext, expr: &Expr) -> Result<HashSet<NodeId>> {
1375    let recursion_limits = crate::config::RecursionLimits::load_or_default()?;
1376    let expr_depth = recursion_limits.effective_expr_depth()?;
1377    let mut guard = crate::query::security::RecursionGuard::new(expr_depth)?;
1378
1379    let arena = ctx.graph.nodes();
1380    let mut matches = HashSet::new();
1381    for (id, _) in arena.iter() {
1382        if evaluate_node(ctx, id, expr, &mut guard)? {
1383            matches.insert(id);
1384        }
1385    }
1386    Ok(matches)
1387}
1388
1389/// Match callers using a precomputed subquery result set.
1390///
1391/// Checks if any of this node's outgoing Calls edges target a node in the
1392/// precomputed set. Returns an error if the subquery was not precomputed
1393/// (indicates a bug in `precompute_subqueries`).
1394fn match_callers_subquery(
1395    ctx: &GraphEvalContext,
1396    node_id: NodeId,
1397    subquery_matches: Option<&HashSet<NodeId>>,
1398) -> Result<bool> {
1399    let Some(matches) = subquery_matches else {
1400        return Err(anyhow!(
1401            "subquery cache miss: precompute_subqueries did not populate cache for this relation predicate"
1402        ));
1403    };
1404    for edge in ctx.graph.edges().edges_from(node_id) {
1405        if let EdgeKind::Calls { .. } = &edge.kind
1406            && matches.contains(&edge.target)
1407        {
1408            return Ok(true);
1409        }
1410    }
1411    Ok(false)
1412}
1413
1414/// Match callees using a precomputed subquery result set.
1415fn match_callees_subquery(
1416    ctx: &GraphEvalContext,
1417    node_id: NodeId,
1418    subquery_matches: Option<&HashSet<NodeId>>,
1419) -> Result<bool> {
1420    let Some(matches) = subquery_matches else {
1421        return Err(anyhow!(
1422            "subquery cache miss: precompute_subqueries did not populate cache for this relation predicate"
1423        ));
1424    };
1425    for edge in ctx.graph.edges().edges_to(node_id) {
1426        if let EdgeKind::Calls { .. } = &edge.kind
1427            && matches.contains(&edge.source)
1428        {
1429            return Ok(true);
1430        }
1431    }
1432    Ok(false)
1433}
1434
1435/// Match imports using a precomputed subquery result set.
1436///
1437/// Per-node semantic (DB15): returns true iff this specific node has an
1438/// outgoing `Imports` edge whose target is in the precomputed subquery set.
1439/// Aligned with [`match_imports`] and `sqry_db::queries::ImportsQuery`.
1440fn match_imports_subquery(
1441    ctx: &GraphEvalContext,
1442    node_id: NodeId,
1443    subquery_matches: Option<&HashSet<NodeId>>,
1444) -> Result<bool> {
1445    let Some(matches) = subquery_matches else {
1446        return Err(anyhow!(
1447            "subquery cache miss: precompute_subqueries did not populate cache for this relation predicate"
1448        ));
1449    };
1450    for edge in ctx.graph.edges().edges_from(node_id) {
1451        if let EdgeKind::Imports { .. } = &edge.kind
1452            && matches.contains(&edge.target)
1453        {
1454            return Ok(true);
1455        }
1456    }
1457    Ok(false)
1458}
1459
1460/// Match exports using a precomputed subquery result set.
1461fn match_exports_subquery(
1462    ctx: &GraphEvalContext,
1463    node_id: NodeId,
1464    subquery_matches: Option<&HashSet<NodeId>>,
1465) -> Result<bool> {
1466    let Some(matches) = subquery_matches else {
1467        return Err(anyhow!(
1468            "subquery cache miss: precompute_subqueries did not populate cache for this relation predicate"
1469        ));
1470    };
1471    for edge in ctx.graph.edges().edges_from(node_id) {
1472        if let EdgeKind::Exports { .. } = &edge.kind
1473            && matches.contains(&edge.target)
1474        {
1475            return Ok(true);
1476        }
1477    }
1478    Ok(false)
1479}
1480
1481/// Match implements using a precomputed subquery result set.
1482fn match_implements_subquery(
1483    ctx: &GraphEvalContext,
1484    node_id: NodeId,
1485    subquery_matches: Option<&HashSet<NodeId>>,
1486) -> Result<bool> {
1487    let Some(matches) = subquery_matches else {
1488        return Err(anyhow!(
1489            "subquery cache miss: precompute_subqueries did not populate cache for this relation predicate"
1490        ));
1491    };
1492    for edge in ctx.graph.edges().edges_from(node_id) {
1493        if let EdgeKind::Implements = &edge.kind
1494            && matches.contains(&edge.target)
1495        {
1496            return Ok(true);
1497        }
1498    }
1499    Ok(false)
1500}
1501
1502/// Match references using a precomputed subquery result set.
1503fn match_references_subquery(
1504    ctx: &GraphEvalContext,
1505    node_id: NodeId,
1506    subquery_matches: Option<&HashSet<NodeId>>,
1507) -> Result<bool> {
1508    let Some(matches) = subquery_matches else {
1509        return Err(anyhow!(
1510            "subquery cache miss: precompute_subqueries did not populate cache for this relation predicate"
1511        ));
1512    };
1513    for edge in ctx.graph.edges().edges_to(node_id) {
1514        let is_reference = matches!(
1515            &edge.kind,
1516            EdgeKind::References
1517                | EdgeKind::Calls { .. }
1518                | EdgeKind::Imports { .. }
1519                | EdgeKind::FfiCall { .. }
1520        );
1521        if is_reference && matches.contains(&edge.source) {
1522            return Ok(true);
1523        }
1524    }
1525    Ok(false)
1526}
1527
1528// ============================================================================
1529// Join evaluation
1530// ============================================================================
1531
1532/// Evaluate a join expression, returning matched (left, right) node pairs.
1533///
1534/// For `(kind:function AND lang:rust) CALLS (kind:function AND lang:python)`:
1535/// 1. Evaluate LHS query → set of matching nodes
1536/// 2. Evaluate RHS query → set of matching nodes
1537/// 3. For each LHS node, check edges of the specified kind
1538/// 4. If edge target is in RHS set → add (lhs, rhs) pair
1539///
1540/// # Errors
1541///
1542/// Returns an error if subquery evaluation or edge traversal fails.
1543pub fn evaluate_join(
1544    ctx: &GraphEvalContext,
1545    join: &JoinExpr,
1546    max_results: Option<usize>,
1547) -> Result<JoinEvalResult> {
1548    let lhs_matches = evaluate_subquery(ctx, &join.left)?;
1549    let rhs_matches = evaluate_subquery(ctx, &join.right)?;
1550    let cap = max_results.unwrap_or(DEFAULT_JOIN_RESULT_CAP);
1551
1552    let mut pairs = Vec::new();
1553    let mut truncated = false;
1554    'outer: for &lhs_id in &lhs_matches {
1555        for edge in ctx.graph.edges().edges_from(lhs_id) {
1556            if edge_matches_join_kind(&edge.kind, &join.edge) && rhs_matches.contains(&edge.target)
1557            {
1558                pairs.push((lhs_id, edge.target));
1559                if pairs.len() >= cap {
1560                    truncated = true;
1561                    break 'outer;
1562                }
1563            }
1564        }
1565    }
1566    Ok(JoinEvalResult { pairs, truncated })
1567}
1568
1569/// Result of a join evaluation including truncation metadata.
1570pub struct JoinEvalResult {
1571    /// The matched (source, target) node ID pairs.
1572    pub pairs: Vec<(NodeId, NodeId)>,
1573    /// Whether the result set was truncated by the result cap.
1574    pub truncated: bool,
1575}
1576
1577/// Default result cap for join queries.
1578///
1579/// Prevents unbounded memory growth when a join produces a large number of matching pairs.
1580const DEFAULT_JOIN_RESULT_CAP: usize = 10_000;
1581
1582/// Check if an edge kind matches a join edge kind.
1583fn edge_matches_join_kind(edge_kind: &EdgeKind, join_kind: &JoinEdgeKind) -> bool {
1584    match join_kind {
1585        JoinEdgeKind::Calls => matches!(edge_kind, EdgeKind::Calls { .. }),
1586        JoinEdgeKind::Imports => matches!(edge_kind, EdgeKind::Imports { .. }),
1587        JoinEdgeKind::Inherits => matches!(edge_kind, EdgeKind::Inherits),
1588        JoinEdgeKind::Implements => matches!(edge_kind, EdgeKind::Implements),
1589    }
1590}
1591
1592#[cfg(test)]
1593mod tests {
1594    use super::*;
1595    use crate::graph::node::Language;
1596    use crate::query::types::{Condition, Field, Span};
1597    use std::path::Path;
1598
1599    #[test]
1600    fn test_import_text_matches_canonicalized_qualified_imports() {
1601        let mut graph = CodeGraph::new();
1602        let file_id = graph
1603            .files_mut()
1604            .register(Path::new("src/FileProcessor.cs"))
1605            .unwrap();
1606        assert!(graph.files_mut().set_language(file_id, Language::CSharp));
1607
1608        assert!(import_text_matches(
1609            &graph,
1610            file_id,
1611            "System::IO",
1612            "System.IO"
1613        ));
1614        assert!(import_text_matches(
1615            &graph,
1616            file_id,
1617            "System::Collections::Generic",
1618            "System.Collections.Generic"
1619        ));
1620        assert!(!import_text_matches(
1621            &graph,
1622            file_id,
1623            "System::Text",
1624            "System.IO"
1625        ));
1626    }
1627
1628    #[test]
1629    fn test_language_aware_segments_match_supports_ruby_method_separators() {
1630        let mut graph = CodeGraph::new();
1631        let file_id = graph
1632            .files_mut()
1633            .register(Path::new("app/models/user.rb"))
1634            .unwrap();
1635        assert!(graph.files_mut().set_language(file_id, Language::Ruby));
1636
1637        assert!(language_aware_segments_match(
1638            &graph,
1639            file_id,
1640            "Admin::Users::Controller::show",
1641            "Admin::Users::Controller#show"
1642        ));
1643        assert!(language_aware_segments_match(
1644            &graph,
1645            file_id,
1646            "Admin::Users::Controller::show",
1647            "show"
1648        ));
1649        assert!(!language_aware_segments_match(
1650            &graph,
1651            file_id,
1652            "Admin::Users::Controller::index",
1653            "Admin::Users::Controller#show"
1654        ));
1655    }
1656
1657    #[test]
1658    fn test_normalize_kind() {
1659        // Case-sensitive synonyms
1660        assert_eq!(normalize_kind("trait"), "interface");
1661        assert_eq!(normalize_kind("TRAIT"), "TRAIT"); // Case-sensitive: not a synonym
1662        assert_eq!(normalize_kind("field"), "property");
1663        assert_eq!(normalize_kind("namespace"), "module");
1664        assert_eq!(normalize_kind("function"), "function"); // unchanged
1665    }
1666
1667    #[test]
1668    fn test_graph_eval_context_builder() {
1669        let graph = CodeGraph::new();
1670        let pm = PluginManager::new();
1671        let ctx = GraphEvalContext::new(&graph, &pm)
1672            .with_workspace_root(Path::new("/test"))
1673            .with_parallel_disabled(true);
1674
1675        assert!(ctx.disable_parallel);
1676        assert_eq!(ctx.workspace_root, Some(Path::new("/test")));
1677    }
1678
1679    // ================================================================
1680    // collect_subquery_exprs tests
1681    // ================================================================
1682
1683    /// Helper: build `field:(inner_expr)` as a Condition with `Value::Subquery`.
1684    fn subquery_condition(field: &str, inner: Expr, start: usize, end: usize) -> Expr {
1685        Expr::Condition(Condition {
1686            field: Field(field.to_string()),
1687            operator: Operator::Equal,
1688            value: Value::Subquery(Box::new(inner)),
1689            span: Span::with_position(start, end, 1, start + 1),
1690        })
1691    }
1692
1693    /// Helper: build a simple `kind:function` condition.
1694    fn kind_condition(kind: &str) -> Expr {
1695        Expr::Condition(Condition {
1696            field: Field("kind".to_string()),
1697            operator: Operator::Equal,
1698            value: Value::String(kind.to_string()),
1699            span: Span::default(),
1700        })
1701    }
1702
1703    #[test]
1704    fn test_collect_subquery_exprs_post_order_depth_2() {
1705        // Build: callers:(callees:(kind:function))
1706        // Inner subquery: callees:(kind:function) at span (20, 40)
1707        // Outer subquery: callers:(...) at span (0, 50)
1708        let inner_subquery = subquery_condition("callees", kind_condition("function"), 20, 40);
1709        let outer_subquery = subquery_condition("callers", inner_subquery, 0, 50);
1710
1711        let mut out = Vec::new();
1712        collect_subquery_exprs(&outer_subquery, &mut out);
1713
1714        // Post-order: inner appears before outer
1715        assert_eq!(
1716            out.len(),
1717            2,
1718            "should collect both inner and outer subqueries"
1719        );
1720        assert_eq!(out[0].0, (20, 40), "inner subquery span should come first");
1721        assert_eq!(out[1].0, (0, 50), "outer subquery span should come second");
1722    }
1723
1724    #[test]
1725    fn test_collect_subquery_exprs_post_order_depth_3() {
1726        // Build: callers:(callees:(imports:(kind:function)))
1727        let innermost = subquery_condition("imports", kind_condition("function"), 30, 50);
1728        let middle = subquery_condition("callees", innermost, 15, 55);
1729        let outer = subquery_condition("callers", middle, 0, 60);
1730
1731        let mut out = Vec::new();
1732        collect_subquery_exprs(&outer, &mut out);
1733
1734        assert_eq!(out.len(), 3, "should collect all three nested subqueries");
1735        assert_eq!(out[0].0, (30, 50), "innermost should come first");
1736        assert_eq!(out[1].0, (15, 55), "middle should come second");
1737        assert_eq!(out[2].0, (0, 60), "outer should come last");
1738    }
1739
1740    #[test]
1741    fn test_collect_subquery_exprs_and_or_branches() {
1742        // Build: callers:(kind:function) AND callees:(kind:method)
1743        let left = subquery_condition("callers", kind_condition("function"), 0, 25);
1744        let right = subquery_condition("callees", kind_condition("method"), 30, 55);
1745        let expr = Expr::And(vec![left, right]);
1746
1747        let mut out = Vec::new();
1748        collect_subquery_exprs(&expr, &mut out);
1749
1750        assert_eq!(out.len(), 2, "should collect subqueries from both branches");
1751        assert_eq!(out[0].0, (0, 25), "left branch subquery");
1752        assert_eq!(out[1].0, (30, 55), "right branch subquery");
1753    }
1754
1755    #[test]
1756    fn test_collect_subquery_exprs_no_subqueries() {
1757        // Simple condition with no subqueries: kind:function
1758        let expr = kind_condition("function");
1759
1760        let mut out = Vec::new();
1761        collect_subquery_exprs(&expr, &mut out);
1762
1763        assert!(
1764            out.is_empty(),
1765            "should collect nothing for plain conditions"
1766        );
1767    }
1768
1769    // ================================================================
1770    // FfiCall edge in references/referenced_by predicates
1771    // ================================================================
1772
1773    use crate::graph::unified::edge::{BidirectionalEdgeStore, FfiConvention};
1774    use crate::graph::unified::storage::{
1775        AuxiliaryIndices, FileRegistry, NodeArena, StringInterner,
1776    };
1777
1778    /// Build a `CodeGraph` with: `caller --FfiCall(C)--> target`
1779    fn build_ffi_graph() -> (CodeGraph, NodeId, NodeId) {
1780        let mut arena = NodeArena::new();
1781        let edges = BidirectionalEdgeStore::new();
1782        let mut strings = StringInterner::new();
1783        let mut files = FileRegistry::new();
1784        let mut indices = AuxiliaryIndices::new();
1785
1786        let caller_name = strings.intern("caller_fn").unwrap();
1787        let target_name = strings.intern("ffi_target").unwrap();
1788        let file_id = files.register(Path::new("test.r")).unwrap();
1789
1790        let caller_id = arena
1791            .alloc(NodeEntry {
1792                kind: NodeKind::Function,
1793                name: caller_name,
1794                file: file_id,
1795                start_byte: 0,
1796                end_byte: 100,
1797                start_line: 1,
1798                start_column: 0,
1799                end_line: 5,
1800                end_column: 0,
1801                signature: None,
1802                doc: None,
1803                qualified_name: None,
1804                visibility: None,
1805                is_async: false,
1806                is_static: false,
1807                is_unsafe: false,
1808                body_hash: None,
1809            })
1810            .unwrap();
1811
1812        let target_id = arena
1813            .alloc(NodeEntry {
1814                kind: NodeKind::Function,
1815                name: target_name,
1816                file: file_id,
1817                start_byte: 200,
1818                end_byte: 300,
1819                start_line: 10,
1820                start_column: 0,
1821                end_line: 15,
1822                end_column: 0,
1823                signature: None,
1824                doc: None,
1825                qualified_name: None,
1826                visibility: None,
1827                is_async: false,
1828                is_static: false,
1829                is_unsafe: false,
1830                body_hash: None,
1831            })
1832            .unwrap();
1833
1834        indices.add(caller_id, NodeKind::Function, caller_name, None, file_id);
1835        indices.add(target_id, NodeKind::Function, target_name, None, file_id);
1836
1837        edges.add_edge(
1838            caller_id,
1839            target_id,
1840            EdgeKind::FfiCall {
1841                convention: FfiConvention::C,
1842            },
1843            file_id,
1844        );
1845
1846        let graph = CodeGraph::from_components(
1847            arena,
1848            edges,
1849            strings,
1850            files,
1851            indices,
1852            crate::graph::unified::NodeMetadataStore::new(),
1853        );
1854        (graph, caller_id, target_id)
1855    }
1856
1857    #[test]
1858    fn test_ffi_call_edge_in_references_predicate() {
1859        let (graph, _caller_id, target_id) = build_ffi_graph();
1860        let pm = PluginManager::new();
1861        let ctx = GraphEvalContext::new(&graph, &pm);
1862
1863        // `references:ffi_target` should match because there is an FfiCall edge to it
1864        let result = match_references(
1865            &ctx,
1866            target_id,
1867            &Operator::Equal,
1868            &Value::String("ffi_target".to_string()),
1869        );
1870        assert!(result, "references: predicate should match FfiCall edges");
1871    }
1872
1873    #[test]
1874    fn test_ffi_call_edge_in_references_subquery() {
1875        let (graph, caller_id, target_id) = build_ffi_graph();
1876        let pm = PluginManager::new();
1877        let ctx = GraphEvalContext::new(&graph, &pm);
1878
1879        // Simulate a subquery that matched the caller node
1880        let mut subquery_results = HashSet::new();
1881        subquery_results.insert(caller_id);
1882
1883        // match_references_subquery checks incoming edges to target_id
1884        // and verifies the source is in the subquery result set
1885        let result = match_references_subquery(&ctx, target_id, Some(&subquery_results)).unwrap();
1886        assert!(
1887            result,
1888            "references subquery should match FfiCall edge sources"
1889        );
1890    }
1891}