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