From 20afb605f2d3c7f71dcade311e8e83423f6e64d5 Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Sun, 5 Jan 2025 16:54:20 +0000 Subject: [PATCH] lsp: Add infrastructure for directive argument completions --- .../server/features/directives/__init__.py | 137 +++++++++++++++++- .../server/features/directives/completion.py | 72 ++++++++- .../esbonio/server/features/rst/directives.py | 22 ++- .../features/sphinx_support/directives.py | 2 +- .../esbonio/sphinx_agent/types/directives.py | 35 +++++ 5 files changed, 256 insertions(+), 12 deletions(-) diff --git a/lib/esbonio/esbonio/server/features/directives/__init__.py b/lib/esbonio/esbonio/server/features/directives/__init__.py index 7bd9c2b8f..5e156724b 100644 --- a/lib/esbonio/esbonio/server/features/directives/__init__.py +++ b/lib/esbonio/esbonio/server/features/directives/__init__.py @@ -12,10 +12,27 @@ from collections.abc import Coroutine from typing import Any + from esbonio.server import Uri + class DirectiveProvider: """Base class for directive providers""" + def get_directive( + self, uri: Uri, name: str + ) -> types.Directive | None | Coroutine[Any, Any, types.Directive | None]: + """Return the definition of the given directive, if known + + Parameters + ---------- + uri + The uri of the document in which the directive name appears + + name + The name of the directive, as the user would type in a document + """ + return None + def suggest_directives( self, context: server.CompletionContext ) -> ( @@ -25,6 +42,20 @@ def suggest_directives( return None +class DirectiveArgumentProvider: + """Base class for directive argument providers.""" + + def suggest_arguments( + self, context: server.CompletionContext, **kwargs + ) -> ( + list[lsp.CompletionItem] + | None + | Coroutine[Any, Any, list[lsp.CompletionItem] | None] + ): + """Given a completion context, suggest directive arguments that may be used.""" + return None + + class DirectiveFeature(server.LanguageFeature): """'Backend' support for directives. @@ -35,9 +66,10 @@ class DirectiveFeature(server.LanguageFeature): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self._providers: dict[int, DirectiveProvider] = {} + self._directive_providers: dict[int, DirectiveProvider] = {} + self._argument_providers: dict[str, DirectiveArgumentProvider] = {} - def add_provider(self, provider: DirectiveProvider): + def add_directive_provider(self, provider: DirectiveProvider): """Register a directive provider. Parameters @@ -45,7 +77,25 @@ def add_provider(self, provider: DirectiveProvider): provider The directive provider """ - self._providers[id(provider)] = provider + self._directive_providers[id(provider)] = provider + + def add_directive_argument_provider( + self, name: str, provider: DirectiveArgumentProvider + ): + """Register a directive argument provider. + + Parameters + ---------- + provider + The directive argument provider + """ + if (existing := self._argument_providers.get(name)) is not None: + raise ValueError( + f"DirectiveArgumentProvider {provider!r} conflicts with existing " + f"provider: {existing!r}" + ) + + self._argument_providers[name] = provider async def suggest_directives( self, context: server.CompletionContext @@ -59,7 +109,7 @@ async def suggest_directives( """ items: list[types.Directive] = [] - for provider in self._providers.values(): + for provider in self._directive_providers.values(): try: result: list[types.Directive] | None = None @@ -77,6 +127,85 @@ async def suggest_directives( return items + async def get_directive(self, uri: Uri, name: str) -> types.Directive | None: + """Return the definition of the given directive name. + + Parameters + ---------- + uri + The uri of the document in which the directive name appears + + name + The name of the directive, as the user would type into a document. + + Returns + ------- + types.Directive | None + The directive's definition, if known + """ + for provider in self._directive_providers.values(): + try: + result: types.Directive | None = None + + aresult = provider.get_directive(uri, name) + if inspect.isawaitable(aresult): + result = await aresult + + if result is not None: + return result + except Exception: + name = type(provider).__name__ + self.logger.error("Error in '%s.get_directive'", name, exc_info=True) + + return None + + async def suggest_arguments( + self, context: server.CompletionContext, role_name: str + ) -> list[lsp.CompletionItem]: + """Suggest directive arguments that may be used, given a completion context. + + Parameters + ---------- + context + The completion context + + directive_name + The directive to suggest arguments for + """ + if (directive := await self.get_directive(context.uri, role_name)) is None: + self.logger.debug("Unknown directive '%s'", role_name) + return [] + + arguments = [] + self.logger.debug( + "Suggesting arguments for directive: '%s' (%s)", + directive.name, + directive.implementation, + ) + + for spec in directive.argument_providers: + if (provider := self._argument_providers.get(spec.name)) is None: + self.logger.error("Unknown argument provider: '%s'", spec.name) + continue + + try: + result: list[lsp.CompletionItem] | None = None + + aresult = provider.suggest_arguments(context, **spec.kwargs) + if inspect.isawaitable(aresult): + result = await aresult + + if result is not None: + arguments.extend(result) + + except Exception: + name = type(provider).__name__ + self.logger.error( + "Error in '%s.suggest_arguments'", name, exc_info=True + ) + + return arguments + def esbonio_setup(server: server.EsbonioLanguageServer): directives = DirectiveFeature(server) diff --git a/lib/esbonio/esbonio/server/features/directives/completion.py b/lib/esbonio/esbonio/server/features/directives/completion.py index ebbd0105e..8086b442d 100644 --- a/lib/esbonio/esbonio/server/features/directives/completion.py +++ b/lib/esbonio/esbonio/server/features/directives/completion.py @@ -19,13 +19,20 @@ [server.CompletionContext, Directive], Optional[types.CompletionItem] ] + DirectiveArgumentRenderer = Callable[ + [server.CompletionContext, types.CompletionItem], Optional[types.CompletionItem] + ] + WORD = re.compile("[a-zA-Z]+") _DIRECTIVE_RENDERERS: dict[tuple[str, str], DirectiveRenderer] = {} """CompletionItem rendering functions for directives.""" +_DIRECTIVE_ARGUMENT_RENDERERS: dict[tuple[str, str], DirectiveArgumentRenderer] = {} +"""CompletionItem rendering functions for role targets.""" + -def renderer(*, language: str, insert_behavior: str): +def directive_renderer(*, language: str, insert_behavior: str): """Define a new rendering function.""" def fn(f: DirectiveRenderer) -> DirectiveRenderer: @@ -35,6 +42,16 @@ def fn(f: DirectiveRenderer) -> DirectiveRenderer: return fn +def directive_argument_renderer(*, language: str, insert_behavior: str): + """Define a new rendering function.""" + + def fn(f: DirectiveArgumentRenderer) -> DirectiveArgumentRenderer: + _DIRECTIVE_ARGUMENT_RENDERERS[(language, insert_behavior)] = f + return f + + return fn + + def get_directive_renderer( language: str, insert_behavior: str ) -> DirectiveRenderer | None: @@ -56,7 +73,28 @@ def get_directive_renderer( return _DIRECTIVE_RENDERERS.get((language, insert_behavior), None) -@renderer(language="rst", insert_behavior="insert") +def get_directive_argument_renderer( + language: str, insert_behavior: str +) -> DirectiveArgumentRenderer | None: + """Return the directive argument renderer to use. + + Parameters + ---------- + language + The source language the completion item will be inserted into + + insert_behavior + How the completion should behave when inserted. + + Returns + ------- + Optional[DirectiveArgumentRenderer] + The rendering function to use that matches the given criteria, if available. + """ + return _DIRECTIVE_ARGUMENT_RENDERERS.get((language, insert_behavior), None) + + +@directive_renderer(language="rst", insert_behavior="insert") def render_rst_directive_with_insert_text( context: server.CompletionContext, directive: Directive, @@ -133,7 +171,31 @@ def render_rst_directive_with_insert_text( return item -@renderer(language="rst", insert_behavior="replace") +@directive_argument_renderer(language="rst", insert_behavior="replace") +def render_directive_agument_with_text_edit( + context: server.CompletionContext, item: types.CompletionItem +) -> types.CompletionItem | None: + """Render a ``CompletionItem`` using ``textEdit``. + + This implements the ``replace`` insert behavior for role targets. + + Parameters + ---------- + context + The context in which the completion is being generated. + + item + The ``CompletionItem`` representing the directive argument. + + Returns + ------- + Optional[types.CompletionItem] + The rendered completion item, or ``None`` if the item should be skipped + """ + return item + + +@directive_renderer(language="rst", insert_behavior="replace") def render_rst_directive_with_text_edit( context: server.CompletionContext, directive: Directive, @@ -183,7 +245,7 @@ def render_rst_directive_with_text_edit( return item -@renderer(language="markdown", insert_behavior="replace") +@directive_renderer(language="markdown", insert_behavior="replace") def render_myst_directive_with_text_edit( context: server.CompletionContext, directive: Directive, @@ -234,7 +296,7 @@ def render_myst_directive_with_text_edit( return item -@renderer(language="markdown", insert_behavior="insert") +@directive_renderer(language="markdown", insert_behavior="insert") def render_myst_directive_with_insert_text( context: server.CompletionContext, directive: Directive, diff --git a/lib/esbonio/esbonio/server/features/rst/directives.py b/lib/esbonio/esbonio/server/features/rst/directives.py index be6082032..aa644a776 100644 --- a/lib/esbonio/esbonio/server/features/rst/directives.py +++ b/lib/esbonio/esbonio/server/features/rst/directives.py @@ -64,8 +64,26 @@ async def completion( async def complete_options(self, context: server.CompletionContext): return None - async def complete_arguments(self, context: server.CompletionContext): - return None + async def complete_arguments( + self, context: server.CompletionContext + ) -> list[types.CompletionItem] | None: + """Return completion suggestions for the current directive's argument""" + + render_func = completion.get_directive_argument_renderer( + context.language, self._insert_behavior + ) + if render_func is None: + return None + + items = [] + directive_name = context.match.group("name") + suggestions = await self.directives.suggest_arguments(context, directive_name) + + for argument in suggestions: + if (item := render_func(context, argument)) is not None: + items.append(item) + + return items if len(items) > 0 else None async def complete_directives( self, context: server.CompletionContext diff --git a/lib/esbonio/esbonio/server/features/sphinx_support/directives.py b/lib/esbonio/esbonio/server/features/sphinx_support/directives.py index bdc84ce4f..c3785fd7d 100644 --- a/lib/esbonio/esbonio/server/features/sphinx_support/directives.py +++ b/lib/esbonio/esbonio/server/features/sphinx_support/directives.py @@ -62,4 +62,4 @@ def esbonio_setup( directive_feature: directives.DirectiveFeature, ): provider = SphinxDirectives(project_manager) - directive_feature.add_provider(provider) + directive_feature.add_directive_provider(provider) diff --git a/lib/esbonio/esbonio/sphinx_agent/types/directives.py b/lib/esbonio/esbonio/sphinx_agent/types/directives.py index 4bc79778c..2773db896 100644 --- a/lib/esbonio/esbonio/sphinx_agent/types/directives.py +++ b/lib/esbonio/esbonio/sphinx_agent/types/directives.py @@ -8,6 +8,7 @@ from .lsp import Location if typing.TYPE_CHECKING: + from typing import Any from typing import Callable from typing import TypeVar @@ -110,6 +111,16 @@ class Directive: """Represents a directive.""" + @dataclass + class ArgumentProvider: + """An argument provider instance.""" + + name: str + """The name of the provider.""" + + kwargs: dict[str, Any] = field(default_factory=dict) + """Arguments to pass to the argument provider""" + name: str """The name of the directive, as the user would type in an rst file.""" @@ -119,6 +130,21 @@ class Directive: location: Location | None = field(default=None) """The location of the directive's implementation, if known""" + argument_providers: list[ArgumentProvider] = field(default_factory=list) + """The list of argument providers that can be used with this directive.""" + + def to_db( + self, dumps: Callable[[Any], str] + ) -> tuple[str, str | None, str | None, str | None]: + """Convert this directive to its database representation""" + + providers = None + if len(self.argument_providers) > 0: + providers = dumps(self.argument_providers) + + location = dumps(self.location) if self.location is not None else None + return (self.name, self.implementation, location, providers) + @classmethod def from_db( cls, @@ -126,11 +152,20 @@ def from_db( name: str, implementation: str | None, location: str | None, + providers: str | None, ) -> Directive: """Create a directive from its database representation""" + loc = load_as(location, Location) if location is not None else None + argument_providers = ( + load_as(providers, list[Directive.ArgumentProvider]) + if providers is not None + else [] + ) + return cls( name=name, implementation=implementation, location=loc, + argument_providers=argument_providers, )