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