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