use std::path::Path;
use anyhow::{Result, bail};
use serde::{Deserialize, Serialize};
use crate::config::StandardConfig;
use crate::engines::shellcheck::{
self, CheckSpec, IoOptions, data::shell_for_executable, interface::Severity,
};
use crate::graph::Product;
fn default_severity() -> String {
"style".to_string()
}
const CHECK_STACK_SIZE: usize = 256 * 1024 * 1024;
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct IshellcheckConfig {
#[serde(default)]
pub shell: String,
#[serde(default)]
pub external_sources: bool,
#[serde(default)]
pub source_path: Vec<String>,
#[serde(default = "default_severity")]
pub severity: String,
#[serde(default)]
pub exclude: Vec<String>,
#[serde(default)]
pub include: Vec<String>,
#[serde(default)]
pub enable: Vec<String>,
#[serde(default)]
pub rcfile: String,
#[serde(default)]
pub norc: bool,
#[serde(default)]
pub check_sourced: bool,
#[serde(default)]
pub extended_analysis: String,
#[serde(flatten)]
pub standard: StandardConfig,
}
impl Default for IshellcheckConfig {
fn default() -> Self {
Self {
shell: String::new(),
external_sources: false,
source_path: Vec::new(),
severity: default_severity(),
exclude: Vec::new(),
include: Vec::new(),
enable: Vec::new(),
rcfile: String::new(),
norc: false,
check_sourced: false,
extended_analysis: String::new(),
standard: StandardConfig::default(),
}
}
}
pub struct IshellcheckProcessor {
config: IshellcheckConfig,
}
fn parse_code(s: &str) -> Result<i64> {
let digits = s
.strip_prefix("SC")
.or_else(|| s.strip_prefix("sc"))
.unwrap_or(s);
digits.parse::<i64>().map_err(|_| {
anyhow::anyhow!("ishellcheck: invalid code {s:?} (expected e.g. SC2034 or 2034)")
})
}
impl IshellcheckProcessor {
pub const fn new(config: IshellcheckConfig) -> Self {
Self { config }
}
fn template(&self) -> Result<(CheckSpec, IoOptions)> {
let c = &self.config;
let mut spec = CheckSpec::new("", String::new());
if !c.shell.is_empty() {
match shell_for_executable(&c.shell) {
Some(sh) => spec.shell_type_override = Some(sh),
None => bail!("ishellcheck: unknown shell {:?}", c.shell),
}
}
spec.min_severity = match c.severity.as_str() {
"error" => Severity::ErrorC,
"warning" => Severity::WarningC,
"info" => Severity::InfoC,
"style" => Severity::StyleC,
other => bail!("ishellcheck: unknown severity {other:?} (error, warning, info, style)"),
};
spec.excluded_warnings = c
.exclude
.iter()
.map(|s| parse_code(s))
.collect::<Result<_>>()?;
if !c.include.is_empty() {
spec.included_warnings = Some(
c.include
.iter()
.map(|s| parse_code(s))
.collect::<Result<_>>()?,
);
}
spec.optional_checks.clone_from(&c.enable);
spec.ignore_rc = c.norc;
spec.check_sourced = c.check_sourced;
spec.extended_analysis = match c.extended_analysis.as_str() {
"" => None,
"true" => Some(true),
"false" => Some(false),
other => {
bail!("ishellcheck: extended_analysis must be true, false or empty, not {other:?}")
}
};
let io = IoOptions {
external_sources: c.external_sources,
source_paths: c.source_path.clone(),
rcfile: if c.rcfile.is_empty() {
None
} else {
Some(c.rcfile.clone())
},
};
Ok((spec, io))
}
fn check_file(&self, file: &Path) -> Result<()> {
let (spec, io) = self.template()?;
let name = file.to_string_lossy().into_owned();
let handle = std::thread::Builder::new()
.stack_size(CHECK_STACK_SIZE)
.spawn(move || shellcheck::check_files(&[name], io, &spec))
.map_err(|e| anyhow::anyhow!("ishellcheck: failed to start the check thread: {e}"))?;
let Ok(lines) = handle.join() else {
bail!(
"ishellcheck: internal error while checking {}",
file.display()
);
};
if lines.is_empty() {
return Ok(());
}
bail!("{}", lines.join("\n"))
}
}
impl crate::processor::Processor for IshellcheckProcessor {
fn scan_config(&self) -> &crate::config::StandardConfig {
&self.config.standard
}
fn config_json(&self) -> Option<String> {
crate::processor::ProcessorBase::config_json(&self.config)
}
fn discovery(&self) -> crate::processor::Discovery {
crate::processor::Discovery::PerFile
}
fn required_tools(&self) -> Vec<String> {
Vec::new()
}
fn execute(&self, _ctx: &crate::build_context::BuildContext, product: &Product) -> Result<()> {
self.check_file(product.primary_input())
}
}
fn plugin_create(toml: &toml::Value) -> anyhow::Result<Box<dyn crate::processor::Processor>> {
crate::registries::deserialize_and_create(toml, |cfg| Box::new(IshellcheckProcessor::new(cfg)))
}
inventory::submit! {
crate::registries::RustAlternative { replaces: "processor.checker.shellcheck", by: "processor.checker.ishellcheck" }
}
inventory::submit! {
crate::registries::ProcessorPlugin {
version: 1,
name: "ishellcheck",
processor_type: crate::processor::ProcessorType::Checker,
create: plugin_create,
defconfig_json: crate::registries::default_config_json::<IshellcheckConfig>,
fields: &[
crate::config::FieldSpec { name: "shell", ty: crate::config::FieldType::String,
affects_output: true, required: false,
doc: "The shell dialect, as --shell (sh, bash, dash, ksh, busybox). Empty: from the shebang, directives or extension" },
crate::config::FieldSpec { name: "external_sources", ty: crate::config::FieldType::Bool,
affects_output: true, required: false,
doc: "Follow sourced files that are not inputs, as --external-sources" },
crate::config::FieldSpec { name: "source_path", ty: crate::config::FieldType::StringArray,
affects_output: true, required: false,
doc: "Where to look for sourced files, as --source-path (SCRIPTDIR is the script's directory)" },
crate::config::FieldSpec { name: "severity", ty: crate::config::FieldType::String,
affects_output: true, required: false,
doc: "The minimum severity reported, as --severity: error, warning, info, style" },
crate::config::FieldSpec { name: "exclude", ty: crate::config::FieldType::StringArray,
affects_output: true, required: false,
doc: "Codes to exclude, as --exclude (SC2034 or 2034)" },
crate::config::FieldSpec { name: "include", ty: crate::config::FieldType::StringArray,
affects_output: true, required: false,
doc: "Codes to report exclusively, as --include. Empty: all codes" },
crate::config::FieldSpec { name: "enable", ty: crate::config::FieldType::StringArray,
affects_output: true, required: false,
doc: "Optional checks to enable, as --enable (all for every one)" },
crate::config::FieldSpec { name: "rcfile", ty: crate::config::FieldType::String,
affects_output: true, required: false,
doc: "A .shellcheckrc to use instead of searching for one, as --rcfile" },
crate::config::FieldSpec { name: "norc", ty: crate::config::FieldType::Bool,
affects_output: true, required: false,
doc: "Ignore .shellcheckrc files, as --norc" },
crate::config::FieldSpec { name: "check_sourced", ty: crate::config::FieldType::Bool,
affects_output: true, required: false,
doc: "Report findings in sourced files too, as --check-sourced" },
crate::config::FieldSpec { name: "extended_analysis", ty: crate::config::FieldType::String,
affects_output: true, required: false,
doc: "Extended dataflow analysis, as --extended-analysis: true, false, or empty for the default" },
],
omit_standard_fields: &[],
scan_defaults: Some(crate::config::ScanDefaultsData { src_dirs: &[], src_extensions: &[".sh", ".bash"], src_exclude_dirs: &[] }),
defaults: Some(crate::config::ProcessorDefaults { dep_auto: &[".shellcheckrc"], ..crate::config::ProcessorDefaults::EMPTY }),
keywords: &["shell", "bash", "sh", "linter", "script", "shellcheck", "rust"],
description: "Lint shell scripts with ShellCheck's analysis (in-process)",
is_native: true,
is_rust: true,
supports_batch: false,
caches_outputs: true,
max_jobs_cap: None,
}
}