From d3f16a3ec99a1b93904d48b066abf9d2d7ae83ce Mon Sep 17 00:00:00 2001 From: L Lllvvuu Date: Wed, 6 Sep 2023 10:56:48 -0700 Subject: [PATCH] feat: add `fixit lsp` subcommand Support for: - [x] `textDocument/didOpen`, `textDocument/didChange` -> `textDocument/publishDiagnostics` - [x] `textDocument/formatting` No support yet: - [ ] `textDocument/codeAction`, `workspace/executeCommand` - [ ] `workspace/didChangeWatchedFiles` to invalidate the config cache test: Added new smoke test for the new `fixit lsp` subcommand. --- docs/guide/commands.rst | 27 +++++ docs/guide/integrations.rst | 39 ++++++++ pyproject.toml | 5 +- src/fixit/cli.py | 36 ++++++- src/fixit/ftypes.py | 12 +++ src/fixit/lsp.py | 191 ++++++++++++++++++++++++++++++++++++ src/fixit/tests/__init__.py | 1 + src/fixit/tests/lsp.py | 41 ++++++++ src/fixit/tests/smoke.py | 30 ++++++ 9 files changed, 380 insertions(+), 2 deletions(-) create mode 100644 src/fixit/lsp.py create mode 100644 src/fixit/tests/lsp.py diff --git a/docs/guide/commands.rst b/docs/guide/commands.rst index 5596db3f..5a8a53e5 100644 --- a/docs/guide/commands.rst +++ b/docs/guide/commands.rst @@ -84,6 +84,33 @@ the input read from STDIN, and the fixed output printed to STDOUT (ignoring Show applied fixes in unified diff format when applied automatically. +``lsp`` +^^^^^^^ + +Start the language server providing IDE features over +`LSP `__. +This command is only available if installed with the `lsp` extra. + +.. code:: console + + $ fixit lsp [--stdio | --tcp PORT | --ws PORT] + +.. attribute:: --stdio + + Serve LSP over stdio. *default* + +.. attribute:: --tcp + + Serve LSP over TCP on PORT. + +.. attribute:: --ws + + Serve LSP over WebSocket on PORT. + +.. attribute:: --debounce-interval + + Delay in seconds for server-side debounce. *default: 0.5* + ``test`` ^^^^^^^^ diff --git a/docs/guide/integrations.rst b/docs/guide/integrations.rst index f683ba7b..af5ffe13 100644 --- a/docs/guide/integrations.rst +++ b/docs/guide/integrations.rst @@ -1,6 +1,45 @@ Integrations ------------ +IDE +^^^ + +Fixit can be used to lint as you type as well as to format files. + +To get this functionality, install the `lsp` extra, e.g. +`pip install fixit[lsp]`, then set up an LSP client to launch and connect to +the Fixit LSP server (``fixit lsp``). Examples of client setup: + +- VSCode: `Generic LSP Client `_: + +.. code:: json + + { + "glspc.languageId": "python", + "glspc.serverCommand": "fixit", + "glspc.serverCommandArguments": ["lsp"], + "glspc.pathPrepend": "/path/to/python/3.11.4/bin/", + } + +- Neovim: `nvim-lspconfig `_: + +.. code:: lua + + require("lspconfig.configs").fixit = { + default_config = { + cmd = { "fixit", "lsp" }, + filetypes = { "python" }, + root_dir = require("lspconfig").util.root_pattern( + "pyproject.toml", "setup.py", "requirements.txt", ".git", + ), + single_file_support = true, + }, + } + + lspconfig.fixit.setup({}) + +- `Other IDEs `_ + pre-commit ^^^^^^^^^^ diff --git a/pyproject.toml b/pyproject.toml index 8d41c2ea..761522df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,6 +54,9 @@ dev = [ pretty = [ "rich >= 12.6.0", ] +lsp = [ + "pygls[ws] >= 1.0.2", +] [project.scripts] fixit = "fixit.cli:main" @@ -83,7 +86,7 @@ exclude = [ features = ["dev", "docs", "pretty"] [tool.hatch.envs.default] -features = ["dev", "pretty"] +features = ["dev", "lsp", "pretty"] [tool.hatch.envs.default.scripts] test = "python -m fixit.tests" diff --git a/src/fixit/cli.py b/src/fixit/cli.py index b9d8bc29..cf572e18 100644 --- a/src/fixit/cli.py +++ b/src/fixit/cli.py @@ -15,7 +15,7 @@ from .api import fixit_paths, print_result from .config import collect_rules, generate_config, parse_rule -from .ftypes import Config, Options, QualifiedRule, Tags +from .ftypes import Config, LSPOptions, Options, QualifiedRule, Tags from .rule import LintRule from .testing import generate_lint_rule_test_cases from .util import capture @@ -203,6 +203,40 @@ def fix( ctx.exit(exit_code) +@main.command() +@click.pass_context +@click.option("--stdio", type=bool, default=True, help="Serve LSP over stdio") +@click.option("--tcp", type=int, help="Port to serve LSP over") +@click.option("--ws", type=int, help="Port to serve WS over") +@click.option( + "--debounce-interval", + type=float, + default=LSPOptions.debounce_interval, + help="Delay in seconds for server-side debounce", +) +def lsp( + ctx: click.Context, + stdio: bool, + tcp: Optional[int], + ws: Optional[int], + debounce_interval: float, +): + """ + Start server for: + https://microsoft.github.io/language-server-protocol/ + """ + from .lsp import LSP + + main_options = ctx.obj + lsp_options = LSPOptions( + tcp=tcp, + ws=ws, + stdio=stdio, + debounce_interval=debounce_interval, + ) + LSP(main_options, lsp_options).start() + + @main.command() @click.pass_context @click.argument("rules", nargs=-1, required=True, type=str) diff --git a/src/fixit/ftypes.py b/src/fixit/ftypes.py index 3fa9ef26..646447ec 100644 --- a/src/fixit/ftypes.py +++ b/src/fixit/ftypes.py @@ -184,6 +184,18 @@ class Options: rules: Sequence[QualifiedRule] = () +@dataclass +class LSPOptions: + """ + Command-line options to affect LSP runtime behavior + """ + + tcp: Optional[int] + ws: Optional[int] + stdio: bool = True + debounce_interval: float = 0.5 + + @dataclass class Config: """ diff --git a/src/fixit/lsp.py b/src/fixit/lsp.py new file mode 100644 index 00000000..54e06a1b --- /dev/null +++ b/src/fixit/lsp.py @@ -0,0 +1,191 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +import threading +from functools import partial +from pathlib import Path +from typing import Any, Callable, cast, Dict, Generator, List, Optional, TypeVar + +import pygls.uris as Uri + +from lsprotocol.types import ( + Diagnostic, + DiagnosticSeverity, + DidChangeTextDocumentParams, + DidOpenTextDocumentParams, + DocumentFormattingParams, + Position, + Range, + TEXT_DOCUMENT_DID_CHANGE, + TEXT_DOCUMENT_DID_OPEN, + TEXT_DOCUMENT_FORMATTING, + TextEdit, +) +from pygls.server import LanguageServer + +from fixit import __version__ +from fixit.util import capture + +from .api import fixit_bytes +from .config import generate_config +from .ftypes import Config, FileContent, LSPOptions, Options, Result + + +class LSP: + """ + Server for the Language Server Protocol. + Provides diagnostics as you type, and exposes a formatter. + https://microsoft.github.io/language-server-protocol/ + """ + + def __init__(self, fixit_options: Options, lsp_options: LSPOptions) -> None: + self.fixit_options = fixit_options + self.lsp_options = lsp_options + + self._config_cache: Dict[Path, Config] = {} + + # separate debounce timer per URI so that linting one URI + # doesn't cancel linting another + self._validate_uri: Dict[str, Callable[[int], None]] = {} + + self.lsp = LanguageServer("fixit-lsp", __version__) + # `partial` since `pygls` can register functions but not methods + self.lsp.feature(TEXT_DOCUMENT_DID_OPEN)(partial(self.on_did_open)) + self.lsp.feature(TEXT_DOCUMENT_DID_CHANGE)(partial(self.on_did_change)) + self.lsp.feature(TEXT_DOCUMENT_FORMATTING)(partial(self.format)) + + def load_config(self, path: Path) -> Config: + """ + Cached fetch of fixit.toml(s) for fixit_bytes. + """ + if path not in self._config_cache: + self._config_cache[path] = generate_config(path, options=self.fixit_options) + return self._config_cache[path] + + def diagnostic_generator( + self, uri: str, autofix=False + ) -> Generator[Result, bool, FileContent | None] | None: + """ + LSP wrapper (provides document state from `pygls`) for `fixit_bytes`. + """ + path = Uri.to_fs_path(uri) + if not path: + return None + path = Path(path) + + return fixit_bytes( + path, + self.lsp.workspace.get_document(uri).source.encode(), + autofix=autofix, + config=self.load_config(path), + ) + + def _validate(self, uri: str, version: int) -> None: + """ + Effect: publishes Fixit diagnostics to the LSP client. + """ + + generator = self.diagnostic_generator(uri) + if not generator: + return + diagnostics = [] + for result in generator: + violation = result.violation + if not violation: + continue + diagnostic = Diagnostic( + Range( + Position( # LSP is 0-indexed; fixit line numbers are 1-indexed + violation.range.start.line - 1, violation.range.start.column + ), + Position(violation.range.end.line - 1, violation.range.end.column), + ), + violation.message, + severity=DiagnosticSeverity.Warning, + code=violation.rule_name, + source="fixit", + ) + diagnostics.append(diagnostic) + self.lsp.publish_diagnostics(uri, diagnostics, version=version) + + def validate(self, uri: str, version: int) -> None: + """ + Effect: may publish Fixit diagnostics to the LSP client after a debounce delay. + """ + if uri not in self._validate_uri: + self._validate_uri[uri] = debounce(self.lsp_options.debounce_interval)( + partial(self._validate, uri) + ) + self._validate_uri[uri](version) + + def on_did_open(self, params: DidOpenTextDocumentParams) -> None: + self.validate(params.text_document.uri, params.text_document.version) + + def on_did_change(self, params: DidChangeTextDocumentParams) -> None: + self.validate(params.text_document.uri, params.text_document.version) + + def format(self, params: DocumentFormattingParams) -> List[TextEdit] | None: + generator = self.diagnostic_generator(params.text_document.uri, autofix=True) + if generator is None: + return None + + captured = capture(generator) + for _ in captured: + pass + formatted_content = captured.result + if not formatted_content: + return None + + doc = self.lsp.workspace.get_document(params.text_document.uri) + entire_range = Range( + start=Position(line=0, character=0), + end=Position(line=len(doc.lines) - 1, character=len(doc.lines[-1])), + ) + + return [TextEdit(new_text=formatted_content.decode(), range=entire_range)] + + def start(self) -> None: + """ + Effect: occupies the specified I/O channels. + """ + if self.lsp_options.ws: + self.lsp.start_ws("localhost", self.lsp_options.ws) + if self.lsp_options.tcp: + self.lsp.start_tcp("localhost", self.lsp_options.tcp) + if self.lsp_options.stdio: + self.lsp.start_io() + + +VoidFunction = TypeVar("VoidFunction", bound=Callable[..., None]) + + +class Debouncer: + def __init__(self, f: Callable[..., Any], interval: float) -> None: + self.f = f + self.interval = interval + self._timer: Optional[threading.Timer] = None + self._lock = threading.Lock() + + def __call__(self, *args, **kwargs) -> None: + with self._lock: + if self._timer is not None: + self._timer.cancel() + self._timer = threading.Timer(self.interval, self.f, args, kwargs) + self._timer.start() + + +def debounce(interval: float): + """ + Wait `interval` seconds before calling `f`, and cancel if called again. + The decorated function will return None immediately, + ignoring the delayed return value of `f`. + """ + + def decorator(f: VoidFunction) -> VoidFunction: + if interval <= 0: + return f + return cast(VoidFunction, Debouncer(f, interval)) + + return decorator diff --git a/src/fixit/tests/__init__.py b/src/fixit/tests/__init__.py index 4021fdec..281e24ad 100644 --- a/src/fixit/tests/__init__.py +++ b/src/fixit/tests/__init__.py @@ -10,6 +10,7 @@ from .config import ConfigTest from .engine import EngineTest from .ftypes import TypesTest +from .lsp import DebounceTest from .rule import RuleTest, RunnerTest from .smoke import SmokeTest diff --git a/src/fixit/tests/lsp.py b/src/fixit/tests/lsp.py new file mode 100644 index 00000000..15592e9c --- /dev/null +++ b/src/fixit/tests/lsp.py @@ -0,0 +1,41 @@ +# Copyright (c) Meta Platforms, Inc. and affiliates. +# +# This source code is licensed under the MIT license found in the +# LICENSE file in the root directory of this source tree. + +import time +from unittest import TestCase + +from fixit.lsp import debounce + + +class DebounceTest(TestCase): + def test_delayed_execution(self): + """Test if the decorated function is delayed by the debounce time.""" + executed = [False] + + @debounce(0.01) + def f(): + executed[0] = True + + result = f() + self.assertIsNone(result) + self.assertFalse(executed[0]) + time.sleep(0.005) + self.assertFalse(executed[0]) + time.sleep(0.01) + self.assertTrue(executed[0]) + + def test_single_execution_after_multiple_calls(self): + """Test if the decorated function is executed once after multiple rapid calls""" + counter = [0] + + @debounce(0.01) + def f(): + counter[0] += 1 + + for _ in range(10): + f() + + time.sleep(0.02) + self.assertEqual(counter[0], 1) diff --git a/src/fixit/tests/smoke.py b/src/fixit/tests/smoke.py index 53d9aba6..5d9454a0 100644 --- a/src/fixit/tests/smoke.py +++ b/src/fixit/tests/smoke.py @@ -3,12 +3,15 @@ # This source code is licensed under the MIT license found in the # LICENSE file in the root directory of this source tree. +import json from collections import defaultdict from pathlib import Path from tempfile import TemporaryDirectory from textwrap import dedent from unittest import TestCase +import pygls.uris as Uri + from click.testing import CliRunner from fixit import __version__ @@ -135,6 +138,33 @@ def func(): self.assertEqual(result.exit_code, 0) self.assertEqual(expected_format, result.output, "unexpected stdout") + with self.subTest("LSP"): + path.write_text(content) + uri = Uri.from_fs_path(path.as_posix()) + + initialize = '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"capabilities":{}}}' + + did_open_template = '{{"jsonrpc":"2.0","id":1,"method":"textDocument/didOpen","params":{{"textDocument":{{"uri":{uri},"languageId":"python","version":0,"text":{content}}}}}}}' + did_open = did_open_template.format( + uri=json.dumps(uri), content=json.dumps(content) + ) + + def payload(content: str) -> str: + return f"Content-Length: {len(content)}\r\n\r\n{content}" + + result = self.runner.invoke( + main, + ["lsp", "--debounce-interval", "0"], + input=payload(initialize) + payload(did_open), + catch_exceptions=False, + ) + + self.assertEqual(result.exit_code, 0) + self.assertRegex( + result.output, + r"file\.py\".+\"range\".+\"start\".+\"end\".+\"severity\": 2, \"code\": \"NoRedundantFString\", \"source\": \"fixit\"", + ) + def test_this_file_is_clean(self) -> None: path = Path(__file__).resolve().as_posix() result = self.runner.invoke(main, ["lint", path], catch_exceptions=False)