Skip to content

Commit

Permalink
lsp: Add infrastructure for directive argument completions
Browse files Browse the repository at this point in the history
  • Loading branch information
alcarney committed Jan 6, 2025
1 parent 570f2d3 commit 20afb60
Show file tree
Hide file tree
Showing 5 changed files with 256 additions and 12 deletions.
137 changes: 133 additions & 4 deletions lib/esbonio/esbonio/server/features/directives/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
) -> (
Expand All @@ -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.
Expand All @@ -35,17 +66,36 @@ 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
----------
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
Expand All @@ -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

Expand All @@ -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)
Expand Down
72 changes: 67 additions & 5 deletions lib/esbonio/esbonio/server/features/directives/completion.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
22 changes: 20 additions & 2 deletions lib/esbonio/esbonio/server/features/rst/directives.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Loading

0 comments on commit 20afb60

Please sign in to comment.