diff --git a/.gitignore b/.gitignore index 7d5cb348..e86da6bd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .coverage .env +.ipynb_checkpoints .tox .vscode-test @@ -14,4 +15,4 @@ node_modules dist .changes.html -CHANGELOG.md \ No newline at end of file +CHANGELOG.md diff --git a/docs/lsp/features.rst b/docs/lsp/features.rst index 0f64e5fd..43e79e51 100644 --- a/docs/lsp/features.rst +++ b/docs/lsp/features.rst @@ -15,7 +15,7 @@ Directives .. figure:: ../../resources/images/complete-directive-demo.gif :align: center - Completing directive targets + Completing directive names .. note:: @@ -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 ^^^^^ diff --git a/lib/esbonio/changes/36.feature.rst b/lib/esbonio/changes/36.feature.rst new file mode 100644 index 00000000..c9643692 --- /dev/null +++ b/lib/esbonio/changes/36.feature.rst @@ -0,0 +1,2 @@ +**Language Server** Directive option completions are now provided + within a directive's options block diff --git a/lib/esbonio/esbonio/lsp/__init__.py b/lib/esbonio/esbonio/lsp/__init__.py index 10ccc4d8..f42b092a 100644 --- a/lib/esbonio/esbonio/lsp/__init__.py +++ b/lib/esbonio/esbonio/lsp/__init__.py @@ -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) diff --git a/lib/esbonio/esbonio/lsp/completion/directives.py b/lib/esbonio/esbonio/lsp/completion/directives.py index 4167d26e..6fe069ce 100644 --- a/lib/esbonio/esbonio/lsp/completion/directives.py +++ b/lib/esbonio/esbonio/lsp/completion/directives.py @@ -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 @@ -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. @@ -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.""" @@ -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[\w-]+)?$ # with an optional name """, re.VERBOSE, - ) + ), + re.compile( + r""" + (?P\s+) # directive options must only be preceeded by whitespace + : # they start with a ':' + (?P[\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[\w-]+)::", line) + if not match: + return [] + + return self.options.get(match.group("name"), []) def setup(rst: RstLanguageServer): diff --git a/lib/esbonio/esbonio/lsp/completion/roles.py b/lib/esbonio/esbonio/lsp/completion/roles.py index 6ed7bfb1..cc730077 100644 --- a/lib/esbonio/esbonio/lsp/completion/roles.py +++ b/lib/esbonio/esbonio/lsp/completion/roles.py @@ -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()) @@ -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. @@ -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()) @@ -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. diff --git a/lib/esbonio/tests/data/sphinx-default/directive_options.rst b/lib/esbonio/tests/data/sphinx-default/directive_options.rst new file mode 100644 index 00000000..71f4118e --- /dev/null +++ b/lib/esbonio/tests/data/sphinx-default/directive_options.rst @@ -0,0 +1,2 @@ +.. image:: filename.png + :alt: test diff --git a/lib/esbonio/tests/lsp/completion/test_integration.py b/lib/esbonio/tests/lsp/completion/test_integration.py index 97f568aa..d1169a31 100644 --- a/lib/esbonio/tests/lsp/completion/test_integration.py +++ b/lib/esbonio/tests/lsp/completion/test_integration.py @@ -1,6 +1,10 @@ +import logging import itertools +import pathlib import time +from typing import Set + import py.test from pygls.features import ( @@ -9,6 +13,7 @@ TEXT_DOCUMENT_DID_CHANGE, TEXT_DOCUMENT_DID_OPEN, ) +from pygls.server import LanguageServer from pygls.types import ( CompletionContext, CompletionParams, @@ -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( @@ -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() @@ -60,7 +109,8 @@ 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, @@ -68,7 +118,7 @@ def do_completion_test(client, server, root, text, expected): VersionedTextDocumentIdentifier(testuri, 2), [ TextDocumentContentChangeEvent( - Range(Position(start, 0), Position(start, 0)), text="\n" + text + Range(Position(start, 0), Position(start, 0)), text=text ) ], ), @@ -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, + ) diff --git a/lib/esbonio/tests/lsp/completion/test_roles.py b/lib/esbonio/tests/lsp/completion/test_roles.py index 4f218e8a..25412a7b 100644 --- a/lib/esbonio/tests/lsp/completion/test_roles.py +++ b/lib/esbonio/tests/lsp/completion/test_roles.py @@ -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", diff --git a/resources/images/complete-directive-options-demo.gif b/resources/images/complete-directive-options-demo.gif new file mode 100644 index 00000000..8b2f1595 Binary files /dev/null and b/resources/images/complete-directive-options-demo.gif differ