Skip to content

Commit 003c31b

Browse files
authored
Send back minimal edits (#73)
1 parent 61d2e71 commit 003c31b

14 files changed

+240
-10
lines changed

Cargo.lock

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ biome_unicode_table = { git = "https://github.com/biomejs/biome", rev = "2648fa4
4444
bytes = "1.8.0"
4545
clap = { version = "4.5.20", features = ["derive"] }
4646
crossbeam = "0.8.4"
47+
dissimilar = "1.0.9"
4748
futures = "0.3.31"
4849
futures-util = "0.3.31"
4950
httparse = "1.9.5"

crates/lsp/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ biome_lsp_converters.workspace = true
2020
biome_parser.workspace = true
2121
biome_text_size.workspace = true
2222
crossbeam.workspace = true
23+
dissimilar.workspace = true
2324
futures.workspace = true
2425
itertools.workspace = true
2526
log.workspace = true

crates/lsp/src/from_proto.rs

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,37 @@
1+
pub(crate) use biome_lsp_converters::from_proto::offset;
12
pub(crate) use biome_lsp_converters::from_proto::text_range;
3+
4+
use tower_lsp::lsp_types;
5+
6+
use crate::documents::Document;
7+
8+
pub fn apply_text_edits(
9+
doc: &Document,
10+
mut edits: Vec<lsp_types::TextEdit>,
11+
) -> anyhow::Result<String> {
12+
let mut text = doc.contents.clone();
13+
14+
// Apply edits from bottom to top to avoid inserted newlines to invalidate
15+
// positions in earlier parts of the doc (they are sent in reading order
16+
// accorder to the LSP protocol)
17+
edits.reverse();
18+
19+
for edit in edits {
20+
let start: usize = offset(
21+
&doc.line_index.index,
22+
edit.range.start,
23+
doc.line_index.encoding,
24+
)?
25+
.into();
26+
let end: usize = offset(
27+
&doc.line_index.index,
28+
edit.range.end,
29+
doc.line_index.encoding,
30+
)?
31+
.into();
32+
33+
text.replace_range(start..end, &edit.new_text);
34+
}
35+
36+
Ok(text)
37+
}

crates/lsp/src/handlers_format.rs

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,54 @@ pub(crate) fn document_formatting(
3737
// files that don't have extensions like `NAMESPACE`, do we hard-code a
3838
// list? What about unnamed temporary files?
3939

40-
let edits = to_proto::replace_all_edit(&doc.line_index, &doc.contents, output)?;
40+
let edits = to_proto::replace_all_edit(&doc.line_index, &doc.contents, &output)?;
4141
Ok(Some(edits))
4242
}
43+
44+
#[cfg(test)]
45+
mod tests {
46+
use crate::{
47+
documents::Document, tower_lsp::init_test_client, tower_lsp_test_client::TestClientExt,
48+
};
49+
50+
#[tests_macros::lsp_test]
51+
async fn test_format() {
52+
let mut client = init_test_client().await;
53+
54+
#[rustfmt::skip]
55+
let doc = Document::doodle(
56+
"
57+
1
58+
2+2
59+
3 + 3 +
60+
3",
61+
);
62+
63+
let formatted = client.format_document(&doc).await;
64+
insta::assert_snapshot!(formatted);
65+
66+
client
67+
}
68+
69+
// https://github.com/posit-dev/air/issues/61
70+
#[tests_macros::lsp_test]
71+
async fn test_format_minimal_diff() {
72+
let mut client = init_test_client().await;
73+
74+
#[rustfmt::skip]
75+
let doc = Document::doodle(
76+
"1
77+
2+2
78+
3
79+
",
80+
);
81+
82+
let edits = client.format_document_edits(&doc).await.unwrap();
83+
assert!(edits.len() == 1);
84+
85+
let edit = edits.get(0).unwrap();
86+
assert_eq!(edit.new_text, " + ");
87+
88+
client
89+
}
90+
}

crates/lsp/src/lib.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ pub mod state;
1717
pub mod to_proto;
1818
pub mod tower_lsp;
1919

20+
#[cfg(test)]
21+
pub mod tower_lsp_test_client;
22+
2023
// These send LSP messages in a non-async and non-blocking way.
2124
// The LOG level is not timestamped so we're not using it.
2225
macro_rules! log_info {

crates/lsp/src/rust_analyzer/diff.rs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// --- source
2+
// authors = ["rust-analyzer team"]
3+
// license = "MIT OR Apache-2.0"
4+
// origin = "https://github.com/rust-lang/rust-analyzer/blob/8d5e91c9/crates/rust-analyzer/src/handlers/request.rs#L2483"
5+
// ---
6+
7+
use biome_text_size::{TextRange, TextSize};
8+
9+
use super::text_edit::TextEdit;
10+
11+
pub(crate) fn diff(left: &str, right: &str) -> TextEdit {
12+
use dissimilar::Chunk;
13+
14+
let chunks = dissimilar::diff(left, right);
15+
16+
let mut builder = TextEdit::builder();
17+
let mut pos = TextSize::default();
18+
19+
let mut chunks = chunks.into_iter().peekable();
20+
while let Some(chunk) = chunks.next() {
21+
if let (Chunk::Delete(deleted), Some(&Chunk::Insert(inserted))) = (chunk, chunks.peek()) {
22+
chunks.next().unwrap();
23+
let deleted_len = TextSize::of(deleted);
24+
builder.replace(TextRange::at(pos, deleted_len), inserted.into());
25+
pos += deleted_len;
26+
continue;
27+
}
28+
29+
match chunk {
30+
Chunk::Equal(text) => {
31+
pos += TextSize::of(text);
32+
}
33+
Chunk::Delete(deleted) => {
34+
let deleted_len = TextSize::of(deleted);
35+
builder.delete(TextRange::at(pos, deleted_len));
36+
pos += deleted_len;
37+
}
38+
Chunk::Insert(inserted) => {
39+
builder.insert(pos, inserted.into());
40+
}
41+
}
42+
}
43+
builder.finish()
44+
}

crates/lsp/src/rust_analyzer/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
pub mod diff;
12
pub mod line_index;
23
pub mod text_edit;
34
pub mod to_proto;

crates/lsp/src/rust_analyzer/text_edit.rs

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -80,13 +80,8 @@ impl TextEdit {
8080
}
8181

8282
// --- Start Posit
83-
pub fn replace_all(text: &str, replace_with: String) -> TextEdit {
84-
let mut builder = TextEdit::builder();
85-
86-
let range = TextRange::new(TextSize::from(0), TextSize::of(text));
87-
88-
builder.replace(range, replace_with);
89-
builder.finish()
83+
pub fn diff(text: &str, replace_with: &str) -> TextEdit {
84+
super::diff::diff(text, replace_with)
9085
}
9186
// --- End Posit
9287

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
source: crates/lsp/src/handlers_format.rs
3+
expression: formatted
4+
---
5+
1
6+
2 + 2
7+
3 + 3 + 3

crates/lsp/src/to_proto.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@ pub(crate) fn doc_edit_vec(
3131
pub(crate) fn replace_all_edit(
3232
line_index: &LineIndex,
3333
text: &str,
34-
replace_with: String,
34+
replace_with: &str,
3535
) -> anyhow::Result<Vec<lsp_types::TextEdit>> {
36-
let edit = TextEdit::replace_all(text, replace_with);
36+
let edit = TextEdit::diff(text, replace_with);
3737
text_edit_vec(line_index, edit)
3838
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
use lsp_test::lsp_client::TestClient;
2+
use tower_lsp::lsp_types;
3+
4+
use crate::{documents::Document, from_proto};
5+
6+
pub(crate) trait TestClientExt {
7+
async fn open_document(&mut self, doc: &Document) -> lsp_types::TextDocumentItem;
8+
async fn format_document(&mut self, doc: &Document) -> String;
9+
async fn format_document_edits(&mut self, doc: &Document) -> Option<Vec<lsp_types::TextEdit>>;
10+
}
11+
12+
impl TestClientExt for TestClient {
13+
async fn open_document(&mut self, doc: &Document) -> lsp_types::TextDocumentItem {
14+
let path = format!("test://{}", uuid::Uuid::new_v4());
15+
let uri = url::Url::parse(&path).unwrap();
16+
17+
let text_document = lsp_types::TextDocumentItem {
18+
uri,
19+
language_id: String::from("r"),
20+
version: 0,
21+
text: doc.contents.clone(),
22+
};
23+
24+
let params = lsp_types::DidOpenTextDocumentParams {
25+
text_document: text_document.clone(),
26+
};
27+
self.did_open_text_document(params).await;
28+
29+
text_document
30+
}
31+
32+
async fn format_document(&mut self, doc: &Document) -> String {
33+
let edits = self.format_document_edits(doc).await.unwrap();
34+
from_proto::apply_text_edits(doc, edits).unwrap()
35+
}
36+
37+
async fn format_document_edits(&mut self, doc: &Document) -> Option<Vec<lsp_types::TextEdit>> {
38+
let lsp_doc = self.open_document(&doc).await;
39+
40+
let options = lsp_types::FormattingOptions {
41+
tab_size: 4,
42+
insert_spaces: false,
43+
..Default::default()
44+
};
45+
46+
self.formatting(lsp_types::DocumentFormattingParams {
47+
text_document: lsp_types::TextDocumentIdentifier {
48+
uri: lsp_doc.uri.clone(),
49+
},
50+
options,
51+
work_done_progress_params: Default::default(),
52+
})
53+
.await;
54+
55+
let response = self.recv_response().await;
56+
57+
let value: Option<Vec<lsp_types::TextEdit>> =
58+
serde_json::from_value(response.result().unwrap().clone()).unwrap();
59+
60+
self.close_document(lsp_doc.uri).await;
61+
62+
value
63+
}
64+
}

crates/lsp_test/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ tokio = { workspace = true, features = ["full"] }
2222
tokio-util.workspace = true
2323
tower-lsp.workspace = true
2424
tracing.workspace = true
25+
url.workspace = true
2526

2627
[lints]
2728
workspace = true

crates/lsp_test/src/lsp_client.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,13 @@ impl TestClient {
9494
self.init_params = Some(init_params);
9595
}
9696

97+
pub async fn close_document(&mut self, uri: url::Url) {
98+
let params = lsp_types::DidCloseTextDocumentParams {
99+
text_document: lsp_types::TextDocumentIdentifier { uri },
100+
};
101+
self.did_close_text_document(params).await;
102+
}
103+
97104
pub async fn shutdown(&mut self) {
98105
// TODO: Check that no messages are incoming
99106

@@ -117,4 +124,18 @@ impl TestClient {
117124
// Unwrap: Panics if task can't shut down as expected
118125
handle.await.unwrap();
119126
}
127+
128+
pub async fn did_open_text_document(&mut self, params: lsp_types::DidOpenTextDocumentParams) {
129+
self.notify::<lsp_types::notification::DidOpenTextDocument>(params)
130+
.await
131+
}
132+
133+
pub async fn did_close_text_document(&mut self, params: lsp_types::DidCloseTextDocumentParams) {
134+
self.notify::<lsp_types::notification::DidCloseTextDocument>(params)
135+
.await
136+
}
137+
138+
pub async fn formatting(&mut self, params: lsp_types::DocumentFormattingParams) -> i64 {
139+
self.request::<lsp_types::request::Formatting>(params).await
140+
}
120141
}

0 commit comments

Comments
 (0)