Coverage for src/debputy/lsp/lsp_debian_control_reference_data.py: 84%
1402 statements
« prev ^ index » next coverage.py v7.8.2, created at 2026-04-19 20:37 +0000
« prev ^ index » next coverage.py v7.8.2, created at 2026-04-19 20:37 +0000
1import collections
2import dataclasses
3import functools
4import importlib.resources
5import itertools
6import operator
7import os.path
8import re
9import textwrap
10from abc import ABC
11from typing import (
12 FrozenSet,
13 Optional,
14 cast,
15 List,
16 Generic,
17 TypeVar,
18 Union,
19 Tuple,
20 Any,
21 Set,
22 TYPE_CHECKING,
23 Dict,
24 Self,
25)
26from collections.abc import Mapping, Iterable, Callable, Sequence, Iterator, Container
28from debian.debian_support import DpkgArchTable, Version
30import debputy.lsp.data.deb822_data as deb822_ref_data_dir
31from debputy.filesystem_scan import VirtualPathBase
32from debputy.linting.lint_util import LintState, with_range_in_continuous_parts
33from debputy.linting.lint_util import te_range_to_lsp
34from debputy.lsp.diagnostics import LintSeverity
35from debputy.lsp.lsp_reference_keyword import (
36 Keyword,
37 allowed_values,
38 format_comp_item_synopsis_doc,
39 LSP_DATA_DOMAIN,
40 ALL_PUBLIC_NAMED_STYLES_AS_KEYWORDS,
41)
42from debputy.lsp.quickfixes import (
43 propose_correct_text_quick_fix,
44 propose_remove_range_quick_fix,
45)
46from debputy.lsp.ref_models.deb822_reference_parse_models import (
47 Deb822ReferenceData,
48 DEB822_REFERENCE_DATA_PARSER,
49 FieldValueClass,
50 StaticValue,
51 Deb822Field,
52 UsageHint,
53 Alias,
54)
55from debputy.lsp.text_edit import apply_text_edits
56from debputy.lsp.text_util import (
57 normalize_dctrl_field_name,
58 LintCapablePositionCodec,
59 trim_end_of_line_whitespace,
60)
61from debian._deb822_repro.parsing import (
62 Deb822KeyValuePairElement,
63 LIST_SPACE_SEPARATED_INTERPRETATION,
64 Deb822ParagraphElement,
65 Deb822FileElement,
66 Interpretation,
67 parse_deb822_file,
68 Deb822ParsedTokenList,
69 Deb822ValueLineElement,
70)
71from debian._deb822_repro.tokens import (
72 Deb822FieldNameToken,
73)
74from debian._deb822_repro.types import FormatterCallback, TE
75from debputy.lsp.vendoring.wrap_and_sort import _sort_packages_key
76from debputy.lsprotocol.types import (
77 DiagnosticTag,
78 Range,
79 TextEdit,
80 Position,
81 CompletionItem,
82 MarkupContent,
83 CompletionItemTag,
84 MarkupKind,
85 CompletionItemKind,
86 CompletionItemLabelDetails,
87)
88from debputy.manifest_parser.exceptions import ManifestParseException
89from debputy.manifest_parser.util import AttributePath
90from debputy.path_matcher import BasenameGlobMatch
91from debputy.plugin.api import VirtualPath
92from debputy.util import PKGNAME_REGEX, _info, detect_possible_typo, _error
93from debputy.yaml import MANIFEST_YAML
95try:
96 from debian._deb822_repro.locatable import (
97 Position as TEPosition,
98 Range as TERange,
99 START_POSITION,
100 )
101except ImportError:
102 pass
105if TYPE_CHECKING:
106 from debputy.lsp.maint_prefs import EffectiveFormattingPreference
107 from debputy.lsp.debputy_ls import DebputyLanguageServer
110F = TypeVar("F", bound="Deb822KnownField", covariant=True)
111S = TypeVar("S", bound="StanzaMetadata")
114SUBSTVAR_RE = re.compile(r"[$][{][a-zA-Z0-9][a-zA-Z0-9-:]*[}]")
116_RE_SYNOPSIS_STARTS_WITH_ARTICLE = re.compile(r"^\s*(an?|the)(?:\s|$)", re.I)
117_RE_SV = re.compile(r"(\d+[.]\d+[.]\d+)([.]\d+)?")
118_RE_SYNOPSIS_IS_TEMPLATE = re.compile(
119 r"^\s*(missing|<insert up to \d+ chars description>)$"
120)
121_RE_SYNOPSIS_IS_TOO_SHORT = re.compile(r"^\s*(\S+)$")
122CURRENT_STANDARDS_VERSION = Version("4.7.2")
125CustomFieldCheck = Callable[
126 [
127 "F",
128 Deb822FileElement,
129 Deb822KeyValuePairElement,
130 "TERange",
131 "TERange",
132 Deb822ParagraphElement,
133 "TEPosition",
134 LintState,
135 ],
136 None,
137]
140@functools.lru_cache
141def all_package_relationship_fields() -> Mapping[str, str]:
142 # TODO: Pull from `dpkg-dev` when possible fallback only to the static list.
143 return {
144 f.lower(): f
145 for f in (
146 "Pre-Depends",
147 "Depends",
148 "Recommends",
149 "Suggests",
150 "Enhances",
151 "Conflicts",
152 "Breaks",
153 "Replaces",
154 "Provides",
155 "Built-Using",
156 "Static-Built-Using",
157 )
158 }
161@functools.lru_cache
162def all_source_relationship_fields() -> Mapping[str, str]:
163 # TODO: Pull from `dpkg-dev` when possible fallback only to the static list.
164 return {
165 f.lower(): f
166 for f in (
167 "Build-Depends",
168 "Build-Depends-Arch",
169 "Build-Depends-Indep",
170 "Build-Conflicts",
171 "Build-Conflicts-Arch",
172 "Build-Conflicts-Indep",
173 )
174 }
177ALL_SECTIONS_WITHOUT_COMPONENT = frozenset(
178 [
179 "admin",
180 "cli-mono",
181 "comm",
182 "database",
183 "debian-installer",
184 "debug",
185 "devel",
186 "doc",
187 "editors",
188 "education",
189 "electronics",
190 "embedded",
191 "fonts",
192 "games",
193 "gnome",
194 "gnu-r",
195 "gnustep",
196 "golang",
197 "graphics",
198 "hamradio",
199 "haskell",
200 "httpd",
201 "interpreters",
202 "introspection",
203 "java",
204 "javascript",
205 "kde",
206 "kernel",
207 "libdevel",
208 "libs",
209 "lisp",
210 "localization",
211 "mail",
212 "math",
213 "metapackages",
214 "misc",
215 "net",
216 "news",
217 "ocaml",
218 "oldlibs",
219 "otherosfs",
220 "perl",
221 "php",
222 "python",
223 "ruby",
224 "rust",
225 "science",
226 "shells",
227 "sound",
228 "tasks",
229 "tex",
230 "text",
231 "utils",
232 "vcs",
233 "video",
234 "virtual",
235 "web",
236 "x11",
237 "xfce",
238 "zope",
239 ]
240)
242ALL_COMPONENTS = frozenset(
243 [
244 "main",
245 "restricted", # Ubuntu
246 "non-free",
247 "non-free-firmware",
248 "contrib",
249 ]
250)
253def _fields(*fields: F) -> Mapping[str, F]:
254 return {normalize_dctrl_field_name(f.name.lower()): f for f in fields}
257def _complete_section_sort_hint(
258 keyword: Keyword,
259 _lint_state: LintState,
260 stanza_parts: Sequence[Deb822ParagraphElement],
261 _value_being_completed: str,
262) -> str | None:
263 for stanza in stanza_parts: 263 ↛ 268line 263 didn't jump to line 268 because the loop on line 263 didn't complete
264 pkg = stanza.get("Package")
265 if pkg is not None: 265 ↛ 263line 265 didn't jump to line 263 because the condition on line 265 was always true
266 break
267 else:
268 return None
269 section = package_name_to_section(pkg)
270 value_parts = keyword.value.rsplit("/", 1)
271 keyword_section = value_parts[-1]
272 keyword_component = f" ({value_parts[0]})" if len(value_parts) > 1 else ""
273 if section is None:
274 if keyword_component == "":
275 return keyword_section
276 return f"zz-{keyword_section}{keyword_component}"
277 if keyword_section != section:
278 return f"zz-{keyword_section}{keyword_component}"
279 return f"aa-{keyword_section}{keyword_component}"
282ALL_SECTIONS = allowed_values(
283 Keyword(
284 s if c is None else f"{c}/{s}",
285 sort_text=_complete_section_sort_hint,
286 replaced_by=s if c == "main" else None,
287 )
288 for c, s in itertools.product(
289 itertools.chain(cast("Iterable[Optional[str]]", [None]), ALL_COMPONENTS),
290 ALL_SECTIONS_WITHOUT_COMPONENT,
291 )
292)
295def all_architectures_and_wildcards(
296 arch2table, *, allow_negations: bool = False
297) -> Iterable[str | Keyword]:
298 wildcards = set()
299 yield Keyword(
300 "any",
301 is_exclusive=True,
302 synopsis="Built once per machine architecture (native code, such as C/C++, interpreter to C bindings)",
303 long_description=textwrap.dedent("""\
304 This is an architecture-dependent package, and needs to be
305 compiled for each and every architecture.
307 The name `any` refers to the fact 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 the fact 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 lint_state.emit_diagnostic(
3212 field_range,
3213 f"The {kvpair.field_name} is defined for use in the"
3214 f' "{confusable_with_stanza_name}" stanza. Please move it to the right place or remove it',
3215 "error",
3216 known_else_where.missing_field_authority,
3217 )
3218 continue
3219 await known_field.field_diagnostics(
3220 deb822_file,
3221 kvpair,
3222 stanza,
3223 stanza_position_in_file,
3224 kvpair_range_te,
3225 lint_state,
3226 field_name_typo_reported=field_name_typo_detected,
3227 )
3229 inherit_value = (
3230 inherit_from_stanza.get(field_name) if inherit_from_stanza else None
3231 )
3233 if (
3234 known_field.inheritable_from_other_stanza
3235 and inherit_value is not None
3236 and field_value == inherit_value
3237 ):
3238 quick_fix = propose_remove_range_quick_fix(
3239 proposed_title="Remove redundant definition"
3240 )
3241 lint_state.emit_diagnostic(
3242 kvpair_range_te,
3243 f"The field {field_name} duplicates the value from the Source stanza.",
3244 "informational",
3245 "debputy",
3246 quickfixes=[quick_fix],
3247 )
3248 for (
3249 field_name,
3250 normalized_field_name,
3251 field_range,
3252 duplicates,
3253 used_fields,
3254 ) in seen_fields.values():
3255 if not duplicates:
3256 continue
3257 if len(used_fields) != 1 or field_name not in used_fields:
3258 via_aliases_msg = " (via aliases)"
3259 else:
3260 via_aliases_msg = ""
3261 for dup_range in duplicates:
3262 lint_state.emit_diagnostic(
3263 dup_range,
3264 f'The field "{field_name}"{via_aliases_msg} was used multiple times in this stanza.'
3265 " Please ensure the field is only used once per stanza.",
3266 "error",
3267 "Policy 5.1",
3268 related_information=[
3269 lint_state.related_diagnostic_information(
3270 field_range,
3271 message=f"First definition of {field_name}",
3272 ),
3273 ],
3274 )
3276 def __getitem__(self, key: str) -> F:
3277 key_lc = key.lower()
3278 key_norm = normalize_dctrl_field_name(key_lc)
3279 return self.stanza_fields[key_norm]
3281 def __len__(self) -> int:
3282 return len(self.stanza_fields)
3284 def __iter__(self) -> Iterator[str]:
3285 return iter(self.stanza_fields.keys())
3287 def omitted_field_diagnostics(
3288 self,
3289 lint_state: LintState,
3290 deb822_file: Deb822FileElement,
3291 stanza: Deb822ParagraphElement,
3292 stanza_position: "TEPosition",
3293 *,
3294 inherit_from_stanza: Deb822ParagraphElement | None = None,
3295 representation_field_range: Range | None = None,
3296 ) -> None:
3297 if representation_field_range is None: 3297 ↛ 3298line 3297 didn't jump to line 3298 because the condition on line 3297 was never true
3298 _, representation_field_range = self.stanza_representation(
3299 stanza,
3300 stanza_position,
3301 )
3302 for known_field in self.stanza_fields.values():
3303 if known_field.name in stanza:
3304 continue
3306 known_field.field_omitted_diagnostics(
3307 deb822_file,
3308 representation_field_range,
3309 stanza,
3310 stanza_position,
3311 inherit_from_stanza,
3312 lint_state,
3313 )
3315 def _paragraph_representation_field(
3316 self,
3317 paragraph: Deb822ParagraphElement,
3318 ) -> Deb822KeyValuePairElement:
3319 return next(iter(paragraph.iter_parts_of_type(Deb822KeyValuePairElement)))
3321 def normalize_field_name(self, field_name: str) -> str:
3322 return field_name
3324 def stanza_representation(
3325 self,
3326 stanza: Deb822ParagraphElement,
3327 stanza_position: TEPosition,
3328 ) -> tuple[Deb822KeyValuePairElement, TERange]:
3329 representation_field = self._paragraph_representation_field(stanza)
3330 representation_field_range = representation_field.range_in_parent().relative_to(
3331 stanza_position
3332 )
3333 return representation_field, representation_field_range
3335 def reformat_stanza(
3336 self,
3337 effective_preference: "EffectiveFormattingPreference",
3338 stanza: Deb822ParagraphElement,
3339 stanza_range: TERange,
3340 formatter: FormatterCallback,
3341 position_codec: LintCapablePositionCodec,
3342 lines: list[str],
3343 ) -> Iterable[TextEdit]:
3344 for field_name in stanza:
3345 known_field = self.stanza_fields.get(field_name.lower())
3346 if known_field is None:
3347 continue
3348 kvpair = stanza.get_kvpair_element(field_name)
3349 yield from known_field.reformat_field(
3350 effective_preference,
3351 stanza_range,
3352 kvpair,
3353 formatter,
3354 position_codec,
3355 lines,
3356 )
3359@dataclasses.dataclass(slots=True)
3360class Dep5StanzaMetadata(StanzaMetadata[Deb822KnownField]):
3361 pass
3364@dataclasses.dataclass(slots=True)
3365class DctrlStanzaMetadata(StanzaMetadata[DctrlKnownField]):
3367 def normalize_field_name(self, field_name: str) -> str:
3368 return normalize_dctrl_field_name(field_name)
3371@dataclasses.dataclass(slots=True)
3372class DTestsCtrlStanzaMetadata(StanzaMetadata[DTestsCtrlKnownField]):
3374 def omitted_field_diagnostics(
3375 self,
3376 lint_state: LintState,
3377 deb822_file: Deb822FileElement,
3378 stanza: Deb822ParagraphElement,
3379 stanza_position: "TEPosition",
3380 *,
3381 inherit_from_stanza: Deb822ParagraphElement | None = None,
3382 representation_field_range: Range | None = None,
3383 ) -> None:
3384 if representation_field_range is None: 3384 ↛ 3385line 3384 didn't jump to line 3385 because the condition on line 3384 was never true
3385 _, representation_field_range = self.stanza_representation(
3386 stanza,
3387 stanza_position,
3388 )
3389 auth_ref = self.stanza_fields["tests"].missing_field_authority
3390 if "Tests" not in stanza and "Test-Command" not in stanza:
3391 lint_state.emit_diagnostic(
3392 representation_field_range,
3393 'Stanza must have either a "Tests" or a "Test-Command" field',
3394 "error",
3395 # TODO: Better authority_reference
3396 auth_ref,
3397 )
3398 if "Tests" in stanza and "Test-Command" in stanza:
3399 lint_state.emit_diagnostic(
3400 representation_field_range,
3401 'Stanza cannot have both a "Tests" and a "Test-Command" field',
3402 "error",
3403 # TODO: Better authority_reference
3404 auth_ref,
3405 )
3407 # Note that since we do not use the field names for stanza classification, we
3408 # always do the super call.
3409 super(DTestsCtrlStanzaMetadata, self).omitted_field_diagnostics(
3410 lint_state,
3411 deb822_file,
3412 stanza,
3413 stanza_position,
3414 representation_field_range=representation_field_range,
3415 inherit_from_stanza=inherit_from_stanza,
3416 )
3419@dataclasses.dataclass(slots=True)
3420class DebianWatchStanzaMetadata(StanzaMetadata[Deb822KnownField]):
3422 def omitted_field_diagnostics(
3423 self,
3424 lint_state: LintState,
3425 deb822_file: Deb822FileElement,
3426 stanza: Deb822ParagraphElement,
3427 stanza_position: "TEPosition",
3428 *,
3429 inherit_from_stanza: Deb822ParagraphElement | None = None,
3430 representation_field_range: Range | None = None,
3431 ) -> None:
3432 if representation_field_range is None: 3432 ↛ 3433line 3432 didn't jump to line 3433 because the condition on line 3432 was never true
3433 _, representation_field_range = self.stanza_representation(
3434 stanza,
3435 stanza_position,
3436 )
3438 if ( 3438 ↛ 3443line 3438 didn't jump to line 3443 because the condition on line 3438 was never true
3439 self.stanza_type_name != "Header"
3440 and "Source" not in stanza
3441 and "Template" not in stanza
3442 ):
3443 lint_state.emit_diagnostic(
3444 representation_field_range,
3445 'Stanza must have either a "Source" or a "Template" field',
3446 "error",
3447 # TODO: Better authority_reference
3448 "debputy",
3449 )
3450 # The required fields depends on which stanza it is. Therefore, we omit the super
3451 # call until this error is resolved.
3452 return
3454 super(DebianWatchStanzaMetadata, self).omitted_field_diagnostics(
3455 lint_state,
3456 deb822_file,
3457 stanza,
3458 stanza_position,
3459 representation_field_range=representation_field_range,
3460 inherit_from_stanza=inherit_from_stanza,
3461 )
3464def lsp_reference_data_dir() -> str:
3465 return os.path.join(
3466 os.path.dirname(__file__),
3467 "data",
3468 )
3471class Deb822FileMetadata(Generic[S, F]):
3473 def __init__(self) -> None:
3474 self._is_initialized = False
3475 self._data: Deb822ReferenceData | None = None
3477 @property
3478 def reference_data_basename(self) -> str:
3479 raise NotImplementedError
3481 def _new_field(
3482 self,
3483 name: str,
3484 field_value_type: FieldValueClass,
3485 ) -> F:
3486 raise NotImplementedError
3488 def _reference_data(self) -> Deb822ReferenceData:
3489 ref = self._data
3490 if ref is not None: 3490 ↛ 3491line 3490 didn't jump to line 3491 because the condition on line 3490 was never true
3491 return ref
3493 p = importlib.resources.files(deb822_ref_data_dir.__name__).joinpath(
3494 self.reference_data_basename
3495 )
3497 with p.open("r", encoding="utf-8") as fd:
3498 raw = MANIFEST_YAML.load(fd)
3500 attr_path = AttributePath.root_path(p)
3501 try:
3502 ref = DEB822_REFERENCE_DATA_PARSER.parse_input(raw, attr_path)
3503 except ManifestParseException as e:
3504 raise ValueError(
3505 f"Internal error: Could not parse reference data [{self.reference_data_basename}]: {e.message}"
3506 ) from e
3507 self._data = ref
3508 return ref
3510 @property
3511 def is_initialized(self) -> bool:
3512 return self._is_initialized
3514 def ensure_initialized(self) -> None:
3515 if self.is_initialized:
3516 return
3517 # Enables us to use __getitem__
3518 self._is_initialized = True
3519 ref_data = self._reference_data()
3520 ref_defs = ref_data.get("definitions")
3521 variables = {}
3522 ref_variables = ref_defs.get("variables", []) if ref_defs else []
3523 for ref_variable in ref_variables:
3524 name = ref_variable["name"]
3525 fallback = ref_variable["fallback"]
3526 variables[name] = fallback
3528 def _resolve_doc(template: str | None) -> str | None:
3529 if template is None: 3529 ↛ 3530line 3529 didn't jump to line 3530 because the condition on line 3529 was never true
3530 return None
3531 try:
3532 return template.format(**variables)
3533 except ValueError as e:
3534 template_escaped = template.replace("\n", "\\r")
3535 _error(f"Bad template: {template_escaped}: {e}")
3537 for ref_stanza_type in ref_data["stanza_types"]:
3538 stanza_name = ref_stanza_type["stanza_name"]
3539 stanza = self[stanza_name]
3540 stanza_fields = dict(stanza.stanza_fields)
3541 stanza.stanza_fields = stanza_fields
3542 for ref_field in ref_stanza_type["fields"]:
3543 _resolve_field(
3544 ref_field,
3545 stanza_fields,
3546 self._new_field,
3547 _resolve_doc,
3548 f"Stanza:{stanza.stanza_type_name}|Field:{ref_field['canonical_name']}",
3549 )
3551 def file_metadata_applies_to_file(
3552 self,
3553 deb822_file: Deb822FileElement | None,
3554 ) -> bool:
3555 return deb822_file is not None
3557 def classify_stanza(self, stanza: Deb822ParagraphElement, stanza_idx: int) -> S:
3558 return self.guess_stanza_classification_by_idx(stanza_idx)
3560 def guess_stanza_classification_by_idx(self, stanza_idx: int) -> S:
3561 raise NotImplementedError
3563 def stanza_types(self) -> Iterable[S]:
3564 raise NotImplementedError
3566 def __getitem__(self, item: str) -> S:
3567 raise NotImplementedError
3569 def get(self, item: str) -> S | None:
3570 try:
3571 return self[item]
3572 except KeyError:
3573 return None
3575 def reformat(
3576 self,
3577 effective_preference: "EffectiveFormattingPreference",
3578 deb822_file: Deb822FileElement,
3579 formatter: FormatterCallback,
3580 _content: str,
3581 position_codec: LintCapablePositionCodec,
3582 lines: list[str],
3583 ) -> Iterable[TextEdit]:
3584 stanza_idx = -1
3585 for token_or_element in deb822_file.iter_parts():
3586 if isinstance(token_or_element, Deb822ParagraphElement):
3587 stanza_range = token_or_element.range_in_parent()
3588 stanza_idx += 1
3589 stanza_metadata = self.classify_stanza(token_or_element, stanza_idx)
3590 yield from stanza_metadata.reformat_stanza(
3591 effective_preference,
3592 token_or_element,
3593 stanza_range,
3594 formatter,
3595 position_codec,
3596 lines,
3597 )
3598 else:
3599 token_range = token_or_element.range_in_parent()
3600 yield from trim_end_of_line_whitespace(
3601 position_codec,
3602 lines,
3603 line_range=range(
3604 token_range.start_pos.line_position,
3605 token_range.end_pos.line_position,
3606 ),
3607 )
3610_DCTRL_SOURCE_STANZA = DctrlStanzaMetadata(
3611 "Source",
3612 SOURCE_FIELDS,
3613 is_substvars_allowed_in_stanza=False,
3614)
3615_DCTRL_PACKAGE_STANZA = DctrlStanzaMetadata(
3616 "Package",
3617 BINARY_FIELDS,
3618 is_substvars_allowed_in_stanza=True,
3619)
3621_DEP5_HEADER_STANZA = Dep5StanzaMetadata(
3622 "Header",
3623 _DEP5_HEADER_FIELDS,
3624 is_substvars_allowed_in_stanza=False,
3625)
3626_DEP5_FILES_STANZA = Dep5StanzaMetadata(
3627 "Files",
3628 _DEP5_FILES_FIELDS,
3629 is_substvars_allowed_in_stanza=False,
3630)
3631_DEP5_LICENSE_STANZA = Dep5StanzaMetadata(
3632 "License",
3633 _DEP5_LICENSE_FIELDS,
3634 is_substvars_allowed_in_stanza=False,
3635)
3637_DTESTSCTRL_STANZA = DTestsCtrlStanzaMetadata(
3638 "Tests",
3639 _DTESTSCTRL_FIELDS,
3640 is_substvars_allowed_in_stanza=False,
3641)
3643_WATCH_HEADER_HEADER_STANZA = DebianWatchStanzaMetadata(
3644 "Header",
3645 _DWATCH_HEADER_FIELDS,
3646 is_substvars_allowed_in_stanza=False,
3647)
3648_WATCH_SOURCE_STANZA = DebianWatchStanzaMetadata(
3649 "Source",
3650 _DWATCH_SOURCE_FIELDS,
3651 is_substvars_allowed_in_stanza=False,
3652)
3655class Dep5FileMetadata(Deb822FileMetadata[Dep5StanzaMetadata, Deb822KnownField]):
3657 @property
3658 def reference_data_basename(self) -> str:
3659 return "debian_copyright_reference_data.yaml"
3661 def _new_field(
3662 self,
3663 name: str,
3664 field_value_type: FieldValueClass,
3665 ) -> F:
3666 return Deb822KnownField(name, field_value_type)
3668 def file_metadata_applies_to_file(
3669 self,
3670 deb822_file: Deb822FileElement | None,
3671 ) -> bool:
3672 if not super().file_metadata_applies_to_file(deb822_file): 3672 ↛ 3673line 3672 didn't jump to line 3673 because the condition on line 3672 was never true
3673 return False
3674 first_stanza = next(iter(deb822_file), None)
3675 if first_stanza is None or "Format" not in first_stanza:
3676 # No parseable stanzas or the first one did not have a Format, which is necessary.
3677 return False
3679 for part in deb822_file.iter_parts(): 3679 ↛ 3685line 3679 didn't jump to line 3685 because the loop on line 3679 didn't complete
3680 if part.is_error:
3681 # Error first, then it might just be a "Format:" in the middle of a free-text file.
3682 return False
3683 if part is first_stanza: 3683 ↛ 3679line 3683 didn't jump to line 3679 because the condition on line 3683 was always true
3684 break
3685 return True
3687 def classify_stanza(
3688 self,
3689 stanza: Deb822ParagraphElement,
3690 stanza_idx: int,
3691 ) -> Dep5StanzaMetadata:
3692 self.ensure_initialized()
3693 if stanza_idx == 0: 3693 ↛ 3694line 3693 didn't jump to line 3694 because the condition on line 3693 was never true
3694 return _DEP5_HEADER_STANZA
3695 if stanza_idx > 0: 3695 ↛ 3699line 3695 didn't jump to line 3699 because the condition on line 3695 was always true
3696 if "Files" in stanza:
3697 return _DEP5_FILES_STANZA
3698 return _DEP5_LICENSE_STANZA
3699 raise ValueError("The stanza_idx must be 0 or greater")
3701 def guess_stanza_classification_by_idx(self, stanza_idx: int) -> Dep5StanzaMetadata:
3702 self.ensure_initialized()
3703 if stanza_idx == 0:
3704 return _DEP5_HEADER_STANZA
3705 if stanza_idx > 0:
3706 return _DEP5_FILES_STANZA
3707 raise ValueError("The stanza_idx must be 0 or greater")
3709 def stanza_types(self) -> Iterable[Dep5StanzaMetadata]:
3710 self.ensure_initialized()
3711 # Order assumption made in the LSP code.
3712 yield _DEP5_HEADER_STANZA
3713 yield _DEP5_FILES_STANZA
3714 yield _DEP5_LICENSE_STANZA
3716 def __getitem__(self, item: str) -> Dep5StanzaMetadata:
3717 self.ensure_initialized()
3718 if item == "Header":
3719 return _DEP5_HEADER_STANZA
3720 if item == "Files":
3721 return _DEP5_FILES_STANZA
3722 if item == "License": 3722 ↛ 3724line 3722 didn't jump to line 3724 because the condition on line 3722 was always true
3723 return _DEP5_LICENSE_STANZA
3724 raise KeyError(item)
3727class DebianWatch5FileMetadata(
3728 Deb822FileMetadata[DebianWatchStanzaMetadata, Deb822KnownField]
3729):
3731 @property
3732 def reference_data_basename(self) -> str:
3733 return "debian_watch_reference_data.yaml"
3735 def _new_field(
3736 self,
3737 name: str,
3738 field_value_type: FieldValueClass,
3739 ) -> F:
3740 return Deb822KnownField(name, field_value_type)
3742 def file_metadata_applies_to_file(
3743 self, deb822_file: Deb822FileElement | None
3744 ) -> bool:
3745 if not super().file_metadata_applies_to_file(deb822_file): 3745 ↛ 3746line 3745 didn't jump to line 3746 because the condition on line 3745 was never true
3746 return False
3747 first_stanza = next(iter(deb822_file), None)
3749 if first_stanza is None or "Version" not in first_stanza: 3749 ↛ 3751line 3749 didn't jump to line 3751 because the condition on line 3749 was never true
3750 # No parseable stanzas or the first one did not have a Version field, which is necessary.
3751 return False
3753 try:
3754 if int(first_stanza.get("Version")) < 5: 3754 ↛ 3755line 3754 didn't jump to line 3755 because the condition on line 3754 was never true
3755 return False
3756 except (ValueError, IndexError, TypeError):
3757 return False
3759 for part in deb822_file.iter_parts(): 3759 ↛ 3765line 3759 didn't jump to line 3765 because the loop on line 3759 didn't complete
3760 if part.is_error: 3760 ↛ 3762line 3760 didn't jump to line 3762 because the condition on line 3760 was never true
3761 # Error first, then it might just be a "Version:" in the middle of a free-text file.
3762 return False
3763 if part is first_stanza: 3763 ↛ 3759line 3763 didn't jump to line 3759 because the condition on line 3763 was always true
3764 break
3765 return True
3767 def classify_stanza(
3768 self,
3769 stanza: Deb822ParagraphElement,
3770 stanza_idx: int,
3771 ) -> DebianWatchStanzaMetadata:
3772 self.ensure_initialized()
3773 if stanza_idx == 0:
3774 return _WATCH_HEADER_HEADER_STANZA
3775 if stanza_idx > 0: 3775 ↛ 3777line 3775 didn't jump to line 3777 because the condition on line 3775 was always true
3776 return _WATCH_SOURCE_STANZA
3777 raise ValueError("The stanza_idx must be 0 or greater")
3779 def guess_stanza_classification_by_idx(
3780 self, stanza_idx: int
3781 ) -> DebianWatchStanzaMetadata:
3782 self.ensure_initialized()
3783 if stanza_idx == 0: 3783 ↛ 3784line 3783 didn't jump to line 3784 because the condition on line 3783 was never true
3784 return _WATCH_HEADER_HEADER_STANZA
3785 if stanza_idx > 0: 3785 ↛ 3787line 3785 didn't jump to line 3787 because the condition on line 3785 was always true
3786 return _WATCH_SOURCE_STANZA
3787 raise ValueError("The stanza_idx must be 0 or greater")
3789 def stanza_types(self) -> Iterable[DebianWatchStanzaMetadata]:
3790 self.ensure_initialized()
3791 # Order assumption made in the LSP code.
3792 yield _WATCH_HEADER_HEADER_STANZA
3793 yield _WATCH_SOURCE_STANZA
3795 def __getitem__(self, item: str) -> DebianWatchStanzaMetadata:
3796 self.ensure_initialized()
3797 if item == "Header":
3798 return _WATCH_HEADER_HEADER_STANZA
3799 if item == "Source": 3799 ↛ 3801line 3799 didn't jump to line 3801 because the condition on line 3799 was always true
3800 return _WATCH_SOURCE_STANZA
3801 raise KeyError(item)
3804def _resolve_keyword(
3805 ref_value: StaticValue,
3806 known_values: dict[str, Keyword],
3807 resolve_template: Callable[[str | None], str | None],
3808 translation_context: str,
3809) -> None:
3810 value_key = ref_value["value"]
3811 changes = {
3812 "translation_context": translation_context,
3813 }
3814 try:
3815 known_value = known_values[value_key]
3816 except KeyError:
3817 known_value = Keyword(value_key)
3818 known_values[value_key] = known_value
3819 else:
3820 if known_value.is_alias_of: 3820 ↛ 3821line 3820 didn't jump to line 3821 because the condition on line 3820 was never true
3821 raise ValueError(
3822 f"The value {known_value.value} has an alias {known_value.is_alias_of} that conflicts with"
3823 f' {value_key} or the data file used an alias in its `canonical-name` rather than the "true" name'
3824 )
3825 value_doc = ref_value.get("documentation")
3826 if value_doc is not None:
3827 changes["synopsis"] = value_doc.get("synopsis")
3828 changes["long_description"] = resolve_template(
3829 value_doc.get("long_description")
3830 )
3831 if is_exclusive := ref_value.get("is_exclusive"):
3832 changes["is_exclusive"] = is_exclusive
3833 if (sort_key := ref_value.get("sort_key")) is not None:
3834 changes["sort_text"] = sort_key
3835 if (usage_hint := ref_value.get("usage_hint")) is not None:
3836 changes["usage_hint"] = usage_hint
3837 if changes: 3837 ↛ 3841line 3837 didn't jump to line 3841 because the condition on line 3837 was always true
3838 known_value = known_value.replace(**changes)
3839 known_values[value_key] = known_value
3841 _expand_aliases(
3842 known_value,
3843 known_values,
3844 operator.attrgetter("value"),
3845 ref_value.get("aliases"),
3846 "The value `{ALIAS}` is an alias of `{NAME}`.",
3847 )
3850def _resolve_field(
3851 ref_field: Deb822Field,
3852 stanza_fields: dict[str, F],
3853 field_constructor: Callable[[str, FieldValueClass], F],
3854 resolve_template: Callable[[str | None], str | None],
3855 translation_context: str,
3856) -> None:
3857 field_name = ref_field["canonical_name"]
3858 field_value_type = FieldValueClass.from_key(ref_field["field_value_type"])
3859 doc = ref_field.get("documentation")
3860 ref_values = ref_field.get("values", [])
3861 norm_field_name = normalize_dctrl_field_name(field_name.lower())
3863 try:
3864 field = stanza_fields[norm_field_name]
3865 except KeyError:
3866 field = field_constructor(
3867 field_name,
3868 field_value_type,
3869 )
3870 stanza_fields[norm_field_name] = field
3871 else:
3872 if field.name != field_name: 3872 ↛ 3873line 3872 didn't jump to line 3873 because the condition on line 3872 was never true
3873 _error(
3874 f'Error in reference data: Code uses "{field.name}" as canonical name and the data file'
3875 f" uses {field_name}. Please ensure the data is correctly aligned."
3876 )
3877 if field.field_value_class != field_value_type: 3877 ↛ 3878line 3877 didn't jump to line 3878 because the condition on line 3877 was never true
3878 _error(
3879 f'Error in reference data for field "{field.name}": Code has'
3880 f" {field.field_value_class.key} and the data file uses {field_value_type.key}"
3881 f" for field-value-type. Please ensure the data is correctly aligned."
3882 )
3883 if field.is_alias_of: 3883 ↛ 3884line 3883 didn't jump to line 3884 because the condition on line 3883 was never true
3884 raise ValueError(
3885 f"The field {field.name} has an alias {field.is_alias_of} that conflicts with"
3886 f' {field_name} or the data file used an alias in its `canonical-name` rather than the "true" name'
3887 )
3889 if doc is not None:
3890 field.synopsis = doc.get("synopsis")
3891 field.long_description = resolve_template(doc.get("long_description"))
3893 field.default_value = ref_field.get("default_value")
3894 field.warn_if_default = ref_field.get("warn_if_default", True)
3895 field.spellcheck_value = ref_field.get("spellcheck_value", False)
3896 field.deprecated_with_no_replacement = ref_field.get(
3897 "is_obsolete_without_replacement", False
3898 )
3899 field.replaced_by = ref_field.get("replaced_by")
3900 field.translation_context = translation_context
3901 field.usage_hint = ref_field.get("usage_hint")
3902 field.missing_field_severity = ref_field.get("missing_field_severity", None)
3903 unknown_value_severity = ref_field.get("unknown_value_severity", "error")
3904 field.unknown_value_severity = (
3905 None if unknown_value_severity == "none" else unknown_value_severity
3906 )
3907 field.unknown_value_authority = ref_field.get("unknown_value_authority", "debputy")
3908 field.missing_field_authority = ref_field.get("missing_field_authority", "debputy")
3909 field.is_substvars_disabled_even_if_allowed_by_stanza = not ref_field.get(
3910 "supports_substvars",
3911 True,
3912 )
3913 field.inheritable_from_other_stanza = ref_field.get(
3914 "inheritable_from_other_stanza",
3915 False,
3916 )
3918 known_values = field.known_values
3919 if known_values is None:
3920 known_values = {}
3921 else:
3922 known_values = dict(known_values)
3924 for ref_value in ref_values:
3925 _resolve_keyword(ref_value, known_values, resolve_template, translation_context)
3927 if known_values:
3928 field.known_values = known_values
3930 _expand_aliases(
3931 field,
3932 stanza_fields,
3933 operator.attrgetter("name"),
3934 ref_field.get("aliases"),
3935 "The field `{ALIAS}` is an alias of `{NAME}`.",
3936 )
3939A = TypeVar("A", Keyword, Deb822KnownField)
3942def _expand_aliases(
3943 item: A,
3944 item_container: dict[str, A],
3945 canonical_name_resolver: Callable[[A], str],
3946 aliases_ref: list[Alias] | None,
3947 doc_template: str,
3948) -> None:
3949 if aliases_ref is None:
3950 return
3951 name = canonical_name_resolver(item)
3952 assert name is not None, "canonical_name_resolver is not allowed to return None"
3953 for alias_ref in aliases_ref:
3954 alias_name = alias_ref["alias"]
3955 alias_doc = item.long_description
3956 is_completion_suggestion = alias_ref.get("is_completion_suggestion", False)
3957 doc_suffix = doc_template.format(NAME=name, ALIAS=alias_name)
3958 if alias_doc:
3959 alias_doc += f"\n\n{doc_suffix}"
3960 else:
3961 alias_doc = doc_suffix
3962 alias_field = item.replace(
3963 long_description=alias_doc,
3964 is_alias_of=name,
3965 is_completion_suggestion=is_completion_suggestion,
3966 )
3967 alias_key = alias_name.lower()
3968 if alias_name in item_container: 3968 ↛ 3969line 3968 didn't jump to line 3969 because the condition on line 3968 was never true
3969 existing_name = canonical_name_resolver(item_container[alias_key])
3970 assert (
3971 existing_name is not None
3972 ), "canonical_name_resolver is not allowed to return None"
3973 raise ValueError(
3974 f"The value {name} has an alias {alias_name} that conflicts with {existing_name}"
3975 )
3976 item_container[alias_key] = alias_field
3979class DctrlFileMetadata(Deb822FileMetadata[DctrlStanzaMetadata, DctrlKnownField]):
3981 @property
3982 def reference_data_basename(self) -> str:
3983 return "debian_control_reference_data.yaml"
3985 def _new_field(
3986 self,
3987 name: str,
3988 field_value_type: FieldValueClass,
3989 ) -> F:
3990 return DctrlKnownField(name, field_value_type)
3992 def guess_stanza_classification_by_idx(
3993 self,
3994 stanza_idx: int,
3995 ) -> DctrlStanzaMetadata:
3996 self.ensure_initialized()
3997 if stanza_idx == 0:
3998 return _DCTRL_SOURCE_STANZA
3999 if stanza_idx > 0: 3999 ↛ 4001line 3999 didn't jump to line 4001 because the condition on line 3999 was always true
4000 return _DCTRL_PACKAGE_STANZA
4001 raise ValueError("The stanza_idx must be 0 or greater")
4003 def stanza_types(self) -> Iterable[DctrlStanzaMetadata]:
4004 self.ensure_initialized()
4005 # Order assumption made in the LSP code.
4006 yield _DCTRL_SOURCE_STANZA
4007 yield _DCTRL_PACKAGE_STANZA
4009 def __getitem__(self, item: str) -> DctrlStanzaMetadata:
4010 self.ensure_initialized()
4011 if item == "Source":
4012 return _DCTRL_SOURCE_STANZA
4013 if item == "Package": 4013 ↛ 4015line 4013 didn't jump to line 4015 because the condition on line 4013 was always true
4014 return _DCTRL_PACKAGE_STANZA
4015 raise KeyError(item)
4017 def reformat(
4018 self,
4019 effective_preference: "EffectiveFormattingPreference",
4020 deb822_file: Deb822FileElement,
4021 formatter: FormatterCallback,
4022 content: str,
4023 position_codec: LintCapablePositionCodec,
4024 lines: list[str],
4025 ) -> Iterable[TextEdit]:
4026 edits = list(
4027 super().reformat(
4028 effective_preference,
4029 deb822_file,
4030 formatter,
4031 content,
4032 position_codec,
4033 lines,
4034 )
4035 )
4037 if ( 4037 ↛ 4042line 4037 didn't jump to line 4042 because the condition on line 4037 was always true
4038 not effective_preference.deb822_normalize_stanza_order
4039 or deb822_file.find_first_error_element() is not None
4040 ):
4041 return edits
4042 names = []
4043 for idx, stanza in enumerate(deb822_file):
4044 if idx < 2:
4045 continue
4046 name = stanza.get("Package")
4047 if name is None:
4048 return edits
4049 names.append(name)
4051 reordered = sorted(names)
4052 if names == reordered:
4053 return edits
4055 if edits:
4056 content = apply_text_edits(content, lines, edits)
4057 lines = content.splitlines(keepends=True)
4058 deb822_file = parse_deb822_file(
4059 lines,
4060 accept_files_with_duplicated_fields=True,
4061 accept_files_with_error_tokens=True,
4062 )
4064 stanzas = list(deb822_file)
4065 reordered_stanza = stanzas[:2] + sorted(
4066 stanzas[2:], key=operator.itemgetter("Package")
4067 )
4068 bits = []
4069 stanza_idx = 0
4070 for token_or_element in deb822_file.iter_parts():
4071 if isinstance(token_or_element, Deb822ParagraphElement):
4072 bits.append(reordered_stanza[stanza_idx].dump())
4073 stanza_idx += 1
4074 else:
4075 bits.append(token_or_element.convert_to_text())
4077 new_content = "".join(bits)
4079 return [
4080 TextEdit(
4081 Range(
4082 Position(0, 0),
4083 Position(len(lines) + 1, 0),
4084 ),
4085 new_content,
4086 )
4087 ]
4090class DTestsCtrlFileMetadata(
4091 Deb822FileMetadata[DTestsCtrlStanzaMetadata, DTestsCtrlKnownField]
4092):
4094 @property
4095 def reference_data_basename(self) -> str:
4096 return "debian_tests_control_reference_data.yaml"
4098 def _new_field(
4099 self,
4100 name: str,
4101 field_value_type: FieldValueClass,
4102 ) -> F:
4103 return DTestsCtrlKnownField(name, field_value_type)
4105 def guess_stanza_classification_by_idx(self, stanza_idx: int) -> S:
4106 if stanza_idx >= 0: 4106 ↛ 4109line 4106 didn't jump to line 4109 because the condition on line 4106 was always true
4107 self.ensure_initialized()
4108 return _DTESTSCTRL_STANZA
4109 raise ValueError("The stanza_idx must be 0 or greater")
4111 def stanza_types(self) -> Iterable[S]:
4112 self.ensure_initialized()
4113 yield _DTESTSCTRL_STANZA
4115 def __getitem__(self, item: str) -> S:
4116 self.ensure_initialized()
4117 if item == "Tests": 4117 ↛ 4119line 4117 didn't jump to line 4119 because the condition on line 4117 was always true
4118 return _DTESTSCTRL_STANZA
4119 raise KeyError(item)
4122TRANSLATABLE_DEB822_FILE_METADATA: Sequence[
4123 Callable[[], Deb822FileMetadata[Any, Any]]
4124] = [
4125 DctrlFileMetadata,
4126 Dep5FileMetadata,
4127 DTestsCtrlFileMetadata,
4128]