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