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

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?
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"
+    ]

pip install -e .[test]
parse_log_pytest
requirements.txt
pytest coverage pytest-cov
3.9
requirements.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
cachetools==5.5.2
chardet==5.2.0
colorama==0.4.6
configparser==7.2.0
coverage==7.8.0
distlib==0.3.9
exceptiongroup==1.2.2
filelock==3.18.0
future==1.0.0
iniconfig==2.1.0
jedi==0.19.2
json-rpc==1.15.0
packaging==24.2
parso==0.8.4
platformdirs==4.3.7
pluggy==1.5.0
pycodestyle==2.13.0
pyflakes==3.3.1
pyproject-api==1.9.0
pytest==8.3.5
pytest-cov==6.0.0
-e git+https://github.com/palantir/python-language-server.git@df7d399499b0a31fd03da72c84428c7f8957935b#egg=python_language_server
tomli==2.2.1
tox==4.25.0
typing_extensions==4.13.0
versioneer==0.29
virtualenv==20.29.3
yapf==0.43.0

name: python-language-server
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=3.0.16=h5eee18b_0
  - pip=25.0=py39h06a4308_0
  - python=3.9.21=he870216_1
  - readline=8.2=h5eee18b_0
  - setuptools=75.8.0=py39h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - tzdata=2025a=h04d1e81_0
  - wheel=0.45.1=py39h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - cachetools==5.5.2
      - chardet==5.2.0
      - colorama==0.4.6
      - configparser==7.2.0
      - coverage==7.8.0
      - distlib==0.3.9
      - exceptiongroup==1.2.2
      - filelock==3.18.0
      - future==1.0.0
      - iniconfig==2.1.0
      - jedi==0.19.2
      - json-rpc==1.15.0
      - packaging==24.2
      - parso==0.8.4
      - platformdirs==4.3.7
      - pluggy==1.5.0
      - pycodestyle==2.13.0
      - pyflakes==3.3.1
      - pyproject-api==1.9.0
      - pytest==8.3.5
      - pytest-cov==6.0.0
      - tomli==2.2.1
      - tox==4.25.0
      - typing-extensions==4.13.0
      - versioneer==0.29
      - virtualenv==20.29.3
      - yapf==0.43.0
prefix: /opt/conda/envs/python-language-server

test/test_workspace.py::test_document_line_edit test/test_workspace.py::test_document_multiline_edit
test/test_workspace.py::test_local test/test_workspace.py::test_put_document test/test_workspace.py::test_get_document test/test_workspace.py::test_rm_document test/test_workspace.py::test_bad_get_document test/test_workspace.py::test_uri_like test/test_workspace.py::test_non_root_project
pyls/python_ls.py pyls/workspace.py
pyls/python_ls.py pyls/workspace.py