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

424 statements  

« prev     ^ index     » next       coverage.py v7.8.2, created at 2025-10-12 15:06 +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 dh_seq = ( 

146 f"DH Sequence: {self.dh_sequence}" if self.dh_sequence is not None else None 

147 ) 

148 doc_uris = self.doc_uris 

149 parts = [def_by, dh_seq] 

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

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

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

153 else: 

154 parts.append("Documentation:") 

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

156 return "\n".join(parts) 

157 

158 @classmethod 

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

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

161 return cls( 

162 x["name"], 

163 x["defined_by"], 

164 x.get("dh_sequence"), 

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

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

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

168 ) 

169 

170 

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

172 relationship_fields = all_package_relationship_fields() 

173 try: 

174 col_idx = substvar.rindex(":") 

175 except ValueError: 

176 return None 

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

178 

179 

180def _as_substvars_metadata( 

181 args: list[SubstvarMetadata], 

182) -> Mapping[str, SubstvarMetadata]: 

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

184 assert len(r) == len(args) 

185 return r 

186 

187 

188def dctrl_variables_metadata_basename() -> str: 

189 return "debian_control_variables_data.yaml" 

190 

191 

192@lru_cache 

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

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

195 dctrl_variables_metadata_basename() 

196 ) 

197 

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

199 raw = MANIFEST_YAML.load(fd) 

200 

201 attr_path = AttributePath.root_path(p) 

202 ref = DCTRL_SUBSTVARS_REFERENCE_DATA_PARSER.parse_input(raw, attr_path) 

203 return _as_substvars_metadata( 

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

205 ) 

206 

207 

208_DCTRL_FILE_METADATA = DctrlFileMetadata() 

209 

210 

211lsp_standard_handler(_DISPATCH_RULE, TEXT_DOCUMENT_CODE_ACTION) 

212 

213 

214@lsp_hover(_DISPATCH_RULE) 

215def _debian_control_hover( 

216 ls: "DebputyLanguageServer", 

217 params: HoverParams, 

218) -> Hover | None: 

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

220 

221 

222def _custom_hover_description( 

223 _ls: "DebputyLanguageServer", 

224 _known_field: DctrlKnownField, 

225 line: str, 

226 _word_at_position: str, 

227) -> Hover | str | None: 

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

229 return None 

230 try: 

231 col_idx = line.index(":") 

232 except ValueError: 

233 return None 

234 

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

236 

237 # Synopsis 

238 return textwrap.dedent( 

239 f"""\ 

240 # Package synopsis 

241 

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

243 complete sentence, so sentential punctuation is inappropriate: it 

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

245 It should also omit any initial indefinite or definite article 

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

247 

248 ``` 

249 Package: libeg0 

250 Description: exemplification support library 

251 ``` 

252 

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

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

255 substitute the package name and synopsis into this formula: 

256 

257 ``` 

258 # Generic 

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

260 

261 # The current package for comparison 

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

263 ``` 

264 

265 Other advice for writing synopsis: 

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

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

268 understand what they are looking at. 

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

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

271 

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

273 ``` 

274 # apt search TERM 

275 package/stable,now 1.0-1 all: 

276 {content} 

277 

278 # apt-get search TERM 

279 package - {content} 

280 ``` 

281 

282 ## Reference example 

283 

284 An reference example for comparison: The Sphinx package 

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

286 

287 ``` 

288 Description: documentation generator for Python projects 

289 ``` 

290 

291 In the test sentence, it would read as: 

292 

293 ``` 

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

295 ``` 

296 

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

298 ``` 

299 # apt search TERM 

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

301 documentation generator for Python projects 

302 

303 package/stable,now 1.0-1 all: 

304 {content} 

305 

306 

307 # apt-get search TERM 

308 package - {content} 

309 python3-sphinx - documentation generator for Python projects 

310 ``` 

311 """ 

312 ) 

313 

314 

315def _render_package_lookup( 

316 package_lookup: PackageLookup, 

317 known_field: DctrlKnownField, 

318) -> str: 

319 name = package_lookup.name 

320 provider = package_lookup.package 

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

322 provider = package_lookup.provided_by[0] 

323 

324 if provider: 

325 segments = [ 

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

327 "", 

328 ] 

329 

330 if ( 

331 _is_bd_field(known_field) 

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

333 and len(name) > 12 

334 ): 

335 sequence = name[12:] 

336 segments.append( 

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

338 ) 

339 segments.append("") 

340 

341 elif ( 

342 known_field.name == "Build-Depends" 

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

344 and len(name) > 15 

345 ): 

346 plugin_name = name[15:] 

347 segments.append( 

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

349 ) 

350 segments.append("") 

351 

352 segments.extend( 

353 [ 

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

355 "", 

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

357 "", 

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

359 ] 

360 ) 

361 if provider.upstream_homepage is not None: 

362 segments.append("") 

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

364 segments.append("") 

365 segments.append( 

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

367 ) 

368 return "\n".join(segments) 

369 

370 segments = [ 

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

372 "", 

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

374 ] 

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

376 segments.append("") 

377 segments.append( 

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

379 ) 

380 return "\n".join(segments) 

381 

382 

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

384 if is_empty: 

385 return textwrap.dedent( 

386 """\ 

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

388 package exist. 

389""" 

390 ) 

391 return textwrap.dedent( 

392 """\ 

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

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

395""" 

396 ) 

397 

398 

399def _render_package_by_name( 

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

401) -> str | None: 

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

403 sequence = name[12:] 

404 return ( 

405 textwrap.dedent( 

406 f"""\ 

407 # {name} 

408 

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

410 

411 """ 

412 ) 

413 + _disclaimer(is_empty) 

414 ) 

415 if ( 

416 known_field.name == "Build-Depends" 

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

418 and len(name) > 15 

419 ): 

420 plugin_name = name[15:] 

421 return ( 

422 textwrap.dedent( 

423 f"""\ 

424 # {name} 

425 

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

427 

428 """ 

429 ) 

430 + _disclaimer(is_empty) 

431 ) 

432 return ( 

433 textwrap.dedent( 

434 f"""\ 

435 # {name} 

436 

437 """ 

438 ) 

439 + _disclaimer(is_empty) 

440 ) 

441 

442 

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

444 return known_field.name in ( 

445 "Build-Depends", 

446 "Build-Depends-Arch", 

447 "Build-Depends-Indep", 

448 ) 

449 

450 

451def _custom_hover_relationship_field( 

452 ls: "DebputyLanguageServer", 

453 known_field: DctrlKnownField, 

454 _line: str, 

455 word_at_position: str, 

456) -> Hover | str | None: 

457 apt_cache = ls.apt_cache 

458 state = apt_cache.state 

459 is_empty = False 

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

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

462 return textwrap.dedent( 

463 f"""\ 

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

465 

466 The relation being matched: `{word_at_position}` 

467 

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

469 """ 

470 ) 

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

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

473 return 

474 package = match.group() 

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

476 state = "loaded" 

477 is_empty = True 

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

479 result = apt_cache.lookup(package) 

480 if result is None: 

481 return _render_package_by_name( 

482 package, 

483 known_field, 

484 is_empty=is_empty, 

485 ) 

486 return _render_package_lookup(result, known_field) 

487 

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

489 "not-loaded", 

490 "failed", 

491 "tooling-not-available", 

492 ): 

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

494 return textwrap.dedent( 

495 f"""\ 

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

497 

498 Details: {details} 

499 """ 

500 ) 

501 

502 if state == "empty-cache": 

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

504 

505 if state == "loading": 

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

507 return None 

508 

509 

510_CUSTOM_FIELD_HOVER = { 

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 

519_CUSTOM_FIELD_HOVER["Description"] = _custom_hover_description 

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 )