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