Coverage for src/debputy/lsp/lsp_generic_yaml.py: 76%

675 statements  

« prev     ^ index     » next       coverage.py v7.8.2, created at 2026-07-22 10:58 +0000

1import textwrap 

2from typing import ( 

3 Union, 

4 Any, 

5 Optional, 

6 List, 

7 Tuple, 

8 TYPE_CHECKING, 

9 get_origin, 

10 Literal, 

11 get_args, 

12 Generic, 

13 cast, 

14) 

15from collections.abc import Iterable, Callable, Sequence, Iterator 

16 

17from debputy.commands.debputy_cmd.output import OutputStyle 

18from debputy.linting.lint_util import LintState 

19from debputy.lsp.diagnostics import LintSeverity 

20from debputy.lsp.quickfixes import propose_correct_text_quick_fix 

21from debian._deb822_repro.locatable import ( 

22 Position as TEPosition, 

23 Range as TERange, 

24) 

25from debputy.manifest_parser.declarative_parser import ( 

26 DeclarativeMappingInputParser, 

27 ParserGenerator, 

28 AttributeDescription, 

29 DeclarativeNonMappingInputParser, 

30 BASIC_SIMPLE_TYPES, 

31) 

32from debputy.manifest_parser.parser_doc import ( 

33 render_rule, 

34 render_attribute_doc, 

35 doc_args_for_parser_doc, 

36) 

37from debputy.manifest_parser.tagging_types import DebputyDispatchableType 

38from debputy.manifest_parser.util import AttributePath 

39from debputy.plugin.api.feature_set import PluginProvidedFeatureSet 

40from debputy.plugin.api.impl import plugin_metadata_for_debputys_own_plugin 

41from debputy.plugin.api.impl_types import ( 

42 DebputyPluginMetadata, 

43 DeclarativeInputParser, 

44 DispatchingParserBase, 

45 InPackageContextParser, 

46 ListWrappedDeclarativeInputParser, 

47 PluginProvidedParser, 

48 DeclarativeValuelessKeywordInputParser, 

49 DispatchingTableParser, 

50 AllowNoneDeclarativeInputParser, 

51) 

52from debputy.substitution import VariableContext 

53from debputy.util import _info, _warn, detect_possible_typo, T 

54from debputy.yaml import MANIFEST_YAML 

55from debputy.yaml.compat import ( 

56 MarkedYAMLError, 

57 YAMLError, 

58) 

59from debputy.yaml.compat import ( 

60 Node, 

61 CommentedMap, 

62 LineCol, 

63 CommentedSeq, 

64 CommentedBase, 

65) 

66 

67if TYPE_CHECKING: 

68 import lsprotocol.types as types 

69else: 

70 import debputy.lsprotocol.types as types 

71 

72try: 

73 from pygls.server import LanguageServer 

74 from debputy.lsp.debputy_ls import DebputyLanguageServer 

75except ImportError: 

76 pass 

77 

78 

79YAML_COMPLETION_HINT_KEY = "___COMPLETE:" 

80YAML_COMPLETION_HINT_VALUE = "___COMPLETE" 

81DEBPUTY_PLUGIN_METADATA = plugin_metadata_for_debputys_own_plugin() 

82 

83 

84class LSPYAMLHelper(Generic[T]): 

85 

86 def __init__( 

87 self, 

88 lint_state: LintState, 

89 pg: ParserGenerator, 

90 custom_data: T, 

91 ) -> None: 

92 self.lint_state = lint_state 

93 self.lines = _lines(lint_state.lines) 

94 self.pg = pg 

95 self.custom_data = custom_data 

96 

97 def _validate_subparser_is_valid_here( 

98 self, 

99 subparser: PluginProvidedParser, 

100 orig_key: str, 

101 line: int, 

102 col: int, 

103 ) -> None: 

104 # Subclasses can provide custom logic here 

105 pass 

106 

107 def _lint_dispatch_parser( 

108 self, 

109 parser: DispatchingParserBase, 

110 dispatch_key: str, 

111 key_pos: tuple[int, int] | None, 

112 value: Any | None, 

113 value_pos: tuple[int, int] | None, 

114 *, 

115 is_keyword_only: bool, 

116 ) -> None: 

117 is_known = parser.is_known_keyword(dispatch_key) 

118 orig_key = dispatch_key 

119 if not is_known and key_pos is not None: 

120 

121 if value is None: 

122 opts = { 

123 "message_format": 'Unknown or unsupported value "{key}".', 

124 } 

125 else: 

126 opts = {} 

127 corrected_key = yaml_flag_unknown_key( 

128 self.lint_state, 

129 dispatch_key, 

130 parser.registered_keywords(), 

131 key_pos, 

132 unknown_keys_diagnostic_severity=parser.unknown_keys_diagnostic_severity, 

133 **opts, 

134 ) 

135 if corrected_key is not None: 

136 dispatch_key = corrected_key 

137 is_known = True 

138 

139 if is_known: 

140 subparser = parser.parser_for(dispatch_key) 

141 assert subparser is not None 

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

143 line, col = key_pos 

144 self._validate_subparser_is_valid_here( 

145 subparser, 

146 orig_key, 

147 line, 

148 col, 

149 ) 

150 if ( 150 ↛ 154line 150 didn't jump to line 154 because the condition on line 150 was never true

151 isinstance(subparser.parser, AllowNoneDeclarativeInputParser) 

152 and value is None 

153 ): 

154 return 

155 if isinstance(subparser.parser, DeclarativeValuelessKeywordInputParser): 

156 if value is not None or not is_keyword_only: 156 ↛ 157line 156 didn't jump to line 157 because the condition on line 156 was never true

157 if value is not None: 

158 line_no, cursor_pos = value_pos if value_pos else key_pos 

159 value_range = self._remaining_line(line_no, cursor_pos) 

160 if _is_empty_range(value_range): 

161 # In the unlikely case that the value position is present but leads to 

162 # an empty range, report the key instead. 

163 line_no, cursor_pos = key_pos 

164 value_range = self._remaining_line(line_no, cursor_pos) 

165 msg = f"The keyword {dispatch_key} does not accept any value" 

166 else: 

167 line_no, cursor_pos = key_pos 

168 value_range = self._remaining_line(line_no, cursor_pos) 

169 msg = f"The keyword {dispatch_key} cannot be used as a mapping key" 

170 

171 assert not _is_empty_range(value_range) 

172 self.lint_state.emit_diagnostic( 

173 value_range, 

174 msg, 

175 "error", 

176 "debputy", 

177 ) 

178 return 

179 

180 self.lint_content( 

181 # Pycharm's type checking gets confused by the isinstance check above. 

182 cast("DeclarativeInputParser[Any]", subparser.parser), 

183 value, 

184 key=orig_key, 

185 content_pos=value_pos, 

186 ) 

187 

188 def lint_content( 

189 self, 

190 parser: DeclarativeInputParser[Any], 

191 content: Any, 

192 *, 

193 key: str | int | None = None, 

194 content_pos: tuple[int, int] | None = None, 

195 ) -> None: 

196 if isinstance(parser, AllowNoneDeclarativeInputParser): 196 ↛ 197line 196 didn't jump to line 197 because the condition on line 196 was never true

197 parser = parser.delegate 

198 if isinstance(parser, DispatchingParserBase): 

199 if isinstance(content, str): 

200 self._lint_dispatch_parser( 

201 parser, 

202 content, 

203 content_pos, 

204 None, 

205 None, 

206 is_keyword_only=True, 

207 ) 

208 

209 return 

210 if not isinstance(content, CommentedMap): 

211 return 

212 lc = content.lc 

213 for dispatch_key, value in content.items(): 

214 key_pos = lc.key(dispatch_key) 

215 value_pos = lc.value(dispatch_key) 

216 self._lint_dispatch_parser( 

217 parser, 

218 dispatch_key, 

219 key_pos, 

220 value, 

221 value_pos, 

222 is_keyword_only=False, 

223 ) 

224 

225 elif isinstance(parser, ListWrappedDeclarativeInputParser): 

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

227 return 

228 subparser = parser.delegate 

229 lc = content.lc 

230 for idx, value in enumerate(content): 

231 value_pos = lc.item(idx) 

232 self.lint_content(subparser, value, content_pos=value_pos, key=idx) 

233 elif isinstance(parser, InPackageContextParser): 

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

235 return 

236 known_packages = self.lint_state.binary_packages 

237 lc = content.lc 

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

239 if k is None or ( 

240 "{{" not in k 

241 and known_packages is not None 

242 and k not in known_packages 

243 ): 

244 yaml_flag_unknown_key( 

245 self.lint_state, 

246 k, 

247 known_packages, 

248 lc.key(k), 

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

250 ) 

251 self.lint_content(parser.delegate, v, key=k, content_pos=lc.value(k)) 

252 elif isinstance(parser, DeclarativeMappingInputParser): 

253 self._lint_declarative_mapping_input_parser( 

254 parser, 

255 content, 

256 content_pos, 

257 key=key, 

258 ) 

259 elif isinstance(parser, DeclarativeNonMappingInputParser): 259 ↛ exitline 259 didn't return from function 'lint_content' because the condition on line 259 was always true

260 if content_pos is not None: 260 ↛ exitline 260 didn't return from function 'lint_content' because the condition on line 260 was always true

261 self._lint_attr_value( 

262 parser.alt_form_parser, 

263 key, 

264 content, 

265 content_pos, 

266 ) 

267 

268 def _lint_declarative_mapping_input_parser( 

269 self, 

270 parser: DeclarativeMappingInputParser, 

271 content: Any, 

272 content_pos: tuple[int, int], 

273 *, 

274 key: str | int | None = None, 

275 ) -> None: 

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

277 alt_form_parser = parser.alt_form_parser 

278 if alt_form_parser: 

279 self._lint_attr_value( 

280 alt_form_parser, 

281 key, 

282 content, 

283 content_pos, 

284 ) 

285 else: 

286 line_no, cursor_pos = content_pos 

287 value_range = self._remaining_line(line_no, cursor_pos) 

288 if _is_empty_range(value_range): 

289 # FIXME: We cannot report an empty range, but there is still a problem here. 

290 return 

291 if isinstance(key, str): 

292 msg = f'The value for "{key}" must be a mapping' 

293 else: 

294 msg = "The value must be a mapping" 

295 self.lint_state.emit_diagnostic( 

296 value_range, 

297 msg, 

298 "error", 

299 "debputy", 

300 ) 

301 return 

302 lc = content.lc 

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

304 attr = parser.manifest_attributes.get(key) 

305 key_pos = lc.key(key) 

306 value_pos = lc.value(key) 

307 if attr is None: 

308 corrected_key = yaml_flag_unknown_key( 

309 self.lint_state, 

310 key, 

311 parser.manifest_attributes, 

312 key_pos, 

313 ) 

314 if corrected_key: 

315 key = corrected_key 

316 attr = parser.manifest_attributes.get(corrected_key) 

317 if attr is None: 

318 continue 

319 

320 self._lint_attr_value( 

321 attr, 

322 key, 

323 value, 

324 value_pos, 

325 ) 

326 

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

328 if forbidden_key in content: 

329 line, col = key_pos 

330 con_line, con_col = lc.key(forbidden_key) 

331 yaml_conflicting_key( 

332 self.lint_state, 

333 key, 

334 forbidden_key, 

335 line, 

336 col, 

337 con_line, 

338 con_col, 

339 ) 

340 for mx in parser.mutually_exclusive_attributes: 

341 matches = content.keys() & mx 

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

343 continue 

344 key, *others = list(matches) 

345 line, col = lc.key(key) 

346 for other in others: 

347 con_line, con_col = lc.key(other) 

348 yaml_conflicting_key( 

349 self.lint_state, 

350 key, 

351 other, 

352 line, 

353 col, 

354 con_line, 

355 con_col, 

356 ) 

357 

358 def _type_based_value_check( 

359 self, 

360 target_attr_type: type, 

361 value: Any, 

362 value_pos: tuple[int, int], 

363 *, 

364 key: str | int | None = None, 

365 ) -> bool: 

366 if issubclass(target_attr_type, DebputyDispatchableType): 

367 parser = self.pg.dispatch_parser_table_for(target_attr_type) 

368 self.lint_content( 

369 parser, 

370 value, 

371 key=key, 

372 content_pos=value_pos, 

373 ) 

374 return True 

375 if ( 

376 (type_mapper := self.pg.get_mapped_type_from_target_type(target_attr_type)) 

377 and (lint_validator := type_mapper.lint_validator) 

378 # TODO: We should flag None, but that is for another day. The `None` is tricky, because 

379 # value_pos + remaining_line leads to an empty range. 

380 and value is not None 

381 and "{{" not in value 

382 ): 

383 try: 

384 lint_validator(value, AttributePath.highlighted_range()) 

385 except Exception as e: 

386 line_no, cursor_pos = value_pos 

387 value_range = self._remaining_line(line_no, cursor_pos) 

388 self.lint_state.emit_diagnostic( 

389 value_range, 

390 str(e), 

391 "error", 

392 "debputy", 

393 ) 

394 return True 

395 return False 

396 

397 def _lint_attr_value( 

398 self, 

399 attr: AttributeDescription, 

400 key: str | int | None, 

401 value: Any, 

402 pos: tuple[int, int], 

403 ) -> None: 

404 target_attr_type = attr.attribute_type 

405 orig = get_origin(target_attr_type) 

406 if orig == list and isinstance(value, CommentedSeq): 

407 lc = value.lc 

408 target_item_type = get_args(target_attr_type)[0] 

409 for idx, v in enumerate(value): 

410 v_pos = lc.item(idx) 

411 self._lint_value( 

412 idx, 

413 v, 

414 target_item_type, 

415 v_pos, 

416 ) 

417 

418 else: 

419 self._lint_value( 

420 key, 

421 value, 

422 target_attr_type, 

423 pos, 

424 ) 

425 

426 def _lint_value( 

427 self, 

428 key: str | int | None, 

429 value: Any, 

430 target_attr_type: Any, 

431 pos: tuple[int, int], 

432 ) -> None: 

433 type_mapping = self.pg.get_mapped_type_from_target_type(target_attr_type) 

434 source_attr_type = target_attr_type 

435 if type_mapping is not None: 

436 source_attr_type = type_mapping.source_type 

437 valid_values: Sequence[Any] | None = None 

438 orig = get_origin(source_attr_type) 

439 if orig == Literal: 

440 valid_values = get_args(target_attr_type) 

441 elif orig == bool or target_attr_type == bool: 

442 valid_values = (True, False) 

443 elif isinstance(target_attr_type, type) and self._type_based_value_check( 

444 target_attr_type, 

445 value, 

446 pos, 

447 key=key, 

448 ): 

449 return 

450 elif source_attr_type in BASIC_SIMPLE_TYPES: 

451 if isinstance(value, source_attr_type): 

452 return 

453 expected_type = BASIC_SIMPLE_TYPES[source_attr_type] 

454 line_no, cursor_pos = pos 

455 value_range = self._remaining_line(line_no, cursor_pos) 

456 if _is_empty_range(value_range): 456 ↛ 459line 456 didn't jump to line 459 because the condition on line 456 was always true

457 # FIXME: We cannot report an empty range, but there is still a problem here. 

458 return 

459 if isinstance(key, str): 

460 msg = f'Value for "{key}" does not match the base type: Expected {expected_type}' 

461 else: 

462 msg = f"Value does not match the base type: Expected {expected_type}" 

463 if issubclass(source_attr_type, str): 

464 quickfixes = [ 

465 propose_correct_text_quick_fix(_as_yaml_value(str(value))) 

466 ] 

467 else: 

468 quickfixes = None 

469 self.lint_state.emit_diagnostic( 

470 value_range, 

471 msg, 

472 "error", 

473 "debputy", 

474 quickfixes=quickfixes, 

475 ) 

476 return 

477 

478 if valid_values is None or value in valid_values: 

479 return 

480 line_no, cursor_pos = pos 

481 value_range = self._remaining_line(line_no, cursor_pos) 

482 if _is_empty_range(value_range): 482 ↛ 484line 482 didn't jump to line 484 because the condition on line 482 was never true

483 # FIXME: We cannot report an empty range, but there is still a problem here. 

484 return 

485 if isinstance(key, str): 485 ↛ 488line 485 didn't jump to line 488 because the condition on line 485 was always true

486 msg = f'Not a supported value for "{key}"' 

487 else: 

488 msg = "Not a supported value here" 

489 self.lint_state.emit_diagnostic( 

490 value_range, 

491 msg, 

492 "error", 

493 "debputy", 

494 quickfixes=[ 

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

496 ], 

497 ) 

498 

499 def _remaining_line(self, line_no: int, pos_start: int) -> "TERange": 

500 raw_line = self.lines[line_no].rstrip() 

501 pos_end = len(raw_line) 

502 return TERange( 

503 TEPosition( 

504 line_no, 

505 pos_start, 

506 ), 

507 TEPosition( 

508 line_no, 

509 pos_end, 

510 ), 

511 ) 

512 

513 

514def _is_empty_range(token_range: "TERange") -> bool: 

515 return token_range.start_pos == token_range.end_pos 

516 

517 

518def _lines(lines: list[str]) -> list[str]: 

519 if not lines or lines[-1].endswith("\n"): 519 ↛ 522line 519 didn't jump to line 522 because the condition on line 519 was always true

520 lines = lines.copy() 

521 lines.append("") 

522 return lines 

523 

524 

525async def generic_yaml_lint( 

526 lint_state: LintState, 

527 root_parser: DeclarativeInputParser[Any], 

528 initialize_yaml_helper: Callable[[LintState], LSPYAMLHelper[Any]], 

529) -> None: 

530 lines = _lines(lint_state.lines) 

531 try: 

532 content = MANIFEST_YAML.load(lint_state.content) 

533 except MarkedYAMLError as e: 

534 if e.context_mark: 

535 line = e.context_mark.line 

536 column = e.context_mark.column 

537 else: 

538 line = e.problem_mark.line 

539 column = e.problem_mark.column 

540 error_range = error_range_at_position( 

541 lines, 

542 line, 

543 column, 

544 ) 

545 lint_state.emit_diagnostic( 

546 error_range, 

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

548 "error", 

549 "debputy", 

550 ) 

551 except YAMLError as e: 

552 error_range = TERange( 

553 TEPosition(0, 0), 

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

555 ) 

556 lint_state.emit_diagnostic( 

557 error_range, 

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

559 "error", 

560 "debputy", 

561 ) 

562 else: 

563 yaml_linter = initialize_yaml_helper(lint_state) 

564 yaml_linter.lint_content( 

565 root_parser, 

566 content, 

567 ) 

568 

569 

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

571 if isinstance(v, bool): 

572 return str(v).lower() 

573 if isinstance(v, str): 573 ↛ 575line 573 didn't jump to line 575 because the condition on line 573 was always true

574 return maybe_quote_yaml_value(str(v)) 

575 return str(v) 

576 

577 

578def resolve_hover_text_for_value( 

579 feature_set: PluginProvidedFeatureSet, 

580 parser: DeclarativeMappingInputParser, 

581 plugin_metadata: DebputyPluginMetadata, 

582 output_style: OutputStyle, 

583 show_integration_mode: bool, 

584 segment: str | int, 

585 matched: Any, 

586) -> str | None: 

587 

588 hover_doc_text: str | None = None 

589 attr = parser.manifest_attributes.get(segment) 

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

591 if attr_type is None: 591 ↛ 592line 591 didn't jump to line 592 because the condition on line 591 was never true

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

593 return None 

594 if isinstance(attr_type, type) and issubclass(attr_type, DebputyDispatchableType): 594 ↛ 614line 594 didn't jump to line 614 because the condition on line 594 was always true

595 parser_generator = feature_set.manifest_parser_generator 

596 parser = parser_generator.dispatch_parser_table_for(attr_type) 

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

598 _info( 

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

600 ) 

601 return None 

602 subparser = parser.parser_for(matched) 

603 if subparser is None: 603 ↛ 604line 603 didn't jump to line 604 because the condition on line 603 was never true

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

605 return None 

606 hover_doc_text = render_rule( 

607 matched, 

608 subparser.parser, 

609 plugin_metadata, 

610 output_style, 

611 show_integration_mode=show_integration_mode, 

612 ) 

613 else: 

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

615 return hover_doc_text 

616 

617 

618def resolve_hover_text( 

619 feature_set: PluginProvidedFeatureSet, 

620 parser: DeclarativeInputParser[Any] | DispatchingParserBase | None, 

621 plugin_metadata: DebputyPluginMetadata, 

622 output_style: OutputStyle, 

623 show_integration_mode: bool, 

624 segments: list[str | int], 

625 at_depth_idx: int, 

626 matched: Any, 

627 matched_key: bool, 

628) -> str | None: 

629 hover_doc_text: str | None = None 

630 if at_depth_idx == len(segments): 

631 segment = segments[at_depth_idx - 1] 

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

633 hover_doc_text = render_rule( 

634 segment, 

635 parser, 

636 plugin_metadata, 

637 output_style, 

638 is_root_rule=False, 

639 show_integration_mode=show_integration_mode, 

640 ) 

641 elif at_depth_idx + 1 == len(segments) and isinstance( 641 ↛ 666line 641 didn't jump to line 666 because the condition on line 641 was always true

642 parser, DeclarativeMappingInputParser 

643 ): 

644 segment = segments[at_depth_idx] 

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

646 if isinstance(segment, str): 646 ↛ 668line 646 didn't jump to line 668 because the condition on line 646 was always true

647 if not matched_key: 

648 hover_doc_text = resolve_hover_text_for_value( 

649 feature_set, 

650 parser, 

651 plugin_metadata, 

652 output_style, 

653 show_integration_mode, 

654 segment, 

655 matched, 

656 ) 

657 if matched_key or hover_doc_text is None: 

658 rule_name = _guess_rule_name(segments, at_depth_idx) 

659 hover_doc_text = _render_param_doc( 

660 rule_name, 

661 parser, 

662 plugin_metadata, 

663 segment, 

664 ) 

665 else: 

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

667 

668 return hover_doc_text 

669 

670 

671def as_hover_doc( 

672 ls: "DebputyLanguageServer", 

673 hover_doc_text: str | None, 

674) -> types.Hover | None: 

675 if hover_doc_text is None: 675 ↛ 676line 675 didn't jump to line 676 because the condition on line 675 was never true

676 return None 

677 return types.Hover( 

678 contents=types.MarkupContent( 

679 kind=ls.hover_markup_format( 

680 types.MarkupKind.Markdown, 

681 types.MarkupKind.PlainText, 

682 ), 

683 value=hover_doc_text, 

684 ), 

685 ) 

686 

687 

688def _render_param_doc( 

689 rule_name: str, 

690 declarative_parser: DeclarativeMappingInputParser, 

691 plugin_metadata: DebputyPluginMetadata, 

692 attribute: str, 

693) -> str | None: 

694 source_form_name = declarative_parser.source_form_attr2source_attributes.get( 

695 attribute 

696 ) 

697 if source_form_name is None: 697 ↛ 698line 697 didn't jump to line 698 because the condition on line 697 was never true

698 return None 

699 attr = declarative_parser.source_attributes[source_form_name] 

700 

701 doc_args, parser_doc = doc_args_for_parser_doc( 

702 rule_name, 

703 declarative_parser, 

704 plugin_metadata, 

705 ) 

706 rendered_docs = render_attribute_doc( 

707 declarative_parser, 

708 declarative_parser.source_attributes, 

709 declarative_parser.input_time_required_parameters, 

710 declarative_parser.at_least_one_of, 

711 parser_doc, 

712 doc_args, 

713 is_interactive=True, 

714 rule_name=rule_name, 

715 ) 

716 

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

718 if source_form_name in attributes: 

719 full_doc = [ 

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

721 "", 

722 ] 

723 full_doc.extend(rendered_doc) 

724 

725 return "\n".join(full_doc) 

726 return None 

727 

728 

729def _guess_rule_name(segments: list[str | int], idx: int) -> str: 

730 orig_idx = idx 

731 idx -= 1 

732 while idx >= 0: 732 ↛ 737line 732 didn't jump to line 737 because the condition on line 732 was always true

733 segment = segments[idx] 

734 if isinstance(segment, str): 

735 return segment 

736 idx -= 1 

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

738 return "<Bug: unknown rule name>" 

739 

740 

741def is_at(position: types.Position, lc_pos: tuple[int, int]) -> bool: 

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

743 

744 

745def is_before(position: types.Position, lc_pos: tuple[int, int]) -> bool: 

746 line, column = lc_pos 

747 if position.line < line: 

748 return True 

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

750 return True 

751 return False 

752 

753 

754def is_after(position: types.Position, lc_pos: tuple[int, int]) -> bool: 

755 line, column = lc_pos 

756 if position.line > line: 

757 return True 

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

759 return True 

760 return False 

761 

762 

763def error_range_at_position( 

764 lines: list[str], 

765 line_no: int, 

766 char_offset: int, 

767) -> TERange: 

768 line = lines[line_no] 

769 line_len = len(line) 

770 start_idx = char_offset 

771 end_idx = start_idx 

772 

773 if line[start_idx].isspace(): 

774 

775 def _check(x: str) -> bool: 

776 return not x.isspace() 

777 

778 else: 

779 

780 def _check(x: str) -> bool: 

781 return x.isspace() 

782 

783 for i in range(end_idx, line_len): 

784 end_idx = i 

785 if _check(line[i]): 

786 break 

787 

788 for i in range(start_idx, -1, -1): 

789 if i > 0 and _check(line[i]): 

790 break 

791 start_idx = i 

792 

793 return TERange( 

794 TEPosition(line_no, start_idx), 

795 TEPosition(line_no, end_idx), 

796 ) 

797 

798 

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

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

801 

802 

803def insert_complete_marker_snippet( 

804 lines: list[str], 

805 server_position: types.Position, 

806) -> bool: 

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

808 line_no = server_position.line 

809 line = lines[line_no] if line_no < len(lines) else "" 

810 

811 lhs_ws = line[: server_position.character] 

812 lhs = lhs_ws.strip() 

813 open_quote = "" 

814 rhs = line[server_position.character + 1 :] 

815 for q in ('"', "'"): 

816 if rhs.endswith(q): 816 ↛ 817line 816 didn't jump to line 817 because the condition on line 816 was never true

817 break 

818 qc = lhs.count(q) & 1 

819 if qc: 

820 open_quote = q 

821 break 

822 

823 if lhs.endswith(":"): 

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

825 new_line = ( 

826 line[: server_position.character] 

827 + YAML_COMPLETION_HINT_VALUE 

828 + f"{open_quote}\n" 

829 ) 

830 elif lhs.startswith("-"): 

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

832 # Respect the provided indentation 

833 snippet = ( 

834 YAML_COMPLETION_HINT_KEY if ":" not in lhs else YAML_COMPLETION_HINT_VALUE 

835 ) 

836 new_line = line[: server_position.character] + snippet + f"{open_quote}\n" 

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

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

839 # Respect the provided indentation 

840 snippet = ( 

841 YAML_COMPLETION_HINT_KEY if ":" not in lhs else YAML_COMPLETION_HINT_VALUE 

842 ) 

843 new_line = line[: server_position.character] + snippet + f"{open_quote}\n" 

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

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

846 # Respect the provided indentation 

847 new_line = ( 

848 line[: server_position.character] 

849 + YAML_COMPLETION_HINT_KEY 

850 + f"{open_quote}\n" 

851 ) 

852 elif open_quote: 

853 _info( 

854 f"Expanding value inside a string: {_escape(line[server_position.character:])}" 

855 ) 

856 new_line = ( 

857 line[: server_position.character] 

858 + YAML_COMPLETION_HINT_VALUE 

859 + f"{open_quote}\n" 

860 ) 

861 else: 

862 c = ( 

863 line[server_position.character] 

864 if server_position.character < len(line) 

865 else "(OOB)" 

866 ) 

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

868 return False 

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

870 if line_no < len(lines): 870 ↛ 872line 870 didn't jump to line 872 because the condition on line 870 was always true

871 lines[line_no] = new_line 

872 elif line_no == len(lines): 

873 lines.append(new_line) 

874 else: 

875 return False 

876 return True 

877 

878 

879def _keywords_with_parser( 

880 parser: DeclarativeMappingInputParser | DispatchingParserBase, 

881) -> Iterator[tuple[str, PluginProvidedParser]]: 

882 for keyword in parser.registered_keywords(): 

883 pp_subparser = parser.parser_for(keyword) 

884 yield keyword, pp_subparser 

885 

886 

887def yaml_key_range( 

888 key: str | None, 

889 line: int, 

890 col: int, 

891) -> "TERange": 

892 key_len = len(key) if key else 1 

893 return TERange.between( 

894 TEPosition(line, col), 

895 TEPosition(line, col + key_len), 

896 ) 

897 

898 

899def yaml_flag_unknown_key( 

900 lint_state: LintState, 

901 key: str | None, 

902 expected_keys: Iterable[str], 

903 key_pos: tuple[int, int], 

904 *, 

905 message_format: str = 'Unknown or unsupported key "{key}".', 

906 unknown_keys_diagnostic_severity: LintSeverity | None = "error", 

907) -> str | None: 

908 line, col = key_pos 

909 key_range = yaml_key_range(key, line, col) 

910 

911 candidates = detect_possible_typo(key, expected_keys) if key is not None else () 

912 extra = "" 

913 corrected_key = None 

914 if candidates: 

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

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

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

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

919 if unknown_keys_diagnostic_severity is None: 919 ↛ 920line 919 didn't jump to line 920 because the condition on line 919 was never true

920 message_format = f"Possible typo of {candidates[0]}." 

921 extra = "" 

922 elif unknown_keys_diagnostic_severity is None: 922 ↛ 923line 922 didn't jump to line 923 because the condition on line 922 was never true

923 return None 

924 

925 if key is None: 

926 message_format = "Missing key" 

927 if unknown_keys_diagnostic_severity is not None: 927 ↛ 935line 927 didn't jump to line 935 because the condition on line 927 was always true

928 lint_state.emit_diagnostic( 

929 key_range, 

930 message_format.format(key=key) + extra, 

931 unknown_keys_diagnostic_severity, 

932 "debputy", 

933 quickfixes=[propose_correct_text_quick_fix(n) for n in candidates], 

934 ) 

935 return corrected_key 

936 

937 

938def yaml_conflicting_key( 

939 lint_state: LintState, 

940 key_a: str, 

941 key_b: str, 

942 key_a_line: int, 

943 key_a_col: int, 

944 key_b_line: int, 

945 key_b_col: int, 

946) -> None: 

947 key_a_range = TERange( 

948 TEPosition( 

949 key_a_line, 

950 key_a_col, 

951 ), 

952 TEPosition( 

953 key_a_line, 

954 key_a_col + len(key_a), 

955 ), 

956 ) 

957 key_b_range = TERange( 

958 TEPosition( 

959 key_b_line, 

960 key_b_col, 

961 ), 

962 TEPosition( 

963 key_b_line, 

964 key_b_col + len(key_b), 

965 ), 

966 ) 

967 lint_state.emit_diagnostic( 

968 key_a_range, 

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

970 "error", 

971 "debputy", 

972 related_information=[ 

973 lint_state.related_diagnostic_information( 

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

975 ), 

976 ], 

977 ) 

978 

979 lint_state.emit_diagnostic( 

980 key_b_range, 

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

982 "error", 

983 "debputy", 

984 related_information=[ 

985 lint_state.related_diagnostic_information( 

986 key_a_range, 

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

988 ), 

989 ], 

990 ) 

991 

992 

993def resolve_keyword( 

994 current_parser: DeclarativeInputParser[Any] | DispatchingParserBase, 

995 current_plugin: DebputyPluginMetadata, 

996 segments: list[str | int], 

997 segment_idx: int, 

998 parser_generator: ParserGenerator, 

999 *, 

1000 is_completion_attempt: bool = False, 

1001) -> None | ( 

1002 tuple[ 

1003 DeclarativeInputParser[Any] | DispatchingParserBase, 

1004 DebputyPluginMetadata, 

1005 int, 

1006 ] 

1007): 

1008 if segment_idx >= len(segments): 

1009 return current_parser, current_plugin, segment_idx 

1010 current_segment = segments[segment_idx] 

1011 if isinstance(current_parser, AllowNoneDeclarativeInputParser): 1011 ↛ 1012line 1011 didn't jump to line 1012 because the condition on line 1011 was never true

1012 current_parser = current_parser.delegate 

1013 if isinstance(current_parser, ListWrappedDeclarativeInputParser): 

1014 if isinstance(current_segment, int): 1014 ↛ 1021line 1014 didn't jump to line 1021 because the condition on line 1014 was always true

1015 current_parser = current_parser.delegate 

1016 segment_idx += 1 

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

1018 return current_parser, current_plugin, segment_idx 

1019 current_segment = segments[segment_idx] 

1020 

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

1022 return None 

1023 

1024 if is_completion_attempt and current_segment.endswith( 

1025 (YAML_COMPLETION_HINT_KEY, YAML_COMPLETION_HINT_VALUE) 

1026 ): 

1027 return current_parser, current_plugin, segment_idx 

1028 

1029 if isinstance(current_parser, InPackageContextParser): 

1030 return resolve_keyword( 

1031 current_parser.delegate, 

1032 current_plugin, 

1033 segments, 

1034 segment_idx + 1, 

1035 parser_generator, 

1036 is_completion_attempt=is_completion_attempt, 

1037 ) 

1038 elif isinstance(current_parser, DispatchingParserBase): 

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

1040 if is_completion_attempt: 

1041 return current_parser, current_plugin, segment_idx 

1042 return None 

1043 subparser = current_parser.parser_for(current_segment) 

1044 segment_idx += 1 

1045 if segment_idx < len(segments): 

1046 return resolve_keyword( 

1047 subparser.parser, 

1048 subparser.plugin_metadata, 

1049 segments, 

1050 segment_idx, 

1051 parser_generator, 

1052 is_completion_attempt=is_completion_attempt, 

1053 ) 

1054 return subparser.parser, subparser.plugin_metadata, segment_idx 

1055 elif isinstance(current_parser, DeclarativeMappingInputParser): 1055 ↛ 1077line 1055 didn't jump to line 1077 because the condition on line 1055 was always true

1056 attr = current_parser.manifest_attributes.get(current_segment) 

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

1058 if ( 

1059 attr_type is not None 

1060 and isinstance(attr_type, type) 

1061 and issubclass(attr_type, DebputyDispatchableType) 

1062 ): 

1063 subparser = parser_generator.dispatch_parser_table_for(attr_type) 

1064 if subparser is not None and ( 

1065 is_completion_attempt or segment_idx + 1 < len(segments) 

1066 ): 

1067 return resolve_keyword( 

1068 subparser, 

1069 current_plugin, 

1070 segments, 

1071 segment_idx + 1, 

1072 parser_generator, 

1073 is_completion_attempt=is_completion_attempt, 

1074 ) 

1075 return current_parser, current_plugin, segment_idx 

1076 else: 

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

1078 return None 

1079 

1080 

1081def _trace_cursor( 

1082 content: Any, 

1083 attribute_path: AttributePath, 

1084 server_position: types.Position, 

1085) -> tuple[bool, AttributePath, Any, Any] | None: 

1086 matched_key: str | int | None = None 

1087 matched: Node | None = None 

1088 matched_was_key: bool = False 

1089 

1090 if isinstance(content, CommentedMap): 

1091 dict_lc: LineCol = content.lc 

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

1093 k_lc = dict_lc.key(k) 

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

1095 break 

1096 v_lc = dict_lc.value(k) 

1097 if is_before(server_position, v_lc): 

1098 # TODO: Handle ":" and "whitespace" 

1099 matched = k 

1100 matched_key = k 

1101 matched_was_key = True 

1102 break 

1103 matched = v 

1104 matched_key = k 

1105 elif isinstance(content, CommentedSeq): 1105 ↛ 1114line 1105 didn't jump to line 1114 because the condition on line 1105 was always true

1106 list_lc: LineCol = content.lc 

1107 for idx, value in enumerate(content): 

1108 i_lc = list_lc.item(idx) 

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

1110 break 

1111 matched_key = idx 

1112 matched = value 

1113 

1114 if matched is not None: 1114 ↛ 1120line 1114 didn't jump to line 1120 because the condition on line 1114 was always true

1115 assert matched_key is not None 

1116 sub_path = attribute_path[matched_key] 

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

1118 return _trace_cursor(matched, sub_path, server_position) 

1119 return matched_was_key, sub_path, matched, content 

1120 return None 

1121 

1122 

1123def maybe_quote_yaml_value(v: str) -> str: 

1124 if v and v[0].isdigit(): 

1125 try: 

1126 float(v) 

1127 return f"'{v}'" 

1128 except ValueError: 

1129 pass 

1130 return v 

1131 

1132 

1133def _complete_value(v: Any) -> str: 

1134 if isinstance(v, str): 1134 ↛ 1136line 1134 didn't jump to line 1136 because the condition on line 1134 was always true

1135 return maybe_quote_yaml_value(v) 

1136 return str(v) 

1137 

1138 

1139def completion_from_attr( 

1140 attr: AttributeDescription, 

1141 pg: ParserGenerator, 

1142 matched: Any, 

1143 *, 

1144 matched_key: bool = False, 

1145 has_colon: bool = False, 

1146) -> types.CompletionList | Sequence[types.CompletionItem] | None: 

1147 type_mapping = pg.get_mapped_type_from_target_type(attr.attribute_type) 

1148 if type_mapping is not None: 1148 ↛ 1149line 1148 didn't jump to line 1149 because the condition on line 1148 was never true

1149 attr_type = type_mapping.source_type 

1150 else: 

1151 attr_type = attr.attribute_type 

1152 

1153 orig = get_origin(attr_type) 

1154 valid_values: Sequence[Any] = tuple() 

1155 

1156 if orig == Literal: 

1157 valid_values = get_args(attr_type) 

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

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

1160 elif isinstance(attr_type, type) and issubclass(attr_type, DebputyDispatchableType): 1160 ↛ 1177line 1160 didn't jump to line 1177 because the condition on line 1160 was always true

1161 parser: DispatchingTableParser[Any] = pg.dispatch_parser_table_for(attr_type) 

1162 if parser is None: 1162 ↛ 1163line 1162 didn't jump to line 1163 because the condition on line 1162 was never true

1163 return None 

1164 valid_values = [ 

1165 k if has_colon or not matched_key else f"{k}:" 

1166 for k in parser.registered_keywords() 

1167 if isinstance( 

1168 parser.parser_for(k).parser, 

1169 ( 

1170 DeclarativeValuelessKeywordInputParser, 

1171 AllowNoneDeclarativeInputParser, 

1172 ), 

1173 ) 

1174 ^ matched_key 

1175 ] 

1176 

1177 if matched in valid_values: 1177 ↛ 1178line 1177 didn't jump to line 1178 because the condition on line 1177 was never true

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

1179 return None 

1180 if valid_values: 1180 ↛ 1182line 1180 didn't jump to line 1182 because the condition on line 1180 was always true

1181 return [types.CompletionItem(_complete_value(x)) for x in valid_values] 

1182 return None 

1183 

1184 

1185def completion_item( 

1186 quoted_keyword: str, 

1187 pp_subparser: PluginProvidedParser, 

1188) -> types.CompletionItem: 

1189 inline_reference_documentation = pp_subparser.parser.inline_reference_documentation 

1190 synopsis = ( 

1191 inline_reference_documentation.synopsis 

1192 if inline_reference_documentation 

1193 else None 

1194 ) 

1195 return types.CompletionItem( 

1196 quoted_keyword, 

1197 detail=synopsis, 

1198 ) 

1199 

1200 

1201def _is_inside_manifest_variable_substitution( 

1202 lines: list[str], 

1203 server_position: types.Position, 

1204) -> bool: 

1205 

1206 current_line = lines[server_position.line] 

1207 try: 

1208 open_idx = current_line[0 : server_position.character].rindex("{{") 

1209 return "}}" not in current_line[open_idx : server_position.character] 

1210 except ValueError: 

1211 return False 

1212 

1213 

1214def _manifest_substitution_variable_at_position( 

1215 lines: list[str], 

1216 server_position: types.Position, 

1217) -> str | None: 

1218 current_line = lines[server_position.line] 

1219 try: 

1220 open_idx = current_line[0 : server_position.character].rindex("{{") + 2 

1221 if "}}" in current_line[open_idx : server_position.character]: 1221 ↛ 1222line 1221 didn't jump to line 1222 because the condition on line 1221 was never true

1222 return None 

1223 variable_len = current_line[open_idx:].index("}}") 

1224 close_idx = open_idx + variable_len 

1225 except ValueError as e: 

1226 return None 

1227 return current_line[open_idx:close_idx] 

1228 

1229 

1230def _insert_complete_marker_and_parse_yaml( 

1231 lines: list[str], 

1232 server_position: types.Position, 

1233) -> Any | None: 

1234 added_key = insert_complete_marker_snippet(lines, server_position) 

1235 attempts = 1 if added_key else 2 

1236 content = None 

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

1238 attempts -= 1 

1239 try: 

1240 # Since we mutated the lines to insert a token, `doc.source` cannot 

1241 # be used here. 

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

1243 break 

1244 except MarkedYAMLError as e: 

1245 context_line = ( 

1246 e.context_mark.line if e.context_mark else e.problem_mark.line 

1247 ) 

1248 if ( 

1249 e.problem_mark.line != server_position.line 

1250 and context_line != server_position.line 

1251 ): 

1252 l_data = ( 

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

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

1255 else "N/A (OOB)" 

1256 ) 

1257 

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

1259 return None 

1260 

1261 if attempts > 0: 

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

1263 new_line = ( 

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

1265 ) 

1266 lines[server_position.line] = new_line 

1267 except YAMLError: 

1268 break 

1269 return content 

1270 

1271 

1272def generic_yaml_completer( 

1273 ls: "DebputyLanguageServer", 

1274 params: types.CompletionParams, 

1275 root_parser: DeclarativeInputParser[Any], 

1276) -> types.CompletionList | Sequence[types.CompletionItem] | None: 

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

1278 lines = _lines(doc.lines) 

1279 server_position = doc.position_codec.position_from_client_units( 

1280 lines, params.position 

1281 ) 

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

1283 has_colon = ":" in orig_line 

1284 

1285 content = _insert_complete_marker_and_parse_yaml(lines, server_position) 

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

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

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

1289 return None 

1290 attribute_root_path = AttributePath.root_path(content) 

1291 m = _trace_cursor(content, attribute_root_path, server_position) 

1292 

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

1294 _info("No match") 

1295 return None 

1296 matched_key, attr_path, matched, parent = m 

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

1298 feature_set = ls.plugin_feature_set 

1299 segments = list(attr_path.path_segments()) 

1300 km = resolve_keyword( 

1301 root_parser, 

1302 DEBPUTY_PLUGIN_METADATA, 

1303 segments, 

1304 0, 

1305 feature_set.manifest_parser_generator, 

1306 is_completion_attempt=True, 

1307 ) 

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

1309 return None 

1310 parser, _, at_depth_idx = km 

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

1312 items = [] 

1313 if at_depth_idx + 1 < len(segments): 1313 ↛ 1314line 1313 didn't jump to line 1314 because the condition on line 1313 was never true

1314 return items 

1315 

1316 if _is_inside_manifest_variable_substitution(lines, server_position): 

1317 return [ 

1318 types.CompletionItem( 

1319 pv.variable_name, 

1320 detail=pv.variable_reference_documentation, 

1321 sort_text=( 

1322 f"zz-{pv.variable_name}" 

1323 if pv.is_for_special_case 

1324 else pv.variable_name 

1325 ), 

1326 ) 

1327 for pv in ls.plugin_feature_set.manifest_variables.values() 

1328 if not pv.is_internal 

1329 ] 

1330 

1331 if isinstance(parser, DispatchingParserBase): 

1332 if matched_key: 

1333 items = [ 

1334 completion_item( 

1335 ( 

1336 maybe_quote_yaml_value(k) 

1337 if has_colon 

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

1339 ), 

1340 pp_subparser, 

1341 ) 

1342 for k, pp_subparser in _keywords_with_parser(parser) 

1343 if k not in parent 

1344 and not isinstance( 

1345 pp_subparser.parser, 

1346 DeclarativeValuelessKeywordInputParser, 

1347 ) 

1348 ] 

1349 else: 

1350 items = [ 

1351 completion_item(maybe_quote_yaml_value(k), pp_subparser) 

1352 for k, pp_subparser in _keywords_with_parser(parser) 

1353 if k not in parent 

1354 and isinstance( 

1355 pp_subparser.parser, 

1356 DeclarativeValuelessKeywordInputParser, 

1357 ) 

1358 ] 

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

1360 binary_packages = ls.lint_state(doc).binary_packages 

1361 if binary_packages is not None: 

1362 items = [ 

1363 types.CompletionItem( 

1364 maybe_quote_yaml_value(p) 

1365 if has_colon 

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

1367 ) 

1368 for p in binary_packages 

1369 if p not in parent 

1370 ] 

1371 elif isinstance(parser, DeclarativeMappingInputParser): 

1372 if matched_key: 

1373 _info("Match attributes") 

1374 locked = set(parent) 

1375 for mx in parser.mutually_exclusive_attributes: 

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

1377 locked.update(mx) 

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

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

1380 locked.add(attr_name) 

1381 break 

1382 items = [ 

1383 types.CompletionItem( 

1384 maybe_quote_yaml_value(k) 

1385 if has_colon 

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

1387 ) 

1388 for k in parser.manifest_attributes 

1389 if k not in locked 

1390 ] 

1391 else: 

1392 # Value 

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

1394 value_attr = ( 

1395 parser.manifest_attributes.get(key) if isinstance(key, str) else None 

1396 ) 

1397 if value_attr is not None: 1397 ↛ 1407line 1397 didn't jump to line 1407 because the condition on line 1397 was always true

1398 _info(f"Expand value / key: {key} -- {value_attr.attribute_type}") 

1399 return completion_from_attr( 

1400 value_attr, 

1401 feature_set.manifest_parser_generator, 

1402 matched, 

1403 matched_key=False, 

1404 has_colon=has_colon, 

1405 ) 

1406 else: 

1407 _info( 

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

1409 ) 

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

1411 alt_attr = parser.alt_form_parser 

1412 return completion_from_attr( 

1413 alt_attr, 

1414 feature_set.manifest_parser_generator, 

1415 matched, 

1416 matched_key=matched_key, 

1417 has_colon=has_colon, 

1418 ) 

1419 return items 

1420 

1421 

1422def generic_yaml_hover( 

1423 ls: "DebputyLanguageServer", 

1424 params: types.HoverParams, 

1425 root_parser_initializer: Callable[ 

1426 [ParserGenerator], DeclarativeInputParser[Any] | DispatchingParserBase 

1427 ], 

1428 *, 

1429 show_integration_mode: bool = False, 

1430) -> types.Hover | None: 

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

1432 lines = doc.lines 

1433 position_codec = doc.position_codec 

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

1435 

1436 try: 

1437 content = MANIFEST_YAML.load(doc.source) 

1438 except YAMLError: 

1439 return None 

1440 attribute_root_path = AttributePath.root_path(content) 

1441 m = _trace_cursor(content, attribute_root_path, server_position) 

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

1443 _info("No match") 

1444 return None 

1445 matched_key, attr_path, matched, _ = m 

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

1447 

1448 feature_set = ls.plugin_feature_set 

1449 parser_generator = feature_set.manifest_parser_generator 

1450 root_parser = root_parser_initializer(parser_generator) 

1451 segments = list(attr_path.path_segments()) 

1452 km = resolve_keyword( 

1453 root_parser, 

1454 DEBPUTY_PLUGIN_METADATA, 

1455 segments, 

1456 0, 

1457 parser_generator, 

1458 ) 

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

1460 _info("No keyword match") 

1461 return None 

1462 parser, plugin_metadata, at_depth_idx = km 

1463 

1464 manifest_variable_at_pos = _manifest_substitution_variable_at_position( 

1465 lines, server_position 

1466 ) 

1467 

1468 if manifest_variable_at_pos: 

1469 variable = ls.plugin_feature_set.manifest_variables.get( 

1470 manifest_variable_at_pos 

1471 ) 

1472 if variable is not None: 1472 ↛ 1499line 1472 didn't jump to line 1499 because the condition on line 1472 was always true

1473 var_doc = ( 

1474 variable.variable_reference_documentation 

1475 or "No documentation available" 

1476 ) 

1477 

1478 if variable.is_context_specific_variable: 1478 ↛ 1479line 1478 didn't jump to line 1479 because the condition on line 1478 was never true

1479 value = "\nThe value depends on the context" 

1480 else: 

1481 debian_dir = ls.lint_state(doc).debian_dir 

1482 value = "" 

1483 if debian_dir: 1483 ↛ 1484line 1483 didn't jump to line 1484 because the condition on line 1483 was never true

1484 variable_context = VariableContext(debian_dir) 

1485 try: 

1486 resolved = variable.resolve(variable_context) 

1487 value = f"\nResolves to: `{resolved}`" 

1488 except RuntimeError: 

1489 pass 

1490 

1491 hover_doc_text = textwrap.dedent("""\ 

1492 # `{NAME}` 

1493 

1494 {DOC} 

1495 {VALUE} 

1496 """).format(NAME=variable.variable_name, DOC=var_doc, VALUE=value) 

1497 return as_hover_doc(ls, hover_doc_text) 

1498 

1499 _info( 

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

1501 ) 

1502 hover_doc_text = resolve_hover_text( 

1503 feature_set, 

1504 parser, 

1505 plugin_metadata, 

1506 ls.hover_output_style, 

1507 show_integration_mode, 

1508 segments, 

1509 at_depth_idx, 

1510 matched, 

1511 matched_key, 

1512 ) 

1513 return as_hover_doc(ls, hover_doc_text)