diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 0609169b..93b828b0 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -75,5 +75,7 @@ This server can be configured using the `workspace/didChangeConfiguration` metho | `pylsp.plugins.yapf.enabled` | `boolean` | Enable or disable the plugin. | `true` | | `pylsp.rope.extensionModules` | `string` | Builtin and c-extension modules that are allowed to be imported and inspected by rope. | `null` | | `pylsp.rope.ropeFolder` | `array` of unique `string` items | The name of the folder in which rope stores project configurations and data. Pass `null` for not using such a folder at all. | `null` | +| `pylsp.signature.formatter` | `string` (one of: `'black'`, `'ruff'`, `None`) | Formatter to use for reformatting signatures in docstrings. | `"black"` | +| `pylsp.signature.line_length` | `number` | Maximum line length in signatures. | `88` | This documentation was generated from `pylsp/config/schema.json`. Please do not edit this file directly. diff --git a/pylsp/_utils.py b/pylsp/_utils.py index 644533df..dfe84b14 100644 --- a/pylsp/_utils.py +++ b/pylsp/_utils.py @@ -7,6 +7,8 @@ import os import pathlib import re +import subprocess +import sys import threading import time from typing import Optional @@ -57,7 +59,7 @@ def run(): def throttle(seconds=1): - """Throttles calls to a function evey `seconds` seconds.""" + """Throttles calls to a function every `seconds` seconds.""" def decorator(func): @functools.wraps(func) @@ -209,8 +211,96 @@ def choose_markup_kind(client_supported_markup_kinds: list[str]): return "markdown" +class Formatter: + command: list[str] + + @property + def is_installed(self) -> bool: + """Returns whether formatter is available""" + if not hasattr(self, "_is_installed"): + self._is_installed = self._is_available_via_cli() + return self._is_installed + + def format(self, code: str, line_length: int) -> str: + """Formats code""" + return subprocess.check_output( + [ + sys.executable, + "-m", + *self.command, + "--line-length", + str(line_length), + "-", + ], + input=code, + text=True, + ).strip() + + def _is_available_via_cli(self) -> bool: + try: + subprocess.check_output( + [ + sys.executable, + "-m", + *self.command, + "--help", + ], + ) + return True + except subprocess.CalledProcessError: + return False + + +class RuffFormatter(Formatter): + command = ["ruff", "format"] + + +class BlackFormatter(Formatter): + command = ["black"] + + +formatters = {"ruff": RuffFormatter(), "black": BlackFormatter()} + + +def format_signature(signature: str, config: dict, signature_formatter: str) -> str: + """Formats signature using ruff or black if either is available.""" + as_func = f"def {signature.strip()}:\n pass" + line_length = config.get("line_length", 88) + formatter = formatters[signature_formatter] + if formatter.is_installed: + try: + return ( + formatter.format(as_func, line_length=line_length) + .removeprefix("def ") + .removesuffix(":\n pass") + ) + except subprocess.CalledProcessError as e: + log.warning("Signature formatter failed %s", e) + else: + log.warning( + "Formatter %s was requested but it does not appear to be installed", + signature_formatter, + ) + return signature + + +def convert_signatures_to_markdown(signatures: list[str], config: dict) -> str: + signature_formatter = config.get("formatter", "black") + if signature_formatter: + signatures = [ + format_signature( + signature, signature_formatter=signature_formatter, config=config + ) + for signature in signatures + ] + return wrap_signature("\n".join(signatures)) + + def format_docstring( - contents: str, markup_kind: str, signatures: Optional[list[str]] = None + contents: str, + markup_kind: str, + signatures: Optional[list[str]] = None, + signature_config: Optional[dict] = None, ): """Transform the provided docstring into a MarkupContent object. @@ -232,7 +322,10 @@ def format_docstring( value = escape_markdown(contents) if signatures: - value = wrap_signature("\n".join(signatures)) + "\n\n" + value + wrapped_signatures = convert_signatures_to_markdown( + signatures, config=signature_config or {} + ) + value = wrapped_signatures + "\n\n" + value return {"kind": "markdown", "value": value} value = contents diff --git a/pylsp/config/schema.json b/pylsp/config/schema.json index 18248384..c4aec460 100644 --- a/pylsp/config/schema.json +++ b/pylsp/config/schema.json @@ -511,6 +511,24 @@ }, "uniqueItems": true, "description": "The name of the folder in which rope stores project configurations and data. Pass `null` for not using such a folder at all." + }, + "pylsp.signature.formatter": { + "type": [ + "string", + "null" + ], + "enum": [ + "black", + "ruff", + null + ], + "default": "black", + "description": "Formatter to use for reformatting signatures in docstrings." + }, + "pylsp.signature.line_length": { + "type": "number", + "default": 88, + "description": "Maximum line length in signatures." } } } diff --git a/pylsp/plugins/hover.py b/pylsp/plugins/hover.py index ca69d1b3..daaae90b 100644 --- a/pylsp/plugins/hover.py +++ b/pylsp/plugins/hover.py @@ -10,6 +10,7 @@ @hookimpl def pylsp_hover(config, document, position): + signature_config = config.settings().get("signature", {}) code_position = _utils.position_to_jedi_linecolumn(document, position) definitions = document.jedi_script(use_document_path=True).infer(**code_position) word = document.word_at_position(position) @@ -46,5 +47,6 @@ def pylsp_hover(config, document, position): definition.docstring(raw=True), preferred_markup_kind, signatures=[signature] if signature else None, + signature_config=signature_config, ) } diff --git a/pylsp/plugins/jedi_completion.py b/pylsp/plugins/jedi_completion.py index 2796a093..51c3589c 100644 --- a/pylsp/plugins/jedi_completion.py +++ b/pylsp/plugins/jedi_completion.py @@ -40,8 +40,9 @@ def pylsp_completions(config, document, position): """Get formatted completions for current code position""" settings = config.plugin_settings("jedi_completion", document_path=document.path) resolve_eagerly = settings.get("eager", False) - code_position = _utils.position_to_jedi_linecolumn(document, position) + signature_config = config.settings().get("signature", {}) + code_position = _utils.position_to_jedi_linecolumn(document, position) code_position["fuzzy"] = settings.get("fuzzy", False) completions = document.jedi_script(use_document_path=True).complete(**code_position) @@ -88,6 +89,7 @@ def pylsp_completions(config, document, position): resolve=resolve_eagerly, resolve_label_or_snippet=(i < max_to_resolve), snippet_support=snippet_support, + signature_config=signature_config, ) for i, c in enumerate(completions) ] @@ -103,6 +105,7 @@ def pylsp_completions(config, document, position): resolve=resolve_eagerly, resolve_label_or_snippet=(i < max_to_resolve), snippet_support=snippet_support, + signature_config=signature_config, ) completion_dict["kind"] = lsp.CompletionItemKind.TypeParameter completion_dict["label"] += " object" @@ -118,6 +121,7 @@ def pylsp_completions(config, document, position): resolve=resolve_eagerly, resolve_label_or_snippet=(i < max_to_resolve), snippet_support=snippet_support, + signature_config=signature_config, ) completion_dict["kind"] = lsp.CompletionItemKind.TypeParameter completion_dict["label"] += " object" @@ -137,7 +141,11 @@ def pylsp_completions(config, document, position): @hookimpl -def pylsp_completion_item_resolve(config, completion_item, document): +def pylsp_completion_item_resolve( + config, + completion_item, + document, +): """Resolve formatted completion for given non-resolved completion""" shared_data = document.shared_data["LAST_JEDI_COMPLETIONS"].get( completion_item["label"] @@ -152,7 +160,12 @@ def pylsp_completion_item_resolve(config, completion_item, document): if shared_data: completion, data = shared_data - return _resolve_completion(completion, data, markup_kind=preferred_markup_kind) + return _resolve_completion( + completion, + data, + markup_kind=preferred_markup_kind, + signature_config=config.settings().get("signature", {}), + ) return completion_item @@ -207,13 +220,14 @@ def use_snippets(document, position): return expr_type not in _IMPORTS and not (expr_type in _ERRORS and "import" in code) -def _resolve_completion(completion, d, markup_kind: str): +def _resolve_completion(completion, d, markup_kind: str, signature_config: dict): completion["detail"] = _detail(d) try: docs = _utils.format_docstring( d.docstring(raw=True), signatures=[signature.to_string() for signature in d.get_signatures()], markup_kind=markup_kind, + signature_config=signature_config, ) except Exception: docs = "" @@ -228,6 +242,7 @@ def _format_completion( resolve=False, resolve_label_or_snippet=False, snippet_support=False, + signature_config=None, ): completion = { "label": _label(d, resolve_label_or_snippet), @@ -237,7 +252,9 @@ def _format_completion( } if resolve: - completion = _resolve_completion(completion, d, markup_kind) + completion = _resolve_completion( + completion, d, markup_kind, signature_config=signature_config + ) # Adjustments for file completions if d.type == "path": diff --git a/pyproject.toml b/pyproject.toml index 583b25a9..62f345b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ "pluggy>=1.0.0", "python-lsp-jsonrpc>=1.1.0,<2.0.0", "ujson>=3.0.0", + "black" ] dynamic = ["version"] diff --git a/test/plugins/test_hover.py b/test/plugins/test_hover.py index 9674b872..b507acd2 100644 --- a/test/plugins/test_hover.py +++ b/test/plugins/test_hover.py @@ -10,7 +10,7 @@ DOC_URI = uris.from_fs_path(__file__) DOC = """ -def main(): +def main(a: float, b: float): \"\"\"hello world\"\"\" pass """ @@ -79,13 +79,47 @@ def test_hover(workspace) -> None: doc = Document(DOC_URI, workspace, DOC) - contents = {"kind": "markdown", "value": "```python\nmain()\n```\n\n\nhello world"} + contents = { + "kind": "markdown", + "value": "```python\nmain(a: float, b: float)\n```\n\n\nhello world", + } assert {"contents": contents} == pylsp_hover(doc._config, doc, hov_position) assert {"contents": ""} == pylsp_hover(doc._config, doc, no_hov_position) +def test_hover_signature_formatting(workspace) -> None: + # Over 'main' in def main(): + hov_position = {"line": 2, "character": 6} + + doc = Document(DOC_URI, workspace, DOC) + # setting low line length should trigger reflow to multiple lines + doc._config.update({"signature": {"line_length": 10}}) + + contents = { + "kind": "markdown", + "value": "```python\nmain(\n a: float,\n b: float,\n)\n```\n\n\nhello world", + } + + assert {"contents": contents} == pylsp_hover(doc._config, doc, hov_position) + + +def test_hover_signature_formatting_opt_out(workspace) -> None: + # Over 'main' in def main(): + hov_position = {"line": 2, "character": 6} + + doc = Document(DOC_URI, workspace, DOC) + doc._config.update({"signature": {"line_length": 10, "formatter": None}}) + + contents = { + "kind": "markdown", + "value": "```python\nmain(a: float, b: float)\n```\n\n\nhello world", + } + + assert {"contents": contents} == pylsp_hover(doc._config, doc, hov_position) + + def test_document_path_hover(workspace_other_root_path, tmpdir) -> None: # Create a dummy module out of the workspace's root_path and try to get # a definition on it in another file placed next to it.