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::Vars => vec![self.variables(None)],
139            DebuggerCommand::Print(name) => {
140                let name = name.trim();
141                if name.is_empty() {
142                    vec![message(Level::Warning, "Usage: print <variable>")]
143                } else {
144                    vec![self.variables(Some(name))]
145                }
146            }
147            DebuggerCommand::Backtrace => vec![self.backtrace()],
148            DebuggerCommand::List => vec![self.listing(LISTING_RADIUS)],
149            DebuggerCommand::Memory { offset, length } => vec![self.memory(offset, length)],
150            DebuggerCommand::Calldata => vec![self.calldata()],
151            DebuggerCommand::Stack { limit } => vec![self.stack(limit)],
152            DebuggerCommand::Unknown(text) => vec![message(
153                Level::Warning,
154                format!("Unknown command: {text}; type `help` for the list"),
155            )],
156            command => {
157                let mut outputs = match self.state.apply_command(command) {
158                    Some(outcome) => self.outcome(outcome),
159                    None => Vec::new(),
160                };
161                if let Some(note) = self.state.take_note() {
162                    match outputs.last_mut() {
163                        Some(Output::Stop(stop)) => stop.note = Some(note),
164                        _ => outputs.push(Output::Message {
165                            level: Level::Note,
166                            text: note,
167                            note: None,
168                        }),
169                    }
170                }
171                outputs
172            }
173        }
174    }
175
176    /// What a movement or breakpoint command did.
177    fn outcome(&self, outcome: StepOutcome) -> Vec<Output> {
178        let stop = |reason: StopReason| -> Vec<Output> {
179            match self.stop(reason) {
180                Some(stop) => vec![Output::Stop(stop)],
181                None => vec![message(Level::Warning, "No trace loaded.")],
182            }
183        };
184        match outcome {
185            StepOutcome::NoTrace => vec![message(Level::Warning, "No trace loaded.")],
186            StepOutcome::Moved { .. } => stop(StopReason::Moved),
187            StepOutcome::BreakpointHit { breakpoint, .. } => stop(StopReason::Breakpoint {
188                id: breakpoint.id,
189                label: breakpoint.label(),
190            }),
191            StepOutcome::AtEnd { .. } => stop(StopReason::End),
192            StepOutcome::AtStart { .. } => stop(StopReason::Start),
193            StepOutcome::InvalidStep {
194                requested,
195                max_step,
196            } => vec![message(
197                Level::Warning,
198                match max_step {
199                    Some(max_step) => format!("Invalid step {requested}; max step is {max_step}"),
200                    None => format!("Invalid step {requested}; trace is empty"),
201                },
202            )],
203            StepOutcome::ModeChanged(mode) => {
204                vec![message(Level::Info, format!("Mode: {}", mode.as_str()))]
205            }
206            StepOutcome::BreakpointSet(breakpoint) => vec![Output::Breakpoint {
207                event: BreakpointEvent::Set,
208                id: breakpoint.id,
209                label: breakpoint.label(),
210            }],
211            StepOutcome::BreakpointCleared(breakpoint) => vec![Output::Breakpoint {
212                event: BreakpointEvent::Cleared,
213                id: breakpoint.id,
214                label: breakpoint.label(),
215            }],
216            StepOutcome::BreakpointMissing(label) => vec![message(
217                Level::Warning,
218                format!("No breakpoint set at {label}"),
219            )],
220            StepOutcome::BreakpointError(text) => vec![message(
221                Level::Warning,
222                format!("Could not set breakpoint: {text}"),
223            )],
224        }
225    }
226
227    /// The current step as a stop, or `None` without a trace.
228    #[must_use]
229    pub fn stop(&self, reason: StopReason) -> Option<Stop> {
230        let step = self.state.current_step_data()?;
231        let location = self.state.location().map(|location| StopLocation {
232            text: self
233                .state
234                .step_map()
235                .and_then(|map| map.contracts().get(location.key.contract))
236                .and_then(|contract| contract.line_text(location.key.source_id, location.line))
237                .map(str::to_owned),
238            path: location.path,
239            line: location.line,
240            function: location.function_name,
241            generated: location.generated,
242        });
243        let address = self
244            .state
245            .step_map()
246            .and_then(|map| map.executing_address(self.state.current_step))
247            .map(str::to_owned);
248        let stack = (self.state.display_mode == DisplayMode::Assembly).then(|| {
249            step.snapshot_ref()
250                .stack
251                .iter()
252                .rev()
253                .map(|word| word.to_string())
254                .collect()
255        });
256        Some(Stop {
257            reason,
258            step: self.state.current_step,
259            last_step: self.state.step_count().saturating_sub(1),
260            pc: step.pc,
261            op: step.op.to_string(),
262            gas: step.gas,
263            location,
264            address,
265            has_source: self.state.has_source(),
266            stack,
267            note: None,
268        })
269    }
270
271    #[must_use]
272    pub fn breakpoints(&self) -> Output {
273        Output::Breakpoints {
274            breakpoints: self
275                .state
276                .breakpoints()
277                .iter()
278                .map(|breakpoint| BreakpointInfo {
279                    id: breakpoint.id,
280                    label: breakpoint.label(),
281                })
282                .collect(),
283        }
284    }
285
286    /// The call structure at the current step, innermost frame first.
287    pub fn backtrace(&mut self) -> Output {
288        let frames = self.state.frames();
289        if frames.is_empty() {
290            return message(Level::Warning, "No trace loaded.");
291        }
292        let show_warning =
293            !self.arguments_warning_shown && frames.iter().any(|frame| !frame.arguments.is_empty());
294        let (warning, note) = if show_warning {
295            self.arguments_warning_shown = true;
296            (
297                Some(FRAME_ARGUMENTS_WARNING.to_owned()),
298                Some(FRAME_ARGUMENTS_NOTE.to_owned()),
299            )
300        } else {
301            (None, None)
302        };
303        let frames = frames
304            .iter()
305            .enumerate()
306            .map(|(index, frame)| FrameInfo {
307                index,
308                name: frame
309                    .function_name
310                    .clone()
311                    .or_else(|| frame.contract_name.clone())
312                    .or_else(|| frame.address.clone())
313                    .unwrap_or_else(|| "<unknown>".to_owned()),
314                arguments: frame
315                    .arguments
316                    .iter()
317                    .map(|argument| ArgumentInfo {
318                        name: argument.name.clone(),
319                        value: argument.value.display.clone(),
320                    })
321                    .collect(),
322                location: frame
323                    .location
324                    .as_ref()
325                    .map(|location| format!("{}:{}", location.path, location.line)),
326                address: (frame.external && frame.function_name.is_some())
327                    .then(|| frame.address.clone())
328                    .flatten(),
329                step: frame.step,
330                pc: frame.pc,
331            })
332            .collect();
333        Output::Backtrace {
334            warning,
335            note,
336            frames,
337        }
338    }
339
340    /// `radius` lines of source on each side of the current step's line.
341    #[must_use]
342    pub fn listing(&self, radius: u64) -> Output {
343        let Some(listing) = self.state.source_listing(radius) else {
344            return if self.state.has_source() {
345                message(Level::Warning, "No source for this step.")
346            } else {
347                message(
348                    Level::Warning,
349                    "Cannot list source: no ETHDebug metadata is loaded; start the session with \
350                     `--ethdebug-dir <address>:<contract>:<dir>`",
351                )
352            };
353        };
354        Output::Listing {
355            path: listing.path,
356            current_line: listing.current_line,
357            lines: listing
358                .lines
359                .into_iter()
360                .map(|(line, text)| ListedLine {
361                    line,
362                    text,
363                    current: line == listing.current_line,
364                })
365                .collect(),
366        }
367    }
368
369    /// Memory at the current step in 32-byte words, the whole of it or one range.
370    #[must_use]
371    pub fn memory(&self, offset: Option<u64>, length: Option<u64>) -> Output {
372        let Some(step) = self.state.current_step_data() else {
373            return message(Level::Warning, "No trace loaded.");
374        };
375        let Some(memory) = step.snapshot_ref().memory else {
376            let captured = self
377                .state
378                .trace()
379                .is_some_and(|trace| trace.capabilities.memory);
380            return if captured {
381                message(Level::Info, "Memory is empty at this step.")
382            } else {
383                message(Level::Warning, "Memory was not captured by this backend.")
384            };
385        };
386        let hex = memory.trim_start_matches("0x");
387        if hex.is_empty() {
388            return message(Level::Info, "Memory is empty at this step.");
389        }
390        if !hex.is_ascii() {
391            return message(Level::Info, format!("Memory: {hex}"));
392        }
393        let total = hex.len() / 2;
394        let start = usize::try_from(offset.unwrap_or(0)).unwrap_or(usize::MAX);
395        if start >= total {
396            return message(
397                Level::Warning,
398                format!("Nothing to show: memory is {total} bytes; offset {start} is past the end"),
399            );
400        }
401        let end = length
402            .and_then(|length| usize::try_from(length).ok())
403            .and_then(|length| start.checked_add(length))
404            .map_or(total, |end| end.min(total));
405        let bytes = hex.as_bytes();
406        let mut words = Vec::new();
407        let mut word_start = start;
408        while word_start < end {
409            let word_end = word_start.saturating_add(32).min(end);
410            words.push(MemoryWord {
411                offset: word_start,
412                hex: std::str::from_utf8(&bytes[word_start * 2..word_end * 2])
413                    .unwrap_or("")
414                    .to_owned(),
415            });
416            word_start = word_end;
417        }
418        Output::Memory(MemoryInfo {
419            total,
420            start,
421            end,
422            words,
423        })
424    }
425
426    /// Every slot whose value is known at the current step: what the transaction has read
427    /// or written so far in this frame's storage.
428    #[must_use]
429    pub fn storage(&self) -> Output {
430        let Some(step) = self.state.current_step_data() else {
431            return message(Level::Warning, "No trace loaded.");
432        };
433        let captured = self
434            .state
435            .trace()
436            .is_some_and(|trace| trace.capabilities.storage);
437        if !captured {
438            return message(
439                Level::Warning,
440                "Storage was not captured by this backend; no per-step storage was recorded.",
441            );
442        }
443        let known = self
444            .state
445            .storage_words()
446            .map(|words| words.known())
447            .unwrap_or_default();
448        // The slots this step itself changed, so a stop at an `SSTORE` shows what moved.
449        let changed = step.snapshot_ref().storage_diff;
450        let slots = known
451            .into_iter()
452            .map(|(slot, value)| {
453                let slot = soldb_debugger::short_hex(&slot);
454                let was = changed
455                    .iter()
456                    .find(|(candidate, _)| normalize_storage_slot(candidate) == slot)
457                    .map(|(_, change)| change.before.clone().unwrap_or_else(|| "0x0".to_owned()));
458                SlotInfo {
459                    slot,
460                    value: soldb_debugger::short_hex(&value),
461                    was,
462                }
463            })
464            .collect();
465        Output::Storage {
466            address: self.state.storage_address().map(str::to_owned),
467            slots,
468        }
469    }
470
471    #[must_use]
472    pub fn calldata(&self) -> Output {
473        if self.state.trace().is_none() {
474            return message(Level::Warning, "No trace loaded.");
475        }
476        match self.state.calldata() {
477            Some(data) => Output::Calldata {
478                bytes: data.trim_start_matches("0x").len() / 2,
479                data,
480            },
481            None => message(
482                Level::Warning,
483                "Calldata for this frame was not recorded by the backend; only the root frame's \
484                 is known.",
485            ),
486        }
487    }
488
489    /// The stack at the current step, all of it or the top `limit` words.
490    #[must_use]
491    pub fn stack(&self, limit: Option<usize>) -> Output {
492        let Some(step) = self.state.current_step_data() else {
493            return message(Level::Warning, "No trace loaded.");
494        };
495        let words = step
496            .snapshot_ref()
497            .stack
498            .iter()
499            .map(|word| word.to_string())
500            .collect::<Vec<_>>();
501        let shown = limit.map_or(words.len(), |limit| limit.min(words.len()));
502        Output::Stack { words, shown }
503    }
504
505    /// The variables in scope at the current step, or the one named by `filter`, which
506    /// may be a path into a local or a state variable.
507    pub fn variables(&mut self, filter: Option<&str>) -> Output {
508        let Some(step) = self.state.current_step_data() else {
509            return message(Level::Warning, "Cannot read variables: no trace is loaded");
510        };
511        let pc = step.pc;
512        // No contract sources loaded at all: the same "load debug info" answer `list`
513        // gives, rather than a per-step "no variables here".
514        let no_metadata = self.state.step_map().is_none_or(|map| {
515            map.contracts()
516                .iter()
517                .all(|c| c.info.instructions.is_empty())
518        });
519        if no_metadata {
520            return message(
521                Level::Warning,
522                "Cannot read variables: no ETHDebug metadata is loaded; start the session with \
523                 `--ethdebug-dir <address>:<contract>:<dir>`",
524            );
525        }
526        let variables = self.state.variables();
527        let warning = match &variables {
528            Ok(step_variables)
529                if step_variables.origin == VariablesOrigin::Inferred
530                    && !self.locals_warning_shown =>
531            {
532                self.locals_warning_shown = true;
533                Some(INFERRED_LOCALS_WARNING.to_owned())
534            }
535            _ => None,
536        };
537        let (locals, unavailable) = match variables {
538            Ok(step_variables) => (step_variables.variables, None),
539            Err(reason) => (Vec::new(), Some(reason)),
540        };
541        let words = self.storage_words();
542        let layout = self.state.storage_layout();
543        let chain_label = words.as_ref().and_then(StorageWords::chain_label);
544
545        if let Some(name) = filter {
546            if let Some(variable) = locals.iter().find(|variable| variable.name == name) {
547                return Output::Variable {
548                    warning,
549                    variable: local_info(variable),
550                };
551            }
552            // A path through a local: `item.tags[1]`, `stored.owners[0xabc]`, `blob.length`.
553            match self.state.local_path(name) {
554                Some(Ok(variable)) => {
555                    return Output::Variable {
556                        warning,
557                        variable: local_info(&variable),
558                    }
559                }
560                Some(Err(reason)) => {
561                    return message(Level::Warning, format!("Cannot read variable: {reason}"))
562                }
563                None => {}
564            }
565            let (Some(layout), Some(words)) = (layout, words.as_ref()) else {
566                return message(
567                    Level::Warning,
568                    format!(
569                        "No such variable: `{name}` is not in scope at PC {pc}; state variables \
570                         need a storage layout, compile with `--storage-layout`"
571                    ),
572                );
573            };
574            return match state_value(layout, words, name) {
575                Ok(variable) => Output::Variable {
576                    warning,
577                    variable: state_info(&variable, chain_label),
578                },
579                Err(error) => Output::Message {
580                    level: Level::Warning,
581                    text: format!("No such variable: `{name}` is not in scope at PC {pc}; {error}"),
582                    note: unavailable.map(|reason| {
583                        format!(
584                            "locals are unavailable here: {reason}, so a local of that name \
585                             cannot be looked up"
586                        )
587                    }),
588                },
589            };
590        }
591
592        let state = match (layout, words.as_ref()) {
593            (Some(layout), _) if layout.variables.is_empty() => StateInfo::None,
594            (Some(layout), Some(words)) => StateInfo::Variables {
595                variables: state_variables(layout, words)
596                    .iter()
597                    .map(|variable| state_info(variable, chain_label))
598                    .collect(),
599            },
600            (Some(_), None) => StateInfo::NoStorage,
601            (None, _) => StateInfo::NoLayout,
602        };
603        Output::Variables {
604            warning,
605            pc,
606            locals: locals.iter().map(local_info).collect(),
607            unavailable,
608            state,
609        }
610    }
611
612    /// The contracts whose debug resources are loaded.
613    #[must_use]
614    pub fn resources(&self, json: bool) -> Output {
615        let contracts = self
616            .state
617            .step_map()
618            .map_or(&[][..], |map| map.contracts())
619            .iter()
620            .filter(|contract| !contract.info.instructions.is_empty())
621            .map(|contract| ResourceInfo {
622                name: contract.name.clone(),
623                address: contract.address.clone(),
624                environment: contract.info.environment.clone(),
625                sources: contract.info.sources.values().cloned().collect(),
626            })
627            .collect();
628        Output::Resources { contracts, json }
629    }
630
631    /// The command list, or one command's details.
632    #[must_use]
633    pub fn help(topic: Option<&str>) -> Output {
634        let lines = match topic.map(str::trim).filter(|topic| !topic.is_empty()) {
635            Some(topic) => match command_spec(topic) {
636                Some(spec) => {
637                    let mut lines = vec![format!("{}  {}", spec.usage, spec.summary)];
638                    if !spec.aliases.is_empty() {
639                        lines.push(format!("aliases: {}", spec.aliases.join(", ")));
640                    }
641                    lines.extend(spec.details.iter().map(|line| (*line).to_owned()));
642                    lines
643                }
644                None => vec![format!("No help for `{topic}`; type `help` for the list")],
645            },
646            None => {
647                let width = COMMANDS
648                    .iter()
649                    .map(|spec| spec.usage.len())
650                    .max()
651                    .unwrap_or(0);
652                let mut lines = Vec::new();
653                for group in CommandGroup::ALL {
654                    lines.push(format!("{}:", group.title()));
655                    for spec in COMMANDS.iter().filter(|spec| spec.group == group) {
656                        lines.push(format!(
657                            "  {:<width$}  {}",
658                            spec.usage,
659                            spec.summary,
660                            width = width
661                        ));
662                    }
663                }
664                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());
665                lines
666            }
667        };
668        Output::Help { lines }
669    }
670}
671
672fn message(level: Level, text: impl Into<String>) -> Output {
673    Output::Message {
674        level,
675        text: text.into(),
676        note: None,
677    }
678}
679
680fn status(status: DebugValueStatus) -> ValueStatus {
681    match status {
682        DebugValueStatus::Decoded => ValueStatus::Decoded,
683        DebugValueStatus::Raw => ValueStatus::Raw,
684        DebugValueStatus::Unavailable => ValueStatus::Unavailable,
685    }
686}
687
688/// A local, parameter, or return value with the stack slot it was read from, or without
689/// one when it could not be read.
690fn local_info(variable: &DebugVariable) -> VariableInfo {
691    VariableInfo {
692        name: variable.name.clone(),
693        ty: variable.ty.clone(),
694        value: variable.value.display.clone(),
695        raw: variable.value.raw.clone(),
696        status: status(variable.value.status),
697        place: (variable.value.status != DebugValueStatus::Unavailable)
698            .then(|| format!("{}+{}", variable.location.kind, variable.location.offset)),
699    }
700}
701
702fn state_info(variable: &StateVariable, chain_label: Option<&str>) -> VariableInfo {
703    let mut place = if variable.offset == 0 {
704        format!("slot {}", variable.slot)
705    } else {
706        format!("slot {} + {}", variable.slot, variable.offset)
707    };
708    if variable.source == StateSource::Chain {
709        place.push_str(&format!(", from {}", chain_label.unwrap_or("the chain")));
710    }
711    VariableInfo {
712        name: variable.name.clone(),
713        ty: variable.ty.clone(),
714        value: variable.value.display.clone(),
715        raw: variable.value.raw.clone(),
716        status: status(variable.value.status),
717        place: Some(place),
718    }
719}
720
721/// A recorded slot the way `storage` prints it: `0x`-prefixed, no leading zeros.
722fn normalize_storage_slot(slot: &str) -> String {
723    let digits = slot.trim_start_matches("0x").trim_start_matches('0');
724    if digits.is_empty() {
725        "0x0".to_owned()
726    } else {
727        format!("0x{}", digits.to_ascii_lowercase())
728    }
729}
730
731/// Whether a breakpoint stops on a source line, for a view that marks lines.
732#[must_use]
733pub fn breakpoint_lines(state: &DebuggerState) -> Vec<(String, u64)> {
734    state
735        .breakpoints()
736        .iter()
737        .flat_map(|breakpoint| match &breakpoint.kind {
738            BreakpointKind::Line(lines) => lines
739                .iter()
740                .map(|line| (line.path.clone(), line.key.line))
741                .collect(),
742            _ => Vec::new(),
743        })
744        .collect()
745}