rulewright 0.1.1

Executable engineering standards for Rust workspaces
Documentation
#[cfg(test)]
use googletest::prelude::*;
use std::{cmp::Reverse, collections::BTreeMap};

use winnow::token::take_until;

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

use fix::fix_aligned;

mod fix;

const MARKER: &str = "// #rw:aligned";
const MIN_ALIGNED_ROWS: usize = 2;
const MAJORITY_DIVISOR: usize = 2;

#[rustfmt::skip]
const EXAMPLES: &[Example] = &[
    Example {
        label: "arrow aligned",
        code: "// #rw:aligned\nParser    => \"a\";\nWriter    => \"b\";\nFiles     => \"c\";",
        pass: true,
    },
    Example {
        label: "arrow misaligned",
        code: "// #rw:aligned\nParser    => \"a\";\nWriter => \"b\";\nFiles     => \"c\";",
        pass: false,
    },
    Example {
        label: "comma aligned",
        code: "// #rw:aligned\ncall(a,  \"x\",  TypeA);\ncall(b,  \"y\",  TypeB);",
        pass: true,
    },
    Example {
        label: "no marker",
        code: "Parser    => \"a\";\nWriter => \"b\";",
        pass: true,
    },
    Example {
        label: "comma misaligned",
        code: "// #rw:aligned\ncall(a,  \"x\",  TypeA);\ncall(long_name, \"y\", TypeB);",
        pass: false,
    },
    Example {
        label: "trailing comments aligned",
        code: "// #rw:aligned\n(A, 1), // first\n(B, 2), // second",
        pass: true,
    },
    Example {
        label: "trailing comments misaligned",
        code: "// #rw:aligned\n(A, 1),      // first\n(B, 2), // second",
        pass: false,
    },
    Example {
        label: "wrapped tuple row",
        code: "// #rw:aligned\n(\n    SHORT,\n    \"first\",\n),\n(LONG_NAME, \"second\"),",
        pass: false,
    },
];

crate::full_line_rule!(
    aligned,
    "Enforce column alignment in regions marked with `// #rw:aligned`.",
    "Consistent column alignment in marked regions makes tabular data and match arms easier to scan.",
    Low,
    fix_aligned,
);

fn check_aligned(ctx: &FileCtx<'_>) -> Vec<Violation> {
    let mut out = Vec::new();
    let mut i = 0;
    let comment_starts = crate::infra::scanner::line_comment_starts(ctx.contents);

    while i < ctx.lines.len() {
        if ctx
            .lines
            .get(i)
            .is_some_and(|line| is_source_marker(line, i, &comment_starts))
        {
            let marker_line = i + 1;
            let block = collect_block(ctx.lines, i + 1);

            if block.len() >= MIN_ALIGNED_ROWS {
                for &(index, line) in &block {
                    if line.trim() == "(" {
                        out.push(violation(
                            ctx.rel,
                            index + 1,
                            format!(
                                "tuple row must stay on one line (aligned block at line {marker_line})"
                            ),
                        ));
                    }
                }

                let texts: Vec<&str> = block.iter().map(|&(_, s)| s).collect();

                match detect_separator(&texts) {
                    Some(Sep::Arrow) => {
                        check_arrow(ctx.rel, &block, marker_line, &mut out);
                    }
                    Some(Sep::Comma) => {
                        check_comma(ctx.rel, &block, marker_line, &mut out);
                    }
                    None => {}
                }

                check_comments(ctx.rel, &block, marker_line, &mut out);
            }

            i = block.last().map_or(i + 1, |&(idx, _)| idx + 1);
        } else {
            i += 1;
        }
    }

    out
}

fn is_marker(line: &str) -> bool {
    line.trim() == MARKER
}

fn is_source_marker(line: &str, index: usize, comment_starts: &[Option<usize>]) -> bool {
    super::marked_region::is_source_marker(line, index, comment_starts, MARKER)
}

fn collect_block<'a>(lines: &[&'a str], start: usize) -> Vec<(usize, &'a str)> {
    let mut block = Vec::new();

    for (i, &line) in lines.iter().enumerate().skip(start) {
        let trimmed = line.trim();

        if super::marked_region::ends_alignment_region(line) || is_marker(line) {
            break;
        }

        if parse::is_comment(trimmed) {
            continue;
        }

        block.push((i, line));
    }

    block
}

enum Sep {
    Arrow,
    Comma,
}

fn detect_separator(block: &[&str]) -> Option<Sep> {
    let arrows = block.iter().filter(|l| l.contains("=>")).count();

    if arrows > block.len() / MAJORITY_DIVISOR {
        return Some(Sep::Arrow);
    }

    let commas = block.iter().filter(|l| l.contains(',')).count();

    if commas > block.len() / MAJORITY_DIVISOR {
        return Some(Sep::Comma);
    }

    None
}

fn check_arrow(rel: &str, block: &[(usize, &str)], marker_line: usize, out: &mut Vec<Violation>) {
    let positions: Vec<(usize, usize)> = block
        .iter()
        .filter_map(|&(idx, line)| {
            let mut input = line;
            let before: &str = parse::try_parse(&mut input, take_until(0.., "=>"))?;

            Some((idx, before.len()))
        })
        .collect();

    if positions.len() < MIN_ALIGNED_ROWS {
        return;
    }

    let expected = majority(positions.iter().map(|&(_, p)| p));

    for &(idx, pos) in &positions {
        if pos != expected {
            out.push(violation(
                rel,
                idx + 1,
                format!(
                    "`=>` at column {pos}, expected {expected} \
                     (aligned block at line {marker_line})"
                ),
            ));
        }
    }
}

fn check_comma(rel: &str, block: &[(usize, &str)], marker_line: usize, out: &mut Vec<Violation>) {
    let mut all: Vec<(usize, Vec<usize>)> = Vec::new();

    for &(idx, line) in block {
        let positions = field_positions(line);

        if !positions.is_empty() {
            all.push((idx, positions));
        }
    }

    if all.len() < MIN_ALIGNED_ROWS {
        return;
    }

    let expected_count = majority(all.iter().map(|(_, p)| p.len()));

    for &(idx, ref positions) in &all {
        if positions.len() != expected_count {
            out.push(violation(
                rel,
                idx + 1,
                format!(
                    "{} aligned fields, expected {expected_count} \
                     (aligned block at line {marker_line})",
                    positions.len()
                ),
            ));
        }
    }

    let matching: Vec<&(usize, Vec<usize>)> = all
        .iter()
        .filter(|(_, p)| p.len() == expected_count)
        .collect();

    if matching.len() < MIN_ALIGNED_ROWS {
        return;
    }

    for col in 0..expected_count {
        let expected_pos = majority(
            matching
                .iter()
                .filter_map(|(_, positions)| positions.get(col).copied()),
        );

        for &&(idx, ref positions) in &matching {
            let Some(actual) = positions.get(col).copied() else {
                continue;
            };

            if actual != expected_pos {
                out.push(violation(
                    rel,
                    idx + 1,
                    format!(
                        "field {} starts at column {}, expected {expected_pos} \
                         (aligned block at line {marker_line})",
                        col + 1,
                        actual
                    ),
                ));
                break;
            }
        }
    }
}

fn comma_positions(line: &str) -> Vec<usize> {
    crate::infra::scanner::char_positions(line, ',')
}

fn field_positions(line: &str) -> Vec<usize> {
    comma_positions(line)
        .into_iter()
        .filter_map(|comma| {
            let remaining = line.get(comma + 1..)?;
            let leading = remaining.len() - remaining.trim_start().len();
            let field = remaining.trim_start();

            (!field.is_empty() && !field.starts_with("//")).then_some(comma + 1 + leading)
        })
        .collect()
}

fn comment_position(line: &str) -> Option<usize> {
    crate::infra::scanner::line_comment_starts(line)
        .into_iter()
        .next()
        .flatten()
}

fn check_comments(
    rel: &str,
    block: &[(usize, &str)],
    marker_line: usize,
    out: &mut Vec<Violation>,
) {
    let positions: Vec<(usize, usize)> = block
        .iter()
        .filter_map(|&(index, line)| comment_position(line).map(|position| (index, position)))
        .collect();

    if positions.len() < MIN_ALIGNED_ROWS {
        return;
    }

    let expected = majority(positions.iter().map(|&(_, position)| position));

    if let Some(&(index, actual)) = positions
        .iter()
        .find(|&&(_, position)| position != expected)
    {
        out.push(violation(
            rel,
            index + 1,
            format!(
                "trailing comment at column {actual}, expected {expected} \
                 (aligned block at line {marker_line})"
            ),
        ));
    }
}

fn majority<T>(iter: impl Iterator<Item = T>) -> T
where
    T: Ord + Copy,
{
    let mut counts: BTreeMap<T, usize> = BTreeMap::default();

    for v in iter {
        *counts.entry(v).or_default() += 1;
    }

    counts
        .into_iter()
        .max_by_key(|&(value, count)| (count, Reverse(value)))
        .expect("majority called on empty iterator")
        .0
}

#[cfg(test)]
mod test_registration;