use std::collections::HashMap;
use std::path::{Path, PathBuf};
use super::diagnostic::{Applicability, Edit, Fix};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FixOutcome {
pub output: String,
pub applied: usize,
pub skipped_conflicts: usize,
}
pub fn apply_fixes(source: &str, fixes: &[Fix], include_unsafe: bool) -> FixOutcome {
let mut eligible: Vec<(usize, usize, &Fix)> = fixes
.iter()
.filter(|f| include_unsafe || f.applicability == Applicability::Safe)
.map(|f| {
let start = f.edits.iter().map(|e| e.start).min().unwrap_or(0);
let end = f.edits.iter().map(|e| e.end).max().unwrap_or(0);
(start, end, f)
})
.collect();
eligible.sort_by_key(|&(start, end, _)| (start, end));
let mut occupied: Vec<(usize, usize)> = Vec::new();
let mut accepted: Vec<&super::diagnostic::Edit> = Vec::new();
let mut applied = 0usize;
let mut skipped_conflicts = 0usize;
for (_, _, fix) in eligible {
let mut edits: Vec<_> = fix.edits.iter().collect();
edits.sort_by_key(|e| (e.start, e.end));
let malformed = edits.is_empty()
|| edits.iter().any(|e| e.path.is_some())
|| edits
.iter()
.any(|e| e.start > e.end || e.end > source.len())
|| edits.windows(2).any(|pair| pair[1].start < pair[0].end);
if malformed || edits.iter().any(|e| overlaps(&occupied, e.start, e.end)) {
skipped_conflicts += 1;
continue;
}
for edit in edits {
let at = occupied.partition_point(|&(_, end)| end <= edit.start);
occupied.insert(at, (edit.start, edit.end));
accepted.push(edit);
}
applied += 1;
}
accepted.sort_by_key(|e| (e.start, e.end));
let mut output = source.to_string();
for edit in accepted.iter().rev() {
output.replace_range(edit.start..edit.end, &edit.content);
}
FixOutcome {
output,
applied,
skipped_conflicts,
}
}
fn overlaps(occupied: &[(usize, usize)], start: usize, end: usize) -> bool {
let at = occupied.partition_point(|&(_, e)| e <= start);
occupied.get(at).is_some_and(|&(s, _)| s < end)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MultiFixOutcome {
pub outputs: HashMap<PathBuf, String>,
pub applied: usize,
pub skipped_conflicts: usize,
}
pub fn apply_fixes_multi(
sources: &HashMap<PathBuf, String>,
fixes: &[(PathBuf, &Fix)],
include_unsafe: bool,
) -> MultiFixOutcome {
let target = |origin: &Path, e: &Edit| -> PathBuf {
e.path.clone().unwrap_or_else(|| origin.to_path_buf())
};
let mut eligible: Vec<(PathBuf, usize, usize, &Path, &Fix)> = fixes
.iter()
.filter(|(_, f)| include_unsafe || f.applicability == Applicability::Safe)
.map(|(origin, f)| {
let key = f
.edits
.iter()
.map(|e| (target(origin, e), e.start, e.end))
.min()
.unwrap_or_else(|| (origin.clone(), 0, 0));
(key.0, key.1, key.2, origin.as_path(), *f)
})
.collect();
eligible.sort_by(|a, b| (&a.0, a.1, a.2).cmp(&(&b.0, b.1, b.2)));
let mut occupied: HashMap<PathBuf, Vec<(usize, usize)>> = HashMap::new();
let mut accepted: Vec<(PathBuf, &Edit)> = Vec::new();
let mut applied = 0usize;
let mut skipped_conflicts = 0usize;
for (_, _, _, origin, fix) in eligible {
let mut resolved: Vec<(PathBuf, &Edit)> =
fix.edits.iter().map(|e| (target(origin, e), e)).collect();
resolved.sort_by(|a, b| (&a.0, a.1.start, a.1.end).cmp(&(&b.0, b.1.start, b.1.end)));
let malformed = resolved.is_empty()
|| resolved.iter().any(|(p, e)| {
sources
.get(p)
.is_none_or(|src| e.start > e.end || e.end > src.len())
})
|| resolved
.windows(2)
.any(|pair| pair[0].0 == pair[1].0 && pair[1].1.start < pair[0].1.end);
let conflicts = resolved.iter().any(|(p, e)| {
occupied
.get(p)
.is_some_and(|claimed| overlaps(claimed, e.start, e.end))
});
if malformed || conflicts {
skipped_conflicts += 1;
continue;
}
for (p, e) in resolved {
let claimed = occupied.entry(p.clone()).or_default();
let at = claimed.partition_point(|&(_, end)| end <= e.start);
claimed.insert(at, (e.start, e.end));
accepted.push((p, e));
}
applied += 1;
}
let mut by_file: HashMap<PathBuf, Vec<&Edit>> = HashMap::new();
for (p, e) in accepted {
by_file.entry(p).or_default().push(e);
}
let mut outputs = HashMap::new();
for (path, mut edits) in by_file {
edits.sort_by_key(|e| (e.start, e.end));
let mut text = sources[&path].clone();
for edit in edits.iter().rev() {
text.replace_range(edit.start..edit.end, &edit.content);
}
outputs.insert(path, text);
}
MultiFixOutcome {
outputs,
applied,
skipped_conflicts,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::linter::diagnostic::Edit;
fn safe(start: usize, end: usize, content: &str) -> Fix {
Fix::safe(start, end, content, "test")
}
#[test]
fn applies_single_fix() {
let out = apply_fixes("$$x$$", &[safe(0, 2, "\\[")], false);
assert_eq!(out.output, "\\[x$$");
assert_eq!(out.applied, 1);
assert_eq!(out.skipped_conflicts, 0);
}
#[test]
fn applies_multiple_fixes_right_to_left() {
let out = apply_fixes("$$x$$", &[safe(0, 2, "\\["), safe(3, 5, "\\]")], false);
assert_eq!(out.output, "\\[x\\]");
assert_eq!(out.applied, 2);
}
#[test]
fn skips_unsafe_unless_opted_in() {
let fixes = [Fix::unsafe_(0, 6, "", "delete")];
let kept = apply_fixes("abcdef", &fixes, false);
assert_eq!(kept.output, "abcdef");
assert_eq!(kept.applied, 0);
let applied = apply_fixes("abcdef", &fixes, true);
assert_eq!(applied.output, "");
assert_eq!(applied.applied, 1);
}
#[test]
fn drops_overlapping_fixes() {
let out = apply_fixes("abcdef", &[safe(0, 3, "X"), safe(2, 5, "Y")], false);
assert_eq!(out.output, "Xdef");
assert_eq!(out.applied, 1);
assert_eq!(out.skipped_conflicts, 1);
}
#[test]
fn adjacent_fixes_do_not_conflict() {
let out = apply_fixes("abcd", &[safe(0, 2, "X"), safe(2, 4, "Y")], false);
assert_eq!(out.output, "XY");
assert_eq!(out.applied, 2);
assert_eq!(out.skipped_conflicts, 0);
}
#[test]
fn applies_multi_edit_fix() {
let fix = Fix::safe_edits(vec![Edit::new(0, 2, "\\["), Edit::new(3, 5, "\\]")], "swap");
let out = apply_fixes("$$x$$", &[fix], false);
assert_eq!(out.output, "\\[x\\]");
assert_eq!(out.applied, 1);
assert_eq!(out.skipped_conflicts, 0);
}
#[test]
fn multi_edit_fix_is_atomic_on_conflict() {
let single = safe(0, 2, "Z");
let multi = Fix::safe_edits(vec![Edit::new(1, 3, "X"), Edit::new(6, 8, "Y")], "pair");
let out = apply_fixes("abcdefgh", &[multi, single], false);
assert_eq!(out.output, "Zcdefgh");
assert_eq!(out.applied, 1);
assert_eq!(out.skipped_conflicts, 1);
}
#[test]
fn multi_edit_fix_conflicts_detected_out_of_order() {
let pair = Fix::safe_edits(vec![Edit::new(0, 2, "A"), Edit::new(6, 8, "B")], "pair");
let between = safe(3, 4, "-");
let clash = safe(7, 9, "!");
let out = apply_fixes("abcdefghij", &[pair, between, clash], false);
assert_eq!(out.output, "Ac-efBij");
assert_eq!(out.applied, 2);
assert_eq!(out.skipped_conflicts, 1);
}
#[test]
fn drops_internally_overlapping_fix() {
let bad = Fix::safe_edits(
vec![Edit::new(0, 3, "X"), Edit::new(2, 5, "Y")],
"malformed",
);
let out = apply_fixes("abcdef", &[bad], false);
assert_eq!(out.output, "abcdef");
assert_eq!(out.applied, 0);
assert_eq!(out.skipped_conflicts, 1);
}
#[test]
fn drops_out_of_bounds_fix() {
let out = apply_fixes("abc", &[safe(1, 9, "X")], false);
assert_eq!(out.output, "abc");
assert_eq!(out.applied, 0);
assert_eq!(out.skipped_conflicts, 1);
}
#[test]
fn single_file_skips_cross_file_fix() {
let other = PathBuf::from("other.tex");
let fix = Fix::safe_edits(
vec![Edit::new(0, 1, "X"), Edit::in_file(other, 0, 1, "Y")],
"cross",
);
let out = apply_fixes("abc", &[fix], false);
assert_eq!(out.output, "abc");
assert_eq!(out.applied, 0);
assert_eq!(out.skipped_conflicts, 1);
}
fn sources(entries: &[(&str, &str)]) -> HashMap<PathBuf, String> {
entries
.iter()
.map(|(p, t)| (PathBuf::from(p), (*t).to_string()))
.collect()
}
#[test]
fn multi_applies_across_two_files() {
let a = PathBuf::from("a.tex");
let b = PathBuf::from("b.tex");
let src = sources(&[("a.tex", "\\label{x}"), ("b.tex", "\\ref{x}")]);
let fix = Fix::safe_edits(
vec![Edit::new(7, 8, "y"), Edit::in_file(b.clone(), 5, 6, "y")],
"rename x to y",
);
let out = apply_fixes_multi(&src, &[(a.clone(), &fix)], false);
assert_eq!(out.applied, 1);
assert_eq!(out.skipped_conflicts, 0);
assert_eq!(out.outputs[&a], "\\label{y}");
assert_eq!(out.outputs[&b], "\\ref{y}");
}
#[test]
fn multi_leaves_untouched_files_absent() {
let a = PathBuf::from("a.tex");
let src = sources(&[("a.tex", "abc"), ("b.tex", "def")]);
let fix = Fix::safe_edits(vec![Edit::new(0, 1, "X")], "local");
let out = apply_fixes_multi(&src, &[(a.clone(), &fix)], false);
assert_eq!(out.outputs.len(), 1);
assert_eq!(out.outputs[&a], "Xbc");
assert!(!out.outputs.contains_key(&PathBuf::from("b.tex")));
}
#[test]
fn multi_cross_file_conflict_drops_whole_fix() {
let a = PathBuf::from("a.tex");
let b = PathBuf::from("b.tex");
let src = sources(&[("a.tex", "abcd"), ("b.tex", "wxyz")]);
let first = (
a.clone(),
Fix::safe_edits(vec![Edit::new(0, 2, "Z")], "first"),
);
let second = (
a.clone(),
Fix::safe_edits(
vec![Edit::new(1, 3, "Q"), Edit::in_file(b.clone(), 0, 1, "!")],
"second",
),
);
let refs = [(first.0.clone(), &first.1), (second.0.clone(), &second.1)];
let out = apply_fixes_multi(&src, &refs, false);
assert_eq!(out.applied, 1);
assert_eq!(out.skipped_conflicts, 1);
assert_eq!(out.outputs[&a], "Zcd");
assert!(!out.outputs.contains_key(&b), "b.tex must stay untouched");
}
#[test]
fn multi_drops_fix_targeting_missing_file() {
let a = PathBuf::from("a.tex");
let missing = PathBuf::from("gone.tex");
let src = sources(&[("a.tex", "abc")]);
let fix = Fix::safe_edits(
vec![Edit::new(0, 1, "X"), Edit::in_file(missing, 0, 1, "Y")],
"cross",
);
let out = apply_fixes_multi(&src, &[(a.clone(), &fix)], false);
assert_eq!(out.applied, 0);
assert_eq!(out.skipped_conflicts, 1);
assert!(out.outputs.is_empty());
}
#[test]
fn multi_none_edit_resolves_to_origin() {
let a = PathBuf::from("a.tex");
let src = sources(&[("a.tex", "abc"), ("b.tex", "abc")]);
let fix = Fix::safe_edits(vec![Edit::new(0, 1, "X")], "local");
let out = apply_fixes_multi(&src, &[(a.clone(), &fix)], false);
assert_eq!(out.outputs[&a], "Xbc");
assert!(!out.outputs.contains_key(&PathBuf::from("b.tex")));
}
#[test]
fn multi_skips_unsafe_unless_opted_in() {
let a = PathBuf::from("a.tex");
let src = sources(&[("a.tex", "abcdef")]);
let fix = Fix::unsafe_edits(vec![Edit::new(0, 6, "")], "delete");
let kept = apply_fixes_multi(&src, &[(a.clone(), &fix)], false);
assert_eq!(kept.applied, 0);
assert!(kept.outputs.is_empty());
let applied = apply_fixes_multi(&src, &[(a.clone(), &fix)], true);
assert_eq!(applied.applied, 1);
assert_eq!(applied.outputs[&a], "");
}
}