python lsp

This commit is contained in:
Christoph Brandau
2025-07-16 17:14:09 +02:00
parent 9febcebddb
commit 99718eb856
194 changed files with 52506 additions and 116 deletions
+25
View File
@@ -0,0 +1,25 @@
############################################################################
# Original work Copyright 2018 Palantir Technologies, Inc. #
# Original work licensed under the MIT License. #
# See ThirdPartyNotices.txt in the project root for license information. #
# All modifications Copyright (c) Open Law Library. All rights reserved. #
# #
# 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. #
############################################################################
import os
import sys
IS_WIN = os.name == "nt"
IS_PYODIDE = "pyodide" in sys.modules
pygls = "pygls"
+460
View File
@@ -0,0 +1,460 @@
############################################################################
# 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 functools import reduce
from typing import Any, Dict, List, Optional, Set, Union, TypeVar
import logging
from lsprotocol import types
logger = logging.getLogger(__name__)
T = TypeVar("T")
def get_capability(
client_capabilities: types.ClientCapabilities, field: str, default: Any = None
) -> Any:
"""Check if ClientCapabilities has some nested value without raising
AttributeError.
e.g. get_capability('text_document.synchronization.will_save')
"""
try:
value = reduce(getattr, field.split("."), client_capabilities)
except AttributeError:
return default
# If we reach the desired leaf value but it's None, return the default.
return default if value is None else value
class ServerCapabilitiesBuilder:
"""Create `ServerCapabilities` instance depending on builtin and user registered
features.
"""
def __init__(
self,
client_capabilities: types.ClientCapabilities,
features: Set[str],
feature_options: Dict[str, Any],
commands: List[str],
text_document_sync_kind: types.TextDocumentSyncKind,
notebook_document_sync: Optional[types.NotebookDocumentSyncOptions] = None,
):
self.client_capabilities = client_capabilities
self.features = features
self.feature_options = feature_options
self.commands = commands
self.text_document_sync_kind = text_document_sync_kind
self.notebook_document_sync = notebook_document_sync
self.server_cap = types.ServerCapabilities()
def _provider_options(self, feature: str, default: T) -> Optional[Union[T, Any]]:
if feature in self.features:
return self.feature_options.get(feature, default)
return None
def _with_text_document_sync(self):
open_close = (
types.TEXT_DOCUMENT_DID_OPEN in self.features
or types.TEXT_DOCUMENT_DID_CLOSE in self.features
)
will_save = (
get_capability(
self.client_capabilities, "text_document.synchronization.will_save"
)
and types.TEXT_DOCUMENT_WILL_SAVE in self.features
)
will_save_wait_until = (
get_capability(
self.client_capabilities,
"text_document.synchronization.will_save_wait_until",
)
and types.TEXT_DOCUMENT_WILL_SAVE_WAIT_UNTIL in self.features
)
if types.TEXT_DOCUMENT_DID_SAVE in self.features:
save = self.feature_options.get(types.TEXT_DOCUMENT_DID_SAVE, True)
else:
save = False
self.server_cap.text_document_sync = types.TextDocumentSyncOptions(
open_close=open_close,
change=self.text_document_sync_kind,
will_save=will_save,
will_save_wait_until=will_save_wait_until,
save=save,
)
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(
types.TEXT_DOCUMENT_COMPLETION, default=types.CompletionOptions()
)
if value is not None:
self.server_cap.completion_provider = value
return self
def _with_hover(self):
value = self._provider_options(types.TEXT_DOCUMENT_HOVER, default=True)
if value is not None:
self.server_cap.hover_provider = value
return self
def _with_signature_help(self):
value = self._provider_options(
types.TEXT_DOCUMENT_SIGNATURE_HELP, default=types.SignatureHelpOptions()
)
if value is not None:
self.server_cap.signature_help_provider = value
return self
def _with_declaration(self):
value = self._provider_options(types.TEXT_DOCUMENT_DECLARATION, default=True)
if value is not None:
self.server_cap.declaration_provider = value
return self
def _with_definition(self):
value = self._provider_options(types.TEXT_DOCUMENT_DEFINITION, default=True)
if value is not None:
self.server_cap.definition_provider = value
return self
def _with_type_definition(self):
value = self._provider_options(
types.TEXT_DOCUMENT_TYPE_DEFINITION, default=types.TypeDefinitionOptions()
)
if value is not None:
self.server_cap.type_definition_provider = value
return self
def _with_inlay_hints(self):
value = self._provider_options(
types.TEXT_DOCUMENT_INLAY_HINT, default=types.InlayHintOptions()
)
if value is not None:
value.resolve_provider = types.INLAY_HINT_RESOLVE in self.features
self.server_cap.inlay_hint_provider = value
return self
def _with_implementation(self):
value = self._provider_options(
types.TEXT_DOCUMENT_IMPLEMENTATION, default=types.ImplementationOptions()
)
if value is not None:
self.server_cap.implementation_provider = value
return self
def _with_references(self):
value = self._provider_options(types.TEXT_DOCUMENT_REFERENCES, default=True)
if value is not None:
self.server_cap.references_provider = value
return self
def _with_document_highlight(self):
value = self._provider_options(
types.TEXT_DOCUMENT_DOCUMENT_HIGHLIGHT, default=True
)
if value is not None:
self.server_cap.document_highlight_provider = value
return self
def _with_document_symbol(self):
value = self._provider_options(
types.TEXT_DOCUMENT_DOCUMENT_SYMBOL, default=True
)
if value is not None:
self.server_cap.document_symbol_provider = value
return self
def _with_code_action(self):
value = self._provider_options(types.TEXT_DOCUMENT_CODE_ACTION, default=True)
if value is not None:
self.server_cap.code_action_provider = value
return self
def _with_code_lens(self):
value = self._provider_options(
types.TEXT_DOCUMENT_CODE_LENS, default=types.CodeLensOptions()
)
if value is not None:
self.server_cap.code_lens_provider = value
return self
def _with_document_link(self):
value = self._provider_options(
types.TEXT_DOCUMENT_DOCUMENT_LINK, default=types.DocumentLinkOptions()
)
if value is not None:
self.server_cap.document_link_provider = value
return self
def _with_color(self):
value = self._provider_options(types.TEXT_DOCUMENT_DOCUMENT_COLOR, default=True)
if value is not None:
self.server_cap.color_provider = value
return self
def _with_document_formatting(self):
value = self._provider_options(types.TEXT_DOCUMENT_FORMATTING, default=True)
if value is not None:
self.server_cap.document_formatting_provider = value
return self
def _with_document_range_formatting(self):
value = self._provider_options(
types.TEXT_DOCUMENT_RANGE_FORMATTING, default=True
)
if value is not None:
self.server_cap.document_range_formatting_provider = value
return self
def _with_document_on_type_formatting(self):
value = self._provider_options(
types.TEXT_DOCUMENT_ON_TYPE_FORMATTING, default=None
)
if value is not None:
self.server_cap.document_on_type_formatting_provider = value
return self
def _with_rename(self):
value = self._provider_options(types.TEXT_DOCUMENT_RENAME, default=True)
if value is not None:
self.server_cap.rename_provider = value
return self
def _with_folding_range(self):
value = self._provider_options(types.TEXT_DOCUMENT_FOLDING_RANGE, default=True)
if value is not None:
self.server_cap.folding_range_provider = value
return self
def _with_execute_command(self):
self.server_cap.execute_command_provider = types.ExecuteCommandOptions(
commands=self.commands
)
return self
def _with_selection_range(self):
value = self._provider_options(
types.TEXT_DOCUMENT_SELECTION_RANGE, default=True
)
if value is not None:
self.server_cap.selection_range_provider = value
return self
def _with_call_hierarchy(self):
value = self._provider_options(
types.TEXT_DOCUMENT_PREPARE_CALL_HIERARCHY, default=True
)
if value is not None:
self.server_cap.call_hierarchy_provider = value
return self
def _with_type_hierarchy(self):
value = self._provider_options(
types.TEXT_DOCUMENT_PREPARE_TYPE_HIERARCHY, default=True
)
if value is not None:
self.server_cap.type_hierarchy_provider = value
return self
def _with_semantic_tokens(self):
providers = [
types.TEXT_DOCUMENT_SEMANTIC_TOKENS_FULL,
types.TEXT_DOCUMENT_SEMANTIC_TOKENS_FULL_DELTA,
types.TEXT_DOCUMENT_SEMANTIC_TOKENS_RANGE,
]
value = None
for provider in providers:
value = self._provider_options(provider, default=None)
if value is not None:
break
if value is None:
return self
if isinstance(value, types.SemanticTokensRegistrationOptions):
self.server_cap.semantic_tokens_provider = value
return self
full_support: Union[bool, types.SemanticTokensOptionsFullType1] = (
types.TEXT_DOCUMENT_SEMANTIC_TOKENS_FULL in self.features
)
if types.TEXT_DOCUMENT_SEMANTIC_TOKENS_FULL_DELTA in self.features:
full_support = types.SemanticTokensOptionsFullType1(delta=True)
options = types.SemanticTokensOptions(
legend=value,
full=full_support or None,
range=types.TEXT_DOCUMENT_SEMANTIC_TOKENS_RANGE in self.features or None,
)
if options.full or options.range:
self.server_cap.semantic_tokens_provider = options
return self
def _with_linked_editing_range(self):
value = self._provider_options(
types.TEXT_DOCUMENT_LINKED_EDITING_RANGE, default=True
)
if value is not None:
self.server_cap.linked_editing_range_provider = value
return self
def _with_moniker(self):
value = self._provider_options(types.TEXT_DOCUMENT_MONIKER, default=True)
if value is not None:
self.server_cap.moniker_provider = value
return self
def _with_workspace_symbol(self):
value = self._provider_options(
types.WORKSPACE_SYMBOL, default=types.WorkspaceSymbolOptions()
)
if value is not None:
value.resolve_provider = types.WORKSPACE_SYMBOL_RESOLVE in self.features
self.server_cap.workspace_symbol_provider = value
return self
def _with_workspace_capabilities(self):
# File operations
file_operations = types.FileOperationOptions()
operations = [
(types.WORKSPACE_WILL_CREATE_FILES, "will_create"),
(types.WORKSPACE_DID_CREATE_FILES, "did_create"),
(types.WORKSPACE_WILL_DELETE_FILES, "will_delete"),
(types.WORKSPACE_DID_DELETE_FILES, "did_delete"),
(types.WORKSPACE_WILL_RENAME_FILES, "will_rename"),
(types.WORKSPACE_DID_RENAME_FILES, "did_rename"),
]
for method_name, capability_name in operations:
client_supports_method = get_capability(
self.client_capabilities, f"workspace.file_operations.{capability_name}"
)
if client_supports_method:
value = self._provider_options(method_name, default=None)
setattr(file_operations, capability_name, value)
self.server_cap.workspace = types.ServerCapabilitiesWorkspaceType(
workspace_folders=types.WorkspaceFoldersServerCapabilities(
supported=True,
change_notifications=True,
),
file_operations=file_operations,
)
return self
def _with_diagnostic_provider(self):
value = self._provider_options(
types.TEXT_DOCUMENT_DIAGNOSTIC,
default=types.DiagnosticOptions(
inter_file_dependencies=False, workspace_diagnostics=False
),
)
if value is not None:
value.workspace_diagnostics = types.WORKSPACE_DIAGNOSTIC in self.features
self.server_cap.diagnostic_provider = value
return self
def _with_inline_value_provider(self):
value = self._provider_options(types.TEXT_DOCUMENT_INLINE_VALUE, default=True)
if value is not None:
self.server_cap.inline_value_provider = value
return self
def _with_position_encodings(self):
self.server_cap.position_encoding = types.PositionEncodingKind.Utf16
general = self.client_capabilities.general
if general is None:
return self
encodings = general.position_encodings
if encodings is None:
return self
if types.PositionEncodingKind.Utf16 in encodings:
return self
if types.PositionEncodingKind.Utf32 in encodings:
self.server_cap.position_encoding = types.PositionEncodingKind.Utf32
return self
if types.PositionEncodingKind.Utf8 in encodings:
self.server_cap.position_encoding = types.PositionEncodingKind.Utf8
return self
logger.warning(f"Unknown `PositionEncoding`s: {encodings}")
return self
def _build(self):
return self.server_cap
def build(self):
return (
self._with_text_document_sync()
._with_notebook_document_sync()
._with_completion()
._with_hover()
._with_signature_help()
._with_declaration()
._with_definition()
._with_type_definition()
._with_inlay_hints()
._with_implementation()
._with_references()
._with_document_highlight()
._with_document_symbol()
._with_code_action()
._with_code_lens()
._with_document_link()
._with_color()
._with_document_formatting()
._with_document_range_formatting()
._with_document_on_type_formatting()
._with_rename()
._with_folding_range()
._with_execute_command()
._with_selection_range()
._with_call_hierarchy()
._with_type_hierarchy()
._with_semantic_tokens()
._with_linked_editing_range()
._with_moniker()
._with_workspace_symbol()
._with_workspace_capabilities()
._with_diagnostic_provider()
._with_inline_value_provider()
._with_position_encodings()
._build()
)
+176
View File
@@ -0,0 +1,176 @@
############################################################################
# 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. #
############################################################################
import asyncio
import logging
import re
from threading import Event
from typing import Any
from typing import Callable
from typing import List
from typing import Optional
from typing import Type
from typing import Union
from cattrs import Converter
from pygls.exceptions import PyglsError, JsonRpcException
from pygls.protocol import JsonRPCProtocol, default_converter
logger = logging.getLogger(__name__)
async def aio_readline(stop_event, reader, message_handler):
CONTENT_LENGTH_PATTERN = re.compile(rb"^Content-Length: (\d+)\r\n$")
# Initialize message buffer
message = []
content_length = 0
while not stop_event.is_set():
# Read a header line
header = await reader.readline()
if not header:
break
message.append(header)
# Extract content length if possible
if not content_length:
match = CONTENT_LENGTH_PATTERN.fullmatch(header)
if match:
content_length = int(match.group(1))
logger.debug("Content length: %s", content_length)
# Check if all headers have been read (as indicated by an empty line \r\n)
if content_length and not header.strip():
# Read body
body = await reader.readexactly(content_length)
if not body:
break
message.append(body)
# Pass message to protocol
message_handler(b"".join(message))
# Reset the buffer
message = []
content_length = 0
class JsonRPCClient:
"""Base JSON-RPC client."""
def __init__(
self,
protocol_cls: Type[JsonRPCProtocol] = JsonRPCProtocol,
converter_factory: Callable[[], Converter] = default_converter,
):
# Strictly speaking `JsonRPCProtocol` wants a `LanguageServer`, not a
# `JsonRPCClient`. However there similar enough for our purposes, which is
# that this client will mostly be used in testing contexts.
self.protocol = protocol_cls(self, converter_factory()) # type: ignore
self._server: Optional[asyncio.subprocess.Process] = None
self._stop_event = Event()
self._async_tasks: List[asyncio.Task] = []
@property
def stopped(self) -> bool:
"""Return ``True`` if the client has been stopped."""
return self._stop_event.is_set()
def feature(
self,
feature_name: str,
options: Optional[Any] = None,
):
"""Decorator used to register LSP features.
Example
-------
::
import logging
from pygls.client import JsonRPCClient
ls = JsonRPCClient()
@ls.feature('window/logMessage')
def completions(ls, params):
logging.info("%s", params.message)
"""
return self.protocol.fm.feature(feature_name, options)
async def start_io(self, cmd: str, *args, **kwargs):
"""Start the given server and communicate with it over stdio."""
logger.debug("Starting server process: %s", " ".join([cmd, *args]))
server = await asyncio.create_subprocess_exec(
cmd,
*args,
stdout=asyncio.subprocess.PIPE,
stdin=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
**kwargs,
)
self.protocol.connection_made(server.stdin) # type: ignore
connection = asyncio.create_task(
aio_readline(self._stop_event, server.stdout, self.protocol.data_received)
)
notify_exit = asyncio.create_task(self._server_exit())
self._server = server
self._async_tasks.extend([connection, notify_exit])
async def _server_exit(self):
if self._server is not None:
await self._server.wait()
logger.debug(
"Server process %s exited with return code: %s",
self._server.pid,
self._server.returncode,
)
await self.server_exit(self._server)
self._stop_event.set()
async def server_exit(self, server: asyncio.subprocess.Process):
"""Called when the server process exits."""
def _report_server_error(
self, error: Exception, source: Union[PyglsError, JsonRpcException]
):
try:
self.report_server_error(error, source)
except Exception:
logger.error("Unable to report error", exc_info=True)
def report_server_error(
self, error: Exception, source: Union[PyglsError, JsonRpcException]
):
"""Called when the server does something unexpected e.g. respond with malformed
JSON."""
async def stop(self):
self._stop_event.set()
if self._server is not None and self._server.returncode is None:
logger.debug("Terminating server process: %s", self._server.pid)
self._server.terminate()
if len(self._async_tasks) > 0:
await asyncio.gather(*self._async_tasks)
+26
View File
@@ -0,0 +1,26 @@
############################################################################
# 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. #
############################################################################
# Dynamically assigned attributes
ATTR_EXECUTE_IN_THREAD = "execute_in_thread"
ATTR_COMMAND_TYPE = "command"
ATTR_FEATURE_TYPE = "feature"
ATTR_REGISTERED_NAME = "reg_name"
ATTR_REGISTERED_TYPE = "reg_type"
# Parameters
PARAM_LS = "ls"
+215
View File
@@ -0,0 +1,215 @@
############################################################################
# Original work Copyright 2018 Palantir Technologies, Inc. #
# Original work licensed under the MIT License. #
# See ThirdPartyNotices.txt in the project root for license information. #
# All modifications Copyright (c) Open Law Library. All rights reserved. #
# #
# 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. #
############################################################################
import traceback
from typing import Set
from typing import Type
from lsprotocol.types import ResponseError
class JsonRpcException(Exception):
"""A class used as a base class for json rpc exceptions."""
def __init__(self, message=None, code=None, data=None):
message = message or getattr(self.__class__, "MESSAGE")
super().__init__(message)
self.message = message
self.code = code or getattr(self.__class__, "CODE")
self.data = data
def __eq__(self, other):
return (
isinstance(other, self.__class__)
and self.code == other.code
and self.message == other.message
)
def __hash__(self):
return hash((self.code, self.message))
@staticmethod
def from_error(error):
for exc_class in _EXCEPTIONS:
if exc_class.supports_code(error.code):
return exc_class(
code=error.code, message=error.message, data=error.data
)
return JsonRpcException(code=error.code, message=error.message, data=error.data)
@classmethod
def supports_code(cls, code):
# Defaults to UnknownErrorCode
return getattr(cls, "CODE", -32001) == code
def to_response_error(self) -> ResponseError:
return ResponseError(code=self.code, message=self.message, data=self.data)
class JsonRpcInternalError(JsonRpcException):
CODE = -32603
MESSAGE = "Internal Error"
@classmethod
def of(cls, exc_info):
exc_type, exc_value, exc_tb = exc_info
return cls(
message="".join(
traceback.format_exception_only(exc_type, exc_value)
).strip(),
data={"traceback": traceback.format_tb(exc_tb)},
)
class JsonRpcInvalidParams(JsonRpcException):
CODE = -32602
MESSAGE = "Invalid Params"
class JsonRpcInvalidRequest(JsonRpcException):
CODE = -32600
MESSAGE = "Invalid Request"
class JsonRpcMethodNotFound(JsonRpcException):
CODE = -32601
MESSAGE = "Method Not Found"
@classmethod
def of(cls, method):
return cls(message=cls.MESSAGE + ": " + method)
class JsonRpcParseError(JsonRpcException):
CODE = -32700
MESSAGE = "Parse Error"
class JsonRpcRequestCancelled(JsonRpcException):
CODE = -32800
MESSAGE = "Request Cancelled"
class JsonRpcContentModified(JsonRpcException):
CODE = -32801
MESSAGE = "Content Modified"
class JsonRpcServerNotInitialized(JsonRpcException):
CODE = -32002
MESSAGE = "ServerNotInitialized"
class JsonRpcUnknownErrorCode(JsonRpcException):
CODE = -32001
MESSAGE = "UnknownErrorCode"
class JsonRpcReservedErrorRangeStart(JsonRpcException):
CODE = -32099
MESSAGE = "jsonrpcReservedErrorRangeStart"
class JsonRpcReservedErrorRangeEnd(JsonRpcException):
CODE = -32000
MESSAGE = "jsonrpcReservedErrorRangeEnd"
class LspReservedErrorRangeStart(JsonRpcException):
CODE = -32899
MESSAGE = "lspReservedErrorRangeStart"
class LspReservedErrorRangeEnd(JsonRpcException):
CODE = -32800
MESSAGE = "lspReservedErrorRangeEnd"
class JsonRpcServerError(JsonRpcException):
def __init__(self, message, code, data=None):
if not _is_server_error_code(code):
raise ValueError("Error code should be in range -32099 - -32000")
super().__init__(message=message, code=code, data=data)
@classmethod
def supports_code(cls, code):
return _is_server_error_code(code)
def _is_server_error_code(code):
return -32099 <= code <= -32000
_EXCEPTIONS: Set[Type[JsonRpcException]] = {
JsonRpcInternalError,
JsonRpcInvalidParams,
JsonRpcInvalidRequest,
JsonRpcMethodNotFound,
JsonRpcParseError,
JsonRpcRequestCancelled,
JsonRpcServerError,
}
class PyglsError(Exception):
pass
class CommandAlreadyRegisteredError(PyglsError):
def __init__(self, command_name):
self.command_name = command_name
def __repr__(self):
return f'Command "{self.command_name}" is already registered.'
class FeatureAlreadyRegisteredError(PyglsError):
def __init__(self, feature_name):
self.feature_name = feature_name
def __repr__(self):
return f'Feature "{self.feature_name}" is already registered.'
class FeatureRequestError(PyglsError):
pass
class FeatureNotificationError(PyglsError):
pass
class MethodTypeNotRegisteredError(PyglsError):
def __init__(self, name):
self.name = name
def __repr__(self):
return f'"{self.name}" is not added to `pygls.lsp.LSP_METHODS_MAP`.'
class ThreadDecoratorError(PyglsError):
pass
class ValidationError(PyglsError):
def __init__(self, errors=None):
self.errors = errors or []
def __repr__(self):
opt_errs = "\n-".join([e for e in self.errors])
return f"Missing options: {opt_errs}"
+244
View File
@@ -0,0 +1,244 @@
############################################################################
# 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. #
############################################################################
import asyncio
import functools
import inspect
import itertools
import logging
from typing import Any, Callable, Dict, Optional, get_type_hints
from pygls.constants import (
ATTR_COMMAND_TYPE,
ATTR_EXECUTE_IN_THREAD,
ATTR_FEATURE_TYPE,
ATTR_REGISTERED_NAME,
ATTR_REGISTERED_TYPE,
PARAM_LS,
)
from pygls.exceptions import (
CommandAlreadyRegisteredError,
FeatureAlreadyRegisteredError,
ThreadDecoratorError,
ValidationError,
)
from pygls.lsp import get_method_options_type, is_instance
logger = logging.getLogger(__name__)
def assign_help_attrs(f, reg_name, reg_type):
setattr(f, ATTR_REGISTERED_NAME, reg_name)
setattr(f, ATTR_REGISTERED_TYPE, reg_type)
def assign_thread_attr(f):
setattr(f, ATTR_EXECUTE_IN_THREAD, True)
def get_help_attrs(f):
return getattr(f, ATTR_REGISTERED_NAME, None), getattr(
f, ATTR_REGISTERED_TYPE, None
)
def has_ls_param_or_annotation(f, annotation):
"""Returns true if callable has first parameter named `ls` or type of
annotation"""
try:
sig = inspect.signature(f)
first_p = next(itertools.islice(sig.parameters.values(), 0, 1))
return first_p.name == PARAM_LS or get_type_hints(f)[first_p.name] == annotation
except Exception:
return False
def is_thread_function(f):
return getattr(f, ATTR_EXECUTE_IN_THREAD, False)
def wrap_with_server(f, server):
"""Returns a new callable/coroutine with server as first argument."""
if not has_ls_param_or_annotation(f, type(server)):
return f
if asyncio.iscoroutinefunction(f):
async def wrapped(*args, **kwargs):
return await f(server, *args, **kwargs)
else:
wrapped = functools.partial(f, server)
if is_thread_function(f):
assign_thread_attr(wrapped)
return wrapped
class FeatureManager:
"""A class for managing server features.
Attributes:
_builtin_features(dict): Predefined set of lsp methods
_feature_options(dict): Registered feature's options
_features(dict): Registered features
_commands(dict): Registered commands
server(LanguageServer): Reference to the language server
If passed, server will be passed to registered
features/commands with first parameter:
1. ls - parameter naming convention
2. name: LanguageServer - add typings
"""
def __init__(self, server=None, converter=None):
self._builtin_features = {}
self._feature_options = {}
self._features = {}
self._commands = {}
self.server = server
self.converter = converter
def add_builtin_feature(self, feature_name: str, func: Callable) -> None:
"""Registers builtin (predefined) feature."""
self._builtin_features[feature_name] = func
logger.info("Registered builtin feature %s", feature_name)
@property
def builtin_features(self) -> Dict:
"""Returns server builtin features."""
return self._builtin_features
def command(self, command_name: str) -> Callable:
"""Decorator used to register custom commands.
Example:
@ls.command('myCustomCommand')
"""
def decorator(f):
# Validate
if command_name is None or command_name.strip() == "":
logger.error("Missing command name.")
raise ValidationError("Command name is required.")
# Check if not already registered
if command_name in self._commands:
logger.error('Command "%s" is already registered.', command_name)
raise CommandAlreadyRegisteredError(command_name)
assign_help_attrs(f, command_name, ATTR_COMMAND_TYPE)
wrapped = wrap_with_server(f, self.server)
# Assign help attributes for thread decorator
assign_help_attrs(wrapped, command_name, ATTR_COMMAND_TYPE)
self._commands[command_name] = wrapped
logger.info('Command "%s" is successfully registered.', command_name)
return f
return decorator
@property
def commands(self) -> Dict:
"""Returns registered custom commands."""
return self._commands
def feature(
self,
feature_name: str,
options: Optional[Any] = None,
) -> Callable:
"""Decorator used to register LSP features.
Example:
@ls.feature('textDocument/completion', CompletionItems(trigger_characters=['.']))
"""
def decorator(f):
# Validate
if feature_name is None or feature_name.strip() == "":
logger.error("Missing feature name.")
raise ValidationError("Feature name is required.")
# Add feature if not exists
if feature_name in self._features:
logger.error('Feature "%s" is already registered.', feature_name)
raise FeatureAlreadyRegisteredError(feature_name)
assign_help_attrs(f, feature_name, ATTR_FEATURE_TYPE)
wrapped = wrap_with_server(f, self.server)
# Assign help attributes for thread decorator
assign_help_attrs(wrapped, feature_name, ATTR_FEATURE_TYPE)
self._features[feature_name] = wrapped
if options:
options_type = get_method_options_type(feature_name)
if options_type and not is_instance(
self.converter, options, options_type
):
raise TypeError(
(
f'Options of method "{feature_name}"'
f" should be instance of type {options_type}"
)
)
self._feature_options[feature_name] = options
logger.info('Registered "%s" with options "%s"', feature_name, options)
return f
return decorator
@property
def feature_options(self) -> Dict:
"""Returns feature options for registered features."""
return self._feature_options
@property
def features(self) -> Dict:
"""Returns registered features"""
return self._features
def thread(self) -> Callable:
"""Decorator that mark function to execute it in a thread."""
def decorator(f):
if asyncio.iscoroutinefunction(f):
raise ThreadDecoratorError(
f'Thread decorator cannot be used with async functions "{f.__name__}"'
)
# Allow any decorator order
try:
reg_name = getattr(f, ATTR_REGISTERED_NAME)
reg_type = getattr(f, ATTR_REGISTERED_TYPE)
if reg_type is ATTR_FEATURE_TYPE:
assign_thread_attr(self.features[reg_name])
elif reg_type is ATTR_COMMAND_TYPE:
assign_thread_attr(self.commands[reg_name])
except AttributeError:
assign_thread_attr(f)
return f
return decorator
+139
View File
@@ -0,0 +1,139 @@
############################################################################
# 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. #
############################################################################
import cattrs
from typing import Any, Callable, List, Optional, Union
from lsprotocol.types import (
ALL_TYPES_MAP,
METHOD_TO_TYPES,
TEXT_DOCUMENT_DID_SAVE,
TEXT_DOCUMENT_SEMANTIC_TOKENS_FULL,
TEXT_DOCUMENT_SEMANTIC_TOKENS_FULL_DELTA,
TEXT_DOCUMENT_SEMANTIC_TOKENS_RANGE,
WORKSPACE_DID_CREATE_FILES,
WORKSPACE_DID_DELETE_FILES,
WORKSPACE_DID_RENAME_FILES,
WORKSPACE_WILL_CREATE_FILES,
WORKSPACE_WILL_DELETE_FILES,
WORKSPACE_WILL_RENAME_FILES,
FileOperationRegistrationOptions,
SaveOptions,
SemanticTokensLegend,
SemanticTokensRegistrationOptions,
ShowDocumentResult,
)
from pygls.exceptions import MethodTypeNotRegisteredError
ConfigCallbackType = Callable[[List[Any]], None]
ShowDocumentCallbackType = Callable[[ShowDocumentResult], None]
METHOD_TO_OPTIONS = {
TEXT_DOCUMENT_DID_SAVE: SaveOptions,
TEXT_DOCUMENT_SEMANTIC_TOKENS_FULL: Union[
SemanticTokensLegend, SemanticTokensRegistrationOptions
],
TEXT_DOCUMENT_SEMANTIC_TOKENS_FULL_DELTA: Union[
SemanticTokensLegend, SemanticTokensRegistrationOptions
],
TEXT_DOCUMENT_SEMANTIC_TOKENS_RANGE: Union[
SemanticTokensLegend, SemanticTokensRegistrationOptions
],
WORKSPACE_DID_CREATE_FILES: FileOperationRegistrationOptions,
WORKSPACE_DID_DELETE_FILES: FileOperationRegistrationOptions,
WORKSPACE_DID_RENAME_FILES: FileOperationRegistrationOptions,
WORKSPACE_WILL_CREATE_FILES: FileOperationRegistrationOptions,
WORKSPACE_WILL_DELETE_FILES: FileOperationRegistrationOptions,
WORKSPACE_WILL_RENAME_FILES: FileOperationRegistrationOptions,
}
def get_method_registration_options_type(
method_name, lsp_methods_map=METHOD_TO_TYPES
) -> Optional[Any]:
"""The type corresponding with a method's options when dynamically registering
capability for it."""
try:
return lsp_methods_map[method_name][3]
except KeyError:
raise MethodTypeNotRegisteredError(method_name)
def get_method_options_type(
method_name, lsp_options_map=METHOD_TO_OPTIONS, lsp_methods_map=METHOD_TO_TYPES
) -> Optional[Any]:
"""Return the type corresponding with a method's ``ServerCapabilities`` fields.
In the majority of cases this simply means returning the ``<MethodName>Options``
type, which we can easily derive from the method's
``<MethodName>RegistrationOptions`` type.
However, where the options are more involved (such as semantic tokens) and
``pygls`` does some extra work to help derive the options for the user the type
has to be provided via the ``lsp_options_map``
Arguments:
method_name:
The lsp method name to retrieve the options for
lsp_options_map:
The map used to override the default options type finding behavior
lsp_methods_map:
The standard map used to look up the various method types.
"""
options_type = lsp_options_map.get(method_name, None)
if options_type is not None:
return options_type
registration_type = get_method_registration_options_type(
method_name, lsp_methods_map
)
if registration_type is None:
return None
type_name = registration_type.__name__.replace("Registration", "")
options_type = ALL_TYPES_MAP.get(type_name, None)
if options_type is None:
raise MethodTypeNotRegisteredError(method_name)
return options_type
def get_method_params_type(method_name, lsp_methods_map=METHOD_TO_TYPES):
try:
return lsp_methods_map[method_name][2]
except KeyError:
raise MethodTypeNotRegisteredError(method_name)
def get_method_return_type(method_name, lsp_methods_map=METHOD_TO_TYPES):
try:
return lsp_methods_map[method_name][1]
except KeyError:
raise MethodTypeNotRegisteredError(method_name)
def is_instance(cv: cattrs.Converter, o, t):
try:
cv.unstructure(o, t)
return True
except TypeError:
return False
File diff suppressed because it is too large Load Diff
+79
View File
@@ -0,0 +1,79 @@
import asyncio
from concurrent.futures import Future
from typing import Dict
from lsprotocol.types import (
PROGRESS,
WINDOW_WORK_DONE_PROGRESS_CREATE,
ProgressParams,
ProgressToken,
WorkDoneProgressBegin,
WorkDoneProgressEnd,
WorkDoneProgressReport,
WorkDoneProgressCreateParams,
)
from pygls.protocol import LanguageServerProtocol
class Progress:
"""A class for working with client's progress bar.
Attributes:
_lsp(LanguageServerProtocol): Language server protocol instance
tokens(dict): Holds futures for work done progress tokens that are
already registered. These futures will be cancelled if the client
sends a cancel work done process notification.
"""
def __init__(self, lsp: LanguageServerProtocol) -> None:
self._lsp = lsp
self.tokens: Dict[ProgressToken, Future] = {}
def _check_token_registered(self, token: ProgressToken) -> None:
if token in self.tokens:
raise Exception("Token is already registered!")
def _register_token(self, token: ProgressToken) -> None:
self.tokens[token] = Future()
def create(self, token: ProgressToken, callback=None) -> Future:
"""Create a server initiated work done progress."""
self._check_token_registered(token)
def on_created(*args, **kwargs):
self._register_token(token)
if callback is not None:
callback(*args, **kwargs)
return self._lsp.send_request(
WINDOW_WORK_DONE_PROGRESS_CREATE,
WorkDoneProgressCreateParams(token=token),
on_created,
)
async def create_async(self, token: ProgressToken) -> asyncio.Future:
"""Create a server initiated work done progress."""
self._check_token_registered(token)
result = await self._lsp.send_request_async(
WINDOW_WORK_DONE_PROGRESS_CREATE,
WorkDoneProgressCreateParams(token=token),
)
self._register_token(token)
return result
def begin(self, token: ProgressToken, value: WorkDoneProgressBegin) -> None:
"""Notify beginning of work."""
# Register cancellation future for the case of client initiated progress
self.tokens.setdefault(token, Future())
return self._lsp.notify(PROGRESS, ProgressParams(token=token, value=value))
def report(self, token: ProgressToken, value: WorkDoneProgressReport) -> None:
"""Notify progress of work."""
self._lsp.notify(PROGRESS, ProgressParams(token=token, value=value))
def end(self, token: ProgressToken, value: WorkDoneProgressEnd) -> None:
"""Notify end of work."""
self._lsp.notify(PROGRESS, ProgressParams(token=token, value=value))
+78
View File
@@ -0,0 +1,78 @@
import json
from typing import Any
from collections import namedtuple
from lsprotocol import converters
from pygls.protocol.json_rpc import (
JsonRPCNotification,
JsonRPCProtocol,
JsonRPCRequestMessage,
JsonRPCResponseMessage,
)
from pygls.protocol.language_server import LanguageServerProtocol, lsp_method
from pygls.protocol.lsp_meta import LSPMeta, call_user_feature
def _dict_to_object(d: Any):
"""Create nested objects (namedtuple) from dict."""
if d is None:
return None
if not isinstance(d, dict):
return d
type_name = d.pop("type_name", "Object")
return json.loads(
json.dumps(d),
object_hook=lambda p: namedtuple(type_name, p.keys(), rename=True)(*p.values()),
)
def _params_field_structure_hook(obj, cls):
if "params" in obj:
obj["params"] = _dict_to_object(obj["params"])
return cls(**obj)
def _result_field_structure_hook(obj, cls):
if "result" in obj:
obj["result"] = _dict_to_object(obj["result"])
return cls(**obj)
def default_converter():
"""Default converter factory function."""
converter = converters.get_converter()
converter.register_structure_hook(
JsonRPCRequestMessage, _params_field_structure_hook
)
converter.register_structure_hook(
JsonRPCResponseMessage, _result_field_structure_hook
)
converter.register_structure_hook(JsonRPCNotification, _params_field_structure_hook)
return converter
__all__ = (
"JsonRPCProtocol",
"LanguageServerProtocol",
"JsonRPCRequestMessage",
"JsonRPCResponseMessage",
"JsonRPCNotification",
"LSPMeta",
"call_user_feature",
"_dict_to_object",
"_params_field_structure_hook",
"_result_field_structure_hook",
"default_converter",
"lsp_method",
)
+560
View File
@@ -0,0 +1,560 @@
############################################################################
# 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 enum
import json
import logging
import re
import sys
import uuid
import traceback
from concurrent.futures import Future
from functools import partial
from typing import (
Any,
Dict,
List,
Optional,
Type,
Union,
TYPE_CHECKING,
)
if TYPE_CHECKING:
from pygls.server import LanguageServer, WebSocketTransportAdapter
import attrs
from cattrs.errors import ClassValidationError
from lsprotocol.types import (
CANCEL_REQUEST,
EXIT,
WORKSPACE_EXECUTE_COMMAND,
ResponseError,
ResponseErrorMessage,
)
from pygls.exceptions import (
JsonRpcException,
JsonRpcInternalError,
JsonRpcInvalidParams,
JsonRpcMethodNotFound,
JsonRpcRequestCancelled,
FeatureNotificationError,
FeatureRequestError,
)
from pygls.feature_manager import FeatureManager, is_thread_function
logger = logging.getLogger(__name__)
@attrs.define
class JsonRPCNotification:
"""A class that represents a generic json rpc notification message.
Used as a fallback for unknown types.
"""
method: str
jsonrpc: str
params: Any
@attrs.define
class JsonRPCRequestMessage:
"""A class that represents a generic json rpc request message.
Used as a fallback for unknown types.
"""
id: Union[int, str]
method: str
jsonrpc: str
params: Any
@attrs.define
class JsonRPCResponseMessage:
"""A class that represents a generic json rpc response message.
Used as a fallback for unknown types.
"""
id: Union[int, str]
jsonrpc: str
result: Any
class JsonRPCProtocol(asyncio.Protocol):
"""Json RPC protocol implementation using on top of `asyncio.Protocol`.
Specification of the protocol can be found here:
https://www.jsonrpc.org/specification
This class provides bidirectional communication which is needed for LSP.
"""
CHARSET = "utf-8"
CONTENT_TYPE = "application/vscode-jsonrpc"
MESSAGE_PATTERN = re.compile(
rb"^(?:[^\r\n]+\r\n)*"
+ rb"Content-Length: (?P<length>\d+)\r\n"
+ rb"(?:[^\r\n]+\r\n)*\r\n"
+ rb"(?P<body>{.*)",
re.DOTALL,
)
VERSION = "2.0"
def __init__(self, server: LanguageServer, converter):
self._server = server
self._converter = converter
self._shutdown = False
# Book keeping for in-flight requests
self._request_futures: Dict[str, Future[Any]] = {}
self._result_types: Dict[str, Any] = {}
self.fm = FeatureManager(server, converter)
self.transport: Optional[
Union[asyncio.WriteTransport, WebSocketTransportAdapter]
] = None
self._message_buf: List[bytes] = []
self._send_only_body = False
def __call__(self):
return self
def _execute_notification(self, handler, *params):
"""Executes notification message handler."""
if asyncio.iscoroutinefunction(handler):
future = asyncio.ensure_future(handler(*params))
future.add_done_callback(self._execute_notification_callback)
else:
if is_thread_function(handler):
self._server.thread_pool.apply_async(handler, (*params,))
else:
handler(*params)
def _execute_notification_callback(self, future):
"""Success callback used for coroutine notification message."""
if future.exception():
try:
raise future.exception()
except Exception:
error = JsonRpcInternalError.of(sys.exc_info())
logger.exception('Exception occurred in notification: "%s"', error)
# Revisit. Client does not support response with msg_id = None
# https://stackoverflow.com/questions/31091376/json-rpc-2-0-allow-notifications-to-have-an-error-response
# self._send_response(None, error=error)
def _execute_request(self, msg_id, handler, params):
"""Executes request message handler."""
if asyncio.iscoroutinefunction(handler):
future = asyncio.ensure_future(handler(params))
self._request_futures[msg_id] = future
future.add_done_callback(partial(self._execute_request_callback, msg_id))
else:
# Can't be canceled
if is_thread_function(handler):
self._server.thread_pool.apply_async(
handler,
(params,),
callback=partial(
self._send_response,
msg_id,
),
error_callback=partial(self._execute_request_err_callback, msg_id),
)
else:
self._send_response(msg_id, handler(params))
def _execute_request_callback(self, msg_id, future):
"""Success callback used for coroutine request message."""
try:
if not future.cancelled():
self._send_response(msg_id, result=future.result())
else:
self._send_response(
msg_id,
error=JsonRpcRequestCancelled(
f'Request with id "{msg_id}" is canceled'
).to_response_error(),
)
self._request_futures.pop(msg_id, None)
except Exception:
error = JsonRpcInternalError.of(sys.exc_info())
logger.exception('Exception occurred for message "%s": %s', msg_id, error)
self._send_response(msg_id, error=error.to_response_error())
def _execute_request_err_callback(self, msg_id, exc):
"""Error callback used for coroutine request message."""
exc_info = (type(exc), exc, None)
error = JsonRpcInternalError.of(exc_info)
logger.exception('Exception occurred for message "%s": %s', msg_id, error)
self._send_response(msg_id, error=error.to_response_error())
def _get_handler(self, feature_name):
"""Returns builtin or used defined feature by name if exists."""
try:
return self.fm.builtin_features[feature_name]
except KeyError:
try:
return self.fm.features[feature_name]
except KeyError:
raise JsonRpcMethodNotFound.of(feature_name)
def _handle_cancel_notification(self, msg_id):
"""Handles a cancel notification from the client."""
future = self._request_futures.pop(msg_id, None)
if not future:
logger.warning('Cancel notification for unknown message id "%s"', msg_id)
return
# Will only work if the request hasn't started executing
if future.cancel():
logger.info('Cancelled request with id "%s"', msg_id)
def _handle_notification(self, method_name, params):
"""Handles a notification from the client."""
if method_name == CANCEL_REQUEST:
self._handle_cancel_notification(params.id)
return
try:
handler = self._get_handler(method_name)
self._execute_notification(handler, params)
except (KeyError, JsonRpcMethodNotFound):
logger.warning('Ignoring notification for unknown method "%s"', method_name)
except Exception as error:
logger.exception(
'Failed to handle notification "%s": %s',
method_name,
params,
exc_info=True,
)
self._server._report_server_error(error, FeatureNotificationError)
def _handle_request(self, msg_id, method_name, params):
"""Handles a request from the client."""
try:
handler = self._get_handler(method_name)
# workspace/executeCommand is a special case
if method_name == WORKSPACE_EXECUTE_COMMAND:
handler(params, msg_id)
else:
self._execute_request(msg_id, handler, params)
except JsonRpcException as error:
logger.exception(
"Failed to handle request %s %s %s",
msg_id,
method_name,
params,
exc_info=True,
)
self._send_response(msg_id, None, error.to_response_error())
self._server._report_server_error(error, FeatureRequestError)
except Exception as error:
logger.exception(
"Failed to handle request %s %s %s",
msg_id,
method_name,
params,
exc_info=True,
)
err = JsonRpcInternalError.of(sys.exc_info()).to_response_error()
self._send_response(msg_id, None, err)
self._server._report_server_error(error, FeatureRequestError)
def _handle_response(self, msg_id, result=None, error=None):
"""Handles a response from the client."""
future = self._request_futures.pop(msg_id, None)
if not future:
logger.warning('Received response to unknown message id "%s"', msg_id)
return
if error is not None:
logger.debug('Received error response to message "%s": %s', msg_id, error)
future.set_exception(JsonRpcException.from_error(error))
else:
logger.debug('Received result for message "%s": %s', msg_id, result)
future.set_result(result)
def _serialize_message(self, data):
"""Function used to serialize data sent to the client."""
if hasattr(data, "__attrs_attrs__"):
return self._converter.unstructure(data)
if isinstance(data, enum.Enum):
return data.value
return data.__dict__
def _deserialize_message(self, data):
"""Function used to deserialize data recevied from the client."""
if "jsonrpc" not in data:
return data
try:
if "id" in data:
if "error" in data:
return self._converter.structure(data, ResponseErrorMessage)
elif "method" in data:
request_type = (
self.get_message_type(data["method"]) or JsonRPCRequestMessage
)
return self._converter.structure(data, request_type)
else:
response_type = (
self._result_types.pop(data["id"]) or JsonRPCResponseMessage
)
return self._converter.structure(data, response_type)
else:
method = data.get("method", "")
notification_type = self.get_message_type(method) or JsonRPCNotification
return self._converter.structure(data, notification_type)
except ClassValidationError as exc:
logger.error("Unable to deserialize message\n%s", traceback.format_exc())
raise JsonRpcInvalidParams() from exc
except Exception as exc:
logger.error("Unable to deserialize message\n%s", traceback.format_exc())
raise JsonRpcInternalError() from exc
def _procedure_handler(self, message):
"""Delegates message to handlers depending on message type."""
if message.jsonrpc != JsonRPCProtocol.VERSION:
logger.warning('Unknown message "%s"', message)
return
if self._shutdown and getattr(message, "method", "") != EXIT:
logger.warning("Server shutting down. No more requests!")
return
if hasattr(message, "method"):
if hasattr(message, "id"):
logger.debug("Request message received.")
self._handle_request(message.id, message.method, message.params)
else:
logger.debug("Notification message received.")
self._handle_notification(message.method, message.params)
else:
if hasattr(message, "error"):
logger.debug("Error message received.")
self._handle_response(message.id, None, message.error)
else:
logger.debug("Response message received.")
self._handle_response(message.id, message.result)
def _send_data(self, data):
"""Sends data to the client."""
if not data:
return
if self.transport is None:
logger.error("Unable to send data, no available transport!")
return
try:
body = json.dumps(data, default=self._serialize_message)
logger.info("Sending data: %s", body)
if self._send_only_body:
# Mypy/Pyright seem to think `write()` wants `"bytes | bytearray | memoryview"`
# But runtime errors with anything but `str`.
self.transport.write(body) # type: ignore
return
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)
def _send_response(
self, msg_id, result=None, error: Union[ResponseError, None] = None
):
"""Sends a JSON RPC response to the client.
Args:
msg_id(str): Id from request
result(any): Result returned by handler
error(any): Error returned by handler
"""
if error is not None:
response = ResponseErrorMessage(id=msg_id, error=error)
else:
response_type = self._result_types.pop(msg_id, JsonRPCResponseMessage)
response = response_type(
id=msg_id, result=result, jsonrpc=JsonRPCProtocol.VERSION
)
self._send_data(response)
def connection_lost(self, exc):
"""Method from base class, called when connection is lost, in which case we
want to shutdown the server's process as well.
"""
logger.error("Connection to the client is lost! Shutting down the server.")
sys.exit(1)
def connection_made( # type: ignore # see: https://github.com/python/typeshed/issues/3021
self,
transport: asyncio.Transport,
):
"""Method from base class, called when connection is established"""
self.transport = transport
def data_received(self, data: bytes):
try:
self._data_received(data)
except Exception as error:
logger.exception("Error receiving data", exc_info=True)
self._server._report_server_error(error, JsonRpcInternalError)
def _data_received(self, data: bytes):
"""Method from base class, called when server receives the data"""
logger.debug("Received %r", data)
while len(data):
# Append the incoming chunk to the message buffer
self._message_buf.append(data)
# Look for the body of the message
message = b"".join(self._message_buf)
found = JsonRPCProtocol.MESSAGE_PATTERN.fullmatch(message)
body = found.group("body") if found else b""
length = int(found.group("length")) if found else 1
if len(body) < length:
# Message is incomplete; bail until more data arrives
return
# Message is complete;
# extract the body and any remaining data,
# and reset the buffer for the next message
body, data = body[:length], body[length:]
self._message_buf = []
# Parse the body
self._procedure_handler(
json.loads(
body.decode(self.CHARSET), object_hook=self._deserialize_message
)
)
def get_message_type(self, method: str) -> Optional[Type]:
"""Return the type definition of the message associated with the given method."""
return None
def get_result_type(self, method: str) -> Optional[Type]:
"""Return the type definition of the result associated with the given method."""
return None
def notify(self, method: str, params=None):
"""Sends a JSON RPC notification to the client."""
logger.debug("Sending notification: '%s' %s", method, params)
notification_type = self.get_message_type(method) or JsonRPCNotification
notification = notification_type(
method=method, params=params, jsonrpc=JsonRPCProtocol.VERSION
)
self._send_data(notification)
def send_request(self, method, params=None, callback=None, msg_id=None):
"""Sends a JSON RPC request to the client.
Args:
method(str): The method name of the message to send
params(any): The payload of the message
Returns:
Future that will be resolved once a response has been received
"""
if msg_id is None:
msg_id = str(uuid.uuid4())
request_type = self.get_message_type(method) or JsonRPCRequestMessage
logger.debug('Sending request with id "%s": %s %s', msg_id, method, params)
request = request_type(
id=msg_id,
method=method,
params=params,
jsonrpc=JsonRPCProtocol.VERSION,
)
future = Future() # type: ignore[var-annotated]
# If callback function is given, call it when result is received
if callback:
def wrapper(future: Future):
result = future.result()
logger.info("Client response for %s received: %s", params, result)
callback(result)
future.add_done_callback(wrapper)
self._request_futures[msg_id] = future
self._result_types[msg_id] = self.get_result_type(method)
self._send_data(request)
return future
def send_request_async(self, method, params=None, msg_id=None):
"""Calls `send_request` and wraps `concurrent.futures.Future` with
`asyncio.Future` so it can be used with `await` keyword.
Args:
method(str): The method name of the message to send
params(any): The payload of the message
msg_id(str|int): Optional, message id
Returns:
`asyncio.Future` that can be awaited
"""
return asyncio.wrap_future(
self.send_request(method, params=params, msg_id=msg_id)
)
def thread(self):
"""Decorator that mark function to execute it in a thread."""
return self.fm.thread()
@@ -0,0 +1,569 @@
############################################################################
# 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 json
import logging
import sys
from concurrent.futures import Future
from functools import lru_cache
from itertools import zip_longest
from typing import (
Callable,
List,
Optional,
Type,
TypeVar,
Union,
)
from pygls.capabilities import ServerCapabilitiesBuilder
from pygls.lsp import ConfigCallbackType, ShowDocumentCallbackType
from lsprotocol.types import (
CLIENT_REGISTER_CAPABILITY,
CLIENT_UNREGISTER_CAPABILITY,
EXIT,
INITIALIZE,
INITIALIZED,
METHOD_TO_TYPES,
NOTEBOOK_DOCUMENT_DID_CHANGE,
NOTEBOOK_DOCUMENT_DID_CLOSE,
NOTEBOOK_DOCUMENT_DID_OPEN,
LOG_TRACE,
SET_TRACE,
SHUTDOWN,
TEXT_DOCUMENT_DID_CHANGE,
TEXT_DOCUMENT_DID_CLOSE,
TEXT_DOCUMENT_DID_OPEN,
TEXT_DOCUMENT_PUBLISH_DIAGNOSTICS,
WINDOW_LOG_MESSAGE,
WINDOW_SHOW_DOCUMENT,
WINDOW_SHOW_MESSAGE,
WINDOW_WORK_DONE_PROGRESS_CANCEL,
WORKSPACE_APPLY_EDIT,
WORKSPACE_CONFIGURATION,
WORKSPACE_DID_CHANGE_WORKSPACE_FOLDERS,
WORKSPACE_EXECUTE_COMMAND,
WORKSPACE_SEMANTIC_TOKENS_REFRESH,
)
from lsprotocol.types import (
ApplyWorkspaceEditParams,
Diagnostic,
DidChangeNotebookDocumentParams,
DidChangeTextDocumentParams,
DidChangeWorkspaceFoldersParams,
DidCloseNotebookDocumentParams,
DidCloseTextDocumentParams,
DidOpenNotebookDocumentParams,
DidOpenTextDocumentParams,
ExecuteCommandParams,
InitializeParams,
InitializeResult,
LogMessageParams,
LogTraceParams,
MessageType,
PublishDiagnosticsParams,
RegistrationParams,
SetTraceParams,
ShowDocumentParams,
ShowMessageParams,
TraceValues,
UnregistrationParams,
WorkspaceApplyEditResponse,
WorkspaceEdit,
InitializeResultServerInfoType,
WorkspaceConfigurationParams,
WorkDoneProgressCancelParams,
)
from pygls.protocol.json_rpc import JsonRPCProtocol
from pygls.protocol.lsp_meta import LSPMeta
from pygls.uris import from_fs_path
from pygls.workspace import Workspace
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, metaclass=LSPMeta):
"""A class that represents language server protocol.
It contains implementations for generic LSP features.
Attributes:
workspace(Workspace): In memory workspace
"""
def __init__(self, server, converter):
super().__init__(server, converter)
self._workspace: Optional[Workspace] = None
self.trace = None
from pygls.progress import Progress
self.progress = Progress(self)
self.server_info = InitializeResultServerInfoType(
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) -> Optional[Type]:
"""Return LSP type definitions, as provided by `lsprotocol`"""
return METHOD_TO_TYPES.get(method, (None,))[0]
@lru_cache()
def get_result_type(self, method: str) -> Optional[Type]:
return METHOD_TO_TYPES.get(method, (None, None))[1]
def apply_edit(
self, edit: WorkspaceEdit, label: Optional[str] = None
) -> WorkspaceApplyEditResponse:
"""Sends apply edit request to the client."""
return self.send_request(
WORKSPACE_APPLY_EDIT, ApplyWorkspaceEditParams(edit=edit, label=label)
)
def apply_edit_async(
self, edit: WorkspaceEdit, label: Optional[str] = None
) -> WorkspaceApplyEditResponse:
"""Sends apply edit request to the client. Should be called with `await`"""
return self.send_request_async(
WORKSPACE_APPLY_EDIT, ApplyWorkspaceEditParams(edit=edit, label=label)
)
@lsp_method(EXIT)
def lsp_exit(self, *args) -> None:
"""Stops the server process."""
if self.transport is not None:
self.transport.close()
sys.exit(0 if self._shutdown else 1)
@lsp_method(INITIALIZE)
def lsp_initialize(self, params: InitializeParams) -> 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
# Initialize server capabilities
self.client_capabilities = params.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,
).build()
logger.debug(
"Server capabilities: %s",
json.dumps(self.server_capabilities, default=self._serialize_message),
)
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
workspace_folders = params.workspace_folders or []
self._workspace = Workspace(
root_uri,
text_document_sync_kind,
workspace_folders,
self.server_capabilities.position_encoding,
)
self.trace = TraceValues.Off
return InitializeResult(
capabilities=self.server_capabilities,
server_info=self.server_info,
)
@lsp_method(INITIALIZED)
def lsp_initialized(self, *args) -> None:
"""Notification received when client and server are connected."""
pass
@lsp_method(SHUTDOWN)
def lsp_shutdown(self, *args) -> None:
"""Request from client which asks server to shutdown."""
for future in self._request_futures.values():
future.cancel()
self._shutdown = True
return None
@lsp_method(TEXT_DOCUMENT_DID_CHANGE)
def lsp_text_document__did_change(
self, params: DidChangeTextDocumentParams
) -> None:
"""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)
@lsp_method(TEXT_DOCUMENT_DID_CLOSE)
def lsp_text_document__did_close(self, params: DidCloseTextDocumentParams) -> None:
"""Removes document from workspace."""
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_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:
"""Changes server trace value."""
self.trace = params.value
@lsp_method(WORKSPACE_DID_CHANGE_WORKSPACE_FOLDERS)
def lsp_workspace__did_change_workspace_folders(
self, params: DidChangeWorkspaceFoldersParams
) -> None:
"""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)
@lsp_method(WORKSPACE_EXECUTE_COMMAND)
def lsp_workspace__execute_command(
self, params: ExecuteCommandParams, msg_id: str
) -> None:
"""Executes commands with passed arguments and returns a value."""
cmd_handler = self.fm.commands[params.command]
self._execute_request(msg_id, cmd_handler, params.arguments)
@lsp_method(WINDOW_WORK_DONE_PROGRESS_CANCEL)
def lsp_work_done_progress_cancel(
self, params: WorkDoneProgressCancelParams
) -> None:
"""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()
def get_configuration(
self,
params: WorkspaceConfigurationParams,
callback: Optional[ConfigCallbackType] = None,
) -> Future:
"""Sends configuration request to the client.
Args:
params(WorkspaceConfigurationParams): WorkspaceConfigurationParams from lsp specs
callback(callable): Callabe which will be called after
response from the client is received
Returns:
concurrent.futures.Future object that will be resolved once a
response has been received
"""
return self.send_request(WORKSPACE_CONFIGURATION, params, callback)
def get_configuration_async(
self, params: WorkspaceConfigurationParams
) -> asyncio.Future:
"""Calls `get_configuration` method but designed to use with coroutines
Args:
params(WorkspaceConfigurationParams): WorkspaceConfigurationParams from lsp specs
Returns:
asyncio.Future that can be awaited
"""
return asyncio.wrap_future(self.get_configuration(params))
def log_trace(self, message: str, verbose: Optional[str] = None) -> None:
"""Sends trace notification to the client."""
if self.trace == TraceValues.Off:
return
params = LogTraceParams(message=message)
if verbose and self.trace == TraceValues.Verbose:
params.verbose = verbose
self.notify(LOG_TRACE, params)
def _publish_diagnostics_deprecator(
self,
params_or_uri: Union[str, PublishDiagnosticsParams],
diagnostics: Optional[List[Diagnostic]],
version: Optional[int],
**kwargs,
) -> PublishDiagnosticsParams:
if isinstance(params_or_uri, str):
message = "DEPRECATION: "
"`publish_diagnostics("
"self, doc_uri: str, diagnostics: List[Diagnostic], version: Optional[int] = None)`"
"will be replaced with `publish_diagnostics(self, params: PublishDiagnosticsParams)`"
logging.warning(message)
params = self._construct_publish_diagnostic_type(
params_or_uri, diagnostics, version, **kwargs
)
else:
params = params_or_uri
return params
def _construct_publish_diagnostic_type(
self,
uri: str,
diagnostics: Optional[List[Diagnostic]],
version: Optional[int],
**kwargs,
) -> PublishDiagnosticsParams:
if diagnostics is None:
diagnostics = []
args = {
**{"uri": uri, "diagnostics": diagnostics, "version": version},
**kwargs,
}
params = PublishDiagnosticsParams(**args) # type:ignore
return params
def publish_diagnostics(
self,
params_or_uri: Union[str, PublishDiagnosticsParams],
diagnostics: Optional[List[Diagnostic]] = None,
version: Optional[int] = None,
**kwargs,
):
"""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
)
self.notify(TEXT_DOCUMENT_PUBLISH_DIAGNOSTICS, params)
def register_capability(
self, params: RegistrationParams, callback: Optional[Callable[[], None]] = None
) -> Future:
"""Register a new capability on the client.
Args:
params(RegistrationParams): RegistrationParams from lsp specs
callback(callable): Callabe which will be called after
response from the client is received
Returns:
concurrent.futures.Future object that will be resolved once a
response has been received
"""
return self.send_request(CLIENT_REGISTER_CAPABILITY, params, callback)
def register_capability_async(self, params: RegistrationParams) -> asyncio.Future:
"""Register a new capability on the client.
Args:
params(RegistrationParams): RegistrationParams from lsp specs
Returns:
asyncio.Future object that will be resolved once a
response has been received
"""
return asyncio.wrap_future(self.register_capability(params, None))
def semantic_tokens_refresh(
self, callback: Optional[Callable[[], None]] = None
) -> Future:
"""Requesting a refresh of all semantic tokens.
Args:
callback(callable): Callabe which will be called after
response from the client is received
Returns:
concurrent.futures.Future object that will be resolved once a
response has been received
"""
return self.send_request(WORKSPACE_SEMANTIC_TOKENS_REFRESH, callback=callback)
def semantic_tokens_refresh_async(self) -> asyncio.Future:
"""Requesting a refresh of all semantic tokens.
Returns:
asyncio.Future object that will be resolved once a
response has been received
"""
return asyncio.wrap_future(self.semantic_tokens_refresh(None))
def show_document(
self,
params: ShowDocumentParams,
callback: Optional[ShowDocumentCallbackType] = None,
) -> Future:
"""Display a particular document in the user interface.
Args:
params(ShowDocumentParams): ShowDocumentParams from lsp specs
callback(callable): Callabe which will be called after
response from the client is received
Returns:
concurrent.futures.Future object that will be resolved once a
response has been received
"""
return self.send_request(WINDOW_SHOW_DOCUMENT, params, callback)
def show_document_async(self, params: ShowDocumentParams) -> asyncio.Future:
"""Display a particular document in the user interface.
Args:
params(ShowDocumentParams): ShowDocumentParams from lsp specs
Returns:
asyncio.Future object that will be resolved once a
response has been received
"""
return asyncio.wrap_future(self.show_document(params, None))
def show_message(self, message, msg_type=MessageType.Info):
"""Sends message to the client to display message."""
self.notify(
WINDOW_SHOW_MESSAGE, ShowMessageParams(type=msg_type, message=message)
)
def show_message_log(self, message, msg_type=MessageType.Log):
"""Sends message to the client's output channel."""
self.notify(
WINDOW_LOG_MESSAGE, LogMessageParams(type=msg_type, message=message)
)
def unregister_capability(
self,
params: UnregistrationParams,
callback: Optional[Callable[[], None]] = None,
) -> Future:
"""Unregister a new capability on the client.
Args:
params(UnregistrationParams): UnregistrationParams from lsp specs
callback(callable): Callabe which will be called after
response from the client is received
Returns:
concurrent.futures.Future object that will be resolved once a
response has been received
"""
return self.send_request(CLIENT_UNREGISTER_CAPABILITY, params, callback)
def unregister_capability_async(
self, params: UnregistrationParams
) -> asyncio.Future:
"""Unregister a new capability on the client.
Args:
params(UnregistrationParams): UnregistrationParams from lsp specs
callback(callable): Callabe which will be called after
response from the client is received
Returns:
asyncio.Future object that will be resolved once a
response has been received
"""
return asyncio.wrap_future(self.unregister_capability(params, None))
+51
View File
@@ -0,0 +1,51 @@
import functools
import logging
from pygls.constants import ATTR_FEATURE_TYPE
from pygls.feature_manager import assign_help_attrs
logger = logging.getLogger(__name__)
def call_user_feature(base_func, method_name):
"""Wraps generic LSP features and calls user registered feature
immediately after it.
"""
@functools.wraps(base_func)
def decorator(self, *args, **kwargs):
ret_val = base_func(self, *args, **kwargs)
try:
user_func = self.fm.features[method_name]
self._execute_notification(user_func, *args, **kwargs)
except KeyError:
pass
except Exception:
logger.exception(
'Failed to handle user defined notification "%s": %s', method_name, args
)
return ret_val
return decorator
class LSPMeta(type):
"""Wraps LSP built-in features (`lsp_` naming convention).
Built-in features cannot be overridden but user defined features with
the same LSP name will be called after them.
"""
def __new__(mcs, cls_name, cls_bases, cls):
for attr_name, attr_val in cls.items():
if callable(attr_val) and hasattr(attr_val, "method_name"):
method_name = attr_val.method_name
wrapped = call_user_feature(attr_val, method_name)
assign_help_attrs(wrapped, method_name, ATTR_FEATURE_TYPE)
cls[attr_name] = wrapped
logger.debug('Added decorator for lsp method: "%s"', attr_name)
return super().__new__(mcs, cls_name, cls_bases, cls)
+2
View File
@@ -0,0 +1,2 @@
# Marker file for PEP 561. The pygls package uses inline types.
+616
View File
@@ -0,0 +1,616 @@
############################################################################
# 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. #
############################################################################
import asyncio
import json
import logging
import re
import sys
from concurrent.futures import Future, ThreadPoolExecutor
from threading import Event
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 (
FeatureNotificationError,
JsonRpcInternalError,
PyglsError,
JsonRpcException,
FeatureRequestError,
)
from lsprotocol.types import (
ClientCapabilities,
Diagnostic,
MessageType,
NotebookDocumentSyncOptions,
RegistrationParams,
ServerCapabilities,
ShowDocumentParams,
TextDocumentSyncKind,
UnregistrationParams,
WorkspaceApplyEditResponse,
WorkspaceEdit,
WorkspaceConfigurationParams,
)
from pygls.progress import Progress
from pygls.protocol import JsonRPCProtocol, LanguageServerProtocol, default_converter
from pygls.workspace import Workspace
if not IS_PYODIDE:
from multiprocessing.pool import ThreadPool
logger = logging.getLogger(__name__)
F = TypeVar("F", bound=Callable)
ServerErrors = Union[
PyglsError,
JsonRpcException,
Type[JsonRpcInternalError],
Type[FeatureNotificationError],
Type[FeatureRequestError],
]
async def aio_readline(loop, executor, stop_event, rfile, proxy):
"""Reads data from stdin in separate thread (asynchronously)."""
CONTENT_LENGTH_PATTERN = re.compile(rb"^Content-Length: (\d+)\r\n$")
# Initialize message buffer
message = []
content_length = 0
while not stop_event.is_set() and not rfile.closed:
# Read a header line
header = await loop.run_in_executor(executor, rfile.readline)
if not header:
break
message.append(header)
# Extract content length if possible
if not content_length:
match = CONTENT_LENGTH_PATTERN.fullmatch(header)
if match:
content_length = int(match.group(1))
logger.debug("Content length: %s", content_length)
# Check if all headers have been read (as indicated by an empty line \r\n)
if content_length and not header.strip():
# Read body
body = await loop.run_in_executor(executor, rfile.read, content_length)
if not body:
break
message.append(body)
# Pass message to language server protocol
proxy(b"".join(message))
# Reset the buffer
message = []
content_length = 0
class StdOutTransportAdapter:
"""Protocol adapter which overrides write method.
Write method sends data to stdout.
"""
def __init__(self, rfile, wfile):
self.rfile = rfile
self.wfile = wfile
def close(self):
self.rfile.close()
self.wfile.close()
def write(self, data):
self.wfile.write(data)
self.wfile.flush()
class PyodideTransportAdapter:
"""Protocol adapter which overrides write method.
Write method sends data to stdout.
"""
def __init__(self, wfile):
self.wfile = wfile
def close(self):
self.wfile.close()
def write(self, data):
self.wfile.write(data)
self.wfile.flush()
class WebSocketTransportAdapter:
"""Protocol adapter which calls write method.
Write method sends data via the WebSocket interface.
"""
def __init__(self, ws, loop):
self._ws = ws
self._loop = loop
def close(self) -> None:
"""Stop the WebSocket server."""
self._ws.close()
def write(self, data: Any) -> None:
"""Create a task to write specified data into a WebSocket."""
asyncio.ensure_future(self._ws.send(data))
class Server:
"""Base server class
Parameters
----------
protocol_cls
Protocol implementation that must be derive from :class:`~pygls.protocol.JsonRPCProtocol`
converter_factory
Factory function to use when constructing a cattrs converter.
loop
The asyncio event loop
max_workers
Maximum number of workers for `ThreadPool` and `ThreadPoolExecutor`
"""
def __init__(
self,
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: 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()
asyncio.set_event_loop(loop)
self._owns_loop = True
else:
self._owns_loop = False
self.loop = loop
# TODO: Will move this to `LanguageServer` soon
self.lsp = protocol_cls(self, converter_factory()) # type: ignore
def shutdown(self):
"""Shutdown server."""
logger.info("Shutting down the server")
if self._stop_event is not None:
self._stop_event.set()
if self._thread_pool:
self._thread_pool.terminate()
self._thread_pool.join()
if self._thread_pool_executor:
self._thread_pool_executor.shutdown()
if self._server:
self._server.close()
self.loop.run_until_complete(self._server.wait_closed())
if self._owns_loop and not self.loop.is_closed():
logger.info("Closing the event loop.")
self.loop.close()
def start_io(self, stdin: Optional[TextIO] = None, stdout: Optional[TextIO] = None):
"""Starts IO server."""
logger.info("Starting IO server")
self._stop_event = Event()
transport = StdOutTransportAdapter(
stdin or sys.stdin.buffer, stdout or sys.stdout.buffer
)
self.lsp.connection_made(transport) # type: ignore[arg-type]
try:
self.loop.run_until_complete(
aio_readline(
self.loop,
self.thread_pool_executor,
self._stop_event,
stdin or sys.stdin.buffer,
self.lsp.data_received,
)
)
except BrokenPipeError:
logger.error("Connection to the client is lost! Shutting down the server.")
except (KeyboardInterrupt, SystemExit):
pass
finally:
self.shutdown()
def start_pyodide(self):
logger.info("Starting Pyodide server")
# 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) # 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:
"""Starts TCP server."""
logger.info("Starting TCP server on %s:%s", host, port)
self._stop_event = Event()
self._server = self.loop.run_until_complete( # type: ignore[assignment]
self.loop.create_server(self.lsp, host, port)
)
try:
self.loop.run_forever()
except (KeyboardInterrupt, SystemExit):
pass
finally:
self.shutdown()
def start_ws(self, host: str, port: int) -> None:
"""Starts WebSocket server."""
try:
from websockets.server import serve
except ImportError:
logger.error("Run `pip install pygls[ws]` to install `websockets`.")
sys.exit(1)
logger.info("Starting WebSocket server on {}:{}".format(host, port))
self._stop_event = Event()
self.lsp._send_only_body = True # Don't send headers within the payload
async def connection_made(websocket, _):
"""Handle new connection wrapped in the WebSocket."""
self.lsp.transport = WebSocketTransportAdapter(websocket, self.loop)
async for message in websocket:
self.lsp._procedure_handler(
json.loads(message, object_hook=self.lsp._deserialize_message)
)
start_server = serve(connection_made, host, port, loop=self.loop)
self._server = start_server.ws_server # type: ignore[assignment]
self.loop.run_until_complete(start_server)
try:
self.loop.run_forever()
except (KeyboardInterrupt, SystemExit):
pass
finally:
self._stop_event.set()
self.shutdown()
if not IS_PYODIDE:
@property
def thread_pool(self) -> ThreadPool:
"""Returns thread pool instance (lazy initialization)."""
if not self._thread_pool:
self._thread_pool = ThreadPool(processes=self._max_workers)
return self._thread_pool
@property
def thread_pool_executor(self) -> ThreadPoolExecutor:
"""Returns thread pool instance (lazy initialization)."""
if not self._thread_pool_executor:
self._thread_pool_executor = ThreadPoolExecutor(
max_workers=self._max_workers
)
return self._thread_pool_executor
class LanguageServer(Server):
"""The default LanguageServer
This class can be extended and it can be passed as a first argument to
registered commands/features.
.. |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
default_error_message = (
"Unexpected error in LSP server, see server's logs for details"
)
"""
The default error message sent to the user's editor when this server encounters an uncaught
exception.
"""
def __init__(
self,
name: str,
version: str,
loop=None,
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):
raise TypeError(
"Protocol class should be subclass of LanguageServerProtocol"
)
self.name = name
self.version = version
self._text_document_sync_kind = text_document_sync_kind
self._notebook_document_sync = notebook_document_sync
self.process_id: Optional[Union[int, None]] = None
super().__init__(protocol_cls, converter_factory, loop, max_workers)
def apply_edit(
self, edit: WorkspaceEdit, label: Optional[str] = None
) -> WorkspaceApplyEditResponse:
"""Sends apply edit request to the client."""
return self.lsp.apply_edit(edit, label)
def apply_edit_async(
self, edit: WorkspaceEdit, label: Optional[str] = None
) -> WorkspaceApplyEditResponse:
"""Sends apply edit request to the client. Should be called with `await`"""
return self.lsp.apply_edit_async(edit, label)
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
"""
return self.lsp.fm.command(command_name)
@property
def client_capabilities(self) -> ClientCapabilities:
"""The client's capabilities."""
return self.lsp.client_capabilities
def feature(
self,
feature_name: str,
options: Optional[Any] = None,
) -> 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")])
"""
return self.lsp.fm.feature(feature_name, options)
def get_configuration(
self,
params: WorkspaceConfigurationParams,
callback: Optional[ConfigCallbackType] = None,
) -> Future:
"""Gets the configuration settings from the client."""
return self.lsp.get_configuration(params, callback)
def get_configuration_async(
self, params: WorkspaceConfigurationParams
) -> asyncio.Future:
"""Gets the configuration settings from the client. Should be called with `await`"""
return self.lsp.get_configuration_async(params)
def log_trace(self, message: str, verbose: Optional[str] = None) -> None:
"""Sends trace notification to the client."""
self.lsp.log_trace(message, verbose)
@property
def progress(self) -> Progress:
"""Gets the object to manage client's progress bar."""
return self.lsp.progress
def publish_diagnostics(
self,
uri: str,
diagnostics: Optional[List[Diagnostic]] = None,
version: Optional[int] = None,
**kwargs
):
"""
Sends diagnostic notification to the client.
"""
params = self.lsp._construct_publish_diagnostic_type(
uri, diagnostics, version, **kwargs
)
self.lsp.publish_diagnostics(params, **kwargs)
def register_capability(
self, params: RegistrationParams, callback: Optional[Callable[[], None]] = None
) -> Future:
"""Register a new capability on the client."""
return self.lsp.register_capability(params, callback)
def register_capability_async(self, params: RegistrationParams) -> asyncio.Future:
"""Register a new capability on the client. Should be called with `await`"""
return self.lsp.register_capability_async(params)
def semantic_tokens_refresh(
self, callback: Optional[Callable[[], None]] = None
) -> Future:
"""Request a refresh of all semantic tokens."""
return self.lsp.semantic_tokens_refresh(callback)
def semantic_tokens_refresh_async(self) -> asyncio.Future:
"""Request a refresh of all semantic tokens. Should be called with `await`"""
return self.lsp.semantic_tokens_refresh_async()
def send_notification(self, method: str, params: object = None) -> None:
"""Sends notification to the client."""
self.lsp.notify(method, params)
@property
def server_capabilities(self) -> ServerCapabilities:
"""Return server capabilities."""
return self.lsp.server_capabilities
def show_document(
self,
params: ShowDocumentParams,
callback: Optional[ShowDocumentCallbackType] = None,
) -> Future:
"""Display a particular document in the user interface."""
return self.lsp.show_document(params, callback)
def show_document_async(self, params: ShowDocumentParams) -> asyncio.Future:
"""Display a particular document in the user interface. Should be called with `await`"""
return self.lsp.show_document_async(params)
def show_message(self, message, msg_type=MessageType.Info) -> None:
"""Sends message to the client to display message."""
self.lsp.show_message(message, msg_type)
def show_message_log(self, message, msg_type=MessageType.Log) -> None:
"""Sends message to the client's output channel."""
self.lsp.show_message_log(message, msg_type)
def _report_server_error(
self,
error: Exception,
source: ServerErrors,
):
# Prevent recursive error reporting
try:
self.report_server_error(error, source)
except Exception:
logger.warning("Failed to report error to client")
def report_server_error(self, error: Exception, source: ServerErrors):
"""
Sends error to the client for displaying.
By default this fucntion does not handle LSP request errors. This is because LSP requests
require direct responses and so already have a mechanism for including unexpected errors
in the response body.
All other errors are "out of band" in the sense that the client isn't explicitly waiting
for them. For example diagnostics are returned as notifications, not responses to requests,
and so can seemingly be sent at random. Also for example consider JSON RPC serialization
and deserialization, if a payload cannot be parsed then the whole request/response cycle
cannot be completed and so one of these "out of band" error messages is sent.
These "out of band" error messages are not a requirement of the LSP spec. Pygls simply
offers this behaviour as a recommended default. It is perfectly reasonble to override this
default.
"""
if source == FeatureRequestError:
return
self.show_message(self.default_error_message, msg_type=MessageType.Error)
def thread(self) -> Callable[[F], F]:
"""Decorator that mark function to execute it in a thread."""
return self.lsp.thread()
def unregister_capability(
self,
params: UnregistrationParams,
callback: Optional[Callable[[], None]] = None,
) -> Future:
"""Unregister a new capability on the client."""
return self.lsp.unregister_capability(params, callback)
def unregister_capability_async(
self, params: UnregistrationParams
) -> asyncio.Future:
"""Unregister a new capability on the client. Should be called with `await`"""
return self.lsp.unregister_capability_async(params)
@property
def workspace(self) -> Workspace:
"""Returns in-memory workspace."""
return self.lsp.workspace
+184
View File
@@ -0,0 +1,184 @@
############################################################################
# Original work Copyright 2017 Palantir Technologies, Inc. #
# Original work licensed under the MIT License. #
# See ThirdPartyNotices.txt in the project root for license information. #
# All modifications Copyright (c) Open Law Library. All rights reserved. #
# #
# 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. #
############################################################################
"""
A collection of URI utilities with logic built on the VSCode URI library.
https://github.com/Microsoft/vscode-uri/blob/e59cab84f5df6265aed18ae5f43552d3eef13bb9/lib/index.ts
"""
from typing import Optional, Tuple
import re
from urllib import parse
from pygls import IS_WIN
RE_DRIVE_LETTER_PATH = re.compile(r"^\/[a-zA-Z]:")
URLParts = Tuple[str, str, str, str, str, str]
def _normalize_win_path(path: str):
netloc = ""
# normalize to fwd-slashes on windows,
# on other systems bwd-slashes are valid
# filename character, eg /f\oo/ba\r.txt
if IS_WIN:
path = path.replace("\\", "/")
# check for authority as used in UNC shares
# or use the path as given
if path[:2] == "//":
idx = path.index("/", 2)
if idx == -1:
netloc = path[2:]
else:
netloc = path[2:idx]
path = path[idx:]
# Ensure that path starts with a slash
# or that it is at least a slash
if not path.startswith("/"):
path = "/" + path
# Normalize drive paths to lower case
if RE_DRIVE_LETTER_PATH.match(path):
path = path[0] + path[1].lower() + path[2:]
return path, netloc
def from_fs_path(path: str):
"""Returns a URI for the given filesystem path."""
try:
scheme = "file"
params, query, fragment = "", "", ""
path, netloc = _normalize_win_path(path)
return urlunparse((scheme, netloc, path, params, query, fragment))
except (AttributeError, TypeError):
return None
def to_fs_path(uri: str):
"""
Returns the filesystem path of the given URI.
Will handle UNC paths and normalize windows drive letters to lower-case.
Also uses the platform specific path separator. Will *not* validate the
path for invalid characters and semantics.
Will *not* look at the scheme of this URI.
"""
try:
# scheme://netloc/path;parameters?query#fragment
scheme, netloc, path, _, _, _ = urlparse(uri)
if netloc and path and scheme == "file":
# unc path: file://shares/c$/far/boo
value = f"//{netloc}{path}"
elif RE_DRIVE_LETTER_PATH.match(path):
# windows drive letter: file:///C:/far/boo
value = path[1].lower() + path[2:]
else:
# Other path
value = path
if IS_WIN:
value = value.replace("/", "\\")
return value
except TypeError:
return None
def uri_scheme(uri: str):
try:
return urlparse(uri)[0]
except (TypeError, IndexError):
return None
# TODO: Use `URLParts` type
def uri_with(
uri: str,
scheme: Optional[str] = None,
netloc: Optional[str] = None,
path: Optional[str] = None,
params: Optional[str] = None,
query: Optional[str] = None,
fragment: Optional[str] = None,
):
"""
Return a URI with the given part(s) replaced.
Parts are decoded / encoded.
"""
old_scheme, old_netloc, old_path, old_params, old_query, old_fragment = urlparse(
uri
)
if path is None:
raise Exception("`path` must not be None")
path, _ = _normalize_win_path(path)
return urlunparse(
(
scheme or old_scheme,
netloc or old_netloc,
path or old_path,
params or old_params,
query or old_query,
fragment or old_fragment,
)
)
def urlparse(uri: str):
"""Parse and decode the parts of a URI."""
scheme, netloc, path, params, query, fragment = parse.urlparse(uri)
return (
parse.unquote(scheme),
parse.unquote(netloc),
parse.unquote(path),
parse.unquote(params),
parse.unquote(query),
parse.unquote(fragment),
)
def urlunparse(parts: URLParts) -> str:
"""Unparse and encode parts of a URI."""
scheme, netloc, path, params, query, fragment = parts
# Avoid encoding the windows drive letter colon
if RE_DRIVE_LETTER_PATH.match(path):
quoted_path = path[:3] + parse.quote(path[3:])
else:
quoted_path = parse.quote(path)
return parse.urlunparse(
(
parse.quote(scheme),
parse.quote(netloc),
quoted_path,
parse.quote(params),
parse.quote(query),
parse.quote(fragment),
)
)
+97
View File
@@ -0,0 +1,97 @@
from typing import List
import warnings
from lsprotocol import types
from .workspace import Workspace
from .text_document import TextDocument
from .position_codec import PositionCodec
# For backwards compatibility
Document = TextDocument
def utf16_unit_offset(chars: str):
warnings.warn(
"'utf16_unit_offset' has been deprecated, instead use "
"'PositionCodec.utf16_unit_offset' via 'workspace.position_codec' "
"or 'text_document.position_codec'",
DeprecationWarning,
stacklevel=2,
)
_codec = PositionCodec()
return _codec.utf16_unit_offset(chars)
def utf16_num_units(chars: str):
warnings.warn(
"'utf16_num_units' has been deprecated, instead use "
"'PositionCodec.client_num_units' via 'workspace.position_codec' "
"or 'text_document.position_codec'",
DeprecationWarning,
stacklevel=2,
)
_codec = PositionCodec()
return _codec.client_num_units(chars)
def position_from_utf16(lines: List[str], position: types.Position):
warnings.warn(
"'position_from_utf16' has been deprecated, instead use "
"'PositionCodec.position_from_client_units' via "
"'workspace.position_codec' or 'text_document.position_codec'",
DeprecationWarning,
stacklevel=2,
)
_codec = PositionCodec()
return _codec.position_from_client_units(lines, position)
def position_to_utf16(lines: List[str], position: types.Position):
warnings.warn(
"'position_to_utf16' has been deprecated, instead use "
"'PositionCodec.position_to_client_units' via "
"'workspace.position_codec' or 'text_document.position_codec'",
DeprecationWarning,
stacklevel=2,
)
_codec = PositionCodec()
return _codec.position_to_client_units(lines, position)
def range_from_utf16(lines: List[str], range: types.Range):
warnings.warn(
"'range_from_utf16' has been deprecated, instead use "
"'PositionCodec.range_from_client_units' via "
"'workspace.position_codec' or 'text_document.position_codec'",
DeprecationWarning,
stacklevel=2,
)
_codec = PositionCodec()
return _codec.range_from_client_units(lines, range)
def range_to_utf16(lines: List[str], range: types.Range):
warnings.warn(
"'range_to_utf16' has been deprecated, instead use "
"'PositionCodec.range_to_client_units' via 'workspace.position_codec' "
"or 'text_document.position_codec'",
DeprecationWarning,
stacklevel=2,
)
_codec = PositionCodec()
return _codec.range_to_client_units(lines, range)
__all__ = (
"Workspace",
"TextDocument",
"PositionCodec",
"Document",
"utf16_unit_offset",
"utf16_num_units",
"position_from_utf16",
"position_to_utf16",
"range_from_utf16",
"range_to_utf16",
)
@@ -0,0 +1,206 @@
############################################################################
# Original work Copyright 2017 Palantir Technologies, Inc. #
# Original work licensed under the MIT License. #
# See ThirdPartyNotices.txt in the project root for license information. #
# All modifications Copyright (c) Open Law Library. All rights reserved. #
# #
# 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. #
############################################################################
import logging
from typing import List, Optional, Union
from lsprotocol import types
log = logging.getLogger(__name__)
class PositionCodec:
def __init__(
self,
encoding: Optional[
Union[types.PositionEncodingKind, str]
] = types.PositionEncodingKind.Utf16,
):
self.encoding = encoding
@classmethod
def is_char_beyond_multilingual_plane(cls, char: str) -> bool:
return ord(char) > 0xFFFF
def utf16_unit_offset(self, chars: str):
"""
Calculate the number of characters which need two utf-16 code units.
Arguments:
chars (str): The string to count occurrences of utf-16 code units for.
"""
return sum(self.is_char_beyond_multilingual_plane(ch) for ch in chars)
def client_num_units(self, chars: str):
"""
Calculate the length of `str` in client-supported UTF-[32|16|8] code units.
Arguments:
chars (str): The string to return the length in UTF-[32|16|8] code units for.
"""
utf32_units = len(chars)
if self.encoding == types.PositionEncodingKind.Utf32:
return utf32_units
if self.encoding == types.PositionEncodingKind.Utf8:
return utf32_units + (self.utf16_unit_offset(chars) * 2)
return utf32_units + self.utf16_unit_offset(chars)
def position_from_client_units(
self, lines: List[str], position: types.Position
) -> types.Position:
"""
Convert the position.character from UTF-[32|16|8] code units to UTF-32.
A python application can't use the character member of `Position`
directly. As per specification it is represented as a zero-based line and
character offset based on posible a UTF-[32|16|8] string representation.
All characters whose code point exceeds the Basic Multilingual Plane are
represented by 2 UTF-16 or 4 UTF-8 code units.
The offset of the closing quotation mark in x="😋" is
- 7 in UTF-8 representation
- 5 in UTF-16 representation
- 4 in UTF-32 representation
see: https://github.com/microsoft/language-server-protocol/issues/376
Arguments:
lines (list):
The content of the document which the position refers to.
position (Position):
The line and character offset in UTF-[32|16|8] code units.
Returns:
The position with `character` being converted to UTF-32 code units.
"""
if len(lines) == 0:
return types.Position(0, 0)
if position.line >= len(lines):
return types.Position(len(lines) - 1, self.client_num_units(lines[-1]))
_line = lines[position.line]
_line = _line.replace("\r\n", "\n") # TODO: it's a bit of a hack
_client_len = self.client_num_units(_line)
_utf32_len = len(_line)
if _client_len == 0:
return types.Position(position.line, 0)
_client_end_of_line = self.client_num_units(_line)
if position.character > _client_end_of_line:
position.character = _client_end_of_line - 1
_client_index = 0
utf32_index = 0
while True:
_is_searching_queried_position = _client_index < position.character
_is_before_end_of_line = utf32_index < _utf32_len
_is_searching_for_position = (
_is_searching_queried_position and _is_before_end_of_line
)
if not _is_searching_for_position:
break
_current_char = _line[utf32_index]
_is_double_width = PositionCodec.is_char_beyond_multilingual_plane(
_current_char
)
if _is_double_width:
if self.encoding == types.PositionEncodingKind.Utf32:
_client_index += 1
if self.encoding == types.PositionEncodingKind.Utf8:
_client_index += 4
_client_index += 2
else:
_client_index += 1
utf32_index += 1
position = types.Position(line=position.line, character=utf32_index)
return position
def position_to_client_units(
self, lines: List[str], position: types.Position
) -> types.Position:
"""
Convert the position.character from its internal UTF-32 representation
to client-supported UTF-[32|16|8] code units.
Arguments:
lines (list):
The content of the document which the position refers to.
position (Position):
The line and character offset in UTF-32 code units.
Returns:
The position with `character` being converted to UTF-[32|16|8] code units.
"""
try:
character = self.client_num_units(
lines[position.line][: position.character]
)
return types.Position(
line=position.line,
character=character,
)
except IndexError:
return types.Position(line=len(lines), character=0)
def range_from_client_units(
self, lines: List[str], range: types.Range
) -> types.Range:
"""
Convert range.[start|end].character from UTF-[32|16|8] code units to UTF-32.
Arguments:
lines (list):
The content of the document which the range refers to.
range (Range):
The line and character offset in UTF-[32|16|8] code units.
Returns:
The range with `character` offsets being converted to UTF-32 code units.
"""
range_new = types.Range(
start=self.position_from_client_units(lines, range.start),
end=self.position_from_client_units(lines, range.end),
)
return range_new
def range_to_client_units(
self, lines: List[str], range: types.Range
) -> types.Range:
"""
Convert range.[start|end].character from UTF-32 to UTF-[32|16|8] code units.
Arguments:
lines (list):
The content of the document which the range refers to.
range (Range):
The line and character offset in code units.
Returns:
The range with `character` offsets being converted to UTF-[32|16|8] code units.
"""
return types.Range(
start=self.position_to_client_units(lines, range.start),
end=self.position_to_client_units(lines, range.end),
)
@@ -0,0 +1,238 @@
############################################################################
# Original work Copyright 2017 Palantir Technologies, Inc. #
# Original work licensed under the MIT License. #
# See ThirdPartyNotices.txt in the project root for license information. #
# All modifications Copyright (c) Open Law Library. All rights reserved. #
# #
# 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. #
############################################################################
import io
import logging
import os
import re
from typing import List, Optional, Pattern
from lsprotocol import types
from pygls.uris import to_fs_path
from .position_codec import PositionCodec
# 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]*$")
logger = logging.getLogger(__name__)
class TextDocument(object):
def __init__(
self,
uri: str,
source: Optional[str] = None,
version: Optional[int] = None,
language_id: Optional[str] = None,
local: bool = True,
sync_kind: types.TextDocumentSyncKind = types.TextDocumentSyncKind.Incremental,
position_codec: Optional[PositionCodec] = None,
):
self.uri = uri
self.version = version
path = to_fs_path(uri)
if path is None:
raise Exception("`path` cannot be None")
self.path = path
self.language_id = language_id
self.filename: Optional[str] = os.path.basename(self.path)
self._local = local
self._source = source
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_
self._position_codec = position_codec if position_codec else PositionCodec()
def __str__(self):
return str(self.uri)
@property
def position_codec(self) -> PositionCodec:
return self._position_codec
def _apply_incremental_change(
self, change: types.TextDocumentContentChangeEvent_Type1
) -> None:
"""Apply an ``Incremental`` text change to the document"""
lines = self.lines
text = change.text
change_range = change.range
range = self._position_codec.range_from_client_units(lines, change_range)
start_line = range.start.line
start_col = range.start.character
end_line = range.end.line
end_col = range.end.character
# Check for an edit occurring at the very end of the file
if start_line == len(lines):
self._source = self.source + text
return
new = io.StringIO()
# Iterate over the existing document until we hit the edit range,
# at which point we write the new text, then loop until we hit
# the end of the range and continue writing.
for i, line in enumerate(lines):
if i < start_line:
new.write(line)
continue
if i > end_line:
new.write(line)
continue
if i == start_line:
new.write(line[:start_col])
new.write(text)
if i == end_line:
new.write(line[end_col:])
self._source = new.getvalue()
def _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, _: types.TextDocumentContentChangeEvent) -> None:
"""Apply a ``None`` text change to the document
Currently does nothing, provided for consistency.
"""
pass
def apply_change(self, change: types.TextDocumentContentChangeEvent) -> None:
"""Apply a text change to a document, considering TextDocumentSyncKind
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
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, types.TextDocumentContentChangeEvent_Type1):
if self._is_sync_kind_incremental:
self._apply_incremental_change(change)
return
# Log an error, but still perform full update to preserve existing
# assumptions in test_document/test_document_full_edit. Test breaks
# otherwise, and fixing the tests would require a broader fix to
# protocol.py.
logger.error(
"Unsupported client-provided TextDocumentContentChangeEvent. "
"Please update / submit a Pull Request to your LSP client."
)
if self._is_sync_kind_none:
self._apply_none_change(change)
else:
self._apply_full_change(change)
@property
def lines(self) -> List[str]:
return self.source.splitlines(True)
def offset_at_position(self, client_position: types.Position) -> int:
"""Return the character offset pointed at by the given client_position."""
lines = self.lines
server_position = self._position_codec.position_from_client_units(
lines, client_position
)
row, col = server_position.line, server_position.character
return col + sum(
self._position_codec.client_num_units(line) for line in lines[:row]
)
@property
def source(self) -> str:
if self._source is None:
with io.open(self.path, "r", encoding="utf-8") as f:
return f.read()
return self._source
def word_at_position(
self,
client_position: types.Position,
re_start_word: Pattern[str] = RE_START_WORD,
re_end_word: Pattern[str] = RE_END_WORD,
) -> str:
"""Return the word 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 client_position.line >= len(lines):
return ""
server_position = self._position_codec.position_from_client_units(
lines, client_position
)
row, col = server_position.line, server_position.character
line = lines[row]
# Split word in two
start = line[:col]
end = line[col:]
# Take end of start and start of end to find word
# These are guaranteed to match, even if they match the empty string
m_start = re_start_word.findall(start)
m_end = re_end_word.findall(end)
return m_start[0] + m_end[-1]
+323
View File
@@ -0,0 +1,323 @@
############################################################################
# Original work Copyright 2017 Palantir Technologies, Inc. #
# Original work licensed under the MIT License. #
# See ThirdPartyNotices.txt in the project root for license information. #
# All modifications Copyright (c) Open Law Library. All rights reserved. #
# #
# 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. #
############################################################################
import copy
import logging
import os
import warnings
from typing import Dict, List, Optional, Union
from lsprotocol import types
from lsprotocol.types import (
PositionEncodingKind,
TextDocumentSyncKind,
WorkspaceFolder,
)
from pygls.uris import to_fs_path, uri_scheme
from pygls.workspace.text_document import TextDocument
from pygls.workspace.position_codec import PositionCodec
logger = logging.getLogger(__name__)
class Workspace(object):
def __init__(
self,
root_uri: Optional[str],
sync_kind: TextDocumentSyncKind = TextDocumentSyncKind.Incremental,
workspace_folders: Optional[List[WorkspaceFolder]] = None,
position_encoding: Optional[
Union[PositionEncodingKind, str]
] = PositionEncodingKind.Utf16,
):
self._root_uri = root_uri
if self._root_uri is not None:
self._root_uri_scheme = uri_scheme(self._root_uri)
root_path = to_fs_path(self._root_uri)
if root_path is None:
raise Exception("Couldn't get `root_path` from `root_uri`")
self._root_path = root_path
else:
self._root_path = None
self._sync_kind = sync_kind
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] = {}
self._folders: Dict[str, WorkspaceFolder] = {}
self._docs: Dict[str, TextDocument] = {}
self._position_encoding = position_encoding
self._position_codec = PositionCodec(encoding=position_encoding)
if workspace_folders is not None:
for folder in workspace_folders:
self.add_folder(folder)
@property
def position_encoding(self) -> Optional[Union[PositionEncodingKind, str]]:
return self._position_encoding
@property
def position_codec(self) -> PositionCodec:
return self._position_codec
def _create_text_document(
self,
doc_uri: str,
source: Optional[str] = None,
version: Optional[int] = None,
language_id: Optional[str] = None,
) -> TextDocument:
return TextDocument(
doc_uri,
source=source,
version=version,
language_id=language_id,
sync_kind=self._sync_kind,
position_codec=self._position_codec,
)
def add_folder(self, folder: WorkspaceFolder):
self._folders[folder.uri] = folder
@property
def documents(self):
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_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._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_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._text_documents[doc_uri] = self._create_text_document(
doc_uri,
source=text_document.text,
version=text_document.version,
language_id=text_document.language_id,
)
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)
try:
del self._folders[folder_uri]
except KeyError:
pass
@property
def root_path(self):
return self._root_path
@property
def root_uri(self):
return self._root_uri
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: types.VersionedTextDocumentIdentifier,
change: types.TextDocumentContentChangeEvent,
):
doc_uri = text_doc.uri
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)