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