Coverage for src/debputy/lsp/languages/lsp_debian_control.py: 63%
424 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 dataclasses
2import importlib.resources
3import os.path
4import textwrap
5from functools import lru_cache
6from itertools import chain
7from typing import (
8 Union,
9 Tuple,
10 Optional,
11 List,
12 Self,
13 TYPE_CHECKING,
14)
15from collections.abc import Sequence, Mapping, Iterable
17import debputy.lsp.data.deb822_data as deb822_ref_data_dir
18from debputy.analysis.analysis_util import flatten_ppfs
19from debputy.analysis.debian_dir import resolve_debhelper_config_files
20from debputy.dh.dh_assistant import extract_dh_compat_level
21from debputy.linting.lint_util import (
22 LintState,
23 te_range_to_lsp,
24 te_position_to_lsp,
25 with_range_in_continuous_parts,
26)
27from debputy.lsp.apt_cache import PackageLookup
28from debputy.lsp.debputy_ls import DebputyLanguageServer
29from debputy.lsp.lsp_debian_control_reference_data import (
30 DctrlKnownField,
31 DctrlFileMetadata,
32 package_name_to_section,
33 all_package_relationship_fields,
34 extract_first_value_and_position,
35 all_source_relationship_fields,
36 StanzaMetadata,
37 SUBSTVAR_RE,
38)
39from debputy.lsp.lsp_features import (
40 lint_diagnostics,
41 lsp_completer,
42 lsp_hover,
43 lsp_standard_handler,
44 lsp_folding_ranges,
45 lsp_semantic_tokens_full,
46 lsp_will_save_wait_until,
47 lsp_format_document,
48 lsp_text_doc_inlay_hints,
49 LanguageDispatchRule,
50 SecondaryLanguage,
51 lsp_cli_reformat_document,
52)
53from debputy.lsp.lsp_generic_deb822 import (
54 deb822_completer,
55 deb822_hover,
56 deb822_folding_ranges,
57 deb822_semantic_tokens_full,
58 deb822_format_file,
59 scan_for_syntax_errors_and_token_level_diagnostics,
60)
61from debputy.lsp.lsp_reference_keyword import LSP_DATA_DOMAIN
62from debputy.lsp.quickfixes import (
63 propose_correct_text_quick_fix,
64 propose_insert_text_on_line_after_diagnostic_quick_fix,
65 propose_remove_range_quick_fix,
66)
67from debputy.lsp.ref_models.deb822_reference_parse_models import (
68 DCTRL_SUBSTVARS_REFERENCE_DATA_PARSER,
69 DCtrlSubstvar,
70)
71from debputy.lsp.text_util import markdown_urlify
72from debputy.lsp.vendoring._deb822_repro import (
73 Deb822ParagraphElement,
74)
75from debputy.lsp.vendoring._deb822_repro.parsing import (
76 Deb822KeyValuePairElement,
77)
78from debputy.lsprotocol.types import (
79 Position,
80 FoldingRange,
81 FoldingRangeParams,
82 CompletionItem,
83 CompletionList,
84 CompletionParams,
85 HoverParams,
86 Hover,
87 TEXT_DOCUMENT_CODE_ACTION,
88 SemanticTokens,
89 SemanticTokensParams,
90 WillSaveTextDocumentParams,
91 TextEdit,
92 DocumentFormattingParams,
93 InlayHint,
94)
95from debputy.manifest_parser.util import AttributePath
96from debputy.packager_provided_files import (
97 PackagerProvidedFile,
98 detect_all_packager_provided_files,
99)
100from debputy.plugin.api.impl import plugin_metadata_for_debputys_own_plugin
101from debputy.util import PKGNAME_REGEX, _info, _trace_log, _is_trace_log_enabled
102from debputy.yaml import MANIFEST_YAML
104if TYPE_CHECKING:
105 import lsprotocol.types as types
106else:
107 import debputy.lsprotocol.types as types
109try:
110 from debputy.lsp.vendoring._deb822_repro.locatable import (
111 Position as TEPosition,
112 Range as TERange,
113 START_POSITION,
114 )
116 from pygls.workspace import TextDocument
117except ImportError:
118 pass
121_DISPATCH_RULE = LanguageDispatchRule.new_rule(
122 "debian/control",
123 None,
124 "debian/control",
125 [
126 # emacs's name
127 SecondaryLanguage("debian-control"),
128 # vim's name
129 SecondaryLanguage("debcontrol"),
130 ],
131)
134@dataclasses.dataclass(slots=True, frozen=True)
135class SubstvarMetadata:
136 name: str
137 defined_by: str
138 dh_sequence: str | None
139 doc_uris: Sequence[str]
140 synopsis: str
141 description: str
143 def render_metadata_fields(self) -> str:
144 def_by = f"Defined by: {self.defined_by}"
145 dh_seq = (
146 f"DH Sequence: {self.dh_sequence}" if self.dh_sequence is not None else None
147 )
148 doc_uris = self.doc_uris
149 parts = [def_by, dh_seq]
150 if doc_uris: 150 ↛ 156line 150 didn't jump to line 156 because the condition on line 150 was always true
151 if len(doc_uris) == 1: 151 ↛ 154line 151 didn't jump to line 154 because the condition on line 151 was always true
152 parts.append(f"Documentation: {markdown_urlify(doc_uris[0])}")
153 else:
154 parts.append("Documentation:")
155 parts.extend(f" - {markdown_urlify(uri)}" for uri in doc_uris)
156 return "\n".join(parts)
158 @classmethod
159 def from_ref_data(cls, x: DCtrlSubstvar) -> "Self":
160 doc = x.get("documentation", {})
161 return cls(
162 x["name"],
163 x["defined_by"],
164 x.get("dh_sequence"),
165 doc.get("uris", []),
166 doc.get("synopsis", ""),
167 doc.get("long_description", ""),
168 )
171def relationship_substvar_for_field(substvar: str) -> str | None:
172 relationship_fields = all_package_relationship_fields()
173 try:
174 col_idx = substvar.rindex(":")
175 except ValueError:
176 return None
177 return relationship_fields.get(substvar[col_idx + 1 : -1].lower())
180def _as_substvars_metadata(
181 args: list[SubstvarMetadata],
182) -> Mapping[str, SubstvarMetadata]:
183 r = {s.name: s for s in args}
184 assert len(r) == len(args)
185 return r
188def dctrl_variables_metadata_basename() -> str:
189 return "debian_control_variables_data.yaml"
192@lru_cache
193def dctrl_substvars_metadata() -> Mapping[str, SubstvarMetadata]:
194 p = importlib.resources.files(deb822_ref_data_dir.__name__).joinpath(
195 dctrl_variables_metadata_basename()
196 )
198 with p.open("r", encoding="utf-8") as fd:
199 raw = MANIFEST_YAML.load(fd)
201 attr_path = AttributePath.root_path(p)
202 ref = DCTRL_SUBSTVARS_REFERENCE_DATA_PARSER.parse_input(raw, attr_path)
203 return _as_substvars_metadata(
204 [SubstvarMetadata.from_ref_data(x) for x in ref["variables"]]
205 )
208_DCTRL_FILE_METADATA = DctrlFileMetadata()
211lsp_standard_handler(_DISPATCH_RULE, TEXT_DOCUMENT_CODE_ACTION)
214@lsp_hover(_DISPATCH_RULE)
215def _debian_control_hover(
216 ls: "DebputyLanguageServer",
217 params: HoverParams,
218) -> Hover | None:
219 return deb822_hover(ls, params, _DCTRL_FILE_METADATA, custom_handler=_custom_hover)
222def _custom_hover_description(
223 _ls: "DebputyLanguageServer",
224 _known_field: DctrlKnownField,
225 line: str,
226 _word_at_position: str,
227) -> Hover | str | None:
228 if line[0].isspace(): 228 ↛ 229line 228 didn't jump to line 229 because the condition on line 228 was never true
229 return None
230 try:
231 col_idx = line.index(":")
232 except ValueError:
233 return None
235 content = line[col_idx + 1 :].strip()
237 # Synopsis
238 return textwrap.dedent(
239 f"""\
240 # Package synopsis
242 The synopsis functions as a phrase describing the package, not a
243 complete sentence, so sentential punctuation is inappropriate: it
244 does not need extra capital letters or a final period (full stop).
245 It should also omit any initial indefinite or definite article
246 - "a", "an", or "the". Thus for instance:
248 ```
249 Package: libeg0
250 Description: exemplification support library
251 ```
253 Technically this is a noun phrase minus articles, as opposed to a
254 verb phrase. A good heuristic is that it should be possible to
255 substitute the package name and synopsis into this formula:
257 ```
258 # Generic
259 The package provides { a,an,the,some} synopsis.
261 # The current package for comparison
262 The package provides { a,an,the,some} {content}.
263 ```
265 Other advice for writing synopsis:
266 * Avoid using the package name. Any software would display the
267 package name already and it generally does not help the user
268 understand what they are looking at.
269 * In many situations, the user will only see the package name
270 and its synopsis. The synopsis must be able to stand alone.
272 **Example renderings in various terminal UIs**:
273 ```
274 # apt search TERM
275 package/stable,now 1.0-1 all:
276 {content}
278 # apt-get search TERM
279 package - {content}
280 ```
282 ## Reference example
284 An reference example for comparison: The Sphinx package
285 (python3-sphinx/7.2.6-6) had the following synopsis:
287 ```
288 Description: documentation generator for Python projects
289 ```
291 In the test sentence, it would read as:
293 ```
294 The python3-sphinx package provides a documentation generator for Python projects.
295 ```
297 **Side-by-side comparison in the terminal UIs**:
298 ```
299 # apt search TERM
300 python3-sphinx/stable,now 7.2.6-6 all:
301 documentation generator for Python projects
303 package/stable,now 1.0-1 all:
304 {content}
307 # apt-get search TERM
308 package - {content}
309 python3-sphinx - documentation generator for Python projects
310 ```
311 """
312 )
315def _render_package_lookup(
316 package_lookup: PackageLookup,
317 known_field: DctrlKnownField,
318) -> str:
319 name = package_lookup.name
320 provider = package_lookup.package
321 if package_lookup.package is None and len(package_lookup.provided_by) == 1:
322 provider = package_lookup.provided_by[0]
324 if provider:
325 segments = [
326 f"# {name} ({provider.version}, {provider.architecture}) ",
327 "",
328 ]
330 if (
331 _is_bd_field(known_field)
332 and name.startswith("dh-sequence-")
333 and len(name) > 12
334 ):
335 sequence = name[12:]
336 segments.append(
337 f"This build-dependency will activate the `dh` sequence called `{sequence}`."
338 )
339 segments.append("")
341 elif (
342 known_field.name == "Build-Depends"
343 and name.startswith("debputy-plugin-")
344 and len(name) > 15
345 ):
346 plugin_name = name[15:]
347 segments.append(
348 f"This build-dependency will activate the `debputy` plugin called `{plugin_name}`."
349 )
350 segments.append("")
352 segments.extend(
353 [
354 f"Synopsis: {provider.synopsis}",
355 "",
356 f"Multi-Arch: {provider.multi_arch}",
357 "",
358 f"Section: {provider.section}",
359 ]
360 )
361 if provider.upstream_homepage is not None:
362 segments.append("")
363 segments.append(f"Upstream homepage: {provider.upstream_homepage}")
364 segments.append("")
365 segments.append(
366 "Data is from the system's APT cache, which may not match the target distribution."
367 )
368 return "\n".join(segments)
370 segments = [
371 f"# {name} [virtual]",
372 "",
373 "The package {name} is a virtual package provided by one of:",
374 ]
375 segments.extend(f" * {p.name}" for p in package_lookup.provided_by)
376 segments.append("")
377 segments.append(
378 "Data is from the system's APT cache, which may not match the target distribution."
379 )
380 return "\n".join(segments)
383def _disclaimer(is_empty: bool) -> str:
384 if is_empty:
385 return textwrap.dedent(
386 """\
387 The system's APT cache is empty, so it was not possible to verify that the
388 package exist.
389"""
390 )
391 return textwrap.dedent(
392 """\
393 The package is not known by the APT cache on this system, so there may be typo
394 or the package may not be available in the version of your distribution.
395"""
396 )
399def _render_package_by_name(
400 name: str, known_field: DctrlKnownField, is_empty: bool
401) -> str | None:
402 if _is_bd_field(known_field) and name.startswith("dh-sequence-") and len(name) > 12:
403 sequence = name[12:]
404 return (
405 textwrap.dedent(
406 f"""\
407 # {name}
409 This build-dependency will activate the `dh` sequence called `{sequence}`.
411 """
412 )
413 + _disclaimer(is_empty)
414 )
415 if (
416 known_field.name == "Build-Depends"
417 and name.startswith("debputy-plugin-")
418 and len(name) > 15
419 ):
420 plugin_name = name[15:]
421 return (
422 textwrap.dedent(
423 f"""\
424 # {name}
426 This build-dependency will activate the `debputy` plugin called `{plugin_name}`.
428 """
429 )
430 + _disclaimer(is_empty)
431 )
432 return (
433 textwrap.dedent(
434 f"""\
435 # {name}
437 """
438 )
439 + _disclaimer(is_empty)
440 )
443def _is_bd_field(known_field: DctrlKnownField) -> bool:
444 return known_field.name in (
445 "Build-Depends",
446 "Build-Depends-Arch",
447 "Build-Depends-Indep",
448 )
451def _custom_hover_relationship_field(
452 ls: "DebputyLanguageServer",
453 known_field: DctrlKnownField,
454 _line: str,
455 word_at_position: str,
456) -> Hover | str | None:
457 apt_cache = ls.apt_cache
458 state = apt_cache.state
459 is_empty = False
460 _info(f"Rel field: {known_field.name} - {word_at_position} - {state}")
461 if "|" in word_at_position: 461 ↛ 462line 461 didn't jump to line 462 because the condition on line 461 was never true
462 return textwrap.dedent(
463 f"""\
464 Sorry, no hover docs for OR relations at the moment.
466 The relation being matched: `{word_at_position}`
468 The code is missing logic to determine which side of the OR the lookup is happening.
469 """
470 )
471 match = next(iter(PKGNAME_REGEX.finditer(word_at_position)), None)
472 if match is None: 472 ↛ 473line 472 didn't jump to line 473 because the condition on line 472 was never true
473 return
474 package = match.group()
475 if state == "empty-cache": 475 ↛ 476line 475 didn't jump to line 476 because the condition on line 475 was never true
476 state = "loaded"
477 is_empty = True
478 if state == "loaded": 478 ↛ 479line 478 didn't jump to line 479 because the condition on line 478 was never true
479 result = apt_cache.lookup(package)
480 if result is None:
481 return _render_package_by_name(
482 package,
483 known_field,
484 is_empty=is_empty,
485 )
486 return _render_package_lookup(result, known_field)
488 if state in ( 488 ↛ 502line 488 didn't jump to line 502 because the condition on line 488 was always true
489 "not-loaded",
490 "failed",
491 "tooling-not-available",
492 ):
493 details = apt_cache.load_error if apt_cache.load_error else "N/A"
494 return textwrap.dedent(
495 f"""\
496 Sorry, the APT cache data is not available due to an error or missing tool.
498 Details: {details}
499 """
500 )
502 if state == "empty-cache":
503 return f"Cannot lookup {package}: APT cache data was empty"
505 if state == "loading":
506 return f"Cannot lookup {package}: APT cache data is still being indexed. Please try again in a moment."
507 return None
510_CUSTOM_FIELD_HOVER = {
511 field: _custom_hover_relationship_field
512 for field in chain(
513 all_package_relationship_fields().values(),
514 all_source_relationship_fields().values(),
515 )
516 if field != "Provides"
517}
519_CUSTOM_FIELD_HOVER["Description"] = _custom_hover_description
522def _custom_hover(
523 ls: "DebputyLanguageServer",
524 server_position: Position,
525 _current_field: str | None,
526 word_at_position: str,
527 known_field: DctrlKnownField | None,
528 in_value: bool,
529 _doc: "TextDocument",
530 lines: list[str],
531) -> Hover | str | None:
532 if not in_value:
533 return None
535 line_no = server_position.line
536 line = lines[line_no]
537 substvar_search_ref = server_position.character
538 substvar = ""
539 try:
540 if line and line[substvar_search_ref] in ("$", "{"):
541 substvar_search_ref += 2
542 substvar_start = line.rindex("${", 0, substvar_search_ref)
543 substvar_end = line.index("}", substvar_start)
544 if server_position.character <= substvar_end:
545 substvar = line[substvar_start : substvar_end + 1]
546 except (ValueError, IndexError):
547 pass
549 if substvar == "${}" or SUBSTVAR_RE.fullmatch(substvar):
550 substvar_md = dctrl_substvars_metadata().get(substvar)
552 computed_doc = ""
553 for_field = relationship_substvar_for_field(substvar)
554 if for_field: 554 ↛ 556line 554 didn't jump to line 556 because the condition on line 554 was never true
555 # Leading empty line is intentional!
556 computed_doc = textwrap.dedent(
557 f"""
558 This substvar is a relationship substvar for the field {for_field}.
559 Relationship substvars are automatically added in the field they
560 are named after in `debhelper-compat (= 14)` or later, or with
561 `debputy` (any integration mode after 0.1.21).
562 """
563 )
565 if substvar_md is None: 565 ↛ 566line 565 didn't jump to line 566 because the condition on line 565 was never true
566 doc = f"No documentation for {substvar}.\n"
567 md_fields = ""
568 else:
569 doc = ls.translation(LSP_DATA_DOMAIN).pgettext(
570 f"Variable:{substvar_md.name}",
571 substvar_md.description,
572 )
573 md_fields = "\n" + substvar_md.render_metadata_fields()
574 return f"# Substvar `{substvar}`\n\n{doc}{computed_doc}{md_fields}"
576 if known_field is None: 576 ↛ 577line 576 didn't jump to line 577 because the condition on line 576 was never true
577 return None
578 dispatch = _CUSTOM_FIELD_HOVER.get(known_field.name)
579 if dispatch is None: 579 ↛ 580line 579 didn't jump to line 580 because the condition on line 579 was never true
580 return None
581 return dispatch(ls, known_field, line, word_at_position)
584@lsp_completer(_DISPATCH_RULE)
585def _debian_control_completions(
586 ls: "DebputyLanguageServer",
587 params: CompletionParams,
588) -> CompletionList | Sequence[CompletionItem] | None:
589 return deb822_completer(ls, params, _DCTRL_FILE_METADATA)
592@lsp_folding_ranges(_DISPATCH_RULE)
593def _debian_control_folding_ranges(
594 ls: "DebputyLanguageServer",
595 params: FoldingRangeParams,
596) -> Sequence[FoldingRange] | None:
597 return deb822_folding_ranges(ls, params, _DCTRL_FILE_METADATA)
600@lsp_text_doc_inlay_hints(_DISPATCH_RULE)
601async def _doc_inlay_hint(
602 ls: "DebputyLanguageServer",
603 params: types.InlayHintParams,
604) -> list[InlayHint] | None:
605 doc = ls.workspace.get_text_document(params.text_document.uri)
606 lint_state = ls.lint_state(doc)
607 deb822_file = lint_state.parsed_deb822_file_content
608 if not deb822_file:
609 return None
610 inlay_hints = []
611 stanzas = list(deb822_file)
612 if len(stanzas) < 2:
613 return None
614 source_stanza = stanzas[0]
615 source_stanza_pos = source_stanza.position_in_file()
616 inherited_inlay_label_part = {}
617 stanza_no = 0
619 async for stanza_range, stanza in lint_state.slow_iter(
620 with_range_in_continuous_parts(deb822_file.iter_parts())
621 ):
622 if not isinstance(stanza, Deb822ParagraphElement):
623 continue
624 stanza_def = _DCTRL_FILE_METADATA.classify_stanza(stanza, stanza_no)
625 stanza_no += 1
626 pkg_kvpair = stanza.get_kvpair_element(("Package", 0), use_get=True)
627 if pkg_kvpair is None:
628 continue
630 parts = []
631 async for known_field in ls.slow_iter(
632 stanza_def.stanza_fields.values(), yield_every=25
633 ):
634 if (
635 not known_field.inheritable_from_other_stanza
636 or not known_field.show_as_inherited
637 or known_field.name in stanza
638 ):
639 continue
641 inherited_value = source_stanza.get(known_field.name)
642 if inherited_value is not None:
643 inlay_hint_label_part = inherited_inlay_label_part.get(known_field.name)
644 if inlay_hint_label_part is None:
645 kvpair = source_stanza.get_kvpair_element(known_field.name)
646 value_range_te = kvpair.range_in_parent().relative_to(
647 source_stanza_pos
648 )
649 value_range = doc.position_codec.range_to_client_units(
650 lint_state.lines,
651 te_range_to_lsp(value_range_te),
652 )
653 inlay_hint_label_part = types.InlayHintLabelPart(
654 f" ({known_field.name}: {inherited_value})",
655 tooltip="Inherited from Source stanza",
656 location=types.Location(
657 params.text_document.uri,
658 value_range,
659 ),
660 )
661 inherited_inlay_label_part[known_field.name] = inlay_hint_label_part
662 parts.append(inlay_hint_label_part)
664 if parts:
665 known_field = stanza_def["Package"]
666 values = known_field.field_value_class.interpreter().interpret(pkg_kvpair)
667 assert values is not None
668 anchor_value = list(values.iter_value_references())[-1]
669 anchor_position = (
670 anchor_value.locatable.range_in_parent().end_pos.relative_to(
671 pkg_kvpair.value_element.position_in_parent().relative_to(
672 stanza_range.start_pos
673 )
674 )
675 )
676 anchor_position_client_units = doc.position_codec.position_to_client_units(
677 lint_state.lines,
678 te_position_to_lsp(anchor_position),
679 )
680 inlay_hints.append(
681 types.InlayHint(
682 anchor_position_client_units,
683 parts,
684 padding_left=True,
685 padding_right=False,
686 )
687 )
688 return inlay_hints
691def _source_package_checks(
692 stanza: Deb822ParagraphElement,
693 stanza_position: "TEPosition",
694 stanza_metadata: StanzaMetadata[DctrlKnownField],
695 lint_state: LintState,
696) -> None:
697 vcs_fields = {}
698 source_fields = _DCTRL_FILE_METADATA["Source"].stanza_fields
699 for kvpair in stanza.iter_parts_of_type(Deb822KeyValuePairElement):
700 name = stanza_metadata.normalize_field_name(kvpair.field_name.lower())
701 if (
702 not name.startswith("vcs-")
703 or name == "vcs-browser"
704 or name not in source_fields
705 ):
706 continue
707 vcs_fields[name] = kvpair
709 if len(vcs_fields) < 2:
710 return
711 for kvpair in vcs_fields.values():
712 lint_state.emit_diagnostic(
713 kvpair.range_in_parent().relative_to(stanza_position),
714 f'Multiple Version Control fields defined ("{kvpair.field_name}")',
715 "warning",
716 "Policy 5.6.26",
717 quickfixes=[
718 propose_remove_range_quick_fix(
719 proposed_title=f'Remove "{kvpair.field_name}"'
720 )
721 ],
722 )
725def _binary_package_checks(
726 stanza: Deb822ParagraphElement,
727 stanza_position: "TEPosition",
728 source_stanza: Deb822ParagraphElement,
729 representation_field_range: "TERange",
730 lint_state: LintState,
731) -> None:
732 package_name = stanza.get("Package", "")
733 source_section = source_stanza.get("Section")
734 section_kvpair = stanza.get_kvpair_element(("Section", 0), use_get=True)
735 section: str | None = None
736 section_range: Optional["TERange"] = None
737 if section_kvpair is not None:
738 section, section_range = extract_first_value_and_position(
739 section_kvpair,
740 stanza_position,
741 )
743 if section_range is None:
744 section_range = representation_field_range
745 effective_section = section or source_section or "unknown"
746 package_type = stanza.get("Package-Type", "")
747 component_prefix = ""
748 if "/" in effective_section:
749 component_prefix, effective_section = effective_section.split("/", maxsplit=1)
750 component_prefix += "/"
752 if package_name.endswith("-udeb") or package_type == "udeb":
753 if package_type != "udeb": 753 ↛ 754line 753 didn't jump to line 754 because the condition on line 753 was never true
754 package_type_kvpair = stanza.get_kvpair_element(
755 "Package-Type", use_get=True
756 )
757 package_type_range: Optional["TERange"] = None
758 if package_type_kvpair is not None:
759 _, package_type_range = extract_first_value_and_position(
760 package_type_kvpair,
761 stanza_position,
762 )
763 if package_type_range is None:
764 package_type_range = representation_field_range
765 lint_state.emit_diagnostic(
766 package_type_range,
767 'The Package-Type should be "udeb" given the package name',
768 "warning",
769 "debputy",
770 )
771 guessed_section = "debian-installer"
772 section_diagnostic_rationale = " since it is an udeb"
773 else:
774 guessed_section = package_name_to_section(package_name)
775 section_diagnostic_rationale = " based on the package name"
776 if guessed_section is not None and guessed_section != effective_section: 776 ↛ 777line 776 didn't jump to line 777 because the condition on line 776 was never true
777 if section is not None:
778 quickfix_data = [
779 propose_correct_text_quick_fix(f"{component_prefix}{guessed_section}")
780 ]
781 else:
782 quickfix_data = [
783 propose_insert_text_on_line_after_diagnostic_quick_fix(
784 f"Section: {component_prefix}{guessed_section}\n"
785 )
786 ]
787 assert section_range is not None # mypy hint
788 lint_state.emit_diagnostic(
789 section_range,
790 f'The Section should be "{component_prefix}{guessed_section}"{section_diagnostic_rationale}',
791 "warning",
792 "debputy",
793 quickfixes=quickfix_data,
794 )
797@lint_diagnostics(_DISPATCH_RULE)
798async def _lint_debian_control(lint_state: LintState) -> None:
799 deb822_file = lint_state.parsed_deb822_file_content
801 if not _DCTRL_FILE_METADATA.file_metadata_applies_to_file(deb822_file): 801 ↛ 802line 801 didn't jump to line 802 because the condition on line 801 was never true
802 return
804 first_error = await scan_for_syntax_errors_and_token_level_diagnostics(
805 deb822_file,
806 lint_state,
807 )
809 stanzas = list(deb822_file)
810 source_stanza = stanzas[0] if stanzas else None
811 binary_stanzas_w_pos = []
813 source_stanza_metadata, binary_stanza_metadata = _DCTRL_FILE_METADATA.stanza_types()
814 stanza_no = 0
816 async for stanza_range, stanza in lint_state.slow_iter(
817 with_range_in_continuous_parts(deb822_file.iter_parts())
818 ):
819 if not isinstance(stanza, Deb822ParagraphElement):
820 continue
821 stanza_position = stanza_range.start_pos
822 if stanza_position.line_position >= first_error: 822 ↛ 823line 822 didn't jump to line 823 because the condition on line 822 was never true
823 break
824 stanza_no += 1
825 is_binary_stanza = stanza_no != 1
826 if is_binary_stanza:
827 stanza_metadata = binary_stanza_metadata
828 other_stanza_metadata = source_stanza_metadata
829 other_stanza_name = "Source"
830 binary_stanzas_w_pos.append((stanza, stanza_position))
831 _, representation_field_range = stanza_metadata.stanza_representation(
832 stanza, stanza_position
833 )
834 _binary_package_checks(
835 stanza,
836 stanza_position,
837 source_stanza,
838 representation_field_range,
839 lint_state,
840 )
841 else:
842 stanza_metadata = source_stanza_metadata
843 other_stanza_metadata = binary_stanza_metadata
844 other_stanza_name = "Binary"
845 _source_package_checks(
846 stanza,
847 stanza_position,
848 stanza_metadata,
849 lint_state,
850 )
852 await stanza_metadata.stanza_diagnostics(
853 deb822_file,
854 stanza,
855 stanza_position,
856 lint_state,
857 confusable_with_stanza_metadata=other_stanza_metadata,
858 confusable_with_stanza_name=other_stanza_name,
859 inherit_from_stanza=source_stanza if is_binary_stanza else None,
860 )
862 _detect_misspelled_packaging_files(
863 lint_state,
864 binary_stanzas_w_pos,
865 )
868def _package_range_of_stanza(
869 binary_stanzas: list[tuple[Deb822ParagraphElement, TEPosition]],
870) -> Iterable[tuple[str, str | None, "TERange"]]:
871 for stanza, stanza_position in binary_stanzas:
872 kvpair = stanza.get_kvpair_element(("Package", 0), use_get=True)
873 if kvpair is None: 873 ↛ 874line 873 didn't jump to line 874 because the condition on line 873 was never true
874 continue
875 representation_field_range = kvpair.range_in_parent().relative_to(
876 stanza_position
877 )
878 yield stanza["Package"], stanza.get("Architecture"), representation_field_range
881def _packaging_files(
882 lint_state: LintState,
883) -> Iterable[PackagerProvidedFile]:
884 source_root = lint_state.source_root
885 debian_dir = lint_state.debian_dir
886 binary_packages = lint_state.binary_packages
887 if (
888 source_root is None
889 or not source_root.has_fs_path
890 or debian_dir is None
891 or binary_packages is None
892 ):
893 return
895 debputy_integration_mode = lint_state.debputy_metadata.debputy_integration_mode
896 dh_sequencer_data = lint_state.dh_sequencer_data
897 dh_sequences = dh_sequencer_data.sequences
898 is_debputy_package = debputy_integration_mode is not None
899 feature_set = lint_state.plugin_feature_set
900 known_packaging_files = feature_set.known_packaging_files
901 static_packaging_files = {
902 kpf.detection_value: kpf
903 for kpf in known_packaging_files.values()
904 if kpf.detection_method == "path"
905 }
906 ignored_path = set(static_packaging_files)
908 if is_debputy_package:
909 all_debputy_ppfs = list(
910 flatten_ppfs(
911 detect_all_packager_provided_files(
912 feature_set,
913 debian_dir,
914 binary_packages,
915 allow_fuzzy_matches=True,
916 detect_typos=True,
917 ignore_paths=ignored_path,
918 )
919 )
920 )
921 for ppf in all_debputy_ppfs:
922 if ppf.path.path in ignored_path: 922 ↛ 923line 922 didn't jump to line 923 because the condition on line 922 was never true
923 continue
924 ignored_path.add(ppf.path.path)
925 yield ppf
927 # FIXME: This should read the editor data, but dh_assistant does not support that.
928 dh_compat_level, _ = extract_dh_compat_level(cwd=source_root.fs_path)
929 if dh_compat_level is not None: 929 ↛ exitline 929 didn't return from function '_packaging_files' because the condition on line 929 was always true
930 debputy_plugin_metadata = plugin_metadata_for_debputys_own_plugin()
931 (
932 all_dh_ppfs,
933 _,
934 _,
935 _,
936 ) = resolve_debhelper_config_files(
937 debian_dir,
938 binary_packages,
939 debputy_plugin_metadata,
940 feature_set,
941 dh_sequences,
942 dh_compat_level,
943 saw_dh=dh_sequencer_data.uses_dh_sequencer,
944 ignore_paths=ignored_path,
945 debputy_integration_mode=debputy_integration_mode,
946 cwd=source_root.fs_path,
947 )
948 for ppf in all_dh_ppfs:
949 if ppf.path.path in ignored_path: 949 ↛ 950line 949 didn't jump to line 950 because the condition on line 949 was never true
950 continue
951 ignored_path.add(ppf.path.path)
952 yield ppf
955def _detect_misspelled_packaging_files(
956 lint_state: LintState,
957 binary_stanzas_w_pos: list[tuple[Deb822ParagraphElement, TEPosition]],
958) -> None:
959 stanza_ranges = {
960 p: (a, r) for p, a, r in _package_range_of_stanza(binary_stanzas_w_pos)
961 }
962 for ppf in _packaging_files(lint_state):
963 binary_package = ppf.package_name
964 explicit_package = ppf.uses_explicit_package_name
965 name_segment = ppf.name_segment is not None
966 stem = ppf.definition.stem
967 if _is_trace_log_enabled(): 967 ↛ 968line 967 didn't jump to line 968 because the condition on line 967 was never true
968 _trace_log(
969 f"PPF check: {binary_package} {stem=} {explicit_package=} {name_segment=} {ppf.expected_path=} {ppf.definition.has_active_command=}"
970 )
971 if binary_package is None or stem is None: 971 ↛ 972line 971 didn't jump to line 972 because the condition on line 971 was never true
972 continue
973 res = stanza_ranges.get(binary_package)
974 if res is None: 974 ↛ 975line 974 didn't jump to line 975 because the condition on line 974 was never true
975 continue
976 declared_arch, diag_range = res
977 if diag_range is None: 977 ↛ 978line 977 didn't jump to line 978 because the condition on line 977 was never true
978 continue
979 path = ppf.path.path
980 likely_typo_of = ppf.expected_path
981 arch_restriction = ppf.architecture_restriction
982 if likely_typo_of is not None:
983 # Handles arch_restriction == 'all' at the same time due to how
984 # the `likely-typo-of` is created
985 lint_state.emit_diagnostic(
986 diag_range,
987 f'The file "{path}" is likely a typo of "{likely_typo_of}"',
988 "warning",
989 "debputy",
990 diagnostic_applies_to_another_file=path,
991 )
992 continue
993 if declared_arch == "all" and arch_restriction is not None: 993 ↛ 994line 993 didn't jump to line 994 because the condition on line 993 was never true
994 lint_state.emit_diagnostic(
995 diag_range,
996 f'The file "{path}" has an architecture restriction but is for an `arch:all` package, so'
997 f" the restriction does not make sense.",
998 "warning",
999 "debputy",
1000 diagnostic_applies_to_another_file=path,
1001 )
1002 elif arch_restriction == "all": 1002 ↛ 1003line 1002 didn't jump to line 1003 because the condition on line 1002 was never true
1003 lint_state.emit_diagnostic(
1004 diag_range,
1005 f'The file "{path}" has an architecture restriction of `all` rather than a real architecture',
1006 "warning",
1007 "debputy",
1008 diagnostic_applies_to_another_file=path,
1009 )
1011 if not ppf.definition.has_active_command: 1011 ↛ 1012line 1011 didn't jump to line 1012 because the condition on line 1011 was never true
1012 lint_state.emit_diagnostic(
1013 diag_range,
1014 f"The file {path} is related to a command that is not active in the dh sequence"
1015 " with the current addons",
1016 "warning",
1017 "debputy",
1018 diagnostic_applies_to_another_file=path,
1019 )
1020 continue
1022 if not explicit_package and name_segment is not None:
1023 basename = os.path.basename(path)
1024 if basename == ppf.definition.stem:
1025 continue
1026 alt_name = f"{binary_package}.{stem}"
1027 if arch_restriction is not None: 1027 ↛ 1028line 1027 didn't jump to line 1028 because the condition on line 1027 was never true
1028 alt_name = f"{alt_name}.{arch_restriction}"
1029 if ppf.definition.allow_name_segment:
1030 or_alt_name = f' (or maybe "debian/{binary_package}.{basename}")'
1031 else:
1032 or_alt_name = ""
1034 lint_state.emit_diagnostic(
1035 diag_range,
1036 f'Possible typo in "{path}". Consider renaming the file to "debian/{alt_name}"'
1037 f"{or_alt_name} if it is intended for {binary_package}",
1038 "warning",
1039 "debputy",
1040 diagnostic_applies_to_another_file=path,
1041 )
1044@lsp_will_save_wait_until(_DISPATCH_RULE)
1045def _debian_control_on_save_formatting(
1046 ls: "DebputyLanguageServer",
1047 params: WillSaveTextDocumentParams,
1048) -> Sequence[TextEdit] | None:
1049 doc = ls.workspace.get_text_document(params.text_document.uri)
1050 lint_state = ls.lint_state(doc)
1051 return _reformat_debian_control(lint_state)
1054@lsp_cli_reformat_document(_DISPATCH_RULE)
1055def _reformat_debian_control(
1056 lint_state: LintState,
1057) -> Sequence[TextEdit] | None:
1058 return deb822_format_file(lint_state, _DCTRL_FILE_METADATA)
1061@lsp_format_document(_DISPATCH_RULE)
1062def _debian_control_format_file(
1063 ls: "DebputyLanguageServer",
1064 params: DocumentFormattingParams,
1065) -> Sequence[TextEdit] | None:
1066 doc = ls.workspace.get_text_document(params.text_document.uri)
1067 lint_state = ls.lint_state(doc)
1068 return _reformat_debian_control(lint_state)
1071@lsp_semantic_tokens_full(_DISPATCH_RULE)
1072async def _debian_control_semantic_tokens_full(
1073 ls: "DebputyLanguageServer",
1074 request: SemanticTokensParams,
1075) -> SemanticTokens | None:
1076 return await deb822_semantic_tokens_full(
1077 ls,
1078 request,
1079 _DCTRL_FILE_METADATA,
1080 )