diff options
Diffstat (limited to 'src')
37 files changed, 1150 insertions, 476 deletions
diff --git a/src/debputy/commands/debputy_cmd/__main__.py b/src/debputy/commands/debputy_cmd/__main__.py index 27edf49..1a7a737 100644 --- a/src/debputy/commands/debputy_cmd/__main__.py +++ b/src/debputy/commands/debputy_cmd/__main__.py @@ -71,6 +71,7 @@ except ImportError: from debputy.version import __version__ from debputy.filesystem_scan import ( FSROOverlay, + FSRootDir, ) from debputy.plugin.api.impl_types import ( PackagerProvidedFileClassSpec, @@ -754,7 +755,8 @@ def _dh_integration_generate_debs(context: CommandContext) -> None: continue # Ensure all fs's are read-only before we enable cross package checks. # This ensures that no metadata detector will never see a read-write FS - cast("FSRootDir", binary_data.fs_root).is_read_write = False + pkg_fs_root: "FSRootDir" = cast("FSRootDir", binary_data.fs_root) + pkg_fs_root.is_read_write = False package_data_table.enable_cross_package_checks = True assemble_debs( @@ -799,7 +801,7 @@ _POST_FORMATTING_REWRITE = { def _fake_PPFClassSpec( debputy_plugin_metadata: DebputyPluginMetadata, stem: str, - doc_uris: Sequence[str], + doc_uris: Optional[Sequence[str]], install_pattern: Optional[str], *, default_priority: Optional[int] = None, @@ -978,7 +980,7 @@ def _resolve_debhelper_config_files( post_formatting_rewrite=post_formatting_rewrite, packageless_is_fallback_for_all_packages=packageless_is_fallback_for_all_packages, ) - dh_ppfs = list( + all_dh_ppfs = list( flatten_ppfs( detect_all_packager_provided_files( dh_ppfs, @@ -988,13 +990,13 @@ def _resolve_debhelper_config_files( ) ) ) - return dh_ppfs, issues, exit_code + return all_dh_ppfs, issues, exit_code def _merge_list( existing_table: Dict[str, Any], key: str, - new_data: Optional[List[str]], + new_data: Optional[Sequence[str]], ) -> None: if not new_data: return @@ -1368,13 +1370,11 @@ def _annotate_debian_directory(context: CommandContext) -> None: def _json_output(data: Any) -> None: - format_options = {} if sys.stdout.isatty(): - format_options = { - "indent": 4, - # sort_keys might be tempting but generally insert order makes more sense in practice. - } - json.dump(data, sys.stdout, **format_options) + # sort_keys might be tempting but generally insert order makes more sense in practice. + json.dump(data, sys.stdout, indent=4) + else: + json.dump(data, sys.stdout) if sys.stdout.isatty(): # Looks better with a final newline. print() diff --git a/src/debputy/commands/debputy_cmd/lint_and_lsp_cmds.py b/src/debputy/commands/debputy_cmd/lint_and_lsp_cmds.py index 3eecb14..2f283e8 100644 --- a/src/debputy/commands/debputy_cmd/lint_and_lsp_cmds.py +++ b/src/debputy/commands/debputy_cmd/lint_and_lsp_cmds.py @@ -21,19 +21,19 @@ _EDITOR_SNIPPETS = { ;; Inform eglot about the debputy LSP (with-eval-after-load 'eglot (add-to-list 'eglot-server-programs - '(debian-control-mode . ("debputy" "lsp" "server"))) + '(debian-control-mode . ("debputy" "lsp" "server" "--ignore-language-ids"))) (add-to-list 'eglot-server-programs - '(debian-changelog-mode . ("debputy" "lsp" "server"))) + '(debian-changelog-mode . ("debputy" "lsp" "server" "--ignore-language-ids"))) (add-to-list 'eglot-server-programs - '(debian-copyright-mode . ("debputy" "lsp" "server"))) + '(debian-copyright-mode . ("debputy" "lsp" "server" "--ignore-language-ids"))) ;; Requires elpa-dpkg-dev-el (>> 37.11) ;; (add-to-list 'eglot-server-programs - ;; '(debian-autopkgtest-control-mode . ("debputy" "lsp" "server"))) + ;; '(debian-autopkgtest-control-mode . ("debputy" "lsp" "server" "--ignore-language-ids"))) ;; The debian/rules file uses the qmake mode. (add-to-list 'eglot-server-programs - '(makefile-gmake-mode . ("debputy" "lsp" "server"))) + '(makefile-gmake-mode . ("debputy" "lsp" "server" "--ignore-language-ids"))) (add-to-list 'eglot-server-programs - '(yaml-mode . ("debputy" "lsp" "server"))) + '(yaml-mode . ("debputy" "lsp" "server" "--ignore-language-ids"))) ) ;; Auto-start eglot for the relevant modes. @@ -64,7 +64,7 @@ _EDITOR_SNIPPETS = { let g:ycm_language_server = [ \\ { 'name': 'debputy', \\ 'filetypes': [ 'debcontrol', 'debcopyright', 'debchangelog', 'make', 'yaml'], - \\ 'cmdline': [ 'debputy', 'lsp', 'server' ] + \\ 'cmdline': [ 'debputy', 'lsp', 'server', '--ignore-language-ids' ] \\ }, \\ ] @@ -92,7 +92,7 @@ _EDITOR_SNIPPETS = { lspServers->add({ filetype: ['debcontrol', 'debcopyright', 'debchangelog', 'make', 'yaml'], path: 'debputy', - args: ['lsp', 'server'] + args: ['lsp', 'server', '--ignore-language-ids'] }) endif @@ -100,6 +100,19 @@ _EDITOR_SNIPPETS = { autocmd User LspSetup g:LspAddServer(lspServers) """ ), + "neovim": "neovim+nvim-lspconfig", + "neovim+nvim-lspconfig": textwrap.dedent( + """\ + # debputy lsp server glue for neovim with nvim-lspconfig. Add to ~/.config/nvim/init.lua + # + # Requires https://github.com/neovim/nvim-lspconfig to be in your packages path + + require("lspconfig").debputy.setup {capabilities = capabilities} + + # Make vim recognize debputy.manifest as YAML file + vim.filetype.add({filename = {["debputy.manifest"] = "yaml"}) + """ + ), } @@ -136,6 +149,13 @@ lsp_command = ROOT_COMMAND.add_dispatching_subcommand( default=2087, help="Bind to this port (Use with --tcp / --ws)", ), + add_arg( + "--ignore-language-ids", + dest="trust_language_ids", + default=True, + action="store_false", + help="Disregard language IDs from the editor (rely solely on filename instead)", + ), ], ) def lsp_server_cmd(context: CommandContext) -> None: @@ -156,6 +176,10 @@ def lsp_server_cmd(context: CommandContext) -> None: debputy_language_server = DEBPUTY_LANGUAGE_SERVER debputy_language_server.plugin_feature_set = feature_set debputy_language_server.dctrl_parser = context.dctrl_parser + debputy_language_server.trust_language_ids = parsed_args.trust_language_ids + + if parsed_args.tcp and parsed_args.ws: + _error("Sorry, --tcp and --ws are mutually exclusive") if parsed_args.tcp: debputy_language_server.start_tcp(parsed_args.host, parsed_args.port) diff --git a/src/debputy/commands/debputy_cmd/output.py b/src/debputy/commands/debputy_cmd/output.py index df8e6eb..2e117ba 100644 --- a/src/debputy/commands/debputy_cmd/output.py +++ b/src/debputy/commands/debputy_cmd/output.py @@ -133,10 +133,10 @@ class OutputStylingBase: row_format = f"| {row_format_inner} |" if self.supports_colors: - c = self._color_support - assert c is not None - header_color = c.Style.bold - header_color_reset = c.Style.reset + cs = self._color_support + assert cs is not None + header_color = cs.Style.bold + header_color_reset = cs.Style.reset else: header_color = "" header_color_reset = "" @@ -218,9 +218,9 @@ class ANSIOutputStylingBase(OutputStylingBase): self._check_color(fg) self._check_color(bg) self._check_text_style(style) - if not self.supports_colors: - return text _colored = self._color_support + if not self.supports_colors or _colored is None: + return text codes = [] if style is not None: code = getattr(_colored.Style, style) diff --git a/src/debputy/commands/debputy_cmd/plugin_cmds.py b/src/debputy/commands/debputy_cmd/plugin_cmds.py index a8103fb..60d3c70 100644 --- a/src/debputy/commands/debputy_cmd/plugin_cmds.py +++ b/src/debputy/commands/debputy_cmd/plugin_cmds.py @@ -1189,7 +1189,7 @@ def _render_value(v: Any) -> str: return str(v) -def ensure_plugin_commands_are_loaded(): +def ensure_plugin_commands_are_loaded() -> None: # Loading the module does the heavy lifting # However, having this function means that we do not have an "unused" import that some tool # gets tempted to remove diff --git a/src/debputy/deb_packaging_support.py b/src/debputy/deb_packaging_support.py index b38cbc2..6de61b4 100644 --- a/src/debputy/deb_packaging_support.py +++ b/src/debputy/deb_packaging_support.py @@ -930,7 +930,7 @@ def _relevant_service_definitions( if key in by_service_manager_key and service_rule.applies_to_service_manager(key[-1]) } - relevant_names = {} + relevant_names: Dict[Tuple[str, str, str, str], ServiceDefinition[Any]] = {} seen_keys = set() if not pending_queue: @@ -954,7 +954,7 @@ def _relevant_service_definitions( ): pending_queue.add(target_key) - return relevant_names + return relevant_names.items() def handle_service_management( @@ -982,7 +982,9 @@ def handle_service_management( ) for service_manager_details in feature_set.service_managers.values(): - service_registry = ServiceRegistryImpl(service_manager_details) + service_registry: ServiceRegistryImpl = ServiceRegistryImpl( + service_manager_details + ) service_manager_details.service_detector( fs_root, service_registry, @@ -1652,6 +1654,7 @@ def _generate_control_files( dctrl_file = "debian/control" if has_dbgsym: + assert dbgsym_root_fs is not None # mypy hint _generate_dbgsym_control_file_if_relevant( binary_package, dbgsym_root_fs, diff --git a/src/debputy/debhelper_emulation.py b/src/debputy/debhelper_emulation.py index 38d9a15..65a26f8 100644 --- a/src/debputy/debhelper_emulation.py +++ b/src/debputy/debhelper_emulation.py @@ -17,6 +17,8 @@ from typing import ( List, ) +from debian.deb822 import Deb822 + from debputy.packages import BinaryPackage from debputy.plugin.api import VirtualPath from debputy.substitution import Substitution @@ -251,7 +253,7 @@ def parse_drules_for_addons(lines: Iterable[str], sequences: Set[str]) -> None: def extract_dh_addons_from_control( - source_paragraph: Mapping[str, str], + source_paragraph: Union[Mapping[str, str], Deb822], sequences: Set[str], ) -> None: for f in ("Build-Depends", "Build-Depends-Indep", "Build-Depends-Arch"): diff --git a/src/debputy/dh_migration/migrators_impl.py b/src/debputy/dh_migration/migrators_impl.py index d7aa252..2ceefd5 100644 --- a/src/debputy/dh_migration/migrators_impl.py +++ b/src/debputy/dh_migration/migrators_impl.py @@ -432,7 +432,7 @@ def migrate_bash_completion( install_as_rules.append((source, dest_basename)) if install_dest_sources: - sources = ( + sources: Union[List[str], str] = ( install_dest_sources if len(install_dest_sources) > 1 else install_dest_sources[0] @@ -1502,7 +1502,7 @@ def read_dh_addon_sequences( ctrl_file = debian_dir.get("control") if ctrl_file: dr_sequences: Set[str] = set() - bd_sequences = set() + bd_sequences: Set[str] = set() drules = debian_dir.get("rules") if drules and drules.is_file: diff --git a/src/debputy/filesystem_scan.py b/src/debputy/filesystem_scan.py index dec123c..0a18899 100644 --- a/src/debputy/filesystem_scan.py +++ b/src/debputy/filesystem_scan.py @@ -1603,9 +1603,9 @@ class FSROOverlay(VirtualPathBase): continue if dir_part == "..": p = current.parent_dir - if current is None: + if p is None: raise ValueError(f'The path "{path}" escapes the root dir') - current = p + current = cast("FSROOverlay", p) continue try: current = current[dir_part] diff --git a/src/debputy/highlevel_manifest.py b/src/debputy/highlevel_manifest.py index 30440f1..1fea1a2 100644 --- a/src/debputy/highlevel_manifest.py +++ b/src/debputy/highlevel_manifest.py @@ -1199,7 +1199,7 @@ class HighLevelManifest: dtmp_dir = None search_dirs = install_request_context.search_dirs into = frozenset(self._binary_packages.values()) - seen = set() + seen: Set[BinaryPackage] = set() for search_dir in search_dirs: seen.update(search_dir.applies_to) diff --git a/src/debputy/highlevel_manifest_parser.py b/src/debputy/highlevel_manifest_parser.py index 28a3f80..c5fb410 100644 --- a/src/debputy/highlevel_manifest_parser.py +++ b/src/debputy/highlevel_manifest_parser.py @@ -444,13 +444,10 @@ class YAMLManifestParser(HighLevelManifestParser): parser_generator = self._plugin_provided_feature_set.manifest_parser_generator dispatchable_object_parsers = parser_generator.dispatchable_object_parsers manifest_root_parser = dispatchable_object_parsers[OPARSER_MANIFEST_ROOT] - parsed_data = cast( - "ManifestRootRule", - manifest_root_parser.parse_input( - yaml_data, - attribute_path, - parser_context=self, - ), + parsed_data = manifest_root_parser.parse_input( + yaml_data, + attribute_path, + parser_context=self, ) packages_dict: Mapping[str, PackageContextData[Mapping[str, Any]]] = cast( diff --git a/src/debputy/installations.py b/src/debputy/installations.py index e1e8f3a..b781757 100644 --- a/src/debputy/installations.py +++ b/src/debputy/installations.py @@ -546,6 +546,7 @@ def _resolve_matches( dest_paths: Union[Sequence[Tuple[str, bool]], Callable[[PathMatch], str]], install_context: "InstallRuleContext", ) -> Iterator[Tuple[PathMatch, Sequence[Tuple[str, "FSPath"]]]]: + dest_and_roots: Sequence[Tuple[str, "FSPath"]] if callable(dest_paths): compute_dest_path = dest_paths for match in matches: diff --git a/src/debputy/interpreter.py b/src/debputy/interpreter.py index 0d986e1..5a933fc 100644 --- a/src/debputy/interpreter.py +++ b/src/debputy/interpreter.py @@ -147,6 +147,10 @@ class DetectedInterpreter(Interpreter): def replace_shebang_line(self, path: "VirtualPath") -> None: new_shebang_line = self.corrected_shebang_line + if new_shebang_line is None: + raise RuntimeError( + "Please do not call replace_shebang_line when fixup_needed returns False" + ) assert new_shebang_line.startswith("#!") if not new_shebang_line.endswith("\n"): new_shebang_line += "\n" diff --git a/src/debputy/linting/lint_impl.py b/src/debputy/linting/lint_impl.py index a6f493e..ec13d53 100644 --- a/src/debputy/linting/lint_impl.py +++ b/src/debputy/linting/lint_impl.py @@ -13,6 +13,7 @@ from lsprotocol.types import ( TextEdit, Position, DiagnosticSeverity, + Diagnostic, ) from debputy.commands.debputy_cmd.context import CommandContext @@ -185,9 +186,9 @@ def _auto_fix_run( lint_report: LintReport, ) -> None: another_round = True - unfixed_diagnostics = [] + unfixed_diagnostics: List[Diagnostic] = [] remaining_rounds = 10 - fixed_count = False + fixed_count = 0 too_many_rounds = False lines = text.splitlines(keepends=True) lint_state = lint_context.state_for( diff --git a/src/debputy/lsp/debputy_ls.py b/src/debputy/lsp/debputy_ls.py index f375992..cc3f00e 100644 --- a/src/debputy/lsp/debputy_ls.py +++ b/src/debputy/lsp/debputy_ls.py @@ -1,6 +1,17 @@ import dataclasses import os -from typing import Optional, List, Any, Mapping +from typing import ( + Optional, + List, + Any, + Mapping, + Container, + TYPE_CHECKING, + Tuple, + Literal, +) + +from lsprotocol.types import MarkupKind from debputy.linting.lint_util import LintState from debputy.lsp.text_util import LintCapablePositionCodec @@ -11,17 +22,23 @@ from debputy.packages import ( ) from debputy.plugin.api.feature_set import PluginProvidedFeatureSet -try: +if TYPE_CHECKING: from pygls.server import LanguageServer from pygls.workspace import TextDocument from pygls.uris import from_fs_path -except ImportError as e: - class LanguageServer: - def __init__(self, *args, **kwargs) -> None: - """Placeholder to work if pygls is not installed""" - # Should not be called - raise e # pragma: no cover +else: + try: + from pygls.server import LanguageServer + from pygls.workspace import TextDocument + from pygls.uris import from_fs_path + except ImportError as e: + + class LanguageServer: + def __init__(self, *args, **kwargs) -> None: + """Placeholder to work if pygls is not installed""" + # Should not be called + raise e # pragma: no cover @dataclasses.dataclass(slots=True) @@ -86,10 +103,13 @@ class LSProvidedLintState(LintState): dctrl_doc = self._ls.workspace.get_text_document(dctrl_cache.doc_uri) re_parse_lines: Optional[List[str]] = None if is_open: + last_doc_version = dctrl_cache.last_doc_version + dctrl_doc_version = dctrl_doc.version if ( not dctrl_cache.is_open_in_editor - or dctrl_cache.last_doc_version is None - or dctrl_cache.last_doc_version < dctrl_doc.version + or last_doc_version is None + or dctrl_doc_version is None + or last_doc_version < dctrl_doc_version ): re_parse_lines = doc.lines @@ -127,6 +147,19 @@ class LSProvidedLintState(LintState): return dctrl.binary_packages if dctrl is not None else None +def _preference( + client_preference: Optional[List[MarkupKind]], + options: Container[MarkupKind], + fallback_kind: MarkupKind, +) -> MarkupKind: + if not client_preference: + return fallback_kind + for markdown_kind in client_preference: + if markdown_kind in options: + return markdown_kind + return fallback_kind + + class DebputyLanguageServer(LanguageServer): def __init__( @@ -137,6 +170,7 @@ class DebputyLanguageServer(LanguageServer): super().__init__(*args, **kwargs) self._dctrl_parser: Optional[DctrlParser] = None self._plugin_feature_set: Optional[PluginProvidedFeatureSet] = None + self._trust_language_ids: Optional[bool] = None @property def plugin_feature_set(self) -> PluginProvidedFeatureSet: @@ -177,3 +211,82 @@ class DebputyLanguageServer(LanguageServer): dir_path = os.path.dirname(dir_path) return LSProvidedLintState(self, doc, dir_path, self.dctrl_parser) + + @property + def _client_hover_markup_formats(self) -> Optional[List[MarkupKind]]: + try: + return ( + self.client_capabilities.text_document.hover.content_format + ) # type : ignore + except AttributeError: + return None + + def hover_markup_format( + self, + *options: MarkupKind, + fallback_kind: MarkupKind = MarkupKind.PlainText, + ) -> MarkupKind: + """Pick the client preferred hover markup format from a set of options + + :param options: The markup kinds possible. + :param fallback_kind: If no overlapping option was found in the client preferences + (or client did not announce a value at all), this parameter is returned instead. + :returns: The client's preferred markup format from the provided options, or, + (if there is no overlap), the `fallback_kind` value is returned. + """ + client_preference = self._client_hover_markup_formats + return _preference(client_preference, frozenset(options), fallback_kind) + + @property + def _client_completion_item_document_markup_formats( + self, + ) -> Optional[List[MarkupKind]]: + try: + return ( + self.client_capabilities.text_document.completion.completion_item.documentation_format # type : ignore + ) + except AttributeError: + return None + + def completion_item_document_markup( + self, + *options: MarkupKind, + fallback_kind: MarkupKind = MarkupKind.PlainText, + ) -> MarkupKind: + """Pick the client preferred completion item documentation markup format from a set of options + + :param options: The markup kinds possible. + :param fallback_kind: If no overlapping option was found in the client preferences + (or client did not announce a value at all), this parameter is returned instead. + :returns: The client's preferred markup format from the provided options, or, + (if there is no overlap), the `fallback_kind` value is returned. + """ + + client_preference = self._client_completion_item_document_markup_formats + return _preference(client_preference, frozenset(options), fallback_kind) + + @property + def trust_language_ids(self) -> bool: + v = self._trust_language_ids + if v is None: + return True + return v + + @trust_language_ids.setter + def trust_language_ids(self, new_value: bool) -> None: + self._trust_language_ids = new_value + + def determine_language_id( + self, + doc: "TextDocument", + ) -> Tuple[Literal["editor-provided", "filename"], str]: + lang_id = doc.language_id + if self.trust_language_ids and lang_id and not lang_id.isspace(): + return "editor-provided", lang_id + path = doc.path + try: + last_idx = path.rindex("debian/") + except ValueError: + return "filename", os.path.basename(path) + guess_language_id = path[last_idx:] + return "filename", guess_language_id diff --git a/src/debputy/lsp/lsp_debian_changelog.py b/src/debputy/lsp/lsp_debian_changelog.py index 89604e4..ecff192 100644 --- a/src/debputy/lsp/lsp_debian_changelog.py +++ b/src/debputy/lsp/lsp_debian_changelog.py @@ -262,7 +262,7 @@ def _scan_debian_changelog_for_diagnostics( *, max_line_length: int = _MAXIMUM_WIDTH, ) -> Iterator[List[Diagnostic]]: - diagnostics = [] + diagnostics: List[Diagnostic] = [] diagnostics_at_last_update = 0 lines_since_last_update = 0 lines = lint_state.lines diff --git a/src/debputy/lsp/lsp_debian_control.py b/src/debputy/lsp/lsp_debian_control.py index 8c246d8..b44e8f9 100644 --- a/src/debputy/lsp/lsp_debian_control.py +++ b/src/debputy/lsp/lsp_debian_control.py @@ -1,5 +1,7 @@ +import dataclasses import re import textwrap +from functools import lru_cache from typing import ( Union, Sequence, @@ -9,15 +11,16 @@ from typing import ( Iterable, Mapping, List, + FrozenSet, + Dict, ) +from debputy.lsp.debputy_ls import DebputyLanguageServer from lsprotocol.types import ( DiagnosticSeverity, Range, Diagnostic, Position, - DidOpenTextDocumentParams, - DidChangeTextDocumentParams, FoldingRange, FoldingRangeParams, CompletionItem, @@ -39,6 +42,7 @@ from debputy.lsp.lsp_debian_control_reference_data import ( BINARY_FIELDS, SOURCE_FIELDS, DctrlFileMetadata, + package_name_to_section, ) from debputy.lsp.lsp_features import ( lint_diagnostics, @@ -53,11 +57,13 @@ from debputy.lsp.lsp_generic_deb822 import ( deb822_hover, deb822_folding_ranges, deb822_semantic_tokens_full, + deb822_token_iter, ) from debputy.lsp.quickfixes import ( propose_remove_line_quick_fix, range_compatible_with_remove_line_fix, propose_correct_text_quick_fix, + propose_insert_text_on_line_after_diagnostic_quick_fix, ) from debputy.lsp.spellchecking import default_spellchecker from debputy.lsp.text_util import ( @@ -100,123 +106,182 @@ _LANGUAGE_IDS = [ # vim's name "debcontrol", ] -_SUBSTVAR_RE = re.compile(r"[$][{][a-zA-Z0-9][a-zA-Z0-9-:]*[}]") -_SUBSTVARS_DOC = { - "${}": textwrap.dedent( - """\ - This is a substvar for a literal `$`. This form will never recurse - into another substvar. As an example, `${}{binary:Version}` will result - literal `${binary:Version}` (which will not be replaced). - - Defined by: `dpkg-gencontrol` - DH Sequence: <default> - Source: <https://manpages.debian.org/deb-substvars.5> - """ - ), - "${binary:Version}": textwrap.dedent( - """\ - The version of the current binary package including binNMU version. - Often used with `Depends: dep (= ${binary:Version})` relations - where: - * The `dep` package is from the same source (listed in the same - `debian/control` file) - * The current package and `dep` are both `arch:any` (or both `arch:all`) - packages. +@dataclasses.dataclass(slots=True, frozen=True) +class SubstvarMetadata: + name: str + defined_by: str + dh_sequence: Optional[str] + source: Optional[str] + description: str - Defined by: `dpkg-gencontrol` - DH Sequence: <default> - Source: <https://manpages.debian.org/deb-substvars.5> - """ - ), - "${source:Version}": textwrap.dedent( - """\ - The version of the current source package excluding binNMU version. + def render_metadata_fields(self) -> str: + def_by = f"Defined by: {self.defined_by}" + dh_seq = ( + f"DH Sequence: {self.dh_sequence}" if self.dh_sequence is not None else None + ) + source = f"Source: {self.source}" if self.source is not None else None + return "\n".join(filter(None, (def_by, dh_seq, source))) + + +def relationship_substvar_for_field(substvar: str) -> Optional[str]: + relationship_fields = _relationship_fields() + try: + col_idx = substvar.rindex(":") + except ValueError: + return None + return relationship_fields.get(substvar[col_idx + 1 : -1].lower()) - Often used with `Depends: dep (= ${source:Version})` relations - where: - * The `dep` package is from the same source (listed in the same - `debian/control` file) - * The `dep` is `arch:all`. +def _substvars_metadata(*args: SubstvarMetadata) -> Mapping[str, SubstvarMetadata]: + r = {s.name: s for s in args} + assert len(r) == len(args) + return r - Defined by: `dpkg-gencontrol` - DH Sequence: <default> - Source: <https://manpages.debian.org/deb-substvars.5> + +_SUBSTVAR_RE = re.compile(r"[$][{][a-zA-Z0-9][a-zA-Z0-9-:]*[}]") +_SUBSTVARS_DOC = _substvars_metadata( + SubstvarMetadata( + "${}", + "`dpkg-gencontrol`", + "(default)", + "<https://manpages.debian.org/deb-substvars.5>", + textwrap.dedent( + """\ + This is a substvar for a literal `$`. This form will never recurse + into another substvar. As an example, `${}{binary:Version}` will result + literal `${binary:Version}` (which will not be replaced). + """ + ), + ), + SubstvarMetadata( + "${binary:Version}", + "`dpkg-gencontrol`", + "(default)", + "<https://manpages.debian.org/deb-substvars.5>", + textwrap.dedent( + """\ + The version of the current binary package including binNMU version. + + Often used with `Depends: dep (= ${binary:Version})` relations + where: + + * The `dep` package is from the same source (listed in the same + `debian/control` file) + * The current package and `dep` are both `arch:any` (or both `arch:all`) + packages. """ + ), ), - "${misc:Depends}": textwrap.dedent( - """\ - Some debhelper commands may make the generated package need to depend on some other packages. - For example, if you use `dh_installdebconf(1)`, your package will generally need to depend on - debconf. Or if you use `dh_installxfonts(1)`, your package will generally need to depend on a - particular version of xutils. Keeping track of these miscellaneous dependencies can be - annoying since they are dependent on how debhelper does things, so debhelper offers a way to - automate it. - - All commands of this type, besides documenting what dependencies may be needed on their man - pages, will automatically generate a substvar called ${misc:Depends}. If you put that token - into your `debian/control` file, it will be expanded to the dependencies debhelper figures - you need. - - This is entirely independent of the standard `${shlibs:Depends}` generated by `dh_makeshlibs(1)`, - and the `${perl:Depends}` generated by `dh_perl(1)`. - - Defined by: `debhelper` - DH Sequence: <default> - Source: <https://manpages.debian.org/debhelper.7> + SubstvarMetadata( + "${source:Version}", + "`dpkg-gencontrol`", + "(default)", + "<https://manpages.debian.org/deb-substvars.5>", + textwrap.dedent( + """\ + The version of the current source package excluding binNMU version. + + Often used with `Depends: dep (= ${source:Version})` relations + where: + + * The `dep` package is from the same source (listed in the same + `debian/control` file) + * The `dep` is `arch:all`. """ + ), ), - "${misc:Pre-Depends}": textwrap.dedent( - """\ - This is the moral equivalent to `${misc:Depends}` but for `Pre-Depends`. - - Defined by: `debhelper` - DH Sequence: <default> + SubstvarMetadata( + "${misc:Depends}", + "`debhelper`", + "(default)", + "<https://manpages.debian.org/debhelper.7>", + textwrap.dedent( + """\ + Some debhelper commands may make the generated package need to depend on some other packages. + For example, if you use `dh_installdebconf(1)`, your package will generally need to depend on + debconf. Or if you use `dh_installxfonts(1)`, your package will generally need to depend on a + particular version of xutils. Keeping track of these miscellaneous dependencies can be + annoying since they are dependent on how debhelper does things, so debhelper offers a way to + automate it. + + All commands of this type, besides documenting what dependencies may be needed on their man + pages, will automatically generate a substvar called ${misc:Depends}. If you put that token + into your `debian/control` file, it will be expanded to the dependencies debhelper figures + you need. + + This is entirely independent of the standard `${shlibs:Depends}` generated by `dh_makeshlibs(1)`, + and the `${perl:Depends}` generated by `dh_perl(1)`. """ + ), ), - "${perl:Depends}": textwrap.dedent( - """\ - The dependency on perl as determined by `dh_perl`. Note this only covers the relationship - with the Perl interpreter and not perl modules. - - Defined by: `dh_perl` - DH Sequence: <default> - Source: <https://manpages.debian.org/dh_perl.1> + SubstvarMetadata( + "${misc:Pre-Depends}", + "`debhelper`", + "(default)", + None, + textwrap.dedent( + """\ + This is the moral equivalent to `${misc:Depends}` but for `Pre-Depends`. """ + ), ), - "${gir:Depends}": textwrap.dedent( - """\ - Dependencies related to GObject introspection data. + SubstvarMetadata( + "${perl:Depends}", + "`dh_perl`", + "(default)", + "<https://manpages.debian.org/dh_perl.1>", + textwrap.dedent( + """\ + The dependency on perl as determined by `dh_perl`. Note this only covers the relationship + with the Perl interpreter and not perl modules. - Defined by: `dh_girepository` - DH Sequence: `gir` - Source: <https://manpages.debian.org/dh_girepository.1> """ + ), ), - "${shlibs:Depends}": textwrap.dedent( - """\ - Dependencies related to ELF dependencies. - - Defined by: `dpkg-shlibdeps` (often via `dh_shlibdeps`) - DH Sequence: <default> - Source: <https://manpages.debian.org/dpkg-shlibdeps.1> + SubstvarMetadata( + "${gir:Depends}", + "`dh_girepository`", + "gir", + "<https://manpages.debian.org/dh_girepository.1>", + textwrap.dedent( + """\ + Dependencies related to GObject introspection data. """ + ), ), - "${shlibs:Pre-Depends}": textwrap.dedent( - """\ - Dependencies related to ELF dependencies. The `Pre-Depends` - version is often only seen in `Essential: yes` packages - or packages that manually request the `Pre-Depends` - relation via `dpkg-shlibdeps`. - - Defined by: `dpkg-shlibdeps` (often via `dh_shlibdeps`) - DH Sequence: <default> - Source: <https://manpages.debian.org/dpkg-shlibdeps.1> + SubstvarMetadata( + "${shlibs:Depends}", + "`dpkg-shlibdeps` (often via `dh_shlibdeps`)", + "(default)", + "<https://manpages.debian.org/dpkg-shlibdeps.1>", + textwrap.dedent( + """\ + Dependencies related to ELF dependencies. """ + ), ), -} + SubstvarMetadata( + "${shlibs:Pre-Depends}", + "`dpkg-shlibdeps` (often via `dh_shlibdeps`)", + "(default)", + "<https://manpages.debian.org/dpkg-shlibdeps.1>", + textwrap.dedent( + """\ + Dependencies related to ELF dependencies. The `Pre-Depends` + version is often only seen in `Essential: yes` packages + or packages that manually request the `Pre-Depends` + relation via `dpkg-shlibdeps`. + + Note: This substvar only appears in `debhelper-compat (= 14)`, or + with use of `debputy` (at an integration level, where `debputy` + runs `dpkg-shlibdeps`), or when passing relevant options to + `dpkg-shlibdeps` (often via `dh_shlibdeps`) such as `-dPre-Depends`. + """ + ), + ), +) _DCTRL_FILE_METADATA = DctrlFileMetadata() @@ -225,9 +290,30 @@ lsp_standard_handler(_LANGUAGE_IDS, TEXT_DOCUMENT_CODE_ACTION) lsp_standard_handler(_LANGUAGE_IDS, TEXT_DOCUMENT_WILL_SAVE_WAIT_UNTIL) +@lru_cache +def _relationship_fields() -> Mapping[str, str]: + # TODO: Pull from `dpkg-dev` when possible fallback only to the static list. + return { + f.lower(): f + for f in ( + "Pre-Depends", + "Depends", + "Recommends", + "Suggests", + "Enhances", + "Conflicts", + "Breaks", + "Replaces", + "Provides", + "Built-Using", + "Static-Built-Using", + ) + } + + @lsp_hover(_LANGUAGE_IDS) def _debian_control_hover( - ls: "LanguageServer", + ls: "DebputyLanguageServer", params: HoverParams, ) -> Optional[Hover]: return deb822_hover(ls, params, _DCTRL_FILE_METADATA, custom_handler=_custom_hover) @@ -248,26 +334,40 @@ def _custom_hover( line_no = server_position.line line = lines[line_no] substvar_search_ref = server_position.character - if line[substvar_search_ref] in ("$", "{"): - substvar_search_ref += 2 substvar = "" try: + if line and line[substvar_search_ref] in ("$", "{"): + substvar_search_ref += 2 substvar_start = line.rindex("${", 0, substvar_search_ref) substvar_end = line.index("}", substvar_start) if server_position.character <= substvar_end: - _info( - f"Range {substvar_start} <= {server_position.character} <= {substvar_end}" - ) substvar = line[substvar_start : substvar_end + 1] - except ValueError: + except (ValueError, IndexError): pass if substvar == "${}" or _SUBSTVAR_RE.fullmatch(substvar): - doc = _SUBSTVARS_DOC.get(substvar) + substvar_md = _SUBSTVARS_DOC.get(substvar) + + computed_doc = "" + for_field = relationship_substvar_for_field(substvar) + if for_field: + # Leading empty line is intentional! + computed_doc = textwrap.dedent( + f""" + This substvar is a relationship substvar for the field {for_field}. + Relationship substvars are automatically added in the field they + are named after in `debhelper-compat (= 14)` or later, or with + `debputy` (any integration mode after 0.1.21). + """ + ) - if doc is None: - doc = "No documentation for {substvar}." - return f"# Substvar `{substvar}`\n\n{doc}" + if substvar_md is None: + doc = f"No documentation for {substvar}.\n" + md_fields = "" + else: + doc = substvar_md.description + md_fields = "\n" + substvar_md.render_metadata_fields() + return f"# Substvar `{substvar}`\n\n{doc}{computed_doc}{md_fields}" if known_field is None or known_field.name != "Description": return None @@ -318,7 +418,7 @@ def _custom_hover( @lsp_completer(_LANGUAGE_IDS) def _debian_control_completions( - ls: "LanguageServer", + ls: "DebputyLanguageServer", params: CompletionParams, ) -> Optional[Union[CompletionList, Sequence[CompletionItem]]]: return deb822_completer(ls, params, _DCTRL_FILE_METADATA) @@ -326,37 +426,12 @@ def _debian_control_completions( @lsp_folding_ranges(_LANGUAGE_IDS) def _debian_control_folding_ranges( - ls: "LanguageServer", + ls: "DebputyLanguageServer", params: FoldingRangeParams, ) -> Optional[Sequence[FoldingRange]]: return deb822_folding_ranges(ls, params, _DCTRL_FILE_METADATA) -def _deb822_token_iter( - tokens: Iterable[Deb822Token], -) -> Iterator[Tuple[Deb822Token, int, int, int, int, int]]: - line_no = 0 - line_offset = 0 - - for token in tokens: - start_line = line_no - start_line_offset = line_offset - - newlines = token.text.count("\n") - line_no += newlines - text_len = len(token.text) - if newlines: - if token.text.endswith("\n"): - line_offset = 0 - else: - # -2, one to remove the "\n" and one to get 0-offset - line_offset = text_len - token.text.rindex("\n") - 2 - else: - line_offset += text_len - - yield token, start_line, start_line_offset, line_no, line_offset - - def _paragraph_representation_field( paragraph: Deb822ParagraphElement, ) -> Deb822KeyValuePairElement: @@ -441,23 +516,32 @@ def _binary_package_checks( source="debputy", ) ) - if effective_section != "debian-installer": - quickfix_data = None - if section is not None: - quickfix_data = [ - propose_correct_text_quick_fix( - f"{component_prefix}debian-installer" - ) - ] - diagnostics.append( - Diagnostic( - section_range, - f'The Section should be "{component_prefix}debian-installer" for udebs', - severity=DiagnosticSeverity.Warning, - source="debputy", - data=quickfix_data, + guessed_section = "debian-installer" + section_diagnostic_rationale = " since it is an udeb" + else: + guessed_section = package_name_to_section(package_name) + section_diagnostic_rationale = " based on the package name" + if guessed_section is not None and guessed_section != effective_section: + if section is not None: + quickfix_data = [ + propose_correct_text_quick_fix(f"{component_prefix}{guessed_section}") + ] + else: + quickfix_data = [ + propose_insert_text_on_line_after_diagnostic_quick_fix( + f"Section: {component_prefix}{guessed_section}\n" ) + ] + assert section_range is not None # mypy hint + diagnostics.append( + Diagnostic( + section_range, + f'The Section should be "{component_prefix}{guessed_section}"{section_diagnostic_rationale}', + severity=DiagnosticSeverity.Warning, + source="debputy", + data=quickfix_data, ) + ) def _diagnostics_for_paragraph( @@ -513,7 +597,7 @@ def _diagnostics_for_paragraph( diagnostics, ) - seen_fields = {} + seen_fields: Dict[str, Tuple[str, str, Range, List[Range]]] = {} for kvpair in stanza.iter_parts_of_type(Deb822KeyValuePairElement): field_name_token = kvpair.field_token @@ -621,12 +705,12 @@ def _diagnostics_for_paragraph( ) if pos: word_pos_te = TEPosition(0, pos).relative_to(word_pos_te) - word_range = TERange( + word_range_te = TERange( START_POSITION, TEPosition(0, endpos - pos), ) word_range_server_units = te_range_to_lsp( - TERange.from_position_and_size(word_pos_te, word_range) + TERange.from_position_and_size(word_pos_te, word_range_te) ) word_range = position_codec.range_to_client_units( lines, @@ -718,7 +802,7 @@ def _scan_for_syntax_errors_and_token_level_diagnostics( start_offset, end_line, end_offset, - ) in _deb822_token_iter(deb822_file.iter_tokens()): + ) in deb822_token_iter(deb822_file.iter_tokens()): if token.is_error: first_error = min(first_error, start_line) start_pos = Position( @@ -741,17 +825,17 @@ def _scan_for_syntax_errors_and_token_level_diagnostics( ) ) elif token.is_comment: - for word, pos, end_pos in spell_checker.iter_words(token.text): + for word, col_pos, end_col_pos in spell_checker.iter_words(token.text): corrections = spell_checker.provide_corrections_for(word) if not corrections: continue start_pos = Position( start_line, - pos, + col_pos, ) end_pos = Position( start_line, - end_pos, + end_col_pos, ) word_range = position_codec.range_to_client_units( lines, Range(start_pos, end_pos) @@ -820,8 +904,8 @@ def _lint_debian_control( @lsp_semantic_tokens_full(_LANGUAGE_IDS) -def _semantic_tokens_full( - ls: "LanguageServer", +def _debian_control_semantic_tokens_full( + ls: "DebputyLanguageServer", request: SemanticTokensParams, ) -> Optional[SemanticTokens]: return deb822_semantic_tokens_full( diff --git a/src/debputy/lsp/lsp_debian_control_reference_data.py b/src/debputy/lsp/lsp_debian_control_reference_data.py index e65ab86..898faab 100644 --- a/src/debputy/lsp/lsp_debian_control_reference_data.py +++ b/src/debputy/lsp/lsp_debian_control_reference_data.py @@ -22,8 +22,6 @@ from typing import ( ) from debian.debian_support import DpkgArchTable -from lsprotocol.types import DiagnosticSeverity, Diagnostic, DiagnosticTag, Range - from debputy.lsp.quickfixes import ( propose_correct_text_quick_fix, propose_remove_line_quick_fix, @@ -56,6 +54,7 @@ from debputy.lsp.vendoring._deb822_repro.tokens import ( Deb822SpaceSeparatorToken, ) from debputy.util import PKGNAME_REGEX +from lsprotocol.types import DiagnosticSeverity, Diagnostic, DiagnosticTag, Range try: from debputy.lsp.vendoring._deb822_repro.locatable import ( @@ -330,7 +329,7 @@ def all_architectures_and_wildcards(arch2table) -> Iterable[Union[str, Keyword]] @functools.lru_cache -def dpkg_arch_and_wildcards() -> FrozenSet[str]: +def dpkg_arch_and_wildcards() -> FrozenSet[Union[str, Keyword]]: dpkg_arch_table = DpkgArchTable.load_arch_table() return frozenset(all_architectures_and_wildcards(dpkg_arch_table._arch2table)) @@ -505,6 +504,180 @@ def _combined_custom_field_check(*checks: CustomFieldCheck) -> CustomFieldCheck: return _validator +@dataclasses.dataclass(slots=True, frozen=True) +class PackageNameSectionRule: + section: str + check: Callable[[str], bool] + + +def _package_name_section_rule( + section: str, + check: Union[Callable[[str], bool], re.Pattern], + *, + confirm_re: Optional[re.Pattern] = None, +) -> PackageNameSectionRule: + if confirm_re is not None: + assert callable(check) + + def _impl(v: str) -> bool: + return check(v) and confirm_re.search(v) + + elif isinstance(check, re.Pattern): + + def _impl(v: str) -> bool: + return check.search(v) is not None + + else: + _impl = check + + return PackageNameSectionRule(section, _impl) + + +# rules: order is important (first match wins in case of a conflict) +_PKGNAME_VS_SECTION_RULES = [ + _package_name_section_rule("debian-installer", lambda n: n.endswith("-udeb")), + _package_name_section_rule("doc", lambda n: n.endswith(("-doc", "-docs"))), + _package_name_section_rule("debug", lambda n: n.endswith(("-dbg", "-dbgsym"))), + _package_name_section_rule( + "httpd", + lambda n: n.startswith(("lighttpd-mod", "libapache2-mod-", "libnginx-mod-")), + ), + _package_name_section_rule("gnustep", lambda n: n.startswith("gnustep-")), + _package_name_section_rule( + "gnustep", + lambda n: n.endswith( + ( + ".framework", + ".framework-common", + ".tool", + ".tool-common", + ".app", + ".app-common", + ) + ), + ), + _package_name_section_rule("embedded", lambda n: n.startswith("moblin-")), + _package_name_section_rule("javascript", lambda n: n.startswith("node-")), + _package_name_section_rule("zope", lambda n: n.startswith(("python-zope", "zope"))), + _package_name_section_rule( + "python", + lambda n: n.startswith(("python-", "python3-")), + ), + _package_name_section_rule( + "gnu-r", + lambda n: n.startswith(("r-cran-", "r-bioc-", "r-other-")), + ), + _package_name_section_rule("editors", lambda n: n.startswith("elpa-")), + _package_name_section_rule("lisp", lambda n: n.startswith("cl-")), + _package_name_section_rule( + "lisp", + lambda n: "-elisp-" in n or n.endswith("-elisp"), + ), + _package_name_section_rule( + "lisp", + lambda n: n.startswith("lib") and n.endswith("-guile"), + ), + _package_name_section_rule("lisp", lambda n: n.startswith("guile-")), + _package_name_section_rule("golang", lambda n: n.startswith("golang-")), + _package_name_section_rule( + "perl", + lambda n: n.startswith("lib") and n.endswith("-perl"), + ), + _package_name_section_rule( + "cli-mono", + lambda n: n.startswith("lib") and n.endswith(("-cil", "-cil-dev")), + ), + _package_name_section_rule( + "java", + lambda n: n.startswith("lib") and n.endswith(("-java", "-gcj", "-jni")), + ), + _package_name_section_rule( + "php", + lambda n: n.startswith(("libphp", "php")), + confirm_re=re.compile(r"^(?:lib)?php(?:\d(?:\.\d)?)?-"), + ), + _package_name_section_rule( + "php", lambda n: n.startswith("lib-") and n.endswith("-php") + ), + _package_name_section_rule( + "haskell", + lambda n: n.startswith(("haskell-", "libhugs-", "libghc-", "libghc6-")), + ), + _package_name_section_rule( + "ruby", + lambda n: "-ruby" in n, + confirm_re=re.compile(r"^lib.*-ruby(?:1\.\d)?$"), + ), + _package_name_section_rule("ruby", lambda n: n.startswith("ruby-")), + _package_name_section_rule( + "rust", + lambda n: n.startswith("librust-") and n.endswith("-dev"), + ), + _package_name_section_rule("rust", lambda n: n.startswith("rust-")), + _package_name_section_rule( + "ocaml", + lambda n: n.startswith("lib-") and n.endswith(("-ocaml-dev", "-camlp4-dev")), + ), + _package_name_section_rule("javascript", lambda n: n.startswith("libjs-")), + _package_name_section_rule( + "interpreters", + lambda n: n.startswith("lib-") and n.endswith(("-tcl", "-lua", "-gst")), + ), + _package_name_section_rule( + "introspection", + lambda n: n.startswith("gir-"), + confirm_re=re.compile(r"^gir\d+\.\d+-.*-\d+\.\d+$"), + ), + _package_name_section_rule( + "fonts", + lambda n: n.startswith(("xfonts-", "fonts-", "ttf-")), + ), + _package_name_section_rule("admin", lambda n: n.startswith(("libnss-", "libpam-"))), + _package_name_section_rule( + "localization", + lambda n: n.startswith( + ( + "aspell-", + "hunspell-", + "myspell-", + "mythes-", + "dict-freedict-", + "gcompris-sound-", + ) + ), + ), + _package_name_section_rule( + "localization", + lambda n: n.startswith("hypen-"), + confirm_re=re.compile(r"^hyphen-[a-z]{2}(?:-[a-z]{2})?$"), + ), + _package_name_section_rule( + "localization", + lambda n: "-l10n-" in n or n.endswith("-l10n"), + ), + _package_name_section_rule("kernel", lambda n: n.endswith(("-dkms", "-firmware"))), + _package_name_section_rule( + "libdevel", + lambda n: n.startswith("lib") and n.endswith(("-dev", "-headers")), + ), + _package_name_section_rule( + "libs", + lambda n: n.startswith("lib"), + confirm_re=re.compile(r"^lib.*\d[ad]?$"), + ), +] + + +# Fiddling with the package name can cause a lot of changes (diagnostic scans), so we have an upper bound +# on the cache. The number is currently just taken out of a hat. +@functools.lru_cache(64) +def package_name_to_section(name: str) -> Optional[str]: + for rule in _PKGNAME_VS_SECTION_RULES: + if rule.check(name): + return rule.section + return None + + class FieldValueClass(Enum): SINGLE_VALUE = auto(), LIST_SPACE_SEPARATED_INTERPRETATION SPACE_SEPARATED_LIST = auto(), LIST_SPACE_SEPARATED_INTERPRETATION @@ -576,6 +749,8 @@ class Deb822KnownField: unknown_value_diagnostic_severity: Optional[DiagnosticSeverity] = ( DiagnosticSeverity.Error ) + # One-line description for space-constrained docs (such as completion docs) + synopsis_doc: Optional[str] = None hover_text: Optional[str] = None spellcheck_value: bool = False is_stanza_name: bool = False @@ -812,6 +987,7 @@ SOURCE_FIELDS = _fields( custom_field_check=_each_value_match_regex_validation(PKGNAME_REGEX), missing_field_severity=DiagnosticSeverity.Error, is_stanza_name=True, + synopsis_doc="Name of source package", hover_text=textwrap.dedent( """\ Declares the name of the source package. @@ -824,6 +1000,7 @@ SOURCE_FIELDS = _fields( "Standards-Version", FieldValueClass.SINGLE_VALUE, missing_field_severity=DiagnosticSeverity.Error, + synopsis_doc="Debian Policy version this package complies with", hover_text=textwrap.dedent( """\ Declares the last semantic version of the Debian Policy this package as last checked against. @@ -843,6 +1020,7 @@ SOURCE_FIELDS = _fields( FieldValueClass.SINGLE_VALUE, known_values=ALL_SECTIONS, unknown_value_diagnostic_severity=DiagnosticSeverity.Warning, + synopsis_doc="Default section", hover_text=textwrap.dedent( """\ Define the default section for packages in this source package. @@ -862,6 +1040,7 @@ SOURCE_FIELDS = _fields( default_value="optional", warn_if_default=False, known_values=ALL_PRIORITIES, + synopsis_doc="Default priority", hover_text=textwrap.dedent( """\ Define the default priority for packages in this source package. @@ -881,6 +1060,7 @@ SOURCE_FIELDS = _fields( "Maintainer", FieldValueClass.SINGLE_VALUE, missing_field_severity=DiagnosticSeverity.Error, + synopsis_doc="Name and email of maintainer / maintenance team", hover_text=textwrap.dedent( """\ The maintainer of the package. @@ -897,6 +1077,7 @@ SOURCE_FIELDS = _fields( DctrlKnownField( "Uploaders", FieldValueClass.COMMA_SEPARATED_EMAIL_LIST, + synopsis_doc="Names and emails of co-maintainers", hover_text=textwrap.dedent( """\ Comma separated list of uploaders associated with the package. @@ -922,6 +1103,7 @@ SOURCE_FIELDS = _fields( DctrlKnownField( "Vcs-Browser", FieldValueClass.SINGLE_VALUE, + synopsis_doc="URL for browsers to interact with packaging VCS", hover_text=textwrap.dedent( """\ URL to the Version control system repo used for the packaging. The URL should be usable with a @@ -934,6 +1116,7 @@ SOURCE_FIELDS = _fields( DctrlKnownField( "Vcs-Git", FieldValueClass.SPACE_SEPARATED_LIST, + synopsis_doc="URL and options for cloning the packaging VCS", hover_text=textwrap.dedent( """\ URL to the git repo used for the packaging. The URL should be usable with `git clone` @@ -952,6 +1135,7 @@ SOURCE_FIELDS = _fields( DctrlKnownField( "Vcs-Svn", FieldValueClass.SPACE_SEPARATED_LIST, # TODO: Might be a single value + synopsis_doc="URL for checking out the packaging VCS", hover_text=textwrap.dedent( """\ URL to the git repo used for the packaging. The URL should be usable with `svn checkout` @@ -965,6 +1149,7 @@ SOURCE_FIELDS = _fields( DctrlKnownField( "Vcs-Arch", FieldValueClass.SPACE_SEPARATED_LIST, # TODO: Might be a single value + synopsis_doc="URL for checking out the packaging VCS", hover_text=textwrap.dedent( """\ URL to the git repo used for the packaging. The URL should be usable for getting a copy of the @@ -977,6 +1162,7 @@ SOURCE_FIELDS = _fields( DctrlKnownField( "Vcs-Cvs", FieldValueClass.SPACE_SEPARATED_LIST, # TODO: Might be a single value + synopsis_doc="URL for checking out the packaging VCS", hover_text=textwrap.dedent( """\ URL to the git repo used for the packaging. The URL should be usable for getting a copy of the @@ -989,6 +1175,7 @@ SOURCE_FIELDS = _fields( DctrlKnownField( "Vcs-Darcs", FieldValueClass.SPACE_SEPARATED_LIST, # TODO: Might be a single value + synopsis_doc="URL for checking out the packaging VCS", hover_text=textwrap.dedent( """\ URL to the git repo used for the packaging. The URL should be usable for getting a copy of the @@ -1001,6 +1188,7 @@ SOURCE_FIELDS = _fields( DctrlKnownField( "Vcs-Hg", FieldValueClass.SPACE_SEPARATED_LIST, # TODO: Might be a single value + synopsis_doc="URL for checking out the packaging VCS", hover_text=textwrap.dedent( """\ URL to the git repo used for the packaging. The URL should be usable for getting a copy of the @@ -1013,6 +1201,7 @@ SOURCE_FIELDS = _fields( DctrlKnownField( "Vcs-Mtn", FieldValueClass.SPACE_SEPARATED_LIST, # TODO: Might be a single value + synopsis_doc="URL for checking out the packaging VCS", hover_text=textwrap.dedent( """\ URL to the git repo used for the packaging. The URL should be usable for getting a copy of the @@ -1028,6 +1217,7 @@ SOURCE_FIELDS = _fields( deprecated_with_no_replacement=True, default_value="no", known_values=_allowed_values("yes", "no"), + synopsis_doc="**Obsolete**: Old ACL mechanism for Debian Managers", hover_text=textwrap.dedent( """\ Obsolete field @@ -1044,6 +1234,7 @@ SOURCE_FIELDS = _fields( DctrlKnownField( "Build-Depends", FieldValueClass.COMMA_SEPARATED_LIST, + synopsis_doc="Dependencies requires for clean and full build actions", hover_text=textwrap.dedent( """\ All minimum build-dependencies for this source package. Needed for any target including **clean**. @@ -1053,6 +1244,7 @@ SOURCE_FIELDS = _fields( DctrlKnownField( "Build-Depends-Arch", FieldValueClass.COMMA_SEPARATED_LIST, + synopsis_doc="Dependencies requires for arch:any action (build-arch/binary-arch)", hover_text=textwrap.dedent( """\ Build-dependencies required for building the architecture dependent binary packages of this source @@ -1068,6 +1260,7 @@ SOURCE_FIELDS = _fields( DctrlKnownField( "Build-Depends-Indep", FieldValueClass.COMMA_SEPARATED_LIST, + synopsis_doc="Dependencies requires for arch:all action (build-indep/binary-indep)", hover_text=textwrap.dedent( """\ Build-dependencies required for building the architecture independent binary packages of this source @@ -1083,6 +1276,7 @@ SOURCE_FIELDS = _fields( DctrlKnownField( "Build-Conflicts", FieldValueClass.COMMA_SEPARATED_LIST, + synopsis_doc="Package versions that will break the build or the clean target (use sparingly)", hover_text=textwrap.dedent( """\ Packages that must **not** be installed during **any** part of the build, including the **clean** @@ -1097,6 +1291,7 @@ SOURCE_FIELDS = _fields( DctrlKnownField( "Build-Conflicts-Arch", FieldValueClass.COMMA_SEPARATED_LIST, + synopsis_doc="Package versions that will break an arch:any build (use sparingly)", hover_text=textwrap.dedent( """\ Packages that must **not** be installed during the **build-arch** or **binary-arch** targets. @@ -1111,6 +1306,7 @@ SOURCE_FIELDS = _fields( DctrlKnownField( "Build-Conflicts-Indep", FieldValueClass.COMMA_SEPARATED_LIST, + synopsis_doc="Package versions that will break an arch:all build (use sparingly)", hover_text=textwrap.dedent( """\ Packages that must **not** be installed during the **build-indep** or **binary-indep** targets. @@ -1125,6 +1321,7 @@ SOURCE_FIELDS = _fields( DctrlKnownField( "Testsuite", FieldValueClass.SPACE_SEPARATED_LIST, + synopsis_doc="Announce **autodep8** tests", hover_text=textwrap.dedent( """\ Declares that this package provides or should run install time tests via `autopkgtest`. @@ -1142,6 +1339,7 @@ SOURCE_FIELDS = _fields( DctrlKnownField( "Homepage", FieldValueClass.SINGLE_VALUE, + synopsis_doc="Upstream homepage", hover_text=textwrap.dedent( """\ Link to the upstream homepage for this source package. @@ -1196,6 +1394,7 @@ SOURCE_FIELDS = _fields( ), ), ), + synopsis_doc="Declare (fake)root requirements for the package", hover_text=textwrap.dedent( """\ Declare if and when the package build assumes it is run as root or fakeroot. @@ -1225,6 +1424,7 @@ SOURCE_FIELDS = _fields( DctrlKnownField( "Bugs", FieldValueClass.SINGLE_VALUE, + synopsis_doc="Custom bugtracker URL (for third-party packages)", hover_text=textwrap.dedent( """\ Provide a custom bug tracker URL @@ -1238,6 +1438,7 @@ SOURCE_FIELDS = _fields( DctrlKnownField( "Origin", FieldValueClass.SINGLE_VALUE, + synopsis_doc="Custom origin (for third-party packages)", hover_text=textwrap.dedent( """\ Declare the origin of the package. @@ -1252,6 +1453,7 @@ SOURCE_FIELDS = _fields( "X-Python-Version", FieldValueClass.COMMA_SEPARATED_LIST, replaced_by="X-Python3-Version", + synopsis_doc="**Obsolete**: Supported Python2 versions (`dh-python` specific)", hover_text=textwrap.dedent( """\ Obsolete field for declaring the supported Python2 versions @@ -1264,6 +1466,7 @@ SOURCE_FIELDS = _fields( DctrlKnownField( "X-Python3-Version", FieldValueClass.COMMA_SEPARATED_LIST, + synopsis_doc="Supported Python3 versions (`dh-python` specific)", hover_text=textwrap.dedent( # Too lazy to provide a better description """\ @@ -1278,6 +1481,7 @@ SOURCE_FIELDS = _fields( "XS-Autobuild", FieldValueClass.SINGLE_VALUE, known_values=_allowed_values("yes"), + synopsis_doc="Whether this non-free is auto-buildable on buildds", hover_text=textwrap.dedent( """\ Used for non-free packages to denote that they may be auto-build on the Debian build infrastructure @@ -1291,6 +1495,7 @@ SOURCE_FIELDS = _fields( "Description", FieldValueClass.FREE_TEXT_FIELD, spellcheck_value=True, + synopsis_doc="Common base description for all packages via substvar", hover_text=textwrap.dedent( """\ This field contains a human-readable description of the package. However, it is not used directly. @@ -1343,6 +1548,7 @@ BINARY_FIELDS = _fields( custom_field_check=_each_value_match_regex_validation(PKGNAME_REGEX), is_stanza_name=True, missing_field_severity=DiagnosticSeverity.Error, + synopsis_doc="Declares the name of a binary package", hover_text="Declares the name of a binary package", ), DctrlKnownField( @@ -1356,6 +1562,7 @@ BINARY_FIELDS = _fields( hover_text="The package will be built as a micro-deb (also known as a udeb). These are solely used by the debian-installer.", ), ), + synopsis_doc="Non-standard package type (such as `udeb`)", hover_text=textwrap.dedent( """\ **Special-purpose only**. *This field is a special purpose field and is rarely needed.* @@ -1373,6 +1580,7 @@ BINARY_FIELDS = _fields( missing_field_severity=DiagnosticSeverity.Error, unknown_value_diagnostic_severity=None, known_values=_allowed_values(*dpkg_arch_and_wildcards()), + synopsis_doc="Architecture of the package", hover_text=textwrap.dedent( """\ Determines which architectures this package can be compiled for or if it is an architecture-independent @@ -1424,6 +1632,7 @@ BINARY_FIELDS = _fields( ), ), ), + synopsis_doc="Whether the package is essential (Policy term)", hover_text=textwrap.dedent( """\ **Special-purpose only**. *This field is a special purpose field and is rarely needed.* @@ -1451,6 +1660,7 @@ BINARY_FIELDS = _fields( FieldValueClass.SINGLE_VALUE, replaced_by="Protected", default_value="no", + synopsis_doc="**Deprecated**: Use Protected instead", known_values=_allowed_values( Keyword( "yes", @@ -1469,6 +1679,13 @@ BINARY_FIELDS = _fields( ), ), ), + hover_text=textwrap.dedent( + """\ + This is the prototype field that lead to `Protected`, which should be used instead. + + It makes `apt` (but not `dpkg`) require extra confirmation before removing the package. + """ + ), ), DctrlKnownField( "Protected", @@ -1492,10 +1709,24 @@ BINARY_FIELDS = _fields( ), ), ), + synopsis_doc="Mark as protected (uninstall protection)", + hover_text=textwrap.dedent( + """\ + Declare this package as a potential system critical package. When set to `yes`, both `apt` + and `dpkg` will assume that removing the package *may* break the system. As a consequence, + they will require extra confirmation (or "force" options) before removing the package. + + This field basically provides a "uninstall" protection similar to that of `Essential` packages + without the other benefits and requirements that comes with `Essential` packages. This option + is generally applicable to packages like bootloaders, kernels, and other packages that might + be necessary for booting the system. + """ + ), ), DctrlKnownField( "Pre-Depends", FieldValueClass.COMMA_SEPARATED_LIST, + synopsis_doc="Very strong dependencies; prefer Depends when applicable", hover_text=textwrap.dedent( """\ **Advanced field**. *This field covers an advanced topic. If you are new to packaging, you are* @@ -1522,6 +1753,7 @@ BINARY_FIELDS = _fields( DctrlKnownField( "Depends", FieldValueClass.COMMA_SEPARATED_LIST, + synopsis_doc="Dependencies required to install and use this package", hover_text=textwrap.dedent( """\ Lists the packages that must be installed, before this package is installed. @@ -1550,6 +1782,7 @@ BINARY_FIELDS = _fields( DctrlKnownField( "Recommends", FieldValueClass.COMMA_SEPARATED_LIST, + synopsis_doc="Optional dependencies **most** people should have", hover_text=textwrap.dedent( """\ Lists the packages that *should* be installed when this package is installed in all but @@ -1573,6 +1806,7 @@ BINARY_FIELDS = _fields( DctrlKnownField( "Suggests", FieldValueClass.COMMA_SEPARATED_LIST, + synopsis_doc="Optional dependencies that some people might want", hover_text=textwrap.dedent( """\ Lists the packages that may make this package more useful but not installing them is perfectly @@ -1589,6 +1823,7 @@ BINARY_FIELDS = _fields( DctrlKnownField( "Enhances", FieldValueClass.COMMA_SEPARATED_LIST, + synopsis_doc="Packages enhanced by installing this package", hover_text=textwrap.dedent( """\ This field is similar to Suggests but works in the opposite direction. It is used to declare that @@ -1606,6 +1841,7 @@ BINARY_FIELDS = _fields( DctrlKnownField( "Provides", FieldValueClass.COMMA_SEPARATED_LIST, + synopsis_doc="Additional packages/versions this package dependency-wise satisfy", hover_text=textwrap.dedent( """\ Declare this package also provide one or more other packages. This means that this package can @@ -1648,6 +1884,7 @@ BINARY_FIELDS = _fields( DctrlKnownField( "Conflicts", FieldValueClass.COMMA_SEPARATED_LIST, + synopsis_doc="Packages that this package is not co-installable with", hover_text=textwrap.dedent( """\ **Warning**: *You may be looking for Breaks instead of Conflicts*. @@ -1675,6 +1912,7 @@ BINARY_FIELDS = _fields( DctrlKnownField( "Breaks", FieldValueClass.COMMA_SEPARATED_LIST, + synopsis_doc="Package/versions that does not work with this package", hover_text=textwrap.dedent( """\ This package cannot be installed together with the packages listed in the `Breaks` field. @@ -1719,6 +1957,7 @@ BINARY_FIELDS = _fields( DctrlKnownField( "Replaces", FieldValueClass.COMMA_SEPARATED_LIST, + synopsis_doc="This package replaces content from these packages/versions", hover_text=textwrap.dedent( """\ This package either replaces another package or overwrites files that used to be provided by @@ -1745,6 +1984,7 @@ BINARY_FIELDS = _fields( DctrlKnownField( "Build-Profiles", FieldValueClass.BUILD_PROFILES_LIST, + synopsis_doc="Conditionally build this package", hover_text=textwrap.dedent( """\ **Advanced field**. *This field covers an advanced topic. If you are new to packaging, you are* @@ -1780,6 +2020,7 @@ BINARY_FIELDS = _fields( inherits_from_source=True, known_values=ALL_SECTIONS, unknown_value_diagnostic_severity=DiagnosticSeverity.Warning, + synopsis_doc="Which section this package should be in", hover_text=textwrap.dedent( """\ Define the section for this package. @@ -1801,6 +2042,7 @@ BINARY_FIELDS = _fields( missing_field_severity=DiagnosticSeverity.Error, inherits_from_source=True, known_values=ALL_PRIORITIES, + synopsis_doc="The package's priority (Policy term)", hover_text=textwrap.dedent( """\ Define the priority this package. @@ -1892,6 +2134,7 @@ BINARY_FIELDS = _fields( ), ), ), + synopsis_doc="**Advanced field**: How this package interacts with multi arch", hover_text=textwrap.dedent( """\ **Advanced field**. *This field covers an advanced topic. If you are new to packaging, you are* @@ -1921,7 +2164,8 @@ BINARY_FIELDS = _fields( * If you have an architecture dependent package, where everything is installed in `/usr/lib/${DEB_HOST_MULTIARCH}` (plus a bit of standard documentation in `/usr/share/doc`), then - you *probably* want `Multi-Arch: same` + you *probably* want `Multi-Arch: same`. Note that `debputy` automatically detects the most common + variants of this case and sets the field for you. * If none of the above applies, then omit the field unless you know what you are doing or you are receiving advice from a Multi-Arch expert. @@ -2001,6 +2245,7 @@ BINARY_FIELDS = _fields( _udeb_only_field_validation, _each_value_match_regex_validation(re.compile(r"^[1-9]\d{3,4}$")), ), + synopsis_doc="(udeb-only) Package's order in the d-i menu", hover_text=textwrap.dedent( """\ This field is only relevant for `udeb` packages (debian-installer). @@ -2034,6 +2279,7 @@ BINARY_FIELDS = _fields( hover_text="The package should be compiled for `DEB_TARGET_ARCH`.", ), ), + synopsis_doc="(Special purpose) For cross-compiling cross-compilers", hover_text=textwrap.dedent( """\ **Special-purpose only**. *This field is a special purpose field and is rarely needed.* @@ -2064,6 +2310,7 @@ BINARY_FIELDS = _fields( "X-Time64-Compat", FieldValueClass.SINGLE_VALUE, custom_field_check=_each_value_match_regex_validation(PKGNAME_REGEX), + synopsis_doc="(Special purpose) Compat name for time64_t transition", hover_text=textwrap.dedent( """\ Special purpose field related to the 64-bit time transition. @@ -2077,6 +2324,7 @@ BINARY_FIELDS = _fields( DctrlKnownField( "Homepage", FieldValueClass.SINGLE_VALUE, + synopsis_doc="(Special purpose) Upstream homepage URL for this binary package", hover_text=textwrap.dedent( """\ Link to the upstream homepage for this binary package. @@ -2095,6 +2343,7 @@ BINARY_FIELDS = _fields( spellcheck_value=True, # It will build just fine. But no one will know what it is for, so it probably won't be installed missing_field_severity=DiagnosticSeverity.Warning, + synopsis_doc="Package synopsis and description", hover_text=textwrap.dedent( """\ A human-readable description of the package. This field consists of two related but distinct parts. @@ -2140,6 +2389,7 @@ BINARY_FIELDS = _fields( "XB-Cnf-Visible-Pkgname", FieldValueClass.SINGLE_VALUE, custom_field_check=_each_value_match_regex_validation(PKGNAME_REGEX), + synopsis_doc="(Special purpose) Hint for `command-not-found`", hover_text=textwrap.dedent( """\ **Special-case field**: *This field is only useful in very special circumstances.* @@ -2168,6 +2418,7 @@ BINARY_FIELDS = _fields( DctrlKnownField( "X-DhRuby-Root", FieldValueClass.SINGLE_VALUE, + synopsis_doc="For multi-binary layout with `dh_ruby`", hover_text=textwrap.dedent( """\ Used by `dh_ruby` to request "multi-binary" layout and where the root for the given @@ -2624,6 +2875,7 @@ _DTESTSCTRL_FIELDS = _fields( FieldValueClass.SPACE_SEPARATED_LIST, unknown_value_diagnostic_severity=None, known_values=_allowed_values(*dpkg_arch_and_wildcards()), + synopsis_doc="Only run these tests on specific architectures", hover_text=textwrap.dedent( """\ When package tests are only supported on a limited set of @@ -2641,6 +2893,7 @@ _DTESTSCTRL_FIELDS = _fields( Deb822KnownField( "Classes", FieldValueClass.FREE_TEXT_FIELD, + synopsis_doc="Hardware related tagging", hover_text=textwrap.dedent( """\ Most package tests should work in a minimal environment and are @@ -2663,6 +2916,7 @@ _DTESTSCTRL_FIELDS = _fields( "Depends", FieldValueClass.COMMA_SEPARATED_LIST, default_value="@", + synopsis_doc="Dependencies for running the tests", hover_text="""\ Declares that the specified packages must be installed for the test to go ahead. This supports all features of dpkg dependencies, including @@ -3019,6 +3273,7 @@ _DTESTSCTRL_FIELDS = _fields( ), ), ), + synopsis_doc="Test restrictions and requirements", hover_text=textwrap.dedent( """\ Declares some restrictions or problems with the tests defined in @@ -3035,6 +3290,7 @@ _DTESTSCTRL_FIELDS = _fields( Deb822KnownField( "Tests", FieldValueClass.COMMA_OR_SPACE_SEPARATED_LIST, + synopsis_doc="List of test scripts to run", hover_text=textwrap.dedent( """\ This field names the tests which are defined by this stanza, and map @@ -3051,6 +3307,7 @@ _DTESTSCTRL_FIELDS = _fields( Deb822KnownField( "Test-Command", FieldValueClass.FREE_TEXT_FIELD, + synopsis_doc="Single test command", hover_text=textwrap.dedent( """\ If your test only contains a shell command or two, or you want to @@ -3069,6 +3326,8 @@ _DTESTSCTRL_FIELDS = _fields( Deb822KnownField( "Test-Directory", FieldValueClass.FREE_TEXT_FIELD, # TODO: Single path + default_value="debian/tests", + synopsis_doc="The directory containing the tests listed in from `Tests`", hover_text=textwrap.dedent( """\ Replaces the path segment `debian/tests` in the filenames of the @@ -3190,7 +3449,9 @@ _DTESTSCTRL_STANZA = DTestsCtrlStanzaMetadata("Tests", _DTESTSCTRL_FIELDS) class Dep5FileMetadata(Deb822FileMetadata[Dep5StanzaMetadata]): - def classify_stanza(self, stanza: Deb822ParagraphElement, stanza_idx: int) -> S: + def classify_stanza( + self, stanza: Deb822ParagraphElement, stanza_idx: int + ) -> Dep5StanzaMetadata: if stanza_idx == 0: return _DEP5_HEADER_STANZA if stanza_idx > 0: @@ -3199,19 +3460,19 @@ class Dep5FileMetadata(Deb822FileMetadata[Dep5StanzaMetadata]): return _DEP5_LICENSE_STANZA raise ValueError("The stanza_idx must be 0 or greater") - def guess_stanza_classification_by_idx(self, stanza_idx: int) -> S: + def guess_stanza_classification_by_idx(self, stanza_idx: int) -> Dep5StanzaMetadata: if stanza_idx == 0: return _DEP5_HEADER_STANZA if stanza_idx > 0: return _DEP5_FILES_STANZA raise ValueError("The stanza_idx must be 0 or greater") - def stanza_types(self) -> Iterable[S]: + def stanza_types(self) -> Iterable[Dep5StanzaMetadata]: yield _DEP5_HEADER_STANZA yield _DEP5_FILES_STANZA yield _DEP5_LICENSE_STANZA - def __getitem__(self, item: str) -> S: + def __getitem__(self, item: str) -> Dep5StanzaMetadata: if item == "Header": return _DEP5_FILES_STANZA if item == "Files": @@ -3222,18 +3483,20 @@ class Dep5FileMetadata(Deb822FileMetadata[Dep5StanzaMetadata]): class DctrlFileMetadata(Deb822FileMetadata[DctrlStanzaMetadata]): - def guess_stanza_classification_by_idx(self, stanza_idx: int) -> S: + def guess_stanza_classification_by_idx( + self, stanza_idx: int + ) -> DctrlStanzaMetadata: if stanza_idx == 0: return _DCTRL_SOURCE_STANZA if stanza_idx > 0: return _DCTRL_PACKAGE_STANZA raise ValueError("The stanza_idx must be 0 or greater") - def stanza_types(self) -> Iterable[S]: + def stanza_types(self) -> Iterable[DctrlStanzaMetadata]: yield _DCTRL_SOURCE_STANZA yield _DCTRL_PACKAGE_STANZA - def __getitem__(self, item: str) -> S: + def __getitem__(self, item: str) -> DctrlStanzaMetadata: if item == "Source": return _DCTRL_SOURCE_STANZA if item == "Package": diff --git a/src/debputy/lsp/lsp_debian_copyright.py b/src/debputy/lsp/lsp_debian_copyright.py index b21cc79..b037792 100644 --- a/src/debputy/lsp/lsp_debian_copyright.py +++ b/src/debputy/lsp/lsp_debian_copyright.py @@ -8,8 +8,10 @@ from typing import ( Iterable, Mapping, List, + Dict, ) +from debputy.lsp.debputy_ls import DebputyLanguageServer from lsprotocol.types import ( DiagnosticSeverity, Range, @@ -51,6 +53,7 @@ from debputy.lsp.lsp_generic_deb822 import ( deb822_hover, deb822_folding_ranges, deb822_semantic_tokens_full, + deb822_token_iter, ) from debputy.lsp.quickfixes import ( propose_correct_text_quick_fix, @@ -105,7 +108,7 @@ lsp_standard_handler(_LANGUAGE_IDS, TEXT_DOCUMENT_WILL_SAVE_WAIT_UNTIL) @lsp_hover(_LANGUAGE_IDS) def _debian_copyright_hover( - ls: "LanguageServer", + ls: "DebputyLanguageServer", params: HoverParams, ) -> Optional[Hover]: return deb822_hover(ls, params, _DEP5_FILE_METADATA) @@ -113,7 +116,7 @@ def _debian_copyright_hover( @lsp_completer(_LANGUAGE_IDS) def _debian_copyright_completions( - ls: "LanguageServer", + ls: "DebputyLanguageServer", params: CompletionParams, ) -> Optional[Union[CompletionList, Sequence[CompletionItem]]]: return deb822_completer(ls, params, _DEP5_FILE_METADATA) @@ -121,37 +124,12 @@ def _debian_copyright_completions( @lsp_folding_ranges(_LANGUAGE_IDS) def _debian_copyright_folding_ranges( - ls: "LanguageServer", + ls: "DebputyLanguageServer", params: FoldingRangeParams, ) -> Optional[Sequence[FoldingRange]]: return deb822_folding_ranges(ls, params, _DEP5_FILE_METADATA) -def _deb822_token_iter( - tokens: Iterable[Deb822Token], -) -> Iterator[Tuple[Deb822Token, int, int, int, int, int]]: - line_no = 0 - line_offset = 0 - - for token in tokens: - start_line = line_no - start_line_offset = line_offset - - newlines = token.text.count("\n") - line_no += newlines - text_len = len(token.text) - if newlines: - if token.text.endswith("\n"): - line_offset = 0 - else: - # -2, one to remove the "\n" and one to get 0-offset - line_offset = text_len - token.text.rindex("\n") - 2 - else: - line_offset += text_len - - yield token, start_line, start_line_offset, line_no, line_offset - - def _paragraph_representation_field( paragraph: Deb822ParagraphElement, ) -> Deb822KeyValuePairElement: @@ -196,7 +174,7 @@ def _diagnostics_for_paragraph( ) ) - seen_fields = {} + seen_fields: Dict[str, Tuple[str, str, Range, List[Range]]] = {} for kvpair in stanza.iter_parts_of_type(Deb822KeyValuePairElement): field_name_token = kvpair.field_token @@ -306,12 +284,12 @@ def _diagnostics_for_paragraph( ) if pos: word_pos_te = TEPosition(0, pos).relative_to(word_pos_te) - word_range = TERange( + word_range_te = TERange( START_POSITION, TEPosition(0, endpos - pos), ) word_range_server_units = te_range_to_lsp( - TERange.from_position_and_size(word_pos_te, word_range) + TERange.from_position_and_size(word_pos_te, word_range_te) ) word_range = position_codec.range_to_client_units( lines, @@ -387,7 +365,7 @@ def _scan_for_syntax_errors_and_token_level_diagnostics( start_offset, end_line, end_offset, - ) in _deb822_token_iter(deb822_file.iter_tokens()): + ) in deb822_token_iter(deb822_file.iter_tokens()): if token.is_error: first_error = min(first_error, start_line) start_pos = Position( @@ -444,7 +422,7 @@ def _lint_debian_copyright( lines = lint_state.lines position_codec = lint_state.position_codec doc_reference = lint_state.doc_uri - diagnostics = [] + diagnostics: List[Diagnostic] = [] deb822_file = parse_deb822_file( lines, accept_files_with_duplicated_fields=True, @@ -494,8 +472,8 @@ def _lint_debian_copyright( @lsp_semantic_tokens_full(_LANGUAGE_IDS) -def _semantic_tokens_full( - ls: "LanguageServer", +def _debian_copyright_semantic_tokens_full( + ls: "DebputyLanguageServer", request: SemanticTokensParams, ) -> Optional[SemanticTokens]: return deb822_semantic_tokens_full( diff --git a/src/debputy/lsp/lsp_debian_debputy_manifest.py b/src/debputy/lsp/lsp_debian_debputy_manifest.py index 03581be..74b5d7b 100644 --- a/src/debputy/lsp/lsp_debian_debputy_manifest.py +++ b/src/debputy/lsp/lsp_debian_debputy_manifest.py @@ -134,7 +134,7 @@ def _lint_debian_debputy_manifest( path = lint_state.path if not is_valid_file(path): return None - diagnostics = [] + diagnostics: List[Diagnostic] = [] try: content = MANIFEST_YAML.load("".join(lines)) except MarkedYAMLError as e: @@ -922,7 +922,7 @@ def debputy_manifest_hover( ) if km is None: _info("No keyword match") - return + return None parser, plugin_metadata, at_depth_idx = km _info(f"Match leaf parser {at_depth_idx}/{len(segments)} -- {parser.__class__}") hover_doc_text = resolve_hover_text( @@ -1020,19 +1020,14 @@ def resolve_hover_text( return hover_doc_text -def _hover_doc(ls: "LanguageServer", hover_doc_text: Optional[str]) -> Optional[Hover]: +def _hover_doc( + ls: "DebputyLanguageServer", hover_doc_text: Optional[str] +) -> Optional[Hover]: if hover_doc_text is None: return None - try: - supported_formats = ls.client_capabilities.text_document.hover.content_format - except AttributeError: - supported_formats = [] - markup_kind = MarkupKind.Markdown - if markup_kind not in supported_formats: - markup_kind = MarkupKind.PlainText return Hover( contents=MarkupContent( - kind=markup_kind, + kind=ls.hover_markup_format(MarkupKind.Markdown, MarkupKind.PlainText), value=hover_doc_text, ), ) diff --git a/src/debputy/lsp/lsp_debian_rules.py b/src/debputy/lsp/lsp_debian_rules.py index b44fad4..7f5aef9 100644 --- a/src/debputy/lsp/lsp_debian_rules.py +++ b/src/debputy/lsp/lsp_debian_rules.py @@ -12,6 +12,7 @@ from typing import ( List, Iterator, Tuple, + Set, ) from lsprotocol.types import ( @@ -238,7 +239,7 @@ def _lint_debian_rules_impl( source_root = os.path.dirname(os.path.dirname(path)) if source_root == "": source_root = "." - diagnostics = [] + diagnostics: List[Diagnostic] = [] make_error = _run_make_dryrun(source_root, lines) if make_error is not None: @@ -316,7 +317,7 @@ def _lint_debian_rules_impl( def _all_dh_commands(source_root: str, lines: List[str]) -> Optional[Sequence[str]]: - drules_sequences = set() + drules_sequences: Set[str] = set() parse_drules_for_addons(lines, drules_sequences) cmd = ["dh_assistant", "list-commands", "--output-format=json"] if drules_sequences: @@ -369,6 +370,8 @@ def _debian_rules_completions( source_root = os.path.dirname(os.path.dirname(doc.path)) all_commands = _all_dh_commands(source_root, lines) + if all_commands is None: + return None items = [CompletionItem(ht) for c in all_commands for ht in _as_hook_targets(c)] return items diff --git a/src/debputy/lsp/lsp_debian_tests_control.py b/src/debputy/lsp/lsp_debian_tests_control.py index 27221f6..cc27579 100644 --- a/src/debputy/lsp/lsp_debian_tests_control.py +++ b/src/debputy/lsp/lsp_debian_tests_control.py @@ -8,8 +8,11 @@ from typing import ( Iterable, Mapping, List, + Set, + Dict, ) +from debputy.lsp.debputy_ls import DebputyLanguageServer from lsprotocol.types import ( DiagnosticSeverity, Range, @@ -49,6 +52,7 @@ from debputy.lsp.lsp_generic_deb822 import ( deb822_hover, deb822_folding_ranges, deb822_semantic_tokens_full, + deb822_token_iter, ) from debputy.lsp.quickfixes import ( propose_correct_text_quick_fix, @@ -103,7 +107,7 @@ lsp_standard_handler(_LANGUAGE_IDS, TEXT_DOCUMENT_WILL_SAVE_WAIT_UNTIL) @lsp_hover(_LANGUAGE_IDS) def debian_tests_control_hover( - ls: "LanguageServer", + ls: "DebputyLanguageServer", params: HoverParams, ) -> Optional[Hover]: return deb822_hover(ls, params, _DEP5_FILE_METADATA) @@ -111,7 +115,7 @@ def debian_tests_control_hover( @lsp_completer(_LANGUAGE_IDS) def debian_tests_control_completions( - ls: "LanguageServer", + ls: "DebputyLanguageServer", params: CompletionParams, ) -> Optional[Union[CompletionList, Sequence[CompletionItem]]]: return deb822_completer(ls, params, _DEP5_FILE_METADATA) @@ -119,37 +123,12 @@ def debian_tests_control_completions( @lsp_folding_ranges(_LANGUAGE_IDS) def debian_tests_control_folding_ranges( - ls: "LanguageServer", + ls: "DebputyLanguageServer", params: FoldingRangeParams, ) -> Optional[Sequence[FoldingRange]]: return deb822_folding_ranges(ls, params, _DEP5_FILE_METADATA) -def _deb822_token_iter( - tokens: Iterable[Deb822Token], -) -> Iterator[Tuple[Deb822Token, int, int, int, int, int]]: - line_no = 0 - line_offset = 0 - - for token in tokens: - start_line = line_no - start_line_offset = line_offset - - newlines = token.text.count("\n") - line_no += newlines - text_len = len(token.text) - if newlines: - if token.text.endswith("\n"): - line_offset = 0 - else: - # -2, one to remove the "\n" and one to get 0-offset - line_offset = text_len - token.text.rindex("\n") - 2 - else: - line_offset += text_len - - yield token, start_line, start_line_offset, line_no, line_offset - - def _paragraph_representation_field( paragraph: Deb822ParagraphElement, ) -> Deb822KeyValuePairElement: @@ -211,7 +190,7 @@ def _diagnostics_for_paragraph( ) ) - seen_fields = {} + seen_fields: Dict[str, Tuple[str, str, Range, List[Range]]] = {} for kvpair in stanza.iter_parts_of_type(Deb822KeyValuePairElement): field_name_token = kvpair.field_token @@ -384,7 +363,7 @@ def _scan_for_syntax_errors_and_token_level_diagnostics( start_offset, end_line, end_offset, - ) in _deb822_token_iter(deb822_file.iter_tokens()): + ) in deb822_token_iter(deb822_file.iter_tokens()): if token.is_error: first_error = min(first_error, start_line) start_pos = Position( @@ -441,7 +420,7 @@ def _lint_debian_tests_control( lines = lint_state.lines position_codec = lint_state.position_codec doc_reference = lint_state.doc_uri - diagnostics = [] + diagnostics: List[Diagnostic] = [] deb822_file = parse_deb822_file( lines, accept_files_with_duplicated_fields=True, @@ -475,8 +454,8 @@ def _lint_debian_tests_control( @lsp_semantic_tokens_full(_LANGUAGE_IDS) -def _semantic_tokens_full( - ls: "LanguageServer", +def _debian_tests_control_semantic_tokens_full( + ls: "DebputyLanguageServer", request: SemanticTokensParams, ) -> Optional[SemanticTokens]: return deb822_semantic_tokens_full( diff --git a/src/debputy/lsp/lsp_dispatch.py b/src/debputy/lsp/lsp_dispatch.py index b63f30c..5d09a44 100644 --- a/src/debputy/lsp/lsp_dispatch.py +++ b/src/debputy/lsp/lsp_dispatch.py @@ -10,6 +10,7 @@ from typing import ( Mapping, List, Tuple, + Literal, ) from lsprotocol.types import ( @@ -75,21 +76,22 @@ def is_doc_at_version(uri: str, version: int) -> bool: return dv == version -def determine_language_id(doc: "TextDocument") -> Tuple[str, str]: - lang_id = doc.language_id - if lang_id and not lang_id.isspace(): - return "declared", lang_id - path = doc.path - try: - last_idx = path.rindex("debian/") - except ValueError: - return "filename", os.path.basename(path) - guess_language_id = path[last_idx:] - return "filename", guess_language_id +@DEBPUTY_LANGUAGE_SERVER.feature(TEXT_DOCUMENT_DID_OPEN) +async def _open_document( + ls: "DebputyLanguageServer", + params: DidChangeTextDocumentParams, +) -> None: + await _open_or_changed_document(ls, params) -@DEBPUTY_LANGUAGE_SERVER.feature(TEXT_DOCUMENT_DID_OPEN) @DEBPUTY_LANGUAGE_SERVER.feature(TEXT_DOCUMENT_DID_CHANGE) +async def _changed_document( + ls: "DebputyLanguageServer", + params: DidChangeTextDocumentParams, +) -> None: + await _open_or_changed_document(ls, params) + + async def _open_or_changed_document( ls: "DebputyLanguageServer", params: Union[DidOpenTextDocumentParams, DidChangeTextDocumentParams], @@ -99,7 +101,7 @@ async def _open_or_changed_document( doc = ls.workspace.get_text_document(doc_uri) _DOCUMENT_VERSION_TABLE[doc_uri] = version - id_source, language_id = determine_language_id(doc) + id_source, language_id = ls.determine_language_id(doc) handler = DIAGNOSTIC_HANDLERS.get(language_id) if handler is None: _info( @@ -214,7 +216,7 @@ def _dispatch_standard_handler( ) -> R: doc = ls.workspace.get_text_document(doc_uri) - id_source, language_id = determine_language_id(doc) + id_source, language_id = ls.determine_language_id(doc) handler = handler_table.get(language_id) if handler is None: _info( diff --git a/src/debputy/lsp/lsp_features.py b/src/debputy/lsp/lsp_features.py index 7a1110d..e7b4445 100644 --- a/src/debputy/lsp/lsp_features.py +++ b/src/debputy/lsp/lsp_features.py @@ -1,7 +1,16 @@ import collections import inspect import sys -from typing import Callable, TypeVar, Sequence, Union, Dict, List, Optional +from typing import ( + Callable, + TypeVar, + Sequence, + Union, + Dict, + List, + Optional, + AsyncIterator, +) from lsprotocol.types import ( TEXT_DOCUMENT_WILL_SAVE_WAIT_UNTIL, @@ -29,14 +38,23 @@ from debputy.lsp.text_util import on_save_trim_end_of_line_whitespace C = TypeVar("C", bound=Callable) SEMANTIC_TOKENS_LEGEND = SemanticTokensLegend( - token_types=["keyword", "enumMember"], + token_types=["keyword", "enumMember", "comment"], token_modifiers=[], ) SEMANTIC_TOKEN_TYPES_IDS = { t: idx for idx, t in enumerate(SEMANTIC_TOKENS_LEGEND.token_types) } -DIAGNOSTIC_HANDLERS = {} +DIAGNOSTIC_HANDLERS: Dict[ + str, + Callable[ + [ + "DebputyLanguageServer", + Union["DidOpenTextDocumentParams", "DidChangeTextDocumentParams"], + ], + AsyncIterator[Optional[List[Diagnostic]]], + ], +] = {} COMPLETER_HANDLERS = {} HOVER_HANDLERS = {} CODE_ACTION_HANDLERS = {} diff --git a/src/debputy/lsp/lsp_generic_deb822.py b/src/debputy/lsp/lsp_generic_deb822.py index ec7b979..e2124e4 100644 --- a/src/debputy/lsp/lsp_generic_deb822.py +++ b/src/debputy/lsp/lsp_generic_deb822.py @@ -1,3 +1,4 @@ +import dataclasses import re from typing import ( Optional, @@ -13,6 +14,7 @@ from typing import ( Callable, ) +from debputy.lsp.debputy_ls import DebputyLanguageServer from debputy.lsp.lsp_debian_control_reference_data import ( Deb822FileMetadata, Deb822KnownField, @@ -22,11 +24,13 @@ from debputy.lsp.lsp_debian_control_reference_data import ( S, ) from debputy.lsp.lsp_features import SEMANTIC_TOKEN_TYPES_IDS -from debputy.lsp.text_util import normalize_dctrl_field_name +from debputy.lsp.text_util import normalize_dctrl_field_name, te_position_to_lsp from debputy.lsp.vendoring._deb822_repro import parse_deb822_file from debputy.lsp.vendoring._deb822_repro.parsing import ( Deb822KeyValuePairElement, LIST_SPACE_SEPARATED_INTERPRETATION, + Deb822ParagraphElement, + Deb822ValueLineElement, ) from debputy.lsp.vendoring._deb822_repro.tokens import tokenize_deb822_file, Deb822Token from debputy.util import _info @@ -64,7 +68,7 @@ def _at_cursor( ) -> Tuple[Position, Optional[str], str, bool, int, Set[str]]: paragraph_no = -1 paragraph_started = False - seen_fields = set() + seen_fields: Set[str] = set() last_field_seen: Optional[str] = None current_field: Optional[str] = None server_position = doc.position_codec.position_from_client_units( @@ -116,7 +120,7 @@ def _at_cursor( def deb822_completer( - ls: "LanguageServer", + ls: "DebputyLanguageServer", params: CompletionParams, file_metadata: Deb822FileMetadata[Any], ) -> Optional[Union[CompletionList, Sequence[CompletionItem]]]: @@ -142,6 +146,7 @@ def deb822_completer( else: _info("Completing field name") items = _complete_field_name( + ls, stanza_metadata, seen_fields, ) @@ -152,7 +157,7 @@ def deb822_completer( def deb822_hover( - ls: "LanguageServer", + ls: "DebputyLanguageServer", params: HoverParams, file_metadata: Deb822FileMetadata[S], *, @@ -170,7 +175,7 @@ def deb822_hover( Optional[Hover], ] ] = None, -) -> Optional[Union[Hover, str]]: +) -> Optional[Hover]: doc = ls.workspace.get_text_document(params.text_document.uri) lines = doc.lines server_pos, current_field, word_at_position, in_value, paragraph_no, _ = _at_cursor( @@ -220,27 +225,17 @@ def deb822_hover( if hover_text is None: return None - - try: - supported_formats = ls.client_capabilities.text_document.hover.content_format - except AttributeError: - supported_formats = [] - - _info(f"Supported formats {supported_formats}") - markup_kind = MarkupKind.Markdown - if markup_kind not in supported_formats: - markup_kind = MarkupKind.PlainText return Hover( contents=MarkupContent( - kind=markup_kind, + kind=ls.hover_markup_format(MarkupKind.Markdown, MarkupKind.PlainText), value=hover_text, ) ) -def _deb822_token_iter( +def deb822_token_iter( tokens: Iterable[Deb822Token], -) -> Iterator[Tuple[Deb822Token, int, int, int, int, int]]: +) -> Iterator[Tuple[Deb822Token, int, int, int, int]]: line_no = 0 line_offset = 0 @@ -264,7 +259,7 @@ def _deb822_token_iter( def deb822_folding_ranges( - ls: "LanguageServer", + ls: "DebputyLanguageServer", params: FoldingRangeParams, # Unused for now: might be relevant for supporting folding for some fields _file_metadata: Deb822FileMetadata[Any], @@ -278,7 +273,7 @@ def deb822_folding_ranges( start_offset, end_line, end_offset, - ) in _deb822_token_iter(tokenize_deb822_file(doc.lines)): + ) in deb822_token_iter(tokenize_deb822_file(doc.lines)): if token.is_comment: if comment_start < 0: comment_start = start_line @@ -295,90 +290,170 @@ def deb822_folding_ranges( return folding_ranges +@dataclasses.dataclass(slots=True) +class SemanticTokenState: + ls: "DebputyLanguageServer" + file_metadata: Deb822FileMetadata[Any] + doc: "TextDocument" + lines: List[str] + tokens: List[int] + keyword_token_code: int + known_value_token_code: int + comment_token_code: int + _previous_line: int = 0 + _previous_col: int = 0 + + def emit_token( + self, + start_pos: Position, + len_client_units: int, + token_code: int, + *, + token_modifiers: int = 0, + ) -> None: + line_delta = start_pos.line - self._previous_line + self._previous_line = start_pos.line + previous_col = self._previous_col + + if line_delta: + previous_col = 0 + + column_delta = start_pos.character - previous_col + self._previous_col = start_pos.character + + tokens = self.tokens + tokens.append(line_delta) # Line delta + tokens.append(column_delta) # Token column delta + tokens.append(len_client_units) # Token length + tokens.append(token_code) + tokens.append(token_modifiers) + + +def _deb822_paragraph_semantic_tokens_full( + sem_token_state: SemanticTokenState, + stanza: Deb822ParagraphElement, + stanza_idx: int, +) -> None: + doc = sem_token_state.doc + keyword_token_code = sem_token_state.keyword_token_code + known_value_token_code = sem_token_state.known_value_token_code + comment_token_code = sem_token_state.comment_token_code + + stanza_position = stanza.position_in_file() + stanza_metadata = sem_token_state.file_metadata.classify_stanza( + stanza, + stanza_idx=stanza_idx, + ) + for kvpair in stanza.iter_parts_of_type(Deb822KeyValuePairElement): + field_start = kvpair.key_position_in_stanza().relative_to(stanza_position) + comment = kvpair.comment_element + if comment: + comment_start_line = field_start.line_position - len(comment) + for comment_line_no, comment_token in enumerate( + comment.iter_parts(), + start=comment_start_line, + ): + assert comment_token.is_comment + assert isinstance(comment_token, Deb822Token) + sem_token_state.emit_token( + Position(comment_line_no, 0), + len(comment_token.text.rstrip()), + comment_token_code, + ) + field_size = doc.position_codec.client_num_units(kvpair.field_name) + + sem_token_state.emit_token( + te_position_to_lsp(field_start), + field_size, + keyword_token_code, + ) + + known_field: Optional[Deb822KnownField] = stanza_metadata.get(kvpair.field_name) + if known_field is not None: + if known_field.spellcheck_value: + continue + known_values: Container[str] = known_field.known_values or frozenset() + interpretation = known_field.field_value_class.interpreter() + else: + known_values = frozenset() + interpretation = None + + value_element_pos = kvpair.value_position_in_stanza().relative_to( + stanza_position + ) + if interpretation is None: + # TODO: Emit tokens for value comments of unknown fields. + continue + else: + parts = kvpair.interpret_as(interpretation).iter_parts() + for te in parts: + if te.is_whitespace: + continue + if te.is_separator: + continue + value_range_in_parent_te = te.range_in_parent() + value_range_te = value_range_in_parent_te.relative_to(value_element_pos) + value = te.convert_to_text() + if te.is_comment: + token_type = comment_token_code + value = value.rstrip() + elif value in known_values: + token_type = known_value_token_code + else: + continue + value_len = doc.position_codec.client_num_units(value) + + sem_token_state.emit_token( + te_position_to_lsp(value_range_te.start_pos), + value_len, + token_type, + ) + + def deb822_semantic_tokens_full( - ls: "LanguageServer", + ls: "DebputyLanguageServer", request: SemanticTokensParams, file_metadata: Deb822FileMetadata[Any], ) -> Optional[SemanticTokens]: doc = ls.workspace.get_text_document(request.text_document.uri) + position_codec = doc.position_codec lines = doc.lines deb822_file = parse_deb822_file( lines, accept_files_with_duplicated_fields=True, accept_files_with_error_tokens=True, ) - tokens = [] - previous_line = 0 - keyword_token_code = SEMANTIC_TOKEN_TYPES_IDS["keyword"] - known_value_token_code = SEMANTIC_TOKEN_TYPES_IDS["enumMember"] - no_modifiers = 0 - - # TODO: Add comment support; slightly complicated by how we parse the file. - - for stanza_idx, stanza in enumerate(deb822_file): - stanza_position = stanza.position_in_file() - stanza_metadata = file_metadata.classify_stanza(stanza, stanza_idx=stanza_idx) - for kvpair in stanza.iter_parts_of_type(Deb822KeyValuePairElement): - kvpair_pos = kvpair.position_in_parent().relative_to(stanza_position) - # These two happen to be the same; the indirection is to make it explicit that the two - # positions for different tokens are the same. - field_position_without_comments = kvpair_pos - field_size = doc.position_codec.client_num_units(kvpair.field_name) - current_line = field_position_without_comments.line_position - line_delta = current_line - previous_line - previous_line = current_line - tokens.append(line_delta) # Line delta - tokens.append(0) # Token column delta - tokens.append(field_size) # Token length - tokens.append(keyword_token_code) - tokens.append(no_modifiers) - - known_field: Optional[Deb822KnownField] = stanza_metadata.get( - kvpair.field_name - ) - if ( - known_field is None - or not known_field.known_values - or known_field.spellcheck_value - ): - continue - - if known_field.field_value_class not in ( - FieldValueClass.SINGLE_VALUE, - FieldValueClass.SPACE_SEPARATED_LIST, - ): - continue - value_element_pos = kvpair.value_element.position_in_parent().relative_to( - kvpair_pos - ) - - last_token_start_column = 0 + tokens: List[int] = [] + comment_token_code = SEMANTIC_TOKEN_TYPES_IDS["comment"] + sem_token_state = SemanticTokenState( + ls, + file_metadata, + doc, + lines, + tokens, + SEMANTIC_TOKEN_TYPES_IDS["keyword"], + SEMANTIC_TOKEN_TYPES_IDS["enumMember"], + comment_token_code, + ) - for value_ref in kvpair.interpret_as( - LIST_SPACE_SEPARATED_INTERPRETATION - ).iter_value_references(): - if value_ref.value not in known_field.known_values: - continue - value_loc = value_ref.locatable - value_range_te = value_loc.range_in_parent().relative_to( - value_element_pos - ) - start_line = value_range_te.start_pos.line_position - line_delta = start_line - current_line - current_line = start_line - if line_delta: - last_token_start_column = 0 - - value_start_column = value_range_te.start_pos.cursor_position - column_delta = value_start_column - last_token_start_column - last_token_start_column = value_start_column - - tokens.append(line_delta) # Line delta - tokens.append(column_delta) # Token column delta - tokens.append(field_size) # Token length - tokens.append(known_value_token_code) - tokens.append(no_modifiers) + stanza_idx = 0 + for part in deb822_file.iter_parts(): + if part.is_comment: + pos = part.position_in_file() + sem_token_state.emit_token( + te_position_to_lsp(pos), + # Avoid trailing newline + position_codec.client_num_units(part.convert_to_text().rstrip()), + comment_token_code, + ) + elif isinstance(part, Deb822ParagraphElement): + _deb822_paragraph_semantic_tokens_full( + sem_token_state, + part, + stanza_idx, + ) + stanza_idx += 1 if not tokens: return None return SemanticTokens(tokens) @@ -396,10 +471,14 @@ def _should_complete_field_with_value(cand: Deb822KnownField) -> bool: def _complete_field_name( + ls: "DebputyLanguageServer", fields: StanzaMetadata[Any], seen_fields: Container[str], ) -> Optional[Union[CompletionList, Sequence[CompletionItem]]]: items = [] + markdown_kind = ls.completion_item_document_markup( + MarkupKind.Markdown, MarkupKind.PlainText + ) for cand_key, cand in fields.items(): if cand_key.lower() in seen_fields: continue @@ -409,14 +488,28 @@ def _complete_field_name( value = next(iter(v for v in cand.known_values if v != cand.default_value)) complete_as += value tags = [] + is_deprecated = False if cand.replaced_by or cand.deprecated_with_no_replacement: + is_deprecated = True tags.append(CompletionItemTag.Deprecated) + doc = cand.hover_text + if doc: + doc = MarkupContent( + value=doc, + kind=markdown_kind, + ) + else: + doc = None + items.append( CompletionItem( name, insert_text=complete_as, + deprecated=is_deprecated, tags=tags, + detail=cand.synopsis_doc, + documentation=doc, ) ) return items diff --git a/src/debputy/lsp/lsp_self_check.py b/src/debputy/lsp/lsp_self_check.py index 61a5733..3c7d2e4 100644 --- a/src/debputy/lsp/lsp_self_check.py +++ b/src/debputy/lsp/lsp_self_check.py @@ -83,7 +83,7 @@ def spell_checking() -> bool: ) -def assert_can_start_lsp(): +def assert_can_start_lsp() -> None: for self_check in LSP_CHECKS: if self_check.is_mandatory and not self_check.test(): _error( diff --git a/src/debputy/lsp/quickfixes.py b/src/debputy/lsp/quickfixes.py index d911961..2d564f4 100644 --- a/src/debputy/lsp/quickfixes.py +++ b/src/debputy/lsp/quickfixes.py @@ -17,7 +17,6 @@ from lsprotocol.types import ( Command, CodeActionParams, Diagnostic, - CodeActionDisabledType, TextEdit, WorkspaceEdit, TextDocumentEdit, @@ -30,7 +29,10 @@ from lsprotocol.types import ( from debputy.util import _warn try: - from debian._deb822_repro.locatable import Position as TEPosition, Range as TERange + from debputy.lsp.vendoring._deb822_repro.locatable import ( + Position as TEPosition, + Range as TERange, + ) from pygls.server import LanguageServer from pygls.workspace import TextDocument @@ -38,7 +40,11 @@ except ImportError: pass -CodeActionName = Literal["correct-text", "remove-line"] +CodeActionName = Literal[ + "correct-text", + "remove-line", + "insert-text-on-line-after-diagnostic", +] class CorrectTextCodeAction(TypedDict): @@ -46,6 +52,11 @@ class CorrectTextCodeAction(TypedDict): correct_value: str +class InsertTextOnLineAfterDiagnosticCodeAction(TypedDict): + code_action: Literal["insert-text-on-line-after-diagnostic"] + text_to_insert: str + + class RemoveLineCodeAction(TypedDict): code_action: Literal["remove-line"] @@ -57,6 +68,15 @@ def propose_correct_text_quick_fix(correct_value: str) -> CorrectTextCodeAction: } +def propose_insert_text_on_line_after_diagnostic_quick_fix( + text_to_insert: str, +) -> InsertTextOnLineAfterDiagnosticCodeAction: + return { + "code_action": "insert-text-on-line-after-diagnostic", + "text_to_insert": text_to_insert, + } + + def propose_remove_line_quick_fix() -> RemoveLineCodeAction: return { "code_action": "remove-line", @@ -93,24 +113,64 @@ def _correct_value_code_action( diagnostic: Diagnostic, ) -> Iterable[Union[CodeAction, Command]]: corrected_value = code_action_data["correct_value"] - edits = [ - TextEdit( - diagnostic.range, - corrected_value, - ), - ] + edit = TextEdit( + diagnostic.range, + corrected_value, + ) yield CodeAction( title=f'Replace with "{corrected_value}"', kind=CodeActionKind.QuickFix, diagnostics=[diagnostic], edit=WorkspaceEdit( - changes={code_action_params.text_document.uri: edits}, + changes={code_action_params.text_document.uri: [edit]}, + document_changes=[ + TextDocumentEdit( + text_document=OptionalVersionedTextDocumentIdentifier( + uri=code_action_params.text_document.uri, + ), + edits=[edit], + ) + ], + ), + ) + + +@_code_handler_for("insert-text-on-line-after-diagnostic") +def _correct_value_code_action( + code_action_data: InsertTextOnLineAfterDiagnosticCodeAction, + code_action_params: CodeActionParams, + diagnostic: Diagnostic, +) -> Iterable[Union[CodeAction, Command]]: + corrected_value = code_action_data["text_to_insert"] + line_no = diagnostic.range.end.line + if diagnostic.range.end.character > 0: + line_no += 1 + insert_range = Range( + Position( + line_no, + 0, + ), + Position( + line_no, + 0, + ), + ) + edit = TextEdit( + insert_range, + corrected_value, + ) + yield CodeAction( + title=f'Insert "{corrected_value}"', + kind=CodeActionKind.QuickFix, + diagnostics=[diagnostic], + edit=WorkspaceEdit( + changes={code_action_params.text_document.uri: [edit]}, document_changes=[ TextDocumentEdit( text_document=OptionalVersionedTextDocumentIdentifier( uri=code_action_params.text_document.uri, ), - edits=edits, + edits=[edit], ) ], ), @@ -126,7 +186,7 @@ def range_compatible_with_remove_line_fix(range_: Range) -> bool: @_code_handler_for("remove-line") -def _correct_value_code_action( +def _remove_line_code_action( _code_action_data: RemoveLineCodeAction, code_action_params: CodeActionParams, diagnostic: Diagnostic, @@ -138,33 +198,31 @@ def _correct_value_code_action( ) return - edits = [ - TextEdit( - Range( - start=Position( - line=start.line, - character=0, - ), - end=Position( - line=start.line + 1, - character=0, - ), + edit = TextEdit( + Range( + start=Position( + line=start.line, + character=0, + ), + end=Position( + line=start.line + 1, + character=0, ), - "", ), - ] + "", + ) yield CodeAction( title="Remove the line", kind=CodeActionKind.QuickFix, diagnostics=[diagnostic], edit=WorkspaceEdit( - changes={code_action_params.text_document.uri: edits}, + changes={code_action_params.text_document.uri: [edit]}, document_changes=[ TextDocumentEdit( text_document=OptionalVersionedTextDocumentIdentifier( uri=code_action_params.text_document.uri, ), - edits=edits, + edits=[edit], ) ], ), @@ -174,7 +232,7 @@ def _correct_value_code_action( def provide_standard_quickfixes_from_diagnostics( code_action_params: CodeActionParams, ) -> Optional[List[Union[Command, CodeAction]]]: - actions = [] + actions: List[Union[Command, CodeAction]] = [] for diagnostic in code_action_params.context.diagnostics: data = diagnostic.data if not isinstance(data, list): diff --git a/src/debputy/lsp/text_util.py b/src/debputy/lsp/text_util.py index d66cb28..ef4cd0a 100644 --- a/src/debputy/lsp/text_util.py +++ b/src/debputy/lsp/text_util.py @@ -1,4 +1,4 @@ -from typing import List, Optional, Sequence, Union, Iterable +from typing import List, Optional, Sequence, Union, Iterable, TYPE_CHECKING from lsprotocol.types import ( TextEdit, @@ -10,15 +10,22 @@ from lsprotocol.types import ( from debputy.linting.lint_util import LinterPositionCodec try: - from debian._deb822_repro.locatable import Position as TEPosition, Range as TERange + from debputy.lsp.vendoring._deb822_repro.locatable import ( + Position as TEPosition, + Range as TERange, + ) except ImportError: pass try: - from pygls.workspace import LanguageServer, TextDocument, PositionCodec + from pygls.server import LanguageServer + from pygls.workspace import TextDocument, PositionCodec +except ImportError: + pass +if TYPE_CHECKING: LintCapablePositionCodec = Union[LinterPositionCodec, PositionCodec] -except ImportError: +else: LintCapablePositionCodec = LinterPositionCodec diff --git a/src/debputy/lsp/vendoring/_deb822_repro/parsing.py b/src/debputy/lsp/vendoring/_deb822_repro/parsing.py index e2c638a..c5753e2 100644 --- a/src/debputy/lsp/vendoring/_deb822_repro/parsing.py +++ b/src/debputy/lsp/vendoring/_deb822_repro/parsing.py @@ -280,6 +280,9 @@ class Deb822ParsedTokenList( # type: () -> Iterator[VE] yield from (v for v in self._token_list if isinstance(v, self._vtype)) + def iter_parts(self) -> Iterable[TokenOrElement]: + yield from self._token_list + def _mark_changed(self): # type: () -> None self._changed = True @@ -1082,6 +1085,14 @@ class Deb822Element(Locatable): return False @property + def is_whitespace(self) -> bool: + return False + + @property + def is_separator(self) -> bool: + return False + + @property def parent_element(self): # type: () -> Optional[Deb822Element] return resolve_ref(self._parent_element) @@ -1492,6 +1503,20 @@ class Deb822KeyValuePairElement(Deb822Element): yield self._separator_token yield self._value_element + def key_position_in_stanza(self) -> Position: + position = super().position_in_parent(skip_leading_comments=False) + if self._comment_element: + field_pos = self._field_token.position_in_parent() + position = field_pos.relative_to(position) + return position + + def value_position_in_stanza(self) -> Position: + position = super().position_in_parent(skip_leading_comments=False) + if self._comment_element: + value_pos = self._value_element.position_in_parent() + position = value_pos.relative_to(position) + return position + def position_in_parent( self, *, diff --git a/src/debputy/lsp/vendoring/_deb822_repro/tokens.py b/src/debputy/lsp/vendoring/_deb822_repro/tokens.py index 6697a2c..88d2058 100644 --- a/src/debputy/lsp/vendoring/_deb822_repro/tokens.py +++ b/src/debputy/lsp/vendoring/_deb822_repro/tokens.py @@ -161,6 +161,10 @@ class Deb822Token(Locatable): return False @property + def is_separator(self) -> bool: + return False + + @property def text(self): # type: () -> str return self._text @@ -253,6 +257,10 @@ class Deb822SpaceSeparatorToken(Deb822SemanticallySignificantWhiteSpace): __slots__ = () + @property + def is_separator(self) -> bool: + return True + class Deb822ErrorToken(Deb822Token): """Token that represents a syntactical error""" @@ -296,8 +304,12 @@ class Deb822SeparatorToken(Deb822Token): __slots__ = () + @property + def is_separator(self) -> bool: + return True + -class Deb822FieldSeparatorToken(Deb822Token): +class Deb822FieldSeparatorToken(Deb822SeparatorToken): __slots__ = () diff --git a/src/debputy/path_matcher.py b/src/debputy/path_matcher.py index 47e5c91..2917b14 100644 --- a/src/debputy/path_matcher.py +++ b/src/debputy/path_matcher.py @@ -229,7 +229,9 @@ class MatchAnything(MatchRule): def _full_pattern(self) -> str: return "**/*" - def finditer(self, fs_root: VP, *, ignore_paths=None) -> Iterable[VP]: + def finditer( + self, fs_root: VP, *, ignore_paths: Optional[Callable[[VP], bool]] = None + ) -> Iterable[VP]: if ignore_paths is not None: yield from (p for p in fs_root.all_paths() if not ignore_paths(p)) yield from fs_root.all_paths() @@ -253,7 +255,9 @@ class ExactFileSystemPath(MatchRule): def _full_pattern(self) -> str: return self._path - def finditer(self, fs_root: VP, *, ignore_paths=None) -> Iterable[VP]: + def finditer( + self, fs_root: VP, *, ignore_paths: Optional[Callable[[VP], bool]] = None + ) -> Iterable[VP]: p = _lookup_path(fs_root, self._path) if p is not None and (ignore_paths is None or not ignore_paths(p)): yield p @@ -376,7 +380,12 @@ class BasenameGlobMatch(MatchRule): return f"{self._directory}/{maybe_recursive}{self._basename_glob}" return self._basename_glob - def finditer(self, fs_root: VP, *, ignore_paths=None) -> Iterable[VP]: + def finditer( + self, + fs_root: VP, + *, + ignore_paths: Optional[Callable[[VP], bool]] = None, + ) -> Iterable[VP]: search_root = fs_root if self._directory is not None: p = _lookup_path(fs_root, self._directory) @@ -466,7 +475,12 @@ class GenericGlobImplementation(MatchRule): def _full_pattern(self) -> str: return self._glob_pattern - def finditer(self, fs_root: VP, *, ignore_paths=None) -> Iterable[VP]: + def finditer( + self, + fs_root: VP, + *, + ignore_paths: Optional[Callable[[VP], bool]] = None, + ) -> Iterable[VP]: search_history = [fs_root] for part in self._match_parts: next_layer = itertools.chain.from_iterable( diff --git a/src/debputy/plugin/api/impl_types.py b/src/debputy/plugin/api/impl_types.py index 5aca980..9075ac6 100644 --- a/src/debputy/plugin/api/impl_types.py +++ b/src/debputy/plugin/api/impl_types.py @@ -420,7 +420,7 @@ class DispatchingParserBase(Generic[TP]): def _add_parser( self, - keyword: Union[str, List[str]], + keyword: Union[str, Iterable[str]], ppp: "PluginProvidedParser[PF, TP]", ) -> None: ks = [keyword] if isinstance(keyword, str) else keyword diff --git a/src/debputy/plugin/api/spec.py b/src/debputy/plugin/api/spec.py index dba4523..07954e6 100644 --- a/src/debputy/plugin/api/spec.py +++ b/src/debputy/plugin/api/spec.py @@ -1046,7 +1046,7 @@ class VirtualPath: self, *, byte_io: Literal[False] = False, - buffering: Optional[int] = ..., + buffering: int = -1, ) -> TextIO: ... @overload @@ -1054,7 +1054,7 @@ class VirtualPath: self, *, byte_io: Literal[True], - buffering: Optional[int] = ..., + buffering: int = -1, ) -> BinaryIO: ... @overload @@ -1062,7 +1062,7 @@ class VirtualPath: self, *, byte_io: bool, - buffering: Optional[int] = ..., + buffering: int = -1, ) -> Union[TextIO, BinaryIO]: ... def open( @@ -1085,7 +1085,7 @@ class VirtualPath: :param byte_io: If True, open the file in binary mode (like `rb` for `open`) :param buffering: Same as open(..., buffering=...) where supported. Notably during testing, the content may be purely in memory and use a BytesIO/StringIO - (which does not accept that parameter, but then is buffered in a different way) + (which does not accept that parameter, but then it is buffered in a different way) :return: The file handle. """ diff --git a/src/debputy/plugin/debputy/metadata_detectors.py b/src/debputy/plugin/debputy/metadata_detectors.py index 4338087..e325500 100644 --- a/src/debputy/plugin/debputy/metadata_detectors.py +++ b/src/debputy/plugin/debputy/metadata_detectors.py @@ -520,8 +520,8 @@ def auto_depends_arch_any_solink( if not roots: return - for libdir, target in targets: - final_path = os.path.join(libdir, target) + for libdir_path, target in targets: + final_path = os.path.join(libdir_path, target) matches = [] for opkg, ofs_root in roots: m = ofs_root.lookup(final_path) diff --git a/src/debputy/plugin/debputy/private_api.py b/src/debputy/plugin/debputy/private_api.py index 8428a5f..37c9318 100644 --- a/src/debputy/plugin/debputy/private_api.py +++ b/src/debputy/plugin/debputy/private_api.py @@ -2517,21 +2517,20 @@ def _install_docs_rule_handler( path, package_type="deb", package_attribute="into" ) ] - into = frozenset(into) if install_as is not None: assert len(sources) == 1 assert dest_dir is None return InstallRule.install_doc_as( sources[0], install_as.match_rule.path, - into, + frozenset(into), path.path, condition, ) return InstallRule.install_doc( sources, dest_dir, - into, + frozenset(into), path.path, condition, ) @@ -2622,10 +2621,9 @@ def _install_man_rule_handler( ) ] condition = parsed_data.get("when") - into = frozenset(into) return InstallRule.install_man( sources, - into, + frozenset(into), section, language, attribute_path.path, diff --git a/src/debputy/plugin/debputy/strip_non_determinism.py b/src/debputy/plugin/debputy/strip_non_determinism.py index 2f8fd39..a94d348 100644 --- a/src/debputy/plugin/debputy/strip_non_determinism.py +++ b/src/debputy/plugin/debputy/strip_non_determinism.py @@ -70,10 +70,10 @@ class ExtensionPlusFileOutputRule(SndDetectionRule): def file_output_verdict( self, path: VirtualPath, - file_analysis: str, + file_analysis: Optional[str], ) -> bool: file_pattern = self.file_pattern - assert file_pattern is not None + assert file_pattern is not None and file_analysis is not None m = file_pattern.search(file_analysis) return m is not None diff --git a/src/debputy/util.py b/src/debputy/util.py index 4da2772..d8cfd67 100644 --- a/src/debputy/util.py +++ b/src/debputy/util.py @@ -70,8 +70,8 @@ _DOUBLE_ESCAPEES = re.compile(r'([\n`$"\\])') _REGULAR_ESCAPEES = re.compile(r'([\s!"$()*+#;<>?@\[\]\\`|~])') _PROFILE_GROUP_SPLIT = re.compile(r">\s+<") _DEFAULT_LOGGER: Optional[logging.Logger] = None -_STDOUT_HANDLER: Optional[logging.StreamHandler] = None -_STDERR_HANDLER: Optional[logging.StreamHandler] = None +_STDOUT_HANDLER: Optional[logging.StreamHandler[Any]] = None +_STDERR_HANDLER: Optional[logging.StreamHandler[Any]] = None def assume_not_none(x: Optional[T]) -> T: @@ -764,14 +764,14 @@ def setup_logging( ) logger = logging.getLogger() if existing_stdout_handler is not None: - logger.removeHandler(existing_stderr_handler) + logger.removeHandler(existing_stdout_handler) _STDERR_HANDLER = stderr_handler logger.addHandler(stderr_handler) else: stderr_handler = logging.StreamHandler(sys.stderr) stderr_handler.setFormatter(logging.Formatter(colorless_format, style="{")) logger = logging.getLogger() - if existing_stdout_handler is not None: + if existing_stderr_handler is not None: logger.removeHandler(existing_stderr_handler) _STDERR_HANDLER = stderr_handler logger.addHandler(stderr_handler) diff --git a/src/debputy/yaml/compat.py b/src/debputy/yaml/compat.py index f26af02..f36fc5a 100644 --- a/src/debputy/yaml/compat.py +++ b/src/debputy/yaml/compat.py @@ -10,10 +10,10 @@ __all__ = [ ] try: - from ruyaml import YAMLError, YAML, Node + from ruyaml import YAML, Node from ruyaml.comments import LineCol, CommentedBase, CommentedMap, CommentedSeq - from ruyaml.error import MarkedYAMLError + from ruyaml.error import YAMLError, MarkedYAMLError except (ImportError, ModuleNotFoundError): - from ruamel.yaml import YAMLError, YAML, Node - from ruamel.yaml.comments import LineCol, CommentedBase, CommentedMap, CommentedSeq - from ruamel.yaml.error import MarkedYAMLError + from ruamel.yaml import YAML, Node # type: ignore + from ruamel.yaml.comments import LineCol, CommentedBase, CommentedMap, CommentedSeq # type: ignore + from ruamel.yaml.error import YAMLError, MarkedYAMLError # type: ignore |