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

467 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2024-04-07 12:14 +0200

1from typing import ( 

2 Optional, 

3 List, 

4 Any, 

5 Tuple, 

6 Union, 

7 Iterable, 

8 Sequence, 

9 Literal, 

10 get_args, 

11 get_origin, 

12) 

13 

14from lsprotocol.types import ( 

15 Diagnostic, 

16 TEXT_DOCUMENT_WILL_SAVE_WAIT_UNTIL, 

17 Position, 

18 Range, 

19 DiagnosticSeverity, 

20 HoverParams, 

21 Hover, 

22 MarkupKind, 

23 MarkupContent, 

24 TEXT_DOCUMENT_CODE_ACTION, 

25 CompletionParams, 

26 CompletionList, 

27 CompletionItem, 

28 DiagnosticRelatedInformation, 

29 Location, 

30) 

31 

32from debputy.linting.lint_util import LintState 

33from debputy.lsp.quickfixes import propose_correct_text_quick_fix 

34from debputy.manifest_parser.base_types import DebputyDispatchableType 

35from debputy.plugin.api.feature_set import PluginProvidedFeatureSet 

36from debputy.yaml.compat import ( 

37 Node, 

38 CommentedMap, 

39 LineCol, 

40 CommentedSeq, 

41 CommentedBase, 

42 MarkedYAMLError, 

43 YAMLError, 

44) 

45 

46from debputy.highlevel_manifest import MANIFEST_YAML 

47from debputy.lsp.lsp_features import ( 

48 lint_diagnostics, 

49 lsp_standard_handler, 

50 lsp_hover, 

51 lsp_completer, 

52) 

53from debputy.lsp.text_util import ( 

54 LintCapablePositionCodec, 

55 detect_possible_typo, 

56) 

57from debputy.manifest_parser.declarative_parser import ( 

58 AttributeDescription, 

59 ParserGenerator, 

60 DeclarativeNonMappingInputParser, 

61) 

62from debputy.manifest_parser.declarative_parser import DeclarativeMappingInputParser 

63from debputy.manifest_parser.parser_doc import ( 

64 render_rule, 

65 render_attribute_doc, 

66 doc_args_for_parser_doc, 

67) 

68from debputy.manifest_parser.util import AttributePath 

69from debputy.plugin.api.impl import plugin_metadata_for_debputys_own_plugin 

70from debputy.plugin.api.impl_types import ( 

71 OPARSER_MANIFEST_ROOT, 

72 DeclarativeInputParser, 

73 DispatchingParserBase, 

74 DebputyPluginMetadata, 

75 ListWrappedDeclarativeInputParser, 

76 InPackageContextParser, 

77 DeclarativeValuelessKeywordInputParser, 

78) 

79from debputy.util import _info, _warn 

80 

81 

82try: 

83 from pygls.server import LanguageServer 

84 from debputy.lsp.debputy_ls import DebputyLanguageServer 

85except ImportError: 

86 pass 

87 

88 

89_LANGUAGE_IDS = [ 

90 "debian/debputy.manifest", 

91 "debputy.manifest", 

92 # LSP's official language ID for YAML files 

93 "yaml", 

94] 

95 

96 

97lsp_standard_handler(_LANGUAGE_IDS, TEXT_DOCUMENT_CODE_ACTION) 

98lsp_standard_handler(_LANGUAGE_IDS, TEXT_DOCUMENT_WILL_SAVE_WAIT_UNTIL) 

99 

100 

101def is_valid_file(path: str) -> bool: 

102 # For debian/debputy.manifest, the language ID is often set to makefile meaning we get random 

103 # "non-debian/debputy.manifest" YAML files here. Skip those. 

104 return path.endswith("debian/debputy.manifest") 

105 

106 

107def _word_range_at_position( 

108 lines: List[str], 

109 line_no: int, 

110 char_offset: int, 

111) -> Range: 

112 line = lines[line_no] 

113 line_len = len(line) 

114 start_idx = char_offset 

115 end_idx = char_offset 

116 while end_idx + 1 < line_len and not line[end_idx + 1].isspace(): 

117 end_idx += 1 

118 

119 while start_idx - 1 >= 0 and not line[start_idx - 1].isspace(): 

120 start_idx -= 1 

121 

122 return Range( 

123 Position(line_no, start_idx), 

124 Position(line_no, end_idx), 

125 ) 

126 

127 

128@lint_diagnostics(_LANGUAGE_IDS) 

129def _lint_debian_debputy_manifest( 

130 lint_state: LintState, 

131) -> Optional[List[Diagnostic]]: 

132 lines = lint_state.lines 

133 position_codec = lint_state.position_codec 

134 doc_reference = lint_state.doc_uri 

135 path = lint_state.path 

136 if not is_valid_file(path): 136 ↛ 137line 136 didn't jump to line 137, because the condition on line 136 was never true

137 return None 

138 diagnostics = [] 

139 try: 

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

141 except MarkedYAMLError as e: 

142 if e.context_mark: 

143 line = e.context_mark.line 

144 column = e.context_mark.column + 1 

145 else: 

146 line = e.problem_mark.line 

147 column = e.problem_mark.column + 1 

148 error_range = position_codec.range_to_client_units( 

149 lines, 

150 _word_range_at_position( 

151 lines, 

152 line, 

153 column, 

154 ), 

155 ) 

156 diagnostics.append( 

157 Diagnostic( 

158 error_range, 

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

160 DiagnosticSeverity.Error, 

161 ), 

162 ) 

163 except YAMLError as e: 

164 error_range = position_codec.range_to_client_units( 

165 lines, 

166 Range( 

167 Position(0, 0), 

168 Position(0, len(lines[0])), 

169 ), 

170 ) 

171 diagnostics.append( 

172 Diagnostic( 

173 error_range, 

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

175 DiagnosticSeverity.Error, 

176 ), 

177 ) 

178 else: 

179 feature_set = lint_state.plugin_feature_set 

180 pg = feature_set.manifest_parser_generator 

181 root_parser = pg.dispatchable_object_parsers[OPARSER_MANIFEST_ROOT] 

182 diagnostics.extend( 

183 _lint_content( 

184 doc_reference, 

185 pg, 

186 root_parser, 

187 content, 

188 lines, 

189 position_codec, 

190 ) 

191 ) 

192 return diagnostics 

193 

194 

195def _unknown_key( 

196 key: str, 

197 expected_keys: Iterable[str], 

198 line: int, 

199 col: int, 

200 lines: List[str], 

201 position_codec: LintCapablePositionCodec, 

202) -> Tuple["Diagnostic", Optional[str]]: 

203 key_range = position_codec.range_to_client_units( 

204 lines, 

205 Range( 

206 Position( 

207 line, 

208 col, 

209 ), 

210 Position( 

211 line, 

212 col + len(key), 

213 ), 

214 ), 

215 ) 

216 

217 candidates = detect_possible_typo(key, expected_keys) 

218 extra = "" 

219 corrected_key = None 

220 if candidates: 

221 extra = f' It looks like a typo of "{candidates[0]}".' 

222 # TODO: We should be able to tell that `install-doc` and `install-docs` are the same. 

223 # That would enable this to work in more cases. 

224 corrected_key = candidates[0] if len(candidates) == 1 else None 

225 

226 diagnostic = Diagnostic( 

227 key_range, 

228 f'Unknown or unsupported key "{key}".{extra}', 

229 DiagnosticSeverity.Error, 

230 source="debputy", 

231 data=[propose_correct_text_quick_fix(n) for n in candidates], 

232 ) 

233 return diagnostic, corrected_key 

234 

235 

236def _conflicting_key( 

237 uri: str, 

238 key_a: str, 

239 key_b: str, 

240 key_a_line: int, 

241 key_a_col: int, 

242 key_b_line: int, 

243 key_b_col: int, 

244 lines: List[str], 

245 position_codec: LintCapablePositionCodec, 

246) -> Iterable["Diagnostic"]: 

247 key_a_range = position_codec.range_to_client_units( 

248 lines, 

249 Range( 

250 Position( 

251 key_a_line, 

252 key_a_col, 

253 ), 

254 Position( 

255 key_a_line, 

256 key_a_col + len(key_a), 

257 ), 

258 ), 

259 ) 

260 key_b_range = position_codec.range_to_client_units( 

261 lines, 

262 Range( 

263 Position( 

264 key_b_line, 

265 key_b_col, 

266 ), 

267 Position( 

268 key_b_line, 

269 key_b_col + len(key_b), 

270 ), 

271 ), 

272 ) 

273 yield Diagnostic( 

274 key_a_range, 

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

276 DiagnosticSeverity.Error, 

277 source="debputy", 

278 related_information=[ 

279 DiagnosticRelatedInformation( 

280 location=Location( 

281 uri, 

282 key_b_range, 

283 ), 

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

285 ) 

286 ], 

287 ) 

288 

289 yield Diagnostic( 

290 key_b_range, 

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

292 DiagnosticSeverity.Error, 

293 source="debputy", 

294 related_information=[ 

295 DiagnosticRelatedInformation( 

296 location=Location( 

297 uri, 

298 key_a_range, 

299 ), 

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

301 ) 

302 ], 

303 ) 

304 

305 

306def _lint_attr_value( 

307 uri: str, 

308 attr: AttributeDescription, 

309 pg: ParserGenerator, 

310 value: Any, 

311 lines: List[str], 

312 position_codec: LintCapablePositionCodec, 

313) -> Iterable["Diagnostic"]: 

314 attr_type = attr.attribute_type 

315 orig = get_origin(attr_type) 

316 valid_values: Sequence[Any] = tuple() 

317 if orig == Literal: 317 ↛ 318line 317 didn't jump to line 318, because the condition on line 317 was never true

318 valid_values = get_args(attr.attribute_type) 

319 elif orig == bool or attr.attribute_type == bool: 319 ↛ 320line 319 didn't jump to line 320, because the condition on line 319 was never true

320 valid_values = ("true", "false") 

321 elif isinstance(attr_type, type) and issubclass(attr_type, DebputyDispatchableType): 

322 parser = pg.dispatch_parser_table_for(attr_type) 

323 yield from _lint_content( 

324 uri, 

325 pg, 

326 parser, 

327 value, 

328 lines, 

329 position_codec, 

330 ) 

331 return 

332 

333 if value in valid_values: 333 ↛ 334line 333 didn't jump to line 334, because the condition on line 333 was never true

334 return 

335 # TODO: Emit diagnostic for broken values 

336 return 

337 

338 

339def _lint_declarative_mapping_input_parser( 

340 uri: str, 

341 pg: ParserGenerator, 

342 parser: DeclarativeMappingInputParser, 

343 content: Any, 

344 lines: List[str], 

345 position_codec: LintCapablePositionCodec, 

346) -> Iterable["Diagnostic"]: 

347 if not isinstance(content, CommentedMap): 

348 return 

349 lc = content.lc 

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

351 attr = parser.manifest_attributes.get(key) 

352 line, col = lc.key(key) 

353 if attr is None: 

354 diag, corrected_key = _unknown_key( 

355 key, 

356 parser.manifest_attributes, 

357 line, 

358 col, 

359 lines, 

360 position_codec, 

361 ) 

362 yield diag 

363 if corrected_key: 363 ↛ 364line 363 didn't jump to line 364, because the condition on line 363 was never true

364 key = corrected_key 

365 attr = parser.manifest_attributes.get(corrected_key) 

366 if attr is None: 

367 continue 

368 

369 yield from _lint_attr_value( 

370 uri, 

371 attr, 

372 pg, 

373 value, 

374 lines, 

375 position_codec, 

376 ) 

377 

378 for forbidden_key in attr.conflicting_attributes: 

379 if forbidden_key in content: 

380 con_line, con_col = lc.key(forbidden_key) 

381 yield from _conflicting_key( 

382 uri, 

383 key, 

384 forbidden_key, 

385 line, 

386 col, 

387 con_line, 

388 con_col, 

389 lines, 

390 position_codec, 

391 ) 

392 for mx in parser.mutually_exclusive_attributes: 

393 matches = content.keys() & mx 

394 if len(matches) < 2: 

395 continue 

396 key, *others = list(matches) 

397 line, col = lc.key(key) 

398 for other in others: 

399 con_line, con_col = lc.key(other) 

400 yield from _conflicting_key( 

401 uri, 

402 key, 

403 other, 

404 line, 

405 col, 

406 con_line, 

407 con_col, 

408 lines, 

409 position_codec, 

410 ) 

411 

412 

413def _lint_content( 

414 uri: str, 

415 pg: ParserGenerator, 

416 parser: DeclarativeInputParser[Any], 

417 content: Any, 

418 lines: List[str], 

419 position_codec: LintCapablePositionCodec, 

420) -> Iterable["Diagnostic"]: 

421 if isinstance(parser, DispatchingParserBase): 

422 if not isinstance(content, CommentedMap): 

423 return 

424 lc = content.lc 

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

426 is_known = parser.is_known_keyword(key) 

427 if not is_known: 

428 line, col = lc.key(key) 

429 diag, corrected_key = _unknown_key( 

430 key, 

431 parser.registered_keywords(), 

432 line, 

433 col, 

434 lines, 

435 position_codec, 

436 ) 

437 yield diag 

438 if corrected_key is not None: 

439 key = corrected_key 

440 is_known = True 

441 

442 if is_known: 

443 subparser = parser.parser_for(key) 

444 assert subparser is not None 

445 yield from _lint_content( 

446 uri, 

447 pg, 

448 subparser.parser, 

449 value, 

450 lines, 

451 position_codec, 

452 ) 

453 elif isinstance(parser, ListWrappedDeclarativeInputParser): 

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

455 return 

456 subparser = parser.delegate 

457 for value in content: 

458 yield from _lint_content(uri, pg, subparser, value, lines, position_codec) 

459 elif isinstance(parser, InPackageContextParser): 

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

461 return 

462 for v in content.values(): 

463 yield from _lint_content(uri, pg, parser.delegate, v, lines, position_codec) 

464 elif isinstance(parser, DeclarativeMappingInputParser): 

465 yield from _lint_declarative_mapping_input_parser( 

466 uri, 

467 pg, 

468 parser, 

469 content, 

470 lines, 

471 position_codec, 

472 ) 

473 

474 

475def is_at(position: Position, lc_pos: Tuple[int, int]) -> bool: 

476 return position.line == lc_pos[0] and position.character == lc_pos[1] 

477 

478 

479def is_before(position: Position, lc_pos: Tuple[int, int]) -> bool: 

480 line, column = lc_pos 

481 if position.line < line: 

482 return True 

483 if position.line == line and position.character < column: 

484 return True 

485 return False 

486 

487 

488def is_after(position: Position, lc_pos: Tuple[int, int]) -> bool: 

489 line, column = lc_pos 

490 if position.line > line: 

491 return True 

492 if position.line == line and position.character > column: 

493 return True 

494 return False 

495 

496 

497def _trace_cursor( 

498 content: Any, 

499 attribute_path: AttributePath, 

500 server_position: Position, 

501) -> Optional[Tuple[bool, AttributePath, Any, Any]]: 

502 matched_key: Optional[Union[str, int]] = None 

503 matched: Optional[Node] = None 

504 matched_was_key: bool = False 

505 

506 if isinstance(content, CommentedMap): 

507 dict_lc: LineCol = content.lc 

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

509 k_lc = dict_lc.key(k) 

510 if is_before(server_position, k_lc): 510 ↛ 511line 510 didn't jump to line 511, because the condition on line 510 was never true

511 break 

512 v_lc = dict_lc.value(k) 

513 if is_before(server_position, v_lc): 

514 # TODO: Handle ":" and "whitespace" 

515 matched = k 

516 matched_key = k 

517 matched_was_key = True 

518 break 

519 matched = v 

520 matched_key = k 

521 elif isinstance(content, CommentedSeq): 521 ↛ 530line 521 didn't jump to line 530, because the condition on line 521 was never false

522 list_lc: LineCol = content.lc 

523 for idx, value in enumerate(content): 

524 i_lc = list_lc.item(idx) 

525 if is_before(server_position, i_lc): 525 ↛ 526line 525 didn't jump to line 526, because the condition on line 525 was never true

526 break 

527 matched_key = idx 

528 matched = value 

529 

530 if matched is not None: 530 ↛ 536line 530 didn't jump to line 536, because the condition on line 530 was never false

531 assert matched_key is not None 

532 sub_path = attribute_path[matched_key] 

533 if not matched_was_key and isinstance(matched, CommentedBase): 

534 return _trace_cursor(matched, sub_path, server_position) 

535 return matched_was_key, sub_path, matched, content 

536 return None 

537 

538 

539_COMPLETION_HINT_KEY = "___COMPLETE:" 

540_COMPLETION_HINT_VALUE = "___COMPLETE" 

541 

542 

543def resolve_keyword( 

544 current_parser: Union[DeclarativeInputParser[Any], DispatchingParserBase], 

545 current_plugin: DebputyPluginMetadata, 

546 segments: List[Union[str, int]], 

547 segment_idx: int, 

548 parser_generator: ParserGenerator, 

549 *, 

550 is_completion_attempt: bool = False, 

551) -> Optional[ 

552 Tuple[ 

553 Union[DeclarativeInputParser[Any], DispatchingParserBase], 

554 DebputyPluginMetadata, 

555 int, 

556 ] 

557]: 

558 if segment_idx >= len(segments): 

559 return current_parser, current_plugin, segment_idx 

560 current_segment = segments[segment_idx] 

561 if isinstance(current_parser, ListWrappedDeclarativeInputParser): 

562 if isinstance(current_segment, int): 562 ↛ 569line 562 didn't jump to line 569, because the condition on line 562 was never false

563 current_parser = current_parser.delegate 

564 segment_idx += 1 

565 if segment_idx >= len(segments): 565 ↛ 566line 565 didn't jump to line 566, because the condition on line 565 was never true

566 return current_parser, current_plugin, segment_idx 

567 current_segment = segments[segment_idx] 

568 

569 if not isinstance(current_segment, str): 569 ↛ 570line 569 didn't jump to line 570, because the condition on line 569 was never true

570 return None 

571 

572 if is_completion_attempt and current_segment.endswith( 

573 (_COMPLETION_HINT_KEY, _COMPLETION_HINT_VALUE) 

574 ): 

575 return current_parser, current_plugin, segment_idx 

576 

577 if isinstance(current_parser, InPackageContextParser): 

578 return resolve_keyword( 

579 current_parser.delegate, 

580 current_plugin, 

581 segments, 

582 segment_idx + 1, 

583 parser_generator, 

584 is_completion_attempt=is_completion_attempt, 

585 ) 

586 elif isinstance(current_parser, DispatchingParserBase): 

587 if not current_parser.is_known_keyword(current_segment): 587 ↛ 588line 587 didn't jump to line 588, because the condition on line 587 was never true

588 if is_completion_attempt: 

589 return current_parser, current_plugin, segment_idx 

590 return None 

591 subparser = current_parser.parser_for(current_segment) 

592 segment_idx += 1 

593 if segment_idx < len(segments): 

594 return resolve_keyword( 

595 subparser.parser, 

596 subparser.plugin_metadata, 

597 segments, 

598 segment_idx, 

599 parser_generator, 

600 is_completion_attempt=is_completion_attempt, 

601 ) 

602 return subparser.parser, subparser.plugin_metadata, segment_idx 

603 elif isinstance(current_parser, DeclarativeMappingInputParser): 603 ↛ 625line 603 didn't jump to line 625, because the condition on line 603 was never false

604 attr = current_parser.manifest_attributes.get(current_segment) 

605 attr_type = attr.attribute_type if attr is not None else None 

606 if ( 

607 attr_type is not None 

608 and isinstance(attr_type, type) 

609 and issubclass(attr_type, DebputyDispatchableType) 

610 ): 

611 subparser = parser_generator.dispatch_parser_table_for(attr_type) 

612 if subparser is not None and ( 

613 is_completion_attempt or segment_idx + 1 < len(segments) 

614 ): 

615 return resolve_keyword( 

616 subparser, 

617 current_plugin, 

618 segments, 

619 segment_idx + 1, 

620 parser_generator, 

621 is_completion_attempt=is_completion_attempt, 

622 ) 

623 return current_parser, current_plugin, segment_idx 

624 else: 

625 _info(f"Unknown parser: {current_parser.__class__}") 

626 return None 

627 

628 

629def _render_param_doc( 

630 rule_name: str, 

631 declarative_parser: DeclarativeMappingInputParser, 

632 plugin_metadata: DebputyPluginMetadata, 

633 attribute: str, 

634) -> Optional[str]: 

635 attr = declarative_parser.source_attributes.get(attribute) 

636 if attr is None: 636 ↛ 637line 636 didn't jump to line 637, because the condition on line 636 was never true

637 return None 

638 

639 doc_args, parser_doc = doc_args_for_parser_doc( 

640 rule_name, 

641 declarative_parser, 

642 plugin_metadata, 

643 ) 

644 rendered_docs = render_attribute_doc( 

645 declarative_parser, 

646 declarative_parser.source_attributes, 

647 declarative_parser.input_time_required_parameters, 

648 declarative_parser.at_least_one_of, 

649 parser_doc, 

650 doc_args, 

651 is_interactive=True, 

652 rule_name=rule_name, 

653 ) 

654 

655 for attributes, rendered_doc in rendered_docs: 655 ↛ 664line 655 didn't jump to line 664, because the loop on line 655 didn't complete

656 if attribute in attributes: 

657 full_doc = [ 

658 f"# Attribute `{attribute}`", 

659 "", 

660 ] 

661 full_doc.extend(rendered_doc) 

662 

663 return "\n".join(full_doc) 

664 return None 

665 

666 

667DEBPUTY_PLUGIN_METADATA = plugin_metadata_for_debputys_own_plugin() 

668 

669 

670def _guess_rule_name(segments: List[Union[str, int]], idx: int) -> str: 

671 orig_idx = idx 

672 idx -= 1 

673 while idx >= 0: 673 ↛ 678line 673 didn't jump to line 678, because the condition on line 673 was never false

674 segment = segments[idx] 

675 if isinstance(segment, str): 

676 return segment 

677 idx -= 1 

678 _warn(f"Unable to derive rule name from {segments} [{orig_idx}]") 

679 return "<Bug: unknown rule name>" 

680 

681 

682def _escape(v: str) -> str: 

683 return '"' + v.replace("\n", "\\n") + '"' 

684 

685 

686def _insert_snippet(lines: List[str], server_position: Position) -> bool: 

687 _info(f"Complete at {server_position}") 

688 line_no = server_position.line 

689 line = lines[line_no] 

690 pos_rhs = line[server_position.character :] 

691 if pos_rhs and not pos_rhs.isspace(): 691 ↛ 692line 691 didn't jump to line 692, because the condition on line 691 was never true

692 _info(f"No insertion: {_escape(line[server_position.character:])}") 

693 return False 

694 lhs_ws = line[: server_position.character] 

695 lhs = lhs_ws.strip() 

696 if lhs.endswith(":"): 

697 _info("Insertion of value (key seen)") 

698 new_line = line[: server_position.character] + _COMPLETION_HINT_VALUE 

699 elif lhs.startswith("-"): 

700 _info("Insertion of key or value (list item)") 

701 # Respect the provided indentation 

702 snippet = _COMPLETION_HINT_KEY if ":" not in lhs else _COMPLETION_HINT_VALUE 

703 new_line = line[: server_position.character] + snippet 

704 elif not lhs or (lhs_ws and not lhs_ws[0].isspace()): 

705 _info(f"Insertion of key or value: {_escape(line[server_position.character:])}") 

706 # Respect the provided indentation 

707 snippet = _COMPLETION_HINT_KEY if ":" not in lhs else _COMPLETION_HINT_VALUE 

708 new_line = line[: server_position.character] + snippet 

709 elif lhs.isalpha() and ":" not in lhs: 

710 _info(f"Expanding value to a key: {_escape(line[server_position.character:])}") 

711 # Respect the provided indentation 

712 new_line = line[: server_position.character] + _COMPLETION_HINT_KEY 

713 else: 

714 c = line[server_position.character] 

715 _info(f"Not touching line: {_escape(line)} -- {_escape(c)}") 

716 return False 

717 _info(f'Evaluating complete on synthetic line: "{new_line}"') 

718 lines[line_no] = new_line 

719 return True 

720 

721 

722@lsp_completer(_LANGUAGE_IDS) 

723def debputy_manifest_completer( 

724 ls: "DebputyLanguageServer", 

725 params: CompletionParams, 

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

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

728 if not is_valid_file(doc.path): 728 ↛ 729line 728 didn't jump to line 729, because the condition on line 728 was never true

729 return None 

730 lines = doc.lines 

731 server_position = doc.position_codec.position_from_client_units( 

732 lines, params.position 

733 ) 

734 attribute_root_path = AttributePath.root_path() 

735 added_key = _insert_snippet(lines, server_position) 

736 attempts = 1 if added_key else 2 

737 content = None 

738 

739 while attempts > 0: 739 ↛ 767line 739 didn't jump to line 767, because the condition on line 739 was never false

740 attempts -= 1 

741 try: 

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

743 break 

744 except MarkedYAMLError as e: 

745 context_line = ( 

746 e.context_mark.line if e.context_mark else e.problem_mark.line 

747 ) 

748 if ( 

749 e.problem_mark.line != server_position.line 

750 and context_line != server_position.line 

751 ): 

752 l_data = ( 

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

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

755 else "N/A (OOB)" 

756 ) 

757 

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

759 return None 

760 

761 if attempts > 0: 

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

763 new_line = lines[server_position.line].rstrip() + _COMPLETION_HINT_KEY 

764 lines[server_position.line] = new_line 

765 except YAMLError: 

766 break 

767 if content is None: 767 ↛ 768line 767 didn't jump to line 768, because the condition on line 767 was never true

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

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

770 return None 

771 m = _trace_cursor(content, attribute_root_path, server_position) 

772 

773 if m is None: 773 ↛ 774line 773 didn't jump to line 774, because the condition on line 773 was never true

774 _info("No match") 

775 return None 

776 matched_key, attr_path, matched, parent = m 

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

778 feature_set = ls.plugin_feature_set 

779 root_parser = feature_set.manifest_parser_generator.dispatchable_object_parsers[ 

780 OPARSER_MANIFEST_ROOT 

781 ] 

782 segments = list(attr_path.path_segments()) 

783 km = resolve_keyword( 

784 root_parser, 

785 DEBPUTY_PLUGIN_METADATA, 

786 segments, 

787 0, 

788 feature_set.manifest_parser_generator, 

789 is_completion_attempt=True, 

790 ) 

791 if km is None: 791 ↛ 792line 791 didn't jump to line 792, because the condition on line 791 was never true

792 return None 

793 parser, _, at_depth_idx = km 

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

795 items = [] 

796 if at_depth_idx + 1 >= len(segments): 796 ↛ 859line 796 didn't jump to line 859, because the condition on line 796 was never false

797 if isinstance(parser, DispatchingParserBase): 

798 if matched_key: 

799 items = [ 

800 CompletionItem(f"{k}:") 

801 for k in parser.registered_keywords() 

802 if k not in parent 

803 and not isinstance( 

804 parser.parser_for(k).parser, 

805 DeclarativeValuelessKeywordInputParser, 

806 ) 

807 ] 

808 else: 

809 items = [ 

810 CompletionItem(k) 

811 for k in parser.registered_keywords() 

812 if k not in parent 

813 and isinstance( 

814 parser.parser_for(k).parser, 

815 DeclarativeValuelessKeywordInputParser, 

816 ) 

817 ] 

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

819 # doc = ls.workspace.get_text_document(params.text_document.uri) 

820 _info(f"TODO: Match package - {parent} -- {matched} -- {matched_key=}") 

821 elif isinstance(parser, DeclarativeMappingInputParser): 

822 if matched_key: 

823 _info("Match attributes") 

824 locked = set(parent) 

825 for mx in parser.mutually_exclusive_attributes: 

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

827 locked.update(mx) 

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

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

830 locked.add(attr_name) 

831 break 

832 items = [ 

833 CompletionItem(f"{k}:") 

834 for k in parser.manifest_attributes 

835 if k not in locked 

836 ] 

837 else: 

838 # Value 

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

840 attr = parser.manifest_attributes.get(key) 

841 if attr is not None: 841 ↛ 849line 841 didn't jump to line 849, because the condition on line 841 was never false

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

843 items = _completion_from_attr( 

844 attr, 

845 feature_set.manifest_parser_generator, 

846 matched, 

847 ) 

848 else: 

849 _info( 

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

851 ) 

852 elif isinstance(parser, DeclarativeNonMappingInputParser): 852 ↛ 859line 852 didn't jump to line 859, because the condition on line 852 was never false

853 attr = parser.alt_form_parser 

854 items = _completion_from_attr( 

855 attr, 

856 feature_set.manifest_parser_generator, 

857 matched, 

858 ) 

859 return items 

860 

861 

862def _completion_from_attr( 

863 attr: AttributeDescription, 

864 pg: ParserGenerator, 

865 matched: Any, 

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

867 orig = get_origin(attr.attribute_type) 

868 valid_values: Sequence[Any] = tuple() 

869 if orig == Literal: 

870 valid_values = get_args(attr.attribute_type) 

871 elif orig == bool or attr.attribute_type == bool: 871 ↛ 873line 871 didn't jump to line 873, because the condition on line 871 was never false

872 valid_values = ("true", "false") 

873 elif isinstance(orig, type) and issubclass(orig, DebputyDispatchableType): 

874 parser = pg.dispatch_parser_table_for(orig) 

875 _info(f"M: {parser}") 

876 

877 if matched in valid_values: 877 ↛ 878line 877 didn't jump to line 878, because the condition on line 877 was never true

878 _info(f"Already filled: {matched} is one of {valid_values}") 

879 return None 

880 if valid_values: 880 ↛ 882line 880 didn't jump to line 882, because the condition on line 880 was never false

881 return [CompletionItem(x) for x in valid_values] 

882 return None 

883 

884 

885@lsp_hover(_LANGUAGE_IDS) 

886def debputy_manifest_hover( 

887 ls: "DebputyLanguageServer", 

888 params: HoverParams, 

889) -> Optional[Hover]: 

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

891 if not is_valid_file(doc.path): 891 ↛ 892line 891 didn't jump to line 892, because the condition on line 891 was never true

892 return None 

893 lines = doc.lines 

894 position_codec = doc.position_codec 

895 attribute_root_path = AttributePath.root_path() 

896 server_position = position_codec.position_from_client_units(lines, params.position) 

897 

898 try: 

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

900 except YAMLError: 

901 return None 

902 m = _trace_cursor(content, attribute_root_path, server_position) 

903 if m is None: 903 ↛ 904line 903 didn't jump to line 904, because the condition on line 903 was never true

904 _info("No match") 

905 return None 

906 matched_key, attr_path, matched, _ = m 

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

908 

909 feature_set = ls.plugin_feature_set 

910 parser_generator = feature_set.manifest_parser_generator 

911 root_parser = parser_generator.dispatchable_object_parsers[OPARSER_MANIFEST_ROOT] 

912 segments = list(attr_path.path_segments()) 

913 km = resolve_keyword( 

914 root_parser, 

915 DEBPUTY_PLUGIN_METADATA, 

916 segments, 

917 0, 

918 parser_generator, 

919 ) 

920 if km is None: 920 ↛ 921line 920 didn't jump to line 921, because the condition on line 920 was never true

921 _info("No keyword match") 

922 return 

923 parser, plugin_metadata, at_depth_idx = km 

924 _info(f"Match leaf parser {at_depth_idx}/{len(segments)} -- {parser.__class__}") 

925 hover_doc_text = resolve_hover_text( 

926 feature_set, 

927 parser, 

928 plugin_metadata, 

929 segments, 

930 at_depth_idx, 

931 matched, 

932 matched_key, 

933 ) 

934 return _hover_doc(ls, hover_doc_text) 

935 

936 

937def resolve_hover_text_for_value( 

938 feature_set: PluginProvidedFeatureSet, 

939 parser: DeclarativeMappingInputParser, 

940 plugin_metadata: DebputyPluginMetadata, 

941 segment: Union[str, int], 

942 matched: Any, 

943) -> Optional[str]: 

944 

945 hover_doc_text: Optional[str] = None 

946 attr = parser.manifest_attributes.get(segment) 

947 attr_type = attr.attribute_type if attr is not None else None 

948 if attr_type is None: 948 ↛ 949line 948 didn't jump to line 949, because the condition on line 948 was never true

949 _info(f"Matched value for {segment} -- No attr or type") 

950 return None 

951 if isinstance(attr_type, type) and issubclass(attr_type, DebputyDispatchableType): 951 ↛ 969line 951 didn't jump to line 969, because the condition on line 951 was never false

952 parser_generator = feature_set.manifest_parser_generator 

953 parser = parser_generator.dispatch_parser_table_for(attr_type) 

954 if parser is None or not isinstance(matched, str): 954 ↛ 955line 954 didn't jump to line 955, because the condition on line 954 was never true

955 _info( 

956 f"Unknown parser for {segment} or matched is not a str -- {attr_type} {type(matched)=}" 

957 ) 

958 return None 

959 subparser = parser.parser_for(matched) 

960 if subparser is None: 960 ↛ 961line 960 didn't jump to line 961, because the condition on line 960 was never true

961 _info(f"Unknown parser for {matched} (subparser)") 

962 return None 

963 hover_doc_text = render_rule( 

964 matched, 

965 subparser.parser, 

966 plugin_metadata, 

967 ) 

968 else: 

969 _info(f"Unknown value: {matched} -- {segment}") 

970 return hover_doc_text 

971 

972 

973def resolve_hover_text( 

974 feature_set: PluginProvidedFeatureSet, 

975 parser: Optional[Union[DeclarativeInputParser[Any], DispatchingParserBase]], 

976 plugin_metadata: DebputyPluginMetadata, 

977 segments: List[Union[str, int]], 

978 at_depth_idx: int, 

979 matched: Any, 

980 matched_key: bool, 

981) -> Optional[str]: 

982 hover_doc_text: Optional[str] = None 

983 if at_depth_idx == len(segments): 

984 segment = segments[at_depth_idx - 1] 

985 _info(f"Matched {segment} at ==, {matched_key=} ") 

986 hover_doc_text = render_rule( 

987 segment, 

988 parser, 

989 plugin_metadata, 

990 is_root_rule=False, 

991 ) 

992 elif at_depth_idx + 1 == len(segments) and isinstance( 992 ↛ 1015line 992 didn't jump to line 1015, because the condition on line 992 was never false

993 parser, DeclarativeMappingInputParser 

994 ): 

995 segment = segments[at_depth_idx] 

996 _info(f"Matched {segment} at -1, {matched_key=} ") 

997 if isinstance(segment, str): 997 ↛ 1017line 997 didn't jump to line 1017, because the condition on line 997 was never false

998 if not matched_key: 

999 hover_doc_text = resolve_hover_text_for_value( 

1000 feature_set, 

1001 parser, 

1002 plugin_metadata, 

1003 segment, 

1004 matched, 

1005 ) 

1006 if matched_key or hover_doc_text is None: 

1007 rule_name = _guess_rule_name(segments, at_depth_idx) 

1008 hover_doc_text = _render_param_doc( 

1009 rule_name, 

1010 parser, 

1011 plugin_metadata, 

1012 segment, 

1013 ) 

1014 else: 

1015 _info(f"No doc: {at_depth_idx=} {len(segments)=}") 

1016 

1017 return hover_doc_text 

1018 

1019 

1020def _hover_doc(ls: "LanguageServer", hover_doc_text: Optional[str]) -> Optional[Hover]: 

1021 if hover_doc_text is None: 1021 ↛ 1022line 1021 didn't jump to line 1022, because the condition on line 1021 was never true

1022 return None 

1023 try: 

1024 supported_formats = ls.client_capabilities.text_document.hover.content_format 

1025 except AttributeError: 

1026 supported_formats = [] 

1027 markup_kind = MarkupKind.Markdown 

1028 if markup_kind not in supported_formats: 1028 ↛ 1030line 1028 didn't jump to line 1030, because the condition on line 1028 was never false

1029 markup_kind = MarkupKind.PlainText 

1030 return Hover( 

1031 contents=MarkupContent( 

1032 kind=markup_kind, 

1033 value=hover_doc_text, 

1034 ), 

1035 )