1use std::{collections::HashMap, path::Path};
20
21use sqry_core::graph::unified::edge::kind::TypeOfContext;
22use sqry_core::graph::unified::{ExportKind, GraphBuildHelper, StagingGraph};
23use sqry_core::graph::{GraphBuilder, GraphBuilderError, GraphResult, Language, Span};
24use tree_sitter::{Node, StreamingIterator, Tree};
25
26use super::type_extractor::{
27 extract_all_type_names_from_elixir_type, extract_type_string, is_type_node,
28};
29
30#[derive(Debug, Clone, Copy)]
32pub struct ElixirGraphBuilder {
33 max_scope_depth: usize,
34}
35
36impl Default for ElixirGraphBuilder {
37 fn default() -> Self {
38 Self {
39 max_scope_depth: 3, }
41 }
42}
43
44impl ElixirGraphBuilder {
45 #[must_use]
46 pub fn new(max_scope_depth: usize) -> Self {
47 Self { max_scope_depth }
48 }
49}
50
51impl GraphBuilder for ElixirGraphBuilder {
52 fn build_graph(
53 &self,
54 tree: &Tree,
55 content: &[u8],
56 file: &Path,
57 staging: &mut StagingGraph,
58 ) -> GraphResult<()> {
59 let mut helper = GraphBuildHelper::new(staging, file, Language::Elixir);
61
62 let ast_graph = ASTGraph::from_tree(tree, content, self.max_scope_depth).map_err(|e| {
64 GraphBuilderError::ParseError {
65 span: Span::default(),
66 reason: e,
67 }
68 })?;
69
70 let mut protocol_map = HashMap::new();
72 collect_protocols(tree.root_node(), content, &mut helper, &mut protocol_map)?;
73
74 let recursion_limits =
76 sqry_core::config::RecursionLimits::load_or_default().map_err(|e| {
77 GraphBuilderError::ParseError {
78 span: Span::default(),
79 reason: format!("Failed to load recursion limits: {e}"),
80 }
81 })?;
82 let file_ops_depth = recursion_limits.effective_file_ops_depth().map_err(|e| {
83 GraphBuilderError::ParseError {
84 span: Span::default(),
85 reason: format!("Invalid file_ops_depth configuration: {e}"),
86 }
87 })?;
88 let mut guard =
89 sqry_core::query::security::RecursionGuard::new(file_ops_depth).map_err(|e| {
90 GraphBuilderError::ParseError {
91 span: Span::default(),
92 reason: format!("Failed to create recursion guard: {e}"),
93 }
94 })?;
95
96 walk_tree_for_graph(
98 tree.root_node(),
99 content,
100 &ast_graph,
101 &mut helper,
102 &protocol_map,
103 &mut guard,
104 )?;
105
106 Ok(())
107 }
108
109 fn language(&self) -> Language {
110 Language::Elixir
111 }
112}
113
114fn collect_protocols(
120 node: Node,
121 content: &[u8],
122 helper: &mut GraphBuildHelper,
123 protocol_map: &mut HashMap<String, sqry_core::graph::unified::NodeId>,
124) -> GraphResult<()> {
125 if node.kind() == "call"
126 && is_protocol_definition(&node, content)
127 && let Some(protocol_id) = build_protocol_node(node, content, helper)?
128 {
129 let mut node_cursor = node.walk();
131 for child in node.children(&mut node_cursor) {
132 if child.kind() == "arguments" {
133 let mut args_cursor = child.walk();
134 for arg_child in child.children(&mut args_cursor) {
135 if (arg_child.kind() == "identifier" || arg_child.kind() == "alias")
136 && let Ok(name) = arg_child.utf8_text(content)
137 {
138 protocol_map.insert(name.to_string(), protocol_id);
139 break;
140 }
141 }
142 break;
143 }
144 }
145 }
146
147 let mut cursor = node.walk();
149 for child in node.children(&mut cursor) {
150 collect_protocols(child, content, helper, protocol_map)?;
151 }
152
153 Ok(())
154}
155
156fn walk_tree_for_graph(
162 node: Node,
163 content: &[u8],
164 ast_graph: &ASTGraph,
165 helper: &mut GraphBuildHelper,
166 protocol_map: &HashMap<String, sqry_core::graph::unified::NodeId>,
167 guard: &mut sqry_core::query::security::RecursionGuard,
168) -> GraphResult<()> {
169 guard.enter().map_err(|e| GraphBuilderError::ParseError {
170 span: Span::default(),
171 reason: format!("Recursion limit exceeded: {e}"),
172 })?;
173
174 if node.kind() == "unary_operator" && is_spec_annotation(&node, content) {
176 process_spec_typeof_edges(node, content, helper)?;
177 }
178
179 if node.kind() == "call" && is_protocol_implementation(&node, content) {
181 build_protocol_impl(node, content, helper, protocol_map)?;
182 }
183 else if is_function_definition(&node, content) {
185 if let Some(context) = ast_graph.get_callable_context(node.id()) {
187 let span = span_from_node(node);
188
189 let visibility = if context.is_private {
192 "private"
193 } else {
194 "public"
195 };
196 let function_id = helper.add_function_with_visibility(
197 &context.qualified_name,
198 Some(span),
199 false, false, Some(visibility),
202 );
203
204 if !context.is_private {
206 let module_id = helper.add_module("<module>", None);
207 helper.add_export_edge_full(module_id, function_id, ExportKind::Direct, None);
208 }
209 }
210 }
211
212 if node.kind() == "call" && is_erlang_load_nif(&node, content) {
214 build_nif_ffi_edge(node, content, ast_graph, helper);
215 }
216 else if node.kind() == "call" && is_import_statement(&node, content) {
218 build_import_edge_with_helper(node, content, helper)?;
220 }
221 else if node.kind() == "call"
223 && !is_function_definition(&node, content)
224 && let Ok(Some((caller_id, callee_id, argument_count, span))) =
225 build_call_edge_with_helper(ast_graph, node, content, helper)
226 {
227 let argument_count = u8::try_from(argument_count).unwrap_or(u8::MAX);
228 helper.add_call_edge_full_with_span(
229 caller_id,
230 callee_id,
231 argument_count,
232 false,
233 vec![span],
234 );
235 }
236
237 let mut cursor = node.walk();
239 for child in node.children(&mut cursor) {
240 walk_tree_for_graph(child, content, ast_graph, helper, protocol_map, guard)?;
241 }
242
243 guard.exit();
244 Ok(())
245}
246
247fn build_call_edge_with_helper(
249 ast_graph: &ASTGraph,
250 call_node: Node<'_>,
251 content: &[u8],
252 helper: &mut GraphBuildHelper,
253) -> GraphResult<
254 Option<(
255 sqry_core::graph::unified::NodeId,
256 sqry_core::graph::unified::NodeId,
257 usize,
258 Span,
259 )>,
260> {
261 let module_context;
263 let call_context = if let Some(ctx) = ast_graph.get_callable_context(call_node.id()) {
264 ctx
265 } else {
266 module_context = CallContext {
268 qualified_name: "<module>".to_string(),
269 span: (0, content.len()),
270 is_private: false,
271 };
272 &module_context
273 };
274
275 let Some(target_node) = call_node.child_by_field_name("target") else {
277 return Ok(None);
278 };
279
280 let (callee_text, _is_erlang_ffi) = extract_call_info(&target_node, content)?;
282
283 if callee_text.is_empty() {
284 return Ok(None);
285 }
286
287 let caller_fn_id = helper.add_function(&call_context.qualified_name, None, false, false);
289 let target_fn_id = helper.add_function(&callee_text, None, false, false);
290
291 let call_span = span_from_node(call_node);
292 let argument_count = count_arguments(call_node);
293
294 Ok(Some((
295 caller_fn_id,
296 target_fn_id,
297 argument_count,
298 call_span,
299 )))
300}
301
302#[allow(dead_code)] fn extract_module_name(tree: &Tree, content: &[u8]) -> Option<String> {
309 let query = tree_sitter::Query::new(
310 &tree_sitter_elixir_sqry::language(),
311 r#"(call
312 target: (identifier) @def
313 (arguments
314 (alias) @module_name)
315 (#eq? @def "defmodule")
316 )"#,
317 )
318 .ok()?;
319
320 let mut cursor = tree_sitter::QueryCursor::new();
321 let root = tree.root_node();
322 let mut matches = cursor.matches(&query, root, content);
323
324 if let Some(m) = matches.next() {
325 for capture in m.captures {
326 if query.capture_names()[capture.index as usize] == "module_name" {
327 return capture.node.utf8_text(content).ok().map(String::from);
328 }
329 }
330 }
331
332 None
333}
334
335fn is_function_definition(call_node: &Node, content: &[u8]) -> bool {
337 if let Some(target) = call_node.child_by_field_name("target")
338 && let Ok(target_text) = target.utf8_text(content)
339 {
340 return matches!(target_text, "def" | "defp" | "defmacro" | "defmacrop");
341 }
342 false
343}
344
345fn is_import_statement(call_node: &Node, content: &[u8]) -> bool {
347 if let Some(target) = call_node.child_by_field_name("target")
348 && let Ok(target_text) = target.utf8_text(content)
349 {
350 return matches!(target_text, "import" | "alias" | "use" | "require");
351 }
352 false
353}
354
355fn is_protocol_definition(call_node: &Node, content: &[u8]) -> bool {
357 if let Some(target) = call_node.child_by_field_name("target")
358 && let Ok(target_text) = target.utf8_text(content)
359 {
360 return target_text == "defprotocol";
361 }
362 false
363}
364
365fn is_protocol_implementation(call_node: &Node, content: &[u8]) -> bool {
367 if let Some(target) = call_node.child_by_field_name("target")
368 && let Ok(target_text) = target.utf8_text(content)
369 {
370 return target_text == "defimpl";
371 }
372 false
373}
374
375fn is_spec_annotation(node: &Node, content: &[u8]) -> bool {
377 if node.kind() != "unary_operator" {
378 return false;
379 }
380
381 if let Some(call_node) = node.named_child(0)
383 && call_node.kind() == "call"
384 && let Some(target) = call_node.named_child(0)
385 && let Ok(target_text) = target.utf8_text(content)
386 {
387 return target_text == "spec" || target_text == "type";
388 }
389 false
390}
391
392#[allow(clippy::unnecessary_wraps)]
394fn process_spec_typeof_edges(
395 spec_node: Node,
396 content: &[u8],
397 helper: &mut GraphBuildHelper,
398) -> GraphResult<()> {
399 if let Some(call_node) = spec_node.named_child(0)
403 && call_node.kind() == "call"
404 && let Some(args_node) = call_node.named_child(1)
405 && args_node.kind() == "arguments"
406 && let Some(binary_op) = args_node.named_child(0)
407 && binary_op.kind() == "binary_operator"
408 && let Some(func_call) = binary_op.named_child(0)
409 {
410 let func_name = if let Some(target) = func_call.named_child(0) {
412 target.utf8_text(content).ok().map(String::from)
413 } else {
414 None
415 };
416
417 if let Some(func_name) = func_name {
418 let function_id = helper.add_function(&func_name, None, false, false);
420
421 if let Some(param_args) = func_call.named_child(1)
423 && param_args.kind() == "arguments"
424 {
425 let mut param_index: u16 = 0;
426 let mut cursor = param_args.walk();
427 for param_type_node in param_args.named_children(&mut cursor) {
428 if is_type_node(param_type_node.kind()) {
429 if let Some(type_text) = extract_type_string(param_type_node, content) {
431 let type_id = helper.add_type(&type_text, None);
432 helper.add_typeof_edge_with_context(
433 function_id,
434 type_id,
435 Some(TypeOfContext::Parameter),
436 Some(param_index),
437 None,
438 );
439 }
440
441 let referenced_types =
443 extract_all_type_names_from_elixir_type(param_type_node, content);
444 for ref_type_name in referenced_types {
445 let ref_type_id = helper.add_type(&ref_type_name, None);
446 helper.add_reference_edge(function_id, ref_type_id);
447 }
448
449 param_index += 1;
450 }
451 }
452 }
453
454 if let Some(return_type_node) = binary_op.named_child(1)
456 && is_type_node(return_type_node.kind())
457 {
458 if let Some(type_text) = extract_type_string(return_type_node, content) {
460 let type_id = helper.add_type(&type_text, None);
461 helper.add_typeof_edge_with_context(
462 function_id,
463 type_id,
464 Some(TypeOfContext::Return),
465 Some(0),
466 None,
467 );
468 }
469
470 let referenced_types =
472 extract_all_type_names_from_elixir_type(return_type_node, content);
473 for ref_type_name in referenced_types {
474 let ref_type_id = helper.add_type(&ref_type_name, None);
475 helper.add_reference_edge(function_id, ref_type_id);
476 }
477 }
478 }
479 }
480
481 Ok(())
482}
483
484#[allow(clippy::unnecessary_wraps)]
486fn build_protocol_node(
487 protocol_node: Node,
488 content: &[u8],
489 helper: &mut GraphBuildHelper,
490) -> GraphResult<Option<sqry_core::graph::unified::NodeId>> {
491 let mut cursor = protocol_node.walk();
495 for child in protocol_node.children(&mut cursor) {
496 if child.kind() == "arguments" {
497 let mut args_cursor = child.walk();
499 for arg_child in child.children(&mut args_cursor) {
500 if (arg_child.kind() == "alias" || arg_child.kind() == "identifier")
501 && let Ok(name) = arg_child.utf8_text(content)
502 {
503 let span = span_from_node(protocol_node);
504 let protocol_id = helper.add_interface(name, Some(span));
506 return Ok(Some(protocol_id));
507 }
508 }
509 }
510 }
511 Ok(None)
512}
513
514#[allow(clippy::unnecessary_wraps)]
516fn build_protocol_impl(
517 impl_node: Node,
518 content: &[u8],
519 helper: &mut GraphBuildHelper,
520 protocol_map: &HashMap<String, sqry_core::graph::unified::NodeId>,
521) -> GraphResult<()> {
522 let mut impl_cursor = impl_node.walk();
526 for child in impl_node.children(&mut impl_cursor) {
527 if child.kind() == "arguments" {
528 let mut protocol_name = None;
529 let mut target_type = None;
530
531 let mut cursor = child.walk();
532 let mut found_protocol = false;
533
534 for arg_child in child.children(&mut cursor) {
535 if !found_protocol
537 && (arg_child.kind() == "identifier" || arg_child.kind() == "alias")
538 {
539 if let Ok(name) = arg_child.utf8_text(content) {
540 protocol_name = Some(name.to_string());
541 found_protocol = true;
542 }
543 }
544 else if arg_child.kind() == "keywords" {
546 let mut kw_cursor = arg_child.walk();
548 for kw_child in arg_child.children(&mut kw_cursor) {
549 if kw_child.kind() == "pair" {
550 if let Some(key) = kw_child.child_by_field_name("key")
552 && let Ok(key_text) = key.utf8_text(content)
553 {
554 let key_trimmed = key_text.trim().trim_end_matches(':');
555 if key_trimmed == "for" {
557 if let Some(value) = kw_child.child_by_field_name("value") {
558 if let Ok(type_name) = value.utf8_text(content) {
559 target_type = Some(type_name.to_string());
560 }
561 } else {
562 let mut pair_cursor = kw_child.walk();
564 for pair_child in kw_child.children(&mut pair_cursor) {
565 if (pair_child.kind() == "alias"
566 || pair_child.kind() == "identifier")
567 && let Ok(type_name) = pair_child.utf8_text(content)
568 && type_name != "for:"
569 && type_name != "for"
570 {
571 target_type = Some(type_name.to_string());
572 break;
573 }
574 }
575 }
576 }
577 }
578 }
579 }
580 }
581 }
582
583 if let (Some(protocol), Some(target)) = (protocol_name, target_type) {
584 let span = span_from_node(impl_node);
585
586 let impl_name = format!("{protocol}.{target}");
589 let impl_id = helper.add_struct(&impl_name, Some(span));
590
591 if let Some(&protocol_id) = protocol_map.get(&protocol) {
593 helper.add_implements_edge(impl_id, protocol_id);
594 } else {
595 let protocol_id = helper.add_interface(&protocol, None);
597 helper.add_implements_edge(impl_id, protocol_id);
598 }
599 }
600 break;
601 }
602 }
603
604 Ok(())
605}
606
607#[allow(clippy::too_many_lines)] #[allow(clippy::unnecessary_wraps)] fn build_import_edge_with_helper(
611 call_node: Node<'_>,
612 content: &[u8],
613 helper: &mut GraphBuildHelper,
614) -> GraphResult<()> {
615 let Some(target) = call_node.child_by_field_name("target") else {
617 return Ok(());
618 };
619 let import_type = target.utf8_text(content).unwrap_or("");
620
621 let mut cursor = call_node.walk();
624 let args_node = call_node
625 .children(&mut cursor)
626 .find(|c| c.kind() == "arguments");
627
628 let Some(args_node) = args_node else {
629 return Ok(());
630 };
631
632 let mut cursor = args_node.walk();
634 let mut module_name: Option<String> = None;
635 let mut alias_name: Option<String> = None;
636 let mut is_wildcard = matches!(import_type, "import" | "use");
643 let mut has_only_or_except = false;
644
645 for child in args_node.named_children(&mut cursor) {
646 match child.kind() {
647 "alias" => {
648 if module_name.is_none()
650 && let Ok(text) = child.utf8_text(content)
651 {
652 module_name = Some(text.to_string());
653 if import_type == "alias"
656 && alias_name.is_none()
657 && let Some(last_segment) = text.rsplit('.').next()
658 {
659 alias_name = Some(last_segment.to_string());
660 }
661 }
662 }
663 "dot" => {
664 if import_type == "alias" {
667 let mut dot_cursor = child.walk();
669 let mut base_module: Option<String> = None;
670 let mut tuple_elements: Vec<String> = Vec::new();
671
672 for dot_child in child.named_children(&mut dot_cursor) {
673 match dot_child.kind() {
674 "alias" => {
675 if base_module.is_none()
677 && let Ok(text) = dot_child.utf8_text(content)
678 {
679 base_module = Some(text.to_string());
680 }
681 }
682 "tuple" => {
683 let mut tuple_cursor = dot_child.walk();
685 for tuple_elem in dot_child.named_children(&mut tuple_cursor) {
686 if tuple_elem.kind() == "alias"
687 && let Ok(text) = tuple_elem.utf8_text(content)
688 {
689 tuple_elements.push(text.to_string());
690 }
691 }
692 }
693 _ => {}
694 }
695 }
696
697 if !tuple_elements.is_empty() {
699 let span = span_from_node(call_node);
700 let module_id = helper.add_module("<module>", None);
701 let base = base_module.unwrap_or_default();
702
703 for element in tuple_elements {
704 let full_module = if base.is_empty() {
706 element.clone()
707 } else {
708 format!("{base}.{element}")
709 };
710
711 let alias_value = element.clone();
713
714 let import_id = helper.add_import(&full_module, Some(span));
715 helper.add_import_edge_full(
717 module_id,
718 import_id,
719 Some(&alias_value),
720 false,
721 );
722 }
723
724 return Ok(());
726 }
727 }
728 if let Ok(text) = child.utf8_text(content) {
731 module_name = Some(text.to_string());
732 if import_type == "alias"
734 && alias_name.is_none()
735 && let Some(last_segment) = text.rsplit('.').next()
736 {
737 alias_name = Some(last_segment.to_string());
738 }
739 }
740 }
741 "tuple" => {
742 if import_type == "alias" {
745 let mut tuple_cursor = child.walk();
747 let tuple_elements: Vec<String> = child
748 .named_children(&mut tuple_cursor)
749 .filter_map(|elem| {
750 if elem.kind() == "alias" {
751 elem.utf8_text(content).ok().map(String::from)
752 } else {
753 None
754 }
755 })
756 .collect();
757
758 if !tuple_elements.is_empty() {
760 let span = span_from_node(call_node);
761 let module_id = helper.add_module("<module>", None);
762
763 for element in tuple_elements {
764 let import_id = helper.add_import(&element, Some(span));
765 helper.add_import_edge_full(
767 module_id,
768 import_id,
769 Some(&element),
770 false,
771 );
772 }
773
774 return Ok(());
776 }
777 }
778 is_wildcard = true;
781 }
782 "keywords" => {
783 let mut kw_cursor = child.walk();
785 for kw_pair in child.named_children(&mut kw_cursor) {
786 if kw_pair.kind() == "pair" {
787 let mut pair_cursor = kw_pair.walk();
789 let mut key: Option<String> = None;
790 let mut value: Option<String> = None;
791
792 for pair_child in kw_pair.named_children(&mut pair_cursor) {
793 match pair_child.kind() {
794 "keyword" | "atom" => {
795 if key.is_none()
796 && let Ok(text) = pair_child.utf8_text(content)
797 {
798 key = Some(text.trim().trim_end_matches(':').to_string());
801 }
802 }
803 "alias" | "identifier" => {
804 if value.is_none()
805 && let Ok(text) = pair_child.utf8_text(content)
806 {
807 value = Some(text.to_string());
808 }
809 }
810 "list" => {
811 has_only_or_except = true;
813 is_wildcard = false;
814 }
815 _ => {}
816 }
817 }
818
819 if key.as_deref() == Some("as") {
820 alias_name = value;
821 } else if key.as_deref() == Some("only") || key.as_deref() == Some("except")
822 {
823 has_only_or_except = true;
824 is_wildcard = false;
825 }
826 }
827 }
828 }
829 _ => {}
830 }
831 }
832
833 let _ = has_only_or_except; if let Some(imported_module) = module_name {
839 let span = span_from_node(call_node);
840
841 let module_id = helper.add_module("<module>", None);
843
844 let import_name = match import_type {
846 "use" => format!("use:{imported_module}"),
847 "require" => format!("require:{imported_module}"),
848 _ => imported_module.clone(),
849 };
850
851 let import_id = helper.add_import(&import_name, Some(span));
852
853 helper.add_import_edge_full(module_id, import_id, alias_name.as_deref(), is_wildcard);
855 }
856
857 Ok(())
858}
859
860fn count_arguments(call_node: Node<'_>) -> usize {
862 if let Some(args_node) = call_node.child_by_field_name("arguments") {
863 let mut cursor = args_node.walk();
864 let children: Vec<_> = args_node.named_children(&mut cursor).collect();
865
866 let count = children
868 .iter()
869 .filter(|child| {
870 !matches!(child.kind(), "," | "(" | ")" | "[" | "]")
872 })
873 .count();
874
875 tracing::trace!(
876 "count_arguments: call_node.kind={}, args_node.kind={}, children={:?}, count={}",
877 call_node.kind(),
878 args_node.kind(),
879 children
880 .iter()
881 .map(tree_sitter::Node::kind)
882 .collect::<Vec<_>>(),
883 count
884 );
885
886 count
887 } else {
888 let mut cursor = call_node.walk();
891 let children: Vec<_> = call_node
892 .named_children(&mut cursor)
893 .filter(|child| {
894 matches!(child.kind(), "arguments" | "argument_list")
896 })
897 .collect();
898
899 if let Some(arg_list) = children.first() {
900 let mut arg_cursor = arg_list.walk();
901 let args: Vec<_> = arg_list.named_children(&mut arg_cursor).collect();
902 let count = args
903 .iter()
904 .filter(|child| !matches!(child.kind(), "," | "(" | ")" | "[" | "]"))
905 .count();
906
907 tracing::trace!(
908 "count_arguments (fallback): found argument_list, args={:?}, count={}",
909 args.iter().map(tree_sitter::Node::kind).collect::<Vec<_>>(),
910 count
911 );
912
913 count
914 } else {
915 tracing::trace!(
916 "count_arguments: no arguments field or argument_list found for call_node.kind={}",
917 call_node.kind()
918 );
919 0
920 }
921 }
922}
923
924fn extract_call_info(target_node: &Node, content: &[u8]) -> GraphResult<(String, bool)> {
926 match target_node.kind() {
928 "identifier" => {
930 let name = target_node
931 .utf8_text(content)
932 .map_err(|_| GraphBuilderError::ParseError {
933 span: span_from_node(*target_node),
934 reason: "failed to read call identifier".to_string(),
935 })?
936 .to_string();
937 Ok((name, false))
938 }
939
940 "dot" => {
942 if let Some(left) = target_node.child_by_field_name("left") {
943 let left_text = left.utf8_text(content).unwrap_or("");
944
945 let is_erlang_ffi = left_text.starts_with(':');
947
948 let full_name = target_node
950 .utf8_text(content)
951 .map_err(|_| GraphBuilderError::ParseError {
952 span: span_from_node(*target_node),
953 reason: "failed to read module-qualified call".to_string(),
954 })?
955 .to_string();
956
957 Ok((full_name, is_erlang_ffi))
958 } else {
959 Ok((String::new(), false))
960 }
961 }
962
963 _ => {
965 if let Ok(text) = target_node.utf8_text(content) {
967 Ok((text.to_string(), false))
968 } else {
969 Ok((String::new(), false))
970 }
971 }
972 }
973}
974
975fn span_from_node(node: Node<'_>) -> Span {
977 Span::from_bytes(node.start_byte(), node.end_byte())
978}
979
980fn is_erlang_load_nif(node: &Node, content: &[u8]) -> bool {
1015 let Some(target) = node.child_by_field_name("target") else {
1017 return false;
1018 };
1019
1020 if target.kind() != "dot" {
1022 return false;
1023 }
1024
1025 let Some(left) = target.child_by_field_name("left") else {
1027 return false;
1028 };
1029 if left.kind() != "atom" {
1030 return false;
1031 }
1032 let Ok(left_text) = left.utf8_text(content) else {
1033 return false;
1034 };
1035 if left_text != ":erlang" {
1036 return false;
1037 }
1038
1039 let Some(right) = target.child_by_field_name("right") else {
1041 return false;
1042 };
1043 let Ok(right_text) = right.utf8_text(content) else {
1044 return false;
1045 };
1046
1047 right_text == "load_nif"
1048}
1049
1050fn build_nif_ffi_edge(
1068 node: Node,
1069 _content: &[u8],
1070 ast_graph: &ASTGraph,
1071 helper: &mut GraphBuildHelper,
1072) {
1073 use sqry_core::graph::unified::edge::kind::FfiConvention;
1074
1075 let caller_name = if let Some(ctx) = ast_graph.get_callable_context(node.id()) {
1077 ctx.qualified_name.clone()
1078 } else {
1079 "<module>".to_string()
1081 };
1082
1083 let caller_id = helper.add_function(&caller_name, None, false, false);
1085
1086 let ffi_func_name = "ffi::erlang::load_nif";
1088 let span = span_from_node(node);
1089 let ffi_func_id = helper.add_function(ffi_func_name, Some(span), false, false);
1090
1091 helper.add_ffi_edge(caller_id, ffi_func_id, FfiConvention::C);
1093}
1094
1095#[derive(Debug)]
1100struct ASTGraph {
1101 contexts: Vec<CallContext>,
1102 node_to_context: HashMap<usize, usize>,
1103}
1104
1105impl ASTGraph {
1106 fn from_tree(tree: &Tree, content: &[u8], _max_depth: usize) -> Result<Self, String> {
1107 let mut contexts = Vec::new();
1108 let mut node_to_context = HashMap::new();
1109
1110 let query = tree_sitter::Query::new(
1113 &tree_sitter_elixir_sqry::language(),
1114 r#"
1115 (call
1116 target: (identifier) @def_keyword
1117 (arguments
1118 (call
1119 target: (identifier) @function_name
1120 ) @function_call
1121 )
1122 (#match? @def_keyword "^(def[p]?|defmacro[p]?)$")
1123 ) @function_node
1124
1125 (call
1126 target: (identifier) @def_keyword
1127 (arguments
1128 (identifier) @function_name_simple
1129 )
1130 (#match? @def_keyword "^(def[p]?|defmacro[p]?)$")
1131 ) @function_node_simple
1132 "#,
1133 )
1134 .map_err(|e| format!("Failed to create query: {e}"))?;
1135
1136 let mut cursor = tree_sitter::QueryCursor::new();
1137 let root = tree.root_node();
1138 let capture_names = query.capture_names();
1139 let mut matches = cursor.matches(&query, root, content);
1140
1141 while let Some(m) = matches.next() {
1142 let mut def_keyword = None;
1143 let mut function_name = None;
1144 let mut function_node = None;
1145
1146 for capture in m.captures {
1147 let capture_name = capture_names[capture.index as usize];
1148 match capture_name {
1149 "def_keyword" => def_keyword = Some(capture.node),
1150 "function_name" | "function_name_simple" => function_name = Some(capture.node),
1151 "function_node" | "function_node_simple" => function_node = Some(capture.node),
1152 _ => {}
1153 }
1154 }
1155
1156 if let (Some(def_kw), Some(name_node), Some(func_node)) =
1157 (def_keyword, function_name, function_node)
1158 {
1159 let name = name_node
1160 .utf8_text(content)
1161 .map_err(|e| format!("Failed to extract function name: {e}"))?
1162 .to_string();
1163
1164 let def_keyword_text = def_kw.utf8_text(content).unwrap_or("");
1165 let is_private = matches!(def_keyword_text, "defp" | "defmacrop");
1166
1167 let context_idx = contexts.len();
1168 contexts.push(CallContext {
1169 qualified_name: name,
1170 span: (func_node.start_byte(), func_node.end_byte()),
1171 is_private,
1172 });
1173
1174 map_descendants_to_context(&func_node, context_idx, &mut node_to_context);
1176 }
1177 }
1178
1179 Ok(Self {
1180 contexts,
1181 node_to_context,
1182 })
1183 }
1184
1185 #[allow(dead_code)] fn contexts(&self) -> &[CallContext] {
1187 &self.contexts
1188 }
1189
1190 fn get_callable_context(&self, node_id: usize) -> Option<&CallContext> {
1191 self.node_to_context
1192 .get(&node_id)
1193 .and_then(|idx| self.contexts.get(*idx))
1194 }
1195}
1196
1197fn map_descendants_to_context(node: &Node, context_idx: usize, map: &mut HashMap<usize, usize>) {
1199 map.insert(node.id(), context_idx);
1200
1201 let mut cursor = node.walk();
1202 for child in node.children(&mut cursor) {
1203 map_descendants_to_context(&child, context_idx, map);
1204 }
1205}
1206
1207#[derive(Debug, Clone)]
1208struct CallContext {
1209 qualified_name: String,
1210 #[allow(dead_code)] span: (usize, usize),
1212 #[allow(dead_code)] is_private: bool,
1214}
1215
1216impl CallContext {
1217 #[allow(dead_code)] fn qualified_name(&self) -> String {
1219 self.qualified_name.clone()
1220 }
1221}
1222
1223#[cfg(test)]
1224mod tests {
1225 use std::collections::HashMap;
1226
1227 use super::*;
1228 use sqry_core::graph::unified::NodeId;
1229 use sqry_core::graph::unified::StringId;
1230 use sqry_core::graph::unified::build::StagingOp;
1231 use sqry_core::graph::unified::build::test_helpers::*;
1232 use sqry_core::graph::unified::edge::EdgeKind as UnifiedEdgeKind;
1233
1234 fn extract_import_edges(staging: &StagingGraph) -> Vec<&UnifiedEdgeKind> {
1236 staging
1237 .operations()
1238 .iter()
1239 .filter_map(|op| {
1240 if let StagingOp::AddEdge { kind, .. } = op
1241 && matches!(kind, UnifiedEdgeKind::Imports { .. })
1242 {
1243 return Some(kind);
1244 }
1245 None
1246 })
1247 .collect()
1248 }
1249
1250 fn build_string_map(staging: &StagingGraph) -> HashMap<StringId, String> {
1253 staging
1254 .operations()
1255 .iter()
1256 .filter_map(|op| {
1257 if let StagingOp::InternString { local_id, value } = op {
1258 Some((*local_id, value.clone()))
1259 } else {
1260 None
1261 }
1262 })
1263 .collect()
1264 }
1265
1266 fn resolve_alias(
1268 alias: Option<&StringId>,
1269 string_map: &HashMap<StringId, String>,
1270 ) -> Option<String> {
1271 alias.as_ref().and_then(|id| string_map.get(id).cloned())
1272 }
1273
1274 fn parse_elixir(source: &str) -> (Tree, Vec<u8>) {
1275 let mut parser = tree_sitter::Parser::new();
1276 parser
1277 .set_language(&tree_sitter_elixir_sqry::language())
1278 .expect("Failed to load Elixir grammar");
1279
1280 let content = source.as_bytes().to_vec();
1281 let tree = parser.parse(&content, None).expect("Failed to parse");
1282 (tree, content)
1283 }
1284
1285 fn print_tree_debug(node: tree_sitter::Node, source: &[u8], depth: usize) {
1286 let indent = " ".repeat(depth);
1287 let text = node.utf8_text(source).unwrap_or("<invalid>");
1288 let text_preview = if text.len() > 30 {
1289 format!("{}...", &text[..30])
1290 } else {
1291 text.to_string()
1292 };
1293 eprintln!("{}{}: {:?}", indent, node.kind(), text_preview);
1294
1295 let mut cursor = node.walk();
1296 for child in node.named_children(&mut cursor) {
1297 print_tree_debug(child, source, depth + 1);
1298 }
1299 }
1300
1301 #[test]
1302 #[ignore = "Debug-only test for AST visualization"]
1303 fn test_debug_ast_elixir() {
1304 let source = r"alias Phoenix.Controller, as: Ctrl";
1305 let (tree, content) = parse_elixir(source);
1306 eprintln!("\n=== AST for 'alias Phoenix.Controller, as: Ctrl' ===");
1307 print_tree_debug(tree.root_node(), &content, 0);
1308
1309 let source2 = r"alias Phoenix.{Socket, Channel}";
1310 let (tree2, content2) = parse_elixir(source2);
1311 eprintln!("\n=== AST for 'alias Phoenix.{{Socket, Channel}}' ===");
1312 print_tree_debug(tree2.root_node(), &content2, 0);
1313 }
1314
1315 #[test]
1316 fn test_extract_public_function() {
1317 let source = r"
1318 def calculate(x, y) do
1319 x + y
1320 end
1321 ";
1322
1323 let (tree, content) = parse_elixir(source);
1324 let mut staging = StagingGraph::new();
1325 let builder = ElixirGraphBuilder::default();
1326
1327 builder
1328 .build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
1329 .unwrap();
1330
1331 assert_has_node(&staging, "calculate");
1332 }
1333
1334 #[test]
1335 fn test_extract_private_function() {
1336 let source = r"
1337 defp internal_helper(data) do
1338 process(data)
1339 end
1340 ";
1341
1342 let (tree, content) = parse_elixir(source);
1343 let mut staging = StagingGraph::new();
1344 let builder = ElixirGraphBuilder::default();
1345
1346 builder
1347 .build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
1348 .unwrap();
1349
1350 assert_has_node(&staging, "internal_helper");
1351 }
1352
1353 #[test]
1354 fn test_extract_simple_call() {
1355 let source = r"
1356 def main(x) do
1357 helper(x)
1358 end
1359
1360 def helper(y) do
1361 y
1362 end
1363 ";
1364
1365 let (tree, content) = parse_elixir(source);
1366 let mut staging = StagingGraph::new();
1367 let builder = ElixirGraphBuilder::default();
1368
1369 builder
1370 .build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
1371 .unwrap();
1372
1373 let calls = collect_call_edges(&staging);
1374 assert!(!calls.is_empty(), "Expected at least one call edge");
1375 }
1376
1377 #[test]
1378 fn test_extract_erlang_ffi_call() {
1379 let source = r"
1380 def hash_password(password) do
1381 :crypto.hash(:sha256, password)
1382 end
1383 ";
1384
1385 let (tree, content) = parse_elixir(source);
1386 let mut staging = StagingGraph::new();
1387 let builder = ElixirGraphBuilder::default();
1388
1389 builder
1390 .build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
1391 .unwrap();
1392
1393 let calls = collect_call_edges(&staging);
1396 assert!(!calls.is_empty(), "Expected call edge for Erlang FFI call");
1397 }
1398
1399 #[test]
1400 fn test_module_qualified_call() {
1401 let source = r#"
1402 def render_page(conn) do
1403 Phoenix.Controller.render(conn, "page.html")
1404 end
1405 "#;
1406
1407 let (tree, content) = parse_elixir(source);
1408 let mut staging = StagingGraph::new();
1409 let builder = ElixirGraphBuilder::default();
1410
1411 builder
1412 .build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
1413 .unwrap();
1414
1415 let calls = collect_call_edges(&staging);
1416 assert!(!calls.is_empty(), "Expected module-qualified call edge");
1417 }
1418
1419 #[test]
1420 fn test_pipe_operator_chain() {
1421 let source = r"
1422 def process_data(data) do
1423 data
1424 |> Enum.map(&transform/1)
1425 |> Enum.filter(&valid?/1)
1426 end
1427 ";
1428
1429 let (tree, content) = parse_elixir(source);
1430 let mut staging = StagingGraph::new();
1431 let builder = ElixirGraphBuilder::default();
1432
1433 builder
1434 .build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
1435 .unwrap();
1436
1437 let calls = collect_call_edges(&staging);
1438 assert!(!calls.is_empty(), "Expected pipe operator call edges");
1439 }
1440
1441 #[test]
1442 fn test_argument_count_two_args() {
1443 let source = r"
1444 def two(a, b) do
1445 helper(a, b)
1446 end
1447
1448 def helper(a, b) do
1449 a + b
1450 end
1451 ";
1452
1453 let (tree, content) = parse_elixir(source);
1454 let mut staging = StagingGraph::new();
1455 let builder = ElixirGraphBuilder::default();
1456
1457 builder
1458 .build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
1459 .unwrap();
1460
1461 let calls = collect_call_edges(&staging);
1462 assert!(!calls.is_empty(), "Expected call edge to helper");
1463 }
1464
1465 #[test]
1470 fn test_import_edge_simple() {
1471 let source = r"
1472 defmodule MyModule do
1473 import Enum
1474 end
1475 ";
1476
1477 let (tree, content) = parse_elixir(source);
1478 let mut staging = StagingGraph::new();
1479 let builder = ElixirGraphBuilder::default();
1480
1481 builder
1482 .build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
1483 .unwrap();
1484
1485 let import_edges = extract_import_edges(&staging);
1486 assert!(
1487 !import_edges.is_empty(),
1488 "Expected at least one import edge"
1489 );
1490
1491 let edge = import_edges[0];
1493 if let UnifiedEdgeKind::Imports { is_wildcard, .. } = edge {
1494 assert!(
1495 *is_wildcard,
1496 "Simple import should be wildcard (imports all)"
1497 );
1498 } else {
1499 panic!("Expected Imports edge kind");
1500 }
1501 }
1502
1503 #[test]
1504 fn test_import_edge_with_only() {
1505 let source = r"
1506 defmodule MyModule do
1507 import List, only: [first: 1, last: 1]
1508 end
1509 ";
1510
1511 let (tree, content) = parse_elixir(source);
1512 let mut staging = StagingGraph::new();
1513 let builder = ElixirGraphBuilder::default();
1514
1515 builder
1516 .build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
1517 .unwrap();
1518
1519 let import_edges = extract_import_edges(&staging);
1520 assert!(
1521 !import_edges.is_empty(),
1522 "Expected import edge with only clause"
1523 );
1524
1525 let edge = import_edges[0];
1527 if let UnifiedEdgeKind::Imports { is_wildcard, .. } = edge {
1528 assert!(
1529 !*is_wildcard,
1530 "Import with only: clause should NOT be wildcard"
1531 );
1532 } else {
1533 panic!("Expected Imports edge kind");
1534 }
1535 }
1536
1537 #[test]
1538 fn test_alias_edge() {
1539 let source = r"
1540 defmodule MyModule do
1541 alias Phoenix.Controller
1542 end
1543 ";
1544
1545 let (tree, content) = parse_elixir(source);
1546 let mut staging = StagingGraph::new();
1547 let builder = ElixirGraphBuilder::default();
1548
1549 builder
1550 .build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
1551 .unwrap();
1552
1553 let import_edges = extract_import_edges(&staging);
1554 assert!(!import_edges.is_empty(), "Expected alias edge");
1555
1556 let string_map = build_string_map(&staging);
1558
1559 let edge = import_edges[0];
1562 if let UnifiedEdgeKind::Imports { alias, is_wildcard } = edge {
1563 assert!(
1564 !*is_wildcard,
1565 "Alias should NOT be wildcard (it's a reference)"
1566 );
1567 let alias_value = resolve_alias(alias.as_ref(), &string_map);
1569 assert_eq!(
1570 alias_value,
1571 Some("Controller".to_string()),
1572 "Default alias should be 'Controller' (last segment)"
1573 );
1574 } else {
1575 panic!("Expected Imports edge kind");
1576 }
1577 }
1578
1579 #[test]
1580 fn test_alias_with_as() {
1581 let source = r"
1582 defmodule MyModule do
1583 alias Phoenix.Controller, as: Ctrl
1584 end
1585 ";
1586
1587 let (tree, content) = parse_elixir(source);
1588 let mut staging = StagingGraph::new();
1589 let builder = ElixirGraphBuilder::default();
1590
1591 builder
1592 .build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
1593 .unwrap();
1594
1595 let import_edges = extract_import_edges(&staging);
1596 assert!(!import_edges.is_empty(), "Expected alias edge with as");
1597
1598 let string_map = build_string_map(&staging);
1600
1601 let edge = import_edges[0];
1604 if let UnifiedEdgeKind::Imports { alias, is_wildcard } = edge {
1605 assert!(
1606 !*is_wildcard,
1607 "Alias should NOT be wildcard (it's a reference)"
1608 );
1609 let alias_value = resolve_alias(alias.as_ref(), &string_map);
1611 assert_eq!(
1612 alias_value,
1613 Some("Ctrl".to_string()),
1614 "Explicit alias should be 'Ctrl'"
1615 );
1616 } else {
1617 panic!("Expected Imports edge kind");
1618 }
1619 }
1620
1621 #[test]
1622 fn test_multi_alias_expansion() {
1623 let source = r"
1624 defmodule MyModule do
1625 alias Phoenix.{Socket, Channel}
1626 end
1627 ";
1628
1629 let (tree, content) = parse_elixir(source);
1630 let mut staging = StagingGraph::new();
1631 let builder = ElixirGraphBuilder::default();
1632
1633 builder
1634 .build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
1635 .unwrap();
1636
1637 let import_edges = extract_import_edges(&staging);
1638
1639 assert_eq!(
1641 import_edges.len(),
1642 2,
1643 "Multi-alias should emit one edge per alias element"
1644 );
1645
1646 let string_map = build_string_map(&staging);
1648
1649 let mut alias_values: Vec<String> = import_edges
1651 .iter()
1652 .filter_map(|edge| {
1653 if let UnifiedEdgeKind::Imports { alias, is_wildcard } = edge {
1654 assert!(!*is_wildcard, "Multi-alias elements should NOT be wildcard");
1656 resolve_alias(alias.as_ref(), &string_map)
1657 } else {
1658 None
1659 }
1660 })
1661 .collect();
1662
1663 alias_values.sort();
1664 assert_eq!(
1665 alias_values,
1666 vec!["Channel".to_string(), "Socket".to_string()],
1667 "Multi-alias should expand to individual aliases"
1668 );
1669 }
1670
1671 #[test]
1672 fn test_use_edge() {
1673 let source = r"
1674 defmodule MyModule do
1675 use GenServer
1676 end
1677 ";
1678
1679 let (tree, content) = parse_elixir(source);
1680 let mut staging = StagingGraph::new();
1681 let builder = ElixirGraphBuilder::default();
1682
1683 builder
1684 .build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
1685 .unwrap();
1686
1687 let import_edges = extract_import_edges(&staging);
1688 assert!(!import_edges.is_empty(), "Expected use edge");
1689
1690 let edge = import_edges[0];
1692 if let UnifiedEdgeKind::Imports { is_wildcard, .. } = edge {
1693 assert!(*is_wildcard, "use statement should be wildcard");
1694 } else {
1695 panic!("Expected Imports edge kind");
1696 }
1697 }
1698
1699 #[test]
1700 fn test_require_edge() {
1701 let source = r"
1702 defmodule MyModule do
1703 require Logger
1704 end
1705 ";
1706
1707 let (tree, content) = parse_elixir(source);
1708 let mut staging = StagingGraph::new();
1709 let builder = ElixirGraphBuilder::default();
1710
1711 builder
1712 .build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
1713 .unwrap();
1714
1715 let import_edges = extract_import_edges(&staging);
1716 assert!(!import_edges.is_empty(), "Expected require edge");
1717
1718 let edge = import_edges[0];
1721 if let UnifiedEdgeKind::Imports { is_wildcard, .. } = edge {
1722 assert!(
1723 !*is_wildcard,
1724 "require statement should NOT be wildcard (only makes macros available)"
1725 );
1726 } else {
1727 panic!("Expected Imports edge kind");
1728 }
1729 }
1730
1731 #[test]
1732 fn test_multiple_imports() {
1733 let source = r"
1734 defmodule MyModule do
1735 import Enum
1736 import List
1737 alias Phoenix.Controller
1738 use GenServer
1739 require Logger
1740 end
1741 ";
1742
1743 let (tree, content) = parse_elixir(source);
1744 let mut staging = StagingGraph::new();
1745 let builder = ElixirGraphBuilder::default();
1746
1747 builder
1748 .build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
1749 .unwrap();
1750
1751 let import_edges = extract_import_edges(&staging);
1753 assert_eq!(
1754 import_edges.len(),
1755 5,
1756 "Expected 5 import edges (import Enum, import List, alias, use, require)"
1757 );
1758
1759 for edge in &import_edges {
1761 assert!(
1762 matches!(edge, UnifiedEdgeKind::Imports { .. }),
1763 "All edges should be Imports"
1764 );
1765 }
1766 }
1767
1768 fn extract_export_edges(staging: &StagingGraph) -> Vec<&UnifiedEdgeKind> {
1774 staging
1775 .operations()
1776 .iter()
1777 .filter_map(|op| {
1778 if let StagingOp::AddEdge { kind, .. } = op
1779 && matches!(kind, UnifiedEdgeKind::Exports { .. })
1780 {
1781 return Some(kind);
1782 }
1783 None
1784 })
1785 .collect()
1786 }
1787
1788 #[test]
1789 fn test_export_public_function() {
1790 let source = r"
1791 defmodule Visibility do
1792 def public_fun do
1793 :ok
1794 end
1795 end
1796 ";
1797
1798 let (tree, content) = parse_elixir(source);
1799 let mut staging = StagingGraph::new();
1800 let builder = ElixirGraphBuilder::default();
1801
1802 builder
1803 .build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
1804 .unwrap();
1805
1806 let export_edges = extract_export_edges(&staging);
1807 assert_eq!(
1808 export_edges.len(),
1809 1,
1810 "Expected one export edge for public function"
1811 );
1812
1813 let edge = export_edges[0];
1815 if let UnifiedEdgeKind::Exports { kind, alias } = edge {
1816 assert_eq!(
1817 *kind,
1818 ExportKind::Direct,
1819 "Public function export should be ExportKind::Direct"
1820 );
1821 assert!(
1822 alias.is_none(),
1823 "Public function export should not have alias"
1824 );
1825 } else {
1826 panic!("Expected Exports edge kind");
1827 }
1828 }
1829
1830 #[test]
1831 fn test_export_multiple_public_functions() {
1832 let source = r"
1833 defmodule MyModule do
1834 def function_one do
1835 :ok
1836 end
1837
1838 def function_two do
1839 :ok
1840 end
1841
1842 def function_three(x) do
1843 x * 2
1844 end
1845 end
1846 ";
1847
1848 let (tree, content) = parse_elixir(source);
1849 let mut staging = StagingGraph::new();
1850 let builder = ElixirGraphBuilder::default();
1851
1852 builder
1853 .build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
1854 .unwrap();
1855
1856 let export_edges = extract_export_edges(&staging);
1857 assert_eq!(
1858 export_edges.len(),
1859 3,
1860 "Expected three export edges for three public functions"
1861 );
1862
1863 for edge in export_edges {
1865 if let UnifiedEdgeKind::Exports { kind, alias } = edge {
1866 assert_eq!(*kind, ExportKind::Direct);
1867 assert!(alias.is_none());
1868 } else {
1869 panic!("Expected Exports edge kind");
1870 }
1871 }
1872 }
1873
1874 #[test]
1875 fn test_no_export_for_private_function() {
1876 let source = r"
1877 defmodule Secret do
1878 defp private_fun do
1879 :secret
1880 end
1881 end
1882 ";
1883
1884 let (tree, content) = parse_elixir(source);
1885 let mut staging = StagingGraph::new();
1886 let builder = ElixirGraphBuilder::default();
1887
1888 builder
1889 .build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
1890 .unwrap();
1891
1892 let export_edges = extract_export_edges(&staging);
1893 assert_eq!(
1894 export_edges.len(),
1895 0,
1896 "Expected no export edges for private function"
1897 );
1898 }
1899
1900 #[test]
1901 fn test_export_mixed_public_private() {
1902 let source = r"
1903 defmodule Mixed do
1904 def public_one, do: :ok
1905
1906 defp private_one, do: :secret
1907
1908 def public_two, do: :ok
1909
1910 defp private_two, do: :secret
1911 end
1912 ";
1913
1914 let (tree, content) = parse_elixir(source);
1915 let mut staging = StagingGraph::new();
1916 let builder = ElixirGraphBuilder::default();
1917
1918 builder
1919 .build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
1920 .unwrap();
1921
1922 let export_edges = extract_export_edges(&staging);
1923 assert_eq!(
1924 export_edges.len(),
1925 2,
1926 "Expected two export edges for two public functions (defp should not be exported)"
1927 );
1928
1929 for edge in export_edges {
1931 if let UnifiedEdgeKind::Exports { kind, alias } = edge {
1932 assert_eq!(*kind, ExportKind::Direct);
1933 assert!(alias.is_none());
1934 } else {
1935 panic!("Expected Exports edge kind");
1936 }
1937 }
1938 }
1939
1940 #[test]
1941 fn test_export_public_macro() {
1942 let source = r"
1943 defmodule Macros do
1944 defmacro public_macro do
1945 quote do: :ok
1946 end
1947 end
1948 ";
1949
1950 let (tree, content) = parse_elixir(source);
1951 let mut staging = StagingGraph::new();
1952 let builder = ElixirGraphBuilder::default();
1953
1954 builder
1955 .build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
1956 .unwrap();
1957
1958 let export_edges = extract_export_edges(&staging);
1959 assert_eq!(
1960 export_edges.len(),
1961 1,
1962 "Expected one export edge for public macro"
1963 );
1964
1965 let edge = export_edges[0];
1967 if let UnifiedEdgeKind::Exports { kind, alias } = edge {
1968 assert_eq!(
1969 *kind,
1970 ExportKind::Direct,
1971 "Public macro export should be ExportKind::Direct"
1972 );
1973 assert!(alias.is_none(), "Public macro export should not have alias");
1974 } else {
1975 panic!("Expected Exports edge kind");
1976 }
1977 }
1978
1979 #[test]
1980 fn test_no_export_for_private_macro() {
1981 let source = r"
1982 defmodule SecretMacros do
1983 defmacrop private_macro do
1984 quote do: :secret
1985 end
1986 end
1987 ";
1988
1989 let (tree, content) = parse_elixir(source);
1990 let mut staging = StagingGraph::new();
1991 let builder = ElixirGraphBuilder::default();
1992
1993 builder
1994 .build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
1995 .unwrap();
1996
1997 let export_edges = extract_export_edges(&staging);
1998 assert_eq!(
1999 export_edges.len(),
2000 0,
2001 "Expected no export edges for private macro"
2002 );
2003 }
2004
2005 #[test]
2006 fn test_export_mixed_functions_and_macros() {
2007 let source = r"
2008 defmodule MixedTypes do
2009 def public_fun, do: :ok
2010 defp private_fun, do: :secret
2011 defmacro public_macro, do: quote(do: :ok)
2012 defmacrop private_macro, do: quote(do: :secret)
2013 end
2014 ";
2015
2016 let (tree, content) = parse_elixir(source);
2017 let mut staging = StagingGraph::new();
2018 let builder = ElixirGraphBuilder::default();
2019
2020 builder
2021 .build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
2022 .unwrap();
2023
2024 let export_edges = extract_export_edges(&staging);
2025 assert_eq!(
2026 export_edges.len(),
2027 2,
2028 "Expected two export edges (one public function, one public macro)"
2029 );
2030
2031 for edge in export_edges {
2033 if let UnifiedEdgeKind::Exports { kind, alias } = edge {
2034 assert_eq!(*kind, ExportKind::Direct);
2035 assert!(alias.is_none());
2036 } else {
2037 panic!("Expected Exports edge kind");
2038 }
2039 }
2040 }
2041
2042 fn extract_ffi_edges(staging: &StagingGraph) -> Vec<&UnifiedEdgeKind> {
2048 staging
2049 .operations()
2050 .iter()
2051 .filter_map(|op| {
2052 if let StagingOp::AddEdge { kind, .. } = op
2053 && matches!(kind, UnifiedEdgeKind::FfiCall { .. })
2054 {
2055 return Some(kind);
2056 }
2057 None
2058 })
2059 .collect()
2060 }
2061
2062 #[test]
2063 fn test_nif_basic_loading() {
2064 let source = r"
2065 defmodule MyNif do
2066 @on_load :load_nifs
2067
2068 def load_nifs do
2069 :erlang.load_nif('./priv/my_nif', 0)
2070 end
2071
2072 def native_function(_arg), do: :erlang.nif_error(:not_loaded)
2073 end
2074 ";
2075
2076 let (tree, content) = parse_elixir(source);
2077 let mut staging = StagingGraph::new();
2078 let builder = ElixirGraphBuilder::default();
2079
2080 builder
2081 .build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
2082 .unwrap();
2083
2084 let ffi_edges = extract_ffi_edges(&staging);
2085 assert_eq!(ffi_edges.len(), 1, "Expected one FFI edge");
2086
2087 if let UnifiedEdgeKind::FfiCall { convention } = ffi_edges[0] {
2089 assert_eq!(
2090 *convention,
2091 sqry_core::graph::unified::edge::kind::FfiConvention::C,
2092 "NIF calls should use C convention"
2093 );
2094 } else {
2095 panic!("Expected FfiCall edge");
2096 }
2097 }
2098
2099 #[test]
2100 fn test_nif_inline_call() {
2101 let source = r"
2102 defmodule SimpleNif do
2103 def init do
2104 :erlang.load_nif('./lib', 0)
2105 end
2106 end
2107 ";
2108
2109 let (tree, content) = parse_elixir(source);
2110 let mut staging = StagingGraph::new();
2111 let builder = ElixirGraphBuilder::default();
2112
2113 builder
2114 .build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
2115 .unwrap();
2116
2117 let ffi_edges = extract_ffi_edges(&staging);
2118 assert_eq!(ffi_edges.len(), 1, "Expected one FFI edge for inline call");
2119 }
2120
2121 #[test]
2122 fn test_nif_without_on_load() {
2123 let source = r"
2124 defmodule NoOnLoad do
2125 def init do
2126 :erlang.load_nif('./nif_lib', 0)
2127 end
2128
2129 def compute(_x), do: :erlang.nif_error(:not_loaded)
2130 end
2131 ";
2132
2133 let (tree, content) = parse_elixir(source);
2134 let mut staging = StagingGraph::new();
2135 let builder = ElixirGraphBuilder::default();
2136
2137 builder
2138 .build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
2139 .unwrap();
2140
2141 let ffi_edges = extract_ffi_edges(&staging);
2142 assert_eq!(
2143 ffi_edges.len(),
2144 1,
2145 "Should detect NIF loading without @on_load"
2146 );
2147 }
2148
2149 #[test]
2150 fn test_nif_without_stubs() {
2151 let source = r"
2152 defmodule NoStubs do
2153 @on_load :init
2154
2155 def init do
2156 :erlang.load_nif('./minimal', 0)
2157 end
2158 end
2159 ";
2160
2161 let (tree, content) = parse_elixir(source);
2162 let mut staging = StagingGraph::new();
2163 let builder = ElixirGraphBuilder::default();
2164
2165 builder
2166 .build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
2167 .unwrap();
2168
2169 let ffi_edges = extract_ffi_edges(&staging);
2170 assert_eq!(
2171 ffi_edges.len(),
2172 1,
2173 "Should detect NIF loading without stub functions"
2174 );
2175 }
2176
2177 #[test]
2178 fn test_nif_minimal() {
2179 let source = r"
2180 defmodule Minimal do
2181 def go do
2182 :erlang.load_nif('./x', 0)
2183 end
2184 end
2185 ";
2186
2187 let (tree, content) = parse_elixir(source);
2188 let mut staging = StagingGraph::new();
2189 let builder = ElixirGraphBuilder::default();
2190
2191 builder
2192 .build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
2193 .unwrap();
2194
2195 let ffi_edges = extract_ffi_edges(&staging);
2196 assert_eq!(ffi_edges.len(), 1, "Minimal NIF loading should be detected");
2197 }
2198
2199 #[test]
2200 fn test_nif_multiple_calls() {
2201 let source = r"
2202 defmodule MultiNif do
2203 def load_crypto do
2204 :erlang.load_nif('./crypto_nif', 0)
2205 end
2206
2207 def load_math do
2208 :erlang.load_nif('./math_nif', 0)
2209 end
2210 end
2211 ";
2212
2213 let (tree, content) = parse_elixir(source);
2214 let mut staging = StagingGraph::new();
2215 let builder = ElixirGraphBuilder::default();
2216
2217 builder
2218 .build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
2219 .unwrap();
2220
2221 let ffi_edges = extract_ffi_edges(&staging);
2222 assert_eq!(
2223 ffi_edges.len(),
2224 2,
2225 "Should detect multiple NIF loading calls"
2226 );
2227 }
2228
2229 #[test]
2230 fn test_nif_string_path() {
2231 let source = r#"
2232 defmodule StringPath do
2233 def init do
2234 :erlang.load_nif("./my_lib", 0)
2235 end
2236 end
2237 "#;
2238
2239 let (tree, content) = parse_elixir(source);
2240 let mut staging = StagingGraph::new();
2241 let builder = ElixirGraphBuilder::default();
2242
2243 builder
2244 .build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
2245 .unwrap();
2246
2247 let ffi_edges = extract_ffi_edges(&staging);
2248 assert_eq!(
2249 ffi_edges.len(),
2250 1,
2251 "Should detect NIF with string path (double quotes)"
2252 );
2253 }
2254
2255 #[test]
2256 fn test_nif_charlist_path() {
2257 let source = r"
2258 defmodule CharlistPath do
2259 def init do
2260 :erlang.load_nif('./path', [])
2261 end
2262 end
2263 ";
2264
2265 let (tree, content) = parse_elixir(source);
2266 let mut staging = StagingGraph::new();
2267 let builder = ElixirGraphBuilder::default();
2268
2269 builder
2270 .build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
2271 .unwrap();
2272
2273 let ffi_edges = extract_ffi_edges(&staging);
2274 assert_eq!(
2275 ffi_edges.len(),
2276 1,
2277 "Should detect NIF with charlist path (single quotes)"
2278 );
2279 }
2280
2281 #[test]
2282 fn test_nif_variable_init_args() {
2283 let source = r"
2284 defmodule VariableArgs do
2285 def init(args) do
2286 :erlang.load_nif('./lib', args)
2287 end
2288 end
2289 ";
2290
2291 let (tree, content) = parse_elixir(source);
2292 let mut staging = StagingGraph::new();
2293 let builder = ElixirGraphBuilder::default();
2294
2295 builder
2296 .build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
2297 .unwrap();
2298
2299 let ffi_edges = extract_ffi_edges(&staging);
2300 assert_eq!(
2301 ffi_edges.len(),
2302 1,
2303 "Should detect NIF with variable init args"
2304 );
2305 }
2306
2307 #[test]
2308 fn test_nif_private_function() {
2309 let source = r"
2310 defmodule PrivateLoader do
2311 defp load_nif do
2312 :erlang.load_nif('./private', 0)
2313 end
2314 end
2315 ";
2316
2317 let (tree, content) = parse_elixir(source);
2318 let mut staging = StagingGraph::new();
2319 let builder = ElixirGraphBuilder::default();
2320
2321 builder
2322 .build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
2323 .unwrap();
2324
2325 let ffi_edges = extract_ffi_edges(&staging);
2326 assert_eq!(
2327 ffi_edges.len(),
2328 1,
2329 "Should detect NIF in private function (defp)"
2330 );
2331 }
2332
2333 #[test]
2334 fn test_nif_public_function() {
2335 let source = r"
2336 defmodule PublicLoader do
2337 def load_nif do
2338 :erlang.load_nif('./public', 0)
2339 end
2340 end
2341 ";
2342
2343 let (tree, content) = parse_elixir(source);
2344 let mut staging = StagingGraph::new();
2345 let builder = ElixirGraphBuilder::default();
2346
2347 builder
2348 .build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
2349 .unwrap();
2350
2351 let ffi_edges = extract_ffi_edges(&staging);
2352 assert_eq!(
2353 ffi_edges.len(),
2354 1,
2355 "Should detect NIF in public function (def)"
2356 );
2357 }
2358
2359 #[test]
2360 fn test_nif_nested_module() {
2361 let source = r"
2362 defmodule Outer do
2363 defmodule Inner do
2364 def init do
2365 :erlang.load_nif('./inner_nif', 0)
2366 end
2367 end
2368 end
2369 ";
2370
2371 let (tree, content) = parse_elixir(source);
2372 let mut staging = StagingGraph::new();
2373 let builder = ElixirGraphBuilder::default();
2374
2375 builder
2376 .build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
2377 .unwrap();
2378
2379 let ffi_edges = extract_ffi_edges(&staging);
2380 assert_eq!(ffi_edges.len(), 1, "Should detect NIF in nested module");
2381 }
2382
2383 #[test]
2384 fn test_nif_convention_is_c() {
2385 let source = r"
2386 defmodule ConventionTest do
2387 def init do
2388 :erlang.load_nif('./lib', 0)
2389 end
2390 end
2391 ";
2392
2393 let (tree, content) = parse_elixir(source);
2394 let mut staging = StagingGraph::new();
2395 let builder = ElixirGraphBuilder::default();
2396
2397 builder
2398 .build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
2399 .unwrap();
2400
2401 let ffi_edges = extract_ffi_edges(&staging);
2402 assert!(!ffi_edges.is_empty(), "Expected at least one FFI edge");
2403
2404 for edge in ffi_edges {
2405 if let UnifiedEdgeKind::FfiCall { convention } = edge {
2406 assert_eq!(
2407 *convention,
2408 sqry_core::graph::unified::edge::kind::FfiConvention::C,
2409 "All NIF edges should use C convention"
2410 );
2411 }
2412 }
2413 }
2414
2415 #[test]
2416 fn test_nif_edge_count() {
2417 let source = r"
2418 defmodule EdgeCount do
2419 def one do
2420 :erlang.load_nif('./one', 0)
2421 end
2422
2423 def two do
2424 :erlang.load_nif('./two', 0)
2425 end
2426
2427 def three do
2428 :erlang.load_nif('./three', 0)
2429 end
2430 end
2431 ";
2432
2433 let (tree, content) = parse_elixir(source);
2434 let mut staging = StagingGraph::new();
2435 let builder = ElixirGraphBuilder::default();
2436
2437 builder
2438 .build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
2439 .unwrap();
2440
2441 let ffi_edges = extract_ffi_edges(&staging);
2442 assert_eq!(
2443 ffi_edges.len(),
2444 3,
2445 "Should create exactly one edge per load_nif call"
2446 );
2447 }
2448
2449 #[test]
2450 #[allow(clippy::similar_names)] fn test_nif_edge_endpoints() {
2452 let source = r"
2453 defmodule NifModule do
2454 def load_nif do
2455 :erlang.load_nif('./mylib', 0)
2456 end
2457 end
2458 ";
2459
2460 let (tree, content) = parse_elixir(source);
2461 let mut staging = StagingGraph::new();
2462 let builder = ElixirGraphBuilder::default();
2463
2464 builder
2465 .build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
2466 .unwrap();
2467
2468 let ffi_edges = extract_ffi_edges(&staging);
2470 assert_eq!(ffi_edges.len(), 1, "Expected exactly one FfiCall edge");
2471
2472 if let UnifiedEdgeKind::FfiCall { convention } = ffi_edges[0] {
2474 assert_eq!(
2475 *convention,
2476 sqry_core::graph::unified::edge::kind::FfiConvention::C,
2477 "NIF calls should use C convention"
2478 );
2479 } else {
2480 panic!("Expected FfiCall edge");
2481 }
2482
2483 let mut caller_node_id: Option<NodeId> = None;
2485 #[allow(clippy::similar_names)] let mut callee_node_id: Option<NodeId> = None;
2487
2488 for op in staging.operations() {
2489 if let StagingOp::AddNode { entry, expected_id } = op {
2490 let canonical_name = staging
2491 .resolve_node_canonical_name(entry)
2492 .expect("Node name should resolve");
2493
2494 if canonical_name == "load_nif"
2496 && matches!(entry.kind, sqry_core::graph::unified::NodeKind::Function)
2497 {
2498 caller_node_id = *expected_id;
2499 }
2500
2501 if canonical_name == "ffi::erlang::load_nif" {
2503 callee_node_id = *expected_id;
2504 }
2505 }
2506 }
2507
2508 assert!(
2510 caller_node_id.is_some(),
2511 "Expected to find caller node named 'load_nif'"
2512 );
2513 assert!(
2514 callee_node_id.is_some(),
2515 "Expected to find callee node named 'ffi::erlang::load_nif'"
2516 );
2517
2518 let caller_id = caller_node_id.unwrap();
2519 let callee_id = callee_node_id.unwrap();
2520
2521 let has_correct_edge = staging.operations().iter().any(|op| {
2523 if let StagingOp::AddEdge {
2524 source,
2525 target,
2526 kind,
2527 ..
2528 } = op
2529 {
2530 matches!(kind, UnifiedEdgeKind::FfiCall { .. })
2531 && *source == caller_id
2532 && *target == callee_id
2533 } else {
2534 false
2535 }
2536 });
2537
2538 assert!(
2539 has_correct_edge,
2540 "Expected FfiCall edge connecting NifModule::load_nif to ffi::erlang::load_nif"
2541 );
2542 }
2543
2544 #[test]
2547 fn test_no_ffi_regular_erlang_call() {
2548 let source = r"
2549 defmodule MyModule do
2550 def process(list) do
2551 :lists.map(fn x -> x * 2 end, list)
2552 end
2553 end
2554 ";
2555
2556 let (tree, content) = parse_elixir(source);
2557 let mut staging = StagingGraph::new();
2558 let builder = ElixirGraphBuilder::default();
2559
2560 builder
2561 .build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
2562 .unwrap();
2563
2564 let ffi_edges = extract_ffi_edges(&staging);
2565 assert_eq!(
2566 ffi_edges.len(),
2567 0,
2568 "Should not detect regular Erlang calls as FFI"
2569 );
2570 }
2571
2572 #[test]
2573 fn test_no_ffi_comment() {
2574 let source = r"
2575 defmodule CommentTest do
2576 # :erlang.load_nif('./commented', 0)
2577 def init do
2578 :ok
2579 end
2580 end
2581 ";
2582
2583 let (tree, content) = parse_elixir(source);
2584 let mut staging = StagingGraph::new();
2585 let builder = ElixirGraphBuilder::default();
2586
2587 builder
2588 .build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
2589 .unwrap();
2590
2591 let ffi_edges = extract_ffi_edges(&staging);
2592 assert_eq!(ffi_edges.len(), 0, "Should not detect load_nif in comments");
2593 }
2594
2595 #[test]
2596 fn test_no_ffi_string_literal() {
2597 let source = r#"
2598 defmodule StringTest do
2599 def message do
2600 "Call :erlang.load_nif to load"
2601 end
2602 end
2603 "#;
2604
2605 let (tree, content) = parse_elixir(source);
2606 let mut staging = StagingGraph::new();
2607 let builder = ElixirGraphBuilder::default();
2608
2609 builder
2610 .build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
2611 .unwrap();
2612
2613 let ffi_edges = extract_ffi_edges(&staging);
2614 assert_eq!(
2615 ffi_edges.len(),
2616 0,
2617 "Should not detect load_nif in string literals"
2618 );
2619 }
2620
2621 #[test]
2622 fn test_no_ffi_similar_name() {
2623 let source = r"
2624 defmodule SimilarName do
2625 def init do
2626 :erlang.load_nif_module('./lib', 0)
2627 end
2628 end
2629 ";
2630
2631 let (tree, content) = parse_elixir(source);
2632 let mut staging = StagingGraph::new();
2633 let builder = ElixirGraphBuilder::default();
2634
2635 builder
2636 .build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
2637 .unwrap();
2638
2639 let ffi_edges = extract_ffi_edges(&staging);
2640 assert_eq!(
2641 ffi_edges.len(),
2642 0,
2643 "Should not detect similar function names (load_nif_module)"
2644 );
2645 }
2646
2647 #[test]
2648 fn test_no_ffi_wrong_module() {
2649 let source = r"
2650 defmodule WrongModule do
2651 def init do
2652 :other.load_nif('./lib', 0)
2653 end
2654 end
2655 ";
2656
2657 let (tree, content) = parse_elixir(source);
2658 let mut staging = StagingGraph::new();
2659 let builder = ElixirGraphBuilder::default();
2660
2661 builder
2662 .build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
2663 .unwrap();
2664
2665 let ffi_edges = extract_ffi_edges(&staging);
2666 assert_eq!(
2667 ffi_edges.len(),
2668 0,
2669 "Should not detect load_nif from modules other than :erlang"
2670 );
2671 }
2672
2673 #[test]
2676 fn test_nif_malformed_incomplete_args() {
2677 let source = r"
2678 defmodule Malformed do
2679 def init do
2680 :erlang.load_nif()
2681 end
2682 end
2683 ";
2684
2685 let (tree, content) = parse_elixir(source);
2686 let mut staging = StagingGraph::new();
2687 let builder = ElixirGraphBuilder::default();
2688
2689 builder
2691 .build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
2692 .unwrap();
2693
2694 let _ffi_edges = extract_ffi_edges(&staging);
2697 }
2698
2699 #[test]
2700 fn test_nif_empty_arguments() {
2701 let source = r"
2702 defmodule EmptyArgs do
2703 def init do
2704 :erlang.load_nif('./lib')
2705 end
2706 end
2707 ";
2708
2709 let (tree, content) = parse_elixir(source);
2710 let mut staging = StagingGraph::new();
2711 let builder = ElixirGraphBuilder::default();
2712
2713 builder
2715 .build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
2716 .unwrap();
2717
2718 let ffi_edges = extract_ffi_edges(&staging);
2719 assert!(
2721 ffi_edges.len() <= 1,
2722 "Should handle NIF calls with non-standard arity gracefully"
2723 );
2724 }
2725
2726 #[test]
2727 fn test_nif_complex_path() {
2728 let source = r#"
2729 defmodule ComplexPath do
2730 def init(base_path) do
2731 :erlang.load_nif(base_path <> "/nif", 0)
2732 end
2733 end
2734 "#;
2735
2736 let (tree, content) = parse_elixir(source);
2737 let mut staging = StagingGraph::new();
2738 let builder = ElixirGraphBuilder::default();
2739
2740 builder
2742 .build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
2743 .unwrap();
2744
2745 let ffi_edges = extract_ffi_edges(&staging);
2746 assert_eq!(
2747 ffi_edges.len(),
2748 1,
2749 "Should detect NIF with complex/interpolated paths"
2750 );
2751 }
2752}