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