use std::collections::{HashMap, HashSet};
#[derive(Default)]
pub struct Directives {
line_disables: HashMap<String, HashSet<usize>>,
range_disables: HashMap<String, Vec<(usize, usize)>>,
open_blocks: HashMap<String, usize>,
}
impl Directives {
pub fn suppresses(&self, cop_name: &str, line: usize) -> bool {
if self.line_disabled("all", line) || self.range_disabled("all", line) {
return true;
}
if self.line_disabled(cop_name, line) || self.range_disabled(cop_name, line) {
return true;
}
if let Some((dept, _)) = cop_name.split_once('/') {
self.line_disabled(dept, line) || self.range_disabled(dept, line)
} else {
false
}
}
fn line_disabled(&self, name: &str, line: usize) -> bool {
self.line_disables
.iter()
.any(|(k, s)| k.eq_ignore_ascii_case(name) && s.contains(&line))
}
fn range_disabled(&self, name: &str, line: usize) -> bool {
self.range_disables.iter().any(|(k, ranges)| {
k.eq_ignore_ascii_case(name) && ranges.iter().any(|&(a, b)| a <= line && line <= b)
})
}
}
fn apply_enable(d: &mut Directives, rest: &str, line_no: usize) {
for name in cop_names(rest.trim_start_matches([' ', ':'])) {
let Some(key) = d
.open_blocks
.keys()
.find(|k| k.eq_ignore_ascii_case(&name))
.cloned()
else {
continue;
};
if let Some(start) = d.open_blocks.remove(&key) {
d.range_disables
.entry(key)
.or_default()
.push((start, line_no));
}
}
}
fn apply_disable(d: &mut Directives, rest: &str, line: &str, line_no: usize) {
let names = cop_names(rest.trim_start_matches([' ', ':']));
if names.is_empty() {
return;
}
let trailing = !line.trim_start().starts_with('#');
for name in names {
if trailing {
d.line_disables.entry(name).or_default().insert(line_no);
} else {
d.open_blocks.entry(name).or_insert(line_no);
}
}
}
fn apply_line(d: &mut Directives, line: &str, line_no: usize) {
let Some(comment) = extract_rubocop_comment(line) else {
return;
};
if let Some(rest) = comment.strip_prefix("enable") {
apply_enable(d, rest, line_no);
return;
}
if let Some(rest) = comment
.strip_prefix("disable")
.or_else(|| comment.strip_prefix("todo"))
{
apply_disable(d, rest, line, line_no);
}
}
pub fn parse(src: &str) -> Directives {
let mut d = Directives::default();
let mut line_no = 0usize;
for line in src.lines() {
line_no += 1;
apply_line(&mut d, line, line_no);
}
for (name, start) in d.open_blocks.drain() {
d.range_disables
.entry(name)
.or_default()
.push((start, line_no.max(1)));
}
d
}
fn extract_rubocop_comment(line: &str) -> Option<&str> {
let idx = crate::parse::comment_hash::first_comment_hash(line.as_bytes())?;
let comment = line[idx..].trim_start();
let lower = comment.to_ascii_lowercase();
let marker = "# rubocop:";
let pos = lower.find(marker)?;
Some(comment[pos + marker.len()..].trim_start())
}
fn cop_names(after: &str) -> Vec<String> {
let after = after.split("--").next().unwrap_or(after);
after
.split(',')
.map(|s| s.trim().trim_start_matches(':').to_string())
.filter(|s| !s.is_empty())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn disable_after_interpolation_hash() {
let d = parse("x = \"a #{b}\" # rubocop:disable Style/StringLiterals\n");
assert!(d.suppresses("Style/StringLiterals", 1));
}
#[test]
fn disable_after_other_hash_in_comment() {
let d = parse("x = 1 # priv: \"abc\" # rubocop:disable Lint/UnreachableCode\n");
assert!(d.suppresses("Lint/UnreachableCode", 1));
}
#[test]
fn disable_cop_name_is_case_insensitive() {
let d = parse("it 'x' do end # rubocop:disable Rspec/ExampleLength\n");
assert!(d.suppresses("RSpec/ExampleLength", 1));
}
#[test]
fn disable_department_is_case_insensitive() {
let d = parse("# rubocop:disable Rspec\nx = 1\n");
assert!(d.suppresses("RSpec/LetSetup", 2));
}
#[test]
fn disable_after_escaped_single_quote() {
let d = parse("it 'doesn\\'t twice' do # rubocop:disable RSpec/MultipleExpectations\n");
assert!(d.suppresses("RSpec/MultipleExpectations", 1));
}
}