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