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