diff --git a/lib/esbonio/esbonio/server/cli.py b/lib/esbonio/esbonio/server/cli.py index 2de49a408..6b443e785 100644 --- a/lib/esbonio/esbonio/server/cli.py +++ b/lib/esbonio/esbonio/server/cli.py @@ -70,6 +70,8 @@ def main(argv: Optional[Sequence[str]] = None): "esbonio.server.features.preview_manager", "esbonio.server.features.directives", "esbonio.server.features.roles", + "esbonio.server.features.rst.directives", + "esbonio.server.features.myst.directives", "esbonio.server.features.sphinx_support.diagnostics", "esbonio.server.features.sphinx_support.symbols", "esbonio.server.features.sphinx_support.directives", diff --git a/lib/esbonio/esbonio/server/features/directives/__init__.py b/lib/esbonio/esbonio/server/features/directives/__init__.py index fdbc4d461..4dbeec08d 100644 --- a/lib/esbonio/esbonio/server/features/directives/__init__.py +++ b/lib/esbonio/esbonio/server/features/directives/__init__.py @@ -4,13 +4,9 @@ import typing import attrs -from lsprotocol import types from esbonio import server -from esbonio.sphinx_agent.types import MYST_DIRECTIVE -from esbonio.sphinx_agent.types import RST_DIRECTIVE -from . import completion if typing.TYPE_CHECKING: from typing import Any @@ -45,13 +41,16 @@ def suggest_directives( class DirectiveFeature(server.LanguageFeature): - """Support for directives.""" + """'Backend' support for directives. + + It's this language feature's responsibility to provide an API that exposes the + information a "frontend" language feature may want. + """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._providers: Dict[int, DirectiveProvider] = {} - self._insert_behavior = "replace" def add_provider(self, provider: DirectiveProvider): """Register a directive provider. @@ -63,72 +62,6 @@ def add_provider(self, provider: DirectiveProvider): """ self._providers[id(provider)] = provider - completion_triggers = [RST_DIRECTIVE, MYST_DIRECTIVE] - - def initialized(self, params: types.InitializedParams): - """Called once the initial handshake between client and server has finished.""" - self.configuration.subscribe( - "esbonio.server.completion", - server.CompletionConfig, - self.update_configuration, - ) - - def update_configuration( - self, event: server.ConfigChangeEvent[server.CompletionConfig] - ): - """Called when the user's configuration is updated.""" - self._insert_behavior = event.value.preferred_insert_behavior - - async def completion( - self, context: server.CompletionContext - ) -> Optional[List[types.CompletionItem]]: - """Provide completion suggestions for directives.""" - - groups = context.match.groupdict() - - # Are we completing a directive's options? - if "directive" not in groups: - return await self.complete_options(context) - - # Don't offer completions for targets - if (groups["name"] or "").startswith("_"): - return None - - # 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 await self.complete_arguments(context) - - return await self.complete_directives(context) - - async def complete_options(self, context: server.CompletionContext): - return None - - async def complete_arguments(self, context: server.CompletionContext): - return None - - async def complete_directives( - self, context: server.CompletionContext - ) -> Optional[List[types.CompletionItem]]: - """Return completion suggestions for the available directives.""" - - language = self.server.get_language_at(context.doc, context.position) - render_func = completion.get_directive_renderer(language, self._insert_behavior) - if render_func is None: - return None - - items = [] - for directive in await self.suggest_directives(context): - if (item := render_func(context, directive)) is not None: - items.append(item) - - if len(items) > 0: - return items - - return None - async def suggest_directives( self, context: server.CompletionContext ) -> List[Directive]: diff --git a/lib/esbonio/esbonio/server/features/myst/__init__.py b/lib/esbonio/esbonio/server/features/myst/__init__.py new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/lib/esbonio/esbonio/server/features/myst/__init__.py @@ -0,0 +1 @@ + diff --git a/lib/esbonio/esbonio/server/features/myst/directives.py b/lib/esbonio/esbonio/server/features/myst/directives.py new file mode 100644 index 000000000..b78fabb1f --- /dev/null +++ b/lib/esbonio/esbonio/server/features/myst/directives.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import typing + +from lsprotocol import types + +from esbonio import server +from esbonio.server.features.directives import DirectiveFeature +from esbonio.server.features.directives import completion +from esbonio.sphinx_agent.types import MYST_DIRECTIVE + +if typing.TYPE_CHECKING: + from typing import List + from typing import Optional + + +class MystDirectives(server.LanguageFeature): + """A frontend to directives for MyST syntax.""" + + def __init__(self, directives: DirectiveFeature, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.directives = directives + self._insert_behavior = "replace" + + completion_triggers = [MYST_DIRECTIVE] + + def initialized(self, params: types.InitializedParams): + """Called once the initial handshake between client and server has finished.""" + self.configuration.subscribe( + "esbonio.server.completion", + server.CompletionConfig, + self.update_configuration, + ) + + def update_configuration( + self, event: server.ConfigChangeEvent[server.CompletionConfig] + ): + """Called when the user's configuration is updated.""" + self._insert_behavior = event.value.preferred_insert_behavior + + async def completion( + self, context: server.CompletionContext + ) -> Optional[List[types.CompletionItem]]: + """Provide completion suggestions for directives.""" + + groups = context.match.groupdict() + + # Are we completing a directive's options? + if "directive" not in groups: + return await self.complete_options(context) + + # Don't offer completions for targets + if (groups["name"] or "").startswith("_"): + return None + + # 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 await self.complete_arguments(context) + + return await self.complete_directives(context) + + async def complete_options(self, context: server.CompletionContext): + return None + + async def complete_arguments(self, context: server.CompletionContext): + return None + + async def complete_directives( + self, context: server.CompletionContext + ) -> Optional[List[types.CompletionItem]]: + """Return completion suggestions for the available directives.""" + + language = self.server.get_language_at(context.doc, context.position) + render_func = completion.get_directive_renderer(language, self._insert_behavior) + if render_func is None: + return None + + items = [] + for directive in await self.directives.suggest_directives(context): + if (item := render_func(context, directive)) is not None: + items.append(item) + + if len(items) > 0: + return items + + return None + + +def esbonio_setup(esbonio: server.EsbonioLanguageServer, directives: DirectiveFeature): + myst_directives = MystDirectives(directives, esbonio) + esbonio.add_feature(myst_directives) + + diff --git a/lib/esbonio/esbonio/server/features/rst/__init__.py b/lib/esbonio/esbonio/server/features/rst/__init__.py new file mode 100644 index 000000000..139597f9c --- /dev/null +++ b/lib/esbonio/esbonio/server/features/rst/__init__.py @@ -0,0 +1,2 @@ + + diff --git a/lib/esbonio/esbonio/server/features/rst/completion.py b/lib/esbonio/esbonio/server/features/rst/completion.py new file mode 100644 index 000000000..2baf9711a --- /dev/null +++ b/lib/esbonio/esbonio/server/features/rst/completion.py @@ -0,0 +1,306 @@ +"""Helper functions for completion support""" + +from __future__ import annotations + +import re +import typing + +from lsprotocol import types + +from esbonio import server + +if typing.TYPE_CHECKING: + from typing import Callable + from typing import Dict + from typing import Optional + from typing import Tuple + + from . import Directive + + DirectiveRenderer = Callable[ + [server.CompletionContext, Directive], Optional[types.CompletionItem] + ] + + +WORD = re.compile("[a-zA-Z]+") +_DIRECTIVE_RENDERERS: Dict[Tuple[str, str], DirectiveRenderer] = {} +"""CompletionItem rendering functions for directives.""" + + +def renderer(*, language: str, insert_behavior: str): + """Define a new rendering function.""" + + def fn(f: DirectiveRenderer) -> DirectiveRenderer: + _DIRECTIVE_RENDERERS[(language, insert_behavior)] = f + return f + + return fn + + +def get_directive_renderer( + language: str, insert_behavior: str +) -> Optional[DirectiveRenderer]: + """Return the directive 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[DirectiveRenderer] + The rendering function to use that matches the given criteria, if available. + """ + return _DIRECTIVE_RENDERERS.get((language, insert_behavior), None) + + +@renderer(language="rst", insert_behavior="insert") +def render_rst_directive_with_insert_text( + context: server.CompletionContext, + directive: Directive, +) -> Optional[types.CompletionItem]: + """Render a ``CompletionItem`` using ``insertText`` fields. + + This implements the ``insert`` behavior for directives. + Parameters + ---------- + context + The context in which the completion is being generated. + + directive + The directive. + + Returns + ------- + Optional[types.CompletionItem] + The rendered completion item, or ``None`` if the directive should be skipped + """ + insert_text = f".. {directive.name}::" + user_text = context.match.group(0).strip() + + # Since we can't replace any existing text, it only makes sense + # to offer completions that align with what the user has already written. + if not insert_text.startswith(user_text): + return None + + # Except that's not entirely true... to quote the LSP spec. (emphasis added) + # + # > in the model the client should filter against what the user has already typed + # > **using the word boundary rules of the language** (e.g. resolving the word + # > under the cursor position). The reason for this mode is that it makes it + # > extremely easy for a server to implement a basic completion list and get it + # > filtered on the client. + # + # So in other words... if the cursor is inside a word, that entire word will be + # replaced with what we have in `insert_text` so we need to be able to do something + # like + # .. -> image:: + # .. im -> image:: + # + # .. -> code-block:: + # .. cod -> code-block:: + # .. code-bl -> block:: + # + # .. -> c:function:: + # .. c -> c:function:: + # .. c: -> function:: + # .. c:fun -> function:: + # + # And since the client is free to interpret this how it likes, it's unlikely we'll + # be able to get this right in all cases for all clients. So for now this is going + # to target Kate's interpretation since it currently does not support ``text_edit`` + # and it was the editor that prompted this to be implemented in the first place. + # + # See: https://github.com/swyddfa/esbonio/issues/471 + + # If the existing text ends with a delimiter, then we should simply remove the + # entire prefix + if user_text.endswith((":", "-", " ")): + start_index = len(user_text) + + # Look for groups of word chars, replace text until the start of the final group + else: + start_indices = [m.start() for m in WORD.finditer(user_text)] or [ + len(user_text) + ] + start_index = max(start_indices) + + item = _render_directive_common(directive) + item.insert_text = insert_text[start_index:] + item.insert_text_format = types.InsertTextFormat.PlainText + return item + + +@renderer(language="rst", insert_behavior="replace") +def render_rst_directive_with_text_edit( + context: server.CompletionContext, + directive: Directive, +) -> Optional[types.CompletionItem]: + """Render a ``CompletionItem`` for a reStructuredText directive using the + ``textEdit`` field. + + Parameters + ---------- + context + The context in which the completion is being generated. + + directive + The directive to render. + + Returns + ------- + Optional[types.CompletionItem] + The rendered completion item, or ``None`` if the directive should be skipped + """ + match = context.match + + # Calculate the range of text the CompletionItems should edit, we don't need to + # touch indentation. + start = match.span()[0] + match.group(0).find(".") + + if match.group("argument"): + # If there is an existing argument to the directive, we should leave it + # untouched + end = match.span()[0] + match.group(0).find("::") + 2 + else: + end = match.span()[1] + + insert_text = f".. {directive.name}::" + + item = _render_directive_common(directive) + item.filter_text = insert_text + item.insert_text_format = types.InsertTextFormat.PlainText + item.text_edit = types.TextEdit( + new_text=insert_text, + range=types.Range( + start=types.Position(line=context.position.line, character=start), + end=types.Position(line=context.position.line, character=end), + ), + ) + + return item + + +def _render_directive_common(directive: Directive) -> types.CompletionItem: + """Render the common fields of a directive's completion item.""" + + return types.CompletionItem( + label=directive.name, + detail=directive.implementation, + kind=types.CompletionItemKind.Class, + data={"completion_type": "directive"}, + ) + + +# def _render_directive_option_with_insert_text( +# context: CompletionContext, +# directive: Directive, +# ) -> Optional[types.CompletionItem]: +# """Render a directive option's ``CompletionItem`` using the ``insertText`` field. + +# This implements the ``insert`` insert behavior for directive options. + +# Parameters +# ---------- +# context +# The context in which the completion is being generated. + +# name +# The name of the directive option, as it appears in an rst file. + +# directive +# The name of the directive, as it appears in an rst file. + +# implementation +# The class implementing the directive. + +# """ + +# insert_text = f":{name}:" +# user_text = context.match.group(0).strip() + +# if not insert_text.startswith(user_text): +# return None + +# if user_text.endswith((":", "-", " ")): +# start_index = len(user_text) + +# else: +# start_indices = [m.start() for m in WORD.finditer(user_text)] or [ +# len(user_text) +# ] +# start_index = max(start_indices) + +# item = _render_directive_option_common(name, directive, implementation) +# item.insert_text = insert_text[start_index:] +# return item + + +# def _render_directive_option_with_text_edit( +# context: CompletionContext, +# name: str, +# directive: str, +# implementation: Type[Directive], +# ) -> CompletionItem: +# """Render a directive option's ``CompletionItem`` using the``textEdit`` field. + +# This implements the ``replace`` insert behavior for directive options. + +# Parameters +# ---------- +# context +# The context in which the completion is being generated. + +# name +# The name of the directive option, as it appears in an rst file. + +# directive +# The name of the directive, as it appears in an rst file. + +# implementation +# The class implementing the directive. + +# """ + +# match = context.match +# groups = match.groupdict() + +# option = groups["option"] +# start = match.span()[0] + match.group(0).find(option) +# end = start + len(option) + +# range_ = Range( +# start=Position(line=context.position.line, character=start), +# end=Position(line=context.position.line, character=end), +# ) + +# insert_text = f":{name}:" + +# item = _render_directive_option_common(name, directive, implementation) +# item.filter_text = insert_text +# item.text_edit = TextEdit(range=range_, new_text=insert_text) + +# return item + + +# def _render_directive_option_common( +# name: str, directive: str, impl: Type[Directive] +# ) -> CompletionItem: +# """Render the common fields of a directive option's completion item.""" + +# try: +# impl_name = f"{impl.__module__}.{impl.__name__}" +# except AttributeError: +# impl_name = f"{impl.__module__}.{impl.__class__.__name__}" + +# return CompletionItem( +# label=name, +# detail=f"{impl_name}:{name}", +# kind=CompletionItemKind.Field, +# data={"completion_type": "directive_option", "for_directive": directive}, +# ) + diff --git a/lib/esbonio/esbonio/server/features/rst/directives.py b/lib/esbonio/esbonio/server/features/rst/directives.py new file mode 100644 index 000000000..046e767df --- /dev/null +++ b/lib/esbonio/esbonio/server/features/rst/directives.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +import typing + +from lsprotocol import types + +from esbonio import server +from esbonio.server.features.directives import DirectiveFeature +from esbonio.server.features.directives import completion +from esbonio.sphinx_agent.types import RST_DIRECTIVE + +if typing.TYPE_CHECKING: + from typing import List + from typing import Optional + + +class RstDirectives(server.LanguageFeature): + """A frontend to directives for reStructuredText syntax.""" + + def __init__(self, directives: DirectiveFeature, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.directives = directives + self._insert_behavior = "replace" + + completion_triggers = [RST_DIRECTIVE] + + def initialized(self, params: types.InitializedParams): + """Called once the initial handshake between client and server has finished.""" + self.configuration.subscribe( + "esbonio.server.completion", + server.CompletionConfig, + self.update_configuration, + ) + + def update_configuration( + self, event: server.ConfigChangeEvent[server.CompletionConfig] + ): + """Called when the user's configuration is updated.""" + self._insert_behavior = event.value.preferred_insert_behavior + + async def completion( + self, context: server.CompletionContext + ) -> Optional[List[types.CompletionItem]]: + """Provide completion suggestions for directives.""" + + groups = context.match.groupdict() + + # Are we completing a directive's options? + if "directive" not in groups: + return await self.complete_options(context) + + # Don't offer completions for targets + if (groups["name"] or "").startswith("_"): + return None + + # 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 await self.complete_arguments(context) + + return await self.complete_directives(context) + + async def complete_options(self, context: server.CompletionContext): + return None + + async def complete_arguments(self, context: server.CompletionContext): + return None + + async def complete_directives( + self, context: server.CompletionContext + ) -> Optional[List[types.CompletionItem]]: + """Return completion suggestions for the available directives.""" + + language = self.server.get_language_at(context.doc, context.position) + render_func = completion.get_directive_renderer(language, self._insert_behavior) + if render_func is None: + return None + + items = [] + for directive in await self.directives.suggest_directives(context): + if (item := render_func(context, directive)) is not None: + items.append(item) + + if len(items) > 0: + return items + + return None + + +def esbonio_setup(esbonio: server.EsbonioLanguageServer, directives: DirectiveFeature): + rst_directives = RstDirectives(directives, esbonio) + esbonio.add_feature(rst_directives) +