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

683 statements  

« prev     ^ index     » next       coverage.py v7.8.2, created at 2026-09-06 13:40 +0000

1import enum 

2import textwrap 

3from enum import StrEnum 

4from typing import ( 

5 Union, 

6 Any, 

7 Optional, 

8 List, 

9 Tuple, 

10 TYPE_CHECKING, 

11 get_origin, 

12 Literal, 

13 get_args, 

14 Generic, 

15 cast, 

16) 

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

18 

19from debputy.commands.debputy_cmd.output import OutputStyle 

20from debputy.linting.lint_util import LintState 

21from debputy.lsp.diagnostics import LintSeverity 

22from debputy.lsp.quickfixes import propose_correct_text_quick_fix 

23from debian._deb822_repro.locatable import ( 

24 Position as TEPosition, 

25 Range as TERange, 

26) 

27from debputy.manifest_parser.declarative_parser import ( 

28 DeclarativeMappingInputParser, 

29 ParserGenerator, 

30 AttributeDescription, 

31 DeclarativeNonMappingInputParser, 

32 BASIC_SIMPLE_TYPES, 

33) 

34from debputy.manifest_parser.parser_doc import ( 

35 render_rule, 

36 render_attribute_doc, 

37 doc_args_for_parser_doc, 

38) 

39from debputy.manifest_parser.tagging_types import DebputyDispatchableType 

40from debputy.manifest_parser.util import AttributePath 

41from debputy.plugin.api.feature_set import PluginProvidedFeatureSet 

42from debputy.plugin.api.impl import plugin_metadata_for_debputys_own_plugin 

43from debputy.plugin.api.impl_types import ( 

44 DebputyPluginMetadata, 

45 DeclarativeInputParser, 

46 DispatchingParserBase, 

47 InPackageContextParser, 

48 ListWrappedDeclarativeInputParser, 

49 PluginProvidedParser, 

50 DeclarativeValuelessKeywordInputParser, 

51 DispatchingTableParser, 

52 AllowNoneDeclarativeInputParser, 

53) 

54from debputy.substitution import VariableContext 

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

56from debputy.yaml import MANIFEST_YAML 

57from debputy.yaml.compat import ( 

58 MarkedYAMLError, 

59 YAMLError, 

60) 

61from debputy.yaml.compat import ( 

62 Node, 

63 CommentedMap, 

64 LineCol, 

65 CommentedSeq, 

66 CommentedBase, 

67) 

68 

69if TYPE_CHECKING: 

70 import lsprotocol.types as types 

71else: 

72 import debputy.lsprotocol.types as types 

73 

74try: 

75 from pygls.server import LanguageServer 

76 from debputy.lsp.debputy_ls import DebputyLanguageServer 

77except ImportError: 

78 pass 

79 

80 

81YAML_COMPLETION_HINT_KEY = "___COMPLETE:" 

82YAML_COMPLETION_HINT_VALUE = "___COMPLETE" 

83DEBPUTY_PLUGIN_METADATA = plugin_metadata_for_debputys_own_plugin() 

84 

85 

86class LSPYAMLHelper(Generic[T]): 

87 

88 def __init__( 

89 self, 

90 lint_state: LintState, 

91 pg: ParserGenerator, 

92 custom_data: T, 

93 ) -> None: 

94 self.lint_state = lint_state 

95 self.lines = _lines(lint_state.lines) 

96 self.pg = pg 

97 self.custom_data = custom_data 

98 

99 def _validate_subparser_is_valid_here( 

100 self, 

101 subparser: PluginProvidedParser, 

102 orig_key: str, 

103 line: int, 

104 col: int, 

105 ) -> None: 

106 # Subclasses can provide custom logic here 

107 pass 

108 

109 def _lint_dispatch_parser( 

110 self, 

111 parser: DispatchingParserBase, 

112 dispatch_key: str, 

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

114 value: Any | None, 

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

116 *, 

117 is_keyword_only: bool, 

118 ) -> None: 

119 is_known = parser.is_known_keyword(dispatch_key) 

120 orig_key = dispatch_key 

121 if not is_known and key_pos is not None: 

122 

123 if value is None: 

124 opts = { 

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

126 } 

127 else: 

128 opts = {} 

129 corrected_key = yaml_flag_unknown_key( 

130 self.lint_state, 

131 dispatch_key, 

132 parser.registered_keywords(), 

133 key_pos, 

134 unknown_keys_diagnostic_severity=parser.unknown_keys_diagnostic_severity, 

135 **opts, 

136 ) 

137 if corrected_key is not None: 

138 dispatch_key = corrected_key 

139 is_known = True 

140 

141 if is_known: 

142 subparser = parser.parser_for(dispatch_key) 

143 assert subparser is not None 

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

145 line, col = key_pos 

146 self._validate_subparser_is_valid_here( 

147 subparser, 

148 orig_key, 

149 line, 

150 col, 

151 ) 

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

153 isinstance(subparser.parser, AllowNoneDeclarativeInputParser) 

154 and value is None 

155 ): 

156 return 

157 if isinstance(subparser.parser, DeclarativeValuelessKeywordInputParser): 

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

159 if value is not None: 

160 line_no, cursor_pos = value_pos if value_pos else key_pos 

161 value_range = self._remaining_line(line_no, cursor_pos) 

162 if _is_empty_range(value_range): 

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

164 # an empty range, report the key instead. 

165 line_no, cursor_pos = key_pos 

166 value_range = self._remaining_line(line_no, cursor_pos) 

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

168 else: 

169 line_no, cursor_pos = key_pos 

170 value_range = self._remaining_line(line_no, cursor_pos) 

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

172 

173 assert not _is_empty_range(value_range) 

174 self.lint_state.emit_diagnostic( 

175 value_range, 

176 msg, 

177 "error", 

178 "debputy", 

179 ) 

180 return 

181 

182 self.lint_content( 

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

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

185 value, 

186 key=orig_key, 

187 content_pos=value_pos, 

188 ) 

189 

190 def lint_content( 

191 self, 

192 parser: DeclarativeInputParser[Any], 

193 content: Any, 

194 *, 

195 key: str | int | None = None, 

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

197 ) -> None: 

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

199 parser = parser.delegate 

200 if isinstance(parser, DispatchingParserBase): 

201 if isinstance(content, str): 

202 self._lint_dispatch_parser( 

203 parser, 

204 content, 

205 content_pos, 

206 None, 

207 None, 

208 is_keyword_only=True, 

209 ) 

210 

211 return 

212 if not isinstance(content, CommentedMap): 

213 return 

214 lc = content.lc 

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

216 key_pos = lc.key(dispatch_key) 

217 value_pos = lc.value(dispatch_key) 

218 self._lint_dispatch_parser( 

219 parser, 

220 dispatch_key, 

221 key_pos, 

222 value, 

223 value_pos, 

224 is_keyword_only=False, 

225 ) 

226 

227 elif isinstance(parser, ListWrappedDeclarativeInputParser): 

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

229 return 

230 subparser = parser.delegate 

231 lc = content.lc 

232 for idx, value in enumerate(content): 

233 value_pos = lc.item(idx) 

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

235 elif isinstance(parser, InPackageContextParser): 

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

237 return 

238 known_packages = self.lint_state.binary_packages 

239 lc = content.lc 

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

241 if k is None or ( 

242 "{{" not in k 

243 and known_packages is not None 

244 and k not in known_packages 

245 ): 

246 yaml_flag_unknown_key( 

247 self.lint_state, 

248 k, 

249 known_packages, 

250 lc.key(k), 

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

252 ) 

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

254 elif isinstance(parser, DeclarativeMappingInputParser): 

255 self._lint_declarative_mapping_input_parser( 

256 parser, 

257 content, 

258 content_pos, 

259 key=key, 

260 ) 

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

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

263 self._lint_attr_value( 

264 parser.alt_form_parser, 

265 key, 

266 content, 

267 content_pos, 

268 ) 

269 

270 def _lint_declarative_mapping_input_parser( 

271 self, 

272 parser: DeclarativeMappingInputParser, 

273 content: Any, 

274 content_pos: tuple[int, int], 

275 *, 

276 key: str | int | None = None, 

277 ) -> None: 

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

279 alt_form_parser = parser.alt_form_parser 

280 if alt_form_parser: 

281 self._lint_attr_value( 

282 alt_form_parser, 

283 key, 

284 content, 

285 content_pos, 

286 ) 

287 else: 

288 line_no, cursor_pos = content_pos 

289 value_range = self._remaining_line(line_no, cursor_pos) 

290 if _is_empty_range(value_range): 

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

292 return 

293 if isinstance(key, str): 

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

295 else: 

296 msg = "The value must be a mapping" 

297 self.lint_state.emit_diagnostic( 

298 value_range, 

299 msg, 

300 "error", 

301 "debputy", 

302 ) 

303 return 

304 lc = content.lc 

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

306 attr = parser.manifest_attributes.get(key) 

307 key_pos = lc.key(key) 

308 value_pos = lc.value(key) 

309 if attr is None: 

310 corrected_key = yaml_flag_unknown_key( 

311 self.lint_state, 

312 key, 

313 parser.manifest_attributes, 

314 key_pos, 

315 ) 

316 if corrected_key: 

317 key = corrected_key 

318 attr = parser.manifest_attributes.get(corrected_key) 

319 if attr is None: 

320 continue 

321 

322 self._lint_attr_value( 

323 attr, 

324 key, 

325 value, 

326 value_pos, 

327 ) 

328 

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

330 if forbidden_key in content: 

331 line, col = key_pos 

332 con_line, con_col = lc.key(forbidden_key) 

333 yaml_conflicting_key( 

334 self.lint_state, 

335 key, 

336 forbidden_key, 

337 line, 

338 col, 

339 con_line, 

340 con_col, 

341 ) 

342 for mx in parser.mutually_exclusive_attributes: 

343 matches = content.keys() & mx 

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

345 continue 

346 key, *others = list(matches) 

347 line, col = lc.key(key) 

348 for other in others: 

349 con_line, con_col = lc.key(other) 

350 yaml_conflicting_key( 

351 self.lint_state, 

352 key, 

353 other, 

354 line, 

355 col, 

356 con_line, 

357 con_col, 

358 ) 

359 

360 def _type_based_value_check( 

361 self, 

362 target_attr_type: type, 

363 value: Any, 

364 value_pos: tuple[int, int], 

365 *, 

366 key: str | int | None = None, 

367 ) -> bool: 

368 if issubclass(target_attr_type, DebputyDispatchableType): 

369 parser = self.pg.dispatch_parser_table_for(target_attr_type) 

370 self.lint_content( 

371 parser, 

372 value, 

373 key=key, 

374 content_pos=value_pos, 

375 ) 

376 return True 

377 if ( 

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

379 and (lint_validator := type_mapper.lint_validator) 

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

381 # value_pos + remaining_line leads to an empty range. 

382 and value is not None 

383 and "{{" not in value 

384 ): 

385 try: 

386 lint_validator(value, AttributePath.highlighted_range()) 

387 except Exception as e: 

388 line_no, cursor_pos = value_pos 

389 value_range = self._remaining_line(line_no, cursor_pos) 

390 self.lint_state.emit_diagnostic( 

391 value_range, 

392 str(e), 

393 "error", 

394 "debputy", 

395 ) 

396 return True 

397 return False 

398 

399 def _lint_attr_value( 

400 self, 

401 attr: AttributeDescription, 

402 key: str | int | None, 

403 value: Any, 

404 pos: tuple[int, int], 

405 ) -> None: 

406 target_attr_type = attr.attribute_type 

407 orig = get_origin(target_attr_type) 

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

409 lc = value.lc 

410 target_item_type = get_args(target_attr_type)[0] 

411 for idx, v in enumerate(value): 

412 v_pos = lc.item(idx) 

413 self._lint_value( 

414 idx, 

415 v, 

416 target_item_type, 

417 v_pos, 

418 ) 

419 

420 else: 

421 self._lint_value( 

422 key, 

423 value, 

424 target_attr_type, 

425 pos, 

426 ) 

427 

428 def _lint_value( 

429 self, 

430 key: str | int | None, 

431 value: Any, 

432 target_attr_type: Any, 

433 pos: tuple[int, int], 

434 ) -> None: 

435 type_mapping = self.pg.get_mapped_type_from_target_type(target_attr_type) 

436 source_attr_type = target_attr_type 

437 if type_mapping is not None: 

438 source_attr_type = type_mapping.source_type 

439 valid_values: Sequence[Any] | None = None 

440 orig = get_origin(source_attr_type) 

441 is_type = isinstance(target_attr_type, type) 

442 if orig == Literal: 

443 valid_values = get_args(target_attr_type) 

444 elif is_type and issubclass(target_attr_type, enum.StrEnum): 444 ↛ 445line 444 didn't jump to line 445 because the condition on line 444 was never true

445 valid_values = [str(v) for v in target_attr_type] 

446 elif orig == bool or target_attr_type == bool: 

447 valid_values = (True, False) 

448 elif is_type and self._type_based_value_check( 

449 target_attr_type, 

450 value, 

451 pos, 

452 key=key, 

453 ): 

454 return 

455 elif source_attr_type in BASIC_SIMPLE_TYPES: 

456 if isinstance(value, source_attr_type): 

457 return 

458 expected_type = BASIC_SIMPLE_TYPES[source_attr_type] 

459 line_no, cursor_pos = pos 

460 value_range = self._remaining_line(line_no, cursor_pos) 

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

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

463 return 

464 if isinstance(key, str): 

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

466 else: 

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

468 if issubclass(source_attr_type, str): 

469 quickfixes = [ 

470 propose_correct_text_quick_fix(_as_yaml_value(str(value))) 

471 ] 

472 else: 

473 quickfixes = None 

474 self.lint_state.emit_diagnostic( 

475 value_range, 

476 msg, 

477 "error", 

478 "debputy", 

479 quickfixes=quickfixes, 

480 ) 

481 return 

482 

483 if valid_values is None or value in valid_values: 

484 return 

485 line_no, cursor_pos = pos 

486 value_range = self._remaining_line(line_no, cursor_pos) 

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

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

489 return 

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

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

492 else: 

493 msg = "Not a supported value here" 

494 self.lint_state.emit_diagnostic( 

495 value_range, 

496 msg, 

497 "error", 

498 "debputy", 

499 quickfixes=[ 

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

501 ], 

502 ) 

503 

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

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

506 pos_end = len(raw_line) 

507 return TERange( 

508 TEPosition( 

509 line_no, 

510 pos_start, 

511 ), 

512 TEPosition( 

513 line_no, 

514 pos_end, 

515 ), 

516 ) 

517 

518 

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

520 return token_range.start_pos == token_range.end_pos 

521 

522 

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

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

525 lines = lines.copy() 

526 lines.append("") 

527 return lines 

528 

529 

530async def generic_yaml_lint( 

531 lint_state: LintState, 

532 root_parser: DeclarativeInputParser[Any], 

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

534) -> None: 

535 lines = _lines(lint_state.lines) 

536 try: 

537 content = MANIFEST_YAML.load(lint_state.content) 

538 except MarkedYAMLError as e: 

539 if e.context_mark: 

540 line = e.context_mark.line 

541 column = e.context_mark.column 

542 else: 

543 line = e.problem_mark.line 

544 column = e.problem_mark.column 

545 error_range = error_range_at_position( 

546 lines, 

547 line, 

548 column, 

549 ) 

550 lint_state.emit_diagnostic( 

551 error_range, 

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

553 "error", 

554 "debputy", 

555 ) 

556 except YAMLError as e: 

557 error_range = TERange( 

558 TEPosition(0, 0), 

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

560 ) 

561 lint_state.emit_diagnostic( 

562 error_range, 

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

564 "error", 

565 "debputy", 

566 ) 

567 else: 

568 yaml_linter = initialize_yaml_helper(lint_state) 

569 yaml_linter.lint_content( 

570 root_parser, 

571 content, 

572 ) 

573 

574 

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

576 if isinstance(v, bool): 

577 return str(v).lower() 

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

579 return maybe_quote_yaml_value(str(v)) 

580 return str(v) 

581 

582 

583def resolve_hover_text_for_value( 

584 feature_set: PluginProvidedFeatureSet, 

585 parser: DeclarativeMappingInputParser, 

586 plugin_metadata: DebputyPluginMetadata, 

587 output_style: OutputStyle, 

588 show_integration_mode: bool, 

589 segment: str | int, 

590 matched: Any, 

591) -> str | None: 

592 

593 hover_doc_text: str | None = None 

594 attr = parser.manifest_attributes.get(segment) 

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

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

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

598 return None 

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

600 parser_generator = feature_set.manifest_parser_generator 

601 parser = parser_generator.dispatch_parser_table_for(attr_type) 

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

603 _info( 

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

605 ) 

606 return None 

607 subparser = parser.parser_for(matched) 

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

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

610 return None 

611 hover_doc_text = render_rule( 

612 matched, 

613 subparser.parser, 

614 plugin_metadata, 

615 output_style, 

616 show_integration_mode=show_integration_mode, 

617 ) 

618 else: 

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

620 return hover_doc_text 

621 

622 

623def resolve_hover_text( 

624 feature_set: PluginProvidedFeatureSet, 

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

626 plugin_metadata: DebputyPluginMetadata, 

627 output_style: OutputStyle, 

628 show_integration_mode: bool, 

629 segments: list[str | int], 

630 at_depth_idx: int, 

631 matched: Any, 

632 matched_key: bool, 

633) -> str | None: 

634 hover_doc_text: str | None = None 

635 if at_depth_idx == len(segments): 

636 segment = segments[at_depth_idx - 1] 

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

638 hover_doc_text = render_rule( 

639 segment, 

640 parser, 

641 plugin_metadata, 

642 output_style, 

643 is_root_rule=False, 

644 show_integration_mode=show_integration_mode, 

645 ) 

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

647 parser, DeclarativeMappingInputParser 

648 ): 

649 segment = segments[at_depth_idx] 

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

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

652 if not matched_key: 

653 hover_doc_text = resolve_hover_text_for_value( 

654 feature_set, 

655 parser, 

656 plugin_metadata, 

657 output_style, 

658 show_integration_mode, 

659 segment, 

660 matched, 

661 ) 

662 if matched_key or hover_doc_text is None: 

663 rule_name = _guess_rule_name(segments, at_depth_idx) 

664 hover_doc_text = _render_param_doc( 

665 rule_name, 

666 parser, 

667 plugin_metadata, 

668 segment, 

669 ) 

670 else: 

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

672 

673 return hover_doc_text 

674 

675 

676def as_hover_doc( 

677 ls: "DebputyLanguageServer", 

678 hover_doc_text: str | None, 

679) -> types.Hover | None: 

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

681 return None 

682 return types.Hover( 

683 contents=types.MarkupContent( 

684 kind=ls.hover_markup_format( 

685 types.MarkupKind.Markdown, 

686 types.MarkupKind.PlainText, 

687 ), 

688 value=hover_doc_text, 

689 ), 

690 ) 

691 

692 

693def _render_param_doc( 

694 rule_name: str, 

695 declarative_parser: DeclarativeMappingInputParser, 

696 plugin_metadata: DebputyPluginMetadata, 

697 attribute: str, 

698) -> str | None: 

699 source_form_name = declarative_parser.source_form_attr2source_attributes.get( 

700 attribute 

701 ) 

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

703 return None 

704 attr = declarative_parser.source_attributes[source_form_name] 

705 

706 doc_args, parser_doc = doc_args_for_parser_doc( 

707 rule_name, 

708 declarative_parser, 

709 plugin_metadata, 

710 ) 

711 rendered_docs = render_attribute_doc( 

712 declarative_parser, 

713 declarative_parser.source_attributes, 

714 declarative_parser.input_time_required_parameters, 

715 declarative_parser.at_least_one_of, 

716 parser_doc, 

717 doc_args, 

718 is_interactive=True, 

719 rule_name=rule_name, 

720 ) 

721 

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

723 if source_form_name in attributes: 

724 full_doc = [ 

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

726 "", 

727 ] 

728 full_doc.extend(rendered_doc) 

729 

730 return "\n".join(full_doc) 

731 return None 

732 

733 

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

735 orig_idx = idx 

736 idx -= 1 

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

738 segment = segments[idx] 

739 if isinstance(segment, str): 

740 return segment 

741 idx -= 1 

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

743 return "<Bug: unknown rule name>" 

744 

745 

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

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

748 

749 

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

751 line, column = lc_pos 

752 if position.line < line: 

753 return True 

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

755 return True 

756 return False 

757 

758 

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

760 line, column = lc_pos 

761 if position.line > line: 

762 return True 

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

764 return True 

765 return False 

766 

767 

768def error_range_at_position( 

769 lines: list[str], 

770 line_no: int, 

771 char_offset: int, 

772) -> TERange: 

773 line = lines[line_no] 

774 line_len = len(line) 

775 start_idx = char_offset 

776 end_idx = start_idx 

777 

778 if line[start_idx].isspace(): 

779 

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

781 return not x.isspace() 

782 

783 else: 

784 

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

786 return x.isspace() 

787 

788 for i in range(end_idx, line_len): 

789 end_idx = i 

790 if _check(line[i]): 

791 break 

792 

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

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

795 break 

796 start_idx = i 

797 

798 return TERange( 

799 TEPosition(line_no, start_idx), 

800 TEPosition(line_no, end_idx), 

801 ) 

802 

803 

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

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

806 

807 

808def insert_complete_marker_snippet( 

809 lines: list[str], 

810 server_position: types.Position, 

811) -> bool: 

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

813 line_no = server_position.line 

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

815 

816 lhs_ws = line[: server_position.character] 

817 lhs = lhs_ws.strip() 

818 open_quote = "" 

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

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

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

822 break 

823 qc = lhs.count(q) & 1 

824 if qc: 

825 open_quote = q 

826 break 

827 

828 if lhs.endswith(":"): 

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

830 new_line = ( 

831 line[: server_position.character] 

832 + YAML_COMPLETION_HINT_VALUE 

833 + f"{open_quote}\n" 

834 ) 

835 elif lhs.startswith("-"): 

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

837 # Respect the provided indentation 

838 snippet = ( 

839 YAML_COMPLETION_HINT_KEY if ":" not in lhs else YAML_COMPLETION_HINT_VALUE 

840 ) 

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

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

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

844 # Respect the provided indentation 

845 snippet = ( 

846 YAML_COMPLETION_HINT_KEY if ":" not in lhs else YAML_COMPLETION_HINT_VALUE 

847 ) 

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

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

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

851 # Respect the provided indentation 

852 new_line = ( 

853 line[: server_position.character] 

854 + YAML_COMPLETION_HINT_KEY 

855 + f"{open_quote}\n" 

856 ) 

857 elif open_quote: 

858 _info( 

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

860 ) 

861 new_line = ( 

862 line[: server_position.character] 

863 + YAML_COMPLETION_HINT_VALUE 

864 + f"{open_quote}\n" 

865 ) 

866 else: 

867 c = ( 

868 line[server_position.character] 

869 if server_position.character < len(line) 

870 else "(OOB)" 

871 ) 

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

873 return False 

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

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

876 lines[line_no] = new_line 

877 elif line_no == len(lines): 

878 lines.append(new_line) 

879 else: 

880 return False 

881 return True 

882 

883 

884def _keywords_with_parser( 

885 parser: DeclarativeMappingInputParser | DispatchingParserBase, 

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

887 for keyword in parser.registered_keywords(): 

888 pp_subparser = parser.parser_for(keyword) 

889 yield keyword, pp_subparser 

890 

891 

892def yaml_key_range( 

893 key: str | None, 

894 line: int, 

895 col: int, 

896) -> "TERange": 

897 key_len = len(key) if key else 1 

898 return TERange.between( 

899 TEPosition(line, col), 

900 TEPosition(line, col + key_len), 

901 ) 

902 

903 

904def yaml_flag_unknown_key( 

905 lint_state: LintState, 

906 key: str | None, 

907 expected_keys: Iterable[str], 

908 key_pos: tuple[int, int], 

909 *, 

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

911 unknown_keys_diagnostic_severity: LintSeverity | None = "error", 

912) -> str | None: 

913 line, col = key_pos 

914 key_range = yaml_key_range(key, line, col) 

915 

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

917 extra = "" 

918 corrected_key = None 

919 if candidates: 

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

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

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

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

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

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

926 extra = "" 

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

928 return None 

929 

930 if key is None: 

931 message_format = "Missing key" 

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

933 lint_state.emit_diagnostic( 

934 key_range, 

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

936 unknown_keys_diagnostic_severity, 

937 "debputy", 

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

939 ) 

940 return corrected_key 

941 

942 

943def yaml_conflicting_key( 

944 lint_state: LintState, 

945 key_a: str, 

946 key_b: str, 

947 key_a_line: int, 

948 key_a_col: int, 

949 key_b_line: int, 

950 key_b_col: int, 

951) -> None: 

952 key_a_range = TERange( 

953 TEPosition( 

954 key_a_line, 

955 key_a_col, 

956 ), 

957 TEPosition( 

958 key_a_line, 

959 key_a_col + len(key_a), 

960 ), 

961 ) 

962 key_b_range = TERange( 

963 TEPosition( 

964 key_b_line, 

965 key_b_col, 

966 ), 

967 TEPosition( 

968 key_b_line, 

969 key_b_col + len(key_b), 

970 ), 

971 ) 

972 lint_state.emit_diagnostic( 

973 key_a_range, 

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

975 "error", 

976 "debputy", 

977 related_information=[ 

978 lint_state.related_diagnostic_information( 

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

980 ), 

981 ], 

982 ) 

983 

984 lint_state.emit_diagnostic( 

985 key_b_range, 

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

987 "error", 

988 "debputy", 

989 related_information=[ 

990 lint_state.related_diagnostic_information( 

991 key_a_range, 

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

993 ), 

994 ], 

995 ) 

996 

997 

998def resolve_keyword( 

999 current_parser: DeclarativeInputParser[Any] | DispatchingParserBase, 

1000 current_plugin: DebputyPluginMetadata, 

1001 segments: list[str | int], 

1002 segment_idx: int, 

1003 parser_generator: ParserGenerator, 

1004 *, 

1005 is_completion_attempt: bool = False, 

1006) -> None | ( 

1007 tuple[ 

1008 DeclarativeInputParser[Any] | DispatchingParserBase, 

1009 DebputyPluginMetadata, 

1010 int, 

1011 ] 

1012): 

1013 if segment_idx >= len(segments): 

1014 return current_parser, current_plugin, segment_idx 

1015 current_segment = segments[segment_idx] 

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

1017 current_parser = current_parser.delegate 

1018 if isinstance(current_parser, ListWrappedDeclarativeInputParser): 

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

1020 current_parser = current_parser.delegate 

1021 segment_idx += 1 

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

1023 return current_parser, current_plugin, segment_idx 

1024 current_segment = segments[segment_idx] 

1025 

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

1027 return None 

1028 

1029 if is_completion_attempt and current_segment.endswith( 

1030 (YAML_COMPLETION_HINT_KEY, YAML_COMPLETION_HINT_VALUE) 

1031 ): 

1032 return current_parser, current_plugin, segment_idx 

1033 

1034 if isinstance(current_parser, InPackageContextParser): 

1035 return resolve_keyword( 

1036 current_parser.delegate, 

1037 current_plugin, 

1038 segments, 

1039 segment_idx + 1, 

1040 parser_generator, 

1041 is_completion_attempt=is_completion_attempt, 

1042 ) 

1043 elif isinstance(current_parser, DispatchingParserBase): 

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

1045 if is_completion_attempt: 

1046 return current_parser, current_plugin, segment_idx 

1047 return None 

1048 subparser = current_parser.parser_for(current_segment) 

1049 segment_idx += 1 

1050 if segment_idx < len(segments): 

1051 return resolve_keyword( 

1052 subparser.parser, 

1053 subparser.plugin_metadata, 

1054 segments, 

1055 segment_idx, 

1056 parser_generator, 

1057 is_completion_attempt=is_completion_attempt, 

1058 ) 

1059 return subparser.parser, subparser.plugin_metadata, segment_idx 

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

1061 attr = current_parser.manifest_attributes.get(current_segment) 

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

1063 if ( 

1064 attr_type is not None 

1065 and isinstance(attr_type, type) 

1066 and issubclass(attr_type, DebputyDispatchableType) 

1067 ): 

1068 subparser = parser_generator.dispatch_parser_table_for(attr_type) 

1069 if subparser is not None and ( 

1070 is_completion_attempt or segment_idx + 1 < len(segments) 

1071 ): 

1072 return resolve_keyword( 

1073 subparser, 

1074 current_plugin, 

1075 segments, 

1076 segment_idx + 1, 

1077 parser_generator, 

1078 is_completion_attempt=is_completion_attempt, 

1079 ) 

1080 return current_parser, current_plugin, segment_idx 

1081 else: 

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

1083 return None 

1084 

1085 

1086def _trace_cursor( 

1087 content: Any, 

1088 attribute_path: AttributePath, 

1089 server_position: types.Position, 

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

1091 matched_key: str | int | None = None 

1092 matched: Node | None = None 

1093 matched_was_key: bool = False 

1094 

1095 if isinstance(content, CommentedMap): 

1096 dict_lc: LineCol = content.lc 

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

1098 k_lc = dict_lc.key(k) 

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

1100 break 

1101 v_lc = dict_lc.value(k) 

1102 if is_before(server_position, v_lc): 

1103 # TODO: Handle ":" and "whitespace" 

1104 matched = k 

1105 matched_key = k 

1106 matched_was_key = True 

1107 break 

1108 matched = v 

1109 matched_key = k 

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

1111 list_lc: LineCol = content.lc 

1112 for idx, value in enumerate(content): 

1113 i_lc = list_lc.item(idx) 

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

1115 break 

1116 matched_key = idx 

1117 matched = value 

1118 

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

1120 assert matched_key is not None 

1121 sub_path = attribute_path[matched_key] 

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

1123 return _trace_cursor(matched, sub_path, server_position) 

1124 return matched_was_key, sub_path, matched, content 

1125 return None 

1126 

1127 

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

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

1130 try: 

1131 float(v) 

1132 return f"'{v}'" 

1133 except ValueError: 

1134 pass 

1135 return v 

1136 

1137 

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

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

1140 return maybe_quote_yaml_value(v) 

1141 return str(v) 

1142 

1143 

1144def completion_from_attr( 

1145 attr: AttributeDescription, 

1146 pg: ParserGenerator, 

1147 matched: Any, 

1148 *, 

1149 matched_key: bool = False, 

1150 has_colon: bool = False, 

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

1152 type_mapping = pg.get_mapped_type_from_target_type(attr.attribute_type) 

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

1154 attr_type = type_mapping.source_type 

1155 else: 

1156 attr_type = attr.attribute_type 

1157 

1158 orig = get_origin(attr_type) 

1159 valid_values: Sequence[Any] = () 

1160 is_type = isinstance(attr_type, type) 

1161 

1162 if orig == Literal: 

1163 valid_values = get_args(attr_type) 

1164 elif is_type and issubclass(attr_type, enum.StrEnum): 1164 ↛ 1165line 1164 didn't jump to line 1165 because the condition on line 1164 was never true

1165 valid_values = [str(v) for v in attr_type] 

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

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

1168 elif is_type and issubclass(attr_type, DebputyDispatchableType): 1168 ↛ 1185line 1168 didn't jump to line 1185 because the condition on line 1168 was always true

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

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

1171 return None 

1172 valid_values = [ 

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

1174 for k in parser.registered_keywords() 

1175 if isinstance( 

1176 parser.parser_for(k).parser, 

1177 ( 

1178 DeclarativeValuelessKeywordInputParser, 

1179 AllowNoneDeclarativeInputParser, 

1180 ), 

1181 ) 

1182 ^ matched_key 

1183 ] 

1184 

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

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

1187 return None 

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

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

1190 return None 

1191 

1192 

1193def completion_item( 

1194 quoted_keyword: str, 

1195 pp_subparser: PluginProvidedParser, 

1196) -> types.CompletionItem: 

1197 inline_reference_documentation = pp_subparser.parser.inline_reference_documentation 

1198 synopsis = ( 

1199 inline_reference_documentation.synopsis 

1200 if inline_reference_documentation 

1201 else None 

1202 ) 

1203 return types.CompletionItem( 

1204 quoted_keyword, 

1205 detail=synopsis, 

1206 ) 

1207 

1208 

1209def _is_inside_manifest_variable_substitution( 

1210 lines: list[str], 

1211 server_position: types.Position, 

1212) -> bool: 

1213 

1214 current_line = lines[server_position.line] 

1215 try: 

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

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

1218 except ValueError: 

1219 return False 

1220 

1221 

1222def _manifest_substitution_variable_at_position( 

1223 lines: list[str], 

1224 server_position: types.Position, 

1225) -> str | None: 

1226 current_line = lines[server_position.line] 

1227 try: 

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

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

1230 return None 

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

1232 close_idx = open_idx + variable_len 

1233 except ValueError as e: 

1234 return None 

1235 return current_line[open_idx:close_idx] 

1236 

1237 

1238def _insert_complete_marker_and_parse_yaml( 

1239 lines: list[str], 

1240 server_position: types.Position, 

1241) -> Any | None: 

1242 added_key = insert_complete_marker_snippet(lines, server_position) 

1243 attempts = 1 if added_key else 2 

1244 content = None 

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

1246 attempts -= 1 

1247 try: 

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

1249 # be used here. 

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

1251 break 

1252 except MarkedYAMLError as e: 

1253 context_line = ( 

1254 e.context_mark.line if e.context_mark else e.problem_mark.line 

1255 ) 

1256 if ( 

1257 e.problem_mark.line != server_position.line 

1258 and context_line != server_position.line 

1259 ): 

1260 l_data = ( 

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

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

1263 else "N/A (OOB)" 

1264 ) 

1265 

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

1267 return None 

1268 

1269 if attempts > 0: 

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

1271 new_line = ( 

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

1273 ) 

1274 lines[server_position.line] = new_line 

1275 except YAMLError: 

1276 break 

1277 return content 

1278 

1279 

1280def generic_yaml_completer( 

1281 ls: "DebputyLanguageServer", 

1282 params: types.CompletionParams, 

1283 root_parser: DeclarativeInputParser[Any], 

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

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

1286 lines = _lines(doc.lines) 

1287 server_position = doc.position_codec.position_from_client_units( 

1288 lines, params.position 

1289 ) 

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

1291 has_colon = ":" in orig_line 

1292 

1293 content = _insert_complete_marker_and_parse_yaml(lines, server_position) 

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

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

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

1297 return None 

1298 attribute_root_path = AttributePath.root_path(content) 

1299 m = _trace_cursor(content, attribute_root_path, server_position) 

1300 

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

1302 _info("No match") 

1303 return None 

1304 matched_key, attr_path, matched, parent = m 

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

1306 feature_set = ls.plugin_feature_set 

1307 segments = list(attr_path.path_segments()) 

1308 km = resolve_keyword( 

1309 root_parser, 

1310 DEBPUTY_PLUGIN_METADATA, 

1311 segments, 

1312 0, 

1313 feature_set.manifest_parser_generator, 

1314 is_completion_attempt=True, 

1315 ) 

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

1317 return None 

1318 parser, _, at_depth_idx = km 

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

1320 items = [] 

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

1322 return items 

1323 

1324 if _is_inside_manifest_variable_substitution(lines, server_position): 

1325 return [ 

1326 types.CompletionItem( 

1327 pv.variable_name, 

1328 detail=pv.variable_reference_documentation, 

1329 sort_text=( 

1330 f"zz-{pv.variable_name}" 

1331 if pv.is_for_special_case 

1332 else pv.variable_name 

1333 ), 

1334 ) 

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

1336 if not pv.is_internal 

1337 ] 

1338 

1339 if isinstance(parser, DispatchingParserBase): 

1340 if matched_key: 

1341 items = [ 

1342 completion_item( 

1343 ( 

1344 maybe_quote_yaml_value(k) 

1345 if has_colon 

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

1347 ), 

1348 pp_subparser, 

1349 ) 

1350 for k, pp_subparser in _keywords_with_parser(parser) 

1351 if k not in parent 

1352 and not isinstance( 

1353 pp_subparser.parser, 

1354 DeclarativeValuelessKeywordInputParser, 

1355 ) 

1356 ] 

1357 else: 

1358 items = [ 

1359 completion_item(maybe_quote_yaml_value(k), pp_subparser) 

1360 for k, pp_subparser in _keywords_with_parser(parser) 

1361 if k not in parent 

1362 and isinstance( 

1363 pp_subparser.parser, 

1364 DeclarativeValuelessKeywordInputParser, 

1365 ) 

1366 ] 

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

1368 binary_packages = ls.lint_state(doc).binary_packages 

1369 if binary_packages is not None: 

1370 items = [ 

1371 types.CompletionItem( 

1372 maybe_quote_yaml_value(p) 

1373 if has_colon 

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

1375 ) 

1376 for p in binary_packages 

1377 if p not in parent 

1378 ] 

1379 elif isinstance(parser, DeclarativeMappingInputParser): 

1380 if matched_key: 

1381 _info("Match attributes") 

1382 locked = set(parent) 

1383 for mx in parser.mutually_exclusive_attributes: 

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

1385 locked.update(mx) 

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

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

1388 locked.add(attr_name) 

1389 break 

1390 items = [ 

1391 types.CompletionItem( 

1392 maybe_quote_yaml_value(k) 

1393 if has_colon 

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

1395 ) 

1396 for k in parser.manifest_attributes 

1397 if k not in locked 

1398 ] 

1399 else: 

1400 # Value 

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

1402 value_attr = ( 

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

1404 ) 

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

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

1407 return completion_from_attr( 

1408 value_attr, 

1409 feature_set.manifest_parser_generator, 

1410 matched, 

1411 matched_key=False, 

1412 has_colon=has_colon, 

1413 ) 

1414 else: 

1415 _info( 

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

1417 ) 

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

1419 alt_attr = parser.alt_form_parser 

1420 return completion_from_attr( 

1421 alt_attr, 

1422 feature_set.manifest_parser_generator, 

1423 matched, 

1424 matched_key=matched_key, 

1425 has_colon=has_colon, 

1426 ) 

1427 return items 

1428 

1429 

1430def generic_yaml_hover( 

1431 ls: "DebputyLanguageServer", 

1432 params: types.HoverParams, 

1433 root_parser_initializer: Callable[ 

1434 [ParserGenerator], DeclarativeInputParser[Any] | DispatchingParserBase 

1435 ], 

1436 *, 

1437 show_integration_mode: bool = False, 

1438) -> types.Hover | None: 

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

1440 lines = doc.lines 

1441 position_codec = doc.position_codec 

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

1443 

1444 try: 

1445 content = MANIFEST_YAML.load(doc.source) 

1446 except YAMLError: 

1447 return None 

1448 attribute_root_path = AttributePath.root_path(content) 

1449 m = _trace_cursor(content, attribute_root_path, server_position) 

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

1451 _info("No match") 

1452 return None 

1453 matched_key, attr_path, matched, _ = m 

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

1455 

1456 feature_set = ls.plugin_feature_set 

1457 parser_generator = feature_set.manifest_parser_generator 

1458 root_parser = root_parser_initializer(parser_generator) 

1459 segments = list(attr_path.path_segments()) 

1460 km = resolve_keyword( 

1461 root_parser, 

1462 DEBPUTY_PLUGIN_METADATA, 

1463 segments, 

1464 0, 

1465 parser_generator, 

1466 ) 

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

1468 _info("No keyword match") 

1469 return None 

1470 parser, plugin_metadata, at_depth_idx = km 

1471 

1472 manifest_variable_at_pos = _manifest_substitution_variable_at_position( 

1473 lines, server_position 

1474 ) 

1475 

1476 if manifest_variable_at_pos: 

1477 variable = ls.plugin_feature_set.manifest_variables.get( 

1478 manifest_variable_at_pos 

1479 ) 

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

1481 var_doc = ( 

1482 variable.variable_reference_documentation 

1483 or "No documentation available" 

1484 ) 

1485 

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

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

1488 else: 

1489 debian_dir = ls.lint_state(doc).debian_dir 

1490 value = "" 

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

1492 variable_context = VariableContext(debian_dir) 

1493 try: 

1494 resolved = variable.resolve(variable_context) 

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

1496 except RuntimeError: 

1497 pass 

1498 

1499 hover_doc_text = textwrap.dedent("""\ 

1500 # `{NAME}` 

1501 

1502 {DOC} 

1503 {VALUE} 

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

1505 return as_hover_doc(ls, hover_doc_text) 

1506 

1507 _info( 

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

1509 ) 

1510 hover_doc_text = resolve_hover_text( 

1511 feature_set, 

1512 parser, 

1513 plugin_metadata, 

1514 ls.hover_output_style, 

1515 show_integration_mode, 

1516 segments, 

1517 at_depth_idx, 

1518 matched, 

1519 matched_key, 

1520 ) 

1521 return as_hover_doc(ls, hover_doc_text)