diff --git a/.vscode/launch.json b/.vscode/launch.json index aec4e6f50..c9b5ad7cb 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -4,10 +4,11 @@ { "type": "extensionHost", "request": "launch", - "name": "VSCode Extension", + "name": "Esbonio VSCode", "runtimeExecutable": "${execPath}", "args": [ - "--extensionDevelopmentPath=${workspaceRoot}/code" + "--extensionDevelopmentPath=${workspaceRoot}/code", + "--folder-uri=${workspaceRoot}/lib/esbonio/tests/data/sphinx-default" ], "outFiles": [ "${workspaceRoot}/code/dist/**/*.js" diff --git a/lib/esbonio/changes/29.feature.rst b/lib/esbonio/changes/29.feature.rst new file mode 100644 index 000000000..3690cb5c2 --- /dev/null +++ b/lib/esbonio/changes/29.feature.rst @@ -0,0 +1,4 @@ +**Language Server** Suggest completions for targets for a number of roles from the +`std `_ +and `py `_ +domains including ``ref``, ``doc``, ``func``, ``meth``, ``class`` and more. \ No newline at end of file diff --git a/lib/esbonio/esbonio/lsp/__init__.py b/lib/esbonio/esbonio/lsp/__init__.py index ef16b8ed6..7cb5fd768 100644 --- a/lib/esbonio/esbonio/lsp/__init__.py +++ b/lib/esbonio/esbonio/lsp/__init__.py @@ -3,16 +3,16 @@ from pygls.types import CompletionParams, InitializeParams, DidSaveTextDocumentParams from esbonio.lsp.completion import completions -from esbonio.lsp.initialize import initialized +from esbonio.lsp.initialize import initialized, discover_targets from esbonio.lsp.server import RstLanguageServer, server @server.feature(INITIALIZED) -def _(rst: RstLanguageServer, params): +def _(rst: RstLanguageServer, params: InitializeParams): return initialized(rst, params) -@server.feature(COMPLETION, trigger_characters=[".", ":"]) +@server.feature(COMPLETION, trigger_characters=[".", ":", "`"]) def _(rst: RstLanguageServer, params: CompletionParams): return completions(rst, params) @@ -21,4 +21,5 @@ def _(rst: RstLanguageServer, params: CompletionParams): def _(rst: RstLanguageServer, params: DidSaveTextDocumentParams): """Re-read sources on save so we get the latest completion targets.""" rst.app.builder.read() + rst.targets = discover_targets(rst.app) return diff --git a/lib/esbonio/esbonio/lsp/completion.py b/lib/esbonio/esbonio/lsp/completion.py index a7c658408..1ee8a2ef3 100644 --- a/lib/esbonio/esbonio/lsp/completion.py +++ b/lib/esbonio/esbonio/lsp/completion.py @@ -1,24 +1,52 @@ """Auto complete suggestions.""" import re -from pygls.types import CompletionList, CompletionParams, Position +from typing import List + +from pygls.types import ( + CompletionList, + CompletionItem, + CompletionItemKind, + CompletionParams, + Position, +) from pygls.workspace import Document from esbonio.lsp.server import RstLanguageServer -NEW_DIRECTIVE = re.compile(r"^\s*\.\.[ ]*([\w-]+)?$") -NEW_ROLE = re.compile(r".*(? str: - """Return the line up until the position of the cursor.""" +# This should match someone typing out a new directive e.g. .. code-bl| +DIRECTIVE = re.compile( + r"""^\s* # directives may be indented + \.\. # they start with an rst comment '..' + [ ]* # followed by a space + ([\w-]+)?$ # with an optional name + """, + re.VERBOSE, +) - try: - line = doc.lines[position.line] - except IndexError: - return "" +# This should match someone typing out a new role e.g. :re| +ROLE = re.compile( + r"""(^|.*[ ]) # roles must be preceeded by a space, or start the line + : # roles start with the ':' character + (?!:) # make sure the next character is not ':' + [\w-]* # match the role name + $ # ensure pattern only matches incomplete roles + """, + re.MULTILINE | re.VERBOSE, +) - return line[: position.character] +# This should match someonw typing out a role target e.g. :ref:`ti| +ROLE_TARGET = re.compile( + r"""(^|.*[ ]) # roles must be preveeded by a space, or start the line + : # roles start with the ':' character + (?P[\w-]+) # capture the role name, suggestions will change based on it + : # the role name ends with a ':' + ` # the target begins with a '`'` + """, + re.MULTILINE | re.VERBOSE, +) def completions(rst: RstLanguageServer, params: CompletionParams): @@ -28,15 +56,47 @@ def completions(rst: RstLanguageServer, params: CompletionParams): doc = rst.workspace.get_document(uri) line = get_line_til_position(doc, pos) - rst.logger.debug("Line: '{}'".format(line)) - if NEW_DIRECTIVE.match(line): - candidates = list(rst.directives.values()) + target_match = ROLE_TARGET.match(line) + + if DIRECTIVE.match(line): + return CompletionList(False, list(rst.directives.values())) + + if target_match: + return CompletionList(False, suggest_targets(rst, target_match)) + + if ROLE.match(line): + return CompletionList(False, list(rst.roles.values())) + + return CompletionList(False, []) + - elif NEW_ROLE.match(line): - candidates = list(rst.roles.values()) +def suggest_targets(rst: RstLanguageServer, match) -> List[CompletionItem]: + """Suggest targets based on the current role.""" - else: - candidates = [] + if match is None: + return [] - return CompletionList(False, candidates) + # Look up the kind of item we need to suggest. + name = match.group("name") + types = rst.target_types.get(name, None) + + if types is None: + return [] + + targets = [] + for type_ in types: + targets += rst.targets.get(type_, []) + + return targets + + +def get_line_til_position(doc: Document, position: Position) -> str: + """Return the line up until the position of the cursor.""" + + try: + line = doc.lines[position.line] + except IndexError: + return "" + + return line[: position.character] diff --git a/lib/esbonio/esbonio/lsp/initialize.py b/lib/esbonio/esbonio/lsp/initialize.py index 425f478fc..12def6615 100644 --- a/lib/esbonio/esbonio/lsp/initialize.py +++ b/lib/esbonio/esbonio/lsp/initialize.py @@ -3,6 +3,7 @@ import importlib import logging import pathlib +from typing import Dict, List import appdirs import sphinx.util.console as console @@ -17,16 +18,18 @@ MessageType, ) from sphinx.application import Sphinx +from sphinx.domains import Domain from esbonio.lsp.server import RstLanguageServer -from esbonio.lsp.logger import LspHandler def initialized(rst: RstLanguageServer, params: InitializeParams): """Do set up once the initial handshake has been completed.""" - init_sphinx(rst) + rst.app = init_sphinx(rst) discover_completion_items(rst) + rst.target_types = discover_target_types(rst.app) + rst.targets = discover_targets(rst.app) def discover_completion_items(rst: RstLanguageServer): @@ -70,6 +73,72 @@ def discover_roles(app: Sphinx): return {**local_roles, **role_registry, **py_roles, **std_roles} +def discover_target_types(app: Sphinx): + """Discover all the target types we could complete on. + + This returns a dictionary of the form {'rolename': 'objecttype'} which will allow + us to determine which kind of completions we should suggest when someone starts + typing out a role. + + This is unlikely to change much during a session, so it's probably safe to compute + this once on startup. + """ + + # TODO: Implement proper domain handling, focus on 'py' and 'std' for now. + domains = app.env.domains + py = domains["py"] + std = domains["std"] + + def make_map(domain: Domain): + types = {} + + for name, obj in domain.object_types.items(): + for role in obj.roles: + objs = types.get(role, None) + + if objs is None: + objs = [] + + objs.append(name) + types[role] = objs + + return types + + return {**make_map(py), **make_map(std)} + + +def discover_targets(app: Sphinx) -> Dict[str, List[CompletionItem]]: + """Discover all the targets we can offer as suggestions. + + This returns a dictionary of the form {'objecttype': [CompletionItems]} + + These are likely to change over the course of an editing session, so this should + also be called when the client notifies us of a file->save event. + """ + + domains = app.env.domains + + def find_targets(domain: Domain): + items = {} + + for (name, disp, type_, _, _, _) in domain.get_objects(): + list = items.get(type_, None) + + if list is None: + list = [] + + list.append(completion_from_target(name, disp, type_)) + items[type_] = list + + return items + + # TODO: Implement proper domain handling, focus on 'py' and 'std' for now + py = find_targets(domains["py"]) + std = find_targets(domains["std"]) + + return {**py, **std} + + def completion_from_directive(name, directive) -> CompletionItem: """Convert an rst directive to a completion item we can return to the client.""" @@ -92,13 +161,33 @@ def completion_from_directive(name, directive) -> CompletionItem: ) +TARGET_KINDS = { + "attribute": CompletionItemKind.Field, + "doc": CompletionItemKind.File, + "class": CompletionItemKind.Class, + # "const": CompletionItemKind.Value, + "envvar": CompletionItemKind.Variable, + "function": CompletionItemKind.Function, + "method": CompletionItemKind.Method, + "module": CompletionItemKind.Module, + "term": CompletionItemKind.Text, +} + + +def completion_from_target(name, display, type_) -> CompletionItem: + """Convert a target into a completion item we can return to the client""" + + kind = TARGET_KINDS.get(type_, CompletionItemKind.Reference) + return CompletionItem(name, kind=kind, detail=display, insert_text=name) + + def completion_from_role(name, role) -> CompletionItem: """Convert an rst directive to a completion item we can return to the client.""" return CompletionItem( name, kind=CompletionItemKind.Function, detail="role", - insert_text="{}:`$1`".format(name), + insert_text="{}:`$0`".format(name), insert_text_format=InsertTextFormat.Snippet, ) @@ -111,7 +200,7 @@ def write(self, line): self.logger.info(line) -def init_sphinx(rst: RstLanguageServer): +def init_sphinx(rst: RstLanguageServer) -> Sphinx: """Initialise a Sphinx application instance.""" rst.logger.debug("Workspace root %s", rst.workspace.root_uri) @@ -139,7 +228,8 @@ def init_sphinx(rst: RstLanguageServer): # Create a 'LogIO' object which we use to redirect Sphinx's output to the LSP Client log = LogIO() - rst.app = Sphinx(src, src, build, doctrees, "html", status=log, warning=log) + app = Sphinx(src, src, build, doctrees, "html", status=log, warning=log) # Do a read of all the sources to populate the environment with completion targets - rst.app.builder.read() + app.builder.read() + return app diff --git a/lib/esbonio/esbonio/lsp/server.py b/lib/esbonio/esbonio/lsp/server.py index 2e8e08cf0..2c61a17bf 100644 --- a/lib/esbonio/esbonio/lsp/server.py +++ b/lib/esbonio/esbonio/lsp/server.py @@ -17,5 +17,11 @@ def __init__(self): self.roles = {} """Dictionary holding the roles that have been registered.""" + self.targets = {} + """Dictionary holding objects that may be referenced by a role.""" + + self.target_types = {} + """Dictionary holding role names and the object types they can reference.""" + server = RstLanguageServer() diff --git a/lib/esbonio/tests/data/sphinx-default/.vscode/settings.json b/lib/esbonio/tests/data/sphinx-default/.vscode/settings.json new file mode 100644 index 000000000..b8fc51f55 --- /dev/null +++ b/lib/esbonio/tests/data/sphinx-default/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "python.pythonPath": "${workspaceRoot}/../../../../../.env/bin/python" +} \ No newline at end of file diff --git a/lib/esbonio/tests/data/sphinx-default/glossary.rst b/lib/esbonio/tests/data/sphinx-default/glossary.rst new file mode 100644 index 000000000..796f635be --- /dev/null +++ b/lib/esbonio/tests/data/sphinx-default/glossary.rst @@ -0,0 +1,10 @@ +Glossary +======== + +.. glossary:: + + hypotenuse + The longest side of a triangle + + right angle + An angle of 90 degrees \ No newline at end of file diff --git a/lib/esbonio/tests/data/sphinx-default/index.rst b/lib/esbonio/tests/data/sphinx-default/index.rst index c8f8ae78c..fd10b88b4 100644 --- a/lib/esbonio/tests/data/sphinx-default/index.rst +++ b/lib/esbonio/tests/data/sphinx-default/index.rst @@ -3,6 +3,8 @@ You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. +.. _welcome: + Welcome to Defaults's documentation! ==================================== @@ -10,11 +12,21 @@ Welcome to Defaults's documentation! :maxdepth: 2 :caption: Contents: + theorems/index + glossary + +Setup +===== + +In order to run the program you need a few environment variables set. + +.. envvar:: ANGLE_UNIT + Use this environment variable to set the unit used when describing angles. Valid + values are ``degress``, ``radians`` or ``gradians``. -Indices and tables -================== +.. envvar:: PRECISION -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` + Use this to set the level of precision used when manipulating floating point numbers. + Its value is an integer which represents the number of decimal places to use, default + value is ``2`` diff --git a/lib/esbonio/tests/data/sphinx-default/theorems/index.rst b/lib/esbonio/tests/data/sphinx-default/theorems/index.rst new file mode 100644 index 000000000..14c0817b5 --- /dev/null +++ b/lib/esbonio/tests/data/sphinx-default/theorems/index.rst @@ -0,0 +1,8 @@ +Theorems +======== + +There are many useful theorems, you will find some of them here. + +.. toctree:: + + pythagoras \ No newline at end of file diff --git a/lib/esbonio/tests/data/sphinx-default/theorems/pythagoras.rst b/lib/esbonio/tests/data/sphinx-default/theorems/pythagoras.rst new file mode 100644 index 000000000..e6177ec1e --- /dev/null +++ b/lib/esbonio/tests/data/sphinx-default/theorems/pythagoras.rst @@ -0,0 +1,64 @@ +.. _pythagoras_theorem: + +Pythagoras' Theorem +=================== + +Pythagoras' Theorem describes the relationship between the length of the +sides of a right angled triangle. + +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 + +.. currentmodule:: pythagoras + +.. data:: PI + + The value of the constant pi. + +.. data:: UNKNOWN + + Used to represent an unknown value. + +.. class:: Triangle(a: float, b: float, c: float) + + Represents a triangle + + .. attribute:: a + + The length of the side labelled ``a`` + + .. attribute:: b + + The length of the side labelled ``b``` + + .. attribute:: c + + The length of the side labelled ``c`` + + .. method:: is_right_angled() -> bool + + :return: :code:`True` if the triangle is right angled. + :rtype: bool + +.. function:: calc_hypotenuse(a: float, b: float) -> float + + Calculates the length of the hypotenuse of a right angled triangle. + + :param float a: The length of the side labelled ``a`` + :param float b: The length of the side labelled ``b`` + :return: Then length of the side ``c`` (the triangle's hypotenuse) + :rtype: float + +.. function:: calc_side(c: float, b: float) -> float + + Calculates the length of a side of a right angled triangle. + + :param float c: The length of the side labelled ``c`` (the triangle's hypotenuse) + :param float b: The length of the side labelled ``b`` + :return: Then length of the side ``a`` + :rtype: float diff --git a/lib/esbonio/tests/lsp/test_completions.py b/lib/esbonio/tests/lsp/test_completions.py index 4e7dc465e..758c8a793 100644 --- a/lib/esbonio/tests/lsp/test_completions.py +++ b/lib/esbonio/tests/lsp/test_completions.py @@ -24,7 +24,11 @@ from pygls.workspace import Document, Workspace from esbonio.lsp.completion import completions -from esbonio.lsp.initialize import discover_roles +from esbonio.lsp.initialize import ( + discover_roles, + discover_target_types, + discover_targets, +) def make_document(contents) -> Document: @@ -54,6 +58,11 @@ def make_params( EXAMPLE_DIRECTIVES = [CompletionItem("doctest", kind=CompletionItemKind.Class)] EXAMPLE_ROLES = [CompletionItem("ref", kind=CompletionItemKind.Function)] +EXAMPLE_CLASSES = [CompletionItem("pythagoras.Triangle", kind=CompletionItemKind.Class)] +EXAMPLE_DOCS = [CompletionItem("reference/index", kind=CompletionItemKind.Reference)] +EXAMPLE_EXC = [CompletionItem("FloatingPointError", kind=CompletionItemKind.Class)] +EXAMPLE_REFS = [CompletionItem("search", kind=CompletionItemKind.Reference)] + @py.test.fixture() def rst(): @@ -78,6 +87,18 @@ def __init__(self): server.directives = {c.label: c for c in EXAMPLE_DIRECTIVES} server.roles = {c.label: c for c in EXAMPLE_ROLES} + server.target_types = { + "class": ["class", "exception"], + "ref": ["label"], + "doc": ["doc"], + } + server.targets = { + "class": EXAMPLE_CLASSES, + "doc": EXAMPLE_DOCS, + "exception": EXAMPLE_EXC, + "label": EXAMPLE_REFS, + } + return server @@ -106,6 +127,22 @@ def __init__(self): (" :r", make_params(character=5), EXAMPLE_ROLES), ("some text :", make_params(character=11), EXAMPLE_ROLES), (" some text :", make_params(character=14), EXAMPLE_ROLES), + (".. _some_target:", make_params(character=16), []), + (" .. _some_target:", make_params(character=19), []), + (":ref:", make_params(character=5), []), + # Role Target Suggestions + (":doc:`", make_params(character=6), EXAMPLE_DOCS), + (":doc:``", make_params(character=6), EXAMPLE_DOCS), + (" :doc:`", make_params(character=9), EXAMPLE_DOCS), + (" :doc:``", make_params(character=9), EXAMPLE_DOCS), + (":class:`", make_params(character=8), EXAMPLE_CLASSES + EXAMPLE_EXC), + (":class:``", make_params(character=8), EXAMPLE_CLASSES + EXAMPLE_EXC), + (" :class:`", make_params(character=12), EXAMPLE_CLASSES + EXAMPLE_EXC), + (" :class:``", make_params(character=12), EXAMPLE_CLASSES + EXAMPLE_EXC), + (":ref:`", make_params(character=6), EXAMPLE_REFS), + (":ref:``", make_params(character=6), EXAMPLE_REFS), + (" :ref:`", make_params(character=9), EXAMPLE_REFS), + (" :ref:``", make_params(character=9), EXAMPLE_REFS), ], ) def test_completion_suggestions(rst, doc, params, expected): @@ -150,3 +187,109 @@ def test_role_discovery(sphinx, project, expected, unexpected): for name in unexpected: assert name not in roles.keys(), "Unexpected role '{}'".format(name) + + +@py.test.mark.parametrize( + "project,type,kind,expected", + [ + ( + "sphinx-default", + "attribute", + CompletionItemKind.Field, + {"pythagoras.Triangle.a", "pythagoras.Triangle.b", "pythagoras.Triangle.c"}, + ), + ("sphinx-default", "class", CompletionItemKind.Class, {"pythagoras.Triangle"}), + ( + "sphinx-default", + "doc", + CompletionItemKind.File, + {"glossary", "index", "theorems/index", "theorems/pythagoras"}, + ), + ( + "sphinx-default", + "envvar", + CompletionItemKind.Variable, + {"ANGLE_UNIT", "PRECISION"}, + ), + ( + "sphinx-default", + "function", + CompletionItemKind.Function, + {"pythagoras.calc_side", "pythagoras.calc_hypotenuse"}, + ), + ( + "sphinx-default", + "method", + CompletionItemKind.Method, + {"pythagoras.Triangle.is_right_angled"}, + ), + ("sphinx-default", "module", CompletionItemKind.Module, {"pythagoras"}), + ( + "sphinx-default", + "label", + CompletionItemKind.Reference, + { + "genindex", + "modindex", + "py-modindex", + "pythagoras_theorem", + "search", + "welcome", + }, + ), + ( + "sphinx-default", + "term", + CompletionItemKind.Text, + {"hypotenuse", "right angle"}, + ), + ], +) +def test_target_discovery(sphinx, project, type, kind, expected): + """Ensure that we can discover the appropriate targets to complete on.""" + + app = sphinx(project) + app.builder.read() + targets = discover_targets(app) + + assert type in targets + assert expected == {item.label for item in targets[type]} + assert kind == targets[type][0].kind + + +@py.test.mark.parametrize( + "role,objects", + [ + ("attr", {"attribute"}), + ("class", {"class", "exception"}), + ("data", {"data"}), + ("doc", {"doc"}), + ("envvar", {"envvar"}), + ("exc", {"class", "exception"}), + ("func", {"function"}), + ("meth", {"method", "classmethod", "staticmethod"}), + ( + "obj", + { + "attribute", + "class", + "classmethod", + "data", + "exception", + "function", + "method", + "module", + "staticmethod", + }, + ), + ("ref", {"label"}), + ("term", {"term"}), + ], +) +def test_target_type_discovery(sphinx, role, objects): + """Ensure that we can discover target types correctly.""" + + app = sphinx("sphinx-default") + types = discover_target_types(app) + + assert {*types[role]} == objects