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

249 statements  

« prev     ^ index     » next       coverage.py v7.6.0, created at 2025-01-27 13:59 +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) 

102def _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 DiagnosticRelatedInformation( 

207 location=Location( 

208 lint_state.doc_uri, 

209 key_b_range, 

210 ), 

211 message=f'The attribute "{key_b}" is used here.', 

212 ) 

213 ], 

214 ) 

215 

216 lint_state.emit_diagnostic( 

217 key_b_range, 

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

219 "error", 

220 "debputy", 

221 related_information=[ 

222 DiagnosticRelatedInformation( 

223 location=Location( 

224 lint_state.doc_uri, 

225 key_a_range, 

226 ), 

227 message=f'The attribute "{key_a}" is used here.', 

228 ) 

229 ], 

230 ) 

231 

232 

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

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

235 pos_end = len(raw_line) 

236 return TERange( 

237 TEPosition( 

238 line_no, 

239 pos_start, 

240 ), 

241 TEPosition( 

242 line_no, 

243 pos_end, 

244 ), 

245 ) 

246 

247 

248def _lint_attr_value( 

249 lint_state: LintState, 

250 attr: AttributeDescription, 

251 pg: ParserGenerator, 

252 debputy_integration_mode: Optional[DebputyIntegrationMode], 

253 key: str, 

254 value: Any, 

255 pos: Tuple[int, int], 

256) -> None: 

257 target_attr_type = attr.attribute_type 

258 type_mapping = pg.get_mapped_type_from_target_type(target_attr_type) 

259 source_attr_type = target_attr_type 

260 if type_mapping is not None: 

261 source_attr_type = type_mapping.source_type 

262 orig = get_origin(source_attr_type) 

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

264 if orig == Literal: 

265 valid_values = get_args(attr.attribute_type) 

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

267 valid_values = (True, False) 

268 elif isinstance(target_attr_type, type): 

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

270 has_libcap, _, is_valid_cap = load_libcap() 

271 if has_libcap and not is_valid_cap(value): 

272 line_no, cursor_pos = pos 

273 cap_range = _remaining_line(lint_state, line_no, cursor_pos) 

274 lint_state.emit_diagnostic( 

275 cap_range, 

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

277 "warning", 

278 "debputy", 

279 ) 

280 return 

281 if issubclass(target_attr_type, DebputyDispatchableType): 

282 parser = pg.dispatch_parser_table_for(target_attr_type) 

283 _lint_content( 

284 lint_state, 

285 pg, 

286 parser, 

287 debputy_integration_mode, 

288 value, 

289 ) 

290 return 

291 

292 if valid_values is None or value in valid_values: 

293 return 

294 line_no, cursor_pos = pos 

295 value_range = _remaining_line(lint_state, line_no, cursor_pos) 

296 lint_state.emit_diagnostic( 

297 value_range, 

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

299 "error", 

300 "debputy", 

301 quickfixes=[ 

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

303 ], 

304 ) 

305 

306 

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

308 if isinstance(v, bool): 

309 return str(v).lower() 

310 return str(v) 

311 

312 

313def _lint_declarative_mapping_input_parser( 

314 lint_state: LintState, 

315 pg: ParserGenerator, 

316 parser: DeclarativeMappingInputParser, 

317 debputy_integration_mode: Optional[DebputyIntegrationMode], 

318 content: Any, 

319) -> None: 

320 if not isinstance(content, CommentedMap): 

321 return 

322 lc = content.lc 

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

324 attr = parser.manifest_attributes.get(key) 

325 line, col = lc.key(key) 

326 if attr is None: 

327 corrected_key = yaml_flag_unknown_key( 

328 lint_state, 

329 key, 

330 parser.manifest_attributes, 

331 line, 

332 col, 

333 ) 

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

335 key = corrected_key 

336 attr = parser.manifest_attributes.get(corrected_key) 

337 if attr is None: 

338 continue 

339 

340 _lint_attr_value( 

341 lint_state, 

342 attr, 

343 pg, 

344 debputy_integration_mode, 

345 key, 

346 value, 

347 lc.value(key), 

348 ) 

349 

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

351 if forbidden_key in content: 

352 con_line, con_col = lc.key(forbidden_key) 

353 _conflicting_key( 

354 lint_state, 

355 key, 

356 forbidden_key, 

357 line, 

358 col, 

359 con_line, 

360 con_col, 

361 ) 

362 for mx in parser.mutually_exclusive_attributes: 

363 matches = content.keys() & mx 

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

365 continue 

366 key, *others = list(matches) 

367 line, col = lc.key(key) 

368 for other in others: 

369 con_line, con_col = lc.key(other) 

370 _conflicting_key( 

371 lint_state, 

372 key, 

373 other, 

374 line, 

375 col, 

376 con_line, 

377 con_col, 

378 ) 

379 

380 

381def _lint_content( 

382 lint_state: LintState, 

383 pg: ParserGenerator, 

384 parser: DeclarativeInputParser[Any], 

385 debputy_integration_mode: Optional[DebputyIntegrationMode], 

386 content: Any, 

387) -> None: 

388 if isinstance(parser, DispatchingParserBase): 

389 if not isinstance(content, CommentedMap): 

390 return 

391 lc = content.lc 

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

393 is_known = parser.is_known_keyword(key) 

394 line, col = lc.key(key) 

395 orig_key = key 

396 if not is_known: 

397 corrected_key = yaml_flag_unknown_key( 

398 lint_state, 

399 key, 

400 parser.registered_keywords(), 

401 line, 

402 col, 

403 unknown_keys_diagnostic_severity=parser.unknown_keys_diagnostic_severity, 

404 ) 

405 if corrected_key is not None: 

406 key = corrected_key 

407 is_known = True 

408 

409 if is_known: 

410 subparser = parser.parser_for(key) 

411 assert subparser is not None 

412 _integration_mode_allows_key( 

413 lint_state, 

414 debputy_integration_mode, 

415 subparser.parser.expected_debputy_integration_mode, 

416 orig_key, 

417 line, 

418 col, 

419 ) 

420 _lint_content( 

421 lint_state, 

422 pg, 

423 subparser.parser, 

424 debputy_integration_mode, 

425 value, 

426 ) 

427 elif isinstance(parser, ListWrappedDeclarativeInputParser): 

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

429 return 

430 subparser = parser.delegate 

431 for value in content: 

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

433 elif isinstance(parser, InPackageContextParser): 

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

435 return 

436 known_packages = lint_state.binary_packages 

437 lc = content.lc 

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

439 if k is None or ( 

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

441 ): 

442 line, col = lc.key(k) 

443 yaml_flag_unknown_key( 

444 lint_state, 

445 k, 

446 known_packages, 

447 line, 

448 col, 

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

450 ) 

451 _lint_content( 

452 lint_state, 

453 pg, 

454 parser.delegate, 

455 debputy_integration_mode, 

456 v, 

457 ) 

458 elif isinstance(parser, DeclarativeMappingInputParser): 

459 _lint_declarative_mapping_input_parser( 

460 lint_state, 

461 pg, 

462 parser, 

463 debputy_integration_mode, 

464 content, 

465 ) 

466 

467 

468def keywords_with_parser( 

469 parser: Union[DeclarativeMappingInputParser, DispatchingParserBase], 

470) -> Tuple[str, PluginProvidedParser]: 

471 for keyword in parser.registered_keywords(): 

472 pp_subparser = parser.parser_for(keyword) 

473 yield keyword, pp_subparser 

474 

475 

476def completion_item( 

477 quoted_keyword: str, 

478 pp_subparser: PluginProvidedParser, 

479) -> CompletionItem: 

480 inline_reference_documentation = pp_subparser.parser.inline_reference_documentation 

481 synopsis = ( 

482 inline_reference_documentation.synopsis 

483 if inline_reference_documentation 

484 else None 

485 ) 

486 return CompletionItem( 

487 quoted_keyword, 

488 detail=synopsis, 

489 ) 

490 

491 

492@lsp_completer(_DISPATCH_RULE) 

493def debputy_manifest_completer( 

494 ls: "DebputyLanguageServer", 

495 params: CompletionParams, 

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

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

498 lines = doc.lines 

499 server_position = doc.position_codec.position_from_client_units( 

500 lines, params.position 

501 ) 

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

503 has_colon = ":" in orig_line 

504 added_key = insert_complete_marker_snippet(lines, server_position) 

505 attempts = 1 if added_key else 2 

506 content = None 

507 

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

509 attempts -= 1 

510 try: 

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

512 break 

513 except MarkedYAMLError as e: 

514 context_line = ( 

515 e.context_mark.line if e.context_mark else e.problem_mark.line 

516 ) 

517 if ( 

518 e.problem_mark.line != server_position.line 

519 and context_line != server_position.line 

520 ): 

521 l_data = ( 

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

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

524 else "N/A (OOB)" 

525 ) 

526 

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

528 return None 

529 

530 if attempts > 0: 

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

532 new_line = ( 

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

534 ) 

535 lines[server_position.line] = new_line 

536 except YAMLError: 

537 break 

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

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

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

541 return None 

542 attribute_root_path = AttributePath.root_path(content) 

543 m = _trace_cursor(content, attribute_root_path, server_position) 

544 

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

546 _info("No match") 

547 return None 

548 matched_key, attr_path, matched, parent = m 

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

550 feature_set = ls.plugin_feature_set 

551 root_parser = feature_set.manifest_parser_generator.dispatchable_object_parsers[ 

552 OPARSER_MANIFEST_ROOT 

553 ] 

554 segments = list(attr_path.path_segments()) 

555 km = resolve_keyword( 

556 root_parser, 

557 DEBPUTY_PLUGIN_METADATA, 

558 segments, 

559 0, 

560 feature_set.manifest_parser_generator, 

561 is_completion_attempt=True, 

562 ) 

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

564 return None 

565 parser, _, at_depth_idx = km 

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

567 items = [] 

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

569 if isinstance(parser, DispatchingParserBase): 

570 if matched_key: 

571 items = [ 

572 completion_item( 

573 ( 

574 maybe_quote_yaml_value(k) 

575 if has_colon 

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

577 ), 

578 pp_subparser, 

579 ) 

580 for k, pp_subparser in keywords_with_parser(parser) 

581 if k not in parent 

582 and not isinstance( 

583 pp_subparser.parser, 

584 DeclarativeValuelessKeywordInputParser, 

585 ) 

586 ] 

587 else: 

588 items = [ 

589 completion_item(maybe_quote_yaml_value(k), pp_subparser) 

590 for k, pp_subparser in keywords_with_parser(parser) 

591 if k not in parent 

592 and isinstance( 

593 pp_subparser.parser, 

594 DeclarativeValuelessKeywordInputParser, 

595 ) 

596 ] 

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

598 binary_packages = ls.lint_state(doc).binary_packages 

599 if binary_packages is not None: 

600 items = [ 

601 CompletionItem( 

602 maybe_quote_yaml_value(p) 

603 if has_colon 

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

605 ) 

606 for p in binary_packages 

607 if p not in parent 

608 ] 

609 elif isinstance(parser, DeclarativeMappingInputParser): 

610 if matched_key: 

611 _info("Match attributes") 

612 locked = set(parent) 

613 for mx in parser.mutually_exclusive_attributes: 

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

615 locked.update(mx) 

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

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

618 locked.add(attr_name) 

619 break 

620 items = [ 

621 CompletionItem( 

622 maybe_quote_yaml_value(k) 

623 if has_colon 

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

625 ) 

626 for k in parser.manifest_attributes 

627 if k not in locked 

628 ] 

629 else: 

630 # Value 

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

632 attr = parser.manifest_attributes.get(key) 

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

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

635 items = completion_from_attr( 

636 attr, 

637 feature_set.manifest_parser_generator, 

638 matched, 

639 ) 

640 else: 

641 _info( 

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

643 ) 

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

645 attr = parser.alt_form_parser 

646 items = completion_from_attr( 

647 attr, 

648 feature_set.manifest_parser_generator, 

649 matched, 

650 ) 

651 return items 

652 

653 

654@lsp_hover(_DISPATCH_RULE) 

655def debputy_manifest_hover( 

656 ls: "DebputyLanguageServer", 

657 params: HoverParams, 

658) -> Optional[Hover]: 

659 return generic_yaml_hover( 

660 ls, 

661 params, 

662 lambda pg: pg.dispatchable_object_parsers[OPARSER_MANIFEST_ROOT], 

663 )