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