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
« 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).
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"""
15from __future__ import annotations
17import os
18import re
19from collections.abc import Callable
20from dataclasses import dataclass
21from pathlib import Path
23from qtpy.QtCore import QEvent, QObject, QPoint, Qt, Signal
24from qtpy.QtGui import QPalette, QSyntaxHighlighter, QTextCharFormat, QTextCursor
25from qtpy.QtWidgets import QPlainTextEdit, QToolTip
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
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
56class LinkHighlighter(QSyntaxHighlighter):
57 """Underlines the locations in each line of output."""
59 def __init__(self, links: OutputLinks):
60 super().__init__(links.view.document())
61 self.links = links
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)
74class OutputLinks(QObject):
75 """Makes the file locations in ``view`` clickable; ``base`` gives the
76 folder relative paths are in (None: only absolute ones)."""
78 open_requested = Signal(str, int, int) # path, line (1-based), column (0-based)
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)
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
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
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
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
155 def forget(self):
156 """Look again whether files exist (a new command's output)."""
157 self._exists.clear()
158 self._by_name = None
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
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