vibefpmgui.window.debugging

Debugging (see plan/debugger.md): the Debug menu, the Debug panel, and breakpoints in the editors' gutters.

A program, a test or an example (Start Debugging (F5: the kind debugged last), Debug a Test…, Debug an Example…, or a row's right-click in the Tests or Run panel) is run as fpm runs it (fpm run, fpm test, fpm run --example, with the Build options), but with the collecting runner (vibefpmgui.test_runner --collect): fpm builds (the Build panel shows it), then says which executable it would run, and how; that one then runs under the debugger (vibefpmgui.debug_session). Several (no Target given): which one is asked.

Breakpoints are kept per file (absolute path → lines), with the open files' own, which move with their edits (CodeEditor.breakpoint_lines); they're saved with the project's open files (its session).

  1"""Debugging (see plan/debugger.md): the Debug menu, the Debug panel, and
  2breakpoints in the editors' gutters.
  3
  4A program, a test or an example (*Start Debugging* (F5: the kind debugged
  5last), *Debug a Test…*, *Debug an Example…*, or a row's right-click in the
  6Tests or Run panel) is run as fpm runs it (``fpm run``, ``fpm test``,
  7``fpm run --example``, with the Build options), but with the collecting
  8runner (`vibefpmgui.test_runner` ``--collect``): fpm builds (the Build panel
  9shows it), then says which executable it would run, and how; that one then
 10runs under the debugger (`vibefpmgui.debug_session`). Several (no Target
 11given): which one is asked.
 12
 13Breakpoints are kept per file (absolute path → lines), with the open files'
 14own, which move with their edits (`CodeEditor.breakpoint_lines`); they're
 15saved with the project's open files (its session).
 16"""
 17
 18from __future__ import annotations
 19
 20import json
 21from pathlib import Path
 22
 23from qtpy.QtCore import QTimer
 24from qtpy.QtGui import QAction, QKeySequence, QPalette
 25from qtpy.QtWidgets import QApplication, QInputDialog, QMessageBox
 26
 27from .. import ansi
 28from ..breakpoints_list import DISABLED, ENABLED, UNPLACED, BreakpointEntry
 29from ..complexity import FORTRAN
 30from ..debug_adapters import NOT_FOUND, find_adapter
 31from ..debug_panel import DebugPanel
 32from ..debug_session import IDLE, STOPPED, DebugSession, Frame
 33from ..editor import EditorTab
 34from ..fpm_args import runner_command, with_runner
 35from ..icons import make_icon
 36from ..output_links import OutputLinks
 37from ..test_monitor import EXE_RE
 38
 39# What can be debugged: fpm's command for it, and what it's called.
 40KINDS = {"program": (["run"], "program"), "test": (["test"], "test"), "example": (["run", "--example"], "example")}
 41
 42# The files a debugger can stop in: Fortran (and its include files), and C and C++.
 43DEBUGGABLE = FORTRAN | {".inc", ".fi", ".c", ".h", ".cc", ".cpp", ".cxx", ".hpp", ".hh"}
 44
 45
 46def debuggable(path: Path) -> bool:
 47    return path.suffix.lower() in DEBUGGABLE
 48
 49
 50class DebuggingMixin:
 51    """Debugging (a part of `vibefpmgui.main_window.MainWindow`)."""
 52
 53    def _build_debugging(self):
 54        """The Debug menu, its actions and the Debug panel's widget."""
 55        self.debug_session = DebugSession(self)
 56        self.breakpoints: dict[str, list[int]] = {}  # file (absolute) → lines, the closed files' too
 57        self.disabled_breakpoints: dict[str, list[int]] = {}  # file → those of its lines disabled (kept, hollow)
 58        self._breakpoint_problems: dict[str, dict[int, str]] = {}  # file → {line: why it wasn't placed}
 59        self._debug_candidates: list[tuple[str, list[str]]] = []  # (name, command line) fpm said it would run
 60        self._debug_adapter = None  # the debugger the session being started uses
 61        self._debug_kind, self._debug_targets = "program", None  # what F5 debugs: the kind debugged last
 62
 63        def action(text, keys, slot, tip):
 64            item = QAction(text, self)
 65            if keys:
 66                item.setShortcut(QKeySequence(keys))
 67            item.setStatusTip(tip)
 68            item.setToolTip(f"{text} ({QKeySequence(keys).toString(QKeySequence.NativeText)})" if keys else text)
 69            item.triggered.connect(slot)
 70            return item
 71
 72        self.debug_start_action = action("Start Debugging", "F5", lambda: self.start_debugging(),
 73                                         "Build, then run the program under the debugger (Target: which one; "
 74                                         "a test or an example, if one was debugged last)")
 75        self.debug_test_action = action("Debug a Test…", "", lambda: self.start_debugging("test"),
 76                                        "Build the tests, then run one under the debugger (Target: which)")
 77        self.debug_example_action = action("Debug an Example…", "", lambda: self.start_debugging("example"),
 78                                           "Build the examples, then run one under the debugger (Target: which)")
 79        self.debug_stop_action = action("Stop Debugging", "Shift+F5", self.debug_session.stop,
 80                                        "End the program being debugged")
 81        self.debug_pause_action = action("Pause", "", self.debug_session.pause, "Pause the program where it is")
 82        self.step_over_action = action("Step Over", "F10", self.debug_session.step_over,
 83                                       "Run the current line, calls and all")
 84        self.step_in_action = action("Step Into", "F11", self.debug_session.step_in,
 85                                     "Into the procedure the current line calls")
 86        self.step_out_action = action("Step Out", "Shift+F11", self.debug_session.step_out,
 87                                      "On to where the current procedure returns")
 88        self.toggle_breakpoint_action = action("Toggle Breakpoint", "F9", self.toggle_breakpoint,
 89                                               "Set or remove a breakpoint on the cursor's line")
 90        self.enable_breakpoint_action = action("Enable/Disable Breakpoint", "Ctrl+F9", self.toggle_breakpoint_enabled,
 91                                               "Disable the breakpoint on the cursor's line (kept, drawn hollow: "
 92                                               "the debugger doesn't stop there), or enable it again")
 93        self.disable_all_breakpoints_action = action("Disable All Breakpoints", "",
 94                                                     lambda: self.set_all_breakpoints_enabled(),
 95                                                     "Every breakpoint, in every file, disabled (kept, hollow); "
 96                                                     "Enable All Breakpoints, when none is enabled")
 97        self.clear_breakpoints_action = action("Remove All Breakpoints", "", self.remove_all_breakpoints,
 98                                               "Every breakpoint, in every file")
 99        menu = self.menuBar().addMenu("&Debug")
100        menu.addActions([self.debug_start_action, self.debug_test_action, self.debug_example_action])
101        menu.addSeparator()
102        menu.addActions([self.debug_pause_action, self.debug_stop_action])
103        menu.addSeparator()
104        menu.addActions([self.step_over_action, self.step_in_action, self.step_out_action])
105        menu.addSeparator()
106        menu.addActions([self.toggle_breakpoint_action, self.enable_breakpoint_action,
107                         self.disable_all_breakpoints_action, self.clear_breakpoints_action])
108        # Icons (the panel's buttons show only them; their tooltips say what they do), drawn
109        # in the theme's text color as the toolbar's are, and again when it changes.
110        for item, icon in ((self.debug_start_action, "run"), (self.debug_pause_action, "pause"),
111                           (self.step_over_action, "step-over"), (self.step_in_action, "step-into"),
112                           (self.step_out_action, "step-out"), (self.debug_stop_action, "stop")):
113            self.command_icons[item] = icon
114        self._update_command_icons()
115        self.debug_panel = DebugPanel(self.debug_session, [
116            self.debug_start_action, self.debug_pause_action, self.step_over_action, self.step_in_action,
117            self.step_out_action, self.debug_stop_action],
118            [self.disable_all_breakpoints_action, self.clear_breakpoints_action])
119        listed = self.debug_panel.breakpoints
120        listed.open_requested.connect(lambda path, line: self.navigate_to(path, line, 0))
121        listed.enabled_toggled.connect(self.toggle_listed_breakpoint)
122        listed.remove_requested.connect(self.remove_breakpoint)
123        listed.mono = self._mono_font()  # (the code: as the Search panel's lines; see window.view for changes)
124        listed.lines_for = self._source_lines_of  # (its syntax colors: the lines before for context)
125        self._source_lines: dict[str, tuple[float, list[str]]] = {}  # closed file -> (mtime, lines), for the list
126        # (An open file's edits move its breakpoints and change their code: the list again, once typing pauses.)
127        self._breakpoint_list_timer = QTimer(self)
128        self._breakpoint_list_timer.setSingleShot(True)
129        self._breakpoint_list_timer.setInterval(300)
130        self._breakpoint_list_timer.timeout.connect(self._refresh_breakpoint_list)
131        self.debug_panel.frame_chosen.connect(self._frame_chosen)
132        # gdb's "Breakpoint 3 at 0x…: file test/x.f90, line 155." (and any other location) open on a click.
133        self.debug_links = OutputLinks(self.debug_panel.console,
134                                       lambda: self.manifest_path.parent if self.manifest_path else None)
135        self.debug_links.open_requested.connect(lambda path, line, column: self.navigate_to(path, line, column,
136                                                                                           preview=True))
137        s = self.debug_session
138        s.state_changed.connect(self._debug_state_changed)
139        s.stopped_at.connect(self._debug_stopped)
140        s.breakpoints_checked.connect(self._breakpoints_checked)
141        s.ended.connect(self._debug_ended)
142        self._debug_state_changed(IDLE)
143
144    # ---------------------------------------------------------- starting
145    def start_debugging(self, kind: str | None = None, targets: list[str] | None = None):
146        """Debug → Start Debugging (Continue, while paused): a ``kind`` of
147        executable ("program", "test", "example"; None: the kind debugged
148        last), ``targets``: these (else the Target field's)."""
149        session = self.debug_session
150        if session.state == STOPPED and kind is None:
151            session.continue_()
152            return
153        if kind is None:
154            kind, targets = self._debug_kind, self._debug_targets
155        if session.is_active() or not self.manifest_path or self.busy():
156            return
157        adapter = find_adapter()
158        if adapter is None:
159            QMessageBox.information(self, "Start Debugging", NOT_FOUND)
160            return
161        if not self.selected_features and self.profile_combo.currentText() == "release" and QMessageBox.question(
162                self, "Start Debugging",
163                "The release profile builds without debug information: the debugger can't show lines or "
164                "variables. Debug anyway? (Choose the debug profile in Build options to debug properly.)",
165        ) != QMessageBox.Yes:
166            return
167        sub, _word = KINDS[kind]
168        args = self.debug_arguments(kind, targets, runner_command(collect=True))
169        if args is None:
170            return
171        self._debug_adapter, self._debug_candidates = adapter, []
172        self._debug_kind, self._debug_targets = kind, targets
173        self.debug_panel.begin("…")
174        self.debug_links.forget()  # (which files exist: looked at again)
175        self.statusBar().showMessage("Building, to debug…")
176        self.run_fpm(sub[:1], True, False, phase="debug", args=args)
177
178    def debug_arguments(self, kind: str, targets: list[str] | None, runner: str) -> list[str] | None:
179        """fpm's arguments to build and run a ``kind`` of executable (`KINDS`)
180        with the Build options, with debug information, through ``runner``
181        (None: a field's quotes don't match, which is said)."""
182        try:
183            args = self.build_arguments(KINDS[kind][0], True, targets=targets)
184        except ValueError as exc:
185            QMessageBox.warning(self, "Invalid arguments", str(exc))
186            return None
187        if not self.selected_features and self.profile_combo.currentIndex() <= 0:
188            # (No profile: fpm (0.13) then passes the compiler none of its flags, -g
189            # among them: no debug information. The debug profile's, then.)
190            args[1:1] = ["--profile", "debug"]
191        return with_runner(args, runner)
192
193    def debug_named(self, kind: str, name: str):
194        """A test (or program) to debug, picked in the Tests (or Run) panel."""
195        if self.debug_session.is_active():
196            self.debug_session.stop()
197        self.start_debugging(kind, [name])
198
199    def _debug_line(self, raw: str) -> str:
200        """A line of "fpm run --runner '… --collect'": the build's output, or a
201        program's command line (taken out, kept for _after_debug_build)."""
202        line = ansi.strip(raw)
203        match = EXE_RE.search(line)
204        if match is None:
205            if self._test_building:
206                self.build_monitor.feed_line(line)
207            return raw + "\n"
208        if self._test_building:  # (built: fpm runs the programs now)
209            self._test_building = False
210            if self._monitoring:
211                self.build_monitor.finish(0)
212                self._update_tree_marks()
213                self._monitoring = False
214        try:
215            argv = [str(a) for a in json.loads(match.group(2))]
216        except ValueError:
217            return raw + "\n"
218        self._debug_candidates.append((match.group(1), argv))
219        return ""
220
221    def _after_debug_build(self, code: int, stopped: bool):
222        """fpm has built the programs and said how it would run them: debug one."""
223        candidates = self._debug_candidates
224        if stopped or code != 0 or not candidates:
225            word = KINDS[self._debug_kind][1]
226            why = ("stopped" if stopped else "the build failed (see Build and Output)" if code != 0
227                   else f"fpm ran no {word} (Target: a {word}'s name)")
228            self.debug_panel.append("console", f"[not debugging: {why}]\n")
229            self.statusBar().showMessage(f"Not debugging: {why}", 6000)
230            return
231        if len(candidates) > 1:
232            names = [name for name, _argv in candidates]
233            name, ok = QInputDialog.getItem(self, "Start Debugging", f"Debug which {KINDS[self._debug_kind][1]}?",
234                                            names, 0, False)
235            if not ok:
236                return
237            candidates = [c for c in candidates if c[0] == name]
238        name, argv = candidates[0]
239        self.debug_panel.begin(name)
240        self.show_panel(self.debug_panel)
241        self.debug_session.start(self._debug_adapter, argv[0], argv[1:], str(self.manifest_path.parent),
242                                 self.all_breakpoints())
243        self.statusBar().showMessage(f"Debugging {name} ({self._debug_adapter.title})", 5000)
244
245    # ---------------------------------------------------------- while debugging
246    def _debug_state_changed(self, state: str):
247        active, stopped = state != IDLE, state == STOPPED
248        start = self.debug_start_action
249        start.setText("Continue" if stopped else "Start Debugging")
250        start.setToolTip(f"{start.text()} ({start.shortcut().toString(QKeySequence.NativeText)})")
251        icon = "continue" if stopped else "run"
252        if self.command_icons.get(start) != icon:
253            self.command_icons[start] = icon
254            start.setIcon(make_icon(icon, QApplication.palette().color(QPalette.WindowText)))
255        self.debug_start_action.setEnabled(stopped or not active)
256        self.debug_test_action.setEnabled(not active)
257        self.debug_example_action.setEnabled(not active)
258        self.debug_stop_action.setEnabled(active)
259        self.debug_pause_action.setEnabled(state == "running")
260        for step in (self.step_over_action, self.step_in_action, self.step_out_action):
261            step.setEnabled(stopped)
262        if not stopped:
263            self._show_execution_line(None)
264            self.debug_panel.breakpoints.show_hit(None)
265
266    def _debug_stopped(self, frames: list[Frame], why: str):
267        self.show_panel(self.debug_panel)
268        self._show_frame(self.debug_session.frame_index)
269        self.debug_panel.breakpoints.show_hit(self._stopped_at_breakpoint(frames))
270        if why == "exception":
271            self.statusBar().showMessage("Stopped: an error in the program (see the Debug panel's call stack)", 8000)
272
273    def _stopped_at_breakpoint(self, frames: list[Frame]) -> tuple[str, int] | None:
274        """The breakpoint the program stopped at (file, line as set), if any:
275        the one the debugger says it hit (maybe moved by it to the next line
276        with code), else one on the line it stopped at."""
277        self._refresh_breakpoints()
278        for path, line in self.debug_session.hit_breakpoints:
279            if line in self.breakpoints.get(path, ()):
280                return path, line
281        top = frames[0] if frames else None
282        if top is not None and top.path:
283            path = str(Path(top.path).resolve())
284            if top.line in self.breakpoints.get(path, ()):
285                return path, top.line
286        return None
287
288    def _frame_chosen(self, index: int):
289        self.debug_session.select_frame(index)
290        self._show_frame(index)
291
292    def _show_frame(self, index: int):
293        """A frame's place: its file opened at its line, the arrow there."""
294        frames = self.debug_session.frames
295        frame = frames[index] if 0 <= index < len(frames) else None
296        if frame is None or not frame.path or not Path(frame.path).is_file():
297            self._show_execution_line(None)
298            return
299        self.navigate_to(frame.path, frame.line, 0, record=False)
300        self._show_execution_line((Path(frame.path).resolve(), frame.line))
301
302    def _show_execution_line(self, where: tuple[Path, int] | None):
303        if not hasattr(self, "tabs"):
304            return  # (the window being built: no editors yet)
305        for editor in self.editors():
306            editor.editor.set_execution_line(where[1] if where and not editor.untitled and editor.path == where[0]
307                                             else None)
308
309    def _debug_ended(self, why: str):
310        self._show_execution_line(None)
311        self.statusBar().showMessage(f"Debugging ended: {why}", 6000)
312
313    # ---------------------------------------------------------- breakpoints
314    def _debug_editor_added(self, editor: EditorTab):
315        """A file opened in the editor: its breakpoint column and breakpoints."""
316        code = editor.editor
317        code.debuggable = not editor.untitled and debuggable(editor.path)
318        code.update_gutter_width()
319        if code.debuggable:
320            key = str(editor.path)
321            code.set_breakpoint_lines(self.breakpoints.get(key, []), self.disabled_breakpoints.get(key, []))
322            code.set_breakpoint_problems(self._breakpoint_problems.get(key, {}))
323        code.breakpoints_changed.connect(lambda e=editor: self._editor_breakpoints_changed(e))
324        editor.path_changed.connect(lambda _p, e=editor: self._debug_path_changed(e))
325        code.document().contentsChanged.connect(
326            lambda c=code: c.breakpoint_lines() and self._breakpoint_list_timer.start())
327
328    def _debug_path_changed(self, editor: EditorTab):
329        """Saved under another name (or a new file saved): a source file or not now."""
330        editor.editor.debuggable = not editor.untitled and debuggable(editor.path)
331        editor.editor.refresh_gutter()
332        self._refresh_breakpoint_list()
333
334    def _editor_breakpoints_changed(self, editor: EditorTab):
335        if editor.untitled:
336            return
337        self._keep_breakpoints(editor)
338        key = str(editor.path)
339        self._breakpoint_problems.pop(key, None)
340        editor.editor.set_breakpoint_problems({})
341        self.debug_session.set_breakpoints(key, editor.editor.enabled_breakpoints())
342        self._update_breakpoint_actions()
343
344    def _keep_breakpoints(self, editor: EditorTab):
345        """An open file's breakpoints, where its edits moved them, kept (for
346        when it's closed, saved, or debugged)."""
347        if editor.untitled or not editor.editor.debuggable:
348            return
349        key, code = str(editor.path), editor.editor
350        for store, lines in ((self.breakpoints, code.breakpoint_lines()),
351                             (self.disabled_breakpoints, code.disabled_breakpoints())):
352            if lines:
353                store[key] = lines
354            else:
355                store.pop(key, None)
356
357    def _debug_editor_closing(self, editor: EditorTab):
358        self._keep_breakpoints(editor)
359
360    def _refresh_breakpoints(self):
361        for editor in self.editors():
362            self._keep_breakpoints(editor)
363
364    def all_breakpoints(self) -> dict[str, list[int]]:
365        """The breakpoints the debugger stops at: every enabled one (the open
366        files' where their edits moved them)."""
367        self._refresh_breakpoints()
368        out = {}
369        for path, lines in self.breakpoints.items():
370            off = set(self.disabled_breakpoints.get(path, ()))
371            enabled = [n for n in lines if n not in off]
372            if enabled:
373                out[path] = enabled
374        return out
375
376    def _breakpoints_checked(self, path: str, states: dict):
377        problems = self._breakpoint_problems.setdefault(path, {})
378        for line, state in states.items():
379            if state.verified:
380                problems.pop(line, None)
381            else:
382                problems[line] = state.message
383        editor = self._open_editor_for(Path(path))
384        if editor is not None:
385            editor.editor.set_breakpoint_problems(problems)
386        self._refresh_breakpoint_list()
387
388    def _cursor_line(self) -> tuple[EditorTab | None, int]:
389        editor = self.current_editor()
390        return editor, (editor.editor.textCursor().blockNumber() + 1 if editor is not None else 0)
391
392    def toggle_breakpoint(self):
393        """Debug → Toggle Breakpoint (F9): set or remove the one on the cursor's line."""
394        editor, line = self._cursor_line()
395        if editor is not None:
396            editor.editor.toggle_breakpoint(line)
397
398    def toggle_breakpoint_enabled(self):
399        """Debug → Enable/Disable Breakpoint (Ctrl+F9): the one on the cursor's
400        line disabled (kept, hollow) or enabled again; none there: one set."""
401        editor, line = self._cursor_line()
402        if editor is not None:
403            editor.editor.toggle_breakpoint_enabled(line)
404
405    def any_enabled_breakpoint(self) -> bool:
406        return bool(self.all_breakpoints())
407
408    def set_all_breakpoints_enabled(self, enabled: bool | None = None):
409        """Debug → Disable All Breakpoints (Enable All, when none is enabled):
410        every breakpoint, in every file, kept."""
411        if enabled is None:
412            enabled = not self.any_enabled_breakpoint()
413        self._refresh_breakpoints()
414        for path, lines in list(self.breakpoints.items()):
415            if enabled:
416                self.disabled_breakpoints.pop(path, None)
417            else:
418                self.disabled_breakpoints[path] = list(lines)
419            editor = self._open_editor_for(Path(path))
420            if editor is not None:
421                editor.editor.set_breakpoints_enabled(lines, enabled)  # (it tells the debugger)
422            else:
423                self.debug_session.set_breakpoints(path, list(lines) if enabled else [])
424        self._update_breakpoint_actions()
425
426    def remove_all_breakpoints(self):
427        self._refresh_breakpoints()
428        for path in list(self.breakpoints):
429            self.debug_session.set_breakpoints(path, [])
430        self.breakpoints.clear()
431        self.disabled_breakpoints.clear()
432        self._breakpoint_problems.clear()
433        for editor in self.editors():
434            editor.editor.set_breakpoint_lines([])
435        self._update_breakpoint_actions()
436
437    def _update_breakpoint_actions(self):
438        """Disable All, or Enable All when none is enabled (but some are disabled)."""
439        if not hasattr(self, "disable_all_breakpoints_action"):
440            return
441        none_enabled = not self.any_enabled_breakpoint() and any(self.breakpoints.values())
442        self.disable_all_breakpoints_action.setText("Enable All Breakpoints" if none_enabled
443                                                    else "Disable All Breakpoints")
444        self._refresh_breakpoint_list()
445
446    # ---------------------------------------------------------- the breakpoints list
447    def _refresh_breakpoint_list(self):
448        """The Debug panel's list: every breakpoint, its state, place and code."""
449        if not hasattr(self, "debug_panel"):
450            return
451        self._refresh_breakpoints()
452        root = self.manifest_path.parent.resolve() if self.manifest_path else None
453        entries = []
454        for path, lines in self.breakpoints.items():
455            off = set(self.disabled_breakpoints.get(path, ()))
456            problems = self._breakpoint_problems.get(path, {})
457            code = self._source_lines_of(path)
458            try:
459                shown = Path(path).relative_to(root).as_posix() if root else Path(path).name
460            except ValueError:  # (outside the project: a dependency's, say)
461                shown = Path(path).name
462            for line in lines:
463                state = DISABLED if line in off else UNPLACED if line in problems else ENABLED
464                entries.append(BreakpointEntry(path, line, state, f"{shown}:{line}",
465                                               code[line - 1] if 0 < line <= len(code) else "", problems.get(line, "")))
466        entries.sort(key=lambda e: (e.shown.rsplit(":", 1)[0].lower(), e.line))
467        self.debug_panel.breakpoints.set_entries(entries)
468
469    def _source_lines_of(self, path: str) -> list[str]:
470        """A file's lines: the editor's if it's open, else the file's (read once, until it changes)."""
471        editor = self._open_editor_for(Path(path))
472        if editor is not None:
473            return editor.editor.toPlainText().split("\n")
474        try:
475            mtime = Path(path).stat().st_mtime
476            cached = self._source_lines.get(path)
477            if cached is None or cached[0] != mtime:
478                cached = (mtime, Path(path).read_text(errors="replace").splitlines())
479                self._source_lines[path] = cached
480            return cached[1]
481        except OSError:
482            return []
483
484    def toggle_listed_breakpoint(self, path: str, line: int):
485        """The list's Enable/Disable Breakpoint."""
486        self.set_breakpoint_enabled(path, line, line in self.disabled_breakpoints.get(path, ()))
487
488    def set_breakpoint_enabled(self, path: str, line: int, enabled: bool):
489        """One breakpoint enabled or disabled, its file open or not."""
490        editor = self._open_editor_for(Path(path))
491        if editor is not None:
492            editor.editor.set_breakpoints_enabled([line], enabled)  # (it tells the debugger, and the list)
493            return
494        if line not in self.breakpoints.get(path, ()):
495            return
496        off = set(self.disabled_breakpoints.get(path, ()))
497        if enabled:
498            off.discard(line)
499        else:
500            off.add(line)
501        if off:
502            self.disabled_breakpoints[path] = sorted(off)
503        else:
504            self.disabled_breakpoints.pop(path, None)
505        self._closed_file_breakpoints_changed(path)
506
507    def remove_breakpoint(self, path: str, line: int):
508        """One breakpoint removed, its file open or not."""
509        editor = self._open_editor_for(Path(path))
510        if editor is not None:
511            if line in editor.editor.breakpoint_lines():
512                editor.editor.toggle_breakpoint(line)  # (it tells the debugger, and the list)
513            return
514        lines = [n for n in self.breakpoints.get(path, ()) if n != line]
515        off = [n for n in self.disabled_breakpoints.get(path, ()) if n != line]
516        for store, kept in ((self.breakpoints, lines), (self.disabled_breakpoints, off)):
517            if kept:
518                store[path] = kept
519            else:
520                store.pop(path, None)
521        self._closed_file_breakpoints_changed(path)
522
523    def _closed_file_breakpoints_changed(self, path: str):
524        self._breakpoint_problems.pop(path, None)
525        off = set(self.disabled_breakpoints.get(path, ()))
526        self.debug_session.set_breakpoints(path, [n for n in self.breakpoints.get(path, ()) if n not in off])
527        self._update_breakpoint_actions()
528
529    def set_session_breakpoints(self, entries: list):
530        """The breakpoints saved with the project's session (see _save_session)."""
531        self.breakpoints, self.disabled_breakpoints = {}, {}
532        for entry in entries or []:
533            if isinstance(entry, dict) and isinstance(entry.get("path"), str):
534                key = str(Path(entry["path"]))
535                lines = sorted({int(n) for n in entry.get("lines") or [] if isinstance(n, int) and n > 0})
536                off = sorted({int(n) for n in entry.get("disabled") or [] if isinstance(n, int)} & set(lines))
537                if lines:
538                    self.breakpoints[key] = lines
539                if off:
540                    self.disabled_breakpoints[key] = off
541        self._breakpoint_problems = {}
542        self._update_breakpoint_actions()
543
544    def session_breakpoints(self) -> list[dict]:
545        self._refresh_breakpoints()
546        return [{"path": path, "lines": lines, **({"disabled": self.disabled_breakpoints[path]}
547                                                  if self.disabled_breakpoints.get(path) else {})}
548                for path, lines in sorted(self.breakpoints.items()) if lines]
KINDS = {'program': (['run'], 'program'), 'test': (['test'], 'test'), 'example': (['run', '--example'], 'example')}
DEBUGGABLE = {'.cpp', '.for', '.f18', '.fi', '.ftn', '.hpp', '.cc', '.hh', '.f95', '.f90', '.f77', '.f', '.h', '.cxx', '.inc', '.f23', '.f08', '.fpp', '.c', '.f03'}
def debuggable(path: pathlib.Path) -> bool:
47def debuggable(path: Path) -> bool:
48    return path.suffix.lower() in DEBUGGABLE
class DebuggingMixin:
 51class DebuggingMixin:
 52    """Debugging (a part of `vibefpmgui.main_window.MainWindow`)."""
 53
 54    def _build_debugging(self):
 55        """The Debug menu, its actions and the Debug panel's widget."""
 56        self.debug_session = DebugSession(self)
 57        self.breakpoints: dict[str, list[int]] = {}  # file (absolute) → lines, the closed files' too
 58        self.disabled_breakpoints: dict[str, list[int]] = {}  # file → those of its lines disabled (kept, hollow)
 59        self._breakpoint_problems: dict[str, dict[int, str]] = {}  # file → {line: why it wasn't placed}
 60        self._debug_candidates: list[tuple[str, list[str]]] = []  # (name, command line) fpm said it would run
 61        self._debug_adapter = None  # the debugger the session being started uses
 62        self._debug_kind, self._debug_targets = "program", None  # what F5 debugs: the kind debugged last
 63
 64        def action(text, keys, slot, tip):
 65            item = QAction(text, self)
 66            if keys:
 67                item.setShortcut(QKeySequence(keys))
 68            item.setStatusTip(tip)
 69            item.setToolTip(f"{text} ({QKeySequence(keys).toString(QKeySequence.NativeText)})" if keys else text)
 70            item.triggered.connect(slot)
 71            return item
 72
 73        self.debug_start_action = action("Start Debugging", "F5", lambda: self.start_debugging(),
 74                                         "Build, then run the program under the debugger (Target: which one; "
 75                                         "a test or an example, if one was debugged last)")
 76        self.debug_test_action = action("Debug a Test…", "", lambda: self.start_debugging("test"),
 77                                        "Build the tests, then run one under the debugger (Target: which)")
 78        self.debug_example_action = action("Debug an Example…", "", lambda: self.start_debugging("example"),
 79                                           "Build the examples, then run one under the debugger (Target: which)")
 80        self.debug_stop_action = action("Stop Debugging", "Shift+F5", self.debug_session.stop,
 81                                        "End the program being debugged")
 82        self.debug_pause_action = action("Pause", "", self.debug_session.pause, "Pause the program where it is")
 83        self.step_over_action = action("Step Over", "F10", self.debug_session.step_over,
 84                                       "Run the current line, calls and all")
 85        self.step_in_action = action("Step Into", "F11", self.debug_session.step_in,
 86                                     "Into the procedure the current line calls")
 87        self.step_out_action = action("Step Out", "Shift+F11", self.debug_session.step_out,
 88                                      "On to where the current procedure returns")
 89        self.toggle_breakpoint_action = action("Toggle Breakpoint", "F9", self.toggle_breakpoint,
 90                                               "Set or remove a breakpoint on the cursor's line")
 91        self.enable_breakpoint_action = action("Enable/Disable Breakpoint", "Ctrl+F9", self.toggle_breakpoint_enabled,
 92                                               "Disable the breakpoint on the cursor's line (kept, drawn hollow: "
 93                                               "the debugger doesn't stop there), or enable it again")
 94        self.disable_all_breakpoints_action = action("Disable All Breakpoints", "",
 95                                                     lambda: self.set_all_breakpoints_enabled(),
 96                                                     "Every breakpoint, in every file, disabled (kept, hollow); "
 97                                                     "Enable All Breakpoints, when none is enabled")
 98        self.clear_breakpoints_action = action("Remove All Breakpoints", "", self.remove_all_breakpoints,
 99                                               "Every breakpoint, in every file")
100        menu = self.menuBar().addMenu("&Debug")
101        menu.addActions([self.debug_start_action, self.debug_test_action, self.debug_example_action])
102        menu.addSeparator()
103        menu.addActions([self.debug_pause_action, self.debug_stop_action])
104        menu.addSeparator()
105        menu.addActions([self.step_over_action, self.step_in_action, self.step_out_action])
106        menu.addSeparator()
107        menu.addActions([self.toggle_breakpoint_action, self.enable_breakpoint_action,
108                         self.disable_all_breakpoints_action, self.clear_breakpoints_action])
109        # Icons (the panel's buttons show only them; their tooltips say what they do), drawn
110        # in the theme's text color as the toolbar's are, and again when it changes.
111        for item, icon in ((self.debug_start_action, "run"), (self.debug_pause_action, "pause"),
112                           (self.step_over_action, "step-over"), (self.step_in_action, "step-into"),
113                           (self.step_out_action, "step-out"), (self.debug_stop_action, "stop")):
114            self.command_icons[item] = icon
115        self._update_command_icons()
116        self.debug_panel = DebugPanel(self.debug_session, [
117            self.debug_start_action, self.debug_pause_action, self.step_over_action, self.step_in_action,
118            self.step_out_action, self.debug_stop_action],
119            [self.disable_all_breakpoints_action, self.clear_breakpoints_action])
120        listed = self.debug_panel.breakpoints
121        listed.open_requested.connect(lambda path, line: self.navigate_to(path, line, 0))
122        listed.enabled_toggled.connect(self.toggle_listed_breakpoint)
123        listed.remove_requested.connect(self.remove_breakpoint)
124        listed.mono = self._mono_font()  # (the code: as the Search panel's lines; see window.view for changes)
125        listed.lines_for = self._source_lines_of  # (its syntax colors: the lines before for context)
126        self._source_lines: dict[str, tuple[float, list[str]]] = {}  # closed file -> (mtime, lines), for the list
127        # (An open file's edits move its breakpoints and change their code: the list again, once typing pauses.)
128        self._breakpoint_list_timer = QTimer(self)
129        self._breakpoint_list_timer.setSingleShot(True)
130        self._breakpoint_list_timer.setInterval(300)
131        self._breakpoint_list_timer.timeout.connect(self._refresh_breakpoint_list)
132        self.debug_panel.frame_chosen.connect(self._frame_chosen)
133        # gdb's "Breakpoint 3 at 0x…: file test/x.f90, line 155." (and any other location) open on a click.
134        self.debug_links = OutputLinks(self.debug_panel.console,
135                                       lambda: self.manifest_path.parent if self.manifest_path else None)
136        self.debug_links.open_requested.connect(lambda path, line, column: self.navigate_to(path, line, column,
137                                                                                           preview=True))
138        s = self.debug_session
139        s.state_changed.connect(self._debug_state_changed)
140        s.stopped_at.connect(self._debug_stopped)
141        s.breakpoints_checked.connect(self._breakpoints_checked)
142        s.ended.connect(self._debug_ended)
143        self._debug_state_changed(IDLE)
144
145    # ---------------------------------------------------------- starting
146    def start_debugging(self, kind: str | None = None, targets: list[str] | None = None):
147        """Debug → Start Debugging (Continue, while paused): a ``kind`` of
148        executable ("program", "test", "example"; None: the kind debugged
149        last), ``targets``: these (else the Target field's)."""
150        session = self.debug_session
151        if session.state == STOPPED and kind is None:
152            session.continue_()
153            return
154        if kind is None:
155            kind, targets = self._debug_kind, self._debug_targets
156        if session.is_active() or not self.manifest_path or self.busy():
157            return
158        adapter = find_adapter()
159        if adapter is None:
160            QMessageBox.information(self, "Start Debugging", NOT_FOUND)
161            return
162        if not self.selected_features and self.profile_combo.currentText() == "release" and QMessageBox.question(
163                self, "Start Debugging",
164                "The release profile builds without debug information: the debugger can't show lines or "
165                "variables. Debug anyway? (Choose the debug profile in Build options to debug properly.)",
166        ) != QMessageBox.Yes:
167            return
168        sub, _word = KINDS[kind]
169        args = self.debug_arguments(kind, targets, runner_command(collect=True))
170        if args is None:
171            return
172        self._debug_adapter, self._debug_candidates = adapter, []
173        self._debug_kind, self._debug_targets = kind, targets
174        self.debug_panel.begin("…")
175        self.debug_links.forget()  # (which files exist: looked at again)
176        self.statusBar().showMessage("Building, to debug…")
177        self.run_fpm(sub[:1], True, False, phase="debug", args=args)
178
179    def debug_arguments(self, kind: str, targets: list[str] | None, runner: str) -> list[str] | None:
180        """fpm's arguments to build and run a ``kind`` of executable (`KINDS`)
181        with the Build options, with debug information, through ``runner``
182        (None: a field's quotes don't match, which is said)."""
183        try:
184            args = self.build_arguments(KINDS[kind][0], True, targets=targets)
185        except ValueError as exc:
186            QMessageBox.warning(self, "Invalid arguments", str(exc))
187            return None
188        if not self.selected_features and self.profile_combo.currentIndex() <= 0:
189            # (No profile: fpm (0.13) then passes the compiler none of its flags, -g
190            # among them: no debug information. The debug profile's, then.)
191            args[1:1] = ["--profile", "debug"]
192        return with_runner(args, runner)
193
194    def debug_named(self, kind: str, name: str):
195        """A test (or program) to debug, picked in the Tests (or Run) panel."""
196        if self.debug_session.is_active():
197            self.debug_session.stop()
198        self.start_debugging(kind, [name])
199
200    def _debug_line(self, raw: str) -> str:
201        """A line of "fpm run --runner '… --collect'": the build's output, or a
202        program's command line (taken out, kept for _after_debug_build)."""
203        line = ansi.strip(raw)
204        match = EXE_RE.search(line)
205        if match is None:
206            if self._test_building:
207                self.build_monitor.feed_line(line)
208            return raw + "\n"
209        if self._test_building:  # (built: fpm runs the programs now)
210            self._test_building = False
211            if self._monitoring:
212                self.build_monitor.finish(0)
213                self._update_tree_marks()
214                self._monitoring = False
215        try:
216            argv = [str(a) for a in json.loads(match.group(2))]
217        except ValueError:
218            return raw + "\n"
219        self._debug_candidates.append((match.group(1), argv))
220        return ""
221
222    def _after_debug_build(self, code: int, stopped: bool):
223        """fpm has built the programs and said how it would run them: debug one."""
224        candidates = self._debug_candidates
225        if stopped or code != 0 or not candidates:
226            word = KINDS[self._debug_kind][1]
227            why = ("stopped" if stopped else "the build failed (see Build and Output)" if code != 0
228                   else f"fpm ran no {word} (Target: a {word}'s name)")
229            self.debug_panel.append("console", f"[not debugging: {why}]\n")
230            self.statusBar().showMessage(f"Not debugging: {why}", 6000)
231            return
232        if len(candidates) > 1:
233            names = [name for name, _argv in candidates]
234            name, ok = QInputDialog.getItem(self, "Start Debugging", f"Debug which {KINDS[self._debug_kind][1]}?",
235                                            names, 0, False)
236            if not ok:
237                return
238            candidates = [c for c in candidates if c[0] == name]
239        name, argv = candidates[0]
240        self.debug_panel.begin(name)
241        self.show_panel(self.debug_panel)
242        self.debug_session.start(self._debug_adapter, argv[0], argv[1:], str(self.manifest_path.parent),
243                                 self.all_breakpoints())
244        self.statusBar().showMessage(f"Debugging {name} ({self._debug_adapter.title})", 5000)
245
246    # ---------------------------------------------------------- while debugging
247    def _debug_state_changed(self, state: str):
248        active, stopped = state != IDLE, state == STOPPED
249        start = self.debug_start_action
250        start.setText("Continue" if stopped else "Start Debugging")
251        start.setToolTip(f"{start.text()} ({start.shortcut().toString(QKeySequence.NativeText)})")
252        icon = "continue" if stopped else "run"
253        if self.command_icons.get(start) != icon:
254            self.command_icons[start] = icon
255            start.setIcon(make_icon(icon, QApplication.palette().color(QPalette.WindowText)))
256        self.debug_start_action.setEnabled(stopped or not active)
257        self.debug_test_action.setEnabled(not active)
258        self.debug_example_action.setEnabled(not active)
259        self.debug_stop_action.setEnabled(active)
260        self.debug_pause_action.setEnabled(state == "running")
261        for step in (self.step_over_action, self.step_in_action, self.step_out_action):
262            step.setEnabled(stopped)
263        if not stopped:
264            self._show_execution_line(None)
265            self.debug_panel.breakpoints.show_hit(None)
266
267    def _debug_stopped(self, frames: list[Frame], why: str):
268        self.show_panel(self.debug_panel)
269        self._show_frame(self.debug_session.frame_index)
270        self.debug_panel.breakpoints.show_hit(self._stopped_at_breakpoint(frames))
271        if why == "exception":
272            self.statusBar().showMessage("Stopped: an error in the program (see the Debug panel's call stack)", 8000)
273
274    def _stopped_at_breakpoint(self, frames: list[Frame]) -> tuple[str, int] | None:
275        """The breakpoint the program stopped at (file, line as set), if any:
276        the one the debugger says it hit (maybe moved by it to the next line
277        with code), else one on the line it stopped at."""
278        self._refresh_breakpoints()
279        for path, line in self.debug_session.hit_breakpoints:
280            if line in self.breakpoints.get(path, ()):
281                return path, line
282        top = frames[0] if frames else None
283        if top is not None and top.path:
284            path = str(Path(top.path).resolve())
285            if top.line in self.breakpoints.get(path, ()):
286                return path, top.line
287        return None
288
289    def _frame_chosen(self, index: int):
290        self.debug_session.select_frame(index)
291        self._show_frame(index)
292
293    def _show_frame(self, index: int):
294        """A frame's place: its file opened at its line, the arrow there."""
295        frames = self.debug_session.frames
296        frame = frames[index] if 0 <= index < len(frames) else None
297        if frame is None or not frame.path or not Path(frame.path).is_file():
298            self._show_execution_line(None)
299            return
300        self.navigate_to(frame.path, frame.line, 0, record=False)
301        self._show_execution_line((Path(frame.path).resolve(), frame.line))
302
303    def _show_execution_line(self, where: tuple[Path, int] | None):
304        if not hasattr(self, "tabs"):
305            return  # (the window being built: no editors yet)
306        for editor in self.editors():
307            editor.editor.set_execution_line(where[1] if where and not editor.untitled and editor.path == where[0]
308                                             else None)
309
310    def _debug_ended(self, why: str):
311        self._show_execution_line(None)
312        self.statusBar().showMessage(f"Debugging ended: {why}", 6000)
313
314    # ---------------------------------------------------------- breakpoints
315    def _debug_editor_added(self, editor: EditorTab):
316        """A file opened in the editor: its breakpoint column and breakpoints."""
317        code = editor.editor
318        code.debuggable = not editor.untitled and debuggable(editor.path)
319        code.update_gutter_width()
320        if code.debuggable:
321            key = str(editor.path)
322            code.set_breakpoint_lines(self.breakpoints.get(key, []), self.disabled_breakpoints.get(key, []))
323            code.set_breakpoint_problems(self._breakpoint_problems.get(key, {}))
324        code.breakpoints_changed.connect(lambda e=editor: self._editor_breakpoints_changed(e))
325        editor.path_changed.connect(lambda _p, e=editor: self._debug_path_changed(e))
326        code.document().contentsChanged.connect(
327            lambda c=code: c.breakpoint_lines() and self._breakpoint_list_timer.start())
328
329    def _debug_path_changed(self, editor: EditorTab):
330        """Saved under another name (or a new file saved): a source file or not now."""
331        editor.editor.debuggable = not editor.untitled and debuggable(editor.path)
332        editor.editor.refresh_gutter()
333        self._refresh_breakpoint_list()
334
335    def _editor_breakpoints_changed(self, editor: EditorTab):
336        if editor.untitled:
337            return
338        self._keep_breakpoints(editor)
339        key = str(editor.path)
340        self._breakpoint_problems.pop(key, None)
341        editor.editor.set_breakpoint_problems({})
342        self.debug_session.set_breakpoints(key, editor.editor.enabled_breakpoints())
343        self._update_breakpoint_actions()
344
345    def _keep_breakpoints(self, editor: EditorTab):
346        """An open file's breakpoints, where its edits moved them, kept (for
347        when it's closed, saved, or debugged)."""
348        if editor.untitled or not editor.editor.debuggable:
349            return
350        key, code = str(editor.path), editor.editor
351        for store, lines in ((self.breakpoints, code.breakpoint_lines()),
352                             (self.disabled_breakpoints, code.disabled_breakpoints())):
353            if lines:
354                store[key] = lines
355            else:
356                store.pop(key, None)
357
358    def _debug_editor_closing(self, editor: EditorTab):
359        self._keep_breakpoints(editor)
360
361    def _refresh_breakpoints(self):
362        for editor in self.editors():
363            self._keep_breakpoints(editor)
364
365    def all_breakpoints(self) -> dict[str, list[int]]:
366        """The breakpoints the debugger stops at: every enabled one (the open
367        files' where their edits moved them)."""
368        self._refresh_breakpoints()
369        out = {}
370        for path, lines in self.breakpoints.items():
371            off = set(self.disabled_breakpoints.get(path, ()))
372            enabled = [n for n in lines if n not in off]
373            if enabled:
374                out[path] = enabled
375        return out
376
377    def _breakpoints_checked(self, path: str, states: dict):
378        problems = self._breakpoint_problems.setdefault(path, {})
379        for line, state in states.items():
380            if state.verified:
381                problems.pop(line, None)
382            else:
383                problems[line] = state.message
384        editor = self._open_editor_for(Path(path))
385        if editor is not None:
386            editor.editor.set_breakpoint_problems(problems)
387        self._refresh_breakpoint_list()
388
389    def _cursor_line(self) -> tuple[EditorTab | None, int]:
390        editor = self.current_editor()
391        return editor, (editor.editor.textCursor().blockNumber() + 1 if editor is not None else 0)
392
393    def toggle_breakpoint(self):
394        """Debug → Toggle Breakpoint (F9): set or remove the one on the cursor's line."""
395        editor, line = self._cursor_line()
396        if editor is not None:
397            editor.editor.toggle_breakpoint(line)
398
399    def toggle_breakpoint_enabled(self):
400        """Debug → Enable/Disable Breakpoint (Ctrl+F9): the one on the cursor's
401        line disabled (kept, hollow) or enabled again; none there: one set."""
402        editor, line = self._cursor_line()
403        if editor is not None:
404            editor.editor.toggle_breakpoint_enabled(line)
405
406    def any_enabled_breakpoint(self) -> bool:
407        return bool(self.all_breakpoints())
408
409    def set_all_breakpoints_enabled(self, enabled: bool | None = None):
410        """Debug → Disable All Breakpoints (Enable All, when none is enabled):
411        every breakpoint, in every file, kept."""
412        if enabled is None:
413            enabled = not self.any_enabled_breakpoint()
414        self._refresh_breakpoints()
415        for path, lines in list(self.breakpoints.items()):
416            if enabled:
417                self.disabled_breakpoints.pop(path, None)
418            else:
419                self.disabled_breakpoints[path] = list(lines)
420            editor = self._open_editor_for(Path(path))
421            if editor is not None:
422                editor.editor.set_breakpoints_enabled(lines, enabled)  # (it tells the debugger)
423            else:
424                self.debug_session.set_breakpoints(path, list(lines) if enabled else [])
425        self._update_breakpoint_actions()
426
427    def remove_all_breakpoints(self):
428        self._refresh_breakpoints()
429        for path in list(self.breakpoints):
430            self.debug_session.set_breakpoints(path, [])
431        self.breakpoints.clear()
432        self.disabled_breakpoints.clear()
433        self._breakpoint_problems.clear()
434        for editor in self.editors():
435            editor.editor.set_breakpoint_lines([])
436        self._update_breakpoint_actions()
437
438    def _update_breakpoint_actions(self):
439        """Disable All, or Enable All when none is enabled (but some are disabled)."""
440        if not hasattr(self, "disable_all_breakpoints_action"):
441            return
442        none_enabled = not self.any_enabled_breakpoint() and any(self.breakpoints.values())
443        self.disable_all_breakpoints_action.setText("Enable All Breakpoints" if none_enabled
444                                                    else "Disable All Breakpoints")
445        self._refresh_breakpoint_list()
446
447    # ---------------------------------------------------------- the breakpoints list
448    def _refresh_breakpoint_list(self):
449        """The Debug panel's list: every breakpoint, its state, place and code."""
450        if not hasattr(self, "debug_panel"):
451            return
452        self._refresh_breakpoints()
453        root = self.manifest_path.parent.resolve() if self.manifest_path else None
454        entries = []
455        for path, lines in self.breakpoints.items():
456            off = set(self.disabled_breakpoints.get(path, ()))
457            problems = self._breakpoint_problems.get(path, {})
458            code = self._source_lines_of(path)
459            try:
460                shown = Path(path).relative_to(root).as_posix() if root else Path(path).name
461            except ValueError:  # (outside the project: a dependency's, say)
462                shown = Path(path).name
463            for line in lines:
464                state = DISABLED if line in off else UNPLACED if line in problems else ENABLED
465                entries.append(BreakpointEntry(path, line, state, f"{shown}:{line}",
466                                               code[line - 1] if 0 < line <= len(code) else "", problems.get(line, "")))
467        entries.sort(key=lambda e: (e.shown.rsplit(":", 1)[0].lower(), e.line))
468        self.debug_panel.breakpoints.set_entries(entries)
469
470    def _source_lines_of(self, path: str) -> list[str]:
471        """A file's lines: the editor's if it's open, else the file's (read once, until it changes)."""
472        editor = self._open_editor_for(Path(path))
473        if editor is not None:
474            return editor.editor.toPlainText().split("\n")
475        try:
476            mtime = Path(path).stat().st_mtime
477            cached = self._source_lines.get(path)
478            if cached is None or cached[0] != mtime:
479                cached = (mtime, Path(path).read_text(errors="replace").splitlines())
480                self._source_lines[path] = cached
481            return cached[1]
482        except OSError:
483            return []
484
485    def toggle_listed_breakpoint(self, path: str, line: int):
486        """The list's Enable/Disable Breakpoint."""
487        self.set_breakpoint_enabled(path, line, line in self.disabled_breakpoints.get(path, ()))
488
489    def set_breakpoint_enabled(self, path: str, line: int, enabled: bool):
490        """One breakpoint enabled or disabled, its file open or not."""
491        editor = self._open_editor_for(Path(path))
492        if editor is not None:
493            editor.editor.set_breakpoints_enabled([line], enabled)  # (it tells the debugger, and the list)
494            return
495        if line not in self.breakpoints.get(path, ()):
496            return
497        off = set(self.disabled_breakpoints.get(path, ()))
498        if enabled:
499            off.discard(line)
500        else:
501            off.add(line)
502        if off:
503            self.disabled_breakpoints[path] = sorted(off)
504        else:
505            self.disabled_breakpoints.pop(path, None)
506        self._closed_file_breakpoints_changed(path)
507
508    def remove_breakpoint(self, path: str, line: int):
509        """One breakpoint removed, its file open or not."""
510        editor = self._open_editor_for(Path(path))
511        if editor is not None:
512            if line in editor.editor.breakpoint_lines():
513                editor.editor.toggle_breakpoint(line)  # (it tells the debugger, and the list)
514            return
515        lines = [n for n in self.breakpoints.get(path, ()) if n != line]
516        off = [n for n in self.disabled_breakpoints.get(path, ()) if n != line]
517        for store, kept in ((self.breakpoints, lines), (self.disabled_breakpoints, off)):
518            if kept:
519                store[path] = kept
520            else:
521                store.pop(path, None)
522        self._closed_file_breakpoints_changed(path)
523
524    def _closed_file_breakpoints_changed(self, path: str):
525        self._breakpoint_problems.pop(path, None)
526        off = set(self.disabled_breakpoints.get(path, ()))
527        self.debug_session.set_breakpoints(path, [n for n in self.breakpoints.get(path, ()) if n not in off])
528        self._update_breakpoint_actions()
529
530    def set_session_breakpoints(self, entries: list):
531        """The breakpoints saved with the project's session (see _save_session)."""
532        self.breakpoints, self.disabled_breakpoints = {}, {}
533        for entry in entries or []:
534            if isinstance(entry, dict) and isinstance(entry.get("path"), str):
535                key = str(Path(entry["path"]))
536                lines = sorted({int(n) for n in entry.get("lines") or [] if isinstance(n, int) and n > 0})
537                off = sorted({int(n) for n in entry.get("disabled") or [] if isinstance(n, int)} & set(lines))
538                if lines:
539                    self.breakpoints[key] = lines
540                if off:
541                    self.disabled_breakpoints[key] = off
542        self._breakpoint_problems = {}
543        self._update_breakpoint_actions()
544
545    def session_breakpoints(self) -> list[dict]:
546        self._refresh_breakpoints()
547        return [{"path": path, "lines": lines, **({"disabled": self.disabled_breakpoints[path]}
548                                                  if self.disabled_breakpoints.get(path) else {})}
549                for path, lines in sorted(self.breakpoints.items()) if lines]

Debugging (a part of vibefpmgui.main_window.MainWindow).

def start_debugging(self, kind: str | None = None, targets: list[str] | None = None):
146    def start_debugging(self, kind: str | None = None, targets: list[str] | None = None):
147        """Debug → Start Debugging (Continue, while paused): a ``kind`` of
148        executable ("program", "test", "example"; None: the kind debugged
149        last), ``targets``: these (else the Target field's)."""
150        session = self.debug_session
151        if session.state == STOPPED and kind is None:
152            session.continue_()
153            return
154        if kind is None:
155            kind, targets = self._debug_kind, self._debug_targets
156        if session.is_active() or not self.manifest_path or self.busy():
157            return
158        adapter = find_adapter()
159        if adapter is None:
160            QMessageBox.information(self, "Start Debugging", NOT_FOUND)
161            return
162        if not self.selected_features and self.profile_combo.currentText() == "release" and QMessageBox.question(
163                self, "Start Debugging",
164                "The release profile builds without debug information: the debugger can't show lines or "
165                "variables. Debug anyway? (Choose the debug profile in Build options to debug properly.)",
166        ) != QMessageBox.Yes:
167            return
168        sub, _word = KINDS[kind]
169        args = self.debug_arguments(kind, targets, runner_command(collect=True))
170        if args is None:
171            return
172        self._debug_adapter, self._debug_candidates = adapter, []
173        self._debug_kind, self._debug_targets = kind, targets
174        self.debug_panel.begin("…")
175        self.debug_links.forget()  # (which files exist: looked at again)
176        self.statusBar().showMessage("Building, to debug…")
177        self.run_fpm(sub[:1], True, False, phase="debug", args=args)

Debug → Start Debugging (Continue, while paused): a kind of executable ("program", "test", "example"; None: the kind debugged last), targets: these (else the Target field's).

def debug_arguments( self, kind: str, targets: list[str] | None, runner: str) -> list[str] | None:
179    def debug_arguments(self, kind: str, targets: list[str] | None, runner: str) -> list[str] | None:
180        """fpm's arguments to build and run a ``kind`` of executable (`KINDS`)
181        with the Build options, with debug information, through ``runner``
182        (None: a field's quotes don't match, which is said)."""
183        try:
184            args = self.build_arguments(KINDS[kind][0], True, targets=targets)
185        except ValueError as exc:
186            QMessageBox.warning(self, "Invalid arguments", str(exc))
187            return None
188        if not self.selected_features and self.profile_combo.currentIndex() <= 0:
189            # (No profile: fpm (0.13) then passes the compiler none of its flags, -g
190            # among them: no debug information. The debug profile's, then.)
191            args[1:1] = ["--profile", "debug"]
192        return with_runner(args, runner)

fpm's arguments to build and run a kind of executable (KINDS) with the Build options, with debug information, through runner (None: a field's quotes don't match, which is said).

def debug_named(self, kind: str, name: str):
194    def debug_named(self, kind: str, name: str):
195        """A test (or program) to debug, picked in the Tests (or Run) panel."""
196        if self.debug_session.is_active():
197            self.debug_session.stop()
198        self.start_debugging(kind, [name])

A test (or program) to debug, picked in the Tests (or Run) panel.

def all_breakpoints(self) -> dict[str, list[int]]:
365    def all_breakpoints(self) -> dict[str, list[int]]:
366        """The breakpoints the debugger stops at: every enabled one (the open
367        files' where their edits moved them)."""
368        self._refresh_breakpoints()
369        out = {}
370        for path, lines in self.breakpoints.items():
371            off = set(self.disabled_breakpoints.get(path, ()))
372            enabled = [n for n in lines if n not in off]
373            if enabled:
374                out[path] = enabled
375        return out

The breakpoints the debugger stops at: every enabled one (the open files' where their edits moved them).

def toggle_breakpoint(self):
393    def toggle_breakpoint(self):
394        """Debug → Toggle Breakpoint (F9): set or remove the one on the cursor's line."""
395        editor, line = self._cursor_line()
396        if editor is not None:
397            editor.editor.toggle_breakpoint(line)

Debug → Toggle Breakpoint (F9): set or remove the one on the cursor's line.

def toggle_breakpoint_enabled(self):
399    def toggle_breakpoint_enabled(self):
400        """Debug → Enable/Disable Breakpoint (Ctrl+F9): the one on the cursor's
401        line disabled (kept, hollow) or enabled again; none there: one set."""
402        editor, line = self._cursor_line()
403        if editor is not None:
404            editor.editor.toggle_breakpoint_enabled(line)

Debug → Enable/Disable Breakpoint (Ctrl+F9): the one on the cursor's line disabled (kept, hollow) or enabled again; none there: one set.

def any_enabled_breakpoint(self) -> bool:
406    def any_enabled_breakpoint(self) -> bool:
407        return bool(self.all_breakpoints())
def set_all_breakpoints_enabled(self, enabled: bool | None = None):
409    def set_all_breakpoints_enabled(self, enabled: bool | None = None):
410        """Debug → Disable All Breakpoints (Enable All, when none is enabled):
411        every breakpoint, in every file, kept."""
412        if enabled is None:
413            enabled = not self.any_enabled_breakpoint()
414        self._refresh_breakpoints()
415        for path, lines in list(self.breakpoints.items()):
416            if enabled:
417                self.disabled_breakpoints.pop(path, None)
418            else:
419                self.disabled_breakpoints[path] = list(lines)
420            editor = self._open_editor_for(Path(path))
421            if editor is not None:
422                editor.editor.set_breakpoints_enabled(lines, enabled)  # (it tells the debugger)
423            else:
424                self.debug_session.set_breakpoints(path, list(lines) if enabled else [])
425        self._update_breakpoint_actions()

Debug → Disable All Breakpoints (Enable All, when none is enabled): every breakpoint, in every file, kept.

def remove_all_breakpoints(self):
427    def remove_all_breakpoints(self):
428        self._refresh_breakpoints()
429        for path in list(self.breakpoints):
430            self.debug_session.set_breakpoints(path, [])
431        self.breakpoints.clear()
432        self.disabled_breakpoints.clear()
433        self._breakpoint_problems.clear()
434        for editor in self.editors():
435            editor.editor.set_breakpoint_lines([])
436        self._update_breakpoint_actions()
def toggle_listed_breakpoint(self, path: str, line: int):
485    def toggle_listed_breakpoint(self, path: str, line: int):
486        """The list's Enable/Disable Breakpoint."""
487        self.set_breakpoint_enabled(path, line, line in self.disabled_breakpoints.get(path, ()))

The list's Enable/Disable Breakpoint.

def set_breakpoint_enabled(self, path: str, line: int, enabled: bool):
489    def set_breakpoint_enabled(self, path: str, line: int, enabled: bool):
490        """One breakpoint enabled or disabled, its file open or not."""
491        editor = self._open_editor_for(Path(path))
492        if editor is not None:
493            editor.editor.set_breakpoints_enabled([line], enabled)  # (it tells the debugger, and the list)
494            return
495        if line not in self.breakpoints.get(path, ()):
496            return
497        off = set(self.disabled_breakpoints.get(path, ()))
498        if enabled:
499            off.discard(line)
500        else:
501            off.add(line)
502        if off:
503            self.disabled_breakpoints[path] = sorted(off)
504        else:
505            self.disabled_breakpoints.pop(path, None)
506        self._closed_file_breakpoints_changed(path)

One breakpoint enabled or disabled, its file open or not.

def remove_breakpoint(self, path: str, line: int):
508    def remove_breakpoint(self, path: str, line: int):
509        """One breakpoint removed, its file open or not."""
510        editor = self._open_editor_for(Path(path))
511        if editor is not None:
512            if line in editor.editor.breakpoint_lines():
513                editor.editor.toggle_breakpoint(line)  # (it tells the debugger, and the list)
514            return
515        lines = [n for n in self.breakpoints.get(path, ()) if n != line]
516        off = [n for n in self.disabled_breakpoints.get(path, ()) if n != line]
517        for store, kept in ((self.breakpoints, lines), (self.disabled_breakpoints, off)):
518            if kept:
519                store[path] = kept
520            else:
521                store.pop(path, None)
522        self._closed_file_breakpoints_changed(path)

One breakpoint removed, its file open or not.

def set_session_breakpoints(self, entries: list):
530    def set_session_breakpoints(self, entries: list):
531        """The breakpoints saved with the project's session (see _save_session)."""
532        self.breakpoints, self.disabled_breakpoints = {}, {}
533        for entry in entries or []:
534            if isinstance(entry, dict) and isinstance(entry.get("path"), str):
535                key = str(Path(entry["path"]))
536                lines = sorted({int(n) for n in entry.get("lines") or [] if isinstance(n, int) and n > 0})
537                off = sorted({int(n) for n in entry.get("disabled") or [] if isinstance(n, int)} & set(lines))
538                if lines:
539                    self.breakpoints[key] = lines
540                if off:
541                    self.disabled_breakpoints[key] = off
542        self._breakpoint_problems = {}
543        self._update_breakpoint_actions()

The breakpoints saved with the project's session (see _save_session).

def session_breakpoints(self) -> list[dict]:
545    def session_breakpoints(self) -> list[dict]:
546        self._refresh_breakpoints()
547        return [{"path": path, "lines": lines, **({"disabled": self.disabled_breakpoints[path]}
548                                                  if self.disabled_breakpoints.get(path) else {})}
549                for path, lines in sorted(self.breakpoints.items()) if lines]