From d1ef412a1bd37c5498a9f1670f14e15621879cd6 Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 14 Apr 2024 22:17:18 +0200 Subject: Merging upstream version 0.1.26. Signed-off-by: Daniel Baumann --- .../commands/debputy_cmd/lint_and_lsp_cmds.py | 7 + src/debputy/dh_migration/migrators_impl.py | 30 +- src/debputy/linting/lint_impl.py | 27 +- src/debputy/lsp/lsp_debian_control.py | 2 - .../lsp/lsp_debian_control_reference_data.py | 578 +++++++++++++++++++- src/debputy/lsp/lsp_debian_copyright.py | 3 - src/debputy/lsp/lsp_debian_debputy_manifest.py | 54 +- src/debputy/lsp/lsp_debian_tests_control.py | 485 +++++++++++++++++ tests/lsp_tests/conftest.py | 12 +- tests/lsp_tests/lsp_tutil.py | 6 +- tests/lsp_tests/test_lsp_dctrl.py | 2 +- .../test_lsp_debputy_manifest_completer.py | 598 +++++++++++++++++++++ 12 files changed, 1754 insertions(+), 50 deletions(-) create mode 100644 src/debputy/lsp/lsp_debian_tests_control.py create mode 100644 tests/lsp_tests/test_lsp_debputy_manifest_completer.py 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 dcb5063..35b5f6a 100644 --- a/src/debputy/commands/debputy_cmd/lint_and_lsp_cmds.py +++ b/src/debputy/commands/debputy_cmd/lint_and_lsp_cmds.py @@ -26,6 +26,9 @@ _EDITOR_SNIPPETS = { '(debian-changelog-mode . ("debputy" "lsp" "server"))) (add-to-list 'eglot-server-programs '(debian-copyright-mode . ("debputy" "lsp" "server"))) + ;; Requires elpa-dpkg-dev-el (>> 37.11) + ;; (add-to-list 'eglot-server-programs + ;; '(debian-autopkgtest-control-mode . ("debputy" "lsp" "server"))) ;; The debian/rules file uses the qmake mode. (add-to-list 'eglot-server-programs '(makefile-gmake-mode . ("debputy" "lsp" "server"))) @@ -40,6 +43,8 @@ _EDITOR_SNIPPETS = { ;; opening the first changelog. It seems to be related to imenu. ;; (add-hook 'debian-changelog-mode-hook 'eglot-ensure) (add-hook 'debian-copyright-mode-hook 'eglot-ensure) + ;; Requires elpa-dpkg-dev-el (>> 37.11) + ;; (add-hook 'debian-autopkgtest-control-mode-hook 'eglot-ensure) (add-hook 'makefile-gmake-mode-hook 'eglot-ensure) (add-hook 'yaml-mode-hook 'eglot-ensure) """ @@ -54,6 +59,8 @@ _EDITOR_SNIPPETS = { # Make vim recognize debputy.manifest as YAML file au BufNewFile,BufRead debputy.manifest setf yaml # Inform vim/ycm about the debputy LSP + # - NB: No known support for debian/tests/control that we can hook into. + # Feel free to provide one :) let g:ycm_language_server = [ \\ { 'name': 'debputy', \\ 'filetypes': [ 'debcontrol', 'debcopyright', 'debchangelog', 'make', 'yaml'], diff --git a/src/debputy/dh_migration/migrators_impl.py b/src/debputy/dh_migration/migrators_impl.py index cb05e50..450b5a9 100644 --- a/src/debputy/dh_migration/migrators_impl.py +++ b/src/debputy/dh_migration/migrators_impl.py @@ -21,6 +21,7 @@ from typing import ( from debian.deb822 import Deb822 +from debputy import DEBPUTY_DOC_ROOT_DIR from debputy.architecture_support import dpkg_architecture_table from debputy.deb_packaging_support import dpkg_field_list_pkg_dep from debputy.debhelper_emulation import ( @@ -124,6 +125,14 @@ DH_COMMANDS_REPLACED = { ), } +_GS_DOC = f"{DEBPUTY_DOC_ROOT_DIR}/GETTING-STARTED-WITH-dh-debputy.md" +MIGRATION_AID_FOR_OVERRIDDEN_COMMANDS = { + "dh_installinit": f"{_GS_DOC}#covert-your-overrides-for-dh_installsystemd-dh_installinit-if-any", + "dh_installsystemd": f"{_GS_DOC}#covert-your-overrides-for-dh_installsystemd-dh_installinit-if-any", + "dh_fixperms": f"{_GS_DOC}#convert-your-overrides-or-excludes-for-dh_fixperms-if-any", + "dh_gencontrol": f"{_GS_DOC}#convert-your-overrides-for-dh_gencontrol-if-any", +} + @dataclasses.dataclass(frozen=True, slots=True) class UnsupportedDHConfig: @@ -1362,19 +1371,26 @@ def migrate_dh_hook_targets( if command not in replaced_commands: continue hook_target = hook_target_def["target-name"] - if sample_hook_target is None: - sample_hook_target = hook_target - feature_migration.warn( - f"TODO: MANUAL MIGRATION required for hook target {hook_target}" - ) + advice = MIGRATION_AID_FOR_OVERRIDDEN_COMMANDS.get(command) + if advice is None: + if sample_hook_target is None: + sample_hook_target = hook_target + feature_migration.warn( + f"TODO: MANUAL MIGRATION required for hook target {hook_target}" + ) + else: + feature_migration.warn( + f"TODO: MANUAL MIGRATION required for hook target {hook_target}. Please see {advice}" + f" for migration advice." + ) if ( feature_migration.warnings and "dh-hook-targets" not in acceptable_migration_issues + and sample_hook_target is not None ): - assert sample_hook_target raise UnsupportedFeature( f"The debian/rules file contains one or more non empty dh hook targets that will not" - f" be run with the requested debputy dh sequence. One of these would be" + f" be run with the requested debputy dh sequence with no known migration advice. One of these would be" f" {sample_hook_target}.", ["dh-hook-targets"], ) diff --git a/src/debputy/linting/lint_impl.py b/src/debputy/linting/lint_impl.py index 66ff635..058b784 100644 --- a/src/debputy/linting/lint_impl.py +++ b/src/debputy/linting/lint_impl.py @@ -27,6 +27,7 @@ from debputy.lsp.lsp_debian_control import _lint_debian_control from debputy.lsp.lsp_debian_copyright import _lint_debian_copyright from debputy.lsp.lsp_debian_debputy_manifest import _lint_debian_debputy_manifest from debputy.lsp.lsp_debian_rules import _lint_debian_rules_impl +from debputy.lsp.lsp_debian_tests_control import _lint_debian_tests_control from debputy.lsp.quickfixes import provide_standard_quickfixes_from_diagnostics from debputy.lsp.spellchecking import disable_spellchecking from debputy.lsp.text_edit import ( @@ -37,11 +38,12 @@ from debputy.lsp.text_edit import ( from debputy.util import _warn, _error, _info LINTER_FORMATS = { + "debian/changelog": _lint_debian_changelog, "debian/control": _lint_debian_control, "debian/copyright": _lint_debian_copyright, - "debian/changelog": _lint_debian_changelog, - "debian/rules": _lint_debian_rules_impl, "debian/debputy.manifest": _lint_debian_debputy_manifest, + "debian/rules": _lint_debian_rules_impl, + "debian/tests/control": _lint_debian_tests_control, } @@ -110,6 +112,18 @@ def perform_linting_of_file( _diagnostics_run(fo, filename, text, handler, lint_report) +def _edit_happens_before_last_fix( + last_edit_pos: Position, + last_fix_position: Position, +) -> bool: + if last_edit_pos.line < last_fix_position.line: + return True + return ( + last_edit_pos.line == last_fix_position.character + and last_edit_pos.character < last_fix_position.character + ) + + def _auto_fix_run( fo: OutputStylingBase, filename: str, @@ -152,12 +166,8 @@ def _auto_fix_run( ) last_edit = sorted_edits[-1] last_edit_pos = last_edit.range.start - if ( - last_edit_pos.line <= last_fix_position.line - or last_edit_pos.character < last_fix_position.character - ): + if _edit_happens_before_last_fix(last_edit_pos, last_fix_position): if not another_round: - if remaining_rounds > 0: remaining_rounds -= 1 print( @@ -172,10 +182,11 @@ def _auto_fix_run( continue edits.extend(sorted_edits) fixed_diagnostics.append(diagnostic) + last_fix_position = sorted_edits[-1].range.start if another_round and not edits: _error( - "Internal error: Detected an overlapping edit and yet had edits to perform..." + "Internal error: Detected an overlapping edit and yet had no edits to perform..." ) fixed_count += len(fixed_diagnostics) diff --git a/src/debputy/lsp/lsp_debian_control.py b/src/debputy/lsp/lsp_debian_control.py index 6a7ed6a..3dbb115 100644 --- a/src/debputy/lsp/lsp_debian_control.py +++ b/src/debputy/lsp/lsp_debian_control.py @@ -27,7 +27,6 @@ from lsprotocol.types import ( HoverParams, Hover, TEXT_DOCUMENT_CODE_ACTION, - DiagnosticTag, SemanticTokens, SemanticTokensParams, ) @@ -75,7 +74,6 @@ from debputy.lsp.vendoring._deb822_repro.parsing import ( ) from debputy.lsp.vendoring._deb822_repro.tokens import ( Deb822Token, - Deb822FieldNameToken, ) from debputy.util import _info diff --git a/src/debputy/lsp/lsp_debian_control_reference_data.py b/src/debputy/lsp/lsp_debian_control_reference_data.py index 689866f..3e16f3c 100644 --- a/src/debputy/lsp/lsp_debian_control_reference_data.py +++ b/src/debputy/lsp/lsp_debian_control_reference_data.py @@ -2,6 +2,7 @@ import dataclasses import functools import itertools import re +import sys import textwrap from abc import ABC from enum import Enum, auto @@ -17,6 +18,7 @@ from typing import ( Union, Callable, Tuple, + Any, ) from debian.debian_support import DpkgArchTable @@ -37,8 +39,22 @@ from debputy.lsp.vendoring._deb822_repro.parsing import ( LIST_SPACE_SEPARATED_INTERPRETATION, Deb822ParagraphElement, Deb822FileElement, + Interpretation, + LIST_COMMA_SEPARATED_INTERPRETATION, + ListInterpretation, + _parsed_value_render_factory, + Deb822ParsedValueElement, + LIST_UPLOADERS_INTERPRETATION, + _parse_whitespace_list_value, +) +from debputy.lsp.vendoring._deb822_repro.tokens import ( + Deb822FieldNameToken, + _value_line_tokenizer, + Deb822ValueToken, + Deb822Token, + _RE_WHITESPACE_SEPARATED_WORD_LIST, + Deb822SpaceSeparatorToken, ) -from debputy.lsp.vendoring._deb822_repro.tokens import Deb822FieldNameToken from debputy.util import PKGNAME_REGEX try: @@ -55,6 +71,43 @@ F = TypeVar("F", bound="Deb822KnownField") S = TypeVar("S", bound="StanzaMetadata") +# FIXME: should go into python3-debian +_RE_COMMA = re.compile("([^,]*),([^,]*)") + + +@_value_line_tokenizer +def comma_or_space_split_tokenizer(v): + # type: (str) -> Iterable[Deb822Token] + assert "\n" not in v + for match in _RE_WHITESPACE_SEPARATED_WORD_LIST.finditer(v): + space_before, word, space_after = match.groups() + if space_before: + yield Deb822SpaceSeparatorToken(sys.intern(space_before)) + if "," in word: + for m in _RE_COMMA.finditer(word): + word_before, word_after = m.groups() + if word_before: + yield Deb822ValueToken(word_before) + # ... not quite a whitespace, but it is too much pain to make it a non-whitespace token. + yield Deb822SpaceSeparatorToken(",") + if word_after: + yield Deb822ValueToken(word_after) + else: + yield Deb822ValueToken(word) + if space_after: + yield Deb822SpaceSeparatorToken(sys.intern(space_after)) + + +# FIXME: should go into python3-debian +LIST_COMMA_OR_SPACE_SEPARATED_INTERPRETATION = ListInterpretation( + comma_or_space_split_tokenizer, + _parse_whitespace_list_value, + Deb822ParsedValueElement, + Deb822SpaceSeparatorToken, + Deb822SpaceSeparatorToken, + _parsed_value_render_factory, +) + CustomFieldCheck = Callable[ [ "F", @@ -387,13 +440,17 @@ def _combined_custom_field_check(*checks: CustomFieldCheck) -> CustomFieldCheck: class FieldValueClass(Enum): - SINGLE_VALUE = auto() - SPACE_SEPARATED_LIST = auto() - BUILD_PROFILES_LIST = auto() - COMMA_SEPARATED_LIST = auto() - COMMA_SEPARATED_EMAIL_LIST = auto() - FREE_TEXT_FIELD = auto() - DEP5_FILE_LIST = auto() + SINGLE_VALUE = auto(), LIST_SPACE_SEPARATED_INTERPRETATION + SPACE_SEPARATED_LIST = auto(), LIST_SPACE_SEPARATED_INTERPRETATION + BUILD_PROFILES_LIST = auto(), None # TODO + COMMA_SEPARATED_LIST = auto(), LIST_COMMA_SEPARATED_INTERPRETATION + COMMA_SEPARATED_EMAIL_LIST = auto(), LIST_UPLOADERS_INTERPRETATION + COMMA_OR_SPACE_SEPARATED_LIST = auto(), LIST_COMMA_OR_SPACE_SEPARATED_INTERPRETATION + FREE_TEXT_FIELD = auto(), None + DEP5_FILE_LIST = auto(), None # TODO + + def interpreter(self) -> Optional[Interpretation[Any]]: + return self.value[1] @dataclasses.dataclass(slots=True, frozen=True) @@ -505,10 +562,11 @@ class Deb822KnownField: ) -> Iterable[Diagnostic]: unknown_value_severity = self.unknown_value_diagnostic_severity allowed_values = self.known_values - if not allowed_values: + interpreter = self.field_value_class.interpreter() + if not allowed_values or interpreter is None: return hint_text = None - values = kvpair.interpret_as(LIST_SPACE_SEPARATED_INTERPRETATION) + values = kvpair.interpret_as(interpreter) value_off = kvpair.value_element.position_in_parent().relative_to( field_position_te ) @@ -1053,9 +1111,9 @@ SOURCE_FIELDS = _fields( If it breaks and you cannot figure out how to fix it, then reset the field to `binary-targets` and move on until you have time to fix it. - The default value for this field depends on the ``dpkg-build-api`` version. If the package - `` Build-Depends`` on ``dpkg-build-api (>= 1)`` or later, the default is ``no``. Otherwise, - the default is ``binary-target`` + The default value for this field depends on the `dpkg-build-api` version. If the package + ` Build-Depends` on `dpkg-build-api (>= 1)` or later, the default is `no`. Otherwise, + the default is `binary-target` Note it is **not** possible to require running the package as "true root". """ @@ -2150,6 +2208,472 @@ _DEP5_LICENSE_FIELDS = _fields( ), ) +_DTESTSCTRL_FIELDS = _fields( + Deb822KnownField( + "Architecture", + FieldValueClass.SPACE_SEPARATED_LIST, + unknown_value_diagnostic_severity=None, + known_values=_allowed_values(*dpkg_arch_and_wildcards()), + hover_text=textwrap.dedent( + """\ + When package tests are only supported on a limited set of + architectures, or are known to not work on a particular (set of) + architecture(s), this field can be used to define the supported + architectures. The autopkgtest will be skipped when the + architecture of the testbed doesn't match the content of this + field. The format is the same as in (Build-)Depends, with the + understanding that `all` is not allowed, and `any` means that + the test will be run on every architecture, which is the default + when not specifying this field at all. + """ + ), + ), + Deb822KnownField( + "Classes", + FieldValueClass.FREE_TEXT_FIELD, + hover_text=textwrap.dedent( + """\ + Most package tests should work in a minimal environment and are + usually not hardware specific. However, some packages like the + kernel, X.org, or graphics drivers should be tested on particular + hardware, and also run on a set of different platforms rather than + just a single virtual testbeds. + + This field can specify a list of abstract class names such as + "desktop" or "graphics-driver". Consumers of autopkgtest can then + map these class names to particular machines/platforms/policies. + Unknown class names should be ignored. + + This is purely an informational field for autopkgtest itself and + will be ignored. + """ + ), + ), + Deb822KnownField( + "Depends", + FieldValueClass.COMMA_SEPARATED_LIST, + default_value="@", + hover_text="""\ + Declares that the specified packages must be installed for the test + to go ahead. This supports all features of dpkg dependencies, including + the architecture qualifiers (see + https://www.debian.org/doc/debian-policy/ch-relationships.html), + plus the following extensions: + + `@` stands for the package(s) generated by the source package + containing the tests; each dependency (strictly, or-clause, which + may contain `|`s but not commas) containing `@` is replicated + once for each such binary package, with the binary package name + substituted for each `@` (but normally `@` should occur only + once and without a version restriction). + + `@builddeps@` will be replaced by the package's + `Build-Depends:`, `Build-Depends-Indep:`, `Build-Depends-Arch:`, and + `build-essential`. This is useful if you have many build + dependencies which are only necessary for running the test suite and + you don't want to replicate them in the test `Depends:`. However, + please use this sparingly, as this can easily lead to missing binary + package dependencies being overlooked if they get pulled in via + build dependencies. + + `@recommends@` stands for all the packages listed in the + `Recommends:` fields of all the binary packages mentioned in the + `debian/control` file. Please note that variables are stripped, + so if some required test dependencies aren't explicitly mentioned, + they may not be installed. + + If no Depends field is present, `Depends: @` is assumed. Note that + the source tree's Build-Dependencies are *not* necessarily + installed, and if you specify any Depends, no binary packages from + the source are installed unless explicitly requested. + """, + ), + Deb822KnownField( + "Features", + FieldValueClass.COMMA_OR_SPACE_SEPARATED_LIST, + hover_text=textwrap.dedent( + """\ + Declares some additional capabilities or good properties of the + tests defined in this stanza. Any unknown features declared will be + completely ignored. See below for the defined features. + + Features are separated by commas and/or whitespace. + """ + ), + ), + Deb822KnownField( + "Restrictions", + FieldValueClass.COMMA_OR_SPACE_SEPARATED_LIST, + unknown_value_diagnostic_severity=DiagnosticSeverity.Warning, + known_values=_allowed_values( + Keyword( + "allow-stderr", + hover_text=textwrap.dedent( + """\ + Output to stderr is not considered a failure. This is useful for + tests which write e. g. lots of logging to stderr. + """ + ), + ), + Keyword( + "breaks-testbed", + hover_text=textwrap.dedent( + """\ + The test, when run, is liable to break the testbed system. This + includes causing data loss, causing services that the machine is + running to malfunction, or permanently disabling services; it does + not include causing services on the machine to temporarily fail. + + When this restriction is present the test will usually be skipped + unless the testbed's virtualisation arrangements are sufficiently + powerful, or alternatively if the user explicitly requests. + """ + ), + ), + Keyword( + "build-needed", + hover_text=textwrap.dedent( + """\ + The tests need to be run from a built source tree. The test runner + will build the source tree (honouring the source package's build + dependencies), before running the tests. However, the tests are + *not* entitled to assume that the source package's build + dependencies will be installed when the test is run. + + Please use this considerately, as for large builds it unnecessarily + builds the entire project when you only need a tiny subset (like the + tests/ subdirectory). It is often possible to run `make -C tests` + instead, or copy the test code to `$AUTOPKGTEST_TMP` and build it + there with some custom commands. This cuts down the load on the + Continuous Integration servers and also makes tests more robust as + it prevents accidentally running them against the built source tree + instead of the installed packages. + """ + ), + ), + Keyword( + "flaky", + hover_text=textwrap.dedent( + """\ + The test is expected to fail intermittently, and is not suitable for + gating continuous integration. This indicates a bug in either the + package under test, a dependency or the test itself, but such bugs + can be difficult to fix, and it is often difficult to know when the + bug has been fixed without running the test for a while. If a + `flaky` test succeeds, it will be treated like any other + successful test, but if it fails it will be treated as though it + had been skipped. + """ + ), + ), + Keyword( + "hint-testsuite-triggers", + hover_text=textwrap.dedent( + """\ + This test exists purely as a hint to suggest when rerunning the + tests is likely to be useful. Specifically, it exists to + influence the way dpkg-source generates the Testsuite-Triggers + .dsc header from test metadata: the Depends for this test are + to be added to Testsuite-Triggers. (Just as they are for any other + test.) + + The test with the hint-testsuite-triggers restriction should not + actually be run. + + The packages listed as Depends for this test are usually indirect + dependencies, updates to which are considered to pose a risk of + regressions in other tests defined in this package. + + There is currently no way to specify this hint on a per-test + basis; but in any case the debian.org machinery is not able to + think about triggering individual tests. + """ + ), + ), + Keyword( + "isolation-container", + hover_text=textwrap.dedent( + """\ + The test wants to start services or open network TCP ports. This + commonly fails in a simple chroot/schroot, so tests need to be run + in their own container (e. g. autopkgtest-virt-lxc) or their own + machine/VM (e. g. autopkgtest-virt-qemu or autopkgtest-virt-null). + When running the test in a virtualization server which does not + provide this (like autopkgtest-schroot) it will be skipped. + + Tests may assume that this restriction implies that process 1 in the + container's process namespace is a system service manager (init system) + such as systemd or sysvinit + sysv-rc, and therefore system services + are available via the `service(8)`, `invoke-rc.d(8)` and + `update-rc.d(8))` interfaces. + + Tests must not assume that a specific init system is in use: a + dependency such as `systemd-sysv` or `sysvinit-core` does not work + in practice, because switching the init system often cannot be done + automatically. Tests that require a specific init system should use the + `skippable` restriction, and skip the test if the required init system + was not detected. + + Many implementations of the `isolation-container` restriction will + also provide `systemd-logind(8)` or a compatible interface, but this + is not guaranteed. Tests requiring a login session registered with + logind should declare a dependency on `default-logind | logind` + or on a more specific implementation of `logind`, and should use the + `skippable` restriction to exit gracefully if its functionality is + not available at runtime. + + """ + ), + ), + Keyword( + "isolation-machine", + hover_text=textwrap.dedent( + """\ + The test wants to interact with the kernel, reboot the machine, or + other things which fail in a simple schroot and even a container. + Those tests need to be run in their own machine/VM (e. g. + autopkgtest-virt-qemu or autopkgtest-virt-null). When running the + test in a virtualization server which does not provide this it will + be skipped. + + This restriction also provides the same facilities as + `isolation-container`. + """ + ), + ), + Keyword( + "needs-internet", + hover_text=textwrap.dedent( + """\ + The test needs unrestricted internet access, e.g. to download test data + that's not shipped as a package, or to test a protocol implementation + against a test server. Please also see the note about Network access later + in this document. + """ + ), + ), + Keyword( + "needs-reboot", + hover_text=textwrap.dedent( + """\ + The test wants to reboot the machine using + `/tmp/autopkgtest-reboot`. + """ + ), + ), + Keyword( + "needs-recommends", + is_obsolete=True, + hover_text=textwrap.dedent( + """\ + Please use `@recommends@` in your test `Depends:` instead. + """ + ), + ), + Keyword( + "needs-root", + hover_text=textwrap.dedent( + """\ + The test script must be run as root. + + While running tests with this restriction, some test runners will + set the `AUTOPKGTEST_NORMAL_USER` environment variable to the name + of an ordinary user account. If so, the test script may drop + privileges from root to that user, for example via the `runuser` + command. Test scripts must not assume that this environment variable + will always be set. + + For tests that declare both the `needs-root` and `isolation-machine` + restrictions, the test may assume that it has "global root" with full + control over the kernel that is running the test, and not just root + in a container (more formally, it has uid 0 and full capabilities in + the initial user namespace as defined in `user_namespaces(7)`). + For example, it can expect that mounting block devices will succeed. + + For tests that declare the `needs-root` restriction but not the + `isolation-machine` restriction, the test will be run as uid 0 in + a user namespace with a reasonable range of system and user uids + available, but will not necessarily have full control over the kernel, + and in particular it is not guaranteed to have elevated capabilities + in the initial user namespace as defined by `user_namespaces(7)`. + For example, it might be run in a namespace where uid 0 is mapped to + an ordinary uid in the initial user namespace, or it might run in a + Docker-style container where global uid 0 is used but its ability to + carry out operations that affect the whole system is restricted by + capabilities and system call filtering. Tests requiring particular + privileges should use the `skippable` restriction to check for + required functionality at runtime. + """ + ), + ), + Keyword( + "needs-sudo", + hover_text=textwrap.dedent( + """\ + The test script needs to be run as a non-root user who is a member of + the `sudo` group, and has the ability to elevate privileges to root + on-demand. + + This is useful for testing user components which should not normally + be run as root, in test scenarios that require configuring a system + service to support the test. For example, gvfs has a test-case which + uses sudo for privileged configuration of a Samba server, so that + the unprivileged gvfs service under test can communicate with that server. + + While running a test with this restriction, `sudo(8)` will be + installed and configured to allow members of the `sudo` group to run + any command without password authentication. + + Because the test user is a member of the `sudo` group, they will + also gain the ability to take any other privileged actions that are + controlled by membership in that group. In particular, several packages + install `polkit(8)` policies allowing members of group `sudo` to + take administrative actions with or without authentication. + + If the test requires access to additional privileged actions, it may + use its access to `sudo(8)` to install additional configuration + files, for example configuring `polkit(8)` or `doas.conf(5)` + to allow running `pkexec(1)` or `doas(1)` without authentication. + + Commands run via `sudo(8)` or another privilege-elevation tool could + be run with either "global root" or root in a container, depending + on the presence or absence of the `isolation-machine` restriction, + in the same way described for `needs-root`. + """ + ), + ), + Keyword( + "rw-build-tree", + hover_text=textwrap.dedent( + """\ + The test(s) needs write access to the built source tree (so it may + need to be copied first). Even with this restriction, the test is + not allowed to make any change to the built source tree which (i) + isn't cleaned up by debian/rules clean, (ii) affects the future + results of any test, or (iii) affects binary packages produced by + the build tree in the future. + """ + ), + ), + Keyword( + "skip-not-installable", + hover_text=textwrap.dedent( + """\ + This restrictions may cause a test to miss a regression due to + installability issues, so use with caution. If one only wants to + skip certain architectures, use the `Architecture` field for + that. + + This test might have test dependencies that can't be fulfilled in + all suites or in derivatives. Therefore, when apt-get installs the + test dependencies, it will fail. Don't treat this as a test + failure, but instead treat it as if the test was skipped. + """ + ), + ), + Keyword( + "skippable", + hover_text=textwrap.dedent( + """\ + The test might need to be skipped for reasons that cannot be + described by an existing restriction such as isolation-machine or + breaks-testbed, but must instead be detected at runtime. If the + test exits with status 77 (a convention borrowed from Automake), it + will be treated as though it had been skipped. If it exits with any + other status, its success or failure will be derived from the exit + status and stderr as usual. Test authors must be careful to ensure + that `skippable` tests never exit with status 77 for reasons that + should be treated as a failure. + """ + ), + ), + Keyword( + "superficial", + hover_text=textwrap.dedent( + """\ + The test does not provide significant test coverage, so if it + passes, that does not necessarily mean that the package under test + is actually functional. If a `superficial` test fails, it will be + treated like any other failing test, but if it succeeds, this is + only a weak indication of success. Continuous integration systems + should treat a package where all non-superficial tests are skipped as + equivalent to a package where all tests are skipped. + + For example, a C library might have a superficial test that simply + compiles, links and executes a "hello world" program against the + library under test but does not attempt to make use of the library's + functionality, while a Python or Perl library might have a + superficial test that runs `import foo` or `require Foo;` but + does not attempt to use the library beyond that. + """ + ), + ), + ), + hover_text=textwrap.dedent( + """\ + Declares some restrictions or problems with the tests defined in + this stanza. Depending on the test environment capabilities, user + requests, and so on, restrictions can cause tests to be skipped or + can cause the test to be run in a different manner. Tests which + declare unknown restrictions will be skipped. See below for the + defined restrictions. + + Restrictions are separated by commas and/or whitespace. + """ + ), + ), + Deb822KnownField( + "Tests", + FieldValueClass.COMMA_OR_SPACE_SEPARATED_LIST, + hover_text=textwrap.dedent( + """\ + This field names the tests which are defined by this stanza, and map + to executables/scripts in the test directory. All of the other + fields in the same stanza apply to all of the named tests. Either + this field or `Test-Command:` must be present. + + Test names are separated by comma and/or whitespace and should + contain only characters which are legal in package names. It is + permitted, but not encouraged, to use upper-case characters as well. + """ + ), + ), + Deb822KnownField( + "Test-Command", + FieldValueClass.FREE_TEXT_FIELD, + hover_text=textwrap.dedent( + """\ + If your test only contains a shell command or two, or you want to + re-use an existing upstream test executable and just need to wrap it + with some command like `dbus-launch` or `env`, you can use this + field to specify the shell command directly. It will be run under + `bash -e`. This is mutually exclusive with the `Tests:` field. + + This is also useful for running the same script under different + interpreters and/or with different dependencies, such as + `Test-Command: python debian/tests/mytest.py` and + `Test-Command: python3 debian/tests/mytest.py`. + """ + ), + ), + Deb822KnownField( + "Test-Directory", + FieldValueClass.FREE_TEXT_FIELD, # TODO: Single path + hover_text=textwrap.dedent( + """\ + Replaces the path segment `debian/tests` in the filenames of the + test programs with `path`. I. e., the tests are run by executing + `built/source/tree/path/testname`. `path` must be a relative + path and is interpreted starting from the root of the built source + tree. + + This allows tests to live outside the debian/ metadata area, so that + they can more palatably be shared with non-Debian distributions. + """ + ), + ), +) + @dataclasses.dataclass(slots=True, frozen=True) class StanzaMetadata(Mapping[str, F], Generic[F], ABC): @@ -2196,6 +2720,17 @@ class DctrlStanzaMetadata(StanzaMetadata[DctrlKnownField]): pass +@dataclasses.dataclass(slots=True, frozen=True) +class DTestsCtrlStanzaMetadata(StanzaMetadata[Deb822KnownField]): + + def stanza_diagnostics( + self, + stanza: Deb822ParagraphElement, + stanza_position_in_file: "TEPosition", + ) -> Iterable[Diagnostic]: + pass + + class Deb822FileMetadata(Generic[S]): def classify_stanza(self, stanza: Deb822ParagraphElement, stanza_idx: int) -> S: return self.guess_stanza_classification_by_idx(stanza_idx) @@ -2241,6 +2776,8 @@ _DEP5_LICENSE_STANZA = Dep5StanzaMetadata( _DEP5_LICENSE_FIELDS, ) +_DTESTSCTRL_STANZA = DTestsCtrlStanzaMetadata("Tests", _DTESTSCTRL_FIELDS) + class Dep5FileMetadata(Deb822FileMetadata[Dep5StanzaMetadata]): def classify_stanza(self, stanza: Deb822ParagraphElement, stanza_idx: int) -> S: @@ -2292,3 +2829,18 @@ class DctrlFileMetadata(Deb822FileMetadata[DctrlStanzaMetadata]): if item == "Package": return _DCTRL_PACKAGE_STANZA raise KeyError(item) + + +class DTestsCtrlFileMetadata(Deb822FileMetadata[DctrlStanzaMetadata]): + def guess_stanza_classification_by_idx(self, stanza_idx: int) -> S: + if stanza_idx >= 0: + return _DTESTSCTRL_STANZA + raise ValueError("The stanza_idx must be 0 or greater") + + def stanza_types(self) -> Iterable[S]: + yield _DTESTSCTRL_STANZA + + def __getitem__(self, item: str) -> S: + if item == "Tests": + return _DTESTSCTRL_STANZA + raise KeyError(item) diff --git a/src/debputy/lsp/lsp_debian_copyright.py b/src/debputy/lsp/lsp_debian_copyright.py index a1bf8e6..f96ed1a 100644 --- a/src/debputy/lsp/lsp_debian_copyright.py +++ b/src/debputy/lsp/lsp_debian_copyright.py @@ -24,7 +24,6 @@ from lsprotocol.types import ( HoverParams, Hover, TEXT_DOCUMENT_CODE_ACTION, - DiagnosticTag, SemanticTokens, SemanticTokensParams, FoldingRangeParams, @@ -53,7 +52,6 @@ from debputy.lsp.lsp_generic_deb822 import ( deb822_semantic_tokens_full, ) from debputy.lsp.quickfixes import ( - propose_remove_line_quick_fix, propose_correct_text_quick_fix, ) from debputy.lsp.spellchecking import default_spellchecker @@ -74,7 +72,6 @@ from debputy.lsp.vendoring._deb822_repro.parsing import ( ) from debputy.lsp.vendoring._deb822_repro.tokens import ( Deb822Token, - Deb822FieldNameToken, ) try: diff --git a/src/debputy/lsp/lsp_debian_debputy_manifest.py b/src/debputy/lsp/lsp_debian_debputy_manifest.py index 1dc5fb3..d24d441 100644 --- a/src/debputy/lsp/lsp_debian_debputy_manifest.py +++ b/src/debputy/lsp/lsp_debian_debputy_manifest.py @@ -54,6 +54,7 @@ from debputy.lsp.text_util import ( from debputy.manifest_parser.declarative_parser import ( AttributeDescription, ParserGenerator, + DeclarativeNonMappingInputParser, ) from debputy.manifest_parser.declarative_parser import DeclarativeMappingInputParser from debputy.manifest_parser.parser_doc import ( @@ -307,7 +308,6 @@ def _trace_cursor( break matched = v matched_key = k - matched_was_key = False elif isinstance(content, CommentedSeq): list_lc: LineCol = content.lc for idx, value in enumerate(content): @@ -481,13 +481,25 @@ def _insert_snippet(lines: List[str], server_position: Position) -> bool: if pos_rhs and not pos_rhs.isspace(): _info(f"No insertion: {_ecsape(line[server_position.character:])}") return False - lhs = line[: server_position.character].strip() - if not lhs: - _info(f"Insertion of key: {_ecsape(line[server_position.character:])}") + lhs_ws = line[: server_position.character] + lhs = lhs_ws.strip() + if lhs.endswith(":"): + _info("Insertion of value (key seen)") + new_line = line[: server_position.character] + _COMPLETION_HINT_VALUE + elif lhs.startswith("-"): + _info("Insertion of key or value (list item)") + # Respect the provided indentation + snippet = _COMPLETION_HINT_KEY if ":" not in lhs else _COMPLETION_HINT_VALUE + new_line = line[: server_position.character] + snippet + elif not lhs or (lhs_ws and not lhs_ws[0].isspace()): + _info(f"Insertion of key or value: {_ecsape(line[server_position.character:])}") + # Respect the provided indentation + snippet = _COMPLETION_HINT_KEY if ":" not in lhs else _COMPLETION_HINT_VALUE + new_line = line[: server_position.character] + snippet + elif lhs.isalpha() and ":" not in lhs: + _info(f"Expanding value to a key: {_ecsape(line[server_position.character:])}") # Respect the provided indentation new_line = line[: server_position.character] + _COMPLETION_HINT_KEY - elif lhs.endswith(":"): - new_line = line[: server_position.character] + _COMPLETION_HINT_VALUE else: c = line[server_position.character] _info(f"Not touching line: {_ecsape(line)} -- {_ecsape(c)}") @@ -513,6 +525,7 @@ def debputy_manifest_completer( added_key = _insert_snippet(lines, server_position) attempts = 1 if added_key else 2 content = None + while attempts > 0: attempts -= 1 try: @@ -531,6 +544,7 @@ def debputy_manifest_completer( if e.problem_mark.line < len(lines) else "N/A (OOB)" ) + _info(f"Parse error on line: {e.problem_mark.line}: {l_data}") return None @@ -540,26 +554,22 @@ def debputy_manifest_completer( lines[server_position.line] = new_line except YAMLError: break - if content is None: context = lines[server_position.line].replace("\n", "\\n") _info(f"Completion failed: parse error: Line in question: {context}") return None - m = _trace_cursor(content, attribute_root_path, server_position) + if m is None: _info("No match") 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() root_parser = feature_set.manifest_parser_generator.dispatchable_object_parsers[ OPARSER_MANIFEST_ROOT ] segments = list(attr_path.path_segments()) - if added_key: - segments.pop() km = resolve_keyword( root_parser, DEBPUTY_PLUGIN_METADATA, @@ -586,18 +596,29 @@ def debputy_manifest_completer( ) ] else: - _info("TODO: Match value") + items = [ + CompletionItem(k) + for k in parser.registered_keywords() + if k not in parent + and isinstance( + parser.parser_for(k).parser, + DeclarativeValuelessKeywordInputParser, + ) + ] elif isinstance(parser, InPackageContextParser): # doc = ls.workspace.get_text_document(params.text_document.uri) _info(f"TODO: Match package - {parent} -- {matched} -- {matched_key=}") elif isinstance(parser, DeclarativeMappingInputParser): - print(f"MMM: {matched} - {parent}") if matched_key: _info("Match attributes") locked = set(parent) for mx in parser.mutually_exclusive_attributes: if not mx.isdisjoint(parent.keys()): locked.update(mx) + for attr_name, attr in parser.manifest_attributes.items(): + if not attr.conflicting_attributes.isdisjoint(parent.keys()): + locked.add(attr_name) + break items = [ CompletionItem(f"{k}:") for k in parser.manifest_attributes @@ -618,6 +639,13 @@ def debputy_manifest_completer( _info( f"Expand value / key: {key} -- !! {list(parser.manifest_attributes)}" ) + elif isinstance(parser, DeclarativeNonMappingInputParser): + attr = parser.alt_form_parser + items = _completion_from_attr( + attr, + feature_set.manifest_parser_generator, + matched, + ) return items diff --git a/src/debputy/lsp/lsp_debian_tests_control.py b/src/debputy/lsp/lsp_debian_tests_control.py new file mode 100644 index 0000000..9153026 --- /dev/null +++ b/src/debputy/lsp/lsp_debian_tests_control.py @@ -0,0 +1,485 @@ +import re +from typing import ( + Union, + Sequence, + Tuple, + Iterator, + Optional, + Iterable, + Mapping, + List, +) + +from lsprotocol.types import ( + DiagnosticSeverity, + Range, + Diagnostic, + Position, + CompletionItem, + CompletionList, + CompletionParams, + TEXT_DOCUMENT_WILL_SAVE_WAIT_UNTIL, + DiagnosticRelatedInformation, + Location, + HoverParams, + Hover, + TEXT_DOCUMENT_CODE_ACTION, + SemanticTokens, + SemanticTokensParams, + FoldingRangeParams, + FoldingRange, +) + +from debputy.lsp.lsp_debian_control_reference_data import ( + Deb822KnownField, + DTestsCtrlFileMetadata, + _DTESTSCTRL_FIELDS, +) +from debputy.lsp.lsp_features import ( + lint_diagnostics, + lsp_completer, + lsp_hover, + lsp_standard_handler, + lsp_folding_ranges, + lsp_semantic_tokens_full, +) +from debputy.lsp.lsp_generic_deb822 import ( + deb822_completer, + deb822_hover, + deb822_folding_ranges, + deb822_semantic_tokens_full, +) +from debputy.lsp.quickfixes import ( + propose_correct_text_quick_fix, +) +from debputy.lsp.spellchecking import default_spellchecker +from debputy.lsp.text_util import ( + normalize_dctrl_field_name, + LintCapablePositionCodec, + detect_possible_typo, + te_range_to_lsp, +) +from debputy.lsp.vendoring._deb822_repro import ( + parse_deb822_file, + Deb822FileElement, + Deb822ParagraphElement, +) +from debputy.lsp.vendoring._deb822_repro.parsing import ( + Deb822KeyValuePairElement, + LIST_SPACE_SEPARATED_INTERPRETATION, +) +from debputy.lsp.vendoring._deb822_repro.tokens import ( + Deb822Token, +) + +try: + from debputy.lsp.vendoring._deb822_repro.locatable import ( + Position as TEPosition, + Range as TERange, + START_POSITION, + ) + + from pygls.server import LanguageServer + from pygls.workspace import TextDocument +except ImportError: + pass + + +_CONTAINS_SPACE_OR_COLON = re.compile(r"[\s:]") +_LANGUAGE_IDS = [ + "debian/tests/control", + # emacs's name - expected in elpa-dpkg-dev-el (>> 37.11) + "debian-autopkgtest-control-mode", + # Likely to be vim's name if it had support + "debtestscontrol", +] + +_DEP5_FILE_METADATA = DTestsCtrlFileMetadata() + +lsp_standard_handler(_LANGUAGE_IDS, TEXT_DOCUMENT_CODE_ACTION) +lsp_standard_handler(_LANGUAGE_IDS, TEXT_DOCUMENT_WILL_SAVE_WAIT_UNTIL) + + +@lsp_hover(_LANGUAGE_IDS) +def debian_tests_control_hover( + ls: "LanguageServer", + params: HoverParams, +) -> Optional[Hover]: + return deb822_hover(ls, params, _DEP5_FILE_METADATA) + + +@lsp_completer(_LANGUAGE_IDS) +def debian_tests_control_completions( + ls: "LanguageServer", + params: CompletionParams, +) -> Optional[Union[CompletionList, Sequence[CompletionItem]]]: + return deb822_completer(ls, params, _DEP5_FILE_METADATA) + + +@lsp_folding_ranges(_LANGUAGE_IDS) +def debian_tests_control_folding_ranges( + ls: "LanguageServer", + params: FoldingRangeParams, +) -> Optional[Sequence[FoldingRange]]: + return deb822_folding_ranges(ls, params, _DEP5_FILE_METADATA) + + +def _deb822_token_iter( + tokens: Iterable[Deb822Token], +) -> Iterator[Tuple[Deb822Token, int, int, int, int, int]]: + line_no = 0 + line_offset = 0 + + for token in tokens: + start_line = line_no + start_line_offset = line_offset + + newlines = token.text.count("\n") + line_no += newlines + text_len = len(token.text) + if newlines: + if token.text.endswith("\n"): + line_offset = 0 + else: + # -2, one to remove the "\n" and one to get 0-offset + line_offset = text_len - token.text.rindex("\n") - 2 + else: + line_offset += text_len + + yield token, start_line, start_line_offset, line_no, line_offset + + +def _paragraph_representation_field( + paragraph: Deb822ParagraphElement, +) -> Deb822KeyValuePairElement: + return next(iter(paragraph.iter_parts_of_type(Deb822KeyValuePairElement))) + + +def _diagnostics_for_paragraph( + stanza: Deb822ParagraphElement, + stanza_position: "TEPosition", + known_fields: Mapping[str, Deb822KnownField], + doc_reference: str, + position_codec: "LintCapablePositionCodec", + lines: List[str], + diagnostics: List[Diagnostic], +) -> None: + representation_field = _paragraph_representation_field(stanza) + representation_field_pos = representation_field.position_in_parent().relative_to( + stanza_position + ) + representation_field_range_server_units = te_range_to_lsp( + TERange.from_position_and_size( + representation_field_pos, representation_field.size() + ) + ) + representation_field_range = position_codec.range_to_client_units( + lines, + representation_field_range_server_units, + ) + for known_field in known_fields.values(): + missing_field_severity = known_field.missing_field_severity + if missing_field_severity is None or known_field.name in stanza: + continue + + diagnostics.append( + Diagnostic( + representation_field_range, + f"Stanza is missing field {known_field.name}", + severity=missing_field_severity, + source="debputy", + ) + ) + + if "Tests" not in stanza and "Test-Command" not in stanza: + diagnostics.append( + Diagnostic( + representation_field_range, + f'Stanza must have either a "Tests" or a "Test-Command" field', + severity=DiagnosticSeverity.Error, + source="debputy", + ) + ) + if "Tests" in stanza and "Test-Command" in stanza: + diagnostics.append( + Diagnostic( + representation_field_range, + 'Stanza cannot have both a "Tests" and a "Test-Command" field', + severity=DiagnosticSeverity.Error, + source="debputy", + ) + ) + + seen_fields = {} + + for kvpair in stanza.iter_parts_of_type(Deb822KeyValuePairElement): + field_name_token = kvpair.field_token + field_name = field_name_token.text + field_name_lc = field_name.lower() + normalized_field_name_lc = normalize_dctrl_field_name(field_name_lc) + known_field = known_fields.get(normalized_field_name_lc) + field_value = stanza[field_name] + field_range_te = kvpair.range_in_parent().relative_to(stanza_position) + field_position_te = field_range_te.start_pos + field_range_server_units = te_range_to_lsp(field_range_te) + field_range = position_codec.range_to_client_units( + lines, + field_range_server_units, + ) + field_name_typo_detected = False + existing_field_range = seen_fields.get(normalized_field_name_lc) + if existing_field_range is not None: + existing_field_range[3].append(field_range) + else: + normalized_field_name = normalize_dctrl_field_name(field_name) + seen_fields[field_name_lc] = ( + field_name, + normalized_field_name, + field_range, + [], + ) + + if known_field is None: + candidates = detect_possible_typo(normalized_field_name_lc, known_fields) + if candidates: + known_field = known_fields[candidates[0]] + token_range_server_units = te_range_to_lsp( + TERange.from_position_and_size( + field_position_te, kvpair.field_token.size() + ) + ) + field_range = position_codec.range_to_client_units( + lines, + token_range_server_units, + ) + field_name_typo_detected = True + diagnostics.append( + Diagnostic( + field_range, + f'The "{field_name}" looks like a typo of "{known_field.name}".', + severity=DiagnosticSeverity.Warning, + source="debputy", + data=[ + propose_correct_text_quick_fix(known_fields[m].name) + for m in candidates + ], + ) + ) + if field_value.strip() == "": + diagnostics.append( + Diagnostic( + field_range, + f"The {field_name} has no value. Either provide a value or remove it.", + severity=DiagnosticSeverity.Error, + source="debputy", + ) + ) + continue + diagnostics.extend( + known_field.field_diagnostics( + kvpair, + stanza, + stanza_position, + position_codec, + lines, + field_name_typo_reported=field_name_typo_detected, + ) + ) + if known_field.spellcheck_value: + words = kvpair.interpret_as(LIST_SPACE_SEPARATED_INTERPRETATION) + spell_checker = default_spellchecker() + value_position = kvpair.value_element.position_in_parent().relative_to( + field_position_te + ) + for word_ref in words.iter_value_references(): + token = word_ref.value + for word, pos, endpos in spell_checker.iter_words(token): + corrections = spell_checker.provide_corrections_for(word) + if not corrections: + continue + word_loc = word_ref.locatable + word_pos_te = word_loc.position_in_parent().relative_to( + value_position + ) + if pos: + word_pos_te = TEPosition(0, pos).relative_to(word_pos_te) + word_range = TERange( + START_POSITION, + TEPosition(0, endpos - pos), + ) + word_range_server_units = te_range_to_lsp( + TERange.from_position_and_size(word_pos_te, word_range) + ) + word_range = position_codec.range_to_client_units( + lines, + word_range_server_units, + ) + diagnostics.append( + Diagnostic( + word_range, + f'Spelling "{word}"', + severity=DiagnosticSeverity.Hint, + source="debputy", + data=[ + propose_correct_text_quick_fix(c) for c in corrections + ], + ) + ) + if known_field.warn_if_default and field_value == known_field.default_value: + diagnostics.append( + Diagnostic( + field_range, + f"The {field_name} is redundant as it is set to the default value and the field should only be" + " used in exceptional cases.", + severity=DiagnosticSeverity.Warning, + source="debputy", + ) + ) + for ( + field_name, + normalized_field_name, + field_range, + duplicates, + ) in seen_fields.values(): + if not duplicates: + continue + related_information = [ + DiagnosticRelatedInformation( + location=Location(doc_reference, field_range), + message=f"First definition of {field_name}", + ) + ] + related_information.extend( + DiagnosticRelatedInformation( + location=Location(doc_reference, r), + message=f"Duplicate of {field_name}", + ) + for r in duplicates + ) + for dup_range in duplicates: + diagnostics.append( + Diagnostic( + dup_range, + f"The {normalized_field_name} field name was used multiple times in this stanza." + f" Please ensure the field is only used once per stanza.", + severity=DiagnosticSeverity.Error, + source="debputy", + related_information=related_information, + ) + ) + + +def _scan_for_syntax_errors_and_token_level_diagnostics( + deb822_file: Deb822FileElement, + position_codec: LintCapablePositionCodec, + lines: List[str], + diagnostics: List[Diagnostic], +) -> int: + first_error = len(lines) + 1 + spell_checker = default_spellchecker() + for ( + token, + start_line, + start_offset, + end_line, + end_offset, + ) in _deb822_token_iter(deb822_file.iter_tokens()): + if token.is_error: + first_error = min(first_error, start_line) + start_pos = Position( + start_line, + start_offset, + ) + end_pos = Position( + end_line, + end_offset, + ) + token_range = position_codec.range_to_client_units( + lines, Range(start_pos, end_pos) + ) + diagnostics.append( + Diagnostic( + token_range, + "Syntax error", + severity=DiagnosticSeverity.Error, + source="debputy (python-debian parser)", + ) + ) + elif token.is_comment: + for word, pos, end_pos in spell_checker.iter_words(token.text): + corrections = spell_checker.provide_corrections_for(word) + if not corrections: + continue + start_pos = Position( + start_line, + pos, + ) + end_pos = Position( + start_line, + end_pos, + ) + word_range = position_codec.range_to_client_units( + lines, Range(start_pos, end_pos) + ) + diagnostics.append( + Diagnostic( + word_range, + f'Spelling "{word}"', + severity=DiagnosticSeverity.Hint, + source="debputy", + data=[propose_correct_text_quick_fix(c) for c in corrections], + ) + ) + return first_error + + +@lint_diagnostics(_LANGUAGE_IDS) +def _lint_debian_tests_control( + doc_reference: str, + _path: str, + lines: List[str], + position_codec: LintCapablePositionCodec, +) -> Optional[List[Diagnostic]]: + diagnostics = [] + deb822_file = parse_deb822_file( + lines, + accept_files_with_duplicated_fields=True, + accept_files_with_error_tokens=True, + ) + + first_error = _scan_for_syntax_errors_and_token_level_diagnostics( + deb822_file, + position_codec, + lines, + diagnostics, + ) + + paragraphs = list(deb822_file) + + for paragraph_no, paragraph in enumerate(paragraphs, start=1): + paragraph_pos = paragraph.position_in_file() + if paragraph_pos.line_position >= first_error: + break + known_fields = _DTESTSCTRL_FIELDS + _diagnostics_for_paragraph( + paragraph, + paragraph_pos, + known_fields, + doc_reference, + position_codec, + lines, + diagnostics, + ) + return diagnostics + + +@lsp_semantic_tokens_full(_LANGUAGE_IDS) +def _semantic_tokens_full( + ls: "LanguageServer", + request: SemanticTokensParams, +) -> Optional[SemanticTokens]: + return deb822_semantic_tokens_full( + ls, + request, + _DEP5_FILE_METADATA, + ) diff --git a/tests/lsp_tests/conftest.py b/tests/lsp_tests/conftest.py index 8b42582..2aff4a2 100644 --- a/tests/lsp_tests/conftest.py +++ b/tests/lsp_tests/conftest.py @@ -1,5 +1,7 @@ import pytest + from debputy.plugin.api.feature_set import PluginProvidedFeatureSet +from debputy.util import setup_logging try: from pygls.server import LanguageServer @@ -22,8 +24,16 @@ except ImportError: HAS_PYGLS = False +@pytest.fixture(scope="session") +def enable_logging() -> None: + setup_logging(log_only_to_stderr=True, reconfigure_logging=True) + + @pytest.fixture() -def ls(debputy_plugin_feature_set: PluginProvidedFeatureSet) -> "LanguageServer": +def ls( + enable_logging, + debputy_plugin_feature_set: PluginProvidedFeatureSet, +) -> "LanguageServer": if not HAS_PYGLS: pytest.skip("Missing pygls") ls = LanguageServer("debputy", "v") diff --git a/tests/lsp_tests/lsp_tutil.py b/tests/lsp_tests/lsp_tutil.py index 1e509af..2873f72 100644 --- a/tests/lsp_tests/lsp_tutil.py +++ b/tests/lsp_tests/lsp_tutil.py @@ -30,10 +30,12 @@ def put_doc_with_cursor( uri: str, language_id: str, content: str, - *, - doc_version: int = 1, ) -> "Position": cleaned_content, cursor_pos = _locate_cursor(content) + doc_version = 1 + existing = ls.workspace.text_documents.get(uri) + if existing is not None: + doc_version = existing.version + 1 ls.workspace.put_text_document( TextDocumentItem( uri, diff --git a/tests/lsp_tests/test_lsp_dctrl.py b/tests/lsp_tests/test_lsp_dctrl.py index 2a2466f..d258e8f 100644 --- a/tests/lsp_tests/test_lsp_dctrl.py +++ b/tests/lsp_tests/test_lsp_dctrl.py @@ -39,7 +39,7 @@ def test_dctrl_complete_field(ls: "LanguageServer") -> None: ls, CompletionParams(TextDocumentIdentifier(dctrl_uri), cursor_pos), ) - assert matches + assert isinstance(matches, list) keywords = {m.label for m in matches} assert "Multi-Arch" in keywords assert "Architecture" in keywords diff --git a/tests/lsp_tests/test_lsp_debputy_manifest_completer.py b/tests/lsp_tests/test_lsp_debputy_manifest_completer.py new file mode 100644 index 0000000..c72e791 --- /dev/null +++ b/tests/lsp_tests/test_lsp_debputy_manifest_completer.py @@ -0,0 +1,598 @@ +import textwrap + +import pytest + +from lsp_tests.lsp_tutil import put_doc_with_cursor + +try: + from pygls.server import LanguageServer + from lsprotocol.types import ( + InitializeParams, + ClientCapabilities, + GeneralClientCapabilities, + PositionEncodingKind, + TextDocumentItem, + Position, + CompletionParams, + TextDocumentIdentifier, + HoverParams, + MarkupContent, + ) + from debputy.lsp.lsp_debian_debputy_manifest import debputy_manifest_completer + + HAS_PYGLS = True +except ImportError: + HAS_PYGLS = False + + +def test_basic_debputy_completer_empty(ls: "LanguageServer") -> None: + debputy_manifest_uri = "file:///nowhere/debian/debputy.manifest" + cursor_pos = put_doc_with_cursor( + ls, + debputy_manifest_uri, + "debian/debputy.manifest", + textwrap.dedent( + """\ + +""" + ), + ) + + completions = debputy_manifest_completer( + ls, + CompletionParams(TextDocumentIdentifier(debputy_manifest_uri), cursor_pos), + ) + assert isinstance(completions, list) + keywords = {m.label for m in completions} + assert "definitions:" in keywords + assert "manifest-version:" in keywords + assert "installations:" in keywords + + cursor_pos = put_doc_with_cursor( + ls, + debputy_manifest_uri, + "debian/debputy.manifest", + textwrap.dedent( + """\ + manif +""" + ), + ) + + completions = debputy_manifest_completer( + ls, + CompletionParams(TextDocumentIdentifier(debputy_manifest_uri), cursor_pos), + ) + assert isinstance(completions, list) + keywords = {m.label for m in completions} + assert "manifest-version:" in keywords + assert "packages:" in keywords + # We rely on client side filtering + assert "installations:" in keywords + + cursor_pos = put_doc_with_cursor( + ls, + debputy_manifest_uri, + "debian/debputy.manifest", + textwrap.dedent( + """\ + manifest-version: 0.1 + +""" + ), + ) + + completions = debputy_manifest_completer( + ls, + CompletionParams(TextDocumentIdentifier(debputy_manifest_uri), cursor_pos), + ) + assert isinstance(completions, list) + keywords = {m.label for m in completions} + assert "definitions:" in keywords + assert "installations:" in keywords + assert "packages:" in keywords + # Already completed + assert "manifest-version:" not in keywords + + cursor_pos = put_doc_with_cursor( + ls, + debputy_manifest_uri, + "debian/debputy.manifest", + textwrap.dedent( + """\ + manifest-version: 0.1 + installations: + - install-docs: + sources: + - foo + - bar + +""" + ), + ) + + completions = debputy_manifest_completer( + ls, + CompletionParams(TextDocumentIdentifier(debputy_manifest_uri), cursor_pos), + ) + assert isinstance(completions, list) + keywords = {m.label for m in completions} + assert "definitions:" in keywords + assert "packages:" in keywords + # Already completed + assert "manifest-version:" not in keywords + assert "installations:" not in keywords + + cursor_pos = put_doc_with_cursor( + ls, + debputy_manifest_uri, + "debian/debputy.manifest", + textwrap.dedent( + """\ + manifest-version: 0.1 + installations: + - install-docs: + sources: + - foo + - bar + packa +""" + ), + ) + + completions = debputy_manifest_completer( + ls, + CompletionParams(TextDocumentIdentifier(debputy_manifest_uri), cursor_pos), + ) + assert isinstance(completions, list) + keywords = {m.label for m in completions} + assert "definitions:" in keywords + assert "packages:" in keywords + # Already completed + assert "manifest-version:" not in keywords + assert "installations:" not in keywords + + cursor_pos = put_doc_with_cursor( + ls, + debputy_manifest_uri, + "debian/debputy.manifest", + textwrap.dedent( + """\ + manifest-version: 0.1 + installations: + - install-docs: + sources: + - foo + - bar + packages: + foo: + services: + - service: foo + service-scope: user + +""" + ), + ) + + completions = debputy_manifest_completer( + ls, + CompletionParams(TextDocumentIdentifier(debputy_manifest_uri), cursor_pos), + ) + assert isinstance(completions, list) + keywords = {m.label for m in completions} + assert "definitions:" in keywords + # Already completed + assert "manifest-version:" not in keywords + assert "installations:" not in keywords + assert "packages:" not in keywords + + +def test_basic_debputy_completer_manifest_variable_value(ls: "LanguageServer") -> None: + debputy_manifest_uri = "file:///nowhere/debian/debputy.manifest" + cursor_pos = put_doc_with_cursor( + ls, + debputy_manifest_uri, + "debian/debputy.manifest", + textwrap.dedent( + """\ + manifest-version: +""" + ), + ) + + completions = debputy_manifest_completer( + ls, + CompletionParams(TextDocumentIdentifier(debputy_manifest_uri), cursor_pos), + ) + assert isinstance(completions, list) + keywords = {m.label for m in completions} + assert "0.1" in keywords + + cursor_pos = put_doc_with_cursor( + ls, + debputy_manifest_uri, + "debian/debputy.manifest", + textwrap.dedent( + """\ + manifest-version: 0. +""" + ), + ) + + completions = debputy_manifest_completer( + ls, + CompletionParams(TextDocumentIdentifier(debputy_manifest_uri), cursor_pos), + ) + assert isinstance(completions, list) + keywords = {m.label for m in completions} + assert "0.1" in keywords + + +def test_basic_debputy_completer_install_rule_dispatch_key( + ls: "LanguageServer", +) -> None: + debputy_manifest_uri = "file:///nowhere/debian/debputy.manifest" + cursor_pos = put_doc_with_cursor( + ls, + debputy_manifest_uri, + "debian/debputy.manifest", + textwrap.dedent( + """\ + manifest-version: 0.1 + installations: + - +""" + ), + ) + + completions = debputy_manifest_completer( + ls, + CompletionParams(TextDocumentIdentifier(debputy_manifest_uri), cursor_pos), + ) + assert isinstance(completions, list) + keywords = {m.label for m in completions} + assert "install:" in keywords + assert "install-doc:" in keywords + assert "install-docs:" in keywords + + cursor_pos = put_doc_with_cursor( + ls, + debputy_manifest_uri, + "debian/debputy.manifest", + textwrap.dedent( + """\ + manifest-version: 0.1 + installations: + - i +""" + ), + ) + + completions = debputy_manifest_completer( + ls, + CompletionParams(TextDocumentIdentifier(debputy_manifest_uri), cursor_pos), + ) + assert isinstance(completions, list) + keywords = {m.label for m in completions} + assert "install:" in keywords + assert "install-doc:" in keywords + assert "install-docs:" in keywords + + +def test_basic_debputy_completer_install_rule_install_keys( + ls: "LanguageServer", +) -> None: + debputy_manifest_uri = "file:///nowhere/debian/debputy.manifest" + cursor_pos = put_doc_with_cursor( + ls, + debputy_manifest_uri, + "debian/debputy.manifest", + textwrap.dedent( + """\ + manifest-version: 0.1 + installations: + - install: + +""" + ), + ) + completions = debputy_manifest_completer( + ls, + CompletionParams(TextDocumentIdentifier(debputy_manifest_uri), cursor_pos), + ) + assert isinstance(completions, list) + keywords = {m.label for m in completions} + assert "source:" in keywords + assert "sources:" in keywords + assert "as:" in keywords + assert "dest-dir:" in keywords + + cursor_pos = put_doc_with_cursor( + ls, + debputy_manifest_uri, + "debian/debputy.manifest", + textwrap.dedent( + """\ + manifest-version: 0.1 + installations: + - install: + sources: + - foo + - bar + +""" + ), + ) + completions = debputy_manifest_completer( + ls, + CompletionParams(TextDocumentIdentifier(debputy_manifest_uri), cursor_pos), + ) + assert isinstance(completions, list) + keywords = {m.label for m in completions} + assert "dest-dir:" in keywords + # Already completed + assert "sources:" not in keywords + + # Not possible (conflict) + assert "source:" not in keywords + assert "as:" not in keywords + + +def test_basic_debputy_completer_packages_foo( + ls: "LanguageServer", +) -> None: + debputy_manifest_uri = "file:///nowhere/debian/debputy.manifest" + + cursor_pos = put_doc_with_cursor( + ls, + debputy_manifest_uri, + "debian/debputy.manifest", + textwrap.dedent( + """\ + manifest-version: 0.1 + packages: + foo: + +""" + ), + ) + completions = debputy_manifest_completer( + ls, + CompletionParams(TextDocumentIdentifier(debputy_manifest_uri), cursor_pos), + ) + assert isinstance(completions, list) + keywords = {m.label for m in completions} + assert "binary-version:" in keywords + assert "services:" in keywords + assert "transformations:" in keywords + + +def test_basic_debputy_completer_packages_foo_xfail( + ls: "LanguageServer", +) -> None: + debputy_manifest_uri = "file:///nowhere/debian/debputy.manifest" + cursor_pos = put_doc_with_cursor( + ls, + debputy_manifest_uri, + "debian/debputy.manifest", + textwrap.dedent( + """\ + manifest-version: 0.1 + packages: + foo: + bin +""" + ), + ) + completions = debputy_manifest_completer( + ls, + CompletionParams(TextDocumentIdentifier(debputy_manifest_uri), cursor_pos), + ) + assert isinstance(completions, list) + keywords = {m.label for m in completions} + assert "binary-version:" in keywords + assert "services:" in keywords + assert "transformations:" in keywords + + +def test_basic_debputy_completer_services_service_scope_values( + ls: "LanguageServer", +) -> None: + debputy_manifest_uri = "file:///nowhere/debian/debputy.manifest" + + cursor_pos = put_doc_with_cursor( + ls, + debputy_manifest_uri, + "debian/debputy.manifest", + textwrap.dedent( + """\ + manifest-version: 0.1 + packages: + foo: + services: + - service: foo + service-scope: +""" + ), + ) + completions = debputy_manifest_completer( + ls, + CompletionParams(TextDocumentIdentifier(debputy_manifest_uri), cursor_pos), + ) + assert isinstance(completions, list) + keywords = {m.label for m in completions} + assert keywords == {"system", "user"} + + cursor_pos = put_doc_with_cursor( + ls, + debputy_manifest_uri, + "debian/debputy.manifest", + textwrap.dedent( + """\ + manifest-version: 0.1 + packages: + foo: + services: + - service: foo + service-scope: s +""" + ), + ) + completions = debputy_manifest_completer( + ls, + CompletionParams(TextDocumentIdentifier(debputy_manifest_uri), cursor_pos), + ) + assert isinstance(completions, list) + keywords = {m.label for m in completions} + assert keywords == {"system", "user"} + + cursor_pos = put_doc_with_cursor( + ls, + debputy_manifest_uri, + "debian/debputy.manifest", + textwrap.dedent( + """\ + manifest-version: 0.1 + packages: + foo: + services: + - service: foo + service-scope: system + enable-on-install: +""" + ), + ) + completions = debputy_manifest_completer( + ls, + CompletionParams(TextDocumentIdentifier(debputy_manifest_uri), cursor_pos), + ) + assert isinstance(completions, list) + keywords = {m.label for m in completions} + assert keywords == {"true", "false"} + + cursor_pos = put_doc_with_cursor( + ls, + debputy_manifest_uri, + "debian/debputy.manifest", + textwrap.dedent( + """\ + manifest-version: 0.1 + packages: + foo: + services: + - service: foo + service-scope: system + enable-on-install: tr +""" + ), + ) + completions = debputy_manifest_completer( + ls, + CompletionParams(TextDocumentIdentifier(debputy_manifest_uri), cursor_pos), + ) + assert isinstance(completions, list) + keywords = {m.label for m in completions} + # "false" is ok, because we rely on client side filtering + assert keywords == {"true", "false"} + + +def test_basic_debputy_completer_manifest_conditions( + ls: "LanguageServer", +) -> None: + debputy_manifest_uri = "file:///nowhere/debian/debputy.manifest" + + cursor_pos = put_doc_with_cursor( + ls, + debputy_manifest_uri, + "debian/debputy.manifest", + textwrap.dedent( + """\ + manifest-version: 0.1 + installations: + - install-docs: + when: +""" + ), + ) + completions = debputy_manifest_completer( + ls, + CompletionParams(TextDocumentIdentifier(debputy_manifest_uri), cursor_pos), + ) + assert isinstance(completions, list) + keywords = {m.label for m in completions} + assert "cross-compiling" in keywords + # Mapping-only forms are not applicable here + assert "not" not in keywords + assert "not:" not in keywords + + cursor_pos = put_doc_with_cursor( + ls, + debputy_manifest_uri, + "debian/debputy.manifest", + textwrap.dedent( + """\ + manifest-version: 0.1 + installations: + - install-docs: + when: c +""" + ), + ) + completions = debputy_manifest_completer( + ls, + CompletionParams(TextDocumentIdentifier(debputy_manifest_uri), cursor_pos), + ) + assert isinstance(completions, list) + keywords = {m.label for m in completions} + assert "cross-compiling" in keywords + # Mapping-only forms are not applicable here + assert "not" not in keywords + assert "not:" not in keywords + + cursor_pos = put_doc_with_cursor( + ls, + debputy_manifest_uri, + "debian/debputy.manifest", + textwrap.dedent( + """\ + manifest-version: 0.1 + installations: + - install-docs: + when: + +""" + ), + ) + completions = debputy_manifest_completer( + ls, + CompletionParams(TextDocumentIdentifier(debputy_manifest_uri), cursor_pos), + ) + assert isinstance(completions, list) + keywords = {m.label for m in completions} + assert "not:" in keywords + # str-only forms are not applicable here + assert "cross-compiling" not in keywords + + cursor_pos = put_doc_with_cursor( + ls, + debputy_manifest_uri, + "debian/debputy.manifest", + textwrap.dedent( + """\ + manifest-version: 0.1 + installations: + - install-docs: + when: + n +""" + ), + ) + completions = debputy_manifest_completer( + ls, + CompletionParams(TextDocumentIdentifier(debputy_manifest_uri), cursor_pos), + ) + assert isinstance(completions, list) + keywords = {m.label for m in completions} + assert "not:" in keywords + # str-only forms are not applicable here + assert "cross-compiling" not in keywords -- cgit v1.2.3