Skip to content

Commit

Permalink
Merge pull request #150 from alcarney/filepath-completion
Browse files Browse the repository at this point in the history
Initial implementation of filepath completions
  • Loading branch information
alcarney authored Apr 25, 2021
2 parents c41e445 + 8882138 commit 10ab8e6
Show file tree
Hide file tree
Showing 12 changed files with 302 additions and 54 deletions.
3 changes: 3 additions & 0 deletions lib/esbonio/changes/34.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
The Language Server will now offer filepath completions for the ``image``,
``figure``, ``include`` and ``literalinclude`` directives as well as the
``download`` role.
24 changes: 21 additions & 3 deletions lib/esbonio/esbonio/lsp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,14 @@
COMPLETION,
INITIALIZE,
INITIALIZED,
TEXT_DOCUMENT_DID_OPEN,
TEXT_DOCUMENT_DID_SAVE,
)
from pygls.lsp.types import (
CompletionList,
CompletionOptions,
CompletionParams,
DidOpenTextDocumentParams,
DidSaveTextDocumentParams,
InitializeParams,
Position,
Expand All @@ -32,6 +35,7 @@
"esbonio.lsp.directives",
"esbonio.lsp.roles",
"esbonio.lsp.intersphinx",
"esbonio.lsp.filepaths",
]


Expand Down Expand Up @@ -119,6 +123,13 @@ def get_line_til_position(doc: Document, position: Position) -> str:
return line[: position.character]


def filepath_from_uri(uri: str) -> pathlib.Path:
"""Given a uri, return the filepath component."""

uri = urlparse(uri)
return pathlib.Path(unquote(uri.path))


def dump(obj) -> str:
"""Debug helper function that converts an object to JSON."""

Expand Down Expand Up @@ -150,17 +161,20 @@ def create_language_server(

@server.feature(INITIALIZE)
def on_initialize(rst: RstLanguageServer, params: InitializeParams):

rst.run_hooks("init")
rst.logger.info("LSP Server Initialized")

@server.feature(INITIALIZED)
def on_initialized(rst: RstLanguageServer, params):
rst.run_hooks("initialized")

@server.feature(COMPLETION, trigger_characters=[".", ":", "`", "<"])
@server.feature(
COMPLETION, CompletionOptions(trigger_characters=[".", ":", "`", "<", "/"])
)
def on_completion(rst: RstLanguageServer, params: CompletionParams):
"""Suggest completions based on the current context."""
rst.logger.debug("Completion: %s", params)

uri = params.text_document.uri
pos = params.position

Expand All @@ -175,7 +189,11 @@ def on_completion(rst: RstLanguageServer, params: CompletionParams):
for handler in handlers:
items += handler(match, doc, pos)

return CompletionList(False, items)
return CompletionList(is_incomplete=False, items=items)

@server.feature(TEXT_DOCUMENT_DID_OPEN)
def on_open(rst: RstLanguageServer, params: DidOpenTextDocumentParams):
rst.logger.debug("DidOpen %s", params)

@server.feature(TEXT_DOCUMENT_DID_SAVE)
def on_save(rst: RstLanguageServer, params: DidSaveTextDocumentParams):
Expand Down
15 changes: 13 additions & 2 deletions lib/esbonio/esbonio/lsp/directives.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,20 @@
from esbonio.lsp.sphinx import get_domains


DIRECTIVE = re.compile(r"\s*\.\.[ ](?P<domain>[\w]+:)?(?P<name>[\w-]+)::")
DIRECTIVE = re.compile(
r"""
(?P<indent>\s*) # directives can be indented
(?P<directive>\.\. # start with a comment
[ ] # separated by a space
(?P<domain>[\w]+:)? # with an optional domain namespace
(?P<name>[\w-]+)) # with a name
::
([\s]+(?P<argument>.*$))? # some directives may take an argument
""",
re.VERBOSE,
)
"""A regular expression that matches a complete, valid directive declaration. Not
including the arguments or options."""
including any options or content."""


PARTIAL_DIRECTIVE = re.compile(
Expand Down
85 changes: 85 additions & 0 deletions lib/esbonio/esbonio/lsp/filepaths.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
"""Completion handler for filepaths.
While :mod:`esbonio.lsp.roles` and :mod:`esbonio.lsp.directives` provide generic
completion handlers for roles and directives, similar to :mod:`esbonio.lsp.intersphinx`
this is a "specialised" module dedicated to providing completion suggestions just for
the roles or directives that accept filepaths as arguments.
"""
import pathlib
import re

from typing import Dict, List

from pygls.lsp.types import CompletionItem, CompletionItemKind, Position
from pygls.workspace import Document

import esbonio.lsp as lsp
from esbonio.lsp import RstLanguageServer, LanguageFeature
from esbonio.lsp.directives import DIRECTIVE
from esbonio.lsp.roles import PARTIAL_PLAIN_TARGET, PARTIAL_ALIASED_TARGET

# TODO: Would it be better to make the role and directive language features extensible
# and have this and the intersphinx feature contribute suggestions when they know
# something?..
class FilepathCompletions(LanguageFeature):
"""Filepath completion support."""

suggest_triggers = [DIRECTIVE, PARTIAL_PLAIN_TARGET, PARTIAL_ALIASED_TARGET]

def suggest(
self, match: "re.Match", doc: Document, position: Position
) -> List[CompletionItem]:

groups = match.groupdict()

if not self.should_suggest(groups):
return []

if "target" in groups:
partial_path = groups["target"]
else:
partial_path = groups["argument"]

if partial_path.startswith("/"):
# Absolute paths are relative to the top level source dir.
candidate_dir = pathlib.Path(self.rst.app.srcdir)

# Be sure to take off the leading '/' character, otherwise the partial
# path will wipe out the srcdir part when concatenated..
partial_path = partial_path[1:]
else:
# Otherwise they're relative to the current file.
filepath = lsp.filepath_from_uri(doc.uri)
candidate_dir = pathlib.Path(filepath).parent

candidate_dir /= pathlib.Path(partial_path)

if partial_path and not partial_path.endswith("/"):
candidate_dir = candidate_dir.parent

return [self.path_to_completion_item(p) for p in candidate_dir.glob("*")]

def should_suggest(self, groups: Dict[str, str]) -> bool:
"""Determines if we should make any suggestions."""

roles = {"download"}
directives = {"image", "figure", "include", "literalinclude"}

return any(
[
groups["name"] in roles and "role" in groups,
groups["name"] in directives and "directive" in groups,
]
)

def path_to_completion_item(self, path: pathlib.Path) -> CompletionItem:

kind = CompletionItemKind.Folder if path.is_dir() else CompletionItemKind.File
return CompletionItem(
label=str(path.name), kind=kind, insert_text=f"{path.name}",
)


def setup(rst: RstLanguageServer):
filepaths = FilepathCompletions(rst)
rst.add_feature(filepaths)
15 changes: 4 additions & 11 deletions lib/esbonio/esbonio/lsp/sphinx.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from sphinx.domains import Domain
from sphinx.util import console

import esbonio.lsp as lsp
from esbonio.lsp import LanguageFeature, RstLanguageServer


Expand Down Expand Up @@ -50,13 +51,6 @@
}


def get_filepath(uri: str) -> pathlib.Path:
"""Given a uri, return the filepath component."""

uri = urlparse(uri)
return pathlib.Path(unquote(uri.path))


def get_domains(app: Sphinx) -> Iterator[Tuple[str, Domain]]:
"""Get all the domains registered with an applications.
Expand Down Expand Up @@ -88,7 +82,7 @@ def get_domains(app: Sphinx) -> Iterator[Tuple[str, Domain]]:
def find_conf_py(root_uri: str) -> Optional[pathlib.Path]:
"""Attempt to find Sphinx's configuration file in the given workspace."""

root = get_filepath(root_uri)
root = lsp.filepath_from_uri(root_uri)

# Strangely for windows paths, there's an extra leading slash which we have to
# remove ourselves.
Expand Down Expand Up @@ -162,7 +156,7 @@ def save(self, params: DidSaveTextDocumentParams):
if self.rst.app is None:
return

filepath = get_filepath(params.text_document.uri)
filepath = lsp.filepath_from_uri(params.text_document.uri)

self.reset_diagnostics(str(filepath))
self.rst.app.build()
Expand Down Expand Up @@ -213,8 +207,7 @@ def create_app(self):

self.rst.sphinx_log.error(exc)
self.rst.show_message(
message,
msg_type=MessageType.Error,
message, msg_type=MessageType.Error,
)

def report_diagnostics(self):
Expand Down
75 changes: 62 additions & 13 deletions lib/esbonio/esbonio/lsp/testing.py
Original file line number Diff line number Diff line change
@@ -1,27 +1,53 @@
"""Utility functions to help with testing Language Server features."""
import logging
import pathlib

from typing import List, Optional, Set

from pygls.lsp.types import Position
from pygls.workspace import Document

from esbonio.lsp import LanguageFeature

logger = logging.getLogger(__name__)


def role_target_patterns(name: str) -> List[str]:
def directive_argument_patterns(name: str, partial: str = "") -> List[str]:
"""Return a number of example directive argument patterns.
These correspond to test cases where directive argument suggestions should be
generated.
Paramters
---------
name:
The name of the directive to generate suggestions for.
partial:
The partial argument that the user has already entered.
"""
return [s.format(name, partial) for s in [".. {}:: {}", " .. {}:: {}"]]


def role_target_patterns(name: str, partial: str = "") -> List[str]:
"""Return a number of example role target patterns.
These correspond to cases where role target completions may be generated.
These correspond to test cases where role target suggestions should be generated.
Parameters
----------
name:
The name of the role to generate examples for
The name of the role to generate suggestions for.
partial:
The partial target that the user as already entered.
"""
return [
s.format(name)
for s in [":{}:`", ":{}:`More Info <", " :{}:`", " :{}:`Some Label <"]
s.format(name, partial)
for s in [
":{}:`{}",
":{}:`More Info <{}",
" :{}:`{}",
" :{}:`Some Label <{}",
]
]


Expand Down Expand Up @@ -49,11 +75,24 @@ def intersphinx_target_patterns(name: str, project: str) -> List[str]:


def completion_test(
feature, text: str, expected: Optional[Set[str]], unexpected: Optional[Set[str]]
feature: LanguageFeature,
text: str,
*,
filepath: str = "index.rst",
expected: Optional[Set[str]] = None,
unexpected: Optional[Set[str]] = None,
):
"""Check to see if a feature provides the correct completion suggestions.
**Only checking CompletionItem labels is supported**
.. admonition:: Assumptions
- The ``feature`` has access to a valid Sphinx application via ``rst.app``
- If ``feature`` requires initialisation, it has already been done.
.. admonition:: Limitations
- Only checking ``CompletionItem`` labels is supported, if you want to check
other aspects of the results, write a dedicated test method.
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
Expand All @@ -75,18 +114,26 @@ def completion_test(
^
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.
``.. 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, set
``expected`` to ``None``.
If you want to test the case where no completions should be suggested, pass ``None``
to both the ``expected`` and ``unexpected`` parameters.
By default completion suggestions will be asked for the main ``index.rst`` file at
the root of the docs project. If you want them to be asked for a file with a
different path (like for filepath completion tests) this can be overridden with the
``filepath`` argument.
Parameters
----------
feature:
An instance of the language service feature to test.
text:
The text to offer completion suggestions for.
filepath:
The path of the file that completion suggestions are being asked for. Relative
to the Sphinx application's srcdir.
expected:
The set of completion item labels you expect to see in the output.
unexpected:
Expand All @@ -99,10 +146,11 @@ def completion_test(
contents = ""

logger.debug("Context text: '%s'", contents)
logger.debug("Insertsion text: '%s'", text)
logger.debug("Insertion text: '%s'", text)
assert "\n" not in text, "Insertion text cannot contain newlines"

document = Document("file:///test_doc.rst", contents)
filepath = pathlib.Path(feature.rst.app.srcdir) / filepath
document = Document(f"file://{filepath}", contents)
position = Position(line=len(document.lines), character=len(text) - 1)

results = []
Expand All @@ -114,6 +162,7 @@ def completion_test(
results += feature.suggest(match, document, position)

items = {item.label for item in results}
unexpected = unexpected or set()

logger.debug("Results: %s", items)
logger.debug("Expected: %s", expected)
Expand Down
Empty file.
Empty file.
4 changes: 2 additions & 2 deletions lib/esbonio/tests/lsp/test_directives.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ def test_directive_completions(sphinx, project, text, expected, unexpected):
feature = Directives(rst)
feature.initialize()

completion_test(feature, text, expected, unexpected)
completion_test(feature, text, expected=expected, unexpected=unexpected)


AUTOCLASS_OPTS = {
Expand Down Expand Up @@ -219,4 +219,4 @@ def test_directive_option_completions(sphinx, project, text, expected, unexpecte
feature = Directives(rst)
feature.initialize()

completion_test(feature, text, expected, unexpected)
completion_test(feature, text, expected=expected, unexpected=unexpected)
Loading

0 comments on commit 10ab8e6

Please sign in to comment.