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