Skip to content

Commit 9ff905c

Browse files
authored
Run ruff format when lsp formatting is invoked (#57)
* Run `ruff format` when lsp formatting is invoked Adds the Subcommand enum to indicate which `ruff` subcommand should be executed by `run_ruff`. At this time, only `check` and `format` are supported. As different subcommands support different parameters, argument generation is delegated based on the specific subcommand value. The `ruff format` subcommand does not currently organize imports and there does not appear to be a way to convince it to do so. Until a unified command exists the approach taken here is to format and then make a second run of `ruff check` that _only_ performs import formatting. * Preserve compatibility with `format` settings Codes listed in this setting should be included in fixes performed as part of a formatting pass. * Make import sorting opt-in
1 parent eff74e1 commit 9ff905c

File tree

5 files changed

+209
-50
lines changed

5 files changed

+209
-50
lines changed

README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ the valid configuration keys:
7878
- `pylsp.plugins.ruff.perFileIgnores`: File-specific error codes to be ignored.
7979
- `pylsp.plugins.ruff.select`: List of error codes to enable.
8080
- `pylsp.plugins.ruff.extendSelect`: Same as select, but append to existing error codes.
81-
- `pylsp.plugins.ruff.format`: List of error codes to fix during formatting. The default is `["I"]`, any additional codes are appended to this list.
81+
- `pylsp.plugins.ruff.format`: List of error codes to fix during formatting. Empty by default, use `["I"]` here to get import sorting as part of formatting.
8282
- `pylsp.plugins.ruff.unsafeFixes`: boolean that enables/disables fixes that are marked "unsafe" by `ruff`. `false` by default.
8383
- `pylsp.plugins.ruff.severities`: Dictionary of custom severity levels for specific codes, see [below](#custom-severities).
8484

pylsp_ruff/plugin.py

+93-15
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import enum
12
import json
23
import logging
34
import re
@@ -45,6 +46,7 @@
4546
r"(?::\s?(?P<codes>([A-Z]+[0-9]+(?:[,\s]+)?)+))?"
4647
)
4748

49+
4850
UNNECESSITY_CODES = {
4951
"F401", # `module` imported but unused
5052
"F504", # % format unused named arguments
@@ -61,6 +63,29 @@
6163
}
6264

6365

66+
class Subcommand(str, enum.Enum):
67+
CHECK = "check"
68+
FORMAT = "format"
69+
70+
def __str__(self) -> str:
71+
return self.value
72+
73+
def build_args(
74+
self,
75+
document_path: str,
76+
settings: PluginSettings,
77+
fix: bool = False,
78+
extra_arguments: Optional[List[str]] = None,
79+
) -> List[str]:
80+
if self == Subcommand.CHECK:
81+
return build_check_arguments(document_path, settings, fix, extra_arguments)
82+
elif self == Subcommand.FORMAT:
83+
return build_format_arguments(document_path, settings, extra_arguments)
84+
else:
85+
logging.warn(f"subcommand without argument builder '{self}'")
86+
return []
87+
88+
6489
@hookimpl
6590
def pylsp_settings():
6691
log.debug("Initializing pylsp_ruff")
@@ -103,8 +128,19 @@ def pylsp_format_document(workspace: Workspace, document: Document) -> Generator
103128
settings=settings, document_path=document.path, document_source=source
104129
)
105130

131+
if settings.format:
132+
# A second pass through the document with `ruff check` and only the rules
133+
# enabled via the format config property. This allows for things like
134+
# specifying `format = ["I"]` to get import sorting as part of formatting.
135+
new_text = run_ruff(
136+
settings=PluginSettings(ignore=["ALL"], select=settings.format),
137+
document_path=document.path,
138+
document_source=new_text,
139+
fix=True,
140+
)
141+
106142
# Avoid applying empty text edit
107-
if new_text == source:
143+
if not new_text or new_text == source:
108144
return
109145

110146
range = Range(
@@ -399,6 +435,7 @@ def run_ruff_check(document: Document, settings: PluginSettings) -> List[RuffChe
399435
document_path=document.path,
400436
document_source=document.source,
401437
settings=settings,
438+
subcommand=Subcommand.CHECK,
402439
)
403440
try:
404441
result = json.loads(result)
@@ -422,26 +459,19 @@ def run_ruff_format(
422459
document_path: str,
423460
document_source: str,
424461
) -> str:
425-
fixable_codes = ["I"]
426-
if settings.format:
427-
fixable_codes.extend(settings.format)
428-
extra_arguments = [
429-
f"--fixable={','.join(fixable_codes)}",
430-
]
431-
result = run_ruff(
462+
return run_ruff(
432463
settings=settings,
433464
document_path=document_path,
434465
document_source=document_source,
435-
fix=True,
436-
extra_arguments=extra_arguments,
466+
subcommand=Subcommand.FORMAT,
437467
)
438-
return result
439468

440469

441470
def run_ruff(
442471
settings: PluginSettings,
443472
document_path: str,
444473
document_source: str,
474+
subcommand: Subcommand = Subcommand.CHECK,
445475
fix: bool = False,
446476
extra_arguments: Optional[List[str]] = None,
447477
) -> str:
@@ -457,6 +487,8 @@ def run_ruff(
457487
document_source : str
458488
Document source or to apply ruff on.
459489
Needed when the source differs from the file source, e.g. during formatting.
490+
subcommand: Subcommand
491+
The ruff subcommand to run. Default = Subcommand.CHECK.
460492
fix : bool
461493
Whether to run fix or no-fix.
462494
extra_arguments : List[str]
@@ -467,7 +499,8 @@ def run_ruff(
467499
String containing the result in json format.
468500
"""
469501
executable = settings.executable
470-
arguments = build_arguments(document_path, settings, fix, extra_arguments)
502+
503+
arguments = subcommand.build_args(document_path, settings, fix, extra_arguments)
471504

472505
if executable is not None:
473506
log.debug(f"Calling {executable} with args: {arguments} on '{document_path}'")
@@ -478,7 +511,7 @@ def run_ruff(
478511
except Exception:
479512
log.error(f"Can't execute ruff with given executable '{executable}'.")
480513
else:
481-
cmd = [sys.executable, "-m", "ruff"]
514+
cmd = [sys.executable, "-m", "ruff", str(subcommand)]
482515
cmd.extend(arguments)
483516
p = Popen(cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE)
484517
(stdout, stderr) = p.communicate(document_source.encode())
@@ -489,14 +522,14 @@ def run_ruff(
489522
return stdout.decode()
490523

491524

492-
def build_arguments(
525+
def build_check_arguments(
493526
document_path: str,
494527
settings: PluginSettings,
495528
fix: bool = False,
496529
extra_arguments: Optional[List[str]] = None,
497530
) -> List[str]:
498531
"""
499-
Build arguments for ruff.
532+
Build arguments for ruff check.
500533
501534
Parameters
502535
----------
@@ -569,6 +602,51 @@ def build_arguments(
569602
return args
570603

571604

605+
def build_format_arguments(
606+
document_path: str,
607+
settings: PluginSettings,
608+
extra_arguments: Optional[List[str]] = None,
609+
) -> List[str]:
610+
"""
611+
Build arguments for ruff format.
612+
613+
Parameters
614+
----------
615+
document : pylsp.workspace.Document
616+
Document to apply ruff on.
617+
settings : PluginSettings
618+
Settings to use for arguments to pass to ruff.
619+
extra_arguments : List[str]
620+
Extra arguments to pass to ruff.
621+
622+
Returns
623+
-------
624+
List containing the arguments.
625+
"""
626+
args = []
627+
# Suppress update announcements
628+
args.append("--quiet")
629+
630+
# Always force excludes
631+
args.append("--force-exclude")
632+
# Pass filename to ruff for per-file-ignores, catch unsaved
633+
if document_path != "":
634+
args.append(f"--stdin-filename={document_path}")
635+
636+
if settings.config:
637+
args.append(f"--config={settings.config}")
638+
639+
if settings.exclude:
640+
args.append(f"--exclude={','.join(settings.exclude)}")
641+
642+
if extra_arguments:
643+
args.extend(extra_arguments)
644+
645+
args.extend(["--", "-"])
646+
647+
return args
648+
649+
572650
def load_settings(workspace: Workspace, document_path: str) -> PluginSettings:
573651
"""
574652
Load settings from pyproject.toml file in the project path.

tests/test_code_actions.py

-34
Original file line numberDiff line numberDiff line change
@@ -150,37 +150,3 @@ def f():
150150
settings = ruff_lint.load_settings(workspace, doc.path)
151151
fixed_str = ruff_lint.run_ruff_fix(doc, settings)
152152
assert fixed_str == expected_str_safe
153-
154-
155-
def test_format_document_default_settings(workspace):
156-
_, doc = temp_document(import_str, workspace)
157-
settings = ruff_lint.load_settings(workspace, doc.path)
158-
formatted_str = ruff_lint.run_ruff_format(
159-
settings, document_path=doc.path, document_source=doc.source
160-
)
161-
assert formatted_str == import_str
162-
163-
164-
def test_format_document_settings(workspace):
165-
expected_str = dedent(
166-
"""
167-
import os
168-
import pathlib
169-
"""
170-
)
171-
workspace._config.update(
172-
{
173-
"plugins": {
174-
"ruff": {
175-
"select": ["I"],
176-
"format": ["I001"],
177-
}
178-
}
179-
}
180-
)
181-
_, doc = temp_document(import_str, workspace)
182-
settings = ruff_lint.load_settings(workspace, doc.path)
183-
formatted_str = ruff_lint.run_ruff_format(
184-
settings, document_path=doc.path, document_source=doc.source
185-
)
186-
assert formatted_str == expected_str

tests/test_ruff_format.py

+114
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import contextlib
2+
import tempfile
3+
import textwrap as tw
4+
from typing import Any, List, Mapping, Optional
5+
from unittest.mock import Mock
6+
7+
import pytest
8+
from pylsp import uris
9+
from pylsp.config.config import Config
10+
from pylsp.workspace import Document, Workspace
11+
12+
import pylsp_ruff.plugin as plugin
13+
14+
_UNSORTED_IMPORTS = tw.dedent(
15+
"""
16+
from thirdparty import x
17+
import io
18+
import asyncio
19+
"""
20+
).strip()
21+
22+
_SORTED_IMPORTS = tw.dedent(
23+
"""
24+
import asyncio
25+
import io
26+
27+
from thirdparty import x
28+
"""
29+
).strip()
30+
31+
_UNFORMATTED_CODE = tw.dedent(
32+
"""
33+
def foo(): pass
34+
def bar(): pass
35+
"""
36+
).strip()
37+
38+
_FORMATTED_CODE = tw.dedent(
39+
"""
40+
def foo():
41+
pass
42+
43+
44+
def bar():
45+
pass
46+
"""
47+
).strip()
48+
49+
50+
@pytest.fixture()
51+
def workspace(tmp_path):
52+
"""Return a workspace."""
53+
ws = Workspace(tmp_path.absolute().as_uri(), Mock())
54+
ws._config = Config(ws.root_uri, {}, 0, {})
55+
return ws
56+
57+
58+
def temp_document(doc_text, workspace):
59+
with tempfile.NamedTemporaryFile(
60+
mode="w", dir=workspace.root_path, delete=False
61+
) as temp_file:
62+
name = temp_file.name
63+
temp_file.write(doc_text)
64+
doc = Document(uris.from_fs_path(name), workspace)
65+
return name, doc
66+
67+
68+
def run_plugin_format(workspace: Workspace, doc: Document) -> str:
69+
class TestResult:
70+
result: Optional[List[Mapping[str, Any]]]
71+
72+
def __init__(self):
73+
self.result = None
74+
75+
def get_result(self):
76+
return self.result
77+
78+
def force_result(self, r):
79+
self.result = r
80+
81+
generator = plugin.pylsp_format_document(workspace, doc)
82+
result = TestResult()
83+
with contextlib.suppress(StopIteration):
84+
generator.send(None)
85+
generator.send(result)
86+
87+
if result.result:
88+
return result.result[0]["newText"]
89+
return pytest.fail()
90+
91+
92+
def test_ruff_format_only(workspace):
93+
txt = f"{_UNSORTED_IMPORTS}\n{_UNFORMATTED_CODE}"
94+
want = f"{_UNSORTED_IMPORTS}\n\n\n{_FORMATTED_CODE}\n"
95+
_, doc = temp_document(txt, workspace)
96+
got = run_plugin_format(workspace, doc)
97+
assert want == got
98+
99+
100+
def test_ruff_format_and_sort_imports(workspace):
101+
txt = f"{_UNSORTED_IMPORTS}\n{_UNFORMATTED_CODE}"
102+
want = f"{_SORTED_IMPORTS}\n\n\n{_FORMATTED_CODE}\n"
103+
_, doc = temp_document(txt, workspace)
104+
workspace._config.update(
105+
{
106+
"plugins": {
107+
"ruff": {
108+
"format": ["I001"],
109+
}
110+
}
111+
}
112+
)
113+
got = run_plugin_format(workspace, doc)
114+
assert want == got

tests/test_ruff_lint.py

+1
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ def f():
178178
str(sys.executable),
179179
"-m",
180180
"ruff",
181+
"check",
181182
"--quiet",
182183
"--exit-zero",
183184
"--output-format=json",

0 commit comments

Comments
 (0)