use super::{Finding, Severity, Sh, ShellChecker};
use serde::Deserialize;
use std::io::Write as _;
use std::process::{Command, Stdio};
const EXCLUDED_CODES: &str = "SC1091,SC2194,SC2050,SC2153,SC2154,SC2157,SC2043";
pub struct ShellcheckCli {
program: String,
}
impl ShellcheckCli {
#[must_use]
pub fn new() -> Self {
Self {
program: "shellcheck".to_owned(),
}
}
#[must_use]
pub fn is_available() -> bool {
Command::new("shellcheck")
.arg("--version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.is_ok_and(|status| status.success())
}
fn run(&self, input: &str, shell: Sh) -> Option<String> {
let mut child = Command::new(&self.program)
.args([
"--norc",
"-f",
"json",
"-x",
"--shell",
shell.flag(),
"-e",
EXCLUDED_CODES,
"-",
])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.ok()?;
child.stdin.take()?.write_all(input.as_bytes()).ok()?;
let output = child.wait_with_output().ok()?;
String::from_utf8(output.stdout).ok()
}
}
impl Default for ShellcheckCli {
fn default() -> Self {
Self::new()
}
}
impl ShellChecker for ShellcheckCli {
fn check(&self, script: &str, shell: Sh) -> Vec<Finding> {
let mut input = String::with_capacity(script.len().saturating_add(20));
input.push_str(shell.setup_line());
input.push('\n');
input.push_str(script);
let Some(stdout) = self.run(&input, shell) else {
return Vec::new();
};
parse_findings(&stdout)
}
}
#[derive(Debug, Deserialize)]
struct RawComment {
line: i64,
column: i64,
level: String,
code: u16,
message: String,
}
fn parse_findings(stdout: &str) -> Vec<Finding> {
let Ok(raw) = serde_json::from_str::<Vec<RawComment>>(stdout) else {
return Vec::new();
};
raw.into_iter()
.map(|c| Finding {
code: c.code,
severity: parse_severity(&c.level),
line: c.line.saturating_sub(1).max(1),
column: c.column,
message: c.message,
})
.collect()
}
fn parse_severity(level: &str) -> Severity {
match level {
"error" => Severity::Error,
"warning" => Severity::Warning,
"style" => Severity::Style,
_ => Severity::Info,
}
}
#[cfg(test)]
#[expect(clippy::indexing_slicing, reason = "tests index into findings freely")]
mod tests {
use super::*;
const SAMPLE: &str = r#"[{"file":"-","line":2,"endLine":2,"column":6,"endColumn":10,"level":"info","code":2086,"message":"Double quote to prevent globbing and word splitting."},{"file":"-","line":3,"endLine":3,"column":4,"endColumn":8,"level":"warning","code":2086,"message":"Double quote to prevent globbing and word splitting."}]"#;
#[test]
fn parses_sample_offsetting_lines() {
let findings = parse_findings(SAMPLE);
assert_eq!(findings.len(), 2);
assert_eq!(findings[0].line, 1);
assert_eq!(findings[0].code, 2086);
assert_eq!(findings[0].severity, Severity::Info);
assert_eq!(findings[0].column, 6);
assert_eq!(findings[1].line, 2);
assert_eq!(findings[1].severity, Severity::Warning);
}
#[test]
fn empty_array_is_clean() {
assert!(parse_findings("[]").is_empty());
}
#[test]
fn garbage_output_yields_no_findings() {
assert!(parse_findings("not json").is_empty());
assert!(parse_findings("").is_empty());
}
#[test]
fn finding_on_setup_line_is_clamped_to_one() {
let raw = r#"[{"file":"-","line":1,"column":1,"level":"error","code":2148,"message":"x"}]"#;
let findings = parse_findings(raw);
assert_eq!(findings[0].line, 1);
}
#[test]
fn severity_mapping() {
assert_eq!(parse_severity("error"), Severity::Error);
assert_eq!(parse_severity("warning"), Severity::Warning);
assert_eq!(parse_severity("style"), Severity::Style);
assert_eq!(parse_severity("info"), Severity::Info);
assert_eq!(parse_severity("mystery"), Severity::Info);
}
}