Skip to main content

soldb_repl/
command.rs

1//! The command language of the debugger: what a typed line means.
2//!
3//! One table, [`COMMANDS`], describes every command once — its name, its aliases, its
4//! usage, and its help — and both the parser and `help` are built from it, so the two
5//! cannot drift apart. Parsing never fails loudly: a line that is not a command becomes
6//! [`DebuggerCommand::Unknown`], which the session answers with a message, the way a
7//! shell does.
8
9use crate::{BreakpointTarget, DisplayMode, SourceBreakpointTarget};
10
11/// A command as typed, with its arguments parsed.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum DebuggerCommand {
14    Next,
15    NextInstruction,
16    Step,
17    Continue,
18    /// Run until the current frame returns to its caller.
19    Finish,
20    /// Step back to the previous source step.
21    ReverseNext,
22    /// Step back one EVM instruction.
23    ReverseNextInstruction,
24    /// Step back into the previous instruction, the mirror of `step`.
25    ReverseStep,
26    /// Run backward until a breakpoint or the first step.
27    ReverseContinue,
28    /// Run backward to the step that entered the current frame.
29    ReverseFinish,
30    Goto(usize),
31    /// List every variable in scope at the current step, and the state variables.
32    Vars,
33    /// Print one variable, or a path into one, at the current step.
34    ///
35    /// An empty name means the user typed `print` with no argument; the session reports
36    /// the usage rather than treating it as an unknown command.
37    Print(String),
38    Info(DebuggerInfoCommand),
39    /// The call structure at the current step.
40    Backtrace,
41    /// The source around the current step.
42    List,
43    /// Memory at the current step, optionally one range of it.
44    Memory {
45        offset: Option<u64>,
46        length: Option<u64>,
47    },
48    /// The calldata of the current call frame.
49    Calldata,
50    /// The return data buffer at the current step.
51    ReturnData,
52    /// The stack at the current step, all of it or the top `limit` words.
53    Stack {
54        limit: Option<usize>,
55    },
56    Mode(Option<DisplayMode>),
57    /// Set a breakpoint, optionally one that stops only when a condition holds.
58    Break(BreakpointTarget, Option<String>),
59    Clear(BreakpointTarget),
60    /// Remove a breakpoint by number.
61    Delete(u32),
62    Help(Option<String>),
63    /// Open the full-screen view.
64    Tui,
65    Quit,
66    Empty,
67    Unknown(String),
68}
69
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub enum DebuggerInfoCommand {
72    Resources { json: bool },
73    Breakpoints,
74    Storage,
75    TransientStorage,
76}
77
78/// What a command is for, which is how `help` groups them.
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum CommandGroup {
81    Stepping,
82    Reverse,
83    Breakpoints,
84    Inspection,
85    Variables,
86    Session,
87}
88
89impl CommandGroup {
90    /// Every group, in the order `help` lists them.
91    pub const ALL: [Self; 6] = [
92        Self::Stepping,
93        Self::Reverse,
94        Self::Breakpoints,
95        Self::Inspection,
96        Self::Variables,
97        Self::Session,
98    ];
99
100    #[must_use]
101    pub fn title(self) -> &'static str {
102        match self {
103            Self::Stepping => "Stepping",
104            Self::Reverse => "Reverse",
105            Self::Breakpoints => "Breakpoints",
106            Self::Inspection => "Inspection",
107            Self::Variables => "Variables",
108            Self::Session => "Session",
109        }
110    }
111}
112
113/// One command's description: what `help` prints, and what the parser answers to.
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub struct CommandSpec {
116    pub name: &'static str,
117    pub aliases: &'static [&'static str],
118    /// The command with its arguments, as typed.
119    pub usage: &'static str,
120    /// One line saying what it does.
121    pub summary: &'static str,
122    /// Further lines `help <command>` prints.
123    pub details: &'static [&'static str],
124    pub group: CommandGroup,
125}
126
127impl CommandSpec {
128    /// The name followed by its aliases in parentheses, as `help` lists it.
129    #[must_use]
130    pub fn names(&self) -> String {
131        if self.aliases.is_empty() {
132            self.name.to_owned()
133        } else {
134            format!("{} ({})", self.name, self.aliases.join(", "))
135        }
136    }
137
138    fn answers_to(&self, word: &str) -> bool {
139        self.name == word || self.aliases.contains(&word)
140    }
141}
142
143/// Every command, in the order `help` lists them.
144pub const COMMANDS: &[CommandSpec] = &[
145    CommandSpec {
146        name: "next",
147        aliases: &["n"],
148        usage: "next",
149        summary: "run to the next source line, stepping over calls",
150        details: &["Without debug info, moves one instruction."],
151        group: CommandGroup::Stepping,
152    },
153    CommandSpec {
154        name: "step",
155        aliases: &["s"],
156        usage: "step",
157        summary: "run to the next source line, entering calls",
158        details: &["Without debug info, moves one instruction."],
159        group: CommandGroup::Stepping,
160    },
161    CommandSpec {
162        name: "nexti",
163        aliases: &["ni", "stepi", "si"],
164        usage: "nexti",
165        summary: "execute one EVM instruction",
166        details: &[],
167        group: CommandGroup::Stepping,
168    },
169    CommandSpec {
170        name: "finish",
171        aliases: &["fin"],
172        usage: "finish",
173        summary: "run until the current frame returns",
174        details: &[],
175        group: CommandGroup::Stepping,
176    },
177    CommandSpec {
178        name: "continue",
179        aliases: &["c"],
180        usage: "continue",
181        summary: "run to the next breakpoint, or the end of the trace",
182        details: &[],
183        group: CommandGroup::Stepping,
184    },
185    CommandSpec {
186        name: "goto",
187        aliases: &[],
188        usage: "goto <step>",
189        summary: "jump to a step of the recording",
190        details: &[],
191        group: CommandGroup::Stepping,
192    },
193    CommandSpec {
194        name: "reverse-next",
195        aliases: &["rn", "rnext"],
196        usage: "reverse-next",
197        summary: "back to the previous source line, stepping over calls",
198        details: &[],
199        group: CommandGroup::Reverse,
200    },
201    CommandSpec {
202        name: "reverse-step",
203        aliases: &["rs", "rstep"],
204        usage: "reverse-step",
205        summary: "back to the previous source line, entering calls",
206        details: &[],
207        group: CommandGroup::Reverse,
208    },
209    CommandSpec {
210        name: "reverse-nexti",
211        aliases: &["rni", "back", "rnexti", "reverse-stepi", "rsi"],
212        usage: "reverse-nexti",
213        summary: "back one EVM instruction",
214        details: &[],
215        group: CommandGroup::Reverse,
216    },
217    CommandSpec {
218        name: "reverse-finish",
219        aliases: &["rfin", "rfinish"],
220        usage: "reverse-finish",
221        summary: "back to the step that entered the current frame",
222        details: &[],
223        group: CommandGroup::Reverse,
224    },
225    CommandSpec {
226        name: "reverse-continue",
227        aliases: &["rc", "rcontinue"],
228        usage: "reverse-continue",
229        summary: "back to the previous breakpoint, or the start of the trace",
230        details: &[],
231        group: CommandGroup::Reverse,
232    },
233    CommandSpec {
234        name: "break",
235        aliases: &["b"],
236        usage: "break <target> [if <condition>]",
237        summary: "stop at a place, or there only when a condition holds",
238        details: &[
239            "Targets:",
240            "  <pc>                        a program counter",
241            "  <file>:<line>, line <line>  entering a source line",
242            "  <function>, <Contract>.<f>  entering a function",
243            "  <state variable>            a write to it: counter, balances[0xabc], config.limit",
244            "  storage <slot>              an SSTORE to a slot",
245            "  revert                      a REVERT, or a failing step",
246            "  call [<address>]            a call, to one address or to any",
247            "  op <OPCODE>                 every execution of an opcode",
248            "A condition compares locals and paths into them (item.tags[1]), state variables,",
249            "the frame's arguments, enum literals (Color.Blue), and pc, gas, depth, op, step,",
250            "with == != < <= > >=, joined with && and ||:",
251            "  break Shop.sol:40 if price > 10 && item.color == Color.Blue",
252        ],
253        group: CommandGroup::Breakpoints,
254    },
255    CommandSpec {
256        name: "clear",
257        aliases: &[],
258        usage: "clear <target>",
259        summary: "remove the breakpoint set with that target",
260        details: &[],
261        group: CommandGroup::Breakpoints,
262    },
263    CommandSpec {
264        name: "delete",
265        aliases: &["d"],
266        usage: "delete <n>",
267        summary: "remove breakpoint number n",
268        details: &[],
269        group: CommandGroup::Breakpoints,
270    },
271    CommandSpec {
272        name: "info",
273        aliases: &["i"],
274        usage: "info breakpoints|storage|transient|resources [--json]",
275        summary: "the breakpoints, the storage or transient storage known here, or the loaded debug resources",
276        details: &[
277            "info breakpoints (i b)  every breakpoint with its number",
278            "info storage            every storage slot known at this step",
279            "info transient          every transient storage slot known at this step",
280            "info resources          the contracts whose sources are loaded, as text or JSON",
281        ],
282        group: CommandGroup::Breakpoints,
283    },
284    CommandSpec {
285        name: "backtrace",
286        aliases: &["bt", "where"],
287        usage: "backtrace",
288        summary: "the call frames at the current step, innermost first",
289        details: &[
290            "Each frame names its function, or the contract or address executing, where it",
291            "is, and the step and program counter it sits at. A frame entered at its function's",
292            "entry point shows the arguments it was entered with.",
293        ],
294        group: CommandGroup::Inspection,
295    },
296    CommandSpec {
297        name: "list",
298        aliases: &["l"],
299        usage: "list",
300        summary: "the source around the current step",
301        details: &[],
302        group: CommandGroup::Inspection,
303    },
304    CommandSpec {
305        name: "stack",
306        aliases: &[],
307        usage: "stack [<n>]",
308        summary: "the EVM stack at the current step, top first, all of it or the top n words",
309        details: &[
310            "Each word is shown with its index from the bottom of the stack, which is the",
311            "index `vars` reports a variable's slot as.",
312        ],
313        group: CommandGroup::Inspection,
314    },
315    CommandSpec {
316        name: "memory",
317        aliases: &["mem"],
318        usage: "memory [<offset> [<length>]]",
319        summary: "memory at the current step, in 32-byte words",
320        details: &[],
321        group: CommandGroup::Inspection,
322    },
323    CommandSpec {
324        name: "storage",
325        aliases: &[],
326        usage: "storage",
327        summary: "every storage slot known at the current step",
328        details: &["The same as `info storage`."],
329        group: CommandGroup::Inspection,
330    },
331    CommandSpec {
332        name: "transient",
333        aliases: &[],
334        usage: "transient",
335        summary: "every transient storage slot known at the current step",
336        details: &[
337            "The slots `TLOAD` and `TSTORE` touched so far in this frame's account, read",
338            "from the trace. A slot not listed is zero: transient storage starts empty in",
339            "every transaction. The same as `info transient`.",
340        ],
341        group: CommandGroup::Inspection,
342    },
343    CommandSpec {
344        name: "calldata",
345        aliases: &[],
346        usage: "calldata",
347        summary: "the calldata of the current frame",
348        details: &[],
349        group: CommandGroup::Inspection,
350    },
351    CommandSpec {
352        name: "returndata",
353        aliases: &[],
354        usage: "returndata",
355        summary: "the return data buffer at the current step",
356        details: &[
357            "What the frame's last call or create returned or reverted with, which",
358            "`RETURNDATASIZE` and `RETURNDATACOPY` read. Empty until the frame makes a call.",
359        ],
360        group: CommandGroup::Inspection,
361    },
362    CommandSpec {
363        name: "vars",
364        aliases: &["locals"],
365        usage: "vars",
366        summary: "the variables in scope at the current step, and every state variable",
367        details: &[
368            "Locals come from the artifact's ETHDebug variable locations when it carries any.",
369            "For solc's legacy pipeline they are read off the stack through the fixed layout",
370            "its code generator keeps, which the first `vars` of a session says: values can",
371            "be wrong under the optimizer inside one basic block, and a frame that could not",
372            "be placed, or a function the optimizer inlined, shows as unavailable. State",
373            "variables are read through the storage layout of `solc --storage-layout`.",
374        ],
375        group: CommandGroup::Variables,
376    },
377    CommandSpec {
378        name: "print",
379        aliases: &["p"],
380        usage: "print <variable>|<path>",
381        summary: "one variable, or a path into one: item.tags[1], balances[0xabc], stored.owner",
382        details: &[
383            "A path follows struct members, array elements, mapping entries, and `length`",
384            "from a local or a state variable, in any chain.",
385        ],
386        group: CommandGroup::Variables,
387    },
388    CommandSpec {
389        name: "mode",
390        aliases: &[],
391        usage: "mode [source|asm]",
392        summary: "show or switch what a stop prints: the source line, or the stack",
393        details: &[],
394        group: CommandGroup::Session,
395    },
396    CommandSpec {
397        name: "tui",
398        aliases: &[],
399        usage: "tui",
400        summary: "open the full-screen view; q returns to this prompt",
401        details: &[],
402        group: CommandGroup::Session,
403    },
404    CommandSpec {
405        name: "help",
406        aliases: &["h", "?"],
407        usage: "help [<command>]",
408        summary: "this list, or one command's details",
409        details: &[],
410        group: CommandGroup::Session,
411    },
412    CommandSpec {
413        name: "quit",
414        aliases: &["q", "exit"],
415        usage: "quit",
416        summary: "leave the debugger",
417        details: &[],
418        group: CommandGroup::Session,
419    },
420];
421
422/// The command a word names, by name or alias.
423#[must_use]
424pub fn command_spec(word: &str) -> Option<&'static CommandSpec> {
425    let word = word.trim().to_ascii_lowercase();
426    COMMANDS.iter().find(|spec| spec.answers_to(&word))
427}
428
429impl DebuggerCommand {
430    /// Parses one typed line.
431    #[must_use]
432    pub fn parse(line: &str) -> Self {
433        let line = line.trim();
434        if line.is_empty() {
435            return Self::Empty;
436        }
437        let mut parts = line.split_whitespace();
438        let word = parts.next().unwrap_or_default();
439        let rest = parts.collect::<Vec<_>>().join(" ");
440        let Some(spec) = command_spec(word) else {
441            return Self::Unknown(line.to_owned());
442        };
443        let unknown = || Self::Unknown(line.to_owned());
444        match spec.name {
445            "next" => Self::Next,
446            "nexti" => Self::NextInstruction,
447            "step" => Self::Step,
448            "continue" => Self::Continue,
449            "finish" => Self::Finish,
450            "reverse-next" => Self::ReverseNext,
451            "reverse-nexti" => Self::ReverseNextInstruction,
452            "reverse-step" => Self::ReverseStep,
453            "reverse-continue" => Self::ReverseContinue,
454            "reverse-finish" => Self::ReverseFinish,
455            "goto" => rest.parse::<usize>().map_or_else(|_| unknown(), Self::Goto),
456            "vars" => Self::Vars,
457            "print" => Self::Print(rest),
458            "backtrace" => Self::Backtrace,
459            "list" => Self::List,
460            "memory" => parse_memory_command(&rest).unwrap_or_else(unknown),
461            "calldata" => Self::Calldata,
462            "returndata" => Self::ReturnData,
463            "stack" => {
464                if rest.is_empty() {
465                    Self::Stack { limit: None }
466                } else {
467                    rest.parse::<usize>()
468                        .map_or_else(|_| unknown(), |limit| Self::Stack { limit: Some(limit) })
469                }
470            }
471            "storage" => Self::Info(DebuggerInfoCommand::Storage),
472            "transient" => Self::Info(DebuggerInfoCommand::TransientStorage),
473            "info" => parse_info_command(&rest).map_or_else(unknown, Self::Info),
474            "mode" => Self::Mode(
475                (!rest.is_empty())
476                    .then(|| DisplayMode::parse(&rest))
477                    .flatten(),
478            ),
479            "break" => {
480                // `break <target> if <condition>`: the condition is everything after the
481                // first ` if `, so a target containing `if` in a name still parses.
482                let (target, condition) = split_condition(&rest);
483                parse_breakpoint_target(target)
484                    .map_or_else(unknown, |target| Self::Break(target, condition))
485            }
486            "clear" => parse_breakpoint_target(&rest).map_or_else(unknown, Self::Clear),
487            "delete" => rest
488                .trim()
489                .trim_start_matches('#')
490                .parse::<u32>()
491                .map_or_else(|_| unknown(), Self::Delete),
492            "help" => Self::Help((!rest.is_empty()).then_some(rest)),
493            "tui" => Self::Tui,
494            "quit" => Self::Quit,
495            _ => unknown(),
496        }
497    }
498}
499
500fn parse_info_command(input: &str) -> Option<DebuggerInfoCommand> {
501    match input.trim() {
502        "resources" => Some(DebuggerInfoCommand::Resources { json: false }),
503        "resources --json" | "resources json" => {
504            Some(DebuggerInfoCommand::Resources { json: true })
505        }
506        "breakpoints" | "break" | "b" => Some(DebuggerInfoCommand::Breakpoints),
507        "storage" => Some(DebuggerInfoCommand::Storage),
508        "transient" => Some(DebuggerInfoCommand::TransientStorage),
509        _ => None,
510    }
511}
512
513fn parse_memory_command(input: &str) -> Option<DebuggerCommand> {
514    let mut parts = input.split_whitespace();
515    let offset = match parts.next() {
516        Some(text) => Some(parse_u64_arg(text)?),
517        None => None,
518    };
519    let length = match parts.next() {
520        Some(text) => Some(parse_u64_arg(text)?),
521        None => None,
522    };
523    if parts.next().is_some() {
524        return None;
525    }
526    Some(DebuggerCommand::Memory { offset, length })
527}
528
529fn parse_u64_arg(input: &str) -> Option<u64> {
530    let input = input.trim();
531    if let Some(hex) = input.strip_prefix("0x") {
532        u64::from_str_radix(hex, 16).ok()
533    } else {
534        input.parse::<u64>().ok()
535    }
536}
537
538fn parse_breakpoint_target(input: &str) -> Option<BreakpointTarget> {
539    let input = input.trim();
540    if input.is_empty() {
541        return None;
542    }
543    let mut parts = input.splitn(2, char::is_whitespace);
544    let head = parts.next().unwrap_or_default();
545    let rest = parts.next().map(str::trim).unwrap_or_default();
546    match head.to_ascii_lowercase().as_str() {
547        "storage" | "slot" => {
548            (!rest.is_empty()).then(|| BreakpointTarget::Storage(rest.to_owned()))
549        }
550        "revert" if rest.is_empty() => Some(BreakpointTarget::Revert),
551        "call" => Some(BreakpointTarget::Call(
552            (!rest.is_empty()).then(|| rest.to_owned()),
553        )),
554        "op" | "opcode" => (!rest.is_empty() && !rest.contains(char::is_whitespace))
555            .then(|| BreakpointTarget::Opcode(rest.to_owned())),
556        _ => {
557            if let Some(target) = parse_source_breakpoint_target(input) {
558                return Some(BreakpointTarget::SourceLine(target));
559            }
560            if let Some(pc) = parse_u64_arg(input) {
561                return Some(BreakpointTarget::Pc(pc));
562            }
563            if is_function_name(input) {
564                return Some(BreakpointTarget::Function(input.to_owned()));
565            }
566            // `balances[0xabc…]` and the like: a place in storage, named the way `print`
567            // names it.
568            is_state_path(input).then(|| BreakpointTarget::State(input.to_owned()))
569        }
570    }
571}
572
573/// Splits `<target> if <condition>` at the first ` if `, which cannot appear inside a
574/// target: a file name, a function name, a slot, and an opcode all lack spaces.
575fn split_condition(input: &str) -> (&str, Option<String>) {
576    let input = input.trim();
577    match input.find(" if ") {
578        Some(index) => {
579            let condition = input[index + 4..].trim();
580            (
581                input[..index].trim(),
582                (!condition.is_empty()).then(|| condition.to_owned()),
583            )
584        }
585        None => (input, None),
586    }
587}
588
589/// Whether the text looks like a state variable path: a name followed by any number of
590/// `[key]` and `.member` steps.
591fn is_state_path(input: &str) -> bool {
592    let head = input.split(['[', '.']).next().unwrap_or_default();
593    !head.is_empty() && is_function_name(head) && input.len() > head.len()
594}
595
596fn is_function_name(input: &str) -> bool {
597    let mut segments = input.split('.');
598    segments.all(|segment| {
599        let mut bytes = segment.bytes();
600        bytes
601            .next()
602            .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_')
603            && bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
604    })
605}
606
607fn parse_source_breakpoint_target(input: &str) -> Option<SourceBreakpointTarget> {
608    let input = input.trim();
609    if let Some(line) = input.strip_prefix("line ") {
610        return parse_source_line_number(line)
611            .map(|line| SourceBreakpointTarget { file: None, line });
612    }
613
614    let (file, line) = input.rsplit_once(':')?;
615    let file = file.trim();
616    if file.is_empty() {
617        return None;
618    }
619    parse_source_line_number(line).map(|line| SourceBreakpointTarget {
620        file: Some(file.to_owned()),
621        line,
622    })
623}
624
625fn parse_source_line_number(input: &str) -> Option<u64> {
626    input.trim().parse::<u64>().ok().filter(|line| *line > 0)
627}
628
629#[cfg(test)]
630mod tests {
631    use super::{command_spec, CommandGroup, DebuggerCommand, COMMANDS};
632
633    #[test]
634    fn every_command_is_named_once_and_parses_by_each_alias() {
635        let mut seen = Vec::new();
636        for spec in COMMANDS {
637            for word in std::iter::once(&spec.name).chain(spec.aliases) {
638                assert!(!seen.contains(word), "`{word}` names two commands");
639                seen.push(word);
640                assert_eq!(command_spec(word).map(|found| found.name), Some(spec.name));
641            }
642            assert!(!spec.summary.is_empty());
643            assert!(spec.usage.starts_with(spec.name), "{}", spec.usage);
644        }
645        assert_eq!(
646            CommandGroup::ALL.len(),
647            6,
648            "every group must be listed for help"
649        );
650        assert_eq!(
651            DebuggerCommand::parse("STACK 4"),
652            DebuggerCommand::Stack { limit: Some(4) }
653        );
654        assert_eq!(DebuggerCommand::parse("tui"), DebuggerCommand::Tui);
655        assert_eq!(DebuggerCommand::parse("?"), DebuggerCommand::Help(None));
656        assert_eq!(
657            DebuggerCommand::parse("help break"),
658            DebuggerCommand::Help(Some("break".to_owned()))
659        );
660        assert_eq!(
661            DebuggerCommand::parse("frobnicate"),
662            DebuggerCommand::Unknown("frobnicate".to_owned())
663        );
664    }
665}