1use std::{
9 collections::{HashMap, HashSet},
10 path::Path,
11};
12
13use sqry_core::graph::unified::edge::ExportKind;
14use sqry_core::graph::{
15 GraphBuilder, GraphBuilderError, GraphResult, Language, Span,
16 unified::{GraphBuildHelper, StagingGraph},
17};
18use tree_sitter::{Node, StreamingIterator, Tree};
19
20pub struct ShellGraphBuilder {
22 max_scope_depth: usize,
23}
24
25impl Default for ShellGraphBuilder {
26 fn default() -> Self {
27 Self {
28 max_scope_depth: 2, }
30 }
31}
32
33impl GraphBuilder for ShellGraphBuilder {
34 fn language(&self) -> Language {
35 Language::Shell
36 }
37
38 #[allow(clippy::too_many_lines)]
40 fn build_graph(
41 &self,
42 tree: &Tree,
43 content: &[u8],
44 file: &Path,
45 staging: &mut StagingGraph,
46 ) -> GraphResult<()> {
47 let mut helper = GraphBuildHelper::new(staging, file, Language::Shell);
49
50 let ast_graph = ASTGraph::from_tree(tree, content, self.max_scope_depth).map_err(|e| {
52 GraphBuilderError::ParseError {
53 span: Span::default(),
54 reason: e,
55 }
56 })?;
57
58 let script_name = file
60 .file_stem()
61 .and_then(|s| s.to_str())
62 .unwrap_or("script");
63 let module_qualified = format!("{script_name}::module");
64 let module_id =
65 helper.add_module(&module_qualified, Some(Span::from_bytes(0, content.len())));
66
67 for context in ast_graph.contexts() {
72 let qualified = context.qualified_name();
73 let span = Span::from_bytes(context.span.0, context.span.1);
74 let visibility = extract_visibility(&qualified);
75 let function_id = helper.add_function_with_visibility(
76 &qualified,
77 Some(span),
78 false,
79 false,
80 Some(visibility),
81 );
82 helper.add_export_edge_full(module_id, function_id, ExportKind::Direct, None);
84 }
85
86 let mut exported_variables = HashSet::new();
88 let root = tree.root_node();
89 let mut root_cursor = root.walk();
90 for command in root.children(&mut root_cursor) {
91 match command.kind() {
92 "command" | "declaration_command" => {}
93 _ => continue,
94 }
95
96 let mut cmd_cursor = command.walk();
97 let mut command_name: Option<String> = None;
98 let mut arg_nodes: Vec<Node> = Vec::new();
99
100 for child in command.children(&mut cmd_cursor) {
101 match child.kind() {
102 "export" | "word" | "command_name" | "variable_name" => {
103 if command_name.is_none() {
104 command_name = Some(get_node_text(child, content)?);
105 } else {
106 arg_nodes.push(child);
107 }
108 }
109 "variable_assignment" => arg_nodes.push(child),
110 _ => {}
111 }
112 }
113
114 let Some(command_name) = command_name else {
115 continue;
116 };
117 if command_name != "export" {
118 continue;
119 }
120
121 let mut mark_next_as_function = false;
122 for arg_node in arg_nodes {
123 match arg_node.kind() {
124 "word" | "command_name" | "variable_name" => {
125 let text = get_node_text(arg_node, content)?;
126 if text == "-f" {
127 mark_next_as_function = true;
128 continue;
129 }
130 if text.starts_with('-') {
131 continue;
132 }
133 if mark_next_as_function {
134 mark_next_as_function = false;
135 continue;
136 }
137
138 if exported_variables.insert(text.clone()) {
139 let var_id = helper.add_variable(&text, Some(span_from_node(arg_node)));
140 helper.add_export_edge_full(
141 module_id,
142 var_id,
143 ExportKind::Direct,
144 None,
145 );
146 }
147 }
148 "variable_assignment" => {
149 if let Some(name_node) = arg_node.child_by_field_name("name") {
150 let name = get_node_text(name_node, content)?;
151 if exported_variables.insert(name.clone()) {
152 let var_id =
153 helper.add_variable(&name, Some(span_from_node(name_node)));
154 helper.add_export_edge_full(
155 module_id,
156 var_id,
157 ExportKind::Direct,
158 None,
159 );
160 }
161 }
162 }
163 _ => {}
164 }
165 }
166 }
167
168 let mut stack = vec![tree.root_node()];
170 let mut visited = HashSet::new();
171
172 while let Some(node) = stack.pop() {
173 let node_id = node.id();
174
175 if !visited.insert(node_id) {
177 continue;
178 }
179
180 match node.kind() {
182 "comment" | "string" | "raw_string" | "ansi_c_string" => {
183 continue;
184 }
185 _ => {}
186 }
187
188 if node.kind() == "command" {
190 if let Some((importer_qname, imported_path, span)) =
192 build_import_edge_for_staging(&ast_graph, node, content, &module_qualified)?
193 {
194 let from_id = helper.add_import(&importer_qname, None);
195 let to_id = helper.add_import(&imported_path, Some(span));
196 helper.add_import_edge(from_id, to_id);
197 }
198 else if let Some((caller_qname, callee_qname, argument_count, span)) =
200 build_call_edge_for_staging(&ast_graph, node, content, &module_qualified)?
201 {
202 let source_id = helper.ensure_function(&caller_qname, None, false, false);
203 let target_id = helper.ensure_function(&callee_qname, None, false, false);
204
205 let argument_count = u8::try_from(argument_count).unwrap_or(u8::MAX);
206 helper.add_call_edge_full_with_span(
207 source_id,
208 target_id,
209 argument_count,
210 false,
211 vec![span],
212 );
213 }
214 }
215
216 let mut cursor = node.walk();
218 for child in node.children(&mut cursor) {
219 stack.push(child);
220 }
221 }
222
223 Ok(())
224 }
225}
226
227fn build_call_edge_for_staging(
234 ast_graph: &ASTGraph,
235 call_node: Node,
236 content: &[u8],
237 module_name: &str,
238) -> GraphResult<Option<(String, String, usize, Span)>> {
239 let module_context;
241 let call_context = if let Some(ctx) = ast_graph.get_callable_context(call_node.id()) {
242 ctx
243 } else {
244 module_context = CallContext {
246 qualified_name: module_name.to_string(),
247 span: (0, content.len()),
248 };
249 &module_context
250 };
251
252 let Some(name_node) = call_node.child_by_field_name("name") else {
254 return Ok(None);
255 };
256
257 let callee_text = get_node_text(name_node, content)?;
258
259 if callee_text.is_empty() {
260 return Ok(None);
261 }
262
263 if is_builtin_command(&callee_text) {
265 return Ok(None);
266 }
267
268 let is_user_defined = ast_graph
270 .contexts()
271 .iter()
272 .any(|ctx| ctx.qualified_name() == callee_text);
273
274 if !is_user_defined {
275 return Ok(None);
276 }
277
278 let target_qname = callee_text.clone();
279 let source_qname = call_context.qualified_name();
280
281 let span = span_from_node(call_node);
282 let argument_count = count_arguments(call_node);
283
284 Ok(Some((source_qname, target_qname, argument_count, span)))
285}
286
287fn is_builtin_command(cmd: &str) -> bool {
289 matches!(
291 cmd,
292 "echo"
293 | "cd"
294 | "pwd"
295 | "ls"
296 | "cat"
297 | "grep"
298 | "sed"
299 | "awk"
300 | "test"
301 | "["
302 | "[["
303 | "printf"
304 | "read"
305 | "set"
306 | "unset"
307 | "export"
308 | "alias"
309 | "unalias"
310 | "bg"
311 | "fg"
312 | "jobs"
313 | "kill"
314 | "wait"
315 | "eval"
316 | "exec"
317 | "exit"
318 | "return"
319 | "shift"
320 | "trap"
321 | "umask"
322 | "readonly"
323 | "local"
324 | "declare"
325 | "typeset"
326 | "enable"
327 | "help"
328 | "let"
329 | "break"
330 | "continue"
331 | "true"
332 | "false"
333 | ":"
334 | "getopts"
335 | "hash"
336 | "type"
337 | "times"
338 | "ulimit"
339 | "shopt"
340 | "complete"
341 | "compgen"
342 | "fc"
343 | "history"
344 | "pushd"
345 | "popd"
346 | "dirs"
347 | "bind"
348 | "builtin"
349 | "command"
350 | "mapfile"
351 | "readarray"
352 | "caller"
353 | "disown"
354 | "suspend"
355 | "compopt"
356 )
357}
358
359fn is_source_command(cmd: &str) -> bool {
361 matches!(cmd, "source" | ".")
362}
363
364fn build_import_edge_for_staging(
369 ast_graph: &ASTGraph,
370 command_node: Node,
371 content: &[u8],
372 module_name: &str,
373) -> GraphResult<Option<(String, String, Span)>> {
374 let Some(name_node) = command_node.child_by_field_name("name") else {
376 return Ok(None);
377 };
378
379 let cmd_text = get_node_text(name_node, content)?;
380 if !is_source_command(&cmd_text) {
381 return Ok(None);
382 }
383
384 let mut arg_node = None;
386 let mut cursor = command_node.walk();
387 let mut past_name = false;
388 for child in command_node.children(&mut cursor) {
389 if child.id() == name_node.id() {
390 past_name = true;
391 continue;
392 }
393 if past_name {
394 match child.kind() {
395 "word" | "string" | "raw_string" | "simple_expansion" | "expansion"
396 | "concatenation" => {
397 arg_node = Some(child);
398 break;
399 }
400 _ => {}
401 }
402 }
403 }
404
405 let Some(arg) = arg_node else {
406 return Ok(None);
407 };
408
409 let imported_path = extract_source_path(arg, content)?;
411 if imported_path.is_empty() {
412 return Ok(None);
413 }
414
415 let importer_qname = if let Some(ctx) = ast_graph.get_callable_context(command_node.id()) {
417 ctx.qualified_name()
418 } else {
419 module_name.to_string()
420 };
421
422 let span = span_from_node(command_node);
423 Ok(Some((importer_qname, imported_path, span)))
424}
425
426fn extract_source_path(node: Node, content: &[u8]) -> GraphResult<String> {
434 match node.kind() {
435 "string" | "raw_string" => {
436 let text = get_node_text(node, content)?;
437 let stripped = text
439 .strip_prefix('"')
440 .and_then(|s| s.strip_suffix('"'))
441 .or_else(|| text.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')))
442 .or_else(|| text.strip_prefix("$'").and_then(|s| s.strip_suffix('\'')))
443 .unwrap_or(&text);
444 Ok(stripped.to_string())
445 }
446 _ => get_node_text(node, content),
448 }
449}
450
451fn count_arguments(call_node: Node) -> usize {
453 let mut count: usize = 0;
454 let mut cursor = call_node.walk();
455
456 for child in call_node.children(&mut cursor) {
457 match child.kind() {
458 "word"
459 | "string"
460 | "raw_string"
461 | "ansi_c_string"
462 | "simple_expansion"
463 | "expansion"
464 | "command_substitution" => {
465 count += 1;
466 }
467 _ => {}
468 }
469 }
470
471 count.saturating_sub(1)
473}
474
475fn span_from_node(node: Node) -> Span {
477 Span::from_bytes(node.start_byte(), node.end_byte())
478}
479
480fn get_node_text(node: Node, content: &[u8]) -> GraphResult<String> {
482 node.utf8_text(content)
483 .map(|s| s.trim().to_string())
484 .map_err(|_| GraphBuilderError::ParseError {
485 span: span_from_node(node),
486 reason: "invalid UTF-8".to_string(),
487 })
488}
489
490#[derive(Debug, Clone)]
495struct CallContext {
496 qualified_name: String,
497 span: (usize, usize),
498}
499
500impl CallContext {
501 fn qualified_name(&self) -> String {
502 self.qualified_name.clone()
503 }
504}
505
506struct ASTGraph {
507 contexts: Vec<CallContext>,
508 node_to_context: HashMap<usize, usize>,
509}
510
511impl ASTGraph {
512 fn from_tree(tree: &Tree, content: &[u8], _max_depth: usize) -> Result<Self, String> {
513 let mut contexts = Vec::new();
514 let mut node_to_context = HashMap::new();
515
516 let query = tree_sitter::Query::new(
518 &tree_sitter_bash::LANGUAGE.into(),
519 r"(function_definition name: (word) @function_name) @function_node",
520 )
521 .map_err(|e| format!("Failed to create query: {e}"))?;
522
523 let mut cursor = tree_sitter::QueryCursor::new();
524 let root = tree.root_node();
525 let capture_names = query.capture_names();
526 let mut matches = cursor.matches(&query, root, content);
527
528 while let Some(m) = matches.next() {
529 let mut name_node = None;
530 let mut func_node = None;
531
532 for capture in m.captures {
533 let capture_name = capture_names[capture.index as usize];
534 match capture_name {
535 "function_name" => name_node = Some(capture.node),
536 "function_node" => func_node = Some(capture.node),
537 _ => {}
538 }
539 }
540
541 let (Some(name_node), Some(func_node)) = (name_node, func_node) else {
542 continue;
543 };
544
545 let function_name = name_node
546 .utf8_text(content)
547 .map_err(|_| "failed to read function name".to_string())?
548 .to_string();
549
550 let context_idx = contexts.len();
551 contexts.push(CallContext {
552 qualified_name: function_name,
553 span: (func_node.start_byte(), func_node.end_byte()),
554 });
555
556 map_descendants_to_context(func_node, &mut node_to_context, context_idx);
558 }
559
560 Ok(Self {
561 contexts,
562 node_to_context,
563 })
564 }
565
566 fn contexts(&self) -> &[CallContext] {
567 &self.contexts
568 }
569
570 fn get_callable_context(&self, node_id: usize) -> Option<&CallContext> {
571 self.node_to_context
572 .get(&node_id)
573 .and_then(|idx| self.contexts.get(*idx))
574 }
575}
576
577fn extract_visibility(_name: &str) -> &'static str {
583 "public"
584}
585
586fn map_descendants_to_context(node: Node, map: &mut HashMap<usize, usize>, context_idx: usize) {
588 map.insert(node.id(), context_idx);
589
590 let mut cursor = node.walk();
591 for child in node.children(&mut cursor) {
592 map_descendants_to_context(child, map, context_idx);
593 }
594}
595
596#[cfg(test)]
597mod tests {
598 use super::*;
599 use sqry_core::graph::unified::build::{StagingOp, test_helpers::*};
600 use sqry_core::graph::unified::edge::{EdgeKind, ExportKind};
601 use sqry_core::graph::unified::node::NodeKind;
602 use std::path::PathBuf;
603
604 fn parse_shell(source: &str) -> Tree {
605 let mut parser = tree_sitter::Parser::new();
606 parser
607 .set_language(&tree_sitter_bash::LANGUAGE.into())
608 .expect("failed to set language");
609 parser.parse(source, None).expect("failed to parse")
610 }
611
612 #[test]
613 fn test_extracts_posix_functions() {
614 let source = r#"
615foo() {
616 echo "foo"
617}
618
619bar() {
620 echo "bar"
621}
622"#;
623
624 let tree = parse_shell(source);
625 let mut staging = StagingGraph::new();
626 let builder = ShellGraphBuilder::default();
627 let file = PathBuf::from("test.sh");
628
629 builder
630 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
631 .unwrap();
632
633 assert_has_node_with_kind(&staging, "test::module", NodeKind::Module);
635
636 assert_has_node_with_kind(&staging, "foo", NodeKind::Function);
638 assert_has_node_with_kind(&staging, "bar", NodeKind::Function);
639
640 let exports = collect_export_edges(&staging);
642 assert_eq!(exports.len(), 2, "Expected 2 function exports");
643 assert_has_export_edge(&staging, "test::module", "foo");
644 assert_has_export_edge(&staging, "test::module", "bar");
645 }
646
647 #[test]
648 fn test_extracts_bash_functions() {
649 let source = r#"
650function foo {
651 echo "foo"
652}
653
654function bar() {
655 echo "bar"
656}
657"#;
658
659 let tree = parse_shell(source);
660 let mut staging = StagingGraph::new();
661 let builder = ShellGraphBuilder::default();
662 let file = PathBuf::from("test.sh");
663
664 builder
665 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
666 .unwrap();
667
668 assert_has_node_with_kind(&staging, "test::module", NodeKind::Module);
670
671 assert_has_node_with_kind(&staging, "foo", NodeKind::Function);
673 assert_has_node_with_kind(&staging, "bar", NodeKind::Function);
674
675 let exports = collect_export_edges(&staging);
677 assert_eq!(exports.len(), 2, "Expected 2 function exports");
678 assert_has_export_edge(&staging, "test::module", "foo");
679 assert_has_export_edge(&staging, "test::module", "bar");
680 }
681
682 #[test]
683 fn test_creates_call_edges() {
684 let source = r#"
685caller() {
686 callee
687}
688
689callee() {
690 echo "callee"
691}
692"#;
693
694 let tree = parse_shell(source);
695 let mut staging = StagingGraph::new();
696 let builder = ShellGraphBuilder::default();
697 let file = PathBuf::from("test.sh");
698
699 builder
700 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
701 .unwrap();
702
703 assert_has_node_with_kind(&staging, "caller", NodeKind::Function);
705 assert_has_node_with_kind(&staging, "callee", NodeKind::Function);
706
707 let call_edges = collect_call_edges(&staging);
709 assert_eq!(call_edges.len(), 1, "Expected 1 call edge");
710 assert_has_call_edge(&staging, "caller", "callee");
711 }
712
713 #[test]
714 fn test_script_module_node_always_present() {
715 let source = r"
717#!/bin/bash
718# Empty script with no functions
719";
720
721 let tree = parse_shell(source);
722 let mut staging = StagingGraph::new();
723 let builder = ShellGraphBuilder::default();
724 let file = PathBuf::from("test.sh");
725
726 builder
727 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
728 .unwrap();
729
730 assert_has_node_with_kind(&staging, "test::module", NodeKind::Module);
732
733 assert_eq!(count_nodes_by_kind(&staging, NodeKind::Function), 0);
735 let exports = collect_export_edges(&staging);
736 assert_eq!(exports.len(), 0, "Expected no exports for empty script");
737 }
738
739 #[test]
740 fn test_script_name_function_collision() {
741 let source = r#"
743#!/bin/bash
744
745deploy() {
746 helper
747}
748
749helper() {
750 echo "hi"
751}
752
753deploy
754"#;
755
756 let tree = parse_shell(source);
757 let mut staging = StagingGraph::new();
758 let builder = ShellGraphBuilder::default();
759 let file = PathBuf::from("deploy.sh");
760
761 builder
762 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
763 .unwrap();
764
765 assert_has_node_with_kind(&staging, "deploy::module", NodeKind::Module);
767 assert_has_node_with_kind(&staging, "deploy", NodeKind::Function);
768 assert_has_node_with_kind(&staging, "helper", NodeKind::Function);
769
770 assert_has_call_edge(&staging, "deploy", "helper");
772
773 assert_has_call_edge(&staging, "deploy::module", "deploy");
775 }
776
777 #[test]
778 fn test_filters_external_tools() {
779 let source = r#"
781deploy() {
782 git status
783 kubectl apply -f deployment.yaml
784 docker build -t myimage .
785 my_helper
786}
787
788my_helper() {
789 echo "ok"
790}
791"#;
792
793 let tree = parse_shell(source);
794 let mut staging = StagingGraph::new();
795 let builder = ShellGraphBuilder::default();
796 let file = PathBuf::from("test.sh");
797
798 builder
799 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
800 .unwrap();
801
802 assert_has_node_with_kind(&staging, "deploy", NodeKind::Function);
804 assert_has_node_with_kind(&staging, "my_helper", NodeKind::Function);
805
806 let call_edges = collect_call_edges(&staging);
808 assert_eq!(
809 call_edges.len(),
810 1,
811 "Expected 1 call edge (only to user function)"
812 );
813 assert_has_call_edge(&staging, "deploy", "my_helper");
814
815 assert!(
817 !staging.nodes().any(|n| staging
818 .resolve_node_name(n.entry)
819 .is_some_and(|name| name.contains("git")
820 || name.contains("kubectl")
821 || name.contains("docker"))),
822 "External tools should not create nodes"
823 );
824 }
825
826 #[test]
827 fn test_filters_builtin_commands() {
828 let source = r#"
829my_function() {
830 echo "test"
831 cd /tmp
832 ls -la
833 my_helper
834}
835
836my_helper() {
837 pwd
838}
839"#;
840
841 let tree = parse_shell(source);
842 let mut staging = StagingGraph::new();
843 let builder = ShellGraphBuilder::default();
844 let file = PathBuf::from("test.sh");
845
846 builder
847 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
848 .unwrap();
849
850 assert_has_node_with_kind(&staging, "my_function", NodeKind::Function);
852 assert_has_node_with_kind(&staging, "my_helper", NodeKind::Function);
853
854 let call_edges = collect_call_edges(&staging);
856 assert_eq!(
857 call_edges.len(),
858 1,
859 "Expected 1 call edge (only to user function)"
860 );
861 assert_has_call_edge(&staging, "my_function", "my_helper");
862
863 assert!(
865 !staging.nodes().any(
866 |n| staging
867 .resolve_node_name(n.entry)
868 .is_some_and(|name| name == "echo"
869 || name == "cd"
870 || name == "ls"
871 || name == "pwd")
872 ),
873 "Builtin commands should not create nodes"
874 );
875 }
876
877 #[test]
878 fn test_exports_user_defined_functions() {
879 let source = r#"
881#!/bin/bash
882
883my_function() {
884 echo "exported function"
885}
886
887helper() {
888 return 0
889}
890"#;
891
892 let tree = parse_shell(source);
893 let mut staging = StagingGraph::new();
894 let builder = ShellGraphBuilder::default();
895 let file = PathBuf::from("functions.sh");
896
897 builder
898 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
899 .unwrap();
900
901 assert_has_node_with_kind(&staging, "my_function", NodeKind::Function);
903 assert_has_node_with_kind(&staging, "helper", NodeKind::Function);
904
905 let exports = collect_export_edges(&staging);
907 assert_eq!(exports.len(), 2, "Expected 2 function exports");
908 assert_has_export_edge(&staging, "functions::module", "my_function");
909 assert_has_export_edge(&staging, "functions::module", "helper");
910 }
911
912 #[test]
913 fn test_exports_exclude_builtins() {
914 let source = r#"
916#!/bin/bash
917
918my_script() {
919 echo "user function"
920 cd /tmp
921 ls -la
922}
923"#;
924
925 let tree = parse_shell(source);
926 let mut staging = StagingGraph::new();
927 let builder = ShellGraphBuilder::default();
928 let file = PathBuf::from("script.sh");
929
930 builder
931 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
932 .unwrap();
933
934 assert_has_node_with_kind(&staging, "my_script", NodeKind::Function);
936
937 let exports = collect_export_edges(&staging);
939 assert_eq!(
940 exports.len(),
941 1,
942 "Expected only 1 export (user function, not builtins)"
943 );
944 assert_has_export_edge(&staging, "script::module", "my_script");
945
946 assert!(
948 !staging.nodes().any(|n| staging
949 .resolve_node_name(n.entry)
950 .is_some_and(|name| name == "echo" || name == "cd" || name == "ls")),
951 "Builtins should not create nodes"
952 );
953 }
954
955 #[test]
956 fn test_export_uses_direct_kind() {
957 let source = r#"
959user_function() {
960 echo "test"
961}
962"#;
963
964 let tree = parse_shell(source);
965 let mut staging = StagingGraph::new();
966 let builder = ShellGraphBuilder::default();
967 let file = PathBuf::from("test.sh");
968
969 builder
970 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
971 .unwrap();
972
973 let exports = collect_export_edges(&staging);
975 assert_eq!(exports.len(), 1, "Expected 1 export");
976
977 if let Some(StagingOp::AddEdge {
979 kind: EdgeKind::Exports { kind, .. },
980 ..
981 }) = exports.first()
982 {
983 assert_eq!(
984 *kind,
985 ExportKind::Direct,
986 "Export should use ExportKind::Direct"
987 );
988 } else {
989 panic!("Expected Exports edge");
990 }
991 }
992
993 #[test]
998 fn test_source_creates_import_edges() {
999 let source = r#"
1000#!/bin/bash
1001source ./config.sh
1002source /etc/profile.sh
1003"#;
1004
1005 let tree = parse_shell(source);
1006 let mut staging = StagingGraph::new();
1007 let builder = ShellGraphBuilder::default();
1008 let file = PathBuf::from("test.sh");
1009
1010 builder
1011 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
1012 .unwrap();
1013
1014 let imports = collect_import_edges(&staging);
1015 assert_eq!(imports.len(), 2, "Expected 2 import edges");
1016 assert_has_import_edge(&staging, "test::module", "./config.sh");
1017 assert_has_import_edge(&staging, "test::module", "/etc/profile.sh");
1018 }
1019
1020 #[test]
1021 fn test_dot_creates_import_edges() {
1022 let source = r#"
1023#!/bin/bash
1024. ./init.sh
1025. config.sh
1026"#;
1027
1028 let tree = parse_shell(source);
1029 let mut staging = StagingGraph::new();
1030 let builder = ShellGraphBuilder::default();
1031 let file = PathBuf::from("test.sh");
1032
1033 builder
1034 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
1035 .unwrap();
1036
1037 let imports = collect_import_edges(&staging);
1038 assert_eq!(imports.len(), 2, "Expected 2 import edges");
1039 assert_has_import_edge(&staging, "test::module", "./init.sh");
1040 assert_has_import_edge(&staging, "test::module", "config.sh");
1041 }
1042
1043 #[test]
1044 fn test_source_inside_function() {
1045 let source = r#"
1046load_config() {
1047 source ./config.sh
1048}
1049"#;
1050
1051 let tree = parse_shell(source);
1052 let mut staging = StagingGraph::new();
1053 let builder = ShellGraphBuilder::default();
1054 let file = PathBuf::from("test.sh");
1055
1056 builder
1057 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
1058 .unwrap();
1059
1060 let imports = collect_import_edges(&staging);
1061 assert_eq!(imports.len(), 1, "Expected 1 import edge");
1062 assert_has_import_edge(&staging, "load_config", "./config.sh");
1063 }
1064
1065 #[test]
1066 fn test_source_with_variable_expansion() {
1067 let source = r#"
1068#!/bin/bash
1069source $CONFIG_DIR/file.sh
1070"#;
1071
1072 let tree = parse_shell(source);
1073 let mut staging = StagingGraph::new();
1074 let builder = ShellGraphBuilder::default();
1075 let file = PathBuf::from("test.sh");
1076
1077 builder
1078 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
1079 .unwrap();
1080
1081 let imports = collect_import_edges(&staging);
1082 assert_eq!(imports.len(), 1, "Expected 1 import edge");
1083 assert_has_import_edge(&staging, "test::module", "$CONFIG_DIR/file.sh");
1085 }
1086
1087 #[test]
1088 fn test_source_with_quoted_path() {
1089 let source = r#"
1090#!/bin/bash
1091source "./path with spaces.sh"
1092"#;
1093
1094 let tree = parse_shell(source);
1095 let mut staging = StagingGraph::new();
1096 let builder = ShellGraphBuilder::default();
1097 let file = PathBuf::from("test.sh");
1098
1099 builder
1100 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
1101 .unwrap();
1102
1103 let imports = collect_import_edges(&staging);
1104 assert_eq!(imports.len(), 1, "Expected 1 import edge");
1105 assert_has_import_edge(&staging, "test::module", "./path with spaces.sh");
1107 }
1108
1109 #[test]
1110 fn test_source_does_not_create_call_edge() {
1111 let source = r#"
1112#!/bin/bash
1113source ./config.sh
1114. ./init.sh
1115"#;
1116
1117 let tree = parse_shell(source);
1118 let mut staging = StagingGraph::new();
1119 let builder = ShellGraphBuilder::default();
1120 let file = PathBuf::from("test.sh");
1121
1122 builder
1123 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
1124 .unwrap();
1125
1126 let call_edges = collect_call_edges(&staging);
1128 assert_eq!(
1129 call_edges.len(),
1130 0,
1131 "source/. commands should not create call edges"
1132 );
1133
1134 let imports = collect_import_edges(&staging);
1136 assert_eq!(imports.len(), 2, "Expected 2 import edges");
1137 }
1138
1139 #[test]
1140 fn test_builtin_filter_still_works_without_source() {
1141 let source = r#"
1143my_func() {
1144 echo "test"
1145 cd /tmp
1146 my_helper
1147}
1148
1149my_helper() {
1150 pwd
1151}
1152"#;
1153
1154 let tree = parse_shell(source);
1155 let mut staging = StagingGraph::new();
1156 let builder = ShellGraphBuilder::default();
1157 let file = PathBuf::from("test.sh");
1158
1159 builder
1160 .build_graph(&tree, source.as_bytes(), &file, &mut staging)
1161 .unwrap();
1162
1163 let call_edges = collect_call_edges(&staging);
1165 assert_eq!(
1166 call_edges.len(),
1167 1,
1168 "Expected 1 call edge (only to user function)"
1169 );
1170 assert_has_call_edge(&staging, "my_func", "my_helper");
1171
1172 let imports = collect_import_edges(&staging);
1174 assert_eq!(imports.len(), 0, "Expected no import edges");
1175
1176 assert!(
1178 !staging.nodes().any(|n| staging
1179 .resolve_node_name(n.entry)
1180 .is_some_and(|name| name == "echo" || name == "cd" || name == "pwd")),
1181 "Builtin commands should not create nodes"
1182 );
1183 }
1184}