summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/debputy/commands/debputy_cmd/context.py93
-rw-r--r--src/debputy/commands/debputy_cmd/lint_and_lsp_cmds.py64
-rw-r--r--src/debputy/linting/lint_impl.py79
-rw-r--r--src/debputy/linting/lint_util.py63
-rw-r--r--src/debputy/lsp/debputy_ls.py179
-rw-r--r--src/debputy/lsp/lsp_debian_changelog.py80
-rw-r--r--src/debputy/lsp/lsp_debian_control.py230
-rw-r--r--src/debputy/lsp/lsp_debian_control_reference_data.py567
-rw-r--r--src/debputy/lsp/lsp_debian_copyright.py9
-rw-r--r--src/debputy/lsp/lsp_debian_debputy_manifest.py106
-rw-r--r--src/debputy/lsp/lsp_debian_rules.py25
-rw-r--r--src/debputy/lsp/lsp_debian_tests_control.py9
-rw-r--r--src/debputy/lsp/lsp_dispatch.py22
-rw-r--r--src/debputy/lsp/lsp_features.py60
-rw-r--r--src/debputy/lsp/lsp_generic_deb822.py124
-rw-r--r--src/debputy/lsp/lsp_self_check.py91
-rw-r--r--src/debputy/packages.py186
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)