Coverage for src/debputy/lsp/quickfixes.py: 46%
128 statements
« prev ^ index » next coverage.py v7.8.2, created at 2025-10-12 15:06 +0000
« prev ^ index » next coverage.py v7.8.2, created at 2025-10-12 15:06 +0000
1from typing import (
2 Literal,
3 TypedDict,
4 Union,
5 TypeVar,
6 Dict,
7 Optional,
8 List,
9 cast,
10 NotRequired,
11 TYPE_CHECKING,
12)
13from collections.abc import Callable, Iterable, Mapping
15from debputy.lsp.diagnostics import DiagnosticData
16from debputy.util import _warn
18if TYPE_CHECKING:
19 import lsprotocol.types as types
20 from debputy.linting.lint_util import LintState
21else:
22 import debputy.lsprotocol.types as types
25try:
26 from debputy.lsp.vendoring._deb822_repro.locatable import (
27 Position as TEPosition,
28 Range as TERange,
29 )
31 from pygls.server import LanguageServer
32 from pygls.workspace import TextDocument
33 from debputy.lsp.debputy_ls import DebputyLanguageServer
34except ImportError:
35 pass
38CodeActionName = Literal[
39 "correct-text",
40 "remove-line",
41 "remove-range",
42 "insert-text-on-line-after-diagnostic",
43]
46class CorrectTextCodeAction(TypedDict):
47 code_action: Literal["correct-text"]
48 correct_value: str
49 proposed_title: NotRequired[str]
52class InsertTextOnLineAfterDiagnosticCodeAction(TypedDict):
53 code_action: Literal["insert-text-on-line-after-diagnostic"]
54 text_to_insert: str
57class RemoveLineCodeAction(TypedDict):
58 code_action: Literal["remove-line"]
61class RemoveRangeCodeAction(TypedDict):
62 code_action: Literal["remove-range"]
63 proposed_title: NotRequired[str]
66def propose_correct_text_quick_fix(
67 correct_value: str,
68 *,
69 proposed_title: str | None = None,
70) -> CorrectTextCodeAction:
71 r: CorrectTextCodeAction = {
72 "code_action": "correct-text",
73 "correct_value": correct_value,
74 }
75 if proposed_title:
76 r["proposed_title"] = proposed_title
77 return r
80def propose_insert_text_on_line_after_diagnostic_quick_fix(
81 text_to_insert: str,
82) -> InsertTextOnLineAfterDiagnosticCodeAction:
83 return {
84 "code_action": "insert-text-on-line-after-diagnostic",
85 "text_to_insert": text_to_insert,
86 }
89def propose_remove_line_quick_fix() -> RemoveLineCodeAction:
90 return {
91 "code_action": "remove-line",
92 }
95def propose_remove_range_quick_fix(
96 *,
97 proposed_title: str | None = None,
98) -> RemoveRangeCodeAction:
99 r: RemoveRangeCodeAction = {
100 "code_action": "remove-range",
101 }
102 if proposed_title:
103 r["proposed_title"] = proposed_title
104 return r
107CODE_ACTION_HANDLERS: dict[
108 CodeActionName,
109 Callable[
110 ["LintState", Mapping[str, str], types.CodeActionParams, types.Diagnostic],
111 Iterable[types.CodeAction | types.Command],
112 ],
113] = {}
114M = TypeVar("M", bound=Mapping[str, str])
115Handler = Callable[
116 ["LintState", M, types.CodeActionParams, types.Diagnostic],
117 Iterable[Union[types.CodeAction, types.Command]],
118]
121def _code_handler_for(action_name: CodeActionName) -> Callable[[Handler], Handler]:
122 def _wrapper(func: Handler) -> Handler:
123 assert action_name not in CODE_ACTION_HANDLERS
124 CODE_ACTION_HANDLERS[action_name] = func
125 return func
127 return _wrapper
130@_code_handler_for("correct-text")
131def _correct_value_code_action(
132 lint_state: "LintState",
133 code_action_data: CorrectTextCodeAction,
134 code_action_params: types.CodeActionParams,
135 diagnostic: types.Diagnostic,
136) -> Iterable[types.CodeAction | types.Command]:
137 corrected_value = code_action_data["correct_value"]
138 title = code_action_data.get("proposed_title")
139 if not title:
140 title = f'Replace with "{corrected_value}"'
141 yield _simple_quick_fix(
142 lint_state,
143 code_action_params,
144 title,
145 [diagnostic],
146 diagnostic.range,
147 corrected_value,
148 )
151@_code_handler_for("insert-text-on-line-after-diagnostic")
152def _insert_text_on_line_after_diagnostic_code_action(
153 lint_state: "LintState",
154 code_action_data: InsertTextOnLineAfterDiagnosticCodeAction,
155 code_action_params: types.CodeActionParams,
156 diagnostic: types.Diagnostic,
157) -> Iterable[types.CodeAction | types.Command]:
158 corrected_value = code_action_data["text_to_insert"]
159 line_no = diagnostic.range.end.line
160 if diagnostic.range.end.character > 0:
161 line_no += 1
162 insert_range = types.Range(
163 types.Position(
164 line_no,
165 0,
166 ),
167 types.Position(
168 line_no,
169 0,
170 ),
171 )
172 yield _simple_quick_fix(
173 lint_state,
174 code_action_params,
175 f'Insert "{corrected_value}"',
176 [diagnostic],
177 insert_range,
178 corrected_value,
179 )
182def _simple_quick_fix(
183 lint_state: "LintState",
184 _code_action_params: types.CodeActionParams,
185 title: str,
186 diagnostics: list[types.Diagnostic],
187 affected_range: types.Range,
188 replacement_text: str,
189) -> types.CodeAction:
190 doc_uri = lint_state.doc_uri
191 edit = types.TextEdit(
192 affected_range,
193 replacement_text,
194 )
195 if lint_state.workspace_text_edit_support.supports_document_changes: 195 ↛ 208line 195 didn't jump to line 208 because the condition on line 195 was always true
196 ws_edit = types.WorkspaceEdit(
197 document_changes=[
198 types.TextDocumentEdit(
199 text_document=types.OptionalVersionedTextDocumentIdentifier(
200 doc_uri,
201 lint_state.doc_version,
202 ),
203 edits=[edit],
204 )
205 ]
206 )
207 else:
208 ws_edit = types.WorkspaceEdit(
209 changes={doc_uri: [edit]},
210 )
211 return types.CodeAction(
212 title=title,
213 kind=types.CodeActionKind.QuickFix,
214 diagnostics=diagnostics,
215 edit=ws_edit,
216 )
219def range_compatible_with_remove_line_fix(range_: types.Range | TERange) -> bool:
220 if isinstance(range_, TERange):
221 start = range_.start_pos
222 end = range_.end_pos
223 if start.line_position != end.line_position and (
224 start.line_position + 1 != end.line_position or end.cursor_position > 0
225 ):
226 return False
227 else:
228 start = range_.start
229 end = range_.end
230 if start.line != end.line and (start.line + 1 != end.line or end.character > 0):
231 return False
232 return True
235@_code_handler_for("remove-line")
236def _remove_line_code_action(
237 lint_state: "LintState",
238 _code_action_data: RemoveLineCodeAction,
239 code_action_params: types.CodeActionParams,
240 diagnostic: types.Diagnostic,
241) -> Iterable[types.CodeAction | types.Command]:
242 start = code_action_params.range.start
243 if range_compatible_with_remove_line_fix(code_action_params.range):
244 _warn(
245 "Bug: the quick was used for a diagnostic that spanned multiple lines and would corrupt the file."
246 )
247 return
249 delete_range = types.Range(
250 start=types.Position(
251 line=start.line,
252 character=0,
253 ),
254 end=types.Position(
255 line=start.line + 1,
256 character=0,
257 ),
258 )
259 yield _simple_quick_fix(
260 lint_state,
261 code_action_params,
262 "Remove the line",
263 [diagnostic],
264 delete_range,
265 "",
266 )
269@_code_handler_for("remove-range")
270def _remove_range_code_action(
271 lint_state: "LintState",
272 code_action_data: RemoveRangeCodeAction,
273 code_action_params: types.CodeActionParams,
274 diagnostic: types.Diagnostic,
275) -> Iterable[types.CodeAction | types.Command]:
276 title = code_action_data.get("proposed_title", "Delete")
277 yield _simple_quick_fix(
278 lint_state,
279 code_action_params,
280 title,
281 [diagnostic],
282 diagnostic.range,
283 "",
284 )
287def accepts_quickfixes(
288 code_action_params: types.CodeActionParams,
289) -> bool:
290 only = code_action_params.context.only
291 if not only:
292 return True
293 return types.CodeActionKind.QuickFix in only
296def provide_standard_quickfixes_from_diagnostics_ls(
297 ls: "DebputyLanguageServer",
298 code_action_params: types.CodeActionParams,
299) -> list[types.Command | types.CodeAction] | None:
300 if not accepts_quickfixes(code_action_params):
301 return None
302 doc_uri = code_action_params.text_document.uri
303 matched_diagnostics = ls.diagnostics_in_range(
304 doc_uri,
305 code_action_params.range,
306 )
307 if not matched_diagnostics:
308 return None
309 doc = ls.workspace.get_text_document(doc_uri)
310 return _provide_standard_quickfixes_from_diagnostics(
311 ls.lint_state(doc),
312 code_action_params,
313 matched_diagnostics,
314 )
317def provide_standard_quickfixes_from_diagnostics_lint(
318 lint_state: "LintState",
319 code_action_params: types.CodeActionParams,
320) -> list[types.Command | types.CodeAction] | None:
321 return _provide_standard_quickfixes_from_diagnostics(
322 lint_state,
323 code_action_params,
324 code_action_params.context.diagnostics,
325 )
328def _provide_standard_quickfixes_from_diagnostics(
329 lint_state: "LintState",
330 code_action_params: types.CodeActionParams,
331 matched_diagnostics: list[types.Diagnostic],
332) -> list[types.Command | types.CodeAction] | None:
333 actions: list[types.Command | types.CodeAction] = []
334 for diagnostic in matched_diagnostics:
335 if not isinstance(diagnostic.data, dict):
336 continue
337 data: DiagnosticData = cast("DiagnosticData", diagnostic.data)
338 quickfixes = data.get("quickfixes")
339 if quickfixes is None:
340 continue
341 for action_suggestion in quickfixes:
342 if (
343 action_suggestion
344 and isinstance(action_suggestion, Mapping)
345 and "code_action" in action_suggestion
346 ):
347 action_name: CodeActionName = action_suggestion["code_action"]
348 handler = CODE_ACTION_HANDLERS.get(action_name)
349 if handler is not None:
350 actions.extend(
351 handler(
352 lint_state,
353 cast("Mapping[str, str]", action_suggestion),
354 code_action_params,
355 diagnostic,
356 )
357 )
358 else:
359 _warn(f"No codeAction handler for {action_name} !?")
360 if not actions:
361 return None
362 return actions