Coverage for src/debputy/lsp/lsp_debian_changelog.py: 82%

121 statements  

« prev     ^ index     » next       coverage.py v7.6.0, created at 2025-01-27 13:59 +0000

1import re 

2from email.utils import parsedate_to_datetime 

3 

4from debputy.linting.lint_util import LintState 

5from debputy.lsp.lsp_features import ( 

6 lsp_standard_handler, 

7 SecondaryLanguage, 

8 LanguageDispatchRule, 

9 lint_diagnostics, 

10) 

11from debputy.lsp.quickfixes import ( 

12 propose_correct_text_quick_fix, 

13) 

14from debputy.lsp.spellchecking import spellcheck_line 

15from debputy.lsprotocol.types import ( 

16 TEXT_DOCUMENT_WILL_SAVE_WAIT_UNTIL, 

17 TEXT_DOCUMENT_CODE_ACTION, 

18) 

19from debputy.util import PKGVERSION_REGEX 

20 

21try: 

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

23 Position as TEPosition, 

24 Range as TERange, 

25 ) 

26 

27 from pygls.server import LanguageServer 

28 from pygls.workspace import TextDocument 

29 from debputy.lsp.debputy_ls import DebputyLanguageServer 

30except ImportError: 

31 pass 

32 

33 

34# Same as Lintian 

35_MAXIMUM_WIDTH: int = 82 

36_HEADER_LINE = re.compile(r"^(\S+)\s*[(]([^)]+)[)]") # TODO: Add rest 

37_DISPATCH_RULE = LanguageDispatchRule.new_rule( 

38 "debian/changelog", 

39 ("debian/changelog", "debian/changelog.dch"), 

40 [ 

41 # emacs's name 

42 SecondaryLanguage("debian-changelog"), 

43 # vim's name 

44 SecondaryLanguage("debchangelog"), 

45 SecondaryLanguage("dch"), 

46 ], 

47) 

48 

49 

50_WEEKDAYS_BY_IDX = [ 

51 "Mon", 

52 "Tue", 

53 "Wed", 

54 "Thu", 

55 "Fri", 

56 "Sat", 

57 "Sun", 

58] 

59_KNOWN_WEEK_DAYS = frozenset(_WEEKDAYS_BY_IDX) 

60 

61 

62lsp_standard_handler(_DISPATCH_RULE, TEXT_DOCUMENT_CODE_ACTION) 

63lsp_standard_handler(_DISPATCH_RULE, TEXT_DOCUMENT_WILL_SAVE_WAIT_UNTIL) 

64 

65 

66def _check_footer_line( 

67 lint_state: LintState, 

68 line: str, 

69 line_no: int, 

70) -> None: 

71 try: 

72 end_email_idx = line.rindex("> ") 

73 except ValueError: 

74 # Syntax error; flag later 

75 return 

76 line_len = len(line) 

77 start_date_idx = end_email_idx + 3 

78 # 3 characters for the day name (Mon), then a comma plus a space followed by the 

79 # actual date. The 6 characters limit is a gross under estimation of the real 

80 # size. 

81 if line_len < start_date_idx + 6: 81 ↛ 82line 81 didn't jump to line 82 because the condition on line 81 was never true

82 text_range = _single_line_subrange(line_no, start_date_idx, line_len) 

83 lint_state.emit_diagnostic( 

84 text_range, 

85 "Expected a date in RFC822 format (Tue, 12 Mar 2024 12:34:56 +0000)", 

86 "error", 

87 "debputy", 

88 ) 

89 return 

90 day_name_range = _single_line_subrange(line_no, start_date_idx, start_date_idx + 3) 

91 day_name = line[start_date_idx : start_date_idx + 3] 

92 if day_name not in _KNOWN_WEEK_DAYS: 92 ↛ 93line 92 didn't jump to line 93 because the condition on line 92 was never true

93 lint_state.emit_diagnostic( 

94 day_name_range, 

95 "Expected a three letter date here using US English format (Mon, Tue, ..., Sun).", 

96 "error", 

97 "debputy", 

98 ) 

99 return 

100 

101 date_str = line[start_date_idx + 5 :] 

102 

103 if line[start_date_idx + 3 : start_date_idx + 5] != ", ": 103 ↛ 104line 103 didn't jump to line 104 because the condition on line 103 was never true

104 sep = line[start_date_idx + 3 : start_date_idx + 5] 

105 text_range = _single_line_subrange( 

106 line_no, 

107 start_date_idx + 3, 

108 start_date_idx + 4, 

109 ) 

110 lint_state.emit_diagnostic( 

111 text_range, 

112 f'Improper formatting of date. Expected ", " here, not "{sep}"', 

113 "error", 

114 "debputy", 

115 ) 

116 return 

117 

118 try: 

119 # FIXME: this parser is too forgiving (it ignores trailing garbage) 

120 date = parsedate_to_datetime(date_str) 

121 except ValueError as e: 

122 error_range = _single_line_subrange(line_no, start_date_idx + 5, line_len) 

123 lint_state.emit_diagnostic( 

124 error_range, 

125 f"Unable to the date as a valid RFC822 date: {e.args[0]}", 

126 "error", 

127 "debputy", 

128 ) 

129 return 

130 expected_week_day = _WEEKDAYS_BY_IDX[date.weekday()] 

131 if expected_week_day != day_name: 131 ↛ 132line 131 didn't jump to line 132 because the condition on line 131 was never true

132 lint_state.emit_diagnostic( 

133 day_name_range, 

134 f"The date was a {expected_week_day}day.", 

135 "warning", 

136 "debputy", 

137 quickfixes=[propose_correct_text_quick_fix(expected_week_day)], 

138 ) 

139 

140 

141def _single_line_subrange( 

142 line_no: int, 

143 character_start_pos: int, 

144 character_end_pos: int, 

145) -> "TERange": 

146 return TERange( 

147 TEPosition( 

148 line_no, 

149 character_start_pos, 

150 ), 

151 TEPosition( 

152 line_no, 

153 character_end_pos, 

154 ), 

155 ) 

156 

157 

158def _check_header_line( 

159 lint_state: LintState, 

160 line: str, 

161 line_no: int, 

162 entry_no: int, 

163) -> None: 

164 m = _HEADER_LINE.search(line) 

165 if not m: 165 ↛ 167line 165 didn't jump to line 167 because the condition on line 165 was never true

166 # Syntax error: TODO flag later 

167 return 

168 source_name, source_version = m.groups() 

169 dctrl_source_pkg = lint_state.source_package 

170 if ( 

171 entry_no == 1 

172 and dctrl_source_pkg is not None 

173 and dctrl_source_pkg.fields.get("Source") != source_name 

174 ): 

175 expected_name = dctrl_source_pkg.fields.get("Source") 

176 start_pos, end_pos = m.span(1) 

177 name_range = _single_line_subrange(line_no, start_pos, end_pos) 

178 if expected_name is None: 178 ↛ 179line 178 didn't jump to line 179

179 msg = ( 

180 "The first entry must use the same source name as debian/control." 

181 ' The d/control file is missing the "Source" field in its first stanza' 

182 ) 

183 else: 

184 msg = ( 

185 "The first entry must use the same source name as debian/control." 

186 f' Changelog uses: "{source_name}" while d/control uses: "{expected_name}"' 

187 ) 

188 

189 lint_state.emit_diagnostic( 

190 name_range, 

191 msg, 

192 "error", 

193 "dpkg", # man:deb-src-control(5) / #1089794 

194 ) 

195 if not PKGVERSION_REGEX.fullmatch(source_version): 

196 vm = PKGVERSION_REGEX.search(source_version) 

197 start_pos, end_pos = m.span(2) 

198 if vm: 

199 start_valid, end_valid = vm.span(0) 

200 invalid_ranges = [] 

201 if start_valid > 0: 201 ↛ 209line 201 didn't jump to line 209 because the condition on line 201 was always true

202 name_range = _single_line_subrange( 

203 line_no, 

204 start_pos, 

205 start_pos + start_valid, 

206 ) 

207 invalid_ranges.append(name_range) 

208 

209 if end_valid < len(source_version): 209 ↛ 217line 209 didn't jump to line 217 because the condition on line 209 was always true

210 name_range = _single_line_subrange( 

211 line_no, 

212 start_pos + end_valid, 

213 end_pos, 

214 ) 

215 invalid_ranges.append(name_range) 

216 

217 for r in invalid_ranges: 

218 lint_state.emit_diagnostic( 

219 r, 

220 "This part cannot be parsed as a valid Debian version.", 

221 "error", 

222 "Policy 5.6.12", 

223 ) 

224 else: 

225 name_range = _single_line_subrange(line_no, start_pos, end_pos) 

226 lint_state.emit_diagnostic( 

227 name_range, 

228 f'Cannot parse "{source_version}" as a Debian version.', 

229 "error", 

230 "Policy 5.6.12", 

231 ) 

232 elif "dsfg" in source_version: 

233 typo_index = source_version.index("dsfg") 

234 start_pos, end_pos = m.span(2) 

235 

236 name_range = _single_line_subrange( 

237 line_no, 

238 start_pos + typo_index, 

239 start_pos + typo_index + 4, 

240 ) 

241 lint_state.emit_diagnostic( 

242 name_range, 

243 'Typo of "dfsg" (Debian Free Software Guidelines)', 

244 "pedantic", 

245 "debputy", 

246 quickfixes=[propose_correct_text_quick_fix("dfsg")], 

247 ) 

248 

249 

250@lint_diagnostics(_DISPATCH_RULE) 

251def _lint_debian_changelog(lint_state: LintState) -> None: 

252 lines = lint_state.lines 

253 entry_no = 0 

254 entry_limit = 2 

255 max_words = 1000 

256 max_line_length = _MAXIMUM_WIDTH 

257 for line_no, line in enumerate(lines): 

258 orig_line = line 

259 line = line.rstrip() 

260 if not line: 

261 continue 

262 if line.startswith(" --"): 

263 _check_footer_line(lint_state, line, line_no) 

264 continue 

265 if not line.startswith(" "): 

266 if not line[0].isspace(): 266 ↛ 278line 266 didn't jump to line 278 because the condition on line 266 was always true

267 entry_no += 1 

268 # Figure out the right cut which may not be as simple as just the 

269 # top two. 

270 if entry_no > entry_limit: 

271 break 

272 _check_header_line( 

273 lint_state, 

274 line, 

275 line_no, 

276 entry_no, 

277 ) 

278 continue 

279 # minus 1 for newline 

280 orig_line_len = len(orig_line) - 1 

281 if orig_line_len > max_line_length: 

282 exceeded_line_range = _single_line_subrange( 

283 line_no, 

284 max_line_length, 

285 orig_line_len, 

286 ) 

287 lint_state.emit_diagnostic( 

288 exceeded_line_range, 

289 f"Line exceeds {max_line_length} characters", 

290 "pedantic", 

291 "debputy", 

292 ) 

293 if len(line) > 3 and line[2] == "[" and line[-1] == "]": 293 ↛ 295line 293 didn't jump to line 295 because the condition on line 293 was never true

294 # Do not spell check [ X ] as X is usually a name 

295 continue 

296 if max_words > 0: 296 ↛ 257line 296 didn't jump to line 257 because the condition on line 296 was always true

297 new_diagnostics = spellcheck_line(lint_state, line_no, line) 

298 max_words -= new_diagnostics