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 serde::Deserialize;
7use std::process::Command;
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",
55            "2187", // Exclude ash shell warning
56            "-e",
57            "1090", // Exclude source directive warning
58            "-e",
59            "1091", // Exclude source directive warning
60            "-",    // Read from stdin
61        ])
62        .stdin(std::process::Stdio::piped())
63        .stdout(std::process::Stdio::piped())
64        .stderr(std::process::Stdio::piped())
65        .spawn();
66
67    let mut child = match output {
68        Ok(child) => child,
69        Err(_) => {
70            // shellcheck not installed or not in PATH
71            return Vec::new();
72        }
73    };
74
75    // Write script to stdin
76    if let Some(stdin) = child.stdin.as_mut() {
77        use std::io::Write;
78        let _ = stdin.write_all(script.as_bytes());
79    }
80
81    // Wait for output
82    let output = match child.wait_with_output() {
83        Ok(output) => output,
84        Err(_) => return Vec::new(),
85    };
86
87    // Parse JSON output
88    // ShellCheck returns exit code 1 if there are warnings, but still outputs valid JSON
89    let stdout = String::from_utf8_lossy(&output.stdout);
90
91    serde_json::from_str::<Vec<ShellCheckComment>>(&stdout).unwrap_or_default()
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").arg("--version").output().ok()?;
108
109    let stdout = String::from_utf8_lossy(&output.stdout);
110
111    // Parse version from output like "ShellCheck - shell script analysis tool\nversion: 0.9.0\n..."
112    for line in stdout.lines() {
113        if line.starts_with("version:") {
114            return Some(line.trim_start_matches("version:").trim().to_string());
115        }
116    }
117
118    None
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    #[test]
126    fn test_is_shellcheck_available() {
127        // This test will pass if shellcheck is installed, skip otherwise
128        let available = is_shellcheck_available();
129        println!("ShellCheck available: {}", available);
130    }
131
132    #[test]
133    fn test_shellcheck_version() {
134        if is_shellcheck_available() {
135            let version = shellcheck_version();
136            println!("ShellCheck version: {:?}", version);
137            assert!(version.is_some());
138        }
139    }
140
141    #[test]
142    fn test_run_shellcheck() {
143        if !is_shellcheck_available() {
144            println!("Skipping test: shellcheck not available");
145            return;
146        }
147
148        // Script with a known shellcheck warning (SC2086: Double quote to prevent globbing)
149        let script = r#"#!/bin/bash
150echo $foo
151"#;
152
153        let comments = run_shellcheck(script, "bash");
154
155        // Should have at least one warning about unquoted variable
156        let has_sc2086 = comments.iter().any(|c| c.code == 2086);
157        assert!(
158            has_sc2086 || comments.is_empty(),
159            "Expected SC2086 warning or empty (if shellcheck behaves differently)"
160        );
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}