1use std::collections::BTreeMap;
13use std::fs;
14use std::path::{Path, PathBuf};
15
16use serde::{Deserialize, Serialize};
17use serde_json::{json, Value};
18
19use soldb_core::{SoldbError, SoldbResult};
20
21use crate::metadata::{read_compilation_source, EthdebugInfo};
22use crate::source_map::{load_source_map_program_with_sources, SourceMapEnvironment};
23use crate::storage_layout::StorageLayout;
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "kebab-case")]
33pub enum CodeGenerator {
34 Legacy,
35 ViaIr,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct DebugProgram {
41 pub info: EthdebugInfo,
42 pub resources: Value,
44 pub source_contents: BTreeMap<u64, String>,
46 pub legacy: bool,
48 pub storage_layout: Option<StorageLayout>,
50 pub missing_sources: Vec<String>,
53}
54
55impl DebugProgram {
56 #[must_use]
60 pub const fn code_generator(&self) -> CodeGenerator {
61 if self.legacy {
62 CodeGenerator::Legacy
63 } else {
64 CodeGenerator::ViaIr
65 }
66 }
67}
68
69#[must_use]
71pub fn find_ethdebug_metadata(root: &Path) -> Option<PathBuf> {
72 [
75 root.join("ethdebug.json"),
76 root.join("ethdebug_resources.json"),
77 ]
78 .into_iter()
79 .find(|path| path.exists())
80}
81
82#[must_use]
88pub fn find_program_ethdebug(
89 root: &Path,
90 contract_name: &str,
91 environment: SourceMapEnvironment,
92) -> Option<PathBuf> {
93 let suffix = program_suffix(environment);
94 let named = root.join(format!("{contract_name}{suffix}"));
95 if !contract_name.is_empty() && named.exists() {
96 return Some(named);
97 }
98
99 let mut candidates = fs::read_dir(root)
100 .ok()?
101 .filter_map(Result::ok)
102 .map(|entry| entry.path())
103 .filter(|path| {
104 path.file_name()
105 .and_then(|name| name.to_str())
106 .is_some_and(|name| name.ends_with(suffix))
107 })
108 .collect::<Vec<_>>();
109 candidates.sort();
110
111 if !contract_name.is_empty() {
112 let contract_suffix = format!("_{contract_name}{suffix}");
113 let mut matching = candidates.iter().filter(|path| {
114 path.file_name()
115 .and_then(|name| name.to_str())
116 .is_some_and(|name| name.ends_with(&contract_suffix))
117 });
118 let matched = matching.next().cloned();
119 if matched.is_some() && matching.next().is_none() {
120 return matched;
121 }
122 }
123
124 (candidates.len() == 1).then(|| candidates.remove(0))
125}
126
127fn program_suffix(environment: SourceMapEnvironment) -> &'static str {
128 match environment {
129 SourceMapEnvironment::Creation => "_ethdebug.json",
130 SourceMapEnvironment::Runtime => "_ethdebug-runtime.json",
131 }
132}
133
134#[must_use]
136pub fn contract_name_from_program_path(
137 path: &Path,
138 environment: SourceMapEnvironment,
139) -> Option<String> {
140 path.file_name()
141 .and_then(|name| name.to_str())
142 .and_then(|name| name.strip_suffix(program_suffix(environment)))
143 .filter(|name| !name.is_empty())
144 .map(str::to_owned)
145}
146
147#[must_use]
150pub fn read_debug_source(root: &Path, source_path: &str) -> Option<String> {
151 read_debug_source_from(root, &[], source_path)
152}
153
154pub fn read_debug_source_from(
157 root: &Path,
158 extra_roots: &[PathBuf],
159 source_path: &str,
160) -> Option<String> {
161 extra_roots
162 .iter()
163 .flat_map(|extra| source_candidates(extra, source_path))
164 .chain(source_candidates(root, source_path))
165 .find_map(|candidate| fs::read_to_string(candidate).ok())
166}
167
168pub fn source_candidates(root: &Path, source_path: &str) -> Vec<PathBuf> {
175 let source = Path::new(source_path);
176 if source.is_absolute() {
177 return vec![source.to_path_buf()];
178 }
179 let mut candidates = Vec::new();
180 let mut directory = Some(root);
181 for _ in 0..4 {
183 let Some(current) = directory else {
184 break;
185 };
186 candidates.push(current.join(source));
187 directory = current.parent();
188 }
189 candidates.push(source.to_path_buf());
190 candidates
191}
192
193pub fn read_json_file(path: &Path) -> SoldbResult<Value> {
195 let content = fs::read_to_string(path).map_err(|error| {
196 SoldbError::Message(format!("failed to read `{}`: {error}", path.display()))
197 })?;
198 serde_json::from_str(&content).map_err(|error| {
199 SoldbError::Message(format!("invalid JSON in `{}`: {error}", path.display()))
200 })
201}
202
203pub fn ethdebug_resources_from_metadata(path: &Path, metadata: &Value) -> SoldbResult<Value> {
206 if let Some(resources) = metadata.get("resources") {
207 return Ok(resources.clone());
208 }
209
210 let Some(compilation) = metadata.get("compilation") else {
211 return Err(SoldbError::Message(format!(
212 "ETHDebug metadata {} does not contain resources or compilation",
213 path.display()
214 )));
215 };
216
217 Ok(json!({
218 "compilation": compilation,
219 "types": metadata.get("types").cloned().unwrap_or_else(|| json!({})),
220 "pointers": metadata.get("pointers").cloned().unwrap_or_else(|| json!({})),
221 }))
222}
223
224pub fn load_debug_program(
232 root: &Path,
233 contract_name: &str,
234 environment: SourceMapEnvironment,
235) -> SoldbResult<Option<DebugProgram>> {
236 load_debug_program_with_sources(root, contract_name, environment, &[])
237}
238
239pub fn load_debug_program_with_sources(
245 root: &Path,
246 contract_name: &str,
247 environment: SourceMapEnvironment,
248 source_roots: &[PathBuf],
249) -> SoldbResult<Option<DebugProgram>> {
250 let environment_name = match environment {
251 SourceMapEnvironment::Creation => "create",
252 SourceMapEnvironment::Runtime => "call",
253 };
254 if let Some(program_path) = find_program_ethdebug(root, contract_name, environment) {
255 let metadata_path = find_ethdebug_metadata(root).ok_or_else(|| {
256 SoldbError::Message(format!(
257 "No ETHDebug metadata file (ethdebug.json or ethdebug_resources.json) found in {}",
258 root.display()
259 ))
260 })?;
261 let metadata = read_json_file(&metadata_path)?;
262 let program = read_json_file(&program_path)?;
263 let resources = ethdebug_resources_from_metadata(&metadata_path, &metadata)?;
264 let name = if contract_name.is_empty() {
265 contract_name_from_program_path(&program_path, environment)
266 .unwrap_or_else(|| "Contract".to_owned())
267 } else {
268 contract_name.to_owned()
269 };
270 let info = EthdebugInfo::from_artifacts(&name, environment_name, &metadata, &program)
271 .map_err(|error| SoldbError::Message(format!("{}: {error}", program_path.display())))?;
272 let (source_contents, missing_sources) =
273 read_sources(root, source_roots, &info, BTreeMap::new());
274 let storage_layout = load_storage_layout(root, &info.contract_name)?;
275 return Ok(Some(DebugProgram {
276 info,
277 resources,
278 source_contents,
279 legacy: false,
280 storage_layout,
281 missing_sources,
282 }));
283 }
284
285 let Some(program) =
286 load_source_map_program_with_sources(root, source_roots, contract_name, environment)?
287 else {
288 return Ok(None);
289 };
290 let (source_contents, missing_sources) =
291 read_sources(root, source_roots, &program.info, program.source_contents);
292 let storage_layout = match program.storage_layout {
293 Some(layout) => Some(layout),
294 None => load_storage_layout(root, &program.info.contract_name)?,
295 };
296 Ok(Some(DebugProgram {
297 info: program.info,
298 resources: program.resources,
299 source_contents,
300 legacy: true,
301 storage_layout,
302 missing_sources,
303 }))
304}
305
306pub fn load_storage_layout(root: &Path, contract_name: &str) -> SoldbResult<Option<StorageLayout>> {
311 let path = root.join(format!("{contract_name}_storage.json"));
312 if !path.exists() {
313 return Ok(None);
314 }
315 let value = read_json_file(&path)?;
316 StorageLayout::parse(&value)
317 .map(Some)
318 .map_err(|error| SoldbError::Message(format!("{}: {error}", path.display())))
319}
320
321fn read_sources(
324 root: &Path,
325 source_roots: &[PathBuf],
326 info: &EthdebugInfo,
327 mut source_contents: BTreeMap<u64, String>,
328) -> (BTreeMap<u64, String>, Vec<String>) {
329 let mut missing = Vec::new();
330 for (source_id, source_path) in &info.sources {
331 if source_contents.contains_key(source_id) {
332 continue;
333 }
334 match read_compilation_source(&info.compilation, *source_id)
335 .or_else(|| read_debug_source_from(root, source_roots, source_path))
336 {
337 Some(source) => {
338 source_contents.insert(*source_id, source);
339 }
340 None => missing.push(source_path.clone()),
341 }
342 }
343 (source_contents, missing)
344}
345
346#[cfg(test)]
347mod tests {
348 use std::fs;
349 use std::path::PathBuf;
350 use std::time::{SystemTime, UNIX_EPOCH};
351
352 use serde_json::json;
353
354 use super::{
355 contract_name_from_program_path, ethdebug_resources_from_metadata, find_ethdebug_metadata,
356 find_program_ethdebug, load_debug_program, load_storage_layout, read_debug_source,
357 read_json_file,
358 };
359 use crate::source_map::SourceMapEnvironment;
360
361 fn temp_dir(label: &str) -> PathBuf {
362 let nanos = SystemTime::now()
363 .duration_since(UNIX_EPOCH)
364 .expect("clock")
365 .as_nanos();
366 let dir = std::env::temp_dir().join(format!("soldb-artifacts-{label}-{nanos}"));
367 fs::create_dir_all(&dir).expect("create temp dir");
368 dir
369 }
370
371 #[test]
372 fn loads_ethdebug_programs_with_their_sources() {
373 let dir = temp_dir("ethdebug");
374 let source =
375 "contract Counter {\n function set(uint256 x) public {\n value = x;\n }\n}\n";
376 fs::write(dir.join("Counter.sol"), source).expect("source");
377 fs::write(
378 dir.join("ethdebug_resources.json"),
379 json!({"compilation": {"sources": [{"id": 0, "path": "Counter.sol"}]}}).to_string(),
380 )
381 .expect("metadata");
382 fs::write(
383 dir.join("Counter_ethdebug-runtime.json"),
384 json!({"instructions": [{
385 "offset": 7,
386 "operation": {"mnemonic": "STOP"},
387 "context": {"code": {"source": {"id": 0}, "range": {"offset": 59, "length": 9}}}
388 }]})
389 .to_string(),
390 )
391 .expect("program");
392
393 let program = load_debug_program(&dir, "Counter", SourceMapEnvironment::Runtime)
394 .expect("load")
395 .expect("present");
396 assert!(!program.legacy);
397 assert_eq!(program.info.contract_name, "Counter");
398 assert_eq!(program.info.environment, "call");
399 assert!(program.info.instruction_at_pc(7).is_some());
400 assert_eq!(
401 program.source_contents.get(&0).map(String::as_str),
402 Some(source)
403 );
404 assert!(program.resources.get("compilation").is_some());
405 assert!(program.storage_layout.is_none());
407
408 let inferred = load_debug_program(&dir, "", SourceMapEnvironment::Runtime)
410 .expect("load")
411 .expect("present");
412 assert_eq!(inferred.info.contract_name, "Counter");
413 assert!(
415 load_debug_program(&dir, "Counter", SourceMapEnvironment::Creation)
416 .expect("load")
417 .is_none()
418 );
419 assert_eq!(
420 contract_name_from_program_path(
421 &dir.join("Counter_ethdebug-runtime.json"),
422 SourceMapEnvironment::Runtime
423 )
424 .as_deref(),
425 Some("Counter")
426 );
427 assert_eq!(
428 find_ethdebug_metadata(&dir),
429 Some(dir.join("ethdebug_resources.json"))
430 );
431 assert_eq!(
432 find_program_ethdebug(&dir, "Other", SourceMapEnvironment::Runtime),
433 Some(dir.join("Counter_ethdebug-runtime.json")),
434 "a single artifact serves any name"
435 );
436 assert_eq!(
437 read_debug_source(&dir, "Counter.sol").as_deref(),
438 Some(source)
439 );
440 assert!(read_debug_source(&dir, "Missing.sol").is_none());
441
442 fs::write(
445 dir.join("Counter_storage.json"),
446 json!({
447 "storage": [{
448 "astId": 1,
449 "contract": "Counter.sol:Counter",
450 "label": "value",
451 "offset": 0,
452 "slot": "1",
453 "type": "t_uint256"
454 }],
455 "types": {"t_uint256": {
456 "encoding": "inplace",
457 "label": "uint256",
458 "numberOfBytes": "32"
459 }}
460 })
461 .to_string(),
462 )
463 .expect("layout");
464 let program = load_debug_program(&dir, "Counter", SourceMapEnvironment::Runtime)
465 .expect("load")
466 .expect("present");
467 let layout = program.storage_layout.as_ref().expect("storage layout");
468 assert_eq!(layout.variable("value").expect("value").slot[31], 1);
469 assert!(load_storage_layout(&dir, "Missing")
470 .expect("absent")
471 .is_none());
472 fs::write(dir.join("Counter_storage.json"), "{}").expect("layout");
473 let error = load_debug_program(&dir, "Counter", SourceMapEnvironment::Runtime)
474 .expect_err("a storage layout that cannot be read is reported");
475 assert!(
476 error.to_string().contains("Counter_storage.json"),
477 "{error}"
478 );
479 }
480
481 #[test]
482 fn a_program_without_metadata_is_an_error_not_a_silent_miss() {
483 let dir = temp_dir("no-metadata");
484 fs::write(dir.join("Counter_ethdebug-runtime.json"), "{}").expect("program");
485 let error = load_debug_program(&dir, "Counter", SourceMapEnvironment::Runtime)
486 .expect_err("metadata is required");
487 assert!(
488 error.to_string().contains("No ETHDebug metadata file"),
489 "{error}"
490 );
491
492 fs::write(dir.join("ethdebug.json"), "{not json").expect("metadata");
493 let error = load_debug_program(&dir, "Counter", SourceMapEnvironment::Runtime)
494 .expect_err("malformed metadata");
495 assert!(error.to_string().contains("invalid JSON"), "{error}");
496 assert!(read_json_file(&dir.join("absent.json")).is_err());
497 }
498
499 #[test]
500 fn falls_back_to_legacy_source_maps() {
501 let dir = temp_dir("legacy");
502 let source =
503 "contract Counter {\n function set(uint256 x) public {\n value = x;\n }\n}\n";
504 fs::write(dir.join("Counter.sol"), source).expect("source");
505 let offset = source.find("value = x").expect("statement");
506 fs::write(
507 dir.join("combined.json"),
508 json!({
509 "sourceList": ["Counter.sol"],
510 "contracts": {"Counter.sol:Counter": {
511 "bin-runtime": "60010000",
512 "srcmap-runtime": format!("{offset}:9:0:-:0;{offset}:9:0;{offset}:9:0")
513 }}
514 })
515 .to_string(),
516 )
517 .expect("combined");
518
519 let program = load_debug_program(&dir, "Counter", SourceMapEnvironment::Runtime)
520 .expect("load")
521 .expect("present");
522 assert!(program.legacy);
523 assert_eq!(
524 program.source_contents.get(&0).map(String::as_str),
525 Some(source)
526 );
527 assert!(program.info.instruction_at_pc(2).is_some());
528
529 let empty = temp_dir("empty");
530 assert!(
531 load_debug_program(&empty, "Counter", SourceMapEnvironment::Runtime)
532 .expect("load")
533 .is_none()
534 );
535 }
536
537 #[test]
538 fn resources_come_from_the_record_or_are_assembled() {
539 let path = PathBuf::from("ethdebug.json");
540 let explicit = json!({"resources": {"compilation": {}}});
541 assert_eq!(
542 ethdebug_resources_from_metadata(&path, &explicit).expect("resources"),
543 json!({"compilation": {}})
544 );
545 let assembled = json!({"compilation": {"sources": []}, "types": {"a": 1}});
546 assert_eq!(
547 ethdebug_resources_from_metadata(&path, &assembled).expect("resources"),
548 json!({"compilation": {"sources": []}, "types": {"a": 1}, "pointers": {}})
549 );
550 let error = ethdebug_resources_from_metadata(&path, &json!({})).expect_err("neither");
551 assert!(
552 error.to_string().contains("does not contain resources"),
553 "{error}"
554 );
555 }
556}