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 #[allow(clippy::cast_possible_truncation)] if let Some(child) = parent.named_child(i as u32)
179 && child.id() == target.id()
180 {
181 let index = u32::try_from(i).ok()?;
182 return Some(index);
183 }
184 }
185 None
186}
187
188fn child_index(parent: Node<'_>, target: Node<'_>) -> Option<u32> {
190 let mut cursor = parent.walk();
191 if !cursor.goto_first_child() {
192 return None;
193 }
194 let mut index = 0u32;
195 loop {
196 if cursor.node().id() == target.id() {
197 return Some(index);
198 }
199 if !cursor.goto_next_sibling() {
200 break;
201 }
202 index += 1;
203 }
204 None
205}
206
207#[allow(clippy::too_many_lines)] fn walk_tree_for_graph(
213 node: Node,
214 content: &[u8],
215 ast_graph: &ASTGraph,
216 helper: &mut GraphBuildHelper,
217 guard: &mut sqry_core::query::security::RecursionGuard,
218 ffi_aliases: &FfiAliasTable,
219) -> GraphResult<()> {
220 guard.enter().map_err(|e| GraphBuilderError::ParseError {
221 span: Span::default(),
222 reason: format!("Recursion limit exceeded: {e}"),
223 })?;
224
225 match node.kind() {
226 "function_declaration" => {
230 if let Some(call_context) = ast_graph.get_callable_context(node.id()) {
232 let span = span_from_node(node);
233
234 let is_local = is_local_function(node);
236 let visibility = get_function_visibility(&call_context.qualified_name);
238
239 if call_context.is_method {
241 let node_id = helper.add_method_with_visibility(
242 &call_context.qualified_name,
243 Some(span),
244 false, false, visibility,
247 );
248 if call_context.module_name.is_some() && !is_local {
251 let module_id = helper.add_module(FILE_MODULE_NAME, None);
252 helper.add_export_edge(module_id, node_id);
253 }
254 } else {
255 let node_id = helper.add_function_with_visibility(
256 &call_context.qualified_name,
257 Some(span),
258 false, false, visibility,
261 );
262 let is_module_scoped = call_context.qualified_name.contains("::");
265 let is_global = !is_local && !is_module_scoped;
266
267 if (is_module_scoped || is_global) && !is_local {
268 let module_id = helper.add_module(FILE_MODULE_NAME, None);
269 helper.add_export_edge(module_id, node_id);
270 }
271 }
272 }
273 }
274 "assignment_statement" => {
275 let mut cursor = node.walk();
278 let func_def = node
279 .children(&mut cursor)
280 .find(|child| child.kind() == "expression_list")
281 .and_then(|expr_list| {
282 expr_list
283 .named_children(&mut expr_list.walk())
284 .find(|child| child.kind() == "function_definition")
285 });
286
287 if let Some(func_def_node) = func_def {
288 if let Some(call_context) = ast_graph.get_callable_context(func_def_node.id()) {
290 let span = span_from_node(node);
291
292 let is_local = is_local_function(node);
294 let visibility = get_function_visibility(&call_context.qualified_name);
296
297 if call_context.is_method {
299 let node_id = helper.add_method_with_visibility(
300 &call_context.qualified_name,
301 Some(span),
302 false,
303 false,
304 visibility,
305 );
306 if call_context.module_name.is_some() && !is_local {
309 let module_id = helper.add_module(FILE_MODULE_NAME, None);
310 helper.add_export_edge(module_id, node_id);
311 }
312 } else {
313 let node_id = helper.add_function_with_visibility(
314 &call_context.qualified_name,
315 Some(span),
316 false,
317 false,
318 visibility,
319 );
320 if call_context.qualified_name.contains("::") && !is_local {
324 let module_id = helper.add_module(FILE_MODULE_NAME, None);
325 helper.add_export_edge(module_id, node_id);
326 }
327 }
328 }
329 }
330 }
331 "return_statement" => {
332 handle_return_table_exports(node, content, helper);
335 }
336 "function_call" => {
337 if let Some(ffi_info) = extract_ffi_call_info(node, content, ffi_aliases) {
339 emit_ffi_edge(ffi_info, node, content, ast_graph, helper);
340 }
341 else if is_require_call(node, content) {
343 build_require_import_edge(node, content, helper);
345 }
346 else if let Ok(Some((caller_qname, callee_qname, argument_count, span))) =
348 build_call_for_staging(ast_graph, node, content)
349 {
350 let call_context = ast_graph.get_callable_context(node.id());
352 let is_method = call_context.is_some_and(|c| c.is_method);
353
354 let source_id = if is_method {
355 helper.ensure_method(&caller_qname, None, false, false)
356 } else {
357 helper.ensure_function(&caller_qname, None, false, false)
358 };
359 let target_id = helper.ensure_function(&callee_qname, None, false, false);
360
361 let argument_count = u8::try_from(argument_count).unwrap_or(u8::MAX);
363 helper.add_call_edge_full_with_span(
364 source_id,
365 target_id,
366 argument_count,
367 false,
368 vec![span],
369 );
370 }
371 }
372 "table_constructor" => {
373 build_table_fields(node, content, helper)?;
376 }
377 "dot_index_expression" | "bracket_index_expression" => {
378 build_field_access(node, content, helper)?;
381 }
382 _ => {}
383 }
384
385 let mut cursor = node.walk();
387 for child in node.children(&mut cursor) {
388 walk_tree_for_graph(child, content, ast_graph, helper, guard, ffi_aliases)?;
389 }
390
391 guard.exit();
392 Ok(())
393}
394
395fn handle_return_table_exports(node: Node<'_>, content: &[u8], helper: &mut GraphBuildHelper) {
400 let Some(expr_list) = node
403 .children(&mut node.walk())
404 .find(|child| child.kind() == "expression_list")
405 else {
406 return;
407 };
408
409 let Some(table_node) = expr_list
410 .children(&mut expr_list.walk())
411 .find(|child| child.kind() == "table_constructor")
412 else {
413 return;
414 };
415
416 let module_id = helper.add_module(FILE_MODULE_NAME, None);
417
418 let mut cursor = table_node.walk();
420 for field in table_node.children(&mut cursor) {
421 if field.kind() != "field" {
422 continue;
423 }
424
425 let key_name = if let Some(name_node) = field.child_by_field_name("name") {
427 name_node
429 .utf8_text(content)
430 .ok()
431 .map(|s| s.trim().to_string())
432 } else {
433 continue;
435 };
436
437 let Some(key) = key_name else {
438 continue;
439 };
440
441 let Some(value_node) = field.child_by_field_name("value") else {
443 continue;
444 };
445
446 let exported_name = match value_node.kind() {
453 "identifier" => {
454 value_node.utf8_text(content).ok().map(str::to_string)
456 }
457 "dot_index_expression" | "method_index_expression" => {
458 extract_table_field_qualified_name(value_node, content).ok()
461 }
462 "function_definition" => {
463 Some(key.clone())
466 }
467 _ => None,
468 };
469
470 if let Some(name) = exported_name {
471 let exported_id = helper.ensure_function(&name, None, false, false);
475
476 helper.add_export_edge(module_id, exported_id);
477 }
478 }
479}
480
481fn extract_table_field_qualified_name(node: Node<'_>, content: &[u8]) -> Result<String, String> {
483 match node.kind() {
484 "identifier" => node
485 .utf8_text(content)
486 .map(str::to_string)
487 .map_err(|_| "failed to read identifier".to_string()),
488 "dot_index_expression" => {
489 let table = node
490 .child_by_field_name("table")
491 .ok_or_else(|| "dot_index_expression missing table".to_string())?;
492 let field = node
493 .child_by_field_name("field")
494 .ok_or_else(|| "dot_index_expression missing field".to_string())?;
495
496 let table_text = extract_table_field_qualified_name(table, content)?;
497 let field_text = field
498 .utf8_text(content)
499 .map_err(|_| "failed to read field".to_string())?;
500
501 Ok(format!("{table_text}::{field_text}"))
502 }
503 "method_index_expression" => {
504 let table = node
505 .child_by_field_name("table")
506 .ok_or_else(|| "method_index_expression missing table".to_string())?;
507 let method = node
508 .child_by_field_name("method")
509 .ok_or_else(|| "method_index_expression missing method".to_string())?;
510
511 let table_text = extract_table_field_qualified_name(table, content)?;
512 let method_text = method
513 .utf8_text(content)
514 .map_err(|_| "failed to read method".to_string())?;
515
516 Ok(format!("{table_text}::{method_text}"))
517 }
518 _ => node
519 .utf8_text(content)
520 .map(str::to_string)
521 .map_err(|_| "failed to read node".to_string()),
522 }
523}
524
525fn is_require_call(call_node: Node<'_>, content: &[u8]) -> bool {
527 if let Some(name_node) = call_node.child_by_field_name("name")
529 && let Ok(text) = name_node.utf8_text(content)
530 {
531 return text.trim() == "require";
532 }
533 false
534}
535
536fn build_require_import_edge(call_node: Node<'_>, content: &[u8], helper: &mut GraphBuildHelper) {
538 let Some(args_node) = call_node.child_by_field_name("arguments") else {
541 return;
542 };
543
544 let mut cursor = args_node.walk();
546 let mut module_name: Option<String> = None;
547
548 for child in args_node.children(&mut cursor) {
549 if (child.kind() == "string" || child.kind() == "string_content")
550 && let Ok(text) = child.utf8_text(content)
551 {
552 let trimmed = text
554 .trim()
555 .trim_start_matches(['"', '\'', '['])
556 .trim_end_matches(['"', '\'', ']'])
557 .to_string();
558 if !trimmed.is_empty() {
559 module_name = Some(trimmed);
560 break;
561 }
562 }
563 let mut inner_cursor = child.walk();
565 for inner_child in child.children(&mut inner_cursor) {
566 if inner_child.kind() == "string_content"
567 && let Ok(text) = inner_child.utf8_text(content)
568 {
569 let trimmed = text.trim().to_string();
570 if !trimmed.is_empty() {
571 module_name = Some(trimmed);
572 break;
573 }
574 }
575 }
576 if module_name.is_some() {
577 break;
578 }
579 }
580
581 if let Some(imported_module) = module_name {
583 let span = span_from_node(call_node);
584
585 let module_id = helper.add_module("<module>", None);
587 let import_id = helper.add_import(&imported_module, Some(span));
588
589 helper.add_import_edge_full(module_id, import_id, None, false);
592 }
593}
594
595fn populate_ffi_aliases(node: Node, content: &[u8], aliases: &mut FfiAliasTable) {
604 match node.kind() {
605 "local_variable_declaration" | "assignment_statement" => {
606 extract_ffi_assignment(node, content, aliases);
608 }
609 _ => {}
610 }
611
612 let mut cursor = node.walk();
614 for child in node.children(&mut cursor) {
615 populate_ffi_aliases(child, content, aliases);
616 }
617}
618
619#[cfg(test)]
620#[allow(dead_code)]
621fn debug_node_structure(node: Node, content: &[u8], indent: usize) {
622 let indent_str = " ".repeat(indent);
623 let text = node.utf8_text(content).ok().and_then(|t| {
624 let trimmed = t.trim();
625 if trimmed.len() > 50 || trimmed.is_empty() {
626 None
627 } else {
628 Some(trimmed)
629 }
630 });
631
632 eprintln!(
633 "{}{}{}",
634 indent_str,
635 node.kind(),
636 text.map(|t| format!(" [{t}]")).unwrap_or_default()
637 );
638
639 if indent < 10 {
640 let mut cursor = node.walk();
641 for child in node.children(&mut cursor) {
642 debug_node_structure(child, content, indent + 1);
643 }
644 }
645}
646
647fn extract_ffi_assignment(node: Node, content: &[u8], aliases: &mut FfiAliasTable) {
649 let assignment = if node.kind() == "variable_declaration" {
653 let mut cursor = node.walk();
655 node.children(&mut cursor)
656 .find(|c| c.kind() == "assignment_statement")
657 } else if node.kind() == "assignment_statement" {
658 Some(node)
659 } else {
660 None
661 };
662
663 if let Some(assign_node) = assignment {
664 extract_from_assignment(assign_node, content, aliases);
665 }
666}
667
668fn extract_from_assignment(assign_node: Node, content: &[u8], aliases: &mut FfiAliasTable) {
670 let mut cursor = assign_node.walk();
672 let children: Vec<_> = assign_node.children(&mut cursor).collect();
673
674 let Some(var_list) = children.iter().find(|c| c.kind() == "variable_list") else {
675 return;
676 };
677 let Some(expr_list) = children.iter().find(|c| c.kind() == "expression_list") else {
678 return;
679 };
680
681 let Some(var_name_node) = var_list.named_child(0) else {
683 return;
684 };
685 let Ok(var_name) = var_name_node.utf8_text(content) else {
686 return;
687 };
688 let var_name = var_name.trim().to_string();
689
690 let Some(value_node) = expr_list.named_child(0) else {
692 return;
693 };
694
695 if is_require_ffi_call(value_node, content) {
697 aliases.insert(var_name, FfiEntity::Module);
698 } else if is_ffi_c_reference(value_node, content, aliases) {
699 aliases.insert(var_name, FfiEntity::CLibrary);
700 } else if let Some(lib_name) = extract_ffi_load_library(value_node, content, aliases) {
701 aliases.insert(var_name, FfiEntity::LoadedLibrary(lib_name));
702 }
703 else if value_node.kind() == "identifier"
705 && let Ok(alias_name) = value_node.utf8_text(content)
706 {
707 let alias_name = alias_name.trim();
708 if let Some(entity) = aliases.get(alias_name).cloned() {
709 aliases.insert(var_name, entity);
711 }
712 }
713}
714
715fn is_require_ffi_call(node: Node, content: &[u8]) -> bool {
717 if node.kind() != "function_call" {
718 return false;
719 }
720
721 let Some(name_node) = node.child_by_field_name("name") else {
723 return false;
724 };
725 let Ok(name_text) = name_node.utf8_text(content) else {
726 return false;
727 };
728 if name_text.trim() != "require" {
729 return false;
730 }
731
732 let Some(args_node) = node.child_by_field_name("arguments") else {
734 return false;
735 };
736 let Some(first_arg) = args_node.named_child(0) else {
737 return false;
738 };
739
740 if let Some(content_str) = extract_string_content(first_arg, content) {
742 return content_str == "ffi";
743 }
744
745 false
746}
747
748fn is_ffi_c_reference(node: Node, content: &[u8], aliases: &FfiAliasTable) -> bool {
750 if node.kind() != "dot_index_expression" {
751 return false;
752 }
753
754 let Some(table_node) = node.child_by_field_name("table") else {
755 return false;
756 };
757 let Some(field_node) = node.child_by_field_name("field") else {
758 return false;
759 };
760
761 let Ok(table_text) = table_node.utf8_text(content) else {
762 return false;
763 };
764 let Ok(field_text) = field_node.utf8_text(content) else {
765 return false;
766 };
767
768 let table_text = table_text.trim();
769 let field_text = field_text.trim();
770
771 aliases.get(table_text) == Some(&FfiEntity::Module) && field_text == "C"
773}
774
775fn extract_ffi_load_library(node: Node, content: &[u8], aliases: &FfiAliasTable) -> Option<String> {
777 if node.kind() != "function_call" {
778 return None;
779 }
780
781 let name_node = node.child_by_field_name("name")?;
783 if name_node.kind() != "dot_index_expression" {
784 return None;
785 }
786
787 let table_node = name_node.child_by_field_name("table")?;
788 let field_node = name_node.child_by_field_name("field")?;
789
790 let table_text = table_node.utf8_text(content).ok()?;
791 let field_text = field_node.utf8_text(content).ok()?;
792
793 let table_text = table_text.trim();
794 let field_text = field_text.trim();
795
796 if aliases.get(table_text) != Some(&FfiEntity::Module) || field_text != "load" {
798 return None;
799 }
800
801 let args_node = node.child_by_field_name("arguments")?;
803 let first_arg = args_node.named_child(0)?;
804
805 extract_string_content(first_arg, content)
807}
808
809fn extract_string_content(string_node: Node, content: &[u8]) -> Option<String> {
811 if string_node.kind() == "string" {
812 let mut cursor = string_node.walk();
814 for child in string_node.children(&mut cursor) {
815 if child.kind() == "string_content"
816 && let Ok(text) = child.utf8_text(content)
817 {
818 return Some(text.trim().to_string());
819 }
820 }
821
822 if let Ok(text) = string_node.utf8_text(content) {
824 let trimmed = text
825 .trim()
826 .trim_start_matches(['"', '\'', '['])
827 .trim_end_matches(['"', '\'', ']'])
828 .to_string();
829 if !trimmed.is_empty() {
830 return Some(trimmed);
831 }
832 }
833 }
834 None
835}
836
837fn extract_ffi_call_info(
839 call_node: Node,
840 content: &[u8],
841 aliases: &FfiAliasTable,
842) -> Option<FfiCallInfo> {
843 let name_node = call_node.child_by_field_name("name")?;
844
845 match name_node.kind() {
847 "dot_index_expression" => {
848 if is_ffi_load_call(name_node, content, aliases) {
850 return extract_ffi_load_call_info(call_node, content);
851 }
852 extract_ffi_from_dot_expression(name_node, content, aliases)
854 }
855 _ => None,
856 }
857}
858
859fn is_ffi_load_call(dot_expr: Node, content: &[u8], aliases: &FfiAliasTable) -> bool {
861 let Some(table_node) = dot_expr.child_by_field_name("table") else {
862 return false;
863 };
864 let Some(field_node) = dot_expr.child_by_field_name("field") else {
865 return false;
866 };
867
868 let Ok(table_text) = table_node.utf8_text(content) else {
869 return false;
870 };
871 let Ok(field_text) = field_node.utf8_text(content) else {
872 return false;
873 };
874
875 aliases.get(table_text.trim()) == Some(&FfiEntity::Module) && field_text.trim() == "load"
876}
877
878fn extract_ffi_load_call_info(call_node: Node, content: &[u8]) -> Option<FfiCallInfo> {
880 let args_node = call_node.child_by_field_name("arguments")?;
881 let first_arg = args_node.named_child(0)?;
882
883 let lib_name = extract_string_content(first_arg, content)?;
884
885 Some(FfiCallInfo {
888 function_name: lib_name,
889 library_name: None,
890 })
891}
892
893fn extract_ffi_from_dot_expression(
895 dot_expr: Node,
896 content: &[u8],
897 aliases: &FfiAliasTable,
898) -> Option<FfiCallInfo> {
899 let table_node = dot_expr.child_by_field_name("table")?;
900 let field_node = dot_expr.child_by_field_name("field")?;
901
902 let function_name = field_node.utf8_text(content).ok()?.trim().to_string();
903
904 if table_node.kind() == "dot_index_expression" {
906 let inner_table = table_node.child_by_field_name("table")?;
907 let inner_field = table_node.child_by_field_name("field")?;
908
909 let base_text = inner_table.utf8_text(content).ok()?.trim();
910 let mid_text = inner_field.utf8_text(content).ok()?.trim();
911
912 if aliases.get(base_text) == Some(&FfiEntity::Module) && mid_text == "C" {
914 return Some(FfiCallInfo {
915 function_name,
916 library_name: None,
917 });
918 }
919 } else {
920 let table_text = table_node.utf8_text(content).ok()?.trim();
922
923 match aliases.get(table_text) {
924 Some(FfiEntity::CLibrary) => {
925 return Some(FfiCallInfo {
926 function_name,
927 library_name: None,
928 });
929 }
930 Some(FfiEntity::LoadedLibrary(lib_name)) => {
931 return Some(FfiCallInfo {
932 function_name,
933 library_name: Some(lib_name.clone()),
934 });
935 }
936 _ => {}
937 }
938 }
939
940 None
941}
942
943fn emit_ffi_edge(
945 ffi_info: FfiCallInfo,
946 call_node: Node,
947 content: &[u8],
948 ast_graph: &ASTGraph,
949 helper: &mut GraphBuildHelper,
950) {
951 let caller_id = get_ffi_caller_node_id(call_node, content, ast_graph, helper);
953
954 let target_name = if let Some(lib) = ffi_info.library_name {
956 format!("native::{}::{}", lib, ffi_info.function_name)
957 } else {
958 format!("native::{}", ffi_info.function_name)
959 };
960
961 let target_id = helper.ensure_function(&target_name, None, false, false);
963
964 helper.add_ffi_edge(caller_id, target_id, FfiConvention::C);
966}
967
968fn get_ffi_caller_node_id(
970 call_node: Node,
971 content: &[u8],
972 ast_graph: &ASTGraph,
973 helper: &mut GraphBuildHelper,
974) -> sqry_core::graph::unified::node::NodeId {
975 if let Some(call_context) = ast_graph.get_callable_context(call_node.id()) {
977 if call_context.is_method {
978 return helper.ensure_method(&call_context.qualified_name, None, false, false);
979 }
980 return helper.ensure_function(&call_context.qualified_name, None, false, false);
981 }
982
983 let mut current = call_node.parent();
985 while let Some(node) = current {
986 if node.kind() == "function_declaration" || node.kind() == "function_definition" {
987 if let Some(name_node) = node.child_by_field_name("name")
989 && let Ok(name_text) = name_node.utf8_text(content)
990 {
991 return helper.ensure_function(name_text, None, false, false);
992 }
993 }
994 current = node.parent();
995 }
996
997 helper.ensure_function("<file_level>", None, false, false)
999}
1000
1001fn build_call_for_staging(
1003 ast_graph: &ASTGraph,
1004 call_node: Node<'_>,
1005 content: &[u8],
1006) -> GraphResult<Option<(String, String, usize, Span)>> {
1007 let module_context;
1009 let call_context = if let Some(ctx) = ast_graph.get_callable_context(call_node.id()) {
1010 ctx
1011 } else {
1012 module_context = CallContext {
1014 qualified_name: "<module>".to_string(),
1015 span: (0, content.len()),
1016 is_method: false,
1017 module_name: None,
1018 };
1019 &module_context
1020 };
1021
1022 let Some(name_node) = call_node.child_by_field_name("name") else {
1024 return Ok(None);
1025 };
1026
1027 let callee_text = name_node
1028 .utf8_text(content)
1029 .map_err(|_| GraphBuilderError::ParseError {
1030 span: span_from_node(call_node),
1031 reason: "failed to read call expression".to_string(),
1032 })?
1033 .trim()
1034 .to_string();
1035
1036 if callee_text.is_empty() {
1037 return Ok(None);
1038 }
1039
1040 let mut target_qualified = extract_call_target(name_node, content, call_context)?;
1042
1043 if !target_qualified.contains("::") {
1045 let scoped_name = if call_context.qualified_name == "<module>" {
1047 target_qualified.clone()
1048 } else {
1049 format!("{}::{}", call_context.qualified_name, &target_qualified)
1050 };
1051
1052 if ast_graph
1054 .contexts()
1055 .iter()
1056 .any(|ctx| ctx.qualified_name == scoped_name)
1057 {
1058 target_qualified = scoped_name;
1059 }
1060 else if let Some(parent_scope) = extract_parent_scope(&call_context.qualified_name) {
1062 let sibling_name = format!("{}::{}", parent_scope, &target_qualified);
1063 if ast_graph
1064 .contexts()
1065 .iter()
1066 .any(|ctx| ctx.qualified_name == sibling_name)
1067 {
1068 target_qualified = sibling_name;
1069 }
1070 }
1071 }
1072
1073 let source_qualified = call_context.qualified_name();
1074
1075 let span = span_from_node(call_node);
1076 let argument_count = count_arguments(call_node);
1077
1078 Ok(Some((
1079 source_qualified,
1080 target_qualified,
1081 argument_count,
1082 span,
1083 )))
1084}
1085
1086fn extract_call_target(
1092 name_node: Node<'_>,
1093 content: &[u8],
1094 call_context: &CallContext,
1095) -> GraphResult<String> {
1096 match name_node.kind() {
1097 "identifier" => {
1098 get_node_text(name_node, content)
1101 }
1102 "dot_index_expression" => {
1103 flatten_dotted_name(name_node, content)
1105 }
1106 "method_index_expression" => {
1107 flatten_method_name(name_node, content, call_context)
1109 }
1110 "bracket_index_expression" => {
1111 flatten_bracket_name(name_node, content, call_context)
1113 }
1114 "function_call" => {
1115 get_node_text(name_node, content)
1118 }
1119 _ => get_node_text(name_node, content),
1120 }
1121}
1122
1123fn flatten_dotted_name(node: Node<'_>, content: &[u8]) -> GraphResult<String> {
1125 let table = node
1126 .child_by_field_name("table")
1127 .ok_or_else(|| GraphBuilderError::ParseError {
1128 span: span_from_node(node),
1129 reason: "dot_index_expression missing table".to_string(),
1130 })?;
1131 let field = node
1132 .child_by_field_name("field")
1133 .ok_or_else(|| GraphBuilderError::ParseError {
1134 span: span_from_node(node),
1135 reason: "dot_index_expression missing field".to_string(),
1136 })?;
1137
1138 let table_text = collect_table_path(table, content)?;
1139 let field_text = get_node_text(field, content)?;
1140
1141 Ok(format!("{table_text}::{field_text}"))
1142}
1143
1144fn flatten_method_name(
1147 node: Node<'_>,
1148 content: &[u8],
1149 call_context: &CallContext,
1150) -> GraphResult<String> {
1151 let table = node
1152 .child_by_field_name("table")
1153 .ok_or_else(|| GraphBuilderError::ParseError {
1154 span: span_from_node(node),
1155 reason: "method_index_expression missing table".to_string(),
1156 })?;
1157 let method =
1158 node.child_by_field_name("method")
1159 .ok_or_else(|| GraphBuilderError::ParseError {
1160 span: span_from_node(node),
1161 reason: "method_index_expression missing method".to_string(),
1162 })?;
1163
1164 let mut table_text = collect_table_path(table, content)?;
1165 let method_text = get_node_text(method, content)?;
1166
1167 if table_text == "self"
1169 && let Some(ref module_name) = call_context.module_name
1170 {
1171 table_text.clone_from(module_name);
1172 }
1173 Ok(format!("{table_text}::{method_text}"))
1176}
1177
1178fn flatten_bracket_name(
1180 node: Node<'_>,
1181 content: &[u8],
1182 call_context: &CallContext,
1183) -> GraphResult<String> {
1184 let table = node
1185 .child_by_field_name("table")
1186 .ok_or_else(|| GraphBuilderError::ParseError {
1187 span: span_from_node(node),
1188 reason: "bracket_index_expression missing table".to_string(),
1189 })?;
1190 let field = node
1191 .child_by_field_name("field")
1192 .ok_or_else(|| GraphBuilderError::ParseError {
1193 span: span_from_node(node),
1194 reason: "bracket_index_expression missing field".to_string(),
1195 })?;
1196
1197 let mut table_text = collect_table_path(table, content)?;
1198 if table_text == "self"
1199 && let Some(ref module_name) = call_context.module_name
1200 {
1201 table_text.clone_from(module_name);
1202 }
1203
1204 let field_text = normalize_field_value(field, content)?;
1205
1206 Ok(format!("{table_text}::{field_text}"))
1207}
1208
1209fn collect_table_path(node: Node<'_>, content: &[u8]) -> GraphResult<String> {
1211 match node.kind() {
1212 "bracket_index_expression" => {
1213 let table =
1214 node.child_by_field_name("table")
1215 .ok_or_else(|| GraphBuilderError::ParseError {
1216 span: span_from_node(node),
1217 reason: "bracket_index_expression missing table".to_string(),
1218 })?;
1219 let field =
1220 node.child_by_field_name("field")
1221 .ok_or_else(|| GraphBuilderError::ParseError {
1222 span: span_from_node(node),
1223 reason: "bracket_index_expression missing field".to_string(),
1224 })?;
1225
1226 let table_text = collect_table_path(table, content)?;
1227 let field_text = normalize_field_value(field, content)?;
1228
1229 Ok(format!("{table_text}::{field_text}"))
1230 }
1231 "dot_index_expression" => {
1232 let table =
1233 node.child_by_field_name("table")
1234 .ok_or_else(|| GraphBuilderError::ParseError {
1235 span: span_from_node(node),
1236 reason: "nested dot_index_expression missing table".to_string(),
1237 })?;
1238 let field =
1239 node.child_by_field_name("field")
1240 .ok_or_else(|| GraphBuilderError::ParseError {
1241 span: span_from_node(node),
1242 reason: "nested dot_index_expression missing field".to_string(),
1243 })?;
1244
1245 let table_text = collect_table_path(table, content)?;
1246 let field_text = get_node_text(field, content)?;
1247
1248 Ok(format!("{table_text}::{field_text}"))
1249 }
1250 _ => get_node_text(node, content),
1251 }
1252}
1253
1254fn get_node_text(node: Node<'_>, content: &[u8]) -> GraphResult<String> {
1256 node.utf8_text(content)
1257 .map(|text| text.trim().to_string())
1258 .map_err(|_| GraphBuilderError::ParseError {
1259 span: span_from_node(node),
1260 reason: "failed to read node text".to_string(),
1261 })
1262}
1263
1264fn span_from_node(node: Node<'_>) -> Span {
1266 let start = node.start_position();
1267 let end = node.end_position();
1268 Span::new(
1269 Position::new(start.row, start.column),
1270 Position::new(end.row, end.column),
1271 )
1272}
1273
1274fn count_arguments(call_node: Node<'_>) -> usize {
1276 call_node
1277 .child_by_field_name("arguments")
1278 .map_or(0, |args| {
1279 args.named_children(&mut args.walk())
1280 .filter(|child| !matches!(child.kind(), "," | "(" | ")"))
1281 .count()
1282 })
1283}
1284
1285fn extract_parent_scope(qualified_name: &str) -> Option<String> {
1289 let parts: Vec<&str> = qualified_name.split("::").collect();
1290 if parts.len() > 1 {
1291 Some(parts[..parts.len() - 1].join("::"))
1292 } else {
1293 None
1294 }
1295}
1296
1297fn normalize_field_value(node: Node<'_>, content: &[u8]) -> GraphResult<String> {
1298 normalize_field_value_simple(node, content).map_err(|reason| GraphBuilderError::ParseError {
1299 span: span_from_node(node),
1300 reason,
1301 })
1302}
1303
1304fn normalize_field_value_simple(node: Node<'_>, content: &[u8]) -> Result<String, String> {
1305 let raw = node
1306 .utf8_text(content)
1307 .map_err(|_| "failed to read field value".to_string())?
1308 .trim()
1309 .to_string();
1310
1311 if raw.is_empty() {
1312 return Err("empty field value".to_string());
1313 }
1314
1315 match node.kind() {
1316 "string" => Ok(strip_string_literal(&raw)),
1317 _ => Ok(raw),
1318 }
1319}
1320
1321fn strip_string_literal(raw: &str) -> String {
1322 if raw.is_empty() {
1323 return raw.to_string();
1324 }
1325
1326 let bytes = raw.as_bytes();
1327 if (bytes[0] == b'"' && bytes[bytes.len() - 1] == b'"')
1328 || (bytes[0] == b'\'' && bytes[bytes.len() - 1] == b'\'')
1329 {
1330 return raw[1..raw.len() - 1].to_string();
1331 }
1332
1333 if raw.starts_with('[') && raw.ends_with(']') {
1334 let mut start = 1usize;
1335 while start < raw.len() && raw.as_bytes()[start] == b'=' {
1336 start += 1;
1337 }
1338 if start < raw.len() && raw.as_bytes()[start] == b'[' {
1339 let mut end = raw.len() - 1;
1340 while end > 0 && raw.as_bytes()[end - 1] == b'=' {
1341 end -= 1;
1342 }
1343 if end > start + 1 {
1344 return raw[start + 1..end - 1].to_string();
1345 }
1346 }
1347 }
1348
1349 raw.to_string()
1350}
1351
1352fn strip_env_prefix(name: String) -> String {
1353 name.strip_prefix("_ENV::")
1354 .map(std::string::ToString::to_string)
1355 .unwrap_or(name)
1356}
1357
1358fn build_table_fields(
1360 table_node: Node<'_>,
1361 content: &[u8],
1362 helper: &mut GraphBuildHelper,
1363) -> GraphResult<()> {
1364 let mut cursor = table_node.walk();
1365
1366 for child in table_node.children(&mut cursor) {
1367 if child.kind() != "field" {
1368 continue;
1369 }
1370
1371 if let Some(name_node) = child.child_by_field_name("name") {
1373 let field_name = name_node
1374 .utf8_text(content)
1375 .map_err(|_| GraphBuilderError::ParseError {
1376 span: span_from_node(child),
1377 reason: "failed to read table field name".to_string(),
1378 })?
1379 .trim();
1380
1381 let span = span_from_node(child);
1383 helper.add_node(field_name, Some(span), NodeKind::Property);
1384 }
1385 }
1386
1387 Ok(())
1388}
1389
1390#[allow(clippy::unnecessary_wraps)]
1392fn build_field_access(
1393 access_node: Node<'_>,
1394 content: &[u8],
1395 helper: &mut GraphBuildHelper,
1396) -> GraphResult<()> {
1397 let field_name = match access_node.kind() {
1399 "dot_index_expression" => {
1400 access_node
1402 .child_by_field_name("field")
1403 .and_then(|n| n.utf8_text(content).ok())
1404 .map(|s| s.trim().to_string())
1405 }
1406 "bracket_index_expression" => {
1407 access_node.child_by_field_name("field").and_then(|n| {
1409 if n.kind() == "string" {
1411 n.utf8_text(content)
1412 .ok()
1413 .map(|s| s.trim().trim_matches(|c| c == '"' || c == '\'').to_string())
1414 } else {
1415 n.utf8_text(content).ok().map(|s| s.trim().to_string())
1417 }
1418 })
1419 }
1420 _ => None,
1421 };
1422
1423 if let Some(name) = field_name {
1424 let span = span_from_node(access_node);
1426 helper.add_node(&name, Some(span), NodeKind::Property);
1427 }
1428
1429 Ok(())
1430}
1431
1432#[derive(Debug, Clone)]
1437struct CallContext {
1438 qualified_name: String,
1439 #[allow(dead_code)] span: (usize, usize),
1441 is_method: bool,
1442 module_name: Option<String>,
1444}
1445
1446impl CallContext {
1447 fn qualified_name(&self) -> String {
1448 self.qualified_name.clone()
1449 }
1450}
1451
1452struct ASTGraph {
1453 contexts: Vec<CallContext>,
1454 node_to_context: HashMap<usize, usize>,
1455}
1456
1457impl ASTGraph {
1458 fn from_tree(tree: &Tree, content: &[u8], max_depth: usize) -> Result<Self, String> {
1459 let mut contexts = Vec::new();
1460 let mut node_to_context = HashMap::new();
1461
1462 let mut state = WalkerState::new(&mut contexts, &mut node_to_context, max_depth);
1463
1464 walk_ast(tree.root_node(), content, &mut state)?;
1465
1466 Ok(Self {
1467 contexts,
1468 node_to_context,
1469 })
1470 }
1471
1472 fn contexts(&self) -> &[CallContext] {
1473 &self.contexts
1474 }
1475
1476 fn get_callable_context(&self, node_id: usize) -> Option<&CallContext> {
1477 self.node_to_context
1478 .get(&node_id)
1479 .and_then(|idx| self.contexts.get(*idx))
1480 }
1481}
1482
1483struct WalkerState<'a> {
1485 contexts: &'a mut Vec<CallContext>,
1486 node_to_context: &'a mut HashMap<usize, usize>,
1487 parent_qualified: Option<String>,
1488 module_context: Option<String>,
1489 lexical_depth: usize,
1490 max_depth: usize,
1491}
1492
1493impl<'a> WalkerState<'a> {
1494 fn new(
1495 contexts: &'a mut Vec<CallContext>,
1496 node_to_context: &'a mut HashMap<usize, usize>,
1497 max_depth: usize,
1498 ) -> Self {
1499 Self {
1500 contexts,
1501 node_to_context,
1502 parent_qualified: None,
1503 module_context: None,
1504 lexical_depth: 0,
1505 max_depth,
1506 }
1507 }
1508}
1509
1510fn walk_ast(node: Node, content: &[u8], state: &mut WalkerState) -> Result<(), String> {
1512 if state.lexical_depth > state.max_depth {
1515 return Ok(());
1516 }
1517
1518 match node.kind() {
1519 "local_function" => {
1520 handle_local_function(node, content, state)?;
1522 }
1523 "function_declaration" => {
1524 handle_function_declaration(node, content, state)?;
1525 }
1526 "assignment_statement" => {
1527 handle_function_assignment(node, content, state)?;
1529 }
1530 _ => {
1531 let mut cursor = node.walk();
1533 for child in node.children(&mut cursor) {
1534 walk_ast(child, content, state)?;
1535 }
1536 }
1537 }
1538
1539 Ok(())
1540}
1541
1542fn handle_local_function(
1544 node: Node,
1545 content: &[u8],
1546 state: &mut WalkerState,
1547) -> Result<(), String> {
1548 let name_node = node
1549 .child_by_field_name("name")
1550 .ok_or_else(|| "local_function missing name".to_string())?;
1551
1552 let base_name = name_node
1554 .utf8_text(content)
1555 .map_err(|_| "failed to read local function name".to_string())?
1556 .to_string();
1557
1558 let qualified_name = if let Some(parent) = state.parent_qualified.as_ref() {
1560 format!("{parent}::{base_name}")
1561 } else {
1562 base_name
1563 };
1564
1565 let context_idx = state.contexts.len();
1567 state.contexts.push(CallContext {
1568 qualified_name: qualified_name.clone(),
1569 span: (node.start_byte(), node.end_byte()),
1570 is_method: false, module_name: state.module_context.clone(),
1572 });
1573
1574 map_descendants_to_context(node, state.node_to_context, context_idx);
1576
1577 let saved_parent = state.parent_qualified.clone();
1579 let saved_module_context = state.module_context.clone();
1580
1581 state.parent_qualified = Some(qualified_name);
1583
1584 state.lexical_depth += 1;
1586
1587 #[allow(clippy::cast_possible_truncation)] if let Some(body) = node.named_child(node.named_child_count().saturating_sub(1) as u32) {
1590 walk_ast(body, content, state)?;
1591 }
1592
1593 state.lexical_depth -= 1;
1595 state.parent_qualified = saved_parent;
1596 state.module_context = saved_module_context;
1597
1598 Ok(())
1599}
1600
1601fn handle_function_declaration(
1603 node: Node,
1604 content: &[u8],
1605 state: &mut WalkerState,
1606) -> Result<(), String> {
1607 let name_node = node
1608 .child_by_field_name("name")
1609 .ok_or_else(|| "function_declaration missing name".to_string())?;
1610
1611 let (base_name, is_method) = extract_function_base_name(name_node, content)?;
1613
1614 let qualified_name = if base_name.contains("::") {
1618 base_name.clone()
1619 } else if let Some(parent) = state.parent_qualified.as_ref() {
1620 format!("{parent}::{base_name}")
1621 } else {
1622 base_name.clone()
1623 };
1624 let qualified_name = strip_env_prefix(qualified_name);
1625
1626 let module_name = if is_method {
1629 if qualified_name.contains("::") {
1631 let parts: Vec<&str> = qualified_name.split("::").collect();
1632 if parts.len() > 1 {
1633 Some(parts[..parts.len() - 1].join("::"))
1634 } else {
1635 None
1636 }
1637 } else {
1638 None
1639 }
1640 } else {
1641 state.module_context.clone()
1643 };
1644
1645 let context_idx = state.contexts.len();
1647 state.contexts.push(CallContext {
1648 qualified_name: qualified_name.clone(),
1649 span: (node.start_byte(), node.end_byte()),
1650 is_method,
1651 module_name: module_name.clone(),
1652 });
1653
1654 map_descendants_to_context(node, state.node_to_context, context_idx);
1656
1657 let saved_parent = state.parent_qualified.clone();
1659 let saved_module_context = state.module_context.clone();
1660
1661 state.parent_qualified = Some(qualified_name);
1663
1664 if is_method && module_name.is_some() {
1666 state.module_context = module_name;
1667 }
1668
1669 state.lexical_depth += 1;
1671
1672 #[allow(clippy::cast_possible_truncation)] if let Some(body) = node.named_child(node.named_child_count().saturating_sub(1) as u32) {
1675 walk_ast(body, content, state)?;
1676 }
1677
1678 state.lexical_depth -= 1;
1680 state.parent_qualified = saved_parent;
1681 state.module_context = saved_module_context;
1682
1683 Ok(())
1684}
1685
1686fn handle_function_assignment(
1688 node: Node,
1689 content: &[u8],
1690 state: &mut WalkerState,
1691) -> Result<(), String> {
1692 let Some(expr_list) = node
1694 .children(&mut node.walk())
1695 .find(|child| child.kind() == "expression_list")
1696 else {
1697 return Ok(());
1698 };
1699
1700 let Some(func_def) = expr_list
1701 .named_children(&mut expr_list.walk())
1702 .find(|child| child.kind() == "function_definition")
1703 else {
1704 return Ok(());
1705 };
1706
1707 let Some(var_list) = node
1709 .children(&mut node.walk())
1710 .find(|child| child.kind() == "variable_list")
1711 else {
1712 return Ok(());
1713 };
1714
1715 let Some(var_node) = var_list.named_child(0) else {
1716 return Ok(());
1717 };
1718
1719 let (base_name, is_method) = extract_assignment_base_name(var_node, content)?;
1721
1722 let qualified_name = if base_name.contains("::") {
1725 base_name.clone()
1726 } else if let Some(parent) = state.parent_qualified.as_ref() {
1727 format!("{parent}::{base_name}")
1728 } else {
1729 base_name.clone()
1730 };
1731 let qualified_name = strip_env_prefix(qualified_name);
1732
1733 let module_name = if is_method {
1736 if qualified_name.contains("::") {
1738 let parts: Vec<&str> = qualified_name.split("::").collect();
1739 if parts.len() > 1 {
1740 Some(parts[..parts.len() - 1].join("::"))
1741 } else {
1742 None
1743 }
1744 } else {
1745 None
1746 }
1747 } else {
1748 state.module_context.clone()
1750 };
1751
1752 let context_idx = state.contexts.len();
1754 state.contexts.push(CallContext {
1755 qualified_name: qualified_name.clone(),
1756 span: (func_def.start_byte(), func_def.end_byte()),
1757 is_method,
1758 module_name: module_name.clone(),
1759 });
1760
1761 map_descendants_to_context(func_def, state.node_to_context, context_idx);
1763
1764 let saved_parent = state.parent_qualified.clone();
1766 let saved_module_context = state.module_context.clone();
1767
1768 state.parent_qualified = Some(qualified_name);
1770
1771 if is_method && module_name.is_some() {
1773 state.module_context = module_name;
1774 }
1775
1776 state.lexical_depth += 1;
1778 #[allow(clippy::cast_possible_truncation)] if let Some(body) = func_def.named_child(func_def.named_child_count().saturating_sub(1) as u32)
1781 {
1782 walk_ast(body, content, state)?;
1783 }
1784
1785 state.lexical_depth -= 1;
1787 state.parent_qualified = saved_parent;
1788 state.module_context = saved_module_context;
1789
1790 Ok(())
1791}
1792
1793fn extract_function_base_name(
1796 name_node: Node<'_>,
1797 content: &[u8],
1798) -> Result<(String, bool), String> {
1799 match name_node.kind() {
1800 "identifier" => {
1801 let name = name_node
1803 .utf8_text(content)
1804 .map_err(|_| "failed to read function name".to_string())?;
1805 Ok((name.to_string(), false))
1806 }
1807 "dot_index_expression" => {
1808 let table = name_node
1810 .child_by_field_name("table")
1811 .ok_or_else(|| "dot_index_expression missing table".to_string())?;
1812 let field = name_node
1813 .child_by_field_name("field")
1814 .ok_or_else(|| "dot_index_expression missing field".to_string())?;
1815
1816 let table_text = collect_table_path_simple(table, content)?;
1817 let field_text = field
1818 .utf8_text(content)
1819 .map_err(|_| "failed to read field name".to_string())?;
1820
1821 Ok((format!("{table_text}::{field_text}"), false))
1822 }
1823 "method_index_expression" => {
1824 let table = name_node
1826 .child_by_field_name("table")
1827 .ok_or_else(|| "method_index_expression missing table".to_string())?;
1828 let method = name_node
1829 .child_by_field_name("method")
1830 .ok_or_else(|| "method_index_expression missing method".to_string())?;
1831
1832 let table_text = collect_table_path_simple(table, content)?;
1833 let method_text = method
1834 .utf8_text(content)
1835 .map_err(|_| "failed to read method name".to_string())?;
1836
1837 Ok((format!("{table_text}::{method_text}"), true))
1838 }
1839 "bracket_index_expression" => {
1840 let table = name_node
1842 .child_by_field_name("table")
1843 .ok_or_else(|| "bracket_index_expression missing table".to_string())?;
1844 let field = name_node
1845 .child_by_field_name("field")
1846 .ok_or_else(|| "bracket_index_expression missing field".to_string())?;
1847
1848 let table_text = collect_table_path_simple(table, content)?;
1849 let field_text = normalize_field_value_simple(field, content)?;
1850
1851 Ok((format!("{table_text}::{field_text}"), false))
1852 }
1853 _ => Err(format!(
1854 "unsupported function name kind: {}",
1855 name_node.kind()
1856 )),
1857 }
1858}
1859
1860fn extract_assignment_base_name(
1863 var_node: Node<'_>,
1864 content: &[u8],
1865) -> Result<(String, bool), String> {
1866 match var_node.kind() {
1867 "identifier" => {
1868 let name = var_node
1870 .utf8_text(content)
1871 .map_err(|_| "failed to read identifier".to_string())?;
1872 Ok((name.to_string(), false))
1873 }
1874 "dot_index_expression" => {
1875 let table = var_node
1877 .child_by_field_name("table")
1878 .ok_or_else(|| "dot_index_expression missing table".to_string())?;
1879 let field = var_node
1880 .child_by_field_name("field")
1881 .ok_or_else(|| "dot_index_expression missing field".to_string())?;
1882
1883 let table_text = collect_table_path_simple(table, content)?;
1884 let field_text = field
1885 .utf8_text(content)
1886 .map_err(|_| "failed to read field".to_string())?;
1887
1888 Ok((format!("{table_text}::{field_text}"), false))
1889 }
1890 "method_index_expression" => {
1891 let table = var_node
1893 .child_by_field_name("table")
1894 .ok_or_else(|| "method_index_expression missing table".to_string())?;
1895 let method = var_node
1896 .child_by_field_name("method")
1897 .ok_or_else(|| "method_index_expression missing method".to_string())?;
1898
1899 let table_text = collect_table_path_simple(table, content)?;
1900 let method_text = method
1901 .utf8_text(content)
1902 .map_err(|_| "failed to read method".to_string())?;
1903
1904 Ok((format!("{table_text}::{method_text}"), true))
1905 }
1906 "bracket_index_expression" => {
1907 let table = var_node
1909 .child_by_field_name("table")
1910 .ok_or_else(|| "bracket_index_expression missing table".to_string())?;
1911 let field = var_node
1912 .child_by_field_name("field")
1913 .ok_or_else(|| "bracket_index_expression missing field".to_string())?;
1914
1915 let table_text = collect_table_path_simple(table, content)?;
1916 let field_text = normalize_field_value_simple(field, content)?;
1917
1918 Ok((format!("{table_text}::{field_text}"), false))
1919 }
1920 _ => Err(format!(
1921 "unsupported assignment target kind: {}",
1922 var_node.kind()
1923 )),
1924 }
1925}
1926
1927fn collect_table_path_simple(node: Node<'_>, content: &[u8]) -> Result<String, String> {
1929 match node.kind() {
1930 "identifier" => node
1931 .utf8_text(content)
1932 .map(std::string::ToString::to_string)
1933 .map_err(|_| "failed to read identifier".to_string()),
1934 "bracket_index_expression" => {
1935 let table = node
1936 .child_by_field_name("table")
1937 .ok_or_else(|| "bracket_index_expression missing table".to_string())?;
1938 let field = node
1939 .child_by_field_name("field")
1940 .ok_or_else(|| "bracket_index_expression missing field".to_string())?;
1941
1942 let table_text = collect_table_path_simple(table, content)?;
1943 let field_text = normalize_field_value_simple(field, content)?;
1944
1945 Ok(format!("{table_text}::{field_text}"))
1946 }
1947 "dot_index_expression" => {
1948 let table = node
1949 .child_by_field_name("table")
1950 .ok_or_else(|| "dot_index_expression missing table".to_string())?;
1951 let field = node
1952 .child_by_field_name("field")
1953 .ok_or_else(|| "dot_index_expression missing field".to_string())?;
1954
1955 let table_text = collect_table_path_simple(table, content)?;
1956 let field_text = field
1957 .utf8_text(content)
1958 .map_err(|_| "failed to read field".to_string())?;
1959
1960 Ok(format!("{table_text}::{field_text}"))
1961 }
1962 _ => node
1963 .utf8_text(content)
1964 .map(std::string::ToString::to_string)
1965 .map_err(|_| "failed to read node".to_string()),
1966 }
1967}
1968
1969fn map_descendants_to_context(
1971 node: Node,
1972 node_to_context: &mut HashMap<usize, usize>,
1973 context_idx: usize,
1974) {
1975 node_to_context.insert(node.id(), context_idx);
1976
1977 let mut cursor = node.walk();
1978 for child in node.children(&mut cursor) {
1979 map_descendants_to_context(child, node_to_context, context_idx);
1980 }
1981}
1982
1983#[cfg(test)]
1984mod tests {
1985 use super::*;
1986 use crate::LuaPlugin;
1987 use sqry_core::graph::unified::build::StagingOp;
1988 use sqry_core::graph::unified::build::test_helpers::*;
1989 use sqry_core::graph::unified::edge::EdgeKind as UnifiedEdgeKind;
1990 use sqry_core::plugin::LanguagePlugin;
1991 use std::path::PathBuf;
1992
1993 fn extract_import_edges(staging: &StagingGraph) -> Vec<&UnifiedEdgeKind> {
1995 staging
1996 .operations()
1997 .iter()
1998 .filter_map(|op| {
1999 if let StagingOp::AddEdge { kind, .. } = op
2000 && matches!(kind, UnifiedEdgeKind::Imports { .. })
2001 {
2002 return Some(kind);
2003 }
2004 None
2005 })
2006 .collect()
2007 }
2008
2009 fn parse_lua(source: &str) -> Tree {
2010 let plugin = LuaPlugin::default();
2011 plugin.parse_ast(source.as_bytes()).unwrap()
2012 }
2013
2014 #[test]
2015 fn test_extracts_global_functions() {
2016 let source = r"
2017 function foo()
2018 end
2019
2020 function bar()
2021 end
2022 ";
2023
2024 let tree = parse_lua(source);
2025 let mut staging = StagingGraph::new();
2026 let builder = LuaGraphBuilder::default();
2027 let file = PathBuf::from("test.lua");
2028
2029 builder
2030 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2031 .unwrap();
2032
2033 assert!(staging.node_count() >= 2);
2034 assert_has_node(&staging, "foo");
2035 assert_has_node(&staging, "bar");
2036 }
2037
2038 #[test]
2039 fn test_creates_call_edges() {
2040 let source = r"
2041 function caller()
2042 callee()
2043 end
2044
2045 function callee()
2046 end
2047 ";
2048
2049 let tree = parse_lua(source);
2050 let mut staging = StagingGraph::new();
2051 let builder = LuaGraphBuilder::default();
2052 let file = PathBuf::from("test.lua");
2053
2054 builder
2055 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2056 .unwrap();
2057
2058 assert_has_node(&staging, "caller");
2059 assert_has_node(&staging, "callee");
2060
2061 let calls = collect_call_edges(&staging);
2062 assert!(!calls.is_empty(), "Expected at least one call edge");
2063 }
2064
2065 #[test]
2066 fn test_handles_module_methods() {
2067 let source = r"
2068 local MyModule = {}
2069
2070 function MyModule.method1()
2071 MyModule.method2()
2072 end
2073
2074 function MyModule:method2()
2075 end
2076 ";
2077
2078 let tree = parse_lua(source);
2079 let mut staging = StagingGraph::new();
2080 let builder = LuaGraphBuilder::default();
2081 let file = PathBuf::from("test.lua");
2082
2083 builder
2084 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2085 .unwrap();
2086
2087 assert_has_node(&staging, "method1");
2088 assert_has_node(&staging, "method2");
2089 assert_has_call_edge(&staging, "MyModule::method1", "MyModule::method2");
2090 }
2091
2092 #[test]
2093 fn test_nested_functions_scope_resolution() {
2094 let source = r"
2097 function outer()
2098 local function inner()
2099 local function deep()
2100 end
2101 end
2102 end
2103 ";
2104
2105 let tree = parse_lua(source);
2106 let mut staging = StagingGraph::new();
2107 let builder = LuaGraphBuilder::default();
2108 let file = PathBuf::from("test.lua");
2109
2110 builder
2111 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2112 .unwrap();
2113
2114 assert_has_node(&staging, "outer");
2116 assert_has_node(&staging, "outer::inner");
2117 assert_has_node(&staging, "outer::inner::deep");
2118
2119 }
2123
2124 #[test]
2125 fn test_self_resolution_in_methods() {
2126 let source = r"
2129 local MyModule = {}
2130
2131 function MyModule:method1()
2132 self:method2()
2133 end
2134
2135 function MyModule:method2()
2136 self:method3()
2137 end
2138
2139 function MyModule:method3()
2140 -- Empty
2141 end
2142 ";
2143
2144 let tree = parse_lua(source);
2145 let mut staging = StagingGraph::new();
2146 let builder = LuaGraphBuilder::default();
2147 let file = PathBuf::from("test.lua");
2148
2149 builder
2150 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2151 .unwrap();
2152
2153 assert_has_node(&staging, "MyModule::method1");
2155 assert_has_node(&staging, "MyModule::method2");
2156 assert_has_node(&staging, "MyModule::method3");
2157
2158 assert_has_call_edge(&staging, "MyModule::method1", "MyModule::method2");
2160
2161 assert_has_call_edge(&staging, "MyModule::method2", "MyModule::method3");
2163 }
2164
2165 #[test]
2166 fn test_local_function_call_resolution() {
2167 let source = r"
2171function outer()
2172 local function inner()
2173 local function deep()
2174 end
2175 deep()
2176 end
2177 inner()
2178end
2179";
2180
2181 let tree = parse_lua(source);
2182 let mut staging = StagingGraph::new();
2183 let builder = LuaGraphBuilder::default();
2184 let file = PathBuf::from("test.lua");
2185
2186 builder
2187 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2188 .unwrap();
2189
2190 assert_has_node(&staging, "outer");
2192 assert_has_node(&staging, "outer::inner");
2193 assert_has_node(&staging, "outer::inner::deep");
2194
2195 assert_has_call_edge(&staging, "outer", "outer::inner");
2197
2198 assert_has_call_edge(&staging, "outer::inner", "outer::inner::deep");
2200 }
2201
2202 #[test]
2203 fn test_nested_helper_self_resolution() {
2204 let source = r"
2208MyModule = {}
2209
2210function MyModule:outer()
2211 local function helper()
2212 self:inner()
2213 end
2214 helper()
2215end
2216
2217function MyModule:inner()
2218end
2219";
2220
2221 let tree = parse_lua(source);
2222 let mut staging = StagingGraph::new();
2223 let builder = LuaGraphBuilder::default();
2224 let file = PathBuf::from("test.lua");
2225
2226 builder
2227 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2228 .unwrap();
2229
2230 assert_has_node(&staging, "MyModule::outer");
2232 assert_has_node(&staging, "MyModule::outer::helper");
2233 assert_has_node(&staging, "MyModule::inner");
2234
2235 assert_has_call_edge(&staging, "MyModule::outer::helper", "MyModule::inner");
2237
2238 assert_has_call_edge(&staging, "MyModule::outer", "MyModule::outer::helper");
2240 }
2241
2242 #[test]
2243 fn test_bracket_string_key_assignment() {
2244 let source = r#"
2245 local Module = {}
2246
2247 Module["string-key"] = function()
2248 return true
2249 end
2250
2251 local function call_it()
2252 Module["string-key"]()
2253 end
2254 "#;
2255
2256 let tree = parse_lua(source);
2257 let mut staging = StagingGraph::new();
2258 let builder = LuaGraphBuilder::default();
2259 let file = PathBuf::from("test.lua");
2260
2261 builder
2262 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2263 .unwrap();
2264
2265 assert_has_node(&staging, "Module::string-key");
2267 assert_has_node(&staging, "call_it");
2268
2269 assert_has_call_edge(&staging, "call_it", "Module::string-key");
2271 }
2272
2273 #[test]
2274 fn test_numeric_command_table_assignment() {
2275 let source = r#"
2276 local commands = {}
2277
2278 commands[1] = function()
2279 return "cmd"
2280 end
2281
2282 local function run()
2283 commands[1]()
2284 end
2285 "#;
2286
2287 let tree = parse_lua(source);
2288 let mut staging = StagingGraph::new();
2289 let builder = LuaGraphBuilder::default();
2290 let file = PathBuf::from("test.lua");
2291
2292 builder
2293 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2294 .unwrap();
2295
2296 assert_has_node(&staging, "commands::1");
2298 assert_has_node(&staging, "run");
2299
2300 assert_has_call_edge(&staging, "run", "commands::1");
2302 }
2303
2304 #[test]
2305 fn test_env_driven_name_injection() {
2306 let source = r#"
2307 _ENV["init"] = function()
2308 return true
2309 end
2310
2311 local function boot()
2312 init()
2313 end
2314 "#;
2315
2316 let tree = parse_lua(source);
2317 let mut staging = StagingGraph::new();
2318 let builder = LuaGraphBuilder::default();
2319 let file = PathBuf::from("test.lua");
2320
2321 builder
2322 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2323 .unwrap();
2324
2325 assert_has_node(&staging, "init");
2327 assert_has_node(&staging, "boot");
2328
2329 assert_has_call_edge(&staging, "boot", "init");
2331 }
2332
2333 #[test]
2334 fn test_deep_namespace_lexical_depth() {
2335 let source = r"
2338Company = {}
2339Company.Product = {}
2340Company.Product.Component = {}
2341
2342function Company.Product.Component:outer()
2343 local function helper1()
2344 local function helper2()
2345 self:inner()
2346 end
2347 helper2()
2348 end
2349 helper1()
2350end
2351
2352function Company.Product.Component:inner()
2353end
2354";
2355
2356 let tree = parse_lua(source);
2357 let mut staging = StagingGraph::new();
2358 let builder = LuaGraphBuilder::default(); let file = PathBuf::from("test.lua");
2360
2361 builder
2362 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2363 .unwrap();
2364
2365 assert_has_node(&staging, "Company::Product::Component::outer");
2367 assert_has_node(&staging, "Company::Product::Component::outer::helper1");
2368 assert_has_node(
2369 &staging,
2370 "Company::Product::Component::outer::helper1::helper2",
2371 );
2372 assert_has_node(&staging, "Company::Product::Component::inner");
2373
2374 assert_has_call_edge(
2377 &staging,
2378 "Company::Product::Component::outer::helper1::helper2",
2379 "Company::Product::Component::inner",
2380 );
2381
2382 assert_has_call_edge(
2384 &staging,
2385 "Company::Product::Component::outer",
2386 "Company::Product::Component::outer::helper1",
2387 );
2388 assert_has_call_edge(
2389 &staging,
2390 "Company::Product::Component::outer::helper1",
2391 "Company::Product::Component::outer::helper1::helper2",
2392 );
2393 }
2394
2395 #[test]
2396 fn test_nested_method_redefinition() {
2397 let source = r"
2401MyModule = {}
2402
2403function MyModule:outer()
2404 function MyModule:inner()
2405 -- redefined inside outer
2406 end
2407 self:inner()
2408end
2409";
2410
2411 let tree = parse_lua(source);
2412 let mut staging = StagingGraph::new();
2413 let builder = LuaGraphBuilder::default();
2414 let file = PathBuf::from("test.lua");
2415
2416 builder
2417 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2418 .unwrap();
2419
2420 assert_has_node(&staging, "MyModule::outer");
2422 assert_has_node(&staging, "MyModule::inner");
2423
2424 assert_has_call_edge(&staging, "MyModule::outer", "MyModule::inner");
2426 }
2427
2428 #[test]
2433 fn test_require_import_edge_double_quotes() {
2434 let source = r#"
2435 local json = require("cjson")
2436 "#;
2437
2438 let tree = parse_lua(source);
2439 let mut staging = StagingGraph::new();
2440 let builder = LuaGraphBuilder::default();
2441 let file = PathBuf::from("test.lua");
2442
2443 builder
2444 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2445 .unwrap();
2446
2447 let import_edges = extract_import_edges(&staging);
2448 assert!(
2449 !import_edges.is_empty(),
2450 "Expected at least one import edge"
2451 );
2452
2453 let edge = import_edges[0];
2455 if let UnifiedEdgeKind::Imports { is_wildcard, .. } = edge {
2456 assert!(
2457 !*is_wildcard,
2458 "Lua require returns module reference, not wildcard"
2459 );
2460 } else {
2461 panic!("Expected Imports edge kind");
2462 }
2463 }
2464
2465 #[test]
2466 fn test_require_import_edge_single_quotes() {
2467 let source = r"
2468 local socket = require('socket')
2469 ";
2470
2471 let tree = parse_lua(source);
2472 let mut staging = StagingGraph::new();
2473 let builder = LuaGraphBuilder::default();
2474 let file = PathBuf::from("test.lua");
2475
2476 builder
2477 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2478 .unwrap();
2479
2480 let import_edges = extract_import_edges(&staging);
2481 assert!(!import_edges.is_empty(), "Expected require import edge");
2482
2483 let edge = import_edges[0];
2485 if let UnifiedEdgeKind::Imports { is_wildcard, .. } = edge {
2486 assert!(
2487 !*is_wildcard,
2488 "Lua require returns module reference, not wildcard"
2489 );
2490 } else {
2491 panic!("Expected Imports edge kind");
2492 }
2493 }
2494
2495 #[test]
2496 fn test_require_dotted_module() {
2497 let source = r#"
2498 local util = require("luasocket.util")
2499 "#;
2500
2501 let tree = parse_lua(source);
2502 let mut staging = StagingGraph::new();
2503 let builder = LuaGraphBuilder::default();
2504 let file = PathBuf::from("test.lua");
2505
2506 builder
2507 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2508 .unwrap();
2509
2510 let import_edges = extract_import_edges(&staging);
2511 assert!(
2512 !import_edges.is_empty(),
2513 "Expected dotted module import edge"
2514 );
2515
2516 let edge = import_edges[0];
2518 assert!(
2519 matches!(edge, UnifiedEdgeKind::Imports { .. }),
2520 "Expected Imports edge kind"
2521 );
2522 }
2523
2524 #[test]
2525 fn test_multiple_requires() {
2526 let source = r#"
2527 local json = require("cjson")
2528 local socket = require("socket")
2529 local lpeg = require("lpeg")
2530 local lfs = require("lfs")
2531 "#;
2532
2533 let tree = parse_lua(source);
2534 let mut staging = StagingGraph::new();
2535 let builder = LuaGraphBuilder::default();
2536 let file = PathBuf::from("test.lua");
2537
2538 builder
2539 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2540 .unwrap();
2541
2542 let import_edges = extract_import_edges(&staging);
2543 assert_eq!(import_edges.len(), 4, "Expected 4 import edges");
2544
2545 for edge in &import_edges {
2547 assert!(
2548 matches!(edge, UnifiedEdgeKind::Imports { .. }),
2549 "All edges should be Imports"
2550 );
2551 }
2552 }
2553}