Skip to main content

anchor_cli/debugger/
tui.rs

1//! `anchor debugger` TUI.
2//!
3//! Two screens drive the whole experience:
4//!
5//! - [`Screen::Picker`] — startup. Lists every captured `(test, tx)` pair
6//!   with total CU; user picks one to step into. Also reachable from the
7//!   stepper via `t`, so jumping across txs within a session is one keypress.
8//! - [`Screen::Stepper`] — foundry-style instruction stepper with panes for
9//!   the instruction list, registers, call stack, and source.
10//!
11//! Keybinds mirror `forge test --debug` where it makes sense:
12//!
13//! ```text
14//! j / k / ↑ / ↓       step ± 1 instruction
15//! s / a               step over next/prev call
16//! c / C               previous / next CPI invocation
17//! g / G               first / last step in current node
18//! t                   return to tx picker (or select in picker)
19//! K / J               scroll call stack
20//! q                   quit
21//! 10k                 repeat count (e.g. move up 10 steps)
22//! ```
23
24use {
25    super::{
26        highlight::highlight_rust,
27        model::{DebugSession, DebugStep, DebugTx},
28        path_label::{classify, PathLabel},
29    },
30    crossterm::{
31        event::{
32            self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyModifiers,
33        },
34        execute,
35        terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
36    },
37    ratatui::{
38        backend::CrosstermBackend,
39        layout::{Alignment, Constraint, Direction, Layout, Rect},
40        style::{Color, Modifier, Style},
41        text::{Line, Span},
42        widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap},
43        Frame, Terminal,
44    },
45    std::{collections::HashMap, io, path::PathBuf},
46};
47
48type DebugTerm = Terminal<CrosstermBackend<io::Stdout>>;
49
50/// Run the debugger TUI over a fully-populated [`DebugSession`]. Blocks
51/// until the user hits `q`.
52pub fn run(session: DebugSession) -> anyhow::Result<()> {
53    if session.txs.is_empty() {
54        anyhow::bail!(
55            "no traces to debug — did your tests call `anchor_v2_testing::svm()` and complete at \
56             least one transaction?"
57        );
58    }
59
60    let backend = CrosstermBackend::new(io::stdout());
61    let terminal = Terminal::new(backend)?;
62    let mut guard = TerminalGuard::new(terminal);
63    let mut app = App::new(session);
64    loop {
65        guard.term.draw(|f| app.draw(f))?;
66        // Block for the first event of the burst, then drain anything else
67        // crossterm has buffered before the next redraw. Holding j/k
68        // generates one event per OS keyboard-repeat tick, which used to
69        // trigger one full redraw per event — now they collapse into one
70        // step delta and one render. Safety cap of 256 events keeps a
71        // pathological event flood from starving the redraw entirely.
72        let mut flow = app.handle(event::read()?);
73        let mut drained = 0;
74        while flow == Flow::Continue && drained < 256 && event::poll(std::time::Duration::ZERO)? {
75            flow = app.handle(event::read()?);
76            drained += 1;
77        }
78        if flow == Flow::Quit {
79            break;
80        }
81    }
82    Ok(())
83}
84
85enum Screen {
86    Picker,
87    Stepper,
88}
89
90#[derive(PartialEq, Eq)]
91enum Flow {
92    Continue,
93    Quit,
94}
95
96struct App {
97    session: DebugSession,
98    screen: Screen,
99    /// Picker list state.
100    picker: ListState,
101    /// Index into `session.txs`.
102    current_tx: usize,
103    /// Index into `session.txs[current_tx].nodes` — the active call context
104    /// (0 = top-level, 1.. = CPIs in order).
105    current_node: usize,
106    /// Index into the active node's `steps`.
107    current_step: usize,
108    /// Digit buffer for `10k`-style repeats.
109    key_buffer: String,
110    /// File contents cache. Source files are read on first access (path
111    /// resolution + disk read) and kept for the session — frame-rate
112    /// stepping was bottlenecked on `read_to_string` per redraw.
113    file_cache: HashMap<PathBuf, FileEntry>,
114    /// Per-(file, line) highlighted-span cache. Avoids re-running syntect
115    /// on the same source line every redraw while you hold j/k.
116    highlight_cache: HashMap<(PathBuf, u32), Vec<Span<'static>>>,
117    /// Per-path label cache (crate / stdlib / workspace classification).
118    /// `classify` walks Cargo.toml siblings on workspace files, so we only
119    /// want to do that once per file across the whole session.
120    label_cache: HashMap<PathBuf, PathLabel>,
121    /// Pre-baked picker rows: a flat sequence of `Header(test_name)` /
122    /// `Tx(tx_idx)` entries, sorted so each test's children sit under it.
123    /// Built once at App-init from the (test, tx)-sorted `session.txs`.
124    picker_rows: Vec<PickerRow>,
125}
126
127#[derive(Clone)]
128enum PickerRow {
129    /// Test name banner — non-selectable. Skipped by j/k navigation.
130    Header(String),
131    /// Selectable row pointing back into `session.txs`.
132    Tx(usize),
133}
134
135/// One entry in [`App::file_cache`]: either the file's lines, or the error
136/// we hit reading it (so we don't retry the disk every frame for missing
137/// stdlib paths).
138enum FileEntry {
139    Loaded(Vec<String>),
140    Missing(String),
141}
142
143impl App {
144    fn new(session: DebugSession) -> Self {
145        // Build the grouped row list once: emit a header row whenever the
146        // test_name changes, then a Tx row per tx in that group. Picker
147        // selection navigates this list with `next_selectable_*` to skip
148        // header rows so the user never lands on one.
149        let mut picker_rows: Vec<PickerRow> = Vec::with_capacity(session.txs.len() * 2);
150        let mut last_test: Option<&str> = None;
151        for (i, tx) in session.txs.iter().enumerate() {
152            if last_test != Some(tx.test_name.as_str()) {
153                picker_rows.push(PickerRow::Header(tx.test_name.clone()));
154                last_test = Some(tx.test_name.as_str());
155            }
156            picker_rows.push(PickerRow::Tx(i));
157        }
158        let initial = picker_rows
159            .iter()
160            .position(|r| matches!(r, PickerRow::Tx(_)))
161            .unwrap_or(0);
162
163        let mut picker = ListState::default();
164        picker.select(Some(initial));
165        Self {
166            session,
167            screen: Screen::Picker,
168            picker,
169            current_tx: 0,
170            current_node: 0,
171            current_step: 0,
172            key_buffer: String::new(),
173            file_cache: HashMap::new(),
174            highlight_cache: HashMap::new(),
175            label_cache: HashMap::new(),
176            picker_rows,
177        }
178    }
179
180    fn draw(&mut self, f: &mut Frame<'_>) {
181        match self.screen {
182            Screen::Picker => self.draw_picker(f),
183            Screen::Stepper => self.draw_stepper(f),
184        }
185    }
186
187    fn handle(&mut self, ev: Event) -> Flow {
188        match (ev, &self.screen) {
189            (Event::Key(k), Screen::Picker) => self.handle_picker_key(k),
190            (Event::Key(k), Screen::Stepper) => self.handle_stepper_key(k),
191            _ => Flow::Continue,
192        }
193    }
194
195    // --- picker --------------------------------------------------------------
196
197    fn draw_picker(&mut self, f: &mut Frame<'_>) {
198        let area = f.area();
199        let [title, list, footer] = Layout::new(
200            Direction::Vertical,
201            [
202                Constraint::Length(3),
203                Constraint::Min(3),
204                Constraint::Length(3),
205            ],
206        )
207        .areas(area);
208
209        let title_block = Paragraph::new(Line::from(vec![
210            Span::styled("anchor debugger", Style::new().add_modifier(Modifier::BOLD)),
211            Span::raw(format!("  —  {} transaction(s)", self.session.txs.len())),
212        ]))
213        .block(Block::default().borders(Borders::ALL));
214        f.render_widget(title_block, title);
215
216        let test_count = self
217            .picker_rows
218            .iter()
219            .filter(|r| matches!(r, PickerRow::Header(_)))
220            .count();
221        let items: Vec<ListItem> = self
222            .picker_rows
223            .iter()
224            .map(|row| match row {
225                PickerRow::Header(name) => {
226                    // Compact header: indented less than the tx rows so it
227                    // visually sits "above" them. Dim by default so the
228                    // selected tx still pops.
229                    ListItem::new(Line::from(vec![Span::styled(
230                        format!("{name}"),
231                        Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD),
232                    )]))
233                    // Disable selection-highlight on header rows so j/k
234                    // visually skips them even mid-frame; the
235                    // `next_selectable_*` helpers do the actual skipping.
236                    .style(Style::new())
237                }
238                PickerRow::Tx(idx) => {
239                    let tx = &self.session.txs[*idx];
240                    let top = tx
241                        .nodes
242                        .first()
243                        .map(|n| n.program_label.as_str())
244                        .unwrap_or("");
245                    let cpis = tx.nodes.len().saturating_sub(1);
246                    let cpi_badge = if cpis > 0 {
247                        format!("  +{cpis} CPI")
248                    } else {
249                        String::new()
250                    };
251                    ListItem::new(Line::from(vec![
252                        // Tree-style indent so the parent test reads as a
253                        // group header.
254                        Span::styled("  ├─ ", Style::new().fg(Color::DarkGray)),
255                        Span::styled(
256                            format!("tx{:<3}", tx.tx_seq),
257                            Style::new().fg(Color::Yellow),
258                        ),
259                        Span::raw(format!("  {:>8} CU  ", tx.total_cu)),
260                        Span::raw(top.to_string()),
261                        Span::styled(cpi_badge, Style::new().fg(Color::Magenta)),
262                    ]))
263                }
264            })
265            .collect();
266
267        let title = format!(
268            " {} test(s), {} tx(s) — select one to step into ",
269            test_count,
270            self.session.txs.len()
271        );
272        let list_widget = List::new(items)
273            .block(Block::default().title(title).borders(Borders::ALL))
274            .highlight_style(
275                Style::new()
276                    .bg(Color::DarkGray)
277                    .add_modifier(Modifier::BOLD),
278            )
279            .highlight_symbol("> ");
280        f.render_stateful_widget(list_widget, list, &mut self.picker);
281
282        let help = Paragraph::new("j/k or ↑/↓  select    enter/t  open    q  quit")
283            .block(Block::default().borders(Borders::ALL))
284            .alignment(Alignment::Center);
285        f.render_widget(help, footer);
286    }
287
288    fn handle_picker_key(&mut self, k: KeyEvent) -> Flow {
289        match k.code {
290            KeyCode::Char('q') | KeyCode::Esc => return Flow::Quit,
291            KeyCode::Char('j') | KeyCode::Down => self.picker_next(),
292            KeyCode::Char('k') | KeyCode::Up => self.picker_prev(),
293            KeyCode::Char('g') | KeyCode::Home => {
294                self.picker.select(self.first_selectable());
295            }
296            KeyCode::Char('G') | KeyCode::End => {
297                self.picker.select(self.last_selectable());
298            }
299            KeyCode::Enter | KeyCode::Char('t') | KeyCode::Char('l') | KeyCode::Right => {
300                self.open_selected();
301            }
302            _ => {}
303        }
304        Flow::Continue
305    }
306
307    /// Step the picker down to the next `Tx` row, skipping headers.
308    /// Stops at the last selectable row instead of wrapping.
309    fn picker_next(&mut self) {
310        let from = self.picker.selected().unwrap_or(0);
311        let next = self
312            .picker_rows
313            .iter()
314            .enumerate()
315            .skip(from + 1)
316            .find(|(_, r)| matches!(r, PickerRow::Tx(_)))
317            .map(|(i, _)| i)
318            .unwrap_or(from);
319        self.picker.select(Some(next));
320    }
321
322    fn picker_prev(&mut self) {
323        let from = self.picker.selected().unwrap_or(0);
324        let next = self
325            .picker_rows
326            .iter()
327            .enumerate()
328            .take(from)
329            .rev()
330            .find(|(_, r)| matches!(r, PickerRow::Tx(_)))
331            .map(|(i, _)| i)
332            .unwrap_or(from);
333        self.picker.select(Some(next));
334    }
335
336    fn first_selectable(&self) -> Option<usize> {
337        self.picker_rows
338            .iter()
339            .position(|r| matches!(r, PickerRow::Tx(_)))
340    }
341
342    fn last_selectable(&self) -> Option<usize> {
343        self.picker_rows
344            .iter()
345            .rposition(|r| matches!(r, PickerRow::Tx(_)))
346    }
347
348    fn open_selected(&mut self) {
349        let Some(i) = self.picker.selected() else {
350            return;
351        };
352        if let Some(PickerRow::Tx(tx_idx)) = self.picker_rows.get(i) {
353            self.current_tx = *tx_idx;
354            self.current_node = 0;
355            self.current_step = 0;
356            self.screen = Screen::Stepper;
357        }
358    }
359
360    // --- stepper -------------------------------------------------------------
361
362    fn current_tx(&self) -> &DebugTx {
363        &self.session.txs[self.current_tx]
364    }
365
366    fn current_steps(&self) -> &[DebugStep] {
367        &self.current_tx().nodes[self.current_node].steps
368    }
369
370    fn draw_stepper(&mut self, f: &mut Frame<'_>) {
371        // (existing body — `&mut self` already in scope so the source pane
372        // can write through to `file_cache` / `highlight_cache`.)
373        let area = f.area();
374        if area.width < 80 || area.height < 20 {
375            let msg = Paragraph::new(format!(
376                "terminal too small ({}x{}) — need at least 80x20",
377                area.width, area.height
378            ))
379            .alignment(Alignment::Center)
380            .wrap(Wrap { trim: true });
381            f.render_widget(msg, area);
382            return;
383        }
384
385        // Header (title + invocation breadcrumb), main region, footer.
386        let [header, main, footer] = Layout::new(
387            Direction::Vertical,
388            [
389                Constraint::Length(4),
390                Constraint::Min(10),
391                Constraint::Length(3),
392            ],
393        )
394        .areas(area);
395
396        self.draw_stepper_header(f, header);
397
398        // Main: left = instructions, right = (regs over source).
399        let [left, right] = Layout::new(
400            Direction::Horizontal,
401            [Constraint::Percentage(55), Constraint::Percentage(45)],
402        )
403        .areas(main);
404
405        self.draw_instructions(f, left);
406
407        let [right_top, right_bot] = Layout::new(
408            Direction::Vertical,
409            [Constraint::Length(14), Constraint::Min(5)],
410        )
411        .areas(right);
412        self.draw_registers(f, right_top);
413        self.draw_source(f, right_bot);
414
415        let footer_text = Paragraph::new(
416            "j/k step   s/a step-over   c/C prev/next CPI   g/G first/last   t tx picker   q quit",
417        )
418        .block(Block::default().borders(Borders::ALL))
419        .alignment(Alignment::Center);
420        f.render_widget(footer_text, footer);
421    }
422
423    fn draw_stepper_header(&self, f: &mut Frame<'_>, area: Rect) {
424        let tx = self.current_tx();
425        let node = &tx.nodes[self.current_node];
426        let n_nodes = tx.nodes.len();
427        let step_total = node.steps.len();
428
429        // Line 1: test/tx breadcrumb + step + cu.
430        let title_line = Line::from(vec![
431            Span::styled(
432                format!("{} ", tx.test_name),
433                Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD),
434            ),
435            Span::raw(format!("· tx{} ", tx.tx_seq)),
436            Span::styled(
437                format!(
438                    "· step {}/{}  cu {}",
439                    self.current_step + 1,
440                    step_total.max(1),
441                    node.steps
442                        .get(self.current_step)
443                        .map(|s| s.cu_cumulative)
444                        .unwrap_or(0)
445                ),
446                Style::new().fg(Color::Yellow),
447            ),
448        ]);
449
450        // Line 2: invocation breadcrumb. Renders all nodes with the
451        // current one inverted so c/C navigation is obvious even when
452        // every node is the same program (the self-CPI case).
453        let invocations_line = if n_nodes <= 1 {
454            Line::from(vec![Span::styled(
455                format!(
456                    "invocations: 1/1 ({})  — single invocation, c/C disabled",
457                    node.program_label
458                ),
459                Style::new().fg(Color::DarkGray),
460            )])
461        } else {
462            // Inactive items render in the default foreground (terminal's
463            // own white-ish), active item is inverted so it pops without
464            // imposing a hard-coded color the user's theme didn't pick.
465            // Separators stay dim so the eye lands on the items themselves.
466            let mut spans: Vec<Span<'static>> = vec![Span::styled(
467                "invocations: ",
468                Style::new().fg(Color::DarkGray),
469            )];
470            for (i, n) in tx.nodes.iter().enumerate() {
471                let is_cur = i == self.current_node;
472                let kind = if i == 0 { "top" } else { "cpi" };
473                let label = format!(" #{} {} {} ", i + 1, kind, n.program_label);
474                let style = if is_cur {
475                    Style::new().add_modifier(Modifier::REVERSED | Modifier::BOLD)
476                } else {
477                    Style::new()
478                };
479                spans.push(Span::styled(label, style));
480                if i + 1 < tx.nodes.len() {
481                    spans.push(Span::styled(" → ", Style::new().fg(Color::DarkGray)));
482                }
483            }
484            Line::from(spans)
485        };
486
487        let header = Paragraph::new(vec![title_line, invocations_line])
488            .block(Block::default().borders(Borders::ALL));
489        f.render_widget(header, area);
490    }
491
492    fn draw_instructions(&mut self, f: &mut Frame<'_>, area: Rect) {
493        let tx = self.current_tx();
494        let node = &tx.nodes[self.current_node];
495        let Some(step) = node.steps.get(self.current_step) else {
496            f.render_widget(
497                Paragraph::new("(no steps)").block(
498                    Block::default()
499                        .title(" instructions ")
500                        .borders(Borders::ALL),
501                ),
502                area,
503            );
504            return;
505        };
506
507        // Static-disasm view: render the program's text section in PC
508        // order, centered on the current step's PC. j/k stepping over a
509        // call/branch jumps the PC; the view re-centers each frame so
510        // you always see the actual code layout around what executed.
511        if let Some(disasm) = self.session.programs.get(&node.program_id) {
512            self.draw_static_disasm(f, area, node, step, disasm);
513            return;
514        }
515
516        // Fallback: program ELF wasn't resolvable (e.g. third-party
517        // deploy) so we have no static disasm. Drop back to the trace
518        // stream view — same data the flamegraph consumes.
519        self.draw_trace_stream(f, area);
520    }
521
522    fn draw_static_disasm(
523        &self,
524        f: &mut Frame<'_>,
525        area: Rect,
526        node: &super::model::DebugNode,
527        step: &DebugStep,
528        disasm: &super::model::ProgramDisasm,
529    ) {
530        // Locate the current PC in the static index. Falls back to the
531        // nearest preceding PC if the exact one isn't there (shouldn't
532        // happen for in-text PCs, but we never want to panic on a weird
533        // trace).
534        let center_idx = disasm
535            .pc_to_idx
536            .get(&step.pc)
537            .copied()
538            .or_else(|| {
539                disasm
540                    .pc_to_idx
541                    .range(..=step.pc)
542                    .next_back()
543                    .map(|(_, i)| *i)
544            })
545            .unwrap_or(0);
546
547        let window = area.height.saturating_sub(2) as usize;
548        let half = window / 2;
549        let start = center_idx.saturating_sub(half);
550        let end = (start + window).min(disasm.insns.len());
551
552        let mut rows: Vec<ListItem> = Vec::with_capacity(end - start);
553        for insn in &disasm.insns[start..end] {
554            // Symbol header above the function entrypoint, when known.
555            // Costs one row per visible function boundary inside the
556            // window — rare enough to ignore for sizing.
557            if let Some(label) = &insn.func_label {
558                rows.push(ListItem::new(Line::from(vec![
559                    Span::styled("   ", Style::new()),
560                    Span::styled(
561                        format!("┌── {label}"),
562                        Style::new().fg(Color::Magenta).add_modifier(Modifier::BOLD),
563                    ),
564                ])));
565            }
566
567            let is_current = insn.pc == step.pc;
568            let marker = if is_current { ">" } else { " " };
569            let mut spans = vec![
570                Span::raw(format!("{marker} ")),
571                Span::styled(
572                    format!("pc {:>5} ", insn.pc),
573                    Style::new().fg(Color::DarkGray),
574                ),
575            ];
576            spans.extend(insn.disasm_spans.iter().cloned());
577            let line = Line::from(spans);
578            let style = if is_current {
579                Style::new()
580                    .bg(Color::DarkGray)
581                    .add_modifier(Modifier::BOLD)
582            } else {
583                Style::new()
584            };
585            rows.push(ListItem::new(line).style(style));
586        }
587
588        let title = format!(
589            " {} · pc {} · trace step {}/{} ",
590            node.program_label,
591            step.pc,
592            self.current_step + 1,
593            node.steps.len()
594        );
595        let widget = List::new(rows).block(Block::default().title(title).borders(Borders::ALL));
596        f.render_widget(widget, area);
597    }
598
599    /// Fallback for unresolved programs: chronological trace view.
600    fn draw_trace_stream(&self, f: &mut Frame<'_>, area: Rect) {
601        let steps = self.current_steps();
602        let window = area.height.saturating_sub(2) as usize;
603        let half = window / 2;
604        let start = self.current_step.saturating_sub(half);
605        let end = (start + window).min(steps.len());
606
607        let items: Vec<ListItem> = steps[start..end]
608            .iter()
609            .enumerate()
610            .map(|(i, s)| {
611                let idx = start + i;
612                let marker = if idx == self.current_step { ">" } else { " " };
613                let mut spans = vec![
614                    Span::raw(format!("{marker} ")),
615                    Span::styled(format!("{:>6} ", idx), Style::new().fg(Color::DarkGray)),
616                    Span::styled(format!("pc {:>5} ", s.pc), Style::new().fg(Color::DarkGray)),
617                ];
618                spans.extend(s.disasm_spans.iter().cloned());
619                let line = Line::from(spans);
620                let style = if idx == self.current_step {
621                    Style::new()
622                        .bg(Color::DarkGray)
623                        .add_modifier(Modifier::BOLD)
624                } else {
625                    Style::new()
626                };
627                ListItem::new(line).style(style)
628            })
629            .collect();
630
631        let title = format!(
632            " trace stream — no static disasm  ({} / {}) ",
633            self.current_step + 1,
634            steps.len()
635        );
636        let widget = List::new(items).block(Block::default().title(title).borders(Borders::ALL));
637        f.render_widget(widget, area);
638    }
639
640    fn draw_registers(&self, f: &mut Frame<'_>, area: Rect) {
641        let Some(step) = self.current_steps().get(self.current_step) else {
642            let widget = Paragraph::new("(no steps)")
643                .block(Block::default().title(" registers ").borders(Borders::ALL));
644            f.render_widget(widget, area);
645            return;
646        };
647
648        let prev = self
649            .current_step
650            .checked_sub(1)
651            .and_then(|i| self.current_steps().get(i));
652
653        let mut lines = Vec::with_capacity(12);
654        for r in 0..11 {
655            let val = step.regs[r];
656            let changed = prev.map_or(false, |p| p.regs[r] != val);
657            let style = if changed {
658                Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD)
659            } else {
660                Style::new()
661            };
662            lines.push(Line::from(vec![
663                Span::raw(format!("r{:<2} ", r)),
664                Span::styled(format!("{:#018x}", val), style),
665                Span::styled(
666                    format!("  ({})", val as i64),
667                    Style::new().fg(Color::DarkGray),
668                ),
669            ]));
670        }
671        lines.push(Line::from(vec![
672            Span::raw("pc  "),
673            Span::styled(
674                format!("{:#010x}  ({})", step.pc, step.pc),
675                Style::new().fg(Color::Cyan),
676            ),
677        ]));
678
679        let widget = Paragraph::new(lines)
680            .block(Block::default().title(" registers ").borders(Borders::ALL));
681        f.render_widget(widget, area);
682    }
683
684    fn draw_source(&mut self, f: &mut Frame<'_>, area: Rect) {
685        let block = Block::default().title(" source ").borders(Borders::ALL);
686        let Some(step) = self.current_steps().get(self.current_step).cloned() else {
687            f.render_widget(Paragraph::new("(no steps)").block(block), area);
688            return;
689        };
690        let Some(loc) = step.src_loc.clone() else {
691            // Two distinct failure modes — distinguishing them keeps us
692            // from telling users to rebuild a binary that's already
693            // built with debug info.
694            let node = &self.current_tx().nodes[self.current_node];
695            let program_disasm = self.session.programs.get(&node.program_id);
696            let msg = match program_disasm {
697                Some(d) if d.has_dwarf => format!(
698                    "pc {pc:#x} has no DWARF line entry.\n\nThis is normal for hand-written asm \
699                     entrypoints, inlined\nframes, compiler-generated stubs, and `.text` padding \
700                     —\nLLVM only emits (file, line) tuples for Rust source.\nOther PCs in \
701                     {program} resolve fine; stepping forward\nshould re-enter mapped code.\n\nIf \
702                     you wrote an asm entrypoint, you can ignore this for\nthe asm region — \
703                     registers + disasm above stay live.",
704                    pc = step.pc,
705                    program = node.program_label,
706                ),
707                Some(_) => format!(
708                    "{program}: ELF has no DWARF line info.\n\nRebuild with debug info:\n  \
709                     CARGO_PROFILE_RELEASE_DEBUG=2 anchor build --no-idl\n\n(`anchor debugger` \
710                     sets this for you when it rebuilds.\nThe flag only sticks if the .so we read \
711                     came from that build.)",
712                    program = node.program_label,
713                ),
714                None => format!(
715                    "no static disasm or DWARF for {program} (pc {pc:#x}).\n\nThe program's \
716                     deployed `.so` wasn't resolvable from the\nworkspace's Anchor.toml — \
717                     third-party deploy, or\nmismatched program-id mapping.",
718                    program = node.program_label,
719                    pc = step.pc,
720                ),
721            };
722            f.render_widget(
723                Paragraph::new(msg).block(block).wrap(Wrap { trim: true }),
724                area,
725            );
726            return;
727        };
728
729        let resolved_path = resolve_src_path(
730            &loc.file,
731            &self.session.src_roots,
732            &self.session.path_rewrites,
733            loc.line,
734        );
735        let Some(path) = resolved_path else {
736            let msg = format!(
737                "can't read {}:{}\nno candidate path resolved\n\ntried roots: {}",
738                loc.file.display(),
739                loc.line,
740                self.session
741                    .src_roots
742                    .iter()
743                    .map(|p| p.display().to_string())
744                    .collect::<Vec<_>>()
745                    .join(", ")
746            );
747            f.render_widget(
748                Paragraph::new(msg).block(block).wrap(Wrap { trim: true }),
749                area,
750            );
751            return;
752        };
753
754        // Pull file contents from cache, populating on first access. Errors
755        // are cached too so we don't retry the disk every frame for paths
756        // that resolved but can't be read (rare — usually a permissions
757        // issue).
758        let file_lines = match self.load_file(&path) {
759            Ok(lines) => lines,
760            Err(e) => {
761                let msg = format!("can't read {}:{}\n{e}", path.display(), loc.line);
762                f.render_widget(
763                    Paragraph::new(msg).block(block).wrap(Wrap { trim: true }),
764                    area,
765                );
766                return;
767            }
768        };
769        let lines = window_from_lines(file_lines, loc.line, area.height.saturating_sub(2) as u32);
770
771        // Pull the cached label or compute it once. Title format:
772        //   " stdlib · core · src/array/equality.rs:150 "
773        //   " pinocchio v0.11.1 · src/cpi.rs:42 "
774        //   " debugger-testing · programs/debugger-testing/src/lib.rs:12 "
775        let label = if let Some(cached) = self.label_cache.get(&path) {
776            cached.clone()
777        } else {
778            let l = classify(
779                &path,
780                &self.session.src_roots,
781                &self.session.path_rewrites,
782                self.session.cwd.as_deref(),
783            );
784            self.label_cache.insert(path.clone(), l.clone());
785            l
786        };
787        let title = format!(" {} · {}:{} ", label.label, label.path_display, loc.line);
788        let is_rust = loc
789            .file
790            .extension()
791            .and_then(|s| s.to_str())
792            .map_or(false, |e| e.eq_ignore_ascii_case("rs"));
793        let text: Vec<Line> = lines
794            .into_iter()
795            .map(|(n, content, is_current)| {
796                let mut spans = vec![Span::styled(
797                    format!("{n:>5}  "),
798                    Style::new().fg(Color::DarkGray),
799                )];
800                if is_rust {
801                    // Per-line highlight cache keyed by (resolved-path,
802                    // line-number). Means a held-down j/k that scrolls
803                    // through a 30-line window only ever pays for the
804                    // first time each line is shown.
805                    let key = (path.clone(), n);
806                    let highlighted: Vec<Span<'static>> =
807                        if let Some(cached) = self.highlight_cache.get(&key) {
808                            cached.clone()
809                        } else {
810                            let h = highlight_rust(&content).spans;
811                            self.highlight_cache.insert(key, h.clone());
812                            h
813                        };
814                    if is_current {
815                        for span in highlighted {
816                            let mut style = span.style;
817                            style.bg = Some(Color::DarkGray);
818                            style = style.add_modifier(Modifier::BOLD);
819                            spans.push(Span::styled(span.content.into_owned(), style));
820                        }
821                    } else {
822                        spans.extend(highlighted);
823                    }
824                } else {
825                    let style = if is_current {
826                        Style::new()
827                            .bg(Color::DarkGray)
828                            .add_modifier(Modifier::BOLD)
829                    } else {
830                        Style::new()
831                    };
832                    spans.push(Span::styled(content, style));
833                }
834                Line::from(spans)
835            })
836            .collect();
837
838        let widget =
839            Paragraph::new(text).block(Block::default().title(title).borders(Borders::ALL));
840        f.render_widget(widget, area);
841    }
842
843    fn handle_stepper_key(&mut self, k: KeyEvent) -> Flow {
844        let ctrl = k.modifiers.contains(KeyModifiers::CONTROL);
845        match k.code {
846            KeyCode::Char('q') => return Flow::Quit,
847            KeyCode::Char('t') | KeyCode::Esc => {
848                // Map back from `current_tx` (index into session.txs) to
849                // the picker row that points at it, so the user lands on
850                // the same entry they entered through.
851                let row = self
852                    .picker_rows
853                    .iter()
854                    .position(|r| matches!(r, PickerRow::Tx(i) if *i == self.current_tx));
855                self.picker.select(row);
856                self.screen = Screen::Picker;
857            }
858            KeyCode::Char(d @ '0'..='9') => {
859                self.key_buffer.push(d);
860                return Flow::Continue;
861            }
862            KeyCode::Char('j') | KeyCode::Down => self.repeat(|app| app.step_forward(1)),
863            KeyCode::Char('k') | KeyCode::Up => self.repeat(|app| app.step_back(1)),
864            KeyCode::Char('s') => self.repeat(App::step_over_forward),
865            KeyCode::Char('a') => self.repeat(App::step_over_back),
866            KeyCode::Char('g') => self.current_step = 0,
867            KeyCode::Char('G') => {
868                self.current_step = self.current_steps().len().saturating_sub(1);
869            }
870            KeyCode::Char('c') if !ctrl => {
871                self.current_node = self.current_node.saturating_sub(1);
872                self.current_step = 0;
873            }
874            KeyCode::Char('C') => {
875                let max = self.current_tx().nodes.len().saturating_sub(1);
876                self.current_node = (self.current_node + 1).min(max);
877                self.current_step = 0;
878            }
879            _ => {}
880        }
881        self.key_buffer.clear();
882        Flow::Continue
883    }
884
885    fn repeat(&mut self, mut f: impl FnMut(&mut Self)) {
886        let n = self
887            .key_buffer
888            .parse::<usize>()
889            .unwrap_or(1)
890            .clamp(1, 100_000);
891        for _ in 0..n {
892            f(self);
893        }
894    }
895
896    fn step_forward(&mut self, n: usize) {
897        let last = self.current_steps().len().saturating_sub(1);
898        self.current_step = (self.current_step + n).min(last);
899    }
900
901    fn step_back(&mut self, n: usize) {
902        self.current_step = self.current_step.saturating_sub(n);
903    }
904
905    /// Step-over: advance until call_depth returns to the current level (or
906    /// shallower). Falls back to a single step when there's no nested frame.
907    fn step_over_forward(&mut self) {
908        let steps = self.current_steps();
909        let Some(cur) = steps.get(self.current_step) else {
910            return;
911        };
912        let base = cur.call_depth;
913        let start = self.current_step + 1;
914        let idx = steps[start..]
915            .iter()
916            .position(|s| s.call_depth <= base)
917            .map(|off| start + off)
918            .unwrap_or_else(|| steps.len().saturating_sub(1));
919        self.current_step = idx;
920    }
921
922    fn step_over_back(&mut self) {
923        let steps = self.current_steps();
924        let Some(cur) = steps.get(self.current_step) else {
925            return;
926        };
927        let base = cur.call_depth;
928        let idx = steps[..self.current_step]
929            .iter()
930            .rposition(|s| s.call_depth <= base)
931            .unwrap_or(0);
932        self.current_step = idx;
933    }
934}
935
936struct TerminalGuard {
937    term: DebugTerm,
938}
939
940impl TerminalGuard {
941    fn new(mut term: DebugTerm) -> Self {
942        let _ = enable_raw_mode();
943        let _ = execute!(term.backend_mut(), EnterAlternateScreen, EnableMouseCapture);
944        let _ = term.hide_cursor();
945        let _ = term.clear();
946        Self { term }
947    }
948}
949
950impl Drop for TerminalGuard {
951    fn drop(&mut self) {
952        let _ = disable_raw_mode();
953        let _ = execute!(
954            self.term.backend_mut(),
955            LeaveAlternateScreen,
956            DisableMouseCapture
957        );
958        let _ = self.term.show_cursor();
959    }
960}
961
962impl App {
963    /// Read a source file from the cache, populating it on first miss.
964    /// Returns the file's lines verbatim. Errors are cached as
965    /// `Missing(msg)` so we don't hit the disk repeatedly for paths that
966    /// resolved but failed to read.
967    fn load_file(&mut self, path: &std::path::Path) -> Result<&[String], String> {
968        if !self.file_cache.contains_key(path) {
969            let entry = match std::fs::read_to_string(path) {
970                Ok(s) => FileEntry::Loaded(s.lines().map(str::to_owned).collect()),
971                Err(e) => FileEntry::Missing(e.to_string()),
972            };
973            self.file_cache.insert(path.to_path_buf(), entry);
974        }
975        match self.file_cache.get(path).expect("just inserted") {
976            FileEntry::Loaded(lines) => Ok(lines.as_slice()),
977            FileEntry::Missing(msg) => Err(msg.clone()),
978        }
979    }
980}
981
982/// Slice a centered window around `target_line` from already-loaded lines.
983/// Returns `(line_number, content, is_current)` triples for the source
984/// pane to render — does no I/O.
985fn window_from_lines(lines: &[String], target_line: u32, height: u32) -> Vec<(u32, String, bool)> {
986    let target_idx = target_line.saturating_sub(1) as usize;
987    let half = (height / 2) as usize;
988    let start = target_idx.saturating_sub(half).min(lines.len());
989    let end = (start + height as usize).min(lines.len());
990    lines[start..end]
991        .iter()
992        .enumerate()
993        .map(|(i, l)| {
994            let n = (start + i + 1) as u32;
995            (n, l.clone(), n == target_line)
996        })
997        .collect()
998}
999
1000/// Resolves a DWARF-reported source path to one we can actually read.
1001///
1002/// Tried in order:
1003/// 1. The path as-is if it's absolute and exists (local build).
1004/// 2. Prefix rewrites — used for stdlib frames whose DWARF path points at
1005///    the CI machine that built `platform-tools`.
1006/// 3. Join against each configured source root (workspace root etc.) for
1007///    paths DWARF left relative.
1008///
1009/// Returns `None` when nothing hits. The caller surfaces that as a
1010/// "source not available" notice in the TUI source pane.
1011fn resolve_src_path(
1012    file: &std::path::Path,
1013    roots: &[std::path::PathBuf],
1014    rewrites: &[(std::path::PathBuf, std::path::PathBuf)],
1015    line: u32,
1016) -> Option<std::path::PathBuf> {
1017    if file.is_absolute() && file.exists() {
1018        return Some(file.to_path_buf());
1019    }
1020
1021    // Prefix rewrites (e.g. `platform-tools` CI path → local stdlib cache).
1022    if let Some(file_str) = file.to_str() {
1023        for (prefix, replacement) in rewrites {
1024            if let Some(prefix_str) = prefix.to_str() {
1025                if let Some(tail) = file_str.strip_prefix(prefix_str) {
1026                    let candidate = replacement.join(tail.trim_start_matches('/'));
1027                    if candidate.exists() {
1028                        return Some(candidate);
1029                    }
1030                }
1031            }
1032        }
1033    }
1034
1035    // SBF DWARF omits DW_AT_comp_dir (-Zremap-cwd-prefix= strips it),
1036    // so relative paths like `src/lib.rs` are ambiguous across crates.
1037    // When multiple roots contain a matching file, prefer the one that
1038    // actually has enough lines for the DWARF-referenced line number.
1039    // This correctly disambiguates e.g. anchor-lang's `src/cpi.rs`
1040    // (538 lines) from pinocchio's `src/cpi.rs` (683 lines) when the
1041    // DWARF says line 668.
1042    let mut fallback: Option<std::path::PathBuf> = None;
1043    for root in roots {
1044        let candidate = root.join(file);
1045        if candidate.exists() {
1046            if line == 0 {
1047                return Some(candidate);
1048            }
1049            if let Ok(contents) = std::fs::read(&candidate) {
1050                let line_count = contents.iter().filter(|&&b| b == b'\n').count() + 1;
1051                if line as usize <= line_count {
1052                    return Some(candidate);
1053                }
1054            }
1055            if fallback.is_none() {
1056                fallback = Some(candidate);
1057            }
1058        }
1059    }
1060
1061    if let Some(fb) = fallback {
1062        return Some(fb);
1063    }
1064
1065    if file.exists() {
1066        return Some(file.to_path_buf());
1067    }
1068    None
1069}