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 helper.add_node(field_name, Some(span), NodeKind::Property);
1487 }
1488 }
1489
1490 Ok(())
1491}
1492
1493#[allow(clippy::unnecessary_wraps)]
1495fn build_field_access(
1496 access_node: Node<'_>,
1497 content: &[u8],
1498 helper: &mut GraphBuildHelper,
1499) -> GraphResult<()> {
1500 let field_name = match access_node.kind() {
1502 "dot_index_expression" => {
1503 access_node
1505 .child_by_field_name("field")
1506 .and_then(|n| n.utf8_text(content).ok())
1507 .map(|s| s.trim().to_string())
1508 }
1509 "bracket_index_expression" => {
1510 access_node.child_by_field_name("field").and_then(|n| {
1512 if n.kind() == "string" {
1514 n.utf8_text(content)
1515 .ok()
1516 .map(|s| s.trim().trim_matches(|c| c == '"' || c == '\'').to_string())
1517 } else {
1518 n.utf8_text(content).ok().map(|s| s.trim().to_string())
1520 }
1521 })
1522 }
1523 _ => None,
1524 };
1525
1526 if let Some(name) = field_name {
1527 let span = span_from_node(access_node);
1529 helper.add_node(&name, Some(span), NodeKind::Property);
1530 }
1531
1532 Ok(())
1533}
1534
1535#[derive(Debug, Clone)]
1540struct CallContext {
1541 qualified_name: String,
1542 #[allow(dead_code)] span: (usize, usize),
1544 is_method: bool,
1545 module_name: Option<String>,
1547}
1548
1549impl CallContext {
1550 fn qualified_name(&self) -> String {
1551 self.qualified_name.clone()
1552 }
1553}
1554
1555struct ASTGraph {
1556 contexts: Vec<CallContext>,
1557 node_to_context: HashMap<usize, usize>,
1558}
1559
1560impl ASTGraph {
1561 fn from_tree(tree: &Tree, content: &[u8], max_depth: usize) -> Result<Self, String> {
1562 let mut contexts = Vec::new();
1563 let mut node_to_context = HashMap::new();
1564
1565 let mut state = WalkerState::new(&mut contexts, &mut node_to_context, max_depth);
1566
1567 walk_ast(tree.root_node(), content, &mut state)?;
1568
1569 Ok(Self {
1570 contexts,
1571 node_to_context,
1572 })
1573 }
1574
1575 fn contexts(&self) -> &[CallContext] {
1576 &self.contexts
1577 }
1578
1579 fn get_callable_context(&self, node_id: usize) -> Option<&CallContext> {
1580 self.node_to_context
1581 .get(&node_id)
1582 .and_then(|idx| self.contexts.get(*idx))
1583 }
1584}
1585
1586struct WalkerState<'a> {
1588 contexts: &'a mut Vec<CallContext>,
1589 node_to_context: &'a mut HashMap<usize, usize>,
1590 parent_qualified: Option<String>,
1591 module_context: Option<String>,
1592 lexical_depth: usize,
1593 max_depth: usize,
1594}
1595
1596impl<'a> WalkerState<'a> {
1597 fn new(
1598 contexts: &'a mut Vec<CallContext>,
1599 node_to_context: &'a mut HashMap<usize, usize>,
1600 max_depth: usize,
1601 ) -> Self {
1602 Self {
1603 contexts,
1604 node_to_context,
1605 parent_qualified: None,
1606 module_context: None,
1607 lexical_depth: 0,
1608 max_depth,
1609 }
1610 }
1611}
1612
1613fn walk_ast(node: Node, content: &[u8], state: &mut WalkerState) -> Result<(), String> {
1615 if state.lexical_depth > state.max_depth {
1618 return Ok(());
1619 }
1620
1621 match node.kind() {
1622 "local_function" => {
1623 handle_local_function(node, content, state)?;
1625 }
1626 "function_declaration" => {
1627 handle_function_declaration(node, content, state)?;
1628 }
1629 "assignment_statement" => {
1630 handle_function_assignment(node, content, state)?;
1632 }
1633 _ => {
1634 let mut cursor = node.walk();
1636 for child in node.children(&mut cursor) {
1637 walk_ast(child, content, state)?;
1638 }
1639 }
1640 }
1641
1642 Ok(())
1643}
1644
1645fn handle_local_function(
1647 node: Node,
1648 content: &[u8],
1649 state: &mut WalkerState,
1650) -> Result<(), String> {
1651 let name_node = node
1652 .child_by_field_name("name")
1653 .ok_or_else(|| "local_function missing name".to_string())?;
1654
1655 let base_name = name_node
1657 .utf8_text(content)
1658 .map_err(|_| "failed to read local function name".to_string())?
1659 .to_string();
1660
1661 let qualified_name = if let Some(parent) = state.parent_qualified.as_ref() {
1663 format!("{parent}::{base_name}")
1664 } else {
1665 base_name
1666 };
1667
1668 let context_idx = state.contexts.len();
1670 state.contexts.push(CallContext {
1671 qualified_name: qualified_name.clone(),
1672 span: (node.start_byte(), node.end_byte()),
1673 is_method: false, module_name: state.module_context.clone(),
1675 });
1676
1677 map_descendants_to_context(node, state.node_to_context, context_idx);
1679
1680 let saved_parent = state.parent_qualified.clone();
1682 let saved_module_context = state.module_context.clone();
1683
1684 state.parent_qualified = Some(qualified_name);
1686
1687 state.lexical_depth += 1;
1689
1690 #[allow(clippy::cast_possible_truncation)] if let Some(body) = node.named_child(node.named_child_count().saturating_sub(1) as u32) {
1693 walk_ast(body, content, state)?;
1694 }
1695
1696 state.lexical_depth -= 1;
1698 state.parent_qualified = saved_parent;
1699 state.module_context = saved_module_context;
1700
1701 Ok(())
1702}
1703
1704fn handle_function_declaration(
1706 node: Node,
1707 content: &[u8],
1708 state: &mut WalkerState,
1709) -> Result<(), String> {
1710 let name_node = node
1711 .child_by_field_name("name")
1712 .ok_or_else(|| "function_declaration missing name".to_string())?;
1713
1714 let (base_name, is_method) = extract_function_base_name(name_node, content)?;
1716
1717 let qualified_name = if base_name.contains("::") {
1721 base_name.clone()
1722 } else if let Some(parent) = state.parent_qualified.as_ref() {
1723 format!("{parent}::{base_name}")
1724 } else {
1725 base_name.clone()
1726 };
1727 let qualified_name = strip_env_prefix(qualified_name);
1728
1729 let module_name = if is_method {
1732 if qualified_name.contains("::") {
1734 let parts: Vec<&str> = qualified_name.split("::").collect();
1735 if parts.len() > 1 {
1736 Some(parts[..parts.len() - 1].join("::"))
1737 } else {
1738 None
1739 }
1740 } else {
1741 None
1742 }
1743 } else {
1744 state.module_context.clone()
1746 };
1747
1748 let context_idx = state.contexts.len();
1750 state.contexts.push(CallContext {
1751 qualified_name: qualified_name.clone(),
1752 span: (node.start_byte(), node.end_byte()),
1753 is_method,
1754 module_name: module_name.clone(),
1755 });
1756
1757 map_descendants_to_context(node, state.node_to_context, context_idx);
1759
1760 let saved_parent = state.parent_qualified.clone();
1762 let saved_module_context = state.module_context.clone();
1763
1764 state.parent_qualified = Some(qualified_name);
1766
1767 if is_method && module_name.is_some() {
1769 state.module_context = module_name;
1770 }
1771
1772 state.lexical_depth += 1;
1774
1775 #[allow(clippy::cast_possible_truncation)] if let Some(body) = node.named_child(node.named_child_count().saturating_sub(1) as u32) {
1778 walk_ast(body, content, state)?;
1779 }
1780
1781 state.lexical_depth -= 1;
1783 state.parent_qualified = saved_parent;
1784 state.module_context = saved_module_context;
1785
1786 Ok(())
1787}
1788
1789fn handle_function_assignment(
1791 node: Node,
1792 content: &[u8],
1793 state: &mut WalkerState,
1794) -> Result<(), String> {
1795 let Some(expr_list) = node
1797 .children(&mut node.walk())
1798 .find(|child| child.kind() == "expression_list")
1799 else {
1800 return Ok(());
1801 };
1802
1803 let Some(func_def) = expr_list
1804 .named_children(&mut expr_list.walk())
1805 .find(|child| child.kind() == "function_definition")
1806 else {
1807 return Ok(());
1808 };
1809
1810 let Some(var_list) = node
1812 .children(&mut node.walk())
1813 .find(|child| child.kind() == "variable_list")
1814 else {
1815 return Ok(());
1816 };
1817
1818 let Some(var_node) = var_list.named_child(0) else {
1819 return Ok(());
1820 };
1821
1822 let (base_name, is_method) = extract_assignment_base_name(var_node, content)?;
1824
1825 let qualified_name = if base_name.contains("::") {
1828 base_name.clone()
1829 } else if let Some(parent) = state.parent_qualified.as_ref() {
1830 format!("{parent}::{base_name}")
1831 } else {
1832 base_name.clone()
1833 };
1834 let qualified_name = strip_env_prefix(qualified_name);
1835
1836 let module_name = if is_method {
1839 if qualified_name.contains("::") {
1841 let parts: Vec<&str> = qualified_name.split("::").collect();
1842 if parts.len() > 1 {
1843 Some(parts[..parts.len() - 1].join("::"))
1844 } else {
1845 None
1846 }
1847 } else {
1848 None
1849 }
1850 } else {
1851 state.module_context.clone()
1853 };
1854
1855 let context_idx = state.contexts.len();
1857 state.contexts.push(CallContext {
1858 qualified_name: qualified_name.clone(),
1859 span: (func_def.start_byte(), func_def.end_byte()),
1860 is_method,
1861 module_name: module_name.clone(),
1862 });
1863
1864 map_descendants_to_context(func_def, state.node_to_context, context_idx);
1866
1867 let saved_parent = state.parent_qualified.clone();
1869 let saved_module_context = state.module_context.clone();
1870
1871 state.parent_qualified = Some(qualified_name);
1873
1874 if is_method && module_name.is_some() {
1876 state.module_context = module_name;
1877 }
1878
1879 state.lexical_depth += 1;
1881 #[allow(clippy::cast_possible_truncation)] if let Some(body) = func_def.named_child(func_def.named_child_count().saturating_sub(1) as u32)
1884 {
1885 walk_ast(body, content, state)?;
1886 }
1887
1888 state.lexical_depth -= 1;
1890 state.parent_qualified = saved_parent;
1891 state.module_context = saved_module_context;
1892
1893 Ok(())
1894}
1895
1896fn extract_function_base_name(
1899 name_node: Node<'_>,
1900 content: &[u8],
1901) -> Result<(String, bool), String> {
1902 match name_node.kind() {
1903 "identifier" => {
1904 let name = name_node
1906 .utf8_text(content)
1907 .map_err(|_| "failed to read function name".to_string())?;
1908 Ok((name.to_string(), false))
1909 }
1910 "dot_index_expression" => {
1911 let table = name_node
1913 .child_by_field_name("table")
1914 .ok_or_else(|| "dot_index_expression missing table".to_string())?;
1915 let field = name_node
1916 .child_by_field_name("field")
1917 .ok_or_else(|| "dot_index_expression missing field".to_string())?;
1918
1919 let table_text = collect_table_path_simple(table, content)?;
1920 let field_text = field
1921 .utf8_text(content)
1922 .map_err(|_| "failed to read field name".to_string())?;
1923
1924 Ok((format!("{table_text}::{field_text}"), false))
1925 }
1926 "method_index_expression" => {
1927 let table = name_node
1929 .child_by_field_name("table")
1930 .ok_or_else(|| "method_index_expression missing table".to_string())?;
1931 let method = name_node
1932 .child_by_field_name("method")
1933 .ok_or_else(|| "method_index_expression missing method".to_string())?;
1934
1935 let table_text = collect_table_path_simple(table, content)?;
1936 let method_text = method
1937 .utf8_text(content)
1938 .map_err(|_| "failed to read method name".to_string())?;
1939
1940 Ok((format!("{table_text}::{method_text}"), true))
1941 }
1942 "bracket_index_expression" => {
1943 let table = name_node
1945 .child_by_field_name("table")
1946 .ok_or_else(|| "bracket_index_expression missing table".to_string())?;
1947 let field = name_node
1948 .child_by_field_name("field")
1949 .ok_or_else(|| "bracket_index_expression missing field".to_string())?;
1950
1951 let table_text = collect_table_path_simple(table, content)?;
1952 let field_text = normalize_field_value_simple(field, content)?;
1953
1954 Ok((format!("{table_text}::{field_text}"), false))
1955 }
1956 _ => Err(format!(
1957 "unsupported function name kind: {}",
1958 name_node.kind()
1959 )),
1960 }
1961}
1962
1963fn extract_assignment_base_name(
1966 var_node: Node<'_>,
1967 content: &[u8],
1968) -> Result<(String, bool), String> {
1969 match var_node.kind() {
1970 "identifier" => {
1971 let name = var_node
1973 .utf8_text(content)
1974 .map_err(|_| "failed to read identifier".to_string())?;
1975 Ok((name.to_string(), false))
1976 }
1977 "dot_index_expression" => {
1978 let table = var_node
1980 .child_by_field_name("table")
1981 .ok_or_else(|| "dot_index_expression missing table".to_string())?;
1982 let field = var_node
1983 .child_by_field_name("field")
1984 .ok_or_else(|| "dot_index_expression missing field".to_string())?;
1985
1986 let table_text = collect_table_path_simple(table, content)?;
1987 let field_text = field
1988 .utf8_text(content)
1989 .map_err(|_| "failed to read field".to_string())?;
1990
1991 Ok((format!("{table_text}::{field_text}"), false))
1992 }
1993 "method_index_expression" => {
1994 let table = var_node
1996 .child_by_field_name("table")
1997 .ok_or_else(|| "method_index_expression missing table".to_string())?;
1998 let method = var_node
1999 .child_by_field_name("method")
2000 .ok_or_else(|| "method_index_expression missing method".to_string())?;
2001
2002 let table_text = collect_table_path_simple(table, content)?;
2003 let method_text = method
2004 .utf8_text(content)
2005 .map_err(|_| "failed to read method".to_string())?;
2006
2007 Ok((format!("{table_text}::{method_text}"), true))
2008 }
2009 "bracket_index_expression" => {
2010 let table = var_node
2012 .child_by_field_name("table")
2013 .ok_or_else(|| "bracket_index_expression missing table".to_string())?;
2014 let field = var_node
2015 .child_by_field_name("field")
2016 .ok_or_else(|| "bracket_index_expression missing field".to_string())?;
2017
2018 let table_text = collect_table_path_simple(table, content)?;
2019 let field_text = normalize_field_value_simple(field, content)?;
2020
2021 Ok((format!("{table_text}::{field_text}"), false))
2022 }
2023 _ => Err(format!(
2024 "unsupported assignment target kind: {}",
2025 var_node.kind()
2026 )),
2027 }
2028}
2029
2030fn collect_table_path_simple(node: Node<'_>, content: &[u8]) -> Result<String, String> {
2032 match node.kind() {
2033 "identifier" => node
2034 .utf8_text(content)
2035 .map(std::string::ToString::to_string)
2036 .map_err(|_| "failed to read identifier".to_string()),
2037 "bracket_index_expression" => {
2038 let table = node
2039 .child_by_field_name("table")
2040 .ok_or_else(|| "bracket_index_expression missing table".to_string())?;
2041 let field = node
2042 .child_by_field_name("field")
2043 .ok_or_else(|| "bracket_index_expression missing field".to_string())?;
2044
2045 let table_text = collect_table_path_simple(table, content)?;
2046 let field_text = normalize_field_value_simple(field, content)?;
2047
2048 Ok(format!("{table_text}::{field_text}"))
2049 }
2050 "dot_index_expression" => {
2051 let table = node
2052 .child_by_field_name("table")
2053 .ok_or_else(|| "dot_index_expression missing table".to_string())?;
2054 let field = node
2055 .child_by_field_name("field")
2056 .ok_or_else(|| "dot_index_expression missing field".to_string())?;
2057
2058 let table_text = collect_table_path_simple(table, content)?;
2059 let field_text = field
2060 .utf8_text(content)
2061 .map_err(|_| "failed to read field".to_string())?;
2062
2063 Ok(format!("{table_text}::{field_text}"))
2064 }
2065 _ => node
2066 .utf8_text(content)
2067 .map(std::string::ToString::to_string)
2068 .map_err(|_| "failed to read node".to_string()),
2069 }
2070}
2071
2072fn map_descendants_to_context(
2074 node: Node,
2075 node_to_context: &mut HashMap<usize, usize>,
2076 context_idx: usize,
2077) {
2078 node_to_context.insert(node.id(), context_idx);
2079
2080 let mut cursor = node.walk();
2081 for child in node.children(&mut cursor) {
2082 map_descendants_to_context(child, node_to_context, context_idx);
2083 }
2084}
2085
2086#[cfg(test)]
2087mod tests {
2088 use super::*;
2089 use crate::LuaPlugin;
2090 use sqry_core::graph::unified::build::StagingOp;
2091 use sqry_core::graph::unified::build::test_helpers::*;
2092 use sqry_core::graph::unified::edge::EdgeKind as UnifiedEdgeKind;
2093 use sqry_core::plugin::LanguagePlugin;
2094 use std::path::PathBuf;
2095
2096 fn extract_import_edges(staging: &StagingGraph) -> Vec<&UnifiedEdgeKind> {
2098 staging
2099 .operations()
2100 .iter()
2101 .filter_map(|op| {
2102 if let StagingOp::AddEdge { kind, .. } = op
2103 && matches!(kind, UnifiedEdgeKind::Imports { .. })
2104 {
2105 return Some(kind);
2106 }
2107 None
2108 })
2109 .collect()
2110 }
2111
2112 fn parse_lua(source: &str) -> Tree {
2113 let plugin = LuaPlugin::default();
2114 plugin.parse_ast(source.as_bytes()).unwrap()
2115 }
2116
2117 #[test]
2118 fn test_extracts_global_functions() {
2119 let source = r"
2120 function foo()
2121 end
2122
2123 function bar()
2124 end
2125 ";
2126
2127 let tree = parse_lua(source);
2128 let mut staging = StagingGraph::new();
2129 let builder = LuaGraphBuilder::default();
2130 let file = PathBuf::from("test.lua");
2131
2132 builder
2133 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2134 .unwrap();
2135
2136 assert!(staging.node_count() >= 2);
2137 assert_has_node(&staging, "foo");
2138 assert_has_node(&staging, "bar");
2139 }
2140
2141 #[test]
2142 fn test_creates_call_edges() {
2143 let source = r"
2144 function caller()
2145 callee()
2146 end
2147
2148 function callee()
2149 end
2150 ";
2151
2152 let tree = parse_lua(source);
2153 let mut staging = StagingGraph::new();
2154 let builder = LuaGraphBuilder::default();
2155 let file = PathBuf::from("test.lua");
2156
2157 builder
2158 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2159 .unwrap();
2160
2161 assert_has_node(&staging, "caller");
2162 assert_has_node(&staging, "callee");
2163
2164 let calls = collect_call_edges(&staging);
2165 assert!(!calls.is_empty(), "Expected at least one call edge");
2166 }
2167
2168 #[test]
2169 fn test_handles_module_methods() {
2170 let source = r"
2171 local MyModule = {}
2172
2173 function MyModule.method1()
2174 MyModule.method2()
2175 end
2176
2177 function MyModule:method2()
2178 end
2179 ";
2180
2181 let tree = parse_lua(source);
2182 let mut staging = StagingGraph::new();
2183 let builder = LuaGraphBuilder::default();
2184 let file = PathBuf::from("test.lua");
2185
2186 builder
2187 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2188 .unwrap();
2189
2190 assert_has_node(&staging, "method1");
2191 assert_has_node(&staging, "method2");
2192 assert_has_call_edge(&staging, "MyModule::method1", "MyModule::method2");
2193 }
2194
2195 #[test]
2196 fn test_nested_functions_scope_resolution() {
2197 let source = r"
2200 function outer()
2201 local function inner()
2202 local function deep()
2203 end
2204 end
2205 end
2206 ";
2207
2208 let tree = parse_lua(source);
2209 let mut staging = StagingGraph::new();
2210 let builder = LuaGraphBuilder::default();
2211 let file = PathBuf::from("test.lua");
2212
2213 builder
2214 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2215 .unwrap();
2216
2217 assert_has_node(&staging, "outer");
2219 assert_has_node(&staging, "outer::inner");
2220 assert_has_node(&staging, "outer::inner::deep");
2221
2222 }
2226
2227 #[test]
2228 fn test_self_resolution_in_methods() {
2229 let source = r"
2232 local MyModule = {}
2233
2234 function MyModule:method1()
2235 self:method2()
2236 end
2237
2238 function MyModule:method2()
2239 self:method3()
2240 end
2241
2242 function MyModule:method3()
2243 -- Empty
2244 end
2245 ";
2246
2247 let tree = parse_lua(source);
2248 let mut staging = StagingGraph::new();
2249 let builder = LuaGraphBuilder::default();
2250 let file = PathBuf::from("test.lua");
2251
2252 builder
2253 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2254 .unwrap();
2255
2256 assert_has_node(&staging, "MyModule::method1");
2258 assert_has_node(&staging, "MyModule::method2");
2259 assert_has_node(&staging, "MyModule::method3");
2260
2261 assert_has_call_edge(&staging, "MyModule::method1", "MyModule::method2");
2263
2264 assert_has_call_edge(&staging, "MyModule::method2", "MyModule::method3");
2266 }
2267
2268 #[test]
2269 fn test_local_function_call_resolution() {
2270 let source = r"
2274function outer()
2275 local function inner()
2276 local function deep()
2277 end
2278 deep()
2279 end
2280 inner()
2281end
2282";
2283
2284 let tree = parse_lua(source);
2285 let mut staging = StagingGraph::new();
2286 let builder = LuaGraphBuilder::default();
2287 let file = PathBuf::from("test.lua");
2288
2289 builder
2290 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2291 .unwrap();
2292
2293 assert_has_node(&staging, "outer");
2295 assert_has_node(&staging, "outer::inner");
2296 assert_has_node(&staging, "outer::inner::deep");
2297
2298 assert_has_call_edge(&staging, "outer", "outer::inner");
2300
2301 assert_has_call_edge(&staging, "outer::inner", "outer::inner::deep");
2303 }
2304
2305 #[test]
2306 fn test_nested_helper_self_resolution() {
2307 let source = r"
2311MyModule = {}
2312
2313function MyModule:outer()
2314 local function helper()
2315 self:inner()
2316 end
2317 helper()
2318end
2319
2320function MyModule:inner()
2321end
2322";
2323
2324 let tree = parse_lua(source);
2325 let mut staging = StagingGraph::new();
2326 let builder = LuaGraphBuilder::default();
2327 let file = PathBuf::from("test.lua");
2328
2329 builder
2330 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2331 .unwrap();
2332
2333 assert_has_node(&staging, "MyModule::outer");
2335 assert_has_node(&staging, "MyModule::outer::helper");
2336 assert_has_node(&staging, "MyModule::inner");
2337
2338 assert_has_call_edge(&staging, "MyModule::outer::helper", "MyModule::inner");
2340
2341 assert_has_call_edge(&staging, "MyModule::outer", "MyModule::outer::helper");
2343 }
2344
2345 #[test]
2346 fn test_bracket_string_key_assignment() {
2347 let source = r#"
2348 local Module = {}
2349
2350 Module["string-key"] = function()
2351 return true
2352 end
2353
2354 local function call_it()
2355 Module["string-key"]()
2356 end
2357 "#;
2358
2359 let tree = parse_lua(source);
2360 let mut staging = StagingGraph::new();
2361 let builder = LuaGraphBuilder::default();
2362 let file = PathBuf::from("test.lua");
2363
2364 builder
2365 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2366 .unwrap();
2367
2368 assert_has_node(&staging, "Module::string-key");
2370 assert_has_node(&staging, "call_it");
2371
2372 assert_has_call_edge(&staging, "call_it", "Module::string-key");
2374 }
2375
2376 #[test]
2377 fn test_numeric_command_table_assignment() {
2378 let source = r#"
2379 local commands = {}
2380
2381 commands[1] = function()
2382 return "cmd"
2383 end
2384
2385 local function run()
2386 commands[1]()
2387 end
2388 "#;
2389
2390 let tree = parse_lua(source);
2391 let mut staging = StagingGraph::new();
2392 let builder = LuaGraphBuilder::default();
2393 let file = PathBuf::from("test.lua");
2394
2395 builder
2396 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2397 .unwrap();
2398
2399 assert_has_node(&staging, "commands::1");
2401 assert_has_node(&staging, "run");
2402
2403 assert_has_call_edge(&staging, "run", "commands::1");
2405 }
2406
2407 #[test]
2408 fn test_env_driven_name_injection() {
2409 let source = r#"
2410 _ENV["init"] = function()
2411 return true
2412 end
2413
2414 local function boot()
2415 init()
2416 end
2417 "#;
2418
2419 let tree = parse_lua(source);
2420 let mut staging = StagingGraph::new();
2421 let builder = LuaGraphBuilder::default();
2422 let file = PathBuf::from("test.lua");
2423
2424 builder
2425 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2426 .unwrap();
2427
2428 assert_has_node(&staging, "init");
2430 assert_has_node(&staging, "boot");
2431
2432 assert_has_call_edge(&staging, "boot", "init");
2434 }
2435
2436 #[test]
2437 fn test_deep_namespace_lexical_depth() {
2438 let source = r"
2441Company = {}
2442Company.Product = {}
2443Company.Product.Component = {}
2444
2445function Company.Product.Component:outer()
2446 local function helper1()
2447 local function helper2()
2448 self:inner()
2449 end
2450 helper2()
2451 end
2452 helper1()
2453end
2454
2455function Company.Product.Component:inner()
2456end
2457";
2458
2459 let tree = parse_lua(source);
2460 let mut staging = StagingGraph::new();
2461 let builder = LuaGraphBuilder::default(); let file = PathBuf::from("test.lua");
2463
2464 builder
2465 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2466 .unwrap();
2467
2468 assert_has_node(&staging, "Company::Product::Component::outer");
2470 assert_has_node(&staging, "Company::Product::Component::outer::helper1");
2471 assert_has_node(
2472 &staging,
2473 "Company::Product::Component::outer::helper1::helper2",
2474 );
2475 assert_has_node(&staging, "Company::Product::Component::inner");
2476
2477 assert_has_call_edge(
2480 &staging,
2481 "Company::Product::Component::outer::helper1::helper2",
2482 "Company::Product::Component::inner",
2483 );
2484
2485 assert_has_call_edge(
2487 &staging,
2488 "Company::Product::Component::outer",
2489 "Company::Product::Component::outer::helper1",
2490 );
2491 assert_has_call_edge(
2492 &staging,
2493 "Company::Product::Component::outer::helper1",
2494 "Company::Product::Component::outer::helper1::helper2",
2495 );
2496 }
2497
2498 #[test]
2499 fn test_nested_method_redefinition() {
2500 let source = r"
2504MyModule = {}
2505
2506function MyModule:outer()
2507 function MyModule:inner()
2508 -- redefined inside outer
2509 end
2510 self:inner()
2511end
2512";
2513
2514 let tree = parse_lua(source);
2515 let mut staging = StagingGraph::new();
2516 let builder = LuaGraphBuilder::default();
2517 let file = PathBuf::from("test.lua");
2518
2519 builder
2520 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2521 .unwrap();
2522
2523 assert_has_node(&staging, "MyModule::outer");
2525 assert_has_node(&staging, "MyModule::inner");
2526
2527 assert_has_call_edge(&staging, "MyModule::outer", "MyModule::inner");
2529 }
2530
2531 #[test]
2536 fn test_require_import_edge_double_quotes() {
2537 let source = r#"
2538 local json = require("cjson")
2539 "#;
2540
2541 let tree = parse_lua(source);
2542 let mut staging = StagingGraph::new();
2543 let builder = LuaGraphBuilder::default();
2544 let file = PathBuf::from("test.lua");
2545
2546 builder
2547 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2548 .unwrap();
2549
2550 let import_edges = extract_import_edges(&staging);
2551 assert!(
2552 !import_edges.is_empty(),
2553 "Expected at least one import edge"
2554 );
2555
2556 let edge = import_edges[0];
2558 if let UnifiedEdgeKind::Imports { is_wildcard, .. } = edge {
2559 assert!(
2560 !*is_wildcard,
2561 "Lua require returns module reference, not wildcard"
2562 );
2563 } else {
2564 panic!("Expected Imports edge kind");
2565 }
2566 }
2567
2568 #[test]
2569 fn test_require_import_edge_single_quotes() {
2570 let source = r"
2571 local socket = require('socket')
2572 ";
2573
2574 let tree = parse_lua(source);
2575 let mut staging = StagingGraph::new();
2576 let builder = LuaGraphBuilder::default();
2577 let file = PathBuf::from("test.lua");
2578
2579 builder
2580 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2581 .unwrap();
2582
2583 let import_edges = extract_import_edges(&staging);
2584 assert!(!import_edges.is_empty(), "Expected require import edge");
2585
2586 let edge = import_edges[0];
2588 if let UnifiedEdgeKind::Imports { is_wildcard, .. } = edge {
2589 assert!(
2590 !*is_wildcard,
2591 "Lua require returns module reference, not wildcard"
2592 );
2593 } else {
2594 panic!("Expected Imports edge kind");
2595 }
2596 }
2597
2598 #[test]
2599 fn test_require_dotted_module() {
2600 let source = r#"
2601 local util = require("luasocket.util")
2602 "#;
2603
2604 let tree = parse_lua(source);
2605 let mut staging = StagingGraph::new();
2606 let builder = LuaGraphBuilder::default();
2607 let file = PathBuf::from("test.lua");
2608
2609 builder
2610 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2611 .unwrap();
2612
2613 let import_edges = extract_import_edges(&staging);
2614 assert!(
2615 !import_edges.is_empty(),
2616 "Expected dotted module import edge"
2617 );
2618
2619 let edge = import_edges[0];
2621 assert!(
2622 matches!(edge, UnifiedEdgeKind::Imports { .. }),
2623 "Expected Imports edge kind"
2624 );
2625 }
2626
2627 #[test]
2628 fn test_multiple_requires() {
2629 let source = r#"
2630 local json = require("cjson")
2631 local socket = require("socket")
2632 local lpeg = require("lpeg")
2633 local lfs = require("lfs")
2634 "#;
2635
2636 let tree = parse_lua(source);
2637 let mut staging = StagingGraph::new();
2638 let builder = LuaGraphBuilder::default();
2639 let file = PathBuf::from("test.lua");
2640
2641 builder
2642 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2643 .unwrap();
2644
2645 let import_edges = extract_import_edges(&staging);
2646 assert_eq!(import_edges.len(), 4, "Expected 4 import edges");
2647
2648 for edge in &import_edges {
2650 assert!(
2651 matches!(edge, UnifiedEdgeKind::Imports { .. }),
2652 "All edges should be Imports"
2653 );
2654 }
2655 }
2656}
2657
2658#[cfg(test)]
2659mod shape_tests {
2660 use super::{cf_bucket_for_lua_kind, lua_shape_mapping};
2664 use sqry_core::graph::unified::build::shape::{
2665 CfBucket, ShapeBudget, ShapeMapping, compute_shape_descriptor,
2666 };
2667 use tree_sitter::{Node, Parser, Tree};
2668
2669 const SAMPLE: &str = include_str!(concat!(
2670 env!("CARGO_MANIFEST_DIR"),
2671 "/../test-fixtures/shape/dynamic/lua.lua"
2672 ));
2673
2674 fn parse(src: &str) -> Tree {
2675 let mut parser = Parser::new();
2676 parser
2677 .set_language(&tree_sitter_lua::LANGUAGE.into())
2678 .expect("load lua grammar");
2679 parser.parse(src, None).expect("parse lua")
2680 }
2681
2682 fn first_function<'t>(tree: &'t Tree) -> Node<'t> {
2683 let root = tree.root_node();
2684 let mut cursor = root.walk();
2685 for child in root.named_children(&mut cursor) {
2686 if child.kind() == "function_declaration" {
2687 return child;
2688 }
2689 }
2690 panic!("no function_declaration in lua fixture");
2691 }
2692
2693 #[test]
2694 fn mapping_is_non_empty_and_covers_real_kinds() {
2695 assert_eq!(
2696 cf_bucket_for_lua_kind("if_statement"),
2697 Some(CfBucket::Branch)
2698 );
2699 assert_eq!(
2700 cf_bucket_for_lua_kind("while_statement"),
2701 Some(CfBucket::Loop)
2702 );
2703 assert_eq!(
2704 cf_bucket_for_lua_kind("repeat_statement"),
2705 Some(CfBucket::Loop)
2706 );
2707 assert_eq!(
2708 cf_bucket_for_lua_kind("break_statement"),
2709 Some(CfBucket::BreakContinue)
2710 );
2711 assert_eq!(
2712 cf_bucket_for_lua_kind("return_statement"),
2713 Some(CfBucket::Return)
2714 );
2715 assert_eq!(
2716 cf_bucket_for_lua_kind("function_call"),
2717 Some(CfBucket::Call)
2718 );
2719 assert_eq!(
2720 cf_bucket_for_lua_kind("function_definition"),
2721 Some(CfBucket::Closure)
2722 );
2723 assert_eq!(cf_bucket_for_lua_kind("nope"), None);
2724
2725 let lang: tree_sitter::Language = tree_sitter_lua::LANGUAGE.into();
2726 let id = (0..lang.node_kind_count())
2727 .map(|i| i as u16)
2728 .find(|&i| {
2729 lang.node_kind_is_named(i) && lang.node_kind_for_id(i) == Some("if_statement")
2730 })
2731 .expect("grammar exposes named if_statement");
2732 assert_eq!(lua_shape_mapping().cf_bucket(id), Some(CfBucket::Branch));
2733 }
2734
2735 #[test]
2736 fn descriptor_covers_fixture_control_flow() {
2737 let tree = parse(SAMPLE);
2738 let func = first_function(&tree);
2739 let descriptor = compute_shape_descriptor(
2740 func,
2741 SAMPLE.as_bytes(),
2742 lua_shape_mapping(),
2743 &ShapeBudget::default(),
2744 );
2745 let hist = descriptor.cf_histogram;
2746 assert!(hist[CfBucket::Branch.index()] >= 1, "branch (if)");
2747 assert!(hist[CfBucket::Loop.index()] >= 1, "loop (while/for/repeat)");
2748 assert!(hist[CfBucket::BreakContinue.index()] >= 1, "break");
2749 assert!(hist[CfBucket::Return.index()] >= 1, "return");
2750 assert!(hist[CfBucket::Call.index()] >= 1, "call");
2751 assert!(hist[CfBucket::Assign.index()] >= 1, "assignment");
2752 assert!(
2753 hist[CfBucket::Closure.index()] >= 1,
2754 "nested function literal"
2755 );
2756 }
2757
2758 #[test]
2759 fn signature_shape_reads_arity_and_varargs() {
2760 let tree = parse(SAMPLE);
2761 let func = first_function(&tree);
2762 let shape = lua_shape_mapping().signature_shape(func, SAMPLE.as_bytes());
2763 assert_eq!(shape.arity_positional, 2, "value + label");
2765 assert!(shape.has_varargs, "...");
2766 }
2767}