Coverage for src/debputy/lsp/lsp_debian_control_reference_data.py: 83%
1362 statements
« prev ^ index » next coverage.py v7.8.2, created at 2025-09-07 09:27 +0000
« prev ^ index » next coverage.py v7.8.2, created at 2025-09-07 09:27 +0000
1import collections
2import dataclasses
3import functools
4import importlib.resources
5import itertools
6import operator
7import os.path
8import re
9import textwrap
10from abc import ABC
11from typing import (
12 FrozenSet,
13 Optional,
14 cast,
15 Mapping,
16 Iterable,
17 List,
18 Generic,
19 TypeVar,
20 Union,
21 Callable,
22 Tuple,
23 Any,
24 Set,
25 TYPE_CHECKING,
26 Sequence,
27 Dict,
28 Iterator,
29 Container,
30 Self,
31)
33from debian.debian_support import DpkgArchTable, Version
35import debputy.lsp.data.deb822_data as deb822_ref_data_dir
36from debputy.filesystem_scan import VirtualPathBase
37from debputy.linting.lint_util import LintState, with_range_in_continuous_parts
38from debputy.linting.lint_util import te_range_to_lsp
39from debputy.lsp.diagnostics import LintSeverity
40from debputy.lsp.lsp_reference_keyword import (
41 Keyword,
42 allowed_values,
43 format_comp_item_synopsis_doc,
44 LSP_DATA_DOMAIN,
45 ALL_PUBLIC_NAMED_STYLES_AS_KEYWORDS,
46)
47from debputy.lsp.quickfixes import (
48 propose_correct_text_quick_fix,
49 propose_remove_range_quick_fix,
50)
51from debputy.lsp.ref_models.deb822_reference_parse_models import (
52 Deb822ReferenceData,
53 DEB822_REFERENCE_DATA_PARSER,
54 FieldValueClass,
55 StaticValue,
56 Deb822Field,
57 UsageHint,
58 Alias,
59)
60from debputy.lsp.text_edit import apply_text_edits
61from debputy.lsp.text_util import (
62 normalize_dctrl_field_name,
63 LintCapablePositionCodec,
64 trim_end_of_line_whitespace,
65)
66from debputy.lsp.vendoring._deb822_repro.parsing import (
67 Deb822KeyValuePairElement,
68 LIST_SPACE_SEPARATED_INTERPRETATION,
69 Deb822ParagraphElement,
70 Deb822FileElement,
71 Interpretation,
72 parse_deb822_file,
73 Deb822ParsedTokenList,
74 Deb822ValueLineElement,
75)
76from debputy.lsp.vendoring._deb822_repro.tokens import (
77 Deb822FieldNameToken,
78)
79from debputy.lsp.vendoring._deb822_repro.types import FormatterCallback
80from debputy.lsp.vendoring._deb822_repro.types import TE
81from debputy.lsp.vendoring.wrap_and_sort import _sort_packages_key
82from debputy.lsprotocol.types import (
83 DiagnosticTag,
84 Range,
85 TextEdit,
86 Position,
87 CompletionItem,
88 MarkupContent,
89 CompletionItemTag,
90 MarkupKind,
91 CompletionItemKind,
92 CompletionItemLabelDetails,
93)
94from debputy.manifest_parser.exceptions import ManifestParseException
95from debputy.manifest_parser.util import AttributePath
96from debputy.path_matcher import BasenameGlobMatch
97from debputy.plugin.api import VirtualPath
98from debputy.util import PKGNAME_REGEX, _info, detect_possible_typo, _error
99from debputy.yaml import MANIFEST_YAML
101try:
102 from debputy.lsp.vendoring._deb822_repro.locatable import (
103 Position as TEPosition,
104 Range as TERange,
105 START_POSITION,
106 )
107except ImportError:
108 pass
111if TYPE_CHECKING:
112 from debputy.lsp.maint_prefs import EffectiveFormattingPreference
113 from debputy.lsp.debputy_ls import DebputyLanguageServer
116F = TypeVar("F", bound="Deb822KnownField", covariant=True)
117S = TypeVar("S", bound="StanzaMetadata")
120SUBSTVAR_RE = re.compile(r"[$][{][a-zA-Z0-9][a-zA-Z0-9-:]*[}]")
122_RE_SYNOPSIS_STARTS_WITH_ARTICLE = re.compile(r"^\s*(an?|the)(?:\s|$)", re.I)
123_RE_SV = re.compile(r"(\d+[.]\d+[.]\d+)([.]\d+)?")
124_RE_SYNOPSIS_IS_TEMPLATE = re.compile(
125 r"^\s*(missing|<insert up to \d+ chars description>)$"
126)
127_RE_SYNOPSIS_IS_TOO_SHORT = re.compile(r"^\s*(\S+)$")
128CURRENT_STANDARDS_VERSION = Version("4.7.2")
131CustomFieldCheck = Callable[
132 [
133 "F",
134 Deb822FileElement,
135 Deb822KeyValuePairElement,
136 "TERange",
137 "TERange",
138 Deb822ParagraphElement,
139 "TEPosition",
140 LintState,
141 ],
142 None,
143]
146@functools.lru_cache
147def all_package_relationship_fields() -> Mapping[str, str]:
148 # TODO: Pull from `dpkg-dev` when possible fallback only to the static list.
149 return {
150 f.lower(): f
151 for f in (
152 "Pre-Depends",
153 "Depends",
154 "Recommends",
155 "Suggests",
156 "Enhances",
157 "Conflicts",
158 "Breaks",
159 "Replaces",
160 "Provides",
161 "Built-Using",
162 "Static-Built-Using",
163 )
164 }
167@functools.lru_cache
168def all_source_relationship_fields() -> Mapping[str, str]:
169 # TODO: Pull from `dpkg-dev` when possible fallback only to the static list.
170 return {
171 f.lower(): f
172 for f in (
173 "Build-Depends",
174 "Build-Depends-Arch",
175 "Build-Depends-Indep",
176 "Build-Conflicts",
177 "Build-Conflicts-Arch",
178 "Build-Conflicts-Indep",
179 )
180 }
183ALL_SECTIONS_WITHOUT_COMPONENT = frozenset(
184 [
185 "admin",
186 "cli-mono",
187 "comm",
188 "database",
189 "debian-installer",
190 "debug",
191 "devel",
192 "doc",
193 "editors",
194 "education",
195 "electronics",
196 "embedded",
197 "fonts",
198 "games",
199 "gnome",
200 "gnu-r",
201 "gnustep",
202 "golang",
203 "graphics",
204 "hamradio",
205 "haskell",
206 "httpd",
207 "interpreters",
208 "introspection",
209 "java",
210 "javascript",
211 "kde",
212 "kernel",
213 "libdevel",
214 "libs",
215 "lisp",
216 "localization",
217 "mail",
218 "math",
219 "metapackages",
220 "misc",
221 "net",
222 "news",
223 "ocaml",
224 "oldlibs",
225 "otherosfs",
226 "perl",
227 "php",
228 "python",
229 "ruby",
230 "rust",
231 "science",
232 "shells",
233 "sound",
234 "tasks",
235 "tex",
236 "text",
237 "utils",
238 "vcs",
239 "video",
240 "virtual",
241 "web",
242 "x11",
243 "xfce",
244 "zope",
245 ]
246)
248ALL_COMPONENTS = frozenset(
249 [
250 "main",
251 "restricted", # Ubuntu
252 "non-free",
253 "non-free-firmware",
254 "contrib",
255 ]
256)
259def _fields(*fields: F) -> Mapping[str, F]:
260 return {normalize_dctrl_field_name(f.name.lower()): f for f in fields}
263def _complete_section_sort_hint(
264 keyword: Keyword,
265 _lint_state: LintState,
266 stanza_parts: Sequence[Deb822ParagraphElement],
267 _value_being_completed: str,
268) -> Optional[str]:
269 for stanza in stanza_parts: 269 ↛ 274line 269 didn't jump to line 274 because the loop on line 269 didn't complete
270 pkg = stanza.get("Package")
271 if pkg is not None: 271 ↛ 269line 271 didn't jump to line 269 because the condition on line 271 was always true
272 break
273 else:
274 return None
275 section = package_name_to_section(pkg)
276 value_parts = keyword.value.rsplit("/", 1)
277 keyword_section = value_parts[-1]
278 keyword_component = f" ({value_parts[0]})" if len(value_parts) > 1 else ""
279 if section is None:
280 if keyword_component == "":
281 return keyword_section
282 return f"zz-{keyword_section}{keyword_component}"
283 if keyword_section != section:
284 return f"zz-{keyword_section}{keyword_component}"
285 return f"aa-{keyword_section}{keyword_component}"
288ALL_SECTIONS = allowed_values(
289 *[
290 Keyword(
291 s if c is None else f"{c}/{s}",
292 sort_text=_complete_section_sort_hint,
293 replaced_by=s if c == "main" else None,
294 )
295 for c, s in itertools.product(
296 itertools.chain(cast("Iterable[Optional[str]]", [None]), ALL_COMPONENTS),
297 ALL_SECTIONS_WITHOUT_COMPONENT,
298 )
299 ]
300)
303def all_architectures_and_wildcards(
304 arch2table, *, allow_negations: bool = False
305) -> Iterable[Union[str, Keyword]]:
306 wildcards = set()
307 yield Keyword(
308 "any",
309 is_exclusive=True,
310 synopsis="Built once per machine architecture (native code, such as C/C++, interpreter to C bindings)",
311 long_description=textwrap.dedent(
312 """\
313 This is an architecture-dependent package, and needs to be
314 compiled for each and every architecture.
316 The name `any` refers to the fact that this is an architecture
317 *wildcard* matching *any machine architecture* supported by
318 dpkg.
319 """
320 ),
321 )
322 yield Keyword(
323 "all",
324 is_exclusive=True,
325 synopsis="Independent of machine architecture (scripts, data, documentation, or Java without JNI)",
326 long_description=textwrap.dedent(
327 """\
328 The package is an architecture independent package. This is
329 typically appropriate for packages containing only scripts,
330 data or documentation.
332 The name `all` refers to the fact that the same build of a package
333 can be used for *all* architectures. Though note that it is still
334 subject to the rules of the `Multi-Arch` field.
335 """
336 ),
337 )
338 for arch_name, quad_tuple in arch2table.items():
339 yield arch_name
340 if allow_negations:
341 yield f"!{arch_name}"
342 cpu_wc = "any-" + quad_tuple.cpu_name
343 os_wc = quad_tuple.os_name + "-any"
344 if cpu_wc not in wildcards:
345 yield cpu_wc
346 if allow_negations:
347 yield f"!{cpu_wc}"
348 wildcards.add(cpu_wc)
349 if os_wc not in wildcards:
350 yield os_wc
351 if allow_negations:
352 yield f"!{os_wc}"
353 wildcards.add(os_wc)
354 # Add the remaining wildcards
357@functools.lru_cache
358def dpkg_arch_and_wildcards(*, allow_negations=False) -> FrozenSet[Union[str, Keyword]]:
359 dpkg_arch_table = DpkgArchTable.load_arch_table()
360 return frozenset(
361 all_architectures_and_wildcards(
362 dpkg_arch_table._arch2table,
363 allow_negations=allow_negations,
364 )
365 )
368def extract_first_value_and_position(
369 kvpair: Deb822KeyValuePairElement,
370 stanza_pos: "TEPosition",
371 *,
372 interpretation: Interpretation[
373 Deb822ParsedTokenList[Any, Any]
374 ] = LIST_SPACE_SEPARATED_INTERPRETATION,
375) -> Tuple[Optional[str], Optional[TERange]]:
376 kvpair_pos = kvpair.position_in_parent().relative_to(stanza_pos)
377 value_element_pos = kvpair.value_element.position_in_parent().relative_to(
378 kvpair_pos
379 )
380 for value_ref in kvpair.interpret_as(interpretation).iter_value_references(): 380 ↛ 387line 380 didn't jump to line 387 because the loop on line 380 didn't complete
381 v = value_ref.value
382 section_value_loc = value_ref.locatable
383 value_range_te = section_value_loc.range_in_parent().relative_to(
384 value_element_pos
385 )
386 return v, value_range_te
387 return None, None
390def _sv_field_validation(
391 known_field: "F",
392 _deb822_file: Deb822FileElement,
393 kvpair: Deb822KeyValuePairElement,
394 _kvpair_range: "TERange",
395 _field_name_range_te: "TERange",
396 _stanza: Deb822ParagraphElement,
397 stanza_position: "TEPosition",
398 lint_state: LintState,
399) -> None:
400 sv_value, sv_value_range = extract_first_value_and_position(
401 kvpair,
402 stanza_position,
403 )
404 m = _RE_SV.fullmatch(sv_value)
405 if m is None:
406 lint_state.emit_diagnostic(
407 sv_value_range,
408 f'Not a valid standards version. Current version is "{CURRENT_STANDARDS_VERSION}"',
409 "warning",
410 known_field.unknown_value_authority,
411 )
412 return
414 sv_version = Version(sv_value)
415 if sv_version < CURRENT_STANDARDS_VERSION:
416 lint_state.emit_diagnostic(
417 sv_value_range,
418 f"Latest Standards-Version is {CURRENT_STANDARDS_VERSION}",
419 "informational",
420 known_field.unknown_value_authority,
421 )
422 return
423 extra = m.group(2)
424 if extra:
425 extra_len = lint_state.position_codec.client_num_units(extra)
426 lint_state.emit_diagnostic(
427 TERange.between(
428 TEPosition(
429 sv_value_range.end_pos.line_position,
430 sv_value_range.end_pos.cursor_position - extra_len,
431 ),
432 sv_value_range.end_pos,
433 ),
434 "Unnecessary version segment. This part of the version is only used for editorial changes",
435 "informational",
436 known_field.unknown_value_authority,
437 quickfixes=[
438 propose_remove_range_quick_fix(
439 proposed_title="Remove unnecessary version part"
440 )
441 ],
442 )
445def _dctrl_ma_field_validation(
446 _known_field: "F",
447 _deb822_file: Deb822FileElement,
448 _kvpair: Deb822KeyValuePairElement,
449 _kvpair_range: "TERange",
450 _field_name_range: "TERange",
451 stanza: Deb822ParagraphElement,
452 stanza_position: "TEPosition",
453 lint_state: LintState,
454) -> None:
455 ma_kvpair = stanza.get_kvpair_element(("Multi-Arch", 0), use_get=True)
456 arch = stanza.get("Architecture", "any")
457 if arch == "all" and ma_kvpair is not None: 457 ↛ exitline 457 didn't return from function '_dctrl_ma_field_validation' because the condition on line 457 was always true
458 ma_value, ma_value_range = extract_first_value_and_position(
459 ma_kvpair,
460 stanza_position,
461 )
462 if ma_value == "same":
463 lint_state.emit_diagnostic(
464 ma_value_range,
465 "Multi-Arch: same is not valid for Architecture: all packages. Maybe you want foreign?",
466 "error",
467 "debputy",
468 )
471def _udeb_only_field_validation(
472 known_field: "F",
473 _deb822_file: Deb822FileElement,
474 _kvpair: Deb822KeyValuePairElement,
475 _kvpair_range: "TERange",
476 field_name_range: "TERange",
477 stanza: Deb822ParagraphElement,
478 _stanza_position: "TEPosition",
479 lint_state: LintState,
480) -> None:
481 package_type = stanza.get("Package-Type")
482 if package_type != "udeb":
483 lint_state.emit_diagnostic(
484 field_name_range,
485 f"The {known_field.name} field is only applicable to udeb packages (`Package-Type: udeb`)",
486 "warning",
487 "debputy",
488 )
491def _complete_only_in_arch_dep_pkgs(
492 stanza_parts: Iterable[Deb822ParagraphElement],
493) -> bool:
494 for stanza in stanza_parts:
495 arch = stanza.get("Architecture")
496 if arch is None:
497 continue
498 archs = arch.split()
499 return "all" not in archs
500 return False
503def _complete_only_for_udeb_pkgs(
504 stanza_parts: Iterable[Deb822ParagraphElement],
505) -> bool:
506 for stanza in stanza_parts:
507 for option in ("Package-Type", "XC-Package-Type"):
508 pkg_type = stanza.get(option)
509 if pkg_type is not None:
510 return pkg_type == "udeb"
511 return False
514def _arch_not_all_only_field_validation(
515 known_field: "F",
516 _deb822_file: Deb822FileElement,
517 _kvpair: Deb822KeyValuePairElement,
518 _kvpair_range_te: "TERange",
519 field_name_range_te: "TERange",
520 stanza: Deb822ParagraphElement,
521 _stanza_position: "TEPosition",
522 lint_state: LintState,
523) -> None:
524 architecture = stanza.get("Architecture")
525 if architecture == "all": 525 ↛ exitline 525 didn't return from function '_arch_not_all_only_field_validation' because the condition on line 525 was always true
526 lint_state.emit_diagnostic(
527 field_name_range_te,
528 f"The {known_field.name} field is not applicable to arch:all packages (`Architecture: all`)",
529 "warning",
530 "debputy",
531 )
534def _single_line_span_range_relative_to_pos(
535 span: Tuple[int, int],
536 relative_to: "TEPosition",
537) -> Range:
538 return TERange(
539 TEPosition(
540 relative_to.line_position,
541 relative_to.cursor_position + span[0],
542 ),
543 TEPosition(
544 relative_to.line_position,
545 relative_to.cursor_position + span[1],
546 ),
547 )
550def _check_extended_description_line(
551 description_value_line: Deb822ValueLineElement,
552 description_line_range_te: "TERange",
553 package: Optional[str],
554 lint_state: LintState,
555) -> None:
556 if description_value_line.comment_element is not None: 556 ↛ 559line 556 didn't jump to line 559 because the condition on line 556 was never true
557 # TODO: Fix this limitation (we get the content and the range wrong with comments.
558 # They are rare inside a Description, so this is a 80:20 trade off
559 return
560 description_line_with_leading_space = (
561 description_value_line.convert_to_text().rstrip()
562 )
563 try:
564 idx = description_line_with_leading_space.index(
565 "<insert long description, indented with spaces>"
566 )
567 except ValueError:
568 pass
569 else:
570 template_span = idx, idx + len(
571 "<insert long description, indented with spaces>"
572 )
573 lint_state.emit_diagnostic(
574 _single_line_span_range_relative_to_pos(
575 template_span,
576 description_line_range_te.start_pos,
577 ),
578 "Unfilled or left-over template from dh_make",
579 "error",
580 "debputy",
581 )
582 if len(description_line_with_leading_space) > 80:
583 # Policy says nothing here, but lintian has 80 characters as hard limit and that
584 # probably matches the limitation of package manager UIs (TUIs/GUIs) somewhere.
585 #
586 # See also debputy#122
587 span = 80, len(description_line_with_leading_space)
588 lint_state.emit_diagnostic(
589 _single_line_span_range_relative_to_pos(
590 span,
591 description_line_range_te.start_pos,
592 ),
593 "Package description line is too long; please line wrap it.",
594 "warning",
595 "debputy",
596 )
599def _check_synopsis(
600 synopsis_value_line: Deb822ValueLineElement,
601 synopsis_range_te: "TERange",
602 field_name_range_te: "TERange",
603 package: Optional[str],
604 lint_state: LintState,
605) -> None:
606 # This function would compute range would be wrong if there is a comment
607 assert synopsis_value_line.comment_element is None
608 synopsis_text_with_leading_space = synopsis_value_line.convert_to_text().rstrip()
609 if not synopsis_text_with_leading_space:
610 lint_state.emit_diagnostic(
611 field_name_range_te,
612 "Package synopsis is missing",
613 "warning",
614 "debputy",
615 )
616 return
617 synopsis_text_trimmed = synopsis_text_with_leading_space.lstrip()
618 synopsis_offset = len(synopsis_text_with_leading_space) - len(synopsis_text_trimmed)
619 starts_with_article = _RE_SYNOPSIS_STARTS_WITH_ARTICLE.search(
620 synopsis_text_with_leading_space
621 )
622 # TODO: Handle ${...} expansion
623 if starts_with_article:
624 lint_state.emit_diagnostic(
625 _single_line_span_range_relative_to_pos(
626 starts_with_article.span(1),
627 synopsis_range_te.start_pos,
628 ),
629 "Package synopsis starts with an article (a/an/the).",
630 "warning",
631 "DevRef 6.2.2",
632 )
633 if len(synopsis_text_trimmed) >= 80:
634 # Policy says `certainly under 80 characters.`, so exactly 80 characters is considered bad too.
635 span = synopsis_offset + 79, len(synopsis_text_with_leading_space)
636 lint_state.emit_diagnostic(
637 _single_line_span_range_relative_to_pos(
638 span,
639 synopsis_range_te.start_pos,
640 ),
641 "Package synopsis is too long.",
642 "warning",
643 "Policy 3.4.1",
644 )
645 if template_match := _RE_SYNOPSIS_IS_TEMPLATE.match(
646 synopsis_text_with_leading_space
647 ):
648 lint_state.emit_diagnostic(
649 _single_line_span_range_relative_to_pos(
650 template_match.span(1),
651 synopsis_range_te.start_pos,
652 ),
653 "Package synopsis is a placeholder",
654 "warning",
655 "debputy",
656 )
657 elif too_short_match := _RE_SYNOPSIS_IS_TOO_SHORT.match(
658 synopsis_text_with_leading_space
659 ):
660 if not SUBSTVAR_RE.match(synopsis_text_with_leading_space.strip()):
661 lint_state.emit_diagnostic(
662 _single_line_span_range_relative_to_pos(
663 too_short_match.span(1),
664 synopsis_range_te.start_pos,
665 ),
666 "Package synopsis is too short",
667 "warning",
668 "debputy",
669 )
672def dctrl_description_validator(
673 _known_field: "F",
674 _deb822_file: Deb822FileElement,
675 kvpair: Deb822KeyValuePairElement,
676 kvpair_range_te: "TERange",
677 _field_name_range: "TERange",
678 stanza: Deb822ParagraphElement,
679 _stanza_position: "TEPosition",
680 lint_state: LintState,
681) -> None:
682 value_lines = kvpair.value_element.value_lines
683 if not value_lines: 683 ↛ 684line 683 didn't jump to line 684 because the condition on line 683 was never true
684 return
685 package = stanza.get("Package")
686 synopsis_value_line = value_lines[0]
687 value_range_te = kvpair.value_element.range_in_parent().relative_to(
688 kvpair_range_te.start_pos
689 )
690 synopsis_line_range_te = synopsis_value_line.range_in_parent().relative_to(
691 value_range_te.start_pos
692 )
693 if synopsis_value_line.continuation_line_token is None: 693 ↛ 706line 693 didn't jump to line 706 because the condition on line 693 was always true
694 field_name_range_te = kvpair.field_token.range_in_parent().relative_to(
695 kvpair_range_te.start_pos
696 )
697 _check_synopsis(
698 synopsis_value_line,
699 synopsis_line_range_te,
700 field_name_range_te,
701 package,
702 lint_state,
703 )
704 description_lines = value_lines[1:]
705 else:
706 description_lines = value_lines
707 for description_line in description_lines:
708 description_line_range_te = description_line.range_in_parent().relative_to(
709 value_range_te.start_pos
710 )
711 _check_extended_description_line(
712 description_line,
713 description_line_range_te,
714 package,
715 lint_state,
716 )
719def _has_packaging_expected_file(
720 name: str,
721 msg: str,
722 severity: LintSeverity = "error",
723) -> CustomFieldCheck:
725 def _impl(
726 _known_field: "F",
727 _deb822_file: Deb822FileElement,
728 _kvpair: Deb822KeyValuePairElement,
729 kvpair_range_te: "TERange",
730 _field_name_range_te: "TERange",
731 _stanza: Deb822ParagraphElement,
732 _stanza_position: "TEPosition",
733 lint_state: LintState,
734 ) -> None:
735 debian_dir = lint_state.debian_dir
736 if debian_dir is None:
737 return
738 cpy = debian_dir.lookup(name)
739 if not cpy: 739 ↛ 740line 739 didn't jump to line 740 because the condition on line 739 was never true
740 lint_state.emit_diagnostic(
741 kvpair_range_te,
742 msg,
743 severity,
744 "debputy",
745 diagnostic_applies_to_another_file=f"debian/{name}",
746 )
748 return _impl
751_check_missing_debian_rules = _has_packaging_expected_file(
752 "rules",
753 'Missing debian/rules when the Build-Driver is unset or set to "debian-rules"',
754)
757def _has_build_instructions(
758 known_field: "F",
759 deb822_file: Deb822FileElement,
760 kvpair: Deb822KeyValuePairElement,
761 kvpair_range_te: "TERange",
762 field_name_range_te: "TERange",
763 stanza: Deb822ParagraphElement,
764 stanza_position: "TEPosition",
765 lint_state: LintState,
766) -> None:
767 if stanza.get("Build-Driver", "debian-rules").lower() != "debian-rules":
768 return
770 _check_missing_debian_rules(
771 known_field,
772 deb822_file,
773 kvpair,
774 kvpair_range_te,
775 field_name_range_te,
776 stanza,
777 stanza_position,
778 lint_state,
779 )
782def _maintainer_field_validator(
783 known_field: "F",
784 _deb822_file: Deb822FileElement,
785 kvpair: Deb822KeyValuePairElement,
786 kvpair_range_te: "TERange",
787 _field_name_range_te: "TERange",
788 _stanza: Deb822ParagraphElement,
789 _stanza_position: "TEPosition",
790 lint_state: LintState,
791) -> None:
793 value_element_pos = kvpair.value_element.position_in_parent().relative_to(
794 kvpair_range_te.start_pos
795 )
796 interpreted_value = kvpair.interpret_as(known_field.field_value_class.interpreter())
797 for part in interpreted_value.iter_parts():
798 if not part.is_separator:
799 continue
800 value_range_te = part.range_in_parent().relative_to(value_element_pos)
801 severity = known_field.unknown_value_severity
802 assert severity is not None
803 # TODO: Check for a follow up maintainer and based on that the quick fix is either
804 # to remove the dead separator OR move the trailing data into `Uploaders`
805 lint_state.emit_diagnostic(
806 value_range_te,
807 'The "Maintainer" field has a trailing separator, but it is a single value field.',
808 severity,
809 known_field.unknown_value_authority,
810 )
813def _use_https_instead_of_http(
814 known_field: "F",
815 _deb822_file: Deb822FileElement,
816 kvpair: Deb822KeyValuePairElement,
817 kvpair_range_te: "TERange",
818 _field_name_range_te: "TERange",
819 _stanza: Deb822ParagraphElement,
820 _stanza_position: "TEPosition",
821 lint_state: LintState,
822) -> None:
823 value_element_pos = kvpair.value_element.position_in_parent().relative_to(
824 kvpair_range_te.start_pos
825 )
826 interpreted_value = kvpair.interpret_as(known_field.field_value_class.interpreter())
827 for part in interpreted_value.iter_parts():
828 value = part.convert_to_text()
829 if not value.startswith("http://"):
830 continue
831 value_range_te = part.range_in_parent().relative_to(value_element_pos)
832 problem_range_te = TERange.between(
833 value_range_te.start_pos,
834 TEPosition(
835 value_range_te.start_pos.line_position,
836 value_range_te.start_pos.cursor_position + 7,
837 ),
838 )
839 lint_state.emit_diagnostic(
840 problem_range_te,
841 "The Format URL should use https:// rather than http://",
842 "warning",
843 "debputy",
844 quickfixes=[propose_correct_text_quick_fix("https://")],
845 )
848def _each_value_match_regex_validation(
849 regex: re.Pattern,
850 *,
851 diagnostic_severity: LintSeverity = "error",
852 authority_reference: Optional[str] = None,
853) -> CustomFieldCheck:
855 def _validator(
856 known_field: "F",
857 _deb822_file: Deb822FileElement,
858 kvpair: Deb822KeyValuePairElement,
859 kvpair_range_te: "TERange",
860 _field_name_range_te: "TERange",
861 _stanza: Deb822ParagraphElement,
862 _stanza_position: "TEPosition",
863 lint_state: LintState,
864 ) -> None:
865 nonlocal authority_reference
866 interpreter = known_field.field_value_class.interpreter()
867 if interpreter is None:
868 raise AssertionError(
869 f"{known_field.name} has field type {known_field.field_value_class}, which cannot be"
870 f" regex validated since it does not have a tokenization"
871 )
872 auth_ref = (
873 authority_reference
874 if authority_reference is not None
875 else known_field.unknown_value_authority
876 )
878 value_element_pos = kvpair.value_element.position_in_parent().relative_to(
879 kvpair_range_te.start_pos
880 )
881 for value_ref in kvpair.interpret_as(interpreter).iter_value_references():
882 v = value_ref.value
883 m = regex.fullmatch(v)
884 if m is not None:
885 continue
887 if "${" in v:
888 # Ignore substvars
889 continue
891 section_value_loc = value_ref.locatable
892 value_range_te = section_value_loc.range_in_parent().relative_to(
893 value_element_pos
894 )
895 lint_state.emit_diagnostic(
896 value_range_te,
897 f'The value "{v}" does not match the regex {regex.pattern}.',
898 diagnostic_severity,
899 auth_ref,
900 )
902 return _validator
905_DEP_OR_RELATION = re.compile(r"[|]")
906_DEP_RELATION_CLAUSE = re.compile(
907 r"""
908 ^
909 \s*
910 (?P<name_arch_qual>[-+.a-zA-Z0-9${}:]{2,})
911 \s*
912 (?: [(] \s* (?P<operator>>>|>=|>|=|<|<=|<<) \s* (?P<version> [0-9$][^)]*|[$][{]\S+[}]) \s* [)] \s* )?
913 (?: \[ (?P<arch_restriction> [\s!\w\-]+) ] \s*)?
914 (?: < (?P<build_profile_restriction> .+ ) > \s*)?
915 ((?P<garbage>\S.*)\s*)?
916 $
917""",
918 re.VERBOSE | re.MULTILINE,
919)
922def _span_to_te_range(
923 text: str,
924 start_pos: int,
925 end_pos: int,
926) -> TERange:
927 prefix = text[0:start_pos]
928 prefix_plus_text = text[0:end_pos]
930 start_line = prefix.count("\n")
931 if start_line:
932 start_newline_offset = prefix.rindex("\n")
933 # +1 to skip past the newline
934 start_cursor_pos = start_pos - (start_newline_offset + 1)
935 else:
936 start_cursor_pos = start_pos
938 end_line = prefix_plus_text.count("\n")
939 if end_line == start_line:
940 end_cursor_pos = start_cursor_pos + (end_pos - start_pos)
941 else:
942 end_newline_offset = prefix_plus_text.rindex("\n")
943 end_cursor_pos = end_pos - (end_newline_offset + 1)
945 return TERange(
946 TEPosition(
947 start_line,
948 start_cursor_pos,
949 ),
950 TEPosition(
951 end_line,
952 end_cursor_pos,
953 ),
954 )
957def _split_w_spans(
958 v: str,
959 sep: str,
960 *,
961 offset: int = 0,
962) -> Sequence[Tuple[str, int, int]]:
963 separator_size = len(sep)
964 parts = v.split(sep)
965 for part in parts:
966 size = len(part)
967 end_offset = offset + size
968 yield part, offset, end_offset
969 offset = end_offset + separator_size
972_COLLAPSE_WHITESPACE = re.compile(r"\s+")
975def _cleanup_rel(rel: str) -> str:
976 return _COLLAPSE_WHITESPACE.sub(" ", rel.strip())
979def _text_to_te_position(text: str) -> "TEPosition":
980 newlines = text.count("\n")
981 if not newlines:
982 return TEPosition(
983 newlines,
984 len(text),
985 )
986 last_newline_offset = text.rindex("\n")
987 line_offset = len(text) - (last_newline_offset + 1)
988 return TEPosition(
989 newlines,
990 line_offset,
991 )
994@dataclasses.dataclass(slots=True, frozen=True)
995class Relation:
996 name: str
997 arch_qual: Optional[str] = None
998 version_operator: Optional[str] = None
999 version: Optional[str] = None
1000 arch_restriction: Optional[str] = None
1001 build_profile_restriction: Optional[str] = None
1002 # These offsets are intended to show the relation itself. They are not
1003 # the relation boundary offsets (they will omit leading whitespace as
1004 # an example).
1005 content_display_offset: int = -1
1006 content_display_end_offset: int = -1
1009def relation_key_variations(
1010 relation: Relation,
1011) -> Tuple[str, Optional[str], Optional[str]]:
1012 operator_variants = (
1013 [relation.version_operator, None]
1014 if relation.version_operator is not None
1015 else [None]
1016 )
1017 arch_qual_variants = (
1018 [relation.arch_qual, None]
1019 if relation.arch_qual is not None and relation.arch_qual != "any"
1020 else [None]
1021 )
1022 for arch_qual, version_operator in itertools.product(
1023 arch_qual_variants,
1024 operator_variants,
1025 ):
1026 yield relation.name, arch_qual, version_operator
1029def dup_check_relations(
1030 known_field: "F",
1031 relations: Sequence[Relation],
1032 raw_value_masked_comments: str,
1033 value_element_pos: "TEPosition",
1034 lint_state: LintState,
1035) -> None:
1036 overlap_table = {}
1037 for relation in relations:
1038 version_operator = relation.version_operator
1039 arch_qual = relation.arch_qual
1040 if relation.arch_restriction or relation.build_profile_restriction: 1040 ↛ 1041line 1040 didn't jump to line 1041 because the condition on line 1040 was never true
1041 continue
1043 for relation_key in relation_key_variations(relation):
1044 prev_relation = overlap_table.get(relation_key)
1045 if prev_relation is None:
1046 overlap_table[relation_key] = relation
1047 else:
1048 prev_version_operator = prev_relation.version_operator
1050 if (
1051 prev_version_operator
1052 and version_operator
1053 and prev_version_operator[0] != version_operator[0]
1054 and version_operator[0] in ("<", ">")
1055 and prev_version_operator[0] in ("<", ">")
1056 ):
1057 # foo (>= 1), foo (<< 2) and similar should not trigger a warning.
1058 continue
1060 prev_arch_qual = prev_relation.arch_qual
1061 if (
1062 arch_qual != prev_arch_qual
1063 and prev_arch_qual != "any"
1064 and arch_qual != "any"
1065 ):
1066 # foo:amd64 != foo:native and that might matter - especially for "libfoo-dev, libfoo-dev:native"
1067 #
1068 # This check is probably a too forgiving in some corner cases.
1069 continue
1071 orig_relation_range = TERange(
1072 _text_to_te_position(
1073 raw_value_masked_comments[
1074 : prev_relation.content_display_offset
1075 ]
1076 ),
1077 _text_to_te_position(
1078 raw_value_masked_comments[
1079 : prev_relation.content_display_end_offset
1080 ]
1081 ),
1082 ).relative_to(value_element_pos)
1084 duplicate_relation_range = TERange(
1085 _text_to_te_position(
1086 raw_value_masked_comments[: relation.content_display_offset]
1087 ),
1088 _text_to_te_position(
1089 raw_value_masked_comments[: relation.content_display_end_offset]
1090 ),
1091 ).relative_to(value_element_pos)
1093 lint_state.emit_diagnostic(
1094 duplicate_relation_range,
1095 "Duplicate relationship. Merge with the previous relationship",
1096 "warning",
1097 known_field.unknown_value_authority,
1098 related_information=[
1099 lint_state.related_diagnostic_information(
1100 orig_relation_range,
1101 "The previous definition",
1102 ),
1103 ],
1104 )
1105 # We only emit for the first duplicate "key" for each relation. Odds are remaining
1106 # keys point to the same match. Even if they do not, it does not really matter as
1107 # we already pointed out an issue for the user to follow up on.
1108 break
1111def _dctrl_check_dep_version_operator(
1112 known_field: "F",
1113 version_operator: str,
1114 version_operator_span: Tuple[int, int],
1115 version_operators: FrozenSet[str],
1116 raw_value_masked_comments: str,
1117 offset: int,
1118 value_element_pos: "TEPosition",
1119 lint_state: LintState,
1120) -> bool:
1121 if (
1122 version_operators
1123 and version_operator is not None
1124 and version_operator not in version_operators
1125 ):
1126 v_start_offset = offset + version_operator_span[0]
1127 v_end_offset = offset + version_operator_span[1]
1128 version_problem_range_te = TERange(
1129 _text_to_te_position(raw_value_masked_comments[:v_start_offset]),
1130 _text_to_te_position(raw_value_masked_comments[:v_end_offset]),
1131 ).relative_to(value_element_pos)
1133 sorted_version_operators = sorted(version_operators)
1135 excluding_equal = f"{version_operator}{version_operator}"
1136 including_equal = f"{version_operator}="
1138 if version_operator in (">", "<") and (
1139 excluding_equal in version_operators or including_equal in version_operators
1140 ):
1141 lint_state.emit_diagnostic(
1142 version_problem_range_te,
1143 f'Obsolete version operator "{version_operator}" that is no longer supported.',
1144 "error",
1145 "Policy 7.1",
1146 quickfixes=[
1147 propose_correct_text_quick_fix(n)
1148 for n in (excluding_equal, including_equal)
1149 if not version_operators or n in version_operators
1150 ],
1151 )
1152 else:
1153 lint_state.emit_diagnostic(
1154 version_problem_range_te,
1155 f'The version operator "{version_operator}" is not allowed in {known_field.name}',
1156 "error",
1157 known_field.unknown_value_authority,
1158 quickfixes=[
1159 propose_correct_text_quick_fix(n) for n in sorted_version_operators
1160 ],
1161 )
1162 return True
1163 return False
1166def _dctrl_validate_dep(
1167 known_field: "DF",
1168 _deb822_file: Deb822FileElement,
1169 kvpair: Deb822KeyValuePairElement,
1170 kvpair_range_te: "TERange",
1171 _field_name_range: "TERange",
1172 _stanza: Deb822ParagraphElement,
1173 _stanza_position: "TEPosition",
1174 lint_state: LintState,
1175) -> None:
1176 value_element_pos = kvpair.value_element.position_in_parent().relative_to(
1177 kvpair_range_te.start_pos
1178 )
1179 raw_value_with_comments = kvpair.value_element.convert_to_text()
1180 raw_value_masked_comments = "".join(
1181 (line if not line.startswith("#") else (" " * (len(line) - 1)) + "\n")
1182 for line in raw_value_with_comments.splitlines(keepends=True)
1183 )
1184 if isinstance(known_field, DctrlRelationshipKnownField):
1185 version_operators = known_field.allowed_version_operators
1186 supports_or_relation = known_field.supports_or_relation
1187 else:
1188 version_operators = frozenset({">>", ">=", "=", "<=", "<<"})
1189 supports_or_relation = True
1191 relation_dup_table = collections.defaultdict(list)
1193 for rel, rel_offset, rel_end_offset in _split_w_spans(
1194 raw_value_masked_comments, ","
1195 ):
1196 sub_relations = []
1197 for or_rel, offset, end_offset in _split_w_spans(rel, "|", offset=rel_offset):
1198 if or_rel.isspace():
1199 continue
1200 if sub_relations and not supports_or_relation:
1201 separator_range_te = TERange(
1202 _text_to_te_position(raw_value_masked_comments[: offset - 1]),
1203 _text_to_te_position(raw_value_masked_comments[:offset]),
1204 ).relative_to(value_element_pos)
1205 lint_state.emit_diagnostic(
1206 separator_range_te,
1207 f'The field {known_field.name} does not support "|" (OR) in relations.',
1208 "error",
1209 known_field.unknown_value_authority,
1210 )
1211 m = _DEP_RELATION_CLAUSE.fullmatch(or_rel)
1213 if m is not None:
1214 garbage = m.group("garbage")
1215 version_operator = m.group("operator")
1216 version_operator_span = m.span("operator")
1217 if _dctrl_check_dep_version_operator(
1218 known_field,
1219 version_operator,
1220 version_operator_span,
1221 version_operators,
1222 raw_value_masked_comments,
1223 offset,
1224 value_element_pos,
1225 lint_state,
1226 ):
1227 sub_relations.append(Relation("<BROKEN>"))
1228 else:
1229 name_arch_qual = m.group("name_arch_qual")
1230 if ":" in name_arch_qual:
1231 name, arch_qual = name_arch_qual.split(":", 1)
1232 else:
1233 name = name_arch_qual
1234 arch_qual = None
1235 sub_relations.append(
1236 Relation(
1237 name,
1238 arch_qual=arch_qual,
1239 version_operator=version_operator,
1240 version=m.group("version"),
1241 arch_restriction=m.group("build_profile_restriction"),
1242 build_profile_restriction=m.group(
1243 "build_profile_restriction"
1244 ),
1245 content_display_offset=offset + m.start("name_arch_qual"),
1246 # TODO: This should be trimmed in the end.
1247 content_display_end_offset=rel_end_offset,
1248 )
1249 )
1250 else:
1251 garbage = None
1252 sub_relations.append(Relation("<BROKEN>"))
1254 if m is not None and not garbage:
1255 continue
1256 if m is not None:
1257 garbage_span = m.span("garbage")
1258 garbage_start, garbage_end = garbage_span
1259 error_start_offset = offset + garbage_start
1260 error_end_offset = offset + garbage_end
1261 garbage_part = raw_value_masked_comments[
1262 error_start_offset:error_end_offset
1263 ]
1264 else:
1265 garbage_part = None
1266 error_start_offset = offset
1267 error_end_offset = end_offset
1269 problem_range_te = TERange(
1270 _text_to_te_position(raw_value_masked_comments[:error_start_offset]),
1271 _text_to_te_position(raw_value_masked_comments[:error_end_offset]),
1272 ).relative_to(value_element_pos)
1274 if garbage_part is not None:
1275 if _DEP_RELATION_CLAUSE.fullmatch(garbage_part) is not None:
1276 msg = (
1277 "Trailing data after a relationship that might be a second relationship."
1278 " Is a separator missing before this part?"
1279 )
1280 else:
1281 msg = "Parse error of the relationship. Either a syntax error or a missing separator somewhere."
1282 lint_state.emit_diagnostic(
1283 problem_range_te,
1284 msg,
1285 "error",
1286 known_field.unknown_value_authority,
1287 )
1288 else:
1289 dep = _cleanup_rel(
1290 raw_value_masked_comments[error_start_offset:error_end_offset]
1291 )
1292 lint_state.emit_diagnostic(
1293 problem_range_te,
1294 f'Could not parse "{dep}" as a dependency relation.',
1295 "error",
1296 known_field.unknown_value_authority,
1297 )
1298 if (
1299 len(sub_relations) == 1
1300 and (relation := sub_relations[0]).name != "<BROKEN>"
1301 ):
1302 # We ignore OR-relations in the dup-check for now. We also skip relations with problems.
1303 relation_dup_table[relation.name].append(relation)
1305 for relations in relation_dup_table.values():
1306 if len(relations) > 1:
1307 dup_check_relations(
1308 known_field,
1309 relations,
1310 raw_value_masked_comments,
1311 value_element_pos,
1312 lint_state,
1313 )
1316def _rrr_build_driver_mismatch(
1317 _known_field: "F",
1318 _deb822_file: Deb822FileElement,
1319 _kvpair: Deb822KeyValuePairElement,
1320 kvpair_range_te: "TERange",
1321 _field_name_range: "TERange",
1322 stanza: Deb822ParagraphElement,
1323 _stanza_position: "TEPosition",
1324 lint_state: LintState,
1325) -> None:
1326 dr = stanza.get("Build-Driver", "debian-rules")
1327 if dr != "debian-rules":
1328 lint_state.emit_diagnostic(
1329 kvpair_range_te,
1330 f'The Rules-Requires-Root field is irrelevant for the Build-Driver "{dr}".',
1331 "informational",
1332 "debputy",
1333 quickfixes=[
1334 propose_remove_range_quick_fix(
1335 proposed_title="Remove Rules-Requires-Root"
1336 )
1337 ],
1338 )
1341class Dep5Matcher(BasenameGlobMatch):
1342 def __init__(self, basename_glob: str) -> None:
1343 super().__init__(
1344 basename_glob,
1345 only_when_in_directory=None,
1346 path_type=None,
1347 recursive_match=False,
1348 )
1351def _match_dep5_segment(
1352 current_dir: VirtualPathBase, basename_glob: str
1353) -> Iterable[VirtualPathBase]:
1354 if "*" in basename_glob or "?" in basename_glob:
1355 return Dep5Matcher(basename_glob).finditer(current_dir)
1356 else:
1357 res = current_dir.get(basename_glob)
1358 if res is None:
1359 return tuple()
1360 return (res,)
1363_RE_SLASHES = re.compile(r"//+")
1366def _dep5_unnecessary_symbols(
1367 value: str,
1368 value_range: TERange,
1369 lint_state: LintState,
1370) -> None:
1371 slash_check_index = 0
1372 if value.startswith(("./", "/")):
1373 prefix_len = 1 if value[0] == "/" else 2
1374 if value[prefix_len - 1 : prefix_len + 2].startswith("//"): 1374 ↛ 1378line 1374 didn't jump to line 1378 because the condition on line 1374 was always true
1375 _, slashes_end = _RE_SLASHES.search(value).span()
1376 prefix_len = slashes_end
1378 slash_check_index = prefix_len
1379 prefix_range = TERange(
1380 value_range.start_pos,
1381 TEPosition(
1382 value_range.start_pos.line_position,
1383 value_range.start_pos.cursor_position + prefix_len,
1384 ),
1385 )
1386 lint_state.emit_diagnostic(
1387 prefix_range,
1388 f'Unnecessary prefix "{value[0:prefix_len]}"',
1389 "warning",
1390 "debputy",
1391 quickfixes=[
1392 propose_remove_range_quick_fix(
1393 proposed_title=f'Delete "{value[0:prefix_len]}"'
1394 )
1395 ],
1396 )
1398 for m in _RE_SLASHES.finditer(value, slash_check_index):
1399 m_start, m_end = m.span(0)
1401 prefix_range = TERange(
1402 TEPosition(
1403 value_range.start_pos.line_position,
1404 value_range.start_pos.cursor_position + m_start,
1405 ),
1406 TEPosition(
1407 value_range.start_pos.line_position,
1408 value_range.start_pos.cursor_position + m_end,
1409 ),
1410 )
1411 lint_state.emit_diagnostic(
1412 prefix_range,
1413 'Simplify to a single "/"',
1414 "warning",
1415 "debputy",
1416 quickfixes=[propose_correct_text_quick_fix("/")],
1417 )
1420def _dep5_files_check(
1421 known_field: "F",
1422 _deb822_file: Deb822FileElement,
1423 kvpair: Deb822KeyValuePairElement,
1424 kvpair_range_te: "TERange",
1425 _field_name_range: "TERange",
1426 _stanza: Deb822ParagraphElement,
1427 _stanza_position: "TEPosition",
1428 lint_state: LintState,
1429) -> None:
1430 interpreter = known_field.field_value_class.interpreter()
1431 assert interpreter is not None
1432 full_value_range = kvpair.value_element.range_in_parent().relative_to(
1433 kvpair_range_te.start_pos
1434 )
1435 values_with_ranges = []
1436 for value_ref in kvpair.interpret_as(interpreter).iter_value_references():
1437 value_range = value_ref.locatable.range_in_parent().relative_to(
1438 full_value_range.start_pos
1439 )
1440 value = value_ref.value
1441 values_with_ranges.append((value_ref.value, value_range))
1442 _dep5_unnecessary_symbols(value, value_range, lint_state)
1444 source_root = lint_state.source_root
1445 if source_root is None:
1446 return
1447 i = 0
1448 limit = len(values_with_ranges)
1449 while i < limit:
1450 value, value_range = values_with_ranges[i]
1451 i += 1
1454_HOMEPAGE_CLUTTER_RE = re.compile(r"<(?:UR[LI]:)?(.*)>")
1455_URI_RE = re.compile(r"(?P<protocol>[a-z0-9]+)://(?P<host>[^\s/+]+)(?P<path>/[^\s?]*)?")
1456_KNOWN_HTTPS_HOSTS = frozenset(
1457 [
1458 "debian.org",
1459 "bioconductor.org",
1460 "cran.r-project.org",
1461 "github.com",
1462 "gitlab.com",
1463 "metacpan.org",
1464 "gnu.org",
1465 ]
1466)
1467_REPLACED_HOSTS = frozenset({"alioth.debian.org"})
1468_NO_DOT_GIT_HOMEPAGE_HOSTS = frozenset(
1469 {
1470 "salsa.debian.org",
1471 "github.com",
1472 "gitlab.com",
1473 }
1474)
1477def _is_known_host(host: str, known_hosts: Container[str]) -> bool:
1478 if host in known_hosts:
1479 return True
1480 while host: 1480 ↛ 1488line 1480 didn't jump to line 1488 because the condition on line 1480 was always true
1481 try:
1482 idx = host.index(".")
1483 host = host[idx + 1 :]
1484 except ValueError:
1485 break
1486 if host in known_hosts:
1487 return True
1488 return False
1491def _validate_homepage_field(
1492 _known_field: "F",
1493 _deb822_file: Deb822FileElement,
1494 kvpair: Deb822KeyValuePairElement,
1495 kvpair_range_te: "TERange",
1496 _field_name_range_te: "TERange",
1497 _stanza: Deb822ParagraphElement,
1498 _stanza_position: "TEPosition",
1499 lint_state: LintState,
1500) -> None:
1501 value = kvpair.value_element.convert_to_text()
1502 offset = 0
1503 homepage = value
1504 if "<" in value and (m := _HOMEPAGE_CLUTTER_RE.search(value)):
1505 expected_value = m.group(1)
1506 quickfixes = []
1507 if expected_value: 1507 ↛ 1511line 1507 didn't jump to line 1511 because the condition on line 1507 was always true
1508 homepage = expected_value.strip()
1509 offset = m.start(1)
1510 quickfixes.append(propose_correct_text_quick_fix(expected_value))
1511 lint_state.emit_diagnostic(
1512 _single_line_span_range_relative_to_pos(
1513 m.span(),
1514 kvpair.value_element.position_in_parent().relative_to(
1515 kvpair_range_te.start_pos
1516 ),
1517 ),
1518 "Superfluous URL/URI wrapping",
1519 "informational",
1520 "Policy 5.6.23",
1521 quickfixes=quickfixes,
1522 )
1523 # Note falling through here can cause "two rounds" for debputy lint --auto-fix
1524 m = _URI_RE.search(homepage)
1525 if not m: 1525 ↛ 1526line 1525 didn't jump to line 1526 because the condition on line 1525 was never true
1526 return
1527 # TODO relative to lintian: `bad-homepage` and most of the `fields/bad-homepages` hints.
1528 protocol = m.group("protocol")
1529 host = m.group("host")
1530 path = m.group("path") or ""
1531 if _is_known_host(host, _REPLACED_HOSTS):
1532 span = m.span("host")
1533 lint_state.emit_diagnostic(
1534 _single_line_span_range_relative_to_pos(
1535 (span[0] + offset, span[1] + offset),
1536 kvpair.value_element.position_in_parent().relative_to(
1537 kvpair_range_te.start_pos
1538 ),
1539 ),
1540 f'The server "{host}" is no longer in use.',
1541 "warning",
1542 "debputy",
1543 )
1544 return
1545 if (
1546 protocol == "ftp"
1547 or protocol == "http"
1548 and _is_known_host(host, _KNOWN_HTTPS_HOSTS)
1549 ):
1550 span = m.span("protocol")
1551 if protocol == "ftp" and not _is_known_host(host, _KNOWN_HTTPS_HOSTS): 1551 ↛ 1552line 1551 didn't jump to line 1552 because the condition on line 1551 was never true
1552 msg = "Insecure protocol for website (check if a https:// variant is available)"
1553 quickfixes = []
1554 else:
1555 msg = "Replace with https://. The host is known to support https"
1556 quickfixes = [propose_correct_text_quick_fix("https")]
1557 lint_state.emit_diagnostic(
1558 _single_line_span_range_relative_to_pos(
1559 (span[0] + offset, span[1] + offset),
1560 kvpair.value_element.position_in_parent().relative_to(
1561 kvpair_range_te.start_pos
1562 ),
1563 ),
1564 msg,
1565 "pedantic",
1566 "debputy",
1567 quickfixes=quickfixes,
1568 )
1569 if path.endswith(".git") and _is_known_host(host, _NO_DOT_GIT_HOMEPAGE_HOSTS):
1570 span = m.span("path")
1571 msg = "Unnecessary suffix"
1572 quickfixes = [propose_correct_text_quick_fix(path[:-4])]
1573 lint_state.emit_diagnostic(
1574 _single_line_span_range_relative_to_pos(
1575 (span[1] - 4 + offset, span[1] + offset),
1576 kvpair.value_element.position_in_parent().relative_to(
1577 kvpair_range_te.start_pos
1578 ),
1579 ),
1580 msg,
1581 "pedantic",
1582 "debputy",
1583 quickfixes=quickfixes,
1584 )
1587def _combined_custom_field_check(*checks: CustomFieldCheck) -> CustomFieldCheck:
1588 def _validator(
1589 known_field: "F",
1590 deb822_file: Deb822FileElement,
1591 kvpair: Deb822KeyValuePairElement,
1592 kvpair_range_te: "TERange",
1593 field_name_range_te: "TERange",
1594 stanza: Deb822ParagraphElement,
1595 stanza_position: "TEPosition",
1596 lint_state: LintState,
1597 ) -> None:
1598 for check in checks:
1599 check(
1600 known_field,
1601 deb822_file,
1602 kvpair,
1603 kvpair_range_te,
1604 field_name_range_te,
1605 stanza,
1606 stanza_position,
1607 lint_state,
1608 )
1610 return _validator
1613@dataclasses.dataclass(slots=True, frozen=True)
1614class PackageNameSectionRule:
1615 section: str
1616 check: Callable[[str], bool]
1619def _package_name_section_rule(
1620 section: str,
1621 check: Union[Callable[[str], bool], re.Pattern],
1622 *,
1623 confirm_re: Optional[re.Pattern] = None,
1624) -> PackageNameSectionRule:
1625 if confirm_re is not None:
1626 assert callable(check)
1628 def _impl(v: str) -> bool:
1629 return check(v) and confirm_re.search(v)
1631 elif isinstance(check, re.Pattern): 1631 ↛ 1633line 1631 didn't jump to line 1633 because the condition on line 1631 was never true
1633 def _impl(v: str) -> bool:
1634 return check.search(v) is not None
1636 else:
1637 _impl = check
1639 return PackageNameSectionRule(section, _impl)
1642# rules: order is important (first match wins in case of a conflict)
1643_PKGNAME_VS_SECTION_RULES = [
1644 _package_name_section_rule("debian-installer", lambda n: n.endswith("-udeb")),
1645 _package_name_section_rule("doc", lambda n: n.endswith(("-doc", "-docs"))),
1646 _package_name_section_rule("debug", lambda n: n.endswith(("-dbg", "-dbgsym"))),
1647 _package_name_section_rule(
1648 "httpd",
1649 lambda n: n.startswith(("lighttpd-mod", "libapache2-mod-", "libnginx-mod-")),
1650 ),
1651 _package_name_section_rule("gnustep", lambda n: n.startswith("gnustep-")),
1652 _package_name_section_rule(
1653 "gnustep",
1654 lambda n: n.endswith(
1655 (
1656 ".framework",
1657 ".framework-common",
1658 ".tool",
1659 ".tool-common",
1660 ".app",
1661 ".app-common",
1662 )
1663 ),
1664 ),
1665 _package_name_section_rule("embedded", lambda n: n.startswith("moblin-")),
1666 _package_name_section_rule("javascript", lambda n: n.startswith("node-")),
1667 _package_name_section_rule(
1668 "zope",
1669 lambda n: n.startswith(("python-zope", "python3-zope", "zope")),
1670 ),
1671 _package_name_section_rule(
1672 "python",
1673 lambda n: n.startswith(("python-", "python3-")),
1674 ),
1675 _package_name_section_rule(
1676 "gnu-r",
1677 lambda n: n.startswith(("r-cran-", "r-bioc-", "r-other-")),
1678 ),
1679 _package_name_section_rule("editors", lambda n: n.startswith("elpa-")),
1680 _package_name_section_rule("lisp", lambda n: n.startswith("cl-")),
1681 _package_name_section_rule(
1682 "lisp",
1683 lambda n: "-elisp-" in n or n.endswith("-elisp"),
1684 ),
1685 _package_name_section_rule(
1686 "lisp",
1687 lambda n: n.startswith("lib") and n.endswith("-guile"),
1688 ),
1689 _package_name_section_rule("lisp", lambda n: n.startswith("guile-")),
1690 _package_name_section_rule("golang", lambda n: n.startswith("golang-")),
1691 _package_name_section_rule(
1692 "perl",
1693 lambda n: n.startswith("lib") and n.endswith("-perl"),
1694 ),
1695 _package_name_section_rule(
1696 "cli-mono",
1697 lambda n: n.startswith("lib") and n.endswith(("-cil", "-cil-dev")),
1698 ),
1699 _package_name_section_rule(
1700 "java",
1701 lambda n: n.startswith("lib") and n.endswith(("-java", "-gcj", "-jni")),
1702 ),
1703 _package_name_section_rule(
1704 "php",
1705 lambda n: n.startswith(("libphp", "php")),
1706 confirm_re=re.compile(r"^(?:lib)?php(?:\d(?:\.\d)?)?-"),
1707 ),
1708 _package_name_section_rule(
1709 "php", lambda n: n.startswith("lib-") and n.endswith("-php")
1710 ),
1711 _package_name_section_rule(
1712 "haskell",
1713 lambda n: n.startswith(("haskell-", "libhugs-", "libghc-", "libghc6-")),
1714 ),
1715 _package_name_section_rule(
1716 "ruby",
1717 lambda n: "-ruby" in n,
1718 confirm_re=re.compile(r"^lib.*-ruby(?:1\.\d)?$"),
1719 ),
1720 _package_name_section_rule("ruby", lambda n: n.startswith("ruby-")),
1721 _package_name_section_rule(
1722 "rust",
1723 lambda n: n.startswith("librust-") and n.endswith("-dev"),
1724 ),
1725 _package_name_section_rule("rust", lambda n: n.startswith("rust-")),
1726 _package_name_section_rule(
1727 "ocaml",
1728 lambda n: n.startswith("lib-") and n.endswith(("-ocaml-dev", "-camlp4-dev")),
1729 ),
1730 _package_name_section_rule("javascript", lambda n: n.startswith("libjs-")),
1731 _package_name_section_rule(
1732 "interpreters",
1733 lambda n: n.startswith("lib-") and n.endswith(("-tcl", "-lua", "-gst")),
1734 ),
1735 _package_name_section_rule(
1736 "introspection",
1737 lambda n: n.startswith("gir-"),
1738 confirm_re=re.compile(r"^gir\d+\.\d+-.*-\d+\.\d+$"),
1739 ),
1740 _package_name_section_rule(
1741 "fonts",
1742 lambda n: n.startswith(("xfonts-", "fonts-", "ttf-")),
1743 ),
1744 _package_name_section_rule("admin", lambda n: n.startswith(("libnss-", "libpam-"))),
1745 _package_name_section_rule(
1746 "localization",
1747 lambda n: n.startswith(
1748 (
1749 "aspell-",
1750 "hunspell-",
1751 "myspell-",
1752 "mythes-",
1753 "dict-freedict-",
1754 "gcompris-sound-",
1755 )
1756 ),
1757 ),
1758 _package_name_section_rule(
1759 "localization",
1760 lambda n: n.startswith("hyphen-"),
1761 confirm_re=re.compile(r"^hyphen-[a-z]{2}(?:-[a-z]{2})?$"),
1762 ),
1763 _package_name_section_rule(
1764 "localization",
1765 lambda n: "-l10n-" in n or n.endswith("-l10n"),
1766 ),
1767 _package_name_section_rule("kernel", lambda n: n.endswith(("-dkms", "-firmware"))),
1768 _package_name_section_rule(
1769 "libdevel",
1770 lambda n: n.startswith("lib") and n.endswith(("-dev", "-headers")),
1771 ),
1772 _package_name_section_rule(
1773 "libs",
1774 lambda n: n.startswith("lib"),
1775 confirm_re=re.compile(r"^lib.*\d[ad]?$"),
1776 ),
1777]
1780# Fiddling with the package name can cause a lot of changes (diagnostic scans), so we have an upper bound
1781# on the cache. The number is currently just taken out of a hat.
1782@functools.lru_cache(64)
1783def package_name_to_section(name: str) -> Optional[str]:
1784 for rule in _PKGNAME_VS_SECTION_RULES:
1785 if rule.check(name):
1786 return rule.section
1787 return None
1790def _unknown_value_check(
1791 field_name: str,
1792 value: str,
1793 known_values: Mapping[str, Keyword],
1794 unknown_value_severity: Optional[LintSeverity],
1795) -> Tuple[Optional[Keyword], Optional[str], Optional[LintSeverity], Optional[Any]]:
1796 known_value = known_values.get(value)
1797 message = None
1798 severity = unknown_value_severity
1799 fix_data = None
1800 if known_value is None:
1801 candidates = detect_possible_typo(
1802 value,
1803 known_values,
1804 )
1805 if len(known_values) < 5: 1805 ↛ 1806line 1805 didn't jump to line 1806 because the condition on line 1805 was never true
1806 values = ", ".join(sorted(known_values))
1807 hint_text = f" Known values for this field: {values}"
1808 else:
1809 hint_text = ""
1810 fix_data = None
1811 severity = unknown_value_severity
1812 fix_text = hint_text
1813 if candidates:
1814 match = candidates[0]
1815 if len(candidates) == 1: 1815 ↛ 1817line 1815 didn't jump to line 1817 because the condition on line 1815 was always true
1816 known_value = known_values[match]
1817 fix_text = (
1818 f' It is possible that the value is a typo of "{match}".{fix_text}'
1819 )
1820 fix_data = [propose_correct_text_quick_fix(m) for m in candidates]
1821 elif severity is None: 1821 ↛ 1822line 1821 didn't jump to line 1822 because the condition on line 1821 was never true
1822 return None, None, None, None
1823 if severity is None:
1824 severity = cast("LintSeverity", "warning")
1825 # It always has leading whitespace
1826 message = fix_text.strip()
1827 else:
1828 message = f'The value "{value}" is not supported in {field_name}.{fix_text}'
1829 return known_value, message, severity, fix_data
1832def _dep5_escape_path(path: str) -> str:
1833 return path.replace(" ", "?")
1836def _noop_escape_path(path: str) -> str:
1837 return path
1840def _should_ignore_dir(
1841 path: VirtualPath,
1842 *,
1843 supports_dir_match: bool = False,
1844 match_non_persistent_paths: bool = False,
1845) -> bool:
1846 if not supports_dir_match and not any(path.iterdir):
1847 return True
1848 cachedir_tag = path.get("CACHEDIR.TAG")
1849 if (
1850 not match_non_persistent_paths
1851 and cachedir_tag is not None
1852 and cachedir_tag.is_file
1853 ):
1854 # https://bford.info/cachedir/
1855 with cachedir_tag.open(byte_io=True, buffering=64) as fd:
1856 start = fd.read(43)
1857 if start == b"Signature: 8a477f597d28d172789f06886806bc55":
1858 return True
1859 return False
1862@dataclasses.dataclass(slots=True)
1863class Deb822KnownField:
1864 name: str
1865 field_value_class: FieldValueClass
1866 warn_if_default: bool = True
1867 unknown_value_authority: str = "debputy"
1868 missing_field_authority: str = "debputy"
1869 replaced_by: Optional[str] = None
1870 deprecated_with_no_replacement: bool = False
1871 missing_field_severity: Optional[LintSeverity] = None
1872 default_value: Optional[str] = None
1873 known_values: Optional[Mapping[str, Keyword]] = None
1874 unknown_value_severity: Optional[LintSeverity] = "error"
1875 translation_context: str = ""
1876 # One-line description for space-constrained docs (such as completion docs)
1877 synopsis: Optional[str] = None
1878 usage_hint: Optional[UsageHint] = None
1879 long_description: Optional[str] = None
1880 spellcheck_value: bool = False
1881 inheritable_from_other_stanza: bool = False
1882 show_as_inherited: bool = True
1883 custom_field_check: Optional[CustomFieldCheck] = None
1884 can_complete_field_in_stanza: Optional[
1885 Callable[[Iterable[Deb822ParagraphElement]], bool]
1886 ] = None
1887 is_substvars_disabled_even_if_allowed_by_stanza: bool = False
1888 is_alias_of: Optional[str] = None
1889 is_completion_suggestion: bool = True
1891 def synopsis_translated(
1892 self, translation_provider: Union["DebputyLanguageServer", "LintState"]
1893 ) -> str:
1894 return translation_provider.translation(LSP_DATA_DOMAIN).pgettext(
1895 self.translation_context,
1896 self.synopsis,
1897 )
1899 def long_description_translated(
1900 self, translation_provider: Union["DebputyLanguageServer", "LintState"]
1901 ) -> str:
1902 return translation_provider.translation(LSP_DATA_DOMAIN).pgettext(
1903 self.translation_context,
1904 self.long_description,
1905 )
1907 def _can_complete_field_in_stanza(
1908 self,
1909 stanza_parts: Sequence[Deb822ParagraphElement],
1910 ) -> bool:
1911 if not self.is_completion_suggestion: 1911 ↛ 1912line 1911 didn't jump to line 1912 because the condition on line 1911 was never true
1912 return False
1913 return (
1914 self.can_complete_field_in_stanza is None
1915 or self.can_complete_field_in_stanza(stanza_parts)
1916 )
1918 def complete_field(
1919 self,
1920 lint_state: LintState,
1921 stanza_parts: Sequence[Deb822ParagraphElement],
1922 markdown_kind: MarkupKind,
1923 ) -> Optional[CompletionItem]:
1924 if not self._can_complete_field_in_stanza(stanza_parts):
1925 return None
1926 name = self.name
1927 complete_as = name + ": "
1928 options = self.value_options_for_completer(
1929 lint_state,
1930 stanza_parts,
1931 "",
1932 markdown_kind,
1933 is_completion_for_field=True,
1934 )
1935 if options is not None and len(options) == 1:
1936 value = options[0].insert_text
1937 if value is not None: 1937 ↛ 1939line 1937 didn't jump to line 1939 because the condition on line 1937 was always true
1938 complete_as += value
1939 tags = []
1940 is_deprecated = False
1941 if self.replaced_by or self.deprecated_with_no_replacement:
1942 is_deprecated = True
1943 tags.append(CompletionItemTag.Deprecated)
1945 doc = self.long_description
1946 if doc:
1947 doc = MarkupContent(
1948 value=doc,
1949 kind=markdown_kind,
1950 )
1951 else:
1952 doc = None
1954 return CompletionItem(
1955 name,
1956 insert_text=complete_as,
1957 deprecated=is_deprecated,
1958 tags=tags,
1959 detail=format_comp_item_synopsis_doc(
1960 self.usage_hint,
1961 self.synopsis_translated(lint_state),
1962 is_deprecated,
1963 ),
1964 documentation=doc,
1965 )
1967 def _complete_files(
1968 self,
1969 base_dir: Optional[VirtualPathBase],
1970 value_being_completed: str,
1971 *,
1972 is_dep5_file_list: bool = False,
1973 supports_dir_match: bool = False,
1974 supports_spaces_in_filename: bool = False,
1975 match_non_persistent_paths: bool = False,
1976 ) -> Optional[Sequence[CompletionItem]]:
1977 _info(f"_complete_files: {base_dir.fs_path} - {value_being_completed!r}")
1978 if base_dir is None or not base_dir.is_dir:
1979 return None
1981 if is_dep5_file_list:
1982 supports_spaces_in_filename = True
1983 supports_dir_match = False
1984 match_non_persistent_paths = False
1986 if value_being_completed == "":
1987 current_dir = base_dir
1988 unmatched_parts: Sequence[str] = ()
1989 else:
1990 current_dir, unmatched_parts = base_dir.attempt_lookup(
1991 value_being_completed
1992 )
1994 if len(unmatched_parts) > 1:
1995 # Unknown directory part / glob, and we currently do not deal with that.
1996 return None
1997 if len(unmatched_parts) == 1 and unmatched_parts[0] == "*":
1998 # Avoid convincing the client to remove the star (seen with emacs)
1999 return None
2000 items = []
2002 path_escaper = _dep5_escape_path if is_dep5_file_list else _noop_escape_path
2004 for child in current_dir.iterdir:
2005 if child.is_symlink and is_dep5_file_list:
2006 continue
2007 if not supports_spaces_in_filename and (
2008 " " in child.name or "\t" in child.name
2009 ):
2010 continue
2011 sort_text = (
2012 f"z-{child.name}" if child.name.startswith(".") else f"a-{child.name}"
2013 )
2014 if child.is_dir:
2015 if _should_ignore_dir(
2016 child,
2017 supports_dir_match=supports_dir_match,
2018 match_non_persistent_paths=match_non_persistent_paths,
2019 ):
2020 continue
2021 items.append(
2022 CompletionItem(
2023 f"{child.path}/",
2024 label_details=CompletionItemLabelDetails(
2025 description=child.path,
2026 ),
2027 insert_text=path_escaper(f"{child.path}/"),
2028 filter_text=f"{child.path}/",
2029 sort_text=sort_text,
2030 kind=CompletionItemKind.Folder,
2031 )
2032 )
2033 else:
2034 items.append(
2035 CompletionItem(
2036 child.path,
2037 label_details=CompletionItemLabelDetails(
2038 description=child.path,
2039 ),
2040 insert_text=path_escaper(child.path),
2041 filter_text=child.path,
2042 sort_text=sort_text,
2043 kind=CompletionItemKind.File,
2044 )
2045 )
2046 return items
2048 def value_options_for_completer(
2049 self,
2050 lint_state: LintState,
2051 stanza_parts: Sequence[Deb822ParagraphElement],
2052 value_being_completed: str,
2053 markdown_kind: MarkupKind,
2054 *,
2055 is_completion_for_field: bool = False,
2056 ) -> Optional[Sequence[CompletionItem]]:
2057 known_values = self.known_values
2058 if self.field_value_class == FieldValueClass.DEP5_FILE_LIST: 2058 ↛ 2059line 2058 didn't jump to line 2059 because the condition on line 2058 was never true
2059 if is_completion_for_field:
2060 return None
2061 return self._complete_files(
2062 lint_state.source_root,
2063 value_being_completed,
2064 is_dep5_file_list=True,
2065 )
2067 if known_values is None:
2068 return None
2069 if is_completion_for_field and (
2070 len(known_values) == 1
2071 or (
2072 len(known_values) == 2
2073 and self.warn_if_default
2074 and self.default_value is not None
2075 )
2076 ):
2077 value = next(
2078 iter(v for v in self.known_values if v != self.default_value),
2079 None,
2080 )
2081 if value is None: 2081 ↛ 2082line 2081 didn't jump to line 2082 because the condition on line 2081 was never true
2082 return None
2083 return [CompletionItem(value, insert_text=value)]
2084 return [
2085 keyword.as_completion_item(
2086 lint_state,
2087 stanza_parts,
2088 value_being_completed,
2089 markdown_kind,
2090 )
2091 for keyword in known_values.values()
2092 if keyword.is_keyword_valid_completion_in_stanza(stanza_parts)
2093 and keyword.is_completion_suggestion
2094 ]
2096 def field_omitted_diagnostics(
2097 self,
2098 deb822_file: Deb822FileElement,
2099 representation_field_range: "TERange",
2100 stanza: Deb822ParagraphElement,
2101 stanza_position: "TEPosition",
2102 header_stanza: Optional[Deb822FileElement],
2103 lint_state: LintState,
2104 ) -> None:
2105 missing_field_severity = self.missing_field_severity
2106 if missing_field_severity is None: 2106 ↛ 2109line 2106 didn't jump to line 2109 because the condition on line 2106 was always true
2107 return
2109 if (
2110 self.inheritable_from_other_stanza
2111 and header_stanza is not None
2112 and self.name in header_stanza
2113 ):
2114 return
2116 lint_state.emit_diagnostic(
2117 representation_field_range,
2118 f"Stanza is missing field {self.name}",
2119 missing_field_severity,
2120 self.missing_field_authority,
2121 )
2123 async def field_diagnostics(
2124 self,
2125 deb822_file: Deb822FileElement,
2126 kvpair: Deb822KeyValuePairElement,
2127 stanza: Deb822ParagraphElement,
2128 stanza_position: "TEPosition",
2129 kvpair_range_te: "TERange",
2130 lint_state: LintState,
2131 *,
2132 field_name_typo_reported: bool = False,
2133 ) -> None:
2134 field_name_token = kvpair.field_token
2135 field_name_range_te = kvpair.field_token.range_in_parent().relative_to(
2136 kvpair_range_te.start_pos
2137 )
2138 field_name = field_name_token.text
2139 # The `self.name` attribute is the canonical name whereas `field_name` is the name used.
2140 # This distinction is important for `d/control` where `X[CBS]-` prefixes might be used
2141 # in one but not the other.
2142 field_value = stanza[field_name]
2143 self._diagnostics_for_field_name(
2144 kvpair_range_te,
2145 field_name_token,
2146 field_name_range_te,
2147 field_name_typo_reported,
2148 lint_state,
2149 )
2150 if self.custom_field_check is not None:
2151 self.custom_field_check(
2152 self,
2153 deb822_file,
2154 kvpair,
2155 kvpair_range_te,
2156 field_name_range_te,
2157 stanza,
2158 stanza_position,
2159 lint_state,
2160 )
2161 self._dep5_file_list_diagnostics(kvpair, kvpair_range_te.start_pos, lint_state)
2162 if self.spellcheck_value:
2163 words = kvpair.interpret_as(LIST_SPACE_SEPARATED_INTERPRETATION)
2164 spell_checker = lint_state.spellchecker()
2165 value_position = kvpair.value_element.position_in_parent().relative_to(
2166 kvpair_range_te.start_pos
2167 )
2168 async for word_ref in lint_state.slow_iter(
2169 words.iter_value_references(), yield_every=25
2170 ):
2171 token = word_ref.value
2172 for word, pos, endpos in spell_checker.iter_words(token):
2173 corrections = spell_checker.provide_corrections_for(word)
2174 if not corrections:
2175 continue
2176 word_loc = word_ref.locatable
2177 word_pos_te = word_loc.position_in_parent().relative_to(
2178 value_position
2179 )
2180 if pos: 2180 ↛ 2181line 2180 didn't jump to line 2181 because the condition on line 2180 was never true
2181 word_pos_te = TEPosition(0, pos).relative_to(word_pos_te)
2182 word_size = TERange(
2183 START_POSITION,
2184 TEPosition(0, endpos - pos),
2185 )
2186 lint_state.emit_diagnostic(
2187 TERange.from_position_and_size(word_pos_te, word_size),
2188 f'Spelling "{word}"',
2189 "spelling",
2190 "debputy",
2191 quickfixes=[
2192 propose_correct_text_quick_fix(c) for c in corrections
2193 ],
2194 enable_non_interactive_auto_fix=False,
2195 )
2196 else:
2197 self._known_value_diagnostics(
2198 kvpair,
2199 kvpair_range_te.start_pos,
2200 lint_state,
2201 )
2203 if self.warn_if_default and field_value == self.default_value: 2203 ↛ 2204line 2203 didn't jump to line 2204 because the condition on line 2203 was never true
2204 lint_state.emit_diagnostic(
2205 kvpair_range_te,
2206 f'The field "{field_name}" is redundant as it is set to the default value and the field'
2207 " should only be used in exceptional cases.",
2208 "warning",
2209 "debputy",
2210 )
2212 def _diagnostics_for_field_name(
2213 self,
2214 kvpair_range: "TERange",
2215 token: Deb822FieldNameToken,
2216 token_range: "TERange",
2217 typo_detected: bool,
2218 lint_state: LintState,
2219 ) -> None:
2220 field_name = token.text
2221 # Defeat the case-insensitivity from python-debian
2222 field_name_cased = str(field_name)
2223 if self.deprecated_with_no_replacement:
2224 lint_state.emit_diagnostic(
2225 kvpair_range,
2226 f'"{field_name_cased}" is deprecated and no longer used',
2227 "warning",
2228 "debputy",
2229 quickfixes=[propose_remove_range_quick_fix()],
2230 tags=[DiagnosticTag.Deprecated],
2231 )
2232 elif self.replaced_by is not None:
2233 lint_state.emit_diagnostic(
2234 token_range,
2235 f'"{field_name_cased}" has been replaced by "{self.replaced_by}"',
2236 "warning",
2237 "debputy",
2238 tags=[DiagnosticTag.Deprecated],
2239 quickfixes=[propose_correct_text_quick_fix(self.replaced_by)],
2240 )
2242 if not typo_detected and field_name_cased != self.name:
2243 lint_state.emit_diagnostic(
2244 token_range,
2245 f'Non-canonical spelling of "{self.name}"',
2246 "pedantic",
2247 self.unknown_value_authority,
2248 quickfixes=[propose_correct_text_quick_fix(self.name)],
2249 )
2251 def _dep5_file_list_diagnostics(
2252 self,
2253 kvpair: Deb822KeyValuePairElement,
2254 kvpair_position: "TEPosition",
2255 lint_state: LintState,
2256 ) -> None:
2257 source_root = lint_state.source_root
2258 if (
2259 self.field_value_class != FieldValueClass.DEP5_FILE_LIST
2260 or source_root is None
2261 ):
2262 return
2263 interpreter = self.field_value_class.interpreter()
2264 values = kvpair.interpret_as(interpreter)
2265 value_off = kvpair.value_element.position_in_parent().relative_to(
2266 kvpair_position
2267 )
2269 assert interpreter is not None
2271 for token in values.iter_parts():
2272 if token.is_whitespace:
2273 continue
2274 text = token.convert_to_text()
2275 if "?" in text or "*" in text: 2275 ↛ 2277line 2275 didn't jump to line 2277 because the condition on line 2275 was never true
2276 # TODO: We should validate these as well
2277 continue
2278 matched_path, missing_part = source_root.attempt_lookup(text)
2279 # It is common practice to delete "dirty" files during clean. This causes files listed
2280 # in `debian/copyright` to go missing and as a consequence, we do not validate whether
2281 # they are present (that would require us to check the `.orig.tar`, which we could but
2282 # do not have the infrastructure for).
2283 if not missing_part and matched_path.is_dir: 2283 ↛ 2271line 2283 didn't jump to line 2271 because the condition on line 2283 was always true
2284 path_range_te = token.range_in_parent().relative_to(value_off)
2285 lint_state.emit_diagnostic(
2286 path_range_te,
2287 "Directories cannot be a match. Use `dir/*` to match everything in it",
2288 "warning",
2289 self.unknown_value_authority,
2290 quickfixes=[
2291 propose_correct_text_quick_fix(f"{matched_path.path}/*")
2292 ],
2293 )
2295 def _known_value_diagnostics(
2296 self,
2297 kvpair: Deb822KeyValuePairElement,
2298 kvpair_position: "TEPosition",
2299 lint_state: LintState,
2300 ) -> None:
2301 unknown_value_severity = self.unknown_value_severity
2302 interpreter = self.field_value_class.interpreter()
2303 if interpreter is None:
2304 return
2305 try:
2306 values = kvpair.interpret_as(interpreter)
2307 except ValueError:
2308 value_range = kvpair.value_element.range_in_parent().relative_to(
2309 kvpair_position
2310 )
2311 lint_state.emit_diagnostic(
2312 value_range,
2313 "Error while parsing field (diagnostics related to this field may be incomplete)",
2314 "pedantic",
2315 "debputy",
2316 )
2317 return
2318 value_off = kvpair.value_element.position_in_parent().relative_to(
2319 kvpair_position
2320 )
2322 last_token_non_ws_sep_token: Optional[TE] = None
2323 for token in values.iter_parts():
2324 if token.is_whitespace:
2325 continue
2326 if not token.is_separator:
2327 last_token_non_ws_sep_token = None
2328 continue
2329 if last_token_non_ws_sep_token is not None:
2330 sep_range_te = token.range_in_parent().relative_to(value_off)
2331 lint_state.emit_diagnostic(
2332 sep_range_te,
2333 "Duplicate separator",
2334 "error",
2335 self.unknown_value_authority,
2336 )
2337 last_token_non_ws_sep_token = token
2339 allowed_values = self.known_values
2340 if not allowed_values:
2341 return
2343 first_value = None
2344 first_exclusive_value_ref = None
2345 first_exclusive_value = None
2346 has_emitted_for_exclusive = False
2348 for value_ref in values.iter_value_references():
2349 value = value_ref.value
2350 if ( 2350 ↛ 2354line 2350 didn't jump to line 2354 because the condition on line 2350 was never true
2351 first_value is not None
2352 and self.field_value_class == FieldValueClass.SINGLE_VALUE
2353 ):
2354 value_loc = value_ref.locatable
2355 range_position_te = value_loc.range_in_parent().relative_to(value_off)
2356 lint_state.emit_diagnostic(
2357 range_position_te,
2358 f"The field {self.name} can only have exactly one value.",
2359 "error",
2360 self.unknown_value_authority,
2361 )
2362 # TODO: Add quickfix if the value is also invalid
2363 continue
2365 if first_exclusive_value_ref is not None and not has_emitted_for_exclusive:
2366 assert first_exclusive_value is not None
2367 value_loc = first_exclusive_value_ref.locatable
2368 value_range_te = value_loc.range_in_parent().relative_to(value_off)
2369 lint_state.emit_diagnostic(
2370 value_range_te,
2371 f'The value "{first_exclusive_value}" cannot be used with other values.',
2372 "error",
2373 self.unknown_value_authority,
2374 )
2376 known_value, unknown_value_message, unknown_severity, typo_fix_data = (
2377 _unknown_value_check(
2378 self.name,
2379 value,
2380 self.known_values,
2381 unknown_value_severity,
2382 )
2383 )
2384 value_loc = value_ref.locatable
2385 value_range = value_loc.range_in_parent().relative_to(value_off)
2387 if known_value and known_value.is_exclusive:
2388 first_exclusive_value = known_value.value # In case of typos.
2389 first_exclusive_value_ref = value_ref
2390 if first_value is not None:
2391 has_emitted_for_exclusive = True
2392 lint_state.emit_diagnostic(
2393 value_range,
2394 f'The value "{known_value.value}" cannot be used with other values.',
2395 "error",
2396 self.unknown_value_authority,
2397 )
2399 if first_value is None:
2400 first_value = value
2402 if unknown_value_message is not None:
2403 assert unknown_severity is not None
2404 lint_state.emit_diagnostic(
2405 value_range,
2406 unknown_value_message,
2407 unknown_severity,
2408 self.unknown_value_authority,
2409 quickfixes=typo_fix_data,
2410 )
2412 if known_value is not None and known_value.is_deprecated:
2413 replacement = known_value.replaced_by
2414 if replacement is not None: 2414 ↛ 2420line 2414 didn't jump to line 2420 because the condition on line 2414 was always true
2415 obsolete_value_message = (
2416 f'The value "{value}" has been replaced by "{replacement}"'
2417 )
2418 obsolete_fix_data = [propose_correct_text_quick_fix(replacement)]
2419 else:
2420 obsolete_value_message = (
2421 f'The value "{value}" is obsolete without a single replacement'
2422 )
2423 obsolete_fix_data = None
2424 lint_state.emit_diagnostic(
2425 value_range,
2426 obsolete_value_message,
2427 "warning",
2428 "debputy",
2429 quickfixes=obsolete_fix_data,
2430 )
2432 def _reformat_field_name(
2433 self,
2434 effective_preference: "EffectiveFormattingPreference",
2435 stanza_range: TERange,
2436 kvpair: Deb822KeyValuePairElement,
2437 position_codec: LintCapablePositionCodec,
2438 lines: List[str],
2439 ) -> Iterable[TextEdit]:
2440 if not effective_preference.deb822_auto_canonical_size_field_names: 2440 ↛ 2441line 2440 didn't jump to line 2441 because the condition on line 2440 was never true
2441 return
2442 # The `str(kvpair.field_name)` is to avoid the "magic" from `python3-debian`'s Deb822 keys.
2443 if str(kvpair.field_name) == self.name:
2444 return
2446 field_name_range_te = kvpair.field_token.range_in_parent().relative_to(
2447 kvpair.range_in_parent().relative_to(stanza_range.start_pos).start_pos
2448 )
2450 edit_range = position_codec.range_to_client_units(
2451 lines,
2452 Range(
2453 Position(
2454 field_name_range_te.start_pos.line_position,
2455 field_name_range_te.start_pos.cursor_position,
2456 ),
2457 Position(
2458 field_name_range_te.start_pos.line_position,
2459 field_name_range_te.end_pos.cursor_position,
2460 ),
2461 ),
2462 )
2463 yield TextEdit(
2464 edit_range,
2465 self.name,
2466 )
2468 def reformat_field(
2469 self,
2470 effective_preference: "EffectiveFormattingPreference",
2471 stanza_range: TERange,
2472 kvpair: Deb822KeyValuePairElement,
2473 formatter: FormatterCallback,
2474 position_codec: LintCapablePositionCodec,
2475 lines: List[str],
2476 ) -> Iterable[TextEdit]:
2477 kvpair_range = kvpair.range_in_parent().relative_to(stanza_range.start_pos)
2478 yield from self._reformat_field_name(
2479 effective_preference,
2480 stanza_range,
2481 kvpair,
2482 position_codec,
2483 lines,
2484 )
2485 return trim_end_of_line_whitespace(
2486 position_codec,
2487 lines,
2488 line_range=range(
2489 kvpair_range.start_pos.line_position,
2490 kvpair_range.end_pos.line_position,
2491 ),
2492 )
2494 def replace(self, **changes: Any) -> "Self":
2495 return dataclasses.replace(self, **changes)
2498@dataclasses.dataclass(slots=True)
2499class DctrlLikeKnownField(Deb822KnownField):
2501 def reformat_field(
2502 self,
2503 effective_preference: "EffectiveFormattingPreference",
2504 stanza_range: TERange,
2505 kvpair: Deb822KeyValuePairElement,
2506 formatter: FormatterCallback,
2507 position_codec: LintCapablePositionCodec,
2508 lines: List[str],
2509 ) -> Iterable[TextEdit]:
2510 interpretation = self.field_value_class.interpreter()
2511 if ( 2511 ↛ 2515line 2511 didn't jump to line 2515 because the condition on line 2511 was never true
2512 not effective_preference.deb822_normalize_field_content
2513 or interpretation is None
2514 ):
2515 yield from super(DctrlLikeKnownField, self).reformat_field(
2516 effective_preference,
2517 stanza_range,
2518 kvpair,
2519 formatter,
2520 position_codec,
2521 lines,
2522 )
2523 return
2524 if not self.reformattable_field:
2525 yield from super(DctrlLikeKnownField, self).reformat_field(
2526 effective_preference,
2527 stanza_range,
2528 kvpair,
2529 formatter,
2530 position_codec,
2531 lines,
2532 )
2533 return
2535 # Preserve the name fixes from the super call.
2536 yield from self._reformat_field_name(
2537 effective_preference,
2538 stanza_range,
2539 kvpair,
2540 position_codec,
2541 lines,
2542 )
2544 seen: Set[str] = set()
2545 old_kvpair_range = kvpair.range_in_parent()
2546 sort = self.is_sortable_field
2548 # Avoid the context manager as we do not want to perform the change (it would contaminate future ranges)
2549 field_content = kvpair.interpret_as(interpretation)
2550 old_value = field_content.convert_to_text(with_field_name=False)
2551 for package_ref in field_content.iter_value_references():
2552 value = package_ref.value
2553 value_range = package_ref.locatable.range_in_parent().relative_to(
2554 stanza_range.start_pos
2555 )
2556 sublines = lines[
2557 value_range.start_pos.line_position : value_range.end_pos.line_position
2558 ]
2560 # debputy#112: Avoid truncating "inline comments"
2561 if any(line.startswith("#") for line in sublines): 2561 ↛ 2562line 2561 didn't jump to line 2562 because the condition on line 2561 was never true
2562 return
2563 if self.is_relationship_field: 2563 ↛ 2566line 2563 didn't jump to line 2566 because the condition on line 2563 was always true
2564 new_value = " | ".join(x.strip() for x in value.split("|"))
2565 else:
2566 new_value = value
2567 if not sort or new_value not in seen: 2567 ↛ 2572line 2567 didn't jump to line 2572 because the condition on line 2567 was always true
2568 if new_value != value: 2568 ↛ 2569line 2568 didn't jump to line 2569 because the condition on line 2568 was never true
2569 package_ref.value = new_value
2570 seen.add(new_value)
2571 else:
2572 package_ref.remove()
2573 if sort: 2573 ↛ 2575line 2573 didn't jump to line 2575 because the condition on line 2573 was always true
2574 field_content.sort(key=_sort_packages_key)
2575 field_content.value_formatter(formatter)
2576 field_content.reformat_when_finished()
2578 new_value = field_content.convert_to_text(with_field_name=False)
2579 if new_value != old_value:
2580 value_range = kvpair.value_element.range_in_parent().relative_to(
2581 old_kvpair_range.start_pos
2582 )
2583 range_server_units = te_range_to_lsp(
2584 value_range.relative_to(stanza_range.start_pos)
2585 )
2586 yield TextEdit(
2587 position_codec.range_to_client_units(lines, range_server_units),
2588 new_value,
2589 )
2591 @property
2592 def reformattable_field(self) -> bool:
2593 return self.is_relationship_field or self.is_sortable_field
2595 @property
2596 def is_relationship_field(self) -> bool:
2597 return False
2599 @property
2600 def is_sortable_field(self) -> bool:
2601 return self.is_relationship_field
2604@dataclasses.dataclass(slots=True)
2605class DTestsCtrlKnownField(DctrlLikeKnownField):
2606 @property
2607 def is_relationship_field(self) -> bool:
2608 return self.name == "Depends"
2610 @property
2611 def is_sortable_field(self) -> bool:
2612 return self.is_relationship_field or self.name in (
2613 "Features",
2614 "Restrictions",
2615 "Tests",
2616 )
2619@dataclasses.dataclass(slots=True)
2620class DctrlKnownField(DctrlLikeKnownField):
2622 def field_omitted_diagnostics(
2623 self,
2624 deb822_file: Deb822FileElement,
2625 representation_field_range: "TERange",
2626 stanza: Deb822ParagraphElement,
2627 stanza_position: "TEPosition",
2628 header_stanza: Optional[Deb822FileElement],
2629 lint_state: LintState,
2630 ) -> None:
2631 missing_field_severity = self.missing_field_severity
2632 if missing_field_severity is None:
2633 return
2635 if (
2636 self.inheritable_from_other_stanza
2637 and header_stanza is not None
2638 and self.name in header_stanza
2639 ):
2640 return
2642 if self.name == "Standards-Version":
2643 stanzas = list(deb822_file)[1:]
2644 if all(s.get("Package-Type") == "udeb" for s in stanzas):
2645 return
2647 lint_state.emit_diagnostic(
2648 representation_field_range,
2649 f"Stanza is missing field {self.name}",
2650 missing_field_severity,
2651 self.missing_field_authority,
2652 )
2654 def reformat_field(
2655 self,
2656 effective_preference: "EffectiveFormattingPreference",
2657 stanza_range: TERange,
2658 kvpair: Deb822KeyValuePairElement,
2659 formatter: FormatterCallback,
2660 position_codec: LintCapablePositionCodec,
2661 lines: List[str],
2662 ) -> Iterable[TextEdit]:
2663 if (
2664 self.name == "Architecture"
2665 and effective_preference.deb822_normalize_field_content
2666 ):
2667 interpretation = self.field_value_class.interpreter()
2668 assert interpretation is not None
2669 interpreted = kvpair.interpret_as(interpretation)
2670 archs = list(interpreted)
2671 # Sort, with wildcard entries (such as linux-any) first:
2672 archs = sorted(archs, key=lambda x: ("any" not in x, x))
2673 new_value = f" {' '.join(archs)}\n"
2674 reformat_edits = list(
2675 self._reformat_field_name(
2676 effective_preference,
2677 stanza_range,
2678 kvpair,
2679 position_codec,
2680 lines,
2681 )
2682 )
2683 if new_value != interpreted.convert_to_text(with_field_name=False):
2684 value_range = kvpair.value_element.range_in_parent().relative_to(
2685 kvpair.range_in_parent().start_pos
2686 )
2687 kvpair_range = te_range_to_lsp(
2688 value_range.relative_to(stanza_range.start_pos)
2689 )
2690 reformat_edits.append(
2691 TextEdit(
2692 position_codec.range_to_client_units(lines, kvpair_range),
2693 new_value,
2694 )
2695 )
2696 return reformat_edits
2698 return super(DctrlKnownField, self).reformat_field(
2699 effective_preference,
2700 stanza_range,
2701 kvpair,
2702 formatter,
2703 position_codec,
2704 lines,
2705 )
2707 @property
2708 def is_relationship_field(self) -> bool:
2709 name_lc = self.name.lower()
2710 return (
2711 name_lc in all_package_relationship_fields()
2712 or name_lc in all_source_relationship_fields()
2713 )
2715 @property
2716 def reformattable_field(self) -> bool:
2717 return self.is_relationship_field or self.name == "Uploaders"
2720@dataclasses.dataclass(slots=True)
2721class DctrlRelationshipKnownField(DctrlKnownField):
2722 allowed_version_operators: FrozenSet[str] = frozenset()
2723 supports_or_relation: bool = True
2725 @property
2726 def is_relationship_field(self) -> bool:
2727 return True
2730SOURCE_FIELDS = _fields(
2731 DctrlKnownField(
2732 "Source",
2733 FieldValueClass.SINGLE_VALUE,
2734 custom_field_check=_combined_custom_field_check(
2735 _each_value_match_regex_validation(PKGNAME_REGEX),
2736 _has_packaging_expected_file(
2737 "copyright",
2738 "No copyright file (package license)",
2739 severity="warning",
2740 ),
2741 _has_packaging_expected_file(
2742 "changelog",
2743 "No Debian changelog file",
2744 severity="error",
2745 ),
2746 _has_build_instructions,
2747 ),
2748 ),
2749 DctrlKnownField(
2750 "Standards-Version",
2751 FieldValueClass.SINGLE_VALUE,
2752 custom_field_check=_sv_field_validation,
2753 ),
2754 DctrlKnownField(
2755 "Section",
2756 FieldValueClass.SINGLE_VALUE,
2757 known_values=ALL_SECTIONS,
2758 ),
2759 DctrlKnownField(
2760 "Priority",
2761 FieldValueClass.SINGLE_VALUE,
2762 ),
2763 DctrlKnownField(
2764 "Maintainer",
2765 FieldValueClass.COMMA_SEPARATED_EMAIL_LIST,
2766 custom_field_check=_maintainer_field_validator,
2767 ),
2768 DctrlRelationshipKnownField(
2769 "Build-Depends",
2770 FieldValueClass.COMMA_SEPARATED_LIST,
2771 custom_field_check=_dctrl_validate_dep,
2772 ),
2773 DctrlRelationshipKnownField(
2774 "Build-Depends-Arch",
2775 FieldValueClass.COMMA_SEPARATED_LIST,
2776 custom_field_check=_dctrl_validate_dep,
2777 ),
2778 DctrlRelationshipKnownField(
2779 "Build-Depends-Indep",
2780 FieldValueClass.COMMA_SEPARATED_LIST,
2781 custom_field_check=_dctrl_validate_dep,
2782 ),
2783 DctrlRelationshipKnownField(
2784 "Build-Conflicts",
2785 FieldValueClass.COMMA_SEPARATED_LIST,
2786 supports_or_relation=False,
2787 custom_field_check=_dctrl_validate_dep,
2788 ),
2789 DctrlRelationshipKnownField(
2790 "Build-Conflicts-Arch",
2791 FieldValueClass.COMMA_SEPARATED_LIST,
2792 supports_or_relation=False,
2793 custom_field_check=_dctrl_validate_dep,
2794 ),
2795 DctrlRelationshipKnownField(
2796 "Build-Conflicts-Indep",
2797 FieldValueClass.COMMA_SEPARATED_LIST,
2798 supports_or_relation=False,
2799 custom_field_check=_dctrl_validate_dep,
2800 ),
2801 DctrlKnownField(
2802 "Rules-Requires-Root",
2803 FieldValueClass.SPACE_SEPARATED_LIST,
2804 custom_field_check=_rrr_build_driver_mismatch,
2805 ),
2806 DctrlKnownField(
2807 "X-Style",
2808 FieldValueClass.SINGLE_VALUE,
2809 known_values=ALL_PUBLIC_NAMED_STYLES_AS_KEYWORDS,
2810 ),
2811 DctrlKnownField(
2812 "Homepage",
2813 FieldValueClass.SINGLE_VALUE,
2814 custom_field_check=_validate_homepage_field,
2815 ),
2816)
2819BINARY_FIELDS = _fields(
2820 DctrlKnownField(
2821 "Package",
2822 FieldValueClass.SINGLE_VALUE,
2823 custom_field_check=_each_value_match_regex_validation(PKGNAME_REGEX),
2824 ),
2825 DctrlKnownField(
2826 "Architecture",
2827 FieldValueClass.SPACE_SEPARATED_LIST,
2828 # FIXME: Specialize validation for architecture ("!foo" is not a "typo" and should have a better warning)
2829 known_values=allowed_values(*dpkg_arch_and_wildcards()),
2830 ),
2831 DctrlKnownField(
2832 "Pre-Depends",
2833 FieldValueClass.COMMA_SEPARATED_LIST,
2834 custom_field_check=_dctrl_validate_dep,
2835 ),
2836 DctrlKnownField(
2837 "Depends",
2838 FieldValueClass.COMMA_SEPARATED_LIST,
2839 custom_field_check=_dctrl_validate_dep,
2840 ),
2841 DctrlKnownField(
2842 "Recommends",
2843 FieldValueClass.COMMA_SEPARATED_LIST,
2844 custom_field_check=_dctrl_validate_dep,
2845 ),
2846 DctrlKnownField(
2847 "Suggests",
2848 FieldValueClass.COMMA_SEPARATED_LIST,
2849 custom_field_check=_dctrl_validate_dep,
2850 ),
2851 DctrlKnownField(
2852 "Enhances",
2853 FieldValueClass.COMMA_SEPARATED_LIST,
2854 custom_field_check=_dctrl_validate_dep,
2855 ),
2856 DctrlRelationshipKnownField(
2857 "Provides",
2858 FieldValueClass.COMMA_SEPARATED_LIST,
2859 custom_field_check=_dctrl_validate_dep,
2860 supports_or_relation=False,
2861 allowed_version_operators=frozenset(["="]),
2862 ),
2863 DctrlRelationshipKnownField(
2864 "Conflicts",
2865 FieldValueClass.COMMA_SEPARATED_LIST,
2866 custom_field_check=_dctrl_validate_dep,
2867 supports_or_relation=False,
2868 ),
2869 DctrlRelationshipKnownField(
2870 "Breaks",
2871 FieldValueClass.COMMA_SEPARATED_LIST,
2872 custom_field_check=_dctrl_validate_dep,
2873 supports_or_relation=False,
2874 ),
2875 DctrlRelationshipKnownField(
2876 "Replaces",
2877 FieldValueClass.COMMA_SEPARATED_LIST,
2878 custom_field_check=_dctrl_validate_dep,
2879 ),
2880 DctrlKnownField(
2881 "Build-Profiles",
2882 FieldValueClass.BUILD_PROFILES_LIST,
2883 ),
2884 DctrlKnownField(
2885 "Section",
2886 FieldValueClass.SINGLE_VALUE,
2887 known_values=ALL_SECTIONS,
2888 ),
2889 DctrlRelationshipKnownField(
2890 "Built-Using",
2891 FieldValueClass.COMMA_SEPARATED_LIST,
2892 custom_field_check=_arch_not_all_only_field_validation,
2893 can_complete_field_in_stanza=_complete_only_in_arch_dep_pkgs,
2894 supports_or_relation=False,
2895 allowed_version_operators=frozenset(["="]),
2896 ),
2897 DctrlRelationshipKnownField(
2898 "Static-Built-Using",
2899 FieldValueClass.COMMA_SEPARATED_LIST,
2900 custom_field_check=_arch_not_all_only_field_validation,
2901 can_complete_field_in_stanza=_complete_only_in_arch_dep_pkgs,
2902 supports_or_relation=False,
2903 allowed_version_operators=frozenset(["="]),
2904 ),
2905 DctrlKnownField(
2906 "Multi-Arch",
2907 FieldValueClass.SINGLE_VALUE,
2908 custom_field_check=_dctrl_ma_field_validation,
2909 known_values=allowed_values(
2910 Keyword(
2911 "same",
2912 can_complete_keyword_in_stanza=_complete_only_in_arch_dep_pkgs,
2913 ),
2914 ),
2915 ),
2916 DctrlKnownField(
2917 "XB-Installer-Menu-Item",
2918 FieldValueClass.SINGLE_VALUE,
2919 can_complete_field_in_stanza=_complete_only_for_udeb_pkgs,
2920 custom_field_check=_combined_custom_field_check(
2921 _udeb_only_field_validation,
2922 _each_value_match_regex_validation(re.compile(r"^[1-9]\d{3,4}$")),
2923 ),
2924 ),
2925 DctrlKnownField(
2926 "X-DH-Build-For-Type",
2927 FieldValueClass.SINGLE_VALUE,
2928 custom_field_check=_arch_not_all_only_field_validation,
2929 can_complete_field_in_stanza=_complete_only_in_arch_dep_pkgs,
2930 ),
2931 DctrlKnownField(
2932 "X-Time64-Compat",
2933 FieldValueClass.SINGLE_VALUE,
2934 can_complete_field_in_stanza=_complete_only_in_arch_dep_pkgs,
2935 custom_field_check=_combined_custom_field_check(
2936 _each_value_match_regex_validation(PKGNAME_REGEX),
2937 _arch_not_all_only_field_validation,
2938 ),
2939 ),
2940 DctrlKnownField(
2941 "Description",
2942 FieldValueClass.FREE_TEXT_FIELD,
2943 custom_field_check=dctrl_description_validator,
2944 ),
2945 DctrlKnownField(
2946 "XB-Cnf-Visible-Pkgname",
2947 FieldValueClass.SINGLE_VALUE,
2948 custom_field_check=_each_value_match_regex_validation(PKGNAME_REGEX),
2949 ),
2950 DctrlKnownField(
2951 "Homepage",
2952 FieldValueClass.SINGLE_VALUE,
2953 show_as_inherited=False,
2954 custom_field_check=_validate_homepage_field,
2955 ),
2956)
2957_DEP5_HEADER_FIELDS = _fields(
2958 Deb822KnownField(
2959 "Format",
2960 FieldValueClass.SINGLE_VALUE,
2961 custom_field_check=_use_https_instead_of_http,
2962 ),
2963)
2964_DEP5_FILES_FIELDS = _fields(
2965 Deb822KnownField(
2966 "Files",
2967 FieldValueClass.DEP5_FILE_LIST,
2968 custom_field_check=_dep5_files_check,
2969 ),
2970)
2971_DEP5_LICENSE_FIELDS = _fields(
2972 Deb822KnownField(
2973 "License",
2974 FieldValueClass.FREE_TEXT_FIELD,
2975 ),
2976)
2978_DTESTSCTRL_FIELDS = _fields(
2979 DTestsCtrlKnownField(
2980 "Architecture",
2981 FieldValueClass.SPACE_SEPARATED_LIST,
2982 # FIXME: Specialize validation for architecture ("!fou" to "foo" would be bad)
2983 known_values=allowed_values(*dpkg_arch_and_wildcards(allow_negations=True)),
2984 ),
2985)
2986_DWATCH_HEADER_FIELDS = _fields()
2987_DWATCH_TEMPLATE_FIELDS = _fields()
2988_DWATCH_SOURCE_FIELDS = _fields()
2991@dataclasses.dataclass(slots=True)
2992class StanzaMetadata(Mapping[str, F], Generic[F], ABC):
2993 stanza_type_name: str
2994 stanza_fields: Mapping[str, F]
2995 is_substvars_allowed_in_stanza: bool
2997 async def stanza_diagnostics(
2998 self,
2999 deb822_file: Deb822FileElement,
3000 stanza: Deb822ParagraphElement,
3001 stanza_position_in_file: "TEPosition",
3002 lint_state: LintState,
3003 *,
3004 inherit_from_stanza: Optional[Deb822ParagraphElement] = None,
3005 confusable_with_stanza_name: Optional[str] = None,
3006 confusable_with_stanza_metadata: Optional["StanzaMetadata[F]"] = None,
3007 ) -> None:
3008 if (confusable_with_stanza_name is None) ^ ( 3008 ↛ 3011line 3008 didn't jump to line 3011 because the condition on line 3008 was never true
3009 confusable_with_stanza_metadata is None
3010 ):
3011 raise ValueError(
3012 "confusable_with_stanza_name and confusable_with_stanza_metadata must be used together"
3013 )
3014 _, representation_field_range = self.stanza_representation(
3015 stanza,
3016 stanza_position_in_file,
3017 )
3018 known_fields = self.stanza_fields
3019 self.omitted_field_diagnostics(
3020 lint_state,
3021 deb822_file,
3022 stanza,
3023 stanza_position_in_file,
3024 inherit_from_stanza=inherit_from_stanza,
3025 representation_field_range=representation_field_range,
3026 )
3027 seen_fields: Dict[str, Tuple[str, str, "TERange", List[Range], Set[str]]] = {}
3029 async for kvpair_range, kvpair in lint_state.slow_iter(
3030 with_range_in_continuous_parts(
3031 stanza.iter_parts(),
3032 start_relative_to=stanza_position_in_file,
3033 ),
3034 yield_every=1,
3035 ):
3036 if not isinstance(kvpair, Deb822KeyValuePairElement): 3036 ↛ 3037line 3036 didn't jump to line 3037 because the condition on line 3036 was never true
3037 continue
3038 field_name_token = kvpair.field_token
3039 field_name = field_name_token.text
3040 field_name_lc = field_name.lower()
3041 # Defeat any tricks from `python-debian` from here on out
3042 field_name = str(field_name)
3043 normalized_field_name_lc = self.normalize_field_name(field_name_lc)
3044 known_field = known_fields.get(normalized_field_name_lc)
3045 field_value = stanza[field_name]
3046 kvpair_range_te = kvpair.range_in_parent().relative_to(
3047 stanza_position_in_file
3048 )
3049 field_range = kvpair.field_token.range_in_parent().relative_to(
3050 kvpair_range_te.start_pos
3051 )
3052 field_position_te = field_range.start_pos
3053 field_name_typo_detected = False
3054 dup_field_key = (
3055 known_field.name
3056 if known_field is not None
3057 else normalized_field_name_lc
3058 )
3059 existing_field_range = seen_fields.get(dup_field_key)
3060 if existing_field_range is not None:
3061 existing_field_range[3].append(field_range)
3062 existing_field_range[4].add(field_name)
3063 else:
3064 normalized_field_name = self.normalize_field_name(field_name)
3065 seen_fields[dup_field_key] = (
3066 known_field.name if known_field else field_name,
3067 normalized_field_name,
3068 field_range,
3069 [],
3070 {field_name},
3071 )
3073 if known_field is None:
3074 candidates = detect_possible_typo(
3075 normalized_field_name_lc, known_fields
3076 )
3077 if candidates:
3078 known_field = known_fields[candidates[0]]
3079 field_range = TERange.from_position_and_size(
3080 field_position_te, kvpair.field_token.size()
3081 )
3082 field_name_typo_detected = True
3083 lint_state.emit_diagnostic(
3084 field_range,
3085 f'The "{field_name}" looks like a typo of "{known_field.name}".',
3086 "warning",
3087 "debputy",
3088 quickfixes=[
3089 propose_correct_text_quick_fix(known_fields[m].name)
3090 for m in candidates
3091 ],
3092 )
3093 if field_value.strip() == "": 3093 ↛ 3094line 3093 didn't jump to line 3094 because the condition on line 3093 was never true
3094 lint_state.emit_diagnostic(
3095 field_range,
3096 f"The {field_name} has no value. Either provide a value or remove it.",
3097 "error",
3098 "Policy 5.1",
3099 )
3100 continue
3101 if known_field is None:
3102 known_else_where = confusable_with_stanza_metadata.stanza_fields.get(
3103 normalized_field_name_lc
3104 )
3105 if known_else_where is not None:
3106 lint_state.emit_diagnostic(
3107 field_range,
3108 f"The {kvpair.field_name} is defined for use in the"
3109 f' "{confusable_with_stanza_name}" stanza. Please move it to the right place or remove it',
3110 "error",
3111 known_else_where.missing_field_authority,
3112 )
3113 continue
3114 await known_field.field_diagnostics(
3115 deb822_file,
3116 kvpair,
3117 stanza,
3118 stanza_position_in_file,
3119 kvpair_range_te,
3120 lint_state,
3121 field_name_typo_reported=field_name_typo_detected,
3122 )
3124 inherit_value = (
3125 inherit_from_stanza.get(field_name) if inherit_from_stanza else None
3126 )
3128 if (
3129 known_field.inheritable_from_other_stanza
3130 and inherit_value is not None
3131 and field_value == inherit_value
3132 ):
3133 quick_fix = propose_remove_range_quick_fix(
3134 proposed_title="Remove redundant definition"
3135 )
3136 lint_state.emit_diagnostic(
3137 kvpair_range_te,
3138 f"The field {field_name} duplicates the value from the Source stanza.",
3139 "informational",
3140 "debputy",
3141 quickfixes=[quick_fix],
3142 )
3143 for (
3144 field_name,
3145 normalized_field_name,
3146 field_range,
3147 duplicates,
3148 used_fields,
3149 ) in seen_fields.values():
3150 if not duplicates:
3151 continue
3152 if len(used_fields) != 1 or field_name not in used_fields:
3153 via_aliases_msg = " (via aliases)"
3154 else:
3155 via_aliases_msg = ""
3156 for dup_range in duplicates:
3157 lint_state.emit_diagnostic(
3158 dup_range,
3159 f'The field "{field_name}"{via_aliases_msg} was used multiple times in this stanza.'
3160 " Please ensure the field is only used once per stanza.",
3161 "error",
3162 "Policy 5.1",
3163 related_information=[
3164 lint_state.related_diagnostic_information(
3165 field_range,
3166 message=f"First definition of {field_name}",
3167 ),
3168 ],
3169 )
3171 def __getitem__(self, key: str) -> F:
3172 key_lc = key.lower()
3173 key_norm = normalize_dctrl_field_name(key_lc)
3174 return self.stanza_fields[key_norm]
3176 def __len__(self) -> int:
3177 return len(self.stanza_fields)
3179 def __iter__(self) -> Iterator[str]:
3180 return iter(self.stanza_fields.keys())
3182 def omitted_field_diagnostics(
3183 self,
3184 lint_state: LintState,
3185 deb822_file: Deb822FileElement,
3186 stanza: Deb822ParagraphElement,
3187 stanza_position: "TEPosition",
3188 *,
3189 inherit_from_stanza: Optional[Deb822ParagraphElement] = None,
3190 representation_field_range: Optional[Range] = None,
3191 ) -> None:
3192 if representation_field_range is None: 3192 ↛ 3193line 3192 didn't jump to line 3193 because the condition on line 3192 was never true
3193 _, representation_field_range = self.stanza_representation(
3194 stanza,
3195 stanza_position,
3196 )
3197 for known_field in self.stanza_fields.values():
3198 if known_field.name in stanza:
3199 continue
3201 known_field.field_omitted_diagnostics(
3202 deb822_file,
3203 representation_field_range,
3204 stanza,
3205 stanza_position,
3206 inherit_from_stanza,
3207 lint_state,
3208 )
3210 def _paragraph_representation_field(
3211 self,
3212 paragraph: Deb822ParagraphElement,
3213 ) -> Deb822KeyValuePairElement:
3214 return next(iter(paragraph.iter_parts_of_type(Deb822KeyValuePairElement)))
3216 def normalize_field_name(self, field_name: str) -> str:
3217 return field_name
3219 def stanza_representation(
3220 self,
3221 stanza: Deb822ParagraphElement,
3222 stanza_position: TEPosition,
3223 ) -> Tuple[Deb822KeyValuePairElement, TERange]:
3224 representation_field = self._paragraph_representation_field(stanza)
3225 representation_field_range = representation_field.range_in_parent().relative_to(
3226 stanza_position
3227 )
3228 return representation_field, representation_field_range
3230 def reformat_stanza(
3231 self,
3232 effective_preference: "EffectiveFormattingPreference",
3233 stanza: Deb822ParagraphElement,
3234 stanza_range: TERange,
3235 formatter: FormatterCallback,
3236 position_codec: LintCapablePositionCodec,
3237 lines: List[str],
3238 ) -> Iterable[TextEdit]:
3239 for field_name in stanza:
3240 known_field = self.stanza_fields.get(field_name.lower())
3241 if known_field is None:
3242 continue
3243 kvpair = stanza.get_kvpair_element(field_name)
3244 yield from known_field.reformat_field(
3245 effective_preference,
3246 stanza_range,
3247 kvpair,
3248 formatter,
3249 position_codec,
3250 lines,
3251 )
3254@dataclasses.dataclass(slots=True)
3255class Dep5StanzaMetadata(StanzaMetadata[Deb822KnownField]):
3256 pass
3259@dataclasses.dataclass(slots=True)
3260class DctrlStanzaMetadata(StanzaMetadata[DctrlKnownField]):
3262 def normalize_field_name(self, field_name: str) -> str:
3263 return normalize_dctrl_field_name(field_name)
3266@dataclasses.dataclass(slots=True)
3267class DTestsCtrlStanzaMetadata(StanzaMetadata[DTestsCtrlKnownField]):
3269 def omitted_field_diagnostics(
3270 self,
3271 lint_state: LintState,
3272 deb822_file: Deb822FileElement,
3273 stanza: Deb822ParagraphElement,
3274 stanza_position: "TEPosition",
3275 *,
3276 inherit_from_stanza: Optional[Deb822ParagraphElement] = None,
3277 representation_field_range: Optional[Range] = None,
3278 ) -> None:
3279 if representation_field_range is None: 3279 ↛ 3280line 3279 didn't jump to line 3280 because the condition on line 3279 was never true
3280 _, representation_field_range = self.stanza_representation(
3281 stanza,
3282 stanza_position,
3283 )
3284 auth_ref = self.stanza_fields["tests"].missing_field_authority
3285 if "Tests" not in stanza and "Test-Command" not in stanza:
3286 lint_state.emit_diagnostic(
3287 representation_field_range,
3288 'Stanza must have either a "Tests" or a "Test-Command" field',
3289 "error",
3290 # TODO: Better authority_reference
3291 auth_ref,
3292 )
3293 if "Tests" in stanza and "Test-Command" in stanza:
3294 lint_state.emit_diagnostic(
3295 representation_field_range,
3296 'Stanza cannot have both a "Tests" and a "Test-Command" field',
3297 "error",
3298 # TODO: Better authority_reference
3299 auth_ref,
3300 )
3302 # Note that since we do not use the field names for stanza classification, we
3303 # always do the super call.
3304 super(DTestsCtrlStanzaMetadata, self).omitted_field_diagnostics(
3305 lint_state,
3306 deb822_file,
3307 stanza,
3308 stanza_position,
3309 representation_field_range=representation_field_range,
3310 inherit_from_stanza=inherit_from_stanza,
3311 )
3314@dataclasses.dataclass(slots=True)
3315class DebianWatchStanzaMetadata(StanzaMetadata[Deb822KnownField]):
3317 def omitted_field_diagnostics(
3318 self,
3319 lint_state: LintState,
3320 deb822_file: Deb822FileElement,
3321 stanza: Deb822ParagraphElement,
3322 stanza_position: "TEPosition",
3323 *,
3324 inherit_from_stanza: Optional[Deb822ParagraphElement] = None,
3325 representation_field_range: Optional[Range] = None,
3326 ) -> None:
3327 if representation_field_range is None: 3327 ↛ 3328line 3327 didn't jump to line 3328 because the condition on line 3327 was never true
3328 _, representation_field_range = self.stanza_representation(
3329 stanza,
3330 stanza_position,
3331 )
3333 if ( 3333 ↛ 3338line 3333 didn't jump to line 3338 because the condition on line 3333 was never true
3334 self.stanza_type_name != "Header"
3335 and "Source" not in stanza
3336 and "Template" not in stanza
3337 ):
3338 lint_state.emit_diagnostic(
3339 representation_field_range,
3340 'Stanza must have either a "Source" or a "Template" field',
3341 "error",
3342 # TODO: Better authority_reference
3343 "debputy",
3344 )
3345 # The required fields depends on which stanza it is. Therefore, we omit the super
3346 # call until this error is resolved.
3347 return
3349 super(DebianWatchStanzaMetadata, self).omitted_field_diagnostics(
3350 lint_state,
3351 deb822_file,
3352 stanza,
3353 stanza_position,
3354 representation_field_range=representation_field_range,
3355 inherit_from_stanza=inherit_from_stanza,
3356 )
3359def lsp_reference_data_dir() -> str:
3360 return os.path.join(
3361 os.path.dirname(__file__),
3362 "data",
3363 )
3366class Deb822FileMetadata(Generic[S, F]):
3368 def __init__(self) -> None:
3369 self._is_initialized = False
3370 self._data: Optional[Deb822ReferenceData] = None
3372 @property
3373 def reference_data_basename(self) -> str:
3374 raise NotImplementedError
3376 def _new_field(
3377 self,
3378 name: str,
3379 field_value_type: FieldValueClass,
3380 ) -> F:
3381 raise NotImplementedError
3383 def _reference_data(self) -> Deb822ReferenceData:
3384 ref = self._data
3385 if ref is not None: 3385 ↛ 3386line 3385 didn't jump to line 3386 because the condition on line 3385 was never true
3386 return ref
3388 p = importlib.resources.files(deb822_ref_data_dir.__name__).joinpath(
3389 self.reference_data_basename
3390 )
3392 with p.open("r", encoding="utf-8") as fd:
3393 raw = MANIFEST_YAML.load(fd)
3395 attr_path = AttributePath.root_path(p)
3396 try:
3397 ref = DEB822_REFERENCE_DATA_PARSER.parse_input(raw, attr_path)
3398 except ManifestParseException as e:
3399 raise ValueError(
3400 f"Internal error: Could not parse reference data [{self.reference_data_basename}]: {e.message}"
3401 ) from e
3402 self._data = ref
3403 return ref
3405 @property
3406 def is_initialized(self) -> bool:
3407 return self._is_initialized
3409 def ensure_initialized(self) -> None:
3410 if self.is_initialized:
3411 return
3412 # Enables us to use __getitem__
3413 self._is_initialized = True
3414 ref_data = self._reference_data()
3415 ref_defs = ref_data.get("definitions")
3416 variables = {}
3417 ref_variables = ref_defs.get("variables", []) if ref_defs else []
3418 for ref_variable in ref_variables:
3419 name = ref_variable["name"]
3420 fallback = ref_variable["fallback"]
3421 variables[name] = fallback
3423 def _resolve_doc(template: Optional[str]) -> Optional[str]:
3424 if template is None: 3424 ↛ 3425line 3424 didn't jump to line 3425 because the condition on line 3424 was never true
3425 return None
3426 try:
3427 return template.format(**variables)
3428 except ValueError as e:
3429 template_escaped = template.replace("\n", "\\r")
3430 _error(f"Bad template: {template_escaped}: {e}")
3432 for ref_stanza_type in ref_data["stanza_types"]:
3433 stanza_name = ref_stanza_type["stanza_name"]
3434 stanza = self[stanza_name]
3435 stanza_fields = dict(stanza.stanza_fields)
3436 stanza.stanza_fields = stanza_fields
3437 for ref_field in ref_stanza_type["fields"]:
3438 _resolve_field(
3439 ref_field,
3440 stanza_fields,
3441 self._new_field,
3442 _resolve_doc,
3443 f"Stanza:{stanza.stanza_type_name}|Field:{ref_field['canonical_name']}",
3444 )
3446 def file_metadata_applies_to_file(
3447 self,
3448 deb822_file: Optional[Deb822FileElement],
3449 ) -> bool:
3450 return deb822_file is not None
3452 def classify_stanza(self, stanza: Deb822ParagraphElement, stanza_idx: int) -> S:
3453 return self.guess_stanza_classification_by_idx(stanza_idx)
3455 def guess_stanza_classification_by_idx(self, stanza_idx: int) -> S:
3456 raise NotImplementedError
3458 def stanza_types(self) -> Iterable[S]:
3459 raise NotImplementedError
3461 def __getitem__(self, item: str) -> S:
3462 raise NotImplementedError
3464 def get(self, item: str) -> Optional[S]:
3465 try:
3466 return self[item]
3467 except KeyError:
3468 return None
3470 def reformat(
3471 self,
3472 effective_preference: "EffectiveFormattingPreference",
3473 deb822_file: Deb822FileElement,
3474 formatter: FormatterCallback,
3475 _content: str,
3476 position_codec: LintCapablePositionCodec,
3477 lines: List[str],
3478 ) -> Iterable[TextEdit]:
3479 stanza_idx = -1
3480 for token_or_element in deb822_file.iter_parts():
3481 if isinstance(token_or_element, Deb822ParagraphElement):
3482 stanza_range = token_or_element.range_in_parent()
3483 stanza_idx += 1
3484 stanza_metadata = self.classify_stanza(token_or_element, stanza_idx)
3485 yield from stanza_metadata.reformat_stanza(
3486 effective_preference,
3487 token_or_element,
3488 stanza_range,
3489 formatter,
3490 position_codec,
3491 lines,
3492 )
3493 else:
3494 token_range = token_or_element.range_in_parent()
3495 yield from trim_end_of_line_whitespace(
3496 position_codec,
3497 lines,
3498 line_range=range(
3499 token_range.start_pos.line_position,
3500 token_range.end_pos.line_position,
3501 ),
3502 )
3505_DCTRL_SOURCE_STANZA = DctrlStanzaMetadata(
3506 "Source",
3507 SOURCE_FIELDS,
3508 is_substvars_allowed_in_stanza=False,
3509)
3510_DCTRL_PACKAGE_STANZA = DctrlStanzaMetadata(
3511 "Package",
3512 BINARY_FIELDS,
3513 is_substvars_allowed_in_stanza=True,
3514)
3516_DEP5_HEADER_STANZA = Dep5StanzaMetadata(
3517 "Header",
3518 _DEP5_HEADER_FIELDS,
3519 is_substvars_allowed_in_stanza=False,
3520)
3521_DEP5_FILES_STANZA = Dep5StanzaMetadata(
3522 "Files",
3523 _DEP5_FILES_FIELDS,
3524 is_substvars_allowed_in_stanza=False,
3525)
3526_DEP5_LICENSE_STANZA = Dep5StanzaMetadata(
3527 "License",
3528 _DEP5_LICENSE_FIELDS,
3529 is_substvars_allowed_in_stanza=False,
3530)
3532_DTESTSCTRL_STANZA = DTestsCtrlStanzaMetadata(
3533 "Tests",
3534 _DTESTSCTRL_FIELDS,
3535 is_substvars_allowed_in_stanza=False,
3536)
3538_WATCH_HEADER_HEADER_STANZA = DebianWatchStanzaMetadata(
3539 "Header",
3540 _DWATCH_HEADER_FIELDS,
3541 is_substvars_allowed_in_stanza=False,
3542)
3543_WATCH_SOURCE_STANZA = DebianWatchStanzaMetadata(
3544 "Source",
3545 _DWATCH_SOURCE_FIELDS,
3546 is_substvars_allowed_in_stanza=False,
3547)
3550class Dep5FileMetadata(Deb822FileMetadata[Dep5StanzaMetadata, Deb822KnownField]):
3552 @property
3553 def reference_data_basename(self) -> str:
3554 return "debian_copyright_reference_data.yaml"
3556 def _new_field(
3557 self,
3558 name: str,
3559 field_value_type: FieldValueClass,
3560 ) -> F:
3561 return Deb822KnownField(name, field_value_type)
3563 def file_metadata_applies_to_file(
3564 self,
3565 deb822_file: Optional[Deb822FileElement],
3566 ) -> bool:
3567 if not super().file_metadata_applies_to_file(deb822_file): 3567 ↛ 3568line 3567 didn't jump to line 3568 because the condition on line 3567 was never true
3568 return False
3569 first_stanza = next(iter(deb822_file), None)
3570 if first_stanza is None or "Format" not in first_stanza:
3571 # No parseable stanzas or the first one did not have a Format, which is necessary.
3572 return False
3574 for part in deb822_file.iter_parts(): 3574 ↛ 3580line 3574 didn't jump to line 3580 because the loop on line 3574 didn't complete
3575 if part.is_error:
3576 # Error first, then it might just be a "Format:" in the middle of a free-text file.
3577 return False
3578 if part is first_stanza: 3578 ↛ 3574line 3578 didn't jump to line 3574 because the condition on line 3578 was always true
3579 break
3580 return True
3582 def classify_stanza(
3583 self,
3584 stanza: Deb822ParagraphElement,
3585 stanza_idx: int,
3586 ) -> Dep5StanzaMetadata:
3587 self.ensure_initialized()
3588 if stanza_idx == 0: 3588 ↛ 3589line 3588 didn't jump to line 3589 because the condition on line 3588 was never true
3589 return _DEP5_HEADER_STANZA
3590 if stanza_idx > 0: 3590 ↛ 3594line 3590 didn't jump to line 3594 because the condition on line 3590 was always true
3591 if "Files" in stanza:
3592 return _DEP5_FILES_STANZA
3593 return _DEP5_LICENSE_STANZA
3594 raise ValueError("The stanza_idx must be 0 or greater")
3596 def guess_stanza_classification_by_idx(self, stanza_idx: int) -> Dep5StanzaMetadata:
3597 self.ensure_initialized()
3598 if stanza_idx == 0:
3599 return _DEP5_HEADER_STANZA
3600 if stanza_idx > 0:
3601 return _DEP5_FILES_STANZA
3602 raise ValueError("The stanza_idx must be 0 or greater")
3604 def stanza_types(self) -> Iterable[Dep5StanzaMetadata]:
3605 self.ensure_initialized()
3606 # Order assumption made in the LSP code.
3607 yield _DEP5_HEADER_STANZA
3608 yield _DEP5_FILES_STANZA
3609 yield _DEP5_LICENSE_STANZA
3611 def __getitem__(self, item: str) -> Dep5StanzaMetadata:
3612 self.ensure_initialized()
3613 if item == "Header":
3614 return _DEP5_HEADER_STANZA
3615 if item == "Files":
3616 return _DEP5_FILES_STANZA
3617 if item == "License": 3617 ↛ 3619line 3617 didn't jump to line 3619 because the condition on line 3617 was always true
3618 return _DEP5_LICENSE_STANZA
3619 raise KeyError(item)
3622class DebianWatch5FileMetadata(
3623 Deb822FileMetadata[DebianWatchStanzaMetadata, Deb822KnownField]
3624):
3626 @property
3627 def reference_data_basename(self) -> str:
3628 return "debian_watch_reference_data.yaml"
3630 def _new_field(
3631 self,
3632 name: str,
3633 field_value_type: FieldValueClass,
3634 ) -> F:
3635 return Deb822KnownField(name, field_value_type)
3637 def file_metadata_applies_to_file(
3638 self, deb822_file: Optional[Deb822FileElement]
3639 ) -> bool:
3640 if not super().file_metadata_applies_to_file(deb822_file): 3640 ↛ 3641line 3640 didn't jump to line 3641 because the condition on line 3640 was never true
3641 return False
3642 first_stanza = next(iter(deb822_file), None)
3644 if first_stanza is None or "Version" not in first_stanza: 3644 ↛ 3646line 3644 didn't jump to line 3646 because the condition on line 3644 was never true
3645 # No parseable stanzas or the first one did not have a Version field, which is necessary.
3646 return False
3648 try:
3649 if int(first_stanza.get("Version")) < 5: 3649 ↛ 3650line 3649 didn't jump to line 3650 because the condition on line 3649 was never true
3650 return False
3651 except (ValueError, IndexError, TypeError):
3652 return False
3654 for part in deb822_file.iter_parts(): 3654 ↛ 3660line 3654 didn't jump to line 3660 because the loop on line 3654 didn't complete
3655 if part.is_error: 3655 ↛ 3657line 3655 didn't jump to line 3657 because the condition on line 3655 was never true
3656 # Error first, then it might just be a "Version:" in the middle of a free-text file.
3657 return False
3658 if part is first_stanza: 3658 ↛ 3654line 3658 didn't jump to line 3654 because the condition on line 3658 was always true
3659 break
3660 return True
3662 def classify_stanza(
3663 self,
3664 stanza: Deb822ParagraphElement,
3665 stanza_idx: int,
3666 ) -> DebianWatchStanzaMetadata:
3667 self.ensure_initialized()
3668 if stanza_idx == 0:
3669 return _WATCH_HEADER_HEADER_STANZA
3670 if stanza_idx > 0: 3670 ↛ 3672line 3670 didn't jump to line 3672 because the condition on line 3670 was always true
3671 return _WATCH_SOURCE_STANZA
3672 raise ValueError("The stanza_idx must be 0 or greater")
3674 def guess_stanza_classification_by_idx(
3675 self, stanza_idx: int
3676 ) -> DebianWatchStanzaMetadata:
3677 self.ensure_initialized()
3678 if stanza_idx == 0: 3678 ↛ 3679line 3678 didn't jump to line 3679 because the condition on line 3678 was never true
3679 return _WATCH_HEADER_HEADER_STANZA
3680 if stanza_idx > 0: 3680 ↛ 3682line 3680 didn't jump to line 3682 because the condition on line 3680 was always true
3681 return _WATCH_SOURCE_STANZA
3682 raise ValueError("The stanza_idx must be 0 or greater")
3684 def stanza_types(self) -> Iterable[DebianWatchStanzaMetadata]:
3685 self.ensure_initialized()
3686 # Order assumption made in the LSP code.
3687 yield _WATCH_HEADER_HEADER_STANZA
3688 yield _WATCH_SOURCE_STANZA
3690 def __getitem__(self, item: str) -> DebianWatchStanzaMetadata:
3691 self.ensure_initialized()
3692 if item == "Header":
3693 return _WATCH_HEADER_HEADER_STANZA
3694 if item == "Source": 3694 ↛ 3696line 3694 didn't jump to line 3696 because the condition on line 3694 was always true
3695 return _WATCH_SOURCE_STANZA
3696 raise KeyError(item)
3699def _resolve_keyword(
3700 ref_value: StaticValue,
3701 known_values: Dict[str, Keyword],
3702 resolve_template: Callable[[Optional[str]], Optional[str]],
3703 translation_context: str,
3704) -> None:
3705 value_key = ref_value["value"]
3706 changes = {
3707 "translation_context": translation_context,
3708 }
3709 try:
3710 known_value = known_values[value_key]
3711 except KeyError:
3712 known_value = Keyword(value_key)
3713 known_values[value_key] = known_value
3714 else:
3715 if known_value.is_alias_of: 3715 ↛ 3716line 3715 didn't jump to line 3716 because the condition on line 3715 was never true
3716 raise ValueError(
3717 f"The value {known_value.value} has an alias {known_value.is_alias_of} that conflicts with"
3718 f' {value_key} or the data file used an alias in its `canonical-name` rather than the "true" name'
3719 )
3720 value_doc = ref_value.get("documentation")
3721 if value_doc is not None:
3722 changes["synopsis"] = value_doc.get("synopsis")
3723 changes["long_description"] = resolve_template(
3724 value_doc.get("long_description")
3725 )
3726 if is_exclusive := ref_value.get("is_exclusive"):
3727 changes["is_exclusive"] = is_exclusive
3728 if (sort_key := ref_value.get("sort_key")) is not None:
3729 changes["sort_text"] = sort_key
3730 if (usage_hint := ref_value.get("usage_hint")) is not None:
3731 changes["usage_hint"] = usage_hint
3732 if changes: 3732 ↛ 3736line 3732 didn't jump to line 3736 because the condition on line 3732 was always true
3733 known_value = known_value.replace(**changes)
3734 known_values[value_key] = known_value
3736 _expand_aliases(
3737 known_value,
3738 known_values,
3739 operator.attrgetter("value"),
3740 ref_value.get("aliases"),
3741 "The value `{ALIAS}` is an alias of `{NAME}`.",
3742 )
3745def _resolve_field(
3746 ref_field: Deb822Field,
3747 stanza_fields: Dict[str, F],
3748 field_constructor: Callable[[str, FieldValueClass], F],
3749 resolve_template: Callable[[Optional[str]], Optional[str]],
3750 translation_context: str,
3751) -> None:
3752 field_name = ref_field["canonical_name"]
3753 field_value_type = FieldValueClass.from_key(ref_field["field_value_type"])
3754 doc = ref_field.get("documentation")
3755 ref_values = ref_field.get("values", [])
3756 norm_field_name = normalize_dctrl_field_name(field_name.lower())
3758 try:
3759 field = stanza_fields[norm_field_name]
3760 except KeyError:
3761 field = field_constructor(
3762 field_name,
3763 field_value_type,
3764 )
3765 stanza_fields[norm_field_name] = field
3766 else:
3767 if field.name != field_name: 3767 ↛ 3768line 3767 didn't jump to line 3768 because the condition on line 3767 was never true
3768 _error(
3769 f'Error in reference data: Code uses "{field.name}" as canonical name and the data file'
3770 f" uses {field_name}. Please ensure the data is correctly aligned."
3771 )
3772 if field.field_value_class != field_value_type: 3772 ↛ 3773line 3772 didn't jump to line 3773 because the condition on line 3772 was never true
3773 _error(
3774 f'Error in reference data for field "{field.name}": Code has'
3775 f" {field.field_value_class.key} and the data file uses {field_value_type.key}"
3776 f" for field-value-type. Please ensure the data is correctly aligned."
3777 )
3778 if field.is_alias_of: 3778 ↛ 3779line 3778 didn't jump to line 3779 because the condition on line 3778 was never true
3779 raise ValueError(
3780 f"The field {field.name} has an alias {field.is_alias_of} that conflicts with"
3781 f' {field_name} or the data file used an alias in its `canonical-name` rather than the "true" name'
3782 )
3784 if doc is not None:
3785 field.synopsis = doc.get("synopsis")
3786 field.long_description = resolve_template(doc.get("long_description"))
3788 field.default_value = ref_field.get("default_value")
3789 field.warn_if_default = ref_field.get("warn_if_default", True)
3790 field.spellcheck_value = ref_field.get("spellcheck_value", False)
3791 field.deprecated_with_no_replacement = ref_field.get(
3792 "is_obsolete_without_replacement", False
3793 )
3794 field.replaced_by = ref_field.get("replaced_by")
3795 field.translation_context = translation_context
3796 field.usage_hint = ref_field.get("usage_hint")
3797 field.missing_field_severity = ref_field.get("missing_field_severity", None)
3798 unknown_value_severity = ref_field.get("unknown_value_severity", "error")
3799 field.unknown_value_severity = (
3800 None if unknown_value_severity == "none" else unknown_value_severity
3801 )
3802 field.unknown_value_authority = ref_field.get("unknown_value_authority", "debputy")
3803 field.missing_field_authority = ref_field.get("missing_field_authority", "debputy")
3804 field.is_substvars_disabled_even_if_allowed_by_stanza = not ref_field.get(
3805 "supports_substvars",
3806 True,
3807 )
3808 field.inheritable_from_other_stanza = ref_field.get(
3809 "inheritable_from_other_stanza",
3810 False,
3811 )
3813 known_values = field.known_values
3814 if known_values is None:
3815 known_values = {}
3816 else:
3817 known_values = dict(known_values)
3819 for ref_value in ref_values:
3820 _resolve_keyword(ref_value, known_values, resolve_template, translation_context)
3822 if known_values:
3823 field.known_values = known_values
3825 _expand_aliases(
3826 field,
3827 stanza_fields,
3828 operator.attrgetter("name"),
3829 ref_field.get("aliases"),
3830 "The field `{ALIAS}` is an alias of `{NAME}`.",
3831 )
3834A = TypeVar("A", Keyword, Deb822KnownField)
3837def _expand_aliases(
3838 item: A,
3839 item_container: Dict[str, A],
3840 canonical_name_resolver: Callable[[A], str],
3841 aliases_ref: Optional[List[Alias]],
3842 doc_template: str,
3843) -> None:
3844 if aliases_ref is None:
3845 return
3846 name = canonical_name_resolver(item)
3847 assert name is not None, "canonical_name_resolver is not allowed to return None"
3848 for alias_ref in aliases_ref:
3849 alias_name = alias_ref["alias"]
3850 alias_doc = item.long_description
3851 is_completion_suggestion = alias_ref.get("is_completion_suggestion", False)
3852 doc_suffix = doc_template.format(NAME=name, ALIAS=alias_name)
3853 if alias_doc:
3854 alias_doc += f"\n\n{doc_suffix}"
3855 else:
3856 alias_doc = doc_suffix
3857 alias_field = item.replace(
3858 long_description=alias_doc,
3859 is_alias_of=name,
3860 is_completion_suggestion=is_completion_suggestion,
3861 )
3862 alias_key = alias_name.lower()
3863 if alias_name in item_container: 3863 ↛ 3864line 3863 didn't jump to line 3864 because the condition on line 3863 was never true
3864 existing_name = canonical_name_resolver(item_container[alias_key])
3865 assert (
3866 existing_name is not None
3867 ), "canonical_name_resolver is not allowed to return None"
3868 raise ValueError(
3869 f"The value {name} has an alias {alias_name} that conflicts with {existing_name}"
3870 )
3871 item_container[alias_key] = alias_field
3874class DctrlFileMetadata(Deb822FileMetadata[DctrlStanzaMetadata, DctrlKnownField]):
3876 @property
3877 def reference_data_basename(self) -> str:
3878 return "debian_control_reference_data.yaml"
3880 def _new_field(
3881 self,
3882 name: str,
3883 field_value_type: FieldValueClass,
3884 ) -> F:
3885 return DctrlKnownField(name, field_value_type)
3887 def guess_stanza_classification_by_idx(
3888 self,
3889 stanza_idx: int,
3890 ) -> DctrlStanzaMetadata:
3891 self.ensure_initialized()
3892 if stanza_idx == 0:
3893 return _DCTRL_SOURCE_STANZA
3894 if stanza_idx > 0: 3894 ↛ 3896line 3894 didn't jump to line 3896 because the condition on line 3894 was always true
3895 return _DCTRL_PACKAGE_STANZA
3896 raise ValueError("The stanza_idx must be 0 or greater")
3898 def stanza_types(self) -> Iterable[DctrlStanzaMetadata]:
3899 self.ensure_initialized()
3900 # Order assumption made in the LSP code.
3901 yield _DCTRL_SOURCE_STANZA
3902 yield _DCTRL_PACKAGE_STANZA
3904 def __getitem__(self, item: str) -> DctrlStanzaMetadata:
3905 self.ensure_initialized()
3906 if item == "Source":
3907 return _DCTRL_SOURCE_STANZA
3908 if item == "Package": 3908 ↛ 3910line 3908 didn't jump to line 3910 because the condition on line 3908 was always true
3909 return _DCTRL_PACKAGE_STANZA
3910 raise KeyError(item)
3912 def reformat(
3913 self,
3914 effective_preference: "EffectiveFormattingPreference",
3915 deb822_file: Deb822FileElement,
3916 formatter: FormatterCallback,
3917 content: str,
3918 position_codec: LintCapablePositionCodec,
3919 lines: List[str],
3920 ) -> Iterable[TextEdit]:
3921 edits = list(
3922 super().reformat(
3923 effective_preference,
3924 deb822_file,
3925 formatter,
3926 content,
3927 position_codec,
3928 lines,
3929 )
3930 )
3932 if ( 3932 ↛ 3937line 3932 didn't jump to line 3937 because the condition on line 3932 was always true
3933 not effective_preference.deb822_normalize_stanza_order
3934 or deb822_file.find_first_error_element() is not None
3935 ):
3936 return edits
3937 names = []
3938 for idx, stanza in enumerate(deb822_file):
3939 if idx < 2:
3940 continue
3941 name = stanza.get("Package")
3942 if name is None:
3943 return edits
3944 names.append(name)
3946 reordered = sorted(names)
3947 if names == reordered:
3948 return edits
3950 if edits:
3951 content = apply_text_edits(content, lines, edits)
3952 lines = content.splitlines(keepends=True)
3953 deb822_file = parse_deb822_file(
3954 lines,
3955 accept_files_with_duplicated_fields=True,
3956 accept_files_with_error_tokens=True,
3957 )
3959 stanzas = list(deb822_file)
3960 reordered_stanza = stanzas[:2] + sorted(
3961 stanzas[2:], key=operator.itemgetter("Package")
3962 )
3963 bits = []
3964 stanza_idx = 0
3965 for token_or_element in deb822_file.iter_parts():
3966 if isinstance(token_or_element, Deb822ParagraphElement):
3967 bits.append(reordered_stanza[stanza_idx].dump())
3968 stanza_idx += 1
3969 else:
3970 bits.append(token_or_element.convert_to_text())
3972 new_content = "".join(bits)
3974 return [
3975 TextEdit(
3976 Range(
3977 Position(0, 0),
3978 Position(len(lines) + 1, 0),
3979 ),
3980 new_content,
3981 )
3982 ]
3985class DTestsCtrlFileMetadata(
3986 Deb822FileMetadata[DTestsCtrlStanzaMetadata, DTestsCtrlKnownField]
3987):
3989 @property
3990 def reference_data_basename(self) -> str:
3991 return "debian_tests_control_reference_data.yaml"
3993 def _new_field(
3994 self,
3995 name: str,
3996 field_value_type: FieldValueClass,
3997 ) -> F:
3998 return DTestsCtrlKnownField(name, field_value_type)
4000 def guess_stanza_classification_by_idx(self, stanza_idx: int) -> S:
4001 if stanza_idx >= 0: 4001 ↛ 4004line 4001 didn't jump to line 4004 because the condition on line 4001 was always true
4002 self.ensure_initialized()
4003 return _DTESTSCTRL_STANZA
4004 raise ValueError("The stanza_idx must be 0 or greater")
4006 def stanza_types(self) -> Iterable[S]:
4007 self.ensure_initialized()
4008 yield _DTESTSCTRL_STANZA
4010 def __getitem__(self, item: str) -> S:
4011 self.ensure_initialized()
4012 if item == "Tests": 4012 ↛ 4014line 4012 didn't jump to line 4014 because the condition on line 4012 was always true
4013 return _DTESTSCTRL_STANZA
4014 raise KeyError(item)
4017TRANSLATABLE_DEB822_FILE_METADATA: Sequence[
4018 Callable[[], Deb822FileMetadata[Any, Any]]
4019] = [
4020 DctrlFileMetadata,
4021 Dep5FileMetadata,
4022 DTestsCtrlFileMetadata,
4023]