Coverage for src/debputy/lsp/languages/lsp_debian_control.py: 63%
424 statements
« prev ^ index » next coverage.py v7.8.2, created at 2026-04-19 20:37 +0000
« prev ^ index » next coverage.py v7.8.2, created at 2026-04-19 20:37 +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 debian._deb822_repro import (
73 Deb822ParagraphElement,
74)
75from debian._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 debian._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(f"""\
238 # Package synopsis
240 The synopsis functions as a phrase describing the package, not a
241 complete sentence, so sentential punctuation is inappropriate: it
242 does not need extra capital letters or a final period (full stop).
243 It should also omit any initial indefinite or definite article
244 - "a", "an", or "the". Thus for instance:
246 ```
247 Package: libeg0
248 Description: exemplification support library
249 ```
251 Technically this is a noun phrase minus articles, as opposed to a
252 verb phrase. A good heuristic is that it should be possible to
253 substitute the package name and synopsis into this formula:
255 ```
256 # Generic
257 The package provides { a,an,the,some} synopsis.
259 # The current package for comparison
260 The package provides { a,an,the,some} {content}.
261 ```
263 Other advice for writing synopsis:
264 * Avoid using the package name. Any software would display the
265 package name already and it generally does not help the user
266 understand what they are looking at.
267 * In many situations, the user will only see the package name
268 and its synopsis. The synopsis must be able to stand alone.
270 **Example renderings in various terminal UIs**:
271 ```
272 # apt search TERM
273 package/stable,now 1.0-1 all:
274 {content}
276 # apt-get search TERM
277 package - {content}
278 ```
280 ## Reference example
282 An reference example for comparison: The Sphinx package
283 (python3-sphinx/7.2.6-6) had the following synopsis:
285 ```
286 Description: documentation generator for Python projects
287 ```
289 In the test sentence, it would read as:
291 ```
292 The python3-sphinx package provides a documentation generator for Python projects.
293 ```
295 **Side-by-side comparison in the terminal UIs**:
296 ```
297 # apt search TERM
298 python3-sphinx/stable,now 7.2.6-6 all:
299 documentation generator for Python projects
301 package/stable,now 1.0-1 all:
302 {content}
305 # apt-get search TERM
306 package - {content}
307 python3-sphinx - documentation generator for Python projects
308 ```
309 """)
312def _render_package_lookup(
313 package_lookup: PackageLookup,
314 known_field: DctrlKnownField,
315) -> str:
316 name = package_lookup.name
317 provider = package_lookup.package
318 if package_lookup.package is None and len(package_lookup.provided_by) == 1:
319 provider = package_lookup.provided_by[0]
321 if provider:
322 segments = [
323 f"# {name} ({provider.version}, {provider.architecture}) ",
324 "",
325 ]
327 if (
328 _is_bd_field(known_field)
329 and name.startswith("dh-sequence-")
330 and len(name) > 12
331 ):
332 sequence = name[12:]
333 segments.append(
334 f"This build-dependency will activate the `dh` sequence called `{sequence}`."
335 )
336 segments.append("")
338 elif (
339 known_field.name == "Build-Depends"
340 and name.startswith("debputy-plugin-")
341 and len(name) > 15
342 ):
343 plugin_name = name[15:]
344 segments.append(
345 f"This build-dependency will activate the `debputy` plugin called `{plugin_name}`."
346 )
347 segments.append("")
349 segments.extend(
350 [
351 f"Synopsis: {provider.synopsis}",
352 "",
353 f"Multi-Arch: {provider.multi_arch}",
354 "",
355 f"Section: {provider.section}",
356 ]
357 )
358 if provider.upstream_homepage is not None:
359 segments.append("")
360 segments.append(f"Upstream homepage: {provider.upstream_homepage}")
361 segments.append("")
362 segments.append(
363 "Data is from the system's APT cache, which may not match the target distribution."
364 )
365 return "\n".join(segments)
367 segments = [
368 f"# {name} [virtual]",
369 "",
370 "The package {name} is a virtual package provided by one of:",
371 ]
372 segments.extend(f" * {p.name}" for p in package_lookup.provided_by)
373 segments.append("")
374 segments.append(
375 "Data is from the system's APT cache, which may not match the target distribution."
376 )
377 return "\n".join(segments)
380def _disclaimer(is_empty: bool) -> str:
381 if is_empty:
382 return textwrap.dedent("""\
383 The system's APT cache is empty, so it was not possible to verify that the
384 package exist.
385""")
386 return textwrap.dedent("""\
387 The package is not known by the APT cache on this system, so there may be typo
388 or the package may not be available in the version of your distribution.
389""")
392def _render_package_by_name(
393 name: str, known_field: DctrlKnownField, is_empty: bool
394) -> str | None:
395 if _is_bd_field(known_field) and name.startswith("dh-sequence-") and len(name) > 12:
396 sequence = name[12:]
397 return textwrap.dedent(f"""\
398 # {name}
400 This build-dependency will activate the `dh` sequence called `{sequence}`.
402 """) + _disclaimer(is_empty)
403 if (
404 known_field.name == "Build-Depends"
405 and name.startswith("debputy-plugin-")
406 and len(name) > 15
407 ):
408 plugin_name = name[15:]
409 return textwrap.dedent(f"""\
410 # {name}
412 This build-dependency will activate the `debputy` plugin called `{plugin_name}`.
414 """) + _disclaimer(is_empty)
415 return textwrap.dedent(f"""\
416 # {name}
418 """) + _disclaimer(is_empty)
421def _is_bd_field(known_field: DctrlKnownField) -> bool:
422 return known_field.name in (
423 "Build-Depends",
424 "Build-Depends-Arch",
425 "Build-Depends-Indep",
426 )
429def _custom_hover_relationship_field(
430 ls: "DebputyLanguageServer",
431 known_field: DctrlKnownField,
432 _line: str,
433 word_at_position: str,
434) -> Hover | str | None:
435 apt_cache = ls.apt_cache
436 state = apt_cache.state
437 is_empty = False
438 _info(f"Rel field: {known_field.name} - {word_at_position} - {state}")
439 if "|" in word_at_position: 439 ↛ 440line 439 didn't jump to line 440 because the condition on line 439 was never true
440 return textwrap.dedent(f"""\
441 Sorry, no hover docs for OR relations at the moment.
443 The relation being matched: `{word_at_position}`
445 The code is missing logic to determine which side of the OR the lookup is happening.
446 """)
447 match = next(iter(PKGNAME_REGEX.finditer(word_at_position)), None)
448 if match is None: 448 ↛ 449line 448 didn't jump to line 449 because the condition on line 448 was never true
449 return None
450 package = match.group()
451 if state == "empty-cache": 451 ↛ 452line 451 didn't jump to line 452 because the condition on line 451 was never true
452 state = "loaded"
453 is_empty = True
454 if state == "loaded": 454 ↛ 455line 454 didn't jump to line 455 because the condition on line 454 was never true
455 result = apt_cache.lookup(package)
456 if result is None:
457 return _render_package_by_name(
458 package,
459 known_field,
460 is_empty=is_empty,
461 )
462 return _render_package_lookup(result, known_field)
464 if state in ( 464 ↛ 476line 464 didn't jump to line 476 because the condition on line 464 was always true
465 "not-loaded",
466 "failed",
467 "tooling-not-available",
468 ):
469 details = apt_cache.load_error if apt_cache.load_error else "N/A"
470 return textwrap.dedent(f"""\
471 Sorry, the APT cache data is not available due to an error or missing tool.
473 Details: {details}
474 """)
476 if state == "empty-cache":
477 return f"Cannot lookup {package}: APT cache data was empty"
479 if state == "loading":
480 return f"Cannot lookup {package}: APT cache data is still being indexed. Please try again in a moment."
481 return None
484_CUSTOM_FIELD_HOVER = dict(
485 (
486 (field, _custom_hover_relationship_field)
487 for field in chain(
488 all_package_relationship_fields().values(),
489 all_source_relationship_fields().values(),
490 )
491 if field != "Provides"
492 ),
493 Description=_custom_hover_description,
494)
497def _custom_hover(
498 ls: "DebputyLanguageServer",
499 server_position: Position,
500 _current_field: str | None,
501 word_at_position: str,
502 known_field: DctrlKnownField | None,
503 in_value: bool,
504 _doc: "TextDocument",
505 lines: list[str],
506) -> Hover | str | None:
507 if not in_value:
508 return None
510 line_no = server_position.line
511 line = lines[line_no]
512 substvar_search_ref = server_position.character
513 substvar = ""
514 try:
515 if line and line[substvar_search_ref] in ("$", "{"):
516 substvar_search_ref += 2
517 substvar_start = line.rindex("${", 0, substvar_search_ref)
518 substvar_end = line.index("}", substvar_start)
519 if server_position.character <= substvar_end:
520 substvar = line[substvar_start : substvar_end + 1]
521 except (ValueError, IndexError):
522 pass
524 if substvar == "${}" or SUBSTVAR_RE.fullmatch(substvar):
525 substvar_md = dctrl_substvars_metadata().get(substvar)
527 computed_doc = ""
528 for_field = relationship_substvar_for_field(substvar)
529 if for_field: 529 ↛ 531line 529 didn't jump to line 531 because the condition on line 529 was never true
530 # Leading empty line is intentional!
531 computed_doc = textwrap.dedent(f"""
532 This substvar is a relationship substvar for the field {for_field}.
533 Relationship substvars are automatically added in the field they
534 are named after in `debhelper-compat (= 14)` or later, or with
535 `debputy` (any integration mode after 0.1.21).
536 """)
538 if substvar_md is None: 538 ↛ 539line 538 didn't jump to line 539 because the condition on line 538 was never true
539 doc = f"No documentation for {substvar}.\n"
540 md_fields = ""
541 else:
542 doc = ls.translation(LSP_DATA_DOMAIN).pgettext(
543 f"Variable:{substvar_md.name}",
544 substvar_md.description,
545 )
546 md_fields = "\n" + substvar_md.render_metadata_fields()
547 return f"# Substvar `{substvar}`\n\n{doc}{computed_doc}{md_fields}"
549 if known_field is None: 549 ↛ 550line 549 didn't jump to line 550 because the condition on line 549 was never true
550 return None
551 dispatch = _CUSTOM_FIELD_HOVER.get(known_field.name)
552 if dispatch is None: 552 ↛ 553line 552 didn't jump to line 553 because the condition on line 552 was never true
553 return None
554 return dispatch(ls, known_field, line, word_at_position)
557@lsp_completer(_DISPATCH_RULE)
558def _debian_control_completions(
559 ls: "DebputyLanguageServer",
560 params: CompletionParams,
561) -> CompletionList | Sequence[CompletionItem] | None:
562 return deb822_completer(ls, params, _DCTRL_FILE_METADATA)
565@lsp_folding_ranges(_DISPATCH_RULE)
566def _debian_control_folding_ranges(
567 ls: "DebputyLanguageServer",
568 params: FoldingRangeParams,
569) -> Sequence[FoldingRange] | None:
570 return deb822_folding_ranges(ls, params, _DCTRL_FILE_METADATA)
573@lsp_text_doc_inlay_hints(_DISPATCH_RULE)
574async def _doc_inlay_hint(
575 ls: "DebputyLanguageServer",
576 params: types.InlayHintParams,
577) -> list[InlayHint] | None:
578 doc = ls.workspace.get_text_document(params.text_document.uri)
579 lint_state = ls.lint_state(doc)
580 deb822_file = lint_state.parsed_deb822_file_content
581 if not deb822_file:
582 return None
583 inlay_hints = []
584 stanzas = list(deb822_file)
585 if len(stanzas) < 2:
586 return None
587 source_stanza = stanzas[0]
588 source_stanza_pos = source_stanza.position_in_file()
589 inherited_inlay_label_part = {}
590 stanza_no = 0
592 async for stanza_range, stanza in lint_state.slow_iter(
593 with_range_in_continuous_parts(deb822_file.iter_parts())
594 ):
595 if not isinstance(stanza, Deb822ParagraphElement):
596 continue
597 stanza_def = _DCTRL_FILE_METADATA.classify_stanza(stanza, stanza_no)
598 stanza_no += 1
599 pkg_kvpair = stanza.get_kvpair_element(("Package", 0), use_get=True)
600 if pkg_kvpair is None:
601 continue
603 parts = []
604 async for known_field in ls.slow_iter(
605 stanza_def.stanza_fields.values(), yield_every=25
606 ):
607 if (
608 not known_field.inheritable_from_other_stanza
609 or not known_field.show_as_inherited
610 or known_field.name in stanza
611 ):
612 continue
614 inherited_value = source_stanza.get(known_field.name)
615 if inherited_value is not None:
616 inlay_hint_label_part = inherited_inlay_label_part.get(known_field.name)
617 if inlay_hint_label_part is None:
618 kvpair = source_stanza.get_kvpair_element(known_field.name)
619 value_range_te = kvpair.range_in_parent().relative_to(
620 source_stanza_pos
621 )
622 value_range = doc.position_codec.range_to_client_units(
623 lint_state.lines,
624 te_range_to_lsp(value_range_te),
625 )
626 inlay_hint_label_part = types.InlayHintLabelPart(
627 f" ({known_field.name}: {inherited_value})",
628 tooltip="Inherited from Source stanza",
629 location=types.Location(
630 params.text_document.uri,
631 value_range,
632 ),
633 )
634 inherited_inlay_label_part[known_field.name] = inlay_hint_label_part
635 parts.append(inlay_hint_label_part)
637 if parts:
638 known_field = stanza_def["Package"]
639 values = known_field.field_value_class.interpreter().interpret(pkg_kvpair)
640 assert values is not None
641 anchor_value = list(values.iter_value_references())[-1]
642 anchor_position = (
643 anchor_value.locatable.range_in_parent().end_pos.relative_to(
644 pkg_kvpair.value_element.position_in_parent().relative_to(
645 stanza_range.start_pos
646 )
647 )
648 )
649 anchor_position_client_units = doc.position_codec.position_to_client_units(
650 lint_state.lines,
651 te_position_to_lsp(anchor_position),
652 )
653 inlay_hints.append(
654 types.InlayHint(
655 anchor_position_client_units,
656 parts,
657 padding_left=True,
658 padding_right=False,
659 )
660 )
661 return inlay_hints
664def _source_package_checks(
665 stanza: Deb822ParagraphElement,
666 stanza_position: "TEPosition",
667 stanza_metadata: StanzaMetadata[DctrlKnownField],
668 lint_state: LintState,
669) -> None:
670 vcs_fields = {}
671 source_fields = _DCTRL_FILE_METADATA["Source"].stanza_fields
672 for kvpair in stanza.iter_parts_of_type(Deb822KeyValuePairElement):
673 name = stanza_metadata.normalize_field_name(kvpair.field_name.lower())
674 if (
675 not name.startswith("vcs-")
676 or name == "vcs-browser"
677 or name not in source_fields
678 ):
679 continue
680 vcs_fields[name] = kvpair
682 if len(vcs_fields) < 2:
683 return
684 for kvpair in vcs_fields.values():
685 lint_state.emit_diagnostic(
686 kvpair.range_in_parent().relative_to(stanza_position),
687 f'Multiple Version Control fields defined ("{kvpair.field_name}")',
688 "warning",
689 "Policy 5.6.26",
690 quickfixes=[
691 propose_remove_range_quick_fix(
692 proposed_title=f'Remove "{kvpair.field_name}"'
693 )
694 ],
695 )
698def _binary_package_checks(
699 stanza: Deb822ParagraphElement,
700 stanza_position: "TEPosition",
701 source_stanza: Deb822ParagraphElement,
702 representation_field_range: "TERange",
703 lint_state: LintState,
704) -> None:
705 package_name = stanza.get("Package", "")
706 source_section = source_stanza.get("Section")
707 section_kvpair = stanza.get_kvpair_element(("Section", 0), use_get=True)
708 section: str | None = None
709 section_range: Optional["TERange"] = None
710 if section_kvpair is not None:
711 section, section_range = extract_first_value_and_position(
712 section_kvpair,
713 stanza_position,
714 )
716 if section_range is None:
717 section_range = representation_field_range
718 effective_section = section or source_section or "unknown"
719 package_type = stanza.get("Package-Type", "")
720 component_prefix = ""
721 if "/" in effective_section:
722 component_prefix, effective_section = effective_section.split("/", maxsplit=1)
723 component_prefix += "/"
725 if package_name.endswith("-udeb") or package_type == "udeb":
726 if package_type != "udeb": 726 ↛ 727line 726 didn't jump to line 727 because the condition on line 726 was never true
727 package_type_kvpair = stanza.get_kvpair_element(
728 "Package-Type", use_get=True
729 )
730 package_type_range: Optional["TERange"] = None
731 if package_type_kvpair is not None:
732 _, package_type_range = extract_first_value_and_position(
733 package_type_kvpair,
734 stanza_position,
735 )
736 if package_type_range is None:
737 package_type_range = representation_field_range
738 lint_state.emit_diagnostic(
739 package_type_range,
740 'The Package-Type should be "udeb" given the package name',
741 "warning",
742 "debputy",
743 )
744 guessed_section = "debian-installer"
745 section_diagnostic_rationale = " since it is an udeb"
746 else:
747 guessed_section = package_name_to_section(package_name)
748 section_diagnostic_rationale = " based on the package name"
749 if guessed_section is not None and guessed_section != effective_section: 749 ↛ 750line 749 didn't jump to line 750 because the condition on line 749 was never true
750 if section is not None:
751 quickfix_data = [
752 propose_correct_text_quick_fix(f"{component_prefix}{guessed_section}")
753 ]
754 else:
755 quickfix_data = [
756 propose_insert_text_on_line_after_diagnostic_quick_fix(
757 f"Section: {component_prefix}{guessed_section}\n"
758 )
759 ]
760 assert section_range is not None # mypy hint
761 lint_state.emit_diagnostic(
762 section_range,
763 f'The Section should be "{component_prefix}{guessed_section}"{section_diagnostic_rationale}',
764 "warning",
765 "debputy",
766 quickfixes=quickfix_data,
767 )
770@lint_diagnostics(_DISPATCH_RULE)
771async def _lint_debian_control(lint_state: LintState) -> None:
772 deb822_file = lint_state.parsed_deb822_file_content
774 if not _DCTRL_FILE_METADATA.file_metadata_applies_to_file(deb822_file): 774 ↛ 775line 774 didn't jump to line 775 because the condition on line 774 was never true
775 return
777 first_error = await scan_for_syntax_errors_and_token_level_diagnostics(
778 deb822_file,
779 lint_state,
780 )
782 stanzas = list(deb822_file)
783 source_stanza = stanzas[0] if stanzas else None
784 binary_stanzas_w_pos = []
786 source_stanza_metadata, binary_stanza_metadata = _DCTRL_FILE_METADATA.stanza_types()
787 stanza_no = 0
789 async for stanza_range, stanza in lint_state.slow_iter(
790 with_range_in_continuous_parts(deb822_file.iter_parts())
791 ):
792 if not isinstance(stanza, Deb822ParagraphElement):
793 continue
794 stanza_position = stanza_range.start_pos
795 if stanza_position.line_position >= first_error: 795 ↛ 796line 795 didn't jump to line 796 because the condition on line 795 was never true
796 break
797 stanza_no += 1
798 is_binary_stanza = stanza_no != 1
799 if is_binary_stanza:
800 stanza_metadata = binary_stanza_metadata
801 other_stanza_metadata = source_stanza_metadata
802 other_stanza_name = "Source"
803 binary_stanzas_w_pos.append((stanza, stanza_position))
804 _, representation_field_range = stanza_metadata.stanza_representation(
805 stanza, stanza_position
806 )
807 _binary_package_checks(
808 stanza,
809 stanza_position,
810 source_stanza,
811 representation_field_range,
812 lint_state,
813 )
814 else:
815 stanza_metadata = source_stanza_metadata
816 other_stanza_metadata = binary_stanza_metadata
817 other_stanza_name = "Binary"
818 _source_package_checks(
819 stanza,
820 stanza_position,
821 stanza_metadata,
822 lint_state,
823 )
825 await stanza_metadata.stanza_diagnostics(
826 deb822_file,
827 stanza,
828 stanza_position,
829 lint_state,
830 confusable_with_stanza_metadata=other_stanza_metadata,
831 confusable_with_stanza_name=other_stanza_name,
832 inherit_from_stanza=source_stanza if is_binary_stanza else None,
833 )
835 _detect_misspelled_packaging_files(
836 lint_state,
837 binary_stanzas_w_pos,
838 )
841def _package_range_of_stanza(
842 binary_stanzas: list[tuple[Deb822ParagraphElement, TEPosition]],
843) -> Iterable[tuple[str, str | None, "TERange"]]:
844 for stanza, stanza_position in binary_stanzas:
845 kvpair = stanza.get_kvpair_element(("Package", 0), use_get=True)
846 if kvpair is None: 846 ↛ 847line 846 didn't jump to line 847 because the condition on line 846 was never true
847 continue
848 representation_field_range = kvpair.range_in_parent().relative_to(
849 stanza_position
850 )
851 yield stanza["Package"], stanza.get("Architecture"), representation_field_range
854def _packaging_files(
855 lint_state: LintState,
856) -> Iterable[PackagerProvidedFile]:
857 source_root = lint_state.source_root
858 debian_dir = lint_state.debian_dir
859 binary_packages = lint_state.binary_packages
860 if (
861 source_root is None
862 or not source_root.has_fs_path
863 or debian_dir is None
864 or binary_packages is None
865 ):
866 return
868 debputy_integration_mode = lint_state.debputy_metadata.debputy_integration_mode
869 dh_sequencer_data = lint_state.dh_sequencer_data
870 dh_sequences = dh_sequencer_data.sequences
871 is_debputy_package = debputy_integration_mode is not None
872 feature_set = lint_state.plugin_feature_set
873 known_packaging_files = feature_set.known_packaging_files
874 static_packaging_files = {
875 kpf.detection_value: kpf
876 for kpf in known_packaging_files.values()
877 if kpf.detection_method == "path"
878 }
879 ignored_path = set(static_packaging_files)
881 if is_debputy_package:
882 all_debputy_ppfs = list(
883 flatten_ppfs(
884 detect_all_packager_provided_files(
885 feature_set,
886 debian_dir,
887 binary_packages,
888 allow_fuzzy_matches=True,
889 detect_typos=True,
890 ignore_paths=ignored_path,
891 )
892 )
893 )
894 for ppf in all_debputy_ppfs:
895 if ppf.path.path in ignored_path: 895 ↛ 896line 895 didn't jump to line 896 because the condition on line 895 was never true
896 continue
897 ignored_path.add(ppf.path.path)
898 yield ppf
900 # FIXME: This should read the editor data, but dh_assistant does not support that.
901 dh_compat_level, _ = extract_dh_compat_level(cwd=source_root.fs_path)
902 if dh_compat_level is not None: 902 ↛ exitline 902 didn't return from function '_packaging_files' because the condition on line 902 was always true
903 debputy_plugin_metadata = plugin_metadata_for_debputys_own_plugin()
904 (
905 all_dh_ppfs,
906 _,
907 _,
908 _,
909 ) = resolve_debhelper_config_files(
910 debian_dir,
911 binary_packages,
912 debputy_plugin_metadata,
913 feature_set,
914 dh_sequences,
915 dh_compat_level,
916 saw_dh=dh_sequencer_data.uses_dh_sequencer,
917 ignore_paths=ignored_path,
918 debputy_integration_mode=debputy_integration_mode,
919 cwd=source_root.fs_path,
920 )
921 for ppf in all_dh_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
928def _detect_misspelled_packaging_files(
929 lint_state: LintState,
930 binary_stanzas_w_pos: list[tuple[Deb822ParagraphElement, TEPosition]],
931) -> None:
932 stanza_ranges = {
933 p: (a, r) for p, a, r in _package_range_of_stanza(binary_stanzas_w_pos)
934 }
935 for ppf in _packaging_files(lint_state):
936 binary_package = ppf.package_name
937 explicit_package = ppf.uses_explicit_package_name
938 name_segment = ppf.name_segment is not None
939 stem = ppf.definition.stem
940 if _is_trace_log_enabled(): 940 ↛ 941line 940 didn't jump to line 941 because the condition on line 940 was never true
941 _trace_log(
942 f"PPF check: {binary_package} {stem=} {explicit_package=} {name_segment=} {ppf.expected_path=} {ppf.definition.has_active_command=}"
943 )
944 if binary_package is None or stem is None: 944 ↛ 945line 944 didn't jump to line 945 because the condition on line 944 was never true
945 continue
946 res = stanza_ranges.get(binary_package)
947 if res is None: 947 ↛ 948line 947 didn't jump to line 948 because the condition on line 947 was never true
948 continue
949 declared_arch, diag_range = res
950 if diag_range is None: 950 ↛ 951line 950 didn't jump to line 951 because the condition on line 950 was never true
951 continue
952 path = ppf.path.path
953 likely_typo_of = ppf.expected_path
954 arch_restriction = ppf.architecture_restriction
955 if likely_typo_of is not None:
956 # Handles arch_restriction == 'all' at the same time due to how
957 # the `likely-typo-of` is created
958 lint_state.emit_diagnostic(
959 diag_range,
960 f'The file "{path}" is likely a typo of "{likely_typo_of}"',
961 "warning",
962 "debputy",
963 diagnostic_applies_to_another_file=path,
964 )
965 continue
966 if declared_arch == "all" and arch_restriction is not None: 966 ↛ 967line 966 didn't jump to line 967 because the condition on line 966 was never true
967 lint_state.emit_diagnostic(
968 diag_range,
969 f'The file "{path}" has an architecture restriction but is for an `arch:all` package, so'
970 f" the restriction does not make sense.",
971 "warning",
972 "debputy",
973 diagnostic_applies_to_another_file=path,
974 )
975 elif arch_restriction == "all": 975 ↛ 976line 975 didn't jump to line 976 because the condition on line 975 was never true
976 lint_state.emit_diagnostic(
977 diag_range,
978 f'The file "{path}" has an architecture restriction of `all` rather than a real architecture',
979 "warning",
980 "debputy",
981 diagnostic_applies_to_another_file=path,
982 )
984 if not ppf.definition.has_active_command: 984 ↛ 985line 984 didn't jump to line 985 because the condition on line 984 was never true
985 lint_state.emit_diagnostic(
986 diag_range,
987 f"The file {path} is related to a command that is not active in the dh sequence"
988 " with the current addons",
989 "warning",
990 "debputy",
991 diagnostic_applies_to_another_file=path,
992 )
993 continue
995 if not explicit_package and name_segment is not None:
996 basename = os.path.basename(path)
997 if basename == ppf.definition.stem:
998 continue
999 alt_name = f"{binary_package}.{stem}"
1000 if arch_restriction is not None: 1000 ↛ 1001line 1000 didn't jump to line 1001 because the condition on line 1000 was never true
1001 alt_name = f"{alt_name}.{arch_restriction}"
1002 if ppf.definition.allow_name_segment:
1003 or_alt_name = f' (or maybe "debian/{binary_package}.{basename}")'
1004 else:
1005 or_alt_name = ""
1007 lint_state.emit_diagnostic(
1008 diag_range,
1009 f'Possible typo in "{path}". Consider renaming the file to "debian/{alt_name}"'
1010 f"{or_alt_name} if it is intended for {binary_package}",
1011 "warning",
1012 "debputy",
1013 diagnostic_applies_to_another_file=path,
1014 )
1017@lsp_will_save_wait_until(_DISPATCH_RULE)
1018def _debian_control_on_save_formatting(
1019 ls: "DebputyLanguageServer",
1020 params: WillSaveTextDocumentParams,
1021) -> Sequence[TextEdit] | None:
1022 doc = ls.workspace.get_text_document(params.text_document.uri)
1023 lint_state = ls.lint_state(doc)
1024 return _reformat_debian_control(lint_state)
1027@lsp_cli_reformat_document(_DISPATCH_RULE)
1028def _reformat_debian_control(
1029 lint_state: LintState,
1030) -> Sequence[TextEdit] | None:
1031 return deb822_format_file(lint_state, _DCTRL_FILE_METADATA)
1034@lsp_format_document(_DISPATCH_RULE)
1035def _debian_control_format_file(
1036 ls: "DebputyLanguageServer",
1037 params: DocumentFormattingParams,
1038) -> Sequence[TextEdit] | None:
1039 doc = ls.workspace.get_text_document(params.text_document.uri)
1040 lint_state = ls.lint_state(doc)
1041 return _reformat_debian_control(lint_state)
1044@lsp_semantic_tokens_full(_DISPATCH_RULE)
1045async def _debian_control_semantic_tokens_full(
1046 ls: "DebputyLanguageServer",
1047 request: SemanticTokensParams,
1048) -> SemanticTokens | None:
1049 return await deb822_semantic_tokens_full(
1050 ls,
1051 request,
1052 _DCTRL_FILE_METADATA,
1053 )