Skip to main content

sqry_lang_lua/relations/
graph_builder.rs

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