use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use soldb_core::{SoldbError, SoldbResult};
use crate::metadata::{read_compilation_source, EthdebugInfo};
use crate::source_map::{load_source_map_program_with_sources, SourceMapEnvironment};
use crate::storage_layout::StorageLayout;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum CodeGenerator {
Legacy,
ViaIr,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DebugProgram {
pub info: EthdebugInfo,
pub resources: Value,
pub source_contents: BTreeMap<u64, String>,
pub source_files: BTreeMap<u64, PathBuf>,
pub legacy: bool,
pub storage_layout: Option<StorageLayout>,
pub transient_storage_layout: Option<StorageLayout>,
pub missing_sources: Vec<String>,
}
impl DebugProgram {
#[must_use]
pub const fn code_generator(&self) -> CodeGenerator {
if self.legacy {
CodeGenerator::Legacy
} else {
CodeGenerator::ViaIr
}
}
fn read_sources(&mut self, root: &Path, source_roots: &[PathBuf]) {
for (source_id, source_path) in &self.info.sources {
if self.source_contents.contains_key(source_id) {
continue;
}
if let Some(source) = read_compilation_source(&self.info.compilation, *source_id) {
self.source_contents.insert(*source_id, source);
continue;
}
match read_debug_source_file(root, source_roots, source_path) {
Some((file, source)) => {
self.source_contents.insert(*source_id, source);
self.source_files.insert(*source_id, file);
}
None => self.missing_sources.push(source_path.clone()),
}
}
}
}
#[must_use]
pub fn find_ethdebug_metadata(root: &Path) -> Option<PathBuf> {
[
root.join("ethdebug.json"),
root.join("ethdebug_resources.json"),
]
.into_iter()
.find(|path| path.exists())
}
#[must_use]
pub fn find_program_ethdebug(
root: &Path,
contract_name: &str,
environment: SourceMapEnvironment,
) -> Option<PathBuf> {
let suffix = program_suffix(environment);
let named = root.join(format!("{contract_name}{suffix}"));
if !contract_name.is_empty() && named.exists() {
return Some(named);
}
let mut candidates = fs::read_dir(root)
.ok()?
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.ends_with(suffix))
})
.collect::<Vec<_>>();
candidates.sort();
if !contract_name.is_empty() {
let contract_suffix = format!("_{contract_name}{suffix}");
let mut matching = candidates.iter().filter(|path| {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.ends_with(&contract_suffix))
});
let matched = matching.next().cloned();
if matched.is_some() && matching.next().is_none() {
return matched;
}
}
(candidates.len() == 1).then(|| candidates.remove(0))
}
fn program_suffix(environment: SourceMapEnvironment) -> &'static str {
match environment {
SourceMapEnvironment::Creation => "_ethdebug.json",
SourceMapEnvironment::Runtime => "_ethdebug-runtime.json",
}
}
#[must_use]
pub fn contract_name_from_program_path(
path: &Path,
environment: SourceMapEnvironment,
) -> Option<String> {
path.file_name()
.and_then(|name| name.to_str())
.and_then(|name| name.strip_suffix(program_suffix(environment)))
.filter(|name| !name.is_empty())
.map(str::to_owned)
}
#[must_use]
pub fn read_debug_source(root: &Path, source_path: &str) -> Option<String> {
let (_, contents) = read_debug_source_file(root, &[], source_path)?;
Some(contents)
}
fn read_debug_source_file(
root: &Path,
extra_roots: &[PathBuf],
source_path: &str,
) -> Option<(PathBuf, String)> {
let candidates = extra_roots
.iter()
.flat_map(|extra| source_candidates(extra, source_path))
.chain(source_candidates(root, source_path));
for candidate in candidates {
if let Ok(contents) = fs::read_to_string(&candidate) {
return Some((candidate, contents));
}
}
None
}
pub fn source_candidates(root: &Path, source_path: &str) -> Vec<PathBuf> {
let source = Path::new(source_path);
if source.is_absolute() {
return vec![source.to_path_buf()];
}
let mut candidates = Vec::new();
let mut directory = Some(root);
for _ in 0..4 {
let Some(current) = directory else {
break;
};
candidates.push(current.join(source));
directory = current.parent();
}
candidates.push(source.to_path_buf());
candidates
}
pub fn read_json_file(path: &Path) -> SoldbResult<Value> {
let content = fs::read_to_string(path).map_err(|error| {
SoldbError::Message(format!("failed to read `{}`: {error}", path.display()))
})?;
serde_json::from_str(&content).map_err(|error| {
SoldbError::Message(format!("invalid JSON in `{}`: {error}", path.display()))
})
}
pub fn ethdebug_resources_from_metadata(path: &Path, metadata: &Value) -> SoldbResult<Value> {
if let Some(resources) = metadata.get("resources") {
return Ok(resources.clone());
}
let Some(compilation) = metadata.get("compilation") else {
return Err(SoldbError::Message(format!(
"ETHDebug metadata {} does not contain resources or compilation",
path.display()
)));
};
Ok(json!({
"compilation": compilation,
"types": metadata.get("types").cloned().unwrap_or_else(|| json!({})),
"pointers": metadata.get("pointers").cloned().unwrap_or_else(|| json!({})),
}))
}
pub fn load_debug_program(
root: &Path,
contract_name: &str,
environment: SourceMapEnvironment,
) -> SoldbResult<Option<DebugProgram>> {
load_debug_program_with_sources(root, contract_name, environment, &[])
}
pub fn load_debug_program_with_sources(
root: &Path,
contract_name: &str,
environment: SourceMapEnvironment,
source_roots: &[PathBuf],
) -> SoldbResult<Option<DebugProgram>> {
let environment_name = match environment {
SourceMapEnvironment::Creation => "create",
SourceMapEnvironment::Runtime => "call",
};
if let Some(program_path) = find_program_ethdebug(root, contract_name, environment) {
let metadata_path = find_ethdebug_metadata(root).ok_or_else(|| {
SoldbError::Message(format!(
"No ETHDebug metadata file (ethdebug.json or ethdebug_resources.json) found in {}",
root.display()
))
})?;
let metadata = read_json_file(&metadata_path)?;
let program = read_json_file(&program_path)?;
let resources = ethdebug_resources_from_metadata(&metadata_path, &metadata)?;
let name = if contract_name.is_empty() {
contract_name_from_program_path(&program_path, environment)
.unwrap_or_else(|| "Contract".to_owned())
} else {
contract_name.to_owned()
};
let info = EthdebugInfo::from_artifacts(&name, environment_name, &metadata, &program)
.map_err(|error| SoldbError::Message(format!("{}: {error}", program_path.display())))?;
let storage_layout = load_storage_layout(root, &info.contract_name)?;
let transient_storage_layout = load_transient_storage_layout(root, &info.contract_name)?;
let mut program = DebugProgram {
info,
resources,
source_contents: BTreeMap::new(),
source_files: BTreeMap::new(),
legacy: false,
storage_layout,
transient_storage_layout,
missing_sources: Vec::new(),
};
program.read_sources(root, source_roots);
return Ok(Some(program));
}
let Some(program) =
load_source_map_program_with_sources(root, source_roots, contract_name, environment)?
else {
return Ok(None);
};
let storage_layout = match program.storage_layout {
Some(layout) => Some(layout),
None => load_storage_layout(root, &program.info.contract_name)?,
};
let transient_storage_layout = match program.transient_storage_layout {
Some(layout) => Some(layout),
None => load_transient_storage_layout(root, &program.info.contract_name)?,
};
let mut program = DebugProgram {
info: program.info,
resources: program.resources,
source_contents: program.source_contents,
source_files: program.source_files,
legacy: true,
storage_layout,
transient_storage_layout,
missing_sources: Vec::new(),
};
program.read_sources(root, source_roots);
Ok(Some(program))
}
pub fn load_storage_layout(root: &Path, contract_name: &str) -> SoldbResult<Option<StorageLayout>> {
load_layout_file(&root.join(format!("{contract_name}_storage.json")))
}
pub fn load_transient_storage_layout(
root: &Path,
contract_name: &str,
) -> SoldbResult<Option<StorageLayout>> {
load_layout_file(&root.join(format!("{contract_name}_transient_storage.json")))
}
fn load_layout_file(path: &Path) -> SoldbResult<Option<StorageLayout>> {
if !path.exists() {
return Ok(None);
}
let value = read_json_file(path)?;
StorageLayout::parse(&value)
.map(Some)
.map_err(|error| SoldbError::Message(format!("{}: {error}", path.display())))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ContractMapping {
pub address: String,
pub name: String,
pub debug_dir: PathBuf,
}
pub fn load_contract_mapping(path: &Path) -> SoldbResult<Vec<ContractMapping>> {
let value = read_json_file(path)?;
parse_contract_mapping(path, &value)
}
pub fn parse_contract_mapping(path: &Path, value: &Value) -> SoldbResult<Vec<ContractMapping>> {
let base_dir = path.parent().unwrap_or_else(|| Path::new("."));
let contracts = value
.get("contracts")
.and_then(Value::as_array)
.ok_or_else(|| {
SoldbError::Message(format!(
"contract mapping `{}` must contain a `contracts` array",
path.display()
))
})?;
Ok(contracts
.iter()
.filter_map(|contract| {
let address = contract.get("address")?.as_str()?.to_owned();
let name = contract
.get("name")
.and_then(Value::as_str)
.unwrap_or("Unknown")
.to_owned();
let debug_dir = contract
.get("debug_dir")
.and_then(Value::as_str)
.map(PathBuf::from)?;
let debug_dir = if debug_dir.is_absolute() {
debug_dir
} else {
base_dir.join(debug_dir)
};
Some(ContractMapping {
address,
name,
debug_dir,
})
})
.collect())
}
#[cfg(test)]
mod tests {
use std::fs;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use serde_json::json;
use super::{
contract_name_from_program_path, ethdebug_resources_from_metadata, find_ethdebug_metadata,
find_program_ethdebug, load_debug_program, load_storage_layout,
load_transient_storage_layout, read_debug_source, read_json_file,
};
use crate::source_map::SourceMapEnvironment;
fn temp_dir(label: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("clock")
.as_nanos();
let dir = std::env::temp_dir().join(format!("soldb-artifacts-{label}-{nanos}"));
fs::create_dir_all(&dir).expect("create temp dir");
dir
}
#[test]
fn loads_ethdebug_programs_with_their_sources() {
let dir = temp_dir("ethdebug");
let source =
"contract Counter {\n function set(uint256 x) public {\n value = x;\n }\n}\n";
fs::write(dir.join("Counter.sol"), source).expect("source");
fs::write(
dir.join("ethdebug_resources.json"),
json!({"compilation": {"sources": [{"id": 0, "path": "Counter.sol"}]}}).to_string(),
)
.expect("metadata");
fs::write(
dir.join("Counter_ethdebug-runtime.json"),
json!({"instructions": [{
"offset": 7,
"operation": {"mnemonic": "STOP"},
"context": {"code": {"source": {"id": 0}, "range": {"offset": 59, "length": 9}}}
}]})
.to_string(),
)
.expect("program");
let program = load_debug_program(&dir, "Counter", SourceMapEnvironment::Runtime)
.expect("load")
.expect("present");
assert!(!program.legacy);
assert_eq!(program.info.contract_name, "Counter");
assert_eq!(program.info.environment, "call");
assert!(program.info.instruction_at_pc(7).is_some());
assert_eq!(
program.source_contents.get(&0).map(String::as_str),
Some(source)
);
assert_eq!(program.source_files.get(&0), Some(&dir.join("Counter.sol")));
assert!(program.resources.get("compilation").is_some());
assert!(program.storage_layout.is_none());
let inferred = load_debug_program(&dir, "", SourceMapEnvironment::Runtime)
.expect("load")
.expect("present");
assert_eq!(inferred.info.contract_name, "Counter");
assert!(
load_debug_program(&dir, "Counter", SourceMapEnvironment::Creation)
.expect("load")
.is_none()
);
assert_eq!(
contract_name_from_program_path(
&dir.join("Counter_ethdebug-runtime.json"),
SourceMapEnvironment::Runtime
)
.as_deref(),
Some("Counter")
);
assert_eq!(
find_ethdebug_metadata(&dir),
Some(dir.join("ethdebug_resources.json"))
);
assert_eq!(
find_program_ethdebug(&dir, "Other", SourceMapEnvironment::Runtime),
Some(dir.join("Counter_ethdebug-runtime.json")),
"a single artifact serves any name"
);
assert_eq!(
read_debug_source(&dir, "Counter.sol").as_deref(),
Some(source)
);
assert!(read_debug_source(&dir, "Missing.sol").is_none());
fs::write(
dir.join("Counter_storage.json"),
json!({
"storage": [{
"astId": 1,
"contract": "Counter.sol:Counter",
"label": "value",
"offset": 0,
"slot": "1",
"type": "t_uint256"
}],
"types": {"t_uint256": {
"encoding": "inplace",
"label": "uint256",
"numberOfBytes": "32"
}}
})
.to_string(),
)
.expect("layout");
let program = load_debug_program(&dir, "Counter", SourceMapEnvironment::Runtime)
.expect("load")
.expect("present");
let layout = program.storage_layout.as_ref().expect("storage layout");
assert_eq!(layout.variable("value").expect("value").slot[31], 1);
assert!(load_storage_layout(&dir, "Missing")
.expect("absent")
.is_none());
assert!(program.transient_storage_layout.is_none());
fs::write(
dir.join("Counter_transient_storage.json"),
json!({
"storage": [{
"astId": 2,
"contract": "Counter.sol:Counter",
"label": "locked",
"offset": 0,
"slot": "0",
"type": "t_bool"
}],
"types": {"t_bool": {
"encoding": "inplace",
"label": "bool",
"numberOfBytes": "1"
}}
})
.to_string(),
)
.expect("transient layout");
let program = load_debug_program(&dir, "Counter", SourceMapEnvironment::Runtime)
.expect("load")
.expect("present");
let transient = program
.transient_storage_layout
.as_ref()
.expect("transient layout");
assert_eq!(transient.variable("locked").expect("locked").slot, [0; 32]);
assert!(transient.variable("value").is_none());
assert!(load_transient_storage_layout(&dir, "Missing")
.expect("absent")
.is_none());
fs::write(dir.join("Counter_storage.json"), "{}").expect("layout");
let error = load_debug_program(&dir, "Counter", SourceMapEnvironment::Runtime)
.expect_err("a storage layout that cannot be read is reported");
assert!(
error.to_string().contains("Counter_storage.json"),
"{error}"
);
}
#[test]
fn a_program_without_metadata_is_an_error_not_a_silent_miss() {
let dir = temp_dir("no-metadata");
fs::write(dir.join("Counter_ethdebug-runtime.json"), "{}").expect("program");
let error = load_debug_program(&dir, "Counter", SourceMapEnvironment::Runtime)
.expect_err("metadata is required");
assert!(
error.to_string().contains("No ETHDebug metadata file"),
"{error}"
);
fs::write(dir.join("ethdebug.json"), "{not json").expect("metadata");
let error = load_debug_program(&dir, "Counter", SourceMapEnvironment::Runtime)
.expect_err("malformed metadata");
assert!(error.to_string().contains("invalid JSON"), "{error}");
assert!(read_json_file(&dir.join("absent.json")).is_err());
}
#[test]
fn falls_back_to_legacy_source_maps() {
let dir = temp_dir("legacy");
let source =
"contract Counter {\n function set(uint256 x) public {\n value = x;\n }\n}\n";
fs::write(dir.join("Counter.sol"), source).expect("source");
let offset = source.find("value = x").expect("statement");
fs::write(
dir.join("combined.json"),
json!({
"sourceList": ["Counter.sol"],
"contracts": {"Counter.sol:Counter": {
"bin-runtime": "60010000",
"srcmap-runtime": format!("{offset}:9:0:-:0;{offset}:9:0;{offset}:9:0")
}}
})
.to_string(),
)
.expect("combined");
let program = load_debug_program(&dir, "Counter", SourceMapEnvironment::Runtime)
.expect("load")
.expect("present");
assert!(program.legacy);
assert_eq!(
program.source_contents.get(&0).map(String::as_str),
Some(source)
);
assert_eq!(program.source_files.get(&0), Some(&dir.join("Counter.sol")));
assert!(program.info.instruction_at_pc(2).is_some());
let empty = temp_dir("empty");
assert!(
load_debug_program(&empty, "Counter", SourceMapEnvironment::Runtime)
.expect("load")
.is_none()
);
}
#[test]
fn resources_come_from_the_record_or_are_assembled() {
let path = PathBuf::from("ethdebug.json");
let explicit = json!({"resources": {"compilation": {}}});
assert_eq!(
ethdebug_resources_from_metadata(&path, &explicit).expect("resources"),
json!({"compilation": {}})
);
let assembled = json!({"compilation": {"sources": []}, "types": {"a": 1}});
assert_eq!(
ethdebug_resources_from_metadata(&path, &assembled).expect("resources"),
json!({"compilation": {"sources": []}, "types": {"a": 1}, "pointers": {}})
);
let error = ethdebug_resources_from_metadata(&path, &json!({})).expect_err("neither");
assert!(
error.to_string().contains("does not contain resources"),
"{error}"
);
}
}