Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Run ruff format when lsp formatting is invoked #56

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 92 additions & 15 deletions pylsp_ruff/plugin.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import enum
import json
import logging
import re
Expand Down Expand Up @@ -45,6 +46,7 @@
r"(?::\s?(?P<codes>([A-Z]+[0-9]+(?:[,\s]+)?)+))?"
)


UNNECESSITY_CODES = {
"F401", # `module` imported but unused
"F504", # % format unused named arguments
Expand All @@ -60,6 +62,31 @@
"H": DiagnosticSeverity.Hint,
}

ISORT_FIXES = "I"


class Subcommand(enum.StrEnum):
CHECK = enum.auto()
FORMAT = enum.auto()

def build_args(
self,
document_path: str,
settings: PluginSettings,
fix: bool = False,
extra_arguments: Optional[List[str]] = None,
) -> List[str]:
match self:

Check notice on line 79 in pylsp_ruff/plugin.py

View workflow job for this annotation

GitHub Actions / build (3.7)

Ruff

(E999) SyntaxError: invalid syntax. Got unexpected token 'self'

Check notice on line 79 in pylsp_ruff/plugin.py

View workflow job for this annotation

GitHub Actions / build (3.8)

Ruff

(E999) SyntaxError: invalid syntax. Got unexpected token 'self'
case Subcommand.CHECK:
return build_check_arguments(
document_path, settings, fix, extra_arguments
)
case Subcommand.FORMAT:
return build_format_arguments(document_path, settings, extra_arguments)
case _:
logging.warn(f"subcommand without argument builder '{self}'")
return []


@hookimpl
def pylsp_settings():
Expand Down Expand Up @@ -103,8 +130,16 @@
settings=settings, document_path=document.path, document_source=source
)

settings.select = [ISORT_FIXES] # clobber to just run import sorting
new_text = run_ruff(
settings=settings,
document_path=document.path,
document_source=new_text,
fix=True,
)

# Avoid applying empty text edit
if new_text == source:
if not new_text or new_text == source:
return

range = Range(
Expand Down Expand Up @@ -395,6 +430,7 @@
document_path=document.path,
document_source=document.source,
settings=settings,
subcommand=Subcommand.CHECK,
)
try:
result = json.loads(result)
Expand All @@ -418,26 +454,19 @@
document_path: str,
document_source: str,
) -> str:
fixable_codes = ["I"]
if settings.format:
fixable_codes.extend(settings.format)
extra_arguments = [
f"--fixable={','.join(fixable_codes)}",
]
result = run_ruff(
return run_ruff(
settings=settings,
document_path=document_path,
document_source=document_source,
fix=True,
extra_arguments=extra_arguments,
subcommand=Subcommand.FORMAT,
)
return result


def run_ruff(
settings: PluginSettings,
document_path: str,
document_source: str,
subcommand: Subcommand = Subcommand.CHECK,
fix: bool = False,
extra_arguments: Optional[List[str]] = None,
) -> str:
Expand All @@ -453,6 +482,8 @@
document_source : str
Document source or to apply ruff on.
Needed when the source differs from the file source, e.g. during formatting.
subcommand: Subcommand
The ruff subcommand to run. Default = Subcommand.CHECK.
fix : bool
Whether to run fix or no-fix.
extra_arguments : List[str]
Expand All @@ -463,7 +494,8 @@
String containing the result in json format.
"""
executable = settings.executable
arguments = build_arguments(document_path, settings, fix, extra_arguments)

arguments = subcommand.build_args(document_path, settings, fix, extra_arguments)

if executable is not None:
log.debug(f"Calling {executable} with args: {arguments} on '{document_path}'")
Expand All @@ -474,7 +506,7 @@
except Exception:
log.error(f"Can't execute ruff with given executable '{executable}'.")
else:
cmd = [sys.executable, "-m", "ruff"]
cmd = [sys.executable, "-m", "ruff", str(subcommand)]
cmd.extend(arguments)
p = Popen(cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE)
(stdout, stderr) = p.communicate(document_source.encode())
Expand All @@ -485,14 +517,14 @@
return stdout.decode()


def build_arguments(
def build_check_arguments(
document_path: str,
settings: PluginSettings,
fix: bool = False,
extra_arguments: Optional[List[str]] = None,
) -> List[str]:
"""
Build arguments for ruff.
Build arguments for ruff check.

Parameters
----------
Expand Down Expand Up @@ -565,6 +597,51 @@
return args


def build_format_arguments(
document_path: str,
settings: PluginSettings,
extra_arguments: Optional[List[str]] = None,
) -> List[str]:
"""
Build arguments for ruff format.

Parameters
----------
document : pylsp.workspace.Document
Document to apply ruff on.
settings : PluginSettings
Settings to use for arguments to pass to ruff.
extra_arguments : List[str]
Extra arguments to pass to ruff.

Returns
-------
List containing the arguments.
"""
args = []
# Suppress update announcements
args.append("--quiet")

# Always force excludes
args.append("--force-exclude")
# Pass filename to ruff for per-file-ignores, catch unsaved
if document_path != "":
args.append(f"--stdin-filename={document_path}")

if settings.config:
args.append(f"--config={settings.config}")

if settings.exclude:
args.append(f"--exclude={','.join(settings.exclude)}")

if extra_arguments:
args.extend(extra_arguments)

args.extend(["--", "-"])

return args


def load_settings(workspace: Workspace, document_path: str) -> PluginSettings:
"""
Load settings from pyproject.toml file in the project path.
Expand Down
34 changes: 0 additions & 34 deletions tests/test_code_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,37 +148,3 @@ def f():
settings = ruff_lint.load_settings(workspace, doc.path)
fixed_str = ruff_lint.run_ruff_fix(doc, settings)
assert fixed_str == expected_str_safe


def test_format_document_default_settings(workspace):
_, doc = temp_document(import_str, workspace)
settings = ruff_lint.load_settings(workspace, doc.path)
formatted_str = ruff_lint.run_ruff_format(
settings, document_path=doc.path, document_source=doc.source
)
assert formatted_str == import_str


def test_format_document_settings(workspace):
expected_str = dedent(
"""
import os
import pathlib
"""
)
workspace._config.update(
{
"plugins": {
"ruff": {
"select": ["I"],
"format": ["I001"],
}
}
}
)
_, doc = temp_document(import_str, workspace)
settings = ruff_lint.load_settings(workspace, doc.path)
formatted_str = ruff_lint.run_ruff_format(
settings, document_path=doc.path, document_source=doc.source
)
assert formatted_str == expected_str
91 changes: 91 additions & 0 deletions tests/test_ruff_format.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import contextlib
import tempfile
import textwrap as tw
from typing import Any, Mapping, Optional
from unittest.mock import Mock

import pytest
from pylsp import uris
from pylsp.config.config import Config
from pylsp.workspace import Document, Workspace

import pylsp_ruff.plugin as plugin


@pytest.fixture()
def workspace(tmp_path):
"""Return a workspace."""
ws = Workspace(tmp_path.absolute().as_uri(), Mock())
ws._config = Config(ws.root_uri, {}, 0, {})
return ws


def temp_document(doc_text, workspace):
with tempfile.NamedTemporaryFile(
mode="w", dir=workspace.root_path, delete=False
) as temp_file:
name = temp_file.name
temp_file.write(doc_text)
doc = Document(uris.from_fs_path(name), workspace)
return name, doc


def run_plugin_format(workspace: Workspace, doc: Document) -> str:
class TestResult:
result: Optional[list[Mapping[str, Any]]]

def __init__(self):
self.result = None

def get_result(self):
return self.result

def force_result(self, r):
self.result = r

generator = plugin.pylsp_format_document(workspace, doc)
result = TestResult()
with contextlib.suppress(StopIteration):
generator.send(None)
generator.send(result)

if result.result:
return result.result[0]["newText"]
pytest.fail()


def test_ruff_format(workspace):
# imports incorrectly ordered, body of foo has a line that's too long, def bar() line missing whitespace above

Check notice on line 58 in tests/test_ruff_format.py

View workflow job for this annotation

GitHub Actions / build (3.7)

Ruff

(E501) Line too long (114 > 88 characters)

Check notice on line 58 in tests/test_ruff_format.py

View workflow job for this annotation

GitHub Actions / build (3.8)

Ruff

(E501) Line too long (114 > 88 characters)
txt = tw.dedent(
"""
from thirdparty import x
import io
import asyncio

def foo():
print("this is a looooooooooooooooooooooooooooooooooooooooooooong line that should exceed the usual line-length limit which is normaly eighty-eight columns")

Check notice on line 66 in tests/test_ruff_format.py

View workflow job for this annotation

GitHub Actions / build (3.7)

Ruff

(E501) Line too long (169 > 88 characters)

Check notice on line 66 in tests/test_ruff_format.py

View workflow job for this annotation

GitHub Actions / build (3.8)

Ruff

(E501) Line too long (169 > 88 characters)
def bar():
pass
"""
).lstrip()
want = tw.dedent(
"""
import asyncio
import io

from thirdparty import x


def foo():
print(
"this is a looooooooooooooooooooooooooooooooooooooooooooong line that should exceed the usual line-length limit which is normaly eighty-eight columns"

Check notice on line 81 in tests/test_ruff_format.py

View workflow job for this annotation

GitHub Actions / build (3.7)

Ruff

(E501) Line too long (166 > 88 characters)

Check notice on line 81 in tests/test_ruff_format.py

View workflow job for this annotation

GitHub Actions / build (3.8)

Ruff

(E501) Line too long (166 > 88 characters)
)


def bar():
pass
"""
).lstrip()
_, doc = temp_document(txt, workspace)
got = run_plugin_format(workspace, doc)
assert want == got, f"want:\n{want}\n\ngot:\n{got}"
1 change: 1 addition & 0 deletions tests/test_ruff_lint.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ def f():
str(sys.executable),
"-m",
"ruff",
"check",
"--quiet",
"--exit-zero",
"--output-format=json",
Expand Down
Loading