use std::cell::OnceCell;
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use skif_config::Prefer;
use skif_core::{
Diagnostic, FileChange, FileSystem, Fix, FixOp, FixPreference, FixSafety, Report, RuleId, TextEdit,
apply_text_edits, slash,
};
use skif_edit::{Document, EditPlanError, FileKind};
use skif_workspace::Workspace;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum Alternatives {
#[default]
Ask,
First,
Preferred(Prefer),
}
const fn preference_of(prefer: Prefer) -> FixPreference {
match prefer {
Prefer::Lowest => FixPreference::Lowest,
_ => FixPreference::Highest,
}
}
#[must_use]
pub fn preferred_alternative(fixes: &[Fix], prefer: Prefer) -> Option<usize> {
let wanted = Some(preference_of(prefer));
fixes.iter().position(|fix| fix.preference == wanted)
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct FixSelection {
pub safety: FixSafety,
pub rules: Option<BTreeSet<String>>,
pub choices: BTreeMap<usize, usize>,
pub alternatives: Alternatives,
pub remove_files: bool,
}
impl FixSelection {
#[must_use]
pub const fn new(safety: FixSafety) -> Self {
Self {
safety,
rules: None,
choices: BTreeMap::new(),
alternatives: Alternatives::Ask,
remove_files: false,
}
}
#[must_use]
pub const fn with_remove_files(mut self, remove_files: bool) -> Self {
self.remove_files = remove_files;
self
}
#[must_use]
pub fn with_rules(mut self, rules: impl IntoIterator<Item = String>) -> Self {
self.rules = Some(rules.into_iter().collect());
self
}
#[must_use]
pub const fn with_alternatives(mut self, alternatives: Alternatives) -> Self {
self.alternatives = alternatives;
self
}
#[must_use]
pub fn choose(mut self, diagnostic: usize, fix: usize) -> Self {
self.choices.insert(diagnostic, fix);
self
}
#[must_use]
pub fn allows(&self, safety: FixSafety) -> bool {
self.safety == FixSafety::Unsafe || safety == FixSafety::Safe
}
fn includes(&self, rule: &RuleId) -> bool {
self.rules.as_ref().is_none_or(|rules| rules.contains(rule.as_str()))
}
}
impl Default for FixSelection {
fn default() -> Self {
Self::new(FixSafety::Safe)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct AppliedFix {
pub diagnostic: usize,
pub rule: RuleId,
pub message: String,
pub title: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum SkipReason {
Safety(FixSafety),
NeedsChoice {
alternatives: Vec<String>,
},
Conflict(String),
Failed(String),
RemovesFiles,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct SkippedFix {
pub diagnostic: usize,
pub rule: RuleId,
pub message: String,
pub reason: SkipReason,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
#[non_exhaustive]
pub struct FixPlan {
pub changes: Vec<FileChange>,
pub applied: Vec<AppliedFix>,
pub skipped: Vec<SkippedFix>,
bases: BTreeMap<PathBuf, u64>,
}
#[must_use]
pub fn content_hash(bytes: &[u8]) -> u64 {
bytes.iter().fold(0xcbf2_9ce4_8422_2325, |hash, &byte| {
(hash ^ u64::from(byte)).wrapping_mul(0x0100_0000_01b3)
})
}
impl FixPlan {
#[must_use]
pub fn base_hash(&self, path: &Path) -> Option<u64> {
self.bases.get(path).copied()
}
#[must_use]
pub fn with_base_hash(mut self, path: impl Into<PathBuf>, hash: u64) -> Self {
self.bases.insert(path.into(), hash);
self
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.changes.is_empty()
}
pub fn paths(&self) -> impl Iterator<Item = &Path> {
self.changes.iter().map(FileChange::path)
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum FixError {
#[error("cannot plan edits for {}: {message}", slash(.path))]
Plan {
path: PathBuf,
message: String,
},
}
enum PlanFailure {
Conflict(String),
Other(String),
}
impl PlanFailure {
fn message(&self) -> &str {
match self {
Self::Conflict(message) | Self::Other(message) => message,
}
}
fn into_skip(self, path: &Path) -> SkipReason {
let text = format!("{}: {}", slash(path), self.message());
match self {
Self::Conflict(_) => SkipReason::Conflict(text),
Self::Other(_) => SkipReason::Failed(text),
}
}
}
enum Entry<'a> {
Edit {
ops: Vec<&'a FixOp>,
edits: Vec<TextEdit>,
},
Create(&'a str),
Delete,
}
enum Staged<'a> {
Edit {
ops: Vec<&'a FixOp>,
edits: Vec<TextEdit>,
},
Create(&'a str),
Delete,
Joint {
ops: Vec<&'a FixOp>,
edits: Vec<TextEdit>,
},
Nothing,
}
struct Sources<'a> {
root: &'a Path,
fs: &'a dyn FileSystem,
texts: BTreeMap<&'a Path, OnceCell<Result<Arc<str>, String>>>,
}
impl<'a> Sources<'a> {
fn new(root: &'a Path, fs: &'a dyn FileSystem, report: &'a Report) -> Self {
let texts = report
.diagnostics
.iter()
.flat_map(|diagnostic| &diagnostic.fixes)
.flat_map(|fix| &fix.ops)
.filter_map(op_path)
.map(|path| (path, OnceCell::new()))
.collect();
Self { root, fs, texts }
}
fn text(&self, path: &Path) -> Result<&Arc<str>, String> {
let cell = self
.texts
.get(path)
.ok_or_else(|| format!("cannot read {}: no fix of the report names it", slash(path)))?;
cell.get_or_init(|| {
self.fs
.read_to_string(&self.root.join(path))
.map_err(|error| format!("cannot read {}: {error}", slash(path)))
})
.as_ref()
.map_err(String::clone)
}
}
struct Documents<'a> {
sources: &'a Sources<'a>,
parsed: BTreeMap<&'a Path, OnceCell<Result<Document<'a>, String>>>,
}
impl<'a> Documents<'a> {
fn new(sources: &'a Sources<'a>) -> Self {
let parsed = sources.texts.keys().map(|&path| (path, OnceCell::new())).collect();
Self { sources, parsed }
}
fn document(&self, path: &Path, kind: FileKind) -> Result<&Document<'a>, String> {
let cell = self
.parsed
.get(path)
.ok_or_else(|| format!("cannot read {}: no fix of the report names it", slash(path)))?;
let sources = self.sources;
cell.get_or_init(|| {
let source = sources.text(path)?;
Document::parse(source, kind).map_err(|error| error.to_string())
})
.as_ref()
.map_err(String::clone)
}
}
struct Planner<'a> {
root: &'a Path,
fs: &'a dyn FileSystem,
documents: &'a Documents<'a>,
entries: BTreeMap<PathBuf, Entry<'a>>,
contributors: BTreeMap<PathBuf, Vec<usize>>,
}
struct Invalid {
path: PathBuf,
message: String,
culprit: Option<usize>,
}
fn op_path(op: &FixOp) -> Option<&Path> {
match op {
FixOp::Json { path, .. }
| FixOp::Yaml { path, .. }
| FixOp::Text { path, .. }
| FixOp::CreateFile { path, .. }
| FixOp::DeleteFile { path, .. } => Some(path),
_ => None,
}
}
const fn overlaps(first: &TextEdit, second: &TextEdit) -> bool {
first.start < second.end && second.start < first.end
}
fn collides(accepted: &[TextEdit], edit: &TextEdit) -> bool {
let first = accepted.partition_point(|other| other.end <= edit.start);
accepted
.get(first)
.is_some_and(|other| other != edit && overlaps(other, edit))
}
fn contains(accepted: &[TextEdit], edit: &TextEdit) -> bool {
let first = accepted.partition_point(|other| (other.start, other.end) < (edit.start, edit.end));
accepted[first..]
.iter()
.take_while(|other| (other.start, other.end) == (edit.start, edit.end))
.any(|other| other == edit)
}
fn extend_sorted(accepted: &mut Vec<TextEdit>, edits: Vec<TextEdit>) {
accepted.extend(edits);
accepted.sort_by_key(|edit| (edit.start, edit.end));
}
impl<'a> Planner<'a> {
fn try_add(&mut self, index: usize, fix: &'a Fix) -> Result<(), SkipReason> {
let mut by_file: BTreeMap<&Path, Vec<&'a FixOp>> = BTreeMap::new();
for op in &fix.ops {
let path = op_path(op).ok_or_else(|| SkipReason::Failed("unsupported operation".to_owned()))?;
by_file.entry(path).or_default().push(op);
}
let mut staged: Vec<(PathBuf, Staged<'a>)> = Vec::new();
for (path, ops) in by_file {
staged.push((path.to_path_buf(), self.stage(path, &ops)?));
}
for (path, change) in staged {
self.contributors.entry(path.clone()).or_default().push(index);
self.commit(path, change);
}
Ok(())
}
fn stage(&mut self, path: &Path, ops: &[&'a FixOp]) -> Result<Staged<'a>, SkipReason> {
let conflict = |what: &str| SkipReason::Conflict(format!("{}: {what}", slash(path)));
let creates = ops.iter().copied().find_map(|op| match op {
FixOp::CreateFile { contents, .. } => Some(contents.as_str()),
_ => None,
});
let deletes = ops.iter().any(|op| matches!(op, FixOp::DeleteFile { .. }));
let edits = ops
.iter()
.any(|op| matches!(op, FixOp::Json { .. } | FixOp::Yaml { .. } | FixOp::Text { .. }));
let existing = self.entries.get(path);
match (creates, deletes, edits) {
(Some(contents), false, false) => match existing {
None if self.fs.is_file(&self.root.join(path)) => Err(SkipReason::Failed(format!(
"{}: cannot create a file that already exists",
slash(path)
))),
None => Ok(Staged::Create(contents)),
Some(Entry::Create(earlier)) if *earlier == contents => Ok(Staged::Nothing),
Some(_) => Err(conflict("another fix already touches a file this fix creates")),
},
(None, true, false) => match existing {
None => Ok(Staged::Delete),
Some(Entry::Delete) => Ok(Staged::Nothing),
Some(_) => Err(conflict("another fix already touches a file this fix deletes")),
},
(None, false, true) => self.stage_edits(path, ops),
_ => Err(conflict("a single fix mixes creating, deleting and editing one file")),
}
}
fn stage_edits(&mut self, path: &Path, ops: &[&'a FixOp]) -> Result<Staged<'a>, SkipReason> {
let conflict = |what: &str| SkipReason::Conflict(format!("{}: {what}", slash(path)));
let fresh: Vec<&'a FixOp> = {
let earlier: &[&FixOp] = match self.entries.get(path) {
None => &[],
Some(Entry::Edit { ops, .. }) => ops.as_slice(),
Some(_) => return Err(conflict("another fix creates or deletes a file this fix edits")),
};
let mut fresh: Vec<&'a FixOp> = Vec::new();
for &op in ops {
if !earlier.contains(&op) && !fresh.contains(&op) {
fresh.push(op);
}
}
fresh
};
if fresh.is_empty() {
return Ok(Staged::Nothing);
}
let planned = self.plan(path, &fresh).map_err(|failure| failure.into_skip(path))?;
let accepted: &[TextEdit] = match self.entries.get(path) {
Some(Entry::Edit { edits, .. }) => edits.as_slice(),
_ => &[],
};
let edits: Vec<TextEdit> = planned.into_iter().filter(|edit| !contains(accepted, edit)).collect();
if edits.iter().any(|edit| collides(accepted, edit)) {
return self
.stage_jointly(path, fresh)
.ok_or_else(|| conflict("the edits overlap an earlier fix"));
}
Ok(Staged::Edit { ops: fresh, edits })
}
fn stage_jointly(&self, path: &Path, fresh: Vec<&'a FixOp>) -> Option<Staged<'a>> {
let kind = FileKind::from_path(path)?;
let Some(Entry::Edit { ops: earlier, .. }) = self.entries.get(path) else {
return None;
};
let all: Vec<&'a FixOp> = earlier.iter().copied().chain(fresh.iter().copied()).collect();
let document = self.documents.document(path, kind).ok()?;
let edits = document.plan(&all).ok()?;
apply_text_edits(document.source(), &edits).ok()?;
Some(Staged::Joint { ops: fresh, edits })
}
fn commit(&mut self, path: PathBuf, change: Staged<'a>) {
match change {
Staged::Nothing => {}
Staged::Create(contents) => {
self.entries.insert(path, Entry::Create(contents));
}
Staged::Delete => {
self.entries.insert(path, Entry::Delete);
}
Staged::Joint { ops, edits } => {
if let Some(Entry::Edit {
ops: accepted_ops,
edits: accepted_edits,
}) = self.entries.get_mut(&path)
{
accepted_ops.extend(ops);
accepted_edits.clear();
extend_sorted(accepted_edits, edits);
}
}
Staged::Edit { ops, edits } => match self.entries.entry(path) {
std::collections::btree_map::Entry::Occupied(mut occupied) => {
if let Entry::Edit {
ops: accepted_ops,
edits: accepted_edits,
} = occupied.get_mut()
{
accepted_ops.extend(ops);
extend_sorted(accepted_edits, edits);
}
}
std::collections::btree_map::Entry::Vacant(vacant) => {
let mut sorted = Vec::with_capacity(edits.len());
extend_sorted(&mut sorted, edits);
vacant.insert(Entry::Edit { ops, edits: sorted });
}
},
}
}
fn plan(&self, path: &Path, ops: &[&FixOp]) -> Result<Vec<TextEdit>, PlanFailure> {
match FileKind::from_path(path) {
Some(kind) => {
let document = self.documents.document(path, kind).map_err(PlanFailure::Other)?;
document.plan(ops).map_err(|error| plan_failure(&error))
}
None => text_edits(self.documents.sources.text(path).map_err(PlanFailure::Other)?, ops),
}
}
fn finish(mut self) -> Result<(Vec<FileChange>, BTreeMap<PathBuf, u64>), Invalid> {
let entries = std::mem::take(&mut self.entries);
let mut changes = Vec::with_capacity(entries.len());
let mut bases = BTreeMap::new();
for (path, entry) in entries {
let culprit = self
.contributors
.get(&path)
.and_then(|accepted| accepted.last().copied());
let invalid = |message: String| Invalid {
path: path.clone(),
message,
culprit,
};
changes.push(match entry {
Entry::Create(contents) => FileChange::Create {
path: path.clone(),
contents: contents.to_owned(),
},
Entry::Delete => {
if let Ok(bytes) = self.fs.read_bytes(&self.root.join(&path)) {
bases.insert(path.clone(), content_hash(&bytes));
}
FileChange::Delete { path: path.clone() }
}
Entry::Edit { ops, edits } => {
let source = self.documents.sources.text(&path).map_err(invalid)?;
let edits = match FileKind::from_path(&path) {
Some(kind) => self
.documents
.document(&path, kind)
.map_err(invalid)?
.plan(&ops)
.map_err(|error| invalid(error.to_string()))?,
None => edits,
};
let new_contents = apply_text_edits(source, &edits).map_err(|error| invalid(error.to_string()))?;
bases.insert(path.clone(), content_hash(source.as_bytes()));
FileChange::Modify {
path: path.clone(),
edits,
new_contents,
}
}
});
}
Ok((changes, bases))
}
}
fn plan_failure(error: &EditPlanError) -> PlanFailure {
match error {
EditPlanError::Conflict { .. } => PlanFailure::Conflict(error.to_string()),
_ => PlanFailure::Other(error.to_string()),
}
}
fn text_edits(source: &str, ops: &[&FixOp]) -> Result<Vec<TextEdit>, PlanFailure> {
let edits: Vec<TextEdit> = ops
.iter()
.map(|op| match op {
FixOp::Text { edit, .. } => Ok(edit.clone()),
_ => Err(PlanFailure::Other("unsupported file type".to_owned())),
})
.collect::<Result<_, _>>()?;
apply_text_edits(source, &edits).map_err(|error| PlanFailure::Other(error.to_string()))?;
Ok(edits)
}
fn deletes_files(fix: &Fix) -> bool {
fix.ops.iter().any(|op| matches!(op, FixOp::DeleteFile { .. }))
}
enum Pick<'a> {
Fix(&'a Fix),
Skip(SkipReason),
}
fn pick<'a>(selection: &FixSelection, index: usize, diagnostic: &'a Diagnostic) -> Pick<'a> {
if let Some(&number) = selection.choices.get(&index) {
return match diagnostic.fixes.get(number) {
Some(chosen) if selection.allows(chosen.safety) => Pick::Fix(chosen),
Some(chosen) => Pick::Skip(SkipReason::Safety(chosen.safety)),
None => Pick::Skip(SkipReason::Failed(format!("diagnostic has no fix number {number}"))),
};
}
let allowed: Vec<&Fix> = diagnostic
.fixes
.iter()
.filter(|fix| selection.allows(fix.safety))
.collect();
match (allowed.as_slice(), diagnostic.fixes.first()) {
([only], _) => Pick::Fix(only),
([], Some(first)) => Pick::Skip(SkipReason::Safety(first.safety)),
(alternatives, _) => {
let chosen = match selection.alternatives {
Alternatives::First => alternatives.first().copied(),
Alternatives::Preferred(prefer) => alternatives
.iter()
.copied()
.find(|fix| fix.preference == Some(preference_of(prefer))),
Alternatives::Ask => None,
};
chosen.map_or_else(
|| {
Pick::Skip(SkipReason::NeedsChoice {
alternatives: alternatives.iter().map(|fix| fix.title.clone()).collect(),
})
},
Pick::Fix,
)
}
}
}
pub fn apply_fixes(
workspace: &Workspace,
report: &Report,
selection: &FixSelection,
fs: &dyn FileSystem,
) -> Result<FixPlan, FixError> {
let sources = Sources::new(&workspace.root, fs, report);
let documents = Documents::new(&sources);
let mut dropped: BTreeMap<usize, SkipReason> = BTreeMap::new();
loop {
match plan_once(&documents, report, selection, &dropped) {
Ok(plan) => return Ok(plan),
Err(Invalid {
path,
culprit: Some(index),
..
}) => {
dropped.insert(
index,
SkipReason::Conflict(format!(
"{}: the combined edits of the accepted fixes do not form a valid file",
slash(&path)
)),
);
}
Err(Invalid { path, message, .. }) => return Err(FixError::Plan { path, message }),
}
}
}
fn plan_once<'a>(
documents: &'a Documents<'a>,
report: &'a Report,
selection: &FixSelection,
dropped: &BTreeMap<usize, SkipReason>,
) -> Result<FixPlan, Invalid> {
let sources = documents.sources;
let mut planner = Planner {
root: sources.root,
fs: sources.fs,
documents,
entries: BTreeMap::new(),
contributors: BTreeMap::new(),
};
let mut applied = Vec::new();
let mut skipped = Vec::new();
for (index, diagnostic) in report.diagnostics.iter().enumerate() {
if diagnostic.fixes.is_empty() || !selection.includes(&diagnostic.rule) {
continue;
}
let outcome = match (dropped.get(&index), pick(selection, index, diagnostic)) {
(Some(reason), _) => Err(reason.clone()),
(None, Pick::Fix(fix)) if !selection.remove_files && deletes_files(fix) => Err(SkipReason::RemovesFiles),
(None, Pick::Fix(fix)) => planner.try_add(index, fix).map(|()| fix.title.clone()),
(None, Pick::Skip(reason)) => Err(reason),
};
match outcome {
Ok(title) => applied.push(AppliedFix {
diagnostic: index,
rule: diagnostic.rule.clone(),
message: diagnostic.message.clone(),
title,
}),
Err(reason) => skipped.push(SkippedFix {
diagnostic: index,
rule: diagnostic.rule.clone(),
message: diagnostic.message.clone(),
reason,
}),
}
}
let (changes, bases) = planner.finish()?;
Ok(FixPlan {
changes,
applied,
skipped,
bases,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn inserted_one_by_one(mut accepted: Vec<TextEdit>, edits: Vec<TextEdit>) -> Vec<TextEdit> {
for edit in edits {
let at = accepted.partition_point(|other| (other.start, other.end) <= (edit.start, edit.end));
accepted.insert(at, edit);
}
accepted
}
#[test]
fn extending_keeps_positions_sorted_and_ties_in_the_order_added() {
let accepted = vec![
TextEdit::new(0, 0, "a"),
TextEdit::new(4, 6, "b"),
TextEdit::new(4, 6, "c"),
TextEdit::new(9, 9, "d"),
];
let edits = vec![
TextEdit::new(9, 9, "e"),
TextEdit::new(0, 0, "f"),
TextEdit::new(4, 6, "g"),
TextEdit::new(2, 3, "h"),
TextEdit::new(0, 0, "i"),
];
let mut extended = accepted.clone();
extend_sorted(&mut extended, edits.clone());
let order: Vec<&str> = extended.iter().map(|edit| edit.replacement.as_str()).collect();
assert_eq!(order, ["a", "f", "i", "h", "b", "c", "g", "d", "e"]);
assert_eq!(extended, inserted_one_by_one(accepted, edits.clone()));
let mut fresh = Vec::new();
extend_sorted(&mut fresh, edits.clone());
assert_eq!(fresh, inserted_one_by_one(Vec::new(), edits));
}
}