From a51d4427101f45fd8cbff9def0421382182e683f Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 11 Apr 2022 18:27:31 +0000 Subject: [PATCH 01/12] [pre-commit.ci] pre-commit autoupdate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/pre-commit/pre-commit-hooks: v4.1.0 → v4.2.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.1.0...v4.2.0) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index de843be18..10a21355d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -2,7 +2,7 @@ exclude: '.bumpversion.cfg$' repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.1.0 + rev: v4.2.0 hooks: - id: end-of-file-fixer - id: trailing-whitespace From 9cf45a2ae477157da8140c74f943f75df7d9cfa4 Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Fri, 15 Apr 2022 00:29:20 +0100 Subject: [PATCH 02/12] code: Bump dependencies --- code/package-lock.json | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/code/package-lock.json b/code/package-lock.json index f3058e663..006497440 100644 --- a/code/package-lock.json +++ b/code/package-lock.json @@ -1,12 +1,12 @@ { "name": "esbonio", - "version": "0.7.3", + "version": "0.8.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "esbonio", - "version": "0.7.3", + "version": "0.8.1", "license": "MIT", "dependencies": { "semver": "^7.3.5", @@ -388,9 +388,9 @@ } }, "node_modules/ansi-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", - "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.1.tgz", + "integrity": "sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw==", "dev": true, "engines": { "node": ">=4" @@ -2268,9 +2268,9 @@ } }, "node_modules/minimist": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", + "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==", "dev": true }, "node_modules/mkdirp": { @@ -4421,9 +4421,9 @@ "dev": true }, "ansi-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", - "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.1.tgz", + "integrity": "sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw==", "dev": true }, "ansi-styles": { @@ -5815,9 +5815,9 @@ } }, "minimist": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", - "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==", + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz", + "integrity": "sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==", "dev": true }, "mkdirp": { From 0e82eeb06522fb1cb98e8088a577ddb3b8cfbae8 Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Sat, 16 Apr 2022 23:32:06 +0100 Subject: [PATCH 03/12] lsp: Lookup `:ref:` targets by name It turns out that when docutils transforms a label into the id used in the parsed doctree, it also maintains a mapping of the "names" that correspond to these ids. To ensure that the language server correctly handles all forms of label it now uses this map to look up the correct id to search for. --- lib/esbonio/changes/357.fix.rst | 1 + lib/esbonio/esbonio/lsp/sphinx/domains.py | 2 +- .../tests/sphinx-default/test_sd_sphinx_domains.py | 11 +++++++++++ .../tests/sphinx-default/workspace/definitions.rst | 2 ++ lib/esbonio/tests/sphinx-default/workspace/index.rst | 2 ++ 5 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 lib/esbonio/changes/357.fix.rst diff --git a/lib/esbonio/changes/357.fix.rst b/lib/esbonio/changes/357.fix.rst new file mode 100644 index 000000000..19ff26031 --- /dev/null +++ b/lib/esbonio/changes/357.fix.rst @@ -0,0 +1 @@ +Goto definition for ``:ref:`` targets now works for labels containing ``-`` characters diff --git a/lib/esbonio/esbonio/lsp/sphinx/domains.py b/lib/esbonio/esbonio/lsp/sphinx/domains.py index 7528b0a23..3f3a8479d 100644 --- a/lib/esbonio/esbonio/lsp/sphinx/domains.py +++ b/lib/esbonio/esbonio/lsp/sphinx/domains.py @@ -138,7 +138,7 @@ def ref_definition(self, label: str) -> List[Location]: if "refid" not in node: continue - if label == node["refid"].replace("-", "_"): + if doctree.nameids.get(label, "") == node["refid"]: uri = Uri.from_fs_path(node.source) line = node.line break diff --git a/lib/esbonio/tests/sphinx-default/test_sd_sphinx_domains.py b/lib/esbonio/tests/sphinx-default/test_sd_sphinx_domains.py index b5570f9ba..a435feb3b 100644 --- a/lib/esbonio/tests/sphinx-default/test_sd_sphinx_domains.py +++ b/lib/esbonio/tests/sphinx-default/test_sd_sphinx_domains.py @@ -272,6 +272,17 @@ async def test_role_target_completions(client: Client, text: str, setup): ), ), ), + ( + "definitions.rst", + Position(line=29, character=36), + Location( + uri="index.rst", + range=Range( + start=Position(line=18, character=0), + end=Position(line=19, character=0), + ), + ), + ), ], ) async def test_role_target_definitions(client: Client, path, position, expected): diff --git a/lib/esbonio/tests/sphinx-default/workspace/definitions.rst b/lib/esbonio/tests/sphinx-default/workspace/definitions.rst index a0c4ab477..133d917de 100644 --- a/lib/esbonio/tests/sphinx-default/workspace/definitions.rst +++ b/lib/esbonio/tests/sphinx-default/workspace/definitions.rst @@ -26,3 +26,5 @@ Some literal includes now .. image:: /_static/vscode-screenshot.png .. figure:: /_static/bad.png + +This line refers to :ref:`setup-label` diff --git a/lib/esbonio/tests/sphinx-default/workspace/index.rst b/lib/esbonio/tests/sphinx-default/workspace/index.rst index 98c3186df..2da220500 100644 --- a/lib/esbonio/tests/sphinx-default/workspace/index.rst +++ b/lib/esbonio/tests/sphinx-default/workspace/index.rst @@ -16,6 +16,8 @@ Welcome to Defaults's documentation! definitions glossary +.. _setup-label: + Setup ===== From 312f719473c66c51b013f43ec8ddd1181b37db5b Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Sun, 17 Apr 2022 01:07:58 +0100 Subject: [PATCH 04/12] lsp: Add `sphinx.forceFullBuild` init option --- code/changes/358.enhancement.rst | 1 + code/package.json | 6 +++++ code/src/lsp/client.ts | 7 +++++- docs/lsp/getting-started.rst | 25 +++++++++++-------- lib/esbonio/changes/358.enhancement.rst | 1 + lib/esbonio/esbonio/lsp/sphinx/__init__.py | 5 +++- .../tests/sphinx-default/test_sd_sphinx.py | 4 +-- 7 files changed, 34 insertions(+), 15 deletions(-) create mode 100644 code/changes/358.enhancement.rst create mode 100644 lib/esbonio/changes/358.enhancement.rst diff --git a/code/changes/358.enhancement.rst b/code/changes/358.enhancement.rst new file mode 100644 index 000000000..3ab6ee030 --- /dev/null +++ b/code/changes/358.enhancement.rst @@ -0,0 +1 @@ +Added the ``esbonio.sphinx.forceFullBuild`` option (default: ``true``) which can be used to control if the language server forces a full Sphinx build on startup. diff --git a/code/package.json b/code/package.json index 8eb2308bd..3b6ccc98c 100644 --- a/code/package.json +++ b/code/package.json @@ -250,6 +250,12 @@ "default": null, "description": "The Language Server should be able to automatically find the folder containing your project's 'conf.py' file. However this setting can be used to force the Language Server to use a particular directory if required." }, + "esbonio.sphinx.forceFullBuild": { + "scope": "window", + "type": "boolean", + "default": true, + "description": "By default the language server will force a full build of your documentation on startup to help improve the accuracy of some features like diagnostics. Disabling this option can help improve startup time for larger projects at the expense of certain features being less accurate." + }, "esbonio.sphinx.srcDir": { "scope": "window", "type": "string", diff --git a/code/src/lsp/client.ts b/code/src/lsp/client.ts index fb9f54ff9..603872e49 100644 --- a/code/src/lsp/client.ts +++ b/code/src/lsp/client.ts @@ -43,6 +43,10 @@ export interface SphinxConfig { */ builderName?: string + /** + * Flag to force a full build of the documentation on startup. + */ + forceFullBuild?: boolean } /** @@ -296,7 +300,8 @@ export class EsbonioClient { sphinx: { srcDir: config.get("sphinx.srcDir"), confDir: config.get('sphinx.confDir'), - buildDir: buildDir + buildDir: buildDir, + forceFullBuild: config.get('sphinx.forceFullBuild') }, server: { logLevel: config.get('server.logLevel'), diff --git a/docs/lsp/getting-started.rst b/docs/lsp/getting-started.rst index f3dc89d8d..d2e2342d5 100644 --- a/docs/lsp/getting-started.rst +++ b/docs/lsp/getting-started.rst @@ -169,6 +169,18 @@ Configuration .. include:: ./editors/emacs-lsp-mode/_configuration.rst +.. confval:: sphinx.buildDir (string) + + By default the language server will choose a cache directory (as determined by + `appdirs `_) to put Sphinx's build output. + This option can be used to force the language server to use a location + of your choosing, currently accepted values include: + + - ``/path/to/src/`` - An absolute path + - ``${workspaceRoot}/docs/src`` - A path relative to the root of your workspace + - ``${workspaceFolder}/docs/src`` - Same as ``${workspaceRoot}``, placeholder for true multi-root workspace support. + - ``${confDir}/../src/`` - A path relative to your project's ``confDir`` + .. confval:: sphinx.confDir (string) The language server attempts to automatically find the folder which contains your @@ -180,7 +192,6 @@ Configuration - ``${workspaceRoot}/docs`` - A path relative to the root of your workspace. - ``${workspaceFolder}/docs`` - Same as ``${workspaceRoot}``, placeholder for true multi-root workspace support. - .. confval:: sphinx.srcDir (string) The language server assumes that your project's ``srcDir`` (the folder containing your @@ -193,17 +204,9 @@ Configuration - ``${workspaceFolder}/docs/src`` - Same as ``${workspaceRoot}``, placeholder for true multi-root workspace support. - ``${confDir}/../src/`` - A path relative to your project's ``confDir`` -.. confval:: sphinx.buildDir (string) +.. confval:: sphinx.forceFullBuild (boolean) - By default the language server will choose a cache directory (as determined by - `appdirs `_) to put Sphinx's build output. - This option can be used to force the language server to use a location - of your choosing, currently accepted values include: - - - ``/path/to/src/`` - An absolute path - - ``${workspaceRoot}/docs/src`` - A path relative to the root of your workspace - - ``${workspaceFolder}/docs/src`` - Same as ``${workspaceRoot}``, placeholder for true multi-root workspace support. - - ``${confDir}/../src/`` - A path relative to your project's ``confDir`` + Flag that indicates if the server should force a full build of the documentation on startup. (Default: ``true``) .. confval:: server.logLevel (string) diff --git a/lib/esbonio/changes/358.enhancement.rst b/lib/esbonio/changes/358.enhancement.rst new file mode 100644 index 000000000..135f2c83f --- /dev/null +++ b/lib/esbonio/changes/358.enhancement.rst @@ -0,0 +1 @@ +Language clients can now control if the server forces a full build of a Sphinx project on startup by providing a ``sphinx.forceFullBuild`` initialization option, which defaults to ``true`` diff --git a/lib/esbonio/esbonio/lsp/sphinx/__init__.py b/lib/esbonio/esbonio/lsp/sphinx/__init__.py index 05e5ad3a0..19823f485 100644 --- a/lib/esbonio/esbonio/lsp/sphinx/__init__.py +++ b/lib/esbonio/esbonio/lsp/sphinx/__init__.py @@ -103,6 +103,9 @@ class SphinxConfig(BaseModel): builder_name: str = Field("html", alias="builderName") """The currently used builder name.""" + force_full_build: bool = Field(True, alias="forceFullBuild") + """Flag that can be used to force a full build on startup.""" + class SphinxServerConfig(ServerConfig): @@ -406,7 +409,7 @@ def create_sphinx_app(self, options: InitializationOptions) -> Optional[Sphinx]: buildername=builder_name, status=None, # type: ignore warning=None, # type: ignore - freshenv=True, # Have Sphinx reload everything on first build. + freshenv=sphinx.force_full_build, ) # This has to happen after app creation otherwise our handler diff --git a/lib/esbonio/tests/sphinx-default/test_sd_sphinx.py b/lib/esbonio/tests/sphinx-default/test_sd_sphinx.py index 3e9f36f4e..f9e720e9f 100644 --- a/lib/esbonio/tests/sphinx-default/test_sd_sphinx.py +++ b/lib/esbonio/tests/sphinx-default/test_sd_sphinx.py @@ -182,14 +182,14 @@ def _(*args, **kwargs): ), ], ) -async def test_initialization(caplog, command: List[str], path: str, options, expected): +async def test_initialization(command: List[str], path: str, options, expected): """Ensure that the server responds correctly to various initialization options.""" root_path = pathlib.Path(__file__).parent / path root_uri = uri.from_fs_path(str(root_path)) for key, value in options.dict().items(): - if key not in {"builder_name"} and value is not None: + if key in {"conf_dir", "src_dir", "build_dir"} and value is not None: path = resolve_path(value, root_path) setattr(options, key, str(path)) From 8ba77508128a8a4df949db5c15774dddb1d24623 Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Sun, 17 Apr 2022 17:49:26 +0100 Subject: [PATCH 05/12] lsp: Move xxx_dir logic into `SphinxConfig` --- lib/esbonio/esbonio/lsp/sphinx/__init__.py | 306 ++++++++++---------- lib/esbonio/tests/unit_tests/test_sphinx.py | 26 +- 2 files changed, 158 insertions(+), 174 deletions(-) diff --git a/lib/esbonio/esbonio/lsp/sphinx/__init__.py b/lib/esbonio/esbonio/lsp/sphinx/__init__.py index 19823f485..b87253213 100644 --- a/lib/esbonio/esbonio/lsp/sphinx/__init__.py +++ b/lib/esbonio/esbonio/lsp/sphinx/__init__.py @@ -106,6 +106,147 @@ class SphinxConfig(BaseModel): force_full_build: bool = Field(True, alias="forceFullBuild") """Flag that can be used to force a full build on startup.""" + def resolve_build_dir(self, root_uri: str, actual_conf_dir: str) -> pathlib.Path: + """Get the build dir to use based on the user's config. + + If nothing is specified in the given ``config``, this will choose a location + within the user's cache dir (as determined by + `appdirs `). The directory name will be a hash + derived from the given ``conf_dir`` for the project. + + Alternatively the user (or least language client) can override this by setting + either an absolute path, or a path based on the following "variables". + + - ``${workspaceRoot}`` which expands to the workspace root as provided + by the language client. + - ``${workspaceFolder}`` alias for ``${workspaceRoot}, placeholder ready for + multi-root support. + - ``${confDir}`` which expands to the configured config dir. + + Parameters + ---------- + root_uri + The workspace root uri + + actual_conf_dir: + The fully resolved conf dir for the project + """ + + if not self.build_dir: + # Try to pick a sensible dir based on the project's location + cache = appdirs.user_cache_dir("esbonio", "swyddfa") + project = hashlib.md5(str(actual_conf_dir).encode()).hexdigest() + + return pathlib.Path(cache) / project + + root_dir = Uri.to_fs_path(root_uri) + match = PATH_VAR_PATTERN.match(self.build_dir) + + if match and match.group(1) in {"workspaceRoot", "workspaceFolder"}: + build = pathlib.Path(self.build_dir).parts[1:] + return pathlib.Path(root_dir, *build).resolve() + + if match and match.group(1) == "confDir": + build = pathlib.Path(self.build_dir).parts[1:] + return pathlib.Path(actual_conf_dir, *build).resolve() + + # Convert path to/from uri so that any path quirks from windows are + # automatically handled + build_uri = Uri.from_fs_path(self.build_dir) + build_dir = Uri.to_fs_path(build_uri) + + # But make sure paths starting with '~' are not corrupted + if build_dir.startswith("/~"): + build_dir = build_dir.replace("/~", "~") + + # But make sure (windows) paths starting with '~' are not corrupted + if build_dir.startswith("\\~"): + build_dir = build_dir.replace("\\~", "~") + + return pathlib.Path(build_dir).expanduser() + + def resolve_conf_dir(self, root_uri: str) -> Optional[pathlib.Path]: + """Get the conf dir to use based on the user's config. + + If ``conf_dir`` is not set, this method will attempt to find it by searching + within the ``root_uri`` for a ``conf.py`` file. If multiple files are found, the + first one found will be chosen. + + If ``conf_dir`` is set the following "variables" are handled by this method + + - ``${workspaceRoot}`` which expands to the workspace root as provided by the + language client. + - ``${workspaceFolder}`` alias for ``${workspaceRoot}, placeholder ready for + multi-root support. + + Parameters + ---------- + root_uri + The workspace root uri + """ + root = Uri.to_fs_path(root_uri) + + if not self.conf_dir: + ignore_paths = [".tox", "site-packages"] + + for candidate in pathlib.Path(root).glob("**/conf.py"): + # Skip any files that obviously aren't part of the project + if any(path in str(candidate) for path in ignore_paths): + continue + + return candidate.parent + + # Nothing found + return None + + match = PATH_VAR_PATTERN.match(self.conf_dir) + if not match or match.group(1) not in {"workspaceRoot", "workspaceFolder"}: + return pathlib.Path(self.conf_dir).expanduser() + + conf = pathlib.Path(self.conf_dir).parts[1:] + return pathlib.Path(root, *conf).resolve() + + def resolve_src_dir(self, root_uri: str, actual_conf_dir: str) -> pathlib.Path: + """Get the src dir to use based on the user's config. + + By default the src dir will be the same as the conf dir, but this can + be overriden by setting the ``src_dir`` field. + + There are a number of "variables" that can be included in the path, + currently we support + + - ``${workspaceRoot}`` which expands to the workspace root as provided + by the language client. + - ``${workspaceFolder}`` alias for ``${workspaceRoot}, placeholder ready for + multi-root support. + - ``${confDir}`` which expands to the configured config dir. + + Parameters + ---------- + root_uri + The workspace root uri + + actual_conf_dir + The fully resolved conf dir for the project + """ + + if not self.src_dir: + return actual_conf_dir + + src_dir = self.src_dir + root_dir = Uri.to_fs_path(root_uri) + + match = PATH_VAR_PATTERN.match(src_dir) + if match and match.group(1) in {"workspaceRoot", "workspaceFolder"}: + src = pathlib.Path(src_dir).parts[1:] + return pathlib.Path(root_dir, *src).resolve() + + if match and match.group(1) == "confDir": + src = pathlib.Path(src_dir).parts[1:] + return pathlib.Path(actual_conf_dir, *src).resolve() + + return pathlib.Path(src_dir).expanduser() + class SphinxServerConfig(ServerConfig): @@ -315,8 +456,8 @@ def save(self, params: DidSaveTextDocumentParams): else: config = SphinxConfig() - conf_dir = find_conf_dir( - self.workspace.root_uri, config + conf_dir = config.resolve_conf_dir( + self.workspace.root_uri ) or pathlib.Path(".") if str(conf_dir / "conf.py") == filepath: @@ -383,13 +524,13 @@ def create_sphinx_app(self, options: InitializationOptions) -> Optional[Sphinx]: self.logger.debug("Workspace root %s", self.workspace.root_uri) self.logger.debug("Sphinx Config %s", sphinx.dict()) - conf_dir = find_conf_dir(self.workspace.root_uri, sphinx) + conf_dir = sphinx.resolve_conf_dir(self.workspace.root_uri) if conf_dir is None: raise MissingConfigError() builder_name = sphinx.builder_name - src_dir = get_src_dir(self.workspace.root_uri, conf_dir, sphinx) - build_dir = get_build_dir(self.workspace.root_uri, conf_dir, sphinx) + src_dir = sphinx.resolve_src_dir(self.workspace.root_uri, conf_dir) + build_dir = sphinx.resolve_build_dir(self.workspace.root_uri, conf_dir) doctree_dir = build_dir / "doctrees" build_dir /= builder_name @@ -821,161 +962,6 @@ def get_intersphinx_targets( return targets -def find_conf_dir(root_uri: str, config: SphinxConfig) -> Optional[pathlib.Path]: - """Attempt to find Sphinx's configuration file within the given workspace.""" - - root = Uri.to_fs_path(root_uri) - - if config.conf_dir: - return expand_conf_dir(root, config.conf_dir) - - ignore_paths = [".tox", "site-packages"] - - for candidate in pathlib.Path(root).glob("**/conf.py"): - # Skip any files that obviously aren't part of the project - if any(path in str(candidate) for path in ignore_paths): - continue - - return candidate.parent - - return None - - -def expand_conf_dir(root_dir: str, conf_dir: str) -> pathlib.Path: - """Expand the user provided conf_dir into a real path. - - Here is where we handle "variables" that can be included in the path, currently - we support - - - ``${workspaceRoot}`` which expands to the workspace root as provided by the - language client. - - ``${workspaceFolder}`` alias for ``${workspaceRoot}, placeholder ready for - multi-root support. - - Parameters - ---------- - root_dir: - The workspace root path - conf_dir: - The user provided path - """ - - match = PATH_VAR_PATTERN.match(conf_dir) - if not match or match.group(1) not in {"workspaceRoot", "workspaceFolder"}: - return pathlib.Path(conf_dir).expanduser() - - conf = pathlib.Path(conf_dir).parts[1:] - return pathlib.Path(root_dir, *conf).resolve() - - -def get_src_dir( - root_uri: str, conf_dir: pathlib.Path, config: SphinxConfig -) -> pathlib.Path: - """Get the src dir to use based on the given conifg. - - By default the src dir will be the same as the conf dir, but this can - be overriden by the given conifg. - - There are a number of "variables" that can be included in the path, - currently we support - - - ``${workspaceRoot}`` which expands to the workspace root as provided - by the language client. - - ``${workspaceFolder}`` alias for ``${workspaceRoot}, placeholder ready for - multi-root support. - - ``${confDir}`` which expands to the configured config dir. - - Parameters - ---------- - root_uri: - The workspace root uri - conf_dir: - The project's conf dir - config: - The user's configuration. - """ - - if not config.src_dir: - return conf_dir - - src_dir = config.src_dir - root_dir = Uri.to_fs_path(root_uri) - - match = PATH_VAR_PATTERN.match(src_dir) - if match and match.group(1) in {"workspaceRoot", "workspaceFolder"}: - src = pathlib.Path(src_dir).parts[1:] - return pathlib.Path(root_dir, *src).resolve() - - if match and match.group(1) == "confDir": - src = pathlib.Path(src_dir).parts[1:] - return pathlib.Path(conf_dir, *src).resolve() - - return pathlib.Path(src_dir).expanduser() - - -def get_build_dir( - root_uri: str, conf_dir: pathlib.Path, config: SphinxConfig -) -> pathlib.Path: - """Get the build dir to use based on the given conifg. - - If nothing is specified in the given ``config``, this will choose a location within - the user's cache dir (as determined by `appdirs `). - The directory name will be a hash derived from the given ``conf_dir`` for the - project. - - Alternatively the user (or least language client) can override this by setting - either an absolute path, or a path based on the following "variables". - - - ``${workspaceRoot}`` which expands to the workspace root as provided - by the language client. - - ``${workspaceFolder}`` alias for ``${workspaceRoot}, placeholder ready for - multi-root support. - - ``${confDir}`` which expands to the configured config dir. - - Parameters - ---------- - root_uri: - The workspace root uri - conf_dir: - The project's conf dir - config: - The user's configuration. - """ - - if not config.build_dir: - # Try to pick a sensible dir based on the project's location - cache = appdirs.user_cache_dir("esbonio", "swyddfa") - project = hashlib.md5(str(conf_dir).encode()).hexdigest() - - return pathlib.Path(cache) / project - - root_dir = Uri.to_fs_path(root_uri) - match = PATH_VAR_PATTERN.match(config.build_dir) - - if match and match.group(1) in {"workspaceRoot", "workspaceFolder"}: - build = pathlib.Path(config.build_dir).parts[1:] - return pathlib.Path(root_dir, *build).resolve() - - if match and match.group(1) == "confDir": - build = pathlib.Path(config.build_dir).parts[1:] - return pathlib.Path(conf_dir, *build).resolve() - - # Convert path to/from uri so that any path quirks from windows are - # automatically handled - build_uri = Uri.from_fs_path(config.build_dir) - build_dir = Uri.to_fs_path(build_uri) - - # But make sure paths starting with '~' are not corrupted - if build_dir.startswith("/~"): - build_dir = build_dir.replace("/~", "~") - - # But make sure paths starting with '~' are not corrupted - if build_dir.startswith("\\~"): - build_dir = build_dir.replace("\\~", "~") - - return pathlib.Path(build_dir).expanduser() - - def exception_to_diagnostic(exc: BaseException): """Convert an exception into a diagnostic we can send to the client.""" diff --git a/lib/esbonio/tests/unit_tests/test_sphinx.py b/lib/esbonio/tests/unit_tests/test_sphinx.py index e10f0afb4..105fba5ef 100644 --- a/lib/esbonio/tests/unit_tests/test_sphinx.py +++ b/lib/esbonio/tests/unit_tests/test_sphinx.py @@ -2,9 +2,6 @@ import pytest -from esbonio.lsp.sphinx import expand_conf_dir -from esbonio.lsp.sphinx import get_build_dir -from esbonio.lsp.sphinx import get_src_dir from esbonio.lsp.sphinx import SphinxConfig @@ -37,12 +34,13 @@ ), ], ) -def test_expand_conf_dir(setup, expected): - """Ensure that the ``expand_conf_dir`` function works as expected.""" +def test_resolve_conf_dir(setup, expected): + """Ensure that the ``resolve_conf_dir`` function works as expected.""" root_uri, conf_dir = setup + config = SphinxConfig(confDir=conf_dir) - actual = expand_conf_dir(root_uri, conf_dir) + actual = config.resolve_conf_dir(root_uri) assert actual == expected @@ -115,12 +113,12 @@ def test_expand_conf_dir(setup, expected): ), ], ) -def test_get_src_dir(setup, expected): - """Ensure that the ``get_src_dir`` function works as expected.""" +def test_resolve_src_dir(setup, expected): + """Ensure that the ``resolve_src_dir`` function works as expected.""" - root_uri, conf_dir, sphinx_config = setup + root_uri, conf_dir, config = setup - actual = get_src_dir(root_uri, conf_dir, sphinx_config) + actual = config.resolve_src_dir(root_uri, conf_dir) assert actual == expected @@ -193,10 +191,10 @@ def test_get_src_dir(setup, expected): ), ], ) -def test_get_build_dir(setup, expected): - """Ensure that the ``get_build_dir`` function works as expected.""" +def test_resolve_build_dir(setup, expected): + """Ensure that the ``resolve_build_dir`` function works as expected.""" - root_uri, conf_dir, sphinx_config = setup + root_uri, conf_dir, config = setup - actual = get_build_dir(root_uri, conf_dir, sphinx_config) + actual = config.resolve_build_dir(root_uri, conf_dir) assert actual == expected From a015f7318774dd5c1285fa96462f6a97867f3a93 Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Sun, 17 Apr 2022 23:37:48 +0100 Subject: [PATCH 06/12] lsp: Add parallel build support The `SphinxConfig` object is now reponsible for preparing the arguments used to create the Sphinx application object. The `sphinx.numJobs` initialization option can be provided by clients to control the number of parallel jobs used by Sphinx. --- code/changes/359.enhancement.rst | 1 + code/package.json | 6 ++ code/src/lsp/client.ts | 36 ++++--- docs/lsp/api-reference.rst | 11 ++ docs/lsp/getting-started.rst | 6 ++ lib/esbonio/changes/359.enhancement.rst | 1 + lib/esbonio/esbonio/lsp/sphinx/__init__.py | 111 ++++++++++++++------- lib/esbonio/setup.cfg | 2 + 8 files changed, 123 insertions(+), 51 deletions(-) create mode 100644 code/changes/359.enhancement.rst create mode 100644 lib/esbonio/changes/359.enhancement.rst diff --git a/code/changes/359.enhancement.rst b/code/changes/359.enhancement.rst new file mode 100644 index 000000000..45963b407 --- /dev/null +++ b/code/changes/359.enhancement.rst @@ -0,0 +1 @@ +Added the ``esbonio.sphinx.numJobs`` option (default: ``auto``) which can be used to control the number of parallel jobs used by Sphinx. diff --git a/code/package.json b/code/package.json index 3b6ccc98c..fc3c2a92e 100644 --- a/code/package.json +++ b/code/package.json @@ -256,6 +256,12 @@ "default": true, "description": "By default the language server will force a full build of your documentation on startup to help improve the accuracy of some features like diagnostics. Disabling this option can help improve startup time for larger projects at the expense of certain features being less accurate." }, + "esbonio.sphinx.numJobs": { + "scope": "window", + "type": "integer", + "default": 0, + "markdownDescription": "The number of parallel jobs to use during a Sphinx build.\n\n- A value of `0` is equivalent to passing `-j auto` to a `sphinx-build` command.\n- A value of `1` will disable parallel processing." + }, "esbonio.sphinx.srcDir": { "scope": "window", "type": "string", diff --git a/code/src/lsp/client.ts b/code/src/lsp/client.ts index 603872e49..061ebb178 100644 --- a/code/src/lsp/client.ts +++ b/code/src/lsp/client.ts @@ -19,9 +19,14 @@ const DEBUG = process.env.VSCODE_LSP_DEBUG === "true" export interface SphinxConfig { /** - * Sphinx's version number. + * The directory where Sphinx's build output should be stored. */ - version?: string + buildDir?: string + + /** + * The name of the builder to use. + */ + builderName?: string /** * The directory containing the project's 'conf.py' file. @@ -29,24 +34,24 @@ export interface SphinxConfig { confDir?: string /** - * The source dir containing the *.rst files for the project. + * Flag to force a full build of the documentation on startup. */ - srcDir?: string + forceFullBuild?: boolean /** - * The directory where Sphinx's build output should be stored. + * The number of parallel jobs to use */ - buildDir?: string + numJobs?: number | string /** - * The name of the builder to use. + * The source dir containing the *.rst files for the project. */ - builderName?: string + srcDir?: string /** - * Flag to force a full build of the documentation on startup. + * Sphinx's version number. */ - forceFullBuild?: boolean + version?: string } /** @@ -289,19 +294,22 @@ export class EsbonioClient { */ private getLanguageClientOptions(config: vscode.WorkspaceConfiguration): LanguageClientOptions { - let cache = this.context.storageUri.path let buildDir = config.get('sphinx.buildDir') + let numJobs = config.get('sphinx.numJobs') + if (!buildDir) { + let cache = this.context.storageUri.path buildDir = join(cache, 'sphinx') } let initOptions: InitOptions = { sphinx: { - srcDir: config.get("sphinx.srcDir"), - confDir: config.get('sphinx.confDir'), buildDir: buildDir, - forceFullBuild: config.get('sphinx.forceFullBuild') + confDir: config.get('sphinx.confDir'), + forceFullBuild: config.get('sphinx.forceFullBuild'), + numJobs: numJobs === 0 ? 'auto' : numJobs, + srcDir: config.get("sphinx.srcDir"), }, server: { logLevel: config.get('server.logLevel'), diff --git a/docs/lsp/api-reference.rst b/docs/lsp/api-reference.rst index 38287b33c..88a28a814 100644 --- a/docs/lsp/api-reference.rst +++ b/docs/lsp/api-reference.rst @@ -11,14 +11,25 @@ Language Servers .. autofunction:: esbonio.lsp.create_language_server +RstLanguageServer +^^^^^^^^^^^^^^^^^ + .. autoclass:: esbonio.lsp.rst.RstLanguageServer :members: :show-inheritance: +SphinxLanguageServer +^^^^^^^^^^^^^^^^^^^^ + +.. autoclass:: esbonio.lsp.sphinx.SphinxConfig + :members: + .. autoclass:: esbonio.lsp.sphinx.SphinxLanguageServer :members: :show-inheritance: +.. autoclass:: esbonio.lsp.sphinx.MissingConfigError + Language Features ----------------- diff --git a/docs/lsp/getting-started.rst b/docs/lsp/getting-started.rst index d2e2342d5..1c5a8e335 100644 --- a/docs/lsp/getting-started.rst +++ b/docs/lsp/getting-started.rst @@ -208,6 +208,12 @@ Configuration Flag that indicates if the server should force a full build of the documentation on startup. (Default: ``true``) +.. confval:: sphinx.numJobs (string or integer) + + Controls the number of parallel jobs used during a Sphinx build. + + The default value of ``"auto"`` will behave the same as passing ``-j auto`` to a ``sphinx-build`` command. Setting this value to ``1`` effectively disables parallel builds. + .. confval:: server.logLevel (string) This can be used to set the level of log messages emitted by the server. This can be set diff --git a/lib/esbonio/changes/359.enhancement.rst b/lib/esbonio/changes/359.enhancement.rst new file mode 100644 index 000000000..a7a6786de --- /dev/null +++ b/lib/esbonio/changes/359.enhancement.rst @@ -0,0 +1 @@ +Language clients can now control the number of parallel jobs by providing a ``sphinx.numJobs`` initialization option, which defaults to ``auto``. Clients can disable parallel builds by setting this option to ``1`` diff --git a/lib/esbonio/esbonio/lsp/sphinx/__init__.py b/lib/esbonio/esbonio/lsp/sphinx/__init__.py index b87253213..bcd79a6b8 100644 --- a/lib/esbonio/esbonio/lsp/sphinx/__init__.py +++ b/lib/esbonio/esbonio/lsp/sphinx/__init__.py @@ -1,5 +1,7 @@ import hashlib +import json import logging +import multiprocessing import pathlib import platform import re @@ -13,6 +15,7 @@ from typing import List from typing import Optional from typing import Tuple +from typing import Union import appdirs import pygls.uris as Uri @@ -37,6 +40,7 @@ from sphinx.util import console from sphinx.util.logging import SphinxLogRecord from sphinx.util.logging import WarningLogRecordTranslator +from typing_extensions import Literal from esbonio.cli import setup_cli from esbonio.lsp.rst import LspHandler @@ -85,7 +89,8 @@ class MissingConfigError(Exception): class SphinxConfig(BaseModel): """Used to represent either the current Sphinx configuration or the config options - provided by the user at startup.""" + provided by the user at startup. + """ version: Optional[str] """Sphinx's version number.""" @@ -106,6 +111,55 @@ class SphinxConfig(BaseModel): force_full_build: bool = Field(True, alias="forceFullBuild") """Flag that can be used to force a full build on startup.""" + num_jobs: Union[Literal["auto"], int] = Field("auto", alias="numJobs") + """The number of jobs to use for parallel builds.""" + + @property + def parallel(self) -> int: + """The parsed value of the ``num_jobs`` field.""" + + if self.num_jobs == "auto": + return multiprocessing.cpu_count() + + return self.num_jobs + + def get_sphinx_args(self, root_uri: str) -> Dict[str, Any]: + """Get the arguments for the Sphinx application object corresponding to this + config. + + Parameters + ---------- + root_uri + The workspace root uri + + Raises + ------ + MissingConfigError + Raised when a valid conf dir cannot be found. + """ + + conf_dir = self.resolve_conf_dir(root_uri) + if conf_dir is None: + raise MissingConfigError() + + builder_name = self.builder_name + src_dir = self.resolve_src_dir(root_uri, str(conf_dir)) + build_dir = self.resolve_build_dir(root_uri, str(conf_dir)) + doctree_dir = build_dir / "doctrees" + build_dir /= builder_name + + return { + "buildername": builder_name, + "confdir": str(conf_dir), + "doctreedir": str(doctree_dir), + "freshenv": self.force_full_build, + "outdir": str(build_dir), + "parallel": self.parallel, + "srcdir": str(src_dir), + "status": None, + "warning": None, + } + def resolve_build_dir(self, root_uri: str, actual_conf_dir: str) -> pathlib.Path: """Get the build dir to use based on the user's config. @@ -118,9 +172,11 @@ def resolve_build_dir(self, root_uri: str, actual_conf_dir: str) -> pathlib.Path either an absolute path, or a path based on the following "variables". - ``${workspaceRoot}`` which expands to the workspace root as provided - by the language client. - - ``${workspaceFolder}`` alias for ``${workspaceRoot}, placeholder ready for - multi-root support. + by the language client. + + - ``${workspaceFolder}`` alias for ``${workspaceRoot}``, placeholder ready for + multi-root support. + - ``${confDir}`` which expands to the configured config dir. Parameters @@ -176,7 +232,8 @@ def resolve_conf_dir(self, root_uri: str) -> Optional[pathlib.Path]: - ``${workspaceRoot}`` which expands to the workspace root as provided by the language client. - - ``${workspaceFolder}`` alias for ``${workspaceRoot}, placeholder ready for + + - ``${workspaceFolder}`` alias for ``${workspaceRoot}``, placeholder ready for multi-root support. Parameters @@ -216,9 +273,11 @@ def resolve_src_dir(self, root_uri: str, actual_conf_dir: str) -> pathlib.Path: currently we support - ``${workspaceRoot}`` which expands to the workspace root as provided - by the language client. - - ``${workspaceFolder}`` alias for ``${workspaceRoot}, placeholder ready for - multi-root support. + by the language client. + + - ``${workspaceFolder}`` alias for ``${workspaceRoot}``, placeholder ready for + multi-root support. + - ``${confDir}`` which expands to the configured config dir. Parameters @@ -231,7 +290,7 @@ def resolve_src_dir(self, root_uri: str, actual_conf_dir: str) -> pathlib.Path: """ if not self.src_dir: - return actual_conf_dir + return pathlib.Path(actual_conf_dir) src_dir = self.src_dir root_dir = Uri.to_fs_path(root_uri) @@ -521,39 +580,17 @@ def create_sphinx_app(self, options: InitializationOptions) -> Optional[Sphinx]: sphinx = options.sphinx server = options.server - self.logger.debug("Workspace root %s", self.workspace.root_uri) - self.logger.debug("Sphinx Config %s", sphinx.dict()) + self.logger.debug("Workspace root '%s'", self.workspace.root_uri) + self.logger.debug("User Config %s", json.dumps(sphinx.dict(), indent=2)) - conf_dir = sphinx.resolve_conf_dir(self.workspace.root_uri) - if conf_dir is None: - raise MissingConfigError() - - builder_name = sphinx.builder_name - src_dir = sphinx.resolve_src_dir(self.workspace.root_uri, conf_dir) - build_dir = sphinx.resolve_build_dir(self.workspace.root_uri, conf_dir) - doctree_dir = build_dir / "doctrees" - build_dir /= builder_name - - self.logger.debug("Config dir %s", conf_dir) - self.logger.debug("Src dir %s", src_dir) - self.logger.debug("Build dir %s", build_dir) - self.logger.debug("Doctree dir %s", doctree_dir) + sphinx_args = sphinx.get_sphinx_args(self.workspace.root_uri) + self.logger.debug("Sphinx Args %s", json.dumps(sphinx_args, indent=2)) # Disable color escape codes in Sphinx's log messages console.nocolor() + app = Sphinx(**sphinx_args) - app = Sphinx( - srcdir=str(src_dir), - confdir=str(conf_dir), - outdir=str(build_dir), - doctreedir=str(doctree_dir), - buildername=builder_name, - status=None, # type: ignore - warning=None, # type: ignore - freshenv=sphinx.force_full_build, - ) - - # This has to happen after app creation otherwise our handler + # This has to happen after app creation otherwise our logging handler # will get cleared by Sphinx's setup. if not server.hide_sphinx_output: sphinx_logger = logging.getLogger("sphinx") diff --git a/lib/esbonio/setup.cfg b/lib/esbonio/setup.cfg index 04015aff2..555634bcd 100644 --- a/lib/esbonio/setup.cfg +++ b/lib/esbonio/setup.cfg @@ -35,6 +35,8 @@ install_requires = sphinx pygls>=0.11.0,<0.12.0 pyspellchecker + typing-extensions + [options.packages.find] exclude = tests* From 5ae009fce59af15d8dfa6e591fbd3cdeaf4899ff Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Mon, 18 Apr 2022 12:39:48 +0100 Subject: [PATCH 07/12] lsp: Add `textDocument/documentLink` support to the server --- lib/esbonio/esbonio/lsp/__init__.py | 16 ++++++++++++++++ lib/esbonio/esbonio/lsp/rst/__init__.py | 24 +++++++++++++++++++++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/lib/esbonio/esbonio/lsp/__init__.py b/lib/esbonio/esbonio/lsp/__init__.py index daf3cf07b..11260dccd 100644 --- a/lib/esbonio/esbonio/lsp/__init__.py +++ b/lib/esbonio/esbonio/lsp/__init__.py @@ -13,6 +13,7 @@ from pygls.lsp.methods import COMPLETION from pygls.lsp.methods import COMPLETION_ITEM_RESOLVE from pygls.lsp.methods import DEFINITION +from pygls.lsp.methods import DOCUMENT_LINK from pygls.lsp.methods import DOCUMENT_SYMBOL from pygls.lsp.methods import INITIALIZE from pygls.lsp.methods import INITIALIZED @@ -31,6 +32,7 @@ from pygls.lsp.types import DidChangeTextDocumentParams from pygls.lsp.types import DidOpenTextDocumentParams from pygls.lsp.types import DidSaveTextDocumentParams +from pygls.lsp.types import DocumentLinkParams from pygls.lsp.types import DocumentSymbolParams from pygls.lsp.types import FileOperationFilter from pygls.lsp.types import FileOperationPattern @@ -42,6 +44,7 @@ from .rst import CompletionContext from .rst import DefinitionContext +from .rst import DocumentLinkContext from .rst import LanguageFeature from .rst import RstLanguageServer from .rst import SymbolVisitor @@ -51,6 +54,7 @@ __all__ = [ "CompletionContext", "DefinitionContext", + "DocumentLinkContext", "LanguageFeature", "RstLanguageServer", "create_language_server", @@ -260,6 +264,18 @@ def on_definition(ls: RstLanguageServer, params: DefinitionParams): return definitions + @server.feature(DOCUMENT_LINK) + def on_document_link(ls: RstLanguageServer, params: DocumentLinkParams): + uri = params.text_document.uri + doc = ls.workspace.get_document(uri) + context = DocumentLinkContext(doc=doc, capabilities=ls.client_capabilities) + + links = [] + for feature in ls._features.values(): + links += feature.document_link(context) or [] + + return links + @server.feature(DOCUMENT_SYMBOL) def on_document_symbol(ls: RstLanguageServer, params: DocumentSymbolParams): diff --git a/lib/esbonio/esbonio/lsp/rst/__init__.py b/lib/esbonio/esbonio/lsp/rst/__init__.py index 915c25ec8..41860e4bd 100644 --- a/lib/esbonio/esbonio/lsp/rst/__init__.py +++ b/lib/esbonio/esbonio/lsp/rst/__init__.py @@ -28,6 +28,7 @@ from pygls.lsp.types import DeleteFilesParams from pygls.lsp.types import Diagnostic from pygls.lsp.types import DidSaveTextDocumentParams +from pygls.lsp.types import DocumentLink from pygls.lsp.types import DocumentSymbol from pygls.lsp.types import InitializedParams from pygls.lsp.types import InitializeParams @@ -82,7 +83,6 @@ def __init__( """The position at which the completion request was made.""" self._client_capabilities: ClientCapabilities = capabilities - """The client's capabilities""" def __repr__(self): p = f"{self.position.line}:{self.position.character}" @@ -147,6 +147,24 @@ def supported_tags(self) -> List[CompletionItemTag]: return capabilities.value_set +class DocumentLinkContext: + """Captures the context within which a document link request has been made.""" + + def __init__(self, *, doc: Document, capabilities: ClientCapabilities): + + self.doc = doc + """The document within which the document link request was made.""" + + self._client_capabilities = capabilities + + @property + def tooltip_support(self) -> bool: + """Indicates if the client supports tooltips.""" + return self._client_capabilities.get_capability( + "text_document.document_link.tooltip_support", False + ) + + class DefinitionContext: """A class that captures the context within which a definition request has been made.""" @@ -245,6 +263,10 @@ def definition(self, context: DefinitionContext) -> List[Location]: """ return [] + def document_link(self, context: DocumentLinkContext) -> List[DocumentLink]: + """Called whenever a ``textDocument/documentLink`` request is received.""" + return [] + class DiagnosticList(collections.UserList): """A list type dedicated to holding diagnostics. From 68e9d3452bb2250a6aef1c45f2734cb7bf42c19e Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Mon, 18 Apr 2022 12:49:46 +0100 Subject: [PATCH 08/12] lsp: Implement `textDocument/documentLink` for role targets --- lib/esbonio/esbonio/lsp/roles.py | 106 ++++++++++++++++++++-- lib/esbonio/esbonio/lsp/sphinx/domains.py | 75 ++++++++++++--- 2 files changed, 162 insertions(+), 19 deletions(-) diff --git a/lib/esbonio/esbonio/lsp/roles.py b/lib/esbonio/esbonio/lsp/roles.py index cc92a021a..8d598be63 100644 --- a/lib/esbonio/esbonio/lsp/roles.py +++ b/lib/esbonio/esbonio/lsp/roles.py @@ -6,31 +6,28 @@ from typing import Dict from typing import List from typing import Optional +from typing import Tuple import pkg_resources from pygls.lsp.types import CompletionItem from pygls.lsp.types import CompletionItemKind +from pygls.lsp.types import DocumentLink from pygls.lsp.types import Location from pygls.lsp.types import MarkupContent from pygls.lsp.types import MarkupKind from pygls.lsp.types import Position from pygls.lsp.types import Range from pygls.lsp.types import TextEdit +from typing_extensions import Protocol from esbonio.lsp.directives import DIRECTIVE from esbonio.lsp.rst import CompletionContext from esbonio.lsp.rst import DefinitionContext +from esbonio.lsp.rst import DocumentLinkContext from esbonio.lsp.rst import LanguageFeature from esbonio.lsp.rst import RstLanguageServer from esbonio.lsp.sphinx import SphinxLanguageServer -try: - from typing import Protocol -except ImportError: - # Protocol is only available in Python 3.8+ - class Protocol: # type: ignore - ... - ROLE = re.compile( r""" @@ -146,6 +143,30 @@ def complete_targets( """ +class TargetLink(Protocol): + """A document link provider for role targets""" + + def resolve_link( + self, context: DocumentLinkContext, name: str, domain: Optional[str], label: str + ) -> Tuple[Optional[str], Optional[str]]: + """Return a link corresponding to the given target. + + Parameters + ---------- + context + The document link context + + domain + The name (if applicable) of the domain the role is a member of + + name + The name of the role to generate completion suggestions for. + + label + The label of the target to provide the link for + """ + + class Roles(LanguageFeature): """Role support for the language server.""" @@ -158,14 +179,41 @@ def __init__(self, *args, **kwargs): self._target_definition_providers: List[TargetDefinition] = [] """A list of providers that locate the definition for the given role target.""" + self._target_link_providers: List[TargetLink] = [] + """A list of providers that resolve document links for role targets.""" + self._target_completion_providers: List[TargetCompletion] = [] """A list of providers that give completion suggestions for role target objects.""" def add_target_definition_provider(self, provider: TargetDefinition) -> None: + """Register a :class:`~esbonio.lsp.roles.TargetDefinition` provider. + + Parameters + ---------- + provider + The provider to register + """ self._target_definition_providers.append(provider) + def add_target_link_provider(self, provider: TargetLink) -> None: + """Register a :class:`~esbonio.lsp.roles.TargetLink` provider. + + Parameters + ---------- + provider + The provider to register + """ + self._target_link_providers.append(provider) + def add_target_completion_provider(self, provider: TargetCompletion) -> None: + """Register a :class:`~esbonio.lsp.roles.TargetCompletion` provider. + + Parameters + ---------- + provider + The provider to register + """ self._target_completion_providers.append(provider) def add_documentation(self, documentation: Dict[str, Dict[str, Any]]) -> None: @@ -252,6 +300,50 @@ def definition(self, context: DefinitionContext) -> List[Location]: return definitions + def document_link(self, context: DocumentLinkContext) -> List[DocumentLink]: + + links = [] + + for line, text in enumerate(context.doc.lines): + for match in ROLE.finditer(text): + label = match.group("label") + + # Be sure to only match complete roles + if not label or not match.group(0).endswith("`"): + continue + + domain = match.group("domain") + name = match.group("name") + + target = None + tooltip = None + for provider in self._target_link_providers: + target, tooltip = provider.resolve_link( + context, name, domain, label + ) + if target: + break + + if not target: + continue + + idx = match.group(0).index(label) + start = match.start() + idx + end = start + len(label) + + links.append( + DocumentLink( + target=target, + tooltip=tooltip if context.tooltip_support else None, + range=Range( + start=Position(line=line, character=start), + end=Position(line=line, character=end), + ), + ) + ) + + return links + def complete(self, context: CompletionContext) -> List[CompletionItem]: """Generate completion suggestions relevant to the current context. diff --git a/lib/esbonio/esbonio/lsp/sphinx/domains.py b/lib/esbonio/esbonio/lsp/sphinx/domains.py index 3f3a8479d..f3405220b 100644 --- a/lib/esbonio/esbonio/lsp/sphinx/domains.py +++ b/lib/esbonio/esbonio/lsp/sphinx/domains.py @@ -4,6 +4,7 @@ from typing import List from typing import Optional from typing import Set +from typing import Tuple import pygls.uris as Uri from docutils import nodes @@ -18,6 +19,7 @@ from esbonio.lsp.roles import Roles from esbonio.lsp.rst import CompletionContext from esbonio.lsp.rst import DefinitionContext +from esbonio.lsp.rst import DocumentLinkContext from esbonio.lsp.rst import RstLanguageServer from esbonio.lsp.sphinx import SphinxLanguageServer @@ -76,6 +78,24 @@ def complete_intersphinx_targets( return items + def resolve_link( + self, context: DocumentLinkContext, name: str, domain: Optional[str], label: str + ) -> Tuple[Optional[str], Optional[str]]: + """``textDocument/documentLink`` support""" + + # We can support intersphinx links. + if ":" in label: + return self.resolve_intersphinx(name, domain, label) + + # We can also support local `:doc:` roles. + if not domain and name == "doc": + return self.resolve_doc(context.doc, label), None + + # Other roles like :ref: do not make sense as the ``textDocument/documentLink`` + # api doesn't support specific locations like goto definition does. + + return None, None + def find_definitions( self, context: DefinitionContext, name: str, domain: Optional[str] ) -> List[Location]: @@ -93,20 +113,13 @@ def find_definitions( def doc_definition(self, doc: Document, label: str) -> List[Location]: """Goto definition implementation for ``:doc:`` targets""" - if self.rst.app is None: + uri = self.resolve_doc(doc, label) + if not uri: return [] - srcdir = self.rst.app.srcdir - currentdir = pathlib.Path(Uri.to_fs_path(doc.uri)).parent - - if label.startswith("/"): - path = str(pathlib.Path(srcdir, label[1:] + ".rst")) - else: - path = str(pathlib.Path(currentdir, label + ".rst")) - return [ Location( - uri=Uri.from_fs_path(path), + uri=uri, range=Range( start=Position(line=0, character=0), end=Position(line=1, character=0), @@ -156,6 +169,42 @@ def ref_definition(self, label: str) -> List[Location]: ) ] + def resolve_doc(self, doc: Document, label: str) -> Optional[str]: + + if self.rst.app is None: + return None + + srcdir = self.rst.app.srcdir + currentdir = pathlib.Path(Uri.to_fs_path(doc.uri)).parent + + if label.startswith("/"): + path = str(pathlib.Path(srcdir, label[1:] + ".rst")) + else: + path = str(pathlib.Path(currentdir, label + ".rst")) + + return Uri.from_fs_path(path) + + def resolve_intersphinx( + self, name: str, domain: Optional[str], label: str + ) -> Tuple[Optional[str], Optional[str]]: + """Resolve an intersphinx reference to a URL""" + + if not self.rst.app: + return None, None + + project, label = label.split(":") + targets = self.rst.get_intersphinx_targets(project, name, domain or "") + + for _, items in targets.items(): + if label in items: + source, version, url, display = items[label] + name = label if display == "-" else display + tooltip = f"{name} - {source} v{version}" + + return url, tooltip + + return None, None + def find_docname_for_label( self, label: str, domain: Domain, types: Optional[Set[str]] = None ) -> Optional[str]: @@ -248,5 +297,7 @@ def esbonio_setup(rst: RstLanguageServer): roles = rst.get_feature("esbonio.lsp.roles.Roles") if roles: - typing.cast(Roles, roles).add_target_definition_provider(domains) - typing.cast(Roles, roles).add_target_completion_provider(domains) + roles = typing.cast(Roles, roles) # let's keep mypy happy + roles.add_target_definition_provider(domains) + roles.add_target_completion_provider(domains) + roles.add_target_link_provider(domains) From be44662a744245e1240ce9133e24edd669ff2306 Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Mon, 18 Apr 2022 15:10:27 +0100 Subject: [PATCH 09/12] lsp: Implement `textDocument/documentLink` for directive arguments --- lib/esbonio/changes/294.feature.rst | 5 + lib/esbonio/esbonio/lsp/directives.py | 93 ++++++++++++++++++- lib/esbonio/esbonio/lsp/sphinx/images.py | 49 +++++++--- lib/esbonio/esbonio/lsp/sphinx/includes.py | 49 +++++++--- .../tests/sphinx-default/test_sd_sphinx.py | 60 ++++++++++++ .../sphinx-default/workspace/definitions.rst | 2 + .../tests/sphinx-extensions/test_se_sphinx.py | 55 +++++++++++ .../workspace/sphinx-extensions/conf.py | 2 +- .../sphinx-extensions/definitions.rst | 10 ++ 9 files changed, 296 insertions(+), 29 deletions(-) create mode 100644 lib/esbonio/changes/294.feature.rst create mode 100644 lib/esbonio/tests/sphinx-extensions/test_se_sphinx.py create mode 100644 lib/esbonio/tests/sphinx-extensions/workspace/sphinx-extensions/definitions.rst diff --git a/lib/esbonio/changes/294.feature.rst b/lib/esbonio/changes/294.feature.rst new file mode 100644 index 000000000..d7dbd2ca6 --- /dev/null +++ b/lib/esbonio/changes/294.feature.rst @@ -0,0 +1,5 @@ +Add ``textDocument/documentLink`` support. + +The server supports resolving links for role targets with initial support for intersphinx references and local ``:doc:`` references. + +The server also supports resolving links for directive arguments with initial support for ``.. image::``, ``.. figure::``, ``.. include::`` and ``.. literalinclude::`` directives. diff --git a/lib/esbonio/esbonio/lsp/directives.py b/lib/esbonio/esbonio/lsp/directives.py index 84162d251..ef3c79d75 100644 --- a/lib/esbonio/esbonio/lsp/directives.py +++ b/lib/esbonio/esbonio/lsp/directives.py @@ -6,10 +6,12 @@ from typing import Dict from typing import List from typing import Optional +from typing import Tuple import pkg_resources from pygls.lsp.types import CompletionItem from pygls.lsp.types import CompletionItemKind +from pygls.lsp.types import DocumentLink from pygls.lsp.types import InsertTextFormat from pygls.lsp.types import Location from pygls.lsp.types import MarkupContent @@ -20,6 +22,7 @@ from esbonio.lsp import CompletionContext from esbonio.lsp import DefinitionContext +from esbonio.lsp import DocumentLinkContext from esbonio.lsp import LanguageFeature from esbonio.lsp import RstLanguageServer from esbonio.lsp.sphinx import SphinxLanguageServer @@ -131,6 +134,31 @@ def find_definitions( """ +class ArgumentLink(Protocol): + """A document link resolver for directive arguments.""" + + def resolve_link( + self, + context: DocumentLinkContext, + directive: str, + domain: Optional[str], + argument: str, + ) -> Tuple[Optional[str], Optional[str]]: + """Resolve a document link request for the given argument. + + Parameters + ---------- + context: + The context of the document link request. + directive: + The name of the directive the argument is associated with. + domain: + The name of the domain the directive belongs to, if applicable. + argument: + The argument to resolve the link for. + """ + + class Directives(LanguageFeature): """Directive support for the language server.""" @@ -145,7 +173,10 @@ def __init__(self, *args, **kwargs): arguments.""" self._argument_definition_providers: Dict[str, ArgumentDefinition] = {} - """A dictionary of providers that give completion suggestions for directive + """A dictionary of providers that locate definitions for directive arguments.""" + + self._argument_link_providers: Dict[str, ArgumentLink] = {} + """A dictionary of providers that resolve document links for directive arguments.""" def add_argument_completion_provider(self, provider: ArgumentCompletion) -> None: @@ -160,7 +191,7 @@ def add_argument_completion_provider(self, provider: ArgumentCompletion) -> None self._argument_completion_providers[key] = provider def add_argument_definition_provider(self, provider: ArgumentDefinition) -> None: - """Register an :class:`~esbonio.lsp.directives.ArgumentCompletion` provider. + """Register an :class:`~esbonio.lsp.directives.ArgumentDefinition` provider. Parameters ---------- @@ -170,6 +201,17 @@ def add_argument_definition_provider(self, provider: ArgumentDefinition) -> None key = f"{provider.__module__}.{provider.__class__.__name__}" self._argument_definition_providers[key] = provider + def add_argument_link_provider(self, provider: ArgumentLink) -> None: + """Register an :class:`~esbonio.lsp.directives.ArgumentLink` provider. + + Parameters + ---------- + provider: + The provider to register. + """ + key = f"{provider.__module__}.{provider.__class__.__name__}" + self._argument_link_providers[key] = provider + def add_documentation(self, documentation: Dict[str, Dict[str, Any]]) -> None: """Register directive documentation. @@ -493,6 +535,48 @@ def find_argument_definition( return definitions + def document_link(self, context: DocumentLinkContext) -> List[DocumentLink]: + links = [] + + for line, text in enumerate(context.doc.lines): + for match in DIRECTIVE.finditer(text): + + argument = match.group("argument") + if not argument: + continue + + domain = match.group("domain") + name = match.group("name") + + target = None + tooltip = None + for provider in self._argument_link_providers.values(): + target, tooltip = provider.resolve_link( + context, name, domain, argument + ) + if target: + break + + if not target: + continue + + idx = match.group(0).index(argument) + start = match.start() + idx + end = start + len(argument) + + links.append( + DocumentLink( + target=target, + tooltip=tooltip if context.tooltip_support else None, + range=Range( + start=Position(line=line, character=start), + end=Position(line=line, character=end), + ), + ) + ) + + return links + def get_surrounding_directive( self, context: CompletionContext ) -> Optional["re.Match"]: @@ -552,9 +636,10 @@ def get_documentation( Parameters ---------- - label: + label The name of the directive, as the user would type in an reStructuredText file. - implementation: + + implementation The full dotted name of the directive's implementation. """ diff --git a/lib/esbonio/esbonio/lsp/sphinx/images.py b/lib/esbonio/esbonio/lsp/sphinx/images.py index 8732ae956..eb954540c 100644 --- a/lib/esbonio/esbonio/lsp/sphinx/images.py +++ b/lib/esbonio/esbonio/lsp/sphinx/images.py @@ -3,16 +3,19 @@ import typing from typing import List from typing import Optional +from typing import Tuple import pygls.uris as Uri from pygls.lsp.types import CompletionItem from pygls.lsp.types import Location from pygls.lsp.types import Position from pygls.lsp.types import Range +from pygls.workspace import Document from esbonio.lsp.directives import Directives from esbonio.lsp.rst import CompletionContext from esbonio.lsp.rst import DefinitionContext +from esbonio.lsp.rst import DocumentLinkContext from esbonio.lsp.rst import RstLanguageServer from esbonio.lsp.sphinx import SphinxLanguageServer from esbonio.lsp.util.filepaths import complete_sphinx_filepaths @@ -52,9 +55,38 @@ def find_definitions( if domain or directive not in {"figure", "image"}: return [] + uri = self.resolve_path(context.doc, argument) + if not uri: + return [] + + return [ + Location( + uri=uri, + range=Range( + start=Position(line=0, character=0), + end=Position(line=1, character=0), + ), + ) + ] + + def resolve_link( + self, + context: DocumentLinkContext, + directive: str, + domain: Optional[str], + argument: str, + ) -> Tuple[Optional[str], Optional[str]]: + + if domain or directive not in {"figure", "image"}: + return None, None + + return self.resolve_path(context.doc, argument), None + + def resolve_path(self, doc: Document, argument: str) -> Optional[str]: + if argument.startswith("/"): if not self.rst.app: - return [] + return None basedir = pathlib.Path(self.rst.app.srcdir) @@ -63,21 +95,13 @@ def find_definitions( argument = argument[1:] else: - basedir = pathlib.Path(Uri.to_fs_path(context.doc.uri)).parent + basedir = pathlib.Path(Uri.to_fs_path(doc.uri)).parent fpath = (basedir / argument).resolve() if not fpath.exists(): - return [] + return None - return [ - Location( - uri=Uri.from_fs_path(str(fpath)), - range=Range( - start=Position(line=0, character=0), - end=Position(line=1, character=0), - ), - ) - ] + return Uri.from_fs_path(str(fpath)) def esbonio_setup(rst: RstLanguageServer): @@ -94,3 +118,4 @@ def esbonio_setup(rst: RstLanguageServer): images = Images(rst) directives.add_argument_definition_provider(images) directives.add_argument_completion_provider(images) + directives.add_argument_link_provider(images) diff --git a/lib/esbonio/esbonio/lsp/sphinx/includes.py b/lib/esbonio/esbonio/lsp/sphinx/includes.py index dc2153454..feb3c7ae9 100644 --- a/lib/esbonio/esbonio/lsp/sphinx/includes.py +++ b/lib/esbonio/esbonio/lsp/sphinx/includes.py @@ -3,16 +3,19 @@ import typing from typing import List from typing import Optional +from typing import Tuple import pygls.uris as Uri from pygls.lsp.types import CompletionItem from pygls.lsp.types import Location from pygls.lsp.types import Position from pygls.lsp.types import Range +from pygls.workspace import Document from esbonio.lsp.directives import Directives from esbonio.lsp.rst import CompletionContext from esbonio.lsp.rst import DefinitionContext +from esbonio.lsp.rst import DocumentLinkContext from esbonio.lsp.rst import RstLanguageServer from esbonio.lsp.sphinx import SphinxLanguageServer from esbonio.lsp.util.filepaths import complete_sphinx_filepaths @@ -52,9 +55,38 @@ def find_definitions( if domain or directive not in {"literalinclude", "include"}: return [] + uri = self.resolve_path(context.doc, argument) + if not uri: + return [] + + return [ + Location( + uri=uri, + range=Range( + start=Position(line=0, character=0), + end=Position(line=1, character=0), + ), + ) + ] + + def resolve_link( + self, + context: DocumentLinkContext, + directive: str, + domain: Optional[str], + argument: str, + ) -> Tuple[Optional[str], Optional[str]]: + + if domain or directive not in {"literalinclude", "include"}: + return None, None + + return self.resolve_path(context.doc, argument), None + + def resolve_path(self, doc: Document, argument: str) -> Optional[str]: + if argument.startswith("/"): if not self.rst.app: - return [] + return None basedir = pathlib.Path(self.rst.app.srcdir) @@ -63,21 +95,13 @@ def find_definitions( argument = argument[1:] else: - basedir = pathlib.Path(Uri.to_fs_path(context.doc.uri)).parent + basedir = pathlib.Path(Uri.to_fs_path(doc.uri)).parent fpath = (basedir / argument).resolve() if not fpath.exists(): - return [] + return None - return [ - Location( - uri=Uri.from_fs_path(str(fpath)), - range=Range( - start=Position(line=0, character=0), - end=Position(line=1, character=0), - ), - ) - ] + return Uri.from_fs_path(str(fpath)) def esbonio_setup(rst: RstLanguageServer): @@ -93,3 +117,4 @@ def esbonio_setup(rst: RstLanguageServer): includes = Includes(rst) directives.add_argument_definition_provider(includes) directives.add_argument_completion_provider(includes) + directives.add_argument_link_provider(includes) diff --git a/lib/esbonio/tests/sphinx-default/test_sd_sphinx.py b/lib/esbonio/tests/sphinx-default/test_sd_sphinx.py index f9e720e9f..7973e835f 100644 --- a/lib/esbonio/tests/sphinx-default/test_sd_sphinx.py +++ b/lib/esbonio/tests/sphinx-default/test_sd_sphinx.py @@ -9,9 +9,12 @@ from pygls import IS_WIN from pygls.lsp.types import Diagnostic from pygls.lsp.types import DiagnosticSeverity +from pygls.lsp.types import DocumentLink from pygls.lsp.types import MessageType from pygls.lsp.types import Position from pygls.lsp.types import Range +from pytest_lsp import check +from pytest_lsp import Client from pytest_lsp import ClientServerConfig from pytest_lsp import make_client_server from pytest_lsp import make_test_client @@ -37,6 +40,63 @@ def _(*args, **kwargs): return client +@pytest.mark.asyncio +@pytest.mark.parametrize( + "uri,expected", + [ + ( + "/definitions.rst", + [ + DocumentLink( + target="${ROOT}/theorems/pythagoras.rst", + range=Range( + start=Position(line=21, character=20), + end=Position(line=21, character=44), + ), + ), + DocumentLink( + target="${ROOT}/index.rst", + range=Range( + start=Position(line=23, character=20), + end=Position(line=23, character=42), + ), + ), + DocumentLink( + target="${ROOT}/_static/vscode-screenshot.png", + range=Range( + start=Position(line=25, character=11), + end=Position(line=25, character=41), + ), + ), + DocumentLink( + target="${ROOT}/glossary.rst", + range=Range( + start=Position(line=31, character=14), + end=Position(line=31, character=23), + ), + ), + ], + ) + ], +) +async def test_document_links(client: Client, uri: str, expected: List[DocumentLink]): + """Ensure that we handle ``textDocument/documentLink`` requests correctly.""" + + test_uri = client.root_uri + uri + links = await client.document_link_request(test_uri) + + assert len(links) == len(expected) + + for expected, actual in zip(expected, links): + assert expected.range == actual.range + + target = expected.target.replace("${ROOT}", client.root_uri) + assert target == actual.target + assert expected.tooltip == actual.tooltip + + check.document_links(client, links) + + @pytest.mark.asyncio @pytest.mark.timeout(10) @pytest.mark.parametrize( diff --git a/lib/esbonio/tests/sphinx-default/workspace/definitions.rst b/lib/esbonio/tests/sphinx-default/workspace/definitions.rst index 133d917de..024eadaa2 100644 --- a/lib/esbonio/tests/sphinx-default/workspace/definitions.rst +++ b/lib/esbonio/tests/sphinx-default/workspace/definitions.rst @@ -28,3 +28,5 @@ Some literal includes now .. figure:: /_static/bad.png This line refers to :ref:`setup-label` + +See the :doc:`/glossary` for details diff --git a/lib/esbonio/tests/sphinx-extensions/test_se_sphinx.py b/lib/esbonio/tests/sphinx-extensions/test_se_sphinx.py new file mode 100644 index 000000000..3e49ad87b --- /dev/null +++ b/lib/esbonio/tests/sphinx-extensions/test_se_sphinx.py @@ -0,0 +1,55 @@ +from typing import List + +import pytest +from pygls.lsp.types import DocumentLink +from pygls.lsp.types import Position +from pygls.lsp.types import Range +from pytest_lsp import check +from pytest_lsp import Client + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "uri,expected", + [ + ( + "/sphinx-extensions/definitions.rst", + [ + DocumentLink( + target="https://docs.python.org/3.9/howto/logging.html#logging-basic-tutorial", + range=Range( + start=Position(line=5, character=29), + end=Position(line=5, character=58), + ), + ), + DocumentLink( + target="https://docs.python.org/3.9/library/logging.html#logging.Filter", + range=Range( + start=Position(line=7, character=19), + end=Position(line=7, character=40), + ), + ), + DocumentLink( + target="https://docs.python.org/3.9/howto/logging-cookbook.html", + range=Range( + start=Position(line=9, character=18), + end=Position(line=9, character=47), + ), + ), + ], + ) + ], +) +async def test_document_links(client: Client, uri: str, expected: List[DocumentLink]): + """Ensure that we handle ``textDocument/documentLink`` requests correctly.""" + + test_uri = client.root_uri + uri + links = await client.document_link_request(test_uri) + + assert len(links) == len(expected) + + for expected, actual in zip(expected, links): + assert expected.range == actual.range + assert expected.target == actual.target + + check.document_links(client, links) diff --git a/lib/esbonio/tests/sphinx-extensions/workspace/sphinx-extensions/conf.py b/lib/esbonio/tests/sphinx-extensions/workspace/sphinx-extensions/conf.py index 34f0fc5bc..83a08c54d 100644 --- a/lib/esbonio/tests/sphinx-extensions/workspace/sphinx-extensions/conf.py +++ b/lib/esbonio/tests/sphinx-extensions/workspace/sphinx-extensions/conf.py @@ -28,7 +28,7 @@ extensions = ["sphinx.ext.autodoc", "sphinx.ext.doctest", "sphinx.ext.intersphinx"] intersphinx_mapping = { - "python": ("https://docs.python.org/3", None), + "python": ("https://docs.python.org/3.9/", None), "sphinx": ("https://www.sphinx-doc.org/en/master", None), } diff --git a/lib/esbonio/tests/sphinx-extensions/workspace/sphinx-extensions/definitions.rst b/lib/esbonio/tests/sphinx-extensions/workspace/sphinx-extensions/definitions.rst new file mode 100644 index 000000000..75656ced1 --- /dev/null +++ b/lib/esbonio/tests/sphinx-extensions/workspace/sphinx-extensions/definitions.rst @@ -0,0 +1,10 @@ +Definitions +=========== + +This file contains a number of test cases for us to test goto definition/document link etc on. + +Here is a reference to :ref:`python:logging-basic-tutorial` + +One for :py:class:`python:logging.Filter` + +Another for :doc:`python:howto/logging-cookbook` From a97d4d153e83f7e26f648d479898a9f4a756bf83 Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Mon, 18 Apr 2022 15:23:35 +0100 Subject: [PATCH 10/12] docs: Update documentation --- README.md | 71 +++++++++++++---------- docs/index.rst | 8 +++ docs/lsp/api-reference.rst | 22 +++++-- docs/lsp/extending.rst | 2 +- resources/images/document-links-demo.png | Bin 0 -> 37800 bytes 5 files changed, 65 insertions(+), 38 deletions(-) create mode 100644 resources/images/document-links-demo.png diff --git a/README.md b/README.md index 582326b9f..8ffd9d202 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,42 @@ Esbonio aims to make it easier to work with [reStructuredText](https://docutils.sourceforge.io/rst.html) tools such as [Sphinx](https://www.sphinx-doc.org/en/master/) by providing a [Language Server](https://langserver.org/) to enhance your editing experience. The Esbonio project is made up from a number of sub-projects +## `lib/esbonio/` - A Language Server for Sphinx projects. +[![PyPI](https://img.shields.io/pypi/v/esbonio?style=flat-square)![PyPI - Downloads](https://img.shields.io/pypi/dm/esbonio?style=flat-square)](https://pypistats.org/packages/esbonio)[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)](https://github.com/swyddfa/esbonio/blob/develop/lib/esbonio/LICENSE) + +The language server is still in early development, but already provides the following features. + +**Completion** + +

+ Completion Suggestions Demo +

+ +**Definitions** + +

+ Goto Definition Demo +

+ +**Diagnostics** + +

+ Diagnostics Demo +

+ +**Document Links** + +

+ Document Links Demo +

+ + +**Document Symbols** + +

+ Document Symbols Demo +

+ ## `code/` - A VSCode extension for editing Sphinx projects [![Visual Studio Marketplace Version](https://img.shields.io/visual-studio-marketplace/v/swyddfa.esbonio?style=flat-square)![Visual Studio Marketplace Installs](https://img.shields.io/visual-studio-marketplace/i/swyddfa.esbonio?style=flat-square)![Visual Studio Marketplace Downloads](https://img.shields.io/visual-studio-marketplace/d/swyddfa.esbonio?style=flat-square)](https://marketplace.visualstudio.com/items?itemName=swyddfa.esbonio)[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)](https://github.com/swyddfa/esbonio/blob/develop/code/LICENSE) @@ -17,9 +53,9 @@ The Esbonio project is made up from a number of sub-projects HTML Preview Demo

-Most of the extension's functionality is provided through the `esbonio` language server (see below.) +This extension is purely focused on bringing the `esbonio` language server into VSCode to help with development and testing new ideas. -### What about the reStructuredText Extension? +### You're probably looking for the reStructuredText Extension You may already be familiar with the [reStructuredText](https://marketplace.visualstudio.com/items?itemName=lextudio.restructuredtext) extension which, as of [v171.0.0](https://github.com/vscode-restructuredtext/vscode-restructuredtext/releases/tag/171.0.0) now also integrates the `esbonio` language server into VSCode. It also integrates other tools such as the linters [`doc8`](https://pypi.org/project/doc8/) and [`rstcheck`](https://pypi.org/project/rstcheck/) and provides additional editor functionality making it easier to work with reStructuredText in general. @@ -35,41 +71,12 @@ Finally, the Esbonio extension serves as an up to date reference for projects th Try the reStructuredText extension if - You need an extension compatible with older versions of VSCode -- You are interested in additional features beyond what is provided by the language server +- You are looking for a more rounded editing experience. Try the Esbonio extension if - You want to make use of the newer features available in recent VSCode versions - You are only interested in the features provided by the language server -## `lib/esbonio/` - A Language Server for Sphinx projects. -[![PyPI](https://img.shields.io/pypi/v/esbonio?style=flat-square)![PyPI - Downloads](https://img.shields.io/pypi/dm/esbonio?style=flat-square)](https://pypistats.org/packages/esbonio)[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)](https://github.com/swyddfa/esbonio/blob/develop/lib/esbonio/LICENSE) - -The language server is still in early development, but already provides the following features. - -**Completion** - -

- Completion Suggestions Demo -

- -**Definitions** - -

- Goto Definition Demo -

- -**Diagnostics** - -

- Diagnostics Demo -

- -**Document Symbols** - -

- Document Symbols Demo -

- ## `lib/esbonio-extensions/` - A collection of Sphinx extensions [![PyPI](https://img.shields.io/pypi/v/esbonio-extensions?style=flat-square)![PyPI - Downloads](https://img.shields.io/pypi/dm/esbonio-extensions?style=flat-square)](https://pypistats.org/packages/esbonio-extensions)[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)](https://github.com/swyddfa/esbonio/blob/develop/lib/esbonio-extensions/LICENSE) diff --git a/docs/index.rst b/docs/index.rst index cca0ad099..b620d352b 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -41,6 +41,14 @@ Here is a quick summary of the features implemented by the language server. :align: center :target: /_images/diagnostic-sphinx-errors-demo.png + .. collection-item:: Document Links + + The language server implements :lsp:`textDocument/documentLink` to make references to other files "Ctrl + Clickable" + + .. figure:: ../resources/images/document-links-demo.png + :align: center + :target: /_images/document-links-demo.png + .. collection-item:: Document Symbols The language server implements :lsp:`textDocument/documentSymbol` which diff --git a/docs/lsp/api-reference.rst b/docs/lsp/api-reference.rst index 88a28a814..c9d1b617e 100644 --- a/docs/lsp/api-reference.rst +++ b/docs/lsp/api-reference.rst @@ -33,13 +33,16 @@ SphinxLanguageServer Language Features ----------------- -.. autoclass:: esbonio.lsp.rst.LanguageFeature +.. autoclass:: esbonio.lsp.LanguageFeature :members: -.. autoclass:: esbonio.lsp.rst.CompletionContext +.. autoclass:: esbonio.lsp.CompletionContext :members: -.. autoclass:: esbonio.lsp.rst.DefinitionContext +.. autoclass:: esbonio.lsp.DefinitionContext + :members: + +.. autoclass:: esbonio.lsp.DocumentLinkContext :members: Directives @@ -56,8 +59,14 @@ Directives .. autoclass:: ArgumentCompletion :members: +.. autoclass:: ArgumentDefinition + :members: + +.. autoclass:: ArgumentLink + :members: + .. autoclass:: Directives - :members: add_argument_completion_provider, add_documentation + :members: add_argument_completion_provider, add_argument_definition_provider, add_argument_link_provider, add_documentation Roles ^^^^^ @@ -71,7 +80,7 @@ Roles :annotation: = re.compile(...) .. autoclass:: Roles - :members: add_documentation, add_target_definition_provider, add_target_completion_provider + :members: add_documentation, add_target_definition_provider, add_target_completion_provider, add_target_link_provider .. autoclass:: TargetCompletion :members: @@ -79,6 +88,9 @@ Roles .. autoclass:: TargetDefinition :members: +.. autoclass:: TargetLink + :members: + Testing ------- diff --git a/docs/lsp/extending.rst b/docs/lsp/extending.rst index aa9abcc7e..32e9974b7 100644 --- a/docs/lsp/extending.rst +++ b/docs/lsp/extending.rst @@ -32,7 +32,7 @@ Architecture Language Feature Language features are subclasses of :class:`~esbonio.lsp.rst.LanguageFeature`. - They are typically based on a single aspect of reStructuedText (e.g. :class:`~esbonio.lsp.roles.Roles`) or Sphinx (e.g. :class:`` responsible for providing + They are typically based on a single aspect of reStructuredText (e.g. :class:`~esbonio.lsp.roles.Roles`) or Sphinx (e.g. :class:`` responsible for providing Language Features (where it makes sense) should be server agnostic, that way the same features can be reused across different envrionments. diff --git a/resources/images/document-links-demo.png b/resources/images/document-links-demo.png new file mode 100644 index 0000000000000000000000000000000000000000..d6b39ac425bb6518d7475652dea65e25a42920e3 GIT binary patch literal 37800 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 literal 0 HcmV?d00001 From fb39a0000a8103ee9977f2cd37a6dab42468baab Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Mon, 18 Apr 2022 18:21:16 +0100 Subject: [PATCH 11/12] lsp: Only return `:doc:` definitions if target exists --- lib/esbonio/changes/369.fix.rst | 1 + lib/esbonio/esbonio/lsp/sphinx/domains.py | 9 ++++++--- .../tests/sphinx-default/test_sd_sphinx_domains.py | 4 ++-- 3 files changed, 9 insertions(+), 5 deletions(-) create mode 100644 lib/esbonio/changes/369.fix.rst diff --git a/lib/esbonio/changes/369.fix.rst b/lib/esbonio/changes/369.fix.rst new file mode 100644 index 000000000..ad846f780 --- /dev/null +++ b/lib/esbonio/changes/369.fix.rst @@ -0,0 +1 @@ +Goto definition for ``:doc:`` targets will now only return a result if the referenced document actually exists. diff --git a/lib/esbonio/esbonio/lsp/sphinx/domains.py b/lib/esbonio/esbonio/lsp/sphinx/domains.py index f3405220b..9e2e21917 100644 --- a/lib/esbonio/esbonio/lsp/sphinx/domains.py +++ b/lib/esbonio/esbonio/lsp/sphinx/domains.py @@ -178,11 +178,14 @@ def resolve_doc(self, doc: Document, label: str) -> Optional[str]: currentdir = pathlib.Path(Uri.to_fs_path(doc.uri)).parent if label.startswith("/"): - path = str(pathlib.Path(srcdir, label[1:] + ".rst")) + path = pathlib.Path(srcdir, label[1:] + ".rst") else: - path = str(pathlib.Path(currentdir, label + ".rst")) + path = pathlib.Path(currentdir, label + ".rst") - return Uri.from_fs_path(path) + if not path.exists(): + return None + + return Uri.from_fs_path(str(path)) def resolve_intersphinx( self, name: str, domain: Optional[str], label: str diff --git a/lib/esbonio/tests/sphinx-default/test_sd_sphinx_domains.py b/lib/esbonio/tests/sphinx-default/test_sd_sphinx_domains.py index a435feb3b..268e8842d 100644 --- a/lib/esbonio/tests/sphinx-default/test_sd_sphinx_domains.py +++ b/lib/esbonio/tests/sphinx-default/test_sd_sphinx_domains.py @@ -263,9 +263,9 @@ async def test_role_target_completions(client: Client, text: str, setup): ), ( "definitions.rst", - Position(line=13, character=36), + Position(line=31, character=20), Location( - uri="changelog.rst", + uri="glossary.rst", range=Range( start=Position(line=0, character=0), end=Position(line=1, character=0), From 5359e462148dccf73338416333e58ae94f57445b Mon Sep 17 00:00:00 2001 From: Alex Carney Date: Mon, 18 Apr 2022 20:27:44 +0100 Subject: [PATCH 12/12] code: Bump required esbonio version --- code/src/constants.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/code/src/constants.ts b/code/src/constants.ts index 0912c3a92..4d5980868 100644 --- a/code/src/constants.ts +++ b/code/src/constants.ts @@ -3,7 +3,7 @@ export const PYTHON_EXTENSION = "ms-python.python" export namespace Server { export const REQUIRED_PYTHON = "3.6.0" - export const REQUIRED_VERSION = "0.10.2" + export const REQUIRED_VERSION = "0.11.0" } export namespace Commands {