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