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
« 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
6from lsprotocol.types import (
7 CodeAction,
8 Command,
9 CodeActionParams,
10 CodeActionContext,
11 TextDocumentIdentifier,
12 TextEdit,
13 Position,
14 DiagnosticSeverity,
15)
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
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}
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 )
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.")
85 if linter_exit_code:
86 _exit_with_lint_code(lint_report)
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)
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()
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)
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 )
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)
170 if not auto_fixing_edits:
171 unfixed_diagnostics.append(diagnostic)
172 continue
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
197 if another_round and not edits:
198 _error(
199 "Internal error: Detected an overlapping edit and yet had no edits to perform..."
200 )
202 fixed_count += len(fixed_diagnostics)
204 text = apply_text_edits(
205 text,
206 lines,
207 edits,
208 )
209 lines = text.splitlines(keepends=True)
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)
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 []
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 )
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 )
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)
320 report_diagnostic(
321 fo,
322 filename,
323 diagnostic,
324 lines,
325 has_auto_fixer,
326 False,
327 lint_report,
328 )
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]