Skip to main content

sqry_lang_lua/relations/
graph_builder.rs

1//! `GraphBuilder` implementation for Lua
2//!
3//! Builds the unified `CodeGraph` for Lua files by:
4//! 1. Extracting function definitions (global, local, module methods)
5//! 2. Detecting function call expressions
6//! 3. Creating call edges between caller and callee
7//! 4. Detecting `LuaJIT` FFI patterns and creating FFI edges
8
9use std::{collections::HashMap, path::Path};
10
11use sqry_core::graph::unified::build::helper::CalleeKindHint;
12use sqry_core::graph::unified::edge::FfiConvention;
13use sqry_core::graph::unified::{GraphBuildHelper, NodeKind, StagingGraph};
14use sqry_core::graph::{GraphBuilder, GraphBuilderError, GraphResult, Language, Position, Span};
15use tree_sitter::{Node, Tree};
16
17const DEFAULT_SCOPE_DEPTH: usize = 4;
18
19/// File-level module name for exports.
20/// In Lua, module methods (M.foo) are exported; local functions are not.
21const FILE_MODULE_NAME: &str = "<file_module>";
22
23/// FFI entity types for alias resolution
24#[derive(Debug, Clone, PartialEq, Eq)]
25enum FfiEntity {
26    /// The ffi module itself (from require("ffi"))
27    Module,
28    /// ffi.C (C standard library)
29    CLibrary,
30    /// `ffi.load("library_name`")
31    LoadedLibrary(String),
32}
33
34/// Type alias for FFI alias table
35type FfiAliasTable = HashMap<String, FfiEntity>;
36
37/// FFI call information extracted from AST
38#[derive(Debug, Clone)]
39struct FfiCallInfo {
40    /// Name of the C function being called
41    function_name: String,
42    /// Optional library name (for ffi.load cases)
43    library_name: Option<String>,
44}
45
46/// `GraphBuilder` for Lua files using manual tree walking approach
47#[derive(Debug, Clone, Copy)]
48pub struct LuaGraphBuilder {
49    max_scope_depth: usize,
50}
51
52impl Default for LuaGraphBuilder {
53    fn default() -> Self {
54        Self {
55            max_scope_depth: DEFAULT_SCOPE_DEPTH,
56        }
57    }
58}
59
60impl LuaGraphBuilder {
61    #[must_use]
62    pub fn new(max_scope_depth: usize) -> Self {
63        Self { max_scope_depth }
64    }
65}
66
67impl GraphBuilder for LuaGraphBuilder {
68    fn build_graph(
69        &self,
70        tree: &Tree,
71        content: &[u8],
72        file: &Path,
73        staging: &mut StagingGraph,
74    ) -> GraphResult<()> {
75        // Create helper for staging graph population
76        let mut helper = GraphBuildHelper::new(staging, file, Language::Lua);
77
78        // Build AST graph for call context tracking
79        let ast_graph = ASTGraph::from_tree(tree, content, self.max_scope_depth).map_err(|e| {
80            GraphBuilderError::ParseError {
81                span: Span::default(),
82                reason: e,
83            }
84        })?;
85
86        // Create recursion guard for tree walking
87        let recursion_limits =
88            sqry_core::config::RecursionLimits::load_or_default().map_err(|e| {
89                GraphBuilderError::ParseError {
90                    span: Span::default(),
91                    reason: format!("Failed to load recursion limits: {e}"),
92                }
93            })?;
94        let file_ops_depth = recursion_limits.effective_file_ops_depth().map_err(|e| {
95            GraphBuilderError::ParseError {
96                span: Span::default(),
97                reason: format!("Invalid file_ops_depth configuration: {e}"),
98            }
99        })?;
100        let mut guard =
101            sqry_core::query::security::RecursionGuard::new(file_ops_depth).map_err(|e| {
102                GraphBuilderError::ParseError {
103                    span: Span::default(),
104                    reason: format!("Failed to create recursion guard: {e}"),
105                }
106            })?;
107
108        // Phase 1: Populate FFI alias table
109        let mut ffi_aliases = FfiAliasTable::new();
110        populate_ffi_aliases(tree.root_node(), content, &mut ffi_aliases);
111
112        // Phase 2: Walk tree to find functions, calls, and FFI patterns
113        walk_tree_for_graph(
114            tree.root_node(),
115            content,
116            &ast_graph,
117            &mut helper,
118            &mut guard,
119            &ffi_aliases,
120        )?;
121
122        Ok(())
123    }
124
125    fn language(&self) -> Language {
126        Language::Lua
127    }
128}
129
130/// Check if a `function_declaration` or `assignment_statement` has the 'local' keyword.
131fn is_local_function(node: Node<'_>) -> bool {
132    let Some(parent) = node.parent() else {
133        return false;
134    };
135
136    // In tree-sitter-lua, local functions are represented as function_declaration nodes
137    // that are children of their parent with field name "local_declaration"
138    // e.g., `local function foo() end` creates a function_declaration with this field
139
140    // Check if this node is the "local_declaration" field of its parent
141    if let Some(index) = named_child_index(parent, node)
142        && let Some(field) = parent.field_name_for_named_child(index)
143    {
144        return field == "local_declaration";
145    }
146
147    if let Some(index) = child_index(parent, node)
148        && let Some(field) = parent.field_name_for_child(index)
149    {
150        return field == "local_declaration";
151    }
152
153    // Also check for local_variable_declaration parent (for assignment patterns)
154    if parent.kind() == "local_variable_declaration" {
155        return true;
156    }
157
158    false
159}
160
161/// Determine visibility based on function name convention.
162/// In Lua, functions starting with underscore are considered private by convention.
163#[allow(clippy::unnecessary_wraps)]
164fn get_function_visibility(qualified_name: &str) -> Option<&'static str> {
165    // Extract the last segment of the qualified name (e.g., "M::_foo" -> "_foo")
166    let function_name = qualified_name.rsplit("::").next().unwrap_or(qualified_name);
167
168    if function_name.starts_with('_') {
169        Some("private")
170    } else {
171        Some("public")
172    }
173}
174
175/// Find the index of a named child in its parent
176fn named_child_index(parent: Node<'_>, target: Node<'_>) -> Option<u32> {
177    for i in 0..parent.named_child_count() {
178        #[allow(clippy::cast_possible_truncation)] // tree-sitter child count fits in u32
179        if let Some(child) = parent.named_child(i as u32)
180            && child.id() == target.id()
181        {
182            let index = u32::try_from(i).ok()?;
183            return Some(index);
184        }
185    }
186    None
187}
188
189/// Find the index of a child in its parent (including anonymous children)
190fn child_index(parent: Node<'_>, target: Node<'_>) -> Option<u32> {
191    let mut cursor = parent.walk();
192    if !cursor.goto_first_child() {
193        return None;
194    }
195    let mut index = 0u32;
196    loop {
197        if cursor.node().id() == target.id() {
198            return Some(index);
199        }
200        if !cursor.goto_next_sibling() {
201            break;
202        }
203        index += 1;
204    }
205    None
206}
207
208/// Walk the tree and populate the staging graph.
209#[allow(clippy::too_many_lines)] // Single pass keeps extraction logic cohesive.
210/// # Errors
211///
212/// Returns [`GraphBuilderError`] if graph operations fail or recursion depth exceeds the guard's limit.
213fn walk_tree_for_graph(
214    node: Node,
215    content: &[u8],
216    ast_graph: &ASTGraph,
217    helper: &mut GraphBuildHelper,
218    guard: &mut sqry_core::query::security::RecursionGuard,
219    ffi_aliases: &FfiAliasTable,
220) -> GraphResult<()> {
221    guard.enter().map_err(|e| GraphBuilderError::ParseError {
222        span: Span::default(),
223        reason: format!("Recursion limit exceeded: {e}"),
224    })?;
225
226    match node.kind() {
227        // Note: tree-sitter-lua represents local functions as function_declaration nodes
228        // with field name "local_declaration", NOT as separate "local_function" nodes.
229        // The is_local_function() helper checks for this field name.
230        "function_declaration" => {
231            // Extract function context from AST graph
232            if let Some(call_context) = ast_graph.get_callable_context(node.id()) {
233                let span = span_from_node(node);
234
235                // Check if this is a local function by looking for 'local' keyword
236                let is_local = is_local_function(node);
237                // Determine visibility based on function name convention (underscore prefix = private)
238                let visibility = get_function_visibility(&call_context.qualified_name);
239
240                // Add function or method node
241                if call_context.is_method {
242                    let node_id = helper.add_method_with_visibility(
243                        &call_context.qualified_name,
244                        Some(span),
245                        false, // Lua doesn't have async
246                        false, // is_static
247                        visibility,
248                    );
249                    // Export module methods (M.foo style) - they're public API
250                    // Local methods are not exported
251                    if call_context.module_name.is_some() && !is_local {
252                        let module_id = helper.add_module(FILE_MODULE_NAME, None);
253                        helper.add_export_edge(module_id, node_id);
254                    }
255                } else {
256                    let node_id = helper.add_function_with_visibility(
257                        &call_context.qualified_name,
258                        Some(span),
259                        false, // Lua doesn't have async
260                        false, // Lua doesn't have unsafe
261                        visibility,
262                    );
263                    // Export module functions (M.foo style) and top-level global functions
264                    // Local functions are NOT exported - they have local scope
265                    let is_module_scoped = call_context.qualified_name.contains("::");
266                    let is_global = !is_local && !is_module_scoped;
267
268                    if (is_module_scoped || is_global) && !is_local {
269                        let module_id = helper.add_module(FILE_MODULE_NAME, None);
270                        helper.add_export_edge(module_id, node_id);
271                    }
272                }
273            }
274        }
275        "assignment_statement" => {
276            // Handle function assignments (local foo = function() end, M.foo = function() end)
277            // Check if there's a function_definition child first
278            let mut cursor = node.walk();
279            let func_def = node
280                .children(&mut cursor)
281                .find(|child| child.kind() == "expression_list")
282                .and_then(|expr_list| {
283                    expr_list
284                        .named_children(&mut expr_list.walk())
285                        .find(|child| child.kind() == "function_definition")
286                });
287
288            if let Some(func_def_node) = func_def {
289                // Get the context for the function definition (not the assignment itself)
290                if let Some(call_context) = ast_graph.get_callable_context(func_def_node.id()) {
291                    let span = span_from_node(node);
292
293                    // Check if this is a local assignment
294                    let is_local = is_local_function(node);
295                    // Determine visibility based on function name convention (underscore prefix = private)
296                    let visibility = get_function_visibility(&call_context.qualified_name);
297
298                    // Add function or method node
299                    if call_context.is_method {
300                        let node_id = helper.add_method_with_visibility(
301                            &call_context.qualified_name,
302                            Some(span),
303                            false,
304                            false,
305                            visibility,
306                        );
307                        // Export module methods (M.foo = function() style) - they're public API
308                        // Don't export local assignments
309                        if call_context.module_name.is_some() && !is_local {
310                            let module_id = helper.add_module(FILE_MODULE_NAME, None);
311                            helper.add_export_edge(module_id, node_id);
312                        }
313                    } else {
314                        let node_id = helper.add_function_with_visibility(
315                            &call_context.qualified_name,
316                            Some(span),
317                            false,
318                            false,
319                            visibility,
320                        );
321                        // Export module functions (M.foo = function() style) - they're public API
322                        // Only export if function has module scope (qualified name contains "::")
323                        // Local functions are NOT exported - they have local scope
324                        if call_context.qualified_name.contains("::") && !is_local {
325                            let module_id = helper.add_module(FILE_MODULE_NAME, None);
326                            helper.add_export_edge(module_id, node_id);
327                        }
328                    }
329                }
330            }
331        }
332        "return_statement" => {
333            // Handle return table exports: return { key = value, ... }
334            // This is a common Lua module pattern
335            handle_return_table_exports(node, content, helper);
336        }
337        "function_call" => {
338            // Check for FFI patterns first (ffi.C.*, lib.*, ffi.load)
339            if let Some(ffi_info) = extract_ffi_call_info(node, content, ffi_aliases) {
340                emit_ffi_edge(ffi_info, node, content, ast_graph, helper);
341            }
342            // Check if this is a require() call
343            else if is_require_call(node, content) {
344                // Build import edge for require()
345                build_require_import_edge(node, content, helper);
346            }
347            // Build call edge and call site (for non-require calls too)
348            else if let Ok(Some((caller_qname, callee_qname, argument_count, span))) =
349                build_call_for_staging(ast_graph, node, content)
350            {
351                // Ensure both nodes exist
352                let call_context = ast_graph.get_callable_context(node.id());
353                let is_method = call_context.is_some_and(|c| c.is_method);
354
355                let source_id = if is_method {
356                    helper.ensure_method(&caller_qname, None, false, false)
357                } else {
358                    helper.ensure_callee(&caller_qname, span, CalleeKindHint::Function)
359                };
360                let target_id = helper.ensure_callee(&callee_qname, span, CalleeKindHint::Function);
361
362                // Add call edge
363                let argument_count = u8::try_from(argument_count).unwrap_or(u8::MAX);
364                helper.add_call_edge_full_with_span(
365                    source_id,
366                    target_id,
367                    argument_count,
368                    false,
369                    vec![span],
370                );
371            }
372        }
373        "table_constructor" => {
374            // Handle table constructor: { key = value, ... }
375            // Create property nodes for table fields
376            build_table_fields(node, content, helper)?;
377        }
378        "dot_index_expression" | "bracket_index_expression" => {
379            // Handle field access: table.field or table["field"]
380            // Create property nodes for field accesses
381            build_field_access(node, content, helper)?;
382        }
383        _ => {}
384    }
385
386    // Recurse into children
387    let mut cursor = node.walk();
388    for child in node.children(&mut cursor) {
389        walk_tree_for_graph(child, content, ast_graph, helper, guard, ffi_aliases)?;
390    }
391
392    guard.exit();
393    Ok(())
394}
395
396/// Handle return table exports: `return { key = value, ... }`
397///
398/// This extracts exports from return statements with table constructors.
399/// Pattern: `return { func1 = func1, func2 = M.func2, ... }`
400fn handle_return_table_exports(node: Node<'_>, content: &[u8], helper: &mut GraphBuildHelper) {
401    // Find the expression_list child, then the table_constructor inside it
402    // return { ... } has structure: return_statement -> expression_list -> table_constructor
403    let Some(expr_list) = node
404        .children(&mut node.walk())
405        .find(|child| child.kind() == "expression_list")
406    else {
407        return;
408    };
409
410    let Some(table_node) = expr_list
411        .children(&mut expr_list.walk())
412        .find(|child| child.kind() == "table_constructor")
413    else {
414        return;
415    };
416
417    let module_id = helper.add_module(FILE_MODULE_NAME, None);
418
419    // Iterate over field entries in the table
420    let mut cursor = table_node.walk();
421    for field in table_node.children(&mut cursor) {
422        if field.kind() != "field" {
423            continue;
424        }
425
426        // Extract the key name (field name)
427        let key_name = if let Some(name_node) = field.child_by_field_name("name") {
428            // Pattern: { key = value }
429            name_node
430                .utf8_text(content)
431                .ok()
432                .map(|s| s.trim().to_string())
433        } else {
434            // Pattern: { [expr] = value } - skip dynamic keys
435            continue;
436        };
437
438        let Some(key) = key_name else {
439            continue;
440        };
441
442        // Extract the value (what's being exported)
443        let Some(value_node) = field.child_by_field_name("value") else {
444            continue;
445        };
446
447        // Try to resolve what the value references
448        // Common patterns:
449        // 1. Direct reference: { func = func }
450        // 2. Module reference: { func = Module.func }
451        // 3. Inline function: { func = function() end }
452
453        let exported_name = match value_node.kind() {
454            "identifier" => {
455                // Direct reference: { func = func }
456                value_node.utf8_text(content).ok().map(str::to_string)
457            }
458            "dot_index_expression" | "method_index_expression" => {
459                // Module reference: { func = Module.func }
460                // Extract the full qualified name
461                extract_table_field_qualified_name(value_node, content).ok()
462            }
463            "function_definition" => {
464                // Inline function: { func = function() end }
465                // Use the key as the function name
466                Some(key.clone())
467            }
468            _ => None,
469        };
470
471        if let Some(name) = exported_name {
472            // Create export edge
473            // The exported symbol should already exist from function_declaration/assignment handling
474            // If it doesn't exist yet, create it as a function node using ensure_callee
475            let exported_id =
476                helper.ensure_callee(&name, span_from_node(field), CalleeKindHint::Function);
477
478            helper.add_export_edge(module_id, exported_id);
479        }
480    }
481}
482
483/// Extract qualified name from a table field expression
484fn extract_table_field_qualified_name(node: Node<'_>, content: &[u8]) -> Result<String, String> {
485    match node.kind() {
486        "identifier" => node
487            .utf8_text(content)
488            .map(str::to_string)
489            .map_err(|_| "failed to read identifier".to_string()),
490        "dot_index_expression" => {
491            let table = node
492                .child_by_field_name("table")
493                .ok_or_else(|| "dot_index_expression missing table".to_string())?;
494            let field = node
495                .child_by_field_name("field")
496                .ok_or_else(|| "dot_index_expression missing field".to_string())?;
497
498            let table_text = extract_table_field_qualified_name(table, content)?;
499            let field_text = field
500                .utf8_text(content)
501                .map_err(|_| "failed to read field".to_string())?;
502
503            Ok(format!("{table_text}::{field_text}"))
504        }
505        "method_index_expression" => {
506            let table = node
507                .child_by_field_name("table")
508                .ok_or_else(|| "method_index_expression missing table".to_string())?;
509            let method = node
510                .child_by_field_name("method")
511                .ok_or_else(|| "method_index_expression missing method".to_string())?;
512
513            let table_text = extract_table_field_qualified_name(table, content)?;
514            let method_text = method
515                .utf8_text(content)
516                .map_err(|_| "failed to read method".to_string())?;
517
518            Ok(format!("{table_text}::{method_text}"))
519        }
520        _ => node
521            .utf8_text(content)
522            .map(str::to_string)
523            .map_err(|_| "failed to read node".to_string()),
524    }
525}
526
527/// Check if a function call is a `require()` call
528fn is_require_call(call_node: Node<'_>, content: &[u8]) -> bool {
529    // Look for the function name
530    if let Some(name_node) = call_node.child_by_field_name("name")
531        && let Ok(text) = name_node.utf8_text(content)
532    {
533        return text.trim() == "require";
534    }
535    false
536}
537
538/// Build import edge from a `require()` call
539fn build_require_import_edge(call_node: Node<'_>, content: &[u8], helper: &mut GraphBuildHelper) {
540    // Extract the module name from the arguments
541    // Pattern: require("module.name") or require 'module.name'
542    let Some(args_node) = call_node.child_by_field_name("arguments") else {
543        return;
544    };
545
546    // Find the first string argument
547    let mut cursor = args_node.walk();
548    let mut module_name: Option<String> = None;
549
550    for child in args_node.children(&mut cursor) {
551        if (child.kind() == "string" || child.kind() == "string_content")
552            && let Ok(text) = child.utf8_text(content)
553        {
554            // Remove quotes from string literal
555            let trimmed = text
556                .trim()
557                .trim_start_matches(['"', '\'', '['])
558                .trim_end_matches(['"', '\'', ']'])
559                .to_string();
560            if !trimmed.is_empty() {
561                module_name = Some(trimmed);
562                break;
563            }
564        }
565        // Also check for nested string content
566        let mut inner_cursor = child.walk();
567        for inner_child in child.children(&mut inner_cursor) {
568            if inner_child.kind() == "string_content"
569                && let Ok(text) = inner_child.utf8_text(content)
570            {
571                let trimmed = text.trim().to_string();
572                if !trimmed.is_empty() {
573                    module_name = Some(trimmed);
574                    break;
575                }
576            }
577        }
578        if module_name.is_some() {
579            break;
580        }
581    }
582
583    // Create import edge if we found a module name
584    if let Some(imported_module) = module_name {
585        let span = span_from_node(call_node);
586
587        // Create module node (importer) and import node (imported)
588        let module_id = helper.add_module("<module>", None);
589        let import_id = helper.add_import(&imported_module, Some(span));
590
591        // Lua require() returns a table/module reference, not a wildcard import
592        // is_wildcard: false because you get a specific module reference
593        helper.add_import_edge_full(module_id, import_id, None, false);
594    }
595}
596
597// ============================================================================
598// FFI Detection - LuaJIT FFI patterns
599// ============================================================================
600
601/// Populate the FFI alias table by walking the AST and tracking:
602/// - `require("ffi")` assignments
603/// - `ffi.C` aliases
604/// - `ffi.load("library")` assignments
605fn populate_ffi_aliases(node: Node, content: &[u8], aliases: &mut FfiAliasTable) {
606    match node.kind() {
607        "local_variable_declaration" | "assignment_statement" => {
608            // Try to extract FFI-related assignments
609            extract_ffi_assignment(node, content, aliases);
610        }
611        _ => {}
612    }
613
614    // Recurse to children
615    let mut cursor = node.walk();
616    for child in node.children(&mut cursor) {
617        populate_ffi_aliases(child, content, aliases);
618    }
619}
620
621#[cfg(test)]
622#[allow(dead_code)]
623fn debug_node_structure(node: Node, content: &[u8], indent: usize) {
624    let indent_str = "  ".repeat(indent);
625    let text = node.utf8_text(content).ok().and_then(|t| {
626        let trimmed = t.trim();
627        if trimmed.len() > 50 || trimmed.is_empty() {
628            None
629        } else {
630            Some(trimmed)
631        }
632    });
633
634    eprintln!(
635        "{}{}{}",
636        indent_str,
637        node.kind(),
638        text.map(|t| format!(" [{t}]")).unwrap_or_default()
639    );
640
641    if indent < 10 {
642        let mut cursor = node.walk();
643        for child in node.children(&mut cursor) {
644            debug_node_structure(child, content, indent + 1);
645        }
646    }
647}
648
649/// Extract FFI assignments from local/assignment statements
650fn extract_ffi_assignment(node: Node, content: &[u8], aliases: &mut FfiAliasTable) {
651    // For variable_declaration, look for assignment_statement child
652    // For assignment_statement, extract directly
653
654    let assignment = if node.kind() == "variable_declaration" {
655        // Find the assignment_statement child
656        let mut cursor = node.walk();
657        node.children(&mut cursor)
658            .find(|c| c.kind() == "assignment_statement")
659    } else if node.kind() == "assignment_statement" {
660        Some(node)
661    } else {
662        None
663    };
664
665    if let Some(assign_node) = assignment {
666        extract_from_assignment(assign_node, content, aliases);
667    }
668}
669
670/// Extract FFI entity from `assignment_statement`
671fn extract_from_assignment(assign_node: Node, content: &[u8], aliases: &mut FfiAliasTable) {
672    // Get variable_list (LHS) and expression_list (RHS)
673    let mut cursor = assign_node.walk();
674    let children: Vec<_> = assign_node.children(&mut cursor).collect();
675
676    let Some(var_list) = children.iter().find(|c| c.kind() == "variable_list") else {
677        return;
678    };
679    let Some(expr_list) = children.iter().find(|c| c.kind() == "expression_list") else {
680        return;
681    };
682
683    // Get first variable name
684    let Some(var_name_node) = var_list.named_child(0) else {
685        return;
686    };
687    let Ok(var_name) = var_name_node.utf8_text(content) else {
688        return;
689    };
690    let var_name = var_name.trim().to_string();
691
692    // Get first expression (value)
693    let Some(value_node) = expr_list.named_child(0) else {
694        return;
695    };
696
697    // Check different FFI patterns
698    if is_require_ffi_call(value_node, content) {
699        aliases.insert(var_name, FfiEntity::Module);
700    } else if is_ffi_c_reference(value_node, content, aliases) {
701        aliases.insert(var_name, FfiEntity::CLibrary);
702    } else if let Some(lib_name) = extract_ffi_load_library(value_node, content, aliases) {
703        aliases.insert(var_name, FfiEntity::LoadedLibrary(lib_name));
704    }
705    // Handle alias chains: local D = C (where C is already in alias table)
706    else if value_node.kind() == "identifier"
707        && let Ok(alias_name) = value_node.utf8_text(content)
708    {
709        let alias_name = alias_name.trim();
710        if let Some(entity) = aliases.get(alias_name).cloned() {
711            // Propagate the alias
712            aliases.insert(var_name, entity);
713        }
714    }
715}
716
717/// Check if a node is a `require("ffi")` call
718fn is_require_ffi_call(node: Node, content: &[u8]) -> bool {
719    if node.kind() != "function_call" {
720        return false;
721    }
722
723    // Check function name is "require"
724    let Some(name_node) = node.child_by_field_name("name") else {
725        return false;
726    };
727    let Ok(name_text) = name_node.utf8_text(content) else {
728        return false;
729    };
730    if name_text.trim() != "require" {
731        return false;
732    }
733
734    // Check first argument is "ffi"
735    let Some(args_node) = node.child_by_field_name("arguments") else {
736        return false;
737    };
738    let Some(first_arg) = args_node.named_child(0) else {
739        return false;
740    };
741
742    // Use extract_string_content for exact equality check
743    if let Some(content_str) = extract_string_content(first_arg, content) {
744        return content_str == "ffi";
745    }
746
747    false
748}
749
750/// Check if a node is a reference to `ffi.C`
751fn is_ffi_c_reference(node: Node, content: &[u8], aliases: &FfiAliasTable) -> bool {
752    if node.kind() != "dot_index_expression" {
753        return false;
754    }
755
756    let Some(table_node) = node.child_by_field_name("table") else {
757        return false;
758    };
759    let Some(field_node) = node.child_by_field_name("field") else {
760        return false;
761    };
762
763    let Ok(table_text) = table_node.utf8_text(content) else {
764        return false;
765    };
766    let Ok(field_text) = field_node.utf8_text(content) else {
767        return false;
768    };
769
770    let table_text = table_text.trim();
771    let field_text = field_text.trim();
772
773    // Check if table is an alias to ffi module and field is "C"
774    aliases.get(table_text) == Some(&FfiEntity::Module) && field_text == "C"
775}
776
777/// Extract library name from `ffi.load("library")` call
778fn extract_ffi_load_library(node: Node, content: &[u8], aliases: &FfiAliasTable) -> Option<String> {
779    if node.kind() != "function_call" {
780        return None;
781    }
782
783    // Check if it's ffi.load(...) pattern
784    let name_node = node.child_by_field_name("name")?;
785    if name_node.kind() != "dot_index_expression" {
786        return None;
787    }
788
789    let table_node = name_node.child_by_field_name("table")?;
790    let field_node = name_node.child_by_field_name("field")?;
791
792    let table_text = table_node.utf8_text(content).ok()?;
793    let field_text = field_node.utf8_text(content).ok()?;
794
795    let table_text = table_text.trim();
796    let field_text = field_text.trim();
797
798    // Verify table is ffi module alias and field is "load"
799    if aliases.get(table_text) != Some(&FfiEntity::Module) || field_text != "load" {
800        return None;
801    }
802
803    // Extract library name from first argument
804    let args_node = node.child_by_field_name("arguments")?;
805    let first_arg = args_node.named_child(0)?;
806
807    // Extract string content
808    extract_string_content(first_arg, content)
809}
810
811/// Extract string content from a string node
812fn extract_string_content(string_node: Node, content: &[u8]) -> Option<String> {
813    if string_node.kind() == "string" {
814        // Try to get string_content child first
815        let mut cursor = string_node.walk();
816        for child in string_node.children(&mut cursor) {
817            if child.kind() == "string_content"
818                && let Ok(text) = child.utf8_text(content)
819            {
820                return Some(text.trim().to_string());
821            }
822        }
823
824        // Fallback: try to parse the string node itself
825        if let Ok(text) = string_node.utf8_text(content) {
826            let trimmed = text
827                .trim()
828                .trim_start_matches(['"', '\'', '['])
829                .trim_end_matches(['"', '\'', ']'])
830                .to_string();
831            if !trimmed.is_empty() {
832                return Some(trimmed);
833            }
834        }
835    }
836    None
837}
838
839/// Extract FFI call information from a function call node
840fn extract_ffi_call_info(
841    call_node: Node,
842    content: &[u8],
843    aliases: &FfiAliasTable,
844) -> Option<FfiCallInfo> {
845    let name_node = call_node.child_by_field_name("name")?;
846
847    // Handle different call patterns
848    match name_node.kind() {
849        "dot_index_expression" => {
850            // First check if this is ffi.load(...) - emit edge for the load call
851            if is_ffi_load_call(name_node, content, aliases) {
852                return extract_ffi_load_call_info(call_node, content);
853            }
854            // Then handle normal FFI call patterns
855            extract_ffi_from_dot_expression(name_node, content, aliases)
856        }
857        _ => None,
858    }
859}
860
861/// Check if a dot expression is ffi.load(...)
862fn is_ffi_load_call(dot_expr: Node, content: &[u8], aliases: &FfiAliasTable) -> bool {
863    let Some(table_node) = dot_expr.child_by_field_name("table") else {
864        return false;
865    };
866    let Some(field_node) = dot_expr.child_by_field_name("field") else {
867        return false;
868    };
869
870    let Ok(table_text) = table_node.utf8_text(content) else {
871        return false;
872    };
873    let Ok(field_text) = field_node.utf8_text(content) else {
874        return false;
875    };
876
877    aliases.get(table_text.trim()) == Some(&FfiEntity::Module) && field_text.trim() == "load"
878}
879
880/// Extract `FfiCallInfo` from ffi.load("mylib") call
881fn extract_ffi_load_call_info(call_node: Node, content: &[u8]) -> Option<FfiCallInfo> {
882    let args_node = call_node.child_by_field_name("arguments")?;
883    let first_arg = args_node.named_child(0)?;
884
885    let lib_name = extract_string_content(first_arg, content)?;
886
887    // Create FfiCallInfo for the load call
888    // Target: native::<library> (library_name is None so it formats as native::{function_name})
889    Some(FfiCallInfo {
890        function_name: lib_name,
891        library_name: None,
892    })
893}
894
895/// Extract FFI info from dot index expression (e.g., ffi.C.printf, C.malloc, lib.func)
896fn extract_ffi_from_dot_expression(
897    dot_expr: Node,
898    content: &[u8],
899    aliases: &FfiAliasTable,
900) -> Option<FfiCallInfo> {
901    let table_node = dot_expr.child_by_field_name("table")?;
902    let field_node = dot_expr.child_by_field_name("field")?;
903
904    let function_name = field_node.utf8_text(content).ok()?.trim().to_string();
905
906    // Check if table is nested dot expression (ffi.C.func)
907    if table_node.kind() == "dot_index_expression" {
908        let inner_table = table_node.child_by_field_name("table")?;
909        let inner_field = table_node.child_by_field_name("field")?;
910
911        let base_text = inner_table.utf8_text(content).ok()?.trim();
912        let mid_text = inner_field.utf8_text(content).ok()?.trim();
913
914        // Pattern: ffi.C.function
915        if aliases.get(base_text) == Some(&FfiEntity::Module) && mid_text == "C" {
916            return Some(FfiCallInfo {
917                function_name,
918                library_name: None,
919            });
920        }
921    } else {
922        // Pattern: C.function or lib.function (direct alias)
923        let table_text = table_node.utf8_text(content).ok()?.trim();
924
925        match aliases.get(table_text) {
926            Some(FfiEntity::CLibrary) => {
927                return Some(FfiCallInfo {
928                    function_name,
929                    library_name: None,
930                });
931            }
932            Some(FfiEntity::LoadedLibrary(lib_name)) => {
933                return Some(FfiCallInfo {
934                    function_name,
935                    library_name: Some(lib_name.clone()),
936                });
937            }
938            _ => {}
939        }
940    }
941
942    None
943}
944
945/// Emit an FFI edge for a detected FFI call
946fn emit_ffi_edge(
947    ffi_info: FfiCallInfo,
948    call_node: Node,
949    content: &[u8],
950    ast_graph: &ASTGraph,
951    helper: &mut GraphBuildHelper,
952) {
953    // Get caller (enclosing function or file-level)
954    let caller_id = get_ffi_caller_node_id(call_node, content, ast_graph, helper);
955
956    // Build target name
957    let target_name = if let Some(lib) = ffi_info.library_name {
958        format!("native::{}::{}", lib, ffi_info.function_name)
959    } else {
960        format!("native::{}", ffi_info.function_name)
961    };
962
963    // Ensure target node exists
964    let target_id = helper.ensure_callee(
965        &target_name,
966        span_from_node(call_node),
967        CalleeKindHint::Function,
968    );
969
970    // Add FfiCall edge with C convention
971    helper.add_ffi_edge(caller_id, target_id, FfiConvention::C);
972}
973
974/// Get the caller node ID for an FFI call (enclosing function or file-level)
975fn get_ffi_caller_node_id(
976    call_node: Node,
977    content: &[u8],
978    ast_graph: &ASTGraph,
979    helper: &mut GraphBuildHelper,
980) -> sqry_core::graph::unified::node::NodeId {
981    let call_span = span_from_node(call_node);
982    // Try to get context from AST graph
983    if let Some(call_context) = ast_graph.get_callable_context(call_node.id()) {
984        if call_context.is_method {
985            return helper.ensure_method(&call_context.qualified_name, None, false, false);
986        }
987        return helper.ensure_callee(
988            &call_context.qualified_name,
989            call_span,
990            CalleeKindHint::Function,
991        );
992    }
993
994    // Walk up tree to find enclosing function
995    let mut current = call_node.parent();
996    while let Some(node) = current {
997        if node.kind() == "function_declaration" || node.kind() == "function_definition" {
998            // Extract function name and ensure node exists
999            if let Some(name_node) = node.child_by_field_name("name")
1000                && let Ok(name_text) = name_node.utf8_text(content)
1001            {
1002                return helper.ensure_callee(
1003                    name_text,
1004                    span_from_node(node),
1005                    CalleeKindHint::Function,
1006                );
1007            }
1008        }
1009        current = node.parent();
1010    }
1011
1012    // Fallback: file-level context
1013    helper.ensure_callee("<file_level>", call_span, CalleeKindHint::Function)
1014}
1015
1016/// Build call edge information for the staging graph.
1017fn build_call_for_staging(
1018    ast_graph: &ASTGraph,
1019    call_node: Node<'_>,
1020    content: &[u8],
1021) -> GraphResult<Option<(String, String, usize, Span)>> {
1022    // Get or create module-level context for top-level calls
1023    let module_context;
1024    let call_context = if let Some(ctx) = ast_graph.get_callable_context(call_node.id()) {
1025        ctx
1026    } else {
1027        // Create synthetic module-level context for top-level calls
1028        module_context = CallContext {
1029            qualified_name: "<module>".to_string(),
1030            span: (0, content.len()),
1031            is_method: false,
1032            module_name: None,
1033        };
1034        &module_context
1035    };
1036
1037    // Extract the call target (the function being called)
1038    let Some(name_node) = call_node.child_by_field_name("name") else {
1039        return Ok(None);
1040    };
1041
1042    let callee_text = name_node
1043        .utf8_text(content)
1044        .map_err(|_| GraphBuilderError::ParseError {
1045            span: span_from_node(call_node),
1046            reason: "failed to read call expression".to_string(),
1047        })?
1048        .trim()
1049        .to_string();
1050
1051    if callee_text.is_empty() {
1052        return Ok(None);
1053    }
1054
1055    // Extract qualified callee name based on call syntax, resolving self if needed
1056    let mut target_qualified = extract_call_target(name_node, content, call_context)?;
1057
1058    // CRITICAL: Resolve bare identifiers against the current scope
1059    if !target_qualified.contains("::") {
1060        // Try to resolve in the parent scope of the caller
1061        let scoped_name = if call_context.qualified_name == "<module>" {
1062            target_qualified.clone()
1063        } else {
1064            format!("{}::{}", call_context.qualified_name, &target_qualified)
1065        };
1066
1067        // Check if this scoped name exists in the AST graph
1068        if ast_graph
1069            .contexts()
1070            .iter()
1071            .any(|ctx| ctx.qualified_name == scoped_name)
1072        {
1073            target_qualified = scoped_name;
1074        }
1075        // If not found in immediate scope, also try sibling scope (parent's scope)
1076        else if let Some(parent_scope) = extract_parent_scope(&call_context.qualified_name) {
1077            let sibling_name = format!("{}::{}", parent_scope, &target_qualified);
1078            if ast_graph
1079                .contexts()
1080                .iter()
1081                .any(|ctx| ctx.qualified_name == sibling_name)
1082            {
1083                target_qualified = sibling_name;
1084            }
1085        }
1086    }
1087
1088    let source_qualified = call_context.qualified_name();
1089
1090    let span = span_from_node(call_node);
1091    let argument_count = count_arguments(call_node);
1092
1093    Ok(Some((
1094        source_qualified,
1095        target_qualified,
1096        argument_count,
1097        span,
1098    )))
1099}
1100
1101// ============================================================================
1102// Helper Functions (extracted from old implementation, now used with GraphBuildHelper)
1103// ============================================================================
1104
1105/// Extract the qualified call target from a function call name node
1106fn extract_call_target(
1107    name_node: Node<'_>,
1108    content: &[u8],
1109    call_context: &CallContext,
1110) -> GraphResult<String> {
1111    match name_node.kind() {
1112        "identifier" => {
1113            // Simple call: foo()
1114            // Just return the bare name - scoped resolution will happen in build_call_edge
1115            get_node_text(name_node, content)
1116        }
1117        "dot_index_expression" => {
1118            // Module.function() or obj.method()
1119            flatten_dotted_name(name_node, content)
1120        }
1121        "method_index_expression" => {
1122            // obj:method() (colon syntax with implicit self)
1123            flatten_method_name(name_node, content, call_context)
1124        }
1125        "bracket_index_expression" => {
1126            // table["field"]() style access
1127            flatten_bracket_name(name_node, content, call_context)
1128        }
1129        "function_call" => {
1130            // Chained call: getFn()()
1131            // Use the whole expression as the target
1132            get_node_text(name_node, content)
1133        }
1134        _ => get_node_text(name_node, content),
1135    }
1136}
1137
1138/// Flatten a `dot_index_expression` (Module.function) into `Module::function`
1139fn flatten_dotted_name(node: Node<'_>, content: &[u8]) -> GraphResult<String> {
1140    let table = node
1141        .child_by_field_name("table")
1142        .ok_or_else(|| GraphBuilderError::ParseError {
1143            span: span_from_node(node),
1144            reason: "dot_index_expression missing table".to_string(),
1145        })?;
1146    let field = node
1147        .child_by_field_name("field")
1148        .ok_or_else(|| GraphBuilderError::ParseError {
1149            span: span_from_node(node),
1150            reason: "dot_index_expression missing field".to_string(),
1151        })?;
1152
1153    let table_text = collect_table_path(table, content)?;
1154    let field_text = get_node_text(field, content)?;
1155
1156    Ok(format!("{table_text}::{field_text}"))
1157}
1158
1159/// Flatten a `method_index_expression` (Module:method) into `Module::method`
1160/// Resolves `self` to the containing module name if available
1161fn flatten_method_name(
1162    node: Node<'_>,
1163    content: &[u8],
1164    call_context: &CallContext,
1165) -> GraphResult<String> {
1166    let table = node
1167        .child_by_field_name("table")
1168        .ok_or_else(|| GraphBuilderError::ParseError {
1169            span: span_from_node(node),
1170            reason: "method_index_expression missing table".to_string(),
1171        })?;
1172    let method =
1173        node.child_by_field_name("method")
1174            .ok_or_else(|| GraphBuilderError::ParseError {
1175                span: span_from_node(node),
1176                reason: "method_index_expression missing method".to_string(),
1177            })?;
1178
1179    let mut table_text = collect_table_path(table, content)?;
1180    let method_text = get_node_text(method, content)?;
1181
1182    // Resolve `self` to the containing module name
1183    if table_text == "self"
1184        && let Some(ref module_name) = call_context.module_name
1185    {
1186        table_text.clone_from(module_name);
1187    }
1188    // If no module_name, keep "self" as-is (might be a top-level method)
1189
1190    Ok(format!("{table_text}::{method_text}"))
1191}
1192
1193/// Flatten a `bracket_index_expression` (e.g. `Module["method"]`) into `Module::method`
1194fn flatten_bracket_name(
1195    node: Node<'_>,
1196    content: &[u8],
1197    call_context: &CallContext,
1198) -> GraphResult<String> {
1199    let table = node
1200        .child_by_field_name("table")
1201        .ok_or_else(|| GraphBuilderError::ParseError {
1202            span: span_from_node(node),
1203            reason: "bracket_index_expression missing table".to_string(),
1204        })?;
1205    let field = node
1206        .child_by_field_name("field")
1207        .ok_or_else(|| GraphBuilderError::ParseError {
1208            span: span_from_node(node),
1209            reason: "bracket_index_expression missing field".to_string(),
1210        })?;
1211
1212    let mut table_text = collect_table_path(table, content)?;
1213    if table_text == "self"
1214        && let Some(ref module_name) = call_context.module_name
1215    {
1216        table_text.clone_from(module_name);
1217    }
1218
1219    let field_text = normalize_field_value(field, content)?;
1220
1221    Ok(format!("{table_text}::{field_text}"))
1222}
1223
1224/// Recursively collect table path for nested expressions (A.B.C -> `A::B::C`)
1225fn collect_table_path(node: Node<'_>, content: &[u8]) -> GraphResult<String> {
1226    match node.kind() {
1227        "bracket_index_expression" => {
1228            let table =
1229                node.child_by_field_name("table")
1230                    .ok_or_else(|| GraphBuilderError::ParseError {
1231                        span: span_from_node(node),
1232                        reason: "bracket_index_expression missing table".to_string(),
1233                    })?;
1234            let field =
1235                node.child_by_field_name("field")
1236                    .ok_or_else(|| GraphBuilderError::ParseError {
1237                        span: span_from_node(node),
1238                        reason: "bracket_index_expression missing field".to_string(),
1239                    })?;
1240
1241            let table_text = collect_table_path(table, content)?;
1242            let field_text = normalize_field_value(field, content)?;
1243
1244            Ok(format!("{table_text}::{field_text}"))
1245        }
1246        "dot_index_expression" => {
1247            let table =
1248                node.child_by_field_name("table")
1249                    .ok_or_else(|| GraphBuilderError::ParseError {
1250                        span: span_from_node(node),
1251                        reason: "nested dot_index_expression missing table".to_string(),
1252                    })?;
1253            let field =
1254                node.child_by_field_name("field")
1255                    .ok_or_else(|| GraphBuilderError::ParseError {
1256                        span: span_from_node(node),
1257                        reason: "nested dot_index_expression missing field".to_string(),
1258                    })?;
1259
1260            let table_text = collect_table_path(table, content)?;
1261            let field_text = get_node_text(field, content)?;
1262
1263            Ok(format!("{table_text}::{field_text}"))
1264        }
1265        _ => get_node_text(node, content),
1266    }
1267}
1268
1269/// Get text content of a node
1270fn get_node_text(node: Node<'_>, content: &[u8]) -> GraphResult<String> {
1271    node.utf8_text(content)
1272        .map(|text| text.trim().to_string())
1273        .map_err(|_| GraphBuilderError::ParseError {
1274            span: span_from_node(node),
1275            reason: "failed to read node text".to_string(),
1276        })
1277}
1278
1279/// Create a Span from a tree-sitter node
1280fn span_from_node(node: Node<'_>) -> Span {
1281    let start = node.start_position();
1282    let end = node.end_position();
1283    Span::new(
1284        Position::new(start.row, start.column),
1285        Position::new(end.row, end.column),
1286    )
1287}
1288
1289/// Count the number of arguments in a function call
1290fn count_arguments(call_node: Node<'_>) -> usize {
1291    call_node
1292        .child_by_field_name("arguments")
1293        .map_or(0, |args| {
1294            args.named_children(&mut args.walk())
1295                .filter(|child| !matches!(child.kind(), "," | "(" | ")"))
1296                .count()
1297        })
1298}
1299
1300/// Extract parent scope from a qualified name
1301/// e.g., "`outer::inner::deep`" -> `Some("outer::inner`")
1302/// e.g., "outer" -> None
1303fn extract_parent_scope(qualified_name: &str) -> Option<String> {
1304    let parts: Vec<&str> = qualified_name.split("::").collect();
1305    if parts.len() > 1 {
1306        Some(parts[..parts.len() - 1].join("::"))
1307    } else {
1308        None
1309    }
1310}
1311
1312fn normalize_field_value(node: Node<'_>, content: &[u8]) -> GraphResult<String> {
1313    normalize_field_value_simple(node, content).map_err(|reason| GraphBuilderError::ParseError {
1314        span: span_from_node(node),
1315        reason,
1316    })
1317}
1318
1319fn normalize_field_value_simple(node: Node<'_>, content: &[u8]) -> Result<String, String> {
1320    let raw = node
1321        .utf8_text(content)
1322        .map_err(|_| "failed to read field value".to_string())?
1323        .trim()
1324        .to_string();
1325
1326    if raw.is_empty() {
1327        return Err("empty field value".to_string());
1328    }
1329
1330    match node.kind() {
1331        "string" => Ok(strip_string_literal(&raw)),
1332        _ => Ok(raw),
1333    }
1334}
1335
1336fn strip_string_literal(raw: &str) -> String {
1337    if raw.is_empty() {
1338        return raw.to_string();
1339    }
1340
1341    let bytes = raw.as_bytes();
1342    if (bytes[0] == b'"' && bytes[bytes.len() - 1] == b'"')
1343        || (bytes[0] == b'\'' && bytes[bytes.len() - 1] == b'\'')
1344    {
1345        return raw[1..raw.len() - 1].to_string();
1346    }
1347
1348    if raw.starts_with('[') && raw.ends_with(']') {
1349        let mut start = 1usize;
1350        while start < raw.len() && raw.as_bytes()[start] == b'=' {
1351            start += 1;
1352        }
1353        if start < raw.len() && raw.as_bytes()[start] == b'[' {
1354            let mut end = raw.len() - 1;
1355            while end > 0 && raw.as_bytes()[end - 1] == b'=' {
1356                end -= 1;
1357            }
1358            if end > start + 1 {
1359                return raw[start + 1..end - 1].to_string();
1360            }
1361        }
1362    }
1363
1364    raw.to_string()
1365}
1366
1367fn strip_env_prefix(name: String) -> String {
1368    name.strip_prefix("_ENV::")
1369        .map(std::string::ToString::to_string)
1370        .unwrap_or(name)
1371}
1372
1373/// Build property nodes for table constructor fields
1374fn build_table_fields(
1375    table_node: Node<'_>,
1376    content: &[u8],
1377    helper: &mut GraphBuildHelper,
1378) -> GraphResult<()> {
1379    let mut cursor = table_node.walk();
1380
1381    for child in table_node.children(&mut cursor) {
1382        if child.kind() != "field" {
1383            continue;
1384        }
1385
1386        // Extract field name
1387        if let Some(name_node) = child.child_by_field_name("name") {
1388            let field_name = name_node
1389                .utf8_text(content)
1390                .map_err(|_| GraphBuilderError::ParseError {
1391                    span: span_from_node(child),
1392                    reason: "failed to read table field name".to_string(),
1393                })?
1394                .trim();
1395
1396            // Create property node for the field
1397            let span = span_from_node(child);
1398            helper.add_node(field_name, Some(span), NodeKind::Property);
1399        }
1400    }
1401
1402    Ok(())
1403}
1404
1405/// Build property nodes for field access expressions
1406#[allow(clippy::unnecessary_wraps)]
1407fn build_field_access(
1408    access_node: Node<'_>,
1409    content: &[u8],
1410    helper: &mut GraphBuildHelper,
1411) -> GraphResult<()> {
1412    // Extract field name based on access type
1413    let field_name = match access_node.kind() {
1414        "dot_index_expression" => {
1415            // table.field
1416            access_node
1417                .child_by_field_name("field")
1418                .and_then(|n| n.utf8_text(content).ok())
1419                .map(|s| s.trim().to_string())
1420        }
1421        "bracket_index_expression" => {
1422            // table["field"] or table[expression]
1423            access_node.child_by_field_name("field").and_then(|n| {
1424                // Try to extract string literal
1425                if n.kind() == "string" {
1426                    n.utf8_text(content)
1427                        .ok()
1428                        .map(|s| s.trim().trim_matches(|c| c == '"' || c == '\'').to_string())
1429                } else {
1430                    // For dynamic keys, use the expression text
1431                    n.utf8_text(content).ok().map(|s| s.trim().to_string())
1432                }
1433            })
1434        }
1435        _ => None,
1436    };
1437
1438    if let Some(name) = field_name {
1439        // Create property node for the field access
1440        let span = span_from_node(access_node);
1441        helper.add_node(&name, Some(span), NodeKind::Property);
1442    }
1443
1444    Ok(())
1445}
1446
1447// ============================================================================
1448// AST Graph - tracks callable contexts (functions, methods, closures)
1449// ============================================================================
1450
1451#[derive(Debug, Clone)]
1452struct CallContext {
1453    qualified_name: String,
1454    #[allow(dead_code)] // Reserved for scope analysis
1455    span: (usize, usize),
1456    is_method: bool,
1457    /// Module/class name for resolving self (e.g., "`MyModule`" from "`MyModule::method`")
1458    module_name: Option<String>,
1459}
1460
1461impl CallContext {
1462    fn qualified_name(&self) -> String {
1463        self.qualified_name.clone()
1464    }
1465}
1466
1467struct ASTGraph {
1468    contexts: Vec<CallContext>,
1469    node_to_context: HashMap<usize, usize>,
1470}
1471
1472impl ASTGraph {
1473    fn from_tree(tree: &Tree, content: &[u8], max_depth: usize) -> Result<Self, String> {
1474        let mut contexts = Vec::new();
1475        let mut node_to_context = HashMap::new();
1476
1477        let mut state = WalkerState::new(&mut contexts, &mut node_to_context, max_depth);
1478
1479        walk_ast(tree.root_node(), content, &mut state)?;
1480
1481        Ok(Self {
1482            contexts,
1483            node_to_context,
1484        })
1485    }
1486
1487    fn contexts(&self) -> &[CallContext] {
1488        &self.contexts
1489    }
1490
1491    fn get_callable_context(&self, node_id: usize) -> Option<&CallContext> {
1492        self.node_to_context
1493            .get(&node_id)
1494            .and_then(|idx| self.contexts.get(*idx))
1495    }
1496}
1497
1498/// State passed through AST walking to track scope and context
1499struct WalkerState<'a> {
1500    contexts: &'a mut Vec<CallContext>,
1501    node_to_context: &'a mut HashMap<usize, usize>,
1502    parent_qualified: Option<String>,
1503    module_context: Option<String>,
1504    lexical_depth: usize,
1505    max_depth: usize,
1506}
1507
1508impl<'a> WalkerState<'a> {
1509    fn new(
1510        contexts: &'a mut Vec<CallContext>,
1511        node_to_context: &'a mut HashMap<usize, usize>,
1512        max_depth: usize,
1513    ) -> Self {
1514        Self {
1515            contexts,
1516            node_to_context,
1517            parent_qualified: None,
1518            module_context: None,
1519            lexical_depth: 0,
1520            max_depth,
1521        }
1522    }
1523}
1524
1525/// Walk the AST to find function definitions and build context mappings
1526fn walk_ast(node: Node, content: &[u8], state: &mut WalkerState) -> Result<(), String> {
1527    // CRITICAL FIX #5: Check lexical depth, not namespace depth
1528    // lexical_depth tracks actual function nesting, excluding module namespace segments
1529    if state.lexical_depth > state.max_depth {
1530        return Ok(());
1531    }
1532
1533    match node.kind() {
1534        "local_function" => {
1535            // Handle: local function foo() end
1536            handle_local_function(node, content, state)?;
1537        }
1538        "function_declaration" => {
1539            handle_function_declaration(node, content, state)?;
1540        }
1541        "assignment_statement" => {
1542            // Handle: local foo = function() end
1543            handle_function_assignment(node, content, state)?;
1544        }
1545        _ => {
1546            // Recurse into other nodes
1547            let mut cursor = node.walk();
1548            for child in node.children(&mut cursor) {
1549                walk_ast(child, content, state)?;
1550            }
1551        }
1552    }
1553
1554    Ok(())
1555}
1556
1557/// Handle `local_function` nodes
1558fn handle_local_function(
1559    node: Node,
1560    content: &[u8],
1561    state: &mut WalkerState,
1562) -> Result<(), String> {
1563    let name_node = node
1564        .child_by_field_name("name")
1565        .ok_or_else(|| "local_function missing name".to_string())?;
1566
1567    // Get the function name (local functions are always simple identifiers)
1568    let base_name = name_node
1569        .utf8_text(content)
1570        .map_err(|_| "failed to read local function name".to_string())?
1571        .to_string();
1572
1573    // Build qualified name with parent scope
1574    let qualified_name = if let Some(parent) = state.parent_qualified.as_ref() {
1575        format!("{parent}::{base_name}")
1576    } else {
1577        base_name
1578    };
1579
1580    // Record this function as a callable context
1581    let context_idx = state.contexts.len();
1582    state.contexts.push(CallContext {
1583        qualified_name: qualified_name.clone(),
1584        span: (node.start_byte(), node.end_byte()),
1585        is_method: false, // Local functions are never methods
1586        module_name: state.module_context.clone(),
1587    });
1588
1589    // Map all descendant nodes to this context
1590    map_descendants_to_context(node, state.node_to_context, context_idx);
1591
1592    // Save the current parent and module context
1593    let saved_parent = state.parent_qualified.clone();
1594    let saved_module_context = state.module_context.clone();
1595
1596    // Update parent for nested functions
1597    state.parent_qualified = Some(qualified_name);
1598
1599    // Increment lexical depth for nested functions
1600    state.lexical_depth += 1;
1601
1602    #[allow(clippy::cast_possible_truncation)] // Graph storage: node/edge index counts fit in u32
1603    // Recurse into function body
1604    if let Some(body) = node.named_child(node.named_child_count().saturating_sub(1) as u32) {
1605        walk_ast(body, content, state)?;
1606    }
1607
1608    // Restore the previous lexical depth, parent, and module context
1609    state.lexical_depth -= 1;
1610    state.parent_qualified = saved_parent;
1611    state.module_context = saved_module_context;
1612
1613    Ok(())
1614}
1615
1616/// Handle `function_declaration` nodes
1617fn handle_function_declaration(
1618    node: Node,
1619    content: &[u8],
1620    state: &mut WalkerState,
1621) -> Result<(), String> {
1622    let name_node = node
1623        .child_by_field_name("name")
1624        .ok_or_else(|| "function_declaration missing name".to_string())?;
1625
1626    // Get the base name from the AST node
1627    let (base_name, is_method) = extract_function_base_name(name_node, content)?;
1628
1629    // Build qualified name: if there's a parent, prefix it; otherwise use base name
1630    // CRITICAL FIX #6: Don't prefix if base_name is already fully qualified
1631    // (e.g., "MyModule::inner" defined inside another function)
1632    let qualified_name = if base_name.contains("::") {
1633        base_name.clone()
1634    } else if let Some(parent) = state.parent_qualified.as_ref() {
1635        format!("{parent}::{base_name}")
1636    } else {
1637        base_name.clone()
1638    };
1639    let qualified_name = strip_env_prefix(qualified_name);
1640
1641    // CRITICAL FIX #4: Module name should be inherited from parent context for nested functions,
1642    // or extracted from the qualified name only for top-level methods
1643    let module_name = if is_method {
1644        // For a method like "MyModule:foo", extract the module part
1645        if qualified_name.contains("::") {
1646            let parts: Vec<&str> = qualified_name.split("::").collect();
1647            if parts.len() > 1 {
1648                Some(parts[..parts.len() - 1].join("::"))
1649            } else {
1650                None
1651            }
1652        } else {
1653            None
1654        }
1655    } else {
1656        // For non-methods (regular functions), inherit the module context from parent
1657        state.module_context.clone()
1658    };
1659
1660    // Record this function as a callable context
1661    let context_idx = state.contexts.len();
1662    state.contexts.push(CallContext {
1663        qualified_name: qualified_name.clone(),
1664        span: (node.start_byte(), node.end_byte()),
1665        is_method,
1666        module_name: module_name.clone(),
1667    });
1668
1669    // Map all descendant nodes to this context
1670    map_descendants_to_context(node, state.node_to_context, context_idx);
1671
1672    // Save the current parent and module context
1673    let saved_parent = state.parent_qualified.clone();
1674    let saved_module_context = state.module_context.clone();
1675
1676    // Update parent for nested functions
1677    state.parent_qualified = Some(qualified_name);
1678
1679    // Update module context if this is a method
1680    if is_method && module_name.is_some() {
1681        state.module_context = module_name;
1682    }
1683
1684    // CRITICAL FIX #5: Increment lexical depth for nested functions
1685    state.lexical_depth += 1;
1686
1687    // Recurse into function body
1688    #[allow(clippy::cast_possible_truncation)] // tree-sitter child count fits in u32
1689    if let Some(body) = node.named_child(node.named_child_count().saturating_sub(1) as u32) {
1690        walk_ast(body, content, state)?;
1691    }
1692
1693    // Restore the previous lexical depth, parent, and module context
1694    state.lexical_depth -= 1;
1695    state.parent_qualified = saved_parent;
1696    state.module_context = saved_module_context;
1697
1698    Ok(())
1699}
1700
1701/// Handle `assignment_statement` nodes that assign functions
1702fn handle_function_assignment(
1703    node: Node,
1704    content: &[u8],
1705    state: &mut WalkerState,
1706) -> Result<(), String> {
1707    // Check if this is a function assignment
1708    let Some(expr_list) = node
1709        .children(&mut node.walk())
1710        .find(|child| child.kind() == "expression_list")
1711    else {
1712        return Ok(());
1713    };
1714
1715    let Some(func_def) = expr_list
1716        .named_children(&mut expr_list.walk())
1717        .find(|child| child.kind() == "function_definition")
1718    else {
1719        return Ok(());
1720    };
1721
1722    // Extract the variable name
1723    let Some(var_list) = node
1724        .children(&mut node.walk())
1725        .find(|child| child.kind() == "variable_list")
1726    else {
1727        return Ok(());
1728    };
1729
1730    let Some(var_node) = var_list.named_child(0) else {
1731        return Ok(());
1732    };
1733
1734    // Get the base name from the AST node
1735    let (base_name, is_method) = extract_assignment_base_name(var_node, content)?;
1736
1737    // Build qualified name: if there's a parent, prefix it; otherwise use base name
1738    // CRITICAL FIX #6: Don't prefix if base_name is already fully qualified
1739    let qualified_name = if base_name.contains("::") {
1740        base_name.clone()
1741    } else if let Some(parent) = state.parent_qualified.as_ref() {
1742        format!("{parent}::{base_name}")
1743    } else {
1744        base_name.clone()
1745    };
1746    let qualified_name = strip_env_prefix(qualified_name);
1747
1748    // CRITICAL FIX #4: Module name should be inherited from parent context for nested functions,
1749    // or extracted from the qualified name only for top-level methods
1750    let module_name = if is_method {
1751        // For a method like "MyModule:foo", extract the module part
1752        if qualified_name.contains("::") {
1753            let parts: Vec<&str> = qualified_name.split("::").collect();
1754            if parts.len() > 1 {
1755                Some(parts[..parts.len() - 1].join("::"))
1756            } else {
1757                None
1758            }
1759        } else {
1760            None
1761        }
1762    } else {
1763        // For non-methods (regular functions), inherit the module context from parent
1764        state.module_context.clone()
1765    };
1766
1767    // Record this function as a callable context
1768    let context_idx = state.contexts.len();
1769    state.contexts.push(CallContext {
1770        qualified_name: qualified_name.clone(),
1771        span: (func_def.start_byte(), func_def.end_byte()),
1772        is_method,
1773        module_name: module_name.clone(),
1774    });
1775
1776    // Map all descendant nodes to this context
1777    map_descendants_to_context(func_def, state.node_to_context, context_idx);
1778
1779    // Save the current parent and module context
1780    let saved_parent = state.parent_qualified.clone();
1781    let saved_module_context = state.module_context.clone();
1782
1783    // Update parent for nested functions
1784    state.parent_qualified = Some(qualified_name);
1785
1786    // Update module context if this is a method
1787    if is_method && module_name.is_some() {
1788        state.module_context = module_name;
1789    }
1790
1791    // CRITICAL FIX #5: Increment lexical depth for nested functions
1792    state.lexical_depth += 1;
1793    // Recurse into function body
1794    #[allow(clippy::cast_possible_truncation)] // tree-sitter child count fits in u32
1795    if let Some(body) = func_def.named_child(func_def.named_child_count().saturating_sub(1) as u32)
1796    {
1797        walk_ast(body, content, state)?;
1798    }
1799
1800    // Restore the previous lexical depth, parent, and module context
1801    state.lexical_depth -= 1;
1802    state.parent_qualified = saved_parent;
1803    state.module_context = saved_module_context;
1804
1805    Ok(())
1806}
1807
1808/// Extract base name from a `function_declaration` name node (without parent context)
1809/// Returns (`base_name`, `is_method`)
1810fn extract_function_base_name(
1811    name_node: Node<'_>,
1812    content: &[u8],
1813) -> Result<(String, bool), String> {
1814    match name_node.kind() {
1815        "identifier" => {
1816            // function foo() end OR local function foo() end
1817            let name = name_node
1818                .utf8_text(content)
1819                .map_err(|_| "failed to read function name".to_string())?;
1820            Ok((name.to_string(), false))
1821        }
1822        "dot_index_expression" => {
1823            // function Module.foo() end
1824            let table = name_node
1825                .child_by_field_name("table")
1826                .ok_or_else(|| "dot_index_expression missing table".to_string())?;
1827            let field = name_node
1828                .child_by_field_name("field")
1829                .ok_or_else(|| "dot_index_expression missing field".to_string())?;
1830
1831            let table_text = collect_table_path_simple(table, content)?;
1832            let field_text = field
1833                .utf8_text(content)
1834                .map_err(|_| "failed to read field name".to_string())?;
1835
1836            Ok((format!("{table_text}::{field_text}"), false))
1837        }
1838        "method_index_expression" => {
1839            // function Module:method() end (implicit self parameter)
1840            let table = name_node
1841                .child_by_field_name("table")
1842                .ok_or_else(|| "method_index_expression missing table".to_string())?;
1843            let method = name_node
1844                .child_by_field_name("method")
1845                .ok_or_else(|| "method_index_expression missing method".to_string())?;
1846
1847            let table_text = collect_table_path_simple(table, content)?;
1848            let method_text = method
1849                .utf8_text(content)
1850                .map_err(|_| "failed to read method name".to_string())?;
1851
1852            Ok((format!("{table_text}::{method_text}"), true))
1853        }
1854        "bracket_index_expression" => {
1855            // function Module["foo"]() end
1856            let table = name_node
1857                .child_by_field_name("table")
1858                .ok_or_else(|| "bracket_index_expression missing table".to_string())?;
1859            let field = name_node
1860                .child_by_field_name("field")
1861                .ok_or_else(|| "bracket_index_expression missing field".to_string())?;
1862
1863            let table_text = collect_table_path_simple(table, content)?;
1864            let field_text = normalize_field_value_simple(field, content)?;
1865
1866            Ok((format!("{table_text}::{field_text}"), false))
1867        }
1868        _ => Err(format!(
1869            "unsupported function name kind: {}",
1870            name_node.kind()
1871        )),
1872    }
1873}
1874
1875/// Extract base name from an assignment variable node (without parent context)
1876/// Returns (`base_name`, `is_method`)
1877fn extract_assignment_base_name(
1878    var_node: Node<'_>,
1879    content: &[u8],
1880) -> Result<(String, bool), String> {
1881    match var_node.kind() {
1882        "identifier" => {
1883            // local foo = function() end
1884            let name = var_node
1885                .utf8_text(content)
1886                .map_err(|_| "failed to read identifier".to_string())?;
1887            Ok((name.to_string(), false))
1888        }
1889        "dot_index_expression" => {
1890            // Module.foo = function() end
1891            let table = var_node
1892                .child_by_field_name("table")
1893                .ok_or_else(|| "dot_index_expression missing table".to_string())?;
1894            let field = var_node
1895                .child_by_field_name("field")
1896                .ok_or_else(|| "dot_index_expression missing field".to_string())?;
1897
1898            let table_text = collect_table_path_simple(table, content)?;
1899            let field_text = field
1900                .utf8_text(content)
1901                .map_err(|_| "failed to read field".to_string())?;
1902
1903            Ok((format!("{table_text}::{field_text}"), false))
1904        }
1905        "method_index_expression" => {
1906            // Module:method = function() end
1907            let table = var_node
1908                .child_by_field_name("table")
1909                .ok_or_else(|| "method_index_expression missing table".to_string())?;
1910            let method = var_node
1911                .child_by_field_name("method")
1912                .ok_or_else(|| "method_index_expression missing method".to_string())?;
1913
1914            let table_text = collect_table_path_simple(table, content)?;
1915            let method_text = method
1916                .utf8_text(content)
1917                .map_err(|_| "failed to read method".to_string())?;
1918
1919            Ok((format!("{table_text}::{method_text}"), true))
1920        }
1921        "bracket_index_expression" => {
1922            // Module["foo"] = function() end or commands[1] = function() end
1923            let table = var_node
1924                .child_by_field_name("table")
1925                .ok_or_else(|| "bracket_index_expression missing table".to_string())?;
1926            let field = var_node
1927                .child_by_field_name("field")
1928                .ok_or_else(|| "bracket_index_expression missing field".to_string())?;
1929
1930            let table_text = collect_table_path_simple(table, content)?;
1931            let field_text = normalize_field_value_simple(field, content)?;
1932
1933            Ok((format!("{table_text}::{field_text}"), false))
1934        }
1935        _ => Err(format!(
1936            "unsupported assignment target kind: {}",
1937            var_node.kind()
1938        )),
1939    }
1940}
1941
1942/// Simplified table path collection (doesn't use `GraphResult`)
1943fn collect_table_path_simple(node: Node<'_>, content: &[u8]) -> Result<String, String> {
1944    match node.kind() {
1945        "identifier" => node
1946            .utf8_text(content)
1947            .map(std::string::ToString::to_string)
1948            .map_err(|_| "failed to read identifier".to_string()),
1949        "bracket_index_expression" => {
1950            let table = node
1951                .child_by_field_name("table")
1952                .ok_or_else(|| "bracket_index_expression missing table".to_string())?;
1953            let field = node
1954                .child_by_field_name("field")
1955                .ok_or_else(|| "bracket_index_expression missing field".to_string())?;
1956
1957            let table_text = collect_table_path_simple(table, content)?;
1958            let field_text = normalize_field_value_simple(field, content)?;
1959
1960            Ok(format!("{table_text}::{field_text}"))
1961        }
1962        "dot_index_expression" => {
1963            let table = node
1964                .child_by_field_name("table")
1965                .ok_or_else(|| "dot_index_expression missing table".to_string())?;
1966            let field = node
1967                .child_by_field_name("field")
1968                .ok_or_else(|| "dot_index_expression missing field".to_string())?;
1969
1970            let table_text = collect_table_path_simple(table, content)?;
1971            let field_text = field
1972                .utf8_text(content)
1973                .map_err(|_| "failed to read field".to_string())?;
1974
1975            Ok(format!("{table_text}::{field_text}"))
1976        }
1977        _ => node
1978            .utf8_text(content)
1979            .map(std::string::ToString::to_string)
1980            .map_err(|_| "failed to read node".to_string()),
1981    }
1982}
1983
1984/// Map all descendant nodes to a context index
1985fn map_descendants_to_context(
1986    node: Node,
1987    node_to_context: &mut HashMap<usize, usize>,
1988    context_idx: usize,
1989) {
1990    node_to_context.insert(node.id(), context_idx);
1991
1992    let mut cursor = node.walk();
1993    for child in node.children(&mut cursor) {
1994        map_descendants_to_context(child, node_to_context, context_idx);
1995    }
1996}
1997
1998#[cfg(test)]
1999mod tests {
2000    use super::*;
2001    use crate::LuaPlugin;
2002    use sqry_core::graph::unified::build::StagingOp;
2003    use sqry_core::graph::unified::build::test_helpers::*;
2004    use sqry_core::graph::unified::edge::EdgeKind as UnifiedEdgeKind;
2005    use sqry_core::plugin::LanguagePlugin;
2006    use std::path::PathBuf;
2007
2008    /// Helper to extract Import edges from staging operations
2009    fn extract_import_edges(staging: &StagingGraph) -> Vec<&UnifiedEdgeKind> {
2010        staging
2011            .operations()
2012            .iter()
2013            .filter_map(|op| {
2014                if let StagingOp::AddEdge { kind, .. } = op
2015                    && matches!(kind, UnifiedEdgeKind::Imports { .. })
2016                {
2017                    return Some(kind);
2018                }
2019                None
2020            })
2021            .collect()
2022    }
2023
2024    fn parse_lua(source: &str) -> Tree {
2025        let plugin = LuaPlugin::default();
2026        plugin.parse_ast(source.as_bytes()).unwrap()
2027    }
2028
2029    #[test]
2030    fn test_extracts_global_functions() {
2031        let source = r"
2032            function foo()
2033            end
2034
2035            function bar()
2036            end
2037        ";
2038
2039        let tree = parse_lua(source);
2040        let mut staging = StagingGraph::new();
2041        let builder = LuaGraphBuilder::default();
2042        let file = PathBuf::from("test.lua");
2043
2044        builder
2045            .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2046            .unwrap();
2047
2048        assert!(staging.node_count() >= 2);
2049        assert_has_node(&staging, "foo");
2050        assert_has_node(&staging, "bar");
2051    }
2052
2053    #[test]
2054    fn test_creates_call_edges() {
2055        let source = r"
2056            function caller()
2057                callee()
2058            end
2059
2060            function callee()
2061            end
2062        ";
2063
2064        let tree = parse_lua(source);
2065        let mut staging = StagingGraph::new();
2066        let builder = LuaGraphBuilder::default();
2067        let file = PathBuf::from("test.lua");
2068
2069        builder
2070            .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2071            .unwrap();
2072
2073        assert_has_node(&staging, "caller");
2074        assert_has_node(&staging, "callee");
2075
2076        let calls = collect_call_edges(&staging);
2077        assert!(!calls.is_empty(), "Expected at least one call edge");
2078    }
2079
2080    #[test]
2081    fn test_handles_module_methods() {
2082        let source = r"
2083            local MyModule = {}
2084
2085            function MyModule.method1()
2086                MyModule.method2()
2087            end
2088
2089            function MyModule:method2()
2090            end
2091        ";
2092
2093        let tree = parse_lua(source);
2094        let mut staging = StagingGraph::new();
2095        let builder = LuaGraphBuilder::default();
2096        let file = PathBuf::from("test.lua");
2097
2098        builder
2099            .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2100            .unwrap();
2101
2102        assert_has_node(&staging, "method1");
2103        assert_has_node(&staging, "method2");
2104        assert_has_call_edge(&staging, "MyModule::method1", "MyModule::method2");
2105    }
2106
2107    #[test]
2108    fn test_nested_functions_scope_resolution() {
2109        // HIGH FIX #1: Scope stack accumulation
2110        // Verifies that nested functions don't create "outer::outer::inner" paths
2111        let source = r"
2112            function outer()
2113                local function inner()
2114                    local function deep()
2115                    end
2116                end
2117            end
2118        ";
2119
2120        let tree = parse_lua(source);
2121        let mut staging = StagingGraph::new();
2122        let builder = LuaGraphBuilder::default();
2123        let file = PathBuf::from("test.lua");
2124
2125        builder
2126            .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2127            .unwrap();
2128
2129        // Check that nested functions have correct qualified names
2130        assert_has_node(&staging, "outer");
2131        assert_has_node(&staging, "outer::inner");
2132        assert_has_node(&staging, "outer::inner::deep");
2133
2134        // The main fix is verified: nested scopes have correct names
2135        // Note: Local function call resolution (e.g., deep() -> outer::inner::deep)
2136        // is a separate concern and not covered by this HIGH fix
2137    }
2138
2139    #[test]
2140    fn test_self_resolution_in_methods() {
2141        // HIGH FIX #2: Self resolution in method calls
2142        // Verifies that self:method() resolves to Module::method, not self::method
2143        let source = r"
2144            local MyModule = {}
2145
2146            function MyModule:method1()
2147                self:method2()
2148            end
2149
2150            function MyModule:method2()
2151                self:method3()
2152            end
2153
2154            function MyModule:method3()
2155                -- Empty
2156            end
2157        ";
2158
2159        let tree = parse_lua(source);
2160        let mut staging = StagingGraph::new();
2161        let builder = LuaGraphBuilder::default();
2162        let file = PathBuf::from("test.lua");
2163
2164        builder
2165            .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2166            .unwrap();
2167
2168        // Verify all methods exist
2169        assert_has_node(&staging, "MyModule::method1");
2170        assert_has_node(&staging, "MyModule::method2");
2171        assert_has_node(&staging, "MyModule::method3");
2172
2173        // Verify self:method2() resolves to MyModule::method2, not self::method2
2174        assert_has_call_edge(&staging, "MyModule::method1", "MyModule::method2");
2175
2176        // Verify self:method3() resolves correctly
2177        assert_has_call_edge(&staging, "MyModule::method2", "MyModule::method3");
2178    }
2179
2180    #[test]
2181    fn test_local_function_call_resolution() {
2182        // HIGH FIX #3: Local function call resolution
2183        // Verifies that calls to nested/local functions resolve to scoped names,
2184        // not bare identifiers that create synthetic nodes
2185        let source = r"
2186function outer()
2187    local function inner()
2188        local function deep()
2189        end
2190        deep()
2191    end
2192    inner()
2193end
2194";
2195
2196        let tree = parse_lua(source);
2197        let mut staging = StagingGraph::new();
2198        let builder = LuaGraphBuilder::default();
2199        let file = PathBuf::from("test.lua");
2200
2201        builder
2202            .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2203            .unwrap();
2204
2205        // Verify all functions exist with correct qualified names
2206        assert_has_node(&staging, "outer");
2207        assert_has_node(&staging, "outer::inner");
2208        assert_has_node(&staging, "outer::inner::deep");
2209
2210        // CRITICAL: Verify that inner() call from outer resolves to outer::inner
2211        assert_has_call_edge(&staging, "outer", "outer::inner");
2212
2213        // CRITICAL: Verify that deep() call from inner resolves to outer::inner::deep
2214        assert_has_call_edge(&staging, "outer::inner", "outer::inner::deep");
2215    }
2216
2217    #[test]
2218    fn test_nested_helper_self_resolution() {
2219        // HIGH FIX #4: Nested helpers inside colon methods should inherit module context
2220        // Verifies that self:inner() inside a helper defined within MyModule:outer()
2221        // resolves to MyModule::inner, not outer::inner
2222        let source = r"
2223MyModule = {}
2224
2225function MyModule:outer()
2226    local function helper()
2227        self:inner()
2228    end
2229    helper()
2230end
2231
2232function MyModule:inner()
2233end
2234";
2235
2236        let tree = parse_lua(source);
2237        let mut staging = StagingGraph::new();
2238        let builder = LuaGraphBuilder::default();
2239        let file = PathBuf::from("test.lua");
2240
2241        builder
2242            .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2243            .unwrap();
2244
2245        // Verify all functions exist
2246        assert_has_node(&staging, "MyModule::outer");
2247        assert_has_node(&staging, "MyModule::outer::helper");
2248        assert_has_node(&staging, "MyModule::inner");
2249
2250        // CRITICAL: Verify that self:inner() inside helper resolves to MyModule::inner
2251        assert_has_call_edge(&staging, "MyModule::outer::helper", "MyModule::inner");
2252
2253        // Verify outer calls helper
2254        assert_has_call_edge(&staging, "MyModule::outer", "MyModule::outer::helper");
2255    }
2256
2257    #[test]
2258    fn test_bracket_string_key_assignment() {
2259        let source = r#"
2260    local Module = {}
2261
2262    Module["string-key"] = function()
2263        return true
2264    end
2265
2266    local function call_it()
2267        Module["string-key"]()
2268    end
2269    "#;
2270
2271        let tree = parse_lua(source);
2272        let mut staging = StagingGraph::new();
2273        let builder = LuaGraphBuilder::default();
2274        let file = PathBuf::from("test.lua");
2275
2276        builder
2277            .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2278            .unwrap();
2279
2280        // Verify the bracket-indexed function exists
2281        assert_has_node(&staging, "Module::string-key");
2282        assert_has_node(&staging, "call_it");
2283
2284        // Verify the call edge
2285        assert_has_call_edge(&staging, "call_it", "Module::string-key");
2286    }
2287
2288    #[test]
2289    fn test_numeric_command_table_assignment() {
2290        let source = r#"
2291    local commands = {}
2292
2293    commands[1] = function()
2294        return "cmd"
2295    end
2296
2297    local function run()
2298        commands[1]()
2299    end
2300    "#;
2301
2302        let tree = parse_lua(source);
2303        let mut staging = StagingGraph::new();
2304        let builder = LuaGraphBuilder::default();
2305        let file = PathBuf::from("test.lua");
2306
2307        builder
2308            .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2309            .unwrap();
2310
2311        // Verify the numeric-indexed function exists
2312        assert_has_node(&staging, "commands::1");
2313        assert_has_node(&staging, "run");
2314
2315        // Verify the call edge
2316        assert_has_call_edge(&staging, "run", "commands::1");
2317    }
2318
2319    #[test]
2320    fn test_env_driven_name_injection() {
2321        let source = r#"
2322    _ENV["init"] = function()
2323        return true
2324    end
2325
2326    local function boot()
2327        init()
2328    end
2329    "#;
2330
2331        let tree = parse_lua(source);
2332        let mut staging = StagingGraph::new();
2333        let builder = LuaGraphBuilder::default();
2334        let file = PathBuf::from("test.lua");
2335
2336        builder
2337            .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2338            .unwrap();
2339
2340        // Verify the _ENV-prefixed function is stripped to plain "init"
2341        assert_has_node(&staging, "init");
2342        assert_has_node(&staging, "boot");
2343
2344        // Verify the call edge
2345        assert_has_call_edge(&staging, "boot", "init");
2346    }
2347
2348    #[test]
2349    fn test_deep_namespace_lexical_depth() {
2350        // HIGH FIX #5: Lexical depth tracking separate from namespace depth
2351        // Verifies that methods with deep namespace paths don't hit depth limit prematurely
2352        let source = r"
2353Company = {}
2354Company.Product = {}
2355Company.Product.Component = {}
2356
2357function Company.Product.Component:outer()
2358    local function helper1()
2359        local function helper2()
2360            self:inner()
2361        end
2362        helper2()
2363    end
2364    helper1()
2365end
2366
2367function Company.Product.Component:inner()
2368end
2369";
2370
2371        let tree = parse_lua(source);
2372        let mut staging = StagingGraph::new();
2373        let builder = LuaGraphBuilder::default(); // max_scope_depth = 4
2374        let file = PathBuf::from("test.lua");
2375
2376        builder
2377            .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2378            .unwrap();
2379
2380        // Verify all functions exist (including deeply nested ones)
2381        assert_has_node(&staging, "Company::Product::Component::outer");
2382        assert_has_node(&staging, "Company::Product::Component::outer::helper1");
2383        assert_has_node(
2384            &staging,
2385            "Company::Product::Component::outer::helper1::helper2",
2386        );
2387        assert_has_node(&staging, "Company::Product::Component::inner");
2388
2389        // CRITICAL: Verify that self:inner() inside helper2 resolves correctly
2390        // despite deep namespace path (3 namespace segments shouldn't count as lexical depth)
2391        assert_has_call_edge(
2392            &staging,
2393            "Company::Product::Component::outer::helper1::helper2",
2394            "Company::Product::Component::inner",
2395        );
2396
2397        // Verify the helper call chain
2398        assert_has_call_edge(
2399            &staging,
2400            "Company::Product::Component::outer",
2401            "Company::Product::Component::outer::helper1",
2402        );
2403        assert_has_call_edge(
2404            &staging,
2405            "Company::Product::Component::outer::helper1",
2406            "Company::Product::Component::outer::helper1::helper2",
2407        );
2408    }
2409
2410    #[test]
2411    fn test_nested_method_redefinition() {
2412        // HIGH FIX #6: Fully-qualified method redefinition inside another method
2413        // Verifies that "function MyModule:inner()" defined inside MyModule:outer()
2414        // creates MyModule::inner (not MyModule::outer::MyModule::inner)
2415        let source = r"
2416MyModule = {}
2417
2418function MyModule:outer()
2419    function MyModule:inner()
2420        -- redefined inside outer
2421    end
2422    self:inner()
2423end
2424";
2425
2426        let tree = parse_lua(source);
2427        let mut staging = StagingGraph::new();
2428        let builder = LuaGraphBuilder::default();
2429        let file = PathBuf::from("test.lua");
2430
2431        builder
2432            .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2433            .unwrap();
2434
2435        // Verify both functions exist with correct qualified names
2436        assert_has_node(&staging, "MyModule::outer");
2437        assert_has_node(&staging, "MyModule::inner");
2438
2439        // CRITICAL: Verify that self:inner() call from outer resolves to MyModule::inner
2440        assert_has_call_edge(&staging, "MyModule::outer", "MyModule::inner");
2441    }
2442
2443    // ============================================================================
2444    // Import Edge Tests (Wave 7)
2445    // ============================================================================
2446
2447    #[test]
2448    fn test_require_import_edge_double_quotes() {
2449        let source = r#"
2450            local json = require("cjson")
2451        "#;
2452
2453        let tree = parse_lua(source);
2454        let mut staging = StagingGraph::new();
2455        let builder = LuaGraphBuilder::default();
2456        let file = PathBuf::from("test.lua");
2457
2458        builder
2459            .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2460            .unwrap();
2461
2462        let import_edges = extract_import_edges(&staging);
2463        assert!(
2464            !import_edges.is_empty(),
2465            "Expected at least one import edge"
2466        );
2467
2468        // Lua require() returns a module reference, NOT a wildcard import
2469        let edge = import_edges[0];
2470        if let UnifiedEdgeKind::Imports { is_wildcard, .. } = edge {
2471            assert!(
2472                !*is_wildcard,
2473                "Lua require returns module reference, not wildcard"
2474            );
2475        } else {
2476            panic!("Expected Imports edge kind");
2477        }
2478    }
2479
2480    #[test]
2481    fn test_require_import_edge_single_quotes() {
2482        let source = r"
2483            local socket = require('socket')
2484        ";
2485
2486        let tree = parse_lua(source);
2487        let mut staging = StagingGraph::new();
2488        let builder = LuaGraphBuilder::default();
2489        let file = PathBuf::from("test.lua");
2490
2491        builder
2492            .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2493            .unwrap();
2494
2495        let import_edges = extract_import_edges(&staging);
2496        assert!(!import_edges.is_empty(), "Expected require import edge");
2497
2498        // Verify it's an Import edge with correct metadata
2499        let edge = import_edges[0];
2500        if let UnifiedEdgeKind::Imports { is_wildcard, .. } = edge {
2501            assert!(
2502                !*is_wildcard,
2503                "Lua require returns module reference, not wildcard"
2504            );
2505        } else {
2506            panic!("Expected Imports edge kind");
2507        }
2508    }
2509
2510    #[test]
2511    fn test_require_dotted_module() {
2512        let source = r#"
2513            local util = require("luasocket.util")
2514        "#;
2515
2516        let tree = parse_lua(source);
2517        let mut staging = StagingGraph::new();
2518        let builder = LuaGraphBuilder::default();
2519        let file = PathBuf::from("test.lua");
2520
2521        builder
2522            .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2523            .unwrap();
2524
2525        let import_edges = extract_import_edges(&staging);
2526        assert!(
2527            !import_edges.is_empty(),
2528            "Expected dotted module import edge"
2529        );
2530
2531        // Verify it's an Import edge
2532        let edge = import_edges[0];
2533        assert!(
2534            matches!(edge, UnifiedEdgeKind::Imports { .. }),
2535            "Expected Imports edge kind"
2536        );
2537    }
2538
2539    #[test]
2540    fn test_multiple_requires() {
2541        let source = r#"
2542            local json = require("cjson")
2543            local socket = require("socket")
2544            local lpeg = require("lpeg")
2545            local lfs = require("lfs")
2546        "#;
2547
2548        let tree = parse_lua(source);
2549        let mut staging = StagingGraph::new();
2550        let builder = LuaGraphBuilder::default();
2551        let file = PathBuf::from("test.lua");
2552
2553        builder
2554            .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2555            .unwrap();
2556
2557        let import_edges = extract_import_edges(&staging);
2558        assert_eq!(import_edges.len(), 4, "Expected 4 import edges");
2559
2560        // Verify all are EdgeKind::Imports
2561        for edge in &import_edges {
2562            assert!(
2563                matches!(edge, UnifiedEdgeKind::Imports { .. }),
2564                "All edges should be Imports"
2565            );
2566        }
2567    }
2568}