use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde_json::{Value, json};
use skif::{
Alternatives, CachedFs, Diagnostic, FileChange, FileSystem, FixPlan, FixSafety, FixSelection, Report, apply_fixes,
content_hash,
};
use skif_core::LineIndex;
use super::{lsp_diagnostic, path_to_uri, position};
use crate::roots::RootRun;
pub const FIX_ALL_KIND: &str = "source.fixAll.skif";
pub const QUICK_FIX_KIND: &str = "quickfix";
type Position = (u64, u64);
pub struct Request<'a> {
pub file: &'a Path,
pub range: (Position, Position),
pub only: Option<Vec<String>>,
pub documents: &'a BTreeMap<PathBuf, (u64, Option<i64>)>,
pub linted: &'a BTreeMap<PathBuf, BTreeMap<PathBuf, u64>>,
pub document_changes: bool,
}
type FileEdits = (Option<i64>, Vec<Value>);
pub fn code_actions(request: &Request<'_>, roots: &BTreeMap<PathBuf, Vec<RootRun>>, fs: &dyn FileSystem) -> Vec<Value> {
let fs = CachedFs::new(fs);
let mut quick_fixes = Vec::new();
let mut fix_all: BTreeMap<String, FileEdits> = BTreeMap::new();
for (base, runs) in roots {
let check = Check {
request,
linted: request.linted.get(base),
};
for run in runs {
let in_file: Vec<&Diagnostic> = run
.report
.diagnostics
.iter()
.filter(|diagnostic| base.join(run.prefix.join(&diagnostic.location.path)) == request.file)
.collect();
if in_file.is_empty() {
continue;
}
let text = fs.read_to_string(request.file).unwrap_or_default();
let index = LineIndex::new(&text);
if wants(request.only.as_deref(), QUICK_FIX_KIND) {
for diagnostic in &in_file {
let lsp = lsp_diagnostic(diagnostic, &index, &text);
if overlaps(&lsp["range"], request.range) {
quick_fixes.extend(fixes_of(run, diagnostic, &lsp, &check, &fs));
}
}
}
if wants(request.only.as_deref(), FIX_ALL_KIND) {
let report = Report::new(in_file.into_iter().cloned().collect(), 0);
let selection = FixSelection::new(FixSafety::Safe)
.with_alternatives(Alternatives::Preferred(run.config.policy.prefer));
if let Ok(plan) = apply_fixes(&run.workspace, &report, &selection, &fs)
&& let Some(changes) = workspace_changes(&plan, &run.workspace.root, &check, &fs)
{
for (uri, (version, edits)) in changes {
fix_all
.entry(uri)
.or_insert_with(|| (version, Vec::new()))
.1
.extend(edits);
}
}
}
}
}
if !fix_all.is_empty() {
quick_fixes.push(json!({
"title": "Fix all safe skif problems in this file",
"kind": FIX_ALL_KIND,
"edit": workspace_edit(fix_all, request.document_changes),
}));
}
quick_fixes
}
fn fixes_of(run: &RootRun, diagnostic: &Diagnostic, lsp: &Value, check: &Check<'_>, fs: &dyn FileSystem) -> Vec<Value> {
let report = Report::new(vec![diagnostic.clone()], 0);
let preferred = diagnostic.fixes.len() == 1;
diagnostic
.fixes
.iter()
.enumerate()
.filter_map(|(number, fix)| {
let selection = FixSelection::new(FixSafety::Unsafe).choose(0, number);
let plan = apply_fixes(&run.workspace, &report, &selection, fs).ok()?;
if plan.applied.is_empty() {
return None;
}
let changes = workspace_changes(&plan, &run.workspace.root, check, fs)?;
let (title, is_preferred) = match fix.safety {
FixSafety::Safe => (fix.title.clone(), preferred),
_ => (format!("{} (unsafe)", fix.title), false),
};
Some(json!({
"title": title,
"kind": QUICK_FIX_KIND,
"diagnostics": [lsp],
"isPreferred": is_preferred,
"edit": workspace_edit(changes, check.request.document_changes),
}))
})
.collect()
}
struct Check<'a> {
request: &'a Request<'a>,
linted: Option<&'a BTreeMap<PathBuf, u64>>,
}
impl Check<'_> {
fn editable(&self, file: &Path, hash: u64) -> bool {
let read = self.linted.and_then(|linted| linted.get(file));
match self.request.documents.get(file) {
Some((current, _)) => *current == hash && read == Some(&hash),
None => read.is_none_or(|read| *read == hash),
}
}
fn version(&self, file: &Path) -> Option<i64> {
self.request.documents.get(file).and_then(|(_, version)| *version)
}
}
fn workspace_changes(
plan: &FixPlan,
root: &Path,
check: &Check<'_>,
fs: &dyn FileSystem,
) -> Option<BTreeMap<String, FileEdits>> {
if plan.is_empty() {
return None;
}
let mut changes = BTreeMap::new();
for change in &plan.changes {
let FileChange::Modify { path, edits, .. } = change else {
return None;
};
let file = root.join(path);
let source = fs.read_to_string(&file).ok()?;
let hash = content_hash(source.as_bytes());
if plan.base_hash(path) != Some(hash) {
return None;
}
if !check.editable(&file, hash) {
return None;
}
let version = check.version(&file);
let index = LineIndex::new(&source);
let edits: Vec<Value> = edits
.iter()
.map(|edit| {
json!({
"range": { "start": position(&index, edit.start), "end": position(&index, edit.end) },
"newText": edit.replacement,
})
})
.collect();
changes.insert(path_to_uri(&file), (version, edits));
}
Some(changes)
}
fn workspace_edit(changes: BTreeMap<String, FileEdits>, document_changes: bool) -> Value {
if document_changes {
let documents: Vec<Value> = changes
.into_iter()
.map(
|(uri, (version, edits))| json!({ "textDocument": { "uri": uri, "version": version }, "edits": edits }),
)
.collect();
json!({ "documentChanges": documents })
} else {
let changes: BTreeMap<String, Vec<Value>> = changes.into_iter().map(|(uri, (_, edits))| (uri, edits)).collect();
json!({ "changes": changes })
}
}
fn wants(only: Option<&[String]>, kind: &str) -> bool {
only.is_none_or(|only| {
only.iter().any(|wanted| {
kind == wanted
|| kind
.strip_prefix(wanted.as_str())
.is_some_and(|rest| rest.starts_with('.'))
})
})
}
pub fn position_of(value: &Value) -> Position {
let field = |name: &str| value.get(name).and_then(Value::as_u64).unwrap_or(0);
(field("line"), field("character"))
}
fn overlaps(range: &Value, requested: (Position, Position)) -> bool {
let (start, end) = (position_of(&range["start"]), position_of(&range["end"]));
start <= requested.1 && requested.0 <= end
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_selects_a_kind_and_its_sub_kinds() {
let only = |kinds: &[&str]| kinds.iter().map(|kind| (*kind).to_owned()).collect::<Vec<_>>();
assert!(wants(None, FIX_ALL_KIND));
assert!(wants(Some(&only(&["source"])), FIX_ALL_KIND));
assert!(wants(Some(&only(&["source.fixAll"])), FIX_ALL_KIND));
assert!(!wants(Some(&only(&["source.fix"])), FIX_ALL_KIND));
assert!(!wants(Some(&only(&["quickfix"])), FIX_ALL_KIND));
assert!(wants(Some(&only(&["refactor", "quickfix"])), QUICK_FIX_KIND));
}
#[test]
fn a_document_closed_since_the_lint_is_editable_only_with_the_linted_text() {
let (open, closed, untouched) = (Path::new("/ws/a"), Path::new("/ws/b"), Path::new("/ws/c"));
let documents = BTreeMap::from([(open.to_path_buf(), (1, Some(3)))]);
let linted = BTreeMap::from([(open.to_path_buf(), 1), (closed.to_path_buf(), 2)]);
let roots = BTreeMap::new();
let request = Request {
file: open,
range: ((0, 0), (0, 0)),
only: None,
documents: &documents,
linted: &roots,
document_changes: false,
};
let check = Check {
request: &request,
linted: Some(&linted),
};
assert!(check.editable(open, 1));
assert!(!check.editable(open, 9));
assert!(check.editable(closed, 2));
assert!(!check.editable(closed, 9));
assert!(check.editable(untouched, 9));
}
#[test]
fn ranges_overlap_when_they_share_a_position() {
let range = json!({ "start": { "line": 6, "character": 2 }, "end": { "line": 6, "character": 23 } });
assert!(overlaps(&range, ((6, 5), (6, 5))));
assert!(overlaps(&range, ((6, 23), (7, 0))));
assert!(overlaps(&range, ((0, 0), (9, 0))));
assert!(!overlaps(&range, ((6, 24), (6, 30))));
assert!(!overlaps(&range, ((5, 0), (6, 1))));
}
}