use std::borrow::Cow;
use std::collections::HashSet;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, ExitStatus, Stdio};
use std::time::{Duration, Instant};
use rpm_spec::ast::{FileTrigger, Scriptlet, Section, Span, Trigger};
use serde::Deserialize;
use tracing::{debug, warn};
use crate::config::Config;
use crate::diagnostic::{Applicability, Diagnostic, LintCategory, Severity, Suggestion};
use crate::lint::{Lint, LintMetadata};
use crate::visit::{self, Visit};
const DEFAULT_BINARY: &str = "shellcheck";
const DEFAULT_SHELL: &str = "bash";
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
const WAIT_POLL: Duration = Duration::from_millis(25);
const ALLOWED_SHELLS: &[&str] = &["sh", "bash", "dash", "ksh"];
const SC1090: u32 = 1090;
const SC1091: u32 = 1091;
const SC2050: u32 = 2050;
const SC2086: u32 = 2086;
const SC2164: u32 = 2164;
const SC2317: u32 = 2317;
const SC3044: u32 = 3044;
const DEFAULT_DISABLED: &[u32] = &[SC2164, SC3044, SC1090, SC1091, SC2050, SC2086, SC2317];
pub static SHELLCHECK_METADATA: LintMetadata = LintMetadata {
id: "RPM200",
name: "shellcheck",
description: "Run shellcheck over %prep/%build/%install and scriptlet/trigger bodies; surface findings as diagnostics.",
default_severity: Severity::Warn,
category: LintCategory::Correctness,
};
pub static SHELLCHECK_UNAVAILABLE_METADATA: LintMetadata = LintMetadata {
id: "RPM201",
name: "shellcheck-unavailable",
description: "shellcheck binary is unavailable or its invocation failed; the umbrella lint cannot run.",
default_severity: Severity::Warn,
category: LintCategory::Correctness,
};
#[derive(Debug)]
enum Availability {
Unprobed,
Available(PathBuf),
Unavailable(String),
}
#[derive(Debug, thiserror::Error)]
enum ShellcheckError {
#[error("spawn failed: {0}")]
Spawn(#[source] std::io::Error),
#[error("subprocess pipe was unavailable (stdin/stdout/stderr)")]
PipeMissing,
#[error("wait failed: {0}")]
Wait(#[source] std::io::Error),
#[error("shellcheck exceeded the {:?} timeout and was killed", _0)]
Timeout(Duration),
#[error("exit {status} with empty stdout; stderr: {stderr}")]
EmptyOutput { status: ExitStatus, stderr: String },
#[error("JSON parse error: {0}")]
Json(#[from] serde_json::Error),
}
#[derive(Debug)]
pub struct ShellcheckLint {
diagnostics: Vec<Diagnostic>,
source: String,
binary_override: Option<String>,
shell: String,
timeout: Duration,
disable: HashSet<u32>,
availability: Availability,
unavailable_reported: bool,
fatal_reported: bool,
}
impl ShellcheckLint {
pub fn new() -> Self {
Self {
diagnostics: Vec::new(),
source: String::new(),
binary_override: None,
shell: DEFAULT_SHELL.to_owned(),
timeout: DEFAULT_TIMEOUT,
disable: DEFAULT_DISABLED.iter().copied().collect(),
availability: Availability::Unprobed,
unavailable_reported: false,
fatal_reported: false,
}
}
fn process_section(&mut self, span: Span) {
if self.fatal_reported {
return;
}
let binary = match self.ensure_available() {
Some(path) => path.to_owned(),
None => return,
};
let Some(section_text) = self.source.get(span.start_byte..span.end_byte) else {
debug!(
target: "rpm_spec_analyzer::shellcheck",
?span,
"section span out of range; skipping",
);
return;
};
if section_text.lines().count() < 2 {
return;
}
let (input, line_map) = build_shellcheck_input(section_text);
let result = invoke_shellcheck(&binary, &self.shell, self.timeout, &input);
match result {
Ok(findings) => {
debug!(
target: "rpm_spec_analyzer::shellcheck",
section_start = span.start_line,
finding_count = findings.len(),
"shellcheck section completed",
);
let mut diags = build_finding_diagnostics(
findings,
section_text,
&line_map,
span,
&self.disable,
);
self.diagnostics.append(&mut diags);
}
Err(err) => self.report_fatal(format!("{err}")),
}
}
fn ensure_available(&mut self) -> Option<&Path> {
if matches!(self.availability, Availability::Unprobed) {
self.availability = match probe_binary(self.binary_override.as_deref()) {
Ok(path) => {
debug!(
target: "rpm_spec_analyzer::shellcheck",
path = %path.display(),
"shellcheck binary probed successfully",
);
Availability::Available(path)
}
Err(reason) => Availability::Unavailable(reason),
};
}
match &self.availability {
Availability::Available(path) => Some(path.as_path()),
Availability::Unavailable(reason) => {
if !self.unavailable_reported {
self.unavailable_reported = true;
self.diagnostics.push(Diagnostic::new(
&SHELLCHECK_UNAVAILABLE_METADATA,
Severity::Warn,
format!(
"shellcheck binary is not available: {reason}; install shellcheck or set `shellcheck = \"allow\"` in .rpmspec.toml"
),
Span::default(),
));
}
None
}
Availability::Unprobed => None,
}
}
fn report_fatal(&mut self, reason: String) {
if !self.fatal_reported {
self.fatal_reported = true;
self.diagnostics.push(Diagnostic::new(
&SHELLCHECK_UNAVAILABLE_METADATA,
Severity::Warn,
reason,
Span::default(),
));
}
}
}
fn build_finding_diagnostics(
findings: Vec<ShellcheckFinding>,
section_text: &str,
line_map: &[u32],
span: Span,
disable: &HashSet<u32>,
) -> Vec<Diagnostic> {
debug_assert!(
!line_map.is_empty(),
"line_map must contain the shebang entry"
);
let line_offsets = compute_line_offsets(section_text);
let mut out = Vec::with_capacity(findings.len());
for f in findings {
if disable.contains(&f.code) {
continue;
}
let map_idx_start = f.line.saturating_sub(1) as usize;
let map_idx_end = f.end_line.unwrap_or(f.line).saturating_sub(1) as usize;
let src_line_in_slice = *line_map
.get(map_idx_start)
.or_else(|| line_map.last())
.unwrap_or(&1);
let src_end_in_slice = *line_map
.get(map_idx_end)
.or_else(|| line_map.last())
.unwrap_or(&src_line_in_slice);
let line_idx = src_line_in_slice.saturating_sub(1) as usize;
let end_line_idx = src_end_in_slice.saturating_sub(1) as usize;
let (start_byte_in_slice, line_end_in_slice) =
line_range(&line_offsets, section_text, line_idx);
let (_, end_byte_in_slice) = line_range(&line_offsets, section_text, end_line_idx);
let abs_start = span.start_byte + start_byte_in_slice;
let abs_end = span.start_byte + end_byte_in_slice.max(line_end_in_slice);
let source_start_line = span.start_line.saturating_add(line_idx as u32);
let source_end_line = span.start_line.saturating_add(end_line_idx as u32);
let diag_span = Span::new(abs_start, abs_end, source_start_line, 0, source_end_line, 0);
let message = format!("[SC{:04}:{}] {}", f.code, f.level, f.message);
let mut diag = Diagnostic::new(
&SHELLCHECK_METADATA,
Severity::Warn, message,
diag_span,
);
if f.fix.is_some() {
diag = diag.with_suggestion(Suggestion::new(
format!(
"shellcheck has an auto-fix for SC{:04}; see https://www.shellcheck.net/wiki/SC{}",
f.code, f.code
),
Vec::new(),
Applicability::Manual,
));
}
out.push(diag);
}
out
}
impl Default for ShellcheckLint {
fn default() -> Self {
Self::new()
}
}
impl<'ast> Visit<'ast> for ShellcheckLint {
fn visit_section(&mut self, node: &'ast Section<Span>) {
match node {
Section::BuildScript { data, .. }
| Section::Verify { data, .. }
| Section::Sepolicy { data, .. } => {
let span = *data;
self.process_section(span);
}
_ => visit::walk_section(self, node),
}
}
fn visit_scriptlet(&mut self, node: &'ast Scriptlet<Span>) {
if node.from_file.is_some() {
return;
}
let span = node.data;
self.process_section(span);
}
fn visit_trigger(&mut self, node: &'ast Trigger<Span>) {
let span = node.data;
self.process_section(span);
}
fn visit_file_trigger(&mut self, node: &'ast FileTrigger<Span>) {
let span = node.data;
self.process_section(span);
}
}
impl Lint for ShellcheckLint {
fn metadata(&self) -> &'static LintMetadata {
&SHELLCHECK_METADATA
}
fn take_diagnostics(&mut self) -> Vec<Diagnostic> {
std::mem::take(&mut self.diagnostics)
}
fn set_source(&mut self, source: &str) {
self.source = source.to_owned();
}
fn set_config(&mut self, config: &Config) {
self.binary_override = config.shellcheck.binary.clone();
if let Some(s) = config.shellcheck.shell.as_deref() {
if ALLOWED_SHELLS.contains(&s) {
self.shell = s.to_owned();
} else {
warn!(
target: "rpm_spec_analyzer::shellcheck",
shell = %s,
allowed = ?ALLOWED_SHELLS,
"[shellcheck].shell value not recognised; keeping default `{}`",
DEFAULT_SHELL,
);
}
}
if let Some(secs) = config.shellcheck.timeout_secs {
self.timeout = Duration::from_secs(secs);
}
let mut disable: HashSet<u32> = DEFAULT_DISABLED.iter().copied().collect();
for s in &config.shellcheck.disable {
match parse_sc_code(s) {
Some(code) => {
disable.insert(code);
}
None => warn!(
target: "rpm_spec_analyzer::shellcheck",
entry = %s,
"ignoring unparseable [shellcheck].disable entry; expected SC<n> or <n>",
),
}
}
for s in &config.shellcheck.enable {
match parse_sc_code(s) {
Some(code) => {
disable.remove(&code);
}
None => warn!(
target: "rpm_spec_analyzer::shellcheck",
entry = %s,
"ignoring unparseable [shellcheck].enable entry; expected SC<n> or <n>",
),
}
}
self.disable = disable;
}
}
fn build_shellcheck_input(slice: &str) -> (String, Vec<u32>) {
let mut out = String::with_capacity(slice.len() + 16);
let mut line_map: Vec<u32> = Vec::new();
let mut src_line: u32 = 1;
let mut first = true;
for raw_line in slice.split_inclusive('\n') {
let (line, terminator) = split_terminator(raw_line);
if first {
out.push_str("#!/bin/bash");
out.push_str(terminator);
line_map.push(src_line);
first = false;
src_line += 1;
continue;
}
if is_rpm_conditional_line(line) {
src_line += 1;
continue;
}
out.push_str(&mask_macros(line));
out.push_str(terminator);
line_map.push(src_line);
src_line += 1;
}
(out, line_map)
}
fn split_terminator(raw: &str) -> (&str, &str) {
if let Some(stripped) = raw.strip_suffix("\r\n") {
(stripped, "\r\n")
} else if let Some(stripped) = raw.strip_suffix('\n') {
(stripped, "\n")
} else {
(raw, "")
}
}
fn is_rpm_conditional_line(line: &str) -> bool {
let trimmed = line.trim_start();
const KEYWORDS: &[&str] = &[
"%ifarch", "%ifnarch", "%ifos", "%ifnos", "%elif", "%else", "%endif", "%if",
];
for kw in KEYWORDS {
if let Some(rest) = trimmed.strip_prefix(kw)
&& (rest.is_empty()
|| rest.starts_with(|c: char| c.is_whitespace())
|| rest.starts_with('#'))
{
return true;
}
}
false
}
fn mask_macros(line: &str) -> Cow<'_, str> {
let bytes = line.as_bytes();
if !bytes.contains(&b'%') {
return Cow::Borrowed(line);
}
let mut out = String::with_capacity(line.len());
let mut run_start = 0;
let mut i = 0;
while i < bytes.len() {
if bytes[i] != b'%' {
i += 1;
continue;
}
if run_start < i {
out.push_str(&line[run_start..i]);
}
if bytes.get(i + 1) == Some(&b'%') {
out.push_str("%%");
i += 2;
run_start = i;
continue;
}
if let Some(end) = scan_macro(&bytes[i..]) {
for _ in 0..end {
out.push('_');
}
i += end;
run_start = i;
continue;
}
out.push('%');
i += 1;
run_start = i;
}
if run_start < bytes.len() {
out.push_str(&line[run_start..]);
}
Cow::Owned(out)
}
fn scan_macro(bytes: &[u8]) -> Option<usize> {
debug_assert!(bytes.first() == Some(&b'%'));
if bytes.len() < 2 {
return None;
}
match bytes[1] {
b'{' => scan_balanced(bytes, 1, b'{', b'}'),
b'(' => scan_balanced(bytes, 1, b'(', b')'),
b'[' => scan_balanced(bytes, 1, b'[', b']'),
b'?' | b'!' => {
let body_start = if bytes[1] == b'!' && bytes.get(2) == Some(&b'?') {
3
} else {
2
};
scan_identifier(bytes, body_start)
}
b'a'..=b'z' | b'A'..=b'Z' | b'_' => scan_identifier(bytes, 1),
b'0'..=b'9' | b'*' | b'#' => Some(2),
_ => None,
}
}
fn scan_balanced(bytes: &[u8], offset: usize, open: u8, close: u8) -> Option<usize> {
debug_assert!(bytes.get(offset) == Some(&open));
let mut depth: usize = 1;
let mut i = offset + 1;
while i < bytes.len() {
let b = bytes[i];
if b == open {
depth += 1;
} else if b == close {
depth -= 1;
if depth == 0 {
return Some(i + 1);
}
}
i += 1;
}
Some(bytes.len())
}
fn scan_identifier(bytes: &[u8], start: usize) -> Option<usize> {
let mut i = start;
while i < bytes.len() {
match bytes[i] {
b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_' => i += 1,
_ => break,
}
}
if i == start { None } else { Some(i) }
}
fn compute_line_offsets(slice: &str) -> Vec<usize> {
let mut offsets = vec![0usize];
for (idx, b) in slice.bytes().enumerate() {
if b == b'\n' {
offsets.push(idx + 1);
}
}
offsets
}
fn line_range(offsets: &[usize], slice: &str, line_idx: usize) -> (usize, usize) {
let idx = line_idx.min(offsets.len().saturating_sub(1));
let start = offsets[idx];
let end = offsets.get(idx + 1).copied().unwrap_or(slice.len());
let end = if end > start && slice.as_bytes().get(end - 1) == Some(&b'\n') {
end - 1
} else {
end
};
(start, end)
}
#[derive(Debug, Deserialize)]
struct ShellcheckOutput {
#[serde(rename = "comments")]
findings: Vec<ShellcheckFinding>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ShellcheckFinding {
line: u32,
#[serde(default)]
end_line: Option<u32>,
level: String,
code: u32,
message: String,
#[serde(default, deserialize_with = "deserialize_present")]
fix: Option<()>,
}
fn deserialize_present<'de, D>(deserializer: D) -> Result<Option<()>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::IgnoredAny;
let opt = <Option<IgnoredAny>>::deserialize(deserializer)?;
Ok(opt.map(|_| ()))
}
fn probe_binary(override_path: Option<&str>) -> Result<PathBuf, String> {
let target: PathBuf = override_path.unwrap_or(DEFAULT_BINARY).into();
let output = Command::new(&target)
.arg("--version")
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.map_err(|e| format!("cannot spawn `{}`: {e}", target.display()))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stderr_trimmed = stderr.trim();
let stderr_part = if stderr_trimmed.is_empty() {
String::new()
} else {
format!("; stderr: {stderr_trimmed}")
};
return Err(format!(
"`{} --version` exited with status {}{}",
target.display(),
output.status,
stderr_part,
));
}
Ok(target)
}
fn invoke_shellcheck(
binary: &Path,
shell: &str,
timeout: Duration,
input: &str,
) -> Result<Vec<ShellcheckFinding>, ShellcheckError> {
let mut child = Command::new(binary)
.arg("--format=json1")
.arg(format!("--shell={shell}"))
.arg("-")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(ShellcheckError::Spawn)?;
let stdin = child.stdin.take().ok_or(ShellcheckError::PipeMissing)?;
let stdout = child.stdout.take().ok_or(ShellcheckError::PipeMissing)?;
let stderr = child.stderr.take().ok_or(ShellcheckError::PipeMissing)?;
let input_bytes = input.as_bytes();
let (stdout_buf, stderr_buf, status_result) = std::thread::scope(|s| {
s.spawn(move || {
let mut stdin = stdin;
let _ = stdin.write_all(input_bytes);
});
let stdout_handle = s.spawn(move || drain_pipe(stdout));
let stderr_handle = s.spawn(move || drain_pipe(stderr));
let status_result = wait_with_timeout(&mut child, timeout);
let stdout_buf = stdout_handle.join().unwrap_or_default();
let stderr_buf = stderr_handle.join().unwrap_or_default();
(stdout_buf, stderr_buf, status_result)
});
let status = status_result?;
let stdout_str = String::from_utf8_lossy(&stdout_buf);
if !stderr_buf.is_empty() {
let stderr_str = String::from_utf8_lossy(&stderr_buf);
let stderr_trimmed = stderr_str.trim();
if !stderr_trimmed.is_empty() {
debug!(
target: "rpm_spec_analyzer::shellcheck",
stderr = %stderr_trimmed,
"shellcheck non-fatal stderr",
);
}
}
if stdout_str.trim().is_empty() {
if status.success() {
return Ok(Vec::new());
}
let stderr_str = String::from_utf8_lossy(&stderr_buf);
return Err(ShellcheckError::EmptyOutput {
status,
stderr: stderr_str.trim().to_owned(),
});
}
let parsed: ShellcheckOutput = serde_json::from_str(&stdout_str)?;
Ok(parsed.findings)
}
fn drain_pipe<R: Read>(mut pipe: R) -> Vec<u8> {
let mut buf = Vec::new();
let _ = pipe.read_to_end(&mut buf);
buf
}
fn wait_with_timeout(child: &mut Child, timeout: Duration) -> Result<ExitStatus, ShellcheckError> {
let deadline = Instant::now() + timeout;
loop {
match child.try_wait().map_err(ShellcheckError::Wait)? {
Some(status) => return Ok(status),
None => {
if Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
return Err(ShellcheckError::Timeout(timeout));
}
std::thread::sleep(WAIT_POLL);
}
}
}
}
fn parse_sc_code(s: &str) -> Option<u32> {
let s = s.trim();
let digits = s
.strip_prefix("SC")
.or_else(|| s.strip_prefix("sc"))
.or_else(|| s.strip_prefix("Sc"))
.unwrap_or(s);
digits.parse::<u32>().ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mask_macros_pads_to_length() {
let input = "rm -rf %{buildroot}/usr";
let out = mask_macros(input);
assert_eq!(out, "rm -rf ____________/usr");
assert_eq!(out.len(), input.len());
}
#[test]
fn mask_macros_keeps_test_expression_one_token() {
let out = mask_macros("if [ -f %{pgdatadir}/PG_VERSION ]; then");
assert!(!out.contains(" "), "no double-space leaks into [: {out}");
assert!(out.contains("_/PG_VERSION"));
}
#[test]
fn mask_macros_handles_plain_form() {
let out = mask_macros("echo %name and %{?foo}");
assert_eq!(out, "echo _____ and _______");
}
#[test]
fn mask_macros_keeps_double_percent() {
let out = mask_macros("printf '%%d\\n' 1");
assert_eq!(out, "printf '%%d\\n' 1");
}
#[test]
fn mask_macros_handles_shell_macro() {
let out = mask_macros("ver=%(date +%Y)");
assert_eq!(out.len(), "ver=%(date +%Y)".len());
assert!(out.starts_with("ver=_"));
}
#[test]
fn mask_macros_handles_expr_macro() {
let out = mask_macros("v=%[1 + 2]");
assert_eq!(out.len(), "v=%[1 + 2]".len());
assert!(out.starts_with("v=_"));
}
#[test]
fn mask_macros_borrows_when_no_percent() {
let line = "echo hello world";
let out = mask_macros(line);
assert!(matches!(out, Cow::Borrowed(_)), "expected zero-copy path");
assert_eq!(out, line);
}
#[test]
fn mask_macros_preserves_multibyte_utf8() {
let line = "echo привет %{name} мир";
let out = mask_macros(line);
assert_eq!(out.len(), line.len(), "byte length must be preserved");
assert!(out.contains("привет"));
assert!(out.contains("мир"));
assert!(out.contains("_______"));
}
#[test]
fn is_rpm_conditional_recognises_keywords() {
assert!(is_rpm_conditional_line("%if 0%{?rhel}"));
assert!(is_rpm_conditional_line(" %else"));
assert!(is_rpm_conditional_line("%endif"));
assert!(is_rpm_conditional_line("%ifarch x86_64"));
assert!(!is_rpm_conditional_line("echo %if"));
assert!(!is_rpm_conditional_line("%install"));
}
#[test]
fn build_input_replaces_header_with_shebang() {
let slice = "%install\nmkdir -p $RPM_BUILD_ROOT\n";
let (out, map) = build_shellcheck_input(slice);
assert!(out.starts_with("#!/bin/bash\n"));
assert!(out.contains("mkdir -p $RPM_BUILD_ROOT"));
assert_eq!(map, vec![1, 2]);
}
#[test]
fn build_input_strips_conditional_lines() {
let slice = "%install\n%if 0%{?foo}\necho yes\n%endif\n";
let (out, map) = build_shellcheck_input(slice);
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines, vec!["#!/bin/bash", "echo yes"]);
assert_eq!(map, vec![1, 3]);
}
#[test]
fn build_input_preserves_continuation_across_if() {
let slice = "%build\n./configure \\\n --foo \\\n%if 1\n --bar \\\n%endif\n --baz\n";
let (out, _) = build_shellcheck_input(slice);
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines[0], "#!/bin/bash");
assert_eq!(lines[1], "./configure \\");
assert_eq!(lines[2], " --foo \\");
assert_eq!(lines[3], " --bar \\");
assert_eq!(lines[4], " --baz");
}
#[test]
fn parse_sc_code_accepts_variants() {
assert_eq!(parse_sc_code("SC2086"), Some(2086));
assert_eq!(parse_sc_code("sc2086"), Some(2086));
assert_eq!(parse_sc_code("2086"), Some(2086));
assert_eq!(parse_sc_code(" SC2086 "), Some(2086));
assert_eq!(parse_sc_code("nope"), None);
}
#[test]
fn line_range_returns_slice_offsets() {
let slice = "abc\ndef\nghi";
let offsets = compute_line_offsets(slice);
assert_eq!(offsets, vec![0, 4, 8]);
assert_eq!(line_range(&offsets, slice, 0), (0, 3));
assert_eq!(line_range(&offsets, slice, 1), (4, 7));
assert_eq!(line_range(&offsets, slice, 2), (8, 11));
assert_eq!(line_range(&offsets, slice, 99), (8, 11));
}
#[test]
fn default_disabled_baseline_locked() {
for code in &[SC2164, SC3044, SC1090, SC1091, SC2050, SC2086, SC2317] {
assert!(
DEFAULT_DISABLED.contains(code),
"expected SC{code} in DEFAULT_DISABLED",
);
}
}
#[test]
fn set_config_normalizes_disable() {
let mut lint = ShellcheckLint::new();
let mut cfg = Config::default();
cfg.shellcheck.disable = vec!["SC2086".into(), "2155".into(), "bogus".into()];
lint.set_config(&cfg);
assert!(lint.disable.contains(&2086));
assert!(lint.disable.contains(&2155));
assert!(!lint.disable.contains(&0));
for code in DEFAULT_DISABLED {
assert!(lint.disable.contains(code), "baseline SC{code} missing");
}
}
#[test]
fn enable_unsuppresses_baseline_codes() {
let mut lint = ShellcheckLint::new();
let mut cfg = Config::default();
cfg.shellcheck.enable = vec!["SC2164".into()];
lint.set_config(&cfg);
assert!(!lint.disable.contains(&2164));
assert!(lint.disable.contains(&3044));
}
#[test]
fn shell_override_takes_effect() {
let mut lint = ShellcheckLint::new();
assert_eq!(lint.shell, "bash");
let mut cfg = Config::default();
cfg.shellcheck.shell = Some("sh".into());
lint.set_config(&cfg);
assert_eq!(lint.shell, "sh");
}
#[test]
fn shell_override_rejects_unknown_dialect() {
let mut lint = ShellcheckLint::new();
let mut cfg = Config::default();
cfg.shellcheck.shell = Some("fish".into());
lint.set_config(&cfg);
assert_eq!(lint.shell, DEFAULT_SHELL);
}
#[test]
fn timeout_config_overrides_default() {
let mut lint = ShellcheckLint::new();
let mut cfg = Config::default();
cfg.shellcheck.timeout_secs = Some(5);
lint.set_config(&cfg);
assert_eq!(lint.timeout, Duration::from_secs(5));
}
#[test]
fn unavailability_emits_single_diagnostic() {
let src1 = "%install\nmkdir -p /tmp\n";
let src2 = "%check\nmake test\n";
let src = format!("{src1}{src2}");
let mut lint = ShellcheckLint::new();
let mut cfg = Config::default();
cfg.shellcheck.binary = Some("/nonexistent/shellcheck-binary".into());
lint.set_config(&cfg);
lint.set_source(&src);
lint.process_section(Span::new(0, src1.len(), 1, 1, 2, 14));
lint.process_section(Span::new(src1.len(), src.len(), 3, 1, 4, 10));
let diags = lint.take_diagnostics();
assert_eq!(diags.len(), 1, "exactly one RPM201 expected");
assert_eq!(diags[0].lint_id, "RPM201");
}
#[test]
fn build_finding_diagnostics_handles_out_of_range_line() {
use crate::diagnostic::Severity as Sev;
let section_text = "%install\necho hello\n";
let line_map = vec![1u32, 2u32];
let span = Span::new(0, section_text.len(), 1, 1, 2, 12);
let f = ShellcheckFinding {
line: 999, end_line: None,
level: "warning".to_owned(),
code: 2086,
message: "synthetic".to_owned(),
fix: None,
};
let disable = HashSet::new();
let diags = build_finding_diagnostics(vec![f], section_text, &line_map, span, &disable);
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].primary_span.start_line, 2);
assert_eq!(diags[0].severity, Sev::Warn);
}
#[test]
fn real_shellcheck_flags_known_issue() {
if probe_binary(None).is_err() {
eprintln!("shellcheck not installed; skipping integration test");
return;
}
let src = "%install\nfunc() { echo $@; }\nfunc a b\n";
let mut lint = ShellcheckLint::new();
lint.set_config(&Config::default());
lint.set_source(src);
let span = Span::new(0, src.len(), 1, 1, 3, 11);
lint.process_section(span);
let diags = lint.take_diagnostics();
assert!(
diags.iter().any(|d| d.lint_id == "RPM200"),
"expected at least one RPM200 finding, got {diags:?}"
);
for d in &diags {
assert!(d.message.starts_with("[SC"), "message: {}", d.message);
}
}
#[test]
fn real_shellcheck_respects_disable_list() {
if probe_binary(None).is_err() {
eprintln!("shellcheck not installed; skipping integration test");
return;
}
let src = "%install\nfunc() { echo $@; }\nfunc a b\n";
let span = Span::new(0, src.len(), 1, 1, 3, 11);
let mut baseline = ShellcheckLint::new();
baseline.set_config(&Config::default());
baseline.set_source(src);
baseline.process_section(span);
let baseline_diags = baseline.take_diagnostics();
assert!(!baseline_diags.is_empty());
let codes: Vec<String> = baseline_diags
.iter()
.filter_map(|d| {
let msg = &d.message;
let start = msg.find("[SC")? + 3;
let end = msg[start..].find(':')?;
Some(format!("SC{}", &msg[start..start + end]))
})
.collect();
let mut cfg = Config::default();
cfg.shellcheck.disable = codes;
let mut filtered = ShellcheckLint::new();
filtered.set_config(&cfg);
filtered.set_source(src);
filtered.process_section(span);
let filtered_diags = filtered.take_diagnostics();
assert!(
filtered_diags.is_empty(),
"expected disable filter to suppress all findings, got {filtered_diags:?}"
);
}
}