use similar::{DiffTag, TextDiff};
use super::*;
fn reparse_options(snapshot: &Analysis, path: &Path) -> ParseOptions {
let markdown = snapshot
.lookup_file(path)
.map(|file| snapshot.roxygen_markdown(file))
.unwrap_or_else(|| crate::project::description::roxygen_markdown_default_for_file(path));
ParseOptions::default().with_roxygen_markdown_default(markdown)
}
pub(crate) fn format_edits_via_db(
snapshot: &Analysis,
path: &Path,
buffer: &TextBuffer,
style: FormatStyle,
encoding: PositionEncoding,
) -> Option<Vec<TextEdit>> {
let text = buffer.text();
if DocumentKind::from_path(path) == DocumentKind::Description {
let formatted = format_description_with_style(text, style).ok()?;
return Some(edits_for_formatted_in(buffer, formatted, encoding));
}
let cached = salsa::Cancelled::catch(AssertUnwindSafe(|| {
let file = snapshot.lookup_file(path)?;
if snapshot.file_text(file) != text {
return None;
}
if !snapshot.parse_diagnostics(file).is_empty() {
return Some(None);
}
let root = snapshot.parsed_tree(file);
let formatted = format_node(&root, style, text).ok();
Some(formatted.map(|formatted| edits_for_formatted_in(buffer, formatted, encoding)))
}));
match cached {
Ok(Some(edits)) => edits,
Ok(None) | Err(_) => {
compute_format_edits(text, style, encoding, &reparse_options(snapshot, path))
}
}
}
pub(crate) fn format_range_edits_via_db(
snapshot: &Analysis,
path: &Path,
buffer: &TextBuffer,
range: Range,
style: FormatStyle,
encoding: PositionEncoding,
) -> Option<Vec<TextEdit>> {
let text = buffer.text();
let cached = salsa::Cancelled::catch(AssertUnwindSafe(|| {
let file = snapshot.lookup_file(path)?;
if snapshot.file_text(file) != text {
return None;
}
if !snapshot.parse_diagnostics(file).is_empty() {
return Some(None);
}
let root = snapshot.parsed_tree(file);
let line_index = buffer.line_index();
let text_range = lsp_range_to_text_range(line_index, range, encoding);
let edits = match format_range(&root, text_range, style, text) {
Ok(Some(formatted)) => Some(range_edits(line_index, text, formatted, encoding)),
Ok(None) => Some(Vec::new()),
Err(_) => None,
};
Some(edits)
}));
match cached {
Ok(Some(edits)) => edits,
Ok(None) | Err(_) => compute_format_range_edits(
text,
range,
style,
encoding,
&reparse_options(snapshot, path),
),
}
}
pub fn compute_format_edits(
text: &str,
style: FormatStyle,
encoding: PositionEncoding,
options: &ParseOptions,
) -> Option<Vec<TextEdit>> {
let formatted = format_with_options(text, style, options).ok()?;
Some(edits_for_formatted(text, formatted, encoding))
}
pub fn compute_format_range_edits(
text: &str,
range: Range,
style: FormatStyle,
encoding: PositionEncoding,
options: &ParseOptions,
) -> Option<Vec<TextEdit>> {
let parsed = parse_with_options(text, options);
if !parsed.diagnostics.is_empty() {
return None;
}
let line_index = LineIndex::new(text);
let text_range = lsp_range_to_text_range(&line_index, range, encoding);
match format_range(&parsed.cst, text_range, style, text).ok()? {
Some(formatted) => Some(range_edits(&line_index, text, formatted, encoding)),
None => Some(Vec::new()),
}
}
pub(crate) fn text_range_to_lsp_range(
line_index: &LineIndex,
range: TextRange,
encoding: PositionEncoding,
) -> Range {
Range {
start: line_index.byte_to_position(u32::from(range.start()) as usize, encoding),
end: line_index.byte_to_position(u32::from(range.end()) as usize, encoding),
}
}
pub(crate) fn lsp_range_to_text_range(
line_index: &LineIndex,
range: Range,
encoding: PositionEncoding,
) -> TextRange {
let start = line_index.position_to_byte(range.start, encoding);
let end = line_index.position_to_byte(range.end, encoding);
TextRange::new(
TextSize::new(start as u32),
TextSize::new(start.max(end) as u32),
)
}
pub(crate) fn range_edits(
line_index: &LineIndex,
text: &str,
formatted: crate::formatter::RangeFormatted,
encoding: PositionEncoding,
) -> Vec<TextEdit> {
let start = usize::from(formatted.range.start());
let end = usize::from(formatted.range.end());
let old = text.get(start..end);
if old == Some(formatted.text.as_str()) {
return Vec::new();
}
if let Some(edits) =
old.and_then(|old| line_diff_edits(line_index, start, old, &formatted.text, encoding))
{
return edits;
}
vec![TextEdit {
range: Range {
start: line_index.byte_to_position(start, encoding),
end: line_index.byte_to_position(end, encoding),
},
new_text: formatted.text,
}]
}
pub(crate) fn edits_for_formatted(
text: &str,
formatted: String,
encoding: PositionEncoding,
) -> Vec<TextEdit> {
edits_for_formatted_in(&TextBuffer::from(text), formatted, encoding)
}
pub(crate) fn edits_for_formatted_in(
buffer: &TextBuffer,
formatted: String,
encoding: PositionEncoding,
) -> Vec<TextEdit> {
let text = buffer.text();
if formatted == text {
return Vec::new();
}
let line_index = buffer.line_index();
if let Some(edits) = line_diff_edits(line_index, 0, text, &formatted, encoding) {
return edits;
}
let end = line_index.byte_to_position(text.len(), encoding);
vec![TextEdit {
range: Range {
start: Position::new(0, 0),
end,
},
new_text: formatted,
}]
}
const MAX_DIFF_COVERAGE: f64 = 0.5;
fn line_diff_edits(
line_index: &LineIndex,
base: usize,
old: &str,
new: &str,
encoding: PositionEncoding,
) -> Option<Vec<TextEdit>> {
let diff = TextDiff::from_lines(old, new);
let old_offsets = line_offsets(diff.iter_old_slices());
let new_offsets = line_offsets(diff.iter_new_slices());
let mut hunks: Vec<(std::ops::Range<usize>, std::ops::Range<usize>)> = Vec::new();
for op in diff.ops() {
let (tag, old_lines, new_lines) = op.as_tag_tuple();
if tag == DiffTag::Equal {
continue;
}
match hunks.last_mut() {
Some((old_prev, new_prev))
if old_prev.end == old_lines.start && new_prev.end == new_lines.start =>
{
old_prev.end = old_lines.end;
new_prev.end = new_lines.end;
}
_ => hunks.push((old_lines, new_lines)),
}
}
let covered: usize = hunks
.iter()
.map(|(old_lines, _)| old_offsets[old_lines.end] - old_offsets[old_lines.start])
.sum();
if hunks.len() > 1 && covered as f64 > old.len() as f64 * MAX_DIFF_COVERAGE {
return None;
}
Some(
hunks
.into_iter()
.map(|(old_lines, new_lines)| TextEdit {
range: Range {
start: line_index
.byte_to_position(base + old_offsets[old_lines.start], encoding),
end: line_index.byte_to_position(base + old_offsets[old_lines.end], encoding),
},
new_text: new[new_offsets[new_lines.start]..new_offsets[new_lines.end]].to_string(),
})
.collect(),
)
}
fn line_offsets<'a>(lines: impl Iterator<Item = &'a str>) -> Vec<usize> {
let mut offsets = vec![0];
let mut at = 0;
for line in lines {
at += line.len();
offsets.push(at);
}
offsets
}
pub(crate) fn to_lsp_diagnostic(
d: &Diagnostic,
idx: &LineIndex,
encoding: PositionEncoding,
) -> LspDiagnostic {
let start = idx.byte_to_position(u32::from(d.range.start()) as usize, encoding);
let end = idx.byte_to_position(u32::from(d.range.end()) as usize, encoding);
let severity = match d.severity {
Severity::Error => DiagnosticSeverity::ERROR,
Severity::Warning => DiagnosticSeverity::WARNING,
Severity::Info => DiagnosticSeverity::INFORMATION,
Severity::Hint => DiagnosticSeverity::HINT,
};
LspDiagnostic {
range: Range { start, end },
severity: Some(severity),
code: Some(NumberOrString::String(d.rule.to_string())),
source: Some("arity".to_string()),
message: d.message.body.clone(),
..Default::default()
}
}
pub(crate) fn findings_to_items(
findings: &[Diagnostic],
buffer: &TextBuffer,
encoding: PositionEncoding,
) -> Vec<LspDiagnostic> {
let idx = buffer.line_index();
findings
.iter()
.map(|d| to_lsp_diagnostic(d, idx, encoding))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn format_via_db_routes_a_description_to_dcf() {
use crate::incremental::IncrementalDatabase;
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("DESCRIPTION");
let buffer = "Package: p\nImports: b, a\n";
let db = IncrementalDatabase::default();
let snapshot = db.snapshot();
let edits = format_edits_via_db(
&snapshot,
&path,
&buf(buffer),
FormatStyle::default(),
PositionEncoding::Utf16,
)
.expect("formatter accepts the buffer");
assert_eq!(
apply(buffer, &edits, PositionEncoding::Utf16),
"Package: p\nImports:\n a,\n b\n"
);
}
#[test]
fn format_via_db_declines_a_description_it_cannot_restyle() {
use crate::incremental::IncrementalDatabase;
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("DESCRIPTION");
let db = IncrementalDatabase::default();
let snapshot = db.snapshot();
assert!(
format_edits_via_db(
&snapshot,
&path,
&buf("Package: p\n\nPackage: q\n"),
FormatStyle::default(),
PositionEncoding::Utf16,
)
.is_none()
);
}
#[test]
fn format_via_db_honors_package_markdown_default() {
use crate::incremental::IncrementalDatabase;
let dir = tempfile::tempdir().expect("tempdir");
std::fs::create_dir(dir.path().join("R")).expect("R/");
std::fs::write(
dir.path().join("DESCRIPTION"),
"Package: p\nRoxygen: list(markdown = TRUE)\n",
)
.expect("DESCRIPTION");
let path = dir.path().join("R/doc.R");
let buffer = "#' Title\n#'\n#' @details\n#' Some prose before the code.\n#'\n#' code_looking <- \"indented\"\nNULL\n";
std::fs::write(&path, buffer).expect("doc.R");
let style = FormatStyle::default();
let encoding = PositionEncoding::Utf16;
let mut db = IncrementalDatabase::default();
db.upsert_file(&path, buffer.to_string());
let snapshot = db.snapshot();
let edits = format_edits_via_db(&snapshot, &path, &buf(buffer), style, encoding)
.expect("formatter accepts the buffer");
assert!(
edits.is_empty(),
"markdown-canonical buffer is clean: {edits:?}"
);
let empty = IncrementalDatabase::default();
let snapshot = empty.snapshot();
let edits = format_edits_via_db(&snapshot, &path, &buf(buffer), style, encoding)
.expect("formatter accepts the buffer");
assert!(
edits.is_empty(),
"fallback resolves the flag too: {edits:?}"
);
}
#[test]
fn findings_to_items_maps_range_severity_and_code() {
use crate::linter::ViolationData;
let text = "line0\nWARN\n";
let findings = vec![Diagnostic {
rule: "demo-rule",
severity: Severity::Warning,
path: test_path().to_path_buf(),
range: TextRange::new(TextSize::from(6), TextSize::from(10)),
message: ViolationData::new("demo-rule", "a demo finding"),
fix: None,
}];
let items = findings_to_items(&findings, &buf(text), PositionEncoding::Utf16);
assert_eq!(items.len(), 1);
let item = &items[0];
assert_eq!(item.range.start, Position::new(1, 0));
assert_eq!(item.range.end, Position::new(1, 4));
assert_eq!(item.severity, Some(DiagnosticSeverity::WARNING));
assert_eq!(item.source.as_deref(), Some("arity"));
assert_eq!(item.message, "a demo finding");
assert!(matches!(&item.code, Some(NumberOrString::String(c)) if c == "demo-rule"));
}
fn apply(text: &str, edits: &[TextEdit], encoding: PositionEncoding) -> String {
let line_index = LineIndex::new(text);
let mut spans: Vec<(usize, usize, &str)> = edits
.iter()
.map(|edit| {
(
line_index.position_to_byte(edit.range.start, encoding),
line_index.position_to_byte(edit.range.end, encoding),
edit.new_text.as_str(),
)
})
.collect();
spans.sort_by_key(|&(start, ..)| start);
for pair in spans.windows(2) {
assert!(pair[0].1 <= pair[1].0, "edits must not overlap: {spans:?}");
}
let mut out = text.to_string();
for &(start, end, new_text) in spans.iter().rev() {
out.replace_range(start..end, new_text);
}
out
}
fn format_edits(text: &str, encoding: PositionEncoding) -> Vec<TextEdit> {
compute_format_edits(text, FormatStyle::default(), encoding, &Default::default())
.expect("formatter accepts the fixture")
}
#[test]
fn format_edits_are_scoped_to_the_lines_that_change() {
let text = "a <- 1\nb <- 2\nc<-3\nd <- 4\ne <- 5\n";
let edits = format_edits(text, PositionEncoding::Utf16);
assert_eq!(edits.len(), 1, "one changed line is one edit: {edits:?}");
assert_eq!(
edits[0].range,
Range::new(Position::new(2, 0), Position::new(3, 0)),
"the edit must cover exactly the third line"
);
assert_eq!(edits[0].new_text, "c <- 3\n");
}
#[test]
fn separated_changes_are_separate_edits() {
let text = "a<-1\nb <- 2\nc <- 3\nd <- 4\ne<-5\n";
let edits = format_edits(text, PositionEncoding::Utf16);
assert_eq!(edits.len(), 2, "two changed lines are two edits: {edits:?}");
assert_eq!(
edits[0].range,
Range::new(Position::new(0, 0), Position::new(1, 0))
);
assert_eq!(edits[0].new_text, "a <- 1\n");
assert_eq!(
edits[1].range,
Range::new(Position::new(4, 0), Position::new(5, 0))
);
assert_eq!(edits[1].new_text, "e <- 5\n");
}
#[test]
fn scattered_wholesale_change_falls_back_to_one_edit() {
let text = "a<-1\nb <- 2\nc<-3\nd <- 4\ne<-5\n";
let edits = format_edits(text, PositionEncoding::Utf16);
assert_eq!(
edits.len(),
1,
"a majority-of-the-file change must collapse: {edits:?}"
);
assert_eq!(
edits[0].range,
Range::new(Position::new(0, 0), Position::new(5, 0)),
"the fallback edit must span the whole document"
);
}
#[test]
fn format_edits_reproduce_the_formatted_document() {
let style = FormatStyle::default();
let cases = [
"",
"x <- 1\n",
"x<-1\n",
"x <- 1",
"x<-1",
"a <- 1\nb <- 2\nc<-3\nd <- 4\ne <- 5\n",
"a<-1\nb<-2\nc<-3\nd<-4\n",
"a<-1\nb <- 2\nc <- 3\nd <- 4\ne<-5\n",
"f <- function(x) {\n y <- 1\n z<-2\n y+z\n}\n",
"s <- \"\u{1F600}\"\nt<-\"\u{00E9}\"\nu <- 3\n",
"a <- 1\r\nb<-2\r\nc <- 3\r\n",
"# comment\n\n\n\nx<-1\n",
"f(a,b,c)\ng( 1 )\n",
"#' Title\n#'\n#' @param x A value.\nf<-function(x) x\n",
];
for text in cases {
let formatted =
format_with_options(text, style, &Default::default()).expect("fixture must format");
for encoding in [PositionEncoding::Utf8, PositionEncoding::Utf16] {
let edits = format_edits(text, encoding);
assert_eq!(
apply(text, &edits, encoding),
formatted,
"edits must reproduce the formatted text for {text:?} ({encoding:?})"
);
}
}
}
#[test]
fn description_edits_are_scoped_to_the_field_that_changes() {
use crate::incremental::IncrementalDatabase;
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("DESCRIPTION");
let text = "Package: p\nImports: b, a\n";
let db = IncrementalDatabase::default();
let edits = format_edits_via_db(
&db.snapshot(),
&path,
&buf(text),
FormatStyle::default(),
PositionEncoding::Utf16,
)
.expect("formatter accepts the buffer");
assert_eq!(edits.len(), 1, "one reflowed field is one edit: {edits:?}");
assert_eq!(
edits[0].range,
Range::new(Position::new(1, 0), Position::new(2, 0)),
"the `Package:` line must be left alone"
);
assert_eq!(edits[0].new_text, "Imports:\n a,\n b\n");
}
#[test]
fn range_edits_are_scoped_to_the_lines_that_change() {
let text = "a <- 1\nb <- 2\nc<-3\nd <- 4\n";
let range = Range::new(Position::new(0, 0), Position::new(3, 6));
let edits = compute_format_range_edits(
text,
range,
FormatStyle::default(),
PositionEncoding::Utf16,
&Default::default(),
)
.expect("formats");
assert_eq!(edits.len(), 1, "one changed line is one edit: {edits:?}");
assert_eq!(
edits[0].range,
Range::new(Position::new(2, 0), Position::new(3, 0)),
"the edit must cover only the changed line, not the widened span"
);
assert_eq!(edits[0].new_text, "c <- 3\n");
}
#[test]
fn range_edits_reproduce_the_formatted_span() {
let style = FormatStyle::default();
let text = "a<-1\nb <- 2\nc<-3\nd <- 4\ne<-5\n";
for encoding in [PositionEncoding::Utf8, PositionEncoding::Utf16] {
let range = Range::new(Position::new(1, 0), Position::new(3, 6));
let edits =
compute_format_range_edits(text, range, style, encoding, &Default::default())
.expect("formats");
assert_eq!(
apply(text, &edits, encoding),
"a<-1\nb <- 2\nc <- 3\nd <- 4\ne<-5\n",
"only the widened span may change ({encoding:?})"
);
}
}
#[test]
fn format_via_db_matches_compute_and_falls_back() {
use crate::incremental::IncrementalDatabase;
let style = FormatStyle::default();
let path = test_path();
let buffer = "x<-f(1 )\n";
let encoding = PositionEncoding::Utf16;
let expected = compute_format_edits(buffer, style, encoding, &Default::default());
assert!(
matches!(&expected, Some(edits) if !edits.is_empty()),
"fixture must require reformatting"
);
let mut db = IncrementalDatabase::default();
db.upsert_file(path, buffer.to_string());
let snapshot = db.snapshot();
assert_eq!(
format_edits_via_db(&snapshot, path, &buf(buffer), style, encoding),
expected,
"cached-tree format must match the re-parse path"
);
let mut stale = IncrementalDatabase::default();
stale.upsert_file(path, "y <- 1\n".to_string());
assert_eq!(
format_edits_via_db(&stale.snapshot(), path, &buf(buffer), style, encoding),
expected,
"version skew must fall back to the buffer text"
);
let empty = IncrementalDatabase::default();
assert_eq!(
format_edits_via_db(&empty.snapshot(), path, &buf(buffer), style, encoding),
expected,
"untracked path must fall back to the buffer text"
);
}
}