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