Skip to main content

sqry_lang_elixir/relations/
graph_builder.rs

1//! `GraphBuilder` implementation for Elixir
2//!
3//! Builds the unified `CodeGraph` for Elixir files by:
4//! 1. Extracting function and macro definitions (def, defp, defmacro, defmacrop, `GenServer` callbacks)
5//! 2. Detecting function call expressions (local, remote, Erlang FFI)
6//! 3. Creating call edges between caller and callee
7//! 4. Creating export edges for public functions and macros (def, defmacro)
8//! 5. Tracking protocol definitions (defprotocol) as Interface nodes
9//! 6. Tracking protocol implementations (defimpl) with Implements edges
10//!
11//! ## Cross-Language Support
12//! - Erlang FFI: Detects `:module.function()` syntax as `FfiCall` edges
13//! - Pipe operator: Chains are expanded into sequential `DirectCall` edges
14//!
15//! ## Limitations
16//! - Macros: Not expanded (compile-time only)
17//! - Dynamic calls: apply/3 not tracked (runtime-only)
18
19use 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/// `GraphBuilder` for Elixir files using manual tree walking approach
31#[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, // Elixir: module -> function -> nested function
40        }
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        // Create helper for staging graph population
60        let mut helper = GraphBuildHelper::new(staging, file, Language::Elixir);
61
62        // Build AST graph for call context tracking
63        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        // First pass: collect protocol definitions
71        let mut protocol_map = HashMap::new();
72        collect_protocols(tree.root_node(), content, &mut helper, &mut protocol_map)?;
73
74        // Create recursion guard for tree walking
75        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        // Second pass: walk tree to extract functions, calls, and protocol implementations
97        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
114// ============================================================================
115// Graph Building with GraphBuildHelper
116// ============================================================================
117
118/// First pass: collect all protocol definitions
119fn 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        // Extract protocol name to store in map
130        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    // Recurse into children
148    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
156/// Walk the tree and build graph nodes/edges using `GraphBuildHelper`
157///
158/// # Errors
159///
160/// Returns [`GraphBuilderError`] if graph operations fail or recursion depth exceeds the guard's limit.
161fn 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    // Check for @spec annotations and process TypeOf/Reference edges
175    if node.kind() == "unary_operator" && is_spec_annotation(&node, content) {
176        process_spec_typeof_edges(node, content, helper)?;
177    }
178
179    // Check for protocol implementations
180    if node.kind() == "call" && is_protocol_implementation(&node, content) {
181        build_protocol_impl(node, content, helper, protocol_map)?;
182    }
183    // Check for function definitions
184    else if is_function_definition(&node, content) {
185        // Extract function context from AST graph
186        if let Some(context) = ast_graph.get_callable_context(node.id()) {
187            let span = span_from_node(node);
188
189            // Add function node with visibility
190            // Visibility: defp/defmacrop = private, def/defmacro = public
191            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, // Elixir doesn't have async in the same way
200                false, // Elixir doesn't have unsafe
201                Some(visibility),
202            );
203
204            // Emit Export edge for public functions and macros (def/defmacro, not defp/defmacrop)
205            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    // Check for Erlang NIF calls (FFI)
213    if node.kind() == "call" && is_erlang_load_nif(&node, content) {
214        build_nif_ffi_edge(node, content, ast_graph, helper);
215    }
216    // Check for import/alias/use/require statements
217    else if node.kind() == "call" && is_import_statement(&node, content) {
218        // Build import edge for import, alias, use, require
219        build_import_edge_with_helper(node, content, helper)?;
220    }
221    // Check for call expressions (excluding function definitions and imports)
222    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    // Recurse into children
238    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
247/// Build a call edge from a call node using `GraphBuildHelper`
248fn 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    // Get or create module-level context for top-level calls
262    let module_context;
263    let call_context = if let Some(ctx) = ast_graph.get_callable_context(call_node.id()) {
264        ctx
265    } else {
266        // Create synthetic module-level context for top-level calls
267        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    // Extract the call target (the function being called)
276    let Some(target_node) = call_node.child_by_field_name("target") else {
277        return Ok(None);
278    };
279
280    // Determine the callee name and edge kind
281    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    // Ensure both nodes exist
288    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// ============================================================================
303// Helper Functions
304// ============================================================================
305
306/// Extract module name from defmodule declaration
307#[allow(dead_code)] // Scaffolding for module-level analysis
308fn 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
335/// Check if a call node is a function definition (def/defp)
336fn 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
345/// Check if a call node is an import statement (import, alias, use, require)
346fn 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
355/// Check if a call node is a protocol definition (defprotocol)
356fn 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
365/// Check if a call node is a protocol implementation (defimpl)
366fn 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
375/// Check if a `unary_operator` node is a @spec or @type annotation
376fn is_spec_annotation(node: &Node, content: &[u8]) -> bool {
377    if node.kind() != "unary_operator" {
378        return false;
379    }
380
381    // Check if this is a spec or type annotation
382    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/// Process a @spec annotation and create TypeOf/Reference edges
393#[allow(clippy::unnecessary_wraps)]
394fn process_spec_typeof_edges(
395    spec_node: Node,
396    content: &[u8],
397    helper: &mut GraphBuildHelper,
398) -> GraphResult<()> {
399    // Extract the function name and types from the @spec
400    // Pattern: @spec function_name(type1, type2) :: return_type
401
402    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        // Extract function name
411        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            // Get or create function node
419            let function_id = helper.add_function(&func_name, None, false, false);
420
421            // Process parameter types
422            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                        // Extract full type string
430                        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                        // Extract nested type names for Reference edges
442                        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            // Process return type (right side of ::)
455            if let Some(return_type_node) = binary_op.named_child(1)
456                && is_type_node(return_type_node.kind())
457            {
458                // Extract full type string
459                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                // Extract nested type names for Reference edges
471                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/// Build protocol node from a defprotocol statement
485#[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    // Extract protocol name from arguments
492    // Pattern: defprotocol Name do ... end
493    // Find the "arguments" child node (it's a direct child, not a field)
494    let mut cursor = protocol_node.walk();
495    for child in protocol_node.children(&mut cursor) {
496        if child.kind() == "arguments" {
497            // Found the arguments node - now extract the protocol name
498            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                    // Protocols are like interfaces in other languages
505                    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/// Build protocol implementation and creates Implements edge
515#[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    // Extract protocol name and target type
523    // Pattern: defimpl ProtocolName, for: TargetType do ... end
524    // Find the "arguments" child node
525    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                // First identifier/alias is the protocol name
536                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                // Look for "for:" keyword list
545                else if arg_child.kind() == "keywords" {
546                    // Find the type after "for:"
547                    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                            // Check if this is the "for:" pair
551                            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                                // Match both "for" and "for:"
556                                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                                        // Try walking children to find the type
563                                        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                // Create a struct/class node for the implementation
587                // Name it as "ProtocolName.TargetType" for uniqueness
588                let impl_name = format!("{protocol}.{target}");
589                let impl_id = helper.add_struct(&impl_name, Some(span));
590
591                // If we have the protocol in the map, create an Implements edge
592                if let Some(&protocol_id) = protocol_map.get(&protocol) {
593                    helper.add_implements_edge(impl_id, protocol_id);
594                } else {
595                    // Protocol not in map - create it as external interface
596                    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/// Build import edge from an import/alias/use/require statement
608#[allow(clippy::too_many_lines)] // Complex AST patterns are clearer in a single pass.
609#[allow(clippy::unnecessary_wraps)] // Returns GraphResult for consistency with other helpers.
610fn build_import_edge_with_helper(
611    call_node: Node<'_>,
612    content: &[u8],
613    helper: &mut GraphBuildHelper,
614) -> GraphResult<()> {
615    // Get the import type (import, alias, use, require)
616    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    // Get the arguments containing the module name
622    // Note: tree-sitter-elixir doesn't use a field name for arguments, so we find by kind
623    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    // Extract the module name (first argument is typically the module alias)
633    let mut cursor = args_node.walk();
634    let mut module_name: Option<String> = None;
635    let mut alias_name: Option<String> = None;
636    // is_wildcard semantics:
637    // - `import Mod` = true (imports all exports from Mod)
638    // - `import Mod, only: [...]` = false (selective import)
639    // - `alias Mod` = false (creates a reference/alias, not a wildcard import)
640    // - `use Mod` = true (injects macros/callbacks, effectively a wildcard)
641    // - `require Mod` = false (makes module's macros available but doesn't import)
642    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                // Module name like Phoenix.Controller or Enum
649                if module_name.is_none()
650                    && let Ok(text) = child.utf8_text(content)
651                {
652                    module_name = Some(text.to_string());
653                    // For `alias` statements, extract the default alias (last segment)
654                    // e.g., `alias Phoenix.Controller` defaults to alias `Controller`
655                    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                // Multi-alias syntax: alias Phoenix.{Socket, Channel}
665                // The dot node contains the base module (alias) and the tuple of elements
666                if import_type == "alias" {
667                    // Extract base module and tuple from the dot node
668                    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                                // This is the base module (e.g., "Phoenix")
676                                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                                // Extract the alias elements from the tuple
684                                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 we found tuple elements, emit individual edges
698                    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                            // Build the full module path: e.g., Phoenix.Socket
705                            let full_module = if base.is_empty() {
706                                element.clone()
707                            } else {
708                                format!("{base}.{element}")
709                            };
710
711                            // Default alias is the element name itself
712                            let alias_value = element.clone();
713
714                            let import_id = helper.add_import(&full_module, Some(span));
715                            // Multi-alias elements are NOT wildcard (they're explicit aliases)
716                            helper.add_import_edge_full(
717                                module_id,
718                                import_id,
719                                Some(&alias_value),
720                                false,
721                            );
722                        }
723
724                        // Return early - we've already emitted all edges
725                        return Ok(());
726                    }
727                }
728                // If we didn't find tuple elements, treat this as a regular dot access
729                // (e.g., Foo.Bar.Baz) - extract the full text as module name
730                if let Ok(text) = child.utf8_text(content) {
731                    module_name = Some(text.to_string());
732                    // For alias statements, extract the default alias (last segment)
733                    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                // Grouped aliases without dot prefix (rare case: alias {Foo, Bar})
743                // This can happen if someone writes `alias {Foo, Bar}` without a base module
744                if import_type == "alias" {
745                    // Extract the elements from the tuple
746                    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 we have tuple elements, emit individual edges for each
759                    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                            // Multi-alias elements are NOT wildcard (they're explicit aliases)
766                            helper.add_import_edge_full(
767                                module_id,
768                                import_id,
769                                Some(&element),
770                                false,
771                            );
772                        }
773
774                        // Return early - we've already emitted all edges
775                        return Ok(());
776                    }
777                }
778                // For non-alias statements with tuple syntax (unusual), fall through
779                // to default behavior with wildcard
780                is_wildcard = true;
781            }
782            "keywords" => {
783                // Options like `only: [...]` or `as: Alias`
784                let mut kw_cursor = child.walk();
785                for kw_pair in child.named_children(&mut kw_cursor) {
786                    if kw_pair.kind() == "pair" {
787                        // Look for `as:` option
788                        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                                        // Trim whitespace first, then the trailing colon
799                                        // "as: " -> "as:" -> "as"
800                                        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                                    // `only: [...]` or `except: [...]` - treat as partial import
812                                    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    // For alias statements without explicit `as:`, the alias is already set to the default
834    // For import/use/require without `only:`/`except:`, is_wildcard remains true
835    let _ = has_only_or_except; // Used to set is_wildcard
836
837    // Create the import edge if we found a module name
838    if let Some(imported_module) = module_name {
839        let span = span_from_node(call_node);
840
841        // Create module node (importer) and import node (imported)
842        let module_id = helper.add_module("<module>", None);
843
844        // For `use`, we prefix the import name to distinguish semantic
845        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        // Always use add_import_edge_full to correctly set metadata
854        helper.add_import_edge_full(module_id, import_id, alias_name.as_deref(), is_wildcard);
855    }
856
857    Ok(())
858}
859
860/// Count the number of arguments in a function call
861fn 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        // Filter out delimiters and count actual argument nodes
867        let count = children
868            .iter()
869            .filter(|child| {
870                // Exclude structural delimiters
871                !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        // No "arguments" field - try to find arguments list directly
889        // Some tree-sitter grammars use different structure
890        let mut cursor = call_node.walk();
891        let children: Vec<_> = call_node
892            .named_children(&mut cursor)
893            .filter(|child| {
894                // Look for argument list nodes
895                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
924/// Extract call information (name and edge kind) from a target node
925fn extract_call_info(target_node: &Node, content: &[u8]) -> GraphResult<(String, bool)> {
926    // Handle different call patterns
927    match target_node.kind() {
928        // Simple identifier: foo()
929        "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 operator: Module.function() or :erlang.function()
941        "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                // Check if it's Erlang FFI (:atom.function)
946                let is_erlang_ffi = left_text.starts_with(':');
947
948                // Get the full qualified name
949                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        // Other patterns (unary_operator, binary_operator, etc.)
964        _ => {
965            // Try to get the text representation
966            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
975/// Convert a tree-sitter node to a Span
976fn span_from_node(node: Node<'_>) -> Span {
977    Span::from_bytes(node.start_byte(), node.end_byte())
978}
979
980// ============================================================================
981// FFI Detection - Erlang NIF Support
982// ============================================================================
983
984/// Check if a call node is `:erlang.load_nif` (Erlang NIF loading)
985///
986/// Detects the primary FFI pattern in Elixir: loading native C libraries via Erlang's NIF system.
987///
988/// # Arity Handling
989///
990/// Accepts any arity, not just /2, because:
991/// - Standard form is `load_nif(path, init_arg)` with arity 2
992/// - But we want to detect incomplete/malformed calls during development
993/// - Macro-generated code may have variations
994/// - Graceful degradation is better than false negatives
995///
996/// The implementation will attempt to extract the library path from the
997/// first argument when present, falling back to a generic target otherwise.
998///
999/// # Pattern
1000///
1001/// ```elixir
1002/// :erlang.load_nif('./path/to/lib', init_args)
1003/// ```
1004///
1005/// # AST Structure
1006///
1007/// ```text
1008/// call
1009/// ├── target: dot
1010/// │   ├── left: atom (:erlang)
1011/// │   └── right: identifier (load_nif)
1012/// └── arguments
1013/// ```
1014fn is_erlang_load_nif(node: &Node, content: &[u8]) -> bool {
1015    // Must have a target field
1016    let Some(target) = node.child_by_field_name("target") else {
1017        return false;
1018    };
1019
1020    // Target must be a dot operator (module.function)
1021    if target.kind() != "dot" {
1022        return false;
1023    }
1024
1025    // Left side must be :erlang atom
1026    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    // Right side must be load_nif identifier
1040    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
1050/// Build FFI edge for Erlang NIF loading (`:erlang.load_nif/2`)
1051///
1052/// Creates an `FfiCall` edge from the calling function to the NIF loader.
1053///
1054/// # Edge Details
1055///
1056/// - **Caller**: Function containing the `:erlang.load_nif` call (from AST graph context)
1057/// - **Callee**: Fixed node `ffi::erlang::load_nif`
1058/// - **Convention**: `FfiConvention::C` (NIFs always use C ABI)
1059///
1060/// # Example
1061///
1062/// ```elixir
1063/// def init do
1064///   :erlang.load_nif('./my_nif', 0)  # Creates: init --FfiCall(C)--> ffi::erlang::load_nif
1065/// end
1066/// ```
1067fn 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    // Get caller context from AST graph
1076    let caller_name = if let Some(ctx) = ast_graph.get_callable_context(node.id()) {
1077        ctx.qualified_name.clone()
1078    } else {
1079        // Top-level call - use module context
1080        "<module>".to_string()
1081    };
1082
1083    // Create caller node
1084    let caller_id = helper.add_function(&caller_name, None, false, false);
1085
1086    // Create FFI function node (fixed name for all NIF loads)
1087    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    // Add FfiCall edge with C convention (NIFs use C ABI)
1092    helper.add_ffi_edge(caller_id, ffi_func_id, FfiConvention::C);
1093}
1094
1095// ============================================================================
1096// AST Graph - Tracks callable contexts
1097// ============================================================================
1098
1099#[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        // Extract function and macro definitions using tree-sitter query
1111        // Match both public (def, defmacro) and private (defp, defmacrop) functions/macros
1112        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 all descendant nodes to this context
1175                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)] // Reserved for future context queries
1186    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
1197/// Recursively map all descendant nodes to a context index
1198fn 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)] // Reserved for scope analysis
1211    span: (usize, usize),
1212    #[allow(dead_code)] // Reserved for visibility filtering
1213    is_private: bool,
1214}
1215
1216impl CallContext {
1217    #[allow(dead_code)] // Reserved for future context queries
1218    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    /// Helper to extract Import edges from staging operations
1235    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    /// Helper to build a `StringId` → String map from staged `InternString` operations.
1251    /// This allows tests to assert the exact alias values.
1252    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    /// Helper to resolve a `StringId` to its string value using the staging operations.
1267    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        // Erlang FFI calls (e.g. :crypto.hash) are currently emitted as Calls edges.
1394        // The is_erlang_ffi flag is extracted but not yet used to produce FfiCall edges.
1395        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    // ============================================================================
1466    // Import Edge Tests (Wave 7)
1467    // ============================================================================
1468
1469    #[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        // Simple import without `only:` should be wildcard
1492        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        // Import with `only:` should NOT be wildcard
1526        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        // Build string map to resolve alias values
1557        let string_map = build_string_map(&staging);
1558
1559        // Alias without `as:` should have default alias (last segment: Controller)
1560        // Alias is NOT wildcard - it creates a named reference
1561        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            // Assert the exact alias value
1568            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        // Build string map to resolve alias values
1599        let string_map = build_string_map(&staging);
1600
1601        // Alias with `as:` should have explicit alias set
1602        // Alias is NOT wildcard - it creates a named reference
1603        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            // Assert the exact alias value
1610            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        // Should emit two edges: one for Socket, one for Channel
1640        assert_eq!(
1641            import_edges.len(),
1642            2,
1643            "Multi-alias should emit one edge per alias element"
1644        );
1645
1646        // Build string map to resolve alias values
1647        let string_map = build_string_map(&staging);
1648
1649        // Extract alias values and verify
1650        let mut alias_values: Vec<String> = import_edges
1651            .iter()
1652            .filter_map(|edge| {
1653                if let UnifiedEdgeKind::Imports { alias, is_wildcard } = edge {
1654                    // Each element should NOT be wildcard
1655                    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        // Use statement should be wildcard (brings in all behavior)
1691        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        // Require statement is NOT wildcard - it just makes macros available for compile-time
1719        // but doesn't import all symbols into the namespace like `import` does
1720        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        // Extract all import edges and validate count
1752        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        // Verify all are EdgeKind::Imports
1760        for edge in &import_edges {
1761            assert!(
1762                matches!(edge, UnifiedEdgeKind::Imports { .. }),
1763                "All edges should be Imports"
1764            );
1765        }
1766    }
1767
1768    // ============================================================================
1769    // Export Edge Tests
1770    // ============================================================================
1771
1772    /// Helper to extract Export edges from staging operations
1773    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        // Verify the export edge has correct kind
1814        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        // All exports should be Direct with no alias
1864        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        // All exports should be Direct with no alias
1930        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        // Verify the export edge has correct kind
1966        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        // All exports should be Direct with no alias
2032        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    // ============================================================================
2043    // FFI Edge Tests (Erlang NIF)
2044    // ============================================================================
2045
2046    /// Helper to extract FFI edges from staging operations
2047    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        // Verify convention is C
2088        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)] // Domain variable naming is intentional
2451    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        // Verify FfiCall edge exists
2469        let ffi_edges = extract_ffi_edges(&staging);
2470        assert_eq!(ffi_edges.len(), 1, "Expected exactly one FfiCall edge");
2471
2472        // Verify convention is C
2473        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        // Extract all nodes to find caller and callee by name
2484        let mut caller_node_id: Option<NodeId> = None;
2485        #[allow(clippy::similar_names)] // AST node variables
2486        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                // Find caller node (should be "load_nif" function, not the FFI target)
2495                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                // Find callee node by its canonical graph identity.
2502                if canonical_name == "ffi::erlang::load_nif" {
2503                    callee_node_id = *expected_id;
2504                }
2505            }
2506        }
2507
2508        // Verify we found both nodes
2509        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        // Verify that the FfiCall edge connects these specific nodes
2522        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    // Negative test cases
2545
2546    #[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    // Edge case tests
2674
2675    #[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        // Should not crash, even with malformed call
2690        builder
2691            .build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
2692            .unwrap();
2693
2694        // May or may not detect - depends on tree-sitter parsing
2695        // Just ensure no panic
2696        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        // Should not crash with missing second argument
2714        builder
2715            .build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
2716            .unwrap();
2717
2718        let ffi_edges = extract_ffi_edges(&staging);
2719        // Should still detect even with non-standard arity
2720        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        // Should handle path interpolation without crashing
2741        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}