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

424 statements  

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

1import dataclasses 

2import importlib.resources 

3import os.path 

4import textwrap 

5from functools import lru_cache 

6from itertools import chain 

7from typing import ( 

8 Union, 

9 Tuple, 

10 Optional, 

11 List, 

12 Self, 

13 TYPE_CHECKING, 

14) 

15from collections.abc import Sequence, Mapping, Iterable 

16 

17import debputy.lsp.data.deb822_data as deb822_ref_data_dir 

18from debputy.analysis.analysis_util import flatten_ppfs 

19from debputy.analysis.debian_dir import resolve_debhelper_config_files 

20from debputy.dh.dh_assistant import extract_dh_compat_level 

21from debputy.linting.lint_util import ( 

22 LintState, 

23 te_range_to_lsp, 

24 te_position_to_lsp, 

25 with_range_in_continuous_parts, 

26) 

27from debputy.lsp.apt_cache import PackageLookup 

28from debputy.lsp.debputy_ls import DebputyLanguageServer 

29from debputy.lsp.lsp_debian_control_reference_data import ( 

30 DctrlKnownField, 

31 DctrlFileMetadata, 

32 package_name_to_section, 

33 all_package_relationship_fields, 

34 extract_first_value_and_position, 

35 all_source_relationship_fields, 

36 StanzaMetadata, 

37 SUBSTVAR_RE, 

38) 

39from debputy.lsp.lsp_features import ( 

40 lint_diagnostics, 

41 lsp_completer, 

42 lsp_hover, 

43 lsp_standard_handler, 

44 lsp_folding_ranges, 

45 lsp_semantic_tokens_full, 

46 lsp_will_save_wait_until, 

47 lsp_format_document, 

48 lsp_text_doc_inlay_hints, 

49 LanguageDispatchRule, 

50 SecondaryLanguage, 

51 lsp_cli_reformat_document, 

52) 

53from debputy.lsp.lsp_generic_deb822 import ( 

54 deb822_completer, 

55 deb822_hover, 

56 deb822_folding_ranges, 

57 deb822_semantic_tokens_full, 

58 deb822_format_file, 

59 scan_for_syntax_errors_and_token_level_diagnostics, 

60) 

61from debputy.lsp.lsp_reference_keyword import LSP_DATA_DOMAIN 

62from debputy.lsp.quickfixes import ( 

63 propose_correct_text_quick_fix, 

64 propose_insert_text_on_line_after_diagnostic_quick_fix, 

65 propose_remove_range_quick_fix, 

66) 

67from debputy.lsp.ref_models.deb822_reference_parse_models import ( 

68 DCTRL_SUBSTVARS_REFERENCE_DATA_PARSER, 

69 DCtrlSubstvar, 

70) 

71from debputy.lsp.text_util import markdown_urlify 

72from debputy.lsp.vendoring._deb822_repro import ( 

73 Deb822ParagraphElement, 

74) 

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

76 Deb822KeyValuePairElement, 

77) 

78from debputy.lsprotocol.types import ( 

79 Position, 

80 FoldingRange, 

81 FoldingRangeParams, 

82 CompletionItem, 

83 CompletionList, 

84 CompletionParams, 

85 HoverParams, 

86 Hover, 

87 TEXT_DOCUMENT_CODE_ACTION, 

88 SemanticTokens, 

89 SemanticTokensParams, 

90 WillSaveTextDocumentParams, 

91 TextEdit, 

92 DocumentFormattingParams, 

93 InlayHint, 

94) 

95from debputy.manifest_parser.util import AttributePath 

96from debputy.packager_provided_files import ( 

97 PackagerProvidedFile, 

98 detect_all_packager_provided_files, 

99) 

100from debputy.plugin.api.impl import plugin_metadata_for_debputys_own_plugin 

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

102from debputy.yaml import MANIFEST_YAML 

103 

104if TYPE_CHECKING: 

105 import lsprotocol.types as types 

106else: 

107 import debputy.lsprotocol.types as types 

108 

109try: 

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

111 Position as TEPosition, 

112 Range as TERange, 

113 START_POSITION, 

114 ) 

115 

116 from pygls.workspace import TextDocument 

117except ImportError: 

118 pass 

119 

120 

121_DISPATCH_RULE = LanguageDispatchRule.new_rule( 

122 "debian/control", 

123 None, 

124 "debian/control", 

125 [ 

126 # emacs's name 

127 SecondaryLanguage("debian-control"), 

128 # vim's name 

129 SecondaryLanguage("debcontrol"), 

130 ], 

131) 

132 

133 

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

135class SubstvarMetadata: 

136 name: str 

137 defined_by: str 

138 dh_sequence: str | None 

139 doc_uris: Sequence[str] 

140 synopsis: str 

141 description: str 

142 

143 def render_metadata_fields(self) -> str: 

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

145 doc_uris = self.doc_uris 

146 parts = [def_by] 

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

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

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

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

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

152 else: 

153 parts.append("Documentation:") 

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

155 return "\n".join(parts) 

156 

157 @classmethod 

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

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

160 return cls( 

161 x["name"], 

162 x["defined_by"], 

163 x.get("dh_sequence"), 

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

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

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

167 ) 

168 

169 

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

171 relationship_fields = all_package_relationship_fields() 

172 try: 

173 col_idx = substvar.rindex(":") 

174 except ValueError: 

175 return None 

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

177 

178 

179def _as_substvars_metadata( 

180 args: list[SubstvarMetadata], 

181) -> Mapping[str, SubstvarMetadata]: 

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

183 assert len(r) == len(args) 

184 return r 

185 

186 

187def dctrl_variables_metadata_basename() -> str: 

188 return "debian_control_variables_data.yaml" 

189 

190 

191@lru_cache 

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

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

194 dctrl_variables_metadata_basename() 

195 ) 

196 

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

198 raw = MANIFEST_YAML.load(fd) 

199 

200 attr_path = AttributePath.root_path(p) 

201 ref = DCTRL_SUBSTVARS_REFERENCE_DATA_PARSER.parse_input(raw, attr_path) 

202 return _as_substvars_metadata( 

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

204 ) 

205 

206 

207_DCTRL_FILE_METADATA = DctrlFileMetadata() 

208 

209 

210lsp_standard_handler(_DISPATCH_RULE, TEXT_DOCUMENT_CODE_ACTION) 

211 

212 

213@lsp_hover(_DISPATCH_RULE) 

214def _debian_control_hover( 

215 ls: "DebputyLanguageServer", 

216 params: HoverParams, 

217) -> Hover | None: 

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

219 

220 

221def _custom_hover_description( 

222 _ls: "DebputyLanguageServer", 

223 _known_field: DctrlKnownField, 

224 line: str, 

225 _word_at_position: str, 

226) -> Hover | str | None: 

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

228 return None 

229 try: 

230 col_idx = line.index(":") 

231 except ValueError: 

232 return None 

233 

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

235 

236 # Synopsis 

237 return textwrap.dedent( 

238 f"""\ 

239 # Package synopsis 

240 

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

242 complete sentence, so sentential punctuation is inappropriate: it 

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

244 It should also omit any initial indefinite or definite article 

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

246 

247 ``` 

248 Package: libeg0 

249 Description: exemplification support library 

250 ``` 

251 

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

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

254 substitute the package name and synopsis into this formula: 

255 

256 ``` 

257 # Generic 

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

259 

260 # The current package for comparison 

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

262 ``` 

263 

264 Other advice for writing synopsis: 

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

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

267 understand what they are looking at. 

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

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

270 

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

272 ``` 

273 # apt search TERM 

274 package/stable,now 1.0-1 all: 

275 {content} 

276 

277 # apt-get search TERM 

278 package - {content} 

279 ``` 

280 

281 ## Reference example 

282 

283 An reference example for comparison: The Sphinx package 

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

285 

286 ``` 

287 Description: documentation generator for Python projects 

288 ``` 

289 

290 In the test sentence, it would read as: 

291 

292 ``` 

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

294 ``` 

295 

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

297 ``` 

298 # apt search TERM 

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

300 documentation generator for Python projects 

301 

302 package/stable,now 1.0-1 all: 

303 {content} 

304 

305 

306 # apt-get search TERM 

307 package - {content} 

308 python3-sphinx - documentation generator for Python projects 

309 ``` 

310 """ 

311 ) 

312 

313 

314def _render_package_lookup( 

315 package_lookup: PackageLookup, 

316 known_field: DctrlKnownField, 

317) -> str: 

318 name = package_lookup.name 

319 provider = package_lookup.package 

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

321 provider = package_lookup.provided_by[0] 

322 

323 if provider: 

324 segments = [ 

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

326 "", 

327 ] 

328 

329 if ( 

330 _is_bd_field(known_field) 

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

332 and len(name) > 12 

333 ): 

334 sequence = name[12:] 

335 segments.append( 

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

337 ) 

338 segments.append("") 

339 

340 elif ( 

341 known_field.name == "Build-Depends" 

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

343 and len(name) > 15 

344 ): 

345 plugin_name = name[15:] 

346 segments.append( 

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

348 ) 

349 segments.append("") 

350 

351 segments.extend( 

352 [ 

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

354 "", 

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

356 "", 

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

358 ] 

359 ) 

360 if provider.upstream_homepage is not None: 

361 segments.append("") 

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

363 segments.append("") 

364 segments.append( 

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

366 ) 

367 return "\n".join(segments) 

368 

369 segments = [ 

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

371 "", 

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

373 ] 

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

375 segments.append("") 

376 segments.append( 

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

378 ) 

379 return "\n".join(segments) 

380 

381 

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

383 if is_empty: 

384 return textwrap.dedent( 

385 """\ 

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

387 package exist. 

388""" 

389 ) 

390 return textwrap.dedent( 

391 """\ 

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

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

394""" 

395 ) 

396 

397 

398def _render_package_by_name( 

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

400) -> str | None: 

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

402 sequence = name[12:] 

403 return ( 

404 textwrap.dedent( 

405 f"""\ 

406 # {name} 

407 

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

409 

410 """ 

411 ) 

412 + _disclaimer(is_empty) 

413 ) 

414 if ( 

415 known_field.name == "Build-Depends" 

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

417 and len(name) > 15 

418 ): 

419 plugin_name = name[15:] 

420 return ( 

421 textwrap.dedent( 

422 f"""\ 

423 # {name} 

424 

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

426 

427 """ 

428 ) 

429 + _disclaimer(is_empty) 

430 ) 

431 return ( 

432 textwrap.dedent( 

433 f"""\ 

434 # {name} 

435 

436 """ 

437 ) 

438 + _disclaimer(is_empty) 

439 ) 

440 

441 

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

443 return known_field.name in ( 

444 "Build-Depends", 

445 "Build-Depends-Arch", 

446 "Build-Depends-Indep", 

447 ) 

448 

449 

450def _custom_hover_relationship_field( 

451 ls: "DebputyLanguageServer", 

452 known_field: DctrlKnownField, 

453 _line: str, 

454 word_at_position: str, 

455) -> Hover | str | None: 

456 apt_cache = ls.apt_cache 

457 state = apt_cache.state 

458 is_empty = False 

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

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

461 return textwrap.dedent( 

462 f"""\ 

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

464 

465 The relation being matched: `{word_at_position}` 

466 

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

468 """ 

469 ) 

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

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

472 return None 

473 package = match.group() 

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

475 state = "loaded" 

476 is_empty = True 

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

478 result = apt_cache.lookup(package) 

479 if result is None: 

480 return _render_package_by_name( 

481 package, 

482 known_field, 

483 is_empty=is_empty, 

484 ) 

485 return _render_package_lookup(result, known_field) 

486 

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

488 "not-loaded", 

489 "failed", 

490 "tooling-not-available", 

491 ): 

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

493 return textwrap.dedent( 

494 f"""\ 

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

496 

497 Details: {details} 

498 """ 

499 ) 

500 

501 if state == "empty-cache": 

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

503 

504 if state == "loading": 

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

506 return None 

507 

508 

509_CUSTOM_FIELD_HOVER = dict( 

510 ( 

511 (field, _custom_hover_relationship_field) 

512 for field in chain( 

513 all_package_relationship_fields().values(), 

514 all_source_relationship_fields().values(), 

515 ) 

516 if field != "Provides" 

517 ), 

518 Description=_custom_hover_description, 

519) 

520 

521 

522def _custom_hover( 

523 ls: "DebputyLanguageServer", 

524 server_position: Position, 

525 _current_field: str | None, 

526 word_at_position: str, 

527 known_field: DctrlKnownField | None, 

528 in_value: bool, 

529 _doc: "TextDocument", 

530 lines: list[str], 

531) -> Hover | str | None: 

532 if not in_value: 

533 return None 

534 

535 line_no = server_position.line 

536 line = lines[line_no] 

537 substvar_search_ref = server_position.character 

538 substvar = "" 

539 try: 

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

541 substvar_search_ref += 2 

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

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

544 if server_position.character <= substvar_end: 

545 substvar = line[substvar_start : substvar_end + 1] 

546 except (ValueError, IndexError): 

547 pass 

548 

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

550 substvar_md = dctrl_substvars_metadata().get(substvar) 

551 

552 computed_doc = "" 

553 for_field = relationship_substvar_for_field(substvar) 

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

555 # Leading empty line is intentional! 

556 computed_doc = textwrap.dedent( 

557 f""" 

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

559 Relationship substvars are automatically added in the field they 

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

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

562 """ 

563 ) 

564 

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

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

567 md_fields = "" 

568 else: 

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

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

571 substvar_md.description, 

572 ) 

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

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

575 

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

577 return None 

578 dispatch = _CUSTOM_FIELD_HOVER.get(known_field.name) 

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

580 return None 

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

582 

583 

584@lsp_completer(_DISPATCH_RULE) 

585def _debian_control_completions( 

586 ls: "DebputyLanguageServer", 

587 params: CompletionParams, 

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

589 return deb822_completer(ls, params, _DCTRL_FILE_METADATA) 

590 

591 

592@lsp_folding_ranges(_DISPATCH_RULE) 

593def _debian_control_folding_ranges( 

594 ls: "DebputyLanguageServer", 

595 params: FoldingRangeParams, 

596) -> Sequence[FoldingRange] | None: 

597 return deb822_folding_ranges(ls, params, _DCTRL_FILE_METADATA) 

598 

599 

600@lsp_text_doc_inlay_hints(_DISPATCH_RULE) 

601async def _doc_inlay_hint( 

602 ls: "DebputyLanguageServer", 

603 params: types.InlayHintParams, 

604) -> list[InlayHint] | None: 

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

606 lint_state = ls.lint_state(doc) 

607 deb822_file = lint_state.parsed_deb822_file_content 

608 if not deb822_file: 

609 return None 

610 inlay_hints = [] 

611 stanzas = list(deb822_file) 

612 if len(stanzas) < 2: 

613 return None 

614 source_stanza = stanzas[0] 

615 source_stanza_pos = source_stanza.position_in_file() 

616 inherited_inlay_label_part = {} 

617 stanza_no = 0 

618 

619 async for stanza_range, stanza in lint_state.slow_iter( 

620 with_range_in_continuous_parts(deb822_file.iter_parts()) 

621 ): 

622 if not isinstance(stanza, Deb822ParagraphElement): 

623 continue 

624 stanza_def = _DCTRL_FILE_METADATA.classify_stanza(stanza, stanza_no) 

625 stanza_no += 1 

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

627 if pkg_kvpair is None: 

628 continue 

629 

630 parts = [] 

631 async for known_field in ls.slow_iter( 

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

633 ): 

634 if ( 

635 not known_field.inheritable_from_other_stanza 

636 or not known_field.show_as_inherited 

637 or known_field.name in stanza 

638 ): 

639 continue 

640 

641 inherited_value = source_stanza.get(known_field.name) 

642 if inherited_value is not None: 

643 inlay_hint_label_part = inherited_inlay_label_part.get(known_field.name) 

644 if inlay_hint_label_part is None: 

645 kvpair = source_stanza.get_kvpair_element(known_field.name) 

646 value_range_te = kvpair.range_in_parent().relative_to( 

647 source_stanza_pos 

648 ) 

649 value_range = doc.position_codec.range_to_client_units( 

650 lint_state.lines, 

651 te_range_to_lsp(value_range_te), 

652 ) 

653 inlay_hint_label_part = types.InlayHintLabelPart( 

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

655 tooltip="Inherited from Source stanza", 

656 location=types.Location( 

657 params.text_document.uri, 

658 value_range, 

659 ), 

660 ) 

661 inherited_inlay_label_part[known_field.name] = inlay_hint_label_part 

662 parts.append(inlay_hint_label_part) 

663 

664 if parts: 

665 known_field = stanza_def["Package"] 

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

667 assert values is not None 

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

669 anchor_position = ( 

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

671 pkg_kvpair.value_element.position_in_parent().relative_to( 

672 stanza_range.start_pos 

673 ) 

674 ) 

675 ) 

676 anchor_position_client_units = doc.position_codec.position_to_client_units( 

677 lint_state.lines, 

678 te_position_to_lsp(anchor_position), 

679 ) 

680 inlay_hints.append( 

681 types.InlayHint( 

682 anchor_position_client_units, 

683 parts, 

684 padding_left=True, 

685 padding_right=False, 

686 ) 

687 ) 

688 return inlay_hints 

689 

690 

691def _source_package_checks( 

692 stanza: Deb822ParagraphElement, 

693 stanza_position: "TEPosition", 

694 stanza_metadata: StanzaMetadata[DctrlKnownField], 

695 lint_state: LintState, 

696) -> None: 

697 vcs_fields = {} 

698 source_fields = _DCTRL_FILE_METADATA["Source"].stanza_fields 

699 for kvpair in stanza.iter_parts_of_type(Deb822KeyValuePairElement): 

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

701 if ( 

702 not name.startswith("vcs-") 

703 or name == "vcs-browser" 

704 or name not in source_fields 

705 ): 

706 continue 

707 vcs_fields[name] = kvpair 

708 

709 if len(vcs_fields) < 2: 

710 return 

711 for kvpair in vcs_fields.values(): 

712 lint_state.emit_diagnostic( 

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

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

715 "warning", 

716 "Policy 5.6.26", 

717 quickfixes=[ 

718 propose_remove_range_quick_fix( 

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

720 ) 

721 ], 

722 ) 

723 

724 

725def _binary_package_checks( 

726 stanza: Deb822ParagraphElement, 

727 stanza_position: "TEPosition", 

728 source_stanza: Deb822ParagraphElement, 

729 representation_field_range: "TERange", 

730 lint_state: LintState, 

731) -> None: 

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

733 source_section = source_stanza.get("Section") 

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

735 section: str | None = None 

736 section_range: Optional["TERange"] = None 

737 if section_kvpair is not None: 

738 section, section_range = extract_first_value_and_position( 

739 section_kvpair, 

740 stanza_position, 

741 ) 

742 

743 if section_range is None: 

744 section_range = representation_field_range 

745 effective_section = section or source_section or "unknown" 

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

747 component_prefix = "" 

748 if "/" in effective_section: 

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

750 component_prefix += "/" 

751 

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

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

754 package_type_kvpair = stanza.get_kvpair_element( 

755 "Package-Type", use_get=True 

756 ) 

757 package_type_range: Optional["TERange"] = None 

758 if package_type_kvpair is not None: 

759 _, package_type_range = extract_first_value_and_position( 

760 package_type_kvpair, 

761 stanza_position, 

762 ) 

763 if package_type_range is None: 

764 package_type_range = representation_field_range 

765 lint_state.emit_diagnostic( 

766 package_type_range, 

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

768 "warning", 

769 "debputy", 

770 ) 

771 guessed_section = "debian-installer" 

772 section_diagnostic_rationale = " since it is an udeb" 

773 else: 

774 guessed_section = package_name_to_section(package_name) 

775 section_diagnostic_rationale = " based on the package name" 

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

777 if section is not None: 

778 quickfix_data = [ 

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

780 ] 

781 else: 

782 quickfix_data = [ 

783 propose_insert_text_on_line_after_diagnostic_quick_fix( 

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

785 ) 

786 ] 

787 assert section_range is not None # mypy hint 

788 lint_state.emit_diagnostic( 

789 section_range, 

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

791 "warning", 

792 "debputy", 

793 quickfixes=quickfix_data, 

794 ) 

795 

796 

797@lint_diagnostics(_DISPATCH_RULE) 

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

799 deb822_file = lint_state.parsed_deb822_file_content 

800 

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

802 return 

803 

804 first_error = await scan_for_syntax_errors_and_token_level_diagnostics( 

805 deb822_file, 

806 lint_state, 

807 ) 

808 

809 stanzas = list(deb822_file) 

810 source_stanza = stanzas[0] if stanzas else None 

811 binary_stanzas_w_pos = [] 

812 

813 source_stanza_metadata, binary_stanza_metadata = _DCTRL_FILE_METADATA.stanza_types() 

814 stanza_no = 0 

815 

816 async for stanza_range, stanza in lint_state.slow_iter( 

817 with_range_in_continuous_parts(deb822_file.iter_parts()) 

818 ): 

819 if not isinstance(stanza, Deb822ParagraphElement): 

820 continue 

821 stanza_position = stanza_range.start_pos 

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

823 break 

824 stanza_no += 1 

825 is_binary_stanza = stanza_no != 1 

826 if is_binary_stanza: 

827 stanza_metadata = binary_stanza_metadata 

828 other_stanza_metadata = source_stanza_metadata 

829 other_stanza_name = "Source" 

830 binary_stanzas_w_pos.append((stanza, stanza_position)) 

831 _, representation_field_range = stanza_metadata.stanza_representation( 

832 stanza, stanza_position 

833 ) 

834 _binary_package_checks( 

835 stanza, 

836 stanza_position, 

837 source_stanza, 

838 representation_field_range, 

839 lint_state, 

840 ) 

841 else: 

842 stanza_metadata = source_stanza_metadata 

843 other_stanza_metadata = binary_stanza_metadata 

844 other_stanza_name = "Binary" 

845 _source_package_checks( 

846 stanza, 

847 stanza_position, 

848 stanza_metadata, 

849 lint_state, 

850 ) 

851 

852 await stanza_metadata.stanza_diagnostics( 

853 deb822_file, 

854 stanza, 

855 stanza_position, 

856 lint_state, 

857 confusable_with_stanza_metadata=other_stanza_metadata, 

858 confusable_with_stanza_name=other_stanza_name, 

859 inherit_from_stanza=source_stanza if is_binary_stanza else None, 

860 ) 

861 

862 _detect_misspelled_packaging_files( 

863 lint_state, 

864 binary_stanzas_w_pos, 

865 ) 

866 

867 

868def _package_range_of_stanza( 

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

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

871 for stanza, stanza_position in binary_stanzas: 

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

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

874 continue 

875 representation_field_range = kvpair.range_in_parent().relative_to( 

876 stanza_position 

877 ) 

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

879 

880 

881def _packaging_files( 

882 lint_state: LintState, 

883) -> Iterable[PackagerProvidedFile]: 

884 source_root = lint_state.source_root 

885 debian_dir = lint_state.debian_dir 

886 binary_packages = lint_state.binary_packages 

887 if ( 

888 source_root is None 

889 or not source_root.has_fs_path 

890 or debian_dir is None 

891 or binary_packages is None 

892 ): 

893 return 

894 

895 debputy_integration_mode = lint_state.debputy_metadata.debputy_integration_mode 

896 dh_sequencer_data = lint_state.dh_sequencer_data 

897 dh_sequences = dh_sequencer_data.sequences 

898 is_debputy_package = debputy_integration_mode is not None 

899 feature_set = lint_state.plugin_feature_set 

900 known_packaging_files = feature_set.known_packaging_files 

901 static_packaging_files = { 

902 kpf.detection_value: kpf 

903 for kpf in known_packaging_files.values() 

904 if kpf.detection_method == "path" 

905 } 

906 ignored_path = set(static_packaging_files) 

907 

908 if is_debputy_package: 

909 all_debputy_ppfs = list( 

910 flatten_ppfs( 

911 detect_all_packager_provided_files( 

912 feature_set, 

913 debian_dir, 

914 binary_packages, 

915 allow_fuzzy_matches=True, 

916 detect_typos=True, 

917 ignore_paths=ignored_path, 

918 ) 

919 ) 

920 ) 

921 for ppf in all_debputy_ppfs: 

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

923 continue 

924 ignored_path.add(ppf.path.path) 

925 yield ppf 

926 

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

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

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

930 debputy_plugin_metadata = plugin_metadata_for_debputys_own_plugin() 

931 ( 

932 all_dh_ppfs, 

933 _, 

934 _, 

935 _, 

936 ) = resolve_debhelper_config_files( 

937 debian_dir, 

938 binary_packages, 

939 debputy_plugin_metadata, 

940 feature_set, 

941 dh_sequences, 

942 dh_compat_level, 

943 saw_dh=dh_sequencer_data.uses_dh_sequencer, 

944 ignore_paths=ignored_path, 

945 debputy_integration_mode=debputy_integration_mode, 

946 cwd=source_root.fs_path, 

947 ) 

948 for ppf in all_dh_ppfs: 

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

950 continue 

951 ignored_path.add(ppf.path.path) 

952 yield ppf 

953 

954 

955def _detect_misspelled_packaging_files( 

956 lint_state: LintState, 

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

958) -> None: 

959 stanza_ranges = { 

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

961 } 

962 for ppf in _packaging_files(lint_state): 

963 binary_package = ppf.package_name 

964 explicit_package = ppf.uses_explicit_package_name 

965 name_segment = ppf.name_segment is not None 

966 stem = ppf.definition.stem 

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

968 _trace_log( 

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

970 ) 

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

972 continue 

973 res = stanza_ranges.get(binary_package) 

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

975 continue 

976 declared_arch, diag_range = res 

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

978 continue 

979 path = ppf.path.path 

980 likely_typo_of = ppf.expected_path 

981 arch_restriction = ppf.architecture_restriction 

982 if likely_typo_of is not None: 

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

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

985 lint_state.emit_diagnostic( 

986 diag_range, 

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

988 "warning", 

989 "debputy", 

990 diagnostic_applies_to_another_file=path, 

991 ) 

992 continue 

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

994 lint_state.emit_diagnostic( 

995 diag_range, 

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

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

998 "warning", 

999 "debputy", 

1000 diagnostic_applies_to_another_file=path, 

1001 ) 

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

1003 lint_state.emit_diagnostic( 

1004 diag_range, 

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

1006 "warning", 

1007 "debputy", 

1008 diagnostic_applies_to_another_file=path, 

1009 ) 

1010 

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

1012 lint_state.emit_diagnostic( 

1013 diag_range, 

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

1015 " with the current addons", 

1016 "warning", 

1017 "debputy", 

1018 diagnostic_applies_to_another_file=path, 

1019 ) 

1020 continue 

1021 

1022 if not explicit_package and name_segment is not None: 

1023 basename = os.path.basename(path) 

1024 if basename == ppf.definition.stem: 

1025 continue 

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

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

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

1029 if ppf.definition.allow_name_segment: 

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

1031 else: 

1032 or_alt_name = "" 

1033 

1034 lint_state.emit_diagnostic( 

1035 diag_range, 

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

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

1038 "warning", 

1039 "debputy", 

1040 diagnostic_applies_to_another_file=path, 

1041 ) 

1042 

1043 

1044@lsp_will_save_wait_until(_DISPATCH_RULE) 

1045def _debian_control_on_save_formatting( 

1046 ls: "DebputyLanguageServer", 

1047 params: WillSaveTextDocumentParams, 

1048) -> Sequence[TextEdit] | None: 

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

1050 lint_state = ls.lint_state(doc) 

1051 return _reformat_debian_control(lint_state) 

1052 

1053 

1054@lsp_cli_reformat_document(_DISPATCH_RULE) 

1055def _reformat_debian_control( 

1056 lint_state: LintState, 

1057) -> Sequence[TextEdit] | None: 

1058 return deb822_format_file(lint_state, _DCTRL_FILE_METADATA) 

1059 

1060 

1061@lsp_format_document(_DISPATCH_RULE) 

1062def _debian_control_format_file( 

1063 ls: "DebputyLanguageServer", 

1064 params: DocumentFormattingParams, 

1065) -> Sequence[TextEdit] | None: 

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

1067 lint_state = ls.lint_state(doc) 

1068 return _reformat_debian_control(lint_state) 

1069 

1070 

1071@lsp_semantic_tokens_full(_DISPATCH_RULE) 

1072async def _debian_control_semantic_tokens_full( 

1073 ls: "DebputyLanguageServer", 

1074 request: SemanticTokensParams, 

1075) -> SemanticTokens | None: 

1076 return await deb822_semantic_tokens_full( 

1077 ls, 

1078 request, 

1079 _DCTRL_FILE_METADATA, 

1080 )