use std::collections::HashMap;
use rumdl_lib::rule::{LintWarning, Severity};
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
struct WarningIdentity<'a> {
rule_name: Option<&'a str>,
message: &'a str,
severity: Severity,
}
impl<'a> WarningIdentity<'a> {
fn of(warning: &'a LintWarning) -> Self {
Self {
rule_name: warning.rule_name.as_deref(),
message: &warning.message,
severity: warning.severity,
}
}
}
struct KeptLines {
moved: Option<HashMap<usize, usize>>,
}
impl KeptLines {
fn between(original_content: &str, fixed_content: &str) -> Self {
if original_content == fixed_content {
return Self { moved: None };
}
let old: Vec<&str> = original_content.split_inclusive('\n').collect();
let new: Vec<&str> = fixed_content.split_inclusive('\n').collect();
let mut moved = HashMap::new();
for op in similar::TextDiff::configure().diff_slices(&old, &new).ops() {
if let similar::DiffOp::Equal {
old_index,
new_index,
len,
} = *op
{
moved.extend((1..=len).map(|offset| (old_index + offset, new_index + offset)));
}
}
Self { moved: Some(moved) }
}
fn now_at(&self, line: usize) -> Option<usize> {
match &self.moved {
None => Some(line),
Some(moved) => moved.get(&line).copied(),
}
}
}
pub struct FixReconciliation {
fixed: Vec<bool>,
}
impl FixReconciliation {
pub fn per_warning(&self) -> &[bool] {
&self.fixed
}
pub fn fixed_count(&self) -> usize {
self.fixed.iter().filter(|&&was_fixed| was_fixed).count()
}
pub fn unfixed(&self, all_warnings: &[LintWarning]) -> Vec<LintWarning> {
all_warnings
.iter()
.zip(&self.fixed)
.filter(|&(_, &was_fixed)| !was_fixed)
.map(|(warning, _)| LintWarning {
fix: None,
..warning.clone()
})
.collect()
}
}
pub fn reconcile_fixed_warnings(
all_warnings: &[LintWarning],
remaining_warnings: &[LintWarning],
original_content: &str,
fixed_content: &str,
) -> FixReconciliation {
let mut net_resolved: HashMap<Option<&str>, usize> = HashMap::new();
for warning in all_warnings {
*net_resolved.entry(warning.rule_name.as_deref()).or_insert(0) += 1;
}
let mut survivors: HashMap<WarningIdentity<'_>, usize> = HashMap::new();
let mut survivors_at: HashMap<(WarningIdentity<'_>, usize, usize), usize> = HashMap::new();
for warning in remaining_warnings {
let identity = WarningIdentity::of(warning);
*survivors.entry(identity).or_insert(0) += 1;
*survivors_at
.entry((identity, warning.line, warning.column))
.or_insert(0) += 1;
let remaining_for_rule = net_resolved.entry(warning.rule_name.as_deref()).or_insert(0);
*remaining_for_rule = remaining_for_rule.saturating_sub(1);
}
let kept_lines = KeptLines::between(original_content, fixed_content);
let mut paired = vec![false; all_warnings.len()];
for (index, warning) in all_warnings.iter().enumerate() {
let identity = WarningIdentity::of(warning);
if let Some(line) = kept_lines.now_at(warning.line)
&& let Some(at_position) = survivors_at.get_mut(&(identity, line, warning.column))
&& *at_position > 0
&& let Some(saying_the_same) = survivors.get_mut(&identity)
{
*at_position -= 1;
*saying_the_same -= 1;
paired[index] = true;
}
}
let mut claim_order: Vec<usize> = (0..all_warnings.len()).filter(|&index| !paired[index]).collect();
claim_order.sort_by_key(|&index| (all_warnings[index].fix.is_some(), index));
let mut unmatched: Vec<usize> = Vec::new();
for index in claim_order {
if !claim_survivor(&mut survivors, &all_warnings[index]) {
unmatched.push(index);
}
}
unmatched.sort_by_key(|&index| (all_warnings[index].fix.is_none(), index));
let mut fixed = vec![false; all_warnings.len()];
for index in unmatched {
let rule_budget = net_resolved
.entry(all_warnings[index].rule_name.as_deref())
.or_insert(0);
if *rule_budget > 0 {
*rule_budget -= 1;
fixed[index] = true;
}
}
FixReconciliation { fixed }
}
fn claim_survivor<'a>(survivors: &mut HashMap<WarningIdentity<'a>, usize>, warning: &'a LintWarning) -> bool {
match survivors.get_mut(&WarningIdentity::of(warning)) {
Some(count) if *count > 0 => {
*count -= 1;
true
}
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use rumdl_lib::rule::{Fix, LintWarning};
fn warning(rule: &str, line: usize, message: &str, fix: Option<Fix>) -> LintWarning {
LintWarning {
message: message.to_string(),
line,
column: 1,
end_line: line,
end_column: 2,
severity: Severity::Warning,
fix,
rule_name: Some(rule.to_string()),
}
}
fn escalated(mut warning: LintWarning) -> LintWarning {
warning.severity = Severity::Error;
warning
}
fn some_fix() -> Option<Fix> {
Some(Fix::new(0..1, String::new()))
}
fn reconcile(all: &[LintWarning], remaining: &[LintWarning]) -> Vec<bool> {
reconcile_fixed_warnings(all, remaining, "before\n", "after\n")
.per_warning()
.to_vec()
}
const BEFORE: &str = "# T\nfirst \n\nsecond\n";
const AFTER: &str = "# T\n\nfirst\n\nsecond\n";
const TOO_LONG: &str = "Line length 90 exceeds 80 characters";
#[test]
fn lines_keep_their_partners_across_inserted_and_removed_lines() {
let kept = KeptLines::between("a\nb\nc\nd\n", "a\nX\nY\nc\n");
let positions: Vec<_> = (1..=4).map(|line| kept.now_at(line)).collect();
assert_eq!(positions, vec![Some(1), None, Some(4), None]);
let unchanged = KeptLines::between("a\nb\n", "a\nb\n");
assert_eq!(unchanged.now_at(2), Some(2));
}
#[test]
fn an_identical_warning_on_an_untouched_line_is_the_survivor() {
let all = vec![warning("MD013", 2, TOO_LONG, None), warning("MD013", 4, TOO_LONG, None)];
let remaining = vec![warning("MD013", 5, TOO_LONG, None)];
let reconciled = reconcile_fixed_warnings(&all, &remaining, BEFORE, AFTER);
assert_eq!(reconciled.per_warning(), [true, false]);
}
#[test]
fn an_identical_warning_on_an_untouched_line_is_the_survivor_whichever_is_reported_first() {
let all = vec![warning("MD013", 4, TOO_LONG, None), warning("MD013", 2, TOO_LONG, None)];
let remaining = vec![warning("MD013", 5, TOO_LONG, None)];
let reconciled = reconcile_fixed_warnings(&all, &remaining, BEFORE, AFTER);
assert_eq!(reconciled.per_warning(), [false, true]);
}
#[test]
fn a_survivor_away_from_an_untouched_line_is_not_that_lines_finding() {
let all = vec![warning("MD013", 2, TOO_LONG, None), warning("MD013", 4, TOO_LONG, None)];
let remaining = vec![warning("MD013", 3, TOO_LONG, None)];
let reconciled = reconcile_fixed_warnings(&all, &remaining, BEFORE, AFTER);
assert_eq!(reconciled.per_warning(), [false, true]);
}
#[test]
fn identical_warnings_on_one_untouched_line_are_told_apart_by_column() {
let at_column = |column| LintWarning {
column,
..warning("MD049", 4, "Emphasis style should be asterisk", None)
};
let all = vec![at_column(1), at_column(8)];
let remaining = vec![LintWarning {
line: 5,
..at_column(8)
}];
let reconciled = reconcile_fixed_warnings(&all, &remaining, BEFORE, AFTER);
assert_eq!(reconciled.per_warning(), [true, false]);
}
#[test]
fn the_unfixed_warnings_keep_their_reported_positions_and_lose_their_fixes() {
let all = vec![
warning("MD022", 1, "Expected 1 blank line below heading", some_fix()),
warning("MD009", 3, "Trailing spaces", some_fix()),
warning("MD052", 7, "Reference 'zz' not found", None),
];
let remaining = vec![
warning("MD022", 1, "Expected 1 blank line below heading", some_fix()),
warning("MD052", 8, "Reference 'zz' not found", None),
];
let unfixed = reconcile_fixed_warnings(&all, &remaining, "before\n", "after\n").unfixed(&all);
let summary: Vec<_> = unfixed
.iter()
.map(|w| (w.rule_name.as_deref(), w.line, w.fix.is_some()))
.collect();
assert_eq!(summary, vec![(Some("MD022"), 1, false), (Some("MD052"), 7, false)]);
}
#[test]
fn a_warning_that_moved_but_survived_is_not_fixed() {
let all = vec![warning("MD052", 7, "Reference 'zz' not found", None)];
let remaining = vec![warning("MD052", 9, "Reference 'zz' not found", None)];
assert_eq!(reconcile(&all, &remaining), vec![false]);
}
#[test]
fn a_document_level_fix_is_credited_without_a_per_warning_fix() {
let all = vec![warning("MD046", 5, "Use fenced code blocks", None)];
assert_eq!(reconcile(&all, &[]), vec![true]);
}
#[test]
fn a_warning_another_rule_resolved_is_credited() {
let all = vec![
warning("MD009", 3, "Trailing spaces", some_fix()),
warning("MD013", 3, "Line length 82 exceeds 80 characters", None),
];
assert_eq!(reconcile(&all, &[]), vec![true, true]);
}
#[test]
fn a_run_that_changed_nothing_credits_nothing() {
let all = vec![
warning("MD046", 5, "Use fenced code blocks", None),
warning("MD013", 9, "Line length 82 exceeds 80 characters", None),
];
let reconciled = reconcile_fixed_warnings(&all, &all, BEFORE, BEFORE);
assert_eq!(reconciled.per_warning(), [false, false]);
}
#[test]
fn identical_warnings_are_credited_one_per_disappearance() {
let all = vec![
warning("MD009", 1, "Trailing spaces", some_fix()),
warning("MD009", 2, "Trailing spaces", some_fix()),
warning("MD009", 3, "Trailing spaces", some_fix()),
];
let remaining = vec![warning("MD009", 2, "Trailing spaces", some_fix())];
let reconciled = reconcile(&all, &remaining);
assert_eq!(reconciled.iter().filter(|&&f| f).count(), 2);
}
#[test]
fn one_of_two_equivalent_warnings_disappearing_credits_the_one_that_was_fixable() {
let all = vec![
warning("MD040", 1, "Code block missing language", None),
warning("MD040", 5, "Code block missing language", some_fix()),
];
let remaining = vec![warning("MD040", 1, "Code block missing language", None)];
assert_eq!(reconcile(&all, &remaining), vec![false, true]);
}
#[test]
fn crediting_does_not_depend_on_the_order_the_warnings_were_reported() {
let all = vec![
warning("MD040", 1, "Code block missing language", some_fix()),
warning("MD040", 5, "Code block missing language", None),
];
let remaining = vec![warning("MD040", 5, "Code block missing language", None)];
assert_eq!(reconcile(&all, &remaining), vec![true, false]);
}
#[test]
fn no_more_warnings_are_credited_than_actually_disappeared() {
let all = vec![
warning("MD009", 1, "Trailing spaces", some_fix()),
warning("MD009", 2, "Trailing spaces", some_fix()),
warning("MD009", 3, "Trailing spaces", some_fix()),
];
let remaining = vec![
warning("MD009", 1, "Trailing spaces", some_fix()),
warning("MD009", 2, "Trailing spaces", some_fix()),
];
let reconciled = reconcile(&all, &remaining);
assert_eq!(reconciled.iter().filter(|&&f| f).count(), 1);
}
#[test]
fn a_reworded_survivor_is_not_counted_as_a_disappearance() {
let all = vec![
warning("MD013", 3, "Line length 131 exceeds 80 characters", some_fix()),
warning("MD013", 5, "Line length 90 exceeds 80 characters", some_fix()),
];
let remaining = vec![warning("MD013", 5, "Line length 86 exceeds 80 characters", some_fix())];
let reconciled = reconcile(&all, &remaining);
assert_eq!(reconciled.iter().filter(|&&f| f).count(), 1);
}
#[test]
fn a_survivor_whose_message_names_a_moved_line_is_not_credited() {
let all = vec![warning("MD053", 7, "Unused link/image reference: [a] (line 7)", None)];
let remaining = vec![warning("MD053", 9, "Unused link/image reference: [a] (line 9)", None)];
assert_eq!(reconcile(&all, &remaining), vec![false]);
}
#[test]
fn a_rule_that_gained_findings_is_credited_for_none_of_them() {
let all = vec![warning("MD012", 4, "Multiple consecutive blank lines", some_fix())];
let remaining = vec![
warning("MD012", 6, "Multiple consecutive blank lines [expected: 1]", some_fix()),
warning("MD012", 9, "Multiple consecutive blank lines [expected: 1]", some_fix()),
];
assert_eq!(reconcile(&all, &remaining), vec![false]);
}
#[test]
fn warnings_differing_only_in_severity_are_not_interchangeable() {
let all = vec![
warning("MD040", 1, "Code block missing language", some_fix()),
escalated(warning("MD040", 5, "Code block missing language", some_fix())),
];
let remaining = vec![escalated(warning(
"MD040",
5,
"Code block missing language",
some_fix(),
))];
assert_eq!(reconcile(&all, &remaining), vec![true, false]);
}
#[test]
fn warnings_differing_only_in_message_are_not_interchangeable() {
let all = vec![
warning("MD075", 5, "Orphaned table row(s)", some_fix()),
warning("MD075", 9, "Table missing header/delimiter", some_fix()),
];
let remaining = vec![warning("MD075", 8, "Table missing header/delimiter", some_fix())];
assert_eq!(reconcile(&all, &remaining), vec![true, false]);
}
#[test]
fn the_survivor_decides_which_warning_is_credited_not_the_order() {
let all = vec![
warning("MD075", 5, "Table missing header/delimiter", some_fix()),
warning("MD075", 9, "Orphaned table row(s)", some_fix()),
];
let remaining = vec![warning("MD075", 4, "Table missing header/delimiter", some_fix())];
assert_eq!(reconcile(&all, &remaining), vec![false, true]);
}
#[test]
fn a_disappearance_is_credited_to_the_warning_the_run_could_act_on() {
let all = vec![
warning("MD013", 3, "Line length 90 exceeds 80 characters", None),
warning("MD013", 5, "Line length exceeds 80 characters", some_fix()),
];
let remaining = vec![warning("MD013", 3, "Line length 86 exceeds 80 characters", None)];
assert_eq!(reconcile(&all, &remaining), vec![false, true]);
}
#[test]
fn one_rule_disappearing_never_credits_another_rules_warning() {
let all = vec![
warning("MD009", 1, "Trailing spaces", some_fix()),
warning("MD052", 3, "Reference 'zz' not found", None),
warning("MD009", 5, "Trailing spaces", some_fix()),
];
let remaining = vec![warning("MD052", 3, "Reference 'zz' not found", None)];
assert_eq!(reconcile(&all, &remaining), vec![true, false, true]);
}
}