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

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) 

16 

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 

41 

42try: 

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

44 Position as TEPosition, 

45 Range as TERange, 

46 ) 

47 

48 from pygls.server import LanguageServer 

49 from pygls.workspace import TextDocument 

50except ImportError: 

51 pass 

52 

53 

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] 

66 

67_KNOWN_TARGETS = { 

68 "binary", 

69 "binary-arch", 

70 "binary-indep", 

71 "build", 

72 "build-arch", 

73 "build-indep", 

74 "clean", 

75} 

76 

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) 

101 

102 

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

109 

110 

111lsp_standard_handler(_DISPATCH_RULE, TEXT_DOCUMENT_CODE_ACTION) 

112lsp_standard_handler(_DISPATCH_RULE, TEXT_DOCUMENT_WILL_SAVE_WAIT_UNTIL) 

113 

114 

115@functools.lru_cache 

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

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

118 

119 

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 

163 

164 

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 

176 

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 

183 

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 

190 

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

192 continue 

193 

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 

198 

199 

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 ) 

208 

209 

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 = "." 

217 

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 

232 

233 missing_targets = {} 

234 forbidden_hook_targets = _forbidden_hook_targets(dh_commands) 

235 all_allowed_hook_targets = all_hook_targets - forbidden_hook_targets 

236 

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 

272 

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 

278 

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 

285 

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 ) 

311 

312 

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 ) 

323 

324 line = lines[server_position.line] 

325 line_start = line[0 : server_position.character] 

326 

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 

329 

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 

343 

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