Repository: sublimelsp/LSP

Issue:
LspApplyDocumentEditCommand is slow
I'm testing my formatter with a 5k LoC file every now and then. My server computes the required edits quickly. LSP then freezes Sublime for 5 s while it applies the ~800 edits. For 1360 changes it takes 30 s. I traced this to the `LspApplyDocumentEditCommand`, which looks like it's at least in O(n²).

The algorithm uses the Sublime API to repeatedly query regions for each change, so Sublime needs to build that list again and again.

Alternatively, we could apply the changes in reverse order, so that the regions we're about to edit are still valid. We're sorting the changes anyways, all it takes is a `reverse=True`. The `TextEdit[]` specification says "_Text edits ranges must never overlap, that means no part of the original document must be manipulated by more than one edit._"

Preparing a PR from https://github.com/haferburg/LSP/tree/faster-edit-command. After my change, it always takes less than 1 s.

Am I missing something?

The LSP spec also says "_If multiple inserts have the same position, the order in the array defines the order in which the inserted strings appear in the resulting text._" I'm not sure if that already works, but I think it does. Needs more thorough testing.


Discussion:

Resolving patch:
diff --git a/plugin/core/edit.py b/plugin/core/edit.py
--- a/plugin/core/edit.py
+++ b/plugin/core/edit.py
@@ -1,10 +1,11 @@
 import os
 import sublime
 import sublime_plugin
+from timeit import default_timer as timer
 
 try:
-    from typing import List, Dict, Optional
-    assert List and Dict and Optional
+    from typing import List, Dict, Optional, Any
+    assert List and Dict and Optional and Any
 except ImportError:
     pass
 
@@ -55,41 +56,29 @@ def open_and_apply_edits(self, path, file_changes):
 
 class LspApplyDocumentEditCommand(sublime_plugin.TextCommand):
     def run(self, edit, changes: 'Optional[List[dict]]' = None, show_status=True):
+        # Apply the changes in reverse, so that we don't invalidate the range
+        # of any change that we haven't applied yet.
+        start = timer()
+        changes2 = changes or []  # New variable of type List[dict]
+        indices = self.changes_order(changes2)
+        for index in indices:
+            change = changes2[index]
+            self.apply_change(self.create_region(change), change.get('newText'), edit)
+        elapsed = timer() - start
 
-        # Sort changes due to issues with self.view.get_regions
-        # See https://github.com/tomv564/LSP/issues/325
-        changes = self.changes_sorted(changes) if changes else []
-        regions = list(self.create_region(change) for change in changes)
-        replacements = list(change.get('newText') for change in changes)
-
-        # TODO why source.python here?
-        self.view.add_regions('lsp_edit', regions, "source.python")
-
-        index = 0
-        last_region_count = len(regions)
-        for newText in replacements:
-            # refresh updated regions after each edit.
-            updated_regions = self.view.get_regions('lsp_edit')
-            region = updated_regions[index]  #
-            self.apply_change(region, newText, edit)
-            if len(self.view.get_regions('lsp_edit')) == last_region_count and last_region_count > 1:
-                index += 1  # no regions lost, move to next region.
-            else:
-                # current region was removed, don't advance index.
-                last_region_count = len(self.view.get_regions('lsp_edit'))
-
-        self.view.erase_regions('lsp_edit')
         if show_status:
             window = self.view.window()
             if window:
                 base_dir = get_project_path(window)
                 file_path = self.view.file_name()
                 relative_file_path = os.path.relpath(file_path, base_dir) if base_dir else file_path
-                message = 'Applied {} change(s) to {}'.format(len(changes), relative_file_path)
+                message = 'Applied {} change(s) to {} in {:.1f} ms'.format(
+                    len(indices), relative_file_path, elapsed * 1000)
                 window.status_message(message)
+                debug(message)
 
-    def changes_sorted(self, changes: 'List[dict]') -> 'List[Dict]':
-        # changes looks like this:
+    def changes_order(self, changes: 'List[dict]') -> 'List[int]':
+        # Changes look like this:
         # [
         #   {
         #       'newText': str,
@@ -100,16 +89,21 @@ def changes_sorted(self, changes: 'List[dict]') -> 'List[Dict]':
         #   }
         # ]
 
-        # Maps a change to the tuple (range.start.line, range.start.character)
-        def get_start_position(change):
-            r = change.get('range')
-            start = r.get('start')
+        def get_start_position(index: int):
+            change = changes[index]  # type: Any
+            start = change.get('range').get('start')
             line = start.get('line')
             character = start.get('character')
-            return (line, character)  # Return tuple so comparing/sorting tuples in the form of (1, 2)
-
-        # Sort by start position
-        return sorted(changes, key=get_start_position)
+            return (line, character, index)
+
+        # The spec reads:
+        # > However, it is possible that multiple edits have the same start position: multiple
+        # > inserts, or any number of inserts followed by a single remove or replace edit. If
+        # > multiple inserts have the same position, the order in the array defines the order in
+        # > which the inserted strings appear in the resulting text.
+        # So we sort by start position. But if multiple text edits start at the same position,
+        # we use the index in the array as the key.
+        return sorted(range(len(changes)), key=get_start_position, reverse=True)
 
     def create_region(self, change):
         return range_to_region(Range.from_lsp(change['range']), self.view)


Test patch:
diff --git a/tests/test_edit.py b/tests/test_edit.py
--- a/tests/test_edit.py
+++ b/tests/test_edit.py
@@ -1,53 +1,69 @@
 from unittesting import DeferrableTestCase
 import sublime
-from os.path import dirname
 
-test_file_path = dirname(__file__) + "/testfile.txt"
 
-ORIGINAL_CONTENT = """<dom-module id="some-thing">
-<style></style>
-<template>
-</template>
-</dom-module>"""
-
-EXPECTED_CONTENT = """<dom-module id="some-thing">
-<template>
-    <style></style>
-</template>
-</dom-module>"""
+def text_edit(Start: 'Tuple[int, int]', End: 'Tuple[int, int]', NewText: str):
+    return {
+        'range': {
+            'start': {'line': Start[0], 'character': Start[1]},
+            'end': {'line': End[0], 'character': End[1]},
+        },
+        'newText': NewText,
+    }
 
 
 class ApplyDocumentEditTests(DeferrableTestCase):
-
     def setUp(self):
         self.view = sublime.active_window().new_file()
-        self.view.run_command('insert', {"characters": ORIGINAL_CONTENT})
 
     def test_apply(self):
-        file_changes = [{
-            'range': {
-                'start': {'line': 0, 'character': 28},
-                'end': {'line': 1, 'character': 0}
-            },
-            'newText': ''
-        }, {
-            'range': {
-                'start': {'line': 1, 'character': 0},
-                'end': {'line': 1, 'character': 15}
-            },
-            'newText': ''
-        }, {
-            'range': {
-                'start': {'line': 2, 'character': 10},
-                'end': {'line': 2, 'character': 10}
-            },
-            'newText': '\n    <style></style>'
-        }]
-
-        self.view.run_command('lsp_apply_document_edit', {'changes': file_changes, 'show_status': False})
+        original = (
+            '<dom-module id="some-thing">\n'
+            '<style></style>\n'
+            '<template>\n'
+            '</template>\n'
+            '</dom-module>\n'
+        )
+        file_changes = [
+            text_edit((0, 28), (1, 0), ''),  # delete first \n
+            text_edit((1, 0), (1, 15), ''),  # delete second line (but not the \n)
+            text_edit((2, 10), (2, 10), '\n    <style></style>'),  # insert after <template>
+        ]
+        expected = (
+            '<dom-module id="some-thing">\n'
+            '<template>\n'
+            '    <style></style>\n'
+            '</template>\n'
+            '</dom-module>\n'
+        )
+        self.run_test(original, expected, file_changes)
+
+    def test_apply_and_preserve_order(self):
+        original = (
+            'abcde\n'
+            'fghij\n'
+        )
+        # Note that (1, 2) comes before (0, 1) in the text.
+        file_changes = [
+            text_edit((1, 2), (1, 2), '4'),  # insert after the g
+            text_edit((1, 2), (1, 2), '5'),
+            text_edit((1, 2), (1, 3), '6'),  # replace the h
+            text_edit((0, 1), (0, 1), '1'),  # insert after a
+            text_edit((0, 1), (0, 1), '2'),
+            text_edit((0, 1), (0, 1), '3'),
+        ]
+        expected = (
+            'a123bcde\n'
+            'fg456ij\n'
+        )
+        self.run_test(original, expected, file_changes)
 
+    def run_test(self, original: str, expected: str, file_changes):
+        self.view.run_command('insert', {"characters": original})
+        self.view.run_command(
+            'lsp_apply_document_edit', {'changes': file_changes, 'show_status': False})
         edited_content = self.view.substr(sublime.Region(0, self.view.size()))
-        self.assertEquals(edited_content, EXPECTED_CONTENT)
+        self.assertEquals(edited_content, expected)
 
     def tearDown(self):
         if self.view:
diff --git a/tests/testfile.txt b/tests/testfile.txt
deleted file mode 100644
--- a/tests/testfile.txt
+++ /dev/null
@@ -1 +0,0 @@
-Hello Wrld
\ No newline at end of file