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