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