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