Files
nx_post_support/server/libs/pygls/protocol/language_server.py
T
Christoph Brandau 53ebc5d055 chore(lsprotocol): migrate to 2025.0.0 and cleanup artifacts
The changes align the project with the 2025.0.0 lsprotocol
release, removing the old backport and updating type hints
in the protocol hooks to use Sequence where appropriate. The
dist-info and packaging metadata for older lsprotocol
versions are replaced with the new 2025.0.0 artifacts.

- Remove exceptiongroup backport used on Python <3.11
- Use Sequence instead of List in LS protocol hooks
- Replace old dist-info with 2025.0.0 metadata
2026-09-03 08:39:12 +02:00

429 lines
15 KiB
Python

############################################################################
# Copyright(c) Open Law Library. All rights reserved. #
# See ThirdPartyNotices.txt in the project root for additional notices. #
# #
# Licensed under the Apache License, Version 2.0 (the "License") #
# you may not use this file except in compliance with the License. #
# You may obtain a copy of the License at #
# #
# http: // www.apache.org/licenses/LICENSE-2.0 #
# #
# Unless required by applicable law or agreed to in writing, software #
# distributed under the License is distributed on an "AS IS" BASIS, #
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. #
# See the License for the specific language governing permissions and #
# limitations under the License. #
############################################################################
from __future__ import annotations
import asyncio
import inspect
import json
import logging
import sys
import typing
from functools import lru_cache
from itertools import zip_longest
from lsprotocol import types
from pygls.capabilities import ServerCapabilitiesBuilder
from pygls.constants import PARAM_LS
from pygls.exceptions import JsonRpcInvalidParams
from pygls.protocol.json_rpc import JsonRPCProtocol
from pygls.uris import from_fs_path
from pygls.workspace import Workspace
if typing.TYPE_CHECKING:
from collections.abc import Generator
from typing import Any, Callable, Optional, Type, TypeVar
from cattrs import Converter
from pygls.lsp.server import LanguageServer
F = TypeVar("F", bound=Callable)
logger = logging.getLogger(__name__)
def lsp_method(method_name: str) -> Callable[[F], F]:
def decorator(f: F) -> F:
f.method_name = method_name # type: ignore[attr-defined]
return f
return decorator
class LanguageServerProtocol(JsonRPCProtocol):
"""A class that represents language server protocol.
It contains implementations for generic LSP features.
Attributes:
workspace(Workspace): In memory workspace
"""
_server: LanguageServer
def __init__(self, server: LanguageServer, converter: Converter):
super().__init__(server, converter)
self._workspace: Optional[Workspace] = None
self.trace = types.TraceValue.Off
from pygls.progress import Progress
self.progress = Progress(self)
self.server_info = types.ServerInfo(
name=server.name,
version=server.version,
)
self._register_builtin_features()
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) -> Type[Any] | None:
"""Return LSP type definitions, as provided by `lsprotocol`"""
return types.METHOD_TO_TYPES.get(method, (None,))[0]
@lru_cache()
def get_result_type(self, method: str) -> Type[Any] | None:
return types.METHOD_TO_TYPES.get(method, (None, None))[1]
@lsp_method(types.EXIT)
def lsp_exit(self, *args) -> Generator[Any, Any, None]:
"""Stops the server process."""
# Ensure that the user handler is called first
if (user_handler := self.fm.features.get(types.EXIT)) is not None:
yield user_handler, args, None
returncode = 0 if self._shutdown else 1
if self.writer is None:
sys.exit(returncode)
res = self.writer.close()
if inspect.isawaitable(res):
# Only call sys.exit once the close task has completed.
fut = asyncio.ensure_future(res)
fut.add_done_callback(lambda t: sys.exit(returncode))
else:
sys.exit(returncode)
@lsp_method(types.INITIALIZE)
def lsp_initialize(
self, params: types.InitializeParams
) -> Generator[Any, Any, types.InitializeResult]:
"""Method that initializes language server.
It will compute and return server capabilities based on
registered features.
"""
logger.info("Language server initialized %s", params)
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
self.client_capabilities = params.capabilities
position_encoding = ServerCapabilitiesBuilder.choose_position_encoding(
self.client_capabilities
)
root_path = params.root_path
root_uri = params.root_uri
if root_path is not None and root_uri is None:
root_uri = from_fs_path(root_path)
# Initialize the workspace before yielding to the user's initialize handler
workspace_folders = params.workspace_folders or []
self._workspace = Workspace(
root_uri,
text_document_sync_kind,
workspace_folders,
position_encoding,
)
if (user_handler := self.fm.features.get(types.INITIALIZE)) is not None:
yield user_handler, (params,), None
# Now that the user has had the opportunity to setup additional features, calculate
# the server's capabilities
self.server_capabilities = ServerCapabilitiesBuilder(
self.client_capabilities,
set({**self.fm.features, **self.fm.builtin_features}.keys()),
self.fm.feature_options,
list(self.fm.commands.keys()),
text_document_sync_kind,
notebook_document_sync,
position_encoding,
).build()
logger.debug(
"Server capabilities: %s",
json.dumps(self.server_capabilities, default=self._serialize_message),
)
return types.InitializeResult(
capabilities=self.server_capabilities,
server_info=self.server_info,
)
@lsp_method(types.INITIALIZED)
def lsp_initialized(self, *args):
"""Notification received when client and server are connected."""
if (user_handler := self.fm.features.get(types.INITIALIZED)) is not None:
yield user_handler, args, None
@lsp_method(types.SHUTDOWN)
def lsp_shutdown(self, *args) -> Generator[Any, Any, None]:
"""Request from client which asks server to shutdown."""
if (user_handler := self.fm.features.get(types.SHUTDOWN)) is not None:
yield user_handler, args, None
# Don't cancel the future for this request!
current_id = self.msg_id
for msg_id, future in self._request_futures.items():
if msg_id != current_id and not future.done():
future.cancel()
self._shutdown = True
return None
@lsp_method(types.TEXT_DOCUMENT_DID_CHANGE)
def lsp_text_document__did_change(self, params: types.DidChangeTextDocumentParams):
"""Updates document's content.
(Incremental(from server capabilities); not configurable for now)
"""
for change in params.content_changes:
self.workspace.update_text_document(params.text_document, change)
if (
user_handler := self.fm.features.get(types.TEXT_DOCUMENT_DID_CHANGE)
) is not None:
yield user_handler, (params,), None
@lsp_method(types.TEXT_DOCUMENT_DID_CLOSE)
def lsp_text_document__did_close(self, params: types.DidCloseTextDocumentParams):
"""Removes document from workspace."""
self.workspace.remove_text_document(params.text_document.uri)
if (
user_handler := self.fm.features.get(types.TEXT_DOCUMENT_DID_CLOSE)
) is not None:
yield user_handler, (params,), None
@lsp_method(types.TEXT_DOCUMENT_DID_OPEN)
def lsp_text_document__did_open(self, params: types.DidOpenTextDocumentParams):
"""Puts document to the workspace."""
self.workspace.put_text_document(params.text_document)
if (
user_handler := self.fm.features.get(types.TEXT_DOCUMENT_DID_OPEN)
) is not None:
yield user_handler, (params,), None
@lsp_method(types.NOTEBOOK_DOCUMENT_DID_OPEN)
def lsp_notebook_document__did_open(
self, params: types.DidOpenNotebookDocumentParams
):
"""Put a notebook document into the workspace"""
self.workspace.put_notebook_document(params)
if (
user_handler := self.fm.features.get(types.NOTEBOOK_DOCUMENT_DID_OPEN)
) is not None:
yield user_handler, (params,), None
@lsp_method(types.NOTEBOOK_DOCUMENT_DID_CHANGE)
def lsp_notebook_document__did_change(
self, params: types.DidChangeNotebookDocumentParams
):
"""Update a notebook's contents"""
self.workspace.update_notebook_document(params)
if (
user_handler := self.fm.features.get(types.NOTEBOOK_DOCUMENT_DID_CHANGE)
) is not None:
yield user_handler, (params,), None
@lsp_method(types.NOTEBOOK_DOCUMENT_DID_CLOSE)
def lsp_notebook_document__did_close(
self, params: types.DidCloseNotebookDocumentParams
):
"""Remove a notebook document from the workspace."""
self.workspace.remove_notebook_document(params)
if (
user_handler := self.fm.features.get(types.NOTEBOOK_DOCUMENT_DID_CLOSE)
) is not None:
yield user_handler, (params,), None
@lsp_method(types.SET_TRACE)
def lsp_set_trace(self, params: types.SetTraceParams) -> Generator[Any, Any, None]:
"""Changes server trace value."""
self.trace = params.value
if (user_handler := self.fm.features.get(types.SET_TRACE)) is not None:
yield user_handler, (params,), None
@lsp_method(types.WORKSPACE_DID_CHANGE_WORKSPACE_FOLDERS)
def lsp_workspace__did_change_workspace_folders(
self, params: types.DidChangeWorkspaceFoldersParams
):
"""Adds/Removes folders from the workspace."""
logger.info("Workspace folders changed: %s", params)
added_folders = params.event.added or []
removed_folders = params.event.removed or []
for f_add, f_remove in zip_longest(added_folders, removed_folders):
if f_add:
self.workspace.add_folder(f_add)
if f_remove:
self.workspace.remove_folder(f_remove.uri)
if (
user_handler := self.fm.features.get(
types.WORKSPACE_DID_CHANGE_WORKSPACE_FOLDERS
)
) is not None:
yield user_handler, (params,), None
@lsp_method(types.WORKSPACE_EXECUTE_COMMAND)
def lsp_workspace__execute_command(
self, params: types.ExecuteCommandParams
) -> Generator[Any, Any, Any]:
"""Executes commands with passed arguments and returns a value."""
if (handler := self.fm.commands.get(params.command, None)) is None:
raise JsonRpcInvalidParams.of(
ValueError(f"Command name {params.command!r} is not defined")
)
try:
args, kwargs = _prepare_command_arguments(handler, params, self._converter)
except Exception as exc:
raise JsonRpcInvalidParams.of(exc)
# Call the user's command handler.
result = yield handler, args, kwargs
return result
@lsp_method(types.WINDOW_WORK_DONE_PROGRESS_CANCEL)
def lsp_work_done_progress_cancel(self, params: types.WorkDoneProgressCancelParams):
"""Received a progress cancellation from client."""
future = self.progress.tokens.get(params.token)
if future is None:
logger.warning(
"Ignoring work done progress cancel for unknown token %s", params.token
)
else:
future.cancel()
if (
user_handler := self.fm.features.get(types.WINDOW_WORK_DONE_PROGRESS_CANCEL)
) is not None:
yield user_handler, (params,), None
def _prepare_command_arguments(
handler: Callable[..., Any],
params: types.ExecuteCommandParams,
converter: Converter,
) -> tuple[tuple[Any, ...], dict[str, Any]]:
"""Prepare the arguments to pass to the command handler."""
if params.arguments is None:
return tuple(), {}
# Import this here to not introduce an import cycle at the module level
from pygls.lsp.server import LanguageServer
param_vals = iter(params.arguments)
param_defs, annotations = _get_handler_params_annotations(handler)
args: list[Any] = []
kwargs: dict[str, Any] = {}
# param_defs is an OrderedDict so *in theory* at least we don't have to
# worry about argument order.
found_ls = False
for idx, (name, param) in enumerate(param_defs.items()):
ptype = annotations.get(name, None)
# We don't need to provide the injected server instance here.
# The @server.command decorator will have already handled it.
if idx == 0:
if name == PARAM_LS:
found_ls = True
continue
if (ptype is not None) and issubclass(ptype, LanguageServer):
found_ls = True
continue
if param.kind == inspect.Parameter.VAR_POSITIONAL: # i.e. *args
# consume the remaining values
args.extend(param_vals)
else:
try:
value = converter.structure(next(param_vals), ptype)
except StopIteration as exc:
raise TypeError(
f"Expected {len(param_defs) - found_ls} arguments, "
f"got {len(params.arguments)}"
) from exc
args.append(value)
# did we consume all the values?
if len(list(param_vals)) > 0:
raise TypeError(
f"Expected {len(param_defs) - found_ls} arguments, "
f"got {len(params.arguments)}"
)
return tuple(args), kwargs
def _get_handler_params_annotations(handler: Callable[..., Any]):
"""Return the parameters and corresponding type annotations for the given handler
function."""
# If the user's handler requests the language server instance, the real function
# is wrapped inside whatever `functools.partial()` returns.
if hasattr(handler, "func"):
annotations = typing.get_type_hints(handler.func)
params = inspect.signature(handler.func).parameters
else:
annotations = typing.get_type_hints(handler)
params = inspect.signature(handler).parameters
return params, annotations