33import json
44import logging
55import sys
6+ import re
67from pathlib import PurePath
78from subprocess import PIPE , Popen
8- from typing import Any
99
1010from pylsp import hookimpl
1111from pylsp ._utils import find_parents
1212from pylsp .config .config import Config
1313from pylsp .workspace import Document , Workspace
1414
1515from 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
2328from lsprotocol .converters import get_converter
2429
2530from pylsp_ruff .ruff import Check as RuffCheck
31+ from pylsp_ruff .ruff import Fix as RuffFix
2632
2733log = logging .getLogger (__name__ )
2834converter = get_converter ()
2935
3036DIAGNOSTIC_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+
3245UNNECESSITY_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