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/// Extract FFI assignments from local/assignment statements
710fn extract_ffi_assignment(node: Node, content: &[u8], aliases: &mut FfiAliasTable) {
711    // For variable_declaration, look for assignment_statement child
712    // For assignment_statement, extract directly
713
714    let assignment = if node.kind() == "variable_declaration" {
715        // Find the assignment_statement child
716        let mut cursor = node.walk();
717        node.children(&mut cursor)
718            .find(|c| c.kind() == "assignment_statement")
719    } else if node.kind() == "assignment_statement" {
720        Some(node)
721    } else {
722        None
723    };
724
725    if let Some(assign_node) = assignment {
726        extract_from_assignment(assign_node, content, aliases);
727    }
728}
729
730/// Extract FFI entity from `assignment_statement`
731fn extract_from_assignment(assign_node: Node, content: &[u8], aliases: &mut FfiAliasTable) {
732    // Get variable_list (LHS) and expression_list (RHS)
733    let mut cursor = assign_node.walk();
734    let children: Vec<_> = assign_node.children(&mut cursor).collect();
735
736    let Some(var_list) = children.iter().find(|c| c.kind() == "variable_list") else {
737        return;
738    };
739    let Some(expr_list) = children.iter().find(|c| c.kind() == "expression_list") else {
740        return;
741    };
742
743    // Get first variable name
744    let Some(var_name_node) = var_list.named_child(0) else {
745        return;
746    };
747    let Ok(var_name) = var_name_node.utf8_text(content) else {
748        return;
749    };
750    let var_name = var_name.trim().to_string();
751
752    // Get first expression (value)
753    let Some(value_node) = expr_list.named_child(0) else {
754        return;
755    };
756
757    // Check different FFI patterns
758    if is_require_ffi_call(value_node, content) {
759        aliases.insert(var_name, FfiEntity::Module);
760    } else if is_ffi_c_reference(value_node, content, aliases) {
761        aliases.insert(var_name, FfiEntity::CLibrary);
762    } else if let Some(lib_name) = extract_ffi_load_library(value_node, content, aliases) {
763        aliases.insert(var_name, FfiEntity::LoadedLibrary(lib_name));
764    }
765    // Handle alias chains: local D = C (where C is already in alias table)
766    else if value_node.kind() == "identifier"
767        && let Ok(alias_name) = value_node.utf8_text(content)
768    {
769        let alias_name = alias_name.trim();
770        if let Some(entity) = aliases.get(alias_name).cloned() {
771            // Propagate the alias
772            aliases.insert(var_name, entity);
773        }
774    }
775}
776
777/// Check if a node is a `require("ffi")` call
778fn is_require_ffi_call(node: Node, content: &[u8]) -> bool {
779    if node.kind() != "function_call" {
780        return false;
781    }
782
783    // Check function name is "require"
784    let Some(name_node) = node.child_by_field_name("name") else {
785        return false;
786    };
787    let Ok(name_text) = name_node.utf8_text(content) else {
788        return false;
789    };
790    if name_text.trim() != "require" {
791        return false;
792    }
793
794    // Check first argument is "ffi"
795    let Some(args_node) = node.child_by_field_name("arguments") else {
796        return false;
797    };
798    let Some(first_arg) = args_node.named_child(0) else {
799        return false;
800    };
801
802    // Use extract_string_content for exact equality check
803    if let Some(content_str) = extract_string_content(first_arg, content) {
804        return content_str == "ffi";
805    }
806
807    false
808}
809
810/// Check if a node is a reference to `ffi.C`
811fn is_ffi_c_reference(node: Node, content: &[u8], aliases: &FfiAliasTable) -> bool {
812    if node.kind() != "dot_index_expression" {
813        return false;
814    }
815
816    let Some(table_node) = node.child_by_field_name("table") else {
817        return false;
818    };
819    let Some(field_node) = node.child_by_field_name("field") else {
820        return false;
821    };
822
823    let Ok(table_text) = table_node.utf8_text(content) else {
824        return false;
825    };
826    let Ok(field_text) = field_node.utf8_text(content) else {
827        return false;
828    };
829
830    let table_text = table_text.trim();
831    let field_text = field_text.trim();
832
833    // Check if table is an alias to ffi module and field is "C"
834    aliases.get(table_text) == Some(&FfiEntity::Module) && field_text == "C"
835}
836
837/// Extract library name from `ffi.load("library")` call
838fn extract_ffi_load_library(node: Node, content: &[u8], aliases: &FfiAliasTable) -> Option<String> {
839    if node.kind() != "function_call" {
840        return None;
841    }
842
843    // Check if it's ffi.load(...) pattern
844    let name_node = node.child_by_field_name("name")?;
845    if name_node.kind() != "dot_index_expression" {
846        return None;
847    }
848
849    let table_node = name_node.child_by_field_name("table")?;
850    let field_node = name_node.child_by_field_name("field")?;
851
852    let table_text = table_node.utf8_text(content).ok()?;
853    let field_text = field_node.utf8_text(content).ok()?;
854
855    let table_text = table_text.trim();
856    let field_text = field_text.trim();
857
858    // Verify table is ffi module alias and field is "load"
859    if aliases.get(table_text) != Some(&FfiEntity::Module) || field_text != "load" {
860        return None;
861    }
862
863    // Extract library name from first argument
864    let args_node = node.child_by_field_name("arguments")?;
865    let first_arg = args_node.named_child(0)?;
866
867    // Extract string content
868    extract_string_content(first_arg, content)
869}
870
871/// Extract string content from a string node
872fn extract_string_content(string_node: Node, content: &[u8]) -> Option<String> {
873    if string_node.kind() == "string" {
874        // Try to get string_content child first
875        let mut cursor = string_node.walk();
876        for child in string_node.children(&mut cursor) {
877            if child.kind() == "string_content"
878                && let Ok(text) = child.utf8_text(content)
879            {
880                return Some(text.trim().to_string());
881            }
882        }
883
884        // Fallback: try to parse the string node itself
885        if let Ok(text) = string_node.utf8_text(content) {
886            let trimmed = text
887                .trim()
888                .trim_start_matches(['"', '\'', '['])
889                .trim_end_matches(['"', '\'', ']'])
890                .to_string();
891            if !trimmed.is_empty() {
892                return Some(trimmed);
893            }
894        }
895    }
896    None
897}
898
899/// Extract FFI call information from a function call node
900fn extract_ffi_call_info(
901    call_node: Node,
902    content: &[u8],
903    aliases: &FfiAliasTable,
904) -> Option<FfiCallInfo> {
905    let name_node = call_node.child_by_field_name("name")?;
906
907    // Handle different call patterns
908    match name_node.kind() {
909        "dot_index_expression" => {
910            // First check if this is ffi.load(...) - emit edge for the load call
911            if is_ffi_load_call(name_node, content, aliases) {
912                return extract_ffi_load_call_info(call_node, content);
913            }
914            // Then handle normal FFI call patterns
915            extract_ffi_from_dot_expression(name_node, content, aliases)
916        }
917        _ => None,
918    }
919}
920
921/// Check if a dot expression is ffi.load(...)
922fn is_ffi_load_call(dot_expr: Node, content: &[u8], aliases: &FfiAliasTable) -> bool {
923    let Some(table_node) = dot_expr.child_by_field_name("table") else {
924        return false;
925    };
926    let Some(field_node) = dot_expr.child_by_field_name("field") else {
927        return false;
928    };
929
930    let Ok(table_text) = table_node.utf8_text(content) else {
931        return false;
932    };
933    let Ok(field_text) = field_node.utf8_text(content) else {
934        return false;
935    };
936
937    aliases.get(table_text.trim()) == Some(&FfiEntity::Module) && field_text.trim() == "load"
938}
939
940/// Extract `FfiCallInfo` from ffi.load("mylib") call
941fn extract_ffi_load_call_info(call_node: Node, content: &[u8]) -> Option<FfiCallInfo> {
942    let args_node = call_node.child_by_field_name("arguments")?;
943    let first_arg = args_node.named_child(0)?;
944
945    let lib_name = extract_string_content(first_arg, content)?;
946
947    // Create FfiCallInfo for the load call
948    // Target: native::<library> (library_name is None so it formats as native::{function_name})
949    Some(FfiCallInfo {
950        function_name: lib_name,
951        library_name: None,
952    })
953}
954
955/// Extract FFI info from dot index expression (e.g., ffi.C.printf, C.malloc, lib.func)
956fn extract_ffi_from_dot_expression(
957    dot_expr: Node,
958    content: &[u8],
959    aliases: &FfiAliasTable,
960) -> Option<FfiCallInfo> {
961    let table_node = dot_expr.child_by_field_name("table")?;
962    let field_node = dot_expr.child_by_field_name("field")?;
963
964    let function_name = field_node.utf8_text(content).ok()?.trim().to_string();
965
966    // Check if table is nested dot expression (ffi.C.func)
967    if table_node.kind() == "dot_index_expression" {
968        let inner_table = table_node.child_by_field_name("table")?;
969        let inner_field = table_node.child_by_field_name("field")?;
970
971        let base_text = inner_table.utf8_text(content).ok()?.trim();
972        let mid_text = inner_field.utf8_text(content).ok()?.trim();
973
974        // Pattern: ffi.C.function
975        if aliases.get(base_text) == Some(&FfiEntity::Module) && mid_text == "C" {
976            return Some(FfiCallInfo {
977                function_name,
978                library_name: None,
979            });
980        }
981    } else {
982        // Pattern: C.function or lib.function (direct alias)
983        let table_text = table_node.utf8_text(content).ok()?.trim();
984
985        match aliases.get(table_text) {
986            Some(FfiEntity::CLibrary) => {
987                return Some(FfiCallInfo {
988                    function_name,
989                    library_name: None,
990                });
991            }
992            Some(FfiEntity::LoadedLibrary(lib_name)) => {
993                return Some(FfiCallInfo {
994                    function_name,
995                    library_name: Some(lib_name.clone()),
996                });
997            }
998            _ => {}
999        }
1000    }
1001
1002    None
1003}
1004
1005/// Emit an FFI edge for a detected FFI call
1006fn emit_ffi_edge(
1007    ffi_info: FfiCallInfo,
1008    call_node: Node,
1009    content: &[u8],
1010    ast_graph: &ASTGraph,
1011    helper: &mut GraphBuildHelper,
1012) {
1013    // Get caller (enclosing function or file-level)
1014    let caller_id = get_ffi_caller_node_id(call_node, content, ast_graph, helper);
1015
1016    // Build target name
1017    let target_name = if let Some(lib) = ffi_info.library_name {
1018        format!("native::{}::{}", lib, ffi_info.function_name)
1019    } else {
1020        format!("native::{}", ffi_info.function_name)
1021    };
1022
1023    // Ensure target node exists
1024    let target_id = helper.ensure_callee(
1025        &target_name,
1026        span_from_node(call_node),
1027        CalleeKindHint::Function,
1028    );
1029
1030    // Add FfiCall edge with C convention
1031    helper.add_ffi_edge(caller_id, target_id, FfiConvention::C);
1032}
1033
1034/// Get the caller node ID for an FFI call (enclosing function or file-level)
1035fn get_ffi_caller_node_id(
1036    call_node: Node,
1037    content: &[u8],
1038    ast_graph: &ASTGraph,
1039    helper: &mut GraphBuildHelper,
1040) -> sqry_core::graph::unified::node::NodeId {
1041    let call_span = span_from_node(call_node);
1042    // Try to get context from AST graph
1043    if let Some(call_context) = ast_graph.get_callable_context(call_node.id()) {
1044        if call_context.is_method {
1045            return helper.ensure_method(&call_context.qualified_name, None, false, false);
1046        }
1047        return helper.ensure_callee(
1048            &call_context.qualified_name,
1049            call_span,
1050            CalleeKindHint::Function,
1051        );
1052    }
1053
1054    // Walk up tree to find enclosing function
1055    let mut current = call_node.parent();
1056    while let Some(node) = current {
1057        if node.kind() == "function_declaration" || node.kind() == "function_definition" {
1058            // Extract function name and ensure node exists
1059            if let Some(name_node) = node.child_by_field_name("name")
1060                && let Ok(name_text) = name_node.utf8_text(content)
1061            {
1062                return helper.ensure_callee(
1063                    name_text,
1064                    span_from_node(node),
1065                    CalleeKindHint::Function,
1066                );
1067            }
1068        }
1069        current = node.parent();
1070    }
1071
1072    // Fallback: file-level context
1073    helper.ensure_callee("<file_level>", call_span, CalleeKindHint::Function)
1074}
1075
1076/// Build call edge information for the staging graph.
1077fn build_call_for_staging(
1078    ast_graph: &ASTGraph,
1079    call_node: Node<'_>,
1080    content: &[u8],
1081) -> GraphResult<Option<(String, String, usize, Span)>> {
1082    // Get or create module-level context for top-level calls
1083    let module_context;
1084    let call_context = if let Some(ctx) = ast_graph.get_callable_context(call_node.id()) {
1085        ctx
1086    } else {
1087        // Create synthetic module-level context for top-level calls
1088        module_context = CallContext {
1089            qualified_name: "<module>".to_string(),
1090            is_method: false,
1091            module_name: None,
1092        };
1093        &module_context
1094    };
1095
1096    // Extract the call target (the function being called)
1097    let Some(name_node) = call_node.child_by_field_name("name") else {
1098        return Ok(None);
1099    };
1100
1101    let callee_text = name_node
1102        .utf8_text(content)
1103        .map_err(|_| GraphBuilderError::ParseError {
1104            span: span_from_node(call_node),
1105            reason: "failed to read call expression".to_string(),
1106        })?
1107        .trim()
1108        .to_string();
1109
1110    if callee_text.is_empty() {
1111        return Ok(None);
1112    }
1113
1114    // Extract qualified callee name based on call syntax, resolving self if needed
1115    let mut target_qualified = extract_call_target(name_node, content, call_context)?;
1116
1117    // CRITICAL: Resolve bare identifiers against the current scope
1118    if !target_qualified.contains("::") {
1119        // Try to resolve in the parent scope of the caller
1120        let scoped_name = if call_context.qualified_name == "<module>" {
1121            target_qualified.clone()
1122        } else {
1123            format!("{}::{}", call_context.qualified_name, &target_qualified)
1124        };
1125
1126        // Check if this scoped name exists in the AST graph
1127        if ast_graph
1128            .contexts()
1129            .iter()
1130            .any(|ctx| ctx.qualified_name == scoped_name)
1131        {
1132            target_qualified = scoped_name;
1133        }
1134        // If not found in immediate scope, also try sibling scope (parent's scope)
1135        else if let Some(parent_scope) = extract_parent_scope(&call_context.qualified_name) {
1136            let sibling_name = format!("{}::{}", parent_scope, &target_qualified);
1137            if ast_graph
1138                .contexts()
1139                .iter()
1140                .any(|ctx| ctx.qualified_name == sibling_name)
1141            {
1142                target_qualified = sibling_name;
1143            }
1144        }
1145    }
1146
1147    let source_qualified = call_context.qualified_name();
1148
1149    let span = span_from_node(call_node);
1150    let argument_count = count_arguments(call_node);
1151
1152    Ok(Some((
1153        source_qualified,
1154        target_qualified,
1155        argument_count,
1156        span,
1157    )))
1158}
1159
1160// ============================================================================
1161// Helper Functions (extracted from old implementation, now used with GraphBuildHelper)
1162// ============================================================================
1163
1164/// Extract the qualified call target from a function call name node
1165fn extract_call_target(
1166    name_node: Node<'_>,
1167    content: &[u8],
1168    call_context: &CallContext,
1169) -> GraphResult<String> {
1170    match name_node.kind() {
1171        "identifier" => {
1172            // Simple call: foo()
1173            // Just return the bare name - scoped resolution will happen in build_call_edge
1174            get_node_text(name_node, content)
1175        }
1176        "dot_index_expression" => {
1177            // Module.function() or obj.method()
1178            flatten_dotted_name(name_node, content)
1179        }
1180        "method_index_expression" => {
1181            // obj:method() (colon syntax with implicit self)
1182            flatten_method_name(name_node, content, call_context)
1183        }
1184        "bracket_index_expression" => {
1185            // table["field"]() style access
1186            flatten_bracket_name(name_node, content, call_context)
1187        }
1188        "function_call" => {
1189            // Chained call: getFn()()
1190            // Use the whole expression as the target
1191            get_node_text(name_node, content)
1192        }
1193        _ => get_node_text(name_node, content),
1194    }
1195}
1196
1197/// Flatten a `dot_index_expression` (Module.function) into `Module::function`
1198fn flatten_dotted_name(node: Node<'_>, content: &[u8]) -> GraphResult<String> {
1199    let table = node
1200        .child_by_field_name("table")
1201        .ok_or_else(|| GraphBuilderError::ParseError {
1202            span: span_from_node(node),
1203            reason: "dot_index_expression missing table".to_string(),
1204        })?;
1205    let field = node
1206        .child_by_field_name("field")
1207        .ok_or_else(|| GraphBuilderError::ParseError {
1208            span: span_from_node(node),
1209            reason: "dot_index_expression missing field".to_string(),
1210        })?;
1211
1212    let table_text = collect_table_path(table, content)?;
1213    let field_text = get_node_text(field, content)?;
1214
1215    Ok(format!("{table_text}::{field_text}"))
1216}
1217
1218/// Flatten a `method_index_expression` (Module:method) into `Module::method`
1219/// Resolves `self` to the containing module name if available
1220fn flatten_method_name(
1221    node: Node<'_>,
1222    content: &[u8],
1223    call_context: &CallContext,
1224) -> GraphResult<String> {
1225    let table = node
1226        .child_by_field_name("table")
1227        .ok_or_else(|| GraphBuilderError::ParseError {
1228            span: span_from_node(node),
1229            reason: "method_index_expression missing table".to_string(),
1230        })?;
1231    let method =
1232        node.child_by_field_name("method")
1233            .ok_or_else(|| GraphBuilderError::ParseError {
1234                span: span_from_node(node),
1235                reason: "method_index_expression missing method".to_string(),
1236            })?;
1237
1238    let mut table_text = collect_table_path(table, content)?;
1239    let method_text = get_node_text(method, content)?;
1240
1241    // Resolve `self` to the containing module name
1242    if table_text == "self"
1243        && let Some(ref module_name) = call_context.module_name
1244    {
1245        table_text.clone_from(module_name);
1246    }
1247    // If no module_name, keep "self" as-is (might be a top-level method)
1248
1249    Ok(format!("{table_text}::{method_text}"))
1250}
1251
1252/// Flatten a `bracket_index_expression` (e.g. `Module["method"]`) into `Module::method`
1253fn flatten_bracket_name(
1254    node: Node<'_>,
1255    content: &[u8],
1256    call_context: &CallContext,
1257) -> GraphResult<String> {
1258    let table = node
1259        .child_by_field_name("table")
1260        .ok_or_else(|| GraphBuilderError::ParseError {
1261            span: span_from_node(node),
1262            reason: "bracket_index_expression missing table".to_string(),
1263        })?;
1264    let field = node
1265        .child_by_field_name("field")
1266        .ok_or_else(|| GraphBuilderError::ParseError {
1267            span: span_from_node(node),
1268            reason: "bracket_index_expression missing field".to_string(),
1269        })?;
1270
1271    let mut table_text = collect_table_path(table, content)?;
1272    if table_text == "self"
1273        && let Some(ref module_name) = call_context.module_name
1274    {
1275        table_text.clone_from(module_name);
1276    }
1277
1278    let field_text = normalize_field_value(field, content)?;
1279
1280    Ok(format!("{table_text}::{field_text}"))
1281}
1282
1283/// Recursively collect table path for nested expressions (A.B.C -> `A::B::C`)
1284fn collect_table_path(node: Node<'_>, content: &[u8]) -> GraphResult<String> {
1285    match node.kind() {
1286        "bracket_index_expression" => {
1287            let table =
1288                node.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 =
1294                node.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 table_text = collect_table_path(table, content)?;
1301            let field_text = normalize_field_value(field, content)?;
1302
1303            Ok(format!("{table_text}::{field_text}"))
1304        }
1305        "dot_index_expression" => {
1306            let table =
1307                node.child_by_field_name("table")
1308                    .ok_or_else(|| GraphBuilderError::ParseError {
1309                        span: span_from_node(node),
1310                        reason: "nested dot_index_expression missing table".to_string(),
1311                    })?;
1312            let field =
1313                node.child_by_field_name("field")
1314                    .ok_or_else(|| GraphBuilderError::ParseError {
1315                        span: span_from_node(node),
1316                        reason: "nested dot_index_expression missing field".to_string(),
1317                    })?;
1318
1319            let table_text = collect_table_path(table, content)?;
1320            let field_text = get_node_text(field, content)?;
1321
1322            Ok(format!("{table_text}::{field_text}"))
1323        }
1324        _ => get_node_text(node, content),
1325    }
1326}
1327
1328/// Get text content of a node
1329fn get_node_text(node: Node<'_>, content: &[u8]) -> GraphResult<String> {
1330    node.utf8_text(content)
1331        .map(|text| text.trim().to_string())
1332        .map_err(|_| GraphBuilderError::ParseError {
1333            span: span_from_node(node),
1334            reason: "failed to read node text".to_string(),
1335        })
1336}
1337
1338/// Create a Span from a tree-sitter node
1339fn span_from_node(node: Node<'_>) -> Span {
1340    let start = node.start_position();
1341    let end = node.end_position();
1342    Span::new(
1343        Position::new(start.row, start.column),
1344        Position::new(end.row, end.column),
1345    )
1346}
1347
1348/// Count the number of arguments in a function call
1349fn count_arguments(call_node: Node<'_>) -> usize {
1350    call_node
1351        .child_by_field_name("arguments")
1352        .map_or(0, |args| {
1353            args.named_children(&mut args.walk())
1354                .filter(|child| !matches!(child.kind(), "," | "(" | ")"))
1355                .count()
1356        })
1357}
1358
1359/// Extract parent scope from a qualified name
1360/// e.g., "`outer::inner::deep`" -> `Some("outer::inner`")
1361/// e.g., "outer" -> None
1362fn extract_parent_scope(qualified_name: &str) -> Option<String> {
1363    let parts: Vec<&str> = qualified_name.split("::").collect();
1364    if parts.len() > 1 {
1365        Some(parts[..parts.len() - 1].join("::"))
1366    } else {
1367        None
1368    }
1369}
1370
1371fn normalize_field_value(node: Node<'_>, content: &[u8]) -> GraphResult<String> {
1372    normalize_field_value_simple(node, content).map_err(|reason| GraphBuilderError::ParseError {
1373        span: span_from_node(node),
1374        reason,
1375    })
1376}
1377
1378fn normalize_field_value_simple(node: Node<'_>, content: &[u8]) -> Result<String, String> {
1379    let raw = node
1380        .utf8_text(content)
1381        .map_err(|_| "failed to read field value".to_string())?
1382        .trim()
1383        .to_string();
1384
1385    if raw.is_empty() {
1386        return Err("empty field value".to_string());
1387    }
1388
1389    match node.kind() {
1390        "string" => Ok(strip_string_literal(&raw)),
1391        _ => Ok(raw),
1392    }
1393}
1394
1395fn strip_string_literal(raw: &str) -> String {
1396    if raw.is_empty() {
1397        return raw.to_string();
1398    }
1399
1400    let bytes = raw.as_bytes();
1401    if (bytes[0] == b'"' && bytes[bytes.len() - 1] == b'"')
1402        || (bytes[0] == b'\'' && bytes[bytes.len() - 1] == b'\'')
1403    {
1404        return raw[1..raw.len() - 1].to_string();
1405    }
1406
1407    if raw.starts_with('[') && raw.ends_with(']') {
1408        let mut start = 1usize;
1409        while start < raw.len() && raw.as_bytes()[start] == b'=' {
1410            start += 1;
1411        }
1412        if start < raw.len() && raw.as_bytes()[start] == b'[' {
1413            let mut end = raw.len() - 1;
1414            while end > 0 && raw.as_bytes()[end - 1] == b'=' {
1415                end -= 1;
1416            }
1417            if end > start + 1 {
1418                return raw[start + 1..end - 1].to_string();
1419            }
1420        }
1421    }
1422
1423    raw.to_string()
1424}
1425
1426fn strip_env_prefix(name: String) -> String {
1427    name.strip_prefix("_ENV::")
1428        .map(std::string::ToString::to_string)
1429        .unwrap_or(name)
1430}
1431
1432/// Build property nodes for table constructor fields
1433fn build_table_fields(
1434    table_node: Node<'_>,
1435    content: &[u8],
1436    helper: &mut GraphBuildHelper,
1437) -> GraphResult<()> {
1438    let mut cursor = table_node.walk();
1439
1440    for child in table_node.children(&mut cursor) {
1441        if child.kind() != "field" {
1442            continue;
1443        }
1444
1445        // Extract field name
1446        if let Some(name_node) = child.child_by_field_name("name") {
1447            let field_name = name_node
1448                .utf8_text(content)
1449                .map_err(|_| GraphBuilderError::ParseError {
1450                    span: span_from_node(child),
1451                    reason: "failed to read table field name".to_string(),
1452                })?
1453                .trim();
1454
1455            // Create property node for the field
1456            let span = span_from_node(child);
1457            // issue #394: real declaration; opt dual-use bare helper into is_definition
1458            let property_id = helper.add_node(field_name, Some(span), NodeKind::Property);
1459            helper.mark_definition(property_id);
1460        }
1461    }
1462
1463    Ok(())
1464}
1465
1466/// Build property nodes for field access expressions
1467#[allow(clippy::unnecessary_wraps)]
1468fn build_field_access(
1469    access_node: Node<'_>,
1470    content: &[u8],
1471    helper: &mut GraphBuildHelper,
1472) -> GraphResult<()> {
1473    // Extract field name based on access type
1474    let field_name = match access_node.kind() {
1475        "dot_index_expression" => {
1476            // table.field
1477            access_node
1478                .child_by_field_name("field")
1479                .and_then(|n| n.utf8_text(content).ok())
1480                .map(|s| s.trim().to_string())
1481        }
1482        "bracket_index_expression" => {
1483            // table["field"] or table[expression]
1484            access_node.child_by_field_name("field").and_then(|n| {
1485                // Try to extract string literal
1486                if n.kind() == "string" {
1487                    n.utf8_text(content)
1488                        .ok()
1489                        .map(|s| s.trim().trim_matches(|c| c == '"' || c == '\'').to_string())
1490                } else {
1491                    // For dynamic keys, use the expression text
1492                    n.utf8_text(content).ok().map(|s| s.trim().to_string())
1493                }
1494            })
1495        }
1496        _ => None,
1497    };
1498
1499    if let Some(name) = field_name {
1500        // Create property node for the field access
1501        let span = span_from_node(access_node);
1502        helper.add_node(&name, Some(span), NodeKind::Property);
1503    }
1504
1505    Ok(())
1506}
1507
1508// ============================================================================
1509// AST Graph - tracks callable contexts (functions, methods, closures)
1510// ============================================================================
1511
1512#[derive(Debug, Clone)]
1513struct CallContext {
1514    qualified_name: String,
1515    is_method: bool,
1516    /// Module/class name for resolving self (e.g., "`MyModule`" from "`MyModule::method`")
1517    module_name: Option<String>,
1518}
1519
1520impl CallContext {
1521    fn qualified_name(&self) -> String {
1522        self.qualified_name.clone()
1523    }
1524}
1525
1526struct ASTGraph {
1527    contexts: Vec<CallContext>,
1528    node_to_context: HashMap<usize, usize>,
1529}
1530
1531impl ASTGraph {
1532    fn from_tree(tree: &Tree, content: &[u8], max_depth: usize) -> Result<Self, String> {
1533        let mut contexts = Vec::new();
1534        let mut node_to_context = HashMap::new();
1535
1536        let mut state = WalkerState::new(&mut contexts, &mut node_to_context, max_depth);
1537
1538        walk_ast(tree.root_node(), content, &mut state)?;
1539
1540        Ok(Self {
1541            contexts,
1542            node_to_context,
1543        })
1544    }
1545
1546    fn contexts(&self) -> &[CallContext] {
1547        &self.contexts
1548    }
1549
1550    fn get_callable_context(&self, node_id: usize) -> Option<&CallContext> {
1551        self.node_to_context
1552            .get(&node_id)
1553            .and_then(|idx| self.contexts.get(*idx))
1554    }
1555}
1556
1557/// State passed through AST walking to track scope and context
1558struct WalkerState<'a> {
1559    contexts: &'a mut Vec<CallContext>,
1560    node_to_context: &'a mut HashMap<usize, usize>,
1561    parent_qualified: Option<String>,
1562    module_context: Option<String>,
1563    lexical_depth: usize,
1564    max_depth: usize,
1565}
1566
1567impl<'a> WalkerState<'a> {
1568    fn new(
1569        contexts: &'a mut Vec<CallContext>,
1570        node_to_context: &'a mut HashMap<usize, usize>,
1571        max_depth: usize,
1572    ) -> Self {
1573        Self {
1574            contexts,
1575            node_to_context,
1576            parent_qualified: None,
1577            module_context: None,
1578            lexical_depth: 0,
1579            max_depth,
1580        }
1581    }
1582}
1583
1584/// Walk the AST to find function definitions and build context mappings
1585fn walk_ast(node: Node, content: &[u8], state: &mut WalkerState) -> Result<(), String> {
1586    // CRITICAL FIX #5: Check lexical depth, not namespace depth
1587    // lexical_depth tracks actual function nesting, excluding module namespace segments
1588    if state.lexical_depth > state.max_depth {
1589        return Ok(());
1590    }
1591
1592    match node.kind() {
1593        "local_function" => {
1594            // Handle: local function foo() end
1595            handle_local_function(node, content, state)?;
1596        }
1597        "function_declaration" => {
1598            handle_function_declaration(node, content, state)?;
1599        }
1600        "assignment_statement" => {
1601            // Handle: local foo = function() end
1602            handle_function_assignment(node, content, state)?;
1603        }
1604        _ => {
1605            // Recurse into other nodes
1606            let mut cursor = node.walk();
1607            for child in node.children(&mut cursor) {
1608                walk_ast(child, content, state)?;
1609            }
1610        }
1611    }
1612
1613    Ok(())
1614}
1615
1616/// Handle `local_function` nodes
1617fn handle_local_function(
1618    node: Node,
1619    content: &[u8],
1620    state: &mut WalkerState,
1621) -> Result<(), String> {
1622    let name_node = node
1623        .child_by_field_name("name")
1624        .ok_or_else(|| "local_function missing name".to_string())?;
1625
1626    // Get the function name (local functions are always simple identifiers)
1627    let base_name = name_node
1628        .utf8_text(content)
1629        .map_err(|_| "failed to read local function name".to_string())?
1630        .to_string();
1631
1632    // Build qualified name with parent scope
1633    let qualified_name = if let Some(parent) = state.parent_qualified.as_ref() {
1634        format!("{parent}::{base_name}")
1635    } else {
1636        base_name
1637    };
1638
1639    // Record this function as a callable context
1640    let context_idx = state.contexts.len();
1641    state.contexts.push(CallContext {
1642        qualified_name: qualified_name.clone(),
1643        is_method: false, // Local functions are never methods
1644        module_name: state.module_context.clone(),
1645    });
1646
1647    // Map all descendant nodes to this context
1648    map_descendants_to_context(node, state.node_to_context, context_idx);
1649
1650    // Save the current parent and module context
1651    let saved_parent = state.parent_qualified.clone();
1652    let saved_module_context = state.module_context.clone();
1653
1654    // Update parent for nested functions
1655    state.parent_qualified = Some(qualified_name);
1656
1657    // Increment lexical depth for nested functions
1658    state.lexical_depth += 1;
1659
1660    #[allow(clippy::cast_possible_truncation)] // Graph storage: node/edge index counts fit in u32
1661    // Recurse into function body
1662    if let Some(body) = node.named_child(node.named_child_count().saturating_sub(1) as u32) {
1663        walk_ast(body, content, state)?;
1664    }
1665
1666    // Restore the previous lexical depth, parent, and module context
1667    state.lexical_depth -= 1;
1668    state.parent_qualified = saved_parent;
1669    state.module_context = saved_module_context;
1670
1671    Ok(())
1672}
1673
1674/// Handle `function_declaration` nodes
1675fn handle_function_declaration(
1676    node: Node,
1677    content: &[u8],
1678    state: &mut WalkerState,
1679) -> Result<(), String> {
1680    let name_node = node
1681        .child_by_field_name("name")
1682        .ok_or_else(|| "function_declaration missing name".to_string())?;
1683
1684    // Get the base name from the AST node
1685    let (base_name, is_method) = extract_function_base_name(name_node, content)?;
1686
1687    // Build qualified name: if there's a parent, prefix it; otherwise use base name
1688    // CRITICAL FIX #6: Don't prefix if base_name is already fully qualified
1689    // (e.g., "MyModule::inner" defined inside another function)
1690    let qualified_name = if base_name.contains("::") {
1691        base_name.clone()
1692    } else if let Some(parent) = state.parent_qualified.as_ref() {
1693        format!("{parent}::{base_name}")
1694    } else {
1695        base_name.clone()
1696    };
1697    let qualified_name = strip_env_prefix(qualified_name);
1698
1699    // CRITICAL FIX #4: Module name should be inherited from parent context for nested functions,
1700    // or extracted from the qualified name only for top-level methods
1701    let module_name = if is_method {
1702        // For a method like "MyModule:foo", extract the module part
1703        if qualified_name.contains("::") {
1704            let parts: Vec<&str> = qualified_name.split("::").collect();
1705            if parts.len() > 1 {
1706                Some(parts[..parts.len() - 1].join("::"))
1707            } else {
1708                None
1709            }
1710        } else {
1711            None
1712        }
1713    } else {
1714        // For non-methods (regular functions), inherit the module context from parent
1715        state.module_context.clone()
1716    };
1717
1718    // Record this function as a callable context
1719    let context_idx = state.contexts.len();
1720    state.contexts.push(CallContext {
1721        qualified_name: qualified_name.clone(),
1722        is_method,
1723        module_name: module_name.clone(),
1724    });
1725
1726    // Map all descendant nodes to this context
1727    map_descendants_to_context(node, state.node_to_context, context_idx);
1728
1729    // Save the current parent and module context
1730    let saved_parent = state.parent_qualified.clone();
1731    let saved_module_context = state.module_context.clone();
1732
1733    // Update parent for nested functions
1734    state.parent_qualified = Some(qualified_name);
1735
1736    // Update module context if this is a method
1737    if is_method && module_name.is_some() {
1738        state.module_context = module_name;
1739    }
1740
1741    // CRITICAL FIX #5: Increment lexical depth for nested functions
1742    state.lexical_depth += 1;
1743
1744    // Recurse into function body
1745    #[allow(clippy::cast_possible_truncation)] // tree-sitter child count fits in u32
1746    if let Some(body) = node.named_child(node.named_child_count().saturating_sub(1) as u32) {
1747        walk_ast(body, content, state)?;
1748    }
1749
1750    // Restore the previous lexical depth, parent, and module context
1751    state.lexical_depth -= 1;
1752    state.parent_qualified = saved_parent;
1753    state.module_context = saved_module_context;
1754
1755    Ok(())
1756}
1757
1758/// Handle `assignment_statement` nodes that assign functions
1759fn handle_function_assignment(
1760    node: Node,
1761    content: &[u8],
1762    state: &mut WalkerState,
1763) -> Result<(), String> {
1764    // Check if this is a function assignment
1765    let Some(expr_list) = node
1766        .children(&mut node.walk())
1767        .find(|child| child.kind() == "expression_list")
1768    else {
1769        return Ok(());
1770    };
1771
1772    let Some(func_def) = expr_list
1773        .named_children(&mut expr_list.walk())
1774        .find(|child| child.kind() == "function_definition")
1775    else {
1776        return Ok(());
1777    };
1778
1779    // Extract the variable name
1780    let Some(var_list) = node
1781        .children(&mut node.walk())
1782        .find(|child| child.kind() == "variable_list")
1783    else {
1784        return Ok(());
1785    };
1786
1787    let Some(var_node) = var_list.named_child(0) else {
1788        return Ok(());
1789    };
1790
1791    // Get the base name from the AST node
1792    let (base_name, is_method) = extract_assignment_base_name(var_node, content)?;
1793
1794    // Build qualified name: if there's a parent, prefix it; otherwise use base name
1795    // CRITICAL FIX #6: Don't prefix if base_name is already fully qualified
1796    let qualified_name = if base_name.contains("::") {
1797        base_name.clone()
1798    } else if let Some(parent) = state.parent_qualified.as_ref() {
1799        format!("{parent}::{base_name}")
1800    } else {
1801        base_name.clone()
1802    };
1803    let qualified_name = strip_env_prefix(qualified_name);
1804
1805    // CRITICAL FIX #4: Module name should be inherited from parent context for nested functions,
1806    // or extracted from the qualified name only for top-level methods
1807    let module_name = if is_method {
1808        // For a method like "MyModule:foo", extract the module part
1809        if qualified_name.contains("::") {
1810            let parts: Vec<&str> = qualified_name.split("::").collect();
1811            if parts.len() > 1 {
1812                Some(parts[..parts.len() - 1].join("::"))
1813            } else {
1814                None
1815            }
1816        } else {
1817            None
1818        }
1819    } else {
1820        // For non-methods (regular functions), inherit the module context from parent
1821        state.module_context.clone()
1822    };
1823
1824    // Record this function as a callable context
1825    let context_idx = state.contexts.len();
1826    state.contexts.push(CallContext {
1827        qualified_name: qualified_name.clone(),
1828        is_method,
1829        module_name: module_name.clone(),
1830    });
1831
1832    // Map all descendant nodes to this context
1833    map_descendants_to_context(func_def, state.node_to_context, context_idx);
1834
1835    // Save the current parent and module context
1836    let saved_parent = state.parent_qualified.clone();
1837    let saved_module_context = state.module_context.clone();
1838
1839    // Update parent for nested functions
1840    state.parent_qualified = Some(qualified_name);
1841
1842    // Update module context if this is a method
1843    if is_method && module_name.is_some() {
1844        state.module_context = module_name;
1845    }
1846
1847    // CRITICAL FIX #5: Increment lexical depth for nested functions
1848    state.lexical_depth += 1;
1849    // Recurse into function body
1850    #[allow(clippy::cast_possible_truncation)] // tree-sitter child count fits in u32
1851    if let Some(body) = func_def.named_child(func_def.named_child_count().saturating_sub(1) as u32)
1852    {
1853        walk_ast(body, content, state)?;
1854    }
1855
1856    // Restore the previous lexical depth, parent, and module context
1857    state.lexical_depth -= 1;
1858    state.parent_qualified = saved_parent;
1859    state.module_context = saved_module_context;
1860
1861    Ok(())
1862}
1863
1864/// Extract base name from a `function_declaration` name node (without parent context)
1865/// Returns (`base_name`, `is_method`)
1866fn extract_function_base_name(
1867    name_node: Node<'_>,
1868    content: &[u8],
1869) -> Result<(String, bool), String> {
1870    match name_node.kind() {
1871        "identifier" => {
1872            // function foo() end OR local function foo() end
1873            let name = name_node
1874                .utf8_text(content)
1875                .map_err(|_| "failed to read function name".to_string())?;
1876            Ok((name.to_string(), false))
1877        }
1878        "dot_index_expression" => {
1879            // function Module.foo() end
1880            let table = name_node
1881                .child_by_field_name("table")
1882                .ok_or_else(|| "dot_index_expression missing table".to_string())?;
1883            let field = name_node
1884                .child_by_field_name("field")
1885                .ok_or_else(|| "dot_index_expression missing field".to_string())?;
1886
1887            let table_text = collect_table_path_simple(table, content)?;
1888            let field_text = field
1889                .utf8_text(content)
1890                .map_err(|_| "failed to read field name".to_string())?;
1891
1892            Ok((format!("{table_text}::{field_text}"), false))
1893        }
1894        "method_index_expression" => {
1895            // function Module:method() end (implicit self parameter)
1896            let table = name_node
1897                .child_by_field_name("table")
1898                .ok_or_else(|| "method_index_expression missing table".to_string())?;
1899            let method = name_node
1900                .child_by_field_name("method")
1901                .ok_or_else(|| "method_index_expression missing method".to_string())?;
1902
1903            let table_text = collect_table_path_simple(table, content)?;
1904            let method_text = method
1905                .utf8_text(content)
1906                .map_err(|_| "failed to read method name".to_string())?;
1907
1908            Ok((format!("{table_text}::{method_text}"), true))
1909        }
1910        "bracket_index_expression" => {
1911            // function Module["foo"]() end
1912            let table = name_node
1913                .child_by_field_name("table")
1914                .ok_or_else(|| "bracket_index_expression missing table".to_string())?;
1915            let field = name_node
1916                .child_by_field_name("field")
1917                .ok_or_else(|| "bracket_index_expression missing field".to_string())?;
1918
1919            let table_text = collect_table_path_simple(table, content)?;
1920            let field_text = normalize_field_value_simple(field, content)?;
1921
1922            Ok((format!("{table_text}::{field_text}"), false))
1923        }
1924        _ => Err(format!(
1925            "unsupported function name kind: {}",
1926            name_node.kind()
1927        )),
1928    }
1929}
1930
1931/// Extract base name from an assignment variable node (without parent context)
1932/// Returns (`base_name`, `is_method`)
1933fn extract_assignment_base_name(
1934    var_node: Node<'_>,
1935    content: &[u8],
1936) -> Result<(String, bool), String> {
1937    match var_node.kind() {
1938        "identifier" => {
1939            // local foo = function() end
1940            let name = var_node
1941                .utf8_text(content)
1942                .map_err(|_| "failed to read identifier".to_string())?;
1943            Ok((name.to_string(), false))
1944        }
1945        "dot_index_expression" => {
1946            // Module.foo = function() end
1947            let table = var_node
1948                .child_by_field_name("table")
1949                .ok_or_else(|| "dot_index_expression missing table".to_string())?;
1950            let field = var_node
1951                .child_by_field_name("field")
1952                .ok_or_else(|| "dot_index_expression missing field".to_string())?;
1953
1954            let table_text = collect_table_path_simple(table, content)?;
1955            let field_text = field
1956                .utf8_text(content)
1957                .map_err(|_| "failed to read field".to_string())?;
1958
1959            Ok((format!("{table_text}::{field_text}"), false))
1960        }
1961        "method_index_expression" => {
1962            // Module:method = function() end
1963            let table = var_node
1964                .child_by_field_name("table")
1965                .ok_or_else(|| "method_index_expression missing table".to_string())?;
1966            let method = var_node
1967                .child_by_field_name("method")
1968                .ok_or_else(|| "method_index_expression missing method".to_string())?;
1969
1970            let table_text = collect_table_path_simple(table, content)?;
1971            let method_text = method
1972                .utf8_text(content)
1973                .map_err(|_| "failed to read method".to_string())?;
1974
1975            Ok((format!("{table_text}::{method_text}"), true))
1976        }
1977        "bracket_index_expression" => {
1978            // Module["foo"] = function() end or commands[1] = function() end
1979            let table = var_node
1980                .child_by_field_name("table")
1981                .ok_or_else(|| "bracket_index_expression missing table".to_string())?;
1982            let field = var_node
1983                .child_by_field_name("field")
1984                .ok_or_else(|| "bracket_index_expression missing field".to_string())?;
1985
1986            let table_text = collect_table_path_simple(table, content)?;
1987            let field_text = normalize_field_value_simple(field, content)?;
1988
1989            Ok((format!("{table_text}::{field_text}"), false))
1990        }
1991        _ => Err(format!(
1992            "unsupported assignment target kind: {}",
1993            var_node.kind()
1994        )),
1995    }
1996}
1997
1998/// Simplified table path collection (doesn't use `GraphResult`)
1999fn collect_table_path_simple(node: Node<'_>, content: &[u8]) -> Result<String, String> {
2000    match node.kind() {
2001        "identifier" => node
2002            .utf8_text(content)
2003            .map(std::string::ToString::to_string)
2004            .map_err(|_| "failed to read identifier".to_string()),
2005        "bracket_index_expression" => {
2006            let table = node
2007                .child_by_field_name("table")
2008                .ok_or_else(|| "bracket_index_expression missing table".to_string())?;
2009            let field = node
2010                .child_by_field_name("field")
2011                .ok_or_else(|| "bracket_index_expression missing field".to_string())?;
2012
2013            let table_text = collect_table_path_simple(table, content)?;
2014            let field_text = normalize_field_value_simple(field, content)?;
2015
2016            Ok(format!("{table_text}::{field_text}"))
2017        }
2018        "dot_index_expression" => {
2019            let table = node
2020                .child_by_field_name("table")
2021                .ok_or_else(|| "dot_index_expression missing table".to_string())?;
2022            let field = node
2023                .child_by_field_name("field")
2024                .ok_or_else(|| "dot_index_expression missing field".to_string())?;
2025
2026            let table_text = collect_table_path_simple(table, content)?;
2027            let field_text = field
2028                .utf8_text(content)
2029                .map_err(|_| "failed to read field".to_string())?;
2030
2031            Ok(format!("{table_text}::{field_text}"))
2032        }
2033        _ => node
2034            .utf8_text(content)
2035            .map(std::string::ToString::to_string)
2036            .map_err(|_| "failed to read node".to_string()),
2037    }
2038}
2039
2040/// Map all descendant nodes to a context index
2041fn map_descendants_to_context(
2042    node: Node,
2043    node_to_context: &mut HashMap<usize, usize>,
2044    context_idx: usize,
2045) {
2046    node_to_context.insert(node.id(), context_idx);
2047
2048    let mut cursor = node.walk();
2049    for child in node.children(&mut cursor) {
2050        map_descendants_to_context(child, node_to_context, context_idx);
2051    }
2052}
2053
2054#[cfg(test)]
2055mod tests {
2056    use super::*;
2057    use crate::LuaPlugin;
2058    use sqry_core::graph::unified::build::StagingOp;
2059    use sqry_core::graph::unified::build::test_helpers::*;
2060    use sqry_core::graph::unified::edge::EdgeKind as UnifiedEdgeKind;
2061    use sqry_core::plugin::LanguagePlugin;
2062    use std::path::PathBuf;
2063
2064    /// Helper to extract Import edges from staging operations
2065    fn extract_import_edges(staging: &StagingGraph) -> Vec<&UnifiedEdgeKind> {
2066        staging
2067            .operations()
2068            .iter()
2069            .filter_map(|op| {
2070                if let StagingOp::AddEdge { kind, .. } = op
2071                    && matches!(kind, UnifiedEdgeKind::Imports { .. })
2072                {
2073                    return Some(kind);
2074                }
2075                None
2076            })
2077            .collect()
2078    }
2079
2080    fn parse_lua(source: &str) -> Tree {
2081        let plugin = LuaPlugin::default();
2082        plugin.parse_ast(source.as_bytes()).unwrap()
2083    }
2084
2085    #[test]
2086    fn test_extracts_global_functions() {
2087        let source = r"
2088            function foo()
2089            end
2090
2091            function bar()
2092            end
2093        ";
2094
2095        let tree = parse_lua(source);
2096        let mut staging = StagingGraph::new();
2097        let builder = LuaGraphBuilder::default();
2098        let file = PathBuf::from("test.lua");
2099
2100        builder
2101            .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2102            .unwrap();
2103
2104        assert!(staging.node_count() >= 2);
2105        assert_has_node(&staging, "foo");
2106        assert_has_node(&staging, "bar");
2107    }
2108
2109    #[test]
2110    fn test_creates_call_edges() {
2111        let source = r"
2112            function caller()
2113                callee()
2114            end
2115
2116            function callee()
2117            end
2118        ";
2119
2120        let tree = parse_lua(source);
2121        let mut staging = StagingGraph::new();
2122        let builder = LuaGraphBuilder::default();
2123        let file = PathBuf::from("test.lua");
2124
2125        builder
2126            .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2127            .unwrap();
2128
2129        assert_has_node(&staging, "caller");
2130        assert_has_node(&staging, "callee");
2131
2132        let calls = collect_call_edges(&staging);
2133        assert!(!calls.is_empty(), "Expected at least one call edge");
2134    }
2135
2136    #[test]
2137    fn test_handles_module_methods() {
2138        let source = r"
2139            local MyModule = {}
2140
2141            function MyModule.method1()
2142                MyModule.method2()
2143            end
2144
2145            function MyModule:method2()
2146            end
2147        ";
2148
2149        let tree = parse_lua(source);
2150        let mut staging = StagingGraph::new();
2151        let builder = LuaGraphBuilder::default();
2152        let file = PathBuf::from("test.lua");
2153
2154        builder
2155            .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2156            .unwrap();
2157
2158        assert_has_node(&staging, "method1");
2159        assert_has_node(&staging, "method2");
2160        assert_has_call_edge(&staging, "MyModule::method1", "MyModule::method2");
2161    }
2162
2163    #[test]
2164    fn test_nested_functions_scope_resolution() {
2165        // HIGH FIX #1: Scope stack accumulation
2166        // Verifies that nested functions don't create "outer::outer::inner" paths
2167        let source = r"
2168            function outer()
2169                local function inner()
2170                    local function deep()
2171                    end
2172                end
2173            end
2174        ";
2175
2176        let tree = parse_lua(source);
2177        let mut staging = StagingGraph::new();
2178        let builder = LuaGraphBuilder::default();
2179        let file = PathBuf::from("test.lua");
2180
2181        builder
2182            .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2183            .unwrap();
2184
2185        // Check that nested functions have correct qualified names
2186        assert_has_node(&staging, "outer");
2187        assert_has_node(&staging, "outer::inner");
2188        assert_has_node(&staging, "outer::inner::deep");
2189
2190        // The main fix is verified: nested scopes have correct names
2191        // Note: Local function call resolution (e.g., deep() -> outer::inner::deep)
2192        // is a separate concern and not covered by this HIGH fix
2193    }
2194
2195    #[test]
2196    fn test_self_resolution_in_methods() {
2197        // HIGH FIX #2: Self resolution in method calls
2198        // Verifies that self:method() resolves to Module::method, not self::method
2199        let source = r"
2200            local MyModule = {}
2201
2202            function MyModule:method1()
2203                self:method2()
2204            end
2205
2206            function MyModule:method2()
2207                self:method3()
2208            end
2209
2210            function MyModule:method3()
2211                -- Empty
2212            end
2213        ";
2214
2215        let tree = parse_lua(source);
2216        let mut staging = StagingGraph::new();
2217        let builder = LuaGraphBuilder::default();
2218        let file = PathBuf::from("test.lua");
2219
2220        builder
2221            .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2222            .unwrap();
2223
2224        // Verify all methods exist
2225        assert_has_node(&staging, "MyModule::method1");
2226        assert_has_node(&staging, "MyModule::method2");
2227        assert_has_node(&staging, "MyModule::method3");
2228
2229        // Verify self:method2() resolves to MyModule::method2, not self::method2
2230        assert_has_call_edge(&staging, "MyModule::method1", "MyModule::method2");
2231
2232        // Verify self:method3() resolves correctly
2233        assert_has_call_edge(&staging, "MyModule::method2", "MyModule::method3");
2234    }
2235
2236    #[test]
2237    fn test_local_function_call_resolution() {
2238        // HIGH FIX #3: Local function call resolution
2239        // Verifies that calls to nested/local functions resolve to scoped names,
2240        // not bare identifiers that create synthetic nodes
2241        let source = r"
2242function outer()
2243    local function inner()
2244        local function deep()
2245        end
2246        deep()
2247    end
2248    inner()
2249end
2250";
2251
2252        let tree = parse_lua(source);
2253        let mut staging = StagingGraph::new();
2254        let builder = LuaGraphBuilder::default();
2255        let file = PathBuf::from("test.lua");
2256
2257        builder
2258            .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2259            .unwrap();
2260
2261        // Verify all functions exist with correct qualified names
2262        assert_has_node(&staging, "outer");
2263        assert_has_node(&staging, "outer::inner");
2264        assert_has_node(&staging, "outer::inner::deep");
2265
2266        // CRITICAL: Verify that inner() call from outer resolves to outer::inner
2267        assert_has_call_edge(&staging, "outer", "outer::inner");
2268
2269        // CRITICAL: Verify that deep() call from inner resolves to outer::inner::deep
2270        assert_has_call_edge(&staging, "outer::inner", "outer::inner::deep");
2271    }
2272
2273    #[test]
2274    fn test_nested_helper_self_resolution() {
2275        // HIGH FIX #4: Nested helpers inside colon methods should inherit module context
2276        // Verifies that self:inner() inside a helper defined within MyModule:outer()
2277        // resolves to MyModule::inner, not outer::inner
2278        let source = r"
2279MyModule = {}
2280
2281function MyModule:outer()
2282    local function helper()
2283        self:inner()
2284    end
2285    helper()
2286end
2287
2288function MyModule:inner()
2289end
2290";
2291
2292        let tree = parse_lua(source);
2293        let mut staging = StagingGraph::new();
2294        let builder = LuaGraphBuilder::default();
2295        let file = PathBuf::from("test.lua");
2296
2297        builder
2298            .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2299            .unwrap();
2300
2301        // Verify all functions exist
2302        assert_has_node(&staging, "MyModule::outer");
2303        assert_has_node(&staging, "MyModule::outer::helper");
2304        assert_has_node(&staging, "MyModule::inner");
2305
2306        // CRITICAL: Verify that self:inner() inside helper resolves to MyModule::inner
2307        assert_has_call_edge(&staging, "MyModule::outer::helper", "MyModule::inner");
2308
2309        // Verify outer calls helper
2310        assert_has_call_edge(&staging, "MyModule::outer", "MyModule::outer::helper");
2311    }
2312
2313    #[test]
2314    fn test_bracket_string_key_assignment() {
2315        let source = r#"
2316    local Module = {}
2317
2318    Module["string-key"] = function()
2319        return true
2320    end
2321
2322    local function call_it()
2323        Module["string-key"]()
2324    end
2325    "#;
2326
2327        let tree = parse_lua(source);
2328        let mut staging = StagingGraph::new();
2329        let builder = LuaGraphBuilder::default();
2330        let file = PathBuf::from("test.lua");
2331
2332        builder
2333            .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2334            .unwrap();
2335
2336        // Verify the bracket-indexed function exists
2337        assert_has_node(&staging, "Module::string-key");
2338        assert_has_node(&staging, "call_it");
2339
2340        // Verify the call edge
2341        assert_has_call_edge(&staging, "call_it", "Module::string-key");
2342    }
2343
2344    #[test]
2345    fn test_numeric_command_table_assignment() {
2346        let source = r#"
2347    local commands = {}
2348
2349    commands[1] = function()
2350        return "cmd"
2351    end
2352
2353    local function run()
2354        commands[1]()
2355    end
2356    "#;
2357
2358        let tree = parse_lua(source);
2359        let mut staging = StagingGraph::new();
2360        let builder = LuaGraphBuilder::default();
2361        let file = PathBuf::from("test.lua");
2362
2363        builder
2364            .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2365            .unwrap();
2366
2367        // Verify the numeric-indexed function exists
2368        assert_has_node(&staging, "commands::1");
2369        assert_has_node(&staging, "run");
2370
2371        // Verify the call edge
2372        assert_has_call_edge(&staging, "run", "commands::1");
2373    }
2374
2375    #[test]
2376    fn test_env_driven_name_injection() {
2377        let source = r#"
2378    _ENV["init"] = function()
2379        return true
2380    end
2381
2382    local function boot()
2383        init()
2384    end
2385    "#;
2386
2387        let tree = parse_lua(source);
2388        let mut staging = StagingGraph::new();
2389        let builder = LuaGraphBuilder::default();
2390        let file = PathBuf::from("test.lua");
2391
2392        builder
2393            .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2394            .unwrap();
2395
2396        // Verify the _ENV-prefixed function is stripped to plain "init"
2397        assert_has_node(&staging, "init");
2398        assert_has_node(&staging, "boot");
2399
2400        // Verify the call edge
2401        assert_has_call_edge(&staging, "boot", "init");
2402    }
2403
2404    #[test]
2405    fn test_deep_namespace_lexical_depth() {
2406        // HIGH FIX #5: Lexical depth tracking separate from namespace depth
2407        // Verifies that methods with deep namespace paths don't hit depth limit prematurely
2408        let source = r"
2409Company = {}
2410Company.Product = {}
2411Company.Product.Component = {}
2412
2413function Company.Product.Component:outer()
2414    local function helper1()
2415        local function helper2()
2416            self:inner()
2417        end
2418        helper2()
2419    end
2420    helper1()
2421end
2422
2423function Company.Product.Component:inner()
2424end
2425";
2426
2427        let tree = parse_lua(source);
2428        let mut staging = StagingGraph::new();
2429        let builder = LuaGraphBuilder::default(); // max_scope_depth = 4
2430        let file = PathBuf::from("test.lua");
2431
2432        builder
2433            .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2434            .unwrap();
2435
2436        // Verify all functions exist (including deeply nested ones)
2437        assert_has_node(&staging, "Company::Product::Component::outer");
2438        assert_has_node(&staging, "Company::Product::Component::outer::helper1");
2439        assert_has_node(
2440            &staging,
2441            "Company::Product::Component::outer::helper1::helper2",
2442        );
2443        assert_has_node(&staging, "Company::Product::Component::inner");
2444
2445        // CRITICAL: Verify that self:inner() inside helper2 resolves correctly
2446        // despite deep namespace path (3 namespace segments shouldn't count as lexical depth)
2447        assert_has_call_edge(
2448            &staging,
2449            "Company::Product::Component::outer::helper1::helper2",
2450            "Company::Product::Component::inner",
2451        );
2452
2453        // Verify the helper call chain
2454        assert_has_call_edge(
2455            &staging,
2456            "Company::Product::Component::outer",
2457            "Company::Product::Component::outer::helper1",
2458        );
2459        assert_has_call_edge(
2460            &staging,
2461            "Company::Product::Component::outer::helper1",
2462            "Company::Product::Component::outer::helper1::helper2",
2463        );
2464    }
2465
2466    #[test]
2467    fn test_nested_method_redefinition() {
2468        // HIGH FIX #6: Fully-qualified method redefinition inside another method
2469        // Verifies that "function MyModule:inner()" defined inside MyModule:outer()
2470        // creates MyModule::inner (not MyModule::outer::MyModule::inner)
2471        let source = r"
2472MyModule = {}
2473
2474function MyModule:outer()
2475    function MyModule:inner()
2476        -- redefined inside outer
2477    end
2478    self:inner()
2479end
2480";
2481
2482        let tree = parse_lua(source);
2483        let mut staging = StagingGraph::new();
2484        let builder = LuaGraphBuilder::default();
2485        let file = PathBuf::from("test.lua");
2486
2487        builder
2488            .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2489            .unwrap();
2490
2491        // Verify both functions exist with correct qualified names
2492        assert_has_node(&staging, "MyModule::outer");
2493        assert_has_node(&staging, "MyModule::inner");
2494
2495        // CRITICAL: Verify that self:inner() call from outer resolves to MyModule::inner
2496        assert_has_call_edge(&staging, "MyModule::outer", "MyModule::inner");
2497    }
2498
2499    // ============================================================================
2500    // Import Edge Tests (Wave 7)
2501    // ============================================================================
2502
2503    #[test]
2504    fn test_require_import_edge_double_quotes() {
2505        let source = r#"
2506            local json = require("cjson")
2507        "#;
2508
2509        let tree = parse_lua(source);
2510        let mut staging = StagingGraph::new();
2511        let builder = LuaGraphBuilder::default();
2512        let file = PathBuf::from("test.lua");
2513
2514        builder
2515            .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2516            .unwrap();
2517
2518        let import_edges = extract_import_edges(&staging);
2519        assert!(
2520            !import_edges.is_empty(),
2521            "Expected at least one import edge"
2522        );
2523
2524        // Lua require() returns a module reference, NOT a wildcard import
2525        let edge = import_edges[0];
2526        if let UnifiedEdgeKind::Imports { is_wildcard, .. } = edge {
2527            assert!(
2528                !*is_wildcard,
2529                "Lua require returns module reference, not wildcard"
2530            );
2531        } else {
2532            panic!("Expected Imports edge kind");
2533        }
2534    }
2535
2536    #[test]
2537    fn test_require_import_edge_single_quotes() {
2538        let source = r"
2539            local socket = require('socket')
2540        ";
2541
2542        let tree = parse_lua(source);
2543        let mut staging = StagingGraph::new();
2544        let builder = LuaGraphBuilder::default();
2545        let file = PathBuf::from("test.lua");
2546
2547        builder
2548            .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2549            .unwrap();
2550
2551        let import_edges = extract_import_edges(&staging);
2552        assert!(!import_edges.is_empty(), "Expected require import edge");
2553
2554        // Verify it's an Import edge with correct metadata
2555        let edge = import_edges[0];
2556        if let UnifiedEdgeKind::Imports { is_wildcard, .. } = edge {
2557            assert!(
2558                !*is_wildcard,
2559                "Lua require returns module reference, not wildcard"
2560            );
2561        } else {
2562            panic!("Expected Imports edge kind");
2563        }
2564    }
2565
2566    #[test]
2567    fn test_require_dotted_module() {
2568        let source = r#"
2569            local util = require("luasocket.util")
2570        "#;
2571
2572        let tree = parse_lua(source);
2573        let mut staging = StagingGraph::new();
2574        let builder = LuaGraphBuilder::default();
2575        let file = PathBuf::from("test.lua");
2576
2577        builder
2578            .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2579            .unwrap();
2580
2581        let import_edges = extract_import_edges(&staging);
2582        assert!(
2583            !import_edges.is_empty(),
2584            "Expected dotted module import edge"
2585        );
2586
2587        // Verify it's an Import edge
2588        let edge = import_edges[0];
2589        assert!(
2590            matches!(edge, UnifiedEdgeKind::Imports { .. }),
2591            "Expected Imports edge kind"
2592        );
2593    }
2594
2595    #[test]
2596    fn test_multiple_requires() {
2597        let source = r#"
2598            local json = require("cjson")
2599            local socket = require("socket")
2600            local lpeg = require("lpeg")
2601            local lfs = require("lfs")
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_eq!(import_edges.len(), 4, "Expected 4 import edges");
2615
2616        // Verify all are EdgeKind::Imports
2617        for edge in &import_edges {
2618            assert!(
2619                matches!(edge, UnifiedEdgeKind::Imports { .. }),
2620                "All edges should be Imports"
2621            );
2622        }
2623    }
2624}
2625
2626#[cfg(test)]
2627mod shape_tests {
2628    //! Coverage for the Lua [`ShapeMapping`]. Consumes the hand-written
2629    //! control-flow fixture so the test is load-bearing.
2630
2631    use super::{cf_bucket_for_lua_kind, lua_shape_mapping};
2632    use sqry_core::graph::unified::build::shape::{
2633        CfBucket, ShapeBudget, ShapeMapping, compute_shape_descriptor,
2634    };
2635    use tree_sitter::{Node, Parser, Tree};
2636
2637    const SAMPLE: &str = include_str!(concat!(
2638        env!("CARGO_MANIFEST_DIR"),
2639        "/../test-fixtures/shape/dynamic/lua.lua"
2640    ));
2641
2642    fn parse(src: &str) -> Tree {
2643        let mut parser = Parser::new();
2644        parser
2645            .set_language(&tree_sitter_lua::LANGUAGE.into())
2646            .expect("load lua grammar");
2647        parser.parse(src, None).expect("parse lua")
2648    }
2649
2650    fn first_function<'t>(tree: &'t Tree) -> Node<'t> {
2651        let root = tree.root_node();
2652        let mut cursor = root.walk();
2653        for child in root.named_children(&mut cursor) {
2654            if child.kind() == "function_declaration" {
2655                return child;
2656            }
2657        }
2658        panic!("no function_declaration in lua fixture");
2659    }
2660
2661    #[test]
2662    fn mapping_is_non_empty_and_covers_real_kinds() {
2663        assert_eq!(
2664            cf_bucket_for_lua_kind("if_statement"),
2665            Some(CfBucket::Branch)
2666        );
2667        assert_eq!(
2668            cf_bucket_for_lua_kind("while_statement"),
2669            Some(CfBucket::Loop)
2670        );
2671        assert_eq!(
2672            cf_bucket_for_lua_kind("repeat_statement"),
2673            Some(CfBucket::Loop)
2674        );
2675        assert_eq!(
2676            cf_bucket_for_lua_kind("break_statement"),
2677            Some(CfBucket::BreakContinue)
2678        );
2679        assert_eq!(
2680            cf_bucket_for_lua_kind("return_statement"),
2681            Some(CfBucket::Return)
2682        );
2683        assert_eq!(
2684            cf_bucket_for_lua_kind("function_call"),
2685            Some(CfBucket::Call)
2686        );
2687        assert_eq!(
2688            cf_bucket_for_lua_kind("function_definition"),
2689            Some(CfBucket::Closure)
2690        );
2691        assert_eq!(cf_bucket_for_lua_kind("nope"), None);
2692
2693        let lang: tree_sitter::Language = tree_sitter_lua::LANGUAGE.into();
2694        let id = (0..lang.node_kind_count())
2695            .map(|i| i as u16)
2696            .find(|&i| {
2697                lang.node_kind_is_named(i) && lang.node_kind_for_id(i) == Some("if_statement")
2698            })
2699            .expect("grammar exposes named if_statement");
2700        assert_eq!(lua_shape_mapping().cf_bucket(id), Some(CfBucket::Branch));
2701    }
2702
2703    #[test]
2704    fn descriptor_covers_fixture_control_flow() {
2705        let tree = parse(SAMPLE);
2706        let func = first_function(&tree);
2707        let descriptor = compute_shape_descriptor(
2708            func,
2709            SAMPLE.as_bytes(),
2710            lua_shape_mapping(),
2711            &ShapeBudget::default(),
2712        );
2713        let hist = descriptor.cf_histogram;
2714        assert!(hist[CfBucket::Branch.index()] >= 1, "branch (if)");
2715        assert!(hist[CfBucket::Loop.index()] >= 1, "loop (while/for/repeat)");
2716        assert!(hist[CfBucket::BreakContinue.index()] >= 1, "break");
2717        assert!(hist[CfBucket::Return.index()] >= 1, "return");
2718        assert!(hist[CfBucket::Call.index()] >= 1, "call");
2719        assert!(hist[CfBucket::Assign.index()] >= 1, "assignment");
2720        assert!(
2721            hist[CfBucket::Closure.index()] >= 1,
2722            "nested function literal"
2723        );
2724    }
2725
2726    #[test]
2727    fn signature_shape_reads_arity_and_varargs() {
2728        let tree = parse(SAMPLE);
2729        let func = first_function(&tree);
2730        let shape = lua_shape_mapping().signature_shape(func, SAMPLE.as_bytes());
2731        // `local function classify(value, label, ...)`.
2732        assert_eq!(shape.arity_positional, 2, "value + label");
2733        assert!(shape.has_varargs, "...");
2734    }
2735}