Coverage for src/debputy/lsp/lsp_debian_debputy_manifest.py: 80%
249 statements
« prev ^ index » next coverage.py v7.6.0, created at 2025-01-27 13:59 +0000
« prev ^ index » next coverage.py v7.6.0, created at 2025-01-27 13:59 +0000
1from typing import (
2 Optional,
3 Any,
4 Tuple,
5 Union,
6 Sequence,
7 Literal,
8 get_args,
9 get_origin,
10 Container,
11)
13from debputy.highlevel_manifest import MANIFEST_YAML
14from debputy.linting.lint_util import LintState
15from debputy.lsp.lsp_features import (
16 lint_diagnostics,
17 lsp_standard_handler,
18 lsp_hover,
19 lsp_completer,
20 SecondaryLanguage,
21 LanguageDispatchRule,
22)
23from debputy.lsp.lsp_generic_yaml import (
24 error_range_at_position,
25 YAML_COMPLETION_HINT_KEY,
26 insert_complete_marker_snippet,
27 yaml_key_range,
28 yaml_flag_unknown_key,
29 _trace_cursor,
30 generic_yaml_hover,
31 resolve_keyword,
32 DEBPUTY_PLUGIN_METADATA,
33 maybe_quote_yaml_value,
34 completion_from_attr,
35)
36from debputy.lsp.quickfixes import propose_correct_text_quick_fix
37from debputy.lsp.vendoring._deb822_repro.locatable import (
38 Position as TEPosition,
39 Range as TERange,
40)
41from debputy.lsprotocol.types import (
42 TEXT_DOCUMENT_WILL_SAVE_WAIT_UNTIL,
43 HoverParams,
44 Hover,
45 TEXT_DOCUMENT_CODE_ACTION,
46 CompletionParams,
47 CompletionList,
48 CompletionItem,
49 DiagnosticRelatedInformation,
50 Location,
51)
52from debputy.manifest_parser.declarative_parser import (
53 AttributeDescription,
54 ParserGenerator,
55 DeclarativeNonMappingInputParser,
56)
57from debputy.manifest_parser.declarative_parser import DeclarativeMappingInputParser
58from debputy.manifest_parser.tagging_types import DebputyDispatchableType
59from debputy.manifest_parser.util import AttributePath
60from debputy.plugin.api.impl_types import (
61 DeclarativeInputParser,
62 DispatchingParserBase,
63 ListWrappedDeclarativeInputParser,
64 InPackageContextParser,
65 DeclarativeValuelessKeywordInputParser,
66 PluginProvidedParser,
67)
68from debputy.plugin.api.parser_tables import OPARSER_MANIFEST_ROOT
69from debputy.plugin.api.spec import DebputyIntegrationMode
70from debputy.plugin.debputy.private_api import Capability, load_libcap
71from debputy.util import _info
72from debputy.yaml.compat import (
73 CommentedMap,
74 CommentedSeq,
75 MarkedYAMLError,
76 YAMLError,
77)
79try:
80 from pygls.server import LanguageServer
81 from debputy.lsp.debputy_ls import DebputyLanguageServer
82except ImportError:
83 pass
86_DISPATCH_RULE = LanguageDispatchRule.new_rule(
87 "debian/debputy.manifest",
88 "debian/debputy.manifest",
89 [
90 SecondaryLanguage("debputy.manifest"),
91 # LSP's official language ID for YAML files
92 SecondaryLanguage("yaml", filename_based_lookup=True),
93 ],
94)
97lsp_standard_handler(_DISPATCH_RULE, TEXT_DOCUMENT_CODE_ACTION)
98lsp_standard_handler(_DISPATCH_RULE, TEXT_DOCUMENT_WILL_SAVE_WAIT_UNTIL)
101@lint_diagnostics(_DISPATCH_RULE)
102def _lint_debian_debputy_manifest(lint_state: LintState) -> None:
103 lines = lint_state.lines
104 try:
105 content = MANIFEST_YAML.load("".join(lines))
106 except MarkedYAMLError as e: 106 ↛ 124line 106 didn't jump to line 124
107 if e.context_mark:
108 line = e.context_mark.line
109 column = e.context_mark.column
110 else:
111 line = e.problem_mark.line
112 column = e.problem_mark.column
113 error_range = error_range_at_position(
114 lines,
115 line,
116 column,
117 )
118 lint_state.emit_diagnostic(
119 error_range,
120 f"YAML parse error: {e}",
121 "error",
122 "debputy",
123 )
124 except YAMLError as e:
125 error_range = TERange(
126 TEPosition(0, 0),
127 TEPosition(0, len(lines[0])),
128 )
129 lint_state.emit_diagnostic(
130 error_range,
131 f"Unknown YAML parse error: {e} [{e!r}]",
132 "error",
133 "debputy",
134 )
135 else:
136 feature_set = lint_state.plugin_feature_set
137 pg = feature_set.manifest_parser_generator
138 root_parser = pg.dispatchable_object_parsers[OPARSER_MANIFEST_ROOT]
139 debputy_integration_mode = lint_state.debputy_metadata.debputy_integration_mode
141 _lint_content(
142 lint_state,
143 pg,
144 root_parser,
145 debputy_integration_mode,
146 content,
147 )
150def _integration_mode_allows_key(
151 lint_state: LintState,
152 debputy_integration_mode: Optional[DebputyIntegrationMode],
153 expected_debputy_integration_modes: Optional[Container[DebputyIntegrationMode]],
154 key: str,
155 line: int,
156 col: int,
157) -> None:
158 if debputy_integration_mode is None or expected_debputy_integration_modes is None:
159 return
160 if debputy_integration_mode in expected_debputy_integration_modes:
161 return
162 key_range = yaml_key_range(key, line, col)
163 lint_state.emit_diagnostic(
164 key_range,
165 f'Feature "{key}" not supported in integration mode {debputy_integration_mode}',
166 "error",
167 "debputy",
168 )
171def _conflicting_key(
172 lint_state: LintState,
173 key_a: str,
174 key_b: str,
175 key_a_line: int,
176 key_a_col: int,
177 key_b_line: int,
178 key_b_col: int,
179) -> None:
180 key_a_range = TERange(
181 TEPosition(
182 key_a_line,
183 key_a_col,
184 ),
185 TEPosition(
186 key_a_line,
187 key_a_col + len(key_a),
188 ),
189 )
190 key_b_range = TERange(
191 TEPosition(
192 key_b_line,
193 key_b_col,
194 ),
195 TEPosition(
196 key_b_line,
197 key_b_col + len(key_b),
198 ),
199 )
200 lint_state.emit_diagnostic(
201 key_a_range,
202 f'The "{key_a}" cannot be used with "{key_b}".',
203 "error",
204 "debputy",
205 related_information=[
206 DiagnosticRelatedInformation(
207 location=Location(
208 lint_state.doc_uri,
209 key_b_range,
210 ),
211 message=f'The attribute "{key_b}" is used here.',
212 )
213 ],
214 )
216 lint_state.emit_diagnostic(
217 key_b_range,
218 f'The "{key_b}" cannot be used with "{key_a}".',
219 "error",
220 "debputy",
221 related_information=[
222 DiagnosticRelatedInformation(
223 location=Location(
224 lint_state.doc_uri,
225 key_a_range,
226 ),
227 message=f'The attribute "{key_a}" is used here.',
228 )
229 ],
230 )
233def _remaining_line(lint_state: LintState, line_no: int, pos_start: int) -> "TERange":
234 raw_line = lint_state.lines[line_no].rstrip()
235 pos_end = len(raw_line)
236 return TERange(
237 TEPosition(
238 line_no,
239 pos_start,
240 ),
241 TEPosition(
242 line_no,
243 pos_end,
244 ),
245 )
248def _lint_attr_value(
249 lint_state: LintState,
250 attr: AttributeDescription,
251 pg: ParserGenerator,
252 debputy_integration_mode: Optional[DebputyIntegrationMode],
253 key: str,
254 value: Any,
255 pos: Tuple[int, int],
256) -> None:
257 target_attr_type = attr.attribute_type
258 type_mapping = pg.get_mapped_type_from_target_type(target_attr_type)
259 source_attr_type = target_attr_type
260 if type_mapping is not None:
261 source_attr_type = type_mapping.source_type
262 orig = get_origin(source_attr_type)
263 valid_values: Optional[Sequence[Any]] = None
264 if orig == Literal:
265 valid_values = get_args(attr.attribute_type)
266 elif orig == bool or attr.attribute_type == bool:
267 valid_values = (True, False)
268 elif isinstance(target_attr_type, type):
269 if issubclass(target_attr_type, Capability): 269 ↛ 270line 269 didn't jump to line 270 because the condition on line 269 was never true
270 has_libcap, _, is_valid_cap = load_libcap()
271 if has_libcap and not is_valid_cap(value):
272 line_no, cursor_pos = pos
273 cap_range = _remaining_line(lint_state, line_no, cursor_pos)
274 lint_state.emit_diagnostic(
275 cap_range,
276 "The value could not be parsed as a capability via cap_from_text on this system",
277 "warning",
278 "debputy",
279 )
280 return
281 if issubclass(target_attr_type, DebputyDispatchableType):
282 parser = pg.dispatch_parser_table_for(target_attr_type)
283 _lint_content(
284 lint_state,
285 pg,
286 parser,
287 debputy_integration_mode,
288 value,
289 )
290 return
292 if valid_values is None or value in valid_values:
293 return
294 line_no, cursor_pos = pos
295 value_range = _remaining_line(lint_state, line_no, cursor_pos)
296 lint_state.emit_diagnostic(
297 value_range,
298 f'Not a supported value for "{key}"',
299 "error",
300 "debputy",
301 quickfixes=[
302 propose_correct_text_quick_fix(_as_yaml_value(m)) for m in valid_values
303 ],
304 )
307def _as_yaml_value(v: Any) -> str:
308 if isinstance(v, bool):
309 return str(v).lower()
310 return str(v)
313def _lint_declarative_mapping_input_parser(
314 lint_state: LintState,
315 pg: ParserGenerator,
316 parser: DeclarativeMappingInputParser,
317 debputy_integration_mode: Optional[DebputyIntegrationMode],
318 content: Any,
319) -> None:
320 if not isinstance(content, CommentedMap):
321 return
322 lc = content.lc
323 for key, value in content.items():
324 attr = parser.manifest_attributes.get(key)
325 line, col = lc.key(key)
326 if attr is None:
327 corrected_key = yaml_flag_unknown_key(
328 lint_state,
329 key,
330 parser.manifest_attributes,
331 line,
332 col,
333 )
334 if corrected_key: 334 ↛ 335line 334 didn't jump to line 335 because the condition on line 334 was never true
335 key = corrected_key
336 attr = parser.manifest_attributes.get(corrected_key)
337 if attr is None:
338 continue
340 _lint_attr_value(
341 lint_state,
342 attr,
343 pg,
344 debputy_integration_mode,
345 key,
346 value,
347 lc.value(key),
348 )
350 for forbidden_key in attr.conflicting_attributes: 350 ↛ 351line 350 didn't jump to line 351 because the loop on line 350 never started
351 if forbidden_key in content:
352 con_line, con_col = lc.key(forbidden_key)
353 _conflicting_key(
354 lint_state,
355 key,
356 forbidden_key,
357 line,
358 col,
359 con_line,
360 con_col,
361 )
362 for mx in parser.mutually_exclusive_attributes:
363 matches = content.keys() & mx
364 if len(matches) < 2: 364 ↛ 366line 364 didn't jump to line 366 because the condition on line 364 was always true
365 continue
366 key, *others = list(matches)
367 line, col = lc.key(key)
368 for other in others:
369 con_line, con_col = lc.key(other)
370 _conflicting_key(
371 lint_state,
372 key,
373 other,
374 line,
375 col,
376 con_line,
377 con_col,
378 )
381def _lint_content(
382 lint_state: LintState,
383 pg: ParserGenerator,
384 parser: DeclarativeInputParser[Any],
385 debputy_integration_mode: Optional[DebputyIntegrationMode],
386 content: Any,
387) -> None:
388 if isinstance(parser, DispatchingParserBase):
389 if not isinstance(content, CommentedMap):
390 return
391 lc = content.lc
392 for key, value in content.items():
393 is_known = parser.is_known_keyword(key)
394 line, col = lc.key(key)
395 orig_key = key
396 if not is_known:
397 corrected_key = yaml_flag_unknown_key(
398 lint_state,
399 key,
400 parser.registered_keywords(),
401 line,
402 col,
403 unknown_keys_diagnostic_severity=parser.unknown_keys_diagnostic_severity,
404 )
405 if corrected_key is not None:
406 key = corrected_key
407 is_known = True
409 if is_known:
410 subparser = parser.parser_for(key)
411 assert subparser is not None
412 _integration_mode_allows_key(
413 lint_state,
414 debputy_integration_mode,
415 subparser.parser.expected_debputy_integration_mode,
416 orig_key,
417 line,
418 col,
419 )
420 _lint_content(
421 lint_state,
422 pg,
423 subparser.parser,
424 debputy_integration_mode,
425 value,
426 )
427 elif isinstance(parser, ListWrappedDeclarativeInputParser):
428 if not isinstance(content, CommentedSeq): 428 ↛ 429line 428 didn't jump to line 429 because the condition on line 428 was never true
429 return
430 subparser = parser.delegate
431 for value in content:
432 _lint_content(lint_state, pg, subparser, debputy_integration_mode, value)
433 elif isinstance(parser, InPackageContextParser):
434 if not isinstance(content, CommentedMap): 434 ↛ 435line 434 didn't jump to line 435 because the condition on line 434 was never true
435 return
436 known_packages = lint_state.binary_packages
437 lc = content.lc
438 for k, v in content.items():
439 if k is None or (
440 "{{" not in k and known_packages is not None and k not in known_packages
441 ):
442 line, col = lc.key(k)
443 yaml_flag_unknown_key(
444 lint_state,
445 k,
446 known_packages,
447 line,
448 col,
449 message_format='Unknown package "{key}".',
450 )
451 _lint_content(
452 lint_state,
453 pg,
454 parser.delegate,
455 debputy_integration_mode,
456 v,
457 )
458 elif isinstance(parser, DeclarativeMappingInputParser):
459 _lint_declarative_mapping_input_parser(
460 lint_state,
461 pg,
462 parser,
463 debputy_integration_mode,
464 content,
465 )
468def keywords_with_parser(
469 parser: Union[DeclarativeMappingInputParser, DispatchingParserBase],
470) -> Tuple[str, PluginProvidedParser]:
471 for keyword in parser.registered_keywords():
472 pp_subparser = parser.parser_for(keyword)
473 yield keyword, pp_subparser
476def completion_item(
477 quoted_keyword: str,
478 pp_subparser: PluginProvidedParser,
479) -> CompletionItem:
480 inline_reference_documentation = pp_subparser.parser.inline_reference_documentation
481 synopsis = (
482 inline_reference_documentation.synopsis
483 if inline_reference_documentation
484 else None
485 )
486 return CompletionItem(
487 quoted_keyword,
488 detail=synopsis,
489 )
492@lsp_completer(_DISPATCH_RULE)
493def debputy_manifest_completer(
494 ls: "DebputyLanguageServer",
495 params: CompletionParams,
496) -> Optional[Union[CompletionList, Sequence[CompletionItem]]]:
497 doc = ls.workspace.get_text_document(params.text_document.uri)
498 lines = doc.lines
499 server_position = doc.position_codec.position_from_client_units(
500 lines, params.position
501 )
502 orig_line = lines[server_position.line].rstrip()
503 has_colon = ":" in orig_line
504 added_key = insert_complete_marker_snippet(lines, server_position)
505 attempts = 1 if added_key else 2
506 content = None
508 while attempts > 0: 508 ↛ 538line 508 didn't jump to line 538 because the condition on line 508 was always true
509 attempts -= 1
510 try:
511 content = MANIFEST_YAML.load("".join(lines))
512 break
513 except MarkedYAMLError as e:
514 context_line = (
515 e.context_mark.line if e.context_mark else e.problem_mark.line
516 )
517 if (
518 e.problem_mark.line != server_position.line
519 and context_line != server_position.line
520 ):
521 l_data = (
522 lines[e.problem_mark.line].rstrip()
523 if e.problem_mark.line < len(lines)
524 else "N/A (OOB)"
525 )
527 _info(f"Parse error on line: {e.problem_mark.line}: {l_data}")
528 return None
530 if attempts > 0:
531 # Try to make it a key and see if that fixes the problem
532 new_line = (
533 lines[server_position.line].rstrip() + YAML_COMPLETION_HINT_KEY
534 )
535 lines[server_position.line] = new_line
536 except YAMLError:
537 break
538 if content is None: 538 ↛ 539line 538 didn't jump to line 539 because the condition on line 538 was never true
539 context = lines[server_position.line].replace("\n", "\\n")
540 _info(f"Completion failed: parse error: Line in question: {context}")
541 return None
542 attribute_root_path = AttributePath.root_path(content)
543 m = _trace_cursor(content, attribute_root_path, server_position)
545 if m is None: 545 ↛ 546line 545 didn't jump to line 546 because the condition on line 545 was never true
546 _info("No match")
547 return None
548 matched_key, attr_path, matched, parent = m
549 _info(f"Matched path: {matched} (path: {attr_path.path}) [{matched_key=}]")
550 feature_set = ls.plugin_feature_set
551 root_parser = feature_set.manifest_parser_generator.dispatchable_object_parsers[
552 OPARSER_MANIFEST_ROOT
553 ]
554 segments = list(attr_path.path_segments())
555 km = resolve_keyword(
556 root_parser,
557 DEBPUTY_PLUGIN_METADATA,
558 segments,
559 0,
560 feature_set.manifest_parser_generator,
561 is_completion_attempt=True,
562 )
563 if km is None: 563 ↛ 564line 563 didn't jump to line 564 because the condition on line 563 was never true
564 return None
565 parser, _, at_depth_idx = km
566 _info(f"Match leaf parser {at_depth_idx} -- {parser.__class__}")
567 items = []
568 if at_depth_idx + 1 >= len(segments): 568 ↛ 651line 568 didn't jump to line 651 because the condition on line 568 was always true
569 if isinstance(parser, DispatchingParserBase):
570 if matched_key:
571 items = [
572 completion_item(
573 (
574 maybe_quote_yaml_value(k)
575 if has_colon
576 else f"{maybe_quote_yaml_value(k)}:"
577 ),
578 pp_subparser,
579 )
580 for k, pp_subparser in keywords_with_parser(parser)
581 if k not in parent
582 and not isinstance(
583 pp_subparser.parser,
584 DeclarativeValuelessKeywordInputParser,
585 )
586 ]
587 else:
588 items = [
589 completion_item(maybe_quote_yaml_value(k), pp_subparser)
590 for k, pp_subparser in keywords_with_parser(parser)
591 if k not in parent
592 and isinstance(
593 pp_subparser.parser,
594 DeclarativeValuelessKeywordInputParser,
595 )
596 ]
597 elif isinstance(parser, InPackageContextParser): 597 ↛ 598line 597 didn't jump to line 598 because the condition on line 597 was never true
598 binary_packages = ls.lint_state(doc).binary_packages
599 if binary_packages is not None:
600 items = [
601 CompletionItem(
602 maybe_quote_yaml_value(p)
603 if has_colon
604 else f"{maybe_quote_yaml_value(p)}:"
605 )
606 for p in binary_packages
607 if p not in parent
608 ]
609 elif isinstance(parser, DeclarativeMappingInputParser):
610 if matched_key:
611 _info("Match attributes")
612 locked = set(parent)
613 for mx in parser.mutually_exclusive_attributes:
614 if not mx.isdisjoint(parent.keys()):
615 locked.update(mx)
616 for attr_name, attr in parser.manifest_attributes.items():
617 if not attr.conflicting_attributes.isdisjoint(parent.keys()):
618 locked.add(attr_name)
619 break
620 items = [
621 CompletionItem(
622 maybe_quote_yaml_value(k)
623 if has_colon
624 else f"{maybe_quote_yaml_value(k)}:"
625 )
626 for k in parser.manifest_attributes
627 if k not in locked
628 ]
629 else:
630 # Value
631 key = segments[at_depth_idx] if len(segments) > at_depth_idx else None
632 attr = parser.manifest_attributes.get(key)
633 if attr is not None: 633 ↛ 641line 633 didn't jump to line 641 because the condition on line 633 was always true
634 _info(f"Expand value / key: {key} -- {attr.attribute_type}")
635 items = completion_from_attr(
636 attr,
637 feature_set.manifest_parser_generator,
638 matched,
639 )
640 else:
641 _info(
642 f"Expand value / key: {key} -- !! {list(parser.manifest_attributes)}"
643 )
644 elif isinstance(parser, DeclarativeNonMappingInputParser): 644 ↛ 651line 644 didn't jump to line 651 because the condition on line 644 was always true
645 attr = parser.alt_form_parser
646 items = completion_from_attr(
647 attr,
648 feature_set.manifest_parser_generator,
649 matched,
650 )
651 return items
654@lsp_hover(_DISPATCH_RULE)
655def debputy_manifest_hover(
656 ls: "DebputyLanguageServer",
657 params: HoverParams,
658) -> Optional[Hover]:
659 return generic_yaml_hover(
660 ls,
661 params,
662 lambda pg: pg.dispatchable_object_parsers[OPARSER_MANIFEST_ROOT],
663 )