diff --git a/docs/source/conf.py b/docs/source/conf.py index 1b4dc01c..b3b3bfc9 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -184,7 +184,7 @@ def lsp_role(name, rawtext, text, lineno, inliner, options={}, content=[]): """Link to sections within the lsp specification.""" - anchor = text.replace("/", "_") + anchor = text.replace("$/", "").replace("/", "_") ref = f"https://microsoft.github.io/language-server-protocol/specification.html#{anchor}" node = nodes.reference(rawtext, text, refuri=ref, **options) diff --git a/docs/source/index.rst b/docs/source/index.rst index 8705175d..f9099019 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -40,7 +40,7 @@ User Guide pages/getting_started pages/tutorial - pages/advanced_usage + pages/user-guide pages/testing pages/migrating-to-v1 pages/reference diff --git a/docs/source/pages/advanced_usage.rst b/docs/source/pages/advanced_usage.rst deleted file mode 100644 index cbedfd3f..00000000 --- a/docs/source/pages/advanced_usage.rst +++ /dev/null @@ -1,495 +0,0 @@ -.. _advanced-usage: - -Advanced Usage -============== - -Language Server ---------------- - -The language server is responsible for receiving and sending messages over -the `Language Server Protocol `__ -which is based on the `Json RPC protocol `__. - -Connections -~~~~~~~~~~~ - -*pygls* supports *TCP* and socket *STDIO* connections. - -TCP -^^^ - -TCP connections are usually used while developing the language server. -This way the server can be started in *debug* mode separately and wait -for the client connection. - -.. note:: Server should be started **before** the client. - -The code snippet below shows how to start the server in *TCP* mode. - -.. code:: python - - from pygls.server import LanguageServer - - server = LanguageServer('example-server', 'v0.1') - - server.start_tcp('127.0.0.1', 8080) - -STDIO -^^^^^ - -STDIO connections are useful when client is starting the server as a child -process. This is the way to go in production. - -The code snippet below shows how to start the server in *STDIO* mode. - -.. code:: python - - from pygls.server import LanguageServer - - server = LanguageServer('example-server', 'v0.1') - - server.start_io() - -WEBSOCKET -^^^^^^^^^ - -WEBSOCKET connections are used when you want to expose language server to -browser based editors. - -The code snippet below shows how to start the server in *WEBSOCKET* mode. - -.. code:: python - - from pygls.server import LanguageServer - - server = LanguageServer('example-server', 'v0.1') - - server.start_websocket('0.0.0.0', 1234) - -Logging -~~~~~~~ - -Logs are useful for tracing client requests, finding out errors and -measuring time needed to return results to the client. - -*pygls* uses built-in python *logging* module which has to be configured -before server is started. - -Official documentation about logging in python can be found -`here `__. Below -is the minimal setup to setup logging in *pygls*: - -.. code:: python - - import logging - - from pygls.server import LanguageServer - - logging.basicConfig(filename='pygls.log', filemode='w', level=logging.DEBUG) - - server = LanguageServer('example-server', 'v0.1') - - server.start_io() - -Overriding ``LanguageServerProtocol`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -If you have a reason to override the existing ``LanguageServerProtocol`` class, -you can do that by inheriting the class and passing it to the ``LanguageServer`` -constructor. - -Features --------- - -What is a feature in *pygls*? In terms of language servers and the -`Language Server Protocol `__, -a feature is one of the predefined methods from -LSP `specification `__, -such as: *code completion*, *formatting*, *code lens*, etc. See the `lsprotocol -`_ module -for the complete and canonical list of available features. - -*Built-In* Features -~~~~~~~~~~~~~~~~~~~ - -*pygls* comes with following predefined set of -`Language Server Protocol `__ -(LSP) features: - -- The `initialize `__ - request is sent as a first request from client to the server to setup - their communication. *pygls* automatically computes registered LSP - capabilities and sends them as part of ``InitializeResult`` response. - -- The `shutdown `__ - request is sent from the client to the server to ask the server to - shutdown. - -- The `exit `__ - notification is sent from client to the server to ask the server to - exit the process. *pygls* automatically releases all resources and - stops the process. - -- The `textDocument/didOpen `__ - notification will tell *pygls* to create a document in the in-memory - workspace which will exist as long as document is opened in editor. - -- The `textDocument/didChange `__ - notification will tell *pygls* to update the document text. - *pygls* supports _full_ and _incremental_ document changes. - -- The `textDocument/didClose `__ - notification will tell *pygls* to remove a document from the - in-memory workspace. - -- The `workspace/didChangeWorkspaceFolders `__ - notification will tell *pygls* to update in-memory workspace folders. - -Commands --------- - -Commands can be treated as a *custom features*, i.e. everything that is -not covered by LSP specification, but needs to be implemented. - -API ---- - -*Feature* and *Command* Advanced Registration -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -*pygls* is a language server which relies on *asyncio event loop*. It is -*asynchronously* listening for incoming messages and, depending on the -way method is registered, applying different execution strategies to -respond to the client. - -Depending on the use case, *features* and *commands* can be registered -in three different ways. - -To make sure that you fully understand what is happening under the hood, -please take a look at the :ref:`tutorial `. - -.. note:: - - *Built-in* features in most cases should *not* be overridden. - Instead, register the feature with the same name and it will be - called immediately after the corresponding built-in feature. - -*Asynchronous* Functions (*Coroutines*) -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -*pygls* supports ``python 3.7+`` which has a keyword ``async`` to -specify coroutines. - -The code snippet below shows how to register a command as a coroutine: - -.. code:: python - - @json_server.command(JsonLanguageServer.CMD_COUNT_DOWN_NON_BLOCKING) - async def count_down_10_seconds_non_blocking(ls, *args): - # Omitted - -Registering a *feature* as a coroutine is exactly the same. - -Coroutines are functions that are executed as tasks in *pygls*'s *event -loop*. They should contain at least one *await* expression (see -`awaitables `__ -for details) which tells event loop to switch to another task while -waiting. This allows *pygls* to listen for client requests in a -*non blocking* way, while still only running in the *main* thread. - -Tasks can be canceled by the client if they didn't start executing (see -`Cancellation -Support `__). - -.. warning:: - - Using computation intensive operations will *block* the main thread and - should be *avoided* inside coroutines. Take a look at - `threaded functions <#threaded-functions>`__ for more details. - -*Synchronous* Functions -^^^^^^^^^^^^^^^^^^^^^^^ - -Synchronous functions are regular functions which *blocks* the *main* -thread until they are executed. - -`Built-in features <#built-in-features>`__ are registered as regular -functions to ensure correct state of language server initialization and -workspace. - -The code snippet below shows how to register a command as a regular -function: - -.. code:: python - - @json_server.command(JsonLanguageServer.CMD_COUNT_DOWN_BLOCKING) - def count_down_10_seconds_blocking(ls, *args): - # Omitted - -Registering *feature* as a regular function is exactly the same. - -.. warning:: - - Using computation intensive operations will *block* the main thread and - should be *avoided* inside regular functions. Take a look at - `threaded functions <#threaded-functions>`__ for more details. - -*Threaded* Functions -^^^^^^^^^^^^^^^^^^^^ - -*Threaded* functions are just regular functions, but marked with -*pygls*'s ``thread`` decorator: - -.. code:: python - - # Decorator order is not important in this case - @json_server.thread() - @json_server.command(JsonLanguageServer.CMD_COUNT_DOWN_BLOCKING) - def count_down_10_seconds_blocking(ls, *args): - # Omitted - -*pygls* uses its own *thread pool* to execute above function in *daemon* -thread and it is *lazy* initialized first time when function marked with -``thread`` decorator is fired. - -*Threaded* functions can be used to run blocking operations. If it has been a -while or you are new to threading in Python, check out Python's -``multithreading`` and `GIL `__ -before messing with threads. - -.. _passing-instance: - -Passing Language Server Instance -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Using language server methods inside registered features and commands are quite -common. We recommend adding language server as a **first parameter** of a -registered function. - -There are two ways of doing this: - -- **ls** (**l**\anguage **s**\erver) naming convention - -Add **ls** as first parameter of a function and *pygls* will automatically pass -the language server instance. - -.. code-block:: python - - @json_server.command(JsonLanguageServer.CMD_COUNT_DOWN_BLOCKING) - def count_down_10_seconds_blocking(ls, *args): - # Omitted - - -- add **type** to first parameter - -Add the **LanguageServer** class or any class derived from it as a type to -first parameter of a function - -.. code-block:: python - - @json_server.command(JsonLanguageServer.CMD_COUNT_DOWN_BLOCKING) - def count_down_10_seconds_blocking(ser: JsonLanguageServer, *args): - # Omitted - - -Using outer ``json_server`` instance inside registered function will make -writing unit :ref:`tests ` more difficult. - - -Notifications -~~~~~~~~~~~~~ - -A *notification* is a request message without the ``id`` field and server -*must not* reply to it. This means that, if your language server received the -notification, even if you return the result inside your handler function, -the result won't be passed to the client. - -The ``Language Server Protocol``, unlike ``Json RPC``, allows bidirectional -communication between the server and the client. - -Configuration -^^^^^^^^^^^^^ - -The `configuration `__ -request is sent from the server to the client in order to fetch -configuration settings from the client. When the requested configuration -is collected, the client sends data as a notification to the server. - -.. note:: - - Although ``configuration`` is a ``request``, it is explained in this - section because the client sends back the ``notification`` object. - -The code snippet below shows how to send configuration to the client: - -.. code:: python - - def get_configuration(self, - params: WorkspaceConfigurationParams, - callback: Optional[Callable[[List[Any]], None]] = None - ) -> asyncio.Future: - # Omitted - -*pygls* has three ways for handling configuration notification from the -client, depending on way how the function is registered (described -`here <#feature-and-command-advanced-registration>`__): - -- *asynchronous* functions (*coroutines*) - -.. code:: python - - # await keyword tells event loop to switch to another task until notification is received - config = await ls.get_configuration(WorkspaceConfigurationParams(items=[ConfigurationItem(scope_uri='doc_uri_here', section='section')])) - -- *synchronous* functions - -.. code:: python - - # callback is called when notification is received - def callback(config): - # Omitted - - config = ls.get_configuration(WorkspaceConfigurationParams(items=[ConfigurationItem(scope_uri='doc_uri_here', section='section')]), callback) - -- *threaded* functions - -.. code:: python - - # .result() will block the thread - config = ls.get_configuration(WorkspaceConfigurationParams(items=[ConfigurationItem(scope_uri='doc_uri_here', section='section')])).result() - -Show Message -^^^^^^^^^^^^ - -`Show -message `__ -is notification that is sent from the server to the client to display -text message. - -The code snippet below shows how to send show message notification: - -.. code:: python - - @json_server.command(JsonLanguageServer.CMD_COUNT_DOWN_NON_BLOCKING) - async def count_down_10_seconds_non_blocking(ls, *args): - for i in range(10): - # Sends message notification to the client - ls.show_message(f"Counting down... {10 - i}") - await asyncio.sleep(1) - -Show Message Log -^^^^^^^^^^^^^^^^ - -`Show message -log `__ -is notification that is sent from the server to the client to display -text message in the output channel. - -The code snippet below shows how to send show message log notification: - -.. code:: python - - @json_server.command(JsonLanguageServer.CMD_COUNT_DOWN_NON_BLOCKING) - async def count_down_10_seconds_non_blocking(ls, *args): - for i in range(10): - # Sends message log notification to the client's output channel - ls.show_message_log(f"Counting down... {10 - i}") - await asyncio.sleep(1) - -Publish Diagnostics -^^^^^^^^^^^^^^^^^^^ - -`Publish -diagnostics `__ -notifications are sent from the server to the client to signal results -of validation runs. - -Usually this notification is sent after document is opened, or on -document content change, e.g.: - -.. code:: python - - @json_server.feature(TEXT_DOCUMENT_DID_OPEN) - async def did_open(ls, params: DidOpenTextDocumentParams): - """Text document did open notification.""" - ls.show_message("Text Document Did Open") - ls.show_message_log("Validating json...") - - # Get document from workspace - text_doc = ls.workspace.get_document(params.text_document.uri) - - diagnostic = Diagnostic( - range=Range( - start=Position(line-1, col-1), - end=Position(line-1, col) - ), - message="Custom validation message", - source="Json Server" - ) - - # Send diagnostics - ls.publish_diagnostics(text_doc.uri, [diagnostic]) - - -Custom Notifications -^^^^^^^^^^^^^^^^^^^^ - -*pygls* supports sending custom notifications to the client and below -is method declaration for this functionality: - -.. code:: python - - def send_notification(self, method: str, params: object = None) -> None: - # Omitted - -And method invocation example: - -.. code:: python - - server.send_notification('myCustomNotification', 'test data') - -Custom Error Reporting -^^^^^^^^^^^^^^^^^^^^^^ - -By default Pygls notifies the client to display any occurences of uncaught exceptions in the -server. To override this behaviour define your own `report_server_error()` method like so: - -.. code:: python - - Class CustomLanguageServer(LanguageServer): - def report_server_error(self, error: Exception, source: Union[PyglsError, JsonRpcException]): - pass - - -Workspace -~~~~~~~~~ - -`Workspace `__ -is a python object that holds information about workspace folders, opened -documents and has the logic for updating document content. - -*pygls* automatically take care about mentioned features of the -workspace. - -Workspace methods that can be used for user defined features are: - -- Get document from the workspace - -.. code:: python - - def get_document(self, doc_uri: str) -> Document: - # Omitted - -- `Apply - edit `__ - request - -.. code:: python - - def apply_edit(self, edit: WorkspaceEdit, label: str = None) -> ApplyWorkspaceEditResponse: - # Omitted - def apply_edit_async(self, edit: WorkspaceEdit, label: str = None) -> ApplyWorkspaceEditResponse: - # Omitted - -.. _pygls.lsp.methods: https://github.com/openlawlibrary/pygls/blob/master/pygls/lsp/methods.py -.. _pygls.lsp.types: https://github.com/openlawlibrary/pygls/tree/master/pygls/lsp/types diff --git a/docs/source/pages/getting_started.rst b/docs/source/pages/getting_started.rst index d8286604..e73d20fa 100644 --- a/docs/source/pages/getting_started.rst +++ b/docs/source/pages/getting_started.rst @@ -6,12 +6,12 @@ servers that are based on it. .. note:: - Before going any further, if you are not familiar with *language servers* - and *Language Server Protocol*, we recommend reading following articles: + Before going any further, if you are not familiar with *language servers* + and *Language Server Protocol*, we recommend reading following articles: - - `Language Server Protocol Overview `_ - - `Language Server Protocol Specification `_ - - `Language Server Protocol SDKs `_ + - `Language Server Protocol Overview `_ + - `Language Server Protocol Specification `_ + - `Language Server Protocol SDKs `_ Installation @@ -23,12 +23,11 @@ To get the latest release from *PyPI*, simply run: pip install pygls -Alternatively, *pygls* source code can be downloaded from our `GitHub`_ -page and installed with following command: +Alternatively, *pygls* source code can be downloaded from our `GitHub`_ page and installed with following command: .. code:: console - python setup.py install + pip install git+https://github.com/openlawlibrary/pygls Quick Start ----------- @@ -78,13 +77,7 @@ Register Features and Commands def cmd_return_hello_world(ls, *args): return 'Hello World!' -See the `lsprotocol`_ module for the complete and canonical list of available features. - -Advanced usage --------------- - -To reveal the full potential of *pygls* (``thread management``, ``coroutines``, -``multi-root workspace``, ``TCP/STDIO communication``, etc.) keep reading. +See the :mod:`lsprotocol.types` module for the complete and canonical list of available features. Tutorial -------- @@ -92,6 +85,10 @@ Tutorial We recommend completing the :ref:`tutorial `, especially if you haven't worked with language servers before. +User Guide +---------- + +To reveal the full potential of *pygls* (``thread management``, ``coroutines``, +``multi-root workspace``, ``TCP/STDIO communication``, etc.) keep reading. .. _GitHub: https://github.com/openlawlibrary/pygls -.. _lsprotocol: https://github.com/microsoft/lsprotocol/blob/main/packages/python/lsprotocol/types.py diff --git a/docs/source/pages/reference.rst b/docs/source/pages/reference.rst index 3e9b4217..2b0aa56b 100644 --- a/docs/source/pages/reference.rst +++ b/docs/source/pages/reference.rst @@ -1,5 +1,5 @@ -Reference -========= +API Reference +============= .. toctree:: :glob: diff --git a/docs/source/pages/reference/protocol.rst b/docs/source/pages/reference/protocol.rst new file mode 100644 index 00000000..e9bd2f77 --- /dev/null +++ b/docs/source/pages/reference/protocol.rst @@ -0,0 +1,11 @@ +Protocol +======== + + +.. autoclass:: pygls.protocol.LanguageServerProtocol + :members: + +.. autoclass:: pygls.protocol.JsonRPCProtocol + :members: + +.. autofunction:: pygls.protocol.default_converter diff --git a/docs/source/pages/reference/servers.rst b/docs/source/pages/reference/servers.rst new file mode 100644 index 00000000..2a664ed0 --- /dev/null +++ b/docs/source/pages/reference/servers.rst @@ -0,0 +1,11 @@ +Servers +======= + +.. autoclass:: pygls.server.LanguageServer + :members: + +.. autoclass:: pygls.server.Server + :members: + + + diff --git a/docs/source/pages/reference/types.rst b/docs/source/pages/reference/types.rst index 1f251bc5..1cbcb1fe 100644 --- a/docs/source/pages/reference/types.rst +++ b/docs/source/pages/reference/types.rst @@ -5,3 +5,6 @@ LSP type definitions in ``pygls`` are provided by the `lsprotocol ` section for more details. -Json Extension example's `unit tests`_ might be helpful, too. - Integration Tests ----------------- @@ -23,6 +21,4 @@ server, we used *pygls* to simulate the client and send desired requests to the server. To get a better understanding of how to set it up, take a look at our test `fixtures`_. - -.. _unit tests: https://github.com/openlawlibrary/pygls/blob/master/examples/json-vscode-extension/server/tests/unit -.. _fixtures: https://github.com/openlawlibrary/pygls/blob/master/tests/conftest.py#L29 +.. _fixtures: https://github.com/openlawlibrary/pygls/blob/main/tests/conftest.py diff --git a/docs/source/pages/tutorial.rst b/docs/source/pages/tutorial.rst index 44c68847..452cef18 100644 --- a/docs/source/pages/tutorial.rst +++ b/docs/source/pages/tutorial.rst @@ -3,10 +3,16 @@ Tutorial ======== -In order to help you with using *pygls* in VSCode, we have created a simple `json-extension`_ example. +In order to help you with using *pygls* in VSCode, we have created the `vscode-playground`_ extension. .. note:: - You do not need this extension when using *pygls* with other text editors. + + This extension is meant to provide an environment in which you can easily experiment with a *pygls* powered language server. + It is not necessary in order to use *pygls* with other text editors. + + If you decide you want to publish your language server on the VSCode marketplace this + `template extension `__ + from Microsoft a useful starting point. Prerequisites ------------- @@ -82,18 +88,19 @@ Language server is **blocked**, because ``time.sleep`` is a **blocking** operation. This is why you didn't hit the breakpoint this time. .. hint:: - To make this command **non blocking**, add ``@json_server.thread()`` - decorator, like in code below: - .. code-block:: python + To make this command **non blocking**, add ``@json_server.thread()`` + decorator, like in code below: + + .. code-block:: python - @json_server.thread() - @json_server.command(JsonLanguageServer.CMD_COUNT_DOWN_BLOCKING) - def count_down_10_seconds_blocking(ls, *args): - # Omitted + @json_server.thread() + @json_server.command(JsonLanguageServer.CMD_COUNT_DOWN_BLOCKING) + def count_down_10_seconds_blocking(ls, *args): + # Omitted - *pygls* uses a **thread pool** to execute functions that are marked with - a ``thread`` decorator. + *pygls* uses a **thread pool** to execute functions that are marked with + a ``thread`` decorator. Non-Blocking Command Test @@ -191,10 +198,10 @@ executing the function. Modify the Example ~~~~~~~~~~~~~~~~~~ -We encourage you to continue to :ref:`advanced section ` and +We encourage you to continue to :ref:`user guide ` and modify this example. -.. _json-extension: https://github.com/openlawlibrary/pygls/blob/master/examples/json-vscode-extension -.. _README: https://github.com/openlawlibrary/pygls/blob/master/examples/json-vscode-extension/README.md -.. _server.py: https://github.com/openlawlibrary/pygls/blob/master/examples/json-vscode-extension/server/server.py +.. _vscode-playground: https://github.com/openlawlibrary/pygls/blob/main/examples/vscode-playground +.. _README: https://github.com/openlawlibrary/pygls/blob/main/examples/vscode-playground/README.md +.. _server.py: https://github.com/openlawlibrary/pygls/blob/main/examples/servers/json_server.py .. _cooperative multitasking: https://en.wikipedia.org/wiki/Cooperative_multitasking diff --git a/docs/source/pages/user-guide.rst b/docs/source/pages/user-guide.rst new file mode 100644 index 00000000..efe26cab --- /dev/null +++ b/docs/source/pages/user-guide.rst @@ -0,0 +1,536 @@ +.. _user-guide: + +User Guide +========== + +Language Server +--------------- + +The language server is responsible for managing the connection with the client as well as sending and receiving messages over +the `Language Server Protocol `__ +which is based on the `Json RPC protocol `__. + +Connections +~~~~~~~~~~~ + +*pygls* supports :ref:`ls-tcp`, :ref:`ls-stdio` and :ref:`ls-websocket` connections. + +.. _ls-tcp: + +TCP +^^^ + +TCP connections are usually used while developing the language server. +This way the server can be started in *debug* mode separately and wait +for the client connection. + +.. note:: Server should be started **before** the client. + +The code snippet below shows how to start the server in *TCP* mode. + +.. code:: python + + from pygls.server import LanguageServer + + server = LanguageServer('example-server', 'v0.1') + + server.start_tcp('127.0.0.1', 8080) + +.. _ls-stdio: + +STDIO +^^^^^ + +STDIO connections are useful when client is starting the server as a child +process. This is the way to go in production. + +The code snippet below shows how to start the server in *STDIO* mode. + +.. code:: python + + from pygls.server import LanguageServer + + server = LanguageServer('example-server', 'v0.1') + + server.start_io() + +.. _ls-websocket: + +WEBSOCKET +^^^^^^^^^ + +WEBSOCKET connections are used when you want to expose language server to +browser based editors. + +The code snippet below shows how to start the server in *WEBSOCKET* mode. + +.. code:: python + + from pygls.server import LanguageServer + + server = LanguageServer('example-server', 'v0.1') + + server.start_ws('0.0.0.0', 1234) + +Logging +~~~~~~~ + +Logs are useful for tracing client requests, finding out errors and +measuring time needed to return results to the client. + +*pygls* uses built-in python *logging* module which has to be configured +before server is started. + +Official documentation about logging in python can be found +`here `__. Below +is the minimal setup to setup logging in *pygls*: + +.. code:: python + + import logging + + from pygls.server import LanguageServer + + logging.basicConfig(filename='pygls.log', filemode='w', level=logging.DEBUG) + + server = LanguageServer('example-server', 'v0.1') + + server.start_io() + +Overriding ``LanguageServerProtocol`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If you have a reason to override the existing ``LanguageServerProtocol`` class, +you can do that by inheriting the class and passing it to the ``LanguageServer`` +constructor. + +Custom Error Reporting +~~~~~~~~~~~~~~~~~~~~~~ + +The default :class:`~pygls.server.LanguageServer` will send a :lsp:`window/showMessage` notification to the client to display any uncaught exceptions in the server. +To override this behaviour define your own :meth:`~pygls.server.LanguageServer.report_server_error` method like so: + +.. code:: python + + class CustomLanguageServer(LanguageServer): + def report_server_error(self, error: Exception, source: Union[PyglsError, JsonRpcException]): + pass + +Handling Client Messages +------------------------ + +.. admonition:: Requests vs Notifications + + Unlike a *request*, a *notification* message has no ``id`` field and the server *must not* reply to it. + This means that, even if you return a result inside a handler function for a notification, the result won't be passed to the client. + + The ``Language Server Protocol``, unlike ``Json RPC``, allows bidirectional communication between the server and the client. + +For the majority of the time, a language server will be responding to requests and notifications sent from the client. +*pygls* refers to the handlers for all of these messages as *features* with one exception. + +The Language Server protocol allows a server to define named methods that a client can invoke by sending a :lsp:`workspace/executeCommand` request. +Unsurprisingly, *pygls* refers to these named methods a *commands*. + +*Built-In* Features +~~~~~~~~~~~~~~~~~~~ + +*pygls* comes with following predefined set of handlers for the following +`Language Server Protocol `__ +(LSP) features: + +.. note:: + + *Built-in* features in most cases should *not* be overridden. + + If you need to do some additional processing of one of the messages listed below, register a feature with the same name and your handler will be called immediately after the corresponding built-in feature. + +**Lifecycle Messages** + +- The :lsp:`initialize` request is sent as a first request from client to the server to setup their communication. + *pygls* automatically computes registered LSP capabilities and sends them as part of the :class:`~lsprotocol.types.InitializeResult` response. + +- The :lsp:`shutdown` request is sent from the client to the server to ask the server to shutdown. + +- The :lsp:`exit` notification is sent from client to the server to ask the server to exit the process. + *pygls* automatically releases all resources and stops the process. + +**Text Document Synchronization** + +- The :lsp:`textDocument/didOpen` notification will tell *pygls* to create a document in the in-memory workspace which will exist as long as the document is opened in editor. + +- The :lsp:`textDocument/didChange` notification will tell *pygls* to update the document text. + *pygls* supports *full* and *incremental* document changes. + +- The :lsp:`textDocument/didClose` notification will tell *pygls* to remove a document from the in-memory workspace. + +**Notebook Document Synchronization** + +- The :lsp:`notebookDocument/didOpen` notification will tell *pygls* to create a notebook document in the in-memory workspace which will exist as long as the document is opened in editor. + +- The :lsp:`notebookDocument/didChange` notification will tell *pygls* to update the notebook document include its content, metadata, execution results and cell structure. + +- The :lsp:`notebookDocument/didClose` notification will tell *pygls* to remove the notebook from the in-memory workspace. + +**Miscellanous** + +- The :lsp:`workspace/didChangeWorkspaceFolders` notification will tell *pygls* to update in-memory workspace folders. + +- The :lsp:`workspace/executeCommand` request will tell *pygls* to execute a custom command. + +- The :lsp:`$/setTrace` notification tells *pygls* to update the server's :class:`TraceValue `. + +.. _ls-handlers: + +Registering Handlers +~~~~~~~~~~~~~~~~~~~~ + +.. seealso:: + + It's recommeded that you follow the :ref:`tutorial ` before reading this section. + +- The :func:`~pygls.server.LanguageServer.feature` decorator is used to register a handler for a given LSP message. +- The :func:`~pygls.server.LanguageServer.command` decorator is used to register a named command. + +The following applies to both feature and command handlers. + +Language servers using *pygls* run in an *asyncio event loop*. +They *asynchronously* listen for incoming messages and, depending on the way handler is registered, apply different execution strategies to process the message. + +Depending on the use case, handlers can be registered in three different ways: + +- as an :ref:`async ` function +- as a :ref:`synchronous ` function +- as a :ref:`threaded ` function + +.. _ls-handler-async: + +*Asynchronous* Functions (*Coroutines*) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +*pygls* supports ``python 3.7+`` which has a keyword ``async`` to +specify coroutines. + +The code snippet below shows how to register a command as a coroutine: + +.. code:: python + + @json_server.command(JsonLanguageServer.CMD_COUNT_DOWN_NON_BLOCKING) + async def count_down_10_seconds_non_blocking(ls, *args): + # Omitted + +Registering a *feature* as a coroutine is exactly the same. + +Coroutines are functions that are executed as tasks in *pygls*'s *event +loop*. They should contain at least one *await* expression (see +`awaitables `__ +for details) which tells event loop to switch to another task while +waiting. This allows *pygls* to listen for client requests in a +*non blocking* way, while still only running in the *main* thread. + +Tasks can be canceled by the client if they didn't start executing (see +`Cancellation +Support `__). + +.. warning:: + + Using computation intensive operations will *block* the main thread and + should be *avoided* inside coroutines. Take a look at + `threaded functions <#threaded-functions>`__ for more details. + +.. _ls-handler-sync: + +*Synchronous* Functions +^^^^^^^^^^^^^^^^^^^^^^^ + +Synchronous functions are regular functions which *blocks* the *main* +thread until they are executed. + +`Built-in features <#built-in-features>`__ are registered as regular +functions to ensure correct state of language server initialization and +workspace. + +The code snippet below shows how to register a command as a regular +function: + +.. code:: python + + @json_server.command(JsonLanguageServer.CMD_COUNT_DOWN_BLOCKING) + def count_down_10_seconds_blocking(ls, *args): + # Omitted + +Registering *feature* as a regular function is exactly the same. + +.. warning:: + + Using computation intensive operations will *block* the main thread and + should be *avoided* inside regular functions. Take a look at + `threaded functions <#threaded-functions>`__ for more details. + +.. _ls-handler-thread: + +*Threaded* Functions +^^^^^^^^^^^^^^^^^^^^ + +*Threaded* functions are just regular functions, but marked with +*pygls*'s ``thread`` decorator: + +.. code:: python + + # Decorator order is not important in this case + @json_server.thread() + @json_server.command(JsonLanguageServer.CMD_COUNT_DOWN_BLOCKING) + def count_down_10_seconds_blocking(ls, *args): + # Omitted + +*pygls* uses its own *thread pool* to execute above function in *daemon* +thread and it is *lazy* initialized first time when function marked with +``thread`` decorator is fired. + +*Threaded* functions can be used to run blocking operations. If it has been a +while or you are new to threading in Python, check out Python's +``multithreading`` and `GIL `__ +before messing with threads. + +.. _passing-instance: + +Passing Language Server Instance +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Using language server methods inside registered features and commands are quite +common. We recommend adding language server as a **first parameter** of a +registered function. + +There are two ways of doing this: + +- **ls** (**l**\anguage **s**\erver) naming convention + +Add **ls** as first parameter of a function and *pygls* will automatically pass +the language server instance. + +.. code-block:: python + + @json_server.command(JsonLanguageServer.CMD_COUNT_DOWN_BLOCKING) + def count_down_10_seconds_blocking(ls, *args): + # Omitted + + +- add **type** to first parameter + +Add the **LanguageServer** class or any class derived from it as a type to +first parameter of a function + +.. code-block:: python + + @json_server.command(JsonLanguageServer.CMD_COUNT_DOWN_BLOCKING) + def count_down_10_seconds_blocking(ser: JsonLanguageServer, *args): + # Omitted + + +Using outer ``json_server`` instance inside registered function will make +writing unit :ref:`tests ` more difficult. + +Communicating with the Client +----------------------------- + +.. important:: + + Most of the messages listed here cannot be sent until the LSP session has been initialized. + See the section on the :lsp:`initiaiize` request in the specification for more details. + +In addition to responding to requests, there are a number of additional messages a server can send to the client. + +Configuration +~~~~~~~~~~~~~ + +The :lsp:`workspace/configuration` request is sent from the server to the client in order to fetch configuration settings from the client. +Depending on how the handler is registered (see :ref:`here `) you can use the :meth:`~pygls.server.LanguageServer.get_configuration` or :meth:`~pygls.server.LanguageServer.get_configuration_async` methods to request configuration from the client: + +- *asynchronous* functions (*coroutines*) + + .. code:: python + + # await keyword tells event loop to switch to another task until notification is received + config = await ls.get_configuration( + WorkspaceConfigurationParams( + items=[ + ConfigurationItem(scope_uri='doc_uri_here', section='section') + ] + ) + ) + +- *synchronous* functions + + .. code:: python + + # callback is called when notification is received + def callback(config): + # Omitted + + params = WorkspaceConfigurationParams( + items=[ + ConfigurationItem(scope_uri='doc_uri_here', section='section') + ] + ) + config = ls.get_configuration(params, callback) + +- *threaded* functions + + .. code:: python + + # .result() will block the thread + config = ls.get_configuration( + WorkspaceConfigurationParams( + items=[ + ConfigurationItem(scope_uri='doc_uri_here', section='section') + ] + ) + ).result() + +Publish Diagnostics +~~~~~~~~~~~~~~~~~~~ + +:lsp:`textDocument/publishDiagnostics` notifications are sent from the server to the client to highlight errors or potential issues. e.g. syntax errors or unused variables. + +Usually this notification is sent after document is opened, or on document content change: + +.. code:: python + + @json_server.feature(TEXT_DOCUMENT_DID_OPEN) + async def did_open(ls, params: DidOpenTextDocumentParams): + """Text document did open notification.""" + ls.show_message("Text Document Did Open") + ls.show_message_log("Validating json...") + + # Get document from workspace + text_doc = ls.workspace.get_text_document(params.text_document.uri) + + diagnostic = Diagnostic( + range=Range( + start=Position(line-1, col-1), + end=Position(line-1, col) + ), + message="Custom validation message", + source="Json Server" + ) + + # Send diagnostics + ls.publish_diagnostics(text_doc.uri, [diagnostic]) + +Show Message +~~~~~~~~~~~~ + +:lsp:`window/showMessage` is a notification that is sent from the server to the client to display a prominant text message. e.g. VSCode will render this as a notification popup + +.. code:: python + + @json_server.command(JsonLanguageServer.CMD_COUNT_DOWN_NON_BLOCKING) + async def count_down_10_seconds_non_blocking(ls, *args): + for i in range(10): + # Sends message notification to the client + ls.show_message(f"Counting down... {10 - i}") + await asyncio.sleep(1) + +Show Message Log +~~~~~~~~~~~~~~~~ + +:lsp:`window/logMessage` is a notification that is sent from the server to the client to display a discrete text message. e.g. VSCode will display the message in an :guilabel:`Output` channel. + +.. code:: python + + @json_server.command(JsonLanguageServer.CMD_COUNT_DOWN_NON_BLOCKING) + async def count_down_10_seconds_non_blocking(ls, *args): + for i in range(10): + # Sends message log notification to the client + ls.show_message_log(f"Counting down... {10 - i}") + await asyncio.sleep(1) + +Workspace Edits +~~~~~~~~~~~~~~~ + +The :lsp:`workspace/applyEdit` request allows your language server to ask the client to modify particular documents in the client's workspace. + +.. code:: python + + def apply_edit(self, edit: WorkspaceEdit, label: str = None) -> ApplyWorkspaceEditResponse: + # Omitted + + def apply_edit_async(self, edit: WorkspaceEdit, label: str = None) -> ApplyWorkspaceEditResponse: + # Omitted + +Custom Notifications +~~~~~~~~~~~~~~~~~~~~ + +.. warning:: + + Custom notifications are not part of the LSP specification and dedicated support for your custom notification(s) will have to be added to each language client you intend to support. + +A custom notification can be sent to the client using the :meth:`~pygls.server.LanguageServer.send_notification` method + +.. code:: python + + server.send_notification('myCustomNotification', 'test data') + + +The Workspace +------------- + +The :class:`~pygls.workspace.Workspace` is a python object that holds information about workspace folders, opened documents and is responsible for synchronising server side document state with that of the client. + +**Text Documents** + +The :class:`~pygls.workspace.TextDocument` class is how *pygls* represents a text document. +Given a text document's uri the :meth:`~pygls.workspace.Workspace.get_text_document` method can be used to access the document itself: + +.. code:: python + + @json_server.feature(TEXT_DOCUMENT_DID_OPEN) + async def did_open(ls, params: DidOpenTextDocumentParams): + + # Get document from workspace + text_doc = ls.workspace.get_text_document(params.text_document.uri) + +**Notebook Documents** + +.. seealso:: + + See the section on :lsp:`notebookDocument/synchronization` in the specification for full details on how notebook documents are handled + +- A notebook's structure, metadata etc. is represented using the :class:`~lsprotocol.types.NotebookDocument` class from ``lsprotocol``. +- The contents of a single notebook cell is represented using a standard :class:`~pygls.workspace.TextDocument` + +In order to receive notebook documents from the client, your language server must provide an instance of :class:`~lsprotocol.types.NotebookDocumentSyncOptions` which declares the kind of notebooks it is interested in + +.. code-block:: python + + server = LanguageServer( + name="example-server", + version="v0.1", + notebook_document_sync=types.NotebookDocumentSyncOptions( + notebook_selector=[ + types.NotebookDocumentSyncOptionsNotebookSelectorType2( + cells=[ + types.NotebookDocumentSyncOptionsNotebookSelectorType2CellsType( + language="python" + ) + ] + ) + ] + ), + ) + +To access the contents of a notebook cell you would call the workspace's :meth:`~pygls.workspace.Workspace.get_text_document` method as normal. + +.. code-block:: python + + cell_doc = ls.workspace.get_text_document(cell_uri) + +To access the notebook itself call the workspace's :meth:`~pygls.workspace.Workspace.get_notebook_document` method with either the uri of the notebook *or* the uri of any of its cells. + +.. code-block:: python + + notebook_doc = ls.workspace.get_notebook_document(notebook_uri=notebook_uri) + + # -- OR -- + + notebook_doc = ls.workspace.get_notebook_document(cell_uri=cell_uri) diff --git a/examples/servers/inlay_hints.py b/examples/servers/inlay_hints.py index a5e3a6c2..e9924dd7 100644 --- a/examples/servers/inlay_hints.py +++ b/examples/servers/inlay_hints.py @@ -17,18 +17,29 @@ import re from typing import Optional +from lsprotocol import types + from pygls.server import LanguageServer -from lsprotocol.types import ( - INLAY_HINT_RESOLVE, - TEXT_DOCUMENT_INLAY_HINT, - InlayHint, - InlayHintKind, - InlayHintParams, - Position, -) NUMBER = re.compile(r"\d+") -server = LanguageServer("inlay-hint-server", "v0.1") +COMMENT = re.compile(r"^#$") + + +server = LanguageServer( + name="inlay-hint-server", + version="v0.1", + notebook_document_sync=types.NotebookDocumentSyncOptions( + notebook_selector=[ + types.NotebookDocumentSyncOptionsNotebookSelectorType2( + cells=[ + types.NotebookDocumentSyncOptionsNotebookSelectorType2CellsType( + language="python" + ) + ] + ) + ] + ), +) def parse_int(chars: str) -> Optional[int]: @@ -38,17 +49,36 @@ def parse_int(chars: str) -> Optional[int]: return None -@server.feature(TEXT_DOCUMENT_INLAY_HINT) -def inlay_hints(params: InlayHintParams): +@server.feature(types.TEXT_DOCUMENT_INLAY_HINT) +def inlay_hints(params: types.InlayHintParams): items = [] document_uri = params.text_document.uri - document = server.workspace.get_document(document_uri) + document = server.workspace.get_text_document(document_uri) start_line = params.range.start.line end_line = params.range.end.line lines = document.lines[start_line : end_line + 1] for lineno, line in enumerate(lines): + match = COMMENT.match(line) + if match is not None: + nb = server.workspace.get_notebook_document(cell_uri=document_uri) + if nb is not None: + idx = 0 + for idx, cell in enumerate(nb.cells): + if cell.document == document_uri: + break + + items.append( + types.InlayHint( + label=f"notebook: {nb.uri}, cell {idx+1}", + kind=types.InlayHintKind.Type, + padding_left=False, + padding_right=True, + position=types.Position(line=lineno, character=match.end()), + ) + ) + for match in NUMBER.finditer(line): if not match: continue @@ -59,22 +89,26 @@ def inlay_hints(params: InlayHintParams): binary_num = bin(number).split("b")[1] items.append( - InlayHint( + types.InlayHint( label=f":{binary_num}", - kind=InlayHintKind.Type, + kind=types.InlayHintKind.Type, padding_left=False, padding_right=True, - position=Position(line=lineno, character=match.end()), + position=types.Position(line=lineno, character=match.end()), ) ) return items -@server.feature(INLAY_HINT_RESOLVE) -def inlay_hint_resolve(hint: InlayHint): - n = int(hint.label[1:], 2) - hint.tooltip = f"Binary representation of the number: {n}" +@server.feature(types.INLAY_HINT_RESOLVE) +def inlay_hint_resolve(hint: types.InlayHint): + try: + n = int(hint.label[1:], 2) + hint.tooltip = f"Binary representation of the number: {n}" + except Exception: + pass + return hint diff --git a/examples/servers/json_server.py b/examples/servers/json_server.py index 93a171f4..d14ec699 100644 --- a/examples/servers/json_server.py +++ b/examples/servers/json_server.py @@ -110,7 +110,7 @@ def workspace_diagnostic( params: lsp.WorkspaceDiagnosticParams, ) -> lsp.WorkspaceDiagnosticReport: """Returns diagnostic report.""" - first = list(json_server.workspace._docs.keys())[0] + first = list(json_server.workspace.text_documents.keys())[0] document = json_server.workspace.get_document(first) return lsp.WorkspaceDiagnosticReport( items=[ diff --git a/examples/workspace/Untitled-1.ipynb b/examples/workspace/Untitled-1.ipynb new file mode 100644 index 00000000..d45e746f --- /dev/null +++ b/examples/workspace/Untitled-1.ipynb @@ -0,0 +1,44 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "12\n", + "#" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "mykey": 3 + }, + "outputs": [], + "source": [ + "#" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python", + "version": "3.11.4" + }, + "orig_nbformat": 4 + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/pygls/capabilities.py b/pygls/capabilities.py index 8adcb575..c36832b0 100644 --- a/pygls/capabilities.py +++ b/pygls/capabilities.py @@ -15,7 +15,7 @@ # limitations under the License. # ############################################################################ from functools import reduce -from typing import Any +from typing import Any, Dict, List, Set, Union from lsprotocol.types import ( INLAY_HINT_RESOLVE, @@ -72,12 +72,14 @@ DocumentLinkOptions, ExecuteCommandOptions, ImplementationOptions, + NotebookDocumentSyncOptions, SemanticTokensOptions, SemanticTokensRegistrationOptions, SemanticTokensOptionsFullType1, ServerCapabilities, ServerCapabilitiesWorkspaceType, SignatureHelpOptions, + TextDocumentSyncKind, TextDocumentSyncOptions, TypeDefinitionOptions, FileOperationOptions, @@ -107,13 +109,20 @@ class ServerCapabilitiesBuilder: """ def __init__( - self, client_capabilities, features, feature_options, commands, sync_kind + self, + client_capabilities: ClientCapabilities, + features: Set[str], + feature_options: Dict[str, Any], + commands: List[str], + text_document_sync_kind: TextDocumentSyncKind, + notebook_document_sync: NotebookDocumentSyncOptions, ): self.client_capabilities = client_capabilities self.features = features self.feature_options = feature_options self.commands = commands - self.sync_kind = sync_kind + self.text_document_sync_kind = text_document_sync_kind + self.notebook_document_sync = notebook_document_sync self.server_cap = ServerCapabilities() @@ -122,7 +131,7 @@ def _provider_options(self, feature, default=True): return self.feature_options.get(feature, default) return None - def _with_text_doc_sync(self): + def _with_text_document_sync(self): open_close = ( TEXT_DOCUMENT_DID_OPEN in self.features or TEXT_DOCUMENT_DID_CLOSE in self.features @@ -147,7 +156,7 @@ def _with_text_doc_sync(self): self.server_cap.text_document_sync = TextDocumentSyncOptions( open_close=open_close, - change=self.sync_kind, + change=self.text_document_sync_kind, will_save=will_save, will_save_wait_until=will_save_wait_until, save=save, @@ -155,6 +164,13 @@ def _with_text_doc_sync(self): return self + def _with_notebook_document_sync(self): + if self.client_capabilities.notebook_document is None: + return self + + self.server_cap.notebook_document_sync = self.notebook_document_sync + return self + def _with_completion(self): value = self._provider_options( TEXT_DOCUMENT_COMPLETION, default=CompletionOptions() @@ -333,10 +349,12 @@ def _with_semantic_tokens(self): self.server_cap.semantic_tokens_provider = value return self + full_support: Union[bool, SemanticTokensOptionsFullType1] = ( + TEXT_DOCUMENT_SEMANTIC_TOKENS_FULL in self.features + ) + if TEXT_DOCUMENT_SEMANTIC_TOKENS_FULL_DELTA in self.features: full_support = SemanticTokensOptionsFullType1(delta=True) - else: - full_support = TEXT_DOCUMENT_SEMANTIC_TOKENS_FULL in self.features options = SemanticTokensOptions( legend=value, @@ -416,7 +434,8 @@ def _build(self): def build(self): return ( - self._with_text_doc_sync() + self._with_text_document_sync() + ._with_notebook_document_sync() ._with_completion() ._with_hover() ._with_signature_help() diff --git a/pygls/exceptions.py b/pygls/exceptions.py index 533640e6..33fe2946 100644 --- a/pygls/exceptions.py +++ b/pygls/exceptions.py @@ -17,6 +17,8 @@ # limitations under the License. # ############################################################################ import traceback +from typing import Set +from typing import Type class JsonRpcException(Exception): @@ -65,7 +67,7 @@ def to_dict(self): class JsonRpcInternalError(JsonRpcException): - CODE = -32602 + CODE = -32603 MESSAGE = "Internal Error" @classmethod @@ -158,7 +160,7 @@ def _is_server_error_code(code): return -32099 <= code <= -32000 -_EXCEPTIONS = ( +_EXCEPTIONS: Set[Type[JsonRpcException]] = { JsonRpcInternalError, JsonRpcInvalidParams, JsonRpcInvalidRequest, @@ -166,7 +168,7 @@ def _is_server_error_code(code): JsonRpcParseError, JsonRpcRequestCancelled, JsonRpcServerError, -) +} class PyglsError(Exception): diff --git a/pygls/protocol.py b/pygls/protocol.py index 28b7d907..b15757b0 100644 --- a/pygls/protocol.py +++ b/pygls/protocol.py @@ -54,6 +54,9 @@ INITIALIZE, INITIALIZED, METHOD_TO_TYPES, + NOTEBOOK_DOCUMENT_DID_CHANGE, + NOTEBOOK_DOCUMENT_DID_CLOSE, + NOTEBOOK_DOCUMENT_DID_OPEN, LOG_TRACE, SET_TRACE, SHUTDOWN, @@ -74,9 +77,12 @@ from lsprotocol.types import ( ApplyWorkspaceEditParams, Diagnostic, + DidChangeNotebookDocumentParams, DidChangeTextDocumentParams, DidChangeWorkspaceFoldersParams, + DidCloseNotebookDocumentParams, DidCloseTextDocumentParams, + DidOpenNotebookDocumentParams, DidOpenTextDocumentParams, ExecuteCommandParams, InitializeParams, @@ -497,16 +503,16 @@ def _send_data(self, data): body = json.dumps(data, default=self._serialize_message) logger.info("Sending data: %s", body) - body = body.encode(self.CHARSET) - if not self._send_only_body: - header = ( - f"Content-Length: {len(body)}\r\n" - f"Content-Type: {self.CONTENT_TYPE}; charset={self.CHARSET}\r\n\r\n" - ).encode(self.CHARSET) + if self._send_only_body: + self.transport.write(body) + return - self.transport.write(header + body) - else: - self.transport.write(body.decode("utf-8")) + header = ( + f"Content-Length: {len(body)}\r\n" + f"Content-Type: {self.CONTENT_TYPE}; charset={self.CHARSET}\r\n\r\n" + ).encode(self.CHARSET) + + self.transport.write(header + body.encode(self.CHARSET)) except Exception as error: logger.exception("Error sending data", exc_info=True) self._server._report_server_error(error, JsonRpcInternalError) @@ -625,7 +631,7 @@ def send_request(self, method, params=None, callback=None, msg_id=None): jsonrpc=JsonRPCProtocol.VERSION, ) - future = Future() + future = Future() # type: ignore[var-annotated] # If callback function is given, call it when result is received if callback: @@ -704,7 +710,7 @@ class LanguageServerProtocol(JsonRPCProtocol, metaclass=LSPMeta): def __init__(self, server, converter): super().__init__(server, converter) - self.workspace = None + self._workspace: Optional[Workspace] = None self.trace = None from pygls.progress import Progress @@ -721,10 +727,22 @@ def __init__(self, server, converter): def _register_builtin_features(self): """Registers generic LSP features from this class.""" for name in dir(self): + if name in {"workspace"}: + continue + attr = getattr(self, name) if callable(attr) and hasattr(attr, "method_name"): self.fm.add_builtin_feature(attr.method_name, attr) + @property + def workspace(self) -> Workspace: + if self._workspace is None: + raise RuntimeError( + "The workspace is not available - has the server been initialized?" + ) + + return self._workspace + @lru_cache() def get_message_type(self, method: str) -> Optional[Type]: """Return LSP type definitions, as provided by `lsprotocol`""" @@ -768,14 +786,18 @@ def lsp_initialize(self, params: InitializeParams) -> InitializeResult: self._server.process_id = params.process_id + text_document_sync_kind = self._server._text_document_sync_kind + notebook_document_sync = self._server._notebook_document_sync + # Initialize server capabilities self.client_capabilities = params.capabilities self.server_capabilities = ServerCapabilitiesBuilder( self.client_capabilities, - {**self.fm.features, **self.fm.builtin_features}.keys(), + set({**self.fm.features, **self.fm.builtin_features}.keys()), self.fm.feature_options, list(self.fm.commands.keys()), - self._server.sync_kind, + text_document_sync_kind, + notebook_document_sync, ).build() logger.debug( "Server capabilities: %s", @@ -787,7 +809,9 @@ def lsp_initialize(self, params: InitializeParams) -> InitializeResult: # Initialize the workspace workspace_folders = params.workspace_folders or [] - self.workspace = Workspace(root_uri, self._server.sync_kind, workspace_folders) + self._workspace = Workspace( + root_uri, text_document_sync_kind, workspace_folders + ) self.trace = TraceValues.Off @@ -818,17 +842,38 @@ def lsp_text_document__did_change( (Incremental(from server capabilities); not configurable for now) """ for change in params.content_changes: - self.workspace.update_document(params.text_document, change) + self.workspace.update_text_document(params.text_document, change) @lsp_method(TEXT_DOCUMENT_DID_CLOSE) def lsp_text_document__did_close(self, params: DidCloseTextDocumentParams) -> None: """Removes document from workspace.""" - self.workspace.remove_document(params.text_document.uri) + self.workspace.remove_text_document(params.text_document.uri) @lsp_method(TEXT_DOCUMENT_DID_OPEN) def lsp_text_document__did_open(self, params: DidOpenTextDocumentParams) -> None: """Puts document to the workspace.""" - self.workspace.put_document(params.text_document) + self.workspace.put_text_document(params.text_document) + + @lsp_method(NOTEBOOK_DOCUMENT_DID_OPEN) + def lsp_notebook_document__did_open( + self, params: DidOpenNotebookDocumentParams + ) -> None: + """Put a notebook document into the workspace""" + self.workspace.put_notebook_document(params) + + @lsp_method(NOTEBOOK_DOCUMENT_DID_CHANGE) + def lsp_notebook_document__did_change( + self, params: DidChangeNotebookDocumentParams + ) -> None: + """Update a notebook's contents""" + self.workspace.update_notebook_document(params) + + @lsp_method(NOTEBOOK_DOCUMENT_DID_CLOSE) + def lsp_notebook_document__did_close( + self, params: DidCloseNotebookDocumentParams + ) -> None: + """Remove a notebook document from the workspace.""" + self.workspace.remove_notebook_document(params) @lsp_method(SET_TRACE) def lsp_set_trace(self, params: SetTraceParams) -> None: @@ -958,10 +1003,24 @@ def publish_diagnostics( version: Optional[int] = None, **kwargs, ): - """ - Sends diagnostic notification to the client. - Deprecation: - `uri`, `diagnostics` and `version` fields will be deprecated + """Sends diagnostic notification to the client. + + .. deprecated:: 1.0.1 + + Passing ``(uri, diagnostics, version)`` as arguments is deprecated. + Pass an instance of :class:`~lsprotocol.types.PublishDiagnosticParams` + instead. + + Parameters + ---------- + params_or_uri + The :class:`~lsprotocol.types.PublishDiagnosticParams` to send to the client. + + diagnostics + *Deprecated*. The diagnostics to publish + + version + *Deprecated*: The version number """ params = self._publish_diagnostics_deprecator( params_or_uri, diagnostics, version, **kwargs diff --git a/pygls/server.py b/pygls/server.py index 4993ad87..b26c9a4b 100644 --- a/pygls/server.py +++ b/pygls/server.py @@ -21,8 +21,18 @@ import sys from concurrent.futures import Future, ThreadPoolExecutor from threading import Event -from typing import Any, Callable, List, Optional, TextIO, TypeVar, Union +from typing import ( + Any, + Callable, + List, + Optional, + TextIO, + Type, + TypeVar, + Union, +) +import cattrs from pygls import IS_PYODIDE from pygls.lsp import ConfigCallbackType, ShowDocumentCallbackType from pygls.exceptions import PyglsError, JsonRpcException, FeatureRequestError @@ -30,6 +40,7 @@ ClientCapabilities, Diagnostic, MessageType, + NotebookDocumentSyncOptions, RegistrationParams, ServerCapabilities, ShowDocumentParams, @@ -40,7 +51,7 @@ WorkspaceConfigurationParams, ) from pygls.progress import Progress -from pygls.protocol import LanguageServerProtocol, default_converter +from pygls.protocol import JsonRPCProtocol, LanguageServerProtocol, default_converter from pygls.workspace import Workspace if not IS_PYODIDE: @@ -147,52 +158,43 @@ def write(self, data: Any) -> None: class Server: - """Class that represents async server. It can be started using TCP or IO. - - Args: - protocol_cls(Protocol): Protocol implementation that must be derived - from `asyncio.Protocol` + """Base server class - converter_factory: Factory function to use when constructing a cattrs converter. + Parameters + ---------- + protocol_cls + Protocol implementation that must be derive from :class:`~pygls.protocol.JsonRPCProtocol` - loop(AbstractEventLoop): asyncio event loop + converter_factory + Factory function to use when constructing a cattrs converter. - max_workers(int, optional): Number of workers for `ThreadPool` and - `ThreadPoolExecutor` + loop + The asyncio event loop - sync_kind(TextDocumentSyncKind): Text document synchronization option - - None(0): no synchronization - - Full(1): replace whole text - - Incremental(2): replace text within a given range + max_workers + Maximum number of workers for `ThreadPool` and `ThreadPoolExecutor` - Attributes: - _max_workers(int): Number of workers for thread pool executor - _server(Server): Server object which can be used to stop the process - _stop_event(Event): Event used for stopping `aio_readline` - _thread_pool(ThreadPool): Thread pool for executing methods decorated - with `@ls.thread()` - lazy instantiated - _thread_pool_executor(ThreadPoolExecutor): Thread pool executor - passed to `run_in_executor` - - lazy instantiated """ def __init__( self, - protocol_cls, - converter_factory, - loop=None, - max_workers=2, - sync_kind=TextDocumentSyncKind.Incremental, + protocol_cls: Type[JsonRPCProtocol], + converter_factory: Callable[[], cattrs.Converter], + loop: Optional[asyncio.AbstractEventLoop] = None, + max_workers: int = 2, + sync_kind: TextDocumentSyncKind = TextDocumentSyncKind.Incremental, ): if not issubclass(protocol_cls, asyncio.Protocol): raise TypeError("Protocol class should be subclass of asyncio.Protocol") self._max_workers = max_workers self._server = None - self._stop_event = None - self._thread_pool = None - self._thread_pool_executor = None - self.sync_kind = sync_kind + self._stop_event: Optional[Event] = None + self._thread_pool: Optional[ThreadPool] = None + self._thread_pool_executor: Optional[ThreadPoolExecutor] = None + + if sync_kind is not None: + self.text_document_sync_kind = sync_kind if loop is None: loop = asyncio.new_event_loop() @@ -208,7 +210,8 @@ def shutdown(self): """Shutdown server.""" logger.info("Shutting down the server") - self._stop_event.set() + if self._stop_event is not None: + self._stop_event.set() if self._thread_pool: self._thread_pool.terminate() @@ -221,7 +224,7 @@ def shutdown(self): self._server.close() self.loop.run_until_complete(self._server.wait_closed()) - if self._owns_loop and not self.loop.is_closed: + if self._owns_loop and not self.loop.is_closed(): logger.info("Closing the event loop.") self.loop.close() @@ -233,7 +236,7 @@ def start_io(self, stdin: Optional[TextIO] = None, stdout: Optional[TextIO] = No transport = StdOutTransportAdapter( stdin or sys.stdin.buffer, stdout or sys.stdout.buffer ) - self.lsp.connection_made(transport) + self.lsp.connection_made(transport) # type: ignore[arg-type] try: self.loop.run_until_complete( @@ -258,7 +261,7 @@ def start_pyodide(self): # Note: We don't actually start anything running as the main event # loop will be handled by the web platform. transport = PyodideTransportAdapter(sys.stdout) - self.lsp.connection_made(transport) + self.lsp.connection_made(transport) # type: ignore[arg-type] self.lsp._send_only_body = True # Don't send headers within the payload def start_tcp(self, host: str, port: int) -> None: @@ -266,7 +269,7 @@ def start_tcp(self, host: str, port: int) -> None: logger.info("Starting TCP server on %s:%s", host, port) self._stop_event = Event() - self._server = self.loop.run_until_complete( + self._server = self.loop.run_until_complete( # type: ignore[assignment] self.loop.create_server(self.lsp, host, port) ) try: @@ -298,7 +301,7 @@ async def connection_made(websocket, _): ) start_server = serve(connection_made, host, port, loop=self.loop) - self._server = start_server.ws_server + self._server = start_server.ws_server # type: ignore[assignment] self.loop.run_until_complete(start_server) try: @@ -331,17 +334,44 @@ def thread_pool_executor(self) -> ThreadPoolExecutor: class LanguageServer(Server): - """A class that represents Language server using Language Server Protocol. + """The default LanguageServer This class can be extended and it can be passed as a first argument to registered commands/features. - Args: - name(str): Name of the server - version(str): Version of the server - protocol_cls(LanguageServerProtocol): LSP or any subclass of it - max_workers(int, optional): Number of workers for `ThreadPool` and - `ThreadPoolExecutor` + .. |ServerInfo| replace:: :class:`~lsprotocol.types.InitializeResultServerInfoType` + + Parameters + ---------- + name + Name of the server, used to populate |ServerInfo| which is sent to + the client during initialization + + version + Version of the server, used to populate |ServerInfo| which is sent to + the client during initialization + + protocol_cls + The :class:`~pygls.protocol.LanguageServerProtocol` class definition, or any + subclass of it. + + max_workers + Maximum number of workers for ``ThreadPool`` and ``ThreadPoolExecutor`` + + text_document_sync_kind + Text document synchronization method + + None + No synchronization + + :attr:`~lsprotocol.types.TextDocumentSyncKind.Full` + Send entire document text with each update + + :attr:`~lsprotocol.types.TextDocumentSyncKind.Incremental` + Send only the region of text that changed with each update + + notebook_document_sync + Advertise :lsp:`NotebookDocument` support to the client. """ lsp: LanguageServerProtocol @@ -359,8 +389,10 @@ def __init__( name: str, version: str, loop=None, - protocol_cls=LanguageServerProtocol, + protocol_cls: Type[LanguageServerProtocol] = LanguageServerProtocol, converter_factory=default_converter, + text_document_sync_kind: TextDocumentSyncKind = TextDocumentSyncKind.Incremental, + notebook_document_sync: Optional[NotebookDocumentSyncOptions] = None, max_workers: int = 2, ): if not issubclass(protocol_cls, LanguageServerProtocol): @@ -370,6 +402,8 @@ def __init__( self.name = name self.version = version + self._text_document_sync_kind = text_document_sync_kind + self._notebook_document_sync = notebook_document_sync super().__init__(protocol_cls, converter_factory, loop, max_workers) def apply_edit( @@ -387,16 +421,19 @@ def apply_edit_async( def command(self, command_name: str) -> Callable[[F], F]: """Decorator used to register custom commands. - Example: - @ls.command('myCustomCommand') - def my_cmd(ls, a, b, c): - pass + Example + ------- + :: + + @ls.command('myCustomCommand') + def my_cmd(ls, a, b, c): + pass """ return self.lsp.fm.command(command_name) @property def client_capabilities(self) -> ClientCapabilities: - """Return client capabilities.""" + """The client's capabilities.""" return self.lsp.client_capabilities def feature( @@ -406,10 +443,13 @@ def feature( ) -> Callable[[F], F]: """Decorator used to register LSP features. - Example: - @ls.feature('textDocument/completion', CompletionOptions(trigger_characters=['.'])) - def completions(ls, params: CompletionParams): - return CompletionList(is_incomplete=False, items=[CompletionItem("Completion 1")]) + Example + ------- + :: + + @ls.feature('textDocument/completion', CompletionOptions(trigger_characters=['.'])) + def completions(ls, params: CompletionParams): + return CompletionList(is_incomplete=False, items=[CompletionItem("Completion 1")]) """ return self.lsp.fm.feature(feature_name, options) diff --git a/pygls/workspace.py b/pygls/workspace.py index 4fcdc4f4..fad94ef6 100644 --- a/pygls/workspace.py +++ b/pygls/workspace.py @@ -16,29 +16,23 @@ # See the License for the specific language governing permissions and # # limitations under the License. # ############################################################################ +import copy import io import logging import os import re -from typing import List, Optional, Pattern - -from lsprotocol.types import ( - Position, - Range, - TextDocumentContentChangeEvent, - TextDocumentContentChangeEvent_Type1, - TextDocumentItem, - TextDocumentSyncKind, - VersionedTextDocumentIdentifier, - WorkspaceFolder, -) +import warnings +from typing import Dict, List, Optional, Pattern + +from lsprotocol import types + from pygls.uris import to_fs_path, uri_scheme # TODO: this is not the best e.g. we capture numbers RE_END_WORD = re.compile("^[A-Za-z_0-9]*") RE_START_WORD = re.compile("[A-Za-z_0-9]*$") -log = logging.getLogger(__name__) +logger = logging.getLogger(__name__) def is_char_beyond_multilingual_plane(char: str) -> bool: @@ -63,7 +57,7 @@ def utf16_num_units(chars: str): return len(chars) + utf16_unit_offset(chars) -def position_from_utf16(lines: List[str], position: Position) -> Position: +def position_from_utf16(lines: List[str], position: types.Position) -> types.Position: """Convert the position.character from utf-16 code units to utf-32. A python application can't use the character member of `Position` @@ -89,9 +83,9 @@ def position_from_utf16(lines: List[str], position: Position) -> Position: The position with `character` being converted to utf-32 code units. """ if len(lines) == 0: - return Position(0, 0) + return types.Position(0, 0) if position.line >= len(lines): - return Position(len(lines) - 1, utf16_num_units(lines[-1])) + return types.Position(len(lines) - 1, utf16_num_units(lines[-1])) _line = lines[position.line] _line = _line.replace("\r\n", "\n") # TODO: it's a bit of a hack @@ -99,7 +93,7 @@ def position_from_utf16(lines: List[str], position: Position) -> Position: _utf32_len = len(_line) if _utf16_len == 0: - return Position(position.line, 0) + return types.Position(position.line, 0) _utf16_end_of_line = utf16_num_units(_line) if position.character > _utf16_end_of_line: @@ -124,11 +118,11 @@ def position_from_utf16(lines: List[str], position: Position) -> Position: _utf16_index += 1 utf32_index += 1 - position = Position(line=position.line, character=utf32_index) + position = types.Position(line=position.line, character=utf32_index) return position -def position_to_utf16(lines: List[str], position: Position) -> Position: +def position_to_utf16(lines: List[str], position: types.Position) -> types.Position: """Convert the position.character from utf-32 to utf-16 code units. A python application can't use the character member of `Position` @@ -154,16 +148,16 @@ def position_to_utf16(lines: List[str], position: Position) -> Position: The position with `character` being converted to utf-16 code units. """ try: - return Position( + return types.Position( line=position.line, character=position.character + utf16_unit_offset(lines[position.line][: position.character]), ) except IndexError: - return Position(line=len(lines), character=0) + return types.Position(line=len(lines), character=0) -def range_from_utf16(lines: List[str], range: Range) -> Range: +def range_from_utf16(lines: List[str], range: types.Range) -> types.Range: """Convert range.[start|end].character from utf-16 code units to utf-32. Arguments: @@ -175,14 +169,14 @@ def range_from_utf16(lines: List[str], range: Range) -> Range: Returns: The range with `character` offsets being converted to utf-16 code units. """ - range_new = Range( + range_new = types.Range( start=position_from_utf16(lines, range.start), end=position_from_utf16(lines, range.end), ) return range_new -def range_to_utf16(lines: List[str], range: Range) -> Range: +def range_to_utf16(lines: List[str], range: types.Range) -> types.Range: """Convert range.[start|end].character from utf-32 to utf-16 code units. Arguments: @@ -194,13 +188,13 @@ def range_to_utf16(lines: List[str], range: Range) -> Range: Returns: The range with `character` offsets being converted to utf-32 code units. """ - return Range( + return types.Range( start=position_to_utf16(lines, range.start), end=position_to_utf16(lines, range.end), ) -class Document(object): +class TextDocument(object): def __init__( self, uri: str, @@ -208,7 +202,7 @@ def __init__( version: Optional[int] = None, language_id: Optional[str] = None, local: bool = True, - sync_kind: TextDocumentSyncKind = TextDocumentSyncKind.Incremental, + sync_kind: types.TextDocumentSyncKind = types.TextDocumentSyncKind.Incremental, ): self.uri = uri self.version = version @@ -219,15 +213,17 @@ def __init__( self._local = local self._source = source - self._is_sync_kind_full = sync_kind == TextDocumentSyncKind.Full - self._is_sync_kind_incremental = sync_kind == TextDocumentSyncKind.Incremental - self._is_sync_kind_none = sync_kind == TextDocumentSyncKind.None_ + self._is_sync_kind_full = sync_kind == types.TextDocumentSyncKind.Full + self._is_sync_kind_incremental = ( + sync_kind == types.TextDocumentSyncKind.Incremental + ) + self._is_sync_kind_none = sync_kind == types.TextDocumentSyncKind.None_ def __str__(self): return str(self.uri) def _apply_incremental_change( - self, change: TextDocumentContentChangeEvent_Type1 + self, change: types.TextDocumentContentChangeEvent_Type1 ) -> None: """Apply an ``Incremental`` text change to the document""" lines = self.lines @@ -268,32 +264,35 @@ def _apply_incremental_change( self._source = new.getvalue() - def _apply_full_change(self, change: TextDocumentContentChangeEvent) -> None: + def _apply_full_change(self, change: types.TextDocumentContentChangeEvent) -> None: """Apply a ``Full`` text change to the document.""" self._source = change.text - def _apply_none_change(self, change: TextDocumentContentChangeEvent) -> None: + def _apply_none_change(self, change: types.TextDocumentContentChangeEvent) -> None: """Apply a ``None`` text change to the document Currently does nothing, provided for consistency. """ pass - def apply_change(self, change: TextDocumentContentChangeEvent) -> None: + def apply_change(self, change: types.TextDocumentContentChangeEvent) -> None: """Apply a text change to a document, considering TextDocumentSyncKind - Performs either ``Incremental``, ``Full``, or ``None`` synchronization based on - both the Client request and server capabilities. + Performs either + :attr:`~lsprotocol.types.TextDocumentSyncKind.Incremental`, + :attr:`~lsprotocol.types.TextDocumentSyncKind.Full`, or no synchronization + based on both the client request and server capabilities. + + .. admonition:: ``Incremental`` versus ``Full`` synchronization - ``Incremental`` versus ``Full`` synchronization: - Even if a server accepts ``Incremantal`` SyncKinds, clients may request - a ``Full`` SyncKind. In LSP 3.x, clients make this request by omitting - both Range and RangeLength from their request. Consequently, the - attributes "range" and "rangeLength" will be missing from ``Full`` - content update client requests in the pygls Python library. + Even if a server accepts ``Incremantal`` SyncKinds, clients may request + a ``Full`` SyncKind. In LSP 3.x, clients make this request by omitting + both Range and RangeLength from their request. Consequently, the + attributes "range" and "rangeLength" will be missing from ``Full`` + content update client requests in the pygls Python library. """ - if isinstance(change, TextDocumentContentChangeEvent_Type1): + if isinstance(change, types.TextDocumentContentChangeEvent_Type1): if self._is_sync_kind_incremental: self._apply_incremental_change(change) return @@ -301,7 +300,7 @@ def apply_change(self, change: TextDocumentContentChangeEvent) -> None: # assumptions in test_document/test_document_full_edit. Test breaks # otherwise, and fixing the tests would require a broader fix to # protocol.py. - log.error( + logger.error( "Unsupported client-provided TextDocumentContentChangeEvent. " "Please update / submit a Pull Request to your LSP client." ) @@ -315,7 +314,7 @@ def apply_change(self, change: TextDocumentContentChangeEvent) -> None: def lines(self) -> List[str]: return self.source.splitlines(True) - def offset_at_position(self, position: Position) -> int: + def offset_at_position(self, position: types.Position) -> int: """Return the character offset pointed at by the given position.""" lines = self.lines pos = position_from_utf16(lines, position) @@ -331,28 +330,38 @@ def source(self) -> str: def word_at_position( self, - position: Position, + position: types.Position, re_start_word: Pattern = RE_START_WORD, re_end_word: Pattern = RE_END_WORD, ) -> str: """Return the word at position. - Arguments: - position (Position): - The line and character offset. - re_start_word (Pattern): - The regular expression for extracting the word backward from - position. Specifically, the first match from a re.findall - call on the line up to the character value of position. The - default pattern is '[A-Za-z_0-9]*$'. - re_end_word (Pattern): - The regular expression for extracting the word forward from - position. Specifically, the last match from a re.findall - call on the line from the character value of position. The - default pattern is '^[A-Za-z_0-9]*'. - - Returns: - The word (obtained by concatenating the two matches) at position. + The word is constructed in two halves, the first half is found by taking + the first match of ``re_start_word`` on the line up until + ``position.character``. + + The second half is found by taking ``position.character`` up until the + last match of ``re_end_word`` on the line. + + :func:`python:re.findall` is used to find the matches. + + Parameters + ---------- + position + The line and character offset. + + re_start_word + The regular expression for extracting the word backward from + position. The default pattern is ``[A-Za-z_0-9]*$``. + + re_end_word + The regular expression for extracting the word forward from + position. The default pattern is ``^[A-Za-z_0-9]*``. + + Returns + ------- + str + The word (obtained by concatenating the two matches) at position. """ lines = self.lines if position.line >= len(lines): @@ -373,6 +382,10 @@ def word_at_position( return m_start[0] + m_end[-1] +# For backwards compatibility +Document = TextDocument + + class Workspace(object): def __init__(self, root_uri, sync_kind=None, workspace_folders=None): self._root_uri = root_uri @@ -380,20 +393,24 @@ def __init__(self, root_uri, sync_kind=None, workspace_folders=None): self._root_path = to_fs_path(self._root_uri) self._sync_kind = sync_kind self._folders = {} - self._docs = {} + self._text_documents: Dict[str, TextDocument] = {} + self._notebook_documents: Dict[str, types.NotebookDocument] = {} + + # Used to lookup notebooks which contain a given cell. + self._cell_in_notebook: Dict[str, str] = {} if workspace_folders is not None: for folder in workspace_folders: self.add_folder(folder) - def _create_document( + def _create_text_document( self, doc_uri: str, source: Optional[str] = None, version: Optional[int] = None, language_id: Optional[str] = None, - ) -> Document: - return Document( + ) -> TextDocument: + return TextDocument( doc_uri, source=source, version=version, @@ -401,43 +418,126 @@ def _create_document( sync_kind=self._sync_kind, ) - def add_folder(self, folder: WorkspaceFolder): + def add_folder(self, folder: types.WorkspaceFolder): self._folders[folder.uri] = folder @property def documents(self): - return self._docs + warnings.warn( + "'workspace.documents' has been deprecated, use " + "'workspace.text_documents' instead", + DeprecationWarning, + stacklevel=2, + ) + return self.text_documents + + @property + def notebook_documents(self): + return self._notebook_documents + + @property + def text_documents(self): + return self._text_documents @property def folders(self): return self._folders - def get_document(self, doc_uri: str) -> Document: + def get_notebook_document( + self, *, notebook_uri: Optional[str] = None, cell_uri: Optional[str] = None + ) -> Optional[types.NotebookDocument]: + """Return the notebook corresponding with the given uri. + + If both ``notebook_uri`` and ``cell_uri`` are given, ``notebook_uri`` takes + precedence. + + Parameters + ---------- + notebook_uri + If given, return the notebook document with the given uri. + + cell_uri + If given, return the notebook document which contains a cell with the + given uri + + Returns + ------- + Optional[NotebookDocument] + The requested notebook document if found, ``None`` otherwise. + """ + if notebook_uri is not None: + return self._notebook_documents.get(notebook_uri) + + if cell_uri is not None: + notebook_uri = self._cell_in_notebook.get(cell_uri) + if notebook_uri is None: + return None + + return self._notebook_documents.get(notebook_uri) + + return None + + def get_text_document(self, doc_uri: str) -> TextDocument: """ Return a managed document if-present, else create one pointing at disk. See https://github.com/Microsoft/language-server-protocol/issues/177 """ - return self._docs.get(doc_uri) or self._create_document(doc_uri) + return self._text_documents.get(doc_uri) or self._create_text_document(doc_uri) def is_local(self): return ( self._root_uri_scheme == "" or self._root_uri_scheme == "file" ) and os.path.exists(self._root_path) - def put_document(self, text_document: TextDocumentItem): + def put_notebook_document(self, params: types.DidOpenNotebookDocumentParams): + notebook = params.notebook_document + + # Create a fresh instance to ensure our copy cannot be accidentally modified. + self._notebook_documents[notebook.uri] = copy.deepcopy(notebook) + + for cell_document in params.cell_text_documents: + self.put_text_document(cell_document, notebook_uri=notebook.uri) + + def put_text_document( + self, + text_document: types.TextDocumentItem, + notebook_uri: Optional[str] = None, + ): + """Add a text document to the workspace. + + Parameters + ---------- + text_document + The text document to add + + notebook_uri + If set, indicates that this text document represents a cell in a notebook + document + """ doc_uri = text_document.uri - self._docs[doc_uri] = self._create_document( + self._text_documents[doc_uri] = self._create_text_document( doc_uri, source=text_document.text, version=text_document.version, language_id=text_document.language_id, ) - def remove_document(self, doc_uri: str): - self._docs.pop(doc_uri) + if notebook_uri: + self._cell_in_notebook[doc_uri] = notebook_uri + + def remove_notebook_document(self, params: types.DidCloseNotebookDocumentParams): + notebook_uri = params.notebook_document.uri + self._notebook_documents.pop(notebook_uri, None) + + for cell_document in params.cell_text_documents: + self.remove_text_document(cell_document.uri) + + def remove_text_document(self, doc_uri: str): + self._text_documents.pop(doc_uri, None) + self._cell_in_notebook.pop(doc_uri, None) def remove_folder(self, folder_uri: str): self._folders.pop(folder_uri, None) @@ -454,11 +554,95 @@ def root_path(self): def root_uri(self): return self._root_uri - def update_document( + def update_notebook_document(self, params: types.DidChangeNotebookDocumentParams): + uri = params.notebook_document.uri + notebook = self._notebook_documents[uri] + notebook.version = params.notebook_document.version + + if params.change.metadata: + notebook.metadata = params.change.metadata + + cell_changes = params.change.cells + if cell_changes is None: + return + + # Process changes to any cell metadata. + nb_cells = {cell.document: cell for cell in notebook.cells} + for new_data in cell_changes.data or []: + nb_cell = nb_cells.get(new_data.document) + if nb_cell is None: + logger.warning( + "Ignoring metadata for '%s': not in notebook.", new_data.document + ) + continue + + nb_cell.kind = new_data.kind + nb_cell.metadata = new_data.metadata + nb_cell.execution_summary = new_data.execution_summary + + # Process changes to the notebook's structure + structure = cell_changes.structure + if structure: + cells = notebook.cells + new_cells = structure.array.cells or [] + + # Re-order the cells + before = cells[: structure.array.start] + after = cells[(structure.array.start + structure.array.delete_count) :] + notebook.cells = [*before, *new_cells, *after] + + for new_cell in structure.did_open or []: + self.put_text_document(new_cell, notebook_uri=uri) + + for removed_cell in structure.did_close or []: + self.remove_text_document(removed_cell.uri) + + # Process changes to the text content of existing cells. + for text in cell_changes.text_content or []: + for change in text.changes: + self.update_text_document(text.document, change) + + def update_text_document( self, - text_doc: VersionedTextDocumentIdentifier, - change: TextDocumentContentChangeEvent, + text_doc: types.VersionedTextDocumentIdentifier, + change: types.TextDocumentContentChangeEvent, ): doc_uri = text_doc.uri - self._docs[doc_uri].apply_change(change) - self._docs[doc_uri].version = text_doc.version + self._text_documents[doc_uri].apply_change(change) + self._text_documents[doc_uri].version = text_doc.version + + def get_document(self, *args, **kwargs): + warnings.warn( + "'workspace.get_document' has been deprecated, use " + "'workspace.get_text_document' instead", + DeprecationWarning, + stacklevel=2, + ) + return self.get_text_document(*args, **kwargs) + + def remove_document(self, *args, **kwargs): + warnings.warn( + "'workspace.remove_document' has been deprecated, use " + "'workspace.remove_text_document' instead", + DeprecationWarning, + stacklevel=2, + ) + return self.remove_text_document(*args, **kwargs) + + def put_document(self, *args, **kwargs): + warnings.warn( + "'workspace.put_document' has been deprecated, use " + "'workspace.put_text_document' instead", + DeprecationWarning, + stacklevel=2, + ) + return self.put_text_document(*args, **kwargs) + + def update_document(self, *args, **kwargs): + warnings.warn( + "'workspace.update_document' has been deprecated, use " + "'workspace.update_text_document' instead", + DeprecationWarning, + stacklevel=2, + ) + return self.update_text_document(*args, **kwargs) diff --git a/pyproject.toml b/pyproject.toml index fa323a6e..93e4cfab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,6 +73,9 @@ line-length = 120 line-length = 88 extend-exclude = "pygls/lsp/client.py" +[tool.mypy] +check_untyped_defs = true + [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" diff --git a/tests/conftest.py b/tests/conftest.py index 1925e9d8..c816bd13 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -21,6 +21,7 @@ import sys import pytest +from lsprotocol import types from pygls import uris, IS_PYODIDE, IS_WIN from pygls.feature_manager import FeatureManager @@ -123,4 +124,7 @@ def feature_manager(): @pytest.fixture def workspace(tmpdir): """Return a workspace.""" - return Workspace(uris.from_fs_path(str(tmpdir))) + return Workspace( + uris.from_fs_path(str(tmpdir)), + sync_kind=types.TextDocumentSyncKind.Incremental, + ) diff --git a/tests/test_feature_manager.py b/tests/test_feature_manager.py index 24856c7f..d9655b85 100644 --- a/tests/test_feature_manager.py +++ b/tests/test_feature_manager.py @@ -616,6 +616,7 @@ def _(): feature_manager.feature_options, [], None, + None, ).build() assert expected == actual @@ -642,6 +643,7 @@ def _(): feature_manager.feature_options, [], None, + None, ).build() assert expected == actual diff --git a/tests/test_workspace.py b/tests/test_workspace.py index 5f9f09a4..53c5a52d 100644 --- a/tests/test_workspace.py +++ b/tests/test_workspace.py @@ -18,59 +18,416 @@ ############################################################################ import os +import pytest +from lsprotocol import types + from pygls import uris -from lsprotocol.types import TextDocumentItem, WorkspaceFolder from pygls.workspace import Workspace DOC_URI = uris.from_fs_path(__file__) DOC_TEXT = """test""" -DOC = TextDocumentItem(uri=DOC_URI, language_id="plaintext", version=0, text=DOC_TEXT) +DOC = types.TextDocumentItem( + uri=DOC_URI, language_id="plaintext", version=0, text=DOC_TEXT +) +NOTEBOOK = types.NotebookDocument( + uri="file:///path/to/notebook.ipynb", + notebook_type="jupyter-notebook", + version=0, + cells=[ + types.NotebookCell( + kind=types.NotebookCellKind.Code, + document="nb-cell-scheme://path/to/notebook.ipynb#cv32321", + ), + types.NotebookCell( + kind=types.NotebookCellKind.Code, + document="nb-cell-scheme://path/to/notebook.ipynb#cp897h32", + ), + ], +) +NB_CELL_1 = types.TextDocumentItem( + uri="nb-cell-scheme://path/to/notebook.ipynb#cv32321", + language_id="python", + version=0, + text="# cell 1", +) +NB_CELL_2 = types.TextDocumentItem( + uri="nb-cell-scheme://path/to/notebook.ipynb#cp897h32", + language_id="python", + version=0, + text="# cell 2", +) +NB_CELL_3 = types.TextDocumentItem( + uri="nb-cell-scheme://path/to/notebook.ipynb#cq343eeds", + language_id="python", + version=0, + text="# cell 3", +) def test_add_folder(workspace): dir_uri = os.path.dirname(DOC_URI) dir_name = "test" - workspace.add_folder(WorkspaceFolder(uri=dir_uri, name=dir_name)) + workspace.add_folder(types.WorkspaceFolder(uri=dir_uri, name=dir_name)) assert workspace.folders[dir_uri].name == dir_name -def test_get_document(workspace): - workspace.put_document(DOC) +def test_get_notebook_document_by_uri(workspace): + """Ensure that we can get a notebook given its uri.""" + params = types.DidOpenNotebookDocumentParams( + notebook_document=NOTEBOOK, + cell_text_documents=[ + NB_CELL_1, + NB_CELL_2, + ], + ) + workspace.put_notebook_document(params) + + notebook = workspace.get_notebook_document(notebook_uri=NOTEBOOK.uri) + assert notebook == NOTEBOOK + + +@pytest.mark.parametrize( + "cell,expected", + [ + (NB_CELL_1, NOTEBOOK), + (NB_CELL_2, NOTEBOOK), + (NB_CELL_3, None), + (DOC, None), + ], +) +def test_get_notebook_document_by_cell_uri(workspace, cell, expected): + """Ensure that we can get a notebook given a uri of one of its cells""" + params = types.DidOpenNotebookDocumentParams( + notebook_document=NOTEBOOK, + cell_text_documents=[ + NB_CELL_1, + NB_CELL_2, + ], + ) + workspace.put_notebook_document(params) + + notebook = workspace.get_notebook_document(cell_uri=cell.uri) + assert notebook == expected + - assert workspace.get_document(DOC_URI).source == DOC_TEXT +def test_get_text_document(workspace): + workspace.put_text_document(DOC) + + assert workspace.get_text_document(DOC_URI).source == DOC_TEXT def test_get_missing_document(tmpdir, workspace): doc_path = tmpdir.join("test_document.py") doc_path.write(DOC_TEXT) doc_uri = uris.from_fs_path(str(doc_path)) - assert workspace.get_document(doc_uri).source == DOC_TEXT + assert workspace.get_text_document(doc_uri).source == DOC_TEXT + + +def test_put_notebook_document(workspace): + """Ensure that we can add notebook documents to the workspace correctly.""" + params = types.DidOpenNotebookDocumentParams( + notebook_document=NOTEBOOK, + cell_text_documents=[ + NB_CELL_1, + NB_CELL_2, + ], + ) + workspace.put_notebook_document(params) + assert NOTEBOOK.uri in workspace._notebook_documents + assert NB_CELL_1.uri in workspace._text_documents + assert NB_CELL_2.uri in workspace._text_documents -def test_put_document(workspace): - workspace.put_document(DOC) - assert DOC_URI in workspace._docs + +def test_put_text_document(workspace): + workspace.put_text_document(DOC) + assert DOC_URI in workspace._text_documents def test_remove_folder(workspace): dir_uri = os.path.dirname(DOC_URI) dir_name = "test" - workspace.add_folder(WorkspaceFolder(uri=dir_uri, name=dir_name)) + workspace.add_folder(types.WorkspaceFolder(uri=dir_uri, name=dir_name)) workspace.remove_folder(dir_uri) assert dir_uri not in workspace.folders -def test_remove_document(workspace): - workspace.put_document(DOC) - assert workspace.get_document(DOC_URI).source == DOC_TEXT - workspace.remove_document(DOC_URI) - assert workspace.get_document(DOC_URI)._source is None +def test_remove_notebook_document(workspace): + """Ensure that we can correctly remove a document from the workspace.""" + params = types.DidOpenNotebookDocumentParams( + notebook_document=NOTEBOOK, + cell_text_documents=[ + NB_CELL_1, + NB_CELL_2, + ], + ) + workspace.put_notebook_document(params) + + assert NOTEBOOK.uri in workspace._notebook_documents + assert NB_CELL_1.uri in workspace._text_documents + assert NB_CELL_2.uri in workspace._text_documents + + params = types.DidCloseNotebookDocumentParams( + notebook_document=types.NotebookDocumentIdentifier(uri=NOTEBOOK.uri), + cell_text_documents=[ + types.TextDocumentIdentifier(uri=NB_CELL_1.uri), + types.TextDocumentIdentifier(uri=NB_CELL_2.uri), + ], + ) + workspace.remove_notebook_document(params) + + assert NOTEBOOK.uri not in workspace._notebook_documents + assert NB_CELL_1.uri not in workspace._text_documents + assert NB_CELL_2.uri not in workspace._text_documents + + +def test_remove_text_document(workspace): + workspace.put_text_document(DOC) + assert workspace.get_text_document(DOC_URI).source == DOC_TEXT + workspace.remove_text_document(DOC_URI) + assert workspace.get_text_document(DOC_URI)._source is None + + +def test_update_notebook_metadata(workspace): + """Ensure we can update a notebook's metadata correctly.""" + params = types.DidOpenNotebookDocumentParams( + notebook_document=NOTEBOOK, + cell_text_documents=[ + NB_CELL_1, + NB_CELL_2, + ], + ) + workspace.put_notebook_document(params) + + notebook = workspace.get_notebook_document(notebook_uri=NOTEBOOK.uri) + assert notebook.version == 0 + assert notebook.metadata is None + + params = types.DidChangeNotebookDocumentParams( + notebook_document=types.VersionedNotebookDocumentIdentifier( + uri=NOTEBOOK.uri, version=31 + ), + change=types.NotebookDocumentChangeEvent( + metadata={"custom": "metadata"}, + ), + ) + workspace.update_notebook_document(params) + + notebook = workspace.get_notebook_document(notebook_uri=NOTEBOOK.uri) + assert notebook.version == 31 + assert notebook.metadata == {"custom": "metadata"} + + +def test_update_notebook_cell_data(workspace): + """Ensure we can update a notebook correctly when cell data changes.""" + params = types.DidOpenNotebookDocumentParams( + notebook_document=NOTEBOOK, + cell_text_documents=[ + NB_CELL_1, + NB_CELL_2, + ], + ) + workspace.put_notebook_document(params) + + notebook = workspace.get_notebook_document(notebook_uri=NOTEBOOK.uri) + assert notebook.version == 0 + + cell_1 = notebook.cells[0] + assert cell_1.metadata is None + assert cell_1.execution_summary is None + + cell_2 = notebook.cells[1] + assert cell_2.metadata is None + assert cell_2.execution_summary is None + + params = types.DidChangeNotebookDocumentParams( + notebook_document=types.VersionedNotebookDocumentIdentifier( + uri=NOTEBOOK.uri, version=31 + ), + change=types.NotebookDocumentChangeEvent( + cells=types.NotebookDocumentChangeEventCellsType( + data=[ + types.NotebookCell( + kind=types.NotebookCellKind.Code, + document=NB_CELL_1.uri, + metadata={"slideshow": {"slide_type": "skip"}}, + execution_summary=types.ExecutionSummary( + execution_order=2, success=True + ), + ), + types.NotebookCell( + kind=types.NotebookCellKind.Code, + document=NB_CELL_2.uri, + metadata={"slideshow": {"slide_type": "note"}}, + execution_summary=types.ExecutionSummary( + execution_order=3, success=False + ), + ), + ] + ) + ), + ) + workspace.update_notebook_document(params) + + notebook = workspace.get_notebook_document(notebook_uri=NOTEBOOK.uri) + assert notebook.version == 31 + + cell_1 = notebook.cells[0] + assert cell_1.metadata == {"slideshow": {"slide_type": "skip"}} + assert cell_1.execution_summary == types.ExecutionSummary( + execution_order=2, success=True + ) + + cell_2 = notebook.cells[1] + assert cell_2.metadata == {"slideshow": {"slide_type": "note"}} + assert cell_2.execution_summary == types.ExecutionSummary( + execution_order=3, success=False + ) + + +def test_update_notebook_cell_content(workspace): + """Ensure we can update a notebook correctly when the cell contents change.""" + params = types.DidOpenNotebookDocumentParams( + notebook_document=NOTEBOOK, + cell_text_documents=[ + NB_CELL_1, + NB_CELL_2, + ], + ) + workspace.put_notebook_document(params) + + notebook = workspace.get_notebook_document(notebook_uri=NOTEBOOK.uri) + assert notebook.version == 0 + + cell_1 = workspace.get_text_document(NB_CELL_1.uri) + assert cell_1.version == 0 + assert cell_1.source == "# cell 1" + + cell_2 = workspace.get_text_document(NB_CELL_2.uri) + assert cell_2.version == 0 + assert cell_2.source == "# cell 2" + + params = types.DidChangeNotebookDocumentParams( + notebook_document=types.VersionedNotebookDocumentIdentifier( + uri=NOTEBOOK.uri, version=31 + ), + change=types.NotebookDocumentChangeEvent( + cells=types.NotebookDocumentChangeEventCellsType( + text_content=[ + types.NotebookDocumentChangeEventCellsTypeTextContentType( + document=types.VersionedTextDocumentIdentifier( + uri=NB_CELL_1.uri, version=13 + ), + changes=[ + types.TextDocumentContentChangeEvent_Type1( + text="new text", + range=types.Range( + start=types.Position(line=0, character=0), + end=types.Position(line=0, character=8), + ), + ) + ], + ), + types.NotebookDocumentChangeEventCellsTypeTextContentType( + document=types.VersionedTextDocumentIdentifier( + uri=NB_CELL_2.uri, version=21 + ), + changes=[ + types.TextDocumentContentChangeEvent_Type1( + text="", + range=types.Range( + start=types.Position(line=0, character=0), + end=types.Position(line=0, character=8), + ), + ), + types.TextDocumentContentChangeEvent_Type1( + text="other text", + range=types.Range( + start=types.Position(line=0, character=0), + end=types.Position(line=0, character=0), + ), + ), + ], + ), + ] + ) + ), + ) + workspace.update_notebook_document(params) + + notebook = workspace.get_notebook_document(notebook_uri=NOTEBOOK.uri) + assert notebook.version == 31 + + cell_1 = workspace.get_text_document(NB_CELL_1.uri) + assert cell_1.version == 13 + assert cell_1.source == "new text" + + cell_2 = workspace.get_text_document(NB_CELL_2.uri) + assert cell_2.version == 21 + assert cell_2.source == "other text" + + +def test_update_notebook_new_cells(workspace): + """Ensure that we can correctly add new cells to an existing notebook.""" + + params = types.DidOpenNotebookDocumentParams( + notebook_document=NOTEBOOK, + cell_text_documents=[ + NB_CELL_1, + NB_CELL_2, + ], + ) + workspace.put_notebook_document(params) + + notebook = workspace.get_notebook_document(notebook_uri=NOTEBOOK.uri) + assert notebook.version == 0 + + cell_uris = [c.document for c in notebook.cells] + assert cell_uris == [NB_CELL_1.uri, NB_CELL_2.uri] + + cell_1 = workspace.get_text_document(NB_CELL_1.uri) + assert cell_1.version == 0 + assert cell_1.source == "# cell 1" + + cell_2 = workspace.get_text_document(NB_CELL_2.uri) + assert cell_2.version == 0 + assert cell_2.source == "# cell 2" + + params = types.DidChangeNotebookDocumentParams( + notebook_document=types.VersionedNotebookDocumentIdentifier( + uri=NOTEBOOK.uri, version=31 + ), + change=types.NotebookDocumentChangeEvent( + cells=types.NotebookDocumentChangeEventCellsType( + structure=types.NotebookDocumentChangeEventCellsTypeStructureType( + array=types.NotebookCellArrayChange( + start=1, + delete_count=0, + cells=[ + types.NotebookCell( + kind=types.NotebookCellKind.Code, document=NB_CELL_3.uri + ) + ], + ), + did_open=[NB_CELL_3], + ) + ) + ), + ) + workspace.update_notebook_document(params) + + notebook = workspace.get_notebook_document(cell_uri=NB_CELL_3.uri) + assert notebook.uri == NOTEBOOK.uri + assert NB_CELL_3.uri in workspace._text_documents + + cell_uris = [c.document for c in notebook.cells] + assert cell_uris == [NB_CELL_1.uri, NB_CELL_3.uri, NB_CELL_2.uri] def test_workspace_folders(): - wf1 = WorkspaceFolder(uri="/ws/f1", name="ws1") - wf2 = WorkspaceFolder(uri="/ws/f2", name="ws2") + wf1 = types.WorkspaceFolder(uri="/ws/f1", name="ws1") + wf2 = types.WorkspaceFolder(uri="/ws/f2", name="ws2") workspace = Workspace("/ws", workspace_folders=[wf1, wf2])