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