Coverage for vibefpmgui/output_links.py: 89%

131 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-11-01 20:51 +0000

1"""Clickable file locations in the Output panel (and the Debug panel's console). 

2 

3Runtime errors and compiler messages name where they happened: 

4gfortran's "At line 1753 of file build/dependencies/slsqp/src/slsqp_core.f90", 

5its backtraces' "… at src/foo.f90:12", compiler messages' "src/foo.f90:12:5:", 

6ifx's traceback rows ("test 00007FF7010C7F25 ROUTINE 1266 x.f90"), and 

7gdb's "Breakpoint 3 at 0x…: file test/x.f90, line 155." and "No compiled 

8code for line 187 in file "/x/y.f90"." in the Debug panel. 

9`OutputLinks` underlines those whose file exists (relative paths are the 

10project's, where fpm runs; a bare file name, as ifx prints, is looked for in 

11the project and its dependencies) and opens the file at that line when one 

12is clicked. 

13""" 

14 

15from __future__ import annotations 

16 

17import os 

18import re 

19from collections.abc import Callable 

20from dataclasses import dataclass 

21from pathlib import Path 

22 

23from qtpy.QtCore import QEvent, QObject, QPoint, Qt, Signal 

24from qtpy.QtGui import QPalette, QSyntaxHighlighter, QTextCharFormat, QTextCursor 

25from qtpy.QtWidgets import QPlainTextEdit, QToolTip 

26 

27_EXTENSIONS = r"f|for|ftn|f77|f90|f95|f03|f08|f18|fpp|fypp|inc|c|h|cc|cpp|cxx|hpp" 

28LOCATION_RES = [ 

29 # gfortran at run time: "At line 1753 of file src/x.f90" (then maybe " (unit = 6, …)"). 

30 re.compile(r"At line (?P<line>\d+) of file (?P<path>[^\s()]+)"), 

31 # Compiler messages and backtraces: "src/x.f90:12:5:", "at /abs/x.f90:12", "x.f90(12)" (Intel). 

32 re.compile(r"(?P<path>(?:[A-Za-z]:[\\/])?[^\s:'\"()<>]*\.(?i:" + _EXTENSIONS + r"))" 

33 r"(?::(?P<line2>\d+)(?::(?P<col>\d+))?|\((?P<line3>\d+)\))"), 

34 # gdb (the Debug panel's console): "Breakpoint 3 at 0x…: file test/x.f90, line 155." 

35 re.compile(r"\bfile (?P<path>[^\s,'\"()<>]+\.(?i:" + _EXTENSIONS + r")), line (?P<line>\d+)"), 

36 # gdb, a breakpoint on a line with no code: 'No compiled code for line 187 in file "/x/y.f90".' 

37 re.compile(r"\bline (?P<line>\d+) in file \"(?P<path>[^\"]+\.(?i:" + _EXTENSIONS + r"))\""), 

38 # ifx's traceback (image, PC, routine, line, source): "x 00007FF7010C7F25 ROUTINE 1266 x.f90". 

39 # The link is the line and the source. 

40 re.compile(r"^\s*\S+\s+[0-9A-Fa-f]{8,16}\s+\S+\s+(?P<link>(?P<line>\d+)\s+" 

41 r"(?P<path>[^\s:'\"()<>]+\.(?i:" + _EXTENSIONS + r")))\s*$"), 

42] 

43SKIPPED_FOLDERS = {".git", ".hg", ".svn", "__pycache__", "node_modules"} # (when looking for a bare name) 

44CACHE_LIMIT = 5000 

45 

46 

47@dataclass(frozen=True) 

48class Location: 

49 start: int # (in the line of output) 

50 end: int 

51 path: Path 

52 line: int 

53 column: int # 0-based 

54 

55 

56class LinkHighlighter(QSyntaxHighlighter): 

57 """Underlines the locations in each line of output.""" 

58 

59 def __init__(self, links: OutputLinks): 

60 super().__init__(links.view.document()) 

61 self.links = links 

62 

63 def highlightBlock(self, text: str): 

64 found = self.links.locations(text) 

65 if not found: 

66 return 

67 fmt = QTextCharFormat() 

68 fmt.setForeground(self.links.view.palette().color(QPalette.Link)) 

69 fmt.setFontUnderline(True) 

70 for loc in found: 

71 self.setFormat(loc.start, loc.end - loc.start, fmt) 

72 

73 

74class OutputLinks(QObject): 

75 """Makes the file locations in ``view`` clickable; ``base`` gives the 

76 folder relative paths are in (None: only absolute ones).""" 

77 

78 open_requested = Signal(str, int, int) # path, line (1-based), column (0-based) 

79 

80 def __init__(self, view: QPlainTextEdit, base: Callable[[], Path | None]): 

81 super().__init__(view) 

82 self.view, self.base = view, base 

83 self._exists: dict[Path, bool] = {} 

84 self._by_name: dict[str, list[Path]] | None = None # the project's files by name (see find_by_name) 

85 self._press: QPoint | None = None 

86 self.highlighter = LinkHighlighter(self) 

87 view.viewport().setMouseTracking(True) 

88 view.viewport().installEventFilter(self) 

89 

90 # ------------------------------------------------------------ finding 

91 def resolve(self, text: str) -> Path | None: 

92 path = Path(text) 

93 if not path.is_absolute(): 

94 base = self.base() 

95 if base is None: 95 ↛ 96line 95 didn't jump to line 96 because the condition on line 95 was never true

96 return None 

97 path = base / path 

98 if path not in self._exists: 

99 if len(self._exists) > CACHE_LIMIT: 99 ↛ 100line 99 didn't jump to line 100 because the condition on line 99 was never true

100 self._exists.clear() 

101 self._exists[path] = path.is_file() 

102 if self._exists[path]: 

103 return path 

104 if len(Path(text).parts) == 1: # (a bare name, as ifx prints: the project's file of that name) 

105 return self.find_by_name(text) 

106 return None 

107 

108 def find_by_name(self, name: str) -> Path | None: 

109 """The project's file called ``name`` (its own before a dependency's; 

110 the name's case only matters if two differ just by it), or None.""" 

111 if self._by_name is None: 

112 self._by_name = self._index() 

113 found = self._by_name.get(name.lower(), []) 

114 exact = [p for p in found if p.name == name] or found 

115 return exact[0] if exact else None 

116 

117 def _index(self) -> dict[str, list[Path]]: 

118 """The project's files by lowercase name: its own first, then the 

119 dependencies' (build/dependencies); not the rest of the build folder.""" 

120 base = self.base() 

121 index: dict[str, list[Path]] = {} 

122 if base is None or not base.is_dir(): 122 ↛ 123line 122 didn't jump to line 123 because the condition on line 122 was never true

123 return index 

124 own, deps = [], [] 

125 for folder, dirs, files in os.walk(base): 

126 here = Path(folder) 

127 relative = here.relative_to(base).parts 

128 in_build = bool(relative) and relative[0] == "build" 

129 dirs[:] = sorted(d for d in dirs if not d.startswith(".") and d not in SKIPPED_FOLDERS 

130 and (not in_build or len(relative) > 1 or d == "dependencies")) 

131 (deps if in_build else own).extend(here / f for f in sorted(files)) 

132 for path in own + deps: 

133 index.setdefault(path.name.lower(), []).append(path) 

134 return index 

135 

136 def locations(self, text: str) -> list[Location]: 

137 """The locations in one line of output whose files exist.""" 

138 if "." not in text: # (every location names a file: name.ext) 

139 return [] 

140 found: list[Location] = [] 

141 for pattern in LOCATION_RES: 

142 for match in pattern.finditer(text): 

143 if any(loc.start < match.end() and match.start() < loc.end for loc in found): 143 ↛ 144line 143 didn't jump to line 144 because the condition on line 143 was never true

144 continue # (already part of one) 

145 path = self.resolve(match.group("path")) 

146 if path is None: 

147 continue 

148 groups = match.groupdict() 

149 line = int(groups.get("line") or groups.get("line2") or groups.get("line3") or 1) 

150 column = max(int(groups.get("col") or 1) - 1, 0) 

151 start, end = match.span("link") if groups.get("link") else match.span() 

152 found.append(Location(start, end, path, line, column)) 

153 return found 

154 

155 def forget(self): 

156 """Look again whether files exist (a new command's output).""" 

157 self._exists.clear() 

158 self._by_name = None 

159 

160 def location_at(self, pos: QPoint) -> Location | None: 

161 """The location under the mouse, if any (``pos``: in the viewport).""" 

162 cursor = self.view.cursorForPosition(pos) 

163 column = cursor.positionInBlock() 

164 for loc in self.locations(cursor.block().text()): 

165 if loc.start <= column <= loc.end: 165 ↛ 164line 165 didn't jump to line 164 because the condition on line 165 was always true

166 end = QTextCursor(cursor.block()) 

167 end.setPosition(cursor.block().position() + loc.end) 

168 if pos.x() <= self.view.cursorRect(end).right(): # (not past the end of the line) 

169 return loc 

170 return None 

171 

172 # ------------------------------------------------------------ the mouse 

173 def eventFilter(self, obj, event): 

174 kind = event.type() 

175 if kind == QEvent.MouseMove: 175 ↛ 176line 175 didn't jump to line 176 because the condition on line 175 was never true

176 over = event.buttons() == Qt.NoButton and self.location_at(event.position().toPoint()) 

177 self.view.viewport().setCursor(Qt.PointingHandCursor if over else Qt.IBeamCursor) 

178 elif kind == QEvent.MouseButtonPress and event.button() == Qt.LeftButton: 

179 self._press = event.position().toPoint() 

180 elif kind == QEvent.MouseButtonRelease and event.button() == Qt.LeftButton and self._press is not None: 

181 pos, self._press = event.position().toPoint(), None 

182 if not self.view.textCursor().hasSelection(): # (not the end of selecting text) 182 ↛ 193line 182 didn't jump to line 193 because the condition on line 182 was always true

183 loc = self.location_at(pos) 

184 if loc is not None: 

185 self.open_requested.emit(str(loc.path), loc.line, loc.column) 

186 elif kind == QEvent.ToolTip: 186 ↛ 187line 186 didn't jump to line 187 because the condition on line 186 was never true

187 loc = self.location_at(event.pos()) 

188 if loc is not None: 

189 QToolTip.showText(event.globalPos(), f"Open {loc.path.name} at line {loc.line}", self.view) 

190 else: 

191 QToolTip.hideText() 

192 return True 

193 return False