Source code for esbonio.lsp.directives

import re
import traceback
import typing
import warnings
from typing import Any
from typing import Dict
from typing import Iterable
from typing import List
from typing import Optional
from typing import Protocol
from typing import Tuple
from typing import Type

from docutils.parsers.rst import Directive
from lsprotocol.types import CompletionItem
from lsprotocol.types import DocumentLink
from lsprotocol.types import Location
from lsprotocol.types import MarkupContent
from lsprotocol.types import MarkupKind
from lsprotocol.types import Position
from lsprotocol.types import Range

from esbonio.lsp import CompletionContext
from esbonio.lsp import DefinitionContext
from esbonio.lsp import DocumentLinkContext
from esbonio.lsp import HoverContext
from esbonio.lsp import ImplementationContext
from esbonio.lsp import LanguageFeature
from esbonio.lsp import RstLanguageServer
from esbonio.lsp.sphinx import SphinxLanguageServer
from esbonio.lsp.util.inspect import get_object_location
from esbonio.lsp.util.patterns import DIRECTIVE
from esbonio.lsp.util.patterns import DIRECTIVE_OPTION

from .completions import render_directive_completion
from .completions import render_directive_option_completion


[docs] class DirectiveLanguageFeature: """Base class for directive language features."""
[docs] def complete_arguments( self, context: CompletionContext, domain: str, name: str ) -> List[CompletionItem]: """Return a list of completion items representing valid targets for the given directive. Parameters ---------- context: The completion context domain: The name of the domain the directive is a member of name: The name of the domain """ return []
[docs] def get_implementation( self, directive: str, domain: Optional[str] ) -> Optional[Type[Directive]]: """Return the implementation for the given directive name.""" return self.index_directives().get(directive, None)
[docs] def index_directives(self) -> Dict[str, Type[Directive]]: """Return all known directives.""" return dict()
[docs] def suggest_directives( self, context: CompletionContext ) -> Iterable[Tuple[str, Type[Directive]]]: """Suggest directives that may be used, given a completion context.""" return self.index_directives().items()
[docs] def suggest_options( self, context: CompletionContext, directive: str, domain: Optional[str] ) -> Iterable[str]: """Suggest options that may be used, given a completion context.""" impl = self.get_implementation(directive, domain) if impl is None: return [] option_spec = getattr(impl, "option_spec", {}) or {} return option_spec.keys()
[docs] def find_argument_definitions( self, context: DefinitionContext, directive: str, domain: Optional[str], argument: str, ) -> List[Location]: """Return a list of locations representing definitions of the given argument. Parameters ---------- context The context of the definition request. directive The name of the directive the argument is associated with. domain The name of the domain the directive belongs to, if applicable. argument The argument to find the definition of. """ return []
[docs] class ArgumentCompletion(Protocol): """A completion provider for directive arguments. .. deprecated:: 0.14.2 This will be removed in ``v1.0``, use subclasses of :class:`~esbonio.lsp.directives.DirectiveLanguageFeature` instead. """
[docs] def complete_arguments( self, context: CompletionContext, domain: str, name: str ) -> List[CompletionItem]: """Return a list of completion items representing valid targets for the given directive. Parameters ---------- context: The completion context domain: The name of the domain the directive is a member of name: The name of the domain """
[docs] class ArgumentDefinition(Protocol): """A definition provider for directive arguments. .. deprecated:: 0.14.2 This will be removed in ``v1.0``, use subclasses of :class:`~esbonio.lsp.directives.DirectiveLanguageFeature` instead. """
[docs] def find_definitions( self, context: DefinitionContext, directive: str, domain: Optional[str], argument: str, ) -> List[Location]: """Return a list of locations representing definitions of the given argument. Parameters ---------- context: The context of the definition request. directive: The name of the directive the argument is associated with. domain: The name of the domain the directive belongs to, if applicable. argument: The argument to find the definition of. """
[docs] class Directives(LanguageFeature): """Directive support for the language server.""" def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._documentation: Dict[str, Dict[str, str]] = {} """Cache for documentation.""" self._features: Dict[str, DirectiveLanguageFeature] = {} """The collection of registered features."""
[docs] def add_feature(self, feature: DirectiveLanguageFeature): """Register a directive language feature. Parameters ---------- feature The directive language feature """ key = f"{feature.__module__}.{feature.__class__.__name__}" # Create an unique key for this instance. if key in self._features: key += f".{len([k for k in self._features.keys() if k.startswith(key)])}" self._features[key] = feature
[docs] def add_argument_completion_provider(self, provider: ArgumentCompletion) -> None: """Register an :class:`~esbonio.lsp.directives.ArgumentCompletion` provider. .. deprecated:: 0.14.2 This will be removed in ``v1.0``, use :meth:`~esbonio.lsp.directives.Directives.add_feature` with a :class:`~esbonio.lsp.directives.DirectiveLanguageFeature` subclass instead. Parameters ---------- provider: The provider to register. """ warnings.warn( "ArgumentCompletion providers are deprecated in favour of " "DirectiveLanguageFeatures, this method will be removed in v1.0", DeprecationWarning, stacklevel=2, ) name = provider.__class__.__name__ key = f"{provider.__module__}.{name}.completion" # Automatically derive the feature definition from the provider. feature = type( f"{name}CompletionProvider", (DirectiveLanguageFeature,), {"complete_arguments": provider.complete_arguments}, )() self._features[key] = feature
[docs] def add_argument_definition_provider(self, provider: ArgumentDefinition) -> None: """Register an :class:`~esbonio.lsp.directives.ArgumentDefinition` provider. .. deprecated:: 0.14.2 This will be removed in ``v1.0``, use :meth:`~esbonio.lsp.directives.Directives.add_feature` with a :class:`~esbonio.lsp.directives.DirectiveLanguageFeature` subclass instead. Parameters ---------- provider: The provider to register. """ warnings.warn( "ArgumentDefinition providers are deprecated in favour of " "DirectiveLanguageFeatures, this method will be removed in v1.0", DeprecationWarning, stacklevel=2, ) name = provider.__class__.__name__ key = f"{provider.__module__}.{name}.definitions" # Automatically derive the feature definition from the provider. feature = type( f"{name}DefinitionProvider", (DirectiveLanguageFeature,), {"find_argument_definitions": provider.find_definitions}, )() self._features[key] = feature
[docs] def add_documentation(self, documentation: Dict[str, Dict[str, Any]]) -> None: """Register directive documentation. ``documentation`` should be a dictionary with the following structure :: documentation = { "raw(docutils.parsers.rst.directives.misc.Raw)": { "is_markdown": true, "license": "https://...", "source": "https://...", "description": [ "# .. raw::", "The raw directive is used for...", ... ] "options": { "file": "The file option allows...", ... } } } where the key has the form ``name(dotted_name)``. There are cases where a directive's implementation is not sufficient to uniquely identify it as multiple directives can be provided by a single class. This means the key has to be a combination of the ``name`` the user writes in a reStructuredText document and ``dotted_name`` is the fully qualified class name of the directive's implementation. .. note:: If there is a clash with an existing key, the existing value will be overwritten with the new value. The values in this dictionary are themselves dictionaries with the following fields. ``description`` A list of strings for the directive's main documentation. ``options``, A dictionary, with a field for the documentaton of each of the directive's options. ``is_markdown`` A boolean flag used to indicate whether the ``description`` and ``options`` are written in plain text or markdown. ``source`` The url to the documentation's source ``license`` The url to the documentation's license Parameters ---------- documentation: The documentation to register. """ for key, doc in documentation.items(): description = doc.get("description", []) if not description: continue source = doc.get("source", "") if source: description.append(f"\n[Source]({source})") license = doc.get("license", "") if license: description.append(f"\n[License]({license})") doc["description"] = "\n".join(description) self._documentation[key] = doc
[docs] def get_directives(self) -> Dict[str, Type[Directive]]: """Return a dictionary of all known directives.""" directives = {} for name, feature in self._features.items(): try: directives.update(feature.index_directives()) except Exception: self.logger.error( "Unable to index directives, error in feature '%s'\n%s", name, traceback.format_exc(), ) return directives
[docs] def get_implementation( self, directive: str, domain: Optional[str] ) -> Optional[Type[Directive]]: """Return the implementation of a directive given its name Parameters ---------- directive The name of the directive. domain The domain of the directive, if applicable. """ if domain: name = f"{domain}:{directive}" else: name = directive for feature_name, feature in self._features.items(): try: impl = feature.get_implementation(directive, domain) if impl is not None: return impl except Exception: self.logger.error( "Unable to get implementation for '%s', error in feature: '%s'\n%s", name, feature_name, traceback.format_exc(), ) self.logger.debug( "Unable to get implementation for '%s', unknown directive", name ) return None
[docs] def suggest_directives( self, context: CompletionContext ) -> Iterable[Tuple[str, Type[Directive]]]: """Suggest directives that may be used, given a completion context. Parameters ---------- context The CompletionContext. """ for name, feature in self._features.items(): try: yield from feature.suggest_directives(context) except Exception: self.logger.error( "Unable to suggest directives, error in feature: '%s'\n%s", name, traceback.format_exc(), )
[docs] def suggest_options( self, context: CompletionContext, directive: str, domain: Optional[str] ) -> Iterable[str]: """Suggest directive options that may be used, given a completion context.""" if domain: name = f"{domain}:{directive}" else: name = directive for feature_name, feature in self._features.items(): try: yield from feature.suggest_options(context, directive, domain) except Exception: self.logger.error( "Unable to suggest options for directive '%s', error in feature: '%s'\n%s", name, feature_name, traceback.format_exc(), )
completion_triggers = [DIRECTIVE, DIRECTIVE_OPTION] definition_triggers = [DIRECTIVE] hover_triggers = [DIRECTIVE] implementation_triggers = [DIRECTIVE] def completion_resolve(self, item: CompletionItem) -> CompletionItem: # We need extra info to know who to call. if not item.data: return item data = typing.cast(Dict, item.data) ctype = data.get("completion_type", "") if ctype == "directive": return self.completion_resolve_directive(item) if ctype == "directive_option": return self.completion_resolve_option(item) return item def complete(self, context: CompletionContext) -> List[CompletionItem]: # Do not suggest completions within the middle of Python code. if context.location == "py": return [] groups = context.match.groupdict() # Are we completing a directive's options? if "directive" not in groups: return self.complete_options(context) # Are we completing the directive's argument? directive_end = context.match.span()[0] + len(groups["directive"]) complete_directive = groups["directive"].endswith("::") if complete_directive and directive_end < context.position.character: return self.complete_arguments(context) return self.complete_directives(context) def complete_arguments(self, context: CompletionContext) -> List[CompletionItem]: arguments = [] name = context.match.group("name") domain = context.match.group("domain") or "" for feature in self._features.values(): arguments += feature.complete_arguments(context, domain, name) or [] return arguments def complete_directives(self, context: CompletionContext) -> List[CompletionItem]: self.logger.debug("Completing directives") items = [] for name, directive in self.suggest_directives(context): item = render_directive_completion(context, name, directive) if item is None: continue items.append(item) return items def completion_resolve_directive(self, item: CompletionItem) -> CompletionItem: # We need the detail field set to the implementation's fully qualified name. if not item.detail: return item documentation = self.get_documentation(item.label, item.detail) if not documentation: return item description = documentation.get("description", "") is_markdown = documentation.get("is_markdown", False) kind = MarkupKind.Markdown if is_markdown else MarkupKind.PlainText item.documentation = MarkupContent(kind=kind, value=description) return item def complete_options(self, context: CompletionContext) -> List[CompletionItem]: surrounding_directive = self._get_surrounding_directive(context) if not surrounding_directive: return [] name = surrounding_directive.group("name") domain = surrounding_directive.group("domain") impl = self.get_implementation(name, domain) if impl is None: return [] items = [] for option in self.suggest_options(context, name, domain): item = render_directive_option_completion(context, option, name, impl) if item is None: continue items.append(item) return items def completion_resolve_option(self, item: CompletionItem) -> CompletionItem: # We need the detail field set to the implementation's fully qualified name. if not item.detail or not item.data: return item directive, option = item.detail.split(":") name = typing.cast(Dict, item.data).get("for_directive", "") documentation = self.get_documentation(name, directive) if not documentation: return item description = documentation.get("options", {}).get(option, None) if not description: return item source = documentation.get("source", "") license = documentation.get("license", "") if source: description += f"\n\n[Source]({source})" if license: description += f"\n\n[License]({license})" kind = MarkupKind.PlainText if documentation.get("is_markdown", False): kind = MarkupKind.Markdown item.documentation = MarkupContent(kind=kind, value=description) return item def definition(self, context: DefinitionContext) -> List[Location]: directive = context.match.group("name") domain = context.match.group("domain") argument = context.match.group("argument") if not argument: return [] start = context.match.group(0).index(argument) end = start + len(argument) if start <= context.position.character <= end: return self.find_argument_definition(context, directive, domain, argument) return [] def find_argument_definition( self, context: DefinitionContext, directive: str, domain: Optional[str], argument: str, ) -> List[Location]: definitions = [] for feature_name, feature in self._features.items(): try: definitions += ( feature.find_argument_definitions( context, directive, domain, argument ) or [] ) except Exception: self.logger.error( "Unable to find definitions of '%s' for directive '%s', " "error in feature: '%s'", argument, f"{domain}:{directive}" if domain else directive, feature_name, exc_info=True, ) return definitions def resolve_argument_link( self, context: DocumentLinkContext, name: str, domain: str, argument: str ) -> Tuple[Optional[str], Optional[str]]: for feature_name, feature in self._features.items(): try: target, tooltip = feature.resolve_argument_link( context, name, domain, argument ) if target: return target, tooltip except Exception: self.logger.error( "Unable to resolve argument link '%s' for directive '%s', " "error in feature: '%s'", argument, f"{domain}:{name}" if domain else name, feature_name, exc_info=True, ) return None, None def document_link(self, context: DocumentLinkContext) -> List[DocumentLink]: links = [] for line, text in enumerate(context.doc.lines): for match in DIRECTIVE.finditer(text): argument = match.group("argument") if not argument: continue domain = match.group("domain") name = match.group("name") target, tooltip = self.resolve_argument_link( context, name, domain, argument ) if not target: continue idx = match.group(0).index(argument) start = match.start() + idx end = start + len(argument) links.append( DocumentLink( target=target, tooltip=tooltip if context.tooltip_support else None, range=Range( start=Position(line=line, character=start), end=Position(line=line, character=end), ), ) ) return links def hover(self, context: HoverContext) -> str: if context.location not in {"rst", "docstring"}: return "" name = context.match.group("name") domain = context.match.group("domain") # Determine if the hover is on the .. directive:: itself, or within the argument # Be sure to include enough chars for the length of '::'! idx = context.position.character - context.match.start() prefix = context.match.group(0)[:idx] if "::" not in prefix: return self.hover_directive(context, name, domain) # TODO: Add extension points for directive arguments and options. return "" def hover_directive( self, context: HoverContext, name: str, domain: Optional[str] ) -> str: label = f"{domain}:{name}" if domain else name self.logger.debug("Calculating hover for directive '%s'", label) directive = self.get_implementation(name, domain) if not directive: return "" try: dotted_name = f"{directive.__module__}.{directive.__name__}" except AttributeError: dotted_name = f"{directive.__module__}.{directive.__class__.__name__}" documentation = self.get_documentation(label, dotted_name) if not documentation: return "" return documentation.get("description", "") def implementation(self, context: ImplementationContext) -> List[Location]: region = context.match.group("directive") name = context.match.group("name") domain = context.match.group("domain") start = context.match.group(0).index(region) end = start + len(region) if start <= context.position.character <= end: return self.find_directive_implementation(context, name, domain) return [] def find_directive_implementation( self, context: ImplementationContext, name: str, domain: Optional[str] ) -> List[Location]: impl = self.get_implementation(name, domain) if impl is None: return [] self.logger.debug( "Getting implementation of '%s' (%s)", f"{domain}:{name}" if domain else name, impl, ) location = get_object_location(impl, self.logger) if location is None: return [] return [location] def _get_surrounding_directive( self, context: CompletionContext ) -> Optional["re.Match"]: """Used to determine which directive we should be offering completions for. When suggestions should be generated this returns an :class:`python:re.Match` object representing the directive the options are associated with. In the case where suggestions should not be generated this will return ``None`` Parameters ---------- context: The completion context """ match = context.match groups = match.groupdict() indent = groups["indent"] self.logger.debug("Match groups: %s", groups) # Search backwards so that we can determine the context for our completion linum = context.position.line - 1 line = context.doc.lines[linum] while linum >= 0 and line.startswith(indent): linum -= 1 line = context.doc.lines[linum] # Only offer completions if we're within a directive's option block directive = DIRECTIVE.match(line) self.logger.debug("Context line: %s", line) self.logger.debug("Context match: %s", directive) if not directive: return None # Now that we know we're in a directive's option block, is the completion # request coming from a valid position on the line? option = groups["option"] start = match.span()[0] + match.group(0).find(option) end = start + len(option) + 1 if start <= context.position.character <= end: return directive return None
[docs] def get_documentation( self, label: str, implementation: str ) -> Optional[Dict[str, Any]]: """Return the documentation for the given directive, if available. If documentation for the given ``label`` cannot be found, this function will also look for the label under the project's :confval:`sphinx:primary_domain` followed by the ``std`` domain. Parameters ---------- label The name of the directive, as the user would type in an reStructuredText file. implementation The full dotted name of the directive's implementation. """ key = f"{label}({implementation})" documentation = self._documentation.get(key, None) if documentation: return documentation if not isinstance(self.rst, SphinxLanguageServer) or not self.rst.app: return None # Nothing found, try the primary domain domain = self.rst.app.config.primary_domain key = f"{domain}:{label}({implementation})" documentation = self._documentation.get(key, None) if documentation: return documentation # Still nothing, try the standard domain key = f"std:{label}({implementation})" documentation = self._documentation.get(key, None) if documentation: return documentation return None
def esbonio_setup(rst: RstLanguageServer): rst.add_feature(Directives(rst))