vibefpmgui.output_links
Clickable file locations in the Output panel (and the Debug panel's console).
Runtime errors and compiler messages name where they happened:
gfortran's "At line 1753 of file build/dependencies/slsqp/src/slsqp_core.f90",
its backtraces' "… at src/foo.f90:12", compiler messages' "src/foo.f90:12:5:",
ifx's traceback rows ("test 00007FF7010C7F25 ROUTINE 1266 x.f90"), and
gdb's "Breakpoint 3 at 0x…: file test/x.f90, line 155." and "No compiled
code for line 187 in file "/x/y.f90"." in the Debug panel.
OutputLinks underlines those whose file exists (relative paths are the
project's, where fpm runs; a bare file name, as ifx prints, is looked for in
the project and its dependencies) and opens the file at that line when one
is clicked.
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: 96 return None 97 path = base / path 98 if path not in self._exists: 99 if len(self._exists) > CACHE_LIMIT: 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(): 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): 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: 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: 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) 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: 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
57class LinkHighlighter(QSyntaxHighlighter): 58 """Underlines the locations in each line of output.""" 59 60 def __init__(self, links: OutputLinks): 61 super().__init__(links.view.document()) 62 self.links = links 63 64 def highlightBlock(self, text: str): 65 found = self.links.locations(text) 66 if not found: 67 return 68 fmt = QTextCharFormat() 69 fmt.setForeground(self.links.view.palette().color(QPalette.Link)) 70 fmt.setFontUnderline(True) 71 for loc in found: 72 self.setFormat(loc.start, loc.end - loc.start, fmt)
Underlines the locations in each line of output.
60 def __init__(self, links: OutputLinks): 61 super().__init__(links.view.document()) 62 self.links = links
__init__(self, parent: PySide6.QtGui.QTextDocument, /) -> None __init__(self, parent: PySide6.QtCore.QObject, /) -> None
Initialize self. See help(type(self)) for accurate signature.
64 def highlightBlock(self, text: str): 65 found = self.links.locations(text) 66 if not found: 67 return 68 fmt = QTextCharFormat() 69 fmt.setForeground(self.links.view.palette().color(QPalette.Link)) 70 fmt.setFontUnderline(True) 71 for loc in found: 72 self.setFormat(loc.start, loc.end - loc.start, fmt)
highlightBlock(self, text: str, /) -> None
75class OutputLinks(QObject): 76 """Makes the file locations in ``view`` clickable; ``base`` gives the 77 folder relative paths are in (None: only absolute ones).""" 78 79 open_requested = Signal(str, int, int) # path, line (1-based), column (0-based) 80 81 def __init__(self, view: QPlainTextEdit, base: Callable[[], Path | None]): 82 super().__init__(view) 83 self.view, self.base = view, base 84 self._exists: dict[Path, bool] = {} 85 self._by_name: dict[str, list[Path]] | None = None # the project's files by name (see find_by_name) 86 self._press: QPoint | None = None 87 self.highlighter = LinkHighlighter(self) 88 view.viewport().setMouseTracking(True) 89 view.viewport().installEventFilter(self) 90 91 # ------------------------------------------------------------ finding 92 def resolve(self, text: str) -> Path | None: 93 path = Path(text) 94 if not path.is_absolute(): 95 base = self.base() 96 if base is None: 97 return None 98 path = base / path 99 if path not in self._exists: 100 if len(self._exists) > CACHE_LIMIT: 101 self._exists.clear() 102 self._exists[path] = path.is_file() 103 if self._exists[path]: 104 return path 105 if len(Path(text).parts) == 1: # (a bare name, as ifx prints: the project's file of that name) 106 return self.find_by_name(text) 107 return None 108 109 def find_by_name(self, name: str) -> Path | None: 110 """The project's file called ``name`` (its own before a dependency's; 111 the name's case only matters if two differ just by it), or None.""" 112 if self._by_name is None: 113 self._by_name = self._index() 114 found = self._by_name.get(name.lower(), []) 115 exact = [p for p in found if p.name == name] or found 116 return exact[0] if exact else None 117 118 def _index(self) -> dict[str, list[Path]]: 119 """The project's files by lowercase name: its own first, then the 120 dependencies' (build/dependencies); not the rest of the build folder.""" 121 base = self.base() 122 index: dict[str, list[Path]] = {} 123 if base is None or not base.is_dir(): 124 return index 125 own, deps = [], [] 126 for folder, dirs, files in os.walk(base): 127 here = Path(folder) 128 relative = here.relative_to(base).parts 129 in_build = bool(relative) and relative[0] == "build" 130 dirs[:] = sorted(d for d in dirs if not d.startswith(".") and d not in SKIPPED_FOLDERS 131 and (not in_build or len(relative) > 1 or d == "dependencies")) 132 (deps if in_build else own).extend(here / f for f in sorted(files)) 133 for path in own + deps: 134 index.setdefault(path.name.lower(), []).append(path) 135 return index 136 137 def locations(self, text: str) -> list[Location]: 138 """The locations in one line of output whose files exist.""" 139 if "." not in text: # (every location names a file: name.ext) 140 return [] 141 found: list[Location] = [] 142 for pattern in LOCATION_RES: 143 for match in pattern.finditer(text): 144 if any(loc.start < match.end() and match.start() < loc.end for loc in found): 145 continue # (already part of one) 146 path = self.resolve(match.group("path")) 147 if path is None: 148 continue 149 groups = match.groupdict() 150 line = int(groups.get("line") or groups.get("line2") or groups.get("line3") or 1) 151 column = max(int(groups.get("col") or 1) - 1, 0) 152 start, end = match.span("link") if groups.get("link") else match.span() 153 found.append(Location(start, end, path, line, column)) 154 return found 155 156 def forget(self): 157 """Look again whether files exist (a new command's output).""" 158 self._exists.clear() 159 self._by_name = None 160 161 def location_at(self, pos: QPoint) -> Location | None: 162 """The location under the mouse, if any (``pos``: in the viewport).""" 163 cursor = self.view.cursorForPosition(pos) 164 column = cursor.positionInBlock() 165 for loc in self.locations(cursor.block().text()): 166 if loc.start <= column <= loc.end: 167 end = QTextCursor(cursor.block()) 168 end.setPosition(cursor.block().position() + loc.end) 169 if pos.x() <= self.view.cursorRect(end).right(): # (not past the end of the line) 170 return loc 171 return None 172 173 # ------------------------------------------------------------ the mouse 174 def eventFilter(self, obj, event): 175 kind = event.type() 176 if kind == QEvent.MouseMove: 177 over = event.buttons() == Qt.NoButton and self.location_at(event.position().toPoint()) 178 self.view.viewport().setCursor(Qt.PointingHandCursor if over else Qt.IBeamCursor) 179 elif kind == QEvent.MouseButtonPress and event.button() == Qt.LeftButton: 180 self._press = event.position().toPoint() 181 elif kind == QEvent.MouseButtonRelease and event.button() == Qt.LeftButton and self._press is not None: 182 pos, self._press = event.position().toPoint(), None 183 if not self.view.textCursor().hasSelection(): # (not the end of selecting text) 184 loc = self.location_at(pos) 185 if loc is not None: 186 self.open_requested.emit(str(loc.path), loc.line, loc.column) 187 elif kind == QEvent.ToolTip: 188 loc = self.location_at(event.pos()) 189 if loc is not None: 190 QToolTip.showText(event.globalPos(), f"Open {loc.path.name} at line {loc.line}", self.view) 191 else: 192 QToolTip.hideText() 193 return True 194 return False
Makes the file locations in view clickable; base gives the
folder relative paths are in (None: only absolute ones).
81 def __init__(self, view: QPlainTextEdit, base: Callable[[], Path | None]): 82 super().__init__(view) 83 self.view, self.base = view, base 84 self._exists: dict[Path, bool] = {} 85 self._by_name: dict[str, list[Path]] | None = None # the project's files by name (see find_by_name) 86 self._press: QPoint | None = None 87 self.highlighter = LinkHighlighter(self) 88 view.viewport().setMouseTracking(True) 89 view.viewport().installEventFilter(self)
__init__(self, /, parent: PySide6.QtCore.QObject | None = None, *, objectName: str | None = None) -> None
Initialize self. See help(type(self)) for accurate signature.
92 def resolve(self, text: str) -> Path | None: 93 path = Path(text) 94 if not path.is_absolute(): 95 base = self.base() 96 if base is None: 97 return None 98 path = base / path 99 if path not in self._exists: 100 if len(self._exists) > CACHE_LIMIT: 101 self._exists.clear() 102 self._exists[path] = path.is_file() 103 if self._exists[path]: 104 return path 105 if len(Path(text).parts) == 1: # (a bare name, as ifx prints: the project's file of that name) 106 return self.find_by_name(text) 107 return None
109 def find_by_name(self, name: str) -> Path | None: 110 """The project's file called ``name`` (its own before a dependency's; 111 the name's case only matters if two differ just by it), or None.""" 112 if self._by_name is None: 113 self._by_name = self._index() 114 found = self._by_name.get(name.lower(), []) 115 exact = [p for p in found if p.name == name] or found 116 return exact[0] if exact else None
The project's file called name (its own before a dependency's;
the name's case only matters if two differ just by it), or None.
137 def locations(self, text: str) -> list[Location]: 138 """The locations in one line of output whose files exist.""" 139 if "." not in text: # (every location names a file: name.ext) 140 return [] 141 found: list[Location] = [] 142 for pattern in LOCATION_RES: 143 for match in pattern.finditer(text): 144 if any(loc.start < match.end() and match.start() < loc.end for loc in found): 145 continue # (already part of one) 146 path = self.resolve(match.group("path")) 147 if path is None: 148 continue 149 groups = match.groupdict() 150 line = int(groups.get("line") or groups.get("line2") or groups.get("line3") or 1) 151 column = max(int(groups.get("col") or 1) - 1, 0) 152 start, end = match.span("link") if groups.get("link") else match.span() 153 found.append(Location(start, end, path, line, column)) 154 return found
The locations in one line of output whose files exist.
156 def forget(self): 157 """Look again whether files exist (a new command's output).""" 158 self._exists.clear() 159 self._by_name = None
Look again whether files exist (a new command's output).
161 def location_at(self, pos: QPoint) -> Location | None: 162 """The location under the mouse, if any (``pos``: in the viewport).""" 163 cursor = self.view.cursorForPosition(pos) 164 column = cursor.positionInBlock() 165 for loc in self.locations(cursor.block().text()): 166 if loc.start <= column <= loc.end: 167 end = QTextCursor(cursor.block()) 168 end.setPosition(cursor.block().position() + loc.end) 169 if pos.x() <= self.view.cursorRect(end).right(): # (not past the end of the line) 170 return loc 171 return None
The location under the mouse, if any (pos: in the viewport).
174 def eventFilter(self, obj, event): 175 kind = event.type() 176 if kind == QEvent.MouseMove: 177 over = event.buttons() == Qt.NoButton and self.location_at(event.position().toPoint()) 178 self.view.viewport().setCursor(Qt.PointingHandCursor if over else Qt.IBeamCursor) 179 elif kind == QEvent.MouseButtonPress and event.button() == Qt.LeftButton: 180 self._press = event.position().toPoint() 181 elif kind == QEvent.MouseButtonRelease and event.button() == Qt.LeftButton and self._press is not None: 182 pos, self._press = event.position().toPoint(), None 183 if not self.view.textCursor().hasSelection(): # (not the end of selecting text) 184 loc = self.location_at(pos) 185 if loc is not None: 186 self.open_requested.emit(str(loc.path), loc.line, loc.column) 187 elif kind == QEvent.ToolTip: 188 loc = self.location_at(event.pos()) 189 if loc is not None: 190 QToolTip.showText(event.globalPos(), f"Open {loc.path.name} at line {loc.line}", self.view) 191 else: 192 QToolTip.hideText() 193 return True 194 return False
eventFilter(self, watched: PySide6.QtCore.QObject, event: PySide6.QtCore.QEvent, /) -> bool