use std::collections::{BTreeMap, HashMap};
use std::fs;
use std::io::{self, BufWriter, Write};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::mpsc::{Receiver, TryRecvError, channel};
use std::{ffi::OsStr, io::IsTerminal};
use anyhow::{Result, anyhow};
use notify::{RecursiveMode, Watcher, recommended_watcher};
use rayon::prelude::*;
use serde::{Deserialize, Serialize};
use shuck_ast::TextSize;
use shuck_cache::{CacheKey, CacheKeyHasher};
use shuck_indexer::Indexer;
use shuck_linter::{
Applicability, CompiledPerFileIgnoreList, LinterSettings, PerFileIgnore, Rule, RuleSelector,
RuleSet, ShellCheckCodeMap, ShellDialect, SuppressionIndex, add_ignores_to_path,
first_statement_line, parse_directives,
};
use shuck_parser::{
Error as ParseError,
parser::{ParseResult, Parser},
};
use crate::ExitStatus;
use crate::args::{CheckCommand, FileSelectionArgs, PatternRuleSelectorPair, RuleSelectionArgs};
use crate::cache::resolve_cache_root;
use crate::commands::check_output::{
DisplayPosition, DisplaySpan, DisplayedApplicability, DisplayedDiagnostic,
DisplayedDiagnosticKind, DisplayedEdit, DisplayedFix, print_report_to,
};
use crate::commands::project_runner::{PendingProjectFile, prepare_project_runs};
use crate::config::{
ConfigArguments, LintConfig, discovered_config_path_for_root, load_project_config,
resolve_project_root_for_input,
};
use crate::discover::{DEFAULT_IGNORED_DIR_NAMES, DiscoveryOptions, ProjectRoot, normalize_path};
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub(crate) struct CheckReport {
diagnostics: Vec<DisplayedDiagnostic>,
cache_hits: usize,
cache_misses: usize,
fixes_applied: usize,
}
impl CheckReport {
fn exit_status(&self, exit_zero: bool, exit_non_zero_on_fix: bool) -> ExitStatus {
if exit_non_zero_on_fix && self.fixes_applied > 0 {
return ExitStatus::Failure;
}
diagnostics_exit_status(&self.diagnostics, exit_zero)
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
struct AddIgnoreReport {
diagnostics: Vec<DisplayedDiagnostic>,
directives_added: usize,
}
impl AddIgnoreReport {
fn exit_status(&self, exit_zero: bool) -> ExitStatus {
diagnostics_exit_status(&self.diagnostics, exit_zero)
}
}
fn diagnostics_exit_status(diagnostics: &[DisplayedDiagnostic], exit_zero: bool) -> ExitStatus {
let has_fatal = diagnostics.iter().any(|d| match &d.kind {
DisplayedDiagnosticKind::ParseError => true,
DisplayedDiagnosticKind::Lint { severity, .. } => severity == "error",
});
if has_fatal {
return ExitStatus::Failure;
}
if diagnostics.is_empty() || exit_zero {
ExitStatus::Success
} else {
ExitStatus::Failure
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct EffectiveCheckSettings {
enabled_rules: Vec<String>,
per_file_ignores: Vec<EffectivePerFileIgnore>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct EffectivePerFileIgnore {
pattern: String,
rules: Vec<String>,
}
impl EffectiveCheckSettings {
fn new(enabled_rules: RuleSet, per_file_ignores: &[PerFileIgnore]) -> Self {
let mut enabled_rules = enabled_rules
.iter()
.map(|rule| rule.code().to_owned())
.collect::<Vec<_>>();
enabled_rules.sort();
let mut per_file_ignores = per_file_ignores
.iter()
.map(|ignore| {
let mut rules = ignore
.rules()
.iter()
.map(|rule| rule.code().to_owned())
.collect::<Vec<_>>();
rules.sort();
EffectivePerFileIgnore {
pattern: ignore.pattern().to_owned(),
rules,
}
})
.collect::<Vec<_>>();
per_file_ignores.sort_by(|left, right| left.pattern.cmp(&right.pattern));
Self {
enabled_rules,
per_file_ignores,
}
}
}
impl CacheKey for EffectiveCheckSettings {
fn cache_key(&self, state: &mut CacheKeyHasher) {
state.write_tag(b"effective-check-settings");
self.enabled_rules.cache_key(state);
self.per_file_ignores.cache_key(state);
}
}
impl CacheKey for EffectivePerFileIgnore {
fn cache_key(&self, state: &mut CacheKeyHasher) {
self.pattern.cache_key(state);
self.rules.cache_key(state);
}
}
#[derive(Debug, Clone)]
struct ResolvedCheckSettings {
linter_settings: LinterSettings,
fixable_rules: RuleSet,
effective: EffectiveCheckSettings,
}
impl CacheKey for ResolvedCheckSettings {
fn cache_key(&self, state: &mut CacheKeyHasher) {
self.effective.cache_key(state);
}
}
#[derive(Debug, Clone, Default)]
struct RuleSelectionLayer {
select: Option<Vec<RuleSelector>>,
ignore: Vec<RuleSelector>,
extend_select: Vec<RuleSelector>,
per_file_ignores: Option<Vec<PerFileIgnoreSpec>>,
extend_per_file_ignores: Vec<PerFileIgnoreSpec>,
fixable: Option<Vec<RuleSelector>>,
unfixable: Vec<RuleSelector>,
extend_fixable: Vec<RuleSelector>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct PerFileIgnoreSpec {
pattern: String,
selectors: Vec<RuleSelector>,
}
impl PerFileIgnoreSpec {
fn into_ignore(self) -> PerFileIgnore {
let rules = self
.selectors
.into_iter()
.fold(RuleSet::EMPTY, |rules, selector| {
rules.union(&selector.into_rule_set())
});
PerFileIgnore::new(self.pattern, rules)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
enum CheckCacheData {
Success(Vec<CachedLintDiagnostic>),
ParseError(ParseCacheFailure),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct ParseCacheFailure {
message: String,
line: usize,
column: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct CachedLintDiagnostic {
start_line: usize,
start_column: usize,
end_line: usize,
end_column: usize,
code: String,
severity: String,
message: String,
fix: Option<CachedLintFix>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
enum CachedApplicability {
Safe,
Unsafe,
}
impl From<Applicability> for CachedApplicability {
fn from(value: Applicability) -> Self {
match value {
Applicability::Safe => Self::Safe,
Applicability::Unsafe => Self::Unsafe,
}
}
}
impl From<CachedApplicability> for DisplayedApplicability {
fn from(value: CachedApplicability) -> Self {
match value {
CachedApplicability::Safe => Self::Safe,
CachedApplicability::Unsafe => Self::Unsafe,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct CachedLintFix {
applicability: CachedApplicability,
message: Option<String>,
edits: Vec<CachedLintEdit>,
}
impl CachedLintFix {
fn from_displayed(fix: &DisplayedFix) -> Self {
Self {
applicability: match fix.applicability {
DisplayedApplicability::Safe => CachedApplicability::Safe,
DisplayedApplicability::Unsafe => CachedApplicability::Unsafe,
},
message: fix.message.clone(),
edits: fix
.edits
.iter()
.map(CachedLintEdit::from_displayed)
.collect(),
}
}
fn to_displayed(&self) -> DisplayedFix {
DisplayedFix {
applicability: self.applicability.into(),
message: self.message.clone(),
edits: self
.edits
.iter()
.map(CachedLintEdit::to_displayed)
.collect(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct CachedLintEdit {
start_line: usize,
start_column: usize,
end_line: usize,
end_column: usize,
content: String,
}
impl CachedLintEdit {
fn from_displayed(edit: &DisplayedEdit) -> Self {
Self {
start_line: edit.location.line,
start_column: edit.location.column,
end_line: edit.end_location.line,
end_column: edit.end_location.column,
content: edit.content.clone(),
}
}
fn to_displayed(&self) -> DisplayedEdit {
DisplayedEdit {
location: DisplayPosition::new(self.start_line, self.start_column),
end_location: DisplayPosition::new(self.end_line, self.end_column),
content: self.content.clone(),
}
}
}
impl CachedLintDiagnostic {
fn from_diagnostic(diagnostic: &shuck_linter::Diagnostic, source: &str) -> Self {
let fix = displayed_fix_from_diagnostic(diagnostic, source)
.as_ref()
.map(CachedLintFix::from_displayed);
Self {
start_line: diagnostic.span.start.line,
start_column: diagnostic.span.start.column,
end_line: diagnostic.span.end.line,
end_column: diagnostic.span.end.column,
code: diagnostic.code().to_owned(),
severity: diagnostic.severity.as_str().to_owned(),
message: diagnostic.message.clone(),
fix,
}
}
}
#[derive(Debug, Clone)]
struct FileCheckResult {
file: crate::discover::DiscoveredFile,
file_key: shuck_cache::FileCacheKey,
cache_data: CheckCacheData,
diagnostics: Vec<DisplayedDiagnostic>,
fixes_applied: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct WatchTarget {
watch_path: PathBuf,
watch_paths: Vec<PathBuf>,
recursive: bool,
match_paths: Vec<PathBuf>,
}
#[derive(Debug, Clone)]
struct WatchPath {
resolved_path: PathBuf,
canonical_path: PathBuf,
}
impl WatchTarget {
fn recursive(path: PathBuf) -> Self {
Self {
watch_path: path.clone(),
watch_paths: vec![path.clone()],
recursive: true,
match_paths: vec![path],
}
}
fn file(path: PathBuf) -> Self {
let watch_path = path.parent().unwrap_or(&path).to_path_buf();
Self {
watch_path: watch_path.clone(),
watch_paths: vec![watch_path],
recursive: false,
match_paths: vec![path],
}
}
fn recursive_mode(&self) -> RecursiveMode {
if self.recursive {
RecursiveMode::Recursive
} else {
RecursiveMode::NonRecursive
}
}
fn matches_event_path(&self, path: &Path) -> bool {
if self.recursive {
self.match_paths
.iter()
.any(|match_path| path.starts_with(match_path))
} else {
self.match_paths.iter().any(|match_path| match_path == path)
}
}
fn add_match_path(&mut self, path: PathBuf) {
self.match_paths.push(path);
self.match_paths.sort();
self.match_paths.dedup();
}
fn add_watch_path(&mut self, path: PathBuf) {
self.watch_paths.push(path);
self.watch_paths.sort();
self.watch_paths.dedup();
}
fn merge(&mut self, other: WatchTarget) {
debug_assert_eq!(self.watch_path, other.watch_path);
debug_assert_eq!(self.recursive, other.recursive);
self.watch_paths.extend(other.watch_paths);
self.watch_paths.sort();
self.watch_paths.dedup();
self.match_paths.extend(other.match_paths);
self.match_paths.sort();
self.match_paths.dedup();
}
fn covers(&self, other: &WatchTarget) -> bool {
if !self.recursive {
return false;
}
other
.match_paths
.iter()
.all(|path| self.matches_event_path(path))
}
}
pub(crate) fn check(
args: CheckCommand,
config_arguments: &ConfigArguments,
cache_dir: Option<&Path>,
) -> Result<ExitStatus> {
let cwd = std::env::current_dir()?;
let cache_root = resolve_cache_root(&cwd, cache_dir)?;
if args.watch {
return watch_check(&args, config_arguments, &cwd, &cache_root);
}
if let Some(raw_reason) = args.add_ignore.as_deref() {
if raw_reason.contains(['\n', '\r']) {
return Err(anyhow!(
"--add-ignore <reason> cannot contain newline characters"
));
}
let report = run_add_ignore_with_cwd(
&args,
config_arguments,
&cwd,
&cache_root,
(!raw_reason.is_empty()).then_some(raw_reason),
)?;
if report.directives_added > 0 {
let s = if report.directives_added == 1 {
""
} else {
"s"
};
eprintln!(
"Added {} shuck ignore directive{s}.",
report.directives_added
);
}
print_diagnostics(&report.diagnostics, args.output_format)?;
return Ok(report.exit_status(args.exit_zero));
}
let report = run_check_with_cwd(&args, config_arguments, &cwd, &cache_root)?;
print_report(&report, args.output_format)?;
Ok(report.exit_status(args.exit_zero, args.exit_non_zero_on_fix))
}
fn watch_check(
args: &CheckCommand,
config_arguments: &ConfigArguments,
cwd: &Path,
cache_root: &Path,
) -> Result<ExitStatus> {
let watch_targets = collect_watch_targets(&args.paths, config_arguments, cwd)?;
let (tx, rx) = channel();
let mut watcher = recommended_watcher(tx)?;
for target in &watch_targets {
for watch_path in &target.watch_paths {
watcher.watch(watch_path, target.recursive_mode())?;
}
}
clear_screen()?;
print_watch_banner("Starting linter in watch mode...")?;
let report = run_check_with_cwd(args, config_arguments, cwd, cache_root)?;
print_report(&report, args.output_format)?;
loop {
wait_for_watch_rerun(&rx, cache_root, &watch_targets)?;
clear_screen()?;
print_watch_banner("File change detected...")?;
let report = run_check_with_cwd(args, config_arguments, cwd, cache_root)?;
print_report(&report, args.output_format)?;
}
}
fn print_report(
report: &CheckReport,
output_format: crate::args::CheckOutputFormatArg,
) -> Result<()> {
print_diagnostics(&report.diagnostics, output_format)
}
fn print_diagnostics(
diagnostics: &[DisplayedDiagnostic],
output_format: crate::args::CheckOutputFormatArg,
) -> Result<()> {
let mut stdout = BufWriter::new(io::stdout().lock());
print_report_to(
&mut stdout,
diagnostics,
output_format,
colored::control::SHOULD_COLORIZE.should_colorize(),
)?;
Ok(())
}
fn should_clear_screen(stdout_is_terminal: bool) -> bool {
stdout_is_terminal
}
fn clear_screen() -> Result<()> {
if !should_clear_screen(io::stdout().is_terminal()) {
return Ok(());
}
clearscreen::clear()?;
Ok(())
}
fn print_watch_banner(message: &str) -> Result<()> {
let mut stderr = BufWriter::new(io::stderr().lock());
writeln!(stderr, "{message}")?;
stderr.flush()?;
Ok(())
}
fn effective_check_inputs(paths: &[PathBuf]) -> Vec<PathBuf> {
if paths.is_empty() {
vec![PathBuf::from(".")]
} else {
paths.to_vec()
}
}
fn collect_watch_targets(
paths: &[PathBuf],
config_arguments: &ConfigArguments,
cwd: &Path,
) -> Result<Vec<WatchTarget>> {
let inputs = effective_check_inputs(paths);
let mut targets = Vec::new();
for input in inputs {
let resolved_input = if input.is_absolute() {
normalize_path(&input)
} else {
normalize_path(&cwd.join(&input))
};
let metadata = fs::metadata(&resolved_input)?;
let canonical_input = fs::canonicalize(&resolved_input).map_err(anyhow::Error::from)?;
let mut target = if metadata.is_dir() {
WatchTarget::recursive(resolved_input.clone())
} else {
WatchTarget::file(resolved_input.clone())
};
if metadata.is_dir() {
target.add_watch_path(canonical_input.clone());
} else if let Some(parent) = canonical_input.parent() {
target.add_watch_path(parent.to_path_buf());
}
target.add_match_path(canonical_input);
targets.push(target);
if let Some(config_path) = watch_config_target(config_arguments, cwd, &resolved_input)? {
let canonical_config_parent =
config_path.canonical_path.parent().map(Path::to_path_buf);
let mut target = WatchTarget::file(config_path.resolved_path);
target.add_match_path(config_path.canonical_path);
if let Some(parent) = canonical_config_parent {
target.add_watch_path(parent.to_path_buf());
}
targets.push(target);
}
}
targets.sort_by(|left, right| {
left.watch_path
.components()
.count()
.cmp(&right.watch_path.components().count())
.then_with(|| right.recursive.cmp(&left.recursive))
.then_with(|| left.watch_path.cmp(&right.watch_path))
});
let mut deduped = Vec::new();
for target in targets {
if let Some(existing) = deduped.iter_mut().find(|existing: &&mut WatchTarget| {
existing.watch_path == target.watch_path && existing.recursive == target.recursive
}) {
existing.merge(target);
continue;
}
if deduped
.iter()
.any(|existing: &WatchTarget| existing.covers(&target))
{
continue;
}
if target.recursive {
deduped.retain(|existing| !target.covers(existing));
}
deduped.push(target);
}
Ok(deduped)
}
fn watch_config_target(
config_arguments: &ConfigArguments,
cwd: &Path,
resolved_input: &Path,
) -> Result<Option<WatchPath>> {
if let Some(explicit_config) = config_arguments.explicit_config_file() {
let resolved_config = if explicit_config.is_absolute() {
normalize_path(explicit_config)
} else {
normalize_path(&cwd.join(explicit_config))
};
return Ok(Some(WatchPath {
canonical_path: fs::canonicalize(&resolved_config).map_err(anyhow::Error::from)?,
resolved_path: resolved_config,
}));
}
if !config_arguments.use_config_roots() {
return Ok(None);
}
let project_root = resolve_project_root_for_input(resolved_input, true)?;
let Some(config_path) = discovered_config_path_for_root(&project_root)? else {
return Ok(None);
};
let resolved_path = normalize_path(&config_path);
Ok(Some(WatchPath {
canonical_path: fs::canonicalize(&resolved_path).map_err(anyhow::Error::from)?,
resolved_path,
}))
}
fn wait_for_watch_rerun(
rx: &Receiver<notify::Result<notify::Event>>,
cache_root: &Path,
watch_targets: &[WatchTarget],
) -> Result<()> {
loop {
let event = match rx.recv() {
Ok(Ok(event)) => event,
Ok(Err(error)) => return Err(error.into()),
Err(error) => return Err(error.into()),
};
if drain_watch_batch(event, rx, cache_root, watch_targets)? {
return Ok(());
}
}
}
fn drain_watch_batch(
first_event: notify::Event,
rx: &Receiver<notify::Result<notify::Event>>,
cache_root: &Path,
watch_targets: &[WatchTarget],
) -> Result<bool> {
let mut should_rerun = watch_event_requires_rerun(&first_event, cache_root, watch_targets);
loop {
match rx.try_recv() {
Ok(Ok(event)) => {
should_rerun |= watch_event_requires_rerun(&event, cache_root, watch_targets);
}
Ok(Err(error)) => return Err(error.into()),
Err(TryRecvError::Empty) => return Ok(should_rerun),
Err(TryRecvError::Disconnected) => {
return Err(anyhow!("watch channel disconnected"));
}
}
}
}
fn watch_event_requires_rerun(
event: ¬ify::Event,
cache_root: &Path,
watch_targets: &[WatchTarget],
) -> bool {
if event.kind.is_access() || event.kind.is_other() {
return false;
}
if event.need_rescan() {
return true;
}
event
.paths
.iter()
.map(|path| normalize_path(path))
.filter(|path| !watch_event_path_is_ignored(path, cache_root))
.any(|path| {
watch_targets
.iter()
.any(|target| target.matches_event_path(&path))
})
}
fn watch_event_path_is_ignored(path: &Path, cache_root: &Path) -> bool {
path.starts_with(cache_root)
|| path.components().any(|component| {
let std::path::Component::Normal(part) = component else {
return false;
};
DEFAULT_IGNORED_DIR_NAMES
.iter()
.any(|name| part == OsStr::new(name))
})
}
fn resolve_project_check_settings(
project_root: &ProjectRoot,
config_arguments: &ConfigArguments,
cli_rule_selection: &RuleSelectionArgs,
) -> Result<ResolvedCheckSettings> {
let config = load_project_config(&project_root.storage_root, config_arguments)?;
let layers = [
parse_lint_config_layer(&config.lint)?,
parse_cli_rule_selection_layer(cli_rule_selection),
];
let mut enabled_rules = LinterSettings::default_rules();
let mut fixable_rules = RuleSet::all();
let mut per_file_ignores = Vec::new();
for layer in layers {
enabled_rules = apply_rule_selector_layer(
enabled_rules,
layer.select.as_deref(),
&layer.extend_select,
&layer.ignore,
);
fixable_rules = apply_rule_selector_layer(
fixable_rules,
layer.fixable.as_deref(),
&layer.extend_fixable,
&layer.unfixable,
);
per_file_ignores = apply_per_file_ignore_layer(
per_file_ignores,
layer.per_file_ignores,
layer.extend_per_file_ignores,
);
}
let compiled_per_file_ignores = CompiledPerFileIgnoreList::resolve(
project_root.canonical_root.clone(),
per_file_ignores.clone(),
)?;
let effective = EffectiveCheckSettings::new(enabled_rules, &per_file_ignores);
Ok(ResolvedCheckSettings {
linter_settings: LinterSettings {
rules: enabled_rules,
per_file_ignores: Arc::new(compiled_per_file_ignores),
..LinterSettings::default()
},
fixable_rules,
effective,
})
}
fn parse_lint_config_layer(lint: &LintConfig) -> Result<RuleSelectionLayer> {
Ok(RuleSelectionLayer {
select: lint
.select
.as_ref()
.map(|selectors| parse_rule_selectors(selectors, "lint.select"))
.transpose()?,
ignore: lint
.ignore
.as_ref()
.map(|selectors| parse_rule_selectors(selectors, "lint.ignore"))
.transpose()?
.unwrap_or_default(),
extend_select: lint
.extend_select
.as_ref()
.map(|selectors| parse_rule_selectors(selectors, "lint.extend-select"))
.transpose()?
.unwrap_or_default(),
per_file_ignores: lint
.per_file_ignores
.as_ref()
.map(|patterns| parse_per_file_ignore_map(patterns, "lint.per-file-ignores"))
.transpose()?,
extend_per_file_ignores: lint
.extend_per_file_ignores
.as_ref()
.map(|patterns| parse_per_file_ignore_map(patterns, "lint.extend-per-file-ignores"))
.transpose()?
.unwrap_or_default(),
fixable: lint
.fixable
.as_ref()
.map(|selectors| parse_rule_selectors(selectors, "lint.fixable"))
.transpose()?,
unfixable: lint
.unfixable
.as_ref()
.map(|selectors| parse_rule_selectors(selectors, "lint.unfixable"))
.transpose()?
.unwrap_or_default(),
extend_fixable: lint
.extend_fixable
.as_ref()
.map(|selectors| parse_rule_selectors(selectors, "lint.extend-fixable"))
.transpose()?
.unwrap_or_default(),
})
}
fn parse_cli_rule_selection_layer(args: &RuleSelectionArgs) -> RuleSelectionLayer {
RuleSelectionLayer {
select: args.select.clone(),
ignore: args.ignore.clone(),
extend_select: args.extend_select.clone(),
per_file_ignores: args
.per_file_ignores
.as_ref()
.map(|pairs| group_per_file_ignore_pairs(pairs)),
extend_per_file_ignores: group_per_file_ignore_pairs(&args.extend_per_file_ignores),
fixable: args.fixable.clone(),
unfixable: args.unfixable.clone(),
extend_fixable: args.extend_fixable.clone(),
}
}
fn parse_rule_selectors(selectors: &[String], scope: &str) -> Result<Vec<RuleSelector>> {
selectors
.iter()
.map(|selector| {
let selector = selector.trim();
if selector.is_empty() {
return Err(anyhow!(
"invalid {scope} selector: selector cannot be empty"
));
}
selector
.parse::<RuleSelector>()
.map_err(|err| anyhow!("invalid {scope} selector `{selector}`: {err}"))
})
.collect()
}
fn parse_per_file_ignore_map(
patterns: &BTreeMap<String, Vec<String>>,
scope: &str,
) -> Result<Vec<PerFileIgnoreSpec>> {
patterns
.iter()
.map(|(pattern, selectors)| {
Ok(PerFileIgnoreSpec {
pattern: pattern.clone(),
selectors: parse_rule_selectors(selectors, scope)?,
})
})
.collect()
}
fn group_per_file_ignore_pairs(pairs: &[PatternRuleSelectorPair]) -> Vec<PerFileIgnoreSpec> {
let mut grouped = BTreeMap::<String, Vec<RuleSelector>>::new();
for pair in pairs {
grouped
.entry(pair.pattern.clone())
.or_default()
.push(pair.selector.clone());
}
grouped
.into_iter()
.map(|(pattern, selectors)| PerFileIgnoreSpec { pattern, selectors })
.collect()
}
fn apply_rule_selector_layer(
current: RuleSet,
select: Option<&[RuleSelector]>,
extend_select: &[RuleSelector],
ignore: &[RuleSelector],
) -> RuleSet {
let mut specificities = select
.into_iter()
.flatten()
.chain(extend_select.iter())
.chain(ignore.iter())
.map(selector_specificity)
.collect::<Vec<_>>();
specificities.sort_unstable();
specificities.dedup();
let mut updates = HashMap::<Rule, bool>::new();
for specificity in specificities {
for selector in select
.into_iter()
.flatten()
.chain(extend_select.iter())
.filter(|selector| selector_specificity(selector) == specificity)
{
for rule in selector.into_rule_set().iter() {
updates.insert(rule, true);
}
}
for selector in ignore
.iter()
.filter(|selector| selector_specificity(selector) == specificity)
{
for rule in selector.into_rule_set().iter() {
updates.insert(rule, false);
}
}
}
if select.is_some() {
updates
.into_iter()
.filter_map(|(rule, enabled)| enabled.then_some(rule))
.collect()
} else {
let mut rules = current;
for (rule, enabled) in updates {
rules.set(rule, enabled);
}
rules
}
}
fn selector_specificity(selector: &RuleSelector) -> usize {
match selector {
RuleSelector::All => 0,
RuleSelector::Category(_) => 1,
RuleSelector::Prefix(prefix) => 2 + prefix.len(),
RuleSelector::Rule(_) => usize::MAX,
}
}
fn apply_per_file_ignore_layer(
current: Vec<PerFileIgnore>,
per_file_ignores: Option<Vec<PerFileIgnoreSpec>>,
extend_per_file_ignores: Vec<PerFileIgnoreSpec>,
) -> Vec<PerFileIgnore> {
let mut per_file_ignores = per_file_ignores
.map(|per_file_ignores| {
per_file_ignores
.into_iter()
.map(PerFileIgnoreSpec::into_ignore)
.collect()
})
.unwrap_or(current);
per_file_ignores.extend(
extend_per_file_ignores
.into_iter()
.map(PerFileIgnoreSpec::into_ignore),
);
per_file_ignores
}
fn run_check_with_cwd(
args: &CheckCommand,
config_arguments: &ConfigArguments,
cwd: &Path,
cache_root: &Path,
) -> Result<CheckReport> {
let include_source = matches!(args.output_format, crate::args::CheckOutputFormatArg::Full);
let fix_applicability = requested_fix_applicability(args);
let runs = prepare_project_runs::<CheckCacheData, ResolvedCheckSettings, _>(
&args.paths,
cwd,
&DiscoveryOptions {
exclude_patterns: args.file_selection.exclude.clone(),
extend_exclude_patterns: args.file_selection.extend_exclude.clone(),
respect_gitignore: args.respect_gitignore(),
force_exclude: args.force_exclude(),
parallel: true,
cache_root: Some(cache_root.to_path_buf()),
use_config_roots: config_arguments.use_config_roots(),
},
cache_root,
args.no_cache || fix_applicability.is_some(),
b"project-cache-key",
|project_root| {
resolve_project_check_settings(project_root, config_arguments, &args.rule_selection)
},
)?;
let shellcheck_map = ShellCheckCodeMap::default();
let mut report = CheckReport::default();
for mut run in runs {
let analyzed_paths = run
.files
.iter()
.map(|file| file.absolute_path.clone())
.collect::<Vec<_>>();
let project_settings = run.settings.clone();
let pending = run.take_pending_files(|file, cached| {
report.cache_hits += 1;
match cached {
CheckCacheData::Success(diagnostics) => {
let source = (include_source && !diagnostics.is_empty())
.then(|| read_shared_source(&file.absolute_path))
.transpose()?;
push_cached_lint_diagnostics(
&mut report,
&file.display_path,
&file.relative_path,
&file.absolute_path,
&diagnostics,
source,
);
}
CheckCacheData::ParseError(error) => {
let source = include_source
.then(|| read_shared_source(&file.absolute_path))
.transpose()?;
report.diagnostics.push(display_parse_error(
&file.display_path,
&file.relative_path,
&file.absolute_path,
error.line,
error.column,
error.message,
source,
));
}
}
Ok(())
})?;
let results = pending
.into_par_iter()
.map(|pending| {
analyze_file(
pending,
&project_settings
.linter_settings
.clone()
.with_analyzed_paths(analyzed_paths.clone()),
&shellcheck_map,
include_source,
fix_applicability,
&project_settings.fixable_rules,
)
})
.collect::<Vec<_>>();
for result in results {
let result = result?;
report.fixes_applied += result.fixes_applied;
report.diagnostics.extend(result.diagnostics);
if let Some(cache) = run.cache.as_mut() {
cache.insert(
result.file.relative_path.clone(),
result.file_key,
result.cache_data,
);
}
report.cache_misses += 1;
}
run.persist_cache()?;
}
report.diagnostics.sort_by(|left, right| {
left.path
.cmp(&right.path)
.then(left.span.start.line.cmp(&right.span.start.line))
.then(left.span.start.column.cmp(&right.span.start.column))
.then(left.message.cmp(&right.message))
});
Ok(report)
}
fn run_add_ignore_with_cwd(
args: &CheckCommand,
config_arguments: &ConfigArguments,
cwd: &Path,
cache_root: &Path,
reason: Option<&str>,
) -> Result<AddIgnoreReport> {
let include_source = matches!(args.output_format, crate::args::CheckOutputFormatArg::Full);
let runs = prepare_project_runs::<CheckCacheData, ResolvedCheckSettings, _>(
&args.paths,
cwd,
&DiscoveryOptions {
exclude_patterns: args.file_selection.exclude.clone(),
extend_exclude_patterns: args.file_selection.extend_exclude.clone(),
respect_gitignore: args.respect_gitignore(),
force_exclude: args.force_exclude(),
parallel: false,
cache_root: Some(cache_root.to_path_buf()),
use_config_roots: config_arguments.use_config_roots(),
},
cache_root,
true,
b"project-cache-key",
|project_root| {
resolve_project_check_settings(project_root, config_arguments, &args.rule_selection)
},
)?;
let mut report = AddIgnoreReport::default();
for run in runs {
let analyzed_paths = run
.files
.iter()
.map(|file| file.absolute_path.clone())
.collect::<Vec<_>>();
let linter_settings = run
.settings
.linter_settings
.clone()
.with_analyzed_paths(analyzed_paths);
for file in run.files {
let result = add_ignores_to_path(&file.absolute_path, &linter_settings, reason)?;
report.directives_added += result.directives_added;
if result.parse_error.is_none() && result.diagnostics.is_empty() {
continue;
}
let raw_source = read_shared_source(&file.absolute_path)?;
let source = include_source.then_some(raw_source.clone());
if let Some(error) = result.parse_error {
report.diagnostics.push(display_parse_error(
&file.display_path,
&file.relative_path,
&file.absolute_path,
error.line,
error.column,
error.message,
source.clone(),
));
}
push_lint_diagnostics(
&mut report.diagnostics,
&file.display_path,
&file.relative_path,
&file.absolute_path,
&result.diagnostics,
&raw_source,
source,
);
}
}
report.diagnostics.sort_by(|left, right| {
left.path
.cmp(&right.path)
.then(left.span.start.line.cmp(&right.span.start.line))
.then(left.span.start.column.cmp(&right.span.start.column))
.then(left.message.cmp(&right.message))
});
Ok(report)
}
pub(crate) fn benchmark_check_paths(
cwd: &Path,
paths: &[PathBuf],
output_format: crate::args::CheckOutputFormatArg,
) -> Result<usize> {
let report = run_check_with_cwd(
&CheckCommand {
fix: false,
unsafe_fixes: false,
add_ignore: None,
no_cache: true,
output_format,
watch: false,
paths: paths.to_vec(),
rule_selection: RuleSelectionArgs::default(),
file_selection: FileSelectionArgs::default(),
exit_zero: false,
exit_non_zero_on_fix: false,
},
&ConfigArguments::default(),
cwd,
&cwd.join("cache"),
)?;
Ok(report.diagnostics.len())
}
fn analyze_file(
pending: PendingProjectFile,
base_linter_settings: &LinterSettings,
shellcheck_map: &ShellCheckCodeMap,
include_source: bool,
fix_applicability: Option<Applicability>,
fixable_rules: &RuleSet,
) -> Result<FileCheckResult> {
let mut source = read_shared_source(&pending.file.absolute_path)?;
let inferred_shell = ShellDialect::infer(&source, Some(&pending.file.absolute_path));
let parse_dialect = match inferred_shell {
ShellDialect::Sh | ShellDialect::Dash | ShellDialect::Ksh => {
shuck_parser::ShellDialect::Posix
}
ShellDialect::Mksh => shuck_parser::ShellDialect::Mksh,
ShellDialect::Zsh => shuck_parser::ShellDialect::Zsh,
ShellDialect::Unknown | ShellDialect::Bash => shuck_parser::ShellDialect::Bash,
};
let linter_settings = base_linter_settings.clone().with_shell(inferred_shell);
let mut parse_result = Parser::with_dialect(&source, parse_dialect).parse();
let mut diagnostics = collect_lint_diagnostics(
&pending,
&source,
&parse_result,
&linter_settings,
shellcheck_map,
);
let mut fixes_applied = 0;
if let Some(applicability) = fix_applicability {
let fixable_diagnostics = diagnostics
.iter()
.filter(|diagnostic| fixable_rules.contains(diagnostic.rule))
.cloned()
.collect::<Vec<_>>();
let applied = shuck_linter::apply_fixes(&source, &fixable_diagnostics, applicability);
if applied.fixes_applied > 0 {
source = Arc::<str>::from(applied.code);
fs::write(&pending.file.absolute_path, &*source)?;
parse_result = Parser::with_dialect(&source, parse_dialect).parse();
diagnostics = collect_lint_diagnostics(
&pending,
&source,
&parse_result,
&linter_settings,
shellcheck_map,
);
fixes_applied = applied.fixes_applied;
}
}
let (cache_data, diagnostics) = if parse_result.is_err() && diagnostics.is_empty() {
let ParseError::Parse {
message,
line,
column,
} = parse_result.strict_error();
(
CheckCacheData::ParseError(ParseCacheFailure {
message: message.clone(),
line,
column,
}),
vec![display_parse_error(
&pending.file.display_path,
&pending.file.relative_path,
&pending.file.absolute_path,
line,
column,
message,
include_source.then_some(source.clone()),
)],
)
} else {
display_lint_diagnostics(&pending, &source, &diagnostics, include_source)
};
Ok(FileCheckResult {
file: pending.file,
file_key: pending.file_key,
cache_data,
diagnostics,
fixes_applied,
})
}
fn collect_lint_diagnostics(
pending: &PendingProjectFile,
source: &Arc<str>,
parse_result: &ParseResult,
linter_settings: &LinterSettings,
shellcheck_map: &ShellCheckCodeMap,
) -> Vec<shuck_linter::Diagnostic> {
let indexer = Indexer::new(source, parse_result);
let directives = parse_directives(
source,
&parse_result.file,
indexer.comment_index(),
shellcheck_map,
);
let suppression_index = (!directives.is_empty()).then(|| {
SuppressionIndex::new(
&directives,
&parse_result.file,
first_statement_line(&parse_result.file).unwrap_or(u32::MAX),
)
});
shuck_linter::lint_file_at_path_with_parse_result(
parse_result,
source,
&indexer,
linter_settings,
suppression_index.as_ref(),
Some(&pending.file.absolute_path),
)
}
fn display_lint_diagnostics(
pending: &PendingProjectFile,
source: &Arc<str>,
diagnostics: &[shuck_linter::Diagnostic],
include_source: bool,
) -> (CheckCacheData, Vec<DisplayedDiagnostic>) {
let diagnostic_source = (!diagnostics.is_empty() && include_source).then(|| source.clone());
(
CheckCacheData::Success(
diagnostics
.iter()
.map(|diagnostic| CachedLintDiagnostic::from_diagnostic(diagnostic, source))
.collect(),
),
diagnostics
.iter()
.map(|diagnostic| DisplayedDiagnostic {
path: pending.file.display_path.clone(),
relative_path: pending.file.relative_path.clone(),
absolute_path: pending.file.absolute_path.clone(),
span: DisplaySpan::new(
DisplayPosition::new(diagnostic.span.start.line, diagnostic.span.start.column),
DisplayPosition::new(diagnostic.span.end.line, diagnostic.span.end.column),
),
message: diagnostic.message.clone(),
kind: DisplayedDiagnosticKind::Lint {
code: diagnostic.code().to_owned(),
severity: diagnostic.severity.as_str().to_owned(),
},
fix: displayed_fix_from_diagnostic(diagnostic, source),
source: diagnostic_source.clone(),
})
.collect(),
)
}
fn display_parse_error(
display_path: &Path,
relative_path: &Path,
absolute_path: &Path,
line: usize,
column: usize,
message: String,
source: Option<Arc<str>>,
) -> DisplayedDiagnostic {
DisplayedDiagnostic {
path: display_path.to_path_buf(),
relative_path: relative_path.to_path_buf(),
absolute_path: absolute_path.to_path_buf(),
span: DisplaySpan::point(line, column),
message,
kind: DisplayedDiagnosticKind::ParseError,
fix: None,
source,
}
}
fn displayed_fix_from_diagnostic(
diagnostic: &shuck_linter::Diagnostic,
source: &str,
) -> Option<DisplayedFix> {
let fix = diagnostic.fix.as_ref()?;
let line_index = shuck_indexer::LineIndex::new(source);
Some(DisplayedFix {
applicability: match fix.applicability() {
Applicability::Safe => DisplayedApplicability::Safe,
Applicability::Unsafe => DisplayedApplicability::Unsafe,
},
message: diagnostic.fix_title.clone(),
edits: fix
.edits()
.iter()
.map(|edit| displayed_edit_from_fix(edit, &line_index, source))
.collect(),
})
}
fn displayed_edit_from_fix(
edit: &shuck_linter::Edit,
line_index: &shuck_indexer::LineIndex,
source: &str,
) -> DisplayedEdit {
let range = edit.range();
let start_offset = floor_char_boundary(source, usize::from(range.start()));
let end_offset = ceil_char_boundary(source, usize::from(range.end()));
DisplayedEdit {
location: display_position_at_offset(source, line_index, start_offset),
end_location: display_position_at_offset(source, line_index, end_offset),
content: edit.content().to_owned(),
}
}
fn display_position_at_offset(
source: &str,
line_index: &shuck_indexer::LineIndex,
target_offset: usize,
) -> DisplayPosition {
let line = line_index.line_number(TextSize::new(target_offset as u32));
let line_start = line_index
.line_start(line)
.map(usize::from)
.unwrap_or_default();
DisplayPosition::new(line, source[line_start..target_offset].chars().count() + 1)
}
fn floor_char_boundary(source: &str, offset: usize) -> usize {
let mut offset = offset.min(source.len());
while offset > 0 && !source.is_char_boundary(offset) {
offset -= 1;
}
offset
}
fn ceil_char_boundary(source: &str, offset: usize) -> usize {
let mut offset = offset.min(source.len());
while offset < source.len() && !source.is_char_boundary(offset) {
offset += 1;
}
offset
}
fn requested_fix_applicability(args: &CheckCommand) -> Option<Applicability> {
if args.unsafe_fixes {
Some(Applicability::Unsafe)
} else if args.fix {
Some(Applicability::Safe)
} else {
None
}
}
fn push_cached_lint_diagnostics(
report: &mut CheckReport,
path: &Path,
relative_path: &Path,
absolute_path: &Path,
diagnostics: &[CachedLintDiagnostic],
source: Option<Arc<str>>,
) {
for diagnostic in diagnostics {
report.diagnostics.push(DisplayedDiagnostic {
path: path.to_path_buf(),
relative_path: relative_path.to_path_buf(),
absolute_path: absolute_path.to_path_buf(),
span: DisplaySpan::new(
DisplayPosition::new(diagnostic.start_line, diagnostic.start_column),
DisplayPosition::new(diagnostic.end_line, diagnostic.end_column),
),
message: diagnostic.message.clone(),
kind: DisplayedDiagnosticKind::Lint {
code: diagnostic.code.clone(),
severity: diagnostic.severity.clone(),
},
fix: diagnostic.fix.as_ref().map(CachedLintFix::to_displayed),
source: source.clone(),
});
}
}
fn push_lint_diagnostics(
displayed: &mut Vec<DisplayedDiagnostic>,
path: &Path,
relative_path: &Path,
absolute_path: &Path,
diagnostics: &[shuck_linter::Diagnostic],
raw_source: &Arc<str>,
source: Option<Arc<str>>,
) {
for diagnostic in diagnostics {
displayed.push(DisplayedDiagnostic {
path: path.to_path_buf(),
relative_path: relative_path.to_path_buf(),
absolute_path: absolute_path.to_path_buf(),
span: DisplaySpan::new(
DisplayPosition::new(diagnostic.span.start.line, diagnostic.span.start.column),
DisplayPosition::new(diagnostic.span.end.line, diagnostic.span.end.column),
),
message: diagnostic.message.clone(),
kind: DisplayedDiagnosticKind::Lint {
code: diagnostic.code().to_owned(),
severity: diagnostic.severity.as_str().to_owned(),
},
fix: displayed_fix_from_diagnostic(diagnostic, raw_source),
source: source.clone(),
});
}
}
fn read_shared_source(path: &Path) -> Result<Arc<str>> {
Ok(Arc::<str>::from(fs::read_to_string(path)?))
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use std::sync::Arc;
use notify::event::{CreateKind, EventAttributes, ModifyKind, RemoveKind, RenameMode};
use shuck_linter::{Rule, RuleSelector};
use tempfile::tempdir;
use super::*;
use crate::args::{CheckOutputFormatArg, PatternRuleSelectorPair, RuleSelectionArgs};
use crate::config::ConfigArguments;
fn pending_project_file(path: &Path, project_root: &Path) -> PendingProjectFile {
PendingProjectFile {
file: crate::discover::DiscoveredFile {
display_path: path.strip_prefix(project_root).unwrap().to_path_buf(),
absolute_path: path.to_path_buf(),
relative_path: path.strip_prefix(project_root).unwrap().to_path_buf(),
project_root: crate::discover::ProjectRoot {
storage_root: project_root.to_path_buf(),
canonical_root: fs::canonicalize(project_root).unwrap(),
},
},
file_key: shuck_cache::FileCacheKey::from_path(path).unwrap(),
}
}
fn cache_root(cwd: &Path) -> PathBuf {
cwd.join("cache")
}
fn diagnostic_paths(path: &str) -> (PathBuf, PathBuf, PathBuf) {
let display = PathBuf::from(path);
let relative = PathBuf::from(path);
let absolute = PathBuf::from(format!("/tmp/{path}"));
(display, relative, absolute)
}
fn match_paths(canonical: &Path, resolved: &Path) -> Vec<PathBuf> {
let mut paths = vec![canonical.to_path_buf(), normalize_path(resolved)];
paths.sort();
paths.dedup();
paths
}
fn watch_paths(canonical: &Path, resolved: &Path) -> Vec<PathBuf> {
let mut paths = vec![canonical.to_path_buf(), normalize_path(resolved)];
paths.sort();
paths.dedup();
paths
}
fn make_file_read_only(path: &Path) {
let mut permissions = fs::metadata(path).unwrap().permissions();
permissions.set_readonly(true);
fs::set_permissions(path, permissions).unwrap();
}
fn check_args_with_format(no_cache: bool, output_format: CheckOutputFormatArg) -> CheckCommand {
CheckCommand {
fix: false,
unsafe_fixes: false,
add_ignore: None,
no_cache,
output_format,
watch: false,
paths: Vec::new(),
rule_selection: RuleSelectionArgs::default(),
file_selection: FileSelectionArgs::default(),
exit_zero: false,
exit_non_zero_on_fix: false,
}
}
fn check_args(no_cache: bool) -> CheckCommand {
check_args_with_format(no_cache, CheckOutputFormatArg::Full)
}
fn lint_displayed_diagnostic(
path: &str,
span: DisplaySpan,
message: &str,
code: &str,
severity: &str,
) -> DisplayedDiagnostic {
let (path, relative_path, absolute_path) = diagnostic_paths(path);
DisplayedDiagnostic {
path,
relative_path,
absolute_path,
span,
message: message.to_owned(),
kind: DisplayedDiagnosticKind::Lint {
code: code.to_owned(),
severity: severity.to_owned(),
},
fix: None,
source: None,
}
}
fn parse_displayed_diagnostic(
path: &str,
span: DisplaySpan,
message: &str,
) -> DisplayedDiagnostic {
let (path, relative_path, absolute_path) = diagnostic_paths(path);
DisplayedDiagnostic {
path,
relative_path,
absolute_path,
span,
message: message.to_owned(),
kind: DisplayedDiagnosticKind::ParseError,
fix: None,
source: None,
}
}
fn diagnostic_codes(report: &CheckReport) -> Vec<String> {
report
.diagnostics
.iter()
.filter_map(|diagnostic| match &diagnostic.kind {
DisplayedDiagnosticKind::Lint { code, .. } => Some(code.clone()),
DisplayedDiagnosticKind::ParseError => None,
})
.collect()
}
#[test]
fn reports_parse_errors() {
let tempdir = tempdir().unwrap();
fs::write(tempdir.path().join("broken.sh"), "#!/bin/bash\nif true\n").unwrap();
let report = run_check_with_cwd(
&check_args(false),
&ConfigArguments::default(),
tempdir.path(),
&cache_root(tempdir.path()),
)
.unwrap();
assert_eq!(report.exit_status(false, false), ExitStatus::Failure);
assert_eq!(report.diagnostics.len(), 1);
assert_eq!(report.cache_hits, 0);
assert_eq!(report.cache_misses, 1);
}
#[test]
fn exit_zero_suppresses_only_non_fatal_diagnostics() {
let warning = lint_displayed_diagnostic(
"warn.sh",
DisplaySpan::point(1, 1),
"lint",
"C001",
"warning",
);
let error_lint =
lint_displayed_diagnostic("err.sh", DisplaySpan::point(1, 1), "lint", "C035", "error");
let parse = parse_displayed_diagnostic("broken.sh", DisplaySpan::point(1, 1), "parse");
let warning_only = CheckReport {
diagnostics: vec![warning.clone()],
..CheckReport::default()
};
assert_eq!(warning_only.exit_status(false, false), ExitStatus::Failure);
assert_eq!(warning_only.exit_status(true, false), ExitStatus::Success);
let with_error_lint = CheckReport {
diagnostics: vec![warning.clone(), error_lint],
..CheckReport::default()
};
assert_eq!(
with_error_lint.exit_status(true, false),
ExitStatus::Failure
);
let with_parse_error = CheckReport {
diagnostics: vec![warning, parse],
..CheckReport::default()
};
assert_eq!(
with_parse_error.exit_status(true, false),
ExitStatus::Failure
);
}
#[test]
fn exit_non_zero_on_fix_fires_when_fixes_applied() {
let report = CheckReport {
fixes_applied: 1,
..CheckReport::default()
};
assert_eq!(report.exit_status(false, false), ExitStatus::Success);
assert_eq!(report.exit_status(false, true), ExitStatus::Failure);
assert_eq!(report.exit_status(true, true), ExitStatus::Failure);
}
#[test]
fn reports_missing_fi_as_c035_lint() {
let tempdir = tempdir().unwrap();
fs::write(
tempdir.path().join("broken.sh"),
"#!/bin/sh\nif true; then\n :\n",
)
.unwrap();
let report = run_check_with_cwd(
&check_args(false),
&ConfigArguments::default(),
tempdir.path(),
&cache_root(tempdir.path()),
)
.unwrap();
assert_eq!(report.diagnostics.len(), 1);
match &report.diagnostics[0].kind {
DisplayedDiagnosticKind::Lint { code, .. } => assert_eq!(code, "C035"),
other => panic!("expected lint diagnostic, got {other:?}"),
}
}
#[test]
fn select_replaces_default_rules() {
let tempdir = tempdir().unwrap();
fs::write(
tempdir.path().join("script.sh"),
"#!/bin/sh\nunused=1\nread\n",
)
.unwrap();
let mut args = check_args(true);
args.rule_selection = RuleSelectionArgs {
select: Some(vec![RuleSelector::Rule(Rule::BareRead)]),
..RuleSelectionArgs::default()
};
let report = run_check_with_cwd(
&args,
&ConfigArguments::default(),
tempdir.path(),
&cache_root(tempdir.path()),
)
.unwrap();
assert_eq!(
diagnostic_codes(&report),
vec![Rule::BareRead.code().to_owned()]
);
}
#[test]
fn extend_select_adds_on_top_of_config_selection() {
let tempdir = tempdir().unwrap();
fs::write(
tempdir.path().join("shuck.toml"),
"[lint]\nselect = ['C001']\n",
)
.unwrap();
fs::write(
tempdir.path().join("script.sh"),
"#!/bin/sh\nunused=1\nread\n",
)
.unwrap();
let mut args = check_args(true);
args.rule_selection = RuleSelectionArgs {
extend_select: vec![RuleSelector::Rule(Rule::BareRead)],
..RuleSelectionArgs::default()
};
let report = run_check_with_cwd(
&args,
&ConfigArguments::default(),
tempdir.path(),
&cache_root(tempdir.path()),
)
.unwrap();
let mut codes = diagnostic_codes(&report);
codes.sort();
assert_eq!(
codes,
vec![
Rule::UnusedAssignment.code().to_owned(),
Rule::BareRead.code().to_owned(),
]
);
}
#[test]
fn ignore_can_trigger_parse_error_fallback() {
let tempdir = tempdir().unwrap();
fs::write(
tempdir.path().join("broken.sh"),
"#!/bin/sh\nif true; then\n :\n",
)
.unwrap();
let mut args = check_args(true);
args.rule_selection = RuleSelectionArgs {
ignore: vec![RuleSelector::Rule(Rule::MissingFi)],
..RuleSelectionArgs::default()
};
let report = run_check_with_cwd(
&args,
&ConfigArguments::default(),
tempdir.path(),
&cache_root(tempdir.path()),
)
.unwrap();
assert_eq!(report.diagnostics.len(), 1);
assert!(matches!(
report.diagnostics[0].kind,
DisplayedDiagnosticKind::ParseError
));
}
#[test]
fn per_file_ignores_suppress_matching_rules_only() {
let tempdir = tempdir().unwrap();
fs::write(
tempdir.path().join("ignored.sh"),
"#!/bin/bash\nunused=1\necho ok\n",
)
.unwrap();
fs::write(
tempdir.path().join("kept.sh"),
"#!/bin/bash\nunused=1\necho ok\n",
)
.unwrap();
let mut args = check_args(true);
args.rule_selection = RuleSelectionArgs {
per_file_ignores: Some(vec![PatternRuleSelectorPair {
pattern: "ignored.sh".to_owned(),
selector: RuleSelector::Rule(Rule::UnusedAssignment),
}]),
..RuleSelectionArgs::default()
};
let report = run_check_with_cwd(
&args,
&ConfigArguments::default(),
tempdir.path(),
&cache_root(tempdir.path()),
)
.unwrap();
assert_eq!(report.diagnostics.len(), 1);
assert_eq!(report.diagnostics[0].path, PathBuf::from("kept.sh"));
assert_eq!(
diagnostic_codes(&report),
vec![Rule::UnusedAssignment.code().to_owned()]
);
}
#[test]
fn rejects_empty_rule_selectors() {
let error = parse_rule_selectors(&[String::new()], "lint.select").unwrap_err();
assert_eq!(
error.to_string(),
"invalid lint.select selector: selector cannot be empty"
);
}
#[test]
fn unfixable_rules_prevent_fix_application() {
let tempdir = tempdir().unwrap();
let script = tempdir.path().join("warn.sh");
let source = "#!/bin/bash\nprintf '%s\\n' x &;\n";
fs::write(&script, source).unwrap();
let mut args = check_args(true);
args.fix = true;
args.rule_selection = RuleSelectionArgs {
unfixable: vec![RuleSelector::Rule(Rule::AmpersandSemicolon)],
..RuleSelectionArgs::default()
};
let report = run_check_with_cwd(
&args,
&ConfigArguments::default(),
tempdir.path(),
&cache_root(tempdir.path()),
)
.unwrap();
assert_eq!(report.fixes_applied, 0);
assert_eq!(fs::read_to_string(script).unwrap(), source);
assert_eq!(
diagnostic_codes(&report),
vec![Rule::AmpersandSemicolon.code().to_owned()]
);
}
#[test]
fn reports_missing_fi_as_parse_error_when_parse_rule_is_disabled() {
let tempdir = tempdir().unwrap();
let broken_path = tempdir.path().join("broken.sh");
fs::write(&broken_path, "#!/bin/sh\nif true; then\n :\n").unwrap();
let result = analyze_file(
pending_project_file(&broken_path, tempdir.path()),
&LinterSettings::for_rule(shuck_linter::Rule::UnusedAssignment)
.with_analyzed_paths([broken_path.clone()]),
&ShellCheckCodeMap::default(),
false,
None,
&RuleSet::all(),
)
.unwrap();
assert_eq!(result.diagnostics.len(), 1);
assert!(matches!(result.cache_data, CheckCacheData::ParseError(_)));
match &result.diagnostics[0].kind {
DisplayedDiagnosticKind::ParseError => {}
other => panic!("expected parse error, got {other:?}"),
}
assert!(result.diagnostics[0].message.contains("expected 'fi'"));
}
#[test]
fn reuses_cached_results() {
let tempdir = tempdir().unwrap();
fs::write(tempdir.path().join("ok.sh"), "#!/bin/bash\necho ok\n").unwrap();
let first = run_check_with_cwd(
&check_args(false),
&ConfigArguments::default(),
tempdir.path(),
&cache_root(tempdir.path()),
)
.unwrap();
let second = run_check_with_cwd(
&check_args(false),
&ConfigArguments::default(),
tempdir.path(),
&cache_root(tempdir.path()),
)
.unwrap();
assert_eq!(first.cache_hits, 0);
assert_eq!(first.cache_misses, 1);
assert_eq!(second.cache_hits, 1);
assert_eq!(second.cache_misses, 0);
}
#[test]
fn invalidates_cache_when_file_changes() {
let tempdir = tempdir().unwrap();
let script = tempdir.path().join("script.sh");
fs::write(&script, "#!/bin/bash\necho ok\n").unwrap();
let first = run_check_with_cwd(
&check_args(false),
&ConfigArguments::default(),
tempdir.path(),
&cache_root(tempdir.path()),
)
.unwrap();
assert_eq!(first.cache_hits, 0);
assert_eq!(first.cache_misses, 1);
fs::write(&script, "#!/bin/bash\nif true\n").unwrap();
make_file_read_only(&script);
let second = run_check_with_cwd(
&check_args(false),
&ConfigArguments::default(),
tempdir.path(),
&cache_root(tempdir.path()),
)
.unwrap();
assert_eq!(second.cache_hits, 0);
assert_eq!(second.cache_misses, 1);
assert_eq!(second.diagnostics.len(), 1);
}
#[test]
fn no_cache_does_not_write_cache_files() {
let tempdir = tempdir().unwrap();
fs::write(tempdir.path().join("ok.sh"), "#!/bin/bash\necho ok\n").unwrap();
let report = run_check_with_cwd(
&check_args(true),
&ConfigArguments::default(),
tempdir.path(),
&cache_root(tempdir.path()),
)
.unwrap();
assert_eq!(report.cache_hits, 0);
assert_eq!(report.cache_misses, 1);
assert!(!tempdir.path().join(".shuck_cache").exists());
}
#[test]
fn infers_shell_from_extension_for_local_rule() {
let tempdir = tempdir().unwrap();
fs::write(tempdir.path().join("posix.sh"), "local foo=bar\n").unwrap();
fs::write(tempdir.path().join("bashy.bash"), "local foo=bar\n").unwrap();
let report = run_check_with_cwd(
&check_args(true),
&ConfigArguments::default(),
tempdir.path(),
&cache_root(tempdir.path()),
)
.unwrap();
let c014 = report
.diagnostics
.iter()
.filter(|diagnostic| matches!(&diagnostic.kind, DisplayedDiagnosticKind::Lint { code, .. } if code == "C014"))
.collect::<Vec<_>>();
assert_eq!(c014.len(), 1);
assert_eq!(c014[0].path, PathBuf::from("bashy.bash"));
}
#[test]
fn mixes_cache_hits_and_misses_in_a_single_run() {
let tempdir = tempdir().unwrap();
let first = tempdir.path().join("first.sh");
let second = tempdir.path().join("second.sh");
fs::write(&first, "#!/bin/bash\necho ok\n").unwrap();
fs::write(&second, "#!/bin/bash\necho ok\n").unwrap();
let cache_root = cache_root(tempdir.path());
let initial = run_check_with_cwd(
&check_args(false),
&ConfigArguments::default(),
tempdir.path(),
&cache_root,
)
.unwrap();
assert_eq!(initial.cache_hits, 0);
assert_eq!(initial.cache_misses, 2);
fs::write(&second, "#!/bin/bash\nif true\n").unwrap();
let rerun = run_check_with_cwd(
&check_args(false),
&ConfigArguments::default(),
tempdir.path(),
&cache_root,
)
.unwrap();
assert_eq!(rerun.cache_hits, 1);
assert_eq!(rerun.cache_misses, 1);
assert_eq!(rerun.diagnostics.len(), 1);
assert_eq!(rerun.diagnostics[0].path, PathBuf::from("second.sh"));
}
#[test]
fn sorts_diagnostics_deterministically_after_parallel_run() {
let tempdir = tempdir().unwrap();
fs::write(tempdir.path().join("z.sh"), "#!/bin/bash\nif true\n").unwrap();
fs::write(tempdir.path().join("a.bash"), "local foo=bar\n").unwrap();
let report = run_check_with_cwd(
&check_args(true),
&ConfigArguments::default(),
tempdir.path(),
&cache_root(tempdir.path()),
)
.unwrap();
let paths = report
.diagnostics
.iter()
.map(|diagnostic| diagnostic.path.clone())
.collect::<Vec<_>>();
let mut sorted_paths = paths.clone();
sorted_paths.sort();
assert_eq!(paths, sorted_paths);
assert_eq!(paths.first(), Some(&PathBuf::from("a.bash")));
assert_eq!(paths.last(), Some(&PathBuf::from("z.sh")));
}
#[test]
fn duplicate_explicit_file_and_directory_inputs_are_deduplicated() {
let tempdir = tempdir().unwrap();
fs::write(tempdir.path().join("dup.sh"), "#!/bin/bash\nif true\n").unwrap();
let args = CheckCommand {
paths: vec![PathBuf::from("."), PathBuf::from("dup.sh")],
..check_args(true)
};
let report = run_check_with_cwd(
&args,
&ConfigArguments::default(),
tempdir.path(),
&cache_root(tempdir.path()),
)
.unwrap();
assert_eq!(report.cache_hits, 0);
assert_eq!(report.cache_misses, 1);
assert_eq!(report.diagnostics.len(), 1);
}
#[test]
fn skips_a_configured_cache_directory_inside_the_walked_tree() {
let tempdir = tempdir().unwrap();
let cache_root = tempdir.path().join("custom-cache");
fs::create_dir_all(&cache_root).unwrap();
fs::write(tempdir.path().join("ok.sh"), "#!/bin/bash\necho ok\n").unwrap();
fs::write(cache_root.join("broken.sh"), "#!/bin/bash\nif true\n").unwrap();
let report = run_check_with_cwd(
&check_args(false),
&ConfigArguments::default(),
tempdir.path(),
&cache_root,
)
.unwrap();
assert!(report.diagnostics.is_empty());
assert!(!tempdir.path().join(".shuck_cache").exists());
}
#[test]
fn report_output_includes_ansi_styles_when_enabled() {
colored::control::set_override(true);
let report = CheckReport {
diagnostics: vec![DisplayedDiagnostic {
source: Some(Arc::<str>::from("echo ok\nvalue=$foo\nprintf '%s' $bar\n")),
..lint_displayed_diagnostic(
"script.sh",
DisplaySpan::new(DisplayPosition::new(3, 14), DisplayPosition::new(3, 18)),
"example message",
"C014",
"warning",
)
}],
cache_hits: 0,
cache_misses: 0,
fixes_applied: 0,
};
let mut output = Vec::new();
print_report_to(
&mut output,
&report.diagnostics,
CheckOutputFormatArg::Full,
true,
)
.unwrap();
let output = String::from_utf8(output).unwrap();
assert!(output.contains("\u{1b}["));
assert!(output.contains("warning"));
assert!(output.contains("C014"));
colored::control::unset_override();
}
#[test]
fn report_output_stays_plain_when_colors_are_disabled() {
let report = CheckReport {
diagnostics: vec![parse_displayed_diagnostic(
"script.sh",
DisplaySpan::point(2, 7),
"unterminated construct",
)],
cache_hits: 0,
cache_misses: 0,
fixes_applied: 0,
};
let mut output = Vec::new();
print_report_to(
&mut output,
&report.diagnostics,
CheckOutputFormatArg::Concise,
false,
)
.unwrap();
assert_eq!(
String::from_utf8(output).unwrap(),
"script.sh:2:7: parse error unterminated construct\n"
);
}
#[test]
fn cached_diagnostics_retain_source_for_full_output() {
let tempdir = tempdir().unwrap();
fs::write(
tempdir.path().join("warn.sh"),
"#!/bin/bash\nunused=1\necho ok\n",
)
.unwrap();
let first = run_check_with_cwd(
&check_args_with_format(false, CheckOutputFormatArg::Full),
&ConfigArguments::default(),
tempdir.path(),
&cache_root(tempdir.path()),
)
.unwrap();
let second = run_check_with_cwd(
&check_args_with_format(false, CheckOutputFormatArg::Full),
&ConfigArguments::default(),
tempdir.path(),
&cache_root(tempdir.path()),
)
.unwrap();
assert_eq!(first.cache_misses, 1);
assert_eq!(second.cache_hits, 1);
assert_eq!(second.diagnostics.len(), 1);
assert_eq!(
second.diagnostics[0].source.as_deref(),
Some("#!/bin/bash\nunused=1\necho ok\n")
);
}
#[test]
fn lint_diagnostics_share_the_original_source_arc_for_full_output() {
let tempdir = tempdir().unwrap();
let path = tempdir.path().join("warn.sh");
fs::write(&path, "#!/bin/bash\nunused=1\necho ok\n").unwrap();
let pending = pending_project_file(&path, tempdir.path());
let source = read_shared_source(&path).unwrap();
let parse_result = Parser::with_dialect(&source, shuck_parser::ShellDialect::Bash).parse();
let diagnostics = collect_lint_diagnostics(
&pending,
&source,
&parse_result,
&LinterSettings::default(),
&ShellCheckCodeMap::default(),
);
let (_, diagnostics) = display_lint_diagnostics(&pending, &source, &diagnostics, true);
let diagnostic_source = diagnostics[0]
.source
.as_ref()
.expect("full output should retain source");
assert!(Arc::ptr_eq(diagnostic_source, &source));
}
#[test]
fn watch_event_filter_ignores_access_other_ignored_dirs_and_cache_paths() {
let cache_root = Path::new("/tmp/shuck-cache");
let watch_targets = vec![
WatchTarget::recursive(PathBuf::from("/workspace/project")),
WatchTarget::file(PathBuf::from("/workspace/config/shuck.toml")),
];
assert!(!watch_event_requires_rerun(
¬ify::Event {
kind: notify::EventKind::Access(notify::event::AccessKind::Any),
paths: vec![PathBuf::from("script.sh")],
attrs: EventAttributes::default(),
},
cache_root,
&watch_targets,
));
assert!(!watch_event_requires_rerun(
¬ify::Event {
kind: notify::EventKind::Other,
paths: vec![PathBuf::from("script.sh")],
attrs: EventAttributes::default(),
},
cache_root,
&watch_targets,
));
assert!(!watch_event_requires_rerun(
¬ify::Event {
kind: notify::EventKind::Create(CreateKind::File),
paths: vec![PathBuf::from(".git/hooks/post-commit")],
attrs: EventAttributes::default(),
},
cache_root,
&watch_targets,
));
assert!(!watch_event_requires_rerun(
¬ify::Event {
kind: notify::EventKind::Modify(ModifyKind::Data(
notify::event::DataChange::Content,
)),
paths: vec![cache_root.join("entry.bin")],
attrs: EventAttributes::default(),
},
cache_root,
&watch_targets,
));
assert!(!watch_event_requires_rerun(
¬ify::Event {
kind: notify::EventKind::Modify(ModifyKind::Data(
notify::event::DataChange::Content,
)),
paths: vec![PathBuf::from("/workspace/config/other.txt")],
attrs: EventAttributes::default(),
},
cache_root,
&watch_targets,
));
}
#[test]
fn watch_event_filter_triggers_on_create_modify_remove_rename_and_rescan() {
let cache_root = Path::new("/tmp/shuck-cache");
let watch_targets = vec![
WatchTarget::recursive(PathBuf::from("/workspace/project")),
WatchTarget::file(PathBuf::from("/workspace/config/shuck.toml")),
];
assert!(watch_event_requires_rerun(
¬ify::Event {
kind: notify::EventKind::Create(CreateKind::File),
paths: vec![PathBuf::from("/workspace/project/script.sh")],
attrs: EventAttributes::default(),
},
cache_root,
&watch_targets,
));
assert!(watch_event_requires_rerun(
¬ify::Event {
kind: notify::EventKind::Modify(ModifyKind::Data(
notify::event::DataChange::Content,
)),
paths: vec![PathBuf::from("/workspace/config/shuck.toml")],
attrs: EventAttributes::default(),
},
cache_root,
&watch_targets,
));
assert!(watch_event_requires_rerun(
¬ify::Event {
kind: notify::EventKind::Remove(RemoveKind::File),
paths: vec![PathBuf::from("/workspace/project/script.sh")],
attrs: EventAttributes::default(),
},
cache_root,
&watch_targets,
));
assert!(watch_event_requires_rerun(
¬ify::Event {
kind: notify::EventKind::Modify(ModifyKind::Name(RenameMode::Both)),
paths: vec![
PathBuf::from("/tmp/tempfile"),
PathBuf::from("/workspace/config/shuck.toml"),
],
attrs: EventAttributes::default(),
},
cache_root,
&watch_targets,
));
let mut attrs = EventAttributes::default();
attrs.set_flag(notify::event::Flag::Rescan);
assert!(watch_event_requires_rerun(
¬ify::Event {
kind: notify::EventKind::Modify(ModifyKind::Any),
paths: vec![],
attrs,
},
cache_root,
&watch_targets,
));
}
#[test]
fn clear_screen_requires_terminal_stdout() {
assert!(should_clear_screen(true));
assert!(!should_clear_screen(false));
}
#[test]
fn collect_watch_targets_stay_within_requested_scope_and_watch_config_files() {
let tempdir = tempdir().unwrap();
let nested = tempdir.path().join("nested");
let deeper = nested.join("deeper");
fs::create_dir_all(&deeper).unwrap();
fs::write(tempdir.path().join("shuck.toml"), "[format]\n").unwrap();
let file = nested.join("script.sh");
fs::write(&file, "#!/bin/bash\necho ok\n").unwrap();
let default_targets =
collect_watch_targets(&[], &ConfigArguments::default(), tempdir.path()).unwrap();
assert_eq!(
default_targets,
vec![WatchTarget {
watch_path: normalize_path(tempdir.path()),
watch_paths: watch_paths(
&fs::canonicalize(tempdir.path()).unwrap(),
tempdir.path()
),
recursive: true,
match_paths: match_paths(
&fs::canonicalize(tempdir.path()).unwrap(),
tempdir.path()
),
}]
);
let nested_targets = collect_watch_targets(
&[PathBuf::from("nested"), PathBuf::from("nested/deeper")],
&ConfigArguments::default(),
tempdir.path(),
)
.unwrap();
assert_eq!(
nested_targets,
vec![
WatchTarget {
watch_path: normalize_path(tempdir.path()),
watch_paths: watch_paths(
&fs::canonicalize(tempdir.path()).unwrap(),
tempdir.path()
),
recursive: false,
match_paths: match_paths(
&fs::canonicalize(tempdir.path().join("shuck.toml")).unwrap(),
&tempdir.path().join("shuck.toml"),
),
},
WatchTarget {
watch_path: normalize_path(&nested),
watch_paths: watch_paths(&fs::canonicalize(&nested).unwrap(), &nested),
recursive: true,
match_paths: match_paths(&fs::canonicalize(&nested).unwrap(), &nested),
},
]
);
let file_targets = collect_watch_targets(
&[PathBuf::from("nested/script.sh")],
&ConfigArguments::default(),
tempdir.path(),
)
.unwrap();
assert_eq!(
file_targets,
vec![
WatchTarget {
watch_path: normalize_path(tempdir.path()),
watch_paths: watch_paths(
&fs::canonicalize(tempdir.path()).unwrap(),
tempdir.path()
),
recursive: false,
match_paths: match_paths(
&fs::canonicalize(tempdir.path().join("shuck.toml")).unwrap(),
&tempdir.path().join("shuck.toml"),
),
},
WatchTarget {
watch_path: normalize_path(&nested),
watch_paths: watch_paths(&fs::canonicalize(&nested).unwrap(), &nested),
recursive: false,
match_paths: match_paths(&fs::canonicalize(&file).unwrap(), &file),
},
]
);
}
#[test]
fn collect_watch_targets_merge_files_in_the_same_parent_directory() {
let tempdir = tempdir().unwrap();
let nested = tempdir.path().join("nested");
fs::create_dir_all(&nested).unwrap();
let first = nested.join("first.sh");
let second = nested.join("second.sh");
fs::write(&first, "#!/bin/bash\necho ok\n").unwrap();
fs::write(&second, "#!/bin/bash\necho ok\n").unwrap();
let targets = collect_watch_targets(
&[
PathBuf::from("nested/first.sh"),
PathBuf::from("nested/second.sh"),
],
&ConfigArguments::from_cli(Vec::new(), true).unwrap(),
tempdir.path(),
)
.unwrap();
assert_eq!(
targets,
vec![WatchTarget {
watch_path: normalize_path(&nested),
watch_paths: watch_paths(&fs::canonicalize(&nested).unwrap(), &nested),
recursive: false,
match_paths: {
let mut paths = vec![
fs::canonicalize(&first).unwrap(),
normalize_path(&first),
fs::canonicalize(&second).unwrap(),
normalize_path(&second),
];
paths.sort();
paths.dedup();
paths
},
}]
);
}
#[test]
fn drain_watch_batch_coalesces_queued_events_before_rerunning() {
let cache_root = Path::new("/tmp/shuck-cache");
let watch_targets = vec![WatchTarget::recursive(PathBuf::from("/workspace/project"))];
let (tx, rx) = channel();
tx.send(Ok(notify::Event {
kind: notify::EventKind::Modify(ModifyKind::Data(notify::event::DataChange::Content)),
paths: vec![PathBuf::from("/workspace/project/ignored/.git/index")],
attrs: EventAttributes::default(),
}))
.unwrap();
let first = notify::Event {
kind: notify::EventKind::Modify(ModifyKind::Data(notify::event::DataChange::Content)),
paths: vec![PathBuf::from("/workspace/project/script.sh")],
attrs: EventAttributes::default(),
};
assert!(drain_watch_batch(first, &rx, cache_root, &watch_targets).unwrap());
assert!(matches!(rx.try_recv(), Err(TryRecvError::Empty)));
}
}