Coverage for src/debputy/lsp/languages/lsp_debian_rules.py: 26%

158 statements  

« prev     ^ index     » next       coverage.py v7.8.2, created at 2025-10-12 15:06 +0000

1import functools 

2import itertools 

3import os 

4import re 

5import subprocess 

6from typing import ( 

7 Union, 

8 Optional, 

9 List, 

10 Tuple, 

11 FrozenSet, 

12) 

13from collections.abc import Sequence, Iterable, Iterator 

14 

15from debputy.dh.dh_assistant import ( 

16 resolve_active_and_inactive_dh_commands, 

17 DhListCommands, 

18) 

19from debputy.linting.lint_util import LintState 

20from debputy.lsp.config.config_options import DCO_SPELLCHECK_COMMENTS 

21from debputy.lsp.debputy_ls import DebputyLanguageServer 

22from debputy.lsp.lsp_features import ( 

23 lint_diagnostics, 

24 lsp_standard_handler, 

25 lsp_completer, 

26 SecondaryLanguage, 

27 LanguageDispatchRule, 

28) 

29from debputy.lsp.quickfixes import propose_correct_text_quick_fix 

30from debputy.lsp.spellchecking import spellcheck_line 

31from debputy.lsprotocol.types import ( 

32 CompletionItem, 

33 CompletionList, 

34 CompletionParams, 

35 TEXT_DOCUMENT_WILL_SAVE_WAIT_UNTIL, 

36 TEXT_DOCUMENT_CODE_ACTION, 

37) 

38from debputy.util import detect_possible_typo 

39 

40try: 

41 from debputy.lsp.vendoring._deb822_repro.locatable import ( 

42 Position as TEPosition, 

43 Range as TERange, 

44 ) 

45 

46 from pygls.server import LanguageServer 

47 from pygls.workspace import TextDocument 

48except ImportError: 

49 pass 

50 

51 

52_CONTAINS_TAB_OR_COLON = re.compile(r"[\t:]") 

53_WORDS_RE = re.compile("([a-zA-Z0-9_-]+)") 

54_MAKE_ERROR_RE = re.compile(r"^[^:]+:(\d+):\s*(\S.+)") 

55_STANDARD_MAKEFILES = [ 

56 "/usr/share/dpkg/architecture.mk", 

57 "/usr/share/dpkg/buildapi.mk", 

58 "/usr/share/dpkg/buildflags.mk", 

59 "/usr/share/dpkg/buildtools.mk", 

60 "/usr/share/dpkg/default.mk", 

61 "/usr/share/dpkg/vendor.mk", 

62 "/usr/share/dpkg/pkg-info.mk", 

63] 

64 

65_KNOWN_TARGETS = { 

66 "binary", 

67 "binary-arch", 

68 "binary-indep", 

69 "build", 

70 "build-arch", 

71 "build-indep", 

72 "clean", 

73} 

74 

75_COMMAND_WORDS = frozenset( 

76 { 

77 "export", 

78 "ifeq", 

79 "ifneq", 

80 "ifdef", 

81 "ifndef", 

82 "endif", 

83 "else", 

84 } 

85) 

86_DISPATCH_RULE = LanguageDispatchRule.new_rule( 

87 "debian/rules", 

88 None, 

89 "debian/rules", 

90 [ 

91 # emacs's name (there is no debian-rules mode) 

92 SecondaryLanguage("makefile-gmake", secondary_lookup="path-name"), 

93 # vim's name (there is no debrules and it does not use the official makefile language name) 

94 SecondaryLanguage("make", secondary_lookup="path-name"), 

95 # LSP's official language ID for Makefile 

96 SecondaryLanguage("makefile", secondary_lookup="path-name"), 

97 ], 

98) 

99 

100 

101def _as_hook_targets(command_name: str) -> Iterable[str]: 

102 for prefix, suffix in itertools.product( 

103 ["override_", "execute_before_", "execute_after_"], 

104 ["", "-arch", "-indep"], 

105 ): 

106 yield f"{prefix}{command_name}{suffix}" 

107 

108 

109lsp_standard_handler(_DISPATCH_RULE, TEXT_DOCUMENT_CODE_ACTION) 

110lsp_standard_handler(_DISPATCH_RULE, TEXT_DOCUMENT_WILL_SAVE_WAIT_UNTIL) 

111 

112 

113@functools.lru_cache 

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

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

116 

117 

118def _run_make_dryrun( 

119 lint_state: LintState, 

120 source_root: str, 

121 lines: list[str], 

122) -> None: 

123 if not _is_project_trusted(source_root): 

124 return None 

125 try: 

126 make_res = subprocess.run( 

127 ["make", "--dry-run", "-f", "-", "debhelper-fail-me"], 

128 input="".join(lines).encode("utf-8"), 

129 stdout=subprocess.DEVNULL, 

130 stderr=subprocess.PIPE, 

131 cwd=source_root, 

132 timeout=1, 

133 ) 

134 except (FileNotFoundError, subprocess.TimeoutExpired): 

135 pass 

136 else: 

137 if make_res.returncode != 0: 

138 make_output = make_res.stderr.decode("utf-8") 

139 m = _MAKE_ERROR_RE.match(make_output) 

140 if m: 

141 # We want it zero-based and make reports it one-based 

142 line_of_error = int(m.group(1)) - 1 

143 msg = m.group(2).strip() 

144 error_range = TERange( 

145 TEPosition( 

146 line_of_error, 

147 0, 

148 ), 

149 TEPosition( 

150 line_of_error + 1, 

151 0, 

152 ), 

153 ) 

154 lint_state.emit_diagnostic( 

155 error_range, 

156 f"make error: {msg}", 

157 "error", 

158 "debputy", 

159 ) 

160 return 

161 

162 

163def iter_make_lines( 

164 lint_state: LintState, 

165 lines: list[str], 

166) -> Iterator[tuple[int, str]]: 

167 skip_next_line = False 

168 is_extended_comment = False 

169 for line_no, line in enumerate(lines): 

170 skip_this = skip_next_line 

171 skip_next_line = False 

172 if line.rstrip().endswith("\\"): 

173 skip_next_line = True 

174 

175 if skip_this: 

176 if is_extended_comment and lint_state.debputy_config.config_value( 

177 DCO_SPELLCHECK_COMMENTS 

178 ): 

179 spellcheck_line(lint_state, line_no, line) 

180 continue 

181 

182 if line.startswith("#"): 

183 if lint_state.debputy_config.config_value(DCO_SPELLCHECK_COMMENTS): 

184 spellcheck_line(lint_state, line_no, line) 

185 is_extended_comment = skip_next_line 

186 continue 

187 is_extended_comment = False 

188 

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

190 continue 

191 

192 is_extended_comment = False 

193 # We are not really dealing with extension lines at the moment (other than for spellchecking), 

194 # since nothing needs it 

195 yield line_no, line 

196 

197 

198def _forbidden_hook_targets(dh_commands: DhListCommands) -> frozenset[str]: 

199 if not dh_commands.disabled_commands: 

200 return frozenset() 

201 return frozenset( 

202 itertools.chain.from_iterable( 

203 _as_hook_targets(c) for c in dh_commands.disabled_commands 

204 ) 

205 ) 

206 

207 

208@lint_diagnostics(_DISPATCH_RULE) 

209async def _lint_debian_rules(lint_state: LintState) -> None: 

210 lines = lint_state.lines 

211 path = lint_state.path 

212 source_root = os.path.dirname(os.path.dirname(path)) 

213 if source_root == "": 

214 source_root = "." 

215 

216 _run_make_dryrun(lint_state, source_root, lines) 

217 dh_sequencer_data = lint_state.dh_sequencer_data 

218 dh_sequences = dh_sequencer_data.sequences 

219 dh_commands = resolve_active_and_inactive_dh_commands( 

220 dh_sequences, 

221 source_root=source_root, 

222 ) 

223 if dh_commands.active_commands: 

224 all_hook_targets = { 

225 ht for c in dh_commands.active_commands for ht in _as_hook_targets(c) 

226 } 

227 all_hook_targets.update(_KNOWN_TARGETS) 

228 else: 

229 all_hook_targets = _KNOWN_TARGETS 

230 

231 missing_targets = {} 

232 forbidden_hook_targets = _forbidden_hook_targets(dh_commands) 

233 all_allowed_hook_targets = all_hook_targets - forbidden_hook_targets 

234 

235 for line_no, line in iter_make_lines(lint_state, lines): 

236 try: 

237 colon_idx = line.index(":") 

238 if len(line) > colon_idx + 1 and line[colon_idx + 1] == "=": 

239 continue 

240 except ValueError: 

241 continue 

242 target_substring = line[0:colon_idx] 

243 if "=" in target_substring or "$(for" in target_substring: 

244 continue 

245 for i, m in enumerate(_WORDS_RE.finditer(target_substring)): 

246 target = m.group(1) 

247 if i == 0 and (target in _COMMAND_WORDS or target.startswith("(")): 

248 break 

249 if "%" in target or "$" in target: 

250 continue 

251 if target in forbidden_hook_targets: 

252 pos, endpos = m.span(1) 

253 r = TERange( 

254 TEPosition( 

255 line_no, 

256 pos, 

257 ), 

258 TEPosition( 

259 line_no, 

260 endpos, 

261 ), 

262 ) 

263 lint_state.emit_diagnostic( 

264 r, 

265 f"The hook target {target} will not be run due to dh compat level or chosen dh add-ons.", 

266 "error", 

267 "debputy", 

268 ) 

269 continue 

270 

271 if target in all_allowed_hook_targets or target in missing_targets: 

272 continue 

273 pos, endpos = m.span(1) 

274 hook_location = line_no, pos, endpos 

275 missing_targets[target] = hook_location 

276 

277 for target, (line_no, pos, endpos) in missing_targets.items(): 

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 

284 r = TERange( 

285 TEPosition( 

286 line_no, 

287 pos, 

288 ), 

289 TEPosition( 

290 line_no, 

291 endpos, 

292 ), 

293 ) 

294 if candidates: 

295 msg = f"Target {target} looks like a typo of a known target" 

296 else: 

297 msg = f"Unknown rules dh hook target {target}" 

298 if candidates: 

299 fixes = [propose_correct_text_quick_fix(c) for c in candidates] 

300 else: 

301 fixes = [] 

302 lint_state.emit_diagnostic( 

303 r, 

304 msg, 

305 "warning", 

306 "debputy", 

307 quickfixes=fixes, 

308 ) 

309 

310 

311@lsp_completer(_DISPATCH_RULE) 

312def debian_rules_completions( 

313 ls: "DebputyLanguageServer", 

314 params: CompletionParams, 

315) -> CompletionList | Sequence[CompletionItem] | None: 

316 doc = ls.workspace.get_text_document(params.text_document.uri) 

317 lines = doc.lines 

318 server_position = doc.position_codec.position_from_client_units( 

319 lines, params.position 

320 ) 

321 

322 line = lines[server_position.line] 

323 line_start = line[0 : server_position.character] 

324 

325 if _CONTAINS_TAB_OR_COLON.search(line_start): 325 ↛ 326line 325 didn't jump to line 326 because the condition on line 325 was never true

326 return None 

327 

328 if line_start.startswith(("include ", "-include ")): 328 ↛ 342line 328 didn't jump to line 342 because the condition on line 328 was always true

329 parts = line_start.split(maxsplit=2) 

330 included = parts[1] if len(parts) > 1 else "" 

331 # Ignore cases with variables (such as $(foo)), since our suggestion will 

332 # never match it, and likely the user wanted something fancy that we 

333 # cannot provide. 

334 if ( 

335 "$" not in line_start 

336 and len(parts) <= 2 

337 and included not in _STANDARD_MAKEFILES 

338 ): 

339 return [CompletionItem(p) for p in _STANDARD_MAKEFILES] 

340 return None 

341 

342 source_root = os.path.dirname(os.path.dirname(doc.path)) 

343 dh_sequencer_data = ls.lint_state(doc).dh_sequencer_data 

344 dh_sequences = dh_sequencer_data.sequences 

345 dh_commands = resolve_active_and_inactive_dh_commands( 

346 dh_sequences, 

347 source_root=source_root, 

348 ) 

349 if not dh_commands.active_commands: 

350 return None 

351 items = [ 

352 CompletionItem(ht) 

353 for c in dh_commands.active_commands 

354 for ht in _as_hook_targets(c) 

355 ] 

356 return items