Skip to main content

brink_runtime/
debug_session.rs

1//! Scripted debug sessions — the shared verb set for driving a debugger
2//! (issue #3247, extracted here by #3248).
3//!
4//! **Why this lives in `brink-runtime`.** Three consumers need one
5//! definition of "step over": the scripted test harness, the CLI debugger,
6//! and the studio. `brink-cli` is a published crate, so it cannot depend on
7//! the test-only harness where this started; and a new crate purely to hold
8//! it would be publishable-but-unpublished, which CI's own publishable
9//! check refuses until a maintainer publishes it by hand. `brink-runtime`
10//! already owns the debug vocabulary (`debug_control`'s `BreakpointSet`,
11//! `StepMode`, `DebugRunOutcome`), so this is its sibling rather than a
12//! new home. Gated behind `debug-hooks`, so a build without the debugger
13//! carries none of it.
14//!
15//! **Why debugger semantics need an artifact at all.** They were otherwise
16//! defined only by Rust unit tests written alongside the code they test —
17//! so a refactor that quietly changes what `step over` does *passes*,
18//! because the test gets updated to match. A scripted transcript makes the
19//! behaviour itself the artifact.
20//!
21//! **Source level, never bytecode.** Every assertion and transcript line is
22//! `main.ink:7`, a local's name and value, a stack of frame names. Bytecode
23//! offsets churn on every codegen change; goldens written against them
24//! would break constantly and teach everyone to re-accept snapshots without
25//! reading them — worse than no goldens, because it launders real
26//! regressions through a habit.
27//!
28//! **Two granularities, both first-class** (RULED 2026-08-28). `stepi` is
29//! VM-instruction stepping; `step` (and `next`) is line stepping. Neither
30//! is a wrapper over the other: the studio presents the `.inkt`
31//! disassembly beside the source, so an author can watch a line and the
32//! instructions it became at the same time. GDB's vocabulary is borrowed on
33//! purpose — it is the convention every debugger user already has.
34//!
35//! **Lines are 1-based here.** A script is a thing a person writes, and
36//! `main.ink:7` means what every editor means by line 7. The engine is
37//! 0-based; the conversion happens at this one edge, which faces a human.
38
39use alloc::format;
40use alloc::string::{String, ToString};
41use alloc::vec::Vec;
42use core::fmt::Write as _;
43
44use crate::debug::DebugValue;
45use crate::debug_control::{BreakpointSet, DEFAULT_DEBUG_BUDGET, DebugStopReason, StepMode};
46use crate::{FastRng, Program, Story};
47
48/// One action or assertion from a `.dbg` script.
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub enum Command {
51    /// `break <file>:<line>` — arm a breakpoint. Line is 1-based.
52    Break {
53        file: String,
54        line: u32,
55    },
56    /// `run` / `continue` — advance to the next breakpoint, choice point,
57    /// or terminal outcome.
58    Run,
59    /// `stepi into|over|out` — one VM instruction.
60    StepInstruction(StepMode),
61    /// `step into|over|out` / `next` — one source line (#3264).
62    StepLine(StepMode),
63    /// `locals` / `stack` — record the current frame's state in the
64    /// transcript without asserting anything.
65    Locals,
66    Stack,
67    /// `expect-line <n>` (1-based).
68    ExpectLine(u32),
69    /// `expect-local <name> = <value>`.
70    ExpectLocal {
71        name: String,
72        value: String,
73    },
74    /// `expect-stack a > b > c`.
75    ExpectStack(Vec<String>),
76    /// `expect-terminal` — the last action ended the story.
77    ExpectTerminal,
78}
79
80/// A script failed to parse. Carries the 1-based line number so a broken
81/// fixture points at itself.
82#[derive(Debug)]
83pub struct ScriptError {
84    pub line: usize,
85    pub message: String,
86}
87
88impl std::fmt::Display for ScriptError {
89    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90        write!(f, "script line {}: {}", self.line, self.message)
91    }
92}
93
94/// Parse a `.dbg` script. Line-oriented; `#` starts a comment; blank lines
95/// are skipped.
96///
97/// # Errors
98/// Returns [`ScriptError`] naming the offending line for an unknown verb or
99/// a malformed argument. Unknown verbs are an error rather than a skip: a
100/// silently-ignored line is a script that appears to test something it does
101/// not.
102pub fn parse_script(text: &str) -> Result<Vec<Command>, ScriptError> {
103    let mut out = Vec::new();
104    for (i, raw) in text.lines().enumerate() {
105        let lineno = i + 1;
106        let line = raw.split('#').next().unwrap_or("").trim();
107        if line.is_empty() {
108            continue;
109        }
110        let err = |message: String| ScriptError {
111            line: lineno,
112            message,
113        };
114        let (verb, rest) = line.split_once(char::is_whitespace).unwrap_or((line, ""));
115        let rest = rest.trim();
116        let cmd = match verb {
117            "break" => {
118                let (file, line_s) = rest
119                    .rsplit_once(':')
120                    .ok_or_else(|| err(format!("expected `break <file>:<line>`, got {rest:?}")))?;
121                let n: u32 = line_s.trim().parse().map_err(|_| {
122                    err(format!(
123                        "line number must be a positive integer: {line_s:?}"
124                    ))
125                })?;
126                if n == 0 {
127                    return Err(err(
128                        "line numbers in scripts are 1-based; 0 is not a line".into()
129                    ));
130                }
131                Command::Break {
132                    file: file.trim().to_string(),
133                    line: n,
134                }
135            }
136            "run" | "continue" => Command::Run,
137            "stepi" => match rest {
138                "into" => Command::StepInstruction(StepMode::Into),
139                "over" => Command::StepInstruction(StepMode::Over),
140                "out" => Command::StepInstruction(StepMode::Out),
141                other => return Err(err(format!("stepi takes into|over|out, got {other:?}"))),
142            },
143            // Line granularity (#3264). `next` is GDB's spelling of
144            // `step over`, accepted as the alias every debugger user
145            // already types.
146            "step" => match rest {
147                "into" => Command::StepLine(StepMode::Into),
148                "over" => Command::StepLine(StepMode::Over),
149                "out" => Command::StepLine(StepMode::Out),
150                other => return Err(err(format!("step takes into|over|out, got {other:?}"))),
151            },
152            "next" => Command::StepLine(StepMode::Over),
153            "locals" => Command::Locals,
154            "stack" => Command::Stack,
155            "expect-line" => Command::ExpectLine(
156                rest.parse()
157                    .map_err(|_| err(format!("expect-line takes a 1-based line, got {rest:?}")))?,
158            ),
159            "expect-local" => {
160                let (name, value) = rest.split_once('=').ok_or_else(|| {
161                    err(format!(
162                        "expected `expect-local <name> = <value>`, got {rest:?}"
163                    ))
164                })?;
165                Command::ExpectLocal {
166                    name: name.trim().to_string(),
167                    value: value.trim().to_string(),
168                }
169            }
170            "expect-stack" => Command::ExpectStack(
171                rest.split('>')
172                    .map(|s| s.trim().to_string())
173                    .filter(|s| !s.is_empty())
174                    .collect(),
175            ),
176            "expect-terminal" => Command::ExpectTerminal,
177            other => return Err(err(format!("unknown verb {other:?}"))),
178        };
179        out.push(cmd);
180    }
181    Ok(out)
182}
183
184/// Render a [`DebugValue`] compactly for a transcript. Structured kinds
185/// keep their structure — a locals panel that can only say `"[list]"` is
186/// not the target, and neither is a golden that records one.
187fn render(value: &DebugValue) -> String {
188    match value {
189        DebugValue::Int(i) => i.to_string(),
190        DebugValue::Float(f) => format!("{f}"),
191        DebugValue::Bool(b) => b.to_string(),
192        DebugValue::Str(s) => format!("{s:?}"),
193        DebugValue::Null => "null".to_string(),
194        DebugValue::List(items) => format!("[{}]", items.join(", ")),
195        DebugValue::DivertTarget(t) => format!("-> {}", t.as_deref().unwrap_or("?")),
196        DebugValue::Struct { name, fields } => {
197            let inner: Vec<String> = fields
198                .iter()
199                .map(|(k, v)| format!("{k}: {}", render(v)))
200                .collect();
201            format!(
202                "{} {{ {} }}",
203                name.as_deref().unwrap_or("?"),
204                inner.join(", ")
205            )
206        }
207        DebugValue::Handle { kind, id } => format!("<{kind} #{id}>"),
208        DebugValue::Other(s) => s.clone(),
209    }
210}
211
212/// A driven debug session: the story, its breakpoints, and the sources
213/// needed to report positions in source terms.
214pub struct Session {
215    story: Story<FastRng>,
216    program: alloc::sync::Arc<Program>,
217    breakpoints: BreakpointSet,
218    transcript: String,
219    last_reason: Option<DebugStopReason>,
220}
221
222impl Session {
223    #[must_use]
224    pub fn new(
225        program: alloc::sync::Arc<Program>,
226        line_tables: Vec<Vec<brink_format::LineEntry>>,
227    ) -> Self {
228        let story = Story::<FastRng>::new(alloc::sync::Arc::clone(&program), line_tables);
229        Self {
230            story,
231            program,
232            breakpoints: BreakpointSet::new(),
233            transcript: String::new(),
234            last_reason: None,
235        }
236    }
237
238    /// The transcript so far.
239    #[must_use]
240    pub fn transcript(&self) -> &str {
241        &self.transcript
242    }
243
244    /// The file and 1-based line the flow is stopped on, or `None` when
245    /// there is no source position — not started, terminal, or parked.
246    /// Public so a host (the CLI's `list`, a UI's current-line highlight)
247    /// can ask without re-deriving it from a transcript.
248    #[must_use]
249    pub fn current_position(&self) -> Option<(String, u32)> {
250        self.current_line()
251    }
252
253    /// The 1-based line the flow is stopped on, with its file.
254    ///
255    /// Needs no source text: since #3261 the `DebugInfo` file table carries
256    /// a per-file line index, so the engine answers byte→line itself. This
257    /// used to hold a `BTreeMap<String, String>` of every file's contents
258    /// purely to count newlines — a copy of the whole project, kept to
259    /// answer a question the artifact can now answer on its own.
260    fn current_line(&self) -> Option<(String, u32)> {
261        let pos = self.story.debug_snapshot().position?;
262        let loc = self.program.resolve_debug_position(pos)?;
263        let file = loc.file?;
264        let line0 = self.program.line_at(&file, loc.range_start)?;
265        Some((file, line0 + 1))
266    }
267
268    fn frame_names(&self) -> Vec<String> {
269        self.story
270            .debug_snapshot()
271            .call_stack
272            .iter()
273            .rev()
274            .filter_map(|f| f.location.clone())
275            // Root-level content (before any knot) has an empty location,
276            // which would render as a blank line — a stack listing that
277            // shows nothing where a frame is, is worse than one that names
278            // it. `<root>` is not a path, so it cannot be mistaken for one.
279            .map(|name| {
280                if name.is_empty() {
281                    "<root>".to_owned()
282                } else {
283                    name
284                }
285            })
286            .collect()
287    }
288
289    fn note_position(&mut self) {
290        match self.current_line() {
291            Some((file, line)) => {
292                let _ = writeln!(self.transcript, "  at {file}:{line}");
293            }
294            None => {
295                let _ = writeln!(self.transcript, "  at <no source position>");
296            }
297        }
298    }
299}
300
301/// Run a parsed script, returning the transcript.
302///
303/// # Errors
304/// Returns the assertion message for the first `expect-*` that does not
305/// hold, or a runtime/breakpoint-binding failure. The transcript up to that
306/// point is included so a failure reads as a session, not a bare assert.
307pub fn run_script(session: &mut Session, script: &[Command]) -> Result<String, String> {
308    for cmd in script {
309        match cmd {
310            Command::Break { .. }
311            | Command::Run
312            | Command::StepInstruction(_)
313            | Command::StepLine(_)
314            | Command::Locals
315            | Command::Stack => apply_action(session, cmd)?,
316            Command::ExpectLine(_)
317            | Command::ExpectLocal { .. }
318            | Command::ExpectStack(_)
319            | Command::ExpectTerminal => apply_expectation(session, cmd)?,
320        }
321    }
322    Ok(session.transcript.clone())
323}
324
325/// The verbs that move the session: break, run, step, and the two that
326/// only record state.
327/// Bind `file:line` to a program address and arm a breakpoint there.
328///
329/// Refuses rather than arming something that can never hit — and says WHY,
330/// because the two ways binding fails call for opposite responses from the
331/// user.
332fn arm_breakpoint(session: &mut Session, file: &str, line: u32) -> Result<(), String> {
333    // Scripts are 1-based; the engine is 0-based.
334    let position = session
335        .program
336        .resolve_source_line(file, line.saturating_sub(1))
337        .ok_or_else(|| {
338            let why = if session.program.has_debug_info() {
339                "that line has no executable code (a comment, a blank, or code that \
340                 folded away)"
341            } else {
342                "this story carries no debug info — recompile the source, or build the \
343                 artifact with `--debug-info`"
344            };
345            format!(
346                "{}\nbreak {file}:{line} bound to nothing — {why}. A breakpoint that can \
347                 never hit is worse than none, so this is an error rather than a silent \
348                 no-op.",
349                session.transcript
350            )
351        })?;
352    session.breakpoints.insert(
353        position.container_idx,
354        position.offset,
355        format!("{file}:{line}"),
356    );
357    let _ = writeln!(session.transcript, "break {file}:{line}");
358    Ok(())
359}
360
361fn apply_action(session: &mut Session, cmd: &Command) -> Result<(), String> {
362    match cmd {
363        Command::Break { file, line } => arm_breakpoint(session, file, *line)?,
364        Command::Run => {
365            let outcome = session
366                .story
367                .debug_run(&session.breakpoints, DEFAULT_DEBUG_BUDGET)
368                .map_err(|e| format!("{}\nrun failed: {e:?}", session.transcript))?;
369            let _ = writeln!(session.transcript, "run -> {}", describe(&outcome.reason));
370            session.last_reason = Some(outcome.reason);
371            session.note_position();
372        }
373        Command::StepInstruction(mode) => {
374            let outcome = session
375                .story
376                .debug_step(*mode, &session.breakpoints, DEFAULT_DEBUG_BUDGET)
377                .map_err(|e| format!("{}\nstepi failed: {e:?}", session.transcript))?;
378            let _ = writeln!(
379                session.transcript,
380                "stepi {} -> {}",
381                match mode {
382                    StepMode::Into => "into",
383                    StepMode::Over => "over",
384                    StepMode::Out => "out",
385                },
386                describe(&outcome.reason)
387            );
388            session.last_reason = Some(outcome.reason);
389            session.note_position();
390        }
391        Command::StepLine(mode) => {
392            let outcome = session
393                .story
394                .debug_step_line(*mode, &session.breakpoints, DEFAULT_DEBUG_BUDGET)
395                .map_err(|e| format!("{}\nstep failed: {e:?}", session.transcript))?;
396            let _ = writeln!(
397                session.transcript,
398                "step {} -> {}",
399                match mode {
400                    StepMode::Into => "into",
401                    StepMode::Over => "over",
402                    StepMode::Out => "out",
403                },
404                describe(&outcome.reason)
405            );
406            session.last_reason = Some(outcome.reason);
407            session.note_position();
408        }
409        Command::Locals => {
410            let snap = session.story.debug_snapshot();
411            let locals = snap.call_stack.first().and_then(|f| f.locals.as_ref());
412            match locals {
413                Some(ls) if !ls.is_empty() => {
414                    let _ = writeln!(session.transcript, "locals");
415                    for l in ls {
416                        let _ = writeln!(session.transcript, "  {} = {}", l.name, render(&l.value));
417                    }
418                }
419                Some(_) => {
420                    let _ = writeln!(session.transcript, "locals (none in scope)");
421                }
422                None => {
423                    let _ = writeln!(
424                        session.transcript,
425                        "locals <unavailable: compiled without debug info>"
426                    );
427                }
428            }
429        }
430        Command::Stack => {
431            let _ = writeln!(session.transcript, "stack");
432            for name in session.frame_names() {
433                let _ = writeln!(session.transcript, "  {name}");
434            }
435        }
436        _ => unreachable!("apply_action only handles action verbs"),
437    }
438    Ok(())
439}
440
441/// The `expect-*` verbs. Each records itself in the transcript and then
442/// fails with the session so far, so a violated expectation reads as a
443/// session rather than a bare assert.
444fn apply_expectation(session: &mut Session, cmd: &Command) -> Result<(), String> {
445    match cmd {
446        Command::ExpectLine(want) => {
447            let got = session.current_line();
448            let _ = writeln!(session.transcript, "expect-line {want}");
449            match got {
450                Some((_, line)) if line == *want => {}
451                Some((file, line)) => {
452                    return Err(format!(
453                        "{}\nexpected to be stopped on line {want}, but the flow is at \
454                         {file}:{line}",
455                        session.transcript
456                    ));
457                }
458                None => {
459                    return Err(format!(
460                        "{}\nexpected to be stopped on line {want}, but the flow has no source \
461                         position (terminal, or parked)",
462                        session.transcript
463                    ));
464                }
465            }
466        }
467        Command::ExpectLocal { name, value } => {
468            let _ = writeln!(session.transcript, "expect-local {name} = {value}");
469            let snap = session.story.debug_snapshot();
470            let locals = snap
471                .call_stack
472                .first()
473                .and_then(|f| f.locals.as_ref())
474                .ok_or_else(|| {
475                    format!(
476                        "{}\nexpect-local {name}: this frame reports no locals at all (compiled \
477                         without debug info?)",
478                        session.transcript
479                    )
480                })?;
481            let found = locals.iter().find(|l| &l.name == name).ok_or_else(|| {
482                let have: Vec<&str> = locals.iter().map(|l| l.name.as_str()).collect();
483                format!(
484                    "{}\nexpect-local {name}: no such local in scope. In scope: {have:?}",
485                    session.transcript
486                )
487            })?;
488            let got = render(&found.value);
489            if &got != value {
490                return Err(format!(
491                    "{}\nexpect-local {name}: expected {value}, got {got}",
492                    session.transcript
493                ));
494            }
495        }
496        Command::ExpectStack(want) => {
497            let _ = writeln!(session.transcript, "expect-stack {}", want.join(" > "));
498            let got = session.frame_names();
499            if &got != want {
500                return Err(format!(
501                    "{}\nexpect-stack: expected {want:?}, got {got:?}",
502                    session.transcript
503                ));
504            }
505        }
506        Command::ExpectTerminal => {
507            let _ = writeln!(session.transcript, "expect-terminal");
508            match &session.last_reason {
509                Some(DebugStopReason::Terminal) => {}
510                other => {
511                    return Err(format!(
512                        "{}\nexpect-terminal: the last action stopped for {other:?}, not a \
513                         terminal outcome",
514                        session.transcript
515                    ));
516                }
517            }
518        }
519        _ => unreachable!("apply_expectation only handles expect verbs"),
520    }
521    Ok(())
522}
523
524/// Source-level description of why the flow stopped. Deliberately omits
525/// bytecode positions — see this module's own doc.
526fn describe(reason: &DebugStopReason) -> String {
527    match reason {
528        DebugStopReason::Breakpoint { name, .. } => format!("breakpoint {name}"),
529        DebugStopReason::Watchpoint { global_idx } => format!("watchpoint on global {global_idx}"),
530        DebugStopReason::Choices => "choice point".to_string(),
531        DebugStopReason::Step => "step".to_string(),
532        other => format!("{other:?}").to_lowercase(),
533    }
534}