Coverage for src/debputy/lsp/lsp_generic_deb822.py: 79%

290 statements  

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

1import re 

2from itertools import chain 

3from typing import ( 

4 Optional, 

5 Union, 

6 Sequence, 

7 Tuple, 

8 Any, 

9 Container, 

10 List, 

11 Iterable, 

12 Iterator, 

13 Callable, 

14 cast, 

15) 

16 

17from debputy.lsprotocol.types import ( 

18 CompletionParams, 

19 CompletionList, 

20 CompletionItem, 

21 Position, 

22 MarkupContent, 

23 Hover, 

24 MarkupKind, 

25 HoverParams, 

26 FoldingRangeParams, 

27 FoldingRange, 

28 FoldingRangeKind, 

29 SemanticTokensParams, 

30 SemanticTokens, 

31 TextEdit, 

32 MessageType, 

33 SemanticTokenTypes, 

34) 

35 

36from debputy.linting.lint_util import LintState, te_position_to_lsp 

37from debputy.lsp.debputy_ls import DebputyLanguageServer 

38from debputy.lsp.lsp_debian_control_reference_data import ( 

39 Deb822FileMetadata, 

40 Deb822KnownField, 

41 StanzaMetadata, 

42 F, 

43 S, 

44) 

45from debputy.lsp.lsp_features import SEMANTIC_TOKEN_TYPES_IDS 

46from debputy.lsp.text_util import ( 

47 trim_end_of_line_whitespace, 

48 SemanticTokensState, 

49) 

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

51 START_POSITION, 

52 Range as TERange, 

53) 

54from debputy.lsp.vendoring._deb822_repro.parsing import ( 

55 Deb822KeyValuePairElement, 

56 Deb822ParagraphElement, 

57 Deb822FileElement, 

58) 

59from debputy.lsp.vendoring._deb822_repro.tokens import tokenize_deb822_file, Deb822Token 

60from debputy.lsp.vendoring._deb822_repro.types import TokenOrElement 

61from debputy.util import _info, _warn 

62 

63try: 

64 from pygls.server import LanguageServer 

65 from pygls.workspace import TextDocument 

66except ImportError: 

67 pass 

68 

69 

70_CONTAINS_SPACE_OR_COLON = re.compile(r"[\s:]") 

71 

72 

73def in_range( 

74 te_range: TERange, 

75 cursor_position: Position, 

76 *, 

77 inclusive_end: bool = False, 

78) -> bool: 

79 cursor_line = cursor_position.line 

80 start_pos = te_range.start_pos 

81 end_pos = te_range.end_pos 

82 if cursor_line < start_pos.line_position or cursor_line > end_pos.line_position: 

83 return False 

84 

85 if start_pos.line_position == end_pos.line_position: 

86 start_col = start_pos.cursor_position 

87 cursor_col = cursor_position.character 

88 end_col = end_pos.cursor_position 

89 if inclusive_end: 89 ↛ 91line 89 didn't jump to line 91 because the condition on line 89 was always true

90 return start_col <= cursor_col <= end_col 

91 return start_col <= cursor_col < end_col 

92 

93 if cursor_line == end_pos.line_position: 

94 return cursor_position.character < end_pos.cursor_position 

95 

96 return ( 

97 cursor_line > start_pos.line_position 

98 or start_pos.cursor_position <= cursor_position.character 

99 ) 

100 

101 

102def _field_at_position( 

103 stanza: Deb822ParagraphElement, 

104 stanza_metadata: S, 

105 stanza_range: TERange, 

106 position: Position, 

107) -> Tuple[Optional[Deb822KeyValuePairElement], Optional[F], str, bool]: 

108 te_range = TERange(stanza_range.start_pos, stanza_range.start_pos) 

109 for token_or_element in stanza.iter_parts(): 109 ↛ 137line 109 didn't jump to line 137 because the loop on line 109 didn't complete

110 te_range = token_or_element.size().relative_to(te_range.end_pos) 

111 if not in_range(te_range, position): 

112 continue 

113 if isinstance(token_or_element, Deb822KeyValuePairElement): 113 ↛ 109line 113 didn't jump to line 109 because the condition on line 113 was always true

114 value_range = token_or_element.value_element.range_in_parent().relative_to( 

115 te_range.start_pos 

116 ) 

117 known_field = stanza_metadata.get(token_or_element.field_name) 

118 in_value = in_range(value_range, position) 

119 interpreter = ( 

120 known_field.field_value_class.interpreter() 

121 if known_field is not None 

122 else None 

123 ) 

124 matched_value = "" 

125 if in_value and interpreter is not None: 

126 interpreted = token_or_element.interpret_as(interpreter) 

127 for value_ref in interpreted.iter_value_references(): 

128 value_token_range = ( 

129 value_ref.locatable.range_in_parent().relative_to( 

130 value_range.start_pos 

131 ) 

132 ) 

133 if in_range(value_token_range, position, inclusive_end=True): 133 ↛ 127line 133 didn't jump to line 127 because the condition on line 133 was always true

134 matched_value = value_ref.value 

135 break 

136 return token_or_element, known_field, matched_value, in_value 

137 return None, None, "", False 

138 

139 

140def _allow_stanza_continuation( 

141 token_or_element: TokenOrElement, 

142 is_completion: bool, 

143) -> bool: 

144 if not is_completion: 

145 return False 

146 if token_or_element.is_error or token_or_element.is_comment: 

147 return True 

148 return ( 

149 token_or_element.is_whitespace 

150 and token_or_element.convert_to_text().count("\n") < 2 

151 ) 

152 

153 

154def _at_cursor( 

155 deb822_file: Deb822FileElement, 

156 file_metadata: Deb822FileMetadata[S, F], 

157 doc: "TextDocument", 

158 lines: List[str], 

159 client_position: Position, 

160 is_completion: bool = False, 

161) -> Tuple[ 

162 Position, 

163 Optional[str], 

164 str, 

165 bool, 

166 Optional[S], 

167 Optional[F], 

168 Iterable[Deb822ParagraphElement], 

169]: 

170 server_position = doc.position_codec.position_from_client_units( 

171 lines, 

172 client_position, 

173 ) 

174 te_range = TERange( 

175 START_POSITION, 

176 START_POSITION, 

177 ) 

178 paragraph_no = -1 

179 previous_stanza: Optional[Deb822ParagraphElement] = None 

180 next_stanza: Optional[Deb822ParagraphElement] = None 

181 current_word = doc.word_at_position(client_position) 

182 in_value: bool = False 

183 file_iter = iter(deb822_file.iter_parts()) 

184 matched_token: Optional[TokenOrElement] = None 

185 matched_field: Optional[str] = None 

186 stanza_metadata: Optional[S] = None 

187 known_field: Optional[F] = None 

188 

189 for token_or_element in file_iter: 189 ↛ 213line 189 didn't jump to line 213 because the loop on line 189 didn't complete

190 te_range = token_or_element.size().relative_to(te_range.end_pos) 

191 if isinstance(token_or_element, Deb822ParagraphElement): 

192 previous_stanza = token_or_element 

193 paragraph_no += 1 

194 elif not _allow_stanza_continuation(token_or_element, is_completion): 

195 previous_stanza = None 

196 if not in_range(te_range, server_position): 

197 continue 

198 matched_token = token_or_element 

199 if isinstance(token_or_element, Deb822ParagraphElement): 

200 stanza_metadata = file_metadata.guess_stanza_classification_by_idx( 

201 paragraph_no 

202 ) 

203 kvpair, known_field, current_word, in_value = _field_at_position( 

204 token_or_element, 

205 stanza_metadata, 

206 te_range, 

207 server_position, 

208 ) 

209 if kvpair is not None: 209 ↛ 211line 209 didn't jump to line 211 because the condition on line 209 was always true

210 matched_field = kvpair.field_name 

211 break 

212 

213 if matched_token is not None and _allow_stanza_continuation( 

214 matched_token, 

215 is_completion, 

216 ): 

217 next_te = next(file_iter, None) 

218 if isinstance(next_te, Deb822ParagraphElement): 

219 next_stanza = next_te 

220 

221 stanza_parts = (p for p in (previous_stanza, next_stanza) if p is not None) 

222 

223 if stanza_metadata is None and is_completion: 

224 if paragraph_no < 0: 224 ↛ 225line 224 didn't jump to line 225 because the condition on line 224 was never true

225 paragraph_no = 0 

226 stanza_metadata = file_metadata.guess_stanza_classification_by_idx(paragraph_no) 

227 

228 return ( 

229 server_position, 

230 matched_field, 

231 current_word, 

232 in_value, 

233 stanza_metadata, 

234 known_field, 

235 stanza_parts, 

236 ) 

237 

238 

239def deb822_completer( 

240 ls: "DebputyLanguageServer", 

241 params: CompletionParams, 

242 file_metadata: Deb822FileMetadata[Any, Any], 

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

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

245 lines = doc.lines 

246 lint_state = ls.lint_state(doc) 

247 deb822_file = lint_state.parsed_deb822_file_content 

248 if deb822_file is None: 248 ↛ 249line 248 didn't jump to line 249 because the condition on line 248 was never true

249 _warn("The deb822 result missing failed!?") 

250 ls.show_message_log( 

251 "Internal error; could not get deb822 content!?", MessageType.Warning 

252 ) 

253 return None 

254 

255 ( 

256 _a, 

257 current_field, 

258 word_at_position, 

259 in_value, 

260 stanza_metadata, 

261 known_field, 

262 matched_stanzas, 

263 ) = _at_cursor( 

264 deb822_file, 

265 file_metadata, 

266 doc, 

267 lines, 

268 params.position, 

269 is_completion=True, 

270 ) 

271 

272 items: Optional[Sequence[CompletionItem]] 

273 markdown_kind = ls.completion_item_document_markup( 

274 MarkupKind.Markdown, MarkupKind.PlainText 

275 ) 

276 if in_value: 

277 _info(f"Completion for field value {current_field} -- {word_at_position}") 

278 if known_field is None: 278 ↛ 279line 278 didn't jump to line 279 because the condition on line 278 was never true

279 return None 

280 value_being_completed = word_at_position 

281 items = known_field.value_options_for_completer( 

282 lint_state, 

283 list(matched_stanzas), 

284 value_being_completed, 

285 markdown_kind, 

286 ) 

287 else: 

288 _info("Completing field name") 

289 assert stanza_metadata is not None 

290 items = _complete_field_name( 

291 lint_state, 

292 stanza_metadata, 

293 matched_stanzas, 

294 markdown_kind, 

295 ) 

296 

297 _info( 

298 f"Completion candidates: {[i.label for i in items] if items is not None else 'None'}" 

299 ) 

300 

301 return items 

302 

303 

304def deb822_hover( 

305 ls: "DebputyLanguageServer", 

306 params: HoverParams, 

307 file_metadata: Deb822FileMetadata[S, F], 

308 *, 

309 custom_handler: Optional[ 

310 Callable[ 

311 [ 

312 "DebputyLanguageServer", 

313 Position, 

314 Optional[str], 

315 str, 

316 Optional[F], 

317 bool, 

318 "TextDocument", 

319 List[str], 

320 ], 

321 Optional[Hover], 

322 ] 

323 ] = None, 

324) -> Optional[Hover]: 

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

326 lines = doc.lines 

327 deb822_file = ls.lint_state(doc).parsed_deb822_file_content 

328 if deb822_file is None: 328 ↛ 329line 328 didn't jump to line 329 because the condition on line 328 was never true

329 _warn("The deb822 result missing failed!?") 

330 ls.show_message_log( 

331 "Internal error; could not get deb822 content!?", MessageType.Warning 

332 ) 

333 return None 

334 

335 ( 

336 server_pos, 

337 current_field, 

338 word_at_position, 

339 in_value, 

340 _, 

341 known_field, 

342 _, 

343 ) = _at_cursor( 

344 deb822_file, 

345 file_metadata, 

346 doc, 

347 lines, 

348 params.position, 

349 ) 

350 hover_text = None 

351 if custom_handler is not None: 351 ↛ 366line 351 didn't jump to line 366 because the condition on line 351 was always true

352 res = custom_handler( 

353 ls, 

354 server_pos, 

355 current_field, 

356 word_at_position, 

357 known_field, 

358 in_value, 

359 doc, 

360 lines, 

361 ) 

362 if isinstance(res, Hover): 362 ↛ 363line 362 didn't jump to line 363 because the condition on line 362 was never true

363 return res 

364 hover_text = res 

365 

366 if hover_text is None: 

367 if current_field is None: 367 ↛ 368line 367 didn't jump to line 368 because the condition on line 367 was never true

368 _info("No hover information as we cannot determine which field it is for") 

369 return None 

370 

371 if known_field is None: 371 ↛ 372line 371 didn't jump to line 372 because the condition on line 371 was never true

372 return None 

373 if in_value: 373 ↛ 374line 373 didn't jump to line 374 because the condition on line 373 was never true

374 if not known_field.known_values: 

375 return None 

376 keyword = known_field.known_values.get(word_at_position) 

377 if keyword is None: 

378 return None 

379 hover_text = keyword.long_description_translated(ls) 

380 if hover_text is not None: 

381 header = "`{VALUE}` (Field: {FIELD_NAME})".format( 

382 VALUE=keyword.value, 

383 FIELD_NAME=known_field.name, 

384 ) 

385 hover_text = f"# {header}\n\n{hover_text}" 

386 else: 

387 hover_text = known_field.long_description_translated(ls) 

388 if hover_text is None: 388 ↛ 389line 388 didn't jump to line 389

389 hover_text = ( 

390 f"No documentation is available for the field {current_field}." 

391 ) 

392 hover_text = f"# {known_field.name}\n\n{hover_text}" 

393 

394 if hover_text is None: 394 ↛ 395line 394 didn't jump to line 395 because the condition on line 394 was never true

395 return None 

396 return Hover( 

397 contents=MarkupContent( 

398 kind=ls.hover_markup_format(MarkupKind.Markdown, MarkupKind.PlainText), 

399 value=hover_text, 

400 ) 

401 ) 

402 

403 

404def deb822_token_iter( 

405 tokens: Iterable[Deb822Token], 

406) -> Iterator[Tuple[Deb822Token, int, int, int, int]]: 

407 line_no = 0 

408 line_offset = 0 

409 

410 for token in tokens: 

411 start_line = line_no 

412 start_line_offset = line_offset 

413 

414 newlines = token.text.count("\n") 

415 line_no += newlines 

416 text_len = len(token.text) 

417 if newlines: 

418 if token.text.endswith("\n"): 418 ↛ 422line 418 didn't jump to line 422 because the condition on line 418 was always true

419 line_offset = 0 

420 else: 

421 # -2, one to remove the "\n" and one to get 0-offset 

422 line_offset = text_len - token.text.rindex("\n") - 2 

423 else: 

424 line_offset += text_len 

425 

426 yield token, start_line, start_line_offset, line_no, line_offset 

427 

428 

429def deb822_folding_ranges( 

430 ls: "DebputyLanguageServer", 

431 params: FoldingRangeParams, 

432 # Unused for now: might be relevant for supporting folding for some fields 

433 _file_metadata: Deb822FileMetadata[Any, Any], 

434) -> Optional[Sequence[FoldingRange]]: 

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

436 comment_start = -1 

437 folding_ranges = [] 

438 for ( 

439 token, 

440 start_line, 

441 start_offset, 

442 end_line, 

443 end_offset, 

444 ) in deb822_token_iter(tokenize_deb822_file(doc.lines)): 

445 if token.is_comment: 

446 if comment_start < 0: 

447 comment_start = start_line 

448 elif comment_start > -1: 

449 comment_start = -1 

450 folding_range = FoldingRange( 

451 comment_start, 

452 end_line, 

453 kind=FoldingRangeKind.Comment, 

454 ) 

455 

456 folding_ranges.append(folding_range) 

457 

458 return folding_ranges 

459 

460 

461class Deb822SemanticTokensState(SemanticTokensState): 

462 

463 __slots__ = ( 

464 "file_metadata", 

465 "keyword_token_code", 

466 "known_value_token_code", 

467 "comment_token_code", 

468 ) 

469 

470 def __init__( 

471 self, 

472 ls: "DebputyLanguageServer", 

473 doc: "TextDocument", 

474 lines: List[str], 

475 tokens: List[int], 

476 file_metadata: Deb822FileMetadata[Any, Any], 

477 keyword_token_code: int, 

478 known_value_token_code: int, 

479 comment_token_code: int, 

480 ) -> None: 

481 super().__init__(ls, doc, lines, tokens) 

482 self.file_metadata = file_metadata 

483 self.keyword_token_code = keyword_token_code 

484 self.known_value_token_code = known_value_token_code 

485 self.comment_token_code = comment_token_code 

486 

487 

488def _deb822_paragraph_semantic_tokens_full( 

489 sem_token_state: Deb822SemanticTokensState, 

490 stanza: Deb822ParagraphElement, 

491 stanza_idx: int, 

492) -> None: 

493 doc = sem_token_state.doc 

494 keyword_token_code = sem_token_state.keyword_token_code 

495 known_value_token_code = sem_token_state.known_value_token_code 

496 comment_token_code = sem_token_state.comment_token_code 

497 

498 stanza_position = stanza.position_in_file() 

499 stanza_metadata = sem_token_state.file_metadata.classify_stanza( 

500 stanza, 

501 stanza_idx=stanza_idx, 

502 ) 

503 for kvpair in stanza.iter_parts_of_type(Deb822KeyValuePairElement): 

504 kvpair_position = kvpair.position_in_parent().relative_to(stanza_position) 

505 field_start = kvpair.field_token.position_in_parent().relative_to( 

506 kvpair_position 

507 ) 

508 comment = kvpair.comment_element 

509 if comment: 

510 comment_start_line = field_start.line_position - len(comment) 

511 for comment_line_no, comment_token in enumerate( 

512 comment.iter_parts(), 

513 start=comment_start_line, 

514 ): 

515 assert comment_token.is_comment 

516 assert isinstance(comment_token, Deb822Token) 

517 sem_token_state.emit_token( 

518 Position(comment_line_no, 0), 

519 len(comment_token.text.rstrip()), 

520 comment_token_code, 

521 ) 

522 field_size = doc.position_codec.client_num_units(kvpair.field_name) 

523 

524 sem_token_state.emit_token( 

525 te_position_to_lsp(field_start), 

526 field_size, 

527 keyword_token_code, 

528 ) 

529 

530 known_field: Optional[Deb822KnownField] = stanza_metadata.get(kvpair.field_name) 

531 if known_field is not None: 

532 if known_field.spellcheck_value: 

533 continue 

534 known_values: Container[str] = known_field.known_values or frozenset() 

535 interpretation = known_field.field_value_class.interpreter() 

536 else: 

537 known_values = frozenset() 

538 interpretation = None 

539 

540 value_element_pos = kvpair.value_element.position_in_parent().relative_to( 

541 kvpair_position 

542 ) 

543 if interpretation is None: 

544 # TODO: Emit tokens for value comments of unknown fields. 

545 continue 

546 else: 

547 parts = kvpair.interpret_as(interpretation).iter_parts() 

548 for te in parts: 

549 if te.is_whitespace: 

550 continue 

551 if te.is_separator: 

552 continue 

553 value_range_in_parent_te = te.range_in_parent() 

554 value_range_te = value_range_in_parent_te.relative_to(value_element_pos) 

555 value = te.convert_to_text() 

556 if te.is_comment: 

557 token_type = comment_token_code 

558 value = value.rstrip() 

559 elif value in known_values: 

560 token_type = known_value_token_code 

561 else: 

562 continue 

563 value_len = doc.position_codec.client_num_units(value) 

564 

565 sem_token_state.emit_token( 

566 te_position_to_lsp(value_range_te.start_pos), 

567 value_len, 

568 token_type, 

569 ) 

570 

571 

572def deb822_format_file( 

573 lint_state: LintState, 

574 file_metadata: Deb822FileMetadata[Any, Any], 

575) -> Optional[Sequence[TextEdit]]: 

576 effective_preference = lint_state.effective_preference 

577 if effective_preference is None: 

578 return trim_end_of_line_whitespace(lint_state.position_codec, lint_state.lines) 

579 formatter = effective_preference.deb822_formatter() 

580 lines = lint_state.lines 

581 deb822_file = lint_state.parsed_deb822_file_content 

582 if deb822_file is None: 

583 _warn("The deb822 result missing failed!?") 

584 return None 

585 

586 return list( 

587 file_metadata.reformat( 

588 effective_preference, 

589 deb822_file, 

590 formatter, 

591 lint_state.content, 

592 lint_state.position_codec, 

593 lines, 

594 ) 

595 ) 

596 

597 

598def deb822_semantic_tokens_full( 

599 ls: "DebputyLanguageServer", 

600 request: SemanticTokensParams, 

601 file_metadata: Deb822FileMetadata[Any, Any], 

602) -> Optional[SemanticTokens]: 

603 doc = ls.workspace.get_text_document(request.text_document.uri) 

604 position_codec = doc.position_codec 

605 lines = doc.lines 

606 deb822_file = ls.lint_state(doc).parsed_deb822_file_content 

607 if deb822_file is None: 607 ↛ 608line 607 didn't jump to line 608 because the condition on line 607 was never true

608 _warn("The deb822 result missing failed!?") 

609 ls.show_message_log( 

610 "Internal error; could not get deb822 content!?", MessageType.Warning 

611 ) 

612 return None 

613 

614 tokens: List[int] = [] 

615 comment_token_code = SEMANTIC_TOKEN_TYPES_IDS[SemanticTokenTypes.Comment.value] 

616 sem_token_state = Deb822SemanticTokensState( 

617 ls, 

618 doc, 

619 lines, 

620 tokens, 

621 file_metadata, 

622 SEMANTIC_TOKEN_TYPES_IDS[SemanticTokenTypes.Keyword], 

623 SEMANTIC_TOKEN_TYPES_IDS[SemanticTokenTypes.EnumMember], 

624 comment_token_code, 

625 ) 

626 

627 stanza_idx = 0 

628 

629 for part in deb822_file.iter_parts(): 

630 if part.is_comment: 

631 pos = part.position_in_file() 

632 sem_token_state.emit_token( 

633 te_position_to_lsp(pos), 

634 # Avoid trailing newline 

635 position_codec.client_num_units(part.convert_to_text().rstrip()), 

636 comment_token_code, 

637 ) 

638 elif isinstance(part, Deb822ParagraphElement): 

639 _deb822_paragraph_semantic_tokens_full( 

640 sem_token_state, 

641 part, 

642 stanza_idx, 

643 ) 

644 stanza_idx += 1 

645 if not tokens: 645 ↛ 646line 645 didn't jump to line 646 because the condition on line 645 was never true

646 return None 

647 return SemanticTokens(tokens) 

648 

649 

650def _complete_field_name( 

651 lint_state: LintState, 

652 stanza_metadata: StanzaMetadata[Any], 

653 matched_stanzas: Iterable[Deb822ParagraphElement], 

654 markdown_kind: MarkupKind, 

655) -> Sequence[CompletionItem]: 

656 items = [] 

657 matched_stanzas = list(matched_stanzas) 

658 seen_fields = set( 

659 stanza_metadata.normalize_field_name(f.lower()) 

660 for f in chain.from_iterable( 

661 # The typing from python3-debian is not entirely optimal here. The iter always return a 

662 # `str`, but the provided type is `ParagraphKey` (because `__getitem__` supports those) 

663 # and that is not exclusively a `str`. 

664 # 

665 # So, this cast for now 

666 cast("Iterable[str]", s) 

667 for s in matched_stanzas 

668 ) 

669 ) 

670 for cand_key, cand in stanza_metadata.items(): 

671 if stanza_metadata.normalize_field_name(cand_key.lower()) in seen_fields: 

672 continue 

673 item = cand.complete_field(lint_state, matched_stanzas, markdown_kind) 

674 if item is not None: 

675 items.append(item) 

676 return items