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

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) 

14 

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) 

31 

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 

47 

48try: 

49 from pygls.server import LanguageServer 

50 from pygls.workspace import TextDocument 

51except ImportError: 

52 pass 

53 

54 

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

56 

57 

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 

73 

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] 

78 

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) 

101 

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 

107 

108 

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 

116 

117 current_field, _, in_value, paragraph_no, seen_fields = _at_cursor( 

118 doc, 

119 lines, 

120 params.position, 

121 ) 

122 

123 stanza_metadata = file_metadata.guess_stanza_classification_by_idx(paragraph_no) 

124 

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 ) 

139 

140 _info(f"Completion candidates: {items}") 

141 

142 return items 

143 

144 

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) 

156 

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) 

161 

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." 

175 

176 try: 

177 supported_formats = ls.client_capabilities.text_document.hover.content_format 

178 except AttributeError: 

179 supported_formats = [] 

180 

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 ) 

191 

192 

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 

198 

199 for token in tokens: 

200 start_line = line_no 

201 start_line_offset = line_offset 

202 

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 

214 

215 yield token, start_line, start_line_offset, line_no, line_offset 

216 

217 

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 ) 

244 

245 folding_ranges.append(folding_range) 

246 

247 return folding_ranges 

248 

249 

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 

267 

268 # TODO: Add comment support; slightly complicated by how we parse the file. 

269 

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) 

287 

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 

297 

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 ) 

306 

307 last_token_start_column = 0 

308 

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 

323 

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 

327 

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) 

333 

334 if not tokens: 

335 return None 

336 return SemanticTokens(tokens) 

337 

338 

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 ) 

348 

349 

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) 

366 

367 items.append( 

368 CompletionItem( 

369 name, 

370 insert_text=complete_as, 

371 tags=tags, 

372 ) 

373 ) 

374 return items 

375 

376 

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]