1use 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
48fn 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
62type SubqueryCache = HashMap<(usize, usize), Arc<HashSet<NodeId>>>;
69
70pub struct GraphEvalContext<'a> {
75 pub graph: &'a CodeGraph,
77 pub plugin_manager: &'a PluginManager,
79 pub workspace_root: Option<&'a Path>,
81 pub disable_parallel: bool,
83 pub subquery_cache: SubqueryCache,
86}
87
88impl<'a> GraphEvalContext<'a> {
89 #[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 #[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 #[must_use]
110 pub fn with_parallel_disabled(mut self, disabled: bool) -> Self {
111 self.disable_parallel = disabled;
112 self
113 }
114
115 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
137fn 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 collect_subquery_exprs(inner, out);
150 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
167pub fn evaluate_all(ctx: &mut GraphEvalContext, expr: &Expr) -> Result<Vec<NodeId>> {
173 ctx.precompute_subqueries(expr)?;
177
178 let arena = ctx.graph.nodes();
179
180 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 let mut matches = Vec::new();
188 for (id, entry) in arena.iter() {
189 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 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 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
231pub 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 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), }
358}
359
360fn is_plugin_field(ctx: &GraphEvalContext, field: &str) -> bool {
362 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 matches!(
377 field,
378 "abstract" | "final" | "generic" | "parameters" | "arity"
379 )
380}
381
382fn normalize_kind(kind: &str) -> &str {
391 match kind {
392 "trait" => "interface", "impl" => "implementation",
395 "field" => "property",
397 "namespace" => "module",
399 "element" => "component",
401 "style" => "style_rule",
403 "at_rule" => "style_at_rule",
404 "css_var" | "custom_property" => "style_variable",
405 _ => 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 ®ex_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
436fn match_name(
441 ctx: &GraphEvalContext,
442 entry: &NodeEntry,
443 operator: &Operator,
444 value: &Value,
445) -> bool {
446 match (operator, value) {
447 (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 ®ex_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
472fn 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 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 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 get_or_compile_regex(
513 ®ex_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
525fn 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 let Some(lang) = ctx.graph.files().language_for_file(entry.file) else {
585 return false;
586 };
587
588 let actual = language_to_canonical(lang);
590
591 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
606fn 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 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 match operator {
645 Operator::Equal => normalized_actual == normalized_expected,
646 _ => false,
647 }
648}
649
650fn 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 if !matches!(entry.kind, NodeKind::Function | NodeKind::Method) {
672 return false;
673 }
674
675 let Some(sig_id) = entry.signature else {
676 return false;
678 };
679
680 let Some(signature) = ctx.graph.strings().resolve(sig_id) else {
681 return false;
682 };
683
684 match operator {
687 Operator::Equal => signature.contains(expected),
688 _ => false,
689 }
690}
691
692fn 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
712fn 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
728fn 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
747fn 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 let method_part = extract_method_name(target_name);
768
769 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 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#[must_use]
807pub fn extract_method_name(qualified: &str) -> Option<String> {
808 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
820fn 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 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
849fn 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#[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 let target_match = graph
902 .nodes()
903 .get(edge.target)
904 .is_some_and(|entry| import_entry_matches(graph, entry, target_module));
905
906 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 let wildcard_match = *is_wildcard && target_module == "*";
917
918 target_match || alias_match || wildcard_match
919}
920
921#[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#[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#[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#[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
1026fn 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 for edge in edges.edges_from(node_id) {
1051 if let EdgeKind::Exports { .. } = &edge.kind {
1052 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 for edge in edges.edges_to(node_id) {
1063 if let EdgeKind::Exports { .. } = &edge.kind {
1064 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
1076fn match_references(
1080 ctx: &GraphEvalContext,
1081 node_id: NodeId,
1082 operator: &Operator,
1083 value: &Value,
1084) -> bool {
1085 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 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
1131fn 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
1152fn 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 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 let scope_type = node_kind_to_scope_type(parent.kind);
1228 return match (operator, value) {
1229 (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 (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
1275fn 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 (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
1307fn 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 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 (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
1363pub 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
1389fn 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
1414fn 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
1435fn 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
1460fn 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
1481fn 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
1502fn 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
1528pub 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
1569pub struct JoinEvalResult {
1571 pub pairs: Vec<(NodeId, NodeId)>,
1573 pub truncated: bool,
1575}
1576
1577const DEFAULT_JOIN_RESULT_CAP: usize = 10_000;
1581
1582fn 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 assert_eq!(normalize_kind("trait"), "interface");
1661 assert_eq!(normalize_kind("TRAIT"), "TRAIT"); assert_eq!(normalize_kind("field"), "property");
1663 assert_eq!(normalize_kind("namespace"), "module");
1664 assert_eq!(normalize_kind("function"), "function"); }
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 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 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 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 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 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 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 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 use crate::graph::unified::edge::{BidirectionalEdgeStore, FfiConvention};
1774 use crate::graph::unified::storage::{
1775 AuxiliaryIndices, FileRegistry, NodeArena, StringInterner,
1776 };
1777
1778 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 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 let mut subquery_results = HashSet::new();
1881 subquery_results.insert(caller_id);
1882
1883 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}