Skip to main content

soldb_repl/
lib.rs

1//! The interactive debugger's command parser and state machine.
2//!
3//! [`DebuggerCommand::parse`] turns a typed line into a command, and
4//! [`DebuggerState::apply_command`] applies the ones that move the debugger, returning a
5//! [`StepOutcome`] describing what happened.
6//!
7//! This crate performs no I/O: it neither reads stdin nor prints. The frontend owns the
8//! terminal and renders outcomes, which is what makes stepping, breakpoints, and command
9//! parsing testable without a terminal or a node. Commands that need context the state
10//! machine does not have — source-line breakpoints, variable lookups — return `None`
11//! from `apply_command` and are resolved by the frontend against ETHDebug metadata.
12
13use std::collections::BTreeSet;
14
15use soldb_core::{TraceStep, TransactionTrace};
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum DisplayMode {
19    Source,
20    Assembly,
21}
22
23impl DisplayMode {
24    pub fn parse(input: &str) -> Option<Self> {
25        match input.trim().to_ascii_lowercase().as_str() {
26            "source" | "src" => Some(Self::Source),
27            "asm" | "assembly" => Some(Self::Assembly),
28            _ => None,
29        }
30    }
31
32    pub fn as_str(self) -> &'static str {
33        match self {
34            Self::Source => "source",
35            Self::Assembly => "asm",
36        }
37    }
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum BreakpointTarget {
42    Pc(u64),
43    SourceLine(SourceBreakpointTarget),
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub struct SourceBreakpointTarget {
48    pub file: Option<String>,
49    pub line: u64,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub enum DebuggerCommand {
54    Next,
55    NextInstruction,
56    Step,
57    Continue,
58    Goto(usize),
59    /// List every source variable ETHDebug reports as live at the current program counter.
60    Vars,
61    /// Print one source variable by name at the current program counter.
62    ///
63    /// An empty name means the user typed `print` with no argument; the frontend reports
64    /// the usage rather than treating it as an unknown command.
65    Print(String),
66    Info(DebuggerInfoCommand),
67    Mode(Option<DisplayMode>),
68    Break(BreakpointTarget),
69    Clear(BreakpointTarget),
70    Help(Option<String>),
71    Quit,
72    Empty,
73    Unknown(String),
74}
75
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub enum DebuggerInfoCommand {
78    Resources { json: bool },
79}
80
81impl DebuggerCommand {
82    pub fn parse(line: &str) -> Self {
83        let line = line.trim();
84        if line.is_empty() {
85            return Self::Empty;
86        }
87
88        let mut parts = line.split_whitespace();
89        let command = parts.next().unwrap_or_default().to_ascii_lowercase();
90        let rest = parts.collect::<Vec<_>>().join(" ");
91        match command.as_str() {
92            "next" | "n" => Self::Next,
93            "nexti" | "ni" | "stepi" | "si" => Self::NextInstruction,
94            "step" | "s" => Self::Step,
95            "continue" | "c" => Self::Continue,
96            "vars" | "locals" => Self::Vars,
97            "print" | "p" => Self::Print(rest),
98            "goto" => rest
99                .parse::<usize>()
100                .map(Self::Goto)
101                .unwrap_or_else(|_| Self::Unknown(line.to_owned())),
102            "info" => parse_info_command(&rest)
103                .map(Self::Info)
104                .unwrap_or_else(|| Self::Unknown(line.to_owned())),
105            "mode" => Self::Mode(
106                (!rest.is_empty())
107                    .then(|| DisplayMode::parse(&rest))
108                    .flatten(),
109            ),
110            "break" | "b" => parse_breakpoint_target(&rest)
111                .map(Self::Break)
112                .unwrap_or_else(|| Self::Unknown(line.to_owned())),
113            "clear" => parse_breakpoint_target(&rest)
114                .map(Self::Clear)
115                .unwrap_or_else(|| Self::Unknown(line.to_owned())),
116            "help" => Self::Help((!rest.is_empty()).then_some(rest)),
117            "exit" | "quit" | "q" => Self::Quit,
118            _ => Self::Unknown(line.to_owned()),
119        }
120    }
121}
122
123#[derive(Debug, Clone, PartialEq, Eq)]
124pub enum StepOutcome {
125    NoTrace,
126    Moved {
127        step: usize,
128        pc: u64,
129        op: String,
130    },
131    BreakpointHit {
132        step: usize,
133        pc: u64,
134    },
135    AtEnd {
136        step: usize,
137    },
138    InvalidStep {
139        requested: usize,
140        max_step: Option<usize>,
141    },
142    ModeChanged(DisplayMode),
143    BreakpointSet(u64),
144    BreakpointCleared(u64),
145    BreakpointMissing(u64),
146}
147
148#[derive(Debug, Clone, PartialEq, Eq)]
149pub struct DebuggerState {
150    pub current_step: usize,
151    pub display_mode: DisplayMode,
152    pub breakpoints: BTreeSet<u64>,
153    trace: Option<TransactionTrace>,
154}
155
156impl Default for DebuggerState {
157    fn default() -> Self {
158        Self {
159            current_step: 0,
160            display_mode: DisplayMode::Source,
161            breakpoints: BTreeSet::new(),
162            trace: None,
163        }
164    }
165}
166
167impl DebuggerState {
168    pub fn new() -> Self {
169        Self::default()
170    }
171
172    pub fn load_trace(&mut self, trace: TransactionTrace) {
173        self.trace = Some(trace);
174        self.current_step = 0;
175    }
176
177    pub fn trace(&self) -> Option<&TransactionTrace> {
178        self.trace.as_ref()
179    }
180
181    pub fn current_step_data(&self) -> Option<&TraceStep> {
182        self.trace
183            .as_ref()
184            .and_then(|trace| trace.steps.get(self.current_step))
185    }
186
187    pub fn step_count(&self) -> usize {
188        self.trace
189            .as_ref()
190            .map(|trace| trace.steps.len())
191            .unwrap_or(0)
192    }
193
194    pub fn set_display_mode(&mut self, mode: DisplayMode) -> StepOutcome {
195        self.display_mode = mode;
196        StepOutcome::ModeChanged(mode)
197    }
198
199    pub fn next_instruction(&mut self) -> StepOutcome {
200        let Some(trace) = &self.trace else {
201            return StepOutcome::NoTrace;
202        };
203        if self.current_step >= trace.steps.len().saturating_sub(1) {
204            return StepOutcome::AtEnd {
205                step: self.current_step,
206            };
207        }
208
209        self.current_step += 1;
210        self.outcome_for_current_step()
211    }
212
213    pub fn next_source(&mut self) -> StepOutcome {
214        self.next_instruction()
215    }
216
217    pub fn step_into(&mut self) -> StepOutcome {
218        self.next_instruction()
219    }
220
221    pub fn continue_execution(&mut self) -> StepOutcome {
222        let Some(trace) = &self.trace else {
223            return StepOutcome::NoTrace;
224        };
225        if self.current_step >= trace.steps.len().saturating_sub(1) {
226            return StepOutcome::AtEnd {
227                step: self.current_step,
228            };
229        }
230
231        while self.current_step < trace.steps.len().saturating_sub(1) {
232            self.current_step += 1;
233            let step = &trace.steps[self.current_step];
234            if self.breakpoints.contains(&step.pc) {
235                return StepOutcome::BreakpointHit {
236                    step: self.current_step,
237                    pc: step.pc,
238                };
239            }
240        }
241
242        StepOutcome::AtEnd {
243            step: self.current_step,
244        }
245    }
246
247    pub fn goto_step(&mut self, step: usize) -> StepOutcome {
248        let Some(trace) = &self.trace else {
249            return StepOutcome::NoTrace;
250        };
251        if step >= trace.steps.len() {
252            return StepOutcome::InvalidStep {
253                requested: step,
254                max_step: trace.steps.len().checked_sub(1),
255            };
256        }
257        self.current_step = step;
258        self.outcome_for_current_step()
259    }
260
261    pub fn set_breakpoint(&mut self, pc: u64) -> StepOutcome {
262        self.breakpoints.insert(pc);
263        StepOutcome::BreakpointSet(pc)
264    }
265
266    pub fn clear_breakpoint(&mut self, pc: u64) -> StepOutcome {
267        if self.breakpoints.remove(&pc) {
268            StepOutcome::BreakpointCleared(pc)
269        } else {
270            StepOutcome::BreakpointMissing(pc)
271        }
272    }
273
274    pub fn apply_command(&mut self, command: DebuggerCommand) -> Option<StepOutcome> {
275        match command {
276            DebuggerCommand::Next => Some(self.next_source()),
277            DebuggerCommand::NextInstruction => Some(self.next_instruction()),
278            DebuggerCommand::Step => Some(self.step_into()),
279            DebuggerCommand::Continue => Some(self.continue_execution()),
280            DebuggerCommand::Goto(step) => Some(self.goto_step(step)),
281            DebuggerCommand::Mode(Some(mode)) => Some(self.set_display_mode(mode)),
282            DebuggerCommand::Break(BreakpointTarget::Pc(pc)) => Some(self.set_breakpoint(pc)),
283            DebuggerCommand::Clear(BreakpointTarget::Pc(pc)) => Some(self.clear_breakpoint(pc)),
284            DebuggerCommand::Break(BreakpointTarget::SourceLine(_))
285            | DebuggerCommand::Clear(BreakpointTarget::SourceLine(_)) => None,
286            DebuggerCommand::Empty
287            | DebuggerCommand::Help(_)
288            | DebuggerCommand::Info(_)
289            | DebuggerCommand::Mode(None)
290            | DebuggerCommand::Print(_)
291            | DebuggerCommand::Quit
292            | DebuggerCommand::Unknown(_)
293            | DebuggerCommand::Vars => None,
294        }
295    }
296
297    fn outcome_for_current_step(&self) -> StepOutcome {
298        let Some(step) = self.current_step_data() else {
299            return StepOutcome::NoTrace;
300        };
301        if self.breakpoints.contains(&step.pc) {
302            return StepOutcome::BreakpointHit {
303                step: self.current_step,
304                pc: step.pc,
305            };
306        }
307        StepOutcome::Moved {
308            step: self.current_step,
309            pc: step.pc,
310            op: step.op.clone(),
311        }
312    }
313}
314
315fn parse_info_command(input: &str) -> Option<DebuggerInfoCommand> {
316    match input.trim() {
317        "resources" => Some(DebuggerInfoCommand::Resources { json: false }),
318        "resources --json" | "resources json" => {
319            Some(DebuggerInfoCommand::Resources { json: true })
320        }
321        _ => None,
322    }
323}
324
325fn parse_u64_arg(input: &str) -> Option<u64> {
326    let input = input.trim();
327    if let Some(hex) = input.strip_prefix("0x") {
328        u64::from_str_radix(hex, 16).ok()
329    } else {
330        input.parse::<u64>().ok()
331    }
332}
333
334fn parse_breakpoint_target(input: &str) -> Option<BreakpointTarget> {
335    parse_source_breakpoint_target(input)
336        .map(BreakpointTarget::SourceLine)
337        .or_else(|| parse_u64_arg(input).map(BreakpointTarget::Pc))
338}
339
340fn parse_source_breakpoint_target(input: &str) -> Option<SourceBreakpointTarget> {
341    let input = input.trim();
342    if let Some(line) = input.strip_prefix("line ") {
343        return parse_source_line_number(line)
344            .map(|line| SourceBreakpointTarget { file: None, line });
345    }
346
347    let (file, line) = input.rsplit_once(':')?;
348    let file = file.trim();
349    if file.is_empty() {
350        return None;
351    }
352    parse_source_line_number(line).map(|line| SourceBreakpointTarget {
353        file: Some(file.to_owned()),
354        line,
355    })
356}
357
358fn parse_source_line_number(input: &str) -> Option<u64> {
359    input.trim().parse::<u64>().ok().filter(|line| *line > 0)
360}
361
362#[cfg(test)]
363mod tests {
364    use std::collections::BTreeMap;
365
366    use super::{
367        BreakpointTarget, DebuggerCommand, DebuggerInfoCommand, DebuggerState, DisplayMode,
368        SourceBreakpointTarget, StepOutcome,
369    };
370    use soldb_core::{TraceStep, TransactionTrace};
371
372    #[test]
373    fn parses_repl_commands_and_aliases() {
374        assert_eq!(DebuggerCommand::parse(""), DebuggerCommand::Empty);
375        assert_eq!(DebuggerCommand::parse("n"), DebuggerCommand::Next);
376        assert_eq!(
377            DebuggerCommand::parse("stepi"),
378            DebuggerCommand::NextInstruction
379        );
380        assert_eq!(DebuggerCommand::parse("s"), DebuggerCommand::Step);
381        assert_eq!(DebuggerCommand::parse("c"), DebuggerCommand::Continue);
382        assert_eq!(DebuggerCommand::parse("goto 2"), DebuggerCommand::Goto(2));
383        assert_eq!(DebuggerCommand::parse("vars"), DebuggerCommand::Vars);
384        assert_eq!(DebuggerCommand::parse("LOCALS"), DebuggerCommand::Vars);
385        assert_eq!(
386            DebuggerCommand::parse("print balance"),
387            DebuggerCommand::Print("balance".to_owned())
388        );
389        assert_eq!(
390            DebuggerCommand::parse("p   balance  "),
391            DebuggerCommand::Print("balance".to_owned())
392        );
393        assert_eq!(
394            DebuggerCommand::parse("print"),
395            DebuggerCommand::Print(String::new())
396        );
397        assert_eq!(
398            DebuggerCommand::parse("info resources"),
399            DebuggerCommand::Info(DebuggerInfoCommand::Resources { json: false })
400        );
401        assert_eq!(
402            DebuggerCommand::parse("info resources --json"),
403            DebuggerCommand::Info(DebuggerInfoCommand::Resources { json: true })
404        );
405        assert_eq!(
406            DebuggerCommand::parse("mode assembly"),
407            DebuggerCommand::Mode(Some(DisplayMode::Assembly))
408        );
409        assert_eq!(
410            DebuggerCommand::parse("break 0x10"),
411            DebuggerCommand::Break(BreakpointTarget::Pc(16))
412        );
413        assert_eq!(
414            DebuggerCommand::parse("break Counter.sol:7"),
415            DebuggerCommand::Break(BreakpointTarget::SourceLine(SourceBreakpointTarget {
416                file: Some("Counter.sol".to_owned()),
417                line: 7
418            }))
419        );
420        assert_eq!(
421            DebuggerCommand::parse("break line 7"),
422            DebuggerCommand::Break(BreakpointTarget::SourceLine(SourceBreakpointTarget {
423                file: None,
424                line: 7
425            }))
426        );
427        assert_eq!(
428            DebuggerCommand::parse("clear 8"),
429            DebuggerCommand::Clear(BreakpointTarget::Pc(8))
430        );
431        assert_eq!(
432            DebuggerCommand::parse("help mode"),
433            DebuggerCommand::Help(Some("mode".to_owned()))
434        );
435        assert_eq!(DebuggerCommand::parse("q"), DebuggerCommand::Quit);
436        assert_eq!(
437            DebuggerCommand::parse("wat"),
438            DebuggerCommand::Unknown("wat".to_owned())
439        );
440    }
441
442    #[test]
443    fn loads_trace_and_steps_instruction_by_instruction() {
444        let mut state = DebuggerState::new();
445        assert_eq!(state.next_instruction(), StepOutcome::NoTrace);
446
447        state.load_trace(sample_trace());
448        assert_eq!(state.step_count(), 4);
449        assert_eq!(state.current_step_data().expect("step").op, "PUSH1");
450
451        assert_eq!(
452            state.next_instruction(),
453            StepOutcome::Moved {
454                step: 1,
455                pc: 2,
456                op: "MSTORE".to_owned()
457            }
458        );
459        assert_eq!(state.current_step, 1);
460    }
461
462    #[test]
463    fn continues_until_breakpoint_or_end() {
464        let mut state = DebuggerState::new();
465        state.load_trace(sample_trace());
466        assert_eq!(state.set_breakpoint(3), StepOutcome::BreakpointSet(3));
467
468        assert_eq!(
469            state.continue_execution(),
470            StepOutcome::BreakpointHit { step: 2, pc: 3 }
471        );
472        assert_eq!(state.current_step, 2);
473
474        assert_eq!(state.clear_breakpoint(3), StepOutcome::BreakpointCleared(3));
475        assert_eq!(state.clear_breakpoint(3), StepOutcome::BreakpointMissing(3));
476        assert_eq!(state.continue_execution(), StepOutcome::AtEnd { step: 3 });
477        assert_eq!(state.continue_execution(), StepOutcome::AtEnd { step: 3 });
478    }
479
480    #[test]
481    fn goto_validates_trace_bounds() {
482        let mut state = DebuggerState::new();
483        assert_eq!(state.goto_step(1), StepOutcome::NoTrace);
484
485        state.load_trace(sample_trace());
486        assert_eq!(
487            state.goto_step(3),
488            StepOutcome::Moved {
489                step: 3,
490                pc: 4,
491                op: "STOP".to_owned()
492            }
493        );
494        assert_eq!(
495            state.goto_step(99),
496            StepOutcome::InvalidStep {
497                requested: 99,
498                max_step: Some(3)
499            }
500        );
501    }
502
503    #[test]
504    fn applies_state_changing_commands() {
505        let mut state = DebuggerState::new();
506        state.load_trace(sample_trace());
507
508        assert_eq!(
509            state.apply_command(DebuggerCommand::Mode(Some(DisplayMode::Assembly))),
510            Some(StepOutcome::ModeChanged(DisplayMode::Assembly))
511        );
512        assert_eq!(state.display_mode.as_str(), "asm");
513        assert_eq!(
514            state.apply_command(DebuggerCommand::Break(BreakpointTarget::Pc(2))),
515            Some(StepOutcome::BreakpointSet(2))
516        );
517        assert_eq!(
518            state.apply_command(DebuggerCommand::NextInstruction),
519            Some(StepOutcome::BreakpointHit { step: 1, pc: 2 })
520        );
521        assert_eq!(state.apply_command(DebuggerCommand::Help(None)), None);
522        assert_eq!(state.apply_command(DebuggerCommand::Quit), None);
523    }
524
525    fn sample_trace() -> TransactionTrace {
526        TransactionTrace {
527            tx_hash: Some("0xabc".to_owned()),
528            from_addr: "0x1".to_owned(),
529            to_addr: Some("0x2".to_owned()),
530            value: "0x0".to_owned(),
531            input_data: "0x".to_owned(),
532            gas_used: 21_000,
533            output: "0x".to_owned(),
534            success: true,
535            error: None,
536            debug_trace_available: true,
537            contract_address: None,
538            backend: Some("debug-rpc".to_owned()),
539            capabilities: Default::default(),
540            artifacts: Default::default(),
541            steps: vec![
542                step(0, "PUSH1", 100),
543                step(2, "MSTORE", 97),
544                step(3, "CALLDATASIZE", 94),
545                step(4, "STOP", 92),
546            ],
547        }
548    }
549
550    fn step(pc: u64, op: &str, gas: u64) -> TraceStep {
551        TraceStep {
552            pc,
553            op: op.to_owned(),
554            gas,
555            gas_cost: 1,
556            depth: 0,
557            stack: Vec::new(),
558            memory: None,
559            storage: Some(BTreeMap::new()),
560            error: None,
561            snapshot: Default::default(),
562        }
563    }
564}