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

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) 

16 

17from debputy.lsp.diagnostics import DiagnosticData 

18from debputy.util import _warn 

19 

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 

25 

26 

27try: 

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

29 Position as TEPosition, 

30 Range as TERange, 

31 ) 

32 

33 from pygls.server import LanguageServer 

34 from pygls.workspace import TextDocument 

35 from debputy.lsp.debputy_ls import DebputyLanguageServer 

36except ImportError: 

37 pass 

38 

39 

40CodeActionName = Literal[ 

41 "correct-text", 

42 "remove-line", 

43 "remove-range", 

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

45] 

46 

47 

48class CorrectTextCodeAction(TypedDict): 

49 code_action: Literal["correct-text"] 

50 correct_value: str 

51 proposed_title: NotRequired[str] 

52 

53 

54class InsertTextOnLineAfterDiagnosticCodeAction(TypedDict): 

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

56 text_to_insert: str 

57 

58 

59class RemoveLineCodeAction(TypedDict): 

60 code_action: Literal["remove-line"] 

61 

62 

63class RemoveRangeCodeAction(TypedDict): 

64 code_action: Literal["remove-range"] 

65 proposed_title: NotRequired[str] 

66 

67 

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 

80 

81 

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 } 

89 

90 

91def propose_remove_line_quick_fix() -> RemoveLineCodeAction: 

92 return { 

93 "code_action": "remove-line", 

94 } 

95 

96 

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 

107 

108 

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] 

121 

122 

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 

128 

129 return _wrapper 

130 

131 

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 ) 

151 

152 

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 ) 

182 

183 

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 ) 

219 

220 

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 

235 

236 

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 

250 

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 ) 

269 

270 

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 ) 

287 

288 

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 

296 

297 

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 ) 

317 

318 

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 ) 

328 

329 

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