From 5b5ebec440841ee98a0aa70b71a135d94f5ca077 Mon Sep 17 00:00:00 2001
From: Aleksey Kladov <aleksey.kladov@gmail.com>
Date: Thu, 21 May 2020 19:50:23 +0200
Subject: [PATCH] Formalize JoinLines protocol extension

---
 crates/ra_ide/src/lib.rs                      |  9 +--
 crates/ra_text_edit/src/lib.rs                | 33 +++++++---
 crates/rust-analyzer/src/caps.rs              |  9 ++-
 crates/rust-analyzer/src/lsp_ext.rs           |  6 +-
 .../rust-analyzer/src/main_loop/handlers.rs   | 30 ++++++---
 crates/rust-analyzer/src/to_proto.rs          |  7 +-
 docs/dev/lsp-extensions.md                    | 66 +++++++++++++++++--
 editors/code/src/commands/join_lines.ts       | 12 ++--
 editors/code/src/rust-analyzer-api.ts         |  4 +-
 9 files changed, 129 insertions(+), 47 deletions(-)

diff --git a/crates/ra_ide/src/lib.rs b/crates/ra_ide/src/lib.rs
index d0aeb3ba72d..97ff67ee891 100644
--- a/crates/ra_ide/src/lib.rs
+++ b/crates/ra_ide/src/lib.rs
@@ -89,6 +89,7 @@ pub use ra_ide_db::{
     symbol_index::Query,
     RootDatabase,
 };
+pub use ra_text_edit::{Indel, TextEdit};
 
 pub type Cancelable<T> = Result<T, Canceled>;
 
@@ -285,14 +286,10 @@ impl Analysis {
 
     /// Returns an edit to remove all newlines in the range, cleaning up minor
     /// stuff like trailing commas.
-    pub fn join_lines(&self, frange: FileRange) -> Cancelable<SourceChange> {
+    pub fn join_lines(&self, frange: FileRange) -> Cancelable<TextEdit> {
         self.with_db(|db| {
             let parse = db.parse(frange.file_id);
-            let file_edit = SourceFileEdit {
-                file_id: frange.file_id,
-                edit: join_lines::join_lines(&parse.tree(), frange.range),
-            };
-            SourceChange::source_file_edit("Join lines", file_edit)
+            join_lines::join_lines(&parse.tree(), frange.range)
         })
     }
 
diff --git a/crates/ra_text_edit/src/lib.rs b/crates/ra_text_edit/src/lib.rs
index 199fd109687..25554f583ec 100644
--- a/crates/ra_text_edit/src/lib.rs
+++ b/crates/ra_text_edit/src/lib.rs
@@ -17,7 +17,7 @@ pub struct Indel {
     pub delete: TextRange,
 }
 
-#[derive(Debug, Clone)]
+#[derive(Default, Debug, Clone)]
 pub struct TextEdit {
     indels: Vec<Indel>,
 }
@@ -64,14 +64,6 @@ impl TextEdit {
         builder.finish()
     }
 
-    pub(crate) fn from_indels(mut indels: Vec<Indel>) -> TextEdit {
-        indels.sort_by_key(|a| (a.delete.start(), a.delete.end()));
-        for (a1, a2) in indels.iter().zip(indels.iter().skip(1)) {
-            assert!(a1.delete.end() <= a2.delete.start())
-        }
-        TextEdit { indels }
-    }
-
     pub fn len(&self) -> usize {
         self.indels.len()
     }
@@ -122,6 +114,17 @@ impl TextEdit {
         *text = buf
     }
 
+    pub fn union(&mut self, other: TextEdit) -> Result<(), TextEdit> {
+        // FIXME: can be done without allocating intermediate vector
+        let mut all = self.iter().chain(other.iter()).collect::<Vec<_>>();
+        if !check_disjoint(&mut all) {
+            return Err(other);
+        }
+        self.indels.extend(other.indels);
+        assert!(check_disjoint(&mut self.indels));
+        Ok(())
+    }
+
     pub fn apply_to_offset(&self, offset: TextSize) -> Option<TextSize> {
         let mut res = offset;
         for indel in self.indels.iter() {
@@ -149,9 +152,19 @@ impl TextEditBuilder {
         self.indels.push(Indel::insert(offset, text))
     }
     pub fn finish(self) -> TextEdit {
-        TextEdit::from_indels(self.indels)
+        let mut indels = self.indels;
+        assert!(check_disjoint(&mut indels));
+        TextEdit { indels }
     }
     pub fn invalidates_offset(&self, offset: TextSize) -> bool {
         self.indels.iter().any(|indel| indel.delete.contains_inclusive(offset))
     }
 }
+
+fn check_disjoint(indels: &mut [impl std::borrow::Borrow<Indel>]) -> bool {
+    indels.sort_by_key(|indel| (indel.borrow().delete.start(), indel.borrow().delete.end()));
+    indels
+        .iter()
+        .zip(indels.iter().skip(1))
+        .all(|(l, r)| l.borrow().delete.end() <= r.borrow().delete.start())
+}
diff --git a/crates/rust-analyzer/src/caps.rs b/crates/rust-analyzer/src/caps.rs
index 110c9a44297..4c417c27045 100644
--- a/crates/rust-analyzer/src/caps.rs
+++ b/crates/rust-analyzer/src/caps.rs
@@ -1,8 +1,6 @@
 //! Advertizes the capabilities of the LSP Server.
 use std::env;
 
-use crate::semantic_tokens;
-
 use lsp_types::{
     CallHierarchyServerCapability, CodeActionOptions, CodeActionProviderCapability,
     CodeLensOptions, CompletionOptions, DocumentOnTypeFormattingOptions,
@@ -12,6 +10,9 @@ use lsp_types::{
     ServerCapabilities, SignatureHelpOptions, TextDocumentSyncCapability, TextDocumentSyncKind,
     TextDocumentSyncOptions, TypeDefinitionProviderCapability, WorkDoneProgressOptions,
 };
+use serde_json::json;
+
+use crate::semantic_tokens;
 
 pub fn server_capabilities() -> ServerCapabilities {
     ServerCapabilities {
@@ -91,6 +92,8 @@ pub fn server_capabilities() -> ServerCapabilities {
             }
             .into(),
         ),
-        experimental: Default::default(),
+        experimental: Some(json!({
+            "joinLines": true,
+        })),
     }
 }
diff --git a/crates/rust-analyzer/src/lsp_ext.rs b/crates/rust-analyzer/src/lsp_ext.rs
index 3c7bd609d24..1bb1b02ab49 100644
--- a/crates/rust-analyzer/src/lsp_ext.rs
+++ b/crates/rust-analyzer/src/lsp_ext.rs
@@ -87,15 +87,15 @@ pub enum JoinLines {}
 
 impl Request for JoinLines {
     type Params = JoinLinesParams;
-    type Result = SourceChange;
-    const METHOD: &'static str = "rust-analyzer/joinLines";
+    type Result = Vec<lsp_types::TextEdit>;
+    const METHOD: &'static str = "experimental/joinLines";
 }
 
 #[derive(Deserialize, Serialize, Debug)]
 #[serde(rename_all = "camelCase")]
 pub struct JoinLinesParams {
     pub text_document: TextDocumentIdentifier,
-    pub range: Range,
+    pub ranges: Vec<Range>,
 }
 
 pub enum OnEnter {}
diff --git a/crates/rust-analyzer/src/main_loop/handlers.rs b/crates/rust-analyzer/src/main_loop/handlers.rs
index fcf08cd7989..12196471880 100644
--- a/crates/rust-analyzer/src/main_loop/handlers.rs
+++ b/crates/rust-analyzer/src/main_loop/handlers.rs
@@ -15,10 +15,11 @@ use lsp_types::{
     DocumentSymbol, FoldingRange, FoldingRangeParams, Hover, HoverContents, Location,
     MarkupContent, MarkupKind, Position, PrepareRenameResponse, Range, RenameParams,
     SemanticTokensParams, SemanticTokensRangeParams, SemanticTokensRangeResult,
-    SemanticTokensResult, SymbolInformation, TextDocumentIdentifier, TextEdit, Url, WorkspaceEdit,
+    SemanticTokensResult, SymbolInformation, TextDocumentIdentifier, Url, WorkspaceEdit,
 };
 use ra_ide::{
     Assist, FileId, FilePosition, FileRange, Query, RangeInfo, Runnable, RunnableKind, SearchScope,
+    TextEdit,
 };
 use ra_prof::profile;
 use ra_project_model::TargetKind;
@@ -149,11 +150,24 @@ pub fn handle_find_matching_brace(
 pub fn handle_join_lines(
     world: WorldSnapshot,
     params: lsp_ext::JoinLinesParams,
-) -> Result<lsp_ext::SourceChange> {
+) -> Result<Vec<lsp_types::TextEdit>> {
     let _p = profile("handle_join_lines");
-    let frange = from_proto::file_range(&world, params.text_document, params.range)?;
-    let source_change = world.analysis().join_lines(frange)?;
-    to_proto::source_change(&world, source_change)
+    let file_id = from_proto::file_id(&world, &params.text_document.uri)?;
+    let line_index = world.analysis().file_line_index(file_id)?;
+    let line_endings = world.file_line_endings(file_id);
+    let mut res = TextEdit::default();
+    for range in params.ranges {
+        let range = from_proto::text_range(&line_index, range);
+        let edit = world.analysis().join_lines(FileRange { file_id, range })?;
+        match res.union(edit) {
+            Ok(()) => (),
+            Err(_edit) => {
+                // just ignore overlapping edits
+            }
+        }
+    }
+    let res = to_proto::text_edit_vec(&line_index, line_endings, res);
+    Ok(res)
 }
 
 pub fn handle_on_enter(
@@ -172,7 +186,7 @@ pub fn handle_on_enter(
 pub fn handle_on_type_formatting(
     world: WorldSnapshot,
     params: lsp_types::DocumentOnTypeFormattingParams,
-) -> Result<Option<Vec<TextEdit>>> {
+) -> Result<Option<Vec<lsp_types::TextEdit>>> {
     let _p = profile("handle_on_type_formatting");
     let mut position = from_proto::file_position(&world, params.text_document_position)?;
     let line_index = world.analysis().file_line_index(position.file_id)?;
@@ -618,7 +632,7 @@ pub fn handle_references(
 pub fn handle_formatting(
     world: WorldSnapshot,
     params: DocumentFormattingParams,
-) -> Result<Option<Vec<TextEdit>>> {
+) -> Result<Option<Vec<lsp_types::TextEdit>>> {
     let _p = profile("handle_formatting");
     let file_id = from_proto::file_id(&world, &params.text_document.uri)?;
     let file = world.analysis().file_text(file_id)?;
@@ -685,7 +699,7 @@ pub fn handle_formatting(
         }
     }
 
-    Ok(Some(vec![TextEdit {
+    Ok(Some(vec![lsp_types::TextEdit {
         range: Range::new(Position::new(0, 0), end_position),
         new_text: captured_stdout,
     }]))
diff --git a/crates/rust-analyzer/src/to_proto.rs b/crates/rust-analyzer/src/to_proto.rs
index 6171979634b..f6f4bb13402 100644
--- a/crates/rust-analyzer/src/to_proto.rs
+++ b/crates/rust-analyzer/src/to_proto.rs
@@ -2,12 +2,11 @@
 use ra_db::{FileId, FileRange};
 use ra_ide::{
     Assist, CompletionItem, CompletionItemKind, Documentation, FileSystemEdit, Fold, FoldKind,
-    FunctionSignature, Highlight, HighlightModifier, HighlightTag, HighlightedRange, InlayHint,
-    InlayKind, InsertTextFormat, LineIndex, NavigationTarget, ReferenceAccess, Severity,
-    SourceChange, SourceFileEdit,
+    FunctionSignature, Highlight, HighlightModifier, HighlightTag, HighlightedRange, Indel,
+    InlayHint, InlayKind, InsertTextFormat, LineIndex, NavigationTarget, ReferenceAccess, Severity,
+    SourceChange, SourceFileEdit, TextEdit,
 };
 use ra_syntax::{SyntaxKind, TextRange, TextSize};
-use ra_text_edit::{Indel, TextEdit};
 use ra_vfs::LineEndings;
 
 use crate::{lsp_ext, semantic_tokens, world::WorldSnapshot, Result};
diff --git a/docs/dev/lsp-extensions.md b/docs/dev/lsp-extensions.md
index d2ec6c0215b..0e3a0af1cbb 100644
--- a/docs/dev/lsp-extensions.md
+++ b/docs/dev/lsp-extensions.md
@@ -7,13 +7,7 @@ All capabilities are enabled via `experimental` field of `ClientCapabilities`.
 
 ## `SnippetTextEdit`
 
-**Capability**
-
-```typescript
-{
-    "snippetTextEdit": boolean
-}
-```
+**Client Capability:** `{ "snippetTextEdit": boolean }`
 
 If this capability is set, `WorkspaceEdit`s returned from `codeAction` requests might contain `SnippetTextEdit`s instead of usual `TextEdit`s:
 
@@ -32,3 +26,61 @@ export interface TextDocumentEdit {
 
 When applying such code action, the editor should insert snippet, with tab stops and placeholder.
 At the moment, rust-analyzer guarantees that only a single edit will have `InsertTextFormat.Snippet`.
+
+### Example
+
+"Add `derive`" code action transforms `struct S;` into `#[derive($0)] struct S;`
+
+### Unresolved Questions
+
+* Where exactly are `SnippetTextEdit`s allowed (only in code actions at the moment)?
+* Can snippets span multiple files (so far, no)?
+
+## `joinLines`
+
+**Server Capability:** `{ "joinLines": boolean }`
+
+This request is send from client to server to handle "Join Lines" editor action.
+
+**Method:** `experimental/JoinLines`
+
+**Request:**
+
+```typescript
+interface JoinLinesParams {
+    textDocument: TextDocumentIdentifier,
+    /// Currently active selections/cursor offsets.
+    /// This is an array to support multiple cursors.
+    ranges: Range[],
+}
+```
+
+**Response:**
+
+```typescript
+TextEdit[]
+```
+
+### Example
+
+```rust
+fn main() {
+    /*cursor here*/let x = {
+        92
+    };
+}
+```
+
+`experimental/joinLines` yields (curly braces are automagiacally removed)
+
+```rust
+fn main() {
+    let x = 92;
+}
+```
+
+### Unresolved Question
+
+* What is the position of the cursor after `joinLines`?
+  Currently this is left to editor's discretion, but it might be useful to specify on the server via snippets.
+  However, it then becomes unclear how it works with multi cursor.
diff --git a/editors/code/src/commands/join_lines.ts b/editors/code/src/commands/join_lines.ts
index de0614653d6..0bf1ee6e671 100644
--- a/editors/code/src/commands/join_lines.ts
+++ b/editors/code/src/commands/join_lines.ts
@@ -1,7 +1,7 @@
 import * as ra from '../rust-analyzer-api';
+import * as lc from 'vscode-languageclient';
 
 import { Ctx, Cmd } from '../ctx';
-import { applySourceChange } from '../source_change';
 
 export function joinLines(ctx: Ctx): Cmd {
     return async () => {
@@ -9,10 +9,14 @@ export function joinLines(ctx: Ctx): Cmd {
         const client = ctx.client;
         if (!editor || !client) return;
 
-        const change = await client.sendRequest(ra.joinLines, {
-            range: client.code2ProtocolConverter.asRange(editor.selection),
+        const items: lc.TextEdit[] = await client.sendRequest(ra.joinLines, {
+            ranges: editor.selections.map((it) => client.code2ProtocolConverter.asRange(it)),
             textDocument: { uri: editor.document.uri.toString() },
         });
-        await applySourceChange(ctx, change);
+        editor.edit((builder) => {
+            client.protocol2CodeConverter.asTextEdits(items).forEach((edit) => {
+                builder.replace(edit.range, edit.newText);
+            });
+        });
     };
 }
diff --git a/editors/code/src/rust-analyzer-api.ts b/editors/code/src/rust-analyzer-api.ts
index 3b83b10e38a..8ed56c173ee 100644
--- a/editors/code/src/rust-analyzer-api.ts
+++ b/editors/code/src/rust-analyzer-api.ts
@@ -64,9 +64,9 @@ export const parentModule = request<lc.TextDocumentPositionParams, Vec<lc.Locati
 
 export interface JoinLinesParams {
     textDocument: lc.TextDocumentIdentifier;
-    range: lc.Range;
+    ranges: lc.Range[];
 }
-export const joinLines = request<JoinLinesParams, SourceChange>("joinLines");
+export const joinLines = new lc.RequestType<JoinLinesParams, lc.TextEdit[], unknown>('experimental/joinLines');
 
 
 export const onEnter = request<lc.TextDocumentPositionParams, Option<lc.WorkspaceEdit>>("onEnter");