Coverage for src/debputy/lsp/lsp_debian_control.py: 65%
420 statements
« prev ^ index » next coverage.py v7.6.0, created at 2025-01-27 13:59 +0000
« prev ^ index » next coverage.py v7.6.0, created at 2025-01-27 13:59 +0000
1import dataclasses
2import importlib.resources
3import os.path
4import textwrap
6import debputy.lsp.data.deb822_data as deb822_ref_data_dir
8from functools import lru_cache
9from itertools import chain
10from typing import (
11 Union,
12 Sequence,
13 Tuple,
14 Optional,
15 Mapping,
16 List,
17 Iterable,
18 Self,
19)
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_token_iter,
60 deb822_format_file,
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 Deb822FileElement,
75 Deb822ParagraphElement,
76)
77from debputy.lsp.vendoring._deb822_repro.parsing import (
78 Deb822KeyValuePairElement,
79)
80from debputy.lsprotocol.types import (
81 Position,
82 FoldingRange,
83 FoldingRangeParams,
84 CompletionItem,
85 CompletionList,
86 CompletionParams,
87 Location,
88 HoverParams,
89 Hover,
90 TEXT_DOCUMENT_CODE_ACTION,
91 SemanticTokens,
92 SemanticTokensParams,
93 WillSaveTextDocumentParams,
94 TextEdit,
95 DocumentFormattingParams,
96 InlayHintParams,
97 InlayHint,
98 InlayHintLabelPart,
99)
100from debputy.manifest_parser.util import AttributePath
101from debputy.packager_provided_files import (
102 PackagerProvidedFile,
103 detect_all_packager_provided_files,
104)
105from debputy.plugin.api.impl import plugin_metadata_for_debputys_own_plugin
106from debputy.util import PKGNAME_REGEX, _info
107from debputy.yaml import MANIFEST_YAML
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 "debian/control",
124 [
125 # emacs's name
126 SecondaryLanguage("debian-control"),
127 # vim's name
128 SecondaryLanguage("debcontrol"),
129 ],
130)
133@dataclasses.dataclass(slots=True, frozen=True)
134class SubstvarMetadata:
135 name: str
136 defined_by: str
137 dh_sequence: Optional[str]
138 doc_uris: Sequence[str]
139 synopsis: str
140 description: str
142 def render_metadata_fields(self) -> str:
143 def_by = f"Defined by: {self.defined_by}"
144 dh_seq = (
145 f"DH Sequence: {self.dh_sequence}" if self.dh_sequence is not None else None
146 )
147 doc_uris = self.doc_uris
148 parts = [def_by, dh_seq]
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) -> Optional[str]:
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 substvars_metadata_basename() -> str:
188 return "debian_control_substvars_data.yaml"
191@lru_cache
192def substvars_metadata() -> Mapping[str, SubstvarMetadata]:
193 p = importlib.resources.files(deb822_ref_data_dir.__name__).joinpath(
194 substvars_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["substvars"]]
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) -> Optional[Hover]:
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) -> Optional[Union[Hover, str]]:
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 \
240 # Package synopsis
242 The synopsis functions as a phrase describing the package, not a
243 complete sentence, so sentential punctuation is inappropriate: it
244 does not need extra capital letters or a final period (full stop).
245 It should also omit any initial indefinite or definite article
246 - "a", "an", or "the". Thus for instance:
248 ```
249 Package: libeg0
250 Description: exemplification support library
251 ```
253 Technically this is a noun phrase minus articles, as opposed to a
254 verb phrase. A good heuristic is that it should be possible to
255 substitute the package name and synopsis into this formula:
257 ```
258 # Generic
259 The package provides { a,an,the,some} synopsis.
261 # The current package for comparison
262 The package provides { a,an,the,some} {content}.
263 ```
265 Other advice for writing synopsis:
266 * Avoid using the package name. Any software would display the
267 package name already and it generally does not help the user
268 understand what they are looking at.
269 * In many situations, the user will only see the package name
270 and its synopsis. The synopsis must be able to stand alone.
272 **Example renderings in various terminal UIs**:
273 ```
274 # apt search TERM
275 package/stable,now 1.0-1 all:
276 {content}
278 # apt-get search TERM
279 package - {content}
280 ```
282 ## Reference example
284 An reference example for comparison: The Sphinx package
285 (python3-sphinx/7.2.6-6) had the following synopsis:
287 ```
288 Description: documentation generator for Python projects
289 ```
291 In the test sentence, it would read as:
293 ```
294 The python3-sphinx package provides a documentation generator for Python projects.
295 ```
297 **Side-by-side comparison in the terminal UIs**:
298 ```
299 # apt search TERM
300 python3-sphinx/stable,now 7.2.6-6 all:
301 documentation generator for Python projects
303 package/stable,now 1.0-1 all:
304 {content}
307 # apt-get search TERM
308 package - {content}
309 python3-sphinx - documentation generator for Python projects
310 ```
311 """
312 )
315def _render_package_lookup(
316 package_lookup: PackageLookup,
317 known_field: DctrlKnownField,
318) -> str:
319 name = package_lookup.name
320 provider = package_lookup.package
321 if package_lookup.package is None and len(package_lookup.provided_by) == 1:
322 provider = package_lookup.provided_by[0]
324 if provider:
325 segments = [
326 f"# {name} ({provider.version}, {provider.architecture}) ",
327 "",
328 ]
330 if (
331 _is_bd_field(known_field)
332 and name.startswith("dh-sequence-")
333 and len(name) > 12
334 ):
335 sequence = name[12:]
336 segments.append(
337 f"This build-dependency will activate the `dh` sequence called `{sequence}`."
338 )
339 segments.append("")
341 elif (
342 known_field.name == "Build-Depends"
343 and name.startswith("debputy-plugin-")
344 and len(name) > 15
345 ):
346 plugin_name = name[15:]
347 segments.append(
348 f"This build-dependency will activate the `debputy` plugin called `{plugin_name}`."
349 )
350 segments.append("")
352 segments.extend(
353 [
354 f"Synopsis: {provider.synopsis}",
355 f"Multi-Arch: {provider.multi_arch}",
356 f"Section: {provider.section}",
357 ]
358 )
359 if provider.upstream_homepage is not None:
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 """\
384 The system's APT cache is empty, so it was not possible to verify that the
385 package exist.
386"""
387 )
388 return textwrap.dedent(
389 """\
390 The package is not known by the APT cache on this system, so there may be typo
391 or the package may not be available in the version of your distribution.
392"""
393 )
396def _render_package_by_name(
397 name: str, known_field: DctrlKnownField, is_empty: bool
398) -> Optional[str]:
399 if _is_bd_field(known_field) and name.startswith("dh-sequence-") and len(name) > 12:
400 sequence = name[12:]
401 return (
402 textwrap.dedent(
403 f"""\
404 \
405 # {name}
407 This build-dependency will activate the `dh` sequence called `{sequence}`.
409 """
410 )
411 + _disclaimer(is_empty)
412 )
413 if (
414 known_field.name == "Build-Depends"
415 and name.startswith("debputy-plugin-")
416 and len(name) > 15
417 ):
418 plugin_name = name[15:]
419 return (
420 textwrap.dedent(
421 f"""\
422 \
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 \
435 # {name}
437 """
438 )
439 + _disclaimer(is_empty)
440 )
443def _is_bd_field(known_field: DctrlKnownField) -> bool:
444 return known_field.name in (
445 "Build-Depends",
446 "Build-Depends-Arch",
447 "Build-Depends-Indep",
448 )
451def _custom_hover_relationship_field(
452 ls: "DebputyLanguageServer",
453 known_field: DctrlKnownField,
454 _line: str,
455 word_at_position: str,
456) -> Optional[Union[Hover, str]]:
457 apt_cache = ls.apt_cache 457 ↛ 458line 457 didn't jump to line 458 because the condition on line 457 was never true
458 state = apt_cache.state
459 is_empty = False
460 _info(f"Rel field: {known_field.name} - {word_at_position} - {state}")
461 if "|" in word_at_position:
462 return textwrap.dedent(
463 f"""\
464 \
465 Sorry, no hover docs for OR relations at the moment.
467 The relation being matched: `{word_at_position}`
468 468 ↛ 469line 468 didn't jump to line 469 because the condition on line 468 was never true
469 The code is missing logic to determine which side of the OR the lookup is happening.
470 """
471 ) 471 ↛ 472line 471 didn't jump to line 472 because the condition on line 471 was never true
472 match = next(iter(PKGNAME_REGEX.finditer(word_at_position)), None)
473 if match is None:
474 return 474 ↛ 475line 474 didn't jump to line 475 because the condition on line 474 was never true
475 package = match.group()
476 if state == "empty-cache":
477 state = "loaded"
478 is_empty = True
479 if state == "loaded":
480 result = apt_cache.lookup(package)
481 if result is None:
482 return _render_package_by_name(
483 package,
484 known_field, 484 ↛ 498line 484 didn't jump to line 498 because the condition on line 484 was always true
485 is_empty=is_empty,
486 )
487 return _render_package_lookup(result, known_field)
489 if state in (
490 "not-loaded",
491 "failed",
492 "tooling-not-available",
493 ):
494 details = apt_cache.load_error if apt_cache.load_error else "N/A"
495 return textwrap.dedent(
496 f"""\
497 \
498 Sorry, the APT cache data is not available due to an error or missing tool.
500 Details: {details}
501 """
502 )
504 if state == "empty-cache":
505 return f"Cannot lookup {package}: APT cache data was empty"
507 if state == "loading":
508 return f"Cannot lookup {package}: APT cache data is still being indexed. Please try again in a moment."
509 return None
512_CUSTOM_FIELD_HOVER = {
513 field: _custom_hover_relationship_field
514 for field in chain(
515 all_package_relationship_fields().values(),
516 all_source_relationship_fields().values(),
517 )
518 if field != "Provides"
519}
521_CUSTOM_FIELD_HOVER["Description"] = _custom_hover_description
524def _custom_hover(
525 ls: "DebputyLanguageServer",
526 server_position: Position,
527 _current_field: Optional[str],
528 word_at_position: str,
529 known_field: Optional[DctrlKnownField],
530 in_value: bool,
531 _doc: "TextDocument",
532 lines: List[str],
533) -> Optional[Union[Hover, str]]:
534 if not in_value:
535 return None
537 line_no = server_position.line
538 line = lines[line_no]
539 substvar_search_ref = server_position.character
540 substvar = ""
541 try:
542 if line and line[substvar_search_ref] in ("$", "{"):
543 substvar_search_ref += 2
544 substvar_start = line.rindex("${", 0, substvar_search_ref)
545 substvar_end = line.index("}", substvar_start)
546 if server_position.character <= substvar_end:
547 substvar = line[substvar_start : substvar_end + 1]
548 except (ValueError, IndexError):
549 pass
550 550 ↛ 552line 550 didn't jump to line 552 because the condition on line 550 was never true
551 if substvar == "${}" or SUBSTVAR_RE.fullmatch(substvar):
552 substvar_md = substvars_metadata().get(substvar)
554 computed_doc = ""
555 for_field = relationship_substvar_for_field(substvar)
556 if for_field:
557 # Leading empty line is intentional!
558 computed_doc = textwrap.dedent(
559 f"""
560 This substvar is a relationship substvar for the field {for_field}.
561 Relationship substvars are automatically added in the field they 561 ↛ 562line 561 didn't jump to line 562 because the condition on line 561 was never true
562 are named after in `debhelper-compat (= 14)` or later, or with
563 `debputy` (any integration mode after 0.1.21).
564 """
565 )
567 if substvar_md is None:
568 doc = f"No documentation for {substvar}.\n"
569 md_fields = ""
570 else:
571 doc = ls.translation(LSP_DATA_DOMAIN).pgettext(
572 f"Substvars:{substvar_md.name}", 572 ↛ 573line 572 didn't jump to line 573 because the condition on line 572 was never true
573 substvar_md.description,
574 )
575 md_fields = "\n" + substvar_md.render_metadata_fields() 575 ↛ 576line 575 didn't jump to line 576 because the condition on line 575 was never true
576 return f"# Substvar `{substvar}`\n\n{doc}{computed_doc}{md_fields}"
578 if known_field is None:
579 return None
580 dispatch = _CUSTOM_FIELD_HOVER.get(known_field.name)
581 if dispatch is None:
582 return None
583 return dispatch(ls, known_field, line, word_at_position)
586@lsp_completer(_DISPATCH_RULE)
587def _debian_control_completions(
588 ls: "DebputyLanguageServer",
589 params: CompletionParams,
590) -> Optional[Union[CompletionList, Sequence[CompletionItem]]]:
591 return deb822_completer(ls, params, _DCTRL_FILE_METADATA)
594@lsp_folding_ranges(_DISPATCH_RULE)
595def _debian_control_folding_ranges(
596 ls: "DebputyLanguageServer",
597 params: FoldingRangeParams,
598) -> Optional[Sequence[FoldingRange]]:
599 return deb822_folding_ranges(ls, params, _DCTRL_FILE_METADATA)
602@lsp_text_doc_inlay_hints(_DISPATCH_RULE)
603def _doc_inlay_hint(
604 ls: "DebputyLanguageServer",
605 params: InlayHintParams,
606) -> Optional[List[InlayHint]]:
607 doc = ls.workspace.get_text_document(params.text_document.uri)
608 lint_state = ls.lint_state(doc)
609 deb822_file = lint_state.parsed_deb822_file_content
610 if not deb822_file:
611 return None
612 inlay_hints = []
613 stanzas = list(deb822_file)
614 if len(stanzas) < 2:
615 return None
616 source_stanza = stanzas[0]
617 source_stanza_pos = source_stanza.position_in_file()
618 for stanza_no, stanza in enumerate(deb822_file):
619 stanza_range = stanza.range_in_parent()
620 if stanza_no < 1:
621 continue
622 pkg_kvpair = stanza.get_kvpair_element(("Package", 0), use_get=True)
623 if pkg_kvpair is None:
624 continue
626 inlay_hint_pos_te = pkg_kvpair.range_in_parent().end_pos.relative_to(
627 stanza_range.start_pos
628 )
629 inlay_hint_pos = doc.position_codec.position_to_client_units(
630 lint_state.lines,
631 te_position_to_lsp(inlay_hint_pos_te),
632 )
633 stanza_def = _DCTRL_FILE_METADATA.classify_stanza(stanza, stanza_no)
634 for known_field in stanza_def.stanza_fields.values():
635 if (
636 not known_field.inheritable_from_other_stanza
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 inlay_hints.append(
650 InlayHint(
651 inlay_hint_pos,
652 [
653 InlayHintLabelPart(
654 f"{known_field.name}: {inherited_value}\n",
655 tooltip="Inherited from Source stanza",
656 location=Location(
657 params.text_document.uri,
658 value_range,
659 ),
660 ),
661 ],
662 )
663 )
665 return inlay_hints
668def _source_package_checks(
669 stanza: Deb822ParagraphElement,
670 stanza_position: "TEPosition",
671 stanza_metadata: StanzaMetadata[DctrlKnownField],
672 lint_state: LintState,
673) -> None:
674 vcs_fields = {}
675 source_fields = _DCTRL_FILE_METADATA["Source"].stanza_fields
676 for kvpair in stanza.iter_parts_of_type(Deb822KeyValuePairElement):
677 name = stanza_metadata.normalize_field_name(kvpair.field_name.lower())
678 if (
679 not name.startswith("vcs-")
680 or name == "vcs-browser"
681 or name not in source_fields
682 ):
683 continue
684 vcs_fields[name] = kvpair
686 if len(vcs_fields) < 2:
687 return
688 for kvpair in vcs_fields.values():
689 lint_state.emit_diagnostic(
690 kvpair.range_in_parent().relative_to(stanza_position),
691 f'Multiple Version Control fields defined ("{kvpair.field_name}")',
692 "warning",
693 "Policy 5.6.26",
694 quickfixes=[
695 propose_remove_range_quick_fix(
696 proposed_title=f'Remove "{kvpair.field_name}"'
697 )
698 ],
699 )
702def _binary_package_checks(
703 stanza: Deb822ParagraphElement,
704 stanza_position: "TEPosition",
705 source_stanza: Deb822ParagraphElement,
706 representation_field_range: "TERange",
707 lint_state: LintState,
708) -> None:
709 package_name = stanza.get("Package", "")
710 source_section = source_stanza.get("Section")
711 section_kvpair = stanza.get_kvpair_element(("Section", 0), use_get=True)
712 section: Optional[str] = None
713 section_range: Optional["TERange"] = None
714 if section_kvpair is not None:
715 section, section_range = extract_first_value_and_position(
716 section_kvpair,
717 stanza_position,
718 )
720 if section_range is None:
721 section_range = representation_field_range
722 effective_section = section or source_section or "unknown"
723 package_type = stanza.get("Package-Type", "")
724 component_prefix = "" 724 ↛ 725line 724 didn't jump to line 725 because the condition on line 724 was never true
725 if "/" in effective_section:
726 component_prefix, effective_section = effective_section.split("/", maxsplit=1)
727 component_prefix += "/"
729 if package_name.endswith("-udeb") or package_type == "udeb":
730 if package_type != "udeb":
731 package_type_kvpair = stanza.get_kvpair_element(
732 "Package-Type", use_get=True
733 )
734 package_type_range: Optional["TERange"] = None
735 if package_type_kvpair is not None:
736 _, package_type_range = extract_first_value_and_position(
737 package_type_kvpair,
738 stanza_position,
739 )
740 if package_type_range is None:
741 package_type_range = representation_field_range
742 lint_state.emit_diagnostic(
743 package_type_range,
744 'The Package-Type should be "udeb" given the package name',
745 "warning",
746 "debputy",
747 ) 747 ↛ 748line 747 didn't jump to line 748 because the condition on line 747 was never true
748 guessed_section = "debian-installer"
749 section_diagnostic_rationale = " since it is an udeb"
750 else:
751 guessed_section = package_name_to_section(package_name)
752 section_diagnostic_rationale = " based on the package name"
753 if guessed_section is not None and guessed_section != effective_section:
754 if section is not None:
755 quickfix_data = [
756 propose_correct_text_quick_fix(f"{component_prefix}{guessed_section}")
757 ]
758 else:
759 quickfix_data = [
760 propose_insert_text_on_line_after_diagnostic_quick_fix(
761 f"Section: {component_prefix}{guessed_section}\n"
762 )
763 ]
764 assert section_range is not None # mypy hint
765 lint_state.emit_diagnostic(
766 section_range,
767 f'The Section should be "{component_prefix}{guessed_section}"{section_diagnostic_rationale}',
768 "warning",
769 "debputy",
770 quickfixes=quickfix_data,
771 )
774def _scan_for_syntax_errors_and_token_level_diagnostics(
775 deb822_file: Deb822FileElement,
776 lint_state: LintState,
777) -> int:
778 first_error = len(lint_state.lines) + 1
779 spell_checker = lint_state.spellchecker()
780 for (
781 token, 781 ↛ 782line 781 didn't jump to line 782 because the condition on line 781 was never true
782 start_line,
783 start_offset,
784 end_line,
785 end_offset,
786 ) in deb822_token_iter(deb822_file.iter_tokens()):
787 if token.is_error:
788 first_error = min(first_error, start_line)
789 start_pos = TEPosition(
790 start_line,
791 start_offset,
792 )
793 end_pos = TEPosition(
794 end_line,
795 end_offset,
796 )
797 token_range = TERange.between(start_pos, end_pos)
798 lint_state.emit_diagnostic(
799 token_range,
800 "Syntax error",
801 "error", 801 ↛ 803line 801 didn't jump to line 803 because the condition on line 801 was always true
802 "debputy",
803 )
804 elif token.is_comment:
805 for word, col_pos, end_col_pos in spell_checker.iter_words(token.text):
806 corrections = spell_checker.provide_corrections_for(word)
807 if not corrections:
808 continue
809 start_pos = TEPosition(
810 start_line,
811 col_pos,
812 )
813 end_pos = TEPosition(
814 start_line,
815 end_col_pos,
816 )
817 word_range = TERange.between(start_pos, end_pos)
818 lint_state.emit_diagnostic(
819 word_range,
820 f'Spelling "{word}"',
821 "spelling",
822 "debputy",
823 quickfixes=[propose_correct_text_quick_fix(c) for c in corrections],
824 enable_non_interactive_auto_fix=False,
825 )
826 return first_error
829@lint_diagnostics(_DISPATCH_RULE)
830def _lint_debian_control(lint_state: LintState) -> None:
831 doc_reference = lint_state.doc_uri
832 deb822_file = lint_state.parsed_deb822_file_content
834 first_error = _scan_for_syntax_errors_and_token_level_diagnostics(
835 deb822_file,
836 lint_state,
837 )
839 stanzas = list(deb822_file)
840 source_stanza = stanzas[0] if stanzas else None
841 binary_stanzas_w_pos = [] 841 ↛ 842line 841 didn't jump to line 842 because the condition on line 841 was never true
843 source_stanza_metadata, binary_stanza_metadata = _DCTRL_FILE_METADATA.stanza_types()
845 for stanza_no, stanza in enumerate(stanzas, start=1):
846 stanza_position = stanza.position_in_file()
847 if stanza_position.line_position >= first_error:
848 break
849 is_binary_stanza = stanza_no != 1
850 if is_binary_stanza:
851 stanza_metadata = binary_stanza_metadata
852 other_stanza_metadata = source_stanza_metadata
853 other_stanza_name = "Source"
854 binary_stanzas_w_pos.append((stanza, stanza_position))
855 _, representation_field_range = stanza_metadata.stanza_representation(
856 stanza, stanza_position
857 )
858 _binary_package_checks(
859 stanza,
860 stanza_position,
861 source_stanza,
862 representation_field_range,
863 lint_state,
864 )
865 else:
866 stanza_metadata = source_stanza_metadata
867 other_stanza_metadata = binary_stanza_metadata
868 other_stanza_name = "Binary"
869 _source_package_checks(
870 stanza,
871 stanza_position,
872 stanza_metadata,
873 lint_state,
874 )
876 stanza_metadata.stanza_diagnostics(
877 deb822_file,
878 stanza,
879 stanza_position,
880 doc_reference,
881 lint_state,
882 confusable_with_stanza_metadata=other_stanza_metadata,
883 confusable_with_stanza_name=other_stanza_name,
884 inherit_from_stanza=source_stanza if is_binary_stanza else None,
885 )
887 _detect_misspelled_packaging_files(
888 lint_state,
889 binary_stanzas_w_pos,
890 )
892 892 ↛ 893line 892 didn't jump to line 893 because the condition on line 892 was never true
893def _package_range_of_stanza(
894 binary_stanzas: List[Tuple[Deb822ParagraphElement, TEPosition]],
895) -> Iterable[Tuple[str, Optional[str], "TERange"]]:
896 for stanza, stanza_position in binary_stanzas:
897 kvpair = stanza.get_kvpair_element(("Package", 0), use_get=True)
898 if kvpair is None:
899 continue
900 representation_field_range = kvpair.range_in_parent().relative_to(
901 stanza_position
902 )
903 yield stanza["Package"], stanza.get("Architecture"), representation_field_range
906def _packaging_files(
907 lint_state: LintState,
908) -> Iterable[PackagerProvidedFile]:
909 source_root = lint_state.source_root
910 debian_dir = lint_state.debian_dir
911 binary_packages = lint_state.binary_packages
912 if (
913 source_root is None
914 or not source_root.has_fs_path
915 or debian_dir is None
916 or binary_packages is None
917 ):
918 return
920 dh_sequencer_data = lint_state.dh_sequencer_data
921 dh_sequences = dh_sequencer_data.sequences
922 is_debputy_package = (
923 "debputy" in dh_sequences
924 or "zz-debputy" in dh_sequences
925 or "zz_debputy" in dh_sequences
926 or "zz-debputy-rrr" in dh_sequences
927 )
928 feature_set = lint_state.plugin_feature_set
929 known_packaging_files = feature_set.known_packaging_files
930 static_packaging_files = {
931 kpf.detection_value: kpf
932 for kpf in known_packaging_files.values()
933 if kpf.detection_method == "path"
934 }
935 ignored_path = set(static_packaging_files)
937 if is_debputy_package:
938 all_debputy_ppfs = list(
939 flatten_ppfs(
940 detect_all_packager_provided_files(
941 feature_set.packager_provided_files,
942 debian_dir,
943 binary_packages,
944 allow_fuzzy_matches=True,
945 detect_typos=True, 945 ↛ 946line 945 didn't jump to line 946 because the condition on line 945 was never true
946 ignore_paths=ignored_path,
947 )
948 )
949 )
950 for ppf in all_debputy_ppfs:
951 if ppf.path.path in ignored_path:
952 continue 952 ↛ exitline 952 didn't return from function '_packaging_files' because the condition on line 952 was always true
953 ignored_path.add(ppf.path.path)
954 yield ppf
956 # FIXME: This should read the editor data, but dh_assistant does not support that.
957 dh_compat_level, _ = extract_dh_compat_level(cwd=source_root.fs_path)
958 if dh_compat_level is not None:
959 debputy_plugin_metadata = plugin_metadata_for_debputys_own_plugin()
960 dh_pkgfile_docs = {
961 kpf.detection_value: kpf
962 for kpf in known_packaging_files.values()
963 if kpf.detection_method == "dh.pkgfile"
964 }
965 (
966 all_dh_ppfs,
967 _,
968 _,
969 ) = resolve_debhelper_config_files(
970 debian_dir,
971 binary_packages,
972 debputy_plugin_metadata,
973 dh_pkgfile_docs,
974 dh_sequences, 974 ↛ 975line 974 didn't jump to line 975 because the condition on line 974 was never true
975 dh_compat_level,
976 saw_dh=dh_sequencer_data.uses_dh_sequencer,
977 ignore_paths=ignored_path,
978 )
979 for ppf in all_dh_ppfs:
980 if ppf.path.path in ignored_path:
981 continue
982 ignored_path.add(ppf.path.path)
983 yield ppf
986def _detect_misspelled_packaging_files(
987 lint_state: LintState,
988 binary_stanzas_w_pos: List[Tuple[Deb822ParagraphElement, TEPosition]],
989) -> None:
990 stanza_ranges = {
991 p: (a, r) for p, a, r in _package_range_of_stanza(binary_stanzas_w_pos)
992 } 992 ↛ 993line 992 didn't jump to line 993 because the condition on line 992 was never true
993 for ppf in _packaging_files(lint_state):
994 binary_package = ppf.package_name
995 explicit_package = ppf.uses_explicit_package_name 995 ↛ 996line 995 didn't jump to line 996 because the condition on line 995 was never true
996 name_segment = ppf.name_segment is not None
997 stem = ppf.definition.stem
998 if binary_package is None or stem is None: 998 ↛ 999line 998 didn't jump to line 999 because the condition on line 998 was never true
999 continue
1000 res = stanza_ranges.get(binary_package)
1001 if res is None:
1002 continue
1003 declared_arch, diag_range = res
1004 if diag_range is None:
1005 continue
1006 path = ppf.path.path
1007 likely_typo_of = ppf.expected_path
1008 arch_restriction = ppf.architecture_restriction
1009 if likely_typo_of is not None:
1010 # Handles arch_restriction == 'all' at the same time due to how
1011 # the `likely-typo-of` is created
1012 lint_state.emit_diagnostic(
1013 diag_range,
1014 f'The file "{path}" is likely a typo of "{likely_typo_of}"', 1014 ↛ 1015line 1014 didn't jump to line 1015 because the condition on line 1014 was never true
1015 "warning",
1016 "debputy",
1017 diagnostic_applies_to_another_file=path,
1018 )
1019 continue
1020 if declared_arch == "all" and arch_restriction is not None:
1021 lint_state.emit_diagnostic(
1022 diag_range,
1023 f'The file "{path}" has an architecture restriction but is for an `arch:all` package, so' 1023 ↛ 1024line 1023 didn't jump to line 1024 because the condition on line 1023 was never true
1024 f" the restriction does not make sense.",
1025 "warning",
1026 "debputy",
1027 diagnostic_applies_to_another_file=path,
1028 )
1029 elif arch_restriction == "all":
1030 lint_state.emit_diagnostic(
1031 diag_range,
1032 f'The file "{path}" has an architecture restriction of `all` rather than a real architecture', 1032 ↛ 1033line 1032 didn't jump to line 1033 because the condition on line 1032 was never true
1033 "warning",
1034 "debputy",
1035 diagnostic_applies_to_another_file=path,
1036 )
1038 if not ppf.definition.has_active_command:
1039 lint_state.emit_diagnostic(
1040 diag_range,
1041 f"The file {path} is related to a command that is not active in the dh sequence"
1042 " with the current addons",
1043 "warning",
1044 "debputy",
1045 diagnostic_applies_to_another_file=path,
1046 )
1047 continue
1048 1048 ↛ 1049line 1048 didn't jump to line 1049 because the condition on line 1048 was never true
1049 if not explicit_package and name_segment is not None:
1050 basename = os.path.basename(path)
1051 if basename == ppf.definition.stem:
1052 continue
1053 alt_name = f"{binary_package}.{stem}"
1054 if arch_restriction is not None:
1055 alt_name = f"{alt_name}.{arch_restriction}"
1056 if ppf.definition.allow_name_segment:
1057 or_alt_name = f' (or maybe "debian/{binary_package}.{basename}")'
1058 else:
1059 or_alt_name = ""
1061 lint_state.emit_diagnostic(
1062 diag_range,
1063 f'Possible typo in "{path}". Consider renaming the file to "debian/{alt_name}"'
1064 f"{or_alt_name} if it is intended for {binary_package}",
1065 "warning",
1066 "debputy",
1067 diagnostic_applies_to_another_file=path,
1068 )
1071@lsp_will_save_wait_until(_DISPATCH_RULE)
1072def _debian_control_on_save_formatting(
1073 ls: "DebputyLanguageServer",
1074 params: WillSaveTextDocumentParams,
1075) -> Optional[Sequence[TextEdit]]:
1076 doc = ls.workspace.get_text_document(params.text_document.uri)
1077 lint_state = ls.lint_state(doc)
1078 return _reformat_debian_control(lint_state)
1081def _reformat_debian_control(
1082 lint_state: LintState,
1083) -> Optional[Sequence[TextEdit]]:
1084 return deb822_format_file(lint_state, _DCTRL_FILE_METADATA)
1087@lsp_format_document(_DISPATCH_RULE)
1088def _debian_control_format_file(
1089 ls: "DebputyLanguageServer",
1090 params: DocumentFormattingParams,
1091) -> Optional[Sequence[TextEdit]]:
1092 doc = ls.workspace.get_text_document(params.text_document.uri)
1093 lint_state = ls.lint_state(doc)
1094 return _reformat_debian_control(lint_state)
1097@lsp_semantic_tokens_full(_DISPATCH_RULE)
1098def _debian_control_semantic_tokens_full(
1099 ls: "DebputyLanguageServer",
1100 request: SemanticTokensParams,
1101) -> Optional[SemanticTokens]:
1102 return deb822_semantic_tokens_full(
1103 ls,
1104 request,
1105 _DCTRL_FILE_METADATA,
1106 )