Skip to content

Commit

Permalink
Suggest completions for role targets (#29)
Browse files Browse the repository at this point in the history
- Use the [Domain API](https://www.sphinx-doc.org/en/master/extdev/domainapi.html#domain-api) to discover potential completion targets for roles
- Tweak `launch.json` so VSCode + Esbonio opens pointed at the `sphinx-default` test project.
  • Loading branch information
alcarney authored Dec 6, 2020
1 parent 4286d4d commit 7fbd015
Show file tree
Hide file tree
Showing 12 changed files with 438 additions and 36 deletions.
5 changes: 3 additions & 2 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 4 additions & 0 deletions lib/esbonio/changes/29.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
**Language Server** Suggest completions for targets for a number of roles from the
`std <https://www.sphinx-doc.org/en/master/usage/restructuredtext/domains.html#the-standard-domain>`_
and `py <https://www.sphinx-doc.org/en/master/usage/restructuredtext/domains.html#the-python-domain>`_
domains including ``ref``, ``doc``, ``func``, ``meth``, ``class`` and more.
7 changes: 4 additions & 3 deletions lib/esbonio/esbonio/lsp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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
98 changes: 79 additions & 19 deletions lib/esbonio/esbonio/lsp/completion.py
Original file line number Diff line number Diff line change
@@ -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".*(?<!:):(?!:)[\w-]*")

# Regular expressions used to determine which completions we should offer.

def get_line_til_position(doc: Document, position: Position) -> 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<name>[\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):
Expand All @@ -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]
102 changes: 96 additions & 6 deletions lib/esbonio/esbonio/lsp/initialize.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import importlib
import logging
import pathlib
from typing import Dict, List

import appdirs
import sphinx.util.console as console
Expand All @@ -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):
Expand Down Expand Up @@ -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."""

Expand All @@ -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,
)

Expand All @@ -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)

Expand Down Expand Up @@ -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
6 changes: 6 additions & 0 deletions lib/esbonio/esbonio/lsp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
3 changes: 3 additions & 0 deletions lib/esbonio/tests/data/sphinx-default/.vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"python.pythonPath": "${workspaceRoot}/../../../../../.env/bin/python"
}
10 changes: 10 additions & 0 deletions lib/esbonio/tests/data/sphinx-default/glossary.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
Glossary
========

.. glossary::

hypotenuse
The longest side of a triangle

right angle
An angle of 90 degrees
22 changes: 17 additions & 5 deletions lib/esbonio/tests/data/sphinx-default/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,30 @@
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!
====================================

.. toctree::
: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``
8 changes: 8 additions & 0 deletions lib/esbonio/tests/data/sphinx-default/theorems/index.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
Theorems
========

There are many useful theorems, you will find some of them here.

.. toctree::

pythagoras
Loading

0 comments on commit 7fbd015

Please sign in to comment.