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
LOCATION_RES = [re.compile('At line (?P<line>\\d+) of file (?P<path>[^\\s()]+)'), re.compile('(?P<path>(?:[A-Za-z]:[\\\\/])?[^\\s:\'\\"()<>]*\\.(?i:f|for|ftn|f77|f90|f95|f03|f08|f18|fpp|fypp|inc|c|h|cc|cpp|cxx|hpp))(?::(?P<line2>\\d+)(?::(?P<col>\\d+))?|\\((?P<line3>\\d+)\\))'), re.compile('\\bfile (?P<path>[^\\s,\'\\"()<>]+\\.(?i:f|for|ftn|f77|f90|f95|f03|f08|f18|fpp|fypp|inc|c|h|cc|cpp|cxx|hpp)), line (?P<line>\\d+)'), re.compile('\\bline (?P<line>\\d+) in file \\"(?P<path>[^\\"]+\\.(?i:f|for|ftn|f77|f90|f95|f03|f08|f18|fpp|fypp|inc|c|h|cc|cpp|cxx|hpp))\\"'), re.compile('^\\s*\\S+\\s+[0-9A-Fa-f]{8,16}\\s+\\S+\\s+(?P<link>(?P<line>\\d+)\\s+(?P<path>[^\\s:\'\\"()<>]+\\.(?i:f|for|ftn|f77|f90|f95|f03|f08|f18|fpp|fypp|inc|c|h|cc|cpp|cxx|hpp)))\\s*$')]
SKIPPED_FOLDERS = {'node_modules', '.svn', '__pycache__', '.git', '.hg'}
CACHE_LIMIT = 5000
@dataclass(frozen=True)
class Location:
48@dataclass(frozen=True)
49class Location:
50    start: int   # (in the line of output)
51    end: int
52    path: Path
53    line: int
54    column: int  # 0-based
Location(start: int, end: int, path: pathlib.Path, line: int, column: int)
start: int
end: int
path: pathlib.Path
line: int
column: int
class LinkHighlighter(PySide6.QtGui.QSyntaxHighlighter):
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.

LinkHighlighter(links: OutputLinks)
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.

def highlightBlock(self, text: str):
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

staticMetaObject = PySide6.QtCore.QMetaObject("LinkHighlighter" inherits "QSyntaxHighlighter": )