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
« 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.
3A part of `vibefpmgui.editor.EditorTab`."""
5from __future__ import annotations
7import re
9from qtpy.QtCore import Qt
10from qtpy.QtGui import QPalette, QTextCursor
11from qtpy.QtWidgets import QInputDialog, QLineEdit, QMessageBox
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
26class EditorLanguageMixin:
27 """The tab's language-server features (a part of `vibefpmgui.editor.EditorTab`)."""
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()
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()
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()
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()
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()
72 text = self.editor.document().findBlockByNumber(line - 1).text()
73 name = re.match(r"\w*", text[column:]).group(0)
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))
81 self.lsp.hover(self.path, line, column, answered)
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")
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)")
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()
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)
116 request(self.path, line, column, answered)
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)
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()
142 def _outline_chosen(self, index: int):
143 if 0 <= index < len(self._outline):
144 self.editor.go_to_line(self._outline[index][3])
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)
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()
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
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()
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")
212 self.lsp.completion(self.path, block + 1, column, answered)
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)
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()
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)
258 self.lsp.signature_help(self.path, block + 1, column, answered)
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()
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]
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)))
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()
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)
334 self.lsp.rename(self.path, line, column, new, answered)
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()