diff --git a/Cargo.lock b/Cargo.lock index a939b37..6c5da33 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -232,6 +232,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chacha20" version = "0.9.1" @@ -317,6 +323,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "colorchoice" version = "1.0.5" @@ -705,6 +720,12 @@ dependencies = [ "log", ] +[[package]] +name = "endian-type" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" + [[package]] name = "env_filter" version = "1.0.1" @@ -755,6 +776,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + [[package]] name = "fake" version = "2.10.0" @@ -772,6 +799,17 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fd-lock" +version = "4.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" +dependencies = [ + "cfg-if", + "rustix 1.1.4", + "windows-sys 0.59.0", +] + [[package]] name = "ff" version = "0.13.1" @@ -1056,6 +1094,15 @@ dependencies = [ "digest", ] +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "id-arena" version = "2.3.0" @@ -1353,9 +1400,9 @@ dependencies = [ [[package]] name = "miden-assembly" -version = "0.22.1" +version = "0.22.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae6013b3a390e0dcb29242f4480a7727965887bbf0903466c88f362b4cb20c0e" +checksum = "1d2094e2b943f7bf955a2bc3b44b0ad7c4f45a286f170eaa7e5060871c44847a" dependencies = [ "log", "miden-assembly-syntax", @@ -1369,9 +1416,9 @@ dependencies = [ [[package]] name = "miden-assembly-syntax" -version = "0.22.1" +version = "0.22.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "996156b8f7c5fe6be17dea71089c6d7985c2dec1e3a4fec068b1dfc690e25df5" +checksum = "b5a3212614ad28399612f39024c1e321dc8cebc8998def06058e60462ddc3856" dependencies = [ "aho-corasick", "lalrpop", @@ -1392,9 +1439,9 @@ dependencies = [ [[package]] name = "miden-core" -version = "0.22.1" +version = "0.22.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdec54a321cdf3d23e9ef615e91cb858038c6b4d4202507bdec048fc6d7763e4" +checksum = "39a4a2e2de49213ec899e88fe399d4ec568c8eb9e8c747d6ed58938c40031daa" dependencies = [ "derive_more", "itertools 0.14.0", @@ -1412,9 +1459,9 @@ dependencies = [ [[package]] name = "miden-core-lib" -version = "0.22.1" +version = "0.22.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "621e8fa911a790bcf3cd3aedce80bc10922a19d6181f08ff3ca078f955cff70b" +checksum = "2d2ea7e17c4382255c6e0cb1e4b90693449dcf5a286a844e2918af66b371c0ab" dependencies = [ "env_logger", "fs-err", @@ -1506,6 +1553,7 @@ dependencies = [ "proptest", "ratatui", "rustc-demangle", + "rustyline", "serde", "serde_json", "signal-hook", @@ -1557,9 +1605,9 @@ dependencies = [ [[package]] name = "miden-debug-types" -version = "0.22.1" +version = "0.22.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6e50274d11c80b901cf6c90362de8c98c8c8ad6030c80624d683b63d899a0fb" +checksum = "16570786d938b7f795921b3a84890708a7d72708442c622eb58c2fb5480821e9" dependencies = [ "memchr", "miden-crypto", @@ -1602,9 +1650,9 @@ dependencies = [ [[package]] name = "miden-mast-package" -version = "0.22.1" +version = "0.22.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc8b2e3447fcde1f0e6b76e5219f129517639772cb02ca543177f0584e315288" +checksum = "0953396dc5e575b79bccb8b7da6e0d18ce71bcde899901bb4293a433f9003b94" dependencies = [ "derive_more", "miden-assembly-syntax", @@ -1652,9 +1700,9 @@ dependencies = [ [[package]] name = "miden-package-registry" -version = "0.22.1" +version = "0.22.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "969ba3942052e52b3968e34dbd1c52c707e75777ee42ebdae2c8f57af56cf6cf" +checksum = "e07af92dc184a71132a34d89ad15e69633435bfd36fb5af4ce18b200bd1952e5" dependencies = [ "miden-assembly-syntax", "miden-core", @@ -1667,9 +1715,9 @@ dependencies = [ [[package]] name = "miden-processor" -version = "0.22.1" +version = "0.22.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ec6cecbf22bd92b73a931ee80b424e46b8b7cdf4f2f3c364c25c5c15d2840da" +checksum = "340c424f9f62b56a808c9a479cef016f25478e227555ce39cb2684e8baf26542" dependencies = [ "itertools 0.14.0", "miden-air", @@ -1686,9 +1734,9 @@ dependencies = [ [[package]] name = "miden-project" -version = "0.22.1" +version = "0.22.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3840520c01881534fbbceb6b3687ec1c407fbaf310a35ce415fd3510abc52fdb" +checksum = "541619ccdf566c2fac0d24bfc3806bc36e1d57a698a937621f1874ceb36a55d4" dependencies = [ "miden-assembly-syntax", "miden-core", @@ -1818,9 +1866,9 @@ dependencies = [ [[package]] name = "miden-utils-core-derive" -version = "0.22.1" +version = "0.22.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3846c8674ccec0c37005f99c1a599a24790ba2a5e5f4e1c7aec5f456821df835" +checksum = "cdd5103e9b6527ad396dce12c135cea1984dfd77ebbffa76f260f4e139906cc4" dependencies = [ "proc-macro2", "quote", @@ -1829,9 +1877,9 @@ dependencies = [ [[package]] name = "miden-utils-diagnostics" -version = "0.22.1" +version = "0.22.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "397f5d1e8679cf17cf7713ffd9654840791a6ed5818b025bbc2fbfdce846579a" +checksum = "72226906c968c2e7c37435d67be9e29aeba05336db30c4e57d290cc6efb1da9d" dependencies = [ "miden-crypto", "miden-debug-types", @@ -1842,9 +1890,9 @@ dependencies = [ [[package]] name = "miden-utils-indexing" -version = "0.22.1" +version = "0.22.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8834e76299686bcce3de1685158aa4cff49b7fa5e0e00a6cc811e8f2cf5775f" +checksum = "5cc2e62161113179a370ae0bf1fd33eb8d20b6131e8559d2dc0bead5cffae586" dependencies = [ "miden-crypto", "serde", @@ -1853,9 +1901,9 @@ dependencies = [ [[package]] name = "miden-utils-sync" -version = "0.22.1" +version = "0.22.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a9e9747e9664c1a0997bb040ae291306ea0a1c74a572141ec66cec855c1b0e8" +checksum = "4e9210b3592b577843710daf68293087c68b53d8482c82f6875ad83d578cb51e" dependencies = [ "lock_api", "loom", @@ -1929,6 +1977,27 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "nibble_vec" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" +dependencies = [ + "smallvec", +] + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -2581,6 +2650,16 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "radix_trie" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" +dependencies = [ + "endian-type", + "nibble_vec", +] + [[package]] name = "rand" version = "0.8.5" @@ -2838,6 +2917,28 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "rustyline" +version = "15.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee1e066dc922e513bda599c6ccb5f3bb2b0ea5870a579448f2622993f0a9a2f" +dependencies = [ + "bitflags", + "cfg-if", + "clipboard-win", + "fd-lock", + "home", + "libc", + "log", + "memchr", + "nix", + "radix_trie", + "unicode-segmentation", + "unicode-width 0.2.0", + "utf8parse", + "windows-sys 0.59.0", +] + [[package]] name = "ryu" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index 807f8bc..b173684 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,7 +41,7 @@ name = "miden-debug" path = "src/main.rs" test = false bench = false -required-features = ["tui"] +required-features = ["std"] [[example]] name = "compile-masm" @@ -50,6 +50,7 @@ required-features = ["std"] [features] default = ["tui", "dap"] tui = ["std", "dep:crossterm", "dep:env_logger", "dep:ratatui", "dep:tui-input", "dep:signal-hook", "dep:syntect", "miden-debug-engine/tui"] +repl = ["std", "dep:env_logger", "dep:rustyline", "miden-debug-engine/tui"] dap = ["dep:dap", "dep:socket2", "miden-debug-engine/dap"] std = ["dep:glob", "clap/std", "clap/env", "compact_str/std", "miden-assembly-syntax/std", "miden-debug-engine/std"] proptest = ["dep:proptest", "miden-debug-engine/proptest"] @@ -97,6 +98,7 @@ syntect = { version = "5.2.0", optional = true, default-features = false, featur thiserror = { package = "miden-thiserror", version = "1.0" } toml = { version = "0.8", features = ["preserve_order"] } tui-input = { version = "0.11", optional = true } +rustyline = { version = "15.0", optional = true } socket2 = { version = "0.5", optional = true } tokio = { version = "1.39.2", features = ["rt", "time", "macros", "rt-multi-thread"] } diff --git a/crates/engine/src/debug/breakpoint.rs b/crates/engine/src/debug/breakpoint.rs index bd5fd9d..0ca5933 100644 --- a/crates/engine/src/debug/breakpoint.rs +++ b/crates/engine/src/debug/breakpoint.rs @@ -54,6 +54,8 @@ pub enum BreakpointType { StepTo(usize), /// Break at the first cycle of the next instruction Next, + /// Break at the next source line, or the next instruction if no source location is available. + NextLine, /// Break when we exit the current call frame Finish, /// Break when any cycle corresponds to a source location whose file matches PATTERN @@ -100,7 +102,13 @@ impl BreakpointType { /// Returns true if this breakpoint is internal to the debugger (i.e. not creatable via :b) pub fn is_internal(&self) -> bool { - matches!(self, BreakpointType::Next | BreakpointType::Step | BreakpointType::Finish) + matches!( + self, + BreakpointType::Next + | BreakpointType::NextLine + | BreakpointType::Step + | BreakpointType::Finish + ) } /// Returns true if this breakpoint is removed upon being hit @@ -108,6 +116,7 @@ impl BreakpointType { matches!( self, BreakpointType::Next + | BreakpointType::NextLine | BreakpointType::Finish | BreakpointType::Step | BreakpointType::StepN(_) diff --git a/crates/engine/src/debug/mod.rs b/crates/engine/src/debug/mod.rs index aea38a8..7f43369 100644 --- a/crates/engine/src/debug/mod.rs +++ b/crates/engine/src/debug/mod.rs @@ -2,6 +2,7 @@ mod breakpoint; mod memory; mod native_ptr; mod stacktrace; +mod variables; pub use self::{ breakpoint::{Breakpoint, BreakpointType}, @@ -11,4 +12,5 @@ pub use self::{ CallFrame, CallStack, ControlFlowOp, CurrentFrame, OpDetail, ResolvedLocation, StackTrace, StepInfo, }, + variables::{DebugVarSnapshot, DebugVarTracker, resolve_variable_value}, }; diff --git a/crates/engine/src/debug/stacktrace.rs b/crates/engine/src/debug/stacktrace.rs index 1a0c539..27f2abe 100644 --- a/crates/engine/src/debug/stacktrace.rs +++ b/crates/engine/src/debug/stacktrace.rs @@ -90,7 +90,15 @@ impl CallStack { pub fn next(&mut self, info: &StepInfo<'_>) -> Option { let procedure = info.asmop.map(|op| self.cache_procedure_name(op.context_name())); - let event = self.trace_events.borrow().get(&info.clk).copied(); + let event = { + let mut trace_events = self.trace_events.borrow_mut(); + match trace_events.first_key_value() { + Some((clk, _)) if *clk <= info.clk => { + trace_events.pop_first().map(|(_, event)| event) + } + _ => None, + } + }; log::trace!( "handling {:?}/{:?} at cycle {}: {:?}", info.control, @@ -98,7 +106,8 @@ impl CallStack { info.clk, &event ); - let popped_frame = self.handle_trace_event(event, procedure.as_ref()); + let is_frame_start = event.is_some_and(|event| event.is_frame_start()); + let popped_frame = self.handle_trace_event(event); let is_frame_end = popped_frame.is_some(); match info.control { @@ -126,7 +135,7 @@ impl CallStack { return popped_frame; }; - if is_frame_end { + if is_frame_start || is_frame_end { return popped_frame; } @@ -204,21 +213,18 @@ impl CallStack { } } - fn handle_trace_event( - &mut self, - event: Option, - procedure: Option<&Rc>, - ) -> Option { + fn handle_trace_event(&mut self, event: Option) -> Option { // Do we need to handle any frame events? if let Some(event) = event { match event { TraceEvent::FrameStart => { // Record the fact that we exec'd a new procedure in the op context if let Some(current_frame) = self.frames.last_mut() { - current_frame.push_exec(procedure.cloned()); + current_frame.push_exec(None); } - // Push a new frame - self.frames.push(CallFrame::new(procedure.cloned())); + // The trace decorator is emitted in the caller, immediately before the exec. + // Leave the new frame unnamed until the first callee op provides its context. + self.frames.push(CallFrame::new(None)); } TraceEvent::Unknown(code) => log::debug!("unknown trace event: {code}"), TraceEvent::FrameEnd => { diff --git a/crates/engine/src/debug/variables.rs b/crates/engine/src/debug/variables.rs new file mode 100644 index 0000000..7be36f8 --- /dev/null +++ b/crates/engine/src/debug/variables.rs @@ -0,0 +1,187 @@ +use std::{cell::RefCell, collections::BTreeMap, rc::Rc}; + +use miden_core::{ + Felt, + operations::{DebugVarInfo, DebugVarLocation}, +}; +use miden_processor::trace::RowIndex; + +const FRAME_BASE_LOCAL_MARKER: u32 = 1 << 31; + +fn decode_frame_base_local_offset(encoded: u32) -> Option { + if encoded & FRAME_BASE_LOCAL_MARKER == 0 { + return None; + } + + let low_bits = (encoded & 0xffff) as u16; + Some(i16::from_le_bytes(low_bits.to_le_bytes())) +} + +/// A snapshot of a debug variable at a specific clock cycle. +#[derive(Debug, Clone)] +pub struct DebugVarSnapshot { + /// The clock cycle when this variable info was recorded. + pub clk: RowIndex, + /// The debug variable information. + pub info: DebugVarInfo, +} + +/// Tracks debug variable snapshots, mapping variable names to their most recent location info. +pub struct DebugVarTracker { + /// All debug variable events recorded during execution, keyed by clock cycle. + events: Rc>>>, + /// Current view of variables - maps variable name to most recent info. + current_vars: BTreeMap, + /// The clock cycle up to which we've processed events. + processed_up_to: RowIndex, +} + +impl DebugVarTracker { + /// Create a new tracker using the given shared event store. + pub fn new(events: Rc>>>) -> Self { + Self { + events, + current_vars: BTreeMap::new(), + processed_up_to: RowIndex::from(0), + } + } + + /// Record debug variable events at the given clock cycle. + pub fn record_events(&self, clk: RowIndex, infos: Vec) { + if !infos.is_empty() { + self.events.borrow_mut().entry(clk).or_default().extend(infos); + } + } + + /// Process all events up to and including `clk`, updating current variable state. + pub fn update_to_cycle(&mut self, clk: RowIndex) { + let events = self.events.borrow(); + + // Process events from processed_up_to to clk + for (event_clk, var_infos) in events.range(self.processed_up_to..=clk) { + for info in var_infos { + let snapshot = DebugVarSnapshot { + clk: *event_clk, + info: info.clone(), + }; + self.current_vars.insert(info.name().to_string(), snapshot); + } + } + + self.processed_up_to = clk; + } + + /// Reset the tracker to the beginning of execution. + pub fn reset(&mut self) { + self.current_vars.clear(); + self.processed_up_to = RowIndex::from(0); + } + + /// Get all currently visible variables. + pub fn current_variables(&self) -> impl Iterator { + self.current_vars.values() + } + + /// Get a specific variable by name. + pub fn get_variable(&self, name: &str) -> Option<&DebugVarSnapshot> { + self.current_vars.get(name) + } + + /// Get the number of tracked variables. + pub fn variable_count(&self) -> usize { + self.current_vars.len() + } + + /// Check if there are any tracked variables. + pub fn has_variables(&self) -> bool { + !self.current_vars.is_empty() + } +} + +/// Resolve a debug variable's value given its location and the current VM state. +pub fn resolve_variable_value( + location: &DebugVarLocation, + stack: &[Felt], + get_memory: impl Fn(u32) -> Option, + get_local: impl Fn(i16) -> Option, +) -> Option { + match location { + DebugVarLocation::Stack(pos) => stack.get(*pos as usize).copied(), + DebugVarLocation::Memory(addr) => get_memory(*addr), + DebugVarLocation::Const(felt) => Some(*felt), + DebugVarLocation::Local(offset) => get_local(*offset), + DebugVarLocation::FrameBase { + global_index, + byte_offset, + } => { + if let Some(local_offset) = decode_frame_base_local_offset(*global_index) { + let base = get_local(local_offset)?; + let byte_addr = base.as_canonical_u64() as i64 + byte_offset; + let elem_addr = u32::try_from(byte_addr / 4).ok()?; + return get_memory(elem_addr); + } + + // global_index was resolved to a Miden byte address during compilation. + // Convert to element address (÷4) to read the stack pointer value. + let sp_elem_addr = *global_index / 4; + let base = get_memory(sp_elem_addr)?; + // The stack pointer value is also a byte address; apply byte_offset, + // then convert to element address to read the variable's value. + let byte_addr = base.as_canonical_u64() as i64 + byte_offset; + let elem_addr = (byte_addr / 4) as u32; + get_memory(elem_addr) + } + DebugVarLocation::Expression(_) => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_tracker_basic() { + let events: Rc>>> = + Rc::new(Default::default()); + + // Add some events + { + let mut events_mut = events.borrow_mut(); + events_mut.insert( + RowIndex::from(1), + vec![DebugVarInfo::new("x", DebugVarLocation::Stack(0))], + ); + events_mut.insert( + RowIndex::from(5), + vec![DebugVarInfo::new("y", DebugVarLocation::Stack(1))], + ); + } + + let mut tracker = DebugVarTracker::new(events); + + // Initially no variables + assert_eq!(tracker.variable_count(), 0); + + // Process up to cycle 3 + tracker.update_to_cycle(RowIndex::from(3)); + assert_eq!(tracker.variable_count(), 1); + assert!(tracker.get_variable("x").is_some()); + assert!(tracker.get_variable("y").is_none()); + + // Process up to cycle 10 + tracker.update_to_cycle(RowIndex::from(10)); + assert_eq!(tracker.variable_count(), 2); + assert!(tracker.get_variable("x").is_some()); + assert!(tracker.get_variable("y").is_some()); + + // Verify resolve_variable_value resolves stack values + let x_snapshot = tracker.get_variable("x").unwrap(); + let value = resolve_variable_value( + x_snapshot.info.value_location(), + &[Felt::new(42)], + |_| None, + |_| None, + ); + assert_eq!(value, Some(Felt::new(42))); + } +} diff --git a/crates/engine/src/exec/executor.rs b/crates/engine/src/exec/executor.rs index 0780a7a..d05b5fe 100644 --- a/crates/engine/src/exec/executor.rs +++ b/crates/engine/src/exec/executor.rs @@ -10,6 +10,7 @@ use std::{ use miden_assembly_syntax::{Library, diagnostics::Report}; use miden_core::{ Word, + operations::DebugVarInfo, program::{Program, StackInputs}, }; use miden_debug_types::{SourceManager, SourceManagerExt}; @@ -23,7 +24,10 @@ use miden_processor::{ }; use super::{DebugExecutor, DebuggerHost, ExecutionConfig, ExecutionTrace, TraceEvent}; -use crate::{debug::CallStack, felt::FromMidenRepr}; +use crate::{ + debug::{CallStack, DebugVarTracker}, + felt::FromMidenRepr, +}; /// The [Executor] is responsible for executing a program with the Miden VM. /// @@ -165,6 +169,12 @@ impl Executor { assertion_events.borrow_mut().insert(clk, event); }); + // Set up debug variable tracking + // Note: Currently no debug var events are emitted (requires new miden-core), + // but we set up the infrastructure for when they become available. + let debug_var_events: Rc>>> = + Rc::new(Default::default()); + let mut processor = FastProcessor::new(self.stack) .with_advice(self.advice) .with_options(self.options) @@ -177,6 +187,7 @@ impl Executor { .expect("failed to get initial resume context"); let callstack = CallStack::new(trace_events); + let debug_vars = DebugVarTracker::new(debug_var_events); DebugExecutor { processor, host, @@ -189,6 +200,9 @@ impl Executor { root_context, current_context: root_context, callstack, + current_proc: None, + debug_vars, + last_debug_var_count: 0, recent: VecDeque::with_capacity(5), cycle: 0, stopped: false, @@ -221,6 +235,9 @@ impl Executor { } host.set_event_replay(event_replay); + let debug_var_events: Rc>>> = + Rc::new(Default::default()); + let trace_events: Rc>> = Rc::new(Default::default()); let frame_start_events = Rc::clone(&trace_events); host.register_trace_handler(TraceEvent::FrameStart, move |clk, event| { @@ -247,6 +264,7 @@ impl Executor { .expect("failed to get initial resume context"); let callstack = CallStack::new(trace_events); + let debug_vars = DebugVarTracker::new(debug_var_events); DebugExecutor { processor, host, @@ -259,6 +277,9 @@ impl Executor { root_context, current_context: root_context, callstack, + current_proc: None, + debug_vars, + last_debug_var_count: 0, recent: VecDeque::with_capacity(5), cycle: 0, stopped: false, diff --git a/crates/engine/src/exec/state.rs b/crates/engine/src/exec/state.rs index c1ece65..87256cd 100644 --- a/crates/engine/src/exec/state.rs +++ b/crates/engine/src/exec/state.rs @@ -1,4 +1,7 @@ -use std::collections::{BTreeSet, VecDeque}; +use std::{ + collections::{BTreeSet, VecDeque}, + rc::Rc, +}; use miden_core::{ mast::{MastNode, MastNodeId}, @@ -10,7 +13,7 @@ use miden_processor::{ }; use super::{DebuggerHost, ExecutionTrace}; -use crate::debug::{CallFrame, CallStack, ControlFlowOp, StepInfo}; +use crate::debug::{CallFrame, CallStack, ControlFlowOp, DebugVarTracker, StepInfo}; /// Resolve a future that is expected to complete immediately (synchronous host methods). /// @@ -60,6 +63,12 @@ pub struct DebugExecutor { pub current_context: ContextId, /// The current call stack pub callstack: CallStack, + /// The most recent live procedure name observed from assembly operation metadata. + pub current_proc: Option>, + /// Debug variable tracker for source-level variable inspection + pub debug_vars: DebugVarTracker, + /// Number of debug variable location records observed during the most recent step. + pub last_debug_var_count: usize, /// A sliding window of the last 5 operations successfully executed by the VM pub recent: VecDeque, /// The current clock cycle @@ -134,6 +143,35 @@ pub(crate) fn extract_current_op( } impl DebugExecutor { + /// Returns true if the current program forest has debug-variable locations associated with + /// `procedure`. + pub fn procedure_has_debug_vars(&self, procedure: &str) -> bool { + let Some(resume_ctx) = self.resume_ctx.as_ref() else { + return false; + }; + + let forest = resume_ctx.current_forest(); + for (node_idx, node) in forest.nodes().iter().enumerate() { + let MastNode::Block(block) = node else { + continue; + }; + let node_id = MastNodeId::new_unchecked(node_idx as u32); + for op_idx in 0..block.num_operations() as usize { + if forest.debug_vars_for_operation(node_id, op_idx).is_empty() { + continue; + } + if forest + .get_assembly_op(node_id, Some(op_idx)) + .is_some_and(|op| op.context_name() == procedure) + { + return true; + } + } + } + + false + } + /// Advance the program state by one cycle. /// /// If the program has already reached its termination state, it returns the same result @@ -142,6 +180,7 @@ impl DebugExecutor { /// Returns the call frame exited this cycle, if any pub fn step(&mut self) -> Result, ExecutionError> { if self.stopped { + self.last_debug_var_count = 0; return Ok(None); } @@ -149,6 +188,7 @@ impl DebugExecutor { Some(ctx) => ctx, None => { self.stopped = true; + self.last_debug_var_count = 0; return Ok(None); } }; @@ -158,6 +198,18 @@ impl DebugExecutor { let asmop = node_id .and_then(|nid| resume_ctx.current_forest().get_assembly_op(nid, op_idx).cloned()); + // Look up debug vars from MAST forest for the current operation + let debug_var_infos: Vec<_> = if let (Some(nid), Some(idx)) = (node_id, op_idx) { + let forest = resume_ctx.current_forest(); + forest + .debug_vars_for_operation(nid, idx) + .iter() + .filter_map(|vid| forest.debug_var(*vid).cloned()) + .collect() + } else { + vec![] + }; + // Execute one step match poll_immediately(self.processor.step(&mut self.host, resume_ctx)) { Ok(Some(new_ctx)) => { @@ -177,6 +229,9 @@ impl DebugExecutor { // Track operation self.current_op = op; self.current_asmop = asmop.clone(); + if let Some(asmop) = asmop.as_ref() { + self.current_proc = Some(Rc::from(asmop.context_name())); + } if let Some(op) = op { if self.recent.len() == 5 { @@ -195,11 +250,19 @@ impl DebugExecutor { }; let exited = self.callstack.next(&step_info); + // Record and process debug variable events + let debug_var_count = debug_var_infos.len(); + self.debug_vars + .record_events(RowIndex::from(self.cycle as u32), debug_var_infos); + self.debug_vars.update_to_cycle(RowIndex::from(self.cycle as u32)); + self.last_debug_var_count = debug_var_count; + Ok(exited) } Ok(None) => { // Program completed self.stopped = true; + self.last_debug_var_count = 0; let state = self.processor.state(); self.current_stack = state.get_stack_state(); @@ -211,6 +274,7 @@ impl DebugExecutor { } Err(err) => { self.stopped = true; + self.last_debug_var_count = 0; Err(err) } } @@ -226,3 +290,84 @@ impl DebugExecutor { } } } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use miden_assembly::DefaultSourceManager; + + use crate::exec::Executor; + + use super::*; + + #[test] + fn callstack_tracks_nested_frame_trace_events() { + let source_manager = Arc::new(DefaultSourceManager::default()); + let program = miden_assembly::Assembler::new(source_manager.clone()) + .assemble_program( + r#" +proc inner + nop +end + +proc outer + trace.240 + nop + exec.inner + trace.252 + nop +end + +begin + trace.240 + nop + exec.outer + trace.252 + nop +end +"#, + ) + .unwrap(); + + let mut executor = Executor::new(Vec::::new()).into_debug(&program, source_manager); + let mut max_depth = 0; + let mut saw_inner = false; + let mut snapshots = Vec::new(); + + for _ in 0..64 { + executor.step().unwrap(); + let frames = executor.callstack.frames(); + max_depth = max_depth.max(frames.len()); + snapshots.push( + frames + .iter() + .map(|frame| { + frame + .procedure("") + .map(|name| name.to_string()) + .unwrap_or_else(|| "".to_string()) + }) + .collect::>(), + ); + saw_inner |= frames.len() >= 3 + && frames + .last() + .and_then(|frame| frame.procedure("")) + .is_some_and(|name| name.contains("inner")); + + if saw_inner || executor.stopped { + break; + } + } + + assert!( + max_depth >= 3, + "expected nested main -> outer -> inner frames, max depth was {max_depth}" + ); + assert!( + saw_inner, + "expected innermost frame to resolve to inner; snapshots: {snapshots:?}" + ); + } +} diff --git a/src/config.rs b/src/config.rs index 99da4ee..48ec89b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -8,22 +8,22 @@ use crate::{exec::ExecutionConfig, felt::Felt, input::InputFile, linker::LinkLib /// Run a compiled Miden program with the Miden VM #[derive(Default, Debug)] -#[cfg_attr(feature = "tui", derive(clap::Parser))] -#[cfg_attr(feature = "tui", command(author, version, about = "The interactive Miden debugger", long_about = None))] +#[cfg_attr(any(feature = "tui", feature = "repl"), derive(clap::Parser))] +#[cfg_attr(any(feature = "tui", feature = "repl"), command(author, version, about = "The interactive Miden debugger", long_about = None))] pub struct DebuggerConfig { /// Specify the path to a Miden program file to execute. /// /// Miden Assembly programs are emitted by the compiler with a `.masp` extension. /// /// You may use `-` as a file name to read a file from stdin. - #[cfg_attr(feature = "tui", arg(value_name = "FILE"))] + #[cfg_attr(any(feature = "tui", feature = "repl"), arg(value_name = "FILE"))] pub input: Option, /// Specify the path to a file containing program inputs. /// /// Program inputs are stack and advice provider values which the program can /// access during execution. The inputs file is a TOML file which describes /// what the inputs are, or where to source them from. - #[cfg_attr(feature = "tui", arg(long, value_name = "FILE"))] + #[cfg_attr(any(feature = "tui", feature = "repl"), arg(long, value_name = "FILE"))] pub inputs: Option, /// Arguments to place on the operand stack before calling the program entrypoint. /// @@ -34,7 +34,10 @@ pub struct DebuggerConfig { /// These arguments must be valid field element values expressed in decimal format. /// /// NOTE: These arguments will override any stack values provided via --inputs - #[cfg_attr(feature = "tui", arg(last(true), value_name = "ARGV"))] + #[cfg_attr( + any(feature = "tui", feature = "repl"), + arg(last(true), value_name = "ARGV") + )] pub args: Vec, /// The working directory for the debugger /// @@ -58,7 +61,7 @@ pub struct DebuggerConfig { )] pub sysroot: Option, /// Whether, and how, to color terminal output - #[cfg_attr(feature = "tui", arg( + #[cfg_attr(any(feature = "tui", feature = "repl"), arg( long, value_enum, default_value_t = ColorChoice::Auto, @@ -69,7 +72,10 @@ pub struct DebuggerConfig { pub color: ColorChoice, /// Specify the function to call as the entrypoint for the program /// in the format `::` - #[cfg_attr(feature = "tui", arg(long, help_heading = "Execution"))] + #[cfg_attr( + any(feature = "tui", feature = "repl"), + arg(long, help_heading = "Execution") + )] pub entrypoint: Option, /// Connect to a remote DAP debug server instead of running a local program. /// @@ -114,6 +120,12 @@ pub struct DebuggerConfig { ) )] pub link_libraries: Vec, + /// Use the REPL (text-mode) debugger instead of the TUI + #[cfg_attr( + any(feature = "tui", feature = "repl"), + arg(long, help_heading = "Output") + )] + pub repl: bool, } /// ColorChoice represents the color preferences of an end user. @@ -125,7 +137,7 @@ pub struct DebuggerConfig { /// string of the variant name to the corresponding variant. Any other string /// results in an error. #[derive(Default, Debug, Copy, Clone, PartialEq, Eq)] -#[cfg_attr(feature = "tui", derive(clap::ValueEnum))] +#[cfg_attr(any(feature = "tui", feature = "repl"), derive(clap::ValueEnum))] pub enum ColorChoice { /// Try very hard to emit colors. This includes emitting ANSI colors /// on Windows if the console API is unavailable. diff --git a/src/lib.rs b/src/lib.rs index cb19a0f..520e2c4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,13 +4,20 @@ mod config; mod input; mod linker; -#[cfg(feature = "tui")] -pub mod logger; -#[cfg(feature = "tui")] -mod ui; +#[cfg(any(feature = "tui", feature = "repl"))] +mod logger; +#[cfg(any(feature = "tui", feature = "repl"))] +pub(crate) mod ui; +#[cfg(feature = "repl")] +mod repl; + +#[cfg(feature = "repl")] +pub use self::repl::{run as run_repl, run_with_log_level as run_repl_with_log_level}; #[cfg(feature = "tui")] -pub use self::ui::{DebugMode, State, run, run_with_state}; +pub use self::ui::{ + DebugMode, State, run, run_with_log_level, run_with_state, run_with_state_and_log_level, +}; pub use self::{ config::{ColorChoice, DebuggerConfig}, debug::*, diff --git a/src/logger.rs b/src/logger.rs index 08fe99d..853d89d 100644 --- a/src/logger.rs +++ b/src/logger.rs @@ -5,7 +5,7 @@ use std::{ }; use compact_str::CompactString; -use log::{Level, Log}; +use log::{Level, LevelFilter, Log}; static LOGGER: LazyLock = LazyLock::new(DebugLogger::default); @@ -22,8 +22,11 @@ struct DebugLoggerImpl { pub struct LogEntry { pub level: Level, + #[allow(unused)] pub target: CompactString, + #[allow(unused)] pub file: Option>, + #[allow(unused)] pub line: Option, pub message: String, } @@ -32,11 +35,17 @@ pub struct LogEntry { pub struct DebugLogger(Arc>); impl Log for DebugLogger { - fn enabled(&self, _metadata: &log::Metadata) -> bool { - true + fn enabled(&self, metadata: &log::Metadata) -> bool { + let guard = self.0.lock().unwrap(); + guard.inner.as_ref().is_some_and(|inner| inner.enabled(metadata)) } fn log(&self, record: &log::Record) { + let mut guard = self.0.lock().unwrap(); + if !guard.inner.as_ref().is_some_and(|inner| inner.enabled(record.metadata())) { + return; + } + let target = CompactString::new(record.target()); let file = record .file_static() @@ -49,14 +58,11 @@ impl Log for DebugLogger { line: record.line(), message: format!("{}", record.args()), }; - let mut guard = self.0.lock().unwrap(); guard.captured.push_back(entry); if guard.captured.len() > HISTORY_SIZE { guard.captured.pop_front(); } - if let Some(inner) = guard.inner.as_ref() - && inner.enabled(record.metadata()) - { + if let Some(inner) = guard.inner.as_ref() { inner.log(record); } } @@ -65,11 +71,11 @@ impl Log for DebugLogger { } impl DebugLogger { - pub fn install(inner: Box) { + pub fn install_with_max_level(inner: Box, max_level: LevelFilter) { let logger = &*LOGGER; logger.set_inner(inner); log::set_logger(logger).unwrap_or_else(|err| panic!("failed to install logger: {err}")); - log::set_max_level(log::LevelFilter::Trace); + log::set_max_level(max_level); } pub fn get() -> &'static Self { diff --git a/src/main.rs b/src/main.rs index dc2f633..0d4033a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,9 +2,13 @@ use std::env; use clap::Parser; use miden_assembly_syntax::diagnostics::{IntoDiagnostic, Report, WrapErr}; -use miden_debug::{DebuggerConfig, run}; +use miden_debug::DebuggerConfig; +#[cfg(feature = "repl")] +use miden_debug::run_repl_with_log_level as run_repl; +#[cfg(feature = "tui")] +use miden_debug::run_with_log_level as run; #[cfg(feature = "dap")] -use miden_debug::{State, run_with_state}; +use miden_debug::{State, run_with_state_and_log_level as run_with_state}; pub fn main() -> Result<(), Report> { setup_diagnostics(); @@ -29,7 +33,9 @@ pub fn main() -> Result<(), Report> { builder.format_timestamp(None); } - let logger = Box::new(builder.build()); + let logger = builder.build(); + let log_level = logger.filter(); + let logger = Box::new(logger); let mut config = Box::new(DebuggerConfig::parse()); if config.working_dir.is_none() { @@ -43,10 +49,21 @@ pub fn main() -> Result<(), Report> { #[cfg(feature = "dap")] if let Some(addr) = config.dap_connect.as_ref() { let state = State::new_for_dap(addr)?; - return run_with_state(state, logger); + return run_with_state(state, logger, log_level); } - run(config, logger) + #[cfg(feature = "repl")] + if config.repl { + return run_repl(config, logger, log_level); + } + + #[cfg(feature = "tui")] + return run(config, logger, log_level); + + #[cfg(not(feature = "tui"))] + Err(Report::msg( + "no UI feature enabled: build with --features tui or --features repl", + )) } fn setup_diagnostics() { diff --git a/src/repl/commands.rs b/src/repl/commands.rs new file mode 100644 index 0000000..2669492 --- /dev/null +++ b/src/repl/commands.rs @@ -0,0 +1,148 @@ +use std::str::FromStr; + +use crate::debug::{BreakpointType, ReadMemoryExpr}; + +/// Commands available in the REPL debugger. +#[derive(Debug, Clone)] +pub enum ReplCommand { + /// Execute one VM cycle + Step, + /// Execute N VM cycles + StepN(usize), + /// Execute until next instruction boundary + Next, + /// Execute until next source line + NextLine, + /// Run until breakpoint or end + Continue, + /// Run until current function returns + Finish, + /// Set a breakpoint + Break(BreakpointType), + /// List all breakpoints + Breakpoints, + /// Delete breakpoint(s) - None means delete all + Delete(Option), + /// Show operand stack + Stack, + /// Show memory at address with optional count + Memory(ReadMemoryExpr), + /// Show local variables + Locals, + /// Show debug variables, including compiler-generated locals if true. + Vars(bool), + /// Show current source location + Where, + /// Show recent instructions + List, + /// Show call stack / backtrace + Backtrace, + /// Restart program + Reload, + /// Show help + Help, + /// Exit debugger + Quit, +} + +impl FromStr for ReplCommand { + type Err = String; + + fn from_str(s: &str) -> Result { + let s = s.trim(); + if s.is_empty() { + return Err("empty command".into()); + } + + // Split into command and arguments + let (cmd, args) = match s.split_once(char::is_whitespace) { + Some((cmd, args)) => (cmd, Some(args.trim())), + None => (s, None), + }; + + match cmd { + // Step commands + "s" | "step" => match args { + Some(n) => { + let n = n.parse::().map_err(|e| format!("invalid step count: {e}"))?; + Ok(ReplCommand::StepN(n)) + } + None => Ok(ReplCommand::Step), + }, + "n" | "next" => Ok(ReplCommand::Next), + "nl" | "next-line" | "nextline" => Ok(ReplCommand::NextLine), + "c" | "continue" => Ok(ReplCommand::Continue), + "e" | "finish" => Ok(ReplCommand::Finish), + + // Breakpoint commands + "b" | "break" | "breakpoint" => { + let args = args.ok_or("breakpoint requires a specification")?; + let bp_type = args.parse::()?; + Ok(ReplCommand::Break(bp_type)) + } + "bp" | "breakpoints" => Ok(ReplCommand::Breakpoints), + "d" | "delete" => match args { + Some(id) => { + let id = id.parse::().map_err(|e| format!("invalid breakpoint id: {e}"))?; + Ok(ReplCommand::Delete(Some(id))) + } + None => Ok(ReplCommand::Delete(None)), + }, + + // Inspection commands + "stack" => Ok(ReplCommand::Stack), + "mem" | "memory" => { + let args = args.ok_or("memory command requires an address")?; + let expr = args.parse::()?; + Ok(ReplCommand::Memory(expr)) + } + "locals" => Ok(ReplCommand::Locals), + "vars" | "variables" => Ok(ReplCommand::Vars(args == Some("all"))), + "where" | "w" => Ok(ReplCommand::Where), + "l" | "list" => Ok(ReplCommand::List), + "bt" | "backtrace" => Ok(ReplCommand::Backtrace), + + // Control commands + "reload" => Ok(ReplCommand::Reload), + "h" | "help" | "?" => Ok(ReplCommand::Help), + "q" | "quit" | "exit" => Ok(ReplCommand::Quit), + + _ => Err(format!("unknown command: {cmd}")), + } + } +} + +impl ReplCommand { + /// Returns the help text for all commands. + pub fn help_text() -> &'static str { + r#"Available commands: + +Execution: + s, step [N] Execute one (or N) VM cycle(s) + n, next Execute until next instruction boundary + nl, next-line Execute until next source line + c, continue Run until breakpoint or end + e, finish Run until current function returns + reload Restart program execution + +Breakpoints: + b, break Set a breakpoint + Specs: at , after , in , :, + bp, breakpoints List all breakpoints + d, delete [id] Delete breakpoint by id, or all if no id given + +Inspection: + stack Show operand stack + mem [type] Show memory at address (e.g., mem 0x100 u32) + locals Show local variables + vars [all] Show source variables; include compiler locals with `all` + where Show current source location + l, list Show recent instructions + bt, backtrace Show call stack + +Other: + h, help Show this help + q, quit Exit debugger +"# + } +} diff --git a/src/repl/mod.rs b/src/repl/mod.rs new file mode 100644 index 0000000..14d5fff --- /dev/null +++ b/src/repl/mod.rs @@ -0,0 +1,26 @@ +mod commands; +mod session; + +use log::LevelFilter; +use miden_assembly_syntax::diagnostics::Report; + +use self::session::ReplSession; +use crate::config::DebuggerConfig; + +/// Run the REPL debugger with the given configuration. +pub fn run(config: Box, logger: Box) -> Result<(), Report> { + run_with_log_level(config, logger, LevelFilter::Trace) +} + +/// Run the REPL debugger with the given configuration and log level filter. +pub fn run_with_log_level( + config: Box, + logger: Box, + max_level: LevelFilter, +) -> Result<(), Report> { + // Install the logger + crate::logger::DebugLogger::install_with_max_level(logger, max_level); + + let mut session = ReplSession::new(config)?; + session.run() +} diff --git a/src/repl/session.rs b/src/repl/session.rs new file mode 100644 index 0000000..198f801 --- /dev/null +++ b/src/repl/session.rs @@ -0,0 +1,549 @@ +use std::rc::Rc; + +use miden_assembly_syntax::diagnostics::Report; +use rustyline::{DefaultEditor, error::ReadlineError}; + +use super::commands::ReplCommand; +use crate::{ + config::DebuggerConfig, + debug::{Breakpoint, BreakpointType}, + ui::state::State, +}; + +/// Interactive REPL session for the debugger. +pub struct ReplSession { + state: State, + editor: DefaultEditor, +} + +impl ReplSession { + /// Create a new REPL session from the given config. + pub fn new(config: Box) -> Result { + let state = State::new(config)?; + let editor = DefaultEditor::new() + .map_err(|e| Report::msg(format!("failed to create editor: {e}")))?; + + Ok(Self { state, editor }) + } + + /// Run the main REPL loop. + pub fn run(&mut self) -> Result<(), Report> { + self.print_welcome(); + self.print_location(); + + loop { + let prompt = self.make_prompt(); + match self.editor.readline(&prompt) { + Ok(line) => { + let line = line.trim(); + if line.is_empty() { + continue; + } + + // Add to history + let _ = self.editor.add_history_entry(line); + + // Parse and execute command + match line.parse::() { + Ok(cmd) => { + if matches!(cmd, ReplCommand::Quit) { + println!("\x1b[36mGoodbye!\x1b[0m"); + break; + } + if let Err(e) = self.execute_command(cmd) { + eprintln!("\x1b[31mError:\x1b[0m {e}"); + } + } + Err(e) => { + eprintln!("\x1b[31mError:\x1b[0m {e}"); + } + } + } + Err(ReadlineError::Interrupted) => { + println!("^C"); + continue; + } + Err(ReadlineError::Eof) => { + println!("\x1b[36mGoodbye!\x1b[0m"); + break; + } + Err(e) => { + eprintln!("\x1b[31mError reading line:\x1b[0m {e}"); + break; + } + } + } + + Ok(()) + } + + fn print_welcome(&self) { + println!("\x1b[1;36mMiden Debugger REPL\x1b[0m"); + println!("Type \x1b[33mhelp\x1b[0m for available commands."); + println!(); + } + + fn make_prompt(&self) -> String { + let cycle = self.state.executor().cycle; + + if self.state.executor().stopped { + if self.state.execution_failed().is_some() { + format!("\x1b[36m[\x1b[0mcycle {cycle} \x1b[1;31mERR\x1b[0m\x1b[36m]\x1b[0m > ") + } else { + format!("\x1b[36m[\x1b[0mcycle {cycle} \x1b[1;32mEND\x1b[0m\x1b[36m]\x1b[0m > ") + } + } else if self.state.stopped { + format!("\x1b[36m[\x1b[0mcycle {cycle} \x1b[1;33mSTOP\x1b[0m\x1b[36m]\x1b[0m > ") + } else { + format!("\x1b[36m[\x1b[0mcycle {cycle}\x1b[36m]\x1b[0m > ") + } + } + + fn print_location(&self) { + let proc_name = self.state.current_procedure().unwrap_or_else(|| Rc::from("")); + if let Some(resolved) = self.state.current_display_location() { + println!("at {} in {}", resolved, proc_name); + } else if self.state.executor().callstack.current_frame().is_some() { + println!("in {}", proc_name); + } + } + + fn execute_command(&mut self, cmd: ReplCommand) -> Result<(), String> { + match cmd { + ReplCommand::Step => self.cmd_step(1), + ReplCommand::StepN(n) => self.cmd_step(n), + ReplCommand::Next => self.cmd_next(), + ReplCommand::NextLine => self.cmd_next_line(), + ReplCommand::Continue => self.cmd_continue(), + ReplCommand::Finish => self.cmd_finish(), + ReplCommand::Break(bp_type) => self.cmd_break(bp_type), + ReplCommand::Breakpoints => self.cmd_breakpoints(), + ReplCommand::Delete(id) => self.cmd_delete(id), + ReplCommand::Stack => self.cmd_stack(), + ReplCommand::Memory(expr) => self.cmd_memory(&expr), + ReplCommand::Locals => self.cmd_locals(), + ReplCommand::Vars(show_all) => self.cmd_vars(show_all), + ReplCommand::Where => self.cmd_where(), + ReplCommand::List => self.cmd_list(), + ReplCommand::Backtrace => self.cmd_backtrace(), + ReplCommand::Reload => self.cmd_reload(), + ReplCommand::Help => self.cmd_help(), + ReplCommand::Quit => unreachable!("quit handled in run loop"), + } + } + + fn cmd_step(&mut self, n: usize) -> Result<(), String> { + if self.state.executor().stopped { + return Err("program has terminated, cannot step".into()); + } + + for _ in 0..n { + if self.state.executor().stopped { + break; + } + match self.state.executor_mut().step() { + Ok(_) => {} + Err(err) => { + let msg = format!("execution error: {err}"); + self.state.set_execution_failed(err); + return Err(msg); + } + } + } + + self.print_location(); + Ok(()) + } + + fn cmd_next(&mut self) -> Result<(), String> { + if self.state.executor().stopped { + return Err("program has terminated, cannot continue".into()); + } + + self.state.create_breakpoint(BreakpointType::Next); + self.state.stopped = false; + self.run_until_stopped(); + self.print_location(); + Ok(()) + } + + fn cmd_next_line(&mut self) -> Result<(), String> { + if self.state.executor().stopped { + return Err("program has terminated, cannot continue".into()); + } + + self.state.create_breakpoint(BreakpointType::NextLine); + self.state.stopped = false; + self.run_until_stopped(); + self.print_location(); + Ok(()) + } + + fn cmd_continue(&mut self) -> Result<(), String> { + if self.state.executor().stopped { + return Err("program has terminated, cannot continue".into()); + } + + self.state.stopped = false; + self.run_until_stopped(); + + if self.state.executor().stopped { + if let Some(err) = self.state.execution_failed() { + println!("Program terminated with error: {}", err); + } else { + println!("Program terminated successfully"); + } + } else { + self.print_location(); + } + + Ok(()) + } + + fn cmd_finish(&mut self) -> Result<(), String> { + if self.state.executor().stopped { + return Err("program has terminated, cannot continue".into()); + } + + self.state.create_breakpoint(BreakpointType::Finish); + self.state.stopped = false; + self.run_until_stopped(); + self.print_location(); + Ok(()) + } + + fn run_until_stopped(&mut self) { + let start_cycle = self.state.executor().cycle; + let start_asmop = self.state.executor().current_asmop.clone(); + let start_proc = self.state.current_procedure(); + let start_line_loc = self.state.current_display_location(); + let mut previous_proc = self.state.current_procedure(); + let mut pending_called_breakpoints = Vec::new(); + let mut breakpoints: Vec = core::mem::take(&mut self.state.breakpoints); + self.state.breakpoints_hit.clear(); + + loop { + // Check if program has terminated + if self.state.executor().stopped { + self.state.stopped = true; + break; + } + + let mut consume_most_recent_finish = false; + match self.state.executor_mut().step() { + Ok(Some(ref exited)) if exited.should_break_on_exit() => { + consume_most_recent_finish = true; + } + Ok(_) => {} + Err(err) => { + self.state.set_execution_failed(err); + self.state.stopped = true; + break; + } + } + + if breakpoints.is_empty() { + continue; + } + + // Get current execution state for breakpoint checking + let is_op_boundary = self.state.executor().current_asmop.is_some(); + let loc = self.state.current_location(); + let line_loc = self.state.current_display_location(); + let proc = self.state.current_procedure(); + + // Check breakpoints + let current_cycle = self.state.executor().cycle; + let cycles_stepped = current_cycle - start_cycle; + + breakpoints.retain_mut(|bp| { + if let Some(n) = bp.cycles_to_skip(current_cycle) { + if cycles_stepped >= n { + let retained = !bp.is_one_shot(); + if retained { + self.state.breakpoints_hit.push(bp.clone()); + } else { + self.state.breakpoints_hit.push(core::mem::take(bp)); + } + return retained; + } + return true; + } + + if cycles_stepped > 0 + && is_op_boundary + && matches!(&bp.ty, BreakpointType::Next) + && self.state.executor().current_asmop != start_asmop + { + self.state.breakpoints_hit.push(core::mem::take(bp)); + return false; + } + + if cycles_stepped > 0 + && is_op_boundary + && matches!(&bp.ty, BreakpointType::NextLine) + { + let has_source_context = start_line_loc.is_some() || line_loc.is_some(); + let reached_next = if has_source_context { + State::is_next_source_line( + start_proc.as_deref(), + start_line_loc.as_ref(), + proc.as_deref(), + line_loc.as_ref(), + ) + } else { + self.state.executor().current_asmop != start_asmop + }; + if reached_next { + self.state.breakpoints_hit.push(core::mem::take(bp)); + return false; + } + } + + if let Some(loc) = loc.as_ref() + && bp.should_break_at(loc) + { + let retained = !bp.is_one_shot(); + if retained { + self.state.breakpoints_hit.push(bp.clone()); + } else { + self.state.breakpoints_hit.push(core::mem::take(bp)); + } + return retained; + } + + if matches!(&bp.ty, BreakpointType::Called(_)) + && let Some(proc) = proc.as_deref() + { + let matched = bp.should_break_in(proc); + if !matched { + pending_called_breakpoints.retain(|id| *id != bp.id); + return true; + } + + let was_matched = previous_proc + .as_deref() + .is_some_and(|previous| bp.should_break_in(previous)); + let matched_at_start = + start_proc.as_deref().is_some_and(|start| bp.should_break_in(start)); + let pending = pending_called_breakpoints.contains(&bp.id); + let entered_matching_proc = !was_matched && !matched_at_start; + + if entered_matching_proc + && self.state.executor().procedure_has_debug_vars(proc) + && self.state.executor().last_debug_var_count == 0 + { + if !pending { + pending_called_breakpoints.push(bp.id); + } + return true; + } + + if entered_matching_proc + || (pending && self.state.executor().last_debug_var_count > 0) + { + pending_called_breakpoints.retain(|id| *id != bp.id); + let retained = !bp.is_one_shot(); + if retained { + self.state.breakpoints_hit.push(bp.clone()); + } else { + self.state.breakpoints_hit.push(core::mem::take(bp)); + } + return retained; + } + } + + true + }); + + // Handle Finish breakpoint + if consume_most_recent_finish + && let Some(id) = breakpoints.iter().rev().find_map(|bp| { + if matches!(bp.ty, BreakpointType::Finish) { + Some(bp.id) + } else { + None + } + }) + { + breakpoints.retain(|bp| bp.id != id); + self.state.stopped = true; + break; + } + + if !self.state.breakpoints_hit.is_empty() { + self.state.stopped = true; + break; + } + + previous_proc = proc; + } + + // Restore breakpoints + self.state.breakpoints = breakpoints; + } + + fn cmd_break(&mut self, bp_type: BreakpointType) -> Result<(), String> { + self.state.create_breakpoint(bp_type.clone()); + let id = self.state.breakpoints.last().map(|bp| bp.id).unwrap_or(0); + println!("Breakpoint {} set: {}", id, format_bp_type(&bp_type)); + Ok(()) + } + + fn cmd_breakpoints(&mut self) -> Result<(), String> { + if self.state.breakpoints.is_empty() { + println!("No breakpoints set"); + return Ok(()); + } + + println!("Breakpoints:"); + for bp in &self.state.breakpoints { + if !bp.is_internal() { + println!(" [{}] {}", bp.id, format_bp_type(&bp.ty)); + } + } + Ok(()) + } + + fn cmd_delete(&mut self, id: Option) -> Result<(), String> { + match id { + Some(id) => { + let count_before = self.state.breakpoints.len(); + self.state.breakpoints.retain(|bp| bp.id != id); + if self.state.breakpoints.len() < count_before { + println!("Deleted breakpoint {}", id); + } else { + return Err(format!("no breakpoint with id {}", id)); + } + } + None => { + // Delete only user-created (non-internal) breakpoints + self.state.breakpoints.retain(|bp| bp.is_internal()); + println!("Deleted all breakpoints"); + } + } + Ok(()) + } + + fn cmd_stack(&mut self) -> Result<(), String> { + let stack = &self.state.executor().current_stack; + + if stack.is_empty() { + println!("Stack is empty"); + return Ok(()); + } + + println!("Operand Stack ({} elements):", stack.len()); + for (i, elem) in stack.iter().enumerate() { + let val = elem.as_canonical_u64(); + let marker = if i == 0 { ">" } else { " " }; + println!(" {} [{}] {} (0x{:x})", marker, i, val, val); + } + Ok(()) + } + + fn cmd_memory(&mut self, expr: &crate::debug::ReadMemoryExpr) -> Result<(), String> { + let result = self.state.read_memory(expr)?; + println!("{}", result); + Ok(()) + } + + fn cmd_locals(&mut self) -> Result<(), String> { + let output = self.state.format_variables(false); + println!("{}", output); + Ok(()) + } + + fn cmd_vars(&mut self, show_all: bool) -> Result<(), String> { + let output = self.state.format_variables(show_all); + println!("{}", output); + Ok(()) + } + + fn cmd_where(&mut self) -> Result<(), String> { + if self.state.executor().callstack.current_frame().is_some() { + let proc_name = self.state.current_procedure().unwrap_or_else(|| Rc::from("")); + + if let Some(resolved) = self.state.current_display_location() { + println!( + "{}:{}:{} in {}", + resolved.source_file.uri().as_str(), + resolved.line, + resolved.col, + proc_name + ); + } else { + println!("in {} (no source location available)", proc_name); + } + } else { + println!("No current frame"); + } + Ok(()) + } + + fn cmd_list(&mut self) -> Result<(), String> { + if let Some(frame) = self.state.executor().callstack.current_frame() { + let recent = frame.recent(); + if recent.is_empty() { + println!("No recent instructions"); + return Ok(()); + } + + println!("Recent instructions:"); + for (i, op) in recent.iter().enumerate() { + let marker = if i == recent.len() - 1 { ">" } else { " " }; + println!(" {} {}", marker, op.display()); + } + } else { + println!("No current frame"); + } + Ok(()) + } + + fn cmd_backtrace(&mut self) -> Result<(), String> { + let frames = self.state.executor().callstack.frames(); + if frames.is_empty() { + println!("No call stack"); + return Ok(()); + } + + println!("Backtrace ({} frames):", frames.len()); + for (i, frame) in frames.iter().rev().enumerate() { + let proc_name = frame.procedure("").unwrap_or_else(|| Rc::from("")); + let loc_str = frame + .last_resolved(&*self.state.source_manager) + .map(|r| format!(" at {}", r)) + .unwrap_or_default(); + + println!(" #{} {}{}", i, proc_name, loc_str); + } + Ok(()) + } + + fn cmd_reload(&mut self) -> Result<(), String> { + self.state.reload().map_err(|e| format!("reload failed: {e}"))?; + println!("Program reloaded"); + self.print_location(); + Ok(()) + } + + fn cmd_help(&mut self) -> Result<(), String> { + println!("{}", ReplCommand::help_text()); + Ok(()) + } +} + +fn format_bp_type(ty: &BreakpointType) -> String { + match ty { + BreakpointType::Step => "next cycle".into(), + BreakpointType::StepN(n) => format!("after {} cycles", n), + BreakpointType::StepTo(c) => format!("at cycle {}", c), + BreakpointType::Next => "next instruction".into(), + BreakpointType::NextLine => "next source line".into(), + BreakpointType::Finish => "function return".into(), + BreakpointType::File(pat) => pat.as_str().to_string(), + BreakpointType::Line { pattern, line } => format!("{}:{}", pattern.as_str(), line), + BreakpointType::Opcode(op) => format!("opcode {:?}", op), + BreakpointType::Called(pat) => format!("call {}", pat.as_str()), + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 568eb63..e12812e 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -3,10 +3,11 @@ mod app; mod duration; mod pages; mod panes; -mod state; +pub(crate) mod state; mod syntax_highlighting; mod tui; +use log::LevelFilter; use miden_assembly_syntax::diagnostics::{IntoDiagnostic, Report}; pub use self::state::{DebugMode, State}; @@ -15,9 +16,17 @@ use crate::config::DebuggerConfig; #[allow(dead_code)] pub fn run(config: Box, logger: Box) -> Result<(), Report> { + run_with_log_level(config, logger, LevelFilter::Trace) +} + +pub fn run_with_log_level( + config: Box, + logger: Box, + max_level: LevelFilter, +) -> Result<(), Report> { let mut builder = tokio::runtime::Builder::new_current_thread(); let rt = builder.enable_all().build().into_diagnostic()?; - rt.block_on(async move { start_ui(config, logger).await }) + rt.block_on(async move { start_ui(config, logger, max_level).await }) } /// Launch the TUI debugger with a pre-built [State]. @@ -25,19 +34,29 @@ pub fn run(config: Box, logger: Box) -> Result<(), /// This is the programmatic entry point used by transaction debugging, where /// the caller constructs a [State] with pre-recorded event replay data. pub fn run_with_state(state: State, logger: Box) -> Result<(), Report> { + run_with_state_and_log_level(state, logger, LevelFilter::Trace) +} + +/// Launch the TUI debugger with a pre-built [State] and log level filter. +pub fn run_with_state_and_log_level( + state: State, + logger: Box, + max_level: LevelFilter, +) -> Result<(), Report> { let mut builder = tokio::runtime::Builder::new_current_thread(); let rt = builder.enable_all().build().into_diagnostic()?; - rt.block_on(async move { start_ui_with_state(state, logger).await }) + rt.block_on(async move { start_ui_with_state(state, logger, max_level).await }) } #[allow(dead_code)] pub async fn start_ui( config: Box, logger: Box, + max_level: LevelFilter, ) -> Result<(), Report> { use ratatui::crossterm as term; - crate::logger::DebugLogger::install(logger); + crate::logger::DebugLogger::install_with_max_level(logger, max_level); let original_hook = std::panic::take_hook(); std::panic::set_hook(Box::new(move |panic_info| { @@ -52,10 +71,14 @@ pub async fn start_ui( Ok(()) } -async fn start_ui_with_state(state: State, logger: Box) -> Result<(), Report> { +async fn start_ui_with_state( + state: State, + logger: Box, + max_level: LevelFilter, +) -> Result<(), Report> { use ratatui::crossterm as term; - crate::logger::DebugLogger::install(logger); + crate::logger::DebugLogger::install_with_max_level(logger, max_level); let original_hook = std::panic::take_hook(); std::panic::set_hook(Box::new(move |panic_info| { diff --git a/src/ui/pages/home.rs b/src/ui/pages/home.rs index ee728aa..a1775ca 100644 --- a/src/ui/pages/home.rs +++ b/src/ui/pages/home.rs @@ -135,6 +135,11 @@ impl Page for Home { }, Err(err) => actions.push(Some(Action::TimedStatusLine(err, 5))), }, + "vars" | "variables" | "locals" => { + let show_all = rest.trim() == "all"; + let result = state.format_variables(show_all); + actions.push(Some(Action::StatusLine(result))); + } _ => { log::debug!("unknown command with arguments: '{cmd} {args}'"); actions.push(Some(Action::TimedStatusLine("unknown command".into(), 1))) @@ -142,12 +147,55 @@ impl Page for Home { }, None => match args.trim() { "q" | "quit" => actions.push(Some(Action::Quit)), - "reload" => { + "r" | "reload" | "restart" => { actions.push(Some(Action::Reload)); } + "nl" | "next-line" | "nextline" => { + if state.stopped && !state.executor().stopped { + state.create_breakpoint(BreakpointType::NextLine); + state.stopped = false; + actions.push(Some(Action::Continue)); + } else if state.executor().stopped { + actions.push(Some(Action::TimedStatusLine( + "program has terminated, cannot continue".to_string(), + 3, + ))); + } + } "debug" => { actions.push(Some(Action::ShowDebug)); } + "vars" | "variables" | "locals" => { + let result = state.format_variables(false); + actions.push(Some(Action::StatusLine(result))); + } + "p" | "proc" | "where" => { + // Show the current procedure name so users can craft + // `b in ` breakpoints. + let live = state + .executor() + .current_asmop + .as_ref() + .map(|op| op.context_name().to_string()); + let frame = state + .executor() + .callstack + .current_frame() + .and_then(|f| f.procedure("")) + .map(|p| p.to_string()); + let msg = match (live, frame) { + (Some(l), Some(f)) if l == f => { + format!("proc: {l}") + } + (Some(l), Some(f)) => { + format!("proc (live): {l} / (frame): {f}") + } + (Some(l), None) => format!("proc (live): {l}"), + (None, Some(f)) => format!("proc (frame): {f}"), + (None, None) => "proc: ".to_string(), + }; + actions.push(Some(Action::StatusLine(msg))); + } invalid => { log::debug!("unknown command: '{invalid}'"); actions.push(Some(Action::TimedStatusLine("unknown command".into(), 1))) @@ -174,9 +222,24 @@ impl Page for Home { return Ok(None); } + // If the program has already terminated, there's nothing to run. + // Let the user know they can restart with `:r` / `:reload`. + if state.executor().stopped { + actions.push(Some(Action::TimedStatusLine( + "program has terminated — use :r to restart".into(), + 3, + ))); + return Ok(None); + } + let start_cycle = state.executor().cycle; let start_asmop = state.executor().current_asmop.clone(); + let start_proc = state.current_procedure(); + let start_line_loc = state.current_display_location(); + let mut previous_proc = state.current_procedure(); + let mut pending_called_breakpoints = Vec::new(); let mut breakpoints = core::mem::take(&mut state.breakpoints); + state.breakpoints_hit.clear(); state.stopped = false; let stopped = loop { // If stepping the program results in the program terminating succesfully, stop @@ -202,7 +265,7 @@ impl Page for Home { continue; } - let (_op, is_op_boundary, proc, loc) = { + let (_op, is_op_boundary, proc, loc, line_loc) = { let op = state.executor().current_op; let is_boundary = state .executor() @@ -210,18 +273,10 @@ impl Page for Home { .as_ref() .map(|_info| true) .unwrap_or(false); - let (proc, loc) = match state.executor().callstack.current_frame() { - Some(frame) => { - let loc = frame - .recent() - .back() - .and_then(|detail| detail.resolve(&state.source_manager)) - .cloned(); - (frame.procedure(""), loc) - } - None => (None, None), - }; - (op, is_boundary, proc, loc) + let loc = state.current_location(); + let line_loc = state.current_display_location(); + let proc = state.current_procedure(); + (op, is_boundary, proc, loc, line_loc) }; // Remove all breakpoints triggered at this cycle @@ -251,20 +306,29 @@ impl Page for Home { return false; } - if let Some(loc) = loc.as_ref() - && bp.should_break_at(loc) + if cycles_stepped > 0 + && is_op_boundary + && matches!(&bp.ty, BreakpointType::NextLine) { - let retained = !bp.is_one_shot(); - if retained { - state.breakpoints_hit.push(bp.clone()); + let has_source_context = start_line_loc.is_some() || line_loc.is_some(); + let reached_next = if has_source_context { + State::is_next_source_line( + start_proc.as_deref(), + start_line_loc.as_ref(), + proc.as_deref(), + line_loc.as_ref(), + ) } else { + state.executor().current_asmop != start_asmop + }; + if reached_next { state.breakpoints_hit.push(core::mem::take(bp)); + return false; } - return retained; } - if let Some(proc) = proc.as_deref() - && bp.should_break_in(proc) + if let Some(loc) = loc.as_ref() + && bp.should_break_at(loc) { let retained = !bp.is_one_shot(); if retained { @@ -275,6 +339,47 @@ impl Page for Home { return retained; } + if matches!(&bp.ty, BreakpointType::Called(_)) + && let Some(proc) = proc.as_deref() + { + let matched = bp.should_break_in(proc); + if !matched { + pending_called_breakpoints.retain(|id| *id != bp.id); + return true; + } + + let was_matched = previous_proc + .as_deref() + .is_some_and(|previous| bp.should_break_in(previous)); + let matched_at_start = start_proc + .as_deref() + .is_some_and(|start| bp.should_break_in(start)); + let pending = pending_called_breakpoints.contains(&bp.id); + let entered_matching_proc = !was_matched && !matched_at_start; + if entered_matching_proc + && state.executor().procedure_has_debug_vars(proc) + && state.executor().last_debug_var_count == 0 + { + if !pending { + pending_called_breakpoints.push(bp.id); + } + return true; + } + + if entered_matching_proc + || (pending && state.executor().last_debug_var_count > 0) + { + pending_called_breakpoints.retain(|id| *id != bp.id); + let retained = !bp.is_one_shot(); + if retained { + state.breakpoints_hit.push(bp.clone()); + } else { + state.breakpoints_hit.push(core::mem::take(bp)); + } + return retained; + } + } + true }); @@ -294,6 +399,8 @@ impl Page for Home { if !state.breakpoints_hit.is_empty() { break true; } + + previous_proc = proc; }; // Restore the breakpoints state diff --git a/src/ui/panes/breakpoints.rs b/src/ui/panes/breakpoints.rs index cd855b8..c61c245 100644 --- a/src/ui/panes/breakpoints.rs +++ b/src/ui/panes/breakpoints.rs @@ -171,9 +171,10 @@ impl Pane for BreakpointsPane { Span::styled("", Style::default()) }; let line = match &bp.ty { - BreakpointType::Next | BreakpointType::Step | BreakpointType::Finish => { - unreachable!() - } + BreakpointType::Next + | BreakpointType::NextLine + | BreakpointType::Step + | BreakpointType::Finish => unreachable!(), BreakpointType::StepN(n) => Line::from(vec![ gutter, Span::styled("cycle:", yellow), diff --git a/src/ui/panes/disasm.rs b/src/ui/panes/disasm.rs index b51bf27..c493e99 100644 --- a/src/ui/panes/disasm.rs +++ b/src/ui/panes/disasm.rs @@ -57,9 +57,21 @@ impl Pane for DisassemblyPane { } fn draw(&mut self, frame: &mut Frame<'_>, area: Rect, state: &State) -> Result<(), Report> { + // Prefer the live AsmOp's context_name — the frame's procedure is sticky + // (set once on frame entry) so for programs that use `exec` instead of + // `call` the entire run appears to stay in the first seen procedure. + let live_proc_name = + state.executor().current_asmop.as_ref().map(|op| op.context_name().to_string()); + let (current_proc, lines) = match state.executor().callstack.current_frame() { None => { - let proc = Line::from("in ").right_aligned(); + let proc = Line::from( + live_proc_name + .as_deref() + .map(|p| format!("in {p}")) + .unwrap_or_else(|| "in ".to_string()), + ) + .right_aligned(); ( proc, state @@ -71,9 +83,10 @@ impl Pane for DisassemblyPane { ) } Some(frame) => { - let proc = frame - .procedure("") - .map(|proc| Line::from(format!("in {proc}"))) + let proc_name = + live_proc_name.clone().or_else(|| frame.procedure("").map(|p| p.to_string())); + let proc = proc_name + .map(|p| Line::from(format!("in {p}"))) .unwrap_or_else(|| Line::from("in ")) .right_aligned(); ( diff --git a/src/ui/panes/source_code.rs b/src/ui/panes/source_code.rs index baec576..0012867 100644 --- a/src/ui/panes/source_code.rs +++ b/src/ui/panes/source_code.rs @@ -15,7 +15,10 @@ use crate::{ action::Action, panes::Pane, state::State, - syntax_highlighting::{Highlighter, HighlighterState, NoopHighlighter, SyntectHighlighter}, + syntax_highlighting::{ + Highlighter, HighlighterState, NoopHighlighter, SyntectHighlighter, + clamp_byte_selection_to_str, + }, tui::Frame, }, }; @@ -83,6 +86,7 @@ impl SourceCodePane { } else { (resolved_span.start - span.start)..(resolved_span.end - span.start) }; + let selection = clamp_byte_selection_to_str(&line_content, selection); highlighter_state.highlight_line_with_selection( line_content.into(), selection, @@ -131,45 +135,26 @@ struct Theme { current_line: Style, current_span: Style, line_number: Style, + #[allow(dead_code)] gutter_border: Style, } impl Default for Theme { fn default() -> Self { Self { focused_border_style: Style::default(), - current_line: Style::default() - .bg(Color::Black) - .fg(Color::White) - .add_modifier(Modifier::BOLD), - current_span: Style::default() - .fg(Color::White) - .bg(Color::DarkGray) - .add_modifier(Modifier::BOLD), + current_line: Style::default().add_modifier(Modifier::BOLD | Modifier::REVERSED), + current_span: Style::default().add_modifier(Modifier::BOLD | Modifier::REVERSED), line_number: Style::default(), gutter_border: Style::default(), } } } impl Theme { - pub fn patch_from_syntect(&mut self, theme: &syntect::highlighting::Theme) { - use crate::ui::syntax_highlighting::convert_color; - if let Some(bg) = theme.settings.line_highlight.map(convert_color) { - self.current_line.bg = Some(bg); - } - if let Some(bg) = theme.settings.selection.map(convert_color) { - self.current_span.bg = Some(bg); - } - if let Some(fg) = theme.settings.selection_foreground.map(convert_color) { - self.current_span.fg = Some(fg); - } - if let Some(bg) = theme.settings.gutter.map(convert_color) { - self.line_number.bg = Some(bg); - self.gutter_border.bg = Some(bg); - } - if let Some(fg) = theme.settings.gutter_foreground.map(convert_color) { - self.line_number.fg = Some(fg); - self.gutter_border.fg = Some(fg); - } + pub fn patch_from_syntect(&mut self, _theme: &syntect::highlighting::Theme) { + // Intentionally do NOT apply the syntect theme's line_highlight, selection, + // or gutter colors here. The defaults use REVERSED which works on both + // light and dark terminals. The syntect dark theme colors would make text + // invisible on light terminals. } } @@ -303,7 +288,7 @@ impl Pane for SourceCodePane { if let Some(loc) = self.current_location(state) { let source_id = loc.source_file.id(); if source_id != self.current_source_id { - self.highlight_file(&loc); + self.current_file = Some(self.highlight_file(&loc)); self.current_source_id = source_id; self.num_lines = loc.source_file.line_count() as u32; self.selected_line = loc.line; @@ -387,15 +372,25 @@ impl Pane for SourceCodePane { .unwrap(); let selection_start = core::cmp::max(span.start(), line_span.start); let selection_end = core::cmp::min(span.end(), line_span.end); - let selected_span = SourceSpan::new(span.source_id(), selection_start..selection_end); - let selected = selected_span.into_slice_index(); - let selected = if selected_span.is_empty() { - // Select the closest character to the span - let start = selected.start - line_span.start.to_usize(); - start..start + // Guard: if the span doesn't overlap this line, select nothing + let selected = if selection_start >= selection_end { + 0..0 } else { - (selected.start - line_span.start.to_usize())..(selected.end - line_span.end.to_usize()) + let selected_span = SourceSpan::new(span.source_id(), selection_start..selection_end); + let selected = selected_span.into_slice_index(); + if selected_span.is_empty() { + let start = selected.start - line_span.start.to_usize(); + start..start + } else { + (selected.start - line_span.start.to_usize()) + ..(selected.end - line_span.start.to_usize()) + } }; + let selected_line_content = selected_line_deconstructed + .iter() + .map(|(_, content)| *content) + .collect::(); + let selected = clamp_byte_selection_to_str(&selected_line_content, selected); let mut parts = syntect::util::modify_range( selected_line_deconstructed.as_slice(), selected, diff --git a/src/ui/panes/stacktrace.rs b/src/ui/panes/stacktrace.rs index f9e790e..1aa2e2a 100644 --- a/src/ui/panes/stacktrace.rs +++ b/src/ui/panes/stacktrace.rs @@ -59,6 +59,11 @@ impl Pane for StackTracePane { fn draw(&mut self, frame: &mut Frame<'_>, area: Rect, state: &State) -> Result<(), Report> { let mut lines = Vec::default(); let num_frames = state.executor().callstack.frames().len(); + // For the top frame, prefer the live AsmOp's context_name over the + // frame's cached procedure, which is set once on frame entry and + // stays stale for programs that use `exec` instead of `call`. + let live_top_name: Option = + state.executor().current_asmop.as_ref().map(|op| op.context_name().to_string()); for (i, frame) in state.executor().callstack.frames().iter().enumerate() { let is_top = i + 1 == num_frames; let mut parts = vec![]; @@ -71,15 +76,16 @@ impl Pane for StackTracePane { */ let gutter = Span::styled(" ", Color::Gray); parts.push(gutter); - let name = frame.procedure(""); - let name = name.as_deref().unwrap_or("").to_string(); + let name = if is_top { + live_top_name.clone().or_else(|| frame.procedure("").map(|p| p.to_string())) + } else { + frame.procedure("").map(|p| p.to_string()) + }; + let name = name.unwrap_or_else(|| "".to_string()); let name = if is_top { Span::styled(name, Color::Gray) } else { - Span::styled( - name, - Style::default().fg(Color::Cyan).bg(Color::Black).add_modifier(Modifier::BOLD), - ) + Span::styled(name, Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)) }; parts.push(name); if let Some(resolved) = frame.last_resolved(&state.source_manager) { diff --git a/src/ui/state.rs b/src/ui/state.rs index 396f09f..2892b1c 100644 --- a/src/ui/state.rs +++ b/src/ui/state.rs @@ -1,4 +1,4 @@ -use std::{collections::VecDeque, sync::Arc}; +use std::{collections::VecDeque, rc::Rc, sync::Arc}; use miden_assembly::{DefaultSourceManager, SourceManager}; use miden_assembly_syntax::diagnostics::{IntoDiagnostic, Report}; @@ -11,8 +11,8 @@ use miden_processor::{ use crate::{ config::DebuggerConfig, - debug::{Breakpoint, BreakpointType, ReadMemoryExpr}, - exec::{DebugExecutor, ExecutionTrace, Executor}, + debug::{Breakpoint, BreakpointType, ReadMemoryExpr, ResolvedLocation, resolve_variable_value}, + exec::{DebugExecutor, Executor}, input::InputFile, }; @@ -74,7 +74,6 @@ pub enum InputMode { struct LocalState { executor: DebugExecutor, - execution_trace: ExecutionTrace, execution_failed: Option, } @@ -104,8 +103,9 @@ struct RemoteSnapshot { #[cfg(feature = "dap")] impl RemoteState { fn connect(addr: &str, source_manager: &Arc) -> Result { - use std::collections::BTreeSet; + use std::{cell::RefCell, collections::BTreeSet, rc::Rc}; + use miden_debug_engine::debug::DebugVarTracker; use miden_processor::{ContextId, FastProcessor}; use crate::exec::DebuggerHost; @@ -116,6 +116,7 @@ impl RemoteState { let processor = FastProcessor::new(StackInputs::default()); let host = DebuggerHost::new(source_manager.clone()); + let debug_vars = DebugVarTracker::new(Rc::new(RefCell::new(Default::default()))); let executor = DebugExecutor { processor, host, @@ -128,6 +129,9 @@ impl RemoteState { root_context: ContextId::root(), current_context: ContextId::root(), callstack: snapshot.callstack, + current_proc: None, + debug_vars, + last_debug_var_count: 0, recent: VecDeque::new(), cycle: snapshot.cycle, stopped: false, @@ -194,7 +198,9 @@ impl RemoteState { self.sync_breakpoints(breakpoints); let has_step = breakpoints.iter().any(|bp| matches!(bp.ty, BreakpointType::Step)); - let has_next = breakpoints.iter().any(|bp| matches!(bp.ty, BreakpointType::Next)); + let has_next = breakpoints + .iter() + .any(|bp| matches!(bp.ty, BreakpointType::Next | BreakpointType::NextLine)); let has_finish = breakpoints.iter().any(|bp| matches!(bp.ty, BreakpointType::Finish)); if has_step { @@ -293,30 +299,17 @@ impl State { // Now resolve package dependencies (they should find the registered libraries) let dependencies = package.manifest.dependencies(); executor.with_dependencies(dependencies)?; - executor.with_advice_inputs(inputs.advice_inputs.clone()); + executor.with_advice_inputs(inputs.advice_inputs); let program = package.unwrap_program(); let executor = executor.into_debug(&program, source_manager.clone()); - // Execute the program until it terminates to capture a full trace for use during debugging - let mut trace_executor = Executor::new(args); - for lib in libs.iter() { - trace_executor.register_library_dependency(lib.clone()); - trace_executor.with_library(lib.clone()); - } - let dependencies = package.manifest.dependencies(); - trace_executor.with_dependencies(dependencies)?; - trace_executor.with_advice_inputs(inputs.advice_inputs.clone()); - - let execution_trace = trace_executor.capture_trace(&program, source_manager.clone()); - Ok(Self::new_local( source_manager, config, DebugMode::Program, LocalState { executor, - execution_trace, execution_failed: None, }, )) @@ -338,35 +331,21 @@ impl State { let args = stack_inputs.iter().copied().rev().collect::>(); // Create debug executor with event replay - let mut executor = Executor::new(args.clone()); - executor.with_advice_inputs(advice_inputs.clone()); + let mut executor = Executor::new(args); + executor.with_advice_inputs(advice_inputs); let debug_executor = executor.into_debug_with_replay( - &program, - source_manager.clone(), - mast_forests.clone(), - clone_event_replay_queue(&event_replay), - ); - - // Create trace executor with a cloned replay queue - let mut trace_executor = Executor::new(args); - trace_executor.with_advice_inputs(advice_inputs); - let trace_debug = trace_executor.into_debug_with_replay( &program, source_manager.clone(), mast_forests, clone_event_replay_queue(&event_replay), ); - // Run trace executor to completion to capture execution trace - let execution_trace = run_to_trace(trace_debug); - Ok(Self::new_local( source_manager, Box::default(), DebugMode::Transaction, LocalState { executor: debug_executor, - execution_trace, execution_failed: None, }, )) @@ -437,25 +416,13 @@ impl State { // Now resolve package dependencies let dependencies = package.manifest.dependencies(); executor.with_dependencies(dependencies)?; - executor.with_advice_inputs(inputs.advice_inputs.clone()); + executor.with_advice_inputs(inputs.advice_inputs); let program = package.unwrap_program(); let executor = executor.into_debug(&program, self.source_manager.clone()); - // Execute the program until it terminates to capture a full trace for use during debugging - let mut trace_executor = Executor::new(args); - for lib in libs.iter() { - trace_executor.register_library_dependency(lib.clone()); - trace_executor.with_library(lib.clone()); - } - let dependencies = package.manifest.dependencies(); - trace_executor.with_dependencies(dependencies)?; - trace_executor.with_advice_inputs(core::mem::take(&mut inputs.advice_inputs)); - let execution_trace = trace_executor.capture_trace(&program, self.source_manager.clone()); - self.session = SessionState::Local(Box::new(LocalState { executor, - execution_trace, execution_failed: None, })); self.breakpoints_hit.clear(); @@ -522,6 +489,60 @@ impl State { } } + pub fn current_procedure(&self) -> Option> { + let live_proc = self + .executor() + .current_asmop + .as_ref() + .map(|op| Rc::from(op.context_name())) + .or_else(|| self.executor().current_proc.clone()); + let frame_proc = + self.executor().callstack.current_frame().and_then(|frame| frame.procedure("")); + live_proc.or(frame_proc) + } + + pub fn current_location(&self) -> Option { + self.executor() + .callstack + .current_frame() + .and_then(|frame| frame.recent().back()) + .and_then(|detail| detail.resolve(&*self.source_manager)) + .cloned() + } + + pub fn current_display_location(&self) -> Option { + self.executor() + .callstack + .current_frame() + .and_then(|frame| frame.last_resolved(&*self.source_manager)) + .cloned() + } + + pub fn is_next_source_line( + start_proc: Option<&str>, + start_loc: Option<&ResolvedLocation>, + current_proc: Option<&str>, + current_loc: Option<&ResolvedLocation>, + ) -> bool { + let same_proc = match (start_proc, current_proc) { + (Some(start), Some(current)) => start == current, + (Some(_), None) => false, + _ => true, + }; + if !same_proc { + return false; + } + + match (start_loc, current_loc) { + (Some(start), Some(current)) => { + start.source_file.uri().as_str() == current.source_file.uri().as_str() + && start.line != current.line + } + (None, Some(_)) => true, + _ => false, + } + } + pub fn execution_failed(&self) -> Option<&miden_processor::ExecutionError> { match &self.session { SessionState::Local(local) => local.execution_failed.as_ref(), @@ -539,14 +560,6 @@ impl State { } } } - - fn local_session(&self) -> &LocalState { - match &self.session { - SessionState::Local(local) => local, - #[cfg(feature = "dap")] - SessionState::Remote(_) => panic!("local session requested while in remote mode"), - } - } } macro_rules! write_with_format_type { @@ -580,9 +593,13 @@ impl State { return Err("remote debug mode requires the `dap` feature".into()); } - let cycle = miden_processor::trace::RowIndex::from(self.executor().cycle); - let context = self.executor().current_context; - let local = self.local_session(); + let executor = self.executor(); + let cycle = miden_processor::trace::RowIndex::from(executor.cycle); + let context = executor.current_context; + let memory = executor.processor.memory(); + let read_element = |addr: u32| -> Option { + memory.read_element(context, Felt::new(addr as u64)).ok() + }; let mut output = String::new(); if expr.count > 1 { return Err("-count with value > 1 is not yet implemented".into()); @@ -592,10 +609,7 @@ impl State { "read failed: type 'felt' must be aligned to an element boundary".into() ); } - let felt = local - .execution_trace - .read_memory_element_in_context(expr.addr.addr, context, cycle) - .unwrap_or(Felt::ZERO); + let felt = read_element(expr.addr.addr).unwrap_or(Felt::ZERO); write_with_format_type!(output, expr, felt.as_canonical_u64()); } else if matches!( expr.ty, @@ -604,7 +618,9 @@ impl State { if !expr.addr.is_word_aligned() { return Err("read failed: type 'word' must be aligned to a word boundary".into()); } - let word = local.execution_trace.read_memory_word(expr.addr.addr).unwrap_or_default(); + let word = memory + .read_word(context, Felt::new(expr.addr.addr as u64), cycle) + .unwrap_or_default(); output.push('['); for (i, elem) in word.iter().enumerate() { if i > 0 { @@ -614,10 +630,26 @@ impl State { } output.push(']'); } else { - let bytes = local - .execution_trace - .read_bytes_for_type(expr.addr, &expr.ty, context, cycle) - .map_err(|err| format!("invalid read: {err}"))?; + if !expr.addr.is_element_aligned() { + return Err("invalid read: unaligned reads are not supported yet".into()); + } + + const U32_MASK: u64 = u32::MAX as u64; + let size = expr.ty.size_in_bytes(); + let size_in_felts = expr.ty.size_in_felts(); + let mut bytes = Vec::with_capacity(size); + let mut needed = size; + for i in 0..size_in_felts { + let addr = expr.addr.addr.checked_add(i as u32).ok_or_else(|| { + "invalid read: attempted to read beyond end of linear memory".to_string() + })?; + let elem = read_element(addr).unwrap_or_default(); + let elem_bytes = ((elem.as_canonical_u64() & U32_MASK) as u32).to_le_bytes(); + let take = core::cmp::min(needed, 4); + bytes.extend(&elem_bytes[..take]); + needed -= take; + } + match &expr.ty { Type::I1 => match expr.format { FormatType::Decimal => write!(&mut output, "{}", bytes[0] != 0).unwrap(), @@ -664,6 +696,88 @@ impl State { Ok(output) } + + /// Format the current debug variables as a string for display. + /// + /// When `show_all` is false, compiler-generated locals (named `local0`, `local1`, etc.) + /// are hidden. Use `show_all` = true (`:vars all`) to include them. + pub fn format_variables(&self, show_all: bool) -> String { + use core::fmt::Write; + + let executor = self.executor(); + let debug_vars = &executor.debug_vars; + + if !debug_vars.has_variables() { + return "No debug variables tracked".to_string(); + } + + let mut output = String::new(); + let stack = executor.current_stack.clone(); + let context = executor.current_context; + + // Use live processor state, not the pre-recorded trace, for current-cycle values. + let read_mem = |addr: u32| -> Option { + executor.processor.memory().read_element(context, Felt::new(addr as u64)).ok() + }; + + let current_source = if show_all { + None + } else { + self.current_display_location() + }; + + for var_snapshot in debug_vars.current_variables() { + let name = var_snapshot.info.name(); + + if !show_all && is_compiler_generated_name(name) { + continue; + } + + if let (Some(current), Some(var_loc)) = + (current_source.as_ref(), var_snapshot.info.location()) + && (var_loc.uri.as_str() != current.source_file.uri().as_str() + || var_loc.line.to_u32() > current.line) + { + continue; + } + + if !output.is_empty() { + output.push_str(", "); + } + + let location = var_snapshot.info.value_location(); + + let value = resolve_variable_value(location, &stack, read_mem, |offset| { + // Read FMP from live memory, then compute address as FMP + offset + let fmp_addr = miden_core::FMP_ADDR.as_canonical_u64() as u32; + let fmp = read_mem(fmp_addr)?; + let addr = (fmp.as_canonical_u64() as i64 + offset as i64) as u32; + read_mem(addr) + }); + + match value { + Some(felt) => { + write!(&mut output, "{name}={}", felt.as_canonical_u64()).unwrap(); + } + None => { + write!(&mut output, "{name}={location}").unwrap(); + } + } + } + + if output.is_empty() { + "No source-level variables (use ':vars all' to show compiler locals)".to_string() + } else { + output + } + } +} + +/// Returns true if the variable name looks compiler-generated (e.g. "local0", "local12"). +/// Source-level variables have DWARF-derived names like "a", "sum", "_info". +fn is_compiler_generated_name(name: &str) -> bool { + name.strip_prefix("local") + .is_some_and(|suffix| !suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_digit())) } // DAP CLIENT MODE @@ -828,20 +942,6 @@ fn load_sysroot_libs( Ok(libs) } -/// Run a [DebugExecutor] to completion and return the [ExecutionTrace]. -fn run_to_trace(mut executor: DebugExecutor) -> ExecutionTrace { - loop { - if executor.stopped { - break; - } - match executor.step() { - Ok(_) => continue, - Err(_) => break, - } - } - executor.into_execution_trace() -} - fn load_package(config: &DebuggerConfig) -> Result, Report> { let input = config.input.as_ref().ok_or_else(|| Report::msg("no input file specified"))?; let package = match input { diff --git a/src/ui/syntax_highlighting.rs b/src/ui/syntax_highlighting.rs index 5cda390..83491e0 100644 --- a/src/ui/syntax_highlighting.rs +++ b/src/ui/syntax_highlighting.rs @@ -95,6 +95,7 @@ fn default_line_with_selection( selected: Range, style: Style, ) -> Vec> { + let selected = clamp_byte_selection_to_str(&line, selected); let prefix_content = core::str::from_utf8(&line.as_bytes()[..selected.start]).expect("invalid selection"); let selected_content = @@ -113,6 +114,20 @@ fn default_line_with_selection( ] } +pub fn clamp_byte_selection_to_str(line: &str, selected: Range) -> Range { + fn floor_char_boundary(line: &str, idx: usize) -> usize { + let mut idx = idx.min(line.len()); + while idx > 0 && !line.is_char_boundary(idx) { + idx -= 1; + } + idx + } + + let start = floor_char_boundary(line, selected.start); + let end = floor_char_boundary(line, selected.end).max(start); + start..end +} + /// Syntax highlighting provided by [syntect](https://docs.rs/syntect/latest/syntect/). /// /// Currently only 24-bit truecolor output is supported due to syntect themes @@ -221,6 +236,7 @@ impl HighlighterState for SyntectHighlighterState<'_> { selected: Range, style: Style, ) -> Vec> { + let selected = clamp_byte_selection_to_str(&line, selected); if let Ok(ops) = self.parse_state.parse_line(&line, &self.syntax_set) { let use_bg_color = self.use_bg_color; let parts = syntax::HighlightIterator::new( @@ -254,14 +270,21 @@ impl HighlighterState for SyntectHighlighterState<'_> { /// Convert syntect [syntax::Style] into ratatui [Style] */ #[inline] pub fn convert_style(syntect_style: syntax::Style, use_bg_color: bool) -> Style { - let style = if use_bg_color { - let fg = blend_fg_color(syntect_style); - let bg = convert_color(syntect_style.background); - Style::new().fg(fg).bg(bg) - } else { - let fg = convert_color(syntect_style.foreground); - Style::new().fg(fg) - }; + let fg = syntect_style.foreground; + let bg = syntect_style.background; + let mut style = Style::new(); + // Skip transparent colors (alpha=0) to use the terminal's native colors + if fg.a > 0 { + let fg_color = if use_bg_color { + blend_fg_color(syntect_style) + } else { + convert_color(fg) + }; + style = style.fg(fg_color); + } + if use_bg_color && bg.a > 0 { + style = style.bg(convert_color(bg)); + } let mods = convert_font_style(syntect_style.font_style); style.add_modifier(mods) } @@ -270,9 +293,18 @@ pub fn convert_to_syntect_style(style: Style, _use_bg_color: bool) -> syntax::St let fg = style.fg.map(convert_to_syntect_color); let bg = style.bg.map(convert_to_syntect_color); let fs = convert_to_font_style(style.add_modifier); + // Use transparent (alpha=0) fallbacks so that convert_style will skip + // setting explicit colors, letting the terminal's native colors show through. + // This avoids hardcoded White/Black that break on light/dark terminals. + let transparent = syntax::Color { + r: 0, + g: 0, + b: 0, + a: 0, + }; syntax::Style { - foreground: fg.unwrap_or(convert_to_syntect_color(Color::White)), - background: bg.unwrap_or(convert_to_syntect_color(Color::Black)), + foreground: fg.unwrap_or(transparent), + background: bg.unwrap_or(transparent), font_style: fs, } }