1use std::{collections::HashMap, path::Path};
10
11use sqry_core::graph::unified::edge::FfiConvention;
12use sqry_core::graph::unified::{GraphBuildHelper, NodeKind, StagingGraph};
13use sqry_core::graph::{GraphBuilder, GraphBuilderError, GraphResult, Language, Position, Span};
14use tree_sitter::{Node, Tree};
15
16const DEFAULT_SCOPE_DEPTH: usize = 4;
17
18const FILE_MODULE_NAME: &str = "<file_module>";
21
22#[derive(Debug, Clone, PartialEq, Eq)]
24enum FfiEntity {
25 Module,
27 CLibrary,
29 LoadedLibrary(String),
31}
32
33type FfiAliasTable = HashMap<String, FfiEntity>;
35
36#[derive(Debug, Clone)]
38struct FfiCallInfo {
39 function_name: String,
41 library_name: Option<String>,
43}
44
45#[derive(Debug, Clone, Copy)]
47pub struct LuaGraphBuilder {
48 max_scope_depth: usize,
49}
50
51impl Default for LuaGraphBuilder {
52 fn default() -> Self {
53 Self {
54 max_scope_depth: DEFAULT_SCOPE_DEPTH,
55 }
56 }
57}
58
59impl LuaGraphBuilder {
60 #[must_use]
61 pub fn new(max_scope_depth: usize) -> Self {
62 Self { max_scope_depth }
63 }
64}
65
66impl GraphBuilder for LuaGraphBuilder {
67 fn build_graph(
68 &self,
69 tree: &Tree,
70 content: &[u8],
71 file: &Path,
72 staging: &mut StagingGraph,
73 ) -> GraphResult<()> {
74 let mut helper = GraphBuildHelper::new(staging, file, Language::Lua);
76
77 let ast_graph = ASTGraph::from_tree(tree, content, self.max_scope_depth).map_err(|e| {
79 GraphBuilderError::ParseError {
80 span: Span::default(),
81 reason: e,
82 }
83 })?;
84
85 let recursion_limits =
87 sqry_core::config::RecursionLimits::load_or_default().map_err(|e| {
88 GraphBuilderError::ParseError {
89 span: Span::default(),
90 reason: format!("Failed to load recursion limits: {e}"),
91 }
92 })?;
93 let file_ops_depth = recursion_limits.effective_file_ops_depth().map_err(|e| {
94 GraphBuilderError::ParseError {
95 span: Span::default(),
96 reason: format!("Invalid file_ops_depth configuration: {e}"),
97 }
98 })?;
99 let mut guard =
100 sqry_core::query::security::RecursionGuard::new(file_ops_depth).map_err(|e| {
101 GraphBuilderError::ParseError {
102 span: Span::default(),
103 reason: format!("Failed to create recursion guard: {e}"),
104 }
105 })?;
106
107 let mut ffi_aliases = FfiAliasTable::new();
109 populate_ffi_aliases(tree.root_node(), content, &mut ffi_aliases);
110
111 walk_tree_for_graph(
113 tree.root_node(),
114 content,
115 &ast_graph,
116 &mut helper,
117 &mut guard,
118 &ffi_aliases,
119 )?;
120
121 Ok(())
122 }
123
124 fn language(&self) -> Language {
125 Language::Lua
126 }
127}
128
129fn is_local_function(node: Node<'_>) -> bool {
131 let Some(parent) = node.parent() else {
132 return false;
133 };
134
135 if let Some(index) = named_child_index(parent, node)
141 && let Some(field) = parent.field_name_for_named_child(index)
142 {
143 return field == "local_declaration";
144 }
145
146 if let Some(index) = child_index(parent, node)
147 && let Some(field) = parent.field_name_for_child(index)
148 {
149 return field == "local_declaration";
150 }
151
152 if parent.kind() == "local_variable_declaration" {
154 return true;
155 }
156
157 false
158}
159
160#[allow(clippy::unnecessary_wraps)]
163fn get_function_visibility(qualified_name: &str) -> Option<&'static str> {
164 let function_name = qualified_name.rsplit("::").next().unwrap_or(qualified_name);
166
167 if function_name.starts_with('_') {
168 Some("private")
169 } else {
170 Some("public")
171 }
172}
173
174fn named_child_index(parent: Node<'_>, target: Node<'_>) -> Option<u32> {
176 for i in 0..parent.named_child_count() {
177 if let Some(child) = parent.named_child(i as u32)
178 && child.id() == target.id()
179 {
180 let index = u32::try_from(i).ok()?;
181 return Some(index);
182 }
183 }
184 None
185}
186
187fn child_index(parent: Node<'_>, target: Node<'_>) -> Option<u32> {
189 let mut cursor = parent.walk();
190 if !cursor.goto_first_child() {
191 return None;
192 }
193 let mut index = 0u32;
194 loop {
195 if cursor.node().id() == target.id() {
196 return Some(index);
197 }
198 if !cursor.goto_next_sibling() {
199 break;
200 }
201 index += 1;
202 }
203 None
204}
205
206#[allow(clippy::too_many_lines)] fn walk_tree_for_graph(
212 node: Node,
213 content: &[u8],
214 ast_graph: &ASTGraph,
215 helper: &mut GraphBuildHelper,
216 guard: &mut sqry_core::query::security::RecursionGuard,
217 ffi_aliases: &FfiAliasTable,
218) -> GraphResult<()> {
219 guard.enter().map_err(|e| GraphBuilderError::ParseError {
220 span: Span::default(),
221 reason: format!("Recursion limit exceeded: {e}"),
222 })?;
223
224 match node.kind() {
225 "function_declaration" => {
229 if let Some(call_context) = ast_graph.get_callable_context(node.id()) {
231 let span = span_from_node(node);
232
233 let is_local = is_local_function(node);
235 let visibility = get_function_visibility(&call_context.qualified_name);
237
238 if call_context.is_method {
240 let node_id = helper.add_method_with_visibility(
241 &call_context.qualified_name,
242 Some(span),
243 false, false, visibility,
246 );
247 if call_context.module_name.is_some() && !is_local {
250 let module_id = helper.add_module(FILE_MODULE_NAME, None);
251 helper.add_export_edge(module_id, node_id);
252 }
253 } else {
254 let node_id = helper.add_function_with_visibility(
255 &call_context.qualified_name,
256 Some(span),
257 false, false, visibility,
260 );
261 let is_module_scoped = call_context.qualified_name.contains("::");
264 let is_global = !is_local && !is_module_scoped;
265
266 if (is_module_scoped || is_global) && !is_local {
267 let module_id = helper.add_module(FILE_MODULE_NAME, None);
268 helper.add_export_edge(module_id, node_id);
269 }
270 }
271 }
272 }
273 "assignment_statement" => {
274 let mut cursor = node.walk();
277 let func_def = node
278 .children(&mut cursor)
279 .find(|child| child.kind() == "expression_list")
280 .and_then(|expr_list| {
281 expr_list
282 .named_children(&mut expr_list.walk())
283 .find(|child| child.kind() == "function_definition")
284 });
285
286 if let Some(func_def_node) = func_def {
287 if let Some(call_context) = ast_graph.get_callable_context(func_def_node.id()) {
289 let span = span_from_node(node);
290
291 let is_local = is_local_function(node);
293 let visibility = get_function_visibility(&call_context.qualified_name);
295
296 if call_context.is_method {
298 let node_id = helper.add_method_with_visibility(
299 &call_context.qualified_name,
300 Some(span),
301 false,
302 false,
303 visibility,
304 );
305 if call_context.module_name.is_some() && !is_local {
308 let module_id = helper.add_module(FILE_MODULE_NAME, None);
309 helper.add_export_edge(module_id, node_id);
310 }
311 } else {
312 let node_id = helper.add_function_with_visibility(
313 &call_context.qualified_name,
314 Some(span),
315 false,
316 false,
317 visibility,
318 );
319 if call_context.qualified_name.contains("::") && !is_local {
323 let module_id = helper.add_module(FILE_MODULE_NAME, None);
324 helper.add_export_edge(module_id, node_id);
325 }
326 }
327 }
328 }
329 }
330 "return_statement" => {
331 handle_return_table_exports(node, content, helper);
334 }
335 "function_call" => {
336 if let Some(ffi_info) = extract_ffi_call_info(node, content, ffi_aliases) {
338 emit_ffi_edge(ffi_info, node, content, ast_graph, helper);
339 }
340 else if is_require_call(node, content) {
342 build_require_import_edge(node, content, helper);
344 }
345 else if let Ok(Some((caller_qname, callee_qname, argument_count, span))) =
347 build_call_for_staging(ast_graph, node, content)
348 {
349 let call_context = ast_graph.get_callable_context(node.id());
351 let is_method = call_context.is_some_and(|c| c.is_method);
352
353 let source_id = if is_method {
354 helper.ensure_method(&caller_qname, None, false, false)
355 } else {
356 helper.ensure_function(&caller_qname, None, false, false)
357 };
358 let target_id = helper.ensure_function(&callee_qname, None, false, false);
359
360 let argument_count = u8::try_from(argument_count).unwrap_or(u8::MAX);
362 helper.add_call_edge_full_with_span(
363 source_id,
364 target_id,
365 argument_count,
366 false,
367 vec![span],
368 );
369 }
370 }
371 "table_constructor" => {
372 build_table_fields(node, content, helper)?;
375 }
376 "dot_index_expression" | "bracket_index_expression" => {
377 build_field_access(node, content, helper)?;
380 }
381 _ => {}
382 }
383
384 let mut cursor = node.walk();
386 for child in node.children(&mut cursor) {
387 walk_tree_for_graph(child, content, ast_graph, helper, guard, ffi_aliases)?;
388 }
389
390 guard.exit();
391 Ok(())
392}
393
394fn handle_return_table_exports(node: Node<'_>, content: &[u8], helper: &mut GraphBuildHelper) {
399 let Some(expr_list) = node
402 .children(&mut node.walk())
403 .find(|child| child.kind() == "expression_list")
404 else {
405 return;
406 };
407
408 let Some(table_node) = expr_list
409 .children(&mut expr_list.walk())
410 .find(|child| child.kind() == "table_constructor")
411 else {
412 return;
413 };
414
415 let module_id = helper.add_module(FILE_MODULE_NAME, None);
416
417 let mut cursor = table_node.walk();
419 for field in table_node.children(&mut cursor) {
420 if field.kind() != "field" {
421 continue;
422 }
423
424 let key_name = if let Some(name_node) = field.child_by_field_name("name") {
426 name_node
428 .utf8_text(content)
429 .ok()
430 .map(|s| s.trim().to_string())
431 } else {
432 continue;
434 };
435
436 let Some(key) = key_name else {
437 continue;
438 };
439
440 let Some(value_node) = field.child_by_field_name("value") else {
442 continue;
443 };
444
445 let exported_name = match value_node.kind() {
452 "identifier" => {
453 value_node.utf8_text(content).ok().map(str::to_string)
455 }
456 "dot_index_expression" | "method_index_expression" => {
457 extract_table_field_qualified_name(value_node, content).ok()
460 }
461 "function_definition" => {
462 Some(key.clone())
465 }
466 _ => None,
467 };
468
469 if let Some(name) = exported_name {
470 let exported_id = helper.ensure_function(&name, None, false, false);
474
475 helper.add_export_edge(module_id, exported_id);
476 }
477 }
478}
479
480fn extract_table_field_qualified_name(node: Node<'_>, content: &[u8]) -> Result<String, String> {
482 match node.kind() {
483 "identifier" => node
484 .utf8_text(content)
485 .map(str::to_string)
486 .map_err(|_| "failed to read identifier".to_string()),
487 "dot_index_expression" => {
488 let table = node
489 .child_by_field_name("table")
490 .ok_or_else(|| "dot_index_expression missing table".to_string())?;
491 let field = node
492 .child_by_field_name("field")
493 .ok_or_else(|| "dot_index_expression missing field".to_string())?;
494
495 let table_text = extract_table_field_qualified_name(table, content)?;
496 let field_text = field
497 .utf8_text(content)
498 .map_err(|_| "failed to read field".to_string())?;
499
500 Ok(format!("{table_text}::{field_text}"))
501 }
502 "method_index_expression" => {
503 let table = node
504 .child_by_field_name("table")
505 .ok_or_else(|| "method_index_expression missing table".to_string())?;
506 let method = node
507 .child_by_field_name("method")
508 .ok_or_else(|| "method_index_expression missing method".to_string())?;
509
510 let table_text = extract_table_field_qualified_name(table, content)?;
511 let method_text = method
512 .utf8_text(content)
513 .map_err(|_| "failed to read method".to_string())?;
514
515 Ok(format!("{table_text}::{method_text}"))
516 }
517 _ => node
518 .utf8_text(content)
519 .map(str::to_string)
520 .map_err(|_| "failed to read node".to_string()),
521 }
522}
523
524fn is_require_call(call_node: Node<'_>, content: &[u8]) -> bool {
526 if let Some(name_node) = call_node.child_by_field_name("name")
528 && let Ok(text) = name_node.utf8_text(content)
529 {
530 return text.trim() == "require";
531 }
532 false
533}
534
535fn build_require_import_edge(call_node: Node<'_>, content: &[u8], helper: &mut GraphBuildHelper) {
537 let Some(args_node) = call_node.child_by_field_name("arguments") else {
540 return;
541 };
542
543 let mut cursor = args_node.walk();
545 let mut module_name: Option<String> = None;
546
547 for child in args_node.children(&mut cursor) {
548 if (child.kind() == "string" || child.kind() == "string_content")
549 && let Ok(text) = child.utf8_text(content)
550 {
551 let trimmed = text
553 .trim()
554 .trim_start_matches(['"', '\'', '['])
555 .trim_end_matches(['"', '\'', ']'])
556 .to_string();
557 if !trimmed.is_empty() {
558 module_name = Some(trimmed);
559 break;
560 }
561 }
562 let mut inner_cursor = child.walk();
564 for inner_child in child.children(&mut inner_cursor) {
565 if inner_child.kind() == "string_content"
566 && let Ok(text) = inner_child.utf8_text(content)
567 {
568 let trimmed = text.trim().to_string();
569 if !trimmed.is_empty() {
570 module_name = Some(trimmed);
571 break;
572 }
573 }
574 }
575 if module_name.is_some() {
576 break;
577 }
578 }
579
580 if let Some(imported_module) = module_name {
582 let span = span_from_node(call_node);
583
584 let module_id = helper.add_module("<module>", None);
586 let import_id = helper.add_import(&imported_module, Some(span));
587
588 helper.add_import_edge_full(module_id, import_id, None, false);
591 }
592}
593
594fn populate_ffi_aliases(node: Node, content: &[u8], aliases: &mut FfiAliasTable) {
603 match node.kind() {
604 "local_variable_declaration" | "assignment_statement" => {
605 extract_ffi_assignment(node, content, aliases);
607 }
608 _ => {}
609 }
610
611 let mut cursor = node.walk();
613 for child in node.children(&mut cursor) {
614 populate_ffi_aliases(child, content, aliases);
615 }
616}
617
618#[cfg(test)]
619#[allow(dead_code)]
620fn debug_node_structure(node: Node, content: &[u8], indent: usize) {
621 let indent_str = " ".repeat(indent);
622 let text = node.utf8_text(content).ok().and_then(|t| {
623 let trimmed = t.trim();
624 if trimmed.len() > 50 || trimmed.is_empty() {
625 None
626 } else {
627 Some(trimmed)
628 }
629 });
630
631 eprintln!(
632 "{}{}{}",
633 indent_str,
634 node.kind(),
635 text.map(|t| format!(" [{t}]")).unwrap_or_default()
636 );
637
638 if indent < 10 {
639 let mut cursor = node.walk();
640 for child in node.children(&mut cursor) {
641 debug_node_structure(child, content, indent + 1);
642 }
643 }
644}
645
646fn extract_ffi_assignment(node: Node, content: &[u8], aliases: &mut FfiAliasTable) {
648 let assignment = if node.kind() == "variable_declaration" {
652 let mut cursor = node.walk();
654 node.children(&mut cursor)
655 .find(|c| c.kind() == "assignment_statement")
656 } else if node.kind() == "assignment_statement" {
657 Some(node)
658 } else {
659 None
660 };
661
662 if let Some(assign_node) = assignment {
663 extract_from_assignment(assign_node, content, aliases);
664 }
665}
666
667fn extract_from_assignment(assign_node: Node, content: &[u8], aliases: &mut FfiAliasTable) {
669 let mut cursor = assign_node.walk();
671 let children: Vec<_> = assign_node.children(&mut cursor).collect();
672
673 let Some(var_list) = children.iter().find(|c| c.kind() == "variable_list") else {
674 return;
675 };
676 let Some(expr_list) = children.iter().find(|c| c.kind() == "expression_list") else {
677 return;
678 };
679
680 let Some(var_name_node) = var_list.named_child(0) else {
682 return;
683 };
684 let Ok(var_name) = var_name_node.utf8_text(content) else {
685 return;
686 };
687 let var_name = var_name.trim().to_string();
688
689 let Some(value_node) = expr_list.named_child(0) else {
691 return;
692 };
693
694 if is_require_ffi_call(value_node, content) {
696 aliases.insert(var_name, FfiEntity::Module);
697 } else if is_ffi_c_reference(value_node, content, aliases) {
698 aliases.insert(var_name, FfiEntity::CLibrary);
699 } else if let Some(lib_name) = extract_ffi_load_library(value_node, content, aliases) {
700 aliases.insert(var_name, FfiEntity::LoadedLibrary(lib_name));
701 }
702 else if value_node.kind() == "identifier"
704 && let Ok(alias_name) = value_node.utf8_text(content)
705 {
706 let alias_name = alias_name.trim();
707 if let Some(entity) = aliases.get(alias_name).cloned() {
708 aliases.insert(var_name, entity);
710 }
711 }
712}
713
714fn is_require_ffi_call(node: Node, content: &[u8]) -> bool {
716 if node.kind() != "function_call" {
717 return false;
718 }
719
720 let Some(name_node) = node.child_by_field_name("name") else {
722 return false;
723 };
724 let Ok(name_text) = name_node.utf8_text(content) else {
725 return false;
726 };
727 if name_text.trim() != "require" {
728 return false;
729 }
730
731 let Some(args_node) = node.child_by_field_name("arguments") else {
733 return false;
734 };
735 let Some(first_arg) = args_node.named_child(0) else {
736 return false;
737 };
738
739 if let Some(content_str) = extract_string_content(first_arg, content) {
741 return content_str == "ffi";
742 }
743
744 false
745}
746
747fn is_ffi_c_reference(node: Node, content: &[u8], aliases: &FfiAliasTable) -> bool {
749 if node.kind() != "dot_index_expression" {
750 return false;
751 }
752
753 let Some(table_node) = node.child_by_field_name("table") else {
754 return false;
755 };
756 let Some(field_node) = node.child_by_field_name("field") else {
757 return false;
758 };
759
760 let Ok(table_text) = table_node.utf8_text(content) else {
761 return false;
762 };
763 let Ok(field_text) = field_node.utf8_text(content) else {
764 return false;
765 };
766
767 let table_text = table_text.trim();
768 let field_text = field_text.trim();
769
770 aliases.get(table_text) == Some(&FfiEntity::Module) && field_text == "C"
772}
773
774fn extract_ffi_load_library(node: Node, content: &[u8], aliases: &FfiAliasTable) -> Option<String> {
776 if node.kind() != "function_call" {
777 return None;
778 }
779
780 let name_node = node.child_by_field_name("name")?;
782 if name_node.kind() != "dot_index_expression" {
783 return None;
784 }
785
786 let table_node = name_node.child_by_field_name("table")?;
787 let field_node = name_node.child_by_field_name("field")?;
788
789 let table_text = table_node.utf8_text(content).ok()?;
790 let field_text = field_node.utf8_text(content).ok()?;
791
792 let table_text = table_text.trim();
793 let field_text = field_text.trim();
794
795 if aliases.get(table_text) != Some(&FfiEntity::Module) || field_text != "load" {
797 return None;
798 }
799
800 let args_node = node.child_by_field_name("arguments")?;
802 let first_arg = args_node.named_child(0)?;
803
804 extract_string_content(first_arg, content)
806}
807
808fn extract_string_content(string_node: Node, content: &[u8]) -> Option<String> {
810 if string_node.kind() == "string" {
811 let mut cursor = string_node.walk();
813 for child in string_node.children(&mut cursor) {
814 if child.kind() == "string_content"
815 && let Ok(text) = child.utf8_text(content)
816 {
817 return Some(text.trim().to_string());
818 }
819 }
820
821 if let Ok(text) = string_node.utf8_text(content) {
823 let trimmed = text
824 .trim()
825 .trim_start_matches(['"', '\'', '['])
826 .trim_end_matches(['"', '\'', ']'])
827 .to_string();
828 if !trimmed.is_empty() {
829 return Some(trimmed);
830 }
831 }
832 }
833 None
834}
835
836fn extract_ffi_call_info(
838 call_node: Node,
839 content: &[u8],
840 aliases: &FfiAliasTable,
841) -> Option<FfiCallInfo> {
842 let name_node = call_node.child_by_field_name("name")?;
843
844 match name_node.kind() {
846 "dot_index_expression" => {
847 if is_ffi_load_call(name_node, content, aliases) {
849 return extract_ffi_load_call_info(call_node, content);
850 }
851 extract_ffi_from_dot_expression(name_node, content, aliases)
853 }
854 _ => None,
855 }
856}
857
858fn is_ffi_load_call(dot_expr: Node, content: &[u8], aliases: &FfiAliasTable) -> bool {
860 let Some(table_node) = dot_expr.child_by_field_name("table") else {
861 return false;
862 };
863 let Some(field_node) = dot_expr.child_by_field_name("field") else {
864 return false;
865 };
866
867 let Ok(table_text) = table_node.utf8_text(content) else {
868 return false;
869 };
870 let Ok(field_text) = field_node.utf8_text(content) else {
871 return false;
872 };
873
874 aliases.get(table_text.trim()) == Some(&FfiEntity::Module) && field_text.trim() == "load"
875}
876
877fn extract_ffi_load_call_info(call_node: Node, content: &[u8]) -> Option<FfiCallInfo> {
879 let args_node = call_node.child_by_field_name("arguments")?;
880 let first_arg = args_node.named_child(0)?;
881
882 let lib_name = extract_string_content(first_arg, content)?;
883
884 Some(FfiCallInfo {
887 function_name: lib_name,
888 library_name: None,
889 })
890}
891
892fn extract_ffi_from_dot_expression(
894 dot_expr: Node,
895 content: &[u8],
896 aliases: &FfiAliasTable,
897) -> Option<FfiCallInfo> {
898 let table_node = dot_expr.child_by_field_name("table")?;
899 let field_node = dot_expr.child_by_field_name("field")?;
900
901 let function_name = field_node.utf8_text(content).ok()?.trim().to_string();
902
903 if table_node.kind() == "dot_index_expression" {
905 let inner_table = table_node.child_by_field_name("table")?;
906 let inner_field = table_node.child_by_field_name("field")?;
907
908 let base_text = inner_table.utf8_text(content).ok()?.trim();
909 let mid_text = inner_field.utf8_text(content).ok()?.trim();
910
911 if aliases.get(base_text) == Some(&FfiEntity::Module) && mid_text == "C" {
913 return Some(FfiCallInfo {
914 function_name,
915 library_name: None,
916 });
917 }
918 } else {
919 let table_text = table_node.utf8_text(content).ok()?.trim();
921
922 match aliases.get(table_text) {
923 Some(FfiEntity::CLibrary) => {
924 return Some(FfiCallInfo {
925 function_name,
926 library_name: None,
927 });
928 }
929 Some(FfiEntity::LoadedLibrary(lib_name)) => {
930 return Some(FfiCallInfo {
931 function_name,
932 library_name: Some(lib_name.clone()),
933 });
934 }
935 _ => {}
936 }
937 }
938
939 None
940}
941
942fn emit_ffi_edge(
944 ffi_info: FfiCallInfo,
945 call_node: Node,
946 content: &[u8],
947 ast_graph: &ASTGraph,
948 helper: &mut GraphBuildHelper,
949) {
950 let caller_id = get_ffi_caller_node_id(call_node, content, ast_graph, helper);
952
953 let target_name = if let Some(lib) = ffi_info.library_name {
955 format!("native::{}::{}", lib, ffi_info.function_name)
956 } else {
957 format!("native::{}", ffi_info.function_name)
958 };
959
960 let target_id = helper.ensure_function(&target_name, None, false, false);
962
963 helper.add_ffi_edge(caller_id, target_id, FfiConvention::C);
965}
966
967fn get_ffi_caller_node_id(
969 call_node: Node,
970 content: &[u8],
971 ast_graph: &ASTGraph,
972 helper: &mut GraphBuildHelper,
973) -> sqry_core::graph::unified::node::NodeId {
974 if let Some(call_context) = ast_graph.get_callable_context(call_node.id()) {
976 if call_context.is_method {
977 return helper.ensure_method(&call_context.qualified_name, None, false, false);
978 }
979 return helper.ensure_function(&call_context.qualified_name, None, false, false);
980 }
981
982 let mut current = call_node.parent();
984 while let Some(node) = current {
985 if node.kind() == "function_declaration" || node.kind() == "function_definition" {
986 if let Some(name_node) = node.child_by_field_name("name")
988 && let Ok(name_text) = name_node.utf8_text(content)
989 {
990 return helper.ensure_function(name_text, None, false, false);
991 }
992 }
993 current = node.parent();
994 }
995
996 helper.ensure_function("<file_level>", None, false, false)
998}
999
1000fn build_call_for_staging(
1002 ast_graph: &ASTGraph,
1003 call_node: Node<'_>,
1004 content: &[u8],
1005) -> GraphResult<Option<(String, String, usize, Span)>> {
1006 let module_context;
1008 let call_context = if let Some(ctx) = ast_graph.get_callable_context(call_node.id()) {
1009 ctx
1010 } else {
1011 module_context = CallContext {
1013 qualified_name: "<module>".to_string(),
1014 span: (0, content.len()),
1015 is_method: false,
1016 module_name: None,
1017 };
1018 &module_context
1019 };
1020
1021 let Some(name_node) = call_node.child_by_field_name("name") else {
1023 return Ok(None);
1024 };
1025
1026 let callee_text = name_node
1027 .utf8_text(content)
1028 .map_err(|_| GraphBuilderError::ParseError {
1029 span: span_from_node(call_node),
1030 reason: "failed to read call expression".to_string(),
1031 })?
1032 .trim()
1033 .to_string();
1034
1035 if callee_text.is_empty() {
1036 return Ok(None);
1037 }
1038
1039 let mut target_qualified = extract_call_target(name_node, content, call_context)?;
1041
1042 if !target_qualified.contains("::") {
1044 let scoped_name = if call_context.qualified_name == "<module>" {
1046 target_qualified.clone()
1047 } else {
1048 format!("{}::{}", call_context.qualified_name, &target_qualified)
1049 };
1050
1051 if ast_graph
1053 .contexts()
1054 .iter()
1055 .any(|ctx| ctx.qualified_name == scoped_name)
1056 {
1057 target_qualified = scoped_name;
1058 }
1059 else if let Some(parent_scope) = extract_parent_scope(&call_context.qualified_name) {
1061 let sibling_name = format!("{}::{}", parent_scope, &target_qualified);
1062 if ast_graph
1063 .contexts()
1064 .iter()
1065 .any(|ctx| ctx.qualified_name == sibling_name)
1066 {
1067 target_qualified = sibling_name;
1068 }
1069 }
1070 }
1071
1072 let source_qualified = call_context.qualified_name();
1073
1074 let span = span_from_node(call_node);
1075 let argument_count = count_arguments(call_node);
1076
1077 Ok(Some((
1078 source_qualified,
1079 target_qualified,
1080 argument_count,
1081 span,
1082 )))
1083}
1084
1085fn extract_call_target(
1091 name_node: Node<'_>,
1092 content: &[u8],
1093 call_context: &CallContext,
1094) -> GraphResult<String> {
1095 match name_node.kind() {
1096 "identifier" => {
1097 get_node_text(name_node, content)
1100 }
1101 "dot_index_expression" => {
1102 flatten_dotted_name(name_node, content)
1104 }
1105 "method_index_expression" => {
1106 flatten_method_name(name_node, content, call_context)
1108 }
1109 "bracket_index_expression" => {
1110 flatten_bracket_name(name_node, content, call_context)
1112 }
1113 "function_call" => {
1114 get_node_text(name_node, content)
1117 }
1118 _ => get_node_text(name_node, content),
1119 }
1120}
1121
1122fn flatten_dotted_name(node: Node<'_>, content: &[u8]) -> GraphResult<String> {
1124 let table = node
1125 .child_by_field_name("table")
1126 .ok_or_else(|| GraphBuilderError::ParseError {
1127 span: span_from_node(node),
1128 reason: "dot_index_expression missing table".to_string(),
1129 })?;
1130 let field = node
1131 .child_by_field_name("field")
1132 .ok_or_else(|| GraphBuilderError::ParseError {
1133 span: span_from_node(node),
1134 reason: "dot_index_expression missing field".to_string(),
1135 })?;
1136
1137 let table_text = collect_table_path(table, content)?;
1138 let field_text = get_node_text(field, content)?;
1139
1140 Ok(format!("{table_text}::{field_text}"))
1141}
1142
1143fn flatten_method_name(
1146 node: Node<'_>,
1147 content: &[u8],
1148 call_context: &CallContext,
1149) -> GraphResult<String> {
1150 let table = node
1151 .child_by_field_name("table")
1152 .ok_or_else(|| GraphBuilderError::ParseError {
1153 span: span_from_node(node),
1154 reason: "method_index_expression missing table".to_string(),
1155 })?;
1156 let method =
1157 node.child_by_field_name("method")
1158 .ok_or_else(|| GraphBuilderError::ParseError {
1159 span: span_from_node(node),
1160 reason: "method_index_expression missing method".to_string(),
1161 })?;
1162
1163 let mut table_text = collect_table_path(table, content)?;
1164 let method_text = get_node_text(method, content)?;
1165
1166 if table_text == "self"
1168 && let Some(ref module_name) = call_context.module_name
1169 {
1170 table_text.clone_from(module_name);
1171 }
1172 Ok(format!("{table_text}::{method_text}"))
1175}
1176
1177fn flatten_bracket_name(
1179 node: Node<'_>,
1180 content: &[u8],
1181 call_context: &CallContext,
1182) -> GraphResult<String> {
1183 let table = node
1184 .child_by_field_name("table")
1185 .ok_or_else(|| GraphBuilderError::ParseError {
1186 span: span_from_node(node),
1187 reason: "bracket_index_expression missing table".to_string(),
1188 })?;
1189 let field = node
1190 .child_by_field_name("field")
1191 .ok_or_else(|| GraphBuilderError::ParseError {
1192 span: span_from_node(node),
1193 reason: "bracket_index_expression missing field".to_string(),
1194 })?;
1195
1196 let mut table_text = collect_table_path(table, content)?;
1197 if table_text == "self"
1198 && let Some(ref module_name) = call_context.module_name
1199 {
1200 table_text.clone_from(module_name);
1201 }
1202
1203 let field_text = normalize_field_value(field, content)?;
1204
1205 Ok(format!("{table_text}::{field_text}"))
1206}
1207
1208fn collect_table_path(node: Node<'_>, content: &[u8]) -> GraphResult<String> {
1210 match node.kind() {
1211 "bracket_index_expression" => {
1212 let table =
1213 node.child_by_field_name("table")
1214 .ok_or_else(|| GraphBuilderError::ParseError {
1215 span: span_from_node(node),
1216 reason: "bracket_index_expression missing table".to_string(),
1217 })?;
1218 let field =
1219 node.child_by_field_name("field")
1220 .ok_or_else(|| GraphBuilderError::ParseError {
1221 span: span_from_node(node),
1222 reason: "bracket_index_expression missing field".to_string(),
1223 })?;
1224
1225 let table_text = collect_table_path(table, content)?;
1226 let field_text = normalize_field_value(field, content)?;
1227
1228 Ok(format!("{table_text}::{field_text}"))
1229 }
1230 "dot_index_expression" => {
1231 let table =
1232 node.child_by_field_name("table")
1233 .ok_or_else(|| GraphBuilderError::ParseError {
1234 span: span_from_node(node),
1235 reason: "nested dot_index_expression missing table".to_string(),
1236 })?;
1237 let field =
1238 node.child_by_field_name("field")
1239 .ok_or_else(|| GraphBuilderError::ParseError {
1240 span: span_from_node(node),
1241 reason: "nested dot_index_expression missing field".to_string(),
1242 })?;
1243
1244 let table_text = collect_table_path(table, content)?;
1245 let field_text = get_node_text(field, content)?;
1246
1247 Ok(format!("{table_text}::{field_text}"))
1248 }
1249 _ => get_node_text(node, content),
1250 }
1251}
1252
1253fn get_node_text(node: Node<'_>, content: &[u8]) -> GraphResult<String> {
1255 node.utf8_text(content)
1256 .map(|text| text.trim().to_string())
1257 .map_err(|_| GraphBuilderError::ParseError {
1258 span: span_from_node(node),
1259 reason: "failed to read node text".to_string(),
1260 })
1261}
1262
1263fn span_from_node(node: Node<'_>) -> Span {
1265 let start = node.start_position();
1266 let end = node.end_position();
1267 Span::new(
1268 Position::new(start.row, start.column),
1269 Position::new(end.row, end.column),
1270 )
1271}
1272
1273fn count_arguments(call_node: Node<'_>) -> usize {
1275 call_node
1276 .child_by_field_name("arguments")
1277 .map_or(0, |args| {
1278 args.named_children(&mut args.walk())
1279 .filter(|child| !matches!(child.kind(), "," | "(" | ")"))
1280 .count()
1281 })
1282}
1283
1284fn extract_parent_scope(qualified_name: &str) -> Option<String> {
1288 let parts: Vec<&str> = qualified_name.split("::").collect();
1289 if parts.len() > 1 {
1290 Some(parts[..parts.len() - 1].join("::"))
1291 } else {
1292 None
1293 }
1294}
1295
1296fn normalize_field_value(node: Node<'_>, content: &[u8]) -> GraphResult<String> {
1297 normalize_field_value_simple(node, content).map_err(|reason| GraphBuilderError::ParseError {
1298 span: span_from_node(node),
1299 reason,
1300 })
1301}
1302
1303fn normalize_field_value_simple(node: Node<'_>, content: &[u8]) -> Result<String, String> {
1304 let raw = node
1305 .utf8_text(content)
1306 .map_err(|_| "failed to read field value".to_string())?
1307 .trim()
1308 .to_string();
1309
1310 if raw.is_empty() {
1311 return Err("empty field value".to_string());
1312 }
1313
1314 match node.kind() {
1315 "string" => Ok(strip_string_literal(&raw)),
1316 _ => Ok(raw),
1317 }
1318}
1319
1320fn strip_string_literal(raw: &str) -> String {
1321 if raw.is_empty() {
1322 return raw.to_string();
1323 }
1324
1325 let bytes = raw.as_bytes();
1326 if (bytes[0] == b'"' && bytes[bytes.len() - 1] == b'"')
1327 || (bytes[0] == b'\'' && bytes[bytes.len() - 1] == b'\'')
1328 {
1329 return raw[1..raw.len() - 1].to_string();
1330 }
1331
1332 if raw.starts_with('[') && raw.ends_with(']') {
1333 let mut start = 1usize;
1334 while start < raw.len() && raw.as_bytes()[start] == b'=' {
1335 start += 1;
1336 }
1337 if start < raw.len() && raw.as_bytes()[start] == b'[' {
1338 let mut end = raw.len() - 1;
1339 while end > 0 && raw.as_bytes()[end - 1] == b'=' {
1340 end -= 1;
1341 }
1342 if end > start + 1 {
1343 return raw[start + 1..end - 1].to_string();
1344 }
1345 }
1346 }
1347
1348 raw.to_string()
1349}
1350
1351fn strip_env_prefix(name: String) -> String {
1352 name.strip_prefix("_ENV::")
1353 .map(std::string::ToString::to_string)
1354 .unwrap_or(name)
1355}
1356
1357fn build_table_fields(
1359 table_node: Node<'_>,
1360 content: &[u8],
1361 helper: &mut GraphBuildHelper,
1362) -> GraphResult<()> {
1363 let mut cursor = table_node.walk();
1364
1365 for child in table_node.children(&mut cursor) {
1366 if child.kind() != "field" {
1367 continue;
1368 }
1369
1370 if let Some(name_node) = child.child_by_field_name("name") {
1372 let field_name = name_node
1373 .utf8_text(content)
1374 .map_err(|_| GraphBuilderError::ParseError {
1375 span: span_from_node(child),
1376 reason: "failed to read table field name".to_string(),
1377 })?
1378 .trim();
1379
1380 let span = span_from_node(child);
1382 helper.add_node(field_name, Some(span), NodeKind::Property);
1383 }
1384 }
1385
1386 Ok(())
1387}
1388
1389#[allow(clippy::unnecessary_wraps)]
1391fn build_field_access(
1392 access_node: Node<'_>,
1393 content: &[u8],
1394 helper: &mut GraphBuildHelper,
1395) -> GraphResult<()> {
1396 let field_name = match access_node.kind() {
1398 "dot_index_expression" => {
1399 access_node
1401 .child_by_field_name("field")
1402 .and_then(|n| n.utf8_text(content).ok())
1403 .map(|s| s.trim().to_string())
1404 }
1405 "bracket_index_expression" => {
1406 access_node.child_by_field_name("field").and_then(|n| {
1408 if n.kind() == "string" {
1410 n.utf8_text(content)
1411 .ok()
1412 .map(|s| s.trim().trim_matches(|c| c == '"' || c == '\'').to_string())
1413 } else {
1414 n.utf8_text(content).ok().map(|s| s.trim().to_string())
1416 }
1417 })
1418 }
1419 _ => None,
1420 };
1421
1422 if let Some(name) = field_name {
1423 let span = span_from_node(access_node);
1425 helper.add_node(&name, Some(span), NodeKind::Property);
1426 }
1427
1428 Ok(())
1429}
1430
1431#[derive(Debug, Clone)]
1436struct CallContext {
1437 qualified_name: String,
1438 #[allow(dead_code)] span: (usize, usize),
1440 is_method: bool,
1441 module_name: Option<String>,
1443}
1444
1445impl CallContext {
1446 fn qualified_name(&self) -> String {
1447 self.qualified_name.clone()
1448 }
1449}
1450
1451struct ASTGraph {
1452 contexts: Vec<CallContext>,
1453 node_to_context: HashMap<usize, usize>,
1454}
1455
1456impl ASTGraph {
1457 fn from_tree(tree: &Tree, content: &[u8], max_depth: usize) -> Result<Self, String> {
1458 let mut contexts = Vec::new();
1459 let mut node_to_context = HashMap::new();
1460
1461 let mut state = WalkerState::new(&mut contexts, &mut node_to_context, max_depth);
1462
1463 walk_ast(tree.root_node(), content, &mut state)?;
1464
1465 Ok(Self {
1466 contexts,
1467 node_to_context,
1468 })
1469 }
1470
1471 fn contexts(&self) -> &[CallContext] {
1472 &self.contexts
1473 }
1474
1475 fn get_callable_context(&self, node_id: usize) -> Option<&CallContext> {
1476 self.node_to_context
1477 .get(&node_id)
1478 .and_then(|idx| self.contexts.get(*idx))
1479 }
1480}
1481
1482struct WalkerState<'a> {
1484 contexts: &'a mut Vec<CallContext>,
1485 node_to_context: &'a mut HashMap<usize, usize>,
1486 parent_qualified: Option<String>,
1487 module_context: Option<String>,
1488 lexical_depth: usize,
1489 max_depth: usize,
1490}
1491
1492impl<'a> WalkerState<'a> {
1493 fn new(
1494 contexts: &'a mut Vec<CallContext>,
1495 node_to_context: &'a mut HashMap<usize, usize>,
1496 max_depth: usize,
1497 ) -> Self {
1498 Self {
1499 contexts,
1500 node_to_context,
1501 parent_qualified: None,
1502 module_context: None,
1503 lexical_depth: 0,
1504 max_depth,
1505 }
1506 }
1507}
1508
1509fn walk_ast(node: Node, content: &[u8], state: &mut WalkerState) -> Result<(), String> {
1511 if state.lexical_depth > state.max_depth {
1514 return Ok(());
1515 }
1516
1517 match node.kind() {
1518 "local_function" => {
1519 handle_local_function(node, content, state)?;
1521 }
1522 "function_declaration" => {
1523 handle_function_declaration(node, content, state)?;
1524 }
1525 "assignment_statement" => {
1526 handle_function_assignment(node, content, state)?;
1528 }
1529 _ => {
1530 let mut cursor = node.walk();
1532 for child in node.children(&mut cursor) {
1533 walk_ast(child, content, state)?;
1534 }
1535 }
1536 }
1537
1538 Ok(())
1539}
1540
1541fn handle_local_function(
1543 node: Node,
1544 content: &[u8],
1545 state: &mut WalkerState,
1546) -> Result<(), String> {
1547 let name_node = node
1548 .child_by_field_name("name")
1549 .ok_or_else(|| "local_function missing name".to_string())?;
1550
1551 let base_name = name_node
1553 .utf8_text(content)
1554 .map_err(|_| "failed to read local function name".to_string())?
1555 .to_string();
1556
1557 let qualified_name = if let Some(parent) = state.parent_qualified.as_ref() {
1559 format!("{parent}::{base_name}")
1560 } else {
1561 base_name
1562 };
1563
1564 let context_idx = state.contexts.len();
1566 state.contexts.push(CallContext {
1567 qualified_name: qualified_name.clone(),
1568 span: (node.start_byte(), node.end_byte()),
1569 is_method: false, module_name: state.module_context.clone(),
1571 });
1572
1573 map_descendants_to_context(node, state.node_to_context, context_idx);
1575
1576 let saved_parent = state.parent_qualified.clone();
1578 let saved_module_context = state.module_context.clone();
1579
1580 state.parent_qualified = Some(qualified_name);
1582
1583 state.lexical_depth += 1;
1585
1586 if let Some(body) = node.named_child(node.named_child_count().saturating_sub(1) as u32) {
1588 walk_ast(body, content, state)?;
1589 }
1590
1591 state.lexical_depth -= 1;
1593 state.parent_qualified = saved_parent;
1594 state.module_context = saved_module_context;
1595
1596 Ok(())
1597}
1598
1599fn handle_function_declaration(
1601 node: Node,
1602 content: &[u8],
1603 state: &mut WalkerState,
1604) -> Result<(), String> {
1605 let name_node = node
1606 .child_by_field_name("name")
1607 .ok_or_else(|| "function_declaration missing name".to_string())?;
1608
1609 let (base_name, is_method) = extract_function_base_name(name_node, content)?;
1611
1612 let qualified_name = if base_name.contains("::") {
1616 base_name.clone()
1617 } else if let Some(parent) = state.parent_qualified.as_ref() {
1618 format!("{parent}::{base_name}")
1619 } else {
1620 base_name.clone()
1621 };
1622 let qualified_name = strip_env_prefix(qualified_name);
1623
1624 let module_name = if is_method {
1627 if qualified_name.contains("::") {
1629 let parts: Vec<&str> = qualified_name.split("::").collect();
1630 if parts.len() > 1 {
1631 Some(parts[..parts.len() - 1].join("::"))
1632 } else {
1633 None
1634 }
1635 } else {
1636 None
1637 }
1638 } else {
1639 state.module_context.clone()
1641 };
1642
1643 let context_idx = state.contexts.len();
1645 state.contexts.push(CallContext {
1646 qualified_name: qualified_name.clone(),
1647 span: (node.start_byte(), node.end_byte()),
1648 is_method,
1649 module_name: module_name.clone(),
1650 });
1651
1652 map_descendants_to_context(node, state.node_to_context, context_idx);
1654
1655 let saved_parent = state.parent_qualified.clone();
1657 let saved_module_context = state.module_context.clone();
1658
1659 state.parent_qualified = Some(qualified_name);
1661
1662 if is_method && module_name.is_some() {
1664 state.module_context = module_name;
1665 }
1666
1667 state.lexical_depth += 1;
1669
1670 if let Some(body) = node.named_child(node.named_child_count().saturating_sub(1) as u32) {
1672 walk_ast(body, content, state)?;
1673 }
1674
1675 state.lexical_depth -= 1;
1677 state.parent_qualified = saved_parent;
1678 state.module_context = saved_module_context;
1679
1680 Ok(())
1681}
1682
1683fn handle_function_assignment(
1685 node: Node,
1686 content: &[u8],
1687 state: &mut WalkerState,
1688) -> Result<(), String> {
1689 let Some(expr_list) = node
1691 .children(&mut node.walk())
1692 .find(|child| child.kind() == "expression_list")
1693 else {
1694 return Ok(());
1695 };
1696
1697 let Some(func_def) = expr_list
1698 .named_children(&mut expr_list.walk())
1699 .find(|child| child.kind() == "function_definition")
1700 else {
1701 return Ok(());
1702 };
1703
1704 let Some(var_list) = node
1706 .children(&mut node.walk())
1707 .find(|child| child.kind() == "variable_list")
1708 else {
1709 return Ok(());
1710 };
1711
1712 let Some(var_node) = var_list.named_child(0) else {
1713 return Ok(());
1714 };
1715
1716 let (base_name, is_method) = extract_assignment_base_name(var_node, content)?;
1718
1719 let qualified_name = if base_name.contains("::") {
1722 base_name.clone()
1723 } else if let Some(parent) = state.parent_qualified.as_ref() {
1724 format!("{parent}::{base_name}")
1725 } else {
1726 base_name.clone()
1727 };
1728 let qualified_name = strip_env_prefix(qualified_name);
1729
1730 let module_name = if is_method {
1733 if qualified_name.contains("::") {
1735 let parts: Vec<&str> = qualified_name.split("::").collect();
1736 if parts.len() > 1 {
1737 Some(parts[..parts.len() - 1].join("::"))
1738 } else {
1739 None
1740 }
1741 } else {
1742 None
1743 }
1744 } else {
1745 state.module_context.clone()
1747 };
1748
1749 let context_idx = state.contexts.len();
1751 state.contexts.push(CallContext {
1752 qualified_name: qualified_name.clone(),
1753 span: (func_def.start_byte(), func_def.end_byte()),
1754 is_method,
1755 module_name: module_name.clone(),
1756 });
1757
1758 map_descendants_to_context(func_def, state.node_to_context, context_idx);
1760
1761 let saved_parent = state.parent_qualified.clone();
1763 let saved_module_context = state.module_context.clone();
1764
1765 state.parent_qualified = Some(qualified_name);
1767
1768 if is_method && module_name.is_some() {
1770 state.module_context = module_name;
1771 }
1772
1773 state.lexical_depth += 1;
1775
1776 if let Some(body) = func_def.named_child(func_def.named_child_count().saturating_sub(1) as u32)
1778 {
1779 walk_ast(body, content, state)?;
1780 }
1781
1782 state.lexical_depth -= 1;
1784 state.parent_qualified = saved_parent;
1785 state.module_context = saved_module_context;
1786
1787 Ok(())
1788}
1789
1790fn extract_function_base_name(
1793 name_node: Node<'_>,
1794 content: &[u8],
1795) -> Result<(String, bool), String> {
1796 match name_node.kind() {
1797 "identifier" => {
1798 let name = name_node
1800 .utf8_text(content)
1801 .map_err(|_| "failed to read function name".to_string())?;
1802 Ok((name.to_string(), false))
1803 }
1804 "dot_index_expression" => {
1805 let table = name_node
1807 .child_by_field_name("table")
1808 .ok_or_else(|| "dot_index_expression missing table".to_string())?;
1809 let field = name_node
1810 .child_by_field_name("field")
1811 .ok_or_else(|| "dot_index_expression missing field".to_string())?;
1812
1813 let table_text = collect_table_path_simple(table, content)?;
1814 let field_text = field
1815 .utf8_text(content)
1816 .map_err(|_| "failed to read field name".to_string())?;
1817
1818 Ok((format!("{table_text}::{field_text}"), false))
1819 }
1820 "method_index_expression" => {
1821 let table = name_node
1823 .child_by_field_name("table")
1824 .ok_or_else(|| "method_index_expression missing table".to_string())?;
1825 let method = name_node
1826 .child_by_field_name("method")
1827 .ok_or_else(|| "method_index_expression missing method".to_string())?;
1828
1829 let table_text = collect_table_path_simple(table, content)?;
1830 let method_text = method
1831 .utf8_text(content)
1832 .map_err(|_| "failed to read method name".to_string())?;
1833
1834 Ok((format!("{table_text}::{method_text}"), true))
1835 }
1836 "bracket_index_expression" => {
1837 let table = name_node
1839 .child_by_field_name("table")
1840 .ok_or_else(|| "bracket_index_expression missing table".to_string())?;
1841 let field = name_node
1842 .child_by_field_name("field")
1843 .ok_or_else(|| "bracket_index_expression missing field".to_string())?;
1844
1845 let table_text = collect_table_path_simple(table, content)?;
1846 let field_text = normalize_field_value_simple(field, content)?;
1847
1848 Ok((format!("{table_text}::{field_text}"), false))
1849 }
1850 _ => Err(format!(
1851 "unsupported function name kind: {}",
1852 name_node.kind()
1853 )),
1854 }
1855}
1856
1857fn extract_assignment_base_name(
1860 var_node: Node<'_>,
1861 content: &[u8],
1862) -> Result<(String, bool), String> {
1863 match var_node.kind() {
1864 "identifier" => {
1865 let name = var_node
1867 .utf8_text(content)
1868 .map_err(|_| "failed to read identifier".to_string())?;
1869 Ok((name.to_string(), false))
1870 }
1871 "dot_index_expression" => {
1872 let table = var_node
1874 .child_by_field_name("table")
1875 .ok_or_else(|| "dot_index_expression missing table".to_string())?;
1876 let field = var_node
1877 .child_by_field_name("field")
1878 .ok_or_else(|| "dot_index_expression missing field".to_string())?;
1879
1880 let table_text = collect_table_path_simple(table, content)?;
1881 let field_text = field
1882 .utf8_text(content)
1883 .map_err(|_| "failed to read field".to_string())?;
1884
1885 Ok((format!("{table_text}::{field_text}"), false))
1886 }
1887 "method_index_expression" => {
1888 let table = var_node
1890 .child_by_field_name("table")
1891 .ok_or_else(|| "method_index_expression missing table".to_string())?;
1892 let method = var_node
1893 .child_by_field_name("method")
1894 .ok_or_else(|| "method_index_expression missing method".to_string())?;
1895
1896 let table_text = collect_table_path_simple(table, content)?;
1897 let method_text = method
1898 .utf8_text(content)
1899 .map_err(|_| "failed to read method".to_string())?;
1900
1901 Ok((format!("{table_text}::{method_text}"), true))
1902 }
1903 "bracket_index_expression" => {
1904 let table = var_node
1906 .child_by_field_name("table")
1907 .ok_or_else(|| "bracket_index_expression missing table".to_string())?;
1908 let field = var_node
1909 .child_by_field_name("field")
1910 .ok_or_else(|| "bracket_index_expression missing field".to_string())?;
1911
1912 let table_text = collect_table_path_simple(table, content)?;
1913 let field_text = normalize_field_value_simple(field, content)?;
1914
1915 Ok((format!("{table_text}::{field_text}"), false))
1916 }
1917 _ => Err(format!(
1918 "unsupported assignment target kind: {}",
1919 var_node.kind()
1920 )),
1921 }
1922}
1923
1924fn collect_table_path_simple(node: Node<'_>, content: &[u8]) -> Result<String, String> {
1926 match node.kind() {
1927 "identifier" => node
1928 .utf8_text(content)
1929 .map(std::string::ToString::to_string)
1930 .map_err(|_| "failed to read identifier".to_string()),
1931 "bracket_index_expression" => {
1932 let table = node
1933 .child_by_field_name("table")
1934 .ok_or_else(|| "bracket_index_expression missing table".to_string())?;
1935 let field = node
1936 .child_by_field_name("field")
1937 .ok_or_else(|| "bracket_index_expression missing field".to_string())?;
1938
1939 let table_text = collect_table_path_simple(table, content)?;
1940 let field_text = normalize_field_value_simple(field, content)?;
1941
1942 Ok(format!("{table_text}::{field_text}"))
1943 }
1944 "dot_index_expression" => {
1945 let table = node
1946 .child_by_field_name("table")
1947 .ok_or_else(|| "dot_index_expression missing table".to_string())?;
1948 let field = node
1949 .child_by_field_name("field")
1950 .ok_or_else(|| "dot_index_expression missing field".to_string())?;
1951
1952 let table_text = collect_table_path_simple(table, content)?;
1953 let field_text = field
1954 .utf8_text(content)
1955 .map_err(|_| "failed to read field".to_string())?;
1956
1957 Ok(format!("{table_text}::{field_text}"))
1958 }
1959 _ => node
1960 .utf8_text(content)
1961 .map(std::string::ToString::to_string)
1962 .map_err(|_| "failed to read node".to_string()),
1963 }
1964}
1965
1966fn map_descendants_to_context(
1968 node: Node,
1969 node_to_context: &mut HashMap<usize, usize>,
1970 context_idx: usize,
1971) {
1972 node_to_context.insert(node.id(), context_idx);
1973
1974 let mut cursor = node.walk();
1975 for child in node.children(&mut cursor) {
1976 map_descendants_to_context(child, node_to_context, context_idx);
1977 }
1978}
1979
1980#[cfg(test)]
1981mod tests {
1982 use super::*;
1983 use crate::LuaPlugin;
1984 use sqry_core::graph::unified::build::StagingOp;
1985 use sqry_core::graph::unified::build::test_helpers::*;
1986 use sqry_core::graph::unified::edge::EdgeKind as UnifiedEdgeKind;
1987 use sqry_core::plugin::LanguagePlugin;
1988 use std::path::PathBuf;
1989
1990 fn extract_import_edges(staging: &StagingGraph) -> Vec<&UnifiedEdgeKind> {
1992 staging
1993 .operations()
1994 .iter()
1995 .filter_map(|op| {
1996 if let StagingOp::AddEdge { kind, .. } = op
1997 && matches!(kind, UnifiedEdgeKind::Imports { .. })
1998 {
1999 return Some(kind);
2000 }
2001 None
2002 })
2003 .collect()
2004 }
2005
2006 fn parse_lua(source: &str) -> Tree {
2007 let plugin = LuaPlugin::default();
2008 plugin.parse_ast(source.as_bytes()).unwrap()
2009 }
2010
2011 #[test]
2012 fn test_extracts_global_functions() {
2013 let source = r"
2014 function foo()
2015 end
2016
2017 function bar()
2018 end
2019 ";
2020
2021 let tree = parse_lua(source);
2022 let mut staging = StagingGraph::new();
2023 let builder = LuaGraphBuilder::default();
2024 let file = PathBuf::from("test.lua");
2025
2026 builder
2027 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2028 .unwrap();
2029
2030 assert!(staging.node_count() >= 2);
2031 assert_has_node(&staging, "foo");
2032 assert_has_node(&staging, "bar");
2033 }
2034
2035 #[test]
2036 fn test_creates_call_edges() {
2037 let source = r"
2038 function caller()
2039 callee()
2040 end
2041
2042 function callee()
2043 end
2044 ";
2045
2046 let tree = parse_lua(source);
2047 let mut staging = StagingGraph::new();
2048 let builder = LuaGraphBuilder::default();
2049 let file = PathBuf::from("test.lua");
2050
2051 builder
2052 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2053 .unwrap();
2054
2055 assert_has_node(&staging, "caller");
2056 assert_has_node(&staging, "callee");
2057
2058 let calls = collect_call_edges(&staging);
2059 assert!(!calls.is_empty(), "Expected at least one call edge");
2060 }
2061
2062 #[test]
2063 fn test_handles_module_methods() {
2064 let source = r"
2065 local MyModule = {}
2066
2067 function MyModule.method1()
2068 MyModule.method2()
2069 end
2070
2071 function MyModule:method2()
2072 end
2073 ";
2074
2075 let tree = parse_lua(source);
2076 let mut staging = StagingGraph::new();
2077 let builder = LuaGraphBuilder::default();
2078 let file = PathBuf::from("test.lua");
2079
2080 builder
2081 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2082 .unwrap();
2083
2084 assert_has_node(&staging, "method1");
2085 assert_has_node(&staging, "method2");
2086 assert_has_call_edge(&staging, "MyModule::method1", "MyModule::method2");
2087 }
2088
2089 #[test]
2090 fn test_nested_functions_scope_resolution() {
2091 let source = r"
2094 function outer()
2095 local function inner()
2096 local function deep()
2097 end
2098 end
2099 end
2100 ";
2101
2102 let tree = parse_lua(source);
2103 let mut staging = StagingGraph::new();
2104 let builder = LuaGraphBuilder::default();
2105 let file = PathBuf::from("test.lua");
2106
2107 builder
2108 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2109 .unwrap();
2110
2111 assert_has_node(&staging, "outer");
2113 assert_has_node(&staging, "outer::inner");
2114 assert_has_node(&staging, "outer::inner::deep");
2115
2116 }
2120
2121 #[test]
2122 fn test_self_resolution_in_methods() {
2123 let source = r"
2126 local MyModule = {}
2127
2128 function MyModule:method1()
2129 self:method2()
2130 end
2131
2132 function MyModule:method2()
2133 self:method3()
2134 end
2135
2136 function MyModule:method3()
2137 -- Empty
2138 end
2139 ";
2140
2141 let tree = parse_lua(source);
2142 let mut staging = StagingGraph::new();
2143 let builder = LuaGraphBuilder::default();
2144 let file = PathBuf::from("test.lua");
2145
2146 builder
2147 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2148 .unwrap();
2149
2150 assert_has_node(&staging, "MyModule::method1");
2152 assert_has_node(&staging, "MyModule::method2");
2153 assert_has_node(&staging, "MyModule::method3");
2154
2155 assert_has_call_edge(&staging, "MyModule::method1", "MyModule::method2");
2157
2158 assert_has_call_edge(&staging, "MyModule::method2", "MyModule::method3");
2160 }
2161
2162 #[test]
2163 fn test_local_function_call_resolution() {
2164 let source = r"
2168function outer()
2169 local function inner()
2170 local function deep()
2171 end
2172 deep()
2173 end
2174 inner()
2175end
2176";
2177
2178 let tree = parse_lua(source);
2179 let mut staging = StagingGraph::new();
2180 let builder = LuaGraphBuilder::default();
2181 let file = PathBuf::from("test.lua");
2182
2183 builder
2184 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2185 .unwrap();
2186
2187 assert_has_node(&staging, "outer");
2189 assert_has_node(&staging, "outer::inner");
2190 assert_has_node(&staging, "outer::inner::deep");
2191
2192 assert_has_call_edge(&staging, "outer", "outer::inner");
2194
2195 assert_has_call_edge(&staging, "outer::inner", "outer::inner::deep");
2197 }
2198
2199 #[test]
2200 fn test_nested_helper_self_resolution() {
2201 let source = r"
2205MyModule = {}
2206
2207function MyModule:outer()
2208 local function helper()
2209 self:inner()
2210 end
2211 helper()
2212end
2213
2214function MyModule:inner()
2215end
2216";
2217
2218 let tree = parse_lua(source);
2219 let mut staging = StagingGraph::new();
2220 let builder = LuaGraphBuilder::default();
2221 let file = PathBuf::from("test.lua");
2222
2223 builder
2224 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2225 .unwrap();
2226
2227 assert_has_node(&staging, "MyModule::outer");
2229 assert_has_node(&staging, "MyModule::outer::helper");
2230 assert_has_node(&staging, "MyModule::inner");
2231
2232 assert_has_call_edge(&staging, "MyModule::outer::helper", "MyModule::inner");
2234
2235 assert_has_call_edge(&staging, "MyModule::outer", "MyModule::outer::helper");
2237 }
2238
2239 #[test]
2240 fn test_bracket_string_key_assignment() {
2241 let source = r#"
2242 local Module = {}
2243
2244 Module["string-key"] = function()
2245 return true
2246 end
2247
2248 local function call_it()
2249 Module["string-key"]()
2250 end
2251 "#;
2252
2253 let tree = parse_lua(source);
2254 let mut staging = StagingGraph::new();
2255 let builder = LuaGraphBuilder::default();
2256 let file = PathBuf::from("test.lua");
2257
2258 builder
2259 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2260 .unwrap();
2261
2262 assert_has_node(&staging, "Module::string-key");
2264 assert_has_node(&staging, "call_it");
2265
2266 assert_has_call_edge(&staging, "call_it", "Module::string-key");
2268 }
2269
2270 #[test]
2271 fn test_numeric_command_table_assignment() {
2272 let source = r#"
2273 local commands = {}
2274
2275 commands[1] = function()
2276 return "cmd"
2277 end
2278
2279 local function run()
2280 commands[1]()
2281 end
2282 "#;
2283
2284 let tree = parse_lua(source);
2285 let mut staging = StagingGraph::new();
2286 let builder = LuaGraphBuilder::default();
2287 let file = PathBuf::from("test.lua");
2288
2289 builder
2290 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2291 .unwrap();
2292
2293 assert_has_node(&staging, "commands::1");
2295 assert_has_node(&staging, "run");
2296
2297 assert_has_call_edge(&staging, "run", "commands::1");
2299 }
2300
2301 #[test]
2302 fn test_env_driven_name_injection() {
2303 let source = r#"
2304 _ENV["init"] = function()
2305 return true
2306 end
2307
2308 local function boot()
2309 init()
2310 end
2311 "#;
2312
2313 let tree = parse_lua(source);
2314 let mut staging = StagingGraph::new();
2315 let builder = LuaGraphBuilder::default();
2316 let file = PathBuf::from("test.lua");
2317
2318 builder
2319 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2320 .unwrap();
2321
2322 assert_has_node(&staging, "init");
2324 assert_has_node(&staging, "boot");
2325
2326 assert_has_call_edge(&staging, "boot", "init");
2328 }
2329
2330 #[test]
2331 fn test_deep_namespace_lexical_depth() {
2332 let source = r"
2335Company = {}
2336Company.Product = {}
2337Company.Product.Component = {}
2338
2339function Company.Product.Component:outer()
2340 local function helper1()
2341 local function helper2()
2342 self:inner()
2343 end
2344 helper2()
2345 end
2346 helper1()
2347end
2348
2349function Company.Product.Component:inner()
2350end
2351";
2352
2353 let tree = parse_lua(source);
2354 let mut staging = StagingGraph::new();
2355 let builder = LuaGraphBuilder::default(); let file = PathBuf::from("test.lua");
2357
2358 builder
2359 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2360 .unwrap();
2361
2362 assert_has_node(&staging, "Company::Product::Component::outer");
2364 assert_has_node(&staging, "Company::Product::Component::outer::helper1");
2365 assert_has_node(
2366 &staging,
2367 "Company::Product::Component::outer::helper1::helper2",
2368 );
2369 assert_has_node(&staging, "Company::Product::Component::inner");
2370
2371 assert_has_call_edge(
2374 &staging,
2375 "Company::Product::Component::outer::helper1::helper2",
2376 "Company::Product::Component::inner",
2377 );
2378
2379 assert_has_call_edge(
2381 &staging,
2382 "Company::Product::Component::outer",
2383 "Company::Product::Component::outer::helper1",
2384 );
2385 assert_has_call_edge(
2386 &staging,
2387 "Company::Product::Component::outer::helper1",
2388 "Company::Product::Component::outer::helper1::helper2",
2389 );
2390 }
2391
2392 #[test]
2393 fn test_nested_method_redefinition() {
2394 let source = r"
2398MyModule = {}
2399
2400function MyModule:outer()
2401 function MyModule:inner()
2402 -- redefined inside outer
2403 end
2404 self:inner()
2405end
2406";
2407
2408 let tree = parse_lua(source);
2409 let mut staging = StagingGraph::new();
2410 let builder = LuaGraphBuilder::default();
2411 let file = PathBuf::from("test.lua");
2412
2413 builder
2414 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2415 .unwrap();
2416
2417 assert_has_node(&staging, "MyModule::outer");
2419 assert_has_node(&staging, "MyModule::inner");
2420
2421 assert_has_call_edge(&staging, "MyModule::outer", "MyModule::inner");
2423 }
2424
2425 #[test]
2430 fn test_require_import_edge_double_quotes() {
2431 let source = r#"
2432 local json = require("cjson")
2433 "#;
2434
2435 let tree = parse_lua(source);
2436 let mut staging = StagingGraph::new();
2437 let builder = LuaGraphBuilder::default();
2438 let file = PathBuf::from("test.lua");
2439
2440 builder
2441 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2442 .unwrap();
2443
2444 let import_edges = extract_import_edges(&staging);
2445 assert!(
2446 !import_edges.is_empty(),
2447 "Expected at least one import edge"
2448 );
2449
2450 let edge = import_edges[0];
2452 if let UnifiedEdgeKind::Imports { is_wildcard, .. } = edge {
2453 assert!(
2454 !*is_wildcard,
2455 "Lua require returns module reference, not wildcard"
2456 );
2457 } else {
2458 panic!("Expected Imports edge kind");
2459 }
2460 }
2461
2462 #[test]
2463 fn test_require_import_edge_single_quotes() {
2464 let source = r"
2465 local socket = require('socket')
2466 ";
2467
2468 let tree = parse_lua(source);
2469 let mut staging = StagingGraph::new();
2470 let builder = LuaGraphBuilder::default();
2471 let file = PathBuf::from("test.lua");
2472
2473 builder
2474 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2475 .unwrap();
2476
2477 let import_edges = extract_import_edges(&staging);
2478 assert!(!import_edges.is_empty(), "Expected require import edge");
2479
2480 let edge = import_edges[0];
2482 if let UnifiedEdgeKind::Imports { is_wildcard, .. } = edge {
2483 assert!(
2484 !*is_wildcard,
2485 "Lua require returns module reference, not wildcard"
2486 );
2487 } else {
2488 panic!("Expected Imports edge kind");
2489 }
2490 }
2491
2492 #[test]
2493 fn test_require_dotted_module() {
2494 let source = r#"
2495 local util = require("luasocket.util")
2496 "#;
2497
2498 let tree = parse_lua(source);
2499 let mut staging = StagingGraph::new();
2500 let builder = LuaGraphBuilder::default();
2501 let file = PathBuf::from("test.lua");
2502
2503 builder
2504 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2505 .unwrap();
2506
2507 let import_edges = extract_import_edges(&staging);
2508 assert!(
2509 !import_edges.is_empty(),
2510 "Expected dotted module import edge"
2511 );
2512
2513 let edge = import_edges[0];
2515 assert!(
2516 matches!(edge, UnifiedEdgeKind::Imports { .. }),
2517 "Expected Imports edge kind"
2518 );
2519 }
2520
2521 #[test]
2522 fn test_multiple_requires() {
2523 let source = r#"
2524 local json = require("cjson")
2525 local socket = require("socket")
2526 local lpeg = require("lpeg")
2527 local lfs = require("lfs")
2528 "#;
2529
2530 let tree = parse_lua(source);
2531 let mut staging = StagingGraph::new();
2532 let builder = LuaGraphBuilder::default();
2533 let file = PathBuf::from("test.lua");
2534
2535 builder
2536 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2537 .unwrap();
2538
2539 let import_edges = extract_import_edges(&staging);
2540 assert_eq!(import_edges.len(), 4, "Expected 4 import edges");
2541
2542 for edge in &import_edges {
2544 assert!(
2545 matches!(edge, UnifiedEdgeKind::Imports { .. }),
2546 "All edges should be Imports"
2547 );
2548 }
2549 }
2550}