diff options
Diffstat (limited to 'src')
-rw-r--r-- | src/debputy/commands/debputy_cmd/context.py | 93 | ||||
-rw-r--r-- | src/debputy/commands/debputy_cmd/lint_and_lsp_cmds.py | 64 | ||||
-rw-r--r-- | src/debputy/linting/lint_impl.py | 79 | ||||
-rw-r--r-- | src/debputy/linting/lint_util.py | 63 | ||||
-rw-r--r-- | src/debputy/lsp/debputy_ls.py | 179 | ||||
-rw-r--r-- | src/debputy/lsp/lsp_debian_changelog.py | 80 | ||||
-rw-r--r-- | src/debputy/lsp/lsp_debian_control.py | 230 | ||||
-rw-r--r-- | src/debputy/lsp/lsp_debian_control_reference_data.py | 567 | ||||
-rw-r--r-- | src/debputy/lsp/lsp_debian_copyright.py | 9 | ||||
-rw-r--r-- | src/debputy/lsp/lsp_debian_debputy_manifest.py | 106 | ||||
-rw-r--r-- | src/debputy/lsp/lsp_debian_rules.py | 25 | ||||
-rw-r--r-- | src/debputy/lsp/lsp_debian_tests_control.py | 9 | ||||
-rw-r--r-- | src/debputy/lsp/lsp_dispatch.py | 22 | ||||
-rw-r--r-- | src/debputy/lsp/lsp_features.py | 60 | ||||
-rw-r--r-- | src/debputy/lsp/lsp_generic_deb822.py | 124 | ||||
-rw-r--r-- | src/debputy/lsp/lsp_self_check.py | 91 | ||||
-rw-r--r-- | src/debputy/packages.py | 186 |
17 files changed, 1588 insertions, 399 deletions
diff --git a/src/debputy/commands/debputy_cmd/context.py b/src/debputy/commands/debputy_cmd/context.py index 47f65f3..4d28408 100644 --- a/src/debputy/commands/debputy_cmd/context.py +++ b/src/debputy/commands/debputy_cmd/context.py @@ -27,7 +27,11 @@ from debputy.exceptions import DebputyRuntimeError from debputy.filesystem_scan import FSROOverlay from debputy.highlevel_manifest import HighLevelManifest from debputy.highlevel_manifest_parser import YAMLManifestParser -from debputy.packages import SourcePackage, BinaryPackage, parse_source_debian_control +from debputy.packages import ( + SourcePackage, + BinaryPackage, + DctrlParser, +) from debputy.plugin.api import VirtualPath from debputy.plugin.api.impl import load_plugin_features from debputy.plugin.api.feature_set import PluginProvidedFeatureSet @@ -94,11 +98,9 @@ class CommandContext: self._substitution: Optional[Substitution] = None self._requested_plugins: Optional[Sequence[str]] = None self._plugins_loaded = False + self._dctrl_parser: Optional[DctrlParser] = None self._dctrl_data: Optional[ Tuple[ - DpkgArchitectureBuildProcessValuesTable, - DpkgArchTable, - DebBuildOptionsAndProfiles, "SourcePackage", Mapping[str, "BinaryPackage"], ] @@ -117,12 +119,32 @@ class CommandContext: ) return self._mtime + @property + def dctrl_parser(self) -> DctrlParser: + parser = self._dctrl_parser + if parser is None: + packages: Union[Set[str], FrozenSet[str]] = frozenset() + if hasattr(self.parsed_args, "packages"): + packages = self.parsed_args.packages + + parser = DctrlParser( + packages, # -p/--package + set(), # -N/--no-package + False, # -i + False, # -a + build_env=DebBuildOptionsAndProfiles.instance(), + dpkg_architecture_variables=dpkg_architecture_table(), + dpkg_arch_query_table=DpkgArchTable.load_arch_table(), + ) + self._dctrl_parser = parser + return parser + def source_package(self) -> SourcePackage: - _a, _b, _c, source, _d = self._parse_dctrl() + source, _ = self._parse_dctrl() return source def binary_packages(self) -> Mapping[str, "BinaryPackage"]: - _a, _b, _c, _source, binary_package_table = self._parse_dctrl() + _, binary_package_table = self._parse_dctrl() return binary_package_table def requested_plugins(self) -> Sequence[str]: @@ -135,8 +157,7 @@ class CommandContext: @property def deb_build_options_and_profiles(self) -> "DebBuildOptionsAndProfiles": - _a, _b, deb_build_options_and_profiles, _c, _d = self._parse_dctrl() - return deb_build_options_and_profiles + return self.dctrl_parser.build_env @property def deb_build_options(self) -> Mapping[str, Optional[str]]: @@ -194,7 +215,7 @@ class CommandContext: yield plugin_name def _resolve_requested_plugins(self) -> Sequence[str]: - _a, _b, _c, source_package, _d = self._parse_dctrl() + source_package, _ = self._parse_dctrl() bd = source_package.fields.get("Build-Depends", "") plugins = list(self._plugin_from_dependency_field(bd)) for field_name in ("Build-Depends-Arch", "Build-Depends-Indep"): @@ -228,21 +249,10 @@ class CommandContext: def _parse_dctrl( self, ) -> Tuple[ - DpkgArchitectureBuildProcessValuesTable, - DpkgArchTable, - DebBuildOptionsAndProfiles, "SourcePackage", Mapping[str, "BinaryPackage"], ]: if self._dctrl_data is None: - build_env = DebBuildOptionsAndProfiles.instance() - dpkg_architecture_variables = dpkg_architecture_table() - dpkg_arch_query_table = DpkgArchTable.load_arch_table() - - packages: Union[Set[str], FrozenSet[str]] = frozenset() - if hasattr(self.parsed_args, "packages"): - packages = self.parsed_args.packages - try: debian_control = self.debian_dir.get("control") if debian_control is None: @@ -251,17 +261,12 @@ class CommandContext: os.strerror(errno.ENOENT), os.path.join(self.debian_dir.fs_path, "control"), ) - source_package, binary_packages = parse_source_debian_control( - debian_control, - packages, # -p/--package - set(), # -N/--no-package - False, # -i - False, # -a - dpkg_architecture_variables=dpkg_architecture_variables, - dpkg_arch_query_table=dpkg_arch_query_table, - build_env=build_env, - ) - assert packages <= binary_packages.keys() + with debian_control.open() as fd: + source_package, binary_packages = ( + self.dctrl_parser.parse_source_debian_control( + fd, + ) + ) except FileNotFoundError: # We are not using `must_be_called_in_source_root`, because we (in this case) require # the file to be readable (that is, parse_source_debian_control can also raise a @@ -271,9 +276,6 @@ class CommandContext: ) self._dctrl_data = ( - dpkg_architecture_variables, - dpkg_arch_query_table, - build_env, source_package, binary_packages, ) @@ -291,14 +293,9 @@ class CommandContext: manifest_path: Optional[str] = None, ) -> YAMLManifestParser: substitution = self.substitution + dctrl_parser = self.dctrl_parser - ( - dpkg_architecture_variables, - dpkg_arch_query_table, - build_env, - source_package, - binary_packages, - ) = self._parse_dctrl() + source_package, binary_packages = self._parse_dctrl() if self.parsed_args.debputy_manifest is not None: manifest_path = self.parsed_args.debputy_manifest @@ -309,9 +306,9 @@ class CommandContext: source_package, binary_packages, substitution, - dpkg_architecture_variables, - dpkg_arch_query_table, - build_env, + dctrl_parser.dpkg_architecture_variables, + dctrl_parser.dpkg_arch_query_table, + dctrl_parser.build_env, self.load_plugins(), debian_dir=self.debian_dir, ) @@ -324,14 +321,6 @@ class CommandContext: substitution = self.substitution manifest_required = False - ( - dpkg_architecture_variables, - dpkg_arch_query_table, - build_env, - _, - binary_packages, - ) = self._parse_dctrl() - if self.parsed_args.debputy_manifest is not None: manifest_path = self.parsed_args.debputy_manifest manifest_required = True 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 35b5f6a..3eecb14 100644 --- a/src/debputy/commands/debputy_cmd/lint_and_lsp_cmds.py +++ b/src/debputy/commands/debputy_cmd/lint_and_lsp_cmds.py @@ -73,6 +73,33 @@ _EDITOR_SNIPPETS = { # nmap <leader>d <plug>(YCMHover) """ ), + "vim+vim9lsp": textwrap.dedent( + """\ + # debputy lsp server glue for vim with vim9 lsp. Add to ~/.vimrc + # + # Requires https://github.com/yegappan/lsp to be in your packages path + + vim9script + + # Make vim recognize debputy.manifest as YAML file + autocmd BufNewFile,BufRead debputy.manifest setfiletype yaml + + packadd! lsp + + final lspServers: list<dict<any>> = [] + + if executable('debputy') + lspServers->add({ + filetype: ['debcontrol', 'debcopyright', 'debchangelog', 'make', 'yaml'], + path: 'debputy', + args: ['lsp', 'server'] + }) + endif + + autocmd User LspSetup g:LspOptionsSet({semanticHighlight: true}) + autocmd User LspSetup g:LspAddServer(lspServers) + """ + ), } @@ -114,25 +141,21 @@ lsp_command = ROOT_COMMAND.add_dispatching_subcommand( def lsp_server_cmd(context: CommandContext) -> None: parsed_args = context.parsed_args - try: - import lsprotocol - import pygls - except ImportError: - _error( - "This feature requires lsprotocol and pygls (apt-get install python3-lsprotocol python3-pygls)" - ) - feature_set = context.load_plugins() + from debputy.lsp.lsp_self_check import assert_can_start_lsp + + assert_can_start_lsp() + from debputy.lsp.lsp_features import ( ensure_lsp_features_are_loaded, - lsp_set_plugin_features, ) from debputy.lsp.lsp_dispatch import DEBPUTY_LANGUAGE_SERVER - lsp_set_plugin_features(feature_set) ensure_lsp_features_are_loaded() debputy_language_server = DEBPUTY_LANGUAGE_SERVER + debputy_language_server.plugin_feature_set = feature_set + debputy_language_server.dctrl_parser = context.dctrl_parser if parsed_args.tcp: debputy_language_server.start_tcp(parsed_args.host, parsed_args.port) @@ -181,18 +204,15 @@ def lsp_editor_glue(context: CommandContext) -> None: "features", help_description="Describe language ids and features", ) -def lsp_editor_glue(_context: CommandContext) -> None: - try: - import lsprotocol - import pygls - except ImportError: - _error( - "This feature requires lsprotocol and pygls (apt-get install python3-lsprotocol python3-pygls)" - ) +def lsp_describe_features(context: CommandContext) -> None: + + from debputy.lsp.lsp_self_check import assert_can_start_lsp + + assert_can_start_lsp() from debputy.lsp.lsp_features import describe_lsp_features - describe_lsp_features() + describe_lsp_features(context) @ROOT_COMMAND.register_subcommand( @@ -231,12 +251,6 @@ def lint_cmd(context: CommandContext) -> None: from debputy.linting.lint_impl import perform_linting context.must_be_called_in_source_root() - feature_set = context.load_plugins() - - from debputy.lsp.lsp_features import lsp_set_plugin_features - - lsp_set_plugin_features(feature_set) - perform_linting(context) diff --git a/src/debputy/linting/lint_impl.py b/src/debputy/linting/lint_impl.py index 058b784..a6f493e 100644 --- a/src/debputy/linting/lint_impl.py +++ b/src/debputy/linting/lint_impl.py @@ -1,7 +1,8 @@ +import dataclasses import os import stat import sys -from typing import Optional, List, Union, NoReturn +from typing import Optional, List, Union, NoReturn, Mapping from lsprotocol.types import ( CodeAction, @@ -17,10 +18,10 @@ from lsprotocol.types import ( from debputy.commands.debputy_cmd.context import CommandContext from debputy.commands.debputy_cmd.output import _output_styling, OutputStylingBase from debputy.linting.lint_util import ( - LINTER_POSITION_CODEC, report_diagnostic, LinterImpl, LintReport, + LintStateImpl, ) from debputy.lsp.lsp_debian_changelog import _lint_debian_changelog from debputy.lsp.lsp_debian_control import _lint_debian_control @@ -35,6 +36,8 @@ from debputy.lsp.text_edit import ( merge_sort_text_edits, apply_text_edits, ) +from debputy.packages import SourcePackage, BinaryPackage +from debputy.plugin.api.feature_set import PluginProvidedFeatureSet from debputy.util import _warn, _error, _info LINTER_FORMATS = { @@ -47,6 +50,37 @@ LINTER_FORMATS = { } +@dataclasses.dataclass(slots=True) +class LintContext: + plugin_feature_set: PluginProvidedFeatureSet + source_package: Optional[SourcePackage] = None + binary_packages: Optional[Mapping[str, BinaryPackage]] = None + + def state_for(self, path, lines) -> LintStateImpl: + return LintStateImpl( + self.plugin_feature_set, + path, + lines, + self.source_package, + self.binary_packages, + ) + + +def gather_lint_info(context: CommandContext) -> LintContext: + lint_context = LintContext(context.load_plugins()) + try: + with open("debian/control") as fd: + source_package, binary_packages = ( + context.dctrl_parser.parse_source_debian_control(fd, ignore_errors=True) + ) + lint_context.source_package = source_package + lint_context.binary_packages = binary_packages + except FileNotFoundError: + pass + + return lint_context + + def perform_linting(context: CommandContext) -> None: parsed_args = context.parsed_args if not parsed_args.spellcheck: @@ -54,12 +88,15 @@ def perform_linting(context: CommandContext) -> None: linter_exit_code = parsed_args.linter_exit_code lint_report = LintReport() fo = _output_styling(context.parsed_args, sys.stdout) + lint_context = gather_lint_info(context) + for name_stem in LINTER_FORMATS: filename = f"./{name_stem}" if not os.path.isfile(filename): continue perform_linting_of_file( fo, + lint_context, filename, name_stem, context.parsed_args.auto_fix, @@ -95,6 +132,7 @@ def _exit_with_lint_code(lint_report: LintReport) -> NoReturn: def perform_linting_of_file( fo: OutputStylingBase, + lint_context: LintContext, filename: str, file_format: str, auto_fixing_enabled: bool, @@ -107,9 +145,23 @@ def perform_linting_of_file( text = fd.read() if auto_fixing_enabled: - _auto_fix_run(fo, filename, text, handler, lint_report) + _auto_fix_run( + fo, + lint_context, + filename, + text, + handler, + lint_report, + ) else: - _diagnostics_run(fo, filename, text, handler, lint_report) + _diagnostics_run( + fo, + lint_context, + filename, + text, + handler, + lint_report, + ) def _edit_happens_before_last_fix( @@ -126,6 +178,7 @@ def _edit_happens_before_last_fix( def _auto_fix_run( fo: OutputStylingBase, + lint_context: LintContext, filename: str, text: str, linter: LinterImpl, @@ -137,7 +190,11 @@ def _auto_fix_run( fixed_count = False too_many_rounds = False lines = text.splitlines(keepends=True) - current_issues = linter(filename, filename, lines, LINTER_POSITION_CODEC) + lint_state = lint_context.state_for( + filename, + lines, + ) + current_issues = linter(lint_state) issue_count_start = len(current_issues) if current_issues else 0 while another_round and current_issues: another_round = False @@ -208,7 +265,8 @@ def _auto_fix_run( True, lint_report, ) - current_issues = linter(filename, filename, lines, LINTER_POSITION_CODEC) + lint_state.lines = lines + current_issues = linter(lint_state) if fixed_count: output_filename = f"{filename}.tmp" @@ -218,9 +276,8 @@ def _auto_fix_run( os.chmod(output_filename, orig_mode) os.rename(output_filename, filename) lines = text.splitlines(keepends=True) - remaining_issues = ( - linter(filename, filename, lines, LINTER_POSITION_CODEC) or [] - ) + lint_state.lines = lines + remaining_issues = linter(lint_state) or [] else: remaining_issues = current_issues or [] @@ -281,13 +338,15 @@ def _auto_fix_run( def _diagnostics_run( fo: OutputStylingBase, + lint_context: LintContext, filename: str, text: str, linter: LinterImpl, lint_report: LintReport, ) -> None: lines = text.splitlines(keepends=True) - issues = linter(filename, filename, lines, LINTER_POSITION_CODEC) or [] + lint_state = lint_context.state_for(filename, lines) + issues = linter(lint_state) or [] for diagnostic in issues: actions = provide_standard_quickfixes_from_diagnostics( CodeActionParams( diff --git a/src/debputy/linting/lint_util.py b/src/debputy/linting/lint_util.py index de74217..8f226fa 100644 --- a/src/debputy/linting/lint_util.py +++ b/src/debputy/linting/lint_util.py @@ -1,14 +1,69 @@ import dataclasses -from typing import List, Optional, Callable, Counter +import os +from typing import List, Optional, Callable, Counter, TYPE_CHECKING, Mapping from lsprotocol.types import Position, Range, Diagnostic, DiagnosticSeverity from debputy.commands.debputy_cmd.output import OutputStylingBase +from debputy.packages import SourcePackage, BinaryPackage +from debputy.plugin.api.feature_set import PluginProvidedFeatureSet from debputy.util import _DEFAULT_LOGGER, _warn -LinterImpl = Callable[ - [str, str, List[str], "LintCapablePositionCodec"], Optional[List[Diagnostic]] -] +if TYPE_CHECKING: + from debputy.lsp.text_util import LintCapablePositionCodec + + +LinterImpl = Callable[["LintState"], Optional[List[Diagnostic]]] + + +class LintState: + + @property + def plugin_feature_set(self) -> PluginProvidedFeatureSet: + raise NotImplementedError + + @property + def doc_uri(self) -> str: + raise NotImplementedError + + @property + def path(self) -> str: + raise NotImplementedError + + @property + def lines(self) -> List[str]: + raise NotImplementedError + + @property + def position_codec(self) -> "LintCapablePositionCodec": + raise NotImplementedError + + @property + def source_package(self) -> Optional[SourcePackage]: + raise NotImplementedError + + @property + def binary_packages(self) -> Optional[Mapping[str, BinaryPackage]]: + raise NotImplementedError + + +@dataclasses.dataclass(slots=True) +class LintStateImpl(LintState): + plugin_feature_set: PluginProvidedFeatureSet = dataclasses.field(repr=False) + path: str + lines: List[str] + source_package: Optional[SourcePackage] + binary_packages: Optional[Mapping[str, BinaryPackage]] + + @property + def doc_uri(self) -> str: + path = self.path + abs_path = os.path.join(os.path.curdir, path) + return f"file://{abs_path}" + + @property + def position_codec(self) -> "LintCapablePositionCodec": + return LINTER_POSITION_CODEC @dataclasses.dataclass(slots=True) diff --git a/src/debputy/lsp/debputy_ls.py b/src/debputy/lsp/debputy_ls.py new file mode 100644 index 0000000..f375992 --- /dev/null +++ b/src/debputy/lsp/debputy_ls.py @@ -0,0 +1,179 @@ +import dataclasses +import os +from typing import Optional, List, Any, Mapping + +from debputy.linting.lint_util import LintState +from debputy.lsp.text_util import LintCapablePositionCodec +from debputy.packages import ( + SourcePackage, + BinaryPackage, + DctrlParser, +) +from debputy.plugin.api.feature_set import PluginProvidedFeatureSet + +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) +class DctrlCache: + doc_uri: str + path: str + is_open_in_editor: Optional[bool] + last_doc_version: Optional[int] + last_mtime: Optional[float] + source_package: Optional[SourcePackage] + binary_packages: Optional[Mapping[str, BinaryPackage]] + + +class LSProvidedLintState(LintState): + def __init__( + self, + ls: "DebputyLanguageServer", + doc: "TextDocument", + debian_dir_path: str, + dctrl_parser: DctrlParser, + ) -> None: + self._ls = ls + self._doc = doc + # Cache lines (doc.lines re-splits everytime) + self._lines = doc.lines + self._dctrl_parser = dctrl_parser + dctrl_file = os.path.join(debian_dir_path, "control") + self._dctrl_cache: DctrlCache = DctrlCache( + from_fs_path(dctrl_file), + dctrl_file, + is_open_in_editor=None, # Unresolved + last_doc_version=None, + last_mtime=None, + source_package=None, + binary_packages=None, + ) + + @property + def plugin_feature_set(self) -> PluginProvidedFeatureSet: + return self._ls.plugin_feature_set + + @property + def doc_uri(self) -> str: + return self._doc.uri + + @property + def path(self) -> str: + return self._doc.path + + @property + def lines(self) -> List[str]: + return self._lines + + @property + def position_codec(self) -> LintCapablePositionCodec: + return self._doc.position_codec + + def _resolve_dctrl(self) -> Optional[DctrlCache]: + dctrl_cache = self._dctrl_cache + doc = self._ls.workspace.text_documents.get(dctrl_cache.doc_uri) + is_open = doc is not None + dctrl_doc = self._ls.workspace.get_text_document(dctrl_cache.doc_uri) + re_parse_lines: Optional[List[str]] = None + if is_open: + 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 + ): + re_parse_lines = doc.lines + + dctrl_cache.last_doc_version = dctrl_doc.version + elif self._doc.uri.startswith("file://"): + try: + with open(dctrl_cache.path) as fd: + st = os.fstat(fd.fileno()) + current_mtime = st.st_mtime + last_mtime = dctrl_cache.last_mtime or current_mtime - 1 + if dctrl_cache.is_open_in_editor or current_mtime > last_mtime: + re_parse_lines = list(fd) + dctrl_cache.last_mtime = current_mtime + except FileNotFoundError: + return None + if re_parse_lines is not None: + source_package, binary_packages = ( + self._dctrl_parser.parse_source_debian_control( + re_parse_lines, + ignore_errors=True, + ) + ) + dctrl_cache.source_package = source_package + dctrl_cache.binary_packages = binary_packages + return dctrl_cache + + @property + def source_package(self) -> Optional[SourcePackage]: + dctrl = self._resolve_dctrl() + return dctrl.source_package if dctrl is not None else None + + @property + def binary_packages(self) -> Optional[Mapping[str, BinaryPackage]]: + dctrl = self._resolve_dctrl() + return dctrl.binary_packages if dctrl is not None else None + + +class DebputyLanguageServer(LanguageServer): + + def __init__( + self, + *args: Any, + **kwargs: Any, + ) -> None: + super().__init__(*args, **kwargs) + self._dctrl_parser: Optional[DctrlParser] = None + self._plugin_feature_set: Optional[PluginProvidedFeatureSet] = None + + @property + def plugin_feature_set(self) -> PluginProvidedFeatureSet: + res = self._plugin_feature_set + if res is None: + raise RuntimeError( + "Initialization error: The plugin feature set has not been initialized before it was needed." + ) + return res + + @plugin_feature_set.setter + def plugin_feature_set(self, plugin_feature_set: PluginProvidedFeatureSet) -> None: + if self._plugin_feature_set is not None: + raise RuntimeError( + "The plugin_feature_set attribute cannot be changed once set" + ) + self._plugin_feature_set = plugin_feature_set + + @property + def dctrl_parser(self) -> DctrlParser: + res = self._dctrl_parser + if res is None: + raise RuntimeError( + "Initialization error: The dctrl_parser has not been initialized before it was needed." + ) + return res + + @dctrl_parser.setter + def dctrl_parser(self, parser: DctrlParser) -> None: + if self._dctrl_parser is not None: + raise RuntimeError("The dctrl_parser attribute cannot be changed once set") + self._dctrl_parser = parser + + def lint_state(self, doc: "TextDocument") -> LintState: + dir_path = os.path.dirname(doc.path) + + while dir_path and dir_path != "/" and os.path.basename(dir_path) != "debian": + dir_path = os.path.dirname(dir_path) + + return LSProvidedLintState(self, doc, dir_path, self.dctrl_parser) diff --git a/src/debputy/lsp/lsp_debian_changelog.py b/src/debputy/lsp/lsp_debian_changelog.py index f99a63b..89604e4 100644 --- a/src/debputy/lsp/lsp_debian_changelog.py +++ b/src/debputy/lsp/lsp_debian_changelog.py @@ -1,3 +1,4 @@ +import re import sys from email.utils import parsedate_to_datetime from typing import ( @@ -21,6 +22,7 @@ from lsprotocol.types import ( DiagnosticSeverity, ) +from debputy.linting.lint_util import LintState from debputy.lsp.lsp_features import lsp_diagnostics, lsp_standard_handler from debputy.lsp.quickfixes import ( propose_correct_text_quick_fix, @@ -35,12 +37,14 @@ try: from pygls.server import LanguageServer from pygls.workspace import TextDocument + from debputy.lsp.debputy_ls import DebputyLanguageServer except ImportError: pass # Same as Lintian _MAXIMUM_WIDTH: int = 82 +_HEADER_LINE = re.compile(r"^(\S+)\s*[(]([^)]+)[)]") # TODO: Add reset _LANGUAGE_IDS = [ "debian/changelog", # emacs's name @@ -84,18 +88,17 @@ lsp_standard_handler(_LANGUAGE_IDS, TEXT_DOCUMENT_WILL_SAVE_WAIT_UNTIL) @lsp_diagnostics(_LANGUAGE_IDS) def _diagnostics_debian_changelog( - ls: "LanguageServer", + ls: "DebputyLanguageServer", params: Union[DidOpenTextDocumentParams, DidChangeTextDocumentParams], ) -> Iterable[List[Diagnostic]]: doc_uri = params.text_document.uri doc = ls.workspace.get_text_document(doc_uri) - lines = doc.lines max_words = 1_000 delta_update_size = 10 max_lines_between_update = 10 + lint_state = ls.lint_state(doc) scanner = _scan_debian_changelog_for_diagnostics( - lines, - doc.position_codec, + lint_state, delta_update_size, max_words, max_lines_between_update, @@ -105,11 +108,12 @@ def _diagnostics_debian_changelog( def _check_footer_line( + lint_state: LintState, line: str, line_no: int, - lines: List[str], - position_codec: LintCapablePositionCodec, ) -> Iterator[Diagnostic]: + lines = lint_state.lines + position_codec = lint_state.position_codec try: end_email_idx = line.rindex("> ") except ValueError: @@ -212,9 +216,46 @@ def _check_footer_line( ) +def _check_header_line( + lint_state: LintState, + line: str, + line_no: int, + entry_no: int, +) -> Iterable[Diagnostic]: + m = _HEADER_LINE.search(line) + if not m: + # Syntax error: TODO flag later + return + position_codec = lint_state.position_codec + source_name, source_version = m.groups() + dctrl_source_pkg = lint_state.source_package + if ( + entry_no == 1 + and dctrl_source_pkg is not None + and dctrl_source_pkg.name != source_name + ): + start_pos, end_pos = m.span(1) + range_server_units = Range( + Position( + line_no, + start_pos, + ), + Position( + line_no, + end_pos, + ), + ) + yield Diagnostic( + position_codec.range_to_client_units(lint_state.lines, range_server_units), + f"The first entry must use the same source name as debian/control." + f' Changelog uses: "{source_name}" while d/control uses: "{dctrl_source_pkg.name}"', + severity=DiagnosticSeverity.Error, + source="debputy", + ) + + def _scan_debian_changelog_for_diagnostics( - lines: List[str], - position_codec: LintCapablePositionCodec, + lint_state: LintState, delta_update_size: int, max_words: int, max_lines_between_update: int, @@ -224,15 +265,28 @@ def _scan_debian_changelog_for_diagnostics( diagnostics = [] diagnostics_at_last_update = 0 lines_since_last_update = 0 + lines = lint_state.lines + position_codec = lint_state.position_codec + entry_no = 0 for line_no, line in enumerate(lines): orig_line = line line = line.rstrip() if not line: continue if line.startswith(" --"): - diagnostics.extend(_check_footer_line(line, line_no, lines, position_codec)) + diagnostics.extend(_check_footer_line(lint_state, line, line_no)) continue if not line.startswith(" "): + if not line[0].isspace(): + entry_no += 1 + diagnostics.extend( + _check_header_line( + lint_state, + line, + line_no, + entry_no, + ) + ) continue # minus 1 for newline orig_line_len = len(orig_line) - 1 @@ -279,15 +333,11 @@ def _scan_debian_changelog_for_diagnostics( def _lint_debian_changelog( - _doc_reference: str, - _path: str, - lines: List[str], - position_codec: LintCapablePositionCodec, + lint_state: LintState, ) -> Optional[List[Diagnostic]]: limits = sys.maxsize scanner = _scan_debian_changelog_for_diagnostics( - lines, - position_codec, + lint_state, limits, limits, limits, diff --git a/src/debputy/lsp/lsp_debian_control.py b/src/debputy/lsp/lsp_debian_control.py index 3dbb115..8c246d8 100644 --- a/src/debputy/lsp/lsp_debian_control.py +++ b/src/debputy/lsp/lsp_debian_control.py @@ -1,3 +1,5 @@ +import re +import textwrap from typing import ( Union, Sequence, @@ -31,6 +33,7 @@ from lsprotocol.types import ( SemanticTokensParams, ) +from debputy.linting.lint_util import LintState from debputy.lsp.lsp_debian_control_reference_data import ( DctrlKnownField, BINARY_FIELDS, @@ -97,7 +100,123 @@ _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. + + 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. + + 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`. + + Defined by: `dpkg-gencontrol` + DH Sequence: <default> + Source: <https://manpages.debian.org/deb-substvars.5> + """ + ), + "${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> + """ + ), + "${misc:Pre-Depends}": textwrap.dedent( + """\ + This is the moral equivalent to `${misc:Depends}` but for `Pre-Depends`. + + Defined by: `debhelper` + DH Sequence: <default> + """ + ), + "${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> + """ + ), + "${gir:Depends}": textwrap.dedent( + """\ + Dependencies related to GObject introspection data. + + 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> + """ + ), + "${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> + """ + ), +} _DCTRL_FILE_METADATA = DctrlFileMetadata() @@ -111,7 +230,90 @@ def _debian_control_hover( ls: "LanguageServer", params: HoverParams, ) -> Optional[Hover]: - return deb822_hover(ls, params, _DCTRL_FILE_METADATA) + return deb822_hover(ls, params, _DCTRL_FILE_METADATA, custom_handler=_custom_hover) + + +def _custom_hover( + server_position: Position, + _current_field: Optional[str], + _word_at_position: str, + known_field: Optional[DctrlKnownField], + in_value: bool, + _doc: "TextDocument", + lines: List[str], +) -> Optional[Union[Hover, str]]: + if not in_value: + return None + + 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: + 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: + pass + + if substvar == "${}" or _SUBSTVAR_RE.fullmatch(substvar): + doc = _SUBSTVARS_DOC.get(substvar) + + if doc is None: + doc = "No documentation for {substvar}." + return f"# Substvar `{substvar}`\n\n{doc}" + + if known_field is None or known_field.name != "Description": + return None + if line[0].isspace(): + return None + try: + col_idx = line.index(":") + except ValueError: + return None + + content = line[col_idx + 1 :].strip() + # Synopsis + return textwrap.dedent( + f"""\ + # Package synopsis + + The synopsis is a single line "noun phrase" description of the package. + It is typically used in search results and other cases where a + user-interface has limited space for text. + + **Example renderings in various terminal UIs**: + ``` + # apt search TERM + package/stable,now 1.0-1 all: + {content} + + # apt-get search TERM + package - {content} + ``` + + Advice for writing synopsis: + * Avoid using the package name. Any software would display the + package name already and it generally does not help the user + understand what they are looking at. + * In many situations, the user will only see the package name + and its synopsis. The synopsis must standalone. + * When writing the synopsis, it is often a good idea to write + it such that it fits into the sentence like "This package + provides [a|an|the] ..." (see below). + + **Phrasing test**: + ``` + This package provides [a|an|the] {content}. + ``` + """ + ) @lsp_completer(_LANGUAGE_IDS) @@ -566,29 +768,13 @@ def _scan_for_syntax_errors_and_token_level_diagnostics( return first_error -def _diagnostics_debian_control( - ls: "LanguageServer", - params: Union[DidOpenTextDocumentParams, DidChangeTextDocumentParams], -) -> None: - doc = ls.workspace.get_text_document(params.text_document.uri) - _info(f"Opened document: {doc.path} ({doc.language_id})") - lines = doc.lines - position_codec: LintCapablePositionCodec = doc.position_codec - - diagnostics = _lint_debian_control(doc.uri, doc.path, lines, position_codec) - ls.publish_diagnostics( - doc.uri, - diagnostics, - ) - - @lint_diagnostics(_LANGUAGE_IDS) def _lint_debian_control( - doc_reference: str, - _path: str, - lines: List[str], - position_codec: LintCapablePositionCodec, + lint_state: LintState, ) -> Optional[List[Diagnostic]]: + lines = lint_state.lines + position_codec = lint_state.position_codec + doc_reference = lint_state.doc_uri diagnostics = [] deb822_file = parse_deb822_file( lines, diff --git a/src/debputy/lsp/lsp_debian_control_reference_data.py b/src/debputy/lsp/lsp_debian_control_reference_data.py index 4237d64..e65ab86 100644 --- a/src/debputy/lsp/lsp_debian_control_reference_data.py +++ b/src/debputy/lsp/lsp_debian_control_reference_data.py @@ -206,11 +206,19 @@ class Keyword: hover_text: Optional[str] = None is_obsolete: bool = False replaced_by: Optional[str] = None + is_exclusive: bool = False + """For keywords in fields that allow multiple keywords, the `is_exclusive` can be + used for keywords that cannot be used with other keywords. As an example, the `all` + value in `Architecture` of `debian/control` cannot be used with any other architecture. + """ def _allowed_values(*values: Union[str, Keyword]) -> Mapping[str, Keyword]: - as_keywords = (k if isinstance(k, Keyword) else Keyword(k) for k in values) - return {k.value: k for k in as_keywords} + as_keywords = [k if isinstance(k, Keyword) else Keyword(k) for k in values] + as_mapping = {k.value: k for k in as_keywords if k.value} + # Simple bug check + assert len(as_keywords) == len(as_mapping) + return as_mapping ALL_SECTIONS = _allowed_values( @@ -279,10 +287,12 @@ ALL_PRIORITIES = _allowed_values( ), ) + def all_architectures_and_wildcards(arch2table) -> Iterable[Union[str, Keyword]]: wildcards = set() yield Keyword( "any", + is_exclusive=True, hover_text=textwrap.dedent( """\ The package is an architecture dependent package and need to be compiled for each and every @@ -295,6 +305,7 @@ def all_architectures_and_wildcards(arch2table) -> Iterable[Union[str, Keyword]] ) yield Keyword( "all", + is_exclusive=True, hover_text=textwrap.dedent( """\ The package is an architecture independent package. This is typically fitting for packages containing @@ -508,6 +519,50 @@ class FieldValueClass(Enum): return self.value[1] +def _unknown_value_check( + field_name: str, + value: str, + known_values: Mapping[str, Keyword], + unknown_value_severity: Optional[DiagnosticSeverity], +) -> Tuple[ + Optional[Keyword], Optional[str], Optional[DiagnosticSeverity], Optional[Any] +]: + known_value = known_values.get(value) + message = None + severity = unknown_value_severity + fix_data = None + if known_value is None: + candidates = detect_possible_typo( + value, + known_values, + ) + if len(known_values) < 5: + values = ", ".join(sorted(known_values)) + hint_text = f" Known values for this field: {values}" + else: + hint_text = "" + fix_data = None + severity = unknown_value_severity + fix_text = hint_text + if candidates: + match = candidates[0] + if len(candidates) == 1: + known_value = known_values[match] + fix_text = ( + f' It is possible that the value is a typo of "{match}".{fix_text}' + ) + fix_data = [propose_correct_text_quick_fix(m) for m in candidates] + elif severity is None: + return None, None, None, None + if severity is None: + severity = DiagnosticSeverity.Warning + # It always has leading whitespace + message = fix_text.strip() + else: + message = f'The value "{value}" is not supported in {field_name}.{fix_text}' + return known_value, message, severity, fix_data + + @dataclasses.dataclass(slots=True, frozen=True) class Deb822KnownField: name: str @@ -620,16 +675,19 @@ class Deb822KnownField: interpreter = self.field_value_class.interpreter() if not allowed_values or interpreter is None: return - hint_text = None values = kvpair.interpret_as(interpreter) value_off = kvpair.value_element.position_in_parent().relative_to( field_position_te ) - first_value = True + first_value = None + first_exclusive_value_ref = None + first_exclusive_value = None + has_emitted_for_exclusive = False + for value_ref in values.iter_value_references(): value = value_ref.value if ( - not first_value + first_value is not None and self.field_value_class == FieldValueClass.SINGLE_VALUE ): value_loc = value_ref.locatable @@ -651,66 +709,95 @@ class Deb822KnownField: ) # TODO: Add quickfix if the value is also invalid continue - first_value = False - known_value = self.known_values.get(value) - if known_value is None: - candidates = detect_possible_typo( + if first_exclusive_value_ref is not None and not has_emitted_for_exclusive: + assert first_exclusive_value is not None + value_loc = first_exclusive_value_ref.locatable + value_range_te = value_loc.range_in_parent().relative_to(value_off) + value_range_in_server_units = te_range_to_lsp(value_range_te) + value_range = position_codec.range_to_client_units( + lines, + value_range_in_server_units, + ) + yield Diagnostic( + value_range, + f'The value "{first_exclusive_value}" cannot be used with other values.', + severity=DiagnosticSeverity.Error, + source="debputy", + ) + + known_value, unknown_value_message, unknown_severity, typo_fix_data = ( + _unknown_value_check( + self.name, value, self.known_values, + unknown_value_severity, ) - if hint_text is None: - if len(self.known_values) < 5: - values = ", ".join(sorted(self.known_values)) - hint_text = f" Known values for this field: {values}" - else: - hint_text = "" - fix_data = None - severity = unknown_value_severity - fix_text = hint_text - if candidates: - match = candidates[0] - fix_text = f' It is possible that the value is a typo of "{match}".{fix_text}' - fix_data = [propose_correct_text_quick_fix(m) for m in candidates] - elif severity is None: - continue - if severity is None: - severity = DiagnosticSeverity.Warning - message = fix_text - else: - message = f'The value "{value}" is not supported in {self.name}.{fix_text}' - elif known_value.is_obsolete: + ) + + issues = [] + + if known_value and known_value.is_exclusive: + first_exclusive_value = known_value.value # In case of typos. + first_exclusive_value_ref = value_ref + if first_value is not None: + has_emitted_for_exclusive = True + issues.append( + { + "message": f'The value "{known_value.value}" cannot be used with other values.', + "severity": DiagnosticSeverity.Error, + "source": "debputy", + } + ) + + if first_value is None: + first_value = value + + if unknown_value_message is not None: + assert unknown_severity is not None + issues.append( + { + "message": unknown_value_message, + "severity": unknown_severity, + "source": "debputy", + "data": typo_fix_data, + } + ) + + if known_value is not None and known_value.is_obsolete: replacement = known_value.replaced_by if replacement is not None: - message = f'The value "{value}" has been replaced by {replacement}' - severity = DiagnosticSeverity.Warning - fix_data = [propose_correct_text_quick_fix(replacement)] + obsolete_value_message = ( + f'The value "{value}" has been replaced by {replacement}' + ) + obsolete_severity = DiagnosticSeverity.Warning + obsolete_fix_data = [propose_correct_text_quick_fix(replacement)] else: - message = ( + obsolete_value_message = ( f'The value "{value}" is obsolete without a single replacement' ) - severity = DiagnosticSeverity.Warning - fix_data = None - else: - # All good + obsolete_severity = DiagnosticSeverity.Warning + obsolete_fix_data = None + issues.append( + { + "message": obsolete_value_message, + "severity": obsolete_severity, + "source": "debputy", + "data": obsolete_fix_data, + } + ) + + if not issues: continue value_loc = value_ref.locatable - value_position_te = value_loc.position_in_parent().relative_to(value_off) - value_range_in_server_units = te_range_to_lsp( - TERange.from_position_and_size(value_position_te, value_loc.size()) - ) + value_range_te = value_loc.range_in_parent().relative_to(value_off) + value_range_in_server_units = te_range_to_lsp(value_range_te) value_range = position_codec.range_to_client_units( lines, value_range_in_server_units, ) - yield Diagnostic( - value_range, - message, - severity=severity, - source="debputy", - data=fix_data, - ) + yield from (Diagnostic(value_range, **issue_data) for issue_data in issues) @dataclasses.dataclass(slots=True, frozen=True) @@ -1073,17 +1160,19 @@ SOURCE_FIELDS = _fields( known_values=_allowed_values( Keyword( "no", + is_exclusive=True, hover_text=textwrap.dedent( """\ The build process will not require root or fakeroot during any step. This enables - dpkg-buildpackage and debhelper to perform several optimizations during the build. + dpkg-buildpackage, debhelper or/and `debputy` to perform several optimizations during the build. This is the default with dpkg-build-api at version 1 or later. """ ), ), Keyword( - "no", + "binary-targets", + is_exclusive=True, hover_text=textwrap.dedent( """\ The build process assumes that dpkg-buildpackage will run the relevant binary @@ -1093,6 +1182,19 @@ SOURCE_FIELDS = _fields( """ ), ), + Keyword( + "debputy/deb-assembly", + hover_text=textwrap.dedent( + """\ + When using `debputy`, `debputy` is expected to use root or fakeroot when assembling + a .deb or .udeb, where it is required to use `dpkg-deb`. + + Note: The `debputy` can always use `no` instead by falling back to an internal + assembly method instead for .deb or .udebs that would need root or fakeroot with + `dpkg-deb`. + """ + ), + ), ), hover_text=textwrap.dedent( """\ @@ -1997,7 +2099,6 @@ BINARY_FIELDS = _fields( """\ A human-readable description of the package. This field consists of two related but distinct parts. - The first line immediately after the field is called the *Synopsis* and is a short "noun-phrase" intended to provide a one-line summary of the package. The lines after the **Synopsis** is known as the **Extended Description** and is intended as a longer summary of the package. @@ -2029,6 +2130,9 @@ BINARY_FIELDS = _fields( how it relates to the rest of the system (in terms of, for example, which subsystem it is which part of). Please see <https://www.debian.org/doc/debian-policy/ch-controlfields.html#description> for more details about the description field and suggestions for how to write it. + + Note: The synopsis part has its own hover doc that is specialized at aiding with writing and checking + the synopsis. """ ), ), @@ -2082,34 +2186,180 @@ _DEP5_HEADER_FIELDS = _fields( FieldValueClass.SINGLE_VALUE, is_stanza_name=True, missing_field_severity=DiagnosticSeverity.Error, + hover_text=textwrap.dedent( + """\ + URI of the format specification. The field that should be used for the current version of this + document is: + + **Example**: + ``` + Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ + ``` + + The original version of this specification used the non-https version of this URL as its URI, namely: + + ``` + Format: http://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ + ``` + + Both versions are valid and refer to the same specification, and parsers should interpret both as + referencing the same format. The https URI is preferred. + + The value must be on a single line (that is, on same line as the field). + """ + ), ), Deb822KnownField( "Upstream-Name", FieldValueClass.FREE_TEXT_FIELD, + hover_text=textwrap.dedent( + """\ + The name upstream uses for the software + + The value must be on a single line (that is, on same line as the field). + """ + ), ), Deb822KnownField( "Upstream-Contact", FieldValueClass.FREE_TEXT_FIELD, + hover_text=textwrap.dedent( + """\ + The preferred address(es) to reach the upstream project. May be free-form text, but by convention will + usually be written as a list of RFC5322 addresses or URIs. + + The value should be written as a line-based list (one value per line). + """ + ), ), Deb822KnownField( "Source", FieldValueClass.FREE_TEXT_FIELD, + hover_text=textwrap.dedent( + """\ + An explanation of where the upstream source came from. Typically this would be a URL, but it might be + a free-form explanation. The [Debian Policy section 12.5] requires this information unless there are + no upstream sources, which is mainly the case for native Debian packages. If the upstream source has + been modified to remove non-free parts, that should be explained in this field. + + The value should be written as "Formatted text" without no synopsis (when it is a free-form explanation). + The "Formatted text" is similar to the extended description (the `Description` from `debian/control`). + + [Debian Policy section 12.5]: https://www.debian.org/doc/debian-policy/ch-docs#s-copyrightfile + """ + ), ), Deb822KnownField( "Disclaimer", FieldValueClass.FREE_TEXT_FIELD, spellcheck_value=True, + hover_text=textwrap.dedent( + """\ + For `non-free`, `non-free-firmware` or `contrib` packages, this field is used to that they are not part + of Debian and to explain why (see [Debian Policy section 12.5]) + + The value should be written as "Formatted text" without no synopsis. The "Formatted text" is similar + to the extended description (the `Description` from `debian/control`). + + [Debian Policy section 12.5]: https://www.debian.org/doc/debian-policy/ch-docs#s-copyrightfile + """ + ), ), Deb822KnownField( "Comment", FieldValueClass.FREE_TEXT_FIELD, spellcheck_value=True, + hover_text=textwrap.dedent( + """\ + Comment field to optionally provide additional information. For example, it might quote an e-mail from + upstream justifying why the combined license is acceptable to the `main` archive, or an explanation of + how this version of the package has been forked from a version known to be [DFSG]-free, even though the + current upstream version is not. + + Note if the `Comment` is only applicable to a set of files or a particular license out of many, + the `Comment` field should probably be moved to the relevant `Files`-stanza or `License`-stanza instead. + + The value should be written as "Formatted text" without no synopsis. The "Formatted text" is similar + to the extended description (the `Description` from `debian/control`). + + [DFSG]: https://www.debian.org/social_contract#guidelines + """ + ), ), Deb822KnownField( "License", FieldValueClass.FREE_TEXT_FIELD, # Do not tempt people to change legal text because the spellchecker wants to do a typo fix. spellcheck_value=False, + hover_text=textwrap.dedent( + """\ + Provide license information for the package as a whole, which may be different or simplified form + a combination of all the per-file license information. + + Using `License` in the `Header`-stanza is useful when it records a notable difference or simplification + of the other `License` fields in this files. However, it serves no purpose to provide the field for the + sole purpose of aggregating the other `License` fields. + + The first line (the same line as as the field name) should use an abbreviated license name or + expression. The following lines can be used for the full license text. Though, to avoid repetition, + the license text would generally be in its own `License`-stanza after the `Header`-stanza. + """ + ), + ), + Deb822KnownField( + "Copyright", + FieldValueClass.FREE_TEXT_FIELD, + # Mostly going to be names with very little free-text; high risk of false positives with low value + spellcheck_value=False, + hover_text=textwrap.dedent( + """\ + One or more free-form copyright statements that applies to the package as a whole. + + Using `Copyright` in the `Header`-stanza is useful when it records a notable difference or simplification + of the other `Copyright` fields in this files. However, it serves no purpose to provide the field for the + sole purpose of aggregating the other `Copyright` fields. + + Any formatting is permitted. Simple cases often end up effectively being one copyright holder per + line; see the examples below for some ideas for how to structure the field to make it easier to read. + + If a work has no copyright holder (i.e., it is in the public domain), that information should be recorded + here. + + The Copyright field collects all relevant copyright notices for the files of this stanza. Not all + copyright notices may apply to every individual file, and years of publication for one copyright + holder may be gathered together. For example, if file A has: + + ``` + Copyright 2008 John Smith + Copyright 2009 Angela Watts + ``` + + and file B has: + + ``` + Copyright 2010 Angela Watts + ``` + + a single stanza may still be used for both files. The Copyright field for that stanza might be written + as: + + ``` + Files: A B + Copyright: + Copyright 2008 John Smith + Copyright 2009, 2010 Angela Watts + License: ... + ``` + + The `Copyright` field may contain the original copyright statement copied exactly (including the word + "Copyright"), or it may shorten the text or merge it with other copyright statements as described above, + as long as it does not sacrifice information. + + Formally, the value should be written as "Formatted text" without no synopsis. Though, it often + ends up resembling a line-based list. The "Formatted text" is similar to the extended description + (the `Description` from `debian/control`). + """ + ), ), ) _DEP5_FILES_FIELDS = _fields( @@ -2118,6 +2368,66 @@ _DEP5_FILES_FIELDS = _fields( FieldValueClass.DEP5_FILE_LIST, is_stanza_name=True, missing_field_severity=DiagnosticSeverity.Error, + hover_text=textwrap.dedent( + """\ + Whitespace separated list of patterns indicating files covered by the license and copyright specified in + this stanza. + + Filename patterns in the `Files` field are specified using a simplified shell glob syntax. Patterns are + separated by whitespace. + + * Only the wildcards `*` and `?` apply; the former matches any number of characters (including none), + the latter a single character. Both match slashes (`/`) and leading dots, unlike shell globs. The + pattern `*.in` therefore matches any file whose name ends in `.in` anywhere in the source tree, + not just at the top level. + + * Patterns match pathnames that start at the root of the source tree. Thus, `Makefile.in` matches only + the file at the root of the tree, but `*/Makefile.in` matches at any depth. + + * The backslash (`\\`) is used to remove the magic from the next character; see below. + + Escape sequences: + * `\\*` matches a single literal asterisk (`*`) + * `\\?` matches a single literal question mark (`?`) + * `\\\\` matches a single literal backslash (`\\`) + + Any other character following a backslash is an error. + + This is the same pattern syntax as [fnmatch(3)] without the FNM_PATHNAME flag, or the argument to the + `-path` test of the GNU find command, except that `[]` wildcards are not recognized. + + Multiple Files stanzas are allowed. The last stanza that matches a particular file applies to it. + More general stanzas should therefore be given first, followed by more specific overrides. Accordingly, + `Files: *` must be the first `Files`-stanza when used. + + Exclusions are only supported by adding `Files` stanzas to override the previous match: + + ``` + Files: * + Copyright: ... + License: ... + ... license that applies by default ... + + Files: data/* + Copyright: ... + License: ... + ... license that applies to all paths in data/* ... + + Files: data/file-with-special-license + Copyright: ... + License: ... + ... license that applies to this particular file ... + ``` + + This syntax does not distinguish file names from directory names; a trailing slash in a pattern will never + match any actual path. A whole directory tree may be selected with a pattern like `foo/*`. + + The space character, used to separate patterns, cannot be escaped with a backslash. A path like `foo bar` + may be selected with a pattern like `foo?bar`. + + [fnmatch(3)]: https://manpages.debian.org/fnmatch.3 + """ + ), ), Deb822KnownField( "Copyright", @@ -2125,6 +2435,50 @@ _DEP5_FILES_FIELDS = _fields( # Mostly going to be names with very little free-text; high risk of false positives with low value spellcheck_value=False, missing_field_severity=DiagnosticSeverity.Error, + hover_text=textwrap.dedent( + """\ + One or more free-form copyright statements that applies to the files matched by this `Files`-stanza. + Any formatting is permitted. Simple cases often end up effectively being one copyright holder per + line; see the examples below for some ideas for how to structure the field to make it easier to read. + + If a work has no copyright holder (i.e., it is in the public domain), that information should be recorded + here. + + The Copyright field collects all relevant copyright notices for the files of this stanza. Not all + copyright notices may apply to every individual file, and years of publication for one copyright + holder may be gathered together. For example, if file A has: + + ``` + Copyright 2008 John Smith + Copyright 2009 Angela Watts + ``` + + and file B has: + + ``` + Copyright 2010 Angela Watts + ``` + + a single stanza may still be used for both files. The Copyright field for that stanza might be written + as: + + ``` + Files: A B + Copyright: + Copyright 2008 John Smith + Copyright 2009, 2010 Angela Watts + License: ... + ``` + + The `Copyright` field may contain the original copyright statement copied exactly (including the word + "Copyright"), or it may shorten the text or merge it with other copyright statements as described above, + as long as it does not sacrifice information. + + Formally, the value should be written as "Formatted text" without no synopsis. Though, it often + ends up resembling a line-based list. The "Formatted text" is similar to the extended description + (the `Description` from `debian/control`). + """ + ), ), Deb822KnownField( "License", @@ -2132,11 +2486,68 @@ _DEP5_FILES_FIELDS = _fields( missing_field_severity=DiagnosticSeverity.Error, # Do not tempt people to change legal text because the spellchecker wants to do a typo fix. spellcheck_value=False, + hover_text=textwrap.dedent( + """\ + Provide license information for the files matched by this `Files`-stanza. + + The first line is either an abbreviated name for the license or an expression giving + alternatives. + + When there are additional lines, they are expected to give the fill license terms for + the files matched or a pointer to `/usr/share/common-licences`. Otherwise, each license + referenced in the first line must have a separate stand-alone `License`-stanza describing + the license terms. + + **Extended example**: + ``` + Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ + + Files: * + Copyright: 2013, Someone + License: GPL-2+ + + Files: tests/* + Copyright: 2013, Someone + # In-line license + License: MIT + ... full license text of the MIT license here ... + + Files: tests/complex_text.py + Copyright: 2013, Someone + License: GPL-2+ + + # Referenced license + License: GPL-2+ + The code is licensed under GNU General Public License version 2 or, at your option, any + later version. + . + On Debian systems the full text of the GNU General Public License version 2 + can be found in the `/usr/share/common-licenses/GPL-2' file. + ``` + + The first line (the same line as as the field name) should use the abbreviated license name that + other stanzas use as reference. + + """ + ), ), Deb822KnownField( "Comment", FieldValueClass.FREE_TEXT_FIELD, spellcheck_value=True, + hover_text=textwrap.dedent( + """\ + Comment field to optionally provide additional information. For example, it might quote an e-mail from + upstream justifying why the license is acceptable to the `main` archive, or an explanation of how this + version of the package has been forked from a version known to be [DFSG]-free, even though the current + upstream version is not. + + The value should be written as "Formatted text" without no synopsis. The "Formatted text" is similar + to the extended description (the `Description` from `debian/control`). + + [DFSG]: https://www.debian.org/social_contract#guidelines + """ + ), ), ) _DEP5_LICENSE_FIELDS = _fields( @@ -2147,11 +2558,63 @@ _DEP5_LICENSE_FIELDS = _fields( # Do not tempt people to change legal text because the spellchecker wants to do a typo fix. spellcheck_value=False, missing_field_severity=DiagnosticSeverity.Error, + hover_text=textwrap.dedent( + """\ + Provide the license text for a given license shortname referenced from either the `Header`-stanza + or a `Files` stanza. + + **Extended example**: + ``` + Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ + + Files: * + Copyright: 2013, Someone + License: GPL-2+ + + Files: tests/* + Copyright: 2013, Someone + # In-line license + License: MIT + ... full license text of the MIT license here ... + + Files: tests/complex_text.py + Copyright: 2013, Someone + License: GPL-2+ + + # Referenced license + License: GPL-2+ + The code is licensed under GNU General Public License version 2 or, at your option, any + later version. + . + On Debian systems the full text of the GNU General Public License version 2 + can be found in the `/usr/share/common-licenses/GPL-2' file. + ``` + + The first line (the same line as as the field name) should use the abbreviated license name that + other stanzas use as reference. In the `License`-stanza, this field must always contain the full + license text in the following lines or a reference to a license in `/usr/share/common-licenses`. + + By convention, stand-alone `License`-stanza are usually placed in the bottom of the file. + """ + ), ), Deb822KnownField( "Comment", FieldValueClass.FREE_TEXT_FIELD, spellcheck_value=True, + hover_text=textwrap.dedent( + """\ + Comment field to optionally provide additional information. For example, it might quote an e-mail from + upstream justifying why the license is acceptable to the `main` archive, or an explanation of how this + version of the package has been forked from a version known to be [DFSG]-free, even though the current + upstream version is not. + + The value should be written as "Formatted text" without no synopsis. The "Formatted text" is similar + to the extended description (the `Description` from `debian/control`). + + [DFSG]: https://www.debian.org/social_contract#guidelines + """ + ), ), ) diff --git a/src/debputy/lsp/lsp_debian_copyright.py b/src/debputy/lsp/lsp_debian_copyright.py index f96ed1a..b21cc79 100644 --- a/src/debputy/lsp/lsp_debian_copyright.py +++ b/src/debputy/lsp/lsp_debian_copyright.py @@ -30,6 +30,7 @@ from lsprotocol.types import ( FoldingRange, ) +from debputy.linting.lint_util import LintState from debputy.lsp.lsp_debian_control_reference_data import ( _DEP5_HEADER_FIELDS, _DEP5_FILES_FIELDS, @@ -438,11 +439,11 @@ def _scan_for_syntax_errors_and_token_level_diagnostics( @lint_diagnostics(_LANGUAGE_IDS) def _lint_debian_copyright( - doc_reference: str, - _path: str, - lines: List[str], - position_codec: LintCapablePositionCodec, + lint_state: LintState, ) -> Optional[List[Diagnostic]]: + lines = lint_state.lines + position_codec = lint_state.position_codec + doc_reference = lint_state.doc_uri diagnostics = [] deb822_file = parse_deb822_file( lines, diff --git a/src/debputy/lsp/lsp_debian_debputy_manifest.py b/src/debputy/lsp/lsp_debian_debputy_manifest.py index ba30c75..03581be 100644 --- a/src/debputy/lsp/lsp_debian_debputy_manifest.py +++ b/src/debputy/lsp/lsp_debian_debputy_manifest.py @@ -28,6 +28,8 @@ from lsprotocol.types import ( DiagnosticRelatedInformation, Location, ) + +from debputy.linting.lint_util import LintState from debputy.lsp.quickfixes import propose_correct_text_quick_fix from debputy.manifest_parser.base_types import DebputyDispatchableType from debputy.plugin.api.feature_set import PluginProvidedFeatureSet @@ -46,7 +48,6 @@ from debputy.lsp.lsp_features import ( lint_diagnostics, lsp_standard_handler, lsp_hover, - lsp_get_plugin_features, lsp_completer, ) from debputy.lsp.text_util import ( @@ -80,6 +81,7 @@ from debputy.util import _info, _warn try: from pygls.server import LanguageServer + from debputy.lsp.debputy_ls import DebputyLanguageServer except ImportError: pass @@ -125,11 +127,11 @@ def _word_range_at_position( @lint_diagnostics(_LANGUAGE_IDS) def _lint_debian_debputy_manifest( - doc_reference: str, - path: str, - lines: List[str], - position_codec: LintCapablePositionCodec, + lint_state: LintState, ) -> Optional[List[Diagnostic]]: + lines = lint_state.lines + position_codec = lint_state.position_codec + path = lint_state.path if not is_valid_file(path): return None diagnostics = [] @@ -173,17 +175,15 @@ def _lint_debian_debputy_manifest( ), ) else: - feature_set = lsp_get_plugin_features() + feature_set = lint_state.plugin_feature_set pg = feature_set.manifest_parser_generator root_parser = pg.dispatchable_object_parsers[OPARSER_MANIFEST_ROOT] diagnostics.extend( _lint_content( - doc_reference, + lint_state, pg, root_parser, content, - lines, - position_codec, ) ) return diagnostics @@ -196,6 +196,8 @@ def _unknown_key( col: int, lines: List[str], position_codec: LintCapablePositionCodec, + *, + message_format: str = 'Unknown or unsupported key "{key}".', ) -> Tuple["Diagnostic", Optional[str]]: key_range = position_codec.range_to_client_units( lines, @@ -222,7 +224,7 @@ def _unknown_key( diagnostic = Diagnostic( key_range, - f'Unknown or unsupported key "{key}".{extra}', + message_format.format(key=key) + extra, DiagnosticSeverity.Error, source="debputy", data=[propose_correct_text_quick_fix(n) for n in candidates], @@ -301,12 +303,10 @@ def _conflicting_key( def _lint_attr_value( - uri: str, + lint_state: LintState, attr: AttributeDescription, pg: ParserGenerator, value: Any, - lines: List[str], - position_codec: LintCapablePositionCodec, ) -> Iterable["Diagnostic"]: attr_type = attr.attribute_type orig = get_origin(attr_type) @@ -318,12 +318,10 @@ def _lint_attr_value( elif isinstance(attr_type, type) and issubclass(attr_type, DebputyDispatchableType): parser = pg.dispatch_parser_table_for(attr_type) yield from _lint_content( - uri, + lint_state, pg, parser, value, - lines, - position_codec, ) return @@ -334,12 +332,10 @@ def _lint_attr_value( def _lint_declarative_mapping_input_parser( - uri: str, + lint_state: LintState, pg: ParserGenerator, parser: DeclarativeMappingInputParser, content: Any, - lines: List[str], - position_codec: LintCapablePositionCodec, ) -> Iterable["Diagnostic"]: if not isinstance(content, CommentedMap): return @@ -353,8 +349,8 @@ def _lint_declarative_mapping_input_parser( parser.manifest_attributes, line, col, - lines, - position_codec, + lint_state.lines, + lint_state.position_codec, ) yield diag if corrected_key: @@ -364,27 +360,25 @@ def _lint_declarative_mapping_input_parser( continue yield from _lint_attr_value( - uri, + lint_state, attr, pg, value, - lines, - position_codec, ) for forbidden_key in attr.conflicting_attributes: if forbidden_key in content: con_line, con_col = lc.key(forbidden_key) yield from _conflicting_key( - uri, + lint_state.doc_uri, key, forbidden_key, line, col, con_line, con_col, - lines, - position_codec, + lint_state.lines, + lint_state.position_codec, ) for mx in parser.mutually_exclusive_attributes: matches = content.keys() & mx @@ -395,25 +389,23 @@ def _lint_declarative_mapping_input_parser( for other in others: con_line, con_col = lc.key(other) yield from _conflicting_key( - uri, + lint_state.doc_uri, key, other, line, col, con_line, con_col, - lines, - position_codec, + lint_state.lines, + lint_state.position_codec, ) def _lint_content( - uri: str, + lint_state: LintState, pg: ParserGenerator, parser: DeclarativeInputParser[Any], content: Any, - lines: List[str], - position_codec: LintCapablePositionCodec, ) -> Iterable["Diagnostic"]: if isinstance(parser, DispatchingParserBase): if not isinstance(content, CommentedMap): @@ -428,8 +420,8 @@ def _lint_content( parser.registered_keywords(), line, col, - lines, - position_codec, + lint_state.lines, + lint_state.position_codec, ) yield diag if corrected_key is not None: @@ -440,32 +432,43 @@ def _lint_content( subparser = parser.parser_for(key) assert subparser is not None yield from _lint_content( - uri, + lint_state, pg, subparser.parser, value, - lines, - position_codec, ) elif isinstance(parser, ListWrappedDeclarativeInputParser): if not isinstance(content, CommentedSeq): return subparser = parser.delegate for value in content: - yield from _lint_content(uri, pg, subparser, value, lines, position_codec) + yield from _lint_content(lint_state, pg, subparser, value) elif isinstance(parser, InPackageContextParser): if not isinstance(content, CommentedMap): return - for v in content.values(): - yield from _lint_content(uri, pg, parser.delegate, v, lines, position_codec) + print(lint_state) + known_packages = lint_state.binary_packages + lc = content.lc + for k, v in content.items(): + if "{{" not in k and known_packages is not None and k not in known_packages: + line, col = lc.key(k) + diag, _ = _unknown_key( + k, + known_packages, + line, + col, + lint_state.lines, + lint_state.position_codec, + message_format='Unknown package "{key}".', + ) + yield diag + yield from _lint_content(lint_state, pg, parser.delegate, v) elif isinstance(parser, DeclarativeMappingInputParser): yield from _lint_declarative_mapping_input_parser( - uri, + lint_state, pg, parser, content, - lines, - position_codec, ) @@ -718,7 +721,7 @@ def _insert_snippet(lines: List[str], server_position: Position) -> bool: @lsp_completer(_LANGUAGE_IDS) def debputy_manifest_completer( - ls: "LanguageServer", + ls: "DebputyLanguageServer", params: CompletionParams, ) -> Optional[Union[CompletionList, Sequence[CompletionItem]]]: doc = ls.workspace.get_text_document(params.text_document.uri) @@ -772,7 +775,7 @@ def debputy_manifest_completer( return None matched_key, attr_path, matched, parent = m _info(f"Matched path: {matched} (path: {attr_path.path}) [{matched_key=}]") - feature_set = lsp_get_plugin_features() + feature_set = ls.plugin_feature_set root_parser = feature_set.manifest_parser_generator.dispatchable_object_parsers[ OPARSER_MANIFEST_ROOT ] @@ -813,8 +816,11 @@ def debputy_manifest_completer( ) ] elif isinstance(parser, InPackageContextParser): - # doc = ls.workspace.get_text_document(params.text_document.uri) - _info(f"TODO: Match package - {parent} -- {matched} -- {matched_key=}") + binary_packages = ls.lint_state(doc).binary_packages + if binary_packages is not None: + items = [ + CompletionItem(f"{p}:") for p in binary_packages if p not in parent + ] elif isinstance(parser, DeclarativeMappingInputParser): if matched_key: _info("Match attributes") @@ -881,7 +887,7 @@ def _completion_from_attr( @lsp_hover(_LANGUAGE_IDS) def debputy_manifest_hover( - ls: "LanguageServer", + ls: "DebputyLanguageServer", params: HoverParams, ) -> Optional[Hover]: doc = ls.workspace.get_text_document(params.text_document.uri) @@ -903,7 +909,7 @@ def debputy_manifest_hover( matched_key, attr_path, matched, _ = m _info(f"Matched path: {matched} (path: {attr_path.path}) [{matched_key=}]") - feature_set = lsp_get_plugin_features() + feature_set = ls.plugin_feature_set parser_generator = feature_set.manifest_parser_generator root_parser = parser_generator.dispatchable_object_parsers[OPARSER_MANIFEST_ROOT] segments = list(attr_path.path_segments()) diff --git a/src/debputy/lsp/lsp_debian_rules.py b/src/debputy/lsp/lsp_debian_rules.py index f05099d..b44fad4 100644 --- a/src/debputy/lsp/lsp_debian_rules.py +++ b/src/debputy/lsp/lsp_debian_rules.py @@ -27,6 +27,7 @@ from lsprotocol.types import ( ) from debputy.debhelper_emulation import parse_drules_for_addons +from debputy.linting.lint_util import LintState from debputy.lsp.lsp_features import ( lint_diagnostics, lsp_standard_handler, @@ -137,20 +138,10 @@ def is_valid_file(path: str) -> bool: @lint_diagnostics(_LANGUAGE_IDS) -def _lint_debian_rules( - doc_reference: str, - path: str, - lines: List[str], - position_codec: LintCapablePositionCodec, -) -> Optional[List[Diagnostic]]: - if not is_valid_file(path): +def _lint_debian_rules(lint_state: LintState) -> Optional[List[Diagnostic]]: + if not is_valid_file(lint_state.path): return None - return _lint_debian_rules_impl( - doc_reference, - path, - lines, - position_codec, - ) + return _lint_debian_rules_impl(lint_state) @functools.lru_cache @@ -239,11 +230,11 @@ def iter_make_lines( def _lint_debian_rules_impl( - _doc_reference: str, - path: str, - lines: List[str], - position_codec: LintCapablePositionCodec, + lint_state: LintState, ) -> Optional[List[Diagnostic]]: + lines = lint_state.lines + position_codec = lint_state.position_codec + path = lint_state.path source_root = os.path.dirname(os.path.dirname(path)) if source_root == "": source_root = "." diff --git a/src/debputy/lsp/lsp_debian_tests_control.py b/src/debputy/lsp/lsp_debian_tests_control.py index 9153026..27221f6 100644 --- a/src/debputy/lsp/lsp_debian_tests_control.py +++ b/src/debputy/lsp/lsp_debian_tests_control.py @@ -30,6 +30,7 @@ from lsprotocol.types import ( FoldingRange, ) +from debputy.linting.lint_util import LintState from debputy.lsp.lsp_debian_control_reference_data import ( Deb822KnownField, DTestsCtrlFileMetadata, @@ -435,11 +436,11 @@ def _scan_for_syntax_errors_and_token_level_diagnostics( @lint_diagnostics(_LANGUAGE_IDS) def _lint_debian_tests_control( - doc_reference: str, - _path: str, - lines: List[str], - position_codec: LintCapablePositionCodec, + lint_state: LintState, ) -> Optional[List[Diagnostic]]: + lines = lint_state.lines + position_codec = lint_state.position_codec + doc_reference = lint_state.doc_uri diagnostics = [] deb822_file = parse_deb822_file( lines, diff --git a/src/debputy/lsp/lsp_dispatch.py b/src/debputy/lsp/lsp_dispatch.py index 7a20ae8..b63f30c 100644 --- a/src/debputy/lsp/lsp_dispatch.py +++ b/src/debputy/lsp/lsp_dispatch.py @@ -52,9 +52,10 @@ _DOCUMENT_VERSION_TABLE: Dict[str, int] = {} try: from pygls.server import LanguageServer from pygls.workspace import TextDocument + from debputy.lsp.debputy_ls import DebputyLanguageServer - DEBPUTY_LANGUAGE_SERVER = LanguageServer("debputy", f"v{__version__}") -except ImportError: + DEBPUTY_LANGUAGE_SERVER = DebputyLanguageServer("debputy", f"v{__version__}") +except ImportError as e: class Mock: @@ -66,6 +67,7 @@ except ImportError: P = TypeVar("P") R = TypeVar("R") +L = TypeVar("L", "LanguageServer", "DebputyLanguageServer") def is_doc_at_version(uri: str, version: int) -> bool: @@ -89,7 +91,7 @@ def determine_language_id(doc: "TextDocument") -> Tuple[str, str]: @DEBPUTY_LANGUAGE_SERVER.feature(TEXT_DOCUMENT_DID_OPEN) @DEBPUTY_LANGUAGE_SERVER.feature(TEXT_DOCUMENT_DID_CHANGE) async def _open_or_changed_document( - ls: "LanguageServer", + ls: "DebputyLanguageServer", params: Union[DidOpenTextDocumentParams, DidChangeTextDocumentParams], ) -> None: version = params.text_document.version @@ -129,7 +131,7 @@ async def _open_or_changed_document( @DEBPUTY_LANGUAGE_SERVER.feature(TEXT_DOCUMENT_COMPLETION) def _completions( - ls: "LanguageServer", + ls: "DebputyLanguageServer", params: CompletionParams, ) -> Optional[Union[CompletionList, Sequence[CompletionItem]]]: return _dispatch_standard_handler( @@ -143,7 +145,7 @@ def _completions( @DEBPUTY_LANGUAGE_SERVER.feature(TEXT_DOCUMENT_HOVER) def _hover( - ls: "LanguageServer", + ls: "DebputyLanguageServer", params: CompletionParams, ) -> Optional[Hover]: return _dispatch_standard_handler( @@ -157,7 +159,7 @@ def _hover( @DEBPUTY_LANGUAGE_SERVER.feature(TEXT_DOCUMENT_CODE_ACTION) def _code_actions( - ls: "LanguageServer", + ls: "DebputyLanguageServer", params: CodeActionParams, ) -> Optional[List[Union[Command, CodeAction]]]: return _dispatch_standard_handler( @@ -171,7 +173,7 @@ def _code_actions( @DEBPUTY_LANGUAGE_SERVER.feature(TEXT_DOCUMENT_FOLDING_RANGE) def _folding_ranges( - ls: "LanguageServer", + ls: "DebputyLanguageServer", params: FoldingRangeParams, ) -> Optional[Sequence[FoldingRange]]: return _dispatch_standard_handler( @@ -191,7 +193,7 @@ def _folding_ranges( ), ) def _semantic_tokens_full( - ls: "LanguageServer", + ls: "DebputyLanguageServer", params: SemanticTokensParams, ) -> Optional[SemanticTokens]: return _dispatch_standard_handler( @@ -204,10 +206,10 @@ def _semantic_tokens_full( def _dispatch_standard_handler( - ls: "LanguageServer", + ls: "DebputyLanguageServer", doc_uri: str, params: P, - handler_table: Mapping[str, Callable[["LanguageServer", P], R]], + handler_table: Mapping[str, Callable[[L, P], R]], request_type: str, ) -> R: doc = ls.workspace.get_text_document(doc_uri) diff --git a/src/debputy/lsp/lsp_features.py b/src/debputy/lsp/lsp_features.py index 00bed1b..7a1110d 100644 --- a/src/debputy/lsp/lsp_features.py +++ b/src/debputy/lsp/lsp_features.py @@ -1,5 +1,6 @@ import collections import inspect +import sys from typing import Callable, TypeVar, Sequence, Union, Dict, List, Optional from lsprotocol.types import ( @@ -11,10 +12,13 @@ from lsprotocol.types import ( SemanticTokensLegend, ) -from debputy.plugin.api.feature_set import PluginProvidedFeatureSet +from debputy.commands.debputy_cmd.context import CommandContext +from debputy.commands.debputy_cmd.output import _output_styling +from debputy.lsp.lsp_self_check import LSP_CHECKS try: from pygls.server import LanguageServer + from debputy.lsp.debputy_ls import DebputyLanguageServer except ImportError: pass @@ -32,7 +36,6 @@ SEMANTIC_TOKEN_TYPES_IDS = { t: idx for idx, t in enumerate(SEMANTIC_TOKENS_LEGEND.token_types) } -LSP_PLUGIN_FEATURE_SET: Optional[PluginProvidedFeatureSet] = None DIAGNOSTIC_HANDLERS = {} COMPLETER_HANDLERS = {} HOVER_HANDLERS = {} @@ -62,19 +65,15 @@ def lint_diagnostics( if not inspect.iscoroutinefunction(func): async def _lint_wrapper( - ls: "LanguageServer", + ls: "DebputyLanguageServer", params: Union[ DidOpenTextDocumentParams, DidChangeTextDocumentParams, ], ) -> Optional[List[Diagnostic]]: doc = ls.workspace.get_text_document(params.text_document.uri) - yield func( - doc.uri, - doc.path, - doc.lines, - doc.position_codec, - ) + lint_state = ls.lint_state(doc) + yield func(lint_state) else: raise ValueError("Linters are all non-async at the moment") @@ -176,21 +175,6 @@ def _register_handler( handler_dict[file_format] = handler -def lsp_set_plugin_features(feature_set: Optional[PluginProvidedFeatureSet]) -> None: - global LSP_PLUGIN_FEATURE_SET - LSP_PLUGIN_FEATURE_SET = feature_set - - -def lsp_get_plugin_features() -> PluginProvidedFeatureSet: - global LSP_PLUGIN_FEATURE_SET - features = LSP_PLUGIN_FEATURE_SET - if features is None: - raise RuntimeError( - "Initialization error: The plugin feature set has not been initialized before it was needed." - ) - return features - - def ensure_lsp_features_are_loaded() -> None: # FIXME: This import is needed to force loading of the LSP files. But it only works # for files with a linter (which currently happens to be all of them, but this is @@ -200,8 +184,8 @@ def ensure_lsp_features_are_loaded() -> None: assert LINTER_FORMATS -def describe_lsp_features() -> None: - +def describe_lsp_features(context: CommandContext) -> None: + fo = _output_styling(context.parsed_args, sys.stdout) ensure_lsp_features_are_loaded() feature_list = [ @@ -234,3 +218,27 @@ def describe_lsp_features() -> None: print("Aliases:") for main_id, aliases in aliases.items(): print(f" * {main_id}: {', '.join(aliases)}") + + print() + print("General features:") + for self_check in LSP_CHECKS: + is_ok = self_check.test() + assert not self_check.is_mandatory or is_ok + if self_check.is_mandatory: + continue + if is_ok: + print(f" * {self_check.feature}: {fo.colored('enabled', fg='green')}") + else: + disabled = fo.colored( + "disabled", + fg="yellow", + bg="black", + style="bold", + ) + + if self_check.how_to_fix: + print(f" * {self_check.feature}: {disabled}") + print(f" - {self_check.how_to_fix}") + else: + problem_suffix = f" ({self_check.problem})" + print(f" * {self_check.feature}: {disabled}{problem_suffix}") diff --git a/src/debputy/lsp/lsp_generic_deb822.py b/src/debputy/lsp/lsp_generic_deb822.py index 7a1f96f..ec7b979 100644 --- a/src/debputy/lsp/lsp_generic_deb822.py +++ b/src/debputy/lsp/lsp_generic_deb822.py @@ -10,8 +10,26 @@ from typing import ( List, Iterable, Iterator, + Callable, ) +from debputy.lsp.lsp_debian_control_reference_data import ( + Deb822FileMetadata, + Deb822KnownField, + StanzaMetadata, + FieldValueClass, + F, + S, +) +from debputy.lsp.lsp_features import SEMANTIC_TOKEN_TYPES_IDS +from debputy.lsp.text_util import normalize_dctrl_field_name +from debputy.lsp.vendoring._deb822_repro import parse_deb822_file +from debputy.lsp.vendoring._deb822_repro.parsing import ( + Deb822KeyValuePairElement, + LIST_SPACE_SEPARATED_INTERPRETATION, +) +from debputy.lsp.vendoring._deb822_repro.tokens import tokenize_deb822_file, Deb822Token +from debputy.util import _info from lsprotocol.types import ( CompletionParams, CompletionList, @@ -29,22 +47,6 @@ from lsprotocol.types import ( SemanticTokens, ) -from debputy.lsp.lsp_debian_control_reference_data import ( - Deb822FileMetadata, - Deb822KnownField, - StanzaMetadata, - FieldValueClass, -) -from debputy.lsp.lsp_features import SEMANTIC_TOKEN_TYPES_IDS -from debputy.lsp.text_util import normalize_dctrl_field_name -from debputy.lsp.vendoring._deb822_repro import parse_deb822_file -from debputy.lsp.vendoring._deb822_repro.parsing import ( - Deb822KeyValuePairElement, - LIST_SPACE_SEPARATED_INTERPRETATION, -) -from debputy.lsp.vendoring._deb822_repro.tokens import tokenize_deb822_file, Deb822Token -from debputy.util import _info - try: from pygls.server import LanguageServer from pygls.workspace import TextDocument @@ -59,7 +61,7 @@ def _at_cursor( doc: "TextDocument", lines: List[str], client_position: Position, -) -> Tuple[Optional[str], str, bool, int, Set[str]]: +) -> Tuple[Position, Optional[str], str, bool, int, Set[str]]: paragraph_no = -1 paragraph_started = False seen_fields = set() @@ -103,7 +105,14 @@ def _at_cursor( current_word = doc.word_at_position(client_position) if current_field is not None: current_field = normalize_dctrl_field_name(current_field) - return current_field, current_word, in_value, paragraph_no, seen_fields + return ( + server_position, + current_field, + current_word, + in_value, + paragraph_no, + seen_fields, + ) def deb822_completer( @@ -114,7 +123,7 @@ def deb822_completer( doc = ls.workspace.get_text_document(params.text_document.uri) lines = doc.lines - current_field, _, in_value, paragraph_no, seen_fields = _at_cursor( + _a, current_field, _b, in_value, paragraph_no, seen_fields = _at_cursor( doc, lines, params.position, @@ -145,33 +154,72 @@ def deb822_completer( def deb822_hover( ls: "LanguageServer", params: HoverParams, - file_metadata: Deb822FileMetadata[Any], -) -> Optional[Hover]: + file_metadata: Deb822FileMetadata[S], + *, + custom_handler: Optional[ + Callable[ + [ + Position, + Optional[str], + str, + Optional[F], + bool, + "TextDocument", + List[str], + ], + Optional[Hover], + ] + ] = None, +) -> Optional[Union[Hover, str]]: doc = ls.workspace.get_text_document(params.text_document.uri) lines = doc.lines - current_field, word_at_position, in_value, paragraph_no, _ = _at_cursor( + server_pos, current_field, word_at_position, in_value, paragraph_no, _ = _at_cursor( doc, lines, params.position ) stanza_metadata = file_metadata.guess_stanza_classification_by_idx(paragraph_no) - if current_field is None: - _info("No hover information as we cannot determine which field it is for") - return None - known_field = stanza_metadata.get(current_field) + known_field = ( + stanza_metadata.get(current_field) if current_field is not None else None + ) + hover_text = None + if custom_handler is not None: + res = custom_handler( + server_pos, + current_field, + word_at_position, + known_field, + in_value, + doc, + lines, + ) + if isinstance(res, Hover): + return res + hover_text = res + + if hover_text is None: + if current_field is None: + _info("No hover information as we cannot determine which field it is for") + return None - if known_field is None: + if known_field is None: + return None + if in_value: + if not known_field.known_values: + return None + keyword = known_field.known_values.get(word_at_position) + if keyword is None: + return None + hover_text = keyword.hover_text + if hover_text is not None: + hover_text = f"# Value `{keyword.value}` (Field: {known_field.name})\n\n{hover_text}" + else: + hover_text = known_field.hover_text + if hover_text is None: + hover_text = f"The field {current_field} had no documentation." + hover_text = f"# {known_field.name}\n\n{hover_text}" + + if hover_text is None: return None - if in_value: - if not known_field.known_values: - return - keyword = known_field.known_values.get(word_at_position) - if keyword is None: - return - hover_text = keyword.hover_text - else: - hover_text = known_field.hover_text - if hover_text is None: - hover_text = f"The field {current_field} had no documentation." try: supported_formats = ls.client_capabilities.text_document.hover.content_format diff --git a/src/debputy/lsp/lsp_self_check.py b/src/debputy/lsp/lsp_self_check.py new file mode 100644 index 0000000..61a5733 --- /dev/null +++ b/src/debputy/lsp/lsp_self_check.py @@ -0,0 +1,91 @@ +import dataclasses +import os.path +from typing import Callable, Sequence, List, Optional, TypeVar + +from debputy.util import _error + + +@dataclasses.dataclass(slots=True, frozen=True) +class LSPSelfCheck: + feature: str + test: Callable[[], bool] + problem: str + how_to_fix: str + is_mandatory: bool = False + + +LSP_CHECKS: List[LSPSelfCheck] = [] + +C = TypeVar("C", bound="Callable") + + +def lsp_import_check( + packages: Sequence[str], + *, + feature_name: Optional[str] = None, + is_mandatory: bool = False, +): + + def _wrapper(func: C) -> C: + + def _impl(): + try: + r = func() + except ImportError: + return False + return r is None or bool(r) + + suffix = "fix this issue" if is_mandatory else "enable this feature" + + LSP_CHECKS.append( + LSPSelfCheck( + _feature_name(feature_name, func), + _impl, + "Missing dependencies", + f"Run `apt satisfy '{', '.join(packages)}'` to {suffix}", + is_mandatory=is_mandatory, + ) + ) + return func + + return _wrapper + + +def _feature_name(feature: Optional[str], func: Callable[[], None]) -> str: + if feature is not None: + return feature + return func.__name__.replace("_", " ") + + +@lsp_import_check(["python3-lsprotocol", "python3-pygls"], is_mandatory=True) +def minimum_requirements() -> bool: + import pygls.server + + # The hasattr is irrelevant; but it avoids the import being flagged as redundant. + return hasattr(pygls.server, "LanguageServer") + + +@lsp_import_check(["python3-levenshtein"]) +def typo_detection() -> bool: + import Levenshtein + + # The hasattr is irrelevant; but it avoids the import being flagged as redundant. + return hasattr(Levenshtein, "distance") + + +@lsp_import_check(["hunspell-en-us", "python3-hunspell"]) +def spell_checking() -> bool: + import Levenshtein + + # The hasattr is irrelevant; but it avoids the import being flagged as redundant. + return hasattr(Levenshtein, "distance") and os.path.exists( + "/usr/share/hunspell/en_US.dic" + ) + + +def assert_can_start_lsp(): + for self_check in LSP_CHECKS: + if self_check.is_mandatory and not self_check.test(): + _error( + f"Cannot start the language server. {self_check.problem}. {self_check.how_to_fix}" + ) diff --git a/src/debputy/packages.py b/src/debputy/packages.py index 3204f46..4dfdd49 100644 --- a/src/debputy/packages.py +++ b/src/debputy/packages.py @@ -7,10 +7,10 @@ from typing import ( cast, Mapping, FrozenSet, - TYPE_CHECKING, + Iterable, + overload, ) -from debian.deb822 import Deb822 from debian.debian_support import DpkgArchTable from ._deb_options_profiles import DebBuildOptionsAndProfiles @@ -18,80 +18,126 @@ from .architecture_support import ( DpkgArchitectureBuildProcessValuesTable, dpkg_architecture_table, ) +from .lsp.vendoring._deb822_repro import parse_deb822_file, Deb822ParagraphElement from .util import DEFAULT_PACKAGE_TYPE, UDEB_PACKAGE_TYPE, _error, active_profiles_match -if TYPE_CHECKING: - from .plugin.api import VirtualPath - - _MANDATORY_BINARY_PACKAGE_FIELD = [ "Package", "Architecture", ] -def parse_source_debian_control( - debian_control: "VirtualPath", - selected_packages: Union[Set[str], FrozenSet[str]], - excluded_packages: Union[Set[str], FrozenSet[str]], - select_arch_all: bool, - select_arch_any: bool, - dpkg_architecture_variables: Optional[ - DpkgArchitectureBuildProcessValuesTable - ] = None, - dpkg_arch_query_table: Optional[DpkgArchTable] = None, - build_env: Optional[DebBuildOptionsAndProfiles] = None, -) -> Tuple["SourcePackage", Dict[str, "BinaryPackage"]]: - if dpkg_architecture_variables is None: - dpkg_architecture_variables = dpkg_architecture_table() - if dpkg_arch_query_table is None: - dpkg_arch_query_table = DpkgArchTable.load_arch_table() - if build_env is None: - build_env = DebBuildOptionsAndProfiles.instance() - - # If no selection option is set, then all packages are acted on (except the - # excluded ones) - if not selected_packages and not select_arch_all and not select_arch_any: - select_arch_all = True - select_arch_any = True - - with debian_control.open() as fd: - dctrl_paragraphs = list(Deb822.iter_paragraphs(fd)) - - if len(dctrl_paragraphs) < 2: - _error( - "debian/control must contain at least two stanza (1 Source + 1-N Package stanza)" - ) +class DctrlParser: - source_package = SourcePackage(dctrl_paragraphs[0]) - - bin_pkgs = [ - _create_binary_package( - p, - selected_packages, - excluded_packages, - select_arch_all, - select_arch_any, - dpkg_architecture_variables, - dpkg_arch_query_table, - build_env, - i, - ) - for i, p in enumerate(dctrl_paragraphs[1:], 1) - ] - bin_pkgs_table = {p.name: p for p in bin_pkgs} - if not selected_packages.issubset(bin_pkgs_table.keys()): - unknown = selected_packages - bin_pkgs_table.keys() - _error( - f"The following *selected* packages (-p) are not listed in debian/control: {sorted(unknown)}" - ) - if not excluded_packages.issubset(bin_pkgs_table.keys()): - unknown = selected_packages - bin_pkgs_table.keys() - _error( - f"The following *excluded* packages (-N) are not listed in debian/control: {sorted(unknown)}" + def __init__( + self, + selected_packages: Union[Set[str], FrozenSet[str]], + excluded_packages: Union[Set[str], FrozenSet[str]], + select_arch_all: bool, + select_arch_any: bool, + dpkg_architecture_variables: Optional[ + DpkgArchitectureBuildProcessValuesTable + ] = None, + dpkg_arch_query_table: Optional[DpkgArchTable] = None, + build_env: Optional[DebBuildOptionsAndProfiles] = None, + ignore_errors: bool = False, + ) -> None: + if dpkg_architecture_variables is None: + dpkg_architecture_variables = dpkg_architecture_table() + if dpkg_arch_query_table is None: + dpkg_arch_query_table = DpkgArchTable.load_arch_table() + if build_env is None: + build_env = DebBuildOptionsAndProfiles.instance() + + # If no selection option is set, then all packages are acted on (except the + # excluded ones) + if not selected_packages and not select_arch_all and not select_arch_any: + select_arch_all = True + select_arch_any = True + + self.selected_packages = selected_packages + self.excluded_packages = excluded_packages + self.select_arch_all = select_arch_all + self.select_arch_any = select_arch_any + self.dpkg_architecture_variables = dpkg_architecture_variables + self.dpkg_arch_query_table = dpkg_arch_query_table + self.build_env = build_env + self.ignore_errors = ignore_errors + + @overload + def parse_source_debian_control( + self, + debian_control_lines: Iterable[str], + ) -> Tuple["SourcePackage", Dict[str, "BinaryPackage"]]: ... + + @overload + def parse_source_debian_control( + self, + debian_control_lines: Iterable[str], + *, + ignore_errors: bool = False, + ) -> Tuple[Optional["SourcePackage"], Optional[Dict[str, "BinaryPackage"]]]: ... + + def parse_source_debian_control( + self, + debian_control_lines: Iterable[str], + *, + ignore_errors: bool = False, + ) -> Tuple[Optional["SourcePackage"], Optional[Dict[str, "BinaryPackage"]]]: + dctrl_paragraphs = list( + parse_deb822_file( + debian_control_lines, + accept_files_with_error_tokens=ignore_errors, + accept_files_with_duplicated_fields=ignore_errors, + ) ) + if len(dctrl_paragraphs) < 2: + if not ignore_errors: + _error( + "debian/control must contain at least two stanza (1 Source + 1-N Package stanza)" + ) + source_package = ( + SourcePackage(dctrl_paragraphs[0]) if dctrl_paragraphs else None + ) + return source_package, None + + source_package = SourcePackage(dctrl_paragraphs[0]) + bin_pkgs = [] + for i, p in enumerate(dctrl_paragraphs[1:], 1): + if ignore_errors: + if "Package" not in p: + continue + for f in _MANDATORY_BINARY_PACKAGE_FIELD: + if f not in p: + p[f] = "unknown" + bin_pkgs.append( + _create_binary_package( + p, + self.selected_packages, + self.excluded_packages, + self.select_arch_all, + self.select_arch_any, + self.dpkg_architecture_variables, + self.dpkg_arch_query_table, + self.build_env, + i, + ) + ) + bin_pkgs_table = {p.name: p for p in bin_pkgs} + + if not ignore_errors: + if not self.selected_packages.issubset(bin_pkgs_table.keys()): + unknown = self.selected_packages - bin_pkgs_table.keys() + _error( + f"The following *selected* packages (-p) are not listed in debian/control: {sorted(unknown)}" + ) + if not self.excluded_packages.issubset(bin_pkgs_table.keys()): + unknown = self.selected_packages - bin_pkgs_table.keys() + _error( + f"The following *excluded* packages (-N) are not listed in debian/control: {sorted(unknown)}" + ) - return source_package, bin_pkgs_table + return source_package, bin_pkgs_table def _check_package_sets( @@ -117,7 +163,7 @@ def _check_package_sets( def _create_binary_package( - paragraph: Union[Deb822, Dict[str, str]], + paragraph: Union[Deb822ParagraphElement, Dict[str, str]], selected_packages: Union[Set[str], FrozenSet[str]], excluded_packages: Union[Set[str], FrozenSet[str]], select_arch_all: bool, @@ -202,7 +248,7 @@ class BinaryPackage: def __init__( self, - fields: Union[Mapping[str, str], Deb822], + fields: Union[Mapping[str, str], Deb822ParagraphElement], dpkg_architecture_variables: DpkgArchitectureBuildProcessValuesTable, dpkg_arch_query: DpkgArchTable, *, @@ -210,7 +256,7 @@ class BinaryPackage: should_be_acted_on: bool = True, ) -> None: super(BinaryPackage, self).__init__() - # Typing-wise, Deb822 is *not* a Mapping[str, str] but it behaves enough + # Typing-wise, Deb822ParagraphElement is *not* a Mapping[str, str] but it behaves enough # like one that we rely on it and just cast it. self._package_fields = cast("Mapping[str, str]", fields) self._dbgsym_binary_package = None @@ -318,8 +364,8 @@ class BinaryPackage: class SourcePackage: __slots__ = ("_package_fields",) - def __init__(self, fields: Union[Mapping[str, str], Deb822]): - # Typing-wise, Deb822 is *not* a Mapping[str, str] but it behaves enough + def __init__(self, fields: Union[Mapping[str, str], Deb822ParagraphElement]): + # Typing-wise, Deb822ParagraphElement is *not* a Mapping[str, str] but it behaves enough # like one that we rely on it and just cast it. self._package_fields = cast("Mapping[str, str]", fields) |