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
709fn extract_ffi_assignment(node: Node, content: &[u8], aliases: &mut FfiAliasTable) {
711 let assignment = if node.kind() == "variable_declaration" {
715 let mut cursor = node.walk();
717 node.children(&mut cursor)
718 .find(|c| c.kind() == "assignment_statement")
719 } else if node.kind() == "assignment_statement" {
720 Some(node)
721 } else {
722 None
723 };
724
725 if let Some(assign_node) = assignment {
726 extract_from_assignment(assign_node, content, aliases);
727 }
728}
729
730fn extract_from_assignment(assign_node: Node, content: &[u8], aliases: &mut FfiAliasTable) {
732 let mut cursor = assign_node.walk();
734 let children: Vec<_> = assign_node.children(&mut cursor).collect();
735
736 let Some(var_list) = children.iter().find(|c| c.kind() == "variable_list") else {
737 return;
738 };
739 let Some(expr_list) = children.iter().find(|c| c.kind() == "expression_list") else {
740 return;
741 };
742
743 let Some(var_name_node) = var_list.named_child(0) else {
745 return;
746 };
747 let Ok(var_name) = var_name_node.utf8_text(content) else {
748 return;
749 };
750 let var_name = var_name.trim().to_string();
751
752 let Some(value_node) = expr_list.named_child(0) else {
754 return;
755 };
756
757 if is_require_ffi_call(value_node, content) {
759 aliases.insert(var_name, FfiEntity::Module);
760 } else if is_ffi_c_reference(value_node, content, aliases) {
761 aliases.insert(var_name, FfiEntity::CLibrary);
762 } else if let Some(lib_name) = extract_ffi_load_library(value_node, content, aliases) {
763 aliases.insert(var_name, FfiEntity::LoadedLibrary(lib_name));
764 }
765 else if value_node.kind() == "identifier"
767 && let Ok(alias_name) = value_node.utf8_text(content)
768 {
769 let alias_name = alias_name.trim();
770 if let Some(entity) = aliases.get(alias_name).cloned() {
771 aliases.insert(var_name, entity);
773 }
774 }
775}
776
777fn is_require_ffi_call(node: Node, content: &[u8]) -> bool {
779 if node.kind() != "function_call" {
780 return false;
781 }
782
783 let Some(name_node) = node.child_by_field_name("name") else {
785 return false;
786 };
787 let Ok(name_text) = name_node.utf8_text(content) else {
788 return false;
789 };
790 if name_text.trim() != "require" {
791 return false;
792 }
793
794 let Some(args_node) = node.child_by_field_name("arguments") else {
796 return false;
797 };
798 let Some(first_arg) = args_node.named_child(0) else {
799 return false;
800 };
801
802 if let Some(content_str) = extract_string_content(first_arg, content) {
804 return content_str == "ffi";
805 }
806
807 false
808}
809
810fn is_ffi_c_reference(node: Node, content: &[u8], aliases: &FfiAliasTable) -> bool {
812 if node.kind() != "dot_index_expression" {
813 return false;
814 }
815
816 let Some(table_node) = node.child_by_field_name("table") else {
817 return false;
818 };
819 let Some(field_node) = node.child_by_field_name("field") else {
820 return false;
821 };
822
823 let Ok(table_text) = table_node.utf8_text(content) else {
824 return false;
825 };
826 let Ok(field_text) = field_node.utf8_text(content) else {
827 return false;
828 };
829
830 let table_text = table_text.trim();
831 let field_text = field_text.trim();
832
833 aliases.get(table_text) == Some(&FfiEntity::Module) && field_text == "C"
835}
836
837fn extract_ffi_load_library(node: Node, content: &[u8], aliases: &FfiAliasTable) -> Option<String> {
839 if node.kind() != "function_call" {
840 return None;
841 }
842
843 let name_node = node.child_by_field_name("name")?;
845 if name_node.kind() != "dot_index_expression" {
846 return None;
847 }
848
849 let table_node = name_node.child_by_field_name("table")?;
850 let field_node = name_node.child_by_field_name("field")?;
851
852 let table_text = table_node.utf8_text(content).ok()?;
853 let field_text = field_node.utf8_text(content).ok()?;
854
855 let table_text = table_text.trim();
856 let field_text = field_text.trim();
857
858 if aliases.get(table_text) != Some(&FfiEntity::Module) || field_text != "load" {
860 return None;
861 }
862
863 let args_node = node.child_by_field_name("arguments")?;
865 let first_arg = args_node.named_child(0)?;
866
867 extract_string_content(first_arg, content)
869}
870
871fn extract_string_content(string_node: Node, content: &[u8]) -> Option<String> {
873 if string_node.kind() == "string" {
874 let mut cursor = string_node.walk();
876 for child in string_node.children(&mut cursor) {
877 if child.kind() == "string_content"
878 && let Ok(text) = child.utf8_text(content)
879 {
880 return Some(text.trim().to_string());
881 }
882 }
883
884 if let Ok(text) = string_node.utf8_text(content) {
886 let trimmed = text
887 .trim()
888 .trim_start_matches(['"', '\'', '['])
889 .trim_end_matches(['"', '\'', ']'])
890 .to_string();
891 if !trimmed.is_empty() {
892 return Some(trimmed);
893 }
894 }
895 }
896 None
897}
898
899fn extract_ffi_call_info(
901 call_node: Node,
902 content: &[u8],
903 aliases: &FfiAliasTable,
904) -> Option<FfiCallInfo> {
905 let name_node = call_node.child_by_field_name("name")?;
906
907 match name_node.kind() {
909 "dot_index_expression" => {
910 if is_ffi_load_call(name_node, content, aliases) {
912 return extract_ffi_load_call_info(call_node, content);
913 }
914 extract_ffi_from_dot_expression(name_node, content, aliases)
916 }
917 _ => None,
918 }
919}
920
921fn is_ffi_load_call(dot_expr: Node, content: &[u8], aliases: &FfiAliasTable) -> bool {
923 let Some(table_node) = dot_expr.child_by_field_name("table") else {
924 return false;
925 };
926 let Some(field_node) = dot_expr.child_by_field_name("field") else {
927 return false;
928 };
929
930 let Ok(table_text) = table_node.utf8_text(content) else {
931 return false;
932 };
933 let Ok(field_text) = field_node.utf8_text(content) else {
934 return false;
935 };
936
937 aliases.get(table_text.trim()) == Some(&FfiEntity::Module) && field_text.trim() == "load"
938}
939
940fn extract_ffi_load_call_info(call_node: Node, content: &[u8]) -> Option<FfiCallInfo> {
942 let args_node = call_node.child_by_field_name("arguments")?;
943 let first_arg = args_node.named_child(0)?;
944
945 let lib_name = extract_string_content(first_arg, content)?;
946
947 Some(FfiCallInfo {
950 function_name: lib_name,
951 library_name: None,
952 })
953}
954
955fn extract_ffi_from_dot_expression(
957 dot_expr: Node,
958 content: &[u8],
959 aliases: &FfiAliasTable,
960) -> Option<FfiCallInfo> {
961 let table_node = dot_expr.child_by_field_name("table")?;
962 let field_node = dot_expr.child_by_field_name("field")?;
963
964 let function_name = field_node.utf8_text(content).ok()?.trim().to_string();
965
966 if table_node.kind() == "dot_index_expression" {
968 let inner_table = table_node.child_by_field_name("table")?;
969 let inner_field = table_node.child_by_field_name("field")?;
970
971 let base_text = inner_table.utf8_text(content).ok()?.trim();
972 let mid_text = inner_field.utf8_text(content).ok()?.trim();
973
974 if aliases.get(base_text) == Some(&FfiEntity::Module) && mid_text == "C" {
976 return Some(FfiCallInfo {
977 function_name,
978 library_name: None,
979 });
980 }
981 } else {
982 let table_text = table_node.utf8_text(content).ok()?.trim();
984
985 match aliases.get(table_text) {
986 Some(FfiEntity::CLibrary) => {
987 return Some(FfiCallInfo {
988 function_name,
989 library_name: None,
990 });
991 }
992 Some(FfiEntity::LoadedLibrary(lib_name)) => {
993 return Some(FfiCallInfo {
994 function_name,
995 library_name: Some(lib_name.clone()),
996 });
997 }
998 _ => {}
999 }
1000 }
1001
1002 None
1003}
1004
1005fn emit_ffi_edge(
1007 ffi_info: FfiCallInfo,
1008 call_node: Node,
1009 content: &[u8],
1010 ast_graph: &ASTGraph,
1011 helper: &mut GraphBuildHelper,
1012) {
1013 let caller_id = get_ffi_caller_node_id(call_node, content, ast_graph, helper);
1015
1016 let target_name = if let Some(lib) = ffi_info.library_name {
1018 format!("native::{}::{}", lib, ffi_info.function_name)
1019 } else {
1020 format!("native::{}", ffi_info.function_name)
1021 };
1022
1023 let target_id = helper.ensure_callee(
1025 &target_name,
1026 span_from_node(call_node),
1027 CalleeKindHint::Function,
1028 );
1029
1030 helper.add_ffi_edge(caller_id, target_id, FfiConvention::C);
1032}
1033
1034fn get_ffi_caller_node_id(
1036 call_node: Node,
1037 content: &[u8],
1038 ast_graph: &ASTGraph,
1039 helper: &mut GraphBuildHelper,
1040) -> sqry_core::graph::unified::node::NodeId {
1041 let call_span = span_from_node(call_node);
1042 if let Some(call_context) = ast_graph.get_callable_context(call_node.id()) {
1044 if call_context.is_method {
1045 return helper.ensure_method(&call_context.qualified_name, None, false, false);
1046 }
1047 return helper.ensure_callee(
1048 &call_context.qualified_name,
1049 call_span,
1050 CalleeKindHint::Function,
1051 );
1052 }
1053
1054 let mut current = call_node.parent();
1056 while let Some(node) = current {
1057 if node.kind() == "function_declaration" || node.kind() == "function_definition" {
1058 if let Some(name_node) = node.child_by_field_name("name")
1060 && let Ok(name_text) = name_node.utf8_text(content)
1061 {
1062 return helper.ensure_callee(
1063 name_text,
1064 span_from_node(node),
1065 CalleeKindHint::Function,
1066 );
1067 }
1068 }
1069 current = node.parent();
1070 }
1071
1072 helper.ensure_callee("<file_level>", call_span, CalleeKindHint::Function)
1074}
1075
1076fn build_call_for_staging(
1078 ast_graph: &ASTGraph,
1079 call_node: Node<'_>,
1080 content: &[u8],
1081) -> GraphResult<Option<(String, String, usize, Span)>> {
1082 let module_context;
1084 let call_context = if let Some(ctx) = ast_graph.get_callable_context(call_node.id()) {
1085 ctx
1086 } else {
1087 module_context = CallContext {
1089 qualified_name: "<module>".to_string(),
1090 is_method: false,
1091 module_name: None,
1092 };
1093 &module_context
1094 };
1095
1096 let Some(name_node) = call_node.child_by_field_name("name") else {
1098 return Ok(None);
1099 };
1100
1101 let callee_text = name_node
1102 .utf8_text(content)
1103 .map_err(|_| GraphBuilderError::ParseError {
1104 span: span_from_node(call_node),
1105 reason: "failed to read call expression".to_string(),
1106 })?
1107 .trim()
1108 .to_string();
1109
1110 if callee_text.is_empty() {
1111 return Ok(None);
1112 }
1113
1114 let mut target_qualified = extract_call_target(name_node, content, call_context)?;
1116
1117 if !target_qualified.contains("::") {
1119 let scoped_name = if call_context.qualified_name == "<module>" {
1121 target_qualified.clone()
1122 } else {
1123 format!("{}::{}", call_context.qualified_name, &target_qualified)
1124 };
1125
1126 if ast_graph
1128 .contexts()
1129 .iter()
1130 .any(|ctx| ctx.qualified_name == scoped_name)
1131 {
1132 target_qualified = scoped_name;
1133 }
1134 else if let Some(parent_scope) = extract_parent_scope(&call_context.qualified_name) {
1136 let sibling_name = format!("{}::{}", parent_scope, &target_qualified);
1137 if ast_graph
1138 .contexts()
1139 .iter()
1140 .any(|ctx| ctx.qualified_name == sibling_name)
1141 {
1142 target_qualified = sibling_name;
1143 }
1144 }
1145 }
1146
1147 let source_qualified = call_context.qualified_name();
1148
1149 let span = span_from_node(call_node);
1150 let argument_count = count_arguments(call_node);
1151
1152 Ok(Some((
1153 source_qualified,
1154 target_qualified,
1155 argument_count,
1156 span,
1157 )))
1158}
1159
1160fn extract_call_target(
1166 name_node: Node<'_>,
1167 content: &[u8],
1168 call_context: &CallContext,
1169) -> GraphResult<String> {
1170 match name_node.kind() {
1171 "identifier" => {
1172 get_node_text(name_node, content)
1175 }
1176 "dot_index_expression" => {
1177 flatten_dotted_name(name_node, content)
1179 }
1180 "method_index_expression" => {
1181 flatten_method_name(name_node, content, call_context)
1183 }
1184 "bracket_index_expression" => {
1185 flatten_bracket_name(name_node, content, call_context)
1187 }
1188 "function_call" => {
1189 get_node_text(name_node, content)
1192 }
1193 _ => get_node_text(name_node, content),
1194 }
1195}
1196
1197fn flatten_dotted_name(node: Node<'_>, content: &[u8]) -> GraphResult<String> {
1199 let table = node
1200 .child_by_field_name("table")
1201 .ok_or_else(|| GraphBuilderError::ParseError {
1202 span: span_from_node(node),
1203 reason: "dot_index_expression missing table".to_string(),
1204 })?;
1205 let field = node
1206 .child_by_field_name("field")
1207 .ok_or_else(|| GraphBuilderError::ParseError {
1208 span: span_from_node(node),
1209 reason: "dot_index_expression missing field".to_string(),
1210 })?;
1211
1212 let table_text = collect_table_path(table, content)?;
1213 let field_text = get_node_text(field, content)?;
1214
1215 Ok(format!("{table_text}::{field_text}"))
1216}
1217
1218fn flatten_method_name(
1221 node: Node<'_>,
1222 content: &[u8],
1223 call_context: &CallContext,
1224) -> GraphResult<String> {
1225 let table = node
1226 .child_by_field_name("table")
1227 .ok_or_else(|| GraphBuilderError::ParseError {
1228 span: span_from_node(node),
1229 reason: "method_index_expression missing table".to_string(),
1230 })?;
1231 let method =
1232 node.child_by_field_name("method")
1233 .ok_or_else(|| GraphBuilderError::ParseError {
1234 span: span_from_node(node),
1235 reason: "method_index_expression missing method".to_string(),
1236 })?;
1237
1238 let mut table_text = collect_table_path(table, content)?;
1239 let method_text = get_node_text(method, content)?;
1240
1241 if table_text == "self"
1243 && let Some(ref module_name) = call_context.module_name
1244 {
1245 table_text.clone_from(module_name);
1246 }
1247 Ok(format!("{table_text}::{method_text}"))
1250}
1251
1252fn flatten_bracket_name(
1254 node: Node<'_>,
1255 content: &[u8],
1256 call_context: &CallContext,
1257) -> GraphResult<String> {
1258 let table = node
1259 .child_by_field_name("table")
1260 .ok_or_else(|| GraphBuilderError::ParseError {
1261 span: span_from_node(node),
1262 reason: "bracket_index_expression missing table".to_string(),
1263 })?;
1264 let field = node
1265 .child_by_field_name("field")
1266 .ok_or_else(|| GraphBuilderError::ParseError {
1267 span: span_from_node(node),
1268 reason: "bracket_index_expression missing field".to_string(),
1269 })?;
1270
1271 let mut table_text = collect_table_path(table, content)?;
1272 if table_text == "self"
1273 && let Some(ref module_name) = call_context.module_name
1274 {
1275 table_text.clone_from(module_name);
1276 }
1277
1278 let field_text = normalize_field_value(field, content)?;
1279
1280 Ok(format!("{table_text}::{field_text}"))
1281}
1282
1283fn collect_table_path(node: Node<'_>, content: &[u8]) -> GraphResult<String> {
1285 match node.kind() {
1286 "bracket_index_expression" => {
1287 let table =
1288 node.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 =
1294 node.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 table_text = collect_table_path(table, content)?;
1301 let field_text = normalize_field_value(field, content)?;
1302
1303 Ok(format!("{table_text}::{field_text}"))
1304 }
1305 "dot_index_expression" => {
1306 let table =
1307 node.child_by_field_name("table")
1308 .ok_or_else(|| GraphBuilderError::ParseError {
1309 span: span_from_node(node),
1310 reason: "nested dot_index_expression missing table".to_string(),
1311 })?;
1312 let field =
1313 node.child_by_field_name("field")
1314 .ok_or_else(|| GraphBuilderError::ParseError {
1315 span: span_from_node(node),
1316 reason: "nested dot_index_expression missing field".to_string(),
1317 })?;
1318
1319 let table_text = collect_table_path(table, content)?;
1320 let field_text = get_node_text(field, content)?;
1321
1322 Ok(format!("{table_text}::{field_text}"))
1323 }
1324 _ => get_node_text(node, content),
1325 }
1326}
1327
1328fn get_node_text(node: Node<'_>, content: &[u8]) -> GraphResult<String> {
1330 node.utf8_text(content)
1331 .map(|text| text.trim().to_string())
1332 .map_err(|_| GraphBuilderError::ParseError {
1333 span: span_from_node(node),
1334 reason: "failed to read node text".to_string(),
1335 })
1336}
1337
1338fn span_from_node(node: Node<'_>) -> Span {
1340 let start = node.start_position();
1341 let end = node.end_position();
1342 Span::new(
1343 Position::new(start.row, start.column),
1344 Position::new(end.row, end.column),
1345 )
1346}
1347
1348fn count_arguments(call_node: Node<'_>) -> usize {
1350 call_node
1351 .child_by_field_name("arguments")
1352 .map_or(0, |args| {
1353 args.named_children(&mut args.walk())
1354 .filter(|child| !matches!(child.kind(), "," | "(" | ")"))
1355 .count()
1356 })
1357}
1358
1359fn extract_parent_scope(qualified_name: &str) -> Option<String> {
1363 let parts: Vec<&str> = qualified_name.split("::").collect();
1364 if parts.len() > 1 {
1365 Some(parts[..parts.len() - 1].join("::"))
1366 } else {
1367 None
1368 }
1369}
1370
1371fn normalize_field_value(node: Node<'_>, content: &[u8]) -> GraphResult<String> {
1372 normalize_field_value_simple(node, content).map_err(|reason| GraphBuilderError::ParseError {
1373 span: span_from_node(node),
1374 reason,
1375 })
1376}
1377
1378fn normalize_field_value_simple(node: Node<'_>, content: &[u8]) -> Result<String, String> {
1379 let raw = node
1380 .utf8_text(content)
1381 .map_err(|_| "failed to read field value".to_string())?
1382 .trim()
1383 .to_string();
1384
1385 if raw.is_empty() {
1386 return Err("empty field value".to_string());
1387 }
1388
1389 match node.kind() {
1390 "string" => Ok(strip_string_literal(&raw)),
1391 _ => Ok(raw),
1392 }
1393}
1394
1395fn strip_string_literal(raw: &str) -> String {
1396 if raw.is_empty() {
1397 return raw.to_string();
1398 }
1399
1400 let bytes = raw.as_bytes();
1401 if (bytes[0] == b'"' && bytes[bytes.len() - 1] == b'"')
1402 || (bytes[0] == b'\'' && bytes[bytes.len() - 1] == b'\'')
1403 {
1404 return raw[1..raw.len() - 1].to_string();
1405 }
1406
1407 if raw.starts_with('[') && raw.ends_with(']') {
1408 let mut start = 1usize;
1409 while start < raw.len() && raw.as_bytes()[start] == b'=' {
1410 start += 1;
1411 }
1412 if start < raw.len() && raw.as_bytes()[start] == b'[' {
1413 let mut end = raw.len() - 1;
1414 while end > 0 && raw.as_bytes()[end - 1] == b'=' {
1415 end -= 1;
1416 }
1417 if end > start + 1 {
1418 return raw[start + 1..end - 1].to_string();
1419 }
1420 }
1421 }
1422
1423 raw.to_string()
1424}
1425
1426fn strip_env_prefix(name: String) -> String {
1427 name.strip_prefix("_ENV::")
1428 .map(std::string::ToString::to_string)
1429 .unwrap_or(name)
1430}
1431
1432fn build_table_fields(
1434 table_node: Node<'_>,
1435 content: &[u8],
1436 helper: &mut GraphBuildHelper,
1437) -> GraphResult<()> {
1438 let mut cursor = table_node.walk();
1439
1440 for child in table_node.children(&mut cursor) {
1441 if child.kind() != "field" {
1442 continue;
1443 }
1444
1445 if let Some(name_node) = child.child_by_field_name("name") {
1447 let field_name = name_node
1448 .utf8_text(content)
1449 .map_err(|_| GraphBuilderError::ParseError {
1450 span: span_from_node(child),
1451 reason: "failed to read table field name".to_string(),
1452 })?
1453 .trim();
1454
1455 let span = span_from_node(child);
1457 let property_id = helper.add_node(field_name, Some(span), NodeKind::Property);
1459 helper.mark_definition(property_id);
1460 }
1461 }
1462
1463 Ok(())
1464}
1465
1466#[allow(clippy::unnecessary_wraps)]
1468fn build_field_access(
1469 access_node: Node<'_>,
1470 content: &[u8],
1471 helper: &mut GraphBuildHelper,
1472) -> GraphResult<()> {
1473 let field_name = match access_node.kind() {
1475 "dot_index_expression" => {
1476 access_node
1478 .child_by_field_name("field")
1479 .and_then(|n| n.utf8_text(content).ok())
1480 .map(|s| s.trim().to_string())
1481 }
1482 "bracket_index_expression" => {
1483 access_node.child_by_field_name("field").and_then(|n| {
1485 if n.kind() == "string" {
1487 n.utf8_text(content)
1488 .ok()
1489 .map(|s| s.trim().trim_matches(|c| c == '"' || c == '\'').to_string())
1490 } else {
1491 n.utf8_text(content).ok().map(|s| s.trim().to_string())
1493 }
1494 })
1495 }
1496 _ => None,
1497 };
1498
1499 if let Some(name) = field_name {
1500 let span = span_from_node(access_node);
1502 helper.add_node(&name, Some(span), NodeKind::Property);
1503 }
1504
1505 Ok(())
1506}
1507
1508#[derive(Debug, Clone)]
1513struct CallContext {
1514 qualified_name: String,
1515 is_method: bool,
1516 module_name: Option<String>,
1518}
1519
1520impl CallContext {
1521 fn qualified_name(&self) -> String {
1522 self.qualified_name.clone()
1523 }
1524}
1525
1526struct ASTGraph {
1527 contexts: Vec<CallContext>,
1528 node_to_context: HashMap<usize, usize>,
1529}
1530
1531impl ASTGraph {
1532 fn from_tree(tree: &Tree, content: &[u8], max_depth: usize) -> Result<Self, String> {
1533 let mut contexts = Vec::new();
1534 let mut node_to_context = HashMap::new();
1535
1536 let mut state = WalkerState::new(&mut contexts, &mut node_to_context, max_depth);
1537
1538 walk_ast(tree.root_node(), content, &mut state)?;
1539
1540 Ok(Self {
1541 contexts,
1542 node_to_context,
1543 })
1544 }
1545
1546 fn contexts(&self) -> &[CallContext] {
1547 &self.contexts
1548 }
1549
1550 fn get_callable_context(&self, node_id: usize) -> Option<&CallContext> {
1551 self.node_to_context
1552 .get(&node_id)
1553 .and_then(|idx| self.contexts.get(*idx))
1554 }
1555}
1556
1557struct WalkerState<'a> {
1559 contexts: &'a mut Vec<CallContext>,
1560 node_to_context: &'a mut HashMap<usize, usize>,
1561 parent_qualified: Option<String>,
1562 module_context: Option<String>,
1563 lexical_depth: usize,
1564 max_depth: usize,
1565}
1566
1567impl<'a> WalkerState<'a> {
1568 fn new(
1569 contexts: &'a mut Vec<CallContext>,
1570 node_to_context: &'a mut HashMap<usize, usize>,
1571 max_depth: usize,
1572 ) -> Self {
1573 Self {
1574 contexts,
1575 node_to_context,
1576 parent_qualified: None,
1577 module_context: None,
1578 lexical_depth: 0,
1579 max_depth,
1580 }
1581 }
1582}
1583
1584fn walk_ast(node: Node, content: &[u8], state: &mut WalkerState) -> Result<(), String> {
1586 if state.lexical_depth > state.max_depth {
1589 return Ok(());
1590 }
1591
1592 match node.kind() {
1593 "local_function" => {
1594 handle_local_function(node, content, state)?;
1596 }
1597 "function_declaration" => {
1598 handle_function_declaration(node, content, state)?;
1599 }
1600 "assignment_statement" => {
1601 handle_function_assignment(node, content, state)?;
1603 }
1604 _ => {
1605 let mut cursor = node.walk();
1607 for child in node.children(&mut cursor) {
1608 walk_ast(child, content, state)?;
1609 }
1610 }
1611 }
1612
1613 Ok(())
1614}
1615
1616fn handle_local_function(
1618 node: Node,
1619 content: &[u8],
1620 state: &mut WalkerState,
1621) -> Result<(), String> {
1622 let name_node = node
1623 .child_by_field_name("name")
1624 .ok_or_else(|| "local_function missing name".to_string())?;
1625
1626 let base_name = name_node
1628 .utf8_text(content)
1629 .map_err(|_| "failed to read local function name".to_string())?
1630 .to_string();
1631
1632 let qualified_name = if let Some(parent) = state.parent_qualified.as_ref() {
1634 format!("{parent}::{base_name}")
1635 } else {
1636 base_name
1637 };
1638
1639 let context_idx = state.contexts.len();
1641 state.contexts.push(CallContext {
1642 qualified_name: qualified_name.clone(),
1643 is_method: false, module_name: state.module_context.clone(),
1645 });
1646
1647 map_descendants_to_context(node, state.node_to_context, context_idx);
1649
1650 let saved_parent = state.parent_qualified.clone();
1652 let saved_module_context = state.module_context.clone();
1653
1654 state.parent_qualified = Some(qualified_name);
1656
1657 state.lexical_depth += 1;
1659
1660 #[allow(clippy::cast_possible_truncation)] if let Some(body) = node.named_child(node.named_child_count().saturating_sub(1) as u32) {
1663 walk_ast(body, content, state)?;
1664 }
1665
1666 state.lexical_depth -= 1;
1668 state.parent_qualified = saved_parent;
1669 state.module_context = saved_module_context;
1670
1671 Ok(())
1672}
1673
1674fn handle_function_declaration(
1676 node: Node,
1677 content: &[u8],
1678 state: &mut WalkerState,
1679) -> Result<(), String> {
1680 let name_node = node
1681 .child_by_field_name("name")
1682 .ok_or_else(|| "function_declaration missing name".to_string())?;
1683
1684 let (base_name, is_method) = extract_function_base_name(name_node, content)?;
1686
1687 let qualified_name = if base_name.contains("::") {
1691 base_name.clone()
1692 } else if let Some(parent) = state.parent_qualified.as_ref() {
1693 format!("{parent}::{base_name}")
1694 } else {
1695 base_name.clone()
1696 };
1697 let qualified_name = strip_env_prefix(qualified_name);
1698
1699 let module_name = if is_method {
1702 if qualified_name.contains("::") {
1704 let parts: Vec<&str> = qualified_name.split("::").collect();
1705 if parts.len() > 1 {
1706 Some(parts[..parts.len() - 1].join("::"))
1707 } else {
1708 None
1709 }
1710 } else {
1711 None
1712 }
1713 } else {
1714 state.module_context.clone()
1716 };
1717
1718 let context_idx = state.contexts.len();
1720 state.contexts.push(CallContext {
1721 qualified_name: qualified_name.clone(),
1722 is_method,
1723 module_name: module_name.clone(),
1724 });
1725
1726 map_descendants_to_context(node, state.node_to_context, context_idx);
1728
1729 let saved_parent = state.parent_qualified.clone();
1731 let saved_module_context = state.module_context.clone();
1732
1733 state.parent_qualified = Some(qualified_name);
1735
1736 if is_method && module_name.is_some() {
1738 state.module_context = module_name;
1739 }
1740
1741 state.lexical_depth += 1;
1743
1744 #[allow(clippy::cast_possible_truncation)] if let Some(body) = node.named_child(node.named_child_count().saturating_sub(1) as u32) {
1747 walk_ast(body, content, state)?;
1748 }
1749
1750 state.lexical_depth -= 1;
1752 state.parent_qualified = saved_parent;
1753 state.module_context = saved_module_context;
1754
1755 Ok(())
1756}
1757
1758fn handle_function_assignment(
1760 node: Node,
1761 content: &[u8],
1762 state: &mut WalkerState,
1763) -> Result<(), String> {
1764 let Some(expr_list) = node
1766 .children(&mut node.walk())
1767 .find(|child| child.kind() == "expression_list")
1768 else {
1769 return Ok(());
1770 };
1771
1772 let Some(func_def) = expr_list
1773 .named_children(&mut expr_list.walk())
1774 .find(|child| child.kind() == "function_definition")
1775 else {
1776 return Ok(());
1777 };
1778
1779 let Some(var_list) = node
1781 .children(&mut node.walk())
1782 .find(|child| child.kind() == "variable_list")
1783 else {
1784 return Ok(());
1785 };
1786
1787 let Some(var_node) = var_list.named_child(0) else {
1788 return Ok(());
1789 };
1790
1791 let (base_name, is_method) = extract_assignment_base_name(var_node, content)?;
1793
1794 let qualified_name = if base_name.contains("::") {
1797 base_name.clone()
1798 } else if let Some(parent) = state.parent_qualified.as_ref() {
1799 format!("{parent}::{base_name}")
1800 } else {
1801 base_name.clone()
1802 };
1803 let qualified_name = strip_env_prefix(qualified_name);
1804
1805 let module_name = if is_method {
1808 if qualified_name.contains("::") {
1810 let parts: Vec<&str> = qualified_name.split("::").collect();
1811 if parts.len() > 1 {
1812 Some(parts[..parts.len() - 1].join("::"))
1813 } else {
1814 None
1815 }
1816 } else {
1817 None
1818 }
1819 } else {
1820 state.module_context.clone()
1822 };
1823
1824 let context_idx = state.contexts.len();
1826 state.contexts.push(CallContext {
1827 qualified_name: qualified_name.clone(),
1828 is_method,
1829 module_name: module_name.clone(),
1830 });
1831
1832 map_descendants_to_context(func_def, state.node_to_context, context_idx);
1834
1835 let saved_parent = state.parent_qualified.clone();
1837 let saved_module_context = state.module_context.clone();
1838
1839 state.parent_qualified = Some(qualified_name);
1841
1842 if is_method && module_name.is_some() {
1844 state.module_context = module_name;
1845 }
1846
1847 state.lexical_depth += 1;
1849 #[allow(clippy::cast_possible_truncation)] if let Some(body) = func_def.named_child(func_def.named_child_count().saturating_sub(1) as u32)
1852 {
1853 walk_ast(body, content, state)?;
1854 }
1855
1856 state.lexical_depth -= 1;
1858 state.parent_qualified = saved_parent;
1859 state.module_context = saved_module_context;
1860
1861 Ok(())
1862}
1863
1864fn extract_function_base_name(
1867 name_node: Node<'_>,
1868 content: &[u8],
1869) -> Result<(String, bool), String> {
1870 match name_node.kind() {
1871 "identifier" => {
1872 let name = name_node
1874 .utf8_text(content)
1875 .map_err(|_| "failed to read function name".to_string())?;
1876 Ok((name.to_string(), false))
1877 }
1878 "dot_index_expression" => {
1879 let table = name_node
1881 .child_by_field_name("table")
1882 .ok_or_else(|| "dot_index_expression missing table".to_string())?;
1883 let field = name_node
1884 .child_by_field_name("field")
1885 .ok_or_else(|| "dot_index_expression missing field".to_string())?;
1886
1887 let table_text = collect_table_path_simple(table, content)?;
1888 let field_text = field
1889 .utf8_text(content)
1890 .map_err(|_| "failed to read field name".to_string())?;
1891
1892 Ok((format!("{table_text}::{field_text}"), false))
1893 }
1894 "method_index_expression" => {
1895 let table = name_node
1897 .child_by_field_name("table")
1898 .ok_or_else(|| "method_index_expression missing table".to_string())?;
1899 let method = name_node
1900 .child_by_field_name("method")
1901 .ok_or_else(|| "method_index_expression missing method".to_string())?;
1902
1903 let table_text = collect_table_path_simple(table, content)?;
1904 let method_text = method
1905 .utf8_text(content)
1906 .map_err(|_| "failed to read method name".to_string())?;
1907
1908 Ok((format!("{table_text}::{method_text}"), true))
1909 }
1910 "bracket_index_expression" => {
1911 let table = name_node
1913 .child_by_field_name("table")
1914 .ok_or_else(|| "bracket_index_expression missing table".to_string())?;
1915 let field = name_node
1916 .child_by_field_name("field")
1917 .ok_or_else(|| "bracket_index_expression missing field".to_string())?;
1918
1919 let table_text = collect_table_path_simple(table, content)?;
1920 let field_text = normalize_field_value_simple(field, content)?;
1921
1922 Ok((format!("{table_text}::{field_text}"), false))
1923 }
1924 _ => Err(format!(
1925 "unsupported function name kind: {}",
1926 name_node.kind()
1927 )),
1928 }
1929}
1930
1931fn extract_assignment_base_name(
1934 var_node: Node<'_>,
1935 content: &[u8],
1936) -> Result<(String, bool), String> {
1937 match var_node.kind() {
1938 "identifier" => {
1939 let name = var_node
1941 .utf8_text(content)
1942 .map_err(|_| "failed to read identifier".to_string())?;
1943 Ok((name.to_string(), false))
1944 }
1945 "dot_index_expression" => {
1946 let table = var_node
1948 .child_by_field_name("table")
1949 .ok_or_else(|| "dot_index_expression missing table".to_string())?;
1950 let field = var_node
1951 .child_by_field_name("field")
1952 .ok_or_else(|| "dot_index_expression missing field".to_string())?;
1953
1954 let table_text = collect_table_path_simple(table, content)?;
1955 let field_text = field
1956 .utf8_text(content)
1957 .map_err(|_| "failed to read field".to_string())?;
1958
1959 Ok((format!("{table_text}::{field_text}"), false))
1960 }
1961 "method_index_expression" => {
1962 let table = var_node
1964 .child_by_field_name("table")
1965 .ok_or_else(|| "method_index_expression missing table".to_string())?;
1966 let method = var_node
1967 .child_by_field_name("method")
1968 .ok_or_else(|| "method_index_expression missing method".to_string())?;
1969
1970 let table_text = collect_table_path_simple(table, content)?;
1971 let method_text = method
1972 .utf8_text(content)
1973 .map_err(|_| "failed to read method".to_string())?;
1974
1975 Ok((format!("{table_text}::{method_text}"), true))
1976 }
1977 "bracket_index_expression" => {
1978 let table = var_node
1980 .child_by_field_name("table")
1981 .ok_or_else(|| "bracket_index_expression missing table".to_string())?;
1982 let field = var_node
1983 .child_by_field_name("field")
1984 .ok_or_else(|| "bracket_index_expression missing field".to_string())?;
1985
1986 let table_text = collect_table_path_simple(table, content)?;
1987 let field_text = normalize_field_value_simple(field, content)?;
1988
1989 Ok((format!("{table_text}::{field_text}"), false))
1990 }
1991 _ => Err(format!(
1992 "unsupported assignment target kind: {}",
1993 var_node.kind()
1994 )),
1995 }
1996}
1997
1998fn collect_table_path_simple(node: Node<'_>, content: &[u8]) -> Result<String, String> {
2000 match node.kind() {
2001 "identifier" => node
2002 .utf8_text(content)
2003 .map(std::string::ToString::to_string)
2004 .map_err(|_| "failed to read identifier".to_string()),
2005 "bracket_index_expression" => {
2006 let table = node
2007 .child_by_field_name("table")
2008 .ok_or_else(|| "bracket_index_expression missing table".to_string())?;
2009 let field = node
2010 .child_by_field_name("field")
2011 .ok_or_else(|| "bracket_index_expression missing field".to_string())?;
2012
2013 let table_text = collect_table_path_simple(table, content)?;
2014 let field_text = normalize_field_value_simple(field, content)?;
2015
2016 Ok(format!("{table_text}::{field_text}"))
2017 }
2018 "dot_index_expression" => {
2019 let table = node
2020 .child_by_field_name("table")
2021 .ok_or_else(|| "dot_index_expression missing table".to_string())?;
2022 let field = node
2023 .child_by_field_name("field")
2024 .ok_or_else(|| "dot_index_expression missing field".to_string())?;
2025
2026 let table_text = collect_table_path_simple(table, content)?;
2027 let field_text = field
2028 .utf8_text(content)
2029 .map_err(|_| "failed to read field".to_string())?;
2030
2031 Ok(format!("{table_text}::{field_text}"))
2032 }
2033 _ => node
2034 .utf8_text(content)
2035 .map(std::string::ToString::to_string)
2036 .map_err(|_| "failed to read node".to_string()),
2037 }
2038}
2039
2040fn map_descendants_to_context(
2042 node: Node,
2043 node_to_context: &mut HashMap<usize, usize>,
2044 context_idx: usize,
2045) {
2046 node_to_context.insert(node.id(), context_idx);
2047
2048 let mut cursor = node.walk();
2049 for child in node.children(&mut cursor) {
2050 map_descendants_to_context(child, node_to_context, context_idx);
2051 }
2052}
2053
2054#[cfg(test)]
2055mod tests {
2056 use super::*;
2057 use crate::LuaPlugin;
2058 use sqry_core::graph::unified::build::StagingOp;
2059 use sqry_core::graph::unified::build::test_helpers::*;
2060 use sqry_core::graph::unified::edge::EdgeKind as UnifiedEdgeKind;
2061 use sqry_core::plugin::LanguagePlugin;
2062 use std::path::PathBuf;
2063
2064 fn extract_import_edges(staging: &StagingGraph) -> Vec<&UnifiedEdgeKind> {
2066 staging
2067 .operations()
2068 .iter()
2069 .filter_map(|op| {
2070 if let StagingOp::AddEdge { kind, .. } = op
2071 && matches!(kind, UnifiedEdgeKind::Imports { .. })
2072 {
2073 return Some(kind);
2074 }
2075 None
2076 })
2077 .collect()
2078 }
2079
2080 fn parse_lua(source: &str) -> Tree {
2081 let plugin = LuaPlugin::default();
2082 plugin.parse_ast(source.as_bytes()).unwrap()
2083 }
2084
2085 #[test]
2086 fn test_extracts_global_functions() {
2087 let source = r"
2088 function foo()
2089 end
2090
2091 function bar()
2092 end
2093 ";
2094
2095 let tree = parse_lua(source);
2096 let mut staging = StagingGraph::new();
2097 let builder = LuaGraphBuilder::default();
2098 let file = PathBuf::from("test.lua");
2099
2100 builder
2101 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2102 .unwrap();
2103
2104 assert!(staging.node_count() >= 2);
2105 assert_has_node(&staging, "foo");
2106 assert_has_node(&staging, "bar");
2107 }
2108
2109 #[test]
2110 fn test_creates_call_edges() {
2111 let source = r"
2112 function caller()
2113 callee()
2114 end
2115
2116 function callee()
2117 end
2118 ";
2119
2120 let tree = parse_lua(source);
2121 let mut staging = StagingGraph::new();
2122 let builder = LuaGraphBuilder::default();
2123 let file = PathBuf::from("test.lua");
2124
2125 builder
2126 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2127 .unwrap();
2128
2129 assert_has_node(&staging, "caller");
2130 assert_has_node(&staging, "callee");
2131
2132 let calls = collect_call_edges(&staging);
2133 assert!(!calls.is_empty(), "Expected at least one call edge");
2134 }
2135
2136 #[test]
2137 fn test_handles_module_methods() {
2138 let source = r"
2139 local MyModule = {}
2140
2141 function MyModule.method1()
2142 MyModule.method2()
2143 end
2144
2145 function MyModule:method2()
2146 end
2147 ";
2148
2149 let tree = parse_lua(source);
2150 let mut staging = StagingGraph::new();
2151 let builder = LuaGraphBuilder::default();
2152 let file = PathBuf::from("test.lua");
2153
2154 builder
2155 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2156 .unwrap();
2157
2158 assert_has_node(&staging, "method1");
2159 assert_has_node(&staging, "method2");
2160 assert_has_call_edge(&staging, "MyModule::method1", "MyModule::method2");
2161 }
2162
2163 #[test]
2164 fn test_nested_functions_scope_resolution() {
2165 let source = r"
2168 function outer()
2169 local function inner()
2170 local function deep()
2171 end
2172 end
2173 end
2174 ";
2175
2176 let tree = parse_lua(source);
2177 let mut staging = StagingGraph::new();
2178 let builder = LuaGraphBuilder::default();
2179 let file = PathBuf::from("test.lua");
2180
2181 builder
2182 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2183 .unwrap();
2184
2185 assert_has_node(&staging, "outer");
2187 assert_has_node(&staging, "outer::inner");
2188 assert_has_node(&staging, "outer::inner::deep");
2189
2190 }
2194
2195 #[test]
2196 fn test_self_resolution_in_methods() {
2197 let source = r"
2200 local MyModule = {}
2201
2202 function MyModule:method1()
2203 self:method2()
2204 end
2205
2206 function MyModule:method2()
2207 self:method3()
2208 end
2209
2210 function MyModule:method3()
2211 -- Empty
2212 end
2213 ";
2214
2215 let tree = parse_lua(source);
2216 let mut staging = StagingGraph::new();
2217 let builder = LuaGraphBuilder::default();
2218 let file = PathBuf::from("test.lua");
2219
2220 builder
2221 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2222 .unwrap();
2223
2224 assert_has_node(&staging, "MyModule::method1");
2226 assert_has_node(&staging, "MyModule::method2");
2227 assert_has_node(&staging, "MyModule::method3");
2228
2229 assert_has_call_edge(&staging, "MyModule::method1", "MyModule::method2");
2231
2232 assert_has_call_edge(&staging, "MyModule::method2", "MyModule::method3");
2234 }
2235
2236 #[test]
2237 fn test_local_function_call_resolution() {
2238 let source = r"
2242function outer()
2243 local function inner()
2244 local function deep()
2245 end
2246 deep()
2247 end
2248 inner()
2249end
2250";
2251
2252 let tree = parse_lua(source);
2253 let mut staging = StagingGraph::new();
2254 let builder = LuaGraphBuilder::default();
2255 let file = PathBuf::from("test.lua");
2256
2257 builder
2258 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2259 .unwrap();
2260
2261 assert_has_node(&staging, "outer");
2263 assert_has_node(&staging, "outer::inner");
2264 assert_has_node(&staging, "outer::inner::deep");
2265
2266 assert_has_call_edge(&staging, "outer", "outer::inner");
2268
2269 assert_has_call_edge(&staging, "outer::inner", "outer::inner::deep");
2271 }
2272
2273 #[test]
2274 fn test_nested_helper_self_resolution() {
2275 let source = r"
2279MyModule = {}
2280
2281function MyModule:outer()
2282 local function helper()
2283 self:inner()
2284 end
2285 helper()
2286end
2287
2288function MyModule:inner()
2289end
2290";
2291
2292 let tree = parse_lua(source);
2293 let mut staging = StagingGraph::new();
2294 let builder = LuaGraphBuilder::default();
2295 let file = PathBuf::from("test.lua");
2296
2297 builder
2298 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2299 .unwrap();
2300
2301 assert_has_node(&staging, "MyModule::outer");
2303 assert_has_node(&staging, "MyModule::outer::helper");
2304 assert_has_node(&staging, "MyModule::inner");
2305
2306 assert_has_call_edge(&staging, "MyModule::outer::helper", "MyModule::inner");
2308
2309 assert_has_call_edge(&staging, "MyModule::outer", "MyModule::outer::helper");
2311 }
2312
2313 #[test]
2314 fn test_bracket_string_key_assignment() {
2315 let source = r#"
2316 local Module = {}
2317
2318 Module["string-key"] = function()
2319 return true
2320 end
2321
2322 local function call_it()
2323 Module["string-key"]()
2324 end
2325 "#;
2326
2327 let tree = parse_lua(source);
2328 let mut staging = StagingGraph::new();
2329 let builder = LuaGraphBuilder::default();
2330 let file = PathBuf::from("test.lua");
2331
2332 builder
2333 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2334 .unwrap();
2335
2336 assert_has_node(&staging, "Module::string-key");
2338 assert_has_node(&staging, "call_it");
2339
2340 assert_has_call_edge(&staging, "call_it", "Module::string-key");
2342 }
2343
2344 #[test]
2345 fn test_numeric_command_table_assignment() {
2346 let source = r#"
2347 local commands = {}
2348
2349 commands[1] = function()
2350 return "cmd"
2351 end
2352
2353 local function run()
2354 commands[1]()
2355 end
2356 "#;
2357
2358 let tree = parse_lua(source);
2359 let mut staging = StagingGraph::new();
2360 let builder = LuaGraphBuilder::default();
2361 let file = PathBuf::from("test.lua");
2362
2363 builder
2364 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2365 .unwrap();
2366
2367 assert_has_node(&staging, "commands::1");
2369 assert_has_node(&staging, "run");
2370
2371 assert_has_call_edge(&staging, "run", "commands::1");
2373 }
2374
2375 #[test]
2376 fn test_env_driven_name_injection() {
2377 let source = r#"
2378 _ENV["init"] = function()
2379 return true
2380 end
2381
2382 local function boot()
2383 init()
2384 end
2385 "#;
2386
2387 let tree = parse_lua(source);
2388 let mut staging = StagingGraph::new();
2389 let builder = LuaGraphBuilder::default();
2390 let file = PathBuf::from("test.lua");
2391
2392 builder
2393 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2394 .unwrap();
2395
2396 assert_has_node(&staging, "init");
2398 assert_has_node(&staging, "boot");
2399
2400 assert_has_call_edge(&staging, "boot", "init");
2402 }
2403
2404 #[test]
2405 fn test_deep_namespace_lexical_depth() {
2406 let source = r"
2409Company = {}
2410Company.Product = {}
2411Company.Product.Component = {}
2412
2413function Company.Product.Component:outer()
2414 local function helper1()
2415 local function helper2()
2416 self:inner()
2417 end
2418 helper2()
2419 end
2420 helper1()
2421end
2422
2423function Company.Product.Component:inner()
2424end
2425";
2426
2427 let tree = parse_lua(source);
2428 let mut staging = StagingGraph::new();
2429 let builder = LuaGraphBuilder::default(); let file = PathBuf::from("test.lua");
2431
2432 builder
2433 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2434 .unwrap();
2435
2436 assert_has_node(&staging, "Company::Product::Component::outer");
2438 assert_has_node(&staging, "Company::Product::Component::outer::helper1");
2439 assert_has_node(
2440 &staging,
2441 "Company::Product::Component::outer::helper1::helper2",
2442 );
2443 assert_has_node(&staging, "Company::Product::Component::inner");
2444
2445 assert_has_call_edge(
2448 &staging,
2449 "Company::Product::Component::outer::helper1::helper2",
2450 "Company::Product::Component::inner",
2451 );
2452
2453 assert_has_call_edge(
2455 &staging,
2456 "Company::Product::Component::outer",
2457 "Company::Product::Component::outer::helper1",
2458 );
2459 assert_has_call_edge(
2460 &staging,
2461 "Company::Product::Component::outer::helper1",
2462 "Company::Product::Component::outer::helper1::helper2",
2463 );
2464 }
2465
2466 #[test]
2467 fn test_nested_method_redefinition() {
2468 let source = r"
2472MyModule = {}
2473
2474function MyModule:outer()
2475 function MyModule:inner()
2476 -- redefined inside outer
2477 end
2478 self:inner()
2479end
2480";
2481
2482 let tree = parse_lua(source);
2483 let mut staging = StagingGraph::new();
2484 let builder = LuaGraphBuilder::default();
2485 let file = PathBuf::from("test.lua");
2486
2487 builder
2488 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2489 .unwrap();
2490
2491 assert_has_node(&staging, "MyModule::outer");
2493 assert_has_node(&staging, "MyModule::inner");
2494
2495 assert_has_call_edge(&staging, "MyModule::outer", "MyModule::inner");
2497 }
2498
2499 #[test]
2504 fn test_require_import_edge_double_quotes() {
2505 let source = r#"
2506 local json = require("cjson")
2507 "#;
2508
2509 let tree = parse_lua(source);
2510 let mut staging = StagingGraph::new();
2511 let builder = LuaGraphBuilder::default();
2512 let file = PathBuf::from("test.lua");
2513
2514 builder
2515 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2516 .unwrap();
2517
2518 let import_edges = extract_import_edges(&staging);
2519 assert!(
2520 !import_edges.is_empty(),
2521 "Expected at least one import edge"
2522 );
2523
2524 let edge = import_edges[0];
2526 if let UnifiedEdgeKind::Imports { is_wildcard, .. } = edge {
2527 assert!(
2528 !*is_wildcard,
2529 "Lua require returns module reference, not wildcard"
2530 );
2531 } else {
2532 panic!("Expected Imports edge kind");
2533 }
2534 }
2535
2536 #[test]
2537 fn test_require_import_edge_single_quotes() {
2538 let source = r"
2539 local socket = require('socket')
2540 ";
2541
2542 let tree = parse_lua(source);
2543 let mut staging = StagingGraph::new();
2544 let builder = LuaGraphBuilder::default();
2545 let file = PathBuf::from("test.lua");
2546
2547 builder
2548 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2549 .unwrap();
2550
2551 let import_edges = extract_import_edges(&staging);
2552 assert!(!import_edges.is_empty(), "Expected require import edge");
2553
2554 let edge = import_edges[0];
2556 if let UnifiedEdgeKind::Imports { is_wildcard, .. } = edge {
2557 assert!(
2558 !*is_wildcard,
2559 "Lua require returns module reference, not wildcard"
2560 );
2561 } else {
2562 panic!("Expected Imports edge kind");
2563 }
2564 }
2565
2566 #[test]
2567 fn test_require_dotted_module() {
2568 let source = r#"
2569 local util = require("luasocket.util")
2570 "#;
2571
2572 let tree = parse_lua(source);
2573 let mut staging = StagingGraph::new();
2574 let builder = LuaGraphBuilder::default();
2575 let file = PathBuf::from("test.lua");
2576
2577 builder
2578 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
2579 .unwrap();
2580
2581 let import_edges = extract_import_edges(&staging);
2582 assert!(
2583 !import_edges.is_empty(),
2584 "Expected dotted module import edge"
2585 );
2586
2587 let edge = import_edges[0];
2589 assert!(
2590 matches!(edge, UnifiedEdgeKind::Imports { .. }),
2591 "Expected Imports edge kind"
2592 );
2593 }
2594
2595 #[test]
2596 fn test_multiple_requires() {
2597 let source = r#"
2598 local json = require("cjson")
2599 local socket = require("socket")
2600 local lpeg = require("lpeg")
2601 local lfs = require("lfs")
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_eq!(import_edges.len(), 4, "Expected 4 import edges");
2615
2616 for edge in &import_edges {
2618 assert!(
2619 matches!(edge, UnifiedEdgeKind::Imports { .. }),
2620 "All edges should be Imports"
2621 );
2622 }
2623 }
2624}
2625
2626#[cfg(test)]
2627mod shape_tests {
2628 use super::{cf_bucket_for_lua_kind, lua_shape_mapping};
2632 use sqry_core::graph::unified::build::shape::{
2633 CfBucket, ShapeBudget, ShapeMapping, compute_shape_descriptor,
2634 };
2635 use tree_sitter::{Node, Parser, Tree};
2636
2637 const SAMPLE: &str = include_str!(concat!(
2638 env!("CARGO_MANIFEST_DIR"),
2639 "/../test-fixtures/shape/dynamic/lua.lua"
2640 ));
2641
2642 fn parse(src: &str) -> Tree {
2643 let mut parser = Parser::new();
2644 parser
2645 .set_language(&tree_sitter_lua::LANGUAGE.into())
2646 .expect("load lua grammar");
2647 parser.parse(src, None).expect("parse lua")
2648 }
2649
2650 fn first_function<'t>(tree: &'t Tree) -> Node<'t> {
2651 let root = tree.root_node();
2652 let mut cursor = root.walk();
2653 for child in root.named_children(&mut cursor) {
2654 if child.kind() == "function_declaration" {
2655 return child;
2656 }
2657 }
2658 panic!("no function_declaration in lua fixture");
2659 }
2660
2661 #[test]
2662 fn mapping_is_non_empty_and_covers_real_kinds() {
2663 assert_eq!(
2664 cf_bucket_for_lua_kind("if_statement"),
2665 Some(CfBucket::Branch)
2666 );
2667 assert_eq!(
2668 cf_bucket_for_lua_kind("while_statement"),
2669 Some(CfBucket::Loop)
2670 );
2671 assert_eq!(
2672 cf_bucket_for_lua_kind("repeat_statement"),
2673 Some(CfBucket::Loop)
2674 );
2675 assert_eq!(
2676 cf_bucket_for_lua_kind("break_statement"),
2677 Some(CfBucket::BreakContinue)
2678 );
2679 assert_eq!(
2680 cf_bucket_for_lua_kind("return_statement"),
2681 Some(CfBucket::Return)
2682 );
2683 assert_eq!(
2684 cf_bucket_for_lua_kind("function_call"),
2685 Some(CfBucket::Call)
2686 );
2687 assert_eq!(
2688 cf_bucket_for_lua_kind("function_definition"),
2689 Some(CfBucket::Closure)
2690 );
2691 assert_eq!(cf_bucket_for_lua_kind("nope"), None);
2692
2693 let lang: tree_sitter::Language = tree_sitter_lua::LANGUAGE.into();
2694 let id = (0..lang.node_kind_count())
2695 .map(|i| i as u16)
2696 .find(|&i| {
2697 lang.node_kind_is_named(i) && lang.node_kind_for_id(i) == Some("if_statement")
2698 })
2699 .expect("grammar exposes named if_statement");
2700 assert_eq!(lua_shape_mapping().cf_bucket(id), Some(CfBucket::Branch));
2701 }
2702
2703 #[test]
2704 fn descriptor_covers_fixture_control_flow() {
2705 let tree = parse(SAMPLE);
2706 let func = first_function(&tree);
2707 let descriptor = compute_shape_descriptor(
2708 func,
2709 SAMPLE.as_bytes(),
2710 lua_shape_mapping(),
2711 &ShapeBudget::default(),
2712 );
2713 let hist = descriptor.cf_histogram;
2714 assert!(hist[CfBucket::Branch.index()] >= 1, "branch (if)");
2715 assert!(hist[CfBucket::Loop.index()] >= 1, "loop (while/for/repeat)");
2716 assert!(hist[CfBucket::BreakContinue.index()] >= 1, "break");
2717 assert!(hist[CfBucket::Return.index()] >= 1, "return");
2718 assert!(hist[CfBucket::Call.index()] >= 1, "call");
2719 assert!(hist[CfBucket::Assign.index()] >= 1, "assignment");
2720 assert!(
2721 hist[CfBucket::Closure.index()] >= 1,
2722 "nested function literal"
2723 );
2724 }
2725
2726 #[test]
2727 fn signature_shape_reads_arity_and_varargs() {
2728 let tree = parse(SAMPLE);
2729 let func = first_function(&tree);
2730 let shape = lua_shape_mapping().signature_shape(func, SAMPLE.as_bytes());
2731 assert_eq!(shape.arity_positional, 2, "value + label");
2733 assert!(shape.has_varargs, "...");
2734 }
2735}