Coverage for src/debputy/lsp/languages/lsp_debian_control.py: 62%

418 statements  

« prev     ^ index     » next       coverage.py v7.8.2, created at 2026-07-22 10:58 +0000

1import dataclasses 

2import importlib.resources 

3import os.path 

4import textwrap 

5from functools import lru_cache 

6from itertools import chain 

7from typing import ( 

8 Optional, 

9 Self, 

10 TYPE_CHECKING, 

11) 

12from collections.abc import Sequence, Mapping, Iterable 

13 

14import debputy.lsp.data.deb822_data as deb822_ref_data_dir 

15from debputy.analysis.analysis_util import flatten_ppfs 

16from debputy.analysis.debian_dir import resolve_debhelper_config_files 

17from debputy.dh.dh_assistant import extract_dh_compat_level 

18from debputy.linting.lint_util import ( 

19 LintState, 

20 te_range_to_lsp, 

21 te_position_to_lsp, 

22 with_range_in_continuous_parts, 

23) 

24from debputy.lsp.apt_cache import PackageLookup 

25from debputy.lsp.debputy_ls import DebputyLanguageServer 

26from debputy.lsp.lsp_debian_control_reference_data import ( 

27 DctrlKnownField, 

28 DctrlFileMetadata, 

29 package_name_to_section, 

30 all_package_relationship_fields, 

31 extract_first_value_and_position, 

32 all_source_relationship_fields, 

33 StanzaMetadata, 

34 SUBSTVAR_RE, 

35) 

36from debputy.lsp.lsp_features import ( 

37 lint_diagnostics, 

38 lsp_completer, 

39 lsp_hover, 

40 lsp_standard_handler, 

41 lsp_folding_ranges, 

42 lsp_semantic_tokens_full, 

43 lsp_will_save_wait_until, 

44 lsp_format_document, 

45 lsp_text_doc_inlay_hints, 

46 LanguageDispatchRule, 

47 SecondaryLanguage, 

48 lsp_cli_reformat_document, 

49) 

50from debputy.lsp.lsp_generic_deb822 import ( 

51 deb822_completer, 

52 deb822_hover, 

53 deb822_folding_ranges, 

54 deb822_semantic_tokens_full, 

55 deb822_format_file, 

56 scan_for_syntax_errors_and_token_level_diagnostics, 

57) 

58from debputy.lsp.lsp_reference_keyword import LSP_DATA_DOMAIN 

59from debputy.lsp.quickfixes import ( 

60 propose_correct_text_quick_fix, 

61 propose_insert_text_on_line_after_diagnostic_quick_fix, 

62 propose_remove_range_quick_fix, 

63) 

64from debputy.lsp.ref_models.deb822_reference_parse_models import ( 

65 DCTRL_SUBSTVARS_REFERENCE_DATA_PARSER, 

66 DCtrlSubstvar, 

67) 

68from debputy.lsp.text_util import markdown_urlify 

69from debian._deb822_repro import ( 

70 Deb822ParagraphElement, 

71) 

72from debian._deb822_repro.parsing import ( 

73 Deb822KeyValuePairElement, 

74) 

75from debputy.lsprotocol.types import ( 

76 Position, 

77 FoldingRange, 

78 FoldingRangeParams, 

79 CompletionItem, 

80 CompletionList, 

81 CompletionParams, 

82 HoverParams, 

83 Hover, 

84 TEXT_DOCUMENT_CODE_ACTION, 

85 SemanticTokens, 

86 SemanticTokensParams, 

87 WillSaveTextDocumentParams, 

88 TextEdit, 

89 DocumentFormattingParams, 

90 InlayHint, 

91) 

92from debputy.manifest_parser.util import AttributePath 

93from debputy.packager_provided_files import ( 

94 PackagerProvidedFile, 

95 detect_all_packager_provided_files, 

96) 

97from debputy.plugin.api.impl import plugin_metadata_for_debputys_own_plugin 

98from debputy.util import PKGNAME_REGEX, _info, _trace_log, _is_trace_log_enabled 

99from debputy.yaml import MANIFEST_YAML 

100 

101if TYPE_CHECKING: 

102 import lsprotocol.types as types 

103else: 

104 import debputy.lsprotocol.types as types 

105 

106try: 

107 from debian._deb822_repro.locatable import ( 

108 Position as TEPosition, 

109 Range as TERange, 

110 START_POSITION, 

111 ) 

112 

113 from pygls.workspace import TextDocument 

114except ImportError: 

115 pass 

116 

117 

118_DISPATCH_RULE = LanguageDispatchRule.new_rule( 

119 "debian/control", 

120 None, 

121 "debian/control", 

122 [ 

123 # emacs's name 

124 SecondaryLanguage("debian-control"), 

125 # vim's name 

126 SecondaryLanguage("debcontrol"), 

127 ], 

128) 

129 

130 

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

132class SubstvarMetadata: 

133 name: str 

134 defined_by: str 

135 dh_sequence: str | None 

136 doc_uris: Sequence[str] 

137 synopsis: str 

138 description: str 

139 

140 def render_metadata_fields(self) -> str: 

141 def_by = f"Defined by: {self.defined_by}" 

142 doc_uris = self.doc_uris 

143 parts = [def_by] 

144 if self.dh_sequence is not None: 144 ↛ 146line 144 didn't jump to line 146 because the condition on line 144 was always true

145 parts.append(f"DH Sequence: {self.dh_sequence}") 

146 if doc_uris: 146 ↛ 152line 146 didn't jump to line 152 because the condition on line 146 was always true

147 if len(doc_uris) == 1: 147 ↛ 150line 147 didn't jump to line 150 because the condition on line 147 was always true

148 parts.append(f"Documentation: {markdown_urlify(doc_uris[0])}") 

149 else: 

150 parts.append("Documentation:") 

151 parts.extend(f" - {markdown_urlify(uri)}" for uri in doc_uris) 

152 return "\n".join(parts) 

153 

154 @classmethod 

155 def from_ref_data(cls, x: DCtrlSubstvar) -> "Self": 

156 doc = x.get("documentation", {}) 

157 return cls( 

158 x["name"], 

159 x["defined_by"], 

160 x.get("dh_sequence"), 

161 doc.get("uris", []), 

162 doc.get("synopsis", ""), 

163 doc.get("long_description", ""), 

164 ) 

165 

166 

167def relationship_substvar_for_field(substvar: str) -> str | None: 

168 relationship_fields = all_package_relationship_fields() 

169 try: 

170 col_idx = substvar.rindex(":") 

171 except ValueError: 

172 return None 

173 return relationship_fields.get(substvar[col_idx + 1 : -1].lower()) 

174 

175 

176def _as_substvars_metadata( 

177 args: list[SubstvarMetadata], 

178) -> Mapping[str, SubstvarMetadata]: 

179 r = {s.name: s for s in args} 

180 assert len(r) == len(args) 

181 return r 

182 

183 

184def dctrl_variables_metadata_basename() -> str: 

185 return "debian_control_variables_data.yaml" 

186 

187 

188@lru_cache 

189def dctrl_substvars_metadata() -> Mapping[str, SubstvarMetadata]: 

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

191 dctrl_variables_metadata_basename() 

192 ) 

193 

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

195 raw = MANIFEST_YAML.load(fd) 

196 

197 attr_path = AttributePath.root_path(p) 

198 ref = DCTRL_SUBSTVARS_REFERENCE_DATA_PARSER.parse_input(raw, attr_path) 

199 return _as_substvars_metadata( 

200 [SubstvarMetadata.from_ref_data(x) for x in ref["variables"]] 

201 ) 

202 

203 

204_DCTRL_FILE_METADATA = DctrlFileMetadata() 

205 

206 

207lsp_standard_handler(_DISPATCH_RULE, TEXT_DOCUMENT_CODE_ACTION) 

208 

209 

210@lsp_hover(_DISPATCH_RULE) 

211def _debian_control_hover( 

212 ls: "DebputyLanguageServer", 

213 params: HoverParams, 

214) -> Hover | None: 

215 return deb822_hover(ls, params, _DCTRL_FILE_METADATA, custom_handler=_custom_hover) 

216 

217 

218def _custom_hover_description( 

219 _ls: "DebputyLanguageServer", 

220 _known_field: DctrlKnownField, 

221 line: str, 

222 _word_at_position: str, 

223) -> Hover | str | None: 

224 if line[0].isspace(): 224 ↛ 225line 224 didn't jump to line 225 because the condition on line 224 was never true

225 return None 

226 try: 

227 col_idx = line.index(":") 

228 except ValueError: 

229 return None 

230 

231 content = line[col_idx + 1 :].strip() 

232 

233 # Synopsis 

234 return textwrap.dedent(f"""\ 

235 # Package synopsis 

236 

237 The synopsis functions as a phrase describing the package, not a 

238 complete sentence, so sentential punctuation is inappropriate: it 

239 does not need extra capital letters or a final period (full stop). 

240 It should also omit any initial indefinite or definite article 

241 - "a", "an", or "the". Thus for instance: 

242 

243 ``` 

244 Package: libeg0 

245 Description: exemplification support library 

246 ``` 

247 

248 Technically this is a noun phrase minus articles, as opposed to a 

249 verb phrase. A good heuristic is that it should be possible to 

250 substitute the package name and synopsis into this formula: 

251 

252 ``` 

253 # Generic 

254 The package provides { a,an,the,some} synopsis. 

255 

256 # The current package for comparison 

257 The package provides { a,an,the,some} {content}. 

258 ``` 

259 

260 Other advice for writing synopsis: 

261 * Avoid using the package name. Any software would display the 

262 package name already and it generally does not help the user 

263 understand what they are looking at. 

264 * In many situations, the user will only see the package name 

265 and its synopsis. The synopsis must be able to stand alone. 

266 

267 **Example renderings in various terminal UIs**: 

268 ``` 

269 # apt search TERM 

270 package/stable,now 1.0-1 all: 

271 {content} 

272 

273 # apt-get search TERM 

274 package - {content} 

275 ``` 

276 

277 ## Reference example 

278 

279 An reference example for comparison: The Sphinx package 

280 (python3-sphinx/7.2.6-6) had the following synopsis: 

281 

282 ``` 

283 Description: documentation generator for Python projects 

284 ``` 

285 

286 In the test sentence, it would read as: 

287 

288 ``` 

289 The python3-sphinx package provides a documentation generator for Python projects. 

290 ``` 

291 

292 **Side-by-side comparison in the terminal UIs**: 

293 ``` 

294 # apt search TERM 

295 python3-sphinx/stable,now 7.2.6-6 all: 

296 documentation generator for Python projects 

297 

298 package/stable,now 1.0-1 all: 

299 {content} 

300 

301 

302 # apt-get search TERM 

303 package - {content} 

304 python3-sphinx - documentation generator for Python projects 

305 ``` 

306 """) 

307 

308 

309def _render_package_lookup( 

310 package_lookup: PackageLookup, 

311 known_field: DctrlKnownField, 

312) -> str: 

313 name = package_lookup.name 

314 provider = package_lookup.package 

315 if package_lookup.package is None and len(package_lookup.provided_by) == 1: 

316 provider = package_lookup.provided_by[0] 

317 

318 if provider: 

319 segments = [ 

320 f"# {name} ({provider.version}, {provider.architecture}) ", 

321 "", 

322 ] 

323 

324 if ( 

325 _is_bd_field(known_field) 

326 and name.startswith("dh-sequence-") 

327 and len(name) > 12 

328 ): 

329 sequence = name[12:] 

330 segments.append( 

331 f"This build-dependency will activate the `dh` sequence called `{sequence}`." 

332 ) 

333 segments.append("") 

334 

335 elif ( 

336 known_field.name == "Build-Depends" 

337 and name.startswith("debputy-plugin-") 

338 and len(name) > 15 

339 ): 

340 plugin_name = name[15:] 

341 segments.append( 

342 f"This build-dependency will activate the `debputy` plugin called `{plugin_name}`." 

343 ) 

344 segments.append("") 

345 

346 segments.extend( 

347 [ 

348 f"Synopsis: {provider.synopsis}", 

349 "", 

350 f"Multi-Arch: {provider.multi_arch}", 

351 "", 

352 f"Section: {provider.section}", 

353 ] 

354 ) 

355 if provider.upstream_homepage is not None: 

356 segments.append("") 

357 segments.append(f"Upstream homepage: {provider.upstream_homepage}") 

358 segments.append("") 

359 segments.append( 

360 "Data is from the system's APT cache, which may not match the target distribution." 

361 ) 

362 return "\n".join(segments) 

363 

364 segments = [ 

365 f"# {name} [virtual]", 

366 "", 

367 "The package {name} is a virtual package provided by one of:", 

368 ] 

369 segments.extend(f" * {p.name}" for p in package_lookup.provided_by) 

370 segments.append("") 

371 segments.append( 

372 "Data is from the system's APT cache, which may not match the target distribution." 

373 ) 

374 return "\n".join(segments) 

375 

376 

377def _disclaimer(is_empty: bool) -> str: 

378 if is_empty: 

379 return textwrap.dedent("""\ 

380 The system's APT cache is empty, so it was not possible to verify that the 

381 package exist. 

382""") 

383 return textwrap.dedent("""\ 

384 The package is not known by the APT cache on this system, so there may be typo 

385 or the package may not be available in the version of your distribution. 

386""") 

387 

388 

389def _render_package_by_name( 

390 name: str, known_field: DctrlKnownField, is_empty: bool 

391) -> str | None: 

392 if _is_bd_field(known_field) and name.startswith("dh-sequence-") and len(name) > 12: 

393 sequence = name[12:] 

394 return textwrap.dedent(f"""\ 

395 # {name} 

396 

397 This build-dependency will activate the `dh` sequence called `{sequence}`. 

398 

399 """) + _disclaimer(is_empty) 

400 if ( 

401 known_field.name == "Build-Depends" 

402 and name.startswith("debputy-plugin-") 

403 and len(name) > 15 

404 ): 

405 plugin_name = name[15:] 

406 return textwrap.dedent(f"""\ 

407 # {name} 

408 

409 This build-dependency will activate the `debputy` plugin called `{plugin_name}`. 

410 

411 """) + _disclaimer(is_empty) 

412 return textwrap.dedent(f"""\ 

413 # {name} 

414 

415 """) + _disclaimer(is_empty) 

416 

417 

418def _is_bd_field(known_field: DctrlKnownField) -> bool: 

419 return known_field.name in ( 

420 "Build-Depends", 

421 "Build-Depends-Arch", 

422 "Build-Depends-Indep", 

423 ) 

424 

425 

426def _custom_hover_relationship_field( 

427 ls: "DebputyLanguageServer", 

428 known_field: DctrlKnownField, 

429 _line: str, 

430 word_at_position: str, 

431) -> Hover | str | None: 

432 apt_cache = ls.apt_cache 

433 state = apt_cache.state 

434 is_empty = False 

435 _info(f"Rel field: {known_field.name} - {word_at_position} - {state}") 

436 if "|" in word_at_position: 436 ↛ 437line 436 didn't jump to line 437 because the condition on line 436 was never true

437 return textwrap.dedent(f"""\ 

438 Sorry, no hover docs for OR relations at the moment. 

439 

440 The relation being matched: `{word_at_position}` 

441 

442 The code is missing logic to determine which side of the OR the lookup is happening. 

443 """) 

444 match = next(iter(PKGNAME_REGEX.finditer(word_at_position)), None) 

445 if match is None: 445 ↛ 446line 445 didn't jump to line 446 because the condition on line 445 was never true

446 return None 

447 package = match.group() 

448 if state == "empty-cache": 448 ↛ 449line 448 didn't jump to line 449 because the condition on line 448 was never true

449 state = "loaded" 

450 is_empty = True 

451 if state == "loaded": 451 ↛ 452line 451 didn't jump to line 452 because the condition on line 451 was never true

452 result = apt_cache.lookup(package) 

453 if result is None: 

454 return _render_package_by_name( 

455 package, 

456 known_field, 

457 is_empty=is_empty, 

458 ) 

459 return _render_package_lookup(result, known_field) 

460 

461 if state in ( 461 ↛ 473line 461 didn't jump to line 473 because the condition on line 461 was always true

462 "not-loaded", 

463 "failed", 

464 "tooling-not-available", 

465 ): 

466 details = apt_cache.load_error if apt_cache.load_error else "N/A" 

467 return textwrap.dedent(f"""\ 

468 Sorry, the APT cache data is not available due to an error or missing tool. 

469 

470 Details: {details} 

471 """) 

472 

473 if state == "empty-cache": 

474 return f"Cannot lookup {package}: APT cache data was empty" 

475 

476 if state == "loading": 

477 return f"Cannot lookup {package}: APT cache data is still being indexed. Please try again in a moment." 

478 return None 

479 

480 

481_CUSTOM_FIELD_HOVER = dict( 

482 ( 

483 (field, _custom_hover_relationship_field) 

484 for field in chain( 

485 all_package_relationship_fields().values(), 

486 all_source_relationship_fields().values(), 

487 ) 

488 if field != "Provides" 

489 ), 

490 Description=_custom_hover_description, 

491) 

492 

493 

494def _custom_hover( 

495 ls: "DebputyLanguageServer", 

496 server_position: Position, 

497 _current_field: str | None, 

498 word_at_position: str, 

499 known_field: DctrlKnownField | None, 

500 in_value: bool, 

501 _doc: "TextDocument", 

502 lines: list[str], 

503) -> Hover | str | None: 

504 if not in_value: 

505 return None 

506 

507 line_no = server_position.line 

508 line = lines[line_no] 

509 substvar_search_ref = server_position.character 

510 substvar = "" 

511 try: 

512 if line and line[substvar_search_ref] in ("$", "{"): 

513 substvar_search_ref += 2 

514 substvar_start = line.rindex("${", 0, substvar_search_ref) 

515 substvar_end = line.index("}", substvar_start) 

516 if server_position.character <= substvar_end: 

517 substvar = line[substvar_start : substvar_end + 1] 

518 except (ValueError, IndexError): 

519 pass 

520 

521 if substvar == "${}" or SUBSTVAR_RE.fullmatch(substvar): 

522 substvar_md = dctrl_substvars_metadata().get(substvar) 

523 

524 computed_doc = "" 

525 for_field = relationship_substvar_for_field(substvar) 

526 if for_field: 526 ↛ 528line 526 didn't jump to line 528 because the condition on line 526 was never true

527 # Leading empty line is intentional! 

528 computed_doc = textwrap.dedent(f""" 

529 This substvar is a relationship substvar for the field {for_field}. 

530 Relationship substvars are automatically added in the field they 

531 are named after in `debhelper-compat (= 14)` or later, or with 

532 `debputy` (any integration mode after 0.1.21). 

533 """) 

534 

535 if substvar_md is None: 535 ↛ 536line 535 didn't jump to line 536 because the condition on line 535 was never true

536 doc = f"No documentation for {substvar}.\n" 

537 md_fields = "" 

538 else: 

539 doc = ls.translation(LSP_DATA_DOMAIN).pgettext( 

540 f"Variable:{substvar_md.name}", 

541 substvar_md.description, 

542 ) 

543 md_fields = "\n" + substvar_md.render_metadata_fields() 

544 return f"# Substvar `{substvar}`\n\n{doc}{computed_doc}{md_fields}" 

545 

546 if known_field is None: 546 ↛ 547line 546 didn't jump to line 547 because the condition on line 546 was never true

547 return None 

548 dispatch = _CUSTOM_FIELD_HOVER.get(known_field.name) 

549 if dispatch is None: 549 ↛ 550line 549 didn't jump to line 550 because the condition on line 549 was never true

550 return None 

551 return dispatch(ls, known_field, line, word_at_position) 

552 

553 

554@lsp_completer(_DISPATCH_RULE) 

555def _debian_control_completions( 

556 ls: "DebputyLanguageServer", 

557 params: CompletionParams, 

558) -> CompletionList | Sequence[CompletionItem] | None: 

559 return deb822_completer(ls, params, _DCTRL_FILE_METADATA) 

560 

561 

562@lsp_folding_ranges(_DISPATCH_RULE) 

563def _debian_control_folding_ranges( 

564 ls: "DebputyLanguageServer", 

565 params: FoldingRangeParams, 

566) -> Sequence[FoldingRange] | None: 

567 return deb822_folding_ranges(ls, params, _DCTRL_FILE_METADATA) 

568 

569 

570@lsp_text_doc_inlay_hints(_DISPATCH_RULE) 

571async def _doc_inlay_hint( 

572 ls: "DebputyLanguageServer", 

573 params: types.InlayHintParams, 

574) -> list[InlayHint] | None: 

575 doc = ls.workspace.get_text_document(params.text_document.uri) 

576 lint_state = ls.lint_state(doc) 

577 deb822_file = lint_state.parsed_deb822_file_content 

578 if not deb822_file: 

579 return None 

580 inlay_hints = [] 

581 stanzas = list(deb822_file) 

582 if len(stanzas) < 2: 

583 return None 

584 source_stanza = stanzas[0] 

585 source_stanza_pos = source_stanza.position_in_file() 

586 inherited_inlay_label_part = {} 

587 stanza_no = 0 

588 

589 async for stanza_range, stanza in lint_state.slow_iter( 

590 with_range_in_continuous_parts(deb822_file.iter_parts()) 

591 ): 

592 if not isinstance(stanza, Deb822ParagraphElement): 

593 continue 

594 stanza_def = _DCTRL_FILE_METADATA.classify_stanza(stanza, stanza_no) 

595 stanza_no += 1 

596 pkg_kvpair = stanza.get_kvpair_element(("Package", 0), use_get=True) 

597 if pkg_kvpair is None: 

598 continue 

599 

600 parts = [] 

601 async for known_field in ls.slow_iter( 

602 stanza_def.stanza_fields.values(), yield_every=25 

603 ): 

604 if ( 

605 not known_field.inheritable_from_other_stanza 

606 or not known_field.show_as_inherited 

607 or known_field.name in stanza 

608 ): 

609 continue 

610 

611 inherited_value = source_stanza.get(known_field.name) 

612 if inherited_value is not None: 

613 inlay_hint_label_part = inherited_inlay_label_part.get(known_field.name) 

614 if inlay_hint_label_part is None: 

615 kvpair = source_stanza.get_kvpair_element(known_field.name) 

616 value_range_te = kvpair.range_in_parent().relative_to( 

617 source_stanza_pos 

618 ) 

619 value_range = doc.position_codec.range_to_client_units( 

620 lint_state.lines, 

621 te_range_to_lsp(value_range_te), 

622 ) 

623 inlay_hint_label_part = types.InlayHintLabelPart( 

624 f" ({known_field.name}: {inherited_value})", 

625 tooltip="Inherited from Source stanza", 

626 location=types.Location( 

627 params.text_document.uri, 

628 value_range, 

629 ), 

630 ) 

631 inherited_inlay_label_part[known_field.name] = inlay_hint_label_part 

632 parts.append(inlay_hint_label_part) 

633 

634 if parts: 

635 known_field = stanza_def["Package"] 

636 values = known_field.field_value_class.interpreter().interpret(pkg_kvpair) 

637 assert values is not None 

638 anchor_value = list(values.iter_value_references())[-1] 

639 anchor_position = ( 

640 anchor_value.locatable.range_in_parent().end_pos.relative_to( 

641 pkg_kvpair.value_element.position_in_parent().relative_to( 

642 stanza_range.start_pos 

643 ) 

644 ) 

645 ) 

646 anchor_position_client_units = doc.position_codec.position_to_client_units( 

647 lint_state.lines, 

648 te_position_to_lsp(anchor_position), 

649 ) 

650 inlay_hints.append( 

651 types.InlayHint( 

652 anchor_position_client_units, 

653 parts, 

654 padding_left=True, 

655 padding_right=False, 

656 ) 

657 ) 

658 return inlay_hints 

659 

660 

661def _source_package_checks( 

662 stanza: Deb822ParagraphElement, 

663 stanza_position: "TEPosition", 

664 stanza_metadata: StanzaMetadata[DctrlKnownField], 

665 lint_state: LintState, 

666) -> None: 

667 vcs_fields = {} 

668 source_fields = _DCTRL_FILE_METADATA["Source"].stanza_fields 

669 for kvpair in stanza.iter_parts_of_type(Deb822KeyValuePairElement): 

670 name = stanza_metadata.normalize_field_name(kvpair.field_name.lower()) 

671 if ( 

672 not name.startswith("vcs-") 

673 or name == "vcs-browser" 

674 or name not in source_fields 

675 ): 

676 continue 

677 vcs_fields[name] = kvpair 

678 

679 if len(vcs_fields) < 2: 

680 return 

681 for kvpair in vcs_fields.values(): 

682 lint_state.emit_diagnostic( 

683 kvpair.range_in_parent().relative_to(stanza_position), 

684 f'Multiple Version Control fields defined ("{kvpair.field_name}")', 

685 "warning", 

686 "Policy 5.6.26", 

687 quickfixes=[ 

688 propose_remove_range_quick_fix( 

689 proposed_title=f'Remove "{kvpair.field_name}"' 

690 ) 

691 ], 

692 ) 

693 

694 

695def _binary_package_checks( 

696 stanza: Deb822ParagraphElement, 

697 stanza_position: "TEPosition", 

698 source_stanza: Deb822ParagraphElement, 

699 representation_field_range: "TERange", 

700 lint_state: LintState, 

701) -> None: 

702 package_name = stanza.get("Package", "") 

703 source_section = source_stanza.get("Section") 

704 section_kvpair = stanza.get_kvpair_element(("Section", 0), use_get=True) 

705 section: str | None = None 

706 section_range: Optional["TERange"] = None 

707 if section_kvpair is not None: 

708 section, section_range = extract_first_value_and_position( 

709 section_kvpair, 

710 stanza_position, 

711 ) 

712 

713 if section_range is None: 

714 section_range = representation_field_range 

715 effective_section = section or source_section or "unknown" 

716 package_type = stanza.get("Package-Type", "") 

717 component_prefix = "" 

718 if "/" in effective_section: 

719 component_prefix, effective_section = effective_section.split("/", maxsplit=1) 

720 component_prefix += "/" 

721 

722 if package_name.endswith("-udeb") or package_type == "udeb": 

723 if package_type != "udeb": 723 ↛ 724line 723 didn't jump to line 724 because the condition on line 723 was never true

724 package_type_kvpair = stanza.get_kvpair_element( 

725 "Package-Type", use_get=True 

726 ) 

727 package_type_range: Optional["TERange"] = None 

728 if package_type_kvpair is not None: 

729 _, package_type_range = extract_first_value_and_position( 

730 package_type_kvpair, 

731 stanza_position, 

732 ) 

733 if package_type_range is None: 

734 package_type_range = representation_field_range 

735 lint_state.emit_diagnostic( 

736 package_type_range, 

737 'The Package-Type should be "udeb" given the package name', 

738 "warning", 

739 "debputy", 

740 ) 

741 guessed_section = "debian-installer" 

742 section_diagnostic_rationale = " since it is an udeb" 

743 else: 

744 guessed_section = package_name_to_section(package_name) 

745 section_diagnostic_rationale = " based on the package name" 

746 if guessed_section is not None and guessed_section != effective_section: 746 ↛ 747line 746 didn't jump to line 747 because the condition on line 746 was never true

747 if section is not None: 

748 quickfix_data = [ 

749 propose_correct_text_quick_fix(f"{component_prefix}{guessed_section}") 

750 ] 

751 else: 

752 quickfix_data = [ 

753 propose_insert_text_on_line_after_diagnostic_quick_fix( 

754 f"Section: {component_prefix}{guessed_section}\n" 

755 ) 

756 ] 

757 assert section_range is not None # mypy hint 

758 lint_state.emit_diagnostic( 

759 section_range, 

760 f'The Section should be "{component_prefix}{guessed_section}"{section_diagnostic_rationale}', 

761 "warning", 

762 "debputy", 

763 quickfixes=quickfix_data, 

764 ) 

765 

766 

767@lint_diagnostics(_DISPATCH_RULE) 

768async def _lint_debian_control(lint_state: LintState) -> None: 

769 deb822_file = lint_state.parsed_deb822_file_content 

770 

771 if not _DCTRL_FILE_METADATA.file_metadata_applies_to_file(deb822_file): 771 ↛ 772line 771 didn't jump to line 772 because the condition on line 771 was never true

772 return 

773 

774 first_error = await scan_for_syntax_errors_and_token_level_diagnostics( 

775 deb822_file, 

776 lint_state, 

777 ) 

778 

779 stanzas = list(deb822_file) 

780 source_stanza = stanzas[0] if stanzas else None 

781 binary_stanzas_w_pos = [] 

782 

783 source_stanza_metadata, binary_stanza_metadata = _DCTRL_FILE_METADATA.stanza_types() 

784 stanza_no = 0 

785 

786 async for stanza_range, stanza in lint_state.slow_iter( 

787 with_range_in_continuous_parts(deb822_file.iter_parts()) 

788 ): 

789 if not isinstance(stanza, Deb822ParagraphElement): 

790 continue 

791 stanza_position = stanza_range.start_pos 

792 if stanza_position.line_position >= first_error: 792 ↛ 793line 792 didn't jump to line 793 because the condition on line 792 was never true

793 break 

794 stanza_no += 1 

795 is_binary_stanza = stanza_no != 1 

796 if is_binary_stanza: 

797 stanza_metadata = binary_stanza_metadata 

798 other_stanza_metadata = source_stanza_metadata 

799 other_stanza_name = "Source" 

800 binary_stanzas_w_pos.append((stanza, stanza_position)) 

801 _, representation_field_range = stanza_metadata.stanza_representation( 

802 stanza, stanza_position 

803 ) 

804 _binary_package_checks( 

805 stanza, 

806 stanza_position, 

807 source_stanza, 

808 representation_field_range, 

809 lint_state, 

810 ) 

811 else: 

812 stanza_metadata = source_stanza_metadata 

813 other_stanza_metadata = binary_stanza_metadata 

814 other_stanza_name = "Binary" 

815 _source_package_checks( 

816 stanza, 

817 stanza_position, 

818 stanza_metadata, 

819 lint_state, 

820 ) 

821 

822 await stanza_metadata.stanza_diagnostics( 

823 deb822_file, 

824 stanza, 

825 stanza_position, 

826 lint_state, 

827 confusable_with_stanza_metadata=other_stanza_metadata, 

828 confusable_with_stanza_name=other_stanza_name, 

829 inherit_from_stanza=source_stanza if is_binary_stanza else None, 

830 ) 

831 

832 _detect_misspelled_packaging_files( 

833 lint_state, 

834 binary_stanzas_w_pos, 

835 ) 

836 

837 

838def _package_range_of_stanza( 

839 binary_stanzas: list[tuple[Deb822ParagraphElement, TEPosition]], 

840) -> Iterable[tuple[str, str | None, "TERange"]]: 

841 for stanza, stanza_position in binary_stanzas: 

842 kvpair = stanza.get_kvpair_element(("Package", 0), use_get=True) 

843 if kvpair is None: 843 ↛ 844line 843 didn't jump to line 844 because the condition on line 843 was never true

844 continue 

845 representation_field_range = kvpair.range_in_parent().relative_to( 

846 stanza_position 

847 ) 

848 yield stanza["Package"], stanza.get("Architecture"), representation_field_range 

849 

850 

851def _packaging_files( 

852 lint_state: LintState, 

853) -> Iterable[PackagerProvidedFile]: 

854 source_root = lint_state.source_root 

855 debian_dir = lint_state.debian_dir 

856 binary_packages = lint_state.binary_packages 

857 if ( 

858 source_root is None 

859 or not source_root.has_fs_path 

860 or debian_dir is None 

861 or binary_packages is None 

862 ): 

863 return 

864 

865 debputy_integration_mode = lint_state.debputy_metadata.debputy_integration_mode 

866 dh_sequencer_data = lint_state.dh_sequencer_data 

867 dh_sequences = dh_sequencer_data.sequences 

868 is_debputy_package = debputy_integration_mode is not None 

869 feature_set = lint_state.plugin_feature_set 

870 known_packaging_files = feature_set.known_packaging_files 

871 static_packaging_files = { 

872 kpf.detection_value: kpf 

873 for kpf in known_packaging_files.values() 

874 if kpf.detection_method == "path" 

875 } 

876 ignored_path = set(static_packaging_files) 

877 

878 if is_debputy_package: 

879 all_debputy_ppfs = list( 

880 flatten_ppfs( 

881 detect_all_packager_provided_files( 

882 feature_set, 

883 debian_dir, 

884 binary_packages, 

885 allow_fuzzy_matches=True, 

886 detect_typos=True, 

887 ignore_paths=ignored_path, 

888 ) 

889 ) 

890 ) 

891 for ppf in all_debputy_ppfs: 

892 if ppf.path.path in ignored_path: 892 ↛ 893line 892 didn't jump to line 893 because the condition on line 892 was never true

893 continue 

894 ignored_path.add(ppf.path.path) 

895 yield ppf 

896 

897 # FIXME: This should read the editor data, but dh_assistant does not support that. 

898 dh_compat_level, _ = extract_dh_compat_level(cwd=source_root.fs_path) 

899 if dh_compat_level is not None: 899 ↛ exitline 899 didn't return from function '_packaging_files' because the condition on line 899 was always true

900 debputy_plugin_metadata = plugin_metadata_for_debputys_own_plugin() 

901 ( 

902 all_dh_ppfs, 

903 _, 

904 _, 

905 _, 

906 ) = resolve_debhelper_config_files( 

907 debian_dir, 

908 binary_packages, 

909 debputy_plugin_metadata, 

910 feature_set, 

911 dh_sequences, 

912 dh_compat_level, 

913 saw_dh=dh_sequencer_data.uses_dh_sequencer, 

914 ignore_paths=ignored_path, 

915 debputy_integration_mode=debputy_integration_mode, 

916 cwd=source_root.fs_path, 

917 ) 

918 for ppf in all_dh_ppfs: 

919 if ppf.path.path in ignored_path: 919 ↛ 920line 919 didn't jump to line 920 because the condition on line 919 was never true

920 continue 

921 ignored_path.add(ppf.path.path) 

922 yield ppf 

923 

924 

925def _detect_misspelled_packaging_files( 

926 lint_state: LintState, 

927 binary_stanzas_w_pos: list[tuple[Deb822ParagraphElement, TEPosition]], 

928) -> None: 

929 stanza_ranges = { 

930 p: (a, r) for p, a, r in _package_range_of_stanza(binary_stanzas_w_pos) 

931 } 

932 for ppf in _packaging_files(lint_state): 

933 binary_package = ppf.package_name 

934 explicit_package = ppf.uses_explicit_package_name 

935 name_segment = ppf.name_segment is not None 

936 stem = ppf.definition.stem 

937 if _is_trace_log_enabled(): 937 ↛ 938line 937 didn't jump to line 938 because the condition on line 937 was never true

938 _trace_log( 

939 f"PPF check: {binary_package} {stem=} {explicit_package=} {name_segment=} {ppf.expected_path=} {ppf.definition.has_active_command=}" 

940 ) 

941 if binary_package is None or stem is None: 941 ↛ 942line 941 didn't jump to line 942 because the condition on line 941 was never true

942 continue 

943 res = stanza_ranges.get(binary_package) 

944 if res is None: 944 ↛ 945line 944 didn't jump to line 945 because the condition on line 944 was never true

945 continue 

946 declared_arch, diag_range = res 

947 if diag_range is None: 947 ↛ 948line 947 didn't jump to line 948 because the condition on line 947 was never true

948 continue 

949 path = ppf.path.path 

950 likely_typo_of = ppf.expected_path 

951 arch_restriction = ppf.architecture_restriction 

952 if likely_typo_of is not None: 

953 # Handles arch_restriction == 'all' at the same time due to how 

954 # the `likely-typo-of` is created 

955 lint_state.emit_diagnostic( 

956 diag_range, 

957 f'The file "{path}" is likely a typo of "{likely_typo_of}"', 

958 "warning", 

959 "debputy", 

960 diagnostic_applies_to_another_file=path, 

961 ) 

962 continue 

963 if declared_arch == "all" and arch_restriction is not None: 963 ↛ 964line 963 didn't jump to line 964 because the condition on line 963 was never true

964 lint_state.emit_diagnostic( 

965 diag_range, 

966 f'The file "{path}" has an architecture restriction but is for an `arch:all` package, so' 

967 f" the restriction does not make sense.", 

968 "warning", 

969 "debputy", 

970 diagnostic_applies_to_another_file=path, 

971 ) 

972 elif arch_restriction == "all": 972 ↛ 973line 972 didn't jump to line 973 because the condition on line 972 was never true

973 lint_state.emit_diagnostic( 

974 diag_range, 

975 f'The file "{path}" has an architecture restriction of `all` rather than a real architecture', 

976 "warning", 

977 "debputy", 

978 diagnostic_applies_to_another_file=path, 

979 ) 

980 

981 if not ppf.definition.has_active_command: 981 ↛ 982line 981 didn't jump to line 982 because the condition on line 981 was never true

982 lint_state.emit_diagnostic( 

983 diag_range, 

984 f"The file {path} is related to a command that is not active in the dh sequence" 

985 " with the current addons", 

986 "warning", 

987 "debputy", 

988 diagnostic_applies_to_another_file=path, 

989 ) 

990 continue 

991 

992 if not explicit_package and name_segment is not None: 

993 basename = os.path.basename(path) 

994 if basename == ppf.definition.stem: 

995 continue 

996 alt_name = f"{binary_package}.{stem}" 

997 if arch_restriction is not None: 997 ↛ 998line 997 didn't jump to line 998 because the condition on line 997 was never true

998 alt_name = f"{alt_name}.{arch_restriction}" 

999 if ppf.definition.allow_name_segment: 

1000 or_alt_name = f' (or maybe "debian/{binary_package}.{basename}")' 

1001 else: 

1002 or_alt_name = "" 

1003 

1004 lint_state.emit_diagnostic( 

1005 diag_range, 

1006 f'Possible typo in "{path}". Consider renaming the file to "debian/{alt_name}"' 

1007 f"{or_alt_name} if it is intended for {binary_package}", 

1008 "warning", 

1009 "debputy", 

1010 diagnostic_applies_to_another_file=path, 

1011 ) 

1012 

1013 

1014@lsp_will_save_wait_until(_DISPATCH_RULE) 

1015def _debian_control_on_save_formatting( 

1016 ls: "DebputyLanguageServer", 

1017 params: WillSaveTextDocumentParams, 

1018) -> Sequence[TextEdit] | None: 

1019 doc = ls.workspace.get_text_document(params.text_document.uri) 

1020 lint_state = ls.lint_state(doc) 

1021 return _reformat_debian_control(lint_state) 

1022 

1023 

1024@lsp_cli_reformat_document(_DISPATCH_RULE) 

1025def _reformat_debian_control( 

1026 lint_state: LintState, 

1027) -> Sequence[TextEdit] | None: 

1028 return deb822_format_file(lint_state, _DCTRL_FILE_METADATA) 

1029 

1030 

1031@lsp_format_document(_DISPATCH_RULE) 

1032def _debian_control_format_file( 

1033 ls: "DebputyLanguageServer", 

1034 params: DocumentFormattingParams, 

1035) -> Sequence[TextEdit] | None: 

1036 doc = ls.workspace.get_text_document(params.text_document.uri) 

1037 lint_state = ls.lint_state(doc) 

1038 return _reformat_debian_control(lint_state) 

1039 

1040 

1041@lsp_semantic_tokens_full(_DISPATCH_RULE) 

1042async def _debian_control_semantic_tokens_full( 

1043 ls: "DebputyLanguageServer", 

1044 request: SemanticTokensParams, 

1045) -> SemanticTokens | None: 

1046 return await deb822_semantic_tokens_full( 

1047 ls, 

1048 request, 

1049 _DCTRL_FILE_METADATA, 

1050 )