From 3fa34547d1c660aca6f430274db871fc2aa1704c Mon Sep 17 00:00:00 2001 From: Manyfestation <240733973+Manyfestation@users.noreply.github.com> Date: Mon, 16 Mar 2026 22:02:05 +0200 Subject: [PATCH 1/6] debugger support for structs args --- debugger/cli/README.md | 30 +- debugger/cli/src/main.rs | 3 +- debugger/cli/tests/cli_tests.rs | 255 ++++++++++++- debugger/session/src/args.rs | 157 +++++++- debugger/session/src/presentation.rs | 13 +- debugger/session/src/session.rs | 354 +++++++++++++----- debugger/session/src/util.rs | 11 + debugger/session/tests/debug_session_tests.rs | 117 +++++- silverscript-lang/src/compiler.rs | 4 +- .../src/compiler/debug_recording.rs | 112 +++++- silverscript-lang/src/debug_info.rs | 20 +- silverscript-lang/tests/compiler_tests.rs | 73 +++- 12 files changed, 1002 insertions(+), 147 deletions(-) diff --git a/debugger/cli/README.md b/debugger/cli/README.md index cc332601..43b1982f 100644 --- a/debugger/cli/README.md +++ b/debugger/cli/README.md @@ -13,6 +13,13 @@ cli-debugger -f [--ctor-arg ]... [--arg ]... cli-debugger ./counter.sil -f check --ctor-arg 10 --arg 7 ``` +Structured `State` and struct-like args use JSON: + +```bash +cli-debugger ./vault.sil -f inspect --arg '{"amount":7,"tag":"0xbeef"}' +cli-debugger ./vault.sil -f inspect_many --arg '[{"amount":7},{"amount":9}]' +``` + --- ## Interactive Debugging @@ -46,7 +53,7 @@ Stepping through 42 bytes of script (sdb) vars Contract Constants: threshold (int) = 10 -Entrypoint Parameters: +Call Arguments: value (int) = 7 Locals: doubled (int) = 14 @@ -93,6 +100,27 @@ Run `.test.json` suites non-interactively to verify logic in bulk. If you pass a The debugger will report `PASS` if the script result matches your `expect` field (either `pass` or `fail`). +Structured args use the same JSON object and object-array form inside `.test.json`: + +```json +{ + "tests": [ + { + "name": "inspect_state", + "function": "inspect", + "args": [{ "amount": 7, "tag": "0xbeef" }], + "expect": "pass" + }, + { + "name": "inspect_many_states", + "function": "inspect_many", + "args": [[{ "amount": 7 }, { "amount": 9 }]], + "expect": "pass" + } + ] +} +``` + ### Commands ```bash diff --git a/debugger/cli/src/main.rs b/debugger/cli/src/main.rs index 774c2b64..faaec5f2 100644 --- a/debugger/cli/src/main.rs +++ b/debugger/cli/src/main.rs @@ -130,7 +130,8 @@ fn show_vars(session: &DebugSession<'_, '_>) { print_variable_section(session, "Contract Constants", &variables, |origin| { matches!(origin, VariableOrigin::ConstructorArg | VariableOrigin::Constant) }); - print_variable_section(session, "Entrypoint Parameters", &variables, |origin| origin == VariableOrigin::Param); + print_variable_section(session, "Contract State", &variables, |origin| origin == VariableOrigin::ContractField); + print_variable_section(session, "Call Arguments", &variables, |origin| origin == VariableOrigin::Param); print_variable_section(session, "Locals", &variables, |origin| origin == VariableOrigin::Local); } } diff --git a/debugger/cli/tests/cli_tests.rs b/debugger/cli/tests/cli_tests.rs index d7f740ef..4589aeab 100644 --- a/debugger/cli/tests/cli_tests.rs +++ b/debugger/cli/tests/cli_tests.rs @@ -6,7 +6,12 @@ fn write_test_fixture() -> (std::path::PathBuf, std::path::PathBuf) { write_named_test_fixture("simple.sil", "simple.test.json") } -fn write_named_test_fixture(script_name: &str, test_file_name: &str) -> (std::path::PathBuf, std::path::PathBuf) { +fn write_fixture_files( + script_name: &str, + test_file_name: &str, + script_source: &str, + test_file_source: &str, +) -> (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"); @@ -14,8 +19,16 @@ fn write_named_test_fixture(script_name: &str, test_file_name: &str) -> (std::pa let script_path = dir.join(script_name); let test_file_path = dir.join(test_file_name); - std::fs::write( - &script_path, + std::fs::write(&script_path, script_source).expect("write fixture contract"); + std::fs::write(&test_file_path, test_file_source).expect("write fixture test file"); + + (script_path, test_file_path) +} + +fn write_named_test_fixture(script_name: &str, test_file_name: &str) -> (std::path::PathBuf, std::path::PathBuf) { + write_fixture_files( + script_name, + test_file_name, r#"pragma silverscript ^0.1.0; contract Simple(int x) { @@ -24,11 +37,6 @@ contract Simple(int x) { } } "#, - ) - .expect("write fixture contract"); - - std::fs::write( - &test_file_path, r#"{ "tests": [ { @@ -49,9 +57,98 @@ contract Simple(int x) { } "#, ) - .expect("write fixture test file"); +} - (script_path, test_file_path) +fn write_structured_args_fixture() -> (std::path::PathBuf, std::path::PathBuf) { + write_fixture_files( + "structured_args.sil", + "structured_args.test.json", + r#"pragma silverscript ^0.1.0; + +contract StructuredArgs() { + int amount = 1; + byte[32] owner = 0x1111111111111111111111111111111111111111111111111111111111111111; + + entrypoint function inspect(State next) { + int bumped = next.amount + 1; + require(bumped > amount); + } + + entrypoint function inspect_many(State[] next_states) { + require(next_states.length == 2); + } +} +"#, + r#"{ + "tests": [ + { + "name": "object_arg_pass", + "function": "inspect", + "args": [ + { + "amount": 7, + "owner": "0x2222222222222222222222222222222222222222222222222222222222222222" + } + ], + "expect": "pass" + }, + { + "name": "object_array_arg_pass", + "function": "inspect_many", + "args": [ + [ + { + "amount": 7, + "owner": "0x2222222222222222222222222222222222222222222222222222222222222222" + }, + { + "amount": 9, + "owner": "0x3333333333333333333333333333333333333333333333333333333333333333" + } + ] + ], + "expect": "pass" + } + ] +} +"#, + ) +} + +fn write_structured_ctor_fixture() -> (std::path::PathBuf, std::path::PathBuf) { + write_fixture_files( + "structured_ctor.sil", + "structured_ctor.test.json", + r#"pragma silverscript ^0.1.0; + +contract StructuredCtor(Pair seed) { + struct Pair { + int amount; + byte[2] code; + } + + entrypoint function inspect() { + require(true); + } +} +"#, + r#"{ + "tests": [ + { + "name": "struct_ctor_pass", + "function": "inspect", + "constructor_args": [ + { + "amount": 7, + "code": "0x1234" + } + ], + "expect": "pass" + } + ] +} +"#, + ) } #[test] @@ -156,6 +253,99 @@ fn cli_debugger_eval_command_reports_results_and_errors() { ); } +#[test] +fn cli_debugger_accepts_state_object_arg_and_renders_source_level_value() { + let (script_path, _test_file_path) = write_structured_args_fixture(); + + let mut child = Command::new(env!("CARGO_BIN_EXE_cli-debugger")) + .arg(&script_path) + .arg("--function") + .arg("inspect") + .arg("--arg") + .arg(r#"{"amount":7,"owner":"0x2222222222222222222222222222222222222222222222222222222222222222"}"#) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("failed to spawn cli-debugger"); + + let input = b"vars\np next\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); + let rendered = "{amount: 7, owner: 0x2222222222222222222222222222222222222222222222222222222222222222}"; + + assert!(stderr.is_empty(), "unexpected stderr: {stderr}"); + assert!(stdout.contains("Contract State:"), "missing Contract State section: {stdout}"); + assert!(stdout.contains("Call Arguments:"), "missing Call Arguments section: {stdout}"); + assert!(stdout.contains(&format!("next (State) = {rendered}")), "missing rendered State value: {stdout}"); +} + +#[test] +fn cli_debugger_accepts_state_object_array_arg_and_renders_source_level_value() { + let (script_path, _test_file_path) = write_structured_args_fixture(); + + let mut child = Command::new(env!("CARGO_BIN_EXE_cli-debugger")) + .arg(&script_path) + .arg("--function") + .arg("inspect_many") + .arg("--arg") + .arg( + r#"[{"amount":7,"owner":"0x2222222222222222222222222222222222222222222222222222222222222222"},{"amount":9,"owner":"0x3333333333333333333333333333333333333333333333333333333333333333"}]"#, + ) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("failed to spawn cli-debugger"); + + let input = b"vars\np next_states\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); + let rendered = "[{amount: 7, owner: 0x2222222222222222222222222222222222222222222222222222222222222222}, {amount: 9, owner: 0x3333333333333333333333333333333333333333333333333333333333333333}]"; + + assert!(stderr.is_empty(), "unexpected stderr: {stderr}"); + assert!(stdout.contains(&format!("next_states (State[]) = {rendered}")), "missing rendered State[] value: {stdout}"); +} + +#[test] +fn cli_debugger_accepts_struct_constructor_arg_and_renders_source_level_value() { + let (script_path, _test_file_path) = write_structured_ctor_fixture(); + + let mut child = Command::new(env!("CARGO_BIN_EXE_cli-debugger")) + .arg(&script_path) + .arg("--function") + .arg("inspect") + .arg("--ctor-arg") + .arg(r#"{"amount":7,"code":"0x1234"}"#) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("failed to spawn cli-debugger"); + + let input = b"vars\np seed\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("seed (Pair) = {amount: 7, code: 0x1234}"), "missing rendered constructor struct value: {stdout}"); +} + #[test] fn cli_debugger_run_test_file_pass_case() { let (_script_path, test_file_path) = write_test_fixture(); @@ -225,6 +415,51 @@ fn cli_debugger_run_all_uses_test_file_suite() { assert!(stdout.contains("2 tests: 2 passed, 0 failed"), "missing summary line: {stdout}"); } +#[test] +fn cli_debugger_run_all_supports_structured_args_from_test_file() { + let (_script_path, test_file_path) = write_structured_args_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 for structured args"); + + assert!( + output.status.success(), + "expected success for structured 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 object_arg_pass"), "missing object_arg_pass line: {stdout}"); + assert!(stdout.contains("PASS object_array_arg_pass"), "missing object_array_arg_pass line: {stdout}"); + assert!(stdout.contains("2 tests: 2 passed, 0 failed"), "missing summary line: {stdout}"); +} + +#[test] +fn cli_debugger_run_all_supports_structured_constructor_args_from_test_file() { + let (_script_path, test_file_path) = write_structured_ctor_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 for structured ctor args"); + + assert!( + output.status.success(), + "expected success for structured ctor 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 struct_ctor_pass"), "missing struct_ctor_pass line: {stdout}"); + assert!(stdout.contains("1 tests: 1 passed, 0 failed"), "missing summary line: {stdout}"); +} + #[test] fn cli_debugger_run_all_infers_test_file_from_script_path() { let (script_path, _test_file_path) = write_test_fixture(); diff --git a/debugger/session/src/args.rs b/debugger/session/src/args.rs index e796a541..ae561135 100644 --- a/debugger/session/src/args.rs +++ b/debugger/session/src/args.rs @@ -1,4 +1,6 @@ +use serde_json::Value; use silverscript_lang::ast::{ContractAst, Expr, ExprKind}; +use silverscript_lang::compiler::struct_object; use silverscript_lang::span; pub fn parse_int_arg(raw: &str) -> Result { @@ -28,23 +30,72 @@ pub fn bytes_expr(bytes: Vec) -> Expr<'static> { Expr::new(ExprKind::Array(bytes.into_iter().map(Expr::byte).collect()), span::Span::default()) } +fn json_value_to_untyped_expr(value: &Value) -> Result, String> { + match value { + Value::Number(n) => Ok(Expr::int(n.as_i64().ok_or_else(|| "invalid int value".to_string())?)), + Value::Bool(b) => Ok(Expr::bool(*b)), + Value::String(s) => { + if s.starts_with("0x") || s.starts_with("0X") { + let bytes = parse_hex_bytes(s)?; + if bytes.len() == 1 { Ok(Expr::byte(bytes[0])) } else { Ok(bytes_expr(bytes)) } + } else { + Ok(Expr::string(s.clone())) + } + } + Value::Array(values) => values + .iter() + .map(json_value_to_untyped_expr) + .collect::, _>>() + .map(|values| Expr::new(ExprKind::Array(values), span::Span::default())), + Value::Object(fields) => { + let mut expr_fields = Vec::with_capacity(fields.len()); + for (name, value) in fields { + expr_fields.push((Box::leak(name.clone().into_boxed_str()) as &'static str, json_value_to_untyped_expr(value)?)); + } + Ok(struct_object(expr_fields)) + } + Value::Null => Err("null is not supported in structured args".to_string()), + } +} + +fn json_value_to_typed_expr(type_name: &str, value: &Value) -> Result, String> { + if let Some(element_type) = type_name.strip_suffix("[]") { + match value { + Value::Array(values) => values + .iter() + .map(|value| json_value_to_typed_expr(element_type, value)) + .collect::, _>>() + .map(|values| Expr::new(ExprKind::Array(values), span::Span::default())), + Value::String(raw) if element_type == "byte" => Ok(bytes_expr(parse_hex_bytes(raw)?)), + _ => Err(format!("unsupported array literal format for '{type_name}'")), + } + } else { + match value { + Value::String(raw) => parse_typed_arg(type_name, raw), + Value::Number(raw) if type_name == "int" => Ok(Expr::int(raw.as_i64().ok_or_else(|| "invalid int value".to_string())?)), + Value::Number(raw) if type_name == "byte" => { + let value = raw.as_u64().ok_or_else(|| "invalid byte value".to_string())?; + let byte = u8::try_from(value).map_err(|_| format!("byte expects value in 0..=255, got {value}"))?; + Ok(Expr::byte(byte)) + } + Value::Bool(raw) if type_name == "bool" => Ok(Expr::bool(*raw)), + Value::Object(_) => json_value_to_untyped_expr(value), + _ => Err(format!("unsupported arg value for '{type_name}'")), + } + } +} + pub fn parse_typed_arg(type_name: &str, raw: &str) -> Result, String> { + let trimmed = raw.trim(); + 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())); + let values = serde_json::from_str::>(trimmed).map_err(|err| format!("invalid array arg '{raw}': {err}"))?; + return values + .iter() + .map(|value| json_value_to_typed_expr(element_type, value)) + .collect::, _>>() + .map(|values| Expr::new(ExprKind::Array(values), span::Span::default())); } if element_type == "byte" { return Ok(bytes_expr(parse_hex_bytes(trimmed)?)); @@ -52,6 +103,15 @@ pub fn parse_typed_arg(type_name: &str, raw: &str) -> Result, Stri return Err(format!("unsupported array literal format for '{type_name}'")); } + if trimmed == "null" { + return Err("null is not supported in structured args".to_string()); + } + + if trimmed.starts_with('{') { + let value = serde_json::from_str::(trimmed).map_err(|err| format!("invalid {type_name} arg '{raw}': {err}"))?; + return json_value_to_untyped_expr(&value); + } + match type_name { "int" => Ok(Expr::int(parse_int_arg(raw)?)), "bool" => match raw { @@ -105,6 +165,75 @@ pub fn parse_typed_arg(type_name: &str, raw: &str) -> Result, Stri } } +#[cfg(test)] +mod tests { + use super::{parse_ctor_args, parse_typed_arg}; + use silverscript_lang::ast::{ExprKind, parse_contract_ast}; + + #[test] + fn parses_state_object_arg() { + let parsed = parse_typed_arg("State", r#"{"amount": 7, "owner": "0x11"}"#).expect("parse State"); + assert!(matches!(parsed.kind, ExprKind::StateObject(_))); + } + + #[test] + fn parses_declared_struct_object_arg() { + let parsed = parse_typed_arg("Pair", r#"{"amount": 7, "owner": "0x11"}"#).expect("parse struct"); + assert!(matches!(parsed.kind, ExprKind::StateObject(_))); + } + + #[test] + fn parses_state_object_array_arg() { + let parsed = parse_typed_arg("State[]", r#"[{"amount": 7}, {"amount": 9}]"#).expect("parse State[]"); + assert!(matches!(parsed.kind, ExprKind::Array(_))); + } + + #[test] + fn parses_struct_array_arg_with_fixed_bytes_fields() { + let parsed = parse_typed_arg("Pair[]", r#"[{"amount": 7, "code": "0x0102"}]"#).expect("parse struct[]"); + let ExprKind::Array(values) = parsed.kind else { + panic!("expected array expr"); + }; + assert_eq!(values.len(), 1); + assert!(matches!(values[0].kind, ExprKind::StateObject(_))); + } + + #[test] + fn rejects_null_in_structured_args() { + let error = parse_typed_arg("State", "null").expect_err("null should be rejected"); + assert!(error.contains("null")); + } + + #[test] + fn rejects_malformed_json_structured_args() { + let error = parse_typed_arg("State[]", "[{]").expect_err("malformed JSON should fail"); + assert!(error.contains("invalid array arg")); + } + + #[test] + fn parses_struct_constructor_arg() { + let contract = parse_contract_ast( + r#" + contract Demo(Pair seed) { + struct Pair { + int amount; + byte[2] code; + } + + entrypoint function inspect() { + require(true); + } + } + "#, + ) + .expect("parse contract"); + + let args = parse_ctor_args(&contract, &[r#"{"amount": 7, "code": "0x1234"}"#.to_string()]).expect("parse ctor args"); + assert_eq!(args.len(), 1); + assert!(matches!(args[0].kind, ExprKind::StateObject(_))); + } +} + 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())); diff --git a/debugger/session/src/presentation.rs b/debugger/session/src/presentation.rs index dafb23f6..d78279c4 100644 --- a/debugger/session/src/presentation.rs +++ b/debugger/session/src/presentation.rs @@ -1,7 +1,7 @@ use silverscript_lang::debug_info::SourceSpan; use crate::session::{DebugValue, FailureReport}; -use crate::util::{decode_i64, encode_hex}; +use crate::util::{decode_i64, encode_hex, fixed_array_element_size}; #[derive(Debug, Clone)] pub struct SourceContextLine { @@ -39,7 +39,7 @@ pub fn format_value(type_name: &str, value: &DebugValue) -> String { (_, 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 { + let Some(element_size) = fixed_array_element_size(element_type) else { return format!("0x{}", encode_hex(bytes)); }; if element_size == 0 || bytes.len() % element_size != 0 { @@ -108,15 +108,6 @@ fn concise_reason(reason: &str) -> String { } } -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(); diff --git a/debugger/session/src/session.rs b/debugger/session/src/session.rs index cf140405..bec5c458 100644 --- a/debugger/session/src/session.rs +++ b/debugger/session/src/session.rs @@ -9,14 +9,15 @@ use kaspa_txscript::{DynOpcodeImplementation, EngineCtx, EngineFlags, TxScriptEn use serde::{Deserialize, Serialize}; use silverscript_lang::ast::{Expr, ExprKind, parse_expression_ast}; -use silverscript_lang::compiler::compile_debug_expr; +use silverscript_lang::compiler::{compile_debug_expr, flattened_struct_name}; use silverscript_lang::debug_info::{ - DebugFunctionRange, DebugInfo, DebugNamedValue, DebugStep, DebugVariableUpdate, RuntimeBinding, SourceSpan, StepId, StepKind, + DebugFunctionRange, DebugInfo, DebugNamedValue, DebugParamBinding, DebugParamLeafBinding, DebugStep, DebugVariableUpdate, + RuntimeBinding, 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}; +use crate::util::{decode_i64, encode_hex, fixed_array_element_size}; pub type DebugTx<'a> = PopulatedTransaction<'a>; pub type DebugReused = SigHashReusedValuesUnsync; @@ -48,6 +49,7 @@ pub enum DebugValue { pub enum VariableOrigin { Local, Param, + ContractField, ConstructorArg, Constant, } @@ -57,6 +59,7 @@ impl VariableOrigin { match self { Self::Local => "local", Self::Param => "arg", + Self::ContractField => "state", Self::ConstructorArg => "ctor", Self::Constant => "const", } @@ -166,6 +169,7 @@ struct VisibleScope<'a, 'i> { #[derive(Clone)] enum ScopeValueSource<'i> { RuntimeSlot { from_top: i64 }, + StructuredParam { leaf_bindings: Vec }, Expr(Expr<'i>), } @@ -596,20 +600,58 @@ impl<'a, 'i> DebugSession<'a, 'i> { fn scope_state(&self, step_id: StepId) -> Result, String> { let scope = self.visible_scope(step_id)?; - Ok(self.scope_state_from_visible(&scope)) - } - - fn scope_state_from_visible(&self, scope: &VisibleScope<'_, 'i>) -> ScopeState<'i> { let mut bindings = HashMap::new(); + let function_params: Vec<_> = self.debug_info.params.iter().filter(|param| param.function == scope.context.function_name).collect(); + let mut contract_field_names = HashSet::new(); + let mut expected_stack_index = 0_i64; + + for param in function_params.iter().rev() { + match ¶m.binding { + DebugParamBinding::SingleValue { stack_index } if *stack_index == expected_stack_index => { + contract_field_names.insert(param.name.clone()); + expected_stack_index += 1; + } + _ => break, + } + } - for param in self.debug_info.params.iter().filter(|param| param.function == scope.context.function_name) { - bindings.entry(param.name.clone()).or_insert_with(|| ScopeBinding { - type_name: param.type_name.clone(), - source: ScopeValueSource::RuntimeSlot { from_top: param.stack_index }, - origin: VariableOrigin::Param, - is_constant: false, - hidden: false, - }); + for param in function_params { + let origin = if contract_field_names.contains(¶m.name) { + VariableOrigin::ContractField + } else { + VariableOrigin::Param + }; + match ¶m.binding { + DebugParamBinding::SingleValue { stack_index } => { + bindings.entry(param.name.clone()).or_insert_with(|| ScopeBinding { + type_name: param.type_name.clone(), + source: ScopeValueSource::RuntimeSlot { from_top: *stack_index }, + origin, + is_constant: false, + hidden: false, + }); + } + DebugParamBinding::StructuredValue { leaf_bindings } => { + let leaf_bindings = leaf_bindings.clone(); + bindings.entry(param.name.clone()).or_insert_with(|| ScopeBinding { + type_name: param.type_name.clone(), + source: ScopeValueSource::StructuredParam { leaf_bindings: leaf_bindings.clone() }, + origin, + is_constant: false, + hidden: false, + }); + for leaf in &leaf_bindings { + let leaf_name = flattened_struct_name(¶m.name, &leaf.field_path); + bindings.entry(leaf_name).or_insert_with(|| ScopeBinding { + type_name: leaf.type_name.clone(), + source: ScopeValueSource::RuntimeSlot { from_top: leaf.stack_index }, + origin, + is_constant: false, + hidden: true, + }); + } + } + } } record_debug_named_values(&mut bindings, &self.debug_info.constructor_args, VariableOrigin::ConstructorArg, false); @@ -636,7 +678,7 @@ impl<'a, 'i> DebugSession<'a, 'i> { }); } - bindings + Ok(bindings) } fn collect_variables_map(&self, scope_state: &ScopeState<'i>) -> Result, String> { @@ -939,11 +981,12 @@ impl<'a, 'i> DebugSession<'a, 'i> { fn resolve_scope_binding(&self, scope_state: &ScopeState<'i>, binding: &ScopeBinding<'i>) -> Result { let mut visiting = HashSet::new(); - if let Some(value) = try_resolve_binding_value(scope_state, binding, &mut visiting) { + if let Some(value) = self.try_resolve_binding_value(scope_state, binding, &mut visiting) { return Ok(value); } match &binding.source { ScopeValueSource::RuntimeSlot { from_top } => self.read_stack_value(*from_top, &binding.type_name), + ScopeValueSource::StructuredParam { leaf_bindings } => self.read_structured_param_value(&binding.type_name, leaf_bindings), ScopeValueSource::Expr(expr) => self.evaluate_scope_expr_as(scope_state, expr, &binding.type_name), } } @@ -971,6 +1014,7 @@ impl<'a, 'i> DebugSession<'a, 'i> { ShadowBindingValue { name: name.clone(), stack_index: *from_top, value: self.read_stack_at_index(*from_top)? }, ); } + ScopeValueSource::StructuredParam { .. } => {} ScopeValueSource::Expr(expr) => { env.insert(name.clone(), expr.clone()); } @@ -1033,10 +1077,127 @@ impl<'a, 'i> DebugSession<'a, 'i> { let bytes = self.read_stack_at_index(index)?; decode_value_by_type(type_name, bytes) } + + fn read_structured_param_value(&self, type_name: &str, leaf_bindings: &[DebugParamLeafBinding]) -> Result { + if type_name.ends_with("[]") { + let mut leaf_arrays = Vec::with_capacity(leaf_bindings.len()); + let mut expected_len = None; + for leaf in leaf_bindings { + let value = self.read_stack_value(leaf.stack_index, &leaf.type_name)?; + let DebugValue::Array(values) = value else { + return Err(format!("structured array leaf '{}' did not decode to an array", format_field_path(&leaf.field_path))); + }; + if let Some(length) = expected_len { + if values.len() != length { + return Err("structured array leaves have mismatched lengths".to_string()); + } + } else { + expected_len = Some(values.len()); + } + leaf_arrays.push((leaf.field_path.clone(), values)); + } + + let mut items = Vec::with_capacity(expected_len.unwrap_or(0)); + for index in 0..expected_len.unwrap_or(0) { + let mut fields = Vec::new(); + for (field_path, values) in &leaf_arrays { + let value = values.get(index).cloned().ok_or_else(|| "structured array leaf index out of range".to_string())?; + insert_object_path(&mut fields, field_path, value)?; + } + items.push(DebugValue::Object(fields)); + } + return Ok(DebugValue::Array(items)); + } + + let mut fields = Vec::with_capacity(leaf_bindings.len()); + for leaf in leaf_bindings { + let value = self.read_stack_value(leaf.stack_index, &leaf.type_name)?; + insert_object_path(&mut fields, &leaf.field_path, value)?; + } + Ok(DebugValue::Object(fields)) + } + + fn try_resolve_binding_value( + &self, + scope_state: &ScopeState<'i>, + binding: &ScopeBinding<'i>, + visiting: &mut HashSet, + ) -> Option { + match &binding.source { + ScopeValueSource::RuntimeSlot { from_top } => self.read_stack_value(*from_top, &binding.type_name).ok(), + ScopeValueSource::StructuredParam { leaf_bindings } => { + self.read_structured_param_value(&binding.type_name, leaf_bindings).ok() + } + ScopeValueSource::Expr(expr) => self.try_resolve_expr_value(scope_state, expr, visiting), + } + } + + fn try_resolve_expr_value( + &self, + scope_state: &ScopeState<'i>, + expr: &Expr<'i>, + visiting: &mut HashSet, + ) -> Option { + match &expr.kind { + ExprKind::Int(value) => Some(DebugValue::Int(*value)), + ExprKind::Bool(value) => Some(DebugValue::Bool(*value)), + ExprKind::Byte(value) => Some(DebugValue::Bytes(vec![*value])), + ExprKind::String(value) => Some(DebugValue::String(value.clone())), + ExprKind::Array(values) => { + if values.iter().all(|value| matches!(value.kind, ExprKind::Byte(_))) { + let bytes = values + .iter() + .map(|value| match value.kind { + ExprKind::Byte(byte) => byte, + _ => unreachable!("checked"), + }) + .collect(); + Some(DebugValue::Bytes(bytes)) + } else { + let mut items = Vec::with_capacity(values.len()); + for value in values { + let item = self.try_resolve_expr_value(scope_state, value, visiting)?; + items.push(item); + } + Some(DebugValue::Array(items)) + } + } + ExprKind::StateObject(fields) => { + let mut values = Vec::with_capacity(fields.len()); + for field in fields { + let value = self.try_resolve_expr_value(scope_state, &field.expr, visiting)?; + values.push((field.name.clone(), value)); + } + Some(DebugValue::Object(values)) + } + ExprKind::Identifier(name) => { + if !visiting.insert(name.clone()) { + return None; + } + let resolved = + scope_state.get(name).and_then(|binding| self.try_resolve_binding_value(scope_state, binding, visiting)); + visiting.remove(name); + resolved + } + ExprKind::FieldAccess { source, field, .. } => { + let Some(DebugValue::Object(fields)) = self.try_resolve_expr_value(scope_state, source, visiting) else { + return None; + }; + fields.into_iter().find_map(|(name, value)| (name == *field).then_some(value)) + } + _ => None, + } + } } /// Decodes raw bytes into a typed debug value based on the type name. fn decode_value_by_type(type_name: &str, bytes: Vec) -> Result { + if let Some(element_type) = type_name.strip_suffix("[]") { + if let Some(element_size) = fixed_array_element_size(element_type) { + return decode_known_width_array(type_name, bytes, element_type, element_size); + } + } + match type_name { "int" => Ok(DebugValue::Int(decode_i64(&bytes)?)), "bool" => Ok(DebugValue::Bool(decode_i64(&bytes)? != 0)), @@ -1048,66 +1209,49 @@ fn decode_value_by_type(type_name: &str, bytes: Vec) -> Result( - scope_state: &ScopeState<'i>, - binding: &ScopeBinding<'i>, - visiting: &mut HashSet, -) -> Option { - match &binding.source { - ScopeValueSource::RuntimeSlot { .. } => None, - ScopeValueSource::Expr(expr) => try_resolve_expr_value(scope_state, expr, visiting), +fn decode_known_width_array(type_name: &str, bytes: Vec, element_type: &str, element_size: usize) -> Result { + if element_size == 0 { + return Err(format!("array element type '{type_name}' has zero width")); + } + if bytes.len() % element_size != 0 { + return Err(format!("encoded value for '{type_name}' has invalid length {}", bytes.len())); + } + + let mut values = Vec::with_capacity(bytes.len() / element_size); + for chunk in bytes.chunks(element_size) { + values.push(decode_value_by_type(element_type, chunk.to_vec())?); } + Ok(DebugValue::Array(values)) } -fn try_resolve_expr_value<'i>(scope_state: &ScopeState<'i>, expr: &Expr<'i>, visiting: &mut HashSet) -> Option { - match &expr.kind { - ExprKind::Int(value) => Some(DebugValue::Int(*value)), - ExprKind::Bool(value) => Some(DebugValue::Bool(*value)), - ExprKind::Byte(value) => Some(DebugValue::Bytes(vec![*value])), - ExprKind::String(value) => Some(DebugValue::String(value.clone())), - ExprKind::Array(values) => { - if values.iter().all(|value| matches!(value.kind, ExprKind::Byte(_))) { - let bytes = values - .iter() - .map(|value| match value.kind { - ExprKind::Byte(byte) => byte, - _ => unreachable!("checked"), - }) - .collect(); - Some(DebugValue::Bytes(bytes)) - } else { - let mut items = Vec::with_capacity(values.len()); - for value in values { - let item = try_resolve_expr_value(scope_state, value, visiting)?; - items.push(item); - } - Some(DebugValue::Array(items)) - } - } - ExprKind::StateObject(fields) => { - let mut values = Vec::with_capacity(fields.len()); - for field in fields { - let value = try_resolve_expr_value(scope_state, &field.expr, visiting)?; - values.push((field.name.clone(), value)); - } - Some(DebugValue::Object(values)) - } - ExprKind::Identifier(name) => { - if !visiting.insert(name.clone()) { - return None; - } - let resolved = scope_state.get(name).and_then(|binding| try_resolve_binding_value(scope_state, binding, visiting)); - visiting.remove(name); - resolved - } - ExprKind::FieldAccess { source, field, .. } => { - let Some(DebugValue::Object(fields)) = try_resolve_expr_value(scope_state, source, visiting) else { - return None; - }; - fields.into_iter().find_map(|(name, value)| (name == *field).then_some(value)) +fn insert_object_path(fields: &mut Vec<(String, DebugValue)>, path: &[String], value: DebugValue) -> Result<(), String> { + let Some((field_name, rest)) = path.split_first() else { + return Err("structured field path cannot be empty".to_string()); + }; + + if rest.is_empty() { + if fields.iter().any(|(name, _)| name == field_name) { + return Err(format!("duplicate structured field '{field_name}'")); } - _ => None, + fields.push((field_name.clone(), value)); + return Ok(()); } + + if let Some((_, existing)) = fields.iter_mut().find(|(name, _)| name == field_name) { + let DebugValue::Object(children) = existing else { + return Err(format!("structured field '{field_name}' is not an object")); + }; + return insert_object_path(children, rest, value); + } + + let mut children = Vec::new(); + insert_object_path(&mut children, rest, value)?; + fields.push((field_name.clone(), DebugValue::Object(children))); + Ok(()) +} + +fn format_field_path(path: &[String]) -> String { + if path.is_empty() { "".to_string() } else { path.join(".") } } /// Executes sigscript to seed the stack before debugging lockscript. @@ -1173,10 +1317,29 @@ mod tests { use silverscript_lang::ast::{BinaryOp, Expr, ExprKind, StateFieldExpr}; use silverscript_lang::debug_info::{ - DebugFunctionRange, DebugInfo, DebugNamedValue, DebugParamMapping, DebugStep, DebugVariableUpdate, SourceSpan, StepKind, + DebugFunctionRange, DebugInfo, DebugNamedValue, DebugParamBinding, DebugParamLeafBinding, DebugParamMapping, DebugStep, + DebugVariableUpdate, SourceSpan, StepKind, }; use silverscript_lang::span; + fn scalar_param(name: &str, type_name: &str, stack_index: i64) -> DebugParamMapping { + DebugParamMapping { + name: name.to_string(), + type_name: type_name.to_string(), + binding: DebugParamBinding::SingleValue { stack_index }, + function: "f".to_string(), + } + } + + fn structured_param(name: &str, type_name: &str, leaf_bindings: Vec) -> DebugParamMapping { + DebugParamMapping { + name: name.to_string(), + type_name: type_name.to_string(), + binding: DebugParamBinding::StructuredValue { leaf_bindings }, + function: "f".to_string(), + } + } + fn make_session( params: Vec, steps: Vec>, @@ -1212,15 +1375,7 @@ mod tests { 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 session = make_session(vec![scalar_param("a", "int", 1), scalar_param("b", "int", 0)], vec![], &sigscript).unwrap(); let update = DebugVariableUpdate { name: "x".to_string(), @@ -1243,7 +1398,7 @@ mod tests { 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![scalar_param("a", "int", 0)], vec![DebugStep { bytecode_start: 0, bytecode_end: 0, @@ -1278,7 +1433,7 @@ mod tests { 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![scalar_param("a", "int", 0)], vec![DebugStep { bytecode_start: 0, bytecode_end: 0, @@ -1376,7 +1531,7 @@ mod tests { 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![scalar_param("a", "int", 0)], vec![DebugStep { bytecode_start: 0, bytecode_end: 0, @@ -1533,4 +1688,33 @@ mod tests { let unknown_err = session.evaluate_expression("missing + 1").unwrap_err(); assert!(unknown_err.contains("undefined identifier: missing")); } + + #[test] + fn structured_param_reconstructs_object_and_exposes_hidden_leaf_bindings() { + let mut sig_builder = ScriptBuilder::new(); + sig_builder.add_i64(7).unwrap(); + sig_builder.add_data(&[0x12, 0x34]).unwrap(); + let sigscript = sig_builder.drain(); + + let session = make_session( + vec![structured_param( + "next", + "State", + vec![ + DebugParamLeafBinding { field_path: vec!["amount".to_string()], type_name: "int".to_string(), stack_index: 1 }, + DebugParamLeafBinding { field_path: vec!["code".to_string()], type_name: "byte[2]".to_string(), stack_index: 0 }, + ], + )], + vec![], + &sigscript, + ) + .unwrap(); + + let next = session.variable_by_name("next").expect("structured param should be visible"); + assert_eq!(session.format_value(&next.type_name, &next.value), "{amount: 7, code: 0x1234}"); + + let scope_state = session.scope_state(StepId::ROOT).expect("scope state"); + assert!(scope_state.contains_key("__struct_next_amount")); + assert!(scope_state.get("__struct_next_amount").is_some_and(|binding| binding.hidden)); + } } diff --git a/debugger/session/src/util.rs b/debugger/session/src/util.rs index aac8e8d2..89d88f18 100644 --- a/debugger/session/src/util.rs +++ b/debugger/session/src/util.rs @@ -25,3 +25,14 @@ pub fn encode_hex(bytes: &[u8]) -> String { } String::from_utf8(out).unwrap_or_default() } + +pub fn fixed_array_element_size(type_name: &str) -> Option { + match type_name { + "int" => Some(8), + "bool" => Some(1), + "byte" => Some(1), + other => other.strip_prefix("bytes").and_then(|value| value.parse::().ok()).or_else(|| { + other.strip_prefix("byte[").and_then(|value| value.strip_suffix(']')).and_then(|value| value.parse::().ok()) + }), + } +} diff --git a/debugger/session/tests/debug_session_tests.rs b/debugger/session/tests/debug_session_tests.rs index 7a62eae5..9d1c1b9d 100644 --- a/debugger/session/tests/debug_session_tests.rs +++ b/debugger/session/tests/debug_session_tests.rs @@ -14,7 +14,7 @@ use kaspa_txscript::{EngineCtx, EngineFlags}; use debugger_session::session::{DebugSession, DebugValue, ShadowTxContext}; use silverscript_lang::ast::{Expr, ExprKind, parse_contract_ast}; -use silverscript_lang::compiler::{CompileOptions, compile_contract}; +use silverscript_lang::compiler::{CompileOptions, compile_contract, struct_object}; use silverscript_lang::debug_info::StepKind; const IF_STATEMENT_CONTRACT: &str = r#"pragma silverscript ^0.1.0; @@ -849,6 +849,121 @@ contract ShiftedBindings() { ) } +#[test] +fn debug_session_classifies_contract_fields_separately_from_entrypoint_params() -> Result<(), Box> { + let source = r#"pragma silverscript ^0.1.0; + +contract ScopeKinds() { + int amount = 1; + byte[32] owner = 0x1111111111111111111111111111111111111111111111111111111111111111; + byte[2] tag = 0xabcd; + + entrypoint function inspect(State next) { + require(next.amount > amount); + } +} +"#; + + with_session_for_source( + source, + vec![], + "inspect", + vec![struct_object(vec![ + ("amount", Expr::int(7)), + ("owner", Expr::bytes(vec![0x22u8; 32])), + ("tag", Expr::bytes(vec![0xbe, 0xef])), + ])], + |session| { + session.run_to_first_executed_statement()?; + + let amount = session.variable_by_name("amount")?; + assert_eq!(amount.origin.label(), "state"); + + let owner = session.variable_by_name("owner")?; + assert_eq!(owner.origin.label(), "state"); + + let tag = session.variable_by_name("tag")?; + assert_eq!(tag.origin.label(), "state"); + + let next = session.variable_by_name("next")?; + assert_eq!(next.origin.label(), "arg"); + Ok(()) + }, + ) +} + +#[test] +fn debug_session_formats_live_state_param_as_object_and_keeps_lowered_locals_resolvable() -> Result<(), Box> { + let source = r#"pragma silverscript ^0.1.0; + +contract StructuredStateParam() { + int amount = 1; + byte[32] owner = 0x1111111111111111111111111111111111111111111111111111111111111111; + + entrypoint function inspect(State next) { + int bumped = next.amount + 1; + require(bumped > amount); + } +} +"#; + + with_session_for_source( + source, + vec![], + "inspect", + vec![struct_object(vec![("amount", Expr::int(7)), ("owner", Expr::bytes(vec![0x22u8; 32]))])], + |session| { + session.run_to_first_executed_statement()?; + + let next = session.variable_by_name("next")?; + assert_eq!(session.format_value(&next.type_name, &next.value), format!("{{amount: 7, owner: 0x{}}}", "22".repeat(32))); + + session.step_over()?; + let bumped = session.variable_by_name("bumped")?; + assert_eq!(session.format_value(&bumped.type_name, &bumped.value), "8"); + Ok(()) + }, + ) +} + +#[test] +fn debug_session_formats_live_state_array_param_as_object_array() -> Result<(), Box> { + let source = r#"pragma silverscript ^0.1.0; + +contract StructuredStateArrayParam() { + int amount = 1; + byte[32] owner = 0x1111111111111111111111111111111111111111111111111111111111111111; + + entrypoint function inspect(State[] next_states) { + require(next_states.length == 2); + } +} +"#; + + with_session_for_source( + source, + vec![], + "inspect", + vec![Expr::new( + ExprKind::Array(vec![ + struct_object(vec![("amount", Expr::int(7)), ("owner", Expr::bytes(vec![0x22u8; 32]))]), + struct_object(vec![("amount", Expr::int(9)), ("owner", Expr::bytes(vec![0x33u8; 32]))]), + ]), + Default::default(), + )], + |session| { + session.run_to_first_executed_statement()?; + + let next_states = session.variable_by_name("next_states")?; + assert_eq!( + session.format_value(&next_states.type_name, &next_states.value), + format!("[{{amount: 7, owner: 0x{}}}, {{amount: 9, owner: 0x{}}}]", "22".repeat(32), "33".repeat(32)) + ); + Ok(()) + }, + ) +} + #[test] fn debug_session_nested_inline_calls_with_args_compile_and_step() -> Result<(), Box> { let source = r#"pragma silverscript ^0.1.0; diff --git a/silverscript-lang/src/compiler.rs b/silverscript-lang/src/compiler.rs index bae71f78..dcc75df6 100644 --- a/silverscript-lang/src/compiler.rs +++ b/silverscript-lang/src/compiler.rs @@ -229,7 +229,7 @@ fn validate_struct_graph(structs: &StructRegistry) -> Result<(), CompilerError> Ok(()) } -fn flattened_struct_name(base: &str, path: &[String]) -> String { +pub fn flattened_struct_name(base: &str, path: &[String]) -> String { let mut out = format!("__struct_{base}"); for part in path { out.push('_'); @@ -2396,7 +2396,7 @@ fn compile_entrypoint_function<'i>( } } - recorder.begin_entrypoint(&function.name, function, contract_fields); + recorder.begin_entrypoint(&function.name, function, contract_fields, structs)?; let body_len = function.body.len(); for (index, stmt) in function.body.iter().enumerate() { diff --git a/silverscript-lang/src/compiler/debug_recording.rs b/silverscript-lang/src/compiler/debug_recording.rs index 92f283a8..44038ab4 100644 --- a/silverscript-lang/src/compiler/debug_recording.rs +++ b/silverscript-lang/src/compiler/debug_recording.rs @@ -3,8 +3,8 @@ use std::fmt; use crate::ast::{ConstantAst, ContractFieldAst, Expr, FunctionAst, ParamAst, Statement}; use crate::debug_info::{ - DebugFunctionRange, DebugInfo, DebugInfoRecorder, DebugNamedValue, DebugParamMapping, DebugStep, DebugVariableUpdate, - RuntimeBinding, SourceSpan, StepKind, + DebugFunctionRange, DebugInfo, DebugInfoRecorder, DebugNamedValue, DebugParamBinding, DebugParamLeafBinding, DebugParamMapping, + DebugStep, DebugVariableUpdate, RuntimeBinding, SourceSpan, StepKind, }; use super::{CompilerError, resolve_expr_for_debug}; @@ -35,8 +35,14 @@ impl<'i> DebugRecorder<'i> { } /// 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); + pub fn begin_entrypoint( + &mut self, + name: &str, + function: &FunctionAst<'i>, + contract_fields: &[ContractFieldAst<'i>], + structs: &super::StructRegistry, + ) -> Result<(), CompilerError> { + self.inner.begin_entrypoint(name, function, contract_fields, structs) } /// Finishes the active entrypoint stage and stores its local script length. @@ -109,7 +115,13 @@ impl<'i> DebugRecorder<'i> { trait DebugRecorderImpl<'i>: fmt::Debug { fn record_contract_scope(&mut self, params: &[ParamAst<'i>], values: &[Expr<'i>], constants: &[ConstantAst<'i>]); - fn begin_entrypoint(&mut self, name: &str, function: &FunctionAst<'i>, contract_fields: &[ContractFieldAst<'i>]); + fn begin_entrypoint( + &mut self, + name: &str, + function: &FunctionAst<'i>, + contract_fields: &[ContractFieldAst<'i>], + structs: &super::StructRegistry, + ) -> Result<(), CompilerError>; 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>, stack_bindings: &HashMap); @@ -147,7 +159,15 @@ struct NoopDebugRecorder; impl<'i> DebugRecorderImpl<'i> for NoopDebugRecorder { fn record_contract_scope(&mut self, _params: &[ParamAst<'i>], _values: &[Expr<'i>], _constants: &[ConstantAst<'i>]) {} - fn begin_entrypoint(&mut self, _name: &str, _function: &FunctionAst<'i>, _contract_fields: &[ContractFieldAst<'i>]) {} + fn begin_entrypoint( + &mut self, + _name: &str, + _function: &FunctionAst<'i>, + _contract_fields: &[ContractFieldAst<'i>], + _structs: &super::StructRegistry, + ) -> Result<(), CompilerError> { + Ok(()) + } fn finish_entrypoint(&mut self, _script_len: usize) {} fn set_entrypoint_start(&mut self, _name: &str, _bytecode_start: usize) {} fn begin_statement_at( @@ -229,10 +249,17 @@ impl<'i> DebugRecorderImpl<'i> for ActiveDebugRecorder<'i> { } } - fn begin_entrypoint(&mut self, name: &str, function: &FunctionAst<'i>, contract_fields: &[ContractFieldAst<'i>]) { + fn begin_entrypoint( + &mut self, + name: &str, + function: &FunctionAst<'i>, + contract_fields: &[ContractFieldAst<'i>], + structs: &super::StructRegistry, + ) -> Result<(), CompilerError> { 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.entrypoints.push(StagedEntrypointDebug::new(name.to_string(), function, contract_fields, structs)?); self.active_entrypoint = Some(self.entrypoints.len().saturating_sub(1)); + Ok(()) } fn finish_entrypoint(&mut self, script_len: usize) { @@ -405,7 +432,12 @@ struct StagedEntrypointDebug<'i> { } impl<'i> StagedEntrypointDebug<'i> { - fn new(name: String, function: &FunctionAst<'i>, contract_fields: &[ContractFieldAst<'i>]) -> Self { + fn new( + name: String, + function: &FunctionAst<'i>, + contract_fields: &[ContractFieldAst<'i>], + structs: &super::StructRegistry, + ) -> Result { let mut entrypoint = Self { name, script_len: 0, @@ -417,8 +449,8 @@ impl<'i> StagedEntrypointDebug<'i> { next_frame_id: 1, statement_stack: Vec::new(), }; - entrypoint.record_param_bindings(function, contract_fields); - entrypoint + entrypoint.record_param_bindings(function, contract_fields, structs)?; + Ok(entrypoint) } fn allocate_frame_id(&mut self) -> u32 { @@ -479,14 +511,55 @@ impl<'i> StagedEntrypointDebug<'i> { 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(); + fn record_param_bindings( + &mut self, + function: &FunctionAst<'i>, + contract_fields: &[ContractFieldAst<'i>], + structs: &super::StructRegistry, + ) -> Result<(), CompilerError> { let field_count = contract_fields.len(); - for (index, param) in function.params.iter().enumerate() { + let mut param_leaf_specs: Vec, String)>>> = Vec::with_capacity(function.params.len()); + let mut flattened_param_names = Vec::new(); + + for param in &function.params { + if super::struct_name_from_type_ref(¶m.type_ref, structs).is_some() + || super::struct_array_name_from_type_ref(¶m.type_ref, structs).is_some() + { + let leaf_specs = super::flatten_type_ref_leaves(¶m.type_ref, structs)? + .into_iter() + .map(|(path, leaf_type)| (path, super::type_name_from_ref(&leaf_type))) + .collect::>(); + for (path, _) in &leaf_specs { + flattened_param_names.push(super::flattened_struct_name(¶m.name, path)); + } + param_leaf_specs.push(Some(leaf_specs)); + } else { + flattened_param_names.push(param.name.clone()); + param_leaf_specs.push(None); + } + } + + let param_count = flattened_param_names.len(); + let mut flat_index = 0usize; + let mut next_stack_index = || { + let stack_index = (field_count + (param_count - 1 - flat_index)) as i64; + flat_index = flat_index.saturating_add(1); + stack_index + }; + for (param, leaf_specs) in function.params.iter().zip(param_leaf_specs.into_iter()) { + let binding = if let Some(leaf_specs) = leaf_specs { + let mut leaf_bindings = Vec::with_capacity(leaf_specs.len()); + for (field_path, leaf_type_name) in leaf_specs { + leaf_bindings.push(DebugParamLeafBinding { field_path, type_name: leaf_type_name, stack_index: next_stack_index() }); + } + DebugParamBinding::StructuredValue { leaf_bindings } + } else { + DebugParamBinding::SingleValue { stack_index: next_stack_index() } + }; 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, + binding, function: function.name.clone(), }); } @@ -494,10 +567,11 @@ impl<'i> StagedEntrypointDebug<'i> { self.params.push(DebugParamMapping { name: field.name.clone(), type_name: field.type_ref.type_name(), - stack_index: (field_count - 1 - index) as i64, + binding: DebugParamBinding::SingleValue { stack_index: (field_count - 1 - index) as i64 }, function: function.name.clone(), }); } + Ok(()) } } @@ -590,10 +664,11 @@ mod tests { let contract = parse_contract_ast(source).expect("parse contract"); let function = contract.functions.first().expect("function"); let stmt = function.body.first().expect("statement"); + let structs = super::super::build_struct_registry(&contract).expect("build struct registry"); let mut recorder = DebugRecorder::new(false); recorder.record_contract_scope(&contract.params, &[], &contract.constants); - recorder.begin_entrypoint("spend", function, &contract.fields); + recorder.begin_entrypoint("spend", function, &contract.fields, &structs).expect("noop begin entrypoint"); let span = SourceSpan::from(stmt.span()); @@ -621,9 +696,10 @@ mod tests { let contract = parse_contract_ast(source).expect("parse contract"); let function = contract.functions.first().expect("function"); let stmt = function.body.first().expect("statement"); + let structs = super::super::build_struct_registry(&contract).expect("build struct registry"); let mut recorder = DebugRecorder::new(true); - recorder.begin_entrypoint("spend", function, &contract.fields); + recorder.begin_entrypoint("spend", function, &contract.fields, &structs).expect("begin entrypoint"); let mut before = HashMap::new(); before.insert("x".to_string(), Expr::identifier("x")); diff --git a/silverscript-lang/src/debug_info.rs b/silverscript-lang/src/debug_info.rs index eea6092c..10780b47 100644 --- a/silverscript-lang/src/debug_info.rs +++ b/silverscript-lang/src/debug_info.rs @@ -130,13 +130,27 @@ pub enum RuntimeBinding { DataStackSlot { from_top: i64 }, } -/// Maps function parameter to its stack position. -/// Stack index is measured from stack top (0 = topmost param). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct DebugParamLeafBinding { + #[serde(default)] + pub field_path: Vec, + pub type_name: String, + pub stack_index: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case", tag = "kind")] +pub enum DebugParamBinding { + SingleValue { stack_index: i64 }, + StructuredValue { leaf_bindings: Vec }, +} + +/// Maps one source parameter to either a single runtime slot or a lowered set of leaf slots. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DebugParamMapping { pub name: String, pub type_name: String, - pub stack_index: i64, + pub binding: DebugParamBinding, pub function: String, } diff --git a/silverscript-lang/tests/compiler_tests.rs b/silverscript-lang/tests/compiler_tests.rs index ccc5ae4b..edd81de0 100644 --- a/silverscript-lang/tests/compiler_tests.rs +++ b/silverscript-lang/tests/compiler_tests.rs @@ -19,7 +19,7 @@ use silverscript_lang::compiler::{ CompileOptions, CompiledContract, CovenantDeclCallOptions, FunctionAbiEntry, FunctionInputAbi, compile_contract, compile_contract_ast, function_branch_index, struct_object, }; -use silverscript_lang::debug_info::RuntimeBinding; +use silverscript_lang::debug_info::{DebugParamBinding, RuntimeBinding}; fn run_script_with_selector(script: Vec, selector: Option) -> Result<(), kaspa_txscript_errors::TxScriptError> { let sigscript = selector_sigscript(selector); @@ -230,6 +230,77 @@ fn debug_info_records_runtime_binding_for_stable_scalar_local() { assert!(matches!(y_update.runtime_binding, Some(RuntimeBinding::DataStackSlot { .. }))); } +#[test] +fn debug_info_records_struct_param_leaf_bindings_in_runtime_order() { + let source = r#" + contract DebugStructParam() { + struct Pair { + int amount; + byte[2] code; + } + + entrypoint function spend(Pair next, int fee) { + require(fee >= 0); + } + } + "#; + + 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"); + + let next = debug_info.params.iter().find(|param| param.name == "next").expect("structured param should be recorded"); + assert_eq!(next.type_name, "Pair"); + let DebugParamBinding::StructuredValue { leaf_bindings } = &next.binding else { + panic!("expected structured binding"); + }; + assert_eq!(leaf_bindings.len(), 2); + assert_eq!(leaf_bindings[0].field_path, vec!["amount".to_string()]); + assert_eq!(leaf_bindings[0].type_name, "int"); + assert_eq!(leaf_bindings[0].stack_index, 2); + assert_eq!(leaf_bindings[1].field_path, vec!["code".to_string()]); + assert_eq!(leaf_bindings[1].type_name, "byte[2]"); + assert_eq!(leaf_bindings[1].stack_index, 1); + + let fee = debug_info.params.iter().find(|param| param.name == "fee").expect("scalar param should be recorded"); + assert_eq!(fee.binding, DebugParamBinding::SingleValue { stack_index: 0 }); +} + +#[test] +fn debug_info_records_state_array_param_leaf_bindings_in_runtime_order() { + let source = r#" + contract DebugStateArrayParam() { + int amount = 1; + byte[32] owner = 0x1111111111111111111111111111111111111111111111111111111111111111; + + entrypoint function spend(State[] next_states, int fee) { + require(fee >= 0); + } + } + "#; + + 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"); + + let next_states = + debug_info.params.iter().find(|param| param.name == "next_states").expect("state-array param should be recorded"); + assert_eq!(next_states.type_name, "State[]"); + let DebugParamBinding::StructuredValue { leaf_bindings } = &next_states.binding else { + panic!("expected structured binding"); + }; + assert_eq!(leaf_bindings.len(), 2); + assert_eq!(leaf_bindings[0].field_path, vec!["amount".to_string()]); + assert_eq!(leaf_bindings[0].type_name, "int[]"); + assert_eq!(leaf_bindings[0].stack_index, 4); + assert_eq!(leaf_bindings[1].field_path, vec!["owner".to_string()]); + assert_eq!(leaf_bindings[1].type_name, "byte[32][]"); + assert_eq!(leaf_bindings[1].stack_index, 3); + + let fee = debug_info.params.iter().find(|param| param.name == "fee").expect("scalar param should be recorded"); + assert_eq!(fee.binding, DebugParamBinding::SingleValue { stack_index: 2 }); +} + #[test] fn debug_info_distinguishes_ctor_args_from_contract_constants() { let source = r#" From 8f992146beae3af18e5afce64f9fb4e64c048e60 Mon Sep 17 00:00:00 2001 From: Manyfestation <240733973+Manyfestation@users.noreply.github.com> Date: Mon, 16 Mar 2026 22:05:32 +0200 Subject: [PATCH 2/6] clippy --- debugger/session/src/args.rs | 48 +++++++++---------- debugger/session/src/session.rs | 10 ++-- .../src/compiler/debug_recording.rs | 8 +++- 3 files changed, 34 insertions(+), 32 deletions(-) diff --git a/debugger/session/src/args.rs b/debugger/session/src/args.rs index ae561135..8e848bc4 100644 --- a/debugger/session/src/args.rs +++ b/debugger/session/src/args.rs @@ -165,6 +165,30 @@ pub fn parse_typed_arg(type_name: &str, raw: &str) -> Result, Stri } } +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) +} + #[cfg(test)] mod tests { use super::{parse_ctor_args, parse_typed_arg}; @@ -233,27 +257,3 @@ mod tests { assert!(matches!(args[0].kind, ExprKind::StateObject(_))); } } - -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/session.rs b/debugger/session/src/session.rs index bec5c458..ec2ce54e 100644 --- a/debugger/session/src/session.rs +++ b/debugger/session/src/session.rs @@ -601,7 +601,8 @@ impl<'a, 'i> DebugSession<'a, 'i> { fn scope_state(&self, step_id: StepId) -> Result, String> { let scope = self.visible_scope(step_id)?; let mut bindings = HashMap::new(); - let function_params: Vec<_> = self.debug_info.params.iter().filter(|param| param.function == scope.context.function_name).collect(); + let function_params: Vec<_> = + self.debug_info.params.iter().filter(|param| param.function == scope.context.function_name).collect(); let mut contract_field_names = HashSet::new(); let mut expected_stack_index = 0_i64; @@ -616,11 +617,8 @@ impl<'a, 'i> DebugSession<'a, 'i> { } for param in function_params { - let origin = if contract_field_names.contains(¶m.name) { - VariableOrigin::ContractField - } else { - VariableOrigin::Param - }; + let origin = + if contract_field_names.contains(¶m.name) { VariableOrigin::ContractField } else { VariableOrigin::Param }; match ¶m.binding { DebugParamBinding::SingleValue { stack_index } => { bindings.entry(param.name.clone()).or_insert_with(|| ScopeBinding { diff --git a/silverscript-lang/src/compiler/debug_recording.rs b/silverscript-lang/src/compiler/debug_recording.rs index 44038ab4..ba622cf2 100644 --- a/silverscript-lang/src/compiler/debug_recording.rs +++ b/silverscript-lang/src/compiler/debug_recording.rs @@ -518,7 +518,7 @@ impl<'i> StagedEntrypointDebug<'i> { structs: &super::StructRegistry, ) -> Result<(), CompilerError> { let field_count = contract_fields.len(); - let mut param_leaf_specs: Vec, String)>>> = Vec::with_capacity(function.params.len()); + let mut param_leaf_specs = Vec::with_capacity(function.params.len()); let mut flattened_param_names = Vec::new(); for param in &function.params { @@ -550,7 +550,11 @@ impl<'i> StagedEntrypointDebug<'i> { let binding = if let Some(leaf_specs) = leaf_specs { let mut leaf_bindings = Vec::with_capacity(leaf_specs.len()); for (field_path, leaf_type_name) in leaf_specs { - leaf_bindings.push(DebugParamLeafBinding { field_path, type_name: leaf_type_name, stack_index: next_stack_index() }); + leaf_bindings.push(DebugParamLeafBinding { + field_path, + type_name: leaf_type_name, + stack_index: next_stack_index(), + }); } DebugParamBinding::StructuredValue { leaf_bindings } } else { From d2e45f9a1e3c72cb78d2e8f022bb23f320a7eff1 Mon Sep 17 00:00:00 2001 From: Manyfestation <240733973+Manyfestation@users.noreply.github.com> Date: Tue, 17 Mar 2026 00:06:07 +0200 Subject: [PATCH 3/6] bugfix --- debugger/cli/src/main.rs | 10 +- debugger/cli/tests/cli_tests.rs | 69 ++++++ debugger/session/src/args.rs | 423 +++++++++++++++++++++----------- debugger/session/src/session.rs | 2 +- examples/debug_state.sil | 40 +++ 5 files changed, 388 insertions(+), 156 deletions(-) create mode 100644 examples/debug_state.sil diff --git a/debugger/cli/src/main.rs b/debugger/cli/src/main.rs index faaec5f2..088a89c5 100644 --- a/debugger/cli/src/main.rs +++ b/debugger/cli/src/main.rs @@ -406,14 +406,8 @@ fn main() -> Result<(), Box> { } 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 typed_args = parse_call_args(&compiled.ast, &selected_name, &raw_args)?; let sigscript = compiled.build_sig_script(&selected_name, typed_args)?; let tx = tx_scenario.unwrap_or_else(|| TestTxScenarioResolved { diff --git a/debugger/cli/tests/cli_tests.rs b/debugger/cli/tests/cli_tests.rs index 4589aeab..c7b77167 100644 --- a/debugger/cli/tests/cli_tests.rs +++ b/debugger/cli/tests/cli_tests.rs @@ -25,6 +25,10 @@ fn write_fixture_files( (script_path, test_file_path) } +fn shared_example_path(name: &str) -> std::path::PathBuf { + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../examples").join(name) +} + fn write_named_test_fixture(script_name: &str, test_file_name: &str) -> (std::path::PathBuf, std::path::PathBuf) { write_fixture_files( script_name, @@ -346,6 +350,71 @@ fn cli_debugger_accepts_struct_constructor_arg_and_renders_source_level_value() assert!(stdout.contains("seed (Pair) = {amount: 7, code: 0x1234}"), "missing rendered constructor struct value: {stdout}"); } +#[test] +fn cli_debugger_accepts_state_arg_with_byte_one_field_and_renders_source_level_value() { + let script_path = shared_example_path("debug_state.sil"); + + let mut child = Command::new(env!("CARGO_BIN_EXE_cli-debugger")) + .arg(&script_path) + .arg("--function") + .arg("inspect_state") + .arg("--ctor-arg") + .arg("4") + .arg("--arg") + .arg(r#"{"amount":5,"active":true,"tag":"0xaa"}"#) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("failed to spawn cli-debugger"); + + let input = b"vars\np next_state\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("next_state (State) = {amount: 5, active: true, tag: 0xaa}"), "missing rendered State value: {stdout}"); +} + +#[test] +fn cli_debugger_accepts_state_array_arg_with_byte_one_field_and_renders_source_level_value() { + let script_path = shared_example_path("debug_state.sil"); + + let mut child = Command::new(env!("CARGO_BIN_EXE_cli-debugger")) + .arg(&script_path) + .arg("--function") + .arg("inspect_state_array") + .arg("--ctor-arg") + .arg("4") + .arg("--arg") + .arg(r#"[{"amount":5,"active":true,"tag":"0xaa"},{"amount":7,"active":true,"tag":"0xaa"}]"#) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("failed to spawn cli-debugger"); + + let input = b"vars\np next_states\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("next_states (State[]) = [{amount: 5, active: true, tag: 0xaa}, {amount: 7, active: true, tag: 0xaa}]"), + "missing rendered State[] value: {stdout}" + ); +} + #[test] fn cli_debugger_run_test_file_pass_case() { let (_script_path, test_file_path) = write_test_fixture(); diff --git a/debugger/session/src/args.rs b/debugger/session/src/args.rs index 8e848bc4..c39bf77f 100644 --- a/debugger/session/src/args.rs +++ b/debugger/session/src/args.rs @@ -1,6 +1,7 @@ +use std::collections::HashMap; + use serde_json::Value; -use silverscript_lang::ast::{ContractAst, Expr, ExprKind}; -use silverscript_lang::compiler::struct_object; +use silverscript_lang::ast::{ArrayDim, ContractAst, Expr, ExprKind, ParamAst, StateFieldExpr, TypeBase, TypeRef}; use silverscript_lang::span; pub fn parse_int_arg(raw: &str) -> Result { @@ -30,230 +31,358 @@ pub fn bytes_expr(bytes: Vec) -> Expr<'static> { Expr::new(ExprKind::Array(bytes.into_iter().map(Expr::byte).collect()), span::Span::default()) } -fn json_value_to_untyped_expr(value: &Value) -> Result, String> { - match value { - Value::Number(n) => Ok(Expr::int(n.as_i64().ok_or_else(|| "invalid int value".to_string())?)), - Value::Bool(b) => Ok(Expr::bool(*b)), - Value::String(s) => { - if s.starts_with("0x") || s.starts_with("0X") { - let bytes = parse_hex_bytes(s)?; - if bytes.len() == 1 { Ok(Expr::byte(bytes[0])) } else { Ok(bytes_expr(bytes)) } - } else { - Ok(Expr::string(s.clone())) - } - } - Value::Array(values) => values - .iter() - .map(json_value_to_untyped_expr) - .collect::, _>>() - .map(|values| Expr::new(ExprKind::Array(values), span::Span::default())), - Value::Object(fields) => { - let mut expr_fields = Vec::with_capacity(fields.len()); - for (name, value) in fields { - expr_fields.push((Box::leak(name.clone().into_boxed_str()) as &'static str, json_value_to_untyped_expr(value)?)); - } - Ok(struct_object(expr_fields)) - } - Value::Null => Err("null is not supported in structured args".to_string()), - } +#[derive(Debug, Clone)] +struct StructShapeField { + name: String, + type_ref: TypeRef, } -fn json_value_to_typed_expr(type_name: &str, value: &Value) -> Result, String> { - if let Some(element_type) = type_name.strip_suffix("[]") { - match value { - Value::Array(values) => values +#[derive(Debug, Clone, Default)] +struct StructShapeRegistry { + shapes: HashMap>, +} + +impl StructShapeRegistry { + fn from_contract(contract: &ContractAst<'_>) -> Self { + let mut shapes = HashMap::new(); + shapes.insert( + "State".to_string(), + contract + .fields .iter() - .map(|value| json_value_to_typed_expr(element_type, value)) - .collect::, _>>() - .map(|values| Expr::new(ExprKind::Array(values), span::Span::default())), - Value::String(raw) if element_type == "byte" => Ok(bytes_expr(parse_hex_bytes(raw)?)), - _ => Err(format!("unsupported array literal format for '{type_name}'")), - } - } else { - match value { - Value::String(raw) => parse_typed_arg(type_name, raw), - Value::Number(raw) if type_name == "int" => Ok(Expr::int(raw.as_i64().ok_or_else(|| "invalid int value".to_string())?)), - Value::Number(raw) if type_name == "byte" => { - let value = raw.as_u64().ok_or_else(|| "invalid byte value".to_string())?; - let byte = u8::try_from(value).map_err(|_| format!("byte expects value in 0..=255, got {value}"))?; - Ok(Expr::byte(byte)) - } - Value::Bool(raw) if type_name == "bool" => Ok(Expr::bool(*raw)), - Value::Object(_) => json_value_to_untyped_expr(value), - _ => Err(format!("unsupported arg value for '{type_name}'")), + .map(|field| StructShapeField { name: field.name.clone(), type_ref: field.type_ref.clone() }) + .collect(), + ); + for item in &contract.structs { + shapes.insert( + item.name.clone(), + item.fields + .iter() + .map(|field| StructShapeField { name: field.name.clone(), type_ref: field.type_ref.clone() }) + .collect(), + ); } + Self { shapes } } -} -pub fn parse_typed_arg(type_name: &str, raw: &str) -> Result, String> { - let trimmed = raw.trim(); - - if let Some(element_type) = type_name.strip_suffix("[]") { - if trimmed.starts_with('[') { - let values = serde_json::from_str::>(trimmed).map_err(|err| format!("invalid array arg '{raw}': {err}"))?; - return values - .iter() - .map(|value| json_value_to_typed_expr(element_type, value)) - .collect::, _>>() - .map(|values| Expr::new(ExprKind::Array(values), span::Span::default())); + fn fields_for_type(&self, type_ref: &TypeRef) -> Option<&[StructShapeField]> { + if type_ref.is_array() { + return None; } - if element_type == "byte" { - return Ok(bytes_expr(parse_hex_bytes(trimmed)?)); + match &type_ref.base { + TypeBase::Custom(name) => self.shapes.get(name).map(Vec::as_slice), + _ => None, } - return Err(format!("unsupported array literal format for '{type_name}'")); } +} - if trimmed == "null" { - return Err("null is not supported in structured args".to_string()); +fn is_one_dim_byte_array_type(type_ref: &TypeRef) -> bool { + matches!(type_ref.base, TypeBase::Byte) && type_ref.array_dims.len() == 1 +} + +fn validate_byte_array_len(type_ref: &TypeRef, len: usize) -> Result<(), String> { + if let Some(ArrayDim::Fixed(size)) = type_ref.array_size() + && len != *size + { + return Err(format!("{} expects {} bytes, got {}", type_ref.type_name(), size, len)); } + Ok(()) +} - if trimmed.starts_with('{') { - let value = serde_json::from_str::(trimmed).map_err(|err| format!("invalid {type_name} arg '{raw}': {err}"))?; - return json_value_to_untyped_expr(&value); +fn validate_array_len(type_ref: &TypeRef, len: usize) -> Result<(), String> { + if let Some(ArrayDim::Fixed(size)) = type_ref.array_size() + && len != *size + { + return Err(format!("{} expects {} elements, got {}", type_ref.type_name(), size, len)); } + Ok(()) +} - match type_name { - "int" => Ok(Expr::int(parse_int_arg(raw)?)), - "bool" => match raw { +fn parse_byte_array_arg(type_ref: &TypeRef, raw: &str) -> Result, String> { + let bytes = parse_hex_bytes(raw)?; + validate_byte_array_len(type_ref, bytes.len())?; + Ok(bytes_expr(bytes)) +} + +fn parse_scalar_arg(type_ref: &TypeRef, raw: &str) -> Result, String> { + match type_ref.base { + TypeBase::Int => Ok(Expr::int(parse_int_arg(raw)?)), + TypeBase::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" => { + TypeBase::String => Ok(Expr::string(raw.to_string())), + TypeBase::Byte if type_ref.is_array() => parse_byte_array_arg(type_ref, raw), + TypeBase::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" => { + TypeBase::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" => { + TypeBase::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" => { + TypeBase::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}'")) - } + TypeBase::Custom(_) => Err(format!("unsupported arg type '{}'", type_ref.type_name())), + } +} + +fn parse_struct_arg( + entries: &serde_json::Map, + declared_fields: &[StructShapeField], + shapes: &StructShapeRegistry, +) -> Result, String> { + let mut provided = entries.iter().collect::>(); + + let mut out = Vec::with_capacity(declared_fields.len()); + for field in declared_fields { + let value = provided.remove(&field.name).ok_or_else(|| format!("struct field '{}' must be initialized", field.name))?; + out.push(StateFieldExpr { + name: field.name.clone(), + expr: parse_json_value_for_type(value, &field.type_ref, shapes)?, + span: span::Span::default(), + name_span: span::Span::default(), + }); + } + + if let Some(extra) = provided.keys().next() { + return Err(format!("unknown struct field '{}'", extra)); + } + + Ok(Expr::new(ExprKind::StateObject(out), span::Span::default())) +} + +fn parse_array_arg(values: &[Value], type_ref: &TypeRef, shapes: &StructShapeRegistry) -> Result, String> { + validate_array_len(type_ref, values.len())?; + let element_type = type_ref.element_type().ok_or_else(|| format!("unsupported arg type '{}'", type_ref.type_name()))?; + values + .iter() + .map(|value| parse_json_value_for_type(value, &element_type, shapes)) + .collect::, _>>() + .map(|values| Expr::new(ExprKind::Array(values), span::Span::default())) +} + +fn parse_json_value_for_type(value: &Value, type_ref: &TypeRef, shapes: &StructShapeRegistry) -> Result, String> { + if matches!(value, Value::Null) { + return Err("null is not supported in structured args".to_string()); + } + + if type_ref.is_array() { + if let Value::String(raw) = value + && is_one_dim_byte_array_type(type_ref) + { + return parse_byte_array_arg(type_ref, raw); } + + let Value::Array(values) = value else { + return Err(format!("unsupported array literal format for '{}'", type_ref.type_name())); + }; + return parse_array_arg(values, type_ref, shapes); + } + + if let Some(fields) = shapes.fields_for_type(type_ref) { + let Value::Object(entries) = value else { + return Err(format!("unsupported object literal format for '{}'", type_ref.type_name())); + }; + return parse_struct_arg(entries, fields, shapes); + } + + match value { + Value::String(raw) => parse_scalar_arg(type_ref, raw), + Value::Number(raw) if matches!(type_ref.base, TypeBase::Int) => { + Ok(Expr::int(raw.as_i64().ok_or_else(|| "invalid int value".to_string())?)) + } + Value::Number(raw) if matches!(type_ref.base, TypeBase::Byte) => { + let byte_value = raw.as_u64().ok_or_else(|| "invalid byte value".to_string())?; + let byte = u8::try_from(byte_value).map_err(|_| format!("byte expects value in 0..=255, got {byte_value}"))?; + Ok(Expr::byte(byte)) + } + Value::Bool(raw) if matches!(type_ref.base, TypeBase::Bool) => Ok(Expr::bool(*raw)), + _ => Err(format!("unsupported arg value for '{}'", type_ref.type_name())), } } -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())); +fn parse_typed_arg(type_ref: &TypeRef, shapes: &StructShapeRegistry, raw: &str) -> Result, String> { + let trimmed = raw.trim(); + if trimmed == "null" { + return Err("null is not supported in structured args".to_string()); } - 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)?); + if trimmed.starts_with('[') { + let value = serde_json::from_str::(trimmed).map_err(|err| format!("invalid array arg '{raw}': {err}"))?; + return parse_json_value_for_type(&value, type_ref, shapes); } - Ok(out) + + if trimmed.starts_with('{') { + let value = + serde_json::from_str::(trimmed).map_err(|err| format!("invalid {} arg '{raw}': {err}", type_ref.type_name()))?; + return parse_json_value_for_type(&value, type_ref, shapes); + } + + if type_ref.is_array() { + if is_one_dim_byte_array_type(type_ref) { + return parse_byte_array_arg(type_ref, trimmed); + } + return Err(format!("unsupported array literal format for '{}'", type_ref.type_name())); + } + + parse_scalar_arg(type_ref, trimmed) } -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())); +fn parse_params(params: &[ParamAst<'_>], shapes: &StructShapeRegistry, raw_args: &[String]) -> Result>, String> { + if params.len() != raw_args.len() { + return Err(format!("function expects {} arguments, got {}", params.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)?); + for (param, raw) in params.iter().zip(raw_args.iter()) { + typed_args.push(parse_typed_arg(¶m.type_ref, shapes, raw)?); } Ok(typed_args) } +pub fn parse_ctor_args(parsed_contract: &ContractAst<'_>, raw_ctor_args: &[String]) -> Result>, String> { + let shapes = StructShapeRegistry::from_contract(parsed_contract); + if parsed_contract.params.len() != raw_ctor_args.len() { + return Err(format!("constructor expects {} arguments, got {}", parsed_contract.params.len(), raw_ctor_args.len())); + } + parse_params(&parsed_contract.params, &shapes, raw_ctor_args) +} + +pub fn parse_call_args(contract: &ContractAst<'_>, function_name: &str, raw_args: &[String]) -> Result>, String> { + let function = contract + .functions + .iter() + .find(|function| function.name == function_name) + .ok_or_else(|| format!("function '{function_name}' not found"))?; + let shapes = StructShapeRegistry::from_contract(contract); + parse_params(&function.params, &shapes, raw_args) +} + #[cfg(test)] mod tests { - use super::{parse_ctor_args, parse_typed_arg}; + use super::{parse_call_args, parse_ctor_args}; use silverscript_lang::ast::{ExprKind, parse_contract_ast}; - #[test] - fn parses_state_object_arg() { - let parsed = parse_typed_arg("State", r#"{"amount": 7, "owner": "0x11"}"#).expect("parse State"); - assert!(matches!(parsed.kind, ExprKind::StateObject(_))); - } + fn debug_shapes_contract() -> silverscript_lang::ast::ContractAst<'static> { + parse_contract_ast( + r#" + contract Demo(Pair seed) { + struct Pair { + int amount; + byte[1] tag; + } - #[test] - fn parses_declared_struct_object_arg() { - let parsed = parse_typed_arg("Pair", r#"{"amount": 7, "owner": "0x11"}"#).expect("parse struct"); - assert!(matches!(parsed.kind, ExprKind::StateObject(_))); + int amount = 1; + bool active = true; + byte[1] tag = 0xaa; + + entrypoint function inspect_state(State next) { + require(next.active == active); + } + + entrypoint function inspect_state_array(State[] next_states) { + require(next_states.length == 2); + } + } + "#, + ) + .expect("parse contract") } #[test] - fn parses_state_object_array_arg() { - let parsed = parse_typed_arg("State[]", r#"[{"amount": 7}, {"amount": 9}]"#).expect("parse State[]"); - assert!(matches!(parsed.kind, ExprKind::Array(_))); + fn parses_state_object_arg_with_byte_one_field() { + let contract = debug_shapes_contract(); + let args = parse_call_args(&contract, "inspect_state", &[r#"{"amount":5,"active":true,"tag":"0xaa"}"#.to_string()]) + .expect("parse State arg"); + let ExprKind::StateObject(fields) = &args[0].kind else { + panic!("expected state object"); + }; + assert_eq!(fields.len(), 3); + let tag = fields.iter().find(|field| field.name == "tag").expect("tag field"); + assert!(matches!(tag.expr.kind, ExprKind::Array(ref values) if values.len() == 1)); } #[test] - fn parses_struct_array_arg_with_fixed_bytes_fields() { - let parsed = parse_typed_arg("Pair[]", r#"[{"amount": 7, "code": "0x0102"}]"#).expect("parse struct[]"); - let ExprKind::Array(values) = parsed.kind else { + fn parses_state_object_array_arg_with_byte_one_field() { + let contract = debug_shapes_contract(); + let args = parse_call_args( + &contract, + "inspect_state_array", + &[r#"[{"amount":5,"active":true,"tag":"0xaa"},{"amount":7,"active":false,"tag":"0xbb"}]"#.to_string()], + ) + .expect("parse State[] arg"); + let ExprKind::Array(values) = &args[0].kind else { panic!("expected array expr"); }; - assert_eq!(values.len(), 1); + assert_eq!(values.len(), 2); assert!(matches!(values[0].kind, ExprKind::StateObject(_))); } #[test] - fn rejects_null_in_structured_args() { - let error = parse_typed_arg("State", "null").expect_err("null should be rejected"); - assert!(error.contains("null")); + fn parses_declared_struct_constructor_arg_with_byte_one_field() { + let contract = debug_shapes_contract(); + let args = parse_ctor_args(&contract, &[r#"{"amount":7,"tag":"0xaa"}"#.to_string()]).expect("parse ctor args"); + assert_eq!(args.len(), 1); + let ExprKind::StateObject(fields) = &args[0].kind else { + panic!("expected struct object"); + }; + let tag = fields.iter().find(|field| field.name == "tag").expect("tag field"); + assert!(matches!(tag.expr.kind, ExprKind::Array(ref values) if values.len() == 1)); } #[test] - fn rejects_malformed_json_structured_args() { - let error = parse_typed_arg("State[]", "[{]").expect_err("malformed JSON should fail"); - assert!(error.contains("invalid array arg")); + fn rejects_missing_struct_field() { + let contract = debug_shapes_contract(); + let error = parse_call_args(&contract, "inspect_state", &[r#"{"amount":5,"active":true}"#.to_string()]) + .expect_err("missing tag should fail"); + assert!(error.contains("struct field 'tag' must be initialized")); } #[test] - fn parses_struct_constructor_arg() { - let contract = parse_contract_ast( - r#" - contract Demo(Pair seed) { - struct Pair { - int amount; - byte[2] code; - } + fn rejects_unknown_struct_field() { + let contract = debug_shapes_contract(); + let error = parse_call_args(&contract, "inspect_state", &[r#"{"amount":5,"active":true,"tag":"0xaa","extra":1}"#.to_string()]) + .expect_err("extra field should fail"); + assert!(error.contains("unknown struct field 'extra'")); + } - entrypoint function inspect() { - require(true); - } - } - "#, - ) - .expect("parse contract"); + #[test] + fn rejects_wrong_typed_struct_field() { + let contract = debug_shapes_contract(); + let error = parse_call_args(&contract, "inspect_state", &[r#"{"amount":5,"active":1,"tag":"0xaa"}"#.to_string()]) + .expect_err("wrong typed field should fail"); + assert!(error.contains("unsupported arg value for 'bool'")); + } - let args = parse_ctor_args(&contract, &[r#"{"amount": 7, "code": "0x1234"}"#.to_string()]).expect("parse ctor args"); - assert_eq!(args.len(), 1); - assert!(matches!(args[0].kind, ExprKind::StateObject(_))); + #[test] + fn rejects_null_in_structured_args() { + let contract = debug_shapes_contract(); + let error = parse_call_args(&contract, "inspect_state", &[r#"null"#.to_string()]).expect_err("null should be rejected"); + assert!(error.contains("null")); + } + + #[test] + fn rejects_malformed_json_structured_args() { + let contract = debug_shapes_contract(); + let error = + parse_call_args(&contract, "inspect_state_array", &[r#"[{]"#.to_string()]).expect_err("malformed JSON should fail"); + assert!(error.contains("invalid array arg")); } } diff --git a/debugger/session/src/session.rs b/debugger/session/src/session.rs index ec2ce54e..779adddb 100644 --- a/debugger/session/src/session.rs +++ b/debugger/session/src/session.rs @@ -1289,7 +1289,7 @@ fn step_matches_offset(step: &DebugStep<'_>, offset: usize) -> bool { } fn is_inline_synthetic_name(name: &str) -> bool { - name.starts_with("__arg_") + name.starts_with("__arg_") || name.starts_with("__struct_") } fn record_debug_named_values<'i>( diff --git a/examples/debug_state.sil b/examples/debug_state.sil new file mode 100644 index 00000000..3cd6aa9b --- /dev/null +++ b/examples/debug_state.sil @@ -0,0 +1,40 @@ +pragma silverscript ^0.1.0; + +contract DebugState(int ctor_x) { + int constant const_y = 5; + + int amount = 1; + bool active = true; + byte[1] tag = 0xaa; + struct Pair { + int amount; + byte[2] code; + } + + entrypoint function inspect_state(State next_state) { + int bumped = next_state.amount + 1; + byte[1] next_tag = next_state.tag; + + require(bumped > amount); + require(next_state.active == active); + require(next_tag == next_state.tag); + } + + entrypoint function inspect_state_array(State[] next_states) { + int first_amount = next_states[0].amount; + byte[1] second_tag = next_states[1].tag; + + require(next_states.length == 2); + require(first_amount < next_states[1].amount); + require(next_states[0].active == true); + require(second_tag == next_states[1].tag); + } + + entrypoint function inspect_pair(Pair next_pair) { + int pair_amount = next_pair.amount; + byte[2] pair_tag = next_pair.code; + + require(pair_amount > 0); + require(pair_tag == next_pair.code); + } +} From d4cd6fb92e3684ac3890db74aa2ddca1ad28b286 Mon Sep 17 00:00:00 2001 From: Manyfestation <240733973+Manyfestation@users.noreply.github.com> Date: Tue, 24 Mar 2026 16:20:10 +0200 Subject: [PATCH 4/6] Support structs inside inline calls and debug eval --- debugger/cli/tests/cli_tests.rs | 42 +- debugger/session/src/session.rs | 526 +++++++++++++++++- debugger/session/tests/debug_session_tests.rs | 209 +++++-- silverscript-lang/src/compiler.rs | 23 +- .../src/compiler/debug_recording.rs | 267 ++++++++- silverscript-lang/src/debug_info.rs | 9 +- silverscript-lang/tests/compiler_tests.rs | 8 +- 7 files changed, 935 insertions(+), 149 deletions(-) diff --git a/debugger/cli/tests/cli_tests.rs b/debugger/cli/tests/cli_tests.rs index 50cda06e..3ac71a25 100644 --- a/debugger/cli/tests/cli_tests.rs +++ b/debugger/cli/tests/cli_tests.rs @@ -410,7 +410,7 @@ fn cli_debugger_accepts_struct_constructor_arg_and_renders_source_level_value() } #[test] -fn cli_debugger_accepts_state_arg_with_byte_one_field_and_renders_source_level_value() { +fn cli_debugger_evals_structured_state_expressions() { let script_path = shared_example_path("debug_state.sil"); let mut child = Command::new(env!("CARGO_BIN_EXE_cli-debugger")) @@ -427,7 +427,7 @@ fn cli_debugger_accepts_state_arg_with_byte_one_field_and_renders_source_level_v .spawn() .expect("failed to spawn cli-debugger"); - let input = b"vars\np next_state\nq\n"; + let input = b"eval next_state\neval next_state.amount\neval next_state.amount + amount\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"); @@ -437,41 +437,9 @@ fn cli_debugger_accepts_state_arg_with_byte_one_field_and_renders_source_level_v let stderr = String::from_utf8_lossy(&output.stderr); assert!(stderr.is_empty(), "unexpected stderr: {stderr}"); - assert!(stdout.contains("next_state (State) = {amount: 5, active: true, tag: 0xaa}"), "missing rendered State value: {stdout}"); -} - -#[test] -fn cli_debugger_accepts_state_array_arg_with_byte_one_field_and_renders_source_level_value() { - let script_path = shared_example_path("debug_state.sil"); - - let mut child = Command::new(env!("CARGO_BIN_EXE_cli-debugger")) - .arg(&script_path) - .arg("--function") - .arg("inspect_state_array") - .arg("--ctor-arg") - .arg("4") - .arg("--arg") - .arg(r#"[{"amount":5,"active":true,"tag":"0xaa"},{"amount":7,"active":true,"tag":"0xaa"}]"#) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .expect("failed to spawn cli-debugger"); - - let input = b"vars\np next_states\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("next_states (State[]) = [{amount: 5, active: true, tag: 0xaa}, {amount: 7, active: true, tag: 0xaa}]"), - "missing rendered State[] value: {stdout}" - ); + assert!(stdout.contains("next_state = (State) {amount: 5, active: true, tag: 0xaa}"), "missing state eval output: {stdout}"); + assert!(stdout.contains("next_state.amount = (int) 5"), "missing state field eval output: {stdout}"); + assert!(stdout.contains("next_state.amount + amount = (int) 6"), "missing state arithmetic eval output: {stdout}"); } #[test] diff --git a/debugger/session/src/session.rs b/debugger/session/src/session.rs index b7ad841e..a04e1d71 100644 --- a/debugger/session/src/session.rs +++ b/debugger/session/src/session.rs @@ -8,12 +8,15 @@ 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, parse_contract_ast, parse_expression_ast}; +use silverscript_lang::ast::{ + Expr, ExprKind, StateFieldExpr, TypeBase, UnarySuffixKind, parse_contract_ast, parse_expression_ast, parse_type_ref, +}; use silverscript_lang::compiler::{compile_debug_expr, flattened_struct_name}; use silverscript_lang::debug_info::{ - DebugFunctionRange, DebugInfo, DebugNamedValue, DebugParamBinding, DebugParamLeafBinding, DebugStep, DebugVariableUpdate, + DebugFunctionRange, DebugInfo, DebugLeafBinding, DebugNamedValue, DebugParamBinding, DebugStep, DebugVariableUpdate, RuntimeBinding, SourceSpan, StepId, StepKind, }; +use silverscript_lang::span; pub use crate::presentation::{SourceContext, SourceContextLine}; use crate::presentation::{build_source_context, format_value as format_debug_value}; @@ -140,6 +143,7 @@ pub struct DebugSession<'a, 'i> { breakpoints: HashSet, // Source-level step ids that were already visited in this session. executed_steps: HashSet, + inline_scope_snapshots: HashMap>, console_output: Vec, } @@ -164,7 +168,7 @@ struct VisibleScope<'a, 'i> { #[derive(Clone)] enum ScopeValueSource<'i> { RuntimeSlot { from_top: i64 }, - StructuredParam { leaf_bindings: Vec }, + StructuredBinding { base_name: String, leaf_bindings: Vec }, Expr(Expr<'i>), } @@ -239,6 +243,7 @@ impl<'a, 'i> DebugSession<'a, 'i> { source_lines, breakpoints: HashSet::new(), executed_steps: HashSet::new(), + inline_scope_snapshots: HashMap::new(), console_output: Vec::new(), }) } @@ -608,18 +613,30 @@ impl<'a, 'i> DebugSession<'a, 'i> { let leaf_bindings = leaf_bindings.clone(); bindings.entry(param.name.clone()).or_insert_with(|| ScopeBinding { type_name: param.type_name.clone(), - source: ScopeValueSource::StructuredParam { leaf_bindings: leaf_bindings.clone() }, + source: ScopeValueSource::StructuredBinding { + base_name: param.name.clone(), + leaf_bindings: leaf_bindings + .iter() + .map(|leaf| DebugLeafBinding { + field_path: leaf.field_path.clone(), + type_name: leaf.type_name.clone(), + stack_index: None, + }) + .collect(), + }, origin, hidden: false, }); for leaf in &leaf_bindings { let leaf_name = flattened_struct_name(¶m.name, &leaf.field_path); - bindings.entry(leaf_name).or_insert_with(|| ScopeBinding { - type_name: leaf.type_name.clone(), - source: ScopeValueSource::RuntimeSlot { from_top: leaf.stack_index }, - origin, - hidden: true, - }); + if let Some(stack_index) = leaf.stack_index { + bindings.entry(leaf_name).or_insert_with(|| ScopeBinding { + type_name: leaf.type_name.clone(), + source: ScopeValueSource::RuntimeSlot { from_top: stack_index }, + origin, + hidden: true, + }); + } } } } @@ -628,10 +645,28 @@ impl<'a, 'i> DebugSession<'a, 'i> { record_debug_named_values(&mut bindings, &self.debug_info.constructor_args, VariableOrigin::ConstructorArg); record_debug_named_values(&mut bindings, &self.debug_info.constants, VariableOrigin::Constant); + let frozen_inline_names = if scope.context.step_id.frame_id == 0 { + HashSet::new() + } else { + self.freeze_inline_snapshot_bindings(&mut bindings, scope.context.step_id.frame_id) + }; + for (name, update) in &scope.updates { - let source = match update.runtime_binding.as_ref() { - Some(RuntimeBinding::DataStackSlot { from_top }) => ScopeValueSource::RuntimeSlot { from_top: *from_top }, - None => ScopeValueSource::Expr(update.expr.clone()), + if frozen_inline_names.contains(name) { + continue; + } + let source = match (&update.structured_leaf_bindings, update.runtime_binding.as_ref()) { + (Some(leaf_bindings), _) => { + ScopeValueSource::StructuredBinding { base_name: name.clone(), leaf_bindings: leaf_bindings.clone() } + } + (None, Some(_)) + if is_inline_synthetic_name(name) + && matches!(&update.expr.kind, ExprKind::Identifier(identifier) if frozen_inline_names.contains(identifier)) => + { + ScopeValueSource::Expr(update.expr.clone()) + } + (None, Some(RuntimeBinding::DataStackSlot { from_top })) => ScopeValueSource::RuntimeSlot { from_top: *from_top }, + (None, None) => ScopeValueSource::Expr(update.expr.clone()), }; bindings .entry(name.clone()) @@ -651,6 +686,60 @@ impl<'a, 'i> DebugSession<'a, 'i> { bindings } + fn freeze_inline_snapshot_bindings(&self, bindings: &mut ScopeState<'i>, frame_id: u32) -> HashSet { + let Some(parent_vars) = self.inline_scope_snapshots.get(&frame_id) else { + return HashSet::new(); + }; + let mut frozen_names = HashSet::new(); + + for (name, variable) in parent_vars { + let Some(expr) = debug_value_to_expr(&variable.value) else { + continue; + }; + + let structured_leaf_bindings = bindings.get(name.as_str()).and_then(|existing| match &existing.source { + ScopeValueSource::StructuredBinding { leaf_bindings, .. } => Some(leaf_bindings.clone()), + _ => None, + }); + if let Some(leaf_bindings) = structured_leaf_bindings { + frozen_names.insert(name.clone()); + for leaf in &leaf_bindings { + let Some(leaf_value) = structured_leaf_value(&variable.value, &leaf.field_path) else { + continue; + }; + let Some(leaf_expr) = debug_value_to_expr(&leaf_value) else { + continue; + }; + let leaf_name = flattened_struct_name(name, &leaf.field_path); + bindings.insert( + leaf_name.clone(), + ScopeBinding { + type_name: leaf.type_name.clone(), + source: ScopeValueSource::Expr(leaf_expr), + origin: variable.origin, + hidden: true, + }, + ); + frozen_names.insert(leaf_name); + } + continue; + } + + bindings.insert( + name.clone(), + ScopeBinding { + type_name: variable.type_name.clone(), + source: ScopeValueSource::Expr(expr), + origin: variable.origin, + hidden: false, + }, + ); + frozen_names.insert(name.clone()); + } + + frozen_names + } + fn collect_variables_map(&self, scope_state: &ScopeState<'i>) -> HashMap { let mut variables: HashMap = HashMap::new(); @@ -737,10 +826,28 @@ impl<'a, 'i> DebugSession<'a, 'i> { fn mark_step_executed(&mut self, step_index: usize) { if let Some(step) = self.step_at_order(step_index).cloned() { self.executed_steps.insert(step.id()); + self.capture_inline_scope_snapshot(&step); self.render_console_messages(&step); } } + fn capture_inline_scope_snapshot(&mut self, step: &DebugStep<'i>) { + if !matches!(step.kind, StepKind::InlineCallEnter { .. }) || self.inline_scope_snapshots.contains_key(&step.frame_id) { + return; + } + + let step_id = self.current_scope_step_id(); + let Ok(scope_state) = self.scope_state(step_id) else { + return; + }; + let snapshot = self + .collect_variables_map(&scope_state) + .into_iter() + .filter(|(_, variable)| matches!(variable.origin, VariableOrigin::Param | VariableOrigin::ContractField)) + .collect(); + self.inline_scope_snapshots.insert(step.frame_id, snapshot); + } + fn render_console_messages(&mut self, step: &DebugStep<'i>) { if step.console_args.is_empty() { return; @@ -975,14 +1082,22 @@ impl<'a, 'i> DebugSession<'a, 'i> { } match &binding.source { ScopeValueSource::RuntimeSlot { from_top } => self.read_stack_value(*from_top, &binding.type_name), - ScopeValueSource::StructuredParam { leaf_bindings } => self.read_structured_param_value(&binding.type_name, leaf_bindings), + ScopeValueSource::StructuredBinding { base_name, leaf_bindings } => { + self.read_structured_binding_value(scope_state, base_name, &binding.type_name, leaf_bindings) + } ScopeValueSource::Expr(expr) => self.evaluate_scope_expr_as(scope_state, expr, &binding.type_name), } } fn evaluate_scope_expr_as(&self, scope_state: &ScopeState<'i>, expr: &Expr<'i>, type_name: &str) -> Result { let (shadow_bindings, env, stack_bindings, eval_types) = self.scope_state_eval_context(scope_state)?; - let (bytecode, _) = compile_debug_expr(expr, &env, &stack_bindings, &eval_types) + if is_structured_type_name(type_name) { + return self + .try_resolve_expr_value(scope_state, expr, &mut HashSet::new()) + .ok_or_else(|| format!("failed to resolve structured expression of type '{type_name}'")); + } + let prepared_expr = lower_expr_for_eval(expr, scope_state)?; + let (bytecode, _) = compile_debug_expr(&prepared_expr, &env, &stack_bindings, &eval_types) .map_err(|err| format!("failed to compile debug expression: {err}"))?; let script = self.build_shadow_script(&shadow_bindings, &bytecode)?; let bytes = self.execute_shadow_script(&script)?; @@ -996,7 +1111,14 @@ impl<'a, 'i> DebugSession<'a, 'i> { fn evaluate_expr_in_scope(&self, scope_state: &ScopeState<'i>, expr: &Expr<'i>) -> Result<(String, DebugValue), String> { let (shadow_bindings, env, stack_bindings, eval_types) = self.scope_state_eval_context(scope_state)?; - let (bytecode, type_name) = compile_debug_expr(expr, &env, &stack_bindings, &eval_types) + if let Some(type_name) = direct_expr_type_name(scope_state, expr).filter(|type_name| is_structured_type_name(type_name)) { + let value = self + .try_resolve_expr_value(scope_state, expr, &mut HashSet::new()) + .ok_or_else(|| format!("failed to resolve structured expression of type '{type_name}'"))?; + return Ok((type_name, value)); + } + let prepared_expr = lower_expr_for_eval(expr, scope_state)?; + let (bytecode, type_name) = compile_debug_expr(&prepared_expr, &env, &stack_bindings, &eval_types) .map_err(|err| format!("failed to compile debug expression: {err}"))?; let script = self.build_shadow_script(&shadow_bindings, &bytecode)?; let bytes = self.execute_shadow_script(&script)?; @@ -1018,7 +1140,7 @@ impl<'a, 'i> DebugSession<'a, 'i> { ShadowBindingValue { name: name.clone(), stack_index: *from_top, value: self.read_stack_at_index(*from_top)? }, ); } - ScopeValueSource::StructuredParam { .. } => {} + ScopeValueSource::StructuredBinding { .. } => {} ScopeValueSource::Expr(expr) => { env.insert(name.clone(), expr.clone()); } @@ -1082,12 +1204,20 @@ impl<'a, 'i> DebugSession<'a, 'i> { decode_value_by_type(type_name, bytes) } - fn read_structured_param_value(&self, type_name: &str, leaf_bindings: &[DebugParamLeafBinding]) -> Result { + fn read_structured_binding_value( + &self, + scope_state: &ScopeState<'i>, + base_name: &str, + type_name: &str, + leaf_bindings: &[DebugLeafBinding], + ) -> Result { if type_name.ends_with("[]") { let mut leaf_arrays = Vec::with_capacity(leaf_bindings.len()); let mut expected_len = None; for leaf in leaf_bindings { - let value = self.read_stack_value(leaf.stack_index, &leaf.type_name)?; + let leaf_name = flattened_struct_name(base_name, &leaf.field_path); + let binding = scope_state.get(&leaf_name).ok_or_else(|| format!("missing structured leaf binding '{leaf_name}'"))?; + let value = self.resolve_scope_binding(scope_state, binding)?; let DebugValue::Array(values) = value else { return Err(format!("structured array leaf '{}' did not decode to an array", format_field_path(&leaf.field_path))); }; @@ -1115,7 +1245,9 @@ impl<'a, 'i> DebugSession<'a, 'i> { let mut fields = Vec::with_capacity(leaf_bindings.len()); for leaf in leaf_bindings { - let value = self.read_stack_value(leaf.stack_index, &leaf.type_name)?; + let leaf_name = flattened_struct_name(base_name, &leaf.field_path); + let binding = scope_state.get(&leaf_name).ok_or_else(|| format!("missing structured leaf binding '{leaf_name}'"))?; + let value = self.resolve_scope_binding(scope_state, binding)?; insert_object_path(&mut fields, &leaf.field_path, value)?; } Ok(DebugValue::Object(fields)) @@ -1129,8 +1261,8 @@ impl<'a, 'i> DebugSession<'a, 'i> { ) -> Option { match &binding.source { ScopeValueSource::RuntimeSlot { from_top } => self.read_stack_value(*from_top, &binding.type_name).ok(), - ScopeValueSource::StructuredParam { leaf_bindings } => { - self.read_structured_param_value(&binding.type_name, leaf_bindings).ok() + ScopeValueSource::StructuredBinding { base_name, leaf_bindings } => { + self.read_structured_binding_value(scope_state, base_name, &binding.type_name, leaf_bindings).ok() } ScopeValueSource::Expr(expr) => self.try_resolve_expr_value(scope_state, expr, visiting), } @@ -1189,6 +1321,25 @@ impl<'a, 'i> DebugSession<'a, 'i> { }; fields.into_iter().find_map(|(name, value)| (name == *field).then_some(value)) } + ExprKind::ArrayIndex { source, index } => { + let Some(DebugValue::Array(values)) = self.try_resolve_expr_value(scope_state, source, visiting) else { + return None; + }; + let Some(DebugValue::Int(index)) = self.try_resolve_expr_value(scope_state, index, visiting) else { + return None; + }; + let index = usize::try_from(index).ok()?; + values.get(index).cloned() + } + ExprKind::UnarySuffix { source, kind, .. } => match kind { + UnarySuffixKind::Length => match self.try_resolve_expr_value(scope_state, source, visiting)? { + DebugValue::Array(values) => Some(DebugValue::Int(values.len() as i64)), + DebugValue::Bytes(bytes) => Some(DebugValue::Int(bytes.len() as i64)), + DebugValue::String(value) => Some(DebugValue::Int(value.len() as i64)), + _ => None, + }, + UnarySuffixKind::Reverse => None, + }, _ => None, } } @@ -1258,6 +1409,57 @@ fn format_field_path(path: &[String]) -> String { if path.is_empty() { "".to_string() } else { path.join(".") } } +fn structured_leaf_value(value: &DebugValue, field_path: &[String]) -> Option { + if field_path.is_empty() { + return Some(value.clone()); + } + + match value { + DebugValue::Array(items) => { + let mut values = Vec::with_capacity(items.len()); + for item in items { + values.push(structured_leaf_value(item, field_path)?); + } + Some(DebugValue::Array(values)) + } + DebugValue::Object(fields) => { + let (field_name, rest) = field_path.split_first()?; + let value = fields.iter().find_map(|(name, value)| (name == field_name).then_some(value))?; + structured_leaf_value(value, rest) + } + _ => None, + } +} + +fn debug_value_to_expr<'i>(value: &DebugValue) -> Option> { + match value { + DebugValue::Int(value) => Some(Expr::int(*value)), + DebugValue::Bool(value) => Some(Expr::bool(*value)), + DebugValue::Bytes(bytes) => Some(Expr::bytes(bytes.clone())), + DebugValue::String(value) => Some(Expr::new(ExprKind::String(value.clone()), span::Span::default())), + DebugValue::Array(items) => { + Some(Expr::new(ExprKind::Array(items.iter().map(debug_value_to_expr).collect::>>()?), span::Span::default())) + } + DebugValue::Object(fields) => Some(Expr::new( + ExprKind::StateObject( + fields + .iter() + .map(|(name, value)| { + Some(StateFieldExpr { + name: name.clone(), + expr: debug_value_to_expr(value)?, + span: span::Span::default(), + name_span: span::Span::default(), + }) + }) + .collect::>>()?, + ), + span::Span::default(), + )), + DebugValue::Unknown(_) => None, + } +} + /// 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) { @@ -1290,10 +1492,217 @@ fn range_matches_offset(bytecode_start: usize, bytecode_end: usize, offset: usiz if bytecode_start == bytecode_end { offset == bytecode_start } else { offset >= bytecode_start && offset < bytecode_end } } +fn map_expr_children_for_eval<'i, F>(expr: &'i Expr<'i>, map_child: &mut F) -> Result, String> +where + F: FnMut(&'i Expr<'i>) -> Result, String>, +{ + let span = expr.span; + match &expr.kind { + ExprKind::Unary { op, expr } => Ok(Expr::new(ExprKind::Unary { op: *op, expr: Box::new(map_child(expr)?) }, span)), + ExprKind::Binary { op, left, right } => { + Ok(Expr::new(ExprKind::Binary { op: *op, left: Box::new(map_child(left)?), right: Box::new(map_child(right)?) }, span)) + } + ExprKind::IfElse { condition, then_expr, else_expr } => Ok(Expr::new( + ExprKind::IfElse { + condition: Box::new(map_child(condition)?), + then_expr: Box::new(map_child(then_expr)?), + else_expr: Box::new(map_child(else_expr)?), + }, + span, + )), + ExprKind::Array(values) => { + Ok(Expr::new(ExprKind::Array(values.iter().map(&mut *map_child).collect::, _>>()?), span)) + } + ExprKind::StateObject(fields) => Ok(Expr::new( + ExprKind::StateObject( + fields + .iter() + .map(|field| { + Ok(StateFieldExpr { + name: field.name.clone(), + expr: map_child(&field.expr)?, + span: field.span, + name_span: field.name_span, + }) + }) + .collect::, String>>()?, + ), + span, + )), + ExprKind::FieldAccess { source, field, field_span } => Ok(Expr::new( + ExprKind::FieldAccess { source: Box::new(map_child(source)?), field: field.clone(), field_span: *field_span }, + span, + )), + ExprKind::Call { name, args, name_span } => Ok(Expr::new( + ExprKind::Call { + name: name.clone(), + args: args.iter().map(&mut *map_child).collect::, _>>()?, + name_span: *name_span, + }, + span, + )), + ExprKind::New { name, args, name_span } => Ok(Expr::new( + ExprKind::New { + name: name.clone(), + args: args.iter().map(&mut *map_child).collect::, _>>()?, + name_span: *name_span, + }, + span, + )), + ExprKind::Split { source, index, part, span: split_span } => Ok(Expr::new( + ExprKind::Split { + source: Box::new(map_child(source)?), + index: Box::new(map_child(index)?), + part: *part, + span: *split_span, + }, + span, + )), + ExprKind::Slice { source, start, end, span: slice_span } => Ok(Expr::new( + ExprKind::Slice { + source: Box::new(map_child(source)?), + start: Box::new(map_child(start)?), + end: Box::new(map_child(end)?), + span: *slice_span, + }, + span, + )), + ExprKind::ArrayIndex { source, index } => { + Ok(Expr::new(ExprKind::ArrayIndex { source: Box::new(map_child(source)?), index: Box::new(map_child(index)?) }, span)) + } + ExprKind::Introspection { kind, index, field_span } => { + Ok(Expr::new(ExprKind::Introspection { kind: *kind, index: Box::new(map_child(index)?), field_span: *field_span }, span)) + } + ExprKind::UnarySuffix { source, kind, span: suffix_span } => { + Ok(Expr::new(ExprKind::UnarySuffix { source: Box::new(map_child(source)?), kind: *kind, span: *suffix_span }, span)) + } + _ => Ok(expr.clone()), + } +} + +enum StructuredFieldAccessBase<'i> { + Binding(String), + IndexedBinding(String, &'i Expr<'i>), +} + +fn collect_structured_field_access<'i>(expr: &'i Expr<'i>) -> Option<(StructuredFieldAccessBase<'i>, Vec)> { + match &expr.kind { + ExprKind::FieldAccess { source, field, .. } => { + let (base, mut path) = collect_structured_field_access(source)?; + path.push(field.clone()); + Some((base, path)) + } + ExprKind::Identifier(name) => Some((StructuredFieldAccessBase::Binding(name.clone()), Vec::new())), + ExprKind::ArrayIndex { source, index } => match &source.kind { + ExprKind::Identifier(name) => Some((StructuredFieldAccessBase::IndexedBinding(name.clone(), index), Vec::new())), + _ => None, + }, + _ => None, + } +} + +fn lower_structured_field_access_for_eval<'i>(expr: &'i Expr<'i>, scope_state: &ScopeState<'i>) -> Result>, String> { + let Some((base, field_path)) = collect_structured_field_access(expr) else { + return Ok(None); + }; + let base_name = match &base { + StructuredFieldAccessBase::Binding(name) | StructuredFieldAccessBase::IndexedBinding(name, _) => name, + }; + let Some(binding) = scope_state.get(base_name.as_str()) else { + return Ok(None); + }; + let ScopeValueSource::StructuredBinding { leaf_bindings, .. } = &binding.source else { + return Ok(None); + }; + if !leaf_bindings.iter().any(|leaf| leaf.field_path == field_path) { + return Ok(None); + } + + let lowered_leaf = Expr::identifier(flattened_struct_name(base_name, &field_path)); + Ok(Some(match base { + StructuredFieldAccessBase::Binding(_) => Expr::new(lowered_leaf.kind, expr.span), + StructuredFieldAccessBase::IndexedBinding(_, index) => Expr::new( + ExprKind::ArrayIndex { source: Box::new(lowered_leaf), index: Box::new(lower_expr_for_eval(index, scope_state)?) }, + expr.span, + ), + })) +} + +fn lower_structured_length_for_eval<'i>(expr: &Expr<'i>, scope_state: &ScopeState<'i>) -> Result>, String> { + let span = expr.span; + let ExprKind::UnarySuffix { source, kind, span: suffix_span } = &expr.kind else { + return Ok(None); + }; + if !matches!(kind, UnarySuffixKind::Length) { + return Ok(None); + } + let ExprKind::Identifier(name) = &source.kind else { + return Ok(None); + }; + let Some(binding) = scope_state.get(name.as_str()) else { + return Ok(None); + }; + let ScopeValueSource::StructuredBinding { leaf_bindings, .. } = &binding.source else { + return Ok(None); + }; + if !binding.type_name.ends_with("[]") { + return Ok(None); + } + let Some(first_leaf) = leaf_bindings.first() else { + return Err("structured array must contain fields".to_string()); + }; + Ok(Some(Expr::new( + ExprKind::UnarySuffix { + source: Box::new(Expr::identifier(flattened_struct_name(name, &first_leaf.field_path))), + kind: *kind, + span: *suffix_span, + }, + span, + ))) +} + +fn lower_expr_for_eval<'i>(expr: &'i Expr<'i>, scope_state: &ScopeState<'i>) -> Result, String> { + match &expr.kind { + ExprKind::FieldAccess { .. } => { + if let Some(lowered) = lower_structured_field_access_for_eval(expr, scope_state)? { + return Ok(lowered); + } + map_expr_children_for_eval(expr, &mut |child| lower_expr_for_eval(child, scope_state)) + } + ExprKind::UnarySuffix { .. } => { + if let Some(lowered) = lower_structured_length_for_eval(expr, scope_state)? { + return Ok(lowered); + } + map_expr_children_for_eval(expr, &mut |child| lower_expr_for_eval(child, scope_state)) + } + _ => map_expr_children_for_eval(expr, &mut |child| lower_expr_for_eval(child, scope_state)), + } +} + fn is_inline_synthetic_name(name: &str) -> bool { name.starts_with("__arg_") || name.starts_with("__struct_") } +fn is_structured_type_name(type_name: &str) -> bool { + parse_type_ref(type_name).ok().is_some_and(|type_ref| is_structured_type_ref(&type_ref)) +} + +fn direct_expr_type_name<'i>(scope_state: &ScopeState<'i>, expr: &Expr<'i>) -> Option { + match &expr.kind { + ExprKind::Identifier(name) => scope_state.get(name).map(|binding| binding.type_name.clone()), + ExprKind::ArrayIndex { source, .. } => { + let source_type = direct_expr_type_name(scope_state, source)?; + let type_ref = parse_type_ref(&source_type).ok()?; + Some(type_ref.element_type()?.type_name()) + } + _ => None, + } +} + +fn is_structured_type_ref(type_ref: &silverscript_lang::ast::TypeRef) -> bool { + matches!(&type_ref.base, TypeBase::Custom(_)) || type_ref.element_type().is_some_and(|element| is_structured_type_ref(&element)) +} + fn record_debug_named_values<'i>(bindings: &mut ScopeState<'i>, values: &[DebugNamedValue<'i>], origin: VariableOrigin) { for value in values { bindings.entry(value.name.clone()).or_insert_with(|| ScopeBinding { @@ -1311,7 +1720,7 @@ mod tests { use silverscript_lang::ast::{BinaryOp, Expr, ExprKind, StateFieldExpr}; use silverscript_lang::debug_info::{ - DebugFunctionRange, DebugInfo, DebugNamedValue, DebugParamBinding, DebugParamLeafBinding, DebugParamMapping, DebugStep, + DebugFunctionRange, DebugInfo, DebugLeafBinding, DebugNamedValue, DebugParamBinding, DebugParamMapping, DebugStep, DebugVariableUpdate, SourceSpan, StepKind, }; use silverscript_lang::span; @@ -1325,7 +1734,7 @@ mod tests { } } - fn structured_param(name: &str, type_name: &str, leaf_bindings: Vec) -> DebugParamMapping { + fn structured_param(name: &str, type_name: &str, leaf_bindings: Vec) -> DebugParamMapping { DebugParamMapping { name: name.to_string(), type_name: type_name.to_string(), @@ -1379,6 +1788,7 @@ mod tests { ExprKind::Binary { op: BinaryOp::Add, left: Box::new(Expr::identifier("a")), right: Box::new(Expr::identifier("b")) }, span::Span::default(), ), + structured_leaf_bindings: None, }; let scope_state = session.scope_state(StepId::ROOT).unwrap(); let value = session.evaluate_scope_expr_as(&scope_state, &update.expr, &update.type_name).unwrap(); @@ -1407,6 +1817,7 @@ mod tests { type_name: "int".to_string(), runtime_binding: None, expr: Expr::identifier("a"), + structured_leaf_bindings: None, }], console_args: vec![], }, @@ -1464,6 +1875,7 @@ mod tests { type_name: "int".to_string(), runtime_binding: None, expr: Expr::identifier("missing"), + structured_leaf_bindings: None, }], console_args: vec![], }], @@ -1501,6 +1913,7 @@ mod tests { type_name: "int".to_string(), runtime_binding: None, expr: Expr::identifier("a"), + structured_leaf_bindings: None, }, DebugVariableUpdate { name: "x".to_string(), @@ -1514,6 +1927,7 @@ mod tests { }, span::Span::default(), ), + structured_leaf_bindings: None, }, ], console_args: vec![], @@ -1600,12 +2014,14 @@ mod tests { type_name: "int".to_string(), runtime_binding: None, expr: Expr::identifier("a"), + structured_leaf_bindings: None, }, DebugVariableUpdate { name: "__arg_inner_0".to_string(), type_name: "int".to_string(), runtime_binding: None, expr: Expr::identifier("__arg_outer_0"), + structured_leaf_bindings: None, }, DebugVariableUpdate { name: "x".to_string(), @@ -1619,6 +2035,7 @@ mod tests { }, span::Span::default(), ), + structured_leaf_bindings: None, }, ], console_args: vec![], @@ -1657,6 +2074,7 @@ mod tests { type_name: "int".to_string(), runtime_binding: Some(RuntimeBinding::DataStackSlot { from_top: 0 }), expr: Expr::identifier("missing"), + structured_leaf_bindings: None, }], console_args: vec![], }, @@ -1706,6 +2124,7 @@ mod tests { type_name: "int".to_string(), runtime_binding: Some(RuntimeBinding::DataStackSlot { from_top: 0 }), expr: Expr::identifier("missing"), + structured_leaf_bindings: None, }], console_args: vec![], }, @@ -1760,8 +2179,8 @@ mod tests { "next", "State", vec![ - DebugParamLeafBinding { field_path: vec!["amount".to_string()], type_name: "int".to_string(), stack_index: 1 }, - DebugParamLeafBinding { field_path: vec!["code".to_string()], type_name: "byte[2]".to_string(), stack_index: 0 }, + DebugLeafBinding { field_path: vec!["amount".to_string()], type_name: "int".to_string(), stack_index: Some(1) }, + DebugLeafBinding { field_path: vec!["code".to_string()], type_name: "byte[2]".to_string(), stack_index: Some(0) }, ], )], vec![], @@ -1776,4 +2195,59 @@ mod tests { assert!(scope_state.contains_key("__struct_next_amount")); assert!(scope_state.get("__struct_next_amount").is_some_and(|binding| binding.hidden)); } + + #[test] + fn lower_expr_for_eval_rewrites_structured_bindings_to_hidden_leaves() { + let session = make_session( + vec![ + structured_param( + "next", + "State", + vec![DebugLeafBinding { + field_path: vec!["amount".to_string()], + type_name: "int".to_string(), + stack_index: Some(0), + }], + ), + structured_param( + "next_states", + "State[]", + vec![DebugLeafBinding { + field_path: vec!["amount".to_string()], + type_name: "int[]".to_string(), + stack_index: Some(1), + }], + ), + ], + vec![], + &[], + ) + .unwrap(); + + let scope_state = session.scope_state(StepId::ROOT).expect("scope state"); + + let field_expr = parse_expression_ast("next.amount").expect("parse field"); + let lowered_field = lower_expr_for_eval(&field_expr, &scope_state).expect("lower field access"); + assert!(matches!(lowered_field.kind, ExprKind::Identifier(ref name) if name == "__struct_next_amount")); + + let indexed_expr = parse_expression_ast("next_states[0].amount").expect("parse indexed field"); + let lowered_indexed = lower_expr_for_eval(&indexed_expr, &scope_state).expect("lower indexed field access"); + match lowered_indexed.kind { + ExprKind::ArrayIndex { source, index } => { + assert!(matches!(source.kind, ExprKind::Identifier(ref name) if name == "__struct_next_states_amount")); + assert!(matches!(index.kind, ExprKind::Int(0))); + } + other => panic!("expected lowered array index, got {other:?}"), + } + + let length_expr = parse_expression_ast("next_states.length").expect("parse structured length"); + let lowered_length = lower_expr_for_eval(&length_expr, &scope_state).expect("lower structured length"); + match lowered_length.kind { + ExprKind::UnarySuffix { source, kind, .. } => { + assert!(matches!(source.kind, ExprKind::Identifier(ref name) if name == "__struct_next_states_amount")); + assert!(matches!(kind, UnarySuffixKind::Length)); + } + other => panic!("expected lowered length suffix, got {other:?}"), + } + } } diff --git a/debugger/session/tests/debug_session_tests.rs b/debugger/session/tests/debug_session_tests.rs index fd262c32..5aa4d51e 100644 --- a/debugger/session/tests/debug_session_tests.rs +++ b/debugger/session/tests/debug_session_tests.rs @@ -870,16 +870,17 @@ contract ShiftedBindings() { } #[test] -fn debug_session_classifies_contract_fields_separately_from_entrypoint_params() -> Result<(), Box> { +fn debug_session_evaluates_structured_state_expressions() -> Result<(), Box> { let source = r#"pragma silverscript ^0.1.0; -contract ScopeKinds() { +contract StructuredEvalState() { int amount = 1; - byte[32] owner = 0x1111111111111111111111111111111111111111111111111111111111111111; - byte[2] tag = 0xabcd; + bool active = true; + byte[1] tag = 0xaa; - entrypoint function inspect(State next) { - require(next.amount > amount); + entrypoint function inspect(State next_state) { + int bumped = next_state.amount + amount; + require(bumped > 0); } } "#; @@ -888,41 +889,37 @@ contract ScopeKinds() { source, vec![], "inspect", - vec![struct_object(vec![ - ("amount", Expr::int(7)), - ("owner", Expr::bytes(vec![0x22u8; 32])), - ("tag", Expr::bytes(vec![0xbe, 0xef])), - ])], + vec![struct_object(vec![("amount", Expr::int(5)), ("active", Expr::bool(true)), ("tag", Expr::bytes(vec![0xaa]))])], |session| { session.run_to_first_executed_statement()?; - let amount = session.variable_by_name("amount")?; - assert_eq!(amount.origin.label(), "state"); - - let owner = session.variable_by_name("owner")?; - assert_eq!(owner.origin.label(), "state"); + let (type_name, value) = session.evaluate_expression("next_state")?; + assert_eq!(type_name, "State"); + assert_eq!(format_value(&type_name, &value), "{amount: 5, active: true, tag: 0xaa}"); - let tag = session.variable_by_name("tag")?; - assert_eq!(tag.origin.label(), "state"); + let (type_name, value) = session.evaluate_expression("next_state.amount")?; + assert_eq!(type_name, "int"); + assert_eq!(format_value(&type_name, &value), "5"); - let next = session.variable_by_name("next")?; - assert_eq!(next.origin.label(), "arg"); + let (type_name, value) = session.evaluate_expression("next_state.amount + amount")?; + assert_eq!(type_name, "int"); + assert_eq!(format_value(&type_name, &value), "6"); Ok(()) }, ) } #[test] -fn debug_session_formats_live_state_param_as_object_and_keeps_lowered_locals_resolvable() -> Result<(), Box> { +fn debug_session_evaluates_structured_state_array_expressions() -> Result<(), Box> { let source = r#"pragma silverscript ^0.1.0; -contract StructuredStateParam() { +contract StructuredEvalStateArray() { int amount = 1; - byte[32] owner = 0x1111111111111111111111111111111111111111111111111111111111111111; + bool active = true; + byte[1] tag = 0xaa; - entrypoint function inspect(State next) { - int bumped = next.amount + 1; - require(bumped > amount); + entrypoint function inspect(State[] next_states) { + require(next_states.length == 2); } } "#; @@ -931,31 +928,96 @@ contract StructuredStateParam() { source, vec![], "inspect", - vec![struct_object(vec![("amount", Expr::int(7)), ("owner", Expr::bytes(vec![0x22u8; 32]))])], + vec![Expr::new( + ExprKind::Array(vec![ + struct_object(vec![("amount", Expr::int(5)), ("active", Expr::bool(true)), ("tag", Expr::bytes(vec![0xaa]))]), + struct_object(vec![("amount", Expr::int(7)), ("active", Expr::bool(true)), ("tag", Expr::bytes(vec![0xaa]))]), + ]), + Default::default(), + )], |session| { session.run_to_first_executed_statement()?; - let next = session.variable_by_name("next")?; - assert_eq!(format_value(&next.type_name, &next.value), format!("{{amount: 7, owner: 0x{}}}", "22".repeat(32))); + let (type_name, value) = session.evaluate_expression("next_states")?; + assert_eq!(type_name, "State[]"); + assert_eq!( + format_value(&type_name, &value), + "[{amount: 5, active: true, tag: 0xaa}, {amount: 7, active: true, tag: 0xaa}]" + ); - session.step_over()?; - let bumped = session.variable_by_name("bumped")?; - assert_eq!(format_value(&bumped.type_name, &bumped.value), "8"); + let (type_name, value) = session.evaluate_expression("next_states.length")?; + assert_eq!(type_name, "int"); + assert_eq!(format_value(&type_name, &value), "2"); + + let (type_name, value) = session.evaluate_expression("next_states[0]")?; + assert_eq!(type_name, "State"); + assert_eq!(format_value(&type_name, &value), "{amount: 5, active: true, tag: 0xaa}"); + + let (type_name, value) = session.evaluate_expression("next_states[1].amount - next_states[0].amount")?; + assert_eq!(type_name, "int"); + assert_eq!(format_value(&type_name, &value), "2"); Ok(()) }, ) } #[test] -fn debug_session_formats_live_state_array_param_as_object_array() -> Result<(), Box> { +fn debug_session_evaluates_custom_struct_expressions() -> Result<(), Box> { let source = r#"pragma silverscript ^0.1.0; -contract StructuredStateArrayParam() { +contract StructuredEvalPair() { + struct Pair { + int amount; + byte[2] code; + } + + entrypoint function inspect(Pair next_pair) { + require(next_pair.amount > 0); + } +} +"#; + + with_session_for_source( + source, + vec![], + "inspect", + vec![struct_object(vec![("amount", Expr::int(9)), ("code", Expr::bytes(vec![0x12, 0x34]))])], + |session| { + session.run_to_first_executed_statement()?; + + let (type_name, value) = session.evaluate_expression("next_pair")?; + assert_eq!(type_name, "Pair"); + assert_eq!(format_value(&type_name, &value), "{amount: 9, code: 0x1234}"); + + let (type_name, value) = session.evaluate_expression("next_pair.amount")?; + assert_eq!(type_name, "int"); + assert_eq!(format_value(&type_name, &value), "9"); + + let (type_name, value) = session.evaluate_expression("next_pair.code")?; + assert_eq!(type_name, "byte[2]"); + assert_eq!(format_value(&type_name, &value), "0x1234"); + Ok(()) + }, + ) +} + +#[test] +fn debug_session_preserves_structured_scope_inside_inline_calls() -> Result<(), Box> { + let source = r#"pragma silverscript ^0.1.0; + +contract InlineStructuredEval() { int amount = 1; - byte[32] owner = 0x1111111111111111111111111111111111111111111111111111111111111111; + bool active = true; + byte[1] tag = 0xaa; - entrypoint function inspect(State[] next_states) { - require(next_states.length == 2); + function inspect_inner(State inner_state) { + int bumped = inner_state.amount + amount; + require(bumped > 0); + } + + entrypoint function inspect(State next_state) { + inspect_inner(next_state); + require(next_state.active == active); } } "#; @@ -964,26 +1026,75 @@ contract StructuredStateArrayParam() { source, vec![], "inspect", - vec![Expr::new( - ExprKind::Array(vec![ - struct_object(vec![("amount", Expr::int(7)), ("owner", Expr::bytes(vec![0x22u8; 32]))]), - struct_object(vec![("amount", Expr::int(9)), ("owner", Expr::bytes(vec![0x33u8; 32]))]), - ]), - Default::default(), - )], + vec![struct_object(vec![("amount", Expr::int(5)), ("active", Expr::bool(true)), ("tag", Expr::bytes(vec![0xaa]))])], |session| { session.run_to_first_executed_statement()?; - let next_states = session.variable_by_name("next_states")?; - assert_eq!( - format_value(&next_states.type_name, &next_states.value), - format!("[{{amount: 7, owner: 0x{}}}, {{amount: 9, owner: 0x{}}}]", "22".repeat(32), "33".repeat(32)) - ); + for _ in 0..3 { + if !session.call_stack().is_empty() && session.variable_by_name("inner_state").is_ok() { + break; + } + session.step_into()?.ok_or("expected inline step")?; + } + + assert_eq!(session.call_stack(), vec!["inspect_inner".to_string()]); + let vars = session.list_variables()?; + assert!(!vars.iter().any(|var| var.name.starts_with("__struct_"))); + + let inner_state = session.variable_by_name("inner_state")?; + assert_eq!(format_value(&inner_state.type_name, &inner_state.value), "{amount: 5, active: true, tag: 0xaa}"); + + let next_state = session.variable_by_name("next_state")?; + assert_eq!(format_value(&next_state.type_name, &next_state.value), "{amount: 5, active: true, tag: 0xaa}"); + + let (type_name, value) = session.evaluate_expression("inner_state")?; + assert_eq!(type_name, "State"); + assert_eq!(format_value(&type_name, &value), "{amount: 5, active: true, tag: 0xaa}"); + + let (type_name, value) = session.evaluate_expression("inner_state.amount")?; + assert_eq!(type_name, "int"); + assert_eq!(format_value(&type_name, &value), "5"); + + let (type_name, value) = session.evaluate_expression("next_state.amount + amount")?; + assert_eq!(type_name, "int"); + assert_eq!(format_value(&type_name, &value), "6"); Ok(()) }, ) } +#[test] +fn debug_session_evaluates_structured_expressions_without_source_text() -> Result<(), Box> { + let source = r#"pragma silverscript ^0.1.0; + +contract MissingStructuredSource() { + int amount = 1; + + entrypoint function inspect(State next) { + require(next.amount > amount); + } +} +"#; + + let compile_opts = CompileOptions { record_debug_infos: true, ..Default::default() }; + let compiled = compile_contract(source, &[], compile_opts)?; + let mut debug_info = compiled.debug_info.clone().ok_or("missing debug info")?; + debug_info.source.clear(); + + let sig_cache = Cache::new(10_000); + let reused_values = SigHashReusedValuesUnsync::new(); + let ctx = EngineCtx::new(&sig_cache).with_reused(&reused_values); + let engine = debugger_session::session::DebugEngine::new(ctx, EngineFlags { covenants_enabled: true }); + let sigscript = compiled.build_sig_script("inspect", vec![struct_object(vec![("amount", Expr::int(7))])])?; + let mut session = DebugSession::full(&sigscript, &compiled.script, "", Some(debug_info), engine)?; + + session.run_to_first_executed_statement()?; + let (type_name, value) = session.evaluate_expression("next.amount")?; + assert_eq!(type_name, "int"); + assert_eq!(format_value(&type_name, &value), "7"); + Ok(()) +} + #[test] fn debug_session_nested_inline_calls_with_args_compile_and_step() -> Result<(), Box> { let source = r#"pragma silverscript ^0.1.0; diff --git a/silverscript-lang/src/compiler.rs b/silverscript-lang/src/compiler.rs index af354656..31ad2d72 100644 --- a/silverscript-lang/src/compiler.rs +++ b/silverscript-lang/src/compiler.rs @@ -2435,7 +2435,7 @@ fn compile_entrypoint_function<'i>( ) .map_err(|err| err.with_span(&stmt.span()))?; } - recorder.finish_statement_at(stmt, builder.script().len(), &env, &types, &stack_bindings)?; + recorder.finish_statement_at(stmt, builder.script().len(), &env, &types, &stack_bindings, structs)?; } let flattened_returns = if has_return { @@ -3759,7 +3759,15 @@ fn compile_inline_call<'i>( } let call_start = builder.script().len(); - recorder.begin_inline_call(call_span, call_start, function, &bindings.debug_env, &bindings.stack_bindings)?; + recorder.begin_inline_call( + call_span, + call_start, + function, + &bindings.debug_env, + &bindings.types, + &bindings.stack_bindings, + structs, + )?; let mut returns: Vec> = Vec::new(); let initial_stack_binding_count = bindings.stack_bindings.len(); @@ -3837,7 +3845,14 @@ fn compile_inline_call<'i>( ) .map_err(|err| err.with_span(&stmt.span()))?; } - recorder.finish_statement_at(stmt, builder.script().len(), &bindings.env, &bindings.types, &bindings.stack_bindings)?; + recorder.finish_statement_at( + stmt, + builder.script().len(), + &bindings.env, + &bindings.types, + &bindings.stack_bindings, + structs, + )?; } for _ in 0..bindings.stack_bindings.len().saturating_sub(initial_stack_binding_count) { @@ -4121,7 +4136,7 @@ fn compile_block<'i>( ) .map_err(|err| err.with_span(&stmt.span()))?, ); - recorder.finish_statement_at(stmt, builder.script().len(), env, types, stack_bindings)?; + recorder.finish_statement_at(stmt, builder.script().len(), env, types, stack_bindings, structs)?; } if scoped_stack_locals && !added_stack_locals.is_empty() { diff --git a/silverscript-lang/src/compiler/debug_recording.rs b/silverscript-lang/src/compiler/debug_recording.rs index df7f8738..df788865 100644 --- a/silverscript-lang/src/compiler/debug_recording.rs +++ b/silverscript-lang/src/compiler/debug_recording.rs @@ -1,9 +1,9 @@ use std::collections::{HashMap, HashSet}; use std::fmt; -use crate::ast::{ConstantAst, ContractFieldAst, Expr, FunctionAst, ParamAst, Statement}; +use crate::ast::{ConstantAst, ContractFieldAst, Expr, ExprKind, FunctionAst, ParamAst, Statement, parse_type_ref}; use crate::debug_info::{ - DebugFunctionRange, DebugInfo, DebugInfoRecorder, DebugNamedValue, DebugParamBinding, DebugParamLeafBinding, DebugParamMapping, + DebugFunctionRange, DebugInfo, DebugInfoRecorder, DebugLeafBinding, DebugNamedValue, DebugParamBinding, DebugParamMapping, DebugStep, DebugVariableUpdate, RuntimeBinding, SourceSpan, StepKind, }; @@ -73,8 +73,9 @@ impl<'i> DebugRecorder<'i> { env: &HashMap>, types: &HashMap, stack_bindings: &HashMap, + structs: &super::StructRegistry, ) -> Result<(), CompilerError> { - self.inner.finish_statement_at(stmt, bytecode_end, env, types, stack_bindings) + self.inner.finish_statement_at(stmt, bytecode_end, env, types, stack_bindings, structs) } /// Records an inline call entry step and opens a nested call frame. @@ -84,9 +85,11 @@ impl<'i> DebugRecorder<'i> { bytecode_offset: usize, function: &FunctionAst<'i>, env: &HashMap>, + types: &HashMap, stack_bindings: &HashMap, + structs: &super::StructRegistry, ) -> Result<(), CompilerError> { - self.inner.begin_inline_call(span, bytecode_offset, function, env, stack_bindings) + self.inner.begin_inline_call(span, bytecode_offset, function, env, types, stack_bindings, structs) } /// Records an inline call exit step and closes the active nested call frame. @@ -132,6 +135,7 @@ trait DebugRecorderImpl<'i>: fmt::Debug { env: &HashMap>, types: &HashMap, stack_bindings: &HashMap, + structs: &super::StructRegistry, ) -> Result<(), CompilerError>; fn begin_inline_call( &mut self, @@ -139,7 +143,9 @@ trait DebugRecorderImpl<'i>: fmt::Debug { bytecode_offset: usize, function: &FunctionAst<'i>, env: &HashMap>, + types: &HashMap, stack_bindings: &HashMap, + structs: &super::StructRegistry, ) -> Result<(), CompilerError>; fn finish_inline_call(&mut self, span: SourceSpan, bytecode_offset: usize, callee: &str); fn record_variable_binding( @@ -185,6 +191,7 @@ impl<'i> DebugRecorderImpl<'i> for NoopDebugRecorder { _env: &HashMap>, _types: &HashMap, _stack_bindings: &HashMap, + _structs: &super::StructRegistry, ) -> Result<(), CompilerError> { Ok(()) } @@ -195,7 +202,9 @@ impl<'i> DebugRecorderImpl<'i> for NoopDebugRecorder { _bytecode_offset: usize, _function: &FunctionAst<'i>, _env: &HashMap>, + _types: &HashMap, _stack_bindings: &HashMap, + _structs: &super::StructRegistry, ) -> Result<(), CompilerError> { Ok(()) } @@ -299,6 +308,7 @@ impl<'i> DebugRecorderImpl<'i> for ActiveDebugRecorder<'i> { env: &HashMap>, types: &HashMap, stack_bindings: &HashMap, + structs: &super::StructRegistry, ) -> Result<(), CompilerError> { let Some(entrypoint) = self.active_entrypoint_mut() else { return Ok(()); @@ -307,7 +317,7 @@ impl<'i> DebugRecorderImpl<'i> for ActiveDebugRecorder<'i> { return Ok(()); }; - let updates = collect_variable_updates(&frame.env_before, &frame.stack_bindings_before, env, types, stack_bindings)?; + let updates = collect_variable_updates(&frame.env_before, &frame.stack_bindings_before, env, types, stack_bindings, structs)?; let console_args = collect_console_args(stmt, env)?; let span = SourceSpan::from(stmt.span()); let bytecode_len = bytecode_end.saturating_sub(frame.start); @@ -323,7 +333,9 @@ impl<'i> DebugRecorderImpl<'i> for ActiveDebugRecorder<'i> { bytecode_offset: usize, function: &FunctionAst<'i>, env: &HashMap>, + types: &HashMap, stack_bindings: &HashMap, + structs: &super::StructRegistry, ) -> Result<(), CompilerError> { let Some(entrypoint) = self.active_entrypoint_mut() else { return Ok(()); @@ -340,21 +352,26 @@ impl<'i> DebugRecorderImpl<'i> for ActiveDebugRecorder<'i> { 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() { - let runtime_binding = runtime_binding_for_inline_binding(&expr, stack_bindings); - resolve_variable_update(env, &mut updates, &name, "internal", expr, runtime_binding)?; - } - } + let mut updates = collect_inline_runtime_updates(env, types, stack_bindings)?; for param in &function.params { let expr = env.get(¶m.name).cloned().unwrap_or_else(|| Expr::identifier(param.name.clone())); let runtime_binding = runtime_binding_for_inline_binding(&expr, stack_bindings) .or_else(|| runtime_binding_for_stack_name(¶m.name, stack_bindings)); - resolve_variable_update(env, &mut updates, ¶m.name, ¶m.type_ref.type_name(), expr, runtime_binding)?; + let structured_leaf_bindings = structured_leaf_bindings_for_type_ref(¶m.type_ref, structs)?; + let has_structured_binding = structured_leaf_bindings.is_some(); + resolve_variable_update( + env, + &mut updates, + ¶m.name, + ¶m.type_ref.type_name(), + expr.clone(), + runtime_binding, + structured_leaf_bindings, + )?; + if has_structured_binding { + collect_inline_struct_leaf_updates(env, &mut updates, param, &expr, stack_bindings, structs)?; + } } entrypoint.steps[enter_step_index].variable_updates.extend(updates); @@ -383,7 +400,13 @@ impl<'i> DebugRecorderImpl<'i> for ActiveDebugRecorder<'i> { 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, runtime_binding, expr }); + entrypoint.steps[step_index].variable_updates.push(DebugVariableUpdate { + name, + type_name, + runtime_binding, + structured_leaf_bindings: None, + expr, + }); } fn into_debug_info(mut self: Box, source: String) -> Option> { @@ -554,10 +577,10 @@ impl<'i> StagedEntrypointDebug<'i> { let binding = if let Some(leaf_specs) = leaf_specs { let mut leaf_bindings = Vec::with_capacity(leaf_specs.len()); for (field_path, leaf_type_name) in leaf_specs { - leaf_bindings.push(DebugParamLeafBinding { + leaf_bindings.push(DebugLeafBinding { field_path, type_name: leaf_type_name, - stack_index: next_stack_index(), + stack_index: Some(next_stack_index()), }); } DebugParamBinding::StructuredValue { leaf_bindings } @@ -596,12 +619,43 @@ struct CallFrame { call_depth: u32, } +fn structured_leaf_bindings_for_type_ref( + type_ref: &crate::ast::TypeRef, + structs: &super::StructRegistry, +) -> Result>, CompilerError> { + if super::struct_name_from_type_ref(type_ref, structs).is_none() + && super::struct_array_name_from_type_ref(type_ref, structs).is_none() + { + return Ok(None); + } + + Ok(Some( + super::flatten_type_ref_leaves(type_ref, structs)? + .into_iter() + .map(|(field_path, leaf_type)| DebugLeafBinding { + field_path, + type_name: super::type_name_from_ref(&leaf_type), + stack_index: None, + }) + .collect(), + )) +} + +fn structured_leaf_bindings_for_type_name( + type_name: &str, + structs: &super::StructRegistry, +) -> Result>, CompilerError> { + let type_ref = parse_type_ref(type_name)?; + structured_leaf_bindings_for_type_ref(&type_ref, structs) +} + fn collect_variable_updates<'i>( before_env: &HashMap>, before_stack_bindings: &HashMap, after_env: &HashMap>, types: &HashMap, after_stack_bindings: &HashMap, + structs: &super::StructRegistry, ) -> Result>, CompilerError> { let mut names: Vec = after_env.keys().chain(after_stack_bindings.keys()).cloned().collect::>().into_iter().collect(); @@ -621,8 +675,41 @@ fn collect_variable_updates<'i>( continue; } - resolve_variable_update(after_env, &mut updates, &name, type_name, after_expr, after_runtime_binding)?; + resolve_variable_update(after_env, &mut updates, &name, type_name, after_expr, after_runtime_binding, None)?; } + + for (name, type_name) in types { + let Some(structured_leaf_bindings) = structured_leaf_bindings_for_type_name(type_name, structs)? else { + continue; + }; + + let mut leaf_changed = false; + let mut leaf_present = false; + for leaf in &structured_leaf_bindings { + let leaf_name = super::flattened_struct_name(name, &leaf.field_path); + let after_expr = after_env.get(&leaf_name).cloned().unwrap_or_else(|| Expr::identifier(leaf_name.clone())); + let expr_changed = before_env.get(&leaf_name) != Some(&after_expr); + let before_runtime_binding = runtime_binding_for_stack_name(&leaf_name, before_stack_bindings); + let after_runtime_binding = runtime_binding_for_stack_name(&leaf_name, after_stack_bindings); + leaf_present |= after_env.contains_key(&leaf_name) || after_stack_bindings.contains_key(&leaf_name); + leaf_changed |= expr_changed || before_runtime_binding != after_runtime_binding; + } + + if !leaf_present || !leaf_changed { + continue; + } + + resolve_variable_update( + after_env, + &mut updates, + name, + type_name, + Expr::identifier(name.to_string()), + None, + Some(structured_leaf_bindings), + )?; + } + Ok(updates) } @@ -633,9 +720,16 @@ fn resolve_variable_update<'i>( type_name: &str, expr: Expr<'i>, runtime_binding: Option, + structured_leaf_bindings: Option>, ) -> 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(), runtime_binding, expr: resolved }); + updates.push(DebugVariableUpdate { + name: name.to_string(), + type_name: type_name.to_string(), + runtime_binding, + structured_leaf_bindings, + expr: resolved, + }); Ok(()) } @@ -658,11 +752,73 @@ fn runtime_binding_for_inline_binding<'i>(expr: &Expr<'i>, stack_bindings: &Hash } } +fn collect_inline_runtime_updates<'i>( + env: &HashMap>, + types: &HashMap, + stack_bindings: &HashMap, +) -> Result>, CompilerError> { + let mut names = env + .keys() + .filter(|name| name.starts_with("__arg_") || name.starts_with("__struct_")) + .chain(stack_bindings.keys().filter(|name| !env.contains_key(*name))) + .cloned() + .collect::>(); + names.sort_unstable(); + names.dedup(); + + let mut updates = Vec::new(); + for name in names { + let Some(type_name) = types + .get(&name) + .map(String::as_str) + .or_else(|| (name.starts_with("__arg_") || name.starts_with("__struct_")).then_some("internal")) + else { + continue; + }; + let expr = env.get(&name).cloned().unwrap_or_else(|| Expr::identifier(name.clone())); + let runtime_binding = runtime_binding_for_stack_name(&name, stack_bindings) + .or_else(|| runtime_binding_for_inline_binding(&expr, stack_bindings)); + resolve_variable_update(env, &mut updates, &name, type_name, expr, runtime_binding, None)?; + } + Ok(updates) +} + +fn collect_inline_struct_leaf_updates<'i>( + env: &HashMap>, + updates: &mut Vec>, + param: &ParamAst<'i>, + param_expr: &Expr<'i>, + stack_bindings: &HashMap, + structs: &super::StructRegistry, +) -> Result<(), CompilerError> { + let ExprKind::Identifier(source_name) = ¶m_expr.kind else { + return Ok(()); + }; + + for (field_path, field_type) in super::flatten_type_ref_leaves(¶m.type_ref, structs)? { + let target_leaf_name = super::flattened_struct_name(¶m.name, &field_path); + let source_leaf_name = super::flattened_struct_name(source_name, &field_path); + let runtime_binding = runtime_binding_for_stack_name(&source_leaf_name, stack_bindings); + resolve_variable_update( + env, + updates, + &target_leaf_name, + &super::type_name_from_ref(&field_type), + Expr::identifier(source_leaf_name), + runtime_binding, + None, + )?; + } + + Ok(()) +} + #[cfg(test)] mod tests { use std::collections::HashMap; use crate::ast::{Expr, parse_contract_ast}; + use crate::compiler::{CompileOptions, compile_contract}; use crate::debug_info::{RuntimeBinding, StepKind}; use super::{DebugRecorder, SourceSpan, collect_variable_updates}; @@ -689,9 +845,13 @@ mod tests { let span = SourceSpan::from(stmt.span()); recorder.begin_statement_at(0, &HashMap::new(), &HashMap::new()); - recorder.finish_statement_at(stmt, 0, &HashMap::new(), &HashMap::new(), &HashMap::new()).expect("noop statement recording"); + recorder + .finish_statement_at(stmt, 0, &HashMap::new(), &HashMap::new(), &HashMap::new(), &structs) + .expect("noop statement recording"); - recorder.begin_inline_call(span, 1, function, &HashMap::new(), &HashMap::new()).expect("noop begin call recording"); + recorder + .begin_inline_call(span, 1, function, &HashMap::new(), &HashMap::new(), &HashMap::new(), &structs) + .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), None, 2, span); recorder.finish_entrypoint(1); @@ -728,12 +888,12 @@ mod tests { types.insert("y".to_string(), "int".to_string()); recorder.begin_statement_at(0, &before, &HashMap::new()); - recorder.finish_statement_at(stmt, 0, &after, &types, &HashMap::new()).expect("record_step first statement"); + recorder.finish_statement_at(stmt, 0, &after, &types, &HashMap::new(), &structs).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, &HashMap::new()).expect("begin call recording"); + recorder.begin_inline_call(span, 1, function, &inline_env, &types, &HashMap::new(), &structs).expect("begin call recording"); recorder.record_variable_binding("tmp".to_string(), "int".to_string(), Expr::int(9), None, 1, span); recorder.finish_inline_call(span, 2, "callee"); @@ -772,18 +932,73 @@ mod tests { #[test] fn collect_variable_updates_records_runtime_slot_changes_without_env_expr() { + let contract = + parse_contract_ast("contract Demo() { entrypoint function spend() { require(true); } }").expect("parse contract"); + let structs = super::super::build_struct_registry(&contract).expect("build struct registry"); let before_env = HashMap::new(); let after_env = HashMap::new(); let before_stack_bindings = HashMap::from([("amount".to_string(), 1)]); let after_stack_bindings = HashMap::from([("amount".to_string(), 2)]); let types = HashMap::from([("amount".to_string(), "int".to_string())]); - let updates = collect_variable_updates(&before_env, &before_stack_bindings, &after_env, &types, &after_stack_bindings) - .expect("collect updates"); + let updates = + collect_variable_updates(&before_env, &before_stack_bindings, &after_env, &types, &after_stack_bindings, &structs) + .expect("collect updates"); assert_eq!(updates.len(), 1); assert_eq!(updates[0].name, "amount"); assert_eq!(updates[0].expr, Expr::identifier("amount")); assert_eq!(updates[0].runtime_binding, Some(RuntimeBinding::DataStackSlot { from_top: 2 })); } + + #[test] + fn inline_structured_params_record_leaf_updates() { + let source = r#" + pragma silverscript ^0.1.0; + + contract InlineStruct() { + int amount = 1; + bool active = true; + byte[1] tag = 0xaa; + + function inspect_inner(State inner_state) { + int bumped = inner_state.amount + amount; + require(bumped > 0); + } + + entrypoint function inspect(State next_state) { + inspect_inner(next_state); + require(next_state.active == active); + } + } + "#; + + let compiled = + compile_contract(source, &[], CompileOptions { record_debug_infos: true, ..Default::default() }).expect("compile"); + let debug_info = compiled.debug_info.expect("debug info"); + let inline_enter_step = debug_info + .steps + .iter() + .find(|step| matches!(step.kind, StepKind::InlineCallEnter { .. }) && step.frame_id == 1) + .expect("inline enter step"); + + for name in [ + "__struct_inner_state_amount", + "__struct_inner_state_active", + "__struct_inner_state_tag", + "__struct_next_state_amount", + "__struct_next_state_active", + "__struct_next_state_tag", + "inner_state", + ] { + assert!( + inline_enter_step.variable_updates.iter().any(|update| update.name == name), + "missing inline structured update for {name}" + ); + } + + let inner_state = + inline_enter_step.variable_updates.iter().find(|update| update.name == "inner_state").expect("inner_state update"); + assert!(inner_state.structured_leaf_bindings.is_some(), "inline structured param should carry structured metadata"); + } } diff --git a/silverscript-lang/src/debug_info.rs b/silverscript-lang/src/debug_info.rs index 765d6941..3e1a12b0 100644 --- a/silverscript-lang/src/debug_info.rs +++ b/silverscript-lang/src/debug_info.rs @@ -120,6 +120,8 @@ pub struct DebugVariableUpdate<'i> { pub type_name: String, #[serde(default)] pub runtime_binding: Option, + #[serde(default)] + pub structured_leaf_bindings: Option>, /// Pre-resolved expression for debugger shadow evaluation. /// Identifiers may include inline synthetic placeholders (`__arg_*`). pub expr: Expr<'i>, @@ -131,18 +133,19 @@ pub enum RuntimeBinding { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct DebugParamLeafBinding { +pub struct DebugLeafBinding { #[serde(default)] pub field_path: Vec, pub type_name: String, - pub stack_index: i64, + #[serde(default)] + pub stack_index: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case", tag = "kind")] pub enum DebugParamBinding { SingleValue { stack_index: i64 }, - StructuredValue { leaf_bindings: Vec }, + StructuredValue { leaf_bindings: Vec }, } /// Maps one source parameter to either a single runtime slot or a lowered set of leaf slots. diff --git a/silverscript-lang/tests/compiler_tests.rs b/silverscript-lang/tests/compiler_tests.rs index ebb09a5e..babb6a8c 100644 --- a/silverscript-lang/tests/compiler_tests.rs +++ b/silverscript-lang/tests/compiler_tests.rs @@ -284,10 +284,10 @@ fn debug_info_records_struct_param_leaf_bindings_in_runtime_order() { assert_eq!(leaf_bindings.len(), 2); assert_eq!(leaf_bindings[0].field_path, vec!["amount".to_string()]); assert_eq!(leaf_bindings[0].type_name, "int"); - assert_eq!(leaf_bindings[0].stack_index, 2); + assert_eq!(leaf_bindings[0].stack_index, Some(2)); assert_eq!(leaf_bindings[1].field_path, vec!["code".to_string()]); assert_eq!(leaf_bindings[1].type_name, "byte[2]"); - assert_eq!(leaf_bindings[1].stack_index, 1); + assert_eq!(leaf_bindings[1].stack_index, Some(1)); let fee = debug_info.params.iter().find(|param| param.name == "fee").expect("scalar param should be recorded"); assert_eq!(fee.binding, DebugParamBinding::SingleValue { stack_index: 0 }); @@ -319,10 +319,10 @@ fn debug_info_records_state_array_param_leaf_bindings_in_runtime_order() { assert_eq!(leaf_bindings.len(), 2); assert_eq!(leaf_bindings[0].field_path, vec!["amount".to_string()]); assert_eq!(leaf_bindings[0].type_name, "int[]"); - assert_eq!(leaf_bindings[0].stack_index, 4); + assert_eq!(leaf_bindings[0].stack_index, Some(4)); assert_eq!(leaf_bindings[1].field_path, vec!["owner".to_string()]); assert_eq!(leaf_bindings[1].type_name, "byte[32][]"); - assert_eq!(leaf_bindings[1].stack_index, 3); + assert_eq!(leaf_bindings[1].stack_index, Some(3)); let fee = debug_info.params.iter().find(|param| param.name == "fee").expect("scalar param should be recorded"); assert_eq!(fee.binding, DebugParamBinding::SingleValue { stack_index: 2 }); From 4549491907b64b538565de043bc81322149ce565 Mon Sep 17 00:00:00 2001 From: Manyfestation <240733973+Manyfestation@users.noreply.github.com> Date: Tue, 24 Mar 2026 17:44:11 +0200 Subject: [PATCH 5/6] fix(debugger): tighten console log step handling --- debugger/cli/tests/cli_tests.rs | 59 +++++++++++++++++++++++++++++++++ debugger/session/src/session.rs | 11 +++--- 2 files changed, 64 insertions(+), 6 deletions(-) diff --git a/debugger/cli/tests/cli_tests.rs b/debugger/cli/tests/cli_tests.rs index 3ac71a25..61ec18fd 100644 --- a/debugger/cli/tests/cli_tests.rs +++ b/debugger/cli/tests/cli_tests.rs @@ -54,6 +54,36 @@ contract Logging(int seed) { ) } +fn write_structured_console_fixture() -> 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_console_fixture_{}_{}", std::process::id(), nonce)); + std::fs::create_dir_all(&dir).expect("create temp fixture dir"); + + let script_path = dir.join("structured_console.sil"); + std::fs::write( + &script_path, + r#"pragma silverscript ^0.1.0; + +contract DebugSmallInline() { + int amount = 1; + bool active = true; + byte[1] tag = 0xaa; + + entrypoint function inspect(State[] next_states) { + console.log("total sum of amounts: ", next_states[0].amount + next_states[1].amount); + + require(next_states[0].active == active); + require(next_states[0].tag == tag); + require(next_states[1].tag == 0xbb); + } +} +"#, + ) + .expect("write fixture contract"); + + script_path +} + fn shared_example_path(name: &str) -> std::path::PathBuf { std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../examples").join(name) } @@ -316,6 +346,35 @@ fn cli_debugger_interactive_prints_console_logs_automatically() { assert!(stdout.contains("seed 5"), "missing first console log: {stdout}"); } +#[test] +fn cli_debugger_interactive_does_not_duplicate_startup_console_logs_for_structured_arrays() { + let script_path = write_structured_console_fixture(); + + let mut child = Command::new(env!("CARGO_BIN_EXE_cli-debugger")) + .arg(&script_path) + .arg("--function") + .arg("inspect") + .arg("--arg") + .arg(r#"[{"amount":5,"active":true,"tag":"0xaa"},{"amount":9,"active":true,"tag":"0xbb"}]"#) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("failed to spawn cli-debugger"); + + child.stdin.as_mut().expect("stdin available").write_all(b"q\n").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); + let needle = "total sum of amounts: 14"; + + assert!(stderr.is_empty(), "unexpected stderr: {stderr}"); + assert_eq!(stdout.matches(needle).count(), 1, "expected exactly one startup console log: {stdout}"); +} + #[test] fn cli_debugger_accepts_state_object_arg_and_renders_source_level_value() { let (script_path, _test_file_path) = write_structured_args_fixture(); diff --git a/debugger/session/src/session.rs b/debugger/session/src/session.rs index a04e1d71..1a6693e3 100644 --- a/debugger/session/src/session.rs +++ b/debugger/session/src/session.rs @@ -310,7 +310,6 @@ impl<'a, 'i> DebugSession<'a, 'i> { }; 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())); } @@ -360,7 +359,6 @@ impl<'a, 'i> DebugSession<'a, 'i> { let offset = self.current_byte_offset(); if self.engine.is_executing() { if let Some(index) = self.steppable_step_index_for_offset(offset, None) { - self.current_step_index = Some(index); self.mark_step_executed(index); return Ok(Some(self.state())); } @@ -824,8 +822,11 @@ impl<'a, 'i> DebugSession<'a, 'i> { } fn mark_step_executed(&mut self, step_index: usize) { + self.current_step_index = Some(step_index); if let Some(step) = self.step_at_order(step_index).cloned() { - self.executed_steps.insert(step.id()); + if !self.executed_steps.insert(step.id()) { + return; + } self.capture_inline_scope_snapshot(&step); self.render_console_messages(&step); } @@ -884,7 +885,6 @@ impl<'a, 'i> DebugSession<'a, 'i> { // `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); } } @@ -1847,8 +1847,7 @@ mod tests { ) .unwrap(); - session.current_step_index = Some(1); - session.executed_steps.insert(StepId::new(0, 1)); + session.mark_step_executed(0); session.mark_step_executed(1); assert_eq!(session.take_console_output(), vec!["inner 6"]); From 4ca707192641c2ae8048d38f39d5444d01032096 Mon Sep 17 00:00:00 2001 From: Manyfestation <240733973+Manyfestation@users.noreply.github.com> Date: Wed, 25 Mar 2026 17:30:11 +0200 Subject: [PATCH 6/6] Refine debugger readme --- debugger/cli/README.md | 60 ++++++++++++++++++++++++--------- debugger/cli/tests/cli_tests.rs | 57 +++++++++++++++++++++++++++++-- examples/debug_state.sil | 40 ---------------------- 3 files changed, 99 insertions(+), 58 deletions(-) delete mode 100644 examples/debug_state.sil diff --git a/debugger/cli/README.md b/debugger/cli/README.md index 43b1982f..c0c8e255 100644 --- a/debugger/cli/README.md +++ b/debugger/cli/README.md @@ -1,6 +1,6 @@ # SilverScript CLI Debugger -A light-weight tool for stepping through and testing SilverScript smart contracts. +A light-weight, GDB-like attempt at stepping through and testing SilverScript contracts. ### Quick Start @@ -13,7 +13,7 @@ cli-debugger -f [--ctor-arg ]... [--arg ]... cli-debugger ./counter.sil -f check --ctor-arg 10 --arg 7 ``` -Structured `State` and struct-like args use JSON: +Structured `State` and custom `struct` args use JSON: ```bash cli-debugger ./vault.sil -f inspect --arg '{"amount":7,"tag":"0xbeef"}' @@ -63,20 +63,50 @@ doubled + 1 = (int) 15 Done. ``` -### Essential Commands +### Commands | Command | Action | |---|---| -| `n` | **Next**: Step over to the next statement | -| `s` | **Step**: Step into a function | -| `c` | **Continue**: Run until the next breakpoint or completion | -| `b ` | **Break**: Set a breakpoint (e.g., `b 10`) | +| `n` (`next`, `over`) | **Next**: Step over to the next statement | +| `s` (`step`, `into`) | **Step**: Step into a function | +| `si` | **Step Opcode**: Advance by one VM opcode | +| `finish` (`out`) | **Step Out**: Continue until the current frame returns | +| `c` (`continue`) | **Continue**: Run until the next breakpoint or completion | +| `b [line]` (`break [line]`) | **Break**: Set a breakpoint (e.g. `b 10`) or list current breakpoints | | `vars` | **Variables**: List all variables and constants in scope | -| `eval ` | **Evaluate**: Run an expression in the current debugger scope | -| `p ` | **Print**: Show the value of a specific variable | +| `e ` (`eval `) | **Evaluate**: Run an expression in the current debugger scope | +| `p ` (`print `) | **Print**: Show the value of a specific variable | | `stack` | **Stack**: Inspect the raw Kaspa VM execution stack | -| `l` | **List**: Show the source code around your current position | -| `q` | **Quit**: Exit the debugger | +| `l` (`list`) | **List**: Show the source code around your current position | +| `h` / `?` (`help`) | **Help**: Show the command summary | +| `q` (`quit`) | **Quit**: Exit the debugger | + +### Inspection + +Use `vars` to inspect the current source-level scope and `eval` to check expressions in that scope. This works for scalars, `State`, `State[]`, and custom `struct` values. + +```bash +cli-debugger examples/debug_struct_state_matrix.sil --function inspect_state \ + --ctor-arg '{"amount":3,"code":"0x1234"}' \ + --arg '{"amount":5,"active":true,"tag":"0xaa"}' +``` + +```text +(sdb) vars +Contract Constants: + seed_pair (Pair) = {amount: 3, code: 0x1234} +Contract State: + amount (int) = 1 + active (bool) = true + tag (byte[1]) = 0xaa +Call Arguments: + next_state (State) = {amount: 5, active: true, tag: 0xaa} + +(sdb) eval next_state.amount + amount +next_state.amount + amount = (int) 6 +``` + +If the contract executes a source-level `console.log(...)`, its output appears under `Console:` while stepping. The same `vars` and `eval` flow also works for custom structs such as `Pair`. --- @@ -121,17 +151,17 @@ Structured args use the same JSON object and object-array form inside `.test.jso } ``` -### Commands +### Test Commands ```bash -# Run all tests using the sidecar inferred from the contract path +# Run all tests using the matching `.test.json` file inferred from the contract path cli-debugger --run-all -# Run a specific test case using the sidecar inferred from the contract path +# Run a specific test case using the matching `.test.json` file inferred from the contract path cli-debugger --run --test-name ``` -Add `--test-file ` to either form to use an explicit test file instead of the inferred json file path +Add `--test-file ` to either form to use an explicit test file instead of the inferred `.test.json` path. **Output Example:** ```text diff --git a/debugger/cli/tests/cli_tests.rs b/debugger/cli/tests/cli_tests.rs index 61ec18fd..b40094fa 100644 --- a/debugger/cli/tests/cli_tests.rs +++ b/debugger/cli/tests/cli_tests.rs @@ -84,8 +84,59 @@ contract DebugSmallInline() { script_path } -fn shared_example_path(name: &str) -> std::path::PathBuf { - std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../examples").join(name) +fn write_debug_state_fixture() -> 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_state_fixture_{}_{}", std::process::id(), nonce)); + std::fs::create_dir_all(&dir).expect("create temp fixture dir"); + + let script_path = dir.join("debug_state.sil"); + std::fs::write( + &script_path, + r#"pragma silverscript ^0.1.0; + +contract DebugState(int ctor_x) { + int constant const_y = 5; + + int amount = 1; + bool active = true; + byte[1] tag = 0xaa; + struct Pair { + int amount; + byte[2] code; + } + + entrypoint function inspect_state(State next_state) { + int bumped = next_state.amount + 1; + byte[1] next_tag = next_state.tag; + + require(bumped > amount); + require(next_state.active == active); + require(next_tag == next_state.tag); + } + + entrypoint function inspect_state_array(State[] next_states) { + int first_amount = next_states[0].amount; + byte[1] second_tag = next_states[1].tag; + + require(next_states.length == 2); + require(first_amount < next_states[1].amount); + require(next_states[0].active == true); + require(second_tag == next_states[1].tag); + } + + entrypoint function inspect_pair(Pair next_pair) { + int pair_amount = next_pair.amount; + byte[2] pair_tag = next_pair.code; + + require(pair_amount > 0); + require(pair_tag == next_pair.code); + } +} +"#, + ) + .expect("write fixture contract"); + + script_path } fn write_named_test_fixture(script_name: &str, test_file_name: &str) -> (std::path::PathBuf, std::path::PathBuf) { @@ -470,7 +521,7 @@ fn cli_debugger_accepts_struct_constructor_arg_and_renders_source_level_value() #[test] fn cli_debugger_evals_structured_state_expressions() { - let script_path = shared_example_path("debug_state.sil"); + let script_path = write_debug_state_fixture(); let mut child = Command::new(env!("CARGO_BIN_EXE_cli-debugger")) .arg(&script_path) diff --git a/examples/debug_state.sil b/examples/debug_state.sil deleted file mode 100644 index 3cd6aa9b..00000000 --- a/examples/debug_state.sil +++ /dev/null @@ -1,40 +0,0 @@ -pragma silverscript ^0.1.0; - -contract DebugState(int ctor_x) { - int constant const_y = 5; - - int amount = 1; - bool active = true; - byte[1] tag = 0xaa; - struct Pair { - int amount; - byte[2] code; - } - - entrypoint function inspect_state(State next_state) { - int bumped = next_state.amount + 1; - byte[1] next_tag = next_state.tag; - - require(bumped > amount); - require(next_state.active == active); - require(next_tag == next_state.tag); - } - - entrypoint function inspect_state_array(State[] next_states) { - int first_amount = next_states[0].amount; - byte[1] second_tag = next_states[1].tag; - - require(next_states.length == 2); - require(first_amount < next_states[1].amount); - require(next_states[0].active == true); - require(second_tag == next_states[1].tag); - } - - entrypoint function inspect_pair(Pair next_pair) { - int pair_amount = next_pair.amount; - byte[2] pair_tag = next_pair.code; - - require(pair_amount > 0); - require(pair_tag == next_pair.code); - } -}