Coverage for src/debputy/lsp/languages/lsp_debian_rules.py: 26%
158 statements
« prev ^ index » next coverage.py v7.8.2, created at 2025-10-12 15:06 +0000
« prev ^ index » next coverage.py v7.8.2, created at 2025-10-12 15:06 +0000
1import functools
2import itertools
3import os
4import re
5import subprocess
6from typing import (
7 Union,
8 Optional,
9 List,
10 Tuple,
11 FrozenSet,
12)
13from collections.abc import Sequence, Iterable, Iterator
15from debputy.dh.dh_assistant import (
16 resolve_active_and_inactive_dh_commands,
17 DhListCommands,
18)
19from debputy.linting.lint_util import LintState
20from debputy.lsp.config.config_options import DCO_SPELLCHECK_COMMENTS
21from debputy.lsp.debputy_ls import DebputyLanguageServer
22from debputy.lsp.lsp_features import (
23 lint_diagnostics,
24 lsp_standard_handler,
25 lsp_completer,
26 SecondaryLanguage,
27 LanguageDispatchRule,
28)
29from debputy.lsp.quickfixes import propose_correct_text_quick_fix
30from debputy.lsp.spellchecking import spellcheck_line
31from debputy.lsprotocol.types import (
32 CompletionItem,
33 CompletionList,
34 CompletionParams,
35 TEXT_DOCUMENT_WILL_SAVE_WAIT_UNTIL,
36 TEXT_DOCUMENT_CODE_ACTION,
37)
38from debputy.util import detect_possible_typo
40try:
41 from debputy.lsp.vendoring._deb822_repro.locatable import (
42 Position as TEPosition,
43 Range as TERange,
44 )
46 from pygls.server import LanguageServer
47 from pygls.workspace import TextDocument
48except ImportError:
49 pass
52_CONTAINS_TAB_OR_COLON = re.compile(r"[\t:]")
53_WORDS_RE = re.compile("([a-zA-Z0-9_-]+)")
54_MAKE_ERROR_RE = re.compile(r"^[^:]+:(\d+):\s*(\S.+)")
55_STANDARD_MAKEFILES = [
56 "/usr/share/dpkg/architecture.mk",
57 "/usr/share/dpkg/buildapi.mk",
58 "/usr/share/dpkg/buildflags.mk",
59 "/usr/share/dpkg/buildtools.mk",
60 "/usr/share/dpkg/default.mk",
61 "/usr/share/dpkg/vendor.mk",
62 "/usr/share/dpkg/pkg-info.mk",
63]
65_KNOWN_TARGETS = {
66 "binary",
67 "binary-arch",
68 "binary-indep",
69 "build",
70 "build-arch",
71 "build-indep",
72 "clean",
73}
75_COMMAND_WORDS = frozenset(
76 {
77 "export",
78 "ifeq",
79 "ifneq",
80 "ifdef",
81 "ifndef",
82 "endif",
83 "else",
84 }
85)
86_DISPATCH_RULE = LanguageDispatchRule.new_rule(
87 "debian/rules",
88 None,
89 "debian/rules",
90 [
91 # emacs's name (there is no debian-rules mode)
92 SecondaryLanguage("makefile-gmake", secondary_lookup="path-name"),
93 # vim's name (there is no debrules and it does not use the official makefile language name)
94 SecondaryLanguage("make", secondary_lookup="path-name"),
95 # LSP's official language ID for Makefile
96 SecondaryLanguage("makefile", secondary_lookup="path-name"),
97 ],
98)
101def _as_hook_targets(command_name: str) -> Iterable[str]:
102 for prefix, suffix in itertools.product(
103 ["override_", "execute_before_", "execute_after_"],
104 ["", "-arch", "-indep"],
105 ):
106 yield f"{prefix}{command_name}{suffix}"
109lsp_standard_handler(_DISPATCH_RULE, TEXT_DOCUMENT_CODE_ACTION)
110lsp_standard_handler(_DISPATCH_RULE, TEXT_DOCUMENT_WILL_SAVE_WAIT_UNTIL)
113@functools.lru_cache
114def _is_project_trusted(source_root: str) -> bool:
115 return os.environ.get("DEBPUTY_TRUST_PROJECT", "0") == "1"
118def _run_make_dryrun(
119 lint_state: LintState,
120 source_root: str,
121 lines: list[str],
122) -> None:
123 if not _is_project_trusted(source_root):
124 return None
125 try:
126 make_res = subprocess.run(
127 ["make", "--dry-run", "-f", "-", "debhelper-fail-me"],
128 input="".join(lines).encode("utf-8"),
129 stdout=subprocess.DEVNULL,
130 stderr=subprocess.PIPE,
131 cwd=source_root,
132 timeout=1,
133 )
134 except (FileNotFoundError, subprocess.TimeoutExpired):
135 pass
136 else:
137 if make_res.returncode != 0:
138 make_output = make_res.stderr.decode("utf-8")
139 m = _MAKE_ERROR_RE.match(make_output)
140 if m:
141 # We want it zero-based and make reports it one-based
142 line_of_error = int(m.group(1)) - 1
143 msg = m.group(2).strip()
144 error_range = TERange(
145 TEPosition(
146 line_of_error,
147 0,
148 ),
149 TEPosition(
150 line_of_error + 1,
151 0,
152 ),
153 )
154 lint_state.emit_diagnostic(
155 error_range,
156 f"make error: {msg}",
157 "error",
158 "debputy",
159 )
160 return
163def iter_make_lines(
164 lint_state: LintState,
165 lines: list[str],
166) -> Iterator[tuple[int, str]]:
167 skip_next_line = False
168 is_extended_comment = False
169 for line_no, line in enumerate(lines):
170 skip_this = skip_next_line
171 skip_next_line = False
172 if line.rstrip().endswith("\\"):
173 skip_next_line = True
175 if skip_this:
176 if is_extended_comment and lint_state.debputy_config.config_value(
177 DCO_SPELLCHECK_COMMENTS
178 ):
179 spellcheck_line(lint_state, line_no, line)
180 continue
182 if line.startswith("#"):
183 if lint_state.debputy_config.config_value(DCO_SPELLCHECK_COMMENTS):
184 spellcheck_line(lint_state, line_no, line)
185 is_extended_comment = skip_next_line
186 continue
187 is_extended_comment = False
189 if line.startswith("\t") or line.isspace():
190 continue
192 is_extended_comment = False
193 # We are not really dealing with extension lines at the moment (other than for spellchecking),
194 # since nothing needs it
195 yield line_no, line
198def _forbidden_hook_targets(dh_commands: DhListCommands) -> frozenset[str]:
199 if not dh_commands.disabled_commands:
200 return frozenset()
201 return frozenset(
202 itertools.chain.from_iterable(
203 _as_hook_targets(c) for c in dh_commands.disabled_commands
204 )
205 )
208@lint_diagnostics(_DISPATCH_RULE)
209async def _lint_debian_rules(lint_state: LintState) -> None:
210 lines = lint_state.lines
211 path = lint_state.path
212 source_root = os.path.dirname(os.path.dirname(path))
213 if source_root == "":
214 source_root = "."
216 _run_make_dryrun(lint_state, source_root, lines)
217 dh_sequencer_data = lint_state.dh_sequencer_data
218 dh_sequences = dh_sequencer_data.sequences
219 dh_commands = resolve_active_and_inactive_dh_commands(
220 dh_sequences,
221 source_root=source_root,
222 )
223 if dh_commands.active_commands:
224 all_hook_targets = {
225 ht for c in dh_commands.active_commands for ht in _as_hook_targets(c)
226 }
227 all_hook_targets.update(_KNOWN_TARGETS)
228 else:
229 all_hook_targets = _KNOWN_TARGETS
231 missing_targets = {}
232 forbidden_hook_targets = _forbidden_hook_targets(dh_commands)
233 all_allowed_hook_targets = all_hook_targets - forbidden_hook_targets
235 for line_no, line in iter_make_lines(lint_state, lines):
236 try:
237 colon_idx = line.index(":")
238 if len(line) > colon_idx + 1 and line[colon_idx + 1] == "=":
239 continue
240 except ValueError:
241 continue
242 target_substring = line[0:colon_idx]
243 if "=" in target_substring or "$(for" in target_substring:
244 continue
245 for i, m in enumerate(_WORDS_RE.finditer(target_substring)):
246 target = m.group(1)
247 if i == 0 and (target in _COMMAND_WORDS or target.startswith("(")):
248 break
249 if "%" in target or "$" in target:
250 continue
251 if target in forbidden_hook_targets:
252 pos, endpos = m.span(1)
253 r = TERange(
254 TEPosition(
255 line_no,
256 pos,
257 ),
258 TEPosition(
259 line_no,
260 endpos,
261 ),
262 )
263 lint_state.emit_diagnostic(
264 r,
265 f"The hook target {target} will not be run due to dh compat level or chosen dh add-ons.",
266 "error",
267 "debputy",
268 )
269 continue
271 if target in all_allowed_hook_targets or target in missing_targets:
272 continue
273 pos, endpos = m.span(1)
274 hook_location = line_no, pos, endpos
275 missing_targets[target] = hook_location
277 for target, (line_no, pos, endpos) in missing_targets.items():
278 candidates = detect_possible_typo(target, all_allowed_hook_targets)
279 if not candidates and not target.startswith(
280 ("override_", "execute_before_", "execute_after_")
281 ):
282 continue
284 r = TERange(
285 TEPosition(
286 line_no,
287 pos,
288 ),
289 TEPosition(
290 line_no,
291 endpos,
292 ),
293 )
294 if candidates:
295 msg = f"Target {target} looks like a typo of a known target"
296 else:
297 msg = f"Unknown rules dh hook target {target}"
298 if candidates:
299 fixes = [propose_correct_text_quick_fix(c) for c in candidates]
300 else:
301 fixes = []
302 lint_state.emit_diagnostic(
303 r,
304 msg,
305 "warning",
306 "debputy",
307 quickfixes=fixes,
308 )
311@lsp_completer(_DISPATCH_RULE)
312def debian_rules_completions(
313 ls: "DebputyLanguageServer",
314 params: CompletionParams,
315) -> CompletionList | Sequence[CompletionItem] | None:
316 doc = ls.workspace.get_text_document(params.text_document.uri)
317 lines = doc.lines
318 server_position = doc.position_codec.position_from_client_units(
319 lines, params.position
320 )
322 line = lines[server_position.line]
323 line_start = line[0 : server_position.character]
325 if _CONTAINS_TAB_OR_COLON.search(line_start): 325 ↛ 326line 325 didn't jump to line 326 because the condition on line 325 was never true
326 return None
328 if line_start.startswith(("include ", "-include ")): 328 ↛ 342line 328 didn't jump to line 342 because the condition on line 328 was always true
329 parts = line_start.split(maxsplit=2)
330 included = parts[1] if len(parts) > 1 else ""
331 # Ignore cases with variables (such as $(foo)), since our suggestion will
332 # never match it, and likely the user wanted something fancy that we
333 # cannot provide.
334 if (
335 "$" not in line_start
336 and len(parts) <= 2
337 and included not in _STANDARD_MAKEFILES
338 ):
339 return [CompletionItem(p) for p in _STANDARD_MAKEFILES]
340 return None
342 source_root = os.path.dirname(os.path.dirname(doc.path))
343 dh_sequencer_data = ls.lint_state(doc).dh_sequencer_data
344 dh_sequences = dh_sequencer_data.sequences
345 dh_commands = resolve_active_and_inactive_dh_commands(
346 dh_sequences,
347 source_root=source_root,
348 )
349 if not dh_commands.active_commands:
350 return None
351 items = [
352 CompletionItem(ht)
353 for c in dh_commands.active_commands
354 for ht in _as_hook_targets(c)
355 ]
356 return items