Coverage for vibefpmgui/window/debugging.py: 93%
394 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-01 20:51 +0000
1"""Debugging (see plan/debugger.md): the Debug menu, the Debug panel, and
2breakpoints in the editors' gutters.
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.
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"""
18from __future__ import annotations
20import json
21from pathlib import Path
23from qtpy.QtCore import QTimer
24from qtpy.QtGui import QAction, QKeySequence, QPalette
25from qtpy.QtWidgets import QApplication, QInputDialog, QMessageBox
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
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")}
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"}
46def debuggable(path: Path) -> bool:
47 return path.suffix.lower() in DEBUGGABLE
50class DebuggingMixin:
51 """Debugging (a part of `vibefpmgui.main_window.MainWindow`)."""
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
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
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)
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(): 155 ↛ 156line 155 didn't jump to line 156 because the condition on line 155 was never true
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: 169 ↛ 170line 169 didn't jump to line 170 because the condition on line 169 was never true
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)
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)
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(): 195 ↛ 196line 195 didn't jump to line 196 because the condition on line 195 was never true
196 self.debug_session.stop()
197 self.start_debugging(kind, [name])
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: 210 ↛ 214line 210 didn't jump to line 214 because the condition on line 210 was always true
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 ""
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: 235 ↛ 236line 235 didn't jump to line 236 because the condition on line 235 was never true
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)
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)
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": 270 ↛ 271line 270 didn't jump to line 271 because the condition on line 270 was never true
271 self.statusBar().showMessage("Stopped: an error in the program (see the Debug panel's call stack)", 8000)
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, ()): 279 ↛ 278line 279 didn't jump to line 278 because the condition on line 279 was always true
280 return path, line
281 top = frames[0] if frames else None
282 if top is not None and top.path: 282 ↛ 286line 282 didn't jump to line 286 because the condition on line 282 was always true
283 path = str(Path(top.path).resolve())
284 if top.line in self.breakpoints.get(path, ()):
285 return path, top.line
286 return None
288 def _frame_chosen(self, index: int):
289 self.debug_session.select_frame(index)
290 self._show_frame(index)
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))
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)
309 def _debug_ended(self, why: str):
310 self._show_execution_line(None)
311 self.statusBar().showMessage(f"Debugging ended: {why}", 6000)
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())
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()
334 def _editor_breakpoints_changed(self, editor: EditorTab):
335 if editor.untitled: 335 ↛ 336line 335 didn't jump to line 336 because the condition on line 335 was never true
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()
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)
357 def _debug_editor_closing(self, editor: EditorTab):
358 self._keep_breakpoints(editor)
360 def _refresh_breakpoints(self):
361 for editor in self.editors():
362 self._keep_breakpoints(editor)
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
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: 384 ↛ 386line 384 didn't jump to line 386 because the condition on line 384 was always true
385 editor.editor.set_breakpoint_problems(problems)
386 self._refresh_breakpoint_list()
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)
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: 395 ↛ exitline 395 didn't return from function 'toggle_breakpoint' because the condition on line 395 was always true
396 editor.editor.toggle_breakpoint(line)
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: 402 ↛ exitline 402 didn't return from function 'toggle_breakpoint_enabled' because the condition on line 402 was always true
403 editor.editor.toggle_breakpoint_enabled(line)
405 def any_enabled_breakpoint(self) -> bool:
406 return bool(self.all_breakpoints())
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: 411 ↛ 413line 411 didn't jump to line 413 because the condition on line 411 was always true
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()
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()
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"): 439 ↛ 440line 439 didn't jump to line 440 because the condition on line 439 was never true
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()
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"): 449 ↛ 450line 449 didn't jump to line 450 because the condition on line 449 was never true
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)
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 []
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, ()))
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, ()): 494 ↛ 495line 494 didn't jump to line 495 because the condition on line 494 was never true
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)
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(): 511 ↛ 513line 511 didn't jump to line 513 because the condition on line 511 was always true
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)
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()
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): 533 ↛ 532line 533 didn't jump to line 532 because the condition on line 533 was always true
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: 537 ↛ 539line 537 didn't jump to line 539 because the condition on line 537 was always true
538 self.breakpoints[key] = lines
539 if off:
540 self.disabled_breakpoints[key] = off
541 self._breakpoint_problems = {}
542 self._update_breakpoint_actions()
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]