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