Coverage for src/debputy/lsp/lsp_debian_rules.py: 18%
188 statements
« prev ^ index » next coverage.py v7.2.7, created at 2024-04-07 12:14 +0200
« prev ^ index » next coverage.py v7.2.7, created at 2024-04-07 12:14 +0200
1import functools
2import itertools
3import json
4import os
5import re
6import subprocess
7from typing import (
8 Union,
9 Sequence,
10 Optional,
11 Iterable,
12 List,
13 Iterator,
14 Tuple,
15)
17from lsprotocol.types import (
18 CompletionItem,
19 Diagnostic,
20 Range,
21 Position,
22 DiagnosticSeverity,
23 CompletionList,
24 CompletionParams,
25 TEXT_DOCUMENT_WILL_SAVE_WAIT_UNTIL,
26 TEXT_DOCUMENT_CODE_ACTION,
27)
29from debputy.debhelper_emulation import parse_drules_for_addons
30from debputy.linting.lint_util import LintState
31from debputy.lsp.lsp_features import (
32 lint_diagnostics,
33 lsp_standard_handler,
34 lsp_completer,
35)
36from debputy.lsp.quickfixes import propose_correct_text_quick_fix
37from debputy.lsp.spellchecking import spellcheck_line
38from debputy.lsp.text_util import (
39 LintCapablePositionCodec,
40)
41from debputy.util import _warn
43try:
44 from debian._deb822_repro.locatable import (
45 Position as TEPosition,
46 Range as TERange,
47 START_POSITION,
48 )
50 from pygls.server import LanguageServer
51 from pygls.workspace import TextDocument
52except ImportError:
53 pass
56try:
57 from Levenshtein import distance
58except ImportError:
60 def _detect_possible_typo(
61 provided_value: str,
62 known_values: Iterable[str],
63 ) -> Sequence[str]:
64 return tuple()
66else:
68 def _detect_possible_typo(
69 provided_value: str,
70 known_values: Iterable[str],
71 ) -> Sequence[str]:
72 k_len = len(provided_value)
73 candidates = []
74 for known_value in known_values:
75 if abs(k_len - len(known_value)) > 2:
76 continue
77 d = distance(provided_value, known_value)
78 if d > 2:
79 continue
80 candidates.append(known_value)
81 return candidates
84_CONTAINS_TAB_OR_COLON = re.compile(r"[\t:]")
85_WORDS_RE = re.compile("([a-zA-Z0-9_-]+)")
86_MAKE_ERROR_RE = re.compile(r"^[^:]+:(\d+):\s*(\S.+)")
89_KNOWN_TARGETS = {
90 "binary",
91 "binary-arch",
92 "binary-indep",
93 "build",
94 "build-arch",
95 "build-indep",
96 "clean",
97}
99_COMMAND_WORDS = frozenset(
100 {
101 "export",
102 "ifeq",
103 "ifneq",
104 "ifdef",
105 "ifndef",
106 "endif",
107 "else",
108 }
109)
111_LANGUAGE_IDS = [
112 "debian/rules",
113 # LSP's official language ID for Makefile
114 "makefile",
115 # emacs's name (there is no debian-rules mode)
116 "makefile-gmake",
117 # vim's name (there is no debrules)
118 "make",
119]
122def _as_hook_targets(command_name: str) -> Iterable[str]:
123 for prefix, suffix in itertools.product(
124 ["override_", "execute_before_", "execute_after_"],
125 ["", "-arch", "-indep"],
126 ):
127 yield f"{prefix}{command_name}{suffix}"
130lsp_standard_handler(_LANGUAGE_IDS, TEXT_DOCUMENT_CODE_ACTION)
131lsp_standard_handler(_LANGUAGE_IDS, TEXT_DOCUMENT_WILL_SAVE_WAIT_UNTIL)
134def is_valid_file(path: str) -> bool:
135 # For debian/rules, the language ID is often set to makefile meaning we get random "non-debian/rules"
136 # makefiles here. Skip those.
137 return path.endswith("debian/rules")
140@lint_diagnostics(_LANGUAGE_IDS)
141def _lint_debian_rules(
142 doc_reference: str,
143 path: str,
144 lines: List[str],
145 position_codec: LintCapablePositionCodec,
146) -> Optional[List[Diagnostic]]:
147 if not is_valid_file(path):
148 return None
149 return _lint_debian_rules_impl(
150 doc_reference,
151 path,
152 lines,
153 position_codec,
154 )
157@functools.lru_cache
158def _is_project_trusted(source_root: str) -> bool:
159 return os.environ.get("DEBPUTY_TRUST_PROJECT", "0") == "1"
162def _run_make_dryrun(
163 source_root: str,
164 lines: List[str],
165) -> Optional[Diagnostic]:
166 if not _is_project_trusted(source_root):
167 return None
168 try:
169 make_res = subprocess.run(
170 ["make", "--dry-run", "-f", "-", "debhelper-fail-me"],
171 input="".join(lines).encode("utf-8"),
172 stdout=subprocess.DEVNULL,
173 stderr=subprocess.PIPE,
174 cwd=source_root,
175 timeout=1,
176 )
177 except (FileNotFoundError, subprocess.TimeoutExpired):
178 pass
179 else:
180 if make_res.returncode != 0:
181 make_output = make_res.stderr.decode("utf-8")
182 m = _MAKE_ERROR_RE.match(make_output)
183 if m:
184 # We want it zero-based and make reports it one-based
185 line_of_error = int(m.group(1)) - 1
186 msg = m.group(2).strip()
187 error_range = Range(
188 Position(
189 line_of_error,
190 0,
191 ),
192 Position(
193 line_of_error + 1,
194 0,
195 ),
196 )
197 # No conversion needed; it is pure line numbers
198 return Diagnostic(
199 error_range,
200 f"make error: {msg}",
201 severity=DiagnosticSeverity.Error,
202 source="debputy (make)",
203 )
204 return None
207def iter_make_lines(
208 lines: List[str],
209 position_codec: LintCapablePositionCodec,
210 diagnostics: List[Diagnostic],
211) -> Iterator[Tuple[int, str]]:
212 skip_next_line = False
213 is_extended_comment = False
214 for line_no, line in enumerate(lines):
215 skip_this = skip_next_line
216 skip_next_line = False
217 if line.rstrip().endswith("\\"):
218 skip_next_line = True
220 if skip_this:
221 if is_extended_comment:
222 diagnostics.extend(
223 spellcheck_line(lines, position_codec, line_no, line)
224 )
225 continue
227 if line.startswith("#"):
228 diagnostics.extend(spellcheck_line(lines, position_codec, line_no, line))
229 is_extended_comment = skip_next_line
230 continue
231 is_extended_comment = False
233 if line.startswith("\t") or line.isspace():
234 continue
236 is_extended_comment = False
237 # We are not really dealing with extension lines at the moment (other than for spellchecking),
238 # since nothing needs it
239 yield line_no, line
242def _lint_debian_rules_impl(
243 lint_state: LintState,
244) -> Optional[List[Diagnostic]]:
245 lines = lint_state.lines
246 position_codec = lint_state.position_codec
247 path = lint_state.path
248 source_root = os.path.dirname(os.path.dirname(path))
249 if source_root == "":
250 source_root = "."
251 diagnostics = []
253 make_error = _run_make_dryrun(source_root, lines)
254 if make_error is not None:
255 diagnostics.append(make_error)
257 all_dh_commands = _all_dh_commands(source_root, lines)
258 if all_dh_commands:
259 all_hook_targets = {ht for c in all_dh_commands for ht in _as_hook_targets(c)}
260 all_hook_targets.update(_KNOWN_TARGETS)
261 source = "debputy (dh_assistant)"
262 else:
263 all_hook_targets = _KNOWN_TARGETS
264 source = "debputy"
266 missing_targets = {}
268 for line_no, line in iter_make_lines(lines, position_codec, diagnostics):
269 try:
270 colon_idx = line.index(":")
271 if len(line) > colon_idx + 1 and line[colon_idx + 1] == "=":
272 continue
273 except ValueError:
274 continue
275 target_substring = line[0:colon_idx]
276 if "=" in target_substring or "$(for" in target_substring:
277 continue
278 for i, m in enumerate(_WORDS_RE.finditer(target_substring)):
279 target = m.group(1)
280 if i == 0 and (target in _COMMAND_WORDS or target.startswith("(")):
281 break
282 if "%" in target or "$" in target:
283 continue
284 if target in all_hook_targets or target in missing_targets:
285 continue
286 pos, endpos = m.span(1)
287 hook_location = line_no, pos, endpos
288 missing_targets[target] = hook_location
290 for target, (line_no, pos, endpos) in missing_targets.items():
291 candidates = _detect_possible_typo(target, all_hook_targets)
292 if not candidates and not target.startswith(
293 ("override_", "execute_before_", "execute_after_")
294 ):
295 continue
297 r_server_units = Range(
298 Position(
299 line_no,
300 pos,
301 ),
302 Position(
303 line_no,
304 endpos,
305 ),
306 )
307 r = position_codec.range_to_client_units(lines, r_server_units)
308 if candidates:
309 msg = f"Target {target} looks like a typo of a known target"
310 else:
311 msg = f"Unknown rules dh hook target {target}"
312 if candidates:
313 fixes = [propose_correct_text_quick_fix(c) for c in candidates]
314 else:
315 fixes = []
316 diagnostics.append(
317 Diagnostic(
318 r,
319 msg,
320 severity=DiagnosticSeverity.Warning,
321 data=fixes,
322 source=source,
323 )
324 )
325 return diagnostics
328def _all_dh_commands(source_root: str, lines: List[str]) -> Optional[Sequence[str]]:
329 drules_sequences = set()
330 parse_drules_for_addons(lines, drules_sequences)
331 cmd = ["dh_assistant", "list-commands", "--output-format=json"]
332 if drules_sequences:
333 cmd.append(f"--with={','.join(drules_sequences)}")
334 try:
335 output = subprocess.check_output(
336 cmd,
337 stderr=subprocess.DEVNULL,
338 cwd=source_root,
339 )
340 except (FileNotFoundError, subprocess.CalledProcessError) as e:
341 _warn(f"dh_assistant failed (dir: {source_root}): {str(e)}")
342 return None
343 data = json.loads(output)
344 commands_raw = data.get("commands") if isinstance(data, dict) else None
345 if not isinstance(commands_raw, list):
346 return None
348 commands = []
350 for command in commands_raw:
351 if not isinstance(command, dict):
352 return None
353 command_name = command.get("command")
354 if not command_name:
355 return None
356 commands.append(command_name)
358 return commands
361@lsp_completer(_LANGUAGE_IDS)
362def _debian_rules_completions(
363 ls: "LanguageServer",
364 params: CompletionParams,
365) -> Optional[Union[CompletionList, Sequence[CompletionItem]]]:
366 doc = ls.workspace.get_text_document(params.text_document.uri)
367 if not is_valid_file(doc.path):
368 return None
369 lines = doc.lines
370 server_position = doc.position_codec.position_from_client_units(
371 lines, params.position
372 )
374 line = lines[server_position.line]
375 line_start = line[0 : server_position.character]
377 if _CONTAINS_TAB_OR_COLON.search(line_start):
378 return None
380 source_root = os.path.dirname(os.path.dirname(doc.path))
381 all_commands = _all_dh_commands(source_root, lines)
382 items = [CompletionItem(ht) for c in all_commands for ht in _as_hook_targets(c)]
384 return items