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