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