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::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
21/// `GraphBuilder` for Shell scripts
22pub struct ShellGraphBuilder {
23    max_scope_depth: usize,
24}
25
26impl Default for ShellGraphBuilder {
27    fn default() -> Self {
28        Self {
29            max_scope_depth: 2, // Shell typically has flat structure (script -> function)
30        }
31    }
32}
33
34impl GraphBuilder for ShellGraphBuilder {
35    fn language(&self) -> Language {
36        Language::Shell
37    }
38
39    // Shell graph extraction is linear and benefits from a single pass.
40    #[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        // Create helper for staging graph population
49        let mut helper = GraphBuildHelper::new(staging, file, Language::Shell);
50
51        // Build AST metadata to track function contexts
52        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        // Phase 0: ALWAYS create script-level module node (entry point)
60        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        // Phase 1: Insert function contexts as nodes and emit Export edges
69        // All shell functions are exported from the script module
70        // DESIGN: Only user-defined functions (from function_definition AST nodes) are exported.
71        // Shell builtins are never defined as function_definition nodes, so they're automatically excluded.
72        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            // Export all user-defined functions using ExportKind::Direct
84            helper.add_export_edge_full(module_id, function_id, ExportKind::Direct, None);
85        }
86
87        // Phase 1b: Export variables referenced in explicit `export` commands.
88        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        // Phase 2: Traverse tree to collect call edges
170        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            // Skip if already visited (prevents infinite loops)
177            if !visited.insert(node_id) {
178                continue;
179            }
180
181            // Skip non-code nodes
182            match node.kind() {
183                "comment" | "string" | "raw_string" | "ansi_c_string" => {
184                    continue;
185                }
186                _ => {}
187            }
188
189            // Detect command invocations
190            if node.kind() == "command" {
191                // Check for import commands (source/.) first
192                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                // Then check for call edges (user-defined function calls)
200                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            // Traverse children
220            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
230// ============================================================================
231// Helper Functions
232// ============================================================================
233
234/// Build call edge information for the staging graph.
235/// Returns (`caller_qname`, `callee_qname`, `argument_count`, span) tuple.
236fn 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    // Find the calling context (which function is this call in?)
243    let module_context;
244    let call_context = if let Some(ctx) = ast_graph.get_callable_context(call_node.id()) {
245        ctx
246    } else {
247        // Script-level call - use module-qualified name as context
248        module_context = CallContext {
249            qualified_name: module_name.to_string(),
250            span: (0, content.len()),
251        };
252        &module_context
253    };
254
255    // Extract the command name
256    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    // CRITICAL: Filter out shell built-in commands
267    if is_builtin_command(&callee_text) {
268        return Ok(None);
269    }
270
271    // DESIGN REQUIREMENT: Only create call edges for user-defined functions
272    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
290/// Check if a command is a shell built-in
291fn is_builtin_command(cmd: &str) -> bool {
292    // Common POSIX and Bash built-ins
293    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
362/// Check if a command is a `source` or `.` (dot) import command
363fn is_source_command(cmd: &str) -> bool {
364    matches!(cmd, "source" | ".")
365}
366
367/// Build import edge information for `source` and `.` commands.
368///
369/// Returns `(importer_qname, imported_path, span)` if the command is an import,
370/// or `None` if it's not a source/dot command or has no argument.
371fn 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    // Extract the command name
378    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    // Find the first argument (the file path) — it's the first child after the command name
388    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    // Extract the imported path, stripping quotes for string/raw_string
413    let imported_path = extract_source_path(arg, content)?;
414    if imported_path.is_empty() {
415        return Ok(None);
416    }
417
418    // Determine the importer context (function or script-level module)
419    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
429/// Extract the file path from a source/dot command argument node.
430///
431/// Handles various node types:
432/// - `word`: bare path (e.g., `./config.sh`)
433/// - `string`/`raw_string`: quoted path — strips surrounding quotes
434/// - `simple_expansion`/`expansion`: variable expansion (e.g., `$HOME/.bashrc`)
435/// - `concatenation`: mixed literals and expansions
436fn 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            // Strip surrounding quotes (", ', $')
441            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        // word, simple_expansion, expansion, concatenation — use raw text
450        _ => get_node_text(node, content),
451    }
452}
453
454/// Count arguments in a command invocation
455fn 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    // Subtract 1 for the command name itself
475    count.saturating_sub(1)
476}
477
478/// Create Span from tree-sitter Node
479fn span_from_node(node: Node) -> Span {
480    Span::from_bytes(node.start_byte(), node.end_byte())
481}
482
483/// Extract text from a node
484fn 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// ============================================================================
494// AST Graph - tracks callable contexts (functions)
495// ============================================================================
496
497#[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        // Extract function definitions using tree-sitter query
520        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 all descendant nodes to this context
560            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
580/// Extract visibility for a Shell function.
581///
582/// In Shell/Bash scripts, all user-defined functions are considered public
583/// as they can be called from anywhere within the script or sourced by
584/// other scripts. Shell doesn't have formal visibility modifiers.
585fn extract_visibility(_name: &str) -> &'static str {
586    "public"
587}
588
589/// Map all descendant nodes to a context index
590fn 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        // Verify script-level module is created
637        assert_has_node_with_kind(&staging, "test::module", NodeKind::Module);
638
639        // Verify both functions are extracted
640        assert_has_node_with_kind(&staging, "foo", NodeKind::Function);
641        assert_has_node_with_kind(&staging, "bar", NodeKind::Function);
642
643        // Verify both functions are exported from the module
644        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        // Verify script-level module is created
672        assert_has_node_with_kind(&staging, "test::module", NodeKind::Module);
673
674        // Verify both Bash-style functions are extracted
675        assert_has_node_with_kind(&staging, "foo", NodeKind::Function);
676        assert_has_node_with_kind(&staging, "bar", NodeKind::Function);
677
678        // Verify both functions are exported from the module
679        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        // Verify both functions are extracted
707        assert_has_node_with_kind(&staging, "caller", NodeKind::Function);
708        assert_has_node_with_kind(&staging, "callee", NodeKind::Function);
709
710        // Verify call edge from caller to callee
711        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        // Test that script-level module node is ALWAYS created, even for empty scripts
719        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        // Verify script-level module is always created, even for empty scripts
734        assert_has_node_with_kind(&staging, "test::module", NodeKind::Module);
735
736        // Verify no functions or exports for empty script
737        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        // Regression: script-level module should not mask functions sharing the script name
745        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        // Verify module and both functions exist
769        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        // Verify call edge from deploy function to helper
774        assert_has_call_edge(&staging, "deploy", "helper");
775
776        // Verify script-level call to deploy function
777        assert_has_call_edge(&staging, "deploy::module", "deploy");
778    }
779
780    #[test]
781    fn test_filters_external_tools() {
782        // Test that external tools (git, kubectl, docker, etc.) do NOT create call edges
783        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        // Verify both functions are extracted
806        assert_has_node_with_kind(&staging, "deploy", NodeKind::Function);
807        assert_has_node_with_kind(&staging, "my_helper", NodeKind::Function);
808
809        // Verify only user-defined function call is recorded (not external tools)
810        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        // Verify no nodes for external tools (git, kubectl, docker)
819        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        // Verify both functions are extracted
854        assert_has_node_with_kind(&staging, "my_function", NodeKind::Function);
855        assert_has_node_with_kind(&staging, "my_helper", NodeKind::Function);
856
857        // Verify only user-defined function call is recorded (not builtins)
858        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        // Verify no nodes for builtin commands (echo, cd, ls, pwd)
867        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        // Test that user-defined functions are exported from the module
883        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        // Verify both functions are extracted
905        assert_has_node_with_kind(&staging, "my_function", NodeKind::Function);
906        assert_has_node_with_kind(&staging, "helper", NodeKind::Function);
907
908        // Verify both functions are exported from the module
909        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        // Test that shell builtins are NOT exported (only user-defined functions)
918        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        // Verify only user-defined function is extracted
938        assert_has_node_with_kind(&staging, "my_script", NodeKind::Function);
939
940        // Verify only user-defined function is exported (not builtins)
941        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        // Verify no nodes or exports for builtins
950        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        // Test that exports use ExportKind::Direct
961        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        // Verify function is exported
977        let exports = collect_export_edges(&staging);
978        assert_eq!(exports.len(), 1, "Expected 1 export");
979
980        // Verify export uses ExportKind::Direct
981        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    // ====================================================================
997    // Import edge tests (source/. commands)
998    // ====================================================================
999
1000    #[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        // Variable expansion is stored as raw text
1087        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        // Quotes should be stripped
1109        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        // source/. should create import edges, NOT call edges
1130        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        // But import edges should exist
1138        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        // Regression: removing source/. from builtins must not break other builtin filtering
1145        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        // Only user-defined function call should exist
1167        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        // No import edges should exist
1176        let imports = collect_import_edges(&staging);
1177        assert_eq!(imports.len(), 0, "Expected no import edges");
1178
1179        // No nodes for builtins
1180        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}