Coverage for src/debputy/lsp/lsp_debian_control_reference_data.py: 83%

1401 statements  

« prev     ^ index     » next       coverage.py v7.8.2, created at 2026-02-14 10:41 +0000

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 

27 

28from debian.debian_support import DpkgArchTable, Version 

29 

30import debputy.lsp.data.deb822_data as deb822_ref_data_dir 

31from debputy.filesystem_scan import VirtualPathBase 

32from debputy.linting.lint_util import LintState, with_range_in_continuous_parts 

33from debputy.linting.lint_util import te_range_to_lsp 

34from debputy.lsp.diagnostics import LintSeverity 

35from debputy.lsp.lsp_reference_keyword import ( 

36 Keyword, 

37 allowed_values, 

38 format_comp_item_synopsis_doc, 

39 LSP_DATA_DOMAIN, 

40 ALL_PUBLIC_NAMED_STYLES_AS_KEYWORDS, 

41) 

42from debputy.lsp.quickfixes import ( 

43 propose_correct_text_quick_fix, 

44 propose_remove_range_quick_fix, 

45) 

46from debputy.lsp.ref_models.deb822_reference_parse_models import ( 

47 Deb822ReferenceData, 

48 DEB822_REFERENCE_DATA_PARSER, 

49 FieldValueClass, 

50 StaticValue, 

51 Deb822Field, 

52 UsageHint, 

53 Alias, 

54) 

55from debputy.lsp.text_edit import apply_text_edits 

56from debputy.lsp.text_util import ( 

57 normalize_dctrl_field_name, 

58 LintCapablePositionCodec, 

59 trim_end_of_line_whitespace, 

60) 

61from debputy.lsp.vendoring._deb822_repro.parsing import ( 

62 Deb822KeyValuePairElement, 

63 LIST_SPACE_SEPARATED_INTERPRETATION, 

64 Deb822ParagraphElement, 

65 Deb822FileElement, 

66 Interpretation, 

67 parse_deb822_file, 

68 Deb822ParsedTokenList, 

69 Deb822ValueLineElement, 

70) 

71from debputy.lsp.vendoring._deb822_repro.tokens import ( 

72 Deb822FieldNameToken, 

73) 

74from debputy.lsp.vendoring._deb822_repro.types import FormatterCallback 

75from debputy.lsp.vendoring._deb822_repro.types import TE 

76from debputy.lsp.vendoring.wrap_and_sort import _sort_packages_key 

77from debputy.lsprotocol.types import ( 

78 DiagnosticTag, 

79 Range, 

80 TextEdit, 

81 Position, 

82 CompletionItem, 

83 MarkupContent, 

84 CompletionItemTag, 

85 MarkupKind, 

86 CompletionItemKind, 

87 CompletionItemLabelDetails, 

88) 

89from debputy.manifest_parser.exceptions import ManifestParseException 

90from debputy.manifest_parser.util import AttributePath 

91from debputy.path_matcher import BasenameGlobMatch 

92from debputy.plugin.api import VirtualPath 

93from debputy.util import PKGNAME_REGEX, _info, detect_possible_typo, _error 

94from debputy.yaml import MANIFEST_YAML 

95 

96try: 

97 from debputy.lsp.vendoring._deb822_repro.locatable import ( 

98 Position as TEPosition, 

99 Range as TERange, 

100 START_POSITION, 

101 ) 

102except ImportError: 

103 pass 

104 

105 

106if TYPE_CHECKING: 

107 from debputy.lsp.maint_prefs import EffectiveFormattingPreference 

108 from debputy.lsp.debputy_ls import DebputyLanguageServer 

109 

110 

111F = TypeVar("F", bound="Deb822KnownField", covariant=True) 

112S = TypeVar("S", bound="StanzaMetadata") 

113 

114 

115SUBSTVAR_RE = re.compile(r"[$][{][a-zA-Z0-9][a-zA-Z0-9-:]*[}]") 

116 

117_RE_SYNOPSIS_STARTS_WITH_ARTICLE = re.compile(r"^\s*(an?|the)(?:\s|$)", re.I) 

118_RE_SV = re.compile(r"(\d+[.]\d+[.]\d+)([.]\d+)?") 

119_RE_SYNOPSIS_IS_TEMPLATE = re.compile( 

120 r"^\s*(missing|<insert up to \d+ chars description>)$" 

121) 

122_RE_SYNOPSIS_IS_TOO_SHORT = re.compile(r"^\s*(\S+)$") 

123CURRENT_STANDARDS_VERSION = Version("4.7.2") 

124 

125 

126CustomFieldCheck = Callable[ 

127 [ 

128 "F", 

129 Deb822FileElement, 

130 Deb822KeyValuePairElement, 

131 "TERange", 

132 "TERange", 

133 Deb822ParagraphElement, 

134 "TEPosition", 

135 LintState, 

136 ], 

137 None, 

138] 

139 

140 

141@functools.lru_cache 

142def all_package_relationship_fields() -> Mapping[str, str]: 

143 # TODO: Pull from `dpkg-dev` when possible fallback only to the static list. 

144 return { 

145 f.lower(): f 

146 for f in ( 

147 "Pre-Depends", 

148 "Depends", 

149 "Recommends", 

150 "Suggests", 

151 "Enhances", 

152 "Conflicts", 

153 "Breaks", 

154 "Replaces", 

155 "Provides", 

156 "Built-Using", 

157 "Static-Built-Using", 

158 ) 

159 } 

160 

161 

162@functools.lru_cache 

163def all_source_relationship_fields() -> Mapping[str, str]: 

164 # TODO: Pull from `dpkg-dev` when possible fallback only to the static list. 

165 return { 

166 f.lower(): f 

167 for f in ( 

168 "Build-Depends", 

169 "Build-Depends-Arch", 

170 "Build-Depends-Indep", 

171 "Build-Conflicts", 

172 "Build-Conflicts-Arch", 

173 "Build-Conflicts-Indep", 

174 ) 

175 } 

176 

177 

178ALL_SECTIONS_WITHOUT_COMPONENT = frozenset( 

179 [ 

180 "admin", 

181 "cli-mono", 

182 "comm", 

183 "database", 

184 "debian-installer", 

185 "debug", 

186 "devel", 

187 "doc", 

188 "editors", 

189 "education", 

190 "electronics", 

191 "embedded", 

192 "fonts", 

193 "games", 

194 "gnome", 

195 "gnu-r", 

196 "gnustep", 

197 "golang", 

198 "graphics", 

199 "hamradio", 

200 "haskell", 

201 "httpd", 

202 "interpreters", 

203 "introspection", 

204 "java", 

205 "javascript", 

206 "kde", 

207 "kernel", 

208 "libdevel", 

209 "libs", 

210 "lisp", 

211 "localization", 

212 "mail", 

213 "math", 

214 "metapackages", 

215 "misc", 

216 "net", 

217 "news", 

218 "ocaml", 

219 "oldlibs", 

220 "otherosfs", 

221 "perl", 

222 "php", 

223 "python", 

224 "ruby", 

225 "rust", 

226 "science", 

227 "shells", 

228 "sound", 

229 "tasks", 

230 "tex", 

231 "text", 

232 "utils", 

233 "vcs", 

234 "video", 

235 "virtual", 

236 "web", 

237 "x11", 

238 "xfce", 

239 "zope", 

240 ] 

241) 

242 

243ALL_COMPONENTS = frozenset( 

244 [ 

245 "main", 

246 "restricted", # Ubuntu 

247 "non-free", 

248 "non-free-firmware", 

249 "contrib", 

250 ] 

251) 

252 

253 

254def _fields(*fields: F) -> Mapping[str, F]: 

255 return {normalize_dctrl_field_name(f.name.lower()): f for f in fields} 

256 

257 

258def _complete_section_sort_hint( 

259 keyword: Keyword, 

260 _lint_state: LintState, 

261 stanza_parts: Sequence[Deb822ParagraphElement], 

262 _value_being_completed: str, 

263) -> str | None: 

264 for stanza in stanza_parts: 264 ↛ 269line 264 didn't jump to line 269 because the loop on line 264 didn't complete

265 pkg = stanza.get("Package") 

266 if pkg is not None: 266 ↛ 264line 266 didn't jump to line 264 because the condition on line 266 was always true

267 break 

268 else: 

269 return None 

270 section = package_name_to_section(pkg) 

271 value_parts = keyword.value.rsplit("/", 1) 

272 keyword_section = value_parts[-1] 

273 keyword_component = f" ({value_parts[0]})" if len(value_parts) > 1 else "" 

274 if section is None: 

275 if keyword_component == "": 

276 return keyword_section 

277 return f"zz-{keyword_section}{keyword_component}" 

278 if keyword_section != section: 

279 return f"zz-{keyword_section}{keyword_component}" 

280 return f"aa-{keyword_section}{keyword_component}" 

281 

282 

283ALL_SECTIONS = allowed_values( 

284 Keyword( 

285 s if c is None else f"{c}/{s}", 

286 sort_text=_complete_section_sort_hint, 

287 replaced_by=s if c == "main" else None, 

288 ) 

289 for c, s in itertools.product( 

290 itertools.chain(cast("Iterable[Optional[str]]", [None]), ALL_COMPONENTS), 

291 ALL_SECTIONS_WITHOUT_COMPONENT, 

292 ) 

293) 

294 

295 

296def all_architectures_and_wildcards( 

297 arch2table, *, allow_negations: bool = False 

298) -> Iterable[str | Keyword]: 

299 wildcards = set() 

300 yield Keyword( 

301 "any", 

302 is_exclusive=True, 

303 synopsis="Built once per machine architecture (native code, such as C/C++, interpreter to C bindings)", 

304 long_description=textwrap.dedent( 

305 """\ 

306 This is an architecture-dependent package, and needs to be 

307 compiled for each and every architecture. 

308 

309 The name `any` refers to the fact that this is an architecture 

310 *wildcard* matching *any machine architecture* supported by 

311 dpkg. 

312 """ 

313 ), 

314 ) 

315 yield Keyword( 

316 "all", 

317 is_exclusive=True, 

318 synopsis="Independent of machine architecture (scripts, data, documentation, or Java without JNI)", 

319 long_description=textwrap.dedent( 

320 """\ 

321 The package is an architecture independent package. This is 

322 typically appropriate for packages containing only scripts, 

323 data or documentation. 

324 

325 The name `all` refers to the fact that the same build of a package 

326 can be used for *all* architectures. Though note that it is still 

327 subject to the rules of the `Multi-Arch` field. 

328 """ 

329 ), 

330 ) 

331 for arch_name, quad_tuple in arch2table.items(): 

332 yield arch_name 

333 if allow_negations: 

334 yield f"!{arch_name}" 

335 cpu_wc = "any-" + quad_tuple.cpu_name 

336 os_wc = quad_tuple.os_name + "-any" 

337 if cpu_wc not in wildcards: 

338 yield cpu_wc 

339 if allow_negations: 

340 yield f"!{cpu_wc}" 

341 wildcards.add(cpu_wc) 

342 if os_wc not in wildcards: 

343 yield os_wc 

344 if allow_negations: 

345 yield f"!{os_wc}" 

346 wildcards.add(os_wc) 

347 # Add the remaining wildcards 

348 

349 

350@functools.lru_cache 

351def dpkg_arch_and_wildcards(*, allow_negations=False) -> frozenset[str | Keyword]: 

352 dpkg_arch_table = DpkgArchTable.load_arch_table() 

353 return frozenset( 

354 all_architectures_and_wildcards( 

355 dpkg_arch_table._arch2table, 

356 allow_negations=allow_negations, 

357 ) 

358 ) 

359 

360 

361def extract_first_value_and_position( 

362 kvpair: Deb822KeyValuePairElement, 

363 stanza_pos: "TEPosition", 

364 *, 

365 interpretation: Interpretation[ 

366 Deb822ParsedTokenList[Any, Any] 

367 ] = LIST_SPACE_SEPARATED_INTERPRETATION, 

368) -> tuple[str | None, TERange | None]: 

369 kvpair_pos = kvpair.position_in_parent().relative_to(stanza_pos) 

370 value_element_pos = kvpair.value_element.position_in_parent().relative_to( 

371 kvpair_pos 

372 ) 

373 for value_ref in kvpair.interpret_as(interpretation).iter_value_references(): 373 ↛ 380line 373 didn't jump to line 380 because the loop on line 373 didn't complete

374 v = value_ref.value 

375 section_value_loc = value_ref.locatable 

376 value_range_te = section_value_loc.range_in_parent().relative_to( 

377 value_element_pos 

378 ) 

379 return v, value_range_te 

380 return None, None 

381 

382 

383def _sv_field_validation( 

384 known_field: "F", 

385 _deb822_file: Deb822FileElement, 

386 kvpair: Deb822KeyValuePairElement, 

387 _kvpair_range: "TERange", 

388 _field_name_range_te: "TERange", 

389 _stanza: Deb822ParagraphElement, 

390 stanza_position: "TEPosition", 

391 lint_state: LintState, 

392) -> None: 

393 sv_value, sv_value_range = extract_first_value_and_position( 

394 kvpair, 

395 stanza_position, 

396 ) 

397 m = _RE_SV.fullmatch(sv_value) 

398 if m is None: 

399 lint_state.emit_diagnostic( 

400 sv_value_range, 

401 f'Not a valid standards version. Current version is "{CURRENT_STANDARDS_VERSION}"', 

402 "warning", 

403 known_field.unknown_value_authority, 

404 ) 

405 return 

406 

407 sv_version = Version(sv_value) 

408 if sv_version < CURRENT_STANDARDS_VERSION: 

409 lint_state.emit_diagnostic( 

410 sv_value_range, 

411 f"Latest Standards-Version is {CURRENT_STANDARDS_VERSION}", 

412 "informational", 

413 known_field.unknown_value_authority, 

414 ) 

415 return 

416 extra = m.group(2) 

417 if extra: 

418 extra_len = lint_state.position_codec.client_num_units(extra) 

419 lint_state.emit_diagnostic( 

420 TERange.between( 

421 TEPosition( 

422 sv_value_range.end_pos.line_position, 

423 sv_value_range.end_pos.cursor_position - extra_len, 

424 ), 

425 sv_value_range.end_pos, 

426 ), 

427 "Unnecessary version segment. This part of the version is only used for editorial changes", 

428 "informational", 

429 known_field.unknown_value_authority, 

430 quickfixes=[ 

431 propose_remove_range_quick_fix( 

432 proposed_title="Remove unnecessary version part" 

433 ) 

434 ], 

435 ) 

436 

437 

438def _dctrl_ma_field_validation( 

439 _known_field: "F", 

440 _deb822_file: Deb822FileElement, 

441 _kvpair: Deb822KeyValuePairElement, 

442 _kvpair_range: "TERange", 

443 _field_name_range: "TERange", 

444 stanza: Deb822ParagraphElement, 

445 stanza_position: "TEPosition", 

446 lint_state: LintState, 

447) -> None: 

448 ma_kvpair = stanza.get_kvpair_element(("Multi-Arch", 0), use_get=True) 

449 arch = stanza.get("Architecture", "any") 

450 if arch == "all" and ma_kvpair is not None: 450 ↛ exitline 450 didn't return from function '_dctrl_ma_field_validation' because the condition on line 450 was always true

451 ma_value, ma_value_range = extract_first_value_and_position( 

452 ma_kvpair, 

453 stanza_position, 

454 ) 

455 if ma_value == "same": 

456 lint_state.emit_diagnostic( 

457 ma_value_range, 

458 "Multi-Arch: same is not valid for Architecture: all packages. Maybe you want foreign?", 

459 "error", 

460 "debputy", 

461 ) 

462 

463 

464def _udeb_only_field_validation( 

465 known_field: "F", 

466 _deb822_file: Deb822FileElement, 

467 _kvpair: Deb822KeyValuePairElement, 

468 _kvpair_range: "TERange", 

469 field_name_range: "TERange", 

470 stanza: Deb822ParagraphElement, 

471 _stanza_position: "TEPosition", 

472 lint_state: LintState, 

473) -> None: 

474 package_type = stanza.get("Package-Type") 

475 if package_type != "udeb": 

476 lint_state.emit_diagnostic( 

477 field_name_range, 

478 f"The {known_field.name} field is only applicable to udeb packages (`Package-Type: udeb`)", 

479 "warning", 

480 "debputy", 

481 ) 

482 

483 

484def _complete_only_in_arch_dep_pkgs( 

485 stanza_parts: Iterable[Deb822ParagraphElement], 

486) -> bool: 

487 for stanza in stanza_parts: 

488 arch = stanza.get("Architecture") 

489 if arch is None: 

490 continue 

491 archs = arch.split() 

492 return "all" not in archs 

493 return False 

494 

495 

496def _complete_only_for_udeb_pkgs( 

497 stanza_parts: Iterable[Deb822ParagraphElement], 

498) -> bool: 

499 for stanza in stanza_parts: 

500 for option in ("Package-Type", "XC-Package-Type"): 

501 pkg_type = stanza.get(option) 

502 if pkg_type is not None: 

503 return pkg_type == "udeb" 

504 return False 

505 

506 

507def _arch_not_all_only_field_validation( 

508 known_field: "F", 

509 _deb822_file: Deb822FileElement, 

510 _kvpair: Deb822KeyValuePairElement, 

511 _kvpair_range_te: "TERange", 

512 field_name_range_te: "TERange", 

513 stanza: Deb822ParagraphElement, 

514 _stanza_position: "TEPosition", 

515 lint_state: LintState, 

516) -> None: 

517 architecture = stanza.get("Architecture") 

518 if architecture == "all": 518 ↛ exitline 518 didn't return from function '_arch_not_all_only_field_validation' because the condition on line 518 was always true

519 lint_state.emit_diagnostic( 

520 field_name_range_te, 

521 f"The {known_field.name} field is not applicable to arch:all packages (`Architecture: all`)", 

522 "warning", 

523 "debputy", 

524 ) 

525 

526 

527def _binary_package_from_same_source( 

528 known_field: "F", 

529 _deb822_file: Deb822FileElement, 

530 _kvpair: Deb822KeyValuePairElement, 

531 kvpair_range: "TERange", 

532 _field_name_range: "TERange", 

533 stanza: Deb822ParagraphElement, 

534 stanza_position: "TEPosition", 

535 lint_state: LintState, 

536) -> None: 

537 doc_main_package_kvpair = stanza.get_kvpair_element( 

538 (known_field.name, 0), use_get=True 

539 ) 

540 if len(lint_state.binary_packages) == 1: 

541 lint_state.emit_diagnostic( 

542 kvpair_range, 

543 f"The {known_field.name} field is redundant for source packages that only build one binary package", 

544 "warning", 

545 "debputy", 

546 quickfixes=[propose_remove_range_quick_fix()], 

547 ) 

548 return 

549 if doc_main_package_kvpair is not None: 549 ↛ exitline 549 didn't return from function '_binary_package_from_same_source' because the condition on line 549 was always true

550 doc_main_package, value_range = extract_first_value_and_position( 

551 doc_main_package_kvpair, 

552 stanza_position, 

553 ) 

554 if doc_main_package is None or doc_main_package in lint_state.binary_packages: 

555 return 

556 lint_state.emit_diagnostic( 

557 value_range, 

558 f"The {known_field.name} field must name a package listed in debian/control", 

559 "error", 

560 "debputy", 

561 quickfixes=[ 

562 propose_correct_text_quick_fix(name) 

563 for name in lint_state.binary_packages 

564 ], 

565 ) 

566 

567 

568def _single_line_span_range_relative_to_pos( 

569 span: tuple[int, int], 

570 relative_to: "TEPosition", 

571) -> Range: 

572 return TERange( 

573 TEPosition( 

574 relative_to.line_position, 

575 relative_to.cursor_position + span[0], 

576 ), 

577 TEPosition( 

578 relative_to.line_position, 

579 relative_to.cursor_position + span[1], 

580 ), 

581 ) 

582 

583 

584def _check_extended_description_line( 

585 description_value_line: Deb822ValueLineElement, 

586 description_line_range_te: "TERange", 

587 package: str | None, 

588 lint_state: LintState, 

589) -> None: 

590 if description_value_line.comment_element is not None: 590 ↛ 593line 590 didn't jump to line 593 because the condition on line 590 was never true

591 # TODO: Fix this limitation (we get the content and the range wrong with comments. 

592 # They are rare inside a Description, so this is a 80:20 trade off 

593 return 

594 description_line_with_leading_space = ( 

595 description_value_line.convert_to_text().rstrip() 

596 ) 

597 try: 

598 idx = description_line_with_leading_space.index( 

599 "<insert long description, indented with spaces>" 

600 ) 

601 except ValueError: 

602 pass 

603 else: 

604 template_span = idx, idx + len( 

605 "<insert long description, indented with spaces>" 

606 ) 

607 lint_state.emit_diagnostic( 

608 _single_line_span_range_relative_to_pos( 

609 template_span, 

610 description_line_range_te.start_pos, 

611 ), 

612 "Unfilled or left-over template from dh_make", 

613 "error", 

614 "debputy", 

615 ) 

616 if len(description_line_with_leading_space) > 80: 

617 # Policy says nothing here, but lintian has 80 characters as hard limit and that 

618 # probably matches the limitation of package manager UIs (TUIs/GUIs) somewhere. 

619 # 

620 # See also debputy#122 

621 span = 80, len(description_line_with_leading_space) 

622 lint_state.emit_diagnostic( 

623 _single_line_span_range_relative_to_pos( 

624 span, 

625 description_line_range_te.start_pos, 

626 ), 

627 "Package description line is too long; please line wrap it.", 

628 "warning", 

629 "debputy", 

630 ) 

631 

632 

633def _check_synopsis( 

634 synopsis_value_line: Deb822ValueLineElement, 

635 synopsis_range_te: "TERange", 

636 field_name_range_te: "TERange", 

637 package: str | None, 

638 lint_state: LintState, 

639) -> None: 

640 # This function would compute range would be wrong if there is a comment 

641 assert synopsis_value_line.comment_element is None 

642 synopsis_text_with_leading_space = synopsis_value_line.convert_to_text().rstrip() 

643 if not synopsis_text_with_leading_space: 

644 lint_state.emit_diagnostic( 

645 field_name_range_te, 

646 "Package synopsis is missing", 

647 "warning", 

648 "debputy", 

649 ) 

650 return 

651 synopsis_text_trimmed = synopsis_text_with_leading_space.lstrip() 

652 synopsis_offset = len(synopsis_text_with_leading_space) - len(synopsis_text_trimmed) 

653 starts_with_article = _RE_SYNOPSIS_STARTS_WITH_ARTICLE.search( 

654 synopsis_text_with_leading_space 

655 ) 

656 # TODO: Handle ${...} expansion 

657 if starts_with_article: 

658 lint_state.emit_diagnostic( 

659 _single_line_span_range_relative_to_pos( 

660 starts_with_article.span(1), 

661 synopsis_range_te.start_pos, 

662 ), 

663 "Package synopsis starts with an article (a/an/the).", 

664 "warning", 

665 "DevRef 6.2.2", 

666 ) 

667 if len(synopsis_text_trimmed) >= 80: 

668 # Policy says `certainly under 80 characters.`, so exactly 80 characters is considered bad too. 

669 span = synopsis_offset + 79, len(synopsis_text_with_leading_space) 

670 lint_state.emit_diagnostic( 

671 _single_line_span_range_relative_to_pos( 

672 span, 

673 synopsis_range_te.start_pos, 

674 ), 

675 "Package synopsis is too long.", 

676 "warning", 

677 "Policy 3.4.1", 

678 ) 

679 if template_match := _RE_SYNOPSIS_IS_TEMPLATE.match( 

680 synopsis_text_with_leading_space 

681 ): 

682 lint_state.emit_diagnostic( 

683 _single_line_span_range_relative_to_pos( 

684 template_match.span(1), 

685 synopsis_range_te.start_pos, 

686 ), 

687 "Package synopsis is a placeholder", 

688 "warning", 

689 "debputy", 

690 ) 

691 elif too_short_match := _RE_SYNOPSIS_IS_TOO_SHORT.match( 

692 synopsis_text_with_leading_space 

693 ): 

694 if not SUBSTVAR_RE.match(synopsis_text_with_leading_space.strip()): 

695 lint_state.emit_diagnostic( 

696 _single_line_span_range_relative_to_pos( 

697 too_short_match.span(1), 

698 synopsis_range_te.start_pos, 

699 ), 

700 "Package synopsis is too short", 

701 "warning", 

702 "debputy", 

703 ) 

704 

705 

706def dctrl_description_validator( 

707 _known_field: "F", 

708 _deb822_file: Deb822FileElement, 

709 kvpair: Deb822KeyValuePairElement, 

710 kvpair_range_te: "TERange", 

711 _field_name_range: "TERange", 

712 stanza: Deb822ParagraphElement, 

713 _stanza_position: "TEPosition", 

714 lint_state: LintState, 

715) -> None: 

716 value_lines = kvpair.value_element.value_lines 

717 if not value_lines: 717 ↛ 718line 717 didn't jump to line 718 because the condition on line 717 was never true

718 return 

719 package = stanza.get("Package") 

720 synopsis_value_line = value_lines[0] 

721 value_range_te = kvpair.value_element.range_in_parent().relative_to( 

722 kvpair_range_te.start_pos 

723 ) 

724 synopsis_line_range_te = synopsis_value_line.range_in_parent().relative_to( 

725 value_range_te.start_pos 

726 ) 

727 if synopsis_value_line.continuation_line_token is None: 727 ↛ 740line 727 didn't jump to line 740 because the condition on line 727 was always true

728 field_name_range_te = kvpair.field_token.range_in_parent().relative_to( 

729 kvpair_range_te.start_pos 

730 ) 

731 _check_synopsis( 

732 synopsis_value_line, 

733 synopsis_line_range_te, 

734 field_name_range_te, 

735 package, 

736 lint_state, 

737 ) 

738 description_lines = value_lines[1:] 

739 else: 

740 description_lines = value_lines 

741 for description_line in description_lines: 

742 description_line_range_te = description_line.range_in_parent().relative_to( 

743 value_range_te.start_pos 

744 ) 

745 _check_extended_description_line( 

746 description_line, 

747 description_line_range_te, 

748 package, 

749 lint_state, 

750 ) 

751 

752 

753def _has_packaging_expected_file( 

754 name: str, 

755 msg: str, 

756 severity: LintSeverity = "error", 

757) -> CustomFieldCheck: 

758 

759 def _impl( 

760 _known_field: "F", 

761 _deb822_file: Deb822FileElement, 

762 _kvpair: Deb822KeyValuePairElement, 

763 kvpair_range_te: "TERange", 

764 _field_name_range_te: "TERange", 

765 _stanza: Deb822ParagraphElement, 

766 _stanza_position: "TEPosition", 

767 lint_state: LintState, 

768 ) -> None: 

769 debian_dir = lint_state.debian_dir 

770 if debian_dir is None: 

771 return 

772 cpy = debian_dir.lookup(name) 

773 if not cpy: 773 ↛ 774line 773 didn't jump to line 774 because the condition on line 773 was never true

774 lint_state.emit_diagnostic( 

775 kvpair_range_te, 

776 msg, 

777 severity, 

778 "debputy", 

779 diagnostic_applies_to_another_file=f"debian/{name}", 

780 ) 

781 

782 return _impl 

783 

784 

785_check_missing_debian_rules = _has_packaging_expected_file( 

786 "rules", 

787 'Missing debian/rules when the Build-Driver is unset or set to "debian-rules"', 

788) 

789 

790 

791def _has_build_instructions( 

792 known_field: "F", 

793 deb822_file: Deb822FileElement, 

794 kvpair: Deb822KeyValuePairElement, 

795 kvpair_range_te: "TERange", 

796 field_name_range_te: "TERange", 

797 stanza: Deb822ParagraphElement, 

798 stanza_position: "TEPosition", 

799 lint_state: LintState, 

800) -> None: 

801 if stanza.get("Build-Driver", "debian-rules").lower() != "debian-rules": 

802 return 

803 

804 _check_missing_debian_rules( 

805 known_field, 

806 deb822_file, 

807 kvpair, 

808 kvpair_range_te, 

809 field_name_range_te, 

810 stanza, 

811 stanza_position, 

812 lint_state, 

813 ) 

814 

815 

816def _canonical_maintainer_name( 

817 known_field: "F", 

818 _deb822_file: Deb822FileElement, 

819 kvpair: Deb822KeyValuePairElement, 

820 kvpair_range_te: "TERange", 

821 _field_name_range_te: "TERange", 

822 _stanza: Deb822ParagraphElement, 

823 _stanza_position: "TEPosition", 

824 lint_state: LintState, 

825) -> None: 

826 value_element_pos = kvpair.value_element.position_in_parent().relative_to( 

827 kvpair_range_te.start_pos 

828 ) 

829 try: 

830 interpreted_value = kvpair.interpret_as( 

831 known_field.field_value_class.interpreter() 

832 ) 

833 except ValueError: 

834 return 

835 

836 for part in interpreted_value.iter_parts(): 

837 if part.is_separator or part.is_whitespace or part.is_whitespace: 

838 continue 

839 name_and_email = part.convert_to_text() 

840 try: 

841 email_start = name_and_email.rindex("<") 

842 email_end = name_and_email.rindex(">") 

843 email = name_and_email[email_start + 1 : email_end] 

844 except IndexError: 

845 continue 

846 

847 pref = lint_state.maint_preference_table.maintainer_preferences.get(email) 

848 if pref is None or not pref.canonical_name: 

849 continue 

850 

851 expected = f"{pref.canonical_name} <{email}>" 

852 if expected == name_and_email: 852 ↛ 853line 852 didn't jump to line 853 because the condition on line 852 was never true

853 continue 

854 value_range_te = part.range_in_parent().relative_to(value_element_pos) 

855 lint_state.emit_diagnostic( 

856 value_range_te, 

857 "Non-canonical or incorrect spelling of maintainer name", 

858 "informational", 

859 "debputy", 

860 quickfixes=[propose_correct_text_quick_fix(expected)], 

861 ) 

862 

863 

864def _maintainer_field_validator( 

865 known_field: "F", 

866 _deb822_file: Deb822FileElement, 

867 kvpair: Deb822KeyValuePairElement, 

868 kvpair_range_te: "TERange", 

869 _field_name_range_te: "TERange", 

870 _stanza: Deb822ParagraphElement, 

871 _stanza_position: "TEPosition", 

872 lint_state: LintState, 

873) -> None: 

874 

875 value_element_pos = kvpair.value_element.position_in_parent().relative_to( 

876 kvpair_range_te.start_pos 

877 ) 

878 interpreted_value = kvpair.interpret_as(known_field.field_value_class.interpreter()) 

879 for part in interpreted_value.iter_parts(): 

880 if not part.is_separator: 

881 continue 

882 value_range_te = part.range_in_parent().relative_to(value_element_pos) 

883 severity = known_field.unknown_value_severity 

884 assert severity is not None 

885 # TODO: Check for a follow up maintainer and based on that the quick fix is either 

886 # to remove the dead separator OR move the trailing data into `Uploaders` 

887 lint_state.emit_diagnostic( 

888 value_range_te, 

889 'The "Maintainer" field has a trailing separator, but it is a single value field.', 

890 severity, 

891 known_field.unknown_value_authority, 

892 ) 

893 

894 

895def _use_https_instead_of_http( 

896 known_field: "F", 

897 _deb822_file: Deb822FileElement, 

898 kvpair: Deb822KeyValuePairElement, 

899 kvpair_range_te: "TERange", 

900 _field_name_range_te: "TERange", 

901 _stanza: Deb822ParagraphElement, 

902 _stanza_position: "TEPosition", 

903 lint_state: LintState, 

904) -> None: 

905 value_element_pos = kvpair.value_element.position_in_parent().relative_to( 

906 kvpair_range_te.start_pos 

907 ) 

908 interpreted_value = kvpair.interpret_as(known_field.field_value_class.interpreter()) 

909 for part in interpreted_value.iter_parts(): 

910 value = part.convert_to_text() 

911 if not value.startswith("http://"): 

912 continue 

913 value_range_te = part.range_in_parent().relative_to(value_element_pos) 

914 problem_range_te = TERange.between( 

915 value_range_te.start_pos, 

916 TEPosition( 

917 value_range_te.start_pos.line_position, 

918 value_range_te.start_pos.cursor_position + 7, 

919 ), 

920 ) 

921 lint_state.emit_diagnostic( 

922 problem_range_te, 

923 "The Format URL should use https:// rather than http://", 

924 "warning", 

925 "debputy", 

926 quickfixes=[propose_correct_text_quick_fix("https://")], 

927 ) 

928 

929 

930def _each_value_match_regex_validation( 

931 regex: re.Pattern, 

932 *, 

933 diagnostic_severity: LintSeverity = "error", 

934 authority_reference: str | None = None, 

935) -> CustomFieldCheck: 

936 

937 def _validator( 

938 known_field: "F", 

939 _deb822_file: Deb822FileElement, 

940 kvpair: Deb822KeyValuePairElement, 

941 kvpair_range_te: "TERange", 

942 _field_name_range_te: "TERange", 

943 _stanza: Deb822ParagraphElement, 

944 _stanza_position: "TEPosition", 

945 lint_state: LintState, 

946 ) -> None: 

947 nonlocal authority_reference 

948 interpreter = known_field.field_value_class.interpreter() 

949 if interpreter is None: 

950 raise AssertionError( 

951 f"{known_field.name} has field type {known_field.field_value_class}, which cannot be" 

952 f" regex validated since it does not have a tokenization" 

953 ) 

954 auth_ref = ( 

955 authority_reference 

956 if authority_reference is not None 

957 else known_field.unknown_value_authority 

958 ) 

959 

960 value_element_pos = kvpair.value_element.position_in_parent().relative_to( 

961 kvpair_range_te.start_pos 

962 ) 

963 for value_ref in kvpair.interpret_as(interpreter).iter_value_references(): 

964 v = value_ref.value 

965 m = regex.fullmatch(v) 

966 if m is not None: 

967 continue 

968 

969 if "${" in v: 

970 # Ignore substvars 

971 continue 

972 

973 section_value_loc = value_ref.locatable 

974 value_range_te = section_value_loc.range_in_parent().relative_to( 

975 value_element_pos 

976 ) 

977 lint_state.emit_diagnostic( 

978 value_range_te, 

979 f'The value "{v}" does not match the regex {regex.pattern}.', 

980 diagnostic_severity, 

981 auth_ref, 

982 ) 

983 

984 return _validator 

985 

986 

987_DEP_OR_RELATION = re.compile(r"[|]") 

988_DEP_RELATION_CLAUSE = re.compile( 

989 r""" 

990 ^ 

991 \s* 

992 (?P<name_arch_qual>[-+.a-zA-Z0-9${}:]{2,}) 

993 \s* 

994 (?: [(] \s* (?P<operator>>>|>=|>|=|<|<=|<<) \s* (?P<version> [0-9$][^)]*|[$][{]\S+[}]) \s* [)] \s* )? 

995 (?: \[ (?P<arch_restriction> [\s!\w\-]+) ] \s*)? 

996 (?: < (?P<build_profile_restriction> .+ ) > \s*)? 

997 ((?P<garbage>\S.*)\s*)? 

998 $ 

999""", 

1000 re.VERBOSE | re.MULTILINE, 

1001) 

1002 

1003 

1004def _span_to_te_range( 

1005 text: str, 

1006 start_pos: int, 

1007 end_pos: int, 

1008) -> TERange: 

1009 prefix = text[0:start_pos] 

1010 prefix_plus_text = text[0:end_pos] 

1011 

1012 start_line = prefix.count("\n") 

1013 if start_line: 

1014 start_newline_offset = prefix.rindex("\n") 

1015 # +1 to skip past the newline 

1016 start_cursor_pos = start_pos - (start_newline_offset + 1) 

1017 else: 

1018 start_cursor_pos = start_pos 

1019 

1020 end_line = prefix_plus_text.count("\n") 

1021 if end_line == start_line: 

1022 end_cursor_pos = start_cursor_pos + (end_pos - start_pos) 

1023 else: 

1024 end_newline_offset = prefix_plus_text.rindex("\n") 

1025 end_cursor_pos = end_pos - (end_newline_offset + 1) 

1026 

1027 return TERange( 

1028 TEPosition( 

1029 start_line, 

1030 start_cursor_pos, 

1031 ), 

1032 TEPosition( 

1033 end_line, 

1034 end_cursor_pos, 

1035 ), 

1036 ) 

1037 

1038 

1039def _split_w_spans( 

1040 v: str, 

1041 sep: str, 

1042 *, 

1043 offset: int = 0, 

1044) -> Sequence[tuple[str, int, int]]: 

1045 separator_size = len(sep) 

1046 parts = v.split(sep) 

1047 for part in parts: 

1048 size = len(part) 

1049 end_offset = offset + size 

1050 yield part, offset, end_offset 

1051 offset = end_offset + separator_size 

1052 

1053 

1054_COLLAPSE_WHITESPACE = re.compile(r"\s+") 

1055 

1056 

1057def _cleanup_rel(rel: str) -> str: 

1058 return _COLLAPSE_WHITESPACE.sub(" ", rel.strip()) 

1059 

1060 

1061def _text_to_te_position(text: str) -> "TEPosition": 

1062 newlines = text.count("\n") 

1063 if not newlines: 

1064 return TEPosition( 

1065 newlines, 

1066 len(text), 

1067 ) 

1068 last_newline_offset = text.rindex("\n") 

1069 line_offset = len(text) - (last_newline_offset + 1) 

1070 return TEPosition( 

1071 newlines, 

1072 line_offset, 

1073 ) 

1074 

1075 

1076@dataclasses.dataclass(slots=True, frozen=True) 

1077class Relation: 

1078 name: str 

1079 arch_qual: str | None = None 

1080 version_operator: str | None = None 

1081 version: str | None = None 

1082 arch_restriction: str | None = None 

1083 build_profile_restriction: str | None = None 

1084 # These offsets are intended to show the relation itself. They are not 

1085 # the relation boundary offsets (they will omit leading whitespace as 

1086 # an example). 

1087 content_display_offset: int = -1 

1088 content_display_end_offset: int = -1 

1089 

1090 

1091def relation_key_variations( 

1092 relation: Relation, 

1093) -> tuple[str, str | None, str | None]: 

1094 operator_variants = ( 

1095 [relation.version_operator, None] 

1096 if relation.version_operator is not None 

1097 else [None] 

1098 ) 

1099 arch_qual_variants = ( 

1100 [relation.arch_qual, None] 

1101 if relation.arch_qual is not None and relation.arch_qual != "any" 

1102 else [None] 

1103 ) 

1104 for arch_qual, version_operator in itertools.product( 

1105 arch_qual_variants, 

1106 operator_variants, 

1107 ): 

1108 yield relation.name, arch_qual, version_operator 

1109 

1110 

1111def dup_check_relations( 

1112 known_field: "F", 

1113 relations: Sequence[Relation], 

1114 raw_value_masked_comments: str, 

1115 value_element_pos: "TEPosition", 

1116 lint_state: LintState, 

1117) -> None: 

1118 overlap_table = {} 

1119 for relation in relations: 

1120 version_operator = relation.version_operator 

1121 arch_qual = relation.arch_qual 

1122 if relation.arch_restriction or relation.build_profile_restriction: 1122 ↛ 1123line 1122 didn't jump to line 1123 because the condition on line 1122 was never true

1123 continue 

1124 

1125 for relation_key in relation_key_variations(relation): 

1126 prev_relation = overlap_table.get(relation_key) 

1127 if prev_relation is None: 

1128 overlap_table[relation_key] = relation 

1129 else: 

1130 prev_version_operator = prev_relation.version_operator 

1131 

1132 if ( 

1133 prev_version_operator 

1134 and version_operator 

1135 and prev_version_operator[0] != version_operator[0] 

1136 and version_operator[0] in ("<", ">") 

1137 and prev_version_operator[0] in ("<", ">") 

1138 ): 

1139 # foo (>= 1), foo (<< 2) and similar should not trigger a warning. 

1140 continue 

1141 

1142 prev_arch_qual = prev_relation.arch_qual 

1143 if ( 

1144 arch_qual != prev_arch_qual 

1145 and prev_arch_qual != "any" 

1146 and arch_qual != "any" 

1147 ): 

1148 # foo:amd64 != foo:native and that might matter - especially for "libfoo-dev, libfoo-dev:native" 

1149 # 

1150 # This check is probably a too forgiving in some corner cases. 

1151 continue 

1152 

1153 orig_relation_range = TERange( 

1154 _text_to_te_position( 

1155 raw_value_masked_comments[ 

1156 : prev_relation.content_display_offset 

1157 ] 

1158 ), 

1159 _text_to_te_position( 

1160 raw_value_masked_comments[ 

1161 : prev_relation.content_display_end_offset 

1162 ] 

1163 ), 

1164 ).relative_to(value_element_pos) 

1165 

1166 duplicate_relation_range = TERange( 

1167 _text_to_te_position( 

1168 raw_value_masked_comments[: relation.content_display_offset] 

1169 ), 

1170 _text_to_te_position( 

1171 raw_value_masked_comments[: relation.content_display_end_offset] 

1172 ), 

1173 ).relative_to(value_element_pos) 

1174 

1175 lint_state.emit_diagnostic( 

1176 duplicate_relation_range, 

1177 "Duplicate relationship. Merge with the previous relationship", 

1178 "warning", 

1179 known_field.unknown_value_authority, 

1180 related_information=[ 

1181 lint_state.related_diagnostic_information( 

1182 orig_relation_range, 

1183 "The previous definition", 

1184 ), 

1185 ], 

1186 ) 

1187 # We only emit for the first duplicate "key" for each relation. Odds are remaining 

1188 # keys point to the same match. Even if they do not, it does not really matter as 

1189 # we already pointed out an issue for the user to follow up on. 

1190 break 

1191 

1192 

1193def _dctrl_check_dep_version_operator( 

1194 known_field: "F", 

1195 version_operator: str, 

1196 version_operator_span: tuple[int, int], 

1197 version_operators: frozenset[str], 

1198 raw_value_masked_comments: str, 

1199 offset: int, 

1200 value_element_pos: "TEPosition", 

1201 lint_state: LintState, 

1202) -> bool: 

1203 if ( 

1204 version_operators 

1205 and version_operator is not None 

1206 and version_operator not in version_operators 

1207 ): 

1208 v_start_offset = offset + version_operator_span[0] 

1209 v_end_offset = offset + version_operator_span[1] 

1210 version_problem_range_te = TERange( 

1211 _text_to_te_position(raw_value_masked_comments[:v_start_offset]), 

1212 _text_to_te_position(raw_value_masked_comments[:v_end_offset]), 

1213 ).relative_to(value_element_pos) 

1214 

1215 sorted_version_operators = sorted(version_operators) 

1216 

1217 excluding_equal = f"{version_operator}{version_operator}" 

1218 including_equal = f"{version_operator}=" 

1219 

1220 if version_operator in (">", "<") and ( 

1221 excluding_equal in version_operators or including_equal in version_operators 

1222 ): 

1223 lint_state.emit_diagnostic( 

1224 version_problem_range_te, 

1225 f'Obsolete version operator "{version_operator}" that is no longer supported.', 

1226 "error", 

1227 "Policy 7.1", 

1228 quickfixes=[ 

1229 propose_correct_text_quick_fix(n) 

1230 for n in (excluding_equal, including_equal) 

1231 if not version_operators or n in version_operators 

1232 ], 

1233 ) 

1234 else: 

1235 lint_state.emit_diagnostic( 

1236 version_problem_range_te, 

1237 f'The version operator "{version_operator}" is not allowed in {known_field.name}', 

1238 "error", 

1239 known_field.unknown_value_authority, 

1240 quickfixes=[ 

1241 propose_correct_text_quick_fix(n) for n in sorted_version_operators 

1242 ], 

1243 ) 

1244 return True 

1245 return False 

1246 

1247 

1248def _dctrl_validate_dep( 

1249 known_field: "DF", 

1250 _deb822_file: Deb822FileElement, 

1251 kvpair: Deb822KeyValuePairElement, 

1252 kvpair_range_te: "TERange", 

1253 _field_name_range: "TERange", 

1254 _stanza: Deb822ParagraphElement, 

1255 _stanza_position: "TEPosition", 

1256 lint_state: LintState, 

1257) -> None: 

1258 value_element_pos = kvpair.value_element.position_in_parent().relative_to( 

1259 kvpair_range_te.start_pos 

1260 ) 

1261 raw_value_with_comments = kvpair.value_element.convert_to_text() 

1262 raw_value_masked_comments = "".join( 

1263 (line if not line.startswith("#") else (" " * (len(line) - 1)) + "\n") 

1264 for line in raw_value_with_comments.splitlines(keepends=True) 

1265 ) 

1266 if isinstance(known_field, DctrlRelationshipKnownField): 

1267 version_operators = known_field.allowed_version_operators 

1268 supports_or_relation = known_field.supports_or_relation 

1269 else: 

1270 version_operators = frozenset({">>", ">=", "=", "<=", "<<"}) 

1271 supports_or_relation = True 

1272 

1273 relation_dup_table = collections.defaultdict(list) 

1274 

1275 for rel, rel_offset, rel_end_offset in _split_w_spans( 

1276 raw_value_masked_comments, "," 

1277 ): 

1278 sub_relations = [] 

1279 for or_rel, offset, end_offset in _split_w_spans(rel, "|", offset=rel_offset): 

1280 if or_rel.isspace(): 

1281 continue 

1282 if sub_relations and not supports_or_relation: 

1283 separator_range_te = TERange( 

1284 _text_to_te_position(raw_value_masked_comments[: offset - 1]), 

1285 _text_to_te_position(raw_value_masked_comments[:offset]), 

1286 ).relative_to(value_element_pos) 

1287 lint_state.emit_diagnostic( 

1288 separator_range_te, 

1289 f'The field {known_field.name} does not support "|" (OR) in relations.', 

1290 "error", 

1291 known_field.unknown_value_authority, 

1292 ) 

1293 m = _DEP_RELATION_CLAUSE.fullmatch(or_rel) 

1294 

1295 if m is not None: 

1296 garbage = m.group("garbage") 

1297 version_operator = m.group("operator") 

1298 version_operator_span = m.span("operator") 

1299 if _dctrl_check_dep_version_operator( 

1300 known_field, 

1301 version_operator, 

1302 version_operator_span, 

1303 version_operators, 

1304 raw_value_masked_comments, 

1305 offset, 

1306 value_element_pos, 

1307 lint_state, 

1308 ): 

1309 sub_relations.append(Relation("<BROKEN>")) 

1310 else: 

1311 name_arch_qual = m.group("name_arch_qual") 

1312 if ":" in name_arch_qual: 

1313 name, arch_qual = name_arch_qual.split(":", 1) 

1314 else: 

1315 name = name_arch_qual 

1316 arch_qual = None 

1317 sub_relations.append( 

1318 Relation( 

1319 name, 

1320 arch_qual=arch_qual, 

1321 version_operator=version_operator, 

1322 version=m.group("version"), 

1323 arch_restriction=m.group("build_profile_restriction"), 

1324 build_profile_restriction=m.group( 

1325 "build_profile_restriction" 

1326 ), 

1327 content_display_offset=offset + m.start("name_arch_qual"), 

1328 # TODO: This should be trimmed in the end. 

1329 content_display_end_offset=rel_end_offset, 

1330 ) 

1331 ) 

1332 else: 

1333 garbage = None 

1334 sub_relations.append(Relation("<BROKEN>")) 

1335 

1336 if m is not None and not garbage: 

1337 continue 

1338 if m is not None: 

1339 garbage_span = m.span("garbage") 

1340 garbage_start, garbage_end = garbage_span 

1341 error_start_offset = offset + garbage_start 

1342 error_end_offset = offset + garbage_end 

1343 garbage_part = raw_value_masked_comments[ 

1344 error_start_offset:error_end_offset 

1345 ] 

1346 else: 

1347 garbage_part = None 

1348 error_start_offset = offset 

1349 error_end_offset = end_offset 

1350 

1351 problem_range_te = TERange( 

1352 _text_to_te_position(raw_value_masked_comments[:error_start_offset]), 

1353 _text_to_te_position(raw_value_masked_comments[:error_end_offset]), 

1354 ).relative_to(value_element_pos) 

1355 

1356 if garbage_part is not None: 

1357 if _DEP_RELATION_CLAUSE.fullmatch(garbage_part) is not None: 

1358 msg = ( 

1359 "Trailing data after a relationship that might be a second relationship." 

1360 " Is a separator missing before this part?" 

1361 ) 

1362 else: 

1363 msg = "Parse error of the relationship. Either a syntax error or a missing separator somewhere." 

1364 lint_state.emit_diagnostic( 

1365 problem_range_te, 

1366 msg, 

1367 "error", 

1368 known_field.unknown_value_authority, 

1369 ) 

1370 else: 

1371 dep = _cleanup_rel( 

1372 raw_value_masked_comments[error_start_offset:error_end_offset] 

1373 ) 

1374 lint_state.emit_diagnostic( 

1375 problem_range_te, 

1376 f'Could not parse "{dep}" as a dependency relation.', 

1377 "error", 

1378 known_field.unknown_value_authority, 

1379 ) 

1380 if ( 

1381 len(sub_relations) == 1 

1382 and (relation := sub_relations[0]).name != "<BROKEN>" 

1383 ): 

1384 # We ignore OR-relations in the dup-check for now. We also skip relations with problems. 

1385 relation_dup_table[relation.name].append(relation) 

1386 

1387 for relations in relation_dup_table.values(): 

1388 if len(relations) > 1: 

1389 dup_check_relations( 

1390 known_field, 

1391 relations, 

1392 raw_value_masked_comments, 

1393 value_element_pos, 

1394 lint_state, 

1395 ) 

1396 

1397 

1398def _rrr_build_driver_mismatch( 

1399 _known_field: "F", 

1400 _deb822_file: Deb822FileElement, 

1401 _kvpair: Deb822KeyValuePairElement, 

1402 kvpair_range_te: "TERange", 

1403 _field_name_range: "TERange", 

1404 stanza: Deb822ParagraphElement, 

1405 _stanza_position: "TEPosition", 

1406 lint_state: LintState, 

1407) -> None: 

1408 dr = stanza.get("Build-Driver", "debian-rules") 

1409 if dr != "debian-rules": 

1410 lint_state.emit_diagnostic( 

1411 kvpair_range_te, 

1412 f'The Rules-Requires-Root field is irrelevant for the Build-Driver "{dr}".', 

1413 "informational", 

1414 "debputy", 

1415 quickfixes=[ 

1416 propose_remove_range_quick_fix( 

1417 proposed_title="Remove Rules-Requires-Root" 

1418 ) 

1419 ], 

1420 ) 

1421 

1422 

1423class Dep5Matcher(BasenameGlobMatch): 

1424 def __init__(self, basename_glob: str) -> None: 

1425 super().__init__( 

1426 basename_glob, 

1427 only_when_in_directory=None, 

1428 path_type=None, 

1429 recursive_match=False, 

1430 ) 

1431 

1432 

1433def _match_dep5_segment( 

1434 current_dir: VirtualPathBase, basename_glob: str 

1435) -> Iterable[VirtualPathBase]: 

1436 if "*" in basename_glob or "?" in basename_glob: 

1437 return Dep5Matcher(basename_glob).finditer(current_dir) 

1438 else: 

1439 res = current_dir.get(basename_glob) 

1440 if res is None: 

1441 return tuple() 

1442 return (res,) 

1443 

1444 

1445_RE_SLASHES = re.compile(r"//+") 

1446 

1447 

1448def _dep5_unnecessary_symbols( 

1449 value: str, 

1450 value_range: TERange, 

1451 lint_state: LintState, 

1452) -> None: 

1453 slash_check_index = 0 

1454 if value.startswith(("./", "/")): 

1455 prefix_len = 1 if value[0] == "/" else 2 

1456 if value[prefix_len - 1 : prefix_len + 2].startswith("//"): 1456 ↛ 1460line 1456 didn't jump to line 1460 because the condition on line 1456 was always true

1457 _, slashes_end = _RE_SLASHES.search(value).span() 

1458 prefix_len = slashes_end 

1459 

1460 slash_check_index = prefix_len 

1461 prefix_range = TERange( 

1462 value_range.start_pos, 

1463 TEPosition( 

1464 value_range.start_pos.line_position, 

1465 value_range.start_pos.cursor_position + prefix_len, 

1466 ), 

1467 ) 

1468 lint_state.emit_diagnostic( 

1469 prefix_range, 

1470 f'Unnecessary prefix "{value[0:prefix_len]}"', 

1471 "warning", 

1472 "debputy", 

1473 quickfixes=[ 

1474 propose_remove_range_quick_fix( 

1475 proposed_title=f'Delete "{value[0:prefix_len]}"' 

1476 ) 

1477 ], 

1478 ) 

1479 

1480 for m in _RE_SLASHES.finditer(value, slash_check_index): 

1481 m_start, m_end = m.span(0) 

1482 

1483 prefix_range = TERange( 

1484 TEPosition( 

1485 value_range.start_pos.line_position, 

1486 value_range.start_pos.cursor_position + m_start, 

1487 ), 

1488 TEPosition( 

1489 value_range.start_pos.line_position, 

1490 value_range.start_pos.cursor_position + m_end, 

1491 ), 

1492 ) 

1493 lint_state.emit_diagnostic( 

1494 prefix_range, 

1495 'Simplify to a single "/"', 

1496 "warning", 

1497 "debputy", 

1498 quickfixes=[propose_correct_text_quick_fix("/")], 

1499 ) 

1500 

1501 

1502def _dep5_files_check( 

1503 known_field: "F", 

1504 _deb822_file: Deb822FileElement, 

1505 kvpair: Deb822KeyValuePairElement, 

1506 kvpair_range_te: "TERange", 

1507 _field_name_range: "TERange", 

1508 _stanza: Deb822ParagraphElement, 

1509 _stanza_position: "TEPosition", 

1510 lint_state: LintState, 

1511) -> None: 

1512 interpreter = known_field.field_value_class.interpreter() 

1513 assert interpreter is not None 

1514 full_value_range = kvpair.value_element.range_in_parent().relative_to( 

1515 kvpair_range_te.start_pos 

1516 ) 

1517 values_with_ranges = [] 

1518 for value_ref in kvpair.interpret_as(interpreter).iter_value_references(): 

1519 value_range = value_ref.locatable.range_in_parent().relative_to( 

1520 full_value_range.start_pos 

1521 ) 

1522 value = value_ref.value 

1523 values_with_ranges.append((value_ref.value, value_range)) 

1524 _dep5_unnecessary_symbols(value, value_range, lint_state) 

1525 

1526 source_root = lint_state.source_root 

1527 if source_root is None: 

1528 return 

1529 i = 0 

1530 limit = len(values_with_ranges) 

1531 while i < limit: 

1532 value, value_range = values_with_ranges[i] 

1533 i += 1 

1534 

1535 

1536_HOMEPAGE_CLUTTER_RE = re.compile(r"<(?:UR[LI]:)?(.*)>") 

1537_URI_RE = re.compile(r"(?P<protocol>[a-z0-9]+)://(?P<host>[^\s/+]+)(?P<path>/[^\s?]*)?") 

1538_KNOWN_HTTPS_HOSTS = frozenset( 

1539 [ 

1540 "debian.org", 

1541 "bioconductor.org", 

1542 "cran.r-project.org", 

1543 "github.com", 

1544 "gitlab.com", 

1545 "metacpan.org", 

1546 "gnu.org", 

1547 ] 

1548) 

1549_REPLACED_HOSTS = frozenset({"alioth.debian.org"}) 

1550_NO_DOT_GIT_HOMEPAGE_HOSTS = frozenset( 

1551 { 

1552 "salsa.debian.org", 

1553 "github.com", 

1554 "gitlab.com", 

1555 } 

1556) 

1557 

1558 

1559def _is_known_host(host: str, known_hosts: Container[str]) -> bool: 

1560 if host in known_hosts: 

1561 return True 

1562 while host: 1562 ↛ 1570line 1562 didn't jump to line 1570 because the condition on line 1562 was always true

1563 try: 

1564 idx = host.index(".") 

1565 host = host[idx + 1 :] 

1566 except ValueError: 

1567 break 

1568 if host in known_hosts: 

1569 return True 

1570 return False 

1571 

1572 

1573def _validate_homepage_field( 

1574 _known_field: "F", 

1575 _deb822_file: Deb822FileElement, 

1576 kvpair: Deb822KeyValuePairElement, 

1577 kvpair_range_te: "TERange", 

1578 _field_name_range_te: "TERange", 

1579 _stanza: Deb822ParagraphElement, 

1580 _stanza_position: "TEPosition", 

1581 lint_state: LintState, 

1582) -> None: 

1583 value = kvpair.value_element.convert_to_text() 

1584 offset = 0 

1585 homepage = value 

1586 if "<" in value and (m := _HOMEPAGE_CLUTTER_RE.search(value)): 

1587 expected_value = m.group(1) 

1588 quickfixes = [] 

1589 if expected_value: 1589 ↛ 1593line 1589 didn't jump to line 1593 because the condition on line 1589 was always true

1590 homepage = expected_value.strip() 

1591 offset = m.start(1) 

1592 quickfixes.append(propose_correct_text_quick_fix(expected_value)) 

1593 lint_state.emit_diagnostic( 

1594 _single_line_span_range_relative_to_pos( 

1595 m.span(), 

1596 kvpair.value_element.position_in_parent().relative_to( 

1597 kvpair_range_te.start_pos 

1598 ), 

1599 ), 

1600 "Superfluous URL/URI wrapping", 

1601 "informational", 

1602 "Policy 5.6.23", 

1603 quickfixes=quickfixes, 

1604 ) 

1605 # Note falling through here can cause "two rounds" for debputy lint --auto-fix 

1606 m = _URI_RE.search(homepage) 

1607 if not m: 1607 ↛ 1608line 1607 didn't jump to line 1608 because the condition on line 1607 was never true

1608 return 

1609 # TODO relative to lintian: `bad-homepage` and most of the `fields/bad-homepages` hints. 

1610 protocol = m.group("protocol") 

1611 host = m.group("host") 

1612 path = m.group("path") or "" 

1613 if _is_known_host(host, _REPLACED_HOSTS): 

1614 span = m.span("host") 

1615 lint_state.emit_diagnostic( 

1616 _single_line_span_range_relative_to_pos( 

1617 (span[0] + offset, span[1] + offset), 

1618 kvpair.value_element.position_in_parent().relative_to( 

1619 kvpair_range_te.start_pos 

1620 ), 

1621 ), 

1622 f'The server "{host}" is no longer in use.', 

1623 "warning", 

1624 "debputy", 

1625 ) 

1626 return 

1627 if ( 

1628 protocol == "ftp" 

1629 or protocol == "http" 

1630 and _is_known_host(host, _KNOWN_HTTPS_HOSTS) 

1631 ): 

1632 span = m.span("protocol") 

1633 if protocol == "ftp" and not _is_known_host(host, _KNOWN_HTTPS_HOSTS): 1633 ↛ 1634line 1633 didn't jump to line 1634 because the condition on line 1633 was never true

1634 msg = "Insecure protocol for website (check if a https:// variant is available)" 

1635 quickfixes = [] 

1636 else: 

1637 msg = "Replace with https://. The host is known to support https" 

1638 quickfixes = [propose_correct_text_quick_fix("https")] 

1639 lint_state.emit_diagnostic( 

1640 _single_line_span_range_relative_to_pos( 

1641 (span[0] + offset, span[1] + offset), 

1642 kvpair.value_element.position_in_parent().relative_to( 

1643 kvpair_range_te.start_pos 

1644 ), 

1645 ), 

1646 msg, 

1647 "pedantic", 

1648 "debputy", 

1649 quickfixes=quickfixes, 

1650 ) 

1651 if path.endswith(".git") and _is_known_host(host, _NO_DOT_GIT_HOMEPAGE_HOSTS): 

1652 span = m.span("path") 

1653 msg = "Unnecessary suffix" 

1654 quickfixes = [propose_correct_text_quick_fix(path[:-4])] 

1655 lint_state.emit_diagnostic( 

1656 _single_line_span_range_relative_to_pos( 

1657 (span[1] - 4 + offset, span[1] + offset), 

1658 kvpair.value_element.position_in_parent().relative_to( 

1659 kvpair_range_te.start_pos 

1660 ), 

1661 ), 

1662 msg, 

1663 "pedantic", 

1664 "debputy", 

1665 quickfixes=quickfixes, 

1666 ) 

1667 

1668 

1669def _combined_custom_field_check(*checks: CustomFieldCheck) -> CustomFieldCheck: 

1670 def _validator( 

1671 known_field: "F", 

1672 deb822_file: Deb822FileElement, 

1673 kvpair: Deb822KeyValuePairElement, 

1674 kvpair_range_te: "TERange", 

1675 field_name_range_te: "TERange", 

1676 stanza: Deb822ParagraphElement, 

1677 stanza_position: "TEPosition", 

1678 lint_state: LintState, 

1679 ) -> None: 

1680 for check in checks: 

1681 check( 

1682 known_field, 

1683 deb822_file, 

1684 kvpair, 

1685 kvpair_range_te, 

1686 field_name_range_te, 

1687 stanza, 

1688 stanza_position, 

1689 lint_state, 

1690 ) 

1691 

1692 return _validator 

1693 

1694 

1695@dataclasses.dataclass(slots=True, frozen=True) 

1696class PackageNameSectionRule: 

1697 section: str 

1698 check: Callable[[str], bool] 

1699 

1700 

1701def _package_name_section_rule( 

1702 section: str, 

1703 check: Callable[[str], bool] | re.Pattern, 

1704 *, 

1705 confirm_re: re.Pattern | None = None, 

1706) -> PackageNameSectionRule: 

1707 if confirm_re is not None: 

1708 assert callable(check) 

1709 

1710 def _impl(v: str) -> bool: 

1711 return check(v) and confirm_re.search(v) 

1712 

1713 elif isinstance(check, re.Pattern): 1713 ↛ 1715line 1713 didn't jump to line 1715 because the condition on line 1713 was never true

1714 

1715 def _impl(v: str) -> bool: 

1716 return check.search(v) is not None 

1717 

1718 else: 

1719 _impl = check 

1720 

1721 return PackageNameSectionRule(section, _impl) 

1722 

1723 

1724# rules: order is important (first match wins in case of a conflict) 

1725_PKGNAME_VS_SECTION_RULES = [ 

1726 _package_name_section_rule("debian-installer", lambda n: n.endswith("-udeb")), 

1727 _package_name_section_rule("doc", lambda n: n.endswith(("-doc", "-docs"))), 

1728 _package_name_section_rule("debug", lambda n: n.endswith(("-dbg", "-dbgsym"))), 

1729 _package_name_section_rule( 

1730 "httpd", 

1731 lambda n: n.startswith(("lighttpd-mod", "libapache2-mod-", "libnginx-mod-")), 

1732 ), 

1733 _package_name_section_rule("gnustep", lambda n: n.startswith("gnustep-")), 

1734 _package_name_section_rule( 

1735 "gnustep", 

1736 lambda n: n.endswith( 

1737 ( 

1738 ".framework", 

1739 ".framework-common", 

1740 ".tool", 

1741 ".tool-common", 

1742 ".app", 

1743 ".app-common", 

1744 ) 

1745 ), 

1746 ), 

1747 _package_name_section_rule("embedded", lambda n: n.startswith("moblin-")), 

1748 _package_name_section_rule("javascript", lambda n: n.startswith("node-")), 

1749 _package_name_section_rule( 

1750 "zope", 

1751 lambda n: n.startswith(("python-zope", "python3-zope", "zope")), 

1752 ), 

1753 _package_name_section_rule( 

1754 "python", 

1755 lambda n: n.startswith(("python-", "python3-")), 

1756 ), 

1757 _package_name_section_rule( 

1758 "gnu-r", 

1759 lambda n: n.startswith(("r-cran-", "r-bioc-", "r-other-")), 

1760 ), 

1761 _package_name_section_rule("editors", lambda n: n.startswith("elpa-")), 

1762 _package_name_section_rule("lisp", lambda n: n.startswith("cl-")), 

1763 _package_name_section_rule( 

1764 "lisp", 

1765 lambda n: "-elisp-" in n or n.endswith("-elisp"), 

1766 ), 

1767 _package_name_section_rule( 

1768 "lisp", 

1769 lambda n: n.startswith("lib") and n.endswith("-guile"), 

1770 ), 

1771 _package_name_section_rule("lisp", lambda n: n.startswith("guile-")), 

1772 _package_name_section_rule("golang", lambda n: n.startswith("golang-")), 

1773 _package_name_section_rule( 

1774 "perl", 

1775 lambda n: n.startswith("lib") and n.endswith("-perl"), 

1776 ), 

1777 _package_name_section_rule( 

1778 "cli-mono", 

1779 lambda n: n.startswith("lib") and n.endswith(("-cil", "-cil-dev")), 

1780 ), 

1781 _package_name_section_rule( 

1782 "java", 

1783 lambda n: n.startswith("lib") and n.endswith(("-java", "-gcj", "-jni")), 

1784 ), 

1785 _package_name_section_rule( 

1786 "php", 

1787 lambda n: n.startswith(("libphp", "php")), 

1788 confirm_re=re.compile(r"^(?:lib)?php(?:\d(?:\.\d)?)?-"), 

1789 ), 

1790 _package_name_section_rule( 

1791 "php", lambda n: n.startswith("lib-") and n.endswith("-php") 

1792 ), 

1793 _package_name_section_rule( 

1794 "haskell", 

1795 lambda n: n.startswith(("haskell-", "libhugs-", "libghc-", "libghc6-")), 

1796 ), 

1797 _package_name_section_rule( 

1798 "ruby", 

1799 lambda n: "-ruby" in n, 

1800 confirm_re=re.compile(r"^lib.*-ruby(?:1\.\d)?$"), 

1801 ), 

1802 _package_name_section_rule("ruby", lambda n: n.startswith("ruby-")), 

1803 _package_name_section_rule( 

1804 "rust", 

1805 lambda n: n.startswith("librust-") and n.endswith("-dev"), 

1806 ), 

1807 _package_name_section_rule("rust", lambda n: n.startswith("rust-")), 

1808 _package_name_section_rule( 

1809 "ocaml", 

1810 lambda n: n.startswith("lib-") and n.endswith(("-ocaml-dev", "-camlp4-dev")), 

1811 ), 

1812 _package_name_section_rule("javascript", lambda n: n.startswith("libjs-")), 

1813 _package_name_section_rule( 

1814 "interpreters", 

1815 lambda n: n.startswith("lib-") and n.endswith(("-tcl", "-lua", "-gst")), 

1816 ), 

1817 _package_name_section_rule( 

1818 "introspection", 

1819 lambda n: n.startswith("gir-"), 

1820 confirm_re=re.compile(r"^gir\d+\.\d+-.*-\d+\.\d+$"), 

1821 ), 

1822 _package_name_section_rule( 

1823 "fonts", 

1824 lambda n: n.startswith(("xfonts-", "fonts-", "ttf-")), 

1825 ), 

1826 _package_name_section_rule("admin", lambda n: n.startswith(("libnss-", "libpam-"))), 

1827 _package_name_section_rule( 

1828 "localization", 

1829 lambda n: n.startswith( 

1830 ( 

1831 "aspell-", 

1832 "hunspell-", 

1833 "myspell-", 

1834 "mythes-", 

1835 "dict-freedict-", 

1836 "gcompris-sound-", 

1837 ) 

1838 ), 

1839 ), 

1840 _package_name_section_rule( 

1841 "localization", 

1842 lambda n: n.startswith("hyphen-"), 

1843 confirm_re=re.compile(r"^hyphen-[a-z]{2}(?:-[a-z]{2})?$"), 

1844 ), 

1845 _package_name_section_rule( 

1846 "localization", 

1847 lambda n: "-l10n-" in n or n.endswith("-l10n"), 

1848 ), 

1849 _package_name_section_rule("kernel", lambda n: n.endswith(("-dkms", "-firmware"))), 

1850 _package_name_section_rule( 

1851 "libdevel", 

1852 lambda n: n.startswith("lib") and n.endswith(("-dev", "-headers")), 

1853 ), 

1854 _package_name_section_rule( 

1855 "libs", 

1856 lambda n: n.startswith("lib"), 

1857 confirm_re=re.compile(r"^lib.*\d[ad]?$"), 

1858 ), 

1859] 

1860 

1861 

1862# Fiddling with the package name can cause a lot of changes (diagnostic scans), so we have an upper bound 

1863# on the cache. The number is currently just taken out of a hat. 

1864@functools.lru_cache(64) 

1865def package_name_to_section(name: str) -> str | None: 

1866 for rule in _PKGNAME_VS_SECTION_RULES: 

1867 if rule.check(name): 

1868 return rule.section 

1869 return None 

1870 

1871 

1872def _unknown_value_check( 

1873 field_name: str, 

1874 value: str, 

1875 known_values: Mapping[str, Keyword], 

1876 unknown_value_severity: LintSeverity | None, 

1877) -> tuple[Keyword | None, str | None, LintSeverity | None, Any | None]: 

1878 known_value = known_values.get(value) 

1879 message = None 

1880 severity = unknown_value_severity 

1881 fix_data = None 

1882 if known_value is None: 

1883 candidates = detect_possible_typo( 

1884 value, 

1885 known_values, 

1886 ) 

1887 if len(known_values) < 5: 1887 ↛ 1888line 1887 didn't jump to line 1888 because the condition on line 1887 was never true

1888 values = ", ".join(sorted(known_values)) 

1889 hint_text = f" Known values for this field: {values}" 

1890 else: 

1891 hint_text = "" 

1892 fix_data = None 

1893 severity = unknown_value_severity 

1894 fix_text = hint_text 

1895 if candidates: 

1896 match = candidates[0] 

1897 if len(candidates) == 1: 1897 ↛ 1899line 1897 didn't jump to line 1899 because the condition on line 1897 was always true

1898 known_value = known_values[match] 

1899 fix_text = ( 

1900 f' It is possible that the value is a typo of "{match}".{fix_text}' 

1901 ) 

1902 fix_data = [propose_correct_text_quick_fix(m) for m in candidates] 

1903 elif severity is None: 1903 ↛ 1904line 1903 didn't jump to line 1904 because the condition on line 1903 was never true

1904 return None, None, None, None 

1905 if severity is None: 

1906 severity = cast("LintSeverity", "warning") 

1907 # It always has leading whitespace 

1908 message = fix_text.strip() 

1909 else: 

1910 message = f'The value "{value}" is not supported in {field_name}.{fix_text}' 

1911 return known_value, message, severity, fix_data 

1912 

1913 

1914def _dep5_escape_path(path: str) -> str: 

1915 return path.replace(" ", "?") 

1916 

1917 

1918def _noop_escape_path(path: str) -> str: 

1919 return path 

1920 

1921 

1922def _should_ignore_dir( 

1923 path: VirtualPath, 

1924 *, 

1925 supports_dir_match: bool = False, 

1926 match_non_persistent_paths: bool = False, 

1927) -> bool: 

1928 if not supports_dir_match and not any(path.iterdir()): 

1929 return True 

1930 cachedir_tag = path.get("CACHEDIR.TAG") 

1931 if ( 

1932 not match_non_persistent_paths 

1933 and cachedir_tag is not None 

1934 and cachedir_tag.is_file 

1935 ): 

1936 # https://bford.info/cachedir/ 

1937 with cachedir_tag.open(byte_io=True, buffering=64) as fd: 

1938 start = fd.read(43) 

1939 if start == b"Signature: 8a477f597d28d172789f06886806bc55": 

1940 return True 

1941 return False 

1942 

1943 

1944@dataclasses.dataclass(slots=True) 

1945class Deb822KnownField: 

1946 name: str 

1947 field_value_class: FieldValueClass 

1948 warn_if_default: bool = True 

1949 unknown_value_authority: str = "debputy" 

1950 missing_field_authority: str = "debputy" 

1951 replaced_by: str | None = None 

1952 deprecated_with_no_replacement: bool = False 

1953 missing_field_severity: LintSeverity | None = None 

1954 default_value: str | None = None 

1955 known_values: Mapping[str, Keyword] | None = None 

1956 unknown_value_severity: LintSeverity | None = "error" 

1957 translation_context: str = "" 

1958 # One-line description for space-constrained docs (such as completion docs) 

1959 synopsis: str | None = None 

1960 usage_hint: UsageHint | None = None 

1961 long_description: str | None = None 

1962 spellcheck_value: bool = False 

1963 inheritable_from_other_stanza: bool = False 

1964 show_as_inherited: bool = True 

1965 custom_field_check: CustomFieldCheck | None = None 

1966 can_complete_field_in_stanza: None | ( 

1967 Callable[[Iterable[Deb822ParagraphElement]], bool] 

1968 ) = None 

1969 is_substvars_disabled_even_if_allowed_by_stanza: bool = False 

1970 is_alias_of: str | None = None 

1971 is_completion_suggestion: bool = True 

1972 

1973 def synopsis_translated( 

1974 self, translation_provider: Union["DebputyLanguageServer", "LintState"] 

1975 ) -> str | None: 

1976 if self.synopsis is None: 

1977 return None 

1978 return translation_provider.translation(LSP_DATA_DOMAIN).pgettext( 

1979 self.translation_context, 

1980 self.synopsis, 

1981 ) 

1982 

1983 def long_description_translated( 

1984 self, translation_provider: Union["DebputyLanguageServer", "LintState"] 

1985 ) -> str | None: 

1986 if self.long_description_translated is None: 1986 ↛ 1987line 1986 didn't jump to line 1987 because the condition on line 1986 was never true

1987 return None 

1988 return translation_provider.translation(LSP_DATA_DOMAIN).pgettext( 

1989 self.translation_context, 

1990 self.long_description, 

1991 ) 

1992 

1993 def _can_complete_field_in_stanza( 

1994 self, 

1995 stanza_parts: Sequence[Deb822ParagraphElement], 

1996 ) -> bool: 

1997 if not self.is_completion_suggestion: 1997 ↛ 1998line 1997 didn't jump to line 1998 because the condition on line 1997 was never true

1998 return False 

1999 return ( 

2000 self.can_complete_field_in_stanza is None 

2001 or self.can_complete_field_in_stanza(stanza_parts) 

2002 ) 

2003 

2004 def complete_field( 

2005 self, 

2006 lint_state: LintState, 

2007 stanza_parts: Sequence[Deb822ParagraphElement], 

2008 markdown_kind: MarkupKind, 

2009 ) -> CompletionItem | None: 

2010 if not self._can_complete_field_in_stanza(stanza_parts): 

2011 return None 

2012 name = self.name 

2013 complete_as = name + ": " 

2014 options = self.value_options_for_completer( 

2015 lint_state, 

2016 stanza_parts, 

2017 "", 

2018 markdown_kind, 

2019 is_completion_for_field=True, 

2020 ) 

2021 if options is not None and len(options) == 1: 

2022 value = options[0].insert_text 

2023 if value is not None: 2023 ↛ 2025line 2023 didn't jump to line 2025 because the condition on line 2023 was always true

2024 complete_as += value 

2025 tags = [] 

2026 is_deprecated = False 

2027 if self.replaced_by or self.deprecated_with_no_replacement: 

2028 is_deprecated = True 

2029 tags.append(CompletionItemTag.Deprecated) 

2030 

2031 doc = self.long_description 

2032 if doc: 

2033 doc = MarkupContent( 

2034 value=doc, 

2035 kind=markdown_kind, 

2036 ) 

2037 else: 

2038 doc = None 

2039 

2040 return CompletionItem( 

2041 name, 

2042 insert_text=complete_as, 

2043 deprecated=is_deprecated, 

2044 tags=tags, 

2045 detail=format_comp_item_synopsis_doc( 

2046 self.usage_hint, 

2047 self.synopsis_translated(lint_state), 

2048 is_deprecated, 

2049 ), 

2050 documentation=doc, 

2051 ) 

2052 

2053 def _complete_files( 

2054 self, 

2055 base_dir: VirtualPathBase | None, 

2056 value_being_completed: str, 

2057 *, 

2058 is_dep5_file_list: bool = False, 

2059 supports_dir_match: bool = False, 

2060 supports_spaces_in_filename: bool = False, 

2061 match_non_persistent_paths: bool = False, 

2062 ) -> Sequence[CompletionItem] | None: 

2063 _info(f"_complete_files: {base_dir.fs_path} - {value_being_completed!r}") 

2064 if base_dir is None or not base_dir.is_dir: 

2065 return None 

2066 

2067 if is_dep5_file_list: 

2068 supports_spaces_in_filename = True 

2069 supports_dir_match = False 

2070 match_non_persistent_paths = False 

2071 

2072 if value_being_completed == "": 

2073 current_dir = base_dir 

2074 unmatched_parts: Sequence[str] = () 

2075 else: 

2076 current_dir, unmatched_parts = base_dir.attempt_lookup( 

2077 value_being_completed 

2078 ) 

2079 

2080 if len(unmatched_parts) > 1: 

2081 # Unknown directory part / glob, and we currently do not deal with that. 

2082 return None 

2083 if len(unmatched_parts) == 1 and unmatched_parts[0] == "*": 

2084 # Avoid convincing the client to remove the star (seen with emacs) 

2085 return None 

2086 items = [] 

2087 

2088 path_escaper = _dep5_escape_path if is_dep5_file_list else _noop_escape_path 

2089 

2090 for child in current_dir.iterdir(): 

2091 if child.is_symlink and is_dep5_file_list: 

2092 continue 

2093 if not supports_spaces_in_filename and ( 

2094 " " in child.name or "\t" in child.name 

2095 ): 

2096 continue 

2097 sort_text = ( 

2098 f"z-{child.name}" if child.name.startswith(".") else f"a-{child.name}" 

2099 ) 

2100 if child.is_dir: 

2101 if _should_ignore_dir( 

2102 child, 

2103 supports_dir_match=supports_dir_match, 

2104 match_non_persistent_paths=match_non_persistent_paths, 

2105 ): 

2106 continue 

2107 items.append( 

2108 CompletionItem( 

2109 f"{child.path}/", 

2110 label_details=CompletionItemLabelDetails( 

2111 description=child.path, 

2112 ), 

2113 insert_text=path_escaper(f"{child.path}/"), 

2114 filter_text=f"{child.path}/", 

2115 sort_text=sort_text, 

2116 kind=CompletionItemKind.Folder, 

2117 ) 

2118 ) 

2119 else: 

2120 items.append( 

2121 CompletionItem( 

2122 child.path, 

2123 label_details=CompletionItemLabelDetails( 

2124 description=child.path, 

2125 ), 

2126 insert_text=path_escaper(child.path), 

2127 filter_text=child.path, 

2128 sort_text=sort_text, 

2129 kind=CompletionItemKind.File, 

2130 ) 

2131 ) 

2132 return items 

2133 

2134 def value_options_for_completer( 

2135 self, 

2136 lint_state: LintState, 

2137 stanza_parts: Sequence[Deb822ParagraphElement], 

2138 value_being_completed: str, 

2139 markdown_kind: MarkupKind, 

2140 *, 

2141 is_completion_for_field: bool = False, 

2142 ) -> Sequence[CompletionItem] | None: 

2143 known_values = self.known_values 

2144 if self.field_value_class == FieldValueClass.DEP5_FILE_LIST: 2144 ↛ 2145line 2144 didn't jump to line 2145 because the condition on line 2144 was never true

2145 if is_completion_for_field: 

2146 return None 

2147 return self._complete_files( 

2148 lint_state.source_root, 

2149 value_being_completed, 

2150 is_dep5_file_list=True, 

2151 ) 

2152 

2153 if known_values is None: 

2154 return None 

2155 if is_completion_for_field and ( 

2156 len(known_values) == 1 

2157 or ( 

2158 len(known_values) == 2 

2159 and self.warn_if_default 

2160 and self.default_value is not None 

2161 ) 

2162 ): 

2163 value = next( 

2164 iter(v for v in self.known_values if v != self.default_value), 

2165 None, 

2166 ) 

2167 if value is None: 2167 ↛ 2168line 2167 didn't jump to line 2168 because the condition on line 2167 was never true

2168 return None 

2169 return [CompletionItem(value, insert_text=value)] 

2170 return [ 

2171 keyword.as_completion_item( 

2172 lint_state, 

2173 stanza_parts, 

2174 value_being_completed, 

2175 markdown_kind, 

2176 ) 

2177 for keyword in known_values.values() 

2178 if keyword.is_keyword_valid_completion_in_stanza(stanza_parts) 

2179 and keyword.is_completion_suggestion 

2180 ] 

2181 

2182 def field_omitted_diagnostics( 

2183 self, 

2184 deb822_file: Deb822FileElement, 

2185 representation_field_range: "TERange", 

2186 stanza: Deb822ParagraphElement, 

2187 stanza_position: "TEPosition", 

2188 header_stanza: Deb822FileElement | None, 

2189 lint_state: LintState, 

2190 ) -> None: 

2191 missing_field_severity = self.missing_field_severity 

2192 if missing_field_severity is None: 2192 ↛ 2195line 2192 didn't jump to line 2195 because the condition on line 2192 was always true

2193 return 

2194 

2195 if ( 

2196 self.inheritable_from_other_stanza 

2197 and header_stanza is not None 

2198 and self.name in header_stanza 

2199 ): 

2200 return 

2201 

2202 lint_state.emit_diagnostic( 

2203 representation_field_range, 

2204 f"Stanza is missing field {self.name}", 

2205 missing_field_severity, 

2206 self.missing_field_authority, 

2207 ) 

2208 

2209 async def field_diagnostics( 

2210 self, 

2211 deb822_file: Deb822FileElement, 

2212 kvpair: Deb822KeyValuePairElement, 

2213 stanza: Deb822ParagraphElement, 

2214 stanza_position: "TEPosition", 

2215 kvpair_range_te: "TERange", 

2216 lint_state: LintState, 

2217 *, 

2218 field_name_typo_reported: bool = False, 

2219 ) -> None: 

2220 field_name_token = kvpair.field_token 

2221 field_name_range_te = kvpair.field_token.range_in_parent().relative_to( 

2222 kvpair_range_te.start_pos 

2223 ) 

2224 field_name = field_name_token.text 

2225 # The `self.name` attribute is the canonical name whereas `field_name` is the name used. 

2226 # This distinction is important for `d/control` where `X[CBS]-` prefixes might be used 

2227 # in one but not the other. 

2228 field_value = stanza[field_name] 

2229 self._diagnostics_for_field_name( 

2230 kvpair_range_te, 

2231 field_name_token, 

2232 field_name_range_te, 

2233 field_name_typo_reported, 

2234 lint_state, 

2235 ) 

2236 if self.custom_field_check is not None: 

2237 self.custom_field_check( 

2238 self, 

2239 deb822_file, 

2240 kvpair, 

2241 kvpair_range_te, 

2242 field_name_range_te, 

2243 stanza, 

2244 stanza_position, 

2245 lint_state, 

2246 ) 

2247 self._dep5_file_list_diagnostics(kvpair, kvpair_range_te.start_pos, lint_state) 

2248 if self.spellcheck_value: 

2249 words = kvpair.interpret_as(LIST_SPACE_SEPARATED_INTERPRETATION) 

2250 spell_checker = lint_state.spellchecker() 

2251 value_position = kvpair.value_element.position_in_parent().relative_to( 

2252 kvpair_range_te.start_pos 

2253 ) 

2254 async for word_ref in lint_state.slow_iter( 

2255 words.iter_value_references(), yield_every=25 

2256 ): 

2257 token = word_ref.value 

2258 for word, pos, endpos in spell_checker.iter_words(token): 

2259 corrections = spell_checker.provide_corrections_for(word) 

2260 if not corrections: 

2261 continue 

2262 word_loc = word_ref.locatable 

2263 word_pos_te = word_loc.position_in_parent().relative_to( 

2264 value_position 

2265 ) 

2266 if pos: 2266 ↛ 2267line 2266 didn't jump to line 2267 because the condition on line 2266 was never true

2267 word_pos_te = TEPosition(0, pos).relative_to(word_pos_te) 

2268 word_size = TERange( 

2269 START_POSITION, 

2270 TEPosition(0, endpos - pos), 

2271 ) 

2272 lint_state.emit_diagnostic( 

2273 TERange.from_position_and_size(word_pos_te, word_size), 

2274 f'Spelling "{word}"', 

2275 "spelling", 

2276 "debputy", 

2277 quickfixes=[ 

2278 propose_correct_text_quick_fix(c) for c in corrections 

2279 ], 

2280 enable_non_interactive_auto_fix=False, 

2281 ) 

2282 else: 

2283 self._known_value_diagnostics( 

2284 kvpair, 

2285 kvpair_range_te.start_pos, 

2286 lint_state, 

2287 ) 

2288 

2289 if self.warn_if_default and field_value == self.default_value: 2289 ↛ 2290line 2289 didn't jump to line 2290 because the condition on line 2289 was never true

2290 lint_state.emit_diagnostic( 

2291 kvpair_range_te, 

2292 f'The field "{field_name}" is redundant as it is set to the default value and the field' 

2293 " should only be used in exceptional cases.", 

2294 "warning", 

2295 "debputy", 

2296 ) 

2297 

2298 def _diagnostics_for_field_name( 

2299 self, 

2300 kvpair_range: "TERange", 

2301 token: Deb822FieldNameToken, 

2302 token_range: "TERange", 

2303 typo_detected: bool, 

2304 lint_state: LintState, 

2305 ) -> None: 

2306 field_name = token.text 

2307 # Defeat the case-insensitivity from python-debian 

2308 field_name_cased = str(field_name) 

2309 if self.deprecated_with_no_replacement: 

2310 lint_state.emit_diagnostic( 

2311 kvpair_range, 

2312 f'"{field_name_cased}" is deprecated and no longer used', 

2313 "warning", 

2314 "debputy", 

2315 quickfixes=[propose_remove_range_quick_fix()], 

2316 tags=[DiagnosticTag.Deprecated], 

2317 ) 

2318 elif self.replaced_by is not None: 

2319 lint_state.emit_diagnostic( 

2320 token_range, 

2321 f'"{field_name_cased}" has been replaced by "{self.replaced_by}"', 

2322 "warning", 

2323 "debputy", 

2324 tags=[DiagnosticTag.Deprecated], 

2325 quickfixes=[propose_correct_text_quick_fix(self.replaced_by)], 

2326 ) 

2327 

2328 if not typo_detected and field_name_cased != self.name: 

2329 lint_state.emit_diagnostic( 

2330 token_range, 

2331 f'Non-canonical spelling of "{self.name}"', 

2332 "pedantic", 

2333 self.unknown_value_authority, 

2334 quickfixes=[propose_correct_text_quick_fix(self.name)], 

2335 ) 

2336 

2337 def _dep5_file_list_diagnostics( 

2338 self, 

2339 kvpair: Deb822KeyValuePairElement, 

2340 kvpair_position: "TEPosition", 

2341 lint_state: LintState, 

2342 ) -> None: 

2343 source_root = lint_state.source_root 

2344 if ( 

2345 self.field_value_class != FieldValueClass.DEP5_FILE_LIST 

2346 or source_root is None 

2347 ): 

2348 return 

2349 interpreter = self.field_value_class.interpreter() 

2350 values = kvpair.interpret_as(interpreter) 

2351 value_off = kvpair.value_element.position_in_parent().relative_to( 

2352 kvpair_position 

2353 ) 

2354 

2355 assert interpreter is not None 

2356 

2357 for token in values.iter_parts(): 

2358 if token.is_whitespace: 

2359 continue 

2360 text = token.convert_to_text() 

2361 if "?" in text or "*" in text: 2361 ↛ 2363line 2361 didn't jump to line 2363 because the condition on line 2361 was never true

2362 # TODO: We should validate these as well 

2363 continue 

2364 matched_path, missing_part = source_root.attempt_lookup(text) 

2365 # It is common practice to delete "dirty" files during clean. This causes files listed 

2366 # in `debian/copyright` to go missing and as a consequence, we do not validate whether 

2367 # they are present (that would require us to check the `.orig.tar`, which we could but 

2368 # do not have the infrastructure for). 

2369 if not missing_part and matched_path.is_dir: 2369 ↛ 2357line 2369 didn't jump to line 2357 because the condition on line 2369 was always true

2370 path_range_te = token.range_in_parent().relative_to(value_off) 

2371 lint_state.emit_diagnostic( 

2372 path_range_te, 

2373 "Directories cannot be a match. Use `dir/*` to match everything in it", 

2374 "warning", 

2375 self.unknown_value_authority, 

2376 quickfixes=[ 

2377 propose_correct_text_quick_fix(f"{matched_path.path}/*") 

2378 ], 

2379 ) 

2380 

2381 def _known_value_diagnostics( 

2382 self, 

2383 kvpair: Deb822KeyValuePairElement, 

2384 kvpair_position: "TEPosition", 

2385 lint_state: LintState, 

2386 ) -> None: 

2387 unknown_value_severity = self.unknown_value_severity 

2388 interpreter = self.field_value_class.interpreter() 

2389 if interpreter is None: 

2390 return 

2391 try: 

2392 values = kvpair.interpret_as(interpreter) 

2393 except ValueError: 

2394 value_range = kvpair.value_element.range_in_parent().relative_to( 

2395 kvpair_position 

2396 ) 

2397 lint_state.emit_diagnostic( 

2398 value_range, 

2399 "Error while parsing field (diagnostics related to this field may be incomplete)", 

2400 "pedantic", 

2401 "debputy", 

2402 ) 

2403 return 

2404 value_off = kvpair.value_element.position_in_parent().relative_to( 

2405 kvpair_position 

2406 ) 

2407 

2408 last_token_non_ws_sep_token: TE | None = None 

2409 for token in values.iter_parts(): 

2410 if token.is_whitespace: 

2411 continue 

2412 if not token.is_separator: 

2413 last_token_non_ws_sep_token = None 

2414 continue 

2415 if last_token_non_ws_sep_token is not None: 

2416 sep_range_te = token.range_in_parent().relative_to(value_off) 

2417 lint_state.emit_diagnostic( 

2418 sep_range_te, 

2419 "Duplicate separator", 

2420 "error", 

2421 self.unknown_value_authority, 

2422 ) 

2423 last_token_non_ws_sep_token = token 

2424 

2425 allowed_values = self.known_values 

2426 if not allowed_values: 

2427 return 

2428 

2429 first_value = None 

2430 first_exclusive_value_ref = None 

2431 first_exclusive_value = None 

2432 has_emitted_for_exclusive = False 

2433 

2434 for value_ref in values.iter_value_references(): 

2435 value = value_ref.value 

2436 if ( 2436 ↛ 2440line 2436 didn't jump to line 2440 because the condition on line 2436 was never true

2437 first_value is not None 

2438 and self.field_value_class == FieldValueClass.SINGLE_VALUE 

2439 ): 

2440 value_loc = value_ref.locatable 

2441 range_position_te = value_loc.range_in_parent().relative_to(value_off) 

2442 lint_state.emit_diagnostic( 

2443 range_position_te, 

2444 f"The field {self.name} can only have exactly one value.", 

2445 "error", 

2446 self.unknown_value_authority, 

2447 ) 

2448 # TODO: Add quickfix if the value is also invalid 

2449 continue 

2450 

2451 if first_exclusive_value_ref is not None and not has_emitted_for_exclusive: 

2452 assert first_exclusive_value is not None 

2453 value_loc = first_exclusive_value_ref.locatable 

2454 value_range_te = value_loc.range_in_parent().relative_to(value_off) 

2455 lint_state.emit_diagnostic( 

2456 value_range_te, 

2457 f'The value "{first_exclusive_value}" cannot be used with other values.', 

2458 "error", 

2459 self.unknown_value_authority, 

2460 ) 

2461 

2462 known_value, unknown_value_message, unknown_severity, typo_fix_data = ( 

2463 _unknown_value_check( 

2464 self.name, 

2465 value, 

2466 self.known_values, 

2467 unknown_value_severity, 

2468 ) 

2469 ) 

2470 value_loc = value_ref.locatable 

2471 value_range = value_loc.range_in_parent().relative_to(value_off) 

2472 

2473 if known_value and known_value.is_exclusive: 

2474 first_exclusive_value = known_value.value # In case of typos. 

2475 first_exclusive_value_ref = value_ref 

2476 if first_value is not None: 

2477 has_emitted_for_exclusive = True 

2478 lint_state.emit_diagnostic( 

2479 value_range, 

2480 f'The value "{known_value.value}" cannot be used with other values.', 

2481 "error", 

2482 self.unknown_value_authority, 

2483 ) 

2484 

2485 if first_value is None: 

2486 first_value = value 

2487 

2488 if unknown_value_message is not None: 

2489 assert unknown_severity is not None 

2490 lint_state.emit_diagnostic( 

2491 value_range, 

2492 unknown_value_message, 

2493 unknown_severity, 

2494 self.unknown_value_authority, 

2495 quickfixes=typo_fix_data, 

2496 ) 

2497 

2498 if known_value is not None and known_value.is_deprecated: 

2499 replacement = known_value.replaced_by 

2500 if replacement is not None: 2500 ↛ 2506line 2500 didn't jump to line 2506 because the condition on line 2500 was always true

2501 obsolete_value_message = ( 

2502 f'The value "{value}" has been replaced by "{replacement}"' 

2503 ) 

2504 obsolete_fix_data = [propose_correct_text_quick_fix(replacement)] 

2505 else: 

2506 obsolete_value_message = ( 

2507 f'The value "{value}" is obsolete without a single replacement' 

2508 ) 

2509 obsolete_fix_data = None 

2510 lint_state.emit_diagnostic( 

2511 value_range, 

2512 obsolete_value_message, 

2513 "warning", 

2514 "debputy", 

2515 quickfixes=obsolete_fix_data, 

2516 ) 

2517 

2518 def _reformat_field_name( 

2519 self, 

2520 effective_preference: "EffectiveFormattingPreference", 

2521 stanza_range: TERange, 

2522 kvpair: Deb822KeyValuePairElement, 

2523 position_codec: LintCapablePositionCodec, 

2524 lines: list[str], 

2525 ) -> Iterable[TextEdit]: 

2526 if not effective_preference.deb822_auto_canonical_size_field_names: 2526 ↛ 2527line 2526 didn't jump to line 2527 because the condition on line 2526 was never true

2527 return 

2528 # The `str(kvpair.field_name)` is to avoid the "magic" from `python3-debian`'s Deb822 keys. 

2529 if str(kvpair.field_name) == self.name: 

2530 return 

2531 

2532 field_name_range_te = kvpair.field_token.range_in_parent().relative_to( 

2533 kvpair.range_in_parent().relative_to(stanza_range.start_pos).start_pos 

2534 ) 

2535 

2536 edit_range = position_codec.range_to_client_units( 

2537 lines, 

2538 Range( 

2539 Position( 

2540 field_name_range_te.start_pos.line_position, 

2541 field_name_range_te.start_pos.cursor_position, 

2542 ), 

2543 Position( 

2544 field_name_range_te.start_pos.line_position, 

2545 field_name_range_te.end_pos.cursor_position, 

2546 ), 

2547 ), 

2548 ) 

2549 yield TextEdit( 

2550 edit_range, 

2551 self.name, 

2552 ) 

2553 

2554 def reformat_field( 

2555 self, 

2556 effective_preference: "EffectiveFormattingPreference", 

2557 stanza_range: TERange, 

2558 kvpair: Deb822KeyValuePairElement, 

2559 formatter: FormatterCallback, 

2560 position_codec: LintCapablePositionCodec, 

2561 lines: list[str], 

2562 ) -> Iterable[TextEdit]: 

2563 kvpair_range = kvpair.range_in_parent().relative_to(stanza_range.start_pos) 

2564 yield from self._reformat_field_name( 

2565 effective_preference, 

2566 stanza_range, 

2567 kvpair, 

2568 position_codec, 

2569 lines, 

2570 ) 

2571 return trim_end_of_line_whitespace( 

2572 position_codec, 

2573 lines, 

2574 line_range=range( 

2575 kvpair_range.start_pos.line_position, 

2576 kvpair_range.end_pos.line_position, 

2577 ), 

2578 ) 

2579 

2580 def replace(self, **changes: Any) -> "Self": 

2581 return dataclasses.replace(self, **changes) 

2582 

2583 

2584@dataclasses.dataclass(slots=True) 

2585class DctrlLikeKnownField(Deb822KnownField): 

2586 

2587 def reformat_field( 

2588 self, 

2589 effective_preference: "EffectiveFormattingPreference", 

2590 stanza_range: TERange, 

2591 kvpair: Deb822KeyValuePairElement, 

2592 formatter: FormatterCallback, 

2593 position_codec: LintCapablePositionCodec, 

2594 lines: list[str], 

2595 ) -> Iterable[TextEdit]: 

2596 interpretation = self.field_value_class.interpreter() 

2597 if ( 2597 ↛ 2601line 2597 didn't jump to line 2601 because the condition on line 2597 was never true

2598 not effective_preference.deb822_normalize_field_content 

2599 or interpretation is None 

2600 ): 

2601 yield from super(DctrlLikeKnownField, self).reformat_field( 

2602 effective_preference, 

2603 stanza_range, 

2604 kvpair, 

2605 formatter, 

2606 position_codec, 

2607 lines, 

2608 ) 

2609 return 

2610 if not self.reformattable_field: 

2611 yield from super(DctrlLikeKnownField, self).reformat_field( 

2612 effective_preference, 

2613 stanza_range, 

2614 kvpair, 

2615 formatter, 

2616 position_codec, 

2617 lines, 

2618 ) 

2619 return 

2620 

2621 # Preserve the name fixes from the super call. 

2622 yield from self._reformat_field_name( 

2623 effective_preference, 

2624 stanza_range, 

2625 kvpair, 

2626 position_codec, 

2627 lines, 

2628 ) 

2629 

2630 seen: set[str] = set() 

2631 old_kvpair_range = kvpair.range_in_parent() 

2632 sort = self.is_sortable_field 

2633 

2634 # Avoid the context manager as we do not want to perform the change (it would contaminate future ranges) 

2635 field_content = kvpair.interpret_as(interpretation) 

2636 old_value = field_content.convert_to_text(with_field_name=False) 

2637 for package_ref in field_content.iter_value_references(): 

2638 value = package_ref.value 

2639 value_range = package_ref.locatable.range_in_parent().relative_to( 

2640 stanza_range.start_pos 

2641 ) 

2642 sublines = lines[ 

2643 value_range.start_pos.line_position : value_range.end_pos.line_position 

2644 ] 

2645 

2646 # debputy#112: Avoid truncating "inline comments" 

2647 if any(line.startswith("#") for line in sublines): 2647 ↛ 2648line 2647 didn't jump to line 2648 because the condition on line 2647 was never true

2648 return 

2649 if self.is_relationship_field: 2649 ↛ 2652line 2649 didn't jump to line 2652 because the condition on line 2649 was always true

2650 new_value = " | ".join(x.strip() for x in value.split("|")) 

2651 else: 

2652 new_value = value 

2653 if not sort or new_value not in seen: 2653 ↛ 2658line 2653 didn't jump to line 2658 because the condition on line 2653 was always true

2654 if new_value != value: 2654 ↛ 2655line 2654 didn't jump to line 2655 because the condition on line 2654 was never true

2655 package_ref.value = new_value 

2656 seen.add(new_value) 

2657 else: 

2658 package_ref.remove() 

2659 if sort: 2659 ↛ 2661line 2659 didn't jump to line 2661 because the condition on line 2659 was always true

2660 field_content.sort(key=_sort_packages_key) 

2661 field_content.value_formatter(formatter) 

2662 field_content.reformat_when_finished() 

2663 

2664 new_value = field_content.convert_to_text(with_field_name=False) 

2665 if new_value != old_value: 

2666 value_range = kvpair.value_element.range_in_parent().relative_to( 

2667 old_kvpair_range.start_pos 

2668 ) 

2669 range_server_units = te_range_to_lsp( 

2670 value_range.relative_to(stanza_range.start_pos) 

2671 ) 

2672 yield TextEdit( 

2673 position_codec.range_to_client_units(lines, range_server_units), 

2674 new_value, 

2675 ) 

2676 

2677 @property 

2678 def reformattable_field(self) -> bool: 

2679 return self.is_relationship_field or self.is_sortable_field 

2680 

2681 @property 

2682 def is_relationship_field(self) -> bool: 

2683 return False 

2684 

2685 @property 

2686 def is_sortable_field(self) -> bool: 

2687 return self.is_relationship_field 

2688 

2689 

2690@dataclasses.dataclass(slots=True) 

2691class DTestsCtrlKnownField(DctrlLikeKnownField): 

2692 @property 

2693 def is_relationship_field(self) -> bool: 

2694 return self.name == "Depends" 

2695 

2696 @property 

2697 def is_sortable_field(self) -> bool: 

2698 return self.is_relationship_field or self.name in ( 

2699 "Features", 

2700 "Restrictions", 

2701 "Tests", 

2702 ) 

2703 

2704 

2705@dataclasses.dataclass(slots=True) 

2706class DctrlKnownField(DctrlLikeKnownField): 

2707 

2708 def field_omitted_diagnostics( 

2709 self, 

2710 deb822_file: Deb822FileElement, 

2711 representation_field_range: "TERange", 

2712 stanza: Deb822ParagraphElement, 

2713 stanza_position: "TEPosition", 

2714 header_stanza: Deb822FileElement | None, 

2715 lint_state: LintState, 

2716 ) -> None: 

2717 missing_field_severity = self.missing_field_severity 

2718 if missing_field_severity is None: 

2719 return 

2720 

2721 if ( 

2722 self.inheritable_from_other_stanza 

2723 and header_stanza is not None 

2724 and self.name in header_stanza 

2725 ): 

2726 return 

2727 

2728 if self.name == "Standards-Version": 

2729 stanzas = list(deb822_file)[1:] 

2730 if all(s.get("Package-Type") == "udeb" for s in stanzas): 

2731 return 

2732 

2733 lint_state.emit_diagnostic( 

2734 representation_field_range, 

2735 f"Stanza is missing field {self.name}", 

2736 missing_field_severity, 

2737 self.missing_field_authority, 

2738 ) 

2739 

2740 def reformat_field( 

2741 self, 

2742 effective_preference: "EffectiveFormattingPreference", 

2743 stanza_range: TERange, 

2744 kvpair: Deb822KeyValuePairElement, 

2745 formatter: FormatterCallback, 

2746 position_codec: LintCapablePositionCodec, 

2747 lines: list[str], 

2748 ) -> Iterable[TextEdit]: 

2749 if ( 

2750 self.name == "Architecture" 

2751 and effective_preference.deb822_normalize_field_content 

2752 ): 

2753 interpretation = self.field_value_class.interpreter() 

2754 assert interpretation is not None 

2755 interpreted = kvpair.interpret_as(interpretation) 

2756 archs = list(interpreted) 

2757 # Sort, with wildcard entries (such as linux-any) first: 

2758 archs = sorted(archs, key=lambda x: ("any" not in x, x)) 

2759 new_value = f" {' '.join(archs)}\n" 

2760 reformat_edits = list( 

2761 self._reformat_field_name( 

2762 effective_preference, 

2763 stanza_range, 

2764 kvpair, 

2765 position_codec, 

2766 lines, 

2767 ) 

2768 ) 

2769 if new_value != interpreted.convert_to_text(with_field_name=False): 

2770 value_range = kvpair.value_element.range_in_parent().relative_to( 

2771 kvpair.range_in_parent().start_pos 

2772 ) 

2773 kvpair_range = te_range_to_lsp( 

2774 value_range.relative_to(stanza_range.start_pos) 

2775 ) 

2776 reformat_edits.append( 

2777 TextEdit( 

2778 position_codec.range_to_client_units(lines, kvpair_range), 

2779 new_value, 

2780 ) 

2781 ) 

2782 return reformat_edits 

2783 

2784 return super(DctrlKnownField, self).reformat_field( 

2785 effective_preference, 

2786 stanza_range, 

2787 kvpair, 

2788 formatter, 

2789 position_codec, 

2790 lines, 

2791 ) 

2792 

2793 @property 

2794 def is_relationship_field(self) -> bool: 

2795 name_lc = self.name.lower() 

2796 return ( 

2797 name_lc in all_package_relationship_fields() 

2798 or name_lc in all_source_relationship_fields() 

2799 ) 

2800 

2801 @property 

2802 def reformattable_field(self) -> bool: 

2803 return self.is_relationship_field or self.name == "Uploaders" 

2804 

2805 

2806@dataclasses.dataclass(slots=True) 

2807class DctrlRelationshipKnownField(DctrlKnownField): 

2808 allowed_version_operators: frozenset[str] = frozenset() 

2809 supports_or_relation: bool = True 

2810 

2811 @property 

2812 def is_relationship_field(self) -> bool: 

2813 return True 

2814 

2815 

2816SOURCE_FIELDS = _fields( 

2817 DctrlKnownField( 

2818 "Source", 

2819 FieldValueClass.SINGLE_VALUE, 

2820 custom_field_check=_combined_custom_field_check( 

2821 _each_value_match_regex_validation(PKGNAME_REGEX), 

2822 _has_packaging_expected_file( 

2823 "copyright", 

2824 "No copyright file (package license)", 

2825 severity="warning", 

2826 ), 

2827 _has_packaging_expected_file( 

2828 "changelog", 

2829 "No Debian changelog file", 

2830 severity="error", 

2831 ), 

2832 _has_build_instructions, 

2833 ), 

2834 ), 

2835 DctrlKnownField( 

2836 "Standards-Version", 

2837 FieldValueClass.SINGLE_VALUE, 

2838 custom_field_check=_sv_field_validation, 

2839 ), 

2840 DctrlKnownField( 

2841 "Section", 

2842 FieldValueClass.SINGLE_VALUE, 

2843 known_values=ALL_SECTIONS, 

2844 ), 

2845 DctrlKnownField( 

2846 "Priority", 

2847 FieldValueClass.SINGLE_VALUE, 

2848 ), 

2849 DctrlKnownField( 

2850 "Maintainer", 

2851 FieldValueClass.COMMA_SEPARATED_EMAIL_LIST, 

2852 custom_field_check=_combined_custom_field_check( 

2853 _maintainer_field_validator, 

2854 _canonical_maintainer_name, 

2855 ), 

2856 ), 

2857 DctrlKnownField( 

2858 "Uploaders", 

2859 FieldValueClass.COMMA_SEPARATED_EMAIL_LIST, 

2860 custom_field_check=_canonical_maintainer_name, 

2861 ), 

2862 DctrlRelationshipKnownField( 

2863 "Build-Depends", 

2864 FieldValueClass.COMMA_SEPARATED_LIST, 

2865 custom_field_check=_dctrl_validate_dep, 

2866 ), 

2867 DctrlRelationshipKnownField( 

2868 "Build-Depends-Arch", 

2869 FieldValueClass.COMMA_SEPARATED_LIST, 

2870 custom_field_check=_dctrl_validate_dep, 

2871 ), 

2872 DctrlRelationshipKnownField( 

2873 "Build-Depends-Indep", 

2874 FieldValueClass.COMMA_SEPARATED_LIST, 

2875 custom_field_check=_dctrl_validate_dep, 

2876 ), 

2877 DctrlRelationshipKnownField( 

2878 "Build-Conflicts", 

2879 FieldValueClass.COMMA_SEPARATED_LIST, 

2880 supports_or_relation=False, 

2881 custom_field_check=_dctrl_validate_dep, 

2882 ), 

2883 DctrlRelationshipKnownField( 

2884 "Build-Conflicts-Arch", 

2885 FieldValueClass.COMMA_SEPARATED_LIST, 

2886 supports_or_relation=False, 

2887 custom_field_check=_dctrl_validate_dep, 

2888 ), 

2889 DctrlRelationshipKnownField( 

2890 "Build-Conflicts-Indep", 

2891 FieldValueClass.COMMA_SEPARATED_LIST, 

2892 supports_or_relation=False, 

2893 custom_field_check=_dctrl_validate_dep, 

2894 ), 

2895 DctrlKnownField( 

2896 "Rules-Requires-Root", 

2897 FieldValueClass.SPACE_SEPARATED_LIST, 

2898 custom_field_check=_rrr_build_driver_mismatch, 

2899 ), 

2900 DctrlKnownField( 

2901 "X-Style", 

2902 FieldValueClass.SINGLE_VALUE, 

2903 known_values=ALL_PUBLIC_NAMED_STYLES_AS_KEYWORDS, 

2904 ), 

2905 DctrlKnownField( 

2906 "Homepage", 

2907 FieldValueClass.SINGLE_VALUE, 

2908 custom_field_check=_validate_homepage_field, 

2909 ), 

2910) 

2911 

2912 

2913BINARY_FIELDS = _fields( 

2914 DctrlKnownField( 

2915 "Package", 

2916 FieldValueClass.SINGLE_VALUE, 

2917 custom_field_check=_each_value_match_regex_validation(PKGNAME_REGEX), 

2918 ), 

2919 DctrlKnownField( 

2920 "Architecture", 

2921 FieldValueClass.SPACE_SEPARATED_LIST, 

2922 # FIXME: Specialize validation for architecture ("!foo" is not a "typo" and should have a better warning) 

2923 known_values=allowed_values(dpkg_arch_and_wildcards()), 

2924 ), 

2925 DctrlKnownField( 

2926 "Pre-Depends", 

2927 FieldValueClass.COMMA_SEPARATED_LIST, 

2928 custom_field_check=_dctrl_validate_dep, 

2929 ), 

2930 DctrlKnownField( 

2931 "Depends", 

2932 FieldValueClass.COMMA_SEPARATED_LIST, 

2933 custom_field_check=_dctrl_validate_dep, 

2934 ), 

2935 DctrlKnownField( 

2936 "Recommends", 

2937 FieldValueClass.COMMA_SEPARATED_LIST, 

2938 custom_field_check=_dctrl_validate_dep, 

2939 ), 

2940 DctrlKnownField( 

2941 "Suggests", 

2942 FieldValueClass.COMMA_SEPARATED_LIST, 

2943 custom_field_check=_dctrl_validate_dep, 

2944 ), 

2945 DctrlKnownField( 

2946 "Enhances", 

2947 FieldValueClass.COMMA_SEPARATED_LIST, 

2948 custom_field_check=_dctrl_validate_dep, 

2949 ), 

2950 DctrlRelationshipKnownField( 

2951 "Provides", 

2952 FieldValueClass.COMMA_SEPARATED_LIST, 

2953 custom_field_check=_dctrl_validate_dep, 

2954 supports_or_relation=False, 

2955 allowed_version_operators=frozenset(["="]), 

2956 ), 

2957 DctrlRelationshipKnownField( 

2958 "Conflicts", 

2959 FieldValueClass.COMMA_SEPARATED_LIST, 

2960 custom_field_check=_dctrl_validate_dep, 

2961 supports_or_relation=False, 

2962 ), 

2963 DctrlRelationshipKnownField( 

2964 "Breaks", 

2965 FieldValueClass.COMMA_SEPARATED_LIST, 

2966 custom_field_check=_dctrl_validate_dep, 

2967 supports_or_relation=False, 

2968 ), 

2969 DctrlRelationshipKnownField( 

2970 "Replaces", 

2971 FieldValueClass.COMMA_SEPARATED_LIST, 

2972 custom_field_check=_dctrl_validate_dep, 

2973 ), 

2974 DctrlKnownField( 

2975 "Build-Profiles", 

2976 FieldValueClass.BUILD_PROFILES_LIST, 

2977 ), 

2978 DctrlKnownField( 

2979 "Section", 

2980 FieldValueClass.SINGLE_VALUE, 

2981 known_values=ALL_SECTIONS, 

2982 ), 

2983 DctrlRelationshipKnownField( 

2984 "Built-Using", 

2985 FieldValueClass.COMMA_SEPARATED_LIST, 

2986 custom_field_check=_arch_not_all_only_field_validation, 

2987 can_complete_field_in_stanza=_complete_only_in_arch_dep_pkgs, 

2988 supports_or_relation=False, 

2989 allowed_version_operators=frozenset(["="]), 

2990 ), 

2991 DctrlRelationshipKnownField( 

2992 "Static-Built-Using", 

2993 FieldValueClass.COMMA_SEPARATED_LIST, 

2994 custom_field_check=_arch_not_all_only_field_validation, 

2995 can_complete_field_in_stanza=_complete_only_in_arch_dep_pkgs, 

2996 supports_or_relation=False, 

2997 allowed_version_operators=frozenset(["="]), 

2998 ), 

2999 DctrlKnownField( 

3000 "Multi-Arch", 

3001 FieldValueClass.SINGLE_VALUE, 

3002 custom_field_check=_dctrl_ma_field_validation, 

3003 known_values=allowed_values( 

3004 ( 

3005 Keyword( 

3006 "same", 

3007 can_complete_keyword_in_stanza=_complete_only_in_arch_dep_pkgs, 

3008 ), 

3009 ), 

3010 ), 

3011 ), 

3012 DctrlKnownField( 

3013 "XB-Installer-Menu-Item", 

3014 FieldValueClass.SINGLE_VALUE, 

3015 can_complete_field_in_stanza=_complete_only_for_udeb_pkgs, 

3016 custom_field_check=_combined_custom_field_check( 

3017 _udeb_only_field_validation, 

3018 _each_value_match_regex_validation(re.compile(r"^[1-9]\d{3,4}$")), 

3019 ), 

3020 ), 

3021 DctrlKnownField( 

3022 "X-DH-Build-For-Type", 

3023 FieldValueClass.SINGLE_VALUE, 

3024 custom_field_check=_arch_not_all_only_field_validation, 

3025 can_complete_field_in_stanza=_complete_only_in_arch_dep_pkgs, 

3026 ), 

3027 DctrlKnownField( 

3028 "X-Doc-Main-Package", 

3029 FieldValueClass.SINGLE_VALUE, 

3030 custom_field_check=_binary_package_from_same_source, 

3031 ), 

3032 DctrlKnownField( 

3033 "X-Time64-Compat", 

3034 FieldValueClass.SINGLE_VALUE, 

3035 can_complete_field_in_stanza=_complete_only_in_arch_dep_pkgs, 

3036 custom_field_check=_combined_custom_field_check( 

3037 _each_value_match_regex_validation(PKGNAME_REGEX), 

3038 _arch_not_all_only_field_validation, 

3039 ), 

3040 ), 

3041 DctrlKnownField( 

3042 "Description", 

3043 FieldValueClass.FREE_TEXT_FIELD, 

3044 custom_field_check=dctrl_description_validator, 

3045 ), 

3046 DctrlKnownField( 

3047 "XB-Cnf-Visible-Pkgname", 

3048 FieldValueClass.SINGLE_VALUE, 

3049 custom_field_check=_each_value_match_regex_validation(PKGNAME_REGEX), 

3050 ), 

3051 DctrlKnownField( 

3052 "Homepage", 

3053 FieldValueClass.SINGLE_VALUE, 

3054 show_as_inherited=False, 

3055 custom_field_check=_validate_homepage_field, 

3056 ), 

3057) 

3058_DEP5_HEADER_FIELDS = _fields( 

3059 Deb822KnownField( 

3060 "Format", 

3061 FieldValueClass.SINGLE_VALUE, 

3062 custom_field_check=_use_https_instead_of_http, 

3063 ), 

3064) 

3065_DEP5_FILES_FIELDS = _fields( 

3066 Deb822KnownField( 

3067 "Files", 

3068 FieldValueClass.DEP5_FILE_LIST, 

3069 custom_field_check=_dep5_files_check, 

3070 ), 

3071) 

3072_DEP5_LICENSE_FIELDS = _fields( 

3073 Deb822KnownField( 

3074 "License", 

3075 FieldValueClass.FREE_TEXT_FIELD, 

3076 ), 

3077) 

3078 

3079_DTESTSCTRL_FIELDS = _fields( 

3080 DTestsCtrlKnownField( 

3081 "Architecture", 

3082 FieldValueClass.SPACE_SEPARATED_LIST, 

3083 # FIXME: Specialize validation for architecture ("!fou" to "foo" would be bad) 

3084 known_values=allowed_values(dpkg_arch_and_wildcards(allow_negations=True)), 

3085 ), 

3086) 

3087_DWATCH_HEADER_FIELDS = _fields() 

3088_DWATCH_TEMPLATE_FIELDS = _fields() 

3089_DWATCH_SOURCE_FIELDS = _fields() 

3090 

3091 

3092@dataclasses.dataclass(slots=True) 

3093class StanzaMetadata(Mapping[str, F], Generic[F], ABC): 

3094 stanza_type_name: str 

3095 stanza_fields: Mapping[str, F] 

3096 is_substvars_allowed_in_stanza: bool 

3097 

3098 async def stanza_diagnostics( 

3099 self, 

3100 deb822_file: Deb822FileElement, 

3101 stanza: Deb822ParagraphElement, 

3102 stanza_position_in_file: "TEPosition", 

3103 lint_state: LintState, 

3104 *, 

3105 inherit_from_stanza: Deb822ParagraphElement | None = None, 

3106 confusable_with_stanza_name: str | None = None, 

3107 confusable_with_stanza_metadata: Optional["StanzaMetadata[F]"] = None, 

3108 ) -> None: 

3109 if (confusable_with_stanza_name is None) ^ ( 3109 ↛ 3112line 3109 didn't jump to line 3112 because the condition on line 3109 was never true

3110 confusable_with_stanza_metadata is None 

3111 ): 

3112 raise ValueError( 

3113 "confusable_with_stanza_name and confusable_with_stanza_metadata must be used together" 

3114 ) 

3115 _, representation_field_range = self.stanza_representation( 

3116 stanza, 

3117 stanza_position_in_file, 

3118 ) 

3119 known_fields = self.stanza_fields 

3120 self.omitted_field_diagnostics( 

3121 lint_state, 

3122 deb822_file, 

3123 stanza, 

3124 stanza_position_in_file, 

3125 inherit_from_stanza=inherit_from_stanza, 

3126 representation_field_range=representation_field_range, 

3127 ) 

3128 seen_fields: dict[str, tuple[str, str, "TERange", list[Range], set[str]]] = {} 

3129 

3130 async for kvpair_range, kvpair in lint_state.slow_iter( 

3131 with_range_in_continuous_parts( 

3132 stanza.iter_parts(), 

3133 start_relative_to=stanza_position_in_file, 

3134 ), 

3135 yield_every=1, 

3136 ): 

3137 if not isinstance(kvpair, Deb822KeyValuePairElement): 3137 ↛ 3138line 3137 didn't jump to line 3138 because the condition on line 3137 was never true

3138 continue 

3139 field_name_token = kvpair.field_token 

3140 field_name = field_name_token.text 

3141 field_name_lc = field_name.lower() 

3142 # Defeat any tricks from `python-debian` from here on out 

3143 field_name = str(field_name) 

3144 normalized_field_name_lc = self.normalize_field_name(field_name_lc) 

3145 known_field = known_fields.get(normalized_field_name_lc) 

3146 field_value = stanza[field_name] 

3147 kvpair_range_te = kvpair.range_in_parent().relative_to( 

3148 stanza_position_in_file 

3149 ) 

3150 field_range = kvpair.field_token.range_in_parent().relative_to( 

3151 kvpair_range_te.start_pos 

3152 ) 

3153 field_position_te = field_range.start_pos 

3154 field_name_typo_detected = False 

3155 dup_field_key = ( 

3156 known_field.name 

3157 if known_field is not None 

3158 else normalized_field_name_lc 

3159 ) 

3160 existing_field_range = seen_fields.get(dup_field_key) 

3161 if existing_field_range is not None: 

3162 existing_field_range[3].append(field_range) 

3163 existing_field_range[4].add(field_name) 

3164 else: 

3165 normalized_field_name = self.normalize_field_name(field_name) 

3166 seen_fields[dup_field_key] = ( 

3167 known_field.name if known_field else field_name, 

3168 normalized_field_name, 

3169 field_range, 

3170 [], 

3171 {field_name}, 

3172 ) 

3173 

3174 if known_field is None: 

3175 candidates = detect_possible_typo( 

3176 normalized_field_name_lc, known_fields 

3177 ) 

3178 if candidates: 

3179 known_field = known_fields[candidates[0]] 

3180 field_range = TERange.from_position_and_size( 

3181 field_position_te, kvpair.field_token.size() 

3182 ) 

3183 field_name_typo_detected = True 

3184 lint_state.emit_diagnostic( 

3185 field_range, 

3186 f'The "{field_name}" looks like a typo of "{known_field.name}".', 

3187 "warning", 

3188 "debputy", 

3189 quickfixes=[ 

3190 propose_correct_text_quick_fix(known_fields[m].name) 

3191 for m in candidates 

3192 ], 

3193 ) 

3194 if field_value.strip() == "": 3194 ↛ 3195line 3194 didn't jump to line 3195 because the condition on line 3194 was never true

3195 lint_state.emit_diagnostic( 

3196 field_range, 

3197 f"The {field_name} has no value. Either provide a value or remove it.", 

3198 "error", 

3199 "Policy 5.1", 

3200 ) 

3201 continue 

3202 if known_field is None: 

3203 known_else_where = confusable_with_stanza_metadata.stanza_fields.get( 

3204 normalized_field_name_lc 

3205 ) 

3206 if known_else_where is not None: 

3207 lint_state.emit_diagnostic( 

3208 field_range, 

3209 f"The {kvpair.field_name} is defined for use in the" 

3210 f' "{confusable_with_stanza_name}" stanza. Please move it to the right place or remove it', 

3211 "error", 

3212 known_else_where.missing_field_authority, 

3213 ) 

3214 continue 

3215 await known_field.field_diagnostics( 

3216 deb822_file, 

3217 kvpair, 

3218 stanza, 

3219 stanza_position_in_file, 

3220 kvpair_range_te, 

3221 lint_state, 

3222 field_name_typo_reported=field_name_typo_detected, 

3223 ) 

3224 

3225 inherit_value = ( 

3226 inherit_from_stanza.get(field_name) if inherit_from_stanza else None 

3227 ) 

3228 

3229 if ( 

3230 known_field.inheritable_from_other_stanza 

3231 and inherit_value is not None 

3232 and field_value == inherit_value 

3233 ): 

3234 quick_fix = propose_remove_range_quick_fix( 

3235 proposed_title="Remove redundant definition" 

3236 ) 

3237 lint_state.emit_diagnostic( 

3238 kvpair_range_te, 

3239 f"The field {field_name} duplicates the value from the Source stanza.", 

3240 "informational", 

3241 "debputy", 

3242 quickfixes=[quick_fix], 

3243 ) 

3244 for ( 

3245 field_name, 

3246 normalized_field_name, 

3247 field_range, 

3248 duplicates, 

3249 used_fields, 

3250 ) in seen_fields.values(): 

3251 if not duplicates: 

3252 continue 

3253 if len(used_fields) != 1 or field_name not in used_fields: 

3254 via_aliases_msg = " (via aliases)" 

3255 else: 

3256 via_aliases_msg = "" 

3257 for dup_range in duplicates: 

3258 lint_state.emit_diagnostic( 

3259 dup_range, 

3260 f'The field "{field_name}"{via_aliases_msg} was used multiple times in this stanza.' 

3261 " Please ensure the field is only used once per stanza.", 

3262 "error", 

3263 "Policy 5.1", 

3264 related_information=[ 

3265 lint_state.related_diagnostic_information( 

3266 field_range, 

3267 message=f"First definition of {field_name}", 

3268 ), 

3269 ], 

3270 ) 

3271 

3272 def __getitem__(self, key: str) -> F: 

3273 key_lc = key.lower() 

3274 key_norm = normalize_dctrl_field_name(key_lc) 

3275 return self.stanza_fields[key_norm] 

3276 

3277 def __len__(self) -> int: 

3278 return len(self.stanza_fields) 

3279 

3280 def __iter__(self) -> Iterator[str]: 

3281 return iter(self.stanza_fields.keys()) 

3282 

3283 def omitted_field_diagnostics( 

3284 self, 

3285 lint_state: LintState, 

3286 deb822_file: Deb822FileElement, 

3287 stanza: Deb822ParagraphElement, 

3288 stanza_position: "TEPosition", 

3289 *, 

3290 inherit_from_stanza: Deb822ParagraphElement | None = None, 

3291 representation_field_range: Range | None = None, 

3292 ) -> None: 

3293 if representation_field_range is None: 3293 ↛ 3294line 3293 didn't jump to line 3294 because the condition on line 3293 was never true

3294 _, representation_field_range = self.stanza_representation( 

3295 stanza, 

3296 stanza_position, 

3297 ) 

3298 for known_field in self.stanza_fields.values(): 

3299 if known_field.name in stanza: 

3300 continue 

3301 

3302 known_field.field_omitted_diagnostics( 

3303 deb822_file, 

3304 representation_field_range, 

3305 stanza, 

3306 stanza_position, 

3307 inherit_from_stanza, 

3308 lint_state, 

3309 ) 

3310 

3311 def _paragraph_representation_field( 

3312 self, 

3313 paragraph: Deb822ParagraphElement, 

3314 ) -> Deb822KeyValuePairElement: 

3315 return next(iter(paragraph.iter_parts_of_type(Deb822KeyValuePairElement))) 

3316 

3317 def normalize_field_name(self, field_name: str) -> str: 

3318 return field_name 

3319 

3320 def stanza_representation( 

3321 self, 

3322 stanza: Deb822ParagraphElement, 

3323 stanza_position: TEPosition, 

3324 ) -> tuple[Deb822KeyValuePairElement, TERange]: 

3325 representation_field = self._paragraph_representation_field(stanza) 

3326 representation_field_range = representation_field.range_in_parent().relative_to( 

3327 stanza_position 

3328 ) 

3329 return representation_field, representation_field_range 

3330 

3331 def reformat_stanza( 

3332 self, 

3333 effective_preference: "EffectiveFormattingPreference", 

3334 stanza: Deb822ParagraphElement, 

3335 stanza_range: TERange, 

3336 formatter: FormatterCallback, 

3337 position_codec: LintCapablePositionCodec, 

3338 lines: list[str], 

3339 ) -> Iterable[TextEdit]: 

3340 for field_name in stanza: 

3341 known_field = self.stanza_fields.get(field_name.lower()) 

3342 if known_field is None: 

3343 continue 

3344 kvpair = stanza.get_kvpair_element(field_name) 

3345 yield from known_field.reformat_field( 

3346 effective_preference, 

3347 stanza_range, 

3348 kvpair, 

3349 formatter, 

3350 position_codec, 

3351 lines, 

3352 ) 

3353 

3354 

3355@dataclasses.dataclass(slots=True) 

3356class Dep5StanzaMetadata(StanzaMetadata[Deb822KnownField]): 

3357 pass 

3358 

3359 

3360@dataclasses.dataclass(slots=True) 

3361class DctrlStanzaMetadata(StanzaMetadata[DctrlKnownField]): 

3362 

3363 def normalize_field_name(self, field_name: str) -> str: 

3364 return normalize_dctrl_field_name(field_name) 

3365 

3366 

3367@dataclasses.dataclass(slots=True) 

3368class DTestsCtrlStanzaMetadata(StanzaMetadata[DTestsCtrlKnownField]): 

3369 

3370 def omitted_field_diagnostics( 

3371 self, 

3372 lint_state: LintState, 

3373 deb822_file: Deb822FileElement, 

3374 stanza: Deb822ParagraphElement, 

3375 stanza_position: "TEPosition", 

3376 *, 

3377 inherit_from_stanza: Deb822ParagraphElement | None = None, 

3378 representation_field_range: Range | None = None, 

3379 ) -> None: 

3380 if representation_field_range is None: 3380 ↛ 3381line 3380 didn't jump to line 3381 because the condition on line 3380 was never true

3381 _, representation_field_range = self.stanza_representation( 

3382 stanza, 

3383 stanza_position, 

3384 ) 

3385 auth_ref = self.stanza_fields["tests"].missing_field_authority 

3386 if "Tests" not in stanza and "Test-Command" not in stanza: 

3387 lint_state.emit_diagnostic( 

3388 representation_field_range, 

3389 'Stanza must have either a "Tests" or a "Test-Command" field', 

3390 "error", 

3391 # TODO: Better authority_reference 

3392 auth_ref, 

3393 ) 

3394 if "Tests" in stanza and "Test-Command" in stanza: 

3395 lint_state.emit_diagnostic( 

3396 representation_field_range, 

3397 'Stanza cannot have both a "Tests" and a "Test-Command" field', 

3398 "error", 

3399 # TODO: Better authority_reference 

3400 auth_ref, 

3401 ) 

3402 

3403 # Note that since we do not use the field names for stanza classification, we 

3404 # always do the super call. 

3405 super(DTestsCtrlStanzaMetadata, self).omitted_field_diagnostics( 

3406 lint_state, 

3407 deb822_file, 

3408 stanza, 

3409 stanza_position, 

3410 representation_field_range=representation_field_range, 

3411 inherit_from_stanza=inherit_from_stanza, 

3412 ) 

3413 

3414 

3415@dataclasses.dataclass(slots=True) 

3416class DebianWatchStanzaMetadata(StanzaMetadata[Deb822KnownField]): 

3417 

3418 def omitted_field_diagnostics( 

3419 self, 

3420 lint_state: LintState, 

3421 deb822_file: Deb822FileElement, 

3422 stanza: Deb822ParagraphElement, 

3423 stanza_position: "TEPosition", 

3424 *, 

3425 inherit_from_stanza: Deb822ParagraphElement | None = None, 

3426 representation_field_range: Range | None = None, 

3427 ) -> None: 

3428 if representation_field_range is None: 3428 ↛ 3429line 3428 didn't jump to line 3429 because the condition on line 3428 was never true

3429 _, representation_field_range = self.stanza_representation( 

3430 stanza, 

3431 stanza_position, 

3432 ) 

3433 

3434 if ( 3434 ↛ 3439line 3434 didn't jump to line 3439 because the condition on line 3434 was never true

3435 self.stanza_type_name != "Header" 

3436 and "Source" not in stanza 

3437 and "Template" not in stanza 

3438 ): 

3439 lint_state.emit_diagnostic( 

3440 representation_field_range, 

3441 'Stanza must have either a "Source" or a "Template" field', 

3442 "error", 

3443 # TODO: Better authority_reference 

3444 "debputy", 

3445 ) 

3446 # The required fields depends on which stanza it is. Therefore, we omit the super 

3447 # call until this error is resolved. 

3448 return 

3449 

3450 super(DebianWatchStanzaMetadata, self).omitted_field_diagnostics( 

3451 lint_state, 

3452 deb822_file, 

3453 stanza, 

3454 stanza_position, 

3455 representation_field_range=representation_field_range, 

3456 inherit_from_stanza=inherit_from_stanza, 

3457 ) 

3458 

3459 

3460def lsp_reference_data_dir() -> str: 

3461 return os.path.join( 

3462 os.path.dirname(__file__), 

3463 "data", 

3464 ) 

3465 

3466 

3467class Deb822FileMetadata(Generic[S, F]): 

3468 

3469 def __init__(self) -> None: 

3470 self._is_initialized = False 

3471 self._data: Deb822ReferenceData | None = None 

3472 

3473 @property 

3474 def reference_data_basename(self) -> str: 

3475 raise NotImplementedError 

3476 

3477 def _new_field( 

3478 self, 

3479 name: str, 

3480 field_value_type: FieldValueClass, 

3481 ) -> F: 

3482 raise NotImplementedError 

3483 

3484 def _reference_data(self) -> Deb822ReferenceData: 

3485 ref = self._data 

3486 if ref is not None: 3486 ↛ 3487line 3486 didn't jump to line 3487 because the condition on line 3486 was never true

3487 return ref 

3488 

3489 p = importlib.resources.files(deb822_ref_data_dir.__name__).joinpath( 

3490 self.reference_data_basename 

3491 ) 

3492 

3493 with p.open("r", encoding="utf-8") as fd: 

3494 raw = MANIFEST_YAML.load(fd) 

3495 

3496 attr_path = AttributePath.root_path(p) 

3497 try: 

3498 ref = DEB822_REFERENCE_DATA_PARSER.parse_input(raw, attr_path) 

3499 except ManifestParseException as e: 

3500 raise ValueError( 

3501 f"Internal error: Could not parse reference data [{self.reference_data_basename}]: {e.message}" 

3502 ) from e 

3503 self._data = ref 

3504 return ref 

3505 

3506 @property 

3507 def is_initialized(self) -> bool: 

3508 return self._is_initialized 

3509 

3510 def ensure_initialized(self) -> None: 

3511 if self.is_initialized: 

3512 return 

3513 # Enables us to use __getitem__ 

3514 self._is_initialized = True 

3515 ref_data = self._reference_data() 

3516 ref_defs = ref_data.get("definitions") 

3517 variables = {} 

3518 ref_variables = ref_defs.get("variables", []) if ref_defs else [] 

3519 for ref_variable in ref_variables: 

3520 name = ref_variable["name"] 

3521 fallback = ref_variable["fallback"] 

3522 variables[name] = fallback 

3523 

3524 def _resolve_doc(template: str | None) -> str | None: 

3525 if template is None: 3525 ↛ 3526line 3525 didn't jump to line 3526 because the condition on line 3525 was never true

3526 return None 

3527 try: 

3528 return template.format(**variables) 

3529 except ValueError as e: 

3530 template_escaped = template.replace("\n", "\\r") 

3531 _error(f"Bad template: {template_escaped}: {e}") 

3532 

3533 for ref_stanza_type in ref_data["stanza_types"]: 

3534 stanza_name = ref_stanza_type["stanza_name"] 

3535 stanza = self[stanza_name] 

3536 stanza_fields = dict(stanza.stanza_fields) 

3537 stanza.stanza_fields = stanza_fields 

3538 for ref_field in ref_stanza_type["fields"]: 

3539 _resolve_field( 

3540 ref_field, 

3541 stanza_fields, 

3542 self._new_field, 

3543 _resolve_doc, 

3544 f"Stanza:{stanza.stanza_type_name}|Field:{ref_field['canonical_name']}", 

3545 ) 

3546 

3547 def file_metadata_applies_to_file( 

3548 self, 

3549 deb822_file: Deb822FileElement | None, 

3550 ) -> bool: 

3551 return deb822_file is not None 

3552 

3553 def classify_stanza(self, stanza: Deb822ParagraphElement, stanza_idx: int) -> S: 

3554 return self.guess_stanza_classification_by_idx(stanza_idx) 

3555 

3556 def guess_stanza_classification_by_idx(self, stanza_idx: int) -> S: 

3557 raise NotImplementedError 

3558 

3559 def stanza_types(self) -> Iterable[S]: 

3560 raise NotImplementedError 

3561 

3562 def __getitem__(self, item: str) -> S: 

3563 raise NotImplementedError 

3564 

3565 def get(self, item: str) -> S | None: 

3566 try: 

3567 return self[item] 

3568 except KeyError: 

3569 return None 

3570 

3571 def reformat( 

3572 self, 

3573 effective_preference: "EffectiveFormattingPreference", 

3574 deb822_file: Deb822FileElement, 

3575 formatter: FormatterCallback, 

3576 _content: str, 

3577 position_codec: LintCapablePositionCodec, 

3578 lines: list[str], 

3579 ) -> Iterable[TextEdit]: 

3580 stanza_idx = -1 

3581 for token_or_element in deb822_file.iter_parts(): 

3582 if isinstance(token_or_element, Deb822ParagraphElement): 

3583 stanza_range = token_or_element.range_in_parent() 

3584 stanza_idx += 1 

3585 stanza_metadata = self.classify_stanza(token_or_element, stanza_idx) 

3586 yield from stanza_metadata.reformat_stanza( 

3587 effective_preference, 

3588 token_or_element, 

3589 stanza_range, 

3590 formatter, 

3591 position_codec, 

3592 lines, 

3593 ) 

3594 else: 

3595 token_range = token_or_element.range_in_parent() 

3596 yield from trim_end_of_line_whitespace( 

3597 position_codec, 

3598 lines, 

3599 line_range=range( 

3600 token_range.start_pos.line_position, 

3601 token_range.end_pos.line_position, 

3602 ), 

3603 ) 

3604 

3605 

3606_DCTRL_SOURCE_STANZA = DctrlStanzaMetadata( 

3607 "Source", 

3608 SOURCE_FIELDS, 

3609 is_substvars_allowed_in_stanza=False, 

3610) 

3611_DCTRL_PACKAGE_STANZA = DctrlStanzaMetadata( 

3612 "Package", 

3613 BINARY_FIELDS, 

3614 is_substvars_allowed_in_stanza=True, 

3615) 

3616 

3617_DEP5_HEADER_STANZA = Dep5StanzaMetadata( 

3618 "Header", 

3619 _DEP5_HEADER_FIELDS, 

3620 is_substvars_allowed_in_stanza=False, 

3621) 

3622_DEP5_FILES_STANZA = Dep5StanzaMetadata( 

3623 "Files", 

3624 _DEP5_FILES_FIELDS, 

3625 is_substvars_allowed_in_stanza=False, 

3626) 

3627_DEP5_LICENSE_STANZA = Dep5StanzaMetadata( 

3628 "License", 

3629 _DEP5_LICENSE_FIELDS, 

3630 is_substvars_allowed_in_stanza=False, 

3631) 

3632 

3633_DTESTSCTRL_STANZA = DTestsCtrlStanzaMetadata( 

3634 "Tests", 

3635 _DTESTSCTRL_FIELDS, 

3636 is_substvars_allowed_in_stanza=False, 

3637) 

3638 

3639_WATCH_HEADER_HEADER_STANZA = DebianWatchStanzaMetadata( 

3640 "Header", 

3641 _DWATCH_HEADER_FIELDS, 

3642 is_substvars_allowed_in_stanza=False, 

3643) 

3644_WATCH_SOURCE_STANZA = DebianWatchStanzaMetadata( 

3645 "Source", 

3646 _DWATCH_SOURCE_FIELDS, 

3647 is_substvars_allowed_in_stanza=False, 

3648) 

3649 

3650 

3651class Dep5FileMetadata(Deb822FileMetadata[Dep5StanzaMetadata, Deb822KnownField]): 

3652 

3653 @property 

3654 def reference_data_basename(self) -> str: 

3655 return "debian_copyright_reference_data.yaml" 

3656 

3657 def _new_field( 

3658 self, 

3659 name: str, 

3660 field_value_type: FieldValueClass, 

3661 ) -> F: 

3662 return Deb822KnownField(name, field_value_type) 

3663 

3664 def file_metadata_applies_to_file( 

3665 self, 

3666 deb822_file: Deb822FileElement | None, 

3667 ) -> bool: 

3668 if not super().file_metadata_applies_to_file(deb822_file): 3668 ↛ 3669line 3668 didn't jump to line 3669 because the condition on line 3668 was never true

3669 return False 

3670 first_stanza = next(iter(deb822_file), None) 

3671 if first_stanza is None or "Format" not in first_stanza: 

3672 # No parseable stanzas or the first one did not have a Format, which is necessary. 

3673 return False 

3674 

3675 for part in deb822_file.iter_parts(): 3675 ↛ 3681line 3675 didn't jump to line 3681 because the loop on line 3675 didn't complete

3676 if part.is_error: 

3677 # Error first, then it might just be a "Format:" in the middle of a free-text file. 

3678 return False 

3679 if part is first_stanza: 3679 ↛ 3675line 3679 didn't jump to line 3675 because the condition on line 3679 was always true

3680 break 

3681 return True 

3682 

3683 def classify_stanza( 

3684 self, 

3685 stanza: Deb822ParagraphElement, 

3686 stanza_idx: int, 

3687 ) -> Dep5StanzaMetadata: 

3688 self.ensure_initialized() 

3689 if stanza_idx == 0: 3689 ↛ 3690line 3689 didn't jump to line 3690 because the condition on line 3689 was never true

3690 return _DEP5_HEADER_STANZA 

3691 if stanza_idx > 0: 3691 ↛ 3695line 3691 didn't jump to line 3695 because the condition on line 3691 was always true

3692 if "Files" in stanza: 

3693 return _DEP5_FILES_STANZA 

3694 return _DEP5_LICENSE_STANZA 

3695 raise ValueError("The stanza_idx must be 0 or greater") 

3696 

3697 def guess_stanza_classification_by_idx(self, stanza_idx: int) -> Dep5StanzaMetadata: 

3698 self.ensure_initialized() 

3699 if stanza_idx == 0: 

3700 return _DEP5_HEADER_STANZA 

3701 if stanza_idx > 0: 

3702 return _DEP5_FILES_STANZA 

3703 raise ValueError("The stanza_idx must be 0 or greater") 

3704 

3705 def stanza_types(self) -> Iterable[Dep5StanzaMetadata]: 

3706 self.ensure_initialized() 

3707 # Order assumption made in the LSP code. 

3708 yield _DEP5_HEADER_STANZA 

3709 yield _DEP5_FILES_STANZA 

3710 yield _DEP5_LICENSE_STANZA 

3711 

3712 def __getitem__(self, item: str) -> Dep5StanzaMetadata: 

3713 self.ensure_initialized() 

3714 if item == "Header": 

3715 return _DEP5_HEADER_STANZA 

3716 if item == "Files": 

3717 return _DEP5_FILES_STANZA 

3718 if item == "License": 3718 ↛ 3720line 3718 didn't jump to line 3720 because the condition on line 3718 was always true

3719 return _DEP5_LICENSE_STANZA 

3720 raise KeyError(item) 

3721 

3722 

3723class DebianWatch5FileMetadata( 

3724 Deb822FileMetadata[DebianWatchStanzaMetadata, Deb822KnownField] 

3725): 

3726 

3727 @property 

3728 def reference_data_basename(self) -> str: 

3729 return "debian_watch_reference_data.yaml" 

3730 

3731 def _new_field( 

3732 self, 

3733 name: str, 

3734 field_value_type: FieldValueClass, 

3735 ) -> F: 

3736 return Deb822KnownField(name, field_value_type) 

3737 

3738 def file_metadata_applies_to_file( 

3739 self, deb822_file: Deb822FileElement | None 

3740 ) -> bool: 

3741 if not super().file_metadata_applies_to_file(deb822_file): 3741 ↛ 3742line 3741 didn't jump to line 3742 because the condition on line 3741 was never true

3742 return False 

3743 first_stanza = next(iter(deb822_file), None) 

3744 

3745 if first_stanza is None or "Version" not in first_stanza: 3745 ↛ 3747line 3745 didn't jump to line 3747 because the condition on line 3745 was never true

3746 # No parseable stanzas or the first one did not have a Version field, which is necessary. 

3747 return False 

3748 

3749 try: 

3750 if int(first_stanza.get("Version")) < 5: 3750 ↛ 3751line 3750 didn't jump to line 3751 because the condition on line 3750 was never true

3751 return False 

3752 except (ValueError, IndexError, TypeError): 

3753 return False 

3754 

3755 for part in deb822_file.iter_parts(): 3755 ↛ 3761line 3755 didn't jump to line 3761 because the loop on line 3755 didn't complete

3756 if part.is_error: 3756 ↛ 3758line 3756 didn't jump to line 3758 because the condition on line 3756 was never true

3757 # Error first, then it might just be a "Version:" in the middle of a free-text file. 

3758 return False 

3759 if part is first_stanza: 3759 ↛ 3755line 3759 didn't jump to line 3755 because the condition on line 3759 was always true

3760 break 

3761 return True 

3762 

3763 def classify_stanza( 

3764 self, 

3765 stanza: Deb822ParagraphElement, 

3766 stanza_idx: int, 

3767 ) -> DebianWatchStanzaMetadata: 

3768 self.ensure_initialized() 

3769 if stanza_idx == 0: 

3770 return _WATCH_HEADER_HEADER_STANZA 

3771 if stanza_idx > 0: 3771 ↛ 3773line 3771 didn't jump to line 3773 because the condition on line 3771 was always true

3772 return _WATCH_SOURCE_STANZA 

3773 raise ValueError("The stanza_idx must be 0 or greater") 

3774 

3775 def guess_stanza_classification_by_idx( 

3776 self, stanza_idx: int 

3777 ) -> DebianWatchStanzaMetadata: 

3778 self.ensure_initialized() 

3779 if stanza_idx == 0: 3779 ↛ 3780line 3779 didn't jump to line 3780 because the condition on line 3779 was never true

3780 return _WATCH_HEADER_HEADER_STANZA 

3781 if stanza_idx > 0: 3781 ↛ 3783line 3781 didn't jump to line 3783 because the condition on line 3781 was always true

3782 return _WATCH_SOURCE_STANZA 

3783 raise ValueError("The stanza_idx must be 0 or greater") 

3784 

3785 def stanza_types(self) -> Iterable[DebianWatchStanzaMetadata]: 

3786 self.ensure_initialized() 

3787 # Order assumption made in the LSP code. 

3788 yield _WATCH_HEADER_HEADER_STANZA 

3789 yield _WATCH_SOURCE_STANZA 

3790 

3791 def __getitem__(self, item: str) -> DebianWatchStanzaMetadata: 

3792 self.ensure_initialized() 

3793 if item == "Header": 

3794 return _WATCH_HEADER_HEADER_STANZA 

3795 if item == "Source": 3795 ↛ 3797line 3795 didn't jump to line 3797 because the condition on line 3795 was always true

3796 return _WATCH_SOURCE_STANZA 

3797 raise KeyError(item) 

3798 

3799 

3800def _resolve_keyword( 

3801 ref_value: StaticValue, 

3802 known_values: dict[str, Keyword], 

3803 resolve_template: Callable[[str | None], str | None], 

3804 translation_context: str, 

3805) -> None: 

3806 value_key = ref_value["value"] 

3807 changes = { 

3808 "translation_context": translation_context, 

3809 } 

3810 try: 

3811 known_value = known_values[value_key] 

3812 except KeyError: 

3813 known_value = Keyword(value_key) 

3814 known_values[value_key] = known_value 

3815 else: 

3816 if known_value.is_alias_of: 3816 ↛ 3817line 3816 didn't jump to line 3817 because the condition on line 3816 was never true

3817 raise ValueError( 

3818 f"The value {known_value.value} has an alias {known_value.is_alias_of} that conflicts with" 

3819 f' {value_key} or the data file used an alias in its `canonical-name` rather than the "true" name' 

3820 ) 

3821 value_doc = ref_value.get("documentation") 

3822 if value_doc is not None: 

3823 changes["synopsis"] = value_doc.get("synopsis") 

3824 changes["long_description"] = resolve_template( 

3825 value_doc.get("long_description") 

3826 ) 

3827 if is_exclusive := ref_value.get("is_exclusive"): 

3828 changes["is_exclusive"] = is_exclusive 

3829 if (sort_key := ref_value.get("sort_key")) is not None: 

3830 changes["sort_text"] = sort_key 

3831 if (usage_hint := ref_value.get("usage_hint")) is not None: 

3832 changes["usage_hint"] = usage_hint 

3833 if changes: 3833 ↛ 3837line 3833 didn't jump to line 3837 because the condition on line 3833 was always true

3834 known_value = known_value.replace(**changes) 

3835 known_values[value_key] = known_value 

3836 

3837 _expand_aliases( 

3838 known_value, 

3839 known_values, 

3840 operator.attrgetter("value"), 

3841 ref_value.get("aliases"), 

3842 "The value `{ALIAS}` is an alias of `{NAME}`.", 

3843 ) 

3844 

3845 

3846def _resolve_field( 

3847 ref_field: Deb822Field, 

3848 stanza_fields: dict[str, F], 

3849 field_constructor: Callable[[str, FieldValueClass], F], 

3850 resolve_template: Callable[[str | None], str | None], 

3851 translation_context: str, 

3852) -> None: 

3853 field_name = ref_field["canonical_name"] 

3854 field_value_type = FieldValueClass.from_key(ref_field["field_value_type"]) 

3855 doc = ref_field.get("documentation") 

3856 ref_values = ref_field.get("values", []) 

3857 norm_field_name = normalize_dctrl_field_name(field_name.lower()) 

3858 

3859 try: 

3860 field = stanza_fields[norm_field_name] 

3861 except KeyError: 

3862 field = field_constructor( 

3863 field_name, 

3864 field_value_type, 

3865 ) 

3866 stanza_fields[norm_field_name] = field 

3867 else: 

3868 if field.name != field_name: 3868 ↛ 3869line 3868 didn't jump to line 3869 because the condition on line 3868 was never true

3869 _error( 

3870 f'Error in reference data: Code uses "{field.name}" as canonical name and the data file' 

3871 f" uses {field_name}. Please ensure the data is correctly aligned." 

3872 ) 

3873 if field.field_value_class != field_value_type: 3873 ↛ 3874line 3873 didn't jump to line 3874 because the condition on line 3873 was never true

3874 _error( 

3875 f'Error in reference data for field "{field.name}": Code has' 

3876 f" {field.field_value_class.key} and the data file uses {field_value_type.key}" 

3877 f" for field-value-type. Please ensure the data is correctly aligned." 

3878 ) 

3879 if field.is_alias_of: 3879 ↛ 3880line 3879 didn't jump to line 3880 because the condition on line 3879 was never true

3880 raise ValueError( 

3881 f"The field {field.name} has an alias {field.is_alias_of} that conflicts with" 

3882 f' {field_name} or the data file used an alias in its `canonical-name` rather than the "true" name' 

3883 ) 

3884 

3885 if doc is not None: 

3886 field.synopsis = doc.get("synopsis") 

3887 field.long_description = resolve_template(doc.get("long_description")) 

3888 

3889 field.default_value = ref_field.get("default_value") 

3890 field.warn_if_default = ref_field.get("warn_if_default", True) 

3891 field.spellcheck_value = ref_field.get("spellcheck_value", False) 

3892 field.deprecated_with_no_replacement = ref_field.get( 

3893 "is_obsolete_without_replacement", False 

3894 ) 

3895 field.replaced_by = ref_field.get("replaced_by") 

3896 field.translation_context = translation_context 

3897 field.usage_hint = ref_field.get("usage_hint") 

3898 field.missing_field_severity = ref_field.get("missing_field_severity", None) 

3899 unknown_value_severity = ref_field.get("unknown_value_severity", "error") 

3900 field.unknown_value_severity = ( 

3901 None if unknown_value_severity == "none" else unknown_value_severity 

3902 ) 

3903 field.unknown_value_authority = ref_field.get("unknown_value_authority", "debputy") 

3904 field.missing_field_authority = ref_field.get("missing_field_authority", "debputy") 

3905 field.is_substvars_disabled_even_if_allowed_by_stanza = not ref_field.get( 

3906 "supports_substvars", 

3907 True, 

3908 ) 

3909 field.inheritable_from_other_stanza = ref_field.get( 

3910 "inheritable_from_other_stanza", 

3911 False, 

3912 ) 

3913 

3914 known_values = field.known_values 

3915 if known_values is None: 

3916 known_values = {} 

3917 else: 

3918 known_values = dict(known_values) 

3919 

3920 for ref_value in ref_values: 

3921 _resolve_keyword(ref_value, known_values, resolve_template, translation_context) 

3922 

3923 if known_values: 

3924 field.known_values = known_values 

3925 

3926 _expand_aliases( 

3927 field, 

3928 stanza_fields, 

3929 operator.attrgetter("name"), 

3930 ref_field.get("aliases"), 

3931 "The field `{ALIAS}` is an alias of `{NAME}`.", 

3932 ) 

3933 

3934 

3935A = TypeVar("A", Keyword, Deb822KnownField) 

3936 

3937 

3938def _expand_aliases( 

3939 item: A, 

3940 item_container: dict[str, A], 

3941 canonical_name_resolver: Callable[[A], str], 

3942 aliases_ref: list[Alias] | None, 

3943 doc_template: str, 

3944) -> None: 

3945 if aliases_ref is None: 

3946 return 

3947 name = canonical_name_resolver(item) 

3948 assert name is not None, "canonical_name_resolver is not allowed to return None" 

3949 for alias_ref in aliases_ref: 

3950 alias_name = alias_ref["alias"] 

3951 alias_doc = item.long_description 

3952 is_completion_suggestion = alias_ref.get("is_completion_suggestion", False) 

3953 doc_suffix = doc_template.format(NAME=name, ALIAS=alias_name) 

3954 if alias_doc: 

3955 alias_doc += f"\n\n{doc_suffix}" 

3956 else: 

3957 alias_doc = doc_suffix 

3958 alias_field = item.replace( 

3959 long_description=alias_doc, 

3960 is_alias_of=name, 

3961 is_completion_suggestion=is_completion_suggestion, 

3962 ) 

3963 alias_key = alias_name.lower() 

3964 if alias_name in item_container: 3964 ↛ 3965line 3964 didn't jump to line 3965 because the condition on line 3964 was never true

3965 existing_name = canonical_name_resolver(item_container[alias_key]) 

3966 assert ( 

3967 existing_name is not None 

3968 ), "canonical_name_resolver is not allowed to return None" 

3969 raise ValueError( 

3970 f"The value {name} has an alias {alias_name} that conflicts with {existing_name}" 

3971 ) 

3972 item_container[alias_key] = alias_field 

3973 

3974 

3975class DctrlFileMetadata(Deb822FileMetadata[DctrlStanzaMetadata, DctrlKnownField]): 

3976 

3977 @property 

3978 def reference_data_basename(self) -> str: 

3979 return "debian_control_reference_data.yaml" 

3980 

3981 def _new_field( 

3982 self, 

3983 name: str, 

3984 field_value_type: FieldValueClass, 

3985 ) -> F: 

3986 return DctrlKnownField(name, field_value_type) 

3987 

3988 def guess_stanza_classification_by_idx( 

3989 self, 

3990 stanza_idx: int, 

3991 ) -> DctrlStanzaMetadata: 

3992 self.ensure_initialized() 

3993 if stanza_idx == 0: 

3994 return _DCTRL_SOURCE_STANZA 

3995 if stanza_idx > 0: 3995 ↛ 3997line 3995 didn't jump to line 3997 because the condition on line 3995 was always true

3996 return _DCTRL_PACKAGE_STANZA 

3997 raise ValueError("The stanza_idx must be 0 or greater") 

3998 

3999 def stanza_types(self) -> Iterable[DctrlStanzaMetadata]: 

4000 self.ensure_initialized() 

4001 # Order assumption made in the LSP code. 

4002 yield _DCTRL_SOURCE_STANZA 

4003 yield _DCTRL_PACKAGE_STANZA 

4004 

4005 def __getitem__(self, item: str) -> DctrlStanzaMetadata: 

4006 self.ensure_initialized() 

4007 if item == "Source": 

4008 return _DCTRL_SOURCE_STANZA 

4009 if item == "Package": 4009 ↛ 4011line 4009 didn't jump to line 4011 because the condition on line 4009 was always true

4010 return _DCTRL_PACKAGE_STANZA 

4011 raise KeyError(item) 

4012 

4013 def reformat( 

4014 self, 

4015 effective_preference: "EffectiveFormattingPreference", 

4016 deb822_file: Deb822FileElement, 

4017 formatter: FormatterCallback, 

4018 content: str, 

4019 position_codec: LintCapablePositionCodec, 

4020 lines: list[str], 

4021 ) -> Iterable[TextEdit]: 

4022 edits = list( 

4023 super().reformat( 

4024 effective_preference, 

4025 deb822_file, 

4026 formatter, 

4027 content, 

4028 position_codec, 

4029 lines, 

4030 ) 

4031 ) 

4032 

4033 if ( 4033 ↛ 4038line 4033 didn't jump to line 4038 because the condition on line 4033 was always true

4034 not effective_preference.deb822_normalize_stanza_order 

4035 or deb822_file.find_first_error_element() is not None 

4036 ): 

4037 return edits 

4038 names = [] 

4039 for idx, stanza in enumerate(deb822_file): 

4040 if idx < 2: 

4041 continue 

4042 name = stanza.get("Package") 

4043 if name is None: 

4044 return edits 

4045 names.append(name) 

4046 

4047 reordered = sorted(names) 

4048 if names == reordered: 

4049 return edits 

4050 

4051 if edits: 

4052 content = apply_text_edits(content, lines, edits) 

4053 lines = content.splitlines(keepends=True) 

4054 deb822_file = parse_deb822_file( 

4055 lines, 

4056 accept_files_with_duplicated_fields=True, 

4057 accept_files_with_error_tokens=True, 

4058 ) 

4059 

4060 stanzas = list(deb822_file) 

4061 reordered_stanza = stanzas[:2] + sorted( 

4062 stanzas[2:], key=operator.itemgetter("Package") 

4063 ) 

4064 bits = [] 

4065 stanza_idx = 0 

4066 for token_or_element in deb822_file.iter_parts(): 

4067 if isinstance(token_or_element, Deb822ParagraphElement): 

4068 bits.append(reordered_stanza[stanza_idx].dump()) 

4069 stanza_idx += 1 

4070 else: 

4071 bits.append(token_or_element.convert_to_text()) 

4072 

4073 new_content = "".join(bits) 

4074 

4075 return [ 

4076 TextEdit( 

4077 Range( 

4078 Position(0, 0), 

4079 Position(len(lines) + 1, 0), 

4080 ), 

4081 new_content, 

4082 ) 

4083 ] 

4084 

4085 

4086class DTestsCtrlFileMetadata( 

4087 Deb822FileMetadata[DTestsCtrlStanzaMetadata, DTestsCtrlKnownField] 

4088): 

4089 

4090 @property 

4091 def reference_data_basename(self) -> str: 

4092 return "debian_tests_control_reference_data.yaml" 

4093 

4094 def _new_field( 

4095 self, 

4096 name: str, 

4097 field_value_type: FieldValueClass, 

4098 ) -> F: 

4099 return DTestsCtrlKnownField(name, field_value_type) 

4100 

4101 def guess_stanza_classification_by_idx(self, stanza_idx: int) -> S: 

4102 if stanza_idx >= 0: 4102 ↛ 4105line 4102 didn't jump to line 4105 because the condition on line 4102 was always true

4103 self.ensure_initialized() 

4104 return _DTESTSCTRL_STANZA 

4105 raise ValueError("The stanza_idx must be 0 or greater") 

4106 

4107 def stanza_types(self) -> Iterable[S]: 

4108 self.ensure_initialized() 

4109 yield _DTESTSCTRL_STANZA 

4110 

4111 def __getitem__(self, item: str) -> S: 

4112 self.ensure_initialized() 

4113 if item == "Tests": 4113 ↛ 4115line 4113 didn't jump to line 4115 because the condition on line 4113 was always true

4114 return _DTESTSCTRL_STANZA 

4115 raise KeyError(item) 

4116 

4117 

4118TRANSLATABLE_DEB822_FILE_METADATA: Sequence[ 

4119 Callable[[], Deb822FileMetadata[Any, Any]] 

4120] = [ 

4121 DctrlFileMetadata, 

4122 Dep5FileMetadata, 

4123 DTestsCtrlFileMetadata, 

4124]