Coverage for src/debputy/lsp/lsp_generic_deb822.py: 46%
203 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
1import re
2from typing import (
3 Optional,
4 Union,
5 Sequence,
6 Tuple,
7 Set,
8 Any,
9 Container,
10 List,
11 Iterable,
12 Iterator,
13)
15from lsprotocol.types import (
16 CompletionParams,
17 CompletionList,
18 CompletionItem,
19 Position,
20 CompletionItemTag,
21 MarkupContent,
22 Hover,
23 MarkupKind,
24 HoverParams,
25 FoldingRangeParams,
26 FoldingRange,
27 FoldingRangeKind,
28 SemanticTokensParams,
29 SemanticTokens,
30)
32from debputy.lsp.lsp_debian_control_reference_data import (
33 Deb822FileMetadata,
34 Deb822KnownField,
35 StanzaMetadata,
36 FieldValueClass,
37)
38from debputy.lsp.lsp_features import SEMANTIC_TOKEN_TYPES_IDS
39from debputy.lsp.text_util import normalize_dctrl_field_name
40from debputy.lsp.vendoring._deb822_repro import parse_deb822_file
41from debputy.lsp.vendoring._deb822_repro.parsing import (
42 Deb822KeyValuePairElement,
43 LIST_SPACE_SEPARATED_INTERPRETATION,
44)
45from debputy.lsp.vendoring._deb822_repro.tokens import tokenize_deb822_file, Deb822Token
46from debputy.util import _info
48try:
49 from pygls.server import LanguageServer
50 from pygls.workspace import TextDocument
51except ImportError:
52 pass
55_CONTAINS_SPACE_OR_COLON = re.compile(r"[\s:]")
58def _at_cursor(
59 doc: "TextDocument",
60 lines: List[str],
61 client_position: Position,
62) -> Tuple[Optional[str], str, bool, int, Set[str]]:
63 paragraph_no = -1
64 paragraph_started = False
65 seen_fields = set()
66 last_field_seen: Optional[str] = None
67 current_field: Optional[str] = None
68 server_position = doc.position_codec.position_from_client_units(
69 lines,
70 client_position,
71 )
72 position_line_no = server_position.line
74 line_at_position = lines[position_line_no]
75 line_start = ""
76 if server_position.character:
77 line_start = line_at_position[0 : server_position.character]
79 for line_no, line in enumerate(lines):
80 if not line or line.isspace():
81 if line_no == position_line_no:
82 current_field = last_field_seen
83 continue
84 last_field_seen = None
85 if line_no > position_line_no: 85 ↛ 86line 85 didn't jump to line 86, because the condition on line 85 was never true
86 break
87 paragraph_started = False
88 elif line and line[0] == "#": 88 ↛ 89line 88 didn't jump to line 89, because the condition on line 88 was never true
89 continue
90 elif line and not line[0].isspace() and ":" in line: 90 ↛ 79line 90 didn't jump to line 79, because the condition on line 90 was never false
91 if not paragraph_started:
92 paragraph_started = True
93 seen_fields = set()
94 paragraph_no += 1
95 key, _ = line.split(":", 1)
96 key_lc = key.lower()
97 last_field_seen = key_lc
98 if line_no == position_line_no:
99 current_field = key_lc
100 seen_fields.add(key_lc)
102 in_value = bool(_CONTAINS_SPACE_OR_COLON.search(line_start))
103 current_word = doc.word_at_position(client_position)
104 if current_field is not None: 104 ↛ 106line 104 didn't jump to line 106, because the condition on line 104 was never false
105 current_field = normalize_dctrl_field_name(current_field)
106 return current_field, current_word, in_value, paragraph_no, seen_fields
109def deb822_completer(
110 ls: "LanguageServer",
111 params: CompletionParams,
112 file_metadata: Deb822FileMetadata[Any],
113) -> Optional[Union[CompletionList, Sequence[CompletionItem]]]:
114 doc = ls.workspace.get_text_document(params.text_document.uri)
115 lines = doc.lines
117 current_field, _, in_value, paragraph_no, seen_fields = _at_cursor(
118 doc,
119 lines,
120 params.position,
121 )
123 stanza_metadata = file_metadata.guess_stanza_classification_by_idx(paragraph_no)
125 if in_value: 125 ↛ 126line 125 didn't jump to line 126, because the condition on line 125 was never true
126 _info(f"Completion for field value {current_field}")
127 if current_field is None:
128 return None
129 known_field = stanza_metadata.get(current_field)
130 if known_field is None:
131 return None
132 items = _complete_field_value(known_field)
133 else:
134 _info("Completing field name")
135 items = _complete_field_name(
136 stanza_metadata,
137 seen_fields,
138 )
140 _info(f"Completion candidates: {items}")
142 return items
145def deb822_hover(
146 ls: "LanguageServer",
147 params: HoverParams,
148 file_metadata: Deb822FileMetadata[Any],
149) -> Optional[Hover]:
150 doc = ls.workspace.get_text_document(params.text_document.uri)
151 lines = doc.lines
152 current_field, word_at_position, in_value, paragraph_no, _ = _at_cursor(
153 doc, lines, params.position
154 )
155 stanza_metadata = file_metadata.guess_stanza_classification_by_idx(paragraph_no)
157 if current_field is None: 157 ↛ 158line 157 didn't jump to line 158, because the condition on line 157 was never true
158 _info("No hover information as we cannot determine which field it is for")
159 return None
160 known_field = stanza_metadata.get(current_field)
162 if known_field is None: 162 ↛ 163line 162 didn't jump to line 163, because the condition on line 162 was never true
163 return None
164 if in_value: 164 ↛ 165line 164 didn't jump to line 165, because the condition on line 164 was never true
165 if not known_field.known_values:
166 return
167 keyword = known_field.known_values.get(word_at_position)
168 if keyword is None:
169 return
170 hover_text = keyword.hover_text
171 else:
172 hover_text = known_field.hover_text
173 if hover_text is None: 173 ↛ 174line 173 didn't jump to line 174, because the condition on line 173 was never true
174 hover_text = f"The field {current_field} had no documentation."
176 try:
177 supported_formats = ls.client_capabilities.text_document.hover.content_format
178 except AttributeError:
179 supported_formats = []
181 _info(f"Supported formats {supported_formats}")
182 markup_kind = MarkupKind.Markdown
183 if markup_kind not in supported_formats: 183 ↛ 185line 183 didn't jump to line 185, because the condition on line 183 was never false
184 markup_kind = MarkupKind.PlainText
185 return Hover(
186 contents=MarkupContent(
187 kind=markup_kind,
188 value=hover_text,
189 )
190 )
193def _deb822_token_iter(
194 tokens: Iterable[Deb822Token],
195) -> Iterator[Tuple[Deb822Token, int, int, int, int, int]]:
196 line_no = 0
197 line_offset = 0
199 for token in tokens:
200 start_line = line_no
201 start_line_offset = line_offset
203 newlines = token.text.count("\n")
204 line_no += newlines
205 text_len = len(token.text)
206 if newlines:
207 if token.text.endswith("\n"):
208 line_offset = 0
209 else:
210 # -2, one to remove the "\n" and one to get 0-offset
211 line_offset = text_len - token.text.rindex("\n") - 2
212 else:
213 line_offset += text_len
215 yield token, start_line, start_line_offset, line_no, line_offset
218def deb822_folding_ranges(
219 ls: "LanguageServer",
220 params: FoldingRangeParams,
221 # Unused for now: might be relevant for supporting folding for some fields
222 _file_metadata: Deb822FileMetadata[Any],
223) -> Optional[Sequence[FoldingRange]]:
224 doc = ls.workspace.get_text_document(params.text_document.uri)
225 comment_start = -1
226 folding_ranges = []
227 for (
228 token,
229 start_line,
230 start_offset,
231 end_line,
232 end_offset,
233 ) in _deb822_token_iter(tokenize_deb822_file(doc.lines)):
234 if token.is_comment:
235 if comment_start < 0:
236 comment_start = start_line
237 elif comment_start > -1:
238 comment_start = -1
239 folding_range = FoldingRange(
240 comment_start,
241 end_line,
242 kind=FoldingRangeKind.Comment,
243 )
245 folding_ranges.append(folding_range)
247 return folding_ranges
250def deb822_semantic_tokens_full(
251 ls: "LanguageServer",
252 request: SemanticTokensParams,
253 file_metadata: Deb822FileMetadata[Any],
254) -> Optional[SemanticTokens]:
255 doc = ls.workspace.get_text_document(request.text_document.uri)
256 lines = doc.lines
257 deb822_file = parse_deb822_file(
258 lines,
259 accept_files_with_duplicated_fields=True,
260 accept_files_with_error_tokens=True,
261 )
262 tokens = []
263 previous_line = 0
264 keyword_token_code = SEMANTIC_TOKEN_TYPES_IDS["keyword"]
265 known_value_token_code = SEMANTIC_TOKEN_TYPES_IDS["enumMember"]
266 no_modifiers = 0
268 # TODO: Add comment support; slightly complicated by how we parse the file.
270 for stanza_idx, stanza in enumerate(deb822_file):
271 stanza_position = stanza.position_in_file()
272 stanza_metadata = file_metadata.classify_stanza(stanza, stanza_idx=stanza_idx)
273 for kvpair in stanza.iter_parts_of_type(Deb822KeyValuePairElement):
274 kvpair_pos = kvpair.position_in_parent().relative_to(stanza_position)
275 # These two happen to be the same; the indirection is to make it explicit that the two
276 # positions for different tokens are the same.
277 field_position_without_comments = kvpair_pos
278 field_size = doc.position_codec.client_num_units(kvpair.field_name)
279 current_line = field_position_without_comments.line_position
280 line_delta = current_line - previous_line
281 previous_line = current_line
282 tokens.append(line_delta) # Line delta
283 tokens.append(0) # Token column delta
284 tokens.append(field_size) # Token length
285 tokens.append(keyword_token_code)
286 tokens.append(no_modifiers)
288 known_field: Optional[Deb822KnownField] = stanza_metadata.get(
289 kvpair.field_name
290 )
291 if (
292 known_field is None
293 or not known_field.known_values
294 or known_field.spellcheck_value
295 ):
296 continue
298 if known_field.field_value_class not in (
299 FieldValueClass.SINGLE_VALUE,
300 FieldValueClass.SPACE_SEPARATED_LIST,
301 ):
302 continue
303 value_element_pos = kvpair.value_element.position_in_parent().relative_to(
304 kvpair_pos
305 )
307 last_token_start_column = 0
309 for value_ref in kvpair.interpret_as(
310 LIST_SPACE_SEPARATED_INTERPRETATION
311 ).iter_value_references():
312 if value_ref.value not in known_field.known_values:
313 continue
314 value_loc = value_ref.locatable
315 value_range_te = value_loc.range_in_parent().relative_to(
316 value_element_pos
317 )
318 start_line = value_range_te.start_pos.line_position
319 line_delta = start_line - current_line
320 current_line = start_line
321 if line_delta:
322 last_token_start_column = 0
324 value_start_column = value_range_te.start_pos.cursor_position
325 column_delta = value_start_column - last_token_start_column
326 last_token_start_column = value_start_column
328 tokens.append(line_delta) # Line delta
329 tokens.append(column_delta) # Token column delta
330 tokens.append(field_size) # Token length
331 tokens.append(known_value_token_code)
332 tokens.append(no_modifiers)
334 if not tokens:
335 return None
336 return SemanticTokens(tokens)
339def _should_complete_field_with_value(cand: Deb822KnownField) -> bool:
340 return cand.known_values is not None and (
341 len(cand.known_values) == 1
342 or (
343 len(cand.known_values) == 2
344 and cand.warn_if_default
345 and cand.default_value is not None
346 )
347 )
350def _complete_field_name(
351 fields: StanzaMetadata[Any],
352 seen_fields: Container[str],
353) -> Optional[Union[CompletionList, Sequence[CompletionItem]]]:
354 items = []
355 for cand_key, cand in fields.items():
356 if cand_key.lower() in seen_fields:
357 continue
358 name = cand.name
359 complete_as = name + ": "
360 if _should_complete_field_with_value(cand):
361 value = next(iter(v for v in cand.known_values if v != cand.default_value)) 361 ↛ exitline 361 didn't finish the generator expression on line 361
362 complete_as += value
363 tags = []
364 if cand.replaced_by or cand.deprecated_with_no_replacement:
365 tags.append(CompletionItemTag.Deprecated)
367 items.append(
368 CompletionItem(
369 name,
370 insert_text=complete_as,
371 tags=tags,
372 )
373 )
374 return items
377def _complete_field_value(
378 field: Deb822KnownField,
379) -> Optional[Union[CompletionList, Sequence[CompletionItem]]]:
380 if field.known_values is None:
381 return None
382 return [CompletionItem(v) for v in field.known_values]