Coverage for src/debputy/lsp/lsp_debian_rules.py: 21%
149 statements
« prev ^ index » next coverage.py v7.6.0, created at 2025-01-27 13:59 +0000
« prev ^ index » next coverage.py v7.6.0, created at 2025-01-27 13:59 +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.debputy_ls import DebputyLanguageServer
23from debputy.lsp.lsp_features import (
24 lint_diagnostics,
25 lsp_standard_handler,
26 lsp_completer,
27 SecondaryLanguage,
28 LanguageDispatchRule,
29)
30from debputy.lsp.quickfixes import propose_correct_text_quick_fix
31from debputy.lsp.spellchecking import spellcheck_line
32from debputy.lsp.text_util import (
33 LintCapablePositionCodec,
34)
35from debputy.lsprotocol.types import (
36 CompletionItem,
37 Diagnostic,
38 CompletionList,
39 CompletionParams,
40 TEXT_DOCUMENT_WILL_SAVE_WAIT_UNTIL,
41 TEXT_DOCUMENT_CODE_ACTION,
42)
43from debputy.util import detect_possible_typo
45try:
46 from debputy.lsp.vendoring._deb822_repro.locatable import (
47 Position as TEPosition,
48 Range as TERange,
49 )
51 from pygls.server import LanguageServer
52 from pygls.workspace import TextDocument
53except ImportError:
54 pass
57_CONTAINS_TAB_OR_COLON = re.compile(r"[\t:]")
58_WORDS_RE = re.compile("([a-zA-Z0-9_-]+)")
59_MAKE_ERROR_RE = re.compile(r"^[^:]+:(\d+):\s*(\S.+)")
62_KNOWN_TARGETS = {
63 "binary",
64 "binary-arch",
65 "binary-indep",
66 "build",
67 "build-arch",
68 "build-indep",
69 "clean",
70}
72_COMMAND_WORDS = frozenset(
73 {
74 "export",
75 "ifeq",
76 "ifneq",
77 "ifdef",
78 "ifndef",
79 "endif",
80 "else",
81 }
82)
83_DISPATCH_RULE = LanguageDispatchRule.new_rule(
84 "debian/rules",
85 "debian/rules",
86 [
87 # emacs's name (there is no debian-rules mode)
88 SecondaryLanguage("makefile-gmake", filename_based_lookup=True),
89 # vim's name (there is no debrules and it does not use the official makefile language name)
90 SecondaryLanguage("make", filename_based_lookup=True),
91 # LSP's official language ID for Makefile
92 SecondaryLanguage("makefile", filename_based_lookup=True),
93 ],
94)
97def _as_hook_targets(command_name: str) -> Iterable[str]:
98 for prefix, suffix in itertools.product(
99 ["override_", "execute_before_", "execute_after_"],
100 ["", "-arch", "-indep"],
101 ):
102 yield f"{prefix}{command_name}{suffix}"
105lsp_standard_handler(_DISPATCH_RULE, TEXT_DOCUMENT_CODE_ACTION)
106lsp_standard_handler(_DISPATCH_RULE, TEXT_DOCUMENT_WILL_SAVE_WAIT_UNTIL)
109@functools.lru_cache
110def _is_project_trusted(source_root: str) -> bool:
111 return os.environ.get("DEBPUTY_TRUST_PROJECT", "0") == "1"
114def _run_make_dryrun(
115 lint_state: LintState,
116 source_root: str,
117 lines: List[str],
118) -> None:
119 if not _is_project_trusted(source_root):
120 return None
121 try:
122 make_res = subprocess.run(
123 ["make", "--dry-run", "-f", "-", "debhelper-fail-me"],
124 input="".join(lines).encode("utf-8"),
125 stdout=subprocess.DEVNULL,
126 stderr=subprocess.PIPE,
127 cwd=source_root,
128 timeout=1,
129 )
130 except (FileNotFoundError, subprocess.TimeoutExpired):
131 pass
132 else:
133 if make_res.returncode != 0:
134 make_output = make_res.stderr.decode("utf-8")
135 m = _MAKE_ERROR_RE.match(make_output)
136 if m:
137 # We want it zero-based and make reports it one-based
138 line_of_error = int(m.group(1)) - 1
139 msg = m.group(2).strip()
140 error_range = TERange(
141 TEPosition(
142 line_of_error,
143 0,
144 ),
145 TEPosition(
146 line_of_error + 1,
147 0,
148 ),
149 )
150 lint_state.emit_diagnostic(
151 error_range,
152 f"make error: {msg}",
153 "error",
154 "debputy",
155 )
156 return
159def iter_make_lines(
160 lint_state: LintState,
161 lines: List[str],
162) -> Iterator[Tuple[int, str]]:
163 skip_next_line = False
164 is_extended_comment = False
165 for line_no, line in enumerate(lines):
166 skip_this = skip_next_line
167 skip_next_line = False
168 if line.rstrip().endswith("\\"):
169 skip_next_line = True
171 if skip_this:
172 if is_extended_comment:
173 spellcheck_line(lint_state, line_no, line)
174 continue
176 if line.startswith("#"):
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)
202def _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 the choice of sequences.",
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 candidates = detect_possible_typo(target, all_allowed_hook_targets)
272 if not candidates and not target.startswith(
273 ("override_", "execute_before_", "execute_after_")
274 ):
275 continue
277 r = TERange(
278 TEPosition(
279 line_no,
280 pos,
281 ),
282 TEPosition(
283 line_no,
284 endpos,
285 ),
286 )
287 if candidates:
288 msg = f"Target {target} looks like a typo of a known target"
289 else:
290 msg = f"Unknown rules dh hook target {target}"
291 if candidates:
292 fixes = [propose_correct_text_quick_fix(c) for c in candidates]
293 else:
294 fixes = []
295 lint_state.emit_diagnostic(
296 r,
297 msg,
298 "warning",
299 "debputy",
300 quickfixes=fixes,
301 )
304@lsp_completer(_DISPATCH_RULE)
305def _debian_rules_completions(
306 ls: "DebputyLanguageServer",
307 params: CompletionParams,
308) -> Optional[Union[CompletionList, Sequence[CompletionItem]]]:
309 doc = ls.workspace.get_text_document(params.text_document.uri)
310 lines = doc.lines
311 server_position = doc.position_codec.position_from_client_units(
312 lines, params.position
313 )
315 line = lines[server_position.line]
316 line_start = line[0 : server_position.character]
318 if _CONTAINS_TAB_OR_COLON.search(line_start):
319 return None
321 source_root = os.path.dirname(os.path.dirname(doc.path))
322 dh_sequencer_data = ls.lint_state(doc).dh_sequencer_data
323 dh_sequences = dh_sequencer_data.sequences
324 dh_commands = resolve_active_and_inactive_dh_commands(
325 dh_sequences,
326 source_root=source_root,
327 )
328 if not dh_commands.active_commands:
329 return None
330 items = [
331 CompletionItem(ht)
332 for c in dh_commands.active_commands
333 for ht in _as_hook_targets(c)
334 ]
336 return items