Coverage for src/debputy/linting/lint_util.py: 54%
409 statements
« prev ^ index » next coverage.py v7.8.2, created at 2026-07-22 10:58 +0000
« prev ^ index » next coverage.py v7.8.2, created at 2026-07-22 10:58 +0000
1import collections
2import contextlib
3import dataclasses
4import datetime
5import os
6import time
7from collections import defaultdict, Counter
8from enum import IntEnum
9from functools import lru_cache
10from typing import (
11 Optional,
12 TYPE_CHECKING,
13 cast,
14 Self,
15 TypeVar,
16)
17from collections.abc import (
18 Callable,
19 Mapping,
20 Sequence,
21 Iterable,
22 Awaitable,
23 AsyncIterable,
24)
27import debputy.l10n as l10n
28from debputy.commands.debputy_cmd.output import IOBasedOutputStyling
29from debputy.dh.dh_assistant import (
30 extract_dh_addons_from_control,
31 DhSequencerData,
32 parse_drules_for_addons,
33)
34from debputy.exceptions import PureVirtualPathError, DebputyRuntimeError
35from debputy.filesystem_scan import VirtualPathBase
36from debputy.integration_detection import determine_debputy_integration_mode
37from debputy.l10n import Translations
38from debputy.lsp.config.debputy_config import DebputyConfig
39from debputy.lsp.diagnostics import (
40 LintSeverity,
41 LINT_SEVERITY2LSP_SEVERITY,
42 DiagnosticData,
43 NATIVELY_LSP_SUPPORTED_SEVERITIES,
44)
45from debputy.lsp.spellchecking import Spellchecker, default_spellchecker
46from debian._deb822_repro import Deb822FileElement, parse_deb822_file
47from debputy.packages import SourcePackage, BinaryPackage
48from debputy.plugin.api.feature_set import PluginProvidedFeatureSet
49from debputy.plugin.api.spec import DebputyIntegrationMode
50from debputy.util import _warn, T
51from debian._deb822_repro.locatable import (
52 Range as TERange,
53 Position as TEPosition,
54 Locatable,
55 START_POSITION,
56)
58if TYPE_CHECKING:
59 import lsprotocol.types as types
60 from debputy.lsp.text_util import LintCapablePositionCodec
61 from debputy.lsp.maint_prefs import (
62 MaintainerPreferenceTable,
63 EffectiveFormattingPreference,
64 )
66else:
67 import debputy.lsprotocol.types as types
70L = TypeVar("L", bound=Locatable)
73AsyncLinterImpl = Callable[["LintState"], Awaitable[None]]
74FormatterImpl = Callable[["LintState"], Optional[Sequence[types.TextEdit]]]
77class AbortTaskError(DebputyRuntimeError):
78 pass
81# If you add a new one to this set, remember to mention it in the docs of `LintState.emit_diagnostic`
82DIAG_SOURCE_WITHOUT_SECTIONS: frozenset[str] = frozenset(
83 {
84 "debputy",
85 "dpkg",
86 }
87)
89# If you add a new one to this set, remember to mention it in the docs of `LintState.emit_diagnostic`
90DIAG_SOURCE_WITH_SECTIONS: frozenset[str] = frozenset(
91 {
92 "Policy",
93 "DevRef",
94 }
95)
98def te_position_to_lsp(te_position: "TEPosition") -> types.Position:
99 return types.Position(
100 te_position.line_position,
101 te_position.cursor_position,
102 )
105def te_range_to_lsp(te_range: "TERange") -> types.Range:
106 return types.Range(
107 te_position_to_lsp(te_range.start_pos),
108 te_position_to_lsp(te_range.end_pos),
109 )
112def with_range_in_continuous_parts(
113 iterable: Iterable["L"],
114 *,
115 start_relative_to: "TEPosition" = START_POSITION,
116) -> Iterable[tuple["TERange", "L"]]:
117 current_pos = start_relative_to
118 for part in iterable:
119 part_range = part.size().relative_to(current_pos)
120 yield part_range, part
121 current_pos = part_range.end_pos
124@lru_cache
125def _check_diagnostic_source(source: str) -> None:
126 if source in DIAG_SOURCE_WITHOUT_SECTIONS:
127 return
128 parts = source.split(" ")
129 s = parts[0]
130 if s not in DIAG_SOURCE_WITH_SECTIONS: 130 ↛ 131line 130 didn't jump to line 131 because the condition on line 130 was never true
131 raise ValueError(
132 f'Unknown diagnostic source: "{source}". If you are adding a new source, update lint_util.py'
133 )
134 if len(parts) != 2: 134 ↛ 135line 134 didn't jump to line 135 because the condition on line 134 was never true
135 raise ValueError(
136 f'The diagnostic source "{source}" should have exactly one section associated with it.'
137 )
140@dataclasses.dataclass(slots=True)
141class DebputyMetadata:
142 debputy_integration_mode: DebputyIntegrationMode | None
144 @classmethod
145 def from_data(
146 cls,
147 source_fields: Mapping[str, str],
148 dh_sequencer_data: DhSequencerData,
149 ) -> Self:
150 integration_mode = determine_debputy_integration_mode(
151 source_fields,
152 dh_sequencer_data.sequences,
153 )
154 return cls(integration_mode)
157@dataclasses.dataclass(slots=True, frozen=True)
158class RelatedDiagnosticInformation:
159 text_range: "TERange"
160 message: str
161 doc_uri: str
163 def to_lsp(self, lint_state: "LintState") -> types.DiagnosticRelatedInformation:
164 return types.DiagnosticRelatedInformation(
165 types.Location(
166 self.doc_uri,
167 lint_state.position_codec.range_to_client_units(
168 lint_state.lines,
169 te_range_to_lsp(self.text_range),
170 ),
171 ),
172 self.message,
173 )
176@dataclasses.dataclass(frozen=True, slots=True)
177class WorkspaceTextEditSupport:
178 supports_document_changes: bool = False
179 supported_resource_operation_edit_kinds: Sequence[types.ResourceOperationKind] = (
180 dataclasses.field(default_factory=list)
181 )
183 @property
184 def supports_versioned_text_edits(self) -> bool:
185 return self.supports_document_changes or bool(
186 self.supported_resource_operation_edit_kinds
187 )
190class LintState:
192 @property
193 def plugin_feature_set(self) -> PluginProvidedFeatureSet:
194 """The plugin features known to the running instance of `debputy`
196 This is mostly only relevant when working with `debputy.manifest`
197 """
198 raise NotImplementedError
200 @property
201 def doc_uri(self) -> str:
202 """The URI for the document being scanned.
204 This can be useful for providing related location ranges.
205 """
206 raise NotImplementedError
208 @property
209 def doc_version(self) -> int | None:
210 raise NotImplementedError
212 @property
213 def source_root(self) -> VirtualPathBase | None:
214 """The path to the unpacked source root directory if available
216 This is the directory that would contain the `debian/` directory. Note, if you need the
217 `debian/` directory, please use `debian_dir` instead. There may be cases where the source
218 root is unavailable but the `debian/` directory is not.
219 """
220 raise NotImplementedError
222 @property
223 def debian_dir(self) -> VirtualPathBase | None:
224 """The path to the `debian/` directory if available"""
225 raise NotImplementedError
227 @property
228 def path(self) -> str:
229 """The filename or path of the file being scanned.
231 Note this path may or may not be accessible to the running `debputy` instance. Nor is it guaranteed
232 that the file on the file system (even if accessible) has correct contents. When doing diagnostics
233 for an editor, the editor often requests diagnostics for unsaved changes.
234 """
235 raise NotImplementedError
237 @property
238 def content(self) -> str:
239 """The full contents of the file being checked"""
240 raise NotImplementedError
242 @property
243 def lines(self) -> list[str]:
244 # FIXME: Replace with `Sequence[str]` if possible
245 """The contents of the file being checked as a list of lines
247 Do **not** change the contents of this list as it may be cached.
248 """
249 raise NotImplementedError
251 @property
252 def position_codec(self) -> "LintCapablePositionCodec":
253 raise NotImplementedError
255 @property
256 def parsed_deb822_file_content(self) -> Deb822FileElement | None:
257 """The contents of the file being checked as a parsed deb822 file
259 This can sometimes use a cached version of the parsed file and is therefore preferable to
260 parsing the file manually from `content` or `lines`.
262 Do **not** change the contents of this as it may be cached.
263 """
264 raise NotImplementedError
266 @property
267 def source_package(self) -> SourcePackage | None:
268 """The source package (source stanza of `debian/control`).
270 Will be `None` if the `debian/control` file cannot be parsed as a deb822 file, or if the
271 source stanza is not available.
272 """
273 raise NotImplementedError
275 @property
276 def binary_packages(self) -> Mapping[str, BinaryPackage] | None:
277 """The binary packages (the Package stanzas of `debian/control`).
279 Will be `None` if the `debian/control` file cannot be parsed, or if no Package stanzas are
280 available.
281 """
282 raise NotImplementedError
284 @property
285 def maint_preference_table(self) -> "MaintainerPreferenceTable":
286 # TODO: Visible only for tests.
287 raise NotImplementedError
289 @property
290 def effective_preference(self) -> Optional["EffectiveFormattingPreference"]:
291 raise NotImplementedError
293 @property
294 def debputy_metadata(self) -> DebputyMetadata:
295 """Information about `debputy` usage such as which integration mode is being used."""
296 src_pkg = self.source_package
297 src_fields = src_pkg.fields if src_pkg else {}
298 return DebputyMetadata.from_data(
299 src_fields,
300 self.dh_sequencer_data,
301 )
303 @property
304 def dh_sequencer_data(self) -> DhSequencerData:
305 """Information about the use of the `dh` sequencer
307 This includes which sequences are being used and whether the `dh` sequencer is used at all.
308 """
309 raise NotImplementedError
311 @property
312 def workspace_text_edit_support(self) -> WorkspaceTextEditSupport:
313 raise NotImplementedError
315 @property
316 def debputy_config(self) -> DebputyConfig:
317 raise NotImplementedError
319 def spellchecker(self) -> "Spellchecker":
320 checker = default_spellchecker()
321 ignored_words = set()
322 source_package = self.source_package
323 binary_packages = self.binary_packages
324 if source_package and (name := source_package.fields.get("Source")) is not None:
325 ignored_words.add(name)
326 if binary_packages:
327 ignored_words.update(binary_packages.keys())
328 return checker.context_ignored_words(ignored_words)
330 async def slow_iter(
331 self,
332 iterable: Iterable[T],
333 *,
334 # Stub implement, arg present to mirror real implementation.
335 yield_every: int = 100, # noqa: S1172
336 ) -> AsyncIterable[T]:
337 for value in iterable:
338 yield value
340 def translation(self, domain: str) -> Translations:
341 return l10n.translation(
342 domain,
343 )
345 def related_diagnostic_information(
346 self,
347 text_range: "TERange",
348 message: str,
349 *,
350 doc_uri: str | None = None,
351 ) -> RelatedDiagnosticInformation:
352 """Provide a related context for the diagnostic
354 The related diagnostic information is typically highlighted with the diagnostic. As an example,
355 `debputy lint`'s terminal output will display the message and display the selected range after
356 the diagnostic itself.
358 :param text_range: The text range to highlight.
359 :param message: The message to associate with the provided text range.
360 :param doc_uri: The URI of the document that the context is from. When omitted, the text range is
361 assumed to be from the "current" file (the `doc_uri` attribute), which is also the default file
362 for ranges passed to `emit_diagnostic`.
363 :return:
364 """
365 return RelatedDiagnosticInformation(
366 text_range,
367 message,
368 doc_uri=doc_uri if doc_uri is not None else self.doc_uri,
369 )
371 def emit_diagnostic(
372 self,
373 text_range: "TERange",
374 diagnostic_msg: str,
375 severity: LintSeverity,
376 authority_reference: str,
377 *,
378 quickfixes: Sequence[Mapping] | None = None,
379 tags: list[types.DiagnosticTag] | None = None,
380 related_information: list[RelatedDiagnosticInformation] | None = None,
381 diagnostic_applies_to_another_file: str | None = None,
382 enable_non_interactive_auto_fix: bool = True,
383 ) -> None:
384 """Emit a diagnostic for an issue detected in the current file.
386 :param text_range: The text range to highlight in the file.
387 :param diagnostic_msg: The message to show to the user for this diagnostic
388 :param severity: The severity to associate with the diagnostic.
389 :param authority_reference: A reference to the authority / guide that this diagnostic is a violation of.
391 Use:
392 * "Policy 3.4.1" for Debian Policy Manual section 3.4.1
393 (replace the section number with the relevant number for your case)
394 * "DevRef 6.2.2" for the Debian Developer Reference section 6.2.2
395 (replace the section number with the relevant number for your case)
396 * "debputy" for diagnostics without a reference or where `debputy` is the authority.
397 (This is also used for cases where `debputy` filters the result. Like with spellchecking
398 via hunspell, where `debputy` provides its own ignore list on top)
400 If you need a new reference, feel free to add it to this list.
401 :param quickfixes: If provided, this is a list of possible fixes for this problem.
402 Use the quickfixes provided in `debputy.lsp.quickfixes` such as `propose_correct_text_quick_fix`.
403 :param tags: TODO: Not yet specified (currently uses LSP format).
404 :param related_information: Provide additional context to the diagnostic. This can be used to define
405 the source of a conflict. As an example, for duplicate definitions, this can be used to show where
406 the definitions are.
408 Every item should be created via the `related_diagnostic_information` method.
409 :param enable_non_interactive_auto_fix: Allow non-interactive auto-fixing (such as via
410 `debputy lint --auto-fix`) of this issue. Set to `False` if the check is likely to have false
411 positives.
412 :param diagnostic_applies_to_another_file: Special-case parameter for flagging invalid file names.
413 Leave this one at `None`, unless you know you need it.
415 It has non-obvious semantics and is primarily useful for reporting typos of filenames such as
416 `debian/install`, etc.
417 """
418 _check_diagnostic_source(authority_reference)
419 lsp_severity = LINT_SEVERITY2LSP_SEVERITY[severity]
420 diag_data: DiagnosticData = {
421 "enable_non_interactive_auto_fix": enable_non_interactive_auto_fix,
422 }
424 if severity not in NATIVELY_LSP_SUPPORTED_SEVERITIES:
425 diag_data["lint_severity"] = severity
426 if quickfixes:
427 diag_data["quickfixes"] = quickfixes
428 if diagnostic_applies_to_another_file is not None:
429 diag_data["report_for_related_file"] = diagnostic_applies_to_another_file
431 lsp_range_client_units = self.position_codec.range_to_client_units(
432 self.lines,
433 te_range_to_lsp(text_range),
434 )
436 if related_information and any( 436 ↛ 439line 436 didn't jump to line 439 because the condition on line 436 was never true
437 i.doc_uri != self.doc_uri for i in related_information
438 ):
439 raise NotImplementedError("Ranges from another document will be wrong")
441 related_lsp_format = (
442 [i.to_lsp(self) for i in related_information]
443 if related_information
444 else None
445 )
446 diag = types.Diagnostic(
447 lsp_range_client_units,
448 diagnostic_msg,
449 severity=lsp_severity,
450 source=authority_reference,
451 data=diag_data if diag_data else None,
452 tags=tags,
453 related_information=related_lsp_format,
454 )
455 self._emit_diagnostic(diag)
457 def _emit_diagnostic(self, diagnostic: types.Diagnostic) -> None:
458 raise NotImplementedError
461CLI_WORKSPACE_TEXT_EDIT_SUPPORT = WorkspaceTextEditSupport(
462 supports_document_changes=True,
463)
466@dataclasses.dataclass(slots=True)
467class LintStateImpl(LintState):
468 plugin_feature_set: PluginProvidedFeatureSet = dataclasses.field(repr=False)
469 maint_preference_table: "MaintainerPreferenceTable" = dataclasses.field(repr=False)
470 source_root: VirtualPathBase | None
471 debian_dir: VirtualPathBase | None
472 path: str
473 content: str
474 lines: list[str]
475 debputy_config: DebputyConfig
476 source_package: SourcePackage | None = None
477 binary_packages: Mapping[str, BinaryPackage] | None = None
478 effective_preference: Optional["EffectiveFormattingPreference"] = None
479 lint_implementation: Optional["AsyncLinterImpl"] = None
480 _parsed_cache: Deb822FileElement | None = None
481 _dh_sequencer_cache: DhSequencerData | None = None
482 _diagnostics: list[types.Diagnostic] | None = None
484 @property
485 def doc_uri(self) -> str:
486 path = self.path
487 abs_path = os.path.join(os.path.curdir, path)
488 return f"file://{abs_path}"
490 @property
491 def doc_version(self) -> int | None:
492 return None
494 @property
495 def position_codec(self) -> "LintCapablePositionCodec":
496 return LINTER_POSITION_CODEC
498 @property
499 def parsed_deb822_file_content(self) -> Deb822FileElement | None:
500 cache = self._parsed_cache
501 if cache is None:
502 cache = parse_deb822_file(
503 self.lines,
504 accept_files_with_error_tokens=True,
505 accept_files_with_duplicated_fields=True,
506 )
507 self._parsed_cache = cache
508 return cache
510 @property
511 def dh_sequencer_data(self) -> DhSequencerData:
512 dh_sequencer_cache = self._dh_sequencer_cache
513 if dh_sequencer_cache is None:
514 debian_dir = self.debian_dir
515 dh_sequences: set[str] = set()
516 saw_dh = False
517 src_pkg = self.source_package
518 drules = debian_dir.get("rules") if debian_dir is not None else None
519 if drules and drules.is_file:
520 try:
521 with drules.open() as fd:
522 saw_dh = parse_drules_for_addons(fd, dh_sequences)
523 except PureVirtualPathError:
524 pass
525 if src_pkg:
526 extract_dh_addons_from_control(src_pkg.fields, dh_sequences)
528 dh_sequencer_cache = DhSequencerData(
529 frozenset(dh_sequences),
530 saw_dh,
531 )
532 self._dh_sequencer_cache = dh_sequencer_cache
533 return dh_sequencer_cache
535 @property
536 def workspace_text_edit_support(self) -> WorkspaceTextEditSupport:
537 return CLI_WORKSPACE_TEXT_EDIT_SUPPORT
539 async def gather_diagnostics(self) -> list[types.Diagnostic]:
540 if self._diagnostics is not None: 540 ↛ 541line 540 didn't jump to line 541 because the condition on line 540 was never true
541 raise RuntimeError(
542 "run_diagnostics cannot be run while it is already running"
543 )
544 linter = self.lint_implementation
545 if linter is None: 545 ↛ 546line 545 didn't jump to line 546 because the condition on line 545 was never true
546 raise TypeError(
547 "run_diagnostics cannot be run:"
548 " LintState was created without a lint implementation (such as for reformat-only)"
549 )
550 diagnostics: list[types.Diagnostic] = []
551 self._diagnostics = diagnostics
553 await linter(self)
555 self._diagnostics = None
556 return diagnostics
558 def clear_cache(self) -> None:
559 self._parsed_cache = None
560 self._dh_sequencer_cache = None
562 def _emit_diagnostic(self, diagnostic: types.Diagnostic) -> None:
563 diagnostics = self._diagnostics
564 if diagnostics is None: 564 ↛ 565line 564 didn't jump to line 565 because the condition on line 564 was never true
565 raise TypeError("Cannot run emit_diagnostic outside of gather_diagnostics")
566 diagnostics.append(diagnostic)
569class LintDiagnosticResultState(IntEnum):
570 REPORTED = 1
571 MANUAL_FIXABLE = 2
572 AUTO_FIXABLE = 3
573 FIXED = 4
576@dataclasses.dataclass(slots=True, frozen=True)
577class LintDiagnosticResult:
578 diagnostic: types.Diagnostic
579 result_state: LintDiagnosticResultState
580 invalid_marker: RuntimeError | None
581 is_file_level_diagnostic: bool
582 has_broken_range: bool
583 missing_severity: bool
584 discovered_in: str
585 report_for_related_file: str | None
588class LintReport:
590 def __init__(self) -> None:
591 self.diagnostics_count: Counter[types.DiagnosticSeverity] = Counter()
592 self.diagnostics_by_file: Mapping[str, list[LintDiagnosticResult]] = (
593 defaultdict(list)
594 )
595 self.number_of_invalid_diagnostics: int = 0
596 self.number_of_broken_diagnostics: int = 0
597 self.lint_state: LintState | None = None
598 self.start_timestamp = datetime.datetime.now()
599 self.durations: dict[str, float] = collections.defaultdict(float)
600 self._timer = time.perf_counter()
602 @contextlib.contextmanager
603 def line_state(self, lint_state: LintState) -> collections.abc.Iterator[None]:
604 previous = self.lint_state
605 if previous is not None:
606 path = previous.path
607 duration = time.perf_counter() - self._timer
608 self.durations[path] += duration
610 self.lint_state = lint_state
612 try:
613 self._timer = time.perf_counter()
614 yield
615 finally:
616 now = time.perf_counter()
617 duration = now - self._timer
618 self.durations[lint_state.path] += duration
619 self._timer = now
620 self.lint_state = previous
622 def report_diagnostic(
623 self,
624 diagnostic: types.Diagnostic,
625 *,
626 result_state: LintDiagnosticResultState = LintDiagnosticResultState.REPORTED,
627 in_file: str | None = None,
628 ) -> None:
629 lint_state = self.lint_state
630 assert lint_state is not None
631 if in_file is None:
632 in_file = lint_state.path
633 assert in_file is not None
634 discovered_in_file = in_file
635 severity = diagnostic.severity
636 missing_severity = False
637 error_marker: RuntimeError | None = None
638 if severity is None:
639 self.number_of_invalid_diagnostics += 1
640 severity = types.DiagnosticSeverity.Warning
641 diagnostic.severity = severity
642 missing_severity = True
644 lines = lint_state.lines
645 diag_range = diagnostic.range
646 start_pos = diag_range.start
647 end_pos = diag_range.end
648 diag_data = diagnostic.data
649 if isinstance(diag_data, dict):
650 report_for_related_file = diag_data.get("report_for_related_file")
651 if report_for_related_file is None or not isinstance(
652 report_for_related_file, str
653 ):
654 report_for_related_file = None
655 else:
656 in_file = report_for_related_file
657 # Force it to exist in self.durations, since subclasses can use .items() or "foo" in self.durations.
658 if in_file not in self.durations:
659 self.durations[in_file] = 0
660 else:
661 report_for_related_file = None
662 if report_for_related_file is not None:
663 is_file_level_diagnostic = True
664 else:
665 is_file_level_diagnostic = _is_file_level_diagnostic(
666 lines,
667 start_pos.line,
668 start_pos.character,
669 end_pos.line,
670 end_pos.character,
671 )
672 has_broken_range = not is_file_level_diagnostic and (
673 end_pos.line > len(lines) or start_pos.line < 0
674 )
676 if has_broken_range or missing_severity:
677 error_marker = RuntimeError("Registration Marker for invalid diagnostic")
679 diagnostic_result = LintDiagnosticResult(
680 diagnostic,
681 result_state,
682 error_marker,
683 is_file_level_diagnostic,
684 has_broken_range,
685 missing_severity,
686 report_for_related_file=report_for_related_file,
687 discovered_in=discovered_in_file,
688 )
690 self.diagnostics_by_file[in_file].append(diagnostic_result)
691 self.diagnostics_count[severity] += 1
692 self.process_diagnostic(in_file, lint_state, diagnostic_result)
694 def process_diagnostic(
695 self,
696 filename: str,
697 lint_state: LintState,
698 diagnostic_result: LintDiagnosticResult,
699 ) -> None:
700 # Subclass hook
701 pass
703 def finish_report(self) -> None:
704 # Subclass hook
705 pass
708_LS2DEBPUTY_SEVERITY: Mapping[types.DiagnosticSeverity, LintSeverity] = {
709 types.DiagnosticSeverity.Error: "error",
710 types.DiagnosticSeverity.Warning: "warning",
711 types.DiagnosticSeverity.Information: "informational",
712 types.DiagnosticSeverity.Hint: "pedantic",
713}
716_TERM_SEVERITY2TAG = {
717 types.DiagnosticSeverity.Error: lambda fo, lint_tag=None: fo.colored(
718 lint_tag if lint_tag else "error",
719 fg="red",
720 bg="black",
721 style="bold",
722 ),
723 types.DiagnosticSeverity.Warning: lambda fo, lint_tag=None: fo.colored(
724 lint_tag if lint_tag else "warning",
725 fg="yellow",
726 bg="black",
727 style="bold",
728 ),
729 types.DiagnosticSeverity.Information: lambda fo, lint_tag=None: fo.colored(
730 lint_tag if lint_tag else "informational",
731 fg="blue",
732 bg="black",
733 style="bold",
734 ),
735 types.DiagnosticSeverity.Hint: lambda fo, lint_tag=None: fo.colored(
736 lint_tag if lint_tag else "pedantic",
737 fg="green",
738 bg="black",
739 style="bold",
740 ),
741}
744def debputy_severity(diagnostic: types.Diagnostic) -> LintSeverity:
745 lint_tag: LintSeverity | None = None
746 if isinstance(diagnostic.data, dict):
747 lint_tag = cast("LintSeverity", diagnostic.data.get("lint_severity"))
749 if lint_tag is not None:
750 return lint_tag
751 severity = diagnostic.severity
752 if severity is None:
753 return "warning"
754 return _LS2DEBPUTY_SEVERITY.get(severity, "warning")
757class TermLintReport(LintReport):
759 def __init__(self, fo: IOBasedOutputStyling) -> None:
760 super().__init__()
761 self.fo = fo
763 def finish_report(self) -> None:
764 # Nothing to do for now
765 pass
767 def process_diagnostic(
768 self,
769 filename: str,
770 lint_state: LintState,
771 diagnostic_result: LintDiagnosticResult,
772 ) -> None:
773 diagnostic = diagnostic_result.diagnostic
774 fo = self.fo
775 severity = diagnostic.severity
776 assert severity is not None
777 if diagnostic_result.result_state != LintDiagnosticResultState.FIXED:
778 tag_unresolved = _TERM_SEVERITY2TAG[severity]
779 lint_tag: LintSeverity | None = debputy_severity(diagnostic)
780 tag = tag_unresolved(fo, lint_tag)
781 else:
782 tag = fo.colored(
783 "auto-fixing",
784 fg="magenta",
785 bg="black",
786 style="bold",
787 )
789 if diagnostic_result.is_file_level_diagnostic:
790 start_line = 0
791 start_position = 0
792 end_line = 0
793 end_position = 0
794 else:
795 start_line = diagnostic.range.start.line
796 start_position = diagnostic.range.start.character
797 end_line = diagnostic.range.end.line
798 end_position = diagnostic.range.end.character
800 authority = diagnostic.source
801 assert authority is not None
802 diag_tags = f" [{authority}]"
803 lines = lint_state.lines
804 line_no_format_width = len(str(len(lines)))
806 if diagnostic_result.result_state == LintDiagnosticResultState.AUTO_FIXABLE:
807 diag_tags += "[Correctable via --auto-fix]"
808 elif diagnostic_result.result_state == LintDiagnosticResultState.MANUAL_FIXABLE:
809 diag_tags += "[LSP interactive quickfix]"
811 code = f"[{diagnostic.code}]: " if diagnostic.code else ""
812 msg = f"{code}{diagnostic.message}"
814 print(
815 f"{tag}: File: {filename}:{start_line+1}:{start_position}:{end_line+1}:{end_position}: {msg}{diag_tags}",
816 )
817 if diagnostic_result.missing_severity:
818 _warn(
819 " This warning did not have an explicit severity; Used Warning as a fallback!"
820 )
821 if diagnostic_result.result_state == LintDiagnosticResultState.FIXED:
822 # If it is fixed, there is no reason to show additional context.
823 return
824 if diagnostic_result.is_file_level_diagnostic:
825 print(" File-level diagnostic")
826 return
827 if diagnostic_result.has_broken_range:
828 _warn(
829 "Bug in the underlying linter: The line numbers of the warning does not fit in the file..."
830 )
831 return
832 self._print_range_context(diagnostic.range, lines, line_no_format_width)
833 related_info_list = diagnostic.related_information or []
834 hint_tag = fo.colored(
835 "Related information",
836 fg="magenta",
837 bg="black",
838 style="bold",
839 )
840 for related_info in related_info_list:
841 if related_info.location.uri != lint_state.doc_uri:
842 continue
843 print(f" {hint_tag}: {related_info.message}")
844 self._print_range_context(
845 related_info.location.range, lines, line_no_format_width
846 )
848 def _print_range_context(
849 self,
850 print_range: types.Range,
851 lines: list[str],
852 line_no_format_width: int,
853 ) -> None:
854 lines_to_print = _lines_to_print(print_range)
855 fo = self.fo
856 start_line = print_range.start.line
857 for line_no in range(start_line, start_line + lines_to_print):
858 line = _highlight_range(fo, lines[line_no], line_no, print_range)
859 print(f" {line_no + 1:{line_no_format_width}}: {line}")
862class LinterPositionCodec:
864 def client_num_units(self, chars: str):
865 return len(chars)
867 def position_from_client_units(
868 self,
869 lines: list[str],
870 position: types.Position,
871 ) -> types.Position:
873 if len(lines) == 0:
874 return types.Position(0, 0)
875 if position.line >= len(lines):
876 return types.Position(len(lines) - 1, self.client_num_units(lines[-1]))
877 return position
879 def position_to_client_units(
880 self,
881 _lines: list[str],
882 position: types.Position,
883 ) -> types.Position:
884 return position
886 def range_from_client_units(
887 self, _lines: list[str], range: types.Range
888 ) -> types.Range:
889 return range
891 def range_to_client_units(
892 self, _lines: list[str], range: types.Range
893 ) -> types.Range:
894 return range
897LINTER_POSITION_CODEC = LinterPositionCodec()
900def _lines_to_print(range_: types.Range) -> int:
901 count = range_.end.line - range_.start.line
902 if range_.end.character > 0:
903 count += 1
904 return count
907def _highlight_range(
908 fo: IOBasedOutputStyling,
909 line: str,
910 line_no: int,
911 range_: types.Range,
912) -> str:
913 line_wo_nl = line.rstrip("\r\n")
914 start_pos = 0
915 prefix = ""
916 suffix = ""
917 if line_no == range_.start.line:
918 start_pos = range_.start.character
919 prefix = line_wo_nl[0:start_pos]
920 if line_no == range_.end.line:
921 end_pos = range_.end.character
922 suffix = line_wo_nl[end_pos:]
923 else:
924 end_pos = len(line_wo_nl)
926 marked_part = fo.colored(line_wo_nl[start_pos:end_pos], fg="red", style="bold")
928 return prefix + marked_part + suffix
931def _is_file_level_diagnostic(
932 lines: list[str],
933 start_line: int,
934 start_position: int,
935 end_line: int,
936 end_position: int,
937) -> bool:
938 if start_line != 0 or start_position != 0:
939 return False
940 line_count = len(lines)
941 if end_line + 1 == line_count and end_position == 0:
942 return True
943 return (
944 end_line == line_count and bool(line_count) and end_position == len(lines[-1])
945 )