rrubocop 0.8.14

10x faster RuboCop drop-in. Same output format and configs, without maintaining RuboCop gems.
//! `# rubocop:disable` / `enable` directive parsing.

use std::collections::{HashMap, HashSet};

#[derive(Default)]
pub struct Directives {
    /// Cop name (or department or "all") → disabled line numbers (trailing).
    line_disables: HashMap<String, HashSet<usize>>,
    /// Cop name → inclusive ranges disabled by block directives.
    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([' ', ':']));
    // Bare `# rubocop:disable` is malformed (Lint/CopDirectiveSyntax), not "all".
    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();
    // RuboCop matches `# rubocop:` anywhere in the trailing comment (e.g.
    // `# note # rubocop:disable Cop`), not only at the first `#`.
    let lower = comment.to_ascii_lowercase();
    let marker = "# rubocop:";
    let pos = lower.find(marker)?;
    // Map ASCII lower find back to original (ASCII-only 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));
    }
}