Skip to content

Commit

Permalink
lsp: Split role support into multiple features
Browse files Browse the repository at this point in the history
As with directives, this commit splits the monolithic `Roles` feature
into a backend feature and two frontend features.

The original `Roles` feature is responsible for providing the API used
by the frontend features.

The `RstRoles` and `MystRoles` features use the backend to provide
features for the reStructuredText and MyST syntaxes respectively. Note
that this is already proving useful as the `MystRoles` feature does
not need the "backtracking" code the `RstRoles` feature uses to
determine if a completion is an actual role, or a directive's option.
  • Loading branch information
alcarney committed May 12, 2024
1 parent 07d8933 commit c38c869
Show file tree
Hide file tree
Showing 4 changed files with 212 additions and 94 deletions.
2 changes: 2 additions & 0 deletions lib/esbonio/esbonio/server/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,9 @@ def main(argv: Optional[Sequence[str]] = None):
"esbonio.server.features.directives",
"esbonio.server.features.roles",
"esbonio.server.features.rst.directives",
"esbonio.server.features.rst.roles",
"esbonio.server.features.myst.directives",
"esbonio.server.features.myst.roles",
"esbonio.server.features.sphinx_support.diagnostics",
"esbonio.server.features.sphinx_support.symbols",
"esbonio.server.features.sphinx_support.directives",
Expand Down
90 changes: 90 additions & 0 deletions lib/esbonio/esbonio/server/features/myst/roles.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
from __future__ import annotations

import typing

from lsprotocol import types

from esbonio import server
from esbonio.server.features.roles import RolesFeature
from esbonio.server.features.roles import completion
from esbonio.sphinx_agent.types import MYST_ROLE

if typing.TYPE_CHECKING:
from typing import List
from typing import Optional


class MystRoles(server.LanguageFeature):
"""A frontend to roles for MyST syntax."""

def __init__(self, roles: RolesFeature, *args, **kwargs):
super().__init__(*args, **kwargs)

self.roles = roles
self._insert_behavior = "replace"

completion_triggers = [MYST_ROLE]

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 roles."""

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 await self.complete_targets(context)

return await self.complete_roles(context)

async def complete_targets(self, context: server.CompletionContext):
return None

async def complete_roles(
self, context: server.CompletionContext
) -> Optional[List[types.CompletionItem]]:
"""Return completion suggestions for the available roles"""

language = self.server.get_language_at(context.doc, context.position)
render_func = completion.get_role_renderer(language, self._insert_behavior)
if render_func is None:
return None

items = []
for role in await self.roles.suggest_roles(context):
if (item := render_func(context, role)) is not None:
items.append(item)

if len(items) > 0:
return items

return None


def esbonio_setup(esbonio: server.EsbonioLanguageServer, roles: RolesFeature):
rst_roles = MystRoles(roles, esbonio)
esbonio.add_feature(rst_roles)
99 changes: 5 additions & 94 deletions lib/esbonio/esbonio/server/features/roles/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,8 @@
import typing

import attrs
from lsprotocol import types

from esbonio import server
from esbonio.sphinx_agent.types import MYST_ROLE
from esbonio.sphinx_agent.types import RST_DIRECTIVE
from esbonio.sphinx_agent.types import RST_ROLE

from . import completion

if typing.TYPE_CHECKING:
from typing import Any
Expand Down Expand Up @@ -44,13 +38,16 @@ def suggest_roles(


class RolesFeature(server.LanguageFeature):
"""Support for roles."""
"""Backend support for roles.
It's this language feature's responsibility to provide an API that exposes the
information a frontend feature may want.
"""

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

self._providers: Dict[int, RoleProvider] = {}
self._insert_behavior = "replace"

def add_provider(self, provider: RoleProvider):
"""Register a role provider.
Expand All @@ -62,92 +59,6 @@ def add_provider(self, provider: RoleProvider):
"""
self._providers[id(provider)] = provider

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

completion_triggers = [MYST_ROLE, RST_ROLE]

async def completion(
self, context: server.CompletionContext
) -> Optional[List[types.CompletionItem]]:
"""Provide completion suggestions for roles."""

language = self.server.get_language_at(context.doc, context.position)
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 await self.complete_targets(context)

# If there's no indent, or this is a markdown document, then this can only be a
# role definition
indent = context.match.group(1)
if indent == "" or language == "markdown":
return await 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 await 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 RST_DIRECTIVE.match(line):
return []

return await self.complete_roles(context)

async def complete_targets(self, context: server.CompletionContext):
return None

async def complete_roles(
self, context: server.CompletionContext
) -> Optional[List[types.CompletionItem]]:
"""Return completion suggestions for the available roles"""

language = self.server.get_language_at(context.doc, context.position)
render_func = completion.get_role_renderer(language, self._insert_behavior)
if render_func is None:
return None

items = []
for role in await self.suggest_roles(context):
if (item := render_func(context, role)) is not None:
items.append(item)

if len(items) > 0:
return items

return None

async def suggest_roles(self, context: server.CompletionContext) -> List[Role]:
"""Suggest roles that may be used, given a completion context.
Expand Down
115 changes: 115 additions & 0 deletions lib/esbonio/esbonio/server/features/rst/roles.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
from __future__ import annotations

import typing

from lsprotocol import types

from esbonio import server
from esbonio.server.features.roles import RolesFeature
from esbonio.server.features.roles import completion
from esbonio.sphinx_agent.types import RST_DIRECTIVE
from esbonio.sphinx_agent.types import RST_ROLE

if typing.TYPE_CHECKING:
from typing import List
from typing import Optional


class RstRoles(server.LanguageFeature):
"""A frontend to roles for reStructuredText syntax."""

def __init__(self, roles: RolesFeature, *args, **kwargs):
super().__init__(*args, **kwargs)

self.roles = roles
self._insert_behavior = "replace"

completion_triggers = [RST_ROLE]

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 roles."""

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 await 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 await 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 await 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 RST_DIRECTIVE.match(line):
return []

return await self.complete_roles(context)

async def complete_targets(self, context: server.CompletionContext):
return None

async def complete_roles(
self, context: server.CompletionContext
) -> Optional[List[types.CompletionItem]]:
"""Return completion suggestions for the available roles"""

language = self.server.get_language_at(context.doc, context.position)
render_func = completion.get_role_renderer(language, self._insert_behavior)
if render_func is None:
return None

items = []
for role in await self.roles.suggest_roles(context):
if (item := render_func(context, role)) is not None:
items.append(item)

if len(items) > 0:
return items

return None


def esbonio_setup(esbonio: server.EsbonioLanguageServer, roles: RolesFeature):
rst_roles = RstRoles(roles, esbonio)
esbonio.add_feature(rst_roles)

0 comments on commit c38c869

Please sign in to comment.