Coverage for src/debputy/lsp/lsp_generic_yaml.py: 76%
675 statements
« prev ^ index » next coverage.py v7.8.2, created at 2026-07-22 10:58 +0000
« prev ^ index » next coverage.py v7.8.2, created at 2026-07-22 10:58 +0000
1import textwrap
2from typing import (
3 Union,
4 Any,
5 Optional,
6 List,
7 Tuple,
8 TYPE_CHECKING,
9 get_origin,
10 Literal,
11 get_args,
12 Generic,
13 cast,
14)
15from collections.abc import Iterable, Callable, Sequence, Iterator
17from debputy.commands.debputy_cmd.output import OutputStyle
18from debputy.linting.lint_util import LintState
19from debputy.lsp.diagnostics import LintSeverity
20from debputy.lsp.quickfixes import propose_correct_text_quick_fix
21from debian._deb822_repro.locatable import (
22 Position as TEPosition,
23 Range as TERange,
24)
25from debputy.manifest_parser.declarative_parser import (
26 DeclarativeMappingInputParser,
27 ParserGenerator,
28 AttributeDescription,
29 DeclarativeNonMappingInputParser,
30 BASIC_SIMPLE_TYPES,
31)
32from debputy.manifest_parser.parser_doc import (
33 render_rule,
34 render_attribute_doc,
35 doc_args_for_parser_doc,
36)
37from debputy.manifest_parser.tagging_types import DebputyDispatchableType
38from debputy.manifest_parser.util import AttributePath
39from debputy.plugin.api.feature_set import PluginProvidedFeatureSet
40from debputy.plugin.api.impl import plugin_metadata_for_debputys_own_plugin
41from debputy.plugin.api.impl_types import (
42 DebputyPluginMetadata,
43 DeclarativeInputParser,
44 DispatchingParserBase,
45 InPackageContextParser,
46 ListWrappedDeclarativeInputParser,
47 PluginProvidedParser,
48 DeclarativeValuelessKeywordInputParser,
49 DispatchingTableParser,
50 AllowNoneDeclarativeInputParser,
51)
52from debputy.substitution import VariableContext
53from debputy.util import _info, _warn, detect_possible_typo, T
54from debputy.yaml import MANIFEST_YAML
55from debputy.yaml.compat import (
56 MarkedYAMLError,
57 YAMLError,
58)
59from debputy.yaml.compat import (
60 Node,
61 CommentedMap,
62 LineCol,
63 CommentedSeq,
64 CommentedBase,
65)
67if TYPE_CHECKING:
68 import lsprotocol.types as types
69else:
70 import debputy.lsprotocol.types as types
72try:
73 from pygls.server import LanguageServer
74 from debputy.lsp.debputy_ls import DebputyLanguageServer
75except ImportError:
76 pass
79YAML_COMPLETION_HINT_KEY = "___COMPLETE:"
80YAML_COMPLETION_HINT_VALUE = "___COMPLETE"
81DEBPUTY_PLUGIN_METADATA = plugin_metadata_for_debputys_own_plugin()
84class LSPYAMLHelper(Generic[T]):
86 def __init__(
87 self,
88 lint_state: LintState,
89 pg: ParserGenerator,
90 custom_data: T,
91 ) -> None:
92 self.lint_state = lint_state
93 self.lines = _lines(lint_state.lines)
94 self.pg = pg
95 self.custom_data = custom_data
97 def _validate_subparser_is_valid_here(
98 self,
99 subparser: PluginProvidedParser,
100 orig_key: str,
101 line: int,
102 col: int,
103 ) -> None:
104 # Subclasses can provide custom logic here
105 pass
107 def _lint_dispatch_parser(
108 self,
109 parser: DispatchingParserBase,
110 dispatch_key: str,
111 key_pos: tuple[int, int] | None,
112 value: Any | None,
113 value_pos: tuple[int, int] | None,
114 *,
115 is_keyword_only: bool,
116 ) -> None:
117 is_known = parser.is_known_keyword(dispatch_key)
118 orig_key = dispatch_key
119 if not is_known and key_pos is not None:
121 if value is None:
122 opts = {
123 "message_format": 'Unknown or unsupported value "{key}".',
124 }
125 else:
126 opts = {}
127 corrected_key = yaml_flag_unknown_key(
128 self.lint_state,
129 dispatch_key,
130 parser.registered_keywords(),
131 key_pos,
132 unknown_keys_diagnostic_severity=parser.unknown_keys_diagnostic_severity,
133 **opts,
134 )
135 if corrected_key is not None:
136 dispatch_key = corrected_key
137 is_known = True
139 if is_known:
140 subparser = parser.parser_for(dispatch_key)
141 assert subparser is not None
142 if key_pos: 142 ↛ 150line 142 didn't jump to line 150 because the condition on line 142 was always true
143 line, col = key_pos
144 self._validate_subparser_is_valid_here(
145 subparser,
146 orig_key,
147 line,
148 col,
149 )
150 if ( 150 ↛ 154line 150 didn't jump to line 154 because the condition on line 150 was never true
151 isinstance(subparser.parser, AllowNoneDeclarativeInputParser)
152 and value is None
153 ):
154 return
155 if isinstance(subparser.parser, DeclarativeValuelessKeywordInputParser):
156 if value is not None or not is_keyword_only: 156 ↛ 157line 156 didn't jump to line 157 because the condition on line 156 was never true
157 if value is not None:
158 line_no, cursor_pos = value_pos if value_pos else key_pos
159 value_range = self._remaining_line(line_no, cursor_pos)
160 if _is_empty_range(value_range):
161 # In the unlikely case that the value position is present but leads to
162 # an empty range, report the key instead.
163 line_no, cursor_pos = key_pos
164 value_range = self._remaining_line(line_no, cursor_pos)
165 msg = f"The keyword {dispatch_key} does not accept any value"
166 else:
167 line_no, cursor_pos = key_pos
168 value_range = self._remaining_line(line_no, cursor_pos)
169 msg = f"The keyword {dispatch_key} cannot be used as a mapping key"
171 assert not _is_empty_range(value_range)
172 self.lint_state.emit_diagnostic(
173 value_range,
174 msg,
175 "error",
176 "debputy",
177 )
178 return
180 self.lint_content(
181 # Pycharm's type checking gets confused by the isinstance check above.
182 cast("DeclarativeInputParser[Any]", subparser.parser),
183 value,
184 key=orig_key,
185 content_pos=value_pos,
186 )
188 def lint_content(
189 self,
190 parser: DeclarativeInputParser[Any],
191 content: Any,
192 *,
193 key: str | int | None = None,
194 content_pos: tuple[int, int] | None = None,
195 ) -> None:
196 if isinstance(parser, AllowNoneDeclarativeInputParser): 196 ↛ 197line 196 didn't jump to line 197 because the condition on line 196 was never true
197 parser = parser.delegate
198 if isinstance(parser, DispatchingParserBase):
199 if isinstance(content, str):
200 self._lint_dispatch_parser(
201 parser,
202 content,
203 content_pos,
204 None,
205 None,
206 is_keyword_only=True,
207 )
209 return
210 if not isinstance(content, CommentedMap):
211 return
212 lc = content.lc
213 for dispatch_key, value in content.items():
214 key_pos = lc.key(dispatch_key)
215 value_pos = lc.value(dispatch_key)
216 self._lint_dispatch_parser(
217 parser,
218 dispatch_key,
219 key_pos,
220 value,
221 value_pos,
222 is_keyword_only=False,
223 )
225 elif isinstance(parser, ListWrappedDeclarativeInputParser):
226 if not isinstance(content, CommentedSeq): 226 ↛ 227line 226 didn't jump to line 227 because the condition on line 226 was never true
227 return
228 subparser = parser.delegate
229 lc = content.lc
230 for idx, value in enumerate(content):
231 value_pos = lc.item(idx)
232 self.lint_content(subparser, value, content_pos=value_pos, key=idx)
233 elif isinstance(parser, InPackageContextParser):
234 if not isinstance(content, CommentedMap): 234 ↛ 235line 234 didn't jump to line 235 because the condition on line 234 was never true
235 return
236 known_packages = self.lint_state.binary_packages
237 lc = content.lc
238 for k, v in content.items():
239 if k is None or (
240 "{{" not in k
241 and known_packages is not None
242 and k not in known_packages
243 ):
244 yaml_flag_unknown_key(
245 self.lint_state,
246 k,
247 known_packages,
248 lc.key(k),
249 message_format='Unknown package "{key}".',
250 )
251 self.lint_content(parser.delegate, v, key=k, content_pos=lc.value(k))
252 elif isinstance(parser, DeclarativeMappingInputParser):
253 self._lint_declarative_mapping_input_parser(
254 parser,
255 content,
256 content_pos,
257 key=key,
258 )
259 elif isinstance(parser, DeclarativeNonMappingInputParser): 259 ↛ exitline 259 didn't return from function 'lint_content' because the condition on line 259 was always true
260 if content_pos is not None: 260 ↛ exitline 260 didn't return from function 'lint_content' because the condition on line 260 was always true
261 self._lint_attr_value(
262 parser.alt_form_parser,
263 key,
264 content,
265 content_pos,
266 )
268 def _lint_declarative_mapping_input_parser(
269 self,
270 parser: DeclarativeMappingInputParser,
271 content: Any,
272 content_pos: tuple[int, int],
273 *,
274 key: str | int | None = None,
275 ) -> None:
276 if not isinstance(content, CommentedMap): 276 ↛ 277line 276 didn't jump to line 277 because the condition on line 276 was never true
277 alt_form_parser = parser.alt_form_parser
278 if alt_form_parser:
279 self._lint_attr_value(
280 alt_form_parser,
281 key,
282 content,
283 content_pos,
284 )
285 else:
286 line_no, cursor_pos = content_pos
287 value_range = self._remaining_line(line_no, cursor_pos)
288 if _is_empty_range(value_range):
289 # FIXME: We cannot report an empty range, but there is still a problem here.
290 return
291 if isinstance(key, str):
292 msg = f'The value for "{key}" must be a mapping'
293 else:
294 msg = "The value must be a mapping"
295 self.lint_state.emit_diagnostic(
296 value_range,
297 msg,
298 "error",
299 "debputy",
300 )
301 return
302 lc = content.lc
303 for key, value in content.items():
304 attr = parser.manifest_attributes.get(key)
305 key_pos = lc.key(key)
306 value_pos = lc.value(key)
307 if attr is None:
308 corrected_key = yaml_flag_unknown_key(
309 self.lint_state,
310 key,
311 parser.manifest_attributes,
312 key_pos,
313 )
314 if corrected_key:
315 key = corrected_key
316 attr = parser.manifest_attributes.get(corrected_key)
317 if attr is None:
318 continue
320 self._lint_attr_value(
321 attr,
322 key,
323 value,
324 value_pos,
325 )
327 for forbidden_key in attr.conflicting_attributes: 327 ↛ 328line 327 didn't jump to line 328 because the loop on line 327 never started
328 if forbidden_key in content:
329 line, col = key_pos
330 con_line, con_col = lc.key(forbidden_key)
331 yaml_conflicting_key(
332 self.lint_state,
333 key,
334 forbidden_key,
335 line,
336 col,
337 con_line,
338 con_col,
339 )
340 for mx in parser.mutually_exclusive_attributes:
341 matches = content.keys() & mx
342 if len(matches) < 2: 342 ↛ 344line 342 didn't jump to line 344 because the condition on line 342 was always true
343 continue
344 key, *others = list(matches)
345 line, col = lc.key(key)
346 for other in others:
347 con_line, con_col = lc.key(other)
348 yaml_conflicting_key(
349 self.lint_state,
350 key,
351 other,
352 line,
353 col,
354 con_line,
355 con_col,
356 )
358 def _type_based_value_check(
359 self,
360 target_attr_type: type,
361 value: Any,
362 value_pos: tuple[int, int],
363 *,
364 key: str | int | None = None,
365 ) -> bool:
366 if issubclass(target_attr_type, DebputyDispatchableType):
367 parser = self.pg.dispatch_parser_table_for(target_attr_type)
368 self.lint_content(
369 parser,
370 value,
371 key=key,
372 content_pos=value_pos,
373 )
374 return True
375 if (
376 (type_mapper := self.pg.get_mapped_type_from_target_type(target_attr_type))
377 and (lint_validator := type_mapper.lint_validator)
378 # TODO: We should flag None, but that is for another day. The `None` is tricky, because
379 # value_pos + remaining_line leads to an empty range.
380 and value is not None
381 and "{{" not in value
382 ):
383 try:
384 lint_validator(value, AttributePath.highlighted_range())
385 except Exception as e:
386 line_no, cursor_pos = value_pos
387 value_range = self._remaining_line(line_no, cursor_pos)
388 self.lint_state.emit_diagnostic(
389 value_range,
390 str(e),
391 "error",
392 "debputy",
393 )
394 return True
395 return False
397 def _lint_attr_value(
398 self,
399 attr: AttributeDescription,
400 key: str | int | None,
401 value: Any,
402 pos: tuple[int, int],
403 ) -> None:
404 target_attr_type = attr.attribute_type
405 orig = get_origin(target_attr_type)
406 if orig == list and isinstance(value, CommentedSeq):
407 lc = value.lc
408 target_item_type = get_args(target_attr_type)[0]
409 for idx, v in enumerate(value):
410 v_pos = lc.item(idx)
411 self._lint_value(
412 idx,
413 v,
414 target_item_type,
415 v_pos,
416 )
418 else:
419 self._lint_value(
420 key,
421 value,
422 target_attr_type,
423 pos,
424 )
426 def _lint_value(
427 self,
428 key: str | int | None,
429 value: Any,
430 target_attr_type: Any,
431 pos: tuple[int, int],
432 ) -> None:
433 type_mapping = self.pg.get_mapped_type_from_target_type(target_attr_type)
434 source_attr_type = target_attr_type
435 if type_mapping is not None:
436 source_attr_type = type_mapping.source_type
437 valid_values: Sequence[Any] | None = None
438 orig = get_origin(source_attr_type)
439 if orig == Literal:
440 valid_values = get_args(target_attr_type)
441 elif orig == bool or target_attr_type == bool:
442 valid_values = (True, False)
443 elif isinstance(target_attr_type, type) and self._type_based_value_check(
444 target_attr_type,
445 value,
446 pos,
447 key=key,
448 ):
449 return
450 elif source_attr_type in BASIC_SIMPLE_TYPES:
451 if isinstance(value, source_attr_type):
452 return
453 expected_type = BASIC_SIMPLE_TYPES[source_attr_type]
454 line_no, cursor_pos = pos
455 value_range = self._remaining_line(line_no, cursor_pos)
456 if _is_empty_range(value_range): 456 ↛ 459line 456 didn't jump to line 459 because the condition on line 456 was always true
457 # FIXME: We cannot report an empty range, but there is still a problem here.
458 return
459 if isinstance(key, str):
460 msg = f'Value for "{key}" does not match the base type: Expected {expected_type}'
461 else:
462 msg = f"Value does not match the base type: Expected {expected_type}"
463 if issubclass(source_attr_type, str):
464 quickfixes = [
465 propose_correct_text_quick_fix(_as_yaml_value(str(value)))
466 ]
467 else:
468 quickfixes = None
469 self.lint_state.emit_diagnostic(
470 value_range,
471 msg,
472 "error",
473 "debputy",
474 quickfixes=quickfixes,
475 )
476 return
478 if valid_values is None or value in valid_values:
479 return
480 line_no, cursor_pos = pos
481 value_range = self._remaining_line(line_no, cursor_pos)
482 if _is_empty_range(value_range): 482 ↛ 484line 482 didn't jump to line 484 because the condition on line 482 was never true
483 # FIXME: We cannot report an empty range, but there is still a problem here.
484 return
485 if isinstance(key, str): 485 ↛ 488line 485 didn't jump to line 488 because the condition on line 485 was always true
486 msg = f'Not a supported value for "{key}"'
487 else:
488 msg = "Not a supported value here"
489 self.lint_state.emit_diagnostic(
490 value_range,
491 msg,
492 "error",
493 "debputy",
494 quickfixes=[
495 propose_correct_text_quick_fix(_as_yaml_value(m)) for m in valid_values
496 ],
497 )
499 def _remaining_line(self, line_no: int, pos_start: int) -> "TERange":
500 raw_line = self.lines[line_no].rstrip()
501 pos_end = len(raw_line)
502 return TERange(
503 TEPosition(
504 line_no,
505 pos_start,
506 ),
507 TEPosition(
508 line_no,
509 pos_end,
510 ),
511 )
514def _is_empty_range(token_range: "TERange") -> bool:
515 return token_range.start_pos == token_range.end_pos
518def _lines(lines: list[str]) -> list[str]:
519 if not lines or lines[-1].endswith("\n"): 519 ↛ 522line 519 didn't jump to line 522 because the condition on line 519 was always true
520 lines = lines.copy()
521 lines.append("")
522 return lines
525async def generic_yaml_lint(
526 lint_state: LintState,
527 root_parser: DeclarativeInputParser[Any],
528 initialize_yaml_helper: Callable[[LintState], LSPYAMLHelper[Any]],
529) -> None:
530 lines = _lines(lint_state.lines)
531 try:
532 content = MANIFEST_YAML.load(lint_state.content)
533 except MarkedYAMLError as e:
534 if e.context_mark:
535 line = e.context_mark.line
536 column = e.context_mark.column
537 else:
538 line = e.problem_mark.line
539 column = e.problem_mark.column
540 error_range = error_range_at_position(
541 lines,
542 line,
543 column,
544 )
545 lint_state.emit_diagnostic(
546 error_range,
547 f"YAML parse error: {e}",
548 "error",
549 "debputy",
550 )
551 except YAMLError as e:
552 error_range = TERange(
553 TEPosition(0, 0),
554 TEPosition(0, len(lines[0])),
555 )
556 lint_state.emit_diagnostic(
557 error_range,
558 f"Unknown YAML parse error: {e} [{e!r}]",
559 "error",
560 "debputy",
561 )
562 else:
563 yaml_linter = initialize_yaml_helper(lint_state)
564 yaml_linter.lint_content(
565 root_parser,
566 content,
567 )
570def _as_yaml_value(v: Any) -> str:
571 if isinstance(v, bool):
572 return str(v).lower()
573 if isinstance(v, str): 573 ↛ 575line 573 didn't jump to line 575 because the condition on line 573 was always true
574 return maybe_quote_yaml_value(str(v))
575 return str(v)
578def resolve_hover_text_for_value(
579 feature_set: PluginProvidedFeatureSet,
580 parser: DeclarativeMappingInputParser,
581 plugin_metadata: DebputyPluginMetadata,
582 output_style: OutputStyle,
583 show_integration_mode: bool,
584 segment: str | int,
585 matched: Any,
586) -> str | None:
588 hover_doc_text: str | None = None
589 attr = parser.manifest_attributes.get(segment)
590 attr_type = attr.attribute_type if attr is not None else None
591 if attr_type is None: 591 ↛ 592line 591 didn't jump to line 592 because the condition on line 591 was never true
592 _info(f"Matched value for {segment} -- No attr or type")
593 return None
594 if isinstance(attr_type, type) and issubclass(attr_type, DebputyDispatchableType): 594 ↛ 614line 594 didn't jump to line 614 because the condition on line 594 was always true
595 parser_generator = feature_set.manifest_parser_generator
596 parser = parser_generator.dispatch_parser_table_for(attr_type)
597 if parser is None or not isinstance(matched, str): 597 ↛ 598line 597 didn't jump to line 598 because the condition on line 597 was never true
598 _info(
599 f"Unknown parser for {segment} or matched is not a str -- {attr_type} {type(matched)=}"
600 )
601 return None
602 subparser = parser.parser_for(matched)
603 if subparser is None: 603 ↛ 604line 603 didn't jump to line 604 because the condition on line 603 was never true
604 _info(f"Unknown parser for {matched} (subparser)")
605 return None
606 hover_doc_text = render_rule(
607 matched,
608 subparser.parser,
609 plugin_metadata,
610 output_style,
611 show_integration_mode=show_integration_mode,
612 )
613 else:
614 _info(f"Unknown value: {matched} -- {segment}")
615 return hover_doc_text
618def resolve_hover_text(
619 feature_set: PluginProvidedFeatureSet,
620 parser: DeclarativeInputParser[Any] | DispatchingParserBase | None,
621 plugin_metadata: DebputyPluginMetadata,
622 output_style: OutputStyle,
623 show_integration_mode: bool,
624 segments: list[str | int],
625 at_depth_idx: int,
626 matched: Any,
627 matched_key: bool,
628) -> str | None:
629 hover_doc_text: str | None = None
630 if at_depth_idx == len(segments):
631 segment = segments[at_depth_idx - 1]
632 _info(f"Matched {segment} at ==, {matched_key=} ")
633 hover_doc_text = render_rule(
634 segment,
635 parser,
636 plugin_metadata,
637 output_style,
638 is_root_rule=False,
639 show_integration_mode=show_integration_mode,
640 )
641 elif at_depth_idx + 1 == len(segments) and isinstance( 641 ↛ 666line 641 didn't jump to line 666 because the condition on line 641 was always true
642 parser, DeclarativeMappingInputParser
643 ):
644 segment = segments[at_depth_idx]
645 _info(f"Matched {segment} at -1, {matched_key=}")
646 if isinstance(segment, str): 646 ↛ 668line 646 didn't jump to line 668 because the condition on line 646 was always true
647 if not matched_key:
648 hover_doc_text = resolve_hover_text_for_value(
649 feature_set,
650 parser,
651 plugin_metadata,
652 output_style,
653 show_integration_mode,
654 segment,
655 matched,
656 )
657 if matched_key or hover_doc_text is None:
658 rule_name = _guess_rule_name(segments, at_depth_idx)
659 hover_doc_text = _render_param_doc(
660 rule_name,
661 parser,
662 plugin_metadata,
663 segment,
664 )
665 else:
666 _info(f"No doc: {at_depth_idx=} {len(segments)=}")
668 return hover_doc_text
671def as_hover_doc(
672 ls: "DebputyLanguageServer",
673 hover_doc_text: str | None,
674) -> types.Hover | None:
675 if hover_doc_text is None: 675 ↛ 676line 675 didn't jump to line 676 because the condition on line 675 was never true
676 return None
677 return types.Hover(
678 contents=types.MarkupContent(
679 kind=ls.hover_markup_format(
680 types.MarkupKind.Markdown,
681 types.MarkupKind.PlainText,
682 ),
683 value=hover_doc_text,
684 ),
685 )
688def _render_param_doc(
689 rule_name: str,
690 declarative_parser: DeclarativeMappingInputParser,
691 plugin_metadata: DebputyPluginMetadata,
692 attribute: str,
693) -> str | None:
694 source_form_name = declarative_parser.source_form_attr2source_attributes.get(
695 attribute
696 )
697 if source_form_name is None: 697 ↛ 698line 697 didn't jump to line 698 because the condition on line 697 was never true
698 return None
699 attr = declarative_parser.source_attributes[source_form_name]
701 doc_args, parser_doc = doc_args_for_parser_doc(
702 rule_name,
703 declarative_parser,
704 plugin_metadata,
705 )
706 rendered_docs = render_attribute_doc(
707 declarative_parser,
708 declarative_parser.source_attributes,
709 declarative_parser.input_time_required_parameters,
710 declarative_parser.at_least_one_of,
711 parser_doc,
712 doc_args,
713 is_interactive=True,
714 rule_name=rule_name,
715 )
717 for attributes, rendered_doc in rendered_docs: 717 ↛ 726line 717 didn't jump to line 726 because the loop on line 717 didn't complete
718 if source_form_name in attributes:
719 full_doc = [
720 f"# Attribute `{attribute}`",
721 "",
722 ]
723 full_doc.extend(rendered_doc)
725 return "\n".join(full_doc)
726 return None
729def _guess_rule_name(segments: list[str | int], idx: int) -> str:
730 orig_idx = idx
731 idx -= 1
732 while idx >= 0: 732 ↛ 737line 732 didn't jump to line 737 because the condition on line 732 was always true
733 segment = segments[idx]
734 if isinstance(segment, str):
735 return segment
736 idx -= 1
737 _warn(f"Unable to derive rule name from {segments} [{orig_idx}]")
738 return "<Bug: unknown rule name>"
741def is_at(position: types.Position, lc_pos: tuple[int, int]) -> bool:
742 return position.line == lc_pos[0] and position.character == lc_pos[1]
745def is_before(position: types.Position, lc_pos: tuple[int, int]) -> bool:
746 line, column = lc_pos
747 if position.line < line:
748 return True
749 if position.line == line and position.character < column:
750 return True
751 return False
754def is_after(position: types.Position, lc_pos: tuple[int, int]) -> bool:
755 line, column = lc_pos
756 if position.line > line:
757 return True
758 if position.line == line and position.character > column:
759 return True
760 return False
763def error_range_at_position(
764 lines: list[str],
765 line_no: int,
766 char_offset: int,
767) -> TERange:
768 line = lines[line_no]
769 line_len = len(line)
770 start_idx = char_offset
771 end_idx = start_idx
773 if line[start_idx].isspace():
775 def _check(x: str) -> bool:
776 return not x.isspace()
778 else:
780 def _check(x: str) -> bool:
781 return x.isspace()
783 for i in range(end_idx, line_len):
784 end_idx = i
785 if _check(line[i]):
786 break
788 for i in range(start_idx, -1, -1):
789 if i > 0 and _check(line[i]):
790 break
791 start_idx = i
793 return TERange(
794 TEPosition(line_no, start_idx),
795 TEPosition(line_no, end_idx),
796 )
799def _escape(v: str) -> str:
800 return '"' + v.replace("\n", "\\n") + '"'
803def insert_complete_marker_snippet(
804 lines: list[str],
805 server_position: types.Position,
806) -> bool:
807 _info(f"Complete at {server_position}")
808 line_no = server_position.line
809 line = lines[line_no] if line_no < len(lines) else ""
811 lhs_ws = line[: server_position.character]
812 lhs = lhs_ws.strip()
813 open_quote = ""
814 rhs = line[server_position.character + 1 :]
815 for q in ('"', "'"):
816 if rhs.endswith(q): 816 ↛ 817line 816 didn't jump to line 817 because the condition on line 816 was never true
817 break
818 qc = lhs.count(q) & 1
819 if qc:
820 open_quote = q
821 break
823 if lhs.endswith(":"):
824 _info("Insertion of value (key seen)")
825 new_line = (
826 line[: server_position.character]
827 + YAML_COMPLETION_HINT_VALUE
828 + f"{open_quote}\n"
829 )
830 elif lhs.startswith("-"):
831 _info("Insertion of key or value (list item)")
832 # Respect the provided indentation
833 snippet = (
834 YAML_COMPLETION_HINT_KEY if ":" not in lhs else YAML_COMPLETION_HINT_VALUE
835 )
836 new_line = line[: server_position.character] + snippet + f"{open_quote}\n"
837 elif not lhs or (lhs_ws and not lhs_ws[0].isspace()):
838 _info(f"Insertion of key or value: {_escape(line[server_position.character:])}")
839 # Respect the provided indentation
840 snippet = (
841 YAML_COMPLETION_HINT_KEY if ":" not in lhs else YAML_COMPLETION_HINT_VALUE
842 )
843 new_line = line[: server_position.character] + snippet + f"{open_quote}\n"
844 elif lhs.isalpha() and ":" not in lhs:
845 _info(f"Expanding value to a key: {_escape(line[server_position.character:])}")
846 # Respect the provided indentation
847 new_line = (
848 line[: server_position.character]
849 + YAML_COMPLETION_HINT_KEY
850 + f"{open_quote}\n"
851 )
852 elif open_quote:
853 _info(
854 f"Expanding value inside a string: {_escape(line[server_position.character:])}"
855 )
856 new_line = (
857 line[: server_position.character]
858 + YAML_COMPLETION_HINT_VALUE
859 + f"{open_quote}\n"
860 )
861 else:
862 c = (
863 line[server_position.character]
864 if server_position.character < len(line)
865 else "(OOB)"
866 )
867 _info(f"Not touching line: {_escape(line)} -- {_escape(c)}")
868 return False
869 _info(f'Evaluating complete on synthetic line: "{new_line}"')
870 if line_no < len(lines): 870 ↛ 872line 870 didn't jump to line 872 because the condition on line 870 was always true
871 lines[line_no] = new_line
872 elif line_no == len(lines):
873 lines.append(new_line)
874 else:
875 return False
876 return True
879def _keywords_with_parser(
880 parser: DeclarativeMappingInputParser | DispatchingParserBase,
881) -> Iterator[tuple[str, PluginProvidedParser]]:
882 for keyword in parser.registered_keywords():
883 pp_subparser = parser.parser_for(keyword)
884 yield keyword, pp_subparser
887def yaml_key_range(
888 key: str | None,
889 line: int,
890 col: int,
891) -> "TERange":
892 key_len = len(key) if key else 1
893 return TERange.between(
894 TEPosition(line, col),
895 TEPosition(line, col + key_len),
896 )
899def yaml_flag_unknown_key(
900 lint_state: LintState,
901 key: str | None,
902 expected_keys: Iterable[str],
903 key_pos: tuple[int, int],
904 *,
905 message_format: str = 'Unknown or unsupported key "{key}".',
906 unknown_keys_diagnostic_severity: LintSeverity | None = "error",
907) -> str | None:
908 line, col = key_pos
909 key_range = yaml_key_range(key, line, col)
911 candidates = detect_possible_typo(key, expected_keys) if key is not None else ()
912 extra = ""
913 corrected_key = None
914 if candidates:
915 extra = f' It looks like a typo of "{candidates[0]}".'
916 # TODO: We should be able to tell that `install-doc` and `install-docs` are the same.
917 # That would enable this to work in more cases.
918 corrected_key = candidates[0] if len(candidates) == 1 else None
919 if unknown_keys_diagnostic_severity is None: 919 ↛ 920line 919 didn't jump to line 920 because the condition on line 919 was never true
920 message_format = f"Possible typo of {candidates[0]}."
921 extra = ""
922 elif unknown_keys_diagnostic_severity is None: 922 ↛ 923line 922 didn't jump to line 923 because the condition on line 922 was never true
923 return None
925 if key is None:
926 message_format = "Missing key"
927 if unknown_keys_diagnostic_severity is not None: 927 ↛ 935line 927 didn't jump to line 935 because the condition on line 927 was always true
928 lint_state.emit_diagnostic(
929 key_range,
930 message_format.format(key=key) + extra,
931 unknown_keys_diagnostic_severity,
932 "debputy",
933 quickfixes=[propose_correct_text_quick_fix(n) for n in candidates],
934 )
935 return corrected_key
938def yaml_conflicting_key(
939 lint_state: LintState,
940 key_a: str,
941 key_b: str,
942 key_a_line: int,
943 key_a_col: int,
944 key_b_line: int,
945 key_b_col: int,
946) -> None:
947 key_a_range = TERange(
948 TEPosition(
949 key_a_line,
950 key_a_col,
951 ),
952 TEPosition(
953 key_a_line,
954 key_a_col + len(key_a),
955 ),
956 )
957 key_b_range = TERange(
958 TEPosition(
959 key_b_line,
960 key_b_col,
961 ),
962 TEPosition(
963 key_b_line,
964 key_b_col + len(key_b),
965 ),
966 )
967 lint_state.emit_diagnostic(
968 key_a_range,
969 f'The "{key_a}" cannot be used with "{key_b}".',
970 "error",
971 "debputy",
972 related_information=[
973 lint_state.related_diagnostic_information(
974 key_b_range, f'The attribute "{key_b}" is used here.'
975 ),
976 ],
977 )
979 lint_state.emit_diagnostic(
980 key_b_range,
981 f'The "{key_b}" cannot be used with "{key_a}".',
982 "error",
983 "debputy",
984 related_information=[
985 lint_state.related_diagnostic_information(
986 key_a_range,
987 f'The attribute "{key_a}" is used here.',
988 ),
989 ],
990 )
993def resolve_keyword(
994 current_parser: DeclarativeInputParser[Any] | DispatchingParserBase,
995 current_plugin: DebputyPluginMetadata,
996 segments: list[str | int],
997 segment_idx: int,
998 parser_generator: ParserGenerator,
999 *,
1000 is_completion_attempt: bool = False,
1001) -> None | (
1002 tuple[
1003 DeclarativeInputParser[Any] | DispatchingParserBase,
1004 DebputyPluginMetadata,
1005 int,
1006 ]
1007):
1008 if segment_idx >= len(segments):
1009 return current_parser, current_plugin, segment_idx
1010 current_segment = segments[segment_idx]
1011 if isinstance(current_parser, AllowNoneDeclarativeInputParser): 1011 ↛ 1012line 1011 didn't jump to line 1012 because the condition on line 1011 was never true
1012 current_parser = current_parser.delegate
1013 if isinstance(current_parser, ListWrappedDeclarativeInputParser):
1014 if isinstance(current_segment, int): 1014 ↛ 1021line 1014 didn't jump to line 1021 because the condition on line 1014 was always true
1015 current_parser = current_parser.delegate
1016 segment_idx += 1
1017 if segment_idx >= len(segments): 1017 ↛ 1018line 1017 didn't jump to line 1018 because the condition on line 1017 was never true
1018 return current_parser, current_plugin, segment_idx
1019 current_segment = segments[segment_idx]
1021 if not isinstance(current_segment, str): 1021 ↛ 1022line 1021 didn't jump to line 1022 because the condition on line 1021 was never true
1022 return None
1024 if is_completion_attempt and current_segment.endswith(
1025 (YAML_COMPLETION_HINT_KEY, YAML_COMPLETION_HINT_VALUE)
1026 ):
1027 return current_parser, current_plugin, segment_idx
1029 if isinstance(current_parser, InPackageContextParser):
1030 return resolve_keyword(
1031 current_parser.delegate,
1032 current_plugin,
1033 segments,
1034 segment_idx + 1,
1035 parser_generator,
1036 is_completion_attempt=is_completion_attempt,
1037 )
1038 elif isinstance(current_parser, DispatchingParserBase):
1039 if not current_parser.is_known_keyword(current_segment): 1039 ↛ 1040line 1039 didn't jump to line 1040 because the condition on line 1039 was never true
1040 if is_completion_attempt:
1041 return current_parser, current_plugin, segment_idx
1042 return None
1043 subparser = current_parser.parser_for(current_segment)
1044 segment_idx += 1
1045 if segment_idx < len(segments):
1046 return resolve_keyword(
1047 subparser.parser,
1048 subparser.plugin_metadata,
1049 segments,
1050 segment_idx,
1051 parser_generator,
1052 is_completion_attempt=is_completion_attempt,
1053 )
1054 return subparser.parser, subparser.plugin_metadata, segment_idx
1055 elif isinstance(current_parser, DeclarativeMappingInputParser): 1055 ↛ 1077line 1055 didn't jump to line 1077 because the condition on line 1055 was always true
1056 attr = current_parser.manifest_attributes.get(current_segment)
1057 attr_type = attr.attribute_type if attr is not None else None
1058 if (
1059 attr_type is not None
1060 and isinstance(attr_type, type)
1061 and issubclass(attr_type, DebputyDispatchableType)
1062 ):
1063 subparser = parser_generator.dispatch_parser_table_for(attr_type)
1064 if subparser is not None and (
1065 is_completion_attempt or segment_idx + 1 < len(segments)
1066 ):
1067 return resolve_keyword(
1068 subparser,
1069 current_plugin,
1070 segments,
1071 segment_idx + 1,
1072 parser_generator,
1073 is_completion_attempt=is_completion_attempt,
1074 )
1075 return current_parser, current_plugin, segment_idx
1076 else:
1077 _info(f"Unknown parser: {current_parser.__class__}")
1078 return None
1081def _trace_cursor(
1082 content: Any,
1083 attribute_path: AttributePath,
1084 server_position: types.Position,
1085) -> tuple[bool, AttributePath, Any, Any] | None:
1086 matched_key: str | int | None = None
1087 matched: Node | None = None
1088 matched_was_key: bool = False
1090 if isinstance(content, CommentedMap):
1091 dict_lc: LineCol = content.lc
1092 for k, v in content.items():
1093 k_lc = dict_lc.key(k)
1094 if is_before(server_position, k_lc): 1094 ↛ 1095line 1094 didn't jump to line 1095 because the condition on line 1094 was never true
1095 break
1096 v_lc = dict_lc.value(k)
1097 if is_before(server_position, v_lc):
1098 # TODO: Handle ":" and "whitespace"
1099 matched = k
1100 matched_key = k
1101 matched_was_key = True
1102 break
1103 matched = v
1104 matched_key = k
1105 elif isinstance(content, CommentedSeq): 1105 ↛ 1114line 1105 didn't jump to line 1114 because the condition on line 1105 was always true
1106 list_lc: LineCol = content.lc
1107 for idx, value in enumerate(content):
1108 i_lc = list_lc.item(idx)
1109 if is_before(server_position, i_lc): 1109 ↛ 1110line 1109 didn't jump to line 1110 because the condition on line 1109 was never true
1110 break
1111 matched_key = idx
1112 matched = value
1114 if matched is not None: 1114 ↛ 1120line 1114 didn't jump to line 1120 because the condition on line 1114 was always true
1115 assert matched_key is not None
1116 sub_path = attribute_path[matched_key]
1117 if not matched_was_key and isinstance(matched, CommentedBase):
1118 return _trace_cursor(matched, sub_path, server_position)
1119 return matched_was_key, sub_path, matched, content
1120 return None
1123def maybe_quote_yaml_value(v: str) -> str:
1124 if v and v[0].isdigit():
1125 try:
1126 float(v)
1127 return f"'{v}'"
1128 except ValueError:
1129 pass
1130 return v
1133def _complete_value(v: Any) -> str:
1134 if isinstance(v, str): 1134 ↛ 1136line 1134 didn't jump to line 1136 because the condition on line 1134 was always true
1135 return maybe_quote_yaml_value(v)
1136 return str(v)
1139def completion_from_attr(
1140 attr: AttributeDescription,
1141 pg: ParserGenerator,
1142 matched: Any,
1143 *,
1144 matched_key: bool = False,
1145 has_colon: bool = False,
1146) -> types.CompletionList | Sequence[types.CompletionItem] | None:
1147 type_mapping = pg.get_mapped_type_from_target_type(attr.attribute_type)
1148 if type_mapping is not None: 1148 ↛ 1149line 1148 didn't jump to line 1149 because the condition on line 1148 was never true
1149 attr_type = type_mapping.source_type
1150 else:
1151 attr_type = attr.attribute_type
1153 orig = get_origin(attr_type)
1154 valid_values: Sequence[Any] = tuple()
1156 if orig == Literal:
1157 valid_values = get_args(attr_type)
1158 elif orig == bool or attr.attribute_type == bool:
1159 valid_values = ("true", "false")
1160 elif isinstance(attr_type, type) and issubclass(attr_type, DebputyDispatchableType): 1160 ↛ 1177line 1160 didn't jump to line 1177 because the condition on line 1160 was always true
1161 parser: DispatchingTableParser[Any] = pg.dispatch_parser_table_for(attr_type)
1162 if parser is None: 1162 ↛ 1163line 1162 didn't jump to line 1163 because the condition on line 1162 was never true
1163 return None
1164 valid_values = [
1165 k if has_colon or not matched_key else f"{k}:"
1166 for k in parser.registered_keywords()
1167 if isinstance(
1168 parser.parser_for(k).parser,
1169 (
1170 DeclarativeValuelessKeywordInputParser,
1171 AllowNoneDeclarativeInputParser,
1172 ),
1173 )
1174 ^ matched_key
1175 ]
1177 if matched in valid_values: 1177 ↛ 1178line 1177 didn't jump to line 1178 because the condition on line 1177 was never true
1178 _info(f"Already filled: {matched} is one of {valid_values}")
1179 return None
1180 if valid_values: 1180 ↛ 1182line 1180 didn't jump to line 1182 because the condition on line 1180 was always true
1181 return [types.CompletionItem(_complete_value(x)) for x in valid_values]
1182 return None
1185def completion_item(
1186 quoted_keyword: str,
1187 pp_subparser: PluginProvidedParser,
1188) -> types.CompletionItem:
1189 inline_reference_documentation = pp_subparser.parser.inline_reference_documentation
1190 synopsis = (
1191 inline_reference_documentation.synopsis
1192 if inline_reference_documentation
1193 else None
1194 )
1195 return types.CompletionItem(
1196 quoted_keyword,
1197 detail=synopsis,
1198 )
1201def _is_inside_manifest_variable_substitution(
1202 lines: list[str],
1203 server_position: types.Position,
1204) -> bool:
1206 current_line = lines[server_position.line]
1207 try:
1208 open_idx = current_line[0 : server_position.character].rindex("{{")
1209 return "}}" not in current_line[open_idx : server_position.character]
1210 except ValueError:
1211 return False
1214def _manifest_substitution_variable_at_position(
1215 lines: list[str],
1216 server_position: types.Position,
1217) -> str | None:
1218 current_line = lines[server_position.line]
1219 try:
1220 open_idx = current_line[0 : server_position.character].rindex("{{") + 2
1221 if "}}" in current_line[open_idx : server_position.character]: 1221 ↛ 1222line 1221 didn't jump to line 1222 because the condition on line 1221 was never true
1222 return None
1223 variable_len = current_line[open_idx:].index("}}")
1224 close_idx = open_idx + variable_len
1225 except ValueError as e:
1226 return None
1227 return current_line[open_idx:close_idx]
1230def _insert_complete_marker_and_parse_yaml(
1231 lines: list[str],
1232 server_position: types.Position,
1233) -> Any | None:
1234 added_key = insert_complete_marker_snippet(lines, server_position)
1235 attempts = 1 if added_key else 2
1236 content = None
1237 while attempts > 0: 1237 ↛ 1269line 1237 didn't jump to line 1269 because the condition on line 1237 was always true
1238 attempts -= 1
1239 try:
1240 # Since we mutated the lines to insert a token, `doc.source` cannot
1241 # be used here.
1242 content = MANIFEST_YAML.load("".join(lines))
1243 break
1244 except MarkedYAMLError as e:
1245 context_line = (
1246 e.context_mark.line if e.context_mark else e.problem_mark.line
1247 )
1248 if (
1249 e.problem_mark.line != server_position.line
1250 and context_line != server_position.line
1251 ):
1252 l_data = (
1253 lines[e.problem_mark.line].rstrip()
1254 if e.problem_mark.line < len(lines)
1255 else "N/A (OOB)"
1256 )
1258 _info(f"Parse error on line: {e.problem_mark.line}: {l_data}")
1259 return None
1261 if attempts > 0:
1262 # Try to make it a key and see if that fixes the problem
1263 new_line = (
1264 lines[server_position.line].rstrip() + YAML_COMPLETION_HINT_KEY
1265 )
1266 lines[server_position.line] = new_line
1267 except YAMLError:
1268 break
1269 return content
1272def generic_yaml_completer(
1273 ls: "DebputyLanguageServer",
1274 params: types.CompletionParams,
1275 root_parser: DeclarativeInputParser[Any],
1276) -> types.CompletionList | Sequence[types.CompletionItem] | None:
1277 doc = ls.workspace.get_text_document(params.text_document.uri)
1278 lines = _lines(doc.lines)
1279 server_position = doc.position_codec.position_from_client_units(
1280 lines, params.position
1281 )
1282 orig_line = lines[server_position.line].rstrip()
1283 has_colon = ":" in orig_line
1285 content = _insert_complete_marker_and_parse_yaml(lines, server_position)
1286 if content is None: 1286 ↛ 1287line 1286 didn't jump to line 1287 because the condition on line 1286 was never true
1287 context = lines[server_position.line].replace("\n", "\\n")
1288 _info(f"Completion failed: parse error: Line in question: {context}")
1289 return None
1290 attribute_root_path = AttributePath.root_path(content)
1291 m = _trace_cursor(content, attribute_root_path, server_position)
1293 if m is None: 1293 ↛ 1294line 1293 didn't jump to line 1294 because the condition on line 1293 was never true
1294 _info("No match")
1295 return None
1296 matched_key, attr_path, matched, parent = m
1297 _info(f"Matched path: {matched} (path: {attr_path.path}) [{matched_key=}]")
1298 feature_set = ls.plugin_feature_set
1299 segments = list(attr_path.path_segments())
1300 km = resolve_keyword(
1301 root_parser,
1302 DEBPUTY_PLUGIN_METADATA,
1303 segments,
1304 0,
1305 feature_set.manifest_parser_generator,
1306 is_completion_attempt=True,
1307 )
1308 if km is None: 1308 ↛ 1309line 1308 didn't jump to line 1309 because the condition on line 1308 was never true
1309 return None
1310 parser, _, at_depth_idx = km
1311 _info(f"Match leaf parser {at_depth_idx} -- {parser.__class__}")
1312 items = []
1313 if at_depth_idx + 1 < len(segments): 1313 ↛ 1314line 1313 didn't jump to line 1314 because the condition on line 1313 was never true
1314 return items
1316 if _is_inside_manifest_variable_substitution(lines, server_position):
1317 return [
1318 types.CompletionItem(
1319 pv.variable_name,
1320 detail=pv.variable_reference_documentation,
1321 sort_text=(
1322 f"zz-{pv.variable_name}"
1323 if pv.is_for_special_case
1324 else pv.variable_name
1325 ),
1326 )
1327 for pv in ls.plugin_feature_set.manifest_variables.values()
1328 if not pv.is_internal
1329 ]
1331 if isinstance(parser, DispatchingParserBase):
1332 if matched_key:
1333 items = [
1334 completion_item(
1335 (
1336 maybe_quote_yaml_value(k)
1337 if has_colon
1338 else f"{maybe_quote_yaml_value(k)}:"
1339 ),
1340 pp_subparser,
1341 )
1342 for k, pp_subparser in _keywords_with_parser(parser)
1343 if k not in parent
1344 and not isinstance(
1345 pp_subparser.parser,
1346 DeclarativeValuelessKeywordInputParser,
1347 )
1348 ]
1349 else:
1350 items = [
1351 completion_item(maybe_quote_yaml_value(k), pp_subparser)
1352 for k, pp_subparser in _keywords_with_parser(parser)
1353 if k not in parent
1354 and isinstance(
1355 pp_subparser.parser,
1356 DeclarativeValuelessKeywordInputParser,
1357 )
1358 ]
1359 elif isinstance(parser, InPackageContextParser): 1359 ↛ 1360line 1359 didn't jump to line 1360 because the condition on line 1359 was never true
1360 binary_packages = ls.lint_state(doc).binary_packages
1361 if binary_packages is not None:
1362 items = [
1363 types.CompletionItem(
1364 maybe_quote_yaml_value(p)
1365 if has_colon
1366 else f"{maybe_quote_yaml_value(p)}:"
1367 )
1368 for p in binary_packages
1369 if p not in parent
1370 ]
1371 elif isinstance(parser, DeclarativeMappingInputParser):
1372 if matched_key:
1373 _info("Match attributes")
1374 locked = set(parent)
1375 for mx in parser.mutually_exclusive_attributes:
1376 if not mx.isdisjoint(parent.keys()):
1377 locked.update(mx)
1378 for attr_name, attr in parser.manifest_attributes.items():
1379 if not attr.conflicting_attributes.isdisjoint(parent.keys()):
1380 locked.add(attr_name)
1381 break
1382 items = [
1383 types.CompletionItem(
1384 maybe_quote_yaml_value(k)
1385 if has_colon
1386 else f"{maybe_quote_yaml_value(k)}:"
1387 )
1388 for k in parser.manifest_attributes
1389 if k not in locked
1390 ]
1391 else:
1392 # Value
1393 key = segments[at_depth_idx] if len(segments) > at_depth_idx else None
1394 value_attr = (
1395 parser.manifest_attributes.get(key) if isinstance(key, str) else None
1396 )
1397 if value_attr is not None: 1397 ↛ 1407line 1397 didn't jump to line 1407 because the condition on line 1397 was always true
1398 _info(f"Expand value / key: {key} -- {value_attr.attribute_type}")
1399 return completion_from_attr(
1400 value_attr,
1401 feature_set.manifest_parser_generator,
1402 matched,
1403 matched_key=False,
1404 has_colon=has_colon,
1405 )
1406 else:
1407 _info(
1408 f"Expand value / key: {key} -- !! {list(parser.manifest_attributes)}"
1409 )
1410 elif isinstance(parser, DeclarativeNonMappingInputParser): 1410 ↛ 1419line 1410 didn't jump to line 1419 because the condition on line 1410 was always true
1411 alt_attr = parser.alt_form_parser
1412 return completion_from_attr(
1413 alt_attr,
1414 feature_set.manifest_parser_generator,
1415 matched,
1416 matched_key=matched_key,
1417 has_colon=has_colon,
1418 )
1419 return items
1422def generic_yaml_hover(
1423 ls: "DebputyLanguageServer",
1424 params: types.HoverParams,
1425 root_parser_initializer: Callable[
1426 [ParserGenerator], DeclarativeInputParser[Any] | DispatchingParserBase
1427 ],
1428 *,
1429 show_integration_mode: bool = False,
1430) -> types.Hover | None:
1431 doc = ls.workspace.get_text_document(params.text_document.uri)
1432 lines = doc.lines
1433 position_codec = doc.position_codec
1434 server_position = position_codec.position_from_client_units(lines, params.position)
1436 try:
1437 content = MANIFEST_YAML.load(doc.source)
1438 except YAMLError:
1439 return None
1440 attribute_root_path = AttributePath.root_path(content)
1441 m = _trace_cursor(content, attribute_root_path, server_position)
1442 if m is None: 1442 ↛ 1443line 1442 didn't jump to line 1443 because the condition on line 1442 was never true
1443 _info("No match")
1444 return None
1445 matched_key, attr_path, matched, _ = m
1446 _info(f"Matched path: {matched} (path: {attr_path.path}) [{matched_key=}]")
1448 feature_set = ls.plugin_feature_set
1449 parser_generator = feature_set.manifest_parser_generator
1450 root_parser = root_parser_initializer(parser_generator)
1451 segments = list(attr_path.path_segments())
1452 km = resolve_keyword(
1453 root_parser,
1454 DEBPUTY_PLUGIN_METADATA,
1455 segments,
1456 0,
1457 parser_generator,
1458 )
1459 if km is None: 1459 ↛ 1460line 1459 didn't jump to line 1460 because the condition on line 1459 was never true
1460 _info("No keyword match")
1461 return None
1462 parser, plugin_metadata, at_depth_idx = km
1464 manifest_variable_at_pos = _manifest_substitution_variable_at_position(
1465 lines, server_position
1466 )
1468 if manifest_variable_at_pos:
1469 variable = ls.plugin_feature_set.manifest_variables.get(
1470 manifest_variable_at_pos
1471 )
1472 if variable is not None: 1472 ↛ 1499line 1472 didn't jump to line 1499 because the condition on line 1472 was always true
1473 var_doc = (
1474 variable.variable_reference_documentation
1475 or "No documentation available"
1476 )
1478 if variable.is_context_specific_variable: 1478 ↛ 1479line 1478 didn't jump to line 1479 because the condition on line 1478 was never true
1479 value = "\nThe value depends on the context"
1480 else:
1481 debian_dir = ls.lint_state(doc).debian_dir
1482 value = ""
1483 if debian_dir: 1483 ↛ 1484line 1483 didn't jump to line 1484 because the condition on line 1483 was never true
1484 variable_context = VariableContext(debian_dir)
1485 try:
1486 resolved = variable.resolve(variable_context)
1487 value = f"\nResolves to: `{resolved}`"
1488 except RuntimeError:
1489 pass
1491 hover_doc_text = textwrap.dedent("""\
1492 # `{NAME}`
1494 {DOC}
1495 {VALUE}
1496 """).format(NAME=variable.variable_name, DOC=var_doc, VALUE=value)
1497 return as_hover_doc(ls, hover_doc_text)
1499 _info(
1500 f"Match leaf parser {at_depth_idx}/{len(segments)} -- {parser.__class__} -- {manifest_variable_at_pos}"
1501 )
1502 hover_doc_text = resolve_hover_text(
1503 feature_set,
1504 parser,
1505 plugin_metadata,
1506 ls.hover_output_style,
1507 show_integration_mode,
1508 segments,
1509 at_depth_idx,
1510 matched,
1511 matched_key,
1512 )
1513 return as_hover_doc(ls, hover_doc_text)