rulewright 0.1.2

Executable engineering standards for Rust workspaces
Documentation
use winnow::token::take_until;

use crate::{Example, FileCtx, Fix, Violation, infra::parse, violation};

const ADJACENT_WORD_COUNT: usize = 2;

#[rustfmt::skip]
const EXAMPLES: &[Example] = &[
    Example {
        label: "the the in doc comment",
        code: "/// the the value",
        pass: false,
    },
    Example {
        label: "normal doc comment",
        code: "/// the value",
        pass: true,
    },
    Example {
        label: "not a comment",
        code: "let the_the = 1;",
        pass: true,
    },
    Example {
        label: "is is in comment",
        code: "// is is",
        pass: false,
    },
    Example {
        label: "case insensitive duplicate",
        code: "/// The the value",
        pass: false,
    },
];

crate::line_rule!(
    duplicate_words,
    "Flag repeated words in comments like `the the` or `is is`.",
    "Repeated words like 'the the' are typos that slip past spell checkers and make documentation look sloppy.",
    Low,
    fix_duplicate_words,
);

fn check_duplicate_words(ctx: &FileCtx<'_>) -> Vec<Violation> {
    let mut out = Vec::new();

    for (i, line) in ctx.lines.iter().enumerate() {
        let lineno = i + 1;
        let trimmed = line.trim();
        let Some(text) = parse::comment_content(trimmed) else {
            continue;
        };

        let words: Vec<&str> = text.split_whitespace().collect();

        for pair in words.windows(ADJACENT_WORD_COUNT) {
            let &[first, second] = pair else { continue };

            if first.eq_ignore_ascii_case(second) {
                out.push(violation(
                    ctx.rel,
                    lineno,
                    format!("duplicate word \"{}\" in comment", first.to_lowercase()),
                ));
                break;
            }
        }
    }

    out
}

fn fix_duplicate_words(ctx: &FileCtx<'_>, v: &Violation) -> Option<Fix> {
    let line = ctx.line(v.line)?;
    let trimmed = line.trim();
    let text = parse::comment_content(trimmed)?;

    let words: Vec<&str> = text.split_whitespace().collect();
    let pair = words.windows(ADJACENT_WORD_COUNT).find(|pair| {
        let [first, second] = *pair else {
            return false;
        };

        first.eq_ignore_ascii_case(second)
    })?;
    let &[first, second] = pair else { return None };
    let mut input = line;
    let before_word: &str = parse::try_parse(&mut input, take_until(0.., first))?;
    let after_first = before_word.len() + first.len();
    let remaining = line.get(after_first..)?;
    let mut rem_input = remaining;
    let gap: &str = parse::try_parse(&mut rem_input, take_until(0.., second))?;
    let remove_end = after_first + gap.len() + second.len();

    Some(Fix::replace_line(
        v.line,
        format!("{}{}", line.get(..after_first)?, line.get(remove_end..)?),
    ))
}

crate::rulewright_test!(check_duplicate_words, {
    crate::example_tests!(EXAMPLES, check_duplicate_words);
    crate::fix_tests!(EXAMPLES, line, check_duplicate_words, fix_duplicate_words);
});