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