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
« 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
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
21try:
22 from debputy.lsp.vendoring._deb822_repro.locatable import (
23 Position as TEPosition,
24 Range as TERange,
25 )
27 from pygls.server import LanguageServer
28 from pygls.workspace import TextDocument
29 from debputy.lsp.debputy_ls import DebputyLanguageServer
30except ImportError:
31 pass
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)
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)
62lsp_standard_handler(_DISPATCH_RULE, TEXT_DOCUMENT_CODE_ACTION)
63lsp_standard_handler(_DISPATCH_RULE, TEXT_DOCUMENT_WILL_SAVE_WAIT_UNTIL)
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
101 date_str = line[start_date_idx + 5 :]
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
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 )
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 )
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 )
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)
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)
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)
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 )
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