Skip to main content

soldb_repl/
session.rs

1//! A debugging session: commands in, [`Output`] out.
2//!
3//! The session owns the [`DebuggerState`] and answers every command with data, never
4//! text, so the terminal REPL, the full-screen view, an editor, and a script all run
5//! the same commands and see the same answers. What is said once a session — that
6//! locals are inferred, that frame arguments are read off the stack — is remembered
7//! here, so it is said once whichever frontend asks first.
8
9use soldb_core::encode_hex;
10use soldb_debugger::{
11    state_value, state_variables, ChainStorage, DebugValueStatus, DebugVariable, StateSource,
12    StateVariable, StorageWords, INFERRED_LOCALS_WARNING,
13};
14
15use crate::command::{command_spec, CommandGroup, COMMANDS};
16use crate::response::{
17    ArgumentInfo, BreakpointEvent, BreakpointInfo, FrameInfo, Level, ListedLine, LoadedContract,
18    MemoryInfo, MemoryWord, Output, ResourceInfo, SlotInfo, StateInfo, Stop, StopLocation,
19    StopReason, ValueStatus, VariableInfo,
20};
21use crate::{
22    BreakpointKind, DebuggerCommand, DebuggerInfoCommand, DebuggerState, DisplayMode, StepOutcome,
23    VariablesOrigin,
24};
25
26/// What `backtrace` says once about arguments read off the stack.
27pub const FRAME_ARGUMENTS_WARNING: &str = "frame arguments are read off the stack, not from \
28compiler-reported variable locations";
29
30/// The note under [`FRAME_ARGUMENTS_WARNING`].
31pub const FRAME_ARGUMENTS_NOTE: &str = "the calling convention was proved from this trace's \
32calldata; ETHDebug variable locations will replace this once the compiler emits them";
33
34/// How many lines `list` shows on each side of the current one.
35pub const LISTING_RADIUS: u64 = 5;
36
37/// A debugging session over one trace.
38pub struct Session {
39    state: DebuggerState,
40    chain: Option<Box<dyn ChainStorage>>,
41    locals_warning_shown: bool,
42    arguments_warning_shown: bool,
43}
44
45impl std::fmt::Debug for Session {
46    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        formatter
48            .debug_struct("Session")
49            .field("step", &self.state.current_step)
50            .field(
51                "chain",
52                &self.chain.as_ref().map(|chain| chain.label().to_owned()),
53            )
54            .finish_non_exhaustive()
55    }
56}
57
58impl Session {
59    #[must_use]
60    pub fn new(state: DebuggerState) -> Self {
61        Self {
62            state,
63            chain: None,
64            locals_warning_shown: false,
65            arguments_warning_shown: false,
66        }
67    }
68
69    /// Reads storage slots the trace never touched from `chain`.
70    #[must_use]
71    pub fn with_chain(mut self, chain: Option<Box<dyn ChainStorage>>) -> Self {
72        self.chain = chain;
73        self
74    }
75
76    #[must_use]
77    pub fn state(&self) -> &DebuggerState {
78        &self.state
79    }
80
81    pub fn state_mut(&mut self) -> &mut DebuggerState {
82        &mut self.state
83    }
84
85    /// The storage words known at the current step, read from the chain for slots the
86    /// trace never touched when a chain is attached.
87    #[must_use]
88    pub fn storage_words(&self) -> Option<StorageWords<'_>> {
89        self.state.storage_words_with_chain(self.chain.as_deref())
90    }
91
92    /// What was loaded: the trace's size and the contracts with sources.
93    #[must_use]
94    pub fn loaded(&self) -> Output {
95        let mut contracts = Vec::<LoadedContract>::new();
96        for contract in self.state.step_map().map_or(&[][..], |map| map.contracts()) {
97            if contract.info.instructions.is_empty() && contract.source_contents.is_empty() {
98                continue;
99            }
100            let entry = LoadedContract {
101                name: contract.name.clone(),
102                address: contract.address.clone(),
103            };
104            if !contracts.contains(&entry) {
105                contracts.push(entry);
106            }
107        }
108        Output::Loaded {
109            steps: self.state.step_count(),
110            contracts,
111        }
112    }
113
114    /// Where the session is now, as the first stop.
115    #[must_use]
116    pub fn initial_stop(&self) -> Vec<Output> {
117        match self.stop(StopReason::Initial) {
118            Some(stop) => vec![Output::Stop(stop)],
119            None => vec![message(Level::Warning, "No trace loaded.")],
120        }
121    }
122
123    /// Runs one command and answers it.
124    pub fn execute(&mut self, command: DebuggerCommand) -> Vec<Output> {
125        match command {
126            DebuggerCommand::Empty => Vec::new(),
127            DebuggerCommand::Quit => vec![message(Level::Info, "Exiting debugger."), Output::Quit],
128            DebuggerCommand::Tui => vec![Output::Tui],
129            DebuggerCommand::Help(topic) => vec![Self::help(topic.as_deref())],
130            DebuggerCommand::Mode(None) => vec![message(
131                Level::Info,
132                format!("Mode: {}", self.state.display_mode.as_str()),
133            )],
134            DebuggerCommand::Info(DebuggerInfoCommand::Resources { json }) => {
135                vec![self.resources(json)]
136            }
137            DebuggerCommand::Info(DebuggerInfoCommand::Breakpoints) => vec![self.breakpoints()],
138            DebuggerCommand::Info(DebuggerInfoCommand::Storage) => vec![self.storage()],
139            DebuggerCommand::Info(DebuggerInfoCommand::TransientStorage) => {
140                vec![self.transient_storage()]
141            }
142            DebuggerCommand::Vars => vec![self.variables(None)],
143            DebuggerCommand::Print(name) => {
144                let name = name.trim();
145                if name.is_empty() {
146                    vec![message(Level::Warning, "Usage: print <variable>")]
147                } else {
148                    vec![self.variables(Some(name))]
149                }
150            }
151            DebuggerCommand::Backtrace => vec![self.backtrace()],
152            DebuggerCommand::List => vec![self.listing(LISTING_RADIUS)],
153            DebuggerCommand::Memory { offset, length } => vec![self.memory(offset, length)],
154            DebuggerCommand::Calldata => vec![self.calldata()],
155            DebuggerCommand::ReturnData => vec![self.return_data()],
156            DebuggerCommand::Stack { limit } => vec![self.stack(limit)],
157            DebuggerCommand::Unknown(text) => vec![message(
158                Level::Warning,
159                format!("Unknown command: {text}; type `help` for the list"),
160            )],
161            command => {
162                let mut outputs = match self.state.apply_command(command) {
163                    Some(outcome) => self.outcome(outcome),
164                    None => Vec::new(),
165                };
166                if let Some(note) = self.state.take_note() {
167                    match outputs.last_mut() {
168                        Some(Output::Stop(stop)) => stop.note = Some(note),
169                        _ => outputs.push(Output::Message {
170                            level: Level::Note,
171                            text: note,
172                            note: None,
173                        }),
174                    }
175                }
176                outputs
177            }
178        }
179    }
180
181    /// What a movement or breakpoint command did.
182    fn outcome(&self, outcome: StepOutcome) -> Vec<Output> {
183        let stop = |reason: StopReason| -> Vec<Output> {
184            match self.stop(reason) {
185                Some(stop) => vec![Output::Stop(stop)],
186                None => vec![message(Level::Warning, "No trace loaded.")],
187            }
188        };
189        match outcome {
190            StepOutcome::NoTrace => vec![message(Level::Warning, "No trace loaded.")],
191            StepOutcome::Moved { .. } => stop(StopReason::Moved),
192            StepOutcome::BreakpointHit { breakpoint, .. } => stop(StopReason::Breakpoint {
193                id: breakpoint.id,
194                label: breakpoint.label(),
195            }),
196            StepOutcome::AtEnd { .. } => stop(StopReason::End),
197            StepOutcome::AtStart { .. } => stop(StopReason::Start),
198            StepOutcome::InvalidStep {
199                requested,
200                max_step,
201            } => vec![message(
202                Level::Warning,
203                match max_step {
204                    Some(max_step) => format!("Invalid step {requested}; max step is {max_step}"),
205                    None => format!("Invalid step {requested}; trace is empty"),
206                },
207            )],
208            StepOutcome::ModeChanged(mode) => {
209                vec![message(Level::Info, format!("Mode: {}", mode.as_str()))]
210            }
211            StepOutcome::BreakpointSet(breakpoint) => vec![Output::Breakpoint {
212                event: BreakpointEvent::Set,
213                id: breakpoint.id,
214                label: breakpoint.label(),
215            }],
216            StepOutcome::BreakpointCleared(breakpoint) => vec![Output::Breakpoint {
217                event: BreakpointEvent::Cleared,
218                id: breakpoint.id,
219                label: breakpoint.label(),
220            }],
221            StepOutcome::BreakpointMissing(label) => vec![message(
222                Level::Warning,
223                format!("No breakpoint set at {label}"),
224            )],
225            StepOutcome::BreakpointError(text) => vec![message(
226                Level::Warning,
227                format!("Could not set breakpoint: {text}"),
228            )],
229        }
230    }
231
232    /// The current step as a stop, or `None` without a trace.
233    #[must_use]
234    pub fn stop(&self, reason: StopReason) -> Option<Stop> {
235        let step = self.state.current_step_data()?;
236        let location = self.state.location().map(|location| StopLocation {
237            text: self
238                .state
239                .step_map()
240                .and_then(|map| map.contracts().get(location.key.contract))
241                .and_then(|contract| contract.line_text(location.key.source_id, location.line))
242                .map(str::to_owned),
243            path: location.path,
244            line: location.line,
245            function: location.function_name,
246            generated: location.generated,
247        });
248        let address = self
249            .state
250            .step_map()
251            .and_then(|map| map.executing_address(self.state.current_step))
252            .map(str::to_owned);
253        let stack = (self.state.display_mode == DisplayMode::Assembly).then(|| {
254            step.stack()
255                .iter()
256                .rev()
257                .map(|word| word.to_string())
258                .collect()
259        });
260        Some(Stop {
261            reason,
262            step: self.state.current_step,
263            last_step: self.state.step_count().saturating_sub(1),
264            pc: step.pc(),
265            op: step.mnemonic().to_owned(),
266            gas: step.gas(),
267            location,
268            address,
269            has_source: self.state.has_source(),
270            stack,
271            note: None,
272        })
273    }
274
275    #[must_use]
276    pub fn breakpoints(&self) -> Output {
277        Output::Breakpoints {
278            breakpoints: self
279                .state
280                .breakpoints()
281                .iter()
282                .map(|breakpoint| BreakpointInfo {
283                    id: breakpoint.id,
284                    label: breakpoint.label(),
285                })
286                .collect(),
287        }
288    }
289
290    /// The call structure at the current step, innermost frame first.
291    pub fn backtrace(&mut self) -> Output {
292        let frames = self.state.frames();
293        if frames.is_empty() {
294            return message(Level::Warning, "No trace loaded.");
295        }
296        let show_warning =
297            !self.arguments_warning_shown && frames.iter().any(|frame| !frame.arguments.is_empty());
298        let (warning, note) = if show_warning {
299            self.arguments_warning_shown = true;
300            (
301                Some(FRAME_ARGUMENTS_WARNING.to_owned()),
302                Some(FRAME_ARGUMENTS_NOTE.to_owned()),
303            )
304        } else {
305            (None, None)
306        };
307        let frames = frames
308            .iter()
309            .enumerate()
310            .map(|(index, frame)| FrameInfo {
311                index,
312                name: frame
313                    .function_name
314                    .clone()
315                    .or_else(|| frame.contract_name.clone())
316                    .or_else(|| frame.address.clone())
317                    .unwrap_or_else(|| "<unknown>".to_owned()),
318                arguments: frame
319                    .arguments
320                    .iter()
321                    .map(|argument| ArgumentInfo {
322                        name: argument.name.clone(),
323                        value: argument.value.display.clone(),
324                    })
325                    .collect(),
326                location: frame
327                    .location
328                    .as_ref()
329                    .map(|location| format!("{}:{}", location.path, location.line)),
330                address: (frame.external && frame.function_name.is_some())
331                    .then(|| frame.address.clone())
332                    .flatten(),
333                step: frame.step,
334                pc: frame.pc,
335                kind: frame.kind.clone(),
336            })
337            .collect();
338        Output::Backtrace {
339            warning,
340            note,
341            frames,
342        }
343    }
344
345    /// `radius` lines of source on each side of the current step's line.
346    #[must_use]
347    pub fn listing(&self, radius: u64) -> Output {
348        let Some(listing) = self.state.source_listing(radius) else {
349            return if self.state.has_source() {
350                message(Level::Warning, "No source for this step.")
351            } else {
352                message(
353                    Level::Warning,
354                    "Cannot list source: no ETHDebug metadata is loaded; start the session with \
355                     `--ethdebug-dir <address>:<contract>:<dir>`",
356                )
357            };
358        };
359        Output::Listing {
360            path: listing.path,
361            current_line: listing.current_line,
362            lines: listing
363                .lines
364                .into_iter()
365                .map(|(line, text)| ListedLine {
366                    line,
367                    text,
368                    current: line == listing.current_line,
369                })
370                .collect(),
371        }
372    }
373
374    /// Memory at the current step in 32-byte words, the whole of it or one range.
375    #[must_use]
376    pub fn memory(&self, offset: Option<u64>, length: Option<u64>) -> Output {
377        let Some(step) = self.state.current_step_data() else {
378            return message(Level::Warning, "No trace loaded.");
379        };
380        let Some(memory) = step.memory() else {
381            let captured = self
382                .state
383                .trace()
384                .is_some_and(|trace| trace.capabilities.memory);
385            return if captured {
386                message(Level::Info, "Memory is empty at this step.")
387            } else {
388                message(Level::Warning, "Memory was not captured by this backend.")
389            };
390        };
391        if memory.is_empty() {
392            return message(Level::Info, "Memory is empty at this step.");
393        }
394        let total = memory.len();
395        let start = usize::try_from(offset.unwrap_or(0)).unwrap_or(usize::MAX);
396        if start >= total {
397            return message(
398                Level::Warning,
399                format!("Nothing to show: memory is {total} bytes; offset {start} is past the end"),
400            );
401        }
402        let end = length
403            .and_then(|length| usize::try_from(length).ok())
404            .and_then(|length| start.checked_add(length))
405            .map_or(total, |end| end.min(total));
406        let words = memory[start..end]
407            .chunks(32)
408            .enumerate()
409            .map(|(index, word)| MemoryWord {
410                offset: start + index * 32,
411                hex: encode_hex(word),
412            })
413            .collect();
414        Output::Memory(MemoryInfo {
415            total,
416            start,
417            end,
418            words,
419        })
420    }
421
422    /// Every slot whose value is known at the current step: what the transaction has read
423    /// or written so far in this frame's storage.
424    #[must_use]
425    pub fn storage(&self) -> Output {
426        let Some(step) = self.state.current_step_data() else {
427            return message(Level::Warning, "No trace loaded.");
428        };
429        let captured = self
430            .state
431            .trace()
432            .is_some_and(|trace| trace.capabilities.storage);
433        if !captured {
434            return message(
435                Level::Warning,
436                "Storage was not captured by this backend; no per-step storage was recorded.",
437            );
438        }
439        let known = self
440            .state
441            .storage_words()
442            .map(|words| words.known())
443            .unwrap_or_default();
444        // The slots this step itself changed, so a stop at an `SSTORE` shows what moved.
445        let changed = step.storage_diff();
446        let slots = known
447            .into_iter()
448            .map(|(slot, value)| {
449                let slot = soldb_debugger::short_hex(&slot);
450                let was = changed
451                    .iter()
452                    .find(|(candidate, _)| normalize_storage_slot(candidate) == slot)
453                    .map(|(_, change)| change.before.clone().unwrap_or_else(|| "0x0".to_owned()));
454                SlotInfo {
455                    slot,
456                    value: soldb_debugger::short_hex(&value),
457                    was,
458                }
459            })
460            .collect();
461        Output::Storage {
462            address: self.state.storage_address().map(str::to_owned),
463            slots,
464        }
465    }
466
467    /// Every transient storage slot whose value is known at the current step.
468    #[must_use]
469    pub fn transient_storage(&self) -> Output {
470        if self.state.current_step_data().is_none() {
471            return message(Level::Warning, "No trace loaded.");
472        }
473        let captured = self
474            .state
475            .trace()
476            .is_some_and(|trace| trace.capabilities.transient_storage);
477        if !captured {
478            return message(
479                Level::Warning,
480                "Transient storage is not in this trace; it was recorded without `TLOAD` and `TSTORE` values.",
481            );
482        }
483        let (Some(words), Some(address)) =
484            (self.state.transient_words(), self.state.storage_address())
485        else {
486            return message(
487                Level::Warning,
488                "Transient storage unavailable: this frame's storage belongs to no known account.",
489            );
490        };
491        let slots = words
492            .known()
493            .into_iter()
494            .map(|(slot, value)| SlotInfo {
495                slot: soldb_debugger::short_hex(&slot),
496                value: soldb_debugger::short_hex(&value),
497                was: None,
498            })
499            .collect();
500        Output::TransientStorage {
501            address: Some(address.to_owned()),
502            slots,
503        }
504    }
505
506    /// The return data buffer at the current step.
507    #[must_use]
508    pub fn return_data(&self) -> Output {
509        let Some(step) = self.state.current_step_data() else {
510            return message(Level::Warning, "No trace loaded.");
511        };
512        let captured = self
513            .state
514            .trace()
515            .is_some_and(|trace| trace.capabilities.return_data);
516        if !captured {
517            return message(
518                Level::Warning,
519                "Return data is not in this trace; the backend did not record it per step.",
520            );
521        }
522        let data = step.return_data().unwrap_or_default();
523        Output::ReturnData {
524            bytes: data.len() / 2,
525            data: format!("0x{data}"),
526        }
527    }
528
529    #[must_use]
530    pub fn calldata(&self) -> Output {
531        if self.state.trace().is_none() {
532            return message(Level::Warning, "No trace loaded.");
533        }
534        match self.state.calldata() {
535            Some(data) => Output::Calldata {
536                bytes: data.trim_start_matches("0x").len() / 2,
537                data,
538            },
539            None => message(
540                Level::Warning,
541                "Calldata for this frame was not recorded by the backend; only the root frame's \
542                 is known.",
543            ),
544        }
545    }
546
547    /// The stack at the current step, all of it or the top `limit` words.
548    #[must_use]
549    pub fn stack(&self, limit: Option<usize>) -> Output {
550        let Some(step) = self.state.current_step_data() else {
551            return message(Level::Warning, "No trace loaded.");
552        };
553        let words = step
554            .stack()
555            .iter()
556            .map(|word| word.to_string())
557            .collect::<Vec<_>>();
558        let shown = limit.map_or(words.len(), |limit| limit.min(words.len()));
559        Output::Stack { words, shown }
560    }
561
562    /// The variables in scope at the current step, or the one named by `filter`, which
563    /// may be a path into a local or a state variable.
564    pub fn variables(&mut self, filter: Option<&str>) -> Output {
565        let Some(step) = self.state.current_step_data() else {
566            return message(Level::Warning, "Cannot read variables: no trace is loaded");
567        };
568        let pc = step.pc();
569        // No contract sources loaded at all: the same "load debug info" answer `list`
570        // gives, rather than a per-step "no variables here".
571        let no_metadata = self.state.step_map().is_none_or(|map| {
572            map.contracts()
573                .iter()
574                .all(|c| c.info.instructions.is_empty())
575        });
576        if no_metadata {
577            return message(
578                Level::Warning,
579                "Cannot read variables: no ETHDebug metadata is loaded; start the session with \
580                 `--ethdebug-dir <address>:<contract>:<dir>`",
581            );
582        }
583        let variables = self.state.variables();
584        let warning = match &variables {
585            Ok(step_variables)
586                if step_variables.origin == VariablesOrigin::Inferred
587                    && !self.locals_warning_shown =>
588            {
589                self.locals_warning_shown = true;
590                Some(INFERRED_LOCALS_WARNING.to_owned())
591            }
592            _ => None,
593        };
594        let (locals, unavailable) = match variables {
595            Ok(step_variables) => (step_variables.variables, None),
596            Err(reason) => (Vec::new(), Some(reason)),
597        };
598        let words = self.storage_words();
599        let layout = self.state.storage_layout();
600        let chain_label = words.as_ref().and_then(StorageWords::chain_label);
601        let transient_words = self.state.transient_words();
602        let transient_layout = self.state.transient_storage_layout();
603
604        if let Some(name) = filter {
605            if let Some(variable) = locals.iter().find(|variable| variable.name == name) {
606                return Output::Variable {
607                    warning,
608                    variable: local_info(variable),
609                };
610            }
611            // A path through a local: `item.tags[1]`, `stored.owners[0xabc]`, `blob.length`.
612            match self.state.local_path(name) {
613                Some(Ok(variable)) => {
614                    return Output::Variable {
615                        warning,
616                        variable: local_info(&variable),
617                    }
618                }
619                Some(Err(reason)) => {
620                    return message(Level::Warning, format!("Cannot read variable: {reason}"))
621                }
622                None => {}
623            }
624            // Names are unique across both spaces, so a transient variable is found by name.
625            if let (Some(transient_layout), Some(transient_words)) =
626                (transient_layout, transient_words.as_ref())
627            {
628                if let Ok(variable) = state_value(transient_layout, transient_words, name) {
629                    return Output::Variable {
630                        warning,
631                        variable: transient_info(&variable),
632                    };
633                }
634            }
635            let (Some(layout), Some(words)) = (layout, words.as_ref()) else {
636                return message(
637                    Level::Warning,
638                    format!(
639                        "No such variable: `{name}` is not in scope at PC {pc}; state variables \
640                         need a storage layout, compile with `--storage-layout`"
641                    ),
642                );
643            };
644            return match state_value(layout, words, name) {
645                Ok(variable) => Output::Variable {
646                    warning,
647                    variable: state_info(&variable, chain_label),
648                },
649                Err(error) => Output::Message {
650                    level: Level::Warning,
651                    text: format!("No such variable: `{name}` is not in scope at PC {pc}; {error}"),
652                    note: unavailable.map(|reason| {
653                        format!(
654                            "locals are unavailable here: {reason}, so a local of that name \
655                             cannot be looked up"
656                        )
657                    }),
658                },
659            };
660        }
661
662        let state = match (layout, words.as_ref()) {
663            (Some(layout), _) if layout.variables.is_empty() => StateInfo::None,
664            (Some(layout), Some(words)) => StateInfo::Variables {
665                variables: state_variables(layout, words)
666                    .iter()
667                    .map(|variable| state_info(variable, chain_label))
668                    .collect(),
669            },
670            (Some(_), None) => StateInfo::NoStorage,
671            (None, _) => StateInfo::NoLayout,
672        };
673        let transient = match (transient_layout, transient_words.as_ref()) {
674            (Some(layout), Some(words)) => state_variables(layout, words)
675                .iter()
676                .map(transient_info)
677                .collect(),
678            _ => Vec::new(),
679        };
680        Output::Variables {
681            warning,
682            pc,
683            locals: locals.iter().map(local_info).collect(),
684            unavailable,
685            state,
686            transient,
687        }
688    }
689
690    /// The contracts whose debug resources are loaded.
691    #[must_use]
692    pub fn resources(&self, json: bool) -> Output {
693        let contracts = self
694            .state
695            .step_map()
696            .map_or(&[][..], |map| map.contracts())
697            .iter()
698            .filter(|contract| !contract.info.instructions.is_empty())
699            .map(|contract| ResourceInfo {
700                name: contract.name.clone(),
701                address: contract.address.clone(),
702                environment: contract.info.environment.clone(),
703                sources: contract.info.sources.values().cloned().collect(),
704            })
705            .collect();
706        Output::Resources { contracts, json }
707    }
708
709    /// The command list, or one command's details.
710    #[must_use]
711    pub fn help(topic: Option<&str>) -> Output {
712        let lines = match topic.map(str::trim).filter(|topic| !topic.is_empty()) {
713            Some(topic) => match command_spec(topic) {
714                Some(spec) => {
715                    let mut lines = vec![format!("{}  {}", spec.usage, spec.summary)];
716                    if !spec.aliases.is_empty() {
717                        lines.push(format!("aliases: {}", spec.aliases.join(", ")));
718                    }
719                    lines.extend(spec.details.iter().map(|line| (*line).to_owned()));
720                    lines
721                }
722                None => vec![format!("No help for `{topic}`; type `help` for the list")],
723            },
724            None => {
725                let width = COMMANDS
726                    .iter()
727                    .map(|spec| spec.usage.len())
728                    .max()
729                    .unwrap_or(0);
730                let mut lines = Vec::new();
731                for group in CommandGroup::ALL {
732                    lines.push(format!("{}:", group.title()));
733                    for spec in COMMANDS.iter().filter(|spec| spec.group == group) {
734                        lines.push(format!(
735                            "  {:<width$}  {}",
736                            spec.usage,
737                            spec.summary,
738                            width = width
739                        ));
740                    }
741                }
742                lines.push("Aliases: n s ni fin c | rn rs rni rfin rc | b d i | bt l mem | p | h q; `help <command>` says more.".to_owned());
743                lines
744            }
745        };
746        Output::Help { lines }
747    }
748}
749
750fn message(level: Level, text: impl Into<String>) -> Output {
751    Output::Message {
752        level,
753        text: text.into(),
754        note: None,
755    }
756}
757
758fn status(status: DebugValueStatus) -> ValueStatus {
759    match status {
760        DebugValueStatus::Decoded => ValueStatus::Decoded,
761        DebugValueStatus::Raw => ValueStatus::Raw,
762        DebugValueStatus::Unavailable => ValueStatus::Unavailable,
763    }
764}
765
766/// A local, parameter, or return value with the stack slot it was read from, or without
767/// one when it could not be read.
768fn local_info(variable: &DebugVariable) -> VariableInfo {
769    VariableInfo {
770        name: variable.name.clone(),
771        ty: variable.ty.clone(),
772        value: variable.value.display.clone(),
773        raw: variable.value.raw.clone(),
774        status: status(variable.value.status),
775        place: (variable.value.status != DebugValueStatus::Unavailable)
776            .then(|| format!("{}+{}", variable.location.kind, variable.location.offset)),
777    }
778}
779
780fn state_info(variable: &StateVariable, chain_label: Option<&str>) -> VariableInfo {
781    let mut place = if variable.offset == 0 {
782        format!("slot {}", variable.slot)
783    } else {
784        format!("slot {} + {}", variable.slot, variable.offset)
785    };
786    if variable.source == StateSource::Chain {
787        place.push_str(&format!(", from {}", chain_label.unwrap_or("the chain")));
788    }
789    VariableInfo {
790        name: variable.name.clone(),
791        ty: variable.ty.clone(),
792        value: variable.value.display.clone(),
793        raw: variable.value.raw.clone(),
794        status: status(variable.value.status),
795        place: Some(place),
796    }
797}
798
799/// A `transient` state variable, its place marked so it is not read as a storage slot.
800fn transient_info(variable: &StateVariable) -> VariableInfo {
801    let place = if variable.offset == 0 {
802        format!("transient slot {}", variable.slot)
803    } else {
804        format!("transient slot {} + {}", variable.slot, variable.offset)
805    };
806    VariableInfo {
807        place: Some(place),
808        ..state_info(variable, None)
809    }
810}
811
812/// A recorded slot the way `storage` prints it: `0x`-prefixed, no leading zeros.
813fn normalize_storage_slot(slot: &str) -> String {
814    let digits = slot.trim_start_matches("0x").trim_start_matches('0');
815    if digits.is_empty() {
816        "0x0".to_owned()
817    } else {
818        format!("0x{}", digits.to_ascii_lowercase())
819    }
820}
821
822/// Whether a breakpoint stops on a source line, for a view that marks lines.
823#[must_use]
824pub fn breakpoint_lines(state: &DebuggerState) -> Vec<(String, u64)> {
825    state
826        .breakpoints()
827        .iter()
828        .flat_map(|breakpoint| match &breakpoint.kind {
829            BreakpointKind::Line(lines) => lines
830                .iter()
831                .map(|line| (line.path.clone(), line.key.line))
832                .collect(),
833            _ => Vec::new(),
834        })
835        .collect()
836}