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

159 statements  

« prev     ^ index     » next       coverage.py v7.8.2, created at 2026-08-23 18:25 +0000

1import functools 

2import itertools 

3import os 

4import re 

5import subprocess 

6from collections.abc import Sequence, Iterable, Iterator 

7 

8from debputy.dh.dh_assistant import ( 

9 resolve_active_and_inactive_dh_commands, 

10 DhListCommands, 

11) 

12from debputy.linting.lint_util import LintState 

13from debputy.lsp.config.config_options import DCO_SPELLCHECK_COMMENTS 

14from debputy.lsp.debputy_ls import DebputyLanguageServer 

15from debputy.lsp.lsp_features import ( 

16 lint_diagnostics, 

17 lsp_standard_handler, 

18 lsp_completer, 

19 SecondaryLanguage, 

20 LanguageDispatchRule, 

21) 

22from debputy.lsp.quickfixes import propose_correct_text_quick_fix 

23from debputy.lsp.spellchecking import spellcheck_line 

24from debputy.lsprotocol.types import ( 

25 CompletionItem, 

26 CompletionList, 

27 CompletionParams, 

28 TEXT_DOCUMENT_WILL_SAVE_WAIT_UNTIL, 

29 TEXT_DOCUMENT_CODE_ACTION, 

30) 

31from debputy.util import detect_possible_typo 

32 

33try: 

34 from debian._deb822_repro.locatable import ( 

35 Position as TEPosition, 

36 Range as TERange, 

37 ) 

38 

39 from pygls.server import LanguageServer 

40 from pygls.workspace import TextDocument 

41except ImportError: 

42 pass 

43 

44 

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

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

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

48_STANDARD_MAKEFILES = [ 

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

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

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

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

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

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

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

56] 

57 

58_KNOWN_TARGETS = { 

59 "binary", 

60 "binary-arch", 

61 "binary-indep", 

62 "build", 

63 "build-arch", 

64 "build-indep", 

65 "clean", 

66} 

67 

68_COMMAND_WORDS = frozenset( 

69 { 

70 "export", 

71 "ifeq", 

72 "ifneq", 

73 "ifdef", 

74 "ifndef", 

75 "endif", 

76 "else", 

77 } 

78) 

79_DISPATCH_RULE = LanguageDispatchRule.new_rule( 

80 "debian/rules", 

81 None, 

82 "debian/rules", 

83 [ 

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

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

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

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

88 # LSP's official language ID for Makefile 

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

90 ], 

91) 

92 

93 

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

95 for prefix, suffix in itertools.product( 

96 ["override_", "execute_before_", "execute_after_"], 

97 ["", "-arch", "-indep"], 

98 ): 

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

100 

101 

102lsp_standard_handler(_DISPATCH_RULE, TEXT_DOCUMENT_CODE_ACTION) 

103lsp_standard_handler(_DISPATCH_RULE, TEXT_DOCUMENT_WILL_SAVE_WAIT_UNTIL) 

104 

105 

106@functools.lru_cache 

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

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

109 

110 

111def _run_make_dryrun( 

112 lint_state: LintState, 

113 source_root: str, 

114 lines: list[str], 

115) -> None: 

116 if not _is_project_trusted(source_root): 

117 return None 

118 try: 

119 make_res = subprocess.run( 

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

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

122 stdout=subprocess.DEVNULL, 

123 stderr=subprocess.PIPE, 

124 cwd=source_root, 

125 timeout=1, 

126 ) 

127 except (FileNotFoundError, subprocess.TimeoutExpired): 

128 pass 

129 else: 

130 if make_res.returncode != 0: 

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

132 m = _MAKE_ERROR_RE.match(make_output) 

133 if m: 

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

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

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

137 error_range = TERange( 

138 TEPosition( 

139 line_of_error, 

140 0, 

141 ), 

142 TEPosition( 

143 line_of_error + 1, 

144 0, 

145 ), 

146 ) 

147 lint_state.emit_diagnostic( 

148 error_range, 

149 f"make error: {msg}", 

150 "error", 

151 "debputy", 

152 ) 

153 return 

154 

155 

156def iter_make_lines( 

157 lint_state: LintState, 

158 lines: list[str], 

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

160 skip_next_line = False 

161 is_extended_comment = False 

162 for line_no, line in enumerate(lines): 

163 skip_this = skip_next_line 

164 skip_next_line = False 

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

166 skip_next_line = True 

167 

168 if skip_this: 

169 if is_extended_comment and lint_state.debputy_config.config_value( 

170 DCO_SPELLCHECK_COMMENTS 

171 ): 

172 spellcheck_line(lint_state, line_no, line) 

173 continue 

174 

175 if line.startswith("#"): 

176 if lint_state.debputy_config.config_value(DCO_SPELLCHECK_COMMENTS): 

177 spellcheck_line(lint_state, line_no, line) 

178 is_extended_comment = skip_next_line 

179 continue 

180 is_extended_comment = False 

181 

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

183 continue 

184 

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 

189 

190 

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 ) 

199 

200 

201@lint_diagnostics(_DISPATCH_RULE) 

202async def _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 = "." 

208 

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 

223 

224 missing_targets = {} 

225 forbidden_hook_targets = _forbidden_hook_targets(dh_commands) 

226 all_allowed_hook_targets = all_hook_targets - forbidden_hook_targets 

227 

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 dh compat level or chosen dh add-ons.", 

259 "error", 

260 "debputy", 

261 ) 

262 continue 

263 

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 

269 

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

271 # Debian#1144502: People sometimes "comment" out targets by prefixing it with `_`. 

272 # 

273 # As safety, we require the first 3 characters to not be `_` just in case it becomes 

274 # `zz_` at some point. Note we only check for 3 characters; limit has to be somewhere, 

275 # and today it was 3. 

276 if "_" in target[:3]: 

277 continue 

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 r = TERange( 

284 TEPosition( 

285 line_no, 

286 pos, 

287 ), 

288 TEPosition( 

289 line_no, 

290 endpos, 

291 ), 

292 ) 

293 if candidates: 

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

295 else: 

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

297 if candidates: 

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

299 else: 

300 fixes = [] 

301 lint_state.emit_diagnostic( 

302 r, 

303 msg, 

304 "warning", 

305 "debputy", 

306 quickfixes=fixes, 

307 ) 

308 

309 

310@lsp_completer(_DISPATCH_RULE) 

311def debian_rules_completions( 

312 ls: "DebputyLanguageServer", 

313 params: CompletionParams, 

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

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

316 lines = doc.lines 

317 server_position = doc.position_codec.position_from_client_units( 

318 lines, params.position 

319 ) 

320 

321 line = lines[server_position.line] 

322 line_start = line[0 : server_position.character] 

323 

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

325 return None 

326 

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

328 parts = line_start.split(maxsplit=2) 

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

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

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

332 # cannot provide. 

333 if ( 

334 "$" not in line_start 

335 and len(parts) <= 2 

336 and included not in _STANDARD_MAKEFILES 

337 ): 

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

339 return None 

340 

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

342 dh_sequencer_data = ls.lint_state(doc).dh_sequencer_data 

343 dh_sequences = dh_sequencer_data.sequences 

344 dh_commands = resolve_active_and_inactive_dh_commands( 

345 dh_sequences, 

346 source_root=source_root, 

347 ) 

348 if not dh_commands.active_commands: 

349 return None 

350 items = [ 

351 CompletionItem(ht) 

352 for c in dh_commands.active_commands 

353 for ht in _as_hook_targets(c) 

354 ] 

355 return items