Skip to content

Commit

Permalink
feat: add fixit lsp subcommand
Browse files Browse the repository at this point in the history
resolves Instagram#387 Instagram#122

Support for:
- [x] `textDocument/didOpen`, `textDocument/didChange` -> `textDocument/publishDiagnostics`
- [x] `textDocument/formatting`

Also adds --log-file CLI arg so that the language server can be observable.

In the interest of keeping the PR small, the following are not included in this PR:

- [ ] `textDocument/codeAction`, `workspace/executeCommand`
- [ ] `workspace/didChangeWatchedFiles` to invalidate the config cache
- [ ] Vendor [Generic LSP Client](https://github.com/llllvvuu/vscode-glspc) to add Fixit branding (code works out of the box, only `README.md`, `LICENSE`, and `package.json` would need to be changed. I published MIT license so it's free to use.)

If this PR gets merged I will create follow-up issues for these items.

test: Added new smoke test for the new `fixit lsp` subcommand.
  • Loading branch information
llllvvuu committed Sep 10, 2023
1 parent 84378a8 commit d7e6512
Show file tree
Hide file tree
Showing 10 changed files with 406 additions and 3 deletions.
30 changes: 30 additions & 0 deletions docs/guide/commands.rst
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ The following options are available for all commands:

Raise or lower the level of output and logging.

.. attribute:: --log-file PATH

Log to a specified file instead of stderr.

.. attribute:: --config-file PATH

Override the normal hierarchical configuration and use the configuration
Expand Down Expand Up @@ -79,6 +83,32 @@ Lint one or more paths, and apply suggested fixes.

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/>`__.

.. 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*


``test``
^^^^^^^^
Expand Down
38 changes: 38 additions & 0 deletions docs/guide/integrations.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,44 @@
Integrations
------------

IDE
^^^

Fixit can be used to lint as you type as well as to format files.

To get this functionality, 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"],
"glsps.pathPrepend": "/Users/me/.local/share/rtx/installs/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
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ dependencies = [
"libcst >= 0.3.18",
"moreorless >= 0.4.0",
"packaging >= 21",
"pygls >= 1.0.2",
"tomli >= 2.0; python_version < '3.11'",
"trailrunner >= 1.2",
]
Expand Down
43 changes: 41 additions & 2 deletions src/fixit/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@

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 .lsp import LSP
from .rule import LintRule
from .testing import generate_lint_rule_test_cases
from .util import capture
Expand Down Expand Up @@ -53,6 +54,12 @@ def f(v: int) -> str:
@click.option(
"--debug/--quiet", is_flag=True, default=None, help="Increase decrease verbosity"
)
@click.option(
"--log-file",
type=click.Path(dir_okay=False, exists=False, path_type=Path),
default=None,
help="Log to file instead of stderr",
)
@click.option(
"--config-file",
"-c",
Expand All @@ -75,14 +82,18 @@ def f(v: int) -> str:
def main(
ctx: click.Context,
debug: Optional[bool],
log_file: Optional[Path],
config_file: Optional[Path],
tags: str,
rules: str,
):
level = logging.WARNING
if debug is not None:
level = logging.DEBUG if debug else logging.ERROR
logging.basicConfig(level=level, stream=sys.stderr)
if log_file is None:
logging.basicConfig(level=level, stream=sys.stderr)
else:
logging.basicConfig(level=level, filename=log_file)

ctx.obj = Options(
debug=debug,
Expand Down Expand Up @@ -194,6 +205,34 @@ 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=0,
help="Delay in seconds for server-side debounce",
)
def lsp(
ctx: click.Context,
stdio: bool,
tcp: Optional[int],
ws: Optional[int],
debounce_interval: float,
):
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 @@ -182,6 +182,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


@dataclass
class Config:
"""
Expand Down
162 changes: 162 additions & 0 deletions src/fixit/lsp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
# 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.

from pathlib import Path
from typing import Callable, Dict

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, debounce

from .api import fixit_bytes
from .config import generate_config
from .ftypes import Config, LspOptions, Options


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):
self.fixit_options = fixit_options
self.lsp_options = lsp_options
self.lsp = LanguageServer("fixit-lsp", __version__)

self._config_cache: Dict[Path, Config] = {}
self._debounced_validators: Dict[str, Callable[[int], None]] = {}

self.set_handlers()

def load_config(self, path: Path, bust_cache=False) -> Config:
"""
Cached fetch of fixit.toml(s) for fixit_bytes.
"""
if bust_cache or (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):
"""
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:
"""
Side-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 debounced_validator(self, uri: str) -> Callable[[int], None]:
"""
Per-URI debounced validation function. See: LSP.validate
"""
if uri not in self._debounced_validators:
self._debounced_validators[uri] = debounce(
self.lsp_options.debounce_interval
)(lambda version: self.validate(uri, version))
return self._debounced_validators[uri]

def set_handlers(self) -> None:
"""
Side-effect: mutates self.lsp to make it respond to
`textDocument/didOpen`, `textDocument/didChange`, `textDocument/formatting`
"""

@self.lsp.feature(TEXT_DOCUMENT_DID_OPEN)
def _(params: DidOpenTextDocumentParams):
self.debounced_validator(params.text_document.uri)(
params.text_document.version
)

@self.lsp.feature(TEXT_DOCUMENT_DID_CHANGE)
def _(params: DidChangeTextDocumentParams):
self.debounced_validator(params.text_document.uri)(
params.text_document.version
)

@self.lsp.feature(TEXT_DOCUMENT_FORMATTING)
def _(params: DocumentFormattingParams):
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:
"""
Side-effect: occupies the configured I/O channels.
"""
if self.lsp_options.stdio:
self.lsp.start_io()
if self.lsp_options.tcp:
self.lsp.start_tcp("localhost", self.lsp_options.tcp)
if self.lsp_options.ws:
self.lsp.start_ws("localhost", self.lsp_options.ws)
1 change: 1 addition & 0 deletions src/fixit/tests/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from .ftypes import TypesTest
from .rule import RuleTest, RunnerTest
from .smoke import SmokeTest
from .util import DebounceTest

add_lint_rule_tests_to_module(
globals(),
Expand Down
Loading

0 comments on commit d7e6512

Please sign in to comment.