vibefpmgui.editor_language

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

A part of vibefpmgui.editor.EditorTab.

  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:
 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:
 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:
 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:
 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:
 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:
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:
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:
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:
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:
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:
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:
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):
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:
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:
219            return
220        cursor = self.editor.textCursor()
221        if cursor.blockNumber() != start[0]:
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():
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():
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():
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:
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:
299            return
300        found = self._name_at_cursor()
301        if found is None:
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:
311            return
312        found = self._name_at_cursor()
313        if found is None:
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:
321            return
322        if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_]*", new):
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:
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():
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()
class EditorLanguageMixin:
 27class EditorLanguageMixin:
 28    """The tab's language-server features (a part of `vibefpmgui.editor.EditorTab`)."""
 29
 30    def attach_lsp(self, client: lsp_mod.LspClient | None):
 31        """Use a language server for this tab (Fortran files only; None detaches)."""
 32        if client is self.lsp:
 33            return
 34        self.detach_lsp()
 35        if client is None or not client.is_running() or not lsp_mod.is_fortran(self.path):
 36            return
 37        self.lsp = client
 38        client.open_document(self.path, self.editor.toPlainText())
 39        self.editor.hover_provider = self._request_hover
 40        self.outline_bar.show()
 41        self.refresh_outline()
 42
 43    def detach_lsp(self):
 44        self._lsp_timer.stop()
 45        self._complete_timer.stop()
 46        self._signature_timer.stop()
 47        self.editor.hide_popups()
 48        if self.lsp is not None:
 49            self.lsp.close_document(self.path)
 50        self.lsp = None
 51        self.editor.hover_provider = None
 52        self._outline = []
 53        self.outline_box.clear()
 54        self.outline_bar.hide()
 55
 56    def _lsp_sync(self):
 57        """Send the current text (once typing pauses), then refresh the outline."""
 58        self._lsp_timer.stop()
 59        if self.lsp is not None:
 60            self.lsp.change_document(self.path, self.editor.toPlainText())
 61            self.refresh_outline()
 62
 63    def _flush_lsp(self):
 64        """Before a question: make sure the server has the latest text."""
 65        if self._lsp_timer.isActive():
 66            self._lsp_sync()
 67
 68    def _request_hover(self, line: int, column: int, global_pos, lint_html: str):
 69        if self.lsp is None:
 70            return
 71        self._flush_lsp()
 72
 73        text = self.editor.document().findBlockByNumber(line - 1).text()
 74        name = re.match(r"\w*", text[column:]).group(0)
 75
 76        def answered(result):
 77            hover_card.set_text_color(self.editor.palette().color(QPalette.Text))
 78            info = lsp_mod.hover_info(result.get("contents")) if isinstance(result, dict) else None
 79            if info is not None:
 80                self.editor.show_hover(line, column, global_pos, hover_card.card_html(info, name, lint_html))
 81
 82        self.lsp.hover(self.path, line, column, answered)
 83
 84    def go_to_definition(self):
 85        """Ask where the name at the cursor is defined, and go there."""
 86        if self.lsp is not None:
 87            self._go_to(self.lsp.definition, "definition")
 88
 89    def go_to_implementation(self):
 90        """For a type-bound procedure: the procedure it binds (Go to
 91        Definition stops at the binding); for a generic interface: its first
 92        module procedure."""
 93        if self.lsp is not None:
 94            self._go_to(self.lsp.implementation, "implementation",
 95                        " (only type-bound procedures and generic interfaces have one)")
 96
 97    def _go_to(self, request, what: str, why_none: str = ""):
 98        """Ask the language server (``request``: e.g. its definition) about
 99        the name at the cursor, and go to the place it answers."""
100        self._flush_lsp()
101        word = self._name_at_cursor()
102        if word is not None:
103            line, column = word[0], word[1]
104        else:
105            cursor = self.editor.textCursor()
106            line, column = cursor.blockNumber() + 1, cursor.positionInBlock()
107
108        def answered(result):
109            found = lsp_mod.first_location(result)
110            if found is None:
111                name = f"'{word[2]}'" if word else "the name at the cursor"
112                self.update_status(f"no {what} found for {name}{why_none}")
113                return
114            path, to_line, to_column = found
115            self.navigate_requested.emit(str(path), to_line, to_column)
116
117        request(self.path, line, column, answered)
118
119    def refresh_outline(self):
120        if self.lsp is None:
121            return
122        self.lsp.document_symbols(self.path, self._show_outline)
123
124    def _show_outline(self, result):
125        if self.lsp is None:
126            return
127        document = self.editor.document()
128        self._outline = lsp_mod.outline(result, lambda n: document.findBlockByNumber(n - 1).text())
129        self.outline_box.blockSignals(True)
130        self.outline_box.clear()
131        # Each entry: a badge for its kind, then its name; the list indents
132        # nested entries (see _OutlineDelegate).
133        for depth, kind, name, first, _last in self._outline:
134            self.outline_box.addItem(symbol_icon(kind), name)
135            row = self.outline_box.count() - 1
136            self.outline_box.setItemData(row, f"{kind} {name} (line {first})", Qt.ToolTipRole)
137            self.outline_box.setItemData(row, depth, DEPTH_ROLE)
138        self.outline_box.setPlaceholderText(f"Outline ({len(self._outline)})" if self._outline
139                                            else "Outline (no procedures)")
140        self.outline_box.blockSignals(False)
141        self._outline_follow_cursor()
142
143    def _outline_chosen(self, index: int):
144        if 0 <= index < len(self._outline):
145            self.editor.go_to_line(self._outline[index][3])
146
147    def _outline_follow_cursor(self):
148        """Show the innermost module/procedure the cursor is in."""
149        if not self._outline:
150            return
151        line = self.editor.textCursor().blockNumber() + 1
152        best = -1
153        for i, (_depth, _kind, _name, first, last) in enumerate(self._outline):
154            if first <= line <= last:
155                best = i  # rows are in file order, so later = deeper
156        if best != self.outline_box.currentIndex():
157            self.outline_box.blockSignals(True)
158            self.outline_box.setCurrentIndex(best)
159            self.outline_box.blockSignals(False)
160
161    def _typed(self, text: str):
162        """After a key changed the text: offer completions or argument hints."""
163        if self.lsp is None or not text:
164            return
165        ch = text[-1]
166        if WORD_RE.match(ch):
167            if not self.editor.completion_popup.isVisible():
168                self._complete_timer.start()  # request_completion checks the length
169        elif ch == "%":  # a type's components
170            self._complete_timer.start()
171        elif ch in "(," or ch == ")" and self.editor.signature_popup.isVisible():
172            self._signature_timer.start()
173
174    def _name_start(self) -> tuple[int, int, str, str]:
175        """(block number, column where the name before the cursor starts,
176        that partial name, the line's text) for the cursor's position."""
177        cursor = self.editor.textCursor()
178        text = cursor.block().text()
179        column = cursor.positionInBlock()
180        start = word_start(text, column)
181        return cursor.blockNumber(), start, text[start:column], text
182
183    def request_completion(self, manual: bool = True):
184        """Ask for completions of the name being typed (Show Completions
185        when ``manual``, or by itself once a few letters are typed)."""
186        if self.lsp is None:
187            return
188        block, start, prefix, text = self._name_start()
189        column = self.editor.textCursor().positionInBlock()
190        after_percent = start > 0 and text[start - 1] == "%"
191        if not manual:
192            if lsp_mod.in_comment_or_string(text, column) or prefix[:1].isdigit():
193                return
194            if len(prefix) < AUTO_COMPLETE_MIN and not after_percent:
195                return
196        self._flush_lsp()
197
198        def answered(result):
199            cursor = self.editor.textCursor()
200            if cursor.blockNumber() != block or cursor.positionInBlock() < start:
201                return  # the cursor has moved on
202            now = cursor.block().text()[start:cursor.positionInBlock()]
203            if not all(WORD_RE.match(c) for c in now):
204                return
205            anchor_cursor = QTextCursor(cursor.block())
206            anchor_cursor.setPosition(cursor.block().position() + start)
207            items = lsp_mod.completions(result)
208            shown = self.editor.completion_popup.open(items, (block, start), now,
209                                                      global_rect(self.editor, anchor_cursor))
210            if manual and not shown:
211                self.update_status("no completions here")
212
213        self.lsp.completion(self.path, block + 1, column, answered)
214
215    def accept_completion(self, item):
216        popup = self.editor.completion_popup
217        start = popup.start
218        popup.hide()
219        if item is None or start is None:
220            return
221        cursor = self.editor.textCursor()
222        if cursor.blockNumber() != start[0]:
223            return
224        block = cursor.block()
225        cursor.setPosition(block.position() + start[1])
226        cursor.setPosition(self.editor.textCursor().position(), QTextCursor.KeepAnchor)
227        cursor.insertText(item.insert)
228        self.editor.setTextCursor(cursor)
229
230    def request_signature(self):
231        """Show (or update, or hide) the argument hints for the call the
232        cursor is in."""
233        popup = self.editor.signature_popup
234        if self.lsp is None:
235            return
236        cursor = self.editor.textCursor()
237        block, column = cursor.blockNumber(), cursor.positionInBlock()
238        before = cursor.block().text()[:column]
239        index = lsp_mod.argument_index(before)
240        if index is None or lsp_mod.in_comment_or_string(cursor.block().text(), column):
241            popup.hide()
242            return
243        self._flush_lsp()
244
245        def answered(result):
246            now = self.editor.textCursor()
247            if now.blockNumber() != block:
248                return
249            text = lsp_mod.signature_html(result, index)
250            if not text:
251                popup.hide()
252                return
253            line_start = QTextCursor(now.block())
254            rect = global_rect(self.editor, now)
255            rect.moveLeft(global_rect(self.editor, line_start).left() + self.editor.fontMetrics()
256                          .horizontalAdvance(" ") * (len(before) - len(before.lstrip())))
257            popup.open(text, block, rect)
258
259        self.lsp.signature_help(self.path, block + 1, column, answered)
260
261    def _cursor_moved_popups(self):
262        """Keep the completion list filtered to what's typed (or close it),
263        and the argument hints up to date."""
264        cursor = self.editor.textCursor()
265        block, column = cursor.blockNumber(), cursor.positionInBlock()
266        popup = self.editor.completion_popup
267        if popup.isVisible():
268            start = popup.start
269            typed = cursor.block().text()[start[1]:column] if start and start[0] == block else None
270            if typed is None or column < start[1] or not all(WORD_RE.match(c) for c in typed):
271                popup.hide()
272            else:
273                popup.filter(typed)
274        hint = self.editor.signature_popup
275        if hint.isVisible():
276            if hint.line != block:
277                hint.hide()
278            else:
279                self._signature_timer.start()
280
281    def _name_at_cursor(self) -> tuple[int, int, str] | None:
282        """(1-based line, start column, name) of the name at the cursor, or
283        of the selected text if that is a single name."""
284        cursor = self.editor.textCursor()
285        if cursor.hasSelection():
286            selected = cursor.selectedText()
287            if re.fullmatch(r"[A-Za-z_]\w*", selected):
288                start = QTextCursor(self.editor.document())
289                start.setPosition(cursor.selectionStart())
290                return start.blockNumber() + 1, start.positionInBlock(), selected
291        text = cursor.block().text()
292        bounds = word_bounds(text, cursor.positionInBlock())
293        if bounds is None:
294            return None
295        start, end = bounds
296        return cursor.blockNumber() + 1, start, text[start:end]
297
298    def find_references(self):
299        if self.lsp is None:
300            return
301        found = self._name_at_cursor()
302        if found is None:
303            self.update_status("put the cursor on a name to find its references")
304            return
305        line, column, name = found
306        self._flush_lsp()
307        self.lsp.references(self.path, line, column,
308                            lambda result: self.references_found.emit(name, lsp_mod.locations(result)))
309
310    def rename_symbol(self):
311        if self.lsp is None:
312            return
313        found = self._name_at_cursor()
314        if found is None:
315            self.update_status("put the cursor on a name to rename it")
316            return
317        line, column, name = found
318        new, ok = QInputDialog.getText(self, "Rename Symbol", f"Rename '{name}' everywhere it is used, to:",
319                                       QLineEdit.Normal, name)
320        new = new.strip()
321        if not ok or not new or new == name:
322            return
323        if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_]*", new):
324            QMessageBox.warning(self, "Rename Symbol", f"'{new}' isn't a valid Fortran name.")
325            return
326        self._flush_lsp()
327
328        def answered(result):
329            edits = lsp_mod.workspace_edits(result)
330            if not edits:
331                self.update_status(f"'{name}' can't be renamed (fortls found nothing to change)")
332                return
333            self.rename_ready.emit(name, new, edits)
334
335        self.lsp.rename(self.path, line, column, new, answered)
336
337    def apply_text_edits(self, edits):
338        """Apply language-server edits to the text as one Undo step."""
339        document = self.editor.document()
340        cursor = QTextCursor(document)
341        cursor.beginEditBlock()
342        for e in sorted(edits, key=lambda e: (e.line, e.column), reverse=True):
343            start, end = document.findBlockByNumber(e.line), document.findBlockByNumber(e.end_line)
344            if not start.isValid() or not end.isValid():
345                continue
346            cursor.setPosition(start.position() + min(e.column, start.length() - 1))
347            cursor.setPosition(end.position() + min(e.end_column, end.length() - 1), QTextCursor.KeepAnchor)
348            cursor.insertText(e.new_text)
349        cursor.endEditBlock()

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

def attach_lsp(self, client: vibefpmgui.lsp.LspClient | None):
30    def attach_lsp(self, client: lsp_mod.LspClient | None):
31        """Use a language server for this tab (Fortran files only; None detaches)."""
32        if client is self.lsp:
33            return
34        self.detach_lsp()
35        if client is None or not client.is_running() or not lsp_mod.is_fortran(self.path):
36            return
37        self.lsp = client
38        client.open_document(self.path, self.editor.toPlainText())
39        self.editor.hover_provider = self._request_hover
40        self.outline_bar.show()
41        self.refresh_outline()

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

def detach_lsp(self):
43    def detach_lsp(self):
44        self._lsp_timer.stop()
45        self._complete_timer.stop()
46        self._signature_timer.stop()
47        self.editor.hide_popups()
48        if self.lsp is not None:
49            self.lsp.close_document(self.path)
50        self.lsp = None
51        self.editor.hover_provider = None
52        self._outline = []
53        self.outline_box.clear()
54        self.outline_bar.hide()
def go_to_definition(self):
84    def go_to_definition(self):
85        """Ask where the name at the cursor is defined, and go there."""
86        if self.lsp is not None:
87            self._go_to(self.lsp.definition, "definition")

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

def go_to_implementation(self):
89    def go_to_implementation(self):
90        """For a type-bound procedure: the procedure it binds (Go to
91        Definition stops at the binding); for a generic interface: its first
92        module procedure."""
93        if self.lsp is not None:
94            self._go_to(self.lsp.implementation, "implementation",
95                        " (only type-bound procedures and generic interfaces have one)")

For a type-bound procedure: the procedure it binds (Go to Definition stops at the binding); for a generic interface: its first module procedure.

def refresh_outline(self):
119    def refresh_outline(self):
120        if self.lsp is None:
121            return
122        self.lsp.document_symbols(self.path, self._show_outline)
def request_completion(self, manual: bool = True):
183    def request_completion(self, manual: bool = True):
184        """Ask for completions of the name being typed (Show Completions
185        when ``manual``, or by itself once a few letters are typed)."""
186        if self.lsp is None:
187            return
188        block, start, prefix, text = self._name_start()
189        column = self.editor.textCursor().positionInBlock()
190        after_percent = start > 0 and text[start - 1] == "%"
191        if not manual:
192            if lsp_mod.in_comment_or_string(text, column) or prefix[:1].isdigit():
193                return
194            if len(prefix) < AUTO_COMPLETE_MIN and not after_percent:
195                return
196        self._flush_lsp()
197
198        def answered(result):
199            cursor = self.editor.textCursor()
200            if cursor.blockNumber() != block or cursor.positionInBlock() < start:
201                return  # the cursor has moved on
202            now = cursor.block().text()[start:cursor.positionInBlock()]
203            if not all(WORD_RE.match(c) for c in now):
204                return
205            anchor_cursor = QTextCursor(cursor.block())
206            anchor_cursor.setPosition(cursor.block().position() + start)
207            items = lsp_mod.completions(result)
208            shown = self.editor.completion_popup.open(items, (block, start), now,
209                                                      global_rect(self.editor, anchor_cursor))
210            if manual and not shown:
211                self.update_status("no completions here")
212
213        self.lsp.completion(self.path, block + 1, column, answered)

Ask for completions of the name being typed (Show Completions when manual, or by itself once a few letters are typed).

def accept_completion(self, item):
215    def accept_completion(self, item):
216        popup = self.editor.completion_popup
217        start = popup.start
218        popup.hide()
219        if item is None or start is None:
220            return
221        cursor = self.editor.textCursor()
222        if cursor.blockNumber() != start[0]:
223            return
224        block = cursor.block()
225        cursor.setPosition(block.position() + start[1])
226        cursor.setPosition(self.editor.textCursor().position(), QTextCursor.KeepAnchor)
227        cursor.insertText(item.insert)
228        self.editor.setTextCursor(cursor)
def request_signature(self):
230    def request_signature(self):
231        """Show (or update, or hide) the argument hints for the call the
232        cursor is in."""
233        popup = self.editor.signature_popup
234        if self.lsp is None:
235            return
236        cursor = self.editor.textCursor()
237        block, column = cursor.blockNumber(), cursor.positionInBlock()
238        before = cursor.block().text()[:column]
239        index = lsp_mod.argument_index(before)
240        if index is None or lsp_mod.in_comment_or_string(cursor.block().text(), column):
241            popup.hide()
242            return
243        self._flush_lsp()
244
245        def answered(result):
246            now = self.editor.textCursor()
247            if now.blockNumber() != block:
248                return
249            text = lsp_mod.signature_html(result, index)
250            if not text:
251                popup.hide()
252                return
253            line_start = QTextCursor(now.block())
254            rect = global_rect(self.editor, now)
255            rect.moveLeft(global_rect(self.editor, line_start).left() + self.editor.fontMetrics()
256                          .horizontalAdvance(" ") * (len(before) - len(before.lstrip())))
257            popup.open(text, block, rect)
258
259        self.lsp.signature_help(self.path, block + 1, column, answered)

Show (or update, or hide) the argument hints for the call the cursor is in.

def find_references(self):
298    def find_references(self):
299        if self.lsp is None:
300            return
301        found = self._name_at_cursor()
302        if found is None:
303            self.update_status("put the cursor on a name to find its references")
304            return
305        line, column, name = found
306        self._flush_lsp()
307        self.lsp.references(self.path, line, column,
308                            lambda result: self.references_found.emit(name, lsp_mod.locations(result)))
def rename_symbol(self):
310    def rename_symbol(self):
311        if self.lsp is None:
312            return
313        found = self._name_at_cursor()
314        if found is None:
315            self.update_status("put the cursor on a name to rename it")
316            return
317        line, column, name = found
318        new, ok = QInputDialog.getText(self, "Rename Symbol", f"Rename '{name}' everywhere it is used, to:",
319                                       QLineEdit.Normal, name)
320        new = new.strip()
321        if not ok or not new or new == name:
322            return
323        if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_]*", new):
324            QMessageBox.warning(self, "Rename Symbol", f"'{new}' isn't a valid Fortran name.")
325            return
326        self._flush_lsp()
327
328        def answered(result):
329            edits = lsp_mod.workspace_edits(result)
330            if not edits:
331                self.update_status(f"'{name}' can't be renamed (fortls found nothing to change)")
332                return
333            self.rename_ready.emit(name, new, edits)
334
335        self.lsp.rename(self.path, line, column, new, answered)
def apply_text_edits(self, edits):
337    def apply_text_edits(self, edits):
338        """Apply language-server edits to the text as one Undo step."""
339        document = self.editor.document()
340        cursor = QTextCursor(document)
341        cursor.beginEditBlock()
342        for e in sorted(edits, key=lambda e: (e.line, e.column), reverse=True):
343            start, end = document.findBlockByNumber(e.line), document.findBlockByNumber(e.end_line)
344            if not start.isValid() or not end.isValid():
345                continue
346            cursor.setPosition(start.position() + min(e.column, start.length() - 1))
347            cursor.setPosition(end.position() + min(e.end_column, end.length() - 1), QTextCursor.KeepAnchor)
348            cursor.insertText(e.new_text)
349        cursor.endEditBlock()

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