Repository: palantir/python-language-server

Issue:
Support incremental TextDocumentSyncKind
Right now we get full text files each time they change. This is obviously bad.

I wonder if we could then propagate these changes to only re-lint the changed parts of the file?

Discussion:

Patch:
diff --git a/pyls/python_ls.py b/pyls/python_ls.py
index 0cc0e40..08d4fed 100644
--- a/pyls/python_ls.py
+++ b/pyls/python_ls.py
@@ -36,7 +36,7 @@ class PythonLanguageServer(LanguageServer):
             'signatureHelpProvider': {
                 'triggerCharacters': ['(', ',']
             },
-            'textDocumentSync': lsp.TextDocumentSyncKind.FULL
+            'textDocumentSync': lsp.TextDocumentSyncKind.INCREMENTAL
         }
 
     def initialize(self, root_path, init_opts, _process_id):
@@ -111,11 +111,12 @@ class PythonLanguageServer(LanguageServer):
         self.lint(textDocument['uri'])
 
     def m_text_document__did_change(self, contentChanges=None, textDocument=None, **_kwargs):
-        # Since we're using a FULL document sync, there is only one change containing the whole file
-        # TODO: debounce, or should this be someone else's responsibility? Probably
-        self.workspace.put_document(
-            textDocument['uri'], contentChanges[0]['text'], version=textDocument.get('version')
-        )
+        for change in contentChanges:
+            self.workspace.update_document(
+                textDocument['uri'],
+                change,
+                version=textDocument.get('version')
+            )
         self.lint(textDocument['uri'])
 
     def m_text_document__did_save(self, textDocument=None, **_kwargs):
diff --git a/pyls/workspace.py b/pyls/workspace.py
index f7a5b06..b8381a9 100644
--- a/pyls/workspace.py
+++ b/pyls/workspace.py
@@ -1,4 +1,5 @@
 # Copyright 2017 Palantir Technologies, Inc.
+import io
 import logging
 import os
 import re
@@ -44,6 +45,10 @@ class Workspace(object):
     def rm_document(self, doc_uri):
         self._docs.pop(doc_uri)
 
+    def update_document(self, doc_uri, change, version=None):
+        self._docs[doc_uri].apply_change(change)
+        self._docs[doc_uri].version = version
+
     def apply_edit(self, edit):
         # Note that lang_server.call currently doesn't return anything
         return self._lang_server.call(self.M_APPLY_EDIT, {'edit': edit})
@@ -98,8 +103,45 @@ class Document(object):
                 return f.read()
         return self._source
 
+    def apply_change(self, change):
+        """Apply a change to the document."""
+        text = change['text']
+        change_range = change.get('range')
+
+        if not change_range:
+            # The whole file has changed
+            self._source = text
+            return
+
+        start_line = change_range['start']['line']
+        start_col = change_range['start']['character']
+        end_line = change_range['end']['line']
+        end_col = change_range['end']['character']
+
+        new = io.StringIO()
+        # Iterate over the existing document until we hit the edit range,
+        # at which point we write the new text, then loop until we hit
+        # the end of the range and continue writing.
+        for i, line in enumerate(self.lines):
+            if i < start_line:
+                new.write(line)
+                continue
+
+            if i > end_line:
+                new.write(line)
+                continue
+
+            if i == start_line:
+                new.write(line[:start_col])
+                new.write(text)
+
+            if i == end_line:
+                new.write(line[end_col:])
+
+        self._source = new.getvalue()
+
     def word_at_position(self, position):
-        """ Get the word under the cursor returning the start and end positions """
+        """Get the word under the cursor returning the start and end positions."""
         line = self.lines[position['line']]
         i = position['character']
         # Split word in two


Test patch:
diff --git a/test/test_workspace.py b/test/test_workspace.py
index f30afa7..fa262ae 100644
--- a/test/test_workspace.py
+++ b/test/test_workspace.py
@@ -51,3 +51,32 @@ def test_non_root_project(pyls):
     pyls.workspace.put_document(test_uri, 'assert True')
     test_doc = pyls.workspace.get_document(test_uri)
     assert project_root in pyls.workspace.syspath_for_path(test_doc.path)
+
+
+def test_document_line_edit():
+    doc = workspace.Document('file:///uri', u'itshelloworld')
+    doc.apply_change({
+        'text': u'goodbye',
+        'range': {
+            'start': {'line': 0, 'character': 3},
+            'end': {'line': 0, 'character': 8}
+        }
+    })
+    assert doc.source == u'itsgoodbyeworld'
+
+
+def test_document_multiline_edit():
+    old = [
+        "def hello(a, b):\n",
+        "    print a\n",
+        "    print b\n"
+    ]
+    doc = workspace.Document('file:///uri', u''.join(old))
+    doc.apply_change({'text': u'print a, b', 'range': {
+        'start': {'line': 1, 'character': 4},
+        'end': {'line': 2, 'character': 11}
+    }})
+    assert doc.lines == [
+        "def hello(a, b):\n",
+        "    print a, b\n"
+    ]