1use std::sync::OnceLock;
9use std::{
10 collections::{HashMap, HashSet},
11 path::Path,
12};
13
14use sqry_core::graph::unified::build::helper::CalleeKindHint;
15use sqry_core::graph::unified::build::shape::{CfBucket, ShapeMapping};
16use sqry_core::graph::unified::edge::ExportKind;
17use sqry_core::graph::unified::storage::shape::SignatureShape;
18use sqry_core::graph::{
19 GraphBuilder, GraphBuilderError, GraphResult, Language, Span,
20 unified::{GraphBuildHelper, StagingGraph},
21};
22use tree_sitter::{Node, StreamingIterator, Tree};
23
24pub struct ShellGraphBuilder {
26 max_scope_depth: usize,
27}
28
29impl Default for ShellGraphBuilder {
30 fn default() -> Self {
31 Self {
32 max_scope_depth: 2, }
34 }
35}
36
37impl GraphBuilder for ShellGraphBuilder {
38 fn language(&self) -> Language {
39 Language::Shell
40 }
41
42 fn shape_mapping(&self) -> Option<&dyn ShapeMapping> {
43 Some(shell_shape_mapping())
44 }
45
46 #[allow(clippy::too_many_lines)]
48 fn build_graph(
49 &self,
50 tree: &Tree,
51 content: &[u8],
52 file: &Path,
53 staging: &mut StagingGraph,
54 ) -> GraphResult<()> {
55 let mut helper = GraphBuildHelper::new(staging, file, Language::Shell);
57
58 let ast_graph = ASTGraph::from_tree(tree, content, self.max_scope_depth).map_err(|e| {
60 GraphBuilderError::ParseError {
61 span: Span::default(),
62 reason: e,
63 }
64 })?;
65
66 let script_name = file
68 .file_stem()
69 .and_then(|s| s.to_str())
70 .unwrap_or("script");
71 let module_qualified = format!("{script_name}::module");
72 let module_id =
73 helper.add_module(&module_qualified, Some(Span::from_bytes(0, content.len())));
74
75 for context in ast_graph.contexts() {
80 let qualified = context.qualified_name();
81 let span = Span::from_bytes(context.span.0, context.span.1);
82 let visibility = extract_visibility(&qualified);
83 let function_id = helper.add_function_with_visibility(
84 &qualified,
85 Some(span),
86 false,
87 false,
88 Some(visibility),
89 );
90 helper.add_export_edge_full(module_id, function_id, ExportKind::Direct, None);
92 }
93
94 let mut exported_variables = HashSet::new();
96 let root = tree.root_node();
97 let mut root_cursor = root.walk();
98 for command in root.children(&mut root_cursor) {
99 match command.kind() {
100 "command" | "declaration_command" => {}
101 _ => continue,
102 }
103
104 let mut cmd_cursor = command.walk();
105 let mut command_name: Option<String> = None;
106 let mut arg_nodes: Vec<Node> = Vec::new();
107
108 for child in command.children(&mut cmd_cursor) {
109 match child.kind() {
110 "export" | "word" | "command_name" | "variable_name" => {
111 if command_name.is_none() {
112 command_name = Some(get_node_text(child, content)?);
113 } else {
114 arg_nodes.push(child);
115 }
116 }
117 "variable_assignment" => arg_nodes.push(child),
118 _ => {}
119 }
120 }
121
122 let Some(command_name) = command_name else {
123 continue;
124 };
125 if command_name != "export" {
126 continue;
127 }
128
129 let mut mark_next_as_function = false;
130 for arg_node in arg_nodes {
131 match arg_node.kind() {
132 "word" | "command_name" | "variable_name" => {
133 let text = get_node_text(arg_node, content)?;
134 if text == "-f" {
135 mark_next_as_function = true;
136 continue;
137 }
138 if text.starts_with('-') {
139 continue;
140 }
141 if mark_next_as_function {
142 mark_next_as_function = false;
143 continue;
144 }
145
146 if exported_variables.insert(text.clone()) {
147 let var_id = helper.add_variable(&text, Some(span_from_node(arg_node)));
148 helper.add_export_edge_full(
149 module_id,
150 var_id,
151 ExportKind::Direct,
152 None,
153 );
154 }
155 }
156 "variable_assignment" => {
157 if let Some(name_node) = arg_node.child_by_field_name("name") {
158 let name = get_node_text(name_node, content)?;
159 if exported_variables.insert(name.clone()) {
160 let var_id =
161 helper.add_variable(&name, Some(span_from_node(name_node)));
162 helper.add_export_edge_full(
163 module_id,
164 var_id,
165 ExportKind::Direct,
166 None,
167 );
168 }
169 }
170 }
171 _ => {}
172 }
173 }
174 }
175
176 let mut stack = vec![tree.root_node()];
178 let mut visited = HashSet::new();
179
180 while let Some(node) = stack.pop() {
181 let node_id = node.id();
182
183 if !visited.insert(node_id) {
185 continue;
186 }
187
188 match node.kind() {
190 "comment" | "string" | "raw_string" | "ansi_c_string" => {
191 continue;
192 }
193 _ => {}
194 }
195
196 if node.kind() == "command" {
198 if let Some((importer_qname, imported_path, span)) =
200 build_import_edge_for_staging(&ast_graph, node, content, &module_qualified)?
201 {
202 let from_id = helper.add_import(&importer_qname, None);
203 let to_id = helper.add_import(&imported_path, Some(span));
204 helper.add_import_edge(from_id, to_id);
205 }
206 else if let Some((caller_qname, callee_qname, argument_count, span)) =
208 build_call_edge_for_staging(&ast_graph, node, content, &module_qualified)?
209 {
210 let source_id =
211 helper.ensure_callee(&caller_qname, span, CalleeKindHint::Function);
212 let target_id =
213 helper.ensure_callee(&callee_qname, span, CalleeKindHint::Function);
214
215 let argument_count = u8::try_from(argument_count).unwrap_or(u8::MAX);
216 helper.add_call_edge_full_with_span(
217 source_id,
218 target_id,
219 argument_count,
220 false,
221 vec![span],
222 );
223 }
224 }
225
226 let mut cursor = node.walk();
228 for child in node.children(&mut cursor) {
229 stack.push(child);
230 }
231 }
232
233 Ok(())
234 }
235}
236
237fn build_call_edge_for_staging(
244 ast_graph: &ASTGraph,
245 call_node: Node,
246 content: &[u8],
247 module_name: &str,
248) -> GraphResult<Option<(String, String, usize, Span)>> {
249 let module_context;
251 let call_context = if let Some(ctx) = ast_graph.get_callable_context(call_node.id()) {
252 ctx
253 } else {
254 module_context = CallContext {
256 qualified_name: module_name.to_string(),
257 span: (0, content.len()),
258 };
259 &module_context
260 };
261
262 let Some(name_node) = call_node.child_by_field_name("name") else {
264 return Ok(None);
265 };
266
267 let callee_text = get_node_text(name_node, content)?;
268
269 if callee_text.is_empty() {
270 return Ok(None);
271 }
272
273 if is_builtin_command(&callee_text) {
275 return Ok(None);
276 }
277
278 let is_user_defined = ast_graph
280 .contexts()
281 .iter()
282 .any(|ctx| ctx.qualified_name() == callee_text);
283
284 if !is_user_defined {
285 return Ok(None);
286 }
287
288 let target_qname = callee_text.clone();
289 let source_qname = call_context.qualified_name();
290
291 let span = span_from_node(call_node);
292 let argument_count = count_arguments(call_node);
293
294 Ok(Some((source_qname, target_qname, argument_count, span)))
295}
296
297fn is_builtin_command(cmd: &str) -> bool {
299 matches!(
301 cmd,
302 "echo"
303 | "cd"
304 | "pwd"
305 | "ls"
306 | "cat"
307 | "grep"
308 | "sed"
309 | "awk"
310 | "test"
311 | "["
312 | "[["
313 | "printf"
314 | "read"
315 | "set"
316 | "unset"
317 | "export"
318 | "alias"
319 | "unalias"
320 | "bg"
321 | "fg"
322 | "jobs"
323 | "kill"
324 | "wait"
325 | "eval"
326 | "exec"
327 | "exit"
328 | "return"
329 | "shift"
330 | "trap"
331 | "umask"
332 | "readonly"
333 | "local"
334 | "declare"
335 | "typeset"
336 | "enable"
337 | "help"
338 | "let"
339 | "break"
340 | "continue"
341 | "true"
342 | "false"
343 | ":"
344 | "getopts"
345 | "hash"
346 | "type"
347 | "times"
348 | "ulimit"
349 | "shopt"
350 | "complete"
351 | "compgen"
352 | "fc"
353 | "history"
354 | "pushd"
355 | "popd"
356 | "dirs"
357 | "bind"
358 | "builtin"
359 | "command"
360 | "mapfile"
361 | "readarray"
362 | "caller"
363 | "disown"
364 | "suspend"
365 | "compopt"
366 )
367}
368
369fn is_source_command(cmd: &str) -> bool {
371 matches!(cmd, "source" | ".")
372}
373
374fn build_import_edge_for_staging(
379 ast_graph: &ASTGraph,
380 command_node: Node,
381 content: &[u8],
382 module_name: &str,
383) -> GraphResult<Option<(String, String, Span)>> {
384 let Some(name_node) = command_node.child_by_field_name("name") else {
386 return Ok(None);
387 };
388
389 let cmd_text = get_node_text(name_node, content)?;
390 if !is_source_command(&cmd_text) {
391 return Ok(None);
392 }
393
394 let mut arg_node = None;
396 let mut cursor = command_node.walk();
397 let mut past_name = false;
398 for child in command_node.children(&mut cursor) {
399 if child.id() == name_node.id() {
400 past_name = true;
401 continue;
402 }
403 if past_name {
404 match child.kind() {
405 "word" | "string" | "raw_string" | "simple_expansion" | "expansion"
406 | "concatenation" => {
407 arg_node = Some(child);
408 break;
409 }
410 _ => {}
411 }
412 }
413 }
414
415 let Some(arg) = arg_node else {
416 return Ok(None);
417 };
418
419 let imported_path = extract_source_path(arg, content)?;
421 if imported_path.is_empty() {
422 return Ok(None);
423 }
424
425 let importer_qname = if let Some(ctx) = ast_graph.get_callable_context(command_node.id()) {
427 ctx.qualified_name()
428 } else {
429 module_name.to_string()
430 };
431
432 let span = span_from_node(command_node);
433 Ok(Some((importer_qname, imported_path, span)))
434}
435
436fn extract_source_path(node: Node, content: &[u8]) -> GraphResult<String> {
444 match node.kind() {
445 "string" | "raw_string" => {
446 let text = get_node_text(node, content)?;
447 let stripped = text
449 .strip_prefix('"')
450 .and_then(|s| s.strip_suffix('"'))
451 .or_else(|| text.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')))
452 .or_else(|| text.strip_prefix("$'").and_then(|s| s.strip_suffix('\'')))
453 .unwrap_or(&text);
454 Ok(stripped.to_string())
455 }
456 _ => get_node_text(node, content),
458 }
459}
460
461fn count_arguments(call_node: Node) -> usize {
463 let mut count: usize = 0;
464 let mut cursor = call_node.walk();
465
466 for child in call_node.children(&mut cursor) {
467 match child.kind() {
468 "word"
469 | "string"
470 | "raw_string"
471 | "ansi_c_string"
472 | "simple_expansion"
473 | "expansion"
474 | "command_substitution" => {
475 count += 1;
476 }
477 _ => {}
478 }
479 }
480
481 count.saturating_sub(1)
483}
484
485fn span_from_node(node: Node) -> Span {
487 Span::from_bytes(node.start_byte(), node.end_byte())
488}
489
490fn get_node_text(node: Node, content: &[u8]) -> GraphResult<String> {
492 node.utf8_text(content)
493 .map(|s| s.trim().to_string())
494 .map_err(|_| GraphBuilderError::ParseError {
495 span: span_from_node(node),
496 reason: "invalid UTF-8".to_string(),
497 })
498}
499
500#[derive(Debug, Clone)]
505struct CallContext {
506 qualified_name: String,
507 span: (usize, usize),
508}
509
510impl CallContext {
511 fn qualified_name(&self) -> String {
512 self.qualified_name.clone()
513 }
514}
515
516struct ASTGraph {
517 contexts: Vec<CallContext>,
518 node_to_context: HashMap<usize, usize>,
519}
520
521impl ASTGraph {
522 fn from_tree(tree: &Tree, content: &[u8], _max_depth: usize) -> Result<Self, String> {
523 let mut contexts = Vec::new();
524 let mut node_to_context = HashMap::new();
525
526 let query = tree_sitter::Query::new(
528 &tree_sitter_bash::LANGUAGE.into(),
529 r"(function_definition name: (word) @function_name) @function_node",
530 )
531 .map_err(|e| format!("Failed to create query: {e}"))?;
532
533 let mut cursor = tree_sitter::QueryCursor::new();
534 let root = tree.root_node();
535 let capture_names = query.capture_names();
536 let mut matches = cursor.matches(&query, root, content);
537
538 while let Some(m) = matches.next() {
539 let mut name_node = None;
540 let mut func_node = None;
541
542 for capture in m.captures {
543 let capture_name = capture_names[capture.index as usize];
544 match capture_name {
545 "function_name" => name_node = Some(capture.node),
546 "function_node" => func_node = Some(capture.node),
547 _ => {}
548 }
549 }
550
551 let (Some(name_node), Some(func_node)) = (name_node, func_node) else {
552 continue;
553 };
554
555 let function_name = name_node
556 .utf8_text(content)
557 .map_err(|_| "failed to read function name".to_string())?
558 .to_string();
559
560 let context_idx = contexts.len();
561 contexts.push(CallContext {
562 qualified_name: function_name,
563 span: (func_node.start_byte(), func_node.end_byte()),
564 });
565
566 map_descendants_to_context(func_node, &mut node_to_context, context_idx);
568 }
569
570 Ok(Self {
571 contexts,
572 node_to_context,
573 })
574 }
575
576 fn contexts(&self) -> &[CallContext] {
577 &self.contexts
578 }
579
580 fn get_callable_context(&self, node_id: usize) -> Option<&CallContext> {
581 self.node_to_context
582 .get(&node_id)
583 .and_then(|idx| self.contexts.get(*idx))
584 }
585}
586
587fn extract_visibility(_name: &str) -> &'static str {
593 "public"
594}
595
596fn map_descendants_to_context(node: Node, map: &mut HashMap<usize, usize>, context_idx: usize) {
598 map.insert(node.id(), context_idx);
599
600 let mut cursor = node.walk();
601 for child in node.children(&mut cursor) {
602 map_descendants_to_context(child, map, context_idx);
603 }
604}
605
606#[cfg(test)]
607mod tests {
608 use super::*;
609 use sqry_core::graph::unified::build::{StagingOp, test_helpers::*};
610 use sqry_core::graph::unified::edge::{EdgeKind, ExportKind};
611 use sqry_core::graph::unified::node::NodeKind;
612 use std::path::PathBuf;
613
614 fn parse_shell(source: &str) -> Tree {
615 let mut parser = tree_sitter::Parser::new();
616 parser
617 .set_language(&tree_sitter_bash::LANGUAGE.into())
618 .expect("failed to set language");
619 parser.parse(source, None).expect("failed to parse")
620 }
621
622 #[test]
623 fn test_extracts_posix_functions() {
624 let source = r#"
625foo() {
626 echo "foo"
627}
628
629bar() {
630 echo "bar"
631}
632"#;
633
634 let tree = parse_shell(source);
635 let mut staging = StagingGraph::new();
636 let builder = ShellGraphBuilder::default();
637 let file = PathBuf::from("test.sh");
638
639 builder
640 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
641 .unwrap();
642
643 assert_has_node_with_kind(&staging, "test::module", NodeKind::Module);
645
646 assert_has_node_with_kind(&staging, "foo", NodeKind::Function);
648 assert_has_node_with_kind(&staging, "bar", NodeKind::Function);
649
650 let exports = collect_export_edges(&staging);
652 assert_eq!(exports.len(), 2, "Expected 2 function exports");
653 assert_has_export_edge(&staging, "test::module", "foo");
654 assert_has_export_edge(&staging, "test::module", "bar");
655 }
656
657 #[test]
658 fn test_extracts_bash_functions() {
659 let source = r#"
660function foo {
661 echo "foo"
662}
663
664function bar() {
665 echo "bar"
666}
667"#;
668
669 let tree = parse_shell(source);
670 let mut staging = StagingGraph::new();
671 let builder = ShellGraphBuilder::default();
672 let file = PathBuf::from("test.sh");
673
674 builder
675 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
676 .unwrap();
677
678 assert_has_node_with_kind(&staging, "test::module", NodeKind::Module);
680
681 assert_has_node_with_kind(&staging, "foo", NodeKind::Function);
683 assert_has_node_with_kind(&staging, "bar", NodeKind::Function);
684
685 let exports = collect_export_edges(&staging);
687 assert_eq!(exports.len(), 2, "Expected 2 function exports");
688 assert_has_export_edge(&staging, "test::module", "foo");
689 assert_has_export_edge(&staging, "test::module", "bar");
690 }
691
692 #[test]
693 fn test_creates_call_edges() {
694 let source = r#"
695caller() {
696 callee
697}
698
699callee() {
700 echo "callee"
701}
702"#;
703
704 let tree = parse_shell(source);
705 let mut staging = StagingGraph::new();
706 let builder = ShellGraphBuilder::default();
707 let file = PathBuf::from("test.sh");
708
709 builder
710 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
711 .unwrap();
712
713 assert_has_node_with_kind(&staging, "caller", NodeKind::Function);
715 assert_has_node_with_kind(&staging, "callee", NodeKind::Function);
716
717 let call_edges = collect_call_edges(&staging);
719 assert_eq!(call_edges.len(), 1, "Expected 1 call edge");
720 assert_has_call_edge(&staging, "caller", "callee");
721 }
722
723 #[test]
724 fn test_script_module_node_always_present() {
725 let source = r"
727#!/bin/bash
728# Empty script with no functions
729";
730
731 let tree = parse_shell(source);
732 let mut staging = StagingGraph::new();
733 let builder = ShellGraphBuilder::default();
734 let file = PathBuf::from("test.sh");
735
736 builder
737 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
738 .unwrap();
739
740 assert_has_node_with_kind(&staging, "test::module", NodeKind::Module);
742
743 assert_eq!(count_nodes_by_kind(&staging, NodeKind::Function), 0);
745 let exports = collect_export_edges(&staging);
746 assert_eq!(exports.len(), 0, "Expected no exports for empty script");
747 }
748
749 #[test]
750 fn test_script_name_function_collision() {
751 let source = r#"
753#!/bin/bash
754
755deploy() {
756 helper
757}
758
759helper() {
760 echo "hi"
761}
762
763deploy
764"#;
765
766 let tree = parse_shell(source);
767 let mut staging = StagingGraph::new();
768 let builder = ShellGraphBuilder::default();
769 let file = PathBuf::from("deploy.sh");
770
771 builder
772 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
773 .unwrap();
774
775 assert_has_node_with_kind(&staging, "deploy::module", NodeKind::Module);
777 assert_has_node_with_kind(&staging, "deploy", NodeKind::Function);
778 assert_has_node_with_kind(&staging, "helper", NodeKind::Function);
779
780 assert_has_call_edge(&staging, "deploy", "helper");
782
783 assert_has_call_edge(&staging, "deploy::module", "deploy");
785 }
786
787 #[test]
788 fn test_filters_external_tools() {
789 let source = r#"
791deploy() {
792 git status
793 kubectl apply -f deployment.yaml
794 docker build -t myimage .
795 my_helper
796}
797
798my_helper() {
799 echo "ok"
800}
801"#;
802
803 let tree = parse_shell(source);
804 let mut staging = StagingGraph::new();
805 let builder = ShellGraphBuilder::default();
806 let file = PathBuf::from("test.sh");
807
808 builder
809 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
810 .unwrap();
811
812 assert_has_node_with_kind(&staging, "deploy", NodeKind::Function);
814 assert_has_node_with_kind(&staging, "my_helper", NodeKind::Function);
815
816 let call_edges = collect_call_edges(&staging);
818 assert_eq!(
819 call_edges.len(),
820 1,
821 "Expected 1 call edge (only to user function)"
822 );
823 assert_has_call_edge(&staging, "deploy", "my_helper");
824
825 assert!(
827 !staging.nodes().any(|n| staging
828 .resolve_node_name(n.entry)
829 .is_some_and(|name| name.contains("git")
830 || name.contains("kubectl")
831 || name.contains("docker"))),
832 "External tools should not create nodes"
833 );
834 }
835
836 #[test]
837 fn test_filters_builtin_commands() {
838 let source = r#"
839my_function() {
840 echo "test"
841 cd /tmp
842 ls -la
843 my_helper
844}
845
846my_helper() {
847 pwd
848}
849"#;
850
851 let tree = parse_shell(source);
852 let mut staging = StagingGraph::new();
853 let builder = ShellGraphBuilder::default();
854 let file = PathBuf::from("test.sh");
855
856 builder
857 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
858 .unwrap();
859
860 assert_has_node_with_kind(&staging, "my_function", NodeKind::Function);
862 assert_has_node_with_kind(&staging, "my_helper", NodeKind::Function);
863
864 let call_edges = collect_call_edges(&staging);
866 assert_eq!(
867 call_edges.len(),
868 1,
869 "Expected 1 call edge (only to user function)"
870 );
871 assert_has_call_edge(&staging, "my_function", "my_helper");
872
873 assert!(
875 !staging.nodes().any(
876 |n| staging
877 .resolve_node_name(n.entry)
878 .is_some_and(|name| name == "echo"
879 || name == "cd"
880 || name == "ls"
881 || name == "pwd")
882 ),
883 "Builtin commands should not create nodes"
884 );
885 }
886
887 #[test]
888 fn test_exports_user_defined_functions() {
889 let source = r#"
891#!/bin/bash
892
893my_function() {
894 echo "exported function"
895}
896
897helper() {
898 return 0
899}
900"#;
901
902 let tree = parse_shell(source);
903 let mut staging = StagingGraph::new();
904 let builder = ShellGraphBuilder::default();
905 let file = PathBuf::from("functions.sh");
906
907 builder
908 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
909 .unwrap();
910
911 assert_has_node_with_kind(&staging, "my_function", NodeKind::Function);
913 assert_has_node_with_kind(&staging, "helper", NodeKind::Function);
914
915 let exports = collect_export_edges(&staging);
917 assert_eq!(exports.len(), 2, "Expected 2 function exports");
918 assert_has_export_edge(&staging, "functions::module", "my_function");
919 assert_has_export_edge(&staging, "functions::module", "helper");
920 }
921
922 #[test]
923 fn test_exports_exclude_builtins() {
924 let source = r#"
926#!/bin/bash
927
928my_script() {
929 echo "user function"
930 cd /tmp
931 ls -la
932}
933"#;
934
935 let tree = parse_shell(source);
936 let mut staging = StagingGraph::new();
937 let builder = ShellGraphBuilder::default();
938 let file = PathBuf::from("script.sh");
939
940 builder
941 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
942 .unwrap();
943
944 assert_has_node_with_kind(&staging, "my_script", NodeKind::Function);
946
947 let exports = collect_export_edges(&staging);
949 assert_eq!(
950 exports.len(),
951 1,
952 "Expected only 1 export (user function, not builtins)"
953 );
954 assert_has_export_edge(&staging, "script::module", "my_script");
955
956 assert!(
958 !staging.nodes().any(|n| staging
959 .resolve_node_name(n.entry)
960 .is_some_and(|name| name == "echo" || name == "cd" || name == "ls")),
961 "Builtins should not create nodes"
962 );
963 }
964
965 #[test]
966 fn test_export_uses_direct_kind() {
967 let source = r#"
969user_function() {
970 echo "test"
971}
972"#;
973
974 let tree = parse_shell(source);
975 let mut staging = StagingGraph::new();
976 let builder = ShellGraphBuilder::default();
977 let file = PathBuf::from("test.sh");
978
979 builder
980 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
981 .unwrap();
982
983 let exports = collect_export_edges(&staging);
985 assert_eq!(exports.len(), 1, "Expected 1 export");
986
987 if let Some(StagingOp::AddEdge {
989 kind: EdgeKind::Exports { kind, .. },
990 ..
991 }) = exports.first()
992 {
993 assert_eq!(
994 *kind,
995 ExportKind::Direct,
996 "Export should use ExportKind::Direct"
997 );
998 } else {
999 panic!("Expected Exports edge");
1000 }
1001 }
1002
1003 #[test]
1008 fn test_source_creates_import_edges() {
1009 let source = r"
1010#!/bin/bash
1011source ./config.sh
1012source /etc/profile.sh
1013";
1014
1015 let tree = parse_shell(source);
1016 let mut staging = StagingGraph::new();
1017 let builder = ShellGraphBuilder::default();
1018 let file = PathBuf::from("test.sh");
1019
1020 builder
1021 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
1022 .unwrap();
1023
1024 let imports = collect_import_edges(&staging);
1025 assert_eq!(imports.len(), 2, "Expected 2 import edges");
1026 assert_has_import_edge(&staging, "test::module", "./config.sh");
1027 assert_has_import_edge(&staging, "test::module", "/etc/profile.sh");
1028 }
1029
1030 #[test]
1031 fn test_dot_creates_import_edges() {
1032 let source = r"
1033#!/bin/bash
1034. ./init.sh
1035. config.sh
1036";
1037
1038 let tree = parse_shell(source);
1039 let mut staging = StagingGraph::new();
1040 let builder = ShellGraphBuilder::default();
1041 let file = PathBuf::from("test.sh");
1042
1043 builder
1044 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
1045 .unwrap();
1046
1047 let imports = collect_import_edges(&staging);
1048 assert_eq!(imports.len(), 2, "Expected 2 import edges");
1049 assert_has_import_edge(&staging, "test::module", "./init.sh");
1050 assert_has_import_edge(&staging, "test::module", "config.sh");
1051 }
1052
1053 #[test]
1054 fn test_source_inside_function() {
1055 let source = r"
1056load_config() {
1057 source ./config.sh
1058}
1059";
1060
1061 let tree = parse_shell(source);
1062 let mut staging = StagingGraph::new();
1063 let builder = ShellGraphBuilder::default();
1064 let file = PathBuf::from("test.sh");
1065
1066 builder
1067 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
1068 .unwrap();
1069
1070 let imports = collect_import_edges(&staging);
1071 assert_eq!(imports.len(), 1, "Expected 1 import edge");
1072 assert_has_import_edge(&staging, "load_config", "./config.sh");
1073 }
1074
1075 #[test]
1076 fn test_source_with_variable_expansion() {
1077 let source = r"
1078#!/bin/bash
1079source $CONFIG_DIR/file.sh
1080";
1081
1082 let tree = parse_shell(source);
1083 let mut staging = StagingGraph::new();
1084 let builder = ShellGraphBuilder::default();
1085 let file = PathBuf::from("test.sh");
1086
1087 builder
1088 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
1089 .unwrap();
1090
1091 let imports = collect_import_edges(&staging);
1092 assert_eq!(imports.len(), 1, "Expected 1 import edge");
1093 assert_has_import_edge(&staging, "test::module", "$CONFIG_DIR/file.sh");
1095 }
1096
1097 #[test]
1098 fn test_source_with_quoted_path() {
1099 let source = r#"
1100#!/bin/bash
1101source "./path with spaces.sh"
1102"#;
1103
1104 let tree = parse_shell(source);
1105 let mut staging = StagingGraph::new();
1106 let builder = ShellGraphBuilder::default();
1107 let file = PathBuf::from("test.sh");
1108
1109 builder
1110 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
1111 .unwrap();
1112
1113 let imports = collect_import_edges(&staging);
1114 assert_eq!(imports.len(), 1, "Expected 1 import edge");
1115 assert_has_import_edge(&staging, "test::module", "./path with spaces.sh");
1117 }
1118
1119 #[test]
1120 fn test_source_does_not_create_call_edge() {
1121 let source = r"
1122#!/bin/bash
1123source ./config.sh
1124. ./init.sh
1125";
1126
1127 let tree = parse_shell(source);
1128 let mut staging = StagingGraph::new();
1129 let builder = ShellGraphBuilder::default();
1130 let file = PathBuf::from("test.sh");
1131
1132 builder
1133 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
1134 .unwrap();
1135
1136 let call_edges = collect_call_edges(&staging);
1138 assert_eq!(
1139 call_edges.len(),
1140 0,
1141 "source/. commands should not create call edges"
1142 );
1143
1144 let imports = collect_import_edges(&staging);
1146 assert_eq!(imports.len(), 2, "Expected 2 import edges");
1147 }
1148
1149 #[test]
1150 fn test_builtin_filter_still_works_without_source() {
1151 let source = r#"
1153my_func() {
1154 echo "test"
1155 cd /tmp
1156 my_helper
1157}
1158
1159my_helper() {
1160 pwd
1161}
1162"#;
1163
1164 let tree = parse_shell(source);
1165 let mut staging = StagingGraph::new();
1166 let builder = ShellGraphBuilder::default();
1167 let file = PathBuf::from("test.sh");
1168
1169 builder
1170 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
1171 .unwrap();
1172
1173 let call_edges = collect_call_edges(&staging);
1175 assert_eq!(
1176 call_edges.len(),
1177 1,
1178 "Expected 1 call edge (only to user function)"
1179 );
1180 assert_has_call_edge(&staging, "my_func", "my_helper");
1181
1182 let imports = collect_import_edges(&staging);
1184 assert_eq!(imports.len(), 0, "Expected no import edges");
1185
1186 assert!(
1188 !staging.nodes().any(|n| staging
1189 .resolve_node_name(n.entry)
1190 .is_some_and(|name| name == "echo" || name == "cd" || name == "pwd")),
1191 "Builtin commands should not create nodes"
1192 );
1193 }
1194}
1195
1196pub struct ShellShapeMapping {
1205 cf_by_kind_id: Vec<Option<CfBucket>>,
1206}
1207
1208impl ShellShapeMapping {
1209 fn build() -> Self {
1210 let lang: tree_sitter::Language = tree_sitter_bash::LANGUAGE.into();
1211 let count = lang.node_kind_count();
1212 let mut cf_by_kind_id = vec![None; count];
1213 for (id, slot) in cf_by_kind_id.iter_mut().enumerate() {
1214 let Ok(kind_id) = u16::try_from(id) else {
1215 break;
1216 };
1217 if !lang.node_kind_is_named(kind_id) {
1218 continue;
1219 }
1220 if let Some(name) = lang.node_kind_for_id(kind_id) {
1221 *slot = cf_bucket_for_shell_kind(name);
1222 }
1223 }
1224 Self { cf_by_kind_id }
1225 }
1226}
1227
1228impl ShapeMapping for ShellShapeMapping {
1229 fn cf_bucket(&self, ts_node_kind_id: u16) -> Option<CfBucket> {
1230 self.cf_by_kind_id
1231 .get(ts_node_kind_id as usize)
1232 .copied()
1233 .flatten()
1234 }
1235
1236 fn signature_shape(&self, _fn_node: Node, _src: &[u8]) -> SignatureShape {
1237 SignatureShape::default()
1240 }
1241}
1242
1243fn cf_bucket_for_shell_kind(name: &str) -> Option<CfBucket> {
1247 let bucket = match name {
1248 "if_statement" | "elif_clause" | "else_clause" | "ternary_expression" => CfBucket::Branch,
1249 "while_statement" | "for_statement" | "c_style_for_statement" => CfBucket::Loop,
1250 "case_statement" | "case_item" => CfBucket::Match,
1251 "command" => CfBucket::Call,
1252 "variable_assignment" | "declaration_command" => CfBucket::Assign,
1253 "function_definition" => CfBucket::Closure,
1255 _ => return None,
1256 };
1257 Some(bucket)
1258}
1259
1260#[must_use]
1262pub fn shell_shape_mapping() -> &'static ShellShapeMapping {
1263 static MAPPING: OnceLock<ShellShapeMapping> = OnceLock::new();
1264 MAPPING.get_or_init(ShellShapeMapping::build)
1265}
1266
1267#[cfg(test)]
1268mod shape_tests {
1269 use super::{cf_bucket_for_shell_kind, shell_shape_mapping};
1273 use sqry_core::graph::unified::build::shape::{
1274 CfBucket, ShapeBudget, ShapeMapping, compute_shape_descriptor,
1275 };
1276 use tree_sitter::{Node, Parser, Tree};
1277
1278 const SAMPLE: &str = include_str!(concat!(
1279 env!("CARGO_MANIFEST_DIR"),
1280 "/../test-fixtures/shape/dynamic/script.sh"
1281 ));
1282
1283 fn parse(src: &str) -> Tree {
1284 let mut parser = Parser::new();
1285 parser
1286 .set_language(&tree_sitter_bash::LANGUAGE.into())
1287 .expect("load bash grammar");
1288 parser.parse(src, None).expect("parse bash")
1289 }
1290
1291 fn first_function<'t>(tree: &'t Tree) -> Node<'t> {
1292 let root = tree.root_node();
1293 let mut cursor = root.walk();
1294 for child in root.named_children(&mut cursor) {
1295 if child.kind() == "function_definition" {
1296 return child;
1297 }
1298 }
1299 panic!("no function_definition in shell fixture");
1300 }
1301
1302 #[test]
1303 fn mapping_is_non_empty_and_covers_real_kinds() {
1304 assert_eq!(
1305 cf_bucket_for_shell_kind("if_statement"),
1306 Some(CfBucket::Branch)
1307 );
1308 assert_eq!(
1309 cf_bucket_for_shell_kind("while_statement"),
1310 Some(CfBucket::Loop)
1311 );
1312 assert_eq!(
1313 cf_bucket_for_shell_kind("for_statement"),
1314 Some(CfBucket::Loop)
1315 );
1316 assert_eq!(
1317 cf_bucket_for_shell_kind("case_statement"),
1318 Some(CfBucket::Match)
1319 );
1320 assert_eq!(cf_bucket_for_shell_kind("command"), Some(CfBucket::Call));
1321 assert_eq!(
1322 cf_bucket_for_shell_kind("variable_assignment"),
1323 Some(CfBucket::Assign)
1324 );
1325 assert_eq!(cf_bucket_for_shell_kind("nope"), None);
1326
1327 let lang: tree_sitter::Language = tree_sitter_bash::LANGUAGE.into();
1328 let id = (0..lang.node_kind_count())
1329 .map(|i| i as u16)
1330 .find(|&i| {
1331 lang.node_kind_is_named(i) && lang.node_kind_for_id(i) == Some("if_statement")
1332 })
1333 .expect("grammar exposes named if_statement");
1334 assert_eq!(shell_shape_mapping().cf_bucket(id), Some(CfBucket::Branch));
1335 }
1336
1337 #[test]
1338 fn descriptor_covers_fixture_control_flow() {
1339 let tree = parse(SAMPLE);
1340 let func = first_function(&tree);
1341 let descriptor = compute_shape_descriptor(
1342 func,
1343 SAMPLE.as_bytes(),
1344 shell_shape_mapping(),
1345 &ShapeBudget::default(),
1346 );
1347 let hist = descriptor.cf_histogram;
1348 assert!(hist[CfBucket::Branch.index()] >= 1, "branch (if/elif)");
1349 assert!(hist[CfBucket::Loop.index()] >= 1, "loop (while/for/until)");
1350 assert!(hist[CfBucket::Match.index()] >= 1, "case");
1351 assert!(hist[CfBucket::Call.index()] >= 1, "command");
1352 assert!(hist[CfBucket::Assign.index()] >= 1, "assignment");
1353 }
1354
1355 #[test]
1356 fn signature_shape_is_minimal() {
1357 let tree = parse(SAMPLE);
1358 let func = first_function(&tree);
1359 let shape = shell_shape_mapping().signature_shape(func, SAMPLE.as_bytes());
1360 assert_eq!(shape.arity_positional, 0);
1362 assert_eq!(shape.arity_keyword_only, 0);
1363 assert!(!shape.has_varargs);
1364 }
1365}