Skip to content

Commit

Permalink
Send back minimal edits (#73)
Browse files Browse the repository at this point in the history
  • Loading branch information
lionel- authored Nov 27, 2024
1 parent 61d2e71 commit 003c31b
Show file tree
Hide file tree
Showing 14 changed files with 240 additions and 10 deletions.
8 changes: 8 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ biome_unicode_table = { git = "https://github.com/biomejs/biome", rev = "2648fa4
bytes = "1.8.0"
clap = { version = "4.5.20", features = ["derive"] }
crossbeam = "0.8.4"
dissimilar = "1.0.9"
futures = "0.3.31"
futures-util = "0.3.31"
httparse = "1.9.5"
Expand Down
1 change: 1 addition & 0 deletions crates/lsp/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ biome_lsp_converters.workspace = true
biome_parser.workspace = true
biome_text_size.workspace = true
crossbeam.workspace = true
dissimilar.workspace = true
futures.workspace = true
itertools.workspace = true
log.workspace = true
Expand Down
36 changes: 36 additions & 0 deletions crates/lsp/src/from_proto.rs
Original file line number Diff line number Diff line change
@@ -1 +1,37 @@
pub(crate) use biome_lsp_converters::from_proto::offset;
pub(crate) use biome_lsp_converters::from_proto::text_range;

use tower_lsp::lsp_types;

use crate::documents::Document;

pub fn apply_text_edits(
doc: &Document,
mut edits: Vec<lsp_types::TextEdit>,
) -> anyhow::Result<String> {
let mut text = doc.contents.clone();

// Apply edits from bottom to top to avoid inserted newlines to invalidate
// positions in earlier parts of the doc (they are sent in reading order
// accorder to the LSP protocol)
edits.reverse();

for edit in edits {
let start: usize = offset(
&doc.line_index.index,
edit.range.start,
doc.line_index.encoding,
)?
.into();
let end: usize = offset(
&doc.line_index.index,
edit.range.end,
doc.line_index.encoding,
)?
.into();

text.replace_range(start..end, &edit.new_text);
}

Ok(text)
}
50 changes: 49 additions & 1 deletion crates/lsp/src/handlers_format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,54 @@ pub(crate) fn document_formatting(
// files that don't have extensions like `NAMESPACE`, do we hard-code a
// list? What about unnamed temporary files?

let edits = to_proto::replace_all_edit(&doc.line_index, &doc.contents, output)?;
let edits = to_proto::replace_all_edit(&doc.line_index, &doc.contents, &output)?;
Ok(Some(edits))
}

#[cfg(test)]
mod tests {
use crate::{
documents::Document, tower_lsp::init_test_client, tower_lsp_test_client::TestClientExt,
};

#[tests_macros::lsp_test]
async fn test_format() {
let mut client = init_test_client().await;

#[rustfmt::skip]
let doc = Document::doodle(
"
1
2+2
3 + 3 +
3",
);

let formatted = client.format_document(&doc).await;
insta::assert_snapshot!(formatted);

client
}

// https://github.com/posit-dev/air/issues/61
#[tests_macros::lsp_test]
async fn test_format_minimal_diff() {
let mut client = init_test_client().await;

#[rustfmt::skip]
let doc = Document::doodle(
"1
2+2
3
",
);

let edits = client.format_document_edits(&doc).await.unwrap();
assert!(edits.len() == 1);

let edit = edits.get(0).unwrap();
assert_eq!(edit.new_text, " + ");

client
}
}
3 changes: 3 additions & 0 deletions crates/lsp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ pub mod state;
pub mod to_proto;
pub mod tower_lsp;

#[cfg(test)]
pub mod tower_lsp_test_client;

// These send LSP messages in a non-async and non-blocking way.
// The LOG level is not timestamped so we're not using it.
macro_rules! log_info {
Expand Down
44 changes: 44 additions & 0 deletions crates/lsp/src/rust_analyzer/diff.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// --- source
// authors = ["rust-analyzer team"]
// license = "MIT OR Apache-2.0"
// origin = "https://github.com/rust-lang/rust-analyzer/blob/8d5e91c9/crates/rust-analyzer/src/handlers/request.rs#L2483"
// ---

use biome_text_size::{TextRange, TextSize};

use super::text_edit::TextEdit;

pub(crate) fn diff(left: &str, right: &str) -> TextEdit {
use dissimilar::Chunk;

let chunks = dissimilar::diff(left, right);

let mut builder = TextEdit::builder();
let mut pos = TextSize::default();

let mut chunks = chunks.into_iter().peekable();
while let Some(chunk) = chunks.next() {
if let (Chunk::Delete(deleted), Some(&Chunk::Insert(inserted))) = (chunk, chunks.peek()) {
chunks.next().unwrap();
let deleted_len = TextSize::of(deleted);
builder.replace(TextRange::at(pos, deleted_len), inserted.into());
pos += deleted_len;
continue;
}

match chunk {
Chunk::Equal(text) => {
pos += TextSize::of(text);
}
Chunk::Delete(deleted) => {
let deleted_len = TextSize::of(deleted);
builder.delete(TextRange::at(pos, deleted_len));
pos += deleted_len;
}
Chunk::Insert(inserted) => {
builder.insert(pos, inserted.into());
}
}
}
builder.finish()
}
1 change: 1 addition & 0 deletions crates/lsp/src/rust_analyzer/mod.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
pub mod diff;
pub mod line_index;
pub mod text_edit;
pub mod to_proto;
Expand Down
9 changes: 2 additions & 7 deletions crates/lsp/src/rust_analyzer/text_edit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,13 +80,8 @@ impl TextEdit {
}

// --- Start Posit
pub fn replace_all(text: &str, replace_with: String) -> TextEdit {
let mut builder = TextEdit::builder();

let range = TextRange::new(TextSize::from(0), TextSize::of(text));

builder.replace(range, replace_with);
builder.finish()
pub fn diff(text: &str, replace_with: &str) -> TextEdit {
super::diff::diff(text, replace_with)
}
// --- End Posit

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
source: crates/lsp/src/handlers_format.rs
expression: formatted
---
1
2 + 2
3 + 3 + 3
4 changes: 2 additions & 2 deletions crates/lsp/src/to_proto.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ pub(crate) fn doc_edit_vec(
pub(crate) fn replace_all_edit(
line_index: &LineIndex,
text: &str,
replace_with: String,
replace_with: &str,
) -> anyhow::Result<Vec<lsp_types::TextEdit>> {
let edit = TextEdit::replace_all(text, replace_with);
let edit = TextEdit::diff(text, replace_with);
text_edit_vec(line_index, edit)
}
64 changes: 64 additions & 0 deletions crates/lsp/src/tower_lsp_test_client.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
use lsp_test::lsp_client::TestClient;
use tower_lsp::lsp_types;

use crate::{documents::Document, from_proto};

pub(crate) trait TestClientExt {
async fn open_document(&mut self, doc: &Document) -> lsp_types::TextDocumentItem;
async fn format_document(&mut self, doc: &Document) -> String;
async fn format_document_edits(&mut self, doc: &Document) -> Option<Vec<lsp_types::TextEdit>>;
}

impl TestClientExt for TestClient {
async fn open_document(&mut self, doc: &Document) -> lsp_types::TextDocumentItem {
let path = format!("test://{}", uuid::Uuid::new_v4());
let uri = url::Url::parse(&path).unwrap();

let text_document = lsp_types::TextDocumentItem {
uri,
language_id: String::from("r"),
version: 0,
text: doc.contents.clone(),
};

let params = lsp_types::DidOpenTextDocumentParams {
text_document: text_document.clone(),
};
self.did_open_text_document(params).await;

text_document
}

async fn format_document(&mut self, doc: &Document) -> String {
let edits = self.format_document_edits(doc).await.unwrap();
from_proto::apply_text_edits(doc, edits).unwrap()
}

async fn format_document_edits(&mut self, doc: &Document) -> Option<Vec<lsp_types::TextEdit>> {
let lsp_doc = self.open_document(&doc).await;

let options = lsp_types::FormattingOptions {
tab_size: 4,
insert_spaces: false,
..Default::default()
};

self.formatting(lsp_types::DocumentFormattingParams {
text_document: lsp_types::TextDocumentIdentifier {
uri: lsp_doc.uri.clone(),
},
options,
work_done_progress_params: Default::default(),
})
.await;

let response = self.recv_response().await;

let value: Option<Vec<lsp_types::TextEdit>> =
serde_json::from_value(response.result().unwrap().clone()).unwrap();

self.close_document(lsp_doc.uri).await;

value
}
}
1 change: 1 addition & 0 deletions crates/lsp_test/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ tokio = { workspace = true, features = ["full"] }
tokio-util.workspace = true
tower-lsp.workspace = true
tracing.workspace = true
url.workspace = true

[lints]
workspace = true
21 changes: 21 additions & 0 deletions crates/lsp_test/src/lsp_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,13 @@ impl TestClient {
self.init_params = Some(init_params);
}

pub async fn close_document(&mut self, uri: url::Url) {
let params = lsp_types::DidCloseTextDocumentParams {
text_document: lsp_types::TextDocumentIdentifier { uri },
};
self.did_close_text_document(params).await;
}

pub async fn shutdown(&mut self) {
// TODO: Check that no messages are incoming

Expand All @@ -117,4 +124,18 @@ impl TestClient {
// Unwrap: Panics if task can't shut down as expected
handle.await.unwrap();
}

pub async fn did_open_text_document(&mut self, params: lsp_types::DidOpenTextDocumentParams) {
self.notify::<lsp_types::notification::DidOpenTextDocument>(params)
.await
}

pub async fn did_close_text_document(&mut self, params: lsp_types::DidCloseTextDocumentParams) {
self.notify::<lsp_types::notification::DidCloseTextDocument>(params)
.await
}

pub async fn formatting(&mut self, params: lsp_types::DocumentFormattingParams) -> i64 {
self.request::<lsp_types::request::Formatting>(params).await
}
}

0 comments on commit 003c31b

Please sign in to comment.