1use std::sync::OnceLock;
10use std::{collections::HashMap, path::Path};
11
12use sqry_core::graph::unified::build::helper::CalleeKindHint;
13use sqry_core::graph::unified::build::shape::{CfBucket, ShapeMapping};
14use sqry_core::graph::unified::edge::FfiConvention;
15use sqry_core::graph::unified::storage::shape::SignatureShape;
16use sqry_core::graph::unified::{GraphBuildHelper, NodeKind, StagingGraph};
17use sqry_core::graph::{GraphBuilder, GraphBuilderError, GraphResult, Language, Position, Span};
18use tree_sitter::{Node, Tree};
19
20const DEFAULT_SCOPE_DEPTH: usize = 4;
21
22const FILE_MODULE_NAME: &str = "<file_module>";
25
26#[derive(Debug, Clone, PartialEq, Eq)]
28enum FfiEntity {
29 Module,
31 CLibrary,
33 LoadedLibrary(String),
35}
36
37type FfiAliasTable = HashMap<String, FfiEntity>;
39
40#[derive(Debug, Clone)]
42struct FfiCallInfo {
43 function_name: String,
45 library_name: Option<String>,
47}
48
49#[derive(Debug, Clone, Copy)]
51pub struct LuaGraphBuilder {
52 max_scope_depth: usize,
53}
54
55impl Default for LuaGraphBuilder {
56 fn default() -> Self {
57 Self {
58 max_scope_depth: DEFAULT_SCOPE_DEPTH,
59 }
60 }
61}
62
63impl LuaGraphBuilder {
64 #[must_use]
65 pub fn new(max_scope_depth: usize) -> Self {
66 Self { max_scope_depth }
67 }
68}
69
70impl GraphBuilder for LuaGraphBuilder {
71 fn build_graph(
72 &self,
73 tree: &Tree,
74 content: &[u8],
75 file: &Path,
76 staging: &mut StagingGraph,
77 ) -> GraphResult<()> {
78 let mut helper = GraphBuildHelper::new(staging, file, Language::Lua);
80
81 let ast_graph = ASTGraph::from_tree(tree, content, self.max_scope_depth).map_err(|e| {
83 GraphBuilderError::ParseError {
84 span: Span::default(),
85 reason: e,
86 }
87 })?;
88
89 let recursion_limits =
91 sqry_core::config::RecursionLimits::load_or_default().map_err(|e| {
92 GraphBuilderError::ParseError {
93 span: Span::default(),
94 reason: format!("Failed to load recursion limits: {e}"),
95 }
96 })?;
97 let file_ops_depth = recursion_limits.effective_file_ops_depth().map_err(|e| {
98 GraphBuilderError::ParseError {
99 span: Span::default(),
100 reason: format!("Invalid file_ops_depth configuration: {e}"),
101 }
102 })?;
103 let mut guard =
104 sqry_core::query::security::RecursionGuard::new(file_ops_depth).map_err(|e| {
105 GraphBuilderError::ParseError {
106 span: Span::default(),
107 reason: format!("Failed to create recursion guard: {e}"),
108 }
109 })?;
110
111 let mut ffi_aliases = FfiAliasTable::new();
113 populate_ffi_aliases(tree.root_node(), content, &mut ffi_aliases);
114
115 walk_tree_for_graph(
117 tree.root_node(),
118 content,
119 &ast_graph,
120 &mut helper,
121 &mut guard,
122 &ffi_aliases,
123 )?;
124
125 Ok(())
126 }
127
128 fn language(&self) -> Language {
129 Language::Lua
130 }
131
132 fn shape_mapping(&self) -> Option<&dyn ShapeMapping> {
133 Some(lua_shape_mapping())
134 }
135}
136
137pub struct LuaShapeMapping {
144 cf_by_kind_id: Vec<Option<CfBucket>>,
145}
146
147impl LuaShapeMapping {
148 fn build() -> Self {
149 let lang: tree_sitter::Language = tree_sitter_lua::LANGUAGE.into();
150 let count = lang.node_kind_count();
151 let mut cf_by_kind_id = vec![None; count];
152 for (id, slot) in cf_by_kind_id.iter_mut().enumerate() {
153 let Ok(kind_id) = u16::try_from(id) else {
154 break;
155 };
156 if !lang.node_kind_is_named(kind_id) {
157 continue;
158 }
159 if let Some(name) = lang.node_kind_for_id(kind_id) {
160 *slot = cf_bucket_for_lua_kind(name);
161 }
162 }
163 Self { cf_by_kind_id }
164 }
165}
166
167impl ShapeMapping for LuaShapeMapping {
168 fn cf_bucket(&self, ts_node_kind_id: u16) -> Option<CfBucket> {
169 self.cf_by_kind_id
170 .get(ts_node_kind_id as usize)
171 .copied()
172 .flatten()
173 }
174
175 fn signature_shape(&self, fn_node: Node, _src: &[u8]) -> SignatureShape {
176 let mut shape = SignatureShape::default();
177 if let Some(params) = fn_node.child_by_field_name("parameters") {
178 let mut cursor = params.walk();
179 for child in params.named_children(&mut cursor) {
180 match child.kind() {
181 "identifier" => {
182 shape.arity_positional = shape.arity_positional.saturating_add(1);
183 }
184 "vararg_expression" => shape.has_varargs = true,
185 _ => {}
186 }
187 }
188 }
189 shape
190 }
191}
192
193fn cf_bucket_for_lua_kind(name: &str) -> Option<CfBucket> {
196 let bucket = match name {
197 "if_statement" | "elseif_statement" | "else_statement" => CfBucket::Branch,
198 "while_statement" | "for_statement" | "repeat_statement" => CfBucket::Loop,
199 "break_statement" | "goto_statement" => CfBucket::BreakContinue,
200 "return_statement" => CfBucket::Return,
201 "function_call" => CfBucket::Call,
202 "assignment_statement" | "variable_declaration" => CfBucket::Assign,
203 "function_definition" => CfBucket::Closure,
206 _ => return None,
207 };
208 Some(bucket)
209}
210
211#[must_use]
213pub fn lua_shape_mapping() -> &'static LuaShapeMapping {
214 static MAPPING: OnceLock<LuaShapeMapping> = OnceLock::new();
215 MAPPING.get_or_init(LuaShapeMapping::build)
216}
217
218fn is_local_function(node: Node<'_>) -> bool {
220 let Some(parent) = node.parent() else {
221 return false;
222 };
223
224 if let Some(index) = named_child_index(parent, node)
230 && let Some(field) = parent.field_name_for_named_child(index)
231 {
232 return field == "local_declaration";
233 }
234
235 if let Some(index) = child_index(parent, node)
236 && let Some(field) = parent.field_name_for_child(index)
237 {
238 return field == "local_declaration";
239 }
240
241 if parent.kind() == "local_variable_declaration" {
243 return true;
244 }
245
246 false
247}
248
249#[allow(clippy::unnecessary_wraps)]
252fn get_function_visibility(qualified_name: &str) -> Option<&'static str> {
253 let function_name = qualified_name.rsplit("::").next().unwrap_or(qualified_name);
255
256 if function_name.starts_with('_') {
257 Some("private")
258 } else {
259 Some("public")
260 }
261}
262
263fn named_child_index(parent: Node<'_>, target: Node<'_>) -> Option<u32> {
265 for i in 0..parent.named_child_count() {
266 #[allow(clippy::cast_possible_truncation)] if let Some(child) = parent.named_child(i as u32)
268 && child.id() == target.id()
269 {
270 let index = u32::try_from(i).ok()?;
271 return Some(index);
272 }
273 }
274 None
275}
276
277fn child_index(parent: Node<'_>, target: Node<'_>) -> Option<u32> {
279 let mut cursor = parent.walk();
280 if !cursor.goto_first_child() {
281 return None;
282 }
283 let mut index = 0u32;
284 loop {
285 if cursor.node().id() == target.id() {
286 return Some(index);
287 }
288 if !cursor.goto_next_sibling() {
289 break;
290 }
291 index += 1;
292 }
293 None
294}
295
296#[allow(clippy::too_many_lines)] fn walk_tree_for_graph(
302 node: Node,
303 content: &[u8],
304 ast_graph: &ASTGraph,
305 helper: &mut GraphBuildHelper,
306 guard: &mut sqry_core::query::security::RecursionGuard,
307 ffi_aliases: &FfiAliasTable,
308) -> GraphResult<()> {
309 guard.enter().map_err(|e| GraphBuilderError::ParseError {
310 span: Span::default(),
311 reason: format!("Recursion limit exceeded: {e}"),
312 })?;
313
314 match node.kind() {
315 "function_declaration" => {
319 if let Some(call_context) = ast_graph.get_callable_context(node.id()) {
321 let span = span_from_node(node);
322
323 let is_local = is_local_function(node);
325 let visibility = get_function_visibility(&call_context.qualified_name);
327
328 if call_context.is_method {
330 let node_id = helper.add_method_with_visibility(
331 &call_context.qualified_name,
332 Some(span),
333 false, false, visibility,
336 );
337 if call_context.module_name.is_some() && !is_local {
340 let module_id = helper.add_module(FILE_MODULE_NAME, None);
341 helper.add_export_edge(module_id, node_id);
342 }
343 } else {
344 let node_id = helper.add_function_with_visibility(
345 &call_context.qualified_name,
346 Some(span),
347 false, false, visibility,
350 );
351 let is_module_scoped = call_context.qualified_name.contains("::");
354 let is_global = !is_local && !is_module_scoped;
355
356 if (is_module_scoped || is_global) && !is_local {
357 let module_id = helper.add_module(FILE_MODULE_NAME, None);
358 helper.add_export_edge(module_id, node_id);
359 }
360 }
361 }
362 }
363 "assignment_statement" => {
364 let mut cursor = node.walk();
367 let func_def = node
368 .children(&mut cursor)
369 .find(|child| child.kind() == "expression_list")
370 .and_then(|expr_list| {
371 expr_list
372 .named_children(&mut expr_list.walk())
373 .find(|child| child.kind() == "function_definition")
374 });
375
376 if let Some(func_def_node) = func_def {
377 if let Some(call_context) = ast_graph.get_callable_context(func_def_node.id()) {
379 let span = span_from_node(node);
380
381 let is_local = is_local_function(node);
383 let visibility = get_function_visibility(&call_context.qualified_name);
385
386 if call_context.is_method {
388 let node_id = helper.add_method_with_visibility(
389 &call_context.qualified_name,
390 Some(span),
391 false,
392 false,
393 visibility,
394 );
395 if call_context.module_name.is_some() && !is_local {
398 let module_id = helper.add_module(FILE_MODULE_NAME, None);
399 helper.add_export_edge(module_id, node_id);
400 }
401 } else {
402 let node_id = helper.add_function_with_visibility(
403 &call_context.qualified_name,
404 Some(span),
405 false,
406 false,
407 visibility,
408 );
409 if call_context.qualified_name.contains("::") && !is_local {
413 let module_id = helper.add_module(FILE_MODULE_NAME, None);
414 helper.add_export_edge(module_id, node_id);
415 }
416 }
417 }
418 }
419 }
420 "return_statement" => {
421 handle_return_table_exports(node, content, helper);
424 }
425 "function_call" => {
426 if let Some(ffi_info) = extract_ffi_call_info(node, content, ffi_aliases) {
428 emit_ffi_edge(ffi_info, node, content, ast_graph, helper);
429 }
430 else if is_require_call(node, content) {
432 build_require_import_edge(node, content, helper);
434 }
435 else if let Ok(Some((caller_qname, callee_qname, argument_count, span))) =
437 build_call_for_staging(ast_graph, node, content)
438 {
439 let call_context = ast_graph.get_callable_context(node.id());
441 let is_method = call_context.is_some_and(|c| c.is_method);
442
443 let source_id = if is_method {
444 helper.ensure_method(&caller_qname, None, false, false)
445 } else {
446 helper.ensure_callee(&caller_qname, span, CalleeKindHint::Function)
447 };
448 let target_id = helper.ensure_callee(&callee_qname, span, CalleeKindHint::Function);
449
450 let argument_count = u8::try_from(argument_count).unwrap_or(u8::MAX);
452 helper.add_call_edge_full_with_span(
453 source_id,
454 target_id,
455 argument_count,
456 false,
457 vec![span],
458 );
459 }
460 }
461 "table_constructor" => {
462 build_table_fields(node, content, helper)?;
465 }
466 "dot_index_expression" | "bracket_index_expression" => {
467 build_field_access(node, content, helper)?;
470 }
471 _ => {}
472 }
473
474 let mut cursor = node.walk();
476 for child in node.children(&mut cursor) {
477 walk_tree_for_graph(child, content, ast_graph, helper, guard, ffi_aliases)?;
478 }
479
480 guard.exit();
481 Ok(())
482}
483
484fn handle_return_table_exports(node: Node<'_>, content: &[u8], helper: &mut GraphBuildHelper) {
489 let Some(expr_list) = node
492 .children(&mut node.walk())
493 .find(|child| child.kind() == "expression_list")
494 else {
495 return;
496 };
497
498 let Some(table_node) = expr_list
499 .children(&mut expr_list.walk())
500 .find(|child| child.kind() == "table_constructor")
501 else {
502 return;
503 };
504
505 let module_id = helper.add_module(FILE_MODULE_NAME, None);
506
507 let mut cursor = table_node.walk();
509 for field in table_node.children(&mut cursor) {
510 if field.kind() != "field" {
511 continue;
512 }
513
514 let key_name = if let Some(name_node) = field.child_by_field_name("name") {
516 name_node
518 .utf8_text(content)
519 .ok()
520 .map(|s| s.trim().to_string())
521 } else {
522 continue;
524 };
525
526 let Some(key) = key_name else {
527 continue;
528 };
529
530 let Some(value_node) = field.child_by_field_name("value") else {
532 continue;
533 };
534
535 let exported_name = match value_node.kind() {
542 "identifier" => {
543 value_node.utf8_text(content).ok().map(str::to_string)
545 }
546 "dot_index_expression" | "method_index_expression" => {
547 extract_table_field_qualified_name(value_node, content).ok()
550 }
551 "function_definition" => {
552 Some(key.clone())
555 }
556 _ => None,
557 };
558
559 if let Some(name) = exported_name {
560 let exported_id =
564 helper.ensure_callee(&name, span_from_node(field), CalleeKindHint::Function);
565
566 helper.add_export_edge(module_id, exported_id);
567 }
568 }
569}
570
571fn extract_table_field_qualified_name(node: Node<'_>, content: &[u8]) -> Result<String, String> {
573 match node.kind() {
574 "identifier" => node
575 .utf8_text(content)
576 .map(str::to_string)
577 .map_err(|_| "failed to read identifier".to_string()),
578 "dot_index_expression" => {
579 let table = node
580 .child_by_field_name("table")
581 .ok_or_else(|| "dot_index_expression missing table".to_string())?;
582 let field = node
583 .child_by_field_name("field")
584 .ok_or_else(|| "dot_index_expression missing field".to_string())?;
585
586 let table_text = extract_table_field_qualified_name(table, content)?;
587 let field_text = field
588 .utf8_text(content)
589 .map_err(|_| "failed to read field".to_string())?;
590
591 Ok(format!("{table_text}::{field_text}"))
592 }
593 "method_index_expression" => {
594 let table = node
595 .child_by_field_name("table")
596 .ok_or_else(|| "method_index_expression missing table".to_string())?;
597 let method = node
598 .child_by_field_name("method")
599 .ok_or_else(|| "method_index_expression missing method".to_string())?;
600
601 let table_text = extract_table_field_qualified_name(table, content)?;
602 let method_text = method
603 .utf8_text(content)
604 .map_err(|_| "failed to read method".to_string())?;
605
606 Ok(format!("{table_text}::{method_text}"))
607 }
608 _ => node
609 .utf8_text(content)
610 .map(str::to_string)
611 .map_err(|_| "failed to read node".to_string()),
612 }
613}
614
615fn is_require_call(call_node: Node<'_>, content: &[u8]) -> bool {
617 if let Some(name_node) = call_node.child_by_field_name("name")
619 && let Ok(text) = name_node.utf8_text(content)
620 {
621 return text.trim() == "require";
622 }
623 false
624}
625
626fn build_require_import_edge(call_node: Node<'_>, content: &[u8], helper: &mut GraphBuildHelper) {
628 let Some(args_node) = call_node.child_by_field_name("arguments") else {
631 return;
632 };
633
634 let mut cursor = args_node.walk();
636 let mut module_name: Option<String> = None;
637
638 for child in args_node.children(&mut cursor) {
639 if (child.kind() == "string" || child.kind() == "string_content")
640 && let Ok(text) = child.utf8_text(content)
641 {
642 let trimmed = text
644 .trim()
645 .trim_start_matches(['"', '\'', '['])
646 .trim_end_matches(['"', '\'', ']'])
647 .to_string();
648 if !trimmed.is_empty() {
649 module_name = Some(trimmed);
650 break;
651 }
652 }
653 let mut inner_cursor = child.walk();
655 for inner_child in child.children(&mut inner_cursor) {
656 if inner_child.kind() == "string_content"
657 && let Ok(text) = inner_child.utf8_text(content)
658 {
659 let trimmed = text.trim().to_string();
660 if !trimmed.is_empty() {
661 module_name = Some(trimmed);
662 break;
663 }
664 }
665 }
666 if module_name.is_some() {
667 break;
668 }
669 }
670
671 if let Some(imported_module) = module_name {
673 let span = span_from_node(call_node);
674
675 let module_id = helper.add_module("<module>", None);
677 let import_id = helper.add_import(&imported_module, Some(span));
678
679 helper.add_import_edge_full(module_id, import_id, None, false);
682 }
683}
684
685fn populate_ffi_aliases(node: Node, content: &[u8], aliases: &mut FfiAliasTable) {
694 match node.kind() {
695 "local_variable_declaration" | "assignment_statement" => {
696 extract_ffi_assignment(node, content, aliases);
698 }
699 _ => {}
700 }
701
702 let mut cursor = node.walk();
704 for child in node.children(&mut cursor) {
705 populate_ffi_aliases(child, content, aliases);
706 }
707}
708
709#[cfg(test)]
710#[allow(dead_code)]
711fn debug_node_structure(node: Node, content: &[u8], indent: usize) {
712 let indent_str = " ".repeat(indent);
713 let text = node.utf8_text(content).ok().and_then(|t| {
714 let trimmed = t.trim();
715 if trimmed.len() > 50 || trimmed.is_empty() {
716 None
717 } else {
718 Some(trimmed)
719 }
720 });
721
722 eprintln!(
723 "{}{}{}",
724 indent_str,
725 node.kind(),
726 text.map(|t| format!(" [{t}]")).unwrap_or_default()
727 );
728
729 if indent < 10 {
730 let mut cursor = node.walk();
731 for child in node.children(&mut cursor) {
732 debug_node_structure(child, content, indent + 1);
733 }
734 }
735}
736
737fn extract_ffi_assignment(node: Node, content: &[u8], aliases: &mut FfiAliasTable) {
739 let assignment = if node.kind() == "variable_declaration" {
743 let mut cursor = node.walk();
745 node.children(&mut cursor)
746 .find(|c| c.kind() == "assignment_statement")
747 } else if node.kind() == "assignment_statement" {
748 Some(node)
749 } else {
750 None
751 };
752
753 if let Some(assign_node) = assignment {
754 extract_from_assignment(assign_node, content, aliases);
755 }
756}
757
758fn extract_from_assignment(assign_node: Node, content: &[u8], aliases: &mut FfiAliasTable) {
760 let mut cursor = assign_node.walk();
762 let children: Vec<_> = assign_node.children(&mut cursor).collect();
763
764 let Some(var_list) = children.iter().find(|c| c.kind() == "variable_list") else {
765 return;
766 };
767 let Some(expr_list) = children.iter().find(|c| c.kind() == "expression_list") else {
768 return;
769 };
770
771 let Some(var_name_node) = var_list.named_child(0) else {
773 return;
774 };
775 let Ok(var_name) = var_name_node.utf8_text(content) else {
776 return;
777 };
778 let var_name = var_name.trim().to_string();
779
780 let Some(value_node) = expr_list.named_child(0) else {
782 return;
783 };
784
785 if is_require_ffi_call(value_node, content) {
787 aliases.insert(var_name, FfiEntity::Module);
788 } else if is_ffi_c_reference(value_node, content, aliases) {
789 aliases.insert(var_name, FfiEntity::CLibrary);
790 } else if let Some(lib_name) = extract_ffi_load_library(value_node, content, aliases) {
791 aliases.insert(var_name, FfiEntity::LoadedLibrary(lib_name));
792 }
793 else if value_node.kind() == "identifier"
795 && let Ok(alias_name) = value_node.utf8_text(content)
796 {
797 let alias_name = alias_name.trim();
798 if let Some(entity) = aliases.get(alias_name).cloned() {
799 aliases.insert(var_name, entity);
801 }
802 }
803}
804
805fn is_require_ffi_call(node: Node, content: &[u8]) -> bool {
807 if node.kind() != "function_call" {
808 return false;
809 }
810
811 let Some(name_node) = node.child_by_field_name("name") else {
813 return false;
814 };
815 let Ok(name_text) = name_node.utf8_text(content) else {
816 return false;
817 };
818 if name_text.trim() != "require" {
819 return false;
820 }
821
822 let Some(args_node) = node.child_by_field_name("arguments") else {
824 return false;
825 };
826 let Some(first_arg) = args_node.named_child(0) else {
827 return false;
828 };
829
830 if let Some(content_str) = extract_string_content(first_arg, content) {
832 return content_str == "ffi";
833 }
834
835 false
836}
837
838fn is_ffi_c_reference(node: Node, content: &[u8], aliases: &FfiAliasTable) -> bool {
840 if node.kind() != "dot_index_expression" {
841 return false;
842 }
843
844 let Some(table_node) = node.child_by_field_name("table") else {
845 return false;
846 };
847 let Some(field_node) = node.child_by_field_name("field") else {
848 return false;
849 };
850
851 let Ok(table_text) = table_node.utf8_text(content) else {
852 return false;
853 };
854 let Ok(field_text) = field_node.utf8_text(content) else {
855 return false;
856 };
857
858 let table_text = table_text.trim();
859 let field_text = field_text.trim();
860
861 aliases.get(table_text) == Some(&FfiEntity::Module) && field_text == "C"
863}
864
865fn extract_ffi_load_library(node: Node, content: &[u8], aliases: &FfiAliasTable) -> Option<String> {
867 if node.kind() != "function_call" {
868 return None;
869 }
870
871 let name_node = node.child_by_field_name("name")?;
873 if name_node.kind() != "dot_index_expression" {
874 return None;
875 }
876
877 let table_node = name_node.child_by_field_name("table")?;
878 let field_node = name_node.child_by_field_name("field")?;
879
880 let table_text = table_node.utf8_text(content).ok()?;
881 let field_text = field_node.utf8_text(content).ok()?;
882
883 let table_text = table_text.trim();
884 let field_text = field_text.trim();
885
886 if aliases.get(table_text) != Some(&FfiEntity::Module) || field_text != "load" {
888 return None;
889 }
890
891 let args_node = node.child_by_field_name("arguments")?;
893 let first_arg = args_node.named_child(0)?;
894
895 extract_string_content(first_arg, content)
897}
898
899fn extract_string_content(string_node: Node, content: &[u8]) -> Option<String> {
901 if string_node.kind() == "string" {
902 let mut cursor = string_node.walk();
904 for child in string_node.children(&mut cursor) {
905 if child.kind() == "string_content"
906 && let Ok(text) = child.utf8_text(content)
907 {
908 return Some(text.trim().to_string());
909 }
910 }
911
912 if let Ok(text) = string_node.utf8_text(content) {
914 let trimmed = text
915 .trim()
916 .trim_start_matches(['"', '\'', '['])
917 .trim_end_matches(['"', '\'', ']'])
918 .to_string();
919 if !trimmed.is_empty() {
920 return Some(trimmed);
921 }
922 }
923 }
924 None
925}
926
927fn extract_ffi_call_info(
929 call_node: Node,
930 content: &[u8],
931 aliases: &FfiAliasTable,
932) -> Option<FfiCallInfo> {
933 let name_node = call_node.child_by_field_name("name")?;
934
935 match name_node.kind() {
937 "dot_index_expression" => {
938 if is_ffi_load_call(name_node, content, aliases) {
940 return extract_ffi_load_call_info(call_node, content);
941 }
942 extract_ffi_from_dot_expression(name_node, content, aliases)
944 }
945 _ => None,
946 }
947}
948
949fn is_ffi_load_call(dot_expr: Node, content: &[u8], aliases: &FfiAliasTable) -> bool {
951 let Some(table_node) = dot_expr.child_by_field_name("table") else {
952 return false;
953 };
954 let Some(field_node) = dot_expr.child_by_field_name("field") else {
955 return false;
956 };
957
958 let Ok(table_text) = table_node.utf8_text(content) else {
959 return false;
960 };
961 let Ok(field_text) = field_node.utf8_text(content) else {
962 return false;
963 };
964
965 aliases.get(table_text.trim()) == Some(&FfiEntity::Module) && field_text.trim() == "load"
966}
967
968fn extract_ffi_load_call_info(call_node: Node, content: &[u8]) -> Option<FfiCallInfo> {
970 let args_node = call_node.child_by_field_name("arguments")?;
971 let first_arg = args_node.named_child(0)?;
972
973 let lib_name = extract_string_content(first_arg, content)?;
974
975 Some(FfiCallInfo {
978 function_name: lib_name,
979 library_name: None,
980 })
981}
982
983fn extract_ffi_from_dot_expression(
985 dot_expr: Node,
986 content: &[u8],
987 aliases: &FfiAliasTable,
988) -> Option<FfiCallInfo> {
989 let table_node = dot_expr.child_by_field_name("table")?;
990 let field_node = dot_expr.child_by_field_name("field")?;
991
992 let function_name = field_node.utf8_text(content).ok()?.trim().to_string();
993
994 if table_node.kind() == "dot_index_expression" {
996 let inner_table = table_node.child_by_field_name("table")?;
997 let inner_field = table_node.child_by_field_name("field")?;
998
999 let base_text = inner_table.utf8_text(content).ok()?.trim();
1000 let mid_text = inner_field.utf8_text(content).ok()?.trim();
1001
1002 if aliases.get(base_text) == Some(&FfiEntity::Module) && mid_text == "C" {
1004 return Some(FfiCallInfo {
1005 function_name,
1006 library_name: None,
1007 });
1008 }
1009 } else {
1010 let table_text = table_node.utf8_text(content).ok()?.trim();
1012
1013 match aliases.get(table_text) {
1014 Some(FfiEntity::CLibrary) => {
1015 return Some(FfiCallInfo {
1016 function_name,
1017 library_name: None,
1018 });
1019 }
1020 Some(FfiEntity::LoadedLibrary(lib_name)) => {
1021 return Some(FfiCallInfo {
1022 function_name,
1023 library_name: Some(lib_name.clone()),
1024 });
1025 }
1026 _ => {}
1027 }
1028 }
1029
1030 None
1031}
1032
1033fn emit_ffi_edge(
1035 ffi_info: FfiCallInfo,
1036 call_node: Node,
1037 content: &[u8],
1038 ast_graph: &ASTGraph,
1039 helper: &mut GraphBuildHelper,
1040) {
1041 let caller_id = get_ffi_caller_node_id(call_node, content, ast_graph, helper);
1043
1044 let target_name = if let Some(lib) = ffi_info.library_name {
1046 format!("native::{}::{}", lib, ffi_info.function_name)
1047 } else {
1048 format!("native::{}", ffi_info.function_name)
1049 };
1050
1051 let target_id = helper.ensure_callee(
1053 &target_name,
1054 span_from_node(call_node),
1055 CalleeKindHint::Function,
1056 );
1057
1058 helper.add_ffi_edge(caller_id, target_id, FfiConvention::C);
1060}
1061
1062fn get_ffi_caller_node_id(
1064 call_node: Node,
1065 content: &[u8],
1066 ast_graph: &ASTGraph,
1067 helper: &mut GraphBuildHelper,
1068) -> sqry_core::graph::unified::node::NodeId {
1069 let call_span = span_from_node(call_node);
1070 if let Some(call_context) = ast_graph.get_callable_context(call_node.id()) {
1072 if call_context.is_method {
1073 return helper.ensure_method(&call_context.qualified_name, None, false, false);
1074 }
1075 return helper.ensure_callee(
1076 &call_context.qualified_name,
1077 call_span,
1078 CalleeKindHint::Function,
1079 );
1080 }
1081
1082 let mut current = call_node.parent();
1084 while let Some(node) = current {
1085 if node.kind() == "function_declaration" || node.kind() == "function_definition" {
1086 if let Some(name_node) = node.child_by_field_name("name")
1088 && let Ok(name_text) = name_node.utf8_text(content)
1089 {
1090 return helper.ensure_callee(
1091 name_text,
1092 span_from_node(node),
1093 CalleeKindHint::Function,
1094 );
1095 }
1096 }
1097 current = node.parent();
1098 }
1099
1100 helper.ensure_callee("<file_level>", call_span, CalleeKindHint::Function)
1102}
1103
1104fn build_call_for_staging(
1106 ast_graph: &ASTGraph,
1107 call_node: Node<'_>,
1108 content: &[u8],
1109) -> GraphResult<Option<(String, String, usize, Span)>> {
1110 let module_context;
1112 let call_context = if let Some(ctx) = ast_graph.get_callable_context(call_node.id()) {
1113 ctx
1114 } else {
1115 module_context = CallContext {
1117 qualified_name: "<module>".to_string(),
1118 span: (0, content.len()),
1119 is_method: false,
1120 module_name: None,
1121 };
1122 &module_context
1123 };
1124
1125 let Some(name_node) = call_node.child_by_field_name("name") else {
1127 return Ok(None);
1128 };
1129
1130 let callee_text = name_node
1131 .utf8_text(content)
1132 .map_err(|_| GraphBuilderError::ParseError {
1133 span: span_from_node(call_node),
1134 reason: "failed to read call expression".to_string(),
1135 })?
1136 .trim()
1137 .to_string();
1138
1139 if callee_text.is_empty() {
1140 return Ok(None);
1141 }
1142
1143 let mut target_qualified = extract_call_target(name_node, content, call_context)?;
1145
1146 if !target_qualified.contains("::") {
1148 let scoped_name = if call_context.qualified_name == "<module>" {
1150 target_qualified.clone()
1151 } else {
1152 format!("{}::{}", call_context.qualified_name, &target_qualified)
1153 };
1154
1155 if ast_graph
1157 .contexts()
1158 .iter()
1159 .any(|ctx| ctx.qualified_name == scoped_name)
1160 {
1161 target_qualified = scoped_name;
1162 }
1163 else if let Some(parent_scope) = extract_parent_scope(&call_context.qualified_name) {
1165 let sibling_name = format!("{}::{}", parent_scope, &target_qualified);
1166 if ast_graph
1167 .contexts()
1168 .iter()
1169 .any(|ctx| ctx.qualified_name == sibling_name)
1170 {
1171 target_qualified = sibling_name;
1172 }
1173 }
1174 }
1175
1176 let source_qualified = call_context.qualified_name();
1177
1178 let span = span_from_node(call_node);
1179 let argument_count = count_arguments(call_node);
1180
1181 Ok(Some((
1182 source_qualified,
1183 target_qualified,
1184 argument_count,
1185 span,
1186 )))
1187}
1188
1189fn extract_call_target(
1195 name_node: Node<'_>,
1196 content: &[u8],
1197 call_context: &CallContext,
1198) -> GraphResult<String> {
1199 match name_node.kind() {
1200 "identifier" => {
1201 get_node_text(name_node, content)
1204 }
1205 "dot_index_expression" => {
1206 flatten_dotted_name(name_node, content)
1208 }
1209 "method_index_expression" => {
1210 flatten_method_name(name_node, content, call_context)
1212 }
1213 "bracket_index_expression" => {
1214 flatten_bracket_name(name_node, content, call_context)
1216 }
1217 "function_call" => {
1218 get_node_text(name_node, content)
1221 }
1222 _ => get_node_text(name_node, content),
1223 }
1224}
1225
1226fn flatten_dotted_name(node: Node<'_>, content: &[u8]) -> GraphResult<String> {
1228 let table = node
1229 .child_by_field_name("table")
1230 .ok_or_else(|| GraphBuilderError::ParseError {
1231 span: span_from_node(node),
1232 reason: "dot_index_expression missing table".to_string(),
1233 })?;
1234 let field = node
1235 .child_by_field_name("field")
1236 .ok_or_else(|| GraphBuilderError::ParseError {
1237 span: span_from_node(node),
1238 reason: "dot_index_expression missing field".to_string(),
1239 })?;
1240
1241 let table_text = collect_table_path(table, content)?;
1242 let field_text = get_node_text(field, content)?;
1243
1244 Ok(format!("{table_text}::{field_text}"))
1245}
1246
1247fn flatten_method_name(
1250 node: Node<'_>,
1251 content: &[u8],
1252 call_context: &CallContext,
1253) -> GraphResult<String> {
1254 let table = node
1255 .child_by_field_name("table")
1256 .ok_or_else(|| GraphBuilderError::ParseError {
1257 span: span_from_node(node),
1258 reason: "method_index_expression missing table".to_string(),
1259 })?;
1260 let method =
1261 node.child_by_field_name("method")
1262 .ok_or_else(|| GraphBuilderError::ParseError {
1263 span: span_from_node(node),
1264 reason: "method_index_expression missing method".to_string(),
1265 })?;
1266
1267 let mut table_text = collect_table_path(table, content)?;
1268 let method_text = get_node_text(method, content)?;
1269
1270 if table_text == "self"
1272 && let Some(ref module_name) = call_context.module_name
1273 {
1274 table_text.clone_from(module_name);
1275 }
1276 Ok(format!("{table_text}::{method_text}"))
1279}
1280
1281fn flatten_bracket_name(
1283 node: Node<'_>,
1284 content: &[u8],
1285 call_context: &CallContext,
1286) -> GraphResult<String> {
1287 let table = node
1288 .child_by_field_name("table")
1289 .ok_or_else(|| GraphBuilderError::ParseError {
1290 span: span_from_node(node),
1291 reason: "bracket_index_expression missing table".to_string(),
1292 })?;
1293 let field = node
1294 .child_by_field_name("field")
1295 .ok_or_else(|| GraphBuilderError::ParseError {
1296 span: span_from_node(node),
1297 reason: "bracket_index_expression missing field".to_string(),
1298 })?;
1299
1300 let mut table_text = collect_table_path(table, content)?;
1301 if table_text == "self"
1302 && let Some(ref module_name) = call_context.module_name
1303 {
1304 table_text.clone_from(module_name);
1305 }
1306
1307 let field_text = normalize_field_value(field, content)?;
1308
1309 Ok(format!("{table_text}::{field_text}"))
1310}
1311
1312fn collect_table_path(node: Node<'_>, content: &[u8]) -> GraphResult<String> {
1314 match node.kind() {
1315 "bracket_index_expression" => {
1316 let table =
1317 node.child_by_field_name("table")
1318 .ok_or_else(|| GraphBuilderError::ParseError {
1319 span: span_from_node(node),
1320 reason: "bracket_index_expression missing table".to_string(),
1321 })?;
1322 let field =
1323 node.child_by_field_name("field")
1324 .ok_or_else(|| GraphBuilderError::ParseError {
1325 span: span_from_node(node),
1326 reason: "bracket_index_expression missing field".to_string(),
1327 })?;
1328
1329 let table_text = collect_table_path(table, content)?;
1330 let field_text = normalize_field_value(field, content)?;
1331
1332 Ok(format!("{table_text}::{field_text}"))
1333 }
1334 "dot_index_expression" => {
1335 let table =
1336 node.child_by_field_name("table")
1337 .ok_or_else(|| GraphBuilderError::ParseError {
1338 span: span_from_node(node),
1339 reason: "nested dot_index_expression missing table".to_string(),
1340 })?;
1341 let field =
1342 node.child_by_field_name("field")
1343 .ok_or_else(|| GraphBuilderError::ParseError {
1344 span: span_from_node(node),
1345 reason: "nested dot_index_expression missing field".to_string(),
1346 })?;
1347
1348 let table_text = collect_table_path(table, content)?;
1349 let field_text = get_node_text(field, content)?;
1350
1351 Ok(format!("{table_text}::{field_text}"))
1352 }
1353 _ => get_node_text(node, content),
1354 }
1355}
1356
1357fn get_node_text(node: Node<'_>, content: &[u8]) -> GraphResult<String> {
1359 node.utf8_text(content)
1360 .map(|text| text.trim().to_string())
1361 .map_err(|_| GraphBuilderError::ParseError {
1362 span: span_from_node(node),
1363 reason: "failed to read node text".to_string(),
1364 })
1365}
1366
1367fn span_from_node(node: Node<'_>) -> Span {
1369 let start = node.start_position();
1370 let end = node.end_position();
1371 Span::new(
1372 Position::new(start.row, start.column),
1373 Position::new(end.row, end.column),
1374 )
1375}
1376
1377fn count_arguments(call_node: Node<'_>) -> usize {
1379 call_node
1380 .child_by_field_name("arguments")
1381 .map_or(0, |args| {
1382 args.named_children(&mut args.walk())
1383 .filter(|child| !matches!(child.kind(), "," | "(" | ")"))
1384 .count()
1385 })
1386}
1387
1388fn extract_parent_scope(qualified_name: &str) -> Option<String> {
1392 let parts: Vec<&str> = qualified_name.split("::").collect();
1393 if parts.len() > 1 {
1394 Some(parts[..parts.len() - 1].join("::"))
1395 } else {
1396 None
1397 }
1398}
1399
1400fn normalize_field_value(node: Node<'_>, content: &[u8]) -> GraphResult<String> {
1401 normalize_field_value_simple(node, content).map_err(|reason| GraphBuilderError::ParseError {
1402 span: span_from_node(node),
1403 reason,
1404 })
1405}
1406
1407fn normalize_field_value_simple(node: Node<'_>, content: &[u8]) -> Result<String, String> {
1408 let raw = node
1409 .utf8_text(content)
1410 .map_err(|_| "failed to read field value".to_string())?
1411 .trim()
1412 .to_string();
1413
1414 if raw.is_empty() {
1415 return Err("empty field value".to_string());
1416 }
1417
1418 match node.kind() {
1419 "string" => Ok(strip_string_literal(&raw)),
1420 _ => Ok(raw),
1421 }
1422}
1423
1424fn strip_string_literal(raw: &str) -> String {
1425 if raw.is_empty() {
1426 return raw.to_string();
1427 }
1428
1429 let bytes = raw.as_bytes();
1430 if (bytes[0] == b'"' && bytes[bytes.len() - 1] == b'"')
1431 || (bytes[0] == b'\'' && bytes[bytes.len() - 1] == b'\'')
1432 {
1433 return raw[1..raw.len() - 1].to_string();
1434 }
1435
1436 if raw.starts_with('[') && raw.ends_with(']') {
1437 let mut start = 1usize;
1438 while start < raw.len() && raw.as_bytes()[start] == b'=' {
1439 start += 1;
1440 }
1441 if start < raw.len() && raw.as_bytes()[start] == b'[' {
1442 let mut end = raw.len() - 1;
1443 while end > 0 && raw.as_bytes()[end - 1] == b'=' {
1444 end -= 1;
1445 }
1446 if end > start + 1 {
1447 return raw[start + 1..end - 1].to_string();
1448 }
1449 }
1450 }
1451
1452 raw.to_string()
1453}
1454
1455fn strip_env_prefix(name: String) -> String {
1456 name.strip_prefix("_ENV::")
1457 .map(std::string::ToString::to_string)
1458 .unwrap_or(name)
1459}
1460
1461fn build_table_fields(
1463 table_node: Node<'_>,
1464 content: &[u8],
1465 helper: &mut GraphBuildHelper,
1466) -> GraphResult<()> {
1467 let mut cursor = table_node.walk();
1468
1469 for child in table_node.children(&mut cursor) {
1470 if child.kind() != "field" {
1471 continue;
1472 }
1473
1474 if let Some(name_node) = child.child_by_field_name("name") {
1476 let field_name = name_node
1477 .utf8_text(content)
1478 .map_err(|_| GraphBuilderError::ParseError {
1479 span: span_from_node(child),
1480 reason: "failed to read table field name".to_string(),
1481 })?
1482 .trim();
1483
1484 let span = span_from_node(child);
1486 let property_id = helper.add_node(field_name, Some(span), NodeKind::Property);
1488 helper.mark_definition(property_id);
1489 }
1490 }
1491
1492 Ok(())
1493}
1494
1495#[allow(clippy::unnecessary_wraps)]
1497fn build_field_access(
1498 access_node: Node<'_>,
1499 content: &[u8],
1500 helper: &mut GraphBuildHelper,
1501) -> GraphResult<()> {
1502 let field_name = match access_node.kind() {
1504 "dot_index_expression" => {
1505 access_node
1507 .child_by_field_name("field")
1508 .and_then(|n| n.utf8_text(content).ok())
1509 .map(|s| s.trim().to_string())
1510 }
1511 "bracket_index_expression" => {
1512 access_node.child_by_field_name("field").and_then(|n| {
1514 if n.kind() == "string" {
1516 n.utf8_text(content)
1517 .ok()
1518 .map(|s| s.trim().trim_matches(|c| c == '"' || c == '\'').to_string())
1519 } else {
1520 n.utf8_text(content).ok().map(|s| s.trim().to_string())
1522 }
1523 })
1524 }
1525 _ => None,
1526 };
1527
1528 if let Some(name) = field_name {
1529 let span = span_from_node(access_node);
1531 helper.add_node(&name, Some(span), NodeKind::Property);
1532 }
1533
1534 Ok(())
1535}
1536
1537#[derive(Debug, Clone)]
1542struct CallContext {
1543 qualified_name: String,
1544 #[allow(dead_code)] span: (usize, usize),
1546 is_method: bool,
1547 module_name: Option<String>,
1549}
1550
1551impl CallContext {
1552 fn qualified_name(&self) -> String {
1553 self.qualified_name.clone()
1554 }
1555}
1556
1557struct ASTGraph {
1558 contexts: Vec<CallContext>,
1559 node_to_context: HashMap<usize, usize>,
1560}
1561
1562impl ASTGraph {
1563 fn from_tree(tree: &Tree, content: &[u8], max_depth: usize) -> Result<Self, String> {
1564 let mut contexts = Vec::new();
1565 let mut node_to_context = HashMap::new();
1566
1567 let mut state = WalkerState::new(&mut contexts, &mut node_to_context, max_depth);
1568
1569 walk_ast(tree.root_node(), content, &mut state)?;
1570
1571 Ok(Self {
1572 contexts,
1573 node_to_context,
1574 })
1575 }
1576
1577 fn contexts(&self) -> &[CallContext] {
1578 &self.contexts
1579 }
1580
1581 fn get_callable_context(&self, node_id: usize) -> Option<&CallContext> {
1582 self.node_to_context
1583 .get(&node_id)
1584 .and_then(|idx| self.contexts.get(*idx))
1585 }
1586}
1587
1588struct WalkerState<'a> {
1590 contexts: &'a mut Vec<CallContext>,
1591 node_to_context: &'a mut HashMap<usize, usize>,
1592 parent_qualified: Option<String>,
1593 module_context: Option<String>,
1594 lexical_depth: usize,
1595 max_depth: usize,
1596}
1597
1598impl<'a> WalkerState<'a> {
1599 fn new(
1600 contexts: &'a mut Vec<CallContext>,
1601 node_to_context: &'a mut HashMap<usize, usize>,
1602 max_depth: usize,
1603 ) -> Self {
1604 Self {
1605 contexts,
1606 node_to_context,
1607 parent_qualified: None,
1608 module_context: None,
1609 lexical_depth: 0,
1610 max_depth,
1611 }
1612 }
1613}
1614
1615fn walk_ast(node: Node, content: &[u8], state: &mut WalkerState) -> Result<(), String> {
1617 if state.lexical_depth > state.max_depth {
1620 return Ok(());
1621 }
1622
1623 match node.kind() {
1624 "local_function" => {
1625 handle_local_function(node, content, state)?;
1627 }
1628 "function_declaration" => {
1629 handle_function_declaration(node, content, state)?;
1630 }
1631 "assignment_statement" => {
1632 handle_function_assignment(node, content, state)?;
1634 }
1635 _ => {
1636 let mut cursor = node.walk();
1638 for child in node.children(&mut cursor) {
1639 walk_ast(child, content, state)?;
1640 }
1641 }
1642 }
1643
1644 Ok(())
1645}
1646
1647fn handle_local_function(
1649 node: Node,
1650 content: &[u8],
1651 state: &mut WalkerState,
1652) -> Result<(), String> {
1653 let name_node = node
1654 .child_by_field_name("name")
1655 .ok_or_else(|| "local_function missing name".to_string())?;
1656
1657 let base_name = name_node
1659 .utf8_text(content)
1660 .map_err(|_| "failed to read local function name".to_string())?
1661 .to_string();
1662
1663 let qualified_name = if let Some(parent) = state.parent_qualified.as_ref() {
1665 format!("{parent}::{base_name}")
1666 } else {
1667 base_name
1668 };
1669
1670 let context_idx = state.contexts.len();
1672 state.contexts.push(CallContext {
1673 qualified_name: qualified_name.clone(),
1674 span: (node.start_byte(), node.end_byte()),
1675 is_method: false, module_name: state.module_context.clone(),
1677 });
1678
1679 map_descendants_to_context(node, state.node_to_context, context_idx);
1681
1682 let saved_parent = state.parent_qualified.clone();
1684 let saved_module_context = state.module_context.clone();
1685
1686 state.parent_qualified = Some(qualified_name);
1688
1689 state.lexical_depth += 1;
1691
1692 #[allow(clippy::cast_possible_truncation)] if let Some(body) = node.named_child(node.named_child_count().saturating_sub(1) as u32) {
1695 walk_ast(body, content, state)?;
1696 }
1697
1698 state.lexical_depth -= 1;
1700 state.parent_qualified = saved_parent;
1701 state.module_context = saved_module_context;
1702
1703 Ok(())
1704}
1705
1706fn handle_function_declaration(
1708 node: Node,
1709 content: &[u8],
1710 state: &mut WalkerState,
1711) -> Result<(), String> {
1712 let name_node = node
1713 .child_by_field_name("name")
1714 .ok_or_else(|| "function_declaration missing name".to_string())?;
1715
1716 let (base_name, is_method) = extract_function_base_name(name_node, content)?;
1718
1719 let qualified_name = if base_name.contains("::") {
1723 base_name.clone()
1724 } else if let Some(parent) = state.parent_qualified.as_ref() {
1725 format!("{parent}::{base_name}")
1726 } else {
1727 base_name.clone()
1728 };
1729 let qualified_name = strip_env_prefix(qualified_name);
1730
1731 let module_name = if is_method {
1734 if qualified_name.contains("::") {
1736 let parts: Vec<&str> = qualified_name.split("::").collect();
1737 if parts.len() > 1 {
1738 Some(parts[..parts.len() - 1].join("::"))
1739 } else {
1740 None
1741 }
1742 } else {
1743 None
1744 }
1745 } else {
1746 state.module_context.clone()
1748 };
1749
1750 let context_idx = state.contexts.len();
1752 state.contexts.push(CallContext {
1753 qualified_name: qualified_name.clone(),
1754 span: (node.start_byte(), node.end_byte()),
1755 is_method,
1756 module_name: module_name.clone(),
1757 });
1758
1759 map_descendants_to_context(node, state.node_to_context, context_idx);
1761
1762 let saved_parent = state.parent_qualified.clone();
1764 let saved_module_context = state.module_context.clone();
1765
1766 state.parent_qualified = Some(qualified_name);
1768
1769 if is_method && module_name.is_some() {
1771 state.module_context = module_name;
1772 }
1773
1774 state.lexical_depth += 1;
1776
1777 #[allow(clippy::cast_possible_truncation)] if let Some(body) = node.named_child(node.named_child_count().saturating_sub(1) as u32) {
1780 walk_ast(body, content, state)?;
1781 }
1782
1783 state.lexical_depth -= 1;
1785 state.parent_qualified = saved_parent;
1786 state.module_context = saved_module_context;
1787
1788 Ok(())
1789}
1790
1791fn handle_function_assignment(
1793 node: Node,
1794 content: &[u8],
1795 state: &mut WalkerState,
1796) -> Result<(), String> {
1797 let Some(expr_list) = node
1799 .children(&mut node.walk())
1800 .find(|child| child.kind() == "expression_list")
1801 else {
1802 return Ok(());
1803 };
1804
1805 let Some(func_def) = expr_list
1806 .named_children(&mut expr_list.walk())
1807 .find(|child| child.kind() == "function_definition")
1808 else {
1809 return Ok(());
1810 };
1811
1812 let Some(var_list) = node
1814 .children(&mut node.walk())
1815 .find(|child| child.kind() == "variable_list")
1816 else {
1817 return Ok(());
1818 };
1819
1820 let Some(var_node) = var_list.named_child(0) else {
1821 return Ok(());
1822 };
1823
1824 let (base_name, is_method) = extract_assignment_base_name(var_node, content)?;
1826
1827 let qualified_name = if base_name.contains("::") {
1830 base_name.clone()
1831 } else if let Some(parent) = state.parent_qualified.as_ref() {
1832 format!("{parent}::{base_name}")
1833 } else {
1834 base_name.clone()
1835 };
1836 let qualified_name = strip_env_prefix(qualified_name);
1837
1838 let module_name = if is_method {
1841 if qualified_name.contains("::") {
1843 let parts: Vec<&str> = qualified_name.split("::").collect();
1844 if parts.len() > 1 {
1845 Some(parts[..parts.len() - 1].join("::"))
1846 } else {
1847 None
1848 }
1849 } else {
1850 None
1851 }
1852 } else {
1853 state.module_context.clone()
1855 };
1856
1857 let context_idx = state.contexts.len();
1859 state.contexts.push(CallContext {
1860 qualified_name: qualified_name.clone(),
1861 span: (func_def.start_byte(), func_def.end_byte()),
1862 is_method,
1863 module_name: module_name.clone(),
1864 });
1865
1866 map_descendants_to_context(func_def, state.node_to_context, context_idx);
1868
1869 let saved_parent = state.parent_qualified.clone();
1871 let saved_module_context = state.module_context.clone();
1872
1873 state.parent_qualified = Some(qualified_name);
1875
1876 if is_method && module_name.is_some() {
1878 state.module_context = module_name;
1879 }
1880
1881 state.lexical_depth += 1;
1883 #[allow(clippy::cast_possible_truncation)] if let Some(body) = func_def.named_child(func_def.named_child_count().saturating_sub(1) as u32)
1886 {
1887 walk_ast(body, content, state)?;
1888 }
1889
1890 state.lexical_depth -= 1;
1892 state.parent_qualified = saved_parent;
1893 state.module_context = saved_module_context;
1894
1895 Ok(())
1896}
1897
1898fn extract_function_base_name(
1901 name_node: Node<'_>,
1902 content: &[u8],
1903) -> Result<(String, bool), String> {
1904 match name_node.kind() {
1905 "identifier" => {
1906 let name = name_node
1908 .utf8_text(content)
1909 .map_err(|_| "failed to read function name".to_string())?;
1910 Ok((name.to_string(), false))
1911 }
1912 "dot_index_expression" => {
1913 let table = name_node
1915 .child_by_field_name("table")
1916 .ok_or_else(|| "dot_index_expression missing table".to_string())?;
1917 let field = name_node
1918 .child_by_field_name("field")
1919 .ok_or_else(|| "dot_index_expression missing field".to_string())?;
1920
1921 let table_text = collect_table_path_simple(table, content)?;
1922 let field_text = field
1923 .utf8_text(content)
1924 .map_err(|_| "failed to read field name".to_string())?;
1925
1926 Ok((format!("{table_text}::{field_text}"), false))
1927 }
1928 "method_index_expression" => {
1929 let table = name_node
1931 .child_by_field_name("table")
1932 .ok_or_else(|| "method_index_expression missing table".to_string())?;
1933 let method = name_node
1934 .child_by_field_name("method")
1935 .ok_or_else(|| "method_index_expression missing method".to_string())?;
1936
1937 let table_text = collect_table_path_simple(table, content)?;
1938 let method_text = method
1939 .utf8_text(content)
1940 .map_err(|_| "failed to read method name".to_string())?;
1941
1942 Ok((format!("{table_text}::{method_text}"), true))
1943 }
1944 "bracket_index_expression" => {
1945 let table = name_node
1947 .child_by_field_name("table")
1948 .ok_or_else(|| "bracket_index_expression missing table".to_string())?;
1949 let field = name_node
1950 .child_by_field_name("field")
1951 .ok_or_else(|| "bracket_index_expression missing field".to_string())?;
1952
1953 let table_text = collect_table_path_simple(table, content)?;
1954 let field_text = normalize_field_value_simple(field, content)?;
1955
1956 Ok((format!("{table_text}::{field_text}"), false))
1957 }
1958 _ => Err(format!(
1959 "unsupported function name kind: {}",
1960 name_node.kind()
1961 )),
1962 }
1963}
1964
1965fn extract_assignment_base_name(
1968 var_node: Node<'_>,
1969 content: &[u8],
1970) -> Result<(String, bool), String> {
1971 match var_node.kind() {
1972 "identifier" => {
1973 let name = var_node
1975 .utf8_text(content)
1976 .map_err(|_| "failed to read identifier".to_string())?;
1977 Ok((name.to_string(), false))
1978 }
1979 "dot_index_expression" => {
1980 let table = var_node
1982 .child_by_field_name("table")
1983 .ok_or_else(|| "dot_index_expression missing table".to_string())?;
1984 let field = var_node
1985 .child_by_field_name("field")
1986 .ok_or_else(|| "dot_index_expression missing field".to_string())?;
1987
1988 let table_text = collect_table_path_simple(table, content)?;
1989 let field_text = field
1990 .utf8_text(content)
1991 .map_err(|_| "failed to read field".to_string())?;
1992
1993 Ok((format!("{table_text}::{field_text}"), false))
1994 }
1995 "method_index_expression" => {
1996 let table = var_node
1998 .child_by_field_name("table")
1999 .ok_or_else(|| "method_index_expression missing table".to_string())?;
2000 let method = var_node
2001 .child_by_field_name("method")
2002 .ok_or_else(|| "method_index_expression missing method".to_string())?;
2003
2004 let table_text = collect_table_path_simple(table, content)?;
2005 let method_text = method
2006 .utf8_text(content)
2007 .map_err(|_| "failed to read method".to_string())?;
2008
2009 Ok((format!("{table_text}::{method_text}"), true))
2010 }
2011 "bracket_index_expression" => {
2012 let table = var_node
2014 .child_by_field_name("table")
2015 .ok_or_else(|| "bracket_index_expression missing table".to_string())?;
2016 let field = var_node
2017 .child_by_field_name("field")
2018 .ok_or_else(|| "bracket_index_expression missing field".to_string())?;
2019
2020 let table_text = collect_table_path_simple(table, content)?;
2021 let field_text = normalize_field_value_simple(field, content)?;
2022
2023 Ok((format!("{table_text}::{field_text}"), false))
2024 }
2025 _ => Err(format!(
2026 "unsupported assignment target kind: {}",
2027 var_node.kind()
2028 )),
2029 }
2030}
2031
2032fn collect_table_path_simple(node: Node<'_>, content: &[u8]) -> Result<String, String> {
2034 match node.kind() {
2035 "identifier" => node
2036 .utf8_text(content)
2037 .map(std::string::ToString::to_string)
2038 .map_err(|_| "failed to read identifier".to_string()),
2039 "bracket_index_expression" => {
2040 let table = node
2041 .child_by_field_name("table")
2042 .ok_or_else(|| "bracket_index_expression missing table".to_string())?;
2043 let field = node
2044 .child_by_field_name("field")
2045 .ok_or_else(|| "bracket_index_expression missing field".to_string())?;
2046
2047 let table_text = collect_table_path_simple(table, content)?;
2048 let field_text = normalize_field_value_simple(field, content)?;
2049
2050 Ok(format!("{table_text}::{field_text}"))
2051 }
2052 "dot_index_expression" => {
2053 let table = node
2054 .child_by_field_name("table")
2055 .ok_or_else(|| "dot_index_expression missing table".to_string())?;
2056 let field = node
2057 .child_by_field_name("field")
2058 .ok_or_else(|| "dot_index_expression missing field".to_string())?;
2059
2060 let table_text = collect_table_path_simple(table, content)?;
2061 let field_text = field
2062 .utf8_text(content)
2063 .map_err(|_| "failed to read field".to_string())?;
2064
2065 Ok(format!("{table_text}::{field_text}"))
2066 }
2067 _ => node
2068 .utf8_text(content)
2069 .map(std::string::ToString::to_string)
2070 .map_err(|_| "failed to read node".to_string()),
2071 }
2072}
2073
2074fn map_descendants_to_context(
2076 node: Node,
2077 node_to_context: &mut HashMap<usize, usize>,
2078 context_idx: usize,
2079) {
2080 node_to_context.insert(node.id(), context_idx);
2081
2082 let mut cursor = node.walk();
2083 for child in node.children(&mut cursor) {
2084 map_descendants_to_context(child, node_to_context, context_idx);
2085 }
2086}
2087
2088#[cfg(test)]
2089mod tests {
2090 use super::*;
2091 use crate::LuaPlugin;
2092 use sqry_core::graph::unified::build::StagingOp;
2093 use sqry_core::graph::unified::build::test_helpers::*;
2094 use sqry_core::graph::unified::edge::EdgeKind as UnifiedEdgeKind;
2095 use sqry_core::plugin::LanguagePlugin;
2096 use std::path::PathBuf;
2097
2098 fn extract_import_edges(staging: &StagingGraph) -> Vec<&UnifiedEdgeKind> {
2100 staging
2101 .operations()
2102 .iter()
2103 .filter_map(|op| {
2104 if let StagingOp::AddEdge { kind, .. } = op
2105 && matches!(kind, UnifiedEdgeKind::Imports { .. })
2106 {
2107 return Some(kind);
2108 }
2109 None
2110 })
2111 .collect()
2112 }
2113
2114 fn parse_lua(source: &str) -> Tree {
2115 let plugin = LuaPlugin::default();
2116 plugin.parse_ast(source.as_bytes()).unwrap()
2117 }
2118
2119 #[test]
2120 fn test_extracts_global_functions() {
2121 let source = r"
2122 function foo()
2123 end
2124
2125 function bar()
2126 end
2127 ";
2128
2129 let tree = parse_lua(source);
2130 let mut staging = StagingGraph::new();
2131 let builder = LuaGraphBuilder::default();
2132 let file = PathBuf::from("test.lua");
2133
2134 builder
2135 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2136 .unwrap();
2137
2138 assert!(staging.node_count() >= 2);
2139 assert_has_node(&staging, "foo");
2140 assert_has_node(&staging, "bar");
2141 }
2142
2143 #[test]
2144 fn test_creates_call_edges() {
2145 let source = r"
2146 function caller()
2147 callee()
2148 end
2149
2150 function callee()
2151 end
2152 ";
2153
2154 let tree = parse_lua(source);
2155 let mut staging = StagingGraph::new();
2156 let builder = LuaGraphBuilder::default();
2157 let file = PathBuf::from("test.lua");
2158
2159 builder
2160 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2161 .unwrap();
2162
2163 assert_has_node(&staging, "caller");
2164 assert_has_node(&staging, "callee");
2165
2166 let calls = collect_call_edges(&staging);
2167 assert!(!calls.is_empty(), "Expected at least one call edge");
2168 }
2169
2170 #[test]
2171 fn test_handles_module_methods() {
2172 let source = r"
2173 local MyModule = {}
2174
2175 function MyModule.method1()
2176 MyModule.method2()
2177 end
2178
2179 function MyModule:method2()
2180 end
2181 ";
2182
2183 let tree = parse_lua(source);
2184 let mut staging = StagingGraph::new();
2185 let builder = LuaGraphBuilder::default();
2186 let file = PathBuf::from("test.lua");
2187
2188 builder
2189 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2190 .unwrap();
2191
2192 assert_has_node(&staging, "method1");
2193 assert_has_node(&staging, "method2");
2194 assert_has_call_edge(&staging, "MyModule::method1", "MyModule::method2");
2195 }
2196
2197 #[test]
2198 fn test_nested_functions_scope_resolution() {
2199 let source = r"
2202 function outer()
2203 local function inner()
2204 local function deep()
2205 end
2206 end
2207 end
2208 ";
2209
2210 let tree = parse_lua(source);
2211 let mut staging = StagingGraph::new();
2212 let builder = LuaGraphBuilder::default();
2213 let file = PathBuf::from("test.lua");
2214
2215 builder
2216 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2217 .unwrap();
2218
2219 assert_has_node(&staging, "outer");
2221 assert_has_node(&staging, "outer::inner");
2222 assert_has_node(&staging, "outer::inner::deep");
2223
2224 }
2228
2229 #[test]
2230 fn test_self_resolution_in_methods() {
2231 let source = r"
2234 local MyModule = {}
2235
2236 function MyModule:method1()
2237 self:method2()
2238 end
2239
2240 function MyModule:method2()
2241 self:method3()
2242 end
2243
2244 function MyModule:method3()
2245 -- Empty
2246 end
2247 ";
2248
2249 let tree = parse_lua(source);
2250 let mut staging = StagingGraph::new();
2251 let builder = LuaGraphBuilder::default();
2252 let file = PathBuf::from("test.lua");
2253
2254 builder
2255 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2256 .unwrap();
2257
2258 assert_has_node(&staging, "MyModule::method1");
2260 assert_has_node(&staging, "MyModule::method2");
2261 assert_has_node(&staging, "MyModule::method3");
2262
2263 assert_has_call_edge(&staging, "MyModule::method1", "MyModule::method2");
2265
2266 assert_has_call_edge(&staging, "MyModule::method2", "MyModule::method3");
2268 }
2269
2270 #[test]
2271 fn test_local_function_call_resolution() {
2272 let source = r"
2276function outer()
2277 local function inner()
2278 local function deep()
2279 end
2280 deep()
2281 end
2282 inner()
2283end
2284";
2285
2286 let tree = parse_lua(source);
2287 let mut staging = StagingGraph::new();
2288 let builder = LuaGraphBuilder::default();
2289 let file = PathBuf::from("test.lua");
2290
2291 builder
2292 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2293 .unwrap();
2294
2295 assert_has_node(&staging, "outer");
2297 assert_has_node(&staging, "outer::inner");
2298 assert_has_node(&staging, "outer::inner::deep");
2299
2300 assert_has_call_edge(&staging, "outer", "outer::inner");
2302
2303 assert_has_call_edge(&staging, "outer::inner", "outer::inner::deep");
2305 }
2306
2307 #[test]
2308 fn test_nested_helper_self_resolution() {
2309 let source = r"
2313MyModule = {}
2314
2315function MyModule:outer()
2316 local function helper()
2317 self:inner()
2318 end
2319 helper()
2320end
2321
2322function MyModule:inner()
2323end
2324";
2325
2326 let tree = parse_lua(source);
2327 let mut staging = StagingGraph::new();
2328 let builder = LuaGraphBuilder::default();
2329 let file = PathBuf::from("test.lua");
2330
2331 builder
2332 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2333 .unwrap();
2334
2335 assert_has_node(&staging, "MyModule::outer");
2337 assert_has_node(&staging, "MyModule::outer::helper");
2338 assert_has_node(&staging, "MyModule::inner");
2339
2340 assert_has_call_edge(&staging, "MyModule::outer::helper", "MyModule::inner");
2342
2343 assert_has_call_edge(&staging, "MyModule::outer", "MyModule::outer::helper");
2345 }
2346
2347 #[test]
2348 fn test_bracket_string_key_assignment() {
2349 let source = r#"
2350 local Module = {}
2351
2352 Module["string-key"] = function()
2353 return true
2354 end
2355
2356 local function call_it()
2357 Module["string-key"]()
2358 end
2359 "#;
2360
2361 let tree = parse_lua(source);
2362 let mut staging = StagingGraph::new();
2363 let builder = LuaGraphBuilder::default();
2364 let file = PathBuf::from("test.lua");
2365
2366 builder
2367 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2368 .unwrap();
2369
2370 assert_has_node(&staging, "Module::string-key");
2372 assert_has_node(&staging, "call_it");
2373
2374 assert_has_call_edge(&staging, "call_it", "Module::string-key");
2376 }
2377
2378 #[test]
2379 fn test_numeric_command_table_assignment() {
2380 let source = r#"
2381 local commands = {}
2382
2383 commands[1] = function()
2384 return "cmd"
2385 end
2386
2387 local function run()
2388 commands[1]()
2389 end
2390 "#;
2391
2392 let tree = parse_lua(source);
2393 let mut staging = StagingGraph::new();
2394 let builder = LuaGraphBuilder::default();
2395 let file = PathBuf::from("test.lua");
2396
2397 builder
2398 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2399 .unwrap();
2400
2401 assert_has_node(&staging, "commands::1");
2403 assert_has_node(&staging, "run");
2404
2405 assert_has_call_edge(&staging, "run", "commands::1");
2407 }
2408
2409 #[test]
2410 fn test_env_driven_name_injection() {
2411 let source = r#"
2412 _ENV["init"] = function()
2413 return true
2414 end
2415
2416 local function boot()
2417 init()
2418 end
2419 "#;
2420
2421 let tree = parse_lua(source);
2422 let mut staging = StagingGraph::new();
2423 let builder = LuaGraphBuilder::default();
2424 let file = PathBuf::from("test.lua");
2425
2426 builder
2427 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2428 .unwrap();
2429
2430 assert_has_node(&staging, "init");
2432 assert_has_node(&staging, "boot");
2433
2434 assert_has_call_edge(&staging, "boot", "init");
2436 }
2437
2438 #[test]
2439 fn test_deep_namespace_lexical_depth() {
2440 let source = r"
2443Company = {}
2444Company.Product = {}
2445Company.Product.Component = {}
2446
2447function Company.Product.Component:outer()
2448 local function helper1()
2449 local function helper2()
2450 self:inner()
2451 end
2452 helper2()
2453 end
2454 helper1()
2455end
2456
2457function Company.Product.Component:inner()
2458end
2459";
2460
2461 let tree = parse_lua(source);
2462 let mut staging = StagingGraph::new();
2463 let builder = LuaGraphBuilder::default(); let file = PathBuf::from("test.lua");
2465
2466 builder
2467 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2468 .unwrap();
2469
2470 assert_has_node(&staging, "Company::Product::Component::outer");
2472 assert_has_node(&staging, "Company::Product::Component::outer::helper1");
2473 assert_has_node(
2474 &staging,
2475 "Company::Product::Component::outer::helper1::helper2",
2476 );
2477 assert_has_node(&staging, "Company::Product::Component::inner");
2478
2479 assert_has_call_edge(
2482 &staging,
2483 "Company::Product::Component::outer::helper1::helper2",
2484 "Company::Product::Component::inner",
2485 );
2486
2487 assert_has_call_edge(
2489 &staging,
2490 "Company::Product::Component::outer",
2491 "Company::Product::Component::outer::helper1",
2492 );
2493 assert_has_call_edge(
2494 &staging,
2495 "Company::Product::Component::outer::helper1",
2496 "Company::Product::Component::outer::helper1::helper2",
2497 );
2498 }
2499
2500 #[test]
2501 fn test_nested_method_redefinition() {
2502 let source = r"
2506MyModule = {}
2507
2508function MyModule:outer()
2509 function MyModule:inner()
2510 -- redefined inside outer
2511 end
2512 self:inner()
2513end
2514";
2515
2516 let tree = parse_lua(source);
2517 let mut staging = StagingGraph::new();
2518 let builder = LuaGraphBuilder::default();
2519 let file = PathBuf::from("test.lua");
2520
2521 builder
2522 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2523 .unwrap();
2524
2525 assert_has_node(&staging, "MyModule::outer");
2527 assert_has_node(&staging, "MyModule::inner");
2528
2529 assert_has_call_edge(&staging, "MyModule::outer", "MyModule::inner");
2531 }
2532
2533 #[test]
2538 fn test_require_import_edge_double_quotes() {
2539 let source = r#"
2540 local json = require("cjson")
2541 "#;
2542
2543 let tree = parse_lua(source);
2544 let mut staging = StagingGraph::new();
2545 let builder = LuaGraphBuilder::default();
2546 let file = PathBuf::from("test.lua");
2547
2548 builder
2549 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2550 .unwrap();
2551
2552 let import_edges = extract_import_edges(&staging);
2553 assert!(
2554 !import_edges.is_empty(),
2555 "Expected at least one import edge"
2556 );
2557
2558 let edge = import_edges[0];
2560 if let UnifiedEdgeKind::Imports { is_wildcard, .. } = edge {
2561 assert!(
2562 !*is_wildcard,
2563 "Lua require returns module reference, not wildcard"
2564 );
2565 } else {
2566 panic!("Expected Imports edge kind");
2567 }
2568 }
2569
2570 #[test]
2571 fn test_require_import_edge_single_quotes() {
2572 let source = r"
2573 local socket = require('socket')
2574 ";
2575
2576 let tree = parse_lua(source);
2577 let mut staging = StagingGraph::new();
2578 let builder = LuaGraphBuilder::default();
2579 let file = PathBuf::from("test.lua");
2580
2581 builder
2582 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2583 .unwrap();
2584
2585 let import_edges = extract_import_edges(&staging);
2586 assert!(!import_edges.is_empty(), "Expected require import edge");
2587
2588 let edge = import_edges[0];
2590 if let UnifiedEdgeKind::Imports { is_wildcard, .. } = edge {
2591 assert!(
2592 !*is_wildcard,
2593 "Lua require returns module reference, not wildcard"
2594 );
2595 } else {
2596 panic!("Expected Imports edge kind");
2597 }
2598 }
2599
2600 #[test]
2601 fn test_require_dotted_module() {
2602 let source = r#"
2603 local util = require("luasocket.util")
2604 "#;
2605
2606 let tree = parse_lua(source);
2607 let mut staging = StagingGraph::new();
2608 let builder = LuaGraphBuilder::default();
2609 let file = PathBuf::from("test.lua");
2610
2611 builder
2612 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2613 .unwrap();
2614
2615 let import_edges = extract_import_edges(&staging);
2616 assert!(
2617 !import_edges.is_empty(),
2618 "Expected dotted module import edge"
2619 );
2620
2621 let edge = import_edges[0];
2623 assert!(
2624 matches!(edge, UnifiedEdgeKind::Imports { .. }),
2625 "Expected Imports edge kind"
2626 );
2627 }
2628
2629 #[test]
2630 fn test_multiple_requires() {
2631 let source = r#"
2632 local json = require("cjson")
2633 local socket = require("socket")
2634 local lpeg = require("lpeg")
2635 local lfs = require("lfs")
2636 "#;
2637
2638 let tree = parse_lua(source);
2639 let mut staging = StagingGraph::new();
2640 let builder = LuaGraphBuilder::default();
2641 let file = PathBuf::from("test.lua");
2642
2643 builder
2644 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2645 .unwrap();
2646
2647 let import_edges = extract_import_edges(&staging);
2648 assert_eq!(import_edges.len(), 4, "Expected 4 import edges");
2649
2650 for edge in &import_edges {
2652 assert!(
2653 matches!(edge, UnifiedEdgeKind::Imports { .. }),
2654 "All edges should be Imports"
2655 );
2656 }
2657 }
2658}
2659
2660#[cfg(test)]
2661mod shape_tests {
2662 use super::{cf_bucket_for_lua_kind, lua_shape_mapping};
2666 use sqry_core::graph::unified::build::shape::{
2667 CfBucket, ShapeBudget, ShapeMapping, compute_shape_descriptor,
2668 };
2669 use tree_sitter::{Node, Parser, Tree};
2670
2671 const SAMPLE: &str = include_str!(concat!(
2672 env!("CARGO_MANIFEST_DIR"),
2673 "/../test-fixtures/shape/dynamic/lua.lua"
2674 ));
2675
2676 fn parse(src: &str) -> Tree {
2677 let mut parser = Parser::new();
2678 parser
2679 .set_language(&tree_sitter_lua::LANGUAGE.into())
2680 .expect("load lua grammar");
2681 parser.parse(src, None).expect("parse lua")
2682 }
2683
2684 fn first_function<'t>(tree: &'t Tree) -> Node<'t> {
2685 let root = tree.root_node();
2686 let mut cursor = root.walk();
2687 for child in root.named_children(&mut cursor) {
2688 if child.kind() == "function_declaration" {
2689 return child;
2690 }
2691 }
2692 panic!("no function_declaration in lua fixture");
2693 }
2694
2695 #[test]
2696 fn mapping_is_non_empty_and_covers_real_kinds() {
2697 assert_eq!(
2698 cf_bucket_for_lua_kind("if_statement"),
2699 Some(CfBucket::Branch)
2700 );
2701 assert_eq!(
2702 cf_bucket_for_lua_kind("while_statement"),
2703 Some(CfBucket::Loop)
2704 );
2705 assert_eq!(
2706 cf_bucket_for_lua_kind("repeat_statement"),
2707 Some(CfBucket::Loop)
2708 );
2709 assert_eq!(
2710 cf_bucket_for_lua_kind("break_statement"),
2711 Some(CfBucket::BreakContinue)
2712 );
2713 assert_eq!(
2714 cf_bucket_for_lua_kind("return_statement"),
2715 Some(CfBucket::Return)
2716 );
2717 assert_eq!(
2718 cf_bucket_for_lua_kind("function_call"),
2719 Some(CfBucket::Call)
2720 );
2721 assert_eq!(
2722 cf_bucket_for_lua_kind("function_definition"),
2723 Some(CfBucket::Closure)
2724 );
2725 assert_eq!(cf_bucket_for_lua_kind("nope"), None);
2726
2727 let lang: tree_sitter::Language = tree_sitter_lua::LANGUAGE.into();
2728 let id = (0..lang.node_kind_count())
2729 .map(|i| i as u16)
2730 .find(|&i| {
2731 lang.node_kind_is_named(i) && lang.node_kind_for_id(i) == Some("if_statement")
2732 })
2733 .expect("grammar exposes named if_statement");
2734 assert_eq!(lua_shape_mapping().cf_bucket(id), Some(CfBucket::Branch));
2735 }
2736
2737 #[test]
2738 fn descriptor_covers_fixture_control_flow() {
2739 let tree = parse(SAMPLE);
2740 let func = first_function(&tree);
2741 let descriptor = compute_shape_descriptor(
2742 func,
2743 SAMPLE.as_bytes(),
2744 lua_shape_mapping(),
2745 &ShapeBudget::default(),
2746 );
2747 let hist = descriptor.cf_histogram;
2748 assert!(hist[CfBucket::Branch.index()] >= 1, "branch (if)");
2749 assert!(hist[CfBucket::Loop.index()] >= 1, "loop (while/for/repeat)");
2750 assert!(hist[CfBucket::BreakContinue.index()] >= 1, "break");
2751 assert!(hist[CfBucket::Return.index()] >= 1, "return");
2752 assert!(hist[CfBucket::Call.index()] >= 1, "call");
2753 assert!(hist[CfBucket::Assign.index()] >= 1, "assignment");
2754 assert!(
2755 hist[CfBucket::Closure.index()] >= 1,
2756 "nested function literal"
2757 );
2758 }
2759
2760 #[test]
2761 fn signature_shape_reads_arity_and_varargs() {
2762 let tree = parse(SAMPLE);
2763 let func = first_function(&tree);
2764 let shape = lua_shape_mapping().signature_shape(func, SAMPLE.as_bytes());
2765 assert_eq!(shape.arity_positional, 2, "value + label");
2767 assert!(shape.has_varargs, "...");
2768 }
2769}