Skip to content

Commit

Permalink
Provide completion suggestions for a directive's options (#78)
Browse files Browse the repository at this point in the history
Contributes towards #36
  • Loading branch information
alcarney authored Feb 1, 2021
1 parent 556aa29 commit 0c40d0e
Show file tree
Hide file tree
Showing 10 changed files with 192 additions and 25 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
.coverage
.env
.ipynb_checkpoints
.tox
.vscode-test

Expand All @@ -14,4 +15,4 @@ node_modules
dist

.changes.html
CHANGELOG.md
CHANGELOG.md
10 changes: 9 additions & 1 deletion docs/lsp/features.rst
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Directives
.. figure:: ../../resources/images/complete-directive-demo.gif
:align: center

Completing directive targets
Completing directive names

.. note::

Expand All @@ -25,6 +25,14 @@ Directives

Support for additional domains will come in a future release

Directive Options
^^^^^^^^^^^^^^^^^

.. figure:: ../../resources/images/complete-directive-options-demo.gif
:align: center

Completing a directive's options

Roles
^^^^^

Expand Down
2 changes: 2 additions & 0 deletions lib/esbonio/changes/36.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
**Language Server** Directive option completions are now provided
within a directive's options block
2 changes: 1 addition & 1 deletion lib/esbonio/esbonio/lsp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ def on_completion(rst: RstLanguageServer, params: CompletionParams):
match = pattern.match(line)
if match:
for handler in handlers:
items += handler(match, line, doc)
items += handler(match, doc, pos)

return CompletionList(False, items)

Expand Down
78 changes: 68 additions & 10 deletions lib/esbonio/esbonio/lsp/completion/directives.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,7 @@
from esbonio.lsp import RstLanguageServer


def to_completion_item(name: str, directive) -> CompletionItem:
"""Convert an rst directive to its CompletionItem representation."""
def resolve_directive(directive):

# 'Core' docutils directives are returned as tuples (modulename, ClassName)
# so its up to us to resolve the reference
Expand All @@ -23,6 +22,13 @@ def to_completion_item(name: str, directive) -> CompletionItem:
module = importlib.import_module(modulename)
directive = getattr(module, cls)

return directive


def directive_to_completion_item(name: str, directive) -> CompletionItem:
"""Convert an rst directive to its CompletionItem representation."""

directive = resolve_directive(directive)
documentation = inspect.getdoc(directive)

# TODO: Give better names to arguments based on what they represent.
Expand All @@ -41,6 +47,23 @@ def to_completion_item(name: str, directive) -> CompletionItem:
)


def options_to_completion_items(directive) -> List[CompletionItem]:
"""Convert a directive's options to a list of completion items."""

directive = resolve_directive(directive)
options = directive.option_spec

if options is None:
return []

return [
CompletionItem(
opt, detail="option", kind=CompletionItemKind.Field, insert_text=f"{opt}:"
)
for opt in options
]


class DirectiveCompletion:
"""A completion handler for directives."""

Expand Down Expand Up @@ -68,26 +91,61 @@ def discover(self):
dirs = {**dirs, **std_directives, **py_directives}

self.directives = {
k: to_completion_item(k, v)
k: directive_to_completion_item(k, v)
for k, v in dirs.items()
if k != "restructuredtext-test-directive"
}

self.options = {
k: options_to_completion_items(v)
for k, v in dirs.items()
if k in self.directives
}

self.rst.logger.debug("Discovered %s directives", len(self.directives))

suggest_triggers = [
re.compile(
r"""
^\s* # directives may be indented
\.\. # they start with an rst comment
[ ]* # followed by a space
([\w-]+)?$ # with an optional name
^\s* # directives may be indented
\.\. # they start with an rst comment
[ ]* # followed by a space
(?P<name>[\w-]+)?$ # with an optional name
""",
re.VERBOSE,
)
),
re.compile(
r"""
(?P<indent>\s+) # directive options must only be preceeded by whitespace
: # they start with a ':'
(?P<name>[\w-]*) # they have a name
$
""",
re.VERBOSE,
),
]

def suggest(self, match, line, doc) -> List[CompletionItem]:
return list(self.directives.values())
def suggest(self, match, doc, position) -> List[CompletionItem]:
groups = match.groupdict()

if "indent" not in groups:
return list(self.directives.values())

# Search backwards so that we can determine the context for our completion
indent = groups["indent"]
linum = position.line - 1
line = doc.lines[linum]

while line.startswith(indent):
linum -= 1
line = doc.lines[linum]

# Only offer completions if we're within a directive's option block
match = re.match(r"\s*\.\.[ ]*(?P<name>[\w-]+)::", line)
if not match:
return []

return self.options.get(match.group("name"), [])


def setup(rst: RstLanguageServer):
Expand Down
28 changes: 24 additions & 4 deletions lib/esbonio/esbonio/lsp/completion/roles.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,27 @@ def discover(self):
)
]

def suggest(self, match, line, doc) -> List[CompletionItem]:
def suggest(self, match, doc, position) -> List[CompletionItem]:
indent = match.group(1)

# If there's no indent, then this can only be a role defn
if indent == "":
return list(self.roles.values())

# Otherwise, search backwards until we find a blank line or an unindent
# so that we can determine the appropriate context.
linum = position.line - 1
line = doc.lines[linum]

while line.startswith(indent):
linum -= 1
line = doc.lines[linum]

# Unless we are within a directive's options block, we should offer role
# suggestions
if re.match(r"\s*\.\.[ ]*([\w-]+)::", line):
return []

return list(self.roles.values())


Expand Down Expand Up @@ -200,7 +220,7 @@ def save(self, params: DidSaveTextDocumentParams):
),
]

def suggest(self, match, line, doc) -> List[CompletionItem]:
def suggest(self, match, doc, position) -> List[CompletionItem]:
# TODO: Detect if we're in an angle bracket e.g. :ref:`More Info <|` in that
# situation, add the closing '>' to the completion item insert text.

Expand Down Expand Up @@ -264,7 +284,7 @@ def initialize(self):

suggest_triggers = RoleTargetCompletion.suggest_triggers

def suggest(self, match, line, doc) -> List[CompletionItem]:
def suggest(self, match, doc, position) -> List[CompletionItem]:
return list(self.namespaces.values())


Expand Down Expand Up @@ -341,7 +361,7 @@ def initialize(self):
),
]

def suggest(self, match, line, doc) -> List[CompletionItem]:
def suggest(self, match, doc, position) -> List[CompletionItem]:
# TODO: Detect if we're in an angle bracket e.g. :ref:`More Info <|` in that
# situation, add the closing '>' to the completion item insert text.

Expand Down
2 changes: 2 additions & 0 deletions lib/esbonio/tests/data/sphinx-default/directive_options.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.. image:: filename.png
:alt: test
84 changes: 77 additions & 7 deletions lib/esbonio/tests/lsp/completion/test_integration.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import logging
import itertools
import pathlib
import time

from typing import Set

import py.test

from pygls.features import (
Expand All @@ -9,6 +13,7 @@
TEXT_DOCUMENT_DID_CHANGE,
TEXT_DOCUMENT_DID_OPEN,
)
from pygls.server import LanguageServer
from pygls.types import (
CompletionContext,
CompletionParams,
Expand All @@ -23,11 +28,55 @@
VersionedTextDocumentIdentifier,
)

WAIT = 0.1
WAIT = 0.1 # How long should we sleep after an lsp.notify(...)


def do_completion_test(
client: LanguageServer,
server: LanguageServer,
root: pathlib.Path,
filename: str,
text: str,
expected: Set[str],
insert_newline: bool = True,
):
"""A generic helper for performing completion tests.
Being an integration test, it is quite involved as it has to use the protocol to
take the server to a point where it can provide completion suggestions. As part of
the setup, this helper
def do_completion_test(client, server, root, text, expected):
"""The actual implementation of the completion test"""
- Sends an 'initialize' request to the server, setting the workspace root.
- Sends a 'textDocument/didOpen' notification, loading the specified document in the
server's workspace
- Sends a 'textDocument/didChange' notification, inserting the text we want
suggestions for
- Sends a 'completion' request, and ensures that the expected completed items are in
the response
Currently this method is only capable of ensuring that item labels are as expected,
none of the other CompletionItem fields are inspected. This method is also not capable
of ensuring particular items are NOT suggested.
Parameters
----------
client:
The client LanguageServer instance
server:
The server LanguageServer instance
root:
The directory to use as the workspace root
filename:
The file to open for the test, relative to the workspace root
text:
The text to insert, this is the text this method requests completions for.
Note this CANNOT contain any newlines.
expected:
The CompletionItem labels that should be returned. This does not have to be
exhaustive.
insert_newline:
Flag to indicate if a newline should be inserted before the given ``text``
"""

# Initialize the language server.
response = client.lsp.send_request(
Expand All @@ -46,7 +95,7 @@ def do_completion_test(client, server, root, text, expected):
# time.sleep(WAIT)

# Let's open a file to edit.
testfile = root / "index.rst"
testfile = root / filename
testuri = testfile.as_uri()
content = testfile.read_text()

Expand All @@ -60,15 +109,16 @@ def do_completion_test(client, server, root, text, expected):

# With the setup out of the way, let's type the text we want completion suggestions
# for
start = len(content.splitlines()) + 1
start = len(content.splitlines()) + insert_newline
text = "\n" + text if insert_newline else text

client.lsp.notify(
TEXT_DOCUMENT_DID_CHANGE,
DidChangeTextDocumentParams(
VersionedTextDocumentIdentifier(testuri, 2),
[
TextDocumentContentChangeEvent(
Range(Position(start, 0), Position(start, 0)), text="\n" + text
Range(Position(start, 0), Position(start, 0)), text=text
)
],
),
Expand Down Expand Up @@ -335,4 +385,24 @@ def test_expected_completions(client_server, testdata, text, setup):
project, expected = setup
root = testdata(project, path_only=True)

do_completion_test(client, server, root, text, expected)
do_completion_test(client, server, root, "index.rst", text, expected)


def test_expected_directive_option_completions(client_server, testdata, caplog):
"""Ensure that we can handle directive option completions."""

caplog.set_level(logging.INFO)

client, server = client_server
root = testdata("sphinx-default", path_only=True)
expected = {"align", "alt", "class", "height", "name", "scale", "target", "width"}

do_completion_test(
client,
server,
root,
"directive_options.rst",
" :a",
expected,
insert_newline=False,
)
8 changes: 7 additions & 1 deletion lib/esbonio/tests/lsp/completion/test_roles.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,13 @@ def test_target_type_discovery(sphinx, role, objects):
"sphinx-default",
"doc",
CompletionItemKind.File,
{"glossary", "index", "theorems/index", "theorems/pythagoras"},
{
"glossary",
"index",
"theorems/index",
"theorems/pythagoras",
"directive_options",
},
),
(
"sphinx-default",
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

0 comments on commit 0c40d0e

Please sign in to comment.