Skip to content

Commit

Permalink
feat: add fixit lsp subcommand
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
llllvvuu committed Nov 8, 2023
1 parent 6512fbd commit d3f16a3
Show file tree
Hide file tree
Showing 9 changed files with 380 additions and 2 deletions.
27 changes: 27 additions & 0 deletions docs/guide/commands.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://microsoft.github.io/language-server-protocol/>`__.
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``
^^^^^^^^
Expand Down
39 changes: 39 additions & 0 deletions docs/guide/integrations.rst
Original file line number Diff line number Diff line change
@@ -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 <https://github.com/llllvvuu/vscode-glspc>`_:

.. code:: json
{
"glspc.languageId": "python",
"glspc.serverCommand": "fixit",
"glspc.serverCommandArguments": ["lsp"],
"glspc.pathPrepend": "/path/to/python/3.11.4/bin/",
}
- Neovim: `nvim-lspconfig <https://github.com/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 <https://microsoft.github.io/language-server-protocol/implementors/tools/>`_

pre-commit
^^^^^^^^^^

Expand Down
5 changes: 4 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ dev = [
pretty = [
"rich >= 12.6.0",
]
lsp = [
"pygls[ws] >= 1.0.2",
]

[project.scripts]
fixit = "fixit.cli:main"
Expand Down Expand Up @@ -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"
Expand Down
36 changes: 35 additions & 1 deletion src/fixit/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
12 changes: 12 additions & 0 deletions src/fixit/ftypes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
"""
Expand Down
191 changes: 191 additions & 0 deletions src/fixit/lsp.py
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions src/fixit/tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading

0 comments on commit d3f16a3

Please sign in to comment.