Coverage for src/debputy/lsp/lsp_debian_rules.py: 21%

149 statements  

« prev     ^ index     » next       coverage.py v7.6.0, created at 2025-01-27 13:59 +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.debputy_ls import DebputyLanguageServer 

23from debputy.lsp.lsp_features import ( 

24 lint_diagnostics, 

25 lsp_standard_handler, 

26 lsp_completer, 

27 SecondaryLanguage, 

28 LanguageDispatchRule, 

29) 

30from debputy.lsp.quickfixes import propose_correct_text_quick_fix 

31from debputy.lsp.spellchecking import spellcheck_line 

32from debputy.lsp.text_util import ( 

33 LintCapablePositionCodec, 

34) 

35from debputy.lsprotocol.types import ( 

36 CompletionItem, 

37 Diagnostic, 

38 CompletionList, 

39 CompletionParams, 

40 TEXT_DOCUMENT_WILL_SAVE_WAIT_UNTIL, 

41 TEXT_DOCUMENT_CODE_ACTION, 

42) 

43from debputy.util import detect_possible_typo 

44 

45try: 

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

47 Position as TEPosition, 

48 Range as TERange, 

49 ) 

50 

51 from pygls.server import LanguageServer 

52 from pygls.workspace import TextDocument 

53except ImportError: 

54 pass 

55 

56 

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

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

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

60 

61 

62_KNOWN_TARGETS = { 

63 "binary", 

64 "binary-arch", 

65 "binary-indep", 

66 "build", 

67 "build-arch", 

68 "build-indep", 

69 "clean", 

70} 

71 

72_COMMAND_WORDS = frozenset( 

73 { 

74 "export", 

75 "ifeq", 

76 "ifneq", 

77 "ifdef", 

78 "ifndef", 

79 "endif", 

80 "else", 

81 } 

82) 

83_DISPATCH_RULE = LanguageDispatchRule.new_rule( 

84 "debian/rules", 

85 "debian/rules", 

86 [ 

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

88 SecondaryLanguage("makefile-gmake", filename_based_lookup=True), 

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

90 SecondaryLanguage("make", filename_based_lookup=True), 

91 # LSP's official language ID for Makefile 

92 SecondaryLanguage("makefile", filename_based_lookup=True), 

93 ], 

94) 

95 

96 

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

98 for prefix, suffix in itertools.product( 

99 ["override_", "execute_before_", "execute_after_"], 

100 ["", "-arch", "-indep"], 

101 ): 

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

103 

104 

105lsp_standard_handler(_DISPATCH_RULE, TEXT_DOCUMENT_CODE_ACTION) 

106lsp_standard_handler(_DISPATCH_RULE, TEXT_DOCUMENT_WILL_SAVE_WAIT_UNTIL) 

107 

108 

109@functools.lru_cache 

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

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

112 

113 

114def _run_make_dryrun( 

115 lint_state: LintState, 

116 source_root: str, 

117 lines: List[str], 

118) -> None: 

119 if not _is_project_trusted(source_root): 

120 return None 

121 try: 

122 make_res = subprocess.run( 

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

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

125 stdout=subprocess.DEVNULL, 

126 stderr=subprocess.PIPE, 

127 cwd=source_root, 

128 timeout=1, 

129 ) 

130 except (FileNotFoundError, subprocess.TimeoutExpired): 

131 pass 

132 else: 

133 if make_res.returncode != 0: 

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

135 m = _MAKE_ERROR_RE.match(make_output) 

136 if m: 

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

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

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

140 error_range = TERange( 

141 TEPosition( 

142 line_of_error, 

143 0, 

144 ), 

145 TEPosition( 

146 line_of_error + 1, 

147 0, 

148 ), 

149 ) 

150 lint_state.emit_diagnostic( 

151 error_range, 

152 f"make error: {msg}", 

153 "error", 

154 "debputy", 

155 ) 

156 return 

157 

158 

159def iter_make_lines( 

160 lint_state: LintState, 

161 lines: List[str], 

162) -> Iterator[Tuple[int, str]]: 

163 skip_next_line = False 

164 is_extended_comment = False 

165 for line_no, line in enumerate(lines): 

166 skip_this = skip_next_line 

167 skip_next_line = False 

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

169 skip_next_line = True 

170 

171 if skip_this: 

172 if is_extended_comment: 

173 spellcheck_line(lint_state, line_no, line) 

174 continue 

175 

176 if line.startswith("#"): 

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) 

202def _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 the choice of sequences.", 

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 candidates = detect_possible_typo(target, all_allowed_hook_targets) 

272 if not candidates and not target.startswith( 

273 ("override_", "execute_before_", "execute_after_") 

274 ): 

275 continue 

276 

277 r = TERange( 

278 TEPosition( 

279 line_no, 

280 pos, 

281 ), 

282 TEPosition( 

283 line_no, 

284 endpos, 

285 ), 

286 ) 

287 if candidates: 

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

289 else: 

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

291 if candidates: 

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

293 else: 

294 fixes = [] 

295 lint_state.emit_diagnostic( 

296 r, 

297 msg, 

298 "warning", 

299 "debputy", 

300 quickfixes=fixes, 

301 ) 

302 

303 

304@lsp_completer(_DISPATCH_RULE) 

305def _debian_rules_completions( 

306 ls: "DebputyLanguageServer", 

307 params: CompletionParams, 

308) -> Optional[Union[CompletionList, Sequence[CompletionItem]]]: 

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

310 lines = doc.lines 

311 server_position = doc.position_codec.position_from_client_units( 

312 lines, params.position 

313 ) 

314 

315 line = lines[server_position.line] 

316 line_start = line[0 : server_position.character] 

317 

318 if _CONTAINS_TAB_OR_COLON.search(line_start): 

319 return None 

320 

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

322 dh_sequencer_data = ls.lint_state(doc).dh_sequencer_data 

323 dh_sequences = dh_sequencer_data.sequences 

324 dh_commands = resolve_active_and_inactive_dh_commands( 

325 dh_sequences, 

326 source_root=source_root, 

327 ) 

328 if not dh_commands.active_commands: 

329 return None 

330 items = [ 

331 CompletionItem(ht) 

332 for c in dh_commands.active_commands 

333 for ht in _as_hook_targets(c) 

334 ] 

335 

336 return items