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