Coverage for src/debputy/lsp/languages/lsp_debian_control.py: 63%
424 statements
« prev ^ index » next coverage.py v7.8.2, created at 2026-02-14 10:41 +0000
« prev ^ index » next coverage.py v7.8.2, created at 2026-02-14 10:41 +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 doc_uris = self.doc_uris
146 parts = [def_by]
147 if self.dh_sequence is not None: 147 ↛ 149line 147 didn't jump to line 149 because the condition on line 147 was always true
148 parts.append(f"DH Sequence: {self.dh_sequence}")
149 if doc_uris: 149 ↛ 155line 149 didn't jump to line 155 because the condition on line 149 was always true
150 if len(doc_uris) == 1: 150 ↛ 153line 150 didn't jump to line 153 because the condition on line 150 was always true
151 parts.append(f"Documentation: {markdown_urlify(doc_uris[0])}")
152 else:
153 parts.append("Documentation:")
154 parts.extend(f" - {markdown_urlify(uri)}" for uri in doc_uris)
155 return "\n".join(parts)
157 @classmethod
158 def from_ref_data(cls, x: DCtrlSubstvar) -> "Self":
159 doc = x.get("documentation", {})
160 return cls(
161 x["name"],
162 x["defined_by"],
163 x.get("dh_sequence"),
164 doc.get("uris", []),
165 doc.get("synopsis", ""),
166 doc.get("long_description", ""),
167 )
170def relationship_substvar_for_field(substvar: str) -> str | None:
171 relationship_fields = all_package_relationship_fields()
172 try:
173 col_idx = substvar.rindex(":")
174 except ValueError:
175 return None
176 return relationship_fields.get(substvar[col_idx + 1 : -1].lower())
179def _as_substvars_metadata(
180 args: list[SubstvarMetadata],
181) -> Mapping[str, SubstvarMetadata]:
182 r = {s.name: s for s in args}
183 assert len(r) == len(args)
184 return r
187def dctrl_variables_metadata_basename() -> str:
188 return "debian_control_variables_data.yaml"
191@lru_cache
192def dctrl_substvars_metadata() -> Mapping[str, SubstvarMetadata]:
193 p = importlib.resources.files(deb822_ref_data_dir.__name__).joinpath(
194 dctrl_variables_metadata_basename()
195 )
197 with p.open("r", encoding="utf-8") as fd:
198 raw = MANIFEST_YAML.load(fd)
200 attr_path = AttributePath.root_path(p)
201 ref = DCTRL_SUBSTVARS_REFERENCE_DATA_PARSER.parse_input(raw, attr_path)
202 return _as_substvars_metadata(
203 [SubstvarMetadata.from_ref_data(x) for x in ref["variables"]]
204 )
207_DCTRL_FILE_METADATA = DctrlFileMetadata()
210lsp_standard_handler(_DISPATCH_RULE, TEXT_DOCUMENT_CODE_ACTION)
213@lsp_hover(_DISPATCH_RULE)
214def _debian_control_hover(
215 ls: "DebputyLanguageServer",
216 params: HoverParams,
217) -> Hover | None:
218 return deb822_hover(ls, params, _DCTRL_FILE_METADATA, custom_handler=_custom_hover)
221def _custom_hover_description(
222 _ls: "DebputyLanguageServer",
223 _known_field: DctrlKnownField,
224 line: str,
225 _word_at_position: str,
226) -> Hover | str | None:
227 if line[0].isspace(): 227 ↛ 228line 227 didn't jump to line 228 because the condition on line 227 was never true
228 return None
229 try:
230 col_idx = line.index(":")
231 except ValueError:
232 return None
234 content = line[col_idx + 1 :].strip()
236 # Synopsis
237 return textwrap.dedent(
238 f"""\
239 # Package synopsis
241 The synopsis functions as a phrase describing the package, not a
242 complete sentence, so sentential punctuation is inappropriate: it
243 does not need extra capital letters or a final period (full stop).
244 It should also omit any initial indefinite or definite article
245 - "a", "an", or "the". Thus for instance:
247 ```
248 Package: libeg0
249 Description: exemplification support library
250 ```
252 Technically this is a noun phrase minus articles, as opposed to a
253 verb phrase. A good heuristic is that it should be possible to
254 substitute the package name and synopsis into this formula:
256 ```
257 # Generic
258 The package provides { a,an,the,some} synopsis.
260 # The current package for comparison
261 The package provides { a,an,the,some} {content}.
262 ```
264 Other advice for writing synopsis:
265 * Avoid using the package name. Any software would display the
266 package name already and it generally does not help the user
267 understand what they are looking at.
268 * In many situations, the user will only see the package name
269 and its synopsis. The synopsis must be able to stand alone.
271 **Example renderings in various terminal UIs**:
272 ```
273 # apt search TERM
274 package/stable,now 1.0-1 all:
275 {content}
277 # apt-get search TERM
278 package - {content}
279 ```
281 ## Reference example
283 An reference example for comparison: The Sphinx package
284 (python3-sphinx/7.2.6-6) had the following synopsis:
286 ```
287 Description: documentation generator for Python projects
288 ```
290 In the test sentence, it would read as:
292 ```
293 The python3-sphinx package provides a documentation generator for Python projects.
294 ```
296 **Side-by-side comparison in the terminal UIs**:
297 ```
298 # apt search TERM
299 python3-sphinx/stable,now 7.2.6-6 all:
300 documentation generator for Python projects
302 package/stable,now 1.0-1 all:
303 {content}
306 # apt-get search TERM
307 package - {content}
308 python3-sphinx - documentation generator for Python projects
309 ```
310 """
311 )
314def _render_package_lookup(
315 package_lookup: PackageLookup,
316 known_field: DctrlKnownField,
317) -> str:
318 name = package_lookup.name
319 provider = package_lookup.package
320 if package_lookup.package is None and len(package_lookup.provided_by) == 1:
321 provider = package_lookup.provided_by[0]
323 if provider:
324 segments = [
325 f"# {name} ({provider.version}, {provider.architecture}) ",
326 "",
327 ]
329 if (
330 _is_bd_field(known_field)
331 and name.startswith("dh-sequence-")
332 and len(name) > 12
333 ):
334 sequence = name[12:]
335 segments.append(
336 f"This build-dependency will activate the `dh` sequence called `{sequence}`."
337 )
338 segments.append("")
340 elif (
341 known_field.name == "Build-Depends"
342 and name.startswith("debputy-plugin-")
343 and len(name) > 15
344 ):
345 plugin_name = name[15:]
346 segments.append(
347 f"This build-dependency will activate the `debputy` plugin called `{plugin_name}`."
348 )
349 segments.append("")
351 segments.extend(
352 [
353 f"Synopsis: {provider.synopsis}",
354 "",
355 f"Multi-Arch: {provider.multi_arch}",
356 "",
357 f"Section: {provider.section}",
358 ]
359 )
360 if provider.upstream_homepage is not None:
361 segments.append("")
362 segments.append(f"Upstream homepage: {provider.upstream_homepage}")
363 segments.append("")
364 segments.append(
365 "Data is from the system's APT cache, which may not match the target distribution."
366 )
367 return "\n".join(segments)
369 segments = [
370 f"# {name} [virtual]",
371 "",
372 "The package {name} is a virtual package provided by one of:",
373 ]
374 segments.extend(f" * {p.name}" for p in package_lookup.provided_by)
375 segments.append("")
376 segments.append(
377 "Data is from the system's APT cache, which may not match the target distribution."
378 )
379 return "\n".join(segments)
382def _disclaimer(is_empty: bool) -> str:
383 if is_empty:
384 return textwrap.dedent(
385 """\
386 The system's APT cache is empty, so it was not possible to verify that the
387 package exist.
388"""
389 )
390 return textwrap.dedent(
391 """\
392 The package is not known by the APT cache on this system, so there may be typo
393 or the package may not be available in the version of your distribution.
394"""
395 )
398def _render_package_by_name(
399 name: str, known_field: DctrlKnownField, is_empty: bool
400) -> str | None:
401 if _is_bd_field(known_field) and name.startswith("dh-sequence-") and len(name) > 12:
402 sequence = name[12:]
403 return (
404 textwrap.dedent(
405 f"""\
406 # {name}
408 This build-dependency will activate the `dh` sequence called `{sequence}`.
410 """
411 )
412 + _disclaimer(is_empty)
413 )
414 if (
415 known_field.name == "Build-Depends"
416 and name.startswith("debputy-plugin-")
417 and len(name) > 15
418 ):
419 plugin_name = name[15:]
420 return (
421 textwrap.dedent(
422 f"""\
423 # {name}
425 This build-dependency will activate the `debputy` plugin called `{plugin_name}`.
427 """
428 )
429 + _disclaimer(is_empty)
430 )
431 return (
432 textwrap.dedent(
433 f"""\
434 # {name}
436 """
437 )
438 + _disclaimer(is_empty)
439 )
442def _is_bd_field(known_field: DctrlKnownField) -> bool:
443 return known_field.name in (
444 "Build-Depends",
445 "Build-Depends-Arch",
446 "Build-Depends-Indep",
447 )
450def _custom_hover_relationship_field(
451 ls: "DebputyLanguageServer",
452 known_field: DctrlKnownField,
453 _line: str,
454 word_at_position: str,
455) -> Hover | str | None:
456 apt_cache = ls.apt_cache
457 state = apt_cache.state
458 is_empty = False
459 _info(f"Rel field: {known_field.name} - {word_at_position} - {state}")
460 if "|" in word_at_position: 460 ↛ 461line 460 didn't jump to line 461 because the condition on line 460 was never true
461 return textwrap.dedent(
462 f"""\
463 Sorry, no hover docs for OR relations at the moment.
465 The relation being matched: `{word_at_position}`
467 The code is missing logic to determine which side of the OR the lookup is happening.
468 """
469 )
470 match = next(iter(PKGNAME_REGEX.finditer(word_at_position)), None)
471 if match is None: 471 ↛ 472line 471 didn't jump to line 472 because the condition on line 471 was never true
472 return None
473 package = match.group()
474 if state == "empty-cache": 474 ↛ 475line 474 didn't jump to line 475 because the condition on line 474 was never true
475 state = "loaded"
476 is_empty = True
477 if state == "loaded": 477 ↛ 478line 477 didn't jump to line 478 because the condition on line 477 was never true
478 result = apt_cache.lookup(package)
479 if result is None:
480 return _render_package_by_name(
481 package,
482 known_field,
483 is_empty=is_empty,
484 )
485 return _render_package_lookup(result, known_field)
487 if state in ( 487 ↛ 501line 487 didn't jump to line 501 because the condition on line 487 was always true
488 "not-loaded",
489 "failed",
490 "tooling-not-available",
491 ):
492 details = apt_cache.load_error if apt_cache.load_error else "N/A"
493 return textwrap.dedent(
494 f"""\
495 Sorry, the APT cache data is not available due to an error or missing tool.
497 Details: {details}
498 """
499 )
501 if state == "empty-cache":
502 return f"Cannot lookup {package}: APT cache data was empty"
504 if state == "loading":
505 return f"Cannot lookup {package}: APT cache data is still being indexed. Please try again in a moment."
506 return None
509_CUSTOM_FIELD_HOVER = dict(
510 (
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 ),
518 Description=_custom_hover_description,
519)
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 )