Coverage for vibefpmgui/editor_language.py: 65%

266 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-11-01 20:51 +0000

1"""An editor tab's language-server features: hover, definitions, completion, references, rename, the outline. 

2 

3A part of `vibefpmgui.editor.EditorTab`.""" 

4 

5from __future__ import annotations 

6 

7import re 

8 

9from qtpy.QtCore import Qt 

10from qtpy.QtGui import QPalette, QTextCursor 

11from qtpy.QtWidgets import QInputDialog, QLineEdit, QMessageBox 

12 

13from . import hover_card 

14from . import lsp as lsp_mod 

15from .completion import global_rect 

16from .editor_shared import ( 

17 AUTO_COMPLETE_MIN, 

18 DEPTH_ROLE, 

19 WORD_RE, 

20 word_bounds, 

21 word_start, 

22) 

23from .icons import symbol_icon 

24 

25 

26class EditorLanguageMixin: 

27 """The tab's language-server features (a part of `vibefpmgui.editor.EditorTab`).""" 

28 

29 def attach_lsp(self, client: lsp_mod.LspClient | None): 

30 """Use a language server for this tab (Fortran files only; None detaches).""" 

31 if client is self.lsp: 

32 return 

33 self.detach_lsp() 

34 if client is None or not client.is_running() or not lsp_mod.is_fortran(self.path): 

35 return 

36 self.lsp = client 

37 client.open_document(self.path, self.editor.toPlainText()) 

38 self.editor.hover_provider = self._request_hover 

39 self.outline_bar.show() 

40 self.refresh_outline() 

41 

42 def detach_lsp(self): 

43 self._lsp_timer.stop() 

44 self._complete_timer.stop() 

45 self._signature_timer.stop() 

46 self.editor.hide_popups() 

47 if self.lsp is not None: 

48 self.lsp.close_document(self.path) 

49 self.lsp = None 

50 self.editor.hover_provider = None 

51 self._outline = [] 

52 self.outline_box.clear() 

53 self.outline_bar.hide() 

54 

55 def _lsp_sync(self): 

56 """Send the current text (once typing pauses), then refresh the outline.""" 

57 self._lsp_timer.stop() 

58 if self.lsp is not None: 58 ↛ exitline 58 didn't return from function '_lsp_sync' because the condition on line 58 was always true

59 self.lsp.change_document(self.path, self.editor.toPlainText()) 

60 self.refresh_outline() 

61 

62 def _flush_lsp(self): 

63 """Before a question: make sure the server has the latest text.""" 

64 if self._lsp_timer.isActive(): 

65 self._lsp_sync() 

66 

67 def _request_hover(self, line: int, column: int, global_pos, lint_html: str): 

68 if self.lsp is None: 68 ↛ 69line 68 didn't jump to line 69 because the condition on line 68 was never true

69 return 

70 self._flush_lsp() 

71 

72 text = self.editor.document().findBlockByNumber(line - 1).text() 

73 name = re.match(r"\w*", text[column:]).group(0) 

74 

75 def answered(result): 

76 hover_card.set_text_color(self.editor.palette().color(QPalette.Text)) 

77 info = lsp_mod.hover_info(result.get("contents")) if isinstance(result, dict) else None 

78 if info is not None: 78 ↛ exitline 78 didn't return from function 'answered' because the condition on line 78 was always true

79 self.editor.show_hover(line, column, global_pos, hover_card.card_html(info, name, lint_html)) 

80 

81 self.lsp.hover(self.path, line, column, answered) 

82 

83 def go_to_definition(self): 

84 """Ask where the name at the cursor is defined, and go there.""" 

85 if self.lsp is not None: 85 ↛ exitline 85 didn't return from function 'go_to_definition' because the condition on line 85 was always true

86 self._go_to(self.lsp.definition, "definition") 

87 

88 def go_to_implementation(self): 

89 """For a type-bound procedure: the procedure it binds (Go to 

90 Definition stops at the binding); for a generic interface: its first 

91 module procedure.""" 

92 if self.lsp is not None: 92 ↛ exitline 92 didn't return from function 'go_to_implementation' because the condition on line 92 was always true

93 self._go_to(self.lsp.implementation, "implementation", 

94 " (only type-bound procedures and generic interfaces have one)") 

95 

96 def _go_to(self, request, what: str, why_none: str = ""): 

97 """Ask the language server (``request``: e.g. its definition) about 

98 the name at the cursor, and go to the place it answers.""" 

99 self._flush_lsp() 

100 word = self._name_at_cursor() 

101 if word is not None: 101 ↛ 104line 101 didn't jump to line 104 because the condition on line 101 was always true

102 line, column = word[0], word[1] 

103 else: 

104 cursor = self.editor.textCursor() 

105 line, column = cursor.blockNumber() + 1, cursor.positionInBlock() 

106 

107 def answered(result): 

108 found = lsp_mod.first_location(result) 

109 if found is None: 

110 name = f"'{word[2]}'" if word else "the name at the cursor" 

111 self.update_status(f"no {what} found for {name}{why_none}") 

112 return 

113 path, to_line, to_column = found 

114 self.navigate_requested.emit(str(path), to_line, to_column) 

115 

116 request(self.path, line, column, answered) 

117 

118 def refresh_outline(self): 

119 if self.lsp is None: 119 ↛ 120line 119 didn't jump to line 120 because the condition on line 119 was never true

120 return 

121 self.lsp.document_symbols(self.path, self._show_outline) 

122 

123 def _show_outline(self, result): 

124 if self.lsp is None: 124 ↛ 125line 124 didn't jump to line 125 because the condition on line 124 was never true

125 return 

126 document = self.editor.document() 

127 self._outline = lsp_mod.outline(result, lambda n: document.findBlockByNumber(n - 1).text()) 

128 self.outline_box.blockSignals(True) 

129 self.outline_box.clear() 

130 # Each entry: a badge for its kind, then its name; the list indents 

131 # nested entries (see _OutlineDelegate). 

132 for depth, kind, name, first, _last in self._outline: 

133 self.outline_box.addItem(symbol_icon(kind), name) 

134 row = self.outline_box.count() - 1 

135 self.outline_box.setItemData(row, f"{kind} {name} (line {first})", Qt.ToolTipRole) 

136 self.outline_box.setItemData(row, depth, DEPTH_ROLE) 

137 self.outline_box.setPlaceholderText(f"Outline ({len(self._outline)})" if self._outline 

138 else "Outline (no procedures)") 

139 self.outline_box.blockSignals(False) 

140 self._outline_follow_cursor() 

141 

142 def _outline_chosen(self, index: int): 

143 if 0 <= index < len(self._outline): 

144 self.editor.go_to_line(self._outline[index][3]) 

145 

146 def _outline_follow_cursor(self): 

147 """Show the innermost module/procedure the cursor is in.""" 

148 if not self._outline: 

149 return 

150 line = self.editor.textCursor().blockNumber() + 1 

151 best = -1 

152 for i, (_depth, _kind, _name, first, last) in enumerate(self._outline): 

153 if first <= line <= last: 

154 best = i # rows are in file order, so later = deeper 

155 if best != self.outline_box.currentIndex(): 

156 self.outline_box.blockSignals(True) 

157 self.outline_box.setCurrentIndex(best) 

158 self.outline_box.blockSignals(False) 

159 

160 def _typed(self, text: str): 

161 """After a key changed the text: offer completions or argument hints.""" 

162 if self.lsp is None or not text: 162 ↛ 164line 162 didn't jump to line 164 because the condition on line 162 was always true

163 return 

164 ch = text[-1] 

165 if WORD_RE.match(ch): 

166 if not self.editor.completion_popup.isVisible(): 

167 self._complete_timer.start() # request_completion checks the length 

168 elif ch == "%": # a type's components 

169 self._complete_timer.start() 

170 elif ch in "(," or ch == ")" and self.editor.signature_popup.isVisible(): 

171 self._signature_timer.start() 

172 

173 def _name_start(self) -> tuple[int, int, str, str]: 

174 """(block number, column where the name before the cursor starts, 

175 that partial name, the line's text) for the cursor's position.""" 

176 cursor = self.editor.textCursor() 

177 text = cursor.block().text() 

178 column = cursor.positionInBlock() 

179 start = word_start(text, column) 

180 return cursor.blockNumber(), start, text[start:column], text 

181 

182 def request_completion(self, manual: bool = True): 

183 """Ask for completions of the name being typed (Show Completions 

184 when ``manual``, or by itself once a few letters are typed).""" 

185 if self.lsp is None: 185 ↛ 186line 185 didn't jump to line 186 because the condition on line 185 was never true

186 return 

187 block, start, prefix, text = self._name_start() 

188 column = self.editor.textCursor().positionInBlock() 

189 after_percent = start > 0 and text[start - 1] == "%" 

190 if not manual: 190 ↛ 191line 190 didn't jump to line 191 because the condition on line 190 was never true

191 if lsp_mod.in_comment_or_string(text, column) or prefix[:1].isdigit(): 

192 return 

193 if len(prefix) < AUTO_COMPLETE_MIN and not after_percent: 

194 return 

195 self._flush_lsp() 

196 

197 def answered(result): 

198 cursor = self.editor.textCursor() 

199 if cursor.blockNumber() != block or cursor.positionInBlock() < start: 199 ↛ 200line 199 didn't jump to line 200 because the condition on line 199 was never true

200 return # the cursor has moved on 

201 now = cursor.block().text()[start:cursor.positionInBlock()] 

202 if not all(WORD_RE.match(c) for c in now): 202 ↛ 203line 202 didn't jump to line 203 because the condition on line 202 was never true

203 return 

204 anchor_cursor = QTextCursor(cursor.block()) 

205 anchor_cursor.setPosition(cursor.block().position() + start) 

206 items = lsp_mod.completions(result) 

207 shown = self.editor.completion_popup.open(items, (block, start), now, 

208 global_rect(self.editor, anchor_cursor)) 

209 if manual and not shown: 209 ↛ 210line 209 didn't jump to line 210 because the condition on line 209 was never true

210 self.update_status("no completions here") 

211 

212 self.lsp.completion(self.path, block + 1, column, answered) 

213 

214 def accept_completion(self, item): 

215 popup = self.editor.completion_popup 

216 start = popup.start 

217 popup.hide() 

218 if item is None or start is None: 218 ↛ 219line 218 didn't jump to line 219 because the condition on line 218 was never true

219 return 

220 cursor = self.editor.textCursor() 

221 if cursor.blockNumber() != start[0]: 221 ↛ 222line 221 didn't jump to line 222 because the condition on line 221 was never true

222 return 

223 block = cursor.block() 

224 cursor.setPosition(block.position() + start[1]) 

225 cursor.setPosition(self.editor.textCursor().position(), QTextCursor.KeepAnchor) 

226 cursor.insertText(item.insert) 

227 self.editor.setTextCursor(cursor) 

228 

229 def request_signature(self): 

230 """Show (or update, or hide) the argument hints for the call the 

231 cursor is in.""" 

232 popup = self.editor.signature_popup 

233 if self.lsp is None: 

234 return 

235 cursor = self.editor.textCursor() 

236 block, column = cursor.blockNumber(), cursor.positionInBlock() 

237 before = cursor.block().text()[:column] 

238 index = lsp_mod.argument_index(before) 

239 if index is None or lsp_mod.in_comment_or_string(cursor.block().text(), column): 

240 popup.hide() 

241 return 

242 self._flush_lsp() 

243 

244 def answered(result): 

245 now = self.editor.textCursor() 

246 if now.blockNumber() != block: 

247 return 

248 text = lsp_mod.signature_html(result, index) 

249 if not text: 

250 popup.hide() 

251 return 

252 line_start = QTextCursor(now.block()) 

253 rect = global_rect(self.editor, now) 

254 rect.moveLeft(global_rect(self.editor, line_start).left() + self.editor.fontMetrics() 

255 .horizontalAdvance(" ") * (len(before) - len(before.lstrip()))) 

256 popup.open(text, block, rect) 

257 

258 self.lsp.signature_help(self.path, block + 1, column, answered) 

259 

260 def _cursor_moved_popups(self): 

261 """Keep the completion list filtered to what's typed (or close it), 

262 and the argument hints up to date.""" 

263 cursor = self.editor.textCursor() 

264 block, column = cursor.blockNumber(), cursor.positionInBlock() 

265 popup = self.editor.completion_popup 

266 if popup.isVisible(): 266 ↛ 267line 266 didn't jump to line 267 because the condition on line 266 was never true

267 start = popup.start 

268 typed = cursor.block().text()[start[1]:column] if start and start[0] == block else None 

269 if typed is None or column < start[1] or not all(WORD_RE.match(c) for c in typed): 

270 popup.hide() 

271 else: 

272 popup.filter(typed) 

273 hint = self.editor.signature_popup 

274 if hint.isVisible(): 274 ↛ 275line 274 didn't jump to line 275 because the condition on line 274 was never true

275 if hint.line != block: 

276 hint.hide() 

277 else: 

278 self._signature_timer.start() 

279 

280 def _name_at_cursor(self) -> tuple[int, int, str] | None: 

281 """(1-based line, start column, name) of the name at the cursor, or 

282 of the selected text if that is a single name.""" 

283 cursor = self.editor.textCursor() 

284 if cursor.hasSelection(): 284 ↛ 285line 284 didn't jump to line 285 because the condition on line 284 was never true

285 selected = cursor.selectedText() 

286 if re.fullmatch(r"[A-Za-z_]\w*", selected): 

287 start = QTextCursor(self.editor.document()) 

288 start.setPosition(cursor.selectionStart()) 

289 return start.blockNumber() + 1, start.positionInBlock(), selected 

290 text = cursor.block().text() 

291 bounds = word_bounds(text, cursor.positionInBlock()) 

292 if bounds is None: 292 ↛ 293line 292 didn't jump to line 293 because the condition on line 292 was never true

293 return None 

294 start, end = bounds 

295 return cursor.blockNumber() + 1, start, text[start:end] 

296 

297 def find_references(self): 

298 if self.lsp is None: 298 ↛ 299line 298 didn't jump to line 299 because the condition on line 298 was never true

299 return 

300 found = self._name_at_cursor() 

301 if found is None: 301 ↛ 302line 301 didn't jump to line 302 because the condition on line 301 was never true

302 self.update_status("put the cursor on a name to find its references") 

303 return 

304 line, column, name = found 

305 self._flush_lsp() 

306 self.lsp.references(self.path, line, column, 

307 lambda result: self.references_found.emit(name, lsp_mod.locations(result))) 

308 

309 def rename_symbol(self): 

310 if self.lsp is None: 310 ↛ 311line 310 didn't jump to line 311 because the condition on line 310 was never true

311 return 

312 found = self._name_at_cursor() 

313 if found is None: 313 ↛ 314line 313 didn't jump to line 314 because the condition on line 313 was never true

314 self.update_status("put the cursor on a name to rename it") 

315 return 

316 line, column, name = found 

317 new, ok = QInputDialog.getText(self, "Rename Symbol", f"Rename '{name}' everywhere it is used, to:", 

318 QLineEdit.Normal, name) 

319 new = new.strip() 

320 if not ok or not new or new == name: 320 ↛ 321line 320 didn't jump to line 321 because the condition on line 320 was never true

321 return 

322 if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_]*", new): 322 ↛ 323line 322 didn't jump to line 323 because the condition on line 322 was never true

323 QMessageBox.warning(self, "Rename Symbol", f"'{new}' isn't a valid Fortran name.") 

324 return 

325 self._flush_lsp() 

326 

327 def answered(result): 

328 edits = lsp_mod.workspace_edits(result) 

329 if not edits: 329 ↛ 330line 329 didn't jump to line 330 because the condition on line 329 was never true

330 self.update_status(f"'{name}' can't be renamed (fortls found nothing to change)") 

331 return 

332 self.rename_ready.emit(name, new, edits) 

333 

334 self.lsp.rename(self.path, line, column, new, answered) 

335 

336 def apply_text_edits(self, edits): 

337 """Apply language-server edits to the text as one Undo step.""" 

338 document = self.editor.document() 

339 cursor = QTextCursor(document) 

340 cursor.beginEditBlock() 

341 for e in sorted(edits, key=lambda e: (e.line, e.column), reverse=True): 

342 start, end = document.findBlockByNumber(e.line), document.findBlockByNumber(e.end_line) 

343 if not start.isValid() or not end.isValid(): 343 ↛ 344line 343 didn't jump to line 344 because the condition on line 343 was never true

344 continue 

345 cursor.setPosition(start.position() + min(e.column, start.length() - 1)) 

346 cursor.setPosition(end.position() + min(e.end_column, end.length() - 1), QTextCursor.KeepAnchor) 

347 cursor.insertText(e.new_text) 

348 cursor.endEditBlock()