Skip to content

Commit ccd8c57

Browse files
committed
feat: support fixes via code-actions
1 parent a80d932 commit ccd8c57

1 file changed

Lines changed: 193 additions & 10 deletions

File tree

pylsp_ruff/plugin.py

Lines changed: 193 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,32 +3,45 @@
33
import json
44
import logging
55
import sys
6+
import re
67
from pathlib import PurePath
78
from subprocess import PIPE, Popen
8-
from typing import Any
99

1010
from pylsp import hookimpl
1111
from pylsp._utils import find_parents
1212
from pylsp.config.config import Config
1313
from pylsp.workspace import Document, Workspace
1414

1515
from lsprotocol.types import (
16+
CodeAction,
17+
CodeActionContext,
18+
CodeActionKind,
1619
Diagnostic,
1720
DiagnosticSeverity,
1821
DiagnosticTag,
1922
Position,
2023
Range,
24+
TextEdit,
25+
WorkspaceEdit,
2126
)
2227

2328
from lsprotocol.converters import get_converter
2429

2530
from pylsp_ruff.ruff import Check as RuffCheck
31+
from pylsp_ruff.ruff import Fix as RuffFix
2632

2733
log = logging.getLogger(__name__)
2834
converter = get_converter()
2935

3036
DIAGNOSTIC_SOURCE = "ruff"
3137

38+
# shamelessly borrowed from:
39+
# https://github.com/charliermarsh/ruff-lsp/blob/2a0e2ea3afefdbf00810b8df91030c1c6b59d103/ruff_lsp/server.py#L214
40+
NOQA_REGEX = re.compile(
41+
r"(?i:# (?:(?:ruff|flake8): )?(?P<noqa>noqa))"
42+
r"(?::\s?(?P<codes>([A-Z]+[0-9]+(?:[,\s]+)?)+))?"
43+
)
44+
3245
UNNECESSITY_CODES = {
3346
"F401", # `module` imported but unused
3447
"F504", # % format unused named arguments
@@ -79,7 +92,7 @@ def pylsp_lint(workspace: Workspace, document: Document) -> list:
7992
-------
8093
List of dicts containing the diagnostics.
8194
"""
82-
checks = run_ruff(workspace, document)
95+
checks = run_ruff_check(workspace, document)
8396
diagnostics = [create_diagnostic(c) for c in checks]
8497
return converter.unstructure(diagnostics)
8598

@@ -118,7 +131,174 @@ def create_diagnostic(check: RuffCheck) -> Diagnostic:
118131
)
119132

120133

121-
def run_ruff(workspace: Workspace, document: Document) -> list[RuffCheck]:
134+
@hookimpl
135+
def pylsp_code_actions(
136+
config: Config,
137+
workspace: Workspace,
138+
document: Document,
139+
range: dict,
140+
context: dict,
141+
):
142+
_context = converter.structure(context, CodeActionContext)
143+
diagnostics = _context.diagnostics
144+
diagnostics_with_fixes = [d for d in diagnostics if d.data]
145+
146+
code_actions = []
147+
has_organize_imports = False
148+
149+
for diagnostic in diagnostics_with_fixes:
150+
fix = converter.structure(diagnostic.data, RuffFix)
151+
152+
if diagnostic.code == "I001":
153+
code_actions.append(
154+
create_organize_imports_code_action(document, diagnostic, fix)
155+
)
156+
has_organize_imports = True
157+
else:
158+
code_actions.extend(
159+
[
160+
create_fix_code_action(document, diagnostic, fix),
161+
create_disable_code_action(document, diagnostic),
162+
]
163+
)
164+
165+
checks = run_ruff_check(workspace, document)
166+
checks_with_fixes = [c for c in checks if c.fix]
167+
checks_organize_imports = [c for c in checks_with_fixes if c.code == "I001"]
168+
169+
if not has_organize_imports and checks_organize_imports:
170+
check = checks_organize_imports[0]
171+
fix = check.fix # type: ignore
172+
diagnostic = create_diagnostic(check)
173+
code_actions.append(
174+
create_organize_imports_code_action(document, diagnostic, fix),
175+
)
176+
177+
if checks_with_fixes:
178+
code_actions.append(
179+
create_fix_all_code_action(workspace, document),
180+
)
181+
182+
return converter.unstructure(code_actions)
183+
184+
185+
def create_fix_code_action(
186+
document: Document,
187+
diagnostic: Diagnostic,
188+
fix: RuffFix,
189+
) -> CodeAction:
190+
title = f"Ruff ({diagnostic.code}): {fix.message}"
191+
kind = CodeActionKind.QuickFix
192+
193+
text_edit = create_text_edit(fix)
194+
workspace_edit = WorkspaceEdit(changes={document.uri: [text_edit]})
195+
return CodeAction(
196+
title=title,
197+
kind=kind,
198+
diagnostics=[diagnostic],
199+
edit=workspace_edit,
200+
)
201+
202+
203+
def create_disable_code_action(
204+
document: Document,
205+
diagnostic: Diagnostic,
206+
) -> CodeAction:
207+
title = f"Ruff ({diagnostic.code}): Disable for this line"
208+
kind = CodeActionKind.QuickFix
209+
210+
line = document.lines[diagnostic.range.start.line].rstrip("\r\n")
211+
match = NOQA_REGEX.search(line)
212+
has_noqa = match is not None
213+
has_codes = match and match.group("codes") is not None
214+
# `foo # noqa: OLD` -> `foo # noqa: OLD,NEW`
215+
if has_noqa and has_codes:
216+
new_line = f"{line},{diagnostic.code}"
217+
# `foo # noqa` -> `foo # noqa: NEW`
218+
elif has_noqa:
219+
new_line = f"{line}: {diagnostic.code}"
220+
# `foo` -> `foo # noqa: NEW`
221+
else:
222+
new_line = f"{line} # noqa: {diagnostic.code}"
223+
224+
range = Range(
225+
start=Position(line=diagnostic.range.start.line, character=0),
226+
end=Position(line=diagnostic.range.start.line, character=len(line)),
227+
)
228+
text_edit = TextEdit(range=range, new_text=new_line)
229+
workspace_edit = WorkspaceEdit(changes={document.uri: [text_edit]})
230+
return CodeAction(
231+
title=title,
232+
kind=kind,
233+
diagnostics=[diagnostic],
234+
edit=workspace_edit,
235+
)
236+
237+
238+
def create_organize_imports_code_action(
239+
document: Document,
240+
diagnostic: Diagnostic,
241+
fix: RuffFix,
242+
) -> CodeAction:
243+
title = f"Ruff: {fix.message}"
244+
kind = CodeActionKind.SourceOrganizeImports
245+
246+
text_edit = create_text_edit(fix)
247+
workspace_edit = WorkspaceEdit(changes={document.uri: [text_edit]})
248+
return CodeAction(
249+
title=title,
250+
kind=kind,
251+
diagnostics=[diagnostic],
252+
edit=workspace_edit,
253+
)
254+
255+
256+
def create_fix_all_code_action(
257+
workspace: Workspace,
258+
document: Document,
259+
) -> CodeAction:
260+
title = "Ruff: Fix All"
261+
kind = CodeActionKind.SourceFixAll
262+
263+
new_text = run_ruff_fix(workspace, document)
264+
range = Range(
265+
start=Position(line=0, character=0),
266+
end=Position(line=len(document.lines), character=0),
267+
)
268+
text_edit = TextEdit(range=range, new_text=new_text)
269+
workspace_edit = WorkspaceEdit(changes={document.uri: [text_edit]})
270+
return CodeAction(
271+
title=title,
272+
kind=kind,
273+
edit=workspace_edit,
274+
)
275+
276+
277+
def create_text_edit(fix: RuffFix) -> TextEdit:
278+
range = Range(
279+
start=Position(
280+
line=fix.location.row - 1,
281+
character=fix.location.column, # yes, no -1
282+
),
283+
end=Position(
284+
line=fix.end_location.row - 1,
285+
character=fix.end_location.column, # yes, no -1
286+
),
287+
)
288+
return TextEdit(range=range, new_text=fix.content)
289+
290+
291+
def run_ruff_check(workspace: Workspace, document: Document) -> list[RuffCheck]:
292+
result = run_ruff(workspace, document)
293+
return converter.structure(json.loads(result), list[RuffCheck])
294+
295+
296+
def run_ruff_fix(workspace: Workspace, document: Document) -> str:
297+
result = run_ruff(workspace, document, fix=True)
298+
return result
299+
300+
301+
def run_ruff(workspace: Workspace, document: Document, fix: bool = False) -> str:
122302
"""
123303
Run ruff on the given document and the given arguments.
124304
@@ -128,16 +308,16 @@ def run_ruff(workspace: Workspace, document: Document) -> list[RuffCheck]:
128308
Workspace to run ruff in.
129309
document : pylsp.workspace.Document
130310
File to run ruff on.
131-
arguments : list
132-
Arguments to provide for ruff.
311+
fix : bool
312+
Whether to run fix or no-fix.
133313
134314
Returns
135315
-------
136316
String containing the result in json format.
137317
"""
138318
config = load_config(workspace, document)
139319
executable = config.pop("executable")
140-
arguments = build_arguments(document, config)
320+
arguments = build_arguments(document, config, fix)
141321

142322
log.debug(f"Calling {executable} with args: {arguments} on '{document.path}'")
143323
try:
@@ -154,10 +334,10 @@ def run_ruff(workspace: Workspace, document: Document) -> list[RuffCheck]:
154334
if stderr:
155335
log.error(f"Error running ruff: {stderr.decode()}")
156336

157-
return converter.structure(json.loads(stdout), list[RuffCheck])
337+
return stdout.decode()
158338

159339

160-
def build_arguments(document: Document, options: dict) -> list[str]:
340+
def build_arguments(document: Document, options: dict, fix: bool = False) -> list[str]:
161341
"""
162342
Build arguments for ruff.
163343
@@ -176,8 +356,11 @@ def build_arguments(document: Document, options: dict) -> list[str]:
176356
args = ["--quiet"]
177357
# Use the json formatting for easier evaluation
178358
args.extend(["--format=json"])
179-
# Do not attempt to fix -> returns file instead of diagnostics
180-
args.extend(["--no-fix"])
359+
if fix:
360+
args.extend(["--fix"])
361+
else:
362+
# Do not attempt to fix -> returns file instead of diagnostics
363+
args.extend(["--no-fix"])
181364
# Always force excludes
182365
args.extend(["--force-exclude"])
183366
# Pass filename to ruff for per-file-ignores, catch unsaved

0 commit comments

Comments
 (0)