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

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(): 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) 

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(): 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]) 

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: 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 "" 

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: 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) 

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": 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) 

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, ()): 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 

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: 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() 

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: 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() 

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: 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) 

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: 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) 

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: 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() 

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"): 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() 

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"): 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) 

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, ()): 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) 

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(): 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) 

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): 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() 

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]