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