use std::collections::{BTreeSet, HashMap};
use serde::Serialize;
use crate::vm::{disassemble, CompiledProgram, DebugVmState, DisasmLine, Vm, VmStep};
const STEP_LIMIT: usize = 5_000_000;
#[derive(Clone, Serialize)]
pub struct DebugReg {
pub index: u16,
pub name: Option<String>,
pub kind: String,
pub value: String,
pub changed: bool,
}
#[derive(Clone, Serialize)]
pub struct DebugFrame {
pub function: Option<String>,
pub base: usize,
pub registers: Vec<DebugReg>,
}
#[derive(Clone, Serialize, Debug)]
pub struct HeapObject {
pub id: String,
pub kind: String,
pub summary: String,
pub storage: String,
pub rc: usize,
pub referenced_by: Vec<String>,
pub shared: bool,
}
#[derive(Clone, Serialize)]
pub struct DebugSnapshot {
pub pc: usize,
pub op_text: String,
pub narration: String,
pub fol: String,
pub socratic: String,
pub op_reads: Vec<u16>,
pub op_writes: Option<u16>,
pub state: String,
pub error: Option<String>,
pub step: usize,
pub total_steps: usize,
pub total_ops: usize,
pub frames: Vec<DebugFrame>,
pub heap: Vec<HeapObject>,
pub globals: Vec<(String, String)>,
pub output: Vec<String>,
pub at_breakpoint: bool,
}
#[derive(Clone, Serialize)]
pub struct VarTrace {
pub reg: u16,
pub name: String,
pub kind: String,
pub points: Vec<TimelinePoint>,
}
#[derive(Clone, Serialize)]
pub struct TimelinePoint {
pub value: String,
pub present: bool,
pub changed: bool,
}
#[derive(Clone, Serialize)]
pub struct VarTimeline {
pub start: usize,
pub steps: usize,
pub cursor: usize,
pub truncated: bool,
pub vars: Vec<VarTrace>,
}
#[derive(Clone, Serialize)]
pub struct VarInsight {
pub name: String,
pub kind: String,
pub facts: Vec<String>,
}
#[derive(Clone, Serialize, Default)]
pub struct ProvenFacts {
pub scalar: Option<String>,
pub int_range: Option<(i64, i64)>,
pub nonneg: bool,
}
impl From<crate::optimize::VarProvenFacts> for ProvenFacts {
fn from(v: crate::optimize::VarProvenFacts) -> Self {
ProvenFacts {
scalar: v.scalar.map(|s| format!("{s:?}")),
int_range: v.int_range,
nonneg: v.nonneg,
}
}
}
impl ProvenFacts {
fn labels(&self) -> Vec<String> {
let mut out = Vec::new();
match self.int_range {
Some((lo, hi)) => out.push(format!("\u{2208} [{lo}, {hi}]")),
None if self.nonneg => out.push("\u{2265} 0".to_string()),
None => {}
}
if let Some(s) = &self.scalar {
out.push(format!("type {s}"));
}
out
}
}
#[derive(Clone, Serialize)]
pub struct ProvenInsight {
pub name: String,
pub facts: Vec<String>,
}
#[derive(Clone, Serialize, PartialEq, Debug)]
pub enum ProofVerdict {
ProvenTrue,
ProvenFalse,
Unknown,
}
#[derive(Clone, Serialize)]
pub struct AssertionResult {
pub query: String,
pub parsed: bool,
pub now: Option<bool>,
pub now_detail: String,
pub verdict: ProofVerdict,
pub verdict_detail: String,
}
enum Operand {
Var(String),
Int(i64),
}
#[derive(Clone, Copy)]
enum Cmp {
Lt,
Le,
Gt,
Ge,
Eq,
Ne,
}
#[derive(Clone, Serialize)]
pub struct CausalNode {
pub step: usize,
pub pc: usize,
pub op_text: String,
pub narration: String,
pub reg: u16,
pub name: Option<String>,
pub kind: String,
pub value: String,
pub inputs: Vec<CausalNode>,
}
const TIMELINE_MAX_STEPS: usize = 512;
const PROVENANCE_MAX_DEPTH: usize = 24;
const PROVENANCE_MAX_NODES: usize = 96;
#[derive(Clone)]
enum Outcome {
Running,
Done,
Blocked,
Error(String),
}
struct Frame {
state: DebugVmState,
outcome: Outcome,
}
pub struct Debugger {
program: CompiledProgram,
disasm: Vec<DisasmLine>,
history: Vec<Frame>,
cursor: usize,
breakpoints: BTreeSet<usize>,
proven: HashMap<String, ProvenFacts>,
}
impl Debugger {
pub fn from_source(src: &str) -> Result<Debugger, String> {
let (program, proven) = compile_source(src)?;
let disasm = disassemble(&program);
let initial = Vm::new(&program).save_debug_state();
Ok(Debugger {
program,
disasm,
history: vec![Frame { state: initial, outcome: Outcome::Running }],
cursor: 0,
breakpoints: BTreeSet::new(),
proven,
})
}
pub fn step(&mut self) {
self.run_one();
}
pub fn step_over(&mut self) {
let start = self.current_depth();
if !self.run_one() {
return;
}
let mut budget = STEP_LIMIT;
while self.is_paused() && self.current_depth() > start {
if self.at_breakpoint() {
break;
}
if !self.run_one() {
break;
}
budget -= 1;
if budget == 0 {
break;
}
}
}
pub fn step_out(&mut self) {
let start = self.current_depth();
let mut budget = STEP_LIMIT;
loop {
if !self.is_paused() || self.current_depth() < start {
break;
}
if !self.run_one() {
break;
}
if self.at_breakpoint() {
break;
}
budget -= 1;
if budget == 0 {
break;
}
}
}
pub fn resume(&mut self) {
let mut budget = STEP_LIMIT;
loop {
if !self.is_paused() {
break;
}
if !self.run_one() {
break;
}
if self.at_breakpoint() {
break;
}
budget -= 1;
if budget == 0 {
break;
}
}
}
pub fn reverse_resume(&mut self) {
while self.cursor > 0 {
self.cursor -= 1;
if self.breakpoints.contains(&self.current().pc()) {
break;
}
}
}
pub fn step_back(&mut self) {
self.cursor = self.cursor.saturating_sub(1);
}
pub fn seek(&mut self, step: usize) {
self.cursor = step.min(self.history.len().saturating_sub(1));
}
pub fn restart(&mut self) {
self.cursor = 0;
}
pub fn toggle_breakpoint(&mut self, pc: usize) {
if !self.breakpoints.remove(&pc) {
self.breakpoints.insert(pc);
}
}
pub fn set_breakpoint(&mut self, pc: usize) {
self.breakpoints.insert(pc);
}
pub fn clear_breakpoint(&mut self, pc: usize) {
self.breakpoints.remove(&pc);
}
pub fn breakpoints(&self) -> Vec<usize> {
self.breakpoints.iter().copied().collect()
}
pub fn disassembly(&self) -> &[DisasmLine] {
&self.disasm
}
pub fn is_running(&self) -> bool {
self.is_paused()
}
pub fn snapshot(&self) -> DebugSnapshot {
let (view, heap_raw) = self.view_and_heap(self.current());
let prev_inner: HashMap<u16, String> = if self.cursor >= 1 {
let pv = self.view_of(&self.history[self.cursor - 1].state);
if pv.frames.len() == view.frames.len() {
pv.frames
.last()
.map(|f| f.registers.iter().map(|(idx, _kind, val)| (*idx, val.clone())).collect())
.unwrap_or_default()
} else {
HashMap::new()
}
} else {
HashMap::new()
};
let main_names: HashMap<u16, String> =
self.program.reg_names.iter().cloned().collect();
let n = view.frames.len();
let frames: Vec<DebugFrame> = view
.frames
.iter()
.enumerate()
.map(|(fi, f)| {
let inner = fi + 1 == n;
DebugFrame {
function: f.func.map(|i| format!("fn#{i}")),
base: f.base,
registers: f
.registers
.iter()
.map(|(idx, kind, val)| DebugReg {
index: *idx,
name: if f.func.is_none() {
main_names.get(idx).cloned()
} else {
None
},
kind: kind.clone(),
value: val.clone(),
changed: inner
&& prev_inner.get(idx).map(|p| p != val).unwrap_or(false),
})
.collect(),
}
})
.collect();
let (state, error) = match &self.cur().outcome {
Outcome::Running => ("paused", None),
Outcome::Done => ("done", None),
Outcome::Blocked => ("blocked", None),
Outcome::Error(e) => ("error", Some(e.clone())),
};
let op_text = self.disasm.get(view.pc).map(|d| d.text.clone()).unwrap_or_default();
let inner_regs: HashMap<u16, (Option<String>, String)> = frames
.last()
.map(|f: &DebugFrame| {
f.registers.iter().map(|r| (r.index, (r.name.clone(), r.value.clone()))).collect()
})
.unwrap_or_default();
let cur_op = self.program.code.get(view.pc).copied();
let (narration, op_reads, op_writes) = match (&self.cur().outcome, cur_op) {
(Outcome::Error(e), _) => (format!("error: {e}"), Vec::new(), None),
(Outcome::Done, _) => ("the program has finished".to_string(), Vec::new(), None),
(Outcome::Blocked, _) => ("waiting on a concurrency operation".to_string(), Vec::new(), None),
(Outcome::Running, Some(op)) => {
let io = crate::vm::op_io(&op);
(narrate(&op, &inner_regs, &self.program), io.reads, io.writes)
}
_ => (String::new(), Vec::new(), None),
};
let fol = match (&self.cur().outcome, cur_op) {
(Outcome::Running, Some(op)) => fol_of_op(&op, &inner_regs, &self.program),
_ => String::new(),
};
let socratic = match (&self.cur().outcome, cur_op) {
(Outcome::Running, Some(op)) => socratic_of_op(&op, &inner_regs),
(Outcome::Done, _) => "The program has finished — did the result match what you expected?".to_string(),
_ => String::new(),
};
let heap: Vec<HeapObject> = heap_raw
.iter()
.enumerate()
.map(|(i, o)| HeapObject {
id: format!("#{}", i + 1),
kind: o.kind.clone(),
summary: o.summary.clone(),
storage: o.storage.clone(),
rc: o.rc,
referenced_by: o.referenced_by.clone(),
shared: o.referenced_by.len() > 1,
})
.collect();
DebugSnapshot {
pc: view.pc,
op_text,
narration,
fol,
socratic,
op_reads,
op_writes,
state: state.to_string(),
error,
step: self.cursor,
total_steps: self.history.len().saturating_sub(1),
total_ops: self.disasm.len(),
frames,
heap,
globals: view.globals.clone(),
output: view.output.clone(),
at_breakpoint: self.at_breakpoint(),
}
}
pub fn variable_timeline(&self) -> VarTimeline {
let names = self.main_names();
let total = self.history.len();
let start = total.saturating_sub(TIMELINE_MAX_STEPS);
let truncated = start > 0;
let window = &self.history[start..];
let n = window.len();
let mut order: Vec<u16> = names.keys().copied().collect();
order.sort_unstable();
let mut series: HashMap<u16, Vec<Option<(String, String)>>> =
order.iter().map(|r| (*r, vec![None; n])).collect();
for (col, frame) in window.iter().enumerate() {
let view = self.view_of(&frame.state);
if let Some(main) = view.frames.iter().find(|f| f.func.is_none()) {
for (idx, kind, val) in &main.registers {
if let Some(slot) = series.get_mut(idx) {
slot[col] = Some((kind.clone(), val.clone()));
}
}
}
}
let vars = order
.iter()
.map(|reg| {
let name = names.get(reg).cloned().unwrap_or_else(|| format!("R{reg}"));
let raw = &series[reg];
let mut kind = String::new();
let mut points = Vec::with_capacity(n);
let mut prev: Option<String> = None;
for cell in raw {
match cell {
Some((k, v)) => {
if k != "Nothing" || kind.is_empty() {
kind = k.clone();
}
let changed = matches!(&prev, Some(p) if p != v);
points.push(TimelinePoint { value: v.clone(), present: true, changed });
prev = Some(v.clone());
}
None => {
points.push(TimelinePoint {
value: String::new(),
present: false,
changed: false,
});
prev = None;
}
}
}
VarTrace { reg: *reg, name, kind, points }
})
.filter(|t| t.points.iter().any(|p| p.present))
.collect();
VarTimeline { start, steps: n, cursor: self.cursor, truncated, vars }
}
pub fn observed_invariants(&self) -> Vec<VarInsight> {
let tl = self.variable_timeline();
tl.vars
.iter()
.filter_map(|v| {
let first = v.points.iter().position(|p| p.present && p.changed)?;
let vals: Vec<&str> =
v.points[first..].iter().filter(|p| p.present).map(|p| p.value.as_str()).collect();
if vals.is_empty() {
return None;
}
let distinct: BTreeSet<&str> = vals.iter().copied().collect();
let nums: Option<Vec<f64>> = vals.iter().map(|s| s.parse::<f64>().ok()).collect();
let mut facts = Vec::new();
if distinct.len() == 1 {
facts.push(format!("constant {}", vals[0]));
} else {
if let Some(ns) = &nums {
let min = ns.iter().cloned().fold(f64::INFINITY, f64::min);
let max = ns.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
facts.push(format!("range [{}, {}]", fmt_num(min), fmt_num(max)));
if ns.windows(2).all(|w| w[1] >= w[0]) {
facts.push("only increases".to_string());
} else if ns.windows(2).all(|w| w[1] <= w[0]) {
facts.push("only decreases".to_string());
}
}
facts.push(format!("{} distinct values", distinct.len()));
}
Some(VarInsight { name: v.name.clone(), kind: v.kind.clone(), facts })
})
.collect()
}
pub fn proven_invariants(&self) -> Vec<ProvenInsight> {
let mut out: Vec<ProvenInsight> = self
.proven
.iter()
.filter_map(|(name, pf)| {
let facts = pf.labels();
if facts.is_empty() {
None
} else {
Some(ProvenInsight { name: name.clone(), facts })
}
})
.collect();
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
pub fn assert_at_cursor(&self, predicate: &str) -> AssertionResult {
let query = predicate.trim().to_string();
let Some((lhs, cmp, rhs)) = parse_comparison(&query) else {
return AssertionResult {
query,
parsed: false,
now: None,
now_detail: "couldn't parse \u{2014} try a comparison like `x < y` or `x >= 0`".to_string(),
verdict: ProofVerdict::Unknown,
verdict_detail: String::new(),
};
};
let frame = self.snapshot();
let live = |o: &Operand| -> Option<i64> {
match o {
Operand::Int(c) => Some(*c),
Operand::Var(name) => frame
.frames
.last()
.and_then(|f| f.registers.iter().find(|r| r.name.as_deref() == Some(name.as_str())))
.and_then(|r| r.value.parse::<i64>().ok()),
}
};
let now = match (live(&lhs), live(&rhs)) {
(Some(a), Some(b)) => Some(apply_cmp(a, cmp, b)),
_ => None,
};
let now_detail = {
let mut parts = Vec::new();
for o in [&lhs, &rhs] {
if let Operand::Var(name) = o {
match live(o) {
Some(v) => parts.push(format!("{name} = {v}")),
None => parts.push(format!("{name} = ?")),
}
}
}
parts.join(", ")
};
let prange = |o: &Operand| -> Option<(i64, i64)> {
match o {
Operand::Int(c) => Some((*c, *c)),
Operand::Var(name) => self.proven.get(name).and_then(|pf| pf.int_range),
}
};
let (verdict, verdict_detail) = match (prange(&lhs), prange(&rhs)) {
(Some(a), Some(b)) => {
let v = entail(a, cmp, b);
let mut srcs = Vec::new();
if let Operand::Var(n) = &lhs {
srcs.push(format!("{n} \u{2208} [{}, {}]", a.0, a.1));
}
if let Operand::Var(n) = &rhs {
srcs.push(format!("{n} \u{2208} [{}, {}]", b.0, b.1));
}
let detail = match v {
ProofVerdict::Unknown => "the proven ranges don't decide it".to_string(),
_ => format!("from {}", srcs.join(", ")),
};
(v, detail)
}
_ => (ProofVerdict::Unknown, "no proven range for one of the terms".to_string()),
};
AssertionResult { query, parsed: true, now, now_detail, verdict, verdict_detail }
}
pub fn provenance(&self, reg: u16) -> Option<CausalNode> {
let mut budget = PROVENANCE_MAX_NODES;
self.trace_value(reg, self.cursor, PROVENANCE_MAX_DEPTH, &mut budget)
}
fn trace_value(&self, reg: u16, at_step: usize, depth: usize, budget: &mut usize) -> Option<CausalNode> {
let (name, kind, value) = self.reg_value_at(at_step, reg)?;
if *budget == 0 || depth == 0 {
return Some(CausalNode {
step: 0,
pc: 0,
op_text: String::new(),
narration: String::new(),
reg,
name,
kind,
value,
inputs: Vec::new(),
});
}
*budget -= 1;
for i in (1..=at_step).rev() {
let producer_pc = self.history[i - 1].state.pc();
let Some(op) = self.program.code.get(producer_pc).copied() else { continue };
let io = crate::vm::op_io(&op);
if io.writes != Some(reg) {
continue;
}
if let crate::vm::Op::Move { src, .. } = op {
if let Some(mut child) = self.trace_value(src, i - 1, depth, budget) {
if let Some((nm, k, v)) = self.reg_value_at(i, reg) {
child.reg = reg;
child.name = nm;
child.kind = k;
child.value = v;
}
return Some(child);
}
}
let (pname, pkind, pvalue) = self
.reg_value_at(i, reg)
.unwrap_or_else(|| (name.clone(), kind.clone(), value.clone()));
let inner = self.input_regs(i - 1);
let narration = narrate(&op, &inner, &self.program);
let op_text = self.disasm.get(producer_pc).map(|d| d.text.clone()).unwrap_or_default();
let inputs = io
.reads
.iter()
.filter_map(|r| self.trace_value(*r, i - 1, depth - 1, budget))
.collect();
return Some(CausalNode {
step: i,
pc: producer_pc,
op_text,
narration,
reg,
name: pname,
kind: pkind,
value: pvalue,
inputs,
});
}
Some(CausalNode {
step: 0,
pc: 0,
op_text: String::new(),
narration: String::new(),
reg,
name,
kind,
value,
inputs: Vec::new(),
})
}
fn main_names(&self) -> HashMap<u16, String> {
self.program.reg_names.iter().cloned().collect()
}
fn reg_value_at(&self, step: usize, reg: u16) -> Option<(Option<String>, String, String)> {
let frame = self.history.get(step)?;
let view = self.view_of(&frame.state);
let f = view.frames.last()?;
let is_main = f.func.is_none();
f.registers.iter().find(|(idx, _, _)| *idx == reg).map(|(idx, kind, val)| {
let name = if is_main { self.main_names().get(idx).cloned() } else { None };
(name, kind.clone(), val.clone())
})
}
fn input_regs(&self, step: usize) -> HashMap<u16, (Option<String>, String)> {
let names = self.main_names();
let Some(frame) = self.history.get(step) else { return HashMap::new() };
let view = self.view_of(&frame.state);
let Some(f) = view.frames.last() else { return HashMap::new() };
let is_main = f.func.is_none();
f.registers
.iter()
.map(|(idx, _, val)| {
let name = if is_main { names.get(idx).cloned() } else { None };
(*idx, (name, val.clone()))
})
.collect()
}
fn cur(&self) -> &Frame {
&self.history[self.cursor]
}
fn current(&self) -> &DebugVmState {
&self.cur().state
}
fn is_paused(&self) -> bool {
matches!(self.cur().outcome, Outcome::Running)
}
fn current_depth(&self) -> usize {
self.current().call_depth()
}
fn at_breakpoint(&self) -> bool {
self.is_paused() && self.breakpoints.contains(&self.current().pc())
}
fn view_of(&self, st: &DebugVmState) -> crate::vm::DebugView {
let mut vm = Vm::new(&self.program);
vm.restore_debug_state(st.clone());
vm.debug_view()
}
fn view_and_heap(
&self,
st: &DebugVmState,
) -> (crate::vm::DebugView, Vec<crate::vm::HeapObjView>) {
let mut vm = Vm::new(&self.program);
vm.restore_debug_state(st.clone());
(vm.debug_view(), vm.debug_heap())
}
fn run_one(&mut self) -> bool {
if !self.is_paused() {
return false;
}
if self.cursor + 1 < self.history.len() {
self.cursor += 1;
return self.is_paused();
}
let cur_state = self.current().clone();
let mut vm = Vm::new(&self.program);
vm.restore_debug_state(cur_state.clone());
let frame = match vm.run_steps(1) {
Ok(VmStep::Paused) => Frame { state: vm.save_debug_state(), outcome: Outcome::Running },
Ok(VmStep::Done(_)) => Frame { state: vm.save_debug_state(), outcome: Outcome::Done },
Ok(VmStep::Blocked) => Frame { state: vm.save_debug_state(), outcome: Outcome::Blocked },
Err(e) => Frame { state: cur_state, outcome: Outcome::Error(e) },
};
self.history.push(frame);
self.cursor += 1;
self.is_paused()
}
}
fn narrate(
op: &crate::vm::Op,
regs: &HashMap<u16, (Option<String>, String)>,
prog: &CompiledProgram,
) -> String {
use crate::vm::Op;
let name = |r: u16| regs.get(&r).and_then(|(n, _)| n.clone()).unwrap_or_else(|| format!("R{r}"));
let operand = |r: u16| match regs.get(&r) {
Some((_, v)) if !v.is_empty() => format!("{}({})", name(r), v),
_ => name(r),
};
match *op {
Op::LoadConst { dst, idx } => {
format!("load {} into {}", crate::vm::format_constant(prog, idx), name(dst))
}
Op::Move { dst, src } => format!("copy {} into {}", operand(src), name(dst)),
Op::Add { dst, lhs, rhs } => format!("add {} + {} \u{2192} {}", operand(lhs), operand(rhs), name(dst)),
Op::Sub { dst, lhs, rhs } => format!("subtract {} - {} \u{2192} {}", operand(lhs), operand(rhs), name(dst)),
Op::Mul { dst, lhs, rhs } => format!("multiply {} \u{00d7} {} \u{2192} {}", operand(lhs), operand(rhs), name(dst)),
Op::Div { dst, lhs, rhs } | Op::ExactDiv { dst, lhs, rhs } => {
format!("divide {} \u{00f7} {} \u{2192} {}", operand(lhs), operand(rhs), name(dst))
}
Op::Mod { dst, lhs, rhs } => format!("{} mod {} \u{2192} {}", operand(lhs), operand(rhs), name(dst)),
Op::Lt { dst, lhs, rhs } => format!("is {} < {}? \u{2192} {}", operand(lhs), operand(rhs), name(dst)),
Op::Gt { dst, lhs, rhs } => format!("is {} > {}? \u{2192} {}", operand(lhs), operand(rhs), name(dst)),
Op::LtEq { dst, lhs, rhs } => format!("is {} <= {}? \u{2192} {}", operand(lhs), operand(rhs), name(dst)),
Op::GtEq { dst, lhs, rhs } => format!("is {} >= {}? \u{2192} {}", operand(lhs), operand(rhs), name(dst)),
Op::Eq { dst, lhs, rhs } => format!("is {} == {}? \u{2192} {}", operand(lhs), operand(rhs), name(dst)),
Op::NotEq { dst, lhs, rhs } => format!("is {} != {}? \u{2192} {}", operand(lhs), operand(rhs), name(dst)),
Op::AddAssign { dst, src } => format!("append {} onto {}", operand(src), name(dst)),
Op::Not { dst, src } => format!("negate {} \u{2192} {}", operand(src), name(dst)),
Op::Show { src } => format!("print {}", operand(src)),
Op::Return { src } => format!("return {}", operand(src)),
Op::ReturnNothing => "return".to_string(),
Op::Jump { target } => format!("jump to step {target}"),
Op::JumpIfFalse { cond, target } => format!("if {} is false, jump to {target}", operand(cond)),
Op::JumpIfTrue { cond, target } => format!("if {} is true, jump to {target}", operand(cond)),
Op::Index { dst, collection, index } | Op::IndexUnchecked { dst, collection, index } => {
format!("read {}[{}] \u{2192} {}", name(collection), operand(index), name(dst))
}
Op::SetIndex { collection, index, value } | Op::SetIndexUnchecked { collection, index, value } => {
format!("set {}[{}] = {}", name(collection), operand(index), operand(value))
}
Op::Length { dst, collection } => format!("count {} \u{2192} {}", name(collection), name(dst)),
Op::ListPush { list, value } => format!("push {} onto {}", operand(value), name(list)),
Op::NewEmptyList { dst } | Op::NewEmptyListI32 { dst } => format!("make an empty list \u{2192} {}", name(dst)),
Op::Call { .. } => "call a function".to_string(),
Op::Halt => "halt \u{2014} the program is done".to_string(),
_ => String::new(),
}
}
fn fol_of_op(
op: &crate::vm::Op,
regs: &HashMap<u16, (Option<String>, String)>,
prog: &CompiledProgram,
) -> String {
use crate::vm::Op;
let name = |r: u16| regs.get(&r).and_then(|(n, _)| n.clone()).unwrap_or_else(|| format!("R{r}"));
match *op {
Op::LoadConst { dst, idx } => format!("{} = {}", name(dst), crate::vm::format_constant(prog, idx)),
Op::Move { dst, src } => format!("{} = {}", name(dst), name(src)),
Op::Add { dst, lhs, rhs } => format!("{} = {} + {}", name(dst), name(lhs), name(rhs)),
Op::Sub { dst, lhs, rhs } => format!("{} = {} \u{2212} {}", name(dst), name(lhs), name(rhs)),
Op::Mul { dst, lhs, rhs } => format!("{} = {} \u{00d7} {}", name(dst), name(lhs), name(rhs)),
Op::Div { dst, lhs, rhs } | Op::ExactDiv { dst, lhs, rhs } => {
format!("{} = {} \u{00f7} {}", name(dst), name(lhs), name(rhs))
}
Op::Mod { dst, lhs, rhs } => format!("{} = {} mod {}", name(dst), name(lhs), name(rhs)),
Op::Lt { dst, lhs, rhs } => format!("{} \u{27fa} ({} < {})", name(dst), name(lhs), name(rhs)),
Op::Gt { dst, lhs, rhs } => format!("{} \u{27fa} ({} > {})", name(dst), name(lhs), name(rhs)),
Op::LtEq { dst, lhs, rhs } => format!("{} \u{27fa} ({} \u{2264} {})", name(dst), name(lhs), name(rhs)),
Op::GtEq { dst, lhs, rhs } => format!("{} \u{27fa} ({} \u{2265} {})", name(dst), name(lhs), name(rhs)),
Op::Eq { dst, lhs, rhs } => format!("{} \u{27fa} ({} = {})", name(dst), name(lhs), name(rhs)),
Op::NotEq { dst, lhs, rhs } => format!("{} \u{27fa} ({} \u{2260} {})", name(dst), name(lhs), name(rhs)),
Op::Not { dst, src } => format!("{} \u{27fa} \u{00ac}{}", name(dst), name(src)),
Op::AddAssign { dst, src } => format!("{} \u{2254} {} \u{29fa} {}", name(dst), name(dst), name(src)),
Op::Index { dst, collection, index } | Op::IndexUnchecked { dst, collection, index } => {
format!("{} = {}[{}]", name(dst), name(collection), name(index))
}
Op::SetIndex { collection, index, value } | Op::SetIndexUnchecked { collection, index, value } => {
format!("{}[{}] \u{2254} {}", name(collection), name(index), name(value))
}
Op::Length { dst, collection } => format!("{} = |{}|", name(dst), name(collection)),
Op::ListPush { list, value } => format!("{} \u{2254} {} \u{2295} {}", name(list), name(list), name(value)),
Op::NewEmptyList { dst } | Op::NewEmptyListI32 { dst } => format!("{} = \u{2205}", name(dst)),
Op::Return { src } => format!("result = {}", name(src)),
Op::Jump { target } => format!("goto {target}"),
Op::JumpIfFalse { cond, target } => format!("\u{00ac}{} \u{2192} goto {target}", name(cond)),
Op::JumpIfTrue { cond, target } => format!("{} \u{2192} goto {target}", name(cond)),
_ => String::new(),
}
}
fn socratic_of_op(op: &crate::vm::Op, regs: &HashMap<u16, (Option<String>, String)>) -> String {
use crate::vm::Op;
let name = |r: u16| regs.get(&r).and_then(|(n, _)| n.clone()).unwrap_or_else(|| format!("R{r}"));
let nv = |r: u16| match regs.get(&r) {
Some((_, v)) if !v.is_empty() => format!("{} ({})", name(r), v),
_ => name(r),
};
match *op {
Op::Add { lhs, rhs, .. } => format!("{} and {} \u{2014} what is their sum?", nv(lhs), nv(rhs)),
Op::Sub { lhs, rhs, .. } => format!("{} minus {} \u{2014} what's left?", nv(lhs), nv(rhs)),
Op::Mul { lhs, rhs, .. } => format!("{} times {} \u{2014} what do you get?", nv(lhs), nv(rhs)),
Op::Div { lhs, rhs, .. } | Op::ExactDiv { lhs, rhs, .. } => {
format!("{} divided by {} \u{2014} what is the quotient?", nv(lhs), nv(rhs))
}
Op::Mod { lhs, rhs, .. } => format!("What remains when {} is divided by {}?", nv(lhs), nv(rhs)),
Op::Lt { lhs, rhs, .. } => format!("Is {} less than {}?", nv(lhs), nv(rhs)),
Op::Gt { lhs, rhs, .. } => format!("Is {} greater than {}?", nv(lhs), nv(rhs)),
Op::LtEq { lhs, rhs, .. } => format!("Is {} at most {}?", nv(lhs), nv(rhs)),
Op::GtEq { lhs, rhs, .. } => format!("Is {} at least {}?", nv(lhs), nv(rhs)),
Op::Eq { lhs, rhs, .. } => format!("Does {} equal {}?", nv(lhs), nv(rhs)),
Op::NotEq { lhs, rhs, .. } => format!("Are {} and {} different?", nv(lhs), nv(rhs)),
Op::Not { src, .. } => format!("{} \u{2014} what is its negation?", nv(src)),
Op::JumpIfFalse { cond, .. } => {
format!("{} \u{2014} will the program take this branch, or fall through?", nv(cond))
}
Op::JumpIfTrue { cond, .. } => {
format!("{} \u{2014} is the condition met, so the program jumps?", nv(cond))
}
Op::Index { collection, index, .. } | Op::IndexUnchecked { collection, index, .. } => {
format!("What sits at position {} of {}?", nv(index), name(collection))
}
Op::Length { collection, .. } => format!("How many items does {} hold?", name(collection)),
Op::Return { src } => {
format!("The result is about to be {} \u{2014} is that what you predicted?", nv(src))
}
_ => String::new(),
}
}
fn parse_comparison(s: &str) -> Option<(Operand, Cmp, Operand)> {
for (sym, cmp) in [("<=", Cmp::Le), (">=", Cmp::Ge), ("==", Cmp::Eq), ("!=", Cmp::Ne)] {
if let Some(i) = s.find(sym) {
return build_cmp(&s[..i], &s[i + sym.len()..], cmp);
}
}
for (sym, cmp) in [('<', Cmp::Lt), ('>', Cmp::Gt), ('=', Cmp::Eq)] {
if let Some(i) = s.find(sym) {
return build_cmp(&s[..i], &s[i + 1..], cmp);
}
}
None
}
fn build_cmp(l: &str, r: &str, cmp: Cmp) -> Option<(Operand, Cmp, Operand)> {
Some((operand_of(l)?, cmp, operand_of(r)?))
}
fn operand_of(s: &str) -> Option<Operand> {
let t = s.trim();
if t.is_empty() {
return None;
}
Some(match t.parse::<i64>() {
Ok(n) => Operand::Int(n),
Err(_) => Operand::Var(t.to_string()),
})
}
fn apply_cmp(a: i64, cmp: Cmp, b: i64) -> bool {
match cmp {
Cmp::Lt => a < b,
Cmp::Le => a <= b,
Cmp::Gt => a > b,
Cmp::Ge => a >= b,
Cmp::Eq => a == b,
Cmp::Ne => a != b,
}
}
fn entail((al, ah): (i64, i64), cmp: Cmp, (bl, bh): (i64, i64)) -> ProofVerdict {
use ProofVerdict::{ProvenFalse, ProvenTrue, Unknown};
let singleton_eq = al == ah && bl == bh && al == bl;
let disjoint = ah < bl || bh < al;
match cmp {
Cmp::Lt => {
if ah < bl {
ProvenTrue
} else if al >= bh {
ProvenFalse
} else {
Unknown
}
}
Cmp::Le => {
if ah <= bl {
ProvenTrue
} else if al > bh {
ProvenFalse
} else {
Unknown
}
}
Cmp::Gt => {
if al > bh {
ProvenTrue
} else if ah <= bl {
ProvenFalse
} else {
Unknown
}
}
Cmp::Ge => {
if al >= bh {
ProvenTrue
} else if ah < bl {
ProvenFalse
} else {
Unknown
}
}
Cmp::Eq => {
if singleton_eq {
ProvenTrue
} else if disjoint {
ProvenFalse
} else {
Unknown
}
}
Cmp::Ne => {
if disjoint {
ProvenTrue
} else if singleton_eq {
ProvenFalse
} else {
Unknown
}
}
}
}
fn fmt_num(n: f64) -> String {
if n.fract() == 0.0 && n.abs() < 9.007e15 {
format!("{}", n as i64)
} else {
format!("{n}")
}
}
fn compile_source(src: &str) -> Result<(CompiledProgram, HashMap<String, ProvenFacts>), String> {
crate::ui_bridge::with_parsed_program(src, |parsed, interner| {
let (stmts, types, _policies) = parsed?;
let program = crate::vm::Compiler::compile_for_debug(stmts, interner, Some(types))?;
let facts = crate::optimize::oracle_analyze_with(stmts, interner);
let proven = facts
.summarize_variables(stmts)
.into_iter()
.map(|(sym, vf)| (interner.resolve(sym).to_string(), ProvenFacts::from(vf)))
.collect();
Ok((program, proven))
})
}
#[cfg(test)]
mod tests {
use super::*;
const PROG: &str = "## Main\n\nLet x be 6.\nLet y be 7.\nShow x + y.";
fn run_to_done(dbg: &mut Debugger) {
for _ in 0..10_000 {
if dbg.snapshot().state != "paused" {
break;
}
dbg.step();
}
}
fn pc_of(dbg: &Debugger, prefix: &str) -> usize {
dbg.disassembly()
.iter()
.find(|l| l.text.starts_with(prefix))
.unwrap_or_else(|| panic!("no `{prefix}` op in the disassembly"))
.pc
}
#[test]
fn arms_at_entry() {
let dbg = Debugger::from_source(PROG).expect("compiles");
let s = dbg.snapshot();
assert_eq!(s.step, 0, "history cursor at the start");
assert_eq!(s.pc, 0, "stopped before the first op");
assert_eq!(s.state, "paused");
assert!(s.output.is_empty());
assert!(s.total_ops > 0, "the program has instructions");
assert!(!s.frames.is_empty(), "at least the Main frame");
}
#[test]
fn stepping_to_done_matches_a_normal_run() {
let mut dbg = Debugger::from_source(PROG).expect("compiles");
run_to_done(&mut dbg);
let s = dbg.snapshot();
assert_eq!(s.state, "done", "reaches completion");
let interp = crate::ui_bridge::interpret_for_ui_sync_with_args(PROG, &[]);
assert_eq!(interp.error, None);
assert_eq!(s.output, interp.lines, "stepped output == single-shot run output");
}
#[test]
fn disassembly_is_faithful_to_the_source() {
let dbg = Debugger::from_source(PROG).expect("compiles");
let texts: Vec<&str> = dbg.disassembly().iter().map(|l| l.text.as_str()).collect();
assert!(texts[0].starts_with("LoadConst"), "first op loads a literal");
assert_eq!(texts.last(), Some(&"Halt"), "program ends in Halt");
assert!(texts.iter().any(|t| t.starts_with("Add")), "the `x + y` add is present");
assert!(texts.iter().any(|t| t.starts_with("Show")), "the `Show` is present");
}
#[test]
fn step_into_advances_one_op() {
let mut dbg = Debugger::from_source(PROG).expect("compiles");
assert!(!dbg.snapshot().op_text.is_empty(), "paused on a real op");
dbg.step();
let s = dbg.snapshot();
assert_eq!(s.step, 1, "history cursor advanced one op");
assert_eq!(s.pc, 1, "straight-line pc advanced");
}
#[test]
fn registers_carry_their_values() {
let mut dbg = Debugger::from_source(PROG).expect("compiles");
let show = pc_of(&dbg, "Show");
dbg.set_breakpoint(show);
dbg.resume();
let s = dbg.snapshot();
assert_eq!(s.pc, show);
let main = &s.frames[0];
let values: Vec<&str> = main.registers.iter().map(|r| r.value.as_str()).collect();
assert!(values.contains(&"6"), "x's value is live in a register: {values:?}");
assert!(values.contains(&"7"), "y's value is live in a register: {values:?}");
assert!(values.contains(&"13"), "x + y was computed: {values:?}");
assert!(
main.registers.iter().any(|r| r.changed),
"the most recent write is flagged changed"
);
}
#[test]
fn registers_carry_their_type() {
let mut dbg = Debugger::from_source(PROG).expect("compiles");
let show = pc_of(&dbg, "Show");
dbg.set_breakpoint(show);
dbg.resume();
let s = dbg.snapshot();
let typed: Vec<(&str, &str)> = s.frames[0]
.registers
.iter()
.map(|r| (r.kind.as_str(), r.value.as_str()))
.collect();
assert!(typed.contains(&("Int", "6")), "x:6 is typed Int: {typed:?}");
assert!(typed.contains(&("Int", "13")), "x+y:13 is typed Int: {typed:?}");
assert!(
s.frames[0].registers.iter().all(|r| !r.kind.is_empty()),
"no live register is left untyped: {typed:?}"
);
}
#[test]
fn variable_timeline_tracks_each_variable_over_time() {
let mut dbg = Debugger::from_source(PROG).expect("compiles");
dbg.resume();
let tl = dbg.variable_timeline();
let names: Vec<&str> = tl.vars.iter().map(|v| v.name.as_str()).collect();
assert!(names.contains(&"x"), "x is a traced variable: {names:?}");
assert!(names.contains(&"y"), "y is a traced variable: {names:?}");
for v in &tl.vars {
assert_eq!(v.points.len(), tl.steps, "trace {} spans the timeline", v.name);
}
let x = tl.vars.iter().find(|v| v.name == "x").unwrap();
assert_eq!(x.kind, "Int", "x is typed on its trace");
assert!(x.points.iter().any(|p| p.value == "6" && p.changed), "x has a 6-edge");
assert_eq!(x.points.last().unwrap().value, "6", "x ends at 6");
assert_eq!(tl.cursor, dbg.snapshot().step, "playhead is at the cursor");
}
#[test]
fn snapshot_carries_fol_semantics() {
let mut dbg = Debugger::from_source(PROG).expect("compiles");
let mut fols = Vec::new();
while dbg.is_running() {
let s = dbg.snapshot();
if !s.fol.is_empty() {
fols.push(s.fol.clone());
}
dbg.step();
}
assert!(fols.iter().any(|f| f == "R0 = 6"), "literal load reads as R0 = 6: {fols:?}");
assert!(fols.iter().any(|f| f == "x = R0"), "the copy into x reads as x = R0: {fols:?}");
assert!(fols.iter().any(|f| f.contains("x + y")), "the addition reads over named vars: {fols:?}");
}
#[test]
fn fol_renders_a_comparison_as_a_biconditional() {
let src = "## Main\n\nLet x be 6.\nLet y be 7.\nLet t be x < y.\nShow t.";
let mut dbg = Debugger::from_source(src).expect("compiles");
let mut fols = Vec::new();
while dbg.is_running() {
let s = dbg.snapshot();
if !s.fol.is_empty() {
fols.push(s.fol.clone());
}
dbg.step();
}
assert!(
fols.iter().any(|f| f.contains("\u{27fa}") && f.contains("x < y")),
"the comparison reads as a biconditional: {fols:?}"
);
}
#[test]
fn socratic_prompt_asks_the_learner_to_predict() {
let mut dbg = Debugger::from_source(PROG).expect("compiles");
let mut qs = Vec::new();
while dbg.is_running() {
let s = dbg.snapshot();
if !s.socratic.is_empty() {
qs.push(s.socratic.clone());
}
dbg.step();
}
assert!(
qs.iter().any(|q| q.ends_with('?') && q.contains("sum") && q.contains("(6)") && q.contains("(7)")),
"the addition asks the learner to predict the sum from the live operands: {qs:?}"
);
assert!(qs.iter().all(|q| q.contains('?')), "every Socratic prompt is a question: {qs:?}");
}
#[test]
fn socratic_poses_a_yes_no_question_for_a_comparison() {
let src = "## Main\n\nLet x be 6.\nLet y be 7.\nLet t be x < y.\nShow t.";
let mut dbg = Debugger::from_source(src).expect("compiles");
let mut qs = Vec::new();
while dbg.is_running() {
let s = dbg.snapshot();
if !s.socratic.is_empty() {
qs.push(s.socratic.clone());
}
dbg.step();
}
assert!(
qs.iter().any(|q| q.starts_with("Is ") && q.contains("less than") && q.ends_with('?')),
"the comparison is posed as a yes/no question: {qs:?}"
);
}
#[test]
fn socratic_done_state_invites_reflection() {
let mut dbg = Debugger::from_source(PROG).expect("compiles");
dbg.resume();
let s = dbg.snapshot();
assert!(s.socratic.contains("expected"), "the finished prompt invites reflection: {}", s.socratic);
}
#[test]
fn live_proof_is_true_now_and_proven_for_every_run() {
let mut dbg = Debugger::from_source(PROG).expect("compiles");
dbg.resume();
let r = dbg.assert_at_cursor("x < y");
assert!(r.parsed, "parsed the comparison");
assert_eq!(r.now, Some(true), "x=6 < y=7 holds now: {}", r.now_detail);
assert_eq!(r.verdict, ProofVerdict::ProvenTrue, "proven for every run: {}", r.verdict_detail);
assert!(r.verdict_detail.contains("[6, 6]"), "cites the proven ranges: {}", r.verdict_detail);
}
#[test]
fn live_proof_statically_refutes_a_false_predicate() {
let mut dbg = Debugger::from_source(PROG).expect("compiles");
dbg.resume();
let r = dbg.assert_at_cursor("x > y");
assert_eq!(r.now, Some(false), "x=6 > y=7 is false now");
assert_eq!(r.verdict, ProofVerdict::ProvenFalse, "refuted for every run: {}", r.verdict_detail);
}
#[test]
fn live_proof_proves_a_constant_equality_and_a_bound() {
let mut dbg = Debugger::from_source(PROG).expect("compiles");
dbg.resume();
assert_eq!(dbg.assert_at_cursor("x == 6").verdict, ProofVerdict::ProvenTrue, "x is provably 6");
assert_eq!(dbg.assert_at_cursor("x >= 0").verdict, ProofVerdict::ProvenTrue, "x is provably non-negative");
assert_eq!(dbg.assert_at_cursor("y <= 100").verdict, ProofVerdict::ProvenTrue, "y is provably under 100");
}
#[test]
fn live_proof_rejects_unparseable_input() {
let dbg = Debugger::from_source(PROG).expect("compiles");
let r = dbg.assert_at_cursor("hello world");
assert!(!r.parsed, "garbage is not a comparison");
assert_eq!(r.verdict, ProofVerdict::Unknown);
}
#[test]
fn proven_invariants_prove_constant_values_and_types() {
let dbg = Debugger::from_source(PROG).expect("compiles");
let proven = dbg.proven_invariants();
let names: Vec<&str> = proven.iter().map(|p| p.name.as_str()).collect();
assert!(names.contains(&"x"), "x has proven facts: {names:?}");
let x = proven.iter().find(|p| p.name == "x").unwrap();
assert!(x.facts.iter().any(|f| f.contains("[6, 6]")), "x proven \u{2208} [6,6]: {:?}", x.facts);
assert!(x.facts.iter().any(|f| f.contains("Int")), "x proven Int: {:?}", x.facts);
let y = proven.iter().find(|p| p.name == "y").unwrap();
assert!(y.facts.iter().any(|f| f.contains("[7, 7]")), "y proven \u{2208} [7,7]: {:?}", y.facts);
}
#[test]
fn proven_facts_are_available_without_stepping() {
let dbg = Debugger::from_source(PROG).expect("compiles");
assert_eq!(dbg.snapshot().step, 0, "fresh debugger, nothing executed");
assert!(!dbg.proven_invariants().is_empty(), "proven facts ready before any step");
}
#[test]
fn observed_invariants_report_a_constant() {
let mut dbg = Debugger::from_source(PROG).expect("compiles");
dbg.resume();
let ins = dbg.observed_invariants();
let x = ins.iter().find(|i| i.name == "x").expect("x has insights");
assert!(
x.facts.iter().any(|f| f.contains("constant") && f.contains('6')),
"x is observed constant 6: {:?}",
x.facts
);
}
#[test]
fn observed_invariants_detect_a_monotonic_range() {
let src = "## Main\n\nLet n be 1.\nSet n to 2.\nSet n to 3.\nShow n.";
let mut dbg = Debugger::from_source(src).expect("compiles");
dbg.resume();
let ins = dbg.observed_invariants();
let n = ins.iter().find(|i| i.name == "n").expect("n has insights");
assert!(
n.facts.iter().any(|f| f.contains("range") && f.contains('1') && f.contains('3')),
"n ranges over [1,3]: {:?}",
n.facts
);
assert!(n.facts.iter().any(|f| f.contains("increase")), "n only increases: {:?}", n.facts);
}
#[test]
fn provenance_explains_why_a_value_exists() {
let mut dbg = Debugger::from_source(PROG).expect("compiles");
dbg.resume();
let snap = dbg.snapshot();
let sum = snap.frames.last().unwrap().registers.iter().find(|r| r.value == "13").unwrap().index;
let node = dbg.provenance(sum).expect("13 has a provenance");
assert_eq!(node.value, "13");
assert!(
node.narration.contains("add") || node.op_text.to_lowercase().contains("add"),
"the sum was produced by an add: {} / {}",
node.op_text,
node.narration
);
let input_vals: Vec<&str> = node.inputs.iter().map(|n| n.value.as_str()).collect();
assert!(input_vals.contains(&"6"), "one input is 6: {input_vals:?}");
assert!(input_vals.contains(&"7"), "one input is 7: {input_vals:?}");
}
#[test]
fn provenance_bottoms_out_at_a_constant_load() {
let mut dbg = Debugger::from_source(PROG).expect("compiles");
dbg.resume();
let snap = dbg.snapshot();
let x = snap
.frames
.last()
.unwrap()
.registers
.iter()
.find(|r| r.name.as_deref() == Some("x"))
.unwrap()
.index;
let node = dbg.provenance(x).expect("x has a provenance");
assert_eq!(node.value, "6");
assert!(node.inputs.is_empty(), "a literal load consumes no registers");
assert!(
node.narration.contains("load") || node.op_text.to_lowercase().contains("load"),
"x came from a load: {} / {}",
node.op_text,
node.narration
);
}
#[test]
fn provenance_chains_through_a_dependent_assignment() {
let src = "## Main\n\nLet x be 6.\nLet y be 7.\nLet z be x + y.\nLet w be z + x.\nShow w.";
let mut dbg = Debugger::from_source(src).expect("compiles");
dbg.resume();
let snap = dbg.snapshot();
let w = snap
.frames
.last()
.unwrap()
.registers
.iter()
.find(|r| r.name.as_deref() == Some("w"))
.unwrap()
.index;
let node = dbg.provenance(w).expect("w has a provenance");
assert_eq!(node.value, "19", "w = (6+7) + 6");
let z = node.inputs.iter().find(|n| n.value == "13").expect("w reads z=13");
let z_inputs: Vec<&str> = z.inputs.iter().map(|n| n.value.as_str()).collect();
assert!(z_inputs.contains(&"6") && z_inputs.contains(&"7"), "z traces to 6 and 7: {z_inputs:?}");
}
#[test]
fn variable_names_are_resolved() {
let mut dbg = Debugger::from_source(PROG).expect("compiles");
let show = pc_of(&dbg, "Show");
dbg.set_breakpoint(show);
dbg.resume();
let s = dbg.snapshot();
let named: Vec<(&str, &str)> = s.frames[0]
.registers
.iter()
.filter_map(|r| r.name.as_deref().map(|n| (n, r.value.as_str())))
.collect();
assert!(named.contains(&("x", "6")), "x = 6 shown by name: {named:?}");
assert!(named.contains(&("y", "7")), "y = 7 shown by name: {named:?}");
}
#[test]
fn production_compile_carries_no_debug_names() {
let (dbg_prog, _proven) = compile_source(PROG).expect("debug compile");
assert!(!dbg_prog.reg_names.is_empty(), "debug path records reg_names");
let prod_prog = crate::ui_bridge::with_parsed_program(PROG, |parsed, interner| {
let (stmts, types, _policies) = parsed?;
crate::vm::Compiler::compile_with_types(stmts, interner, Some(types))
})
.expect("production compile");
assert!(prod_prog.reg_names.is_empty(), "production path captures no debug names");
}
#[test]
fn breakpoint_halts_continue() {
let mut dbg = Debugger::from_source(PROG).expect("compiles");
let show = pc_of(&dbg, "Show");
dbg.set_breakpoint(show);
dbg.resume();
let s = dbg.snapshot();
assert_eq!(s.pc, show, "Continue stopped at the breakpoint");
assert!(s.at_breakpoint);
assert_eq!(s.state, "paused");
assert!(s.output.is_empty(), "the Show has not run yet");
dbg.step();
assert!(!dbg.snapshot().output.is_empty(), "stepping the Show emits a line");
}
#[test]
fn time_travel_steps_backwards() {
let mut dbg = Debugger::from_source(PROG).expect("compiles");
dbg.step();
dbg.step();
dbg.step();
let three = dbg.snapshot();
assert_eq!(three.step, 3);
let pc_at_three = three.pc;
dbg.step_back();
assert_eq!(dbg.snapshot().step, 2, "rewound one op");
dbg.step();
let again = dbg.snapshot();
assert_eq!(again.step, 3);
assert_eq!(again.pc, pc_at_three, "replay is deterministic");
}
#[test]
fn restart_rewinds_to_entry() {
let mut dbg = Debugger::from_source(PROG).expect("compiles");
run_to_done(&mut dbg);
assert_eq!(dbg.snapshot().state, "done");
dbg.restart();
let s = dbg.snapshot();
assert_eq!(s.step, 0);
assert_eq!(s.pc, 0);
assert_eq!(s.state, "paused");
assert!(s.output.is_empty());
}
const FUNC: &str =
"## To double (x: Int) -> Int:\n Return x + x.\n\n## Main\nLet result be double(5).\nShow result.";
const WHILE: &str = "## Main\nLet mutable sum be 0.\nLet mutable i be 1.\nWhile i is at most 5:\n Set sum to sum + i.\n Set i to i + 1.\nShow sum.";
fn drive_to_end(src: &str) -> DebugSnapshot {
let mut dbg = Debugger::from_source(src)
.unwrap_or_else(|e| panic!("compile failed: {e}\n--- src ---\n{src}"));
let mut guard = 0;
while dbg.is_running() && guard < 500_000 {
dbg.step();
guard += 1;
}
dbg.snapshot()
}
#[test]
fn corpus_stepped_output_matches_interpreter() {
let corpus: &[(&str, &str)] = &[
("while_loop", WHILE),
("repeat_range", "## Main\nLet mutable total be 0.\nRepeat for i from 1 to 5:\n Set total to total + i.\nShow total."),
("for_in", "## Main\nLet mutable sum be 0.\nRepeat for x in [10, 20, 30]:\n Set sum to sum + x.\nShow sum."),
("conditional", "## Main\nLet x be 3.\nIf x is greater than 5:\n Show \"big\".\nOtherwise:\n Show \"small\"."),
("function", FUNC),
("recursion", "## To fib (n: Int) -> Int:\n If n is less than 2:\n Return n.\n Return fib(n - 1) + fib(n - 2).\n\n## Main\nShow fib(6)."),
("list", "## Main\nLet xs be [1, 2, 3].\nPush 4 to xs.\nShow length of xs."),
];
for (name, src) in corpus {
let oracle = crate::ui_bridge::interpret_for_ui_sync_with_args(src, &[]);
assert_eq!(oracle.error, None, "{name}: the interpreter itself errored: {:?}", oracle.error);
let snap = drive_to_end(src);
assert_eq!(
snap.state, "done",
"{name}: debugger did not finish (state={}, out={:?})", snap.state, snap.output
);
assert_eq!(snap.output, oracle.lines, "{name}: stepped output diverged from the interpreter");
}
}
#[test]
fn stepping_a_while_loop_runs_all_iterations() {
let snap = drive_to_end(WHILE);
assert_eq!(snap.state, "done");
assert_eq!(snap.output, vec!["15".to_string()], "op-by-op stepping summed 1..=5");
}
#[test]
fn call_stack_descends_into_the_function() {
let mut dbg = Debugger::from_source(FUNC).expect("compiles");
let mut entered = false;
let mut guard = 0;
while dbg.is_running() && guard < 10_000 {
let s = dbg.snapshot();
if s.frames.len() >= 2 {
entered = true;
assert!(s.frames.last().unwrap().function.is_some(), "inner frame is a function");
break;
}
dbg.step();
guard += 1;
}
assert!(entered, "stepping descends into the called function");
}
#[test]
fn step_over_runs_the_call_without_descending() {
let mut dbg = Debugger::from_source(FUNC).expect("compiles");
let call_pc = pc_of(&dbg, "Call");
let mut guard = 0;
while dbg.snapshot().pc != call_pc && dbg.is_running() && guard < 1000 {
dbg.step();
guard += 1;
}
assert_eq!(dbg.snapshot().pc, call_pc, "reached the Call op");
let depth = dbg.snapshot().frames.len();
dbg.step_over();
let s = dbg.snapshot();
assert_eq!(s.frames.len(), depth, "step-over did not leave us inside the callee");
assert!(s.pc > call_pc || s.state == "done", "advanced past the call");
}
#[test]
fn step_out_returns_to_the_caller() {
let mut dbg = Debugger::from_source(FUNC).expect("compiles");
let mut guard = 0;
while dbg.is_running() && dbg.snapshot().frames.len() < 2 && guard < 1000 {
dbg.step();
guard += 1;
}
assert_eq!(dbg.snapshot().frames.len(), 2, "inside the function");
dbg.step_out();
assert!(dbg.snapshot().frames.len() <= 1, "step-out returns to the caller");
}
#[test]
fn runtime_error_surfaces_without_panicking() {
let mut dbg = Debugger::from_source("## Main\nLet x be 1 / 0.\nShow x.").expect("compiles");
let mut guard = 0;
while dbg.is_running() && guard < 100 {
dbg.step();
guard += 1;
}
let s = dbg.snapshot();
assert_eq!(s.state, "error", "division by zero surfaces as an error, not a panic");
assert!(s.error.is_some(), "the error carries a message");
}
#[test]
fn entering_a_function_does_not_flag_spurious_changes() {
let mut dbg = Debugger::from_source(FUNC).expect("compiles");
let mut guard = 0;
while dbg.is_running() && dbg.snapshot().frames.len() < 2 && guard < 1000 {
dbg.step();
guard += 1;
}
let s = dbg.snapshot();
assert_eq!(s.frames.len(), 2, "entered the function");
assert!(
s.frames.last().unwrap().registers.iter().all(|r| !r.changed),
"crossing into a new frame must not flag stale registers as changed"
);
}
#[test]
fn time_travel_across_a_call_restores_the_frame() {
let mut dbg = Debugger::from_source(FUNC).expect("compiles");
let mut guard = 0;
while dbg.is_running() && dbg.snapshot().frames.len() < 2 && guard < 1000 {
dbg.step();
guard += 1;
}
let inside = dbg.snapshot();
assert_eq!(inside.frames.len(), 2, "inside the function");
let (pc, step) = (inside.pc, inside.step);
dbg.step();
dbg.step_back();
let back = dbg.snapshot();
assert_eq!(back.step, step, "rewound to the in-function step");
assert_eq!(back.pc, pc, "pc restored exactly");
assert_eq!(back.frames.len(), 2, "still inside the function after the rewind");
}
#[test]
fn narration_explains_each_step_in_english() {
let mut dbg = Debugger::from_source(PROG).expect("compiles");
let mut narrations = Vec::new();
let mut guard = 0;
while dbg.is_running() && guard < 1000 {
let s = dbg.snapshot();
if !s.narration.is_empty() {
narrations.push(s.narration.clone());
}
dbg.step();
guard += 1;
}
let all = narrations.join(" | ");
assert!(all.contains("add"), "an add step is narrated in English: {all}");
assert!(all.contains("print"), "the print is narrated: {all}");
assert!(
narrations.iter().any(|n| n.contains("x(6)") && n.contains("y(7)")),
"the add narration names the variables and their live values: {narrations:?}"
);
}
#[test]
fn op_io_in_snapshot_targets_the_operands() {
let mut dbg = Debugger::from_source(PROG).expect("compiles");
let add = pc_of(&dbg, "Add");
let mut guard = 0;
while dbg.snapshot().pc != add && dbg.is_running() && guard < 100 {
dbg.step();
guard += 1;
}
let s = dbg.snapshot();
assert_eq!(s.pc, add);
assert!(s.op_writes.is_some(), "the Add writes a destination register");
assert_eq!(s.op_reads.len(), 2, "the Add reads its two operands (for the datapath)");
}
#[test]
fn seek_scrubs_anywhere_in_history() {
let mut dbg = Debugger::from_source(WHILE).expect("compiles");
while dbg.is_running() {
dbg.step();
}
let end = dbg.snapshot();
assert_eq!(end.state, "done");
let total = end.total_steps;
assert!(total > 3, "the loop took several ops");
dbg.seek(0);
let s0 = dbg.snapshot();
assert_eq!(s0.step, 0);
assert_eq!(s0.state, "paused");
assert!(s0.output.is_empty(), "no output has happened at the entry");
dbg.seek(total / 2);
assert_eq!(dbg.snapshot().step, total / 2);
assert_eq!(dbg.snapshot().state, "paused");
dbg.seek(total);
let again = dbg.snapshot();
assert_eq!(again.step, total);
assert_eq!(again.state, "done");
assert_eq!(again.output, end.output, "scrubbing to the end restores the final output");
}
#[test]
fn reverse_continue_runs_back_to_a_breakpoint() {
let mut dbg = Debugger::from_source(WHILE).expect("compiles");
let show = pc_of(&dbg, "Show");
while dbg.is_running() {
dbg.step();
}
assert_eq!(dbg.snapshot().state, "done");
dbg.set_breakpoint(show);
dbg.reverse_resume();
let s = dbg.snapshot();
assert_eq!(s.pc, show, "reverse-continue landed on the breakpoint");
assert!(s.at_breakpoint);
assert!(s.output.is_empty(), "rewound to the moment before the Show ran");
}
#[test]
fn restart_rewinds_but_keeps_explored_history() {
let mut dbg = Debugger::from_source(WHILE).expect("compiles");
while dbg.is_running() {
dbg.step();
}
let total = dbg.snapshot().total_steps;
dbg.restart();
let s = dbg.snapshot();
assert_eq!(s.step, 0, "back at the entry");
assert_eq!(s.state, "paused");
assert_eq!(s.total_steps, total, "explored history is retained, so re-stepping is instant");
}
#[test]
fn heap_view_lists_a_distinct_object() {
let src = "## Main\nLet xs be [1, 2, 3].\nShow length of xs.";
let mut dbg = Debugger::from_source(src).expect("compiles");
while dbg.is_running() {
dbg.step();
}
let s = dbg.snapshot();
assert!(
s.heap.iter().any(|o| o.kind == "list" && o.referenced_by.contains(&"xs".to_string())),
"the list `xs` shows up as a heap object: {:?}", s.heap
);
}
#[test]
fn heap_view_shows_storage_layout() {
let src = "## Main\nLet xs be [1, 2, 3].\nShow length of xs.";
let mut dbg = Debugger::from_source(src).expect("compiles");
while dbg.is_running() {
dbg.step();
}
let s = dbg.snapshot();
let list = s.heap.iter().find(|o| o.kind == "list").expect("a list on the heap");
assert_eq!(list.storage, "packed Vec<i64>", "an int list is densely packed: {list:?}");
}
#[test]
fn heap_view_reveals_aliasing() {
let src = "## Main\nLet a be [1, 2, 3].\nLet b be a.\nShow a.";
let mut dbg = Debugger::from_source(src).expect("compiles");
while dbg.is_running() {
dbg.step();
}
let s = dbg.snapshot();
let lists: Vec<&HeapObject> = s.heap.iter().filter(|o| o.kind == "list").collect();
assert_eq!(lists.len(), 1, "a and b share ONE list allocation, not two: {:?}", s.heap);
let list = lists[0];
assert!(list.referenced_by.contains(&"a".to_string()), "`a` references it: {list:?}");
assert!(list.referenced_by.contains(&"b".to_string()), "`b` references it: {list:?}");
assert!(list.shared, "aliasing is flagged");
}
#[test]
fn stack_frames_carry_their_base_address() {
let mut dbg = Debugger::from_source(FUNC).expect("compiles");
let mut guard = 0;
while dbg.is_running() && dbg.snapshot().frames.len() < 2 && guard < 1000 {
dbg.step();
guard += 1;
}
let s = dbg.snapshot();
assert_eq!(s.frames.len(), 2, "inside the function");
assert_eq!(s.frames[0].base, 0, "the Main frame starts at stack address 0");
assert!(s.frames[1].base > 0, "the callee frame is stacked above Main (higher address)");
}
}