rrubocop 0.8.15

10x faster RuboCop drop-in. Same output format and configs, without maintaining RuboCop gems.
//! Layout/LeadingCommentSpace.

use tree_sitter::Tree;

use crate::cop::layout::report;
use crate::cop::shared;
use crate::cop::{Cop, CopConfig};
use crate::correction::Correction;
use crate::diagnostic::Diagnostic;
use crate::parse::codemap::CodeMap;
use crate::parse::source::SourceFile;

pub struct LeadingCommentSpace;

fn missing_space_after_hash(text: &[u8]) -> bool {
    if text.is_empty() || text[0] != b'#' {
        return false;
    }
    if text.starts_with(b"#++") || text.starts_with(b"#--") {
        return false;
    }
    let hash_run = text.iter().take_while(|&&b| b == b'#').count();
    match text.get(hash_run) {
        None => false,
        Some(b) if b.is_ascii_whitespace() || *b == b'=' => false,
        Some(_) => true,
    }
}

fn skip_rbs_annotation(config: &CopConfig, text: &[u8]) -> bool {
    if !config.get_bool("AllowRBSInlineAnnotation", false) {
        return false;
    }
    text.starts_with(b"#:")
        || text.starts_with(b"#[")
        || text.starts_with(b"#|")
}

fn skip_steep_annotation(config: &CopConfig, text: &[u8]) -> bool {
    config.get_bool("AllowSteepAnnotation", false)
        && (text.starts_with(b"#:") || text.starts_with(b"#$"))
}

fn skip_doxygen(config: &CopConfig, text: &[u8]) -> bool {
    config.get_bool("AllowDoxygenCommentStyle", false) && text.starts_with(b"#*")
}

fn skip_gemfile_ruby(config: &CopConfig, source: &SourceFile, text: &[u8]) -> bool {
    config.get_bool("AllowGemfileRubyComment", false)
        && text.starts_with(b"#ruby")
        && std::path::Path::new(source.path_str())
            .file_name()
            .and_then(|n| n.to_str())
            == Some("Gemfile")
}

fn skip_shebang(text: &[u8], line: usize, prev_line: Option<usize>, prev_shebang: bool) -> bool {
    if !text.starts_with(b"#!") {
        return false;
    }
    line == 1 || (line > 1 && prev_line == Some(line - 1) && prev_shebang)
}

fn skip_config_ru(source: &SourceFile, text: &[u8], line: usize) -> bool {
    text.starts_with(b"#\\")
        && line == 1
        && std::path::Path::new(source.path_str())
            .file_name()
            .and_then(|n| n.to_str())
            == Some("config.ru")
}

fn advance(prev_line: &mut Option<usize>, prev_shebang: &mut bool, line: usize, shebang: bool) {
    *prev_line = Some(line);
    *prev_shebang = shebang;
}

fn skip_allowed_comment(
    source: &SourceFile,
    config: &CopConfig,
    text: &[u8],
    line: usize,
    prev_line: Option<usize>,
    prev_shebang: bool,
) -> bool {
    skip_shebang(text, line, prev_line, prev_shebang)
        || skip_config_ru(source, text, line)
        || skip_rbs_annotation(config, text)
        || skip_steep_annotation(config, text)
        || skip_doxygen(config, text)
        || skip_gemfile_ruby(config, source, text)
}

fn handle_comment(
    cop: &dyn Cop,
    source: &SourceFile,
    config: &CopConfig,
    text: &[u8],
    start: usize,
    line: usize,
    prev_line: &mut Option<usize>,
    prev_shebang: &mut bool,
    diagnostics: &mut Vec<Diagnostic>,
    corrections: &mut Option<&mut Vec<Correction>>,
) {
    if !missing_space_after_hash(text) {
        advance(prev_line, prev_shebang, line, false);
        return;
    }
    if skip_allowed_comment(source, config, text, line, *prev_line, *prev_shebang) {
        advance(prev_line, prev_shebang, line, text.starts_with(b"#!"));
        return;
    }
    report::insert_space(
        cop,
        source,
        start,
        "Missing space after `#`.".into(),
        diagnostics,
        corrections,
        start + 1,
    );
    advance(prev_line, prev_shebang, line, false);
}

impl Cop for LeadingCommentSpace {
    fn name(&self) -> &'static str {
        "Layout/LeadingCommentSpace"
    }
    fn supports_autocorrect(&self) -> bool {
        true
    }

    fn uses_source_phase(&self) -> bool {
        true
    }

    fn check_source(
        &self,
        source: &SourceFile,
        tree: &Tree,
        code_map: &CodeMap,
        config: &CopConfig,
        diagnostics: &mut Vec<Diagnostic>,
        mut corrections: Option<&mut Vec<Correction>>,
    ) {
        let _ = code_map;
        let bytes = source.as_bytes();
        let mut prev_line = None::<usize>;
        let mut prev_shebang = false;
        for comment in shared::collect_comments(tree.root_node()) {
            let start = comment.start_byte();
            let text = &bytes[start..comment.end_byte()];
            let (line, _) = source.offset_to_line_col(start);
            handle_comment(
                self,
                source,
                config,
                text,
                start,
                line,
                &mut prev_line,
                &mut prev_shebang,
                diagnostics,
                &mut corrections,
            );
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::cop::CopConfig;
    use crate::testutil::{assert_cop_no_offenses_full_with_config, assert_cop_offenses_full};

    crate::cop_fixture_tests!(LeadingCommentSpace, "cops/layout/leading_comment_space");

    #[test]
    fn rbs_inline_annotation_allowed() {
        let mut config = CopConfig::default();
        config
            .options
            .insert("AllowRBSInlineAnnotation".into(), serde_yml::Value::Bool(true));
        assert_cop_no_offenses_full_with_config(
            &LeadingCommentSpace,
            b"x = false #:page_load\n",
            config,
        );
    }

    #[test]
    fn rbs_inline_annotation_offense_when_disabled() {
        assert_cop_offenses_full(
            &LeadingCommentSpace,
            b"x = false #:page_load\n          ^ Layout/LeadingCommentSpace: Missing space after `#`.\n",
        );
    }
}