Coverage for src/debputy/linting/lint_impl.py: 12%

152 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2024-04-07 12:14 +0200

1import os 

2import stat 

3import sys 

4from typing import Optional, List, Union, NoReturn 

5 

6from lsprotocol.types import ( 

7 CodeAction, 

8 Command, 

9 CodeActionParams, 

10 CodeActionContext, 

11 TextDocumentIdentifier, 

12 TextEdit, 

13 Position, 

14 DiagnosticSeverity, 

15) 

16 

17from debputy.commands.debputy_cmd.context import CommandContext 

18from debputy.commands.debputy_cmd.output import _output_styling, OutputStylingBase 

19from debputy.linting.lint_util import ( 

20 report_diagnostic, 

21 LinterImpl, 

22 LintReport, 

23 LintStateImpl, 

24) 

25from debputy.lsp.lsp_debian_changelog import _lint_debian_changelog 

26from debputy.lsp.lsp_debian_control import _lint_debian_control 

27from debputy.lsp.lsp_debian_copyright import _lint_debian_copyright 

28from debputy.lsp.lsp_debian_debputy_manifest import _lint_debian_debputy_manifest 

29from debputy.lsp.lsp_debian_rules import _lint_debian_rules_impl 

30from debputy.lsp.lsp_debian_tests_control import _lint_debian_tests_control 

31from debputy.lsp.quickfixes import provide_standard_quickfixes_from_diagnostics 

32from debputy.lsp.spellchecking import disable_spellchecking 

33from debputy.lsp.text_edit import ( 

34 get_well_formatted_edit, 

35 merge_sort_text_edits, 

36 apply_text_edits, 

37) 

38from debputy.plugin.api.feature_set import PluginProvidedFeatureSet 

39from debputy.util import _warn, _error, _info 

40 

41LINTER_FORMATS = { 

42 "debian/changelog": _lint_debian_changelog, 

43 "debian/control": _lint_debian_control, 

44 "debian/copyright": _lint_debian_copyright, 

45 "debian/debputy.manifest": _lint_debian_debputy_manifest, 

46 "debian/rules": _lint_debian_rules_impl, 

47 "debian/tests/control": _lint_debian_tests_control, 

48} 

49 

50 

51def perform_linting(context: CommandContext) -> None: 

52 parsed_args = context.parsed_args 

53 if not parsed_args.spellcheck: 

54 disable_spellchecking() 

55 linter_exit_code = parsed_args.linter_exit_code 

56 lint_report = LintReport() 

57 fo = _output_styling(context.parsed_args, sys.stdout) 

58 plugin_feature_set = context.load_plugins() 

59 for name_stem in LINTER_FORMATS: 

60 filename = f"./{name_stem}" 

61 if not os.path.isfile(filename): 

62 continue 

63 perform_linting_of_file( 

64 fo, 

65 plugin_feature_set, 

66 filename, 

67 name_stem, 

68 context.parsed_args.auto_fix, 

69 lint_report, 

70 ) 

71 if lint_report.diagnostics_without_severity: 

72 _warn( 

73 "Some diagnostics did not explicitly set severity. Please report the bug and include the output" 

74 ) 

75 if lint_report.diagnostic_errors: 

76 _error( 

77 "Some sub-linters reported issues. Please report the bug and include the output" 

78 ) 

79 

80 if os.path.isfile("debian/debputy.manifest"): 

81 _info("Note: Due to a limitation in the linter, debian/debputy.manifest is") 

82 _info("only **partially** checked by this command at the time of writing.") 

83 _info("Please use `debputy check-manifest` to fully check the manifest.") 

84 

85 if linter_exit_code: 

86 _exit_with_lint_code(lint_report) 

87 

88 

89def _exit_with_lint_code(lint_report: LintReport) -> NoReturn: 

90 diagnostics_count = lint_report.diagnostics_count 

91 if ( 

92 diagnostics_count[DiagnosticSeverity.Error] 

93 or diagnostics_count[DiagnosticSeverity.Warning] 

94 ): 

95 sys.exit(2) 

96 sys.exit(0) 

97 

98 

99def perform_linting_of_file( 

100 fo: OutputStylingBase, 

101 plugin_feature_set: PluginProvidedFeatureSet, 

102 filename: str, 

103 file_format: str, 

104 auto_fixing_enabled: bool, 

105 lint_report: LintReport, 

106) -> None: 

107 handler = LINTER_FORMATS.get(file_format) 

108 if handler is None: 

109 return 

110 with open(filename, "rt", encoding="utf-8") as fd: 

111 text = fd.read() 

112 

113 if auto_fixing_enabled: 

114 _auto_fix_run(fo, plugin_feature_set, filename, text, handler, lint_report) 

115 else: 

116 _diagnostics_run(fo, plugin_feature_set, filename, text, handler, lint_report) 

117 

118 

119def _edit_happens_before_last_fix( 

120 last_edit_pos: Position, 

121 last_fix_position: Position, 

122) -> bool: 

123 if last_edit_pos.line < last_fix_position.line: 

124 return True 

125 return ( 

126 last_edit_pos.line == last_fix_position.character 

127 and last_edit_pos.character < last_fix_position.character 

128 ) 

129 

130 

131def _auto_fix_run( 

132 fo: OutputStylingBase, 

133 plugin_feature_set: PluginProvidedFeatureSet, 

134 filename: str, 

135 text: str, 

136 linter: LinterImpl, 

137 lint_report: LintReport, 

138) -> None: 

139 another_round = True 

140 unfixed_diagnostics = [] 

141 remaining_rounds = 10 

142 fixed_count = False 

143 too_many_rounds = False 

144 lines = text.splitlines(keepends=True) 

145 lint_state = LintStateImpl( 

146 plugin_feature_set, 

147 filename, 

148 lines, 

149 ) 

150 current_issues = linter(lint_state) 

151 issue_count_start = len(current_issues) if current_issues else 0 

152 while another_round and current_issues: 

153 another_round = False 

154 last_fix_position = Position(0, 0) 

155 unfixed_diagnostics.clear() 

156 edits = [] 

157 fixed_diagnostics = [] 

158 for diagnostic in current_issues: 

159 actions = provide_standard_quickfixes_from_diagnostics( 

160 CodeActionParams( 

161 TextDocumentIdentifier(filename), 

162 diagnostic.range, 

163 CodeActionContext( 

164 [diagnostic], 

165 ), 

166 ), 

167 ) 

168 auto_fixing_edits = resolve_auto_fixer(filename, actions) 

169 

170 if not auto_fixing_edits: 

171 unfixed_diagnostics.append(diagnostic) 

172 continue 

173 

174 sorted_edits = merge_sort_text_edits( 

175 [get_well_formatted_edit(e) for e in auto_fixing_edits], 

176 ) 

177 last_edit = sorted_edits[-1] 

178 last_edit_pos = last_edit.range.start 

179 if _edit_happens_before_last_fix(last_edit_pos, last_fix_position): 

180 if not another_round: 

181 if remaining_rounds > 0: 

182 remaining_rounds -= 1 

183 print( 

184 "Detected overlapping edit; scheduling another edit round." 

185 ) 

186 another_round = True 

187 else: 

188 _warn( 

189 "Too many overlapping edits; stopping after this round (circuit breaker)." 

190 ) 

191 too_many_rounds = True 

192 continue 

193 edits.extend(sorted_edits) 

194 fixed_diagnostics.append(diagnostic) 

195 last_fix_position = sorted_edits[-1].range.start 

196 

197 if another_round and not edits: 

198 _error( 

199 "Internal error: Detected an overlapping edit and yet had no edits to perform..." 

200 ) 

201 

202 fixed_count += len(fixed_diagnostics) 

203 

204 text = apply_text_edits( 

205 text, 

206 lines, 

207 edits, 

208 ) 

209 lines = text.splitlines(keepends=True) 

210 

211 for diagnostic in fixed_diagnostics: 

212 report_diagnostic( 

213 fo, 

214 filename, 

215 diagnostic, 

216 lines, 

217 True, 

218 True, 

219 lint_report, 

220 ) 

221 lint_state.lines = lines 

222 current_issues = linter(lint_state) 

223 

224 if fixed_count: 

225 output_filename = f"{filename}.tmp" 

226 with open(output_filename, "wt", encoding="utf-8") as fd: 

227 fd.write(text) 

228 orig_mode = stat.S_IMODE(os.stat(filename).st_mode) 

229 os.chmod(output_filename, orig_mode) 

230 os.rename(output_filename, filename) 

231 lines = text.splitlines(keepends=True) 

232 lint_state.lines = lines 

233 remaining_issues = linter(lint_state) or [] 

234 else: 

235 remaining_issues = current_issues or [] 

236 

237 for diagnostic in remaining_issues: 

238 report_diagnostic( 

239 fo, 

240 filename, 

241 diagnostic, 

242 lines, 

243 False, 

244 False, 

245 lint_report, 

246 ) 

247 

248 print() 

249 if fixed_count: 

250 remaining_issues_count = len(remaining_issues) 

251 print( 

252 fo.colored( 

253 f"Fixes applied to {filename}: {fixed_count}." 

254 f" Number of issues went from {issue_count_start} to {remaining_issues_count}", 

255 fg="green", 

256 style="bold", 

257 ) 

258 ) 

259 elif remaining_issues: 

260 print( 

261 fo.colored( 

262 f"None of the issues in {filename} could be fixed automatically. Sorry!", 

263 fg="yellow", 

264 bg="black", 

265 style="bold", 

266 ) 

267 ) 

268 else: 

269 assert not current_issues 

270 print( 

271 fo.colored( 

272 f"No issues detected in {filename}", 

273 fg="green", 

274 style="bold", 

275 ) 

276 ) 

277 if too_many_rounds: 

278 print( 

279 fo.colored( 

280 f"Not all fixes for issues in {filename} could be applied due to overlapping edits.", 

281 fg="yellow", 

282 bg="black", 

283 style="bold", 

284 ) 

285 ) 

286 print( 

287 "Running once more may cause more fixes to be applied. However, you may be facing" 

288 " pathological performance." 

289 ) 

290 

291 

292def _diagnostics_run( 

293 fo: OutputStylingBase, 

294 plugin_feature_set: PluginProvidedFeatureSet, 

295 filename: str, 

296 text: str, 

297 linter: LinterImpl, 

298 lint_report: LintReport, 

299) -> None: 

300 lines = text.splitlines(keepends=True) 

301 lint_state = LintStateImpl( 

302 plugin_feature_set, 

303 filename, 

304 lines, 

305 ) 

306 issues = linter(lint_state) or [] 

307 for diagnostic in issues: 

308 actions = provide_standard_quickfixes_from_diagnostics( 

309 CodeActionParams( 

310 TextDocumentIdentifier(filename), 

311 diagnostic.range, 

312 CodeActionContext( 

313 [diagnostic], 

314 ), 

315 ), 

316 ) 

317 auto_fixer = resolve_auto_fixer(filename, actions) 

318 has_auto_fixer = bool(auto_fixer) 

319 

320 report_diagnostic( 

321 fo, 

322 filename, 

323 diagnostic, 

324 lines, 

325 has_auto_fixer, 

326 False, 

327 lint_report, 

328 ) 

329 

330 

331def resolve_auto_fixer( 

332 document_ref: str, 

333 actions: Optional[List[Union[Command, CodeAction]]], 

334) -> Optional[List[TextEdit]]: 

335 if actions is None or len(actions) != 1: 

336 return None 

337 action = actions[0] 

338 if not isinstance(action, CodeAction): 

339 return None 

340 workspace_edit = action.edit 

341 if workspace_edit is None or action.command is not None: 

342 return None 

343 if ( 

344 not workspace_edit.changes 

345 or len(workspace_edit.changes) != 1 

346 or document_ref not in workspace_edit.changes 

347 ): 

348 return None 

349 return workspace_edit.changes[document_ref]