Skip to content

Commit 9154fb7

Browse files
committed
lsp: Implement completions for ..image::
and other file related directives
1 parent 11d0074 commit 9154fb7

File tree

3 files changed

+173
-6
lines changed

3 files changed

+173
-6
lines changed

lib/esbonio/esbonio/server/features/directives/__init__.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -201,10 +201,10 @@ async def suggest_arguments(
201201
def esbonio_setup(server: server.EsbonioLanguageServer):
202202
directives = DirectiveFeature(server)
203203
directives.add_directive_argument_provider(
204-
"values",
205-
providers.ValuesProvider(
206-
server.converter, server.logger.getChild("ValuesProvider")
207-
),
204+
"filepath", providers.FilepathProvider(server)
205+
)
206+
directives.add_directive_argument_provider(
207+
"values", providers.ValuesProvider(server)
208208
)
209209

210210
server.add_feature(directives)
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
from __future__ import annotations
2+
3+
import pathlib
4+
import typing
5+
6+
from lsprotocol import types as lsp
7+
8+
from esbonio import server
9+
10+
if typing.TYPE_CHECKING:
11+
from collections.abc import Coroutine
12+
from typing import Any
13+
14+
15+
class DirectiveArgumentProvider:
16+
"""Base class for directive argument providers."""
17+
18+
def __init__(self, esbonio: server.EsbonioLanguageServer):
19+
self.converter = esbonio.converter
20+
self.logger = esbonio.logger.getChild(self.__class__.__name__)
21+
22+
def suggest_arguments(
23+
self, context: server.CompletionContext, **kwargs
24+
) -> (
25+
list[lsp.CompletionItem]
26+
| None
27+
| Coroutine[Any, Any, list[lsp.CompletionItem] | None]
28+
):
29+
"""Given a completion context, suggest directive arguments that may be used."""
30+
return None
31+
32+
33+
class ValuesProvider(DirectiveArgumentProvider):
34+
"""Simple completions provider that supports a static list of values."""
35+
36+
def suggest_arguments( # type: ignore[override]
37+
self, context: server.CompletionContext, *, values: list[str | dict[str, Any]]
38+
) -> list[lsp.CompletionItem]:
39+
"""Given a completion context, suggest directive arguments that may be used."""
40+
result: list[lsp.CompletionItem] = []
41+
42+
for value in values:
43+
if isinstance(value, str):
44+
result.append(lsp.CompletionItem(label=value))
45+
continue
46+
47+
try:
48+
result.append(self.converter.structure(value, lsp.CompletionItem))
49+
except Exception:
50+
self.logger.exception("Unable to create CompletionItem")
51+
52+
return result
53+
54+
55+
class FilepathProvider(DirectiveArgumentProvider):
56+
"""Argument provider for filepaths."""
57+
58+
def suggest_arguments( # type: ignore[override]
59+
self,
60+
context: server.CompletionContext,
61+
*,
62+
root: str = "/",
63+
pattern: str | None = None,
64+
) -> list[lsp.CompletionItem]:
65+
"""Given a completion context, suggest files (or folders) that may be used.
66+
67+
Parameters
68+
----------
69+
root
70+
If the user provides an absolute path, generate suggestions relative to this directory.
71+
72+
pattern
73+
If set, limit suggestions only to matching files.
74+
75+
Returns
76+
-------
77+
list[lsp.CompletionItem]
78+
A list of completion items to suggest.
79+
"""
80+
uri = server.Uri.parse(context.doc.uri)
81+
cwd = pathlib.Path(uri).parent
82+
83+
if (partial := context.match.group("argument")) and partial.startswith("/"):
84+
candidate_dir = pathlib.Path(root)
85+
86+
# Be sure to remove the leading '/', otherwise partial will wipe out the
87+
# root when concatenated.
88+
partial = partial[1:]
89+
else:
90+
candidate_dir = cwd
91+
92+
candidate_dir /= partial
93+
if partial and not partial.endswith("/"):
94+
candidate_dir = candidate_dir.parent
95+
96+
self.logger.debug("Suggesting files relative to %r", candidate_dir)
97+
return [
98+
self._path_to_completion_item(context, p) for p in candidate_dir.glob("*")
99+
]
100+
101+
def _path_to_completion_item(
102+
self, context: server.CompletionContext, path: pathlib.Path
103+
) -> lsp.CompletionItem:
104+
"""Create the ``CompletionItem`` for the given path.
105+
106+
In the case where there are multiple filepath components, this function needs to
107+
provide an appropriate ``TextEdit`` so that the most recent entry in the path can
108+
be easily edited - without clobbering the existing path.
109+
110+
Also bear in mind that this function must play nice with both role target and
111+
directive argument completions.
112+
"""
113+
114+
new_text = f"{path.name}"
115+
kind = (
116+
lsp.CompletionItemKind.Folder
117+
if path.is_dir()
118+
else lsp.CompletionItemKind.File
119+
)
120+
121+
if (start := self._find_start_char(context)) == -1:
122+
insert_text = new_text
123+
filter_text = None
124+
text_edit = None
125+
else:
126+
start += 1
127+
_, end = context.match.span()
128+
prefix = context.match.group(0)[start:]
129+
130+
insert_text = None
131+
filter_text = f"{prefix}{new_text}" # Needed so VSCode will actually show the results.
132+
133+
text_edit = lsp.TextEdit(
134+
range=lsp.Range(
135+
start=lsp.Position(line=context.position.line, character=start),
136+
end=lsp.Position(line=context.position.line, character=end),
137+
),
138+
new_text=new_text,
139+
)
140+
141+
return lsp.CompletionItem(
142+
label=new_text,
143+
kind=kind,
144+
insert_text=insert_text,
145+
filter_text=filter_text,
146+
text_edit=text_edit,
147+
)
148+
149+
def _find_start_char(self, context: server.CompletionContext) -> int:
150+
matched_text = context.match.group(0)
151+
idx = matched_text.find("/")
152+
153+
while True:
154+
next_idx = matched_text.find("/", idx + 1)
155+
if next_idx == -1:
156+
break
157+
158+
idx = next_idx
159+
160+
return idx

lib/esbonio/esbonio/sphinx_agent/handlers/directives.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ def index_directives(app: Sphinx):
101101

102102
directives[name] = types.Directive(name, get_impl_name(directive))
103103

104-
_add_providers(directives)
104+
_add_providers(app, directives)
105105

106106
app.esbonio.db.ensure_table(DIRECTIVES_TABLE)
107107
app.esbonio.db.clear_table(DIRECTIVES_TABLE)
@@ -114,15 +114,22 @@ def setup(app: Sphinx):
114114
app.connect("builder-inited", index_directives, priority=999)
115115

116116

117-
def _add_providers(directives: dict[str, types.Directive]):
117+
def _add_providers(app: Sphinx, directives: dict[str, types.Directive]):
118118
"""Add provider definitions to built in directive types we know about."""
119119

120120
lexers_provider = _get_lexers_provider()
121+
filepath_provider = types.Directive.ArgumentProvider(
122+
"filepath", {"root": app.srcdir}
123+
)
121124

122125
for name in ["code-block", "sourcecode", "highlight"]:
123126
if (directive := directives.get(name)) is not None:
124127
directive.argument_providers = [lexers_provider]
125128

129+
for name in ["image", "figure", "include", "literalinclude"]:
130+
if (directive := directives.get(name)) is not None:
131+
directive.argument_providers = [filepath_provider]
132+
126133

127134
def _get_lexers_provider() -> types.Directive.ArgumentProvider:
128135
"""Get the argument provider instance that returns the names of pygments lexers."""

0 commit comments

Comments
 (0)