use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use anyhow::{Context, Result};
use globset::{Glob, GlobMatcher};
use rustc_hash::FxHashMap;
use rustc_hash::FxHashSet;
use shuck_semantic::{UnreachedFunctionAnalysisOptions, UnusedAssignmentAnalysisOptions};
use crate::ambient_contracts::ResolvedAmbientContracts;
use crate::{Category, Rule, RuleSelector, RuleSet, Severity, ShellDialect};
const DEFAULT_DISABLED_NON_STYLE_RULES: &[Rule] = &[
Rule::ImplicitGlobalInFunction,
Rule::MutableGlobal,
Rule::UnanchoredSourcePath,
Rule::FunctionCalledBeforeDefined,
];
const DEFAULT_C160_ALLOWED_ANCHORS: &[&str] = &[
"${BASH_SOURCE[0]%/*}",
"$(dirname \"$0\")",
"$(dirname \"${BASH_SOURCE[0]}\")",
];
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct LinterRuleOptions {
pub c001: C001RuleOptions,
pub c063: C063RuleOptions,
pub s078: S078RuleOptions,
pub s079: S079RuleOptions,
pub s080: S080RuleOptions,
pub s081: S081RuleOptions,
pub s082: S082RuleOptions,
pub s083: S083RuleOptions,
pub s084: S084RuleOptions,
pub s085: S085RuleOptions,
pub c158: C158RuleOptions,
pub c159: C159RuleOptions,
pub c160: C160RuleOptions,
pub c161: C161RuleOptions,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct C001RuleOptions {
pub treat_indirect_expansion_targets_as_used: bool,
}
impl C001RuleOptions {
pub(crate) fn semantic_options(&self) -> UnusedAssignmentAnalysisOptions {
UnusedAssignmentAnalysisOptions {
treat_indirect_expansion_targets_as_used: self.treat_indirect_expansion_targets_as_used,
report_unreachable_assignments: true,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct C063RuleOptions {
pub report_unreached_nested_definitions: bool,
}
impl C063RuleOptions {
pub(crate) fn semantic_options(&self) -> UnreachedFunctionAnalysisOptions {
UnreachedFunctionAnalysisOptions {
report_unreached_nested_definitions: self.report_unreached_nested_definitions,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct S080RuleOptions {
pub max_lines: usize,
pub count: String,
}
impl Default for S080RuleOptions {
fn default() -> Self {
Self {
max_lines: 100,
count: "physical".to_owned(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct S078RuleOptions {
pub allowed_shells: Vec<String>,
}
impl Default for S078RuleOptions {
fn default() -> Self {
Self {
allowed_shells: vec!["bash".to_owned()],
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct S079RuleOptions {
pub allowed_forms: Vec<String>,
pub allowed_paths: Vec<String>,
}
impl Default for S079RuleOptions {
fn default() -> Self {
Self {
allowed_forms: vec!["env-lookup".to_owned()],
allowed_paths: vec!["/bin/bash".to_owned(), "/usr/bin/env bash".to_owned()],
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct S081RuleOptions {
pub ignore_shebang_only_files: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct S082RuleOptions {
pub kinds: Vec<String>,
pub require_owner: bool,
pub require_message: bool,
}
impl Default for S082RuleOptions {
fn default() -> Self {
Self {
kinds: vec!["TODO".to_owned(), "FIXME".to_owned(), "XXX".to_owned()],
require_owner: true,
require_message: true,
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum S083FunctionDocRequirement {
All,
Exported,
#[default]
Long,
Parameterized,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct S083RuleOptions {
pub require_for: S083FunctionDocRequirement,
pub long_function_line_threshold: usize,
}
impl Default for S083RuleOptions {
fn default() -> Self {
Self {
require_for: S083FunctionDocRequirement::Long,
long_function_line_threshold: 10,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct S084RuleOptions {
pub require_globals: bool,
pub require_arguments: bool,
pub require_outputs: bool,
pub require_returns: bool,
}
impl Default for S084RuleOptions {
fn default() -> Self {
Self {
require_globals: true,
require_arguments: true,
require_outputs: true,
require_returns: true,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct S085RuleOptions {
pub non_trivial_line_threshold: usize,
pub non_trivial_function_count: usize,
pub main_name: String,
}
impl Default for S085RuleOptions {
fn default() -> Self {
Self {
non_trivial_line_threshold: 30,
non_trivial_function_count: 2,
main_name: "main".to_owned(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct C158RuleOptions {
pub treat_readonly_as_documented: bool,
pub treat_export_as_intentional: bool,
}
impl Default for C158RuleOptions {
fn default() -> Self {
Self {
treat_readonly_as_documented: true,
treat_export_as_intentional: true,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct C159RuleOptions {
pub allow_conditional_init: bool,
}
impl Default for C159RuleOptions {
fn default() -> Self {
Self {
allow_conditional_init: true,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct C160RuleOptions {
pub allowed_anchors: Vec<String>,
}
impl Default for C160RuleOptions {
fn default() -> Self {
Self {
allowed_anchors: DEFAULT_C160_ALLOWED_ANCHORS
.iter()
.map(|anchor| (*anchor).to_owned())
.collect(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct C161RuleOptions {
pub ignore_after_source: bool,
}
impl Default for C161RuleOptions {
fn default() -> Self {
Self {
ignore_after_source: true,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LinterSettings {
pub rules: RuleSet,
pub severity_overrides: FxHashMap<Rule, Severity>,
pub shell: ShellDialect,
pub ambient_shell_options: AmbientShellOptions,
pub ambient_contracts: Arc<ResolvedAmbientContracts>,
pub analyzed_paths: Option<Arc<FxHashSet<PathBuf>>>,
pub per_file_ignores: Arc<CompiledPerFileIgnoreList>,
pub report_environment_style_names: bool,
pub resolve_source_closure: bool,
pub rule_options: LinterRuleOptions,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct AmbientShellOptions {
pub errexit: bool,
pub pipefail: bool,
}
impl Default for LinterSettings {
fn default() -> Self {
Self {
rules: Self::default_rules(),
severity_overrides: FxHashMap::default(),
shell: ShellDialect::Unknown,
ambient_shell_options: AmbientShellOptions::default(),
ambient_contracts: Arc::new(ResolvedAmbientContracts::default()),
analyzed_paths: None,
per_file_ignores: Arc::new(CompiledPerFileIgnoreList::default()),
report_environment_style_names: false,
resolve_source_closure: true,
rule_options: LinterRuleOptions::default(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PerFileIgnore {
pattern: String,
rules: RuleSet,
}
impl PerFileIgnore {
pub fn new(pattern: impl Into<String>, rules: RuleSet) -> Self {
Self {
pattern: pattern.into(),
rules,
}
}
pub fn pattern(&self) -> &str {
&self.pattern
}
pub const fn rules(&self) -> RuleSet {
self.rules
}
}
#[derive(Debug, Clone, Default)]
pub struct CompiledPerFileIgnoreList {
project_root: PathBuf,
entries: Vec<CompiledPerFileIgnore>,
}
impl PartialEq for CompiledPerFileIgnoreList {
fn eq(&self, other: &Self) -> bool {
self.project_root == other.project_root && self.entries == other.entries
}
}
impl Eq for CompiledPerFileIgnoreList {}
#[derive(Debug, Clone)]
struct CompiledPerFileIgnore {
pattern: String,
basename_matcher: GlobMatcher,
relative_matcher: GlobMatcher,
absolute_matcher: GlobMatcher,
negated: bool,
rules: RuleSet,
}
impl PartialEq for CompiledPerFileIgnore {
fn eq(&self, other: &Self) -> bool {
self.pattern == other.pattern && self.negated == other.negated && self.rules == other.rules
}
}
impl Eq for CompiledPerFileIgnore {}
impl LinterSettings {
pub fn for_rule(rule: Rule) -> Self {
Self {
rules: RuleSet::from_iter([rule]),
..Self::default()
}
}
pub fn for_rules(rules: impl IntoIterator<Item = Rule>) -> Self {
Self {
rules: rules.into_iter().collect(),
..Self::default()
}
}
pub fn default_rules() -> RuleSet {
Rule::iter()
.filter(|rule| !matches!(rule.category(), Category::Style))
.collect::<RuleSet>()
.subtract(&default_disabled_non_style_rules())
}
pub fn from_selectors(select: &[RuleSelector], ignore: &[RuleSelector]) -> Self {
let mut rules = RuleSet::EMPTY;
for selector in select {
rules = rules.union(&selector.into_rule_set());
}
for selector in ignore {
rules = rules.subtract(&selector.into_rule_set());
}
Self {
rules,
..Self::default()
}
}
pub fn with_shell(mut self, shell: ShellDialect) -> Self {
self.shell = shell;
self
}
pub fn with_ambient_shell_options(
mut self,
ambient_shell_options: AmbientShellOptions,
) -> Self {
self.ambient_shell_options = ambient_shell_options;
self
}
pub fn analyzed_path_set(paths: impl IntoIterator<Item = PathBuf>) -> Arc<FxHashSet<PathBuf>> {
Arc::new(
paths
.into_iter()
.map(|path| std::fs::canonicalize(&path).unwrap_or(path))
.collect(),
)
}
pub fn with_analyzed_path_set(mut self, paths: Arc<FxHashSet<PathBuf>>) -> Self {
self.analyzed_paths = Some(paths);
self
}
pub fn with_analyzed_paths(self, paths: impl IntoIterator<Item = PathBuf>) -> Self {
self.with_analyzed_path_set(Self::analyzed_path_set(paths))
}
pub fn with_per_file_ignores(mut self, per_file_ignores: CompiledPerFileIgnoreList) -> Self {
self.per_file_ignores = Arc::new(per_file_ignores);
self
}
pub fn with_c001_treat_indirect_expansion_targets_as_used(mut self, value: bool) -> Self {
self.rule_options
.c001
.treat_indirect_expansion_targets_as_used = value;
self
}
pub fn with_resolve_source_closure(mut self, value: bool) -> Self {
self.resolve_source_closure = value;
self
}
pub fn with_c063_report_unreached_nested_definitions(mut self, value: bool) -> Self {
self.rule_options.c063.report_unreached_nested_definitions = value;
self
}
pub fn with_s080_max_lines(mut self, value: usize) -> Self {
self.rule_options.s080.max_lines = value;
self
}
pub fn with_s080_count(mut self, value: impl Into<String>) -> Self {
self.rule_options.s080.count = value.into();
self
}
pub fn with_s081_ignore_shebang_only_files(mut self, value: bool) -> Self {
self.rule_options.s081.ignore_shebang_only_files = value;
self
}
pub fn with_s082_kinds(mut self, kinds: impl IntoIterator<Item = String>) -> Self {
self.rule_options.s082.kinds = kinds.into_iter().collect();
self
}
pub fn with_s082_require_owner(mut self, value: bool) -> Self {
self.rule_options.s082.require_owner = value;
self
}
pub fn with_s082_require_message(mut self, value: bool) -> Self {
self.rule_options.s082.require_message = value;
self
}
pub fn with_s083_require_for(mut self, value: S083FunctionDocRequirement) -> Self {
self.rule_options.s083.require_for = value;
self
}
pub fn with_s083_long_function_line_threshold(mut self, value: usize) -> Self {
self.rule_options.s083.long_function_line_threshold = value;
self
}
pub fn with_s084_require_globals(mut self, value: bool) -> Self {
self.rule_options.s084.require_globals = value;
self
}
pub fn with_s084_require_arguments(mut self, value: bool) -> Self {
self.rule_options.s084.require_arguments = value;
self
}
pub fn with_s084_require_outputs(mut self, value: bool) -> Self {
self.rule_options.s084.require_outputs = value;
self
}
pub fn with_s084_require_returns(mut self, value: bool) -> Self {
self.rule_options.s084.require_returns = value;
self
}
pub fn with_s085_non_trivial_line_threshold(mut self, value: usize) -> Self {
self.rule_options.s085.non_trivial_line_threshold = value;
self
}
pub fn with_s085_non_trivial_function_count(mut self, value: usize) -> Self {
self.rule_options.s085.non_trivial_function_count = value;
self
}
pub fn with_s085_main_name(mut self, value: impl Into<String>) -> Self {
self.rule_options.s085.main_name = value.into();
self
}
pub fn with_s078_allowed_shells(
mut self,
allowed_shells: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
self.rule_options.s078.allowed_shells =
allowed_shells.into_iter().map(Into::into).collect();
self
}
pub fn with_c158_treat_readonly_as_documented(mut self, value: bool) -> Self {
self.rule_options.c158.treat_readonly_as_documented = value;
self
}
pub fn with_c158_treat_export_as_intentional(mut self, value: bool) -> Self {
self.rule_options.c158.treat_export_as_intentional = value;
self
}
pub fn with_c159_allow_conditional_init(mut self, value: bool) -> Self {
self.rule_options.c159.allow_conditional_init = value;
self
}
pub fn with_c160_allowed_anchors<I, S>(mut self, anchors: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.rule_options.c160.allowed_anchors = anchors.into_iter().map(Into::into).collect();
self
}
pub fn with_c161_ignore_after_source(mut self, value: bool) -> Self {
self.rule_options.c161.ignore_after_source = value;
self
}
pub fn with_s079_allowed_forms(
mut self,
allowed_forms: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
self.rule_options.s079.allowed_forms = allowed_forms.into_iter().map(Into::into).collect();
self
}
pub fn with_s079_allowed_paths(
mut self,
allowed_paths: impl IntoIterator<Item = impl Into<String>>,
) -> Self {
self.rule_options.s079.allowed_paths = allowed_paths.into_iter().map(Into::into).collect();
self
}
pub fn per_file_ignored_rules(&self, path: Option<&Path>) -> RuleSet {
path.map_or(RuleSet::EMPTY, |path| {
self.per_file_ignores.ignored_rules(path)
})
}
}
fn default_disabled_non_style_rules() -> RuleSet {
DEFAULT_DISABLED_NON_STYLE_RULES.iter().copied().collect()
}
impl CompiledPerFileIgnoreList {
pub fn resolve(
project_root: impl Into<PathBuf>,
per_file_ignores: impl IntoIterator<Item = PerFileIgnore>,
) -> Result<Self> {
let project_root = project_root.into();
let entries = per_file_ignores
.into_iter()
.map(|per_file_ignore| {
let mut pattern = per_file_ignore.pattern().to_owned();
let negated = pattern.starts_with('!');
if negated {
pattern.drain(..1);
}
let basename_matcher = Glob::new(&pattern)
.with_context(|| format!("invalid glob {:?}", per_file_ignore.pattern()))?
.compile_matcher();
let relative_matcher = Glob::new(&pattern)
.with_context(|| format!("invalid glob {:?}", per_file_ignore.pattern()))?
.compile_matcher();
let absolute_matcher = Glob::new(&pattern)
.with_context(|| format!("invalid glob {:?}", per_file_ignore.pattern()))?
.compile_matcher();
Ok(CompiledPerFileIgnore {
pattern: per_file_ignore.pattern().to_owned(),
basename_matcher,
relative_matcher,
absolute_matcher,
negated,
rules: per_file_ignore.rules(),
})
})
.collect::<Result<Vec<_>>>()?;
Ok(Self {
project_root,
entries,
})
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn ignored_rules(&self, path: &Path) -> RuleSet {
let relative_path = path.strip_prefix(&self.project_root).unwrap_or(path);
let file_name = relative_path.file_name().or_else(|| path.file_name());
let Some(file_name) = file_name else {
return RuleSet::EMPTY;
};
self.entries.iter().fold(RuleSet::EMPTY, |ignored, entry| {
let matches = entry.basename_matcher.is_match(file_name)
|| entry.relative_matcher.is_match(relative_path)
|| matches_absolute_path(&entry.absolute_matcher, path);
let applies = if entry.negated { !matches } else { matches };
if applies {
ignored.union(&entry.rules)
} else {
ignored
}
})
}
}
fn matches_absolute_path(matcher: &GlobMatcher, path: &Path) -> bool {
matcher.is_match(path)
|| normalized_absolute_match_path(path)
.as_deref()
.is_some_and(|normalized| matcher.is_match(normalized))
}
fn normalized_absolute_match_path(path: &Path) -> Option<PathBuf> {
let path = path.to_string_lossy();
if let Some(stripped) = path.strip_prefix(r"\\?\UNC\") {
return Some(PathBuf::from(format!(r"\\{stripped}")));
}
path.strip_prefix(r"\\?\").map(PathBuf::from)
}
#[cfg(test)]
mod tests {
use std::path::{Path, PathBuf};
use tempfile::tempdir;
use super::*;
use crate::RuleSet;
#[test]
fn default_rules_exclude_all_style_rules() {
let defaults = LinterSettings::default_rules();
for rule in Rule::iter().filter(|rule| matches!(rule.category(), Category::Style)) {
assert!(
!defaults.contains(rule),
"{rule:?} should be disabled by default"
);
}
}
#[test]
fn default_rules_include_non_style_rules() {
let defaults = LinterSettings::default_rules();
assert!(defaults.contains(Rule::UndefinedVariable));
assert!(defaults.contains(Rule::ConstantCaseSubject));
assert!(defaults.contains(Rule::RmGlobOnVariablePath));
assert!(!defaults.contains(Rule::ImplicitGlobalInFunction));
assert!(!defaults.contains(Rule::MutableGlobal));
assert!(!defaults.contains(Rule::UnanchoredSourcePath));
assert!(!defaults.contains(Rule::FunctionCalledBeforeDefined));
assert!(!defaults.contains(Rule::AmpersandSemicolon));
}
#[test]
fn default_rules_exclude_verified_default_disabled_non_style_rules() {
let defaults = LinterSettings::default_rules();
for rule in DEFAULT_DISABLED_NON_STYLE_RULES {
assert!(
!defaults.contains(*rule),
"{rule:?} should be excluded from the native default baseline"
);
assert!(
!matches!(rule.category(), Category::Style),
"{rule:?} must stay in the non-style default-disabled set"
);
}
}
#[test]
fn with_analyzed_path_set_reuses_shared_set() {
let tempdir = tempdir().unwrap();
let script_path = tempdir.path().join("script.sh");
std::fs::write(&script_path, "echo hi\n").unwrap();
let analyzed_paths = LinterSettings::analyzed_path_set([script_path.clone()]);
let settings =
LinterSettings::default().with_analyzed_path_set(Arc::clone(&analyzed_paths));
let stored = settings.analyzed_paths.as_ref().unwrap();
assert!(Arc::ptr_eq(stored, &analyzed_paths));
assert!(stored.contains(&std::fs::canonicalize(script_path).unwrap()));
}
#[test]
fn matches_absolute_per_file_ignore_patterns() {
let tempdir = tempdir().unwrap();
let project_root = tempdir.path().to_path_buf();
let script_path = project_root.join("nested").join("script.sh");
let absolute_pattern = script_path
.parent()
.unwrap()
.join("*.sh")
.to_string_lossy()
.into_owned();
let per_file_ignores = CompiledPerFileIgnoreList::resolve(
project_root,
[PerFileIgnore::new(
absolute_pattern,
RuleSet::from_iter([Rule::UnusedAssignment]),
)],
)
.unwrap();
let ignored_rules = per_file_ignores.ignored_rules(&script_path);
assert!(ignored_rules.contains(Rule::UnusedAssignment));
}
#[test]
fn strips_windows_verbatim_disk_prefixes_for_absolute_matching() {
assert_eq!(
normalized_absolute_match_path(Path::new(r"\\?\C:\repo\nested\script.sh")),
Some(PathBuf::from(r"C:\repo\nested\script.sh"))
);
}
#[test]
fn strips_windows_verbatim_unc_prefixes_for_absolute_matching() {
assert_eq!(
normalized_absolute_match_path(Path::new(r"\\?\UNC\server\share\script.sh")),
Some(PathBuf::from(r"\\server\share\script.sh"))
);
}
}