Coverage for src/debputy/lsp/lsp_debian_debputy_manifest.py: 80%

249 statements  

« prev     ^ index     » next       coverage.py v7.6.0, created at 2025-03-24 16:38 +0000

1from typing import ( 

2 Optional, 

3 Any, 

4 Tuple, 

5 Union, 

6 Sequence, 

7 Literal, 

8 get_args, 

9 get_origin, 

10 Container, 

11) 

12 

13from debputy.highlevel_manifest import MANIFEST_YAML 

14from debputy.linting.lint_util import LintState 

15from debputy.lsp.lsp_features import ( 

16 lint_diagnostics, 

17 lsp_standard_handler, 

18 lsp_hover, 

19 lsp_completer, 

20 SecondaryLanguage, 

21 LanguageDispatchRule, 

22) 

23from debputy.lsp.lsp_generic_yaml import ( 

24 error_range_at_position, 

25 YAML_COMPLETION_HINT_KEY, 

26 insert_complete_marker_snippet, 

27 yaml_key_range, 

28 yaml_flag_unknown_key, 

29 _trace_cursor, 

30 generic_yaml_hover, 

31 resolve_keyword, 

32 DEBPUTY_PLUGIN_METADATA, 

33 maybe_quote_yaml_value, 

34 completion_from_attr, 

35) 

36from debputy.lsp.quickfixes import propose_correct_text_quick_fix 

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

38 Position as TEPosition, 

39 Range as TERange, 

40) 

41from debputy.lsprotocol.types import ( 

42 TEXT_DOCUMENT_WILL_SAVE_WAIT_UNTIL, 

43 HoverParams, 

44 Hover, 

45 TEXT_DOCUMENT_CODE_ACTION, 

46 CompletionParams, 

47 CompletionList, 

48 CompletionItem, 

49 DiagnosticRelatedInformation, 

50 Location, 

51) 

52from debputy.manifest_parser.declarative_parser import ( 

53 AttributeDescription, 

54 ParserGenerator, 

55 DeclarativeNonMappingInputParser, 

56) 

57from debputy.manifest_parser.declarative_parser import DeclarativeMappingInputParser 

58from debputy.manifest_parser.tagging_types import DebputyDispatchableType 

59from debputy.manifest_parser.util import AttributePath 

60from debputy.plugin.api.impl_types import ( 

61 DeclarativeInputParser, 

62 DispatchingParserBase, 

63 ListWrappedDeclarativeInputParser, 

64 InPackageContextParser, 

65 DeclarativeValuelessKeywordInputParser, 

66 PluginProvidedParser, 

67) 

68from debputy.plugin.api.parser_tables import OPARSER_MANIFEST_ROOT 

69from debputy.plugin.api.spec import DebputyIntegrationMode 

70from debputy.plugin.debputy.private_api import Capability, load_libcap 

71from debputy.util import _info 

72from debputy.yaml.compat import ( 

73 CommentedMap, 

74 CommentedSeq, 

75 MarkedYAMLError, 

76 YAMLError, 

77) 

78 

79try: 

80 from pygls.server import LanguageServer 

81 from debputy.lsp.debputy_ls import DebputyLanguageServer 

82except ImportError: 

83 pass 

84 

85 

86_DISPATCH_RULE = LanguageDispatchRule.new_rule( 

87 "debian/debputy.manifest", 

88 "debian/debputy.manifest", 

89 [ 

90 SecondaryLanguage("debputy.manifest"), 

91 # LSP's official language ID for YAML files 

92 SecondaryLanguage("yaml", filename_based_lookup=True), 

93 ], 

94) 

95 

96 

97lsp_standard_handler(_DISPATCH_RULE, TEXT_DOCUMENT_CODE_ACTION) 

98lsp_standard_handler(_DISPATCH_RULE, TEXT_DOCUMENT_WILL_SAVE_WAIT_UNTIL) 

99 

100 

101@lint_diagnostics(_DISPATCH_RULE) 

102async def _lint_debian_debputy_manifest(lint_state: LintState) -> None: 

103 lines = lint_state.lines 

104 try: 

105 content = MANIFEST_YAML.load("".join(lines)) 

106 except MarkedYAMLError as e: 106 ↛ 124line 106 didn't jump to line 124

107 if e.context_mark: 

108 line = e.context_mark.line 

109 column = e.context_mark.column 

110 else: 

111 line = e.problem_mark.line 

112 column = e.problem_mark.column 

113 error_range = error_range_at_position( 

114 lines, 

115 line, 

116 column, 

117 ) 

118 lint_state.emit_diagnostic( 

119 error_range, 

120 f"YAML parse error: {e}", 

121 "error", 

122 "debputy", 

123 ) 

124 except YAMLError as e: 

125 error_range = TERange( 

126 TEPosition(0, 0), 

127 TEPosition(0, len(lines[0])), 

128 ) 

129 lint_state.emit_diagnostic( 

130 error_range, 

131 f"Unknown YAML parse error: {e} [{e!r}]", 

132 "error", 

133 "debputy", 

134 ) 

135 else: 

136 feature_set = lint_state.plugin_feature_set 

137 pg = feature_set.manifest_parser_generator 

138 root_parser = pg.dispatchable_object_parsers[OPARSER_MANIFEST_ROOT] 

139 debputy_integration_mode = lint_state.debputy_metadata.debputy_integration_mode 

140 

141 _lint_content( 

142 lint_state, 

143 pg, 

144 root_parser, 

145 debputy_integration_mode, 

146 content, 

147 ) 

148 

149 

150def _integration_mode_allows_key( 

151 lint_state: LintState, 

152 debputy_integration_mode: Optional[DebputyIntegrationMode], 

153 expected_debputy_integration_modes: Optional[Container[DebputyIntegrationMode]], 

154 key: str, 

155 line: int, 

156 col: int, 

157) -> None: 

158 if debputy_integration_mode is None or expected_debputy_integration_modes is None: 

159 return 

160 if debputy_integration_mode in expected_debputy_integration_modes: 

161 return 

162 key_range = yaml_key_range(key, line, col) 

163 lint_state.emit_diagnostic( 

164 key_range, 

165 f'Feature "{key}" not supported in integration mode {debputy_integration_mode}', 

166 "error", 

167 "debputy", 

168 ) 

169 

170 

171def _conflicting_key( 

172 lint_state: LintState, 

173 key_a: str, 

174 key_b: str, 

175 key_a_line: int, 

176 key_a_col: int, 

177 key_b_line: int, 

178 key_b_col: int, 

179) -> None: 

180 key_a_range = TERange( 

181 TEPosition( 

182 key_a_line, 

183 key_a_col, 

184 ), 

185 TEPosition( 

186 key_a_line, 

187 key_a_col + len(key_a), 

188 ), 

189 ) 

190 key_b_range = TERange( 

191 TEPosition( 

192 key_b_line, 

193 key_b_col, 

194 ), 

195 TEPosition( 

196 key_b_line, 

197 key_b_col + len(key_b), 

198 ), 

199 ) 

200 lint_state.emit_diagnostic( 

201 key_a_range, 

202 f'The "{key_a}" cannot be used with "{key_b}".', 

203 "error", 

204 "debputy", 

205 related_information=[ 

206 lint_state.related_diagnostic_information( 

207 key_b_range, f'The attribute "{key_b}" is used here.' 

208 ), 

209 ], 

210 ) 

211 

212 lint_state.emit_diagnostic( 

213 key_b_range, 

214 f'The "{key_b}" cannot be used with "{key_a}".', 

215 "error", 

216 "debputy", 

217 related_information=[ 

218 lint_state.related_diagnostic_information( 

219 key_a_range, 

220 f'The attribute "{key_a}" is used here.', 

221 ), 

222 ], 

223 ) 

224 

225 

226def _remaining_line(lint_state: LintState, line_no: int, pos_start: int) -> "TERange": 

227 raw_line = lint_state.lines[line_no].rstrip() 

228 pos_end = len(raw_line) 

229 return TERange( 

230 TEPosition( 

231 line_no, 

232 pos_start, 

233 ), 

234 TEPosition( 

235 line_no, 

236 pos_end, 

237 ), 

238 ) 

239 

240 

241def _lint_attr_value( 

242 lint_state: LintState, 

243 attr: AttributeDescription, 

244 pg: ParserGenerator, 

245 debputy_integration_mode: Optional[DebputyIntegrationMode], 

246 key: str, 

247 value: Any, 

248 pos: Tuple[int, int], 

249) -> None: 

250 target_attr_type = attr.attribute_type 

251 type_mapping = pg.get_mapped_type_from_target_type(target_attr_type) 

252 source_attr_type = target_attr_type 

253 if type_mapping is not None: 

254 source_attr_type = type_mapping.source_type 

255 orig = get_origin(source_attr_type) 

256 valid_values: Optional[Sequence[Any]] = None 

257 if orig == Literal: 

258 valid_values = get_args(attr.attribute_type) 

259 elif orig == bool or attr.attribute_type == bool: 

260 valid_values = (True, False) 

261 elif isinstance(target_attr_type, type): 

262 if issubclass(target_attr_type, Capability): 262 ↛ 263line 262 didn't jump to line 263 because the condition on line 262 was never true

263 has_libcap, _, is_valid_cap = load_libcap() 

264 if has_libcap and not is_valid_cap(value): 

265 line_no, cursor_pos = pos 

266 cap_range = _remaining_line(lint_state, line_no, cursor_pos) 

267 lint_state.emit_diagnostic( 

268 cap_range, 

269 "The value could not be parsed as a capability via cap_from_text on this system", 

270 "warning", 

271 "debputy", 

272 ) 

273 return 

274 if issubclass(target_attr_type, DebputyDispatchableType): 

275 parser = pg.dispatch_parser_table_for(target_attr_type) 

276 _lint_content( 

277 lint_state, 

278 pg, 

279 parser, 

280 debputy_integration_mode, 

281 value, 

282 ) 

283 return 

284 

285 if valid_values is None or value in valid_values: 

286 return 

287 line_no, cursor_pos = pos 

288 value_range = _remaining_line(lint_state, line_no, cursor_pos) 

289 lint_state.emit_diagnostic( 

290 value_range, 

291 f'Not a supported value for "{key}"', 

292 "error", 

293 "debputy", 

294 quickfixes=[ 

295 propose_correct_text_quick_fix(_as_yaml_value(m)) for m in valid_values 

296 ], 

297 ) 

298 

299 

300def _as_yaml_value(v: Any) -> str: 

301 if isinstance(v, bool): 

302 return str(v).lower() 

303 return str(v) 

304 

305 

306def _lint_declarative_mapping_input_parser( 

307 lint_state: LintState, 

308 pg: ParserGenerator, 

309 parser: DeclarativeMappingInputParser, 

310 debputy_integration_mode: Optional[DebputyIntegrationMode], 

311 content: Any, 

312) -> None: 

313 if not isinstance(content, CommentedMap): 

314 return 

315 lc = content.lc 

316 for key, value in content.items(): 

317 attr = parser.manifest_attributes.get(key) 

318 line, col = lc.key(key) 

319 if attr is None: 

320 corrected_key = yaml_flag_unknown_key( 

321 lint_state, 

322 key, 

323 parser.manifest_attributes, 

324 line, 

325 col, 

326 ) 

327 if corrected_key: 327 ↛ 328line 327 didn't jump to line 328 because the condition on line 327 was never true

328 key = corrected_key 

329 attr = parser.manifest_attributes.get(corrected_key) 

330 if attr is None: 

331 continue 

332 

333 _lint_attr_value( 

334 lint_state, 

335 attr, 

336 pg, 

337 debputy_integration_mode, 

338 key, 

339 value, 

340 lc.value(key), 

341 ) 

342 

343 for forbidden_key in attr.conflicting_attributes: 343 ↛ 344line 343 didn't jump to line 344 because the loop on line 343 never started

344 if forbidden_key in content: 

345 con_line, con_col = lc.key(forbidden_key) 

346 _conflicting_key( 

347 lint_state, 

348 key, 

349 forbidden_key, 

350 line, 

351 col, 

352 con_line, 

353 con_col, 

354 ) 

355 for mx in parser.mutually_exclusive_attributes: 

356 matches = content.keys() & mx 

357 if len(matches) < 2: 357 ↛ 359line 357 didn't jump to line 359 because the condition on line 357 was always true

358 continue 

359 key, *others = list(matches) 

360 line, col = lc.key(key) 

361 for other in others: 

362 con_line, con_col = lc.key(other) 

363 _conflicting_key( 

364 lint_state, 

365 key, 

366 other, 

367 line, 

368 col, 

369 con_line, 

370 con_col, 

371 ) 

372 

373 

374def _lint_content( 

375 lint_state: LintState, 

376 pg: ParserGenerator, 

377 parser: DeclarativeInputParser[Any], 

378 debputy_integration_mode: Optional[DebputyIntegrationMode], 

379 content: Any, 

380) -> None: 

381 if isinstance(parser, DispatchingParserBase): 

382 if not isinstance(content, CommentedMap): 

383 return 

384 lc = content.lc 

385 for key, value in content.items(): 

386 is_known = parser.is_known_keyword(key) 

387 line, col = lc.key(key) 

388 orig_key = key 

389 if not is_known: 

390 corrected_key = yaml_flag_unknown_key( 

391 lint_state, 

392 key, 

393 parser.registered_keywords(), 

394 line, 

395 col, 

396 unknown_keys_diagnostic_severity=parser.unknown_keys_diagnostic_severity, 

397 ) 

398 if corrected_key is not None: 

399 key = corrected_key 

400 is_known = True 

401 

402 if is_known: 

403 subparser = parser.parser_for(key) 

404 assert subparser is not None 

405 _integration_mode_allows_key( 

406 lint_state, 

407 debputy_integration_mode, 

408 subparser.parser.expected_debputy_integration_mode, 

409 orig_key, 

410 line, 

411 col, 

412 ) 

413 _lint_content( 

414 lint_state, 

415 pg, 

416 subparser.parser, 

417 debputy_integration_mode, 

418 value, 

419 ) 

420 elif isinstance(parser, ListWrappedDeclarativeInputParser): 

421 if not isinstance(content, CommentedSeq): 421 ↛ 422line 421 didn't jump to line 422 because the condition on line 421 was never true

422 return 

423 subparser = parser.delegate 

424 for value in content: 

425 _lint_content(lint_state, pg, subparser, debputy_integration_mode, value) 

426 elif isinstance(parser, InPackageContextParser): 

427 if not isinstance(content, CommentedMap): 427 ↛ 428line 427 didn't jump to line 428 because the condition on line 427 was never true

428 return 

429 known_packages = lint_state.binary_packages 

430 lc = content.lc 

431 for k, v in content.items(): 

432 if k is None or ( 

433 "{{" not in k and known_packages is not None and k not in known_packages 

434 ): 

435 line, col = lc.key(k) 

436 yaml_flag_unknown_key( 

437 lint_state, 

438 k, 

439 known_packages, 

440 line, 

441 col, 

442 message_format='Unknown package "{key}".', 

443 ) 

444 _lint_content( 

445 lint_state, 

446 pg, 

447 parser.delegate, 

448 debputy_integration_mode, 

449 v, 

450 ) 

451 elif isinstance(parser, DeclarativeMappingInputParser): 

452 _lint_declarative_mapping_input_parser( 

453 lint_state, 

454 pg, 

455 parser, 

456 debputy_integration_mode, 

457 content, 

458 ) 

459 

460 

461def keywords_with_parser( 

462 parser: Union[DeclarativeMappingInputParser, DispatchingParserBase], 

463) -> Tuple[str, PluginProvidedParser]: 

464 for keyword in parser.registered_keywords(): 

465 pp_subparser = parser.parser_for(keyword) 

466 yield keyword, pp_subparser 

467 

468 

469def completion_item( 

470 quoted_keyword: str, 

471 pp_subparser: PluginProvidedParser, 

472) -> CompletionItem: 

473 inline_reference_documentation = pp_subparser.parser.inline_reference_documentation 

474 synopsis = ( 

475 inline_reference_documentation.synopsis 

476 if inline_reference_documentation 

477 else None 

478 ) 

479 return CompletionItem( 

480 quoted_keyword, 

481 detail=synopsis, 

482 ) 

483 

484 

485@lsp_completer(_DISPATCH_RULE) 

486def debputy_manifest_completer( 

487 ls: "DebputyLanguageServer", 

488 params: CompletionParams, 

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

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

491 lines = doc.lines 

492 server_position = doc.position_codec.position_from_client_units( 

493 lines, params.position 

494 ) 

495 orig_line = lines[server_position.line].rstrip() 

496 has_colon = ":" in orig_line 

497 added_key = insert_complete_marker_snippet(lines, server_position) 

498 attempts = 1 if added_key else 2 

499 content = None 

500 

501 while attempts > 0: 501 ↛ 531line 501 didn't jump to line 531 because the condition on line 501 was always true

502 attempts -= 1 

503 try: 

504 content = MANIFEST_YAML.load("".join(lines)) 

505 break 

506 except MarkedYAMLError as e: 

507 context_line = ( 

508 e.context_mark.line if e.context_mark else e.problem_mark.line 

509 ) 

510 if ( 

511 e.problem_mark.line != server_position.line 

512 and context_line != server_position.line 

513 ): 

514 l_data = ( 

515 lines[e.problem_mark.line].rstrip() 

516 if e.problem_mark.line < len(lines) 

517 else "N/A (OOB)" 

518 ) 

519 

520 _info(f"Parse error on line: {e.problem_mark.line}: {l_data}") 

521 return None 

522 

523 if attempts > 0: 

524 # Try to make it a key and see if that fixes the problem 

525 new_line = ( 

526 lines[server_position.line].rstrip() + YAML_COMPLETION_HINT_KEY 

527 ) 

528 lines[server_position.line] = new_line 

529 except YAMLError: 

530 break 

531 if content is None: 531 ↛ 532line 531 didn't jump to line 532 because the condition on line 531 was never true

532 context = lines[server_position.line].replace("\n", "\\n") 

533 _info(f"Completion failed: parse error: Line in question: {context}") 

534 return None 

535 attribute_root_path = AttributePath.root_path(content) 

536 m = _trace_cursor(content, attribute_root_path, server_position) 

537 

538 if m is None: 538 ↛ 539line 538 didn't jump to line 539 because the condition on line 538 was never true

539 _info("No match") 

540 return None 

541 matched_key, attr_path, matched, parent = m 

542 _info(f"Matched path: {matched} (path: {attr_path.path}) [{matched_key=}]") 

543 feature_set = ls.plugin_feature_set 

544 root_parser = feature_set.manifest_parser_generator.dispatchable_object_parsers[ 

545 OPARSER_MANIFEST_ROOT 

546 ] 

547 segments = list(attr_path.path_segments()) 

548 km = resolve_keyword( 

549 root_parser, 

550 DEBPUTY_PLUGIN_METADATA, 

551 segments, 

552 0, 

553 feature_set.manifest_parser_generator, 

554 is_completion_attempt=True, 

555 ) 

556 if km is None: 556 ↛ 557line 556 didn't jump to line 557 because the condition on line 556 was never true

557 return None 

558 parser, _, at_depth_idx = km 

559 _info(f"Match leaf parser {at_depth_idx} -- {parser.__class__}") 

560 items = [] 

561 if at_depth_idx + 1 >= len(segments): 561 ↛ 644line 561 didn't jump to line 644 because the condition on line 561 was always true

562 if isinstance(parser, DispatchingParserBase): 

563 if matched_key: 

564 items = [ 

565 completion_item( 

566 ( 

567 maybe_quote_yaml_value(k) 

568 if has_colon 

569 else f"{maybe_quote_yaml_value(k)}:" 

570 ), 

571 pp_subparser, 

572 ) 

573 for k, pp_subparser in keywords_with_parser(parser) 

574 if k not in parent 

575 and not isinstance( 

576 pp_subparser.parser, 

577 DeclarativeValuelessKeywordInputParser, 

578 ) 

579 ] 

580 else: 

581 items = [ 

582 completion_item(maybe_quote_yaml_value(k), pp_subparser) 

583 for k, pp_subparser in keywords_with_parser(parser) 

584 if k not in parent 

585 and isinstance( 

586 pp_subparser.parser, 

587 DeclarativeValuelessKeywordInputParser, 

588 ) 

589 ] 

590 elif isinstance(parser, InPackageContextParser): 590 ↛ 591line 590 didn't jump to line 591 because the condition on line 590 was never true

591 binary_packages = ls.lint_state(doc).binary_packages 

592 if binary_packages is not None: 

593 items = [ 

594 CompletionItem( 

595 maybe_quote_yaml_value(p) 

596 if has_colon 

597 else f"{maybe_quote_yaml_value(p)}:" 

598 ) 

599 for p in binary_packages 

600 if p not in parent 

601 ] 

602 elif isinstance(parser, DeclarativeMappingInputParser): 

603 if matched_key: 

604 _info("Match attributes") 

605 locked = set(parent) 

606 for mx in parser.mutually_exclusive_attributes: 

607 if not mx.isdisjoint(parent.keys()): 

608 locked.update(mx) 

609 for attr_name, attr in parser.manifest_attributes.items(): 

610 if not attr.conflicting_attributes.isdisjoint(parent.keys()): 

611 locked.add(attr_name) 

612 break 

613 items = [ 

614 CompletionItem( 

615 maybe_quote_yaml_value(k) 

616 if has_colon 

617 else f"{maybe_quote_yaml_value(k)}:" 

618 ) 

619 for k in parser.manifest_attributes 

620 if k not in locked 

621 ] 

622 else: 

623 # Value 

624 key = segments[at_depth_idx] if len(segments) > at_depth_idx else None 

625 attr = parser.manifest_attributes.get(key) 

626 if attr is not None: 626 ↛ 634line 626 didn't jump to line 634 because the condition on line 626 was always true

627 _info(f"Expand value / key: {key} -- {attr.attribute_type}") 

628 items = completion_from_attr( 

629 attr, 

630 feature_set.manifest_parser_generator, 

631 matched, 

632 ) 

633 else: 

634 _info( 

635 f"Expand value / key: {key} -- !! {list(parser.manifest_attributes)}" 

636 ) 

637 elif isinstance(parser, DeclarativeNonMappingInputParser): 637 ↛ 644line 637 didn't jump to line 644 because the condition on line 637 was always true

638 attr = parser.alt_form_parser 

639 items = completion_from_attr( 

640 attr, 

641 feature_set.manifest_parser_generator, 

642 matched, 

643 ) 

644 return items 

645 

646 

647@lsp_hover(_DISPATCH_RULE) 

648def debputy_manifest_hover( 

649 ls: "DebputyLanguageServer", 

650 params: HoverParams, 

651) -> Optional[Hover]: 

652 return generic_yaml_hover( 

653 ls, 

654 params, 

655 lambda pg: pg.dispatchable_object_parsers[OPARSER_MANIFEST_ROOT], 

656 )