use serde::Deserialize;
use std::process::Command;
#[derive(Debug, Clone, Deserialize)]
pub struct ShellCheckComment {
pub file: String,
pub line: u32,
#[serde(rename = "endLine")]
pub end_line: u32,
pub column: u32,
#[serde(rename = "endColumn")]
pub end_column: u32,
pub level: String,
pub code: u32,
pub message: String,
}
impl ShellCheckComment {
pub fn rule_code(&self) -> String {
format!("SC{}", self.code)
}
}
pub fn run_shellcheck(script: &str, shell: &str) -> Vec<ShellCheckComment> {
let output = Command::new("shellcheck")
.args([
"--format=json",
&format!("--shell={}", shell),
"-e",
"2187", "-e",
"1090", "-e",
"1091", "-", ])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn();
let mut child = match output {
Ok(child) => child,
Err(_) => {
return Vec::new();
}
};
if let Some(stdin) = child.stdin.as_mut() {
use std::io::Write;
let _ = stdin.write_all(script.as_bytes());
}
let output = match child.wait_with_output() {
Ok(output) => output,
Err(_) => return Vec::new(),
};
let stdout = String::from_utf8_lossy(&output.stdout);
serde_json::from_str::<Vec<ShellCheckComment>>(&stdout).unwrap_or_default()
}
pub fn is_shellcheck_available() -> bool {
Command::new("shellcheck")
.arg("--version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
pub fn shellcheck_version() -> Option<String> {
let output = Command::new("shellcheck").arg("--version").output().ok()?;
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
if line.starts_with("version:") {
return Some(line.trim_start_matches("version:").trim().to_string());
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_shellcheck_available() {
let available = is_shellcheck_available();
println!("ShellCheck available: {}", available);
}
#[test]
fn test_shellcheck_version() {
if is_shellcheck_available() {
let version = shellcheck_version();
println!("ShellCheck version: {:?}", version);
assert!(version.is_some());
}
}
#[test]
fn test_run_shellcheck() {
if !is_shellcheck_available() {
println!("Skipping test: shellcheck not available");
return;
}
let script = r#"#!/bin/bash
echo $foo
"#;
let comments = run_shellcheck(script, "bash");
let has_sc2086 = comments.iter().any(|c| c.code == 2086);
assert!(
has_sc2086 || comments.is_empty(),
"Expected SC2086 warning or empty (if shellcheck behaves differently)"
);
}
#[test]
fn test_shellcheck_comment_rule_code() {
let comment = ShellCheckComment {
file: "-".to_string(),
line: 1,
end_line: 1,
column: 1,
end_column: 10,
level: "warning".to_string(),
code: 2086,
message: "Double quote to prevent globbing".to_string(),
};
assert_eq!(comment.rule_code(), "SC2086");
}
}