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