syncable_cli/analyzer/hadolint/shell/
shellcheck.rs

1//! ShellCheck integration for shell analysis.
2//!
3//! Calls the external shellcheck binary to get detailed shell script analysis.
4//! Requires shellcheck to be installed on the system.
5
6use std::process::Command;
7use serde::Deserialize;
8
9/// A ShellCheck warning/error.
10#[derive(Debug, Clone, Deserialize)]
11pub struct ShellCheckComment {
12    /// File path (usually "-" for stdin).
13    pub file: String,
14    /// Line number.
15    pub line: u32,
16    /// End line number.
17    #[serde(rename = "endLine")]
18    pub end_line: u32,
19    /// Column number.
20    pub column: u32,
21    /// End column number.
22    #[serde(rename = "endColumn")]
23    pub end_column: u32,
24    /// Severity level.
25    pub level: String,
26    /// ShellCheck code (e.g., 2086).
27    pub code: u32,
28    /// Warning message.
29    pub message: String,
30}
31
32impl ShellCheckComment {
33    /// Get the rule code as a string (e.g., "SC2086").
34    pub fn rule_code(&self) -> String {
35        format!("SC{}", self.code)
36    }
37}
38
39/// Run shellcheck on a script and return warnings.
40///
41/// # Arguments
42/// * `script` - The shell script to analyze
43/// * `shell` - The shell to use (e.g., "bash", "sh")
44///
45/// # Returns
46/// A vector of ShellCheck comments/warnings, or an empty vector if shellcheck
47/// is not available or fails.
48pub fn run_shellcheck(script: &str, shell: &str) -> Vec<ShellCheckComment> {
49    // Build the shellcheck command
50    let output = Command::new("shellcheck")
51        .args([
52            "--format=json",
53            &format!("--shell={}", shell),
54            "-e", "2187", // Exclude ash shell warning
55            "-e", "1090", // Exclude source directive warning
56            "-e", "1091", // Exclude source directive warning
57            "-",          // Read from stdin
58        ])
59        .stdin(std::process::Stdio::piped())
60        .stdout(std::process::Stdio::piped())
61        .stderr(std::process::Stdio::piped())
62        .spawn();
63
64    let mut child = match output {
65        Ok(child) => child,
66        Err(_) => {
67            // shellcheck not installed or not in PATH
68            return Vec::new();
69        }
70    };
71
72    // Write script to stdin
73    if let Some(stdin) = child.stdin.as_mut() {
74        use std::io::Write;
75        let _ = stdin.write_all(script.as_bytes());
76    }
77
78    // Wait for output
79    let output = match child.wait_with_output() {
80        Ok(output) => output,
81        Err(_) => return Vec::new(),
82    };
83
84    // Parse JSON output
85    // ShellCheck returns exit code 1 if there are warnings, but still outputs valid JSON
86    let stdout = String::from_utf8_lossy(&output.stdout);
87
88    match serde_json::from_str::<Vec<ShellCheckComment>>(&stdout) {
89        Ok(comments) => comments,
90        Err(_) => Vec::new(),
91    }
92}
93
94/// Check if shellcheck is available on the system.
95pub fn is_shellcheck_available() -> bool {
96    Command::new("shellcheck")
97        .arg("--version")
98        .stdout(std::process::Stdio::null())
99        .stderr(std::process::Stdio::null())
100        .status()
101        .map(|s| s.success())
102        .unwrap_or(false)
103}
104
105/// Get the shellcheck version if available.
106pub fn shellcheck_version() -> Option<String> {
107    let output = Command::new("shellcheck")
108        .arg("--version")
109        .output()
110        .ok()?;
111
112    let stdout = String::from_utf8_lossy(&output.stdout);
113
114    // Parse version from output like "ShellCheck - shell script analysis tool\nversion: 0.9.0\n..."
115    for line in stdout.lines() {
116        if line.starts_with("version:") {
117            return Some(line.trim_start_matches("version:").trim().to_string());
118        }
119    }
120
121    None
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn test_is_shellcheck_available() {
130        // This test will pass if shellcheck is installed, skip otherwise
131        let available = is_shellcheck_available();
132        println!("ShellCheck available: {}", available);
133    }
134
135    #[test]
136    fn test_shellcheck_version() {
137        if is_shellcheck_available() {
138            let version = shellcheck_version();
139            println!("ShellCheck version: {:?}", version);
140            assert!(version.is_some());
141        }
142    }
143
144    #[test]
145    fn test_run_shellcheck() {
146        if !is_shellcheck_available() {
147            println!("Skipping test: shellcheck not available");
148            return;
149        }
150
151        // Script with a known shellcheck warning (SC2086: Double quote to prevent globbing)
152        let script = r#"#!/bin/bash
153echo $foo
154"#;
155
156        let comments = run_shellcheck(script, "bash");
157
158        // Should have at least one warning about unquoted variable
159        let has_sc2086 = comments.iter().any(|c| c.code == 2086);
160        assert!(has_sc2086 || comments.is_empty(), "Expected SC2086 warning or empty (if shellcheck behaves differently)");
161    }
162
163    #[test]
164    fn test_shellcheck_comment_rule_code() {
165        let comment = ShellCheckComment {
166            file: "-".to_string(),
167            line: 1,
168            end_line: 1,
169            column: 1,
170            end_column: 10,
171            level: "warning".to_string(),
172            code: 2086,
173            message: "Double quote to prevent globbing".to_string(),
174        };
175
176        assert_eq!(comment.rule_code(), "SC2086");
177    }
178}