usse/scrape/venv/lib/python3.10/site-packages/esbonio/server/server.py
2023-12-22 15:26:01 +01:00

392 lines
12 KiB
Python

from __future__ import annotations
import collections
import inspect
import json
import logging
import typing
from typing import Any
from typing import Callable
from typing import Dict
from typing import List
from typing import Optional
from typing import Tuple
from typing import Type
from typing import TypeVar
import attrs
from lsprotocol import types
from pygls.server import LanguageServer
from pygls.workspace import Document
from pygls.workspace import Workspace
from ._uri import Uri
from .log import setup_logging
if typing.TYPE_CHECKING:
from .feature import LanguageFeature
__version__ = "0.16.1"
T = TypeVar("T")
LF = TypeVar("LF", bound="LanguageFeature")
@attrs.define
class ServerConfig:
"""Configuration options for the server."""
log_filter: List[str] = attrs.field(factory=list)
"""A list of logger names to restrict output to."""
log_level: str = attrs.field(default="error")
"""The logging level of server messages to display."""
show_deprecation_warnings: bool = attrs.field(default=False)
"""Developer flag to enable deprecation warnings."""
class EsbonioWorkspace(Workspace):
"""A modified version of pygls' workspace that ensures uris are always resolved."""
def get_document(self, doc_uri: str) -> Document:
uri = str(Uri.parse(doc_uri).resolve())
return super().get_document(uri)
def put_document(self, text_document: types.TextDocumentItem):
text_document.uri = str(Uri.parse(text_document.uri).resolve())
return super().put_document(text_document)
def remove_document(self, doc_uri: str):
doc_uri = str(Uri.parse(doc_uri).resolve())
return super().remove_document(doc_uri)
def update_document(
self,
text_doc: types.VersionedTextDocumentIdentifier,
change: types.TextDocumentContentChangeEvent,
):
text_doc.uri = str(Uri.parse(text_doc.uri).resolve())
return super().update_document(text_doc, change)
class EsbonioLanguageServer(LanguageServer):
"""The Esbonio language server"""
def __init__(self, logger: Optional[logging.Logger] = None, *args, **kwargs):
if "name" not in kwargs:
kwargs["name"] = "esbonio"
if "version" not in kwargs:
kwargs["version"] = __version__
super().__init__(*args, **kwargs)
self._diagnostics: Dict[Tuple[str, Uri], List[types.Diagnostic]] = {}
"""Where we store and manage diagnostics."""
self._loaded_extensions: Dict[str, Any] = {}
"""Record of server modules that have been loaded."""
self._features: Dict[Type[LanguageFeature], LanguageFeature] = {}
"""The collection of language features registered with the server."""
self.logger = logger or logging.getLogger(__name__)
"""The logger instance to use."""
self.converter = self.lsp._converter
"""The cattrs converter instance we should use."""
self.initialization_options: Optional[types.LSPAny] = None
"""The received initializaion options (if any)"""
def __iter__(self):
return iter(self._features.items())
def initialize(self, params: types.InitializeParams):
self.logger.info("Initialising esbonio v%s", __version__)
if (client := params.client_info) is not None:
self.logger.info("Language client: %s %s", client.name, client.version)
# TODO: Propose patch to pygls for providing custom Workspace implementations.
self.lsp._workspace = EsbonioWorkspace(
self.workspace.root_uri,
self.workspace._sync_kind,
list(self.workspace.folders.values()),
)
self.initialization_options = params.initialization_options
# TODO: Merge this with self.get_user_config somehow...
server_config = ServerConfig()
if self.initialization_options is not None:
try:
config = self.initialization_options.get("server", {})
server_config = self.converter.structure(config, ServerConfig)
except Exception:
self.logger.error("Unable to parse server config", exc_info=True)
setup_logging(self, server_config)
def load_extension(self, name: str, setup: Callable):
"""Load the given setup function as an extension.
If an extension with the given ``name`` already exists, the given setup function
will be ignored.
The ``setup`` function can declare dependencies in the form of type
annotations.
.. code-block:: python
from esbonio.lsp.roles import Roles
from esbonio.lsp.sphinx import SphinxLanguageServer
def esbonio_setup(rst: SphinxLanguageServer, roles: Roles):
...
In this example the setup function is requesting instances of the
:class:`~esbonio.lsp.sphinx.SphinxLanguageServer` and the
:class:`~esbonio.lsp.roles.Roles` language feature.
Parameters
----------
name
The name to give the extension
setup
The setup function to call
"""
if name in self._loaded_extensions:
self.logger.debug("Skipping extension '%s', already loaded", name)
return
arguments = _get_setup_arguments(self, setup, name)
if not arguments:
return
try:
setup(**arguments)
self.logger.debug("Loaded extension '%s'", name)
self._loaded_extensions[name] = setup
except Exception:
self.logger.error("Unable to load extension '%s'", name, exc_info=True)
def add_feature(self, feature: LanguageFeature):
"""Register a language feature with the server.
Parameters
----------
feature
The language feature
"""
feature_cls = type(feature)
if feature_cls in self._features:
name = f"{feature_cls.__module__}.{feature_cls.__name__}"
raise RuntimeError(f"Feature '{name}' has already been registered")
self._features[feature_cls] = feature
def get_feature(self, feature_cls: Type[LF]) -> Optional[LF]:
"""Returns the requested language feature if it exists, otherwise it returns
``None``.
Parameters
----------
feature_cls
The class definiion of the feature to retrieve
"""
return self._features.get(feature_cls, None) # type: ignore
async def get_user_config(
self,
section: str,
spec: Type[T],
scope: Optional[Uri] = None,
) -> Optional[T]:
"""Return the user's configuration for the given ``section``.
Using a ``workspace/configuration`` request, ask the client for the user's
configuration for the given ``section``.
``spec`` should be a class definition representing the expected "shape" of the
result.
Parameters
----------
section
The name of the configuration section to retrieve
spec
The class definition representing the expected result.
scope
An optional URI, useful in a multi-root context to select which root the
configuration should be retrieved from.
Returns
-------
T | None
The user's configuration, parsed as an instance of ``T``.
If ``None``, the config was not available / there was an error.
"""
params = types.ConfigurationParams(
items=[
types.ConfigurationItem(
section=section, scope_uri=str(scope) if scope else None
)
]
)
self.logger.debug(
"workspace/configuration: %s",
json.dumps(self.converter.unstructure(params), indent=2),
)
result = await self.get_configuration_async(params)
try:
self.logger.debug("configuration: %s", json.dumps(result[0], indent=2))
return self.converter.structure(result[0], spec)
except Exception:
self.logger.error(
"Unable to parse configuration as '%s'", spec.__name__, exc_info=True
)
return None
def clear_diagnostics(self, source: str, uri: Optional[Uri] = None) -> None:
"""Clear diagnostics from the given source.
Parameters
----------
source:
The source from which to clear diagnostics.
uri:
If given, clear diagnostics from within just this uri. Otherwise, all
diagnostics from the given source are cleared.
"""
for key in self._diagnostics.keys():
clear_source = source == key[0]
clear_uri = uri == key[1] or uri is None
if clear_source and clear_uri:
self._diagnostics[key] = []
def add_diagnostics(self, source: str, uri: Uri, diagnostic: types.Diagnostic):
"""Add a diagnostic to the given source and uri.
Parameters
----------
source
The source the diagnostics are from
uri
The uri the diagnostics are associated with
diagnostic
The diagnostic to add
"""
key = (source, uri)
self._diagnostics.setdefault(key, []).append(diagnostic)
def set_diagnostics(
self, source: str, uri: Uri, diagnostics: List[types.Diagnostic]
) -> None:
"""Set the diagnostics for the given source and uri.
Parameters
----------
source:
The source the diagnostics are from
uri:
The uri the diagnostics are associated with
diagnostics:
The diagnostics themselves
"""
self._diagnostics[(source, uri)] = diagnostics
def sync_diagnostics(self) -> None:
"""Update the client with the currently stored diagnostics."""
uris = {uri for _, uri in self._diagnostics.keys()}
diagnostics = {uri: DiagnosticList() for uri in uris}
for (source, uri), diags in self._diagnostics.items():
for diag in diags:
diag.source = source
diagnostics[uri].append(diag)
for uri, diag_list in diagnostics.items():
self.logger.debug("Publishing %d diagnostics for: %s", len(diag_list), uri)
self.publish_diagnostics(str(uri), diag_list.data)
class DiagnosticList(collections.UserList):
"""A list type dedicated to holding diagnostics.
This is mainly to ensure that only one instance of a diagnostic ever gets
reported.
"""
def append(self, item: types.Diagnostic):
if not isinstance(item, types.Diagnostic):
raise TypeError("Expected Diagnostic")
for existing in self.data:
fields = [
existing.range == item.range,
existing.message == item.message,
existing.severity == item.severity,
existing.code == item.code,
existing.source == item.source,
]
if all(fields):
# Item already added, nothing to do.
return
self.data.append(item)
def _get_setup_arguments(
server: EsbonioLanguageServer, setup: Callable, modname: str
) -> Optional[Dict[str, Any]]:
"""Given a setup function, try to construct the collection of arguments to pass to
it.
"""
annotations = typing.get_type_hints(setup)
parameters = {
p.name: annotations[p.name]
for p in inspect.signature(setup).parameters.values()
}
args = {}
for name, type_ in parameters.items():
if issubclass(server.__class__, type_):
args[name] = server
continue
from .feature import LanguageFeature # noqa: F402
if issubclass(type_, LanguageFeature):
# Try and obtain an instance of the requested language feature.
feature = server.get_feature(type_)
if feature is not None:
args[name] = feature
continue
server.logger.debug(
"Skipping extension '%s', server missing requested feature: '%s'",
modname,
type_,
)
return None
server.logger.error(
"Skipping extension '%s', parameter '%s' has unsupported type: '%s'",
modname,
name,
type_,
)
return None
return args