Skip to main content

sqry_lang_shell/relations/
graph_builder.rs

1//! `GraphBuilder` for Shell scripts using manual tree walking approach.
2//!
3//! Extracts function definitions, call edges, and import edges from Shell/Bash scripts.
4//! Handles both POSIX (`foo() { ... }`) and Bash (`function foo { ... }`) syntax.
5//! Filters out built-in commands to avoid synthetic nodes for shell builtins.
6//! Detects `source` and `.` commands as import edges for cross-file module inclusion.
7
8use 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
20/// `GraphBuilder` for Shell scripts
21pub struct ShellGraphBuilder {
22    max_scope_depth: usize,
23}
24
25impl Default for ShellGraphBuilder {
26    fn default() -> Self {
27        Self {
28            max_scope_depth: 2, // Shell typically has flat structure (script -> function)
29        }
30    }
31}
32
33impl GraphBuilder for ShellGraphBuilder {
34    fn language(&self) -> Language {
35        Language::Shell
36    }
37
38    // Shell graph extraction is linear and benefits from a single pass.
39    #[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        // Create helper for staging graph population
48        let mut helper = GraphBuildHelper::new(staging, file, Language::Shell);
49
50        // Build AST metadata to track function contexts
51        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        // Phase 0: ALWAYS create script-level module node (entry point)
59        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        // Phase 1: Insert function contexts as nodes and emit Export edges
68        // All shell functions are exported from the script module
69        // DESIGN: Only user-defined functions (from function_definition AST nodes) are exported.
70        // Shell builtins are never defined as function_definition nodes, so they're automatically excluded.
71        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            // Export all user-defined functions using ExportKind::Direct
83            helper.add_export_edge_full(module_id, function_id, ExportKind::Direct, None);
84        }
85
86        // Phase 1b: Export variables referenced in explicit `export` commands.
87        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        // Phase 2: Traverse tree to collect call edges
169        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            // Skip if already visited (prevents infinite loops)
176            if !visited.insert(node_id) {
177                continue;
178            }
179
180            // Skip non-code nodes
181            match node.kind() {
182                "comment" | "string" | "raw_string" | "ansi_c_string" => {
183                    continue;
184                }
185                _ => {}
186            }
187
188            // Detect command invocations
189            if node.kind() == "command" {
190                // Check for import commands (source/.) first
191                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                // Then check for call edges (user-defined function calls)
199                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            // Traverse children
217            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
227// ============================================================================
228// Helper Functions
229// ============================================================================
230
231/// Build call edge information for the staging graph.
232/// Returns (`caller_qname`, `callee_qname`, `argument_count`, span) tuple.
233fn 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    // Find the calling context (which function is this call in?)
240    let module_context;
241    let call_context = if let Some(ctx) = ast_graph.get_callable_context(call_node.id()) {
242        ctx
243    } else {
244        // Script-level call - use module-qualified name as context
245        module_context = CallContext {
246            qualified_name: module_name.to_string(),
247            span: (0, content.len()),
248        };
249        &module_context
250    };
251
252    // Extract the command name
253    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    // CRITICAL: Filter out shell built-in commands
264    if is_builtin_command(&callee_text) {
265        return Ok(None);
266    }
267
268    // DESIGN REQUIREMENT: Only create call edges for user-defined functions
269    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
287/// Check if a command is a shell built-in
288fn is_builtin_command(cmd: &str) -> bool {
289    // Common POSIX and Bash built-ins
290    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
359/// Check if a command is a `source` or `.` (dot) import command
360fn is_source_command(cmd: &str) -> bool {
361    matches!(cmd, "source" | ".")
362}
363
364/// Build import edge information for `source` and `.` commands.
365///
366/// Returns `(importer_qname, imported_path, span)` if the command is an import,
367/// or `None` if it's not a source/dot command or has no argument.
368fn 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    // Extract the command name
375    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    // Find the first argument (the file path) — it's the first child after the command name
385    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    // Extract the imported path, stripping quotes for string/raw_string
410    let imported_path = extract_source_path(arg, content)?;
411    if imported_path.is_empty() {
412        return Ok(None);
413    }
414
415    // Determine the importer context (function or script-level module)
416    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
426/// Extract the file path from a source/dot command argument node.
427///
428/// Handles various node types:
429/// - `word`: bare path (e.g., `./config.sh`)
430/// - `string`/`raw_string`: quoted path — strips surrounding quotes
431/// - `simple_expansion`/`expansion`: variable expansion (e.g., `$HOME/.bashrc`)
432/// - `concatenation`: mixed literals and expansions
433fn 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            // Strip surrounding quotes (", ', $')
438            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        // word, simple_expansion, expansion, concatenation — use raw text
447        _ => get_node_text(node, content),
448    }
449}
450
451/// Count arguments in a command invocation
452fn 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    // Subtract 1 for the command name itself
472    count.saturating_sub(1)
473}
474
475/// Create Span from tree-sitter Node
476fn span_from_node(node: Node) -> Span {
477    Span::from_bytes(node.start_byte(), node.end_byte())
478}
479
480/// Extract text from a node
481fn 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// ============================================================================
491// AST Graph - tracks callable contexts (functions)
492// ============================================================================
493
494#[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        // Extract function definitions using tree-sitter query
517        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 all descendant nodes to this context
557            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
577/// Extract visibility for a Shell function.
578///
579/// In Shell/Bash scripts, all user-defined functions are considered public
580/// as they can be called from anywhere within the script or sourced by
581/// other scripts. Shell doesn't have formal visibility modifiers.
582fn extract_visibility(_name: &str) -> &'static str {
583    "public"
584}
585
586/// Map all descendant nodes to a context index
587fn 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        // Verify script-level module is created
634        assert_has_node_with_kind(&staging, "test::module", NodeKind::Module);
635
636        // Verify both functions are extracted
637        assert_has_node_with_kind(&staging, "foo", NodeKind::Function);
638        assert_has_node_with_kind(&staging, "bar", NodeKind::Function);
639
640        // Verify both functions are exported from the module
641        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        // Verify script-level module is created
669        assert_has_node_with_kind(&staging, "test::module", NodeKind::Module);
670
671        // Verify both Bash-style functions are extracted
672        assert_has_node_with_kind(&staging, "foo", NodeKind::Function);
673        assert_has_node_with_kind(&staging, "bar", NodeKind::Function);
674
675        // Verify both functions are exported from the module
676        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        // Verify both functions are extracted
704        assert_has_node_with_kind(&staging, "caller", NodeKind::Function);
705        assert_has_node_with_kind(&staging, "callee", NodeKind::Function);
706
707        // Verify call edge from caller to callee
708        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        // Test that script-level module node is ALWAYS created, even for empty scripts
716        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        // Verify script-level module is always created, even for empty scripts
731        assert_has_node_with_kind(&staging, "test::module", NodeKind::Module);
732
733        // Verify no functions or exports for empty script
734        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        // Regression: script-level module should not mask functions sharing the script name
742        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        // Verify module and both functions exist
766        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        // Verify call edge from deploy function to helper
771        assert_has_call_edge(&staging, "deploy", "helper");
772
773        // Verify script-level call to deploy function
774        assert_has_call_edge(&staging, "deploy::module", "deploy");
775    }
776
777    #[test]
778    fn test_filters_external_tools() {
779        // Test that external tools (git, kubectl, docker, etc.) do NOT create call edges
780        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        // Verify both functions are extracted
803        assert_has_node_with_kind(&staging, "deploy", NodeKind::Function);
804        assert_has_node_with_kind(&staging, "my_helper", NodeKind::Function);
805
806        // Verify only user-defined function call is recorded (not external tools)
807        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        // Verify no nodes for external tools (git, kubectl, docker)
816        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        // Verify both functions are extracted
851        assert_has_node_with_kind(&staging, "my_function", NodeKind::Function);
852        assert_has_node_with_kind(&staging, "my_helper", NodeKind::Function);
853
854        // Verify only user-defined function call is recorded (not builtins)
855        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        // Verify no nodes for builtin commands (echo, cd, ls, pwd)
864        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        // Test that user-defined functions are exported from the module
880        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        // Verify both functions are extracted
902        assert_has_node_with_kind(&staging, "my_function", NodeKind::Function);
903        assert_has_node_with_kind(&staging, "helper", NodeKind::Function);
904
905        // Verify both functions are exported from the module
906        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        // Test that shell builtins are NOT exported (only user-defined functions)
915        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        // Verify only user-defined function is extracted
935        assert_has_node_with_kind(&staging, "my_script", NodeKind::Function);
936
937        // Verify only user-defined function is exported (not builtins)
938        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        // Verify no nodes or exports for builtins
947        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        // Test that exports use ExportKind::Direct
958        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        // Verify function is exported
974        let exports = collect_export_edges(&staging);
975        assert_eq!(exports.len(), 1, "Expected 1 export");
976
977        // Verify export uses ExportKind::Direct
978        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    // ====================================================================
994    // Import edge tests (source/. commands)
995    // ====================================================================
996
997    #[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        // Variable expansion is stored as raw text
1084        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        // Quotes should be stripped
1106        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        // source/. should create import edges, NOT call edges
1127        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        // But import edges should exist
1135        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        // Regression: removing source/. from builtins must not break other builtin filtering
1142        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        // Only user-defined function call should exist
1164        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        // No import edges should exist
1173        let imports = collect_import_edges(&staging);
1174        assert_eq!(imports.len(), 0, "Expected no import edges");
1175
1176        // No nodes for builtins
1177        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}