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

660 statements  

« prev     ^ index     » next       coverage.py v7.8.2, created at 2025-10-12 15:06 +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 

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 debputy.lsp.vendoring._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) 

51from debputy.substitution import VariableContext 

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

53from debputy.yaml import MANIFEST_YAML 

54from debputy.yaml.compat import ( 

55 MarkedYAMLError, 

56 YAMLError, 

57) 

58from debputy.yaml.compat import ( 

59 Node, 

60 CommentedMap, 

61 LineCol, 

62 CommentedSeq, 

63 CommentedBase, 

64) 

65 

66if TYPE_CHECKING: 

67 import lsprotocol.types as types 

68else: 

69 import debputy.lsprotocol.types as types 

70 

71try: 

72 from pygls.server import LanguageServer 

73 from debputy.lsp.debputy_ls import DebputyLanguageServer 

74except ImportError: 

75 pass 

76 

77 

78YAML_COMPLETION_HINT_KEY = "___COMPLETE:" 

79YAML_COMPLETION_HINT_VALUE = "___COMPLETE" 

80DEBPUTY_PLUGIN_METADATA = plugin_metadata_for_debputys_own_plugin() 

81 

82 

83class LSPYAMLHelper(Generic[T]): 

84 

85 def __init__( 

86 self, 

87 lint_state: LintState, 

88 pg: ParserGenerator, 

89 custom_data: T, 

90 ) -> None: 

91 self.lint_state = lint_state 

92 self.lines = _lines(lint_state.lines) 

93 self.pg = pg 

94 self.custom_data = custom_data 

95 

96 def _validate_subparser_is_valid_here( 

97 self, 

98 subparser: PluginProvidedParser, 

99 orig_key: str, 

100 line: int, 

101 col: int, 

102 ) -> None: 

103 # Subclasses can provide custom logic here 

104 pass 

105 

106 def _lint_dispatch_parser( 

107 self, 

108 parser: DispatchingParserBase, 

109 dispatch_key: str, 

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

111 value: Any | None, 

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

113 *, 

114 is_keyword_only: bool, 

115 ) -> None: 

116 is_known = parser.is_known_keyword(dispatch_key) 

117 orig_key = dispatch_key 

118 if not is_known and key_pos is not None: 

119 

120 if value is None: 

121 opts = { 

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

123 } 

124 else: 

125 opts = {} 

126 corrected_key = yaml_flag_unknown_key( 

127 self.lint_state, 

128 dispatch_key, 

129 parser.registered_keywords(), 

130 key_pos, 

131 unknown_keys_diagnostic_severity=parser.unknown_keys_diagnostic_severity, 

132 **opts, 

133 ) 

134 if corrected_key is not None: 

135 dispatch_key = corrected_key 

136 is_known = True 

137 

138 if is_known: 

139 subparser = parser.parser_for(dispatch_key) 

140 assert subparser is not None 

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

142 line, col = key_pos 

143 self._validate_subparser_is_valid_here( 

144 subparser, 

145 orig_key, 

146 line, 

147 col, 

148 ) 

149 

150 if isinstance(subparser.parser, DeclarativeValuelessKeywordInputParser): 

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

152 if value is not None: 

153 line_no, cursor_pos = value_pos if value_pos else key_pos 

154 value_range = self._remaining_line(line_no, cursor_pos) 

155 if _is_empty_range(value_range): 

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

157 # an empty range, report the key instead. 

158 line_no, cursor_pos = key_pos 

159 value_range = self._remaining_line(line_no, cursor_pos) 

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

161 else: 

162 line_no, cursor_pos = key_pos 

163 value_range = self._remaining_line(line_no, cursor_pos) 

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

165 

166 assert not _is_empty_range(value_range) 

167 self.lint_state.emit_diagnostic( 

168 value_range, 

169 msg, 

170 "error", 

171 "debputy", 

172 ) 

173 return 

174 

175 self.lint_content( 

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

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

178 value, 

179 key=orig_key, 

180 content_pos=value_pos, 

181 ) 

182 

183 def lint_content( 

184 self, 

185 parser: DeclarativeInputParser[Any], 

186 content: Any, 

187 *, 

188 key: str | int | None = None, 

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

190 ) -> None: 

191 if isinstance(parser, DispatchingParserBase): 

192 if isinstance(content, str): 

193 self._lint_dispatch_parser( 

194 parser, 

195 content, 

196 content_pos, 

197 None, 

198 None, 

199 is_keyword_only=True, 

200 ) 

201 

202 return 

203 if not isinstance(content, CommentedMap): 

204 return 

205 lc = content.lc 

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

207 key_pos = lc.key(dispatch_key) 

208 value_pos = lc.value(dispatch_key) 

209 self._lint_dispatch_parser( 

210 parser, 

211 dispatch_key, 

212 key_pos, 

213 value, 

214 value_pos, 

215 is_keyword_only=False, 

216 ) 

217 

218 elif isinstance(parser, ListWrappedDeclarativeInputParser): 

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

220 return 

221 subparser = parser.delegate 

222 lc = content.lc 

223 for idx, value in enumerate(content): 

224 value_pos = lc.item(idx) 

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

226 elif isinstance(parser, InPackageContextParser): 

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

228 return 

229 known_packages = self.lint_state.binary_packages 

230 lc = content.lc 

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

232 if k is None or ( 

233 "{{" not in k 

234 and known_packages is not None 

235 and k not in known_packages 

236 ): 

237 yaml_flag_unknown_key( 

238 self.lint_state, 

239 k, 

240 known_packages, 

241 lc.key(k), 

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

243 ) 

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

245 elif isinstance(parser, DeclarativeMappingInputParser): 

246 self._lint_declarative_mapping_input_parser( 

247 parser, 

248 content, 

249 content_pos, 

250 key=key, 

251 ) 

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

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

254 self._lint_attr_value( 

255 parser.alt_form_parser, 

256 key, 

257 content, 

258 content_pos, 

259 ) 

260 

261 def _lint_declarative_mapping_input_parser( 

262 self, 

263 parser: DeclarativeMappingInputParser, 

264 content: Any, 

265 content_pos: tuple[int, int], 

266 *, 

267 key: str | int | None = None, 

268 ) -> None: 

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

270 alt_form_parser = parser.alt_form_parser 

271 if alt_form_parser: 

272 self._lint_attr_value( 

273 alt_form_parser, 

274 key, 

275 content, 

276 content_pos, 

277 ) 

278 else: 

279 line_no, cursor_pos = content_pos 

280 value_range = self._remaining_line(line_no, cursor_pos) 

281 if _is_empty_range(value_range): 

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

283 return 

284 if isinstance(key, str): 

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

286 else: 

287 msg = "The value must be a mapping" 

288 self.lint_state.emit_diagnostic( 

289 value_range, 

290 msg, 

291 "error", 

292 "debputy", 

293 ) 

294 return 

295 lc = content.lc 

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

297 attr = parser.manifest_attributes.get(key) 

298 key_pos = lc.key(key) 

299 value_pos = lc.value(key) 

300 if attr is None: 

301 corrected_key = yaml_flag_unknown_key( 

302 self.lint_state, 

303 key, 

304 parser.manifest_attributes, 

305 key_pos, 

306 ) 

307 if corrected_key: 

308 key = corrected_key 

309 attr = parser.manifest_attributes.get(corrected_key) 

310 if attr is None: 

311 continue 

312 

313 self._lint_attr_value( 

314 attr, 

315 key, 

316 value, 

317 value_pos, 

318 ) 

319 

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

321 if forbidden_key in content: 

322 line, col = key_pos 

323 con_line, con_col = lc.key(forbidden_key) 

324 yaml_conflicting_key( 

325 self.lint_state, 

326 key, 

327 forbidden_key, 

328 line, 

329 col, 

330 con_line, 

331 con_col, 

332 ) 

333 for mx in parser.mutually_exclusive_attributes: 

334 matches = content.keys() & mx 

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

336 continue 

337 key, *others = list(matches) 

338 line, col = lc.key(key) 

339 for other in others: 

340 con_line, con_col = lc.key(other) 

341 yaml_conflicting_key( 

342 self.lint_state, 

343 key, 

344 other, 

345 line, 

346 col, 

347 con_line, 

348 con_col, 

349 ) 

350 

351 def _type_based_value_check( 

352 self, 

353 target_attr_type: type, 

354 value: Any, 

355 value_pos: tuple[int, int], 

356 *, 

357 key: str | int | None = None, 

358 ) -> bool: 

359 if issubclass(target_attr_type, DebputyDispatchableType): 

360 parser = self.pg.dispatch_parser_table_for(target_attr_type) 

361 self.lint_content( 

362 parser, 

363 value, 

364 key=key, 

365 content_pos=value_pos, 

366 ) 

367 return True 

368 return False 

369 

370 def _lint_attr_value( 

371 self, 

372 attr: AttributeDescription, 

373 key: str | int | None, 

374 value: Any, 

375 pos: tuple[int, int], 

376 ) -> None: 

377 target_attr_type = attr.attribute_type 

378 orig = get_origin(target_attr_type) 

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

380 lc = value.lc 

381 target_item_type = get_args(target_attr_type)[0] 

382 for idx, v in enumerate(value): 

383 v_pos = lc.item(idx) 

384 self._lint_value( 

385 idx, 

386 v, 

387 target_item_type, 

388 v_pos, 

389 ) 

390 

391 else: 

392 self._lint_value( 

393 key, 

394 value, 

395 target_attr_type, 

396 pos, 

397 ) 

398 

399 def _lint_value( 

400 self, 

401 key: str | int | None, 

402 value: Any, 

403 target_attr_type: Any, 

404 pos: tuple[int, int], 

405 ) -> None: 

406 type_mapping = self.pg.get_mapped_type_from_target_type(target_attr_type) 

407 source_attr_type = target_attr_type 

408 if type_mapping is not None: 

409 source_attr_type = type_mapping.source_type 

410 valid_values: Sequence[Any] | None = None 

411 orig = get_origin(source_attr_type) 

412 if orig == Literal: 

413 valid_values = get_args(target_attr_type) 

414 elif orig == bool or target_attr_type == bool: 

415 valid_values = (True, False) 

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

417 target_attr_type, 

418 value, 

419 pos, 

420 key=key, 

421 ): 

422 return 

423 elif source_attr_type in BASIC_SIMPLE_TYPES: 

424 if isinstance(value, source_attr_type): 

425 return 

426 expected_type = BASIC_SIMPLE_TYPES[source_attr_type] 

427 line_no, cursor_pos = pos 

428 value_range = self._remaining_line(line_no, cursor_pos) 

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

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

431 return 

432 if isinstance(key, str): 

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

434 else: 

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

436 if issubclass(source_attr_type, str): 

437 quickfixes = [ 

438 propose_correct_text_quick_fix(_as_yaml_value(str(value))) 

439 ] 

440 else: 

441 quickfixes = None 

442 self.lint_state.emit_diagnostic( 

443 value_range, 

444 msg, 

445 "error", 

446 "debputy", 

447 quickfixes=quickfixes, 

448 ) 

449 return 

450 

451 if valid_values is None or value in valid_values: 

452 return 

453 line_no, cursor_pos = pos 

454 value_range = self._remaining_line(line_no, cursor_pos) 

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

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

457 return 

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

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

460 else: 

461 msg = "Not a supported value here" 

462 self.lint_state.emit_diagnostic( 

463 value_range, 

464 msg, 

465 "error", 

466 "debputy", 

467 quickfixes=[ 

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

469 ], 

470 ) 

471 

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

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

474 pos_end = len(raw_line) 

475 return TERange( 

476 TEPosition( 

477 line_no, 

478 pos_start, 

479 ), 

480 TEPosition( 

481 line_no, 

482 pos_end, 

483 ), 

484 ) 

485 

486 

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

488 return token_range.start_pos == token_range.end_pos 

489 

490 

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

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

493 lines = lines.copy() 

494 lines.append("") 

495 return lines 

496 

497 

498async def generic_yaml_lint( 

499 lint_state: LintState, 

500 root_parser: DeclarativeInputParser[Any], 

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

502) -> None: 

503 lines = _lines(lint_state.lines) 

504 try: 

505 content = MANIFEST_YAML.load(lint_state.content) 

506 except MarkedYAMLError as e: 

507 if e.context_mark: 

508 line = e.context_mark.line 

509 column = e.context_mark.column 

510 else: 

511 line = e.problem_mark.line 

512 column = e.problem_mark.column 

513 error_range = error_range_at_position( 

514 lines, 

515 line, 

516 column, 

517 ) 

518 lint_state.emit_diagnostic( 

519 error_range, 

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

521 "error", 

522 "debputy", 

523 ) 

524 except YAMLError as e: 

525 error_range = TERange( 

526 TEPosition(0, 0), 

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

528 ) 

529 lint_state.emit_diagnostic( 

530 error_range, 

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

532 "error", 

533 "debputy", 

534 ) 

535 else: 

536 yaml_linter = initialize_yaml_helper(lint_state) 

537 yaml_linter.lint_content( 

538 root_parser, 

539 content, 

540 ) 

541 

542 

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

544 if isinstance(v, bool): 

545 return str(v).lower() 

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

547 return maybe_quote_yaml_value(str(v)) 

548 return str(v) 

549 

550 

551def resolve_hover_text_for_value( 

552 feature_set: PluginProvidedFeatureSet, 

553 parser: DeclarativeMappingInputParser, 

554 plugin_metadata: DebputyPluginMetadata, 

555 output_style: OutputStyle, 

556 show_integration_mode: bool, 

557 segment: str | int, 

558 matched: Any, 

559) -> str | None: 

560 

561 hover_doc_text: str | None = None 

562 attr = parser.manifest_attributes.get(segment) 

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

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

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

566 return None 

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

568 parser_generator = feature_set.manifest_parser_generator 

569 parser = parser_generator.dispatch_parser_table_for(attr_type) 

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

571 _info( 

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

573 ) 

574 return None 

575 subparser = parser.parser_for(matched) 

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

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

578 return None 

579 hover_doc_text = render_rule( 

580 matched, 

581 subparser.parser, 

582 plugin_metadata, 

583 output_style, 

584 show_integration_mode=show_integration_mode, 

585 ) 

586 else: 

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

588 return hover_doc_text 

589 

590 

591def resolve_hover_text( 

592 feature_set: PluginProvidedFeatureSet, 

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

594 plugin_metadata: DebputyPluginMetadata, 

595 output_style: OutputStyle, 

596 show_integration_mode: bool, 

597 segments: list[str | int], 

598 at_depth_idx: int, 

599 matched: Any, 

600 matched_key: bool, 

601) -> str | None: 

602 hover_doc_text: str | None = None 

603 if at_depth_idx == len(segments): 

604 segment = segments[at_depth_idx - 1] 

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

606 hover_doc_text = render_rule( 

607 segment, 

608 parser, 

609 plugin_metadata, 

610 output_style, 

611 is_root_rule=False, 

612 show_integration_mode=show_integration_mode, 

613 ) 

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

615 parser, DeclarativeMappingInputParser 

616 ): 

617 segment = segments[at_depth_idx] 

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

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

620 if not matched_key: 

621 hover_doc_text = resolve_hover_text_for_value( 

622 feature_set, 

623 parser, 

624 plugin_metadata, 

625 output_style, 

626 show_integration_mode, 

627 segment, 

628 matched, 

629 ) 

630 if matched_key or hover_doc_text is None: 

631 rule_name = _guess_rule_name(segments, at_depth_idx) 

632 hover_doc_text = _render_param_doc( 

633 rule_name, 

634 parser, 

635 plugin_metadata, 

636 segment, 

637 ) 

638 else: 

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

640 

641 return hover_doc_text 

642 

643 

644def as_hover_doc( 

645 ls: "DebputyLanguageServer", 

646 hover_doc_text: str | None, 

647) -> types.Hover | None: 

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

649 return None 

650 return types.Hover( 

651 contents=types.MarkupContent( 

652 kind=ls.hover_markup_format( 

653 types.MarkupKind.Markdown, 

654 types.MarkupKind.PlainText, 

655 ), 

656 value=hover_doc_text, 

657 ), 

658 ) 

659 

660 

661def _render_param_doc( 

662 rule_name: str, 

663 declarative_parser: DeclarativeMappingInputParser, 

664 plugin_metadata: DebputyPluginMetadata, 

665 attribute: str, 

666) -> str | None: 

667 attr = declarative_parser.source_attributes.get(attribute) 

668 if attr is None: 668 ↛ 669line 668 didn't jump to line 669 because the condition on line 668 was never true

669 return None 

670 

671 doc_args, parser_doc = doc_args_for_parser_doc( 

672 rule_name, 

673 declarative_parser, 

674 plugin_metadata, 

675 ) 

676 rendered_docs = render_attribute_doc( 

677 declarative_parser, 

678 declarative_parser.source_attributes, 

679 declarative_parser.input_time_required_parameters, 

680 declarative_parser.at_least_one_of, 

681 parser_doc, 

682 doc_args, 

683 is_interactive=True, 

684 rule_name=rule_name, 

685 ) 

686 

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

688 if attribute in attributes: 

689 full_doc = [ 

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

691 "", 

692 ] 

693 full_doc.extend(rendered_doc) 

694 

695 return "\n".join(full_doc) 

696 return None 

697 

698 

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

700 orig_idx = idx 

701 idx -= 1 

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

703 segment = segments[idx] 

704 if isinstance(segment, str): 

705 return segment 

706 idx -= 1 

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

708 return "<Bug: unknown rule name>" 

709 

710 

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

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

713 

714 

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

716 line, column = lc_pos 

717 if position.line < line: 

718 return True 

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

720 return True 

721 return False 

722 

723 

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

725 line, column = lc_pos 

726 if position.line > line: 

727 return True 

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

729 return True 

730 return False 

731 

732 

733def error_range_at_position( 

734 lines: list[str], 

735 line_no: int, 

736 char_offset: int, 

737) -> TERange: 

738 line = lines[line_no] 

739 line_len = len(line) 

740 start_idx = char_offset 

741 end_idx = start_idx 

742 

743 if line[start_idx].isspace(): 

744 

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

746 return not x.isspace() 

747 

748 else: 

749 

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

751 return x.isspace() 

752 

753 for i in range(end_idx, line_len): 

754 end_idx = i 

755 if _check(line[i]): 

756 break 

757 

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

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

760 break 

761 start_idx = i 

762 

763 return TERange( 

764 TEPosition(line_no, start_idx), 

765 TEPosition(line_no, end_idx), 

766 ) 

767 

768 

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

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

771 

772 

773def insert_complete_marker_snippet( 

774 lines: list[str], 

775 server_position: types.Position, 

776) -> bool: 

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

778 line_no = server_position.line 

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

780 

781 lhs_ws = line[: server_position.character] 

782 lhs = lhs_ws.strip() 

783 open_quote = "" 

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

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

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

787 break 

788 qc = lhs.count(q) & 1 

789 if qc: 

790 open_quote = q 

791 break 

792 

793 if lhs.endswith(":"): 

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

795 new_line = ( 

796 line[: server_position.character] 

797 + YAML_COMPLETION_HINT_VALUE 

798 + f"{open_quote}\n" 

799 ) 

800 elif lhs.startswith("-"): 

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

802 # Respect the provided indentation 

803 snippet = ( 

804 YAML_COMPLETION_HINT_KEY if ":" not in lhs else YAML_COMPLETION_HINT_VALUE 

805 ) 

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

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

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

809 # Respect the provided indentation 

810 snippet = ( 

811 YAML_COMPLETION_HINT_KEY if ":" not in lhs else YAML_COMPLETION_HINT_VALUE 

812 ) 

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

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

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

816 # Respect the provided indentation 

817 new_line = ( 

818 line[: server_position.character] 

819 + YAML_COMPLETION_HINT_KEY 

820 + f"{open_quote}\n" 

821 ) 

822 elif open_quote: 

823 _info( 

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

825 ) 

826 new_line = ( 

827 line[: server_position.character] 

828 + YAML_COMPLETION_HINT_VALUE 

829 + f"{open_quote}\n" 

830 ) 

831 else: 

832 c = ( 

833 line[server_position.character] 

834 if server_position.character < len(line) 

835 else "(OOB)" 

836 ) 

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

838 return False 

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

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

841 lines[line_no] = new_line 

842 elif line_no == len(lines): 

843 lines.append(new_line) 

844 else: 

845 return False 

846 return True 

847 

848 

849def _keywords_with_parser( 

850 parser: DeclarativeMappingInputParser | DispatchingParserBase, 

851) -> tuple[str, PluginProvidedParser]: 

852 for keyword in parser.registered_keywords(): 

853 pp_subparser = parser.parser_for(keyword) 

854 yield keyword, pp_subparser 

855 

856 

857def yaml_key_range( 

858 key: str | None, 

859 line: int, 

860 col: int, 

861) -> "TERange": 

862 key_len = len(key) if key else 1 

863 return TERange.between( 

864 TEPosition(line, col), 

865 TEPosition(line, col + key_len), 

866 ) 

867 

868 

869def yaml_flag_unknown_key( 

870 lint_state: LintState, 

871 key: str | None, 

872 expected_keys: Iterable[str], 

873 key_pos: tuple[int, int], 

874 *, 

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

876 unknown_keys_diagnostic_severity: LintSeverity | None = "error", 

877) -> str | None: 

878 line, col = key_pos 

879 key_range = yaml_key_range(key, line, col) 

880 

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

882 extra = "" 

883 corrected_key = None 

884 if candidates: 

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

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

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

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

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

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

891 extra = "" 

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

893 return None 

894 

895 if key is None: 

896 message_format = "Missing key" 

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

898 lint_state.emit_diagnostic( 

899 key_range, 

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

901 unknown_keys_diagnostic_severity, 

902 "debputy", 

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

904 ) 

905 return corrected_key 

906 

907 

908def yaml_conflicting_key( 

909 lint_state: LintState, 

910 key_a: str, 

911 key_b: str, 

912 key_a_line: int, 

913 key_a_col: int, 

914 key_b_line: int, 

915 key_b_col: int, 

916) -> None: 

917 key_a_range = TERange( 

918 TEPosition( 

919 key_a_line, 

920 key_a_col, 

921 ), 

922 TEPosition( 

923 key_a_line, 

924 key_a_col + len(key_a), 

925 ), 

926 ) 

927 key_b_range = TERange( 

928 TEPosition( 

929 key_b_line, 

930 key_b_col, 

931 ), 

932 TEPosition( 

933 key_b_line, 

934 key_b_col + len(key_b), 

935 ), 

936 ) 

937 lint_state.emit_diagnostic( 

938 key_a_range, 

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

940 "error", 

941 "debputy", 

942 related_information=[ 

943 lint_state.related_diagnostic_information( 

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

945 ), 

946 ], 

947 ) 

948 

949 lint_state.emit_diagnostic( 

950 key_b_range, 

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

952 "error", 

953 "debputy", 

954 related_information=[ 

955 lint_state.related_diagnostic_information( 

956 key_a_range, 

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

958 ), 

959 ], 

960 ) 

961 

962 

963def resolve_keyword( 

964 current_parser: DeclarativeInputParser[Any] | DispatchingParserBase, 

965 current_plugin: DebputyPluginMetadata, 

966 segments: list[str | int], 

967 segment_idx: int, 

968 parser_generator: ParserGenerator, 

969 *, 

970 is_completion_attempt: bool = False, 

971) -> None | ( 

972 tuple[ 

973 DeclarativeInputParser[Any] | DispatchingParserBase, 

974 DebputyPluginMetadata, 

975 int, 

976 ] 

977): 

978 if segment_idx >= len(segments): 

979 return current_parser, current_plugin, segment_idx 

980 current_segment = segments[segment_idx] 

981 if isinstance(current_parser, ListWrappedDeclarativeInputParser): 

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

983 current_parser = current_parser.delegate 

984 segment_idx += 1 

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

986 return current_parser, current_plugin, segment_idx 

987 current_segment = segments[segment_idx] 

988 

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

990 return None 

991 

992 if is_completion_attempt and current_segment.endswith( 

993 (YAML_COMPLETION_HINT_KEY, YAML_COMPLETION_HINT_VALUE) 

994 ): 

995 return current_parser, current_plugin, segment_idx 

996 

997 if isinstance(current_parser, InPackageContextParser): 

998 return resolve_keyword( 

999 current_parser.delegate, 

1000 current_plugin, 

1001 segments, 

1002 segment_idx + 1, 

1003 parser_generator, 

1004 is_completion_attempt=is_completion_attempt, 

1005 ) 

1006 elif isinstance(current_parser, DispatchingParserBase): 

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

1008 if is_completion_attempt: 

1009 return current_parser, current_plugin, segment_idx 

1010 return None 

1011 subparser = current_parser.parser_for(current_segment) 

1012 segment_idx += 1 

1013 if segment_idx < len(segments): 

1014 return resolve_keyword( 

1015 subparser.parser, 

1016 subparser.plugin_metadata, 

1017 segments, 

1018 segment_idx, 

1019 parser_generator, 

1020 is_completion_attempt=is_completion_attempt, 

1021 ) 

1022 return subparser.parser, subparser.plugin_metadata, segment_idx 

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

1024 attr = current_parser.manifest_attributes.get(current_segment) 

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

1026 if ( 

1027 attr_type is not None 

1028 and isinstance(attr_type, type) 

1029 and issubclass(attr_type, DebputyDispatchableType) 

1030 ): 

1031 subparser = parser_generator.dispatch_parser_table_for(attr_type) 

1032 if subparser is not None and ( 

1033 is_completion_attempt or segment_idx + 1 < len(segments) 

1034 ): 

1035 return resolve_keyword( 

1036 subparser, 

1037 current_plugin, 

1038 segments, 

1039 segment_idx + 1, 

1040 parser_generator, 

1041 is_completion_attempt=is_completion_attempt, 

1042 ) 

1043 return current_parser, current_plugin, segment_idx 

1044 else: 

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

1046 return None 

1047 

1048 

1049def _trace_cursor( 

1050 content: Any, 

1051 attribute_path: AttributePath, 

1052 server_position: types.Position, 

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

1054 matched_key: str | int | None = None 

1055 matched: Node | None = None 

1056 matched_was_key: bool = False 

1057 

1058 if isinstance(content, CommentedMap): 

1059 dict_lc: LineCol = content.lc 

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

1061 k_lc = dict_lc.key(k) 

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

1063 break 

1064 v_lc = dict_lc.value(k) 

1065 if is_before(server_position, v_lc): 

1066 # TODO: Handle ":" and "whitespace" 

1067 matched = k 

1068 matched_key = k 

1069 matched_was_key = True 

1070 break 

1071 matched = v 

1072 matched_key = k 

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

1074 list_lc: LineCol = content.lc 

1075 for idx, value in enumerate(content): 

1076 i_lc = list_lc.item(idx) 

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

1078 break 

1079 matched_key = idx 

1080 matched = value 

1081 

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

1083 assert matched_key is not None 

1084 sub_path = attribute_path[matched_key] 

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

1086 return _trace_cursor(matched, sub_path, server_position) 

1087 return matched_was_key, sub_path, matched, content 

1088 return None 

1089 

1090 

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

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

1093 try: 

1094 float(v) 

1095 return f"'{v}'" 

1096 except ValueError: 

1097 pass 

1098 return v 

1099 

1100 

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

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

1103 return maybe_quote_yaml_value(v) 

1104 return str(v) 

1105 

1106 

1107def completion_from_attr( 

1108 attr: AttributeDescription, 

1109 pg: ParserGenerator, 

1110 matched: Any, 

1111 *, 

1112 matched_key: bool = False, 

1113 has_colon: bool = False, 

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

1115 type_mapping = pg.get_mapped_type_from_target_type(attr.attribute_type) 

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

1117 attr_type = type_mapping.source_type 

1118 else: 

1119 attr_type = attr.attribute_type 

1120 

1121 orig = get_origin(attr_type) 

1122 valid_values: Sequence[Any] = tuple() 

1123 

1124 if orig == Literal: 

1125 valid_values = get_args(attr_type) 

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

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

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

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

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

1131 return None 

1132 valid_values = [ 

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

1134 for k in parser.registered_keywords() 

1135 if isinstance( 

1136 parser.parser_for(k).parser, DeclarativeValuelessKeywordInputParser 

1137 ) 

1138 ^ matched_key 

1139 ] 

1140 

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

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

1143 return None 

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

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

1146 return None 

1147 

1148 

1149def completion_item( 

1150 quoted_keyword: str, 

1151 pp_subparser: PluginProvidedParser, 

1152) -> types.CompletionItem: 

1153 inline_reference_documentation = pp_subparser.parser.inline_reference_documentation 

1154 synopsis = ( 

1155 inline_reference_documentation.synopsis 

1156 if inline_reference_documentation 

1157 else None 

1158 ) 

1159 return types.CompletionItem( 

1160 quoted_keyword, 

1161 detail=synopsis, 

1162 ) 

1163 

1164 

1165def _is_inside_manifest_variable_substitution( 

1166 lines: list[str], 

1167 server_position: types.Position, 

1168) -> bool: 

1169 

1170 current_line = lines[server_position.line] 

1171 try: 

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

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

1174 except ValueError: 

1175 return False 

1176 

1177 

1178def _manifest_substitution_variable_at_position( 

1179 lines: list[str], 

1180 server_position: types.Position, 

1181) -> str | None: 

1182 current_line = lines[server_position.line] 

1183 try: 

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

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

1186 return None 

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

1188 close_idx = open_idx + variable_len 

1189 except ValueError as e: 

1190 return None 

1191 return current_line[open_idx:close_idx] 

1192 

1193 

1194def _insert_complete_marker_and_parse_yaml( 

1195 lines: list[str], 

1196 server_position: types.Position, 

1197) -> Any | None: 

1198 added_key = insert_complete_marker_snippet(lines, server_position) 

1199 attempts = 1 if added_key else 2 

1200 content = None 

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

1202 attempts -= 1 

1203 try: 

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

1205 # be used here. 

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

1207 break 

1208 except MarkedYAMLError as e: 

1209 context_line = ( 

1210 e.context_mark.line if e.context_mark else e.problem_mark.line 

1211 ) 

1212 if ( 

1213 e.problem_mark.line != server_position.line 

1214 and context_line != server_position.line 

1215 ): 

1216 l_data = ( 

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

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

1219 else "N/A (OOB)" 

1220 ) 

1221 

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

1223 return None 

1224 

1225 if attempts > 0: 

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

1227 new_line = ( 

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

1229 ) 

1230 lines[server_position.line] = new_line 

1231 except YAMLError: 

1232 break 

1233 return content 

1234 

1235 

1236def generic_yaml_completer( 

1237 ls: "DebputyLanguageServer", 

1238 params: types.CompletionParams, 

1239 root_parser: DeclarativeInputParser[Any], 

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

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

1242 lines = _lines(doc.lines) 

1243 server_position = doc.position_codec.position_from_client_units( 

1244 lines, params.position 

1245 ) 

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

1247 has_colon = ":" in orig_line 

1248 

1249 content = _insert_complete_marker_and_parse_yaml(lines, server_position) 

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

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

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

1253 return None 

1254 attribute_root_path = AttributePath.root_path(content) 

1255 m = _trace_cursor(content, attribute_root_path, server_position) 

1256 

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

1258 _info("No match") 

1259 return None 

1260 matched_key, attr_path, matched, parent = m 

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

1262 feature_set = ls.plugin_feature_set 

1263 segments = list(attr_path.path_segments()) 

1264 km = resolve_keyword( 

1265 root_parser, 

1266 DEBPUTY_PLUGIN_METADATA, 

1267 segments, 

1268 0, 

1269 feature_set.manifest_parser_generator, 

1270 is_completion_attempt=True, 

1271 ) 

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

1273 return None 

1274 parser, _, at_depth_idx = km 

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

1276 items = [] 

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

1278 return items 

1279 

1280 if _is_inside_manifest_variable_substitution(lines, server_position): 

1281 return [ 

1282 types.CompletionItem( 

1283 pv.variable_name, 

1284 detail=pv.variable_reference_documentation, 

1285 sort_text=( 

1286 f"zz-{pv.variable_name}" 

1287 if pv.is_for_special_case 

1288 else pv.variable_name 

1289 ), 

1290 ) 

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

1292 if not pv.is_internal 

1293 ] 

1294 

1295 if isinstance(parser, DispatchingParserBase): 

1296 if matched_key: 

1297 items = [ 

1298 completion_item( 

1299 ( 

1300 maybe_quote_yaml_value(k) 

1301 if has_colon 

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

1303 ), 

1304 pp_subparser, 

1305 ) 

1306 for k, pp_subparser in _keywords_with_parser(parser) 

1307 if k not in parent 

1308 and not isinstance( 

1309 pp_subparser.parser, 

1310 DeclarativeValuelessKeywordInputParser, 

1311 ) 

1312 ] 

1313 else: 

1314 items = [ 

1315 completion_item(maybe_quote_yaml_value(k), pp_subparser) 

1316 for k, pp_subparser in _keywords_with_parser(parser) 

1317 if k not in parent 

1318 and isinstance( 

1319 pp_subparser.parser, 

1320 DeclarativeValuelessKeywordInputParser, 

1321 ) 

1322 ] 

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

1324 binary_packages = ls.lint_state(doc).binary_packages 

1325 if binary_packages is not None: 

1326 items = [ 

1327 types.CompletionItem( 

1328 maybe_quote_yaml_value(p) 

1329 if has_colon 

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

1331 ) 

1332 for p in binary_packages 

1333 if p not in parent 

1334 ] 

1335 elif isinstance(parser, DeclarativeMappingInputParser): 

1336 if matched_key: 

1337 _info("Match attributes") 

1338 locked = set(parent) 

1339 for mx in parser.mutually_exclusive_attributes: 

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

1341 locked.update(mx) 

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

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

1344 locked.add(attr_name) 

1345 break 

1346 items = [ 

1347 types.CompletionItem( 

1348 maybe_quote_yaml_value(k) 

1349 if has_colon 

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

1351 ) 

1352 for k in parser.manifest_attributes 

1353 if k not in locked 

1354 ] 

1355 else: 

1356 # Value 

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

1358 value_attr = ( 

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

1360 ) 

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

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

1363 return completion_from_attr( 

1364 value_attr, 

1365 feature_set.manifest_parser_generator, 

1366 matched, 

1367 matched_key=False, 

1368 has_colon=has_colon, 

1369 ) 

1370 else: 

1371 _info( 

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

1373 ) 

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

1375 alt_attr = parser.alt_form_parser 

1376 return completion_from_attr( 

1377 alt_attr, 

1378 feature_set.manifest_parser_generator, 

1379 matched, 

1380 matched_key=matched_key, 

1381 has_colon=has_colon, 

1382 ) 

1383 return items 

1384 

1385 

1386def generic_yaml_hover( 

1387 ls: "DebputyLanguageServer", 

1388 params: types.HoverParams, 

1389 root_parser_initializer: Callable[ 

1390 [ParserGenerator], DeclarativeInputParser[Any] | DispatchingParserBase 

1391 ], 

1392 *, 

1393 show_integration_mode: bool = False, 

1394) -> types.Hover | None: 

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

1396 lines = doc.lines 

1397 position_codec = doc.position_codec 

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

1399 

1400 try: 

1401 content = MANIFEST_YAML.load(doc.source) 

1402 except YAMLError: 

1403 return None 

1404 attribute_root_path = AttributePath.root_path(content) 

1405 m = _trace_cursor(content, attribute_root_path, server_position) 

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

1407 _info("No match") 

1408 return None 

1409 matched_key, attr_path, matched, _ = m 

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

1411 

1412 feature_set = ls.plugin_feature_set 

1413 parser_generator = feature_set.manifest_parser_generator 

1414 root_parser = root_parser_initializer(parser_generator) 

1415 segments = list(attr_path.path_segments()) 

1416 km = resolve_keyword( 

1417 root_parser, 

1418 DEBPUTY_PLUGIN_METADATA, 

1419 segments, 

1420 0, 

1421 parser_generator, 

1422 ) 

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

1424 _info("No keyword match") 

1425 return None 

1426 parser, plugin_metadata, at_depth_idx = km 

1427 

1428 manifest_variable_at_pos = _manifest_substitution_variable_at_position( 

1429 lines, server_position 

1430 ) 

1431 

1432 if manifest_variable_at_pos: 

1433 variable = ls.plugin_feature_set.manifest_variables.get( 

1434 manifest_variable_at_pos 

1435 ) 

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

1437 var_doc = ( 

1438 variable.variable_reference_documentation 

1439 or "No documentation available" 

1440 ) 

1441 

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

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

1444 else: 

1445 debian_dir = ls.lint_state(doc).debian_dir 

1446 value = "" 

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

1448 variable_context = VariableContext(debian_dir) 

1449 try: 

1450 resolved = variable.resolve(variable_context) 

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

1452 except RuntimeError: 

1453 pass 

1454 

1455 hover_doc_text = textwrap.dedent( 

1456 """\ 

1457 # `{NAME}` 

1458 

1459 {DOC} 

1460 {VALUE} 

1461 """ 

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

1463 return as_hover_doc(ls, hover_doc_text) 

1464 

1465 _info( 

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

1467 ) 

1468 hover_doc_text = resolve_hover_text( 

1469 feature_set, 

1470 parser, 

1471 plugin_metadata, 

1472 ls.hover_output_style, 

1473 show_integration_mode, 

1474 segments, 

1475 at_depth_idx, 

1476 matched, 

1477 matched_key, 

1478 ) 

1479 return as_hover_doc(ls, hover_doc_text)