diff --git a/Cargo.lock b/Cargo.lock index cfbe23b..2235689 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -761,6 +761,18 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" +[[package]] +name = "cli-debugger" +version = "0.1.0" +dependencies = [ + "clap", + "debugger-session", + "kaspa-consensus-core", + "kaspa-txscript", + "kaspa-txscript-errors", + "silverscript-lang", +] + [[package]] name = "cobs" version = "0.3.0" @@ -910,6 +922,20 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "debugger-session" +version = "0.1.0" +dependencies = [ + "faster-hex 0.10.0", + "kaspa-addresses", + "kaspa-consensus-core", + "kaspa-txscript", + "kaspa-txscript-errors", + "serde", + "serde_json", + "silverscript-lang", +] + [[package]] name = "deranged" version = "0.5.8" @@ -1135,6 +1161,16 @@ dependencies = [ "serde", ] +[[package]] +name = "faster-hex" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7223ae2d2f179b803433d9c830478527e92b8117eab39460edae7f1614d9fb73" +dependencies = [ + "heapless", + "serde", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -1357,6 +1393,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -1382,6 +1427,16 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.5.0" @@ -1557,6 +1612,7 @@ dependencies = [ [[package]] name = "kaspa-addresses" version = "1.1.0-rc.3" +source = "git+https://github.com/kaspanet/rusty-kaspa?branch=tn12#c6819f3b3fe37f712acd88dafbb583297e2ef938" dependencies = [ "borsh", "js-sys", @@ -1571,13 +1627,14 @@ dependencies = [ [[package]] name = "kaspa-consensus-core" version = "1.1.0-rc.3" +source = "git+https://github.com/kaspanet/rusty-kaspa?branch=tn12#c6819f3b3fe37f712acd88dafbb583297e2ef938" dependencies = [ "arc-swap", "async-trait", "bitflags 2.11.0", "borsh", "cfg-if", - "faster-hex", + "faster-hex 0.9.0", "futures-util", "getrandom 0.2.17", "itertools 0.13.0", @@ -1607,6 +1664,7 @@ dependencies = [ [[package]] name = "kaspa-core" version = "1.1.0-rc.3" +source = "git+https://github.com/kaspanet/rusty-kaspa?branch=tn12#c6819f3b3fe37f712acd88dafbb583297e2ef938" dependencies = [ "anyhow", "cfg-if", @@ -1637,12 +1695,13 @@ dependencies = [ [[package]] name = "kaspa-hashes" version = "1.1.0-rc.3" +source = "git+https://github.com/kaspanet/rusty-kaspa?branch=tn12#c6819f3b3fe37f712acd88dafbb583297e2ef938" dependencies = [ "blake2b_simd", "blake3", "borsh", "cc", - "faster-hex", + "faster-hex 0.9.0", "js-sys", "kaspa-utils", "keccak", @@ -1656,9 +1715,10 @@ dependencies = [ [[package]] name = "kaspa-math" version = "1.1.0-rc.3" +source = "git+https://github.com/kaspanet/rusty-kaspa?branch=tn12#c6819f3b3fe37f712acd88dafbb583297e2ef938" dependencies = [ "borsh", - "faster-hex", + "faster-hex 0.9.0", "js-sys", "kaspa-utils", "malachite-base", @@ -1675,6 +1735,7 @@ dependencies = [ [[package]] name = "kaspa-merkle" version = "1.1.0-rc.3" +source = "git+https://github.com/kaspanet/rusty-kaspa?branch=tn12#c6819f3b3fe37f712acd88dafbb583297e2ef938" dependencies = [ "kaspa-hashes", ] @@ -1682,6 +1743,7 @@ dependencies = [ [[package]] name = "kaspa-muhash" version = "1.1.0-rc.3" +source = "git+https://github.com/kaspanet/rusty-kaspa?branch=tn12#c6819f3b3fe37f712acd88dafbb583297e2ef938" dependencies = [ "kaspa-hashes", "kaspa-math", @@ -1692,6 +1754,7 @@ dependencies = [ [[package]] name = "kaspa-txscript" version = "1.1.0-rc.3" +source = "git+https://github.com/kaspanet/rusty-kaspa?branch=tn12#c6819f3b3fe37f712acd88dafbb583297e2ef938" dependencies = [ "ark-bn254", "ark-ec", @@ -1704,7 +1767,7 @@ dependencies = [ "bytemuck", "cc", "cfg-if", - "faster-hex", + "faster-hex 0.9.0", "hexplay", "indexmap", "itertools 0.13.0", @@ -1737,6 +1800,7 @@ dependencies = [ [[package]] name = "kaspa-txscript-errors" version = "1.1.0-rc.3" +source = "git+https://github.com/kaspanet/rusty-kaspa?branch=tn12#c6819f3b3fe37f712acd88dafbb583297e2ef938" dependencies = [ "borsh", "kaspa-hashes", @@ -1747,6 +1811,7 @@ dependencies = [ [[package]] name = "kaspa-utils" version = "1.1.0-rc.3" +source = "git+https://github.com/kaspanet/rusty-kaspa?branch=tn12#c6819f3b3fe37f712acd88dafbb583297e2ef938" dependencies = [ "arc-swap", "async-channel 2.5.0", @@ -1754,7 +1819,7 @@ dependencies = [ "cfg-if", "duct", "event-listener 2.5.3", - "faster-hex", + "faster-hex 0.9.0", "ipnet", "itertools 0.13.0", "log", @@ -1776,8 +1841,9 @@ dependencies = [ [[package]] name = "kaspa-wasm-core" version = "1.1.0-rc.3" +source = "git+https://github.com/kaspanet/rusty-kaspa?branch=tn12#c6819f3b3fe37f712acd88dafbb583297e2ef938" dependencies = [ - "faster-hex", + "faster-hex 0.9.0", "hexplay", "js-sys", "wasm-bindgen", @@ -2917,6 +2983,7 @@ dependencies = [ "blake2b_simd", "chrono", "clap", + "faster-hex 0.10.0", "kaspa-addresses", "kaspa-consensus-core", "kaspa-txscript", @@ -2967,6 +3034,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "strsim" version = "0.11.1" @@ -3953,7 +4026,7 @@ dependencies = [ "cfg-if", "chrono", "dirs", - "faster-hex", + "faster-hex 0.9.0", "futures", "getrandom 0.2.17", "instant", @@ -4048,7 +4121,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "799e5fbf266e0fffb5c24d6103735eb2b94bb31f93b664b91eaaf63b4f959804" dependencies = [ "cfg-if", - "faster-hex", + "faster-hex 0.9.0", "futures", "js-sys", "serde", @@ -4078,18 +4151,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.40" +version = "0.8.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a789c6e490b576db9f7e6b6d661bcc9799f7c0ac8352f56ea20193b2681532e5" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.40" +version = "0.8.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index aa81eaa..c6b9926 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,10 @@ [workspace] -members = ["silverscript-lang", "covenants/sdk"] +members = [ + "silverscript-lang", + "debugger/session", + "debugger/cli", + "covenants/sdk", +] exclude = ["tree-sitter"] resolver = "2" diff --git a/README.md b/README.md index a0f2673..e69fd74 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,23 @@ This repository is a Rust workspace. The main crate is `silverscript-lang`. cargo test -p silverscript-lang ``` +## Debugger + +The workspace includes a source-level debugger for stepping through scripts: + +```bash +cargo run -p cli-debugger -- \ + silverscript-lang/tests/examples/if_statement.sil \ + --function hello \ + --ctor-arg 3 --ctor-arg 10 \ + --arg 1 --arg 2 +``` + ## Layout - `silverscript-lang/` – compiler, parser, and tests +- `debugger/session/` – `DebugSession` runtime (stepping, variable inspection) +- `debugger/cli/` – `sil-debug` CLI REPL - `silverscript-lang/tests/examples/` – example contracts (`.sil` files) ## Documentation diff --git a/debugger/cli/Cargo.toml b/debugger/cli/Cargo.toml new file mode 100644 index 0000000..7c28806 --- /dev/null +++ b/debugger/cli/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "cli-debugger" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +rust-version.workspace = true + +[[bin]] +name = "cli-debugger" +path = "src/main.rs" + +[dependencies] +debugger-session = { path = "../session" } +silverscript-lang = { path = "../../silverscript-lang" } +kaspa-consensus-core.workspace = true +kaspa-txscript.workspace = true +kaspa-txscript-errors.workspace = true +clap = { version = "4.5.60", features = ["derive"] } diff --git a/debugger/cli/src/main.rs b/debugger/cli/src/main.rs new file mode 100644 index 0000000..42c30bc --- /dev/null +++ b/debugger/cli/src/main.rs @@ -0,0 +1,539 @@ +use std::collections::HashMap; +use std::fs; +use std::io::{self, BufRead, Write}; +use std::path::{Path, PathBuf}; + +use clap::Parser; +use debugger_session::args::{parse_call_args, parse_ctor_args, parse_hex_bytes}; +use debugger_session::format_failure_report; +use debugger_session::session::{DebugEngine, DebugSession, ShadowTxContext}; +use debugger_session::test_runner::{ + TestExpectation, TestTxInputScenarioResolved, TestTxOutputScenarioResolved, TestTxScenarioResolved, resolve_contract_test, +}; +use kaspa_consensus_core::Hash; +use kaspa_consensus_core::hashing::sighash::SigHashReusedValuesUnsync; +use kaspa_consensus_core::tx::{ + CovenantBinding, PopulatedTransaction, ScriptPublicKey, Transaction, TransactionId, TransactionInput, TransactionOutpoint, + TransactionOutput, UtxoEntry, VerifiableTransaction, +}; +use kaspa_txscript::caches::Cache; +use kaspa_txscript::covenants::CovenantsContext; +use kaspa_txscript::script_builder::ScriptBuilder; +use kaspa_txscript::{EngineCtx, EngineFlags, pay_to_script_hash_script}; +use silverscript_lang::ast::{ContractAst, parse_contract_ast}; +use silverscript_lang::compiler::{CompileOptions, compile_contract}; + +const PROMPT: &str = "(sdb) "; + +#[derive(Debug, Parser)] +#[command(name = "cli-debugger", about = "SilverScript debugger")] +struct CliArgs { + script_path: Option, + #[arg(long = "test-file")] + test_file: Option, + #[arg(long = "test-name")] + test_name: Option, + /// Run non-interactively: execute and report pass/fail + #[arg(long = "run", short = 'r')] + run: bool, + /// Run all tests in a test file + #[arg(long = "run-all")] + run_all: bool, + #[arg(long = "no-selector")] + without_selector: bool, + #[arg(long = "function", short = 'f')] + function_name: Option, + #[arg(long = "ctor-arg")] + raw_ctor_args: Vec, + #[arg(long = "arg", short = 'a')] + raw_args: Vec, +} + +fn compile_script_for_ctor_args( + source: &str, + parsed_contract: &ContractAst<'_>, + raw_ctor_args: &[String], + cache: &mut HashMap, Vec>, +) -> Result, Box> { + if let Some(script) = cache.get(raw_ctor_args) { + return Ok(script.clone()); + } + let ctor_args = parse_ctor_args(parsed_contract, raw_ctor_args)?; + let compiled = compile_contract(source, &ctor_args, CompileOptions::default())?; + cache.insert(raw_ctor_args.to_vec(), compiled.script.clone()); + Ok(compiled.script) +} + +fn parse_hash32(raw: &str) -> Result> { + let bytes = parse_hex_bytes(raw)?; + if bytes.len() != 32 { + return Err(format!("hash expects 32 bytes, got {}", bytes.len()).into()); + } + let mut array = [0u8; 32]; + array.copy_from_slice(&bytes); + Ok(Hash::from_bytes(array)) +} + +fn parse_txid32(raw: &str) -> Result> { + let bytes = parse_hex_bytes(raw)?; + if bytes.len() != 32 { + return Err(format!("txid expects 32 bytes, got {}", bytes.len()).into()); + } + let mut array = [0u8; 32]; + array.copy_from_slice(&bytes); + Ok(TransactionId::from_bytes(array)) +} + +fn build_p2pk_script(pubkey: &[u8]) -> Vec { + ScriptBuilder::new() + .add_data(pubkey) + .expect("push pubkey") + .add_op(kaspa_txscript::opcodes::codes::OpCheckSig) + .expect("add OpCheckSig") + .drain() +} + +fn sigscript_push_script(script: &[u8]) -> Vec { + ScriptBuilder::new().add_data(script).expect("push script data").drain() +} + +fn combine_action_and_redeem(action: &[u8], redeem_script: &[u8]) -> Result, Box> { + let mut builder = ScriptBuilder::new(); + builder.add_ops(action)?; + builder.add_data(redeem_script)?; + Ok(builder.drain()) +} + +fn show_stack(session: &DebugSession<'_, '_>) { + println!("Stack:"); + let stack = session.stack(); + for (i, item) in stack.iter().enumerate().rev() { + println!("[{i}] {item}"); + } +} + +fn show_source_context(session: &DebugSession<'_, '_>) { + let Some(context) = session.source_context() else { + println!("No source context available."); + return; + }; + + for line in context.lines { + let marker = if line.is_active { "→" } else { " " }; + println!("{marker} {:>4} | {}", line.line, line.text); + } +} + +fn show_vars(session: &DebugSession<'_, '_>) { + match session.list_variables() { + Ok(variables) => { + if variables.is_empty() { + println!("No variables in scope."); + } else { + for var in variables { + let constant_suffix = if var.is_constant { " (const)" } else { "" }; + println!( + "{}{} ({}) = {}", + var.name, + constant_suffix, + var.type_name, + session.format_value(&var.type_name, &var.value) + ); + } + } + } + Err(err) => println!("ERROR: {err}"), + } +} + +fn show_step_view(session: &DebugSession<'_, '_>) { + show_source_context(session); + show_vars(session); +} + +fn print_failure(session: &DebugSession<'_, '_>, err: kaspa_txscript_errors::TxScriptError) { + let report = session.build_failure_report(&err); + let formatted = format_failure_report(&report, &|type_name, value| session.format_value(type_name, value)); + eprintln!("{formatted}"); +} + +fn run_repl(session: &mut DebugSession<'_, '_>) -> Result<(), Box> { + let stdin = io::stdin(); + loop { + print!("{PROMPT}"); + io::stdout().flush().ok(); + + let mut cmd = String::new(); + if stdin.lock().read_line(&mut cmd).is_err() { + println!("Failed to read input."); + continue; + } + + let cmd = cmd.trim(); + if cmd.is_empty() || cmd == "n" || cmd == "next" { + match session.step_over() { + Ok(Some(_)) => show_step_view(session), + Ok(None) => { + println!("Done."); + break; + } + Err(err) => { + print_failure(session, err); + break; + } + } + continue; + } + + let mut parts = cmd.split_whitespace(); + match parts.next().unwrap_or("") { + "step" | "s" => match session.step_into() { + Ok(Some(_)) => show_step_view(session), + Ok(None) => { + println!("Done."); + break; + } + Err(err) => { + print_failure(session, err); + break; + } + }, + "si" => match session.step_opcode() { + Ok(Some(_)) => show_step_view(session), + Ok(None) => { + println!("Done."); + break; + } + Err(err) => { + print_failure(session, err); + break; + } + }, + "finish" | "out" => match session.step_out() { + Ok(Some(_)) => show_step_view(session), + Ok(None) => { + println!("Done."); + break; + } + Err(err) => { + print_failure(session, err); + break; + } + }, + "c" | "continue" => match session.continue_to_breakpoint() { + Ok(Some(_)) => show_step_view(session), + Ok(None) => { + println!("Done."); + break; + } + Err(err) => { + print_failure(session, err); + break; + } + }, + "b" | "break" => { + if let Some(arg) = parts.next() { + match arg.parse::() { + Ok(line) => { + if session.add_breakpoint(line) { + println!("Breakpoint set at line {line}"); + } else { + println!("Warning: no statement at line {line}, breakpoint not set"); + } + } + Err(_) => println!("Invalid line number."), + } + } else { + let lines = session.breakpoints(); + if lines.is_empty() { + println!("No breakpoints set."); + } else { + println!("Breakpoints: {}", lines.iter().map(|line| line.to_string()).collect::>().join(", ")); + } + } + } + "l" | "list" => show_source_context(session), + "vars" => show_vars(session), + "print" | "p" => { + if let Some(name) = parts.next() { + match session.variable_by_name(name) { + Ok(var) => { + let constant_suffix = if var.is_constant { " (const)" } else { "" }; + println!( + "{}{} ({}) = {}", + var.name, + constant_suffix, + var.type_name, + session.format_value(&var.type_name, &var.value) + ); + } + Err(err) => println!("ERROR: {err}"), + } + } else { + println!("Usage: print "); + } + } + "stack" => show_stack(session), + "q" | "quit" => break, + "help" | "h" | "?" => { + println!( + "Commands: next/over (n), step/into (s), step opcode (si), finish/out, continue (c), break (b ), list (l), vars, print , stack, quit (q)" + ) + } + _ => println!( + "Commands: next/over (n), step/into (s), step opcode (si), finish/out, continue (c), break (b ), list (l), vars, print , stack, quit (q)" + ), + } + } + Ok(()) +} + +fn run_all_tests(test_file: &str) -> Result<(), Box> { + use debugger_session::test_runner::read_contract_test_file; + let test_file_path = Path::new(test_file); + let parsed = read_contract_test_file(test_file_path)?; + let test_names: Vec = parsed.tests.iter().map(|t| t.name.clone()).collect(); + let total = test_names.len(); + let mut passed = 0; + let mut failed = 0; + for name in &test_names { + let result = std::process::Command::new(std::env::current_exe()?) + .args(["--run", "--test-file", test_file, "--test-name", name]) + .output()?; + let stderr = String::from_utf8_lossy(&result.stderr); + if result.status.success() { + passed += 1; + println!(" PASS {name}"); + } else { + failed += 1; + println!(" FAIL {name}"); + if !stderr.is_empty() { + for line in stderr.lines() { + println!(" {line}"); + } + } + } + } + println!("\n{total} tests: {passed} passed, {failed} failed"); + if failed > 0 { Err("some tests failed".into()) } else { Ok(()) } +} + +fn main() -> Result<(), Box> { + let cli = CliArgs::parse(); + + if cli.run_all { + let test_file = cli.test_file.as_deref().ok_or("--run-all requires --test-file")?; + return run_all_tests(test_file); + } + + // Resolve source, ctor args, function, call args, and tx from test file or CLI flags + let (script_path, raw_ctor_args, selected_name, raw_args, tx_scenario, expect) = if let Some(test_file) = cli.test_file.as_deref() + { + let test_name = cli.test_name.as_deref().ok_or("--test-file requires --test-name")?; + let script_override = cli.script_path.as_deref().map(Path::new); + let resolved = resolve_contract_test(Path::new(test_file), test_name, script_override) + .map_err(|e| -> Box { e.into() })?; + let ctor = if !cli.raw_ctor_args.is_empty() { cli.raw_ctor_args.clone() } else { resolved.test.constructor_args }; + let fname = cli.function_name.clone().unwrap_or(resolved.test.function); + let args = if !cli.raw_args.is_empty() { cli.raw_args.clone() } else { resolved.test.args }; + let expect = Some(resolved.test.expect); + (resolved.script_path, ctor, fname, args, resolved.test.tx, expect) + } else { + let path = cli.script_path.as_deref().ok_or("missing script path: pass SCRIPT_PATH or --test-file")?; + let ctor = cli.raw_ctor_args.clone(); + let args = cli.raw_args.clone(); + (PathBuf::from(path), ctor, cli.function_name.clone().unwrap_or_default(), args, None, None) + }; + + let source = fs::read_to_string(&script_path)?; + let parsed_contract = parse_contract_ast(&source)?; + + if cli.without_selector { + let entrypoint_count = parsed_contract.functions.iter().filter(|func| func.entrypoint).count(); + if entrypoint_count != 1 { + return Err("--no-selector requires exactly one entrypoint function".into()); + } + } + + let ctor_args = parse_ctor_args(&parsed_contract, &raw_ctor_args)?; + let compile_opts = CompileOptions { record_debug_infos: true, ..Default::default() }; + let compiled = compile_contract(&source, &ctor_args, compile_opts)?; + let debug_info = compiled.debug_info.clone(); + let mut ctor_script_cache = HashMap::, Vec>::new(); + ctor_script_cache.insert(raw_ctor_args.clone(), compiled.script.clone()); + + let selected_name = if selected_name.is_empty() { + compiled.abi.first().map(|entry| entry.name.clone()).ok_or("contract has no functions")? + } else { + selected_name + }; + let entry = compiled + .abi + .iter() + .find(|entry| entry.name == selected_name) + .ok_or_else(|| format!("function '{selected_name}' not found"))?; + + let input_types = entry.inputs.iter().map(|input| input.type_name.clone()).collect::>(); + let typed_args = parse_call_args(&input_types, &raw_args)?; + let sigscript = compiled.build_sig_script(&selected_name, typed_args)?; + + let tx = tx_scenario.unwrap_or_else(|| TestTxScenarioResolved { + version: 1, + lock_time: 0, + active_input_index: 0, + inputs: vec![TestTxInputScenarioResolved { + prev_txid: None, + prev_index: 0, + sequence: 0, + sig_op_count: 100, + utxo_value: 5000, + covenant_id: None, + constructor_args: None, + signature_script_hex: None, + utxo_script_hex: None, + }], + outputs: vec![TestTxOutputScenarioResolved { + value: 5000, + covenant_id: None, + authorizing_input: None, + constructor_args: None, + script_hex: None, + p2pk_pubkey: None, + }], + }); + + if tx.inputs.is_empty() { + return Err("tx.inputs must contain at least one input".into()); + } + if tx.active_input_index >= tx.inputs.len() { + return Err(format!("tx.active_input_index {} out of range for {} inputs", tx.active_input_index, tx.inputs.len()).into()); + } + + let mut tx_inputs = Vec::with_capacity(tx.inputs.len()); + let mut utxo_specs = Vec::with_capacity(tx.inputs.len()); + for (input_idx, input) in tx.inputs.iter().enumerate() { + let mut default_prev_txid = [0u8; 32]; + default_prev_txid.fill(input_idx as u8); + let prev_txid = if let Some(raw_txid) = input.prev_txid.as_deref() { + parse_txid32(raw_txid)? + } else { + TransactionId::from_bytes(default_prev_txid) + }; + + let input_ctor_raw = input.constructor_args.clone().unwrap_or_else(|| raw_ctor_args.clone()); + let redeem_script = if input.utxo_script_hex.is_none() { + Some(compile_script_for_ctor_args(&source, &parsed_contract, &input_ctor_raw, &mut ctor_script_cache)?) + } else { + None + }; + + let signature_script = if let Some(raw_sig) = input.signature_script_hex.as_deref() { + parse_hex_bytes(raw_sig)? + } else if input_idx == tx.active_input_index { + if let Some(redeem) = redeem_script.as_ref() { combine_action_and_redeem(&sigscript, redeem)? } else { sigscript.clone() } + } else if let Some(redeem) = redeem_script.as_ref() { + sigscript_push_script(redeem) + } else { + vec![] + }; + + let utxo_spk = if let Some(raw_script) = input.utxo_script_hex.as_deref() { + ScriptPublicKey::new(0, parse_hex_bytes(raw_script)?.into()) + } else { + let redeem = redeem_script.as_ref().ok_or("internal error: missing redeem script for tx input without utxo_script_hex")?; + pay_to_script_hash_script(redeem) + }; + + let covenant_id = if let Some(raw) = input.covenant_id.as_deref() { Some(parse_hash32(raw)?) } else { None }; + + tx_inputs.push(TransactionInput { + previous_outpoint: TransactionOutpoint { transaction_id: prev_txid, index: input.prev_index }, + signature_script, + sequence: input.sequence, + sig_op_count: input.sig_op_count, + }); + utxo_specs.push((input.utxo_value, utxo_spk, covenant_id)); + } + + let mut tx_outputs = Vec::with_capacity(tx.outputs.len()); + for output in tx.outputs.iter() { + let script_public_key = if let Some(raw_script) = output.script_hex.as_deref() { + ScriptPublicKey::new(0, parse_hex_bytes(raw_script)?.into()) + } else if let Some(raw_pubkey) = output.p2pk_pubkey.as_deref() { + let pubkey_bytes = parse_hex_bytes(raw_pubkey)?; + let p2pk_script = build_p2pk_script(&pubkey_bytes); + ScriptPublicKey::new(0, p2pk_script.into()) + } else { + let output_ctor_raw = output.constructor_args.clone().unwrap_or_else(|| raw_ctor_args.clone()); + let output_script = compile_script_for_ctor_args(&source, &parsed_contract, &output_ctor_raw, &mut ctor_script_cache)?; + pay_to_script_hash_script(&output_script) + }; + + let covenant = if let Some(raw) = output.covenant_id.as_deref() { + Some(CovenantBinding { + authorizing_input: output.authorizing_input.unwrap_or(tx.active_input_index as u16), + covenant_id: parse_hash32(raw)?, + }) + } else { + None + }; + + tx_outputs.push(TransactionOutput { value: output.value, script_public_key, covenant }); + } + + let kas_tx = Transaction::new(tx.version, tx_inputs, tx_outputs, tx.lock_time, Default::default(), 0, vec![]); + + let sig_cache = Cache::new(10_000); + let reused_values = SigHashReusedValuesUnsync::new(); + let flags = EngineFlags { covenants_enabled: true }; + + let utxos = utxo_specs + .into_iter() + .map(|(value, spk, covenant_id)| UtxoEntry::new(value, spk, 0, kas_tx.is_coinbase(), covenant_id)) + .collect::>(); + let populated_tx = PopulatedTransaction::new(&kas_tx, utxos); + let cov_ctx = CovenantsContext::from_tx(&populated_tx)?; + let ctx = EngineCtx::new(&sig_cache).with_reused(&reused_values).with_covenants_ctx(&cov_ctx); + let active_input = + kas_tx.inputs.get(tx.active_input_index).ok_or_else(|| format!("missing tx input at index {}", tx.active_input_index))?; + let active_utxo = + populated_tx.utxo(tx.active_input_index).ok_or_else(|| format!("missing utxo entry for input {}", tx.active_input_index))?; + let engine = DebugEngine::from_transaction_input(&populated_tx, active_input, tx.active_input_index, active_utxo, ctx, flags); + let shadow_tx_context = ShadowTxContext { + tx: &populated_tx, + input: active_input, + input_index: tx.active_input_index, + utxo_entry: active_utxo, + covenants_ctx: &cov_ctx, + }; + let mut session = + DebugSession::full(&sigscript, &compiled.script, &source, debug_info, engine)?.with_shadow_tx_context(shadow_tx_context); + + if cli.run { + let expect_fail = expect == Some(TestExpectation::Fail); + match session.continue_to_breakpoint() { + Ok(_) if expect_fail => { + eprintln!("FAIL: expected failure but script passed"); + Err("FAIL".into()) + } + Ok(_) => { + println!("PASS"); + Ok(()) + } + Err(_) if expect_fail => { + println!("PASS (expected failure)"); + Ok(()) + } + Err(err) => { + print_failure(&session, err); + Err("FAIL".into()) + } + } + } else { + println!("Stepping through {} bytes of script", compiled.script.len()); + session.run_to_first_executed_statement()?; + show_source_context(&session); + run_repl(&mut session)?; + Ok(()) + } +} diff --git a/debugger/cli/tests/cli_tests.rs b/debugger/cli/tests/cli_tests.rs new file mode 100644 index 0000000..d156647 --- /dev/null +++ b/debugger/cli/tests/cli_tests.rs @@ -0,0 +1,214 @@ +use std::io::Write; +use std::process::{Command, Stdio}; +use std::time::{SystemTime, UNIX_EPOCH}; + +fn write_test_fixture() -> (std::path::PathBuf, std::path::PathBuf) { + let nonce = SystemTime::now().duration_since(UNIX_EPOCH).expect("clock").as_nanos(); + let dir = std::env::temp_dir().join(format!("cli_debugger_test_fixture_{}_{}", std::process::id(), nonce)); + std::fs::create_dir_all(&dir).expect("create temp fixture dir"); + + let script_path = dir.join("simple.sil"); + let test_file_path = dir.join("simple.test.json"); + + std::fs::write( + &script_path, + r#"pragma silverscript ^0.1.0; + +contract Simple(int x) { + entrypoint function check(int a) { + require(a == x); + } +} +"#, + ) + .expect("write fixture contract"); + + std::fs::write( + &test_file_path, + r#"{ + "tests": [ + { + "name": "pass_case", + "function": "check", + "constructor_args": [5], + "args": [5], + "expect": "pass" + }, + { + "name": "fail_case", + "function": "check", + "constructor_args": [5], + "args": [4], + "expect": "fail" + } + ] +} +"#, + ) + .expect("write fixture test file"); + + (script_path, test_file_path) +} + +#[test] +fn cli_debugger_repl_all_commands_smoke() { + let tmp = std::env::temp_dir().join("cli_test_if_statement.sil"); + std::fs::write( + &tmp, + r#"pragma silverscript ^0.1.0; + +contract IfStatement(int x, int y) { + entrypoint function hello(int a, int b) { + int d = a + b; + d = d - a; + if (d == x - 2) { + int c = d + b; + d = a + c; + require(c > d); + } else { + require(d == a); + } + d = d + a; + require(d == y); + } +} +"#, + ) + .expect("write temp contract"); + let contract_path = &tmp; + + let mut child = Command::new(env!("CARGO_BIN_EXE_cli-debugger")) + .arg(contract_path) + .arg("--function") + .arg("hello") + .arg("--ctor-arg") + .arg("3") + .arg("--ctor-arg") + .arg("10") + .arg("--arg") + .arg("5") + .arg("--arg") + .arg("5") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("failed to spawn cli-debugger"); + + let input = b"help\nl\nstack\nb 1\nb 7\nb\nn\nsi\nq\n"; + child.stdin.as_mut().expect("stdin available").write_all(input).expect("write stdin"); + + let output = child.wait_with_output().expect("wait for cli-debugger"); + assert!(output.status.success(), "cli-debugger exited with status {:?}", output.status.code()); + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert!(stderr.is_empty(), "unexpected stderr: {stderr}"); + assert!(stdout.contains("Stepping through"), "missing startup output"); + assert!(stdout.contains("(sdb)"), "missing prompt output"); + assert!(stdout.contains("Commands:"), "missing help output"); + assert!(stdout.contains("Stack:"), "missing stack output"); + let saw_line1_feedback = stdout.contains("no statement at line 1") || stdout.contains("Breakpoint set at line 1"); + assert!(saw_line1_feedback, "missing breakpoint feedback for line 1"); + assert!(stdout.contains("Breakpoint set at line 7"), "missing line-7 breakpoint success"); + let listing_contains_7 = stdout.lines().any(|line| line.contains("Breakpoints:") && line.contains('7')); + assert!(listing_contains_7, "missing breakpoint listing containing line 7"); +} + +#[test] +fn cli_debugger_run_test_file_pass_case() { + let (_script_path, test_file_path) = write_test_fixture(); + + let output = Command::new(env!("CARGO_BIN_EXE_cli-debugger")) + .arg("--run") + .arg("--test-file") + .arg(&test_file_path) + .arg("--test-name") + .arg("pass_case") + .output() + .expect("run cli-debugger pass test"); + + assert!( + output.status.success(), + "expected success, status={:?}, stderr={}", + output.status.code(), + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("PASS"), "expected PASS in stdout, got: {stdout}"); +} + +#[test] +fn cli_debugger_run_test_file_expected_fail_case() { + let (_script_path, test_file_path) = write_test_fixture(); + + let output = Command::new(env!("CARGO_BIN_EXE_cli-debugger")) + .arg("--run") + .arg("--test-file") + .arg(&test_file_path) + .arg("--test-name") + .arg("fail_case") + .output() + .expect("run cli-debugger expected-fail test"); + + assert!( + output.status.success(), + "expected success for expected-fail test, status={:?}, stderr={}", + output.status.code(), + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("PASS (expected failure)"), "expected expected-failure PASS marker in stdout, got: {stdout}"); +} + +#[test] +fn cli_debugger_run_all_uses_test_file_suite() { + let (_script_path, test_file_path) = write_test_fixture(); + + let output = Command::new(env!("CARGO_BIN_EXE_cli-debugger")) + .arg("--run-all") + .arg("--test-file") + .arg(&test_file_path) + .output() + .expect("run cli-debugger --run-all"); + + assert!( + output.status.success(), + "expected success for run-all, status={:?}, stderr={}", + output.status.code(), + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("PASS pass_case"), "missing pass_case line: {stdout}"); + assert!(stdout.contains("PASS fail_case"), "missing fail_case line (expected-fail test should still pass): {stdout}"); + assert!(stdout.contains("2 tests: 2 passed, 0 failed"), "missing summary line: {stdout}"); +} + +#[test] +fn cli_debugger_run_all_requires_test_file() { + let output = Command::new(env!("CARGO_BIN_EXE_cli-debugger")) + .arg("--run-all") + .output() + .expect("run cli-debugger --run-all without test file"); + + assert!(!output.status.success(), "expected failure when --test-file is missing"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("--run-all requires --test-file"), "unexpected stderr: {stderr}"); +} + +#[test] +fn cli_debugger_test_file_requires_test_name_in_run_mode() { + let (_script_path, test_file_path) = write_test_fixture(); + + let output = Command::new(env!("CARGO_BIN_EXE_cli-debugger")) + .arg("--run") + .arg("--test-file") + .arg(&test_file_path) + .output() + .expect("run cli-debugger --run --test-file without test-name"); + + assert!(!output.status.success(), "expected failure when --test-name is missing"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("--test-file requires --test-name"), "unexpected stderr: {stderr}"); +} diff --git a/debugger/session/Cargo.toml b/debugger/session/Cargo.toml new file mode 100644 index 0000000..76fa840 --- /dev/null +++ b/debugger/session/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "debugger-session" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +rust-version.workspace = true + +[lib] +name = "debugger_session" +path = "src/lib.rs" + +[dependencies] +silverscript-lang = { path = "../../silverscript-lang" } +kaspa-consensus-core.workspace = true +kaspa-txscript.workspace = true +kaspa-txscript-errors.workspace = true +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +faster-hex = "0.10" + +[dev-dependencies] +kaspa-addresses.workspace = true diff --git a/debugger/session/src/args.rs b/debugger/session/src/args.rs new file mode 100644 index 0000000..e796a54 --- /dev/null +++ b/debugger/session/src/args.rs @@ -0,0 +1,130 @@ +use silverscript_lang::ast::{ContractAst, Expr, ExprKind}; +use silverscript_lang::span; + +pub fn parse_int_arg(raw: &str) -> Result { + let cleaned = raw.replace('_', ""); + if let Some(hex) = cleaned.strip_prefix("0x").or_else(|| cleaned.strip_prefix("0X")) { + return i64::from_str_radix(hex, 16).map_err(|err| format!("invalid hex int '{raw}': {err}")); + } + cleaned.parse::().map_err(|err| format!("invalid int '{raw}': {err}")) +} + +pub fn parse_hex_bytes(raw: &str) -> Result, String> { + let trimmed = raw.trim(); + let hex_str = trimmed.strip_prefix("0x").or_else(|| trimmed.strip_prefix("0X")).unwrap_or(trimmed); + if hex_str.is_empty() { + return Ok(vec![]); + } + let normalized = if hex_str.len() % 2 != 0 { format!("0{hex_str}") } else { hex_str.to_string() }; + if !normalized.chars().all(|ch| ch.is_ascii_hexdigit()) { + return Err(format!("invalid hex bytes '{raw}'")); + } + let mut out = vec![0u8; normalized.len() / 2]; + faster_hex::hex_decode(normalized.as_bytes(), &mut out).map_err(|err| format!("invalid hex '{raw}': {err}"))?; + Ok(out) +} + +pub fn bytes_expr(bytes: Vec) -> Expr<'static> { + Expr::new(ExprKind::Array(bytes.into_iter().map(Expr::byte).collect()), span::Span::default()) +} + +pub fn parse_typed_arg(type_name: &str, raw: &str) -> Result, String> { + if let Some(element_type) = type_name.strip_suffix("[]") { + let trimmed = raw.trim(); + if trimmed.starts_with('[') { + let values = + serde_json::from_str::>(trimmed).map_err(|err| format!("invalid array arg '{raw}': {err}"))?; + let mut out = Vec::with_capacity(values.len()); + for value in values { + let expr = match value { + serde_json::Value::Number(n) => Expr::int(n.as_i64().ok_or_else(|| "invalid int in array".to_string())?), + serde_json::Value::Bool(b) => Expr::bool(b), + serde_json::Value::String(s) => parse_typed_arg(element_type, &s)?, + _ => return Err("unsupported array element (expected number/bool/string)".to_string()), + }; + out.push(expr); + } + return Ok(Expr::new(ExprKind::Array(out), span::Span::default())); + } + if element_type == "byte" { + return Ok(bytes_expr(parse_hex_bytes(trimmed)?)); + } + return Err(format!("unsupported array literal format for '{type_name}'")); + } + + match type_name { + "int" => Ok(Expr::int(parse_int_arg(raw)?)), + "bool" => match raw { + "true" => Ok(Expr::bool(true)), + "false" => Ok(Expr::bool(false)), + _ => Err(format!("invalid bool '{raw}' (expected true/false)")), + }, + "string" => Ok(Expr::string(raw.to_string())), + "byte" => { + let bytes = parse_hex_bytes(raw)?; + if bytes.len() == 1 { Ok(Expr::byte(bytes[0])) } else { Err(format!("byte expects 1 byte, got {}", bytes.len())) } + } + "bytes" => Ok(bytes_expr(parse_hex_bytes(raw)?)), + "pubkey" => { + let bytes = parse_hex_bytes(raw)?; + if bytes.len() != 32 { + return Err(format!("pubkey expects 32 bytes, got {}", bytes.len())); + } + Ok(bytes_expr(bytes)) + } + "sig" => { + let bytes = parse_hex_bytes(raw)?; + if bytes.len() != 65 && bytes.len() != 32 { + return Err(format!("sig expects 65 bytes (or 32-byte secret key for auto-sign), got {}", bytes.len())); + } + Ok(bytes_expr(bytes)) + } + "datasig" => { + let bytes = parse_hex_bytes(raw)?; + if bytes.len() != 64 && bytes.len() != 32 { + return Err(format!("datasig expects 64 bytes (or 32-byte secret key for auto-sign), got {}", bytes.len())); + } + Ok(bytes_expr(bytes)) + } + other => { + let size = other + .strip_prefix("bytes") + .and_then(|v| v.parse::().ok()) + .or_else(|| other.strip_prefix("byte[").and_then(|v| v.strip_suffix(']')).and_then(|v| v.parse::().ok())); + + if let Some(size) = size { + let bytes = parse_hex_bytes(raw)?; + if bytes.len() != size { + return Err(format!("{other} expects {size} bytes, got {}", bytes.len())); + } + Ok(bytes_expr(bytes)) + } else { + Err(format!("unsupported arg type '{other}'")) + } + } + } +} + +pub fn parse_ctor_args(parsed_contract: &ContractAst<'_>, raw_ctor_args: &[String]) -> Result>, String> { + if parsed_contract.params.len() != raw_ctor_args.len() { + return Err(format!("constructor expects {} arguments, got {}", parsed_contract.params.len(), raw_ctor_args.len())); + } + + let mut out = Vec::with_capacity(raw_ctor_args.len()); + for (param, raw) in parsed_contract.params.iter().zip(raw_ctor_args.iter()) { + out.push(parse_typed_arg(¶m.type_ref.type_name(), raw)?); + } + Ok(out) +} + +pub fn parse_call_args(input_types: &[String], raw_args: &[String]) -> Result>, String> { + if input_types.len() != raw_args.len() { + return Err(format!("function expects {} arguments, got {}", input_types.len(), raw_args.len())); + } + + let mut typed_args = Vec::with_capacity(raw_args.len()); + for (input_type, raw) in input_types.iter().zip(raw_args.iter()) { + typed_args.push(parse_typed_arg(input_type, raw)?); + } + Ok(typed_args) +} diff --git a/debugger/session/src/lib.rs b/debugger/session/src/lib.rs new file mode 100644 index 0000000..a97f3ea --- /dev/null +++ b/debugger/session/src/lib.rs @@ -0,0 +1,8 @@ +pub mod args; +pub mod presentation; +pub mod session; +pub mod test_runner; +pub mod util; + +pub use presentation::format_failure_report; +pub use session::{CallStackEntry, FailureFrame, FailureReport}; diff --git a/debugger/session/src/presentation.rs b/debugger/session/src/presentation.rs new file mode 100644 index 0000000..272a554 --- /dev/null +++ b/debugger/session/src/presentation.rs @@ -0,0 +1,177 @@ +use silverscript_lang::debug_info::SourceSpan; + +use crate::session::{DebugValue, FailureReport}; +use crate::util::{decode_i64, encode_hex}; + +#[derive(Debug, Clone)] +pub struct SourceContextLine { + pub line: u32, + pub text: String, + pub is_active: bool, +} + +#[derive(Debug, Clone)] +pub struct SourceContext { + pub lines: Vec, +} + +pub fn build_source_context(source_lines: &[String], span: SourceSpan, radius: usize) -> SourceContext { + let line = span.line.saturating_sub(1) as usize; + let start = line.saturating_sub(radius); + let end = (line + radius).min(source_lines.len().saturating_sub(1)); + + let mut lines = Vec::new(); + for idx in start..=end { + let display_line = idx + 1; + let content = source_lines.get(idx).map(String::as_str).unwrap_or(""); + lines.push(SourceContextLine { line: display_line as u32, text: content.to_string(), is_active: idx == line }); + } + + SourceContext { lines } +} + +pub fn format_value(type_name: &str, value: &DebugValue) -> String { + let element_type = type_name.strip_suffix("[]"); + match (type_name, value) { + ("int", DebugValue::Int(number)) => number.to_string(), + ("bool", DebugValue::Bool(value)) => value.to_string(), + ("string", DebugValue::String(value)) => value.clone(), + (_, DebugValue::Unknown(reason)) => unavailable_reason(reason), + (_, DebugValue::Bytes(bytes)) if element_type.is_some() => { + let element_type = element_type.expect("checked"); + let Some(element_size) = array_element_size(element_type) else { + return format!("0x{}", encode_hex(bytes)); + }; + if element_size == 0 || bytes.len() % element_size != 0 { + return format!("0x{}", encode_hex(bytes)); + } + + let mut values: Vec = Vec::new(); + for chunk in bytes.chunks(element_size) { + let decoded = match element_type { + "int" => DebugValue::Int(decode_i64(chunk).unwrap_or(0)), + "bool" => DebugValue::Bool(decode_i64(chunk).unwrap_or(0) != 0), + _ => DebugValue::Bytes(chunk.to_vec()), + }; + values.push(format_value(element_type, &decoded)); + } + format!("[{}]", values.join(", ")) + } + (_, DebugValue::Bytes(bytes)) => format!("0x{}", encode_hex(bytes)), + (_, DebugValue::Int(number)) => number.to_string(), + (_, DebugValue::Bool(value)) => value.to_string(), + (_, DebugValue::String(value)) => value.clone(), + (_, DebugValue::Array(values)) => { + let value_type = element_type.unwrap_or(type_name); + format!("[{}]", values.iter().map(|v| format_value(value_type, v)).collect::>().join(", ")) + } + } +} + +fn unavailable_reason(reason: &str) -> String { + if reason.trim().is_empty() { + "".to_string() + } else if reason.contains("failed to compile debug expression") + || reason.contains("undefined identifier") + || reason.contains("__arg_") + { + "".to_string() + } else if reason.contains("failed to execute shadow script") { + "".to_string() + } else { + format!("", concise_reason(reason)) + } +} + +/// Truncates error messages to 96 chars for display in debugger UI. +fn concise_reason(reason: &str) -> String { + let trimmed = reason.trim(); + if trimmed.is_empty() { + return "unknown".to_string(); + } + let first_line = trimmed.lines().next().unwrap_or(trimmed); + const MAX_CHARS: usize = 96; + if first_line.chars().count() <= MAX_CHARS { + first_line.to_string() + } else { + let mut out = String::new(); + for ch in first_line.chars().take(MAX_CHARS) { + out.push(ch); + } + out.push_str("..."); + out + } +} + +fn array_element_size(element_type: &str) -> Option { + match element_type { + "int" => Some(8), + "bool" => Some(1), + "byte" => Some(1), + other => other.strip_prefix("bytes").and_then(|v| v.parse::().ok()), + } +} + +/// Renders a `FailureReport` in a Rust-style diagnostic format. +pub fn format_failure_report(report: &FailureReport, format_var: &dyn Fn(&str, &DebugValue) -> String) -> String { + let source_lines: Vec<&str> = report.source_text.lines().collect(); + let mut out = String::new(); + + let max_line = report.frames.iter().filter_map(|f| f.span.map(|s| s.line)).max().unwrap_or(1); + let w = format!("{max_line}").len().max(2); + let pad = " ".repeat(w); + + out.push_str(&format!("error: {}\n", report.message)); + + for (frame_idx, frame) in report.frames.iter().enumerate() { + let Some(span) = frame.span else { + continue; + }; + + let line_idx = span.line.saturating_sub(1) as usize; + + if frame_idx == 0 { + out.push_str(&format!("{pad} --> {}:{}\n", span.line, span.col)); + } else { + out.push_str(&format!("{pad} ::: called from {}\n", frame.function_name)); + } + + out.push_str(&format!("{pad} |\n")); + + if line_idx > 0 { + if let Some(prev) = source_lines.get(line_idx - 1) { + out.push_str(&format!("{:>w$} | {prev}\n", span.line - 1)); + } + } + + if let Some(line_text) = source_lines.get(line_idx) { + out.push_str(&format!("{:>w$} | {line_text}\n", span.line)); + + let start_col = span.col.saturating_sub(1) as usize; + let end_col = if span.end_line == span.line && span.end_col > span.col { + span.end_col.saturating_sub(1) as usize + } else { + line_text.len() + }; + let underline_len = end_col.saturating_sub(start_col).max(1); + let marker_pad = " ".repeat(start_col); + let underline = "^".repeat(underline_len); + let label = if frame_idx == 0 { " verification failed here" } else { " in this call" }; + out.push_str(&format!("{pad} | {marker_pad}{underline}{label}\n")); + + if !frame.variables.is_empty() { + let vars_str = frame + .variables + .iter() + .map(|var| format!("{} = {}", var.name, format_var(&var.type_name, &var.value))) + .collect::>() + .join(", "); + out.push_str(&format!("{pad} | {vars_str}\n")); + } + } + + out.push_str(&format!("{pad} |\n")); + } + + out +} diff --git a/debugger/session/src/session.rs b/debugger/session/src/session.rs new file mode 100644 index 0000000..511ef46 --- /dev/null +++ b/debugger/session/src/session.rs @@ -0,0 +1,1122 @@ +use std::collections::{HashMap, HashSet}; + +use kaspa_consensus_core::hashing::sighash::SigHashReusedValuesUnsync; +use kaspa_consensus_core::tx::{PopulatedTransaction, TransactionInput, UtxoEntry}; +use kaspa_txscript::caches::Cache; +use kaspa_txscript::covenants::CovenantsContext; +use kaspa_txscript::script_builder::ScriptBuilder; +use kaspa_txscript::{DynOpcodeImplementation, EngineCtx, EngineFlags, TxScriptEngine, parse_script}; +use serde::{Deserialize, Serialize}; + +use silverscript_lang::ast::{Expr, ExprKind}; +use silverscript_lang::compiler::compile_debug_expr; +use silverscript_lang::debug_info::{ + DebugFunctionRange, DebugInfo, DebugParamMapping, DebugStep, DebugVariableUpdate, SourceSpan, StepId, StepKind, +}; + +pub use crate::presentation::{SourceContext, SourceContextLine}; +use crate::presentation::{build_source_context, format_value as format_debug_value}; +use crate::util::{decode_i64, encode_hex}; + +pub type DebugTx<'a> = PopulatedTransaction<'a>; +pub type DebugReused = SigHashReusedValuesUnsync; +pub type DebugOpcode<'a> = DynOpcodeImplementation, DebugReused>; +pub type DebugEngine<'a> = TxScriptEngine<'a, DebugTx<'a>, DebugReused>; + +#[derive(Clone, Copy)] +pub struct ShadowTxContext<'a> { + pub tx: &'a DebugTx<'a>, + pub input: &'a TransactionInput, + pub input_index: usize, + pub utxo_entry: &'a UtxoEntry, + pub covenants_ctx: &'a CovenantsContext, +} + +#[derive(Debug, Clone)] +pub enum DebugValue { + Int(i64), + Bool(bool), + Bytes(Vec), + String(String), + Array(Vec), + /// Value could not be evaluated (for example unresolved identifiers or shadow VM failures). + Unknown(std::string::String), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VariableOrigin { + Local, + Param, + Constant, +} + +impl VariableOrigin { + pub fn label(self) -> &'static str { + match self { + Self::Local => "local", + Self::Param => "arg", + Self::Constant => "const", + } + } +} + +#[derive(Debug, Clone)] +pub struct Variable { + pub name: String, + pub type_name: String, + pub value: DebugValue, + pub is_constant: bool, + pub origin: VariableOrigin, +} + +#[derive(Debug, Clone)] +pub struct SessionState<'i> { + pub pc: usize, + pub opcode: Option, + pub step: Option>, + pub stack: Vec, +} + +#[derive(Debug, Clone)] +pub struct CallStackEntry { + pub callee_name: String, + pub call_site_span: Option, + /// Sequence of the InlineCallEnter step (caller's context). + pub sequence: u32, + /// Frame ID of the InlineCallEnter step (caller's frame). + pub frame_id: u32, +} + +#[derive(Debug, Clone)] +pub struct FailureFrame { + pub function_name: String, + /// Source location: failure site for innermost frame, call-site for callers. + pub span: Option, + pub variables: Vec, +} + +#[derive(Debug, Clone)] +pub struct FailureReport { + /// Human-readable description, e.g. "require() failed". + pub message: String, + /// Innermost frame first. + pub frames: Vec, + /// Full source text for rendering context lines. + pub source_text: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StackSnapshot { + pub dstack: Vec, + pub astack: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OpcodeMeta<'i> { + pub index: usize, + pub byte_offset: usize, + pub display: String, + pub step: Option>, +} + +pub struct DebugSession<'a, 'i> { + engine: DebugEngine<'a>, + shadow_tx_context: Option>, + opcodes: Vec>>, + op_displays: Vec, + opcode_offsets: Vec, + script_len: usize, + pc: usize, + debug_info: DebugInfo<'i>, + step_order: Vec, + current_step_index: Option, + source_lines: Vec, + breakpoints: HashSet, + // Source-level step ids that were already visited in this session. + executed_steps: HashSet, +} + +struct ShadowParamValue { + name: String, + type_name: String, + stack_index: i64, + value: Vec, +} + +struct VariableContext<'a> { + function_name: &'a str, + function_start: usize, + function_end: usize, + offset: usize, + step_id: StepId, +} + +impl<'a, 'i> DebugSession<'a, 'i> { + // --- Session construction + stepping --- + + /// Creates a debug session simulating a full transaction spend. + /// Executes sigscript first to seed the stack, then debugs lockscript execution. + pub fn full( + sigscript: &[u8], + lockscript: &[u8], + source: &str, + debug_info: Option>, + mut engine: DebugEngine<'a>, + ) -> Result { + seed_engine_with_sigscript(&mut engine, sigscript)?; + Self::from_scripts(lockscript, source, debug_info, engine) + } + + /// Internal constructor: parses script, prepares opcodes, extracts statement steps. + pub fn from_scripts( + script: &[u8], + source: &str, + debug_info: Option>, + engine: DebugEngine<'a>, + ) -> Result { + let debug_info = debug_info.unwrap_or_else(DebugInfo::empty); + let opcodes = parse_script::, DebugReused>(script).collect::, _>>()?; + let op_displays = opcodes.iter().map(|op| format!("{op:?}")).collect(); + let opcodes: Vec>> = opcodes.into_iter().map(Some).collect(); + let source_lines: Vec = source.lines().map(String::from).collect(); + let (opcode_offsets, script_len) = build_opcode_offsets(&opcodes); + + let mut step_order: Vec = (0..debug_info.steps.len()).collect(); + // Overlapping inline ranges can share the same bytecode offsets; keep + // compiler emission order via sequence before comparing range width. + step_order.sort_by_key(|&index| { + let step = &debug_info.steps[index]; + (step.bytecode_start, step.sequence, step_kind_order(&step.kind), step.call_depth, step.bytecode_end, step.frame_id) + }); + + Ok(Self { + engine, + shadow_tx_context: None, + opcodes, + op_displays, + opcode_offsets, + script_len, + pc: 0, + debug_info, + step_order, + current_step_index: None, + source_lines, + breakpoints: HashSet::new(), + executed_steps: HashSet::new(), + }) + } + + /// Executes a single opcode and advances the program counter. + pub fn step_opcode(&mut self) -> Result>, kaspa_txscript_errors::TxScriptError> { + if self.pc >= self.opcodes.len() { + return Ok(None); + } + + let opcode = self.opcodes[self.pc].take().expect("opcode already executed"); + self.engine.execute_opcode(opcode)?; + self.pc += 1; + self.sync_step_cursor_to_current_offset(); + Ok(Some(self.state())) + } + + pub fn with_shadow_tx_context(mut self, shadow_tx_context: ShadowTxContext<'a>) -> Self { + self.shadow_tx_context = Some(shadow_tx_context); + self + } + + /// Step into: advance to next source step regardless of call depth. + pub fn step_into(&mut self) -> Result>, kaspa_txscript_errors::TxScriptError> { + self.step_with_depth_predicate(|_, _| true) + } + + /// Step over: advance to next source step at the same or shallower call depth. + pub fn step_over(&mut self) -> Result>, kaspa_txscript_errors::TxScriptError> { + self.step_with_depth_predicate(|candidate, current| candidate <= current) + } + + /// Step out: advance to next source step at a shallower call depth. + pub fn step_out(&mut self) -> Result>, kaspa_txscript_errors::TxScriptError> { + self.step_with_depth_predicate(|candidate, current| candidate < current) + } + + /// Shared stepping loop for `step_into`, `step_over`, and `step_out`. + /// Picks the next steppable step whose call depth satisfies `predicate`, + /// executes opcodes until that step becomes active, and skips candidates + /// that are already behind the current byte offset (for example, non-taken + /// branch steps). + fn step_with_depth_predicate( + &mut self, + predicate: impl Fn(u32, u32) -> bool, + ) -> Result>, kaspa_txscript_errors::TxScriptError> { + if self.step_order.is_empty() { + return self.step_opcode(); + } + + let current_depth = self.current_timeline_step().map(|step| step.call_depth).unwrap_or(0); + let mut search_from = self.current_step_index; + + loop { + let Some(target_index) = self.next_steppable_step_index(search_from, |step| predicate(step.call_depth, current_depth)) + else { + while self.step_opcode()?.is_some() {} + return Ok(None); + }; + + if self.advance_to_step(target_index)? { + self.current_step_index = Some(target_index); + self.mark_step_executed(target_index); + return Ok(Some(self.state())); + } + + search_from = Some(target_index); + } + } + + fn advance_to_step(&mut self, target_index: usize) -> Result { + let Some(target) = self.step_at_order(target_index) else { + return Ok(false); + }; + let (target_start, target_end) = (target.bytecode_start, target.bytecode_end); + loop { + let offset = self.current_byte_offset(); + + if offset > target_start { + return Ok(false); + } + + if range_matches_offset(target_start, target_end, offset) && self.engine.is_executing() { + return Ok(true); + } + + if self.step_opcode()?.is_none() { + return Ok(false); + } + } + } + + /// Advances execution to the first user statement, skipping dispatcher/synthetic bytecode. + /// Call this after session creation to skip over contract setup code. + /// Skips opcodes until the first source step is encountered. + pub fn run_to_first_executed_statement(&mut self) -> Result<(), kaspa_txscript_errors::TxScriptError> { + if self.step_order.is_empty() { + return Ok(()); + } + loop { + if self.pc >= self.opcodes.len() { + return Ok(()); + } + let offset = self.current_byte_offset(); + if self.engine.is_executing() { + if let Some(index) = self.steppable_step_index_for_offset(offset) { + self.current_step_index = Some(index); + self.mark_step_executed(index); + return Ok(()); + } + } + if self.step_opcode()?.is_none() { + return Ok(()); + } + } + } + + /// Continues execution until a breakpoint is hit or script completes. + pub fn continue_to_breakpoint(&mut self) -> Result>, kaspa_txscript_errors::TxScriptError> { + if self.breakpoints.is_empty() { + while self.step_opcode()?.is_some() {} + return Ok(None); + } + loop { + if self.step_into()?.is_none() { + return Ok(None); + } + if let Some(step) = self.current_timeline_step() { + if self.step_hits_breakpoint(step) { + return Ok(Some(self.state())); + } + } + } + } + + /// Returns the current execution state snapshot. + pub fn state(&self) -> SessionState<'i> { + let executed = self.pc.saturating_sub(1); + let opcode = self.op_displays.get(executed).cloned(); + SessionState { pc: self.pc, opcode, step: self.current_step(), stack: self.stack() } + } + + /// Returns true if the script engine is still running. + pub fn is_executing(&self) -> bool { + self.engine.is_executing() + } + + pub fn debug_info(&self) -> &DebugInfo<'i> { + &self.debug_info + } + + // --- Step + source context --- + + /// Returns source lines around the current statement (radius = 6 lines). + /// Returns surrounding source lines with the current line highlighted. + pub fn source_context(&self) -> Option { + let span = self.current_span()?; + Some(build_source_context(&self.source_lines, span, 6)) + } + + /// Adds a breakpoint at the given line number. Returns true if added. + pub fn add_breakpoint(&mut self, line: u32) -> bool { + let valid = self + .step_order + .iter() + .filter_map(|&index| self.debug_info.steps.get(index)) + .any(|step| self.is_steppable_step(step) && line >= step.span.line && line <= step.span.end_line); + if valid { + self.breakpoints.insert(line); + } + valid + } + + /// Resolves a requested source line to a steppable line, preferring exact + /// hits then the next steppable line. + pub fn resolve_breakpoint_line(&self, line: u32) -> Option { + let mut next: Option = None; + for step in self.step_order.iter().filter_map(|&index| self.debug_info.steps.get(index)) { + if !self.is_steppable_step(step) { + continue; + } + if line >= step.span.line && line <= step.span.end_line { + return Some(line); + } + if step.span.line > line { + match next { + Some(current) if current <= step.span.line => {} + _ => next = Some(step.span.line), + } + } + } + next + } + + /// Resolves and adds a breakpoint. Returns the actual line if set. + pub fn add_breakpoint_resolved(&mut self, line: u32) -> Option { + let resolved = self.resolve_breakpoint_line(line)?; + self.breakpoints.insert(resolved); + Some(resolved) + } + + /// Returns all currently set breakpoint line numbers. + pub fn breakpoints(&self) -> Vec { + let mut lines = self.breakpoints.iter().copied().collect::>(); + lines.sort_unstable(); + lines + } + + /// Removes the breakpoint at the given line number. + pub fn clear_breakpoint(&mut self, line: u32) { + self.breakpoints.remove(&line); + } + + // --- Variable inspection --- + + /// Returns all variables in scope at current execution point. + /// Includes params, local variables (up to current offset), and constructor constants. + /// Values are computed via shadow VM evaluation. + pub fn list_variables(&self) -> Result, String> { + self.collect_variables(self.current_step_id()) + } + + pub fn list_variables_at_sequence(&self, sequence: u32, frame_id: u32) -> Result, String> { + self.collect_variables(StepId::new(sequence, frame_id)) + } + + fn collect_variables(&self, step_id: StepId) -> Result, String> { + let context = self.current_variable_context(step_id)?; + let mut variables = self.collect_variables_map(&context)?.into_values().collect::>(); + variables.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(variables) + } + + /// Returns a specific variable by name, or error if not in scope. + pub fn variable_by_name(&self, name: &str) -> Result { + let context = self.current_variable_context(self.current_step_id())?; + let variables = self.collect_variables_map(&context)?; + variables.get(name).cloned().ok_or_else(|| format!("unknown variable '{name}'")) + } + + // --- DebugValue formatting --- + /// Formats a debug value for display based on its type. + pub fn format_value(&self, type_name: &str, value: &DebugValue) -> String { + format_debug_value(type_name, value) + } + + /// Returns the debug step for the current bytecode position. + pub fn current_step(&self) -> Option> { + self.current_timeline_step().cloned().or_else(|| self.step_for_offset(self.current_byte_offset()).cloned()) + } + + /// Returns the current bytecode offset in the script. + pub fn current_byte_offset(&self) -> usize { + self.opcode_offsets.get(self.pc).copied().unwrap_or(self.script_len) + } + + /// Returns the source span (line/col range) at the current position. + pub fn current_span(&self) -> Option { + self.current_step().map(|step| step.span) + } + + pub fn call_stack(&self) -> Vec { + let mut stack = Vec::new(); + let Some(current) = self.current_step_index else { + return stack; + }; + for order_index in 0..=current { + let Some(step) = self.step_at_order(order_index) else { + continue; + }; + match &step.kind { + StepKind::InlineCallEnter { callee } => stack.push(callee.clone()), + StepKind::InlineCallExit { .. } => { + stack.pop(); + } + _ => {} + } + } + stack + } + + /// Returns the active inline call stack with source spans and frame identity. + pub fn call_stack_with_spans(&self) -> Vec { + let mut stack = Vec::new(); + let Some(current) = self.current_step_index else { + return stack; + }; + for order_index in 0..=current { + let Some(step) = self.step_at_order(order_index) else { + continue; + }; + match &step.kind { + StepKind::InlineCallEnter { callee } => stack.push(CallStackEntry { + callee_name: callee.clone(), + call_site_span: Some(step.span), + sequence: step.sequence, + frame_id: step.frame_id, + }), + StepKind::InlineCallExit { .. } => { + stack.pop(); + } + _ => {} + } + } + stack + } + + /// Returns the name of the function currently being executed. + pub fn current_function_name(&self) -> Option<&str> { + self.current_function_range().map(|range| range.name.as_str()) + } + + fn current_function_range(&self) -> Option<&DebugFunctionRange> { + let offset = self.current_byte_offset(); + self.debug_info.functions.iter().find(|function| offset >= function.bytecode_start && offset < function.bytecode_end) + } + + fn current_variable_updates(&self, context: &VariableContext<'_>) -> HashMap> { + let mut latest_by_name: HashMap)> = HashMap::new(); + for step in self.debug_info.steps.iter().filter(|step| self.step_updates_are_visible(step, context)) { + for update in &step.variable_updates { + match latest_by_name.get(&update.name) { + Some((existing_sequence, _)) if *existing_sequence > step.sequence => {} + _ => { + latest_by_name.insert(update.name.clone(), (step.sequence, update)); + } + } + } + } + latest_by_name.into_iter().map(|(name, (_, update))| (name, update)).collect() + } + + fn current_variable_context(&self, step_id: StepId) -> Result, String> { + let function = self.current_function_range().ok_or_else(|| "No function context available".to_string())?; + Ok(VariableContext { + function_name: function.name.as_str(), + function_start: function.bytecode_start, + function_end: function.bytecode_end, + offset: self.current_byte_offset(), + step_id, + }) + } + + fn collect_variables_map(&self, context: &VariableContext<'_>) -> Result, String> { + let mut variables: HashMap = HashMap::new(); + let var_updates = self.current_variable_updates(context); + + for (name, update) in &var_updates { + if is_inline_synthetic_name(name) { + continue; + } + let value = + self.evaluate_update_with_shadow_vm(context.function_name, update, &var_updates).unwrap_or_else(DebugValue::Unknown); + variables.insert( + name.clone(), + Variable { + name: name.clone(), + type_name: update.type_name.clone(), + value, + is_constant: false, + origin: VariableOrigin::Local, + }, + ); + } + + for param in self.debug_info.params.iter().filter(|param| param.function == context.function_name) { + if variables.contains_key(¶m.name) { + continue; + } + let value = self.read_param_value(param)?; + variables.insert( + param.name.clone(), + Variable { + name: param.name.clone(), + type_name: param.type_name.clone(), + value, + is_constant: false, + origin: VariableOrigin::Param, + }, + ); + } + + // Contract constants are contract-scoped, not frame-scoped, so they + // remain visible while stepping through inline callee frames. + for constant in &self.debug_info.constants { + if variables.contains_key(&constant.name) { + continue; + } + variables.insert( + constant.name.clone(), + Variable { + name: constant.name.clone(), + type_name: constant.type_name.clone(), + value: self.evaluate_constant(&constant.value), + is_constant: true, + origin: VariableOrigin::Constant, + }, + ); + } + + Ok(variables) + } + + fn step_updates_are_visible(&self, step: &DebugStep<'i>, context: &VariableContext<'_>) -> bool { + if step.bytecode_start < context.function_start || step.bytecode_start >= context.function_end { + return false; + } + // Stay in the active inline frame and only consider updates from + // steps already executed in this session. + let step_id = step.id(); + step_id.frame_id == context.step_id.frame_id + && self.executed_steps.contains(&step_id) + && step_id.sequence < context.step_id.sequence + && step.bytecode_end <= context.offset + } + + /// Returns the most specific step for `offset`. + /// Multiple steps may overlap; choosing the narrowest bytecode span makes + /// location lookups prefer inner statement/inline ranges over broader ranges. + fn step_for_offset(&self, offset: usize) -> Option<&DebugStep<'i>> { + let mut best: Option<&DebugStep<'i>> = None; + let mut best_len = usize::MAX; + for step in &self.debug_info.steps { + if step_matches_offset(step, offset) { + let len = step.bytecode_end.saturating_sub(step.bytecode_start); + if len < best_len { + best = Some(step); + best_len = len; + } + } + } + best + } + + fn step_at_order(&self, order_index: usize) -> Option<&DebugStep<'i>> { + let step_index = *self.step_order.get(order_index)?; + self.debug_info.steps.get(step_index) + } + + fn current_timeline_step(&self) -> Option<&DebugStep<'i>> { + self.current_step_index.and_then(|index| self.step_at_order(index)) + } + + fn current_step_id(&self) -> StepId { + self.current_timeline_step().map(DebugStep::id).unwrap_or(StepId::ROOT) + } + + fn mark_step_executed(&mut self, step_index: usize) { + if let Some(step) = self.step_at_order(step_index) { + self.executed_steps.insert(step.id()); + } + } + + fn sync_step_cursor_to_current_offset(&mut self) { + let offset = self.current_byte_offset(); + if let Some(index) = self.steppable_step_index_for_offset(offset) { + if self.current_step_index.is_some_and(|current| index < current) { + // In sequence mode multiple steps may resolve to the same byte offset. + // Keep cursor monotonic and avoid snapping backward to an earlier + // step for that offset. + return; + } + // `si` executes raw opcodes; keep statement cursor in sync so later + // source-level steps (`next`/`step`/`finish`) start from the real + // current step instead of an old one. + self.current_step_index = Some(index); + self.mark_step_executed(index); + } + } + + fn is_steppable_step(&self, step: &DebugStep<'i>) -> bool { + // InlineCallEnter is steppable so `step_into` can land on a call + // boundary and build call-stack transitions. InlineCallExit is not + // steppable to avoid synthetic extra stops while unwinding. + matches!(&step.kind, StepKind::Source {} | StepKind::InlineCallEnter { .. }) + } + + fn steppable_step_index_for_offset(&self, offset: usize) -> Option { + self.step_order.iter().enumerate().find_map(|(order_index, &step_index)| { + let step = self.debug_info.steps.get(step_index)?; + (self.is_steppable_step(step) && step_matches_offset(step, offset)).then_some(order_index) + }) + } + + fn next_steppable_step_index(&self, from: Option, predicate: impl Fn(&DebugStep<'i>) -> bool) -> Option { + let start = from.map(|index| index.saturating_add(1)).unwrap_or(0); + for index in start..self.step_order.len() { + let step = self.step_at_order(index)?; + if !self.is_steppable_step(step) { + continue; + } + if predicate(step) { + return Some(index); + } + } + None + } + + fn step_hits_breakpoint(&self, step: &DebugStep<'i>) -> bool { + (step.span.line..=step.span.end_line).any(|line| self.breakpoints.contains(&line)) + } + + /// Returns the current main stack as hex-encoded strings. + pub fn stack(&self) -> Vec { + let stacks = self.engine.stacks(); + stacks.dstack.iter().map(|item| encode_hex(item)).collect() + } + + /// Returns both main and alt stacks as hex strings. + pub fn stack_snapshot(&self) -> StackSnapshot { + let stacks = self.engine.stacks(); + StackSnapshot { + dstack: stacks.dstack.iter().map(|item| encode_hex(item)).collect(), + astack: stacks.astack.iter().map(|item| encode_hex(item)).collect(), + } + } + + /// Returns bytecode/opcode metadata aligned with source steps. + pub fn opcode_metas(&self) -> Vec> { + self.op_displays + .iter() + .enumerate() + .map(|(index, display)| OpcodeMeta { + index, + byte_offset: self.opcode_offsets.get(index).copied().unwrap_or(self.script_len), + display: display.clone(), + step: self.step_for_offset(self.opcode_offsets.get(index).copied().unwrap_or(self.script_len)).cloned(), + }) + .collect() + } + + /// Builds a structured failure report suitable for CLI/DAP rendering. + pub fn build_failure_report(&self, error: &kaspa_txscript_errors::TxScriptError) -> FailureReport { + let failure_span = self.current_span(); + let call_stack = self.call_stack_with_spans(); + let innermost_function = self.current_function_name().unwrap_or("").to_string(); + let innermost_vars: Vec = self.list_variables().unwrap_or_default().into_iter().filter(|v| !v.is_constant).collect(); + + let mut frames = + vec![FailureFrame { function_name: innermost_function.clone(), span: failure_span, variables: innermost_vars }]; + + let entry_name = self.current_function_name().unwrap_or("").to_string(); + for idx in (0..call_stack.len()).rev() { + let entry = &call_stack[idx]; + let caller_vars: Vec = self + .list_variables_at_sequence(entry.sequence, entry.frame_id) + .unwrap_or_default() + .into_iter() + .filter(|v| !v.is_constant) + .collect(); + let caller_name = if idx == 0 { entry_name.clone() } else { call_stack[idx - 1].callee_name.clone() }; + frames.push(FailureFrame { function_name: caller_name, span: entry.call_site_span, variables: caller_vars }); + } + + FailureReport { message: format!("{error}"), frames, source_text: self.source_lines.join("\n") } + } + + /// Evaluates an expression using shadow VM execution. + /// + /// Strategy: compile the pre-resolved expression to bytecode, build a mini-script + /// that pushes current param values then executes the bytecode, run on fresh VM, + /// read result from top of stack. This guarantees debugger sees same semantics as + /// real execution without duplicating evaluation logic. + fn evaluate_update_with_shadow_vm( + &self, + function_name: &str, + update: &DebugVariableUpdate<'i>, + updates: &HashMap>, + ) -> Result { + let params = self.shadow_param_values(function_name)?; + let type_name = &update.type_name; + let expr = &update.expr; + let mut param_indexes = HashMap::new(); + let mut param_types = HashMap::new(); + for param in ¶ms { + param_indexes.insert(param.name.clone(), param.stack_index); + param_types.insert(param.name.clone(), param.type_name.clone()); + } + let mut env: HashMap> = HashMap::new(); + let mut eval_types = param_types; + for (name, update) in updates { + env.insert((*name).clone(), update.expr.clone()); + eval_types.insert((*name).clone(), update.type_name.clone()); + } + let bytecode = compile_debug_expr(expr, &env, ¶m_indexes, &eval_types) + .map_err(|err| format!("failed to compile debug expression: {err}"))?; + let script = self.build_shadow_script(¶ms, &bytecode)?; + let bytes = self.execute_shadow_script(&script)?; + decode_value_by_type(type_name, bytes) + } + + fn shadow_param_values(&self, function_name: &str) -> Result, String> { + let mut params = Vec::new(); + for param in self.debug_info.params.iter().filter(|param| param.function == function_name) { + params.push(ShadowParamValue { + name: param.name.clone(), + type_name: param.type_name.clone(), + stack_index: param.stack_index, + value: self.read_stack_at_index(param.stack_index)?, + }); + } + // Push higher stack indexes first so index 0 remains the top parameter. + params.sort_by(|left, right| right.stack_index.cmp(&left.stack_index)); + Ok(params) + } + + fn build_shadow_script(&self, params: &[ShadowParamValue], expr_bytecode: &[u8]) -> Result, String> { + let mut builder = ScriptBuilder::new(); + for param in params { + builder.add_data(¶m.value).map_err(|err| err.to_string())?; + } + builder.add_ops(expr_bytecode).map_err(|err| err.to_string())?; + Ok(builder.drain()) + } + + fn execute_shadow_script(&self, script: &[u8]) -> Result, String> { + let sig_cache = Cache::new(0); + let reused_values = SigHashReusedValuesUnsync::new(); + let mut engine: DebugEngine<'_> = if let Some(shadow) = self.shadow_tx_context { + let ctx = EngineCtx::new(&sig_cache).with_reused(&reused_values).with_covenants_ctx(shadow.covenants_ctx); + TxScriptEngine::from_transaction_input( + shadow.tx, + shadow.input, + shadow.input_index, + shadow.utxo_entry, + ctx, + EngineFlags { covenants_enabled: true }, + ) + } else { + TxScriptEngine::new(EngineCtx::new(&sig_cache).with_reused(&reused_values), EngineFlags { covenants_enabled: true }) + }; + for opcode in parse_script::, DebugReused>(script) { + let opcode = opcode.map_err(|err| format!("failed to parse shadow script: {err}"))?; + engine.execute_opcode(opcode).map_err(|err| format!("failed to execute shadow script: {err}"))?; + } + engine.stacks().dstack.last().cloned().ok_or_else(|| "shadow VM produced an empty stack".to_string()) + } + + fn read_param_value(&self, param: &DebugParamMapping) -> Result { + let bytes = self.read_stack_at_index(param.stack_index)?; + decode_value_by_type(¶m.type_name, bytes) + } + + fn evaluate_constant(&self, expr: &Expr<'i>) -> DebugValue { + match &expr.kind { + ExprKind::Int(v) => DebugValue::Int(*v), + ExprKind::Bool(v) => DebugValue::Bool(*v), + ExprKind::Byte(v) => DebugValue::Bytes(vec![*v]), + ExprKind::String(v) => DebugValue::String(v.clone()), + _ => DebugValue::Unknown("complex expression".to_string()), + } + } + + fn read_stack_at_index(&self, index: i64) -> Result, String> { + if index < 0 { + return Err("negative stack index".to_string()); + } + let stacks = self.engine.stacks(); + let stack = stacks.dstack; + let idx = index as usize; + if idx >= stack.len() { + return Err("stack index out of range".to_string()); + } + let stack_index = stack.len() - 1 - idx; + Ok(stack.get(stack_index).cloned().unwrap_or_default()) + } +} + +/// Decodes raw bytes into a typed debug value based on the type name. +fn decode_value_by_type(type_name: &str, bytes: Vec) -> Result { + match type_name { + "int" => Ok(DebugValue::Int(decode_i64(&bytes)?)), + "bool" => Ok(DebugValue::Bool(decode_i64(&bytes)? != 0)), + "string" => match String::from_utf8(bytes.clone()) { + Ok(value) => Ok(DebugValue::String(value)), + Err(_) => Ok(DebugValue::Bytes(bytes)), + }, + _ => Ok(DebugValue::Bytes(bytes)), + } +} + +/// Executes sigscript to seed the stack before debugging lockscript. +fn seed_engine_with_sigscript(engine: &mut DebugEngine<'_>, sigscript: &[u8]) -> Result<(), kaspa_txscript_errors::TxScriptError> { + for opcode in parse_script::, DebugReused>(sigscript) { + engine.execute_opcode(opcode?)?; + } + Ok(()) +} + +fn build_opcode_offsets(opcodes: &[Option>]) -> (Vec, usize) { + let mut offsets = Vec::with_capacity(opcodes.len() + 1); + let mut offset = 0usize; + for opcode in opcodes { + offsets.push(offset); + if let Some(op) = opcode { + offset = offset.saturating_add(op.serialize().len()); + } + } + (offsets, offset) +} + +fn step_kind_order(kind: &StepKind) -> u8 { + match kind { + StepKind::InlineCallEnter { .. } => 0, + StepKind::Source {} => 1, + StepKind::InlineCallExit { .. } => 2, + } +} + +fn range_matches_offset(bytecode_start: usize, bytecode_end: usize, offset: usize) -> bool { + if bytecode_start == bytecode_end { offset == bytecode_start } else { offset >= bytecode_start && offset < bytecode_end } +} + +fn step_matches_offset(step: &DebugStep<'_>, offset: usize) -> bool { + range_matches_offset(step.bytecode_start, step.bytecode_end, offset) +} + +fn is_inline_synthetic_name(name: &str) -> bool { + name.starts_with("__arg_") +} + +#[cfg(test)] +mod tests { + use super::*; + + use silverscript_lang::ast::{BinaryOp, Expr, ExprKind}; + use silverscript_lang::debug_info::{ + DebugConstantMapping, DebugFunctionRange, DebugInfo, DebugParamMapping, DebugStep, DebugVariableUpdate, SourceSpan, StepKind, + }; + use silverscript_lang::span; + + fn make_session( + params: Vec, + steps: Vec>, + sigscript: &[u8], + ) -> Result, kaspa_txscript_errors::TxScriptError> { + let sig_cache = Box::leak(Box::new(Cache::new(10_000))); + let reused_values: &'static SigHashReusedValuesUnsync = Box::leak(Box::new(SigHashReusedValuesUnsync::new())); + let engine: DebugEngine<'static> = + TxScriptEngine::new(EngineCtx::new(sig_cache).with_reused(reused_values), EngineFlags { covenants_enabled: true }); + let debug_info = DebugInfo { + source: String::new(), + steps, + params, + functions: vec![DebugFunctionRange { name: "f".to_string(), bytecode_start: 0, bytecode_end: 1 }], + constants: vec![DebugConstantMapping { name: "K".to_string(), type_name: "int".to_string(), value: Expr::int(7) }], + }; + DebugSession::full(sigscript, &[], "", Some(debug_info), engine) + } + + #[test] + fn decode_i64_handles_basic_values() { + assert_eq!(decode_i64(&[]).unwrap(), 0); + assert_eq!(decode_i64(&[1]).unwrap(), 1); + assert_eq!(decode_i64(&[0x81]).unwrap(), -1); + assert_eq!(decode_i64(&[0, 0x80]).unwrap(), 0); + } + + #[test] + fn shadow_vm_evaluates_param_expression() { + let mut sig_builder = ScriptBuilder::new(); + sig_builder.add_i64(3).unwrap(); + sig_builder.add_i64(9).unwrap(); + let sigscript = sig_builder.drain(); + + let session = make_session( + vec![ + DebugParamMapping { name: "a".to_string(), type_name: "int".to_string(), stack_index: 1, function: "f".to_string() }, + DebugParamMapping { name: "b".to_string(), type_name: "int".to_string(), stack_index: 0, function: "f".to_string() }, + ], + vec![], + &sigscript, + ) + .unwrap(); + + let update = DebugVariableUpdate { + name: "x".to_string(), + type_name: "int".to_string(), + expr: Expr::new( + ExprKind::Binary { op: BinaryOp::Add, left: Box::new(Expr::identifier("a")), right: Box::new(Expr::identifier("b")) }, + span::Span::default(), + ), + }; + let value = session.evaluate_update_with_shadow_vm("f", &update, &HashMap::new()).unwrap(); + assert!(matches!(value, DebugValue::Int(12))); + } + + #[test] + fn list_variables_returns_unknown_for_uncompilable_expr() { + let mut sig_builder = ScriptBuilder::new(); + sig_builder.add_i64(5).unwrap(); + let sigscript = sig_builder.drain(); + + let mut session = make_session( + vec![DebugParamMapping { name: "a".to_string(), type_name: "int".to_string(), stack_index: 0, function: "f".to_string() }], + vec![DebugStep { + bytecode_start: 0, + bytecode_end: 0, + span: SourceSpan { line: 1, col: 1, end_line: 1, end_col: 1 }, + kind: StepKind::Source {}, + sequence: 0, + call_depth: 0, + frame_id: 0, + variable_updates: vec![DebugVariableUpdate { + name: "x".to_string(), + type_name: "int".to_string(), + expr: Expr::identifier("missing"), + }], + }], + &sigscript, + ) + .unwrap(); + + session.executed_steps.insert(StepId { sequence: 0, frame_id: 0 }); + // In sequence-only mode, query visibility at an explicit sequence that + // is after the update's sequence. + let vars = session.list_variables_at_sequence(1, 0).unwrap(); + let x = vars.into_iter().find(|var| var.name == "x").expect("x variable"); + assert!(matches!(x.value, DebugValue::Unknown(_))); + } + + #[test] + fn list_variables_hides_inline_synthetics_but_uses_them_for_shadow_eval() { + let mut sig_builder = ScriptBuilder::new(); + sig_builder.add_i64(5).unwrap(); + let sigscript = sig_builder.drain(); + + let mut session = make_session( + vec![DebugParamMapping { name: "a".to_string(), type_name: "int".to_string(), stack_index: 0, function: "f".to_string() }], + vec![DebugStep { + bytecode_start: 0, + bytecode_end: 0, + span: SourceSpan { line: 1, col: 1, end_line: 1, end_col: 1 }, + kind: StepKind::Source {}, + sequence: 0, + call_depth: 0, + frame_id: 0, + variable_updates: vec![ + DebugVariableUpdate { name: "__arg_f_0".to_string(), type_name: "int".to_string(), expr: Expr::identifier("a") }, + DebugVariableUpdate { + name: "x".to_string(), + type_name: "int".to_string(), + expr: Expr::new( + ExprKind::Binary { + op: BinaryOp::Add, + left: Box::new(Expr::identifier("__arg_f_0")), + right: Box::new(Expr::int(1)), + }, + span::Span::default(), + ), + }, + ], + }], + &sigscript, + ) + .unwrap(); + + session.executed_steps.insert(StepId { sequence: 0, frame_id: 0 }); + let vars = session.list_variables_at_sequence(1, 0).unwrap(); + + assert!(!vars.iter().any(|var| var.name.starts_with("__arg_"))); + let x = vars.into_iter().find(|var| var.name == "x").expect("x variable"); + assert!(matches!(x.value, DebugValue::Int(6))); + } + + #[test] + fn shadow_eval_resolves_nested_inline_synthetic_chain() { + let mut sig_builder = ScriptBuilder::new(); + sig_builder.add_i64(5).unwrap(); + let sigscript = sig_builder.drain(); + + let mut session = make_session( + vec![DebugParamMapping { name: "a".to_string(), type_name: "int".to_string(), stack_index: 0, function: "f".to_string() }], + vec![DebugStep { + bytecode_start: 0, + bytecode_end: 0, + span: SourceSpan { line: 1, col: 1, end_line: 1, end_col: 1 }, + kind: StepKind::Source {}, + sequence: 0, + call_depth: 0, + frame_id: 0, + variable_updates: vec![ + DebugVariableUpdate { + name: "__arg_outer_0".to_string(), + type_name: "int".to_string(), + expr: Expr::identifier("a"), + }, + DebugVariableUpdate { + name: "__arg_inner_0".to_string(), + type_name: "int".to_string(), + expr: Expr::identifier("__arg_outer_0"), + }, + DebugVariableUpdate { + name: "x".to_string(), + type_name: "int".to_string(), + expr: Expr::new( + ExprKind::Binary { + op: BinaryOp::Add, + left: Box::new(Expr::identifier("__arg_inner_0")), + right: Box::new(Expr::int(1)), + }, + span::Span::default(), + ), + }, + ], + }], + &sigscript, + ) + .unwrap(); + + session.executed_steps.insert(StepId { sequence: 0, frame_id: 0 }); + let vars = session.list_variables_at_sequence(1, 0).unwrap(); + + assert!(!vars.iter().any(|var| var.name.starts_with("__arg_"))); + let x = vars.into_iter().find(|var| var.name == "x").expect("x variable"); + assert!(matches!(x.value, DebugValue::Int(6))); + } +} diff --git a/debugger/session/src/test_runner.rs b/debugger/session/src/test_runner.rs new file mode 100644 index 0000000..8d65267 --- /dev/null +++ b/debugger/session/src/test_runner.rs @@ -0,0 +1,249 @@ +use std::path::{Path, PathBuf}; + +use serde::Deserialize; +use serde_json::Value; + +#[derive(Debug, Clone, Deserialize)] +pub struct ContractTestFile { + pub tests: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ContractTestCase { + pub name: String, + pub function: String, + #[serde(default)] + pub constructor_args: Vec, + #[serde(default)] + pub args: Vec, + pub expect: TestExpectation, + #[serde(default)] + pub tx: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum TestExpectation { + Pass, + Fail, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct TestTxScenario { + #[serde(default = "default_tx_version")] + pub version: u16, + #[serde(default)] + pub lock_time: u64, + #[serde(default)] + pub active_input_index: usize, + pub inputs: Vec, + pub outputs: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct TestTxInputScenario { + #[serde(default)] + pub prev_txid: Option, + #[serde(default)] + pub prev_index: u32, + #[serde(default)] + pub sequence: u64, + #[serde(default = "default_sig_op_count")] + pub sig_op_count: u8, + pub utxo_value: u64, + #[serde(default)] + pub covenant_id: Option, + #[serde(default)] + pub constructor_args: Option>, + #[serde(default)] + pub signature_script_hex: Option, + #[serde(default)] + pub utxo_script_hex: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct TestTxOutputScenario { + pub value: u64, + #[serde(default)] + pub covenant_id: Option, + #[serde(default)] + pub authorizing_input: Option, + #[serde(default)] + pub constructor_args: Option>, + #[serde(default)] + pub script_hex: Option, + #[serde(default)] + pub p2pk_pubkey: Option, +} + +#[derive(Debug, Clone)] +pub struct ResolvedContractTest { + pub script_path: PathBuf, + pub test_file_path: PathBuf, + pub test: ContractTestCaseResolved, +} + +#[derive(Debug, Clone)] +pub struct ContractTestCaseResolved { + pub name: String, + pub function: String, + pub constructor_args: Vec, + pub args: Vec, + pub expect: TestExpectation, + pub tx: Option, +} + +#[derive(Debug, Clone)] +pub struct TestTxScenarioResolved { + pub version: u16, + pub lock_time: u64, + pub active_input_index: usize, + pub inputs: Vec, + pub outputs: Vec, +} + +#[derive(Debug, Clone)] +pub struct TestTxInputScenarioResolved { + pub prev_txid: Option, + pub prev_index: u32, + pub sequence: u64, + pub sig_op_count: u8, + pub utxo_value: u64, + pub covenant_id: Option, + pub constructor_args: Option>, + pub signature_script_hex: Option, + pub utxo_script_hex: Option, +} + +#[derive(Debug, Clone)] +pub struct TestTxOutputScenarioResolved { + pub value: u64, + pub covenant_id: Option, + pub authorizing_input: Option, + pub constructor_args: Option>, + pub script_hex: Option, + pub p2pk_pubkey: Option, +} + +fn default_tx_version() -> u16 { + 1 +} + +fn default_sig_op_count() -> u8 { + 100 +} + +pub fn discover_sidecar_path(script_path: &Path) -> Result { + let stem = script_path + .file_stem() + .and_then(|stem| stem.to_str()) + .ok_or_else(|| format!("failed to derive stem from '{}'", script_path.display()))?; + let sidecar_name = format!("{stem}.test.json"); + Ok(script_path.with_file_name(sidecar_name)) +} + +pub fn read_contract_test_file(test_file_path: &Path) -> Result { + let raw = std::fs::read_to_string(test_file_path) + .map_err(|err| format!("failed to read test file '{}': {err}", test_file_path.display()))?; + serde_json::from_str::(&raw).map_err(|err| format!("invalid test file '{}': {err}", test_file_path.display())) +} + +pub fn resolve_contract_test( + test_file_path: &Path, + test_name: &str, + script_path_override: Option<&Path>, +) -> Result { + let script_path = if let Some(script_path) = script_path_override { + std::fs::canonicalize(script_path) + .map_err(|err| format!("failed to canonicalize script path '{}': {err}", script_path.display()))? + } else { + let inferred = infer_script_path_from_sidecar(test_file_path)?; + std::fs::canonicalize(&inferred) + .map_err(|err| format!("failed to canonicalize inferred script path '{}': {err}", inferred.display()))? + }; + + let canonical_test_file = std::fs::canonicalize(test_file_path) + .map_err(|err| format!("failed to canonicalize test file '{}': {err}", test_file_path.display()))?; + + let parsed = read_contract_test_file(&canonical_test_file)?; + let test = parsed + .tests + .into_iter() + .find(|entry| entry.name == test_name) + .ok_or_else(|| format!("test '{test_name}' not found in '{}'", canonical_test_file.display()))?; + + let resolved = ContractTestCaseResolved { + name: test.name, + function: test.function, + constructor_args: values_to_args(&test.constructor_args)?, + args: values_to_args(&test.args)?, + expect: test.expect, + tx: test.tx.map(resolve_tx_scenario).transpose()?, + }; + + Ok(ResolvedContractTest { script_path, test_file_path: canonical_test_file, test: resolved }) +} + +fn infer_script_path_from_sidecar(test_file_path: &Path) -> Result { + let file_name = test_file_path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| format!("invalid test file name '{}'", test_file_path.display()))?; + + let script_name = file_name + .strip_suffix(".test.json") + .ok_or_else(|| format!("test file '{}' must end with '.test.json'", test_file_path.display()))?; + + Ok(test_file_path.with_file_name(format!("{script_name}.sil"))) +} + +pub fn resolve_tx_scenario(tx: TestTxScenario) -> Result { + let mut inputs = Vec::with_capacity(tx.inputs.len()); + for input in tx.inputs { + inputs.push(TestTxInputScenarioResolved { + prev_txid: input.prev_txid, + prev_index: input.prev_index, + sequence: input.sequence, + sig_op_count: input.sig_op_count, + utxo_value: input.utxo_value, + covenant_id: input.covenant_id, + constructor_args: input.constructor_args.as_ref().map(|values| values_to_args(values)).transpose()?, + signature_script_hex: input.signature_script_hex, + utxo_script_hex: input.utxo_script_hex, + }); + } + + let mut outputs = Vec::with_capacity(tx.outputs.len()); + for output in tx.outputs { + outputs.push(TestTxOutputScenarioResolved { + value: output.value, + covenant_id: output.covenant_id, + authorizing_input: output.authorizing_input, + constructor_args: output.constructor_args.as_ref().map(|values| values_to_args(values)).transpose()?, + script_hex: output.script_hex, + p2pk_pubkey: output.p2pk_pubkey, + }); + } + + Ok(TestTxScenarioResolved { + version: tx.version, + lock_time: tx.lock_time, + active_input_index: tx.active_input_index, + inputs, + outputs, + }) +} + +pub fn values_to_args(values: &[Value]) -> Result, String> { + values.iter().map(value_to_arg).collect() +} + +fn value_to_arg(value: &Value) -> Result { + match value { + Value::String(raw) => Ok(raw.clone()), + Value::Number(raw) => Ok(raw.to_string()), + Value::Bool(raw) => Ok(raw.to_string()), + Value::Null => Ok("null".to_string()), + Value::Array(_) | Value::Object(_) => serde_json::to_string(value).map_err(|err| format!("invalid arg value: {err}")), + } +} diff --git a/debugger/session/src/util.rs b/debugger/session/src/util.rs new file mode 100644 index 0000000..aac8e8d --- /dev/null +++ b/debugger/session/src/util.rs @@ -0,0 +1,27 @@ +/// Decodes a txscript script number (little-endian sign-magnitude, max 8 bytes). +/// Mirrors txscript's internal numeric decode logic; kept local because txscript +/// exposes this helper only as crate-private internals today. +pub fn decode_i64(bytes: &[u8]) -> Result { + if bytes.is_empty() { + return Ok(0); + } + if bytes.len() > 8 { + return Err("numeric value is longer than 8 bytes".to_string()); + } + let msb = bytes[bytes.len() - 1]; + let sign = 1 - 2 * ((msb >> 7) as i64); + let first_byte = (msb & 0x7f) as i64; + let mut value = first_byte; + for byte in bytes[..bytes.len() - 1].iter().rev() { + value = (value << 8) + (*byte as i64); + } + Ok(value * sign) +} + +pub fn encode_hex(bytes: &[u8]) -> String { + let mut out = vec![0u8; bytes.len() * 2]; + if faster_hex::hex_encode(bytes, &mut out).is_err() { + return String::new(); + } + String::from_utf8(out).unwrap_or_default() +} diff --git a/debugger/session/tests/debug_session_tests.rs b/debugger/session/tests/debug_session_tests.rs new file mode 100644 index 0000000..3c34e65 --- /dev/null +++ b/debugger/session/tests/debug_session_tests.rs @@ -0,0 +1,806 @@ +use std::collections::HashSet; +use std::error::Error; + +use kaspa_consensus_core::Hash; +use kaspa_consensus_core::hashing::sighash::SigHashReusedValuesUnsync; +use kaspa_consensus_core::tx::{ + PopulatedTransaction, ScriptPublicKey, Transaction, TransactionId, TransactionInput, TransactionOutpoint, TransactionOutput, + UtxoEntry, VerifiableTransaction, +}; +use kaspa_txscript::caches::Cache; +use kaspa_txscript::covenants::CovenantsContext; +use kaspa_txscript::opcodes::codes::OpTrue; +use kaspa_txscript::{EngineCtx, EngineFlags}; + +use debugger_session::session::{DebugSession, ShadowTxContext}; +use silverscript_lang::ast::{Expr, parse_contract_ast}; +use silverscript_lang::compiler::{CompileOptions, compile_contract}; +use silverscript_lang::debug_info::StepKind; + +const IF_STATEMENT_CONTRACT: &str = r#"pragma silverscript ^0.1.0; + +contract IfStatement(int x, int y) { + entrypoint function hello(int a, int b) { + int d = a + b; + d = d - a; + if (d == x - 2) { + int c = d + b; + d = a + c; + require(c > d); + } else { + require(d == a); + } + d = d + a; + require(d == y); + } +} +"#; + +// Convenience harness for the canonical example contract used by baseline session tests. +fn with_session(mut f: F) -> Result<(), Box> +where + F: FnMut(&mut DebugSession<'_, '_>) -> Result<(), Box>, +{ + with_session_for_source( + IF_STATEMENT_CONTRACT, + vec![Expr::int(3), Expr::int(10)], + "hello", + vec![Expr::int(5), Expr::int(5)], + &mut f, + ) +} + +// Generic harness that compiles a contract and boots a debugger session for a selected function call. +fn with_session_for_source( + source: &str, + ctor_args: Vec>, + function_name: &str, + function_args: Vec>, + mut f: F, +) -> Result<(), Box> +where + F: FnMut(&mut DebugSession<'_, '_>) -> Result<(), Box>, +{ + let parsed_contract = parse_contract_ast(source)?; + assert_eq!(parsed_contract.params.len(), ctor_args.len()); + + // Compile with debug metadata enabled so line steps and variable updates are available. + let compile_opts = CompileOptions { record_debug_infos: true, ..Default::default() }; + let compiled = compile_contract(source, &ctor_args, compile_opts)?; + let debug_info = compiled.debug_info.clone(); + + let sig_cache = Cache::new(10_000); + let reused_values = SigHashReusedValuesUnsync::new(); + let ctx = EngineCtx::new(&sig_cache).with_reused(&reused_values); + + let flags = EngineFlags { covenants_enabled: true }; + let engine = debugger_session::session::DebugEngine::new(ctx, flags); + + let entry = compiled + .abi + .iter() + .find(|entry| entry.name == function_name) + .ok_or_else(|| format!("function '{function_name}' not found"))?; + + assert_eq!(entry.inputs.len(), function_args.len()); + + // Seed stack with sigscript args and then execute the lockscript in debug mode. + let sigscript = compiled.build_sig_script(function_name, function_args)?; + let mut session = DebugSession::full(&sigscript, &compiled.script, source, debug_info, engine)?; + + f(&mut session) +} + +#[test] +fn debug_session_provides_source_context_and_vars() -> Result<(), Box> { + with_session(|session| { + // Skip dispatcher setup and land on first user statement. + session.run_to_first_executed_statement()?; + let context = session.source_context(); + assert!(context.is_some(), "expected source context"); + + let vars = session.list_variables().expect("variables available"); + let names = vars.iter().map(|var| var.name.as_str()).collect::>(); + assert!(names.contains("a"), "expected param 'a' in variables"); + assert!(names.contains("b"), "expected param 'b' in variables"); + + Ok(()) + }) +} + +#[test] +fn debug_session_steps_forward() -> Result<(), Box> { + with_session(|session| { + session.run_to_first_executed_statement()?; + let before = session.state().pc; + let before_span = session.current_span(); + session.step_over()?; + let after = session.state().pc; + let after_span = session.current_span(); + assert!(after > before || after_span != before_span, "expected statement step to make source progress"); + Ok(()) + }) +} + +#[test] +fn debug_session_breakpoint_management() -> Result<(), Box> { + with_session(|session| { + session.run_to_first_executed_statement()?; + let span = session.current_span().ok_or("no current span")?; + let line = span.line; + + session.add_breakpoint(line); + assert!(session.breakpoints().contains(&line)); + + session.clear_breakpoint(line); + assert!(!session.breakpoints().contains(&line)); + Ok(()) + }) +} + +#[test] +fn debug_session_hits_multiline_breakpoints() -> Result<(), Box> { + let source = r#"pragma silverscript ^0.1.0; + +contract BP() { + entrypoint function main(int a) { + require(a == 1); + require(a == 1); + require( + a == 1 + ); + } +} +"#; + + with_session_for_source(source, vec![], "main", vec![Expr::int(1)], |session| { + session.run_to_first_executed_statement()?; + // Line 8 is inside a multiline `require(...)` span and should still be hit. + assert!(session.add_breakpoint(8), "expected breakpoint line to be valid"); + + let hit = session.continue_to_breakpoint()?; + assert!(hit.is_some(), "expected to stop at multiline statement breakpoint"); + + let span = session.current_span().ok_or("expected source span at breakpoint")?; + assert!((span.line..=span.end_line).contains(&8)); + Ok(()) + }) +} + +#[test] +fn debug_session_dedupes_shadowed_constructor_constants() -> Result<(), Box> { + let source = r#"pragma silverscript ^0.1.0; + +contract Shadow(int x) { + entrypoint function main(int x) { + require(x == x); + } +} +"#; + + with_session_for_source(source, vec![Expr::int(7)], "main", vec![Expr::int(3)], |session| { + session.run_to_first_executed_statement()?; + + // Function param `x` should shadow constructor constant `x` in visible debugger variables. + let vars = session.list_variables()?; + let x_count = vars.iter().filter(|var| var.name == "x").count(); + assert_eq!(x_count, 1, "expected a single visible x variable"); + + let x = session.variable_by_name("x")?; + assert!(!x.is_constant, "function parameter should shadow constructor constant"); + assert_eq!(session.format_value(&x.type_name, &x.value), "3"); + Ok(()) + }) +} + +#[test] +fn debug_session_prefers_function_param_value_over_shadowed_constructor_constant() -> Result<(), Box> { + let source = r#"pragma silverscript ^0.1.0; + +contract ShadowMath(int fee) { + entrypoint function main(int fee) { + int local = fee + 1; + local = local + fee; + require(local > 0); + } +} +"#; + + with_session_for_source(source, vec![Expr::int(2)], "main", vec![Expr::int(3)], |session| { + session.run_to_first_executed_statement()?; + + session.step_over()?; + let local_after_init = session.variable_by_name("local")?; + assert_eq!(session.format_value(&local_after_init.type_name, &local_after_init.value), "4"); + + session.step_over()?; + let local_after_update = session.variable_by_name("local")?; + assert_eq!(session.format_value(&local_after_update.type_name, &local_after_update.value), "7"); + + let fee = session.variable_by_name("fee")?; + assert!(!fee.is_constant); + assert_eq!(session.format_value(&fee.type_name, &fee.value), "3"); + Ok(()) + }) +} + +#[test] +fn debug_session_offsets_param_indexes_when_contract_has_fields() -> Result<(), Box> { + let source = r#"pragma silverscript ^0.1.0; + +contract FieldOffset(int c) { + int x = 7; + + entrypoint function main(int a) { + require(a > 0); + } +} +"#; + + with_session_for_source(source, vec![Expr::int(2)], "main", vec![Expr::int(5)], |session| { + session.run_to_first_executed_statement()?; + + let a = session.variable_by_name("a")?; + assert_eq!(session.format_value(&a.type_name, &a.value), "5"); + + let x = session.variable_by_name("x")?; + assert_eq!(session.format_value(&x.type_name, &x.value), "7"); + Ok(()) + }) +} + +#[test] +fn debug_session_resolves_updates_that_reference_contract_fields() -> Result<(), Box> { + let source = r#"pragma silverscript ^0.1.0; + +contract FieldMath(int c) { + int x = 7; + + entrypoint function main(int a) { + int z = a + x + c; + require(z > 0); + } +} +"#; + + with_session_for_source(source, vec![Expr::int(2)], "main", vec![Expr::int(5)], |session| { + session.run_to_first_executed_statement()?; + + for _ in 0..4 { + if let Ok(z) = session.variable_by_name("z") { + assert_eq!(session.format_value(&z.type_name, &z.value), "14"); + return Ok(()); + } + if session.step_over()?.is_none() { + break; + } + } + + Err("expected z to become visible after assignment".into()) + }) +} + +#[test] +fn debug_session_exposes_virtual_steps() -> Result<(), Box> { + let source = r#"pragma silverscript ^0.1.0; + +contract Virtuals() { + entrypoint function main(int a) { + int x = a + 1; + x = x + 2; + require(x > 0); + } +} +"#; + + with_session_for_source(source, vec![], "main", vec![Expr::int(3)], |session| { + session.run_to_first_executed_statement()?; + let first = session.current_step().ok_or("missing first location")?; + assert!(matches!(first.kind, StepKind::Source {})); + assert_eq!(first.bytecode_start, first.bytecode_end, "first step should be zero-width"); + let first_pc = session.state().pc; + + let second = session.step_over()?.ok_or("missing second step")?.step.ok_or("missing second step payload")?; + assert!(matches!(second.kind, StepKind::Source {})); + assert_eq!(second.bytecode_start, second.bytecode_end, "second step should be zero-width"); + assert_eq!(session.state().pc, first_pc, "virtual step should not execute opcodes"); + + let third = session.step_over()?.ok_or("missing third step")?.step.ok_or("missing third step payload")?; + assert!(matches!(third.kind, StepKind::Source {})); + assert!(third.bytecode_end > third.bytecode_start, "third step should execute bytecode"); + assert_eq!(session.state().pc, first_pc, "first real statement should still be at same pc boundary"); + Ok(()) + }) +} + +#[test] +fn debug_session_step_opcode_advances_statement_cursor() -> Result<(), Box> { + let source = r#"pragma silverscript ^0.1.0; + +contract OpcodeCursor() { + entrypoint function main(int a) { + int x = a + 1; + x = x + 2; + require(x > 0); + } +} +"#; + + with_session_for_source(source, vec![], "main", vec![Expr::int(3)], |session| { + session.run_to_first_executed_statement()?; + let start = session.current_span().ok_or("missing start span")?; + assert_eq!(start.line, 5); + + session.step_opcode()?.ok_or("expected si to execute one opcode")?; + let after_si = session.current_span().ok_or("missing span after si")?; + assert_ne!(after_si.line, start.line, "si should refresh statement cursor"); + + let x = session.variable_by_name("x")?; + assert_eq!(session.format_value(&x.type_name, &x.value), "1"); + Ok(()) + }) +} + +#[test] +fn debug_session_breakpoint_hits_virtual_line() -> Result<(), Box> { + let source = r#"pragma silverscript ^0.1.0; + +contract VirtualBp() { + entrypoint function main(int a) { + int x = a + 1; + x = x + 2; + require(x > 0); + } +} +"#; + + with_session_for_source(source, vec![], "main", vec![Expr::int(3)], |session| { + session.run_to_first_executed_statement()?; + assert!(session.add_breakpoint(6), "line with virtual assignment should be a valid breakpoint"); + let hit = session.continue_to_breakpoint()?; + assert!(hit.is_some(), "expected breakpoint on virtual line"); + let span = session.current_span().ok_or("missing span at virtual breakpoint")?; + assert_eq!(span.line, 6); + Ok(()) + }) +} + +#[test] +fn debug_session_tracks_local_variable_updates() -> Result<(), Box> { + let source = r#"pragma silverscript ^0.1.0; + +contract LocalVars() { + entrypoint function main(int a) { + int x = a + 1; + x = x + 2; + require(x > 0); + } +} +"#; + + with_session_for_source(source, vec![], "main", vec![Expr::int(3)], |session| { + session.run_to_first_executed_statement()?; + assert!(session.variable_by_name("x").is_err(), "x should not exist before its statement executes"); + + session.step_over()?; + let x_after_init = session.variable_by_name("x")?; + assert_eq!(session.format_value(&x_after_init.type_name, &x_after_init.value), "4"); + + session.step_over()?; + let x_after_assign = session.variable_by_name("x")?; + assert_eq!(session.format_value(&x_after_assign.type_name, &x_after_assign.value), "6"); + Ok(()) + }) +} + +#[test] +fn debug_session_hits_if_header_breakpoint() -> Result<(), Box> { + with_session(|session| { + session.run_to_first_executed_statement()?; + assert!(session.add_breakpoint(7), "expected if-header line to accept breakpoints"); + + let hit = session.continue_to_breakpoint()?; + assert!(hit.is_some(), "expected to stop at if-header breakpoint"); + + let span = session.current_span().ok_or("missing span at breakpoint")?; + assert!((span.line..=span.end_line).contains(&7), "breakpoint should resolve to line 7 span"); + Ok(()) + }) +} + +#[test] +fn debug_session_step_over_and_out_handle_inline_calls() -> Result<(), Box> { + let source = r#"pragma silverscript ^0.1.0; + +contract InlineCalls() { + function addOne(int x) : (int) { + int y = x + 1; + return(y); + } + + entrypoint function main(int a) { + (int b) = addOne(a); + require(b == a + 1); + } +} +"#; + + with_session_for_source(source, vec![], "main", vec![Expr::int(3)], |session| { + session.run_to_first_executed_statement()?; + let start = session.current_span().ok_or("missing start span")?; + assert_eq!(start.line, 10); + + session.step_over()?; + let mut after_over = session.current_span().ok_or("missing span after step_over")?; + if after_over.line == 10 { + // In simplified inline stepping mode we may stop once on the call-site + // boundary before advancing past the call. + session.step_over()?; + after_over = session.current_span().ok_or("missing span after second step_over")?; + } + assert_eq!(after_over.line, 11, "step_over should eventually move past inline call"); + let b = session.variable_by_name("b")?; + assert_eq!(session.format_value(&b.type_name, &b.value), "4", "inline return should resolve against caller params"); + Ok(()) + })?; + + with_session_for_source(source, vec![], "main", vec![Expr::int(3)], |session| { + session.run_to_first_executed_statement()?; + session.step_into()?; + let mut in_callee = session.current_span().ok_or("missing span in callee")?; + if in_callee.line == 10 { + // First stop can be the inline enter boundary on the caller line. + session.step_into()?; + in_callee = session.current_span().ok_or("missing span in callee after second step_into")?; + } + assert_eq!(in_callee.line, 5, "step_into should enter callee body"); + assert_eq!(session.call_stack(), vec!["addOne".to_string()]); + + session.step_out()?; + let mut after_out = session.current_span().ok_or("missing span after step_out")?; + if after_out.line == 10 { + session.step_over()?; + after_out = session.current_span().ok_or("missing span after post-step_out step_over")?; + } + assert_eq!(after_out.line, 11, "step_out should return to caller after inline call"); + assert!(session.call_stack().is_empty(), "call stack should unwind after step_out"); + Ok(()) + })?; + + Ok(()) +} + +#[test] +fn debug_session_run_to_first_statement_starts_in_caller_for_inline_entry() -> Result<(), Box> { + let source = r#"pragma silverscript ^0.1.0; + +contract Repeat() { + function inc(int x) { + int y = x + 1; + require(y > 0); + } + + entrypoint function main(int a) { + inc(a); + inc(a); + require(a >= 0); + } +} +"#; + + with_session_for_source(source, vec![], "main", vec![Expr::int(0)], |session| { + session.run_to_first_executed_statement()?; + let start = session.current_span().ok_or("missing start span")?; + assert_eq!(start.line, 10, "first source step should be caller line, not callee internals"); + Ok(()) + }) +} + +#[test] +fn debug_session_step_into_repeated_inline_calls_preserves_order_and_stack() -> Result<(), Box> { + let source = r#"pragma silverscript ^0.1.0; + +contract Repeat() { + function inc(int x) { + int y = x + 1; + require(y > 0); + } + + entrypoint function main(int a) { + inc(a); + inc(a); + require(a >= 0); + } +} +"#; + + with_session_for_source(source, vec![], "main", vec![Expr::int(0)], |session| { + session.run_to_first_executed_statement()?; + + let mut lines = vec![session.current_span().ok_or("missing initial span")?.line]; + let mut max_depth = session.call_stack().len(); + while (session.step_into()?).is_some() { + lines.push(session.current_span().ok_or("missing span while stepping")?.line); + max_depth = max_depth.max(session.call_stack().len()); + } + + assert_eq!(max_depth, 1, "repeated inline calls should not nest call frames"); + let count_10 = lines.iter().filter(|&&line| line == 10).count(); + assert!(count_10 >= 2, "expected duplicate call-site stops for first call"); + assert!(lines.windows(2).any(|window| window == [5, 6]), "expected callee body stepping"); + assert_eq!(lines.last().copied(), Some(12), "final step should reach caller require"); + assert!(session.call_stack().is_empty(), "call stack should be empty after execution"); + Ok(()) + }) +} + +#[test] +fn debug_session_step_into_nested_inline_calls_preserves_execution_order() -> Result<(), Box> { + let source = r#"pragma silverscript ^0.1.0; + +contract NestedNoArgs() { + function inner() { + int y = 1; + require(y > 0); + } + + function outer() { + inner(); + require(1 == 1); + } + + entrypoint function main() { + outer(); + require(1 == 1); + } +} +"#; + + with_session_for_source(source, vec![], "main", vec![], |session| { + session.run_to_first_executed_statement()?; + let mut lines = vec![session.current_span().ok_or("missing initial span")?.line]; + + for _ in 0..5 { + session.step_into()?.ok_or("expected additional source step")?; + lines.push(session.current_span().ok_or("missing span while stepping")?.line); + } + + assert_eq!(lines, vec![15, 10, 5, 6, 10, 15], "nested inline stepping order regressed"); + Ok(()) + }) +} + +#[test] +fn debug_session_inline_source_sequences_are_monotonic() -> Result<(), Box> { + let source = r#"pragma silverscript ^0.1.0; + +contract DebugPoC(int const) { + function bump(int x) { + int y = x + 1; + require(y > 0); + } + + function check_pair(int leftInput, int rightInput) { + int left = leftInput + rightInput; + int right = left * 2; + require(right >= left); + } + + entrypoint function main(int a, int b) { + int seed = a + const; + check_pair(a, b); + bump(seed); + require(seed >= const); + require(b >= 0); + } +} +"#; + + with_session_for_source(source, vec![Expr::int(0)], "main", vec![Expr::int(0), Expr::int(0)], |session| { + session.run_to_first_executed_statement()?; + + let initial = session.current_step().ok_or("missing initial location")?; + let mut prev_sequence = initial.sequence; + let mut lines = vec![session.current_span().ok_or("missing initial span")?.line]; + + while session.step_into()?.is_some() { + let loc = session.current_step().ok_or("missing location after step_into")?; + assert!( + loc.sequence >= prev_sequence, + "source sequence rewound from {} to {} (lines {:?})", + prev_sequence, + loc.sequence, + lines + ); + prev_sequence = loc.sequence; + lines.push(session.current_span().ok_or("missing span after step_into")?.line); + } + + assert!(lines.starts_with(&[16, 17, 10, 11, 12, 17, 18, 5]), "unexpected inline stepping prefix: {:?}", lines); + Ok(()) + }) +} + +#[test] +fn debug_session_inline_params_visible_inside_callee() -> Result<(), Box> { + let source = r#"pragma silverscript ^0.1.0; + +contract InlineParams() { + function add1(int x) : (int) { + int y = x + 1; + require(y > 0); + return(y); + } + + entrypoint function main(int a) { + int seed = a; + (int r) = add1(seed); + require(r > 0); + } +} +"#; + + with_session_for_source(source, vec![], "main", vec![Expr::int(4)], |session| { + session.run_to_first_executed_statement()?; + + let mut saw_inline_param = false; + for _ in 0..8 { + let in_callee = session.call_stack().iter().any(|name| name == "add1"); + if in_callee { + if let Ok(x) = session.variable_by_name("x") { + let rendered = session.format_value(&x.type_name, &x.value); + assert_eq!(rendered, "4", "inline param x should reflect caller-provided value"); + saw_inline_param = true; + break; + } + } + if session.step_into()?.is_none() { + break; + } + } + + assert!(saw_inline_param, "expected inline param x to be visible while inside add1"); + Ok(()) + }) +} + +#[test] +fn debug_session_nested_inline_calls_with_args_compile_and_step() -> Result<(), Box> { + let source = r#"pragma silverscript ^0.1.0; + +contract NestedArgs() { + function inner(int x) { + int y = x + 1; + require(y > 0); + } + + function outer(int v) { + inner(v); + require(v >= 0); + } + + entrypoint function main(int a) { + outer(a); + require(a >= 0); + } +} +"#; + + with_session_for_source(source, vec![], "main", vec![Expr::int(0)], |session| { + session.run_to_first_executed_statement()?; + let start = session.current_span().ok_or("missing start span")?; + assert_eq!(start.line, 15); + + session.step_over()?; + let mut after_over = session.current_span().ok_or("missing span after step_over")?; + if after_over.line == 15 { + session.step_over()?; + after_over = session.current_span().ok_or("missing span after second step_over")?; + } + assert_eq!(after_over.line, 16, "step_over should move past nested inline call in caller"); + Ok(()) + }) +} + +#[test] +fn debug_session_exposes_loop_index_variable_i() -> Result<(), Box> { + let source = r#"pragma silverscript ^0.1.0; + +contract LoopIndex() { + entrypoint function main() { + int sum = 0; + for(i,0,2){ + if(i < 2){ + sum = sum + i; + } + } + require(sum >= 0); + } +} +"#; + + with_session_for_source(source, vec![], "main", vec![], |session| { + session.run_to_first_executed_statement()?; + let mut saw_loop_index = false; + + for _ in 0..12 { + if let Ok(i) = session.variable_by_name("i") { + assert_eq!(session.format_value(&i.type_name, &i.value), "0"); + saw_loop_index = true; + break; + } + if session.step_over()?.is_none() { + break; + } + } + + assert!(saw_loop_index, "expected loop index 'i' to be visible while stepping loop body"); + Ok(()) + }) +} + +#[test] +fn debug_session_shadow_eval_uses_tx_context_for_covenant_opcode_locals() -> Result<(), Box> { + let source = r#"pragma silverscript ^0.1.0; + +contract CovLocal() { + entrypoint function main() { + byte[32] covid = OpInputCovenantId(this.activeInputIndex); + require(covid == covid); + } +} +"#; + + let compile_opts = CompileOptions { record_debug_infos: true, ..Default::default() }; + let compiled = compile_contract(source, &[], compile_opts)?; + let debug_info = compiled.debug_info.clone(); + let sigscript = compiled.build_sig_script("main", vec![])?; + + let input = TransactionInput { + previous_outpoint: TransactionOutpoint { transaction_id: TransactionId::from_bytes([0x44u8; 32]), index: 0 }, + signature_script: sigscript.clone(), + sequence: 0, + sig_op_count: 0, + }; + let output = TransactionOutput { value: 1000, script_public_key: ScriptPublicKey::new(0, vec![OpTrue].into()), covenant: None }; + let tx = Transaction::new(1, vec![input], vec![output], 0, Default::default(), 0, vec![]); + + let covenant_id = Hash::from_bytes([0x11u8; 32]); + let utxo_entry = + UtxoEntry::new(1000, ScriptPublicKey::new(0, compiled.script.clone().into()), 0, tx.is_coinbase(), Some(covenant_id)); + let populated_tx = PopulatedTransaction::new(&tx, vec![utxo_entry]); + let cov_ctx = CovenantsContext::from_tx(&populated_tx)?; + + let sig_cache = Cache::new(10_000); + let reused_values = SigHashReusedValuesUnsync::new(); + let ctx = EngineCtx::new(&sig_cache).with_reused(&reused_values).with_covenants_ctx(&cov_ctx); + let input_ref = &tx.inputs[0]; + let utxo_ref = populated_tx.utxo(0).ok_or("missing utxo for input 0")?; + let engine = debugger_session::session::DebugEngine::from_transaction_input( + &populated_tx, + input_ref, + 0, + utxo_ref, + ctx, + EngineFlags { covenants_enabled: true }, + ); + + let shadow_ctx = + ShadowTxContext { tx: &populated_tx, input: input_ref, input_index: 0, utxo_entry: utxo_ref, covenants_ctx: &cov_ctx }; + + let mut session = DebugSession::full(&sigscript, &compiled.script, source, debug_info, engine)?.with_shadow_tx_context(shadow_ctx); + session.run_to_first_executed_statement()?; + + for _ in 0..4 { + if let Ok(covid) = session.variable_by_name("covid") { + let rendered = session.format_value(&covid.type_name, &covid.value); + assert_eq!(rendered, format!("0x{}", "11".repeat(32))); + return Ok(()); + } + if session.step_over()?.is_none() { + break; + } + } + + Err("expected covid local to be evaluated using tx context".into()) +} diff --git a/silverscript-lang/Cargo.toml b/silverscript-lang/Cargo.toml index ea4a721..0262335 100644 --- a/silverscript-lang/Cargo.toml +++ b/silverscript-lang/Cargo.toml @@ -25,7 +25,7 @@ thiserror.workspace = true serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" clap = { version = "4.5.60", features = ["derive"] } +faster-hex = "0.10" [dev-dependencies] kaspa-addresses.workspace = true - diff --git a/silverscript-lang/src/compiler.rs b/silverscript-lang/src/compiler.rs index 008a5ea..9428d87 100644 --- a/silverscript-lang/src/compiler.rs +++ b/silverscript-lang/src/compiler.rs @@ -10,9 +10,13 @@ use crate::ast::{ StateBindingAst, StateFieldExpr, Statement, TimeVar, TypeBase, TypeRef, UnaryOp, UnarySuffixKind, parse_contract_ast, parse_type_ref, }; +use crate::debug_info::{DebugInfo, SourceSpan}; pub use crate::errors::{CompilerError, ErrorSpan}; use crate::span; +mod debug_recording; + +use debug_recording::DebugRecorder; /// Prefix used for synthetic argument bindings during inline function expansion. pub const SYNTHETIC_ARG_PREFIX: &str = "__arg"; @@ -20,6 +24,7 @@ pub const SYNTHETIC_ARG_PREFIX: &str = "__arg"; pub struct CompileOptions { pub allow_yield: bool, pub allow_entrypoint_return: bool, + pub record_debug_infos: bool, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -41,6 +46,7 @@ pub struct CompiledContract<'i> { pub ast: ContractAst<'i>, pub abi: Vec, pub without_selector: bool, + pub debug_info: Option>, } pub fn compile_contract<'i>( @@ -49,13 +55,22 @@ pub fn compile_contract<'i>( options: CompileOptions, ) -> Result, CompilerError> { let contract = parse_contract_ast(source)?; - compile_contract_ast(&contract, constructor_args, options) + compile_contract_impl(&contract, constructor_args, options, Some(source)) } pub fn compile_contract_ast<'i>( contract: &ContractAst<'i>, constructor_args: &[Expr<'i>], options: CompileOptions, +) -> Result, CompilerError> { + compile_contract_impl(contract, constructor_args, options, None) +} + +fn compile_contract_impl<'i>( + contract: &ContractAst<'i>, + constructor_args: &[Expr<'i>], + options: CompileOptions, + source: Option<&'i str>, ) -> Result, CompilerError> { if contract.functions.is_empty() { return Err(CompilerError::Unsupported("contract has no functions".to_string())); @@ -96,9 +111,11 @@ pub fn compile_contract_ast<'i>( let (_contract_fields, field_prolog_script) = compile_contract_fields(&contract.fields, &constants, options, script_size)?; let mut compiled_entrypoints = Vec::new(); + let mut recorder = DebugRecorder::new(options.record_debug_infos); + recorder.record_constructor_constants(&contract.params, constructor_args); for (index, func) in contract.functions.iter().enumerate() { if func.entrypoint { - compiled_entrypoints.push(compile_function( + compiled_entrypoints.push(compile_entrypoint_function( func, index, &contract.fields, @@ -108,33 +125,34 @@ pub fn compile_contract_ast<'i>( &functions_map, &function_order, script_size, + &mut recorder, )?); } } let entrypoint_script = if without_selector { - compiled_entrypoints + let (name, script) = compiled_entrypoints .first() - .ok_or_else(|| CompilerError::Unsupported("contract has no entrypoint functions".to_string()))? - .1 - .clone() + .ok_or_else(|| CompilerError::Unsupported("contract has no entrypoint functions".to_string()))?; + recorder.set_entrypoint_start(name, field_prolog_script.len()); + script.clone() } else { let mut builder = ScriptBuilder::new(); let total = compiled_entrypoints.len(); - for (index, (_, script)) in compiled_entrypoints.iter().enumerate() { + for (index, (name, script)) in compiled_entrypoints.iter().enumerate() { builder.add_op(OpDup)?; builder.add_i64(index as i64)?; builder.add_op(OpNumEqual)?; builder.add_op(OpIf)?; builder.add_op(OpDrop)?; + let start = field_prolog_script.len() + builder.script().len(); + recorder.set_entrypoint_start(name, start); builder.add_ops(script)?; + builder.add_op(OpElse)?; if index == total - 1 { - builder.add_op(OpElse)?; builder.add_op(OpDrop)?; builder.add_op(OpFalse)?; builder.add_op(OpVerify)?; - } else { - builder.add_op(OpElse)?; } } @@ -147,6 +165,7 @@ pub fn compile_contract_ast<'i>( let mut script = field_prolog_script.clone(); script.extend(entrypoint_script); + let debug_info = recorder.into_debug_info(source.unwrap_or_default().to_string()); if !uses_script_size { return Ok(CompiledContract { @@ -155,6 +174,7 @@ pub fn compile_contract_ast<'i>( ast: contract.clone(), abi: function_abi_entries, without_selector, + debug_info, }); } @@ -166,6 +186,7 @@ pub fn compile_contract_ast<'i>( ast: contract.clone(), abi: function_abi_entries, without_selector, + debug_info, }); } script_size = Some(actual_size); @@ -887,7 +908,7 @@ pub fn function_branch_index<'i>(contract: &ContractAst<'i>, function_name: &str .ok_or_else(|| CompilerError::Unsupported(format!("function '{function_name}' not found"))) } -fn compile_function<'i>( +fn compile_entrypoint_function<'i>( function: &FunctionAst<'i>, function_index: usize, contract_fields: &[ContractFieldAst<'i>], @@ -897,6 +918,7 @@ fn compile_function<'i>( functions: &HashMap>, function_order: &HashMap, script_size: Option, + recorder: &mut DebugRecorder<'i>, ) -> Result<(String, Vec), CompilerError> { let contract_field_count = contract_fields.len(); let param_count = function.params.len(); @@ -961,8 +983,11 @@ fn compile_function<'i>( } } + recorder.begin_entrypoint(&function.name, function, contract_fields); + let body_len = function.body.len(); for (index, stmt) in function.body.iter().enumerate() { + recorder.begin_statement_at(builder.script().len(), &env); if let Statement::Return { exprs, .. } = stmt { if index != body_len - 1 { return Err(CompilerError::Unsupported("return statement must be the last statement".to_string())); @@ -972,25 +997,27 @@ fn compile_function<'i>( let resolved = resolve_expr(expr.clone(), &env, &mut HashSet::new()).map_err(|err| err.with_span(&expr.span))?; yields.push(resolved); } - continue; + } else { + compile_statement( + stmt, + &mut env, + ¶ms, + &mut types, + &mut builder, + options, + contract_fields, + contract_field_prefix_len, + constants, + functions, + function_order, + function_index, + &mut yields, + script_size, + recorder, + ) + .map_err(|err| err.with_span(&stmt.span()))?; } - compile_statement( - stmt, - &mut env, - ¶ms, - &mut types, - &mut builder, - options, - contract_fields, - contract_field_prefix_len, - constants, - functions, - function_order, - function_index, - &mut yields, - script_size, - ) - .map_err(|err| err.with_span(&stmt.span()))?; + recorder.finish_statement_at(stmt, builder.script().len(), &env, &types)?; } let yield_count = yields.len(); @@ -1029,7 +1056,9 @@ fn compile_function<'i>( builder.add_op(OpDrop)?; } } - Ok((function.name.clone(), builder.drain())) + let script = builder.drain(); + recorder.finish_entrypoint(script.len()); + Ok((function.name.clone(), script)) } #[allow(clippy::too_many_arguments)] @@ -1048,6 +1077,7 @@ fn compile_statement<'i>( function_index: usize, yields: &mut Vec>, script_size: Option, + recorder: &mut DebugRecorder<'i>, ) -> Result<(), CompilerError> { match stmt { Statement::VariableDefinition { type_ref, name, expr, .. } => { @@ -1242,12 +1272,14 @@ fn compile_statement<'i>( function_index, yields, script_size, + recorder, ), - Statement::For { ident, start, end, body, .. } => compile_for_statement( + Statement::For { ident, start, end, body, span, .. } => compile_for_statement( ident, start, end, body, + *span, env, params, types, @@ -1261,6 +1293,7 @@ fn compile_statement<'i>( function_index, yields, script_size, + recorder, ), Statement::Yield { expr, .. } => { let mut visiting = HashSet::new(); @@ -1303,6 +1336,7 @@ fn compile_statement<'i>( let returns = compile_inline_call( name, args, + SourceSpan::from(stmt.span()), params, types, env, @@ -1313,6 +1347,7 @@ fn compile_statement<'i>( function_order, function_index, script_size, + recorder, )?; if !returns.is_empty() { let mut stack_depth = 0i64; @@ -1370,6 +1405,7 @@ fn compile_statement<'i>( let returns = compile_inline_call( name, args, + SourceSpan::from(stmt.span()), params, types, env, @@ -1380,6 +1416,7 @@ fn compile_statement<'i>( function_order, function_index, script_size, + recorder, )?; if returns.len() != bindings.len() { return Err(CompilerError::Unsupported("return values count must match function return types".to_string())); @@ -1724,6 +1761,7 @@ fn compile_validate_output_state_statement( fn compile_inline_call<'i>( name: &str, args: &[Expr<'i>], + call_span: SourceSpan, params: &HashMap, caller_types: &mut HashMap, caller_env: &mut HashMap>, @@ -1734,6 +1772,7 @@ fn compile_inline_call<'i>( function_order: &HashMap, caller_index: usize, script_size: Option, + recorder: &mut DebugRecorder<'i>, ) -> Result>, CompilerError> { let function = functions.get(name).ok_or_else(|| CompilerError::Unsupported(format!("function '{}' not found", name)))?; let callee_index = @@ -1800,9 +1839,13 @@ fn compile_inline_call<'i>( } } + let call_start = builder.script().len(); + recorder.begin_inline_call(call_span, call_start, function, &env)?; + let mut yields: Vec> = Vec::new(); let body_len = function.body.len(); for (index, stmt) in function.body.iter().enumerate() { + recorder.begin_statement_at(builder.script().len(), &env); if let Statement::Return { exprs, .. } = stmt { if index != body_len - 1 { return Err(CompilerError::Unsupported("return statement must be the last statement".to_string())); @@ -1813,26 +1856,30 @@ fn compile_inline_call<'i>( let resolved = resolve_expr(expr.clone(), &env, &mut HashSet::new()).map_err(|err| err.with_span(&expr.span))?; yields.push(resolved); } - continue; + } else { + compile_statement( + stmt, + &mut env, + params, + &mut types, + builder, + options, + &[], + 0, + contract_constants, + functions, + function_order, + callee_index, + &mut yields, + script_size, + recorder, + ) + .map_err(|err| err.with_span(&stmt.span()))?; } - compile_statement( - stmt, - &mut env, - params, - &mut types, - builder, - options, - &[], - 0, - contract_constants, - functions, - function_order, - callee_index, - &mut yields, - script_size, - ) - .map_err(|err| err.with_span(&stmt.span()))?; + recorder.finish_statement_at(stmt, builder.script().len(), &env, &types)?; } + let call_end = builder.script().len(); + recorder.finish_inline_call(call_span, call_end, name); for (name, value) in env.iter() { if name.starts_with(SYNTHETIC_ARG_PREFIX) { @@ -1864,6 +1911,7 @@ fn compile_if_statement<'i>( function_index: usize, yields: &mut Vec>, script_size: Option, + recorder: &mut DebugRecorder<'i>, ) -> Result<(), CompilerError> { let mut stack_depth = 0i64; compile_expr( @@ -1898,6 +1946,7 @@ fn compile_if_statement<'i>( function_index, yields, script_size, + recorder, )?; let mut else_env = original_env.clone(); @@ -1919,6 +1968,7 @@ fn compile_if_statement<'i>( function_index, yields, script_size, + recorder, )?; } @@ -2000,8 +2050,10 @@ fn compile_block<'i>( function_index: usize, yields: &mut Vec>, script_size: Option, + recorder: &mut DebugRecorder<'i>, ) -> Result<(), CompilerError> { for stmt in statements { + recorder.begin_statement_at(builder.script().len(), env); compile_statement( stmt, env, @@ -2017,8 +2069,10 @@ fn compile_block<'i>( function_index, yields, script_size, + recorder, ) .map_err(|err| err.with_span(&stmt.span()))?; + recorder.finish_statement_at(stmt, builder.script().len(), env, types)?; } Ok(()) } @@ -2029,6 +2083,7 @@ fn compile_for_statement<'i>( start_expr: &Expr<'i>, end_expr: &Expr<'i>, body: &[Statement<'i>], + for_span: span::Span<'i>, env: &mut HashMap>, params: &HashMap, types: &mut HashMap, @@ -2042,6 +2097,7 @@ fn compile_for_statement<'i>( function_index: usize, yields: &mut Vec>, script_size: Option, + recorder: &mut DebugRecorder<'i>, ) -> Result<(), CompilerError> { let start = eval_const_int(start_expr, contract_constants)?; let end = eval_const_int(end_expr, contract_constants)?; @@ -2050,9 +2106,11 @@ fn compile_for_statement<'i>( } let name = ident.to_string(); + let loop_span = SourceSpan::from(for_span); let previous = env.get(&name).cloned(); for value in start..end { env.insert(name.clone(), Expr::int(value)); + recorder.record_variable_binding(name.clone(), "int".to_string(), Expr::int(value), builder.script().len(), loop_span); compile_block( body, env, @@ -2068,6 +2126,7 @@ fn compile_for_statement<'i>( function_index, yields, script_size, + recorder, )?; } @@ -3771,6 +3830,39 @@ fn data_prefix(data_len: usize) -> Vec { script[..script.len() - data_len].to_vec() } +/// Compiles a pre-resolved expression for debugger shadow evaluation. +pub fn compile_debug_expr<'i>( + expr: &Expr<'i>, + env: &HashMap>, + params: &HashMap, + types: &HashMap, +) -> Result, CompilerError> { + let constants = HashMap::new(); + let mut builder = ScriptBuilder::new(); + let mut stack_depth = 0i64; + compile_expr( + expr, + env, + params, + types, + &mut builder, + CompileOptions::default(), + &mut HashSet::new(), + &mut stack_depth, + None, + &constants, + )?; + Ok(builder.drain()) +} + +pub(super) fn resolve_expr_for_debug<'i>( + expr: Expr<'i>, + env: &HashMap>, + visiting: &mut HashSet, +) -> Result, CompilerError> { + resolve_expr(expr, env, visiting) +} + #[cfg(test)] mod tests { use super::{Op0, OpPushData1, OpPushData2, data_prefix}; diff --git a/silverscript-lang/src/compiler/debug_recording.rs b/silverscript-lang/src/compiler/debug_recording.rs new file mode 100644 index 0000000..6d4a9ee --- /dev/null +++ b/silverscript-lang/src/compiler/debug_recording.rs @@ -0,0 +1,615 @@ +use std::collections::{HashMap, HashSet}; +use std::fmt; + +use crate::ast::{ContractFieldAst, Expr, FunctionAst, ParamAst, Statement}; +use crate::debug_info::{ + DebugConstantMapping, DebugFunctionRange, DebugInfo, DebugInfoRecorder, DebugParamMapping, DebugStep, DebugVariableUpdate, + SourceSpan, StepKind, +}; + +use super::{CompilerError, resolve_expr_for_debug}; + +/// Contract-level debug recorder used by the compiler. +/// +/// This facade routes calls to either an active backend (records debug metadata) +/// or a no-op backend (recording disabled), keeping compiler call sites uniform. +pub struct DebugRecorder<'i> { + inner: Box + 'i>, +} + +impl fmt::Debug for DebugRecorder<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("DebugRecorder").finish_non_exhaustive() + } +} + +impl<'i> DebugRecorder<'i> { + /// Creates a debug recorder. When `enabled` is false, all methods become no-ops. + pub fn new(enabled: bool) -> Self { + if enabled { Self { inner: Box::new(ActiveDebugRecorder::default()) } } else { Self { inner: Box::new(NoopDebugRecorder) } } + } + + /// Records constructor constants for debugger display. + pub fn record_constructor_constants(&mut self, params: &[ParamAst<'i>], values: &[Expr<'i>]) { + self.inner.record_constructor_constants(params, values); + } + + /// Starts staging debug metadata for one entrypoint compilation. + pub fn begin_entrypoint(&mut self, name: &str, function: &FunctionAst<'i>, contract_fields: &[ContractFieldAst<'i>]) { + self.inner.begin_entrypoint(name, function, contract_fields); + } + + /// Finishes the active entrypoint stage and stores its local script length. + pub fn finish_entrypoint(&mut self, script_len: usize) { + self.inner.finish_entrypoint(script_len); + } + + /// Sets the absolute script start of a staged entrypoint in final contract bytecode. + pub fn set_entrypoint_start(&mut self, name: &str, bytecode_start: usize) { + self.inner.set_entrypoint_start(name, bytecode_start); + } + + /// Starts one statement frame at the provided bytecode offset. + pub fn begin_statement_at(&mut self, bytecode_offset: usize, env: &HashMap>) { + self.inner.begin_statement_at(bytecode_offset, env); + } + + /// Finishes one statement frame and records variable diffs and bytecode range. + pub fn finish_statement_at( + &mut self, + stmt: &Statement<'i>, + bytecode_end: usize, + env: &HashMap>, + types: &HashMap, + ) -> Result<(), CompilerError> { + self.inner.finish_statement_at(stmt, bytecode_end, env, types) + } + + /// Records an inline call entry step and opens a nested call frame. + pub fn begin_inline_call( + &mut self, + span: SourceSpan, + bytecode_offset: usize, + function: &FunctionAst<'i>, + env: &HashMap>, + ) -> Result<(), CompilerError> { + self.inner.begin_inline_call(span, bytecode_offset, function, env) + } + + /// Records an inline call exit step and closes the active nested call frame. + pub fn finish_inline_call(&mut self, span: SourceSpan, bytecode_offset: usize, callee: &str) { + self.inner.finish_inline_call(span, bytecode_offset, callee); + } + + /// Records an explicit variable binding as a zero-width source step. + pub fn record_variable_binding( + &mut self, + name: String, + type_name: String, + expr: Expr<'i>, + bytecode_offset: usize, + span: SourceSpan, + ) { + self.inner.record_variable_binding(name, type_name, expr, bytecode_offset, span); + } + + /// Finalizes and returns debug info if recording is enabled. + pub fn into_debug_info(self, source: String) -> Option> { + self.inner.into_debug_info(source) + } +} + +trait DebugRecorderImpl<'i>: fmt::Debug { + fn record_constructor_constants(&mut self, params: &[ParamAst<'i>], values: &[Expr<'i>]); + fn begin_entrypoint(&mut self, name: &str, function: &FunctionAst<'i>, contract_fields: &[ContractFieldAst<'i>]); + fn finish_entrypoint(&mut self, script_len: usize); + fn set_entrypoint_start(&mut self, name: &str, bytecode_start: usize); + fn begin_statement_at(&mut self, bytecode_offset: usize, env: &HashMap>); + fn finish_statement_at( + &mut self, + stmt: &Statement<'i>, + bytecode_end: usize, + env: &HashMap>, + types: &HashMap, + ) -> Result<(), CompilerError>; + fn begin_inline_call( + &mut self, + span: SourceSpan, + bytecode_offset: usize, + function: &FunctionAst<'i>, + env: &HashMap>, + ) -> Result<(), CompilerError>; + fn finish_inline_call(&mut self, span: SourceSpan, bytecode_offset: usize, callee: &str); + fn record_variable_binding(&mut self, name: String, type_name: String, expr: Expr<'i>, bytecode_offset: usize, span: SourceSpan); + fn into_debug_info(self: Box, source: String) -> Option>; +} + +#[derive(Debug, Default)] +struct NoopDebugRecorder; + +impl<'i> DebugRecorderImpl<'i> for NoopDebugRecorder { + fn record_constructor_constants(&mut self, _params: &[ParamAst<'i>], _values: &[Expr<'i>]) {} + fn begin_entrypoint(&mut self, _name: &str, _function: &FunctionAst<'i>, _contract_fields: &[ContractFieldAst<'i>]) {} + fn finish_entrypoint(&mut self, _script_len: usize) {} + fn set_entrypoint_start(&mut self, _name: &str, _bytecode_start: usize) {} + fn begin_statement_at(&mut self, _bytecode_offset: usize, _env: &HashMap>) {} + + fn finish_statement_at( + &mut self, + _stmt: &Statement<'i>, + _bytecode_end: usize, + _env: &HashMap>, + _types: &HashMap, + ) -> Result<(), CompilerError> { + Ok(()) + } + + fn begin_inline_call( + &mut self, + _span: SourceSpan, + _bytecode_offset: usize, + _function: &FunctionAst<'i>, + _env: &HashMap>, + ) -> Result<(), CompilerError> { + Ok(()) + } + + fn finish_inline_call(&mut self, _span: SourceSpan, _bytecode_offset: usize, _callee: &str) {} + fn record_variable_binding( + &mut self, + _name: String, + _type_name: String, + _expr: Expr<'i>, + _bytecode_offset: usize, + _span: SourceSpan, + ) { + } + + fn into_debug_info(self: Box, _source: String) -> Option> { + None + } +} + +#[derive(Debug, Default)] +struct ActiveDebugRecorder<'i> { + recorder: DebugInfoRecorder<'i>, + entrypoints: Vec>, + active_entrypoint: Option, +} + +impl<'i> ActiveDebugRecorder<'i> { + fn active_entrypoint_mut(&mut self) -> Option<&mut StagedEntrypointDebug<'i>> { + let index = self.active_entrypoint?; + self.entrypoints.get_mut(index) + } +} + +impl<'i> DebugRecorderImpl<'i> for ActiveDebugRecorder<'i> { + fn record_constructor_constants(&mut self, params: &[ParamAst<'i>], values: &[Expr<'i>]) { + for (param, value) in params.iter().zip(values.iter()) { + self.recorder.record_constant(DebugConstantMapping { + name: param.name.clone(), + type_name: param.type_ref.type_name(), + value: value.clone(), + }); + } + } + + fn begin_entrypoint(&mut self, name: &str, function: &FunctionAst<'i>, contract_fields: &[ContractFieldAst<'i>]) { + debug_assert!(self.active_entrypoint.is_none(), "begin_entrypoint called while another entrypoint is active"); + self.entrypoints.push(StagedEntrypointDebug::new(name.to_string(), function, contract_fields)); + self.active_entrypoint = Some(self.entrypoints.len().saturating_sub(1)); + } + + fn finish_entrypoint(&mut self, script_len: usize) { + let Some(index) = self.active_entrypoint.take() else { + return; + }; + let Some(entrypoint) = self.entrypoints.get_mut(index) else { + return; + }; + entrypoint.script_len = script_len; + debug_assert!(entrypoint.statement_stack.is_empty(), "entrypoint ended with unclosed statement frames"); + debug_assert!(entrypoint.call_stack.len() == 1, "entrypoint ended with unclosed inline call frames"); + } + + fn set_entrypoint_start(&mut self, name: &str, bytecode_start: usize) { + let Some(entrypoint) = self.entrypoints.iter_mut().find(|entrypoint| entrypoint.name == name) else { + return; + }; + entrypoint.bytecode_start = Some(bytecode_start); + } + + fn begin_statement_at(&mut self, bytecode_offset: usize, env: &HashMap>) { + let Some(entrypoint) = self.active_entrypoint_mut() else { + return; + }; + entrypoint.statement_stack.push(StatementFrame { start: bytecode_offset, env_before: env.clone() }); + } + + fn finish_statement_at( + &mut self, + stmt: &Statement<'i>, + bytecode_end: usize, + env: &HashMap>, + types: &HashMap, + ) -> Result<(), CompilerError> { + let Some(entrypoint) = self.active_entrypoint_mut() else { + return Ok(()); + }; + let Some(frame) = entrypoint.statement_stack.pop() else { + return Ok(()); + }; + + let updates = collect_variable_updates(&frame.env_before, env, types)?; + let span = SourceSpan::from(stmt.span()); + let bytecode_len = bytecode_end.saturating_sub(frame.start); + let step_index = entrypoint.push_step(frame.start, frame.start + bytecode_len, span, StepKind::Source {}); + entrypoint.steps[step_index].variable_updates.extend(updates); + Ok(()) + } + + fn begin_inline_call( + &mut self, + span: SourceSpan, + bytecode_offset: usize, + function: &FunctionAst<'i>, + env: &HashMap>, + ) -> Result<(), CompilerError> { + let Some(entrypoint) = self.active_entrypoint_mut() else { + return Ok(()); + }; + + let parent_depth = entrypoint.current_call_depth(); + let callee_frame_id = entrypoint.allocate_frame_id(); + let enter_step_index = entrypoint.push_step_with_context( + bytecode_offset, + bytecode_offset, + span, + StepKind::InlineCallEnter { callee: function.name.clone() }, + parent_depth, + callee_frame_id, + ); + + let mut updates = Vec::new(); + let mut synthetic_names: Vec = env.keys().filter(|name| name.starts_with("__arg_")).cloned().collect(); + synthetic_names.sort_unstable(); + for name in synthetic_names { + if let Some(expr) = env.get(&name).cloned() { + resolve_variable_update(env, &mut updates, &name, "internal", expr)?; + } + } + + for param in &function.params { + resolve_variable_update( + env, + &mut updates, + ¶m.name, + ¶m.type_ref.type_name(), + env.get(¶m.name).cloned().unwrap_or_else(|| Expr::identifier(param.name.clone())), + )?; + } + + entrypoint.steps[enter_step_index].variable_updates.extend(updates); + entrypoint.push_call_frame(callee_frame_id, parent_depth.saturating_add(1)); + Ok(()) + } + + fn finish_inline_call(&mut self, span: SourceSpan, bytecode_offset: usize, callee: &str) { + let Some(entrypoint) = self.active_entrypoint_mut() else { + return; + }; + entrypoint.pop_call_frame(); + entrypoint.push_step(bytecode_offset, bytecode_offset, span, StepKind::InlineCallExit { callee: callee.to_string() }); + } + + fn record_variable_binding(&mut self, name: String, type_name: String, expr: Expr<'i>, bytecode_offset: usize, span: SourceSpan) { + let Some(entrypoint) = self.active_entrypoint_mut() else { + return; + }; + let step_index = entrypoint.push_step(bytecode_offset, bytecode_offset, span, StepKind::Source {}); + entrypoint.steps[step_index].variable_updates.push(DebugVariableUpdate { name, type_name, expr }); + } + + fn into_debug_info(mut self: Box, source: String) -> Option> { + for entrypoint in self.entrypoints.drain(..) { + debug_assert!(entrypoint.bytecode_start.is_some(), "missing bytecode start for staged entrypoint '{}'", entrypoint.name); + let bytecode_start = entrypoint.bytecode_start.unwrap_or(0); + let seq_base = self.recorder.reserve_sequence_block(entrypoint.next_step_sequence); + + for step in entrypoint.steps { + self.recorder.record_step(DebugStep { + bytecode_start: step.bytecode_start + bytecode_start, + bytecode_end: step.bytecode_end + bytecode_start, + span: step.span, + kind: step.kind, + sequence: seq_base.saturating_add(step.sequence), + call_depth: step.call_depth, + frame_id: step.frame_id, + variable_updates: step.variable_updates, + }); + } + + for param in entrypoint.params { + self.recorder.record_param(param); + } + + self.recorder.record_function(DebugFunctionRange { + name: entrypoint.name, + bytecode_start, + bytecode_end: bytecode_start + entrypoint.script_len, + }); + } + + Some(self.recorder.into_debug_info(source)) + } +} + +#[derive(Debug)] +struct StagedEntrypointDebug<'i> { + name: String, + script_len: usize, + bytecode_start: Option, + steps: Vec>, + params: Vec, + next_step_sequence: u32, + call_stack: Vec, + next_frame_id: u32, + statement_stack: Vec>, +} + +impl<'i> StagedEntrypointDebug<'i> { + fn new(name: String, function: &FunctionAst<'i>, contract_fields: &[ContractFieldAst<'i>]) -> Self { + let mut entrypoint = Self { + name, + script_len: 0, + bytecode_start: None, + steps: Vec::new(), + params: Vec::new(), + next_step_sequence: 0, + call_stack: vec![CallFrame { frame_id: 0, call_depth: 0 }], + next_frame_id: 1, + statement_stack: Vec::new(), + }; + entrypoint.record_param_bindings(function, contract_fields); + entrypoint + } + + fn allocate_frame_id(&mut self) -> u32 { + let frame_id = self.next_frame_id; + self.next_frame_id = self.next_frame_id.saturating_add(1); + frame_id + } + + fn push_call_frame(&mut self, frame_id: u32, call_depth: u32) { + self.call_stack.push(CallFrame { frame_id, call_depth }); + } + + fn pop_call_frame(&mut self) { + if self.call_stack.len() > 1 { + self.call_stack.pop(); + } + } + + fn current_frame(&self) -> CallFrame { + self.call_stack.last().copied().unwrap_or(CallFrame { frame_id: 0, call_depth: 0 }) + } + + fn current_call_depth(&self) -> u32 { + self.current_frame().call_depth + } + + fn next_sequence(&mut self) -> u32 { + let sequence = self.next_step_sequence; + self.next_step_sequence = self.next_step_sequence.saturating_add(1); + sequence + } + + fn push_step(&mut self, bytecode_start: usize, bytecode_end: usize, span: SourceSpan, kind: StepKind) -> usize { + let frame = self.current_frame(); + self.push_step_with_context(bytecode_start, bytecode_end, span, kind, frame.call_depth, frame.frame_id) + } + + fn push_step_with_context( + &mut self, + bytecode_start: usize, + bytecode_end: usize, + span: SourceSpan, + kind: StepKind, + call_depth: u32, + frame_id: u32, + ) -> usize { + let sequence = self.next_sequence(); + self.steps.push(DebugStep { + bytecode_start, + bytecode_end, + span, + kind, + sequence, + call_depth, + frame_id, + variable_updates: Vec::new(), + }); + self.steps.len().saturating_sub(1) + } + + fn record_param_bindings(&mut self, function: &FunctionAst<'i>, contract_fields: &[ContractFieldAst<'i>]) { + let param_count = function.params.len(); + let field_count = contract_fields.len(); + for (index, param) in function.params.iter().enumerate() { + self.params.push(DebugParamMapping { + name: param.name.clone(), + type_name: param.type_ref.type_name(), + stack_index: (field_count + (param_count - 1 - index)) as i64, + function: function.name.clone(), + }); + } + for (index, field) in contract_fields.iter().enumerate() { + self.params.push(DebugParamMapping { + name: field.name.clone(), + type_name: field.type_ref.type_name(), + stack_index: (field_count - 1 - index) as i64, + function: function.name.clone(), + }); + } + } +} + +#[derive(Debug)] +struct StatementFrame<'i> { + start: usize, + env_before: HashMap>, +} + +#[derive(Debug, Clone, Copy)] +struct CallFrame { + frame_id: u32, + call_depth: u32, +} + +fn collect_variable_updates<'i>( + before_env: &HashMap>, + after_env: &HashMap>, + types: &HashMap, +) -> Result>, CompilerError> { + let mut names: Vec = after_env.keys().cloned().collect(); + names.sort_unstable(); + + let mut updates = Vec::new(); + for name in names { + let Some(after_expr) = after_env.get(&name) else { + continue; + }; + if before_env.get(&name).is_some_and(|before_expr| before_expr == after_expr) { + continue; + } + let Some(type_name) = types.get(&name) else { + continue; + }; + resolve_variable_update(after_env, &mut updates, &name, type_name, after_expr.clone())?; + } + Ok(updates) +} + +fn resolve_variable_update<'i>( + env: &HashMap>, + updates: &mut Vec>, + name: &str, + type_name: &str, + expr: Expr<'i>, +) -> Result<(), CompilerError> { + let resolved = resolve_expr_for_debug(expr, env, &mut HashSet::new())?; + updates.push(DebugVariableUpdate { name: name.to_string(), type_name: type_name.to_string(), expr: resolved }); + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use crate::ast::{Expr, parse_contract_ast}; + use crate::debug_info::StepKind; + + use super::{DebugRecorder, SourceSpan}; + + #[test] + fn noop_recorders_are_pure_noops() { + let source = r#" + contract Demo() { + entrypoint function spend(int x) { + int y = x; + require(true); + } + } + "#; + let contract = parse_contract_ast(source).expect("parse contract"); + let function = contract.functions.first().expect("function"); + let stmt = function.body.first().expect("statement"); + + let mut recorder = DebugRecorder::new(false); + recorder.record_constructor_constants(&contract.params, &[]); + recorder.begin_entrypoint("spend", function, &contract.fields); + + let span = SourceSpan::from(stmt.span()); + + recorder.begin_statement_at(0, &HashMap::new()); + recorder.finish_statement_at(stmt, 0, &HashMap::new(), &HashMap::new()).expect("noop statement recording"); + + recorder.begin_inline_call(span, 1, function, &HashMap::new()).expect("noop begin call recording"); + recorder.finish_inline_call(span, 2, "callee"); + recorder.record_variable_binding("tmp".to_string(), "int".to_string(), Expr::int(1), 2, span); + recorder.finish_entrypoint(1); + + assert!(recorder.into_debug_info(String::new()).is_none()); + } + + #[test] + fn active_recorders_preserve_sequences_and_inline_frame_ids() { + let source = r#" + contract Demo() { + entrypoint function spend(int x) { + int y = x; + require(true); + } + } + "#; + let contract = parse_contract_ast(source).expect("parse contract"); + let function = contract.functions.first().expect("function"); + let stmt = function.body.first().expect("statement"); + + let mut recorder = DebugRecorder::new(true); + recorder.begin_entrypoint("spend", function, &contract.fields); + + let mut before = HashMap::new(); + before.insert("x".to_string(), Expr::identifier("x")); + + let mut after = before.clone(); + after.insert("y".to_string(), Expr::int(7)); + + let mut types = HashMap::new(); + types.insert("x".to_string(), "int".to_string()); + types.insert("y".to_string(), "int".to_string()); + + recorder.begin_statement_at(0, &before); + recorder.finish_statement_at(stmt, 0, &after, &types).expect("record_step first statement"); + + let span = SourceSpan::from(stmt.span()); + let mut inline_env = HashMap::new(); + inline_env.insert("x".to_string(), Expr::int(3)); + recorder.begin_inline_call(span, 1, function, &inline_env).expect("begin call recording"); + recorder.record_variable_binding("tmp".to_string(), "int".to_string(), Expr::int(9), 1, span); + recorder.finish_inline_call(span, 2, "callee"); + + recorder.finish_entrypoint(2); + recorder.set_entrypoint_start("spend", 0); + + let info = recorder.into_debug_info(String::new()).expect("debug info available"); + + let sequences = info.steps.iter().map(|step| step.sequence).collect::>(); + assert_eq!(sequences, vec![0, 1, 2, 3]); + + let inline_enter_step = info + .steps + .iter() + .find(|step| matches!(&step.kind, StepKind::InlineCallEnter { .. }) && step.frame_id == 1) + .expect("inline enter step exists"); + assert!(inline_enter_step.variable_updates.iter().any(|update| update.name == "x")); + + let inline_zero_width_source_step = info + .steps + .iter() + .find(|step| { + step.is_zero_width() + && step.frame_id == 1 + && matches!(&step.kind, StepKind::Source {}) + && step.variable_updates.iter().any(|update| update.name == "tmp") + }) + .expect("inline zero-width source step exists"); + assert_eq!(inline_zero_width_source_step.variable_updates.len(), 1); + + let tmp_update = inline_zero_width_source_step.variable_updates.first().expect("tmp update exists"); + assert_eq!(tmp_update.name, "tmp"); + + assert!(info.params.iter().any(|param| param.name == "x")); + } +} diff --git a/silverscript-lang/src/debug_info.rs b/silverscript-lang/src/debug_info.rs new file mode 100644 index 0000000..f901ca6 --- /dev/null +++ b/silverscript-lang/src/debug_info.rs @@ -0,0 +1,244 @@ +use crate::ast::Expr; +use crate::span; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub struct SourceSpan { + pub line: u32, + pub col: u32, + pub end_line: u32, + pub end_col: u32, +} + +impl<'a> From> for SourceSpan { + fn from(span: span::Span<'a>) -> Self { + let (line, col, end_line, end_col) = span.line_col_range(); + Self { line: line as u32, col: col as u32, end_line: end_line as u32, end_col: end_col as u32 } + } +} + +/// `DebugInfo` builder used by compiler-side recorders. +/// +/// Accumulates debug metadata during compilation. +/// Collects steps, variable updates, param mappings, function ranges, and constants. +/// Converted to `DebugInfo` after compilation completes. +#[derive(Debug, Default)] +pub struct DebugInfoRecorder<'i> { + steps: Vec>, + params: Vec, + entry_points: Vec, + constants: Vec>, + next_sequence: u32, +} + +impl<'i> DebugInfoRecorder<'i> { + /// Appends one recorded step. + pub fn record_step(&mut self, step: DebugStep<'i>) { + self.steps.push(step); + } + + /// Appends one parameter stack mapping. + pub fn record_param(&mut self, param: DebugParamMapping) { + self.params.push(param); + } + + /// Appends one compiled function bytecode range. + pub fn record_function(&mut self, function: DebugFunctionRange) { + self.entry_points.push(function); + } + + /// Appends one constructor constant mapping. + pub fn record_constant(&mut self, constant: DebugConstantMapping<'i>) { + self.constants.push(constant); + } + + /// Returns the next global sequence id for one emitted debug event. + pub fn next_sequence(&mut self) -> u32 { + let sequence = self.next_sequence; + self.next_sequence = self.next_sequence.saturating_add(1); + sequence + } + + /// Reserves a contiguous sequence block and returns its base id. + /// Callers use this when merging per-function debug data into contract-level + /// metadata so each function keeps local order while remaining globally ordered. + pub fn reserve_sequence_block(&mut self, count: u32) -> u32 { + let base = self.next_sequence; + self.next_sequence = self.next_sequence.saturating_add(count); + base + } + + /// Builds the final serializable debug payload. + pub fn into_debug_info(self, source: String) -> DebugInfo<'i> { + DebugInfo { source, steps: self.steps, params: self.params, functions: self.entry_points, constants: self.constants } + } +} + +/// Complete debug metadata attached to compiled contract. +/// Contains everything needed to map bytecode execution back to source and evaluate variables. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DebugInfo<'i> { + pub source: String, + pub steps: Vec>, + pub params: Vec, + pub functions: Vec, + pub constants: Vec>, +} + +impl<'i> DebugInfo<'i> { + pub fn empty() -> Self { + Self { source: String::new(), steps: Vec::new(), params: Vec::new(), functions: Vec::new(), constants: Vec::new() } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DebugVariableUpdate<'i> { + pub name: String, + pub type_name: String, + /// Pre-resolved expression for debugger shadow evaluation. + /// Identifiers may include inline synthetic placeholders (`__arg_*`). + pub expr: Expr<'i>, +} + +/// Maps function parameter to its stack position. +/// Stack index is measured from stack top (0 = topmost param). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DebugParamMapping { + pub name: String, + pub type_name: String, + pub stack_index: i64, + pub function: String, +} + +/// Bytecode range for a compiled function. +/// Used to determine which function is executing at a given bytecode offset. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DebugFunctionRange { + pub name: String, + pub bytecode_start: usize, + pub bytecode_end: usize, +} + +/// Constructor constant (contract instantiation parameter). +/// Recorded for display in debugger variable list. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DebugConstantMapping<'i> { + pub name: String, + pub type_name: String, + pub value: Expr<'i>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DebugStep<'i> { + pub bytecode_start: usize, + pub bytecode_end: usize, + pub span: SourceSpan, + pub kind: StepKind, + /// Global step order used as a stable tiebreak for overlapping steps. + #[serde(default)] + pub sequence: u32, + #[serde(default)] + pub call_depth: u32, + #[serde(default)] + pub frame_id: u32, + #[serde(default)] + pub variable_updates: Vec>, +} + +impl<'i> DebugStep<'i> { + pub fn id(&self) -> StepId { + StepId { sequence: self.sequence, frame_id: self.frame_id } + } + + pub fn is_zero_width(&self) -> bool { + self.bytecode_start == self.bytecode_end + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct StepId { + pub sequence: u32, + pub frame_id: u32, +} + +impl StepId { + pub const ROOT: Self = Self { sequence: 0, frame_id: 0 }; + + pub fn new(sequence: u32, frame_id: u32) -> Self { + Self { sequence, frame_id } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum StepKind { + Source {}, + InlineCallEnter { callee: String }, + InlineCallExit { callee: String }, +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::{DebugInfo, SourceSpan}; + use crate::span::Span; + + #[test] + fn source_span_from_span_uses_line_col_range() { + let source = "alpha\nbeta\ngamma"; + let span = Span::new(source, 6, 10).expect("span"); + let source_span = SourceSpan::from(span); + assert_eq!(source_span.line, 2); + assert_eq!(source_span.col, 1); + assert_eq!(source_span.end_line, 2); + assert_eq!(source_span.end_col, 5); + } + + #[test] + fn debug_info_schema_requires_step_span() { + let value = json!({ + "source": "", + "steps": [{ + "bytecode_start": 0, + "bytecode_end": 1, + "kind": { "Source": {} }, + "sequence": 0, + "call_depth": 0, + "frame_id": 0, + "variable_updates": [] + }], + "variable_updates": [], + "params": [], + "functions": [], + "constants": [] + }); + + let parsed: Result, _> = serde_json::from_value(value); + assert!(parsed.is_err(), "step span should be required"); + } + + #[test] + fn debug_info_schema_nests_variable_updates_in_steps() { + let value = json!({ + "source": "", + "steps": [{ + "bytecode_start": 0, + "bytecode_end": 1, + "span": { "line": 1, "col": 1, "end_line": 1, "end_col": 1 }, + "kind": { "Source": {} }, + "sequence": 0, + "call_depth": 0, + "frame_id": 0, + "variable_updates": [] + }], + "params": [], + "functions": [], + "constants": [] + }); + + let parsed: DebugInfo<'static> = serde_json::from_value(value).expect("parse debug info"); + let serialized = serde_json::to_value(parsed).expect("serialize debug info"); + + assert!(serialized["steps"][0].get("variable_updates").is_some(), "step should carry variable_updates"); + } +} diff --git a/silverscript-lang/src/lib.rs b/silverscript-lang/src/lib.rs index 60fb7e2..d1423e0 100644 --- a/silverscript-lang/src/lib.rs +++ b/silverscript-lang/src/lib.rs @@ -1,5 +1,6 @@ pub mod ast; pub mod compiler; +pub mod debug_info; pub mod diagnostic; pub mod errors; pub mod parser; diff --git a/silverscript-lang/src/span.rs b/silverscript-lang/src/span.rs index 00e415d..cdc1f54 100644 --- a/silverscript-lang/src/span.rs +++ b/silverscript-lang/src/span.rs @@ -19,6 +19,12 @@ impl<'i> Span<'i> { let end = self.end().max(other.end()); Span::new(input, start, end).unwrap_or(*self) } + + pub(crate) fn line_col_range(&self) -> (usize, usize, usize, usize) { + let (line, col) = self.start_pos().line_col(); + let (end_line, end_col) = self.end_pos().line_col(); + (line, col, end_line, end_col) + } } impl<'i> Default for Span<'i> { @@ -79,3 +85,16 @@ impl<'i> SpanUtils for Span<'i> { pub fn join<'i>(left: &Span<'i>, right: &Span<'i>) -> Span<'i> { left.join(right) } + +#[cfg(test)] +mod tests { + use super::Span; + + #[test] + fn line_col_range_reports_expected_bounds() { + let source = "a\nbc\ndef"; + let span = Span::new(source, 2, 3).expect("span"); + let (line, col, end_line, end_col) = span.line_col_range(); + assert_eq!((line, col, end_line, end_col), (2, 1, 2, 2)); + } +} diff --git a/silverscript-lang/tests/compiler_tests.rs b/silverscript-lang/tests/compiler_tests.rs index 8e6ea91..d031607 100644 --- a/silverscript-lang/tests/compiler_tests.rs +++ b/silverscript-lang/tests/compiler_tests.rs @@ -139,6 +139,121 @@ fn accepts_constructor_args_with_matching_types() { compile_contract(source, &args, CompileOptions::default()).expect("compile succeeds"); } +#[test] +fn compile_contract_omits_debug_info_when_recording_disabled() { + let source = r#" + contract DebugToggle() { + entrypoint function spend(int x) { + require(x == x); + } + } + "#; + + let compiled = compile_contract(source, &[], CompileOptions::default()).expect("compile succeeds"); + assert!(compiled.debug_info.is_none()); +} + +#[test] +fn compile_contract_emits_debug_info_when_recording_enabled() { + let source = r#" + contract DebugToggle() { + entrypoint function spend(int x) { + require(x == x); + } + } + "#; + + let options = CompileOptions { record_debug_infos: true, ..Default::default() }; + let compiled = compile_contract(source, &[], options).expect("compile succeeds"); + let debug_info = compiled.debug_info.expect("debug info should be present"); + assert!(!debug_info.steps.is_empty()); + assert!(!debug_info.functions.is_empty()); + assert!(debug_info.params.iter().any(|param| param.name == "x")); +} + +#[test] +fn debug_info_single_entrypoint_sequences_and_offsets_are_stable() { + let source = r#" + contract DebugSingle() { + entrypoint function spend(int x) { + int y = x; + require(y == x); + } + } + "#; + + let options = CompileOptions { record_debug_infos: true, ..Default::default() }; + let compiled = compile_contract(source, &[], options).expect("compile succeeds"); + assert!(compiled.without_selector); + + let debug_info = compiled.debug_info.expect("debug info should be present"); + let function = debug_info.functions.iter().find(|function| function.name == "spend").expect("function range for spend"); + assert_eq!(function.bytecode_start, 0, "single-entrypoint contract should not use selector prefix"); + assert!(function.bytecode_end > function.bytecode_start); + + let mut sequences = debug_info.steps.iter().map(|step| step.sequence).collect::>(); + sequences.sort_unstable(); + assert_eq!(sequences, (0..debug_info.steps.len() as u32).collect::>(), "step sequences should be contiguous"); + + let function_steps = debug_info + .steps + .iter() + .filter(|step| step.bytecode_start >= function.bytecode_start && step.bytecode_end <= function.bytecode_end) + .collect::>(); + assert!(!function_steps.is_empty(), "function should contain at least one debug step"); + assert!(function_steps.iter().all(|step| step.bytecode_start <= step.bytecode_end), "step ranges should be valid"); +} + +#[test] +fn debug_info_selector_entrypoints_have_global_sequences_and_offset_ranges() { + let source = r#" + contract DebugSelector() { + entrypoint function a(int x) { + int y = x; + require(y == x); + } + + entrypoint function b(int x) { + int z = x; + require(z == x); + } + } + "#; + + let options = CompileOptions { record_debug_infos: true, ..Default::default() }; + let compiled = compile_contract(source, &[], options).expect("compile succeeds"); + assert!(!compiled.without_selector); + + let debug_info = compiled.debug_info.expect("debug info should be present"); + let function_a = debug_info.functions.iter().find(|function| function.name == "a").expect("function range for a"); + let function_b = debug_info.functions.iter().find(|function| function.name == "b").expect("function range for b"); + + assert!(function_a.bytecode_start > 0, "selector mode should prepend dispatcher ops"); + assert!(function_a.bytecode_start < function_b.bytecode_start, "entrypoint ranges should follow compile order"); + assert!(function_a.bytecode_end <= function_b.bytecode_start, "entrypoint ranges should not overlap"); + + let steps_for_a = debug_info + .steps + .iter() + .filter(|step| step.bytecode_start >= function_a.bytecode_start && step.bytecode_end <= function_a.bytecode_end) + .collect::>(); + let steps_for_b = debug_info + .steps + .iter() + .filter(|step| step.bytecode_start >= function_b.bytecode_start && step.bytecode_end <= function_b.bytecode_end) + .collect::>(); + assert!(!steps_for_a.is_empty(), "entrypoint a should contain debug steps"); + assert!(!steps_for_b.is_empty(), "entrypoint b should contain debug steps"); + + let mut sequences = debug_info.steps.iter().map(|step| step.sequence).collect::>(); + sequences.sort_unstable(); + assert_eq!(sequences, (0..debug_info.steps.len() as u32).collect::>(), "global step sequences should be contiguous"); + + let max_a_sequence = steps_for_a.iter().map(|step| step.sequence).max().expect("a sequence max"); + let min_b_sequence = steps_for_b.iter().map(|step| step.sequence).min().expect("b sequence min"); + assert!(max_a_sequence < min_b_sequence, "later entrypoint should reserve a later sequence block"); +} + #[test] fn rejects_constructor_args_with_wrong_scalar_types() { let source = r#" @@ -1654,7 +1769,6 @@ fn compiles_validate_output_state_to_expected_script() { .unwrap() .add_op(OpCat) .unwrap() - // ---- Build new_state.y pushdata chunk ---- // raw y bytes .add_data(&[0x34, 0x12])