Skip to main content

soldb_ethdebug/
metadata.rs

1//! ETHDebug artifact model: instructions, source spans, and variable locations.
2//!
3//! An ETHDebug runtime artifact is a list of instructions, each carrying its program
4//! counter and a `context` describing the source range it was generated from. Mapping a
5//! program counter back to Solidity means finding its instruction and reading that span.
6//!
7//! Spans nest: a compiler attaches a whole-contract range to dispatcher and preamble
8//! instructions, so a span containing a given line is not the same thing as a span
9//! generated for it. Callers that resolve a source line to a program counter must rank
10//! candidates accordingly rather than taking the first intersecting span.
11
12use std::borrow::Cow;
13use std::collections::BTreeMap;
14
15use serde::{Deserialize, Serialize};
16use serde_json::{json, Value};
17use soldb_core::{SoldbError, SoldbResult};
18
19#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
20pub struct SourceLocation {
21    pub source_id: u64,
22    pub offset: u64,
23    pub length: u64,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
27pub struct FunctionIdentity {
28    pub identifier: Option<String>,
29    pub declaration: Option<SourceLocation>,
30    pub activation: Option<String>,
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
34#[serde(rename_all = "lowercase")]
35pub enum FunctionExit {
36    Return,
37    Revert,
38}
39
40/// One instruction of a program and what the compiler says about it.
41///
42/// It reads and writes as an ETHDebug program lists it: its `offset`, an `operation`
43/// naming the mnemonic, and a `context`. A legacy source map says the same few things
44/// about every instruction, so one made from a source map holds them as plain values
45/// (see [`Instruction::from_source_map`]) rather than as the JSON objects they stand
46/// for, which cost a dozen allocations an instruction to build, read, and drop.
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(from = "InstructionJson", into = "InstructionJson")]
49pub struct Instruction {
50    pub offset: u64,
51    operation: Operation,
52    context: Option<Context>,
53}
54
55/// An instruction as an ETHDebug program writes it.
56#[derive(Serialize, Deserialize)]
57struct InstructionJson {
58    offset: u64,
59    operation: Value,
60    #[serde(default)]
61    context: Option<Value>,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
65enum Operation {
66    /// An operation that says nothing but its mnemonic.
67    Mnemonic(Cow<'static, str>),
68    /// Any other operation, as the program wrote it.
69    Json(Value),
70}
71
72#[derive(Debug, Clone, PartialEq, Eq)]
73enum Context {
74    /// A context saying only what a legacy source map does.
75    SourceMap(SourceMapContext),
76    /// Any other context, as the program wrote it.
77    Json(Value),
78}
79
80/// What a legacy source map says about one instruction, as the ETHDebug context
81/// `{"modifierDepth": .., "code": .., "invoke" | "return": {}}` says it.
82#[derive(Debug, Clone, PartialEq, Eq, Hash)]
83pub struct SourceMapContext {
84    /// The source range the instruction was generated from; generated code has none.
85    pub code: Option<SourceLocation>,
86    /// The jump the instruction makes.
87    pub jump: SourceMapJump,
88    /// How many modifiers deep the instruction runs.
89    pub modifier_depth: i64,
90}
91
92/// The jump a legacy source map says an instruction makes.
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
94pub enum SourceMapJump {
95    /// An ordinary jump, or none: `-`.
96    #[default]
97    Regular,
98    /// Into a function: `i`, the ETHDebug `invoke` context.
99    Into,
100    /// Out of a function: `o`, the ETHDebug `return` context.
101    Out,
102}
103
104impl Instruction {
105    /// An instruction at program counter `offset` that runs `mnemonic`, with its ETHDebug
106    /// context, if any.
107    #[must_use]
108    pub fn new(offset: u64, mnemonic: &str, context: Option<Value>) -> Self {
109        Self {
110            offset,
111            operation: Operation::Mnemonic(Cow::Owned(mnemonic.to_owned())),
112            context: context.map(Context::from_json),
113        }
114    }
115
116    /// An instruction at program counter `offset` that runs `mnemonic`, as a legacy
117    /// source map describes it.
118    #[must_use]
119    pub fn from_source_map(
120        offset: u64,
121        mnemonic: impl Into<Cow<'static, str>>,
122        context: SourceMapContext,
123    ) -> Self {
124        Self {
125            offset,
126            operation: Operation::Mnemonic(mnemonic.into()),
127            context: Some(Context::SourceMap(context)),
128        }
129    }
130
131    #[must_use]
132    pub fn mnemonic(&self) -> Option<&str> {
133        match &self.operation {
134            Operation::Mnemonic(mnemonic) => Some(mnemonic),
135            Operation::Json(operation) => operation.get("mnemonic").and_then(Value::as_str),
136        }
137    }
138
139    #[must_use]
140    pub fn arguments(&self) -> Vec<&str> {
141        let Operation::Json(operation) = &self.operation else {
142            return Vec::new();
143        };
144        operation
145            .get("arguments")
146            .and_then(Value::as_array)
147            .map(|arguments| arguments.iter().filter_map(Value::as_str).collect())
148            .unwrap_or_default()
149    }
150
151    #[must_use]
152    pub fn source_location(&self) -> Option<SourceLocation> {
153        match &self.context {
154            Some(Context::SourceMap(context)) => context.code.clone(),
155            _ => {
156                let mut locations = self.source_locations();
157                (locations.len() == 1).then(|| locations.remove(0))
158            }
159        }
160    }
161
162    /// Returns every source alternative attached to this instruction.
163    #[must_use]
164    pub fn source_locations(&self) -> Vec<SourceLocation> {
165        let mut locations = Vec::new();
166        match &self.context {
167            Some(Context::SourceMap(context)) => locations.extend(context.code.clone()),
168            Some(Context::Json(context)) => collect_source_locations(context, &mut locations),
169            None => {}
170        }
171        locations
172    }
173
174    /// Returns every possible function invocation attached to this instruction.
175    #[must_use]
176    pub fn function_invocations(&self) -> Vec<FunctionIdentity> {
177        let mut functions = Vec::new();
178        match &self.context {
179            Some(Context::SourceMap(context)) if context.jump == SourceMapJump::Into => {
180                functions.push(FunctionIdentity {
181                    identifier: None,
182                    declaration: None,
183                    activation: None,
184                });
185            }
186            Some(Context::Json(context)) => collect_function_invocations(context, &mut functions),
187            _ => {}
188        }
189        functions
190    }
191
192    /// Returns the function exit attached to this instruction.
193    #[must_use]
194    pub fn function_exit(&self) -> Option<FunctionExit> {
195        match self.context.as_ref()? {
196            Context::SourceMap(context) => {
197                (context.jump == SourceMapJump::Out).then_some(FunctionExit::Return)
198            }
199            Context::Json(context) => {
200                if context.get("return").is_some() {
201                    Some(FunctionExit::Return)
202                } else if context.get("revert").is_some() {
203                    Some(FunctionExit::Revert)
204                } else {
205                    None
206                }
207            }
208        }
209    }
210
211    /// Legacy source-map modifier depth, when this instruction was adapted from one.
212    #[must_use]
213    pub fn modifier_depth(&self) -> Option<i64> {
214        match self.context.as_ref()? {
215            Context::SourceMap(context) => Some(context.modifier_depth),
216            Context::Json(context) => context.get("modifierDepth")?.as_i64(),
217        }
218    }
219}
220
221impl From<InstructionJson> for Instruction {
222    fn from(instruction: InstructionJson) -> Self {
223        Self {
224            offset: instruction.offset,
225            operation: Operation::from_json(instruction.operation),
226            context: instruction.context.map(Context::from_json),
227        }
228    }
229}
230
231impl From<Instruction> for InstructionJson {
232    fn from(instruction: Instruction) -> Self {
233        Self {
234            offset: instruction.offset,
235            operation: match instruction.operation {
236                Operation::Mnemonic(mnemonic) => json!({"mnemonic": mnemonic}),
237                Operation::Json(operation) => operation,
238            },
239            context: instruction.context.map(|context| match context {
240                Context::SourceMap(context) => context.to_json(),
241                Context::Json(context) => context,
242            }),
243        }
244    }
245}
246
247impl Operation {
248    /// Keeps an operation that names only its mnemonic as the mnemonic, so an
249    /// instruction reads back equal to the one written.
250    fn from_json(operation: Value) -> Self {
251        if let Value::Object(fields) = &operation {
252            if let (1, Some(Value::String(mnemonic))) = (fields.len(), fields.get("mnemonic")) {
253                return Self::Mnemonic(Cow::Owned(mnemonic.clone()));
254            }
255        }
256        Self::Json(operation)
257    }
258}
259
260impl Context {
261    /// Keeps a context that says only what a legacy source map does as values, so an
262    /// instruction reads back equal to the one written.
263    fn from_json(context: Value) -> Self {
264        SourceMapContext::from_json(&context).map_or(Self::Json(context), Self::SourceMap)
265    }
266}
267
268impl SourceMapContext {
269    fn from_json(context: &Value) -> Option<Self> {
270        let fields = context.as_object()?;
271        let modifier_depth = fields.get("modifierDepth")?.as_i64()?;
272        let mut code = None;
273        let mut jump = SourceMapJump::Regular;
274        for (key, value) in fields {
275            match key.as_str() {
276                "modifierDepth" => {}
277                "code" => code = Some(source_range_of(value)?),
278                "invoke" | "return" if empty_object(value) && jump == SourceMapJump::Regular => {
279                    jump = if key == "invoke" {
280                        SourceMapJump::Into
281                    } else {
282                        SourceMapJump::Out
283                    };
284                }
285                _ => return None,
286            }
287        }
288        Some(Self {
289            code,
290            jump,
291            modifier_depth,
292        })
293    }
294
295    fn to_json(&self) -> Value {
296        let mut context = serde_json::Map::new();
297        context.insert("modifierDepth".to_owned(), json!(self.modifier_depth));
298        if let Some(code) = &self.code {
299            context.insert(
300                "code".to_owned(),
301                source_range(code.source_id, code.offset, code.length),
302            );
303        }
304        match self.jump {
305            SourceMapJump::Regular => {}
306            SourceMapJump::Into => {
307                context.insert("invoke".to_owned(), json!({}));
308            }
309            SourceMapJump::Out => {
310                context.insert("return".to_owned(), json!({}));
311            }
312        }
313        Value::Object(context)
314    }
315}
316
317/// The source location `value` holds, when it is exactly what [`source_range`] writes.
318fn source_range_of(value: &Value) -> Option<SourceLocation> {
319    let fields = value.as_object()?;
320    let source = fields.get("source")?.as_object()?;
321    let range = fields.get("range")?.as_object()?;
322    if fields.len() != 2 || source.len() != 1 || range.len() != 2 {
323        return None;
324    }
325    Some(SourceLocation {
326        source_id: source.get("id")?.as_u64()?,
327        offset: range.get("offset")?.as_u64()?,
328        length: range.get("length")?.as_u64()?,
329    })
330}
331
332fn empty_object(value: &Value) -> bool {
333    value.as_object().is_some_and(serde_json::Map::is_empty)
334}
335
336fn collect_source_locations(context: &Value, locations: &mut Vec<SourceLocation>) {
337    if let Some(location) = context.get("code").and_then(parse_source_location) {
338        if !locations.contains(&location) {
339            locations.push(location);
340        }
341    }
342    if let Some(pick) = context.get("pick").and_then(Value::as_array) {
343        for alternative in pick {
344            collect_source_locations(alternative, locations);
345        }
346    }
347}
348
349fn collect_function_invocations(context: &Value, functions: &mut Vec<FunctionIdentity>) {
350    if let Some(invoke) = context.get("invoke") {
351        let function = FunctionIdentity {
352            identifier: invoke
353                .get("identifier")
354                .and_then(Value::as_str)
355                .map(str::to_owned),
356            declaration: invoke.get("declaration").and_then(parse_source_location),
357            activation: invoke
358                .get("activation")
359                .and_then(Value::as_str)
360                .map(str::to_owned),
361        };
362        if !functions.contains(&function) {
363            functions.push(function);
364        }
365    }
366    if let Some(pick) = context.get("pick").and_then(Value::as_array) {
367        for alternative in pick {
368            collect_function_invocations(alternative, functions);
369        }
370    }
371}
372
373/// The ETHDebug source range of `length` bytes at `offset` in source `source_id`, as an
374/// instruction context carries it under `code`.
375#[must_use]
376pub fn source_range(source_id: u64, offset: u64, length: u64) -> Value {
377    json!({"source": {"id": source_id}, "range": {"offset": offset, "length": length}})
378}
379
380fn parse_source_location(value: &Value) -> Option<SourceLocation> {
381    let source_id = value.get("source")?.get("id")?.as_u64()?;
382    let range = value.get("range")?;
383    Some(SourceLocation {
384        source_id,
385        offset: range.get("offset")?.as_u64()?,
386        length: range.get("length")?.as_u64()?,
387    })
388}
389
390#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
391pub struct VariableLocation {
392    pub name: String,
393    pub ty: String,
394    pub location_type: String,
395    pub offset: u64,
396    pub pc_range: (u64, u64),
397}
398
399impl VariableLocation {
400    #[must_use]
401    pub fn is_active_at_pc(&self, pc: u64) -> bool {
402        self.pc_range.0 <= pc && pc <= self.pc_range.1
403    }
404}
405
406#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
407pub struct EthdebugInfo {
408    pub compilation: Value,
409    pub contract_name: String,
410    pub environment: String,
411    pub instructions: Vec<Instruction>,
412    pub sources: BTreeMap<u64, String>,
413    #[serde(default)]
414    pub variable_locations: BTreeMap<u64, Vec<VariableLocation>>,
415}
416
417impl EthdebugInfo {
418    /// Builds the debug info for one contract from already-parsed ETHDebug artifacts.
419    ///
420    /// `metadata` is the global resource file (`ethdebug.json` from older compilers,
421    /// `ethdebug_resources.json` from modern ones) and `program` is the contract's
422    /// program artifact (`<Contract>_ethdebug.json` for creation code,
423    /// `<Contract>_ethdebug-runtime.json` for runtime code). Nothing is read from disk
424    /// here, so the CLI, the DAP server, and any host that receives the artifacts as
425    /// strings all build the same value through this one path.
426    ///
427    /// A program without an `instructions` array yields no instructions rather than an
428    /// error; callers that need to know report it as a debug-info gap.
429    pub fn from_artifacts(
430        contract_name: &str,
431        environment: &str,
432        metadata: &Value,
433        program: &Value,
434    ) -> SoldbResult<Self> {
435        let instructions = program
436            .get("instructions")
437            .cloned()
438            .map(serde_json::from_value::<Vec<Instruction>>)
439            .transpose()
440            .map_err(|error| {
441                SoldbError::Message(format!(
442                    "invalid `instructions` in ETHDebug program artifact: {error}"
443                ))
444            })?
445            .unwrap_or_default();
446        let compilation = metadata
447            .get("compilation")
448            .cloned()
449            .unwrap_or_else(|| metadata.clone());
450        let sources = parse_compilation_sources(&compilation);
451        let variable_locations = parse_variable_locations(program)?;
452        Ok(Self {
453            compilation,
454            contract_name: contract_name.to_owned(),
455            environment: environment.to_owned(),
456            instructions,
457            sources,
458            variable_locations,
459        })
460    }
461
462    #[must_use]
463    pub fn instruction_at_pc(&self, pc: u64) -> Option<&Instruction> {
464        self.instructions
465            .iter()
466            .find(|instruction| instruction.offset == pc)
467    }
468
469    #[must_use]
470    pub fn source_info(&self, pc: u64) -> Option<(&str, u64, u64)> {
471        let instruction = self.instruction_at_pc(pc)?;
472        let source_location = instruction.source_location()?;
473        let source_path = self.sources.get(&source_location.source_id)?;
474        Some((source_path, source_location.offset, source_location.length))
475    }
476
477    /// Whether the artifact carries variable locations at all.
478    ///
479    /// No compiler release emits them yet, so this is usually false, and the difference
480    /// matters to a user: nothing in scope at one program counter is not the same as a
481    /// compiler that describes no variables anywhere.
482    #[must_use]
483    pub fn has_variable_locations(&self) -> bool {
484        !self.variable_locations.is_empty()
485    }
486
487    pub fn variables_at_pc(&self, pc: u64) -> Vec<&VariableLocation> {
488        if let Some(exact) = self.variable_locations.get(&pc) {
489            return exact.iter().collect();
490        }
491
492        self.variable_locations
493            .values()
494            .flat_map(|variables| variables.iter())
495            .filter(|variable| variable.is_active_at_pc(pc))
496            .collect()
497    }
498}
499
500#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
501pub struct EthdebugSpec {
502    pub address: Option<String>,
503    pub name: Option<String>,
504    pub path: String,
505}
506
507pub fn parse_ethdebug_spec(input: &str) -> EthdebugSpec {
508    if let Ok(spec) = parse_single_contract_spec(input) {
509        return spec;
510    }
511
512    parse_multi_contract_spec(input).unwrap_or_else(|_| EthdebugSpec {
513        address: None,
514        name: None,
515        path: input.to_owned(),
516    })
517}
518
519pub fn parse_single_contract_spec(input: &str) -> SoldbResult<EthdebugSpec> {
520    if !input.starts_with("0x") {
521        return Err(SoldbError::Message(format!(
522            "Must use format 'address:name:path' (got: {input})"
523        )));
524    }
525
526    let parts = input.splitn(3, ':').collect::<Vec<_>>();
527    if parts.len() != 3 || parts[1].is_empty() || parts[2].is_empty() {
528        return Err(SoldbError::Message(format!(
529            "Must use format 'address:name:path' (got: {input})"
530        )));
531    }
532
533    Ok(EthdebugSpec {
534        address: Some(parts[0].to_owned()),
535        name: Some(parts[1].to_owned()),
536        path: parts[2].to_owned(),
537    })
538}
539
540pub fn parse_multi_contract_spec(input: &str) -> SoldbResult<EthdebugSpec> {
541    if input.is_empty() {
542        return Err(SoldbError::Message("Path cannot be empty".to_owned()));
543    }
544
545    if input.starts_with("0x") && input.contains(':') {
546        let parts = input.splitn(2, ':').collect::<Vec<_>>();
547        if parts.len() != 2 || parts[1].is_empty() {
548            return Err(SoldbError::Message(format!(
549                "Must use format 'address:path' (got: {input})"
550            )));
551        }
552
553        return Ok(EthdebugSpec {
554            address: Some(parts[0].to_owned()),
555            name: None,
556            path: parts[1].to_owned(),
557        });
558    }
559
560    Ok(EthdebugSpec {
561        address: None,
562        name: None,
563        path: input.to_owned(),
564    })
565}
566
567/// Maps every source id listed in an ETHDebug `compilation` object to its path.
568///
569/// Entries without a numeric `id` or a string `path` are skipped rather than failing the
570/// whole load, since one malformed source entry should not hide the mapping for the rest.
571#[must_use]
572pub fn parse_compilation_sources(compilation: &Value) -> BTreeMap<u64, String> {
573    compilation
574        .get("sources")
575        .and_then(Value::as_array)
576        .into_iter()
577        .flatten()
578        .filter_map(|source| {
579            Some((
580                source.get("id")?.as_u64()?,
581                source.get("path")?.as_str()?.to_owned(),
582            ))
583        })
584        .collect()
585}
586
587/// Returns the contents of a source embedded in an ETHDebug `compilation` object.
588///
589/// Some artifacts inline each source under `contents` (older builds used `content`);
590/// when they do, a frontend can show source without reading the file, which is the only
591/// option for a host without a filesystem.
592#[must_use]
593pub fn read_compilation_source(compilation: &Value, source_id: u64) -> Option<String> {
594    compilation
595        .get("sources")
596        .and_then(Value::as_array)?
597        .iter()
598        .find(|source| source.get("id").and_then(Value::as_u64) == Some(source_id))
599        .and_then(|source| {
600            source
601                .get("contents")
602                .or_else(|| source.get("content"))
603                .and_then(Value::as_str)
604        })
605        .map(str::to_owned)
606}
607
608pub fn parse_variable_locations(
609    contract_data: &Value,
610) -> SoldbResult<BTreeMap<u64, Vec<VariableLocation>>> {
611    let mut variable_locations = BTreeMap::<u64, Vec<VariableLocation>>::new();
612
613    if let Some(instructions) = contract_data
614        .get("instructions")
615        .and_then(serde_json::Value::as_array)
616    {
617        for instruction in instructions {
618            let pc = instruction
619                .get("offset")
620                .and_then(serde_json::Value::as_u64)
621                .ok_or_else(|| SoldbError::Message("Instruction missing offset".to_owned()))?;
622
623            let Some(variables) = instruction
624                .get("context")
625                .and_then(|context| context.get("variables"))
626                .and_then(serde_json::Value::as_array)
627            else {
628                continue;
629            };
630
631            for variable in variables {
632                variable_locations
633                    .entry(pc)
634                    .or_default()
635                    .push(parse_context_variable(variable, pc));
636            }
637        }
638    }
639
640    if let Some(variables) = contract_data
641        .get("variables")
642        .and_then(serde_json::Value::as_array)
643    {
644        for variable in variables {
645            let variable_location = parse_top_level_variable(variable);
646            for pc in variable_location.pc_range.0..=variable_location.pc_range.1 {
647                variable_locations
648                    .entry(pc)
649                    .or_default()
650                    .push(variable_location.clone());
651            }
652        }
653    }
654
655    Ok(variable_locations)
656}
657
658fn parse_context_variable(variable: &Value, pc: u64) -> VariableLocation {
659    let location = variable.get("location");
660    let scope = variable.get("scope");
661
662    VariableLocation {
663        name: get_string(variable, "name", "unknown"),
664        ty: get_string(variable, "type", "unknown"),
665        location_type: location
666            .and_then(|value| value.get("type"))
667            .and_then(serde_json::Value::as_str)
668            .unwrap_or("stack")
669            .to_owned(),
670        offset: location
671            .and_then(|value| value.get("offset"))
672            .and_then(serde_json::Value::as_u64)
673            .unwrap_or(0),
674        pc_range: (
675            scope
676                .and_then(|value| value.get("start"))
677                .and_then(serde_json::Value::as_u64)
678                .unwrap_or(pc),
679            scope
680                .and_then(|value| value.get("end"))
681                .and_then(serde_json::Value::as_u64)
682                .unwrap_or(pc),
683        ),
684    }
685}
686
687fn parse_top_level_variable(variable: &Value) -> VariableLocation {
688    VariableLocation {
689        name: get_string(variable, "name", "unknown"),
690        ty: get_string(variable, "type", "unknown"),
691        location_type: get_string(variable, "location_type", "stack"),
692        offset: variable
693            .get("offset")
694            .and_then(serde_json::Value::as_u64)
695            .unwrap_or(0),
696        pc_range: (
697            variable
698                .get("pc_start")
699                .and_then(serde_json::Value::as_u64)
700                .unwrap_or(0),
701            variable
702                .get("pc_end")
703                .and_then(serde_json::Value::as_u64)
704                .unwrap_or(0),
705        ),
706    }
707}
708
709fn get_string(value: &Value, key: &str, default: &str) -> String {
710    value
711        .get(key)
712        .and_then(serde_json::Value::as_str)
713        .unwrap_or(default)
714        .to_owned()
715}
716
717#[cfg(test)]
718mod tests {
719    use std::collections::BTreeMap;
720
721    use super::{
722        parse_compilation_sources, parse_ethdebug_spec, parse_multi_contract_spec,
723        parse_single_contract_spec, parse_variable_locations, read_compilation_source,
724        EthdebugInfo, FunctionExit, Instruction, SourceLocation, SourceMapContext, SourceMapJump,
725    };
726    use serde_json::json;
727
728    #[test]
729    fn reads_sources_embedded_in_the_compilation() {
730        let compilation = json!({
731            "sources": [
732                {"id": 0, "path": "A.sol", "contents": "contract A {}"},
733                {"id": 1, "path": "B.sol", "content": "contract B {}"},
734                {"id": 2, "path": "C.sol"}
735            ]
736        });
737
738        assert_eq!(
739            read_compilation_source(&compilation, 0).as_deref(),
740            Some("contract A {}")
741        );
742        assert_eq!(
743            read_compilation_source(&compilation, 1).as_deref(),
744            Some("contract B {}")
745        );
746        assert_eq!(read_compilation_source(&compilation, 2), None);
747        assert_eq!(read_compilation_source(&compilation, 9), None);
748        assert_eq!(read_compilation_source(&json!({}), 0), None);
749    }
750
751    #[test]
752    fn builds_info_from_parsed_artifacts() {
753        let metadata = json!({
754            "compilation": {
755                "sources": [
756                    {"id": 0, "path": "Counter.sol"},
757                    {"id": 1, "path": "lib/Math.sol"},
758                    {"path": "missing-id.sol"},
759                    {"id": 2}
760                ]
761            },
762            "types": {},
763            "pointers": {}
764        });
765        let program = json!({
766            "instructions": [
767                {
768                    "offset": 0,
769                    "operation": {"mnemonic": "PUSH1", "arguments": ["0x80"]},
770                    "context": {
771                        "code": {"source": {"id": 0}, "range": {"offset": 10, "length": 4}},
772                        "variables": [
773                            {"identifier": "x", "pointer": {"location": "stack", "slot": 0}}
774                        ]
775                    }
776                },
777                {"offset": 2, "operation": {"mnemonic": "STOP"}}
778            ]
779        });
780
781        let info = EthdebugInfo::from_artifacts("Counter", "runtime", &metadata, &program)
782            .expect("artifacts");
783
784        assert_eq!(info.contract_name, "Counter");
785        assert_eq!(info.environment, "runtime");
786        assert_eq!(info.compilation, metadata["compilation"]);
787        assert_eq!(
788            info.sources,
789            BTreeMap::from([
790                (0, "Counter.sol".to_owned()),
791                (1, "lib/Math.sol".to_owned())
792            ])
793        );
794        assert_eq!(info.instructions.len(), 2);
795        assert_eq!(info.source_info(0), Some(("Counter.sol", 10, 4)));
796        assert_eq!(info.variables_at_pc(0).len(), 1);
797        assert!(info.variables_at_pc(2).is_empty());
798    }
799
800    #[test]
801    fn treats_legacy_metadata_without_compilation_wrapper_as_the_compilation() {
802        let metadata = json!({"sources": [{"id": 3, "path": "Legacy.sol"}]});
803        let program = json!({});
804
805        let info = EthdebugInfo::from_artifacts("Legacy", "create", &metadata, &program)
806            .expect("artifacts");
807
808        assert_eq!(info.compilation, metadata);
809        assert_eq!(info.sources, BTreeMap::from([(3, "Legacy.sol".to_owned())]));
810        assert!(info.instructions.is_empty());
811        assert!(info.variable_locations.is_empty());
812    }
813
814    #[test]
815    fn rejects_malformed_instructions() {
816        let metadata = json!({"compilation": {"sources": []}});
817        let program = json!({"instructions": [{"operation": {"mnemonic": "STOP"}}]});
818
819        let error = EthdebugInfo::from_artifacts("Broken", "runtime", &metadata, &program)
820            .expect_err("missing offset must fail");
821
822        assert!(
823            error.to_string().contains("`instructions`"),
824            "unexpected error: {error}"
825        );
826    }
827
828    #[test]
829    fn parses_compilation_sources_skipping_incomplete_entries() {
830        let compilation = json!({
831            "sources": [
832                {"id": 0, "path": "A.sol"},
833                {"id": "1", "path": "B.sol"},
834                {"id": 2, "path": 7},
835                {"id": 3, "path": "D.sol"}
836            ]
837        });
838
839        assert_eq!(
840            parse_compilation_sources(&compilation),
841            BTreeMap::from([(0, "A.sol".to_owned()), (3, "D.sol".to_owned())])
842        );
843        assert!(parse_compilation_sources(&json!({})).is_empty());
844    }
845
846    #[test]
847    fn parses_single_and_multi_contract_specs() {
848        let spec = parse_single_contract_spec("0xabc:Token:out").expect("single spec");
849        assert_eq!(spec.address.as_deref(), Some("0xabc"));
850        assert_eq!(spec.name.as_deref(), Some("Token"));
851        assert_eq!(spec.path, "out");
852
853        let multi = parse_multi_contract_spec("0xabc:out").expect("multi spec");
854        assert_eq!(multi.address.as_deref(), Some("0xabc"));
855        assert_eq!(multi.name, None);
856        assert_eq!(multi.path, "out");
857
858        let plain = parse_ethdebug_spec("out");
859        assert_eq!(plain.address, None);
860        assert_eq!(plain.path, "out");
861
862        assert!(parse_single_contract_spec("out").is_err());
863        assert!(parse_multi_contract_spec("").is_err());
864    }
865
866    #[test]
867    fn extracts_instruction_source_locations() {
868        let instruction: Instruction = serde_json::from_value(json!({
869            "offset": 12,
870            "operation": {"mnemonic": "PUSH1", "arguments": ["0x2a"]},
871            "context": {
872                "code": {
873                    "source": {"id": 7},
874                    "range": {"offset": 20, "length": 4}
875                }
876            }
877        }))
878        .expect("instruction");
879
880        let source_location = instruction.source_location().expect("source location");
881        assert_eq!(instruction.mnemonic(), Some("PUSH1"));
882        assert_eq!(instruction.arguments(), ["0x2a"]);
883        assert_eq!(source_location.source_id, 7);
884        assert_eq!(source_location.offset, 20);
885        assert_eq!(source_location.length, 4);
886    }
887
888    #[test]
889    fn source_map_instructions_read_and_write_as_their_json() {
890        let code = SourceLocation {
891            source_id: 3,
892            offset: 40,
893            length: 9,
894        };
895        let typed = Instruction::from_source_map(
896            7,
897            "JUMP",
898            SourceMapContext {
899                code: Some(code.clone()),
900                jump: SourceMapJump::Into,
901                modifier_depth: 1,
902            },
903        );
904        let json = json!({
905            "offset": 7,
906            "operation": {"mnemonic": "JUMP"},
907            "context": {
908                "modifierDepth": 1,
909                "code": {"source": {"id": 3}, "range": {"offset": 40, "length": 9}},
910                "invoke": {}
911            }
912        });
913        assert_eq!(serde_json::to_value(&typed).expect("json"), json);
914        let read: Instruction = serde_json::from_value(json.clone()).expect("instruction");
915        assert_eq!(read, typed);
916        let context = json.get("context").cloned();
917        assert_eq!(Instruction::new(7, "JUMP", context), typed);
918
919        assert_eq!(typed.mnemonic(), Some("JUMP"));
920        assert!(typed.arguments().is_empty());
921        assert_eq!(typed.source_location(), Some(code.clone()));
922        assert_eq!(typed.source_locations(), [code]);
923        assert_eq!(typed.function_invocations().len(), 1);
924        assert_eq!(typed.function_exit(), None);
925        assert_eq!(typed.modifier_depth(), Some(1));
926
927        let generated = Instruction::from_source_map(
928            8,
929            "JUMP",
930            SourceMapContext {
931                code: None,
932                jump: SourceMapJump::Out,
933                modifier_depth: 0,
934            },
935        );
936        assert_eq!(generated.source_location(), None);
937        assert!(generated.function_invocations().is_empty());
938        assert_eq!(generated.function_exit(), Some(FunctionExit::Return));
939        assert_eq!(
940            serde_json::to_value(&generated).expect("json")["context"],
941            json!({"modifierDepth": 0, "return": {}})
942        );
943    }
944
945    #[test]
946    fn keeps_contexts_a_source_map_cannot_say_as_written() {
947        for context in [
948            // No modifier depth, or anything a source map does not say.
949            json!({"code": {"source": {"id": 1}, "range": {"offset": 0, "length": 1}}}),
950            json!({"modifierDepth": 0, "remark": "x"}),
951            json!({"modifierDepth": 0, "invoke": {"identifier": "f"}}),
952            json!({"modifierDepth": 0, "invoke": {}, "return": {}}),
953            json!({"modifierDepth": 0, "code": {"source": {"id": 1, "path": "a.sol"},
954                "range": {"offset": 0, "length": 1}}}),
955        ] {
956            let instruction = Instruction::new(0, "STOP", Some(context.clone()));
957            let written = serde_json::to_value(&instruction).expect("json");
958            assert_eq!(written["context"], context);
959        }
960        let push: Instruction = serde_json::from_value(json!({
961            "offset": 0,
962            "operation": {"mnemonic": "PUSH1", "arguments": ["0x01"]}
963        }))
964        .expect("instruction");
965        assert_eq!(
966            serde_json::to_value(&push).expect("json")["operation"],
967            json!({"mnemonic": "PUSH1", "arguments": ["0x01"]})
968        );
969    }
970
971    #[test]
972    fn extracts_source_picks_and_function_events() {
973        let invoke: Instruction = serde_json::from_value(json!({
974            "offset": 12,
975            "operation": {"mnemonic": "JUMPDEST"},
976            "context": {
977                "invoke": {
978                    "identifier": "calculate",
979                    "declaration": {
980                        "source": {"id": 7},
981                        "range": {"offset": 100, "length": 80}
982                    },
983                    "jump": true
984                }
985            }
986        }))
987        .expect("invoke instruction");
988        let shared: Instruction = serde_json::from_value(json!({
989            "offset": 13,
990            "operation": {"mnemonic": "SSTORE"},
991            "context": {
992                "pick": [
993                    {"code": {
994                        "source": {"id": 7},
995                        "range": {"offset": 20, "length": 4}
996                    }},
997                    {"code": {
998                        "source": {"id": 7},
999                        "range": {"offset": 140, "length": 4}
1000                    }}
1001                ]
1002            }
1003        }))
1004        .expect("shared instruction");
1005        let returned: Instruction = serde_json::from_value(json!({
1006            "offset": 14,
1007            "operation": {"mnemonic": "STOP"},
1008            "context": {"return": {}}
1009        }))
1010        .expect("return instruction");
1011
1012        let function = invoke.function_invocations().remove(0);
1013        assert_eq!(function.identifier.as_deref(), Some("calculate"));
1014        assert_eq!(function.declaration.map(|range| range.offset), Some(100));
1015        assert_eq!(shared.source_location(), None);
1016        assert_eq!(
1017            shared
1018                .source_locations()
1019                .into_iter()
1020                .map(|location| location.offset)
1021                .collect::<Vec<_>>(),
1022            [20, 140]
1023        );
1024        assert_eq!(returned.function_exit(), Some(super::FunctionExit::Return));
1025    }
1026
1027    #[test]
1028    fn reports_source_and_variable_info_at_pc() {
1029        let instruction: Instruction = serde_json::from_value(json!({
1030            "offset": 3,
1031            "operation": {"mnemonic": "SLOAD"},
1032            "context": {
1033                "code": {
1034                    "source": {"id": 0},
1035                    "range": {"offset": 9, "length": 5}
1036                }
1037            }
1038        }))
1039        .expect("instruction");
1040
1041        let variable = super::VariableLocation {
1042            name: "stored".to_owned(),
1043            ty: "uint256".to_owned(),
1044            location_type: "storage".to_owned(),
1045            offset: 0,
1046            pc_range: (2, 5),
1047        };
1048        let mut sources = BTreeMap::new();
1049        sources.insert(0, "Counter.sol".to_owned());
1050        let mut variable_locations = BTreeMap::new();
1051        variable_locations.insert(2, vec![variable]);
1052        let info = EthdebugInfo {
1053            compilation: json!({}),
1054            contract_name: "Counter".to_owned(),
1055            environment: "runtime".to_owned(),
1056            instructions: vec![instruction],
1057            sources,
1058            variable_locations,
1059        };
1060
1061        assert_eq!(info.source_info(3), Some(("Counter.sol", 9, 5)));
1062        assert_eq!(info.variables_at_pc(3)[0].name, "stored");
1063        assert!(info.variables_at_pc(9).is_empty());
1064    }
1065
1066    #[test]
1067    fn parses_variable_locations_from_contract_data() {
1068        let variables = parse_variable_locations(&json!({
1069            "instructions": [
1070                {
1071                    "offset": 10,
1072                    "context": {
1073                        "variables": [
1074                            {
1075                                "name": "amount",
1076                                "type": "uint256",
1077                                "location": {"type": "stack", "offset": 1},
1078                                "scope": {"start": 10, "end": 12}
1079                            }
1080                        ]
1081                    }
1082                }
1083            ],
1084            "variables": [
1085                {
1086                    "name": "stored",
1087                    "type": "uint256",
1088                    "location_type": "storage",
1089                    "offset": 0,
1090                    "pc_start": 20,
1091                    "pc_end": 21
1092                }
1093            ]
1094        }))
1095        .expect("variables");
1096
1097        assert_eq!(variables.get(&10).expect("pc 10")[0].name, "amount");
1098        assert_eq!(
1099            variables.get(&20).expect("pc 20")[0].location_type,
1100            "storage"
1101        );
1102        assert_eq!(variables.get(&21).expect("pc 21")[0].name, "stored");
1103    }
1104}