use crate::cli::args::{LintFormat, LintLevel, LintProfileArg};
use crate::cli::logic::convert_lint_profile;
use crate::cli::logic::{is_dockerfile, is_makefile, is_shell_script_file};
use crate::models::{Error, Result};
use std::collections::HashSet;
use std::fs;
use std::path::{Path, PathBuf};
use tracing::{info, warn};
pub(crate) struct LintCommandOptions<'a> {
pub inputs: &'a [PathBuf],
pub format: LintFormat,
pub fix: bool,
pub fix_assumptions: bool,
pub output: Option<&'a Path>,
pub no_ignore: bool,
pub ignore_file_path: Option<&'a Path>,
pub quiet: bool,
pub level: LintLevel,
pub ignore_rules: Option<&'a str>,
pub exclude_rules: Option<&'a [String]>,
pub citl_export_path: Option<&'a Path>,
pub profile: LintProfileArg,
pub ci: bool,
pub fail_on: LintLevel,
}
pub(crate) fn lint_command(opts: LintCommandOptions<'_>) -> Result<()> {
let files = expand_inputs(opts.inputs)?;
if files.is_empty() {
return Err(Error::Validation(
"No lintable files found in the given inputs".to_string(),
));
}
if files.len() == 1 {
return lint_single_file(&files[0], &opts);
}
lint_multiple_files(&files, &opts)
}
fn expand_inputs(inputs: &[PathBuf]) -> Result<Vec<PathBuf>> {
let mut files = Vec::new();
for input in inputs {
if input.is_dir() {
walk_for_lintable_files(input, &mut files)?;
} else {
files.push(input.clone());
}
}
Ok(files)
}
fn walk_for_lintable_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
let entries = fs::read_dir(dir).map_err(Error::Io)?;
for entry in entries {
let entry = entry.map_err(Error::Io)?;
let path = entry.path();
if path.is_dir() {
if path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with('.'))
{
continue;
}
walk_for_lintable_files(&path, out)?;
} else {
let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if is_makefile(filename) || is_dockerfile(filename) {
out.push(path);
continue;
}
if let Ok(content) = fs::read_to_string(&path) {
if is_shell_script_file(&path, &content) {
out.push(path);
}
}
}
}
Ok(())
}
fn lint_single_file(input: &Path, opts: &LintCommandOptions<'_>) -> Result<()> {
use crate::linter::ignore_file::IgnoreResult;
use crate::linter::rules::lint_shell;
use crate::linter::{
rules::{lint_dockerfile_with_profile, lint_makefile, LintProfile},
LintResult,
};
info!("Linting {}", input.display());
let ignore_file_data = load_ignore_file(input, opts.no_ignore, opts.ignore_file_path);
if let Some(ref ignore) = ignore_file_data {
if let IgnoreResult::Ignored(pattern) = ignore.should_ignore(input) {
info!(
"Skipped {} (matched .bashrsignore pattern: {})",
input.display(),
pattern
);
if !opts.ci {
println!(
"Skipped: {} (matched .bashrsignore pattern: '{}')",
input.display(),
pattern
);
}
return Ok(());
}
}
let ignored_rules = build_ignored_rules(
opts.ignore_rules,
opts.exclude_rules,
ignore_file_data.as_ref(),
);
let min_severity = determine_min_severity(opts.quiet, opts.level);
let filter_diagnostics = |result: LintResult| -> LintResult {
let filtered = result
.diagnostics
.into_iter()
.filter(|d| d.severity >= min_severity)
.filter(|d| !ignored_rules.contains(&d.code.to_uppercase()))
.collect();
LintResult {
diagnostics: filtered,
}
};
let source = fs::read_to_string(input).map_err(Error::Io)?;
let filename = input.file_name().and_then(|n| n.to_str()).unwrap_or("");
let file_is_makefile = is_makefile(filename);
let file_is_dockerfile = is_dockerfile(filename);
let lint_profile = convert_lint_profile(opts.profile);
let result_raw = if file_is_makefile {
lint_makefile(&source)
} else if file_is_dockerfile {
lint_dockerfile_with_profile(&source, lint_profile)
} else {
lint_shell(&source)
};
if file_is_dockerfile && lint_profile != LintProfile::Standard {
info!("Using lint profile: {}", lint_profile);
}
let result = filter_diagnostics(result_raw.clone());
export_citl_if_requested(input, &result_raw, opts.citl_export_path);
if opts.fix && result_raw.diagnostics.iter().any(|d| d.fix.is_some()) {
handle_lint_fixes(
input,
&result_raw,
opts.fix_assumptions,
opts.output,
file_is_makefile,
opts.format,
&filter_diagnostics,
)
} else if opts.ci {
emit_ci_annotations(input, &result);
exit_for_fail_on(&result, opts.fail_on)
} else {
output_lint_results(&result, opts.format, input)
}
}
fn lint_multiple_files(files: &[PathBuf], opts: &LintCommandOptions<'_>) -> Result<()> {
use crate::linter::rules::lint_shell;
use crate::linter::{
rules::{lint_dockerfile_with_profile, lint_makefile},
LintResult,
};
let ignored_rules = build_ignored_rules(opts.ignore_rules, opts.exclude_rules, None);
let min_severity = determine_min_severity(opts.quiet, opts.level);
let lint_profile = convert_lint_profile(opts.profile);
let mut all_results: Vec<(PathBuf, LintResult)> = Vec::new();
let mut total_errors = 0u32;
let mut total_warnings = 0u32;
for file in files {
let source = match fs::read_to_string(file) {
Ok(s) => s,
Err(e) => {
warn!("Could not read {}: {}", file.display(), e);
continue;
}
};
let filename = file.file_name().and_then(|n| n.to_str()).unwrap_or("");
let file_is_makefile = is_makefile(filename);
let file_is_dockerfile = is_dockerfile(filename);
let result_raw = if file_is_makefile {
lint_makefile(&source)
} else if file_is_dockerfile {
lint_dockerfile_with_profile(&source, lint_profile)
} else {
lint_shell(&source)
};
let result = LintResult {
diagnostics: result_raw
.diagnostics
.into_iter()
.filter(|d| d.severity >= min_severity)
.filter(|d| !ignored_rules.contains(&d.code.to_uppercase()))
.collect(),
};
if result.has_errors() {
total_errors += 1;
}
if result.has_warnings() {
total_warnings += 1;
}
if opts.ci {
emit_ci_annotations(file, &result);
}
if !result.diagnostics.is_empty() {
all_results.push((file.clone(), result));
}
}
if !opts.ci {
for (file, result) in &all_results {
output_lint_results_no_exit(result, opts.format, file)?;
}
eprintln!(
"\nLinted {} file(s): {} with errors, {} with warnings",
files.len(),
total_errors,
total_warnings,
);
}
let has_failing = match opts.fail_on {
LintLevel::Error => total_errors > 0,
LintLevel::Warning => total_errors > 0 || total_warnings > 0,
LintLevel::Info => all_results.iter().any(|(_, r)| !r.diagnostics.is_empty()),
};
if has_failing {
if total_errors > 0 {
std::process::exit(2);
} else {
std::process::exit(1);
}
}
Ok(())
}
fn emit_ci_annotations(input: &Path, result: &crate::linter::LintResult) {
use crate::linter::Severity;
for diag in &result.diagnostics {
let level = match diag.severity {
Severity::Error => "error",
Severity::Warning | Severity::Risk => "warning",
Severity::Info | Severity::Note | Severity::Perf => "notice",
};
println!(
"::{level} file={},line={},col={},title={}::{}",
input.display(),
diag.span.start_line,
diag.span.start_col,
diag.code,
diag.message,
);
}
}
fn exit_for_fail_on(result: &crate::linter::LintResult, fail_on: LintLevel) -> Result<()> {
let should_fail = match fail_on {
LintLevel::Error => result.has_errors(),
LintLevel::Warning => result.has_errors() || result.has_warnings(),
LintLevel::Info => !result.diagnostics.is_empty(),
};
if should_fail {
if result.has_errors() {
std::process::exit(2);
} else {
std::process::exit(1);
}
}
Ok(())
}
pub(crate) fn load_ignore_file(
input: &Path,
no_ignore: bool,
ignore_file_path: Option<&Path>,
) -> Option<crate::linter::ignore_file::IgnoreFile> {
use crate::linter::ignore_file::IgnoreFile;
if no_ignore {
return None;
}
let ignore_path = ignore_file_path
.map(|p| p.to_path_buf())
.unwrap_or_else(|| {
let mut current = input
.parent()
.and_then(|p| p.canonicalize().ok())
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
loop {
let candidate = current.join(".bashrsignore");
if candidate.exists() {
return candidate;
}
if !current.pop() {
break;
}
}
PathBuf::from(".bashrsignore")
});
match IgnoreFile::load(&ignore_path) {
Ok(Some(ignore)) => Some(ignore),
Ok(None) => None,
Err(e) => {
warn!("Failed to load .bashrsignore: {}", e);
None
}
}
}
include!("lint_commands_build.rs");