Coverage for src/debputy/lsp/lsp_debian_control.py: 72%
216 statements
« prev ^ index » next coverage.py v7.2.7, created at 2024-04-07 12:14 +0200
« prev ^ index » next coverage.py v7.2.7, created at 2024-04-07 12:14 +0200
1from typing import (
2 Union,
3 Sequence,
4 Tuple,
5 Iterator,
6 Optional,
7 Iterable,
8 Mapping,
9 List,
10)
12from lsprotocol.types import (
13 DiagnosticSeverity,
14 Range,
15 Diagnostic,
16 Position,
17 DidOpenTextDocumentParams,
18 DidChangeTextDocumentParams,
19 FoldingRange,
20 FoldingRangeParams,
21 CompletionItem,
22 CompletionList,
23 CompletionParams,
24 TEXT_DOCUMENT_WILL_SAVE_WAIT_UNTIL,
25 DiagnosticRelatedInformation,
26 Location,
27 HoverParams,
28 Hover,
29 TEXT_DOCUMENT_CODE_ACTION,
30 SemanticTokens,
31 SemanticTokensParams,
32)
34from debputy.linting.lint_util import LintState
35from debputy.lsp.lsp_debian_control_reference_data import (
36 DctrlKnownField,
37 BINARY_FIELDS,
38 SOURCE_FIELDS,
39 DctrlFileMetadata,
40)
41from debputy.lsp.lsp_features import (
42 lint_diagnostics,
43 lsp_completer,
44 lsp_hover,
45 lsp_standard_handler,
46 lsp_folding_ranges,
47 lsp_semantic_tokens_full,
48)
49from debputy.lsp.lsp_generic_deb822 import (
50 deb822_completer,
51 deb822_hover,
52 deb822_folding_ranges,
53 deb822_semantic_tokens_full,
54)
55from debputy.lsp.quickfixes import (
56 propose_remove_line_quick_fix,
57 range_compatible_with_remove_line_fix,
58 propose_correct_text_quick_fix,
59)
60from debputy.lsp.spellchecking import default_spellchecker
61from debputy.lsp.text_util import (
62 normalize_dctrl_field_name,
63 LintCapablePositionCodec,
64 detect_possible_typo,
65 te_range_to_lsp,
66)
67from debputy.lsp.vendoring._deb822_repro import (
68 parse_deb822_file,
69 Deb822FileElement,
70 Deb822ParagraphElement,
71)
72from debputy.lsp.vendoring._deb822_repro.parsing import (
73 Deb822KeyValuePairElement,
74 LIST_SPACE_SEPARATED_INTERPRETATION,
75)
76from debputy.lsp.vendoring._deb822_repro.tokens import (
77 Deb822Token,
78)
79from debputy.util import _info
81try:
82 from debputy.lsp.vendoring._deb822_repro.locatable import (
83 Position as TEPosition,
84 Range as TERange,
85 START_POSITION,
86 )
88 from pygls.server import LanguageServer
89 from pygls.workspace import TextDocument
90except ImportError:
91 pass
94_LANGUAGE_IDS = [
95 "debian/control",
96 # emacs's name
97 "debian-control",
98 # vim's name
99 "debcontrol",
100]
103_DCTRL_FILE_METADATA = DctrlFileMetadata()
106lsp_standard_handler(_LANGUAGE_IDS, TEXT_DOCUMENT_CODE_ACTION)
107lsp_standard_handler(_LANGUAGE_IDS, TEXT_DOCUMENT_WILL_SAVE_WAIT_UNTIL)
110@lsp_hover(_LANGUAGE_IDS)
111def _debian_control_hover(
112 ls: "LanguageServer",
113 params: HoverParams,
114) -> Optional[Hover]:
115 return deb822_hover(ls, params, _DCTRL_FILE_METADATA)
118@lsp_completer(_LANGUAGE_IDS)
119def _debian_control_completions(
120 ls: "LanguageServer",
121 params: CompletionParams,
122) -> Optional[Union[CompletionList, Sequence[CompletionItem]]]:
123 return deb822_completer(ls, params, _DCTRL_FILE_METADATA)
126@lsp_folding_ranges(_LANGUAGE_IDS)
127def _debian_control_folding_ranges(
128 ls: "LanguageServer",
129 params: FoldingRangeParams,
130) -> Optional[Sequence[FoldingRange]]:
131 return deb822_folding_ranges(ls, params, _DCTRL_FILE_METADATA)
134def _deb822_token_iter(
135 tokens: Iterable[Deb822Token],
136) -> Iterator[Tuple[Deb822Token, int, int, int, int, int]]:
137 line_no = 0
138 line_offset = 0
140 for token in tokens:
141 start_line = line_no
142 start_line_offset = line_offset
144 newlines = token.text.count("\n")
145 line_no += newlines
146 text_len = len(token.text)
147 if newlines:
148 if token.text.endswith("\n"): 148 ↛ 152line 148 didn't jump to line 152, because the condition on line 148 was never false
149 line_offset = 0
150 else:
151 # -2, one to remove the "\n" and one to get 0-offset
152 line_offset = text_len - token.text.rindex("\n") - 2
153 else:
154 line_offset += text_len
156 yield token, start_line, start_line_offset, line_no, line_offset
159def _paragraph_representation_field(
160 paragraph: Deb822ParagraphElement,
161) -> Deb822KeyValuePairElement:
162 return next(iter(paragraph.iter_parts_of_type(Deb822KeyValuePairElement)))
165def _extract_first_value_and_position(
166 kvpair: Deb822KeyValuePairElement,
167 stanza_pos: "TEPosition",
168 position_codec: "LintCapablePositionCodec",
169 lines: List[str],
170) -> Tuple[Optional[str], Optional[Range]]:
171 kvpair_pos = kvpair.position_in_parent().relative_to(stanza_pos)
172 value_element_pos = kvpair.value_element.position_in_parent().relative_to(
173 kvpair_pos
174 )
175 for value_ref in kvpair.interpret_as( 175 ↛ 188line 175 didn't jump to line 188, because the loop on line 175 didn't complete
176 LIST_SPACE_SEPARATED_INTERPRETATION
177 ).iter_value_references():
178 v = value_ref.value
179 section_value_loc = value_ref.locatable
180 value_range_te = section_value_loc.range_in_parent().relative_to(
181 value_element_pos
182 )
183 section_range_server_units = te_range_to_lsp(value_range_te)
184 section_range = position_codec.range_to_client_units(
185 lines, section_range_server_units
186 )
187 return v, section_range
188 return None, None
191def _binary_package_checks(
192 stanza: Deb822ParagraphElement,
193 stanza_position: "TEPosition",
194 source_stanza: Deb822ParagraphElement,
195 representation_field_range: Range,
196 position_codec: "LintCapablePositionCodec",
197 lines: List[str],
198 diagnostics: List[Diagnostic],
199) -> None:
200 package_name = stanza.get("Package", "")
201 source_section = source_stanza.get("Section")
202 section_kvpair = stanza.get_kvpair_element("Section", use_get=True)
203 section: Optional[str] = None
204 if section_kvpair is not None:
205 section, section_range = _extract_first_value_and_position(
206 section_kvpair,
207 stanza_position,
208 position_codec,
209 lines,
210 )
211 else:
212 section_range = representation_field_range
213 effective_section = section or source_section or "unknown"
214 package_type = stanza.get("Package-Type", "")
215 component_prefix = ""
216 if "/" in effective_section: 216 ↛ 217line 216 didn't jump to line 217, because the condition on line 216 was never true
217 component_prefix, effective_section = effective_section.split("/", maxsplit=1)
218 component_prefix += "/"
220 if package_name.endswith("-udeb") or package_type == "udeb": 220 ↛ 221line 220 didn't jump to line 221, because the condition on line 220 was never true
221 if package_type != "udeb":
222 package_type_kvpair = stanza.get_kvpair_element(
223 "Package-Type", use_get=True
224 )
225 package_type_range = None
226 if package_type_kvpair is not None:
227 _, package_type_range = _extract_first_value_and_position(
228 package_type_kvpair,
229 stanza_position,
230 position_codec,
231 lines,
232 )
233 if package_type_range is None:
234 package_type_range = representation_field_range
235 diagnostics.append(
236 Diagnostic(
237 package_type_range,
238 'The Package-Type should be "udeb" given the package name',
239 severity=DiagnosticSeverity.Warning,
240 source="debputy",
241 )
242 )
243 if effective_section != "debian-installer":
244 quickfix_data = None
245 if section is not None:
246 quickfix_data = [
247 propose_correct_text_quick_fix(
248 f"{component_prefix}debian-installer"
249 )
250 ]
251 diagnostics.append(
252 Diagnostic(
253 section_range,
254 f'The Section should be "{component_prefix}debian-installer" for udebs',
255 severity=DiagnosticSeverity.Warning,
256 source="debputy",
257 data=quickfix_data,
258 )
259 )
262def _diagnostics_for_paragraph(
263 stanza: Deb822ParagraphElement,
264 stanza_position: "TEPosition",
265 source_stanza: Deb822ParagraphElement,
266 known_fields: Mapping[str, DctrlKnownField],
267 other_known_fields: Mapping[str, DctrlKnownField],
268 is_binary_paragraph: bool,
269 doc_reference: str,
270 position_codec: "LintCapablePositionCodec",
271 lines: List[str],
272 diagnostics: List[Diagnostic],
273) -> None:
274 representation_field = _paragraph_representation_field(stanza)
275 representation_field_pos = representation_field.position_in_parent().relative_to(
276 stanza_position
277 )
278 representation_field_range_server_units = te_range_to_lsp(
279 TERange.from_position_and_size(
280 representation_field_pos, representation_field.size()
281 )
282 )
283 representation_field_range = position_codec.range_to_client_units(
284 lines,
285 representation_field_range_server_units,
286 )
287 for known_field in known_fields.values():
288 missing_field_severity = known_field.missing_field_severity
289 if missing_field_severity is None or known_field.name in stanza:
290 continue
292 if known_field.inherits_from_source and known_field.name in source_stanza:
293 continue
295 diagnostics.append(
296 Diagnostic(
297 representation_field_range,
298 f"Stanza is missing field {known_field.name}",
299 severity=missing_field_severity,
300 source="debputy",
301 )
302 )
304 if is_binary_paragraph:
305 _binary_package_checks(
306 stanza,
307 stanza_position,
308 source_stanza,
309 representation_field_range,
310 position_codec,
311 lines,
312 diagnostics,
313 )
315 seen_fields = {}
317 for kvpair in stanza.iter_parts_of_type(Deb822KeyValuePairElement):
318 field_name_token = kvpair.field_token
319 field_name = field_name_token.text
320 field_name_lc = field_name.lower()
321 normalized_field_name_lc = normalize_dctrl_field_name(field_name_lc)
322 known_field = known_fields.get(normalized_field_name_lc)
323 field_value = stanza[field_name]
324 field_range_te = kvpair.range_in_parent().relative_to(stanza_position)
325 field_position_te = field_range_te.start_pos
326 field_range_server_units = te_range_to_lsp(field_range_te)
327 field_range = position_codec.range_to_client_units(
328 lines,
329 field_range_server_units,
330 )
331 field_name_typo_detected = False
332 existing_field_range = seen_fields.get(normalized_field_name_lc)
333 if existing_field_range is not None: 333 ↛ 334line 333 didn't jump to line 334, because the condition on line 333 was never true
334 existing_field_range[3].append(field_range)
335 else:
336 normalized_field_name = normalize_dctrl_field_name(field_name)
337 seen_fields[field_name_lc] = (
338 field_name,
339 normalized_field_name,
340 field_range,
341 [],
342 )
344 if known_field is None:
345 candidates = detect_possible_typo(normalized_field_name_lc, known_fields)
346 if candidates:
347 known_field = known_fields[candidates[0]]
348 token_range_server_units = te_range_to_lsp(
349 TERange.from_position_and_size(
350 field_position_te, kvpair.field_token.size()
351 )
352 )
353 field_range = position_codec.range_to_client_units(
354 lines,
355 token_range_server_units,
356 )
357 field_name_typo_detected = True
358 diagnostics.append(
359 Diagnostic(
360 field_range,
361 f'The "{field_name}" looks like a typo of "{known_field.name}".',
362 severity=DiagnosticSeverity.Warning,
363 source="debputy",
364 data=[
365 propose_correct_text_quick_fix(known_fields[m].name)
366 for m in candidates
367 ],
368 )
369 )
370 if known_field is None:
371 known_else_where = other_known_fields.get(normalized_field_name_lc)
372 if known_else_where is not None: 372 ↛ 373line 372 didn't jump to line 373, because the condition on line 372 was never true
373 intended_usage = "Source" if is_binary_paragraph else "Package"
374 diagnostics.append(
375 Diagnostic(
376 field_range,
377 f'The {field_name} is defined for use in the "{intended_usage}" stanza.'
378 f" Please move it to the right place or remove it",
379 severity=DiagnosticSeverity.Error,
380 source="debputy",
381 )
382 )
383 continue
385 if field_value.strip() == "": 385 ↛ 386line 385 didn't jump to line 386, because the condition on line 385 was never true
386 diagnostics.append(
387 Diagnostic(
388 field_range,
389 f"The {field_name} has no value. Either provide a value or remove it.",
390 severity=DiagnosticSeverity.Error,
391 source="debputy",
392 )
393 )
394 continue
395 diagnostics.extend(
396 known_field.field_diagnostics(
397 kvpair,
398 stanza,
399 stanza_position,
400 position_codec,
401 lines,
402 field_name_typo_reported=field_name_typo_detected,
403 )
404 )
405 if known_field.spellcheck_value:
406 words = kvpair.interpret_as(LIST_SPACE_SEPARATED_INTERPRETATION)
407 spell_checker = default_spellchecker()
408 value_position = kvpair.value_element.position_in_parent().relative_to(
409 field_position_te
410 )
411 for word_ref in words.iter_value_references():
412 token = word_ref.value
413 for word, pos, endpos in spell_checker.iter_words(token):
414 corrections = spell_checker.provide_corrections_for(word)
415 if not corrections: 415 ↛ 417line 415 didn't jump to line 417, because the condition on line 415 was never false
416 continue
417 word_loc = word_ref.locatable
418 word_pos_te = word_loc.position_in_parent().relative_to(
419 value_position
420 )
421 if pos:
422 word_pos_te = TEPosition(0, pos).relative_to(word_pos_te)
423 word_range = TERange(
424 START_POSITION,
425 TEPosition(0, endpos - pos),
426 )
427 word_range_server_units = te_range_to_lsp(
428 TERange.from_position_and_size(word_pos_te, word_range)
429 )
430 word_range = position_codec.range_to_client_units(
431 lines,
432 word_range_server_units,
433 )
434 diagnostics.append(
435 Diagnostic(
436 word_range,
437 f'Spelling "{word}"',
438 severity=DiagnosticSeverity.Hint,
439 source="debputy",
440 data=[
441 propose_correct_text_quick_fix(c) for c in corrections
442 ],
443 )
444 )
445 source_value = source_stanza.get(field_name)
446 if known_field.warn_if_default and field_value == known_field.default_value: 446 ↛ 447line 446 didn't jump to line 447, because the condition on line 446 was never true
447 diagnostics.append(
448 Diagnostic(
449 field_range,
450 f"The {field_name} is redundant as it is set to the default value and the field should only be"
451 " used in exceptional cases.",
452 severity=DiagnosticSeverity.Warning,
453 source="debputy",
454 )
455 )
457 if known_field.inherits_from_source and field_value == source_value: 457 ↛ 458line 457 didn't jump to line 458, because the condition on line 457 was never true
458 if range_compatible_with_remove_line_fix(field_range):
459 fix_data = propose_remove_line_quick_fix()
460 else:
461 fix_data = None
462 diagnostics.append(
463 Diagnostic(
464 field_range,
465 f"The field {field_name} duplicates the value from the Source stanza.",
466 severity=DiagnosticSeverity.Information,
467 source="debputy",
468 data=fix_data,
469 )
470 )
471 for (
472 field_name,
473 normalized_field_name,
474 field_range,
475 duplicates,
476 ) in seen_fields.values():
477 if not duplicates: 477 ↛ 479line 477 didn't jump to line 479
478 continue
479 related_information = [
480 DiagnosticRelatedInformation(
481 location=Location(doc_reference, field_range),
482 message=f"First definition of {field_name}",
483 )
484 ]
485 related_information.extend(
486 DiagnosticRelatedInformation(
487 location=Location(doc_reference, r),
488 message=f"Duplicate of {field_name}",
489 )
490 for r in duplicates
491 )
492 for dup_range in duplicates:
493 diagnostics.append(
494 Diagnostic(
495 dup_range,
496 f"The {normalized_field_name} field name was used multiple times in this stanza."
497 f" Please ensure the field is only used once per stanza. Note that {normalized_field_name} and"
498 f" X[BCS]-{normalized_field_name} are considered the same field.",
499 severity=DiagnosticSeverity.Error,
500 source="debputy",
501 related_information=related_information,
502 )
503 )
506def _scan_for_syntax_errors_and_token_level_diagnostics(
507 deb822_file: Deb822FileElement,
508 position_codec: LintCapablePositionCodec,
509 lines: List[str],
510 diagnostics: List[Diagnostic],
511) -> int:
512 first_error = len(lines) + 1
513 spell_checker = default_spellchecker()
514 for (
515 token,
516 start_line,
517 start_offset,
518 end_line,
519 end_offset,
520 ) in _deb822_token_iter(deb822_file.iter_tokens()):
521 if token.is_error: 521 ↛ 522line 521 didn't jump to line 522, because the condition on line 521 was never true
522 first_error = min(first_error, start_line)
523 start_pos = Position(
524 start_line,
525 start_offset,
526 )
527 end_pos = Position(
528 end_line,
529 end_offset,
530 )
531 token_range = position_codec.range_to_client_units(
532 lines, Range(start_pos, end_pos)
533 )
534 diagnostics.append(
535 Diagnostic(
536 token_range,
537 "Syntax error",
538 severity=DiagnosticSeverity.Error,
539 source="debputy (python-debian parser)",
540 )
541 )
542 elif token.is_comment:
543 for word, pos, end_pos in spell_checker.iter_words(token.text):
544 corrections = spell_checker.provide_corrections_for(word)
545 if not corrections: 545 ↛ 547line 545 didn't jump to line 547, because the condition on line 545 was never false
546 continue
547 start_pos = Position(
548 start_line,
549 pos,
550 )
551 end_pos = Position(
552 start_line,
553 end_pos,
554 )
555 word_range = position_codec.range_to_client_units(
556 lines, Range(start_pos, end_pos)
557 )
558 diagnostics.append(
559 Diagnostic(
560 word_range,
561 f'Spelling "{word}"',
562 severity=DiagnosticSeverity.Hint,
563 source="debputy",
564 data=[propose_correct_text_quick_fix(c) for c in corrections],
565 )
566 )
567 return first_error
570@lint_diagnostics(_LANGUAGE_IDS)
571def _lint_debian_control(
572 lint_state: LintState,
573) -> Optional[List[Diagnostic]]:
574 lines = lint_state.lines
575 position_codec = lint_state.position_codec
576 doc_reference = lint_state.doc_uri
577 diagnostics = []
578 deb822_file = parse_deb822_file(
579 lines,
580 accept_files_with_duplicated_fields=True,
581 accept_files_with_error_tokens=True,
582 )
584 first_error = _scan_for_syntax_errors_and_token_level_diagnostics(
585 deb822_file,
586 position_codec,
587 lines,
588 diagnostics,
589 )
591 paragraphs = list(deb822_file)
592 source_paragraph = paragraphs[0] if paragraphs else None
594 for paragraph_no, paragraph in enumerate(paragraphs, start=1):
595 paragraph_pos = paragraph.position_in_file()
596 if paragraph_pos.line_position >= first_error: 596 ↛ 597line 596 didn't jump to line 597, because the condition on line 596 was never true
597 break
598 is_binary_paragraph = paragraph_no != 1
599 if is_binary_paragraph:
600 known_fields = BINARY_FIELDS
601 other_known_fields = SOURCE_FIELDS
602 else:
603 known_fields = SOURCE_FIELDS
604 other_known_fields = BINARY_FIELDS
605 _diagnostics_for_paragraph(
606 paragraph,
607 paragraph_pos,
608 source_paragraph,
609 known_fields,
610 other_known_fields,
611 is_binary_paragraph,
612 doc_reference,
613 position_codec,
614 lines,
615 diagnostics,
616 )
618 return diagnostics
621@lsp_semantic_tokens_full(_LANGUAGE_IDS)
622def _semantic_tokens_full(
623 ls: "LanguageServer",
624 request: SemanticTokensParams,
625) -> Optional[SemanticTokens]:
626 return deb822_semantic_tokens_full(
627 ls,
628 request,
629 _DCTRL_FILE_METADATA,
630 )