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
« 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)
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)
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
63try:
64 from pygls.server import LanguageServer
65 from pygls.workspace import TextDocument
66except ImportError:
67 pass
70_CONTAINS_SPACE_OR_COLON = re.compile(r"[\s:]")
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
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
93 if cursor_line == end_pos.line_position:
94 return cursor_position.character < end_pos.cursor_position
96 return (
97 cursor_line > start_pos.line_position
98 or start_pos.cursor_position <= cursor_position.character
99 )
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
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 )
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
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
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
221 stanza_parts = (p for p in (previous_stanza, next_stanza) if p is not None)
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)
228 return (
229 server_position,
230 matched_field,
231 current_word,
232 in_value,
233 stanza_metadata,
234 known_field,
235 stanza_parts,
236 )
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
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 )
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 )
297 _info(
298 f"Completion candidates: {[i.label for i in items] if items is not None else 'None'}"
299 )
301 return items
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
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
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
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}"
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 )
404def deb822_token_iter(
405 tokens: Iterable[Deb822Token],
406) -> Iterator[Tuple[Deb822Token, int, int, int, int]]:
407 line_no = 0
408 line_offset = 0
410 for token in tokens:
411 start_line = line_no
412 start_line_offset = line_offset
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
426 yield token, start_line, start_line_offset, line_no, line_offset
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 )
456 folding_ranges.append(folding_range)
458 return folding_ranges
461class Deb822SemanticTokensState(SemanticTokensState):
463 __slots__ = (
464 "file_metadata",
465 "keyword_token_code",
466 "known_value_token_code",
467 "comment_token_code",
468 )
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
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
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)
524 sem_token_state.emit_token(
525 te_position_to_lsp(field_start),
526 field_size,
527 keyword_token_code,
528 )
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
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)
565 sem_token_state.emit_token(
566 te_position_to_lsp(value_range_te.start_pos),
567 value_len,
568 token_type,
569 )
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
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 )
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
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 )
627 stanza_idx = 0
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)
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