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

1from typing import ( 

2 Union, 

3 Sequence, 

4 Tuple, 

5 Iterator, 

6 Optional, 

7 Iterable, 

8 Mapping, 

9 List, 

10) 

11 

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) 

33 

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 

80 

81try: 

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

83 Position as TEPosition, 

84 Range as TERange, 

85 START_POSITION, 

86 ) 

87 

88 from pygls.server import LanguageServer 

89 from pygls.workspace import TextDocument 

90except ImportError: 

91 pass 

92 

93 

94_LANGUAGE_IDS = [ 

95 "debian/control", 

96 # emacs's name 

97 "debian-control", 

98 # vim's name 

99 "debcontrol", 

100] 

101 

102 

103_DCTRL_FILE_METADATA = DctrlFileMetadata() 

104 

105 

106lsp_standard_handler(_LANGUAGE_IDS, TEXT_DOCUMENT_CODE_ACTION) 

107lsp_standard_handler(_LANGUAGE_IDS, TEXT_DOCUMENT_WILL_SAVE_WAIT_UNTIL) 

108 

109 

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) 

116 

117 

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) 

124 

125 

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) 

132 

133 

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 

139 

140 for token in tokens: 

141 start_line = line_no 

142 start_line_offset = line_offset 

143 

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 

155 

156 yield token, start_line, start_line_offset, line_no, line_offset 

157 

158 

159def _paragraph_representation_field( 

160 paragraph: Deb822ParagraphElement, 

161) -> Deb822KeyValuePairElement: 

162 return next(iter(paragraph.iter_parts_of_type(Deb822KeyValuePairElement))) 

163 

164 

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 

189 

190 

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 += "/" 

219 

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 ) 

260 

261 

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 

291 

292 if known_field.inherits_from_source and known_field.name in source_stanza: 

293 continue 

294 

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 ) 

303 

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 ) 

314 

315 seen_fields = {} 

316 

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 ) 

343 

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 

384 

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 ) 

456 

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 ) 

504 

505 

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 

568 

569 

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 ) 

583 

584 first_error = _scan_for_syntax_errors_and_token_level_diagnostics( 

585 deb822_file, 

586 position_codec, 

587 lines, 

588 diagnostics, 

589 ) 

590 

591 paragraphs = list(deb822_file) 

592 source_paragraph = paragraphs[0] if paragraphs else None 

593 

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 ) 

617 

618 return diagnostics 

619 

620 

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 )