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