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

1"""Go to Symbol in Project: find a module, type or procedure by name. 

2 

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""" 

7 

8from __future__ import annotations 

9 

10from pathlib import Path 

11 

12from qtpy.QtCore import Qt, QTimer, Signal 

13from qtpy.QtWidgets import ( 

14 QDialog, 

15 QLabel, 

16 QLineEdit, 

17 QListWidget, 

18 QListWidgetItem, 

19 QVBoxLayout, 

20) 

21 

22from . import lsp as lsp_mod 

23 

24MAX_RESULTS = 200 

25LOCATION_ROLE = Qt.UserRole + 1 

26 

27 

28class SymbolSearchDialog(QDialog): 

29 chosen = Signal(str, int, int) # file, line (1-based), column (0-based) 

30 

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) 

48 

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 

59 

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) 

67 

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

77 

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

112 

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) 

122 

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)