use std::collections::HashMap;
use super::*;
use crate::linter::diagnostic::Applicability;
use lsp_types::{CodeAction, CodeActionKind, CodeActionOrCommand, CodeActionResponse};
pub(crate) fn code_actions_for_range(
findings: &[crate::linter::Diagnostic],
text: &str,
uri: &Uri,
request_range: Range,
) -> CodeActionResponse {
let idx = LineIndex::new(text);
let req_start = idx.offset_at(
text,
request_range.start.line,
request_range.start.character,
);
let req_end = idx.offset_at(text, 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 edit = TextEdit {
range: byte_range_to_lsp(&idx, text, fix.start, fix.end),
new_text: fix.content.clone(),
};
let changes = HashMap::from([(uri.clone(), vec![edit])]);
Some(CodeActionOrCommand::CodeAction(CodeAction {
title: fix.description.clone(),
kind: Some(CodeActionKind::QUICKFIX),
diagnostics: Some(vec![lint_to_lsp(&idx, text, d.clone())]),
edit: Some(WorkspaceEdit {
changes: Some(changes),
..Default::default()
}),
is_preferred: Some(fix.applicability == Applicability::Safe),
..Default::default()
}))
})
.collect()
}
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.utf16_position(text, 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())
}
#[test]
fn offers_quickfix_for_deprecated_command_in_range() {
let src = "{\\bf hi}\n";
let actions = code_actions_for_range(&findings(src), src, &uri(), full_range(src));
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(), cursor);
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(), full_range(src));
assert!(actions.iter().any(|a| matches!(
a,
CodeActionOrCommand::CodeAction(a) if a.title.contains("\\[")
)));
}
}