Repository: openlawlibrary/pygls

Issue:
Support multiple text document synchronization options
Currently we support just incremental updates. The goal is to support all synchronization options specified by the [protocol](https://microsoft.github.io/language-server-protocol/specification):

```
/**
 * Defines how the host (editor) should sync document changes to the language server.
 */
export namespace TextDocumentSyncKind {
	/**
	 * Documents should not be synced at all.
	 */
	export const None = 0;

	/**
	 * Documents are synced by always sending the full content
	 * of the document.
	 */
	export const Full = 1;

	/**
	 * Documents are synced by sending the full content on open.
	 * After that only incremental updates to the document are
	 * send.
	 */
	export const Incremental = 2;
}
```

Discussion:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4667dd6..f5b2be6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning][semver].
 
 ### Added
 
+- Full text document synchronization support ([#65])
 - Add more tests for `deserialize_message` function ([#61])
 
 ### Changed
@@ -16,6 +17,7 @@ and this project adheres to [Semantic Versioning][semver].
 - Response object should contain result OR error field ([#64])
 - Fix handling parameters whose names are reserved by Python ([#56])
 
+[#65]: https://github.com/openlawlibrary/pygls/pull/65
 [#64]: https://github.com/openlawlibrary/pygls/pull/64
 [#61]: https://github.com/openlawlibrary/pygls/pull/61
 [#56]: https://github.com/openlawlibrary/pygls/pull/56
diff --git a/docs/source/index.rst b/docs/source/index.rst
index d86a85b..28d67c0 100644
--- a/docs/source/index.rst
+++ b/docs/source/index.rst
@@ -23,7 +23,7 @@ Features
    -  functions that will be executed in separate thread
 
 -  thread management
--  in-memory workspace with incremental document updates
+-  in-memory workspace with _full_ and _incremental_ document updates
 -  no third-party libraries
 -  good test coverage
 
diff --git a/docs/source/pages/advanced_usage.rst b/docs/source/pages/advanced_usage.rst
index 0b557f2..a12345e 100644
--- a/docs/source/pages/advanced_usage.rst
+++ b/docs/source/pages/advanced_usage.rst
@@ -119,8 +119,8 @@ module.
    workspace which will exist as long as document is opened in editor.
 
 -  The `textDocument/didChange <https://microsoft.github.io/language-server-protocol/specification#textDocument_didChange>`__
-   notification will tell *pygls* to update the document text. For now,
-   *pygls* supports **only** incremental document changes.
+   notification will tell *pygls* to update the document text.
+   *pygls* supports _full_ and _incremental_ document changes.
 
 -  The `textDocument/didClose <https://microsoft.github.io/language-server-protocol/specification#textDocument_didClose>`__
    notification will tell *pygls* to remove a document from the
diff --git a/pygls/protocol.py b/pygls/protocol.py
index d035c8f..d0863e9 100644
--- a/pygls/protocol.py
+++ b/pygls/protocol.py
@@ -524,6 +524,7 @@ class LanguageServerProtocol(JsonRPCProtocol, metaclass=LSPMeta):
         server_capabilities = ServerCapabilities(self.fm.features.keys(),
                                                  self.fm.feature_options,
                                                  self.fm.commands,
+                                                 self._server.sync_kind,
                                                  client_capabilities)
         logger.debug('Server capabilities: {}'
                      .format(server_capabilities.__dict__))
@@ -533,7 +534,7 @@ class LanguageServerProtocol(JsonRPCProtocol, metaclass=LSPMeta):
 
         # Initialize the workspace
         workspace_folders = getattr(params, 'workspaceFolders', [])
-        self.workspace = Workspace(root_uri, workspace_folders)
+        self.workspace = Workspace(root_uri, self._server.sync_kind, workspace_folders)
 
         return InitializeResult(server_capabilities)
 
diff --git a/pygls/server.py b/pygls/server.py
index 7945338..c340466 100644
--- a/pygls/server.py
+++ b/pygls/server.py
@@ -24,7 +24,8 @@ from threading import Event
 from typing import Callable, Dict, List
 
 from pygls.types import (ApplyWorkspaceEditResponse, ConfigCallbackType,
-                         Diagnostic, MessageType, WorkspaceEdit)
+                         Diagnostic, MessageType, TextDocumentSyncKind,
+                         WorkspaceEdit)
 
 from . import IS_WIN
 from .protocol import LanguageServerProtocol
@@ -90,8 +91,13 @@ class Server:
     Args:
         protocol_cls(Protocol): Protocol implementation that must be derived
                                 from `asyncio.Protocol`
+        loop(AbstractEventLoop): asyncio event loop
         max_workers(int, optional): Number of workers for `ThreadPool` and
                                     `ThreadPoolExecutor`
+        sync_kind(TextDocumentSyncKind): Text document synchronization option
+            - NONE(0): no synchronization
+            - FULL(1): replace whole text
+            - INCREMENTAL(2): replace text within a given range
 
     Attributes:
         _max_workers(int): Number of workers for thread pool executor
@@ -104,7 +110,8 @@ class Server:
                                                     - lazy instantiated
     """
 
-    def __init__(self, protocol_cls, loop=None, max_workers=2):
+    def __init__(self, protocol_cls, loop=None, max_workers=2,
+                 sync_kind=TextDocumentSyncKind.INCREMENTAL):
         if not issubclass(protocol_cls, asyncio.Protocol):
             raise TypeError('Protocol class should be subclass of asyncio.Protocol')
 
@@ -113,6 +120,7 @@ class Server:
         self._stop_event = None
         self._thread_pool = None
         self._thread_pool_executor = None
+        self.sync_kind = sync_kind
 
         if IS_WIN:
             asyncio.set_event_loop(asyncio.ProactorEventLoop())
diff --git a/pygls/types.py b/pygls/types.py
index cc6564b..f8be2e1 100644
--- a/pygls/types.py
+++ b/pygls/types.py
@@ -881,8 +881,9 @@ class ServerCapabilities:
                  features,
                  feature_options,
                  commands,
+                 sync_kind,
                  client_capabilities):
-        self.textDocumentSync = TextDocumentSyncKind.INCREMENTAL
+        self.textDocumentSync = sync_kind
         self.hoverProvider = HOVER in features
 
         if COMPLETION in features:
diff --git a/pygls/workspace.py b/pygls/workspace.py
index 8a7f5a4..622c217 100644
--- a/pygls/workspace.py
+++ b/pygls/workspace.py
@@ -23,7 +23,7 @@ import re
 from typing import List
 
 from .types import (NumType, Position, TextDocumentContentChangeEvent,
-                    TextDocumentItem, WorkspaceFolder)
+                    TextDocumentItem, TextDocumentSyncKind, WorkspaceFolder)
 from .uris import to_fs_path, uri_scheme
 
 # TODO: this is not the best e.g. we capture numbers
@@ -35,7 +35,8 @@ log = logging.getLogger(__name__)
 
 class Document(object):
 
-    def __init__(self, uri, source=None, version=None, local=True,):
+    def __init__(self, uri, source=None, version=None, local=True,
+                 sync_kind=TextDocumentSyncKind.INCREMENTAL):
         self.uri = uri
         self.version = version
         self.path = to_fs_path(uri)
@@ -44,11 +45,16 @@ class Document(object):
         self._local = local
         self._source = source
 
+        self._sync_func = {
+            TextDocumentSyncKind.FULL: self._apply_full_change,
+            TextDocumentSyncKind.INCREMENTAL: self._apply_incremental_change
+        }.get(sync_kind, None)
+
     def __str__(self):
         return str(self.uri)
 
-    def apply_change(self, change: TextDocumentContentChangeEvent) -> None:
-        """Apply a change to the document."""
+    def _apply_incremental_change(self, change: TextDocumentContentChangeEvent) -> None:
+        """Apply an incremental text change to the document."""
         text = change.text
         change_range = change.range
 
@@ -90,6 +96,18 @@ class Document(object):
 
         self._source = new.getvalue()
 
+    def _apply_full_change(self, change: TextDocumentContentChangeEvent) -> None:
+        """Apply a full text change to the document."""
+        self._source = change.text
+
+    def apply_change(self, change: TextDocumentContentChangeEvent) -> None:
+        """Apply change to a document, depending on synchronization kind."""
+        try:
+            self._sync_func(change)
+        except TypeError:
+            # Don't sync document if sync kind is TextDocumentSyncKind.NONE
+            pass
+
     @property
     def lines(self) -> List[str]:
         return self.source.splitlines(True)
@@ -128,10 +146,11 @@ class Document(object):
 
 class Workspace(object):
 
-    def __init__(self, root_uri, workspace_folders=None):
+    def __init__(self, root_uri, sync_kind=None, workspace_folders=None):
         self._root_uri = root_uri
         self._root_uri_scheme = uri_scheme(self._root_uri)
         self._root_path = to_fs_path(self._root_uri)
+        self._sync_kind = sync_kind
         self._folders = {}
         self._docs = {}
 
@@ -143,7 +162,8 @@ class Workspace(object):
                          doc_uri: str,
                          source: str = None,
                          version: NumType = None) -> Document:
-        return Document(doc_uri, source=source, version=version)
+        return Document(doc_uri, source=source, version=version,
+                        sync_kind=self._sync_kind)
 
     def add_folder(self, folder: WorkspaceFolder):
         self._folders[folder.uri] = folder


Test patch:
diff --git a/tests/test_document.py b/tests/test_document.py
index 6059dc3..4c1666d 100644
--- a/tests/test_document.py
+++ b/tests/test_document.py
@@ -16,7 +16,8 @@
 # See the License for the specific language governing permissions and      #
 # limitations under the License.                                           #
 ############################################################################
-from pygls.types import Position, Range, TextDocumentContentChangeEvent
+from pygls.types import (Position, Range, TextDocumentContentChangeEvent,
+                         TextDocumentSyncKind)
 from pygls.workspace import Document
 
 from .conftest import DOC, DOC_URI
@@ -48,6 +49,22 @@ def test_document_end_of_file_edit():
     ]
 
 
+def test_document_full_edit():
+    old = [
+        "def hello(a, b):\n",
+        "    print a\n",
+        "    print b\n"
+    ]
+    doc = Document('file:///uri', u''.join(old), sync_kind=TextDocumentSyncKind.FULL)
+    change = TextDocumentContentChangeEvent(
+        Range(Position(1, 4), Position(2, 11)), 0, u'print a, b')
+    doc.apply_change(change)
+
+    assert doc.lines == [
+        "print a, b"
+    ]
+
+
 def test_document_line_edit():
     doc = Document('file:///uri', u'itshelloworld')
     change = TextDocumentContentChangeEvent(
@@ -67,7 +84,7 @@ def test_document_multiline_edit():
         "    print a\n",
         "    print b\n"
     ]
-    doc = Document('file:///uri', u''.join(old))
+    doc = Document('file:///uri', u''.join(old), sync_kind=TextDocumentSyncKind.INCREMENTAL)
     change = TextDocumentContentChangeEvent(
         Range(Position(1, 4), Position(2, 11)), 0, u'print a, b')
     doc.apply_change(change)
@@ -78,6 +95,20 @@ def test_document_multiline_edit():
     ]
 
 
+def test_document_no_edit():
+    old = [
+        "def hello(a, b):\n",
+        "    print a\n",
+        "    print b\n"
+    ]
+    doc = Document('file:///uri', u''.join(old), sync_kind=TextDocumentSyncKind.NONE)
+    change = TextDocumentContentChangeEvent(
+        Range(Position(1, 4), Position(2, 11)), 0, u'print a, b')
+    doc.apply_change(change)
+
+    assert doc.lines == old
+
+
 def test_document_props(doc):
     assert doc.uri == DOC_URI
     assert doc.source == DOC
diff --git a/tests/test_workspace.py b/tests/test_workspace.py
index c6c122b..4cb3195 100644
--- a/tests/test_workspace.py
+++ b/tests/test_workspace.py
@@ -19,8 +19,8 @@
 import os
 
 from pygls import uris
-from pygls.workspace import Workspace
 from pygls.types import TextDocumentItem, WorkspaceFolder
+from pygls.workspace import Workspace
 
 DOC_URI = uris.from_fs_path(__file__)
 DOC_TEXT = '''test'''
@@ -72,7 +72,7 @@ def test_workspace_folders():
     wf1 = WorkspaceFolder('/ws/f1', 'ws1')
     wf2 = WorkspaceFolder('/ws/f2', 'ws2')
 
-    workspace = Workspace('/ws', [wf1, wf2])
+    workspace = Workspace('/ws', workspace_folders=[wf1, wf2])
 
     assert workspace.folders['/ws/f1'] is wf1
     assert workspace.folders['/ws/f2'] is wf2