From d92b4b75140e752d777a50c90b6525970de6ff1c Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Mon, 8 Feb 2021 17:17:16 +0000 Subject: [PATCH 1/7] Update docs --- docs/conf.py | 23 +++++++++++++++++++++-- docs/contributing/lsp.rst | 11 +++++++++++ docs/contributing/lsp/directives.rst | 5 +++++ docs/contributing/lsp/testing.rst | 4 ++++ 4 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 docs/contributing/lsp.rst create mode 100644 docs/contributing/lsp/directives.rst create mode 100644 docs/contributing/lsp/testing.rst diff --git a/docs/conf.py b/docs/conf.py index a847d81e6..b06f42428 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -30,7 +30,20 @@ # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. -extensions = ["sphinx.ext.intersphinx", "esbonio.tutorial"] +extensions = [ + "sphinx.ext.autodoc", + "sphinx.ext.intersphinx", + "sphinx.ext.napoleon", + "sphinx.ext.viewcode", + "esbonio.tutorial", +] + +autodoc_default_options = { + "members": True, + "member-order": "bysource", + "undoc-members": True, +} +autodoc_typehints = "description" intersphinx_mapping = { "python": ("https://docs.python.org/3/", None), @@ -52,7 +65,13 @@ # a list of builtin themes. # html_theme = "sphinx_rtd_theme" - +html_context = { + "conf_py_path": "/docs/", + "display_github": True, + "github_repo": "esbonio", + "github_user": "swyddfa", + "github_version": "release", +} # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". diff --git a/docs/contributing/lsp.rst b/docs/contributing/lsp.rst new file mode 100644 index 000000000..170f4b496 --- /dev/null +++ b/docs/contributing/lsp.rst @@ -0,0 +1,11 @@ +Language Server +=============== + +This section contains autogenerated implementation notes based on docstrings in +the codebase. + +.. toctree:: + :maxdepth: 2 + :glob: + + lsp/* \ No newline at end of file diff --git a/docs/contributing/lsp/directives.rst b/docs/contributing/lsp/directives.rst new file mode 100644 index 000000000..8711b72a7 --- /dev/null +++ b/docs/contributing/lsp/directives.rst @@ -0,0 +1,5 @@ +Directives +========== + +.. automodule:: esbonio.lsp.directives + :members: \ No newline at end of file diff --git a/docs/contributing/lsp/testing.rst b/docs/contributing/lsp/testing.rst new file mode 100644 index 000000000..aece4d52f --- /dev/null +++ b/docs/contributing/lsp/testing.rst @@ -0,0 +1,4 @@ +Testing +======= + +.. automodule:: esbonio.lsp.testing From ceaf64f5d75963499be79e07dcb59138d6545021 Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Mon, 8 Feb 2021 17:19:00 +0000 Subject: [PATCH 2/7] Implement initial domain support for directives --- lib/esbonio/esbonio/lsp/__init__.py | 23 +- .../esbonio/lsp/completion/__init__.py | 1 - .../esbonio/lsp/completion/directives.py | 154 --------- lib/esbonio/esbonio/lsp/directives.py | 298 ++++++++++++++++++ .../esbonio/lsp/{completion => }/roles.py | 7 +- 5 files changed, 317 insertions(+), 166 deletions(-) delete mode 100644 lib/esbonio/esbonio/lsp/completion/__init__.py delete mode 100644 lib/esbonio/esbonio/lsp/completion/directives.py create mode 100644 lib/esbonio/esbonio/lsp/directives.py rename lib/esbonio/esbonio/lsp/{completion => }/roles.py (98%) diff --git a/lib/esbonio/esbonio/lsp/__init__.py b/lib/esbonio/esbonio/lsp/__init__.py index f42b092a0..b2d3fe1f3 100644 --- a/lib/esbonio/esbonio/lsp/__init__.py +++ b/lib/esbonio/esbonio/lsp/__init__.py @@ -1,7 +1,9 @@ +# from __future__ import annotations + import importlib import logging -from typing import List +from typing import List, Optional from pygls.features import COMPLETION, INITIALIZE, INITIALIZED, TEXT_DOCUMENT_DID_SAVE from pygls.server import LanguageServer @@ -13,15 +15,24 @@ Position, ) from pygls.workspace import Document +from sphinx.application import Sphinx BUILTIN_MODULES = [ "esbonio.lsp.sphinx", - "esbonio.lsp.completion.directives", - "esbonio.lsp.completion.roles", + "esbonio.lsp.directives", + "esbonio.lsp.roles", ] +class LanguageFeature: + """Base class for language features.""" + + def __init__(self, rst: "RstLanguageServer"): + self.rst = rst + self.logger = rst.logger.getChild(self.__class__.__name__) + + class RstLanguageServer(LanguageServer): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -29,7 +40,7 @@ def __init__(self, *args, **kwargs): self.logger = logging.getLogger(__name__) """The logger that should be used for all Language Server log entries""" - self.app = None + self.app: Optional[Sphinx] = None """Sphinx application instance configured for the current project.""" self.on_init_hooks = [] @@ -96,9 +107,7 @@ def create_language_server(modules: List[str]) -> RstLanguageServer: modules: The list of modules that should be loaded. """ - import asyncio - - server = RstLanguageServer(asyncio.new_event_loop()) + server = RstLanguageServer() for mod in modules: server.load_module(mod) diff --git a/lib/esbonio/esbonio/lsp/completion/__init__.py b/lib/esbonio/esbonio/lsp/completion/__init__.py deleted file mode 100644 index 369e399f0..000000000 --- a/lib/esbonio/esbonio/lsp/completion/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Completions.""" diff --git a/lib/esbonio/esbonio/lsp/completion/directives.py b/lib/esbonio/esbonio/lsp/completion/directives.py deleted file mode 100644 index 6fe069ce5..000000000 --- a/lib/esbonio/esbonio/lsp/completion/directives.py +++ /dev/null @@ -1,154 +0,0 @@ -"""Logic around directive completions goes here.""" -import importlib -import inspect -import re - -from typing import List - -from docutils.parsers.rst import directives -from pygls.types import CompletionItem, CompletionItemKind, InsertTextFormat - -from esbonio.lsp import RstLanguageServer - - -def resolve_directive(directive): - - # 'Core' docutils directives are returned as tuples (modulename, ClassName) - # so its up to us to resolve the reference - if isinstance(directive, tuple): - mod, cls = directive - - modulename = "docutils.parsers.rst.directives.{}".format(mod) - 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. - args = " ".join( - "${{{0}:arg{0}}}".format(i) for i in range(1, directive.required_arguments + 1) - ) - snippet = " {}:: {}$0".format(name, args) - - return CompletionItem( - name, - kind=CompletionItemKind.Class, - detail="directive", - documentation=documentation, - insert_text=snippet, - insert_text_format=InsertTextFormat.Snippet, - ) - - -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.""" - - def __init__(self, rst: RstLanguageServer): - self.rst = rst - - def initialize(self): - self.discover() - - def discover(self): - std_directives = {} - py_directives = {} - - # Find directives that have been registered directly with docutils. - dirs = {**directives._directive_registry, **directives._directives} - - if self.rst.app is not None: - - # Find directives that are held in a Sphinx domain. - # TODO: Implement proper domain handling, will focus on std + python for now - domains = self.rst.app.registry.domains - std_directives = domains["std"].directives - py_directives = domains["py"].directives - - dirs = {**dirs, **std_directives, **py_directives} - - self.directives = { - 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 - (?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, 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): - - directive_completion = DirectiveCompletion(rst) - rst.add_feature(directive_completion) diff --git a/lib/esbonio/esbonio/lsp/directives.py b/lib/esbonio/esbonio/lsp/directives.py new file mode 100644 index 000000000..ec4fa0b0c --- /dev/null +++ b/lib/esbonio/esbonio/lsp/directives.py @@ -0,0 +1,298 @@ +"""Logic around directive completions goes here.""" +import importlib +import inspect +import re + +from typing import List, Union, Tuple + +from docutils.parsers.rst import directives, Directive +from pygls.types import ( + CompletionItem, + CompletionItemKind, + InsertTextFormat, + Position, + Range, + TextEdit, +) +from pygls.workspace import Document + +from esbonio.lsp import RstLanguageServer, LanguageFeature + +DIRECTIVE = re.compile(r"\s*\.\.[ ](?P[\w]+:)?(?P[\w-]+)::") +"""A regular expression that matches a complete, valid directive declaration. Not +including the arguments or options.""" + +PARTIAL_DIRECTIVE = re.compile( + r""" + (?P^\s*) # directives can be indented + \.\. # start with a commment + (?P[ ]?) # may have a space + (?P[\w]+:)? # with an optional domain namespace + (?P[\w-]+)? # with an optional name + $ + """, + re.VERBOSE, +) +"""A regular expression that matches a partial directive declaraiton. Used when +generating auto complete suggestions.""" + +PARTIAL_DIRECTIVE_OPTION = 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, +) +"""A regular expression that matches a partial directive option. Used when generating +auto complete suggestions.""" + + +class Directives(LanguageFeature): + """Directive support for the language server.""" + + def initialize(self): + self.discover() + + def discover(self): + """Build an index of all available directives and the their options.""" + ignored_directives = ["restructuredtext-test-directive"] + + # Find directives that have been registered directly with docutils. + dirs = {**directives._directive_registry, **directives._directives} + + # Find directives under Sphinx domains + if self.rst.app is not None: + + domains = self.rst.app.registry.domains + primary_domain = self.rst.app.config.primary_domain + + for name, domain in domains.items(): + namefmt = "{name}:{dirname}" + + # The "standard" domain and the "primary_domain" do not require + # the prefix + if name == "std" or name == primary_domain: + namefmt = "{dirname}" + + dirs.update( + { + namefmt.format(name=name, dirname=dirname): directive + for dirname, directive in domain.directives.items() + } + ) + + self.directives = { + k: self.resolve_directive(v) + for k, v in dirs.items() + if k not in ignored_directives + } + + self.options = { + k: self.options_to_completion_items(v) for k, v in self.directives.items() + } + + self.logger.info("Discovered %s directives", len(self.directives)) + self.logger.debug(self.directives.keys()) + + def resolve_directive(self, directive: Union[Directive, Tuple[str]]): + + # 'Core' docutils directives are returned as tuples (modulename, ClassName) + # so its up to us to resolve the reference + if isinstance(directive, tuple): + mod, cls = directive + + modulename = "docutils.parsers.rst.directives.{}".format(mod) + module = importlib.import_module(modulename) + directive = getattr(module, cls) + + return directive + + suggest_triggers = [PARTIAL_DIRECTIVE, PARTIAL_DIRECTIVE_OPTION] + """Regular expressions that match lines that we want to offer autocomplete + suggestions for.""" + + def suggest( + self, match: re.Match, doc: Document, position: Position + ) -> List[CompletionItem]: + self.logger.debug("Trigger match: %s", match) + groups = match.groupdict() + + if "domain" in groups: + return self.suggest_directives(match, position) + + return self.suggest_options(match, doc, position) + + def suggest_directives(self, match, position) -> List[CompletionItem]: + self.logger.debug("Suggesting directives") + + domain = match.groupdict()["domain"] or "" + items = [] + + for name, directive in self.directives.items(): + + if not name.startswith(domain): + continue + + item = self.directive_to_completion_item(name, directive, match, position) + items.append(item) + + return items + + def suggest_options( + self, match: re.Match, doc: Document, position: Position + ) -> List[CompletionItem]: + + groups = match.groupdict() + + self.logger.debug("Suggesting options") + self.logger.debug("Match groups: %s", groups) + + indent = groups["indent"] + + # Search backwards so that we can determine the context for our completion + 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 = DIRECTIVE.match(line) + + self.logger.debug("Context line: %s", line) + self.logger.debug("Context match: %s", match) + + if not match: + return [] + + domain = match.group("domain") or "" + name = f"{domain}{match.group('name')}" + + self.logger.debug("Returning options for directive: %s", name) + return self.options.get(name, []) + + def directive_to_completion_item( + self, name: str, directive: Directive, match: re.Match, position: Position + ) -> CompletionItem: + """Convert an rst directive to its CompletionItem representation. + + Previously, it was fine to pre-convert directives into their completion item + representation during the :meth:`discover` phase. However a number of factors + combined to force this to be something we have to compute specifically for each + completion site. + + It all stems from directives that live under a namespaced domain e.g. + ``.. c:macro::``. First in order to get trigger character completions for + directives, we need to allow users to start typing the directive name + immediately after the second dot and have the CompletionItem insert the leading + space. Which is exactly what we used to do, setting + ``insert_text=" directive::"`` and we were done. + + However with domain support, we introduced the possibility of a ``:`` character + in the name of a directive. You can imagine a scenario where a user types in a + domain namespace, say ``py:`` in order to filter down the list of options to + directives that belong to that namespace. With ``:`` being a trigger character + for role completions and the like, this would cause editors like VSCode to issue + a new completion request ignoring the old one. + + That isn't necessarily the end of the world, but with CompletionItems assuming + that they were following the ``..`` characters, the ``insert_text`` was no + longer correct leading to broken completions like ``..py: py:function::``. + + In order to handle the two scenarios, conceptually the easiest approach is to + switch to using a ``text_edit`` and replace the entire line with the correct + text. Unfortunately in practice this was rather fiddly. + + Upon first setting the ``text_edit`` field VSCode suddenly stopped presenting + any options! After much debugging, head scratching and searching, I eventually + found a `couple `_ of + `issues `_ that hinted as to + what was happening. + + I **think** what happens is that since the ``range`` of the text edit extends + back to the start of the line VSCode considers the entire line to be the filter + for the CompletionItems so it's looking to select items that start with ``..`` + - which is none of them! + + To work around this, we additionaly need to set the ``filter_text`` field so + that VSCode computes matches against that instead of the label. Then in order + for the items to be shown the value of that field needs to be ``..my:directive`` + so it corresponds with what the user has actually written. + + Parameters + ---------- + name: + The name of the directive as a user would type in an rst document + directive: + The class definition that implements the Directive's behavior + match: + The regular expression match object that represents the line we are providing + the autocomplete suggestions for. + position: + The position in the source code where the autocompletion request was sent + from. + """ + groups = match.groupdict() + prefix = groups["prefix"] + indent = groups["indent"] + + documentation = inspect.getdoc(directive) + + # Ignore directives that do not provide their own documentation. + if documentation.startswith("Base class for reStructedText directives."): + documentation = None + + # TODO: Give better names to arguments based on what they represent. + args = " ".join( + "${{{0}:arg{0}}}".format(i) + for i in range(1, directive.required_arguments + 1) + ) + + return CompletionItem( + name, + kind=CompletionItemKind.Class, + detail="directive", + documentation=documentation, + filter_text=f"..{prefix}{name}", + insert_text_format=InsertTextFormat.Snippet, + text_edit=TextEdit( + range=Range( + Position(position.line, 0), + Position(position.line, position.character - 1), + ), + new_text=f"{indent}.. {name}:: {args}", + ), + ) + + def options_to_completion_items(self, directive: Directive) -> List[CompletionItem]: + """Convert a directive's options to a list of completion items. + + Parameters + ---------- + directive: + The directive whose options we are creating completions for. + """ + + 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 + ] + + +def setup(rst: RstLanguageServer): + + directive_completion = Directives(rst) + rst.add_feature(directive_completion) diff --git a/lib/esbonio/esbonio/lsp/completion/roles.py b/lib/esbonio/esbonio/lsp/roles.py similarity index 98% rename from lib/esbonio/esbonio/lsp/completion/roles.py rename to lib/esbonio/esbonio/lsp/roles.py index cc7300774..98f710db2 100644 --- a/lib/esbonio/esbonio/lsp/completion/roles.py +++ b/lib/esbonio/esbonio/lsp/roles.py @@ -8,13 +8,12 @@ from sphinx.domains import Domain from esbonio.lsp import RstLanguageServer +from esbonio.lsp.directives import DIRECTIVE def namespace_to_completion_item(namespace: str) -> CompletionItem: return CompletionItem( - namespace, - detail="intersphinx namespace", - kind=CompletionItemKind.Module, + namespace, detail="intersphinx namespace", kind=CompletionItemKind.Module, ) @@ -134,7 +133,7 @@ def suggest(self, match, doc, position) -> List[CompletionItem]: # Unless we are within a directive's options block, we should offer role # suggestions - if re.match(r"\s*\.\.[ ]*([\w-]+)::", line): + if DIRECTIVE.match(line): return [] return list(self.roles.values()) From 136cb516af42a0414d664a741737d7e2fd080e44 Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Mon, 8 Feb 2021 17:19:59 +0000 Subject: [PATCH 3/7] Start transitioning away from integration tests --- lib/esbonio/esbonio/lsp/testing.py | 83 ++++++++ .../tests/data/sphinx-extensions/conf.py | 3 + .../tests/lsp/completion/test_integration.py | 43 ---- lib/esbonio/tests/lsp/test_directives.py | 189 ++++++++++++++++++ 4 files changed, 275 insertions(+), 43 deletions(-) create mode 100644 lib/esbonio/esbonio/lsp/testing.py create mode 100644 lib/esbonio/tests/lsp/test_directives.py diff --git a/lib/esbonio/esbonio/lsp/testing.py b/lib/esbonio/esbonio/lsp/testing.py new file mode 100644 index 000000000..3ddf70b7a --- /dev/null +++ b/lib/esbonio/esbonio/lsp/testing.py @@ -0,0 +1,83 @@ +"""Utility functions to help with testing Language Server features.""" +import logging + +from typing import Optional, Set + +from pygls.types import Position +from pygls.workspace import Document + +logger = logging.getLogger(__name__) + + +def completion_test( + feature, text: str, expected: Optional[Set[str]], unexpected: Optional[Set[str]] +): + """Check to see if a feature provides the correct completion suggestions. + + **Only checking CompletionItem labels is supported** + + This function takes the given ``feature`` and calls it in the same manner as the + real language server so that it can simulate real usage without being a full blown + integration test. + + This requires ``suggest_triggers`` to be set and it to have a working ``suggest`` + method. + + Completions will be asked for with the cursor's position to be at the end of the + inserted ``text`` in a blank document by default. If your test case requires + additional context this can be included in ``text`` delimited by a ``\\f`` character. + + For example to pass text representing the following scenario (``^`` represents the + user's cursor):: + + .. image:: filename.png + :align: center + : + ^ + + The ``text`` parameter should be set to + ``.. image:: filename.png\\n :align: center\\n\\f :``. It's important to note that + newlines **cannot** come after the ``\\f`` character. + + If you want to test the case where no completions should be suggested, pass ``None`` + to both the ``expected`` and ``unexpected`` parameters. + + Parameters + ---------- + feature: + An instance of the language service feature to test. + text: + The text to offer completion suggestions for. + expected: + The set of completion item labels you expect to see in the output. + unexpected: + The set of completion item labels you do *not* expect to see in the output. + """ + + if "\f" in text: + contents, text = text.split("\f") + else: + contents = "" + + logger.debug("Context text: '%s'", contents) + logger.debug("Insertsion text: '%s'", text) + assert "\n" not in text, "Insertion text cannot contain newlines" + + document = Document("file:///test_doc.rst", contents) + position = Position(len(document.lines), len(text) - 1) + + results = [] + for trigger in feature.suggest_triggers: + match = trigger.match(text) + logger.debug("Match: %s", match) + + if match: + results += feature.suggest(match, document, position) + + items = {item.label for item in results} + + if expected is None: + assert len(items) == 0 + else: + assert expected == items & expected + assert set() == items & unexpected diff --git a/lib/esbonio/tests/data/sphinx-extensions/conf.py b/lib/esbonio/tests/data/sphinx-extensions/conf.py index 716017aa8..6dd79b80c 100644 --- a/lib/esbonio/tests/data/sphinx-extensions/conf.py +++ b/lib/esbonio/tests/data/sphinx-extensions/conf.py @@ -32,6 +32,9 @@ "sphinx": ("https://www.sphinx-doc.org/en/master", None), } +# Test with a different default domain. +primary_domain = "c" + # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] diff --git a/lib/esbonio/tests/lsp/completion/test_integration.py b/lib/esbonio/tests/lsp/completion/test_integration.py index d1169a318..a5a75167f 100644 --- a/lib/esbonio/tests/lsp/completion/test_integration.py +++ b/lib/esbonio/tests/lsp/completion/test_integration.py @@ -170,49 +170,6 @@ def intersphinx_patterns(rolename, namespace): @py.test.mark.parametrize( "text,setup", [ - *itertools.product( - [ - ".", - ".. doctest::", - ".. code-block::", - " .", - " .. doctest::", - " .. code-block::", - ".. _some_label:", - " .. _some_label:", - ], - [("sphinx-default", set())], - ), - *itertools.product( - [ - "..", - ".. ", - ".. d", - ".. code-b", - " ..", - " .. ", - " .. d", - " .. code-b", - ], - [ - ( - "sphinx-default", - {"admonition", "classmethod", "code-block", "image", "toctree"}, - ), - ( - "sphinx-extensions", - { - "admonition", - "classmethod", - "code-block", - "doctest", - "image", - "testsetup", - "toctree", - }, - ), - ], - ), *itertools.product( [":", ":r", "some text :", " :", " :r", " some text :"], [ diff --git a/lib/esbonio/tests/lsp/test_directives.py b/lib/esbonio/tests/lsp/test_directives.py new file mode 100644 index 000000000..63e9eb5e6 --- /dev/null +++ b/lib/esbonio/tests/lsp/test_directives.py @@ -0,0 +1,189 @@ +import logging +import unittest.mock as mock + +import py.test + +from esbonio.lsp.directives import Directives +from esbonio.lsp.testing import completion_test + + +DEFAULT_EXPECTED = { + "function", + "module", + "option", + "program", + "image", + "toctree", + "c:macro", + "c:function", +} + +DEFAULT_UNEXPECTED = { + "autoclass", + "automodule", + "py:function", + "py:module", + "std:program", + "std:option", +} + +EXTENSIONS_EXPECTED = { + "py:function", + "py:module", + "option", + "program", + "image", + "toctree", + "macro", + "function", +} + +EXTENSIONS_UNEXPECTED = { + "autoclass", + "automodule", + "c:macro", + "module", + "std:program", + "std:option", +} + + +@py.test.mark.parametrize( + "project,text,expected,unexpected", + [ + ("sphinx-default", ".", None, None), + ("sphinx-default", "..", DEFAULT_EXPECTED, DEFAULT_UNEXPECTED), + ("sphinx-default", ".. ", DEFAULT_EXPECTED, DEFAULT_UNEXPECTED), + ("sphinx-default", ".. d", DEFAULT_EXPECTED, DEFAULT_UNEXPECTED), + ("sphinx-default", ".. code-b", DEFAULT_EXPECTED, DEFAULT_UNEXPECTED), + ("sphinx-default", ".. code-block::", None, None), + ("sphinx-default", ".. py:", None, None), + ( + "sphinx-default", + ".. c:", + {"c:macro", "c:function"}, + {"function", "image", "toctree"}, + ), + ("sphinx-default", ".. _some_label:", None, None), + ("sphinx-default", " .", None, None), + ("sphinx-default", " ..", DEFAULT_EXPECTED, DEFAULT_UNEXPECTED), + ("sphinx-default", " .. ", DEFAULT_EXPECTED, DEFAULT_UNEXPECTED), + ("sphinx-default", " .. d", DEFAULT_EXPECTED, DEFAULT_UNEXPECTED), + ("sphinx-default", " .. doctest::", None, None), + ("sphinx-default", " .. code-b", DEFAULT_EXPECTED, DEFAULT_UNEXPECTED), + ("sphinx-default", " .. code-block::", None, None), + ("sphinx-default", " .. py:", None, None), + ("sphinx-default", " .. _some_label:", None, None), + ( + "sphinx-default", + " .. c:", + {"c:macro", "c:function"}, + {"function", "image", "toctree"}, + ), + ("sphinx-extensions", ".", None, None), + ("sphinx-extensions", "..", EXTENSIONS_EXPECTED, EXTENSIONS_UNEXPECTED), + ("sphinx-extensions", ".. ", EXTENSIONS_EXPECTED, EXTENSIONS_UNEXPECTED), + ("sphinx-extensions", ".. d", EXTENSIONS_EXPECTED, EXTENSIONS_UNEXPECTED), + ("sphinx-extensions", ".. code-b", EXTENSIONS_EXPECTED, EXTENSIONS_UNEXPECTED), + ("sphinx-extensions", ".. code-block::", None, None), + ("sphinx-extensions", ".. _some_label:", None, None), + ( + "sphinx-extensions", + ".. py:", + {"py:function", "py:module"}, + {"image, toctree", "macro", "function"}, + ), + ("sphinx-extensions", ".. c:", None, None), + ("sphinx-extensions", " .", None, None), + ("sphinx-extensions", " ..", EXTENSIONS_EXPECTED, EXTENSIONS_UNEXPECTED), + ("sphinx-extensions", " .. ", EXTENSIONS_EXPECTED, EXTENSIONS_UNEXPECTED), + ("sphinx-extensions", " .. d", EXTENSIONS_EXPECTED, EXTENSIONS_UNEXPECTED), + ("sphinx-extensions", " .. doctest::", None, None), + ("sphinx-extensions", " .. _some_label:", None, None), + ( + "sphinx-extensions", + " .. code-b", + EXTENSIONS_EXPECTED, + EXTENSIONS_UNEXPECTED, + ), + ("sphinx-extensions", " .. code-block::", None, None), + ( + "sphinx-extensions", + ".. py:", + {"py:function", "py:module"}, + {"image, toctree", "macro", "function"}, + ), + ("sphinx-extensions", " .. c:", None, None), + ], +) +def test_directive_completions(sphinx, project, text, expected, unexpected): + """Ensure that we can provide the correct completions for directives.""" + + rst = mock.Mock() + rst.app = sphinx(project) + rst.logger = logging.getLogger("rst") + + feature = Directives(rst) + feature.initialize() + + completion_test(feature, text, expected, unexpected) + + +IMAGE_OPTS = {"align", "alt", "class", "height", "scale", "target", "width"} +PY_FUNC_OPTS = {"annotation", "async", "module", "noindex", "noindexentry"} +C_FUNC_OPTS = {"noindexentry"} + + +@py.test.mark.parametrize( + "project,text,expected,unexpected", + [ + ("sphinx-default", ".. image:: f.png\n\f :", IMAGE_OPTS, {"ref", "func"}), + ("sphinx-default", ".. function:: foo\n\f :", PY_FUNC_OPTS, {"ref", "func"}), + ( + "sphinx-default", + " .. image:: f.png\n\f :", + IMAGE_OPTS, + {"ref", "func"}, + ), + ( + "sphinx-default", + " .. function:: foo\n\f :", + PY_FUNC_OPTS, + {"ref", "func"}, + ), + ("sphinx-extensions", ".. image:: f.png\n\f :", IMAGE_OPTS, {"ref", "func"}), + ( + "sphinx-extensions", + ".. function:: foo\n\f :", + C_FUNC_OPTS, + {"ref", "func"}, + ), + ( + "sphinx-extensions", + " .. image:: f.png\n\f :", + IMAGE_OPTS, + {"ref", "func"}, + ), + ( + "sphinx-extensions", + " .. function:: foo\n\f :", + C_FUNC_OPTS, + {"ref", "func"}, + ), + ], +) +def test_directive_option_completions( + sphinx, project, text, expected, unexpected, caplog +): + """Ensure that we can provide the correct completions for directive options.""" + + caplog.set_level(logging.DEBUG) + + rst = mock.Mock() + rst.app = sphinx(project) + rst.logger = logging.getLogger("rst") + + feature = Directives(rst) + feature.initialize() + + completion_test(feature, text, expected, unexpected) From 5f2dd297dc52770ee3f19e56d41bd6723470932a Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Mon, 8 Feb 2021 17:20:12 +0000 Subject: [PATCH 4/7] Cache sphinx project instances --- lib/esbonio/tests/conftest.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/lib/esbonio/tests/conftest.py b/lib/esbonio/tests/conftest.py index f28c9a853..113cb9877 100644 --- a/lib/esbonio/tests/conftest.py +++ b/lib/esbonio/tests/conftest.py @@ -88,13 +88,22 @@ def loader(filename, path_only=False): def sphinx(): """Return a Sphinx application instance pointed at the given project.""" + # Since extensions like intersphinx need to hit the network, let's cache + # app instances so we only incur this cost once. + cache = {} + basepath = pathlib.Path(__file__).parent / "data" def loader(project): src = str(basepath / project) + + if src in cache: + return cache[src] + build = str(basepath / project / "_build") - return Sphinx(src, src, build, build, "html", status=None, warning=None) + cache[src] = Sphinx(src, src, build, build, "html", status=None, warning=None) + return cache[src] return loader From 4a73fe38d18021ccf6ff5fe77300cef78d73f7e0 Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Mon, 8 Feb 2021 17:31:04 +0000 Subject: [PATCH 5/7] Add changelog entry --- lib/esbonio/changes/101.feature.rst | 1 + 1 file changed, 1 insertion(+) create mode 100644 lib/esbonio/changes/101.feature.rst diff --git a/lib/esbonio/changes/101.feature.rst b/lib/esbonio/changes/101.feature.rst new file mode 100644 index 000000000..d1e930b74 --- /dev/null +++ b/lib/esbonio/changes/101.feature.rst @@ -0,0 +1 @@ +Directive completions are now domain aware. \ No newline at end of file From 27738ea5bc73cc3245ab5d39d21ee6eb4dfe333d Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Mon, 8 Feb 2021 17:33:54 +0000 Subject: [PATCH 6/7] Fix tests --- .../sphinx-extensions/theorems/pythagoras.rst | 24 +++++----- .../tests/lsp/completion/test_directives.py | 46 ------------------- .../tests/lsp/completion/test_integration.py | 31 +------------ lib/esbonio/tests/lsp/test_directives.py | 6 +-- .../tests/lsp/{completion => }/test_roles.py | 2 +- 5 files changed, 16 insertions(+), 93 deletions(-) delete mode 100644 lib/esbonio/tests/lsp/completion/test_directives.py rename lib/esbonio/tests/lsp/{completion => }/test_roles.py (98%) diff --git a/lib/esbonio/tests/data/sphinx-extensions/theorems/pythagoras.rst b/lib/esbonio/tests/data/sphinx-extensions/theorems/pythagoras.rst index e6177ec1e..8816b8415 100644 --- a/lib/esbonio/tests/data/sphinx-extensions/theorems/pythagoras.rst +++ b/lib/esbonio/tests/data/sphinx-extensions/theorems/pythagoras.rst @@ -12,40 +12,40 @@ Implementation This project provides some functions which use Pythagoras' Theorem to calculate the length of a missing side of a right angled triangle when the other two are known. -.. module:: pythagoras +.. py:module:: pythagoras -.. currentmodule:: pythagoras +.. py:currentmodule:: pythagoras -.. data:: PI +.. py:data:: PI The value of the constant pi. -.. data:: UNKNOWN +.. py:data:: UNKNOWN Used to represent an unknown value. -.. class:: Triangle(a: float, b: float, c: float) +.. py:class:: Triangle(a: float, b: float, c: float) Represents a triangle - .. attribute:: a + .. py:attribute:: a The length of the side labelled ``a`` - .. attribute:: b + .. py:attribute:: b - The length of the side labelled ``b``` + The length of the side labelled ``b`` - .. attribute:: c + .. py:attribute:: c The length of the side labelled ``c`` - .. method:: is_right_angled() -> bool + .. py:method:: is_right_angled() -> bool :return: :code:`True` if the triangle is right angled. :rtype: bool -.. function:: calc_hypotenuse(a: float, b: float) -> float +.. py:function:: calc_hypotenuse(a: float, b: float) -> float Calculates the length of the hypotenuse of a right angled triangle. @@ -54,7 +54,7 @@ length of a missing side of a right angled triangle when the other two are known :return: Then length of the side ``c`` (the triangle's hypotenuse) :rtype: float -.. function:: calc_side(c: float, b: float) -> float +.. py:function:: calc_side(c: float, b: float) -> float Calculates the length of a side of a right angled triangle. diff --git a/lib/esbonio/tests/lsp/completion/test_directives.py b/lib/esbonio/tests/lsp/completion/test_directives.py deleted file mode 100644 index 0d9b1a27e..000000000 --- a/lib/esbonio/tests/lsp/completion/test_directives.py +++ /dev/null @@ -1,46 +0,0 @@ -from mock import Mock - -import py.test - -from esbonio.lsp.completion.directives import DirectiveCompletion - - -@py.test.mark.parametrize( - "project,expected,unexpected", - [ - ( - "sphinx-default", - [ - "figure", - "function", - "glossary", - "image", - "list-table", - "module", - "toctree", - ], - [ - "testcode", - "autoclass", - "automodule", - "restructuredtext-test-directive", - ], - ) - ], -) -def test_discovery(sphinx, project, expected, unexpected): - """Ensure that we can discover directives to offer as completion suggestions""" - - rst = Mock() - rst.app = sphinx(project) - - completion = DirectiveCompletion(rst) - completion.discover() - - for name in expected: - message = "Missing directive '{}'" - assert name in completion.directives.keys(), message.format(name) - - for name in unexpected: - message = "Unexpected directive '{}'" - assert name not in completion.directives.keys(), message.format(name) diff --git a/lib/esbonio/tests/lsp/completion/test_integration.py b/lib/esbonio/tests/lsp/completion/test_integration.py index a5a75167f..70cb2f0ac 100644 --- a/lib/esbonio/tests/lsp/completion/test_integration.py +++ b/lib/esbonio/tests/lsp/completion/test_integration.py @@ -145,12 +145,7 @@ def do_completion_test( def role_target_patterns(rolename): return [ s.format(rolename) - for s in [ - ":{}:`", - ":{}:`More Info <", - " :{}:`", - " :{}:`Some Label <", - ] + for s in [":{}:`", ":{}:`More Info <", " :{}:`", " :{}:`Some Label <"] ] @@ -172,9 +167,7 @@ def intersphinx_patterns(rolename, namespace): [ *itertools.product( [":", ":r", "some text :", " :", " :r", " some text :"], - [ - ("sphinx-default", {"class", "doc", "func", "ref", "term"}), - ], + [("sphinx-default", {"class", "doc", "func", "ref", "term"}),], ), *itertools.product( role_target_patterns("class"), @@ -343,23 +336,3 @@ def test_expected_completions(client_server, testdata, text, setup): root = testdata(project, path_only=True) 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/test_directives.py b/lib/esbonio/tests/lsp/test_directives.py index 63e9eb5e6..8efcd5c8e 100644 --- a/lib/esbonio/tests/lsp/test_directives.py +++ b/lib/esbonio/tests/lsp/test_directives.py @@ -172,13 +172,9 @@ def test_directive_completions(sphinx, project, text, expected, unexpected): ), ], ) -def test_directive_option_completions( - sphinx, project, text, expected, unexpected, caplog -): +def test_directive_option_completions(sphinx, project, text, expected, unexpected): """Ensure that we can provide the correct completions for directive options.""" - caplog.set_level(logging.DEBUG) - rst = mock.Mock() rst.app = sphinx(project) rst.logger = logging.getLogger("rst") diff --git a/lib/esbonio/tests/lsp/completion/test_roles.py b/lib/esbonio/tests/lsp/test_roles.py similarity index 98% rename from lib/esbonio/tests/lsp/completion/test_roles.py rename to lib/esbonio/tests/lsp/test_roles.py index 25412a7b6..b581550e2 100644 --- a/lib/esbonio/tests/lsp/completion/test_roles.py +++ b/lib/esbonio/tests/lsp/test_roles.py @@ -4,7 +4,7 @@ from pygls.types import CompletionItemKind -from esbonio.lsp.completion.roles import RoleCompletion, RoleTargetCompletion +from esbonio.lsp.roles import RoleCompletion, RoleTargetCompletion @py.test.mark.parametrize( From 718dd190d218d058fbc26bd867134c5e178becca Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Mon, 8 Feb 2021 17:51:39 +0000 Subject: [PATCH 7/7] Fix reference on 3.6 --- lib/esbonio/esbonio/lsp/directives.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/esbonio/esbonio/lsp/directives.py b/lib/esbonio/esbonio/lsp/directives.py index ec4fa0b0c..e28121e57 100644 --- a/lib/esbonio/esbonio/lsp/directives.py +++ b/lib/esbonio/esbonio/lsp/directives.py @@ -114,7 +114,7 @@ def resolve_directive(self, directive: Union[Directive, Tuple[str]]): suggestions for.""" def suggest( - self, match: re.Match, doc: Document, position: Position + self, match: "re.Match", doc: Document, position: Position ) -> List[CompletionItem]: self.logger.debug("Trigger match: %s", match) groups = match.groupdict() @@ -141,7 +141,7 @@ def suggest_directives(self, match, position) -> List[CompletionItem]: return items def suggest_options( - self, match: re.Match, doc: Document, position: Position + self, match: "re.Match", doc: Document, position: Position ) -> List[CompletionItem]: groups = match.groupdict() @@ -175,7 +175,7 @@ def suggest_options( return self.options.get(name, []) def directive_to_completion_item( - self, name: str, directive: Directive, match: re.Match, position: Position + self, name: str, directive: Directive, match: "re.Match", position: Position ) -> CompletionItem: """Convert an rst directive to its CompletionItem representation.