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

423 statements  

« prev     ^ index     » next       coverage.py v7.8.2, created at 2025-09-07 09:27 +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 Sequence, 

10 Tuple, 

11 Optional, 

12 Mapping, 

13 List, 

14 Iterable, 

15 Self, 

16 TYPE_CHECKING, 

17) 

18 

19import debputy.lsp.data.deb822_data as deb822_ref_data_dir 

20from debputy.analysis.analysis_util import flatten_ppfs 

21from debputy.analysis.debian_dir import resolve_debhelper_config_files 

22from debputy.dh.dh_assistant import extract_dh_compat_level 

23from debputy.linting.lint_util import ( 

24 LintState, 

25 te_range_to_lsp, 

26 te_position_to_lsp, 

27 with_range_in_continuous_parts, 

28) 

29from debputy.lsp.apt_cache import PackageLookup 

30from debputy.lsp.debputy_ls import DebputyLanguageServer 

31from debputy.lsp.lsp_debian_control_reference_data import ( 

32 DctrlKnownField, 

33 DctrlFileMetadata, 

34 package_name_to_section, 

35 all_package_relationship_fields, 

36 extract_first_value_and_position, 

37 all_source_relationship_fields, 

38 StanzaMetadata, 

39 SUBSTVAR_RE, 

40) 

41from debputy.lsp.lsp_features import ( 

42 lint_diagnostics, 

43 lsp_completer, 

44 lsp_hover, 

45 lsp_standard_handler, 

46 lsp_folding_ranges, 

47 lsp_semantic_tokens_full, 

48 lsp_will_save_wait_until, 

49 lsp_format_document, 

50 lsp_text_doc_inlay_hints, 

51 LanguageDispatchRule, 

52 SecondaryLanguage, 

53 lsp_cli_reformat_document, 

54) 

55from debputy.lsp.lsp_generic_deb822 import ( 

56 deb822_completer, 

57 deb822_hover, 

58 deb822_folding_ranges, 

59 deb822_semantic_tokens_full, 

60 deb822_format_file, 

61 scan_for_syntax_errors_and_token_level_diagnostics, 

62) 

63from debputy.lsp.lsp_reference_keyword import LSP_DATA_DOMAIN 

64from debputy.lsp.quickfixes import ( 

65 propose_correct_text_quick_fix, 

66 propose_insert_text_on_line_after_diagnostic_quick_fix, 

67 propose_remove_range_quick_fix, 

68) 

69from debputy.lsp.ref_models.deb822_reference_parse_models import ( 

70 DCTRL_SUBSTVARS_REFERENCE_DATA_PARSER, 

71 DCtrlSubstvar, 

72) 

73from debputy.lsp.text_util import markdown_urlify 

74from debputy.lsp.vendoring._deb822_repro import ( 

75 Deb822ParagraphElement, 

76) 

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

78 Deb822KeyValuePairElement, 

79) 

80from debputy.lsprotocol.types import ( 

81 Position, 

82 FoldingRange, 

83 FoldingRangeParams, 

84 CompletionItem, 

85 CompletionList, 

86 CompletionParams, 

87 HoverParams, 

88 Hover, 

89 TEXT_DOCUMENT_CODE_ACTION, 

90 SemanticTokens, 

91 SemanticTokensParams, 

92 WillSaveTextDocumentParams, 

93 TextEdit, 

94 DocumentFormattingParams, 

95 InlayHint, 

96) 

97from debputy.manifest_parser.util import AttributePath 

98from debputy.packager_provided_files import ( 

99 PackagerProvidedFile, 

100 detect_all_packager_provided_files, 

101) 

102from debputy.plugin.api.impl import plugin_metadata_for_debputys_own_plugin 

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

104from debputy.yaml import MANIFEST_YAML 

105 

106if TYPE_CHECKING: 

107 import lsprotocol.types as types 

108else: 

109 import debputy.lsprotocol.types as types 

110 

111try: 

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

113 Position as TEPosition, 

114 Range as TERange, 

115 START_POSITION, 

116 ) 

117 

118 from pygls.workspace import TextDocument 

119except ImportError: 

120 pass 

121 

122 

123_DISPATCH_RULE = LanguageDispatchRule.new_rule( 

124 "debian/control", 

125 None, 

126 "debian/control", 

127 [ 

128 # emacs's name 

129 SecondaryLanguage("debian-control"), 

130 # vim's name 

131 SecondaryLanguage("debcontrol"), 

132 ], 

133) 

134 

135 

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

137class SubstvarMetadata: 

138 name: str 

139 defined_by: str 

140 dh_sequence: Optional[str] 

141 doc_uris: Sequence[str] 

142 synopsis: str 

143 description: str 

144 

145 def render_metadata_fields(self) -> str: 

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

147 dh_seq = ( 

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

149 ) 

150 doc_uris = self.doc_uris 

151 parts = [def_by, dh_seq] 

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

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

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

155 else: 

156 parts.append("Documentation:") 

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

158 return "\n".join(parts) 

159 

160 @classmethod 

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

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

163 return cls( 

164 x["name"], 

165 x["defined_by"], 

166 x.get("dh_sequence"), 

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

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

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

170 ) 

171 

172 

173def relationship_substvar_for_field(substvar: str) -> Optional[str]: 

174 relationship_fields = all_package_relationship_fields() 

175 try: 

176 col_idx = substvar.rindex(":") 

177 except ValueError: 

178 return None 

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

180 

181 

182def _as_substvars_metadata( 

183 args: List[SubstvarMetadata], 

184) -> Mapping[str, SubstvarMetadata]: 

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

186 assert len(r) == len(args) 

187 return r 

188 

189 

190def dctrl_variables_metadata_basename() -> str: 

191 return "debian_control_variables_data.yaml" 

192 

193 

194@lru_cache 

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

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

197 dctrl_variables_metadata_basename() 

198 ) 

199 

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

201 raw = MANIFEST_YAML.load(fd) 

202 

203 attr_path = AttributePath.root_path(p) 

204 ref = DCTRL_SUBSTVARS_REFERENCE_DATA_PARSER.parse_input(raw, attr_path) 

205 return _as_substvars_metadata( 

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

207 ) 

208 

209 

210_DCTRL_FILE_METADATA = DctrlFileMetadata() 

211 

212 

213lsp_standard_handler(_DISPATCH_RULE, TEXT_DOCUMENT_CODE_ACTION) 

214 

215 

216@lsp_hover(_DISPATCH_RULE) 

217def _debian_control_hover( 

218 ls: "DebputyLanguageServer", 

219 params: HoverParams, 

220) -> Optional[Hover]: 

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

222 

223 

224def _custom_hover_description( 

225 _ls: "DebputyLanguageServer", 

226 _known_field: DctrlKnownField, 

227 line: str, 

228 _word_at_position: str, 

229) -> Optional[Union[Hover, str]]: 

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

231 return None 

232 try: 

233 col_idx = line.index(":") 

234 except ValueError: 

235 return None 

236 

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

238 

239 # Synopsis 

240 return textwrap.dedent( 

241 f"""\ 

242 # Package synopsis 

243 

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

245 complete sentence, so sentential punctuation is inappropriate: it 

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

247 It should also omit any initial indefinite or definite article 

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

249 

250 ``` 

251 Package: libeg0 

252 Description: exemplification support library 

253 ``` 

254 

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

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

257 substitute the package name and synopsis into this formula: 

258 

259 ``` 

260 # Generic 

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

262 

263 # The current package for comparison 

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

265 ``` 

266 

267 Other advice for writing synopsis: 

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

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

270 understand what they are looking at. 

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

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

273 

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

275 ``` 

276 # apt search TERM 

277 package/stable,now 1.0-1 all: 

278 {content} 

279 

280 # apt-get search TERM 

281 package - {content} 

282 ``` 

283 

284 ## Reference example 

285 

286 An reference example for comparison: The Sphinx package 

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

288 

289 ``` 

290 Description: documentation generator for Python projects 

291 ``` 

292 

293 In the test sentence, it would read as: 

294 

295 ``` 

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

297 ``` 

298 

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

300 ``` 

301 # apt search TERM 

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

303 documentation generator for Python projects 

304 

305 package/stable,now 1.0-1 all: 

306 {content} 

307 

308 

309 # apt-get search TERM 

310 package - {content} 

311 python3-sphinx - documentation generator for Python projects 

312 ``` 

313 """ 

314 ) 

315 

316 

317def _render_package_lookup( 

318 package_lookup: PackageLookup, 

319 known_field: DctrlKnownField, 

320) -> str: 

321 name = package_lookup.name 

322 provider = package_lookup.package 

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

324 provider = package_lookup.provided_by[0] 

325 

326 if provider: 

327 segments = [ 

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

329 "", 

330 ] 

331 

332 if ( 

333 _is_bd_field(known_field) 

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

335 and len(name) > 12 

336 ): 

337 sequence = name[12:] 

338 segments.append( 

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

340 ) 

341 segments.append("") 

342 

343 elif ( 

344 known_field.name == "Build-Depends" 

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

346 and len(name) > 15 

347 ): 

348 plugin_name = name[15:] 

349 segments.append( 

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

351 ) 

352 segments.append("") 

353 

354 segments.extend( 

355 [ 

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

357 "", 

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

359 "", 

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

361 ] 

362 ) 

363 if provider.upstream_homepage is not None: 

364 segments.append("") 

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

366 segments.append("") 

367 segments.append( 

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

369 ) 

370 return "\n".join(segments) 

371 

372 segments = [ 

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

374 "", 

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

376 ] 

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

378 segments.append("") 

379 segments.append( 

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

381 ) 

382 return "\n".join(segments) 

383 

384 

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

386 if is_empty: 

387 return textwrap.dedent( 

388 """\ 

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

390 package exist. 

391""" 

392 ) 

393 return textwrap.dedent( 

394 """\ 

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

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

397""" 

398 ) 

399 

400 

401def _render_package_by_name( 

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

403) -> Optional[str]: 

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

405 sequence = name[12:] 

406 return ( 

407 textwrap.dedent( 

408 f"""\ 

409 # {name} 

410 

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

412 

413 """ 

414 ) 

415 + _disclaimer(is_empty) 

416 ) 

417 if ( 

418 known_field.name == "Build-Depends" 

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

420 and len(name) > 15 

421 ): 

422 plugin_name = name[15:] 

423 return ( 

424 textwrap.dedent( 

425 f"""\ 

426 # {name} 

427 

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

429 

430 """ 

431 ) 

432 + _disclaimer(is_empty) 

433 ) 

434 return ( 

435 textwrap.dedent( 

436 f"""\ 

437 # {name} 

438 

439 """ 

440 ) 

441 + _disclaimer(is_empty) 

442 ) 

443 

444 

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

446 return known_field.name in ( 

447 "Build-Depends", 

448 "Build-Depends-Arch", 

449 "Build-Depends-Indep", 

450 ) 

451 

452 

453def _custom_hover_relationship_field( 

454 ls: "DebputyLanguageServer", 

455 known_field: DctrlKnownField, 

456 _line: str, 

457 word_at_position: str, 

458) -> Optional[Union[Hover, str]]: 

459 apt_cache = ls.apt_cache 

460 state = apt_cache.state 

461 is_empty = False 

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

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

464 return textwrap.dedent( 

465 f"""\ 

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

467 

468 The relation being matched: `{word_at_position}` 

469 

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

471 """ 

472 ) 

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

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

475 return 

476 package = match.group() 

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

478 state = "loaded" 

479 is_empty = True 

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

481 result = apt_cache.lookup(package) 

482 if result is None: 

483 return _render_package_by_name( 

484 package, 

485 known_field, 

486 is_empty=is_empty, 

487 ) 

488 return _render_package_lookup(result, known_field) 

489 

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

491 "not-loaded", 

492 "failed", 

493 "tooling-not-available", 

494 ): 

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

496 return textwrap.dedent( 

497 f"""\ 

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

499 

500 Details: {details} 

501 """ 

502 ) 

503 

504 if state == "empty-cache": 

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

506 

507 if state == "loading": 

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

509 return None 

510 

511 

512_CUSTOM_FIELD_HOVER = { 

513 field: _custom_hover_relationship_field 

514 for field in chain( 

515 all_package_relationship_fields().values(), 

516 all_source_relationship_fields().values(), 

517 ) 

518 if field != "Provides" 

519} 

520 

521_CUSTOM_FIELD_HOVER["Description"] = _custom_hover_description 

522 

523 

524def _custom_hover( 

525 ls: "DebputyLanguageServer", 

526 server_position: Position, 

527 _current_field: Optional[str], 

528 word_at_position: str, 

529 known_field: Optional[DctrlKnownField], 

530 in_value: bool, 

531 _doc: "TextDocument", 

532 lines: List[str], 

533) -> Optional[Union[Hover, str]]: 

534 if not in_value: 

535 return None 

536 

537 line_no = server_position.line 

538 line = lines[line_no] 

539 substvar_search_ref = server_position.character 

540 substvar = "" 

541 try: 

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

543 substvar_search_ref += 2 

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

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

546 if server_position.character <= substvar_end: 

547 substvar = line[substvar_start : substvar_end + 1] 

548 except (ValueError, IndexError): 

549 pass 

550 

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

552 substvar_md = dctrl_substvars_metadata().get(substvar) 

553 

554 computed_doc = "" 

555 for_field = relationship_substvar_for_field(substvar) 

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

557 # Leading empty line is intentional! 

558 computed_doc = textwrap.dedent( 

559 f""" 

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

561 Relationship substvars are automatically added in the field they 

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

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

564 """ 

565 ) 

566 

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

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

569 md_fields = "" 

570 else: 

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

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

573 substvar_md.description, 

574 ) 

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

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

577 

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

579 return None 

580 dispatch = _CUSTOM_FIELD_HOVER.get(known_field.name) 

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

582 return None 

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

584 

585 

586@lsp_completer(_DISPATCH_RULE) 

587def _debian_control_completions( 

588 ls: "DebputyLanguageServer", 

589 params: CompletionParams, 

590) -> Optional[Union[CompletionList, Sequence[CompletionItem]]]: 

591 return deb822_completer(ls, params, _DCTRL_FILE_METADATA) 

592 

593 

594@lsp_folding_ranges(_DISPATCH_RULE) 

595def _debian_control_folding_ranges( 

596 ls: "DebputyLanguageServer", 

597 params: FoldingRangeParams, 

598) -> Optional[Sequence[FoldingRange]]: 

599 return deb822_folding_ranges(ls, params, _DCTRL_FILE_METADATA) 

600 

601 

602@lsp_text_doc_inlay_hints(_DISPATCH_RULE) 

603async def _doc_inlay_hint( 

604 ls: "DebputyLanguageServer", 

605 params: types.InlayHintParams, 

606) -> Optional[List[InlayHint]]: 

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

608 lint_state = ls.lint_state(doc) 

609 deb822_file = lint_state.parsed_deb822_file_content 

610 if not deb822_file: 

611 return None 

612 inlay_hints = [] 

613 stanzas = list(deb822_file) 

614 if len(stanzas) < 2: 

615 return None 

616 source_stanza = stanzas[0] 

617 source_stanza_pos = source_stanza.position_in_file() 

618 inherited_inlay_label_part = {} 

619 stanza_no = 0 

620 

621 async for stanza_range, stanza in lint_state.slow_iter( 

622 with_range_in_continuous_parts(deb822_file.iter_parts()) 

623 ): 

624 if not isinstance(stanza, Deb822ParagraphElement): 

625 continue 

626 stanza_def = _DCTRL_FILE_METADATA.classify_stanza(stanza, stanza_no) 

627 stanza_no += 1 

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

629 if pkg_kvpair is None: 

630 continue 

631 

632 parts = [] 

633 async for known_field in ls.slow_iter( 

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

635 ): 

636 if ( 

637 not known_field.inheritable_from_other_stanza 

638 or not known_field.show_as_inherited 

639 or known_field.name in stanza 

640 ): 

641 continue 

642 

643 inherited_value = source_stanza.get(known_field.name) 

644 if inherited_value is not None: 

645 inlay_hint_label_part = inherited_inlay_label_part.get(known_field.name) 

646 if inlay_hint_label_part is None: 

647 kvpair = source_stanza.get_kvpair_element(known_field.name) 

648 value_range_te = kvpair.range_in_parent().relative_to( 

649 source_stanza_pos 

650 ) 

651 value_range = doc.position_codec.range_to_client_units( 

652 lint_state.lines, 

653 te_range_to_lsp(value_range_te), 

654 ) 

655 inlay_hint_label_part = types.InlayHintLabelPart( 

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

657 tooltip="Inherited from Source stanza", 

658 location=types.Location( 

659 params.text_document.uri, 

660 value_range, 

661 ), 

662 ) 

663 inherited_inlay_label_part[known_field.name] = inlay_hint_label_part 

664 parts.append(inlay_hint_label_part) 

665 

666 if parts: 

667 known_field = stanza_def["Package"] 

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

669 assert values is not None 

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

671 anchor_position = ( 

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

673 pkg_kvpair.value_element.position_in_parent().relative_to( 

674 stanza_range.start_pos 

675 ) 

676 ) 

677 ) 

678 anchor_position_client_units = doc.position_codec.position_to_client_units( 

679 lint_state.lines, 

680 te_position_to_lsp(anchor_position), 

681 ) 

682 inlay_hints.append( 

683 types.InlayHint( 

684 anchor_position_client_units, 

685 parts, 

686 padding_left=True, 

687 padding_right=False, 

688 ) 

689 ) 

690 return inlay_hints 

691 

692 

693def _source_package_checks( 

694 stanza: Deb822ParagraphElement, 

695 stanza_position: "TEPosition", 

696 stanza_metadata: StanzaMetadata[DctrlKnownField], 

697 lint_state: LintState, 

698) -> None: 

699 vcs_fields = {} 

700 source_fields = _DCTRL_FILE_METADATA["Source"].stanza_fields 

701 for kvpair in stanza.iter_parts_of_type(Deb822KeyValuePairElement): 

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

703 if ( 

704 not name.startswith("vcs-") 

705 or name == "vcs-browser" 

706 or name not in source_fields 

707 ): 

708 continue 

709 vcs_fields[name] = kvpair 

710 

711 if len(vcs_fields) < 2: 

712 return 

713 for kvpair in vcs_fields.values(): 

714 lint_state.emit_diagnostic( 

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

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

717 "warning", 

718 "Policy 5.6.26", 

719 quickfixes=[ 

720 propose_remove_range_quick_fix( 

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

722 ) 

723 ], 

724 ) 

725 

726 

727def _binary_package_checks( 

728 stanza: Deb822ParagraphElement, 

729 stanza_position: "TEPosition", 

730 source_stanza: Deb822ParagraphElement, 

731 representation_field_range: "TERange", 

732 lint_state: LintState, 

733) -> None: 

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

735 source_section = source_stanza.get("Section") 

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

737 section: Optional[str] = None 

738 section_range: Optional["TERange"] = None 

739 if section_kvpair is not None: 

740 section, section_range = extract_first_value_and_position( 

741 section_kvpair, 

742 stanza_position, 

743 ) 

744 

745 if section_range is None: 

746 section_range = representation_field_range 

747 effective_section = section or source_section or "unknown" 

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

749 component_prefix = "" 

750 if "/" in effective_section: 

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

752 component_prefix += "/" 

753 

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

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

756 package_type_kvpair = stanza.get_kvpair_element( 

757 "Package-Type", use_get=True 

758 ) 

759 package_type_range: Optional["TERange"] = None 

760 if package_type_kvpair is not None: 

761 _, package_type_range = extract_first_value_and_position( 

762 package_type_kvpair, 

763 stanza_position, 

764 ) 

765 if package_type_range is None: 

766 package_type_range = representation_field_range 

767 lint_state.emit_diagnostic( 

768 package_type_range, 

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

770 "warning", 

771 "debputy", 

772 ) 

773 guessed_section = "debian-installer" 

774 section_diagnostic_rationale = " since it is an udeb" 

775 else: 

776 guessed_section = package_name_to_section(package_name) 

777 section_diagnostic_rationale = " based on the package name" 

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

779 if section is not None: 

780 quickfix_data = [ 

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

782 ] 

783 else: 

784 quickfix_data = [ 

785 propose_insert_text_on_line_after_diagnostic_quick_fix( 

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

787 ) 

788 ] 

789 assert section_range is not None # mypy hint 

790 lint_state.emit_diagnostic( 

791 section_range, 

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

793 "warning", 

794 "debputy", 

795 quickfixes=quickfix_data, 

796 ) 

797 

798 

799@lint_diagnostics(_DISPATCH_RULE) 

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

801 deb822_file = lint_state.parsed_deb822_file_content 

802 

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

804 return 

805 

806 first_error = await scan_for_syntax_errors_and_token_level_diagnostics( 

807 deb822_file, 

808 lint_state, 

809 ) 

810 

811 stanzas = list(deb822_file) 

812 source_stanza = stanzas[0] if stanzas else None 

813 binary_stanzas_w_pos = [] 

814 

815 source_stanza_metadata, binary_stanza_metadata = _DCTRL_FILE_METADATA.stanza_types() 

816 stanza_no = 0 

817 

818 async for stanza_range, stanza in lint_state.slow_iter( 

819 with_range_in_continuous_parts(deb822_file.iter_parts()) 

820 ): 

821 if not isinstance(stanza, Deb822ParagraphElement): 

822 continue 

823 stanza_position = stanza_range.start_pos 

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

825 break 

826 stanza_no += 1 

827 is_binary_stanza = stanza_no != 1 

828 if is_binary_stanza: 

829 stanza_metadata = binary_stanza_metadata 

830 other_stanza_metadata = source_stanza_metadata 

831 other_stanza_name = "Source" 

832 binary_stanzas_w_pos.append((stanza, stanza_position)) 

833 _, representation_field_range = stanza_metadata.stanza_representation( 

834 stanza, stanza_position 

835 ) 

836 _binary_package_checks( 

837 stanza, 

838 stanza_position, 

839 source_stanza, 

840 representation_field_range, 

841 lint_state, 

842 ) 

843 else: 

844 stanza_metadata = source_stanza_metadata 

845 other_stanza_metadata = binary_stanza_metadata 

846 other_stanza_name = "Binary" 

847 _source_package_checks( 

848 stanza, 

849 stanza_position, 

850 stanza_metadata, 

851 lint_state, 

852 ) 

853 

854 await stanza_metadata.stanza_diagnostics( 

855 deb822_file, 

856 stanza, 

857 stanza_position, 

858 lint_state, 

859 confusable_with_stanza_metadata=other_stanza_metadata, 

860 confusable_with_stanza_name=other_stanza_name, 

861 inherit_from_stanza=source_stanza if is_binary_stanza else None, 

862 ) 

863 

864 _detect_misspelled_packaging_files( 

865 lint_state, 

866 binary_stanzas_w_pos, 

867 ) 

868 

869 

870def _package_range_of_stanza( 

871 binary_stanzas: List[Tuple[Deb822ParagraphElement, TEPosition]], 

872) -> Iterable[Tuple[str, Optional[str], "TERange"]]: 

873 for stanza, stanza_position in binary_stanzas: 

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

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

876 continue 

877 representation_field_range = kvpair.range_in_parent().relative_to( 

878 stanza_position 

879 ) 

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

881 

882 

883def _packaging_files( 

884 lint_state: LintState, 

885) -> Iterable[PackagerProvidedFile]: 

886 source_root = lint_state.source_root 

887 debian_dir = lint_state.debian_dir 

888 binary_packages = lint_state.binary_packages 

889 if ( 

890 source_root is None 

891 or not source_root.has_fs_path 

892 or debian_dir is None 

893 or binary_packages is None 

894 ): 

895 return 

896 

897 debputy_integration_mode = lint_state.debputy_metadata.debputy_integration_mode 

898 dh_sequencer_data = lint_state.dh_sequencer_data 

899 dh_sequences = dh_sequencer_data.sequences 

900 is_debputy_package = debputy_integration_mode is not None 

901 feature_set = lint_state.plugin_feature_set 

902 known_packaging_files = feature_set.known_packaging_files 

903 static_packaging_files = { 

904 kpf.detection_value: kpf 

905 for kpf in known_packaging_files.values() 

906 if kpf.detection_method == "path" 

907 } 

908 ignored_path = set(static_packaging_files) 

909 

910 if is_debputy_package: 

911 all_debputy_ppfs = list( 

912 flatten_ppfs( 

913 detect_all_packager_provided_files( 

914 feature_set, 

915 debian_dir, 

916 binary_packages, 

917 allow_fuzzy_matches=True, 

918 detect_typos=True, 

919 ignore_paths=ignored_path, 

920 ) 

921 ) 

922 ) 

923 for ppf in all_debputy_ppfs: 

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

925 continue 

926 ignored_path.add(ppf.path.path) 

927 yield ppf 

928 

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

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

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

932 debputy_plugin_metadata = plugin_metadata_for_debputys_own_plugin() 

933 ( 

934 all_dh_ppfs, 

935 _, 

936 _, 

937 _, 

938 ) = resolve_debhelper_config_files( 

939 debian_dir, 

940 binary_packages, 

941 debputy_plugin_metadata, 

942 feature_set, 

943 dh_sequences, 

944 dh_compat_level, 

945 saw_dh=dh_sequencer_data.uses_dh_sequencer, 

946 ignore_paths=ignored_path, 

947 debputy_integration_mode=debputy_integration_mode, 

948 cwd=source_root.fs_path, 

949 ) 

950 for ppf in all_dh_ppfs: 

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

952 continue 

953 ignored_path.add(ppf.path.path) 

954 yield ppf 

955 

956 

957def _detect_misspelled_packaging_files( 

958 lint_state: LintState, 

959 binary_stanzas_w_pos: List[Tuple[Deb822ParagraphElement, TEPosition]], 

960) -> None: 

961 stanza_ranges = { 

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

963 } 

964 for ppf in _packaging_files(lint_state): 

965 binary_package = ppf.package_name 

966 explicit_package = ppf.uses_explicit_package_name 

967 name_segment = ppf.name_segment is not None 

968 stem = ppf.definition.stem 

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

970 _trace_log( 

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

972 ) 

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

974 continue 

975 res = stanza_ranges.get(binary_package) 

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

977 continue 

978 declared_arch, diag_range = res 

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

980 continue 

981 path = ppf.path.path 

982 likely_typo_of = ppf.expected_path 

983 arch_restriction = ppf.architecture_restriction 

984 if likely_typo_of is not None: 

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

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

987 lint_state.emit_diagnostic( 

988 diag_range, 

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

990 "warning", 

991 "debputy", 

992 diagnostic_applies_to_another_file=path, 

993 ) 

994 continue 

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

996 lint_state.emit_diagnostic( 

997 diag_range, 

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

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

1000 "warning", 

1001 "debputy", 

1002 diagnostic_applies_to_another_file=path, 

1003 ) 

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

1005 lint_state.emit_diagnostic( 

1006 diag_range, 

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

1008 "warning", 

1009 "debputy", 

1010 diagnostic_applies_to_another_file=path, 

1011 ) 

1012 

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

1014 lint_state.emit_diagnostic( 

1015 diag_range, 

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

1017 " with the current addons", 

1018 "warning", 

1019 "debputy", 

1020 diagnostic_applies_to_another_file=path, 

1021 ) 

1022 continue 

1023 

1024 if not explicit_package and name_segment is not None: 

1025 basename = os.path.basename(path) 

1026 if basename == ppf.definition.stem: 

1027 continue 

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

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

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

1031 if ppf.definition.allow_name_segment: 

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

1033 else: 

1034 or_alt_name = "" 

1035 

1036 lint_state.emit_diagnostic( 

1037 diag_range, 

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

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

1040 "warning", 

1041 "debputy", 

1042 diagnostic_applies_to_another_file=path, 

1043 ) 

1044 

1045 

1046@lsp_will_save_wait_until(_DISPATCH_RULE) 

1047def _debian_control_on_save_formatting( 

1048 ls: "DebputyLanguageServer", 

1049 params: WillSaveTextDocumentParams, 

1050) -> Optional[Sequence[TextEdit]]: 

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

1052 lint_state = ls.lint_state(doc) 

1053 return _reformat_debian_control(lint_state) 

1054 

1055 

1056@lsp_cli_reformat_document(_DISPATCH_RULE) 

1057def _reformat_debian_control( 

1058 lint_state: LintState, 

1059) -> Optional[Sequence[TextEdit]]: 

1060 return deb822_format_file(lint_state, _DCTRL_FILE_METADATA) 

1061 

1062 

1063@lsp_format_document(_DISPATCH_RULE) 

1064def _debian_control_format_file( 

1065 ls: "DebputyLanguageServer", 

1066 params: DocumentFormattingParams, 

1067) -> Optional[Sequence[TextEdit]]: 

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

1069 lint_state = ls.lint_state(doc) 

1070 return _reformat_debian_control(lint_state) 

1071 

1072 

1073@lsp_semantic_tokens_full(_DISPATCH_RULE) 

1074async def _debian_control_semantic_tokens_full( 

1075 ls: "DebputyLanguageServer", 

1076 request: SemanticTokensParams, 

1077) -> Optional[SemanticTokens]: 

1078 return await deb822_semantic_tokens_full( 

1079 ls, 

1080 request, 

1081 _DCTRL_FILE_METADATA, 

1082 )