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