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