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::{EditPlanError, FileKind, plan_edits};
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,
}
impl FixSelection {
#[must_use]
pub const fn new(safety: FixSafety) -> Self {
Self {
safety,
rules: None,
choices: BTreeMap::new(),
alternatives: Alternatives::Ask,
}
}
#[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),
}
#[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,
Nothing,
}
struct Planner<'a> {
root: &'a Path,
fs: &'a dyn FileSystem,
sources: BTreeMap<PathBuf, Arc<str>>,
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 insert_sorted(accepted: &mut Vec<TextEdit>, edit: TextEdit) {
let at = accepted.partition_point(|other| (other.start, other.end) <= (edit.start, edit.end));
accepted.insert(at, edit);
}
impl<'a> Planner<'a> {
fn source(&mut self, path: &Path) -> Result<Arc<str>, String> {
if let Some(source) = self.sources.get(path) {
return Ok(Arc::clone(source));
}
let source = self
.fs
.read_to_string(&self.root.join(path))
.map_err(|error| format!("cannot read {}: {error}", slash(path)))?;
self.sources.insert(path.to_path_buf(), Arc::clone(&source));
Ok(source)
}
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 Err(conflict("the edits overlap an earlier fix"));
}
Ok(Staged::Edit { 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::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);
for edit in edits {
insert_sorted(accepted_edits, edit);
}
}
}
std::collections::btree_map::Entry::Vacant(vacant) => {
let mut sorted = Vec::with_capacity(edits.len());
for edit in edits {
insert_sorted(&mut sorted, edit);
}
vacant.insert(Entry::Edit { ops, edits: sorted });
}
},
}
}
fn plan(&mut self, path: &Path, ops: &[&FixOp]) -> Result<Vec<TextEdit>, PlanFailure> {
let source = self.source(path).map_err(PlanFailure::Other)?;
match FileKind::from_path(path) {
Some(kind) => plan_edits(&source, kind, ops).map_err(|error| plan_failure(&error)),
None => text_edits(&source, 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.source(&path).map_err(invalid)?;
let edits = match FileKind::from_path(&path) {
Some(kind) => {
skif_edit::plan_edits(&source, kind, &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)
}
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 mut dropped: BTreeMap<usize, SkipReason> = BTreeMap::new();
loop {
match plan_once(workspace, report, selection, fs, &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(
workspace: &Workspace,
report: &Report,
selection: &FixSelection,
fs: &dyn FileSystem,
dropped: &BTreeMap<usize, SkipReason>,
) -> Result<FixPlan, Invalid> {
let mut planner = Planner {
root: &workspace.root,
fs,
sources: BTreeMap::new(),
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)) => 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,
})
}