Coverage for src/debputy/lsp/lsp_debian_control_reference_data.py: 84%
1402 statements
« prev ^ index » next coverage.py v7.8.2, created at 2026-03-22 09:00 +0000
« prev ^ index » next coverage.py v7.8.2, created at 2026-03-22 09:00 +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 if (
1153 known_field.name == "Provides"
1154 and version_operator == "="
1155 and prev_version_operator == version_operator
1156 and relation.version != prev_relation.version
1157 ):
1158 # Provides: foo (= 1), foo (= 2) is legal and not mergeable
1159 continue
1161 orig_relation_range = TERange(
1162 _text_to_te_position(
1163 raw_value_masked_comments[
1164 : prev_relation.content_display_offset
1165 ]
1166 ),
1167 _text_to_te_position(
1168 raw_value_masked_comments[
1169 : prev_relation.content_display_end_offset
1170 ]
1171 ),
1172 ).relative_to(value_element_pos)
1174 duplicate_relation_range = TERange(
1175 _text_to_te_position(
1176 raw_value_masked_comments[: relation.content_display_offset]
1177 ),
1178 _text_to_te_position(
1179 raw_value_masked_comments[: relation.content_display_end_offset]
1180 ),
1181 ).relative_to(value_element_pos)
1183 lint_state.emit_diagnostic(
1184 duplicate_relation_range,
1185 "Duplicate relationship. Merge with the previous relationship",
1186 "warning",
1187 known_field.unknown_value_authority,
1188 related_information=[
1189 lint_state.related_diagnostic_information(
1190 orig_relation_range,
1191 "The previous definition",
1192 ),
1193 ],
1194 )
1195 # We only emit for the first duplicate "key" for each relation. Odds are remaining
1196 # keys point to the same match. Even if they do not, it does not really matter as
1197 # we already pointed out an issue for the user to follow up on.
1198 break
1201def _dctrl_check_dep_version_operator(
1202 known_field: "F",
1203 version_operator: str,
1204 version_operator_span: tuple[int, int],
1205 version_operators: frozenset[str],
1206 raw_value_masked_comments: str,
1207 offset: int,
1208 value_element_pos: "TEPosition",
1209 lint_state: LintState,
1210) -> bool:
1211 if (
1212 version_operators
1213 and version_operator is not None
1214 and version_operator not in version_operators
1215 ):
1216 v_start_offset = offset + version_operator_span[0]
1217 v_end_offset = offset + version_operator_span[1]
1218 version_problem_range_te = TERange(
1219 _text_to_te_position(raw_value_masked_comments[:v_start_offset]),
1220 _text_to_te_position(raw_value_masked_comments[:v_end_offset]),
1221 ).relative_to(value_element_pos)
1223 sorted_version_operators = sorted(version_operators)
1225 excluding_equal = f"{version_operator}{version_operator}"
1226 including_equal = f"{version_operator}="
1228 if version_operator in (">", "<") and (
1229 excluding_equal in version_operators or including_equal in version_operators
1230 ):
1231 lint_state.emit_diagnostic(
1232 version_problem_range_te,
1233 f'Obsolete version operator "{version_operator}" that is no longer supported.',
1234 "error",
1235 "Policy 7.1",
1236 quickfixes=[
1237 propose_correct_text_quick_fix(n)
1238 for n in (excluding_equal, including_equal)
1239 if not version_operators or n in version_operators
1240 ],
1241 )
1242 else:
1243 lint_state.emit_diagnostic(
1244 version_problem_range_te,
1245 f'The version operator "{version_operator}" is not allowed in {known_field.name}',
1246 "error",
1247 known_field.unknown_value_authority,
1248 quickfixes=[
1249 propose_correct_text_quick_fix(n) for n in sorted_version_operators
1250 ],
1251 )
1252 return True
1253 return False
1256def _dctrl_validate_dep(
1257 known_field: "DF",
1258 _deb822_file: Deb822FileElement,
1259 kvpair: Deb822KeyValuePairElement,
1260 kvpair_range_te: "TERange",
1261 _field_name_range: "TERange",
1262 _stanza: Deb822ParagraphElement,
1263 _stanza_position: "TEPosition",
1264 lint_state: LintState,
1265) -> None:
1266 value_element_pos = kvpair.value_element.position_in_parent().relative_to(
1267 kvpair_range_te.start_pos
1268 )
1269 raw_value_with_comments = kvpair.value_element.convert_to_text()
1270 raw_value_masked_comments = "".join(
1271 (line if not line.startswith("#") else (" " * (len(line) - 1)) + "\n")
1272 for line in raw_value_with_comments.splitlines(keepends=True)
1273 )
1274 if isinstance(known_field, DctrlRelationshipKnownField):
1275 version_operators = known_field.allowed_version_operators
1276 supports_or_relation = known_field.supports_or_relation
1277 else:
1278 version_operators = frozenset({">>", ">=", "=", "<=", "<<"})
1279 supports_or_relation = True
1281 relation_dup_table = collections.defaultdict(list)
1283 for rel, rel_offset, rel_end_offset in _split_w_spans(
1284 raw_value_masked_comments, ","
1285 ):
1286 sub_relations = []
1287 for or_rel, offset, end_offset in _split_w_spans(rel, "|", offset=rel_offset):
1288 if or_rel.isspace():
1289 continue
1290 if sub_relations and not supports_or_relation:
1291 separator_range_te = TERange(
1292 _text_to_te_position(raw_value_masked_comments[: offset - 1]),
1293 _text_to_te_position(raw_value_masked_comments[:offset]),
1294 ).relative_to(value_element_pos)
1295 lint_state.emit_diagnostic(
1296 separator_range_te,
1297 f'The field {known_field.name} does not support "|" (OR) in relations.',
1298 "error",
1299 known_field.unknown_value_authority,
1300 )
1301 m = _DEP_RELATION_CLAUSE.fullmatch(or_rel)
1303 if m is not None:
1304 garbage = m.group("garbage")
1305 version_operator = m.group("operator")
1306 version_operator_span = m.span("operator")
1307 if _dctrl_check_dep_version_operator(
1308 known_field,
1309 version_operator,
1310 version_operator_span,
1311 version_operators,
1312 raw_value_masked_comments,
1313 offset,
1314 value_element_pos,
1315 lint_state,
1316 ):
1317 sub_relations.append(Relation("<BROKEN>"))
1318 else:
1319 name_arch_qual = m.group("name_arch_qual")
1320 if ":" in name_arch_qual:
1321 name, arch_qual = name_arch_qual.split(":", 1)
1322 else:
1323 name = name_arch_qual
1324 arch_qual = None
1325 sub_relations.append(
1326 Relation(
1327 name,
1328 arch_qual=arch_qual,
1329 version_operator=version_operator,
1330 version=m.group("version"),
1331 arch_restriction=m.group("build_profile_restriction"),
1332 build_profile_restriction=m.group(
1333 "build_profile_restriction"
1334 ),
1335 content_display_offset=offset + m.start("name_arch_qual"),
1336 # TODO: This should be trimmed in the end.
1337 content_display_end_offset=rel_end_offset,
1338 )
1339 )
1340 else:
1341 garbage = None
1342 sub_relations.append(Relation("<BROKEN>"))
1344 if m is not None and not garbage:
1345 continue
1346 if m is not None:
1347 garbage_span = m.span("garbage")
1348 garbage_start, garbage_end = garbage_span
1349 error_start_offset = offset + garbage_start
1350 error_end_offset = offset + garbage_end
1351 garbage_part = raw_value_masked_comments[
1352 error_start_offset:error_end_offset
1353 ]
1354 else:
1355 garbage_part = None
1356 error_start_offset = offset
1357 error_end_offset = end_offset
1359 problem_range_te = TERange(
1360 _text_to_te_position(raw_value_masked_comments[:error_start_offset]),
1361 _text_to_te_position(raw_value_masked_comments[:error_end_offset]),
1362 ).relative_to(value_element_pos)
1364 if garbage_part is not None:
1365 if _DEP_RELATION_CLAUSE.fullmatch(garbage_part) is not None:
1366 msg = (
1367 "Trailing data after a relationship that might be a second relationship."
1368 " Is a separator missing before this part?"
1369 )
1370 else:
1371 msg = "Parse error of the relationship. Either a syntax error or a missing separator somewhere."
1372 lint_state.emit_diagnostic(
1373 problem_range_te,
1374 msg,
1375 "error",
1376 known_field.unknown_value_authority,
1377 )
1378 else:
1379 dep = _cleanup_rel(
1380 raw_value_masked_comments[error_start_offset:error_end_offset]
1381 )
1382 lint_state.emit_diagnostic(
1383 problem_range_te,
1384 f'Could not parse "{dep}" as a dependency relation.',
1385 "error",
1386 known_field.unknown_value_authority,
1387 )
1388 if (
1389 len(sub_relations) == 1
1390 and (relation := sub_relations[0]).name != "<BROKEN>"
1391 ):
1392 # We ignore OR-relations in the dup-check for now. We also skip relations with problems.
1393 relation_dup_table[relation.name].append(relation)
1395 for relations in relation_dup_table.values():
1396 if len(relations) > 1:
1397 dup_check_relations(
1398 known_field,
1399 relations,
1400 raw_value_masked_comments,
1401 value_element_pos,
1402 lint_state,
1403 )
1406def _rrr_build_driver_mismatch(
1407 _known_field: "F",
1408 _deb822_file: Deb822FileElement,
1409 _kvpair: Deb822KeyValuePairElement,
1410 kvpair_range_te: "TERange",
1411 _field_name_range: "TERange",
1412 stanza: Deb822ParagraphElement,
1413 _stanza_position: "TEPosition",
1414 lint_state: LintState,
1415) -> None:
1416 dr = stanza.get("Build-Driver", "debian-rules")
1417 if dr != "debian-rules":
1418 lint_state.emit_diagnostic(
1419 kvpair_range_te,
1420 f'The Rules-Requires-Root field is irrelevant for the Build-Driver "{dr}".',
1421 "informational",
1422 "debputy",
1423 quickfixes=[
1424 propose_remove_range_quick_fix(
1425 proposed_title="Remove Rules-Requires-Root"
1426 )
1427 ],
1428 )
1431class Dep5Matcher(BasenameGlobMatch):
1432 def __init__(self, basename_glob: str) -> None:
1433 super().__init__(
1434 basename_glob,
1435 only_when_in_directory=None,
1436 path_type=None,
1437 recursive_match=False,
1438 )
1441def _match_dep5_segment(
1442 current_dir: VirtualPathBase, basename_glob: str
1443) -> Iterable[VirtualPathBase]:
1444 if "*" in basename_glob or "?" in basename_glob:
1445 return Dep5Matcher(basename_glob).finditer(current_dir)
1446 else:
1447 res = current_dir.get(basename_glob)
1448 if res is None:
1449 return tuple()
1450 return (res,)
1453_RE_SLASHES = re.compile(r"//+")
1456def _dep5_unnecessary_symbols(
1457 value: str,
1458 value_range: TERange,
1459 lint_state: LintState,
1460) -> None:
1461 slash_check_index = 0
1462 if value.startswith(("./", "/")):
1463 prefix_len = 1 if value[0] == "/" else 2
1464 if value[prefix_len - 1 : prefix_len + 2].startswith("//"): 1464 ↛ 1468line 1464 didn't jump to line 1468 because the condition on line 1464 was always true
1465 _, slashes_end = _RE_SLASHES.search(value).span()
1466 prefix_len = slashes_end
1468 slash_check_index = prefix_len
1469 prefix_range = TERange(
1470 value_range.start_pos,
1471 TEPosition(
1472 value_range.start_pos.line_position,
1473 value_range.start_pos.cursor_position + prefix_len,
1474 ),
1475 )
1476 lint_state.emit_diagnostic(
1477 prefix_range,
1478 f'Unnecessary prefix "{value[0:prefix_len]}"',
1479 "warning",
1480 "debputy",
1481 quickfixes=[
1482 propose_remove_range_quick_fix(
1483 proposed_title=f'Delete "{value[0:prefix_len]}"'
1484 )
1485 ],
1486 )
1488 for m in _RE_SLASHES.finditer(value, slash_check_index):
1489 m_start, m_end = m.span(0)
1491 prefix_range = TERange(
1492 TEPosition(
1493 value_range.start_pos.line_position,
1494 value_range.start_pos.cursor_position + m_start,
1495 ),
1496 TEPosition(
1497 value_range.start_pos.line_position,
1498 value_range.start_pos.cursor_position + m_end,
1499 ),
1500 )
1501 lint_state.emit_diagnostic(
1502 prefix_range,
1503 'Simplify to a single "/"',
1504 "warning",
1505 "debputy",
1506 quickfixes=[propose_correct_text_quick_fix("/")],
1507 )
1510def _dep5_files_check(
1511 known_field: "F",
1512 _deb822_file: Deb822FileElement,
1513 kvpair: Deb822KeyValuePairElement,
1514 kvpair_range_te: "TERange",
1515 _field_name_range: "TERange",
1516 _stanza: Deb822ParagraphElement,
1517 _stanza_position: "TEPosition",
1518 lint_state: LintState,
1519) -> None:
1520 interpreter = known_field.field_value_class.interpreter()
1521 assert interpreter is not None
1522 full_value_range = kvpair.value_element.range_in_parent().relative_to(
1523 kvpair_range_te.start_pos
1524 )
1525 values_with_ranges = []
1526 for value_ref in kvpair.interpret_as(interpreter).iter_value_references():
1527 value_range = value_ref.locatable.range_in_parent().relative_to(
1528 full_value_range.start_pos
1529 )
1530 value = value_ref.value
1531 values_with_ranges.append((value_ref.value, value_range))
1532 _dep5_unnecessary_symbols(value, value_range, lint_state)
1534 source_root = lint_state.source_root
1535 if source_root is None:
1536 return
1537 i = 0
1538 limit = len(values_with_ranges)
1539 while i < limit:
1540 value, value_range = values_with_ranges[i]
1541 i += 1
1544_HOMEPAGE_CLUTTER_RE = re.compile(r"<(?:UR[LI]:)?(.*)>")
1545_URI_RE = re.compile(r"(?P<protocol>[a-z0-9]+)://(?P<host>[^\s/+]+)(?P<path>/[^\s?]*)?")
1546_KNOWN_HTTPS_HOSTS = frozenset(
1547 [
1548 "debian.org",
1549 "bioconductor.org",
1550 "cran.r-project.org",
1551 "github.com",
1552 "gitlab.com",
1553 "metacpan.org",
1554 "gnu.org",
1555 ]
1556)
1557_REPLACED_HOSTS = frozenset({"alioth.debian.org"})
1558_NO_DOT_GIT_HOMEPAGE_HOSTS = frozenset(
1559 {
1560 "salsa.debian.org",
1561 "github.com",
1562 "gitlab.com",
1563 }
1564)
1567def _is_known_host(host: str, known_hosts: Container[str]) -> bool:
1568 if host in known_hosts:
1569 return True
1570 while host: 1570 ↛ 1578line 1570 didn't jump to line 1578 because the condition on line 1570 was always true
1571 try:
1572 idx = host.index(".")
1573 host = host[idx + 1 :]
1574 except ValueError:
1575 break
1576 if host in known_hosts:
1577 return True
1578 return False
1581def _validate_homepage_field(
1582 _known_field: "F",
1583 _deb822_file: Deb822FileElement,
1584 kvpair: Deb822KeyValuePairElement,
1585 kvpair_range_te: "TERange",
1586 _field_name_range_te: "TERange",
1587 _stanza: Deb822ParagraphElement,
1588 _stanza_position: "TEPosition",
1589 lint_state: LintState,
1590) -> None:
1591 value = kvpair.value_element.convert_to_text()
1592 offset = 0
1593 homepage = value
1594 if "<" in value and (m := _HOMEPAGE_CLUTTER_RE.search(value)):
1595 expected_value = m.group(1)
1596 quickfixes = []
1597 if expected_value: 1597 ↛ 1601line 1597 didn't jump to line 1601 because the condition on line 1597 was always true
1598 homepage = expected_value.strip()
1599 offset = m.start(1)
1600 quickfixes.append(propose_correct_text_quick_fix(expected_value))
1601 lint_state.emit_diagnostic(
1602 _single_line_span_range_relative_to_pos(
1603 m.span(),
1604 kvpair.value_element.position_in_parent().relative_to(
1605 kvpair_range_te.start_pos
1606 ),
1607 ),
1608 "Superfluous URL/URI wrapping",
1609 "informational",
1610 "Policy 5.6.23",
1611 quickfixes=quickfixes,
1612 )
1613 # Note falling through here can cause "two rounds" for debputy lint --auto-fix
1614 m = _URI_RE.search(homepage)
1615 if not m: 1615 ↛ 1616line 1615 didn't jump to line 1616 because the condition on line 1615 was never true
1616 return
1617 # TODO relative to lintian: `bad-homepage` and most of the `fields/bad-homepages` hints.
1618 protocol = m.group("protocol")
1619 host = m.group("host")
1620 path = m.group("path") or ""
1621 if _is_known_host(host, _REPLACED_HOSTS):
1622 span = m.span("host")
1623 lint_state.emit_diagnostic(
1624 _single_line_span_range_relative_to_pos(
1625 (span[0] + offset, span[1] + offset),
1626 kvpair.value_element.position_in_parent().relative_to(
1627 kvpair_range_te.start_pos
1628 ),
1629 ),
1630 f'The server "{host}" is no longer in use.',
1631 "warning",
1632 "debputy",
1633 )
1634 return
1635 if (
1636 protocol == "ftp"
1637 or protocol == "http"
1638 and _is_known_host(host, _KNOWN_HTTPS_HOSTS)
1639 ):
1640 span = m.span("protocol")
1641 if protocol == "ftp" and not _is_known_host(host, _KNOWN_HTTPS_HOSTS): 1641 ↛ 1642line 1641 didn't jump to line 1642 because the condition on line 1641 was never true
1642 msg = "Insecure protocol for website (check if a https:// variant is available)"
1643 quickfixes = []
1644 else:
1645 msg = "Replace with https://. The host is known to support https"
1646 quickfixes = [propose_correct_text_quick_fix("https")]
1647 lint_state.emit_diagnostic(
1648 _single_line_span_range_relative_to_pos(
1649 (span[0] + offset, span[1] + offset),
1650 kvpair.value_element.position_in_parent().relative_to(
1651 kvpair_range_te.start_pos
1652 ),
1653 ),
1654 msg,
1655 "pedantic",
1656 "debputy",
1657 quickfixes=quickfixes,
1658 )
1659 if path.endswith(".git") and _is_known_host(host, _NO_DOT_GIT_HOMEPAGE_HOSTS):
1660 span = m.span("path")
1661 msg = "Unnecessary suffix"
1662 quickfixes = [propose_correct_text_quick_fix(path[:-4])]
1663 lint_state.emit_diagnostic(
1664 _single_line_span_range_relative_to_pos(
1665 (span[1] - 4 + offset, span[1] + offset),
1666 kvpair.value_element.position_in_parent().relative_to(
1667 kvpair_range_te.start_pos
1668 ),
1669 ),
1670 msg,
1671 "pedantic",
1672 "debputy",
1673 quickfixes=quickfixes,
1674 )
1677def _combined_custom_field_check(*checks: CustomFieldCheck) -> CustomFieldCheck:
1678 def _validator(
1679 known_field: "F",
1680 deb822_file: Deb822FileElement,
1681 kvpair: Deb822KeyValuePairElement,
1682 kvpair_range_te: "TERange",
1683 field_name_range_te: "TERange",
1684 stanza: Deb822ParagraphElement,
1685 stanza_position: "TEPosition",
1686 lint_state: LintState,
1687 ) -> None:
1688 for check in checks:
1689 check(
1690 known_field,
1691 deb822_file,
1692 kvpair,
1693 kvpair_range_te,
1694 field_name_range_te,
1695 stanza,
1696 stanza_position,
1697 lint_state,
1698 )
1700 return _validator
1703@dataclasses.dataclass(slots=True, frozen=True)
1704class PackageNameSectionRule:
1705 section: str
1706 check: Callable[[str], bool]
1709def _package_name_section_rule(
1710 section: str,
1711 check: Callable[[str], bool] | re.Pattern,
1712 *,
1713 confirm_re: re.Pattern | None = None,
1714) -> PackageNameSectionRule:
1715 if confirm_re is not None:
1716 assert callable(check)
1718 def _impl(v: str) -> bool:
1719 return check(v) and confirm_re.search(v)
1721 elif isinstance(check, re.Pattern): 1721 ↛ 1723line 1721 didn't jump to line 1723 because the condition on line 1721 was never true
1723 def _impl(v: str) -> bool:
1724 return check.search(v) is not None
1726 else:
1727 _impl = check
1729 return PackageNameSectionRule(section, _impl)
1732# rules: order is important (first match wins in case of a conflict)
1733_PKGNAME_VS_SECTION_RULES = [
1734 _package_name_section_rule("debian-installer", lambda n: n.endswith("-udeb")),
1735 _package_name_section_rule("doc", lambda n: n.endswith(("-doc", "-docs"))),
1736 _package_name_section_rule("debug", lambda n: n.endswith(("-dbg", "-dbgsym"))),
1737 _package_name_section_rule(
1738 "httpd",
1739 lambda n: n.startswith(("lighttpd-mod", "libapache2-mod-", "libnginx-mod-")),
1740 ),
1741 _package_name_section_rule("gnustep", lambda n: n.startswith("gnustep-")),
1742 _package_name_section_rule(
1743 "gnustep",
1744 lambda n: n.endswith(
1745 (
1746 ".framework",
1747 ".framework-common",
1748 ".tool",
1749 ".tool-common",
1750 ".app",
1751 ".app-common",
1752 )
1753 ),
1754 ),
1755 _package_name_section_rule("embedded", lambda n: n.startswith("moblin-")),
1756 _package_name_section_rule("javascript", lambda n: n.startswith("node-")),
1757 _package_name_section_rule(
1758 "zope",
1759 lambda n: n.startswith(("python-zope", "python3-zope", "zope")),
1760 ),
1761 _package_name_section_rule(
1762 "python",
1763 lambda n: n.startswith(("python-", "python3-")),
1764 ),
1765 _package_name_section_rule(
1766 "gnu-r",
1767 lambda n: n.startswith(("r-cran-", "r-bioc-", "r-other-")),
1768 ),
1769 _package_name_section_rule("editors", lambda n: n.startswith("elpa-")),
1770 _package_name_section_rule("lisp", lambda n: n.startswith("cl-")),
1771 _package_name_section_rule(
1772 "lisp",
1773 lambda n: "-elisp-" in n or n.endswith("-elisp"),
1774 ),
1775 _package_name_section_rule(
1776 "lisp",
1777 lambda n: n.startswith("lib") and n.endswith("-guile"),
1778 ),
1779 _package_name_section_rule("lisp", lambda n: n.startswith("guile-")),
1780 _package_name_section_rule("golang", lambda n: n.startswith("golang-")),
1781 _package_name_section_rule(
1782 "perl",
1783 lambda n: n.startswith("lib") and n.endswith("-perl"),
1784 ),
1785 _package_name_section_rule(
1786 "cli-mono",
1787 lambda n: n.startswith("lib") and n.endswith(("-cil", "-cil-dev")),
1788 ),
1789 _package_name_section_rule(
1790 "java",
1791 lambda n: n.startswith("lib") and n.endswith(("-java", "-gcj", "-jni")),
1792 ),
1793 _package_name_section_rule(
1794 "php",
1795 lambda n: n.startswith(("libphp", "php")),
1796 confirm_re=re.compile(r"^(?:lib)?php(?:\d(?:\.\d)?)?-"),
1797 ),
1798 _package_name_section_rule(
1799 "php", lambda n: n.startswith("lib-") and n.endswith("-php")
1800 ),
1801 _package_name_section_rule(
1802 "haskell",
1803 lambda n: n.startswith(("haskell-", "libhugs-", "libghc-", "libghc6-")),
1804 ),
1805 _package_name_section_rule(
1806 "ruby",
1807 lambda n: "-ruby" in n,
1808 confirm_re=re.compile(r"^lib.*-ruby(?:1\.\d)?$"),
1809 ),
1810 _package_name_section_rule("ruby", lambda n: n.startswith("ruby-")),
1811 _package_name_section_rule(
1812 "rust",
1813 lambda n: n.startswith("librust-") and n.endswith("-dev"),
1814 ),
1815 _package_name_section_rule("rust", lambda n: n.startswith("rust-")),
1816 _package_name_section_rule(
1817 "ocaml",
1818 lambda n: n.startswith("lib-") and n.endswith(("-ocaml-dev", "-camlp4-dev")),
1819 ),
1820 _package_name_section_rule("javascript", lambda n: n.startswith("libjs-")),
1821 _package_name_section_rule(
1822 "interpreters",
1823 lambda n: n.startswith("lib-") and n.endswith(("-tcl", "-lua", "-gst")),
1824 ),
1825 _package_name_section_rule(
1826 "introspection",
1827 lambda n: n.startswith("gir-"),
1828 confirm_re=re.compile(r"^gir\d+\.\d+-.*-\d+\.\d+$"),
1829 ),
1830 _package_name_section_rule(
1831 "fonts",
1832 lambda n: n.startswith(("xfonts-", "fonts-", "ttf-")),
1833 ),
1834 _package_name_section_rule("admin", lambda n: n.startswith(("libnss-", "libpam-"))),
1835 _package_name_section_rule(
1836 "localization",
1837 lambda n: n.startswith(
1838 (
1839 "aspell-",
1840 "hunspell-",
1841 "myspell-",
1842 "mythes-",
1843 "dict-freedict-",
1844 "gcompris-sound-",
1845 )
1846 ),
1847 ),
1848 _package_name_section_rule(
1849 "localization",
1850 lambda n: n.startswith("hyphen-"),
1851 confirm_re=re.compile(r"^hyphen-[a-z]{2}(?:-[a-z]{2})?$"),
1852 ),
1853 _package_name_section_rule(
1854 "localization",
1855 lambda n: "-l10n-" in n or n.endswith("-l10n"),
1856 ),
1857 _package_name_section_rule("kernel", lambda n: n.endswith(("-dkms", "-firmware"))),
1858 _package_name_section_rule(
1859 "libdevel",
1860 lambda n: n.startswith("lib") and n.endswith(("-dev", "-headers")),
1861 ),
1862 _package_name_section_rule(
1863 "libs",
1864 lambda n: n.startswith("lib"),
1865 confirm_re=re.compile(r"^lib.*\d[ad]?$"),
1866 ),
1867]
1870# Fiddling with the package name can cause a lot of changes (diagnostic scans), so we have an upper bound
1871# on the cache. The number is currently just taken out of a hat.
1872@functools.lru_cache(64)
1873def package_name_to_section(name: str) -> str | None:
1874 for rule in _PKGNAME_VS_SECTION_RULES:
1875 if rule.check(name):
1876 return rule.section
1877 return None
1880def _unknown_value_check(
1881 field_name: str,
1882 value: str,
1883 known_values: Mapping[str, Keyword],
1884 unknown_value_severity: LintSeverity | None,
1885) -> tuple[Keyword | None, str | None, LintSeverity | None, Any | None]:
1886 known_value = known_values.get(value)
1887 message = None
1888 severity = unknown_value_severity
1889 fix_data = None
1890 if known_value is None:
1891 candidates = detect_possible_typo(
1892 value,
1893 known_values,
1894 )
1895 if len(known_values) < 5: 1895 ↛ 1896line 1895 didn't jump to line 1896 because the condition on line 1895 was never true
1896 values = ", ".join(sorted(known_values))
1897 hint_text = f" Known values for this field: {values}"
1898 else:
1899 hint_text = ""
1900 fix_data = None
1901 severity = unknown_value_severity
1902 fix_text = hint_text
1903 if candidates:
1904 match = candidates[0]
1905 if len(candidates) == 1: 1905 ↛ 1907line 1905 didn't jump to line 1907 because the condition on line 1905 was always true
1906 known_value = known_values[match]
1907 fix_text = (
1908 f' It is possible that the value is a typo of "{match}".{fix_text}'
1909 )
1910 fix_data = [propose_correct_text_quick_fix(m) for m in candidates]
1911 elif severity is None: 1911 ↛ 1912line 1911 didn't jump to line 1912 because the condition on line 1911 was never true
1912 return None, None, None, None
1913 if severity is None:
1914 severity = cast("LintSeverity", "warning")
1915 # It always has leading whitespace
1916 message = fix_text.strip()
1917 else:
1918 message = f'The value "{value}" is not supported in {field_name}.{fix_text}'
1919 return known_value, message, severity, fix_data
1922def _dep5_escape_path(path: str) -> str:
1923 return path.replace(" ", "?")
1926def _noop_escape_path(path: str) -> str:
1927 return path
1930def _should_ignore_dir(
1931 path: VirtualPath,
1932 *,
1933 supports_dir_match: bool = False,
1934 match_non_persistent_paths: bool = False,
1935) -> bool:
1936 if not supports_dir_match and not any(path.iterdir()):
1937 return True
1938 cachedir_tag = path.get("CACHEDIR.TAG")
1939 if (
1940 not match_non_persistent_paths
1941 and cachedir_tag is not None
1942 and cachedir_tag.is_file
1943 ):
1944 # https://bford.info/cachedir/
1945 with cachedir_tag.open(byte_io=True, buffering=64) as fd:
1946 start = fd.read(43)
1947 if start == b"Signature: 8a477f597d28d172789f06886806bc55":
1948 return True
1949 return False
1952@dataclasses.dataclass(slots=True)
1953class Deb822KnownField:
1954 name: str
1955 field_value_class: FieldValueClass
1956 warn_if_default: bool = True
1957 unknown_value_authority: str = "debputy"
1958 missing_field_authority: str = "debputy"
1959 replaced_by: str | None = None
1960 deprecated_with_no_replacement: bool = False
1961 missing_field_severity: LintSeverity | None = None
1962 default_value: str | None = None
1963 known_values: Mapping[str, Keyword] | None = None
1964 unknown_value_severity: LintSeverity | None = "error"
1965 translation_context: str = ""
1966 # One-line description for space-constrained docs (such as completion docs)
1967 synopsis: str | None = None
1968 usage_hint: UsageHint | None = None
1969 long_description: str | None = None
1970 spellcheck_value: bool = False
1971 inheritable_from_other_stanza: bool = False
1972 show_as_inherited: bool = True
1973 custom_field_check: CustomFieldCheck | None = None
1974 can_complete_field_in_stanza: None | (
1975 Callable[[Iterable[Deb822ParagraphElement]], bool]
1976 ) = None
1977 is_substvars_disabled_even_if_allowed_by_stanza: bool = False
1978 is_alias_of: str | None = None
1979 is_completion_suggestion: bool = True
1981 def synopsis_translated(
1982 self, translation_provider: Union["DebputyLanguageServer", "LintState"]
1983 ) -> str | None:
1984 if self.synopsis is None:
1985 return None
1986 return translation_provider.translation(LSP_DATA_DOMAIN).pgettext(
1987 self.translation_context,
1988 self.synopsis,
1989 )
1991 def long_description_translated(
1992 self, translation_provider: Union["DebputyLanguageServer", "LintState"]
1993 ) -> str | None:
1994 if self.long_description_translated is None: 1994 ↛ 1995line 1994 didn't jump to line 1995 because the condition on line 1994 was never true
1995 return None
1996 return translation_provider.translation(LSP_DATA_DOMAIN).pgettext(
1997 self.translation_context,
1998 self.long_description,
1999 )
2001 def _can_complete_field_in_stanza(
2002 self,
2003 stanza_parts: Sequence[Deb822ParagraphElement],
2004 ) -> bool:
2005 if not self.is_completion_suggestion: 2005 ↛ 2006line 2005 didn't jump to line 2006 because the condition on line 2005 was never true
2006 return False
2007 return (
2008 self.can_complete_field_in_stanza is None
2009 or self.can_complete_field_in_stanza(stanza_parts)
2010 )
2012 def complete_field(
2013 self,
2014 lint_state: LintState,
2015 stanza_parts: Sequence[Deb822ParagraphElement],
2016 markdown_kind: MarkupKind,
2017 ) -> CompletionItem | None:
2018 if not self._can_complete_field_in_stanza(stanza_parts):
2019 return None
2020 name = self.name
2021 complete_as = name + ": "
2022 options = self.value_options_for_completer(
2023 lint_state,
2024 stanza_parts,
2025 "",
2026 markdown_kind,
2027 is_completion_for_field=True,
2028 )
2029 if options is not None and len(options) == 1:
2030 value = options[0].insert_text
2031 if value is not None: 2031 ↛ 2033line 2031 didn't jump to line 2033 because the condition on line 2031 was always true
2032 complete_as += value
2033 tags = []
2034 is_deprecated = False
2035 if self.replaced_by or self.deprecated_with_no_replacement:
2036 is_deprecated = True
2037 tags.append(CompletionItemTag.Deprecated)
2039 doc = self.long_description
2040 if doc:
2041 doc = MarkupContent(
2042 value=doc,
2043 kind=markdown_kind,
2044 )
2045 else:
2046 doc = None
2048 return CompletionItem(
2049 name,
2050 insert_text=complete_as,
2051 deprecated=is_deprecated,
2052 tags=tags,
2053 detail=format_comp_item_synopsis_doc(
2054 self.usage_hint,
2055 self.synopsis_translated(lint_state),
2056 is_deprecated,
2057 ),
2058 documentation=doc,
2059 )
2061 def _complete_files(
2062 self,
2063 base_dir: VirtualPathBase | None,
2064 value_being_completed: str,
2065 *,
2066 is_dep5_file_list: bool = False,
2067 supports_dir_match: bool = False,
2068 supports_spaces_in_filename: bool = False,
2069 match_non_persistent_paths: bool = False,
2070 ) -> Sequence[CompletionItem] | None:
2071 _info(f"_complete_files: {base_dir.fs_path} - {value_being_completed!r}")
2072 if base_dir is None or not base_dir.is_dir:
2073 return None
2075 if is_dep5_file_list:
2076 supports_spaces_in_filename = True
2077 supports_dir_match = False
2078 match_non_persistent_paths = False
2080 if value_being_completed == "":
2081 current_dir = base_dir
2082 unmatched_parts: Sequence[str] = ()
2083 else:
2084 current_dir, unmatched_parts = base_dir.attempt_lookup(
2085 value_being_completed
2086 )
2088 if len(unmatched_parts) > 1:
2089 # Unknown directory part / glob, and we currently do not deal with that.
2090 return None
2091 if len(unmatched_parts) == 1 and unmatched_parts[0] == "*":
2092 # Avoid convincing the client to remove the star (seen with emacs)
2093 return None
2094 items = []
2096 path_escaper = _dep5_escape_path if is_dep5_file_list else _noop_escape_path
2098 for child in current_dir.iterdir():
2099 if child.is_symlink and is_dep5_file_list:
2100 continue
2101 if not supports_spaces_in_filename and (
2102 " " in child.name or "\t" in child.name
2103 ):
2104 continue
2105 sort_text = (
2106 f"z-{child.name}" if child.name.startswith(".") else f"a-{child.name}"
2107 )
2108 if child.is_dir:
2109 if _should_ignore_dir(
2110 child,
2111 supports_dir_match=supports_dir_match,
2112 match_non_persistent_paths=match_non_persistent_paths,
2113 ):
2114 continue
2115 items.append(
2116 CompletionItem(
2117 f"{child.path}/",
2118 label_details=CompletionItemLabelDetails(
2119 description=child.path,
2120 ),
2121 insert_text=path_escaper(f"{child.path}/"),
2122 filter_text=f"{child.path}/",
2123 sort_text=sort_text,
2124 kind=CompletionItemKind.Folder,
2125 )
2126 )
2127 else:
2128 items.append(
2129 CompletionItem(
2130 child.path,
2131 label_details=CompletionItemLabelDetails(
2132 description=child.path,
2133 ),
2134 insert_text=path_escaper(child.path),
2135 filter_text=child.path,
2136 sort_text=sort_text,
2137 kind=CompletionItemKind.File,
2138 )
2139 )
2140 return items
2142 def value_options_for_completer(
2143 self,
2144 lint_state: LintState,
2145 stanza_parts: Sequence[Deb822ParagraphElement],
2146 value_being_completed: str,
2147 markdown_kind: MarkupKind,
2148 *,
2149 is_completion_for_field: bool = False,
2150 ) -> Sequence[CompletionItem] | None:
2151 known_values = self.known_values
2152 if self.field_value_class == FieldValueClass.DEP5_FILE_LIST: 2152 ↛ 2153line 2152 didn't jump to line 2153 because the condition on line 2152 was never true
2153 if is_completion_for_field:
2154 return None
2155 return self._complete_files(
2156 lint_state.source_root,
2157 value_being_completed,
2158 is_dep5_file_list=True,
2159 )
2161 if known_values is None:
2162 return None
2163 if is_completion_for_field and (
2164 len(known_values) == 1
2165 or (
2166 len(known_values) == 2
2167 and self.warn_if_default
2168 and self.default_value is not None
2169 )
2170 ):
2171 value = next(
2172 iter(v for v in self.known_values if v != self.default_value),
2173 None,
2174 )
2175 if value is None: 2175 ↛ 2176line 2175 didn't jump to line 2176 because the condition on line 2175 was never true
2176 return None
2177 return [CompletionItem(value, insert_text=value)]
2178 return [
2179 keyword.as_completion_item(
2180 lint_state,
2181 stanza_parts,
2182 value_being_completed,
2183 markdown_kind,
2184 )
2185 for keyword in known_values.values()
2186 if keyword.is_keyword_valid_completion_in_stanza(stanza_parts)
2187 and keyword.is_completion_suggestion
2188 ]
2190 def field_omitted_diagnostics(
2191 self,
2192 deb822_file: Deb822FileElement,
2193 representation_field_range: "TERange",
2194 stanza: Deb822ParagraphElement,
2195 stanza_position: "TEPosition",
2196 header_stanza: Deb822FileElement | None,
2197 lint_state: LintState,
2198 ) -> None:
2199 missing_field_severity = self.missing_field_severity
2200 if missing_field_severity is None: 2200 ↛ 2203line 2200 didn't jump to line 2203 because the condition on line 2200 was always true
2201 return
2203 if (
2204 self.inheritable_from_other_stanza
2205 and header_stanza is not None
2206 and self.name in header_stanza
2207 ):
2208 return
2210 lint_state.emit_diagnostic(
2211 representation_field_range,
2212 f"Stanza is missing field {self.name}",
2213 missing_field_severity,
2214 self.missing_field_authority,
2215 )
2217 async def field_diagnostics(
2218 self,
2219 deb822_file: Deb822FileElement,
2220 kvpair: Deb822KeyValuePairElement,
2221 stanza: Deb822ParagraphElement,
2222 stanza_position: "TEPosition",
2223 kvpair_range_te: "TERange",
2224 lint_state: LintState,
2225 *,
2226 field_name_typo_reported: bool = False,
2227 ) -> None:
2228 field_name_token = kvpair.field_token
2229 field_name_range_te = kvpair.field_token.range_in_parent().relative_to(
2230 kvpair_range_te.start_pos
2231 )
2232 field_name = field_name_token.text
2233 # The `self.name` attribute is the canonical name whereas `field_name` is the name used.
2234 # This distinction is important for `d/control` where `X[CBS]-` prefixes might be used
2235 # in one but not the other.
2236 field_value = stanza[field_name]
2237 self._diagnostics_for_field_name(
2238 kvpair_range_te,
2239 field_name_token,
2240 field_name_range_te,
2241 field_name_typo_reported,
2242 lint_state,
2243 )
2244 if self.custom_field_check is not None:
2245 self.custom_field_check(
2246 self,
2247 deb822_file,
2248 kvpair,
2249 kvpair_range_te,
2250 field_name_range_te,
2251 stanza,
2252 stanza_position,
2253 lint_state,
2254 )
2255 self._dep5_file_list_diagnostics(kvpair, kvpair_range_te.start_pos, lint_state)
2256 if self.spellcheck_value:
2257 words = kvpair.interpret_as(LIST_SPACE_SEPARATED_INTERPRETATION)
2258 spell_checker = lint_state.spellchecker()
2259 value_position = kvpair.value_element.position_in_parent().relative_to(
2260 kvpair_range_te.start_pos
2261 )
2262 async for word_ref in lint_state.slow_iter(
2263 words.iter_value_references(), yield_every=25
2264 ):
2265 token = word_ref.value
2266 for word, pos, endpos in spell_checker.iter_words(token):
2267 corrections = spell_checker.provide_corrections_for(word)
2268 if not corrections:
2269 continue
2270 word_loc = word_ref.locatable
2271 word_pos_te = word_loc.position_in_parent().relative_to(
2272 value_position
2273 )
2274 if pos: 2274 ↛ 2275line 2274 didn't jump to line 2275 because the condition on line 2274 was never true
2275 word_pos_te = TEPosition(0, pos).relative_to(word_pos_te)
2276 word_size = TERange(
2277 START_POSITION,
2278 TEPosition(0, endpos - pos),
2279 )
2280 lint_state.emit_diagnostic(
2281 TERange.from_position_and_size(word_pos_te, word_size),
2282 f'Spelling "{word}"',
2283 "spelling",
2284 "debputy",
2285 quickfixes=[
2286 propose_correct_text_quick_fix(c) for c in corrections
2287 ],
2288 enable_non_interactive_auto_fix=False,
2289 )
2290 else:
2291 self._known_value_diagnostics(
2292 kvpair,
2293 kvpair_range_te.start_pos,
2294 lint_state,
2295 )
2297 if self.warn_if_default and field_value == self.default_value: 2297 ↛ 2298line 2297 didn't jump to line 2298 because the condition on line 2297 was never true
2298 lint_state.emit_diagnostic(
2299 kvpair_range_te,
2300 f'The field "{field_name}" is redundant as it is set to the default value and the field'
2301 " should only be used in exceptional cases.",
2302 "warning",
2303 "debputy",
2304 )
2306 def _diagnostics_for_field_name(
2307 self,
2308 kvpair_range: "TERange",
2309 token: Deb822FieldNameToken,
2310 token_range: "TERange",
2311 typo_detected: bool,
2312 lint_state: LintState,
2313 ) -> None:
2314 field_name = token.text
2315 # Defeat the case-insensitivity from python-debian
2316 field_name_cased = str(field_name)
2317 if self.deprecated_with_no_replacement:
2318 lint_state.emit_diagnostic(
2319 kvpair_range,
2320 f'"{field_name_cased}" is deprecated and no longer used',
2321 "warning",
2322 "debputy",
2323 quickfixes=[propose_remove_range_quick_fix()],
2324 tags=[DiagnosticTag.Deprecated],
2325 )
2326 elif self.replaced_by is not None:
2327 lint_state.emit_diagnostic(
2328 token_range,
2329 f'"{field_name_cased}" has been replaced by "{self.replaced_by}"',
2330 "warning",
2331 "debputy",
2332 tags=[DiagnosticTag.Deprecated],
2333 quickfixes=[propose_correct_text_quick_fix(self.replaced_by)],
2334 )
2336 if not typo_detected and field_name_cased != self.name:
2337 lint_state.emit_diagnostic(
2338 token_range,
2339 f'Non-canonical spelling of "{self.name}"',
2340 "pedantic",
2341 self.unknown_value_authority,
2342 quickfixes=[propose_correct_text_quick_fix(self.name)],
2343 )
2345 def _dep5_file_list_diagnostics(
2346 self,
2347 kvpair: Deb822KeyValuePairElement,
2348 kvpair_position: "TEPosition",
2349 lint_state: LintState,
2350 ) -> None:
2351 source_root = lint_state.source_root
2352 if (
2353 self.field_value_class != FieldValueClass.DEP5_FILE_LIST
2354 or source_root is None
2355 ):
2356 return
2357 interpreter = self.field_value_class.interpreter()
2358 values = kvpair.interpret_as(interpreter)
2359 value_off = kvpair.value_element.position_in_parent().relative_to(
2360 kvpair_position
2361 )
2363 assert interpreter is not None
2365 for token in values.iter_parts():
2366 if token.is_whitespace:
2367 continue
2368 text = token.convert_to_text()
2369 if "?" in text or "*" in text: 2369 ↛ 2371line 2369 didn't jump to line 2371 because the condition on line 2369 was never true
2370 # TODO: We should validate these as well
2371 continue
2372 matched_path, missing_part = source_root.attempt_lookup(text)
2373 # It is common practice to delete "dirty" files during clean. This causes files listed
2374 # in `debian/copyright` to go missing and as a consequence, we do not validate whether
2375 # they are present (that would require us to check the `.orig.tar`, which we could but
2376 # do not have the infrastructure for).
2377 if not missing_part and matched_path.is_dir and self.name == "Files":
2378 path_range_te = token.range_in_parent().relative_to(value_off)
2379 lint_state.emit_diagnostic(
2380 path_range_te,
2381 "Directories cannot be a match. Use `dir/*` to match everything in it",
2382 "warning",
2383 self.unknown_value_authority,
2384 quickfixes=[
2385 propose_correct_text_quick_fix(f"{matched_path.path}/*")
2386 ],
2387 )
2389 def _known_value_diagnostics(
2390 self,
2391 kvpair: Deb822KeyValuePairElement,
2392 kvpair_position: "TEPosition",
2393 lint_state: LintState,
2394 ) -> None:
2395 unknown_value_severity = self.unknown_value_severity
2396 interpreter = self.field_value_class.interpreter()
2397 if interpreter is None:
2398 return
2399 try:
2400 values = kvpair.interpret_as(interpreter)
2401 except ValueError:
2402 value_range = kvpair.value_element.range_in_parent().relative_to(
2403 kvpair_position
2404 )
2405 lint_state.emit_diagnostic(
2406 value_range,
2407 "Error while parsing field (diagnostics related to this field may be incomplete)",
2408 "pedantic",
2409 "debputy",
2410 )
2411 return
2412 value_off = kvpair.value_element.position_in_parent().relative_to(
2413 kvpair_position
2414 )
2416 last_token_non_ws_sep_token: TE | None = None
2417 for token in values.iter_parts():
2418 if token.is_whitespace:
2419 continue
2420 if not token.is_separator:
2421 last_token_non_ws_sep_token = None
2422 continue
2423 if last_token_non_ws_sep_token is not None:
2424 sep_range_te = token.range_in_parent().relative_to(value_off)
2425 lint_state.emit_diagnostic(
2426 sep_range_te,
2427 "Duplicate separator",
2428 "error",
2429 self.unknown_value_authority,
2430 )
2431 last_token_non_ws_sep_token = token
2433 allowed_values = self.known_values
2434 if not allowed_values:
2435 return
2437 first_value = None
2438 first_exclusive_value_ref = None
2439 first_exclusive_value = None
2440 has_emitted_for_exclusive = False
2442 for value_ref in values.iter_value_references():
2443 value = value_ref.value
2444 if ( 2444 ↛ 2448line 2444 didn't jump to line 2448 because the condition on line 2444 was never true
2445 first_value is not None
2446 and self.field_value_class == FieldValueClass.SINGLE_VALUE
2447 ):
2448 value_loc = value_ref.locatable
2449 range_position_te = value_loc.range_in_parent().relative_to(value_off)
2450 lint_state.emit_diagnostic(
2451 range_position_te,
2452 f"The field {self.name} can only have exactly one value.",
2453 "error",
2454 self.unknown_value_authority,
2455 )
2456 # TODO: Add quickfix if the value is also invalid
2457 continue
2459 if first_exclusive_value_ref is not None and not has_emitted_for_exclusive:
2460 assert first_exclusive_value is not None
2461 value_loc = first_exclusive_value_ref.locatable
2462 value_range_te = value_loc.range_in_parent().relative_to(value_off)
2463 lint_state.emit_diagnostic(
2464 value_range_te,
2465 f'The value "{first_exclusive_value}" cannot be used with other values.',
2466 "error",
2467 self.unknown_value_authority,
2468 )
2470 known_value, unknown_value_message, unknown_severity, typo_fix_data = (
2471 _unknown_value_check(
2472 self.name,
2473 value,
2474 self.known_values,
2475 unknown_value_severity,
2476 )
2477 )
2478 value_loc = value_ref.locatable
2479 value_range = value_loc.range_in_parent().relative_to(value_off)
2481 if known_value and known_value.is_exclusive:
2482 first_exclusive_value = known_value.value # In case of typos.
2483 first_exclusive_value_ref = value_ref
2484 if first_value is not None:
2485 has_emitted_for_exclusive = True
2486 lint_state.emit_diagnostic(
2487 value_range,
2488 f'The value "{known_value.value}" cannot be used with other values.',
2489 "error",
2490 self.unknown_value_authority,
2491 )
2493 if first_value is None:
2494 first_value = value
2496 if unknown_value_message is not None:
2497 assert unknown_severity is not None
2498 lint_state.emit_diagnostic(
2499 value_range,
2500 unknown_value_message,
2501 unknown_severity,
2502 self.unknown_value_authority,
2503 quickfixes=typo_fix_data,
2504 )
2506 if known_value is not None and known_value.is_deprecated:
2507 replacement = known_value.replaced_by
2508 if replacement is not None: 2508 ↛ 2514line 2508 didn't jump to line 2514 because the condition on line 2508 was always true
2509 obsolete_value_message = (
2510 f'The value "{value}" has been replaced by "{replacement}"'
2511 )
2512 obsolete_fix_data = [propose_correct_text_quick_fix(replacement)]
2513 else:
2514 obsolete_value_message = (
2515 f'The value "{value}" is obsolete without a single replacement'
2516 )
2517 obsolete_fix_data = None
2518 lint_state.emit_diagnostic(
2519 value_range,
2520 obsolete_value_message,
2521 "warning",
2522 "debputy",
2523 quickfixes=obsolete_fix_data,
2524 )
2526 def _reformat_field_name(
2527 self,
2528 effective_preference: "EffectiveFormattingPreference",
2529 stanza_range: TERange,
2530 kvpair: Deb822KeyValuePairElement,
2531 position_codec: LintCapablePositionCodec,
2532 lines: list[str],
2533 ) -> Iterable[TextEdit]:
2534 if not effective_preference.deb822_auto_canonical_size_field_names: 2534 ↛ 2535line 2534 didn't jump to line 2535 because the condition on line 2534 was never true
2535 return
2536 # The `str(kvpair.field_name)` is to avoid the "magic" from `python3-debian`'s Deb822 keys.
2537 if str(kvpair.field_name) == self.name:
2538 return
2540 field_name_range_te = kvpair.field_token.range_in_parent().relative_to(
2541 kvpair.range_in_parent().relative_to(stanza_range.start_pos).start_pos
2542 )
2544 edit_range = position_codec.range_to_client_units(
2545 lines,
2546 Range(
2547 Position(
2548 field_name_range_te.start_pos.line_position,
2549 field_name_range_te.start_pos.cursor_position,
2550 ),
2551 Position(
2552 field_name_range_te.start_pos.line_position,
2553 field_name_range_te.end_pos.cursor_position,
2554 ),
2555 ),
2556 )
2557 yield TextEdit(
2558 edit_range,
2559 self.name,
2560 )
2562 def reformat_field(
2563 self,
2564 effective_preference: "EffectiveFormattingPreference",
2565 stanza_range: TERange,
2566 kvpair: Deb822KeyValuePairElement,
2567 formatter: FormatterCallback,
2568 position_codec: LintCapablePositionCodec,
2569 lines: list[str],
2570 ) -> Iterable[TextEdit]:
2571 kvpair_range = kvpair.range_in_parent().relative_to(stanza_range.start_pos)
2572 yield from self._reformat_field_name(
2573 effective_preference,
2574 stanza_range,
2575 kvpair,
2576 position_codec,
2577 lines,
2578 )
2579 return trim_end_of_line_whitespace(
2580 position_codec,
2581 lines,
2582 line_range=range(
2583 kvpair_range.start_pos.line_position,
2584 kvpair_range.end_pos.line_position,
2585 ),
2586 )
2588 def replace(self, **changes: Any) -> "Self":
2589 return dataclasses.replace(self, **changes)
2592@dataclasses.dataclass(slots=True)
2593class DctrlLikeKnownField(Deb822KnownField):
2595 def reformat_field(
2596 self,
2597 effective_preference: "EffectiveFormattingPreference",
2598 stanza_range: TERange,
2599 kvpair: Deb822KeyValuePairElement,
2600 formatter: FormatterCallback,
2601 position_codec: LintCapablePositionCodec,
2602 lines: list[str],
2603 ) -> Iterable[TextEdit]:
2604 interpretation = self.field_value_class.interpreter()
2605 if ( 2605 ↛ 2609line 2605 didn't jump to line 2609 because the condition on line 2605 was never true
2606 not effective_preference.deb822_normalize_field_content
2607 or interpretation is None
2608 ):
2609 yield from super(DctrlLikeKnownField, self).reformat_field(
2610 effective_preference,
2611 stanza_range,
2612 kvpair,
2613 formatter,
2614 position_codec,
2615 lines,
2616 )
2617 return
2618 if not self.reformattable_field:
2619 yield from super(DctrlLikeKnownField, self).reformat_field(
2620 effective_preference,
2621 stanza_range,
2622 kvpair,
2623 formatter,
2624 position_codec,
2625 lines,
2626 )
2627 return
2629 # Preserve the name fixes from the super call.
2630 yield from self._reformat_field_name(
2631 effective_preference,
2632 stanza_range,
2633 kvpair,
2634 position_codec,
2635 lines,
2636 )
2638 seen: set[str] = set()
2639 old_kvpair_range = kvpair.range_in_parent()
2640 sort = self.is_sortable_field
2642 # Avoid the context manager as we do not want to perform the change (it would contaminate future ranges)
2643 field_content = kvpair.interpret_as(interpretation)
2644 old_value = field_content.convert_to_text(with_field_name=False)
2645 for package_ref in field_content.iter_value_references():
2646 value = package_ref.value
2647 value_range = package_ref.locatable.range_in_parent().relative_to(
2648 stanza_range.start_pos
2649 )
2650 sublines = lines[
2651 value_range.start_pos.line_position : value_range.end_pos.line_position
2652 ]
2654 # debputy#112: Avoid truncating "inline comments"
2655 if any(line.startswith("#") for line in sublines): 2655 ↛ 2656line 2655 didn't jump to line 2656 because the condition on line 2655 was never true
2656 return
2657 if self.is_relationship_field: 2657 ↛ 2660line 2657 didn't jump to line 2660 because the condition on line 2657 was always true
2658 new_value = " | ".join(x.strip() for x in value.split("|"))
2659 else:
2660 new_value = value
2661 if not sort or new_value not in seen: 2661 ↛ 2666line 2661 didn't jump to line 2666 because the condition on line 2661 was always true
2662 if new_value != value: 2662 ↛ 2663line 2662 didn't jump to line 2663 because the condition on line 2662 was never true
2663 package_ref.value = new_value
2664 seen.add(new_value)
2665 else:
2666 package_ref.remove()
2667 if sort: 2667 ↛ 2669line 2667 didn't jump to line 2669 because the condition on line 2667 was always true
2668 field_content.sort(key=_sort_packages_key)
2669 field_content.value_formatter(formatter)
2670 field_content.reformat_when_finished()
2672 new_value = field_content.convert_to_text(with_field_name=False)
2673 if new_value != old_value:
2674 value_range = kvpair.value_element.range_in_parent().relative_to(
2675 old_kvpair_range.start_pos
2676 )
2677 range_server_units = te_range_to_lsp(
2678 value_range.relative_to(stanza_range.start_pos)
2679 )
2680 yield TextEdit(
2681 position_codec.range_to_client_units(lines, range_server_units),
2682 new_value,
2683 )
2685 @property
2686 def reformattable_field(self) -> bool:
2687 return self.is_relationship_field or self.is_sortable_field
2689 @property
2690 def is_relationship_field(self) -> bool:
2691 return False
2693 @property
2694 def is_sortable_field(self) -> bool:
2695 return self.is_relationship_field
2698@dataclasses.dataclass(slots=True)
2699class DTestsCtrlKnownField(DctrlLikeKnownField):
2700 @property
2701 def is_relationship_field(self) -> bool:
2702 return self.name == "Depends"
2704 @property
2705 def is_sortable_field(self) -> bool:
2706 return self.is_relationship_field or self.name in (
2707 "Features",
2708 "Restrictions",
2709 "Tests",
2710 )
2713@dataclasses.dataclass(slots=True)
2714class DctrlKnownField(DctrlLikeKnownField):
2716 def field_omitted_diagnostics(
2717 self,
2718 deb822_file: Deb822FileElement,
2719 representation_field_range: "TERange",
2720 stanza: Deb822ParagraphElement,
2721 stanza_position: "TEPosition",
2722 header_stanza: Deb822FileElement | None,
2723 lint_state: LintState,
2724 ) -> None:
2725 missing_field_severity = self.missing_field_severity
2726 if missing_field_severity is None:
2727 return
2729 if (
2730 self.inheritable_from_other_stanza
2731 and header_stanza is not None
2732 and self.name in header_stanza
2733 ):
2734 return
2736 if self.name == "Standards-Version":
2737 stanzas = list(deb822_file)[1:]
2738 if all(s.get("Package-Type") == "udeb" for s in stanzas):
2739 return
2741 lint_state.emit_diagnostic(
2742 representation_field_range,
2743 f"Stanza is missing field {self.name}",
2744 missing_field_severity,
2745 self.missing_field_authority,
2746 )
2748 def reformat_field(
2749 self,
2750 effective_preference: "EffectiveFormattingPreference",
2751 stanza_range: TERange,
2752 kvpair: Deb822KeyValuePairElement,
2753 formatter: FormatterCallback,
2754 position_codec: LintCapablePositionCodec,
2755 lines: list[str],
2756 ) -> Iterable[TextEdit]:
2757 if (
2758 self.name == "Architecture"
2759 and effective_preference.deb822_normalize_field_content
2760 ):
2761 interpretation = self.field_value_class.interpreter()
2762 assert interpretation is not None
2763 interpreted = kvpair.interpret_as(interpretation)
2764 archs = list(interpreted)
2765 # Sort, with wildcard entries (such as linux-any) first:
2766 archs = sorted(archs, key=lambda x: ("any" not in x, x))
2767 new_value = f" {' '.join(archs)}\n"
2768 reformat_edits = list(
2769 self._reformat_field_name(
2770 effective_preference,
2771 stanza_range,
2772 kvpair,
2773 position_codec,
2774 lines,
2775 )
2776 )
2777 if new_value != interpreted.convert_to_text(with_field_name=False):
2778 value_range = kvpair.value_element.range_in_parent().relative_to(
2779 kvpair.range_in_parent().start_pos
2780 )
2781 kvpair_range = te_range_to_lsp(
2782 value_range.relative_to(stanza_range.start_pos)
2783 )
2784 reformat_edits.append(
2785 TextEdit(
2786 position_codec.range_to_client_units(lines, kvpair_range),
2787 new_value,
2788 )
2789 )
2790 return reformat_edits
2792 return super(DctrlKnownField, self).reformat_field(
2793 effective_preference,
2794 stanza_range,
2795 kvpair,
2796 formatter,
2797 position_codec,
2798 lines,
2799 )
2801 @property
2802 def is_relationship_field(self) -> bool:
2803 name_lc = self.name.lower()
2804 return (
2805 name_lc in all_package_relationship_fields()
2806 or name_lc in all_source_relationship_fields()
2807 )
2809 @property
2810 def reformattable_field(self) -> bool:
2811 return self.is_relationship_field or self.name == "Uploaders"
2814@dataclasses.dataclass(slots=True)
2815class DctrlRelationshipKnownField(DctrlKnownField):
2816 allowed_version_operators: frozenset[str] = frozenset()
2817 supports_or_relation: bool = True
2819 @property
2820 def is_relationship_field(self) -> bool:
2821 return True
2824SOURCE_FIELDS = _fields(
2825 DctrlKnownField(
2826 "Source",
2827 FieldValueClass.SINGLE_VALUE,
2828 custom_field_check=_combined_custom_field_check(
2829 _each_value_match_regex_validation(PKGNAME_REGEX),
2830 _has_packaging_expected_file(
2831 "copyright",
2832 "No copyright file (package license)",
2833 severity="warning",
2834 ),
2835 _has_packaging_expected_file(
2836 "changelog",
2837 "No Debian changelog file",
2838 severity="error",
2839 ),
2840 _has_build_instructions,
2841 ),
2842 ),
2843 DctrlKnownField(
2844 "Standards-Version",
2845 FieldValueClass.SINGLE_VALUE,
2846 custom_field_check=_sv_field_validation,
2847 ),
2848 DctrlKnownField(
2849 "Section",
2850 FieldValueClass.SINGLE_VALUE,
2851 known_values=ALL_SECTIONS,
2852 ),
2853 DctrlKnownField(
2854 "Priority",
2855 FieldValueClass.SINGLE_VALUE,
2856 ),
2857 DctrlKnownField(
2858 "Maintainer",
2859 FieldValueClass.COMMA_SEPARATED_EMAIL_LIST,
2860 custom_field_check=_combined_custom_field_check(
2861 _maintainer_field_validator,
2862 _canonical_maintainer_name,
2863 ),
2864 ),
2865 DctrlKnownField(
2866 "Uploaders",
2867 FieldValueClass.COMMA_SEPARATED_EMAIL_LIST,
2868 custom_field_check=_canonical_maintainer_name,
2869 ),
2870 DctrlRelationshipKnownField(
2871 "Build-Depends",
2872 FieldValueClass.COMMA_SEPARATED_LIST,
2873 custom_field_check=_dctrl_validate_dep,
2874 ),
2875 DctrlRelationshipKnownField(
2876 "Build-Depends-Arch",
2877 FieldValueClass.COMMA_SEPARATED_LIST,
2878 custom_field_check=_dctrl_validate_dep,
2879 ),
2880 DctrlRelationshipKnownField(
2881 "Build-Depends-Indep",
2882 FieldValueClass.COMMA_SEPARATED_LIST,
2883 custom_field_check=_dctrl_validate_dep,
2884 ),
2885 DctrlRelationshipKnownField(
2886 "Build-Conflicts",
2887 FieldValueClass.COMMA_SEPARATED_LIST,
2888 supports_or_relation=False,
2889 custom_field_check=_dctrl_validate_dep,
2890 ),
2891 DctrlRelationshipKnownField(
2892 "Build-Conflicts-Arch",
2893 FieldValueClass.COMMA_SEPARATED_LIST,
2894 supports_or_relation=False,
2895 custom_field_check=_dctrl_validate_dep,
2896 ),
2897 DctrlRelationshipKnownField(
2898 "Build-Conflicts-Indep",
2899 FieldValueClass.COMMA_SEPARATED_LIST,
2900 supports_or_relation=False,
2901 custom_field_check=_dctrl_validate_dep,
2902 ),
2903 DctrlKnownField(
2904 "Rules-Requires-Root",
2905 FieldValueClass.SPACE_SEPARATED_LIST,
2906 custom_field_check=_rrr_build_driver_mismatch,
2907 ),
2908 DctrlKnownField(
2909 "X-Style",
2910 FieldValueClass.SINGLE_VALUE,
2911 known_values=ALL_PUBLIC_NAMED_STYLES_AS_KEYWORDS,
2912 ),
2913 DctrlKnownField(
2914 "Homepage",
2915 FieldValueClass.SINGLE_VALUE,
2916 custom_field_check=_validate_homepage_field,
2917 ),
2918)
2921BINARY_FIELDS = _fields(
2922 DctrlKnownField(
2923 "Package",
2924 FieldValueClass.SINGLE_VALUE,
2925 custom_field_check=_each_value_match_regex_validation(PKGNAME_REGEX),
2926 ),
2927 DctrlKnownField(
2928 "Architecture",
2929 FieldValueClass.SPACE_SEPARATED_LIST,
2930 # FIXME: Specialize validation for architecture ("!foo" is not a "typo" and should have a better warning)
2931 known_values=allowed_values(dpkg_arch_and_wildcards()),
2932 ),
2933 DctrlKnownField(
2934 "Pre-Depends",
2935 FieldValueClass.COMMA_SEPARATED_LIST,
2936 custom_field_check=_dctrl_validate_dep,
2937 ),
2938 DctrlKnownField(
2939 "Depends",
2940 FieldValueClass.COMMA_SEPARATED_LIST,
2941 custom_field_check=_dctrl_validate_dep,
2942 ),
2943 DctrlKnownField(
2944 "Recommends",
2945 FieldValueClass.COMMA_SEPARATED_LIST,
2946 custom_field_check=_dctrl_validate_dep,
2947 ),
2948 DctrlKnownField(
2949 "Suggests",
2950 FieldValueClass.COMMA_SEPARATED_LIST,
2951 custom_field_check=_dctrl_validate_dep,
2952 ),
2953 DctrlKnownField(
2954 "Enhances",
2955 FieldValueClass.COMMA_SEPARATED_LIST,
2956 custom_field_check=_dctrl_validate_dep,
2957 ),
2958 DctrlRelationshipKnownField(
2959 "Provides",
2960 FieldValueClass.COMMA_SEPARATED_LIST,
2961 custom_field_check=_dctrl_validate_dep,
2962 supports_or_relation=False,
2963 allowed_version_operators=frozenset(["="]),
2964 ),
2965 DctrlRelationshipKnownField(
2966 "Conflicts",
2967 FieldValueClass.COMMA_SEPARATED_LIST,
2968 custom_field_check=_dctrl_validate_dep,
2969 supports_or_relation=False,
2970 ),
2971 DctrlRelationshipKnownField(
2972 "Breaks",
2973 FieldValueClass.COMMA_SEPARATED_LIST,
2974 custom_field_check=_dctrl_validate_dep,
2975 supports_or_relation=False,
2976 ),
2977 DctrlRelationshipKnownField(
2978 "Replaces",
2979 FieldValueClass.COMMA_SEPARATED_LIST,
2980 custom_field_check=_dctrl_validate_dep,
2981 ),
2982 DctrlKnownField(
2983 "Build-Profiles",
2984 FieldValueClass.BUILD_PROFILES_LIST,
2985 ),
2986 DctrlKnownField(
2987 "Section",
2988 FieldValueClass.SINGLE_VALUE,
2989 known_values=ALL_SECTIONS,
2990 ),
2991 DctrlRelationshipKnownField(
2992 "Built-Using",
2993 FieldValueClass.COMMA_SEPARATED_LIST,
2994 custom_field_check=_arch_not_all_only_field_validation,
2995 can_complete_field_in_stanza=_complete_only_in_arch_dep_pkgs,
2996 supports_or_relation=False,
2997 allowed_version_operators=frozenset(["="]),
2998 ),
2999 DctrlRelationshipKnownField(
3000 "Static-Built-Using",
3001 FieldValueClass.COMMA_SEPARATED_LIST,
3002 custom_field_check=_arch_not_all_only_field_validation,
3003 can_complete_field_in_stanza=_complete_only_in_arch_dep_pkgs,
3004 supports_or_relation=False,
3005 allowed_version_operators=frozenset(["="]),
3006 ),
3007 DctrlKnownField(
3008 "Multi-Arch",
3009 FieldValueClass.SINGLE_VALUE,
3010 custom_field_check=_dctrl_ma_field_validation,
3011 known_values=allowed_values(
3012 (
3013 Keyword(
3014 "same",
3015 can_complete_keyword_in_stanza=_complete_only_in_arch_dep_pkgs,
3016 ),
3017 ),
3018 ),
3019 ),
3020 DctrlKnownField(
3021 "XB-Installer-Menu-Item",
3022 FieldValueClass.SINGLE_VALUE,
3023 can_complete_field_in_stanza=_complete_only_for_udeb_pkgs,
3024 custom_field_check=_combined_custom_field_check(
3025 _udeb_only_field_validation,
3026 _each_value_match_regex_validation(re.compile(r"^[1-9]\d{3,4}$")),
3027 ),
3028 ),
3029 DctrlKnownField(
3030 "X-DH-Build-For-Type",
3031 FieldValueClass.SINGLE_VALUE,
3032 custom_field_check=_arch_not_all_only_field_validation,
3033 can_complete_field_in_stanza=_complete_only_in_arch_dep_pkgs,
3034 ),
3035 DctrlKnownField(
3036 "X-Doc-Main-Package",
3037 FieldValueClass.SINGLE_VALUE,
3038 custom_field_check=_binary_package_from_same_source,
3039 ),
3040 DctrlKnownField(
3041 "X-Time64-Compat",
3042 FieldValueClass.SINGLE_VALUE,
3043 can_complete_field_in_stanza=_complete_only_in_arch_dep_pkgs,
3044 custom_field_check=_combined_custom_field_check(
3045 _each_value_match_regex_validation(PKGNAME_REGEX),
3046 _arch_not_all_only_field_validation,
3047 ),
3048 ),
3049 DctrlKnownField(
3050 "Description",
3051 FieldValueClass.FREE_TEXT_FIELD,
3052 custom_field_check=dctrl_description_validator,
3053 ),
3054 DctrlKnownField(
3055 "XB-Cnf-Visible-Pkgname",
3056 FieldValueClass.SINGLE_VALUE,
3057 custom_field_check=_each_value_match_regex_validation(PKGNAME_REGEX),
3058 ),
3059 DctrlKnownField(
3060 "Homepage",
3061 FieldValueClass.SINGLE_VALUE,
3062 show_as_inherited=False,
3063 custom_field_check=_validate_homepage_field,
3064 ),
3065)
3066_DEP5_HEADER_FIELDS = _fields(
3067 Deb822KnownField(
3068 "Format",
3069 FieldValueClass.SINGLE_VALUE,
3070 custom_field_check=_use_https_instead_of_http,
3071 ),
3072)
3073_DEP5_FILES_FIELDS = _fields(
3074 Deb822KnownField(
3075 "Files",
3076 FieldValueClass.DEP5_FILE_LIST,
3077 custom_field_check=_dep5_files_check,
3078 ),
3079)
3080_DEP5_LICENSE_FIELDS = _fields(
3081 Deb822KnownField(
3082 "License",
3083 FieldValueClass.FREE_TEXT_FIELD,
3084 ),
3085)
3087_DTESTSCTRL_FIELDS = _fields(
3088 DTestsCtrlKnownField(
3089 "Architecture",
3090 FieldValueClass.SPACE_SEPARATED_LIST,
3091 # FIXME: Specialize validation for architecture ("!fou" to "foo" would be bad)
3092 known_values=allowed_values(dpkg_arch_and_wildcards(allow_negations=True)),
3093 ),
3094)
3095_DWATCH_HEADER_FIELDS = _fields()
3096_DWATCH_TEMPLATE_FIELDS = _fields()
3097_DWATCH_SOURCE_FIELDS = _fields()
3100@dataclasses.dataclass(slots=True)
3101class StanzaMetadata(Mapping[str, F], Generic[F], ABC):
3102 stanza_type_name: str
3103 stanza_fields: Mapping[str, F]
3104 is_substvars_allowed_in_stanza: bool
3106 async def stanza_diagnostics(
3107 self,
3108 deb822_file: Deb822FileElement,
3109 stanza: Deb822ParagraphElement,
3110 stanza_position_in_file: "TEPosition",
3111 lint_state: LintState,
3112 *,
3113 inherit_from_stanza: Deb822ParagraphElement | None = None,
3114 confusable_with_stanza_name: str | None = None,
3115 confusable_with_stanza_metadata: Optional["StanzaMetadata[F]"] = None,
3116 ) -> None:
3117 if (confusable_with_stanza_name is None) ^ ( 3117 ↛ 3120line 3117 didn't jump to line 3120 because the condition on line 3117 was never true
3118 confusable_with_stanza_metadata is None
3119 ):
3120 raise ValueError(
3121 "confusable_with_stanza_name and confusable_with_stanza_metadata must be used together"
3122 )
3123 _, representation_field_range = self.stanza_representation(
3124 stanza,
3125 stanza_position_in_file,
3126 )
3127 known_fields = self.stanza_fields
3128 self.omitted_field_diagnostics(
3129 lint_state,
3130 deb822_file,
3131 stanza,
3132 stanza_position_in_file,
3133 inherit_from_stanza=inherit_from_stanza,
3134 representation_field_range=representation_field_range,
3135 )
3136 seen_fields: dict[str, tuple[str, str, "TERange", list[Range], set[str]]] = {}
3138 async for kvpair_range, kvpair in lint_state.slow_iter(
3139 with_range_in_continuous_parts(
3140 stanza.iter_parts(),
3141 start_relative_to=stanza_position_in_file,
3142 ),
3143 yield_every=1,
3144 ):
3145 if not isinstance(kvpair, Deb822KeyValuePairElement): 3145 ↛ 3146line 3145 didn't jump to line 3146 because the condition on line 3145 was never true
3146 continue
3147 field_name_token = kvpair.field_token
3148 field_name = field_name_token.text
3149 field_name_lc = field_name.lower()
3150 # Defeat any tricks from `python-debian` from here on out
3151 field_name = str(field_name)
3152 normalized_field_name_lc = self.normalize_field_name(field_name_lc)
3153 known_field = known_fields.get(normalized_field_name_lc)
3154 field_value = stanza[field_name]
3155 kvpair_range_te = kvpair.range_in_parent().relative_to(
3156 stanza_position_in_file
3157 )
3158 field_range = kvpair.field_token.range_in_parent().relative_to(
3159 kvpair_range_te.start_pos
3160 )
3161 field_position_te = field_range.start_pos
3162 field_name_typo_detected = False
3163 dup_field_key = (
3164 known_field.name
3165 if known_field is not None
3166 else normalized_field_name_lc
3167 )
3168 existing_field_range = seen_fields.get(dup_field_key)
3169 if existing_field_range is not None:
3170 existing_field_range[3].append(field_range)
3171 existing_field_range[4].add(field_name)
3172 else:
3173 normalized_field_name = self.normalize_field_name(field_name)
3174 seen_fields[dup_field_key] = (
3175 known_field.name if known_field else field_name,
3176 normalized_field_name,
3177 field_range,
3178 [],
3179 {field_name},
3180 )
3182 if known_field is None:
3183 candidates = detect_possible_typo(
3184 normalized_field_name_lc, known_fields
3185 )
3186 if candidates:
3187 known_field = known_fields[candidates[0]]
3188 field_range = TERange.from_position_and_size(
3189 field_position_te, kvpair.field_token.size()
3190 )
3191 field_name_typo_detected = True
3192 lint_state.emit_diagnostic(
3193 field_range,
3194 f'The "{field_name}" looks like a typo of "{known_field.name}".',
3195 "warning",
3196 "debputy",
3197 quickfixes=[
3198 propose_correct_text_quick_fix(known_fields[m].name)
3199 for m in candidates
3200 ],
3201 )
3202 if field_value.strip() == "": 3202 ↛ 3203line 3202 didn't jump to line 3203 because the condition on line 3202 was never true
3203 lint_state.emit_diagnostic(
3204 field_range,
3205 f"The {field_name} has no value. Either provide a value or remove it.",
3206 "error",
3207 "Policy 5.1",
3208 )
3209 continue
3210 if known_field is None:
3211 known_else_where = confusable_with_stanza_metadata.stanza_fields.get(
3212 normalized_field_name_lc
3213 )
3214 if known_else_where is not None:
3215 lint_state.emit_diagnostic(
3216 field_range,
3217 f"The {kvpair.field_name} is defined for use in the"
3218 f' "{confusable_with_stanza_name}" stanza. Please move it to the right place or remove it',
3219 "error",
3220 known_else_where.missing_field_authority,
3221 )
3222 continue
3223 await known_field.field_diagnostics(
3224 deb822_file,
3225 kvpair,
3226 stanza,
3227 stanza_position_in_file,
3228 kvpair_range_te,
3229 lint_state,
3230 field_name_typo_reported=field_name_typo_detected,
3231 )
3233 inherit_value = (
3234 inherit_from_stanza.get(field_name) if inherit_from_stanza else None
3235 )
3237 if (
3238 known_field.inheritable_from_other_stanza
3239 and inherit_value is not None
3240 and field_value == inherit_value
3241 ):
3242 quick_fix = propose_remove_range_quick_fix(
3243 proposed_title="Remove redundant definition"
3244 )
3245 lint_state.emit_diagnostic(
3246 kvpair_range_te,
3247 f"The field {field_name} duplicates the value from the Source stanza.",
3248 "informational",
3249 "debputy",
3250 quickfixes=[quick_fix],
3251 )
3252 for (
3253 field_name,
3254 normalized_field_name,
3255 field_range,
3256 duplicates,
3257 used_fields,
3258 ) in seen_fields.values():
3259 if not duplicates:
3260 continue
3261 if len(used_fields) != 1 or field_name not in used_fields:
3262 via_aliases_msg = " (via aliases)"
3263 else:
3264 via_aliases_msg = ""
3265 for dup_range in duplicates:
3266 lint_state.emit_diagnostic(
3267 dup_range,
3268 f'The field "{field_name}"{via_aliases_msg} was used multiple times in this stanza.'
3269 " Please ensure the field is only used once per stanza.",
3270 "error",
3271 "Policy 5.1",
3272 related_information=[
3273 lint_state.related_diagnostic_information(
3274 field_range,
3275 message=f"First definition of {field_name}",
3276 ),
3277 ],
3278 )
3280 def __getitem__(self, key: str) -> F:
3281 key_lc = key.lower()
3282 key_norm = normalize_dctrl_field_name(key_lc)
3283 return self.stanza_fields[key_norm]
3285 def __len__(self) -> int:
3286 return len(self.stanza_fields)
3288 def __iter__(self) -> Iterator[str]:
3289 return iter(self.stanza_fields.keys())
3291 def omitted_field_diagnostics(
3292 self,
3293 lint_state: LintState,
3294 deb822_file: Deb822FileElement,
3295 stanza: Deb822ParagraphElement,
3296 stanza_position: "TEPosition",
3297 *,
3298 inherit_from_stanza: Deb822ParagraphElement | None = None,
3299 representation_field_range: Range | None = None,
3300 ) -> None:
3301 if representation_field_range is None: 3301 ↛ 3302line 3301 didn't jump to line 3302 because the condition on line 3301 was never true
3302 _, representation_field_range = self.stanza_representation(
3303 stanza,
3304 stanza_position,
3305 )
3306 for known_field in self.stanza_fields.values():
3307 if known_field.name in stanza:
3308 continue
3310 known_field.field_omitted_diagnostics(
3311 deb822_file,
3312 representation_field_range,
3313 stanza,
3314 stanza_position,
3315 inherit_from_stanza,
3316 lint_state,
3317 )
3319 def _paragraph_representation_field(
3320 self,
3321 paragraph: Deb822ParagraphElement,
3322 ) -> Deb822KeyValuePairElement:
3323 return next(iter(paragraph.iter_parts_of_type(Deb822KeyValuePairElement)))
3325 def normalize_field_name(self, field_name: str) -> str:
3326 return field_name
3328 def stanza_representation(
3329 self,
3330 stanza: Deb822ParagraphElement,
3331 stanza_position: TEPosition,
3332 ) -> tuple[Deb822KeyValuePairElement, TERange]:
3333 representation_field = self._paragraph_representation_field(stanza)
3334 representation_field_range = representation_field.range_in_parent().relative_to(
3335 stanza_position
3336 )
3337 return representation_field, representation_field_range
3339 def reformat_stanza(
3340 self,
3341 effective_preference: "EffectiveFormattingPreference",
3342 stanza: Deb822ParagraphElement,
3343 stanza_range: TERange,
3344 formatter: FormatterCallback,
3345 position_codec: LintCapablePositionCodec,
3346 lines: list[str],
3347 ) -> Iterable[TextEdit]:
3348 for field_name in stanza:
3349 known_field = self.stanza_fields.get(field_name.lower())
3350 if known_field is None:
3351 continue
3352 kvpair = stanza.get_kvpair_element(field_name)
3353 yield from known_field.reformat_field(
3354 effective_preference,
3355 stanza_range,
3356 kvpair,
3357 formatter,
3358 position_codec,
3359 lines,
3360 )
3363@dataclasses.dataclass(slots=True)
3364class Dep5StanzaMetadata(StanzaMetadata[Deb822KnownField]):
3365 pass
3368@dataclasses.dataclass(slots=True)
3369class DctrlStanzaMetadata(StanzaMetadata[DctrlKnownField]):
3371 def normalize_field_name(self, field_name: str) -> str:
3372 return normalize_dctrl_field_name(field_name)
3375@dataclasses.dataclass(slots=True)
3376class DTestsCtrlStanzaMetadata(StanzaMetadata[DTestsCtrlKnownField]):
3378 def omitted_field_diagnostics(
3379 self,
3380 lint_state: LintState,
3381 deb822_file: Deb822FileElement,
3382 stanza: Deb822ParagraphElement,
3383 stanza_position: "TEPosition",
3384 *,
3385 inherit_from_stanza: Deb822ParagraphElement | None = None,
3386 representation_field_range: Range | None = None,
3387 ) -> None:
3388 if representation_field_range is None: 3388 ↛ 3389line 3388 didn't jump to line 3389 because the condition on line 3388 was never true
3389 _, representation_field_range = self.stanza_representation(
3390 stanza,
3391 stanza_position,
3392 )
3393 auth_ref = self.stanza_fields["tests"].missing_field_authority
3394 if "Tests" not in stanza and "Test-Command" not in stanza:
3395 lint_state.emit_diagnostic(
3396 representation_field_range,
3397 'Stanza must have either a "Tests" or a "Test-Command" field',
3398 "error",
3399 # TODO: Better authority_reference
3400 auth_ref,
3401 )
3402 if "Tests" in stanza and "Test-Command" in stanza:
3403 lint_state.emit_diagnostic(
3404 representation_field_range,
3405 'Stanza cannot have both a "Tests" and a "Test-Command" field',
3406 "error",
3407 # TODO: Better authority_reference
3408 auth_ref,
3409 )
3411 # Note that since we do not use the field names for stanza classification, we
3412 # always do the super call.
3413 super(DTestsCtrlStanzaMetadata, self).omitted_field_diagnostics(
3414 lint_state,
3415 deb822_file,
3416 stanza,
3417 stanza_position,
3418 representation_field_range=representation_field_range,
3419 inherit_from_stanza=inherit_from_stanza,
3420 )
3423@dataclasses.dataclass(slots=True)
3424class DebianWatchStanzaMetadata(StanzaMetadata[Deb822KnownField]):
3426 def omitted_field_diagnostics(
3427 self,
3428 lint_state: LintState,
3429 deb822_file: Deb822FileElement,
3430 stanza: Deb822ParagraphElement,
3431 stanza_position: "TEPosition",
3432 *,
3433 inherit_from_stanza: Deb822ParagraphElement | None = None,
3434 representation_field_range: Range | None = None,
3435 ) -> None:
3436 if representation_field_range is None: 3436 ↛ 3437line 3436 didn't jump to line 3437 because the condition on line 3436 was never true
3437 _, representation_field_range = self.stanza_representation(
3438 stanza,
3439 stanza_position,
3440 )
3442 if ( 3442 ↛ 3447line 3442 didn't jump to line 3447 because the condition on line 3442 was never true
3443 self.stanza_type_name != "Header"
3444 and "Source" not in stanza
3445 and "Template" not in stanza
3446 ):
3447 lint_state.emit_diagnostic(
3448 representation_field_range,
3449 'Stanza must have either a "Source" or a "Template" field',
3450 "error",
3451 # TODO: Better authority_reference
3452 "debputy",
3453 )
3454 # The required fields depends on which stanza it is. Therefore, we omit the super
3455 # call until this error is resolved.
3456 return
3458 super(DebianWatchStanzaMetadata, self).omitted_field_diagnostics(
3459 lint_state,
3460 deb822_file,
3461 stanza,
3462 stanza_position,
3463 representation_field_range=representation_field_range,
3464 inherit_from_stanza=inherit_from_stanza,
3465 )
3468def lsp_reference_data_dir() -> str:
3469 return os.path.join(
3470 os.path.dirname(__file__),
3471 "data",
3472 )
3475class Deb822FileMetadata(Generic[S, F]):
3477 def __init__(self) -> None:
3478 self._is_initialized = False
3479 self._data: Deb822ReferenceData | None = None
3481 @property
3482 def reference_data_basename(self) -> str:
3483 raise NotImplementedError
3485 def _new_field(
3486 self,
3487 name: str,
3488 field_value_type: FieldValueClass,
3489 ) -> F:
3490 raise NotImplementedError
3492 def _reference_data(self) -> Deb822ReferenceData:
3493 ref = self._data
3494 if ref is not None: 3494 ↛ 3495line 3494 didn't jump to line 3495 because the condition on line 3494 was never true
3495 return ref
3497 p = importlib.resources.files(deb822_ref_data_dir.__name__).joinpath(
3498 self.reference_data_basename
3499 )
3501 with p.open("r", encoding="utf-8") as fd:
3502 raw = MANIFEST_YAML.load(fd)
3504 attr_path = AttributePath.root_path(p)
3505 try:
3506 ref = DEB822_REFERENCE_DATA_PARSER.parse_input(raw, attr_path)
3507 except ManifestParseException as e:
3508 raise ValueError(
3509 f"Internal error: Could not parse reference data [{self.reference_data_basename}]: {e.message}"
3510 ) from e
3511 self._data = ref
3512 return ref
3514 @property
3515 def is_initialized(self) -> bool:
3516 return self._is_initialized
3518 def ensure_initialized(self) -> None:
3519 if self.is_initialized:
3520 return
3521 # Enables us to use __getitem__
3522 self._is_initialized = True
3523 ref_data = self._reference_data()
3524 ref_defs = ref_data.get("definitions")
3525 variables = {}
3526 ref_variables = ref_defs.get("variables", []) if ref_defs else []
3527 for ref_variable in ref_variables:
3528 name = ref_variable["name"]
3529 fallback = ref_variable["fallback"]
3530 variables[name] = fallback
3532 def _resolve_doc(template: str | None) -> str | None:
3533 if template is None: 3533 ↛ 3534line 3533 didn't jump to line 3534 because the condition on line 3533 was never true
3534 return None
3535 try:
3536 return template.format(**variables)
3537 except ValueError as e:
3538 template_escaped = template.replace("\n", "\\r")
3539 _error(f"Bad template: {template_escaped}: {e}")
3541 for ref_stanza_type in ref_data["stanza_types"]:
3542 stanza_name = ref_stanza_type["stanza_name"]
3543 stanza = self[stanza_name]
3544 stanza_fields = dict(stanza.stanza_fields)
3545 stanza.stanza_fields = stanza_fields
3546 for ref_field in ref_stanza_type["fields"]:
3547 _resolve_field(
3548 ref_field,
3549 stanza_fields,
3550 self._new_field,
3551 _resolve_doc,
3552 f"Stanza:{stanza.stanza_type_name}|Field:{ref_field['canonical_name']}",
3553 )
3555 def file_metadata_applies_to_file(
3556 self,
3557 deb822_file: Deb822FileElement | None,
3558 ) -> bool:
3559 return deb822_file is not None
3561 def classify_stanza(self, stanza: Deb822ParagraphElement, stanza_idx: int) -> S:
3562 return self.guess_stanza_classification_by_idx(stanza_idx)
3564 def guess_stanza_classification_by_idx(self, stanza_idx: int) -> S:
3565 raise NotImplementedError
3567 def stanza_types(self) -> Iterable[S]:
3568 raise NotImplementedError
3570 def __getitem__(self, item: str) -> S:
3571 raise NotImplementedError
3573 def get(self, item: str) -> S | None:
3574 try:
3575 return self[item]
3576 except KeyError:
3577 return None
3579 def reformat(
3580 self,
3581 effective_preference: "EffectiveFormattingPreference",
3582 deb822_file: Deb822FileElement,
3583 formatter: FormatterCallback,
3584 _content: str,
3585 position_codec: LintCapablePositionCodec,
3586 lines: list[str],
3587 ) -> Iterable[TextEdit]:
3588 stanza_idx = -1
3589 for token_or_element in deb822_file.iter_parts():
3590 if isinstance(token_or_element, Deb822ParagraphElement):
3591 stanza_range = token_or_element.range_in_parent()
3592 stanza_idx += 1
3593 stanza_metadata = self.classify_stanza(token_or_element, stanza_idx)
3594 yield from stanza_metadata.reformat_stanza(
3595 effective_preference,
3596 token_or_element,
3597 stanza_range,
3598 formatter,
3599 position_codec,
3600 lines,
3601 )
3602 else:
3603 token_range = token_or_element.range_in_parent()
3604 yield from trim_end_of_line_whitespace(
3605 position_codec,
3606 lines,
3607 line_range=range(
3608 token_range.start_pos.line_position,
3609 token_range.end_pos.line_position,
3610 ),
3611 )
3614_DCTRL_SOURCE_STANZA = DctrlStanzaMetadata(
3615 "Source",
3616 SOURCE_FIELDS,
3617 is_substvars_allowed_in_stanza=False,
3618)
3619_DCTRL_PACKAGE_STANZA = DctrlStanzaMetadata(
3620 "Package",
3621 BINARY_FIELDS,
3622 is_substvars_allowed_in_stanza=True,
3623)
3625_DEP5_HEADER_STANZA = Dep5StanzaMetadata(
3626 "Header",
3627 _DEP5_HEADER_FIELDS,
3628 is_substvars_allowed_in_stanza=False,
3629)
3630_DEP5_FILES_STANZA = Dep5StanzaMetadata(
3631 "Files",
3632 _DEP5_FILES_FIELDS,
3633 is_substvars_allowed_in_stanza=False,
3634)
3635_DEP5_LICENSE_STANZA = Dep5StanzaMetadata(
3636 "License",
3637 _DEP5_LICENSE_FIELDS,
3638 is_substvars_allowed_in_stanza=False,
3639)
3641_DTESTSCTRL_STANZA = DTestsCtrlStanzaMetadata(
3642 "Tests",
3643 _DTESTSCTRL_FIELDS,
3644 is_substvars_allowed_in_stanza=False,
3645)
3647_WATCH_HEADER_HEADER_STANZA = DebianWatchStanzaMetadata(
3648 "Header",
3649 _DWATCH_HEADER_FIELDS,
3650 is_substvars_allowed_in_stanza=False,
3651)
3652_WATCH_SOURCE_STANZA = DebianWatchStanzaMetadata(
3653 "Source",
3654 _DWATCH_SOURCE_FIELDS,
3655 is_substvars_allowed_in_stanza=False,
3656)
3659class Dep5FileMetadata(Deb822FileMetadata[Dep5StanzaMetadata, Deb822KnownField]):
3661 @property
3662 def reference_data_basename(self) -> str:
3663 return "debian_copyright_reference_data.yaml"
3665 def _new_field(
3666 self,
3667 name: str,
3668 field_value_type: FieldValueClass,
3669 ) -> F:
3670 return Deb822KnownField(name, field_value_type)
3672 def file_metadata_applies_to_file(
3673 self,
3674 deb822_file: Deb822FileElement | None,
3675 ) -> bool:
3676 if not super().file_metadata_applies_to_file(deb822_file): 3676 ↛ 3677line 3676 didn't jump to line 3677 because the condition on line 3676 was never true
3677 return False
3678 first_stanza = next(iter(deb822_file), None)
3679 if first_stanza is None or "Format" not in first_stanza:
3680 # No parseable stanzas or the first one did not have a Format, which is necessary.
3681 return False
3683 for part in deb822_file.iter_parts(): 3683 ↛ 3689line 3683 didn't jump to line 3689 because the loop on line 3683 didn't complete
3684 if part.is_error:
3685 # Error first, then it might just be a "Format:" in the middle of a free-text file.
3686 return False
3687 if part is first_stanza: 3687 ↛ 3683line 3687 didn't jump to line 3683 because the condition on line 3687 was always true
3688 break
3689 return True
3691 def classify_stanza(
3692 self,
3693 stanza: Deb822ParagraphElement,
3694 stanza_idx: int,
3695 ) -> Dep5StanzaMetadata:
3696 self.ensure_initialized()
3697 if stanza_idx == 0: 3697 ↛ 3698line 3697 didn't jump to line 3698 because the condition on line 3697 was never true
3698 return _DEP5_HEADER_STANZA
3699 if stanza_idx > 0: 3699 ↛ 3703line 3699 didn't jump to line 3703 because the condition on line 3699 was always true
3700 if "Files" in stanza:
3701 return _DEP5_FILES_STANZA
3702 return _DEP5_LICENSE_STANZA
3703 raise ValueError("The stanza_idx must be 0 or greater")
3705 def guess_stanza_classification_by_idx(self, stanza_idx: int) -> Dep5StanzaMetadata:
3706 self.ensure_initialized()
3707 if stanza_idx == 0:
3708 return _DEP5_HEADER_STANZA
3709 if stanza_idx > 0:
3710 return _DEP5_FILES_STANZA
3711 raise ValueError("The stanza_idx must be 0 or greater")
3713 def stanza_types(self) -> Iterable[Dep5StanzaMetadata]:
3714 self.ensure_initialized()
3715 # Order assumption made in the LSP code.
3716 yield _DEP5_HEADER_STANZA
3717 yield _DEP5_FILES_STANZA
3718 yield _DEP5_LICENSE_STANZA
3720 def __getitem__(self, item: str) -> Dep5StanzaMetadata:
3721 self.ensure_initialized()
3722 if item == "Header":
3723 return _DEP5_HEADER_STANZA
3724 if item == "Files":
3725 return _DEP5_FILES_STANZA
3726 if item == "License": 3726 ↛ 3728line 3726 didn't jump to line 3728 because the condition on line 3726 was always true
3727 return _DEP5_LICENSE_STANZA
3728 raise KeyError(item)
3731class DebianWatch5FileMetadata(
3732 Deb822FileMetadata[DebianWatchStanzaMetadata, Deb822KnownField]
3733):
3735 @property
3736 def reference_data_basename(self) -> str:
3737 return "debian_watch_reference_data.yaml"
3739 def _new_field(
3740 self,
3741 name: str,
3742 field_value_type: FieldValueClass,
3743 ) -> F:
3744 return Deb822KnownField(name, field_value_type)
3746 def file_metadata_applies_to_file(
3747 self, deb822_file: Deb822FileElement | None
3748 ) -> bool:
3749 if not super().file_metadata_applies_to_file(deb822_file): 3749 ↛ 3750line 3749 didn't jump to line 3750 because the condition on line 3749 was never true
3750 return False
3751 first_stanza = next(iter(deb822_file), None)
3753 if first_stanza is None or "Version" not in first_stanza: 3753 ↛ 3755line 3753 didn't jump to line 3755 because the condition on line 3753 was never true
3754 # No parseable stanzas or the first one did not have a Version field, which is necessary.
3755 return False
3757 try:
3758 if int(first_stanza.get("Version")) < 5: 3758 ↛ 3759line 3758 didn't jump to line 3759 because the condition on line 3758 was never true
3759 return False
3760 except (ValueError, IndexError, TypeError):
3761 return False
3763 for part in deb822_file.iter_parts(): 3763 ↛ 3769line 3763 didn't jump to line 3769 because the loop on line 3763 didn't complete
3764 if part.is_error: 3764 ↛ 3766line 3764 didn't jump to line 3766 because the condition on line 3764 was never true
3765 # Error first, then it might just be a "Version:" in the middle of a free-text file.
3766 return False
3767 if part is first_stanza: 3767 ↛ 3763line 3767 didn't jump to line 3763 because the condition on line 3767 was always true
3768 break
3769 return True
3771 def classify_stanza(
3772 self,
3773 stanza: Deb822ParagraphElement,
3774 stanza_idx: int,
3775 ) -> DebianWatchStanzaMetadata:
3776 self.ensure_initialized()
3777 if stanza_idx == 0:
3778 return _WATCH_HEADER_HEADER_STANZA
3779 if stanza_idx > 0: 3779 ↛ 3781line 3779 didn't jump to line 3781 because the condition on line 3779 was always true
3780 return _WATCH_SOURCE_STANZA
3781 raise ValueError("The stanza_idx must be 0 or greater")
3783 def guess_stanza_classification_by_idx(
3784 self, stanza_idx: int
3785 ) -> DebianWatchStanzaMetadata:
3786 self.ensure_initialized()
3787 if stanza_idx == 0: 3787 ↛ 3788line 3787 didn't jump to line 3788 because the condition on line 3787 was never true
3788 return _WATCH_HEADER_HEADER_STANZA
3789 if stanza_idx > 0: 3789 ↛ 3791line 3789 didn't jump to line 3791 because the condition on line 3789 was always true
3790 return _WATCH_SOURCE_STANZA
3791 raise ValueError("The stanza_idx must be 0 or greater")
3793 def stanza_types(self) -> Iterable[DebianWatchStanzaMetadata]:
3794 self.ensure_initialized()
3795 # Order assumption made in the LSP code.
3796 yield _WATCH_HEADER_HEADER_STANZA
3797 yield _WATCH_SOURCE_STANZA
3799 def __getitem__(self, item: str) -> DebianWatchStanzaMetadata:
3800 self.ensure_initialized()
3801 if item == "Header":
3802 return _WATCH_HEADER_HEADER_STANZA
3803 if item == "Source": 3803 ↛ 3805line 3803 didn't jump to line 3805 because the condition on line 3803 was always true
3804 return _WATCH_SOURCE_STANZA
3805 raise KeyError(item)
3808def _resolve_keyword(
3809 ref_value: StaticValue,
3810 known_values: dict[str, Keyword],
3811 resolve_template: Callable[[str | None], str | None],
3812 translation_context: str,
3813) -> None:
3814 value_key = ref_value["value"]
3815 changes = {
3816 "translation_context": translation_context,
3817 }
3818 try:
3819 known_value = known_values[value_key]
3820 except KeyError:
3821 known_value = Keyword(value_key)
3822 known_values[value_key] = known_value
3823 else:
3824 if known_value.is_alias_of: 3824 ↛ 3825line 3824 didn't jump to line 3825 because the condition on line 3824 was never true
3825 raise ValueError(
3826 f"The value {known_value.value} has an alias {known_value.is_alias_of} that conflicts with"
3827 f' {value_key} or the data file used an alias in its `canonical-name` rather than the "true" name'
3828 )
3829 value_doc = ref_value.get("documentation")
3830 if value_doc is not None:
3831 changes["synopsis"] = value_doc.get("synopsis")
3832 changes["long_description"] = resolve_template(
3833 value_doc.get("long_description")
3834 )
3835 if is_exclusive := ref_value.get("is_exclusive"):
3836 changes["is_exclusive"] = is_exclusive
3837 if (sort_key := ref_value.get("sort_key")) is not None:
3838 changes["sort_text"] = sort_key
3839 if (usage_hint := ref_value.get("usage_hint")) is not None:
3840 changes["usage_hint"] = usage_hint
3841 if changes: 3841 ↛ 3845line 3841 didn't jump to line 3845 because the condition on line 3841 was always true
3842 known_value = known_value.replace(**changes)
3843 known_values[value_key] = known_value
3845 _expand_aliases(
3846 known_value,
3847 known_values,
3848 operator.attrgetter("value"),
3849 ref_value.get("aliases"),
3850 "The value `{ALIAS}` is an alias of `{NAME}`.",
3851 )
3854def _resolve_field(
3855 ref_field: Deb822Field,
3856 stanza_fields: dict[str, F],
3857 field_constructor: Callable[[str, FieldValueClass], F],
3858 resolve_template: Callable[[str | None], str | None],
3859 translation_context: str,
3860) -> None:
3861 field_name = ref_field["canonical_name"]
3862 field_value_type = FieldValueClass.from_key(ref_field["field_value_type"])
3863 doc = ref_field.get("documentation")
3864 ref_values = ref_field.get("values", [])
3865 norm_field_name = normalize_dctrl_field_name(field_name.lower())
3867 try:
3868 field = stanza_fields[norm_field_name]
3869 except KeyError:
3870 field = field_constructor(
3871 field_name,
3872 field_value_type,
3873 )
3874 stanza_fields[norm_field_name] = field
3875 else:
3876 if field.name != field_name: 3876 ↛ 3877line 3876 didn't jump to line 3877 because the condition on line 3876 was never true
3877 _error(
3878 f'Error in reference data: Code uses "{field.name}" as canonical name and the data file'
3879 f" uses {field_name}. Please ensure the data is correctly aligned."
3880 )
3881 if field.field_value_class != field_value_type: 3881 ↛ 3882line 3881 didn't jump to line 3882 because the condition on line 3881 was never true
3882 _error(
3883 f'Error in reference data for field "{field.name}": Code has'
3884 f" {field.field_value_class.key} and the data file uses {field_value_type.key}"
3885 f" for field-value-type. Please ensure the data is correctly aligned."
3886 )
3887 if field.is_alias_of: 3887 ↛ 3888line 3887 didn't jump to line 3888 because the condition on line 3887 was never true
3888 raise ValueError(
3889 f"The field {field.name} has an alias {field.is_alias_of} that conflicts with"
3890 f' {field_name} or the data file used an alias in its `canonical-name` rather than the "true" name'
3891 )
3893 if doc is not None:
3894 field.synopsis = doc.get("synopsis")
3895 field.long_description = resolve_template(doc.get("long_description"))
3897 field.default_value = ref_field.get("default_value")
3898 field.warn_if_default = ref_field.get("warn_if_default", True)
3899 field.spellcheck_value = ref_field.get("spellcheck_value", False)
3900 field.deprecated_with_no_replacement = ref_field.get(
3901 "is_obsolete_without_replacement", False
3902 )
3903 field.replaced_by = ref_field.get("replaced_by")
3904 field.translation_context = translation_context
3905 field.usage_hint = ref_field.get("usage_hint")
3906 field.missing_field_severity = ref_field.get("missing_field_severity", None)
3907 unknown_value_severity = ref_field.get("unknown_value_severity", "error")
3908 field.unknown_value_severity = (
3909 None if unknown_value_severity == "none" else unknown_value_severity
3910 )
3911 field.unknown_value_authority = ref_field.get("unknown_value_authority", "debputy")
3912 field.missing_field_authority = ref_field.get("missing_field_authority", "debputy")
3913 field.is_substvars_disabled_even_if_allowed_by_stanza = not ref_field.get(
3914 "supports_substvars",
3915 True,
3916 )
3917 field.inheritable_from_other_stanza = ref_field.get(
3918 "inheritable_from_other_stanza",
3919 False,
3920 )
3922 known_values = field.known_values
3923 if known_values is None:
3924 known_values = {}
3925 else:
3926 known_values = dict(known_values)
3928 for ref_value in ref_values:
3929 _resolve_keyword(ref_value, known_values, resolve_template, translation_context)
3931 if known_values:
3932 field.known_values = known_values
3934 _expand_aliases(
3935 field,
3936 stanza_fields,
3937 operator.attrgetter("name"),
3938 ref_field.get("aliases"),
3939 "The field `{ALIAS}` is an alias of `{NAME}`.",
3940 )
3943A = TypeVar("A", Keyword, Deb822KnownField)
3946def _expand_aliases(
3947 item: A,
3948 item_container: dict[str, A],
3949 canonical_name_resolver: Callable[[A], str],
3950 aliases_ref: list[Alias] | None,
3951 doc_template: str,
3952) -> None:
3953 if aliases_ref is None:
3954 return
3955 name = canonical_name_resolver(item)
3956 assert name is not None, "canonical_name_resolver is not allowed to return None"
3957 for alias_ref in aliases_ref:
3958 alias_name = alias_ref["alias"]
3959 alias_doc = item.long_description
3960 is_completion_suggestion = alias_ref.get("is_completion_suggestion", False)
3961 doc_suffix = doc_template.format(NAME=name, ALIAS=alias_name)
3962 if alias_doc:
3963 alias_doc += f"\n\n{doc_suffix}"
3964 else:
3965 alias_doc = doc_suffix
3966 alias_field = item.replace(
3967 long_description=alias_doc,
3968 is_alias_of=name,
3969 is_completion_suggestion=is_completion_suggestion,
3970 )
3971 alias_key = alias_name.lower()
3972 if alias_name in item_container: 3972 ↛ 3973line 3972 didn't jump to line 3973 because the condition on line 3972 was never true
3973 existing_name = canonical_name_resolver(item_container[alias_key])
3974 assert (
3975 existing_name is not None
3976 ), "canonical_name_resolver is not allowed to return None"
3977 raise ValueError(
3978 f"The value {name} has an alias {alias_name} that conflicts with {existing_name}"
3979 )
3980 item_container[alias_key] = alias_field
3983class DctrlFileMetadata(Deb822FileMetadata[DctrlStanzaMetadata, DctrlKnownField]):
3985 @property
3986 def reference_data_basename(self) -> str:
3987 return "debian_control_reference_data.yaml"
3989 def _new_field(
3990 self,
3991 name: str,
3992 field_value_type: FieldValueClass,
3993 ) -> F:
3994 return DctrlKnownField(name, field_value_type)
3996 def guess_stanza_classification_by_idx(
3997 self,
3998 stanza_idx: int,
3999 ) -> DctrlStanzaMetadata:
4000 self.ensure_initialized()
4001 if stanza_idx == 0:
4002 return _DCTRL_SOURCE_STANZA
4003 if stanza_idx > 0: 4003 ↛ 4005line 4003 didn't jump to line 4005 because the condition on line 4003 was always true
4004 return _DCTRL_PACKAGE_STANZA
4005 raise ValueError("The stanza_idx must be 0 or greater")
4007 def stanza_types(self) -> Iterable[DctrlStanzaMetadata]:
4008 self.ensure_initialized()
4009 # Order assumption made in the LSP code.
4010 yield _DCTRL_SOURCE_STANZA
4011 yield _DCTRL_PACKAGE_STANZA
4013 def __getitem__(self, item: str) -> DctrlStanzaMetadata:
4014 self.ensure_initialized()
4015 if item == "Source":
4016 return _DCTRL_SOURCE_STANZA
4017 if item == "Package": 4017 ↛ 4019line 4017 didn't jump to line 4019 because the condition on line 4017 was always true
4018 return _DCTRL_PACKAGE_STANZA
4019 raise KeyError(item)
4021 def reformat(
4022 self,
4023 effective_preference: "EffectiveFormattingPreference",
4024 deb822_file: Deb822FileElement,
4025 formatter: FormatterCallback,
4026 content: str,
4027 position_codec: LintCapablePositionCodec,
4028 lines: list[str],
4029 ) -> Iterable[TextEdit]:
4030 edits = list(
4031 super().reformat(
4032 effective_preference,
4033 deb822_file,
4034 formatter,
4035 content,
4036 position_codec,
4037 lines,
4038 )
4039 )
4041 if ( 4041 ↛ 4046line 4041 didn't jump to line 4046 because the condition on line 4041 was always true
4042 not effective_preference.deb822_normalize_stanza_order
4043 or deb822_file.find_first_error_element() is not None
4044 ):
4045 return edits
4046 names = []
4047 for idx, stanza in enumerate(deb822_file):
4048 if idx < 2:
4049 continue
4050 name = stanza.get("Package")
4051 if name is None:
4052 return edits
4053 names.append(name)
4055 reordered = sorted(names)
4056 if names == reordered:
4057 return edits
4059 if edits:
4060 content = apply_text_edits(content, lines, edits)
4061 lines = content.splitlines(keepends=True)
4062 deb822_file = parse_deb822_file(
4063 lines,
4064 accept_files_with_duplicated_fields=True,
4065 accept_files_with_error_tokens=True,
4066 )
4068 stanzas = list(deb822_file)
4069 reordered_stanza = stanzas[:2] + sorted(
4070 stanzas[2:], key=operator.itemgetter("Package")
4071 )
4072 bits = []
4073 stanza_idx = 0
4074 for token_or_element in deb822_file.iter_parts():
4075 if isinstance(token_or_element, Deb822ParagraphElement):
4076 bits.append(reordered_stanza[stanza_idx].dump())
4077 stanza_idx += 1
4078 else:
4079 bits.append(token_or_element.convert_to_text())
4081 new_content = "".join(bits)
4083 return [
4084 TextEdit(
4085 Range(
4086 Position(0, 0),
4087 Position(len(lines) + 1, 0),
4088 ),
4089 new_content,
4090 )
4091 ]
4094class DTestsCtrlFileMetadata(
4095 Deb822FileMetadata[DTestsCtrlStanzaMetadata, DTestsCtrlKnownField]
4096):
4098 @property
4099 def reference_data_basename(self) -> str:
4100 return "debian_tests_control_reference_data.yaml"
4102 def _new_field(
4103 self,
4104 name: str,
4105 field_value_type: FieldValueClass,
4106 ) -> F:
4107 return DTestsCtrlKnownField(name, field_value_type)
4109 def guess_stanza_classification_by_idx(self, stanza_idx: int) -> S:
4110 if stanza_idx >= 0: 4110 ↛ 4113line 4110 didn't jump to line 4113 because the condition on line 4110 was always true
4111 self.ensure_initialized()
4112 return _DTESTSCTRL_STANZA
4113 raise ValueError("The stanza_idx must be 0 or greater")
4115 def stanza_types(self) -> Iterable[S]:
4116 self.ensure_initialized()
4117 yield _DTESTSCTRL_STANZA
4119 def __getitem__(self, item: str) -> S:
4120 self.ensure_initialized()
4121 if item == "Tests": 4121 ↛ 4123line 4121 didn't jump to line 4123 because the condition on line 4121 was always true
4122 return _DTESTSCTRL_STANZA
4123 raise KeyError(item)
4126TRANSLATABLE_DEB822_FILE_METADATA: Sequence[
4127 Callable[[], Deb822FileMetadata[Any, Any]]
4128] = [
4129 DctrlFileMetadata,
4130 Dep5FileMetadata,
4131 DTestsCtrlFileMetadata,
4132]