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

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 

14 

15from debputy.lsp.diagnostics import DiagnosticData 

16from debputy.util import _warn 

17 

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 

23 

24 

25try: 

26 from debputy.lsp.vendoring._deb822_repro.locatable import ( 

27 Position as TEPosition, 

28 Range as TERange, 

29 ) 

30 

31 from pygls.server import LanguageServer 

32 from pygls.workspace import TextDocument 

33 from debputy.lsp.debputy_ls import DebputyLanguageServer 

34except ImportError: 

35 pass 

36 

37 

38CodeActionName = Literal[ 

39 "correct-text", 

40 "remove-line", 

41 "remove-range", 

42 "insert-text-on-line-after-diagnostic", 

43] 

44 

45 

46class CorrectTextCodeAction(TypedDict): 

47 code_action: Literal["correct-text"] 

48 correct_value: str 

49 proposed_title: NotRequired[str] 

50 

51 

52class InsertTextOnLineAfterDiagnosticCodeAction(TypedDict): 

53 code_action: Literal["insert-text-on-line-after-diagnostic"] 

54 text_to_insert: str 

55 

56 

57class RemoveLineCodeAction(TypedDict): 

58 code_action: Literal["remove-line"] 

59 

60 

61class RemoveRangeCodeAction(TypedDict): 

62 code_action: Literal["remove-range"] 

63 proposed_title: NotRequired[str] 

64 

65 

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 

78 

79 

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 } 

87 

88 

89def propose_remove_line_quick_fix() -> RemoveLineCodeAction: 

90 return { 

91 "code_action": "remove-line", 

92 } 

93 

94 

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 

105 

106 

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] 

119 

120 

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 

126 

127 return _wrapper 

128 

129 

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 ) 

149 

150 

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 ) 

180 

181 

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 ) 

217 

218 

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 

233 

234 

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 

248 

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 ) 

267 

268 

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 ) 

285 

286 

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 

294 

295 

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 ) 

315 

316 

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 ) 

326 

327 

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