Coverage for src/debputy/lsp/lsp_debian_debputy_manifest.py: 80%
249 statements
« prev ^ index » next coverage.py v7.6.0, created at 2025-03-24 16:38 +0000
« prev ^ index » next coverage.py v7.6.0, created at 2025-03-24 16:38 +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)
102async def _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 lint_state.related_diagnostic_information(
207 key_b_range, f'The attribute "{key_b}" is used here.'
208 ),
209 ],
210 )
212 lint_state.emit_diagnostic(
213 key_b_range,
214 f'The "{key_b}" cannot be used with "{key_a}".',
215 "error",
216 "debputy",
217 related_information=[
218 lint_state.related_diagnostic_information(
219 key_a_range,
220 f'The attribute "{key_a}" is used here.',
221 ),
222 ],
223 )
226def _remaining_line(lint_state: LintState, line_no: int, pos_start: int) -> "TERange":
227 raw_line = lint_state.lines[line_no].rstrip()
228 pos_end = len(raw_line)
229 return TERange(
230 TEPosition(
231 line_no,
232 pos_start,
233 ),
234 TEPosition(
235 line_no,
236 pos_end,
237 ),
238 )
241def _lint_attr_value(
242 lint_state: LintState,
243 attr: AttributeDescription,
244 pg: ParserGenerator,
245 debputy_integration_mode: Optional[DebputyIntegrationMode],
246 key: str,
247 value: Any,
248 pos: Tuple[int, int],
249) -> None:
250 target_attr_type = attr.attribute_type
251 type_mapping = pg.get_mapped_type_from_target_type(target_attr_type)
252 source_attr_type = target_attr_type
253 if type_mapping is not None:
254 source_attr_type = type_mapping.source_type
255 orig = get_origin(source_attr_type)
256 valid_values: Optional[Sequence[Any]] = None
257 if orig == Literal:
258 valid_values = get_args(attr.attribute_type)
259 elif orig == bool or attr.attribute_type == bool:
260 valid_values = (True, False)
261 elif isinstance(target_attr_type, type):
262 if issubclass(target_attr_type, Capability): 262 ↛ 263line 262 didn't jump to line 263 because the condition on line 262 was never true
263 has_libcap, _, is_valid_cap = load_libcap()
264 if has_libcap and not is_valid_cap(value):
265 line_no, cursor_pos = pos
266 cap_range = _remaining_line(lint_state, line_no, cursor_pos)
267 lint_state.emit_diagnostic(
268 cap_range,
269 "The value could not be parsed as a capability via cap_from_text on this system",
270 "warning",
271 "debputy",
272 )
273 return
274 if issubclass(target_attr_type, DebputyDispatchableType):
275 parser = pg.dispatch_parser_table_for(target_attr_type)
276 _lint_content(
277 lint_state,
278 pg,
279 parser,
280 debputy_integration_mode,
281 value,
282 )
283 return
285 if valid_values is None or value in valid_values:
286 return
287 line_no, cursor_pos = pos
288 value_range = _remaining_line(lint_state, line_no, cursor_pos)
289 lint_state.emit_diagnostic(
290 value_range,
291 f'Not a supported value for "{key}"',
292 "error",
293 "debputy",
294 quickfixes=[
295 propose_correct_text_quick_fix(_as_yaml_value(m)) for m in valid_values
296 ],
297 )
300def _as_yaml_value(v: Any) -> str:
301 if isinstance(v, bool):
302 return str(v).lower()
303 return str(v)
306def _lint_declarative_mapping_input_parser(
307 lint_state: LintState,
308 pg: ParserGenerator,
309 parser: DeclarativeMappingInputParser,
310 debputy_integration_mode: Optional[DebputyIntegrationMode],
311 content: Any,
312) -> None:
313 if not isinstance(content, CommentedMap):
314 return
315 lc = content.lc
316 for key, value in content.items():
317 attr = parser.manifest_attributes.get(key)
318 line, col = lc.key(key)
319 if attr is None:
320 corrected_key = yaml_flag_unknown_key(
321 lint_state,
322 key,
323 parser.manifest_attributes,
324 line,
325 col,
326 )
327 if corrected_key: 327 ↛ 328line 327 didn't jump to line 328 because the condition on line 327 was never true
328 key = corrected_key
329 attr = parser.manifest_attributes.get(corrected_key)
330 if attr is None:
331 continue
333 _lint_attr_value(
334 lint_state,
335 attr,
336 pg,
337 debputy_integration_mode,
338 key,
339 value,
340 lc.value(key),
341 )
343 for forbidden_key in attr.conflicting_attributes: 343 ↛ 344line 343 didn't jump to line 344 because the loop on line 343 never started
344 if forbidden_key in content:
345 con_line, con_col = lc.key(forbidden_key)
346 _conflicting_key(
347 lint_state,
348 key,
349 forbidden_key,
350 line,
351 col,
352 con_line,
353 con_col,
354 )
355 for mx in parser.mutually_exclusive_attributes:
356 matches = content.keys() & mx
357 if len(matches) < 2: 357 ↛ 359line 357 didn't jump to line 359 because the condition on line 357 was always true
358 continue
359 key, *others = list(matches)
360 line, col = lc.key(key)
361 for other in others:
362 con_line, con_col = lc.key(other)
363 _conflicting_key(
364 lint_state,
365 key,
366 other,
367 line,
368 col,
369 con_line,
370 con_col,
371 )
374def _lint_content(
375 lint_state: LintState,
376 pg: ParserGenerator,
377 parser: DeclarativeInputParser[Any],
378 debputy_integration_mode: Optional[DebputyIntegrationMode],
379 content: Any,
380) -> None:
381 if isinstance(parser, DispatchingParserBase):
382 if not isinstance(content, CommentedMap):
383 return
384 lc = content.lc
385 for key, value in content.items():
386 is_known = parser.is_known_keyword(key)
387 line, col = lc.key(key)
388 orig_key = key
389 if not is_known:
390 corrected_key = yaml_flag_unknown_key(
391 lint_state,
392 key,
393 parser.registered_keywords(),
394 line,
395 col,
396 unknown_keys_diagnostic_severity=parser.unknown_keys_diagnostic_severity,
397 )
398 if corrected_key is not None:
399 key = corrected_key
400 is_known = True
402 if is_known:
403 subparser = parser.parser_for(key)
404 assert subparser is not None
405 _integration_mode_allows_key(
406 lint_state,
407 debputy_integration_mode,
408 subparser.parser.expected_debputy_integration_mode,
409 orig_key,
410 line,
411 col,
412 )
413 _lint_content(
414 lint_state,
415 pg,
416 subparser.parser,
417 debputy_integration_mode,
418 value,
419 )
420 elif isinstance(parser, ListWrappedDeclarativeInputParser):
421 if not isinstance(content, CommentedSeq): 421 ↛ 422line 421 didn't jump to line 422 because the condition on line 421 was never true
422 return
423 subparser = parser.delegate
424 for value in content:
425 _lint_content(lint_state, pg, subparser, debputy_integration_mode, value)
426 elif isinstance(parser, InPackageContextParser):
427 if not isinstance(content, CommentedMap): 427 ↛ 428line 427 didn't jump to line 428 because the condition on line 427 was never true
428 return
429 known_packages = lint_state.binary_packages
430 lc = content.lc
431 for k, v in content.items():
432 if k is None or (
433 "{{" not in k and known_packages is not None and k not in known_packages
434 ):
435 line, col = lc.key(k)
436 yaml_flag_unknown_key(
437 lint_state,
438 k,
439 known_packages,
440 line,
441 col,
442 message_format='Unknown package "{key}".',
443 )
444 _lint_content(
445 lint_state,
446 pg,
447 parser.delegate,
448 debputy_integration_mode,
449 v,
450 )
451 elif isinstance(parser, DeclarativeMappingInputParser):
452 _lint_declarative_mapping_input_parser(
453 lint_state,
454 pg,
455 parser,
456 debputy_integration_mode,
457 content,
458 )
461def keywords_with_parser(
462 parser: Union[DeclarativeMappingInputParser, DispatchingParserBase],
463) -> Tuple[str, PluginProvidedParser]:
464 for keyword in parser.registered_keywords():
465 pp_subparser = parser.parser_for(keyword)
466 yield keyword, pp_subparser
469def completion_item(
470 quoted_keyword: str,
471 pp_subparser: PluginProvidedParser,
472) -> CompletionItem:
473 inline_reference_documentation = pp_subparser.parser.inline_reference_documentation
474 synopsis = (
475 inline_reference_documentation.synopsis
476 if inline_reference_documentation
477 else None
478 )
479 return CompletionItem(
480 quoted_keyword,
481 detail=synopsis,
482 )
485@lsp_completer(_DISPATCH_RULE)
486def debputy_manifest_completer(
487 ls: "DebputyLanguageServer",
488 params: CompletionParams,
489) -> Optional[Union[CompletionList, Sequence[CompletionItem]]]:
490 doc = ls.workspace.get_text_document(params.text_document.uri)
491 lines = doc.lines
492 server_position = doc.position_codec.position_from_client_units(
493 lines, params.position
494 )
495 orig_line = lines[server_position.line].rstrip()
496 has_colon = ":" in orig_line
497 added_key = insert_complete_marker_snippet(lines, server_position)
498 attempts = 1 if added_key else 2
499 content = None
501 while attempts > 0: 501 ↛ 531line 501 didn't jump to line 531 because the condition on line 501 was always true
502 attempts -= 1
503 try:
504 content = MANIFEST_YAML.load("".join(lines))
505 break
506 except MarkedYAMLError as e:
507 context_line = (
508 e.context_mark.line if e.context_mark else e.problem_mark.line
509 )
510 if (
511 e.problem_mark.line != server_position.line
512 and context_line != server_position.line
513 ):
514 l_data = (
515 lines[e.problem_mark.line].rstrip()
516 if e.problem_mark.line < len(lines)
517 else "N/A (OOB)"
518 )
520 _info(f"Parse error on line: {e.problem_mark.line}: {l_data}")
521 return None
523 if attempts > 0:
524 # Try to make it a key and see if that fixes the problem
525 new_line = (
526 lines[server_position.line].rstrip() + YAML_COMPLETION_HINT_KEY
527 )
528 lines[server_position.line] = new_line
529 except YAMLError:
530 break
531 if content is None: 531 ↛ 532line 531 didn't jump to line 532 because the condition on line 531 was never true
532 context = lines[server_position.line].replace("\n", "\\n")
533 _info(f"Completion failed: parse error: Line in question: {context}")
534 return None
535 attribute_root_path = AttributePath.root_path(content)
536 m = _trace_cursor(content, attribute_root_path, server_position)
538 if m is None: 538 ↛ 539line 538 didn't jump to line 539 because the condition on line 538 was never true
539 _info("No match")
540 return None
541 matched_key, attr_path, matched, parent = m
542 _info(f"Matched path: {matched} (path: {attr_path.path}) [{matched_key=}]")
543 feature_set = ls.plugin_feature_set
544 root_parser = feature_set.manifest_parser_generator.dispatchable_object_parsers[
545 OPARSER_MANIFEST_ROOT
546 ]
547 segments = list(attr_path.path_segments())
548 km = resolve_keyword(
549 root_parser,
550 DEBPUTY_PLUGIN_METADATA,
551 segments,
552 0,
553 feature_set.manifest_parser_generator,
554 is_completion_attempt=True,
555 )
556 if km is None: 556 ↛ 557line 556 didn't jump to line 557 because the condition on line 556 was never true
557 return None
558 parser, _, at_depth_idx = km
559 _info(f"Match leaf parser {at_depth_idx} -- {parser.__class__}")
560 items = []
561 if at_depth_idx + 1 >= len(segments): 561 ↛ 644line 561 didn't jump to line 644 because the condition on line 561 was always true
562 if isinstance(parser, DispatchingParserBase):
563 if matched_key:
564 items = [
565 completion_item(
566 (
567 maybe_quote_yaml_value(k)
568 if has_colon
569 else f"{maybe_quote_yaml_value(k)}:"
570 ),
571 pp_subparser,
572 )
573 for k, pp_subparser in keywords_with_parser(parser)
574 if k not in parent
575 and not isinstance(
576 pp_subparser.parser,
577 DeclarativeValuelessKeywordInputParser,
578 )
579 ]
580 else:
581 items = [
582 completion_item(maybe_quote_yaml_value(k), pp_subparser)
583 for k, pp_subparser in keywords_with_parser(parser)
584 if k not in parent
585 and isinstance(
586 pp_subparser.parser,
587 DeclarativeValuelessKeywordInputParser,
588 )
589 ]
590 elif isinstance(parser, InPackageContextParser): 590 ↛ 591line 590 didn't jump to line 591 because the condition on line 590 was never true
591 binary_packages = ls.lint_state(doc).binary_packages
592 if binary_packages is not None:
593 items = [
594 CompletionItem(
595 maybe_quote_yaml_value(p)
596 if has_colon
597 else f"{maybe_quote_yaml_value(p)}:"
598 )
599 for p in binary_packages
600 if p not in parent
601 ]
602 elif isinstance(parser, DeclarativeMappingInputParser):
603 if matched_key:
604 _info("Match attributes")
605 locked = set(parent)
606 for mx in parser.mutually_exclusive_attributes:
607 if not mx.isdisjoint(parent.keys()):
608 locked.update(mx)
609 for attr_name, attr in parser.manifest_attributes.items():
610 if not attr.conflicting_attributes.isdisjoint(parent.keys()):
611 locked.add(attr_name)
612 break
613 items = [
614 CompletionItem(
615 maybe_quote_yaml_value(k)
616 if has_colon
617 else f"{maybe_quote_yaml_value(k)}:"
618 )
619 for k in parser.manifest_attributes
620 if k not in locked
621 ]
622 else:
623 # Value
624 key = segments[at_depth_idx] if len(segments) > at_depth_idx else None
625 attr = parser.manifest_attributes.get(key)
626 if attr is not None: 626 ↛ 634line 626 didn't jump to line 634 because the condition on line 626 was always true
627 _info(f"Expand value / key: {key} -- {attr.attribute_type}")
628 items = completion_from_attr(
629 attr,
630 feature_set.manifest_parser_generator,
631 matched,
632 )
633 else:
634 _info(
635 f"Expand value / key: {key} -- !! {list(parser.manifest_attributes)}"
636 )
637 elif isinstance(parser, DeclarativeNonMappingInputParser): 637 ↛ 644line 637 didn't jump to line 644 because the condition on line 637 was always true
638 attr = parser.alt_form_parser
639 items = completion_from_attr(
640 attr,
641 feature_set.manifest_parser_generator,
642 matched,
643 )
644 return items
647@lsp_hover(_DISPATCH_RULE)
648def debputy_manifest_hover(
649 ls: "DebputyLanguageServer",
650 params: HoverParams,
651) -> Optional[Hover]:
652 return generic_yaml_hover(
653 ls,
654 params,
655 lambda pg: pg.dispatchable_object_parsers[OPARSER_MANIFEST_ROOT],
656 )