use std::collections::HashMap;
use std::path::PathBuf;
use super::*;
use crate::linter::diagnostic::Applicability;
use lsp_types::{CodeAction, CodeActionKind, CodeActionOrCommand, CodeActionResponse};
#[allow(clippy::too_many_arguments)]
pub(crate) fn code_actions_for_range(
findings: &[crate::linter::Diagnostic],
text: &str,
uri: &Uri,
self_path: &Path,
request_range: Range,
enc: PositionEncoding,
link_docs: bool,
resolve: &dyn Fn(&Path) -> Option<(Uri, String)>,
) -> CodeActionResponse {
let idx = LineIndex::with_encoding(text, enc);
let req_start = idx.offset_at(request_range.start.line, request_range.start.character);
let req_end = idx.offset_at(request_range.end.line, request_range.end.character);
findings
.iter()
.filter_map(|d| {
let fix = d.fix.as_ref()?;
if !byte_ranges_overlap(d.start, d.end, req_start, req_end) {
return None;
}
let changes = workspace_changes(fix, uri, &idx, enc, resolve)?;
Some(CodeActionOrCommand::CodeAction(CodeAction {
title: fix.description.clone(),
kind: Some(CodeActionKind::QUICKFIX),
diagnostics: Some(vec![lint_to_lsp(&idx, d.clone(), link_docs, self_path)]),
edit: Some(WorkspaceEdit {
changes: Some(changes),
..Default::default()
}),
is_preferred: Some(fix.applicability == Applicability::Safe),
..Default::default()
}))
})
.collect()
}
fn workspace_changes(
fix: &crate::linter::Fix,
self_uri: &Uri,
self_idx: &LineIndex,
enc: PositionEncoding,
resolve: &dyn Fn(&Path) -> Option<(Uri, String)>,
) -> Option<HashMap<Uri, Vec<TextEdit>>> {
let mut foreign: HashMap<PathBuf, (Uri, LineIndex)> = HashMap::new();
let mut changes: HashMap<Uri, Vec<TextEdit>> = HashMap::new();
for e in &fix.edits {
let (target_uri, edit) = match &e.path {
None => (
self_uri.clone(),
TextEdit {
range: byte_range_to_lsp(self_idx, e.start, e.end),
new_text: e.content.clone(),
},
),
Some(p) => {
if !foreign.contains_key(p) {
let (u, txt) = resolve(p)?;
foreign.insert(p.clone(), (u, LineIndex::with_encoding(&txt, enc)));
}
let (u, fidx) = &foreign[p];
(
u.clone(),
TextEdit {
range: byte_range_to_lsp(fidx, e.start, e.end),
new_text: e.content.clone(),
},
)
}
};
changes.entry(target_uri).or_default().push(edit);
}
Some(changes)
}
fn byte_ranges_overlap(a_start: usize, a_end: usize, b_start: usize, b_end: usize) -> bool {
a_start <= b_end && b_start <= a_end
}
#[cfg(test)]
mod tests {
use super::*;
use crate::linter::check_document;
use crate::parser::LexConfig;
fn uri() -> Uri {
"file:///x.tex".parse().unwrap()
}
fn full_range(text: &str) -> Range {
let idx = LineIndex::new(text);
let (el, ec) = idx.position(text.len());
Range {
start: Position::new(0, 0),
end: Position::new(el, ec),
}
}
fn findings(src: &str) -> Vec<crate::linter::Diagnostic> {
check_document(std::path::Path::new("x.tex"), src, LexConfig::default())
}
fn no_resolve(_: &Path) -> Option<(Uri, String)> {
None
}
#[test]
fn offers_quickfix_for_deprecated_command_in_range() {
let src = "{\\bf hi}\n";
let actions = code_actions_for_range(
&findings(src),
src,
&uri(),
std::path::Path::new("x.tex"),
full_range(src),
PositionEncoding::Utf16,
true,
&no_resolve,
);
let CodeActionOrCommand::CodeAction(action) = actions
.iter()
.find(
|a| matches!(a, CodeActionOrCommand::CodeAction(a) if a.title.contains("bfseries")),
)
.expect("a `\\bf` → `\\bfseries` quick-fix")
else {
unreachable!()
};
assert_eq!(action.kind, Some(CodeActionKind::QUICKFIX));
assert_eq!(action.is_preferred, Some(true));
let edits = action
.edit
.as_ref()
.and_then(|e| e.changes.as_ref())
.and_then(|c| c.get(&uri()))
.expect("a single-file edit");
assert_eq!(edits.len(), 1);
assert_eq!(edits[0].new_text, "\\bfseries");
assert_eq!(edits[0].range.start, Position::new(0, 1));
assert_eq!(edits[0].range.end, Position::new(0, 4));
}
#[test]
fn empty_when_range_misses_the_finding() {
let src = "ok\n{\\bf hi}\n";
let cursor = Range {
start: Position::new(0, 0),
end: Position::new(0, 0),
};
let actions = code_actions_for_range(
&findings(src),
src,
&uri(),
std::path::Path::new("x.tex"),
cursor,
PositionEncoding::Utf16,
true,
&no_resolve,
);
assert!(actions.is_empty());
}
#[test]
fn surfaces_dollar_display_math_fix() {
let src = "$$x = y$$\n";
let actions = code_actions_for_range(
&findings(src),
src,
&uri(),
std::path::Path::new("x.tex"),
full_range(src),
PositionEncoding::Utf16,
true,
&no_resolve,
);
assert!(actions.iter().any(|a| matches!(
a,
CodeActionOrCommand::CodeAction(a) if a.title.contains("\\[")
)));
}
#[test]
fn cross_file_fix_spans_two_uris() {
use crate::linter::{Diagnostic, Edit, Fix, Severity};
let other_uri: Uri = "file:///other.tex".parse().unwrap();
let other_text = "\\ref{x}\n";
let resolve = |p: &Path| -> Option<(Uri, String)> {
(p == Path::new("other.tex")).then(|| (other_uri.clone(), other_text.to_string()))
};
let src = "\\label{x}\n";
let d = Diagnostic {
rule: "synthetic",
severity: Severity::Warning,
path: std::path::PathBuf::from("x.tex"),
start: 7,
end: 8,
message: "rename x".into(),
fix: Some(Fix::safe_edits(
vec![
Edit::new(7, 8, "y"),
Edit::in_file(PathBuf::from("other.tex"), 5, 6, "y"),
],
"rename x to y",
)),
related: Vec::new(),
};
let actions = code_actions_for_range(
std::slice::from_ref(&d),
src,
&uri(),
std::path::Path::new("x.tex"),
full_range(src),
PositionEncoding::Utf16,
true,
&resolve,
);
let CodeActionOrCommand::CodeAction(action) = actions
.iter()
.find(|a| matches!(a, CodeActionOrCommand::CodeAction(a) if a.title == "rename x to y"))
.expect("the cross-file quick-fix")
else {
unreachable!()
};
let changes = action
.edit
.as_ref()
.and_then(|e| e.changes.as_ref())
.expect("workspace changes");
assert_eq!(changes.len(), 2, "one entry per touched file");
assert_eq!(changes[&uri()][0].new_text, "y");
assert_eq!(changes[&other_uri][0].new_text, "y");
assert_eq!(changes[&other_uri][0].range.start, Position::new(0, 5));
}
#[test]
fn cross_file_fix_dropped_when_target_unresolved() {
use crate::linter::{Diagnostic, Edit, Fix, Severity};
let src = "\\label{x}\n";
let d = Diagnostic {
rule: "synthetic",
severity: Severity::Warning,
path: std::path::PathBuf::from("x.tex"),
start: 7,
end: 8,
message: "rename x".into(),
fix: Some(Fix::safe_edits(
vec![
Edit::new(7, 8, "y"),
Edit::in_file(PathBuf::from("gone.tex"), 5, 6, "y"),
],
"rename x to y",
)),
related: Vec::new(),
};
let actions = code_actions_for_range(
std::slice::from_ref(&d),
src,
&uri(),
std::path::Path::new("x.tex"),
full_range(src),
PositionEncoding::Utf16,
true,
&no_resolve,
);
assert!(
!actions.iter().any(|a| matches!(
a,
CodeActionOrCommand::CodeAction(a) if a.title == "rename x to y"
)),
"unresolved cross-file fix must not be offered"
);
}
}