Source code for esbonio.lsp.roles

"""Role support."""
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 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 lsprotocol.types import TextEdit

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

from .completions import render_role_completion


[docs] class RoleLanguageFeature: """Base class for role language features."""
[docs] def complete_targets( self, context: CompletionContext, name: str, domain: str ) -> List[CompletionItem]: """Return a list of completion items representing valid targets for the given role. Parameters ---------- context The completion context name The name of the role to generate completion suggestions for. domain The name of the domain the role is a member of """ return []
[docs] def find_target_definitions( self, context: DefinitionContext, name: str, domain: str, label: str ) -> List[Location]: """Return a list of locations representing the definition of the given role target. Parameters ---------- doc: The document containing the match match: The match object that triggered the definition request name: The name of the role domain: The domain the role is part of, if applicable. """ return []
[docs] def get_implementation(self, role: str, domain: str) -> Optional[Any]: """Return the implementation for the given role name. Parameters ---------- role The name of the role domain The domain the role belongs to, if any """ return self.index_roles().get(role, None)
[docs] def index_roles(self) -> Dict[str, Any]: """Return all known roles.""" return dict()
[docs] def suggest_roles(self, context: CompletionContext) -> Iterable[Tuple[str, Any]]: """Suggest roles that may be used, given a completion context.""" return self.index_roles().items()
[docs] class TargetDefinition(Protocol): """A definition provider for role targets. .. deprecated:: 0.15.0 This will be removed in ``v1.0``, use a subclass of :class:`~esbonio.lsp.roles.RoleLanguageFeature` instead. """
[docs] def find_definitions( self, context: DefinitionContext, name: str, domain: Optional[str] ) -> List[Location]: """Return a list of locations representing the definition of the given role target. Parameters ---------- doc: The document containing the match match: The match object that triggered the definition request name: The name of the role domain: The domain the role is part of, if applicable. """
[docs] class TargetCompletion(Protocol): """A completion provider for role targets. .. deprecated:: 0.15.0 This will be removed in ``v1.0``, use a subclass of :class:`~esbonio.lsp.roles.RoleLanguageFeature` instead. """
[docs] def complete_targets( self, context: CompletionContext, name: str, domain: Optional[str] ) -> List[CompletionItem]: """Return a list of completion items representing valid targets for the given role. Parameters ---------- context: The completion context domain: The name of the domain the role is a member of name: The name of the role to generate completion suggestions for. """
[docs] class Roles(LanguageFeature): """Role 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, RoleLanguageFeature] = {} """Collection of registered features"""
[docs] def add_feature(self, feature: RoleLanguageFeature): """Register a role language feature Parameters ---------- feature The role language feature """ key = f"{feature.__module__}.{feature.__class__.__name__}" # Create a 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_target_definition_provider(self, provider: TargetDefinition) -> None: """Register a :class:`~esbonio.lsp.roles.TargetDefinition` provider. .. deprecated:: 0.15.0 This will be removed in ``v1.0`` use :meth:`~esbonio.lsp.roles.Roles.add_feature` with a :class:`~esbonio.lsp.roles.RoleLanguageFeature` subclass instead. Parameters ---------- provider The provider to register """ warnings.warn( "TargetDefinition providers are deprecated in favour of " "RoleLanguageFeatures, this method will be removed in v1.0", DeprecationWarning, stacklevel=2, ) name = provider.__class__.__name__ key = f"{provider.__module__}.{name}.definition" def find_target_definitions(self, context, name, domain, label): return provider.find_definitions(context, name, domain) feature = type( f"{name}TargetDefinitionProvider", (RoleLanguageFeature,), {"find_target_definitions": find_target_definitions}, )() self._features[key] = feature
[docs] def add_target_completion_provider(self, provider: TargetCompletion) -> None: """Register a :class:`~esbonio.lsp.roles.TargetCompletion` provider. .. deprecated:: 0.15.0 This will be removed in ``v1.0`` use :meth:`~esbonio.lsp.roles.Roles.add_feature` with a :class:`~esbonio.lsp.roles.RoleLanguageFeature` subclass instead. Parameters ---------- provider The provider to register """ warnings.warn( "TargetCompletion providers are deprecated in favour of " "RoleLanguageFeatures, this method will be removed in v1.0", DeprecationWarning, stacklevel=2, ) name = provider.__class__.__name__ key = f"{provider.__module__}.{name}.completion" feature = type( f"{name}TargetCompletionProvider", (RoleLanguageFeature,), {"complete_targets": provider.complete_targets}, )() self._features[key] = feature
[docs] def add_documentation(self, documentation: Dict[str, Dict[str, Any]]) -> None: """Register role documentation. ``documentation`` should be a dictionary of the form :: documentation = { "raw(docutils.parsers.rst.roles.raw_role)": { "is_markdown": true, "license": "https://...", "source": "https://...", "description": [ "# :raw:", "The raw role is used for...", ... ] } } where the key is of the form `name(dotted_name)`. There are cases where a role's implementation is not sufficient to uniquely identify it as multiple roles can be provided by a single class. This means the key has to be a combination of the ``name`` the user writes in an reStructuredText document and ``dotted_name`` is the fully qualified name of the role'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 role's usage. ``is_markdown`` A boolean flag used to indicate whether the ``description`` is 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
completion_triggers = [ROLE, DEFAULT_ROLE] definition_triggers = [ROLE] hover_triggers = [ROLE] implementation_triggers = [ROLE] def definition(self, context: DefinitionContext) -> List[Location]: domain = context.match.group("domain") or "" name = context.match.group("name") label = context.match.group("label") # Be sure to only match complete roles if not label or not context.match.group(0).endswith("`"): return [] return self.find_target_definitions(context, name, domain, label) def find_target_definitions( self, context: DefinitionContext, name: str, domain: str, label: str ) -> List[Location]: definitions = [] for feature_name, feature in self._features.items(): try: definitions += feature.find_target_definitions( context, name, domain, label ) except Exception: self.logger.error( "Unable to find definitions of '%s' for role ':%s:', " "error in feature: '%s'", label, f"{domain}:{name}" if domain else name, feature_name, exc_info=True, ) return definitions def document_link(self, context: DocumentLinkContext) -> List[DocumentLink]: links = [] for line, text in enumerate(context.doc.lines): for match in ROLE.finditer(text): label = match.group("label") # Be sure to only match complete roles if not label or not match.group(0).endswith("`"): continue domain = match.group("domain") name = match.group("name") target, tooltip = self.resolve_target_link(context, name, domain, label) if not target: continue idx = match.group(0).index(label) start = match.start() + idx end = start + len(label) link = 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), ), ) links.append(link) return links def complete(self, context: CompletionContext) -> List[CompletionItem]: """Generate completion suggestions relevant to the current context. This function is a little intense, but its sole purpose is to determine the context in which the completion request is being made and either return nothing, or the results of :meth:`~esbonio.lsp.roles.Roles.complete_roles` or :meth:`esbonio.lsp.roles.Roles.complete_targets` whichever is appropriate. Parameters ---------- context: The context of the completion request. """ # Do not suggest completions within the middle of Python code. if context.location == "py": return [] groups = context.match.groupdict() target = groups["target"] # All text matched by the regex text = context.match.group(0) start, end = context.match.span() if target: target_index = start + text.find(target) # Only trigger target completions if the request was made from within # the target part of the role. if target_index <= context.position.character <= end: return self.complete_targets(context) # If there's no indent, then this can only be a role definition indent = context.match.group(1) if indent == "": return self.complete_roles(context) # Otherwise, search backwards until we find a blank line or an unindent # so that we can determine the appropriate context. linum = context.position.line - 1 try: line = context.doc.lines[linum] except IndexError: return self.complete_roles(context) while linum >= 0 and line.startswith(indent): linum -= 1 line = context.doc.lines[linum] # Unless we are within a directive's options block, we should offer role # suggestions if DIRECTIVE.match(line): return [] return self.complete_roles(context) 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 == "role": return self.completion_resolve_role(item) return item
[docs] def suggest_roles(self, context: CompletionContext) -> Iterable[Tuple[str, Any]]: """Suggest roles that may be used, given a completion context. Parameters ---------- context The completion context """ for name, feature in self._features.items(): try: yield from feature.suggest_roles(context) except Exception: self.logger.error( "Unable to suggest roles, error in feature: '%s'", name, exc_info=True, )
def complete_roles(self, context: CompletionContext) -> List[CompletionItem]: items = [] for name, role in self.suggest_roles(context): item = render_role_completion(context, name, role) if item is None: continue items.append(item) return items def completion_resolve_role(self, item: CompletionItem) -> CompletionItem: # We need the detail field set to the role 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 suggest_targets( self, context: CompletionContext, name: str, domain: str ) -> List[CompletionItem]: targets = [] for feature_name, feature in self._features.items(): try: targets += feature.complete_targets(context, name, domain) except Exception: self.logger.error( "Unable to suggest targets for role ':%s:', error in feature: '%s'", f"{domain}:{name}" if domain else name, feature_name, exc_info=True, ) return targets def complete_targets(self, context: CompletionContext) -> List[CompletionItem]: """Generate the list of role target completion suggestions.""" groups = context.match.groupdict() # Handle the default role case. if "role" not in groups: domain, name = self.rst.get_default_role() if not name: return [] else: name = groups["name"] domain = groups["domain"] domain = domain or "" name = name or "" # Only generate suggestions for "aliased" targets if the request comes from # within the <> chars. if groups["alias"]: text = context.match.group(0) start = context.match.span()[0] + text.find(groups["alias"]) end = start + len(groups["alias"]) if start <= context.position.character <= end: return [] targets = [] startchar = "<" if "<" in groups["target"] else "`" endchars = ">`" if "<" in groups["target"] else "`" start, end = context.match.span() start += context.match.group(0).index(startchar) + 1 range_ = Range( start=Position(line=context.position.line, character=start), end=Position(line=context.position.line, character=end), ) prefix = context.match.group(0)[start:] modifier = groups["modifier"] or "" for candidate in self.suggest_targets(context, name, domain): # Don't interfere with items that already carry a `text_edit`, allowing # some providers (like filepaths) to do something special. if not candidate.text_edit: new_text = candidate.insert_text or candidate.label # This is rather annoying, but `filter_text` needs to start with # the text we are going to replace, otherwise VSCode won't show our # suggestions! candidate.filter_text = f"{prefix}{new_text}" candidate.text_edit = TextEdit( range=range_, new_text=f"{modifier}{new_text}" ) candidate.insert_text = None if not candidate.text_edit.new_text.endswith(endchars): candidate.text_edit.new_text += endchars targets.append(candidate) return targets 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 :role: itself, or within the `target`. idx = context.position.character - context.match.start() prefix = context.match.group(0)[:idx] if "`" in prefix: return self.hover_target(context, name, domain) return self.hover_role(context, name, domain) def hover_role(self, context: HoverContext, name: str, domain: str) -> str: label = f"{domain}:{name}" if domain else name role = self.get_implementation(name, domain) if not role: return "" try: dotted_name = f"{role.__module__}.{role.__name__}" except AttributeError: dotted_name = f"{role.__module__}.{role.__class__.__name__}" documentation = self.get_documentation(label, dotted_name) if not documentation: return "" return documentation.get("description", "") def hover_target( self, context: HoverContext, name: str, domain: Optional[str] ) -> str: # TODO: Add extension point for providers to contribute hovers for a target. return ""
[docs] def get_roles(self) -> Dict[str, Any]: """Return a dictionary of all known roles.""" roles = {} for name, feature in self._features.items(): self.logger.debug("calling '%s'", name) try: roles.update(feature.index_roles()) except Exception: self.logger.error( "Unable to index roles, error in feature '%s'", name, exc_info=True ) return roles
[docs] def get_implementation(self, role: str, domain: str) -> Optional[Any]: """Return the implementation of a role given its name Parameters ---------- role The name of the role. domain The domain of the role, if applicable. """ if domain: name = f"{domain}:{role}" else: name = role for feature_name, feature in self._features.items(): try: impl = feature.get_implementation(role, 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, exc_info=True, ) self.logger.debug("Unable to get implementation for ':%s:', unknown role", name) return None
def implementation(self, context: ImplementationContext) -> List[Location]: region = context.match.group("role") name = context.match.group("name") domain = context.match.group("domain") start = context.match.start() + context.match.group(0).index(region) end = start + len(region) self.logger.debug("%s, %s, %s", region, name, domain) self.logger.debug("%s, %s", start, end) if start <= context.position.character <= end: return self.find_role_implementation(context, name, domain) return [] def find_role_implementation( self, context: ImplementationContext, name: str, domain: str ) -> List[Location]: impl = self.get_implementation(name, domain) if impl is None: return [] location = get_object_location(impl, self.logger) if location is not None: return [location] # Some roles are implemented as instances of some class location = get_object_location(impl.__class__, self.logger) if location is not None: return [location] return []
[docs] def get_documentation( self, label: str, implementation: str ) -> Optional[Dict[str, Any]]: """Return the documentation for the given role, 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 role, as the user would type in an reStructuredText file. implementation: The full dotted name of the role'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(Roles(rst))