use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SuppressionType {
File,
NextLine,
Line,
}
#[derive(Debug, Clone)]
pub struct Suppression {
pub suppression_type: SuppressionType,
pub line: usize,
pub rules: HashSet<String>,
}
#[derive(Debug, Default)]
pub struct SuppressionManager {
file_suppressions: HashSet<String>,
line_suppressions: HashMap<usize, HashSet<String>>,
}
impl SuppressionManager {
pub fn from_source(source: &str) -> Self {
let mut manager = Self::default();
let lines: Vec<&str> = source.lines().collect();
let mut seen_code = false;
for (line_idx, line) in lines.iter().enumerate() {
let line_num = line_idx + 1;
let trimmed = line.trim();
if !trimmed.is_empty()
&& !trimmed.starts_with('#')
&& !trimmed.starts_with("set ")
&& !trimmed.starts_with("shopt ")
{
seen_code = true;
}
if let Some(suppression) = parse_suppression(line, line_num) {
match suppression.suppression_type {
SuppressionType::File => {
manager.file_suppressions.extend(suppression.rules);
}
SuppressionType::NextLine => {
if !seen_code && is_shellcheck_directive(line) {
manager.file_suppressions.extend(suppression.rules);
} else {
if line_idx + 1 < lines.len() {
manager
.line_suppressions
.entry(line_num + 1)
.or_default()
.extend(suppression.rules);
}
}
}
SuppressionType::Line => {
manager
.line_suppressions
.entry(line_num)
.or_default()
.extend(suppression.rules);
}
}
}
}
manager
}
pub fn is_suppressed(&self, rule_code: &str, line: usize) -> bool {
if self.file_suppressions.contains(rule_code) {
return true;
}
if let Some(rules) = self.line_suppressions.get(&line) {
if rules.contains(rule_code) {
return true;
}
}
false
}
}
fn is_shellcheck_directive(line: &str) -> bool {
line.contains("# shellcheck disable=")
}
fn parse_suppression(line: &str, line_num: usize) -> Option<Suppression> {
let trimmed = line.trim();
if let Some(pos) = trimmed.find("# bashrs disable-file=") {
let rules_str = &trimmed[pos + "# bashrs disable-file=".len()..];
let rules = expand_legacy_aliases(parse_rule_list(rules_str));
return Some(Suppression {
suppression_type: SuppressionType::File,
line: line_num,
rules,
});
}
if let Some(pos) = trimmed.find("# bashrs disable-next-line=") {
let rules_str = &trimmed[pos + "# bashrs disable-next-line=".len()..];
let rules = expand_legacy_aliases(parse_rule_list(rules_str));
return Some(Suppression {
suppression_type: SuppressionType::NextLine,
line: line_num,
rules,
});
}
if let Some(pos) = line.find("# bashrs disable-line=") {
let rules_str = &line[pos + "# bashrs disable-line=".len()..];
let rules = expand_legacy_aliases(parse_rule_list(rules_str));
return Some(Suppression {
suppression_type: SuppressionType::Line,
line: line_num,
rules,
});
}
if let Some(pos) = trimmed.find("# bashrs disable=") {
if !trimmed.contains("disable-file=")
&& !trimmed.contains("disable-next-line=")
&& !trimmed.contains("disable-line=")
{
let rules_str = &trimmed[pos + "# bashrs disable=".len()..];
let rules = expand_legacy_aliases(parse_rule_list(rules_str));
return Some(Suppression {
suppression_type: SuppressionType::NextLine,
line: line_num,
rules,
});
}
}
if let Some(pos) = trimmed.find("# shellcheck disable=") {
let rules_str = &trimmed[pos + "# shellcheck disable=".len()..];
let rules = parse_rule_list(rules_str);
return Some(Suppression {
suppression_type: SuppressionType::NextLine,
line: line_num,
rules,
});
}
None
}
fn expand_legacy_aliases(rules: HashSet<String>) -> HashSet<String> {
use crate::linter::code_namespace::canonical;
let mut out = HashSet::with_capacity(rules.len());
for rule in rules {
let canon = canonical(&rule);
if canon != rule {
out.insert(canon.to_string());
}
out.insert(rule);
}
out
}
fn parse_rule_list(rules_str: &str) -> HashSet<String> {
let rules_part = rules_str
.split('(')
.next()
.unwrap_or(rules_str)
.split('#')
.next()
.unwrap_or(rules_str);
rules_part
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty() && is_valid_rule_code(s))
.collect()
}
fn is_valid_rule_code(code: &str) -> bool {
let code = code.trim();
if code.len() < 3 || code.len() > 10 {
return false;
}
let letter_count = code.chars().take_while(|c| c.is_ascii_uppercase()).count();
if letter_count == 0 || letter_count > 6 {
return false;
}
let digit_part = &code[letter_count..];
!digit_part.is_empty() && digit_part.chars().all(|c| c.is_ascii_digit())
}
#[cfg(test)]
#[path = "suppression_tests_parse_file.rs"]
mod tests_extracted;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnrecognisedDirective {
pub line: usize,
pub text: String,
}
pub const SUPPORTED_DIRECTIVES: &[&str] = &[
"# bashrs disable-file=RULE[,RULE...]",
"# bashrs disable-next-line=RULE",
"# bashrs disable-line=RULE",
"# bashrs disable=RULE",
];
fn looks_like_attempted_directive(line: &str) -> bool {
let Some(hash) = line.find('#') else {
return false;
};
let comment = line[hash..].to_lowercase();
if !comment.contains("bashrs") {
return false;
}
if !(comment.contains("disable") || comment.contains("ignore")) {
return false;
}
parse_suppression(line, 1).is_none()
}
pub fn unrecognised_directives(source: &str) -> Vec<UnrecognisedDirective> {
source
.lines()
.enumerate()
.filter(|(_, l)| looks_like_attempted_directive(l))
.map(|(i, l)| UnrecognisedDirective {
line: i + 1,
text: l.trim().to_string(),
})
.collect()
}
#[cfg(test)]
mod unrecognised_tests {
use super::*;
#[test]
fn near_miss_directives_are_reported() {
for line in [
"# bashrs-disable: SC2086",
"# bashrs: disable=SC2086",
"# bashrs disable SC2086",
"# bashrs ignore SC2086",
"echo hi # bashrs-disable-line: SC2086",
] {
let found = unrecognised_directives(line);
assert_eq!(found.len(), 1, "not reported: {line}");
assert_eq!(found[0].line, 1);
}
}
#[test]
fn supported_directives_are_not_reported() {
for line in [
"# bashrs disable-file=SC2086,DET002",
"# bashrs disable-next-line=SC2086",
"echo $var # bashrs disable-line=SC2086",
"# bashrs disable=SEC010",
] {
assert!(
unrecognised_directives(line).is_empty(),
"a SUPPORTED directive was reported as unrecognised: {line}"
);
}
}
#[test]
fn ordinary_comments_are_not_reported() {
for line in [
"# built by bashrs",
"# see bashrs docs for details",
"# disable the cache here",
"echo 'bashrs disable=SC2086'",
"#!/usr/bin/env bash",
] {
assert!(
unrecognised_directives(line).is_empty(),
"ordinary comment reported: {line}"
);
}
}
#[test]
fn shellcheck_directives_are_not_reported() {
assert!(unrecognised_directives("# shellcheck disable=SC2086").is_empty());
}
}
pub fn report_unrecognised_directives(
source: &str,
manager: &SuppressionManager,
result: &mut crate::linter::LintResult,
) {
for d in unrecognised_directives(source) {
if manager.is_suppressed("BASHRS001", d.line) {
continue;
}
result.add(crate::linter::Diagnostic::new(
"BASHRS001",
crate::linter::Severity::Warning,
format!(
"`{}` is not a recognised bashrs directive and does nothing. Supported: {}",
d.text,
SUPPORTED_DIRECTIVES.join(", ")
),
crate::linter::Span::new(d.line, 1, d.line, d.text.len() + 1),
));
}
}