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

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) 

16 

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) 

28 

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 

42 

43try: 

44 from debian._deb822_repro.locatable import ( 

45 Position as TEPosition, 

46 Range as TERange, 

47 START_POSITION, 

48 ) 

49 

50 from pygls.server import LanguageServer 

51 from pygls.workspace import TextDocument 

52except ImportError: 

53 pass 

54 

55 

56try: 

57 from Levenshtein import distance 

58except ImportError: 

59 

60 def _detect_possible_typo( 

61 provided_value: str, 

62 known_values: Iterable[str], 

63 ) -> Sequence[str]: 

64 return tuple() 

65 

66else: 

67 

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 

82 

83 

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.+)") 

87 

88 

89_KNOWN_TARGETS = { 

90 "binary", 

91 "binary-arch", 

92 "binary-indep", 

93 "build", 

94 "build-arch", 

95 "build-indep", 

96 "clean", 

97} 

98 

99_COMMAND_WORDS = frozenset( 

100 { 

101 "export", 

102 "ifeq", 

103 "ifneq", 

104 "ifdef", 

105 "ifndef", 

106 "endif", 

107 "else", 

108 } 

109) 

110 

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] 

120 

121 

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}" 

128 

129 

130lsp_standard_handler(_LANGUAGE_IDS, TEXT_DOCUMENT_CODE_ACTION) 

131lsp_standard_handler(_LANGUAGE_IDS, TEXT_DOCUMENT_WILL_SAVE_WAIT_UNTIL) 

132 

133 

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") 

138 

139 

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 ) 

155 

156 

157@functools.lru_cache 

158def _is_project_trusted(source_root: str) -> bool: 

159 return os.environ.get("DEBPUTY_TRUST_PROJECT", "0") == "1" 

160 

161 

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 

205 

206 

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 

219 

220 if skip_this: 

221 if is_extended_comment: 

222 diagnostics.extend( 

223 spellcheck_line(lines, position_codec, line_no, line) 

224 ) 

225 continue 

226 

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 

232 

233 if line.startswith("\t") or line.isspace(): 

234 continue 

235 

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 

240 

241 

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 = [] 

252 

253 make_error = _run_make_dryrun(source_root, lines) 

254 if make_error is not None: 

255 diagnostics.append(make_error) 

256 

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" 

265 

266 missing_targets = {} 

267 

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 

289 

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 

296 

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 

326 

327 

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 

347 

348 commands = [] 

349 

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) 

357 

358 return commands 

359 

360 

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 ) 

373 

374 line = lines[server_position.line] 

375 line_start = line[0 : server_position.character] 

376 

377 if _CONTAINS_TAB_OR_COLON.search(line_start): 

378 return None 

379 

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)] 

383 

384 return items