Coverage for vibefpmgui/symbol_search.py: 91%
93 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"""Go to Symbol in Project: find a module, type or procedure by name.
3Type part of a name; the language server's matches (from the project and
4its dependencies) are listed as you type. Enter or double-click opens the
5file at the symbol.
6"""
8from __future__ import annotations
10from pathlib import Path
12from qtpy.QtCore import Qt, QTimer, Signal
13from qtpy.QtWidgets import (
14 QDialog,
15 QLabel,
16 QLineEdit,
17 QListWidget,
18 QListWidgetItem,
19 QVBoxLayout,
20)
22from . import lsp as lsp_mod
24MAX_RESULTS = 200
25LOCATION_ROLE = Qt.UserRole + 1
28class SymbolSearchDialog(QDialog):
29 chosen = Signal(str, int, int) # file, line (1-based), column (0-based)
31 def __init__(self, client: lsp_mod.LspClient, project_dir: Path | None, parent=None):
32 super().__init__(parent)
33 self.client = client
34 self.project_dir = project_dir
35 self.setWindowTitle("Go to Symbol in Project")
36 self.resize(640, 420)
37 self.query = QLineEdit()
38 self.query.setPlaceholderText("Name of a module, type, procedure…")
39 self.query.setClearButtonEnabled(True)
40 self.results = QListWidget()
41 self.results.setUniformItemSizes(True)
42 self.note = QLabel("Type part of a name.")
43 self.note.setStyleSheet("color: gray;")
44 layout = QVBoxLayout(self)
45 layout.addWidget(self.query)
46 layout.addWidget(self.results, 1)
47 layout.addWidget(self.note)
49 self._timer = QTimer(self) # search once typing pauses
50 self._timer.setSingleShot(True)
51 self._timer.setInterval(150)
52 self._timer.timeout.connect(self._search)
53 self.query.textChanged.connect(self._timer.start)
54 self.query.returnPressed.connect(self._accept_current)
55 self.results.itemActivated.connect(self._accept_item)
56 self.query.installEventFilter(self)
57 self._asked = ""
58 self._open_first = False # Enter was pressed before the answer came
60 def eventFilter(self, obj, event):
61 # Up/Down in the text field move through the results.
62 if obj is self.query and event.type() == event.Type.KeyPress and \
63 event.key() in (Qt.Key_Up, Qt.Key_Down, Qt.Key_PageUp, Qt.Key_PageDown):
64 self.results.keyPressEvent(event)
65 return True
66 return super().eventFilter(obj, event)
68 def _search(self):
69 text = self.query.text().strip()
70 self._asked = text
71 if not text: 71 ↛ 72line 71 didn't jump to line 72 because the condition on line 71 was never true
72 self.results.clear()
73 self.note.setText("Type part of a name.")
74 return
75 if not self.client.workspace_symbols(text, lambda result, asked=text: self._show(asked, result)):
76 self.note.setText("The language server (fortls) isn't running.")
78 def _show(self, asked: str, result):
79 if asked != self._asked: 79 ↛ 80line 79 didn't jump to line 80 because the condition on line 79 was never true
80 return # an older search
81 symbols = [s for s in result or [] if isinstance(s, dict) and s.get("location")]
82 lower = asked.lower()
83 # Exact names first, then names starting with the text, then the rest.
84 symbols.sort(key=lambda s: (s.get("name", "").lower() != lower,
85 not s.get("name", "").lower().startswith(lower),
86 len(s.get("name", "")), s.get("name", "").lower()))
87 self.results.clear()
88 for s in symbols[:MAX_RESULTS]:
89 path = lsp_mod.from_uri(s["location"]["uri"])
90 line = s["location"]["range"]["start"]["line"] + 1
91 column = s["location"]["range"]["start"]["character"]
92 shown = path
93 if self.project_dir is not None: 93 ↛ 98line 93 didn't jump to line 98 because the condition on line 93 was always true
94 try:
95 shown = path.relative_to(self.project_dir.resolve())
96 except ValueError:
97 pass
98 kind = lsp_mod.SYMBOL_KINDS.get(s.get("kind"), "")
99 container = f"{s['containerName']}::" if s.get("containerName") else ""
100 item = QListWidgetItem(f"{container}{s.get('name', '')} ({kind}) — {shown}:{line}")
101 item.setData(LOCATION_ROLE, (str(path), line, column))
102 item.setToolTip(f"{path}:{line}")
103 self.results.addItem(item)
104 if self.results.count():
105 self.results.setCurrentRow(0)
106 if self._open_first:
107 self._accept_item(self.results.item(0))
108 return
109 more = f" (first {MAX_RESULTS} shown)" if len(symbols) > MAX_RESULTS else ""
110 self.note.setText(f"{len(symbols)} match{'es' * (len(symbols) != 1)}{more} — "
111 "Enter opens the selected one." if symbols else "No matches.")
113 def _accept_current(self):
114 if self._timer.isActive() or self.query.text().strip() != self._asked:
115 self._open_first = True # Enter before the search ran: open the best match
116 self._timer.stop()
117 self._search()
118 return
119 item = self.results.currentItem()
120 if item is not None: 120 ↛ exitline 120 didn't return from function '_accept_current' because the condition on line 120 was always true
121 self._accept_item(item)
123 def _accept_item(self, item: QListWidgetItem):
124 path, line, column = item.data(LOCATION_ROLE)
125 self.accept()
126 self.chosen.emit(path, line, column)