use crate::lint_context::LintContext;
use crate::rule::LintWarning;
use crate::utils::fix_utils::apply_warning_fixes;
use crate::utils::parser_options::rumdl_parser_options;
use pulldown_cmark::{Event, Parser, Tag};
use std::ops::Range;
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum Allowed {
Nothing,
ListStart,
}
pub(crate) fn drop_structure_changing_fixes(ctx: &LintContext, warnings: &mut [LintWarning], allowed: Allowed) {
let content = ctx.content;
if warnings.iter().all(|w| w.fix.is_none()) {
return;
}
let lists = outermost_lists(content);
let list_at = |offset: usize| {
lists
.partition_point(|l| l.start <= offset)
.checked_sub(1)
.filter(|&i| offset < lists[i].end)
};
let group_of = |w: &LintWarning| -> Option<usize> {
let start = w.fix.as_ref()?.range.start;
Some(list_at(start).unwrap_or(lists.len() + start))
};
let mut groups: Vec<usize> = warnings.iter().filter_map(group_of).collect();
groups.sort_unstable();
groups.dedup();
let before = Parsed::new(content, allowed);
let fixed_with = |kept: &[usize]| -> Option<Parsed> {
let subset: Vec<LintWarning> = warnings
.iter()
.filter(|w| group_of(w).is_some_and(|g| kept.binary_search(&g).is_ok()))
.cloned()
.collect();
apply_warning_fixes(content, &subset)
.ok()
.map(|fixed| Parsed::new(&fixed, allowed))
};
let before_ref = &before;
let mut fix_lines: Vec<(usize, usize)> = warnings
.iter()
.filter_map(|w| Some((w.fix.as_ref()?, group_of(w)?)))
.flat_map(|(fix, group)| {
std::iter::once(fix)
.chain(&fix.additional_edits)
.map(move |edit| (before_ref.line_of(edit.range.start), group))
})
.collect();
fix_lines.sort_unstable();
let culprit = |line: usize, kept: &[usize]| -> Option<usize> {
let is_kept = |g: &usize| kept.binary_search(g).is_ok();
list_at(before.line_starts[line]).filter(is_kept).or_else(|| {
let split = fix_lines.partition_point(|&(l, _)| l < line);
let above = fix_lines[..split].iter().rev().find(|(_, g)| is_kept(g));
let below = fix_lines[split..].iter().find(|(_, g)| is_kept(g));
match (above, below) {
(Some(a), Some(b)) => Some(if line - a.0 <= b.0 - line { a.1 } else { b.1 }),
(a, b) => a.or(b).map(|&(_, g)| g),
}
})
};
let mut kept = groups;
while let Some(after) = fixed_with(&kept) {
if after.events == before.events {
break;
}
let mut blamed: Vec<usize> = match before.changed_lines(&after) {
Some(lines) => lines.into_iter().filter_map(|line| culprit(line, &kept)).collect(),
None => Vec::new(),
};
if blamed.is_empty() {
let preserves = |kept: &[usize]| fixed_with(kept).is_some_and(|after| after.events == before.events);
kept = select_groups(&kept, &preserves);
break;
}
blamed.sort_unstable();
kept.retain(|g| blamed.binary_search(g).is_err());
}
for warning in warnings.iter_mut() {
if group_of(warning).is_some_and(|g| kept.binary_search(&g).is_err()) {
warning.fix = None;
}
}
}
fn select_groups(groups: &[usize], preserves: &impl Fn(&[usize]) -> bool) -> Vec<usize> {
let mut kept = bisect(groups, preserves);
if kept.len() > 1 && !preserves(&kept) {
let candidates = std::mem::take(&mut kept);
for group in candidates {
kept.push(group);
if !preserves(&kept) {
kept.pop();
}
}
}
kept
}
fn bisect(groups: &[usize], preserves: &impl Fn(&[usize]) -> bool) -> Vec<usize> {
if groups.is_empty() || preserves(groups) {
return groups.to_vec();
}
if groups.len() == 1 {
return Vec::new();
}
let (left, right) = groups.split_at(groups.len() / 2);
let mut kept = bisect(left, preserves);
kept.extend(bisect(right, preserves));
kept
}
fn outermost_lists(content: &str) -> Vec<Range<usize>> {
let mut lists = Vec::new();
let mut depth = 0usize;
for (event, range) in Parser::new_ext(content, rumdl_parser_options()).into_offset_iter() {
match event {
Event::Start(Tag::List(_)) => {
if depth == 0 {
lists.push(range);
}
depth += 1;
}
Event::End(pulldown_cmark::TagEnd::List(_)) => depth -= 1,
_ => {}
}
}
lists
}
struct Parsed {
events: Vec<Event<'static>>,
places: Vec<(usize, usize)>,
line_starts: Vec<usize>,
}
fn line_at(line_starts: &[usize], offset: usize) -> usize {
line_starts.partition_point(|&start| start <= offset) - 1
}
#[cfg(test)]
thread_local! {
static PARSES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
impl Parsed {
fn new(text: &str, allowed: Allowed) -> Self {
#[cfg(test)]
PARSES.with(|parses| parses.set(parses.get() + 1));
let line_starts: Vec<usize> = std::iter::once(0)
.chain(text.match_indices('\n').map(|(i, _)| i + 1))
.collect();
let mut events = Vec::new();
let mut places = Vec::new();
let mut depth = 0usize;
for (event, range) in Parser::new_ext(text, rumdl_parser_options()).into_offset_iter() {
let line = match event {
Event::End(_) => {
depth -= 1;
line_at(&line_starts, range.end.saturating_sub(1).max(range.start))
}
_ => line_at(&line_starts, range.start),
};
places.push((line, depth));
if matches!(event, Event::Start(_)) {
depth += 1;
}
events.push(match (allowed, event.into_static()) {
(Allowed::ListStart, Event::Start(Tag::List(Some(_)))) => Event::Start(Tag::List(Some(1))),
(_, event) => event,
});
}
Self {
events,
places,
line_starts,
}
}
fn line_of(&self, offset: usize) -> usize {
line_at(&self.line_starts, offset)
}
fn by_line(&self) -> Vec<Vec<(&Event<'static>, usize)>> {
let mut lines = vec![Vec::new(); self.line_starts.len()];
for (event, &(line, depth)) in self.events.iter().zip(&self.places) {
lines[line].push((event, depth));
}
lines
}
fn changed_lines(&self, other: &Parsed) -> Option<Vec<usize>> {
if self.line_starts.len() != other.line_starts.len() {
return None;
}
let (mine, theirs) = (self.by_line(), other.by_line());
Some((0..mine.len()).filter(|&line| mine[line] != theirs[line]).collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::MarkdownFlavor;
use crate::rule::Fix;
fn edit(line: usize, range: Range<usize>, replacement: &str) -> LintWarning {
LintWarning {
message: String::new(),
line,
column: 1,
end_line: line,
end_column: 1,
severity: crate::rule::Severity::Warning,
fix: Some(Fix::new(range, replacement.to_string())),
rule_name: None,
}
}
fn guard(content: &str, warnings: &mut [LintWarning], allowed: Allowed) {
let ctx = LintContext::new(content, MarkdownFlavor::Standard, None);
drop_structure_changing_fixes(&ctx, warnings, allowed);
}
#[test]
fn keeps_fixes_that_only_move_discarded_whitespace() {
let content = "- a\n - b\n";
let mut warnings = vec![edit(2, 4..7, " ")];
guard(content, &mut warnings, Allowed::Nothing);
assert!(warnings[0].fix.is_some());
}
#[test]
fn drops_only_the_fixes_of_the_list_whose_structure_changes() {
let content = "> - item\n + item\n\ntext\n\n- a\n - b\n";
let lazy = content.find(" + item").unwrap();
let nested = content.rfind(" - b").unwrap();
let mut warnings = vec![edit(2, lazy..lazy + 5, ""), edit(7, nested..nested + 3, " ")];
guard(content, &mut warnings, Allowed::Nothing);
assert!(warnings[0].fix.is_none());
assert!(warnings[1].fix.is_some());
}
#[test]
fn a_list_start_changes_only_when_allowed() {
let content = "3. a\n4. b\n";
let renumber = || vec![edit(1, 0..1, "1"), edit(2, 5..6, "2")];
let mut warnings = renumber();
guard(content, &mut warnings, Allowed::ListStart);
assert!(warnings.iter().all(|w| w.fix.is_some()));
let mut warnings = renumber();
guard(content, &mut warnings, Allowed::Nothing);
assert!(warnings.iter().all(|w| w.fix.is_none()));
}
#[test]
fn indentation_inside_a_code_span_is_compared_exactly() {
let content = "> - item\n ```\n ```\n";
let closing = content.rfind(" ```").unwrap();
let mut warnings = vec![edit(3, closing..closing + 10, " ")];
guard(content, &mut warnings, Allowed::Nothing);
assert!(warnings[0].fix.is_none());
}
#[test]
fn a_change_is_blamed_on_the_nearest_fix_below_it_too() {
let content = "- a\n - b\n\ntext\n - c\n";
let nested = content.find(" - b").unwrap();
let below = content.find(" - c").unwrap();
let mut warnings = vec![edit(2, nested..nested + 3, " "), edit(5, below..below + 5, " ===")];
guard(content, &mut warnings, Allowed::Nothing);
assert!(warnings[0].fix.is_some());
assert!(warnings[1].fix.is_none());
}
#[test]
fn a_change_is_blamed_on_the_fix_that_edits_its_line() {
let content = "> - a\n>\n> code\n text\n * c\n";
let quoted = content.find("- a").unwrap();
let inner = content.find(" code").unwrap();
let outer = content.find(" text").unwrap();
let last = content.find(" * c").unwrap();
let mut quoted_fix = edit(1, quoted..quoted, " ");
let fix = quoted_fix.fix.as_mut().unwrap();
fix.additional_edits = vec![
Fix::new(inner..inner + 4, String::new()),
Fix::new(outer..outer + 5, String::new()),
];
let mut warnings = vec![quoted_fix, edit(5, last..last + 1, "")];
guard(content, &mut warnings, Allowed::Nothing);
assert!(warnings[0].fix.is_none());
assert!(warnings[1].fix.is_some());
}
#[test]
fn a_fix_that_adds_lines_is_found_by_search() {
let content = "- a\n - b\n\ntext\n\n- c\n";
let nested = content.find(" - b").unwrap();
let last = content.rfind("- c").unwrap();
let mut warnings = vec![
edit(2, nested..nested + 3, " "),
edit(6, last..last + 3, "- c\n\n code"),
];
guard(content, &mut warnings, Allowed::Nothing);
assert!(warnings[0].fix.is_some());
assert!(warnings[1].fix.is_none());
}
#[test]
fn many_broken_lists_cost_a_few_parses_not_one_per_list() {
let broken = "> - item\n + item\n\n";
let sound = "- a\n - b\n\n";
let content = format!("{broken}{sound}").repeat(200);
let mut warnings = Vec::new();
for (block, start) in content.match_indices("> - item").map(|(i, _)| i).enumerate() {
let lazy = start + "> - item\n".len();
let nested = start + broken.len() + "- a\n".len();
warnings.push(edit(4 * block + 2, lazy..lazy + 5, ""));
warnings.push(edit(4 * block + 4, nested..nested + 3, " "));
}
PARSES.with(|parses| parses.set(0));
guard(&content, &mut warnings, Allowed::Nothing);
let parses = PARSES.with(std::cell::Cell::get);
assert!(warnings.iter().step_by(2).all(|w| w.fix.is_none()));
assert!(warnings.iter().skip(1).step_by(2).all(|w| w.fix.is_some()));
assert!(parses <= 4, "{parses} parses");
}
#[test]
fn groups_sound_alone_must_also_pass_together() {
let preserves = |kept: &[usize]| !(kept.contains(&2) || (kept.contains(&1) && kept.contains(&3)));
assert_eq!(select_groups(&[1, 2, 3], &preserves), vec![1]);
assert_eq!(select_groups(&[1, 3], &|_: &[usize]| true), vec![1, 3]);
assert_eq!(select_groups(&[4], &|_: &[usize]| false), Vec::<usize>::new());
}
}