From fd8b74a8e61399aa09208a41878fdf8573b5e47d Mon Sep 17 00:00:00 2001 From: Manyfestation <240733973+Manyfestation@users.noreply.github.com> Date: Tue, 10 Mar 2026 13:02:18 +0200 Subject: [PATCH 1/6] feat(debugger): DAP & VS Code debugger extension --- .gitignore | 1 + Cargo.lock | 28 + Cargo.toml | 1 + debugger/dap/Cargo.toml | 27 + debugger/dap/src/adapter.rs | 594 +++++++++ debugger/dap/src/launch_config.rs | 152 +++ debugger/dap/src/main.rs | 65 + debugger/dap/src/refs.rs | 41 + debugger/dap/src/runtime_builder.rs | 588 +++++++++ debugger/dap/tests/harness.rs | 226 ++++ debugger/dap/tests/test_launch.rs | 1124 +++++++++++++++++ debugger/session/src/args.rs | 15 + debugger/session/src/session.rs | 37 +- extensions/vscode/.gitignore | 3 + extensions/vscode/.vscodeignore | 11 +- extensions/vscode/CHANGELOG.md | 2 + extensions/vscode/README.md | 73 +- extensions/vscode/bin/.gitignore | 2 + extensions/vscode/package.json | 157 ++- extensions/vscode/scripts/prepare-adapter.mjs | 88 ++ extensions/vscode/src/contractModel.ts | 201 +++ extensions/vscode/src/debug.ts | 315 +++++ extensions/vscode/src/debugAdapter.ts | 294 +++++ extensions/vscode/src/extension.ts | 31 +- extensions/vscode/src/quickLaunchPanel.ts | 919 ++++++++++++++ 25 files changed, 4977 insertions(+), 18 deletions(-) create mode 100644 debugger/dap/Cargo.toml create mode 100644 debugger/dap/src/adapter.rs create mode 100644 debugger/dap/src/launch_config.rs create mode 100644 debugger/dap/src/main.rs create mode 100644 debugger/dap/src/refs.rs create mode 100644 debugger/dap/src/runtime_builder.rs create mode 100644 debugger/dap/tests/harness.rs create mode 100644 debugger/dap/tests/test_launch.rs create mode 100644 extensions/vscode/bin/.gitignore create mode 100644 extensions/vscode/scripts/prepare-adapter.mjs create mode 100644 extensions/vscode/src/contractModel.ts create mode 100644 extensions/vscode/src/debug.ts create mode 100644 extensions/vscode/src/debugAdapter.ts create mode 100644 extensions/vscode/src/quickLaunchPanel.ts diff --git a/.gitignore b/.gitignore index 71698963..25cffb8a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ target +.vscode .cargo \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 856c8104..9c8e8419 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -922,6 +922,34 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "dap" +version = "0.4.1-alpha1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35c7fc89d334ab745ba679f94c7314c9b17ecdcd923c111df6206e9fd7729fa9" +dependencies = [ + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "debugger-dap" +version = "0.1.0" +dependencies = [ + "blake2b_simd", + "dap", + "debugger-session", + "kaspa-consensus-core", + "kaspa-txscript", + "kaspa-txscript-errors", + "rand 0.8.5", + "secp256k1", + "serde", + "serde_json", + "silverscript-lang", +] + [[package]] name = "debugger-session" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index c6b99261..374104ec 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ members = [ "silverscript-lang", "debugger/session", "debugger/cli", + "debugger/dap", "covenants/sdk", ] exclude = ["tree-sitter"] diff --git a/debugger/dap/Cargo.toml b/debugger/dap/Cargo.toml new file mode 100644 index 00000000..efbd0c2f --- /dev/null +++ b/debugger/dap/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "debugger-dap" +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +rust-version.workspace = true + +[[bin]] +name = "debugger-dap" +path = "src/main.rs" + +[dependencies] +dap = "0.4.1-alpha1" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +debugger-session = { path = "../session" } +silverscript-lang = { path = "../../silverscript-lang" } +kaspa-consensus-core.workspace = true +kaspa-txscript.workspace = true +kaspa-txscript-errors.workspace = true +secp256k1.workspace = true +blake2b_simd.workspace = true +rand.workspace = true + +[dev-dependencies] diff --git a/debugger/dap/src/adapter.rs b/debugger/dap/src/adapter.rs new file mode 100644 index 00000000..7d87944d --- /dev/null +++ b/debugger/dap/src/adapter.rs @@ -0,0 +1,594 @@ +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::path::{Path, PathBuf}; + +use dap::events::{Event, OutputEventBody, StoppedEventBody}; +use dap::prelude::{Command, Request, Response, ResponseBody}; +use dap::responses::{ + ContinueResponse, ScopesResponse, SetBreakpointsResponse, StackTraceResponse, ThreadsResponse, VariablesResponse, +}; +use dap::types::{ + Breakpoint, Capabilities, OutputEventCategory, Scope, ScopePresentationhint, Source, StackFrame, StoppedEventReason, Thread, + Variable, +}; +use debugger_session::format_failure_report; +use debugger_session::session::{DebugSession, VariableOrigin}; + +use crate::launch_config::LaunchConfig; +use crate::refs::{RefAllocator, RefTarget, ScopeKind}; +use crate::runtime_builder::{OwnedRuntime, build_launch}; + +const MAIN_THREAD_ID: i64 = 1; + +pub struct AdapterResult { + pub response: Response, + pub events: Vec, + pub should_exit: bool, +} + +pub struct DapAdapter { + runtime: Option, + refs: RefAllocator, + frame_sequence: i64, + configured: bool, +} + +struct Runtime { + runtime: OwnedRuntime, + source_path: PathBuf, + source_name: String, + stop_on_entry: bool, + no_debug: bool, + breakpoints_by_source: HashMap>, + frame_map: Vec, +} + +#[derive(Clone)] +struct FrameMeta { + frame_id: i64, + sequence: u32, + frame_token: u32, +} + +impl DapAdapter { + pub fn new() -> Self { + Self { runtime: None, refs: RefAllocator::new(), frame_sequence: 1, configured: false } + } + + pub fn handle_request(&mut self, req: Request) -> AdapterResult { + match self.handle_request_inner(req.clone()) { + Ok(result) => result, + Err(err) => { + AdapterResult { response: req.error(&format!("internal adapter error: {err}")), events: vec![], should_exit: false } + } + } + } + + fn handle_request_inner(&mut self, req: Request) -> Result { + match req.command.clone() { + Command::Initialize(_) => { + let capabilities = Capabilities { + supports_configuration_done_request: Some(true), + supports_step_in_targets_request: Some(false), + supports_function_breakpoints: Some(false), + supports_conditional_breakpoints: Some(false), + support_terminate_debuggee: Some(true), + supports_loaded_sources_request: Some(false), + supports_evaluate_for_hovers: Some(false), + ..Default::default() + }; + Ok(AdapterResult { + response: req.success(ResponseBody::Initialize(capabilities)), + events: vec![Event::Initialized], + should_exit: false, + }) + } + Command::Launch(args) => { + let launch = match LaunchConfig::from_launch_args(&args) { + Ok(cfg) => cfg, + Err(err) => { + return Ok(AdapterResult { + response: req.error(&err), + events: vec![self.output_stderr(err)], + should_exit: false, + }); + } + }; + + match build_runtime(launch) { + Ok(runtime) => { + self.runtime = Some(runtime); + self.configured = false; + Ok(AdapterResult { response: req.success(ResponseBody::Launch), events: vec![], should_exit: false }) + } + Err(err) => { + Ok(AdapterResult { response: req.error(&err), events: vec![self.output_stderr(err)], should_exit: false }) + } + } + } + Command::SetBreakpoints(args) => { + let runtime = self.runtime.as_mut().ok_or_else(|| "setBreakpoints before launch".to_string())?; + let requested_source_path = + args.source.path.as_deref().map(PathBuf::from).unwrap_or_else(|| runtime.source_path.clone()); + let source_key = canonical_source_key(&requested_source_path); + + if let Some(existing) = runtime.breakpoints_by_source.remove(&source_key) { + for line in existing { + runtime.runtime.session_mut().clear_breakpoint(line); + } + } + let requested_lines: Vec = if let Some(requested) = args.breakpoints { + Some(requested.into_iter().map(|source_bp| source_bp.line).collect::>()) + } else { + #[allow(deprecated)] + args.lines + } + .unwrap_or_default(); + + if !runtime.stop_on_entry { + runtime.breakpoints_by_source.insert(source_key, HashSet::new()); + let breakpoints = requested_lines + .into_iter() + .map(|line| Breakpoint { verified: false, line: Some(line), ..Default::default() }) + .collect(); + return Ok(AdapterResult { + response: req.success(ResponseBody::SetBreakpoints(SetBreakpointsResponse { breakpoints })), + events: vec![], + should_exit: false, + }); + } + + let runtime_source_key = canonical_source_key(&runtime.source_path); + if source_key != runtime_source_key { + // This adapter session executes one script file. Keep breakpoints + // from other files isolated and report them as unverified. + runtime.breakpoints_by_source.insert(source_key, HashSet::new()); + let breakpoints = requested_lines + .into_iter() + .map(|line| Breakpoint { verified: false, line: Some(line), ..Default::default() }) + .collect(); + return Ok(AdapterResult { + response: req.success(ResponseBody::SetBreakpoints(SetBreakpointsResponse { breakpoints })), + events: vec![], + should_exit: false, + }); + } + + let mut breakpoints = Vec::new(); + let mut resolved_for_source = HashSet::new(); + for line_value in requested_lines { + if line_value <= 0 { + breakpoints.push(Breakpoint { verified: false, line: Some(line_value), ..Default::default() }); + continue; + } + let line = line_value as u32; + let resolved = runtime.runtime.session_mut().add_breakpoint_resolved(line); + let verified = resolved.is_some(); + if let Some(actual_line) = resolved { + resolved_for_source.insert(actual_line); + } + breakpoints.push(Breakpoint { verified, line: Some(resolved.unwrap_or(line) as i64), ..Default::default() }); + } + + runtime.breakpoints_by_source.insert(source_key, resolved_for_source); + + Ok(AdapterResult { + response: req.success(ResponseBody::SetBreakpoints(SetBreakpointsResponse { breakpoints })), + events: vec![], + should_exit: false, + }) + } + Command::SetExceptionBreakpoints(_) => Ok(AdapterResult { + response: req.success(ResponseBody::SetExceptionBreakpoints(Default::default())), + events: vec![], + should_exit: false, + }), + Command::ConfigurationDone => { + let runtime = self.runtime.as_mut().ok_or_else(|| "configurationDone before launch".to_string())?; + self.configured = true; + + runtime + .runtime + .session_mut() + .run_to_first_executed_statement() + .map_err(|err| format!("failed to start session: {err}"))?; + + let events = if runtime.stop_on_entry { + vec![self.make_stopped_event(StoppedEventReason::Entry, None)] + } else { + match runtime.runtime.session_mut().continue_to_breakpoint() { + Ok(Some(_)) => vec![self.make_stopped_event(StoppedEventReason::Breakpoint, None)], + Ok(None) => vec![self.output_stdout("Execution completed successfully."), Event::Terminated(None)], + Err(err) => { + let report = runtime.runtime.session().build_failure_report(&err); + let formatted = format_failure_report(&report, &|type_name, value| { + runtime.runtime.session().format_value(type_name, value) + }); + if runtime.no_debug { + vec![self.output_stderr(formatted), Event::Terminated(None)] + } else { + vec![ + self.output_stderr(formatted.clone()), + self.make_stopped_event(StoppedEventReason::Exception, Some(formatted)), + ] + } + } + } + }; + + Ok(AdapterResult { response: req.success(ResponseBody::ConfigurationDone), events, should_exit: false }) + } + Command::Threads => Ok(AdapterResult { + response: req.success(ResponseBody::Threads(ThreadsResponse { + threads: vec![Thread { id: MAIN_THREAD_ID, name: "main".to_string() }], + })), + events: vec![], + should_exit: false, + }), + Command::StackTrace(_) => { + let (span, current_step, source, current_function_name, call_stack) = { + let runtime = self.runtime.as_ref().ok_or_else(|| "stackTrace before launch".to_string())?; + let span = runtime.runtime.session().current_span(); + let current_step = runtime.runtime.session().current_step(); + let source = Source { + name: Some(runtime.source_name.clone()), + path: Some(runtime.source_path.to_string_lossy().to_string()), + ..Default::default() + }; + let current_function_name = runtime + .runtime + .session() + .current_function_name() + .map(ToOwned::to_owned) + .unwrap_or_else(|| "".to_string()); + let call_stack = runtime.runtime.session().call_stack_with_spans(); + (span, current_step, source, current_function_name, call_stack) + }; + + let mut frames = Vec::new(); + let mut frame_map = Vec::new(); + + let current_line = span.map(|s| s.line as i64).unwrap_or(1); + let current_col = span.map(|s| s.col as i64).unwrap_or(1); + + let frame_id = self.next_frame_id(); + frames.push(StackFrame { + id: frame_id, + name: current_function_name, + source: Some(source.clone()), + line: current_line, + column: current_col, + ..Default::default() + }); + frame_map.push(FrameMeta { + frame_id, + sequence: current_step.as_ref().map(|step| step.sequence).unwrap_or(0), + frame_token: current_step.as_ref().map(|step| step.frame_id).unwrap_or(0), + }); + + for entry in call_stack.into_iter().rev() { + let id = self.next_frame_id(); + let frame_line = entry.call_site_span.map(|s| s.line as i64).unwrap_or(current_line); + let frame_col = entry.call_site_span.map(|s| s.col as i64).unwrap_or(current_col); + frames.push(StackFrame { + id, + name: entry.callee_name, + source: Some(source.clone()), + line: frame_line, + column: frame_col, + ..Default::default() + }); + frame_map.push(FrameMeta { frame_id: id, sequence: entry.sequence, frame_token: entry.frame_id }); + } + + if let Some(runtime_mut) = self.runtime.as_mut() { + runtime_mut.frame_map = frame_map; + } + + Ok(AdapterResult { + response: req.success(ResponseBody::StackTrace(StackTraceResponse { + total_frames: Some(frames.len() as i64), + stack_frames: frames, + })), + events: vec![], + should_exit: false, + }) + } + Command::Scopes(args) => { + let runtime = self.runtime.as_ref().ok_or_else(|| "scopes before launch".to_string())?; + let frame_meta = runtime + .frame_map + .iter() + .find(|frame| frame.frame_id == args.frame_id) + .cloned() + .unwrap_or(FrameMeta { frame_id: args.frame_id, sequence: 0, frame_token: 0 }); + + let variables_ref = self.refs.alloc(scope_target(ScopeKind::Variables, &frame_meta)); + let dstack_ref = self.refs.alloc(scope_target(ScopeKind::DataStack, &frame_meta)); + let astack_ref = self.refs.alloc(scope_target(ScopeKind::AltStack, &frame_meta)); + let scoped_vars = runtime + .runtime + .session() + .list_variables_at_sequence(frame_meta.sequence, frame_meta.frame_token) + .unwrap_or_default(); + let stack_snapshot = runtime.runtime.session().stack_snapshot(); + + let scopes = vec![ + Scope { + name: "Variables".to_string(), + presentation_hint: Some(ScopePresentationhint::Locals), + variables_reference: variables_ref, + named_variables: Some(scoped_vars.len() as i64), + expensive: false, + ..Default::default() + }, + Scope { + name: "Data Stack".to_string(), + presentation_hint: Some(ScopePresentationhint::Registers), + variables_reference: dstack_ref, + indexed_variables: Some(stack_snapshot.dstack.len() as i64), + expensive: false, + ..Default::default() + }, + Scope { + name: "Alt Stack".to_string(), + presentation_hint: Some(ScopePresentationhint::Registers), + variables_reference: astack_ref, + indexed_variables: Some(stack_snapshot.astack.len() as i64), + expensive: false, + ..Default::default() + }, + ]; + + Ok(AdapterResult { + response: req.success(ResponseBody::Scopes(ScopesResponse { scopes })), + events: vec![], + should_exit: false, + }) + } + Command::Variables(args) => { + let runtime = self.runtime.as_ref().ok_or_else(|| "variables before launch".to_string())?; + let target = self + .refs + .get(args.variables_reference) + .cloned() + .ok_or_else(|| format!("unknown variablesReference {}", args.variables_reference))?; + + let variables = match target { + RefTarget::Scope { kind: ScopeKind::Variables, sequence, frame_token } => { + let vars = runtime + .runtime + .session() + .list_variables_at_sequence(sequence, frame_token) + .map_err(|err| format!("variables unavailable: {err}"))?; + let mut bindings = vars; + bindings.sort_by_key(|item| { + let rank = match item.origin { + VariableOrigin::Param | VariableOrigin::Local => 0, + VariableOrigin::Constant => 1, + }; + (rank, item.name.clone()) + }); + bindings + .into_iter() + .map(|item| Variable { + name: binding_name(&item), + value: runtime.runtime.session().format_value(&item.type_name, &item.value), + type_field: Some(item.type_name), + evaluate_name: Some(item.name), + variables_reference: 0, + ..Default::default() + }) + .collect::>() + } + RefTarget::Scope { kind: ScopeKind::DataStack, .. } => { + let snapshot = runtime.runtime.session().stack_snapshot(); + stack_scope_variables("dstack", &snapshot.dstack) + } + RefTarget::Scope { kind: ScopeKind::AltStack, .. } => { + let snapshot = runtime.runtime.session().stack_snapshot(); + stack_scope_variables("astack", &snapshot.astack) + } + }; + + Ok(AdapterResult { + response: req.success(ResponseBody::Variables(VariablesResponse { variables })), + events: vec![], + should_exit: false, + }) + } + Command::Next(_) => self.handle_step(req, StepKind::Next, |session| session.step_over()), + Command::StepIn(_) => self.handle_step(req, StepKind::StepIn, |session| session.step_into()), + Command::StepOut(_) => self.handle_step(req, StepKind::StepOut, |session| session.step_out()), + Command::Continue(_) => { + let runtime = self.runtime.as_mut().ok_or_else(|| "continue before launch".to_string())?; + let no_debug = runtime.no_debug; + let mut events = Vec::new(); + match runtime.runtime.session_mut().continue_to_breakpoint() { + Ok(Some(_)) => events.push(self.make_stopped_event(StoppedEventReason::Breakpoint, None)), + Ok(None) => { + events.push(self.output_stdout("Execution completed successfully.")); + events.push(Event::Terminated(None)); + } + Err(err) => { + let report = runtime.runtime.session().build_failure_report(&err); + let formatted = format_failure_report(&report, &|type_name, value| { + runtime.runtime.session().format_value(type_name, value) + }); + events.push(self.output_stderr(formatted.clone())); + if no_debug { + events.push(Event::Terminated(None)); + } else { + events.push(self.make_stopped_event(StoppedEventReason::Exception, Some(formatted))); + } + } + } + Ok(AdapterResult { + response: req.success(ResponseBody::Continue(ContinueResponse { all_threads_continued: Some(true) })), + events, + should_exit: false, + }) + } + Command::Disconnect(_) => { + self.runtime = None; + Ok(AdapterResult { response: req.success(ResponseBody::Disconnect), events: vec![], should_exit: true }) + } + _ => Ok(AdapterResult { response: req.error("unsupported request"), events: vec![], should_exit: false }), + } + } + + fn handle_step( + &mut self, + req: Request, + step_kind: StepKind, + mut step_fn: impl FnMut( + &mut DebugSession<'static, 'static>, + ) + -> Result>, kaspa_txscript_errors::TxScriptError>, + ) -> Result { + if !self.configured { + return Ok(AdapterResult { + response: req.error("cannot step before configurationDone"), + events: vec![], + should_exit: false, + }); + } + + let runtime = self.runtime.as_mut().ok_or_else(|| "step request before launch".to_string())?; + let mut events = Vec::new(); + let before_location = current_location_key(runtime.runtime.session()); + let mut step_result = step_fn(runtime.runtime.session_mut()); + + let mut guard = 0usize; + while matches!(step_result, Ok(Some(_))) && guard < 32 { + let after_location = current_location_key(runtime.runtime.session()); + if after_location != before_location { + break; + } + step_result = step_fn(runtime.runtime.session_mut()); + guard += 1; + } + + match step_result { + Ok(Some(_)) => events.push(self.make_stopped_event(StoppedEventReason::Step, None)), + Ok(None) => { + events.push(self.output_stdout("Execution completed successfully.")); + events.push(Event::Terminated(None)); + } + Err(err) => { + let report = runtime.runtime.session().build_failure_report(&err); + let formatted = + format_failure_report(&report, &|type_name, value| runtime.runtime.session().format_value(type_name, value)); + events.push(self.output_stderr(formatted.clone())); + events.push(self.make_stopped_event(StoppedEventReason::Exception, Some(formatted))); + } + } + + let body = match step_kind { + StepKind::Next => ResponseBody::Next, + StepKind::StepIn => ResponseBody::StepIn, + StepKind::StepOut => ResponseBody::StepOut, + }; + + Ok(AdapterResult { response: req.success(body), events, should_exit: false }) + } + + fn make_stopped_event(&mut self, reason: StoppedEventReason, text: Option) -> Event { + self.refs.reset(); + Event::Stopped(StoppedEventBody { + reason, + description: None, + thread_id: Some(MAIN_THREAD_ID), + preserve_focus_hint: None, + text, + all_threads_stopped: Some(true), + hit_breakpoint_ids: None, + }) + } + + fn next_frame_id(&mut self) -> i64 { + let id = self.frame_sequence; + self.frame_sequence += 1; + id + } + + fn output_stderr(&self, msg: impl Into) -> Event { + Event::Output(OutputEventBody { + category: Some(OutputEventCategory::Stderr), + output: format!("{}\n", msg.into()), + ..Default::default() + }) + } + + fn output_stdout(&self, msg: impl Into) -> Event { + Event::Output(OutputEventBody { + category: Some(OutputEventCategory::Stdout), + output: format!("{}\n", msg.into()), + ..Default::default() + }) + } +} + +enum StepKind { + Next, + StepIn, + StepOut, +} + +fn current_location_key(session: &DebugSession<'static, 'static>) -> Option { + session.current_span().map(|span| span.line) +} + +fn canonical_source_key(path: &Path) -> String { + fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()).to_string_lossy().to_string() +} + +fn scope_target(kind: ScopeKind, frame_meta: &FrameMeta) -> RefTarget { + RefTarget::Scope { kind, sequence: frame_meta.sequence, frame_token: frame_meta.frame_token } +} + +fn binding_name(variable: &debugger_session::session::Variable) -> String { + match variable.origin { + VariableOrigin::Param | VariableOrigin::Local => variable.name.clone(), + VariableOrigin::Constant => format!("{} (const)", variable.name), + } +} + +fn stack_scope_variables(scope_name: &str, items: &[String]) -> Vec { + if items.is_empty() { + return vec![Variable { + name: "(empty)".to_string(), + value: "".to_string(), + variables_reference: 0, + ..Default::default() + }]; + } + + items + .iter() + .enumerate() + .map(|(index, item)| Variable { + name: format!("{scope_name}[{index}]"), + value: stack_item_value(item), + variables_reference: 0, + ..Default::default() + }) + .collect() +} + +fn stack_item_value(item: &str) -> String { + if item.is_empty() { " (script 0 / false)".to_string() } else { format!("0x{item}") } +} + +fn build_runtime(config: LaunchConfig) -> Result { + let built = build_launch(config.resolve(None)?)?; + Ok(Runtime { + runtime: built.runtime, + source_path: built.source_path, + source_name: built.source_name, + stop_on_entry: built.stop_on_entry, + no_debug: built.no_debug, + breakpoints_by_source: HashMap::new(), + frame_map: Vec::new(), + }) +} diff --git a/debugger/dap/src/launch_config.rs b/debugger/dap/src/launch_config.rs new file mode 100644 index 00000000..7a2e28d2 --- /dev/null +++ b/debugger/dap/src/launch_config.rs @@ -0,0 +1,152 @@ +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use dap::requests::LaunchRequestArguments; +use debugger_session::args::values_to_args; +use debugger_session::test_runner::{TestTxScenario, TestTxScenarioResolved, resolve_tx_scenario}; +use serde::Deserialize; +use serde_json::Value; + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LaunchConfig { + pub script_path: Option, + pub params_file: Option, + pub function: Option, + pub constructor_args: Option, + pub args: Option, + pub tx: Option, + pub no_debug: Option, + pub stop_on_entry: Option, +} + +#[derive(Debug, Clone)] +pub struct ResolvedLaunchConfig { + pub script_path: PathBuf, + pub function: Option, + pub constructor_args: Option, + pub args: Option, + pub tx: Option, + pub no_debug: bool, + pub stop_on_entry: bool, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +pub enum ArgInput { + Values(Vec), + Named(BTreeMap), +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ParamsFileConfig { + pub function: Option, + #[serde(default, alias = "constructor_args")] + pub constructor_args: Option, + #[serde(default)] + pub args: Option, + #[serde(default)] + pub tx: Option, +} + +impl LaunchConfig { + pub fn from_launch_args(args: &LaunchRequestArguments) -> Result { + let value = args.additional_data.clone().unwrap_or(Value::Null); + let config: Self = serde_json::from_value(value).map_err(|err| format!("invalid launch config: {err}"))?; + + if config.script_path.is_none() { + return Err("launch config must include 'scriptPath'".to_string()); + } + + Ok(config) + } + + pub fn resolve(self, workspace_root: Option<&Path>) -> Result { + let script_path = self.resolve_script_path(workspace_root)?; + let params_file = self.resolve_params_file(workspace_root, &script_path)?; + + let function = self.function.or_else(|| params_file.function); + let constructor_args = self.constructor_args.or(params_file.constructor_args); + let args = self.args.or(params_file.args); + let tx = self.tx.or(params_file.tx).map(resolve_tx_scenario).transpose()?; + + Ok(ResolvedLaunchConfig { + script_path, + function, + constructor_args, + args, + tx, + no_debug: self.no_debug.unwrap_or(false), + stop_on_entry: self.stop_on_entry.unwrap_or(!self.no_debug.unwrap_or(false)), + }) + } + + fn resolve_script_path(&self, workspace_root: Option<&Path>) -> Result { + let raw = self.script_path.as_deref().ok_or_else(|| "scriptPath is required".to_string())?; + canonicalize_with_workspace(raw, workspace_root) + } + + fn resolve_params_file(&self, workspace_root: Option<&Path>, script_path: &Path) -> Result { + if let Some(raw) = self.params_file.as_deref() { + let path = canonicalize_with_workspace(raw, workspace_root)?; + return read_params_file(&path); + } + + let inferred = infer_params_file_path(script_path)?; + if inferred.exists() { + return read_params_file(&inferred); + } + + Ok(ParamsFileConfig::default()) + } +} + +fn read_params_file(path: &Path) -> Result { + let raw = std::fs::read_to_string(path).map_err(|err| format!("failed to read params file '{}': {err}", path.display()))?; + serde_json::from_str::(&raw).map_err(|err| format!("invalid params file '{}': {err}", path.display())) +} + +fn infer_params_file_path(script_path: &Path) -> Result { + let stem = script_path + .file_stem() + .and_then(|stem| stem.to_str()) + .ok_or_else(|| format!("failed to derive stem from '{}'", script_path.display()))?; + Ok(script_path.with_file_name(format!("{stem}.debug.json"))) +} + +fn canonicalize_with_workspace(raw: &str, workspace_root: Option<&Path>) -> Result { + let candidate = PathBuf::from(raw); + let resolved = if candidate.is_absolute() { + candidate + } else if let Some(root) = workspace_root { + root.join(candidate) + } else { + std::env::current_dir().map_err(|err| format!("failed to resolve current_dir: {err}"))?.join(candidate) + }; + + std::fs::canonicalize(&resolved).map_err(|err| format!("failed to canonicalize '{}': {err}", resolved.display())) +} + +pub fn resolve_arg_input(input: Option<&ArgInput>, param_names: &[String], label: &str) -> Result, String> { + match input { + None => Ok(Vec::new()), + Some(ArgInput::Values(values)) => values_to_args(values), + Some(ArgInput::Named(named)) => { + let mut remaining = named.clone(); + let mut ordered = Vec::with_capacity(param_names.len()); + + for name in param_names { + let value = remaining.remove(name).ok_or_else(|| format!("{label} missing value for '{name}'"))?; + ordered.push(value); + } + + if !remaining.is_empty() { + let extras = remaining.keys().cloned().collect::>().join(", "); + return Err(format!("{label} has unknown name(s): {extras}")); + } + + values_to_args(&ordered) + } + } +} diff --git a/debugger/dap/src/main.rs b/debugger/dap/src/main.rs new file mode 100644 index 00000000..1a72bb6e --- /dev/null +++ b/debugger/dap/src/main.rs @@ -0,0 +1,65 @@ +use std::io::{BufReader, BufWriter}; + +use dap::prelude::Server; +use secp256k1::{Keypair, Secp256k1, rand::thread_rng}; + +mod adapter; +mod launch_config; +mod refs; +mod runtime_builder; + +use adapter::DapAdapter; + +fn main() -> Result<(), Box> { + if std::env::args().any(|a| a == "--keygen") { + return keygen(); + } + + let input = BufReader::new(std::io::stdin()); + let output = BufWriter::new(std::io::stdout()); + let mut server = Server::new(input, output); + let mut adapter = DapAdapter::new(); + + loop { + let req = match server.poll_request() { + Ok(Some(req)) => req, + Ok(None) => break, + Err(err) => return Err(Box::new(err)), + }; + + let result = adapter.handle_request(req); + if let Err(err) = server.respond(result.response) { + return Err(Box::new(err)); + } + + for event in result.events { + if let Err(err) = server.send_event(event) { + return Err(Box::new(err)); + } + } + + if result.should_exit { + break; + } + } + Ok(()) +} + +fn keygen() -> Result<(), Box> { + let secp = Secp256k1::new(); + let kp = Keypair::new(&secp, &mut thread_rng()); + let (xonly, _parity) = kp.x_only_public_key(); + let secret_bytes = kp.secret_key().secret_bytes(); + let pubkey_bytes = xonly.serialize(); + let pkh = blake2b_simd::Params::new().hash_length(32).hash(&pubkey_bytes); + + let hex = |bytes: &[u8]| -> String { format!("0x{}", bytes.iter().map(|b| format!("{b:02x}")).collect::()) }; + let payload = serde_json::json!({ + "pubkey": hex(&pubkey_bytes), + "secret_key": hex(&secret_bytes), + "pkh": hex(pkh.as_bytes()), + }); + + println!("{}", serde_json::to_string(&payload)?); + Ok(()) +} diff --git a/debugger/dap/src/refs.rs b/debugger/dap/src/refs.rs new file mode 100644 index 00000000..a0f14dc9 --- /dev/null +++ b/debugger/dap/src/refs.rs @@ -0,0 +1,41 @@ +use std::collections::HashMap; + +#[derive(Debug, Clone)] +pub enum ScopeKind { + Variables, + DataStack, + AltStack, +} + +#[derive(Debug, Clone)] +pub enum RefTarget { + Scope { kind: ScopeKind, sequence: u32, frame_token: u32 }, +} + +#[derive(Debug, Default)] +pub struct RefAllocator { + next_id: i64, + refs: HashMap, +} + +impl RefAllocator { + pub fn new() -> Self { + Self { next_id: 1, refs: HashMap::new() } + } + + pub fn reset(&mut self) { + self.next_id = 1; + self.refs.clear(); + } + + pub fn alloc(&mut self, target: RefTarget) -> i64 { + let id = self.next_id; + self.next_id += 1; + self.refs.insert(id, target); + id + } + + pub fn get(&self, id: i64) -> Option<&RefTarget> { + self.refs.get(&id) + } +} diff --git a/debugger/dap/src/runtime_builder.rs b/debugger/dap/src/runtime_builder.rs new file mode 100644 index 00000000..52765a49 --- /dev/null +++ b/debugger/dap/src/runtime_builder.rs @@ -0,0 +1,588 @@ +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; +use std::ptr::NonNull; + +use debugger_session::args::{parse_call_args, parse_ctor_args, parse_hex_bytes}; +use debugger_session::session::{DebugEngine, DebugSession, ShadowTxContext}; +use debugger_session::test_runner::{TestTxInputScenarioResolved, TestTxOutputScenarioResolved, TestTxScenarioResolved}; +use kaspa_consensus_core::Hash; +use kaspa_consensus_core::hashing::sighash::{SigHashReusedValuesUnsync, calc_schnorr_signature_hash}; +use kaspa_consensus_core::hashing::sighash_type::SIG_HASH_ALL; +use kaspa_consensus_core::tx::{ + CovenantBinding, PopulatedTransaction, ScriptPublicKey, Transaction, TransactionId, TransactionInput, TransactionOutpoint, + TransactionOutput, UtxoEntry, VerifiableTransaction, +}; +use kaspa_txscript::caches::Cache; +use kaspa_txscript::covenants::CovenantsContext; +use kaspa_txscript::script_builder::ScriptBuilder; +use kaspa_txscript::{EngineCtx, EngineFlags, SigCacheKey, pay_to_script_hash_script}; +use secp256k1::{Keypair, Message, Secp256k1, SecretKey}; +use silverscript_lang::ast::{ContractAst, parse_contract_ast}; +use silverscript_lang::compiler::{CompileOptions, compile_contract}; + +use crate::launch_config::{ResolvedLaunchConfig, resolve_arg_input}; + +pub struct BuiltLaunch { + pub runtime: OwnedRuntime, + pub source_path: PathBuf, + pub source_name: String, + pub stop_on_entry: bool, + pub no_debug: bool, +} + +pub fn build_launch(config: ResolvedLaunchConfig) -> Result { + let source_owned = fs::read_to_string(&config.script_path) + .map_err(|err| format!("failed to read source '{}': {err}", config.script_path.display()))?; + let source_box = source_owned.into_boxed_str(); + let source_ptr = Box::into_raw(source_box); + let source: &'static str = unsafe { &*source_ptr }; + + let parsed_contract = parse_contract_ast(source).map_err(|err| format!("parse error: {err}"))?; + let ctor_param_names = parsed_contract.params.iter().map(|param| param.name.clone()).collect::>(); + let raw_ctor_args = resolve_arg_input(config.constructor_args.as_ref(), &ctor_param_names, "constructor arguments")?; + let ctor_args = parse_ctor_args(&parsed_contract, &raw_ctor_args)?; + + let compile_opts = CompileOptions { record_debug_infos: true, ..Default::default() }; + let compiled = compile_contract(source, &ctor_args, compile_opts).map_err(|err| format!("compile error: {err}"))?; + let selected_name = resolve_entrypoint_name(&compiled.abi, config.function)?; + let entry = compiled + .abi + .iter() + .find(|entry| entry.name == selected_name) + .ok_or_else(|| format!("function '{}' not found", selected_name))?; + + let input_types = entry.inputs.iter().map(|input| input.type_name.clone()).collect::>(); + let input_names = entry.inputs.iter().map(|input| input.name.clone()).collect::>(); + let raw_args = resolve_arg_input(config.args.as_ref(), &input_names, "function arguments")?; + let tx = config.tx.unwrap_or_else(default_tx_scenario); + + let mut ctor_script_cache = HashMap::, Vec>::new(); + ctor_script_cache.insert(raw_ctor_args.clone(), compiled.script.clone()); + + let resolved_raw_args = resolve_auto_sign_args( + &input_types, + &input_names, + &raw_args, + source, + &parsed_contract, + &raw_ctor_args, + &tx, + &mut ctor_script_cache, + )?; + let typed_args = parse_call_args(&input_types, &resolved_raw_args)?; + let sigscript = + compiled.build_sig_script(&selected_name, typed_args).map_err(|err| format!("failed to build sigscript: {err}"))?; + + let tx_context = build_tx_context(source, &parsed_contract, &raw_ctor_args, &tx, Some(&sigscript), &mut ctor_script_cache)?; + let BuiltTxContext { + transaction, + populated_tx, + populated_tx_ptr, + covenants_ctx, + covenants_ctx_ptr, + active_input, + active_utxo, + reused_values, + reused_values_ptr, + } = tx_context; + + let cache_ptr = Box::into_raw(Box::new(Cache::new(10_000))); + let cache = unsafe { &*cache_ptr }; + let flags = EngineFlags { covenants_enabled: true }; + let ctx = EngineCtx::new(cache).with_reused(reused_values).with_covenants_ctx(covenants_ctx); + let engine = DebugEngine::from_transaction_input(populated_tx, active_input, tx.active_input_index, active_utxo, ctx, flags); + let shadow_tx_context = ShadowTxContext { + tx: populated_tx, + input: active_input, + input_index: tx.active_input_index, + utxo_entry: active_utxo, + covenants_ctx, + }; + let session = DebugSession::full(&sigscript, &compiled.script, source, compiled.debug_info.clone(), engine) + .map_err(|err| format!("failed to create debug session: {err}"))? + .with_shadow_tx_context(shadow_tx_context); + let runtime = OwnedRuntime { + session, + _backing: RuntimeBacking { + source: Some(unsafe { NonNull::new_unchecked(source_ptr) }), + cache: Some(unsafe { NonNull::new_unchecked(cache_ptr) }), + transaction, + populated_tx: populated_tx_ptr, + covenants_ctx: covenants_ctx_ptr, + reused_values: reused_values_ptr, + }, + }; + + let source_name = config + .script_path + .file_name() + .and_then(|name| name.to_str()) + .map(ToOwned::to_owned) + .unwrap_or_else(|| config.script_path.to_string_lossy().to_string()); + + Ok(BuiltLaunch { + runtime, + source_path: config.script_path, + source_name, + stop_on_entry: config.stop_on_entry, + no_debug: config.no_debug, + }) +} + +fn resolve_entrypoint_name( + abi: &[silverscript_lang::compiler::FunctionAbiEntry], + requested: Option, +) -> Result { + if let Some(function) = requested { + return Ok(function); + } + + match abi { + [] => Err("contract has no functions".to_string()), + [entry] => Ok(entry.name.clone()), + entries => { + let names = entries.iter().map(|entry| entry.name.as_str()).collect::>().join(", "); + Err(format!("launch config must include 'function' for multi-entrypoint contract (available: {names})")) + } + } +} + +fn default_tx_scenario() -> TestTxScenarioResolved { + TestTxScenarioResolved { + version: 1, + lock_time: 0, + active_input_index: 0, + inputs: vec![TestTxInputScenarioResolved { + prev_txid: None, + prev_index: 0, + sequence: 0, + sig_op_count: 100, + utxo_value: 5000, + covenant_id: None, + constructor_args: None, + signature_script_hex: None, + utxo_script_hex: None, + }], + outputs: vec![TestTxOutputScenarioResolved { + value: 5000, + covenant_id: None, + authorizing_input: None, + constructor_args: None, + script_hex: None, + p2pk_pubkey: None, + }], + } +} + +fn resolve_auto_sign_args( + input_types: &[String], + input_names: &[String], + raw_args: &[String], + source: &str, + parsed_contract: &ContractAst<'_>, + raw_ctor_args: &[String], + tx: &TestTxScenarioResolved, + ctor_script_cache: &mut HashMap, Vec>, +) -> Result, String> { + let mut resolved = raw_args.to_vec(); + let mut has_secret_sig = false; + + for (index, (type_name, raw)) in input_types.iter().zip(raw_args.iter()).enumerate() { + if type_name != "sig" && type_name != "datasig" { + continue; + } + let bytes = parse_hex_bytes(raw)?; + if type_name == "sig" && bytes.len() == 32 { + has_secret_sig = true; + continue; + } + if type_name == "datasig" && bytes.len() == 32 { + return Err(format!( + "function argument '{}' uses a 32-byte secret key for datasig, but debugger launch only auto-signs 'sig' arguments", + input_names[index] + )); + } + } + + if !has_secret_sig { + return Ok(resolved); + } + + let signing_tx = build_tx_context(source, parsed_contract, raw_ctor_args, tx, None, ctor_script_cache)?; + + for (index, type_name) in input_types.iter().enumerate() { + if type_name != "sig" { + continue; + } + let secret_bytes = parse_hex_bytes(&resolved[index])?; + if secret_bytes.len() != 32 { + continue; + } + resolved[index] = sign_tx_input(&secret_bytes, signing_tx.populated_tx, tx.active_input_index, signing_tx.reused_values) + .map_err(|err| format!("failed to auto-sign argument '{}': {err}", input_names[index]))?; + } + + Ok(resolved) +} + +struct BuiltTxContext { + transaction: NonNull, + populated_tx: &'static PopulatedTransaction<'static>, + populated_tx_ptr: NonNull>, + covenants_ctx: &'static CovenantsContext, + covenants_ctx_ptr: NonNull, + active_input: &'static TransactionInput, + active_utxo: &'static UtxoEntry, + reused_values: &'static SigHashReusedValuesUnsync, + reused_values_ptr: NonNull, +} + +pub struct OwnedRuntime { + pub session: DebugSession<'static, 'static>, + _backing: RuntimeBacking, +} + +struct RuntimeBacking { + source: Option>, + cache: Option>>, + transaction: NonNull, + populated_tx: NonNull>, + covenants_ctx: NonNull, + reused_values: NonNull, +} + +impl OwnedRuntime { + pub fn session(&self) -> &DebugSession<'static, 'static> { + &self.session + } + + pub fn session_mut(&mut self) -> &mut DebugSession<'static, 'static> { + &mut self.session + } +} + +impl Drop for RuntimeBacking { + fn drop(&mut self) { + unsafe { + drop(Box::from_raw(self.covenants_ctx.as_ptr())); + drop(Box::from_raw(self.populated_tx.as_ptr())); + drop(Box::from_raw(self.transaction.as_ptr())); + drop(Box::from_raw(self.reused_values.as_ptr())); + if let Some(cache) = self.cache.take() { + drop(Box::from_raw(cache.as_ptr())); + } + if let Some(source) = self.source.take() { + drop(Box::from_raw(source.as_ptr())); + } + } + } +} + +fn build_tx_context( + source: &str, + parsed_contract: &ContractAst<'_>, + raw_ctor_args: &[String], + tx: &TestTxScenarioResolved, + active_sigscript: Option<&[u8]>, + ctor_script_cache: &mut HashMap, Vec>, +) -> Result { + if tx.inputs.is_empty() { + return Err("tx.inputs must contain at least one input".to_string()); + } + if tx.active_input_index >= tx.inputs.len() { + return Err(format!("tx.active_input_index {} out of range for {} inputs", tx.active_input_index, tx.inputs.len())); + } + + let mut tx_inputs = Vec::with_capacity(tx.inputs.len()); + let mut utxo_specs = Vec::with_capacity(tx.inputs.len()); + + for (input_idx, input) in tx.inputs.iter().enumerate() { + let mut default_prev_txid = [0u8; 32]; + default_prev_txid.fill(input_idx as u8); + let prev_txid = if let Some(raw_txid) = input.prev_txid.as_deref() { + parse_txid32(raw_txid)? + } else { + TransactionId::from_bytes(default_prev_txid) + }; + + let input_ctor_raw = input.constructor_args.clone().unwrap_or_else(|| raw_ctor_args.to_vec()); + let redeem_script = if input.utxo_script_hex.is_none() { + Some(compile_script_for_ctor_args(source, parsed_contract, &input_ctor_raw, ctor_script_cache)?) + } else { + None + }; + + let signature_script = if let Some(raw_sig) = input.signature_script_hex.as_deref() { + parse_hex_bytes(raw_sig)? + } else if input_idx == tx.active_input_index { + match (active_sigscript, redeem_script.as_ref()) { + (Some(action), Some(redeem)) => combine_action_and_redeem(action, redeem)?, + (Some(action), None) => action.to_vec(), + (None, _) => vec![], + } + } else if let Some(redeem) = redeem_script.as_ref() { + sigscript_push_script(redeem) + } else { + vec![] + }; + + let utxo_spk = if let Some(raw_script) = input.utxo_script_hex.as_deref() { + ScriptPublicKey::new(0, parse_hex_bytes(raw_script)?.into()) + } else { + let redeem = redeem_script + .as_ref() + .ok_or_else(|| "internal error: missing redeem script for tx input without utxo_script_hex".to_string())?; + pay_to_script_hash_script(redeem) + }; + + let covenant_id = input.covenant_id.as_deref().map(parse_hash32).transpose()?; + + tx_inputs.push(TransactionInput { + previous_outpoint: TransactionOutpoint { transaction_id: prev_txid, index: input.prev_index }, + signature_script, + sequence: input.sequence, + sig_op_count: input.sig_op_count, + }); + utxo_specs.push((input.utxo_value, utxo_spk, covenant_id)); + } + + let mut tx_outputs = Vec::with_capacity(tx.outputs.len()); + for output in tx.outputs.iter() { + tx_outputs.push(build_output(source, parsed_contract, output, raw_ctor_args, tx.active_input_index, ctor_script_cache)?); + } + + let transaction = + Box::into_raw(Box::new(Transaction::new(tx.version, tx_inputs, tx_outputs, tx.lock_time, Default::default(), 0, vec![]))); + let transaction_ref = unsafe { &*transaction }; + let reused_values = Box::into_raw(Box::new(SigHashReusedValuesUnsync::new())); + let reused_values_ref = unsafe { &*reused_values }; + let utxos = utxo_specs + .into_iter() + .map(|(value, spk, covenant_id)| UtxoEntry::new(value, spk, 0, transaction_ref.is_coinbase(), covenant_id)) + .collect::>(); + let populated_tx = Box::into_raw(Box::new(PopulatedTransaction::new(transaction_ref, utxos))); + let populated_tx_ref = unsafe { &*populated_tx }; + let covenants_ctx = Box::into_raw(Box::new( + CovenantsContext::from_tx(populated_tx_ref).map_err(|err| format!("failed to build covenant context: {err}"))?, + )); + let covenants_ctx_ref = unsafe { &*covenants_ctx }; + let active_input = transaction_ref + .inputs + .get(tx.active_input_index) + .ok_or_else(|| format!("missing tx input at index {}", tx.active_input_index))?; + let active_utxo = populated_tx_ref + .utxo(tx.active_input_index) + .ok_or_else(|| format!("missing utxo entry for input {}", tx.active_input_index))?; + + Ok(BuiltTxContext { + transaction: unsafe { NonNull::new_unchecked(transaction) }, + populated_tx: populated_tx_ref, + populated_tx_ptr: unsafe { NonNull::new_unchecked(populated_tx) }, + covenants_ctx: covenants_ctx_ref, + covenants_ctx_ptr: unsafe { NonNull::new_unchecked(covenants_ctx) }, + active_input, + active_utxo, + reused_values: reused_values_ref, + reused_values_ptr: unsafe { NonNull::new_unchecked(reused_values) }, + }) +} + +fn build_output( + source: &str, + parsed_contract: &ContractAst<'_>, + output: &TestTxOutputScenarioResolved, + raw_ctor_args: &[String], + active_input_index: usize, + ctor_script_cache: &mut HashMap, Vec>, +) -> Result { + let script_public_key = if let Some(raw_script) = output.script_hex.as_deref() { + ScriptPublicKey::new(0, parse_hex_bytes(raw_script)?.into()) + } else if let Some(raw_pubkey) = output.p2pk_pubkey.as_deref() { + let pubkey_bytes = parse_hex_bytes(raw_pubkey)?; + ScriptPublicKey::new(0, build_p2pk_script(&pubkey_bytes).into()) + } else { + let output_ctor_raw = output.constructor_args.clone().unwrap_or_else(|| raw_ctor_args.to_vec()); + let output_script = compile_script_for_ctor_args(source, parsed_contract, &output_ctor_raw, ctor_script_cache)?; + pay_to_script_hash_script(&output_script) + }; + + let covenant = output + .covenant_id + .as_deref() + .map(|raw| -> Result { + Ok(CovenantBinding { + authorizing_input: output.authorizing_input.unwrap_or(active_input_index as u16), + covenant_id: parse_hash32(raw)?, + }) + }) + .transpose()?; + + Ok(TransactionOutput { value: output.value, script_public_key, covenant }) +} + +fn compile_script_for_ctor_args( + source: &str, + parsed_contract: &ContractAst<'_>, + raw_ctor_args: &[String], + cache: &mut HashMap, Vec>, +) -> Result, String> { + if let Some(script) = cache.get(raw_ctor_args) { + return Ok(script.clone()); + } + let ctor_args = parse_ctor_args(parsed_contract, raw_ctor_args)?; + let compiled = compile_contract(source, &ctor_args, CompileOptions::default()).map_err(|err| format!("compile error: {err}"))?; + cache.insert(raw_ctor_args.to_vec(), compiled.script.clone()); + Ok(compiled.script) +} + +fn parse_hash32(raw: &str) -> Result { + let bytes = parse_hex_bytes(raw)?; + if bytes.len() != 32 { + return Err(format!("hash expects 32 bytes, got {}", bytes.len())); + } + let mut array = [0u8; 32]; + array.copy_from_slice(&bytes); + Ok(Hash::from_bytes(array)) +} + +fn parse_txid32(raw: &str) -> Result { + let bytes = parse_hex_bytes(raw)?; + if bytes.len() != 32 { + return Err(format!("txid expects 32 bytes, got {}", bytes.len())); + } + let mut array = [0u8; 32]; + array.copy_from_slice(&bytes); + Ok(TransactionId::from_bytes(array)) +} + +fn build_p2pk_script(pubkey: &[u8]) -> Vec { + ScriptBuilder::new() + .add_data(pubkey) + .expect("push pubkey") + .add_op(kaspa_txscript::opcodes::codes::OpCheckSig) + .expect("add OpCheckSig") + .drain() +} + +fn sigscript_push_script(script: &[u8]) -> Vec { + ScriptBuilder::new().add_data(script).expect("push script data").drain() +} + +fn combine_action_and_redeem(action: &[u8], redeem_script: &[u8]) -> Result, String> { + let mut builder = ScriptBuilder::new(); + builder.add_ops(action).map_err(|err| err.to_string())?; + builder.add_data(redeem_script).map_err(|err| err.to_string())?; + Ok(builder.drain()) +} + +fn sign_tx_input( + secret_key_bytes: &[u8], + tx: &PopulatedTransaction<'_>, + input_index: usize, + reused_values: &SigHashReusedValuesUnsync, +) -> Result { + let secret_key = SecretKey::from_slice(secret_key_bytes).map_err(|err| format!("invalid secret key: {err}"))?; + let secp = Secp256k1::new(); + let keypair = Keypair::from_secret_key(&secp, &secret_key); + let sig_hash = calc_schnorr_signature_hash(tx, input_index, SIG_HASH_ALL, reused_values); + let msg = Message::from_digest_slice(sig_hash.as_bytes().as_slice()).map_err(|err| format!("invalid sighash digest: {err}"))?; + let sig = keypair.sign_schnorr(msg); + let mut signature = Vec::with_capacity(65); + signature.extend_from_slice(sig.as_ref().as_slice()); + signature.push(SIG_HASH_ALL.to_u8()); + Ok(format!("0x{}", encode_hex(&signature))) +} + +fn encode_hex(bytes: &[u8]) -> String { + let mut out = String::with_capacity(bytes.len() * 2); + for byte in bytes { + out.push(char::from_digit((byte >> 4) as u32, 16).unwrap()); + out.push(char::from_digit((byte & 0x0f) as u32, 16).unwrap()); + } + out +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::path::PathBuf; + use std::time::{SystemTime, UNIX_EPOCH}; + + use crate::launch_config::ResolvedLaunchConfig; + use debugger_session::test_runner::{TestTxInputScenarioResolved, TestTxOutputScenarioResolved, TestTxScenarioResolved}; + + use super::build_launch; + + const SIMPLE_SCRIPT: &str = r#"pragma silverscript ^0.1.0; + +contract Simple() { + entrypoint function main() { + int a = 1; + require(a == 1); + } +} +"#; + + struct TempScript { + path: PathBuf, + } + + impl TempScript { + fn new(source: &str) -> Self { + let unique = SystemTime::now().duration_since(UNIX_EPOCH).map(|duration| duration.as_nanos()).unwrap_or_default(); + let path = std::env::temp_dir().join(format!("silverscript-runtime-builder-{unique}.sil")); + fs::write(&path, source).expect("failed to write temp script"); + Self { path } + } + } + + impl Drop for TempScript { + fn drop(&mut self) { + let _ = fs::remove_file(&self.path); + } + } + + #[test] + fn build_launch_rejects_invalid_tx_override() { + let script = TempScript::new(SIMPLE_SCRIPT); + let config = ResolvedLaunchConfig { + script_path: script.path.clone(), + function: Some("main".to_string()), + constructor_args: None, + args: None, + tx: Some(TestTxScenarioResolved { + version: 1, + lock_time: 0, + active_input_index: 1, + inputs: vec![TestTxInputScenarioResolved { + prev_txid: None, + prev_index: 0, + sequence: 0, + sig_op_count: 100, + utxo_value: 5000, + covenant_id: None, + constructor_args: None, + signature_script_hex: None, + utxo_script_hex: None, + }], + outputs: vec![TestTxOutputScenarioResolved { + value: 5000, + covenant_id: None, + authorizing_input: None, + constructor_args: None, + script_hex: None, + p2pk_pubkey: None, + }], + }), + no_debug: false, + stop_on_entry: true, + }; + + let err = match build_launch(config) { + Ok(_) => panic!("invalid tx override should fail"), + Err(err) => err, + }; + assert!(err.contains("active_input_index 1 out of range"), "unexpected error: {err}"); + } +} diff --git a/debugger/dap/tests/harness.rs b/debugger/dap/tests/harness.rs new file mode 100644 index 00000000..d359e6e8 --- /dev/null +++ b/debugger/dap/tests/harness.rs @@ -0,0 +1,226 @@ +use std::io::{BufRead, BufReader, Read, Write}; +use std::path::PathBuf; +use std::process::{Child, ChildStdin, Command, Stdio}; +use std::sync::{Arc, Mutex, mpsc}; +use std::thread; +use std::time::Duration; + +use serde_json::{Value, json}; + +const MESSAGE_TIMEOUT: Duration = Duration::from_secs(10); + +pub struct TestClient { + child: Child, + stdin: ChildStdin, + messages: mpsc::Receiver, + stderr_log: Arc>, + seq: i64, +} + +impl TestClient { + pub fn spawn() -> Self { + let binary = resolve_debugger_dap_binary(); + let mut child = Command::new(&binary) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap_or_else(|err| panic!("failed to spawn debugger-dap binary at {:?}: {err}", binary)); + + let stdin = child.stdin.take().expect("missing child stdin"); + let stdout = child.stdout.take().expect("missing child stdout"); + let stderr = child.stderr.take().expect("missing child stderr"); + let stderr_log = Arc::new(Mutex::new(String::new())); + let stderr_sink = Arc::clone(&stderr_log); + let (tx, rx) = mpsc::channel(); + + thread::spawn(move || read_stdout_messages(stdout, tx)); + thread::spawn(move || capture_stderr(stderr, stderr_sink)); + + Self { child, stdin, messages: rx, stderr_log, seq: 1 } + } + + pub fn send_request(&mut self, command: &str, arguments: Value) { + let message = json!({ + "seq": self.seq, + "type": "request", + "command": command, + "arguments": arguments, + }); + self.seq += 1; + self.write_message(&message); + } + + pub fn read_message(&mut self) -> Value { + match self.messages.recv_timeout(MESSAGE_TIMEOUT) { + Ok(message) => message, + Err(mpsc::RecvTimeoutError::Timeout) => { + let stderr = self.stderr_snapshot(); + panic!("timed out waiting for DAP message after {:?}; stderr: {}", MESSAGE_TIMEOUT, stderr); + } + Err(mpsc::RecvTimeoutError::Disconnected) => { + let stderr = self.stderr_snapshot(); + panic!("adapter closed message channel unexpectedly; stderr: {}", stderr); + } + } + } + + pub fn expect_response_success(&mut self, command: &str) -> Value { + loop { + let msg = self.read_message(); + if msg.get("type") == Some(&Value::String("response".to_string())) { + let actual = msg.get("command").and_then(|v| v.as_str()).unwrap_or_default(); + if actual == command { + let success = msg.get("success").and_then(|v| v.as_bool()).unwrap_or(false); + assert!(success, "expected successful response for {command}, got {msg:#}"); + return msg; + } + } + } + } + + pub fn expect_event(&mut self, event: &str) -> Value { + loop { + let msg = self.read_message(); + if msg.get("type") == Some(&Value::String("event".to_string())) { + let actual = msg.get("event").and_then(|v| v.as_str()).unwrap_or_default(); + if actual == event { + return msg; + } + } + } + } + + pub fn full_launch_sequence(&mut self, script_path: &str) -> Value { + self.send_request( + "initialize", + json!({ + "adapterID": "silverscript", + "pathFormat": "path", + "linesStartAt1": true, + "columnsStartAt1": true, + "supportsVariableType": true, + "supportsVariablePaging": false, + "supportsRunInTerminalRequest": false + }), + ); + self.expect_response_success("initialize"); + self.expect_event("initialized"); + + self.send_request( + "launch", + json!({ + "scriptPath": script_path, + "stopOnEntry": true + }), + ); + self.expect_response_success("launch"); + + self.send_request("setBreakpoints", json!({"source": {"path": script_path}, "breakpoints": []})); + self.expect_response_success("setBreakpoints"); + + self.send_request("setExceptionBreakpoints", json!({"filters": []})); + self.expect_response_success("setExceptionBreakpoints"); + + self.send_request("configurationDone", Value::Null); + self.expect_response_success("configurationDone"); + self.expect_event("stopped") + } + + fn write_message(&mut self, payload: &Value) { + let encoded = serde_json::to_vec(payload).expect("failed to serialize request"); + let header = format!("Content-Length: {}\r\n\r\n", encoded.len()); + self.stdin.write_all(header.as_bytes()).expect("failed to write header"); + self.stdin.write_all(&encoded).expect("failed to write body"); + self.stdin.flush().expect("failed to flush request"); + } + + fn stderr_snapshot(&self) -> String { + self.stderr_log.lock().map(|value| value.trim().to_string()).unwrap_or_else(|_| "".to_string()) + } +} + +fn read_stdout_messages(stdout: impl Read, tx: mpsc::Sender) { + let mut stdout = BufReader::new(stdout); + loop { + let mut content_length: usize = 0; + let mut raw_headers: Vec = Vec::new(); + loop { + let mut line = String::new(); + let bytes = match stdout.read_line(&mut line) { + Ok(bytes) => bytes, + Err(_) => return, + }; + if bytes == 0 { + return; + } + raw_headers.push(line.clone()); + if line.trim().is_empty() { + if content_length == 0 { + continue; + } + break; + } + if let Some(rest) = line.trim().strip_prefix("Content-Length: ") { + content_length = rest.trim().parse::().expect("invalid Content-Length header"); + } + } + + assert!(content_length > 0, "received DAP message with zero Content-Length; headers: {:?}", raw_headers); + + let mut body = vec![0u8; content_length]; + if stdout.read_exact(&mut body).is_err() { + return; + } + + let payload = serde_json::from_slice::(&body).expect("invalid JSON payload"); + if tx.send(payload).is_err() { + return; + } + } +} + +fn capture_stderr(stderr: impl Read, sink: Arc>) { + let mut stderr = BufReader::new(stderr); + let mut buffer = String::new(); + let _ = stderr.read_to_string(&mut buffer); + if let Ok(mut stored) = sink.lock() { + *stored = buffer; + } +} + +pub fn resolve_debugger_dap_binary() -> PathBuf { + let env_candidates = + ["CARGO_BIN_EXE_debugger-dap", "CARGO_BIN_EXE_debugger_dap"].iter().filter_map(|key| std::env::var_os(key).map(PathBuf::from)); + for candidate in env_candidates { + if candidate.exists() { + return candidate; + } + } + + let target_dir = std::env::var_os("CARGO_TARGET_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target")); + let exe = format!("debugger-dap{}", std::env::consts::EXE_SUFFIX); + let profiles = if cfg!(debug_assertions) { ["debug", "release"] } else { ["release", "debug"] }; + + for profile in profiles { + let candidate = target_dir.join(profile).join(&exe); + if candidate.exists() { + return candidate; + } + } + + panic!( + "could not locate debugger-dap binary via env vars or target dir {}; looked for {} in debug/release", + target_dir.display(), + exe + ); +} + +impl Drop for TestClient { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} diff --git a/debugger/dap/tests/test_launch.rs b/debugger/dap/tests/test_launch.rs new file mode 100644 index 00000000..6de7901a --- /dev/null +++ b/debugger/dap/tests/test_launch.rs @@ -0,0 +1,1124 @@ +mod harness; + +use std::fs; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +use harness::TestClient; +use serde_json::json; + +const SIMPLE_SCRIPT: &str = r#"pragma silverscript ^0.1.0; + +contract Simple() { + entrypoint function main() { + int a = 1; + int b = 2; + require(a + b == 3); + } +} +"#; + +const MULTIFUNCTION_IF_STATEMENTS_SCRIPT: &str = r#"pragma silverscript ^0.1.0; + +contract MultiFunctionIfStatements(int x, int y) { + entrypoint function transfer(int a, int b) { + int d = a + b; + d = d - a; + if (d == x) { + int c = d + b; + d = a + c; + require(c > d); + } else { + d = a; + } + d = d + a; + require(d == y); + } + + entrypoint function timeout(int b) { + int d = b; + d = d + 2; + if (d == x) { + int c = d + b; + d = c + d; + require(c > d); + } + d = b; + require(d == y); + } +} +"#; + +const INLINE_CALL_BOUNCE_SCRIPT: &str = r#"pragma silverscript ^0.1.0; + +contract InlineBounce() { + function check_pair(int leftInput, int rightInput) { + int left = leftInput + rightInput; + int right = left * 2; + require(right >= left); + } + + entrypoint function main(int a, int b) { + check_pair(a, b); + require(a >= 0); + } +} +"#; + +const STACK_RENDER_SCRIPT: &str = r#"pragma silverscript ^0.1.0; + +contract StackRender() { + entrypoint function main(bool flag) { + require(!flag); + } +} +"#; + +const CHECKSIG_SCRIPT: &str = r#"pragma silverscript ^0.1.0; + +contract CheckSig(pubkey pk) { + entrypoint function main(sig s) { + require(checkSig(s, pk)); + } +} +"#; + +struct TempScript { + path: PathBuf, +} + +impl TempScript { + fn new(source: &str) -> Self { + let unique = SystemTime::now().duration_since(UNIX_EPOCH).map(|duration| duration.as_nanos()).unwrap_or_default(); + let file_name = format!("silverscript-dap-test-{}-{}.sil", std::process::id(), unique); + let path = std::env::temp_dir().join(file_name); + fs::write(&path, source).expect("failed to write temp script"); + Self { path } + } + + fn path_str(&self) -> String { + self.path.to_string_lossy().to_string() + } + + fn debug_params_path(&self) -> PathBuf { + let stem = self.path.file_stem().and_then(|value| value.to_str()).unwrap_or("script"); + self.path.with_file_name(format!("{stem}.debug.json")) + } + + fn write_debug_params(&self, contents: &str) { + fs::write(self.debug_params_path(), contents).expect("failed to write debug params"); + } +} + +impl Drop for TempScript { + fn drop(&mut self) { + let _ = fs::remove_file(&self.path); + let _ = fs::remove_file(self.debug_params_path()); + } +} + +fn equivalent_path_variant(path: &str) -> String { + let path_buf = PathBuf::from(path); + let Some(parent) = path_buf.parent() else { + return path.to_string(); + }; + let Some(parent_name) = parent.file_name() else { + return path.to_string(); + }; + let Some(file_name) = path_buf.file_name() else { + return path.to_string(); + }; + parent.join("..").join(parent_name).join(file_name).to_string_lossy().to_string() +} + +#[test] +fn launch_stops_on_entry_and_disconnects() { + let script = TempScript::new(SIMPLE_SCRIPT); + let script_path = script.path_str(); + + let mut client = TestClient::spawn(); + let stopped = client.full_launch_sequence(&script_path); + + let reason = stopped.get("body").and_then(|v| v.get("reason")).and_then(|v| v.as_str()).unwrap_or_default(); + assert_eq!(reason, "entry"); + + client.send_request("threads", serde_json::Value::Null); + let threads = client.expect_response_success("threads"); + let size = threads.get("body").and_then(|v| v.get("threads")).and_then(|v| v.as_array()).map(|arr| arr.len()).unwrap_or(0); + assert!(size >= 1); + + client.send_request("disconnect", serde_json::json!({})); + client.expect_response_success("disconnect"); +} + +#[test] +fn breakpoint_snaps_and_continue_stops() { + let script = TempScript::new(SIMPLE_SCRIPT); + let script_path = script.path_str(); + + let mut client = TestClient::spawn(); + + client.send_request( + "initialize", + json!({ + "adapterID": "silverscript", + "pathFormat": "path", + "linesStartAt1": true, + "columnsStartAt1": true, + "supportsVariableType": true, + "supportsVariablePaging": false, + "supportsRunInTerminalRequest": false + }), + ); + client.expect_response_success("initialize"); + client.expect_event("initialized"); + + client.send_request( + "launch", + json!({ + "scriptPath": script_path, + "stopOnEntry": true + }), + ); + client.expect_response_success("launch"); + + client.send_request( + "setBreakpoints", + json!({ + "source": {"path": script_path}, + "breakpoints": [{"line": 2}, {"line": 6}] + }), + ); + let set_bp = client.expect_response_success("setBreakpoints"); + let breakpoints = set_bp.get("body").and_then(|v| v.get("breakpoints")).and_then(|v| v.as_array()).cloned().unwrap_or_default(); + assert_eq!(breakpoints.len(), 2, "expected two breakpoint responses: {set_bp:#}"); + + let first_verified = breakpoints.first().and_then(|v| v.get("verified")).and_then(|v| v.as_bool()).unwrap_or(false); + assert!(first_verified, "first breakpoint should be verified: {set_bp:#}"); + + let first_resolved = breakpoints.first().and_then(|v| v.get("line")).and_then(|v| v.as_i64()).unwrap_or_default(); + assert!(first_resolved >= 4, "expected first breakpoint to snap to executable line >= 4, got {first_resolved}"); + + let second_resolved = breakpoints.get(1).and_then(|v| v.get("line")).and_then(|v| v.as_i64()).unwrap_or_default(); + assert_eq!(second_resolved, 6, "expected second breakpoint to stay on line 6: {set_bp:#}"); + + client.send_request("setExceptionBreakpoints", json!({"filters": []})); + client.expect_response_success("setExceptionBreakpoints"); + + client.send_request("configurationDone", serde_json::Value::Null); + client.expect_response_success("configurationDone"); + let entry_stop = client.expect_event("stopped"); + let entry_reason = entry_stop.get("body").and_then(|v| v.get("reason")).and_then(|v| v.as_str()).unwrap_or_default(); + assert_eq!(entry_reason, "entry"); + + client.send_request("continue", json!({"threadId": 1})); + client.expect_response_success("continue"); + + let mut stopped_reason: Option = None; + let mut terminated_seen = false; + for _ in 0..12 { + let msg = client.read_message(); + if msg.get("type") == Some(&serde_json::Value::String("event".to_string())) { + let event = msg.get("event").and_then(|v| v.as_str()).unwrap_or_default(); + if event == "stopped" { + stopped_reason = msg.get("body").and_then(|v| v.get("reason")).and_then(|v| v.as_str()).map(|v| v.to_string()); + break; + } + if event == "terminated" { + terminated_seen = true; + break; + } + } + } + + assert!( + stopped_reason.as_deref() == Some("breakpoint"), + "expected breakpoint stop after continue; stopped_reason={stopped_reason:?}, terminated_seen={terminated_seen}" + ); + + client.send_request("disconnect", json!({})); + client.expect_response_success("disconnect"); +} + +#[test] +fn launch_auto_signs_sig_argument_from_secret_key() { + let script = TempScript::new(CHECKSIG_SCRIPT); + let script_path = script.path_str(); + + let keygen = std::process::Command::new(harness::resolve_debugger_dap_binary()) + .arg("--keygen") + .output() + .expect("failed to run debugger-dap --keygen"); + assert!(keygen.status.success(), "keygen failed: {}", String::from_utf8_lossy(&keygen.stderr)); + let key_material: serde_json::Value = serde_json::from_slice(&keygen.stdout).expect("keygen should emit valid json"); + let pubkey = key_material.get("pubkey").and_then(|v| v.as_str()).expect("missing pubkey"); + let secret_key = key_material.get("secret_key").and_then(|v| v.as_str()).expect("missing secret_key"); + + let mut client = TestClient::spawn(); + client.send_request( + "initialize", + json!({ + "adapterID": "silverscript", + "pathFormat": "path", + "linesStartAt1": true, + "columnsStartAt1": true, + "supportsVariableType": true, + "supportsVariablePaging": false, + "supportsRunInTerminalRequest": false + }), + ); + client.expect_response_success("initialize"); + client.expect_event("initialized"); + + client.send_request( + "launch", + json!({ + "scriptPath": script_path, + "function": "main", + "constructorArgs": [pubkey], + "args": [secret_key], + "stopOnEntry": true + }), + ); + client.expect_response_success("launch"); + client.send_request("setBreakpoints", json!({"source": {"path": script_path}, "breakpoints": []})); + client.expect_response_success("setBreakpoints"); + client.send_request("setExceptionBreakpoints", json!({"filters": []})); + client.expect_response_success("setExceptionBreakpoints"); + client.send_request("configurationDone", serde_json::Value::Null); + client.expect_response_success("configurationDone"); + let entry_stop = client.expect_event("stopped"); + let entry_reason = entry_stop.get("body").and_then(|v| v.get("reason")).and_then(|v| v.as_str()).unwrap_or_default(); + assert_eq!(entry_reason, "entry"); + + client.send_request("continue", json!({"threadId": 1})); + client.expect_response_success("continue"); + + let mut terminated = false; + for _ in 0..8 { + let msg = client.read_message(); + if msg.get("type") != Some(&serde_json::Value::String("event".to_string())) { + continue; + } + if msg.get("event").and_then(|v| v.as_str()) == Some("terminated") { + terminated = true; + break; + } + if msg.get("event").and_then(|v| v.as_str()) == Some("stopped") { + panic!("expected successful termination, got stop event: {msg:#}"); + } + } + + assert!(terminated, "expected debug session to terminate successfully after auto-sign"); + + client.send_request("disconnect", json!({})); + client.expect_response_success("disconnect"); +} + +#[test] +fn continue_hits_breakpoint_in_second_entrypoint() { + let script = TempScript::new(MULTIFUNCTION_IF_STATEMENTS_SCRIPT); + let script_path = script.path_str(); + + let mut client = TestClient::spawn(); + + client.send_request( + "initialize", + json!({ + "adapterID": "silverscript", + "pathFormat": "path", + "linesStartAt1": true, + "columnsStartAt1": true, + "supportsVariableType": true, + "supportsVariablePaging": false, + "supportsRunInTerminalRequest": false + }), + ); + client.expect_response_success("initialize"); + client.expect_event("initialized"); + + client.send_request( + "launch", + json!({ + "scriptPath": script_path, + "function": "timeout", + "constructorArgs": ["100", "9"], + "args": ["9"], + "stopOnEntry": true + }), + ); + client.expect_response_success("launch"); + + client.send_request( + "setBreakpoints", + json!({ + "source": {"path": script_path}, + "breakpoints": [{"line": 26}] + }), + ); + let set_bp = client.expect_response_success("setBreakpoints"); + let breakpoints = set_bp.get("body").and_then(|v| v.get("breakpoints")).and_then(|v| v.as_array()).cloned().unwrap_or_default(); + assert_eq!(breakpoints.len(), 1, "expected one breakpoint response: {set_bp:#}"); + let verified = breakpoints.first().and_then(|v| v.get("verified")).and_then(|v| v.as_bool()).unwrap_or(false); + assert!(verified, "breakpoint should be verified: {set_bp:#}"); + + client.send_request("setExceptionBreakpoints", json!({"filters": []})); + client.expect_response_success("setExceptionBreakpoints"); + + client.send_request("configurationDone", serde_json::Value::Null); + client.expect_response_success("configurationDone"); + let entry_stop = client.expect_event("stopped"); + let entry_reason = entry_stop.get("body").and_then(|v| v.get("reason")).and_then(|v| v.as_str()).unwrap_or_default(); + assert_eq!(entry_reason, "entry"); + + client.send_request("continue", json!({"threadId": 1})); + client.expect_response_success("continue"); + + let mut stopped_reason: Option = None; + let mut stopped_line: Option = None; + let mut terminated_seen = false; + for _ in 0..16 { + let msg = client.read_message(); + if msg.get("type") == Some(&serde_json::Value::String("event".to_string())) { + let event = msg.get("event").and_then(|v| v.as_str()).unwrap_or_default(); + if event == "stopped" { + stopped_reason = msg.get("body").and_then(|v| v.get("reason")).and_then(|v| v.as_str()).map(|v| v.to_string()); + + client.send_request("stackTrace", json!({"threadId": 1})); + let stack = client.expect_response_success("stackTrace"); + stopped_line = stack + .get("body") + .and_then(|v| v.get("stackFrames")) + .and_then(|v| v.as_array()) + .and_then(|frames| frames.first()) + .and_then(|frame| frame.get("line")) + .and_then(|v| v.as_i64()); + break; + } + if event == "terminated" { + terminated_seen = true; + break; + } + } + } + + assert!( + stopped_reason.as_deref() == Some("breakpoint"), + "expected breakpoint stop after continue; stopped_reason={stopped_reason:?}, terminated_seen={terminated_seen}" + ); + assert!(stopped_line.is_some(), "expected stack frame line to be present when stopped"); + + client.send_request("disconnect", json!({})); + client.expect_response_success("disconnect"); +} + +#[test] +fn adjacent_debug_params_file_supplies_function_and_args() { + let script = TempScript::new(MULTIFUNCTION_IF_STATEMENTS_SCRIPT); + script.write_debug_params( + r#"{ + "function": "timeout", + "constructorArgs": ["100", "9"], + "args": ["9"] +} +"#, + ); + let script_path = script.path_str(); + + let mut client = TestClient::spawn(); + + client.send_request( + "initialize", + json!({ + "adapterID": "silverscript", + "pathFormat": "path", + "linesStartAt1": true, + "columnsStartAt1": true, + "supportsVariableType": true, + "supportsVariablePaging": false, + "supportsRunInTerminalRequest": false + }), + ); + client.expect_response_success("initialize"); + client.expect_event("initialized"); + + client.send_request( + "launch", + json!({ + "scriptPath": script_path, + "stopOnEntry": true + }), + ); + client.expect_response_success("launch"); + + client.send_request( + "setBreakpoints", + json!({ + "source": {"path": script_path}, + "breakpoints": [{"line": 26}] + }), + ); + let set_bp = client.expect_response_success("setBreakpoints"); + let breakpoints = set_bp.get("body").and_then(|v| v.get("breakpoints")).and_then(|v| v.as_array()).cloned().unwrap_or_default(); + assert_eq!(breakpoints.len(), 1, "expected one breakpoint response: {set_bp:#}"); + let verified = breakpoints.first().and_then(|v| v.get("verified")).and_then(|v| v.as_bool()).unwrap_or(false); + assert!(verified, "breakpoint should be verified: {set_bp:#}"); + + client.send_request("setExceptionBreakpoints", json!({"filters": []})); + client.expect_response_success("setExceptionBreakpoints"); + + client.send_request("configurationDone", serde_json::Value::Null); + client.expect_response_success("configurationDone"); + let entry_stop = client.expect_event("stopped"); + let entry_reason = entry_stop.get("body").and_then(|v| v.get("reason")).and_then(|v| v.as_str()).unwrap_or_default(); + assert_eq!(entry_reason, "entry"); + + client.send_request("continue", json!({"threadId": 1})); + client.expect_response_success("continue"); + + let mut stopped_line: Option = None; + for _ in 0..16 { + let msg = client.read_message(); + if msg.get("type") != Some(&serde_json::Value::String("event".to_string())) { + continue; + } + + let event = msg.get("event").and_then(|v| v.as_str()).unwrap_or_default(); + if event == "terminated" { + break; + } + if event == "stopped" { + let reason = msg.get("body").and_then(|v| v.get("reason")).and_then(|v| v.as_str()).unwrap_or_default(); + assert_eq!(reason, "breakpoint", "expected breakpoint stop event: {msg:#}"); + + client.send_request("stackTrace", json!({"threadId": 1})); + let stack = client.expect_response_success("stackTrace"); + stopped_line = stack + .get("body") + .and_then(|v| v.get("stackFrames")) + .and_then(|v| v.as_array()) + .and_then(|frames| frames.first()) + .and_then(|frame| frame.get("line")) + .and_then(|v| v.as_i64()); + break; + } + } + + let stopped_line = stopped_line.expect("expected sidecar-selected breakpoint stop"); + assert!((18..=27).contains(&stopped_line), "expected breakpoint inside timeout entrypoint, got line {stopped_line}",); + + client.send_request("disconnect", json!({})); + client.expect_response_success("disconnect"); +} + +#[test] +fn adjacent_named_debug_params_file_supplies_function_and_args() { + let script = TempScript::new(MULTIFUNCTION_IF_STATEMENTS_SCRIPT); + script.write_debug_params( + r#"{ + "function": "timeout", + "constructorArgs": { + "x": 100, + "y": 9 + }, + "args": { + "b": 9 + } +} +"#, + ); + let script_path = script.path_str(); + + let mut client = TestClient::spawn(); + + client.send_request( + "initialize", + json!({ + "adapterID": "silverscript", + "pathFormat": "path", + "linesStartAt1": true, + "columnsStartAt1": true, + "supportsVariableType": true, + "supportsVariablePaging": false, + "supportsRunInTerminalRequest": false + }), + ); + client.expect_response_success("initialize"); + client.expect_event("initialized"); + + client.send_request( + "launch", + json!({ + "scriptPath": script_path, + "stopOnEntry": true + }), + ); + client.expect_response_success("launch"); + + client.send_request( + "setBreakpoints", + json!({ + "source": {"path": script_path}, + "breakpoints": [{"line": 24}] + }), + ); + client.expect_response_success("setBreakpoints"); + + client.send_request("setExceptionBreakpoints", json!({"filters": []})); + client.expect_response_success("setExceptionBreakpoints"); + + client.send_request("configurationDone", serde_json::Value::Null); + client.expect_response_success("configurationDone"); + let entry_stop = client.expect_event("stopped"); + let entry_reason = entry_stop.get("body").and_then(|v| v.get("reason")).and_then(|v| v.as_str()).unwrap_or_default(); + assert_eq!(entry_reason, "entry"); + + client.send_request("continue", json!({"threadId": 1})); + client.expect_response_success("continue"); + + let mut stopped_line: Option = None; + for _ in 0..16 { + let msg = client.read_message(); + if msg.get("type") == Some(&serde_json::Value::String("event".to_string())) + && msg.get("event").and_then(|v| v.as_str()) == Some("stopped") + { + client.send_request("stackTrace", json!({"threadId": 1})); + let stack = client.expect_response_success("stackTrace"); + stopped_line = stack + .get("body") + .and_then(|v| v.get("stackFrames")) + .and_then(|v| v.as_array()) + .and_then(|frames| frames.first()) + .and_then(|frame| frame.get("line")) + .and_then(|v| v.as_i64()); + break; + } + } + + let stopped_line = stopped_line.expect("expected named sidecar-selected breakpoint stop"); + assert!((18..=27).contains(&stopped_line), "expected breakpoint inside timeout entrypoint, got line {stopped_line}",); + + client.send_request("disconnect", json!({})); + client.expect_response_success("disconnect"); +} + +#[test] +fn scopes_expose_variables_and_stacks() { + let script = TempScript::new( + r#"pragma silverscript ^0.1.0; + +contract ScopeTest(int threshold) { + entrypoint function main(int a, int b) { + int local = a + b; + require(local > threshold); + } +} +"#, + ); + let script_path = script.path_str(); + + let mut client = TestClient::spawn(); + client.send_request( + "initialize", + json!({ + "adapterID": "silverscript", + "pathFormat": "path", + "linesStartAt1": true, + "columnsStartAt1": true, + "supportsVariableType": true, + "supportsVariablePaging": false, + "supportsRunInTerminalRequest": false + }), + ); + client.expect_response_success("initialize"); + client.expect_event("initialized"); + + client.send_request( + "launch", + json!({ + "scriptPath": script_path, + "function": "main", + "constructorArgs": ["3"], + "args": ["5", "4"], + "stopOnEntry": true + }), + ); + client.expect_response_success("launch"); + + client.send_request("setBreakpoints", json!({"source": {"path": script_path}, "breakpoints": []})); + client.expect_response_success("setBreakpoints"); + client.send_request("setExceptionBreakpoints", json!({"filters": []})); + client.expect_response_success("setExceptionBreakpoints"); + client.send_request("configurationDone", serde_json::Value::Null); + client.expect_response_success("configurationDone"); + let entry_stop = client.expect_event("stopped"); + let entry_reason = entry_stop.get("body").and_then(|v| v.get("reason")).and_then(|v| v.as_str()).unwrap_or_default(); + assert_eq!(entry_reason, "entry"); + + client.send_request("next", json!({"threadId": 1})); + client.expect_response_success("next"); + let step_stop = client.expect_event("stopped"); + let step_reason = step_stop.get("body").and_then(|v| v.get("reason")).and_then(|v| v.as_str()).unwrap_or_default(); + assert_eq!(step_reason, "step"); + + client.send_request("stackTrace", json!({"threadId": 1})); + let stack = client.expect_response_success("stackTrace"); + let frame_id = stack + .get("body") + .and_then(|v| v.get("stackFrames")) + .and_then(|v| v.as_array()) + .and_then(|frames| frames.first()) + .and_then(|frame| frame.get("id")) + .and_then(|v| v.as_i64()) + .expect("expected stack frame id"); + + client.send_request("scopes", json!({"frameId": frame_id})); + let scopes = client.expect_response_success("scopes"); + let scope_entries = scopes.get("body").and_then(|v| v.get("scopes")).and_then(|v| v.as_array()).cloned().unwrap_or_default(); + let scope_names = scope_entries.iter().filter_map(|scope| scope.get("name").and_then(|value| value.as_str())).collect::>(); + assert!(scope_names.contains(&"Variables")); + assert!(scope_names.contains(&"Data Stack")); + assert!(scope_names.contains(&"Alt Stack")); + + let variables_ref = scope_entries + .iter() + .find(|scope| scope.get("name").and_then(|value| value.as_str()) == Some("Variables")) + .and_then(|scope| scope.get("variablesReference")) + .and_then(|value| value.as_i64()) + .expect("expected variables scope"); + let dstack_ref = scope_entries + .iter() + .find(|scope| scope.get("name").and_then(|value| value.as_str()) == Some("Data Stack")) + .and_then(|scope| scope.get("variablesReference")) + .and_then(|value| value.as_i64()) + .expect("expected data stack scope"); + let astack_ref = scope_entries + .iter() + .find(|scope| scope.get("name").and_then(|value| value.as_str()) == Some("Alt Stack")) + .and_then(|scope| scope.get("variablesReference")) + .and_then(|value| value.as_i64()) + .expect("expected alt stack scope"); + + client.send_request("variables", json!({"variablesReference": variables_ref})); + let variables = client.expect_response_success("variables"); + let variable_names = variables + .get("body") + .and_then(|v| v.get("variables")) + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default() + .into_iter() + .filter_map(|item| item.get("name").and_then(|value| value.as_str()).map(ToOwned::to_owned)) + .collect::>(); + assert_eq!(variable_names, vec!["a".to_string(), "b".to_string(), "local".to_string(), "threshold (const)".to_string()]); + + client.send_request("variables", json!({"variablesReference": dstack_ref})); + let dstack = client.expect_response_success("variables"); + let dstack_count = + dstack.get("body").and_then(|v| v.get("variables")).and_then(|v| v.as_array()).map(|items| items.len()).unwrap_or_default(); + assert!(dstack_count >= 2, "expected parameters to be visible on the data stack"); + + client.send_request("variables", json!({"variablesReference": astack_ref})); + let astack = client.expect_response_success("variables"); + let astack_entries = astack.get("body").and_then(|v| v.get("variables")).and_then(|v| v.as_array()).cloned().unwrap_or_default(); + assert_eq!(astack_entries.len(), 1, "expected empty alt stack placeholder"); + assert_eq!(astack_entries.first().and_then(|entry| entry.get("name")).and_then(|value| value.as_str()), Some("(empty)")); + assert_eq!(astack_entries.first().and_then(|entry| entry.get("value")).and_then(|value| value.as_str()), Some("")); + + client.send_request("disconnect", json!({})); + client.expect_response_success("disconnect"); +} + +#[test] +fn data_stack_renders_empty_bytes_without_bare_hex_prefix() { + let script = TempScript::new(STACK_RENDER_SCRIPT); + let script_path = script.path_str(); + + let mut client = TestClient::spawn(); + client.send_request( + "initialize", + json!({ + "adapterID": "silverscript", + "pathFormat": "path", + "linesStartAt1": true, + "columnsStartAt1": true, + "supportsVariableType": true, + "supportsVariablePaging": false, + "supportsRunInTerminalRequest": false + }), + ); + client.expect_response_success("initialize"); + client.expect_event("initialized"); + + client.send_request( + "launch", + json!({ + "scriptPath": script_path, + "function": "main", + "constructorArgs": {}, + "args": { + "flag": false + }, + "stopOnEntry": true + }), + ); + client.expect_response_success("launch"); + + client.send_request("setBreakpoints", json!({"source": {"path": script_path}, "breakpoints": []})); + client.expect_response_success("setBreakpoints"); + client.send_request("setExceptionBreakpoints", json!({"filters": []})); + client.expect_response_success("setExceptionBreakpoints"); + client.send_request("configurationDone", serde_json::Value::Null); + client.expect_response_success("configurationDone"); + client.expect_event("stopped"); + + client.send_request("stackTrace", json!({"threadId": 1})); + let stack = client.expect_response_success("stackTrace"); + let frame_id = stack + .get("body") + .and_then(|v| v.get("stackFrames")) + .and_then(|v| v.as_array()) + .and_then(|frames| frames.first()) + .and_then(|frame| frame.get("id")) + .and_then(|v| v.as_i64()) + .expect("expected stack frame id"); + + client.send_request("scopes", json!({"frameId": frame_id})); + let scopes = client.expect_response_success("scopes"); + let dstack_ref = scopes + .get("body") + .and_then(|v| v.get("scopes")) + .and_then(|v| v.as_array()) + .and_then(|entries| entries.iter().find(|scope| scope.get("name").and_then(|value| value.as_str()) == Some("Data Stack"))) + .and_then(|scope| scope.get("variablesReference")) + .and_then(|value| value.as_i64()) + .expect("expected data stack scope"); + + client.send_request("variables", json!({"variablesReference": dstack_ref})); + let dstack = client.expect_response_success("variables"); + let values = dstack + .get("body") + .and_then(|v| v.get("variables")) + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default() + .into_iter() + .filter_map(|item| item.get("value").and_then(|value| value.as_str()).map(ToOwned::to_owned)) + .collect::>(); + assert!( + values.iter().any(|value| value.starts_with("")), + "expected empty bool stack item to describe empty bytes, got {values:?}", + ); + assert!(values.iter().all(|value| value != "0x"), "unexpected bare hex prefix in stack values: {values:?}",); + + client.send_request("disconnect", json!({})); + client.expect_response_success("disconnect"); +} + +#[test] +fn continue_with_inline_call_and_callee_breakpoints_does_not_bounce_back_to_call_site() { + let script = TempScript::new(INLINE_CALL_BOUNCE_SCRIPT); + let script_path = script.path_str(); + + let mut client = TestClient::spawn(); + + client.send_request( + "initialize", + json!({ + "adapterID": "silverscript", + "pathFormat": "path", + "linesStartAt1": true, + "columnsStartAt1": true, + "supportsVariableType": true, + "supportsVariablePaging": false, + "supportsRunInTerminalRequest": false + }), + ); + client.expect_response_success("initialize"); + client.expect_event("initialized"); + + client.send_request( + "launch", + json!({ + "scriptPath": script_path, + "constructorArgs": [], + "function": "main", + "args": ["1", "2"], + "stopOnEntry": true + }), + ); + client.expect_response_success("launch"); + + // Request one call-site breakpoint and one callee-body breakpoint. + client.send_request( + "setBreakpoints", + json!({ + "source": {"path": script_path}, + "breakpoints": [{"line": 11}, {"line": 5}] + }), + ); + let set_bp = client.expect_response_success("setBreakpoints"); + let breakpoints = set_bp.get("body").and_then(|v| v.get("breakpoints")).and_then(|v| v.as_array()).cloned().unwrap_or_default(); + assert_eq!(breakpoints.len(), 2, "expected two breakpoints: {set_bp:#}"); + let call_site_line = breakpoints.first().and_then(|v| v.get("line")).and_then(|v| v.as_i64()).unwrap_or_default(); + let callee_line = breakpoints.get(1).and_then(|v| v.get("line")).and_then(|v| v.as_i64()).unwrap_or_default(); + + client.send_request("setExceptionBreakpoints", json!({"filters": []})); + client.expect_response_success("setExceptionBreakpoints"); + + client.send_request("configurationDone", serde_json::Value::Null); + client.expect_response_success("configurationDone"); + let entry_stop = client.expect_event("stopped"); + let entry_reason = entry_stop.get("body").and_then(|v| v.get("reason")).and_then(|v| v.as_str()).unwrap_or_default(); + assert_eq!(entry_reason, "entry"); + + let continue_and_capture_line = |client: &mut TestClient| -> Option { + client.send_request("continue", json!({"threadId": 1})); + client.expect_response_success("continue"); + + for _ in 0..12 { + let msg = client.read_message(); + if msg.get("type") == Some(&serde_json::Value::String("event".to_string())) { + let event = msg.get("event").and_then(|v| v.as_str()).unwrap_or_default(); + if event == "terminated" { + return None; + } + if event == "stopped" { + client.send_request("stackTrace", json!({"threadId": 1})); + let stack = client.expect_response_success("stackTrace"); + return stack + .get("body") + .and_then(|v| v.get("stackFrames")) + .and_then(|v| v.as_array()) + .and_then(|frames| frames.first()) + .and_then(|frame| frame.get("line")) + .and_then(|v| v.as_i64()); + } + } + } + None + }; + + let first = continue_and_capture_line(&mut client); + let second = continue_and_capture_line(&mut client); + let third = continue_and_capture_line(&mut client); + + // Regression check for the user-reported bounce pattern: + // call-site -> callee -> same call-site. + let bounced = first == Some(call_site_line) && second == Some(callee_line) && third == Some(call_site_line); + assert!( + !bounced, + "inline breakpoint bounce reproduced: first={first:?}, second={second:?}, third={third:?}, call_site_line={call_site_line}, callee_line={callee_line}" + ); + + client.send_request("disconnect", json!({})); + client.expect_response_success("disconnect"); +} + +#[test] +fn continue_after_clearing_breakpoints_with_path_variant_does_not_stop() { + let script = TempScript::new(INLINE_CALL_BOUNCE_SCRIPT); + let script_path = script.path_str(); + let variant_path = equivalent_path_variant(&script_path); + + let mut client = TestClient::spawn(); + + client.send_request( + "initialize", + json!({ + "adapterID": "silverscript", + "pathFormat": "path", + "linesStartAt1": true, + "columnsStartAt1": true, + "supportsVariableType": true, + "supportsVariablePaging": false, + "supportsRunInTerminalRequest": false + }), + ); + client.expect_response_success("initialize"); + client.expect_event("initialized"); + + client.send_request( + "launch", + json!({ + "scriptPath": script_path, + "constructorArgs": [], + "function": "main", + "args": ["1", "2"], + "stopOnEntry": true + }), + ); + client.expect_response_success("launch"); + + // First set breakpoints on canonical path. + client.send_request( + "setBreakpoints", + json!({ + "source": {"path": script_path}, + "breakpoints": [{"line": 11}, {"line": 5}] + }), + ); + let initial_set = client.expect_response_success("setBreakpoints"); + let initial_breakpoints = + initial_set.get("body").and_then(|v| v.get("breakpoints")).and_then(|v| v.as_array()).cloned().unwrap_or_default(); + assert_eq!(initial_breakpoints.len(), 2, "expected two breakpoint responses: {initial_set:#}"); + + // Then clear using an equivalent but differently formatted path. + client.send_request( + "setBreakpoints", + json!({ + "source": {"path": variant_path}, + "breakpoints": [] + }), + ); + client.expect_response_success("setBreakpoints"); + + client.send_request("setExceptionBreakpoints", json!({"filters": []})); + client.expect_response_success("setExceptionBreakpoints"); + + client.send_request("configurationDone", serde_json::Value::Null); + client.expect_response_success("configurationDone"); + let entry_stop = client.expect_event("stopped"); + let entry_reason = entry_stop.get("body").and_then(|v| v.get("reason")).and_then(|v| v.as_str()).unwrap_or_default(); + assert_eq!(entry_reason, "entry"); + + client.send_request("continue", json!({"threadId": 1})); + client.expect_response_success("continue"); + + let mut stopped_reason: Option = None; + let mut terminated_seen = false; + for _ in 0..16 { + let msg = client.read_message(); + if msg.get("type") == Some(&serde_json::Value::String("event".to_string())) { + let event = msg.get("event").and_then(|v| v.as_str()).unwrap_or_default(); + if event == "stopped" { + stopped_reason = msg.get("body").and_then(|v| v.get("reason")).and_then(|v| v.as_str()).map(|v| v.to_string()); + break; + } + if event == "terminated" { + terminated_seen = true; + break; + } + } + } + + assert!( + stopped_reason.is_none() && terminated_seen, + "expected termination after clearing breakpoints; stopped_reason={stopped_reason:?}, terminated_seen={terminated_seen}" + ); + + client.send_request("disconnect", json!({})); + client.expect_response_success("disconnect"); +} + +#[test] +fn breakpoints_for_launch_source_survive_other_source_updates() { + let launch_script = TempScript::new(INLINE_CALL_BOUNCE_SCRIPT); + let launch_path = launch_script.path_str(); + let other_script = TempScript::new(SIMPLE_SCRIPT); + let other_path = other_script.path_str(); + + let mut client = TestClient::spawn(); + + client.send_request( + "initialize", + json!({ + "adapterID": "silverscript", + "pathFormat": "path", + "linesStartAt1": true, + "columnsStartAt1": true, + "supportsVariableType": true, + "supportsVariablePaging": false, + "supportsRunInTerminalRequest": false + }), + ); + client.expect_response_success("initialize"); + client.expect_event("initialized"); + + client.send_request( + "launch", + json!({ + "scriptPath": launch_path, + "constructorArgs": [], + "function": "main", + "args": ["1", "2"], + "stopOnEntry": true + }), + ); + client.expect_response_success("launch"); + + // Set one breakpoint in the launched source (call-site line). + client.send_request( + "setBreakpoints", + json!({ + "source": {"path": launch_path}, + "breakpoints": [{"line": 5}] + }), + ); + let launch_set = client.expect_response_success("setBreakpoints"); + let launch_bp = launch_set.get("body").and_then(|v| v.get("breakpoints")).and_then(|v| v.as_array()).cloned().unwrap_or_default(); + assert_eq!(launch_bp.len(), 1, "expected one launch-source breakpoint response: {launch_set:#}"); + let launch_line = launch_bp.first().and_then(|v| v.get("line")).and_then(|v| v.as_i64()).unwrap_or_default(); + assert!(launch_line > 0, "launch breakpoint should resolve to executable line: {launch_set:#}"); + + // Simulate a client sending setBreakpoints for a different source. + // It should not clear or override launch-source breakpoints. + client.send_request( + "setBreakpoints", + json!({ + "source": {"path": other_path}, + "breakpoints": [{"line": 5}] + }), + ); + let other_set = client.expect_response_success("setBreakpoints"); + let other_bp = other_set.get("body").and_then(|v| v.get("breakpoints")).and_then(|v| v.as_array()).cloned().unwrap_or_default(); + assert_eq!(other_bp.len(), 1, "expected one foreign-source breakpoint response: {other_set:#}"); + let other_verified = other_bp.first().and_then(|v| v.get("verified")).and_then(|v| v.as_bool()).unwrap_or(true); + assert!(!other_verified, "foreign-source breakpoint should be unverified: {other_set:#}"); + + client.send_request("setExceptionBreakpoints", json!({"filters": []})); + client.expect_response_success("setExceptionBreakpoints"); + + client.send_request("configurationDone", serde_json::Value::Null); + client.expect_response_success("configurationDone"); + let entry_stop = client.expect_event("stopped"); + let entry_reason = entry_stop.get("body").and_then(|v| v.get("reason")).and_then(|v| v.as_str()).unwrap_or_default(); + assert_eq!(entry_reason, "entry"); + + client.send_request("continue", json!({"threadId": 1})); + client.expect_response_success("continue"); + + let mut stopped_line: Option = None; + let mut terminated_seen = false; + for _ in 0..16 { + let msg = client.read_message(); + if msg.get("type") == Some(&serde_json::Value::String("event".to_string())) { + let event = msg.get("event").and_then(|v| v.as_str()).unwrap_or_default(); + if event == "stopped" { + let reason = msg.get("body").and_then(|v| v.get("reason")).and_then(|v| v.as_str()).unwrap_or_default(); + assert_eq!(reason, "breakpoint", "expected breakpoint stop event: {msg:#}"); + + client.send_request("stackTrace", json!({"threadId": 1})); + let stack = client.expect_response_success("stackTrace"); + stopped_line = stack + .get("body") + .and_then(|v| v.get("stackFrames")) + .and_then(|v| v.as_array()) + .and_then(|frames| frames.first()) + .and_then(|frame| frame.get("line")) + .and_then(|v| v.as_i64()); + break; + } + if event == "terminated" { + terminated_seen = true; + break; + } + } + } + + assert!(!terminated_seen, "launch-source breakpoint should still be active after foreign-source update"); + assert_eq!(stopped_line, Some(launch_line), "expected stop on launch-source breakpoint line after foreign-source update"); + + client.send_request("disconnect", json!({})); + client.expect_response_success("disconnect"); +} diff --git a/debugger/session/src/args.rs b/debugger/session/src/args.rs index e796a541..b323902f 100644 --- a/debugger/session/src/args.rs +++ b/debugger/session/src/args.rs @@ -1,3 +1,4 @@ +use serde_json::Value; use silverscript_lang::ast::{ContractAst, Expr, ExprKind}; use silverscript_lang::span; @@ -128,3 +129,17 @@ pub fn parse_call_args(input_types: &[String], raw_args: &[String]) -> Result Result, String> { + values.iter().map(value_to_arg).collect() +} + +fn value_to_arg(value: &Value) -> Result { + match value { + Value::String(raw) => Ok(raw.clone()), + Value::Number(raw) => Ok(raw.to_string()), + Value::Bool(raw) => Ok(raw.to_string()), + Value::Null => Ok("null".to_string()), + Value::Array(_) | Value::Object(_) => serde_json::to_string(value).map_err(|err| format!("invalid arg value: {err}")), + } +} diff --git a/debugger/session/src/session.rs b/debugger/session/src/session.rs index 511ef46d..903ea92d 100644 --- a/debugger/session/src/session.rs +++ b/debugger/session/src/session.rs @@ -128,6 +128,7 @@ pub struct DebugSession<'a, 'i> { script_len: usize, pc: usize, debug_info: DebugInfo<'i>, + step_index_by_id: HashMap, step_order: Vec, current_step_index: Option, source_lines: Vec, @@ -181,6 +182,7 @@ impl<'a, 'i> DebugSession<'a, 'i> { let source_lines: Vec = source.lines().map(String::from).collect(); let (opcode_offsets, script_len) = build_opcode_offsets(&opcodes); + let step_index_by_id = debug_info.steps.iter().enumerate().map(|(index, step)| (step.id(), index)).collect(); let mut step_order: Vec = (0..debug_info.steps.len()).collect(); // Overlapping inline ranges can share the same bytecode offsets; keep // compiler emission order via sequence before comparing range width. @@ -198,6 +200,7 @@ impl<'a, 'i> DebugSession<'a, 'i> { script_len, pc: 0, debug_info, + step_index_by_id, step_order, current_step_index: None, source_lines, @@ -515,10 +518,27 @@ impl<'a, 'i> DebugSession<'a, 'i> { } fn current_function_range(&self) -> Option<&DebugFunctionRange> { - let offset = self.current_byte_offset(); + self.function_range_for_offset(self.current_byte_offset()) + } + + fn function_range_for_offset(&self, offset: usize) -> Option<&DebugFunctionRange> { self.debug_info.functions.iter().find(|function| offset >= function.bytecode_start && offset < function.bytecode_end) } + fn step_by_id(&self, step_id: StepId) -> Option<&DebugStep<'i>> { + self.step_index_by_id.get(&step_id).map(|index| &self.debug_info.steps[*index]) + } + + fn variable_anchor_step(&self, step_id: StepId) -> Option<&DebugStep<'i>> { + self.step_by_id(step_id).or_else(|| { + self.debug_info + .steps + .iter() + .filter(|step| step.frame_id == step_id.frame_id && step.sequence < step_id.sequence) + .max_by_key(|step| step.sequence) + }) + } + fn current_variable_updates(&self, context: &VariableContext<'_>) -> HashMap> { let mut latest_by_name: HashMap)> = HashMap::new(); for step in self.debug_info.steps.iter().filter(|step| self.step_updates_are_visible(step, context)) { @@ -535,12 +555,23 @@ impl<'a, 'i> DebugSession<'a, 'i> { } fn current_variable_context(&self, step_id: StepId) -> Result, String> { - let function = self.current_function_range().ok_or_else(|| "No function context available".to_string())?; + let offset = if step_id == self.current_step_id() { + self.current_byte_offset() + } else { + self.variable_anchor_step(step_id) + .map(|step| step.bytecode_end) + .ok_or_else(|| format!("No debug step for sequence {} frame {}", step_id.sequence, step_id.frame_id))? + }; + let function = self + .variable_anchor_step(step_id) + .and_then(|step| self.function_range_for_offset(step.bytecode_start)) + .or_else(|| self.function_range_for_offset(offset)) + .ok_or_else(|| "No function context available".to_string())?; Ok(VariableContext { function_name: function.name.as_str(), function_start: function.bytecode_start, function_end: function.bytecode_end, - offset: self.current_byte_offset(), + offset, step_id, }) } diff --git a/extensions/vscode/.gitignore b/extensions/vscode/.gitignore index f06235c4..910a5fa6 100644 --- a/extensions/vscode/.gitignore +++ b/extensions/vscode/.gitignore @@ -1,2 +1,5 @@ node_modules dist +bin/* +!bin/.gitignore +*.vsix diff --git a/extensions/vscode/.vscodeignore b/extensions/vscode/.vscodeignore index ba7a4413..cd71f7af 100644 --- a/extensions/vscode/.vscodeignore +++ b/extensions/vscode/.vscodeignore @@ -3,7 +3,9 @@ out/** node_modules/** src/** -.gitignore +scripts/** +**/.gitignore +**/.gitkeep .yarnrc esbuild.js vsc-extension-quickstart.md @@ -14,6 +16,9 @@ vsc-extension-quickstart.md **/.vscode-test.* # keep these -!assets/** +!assets/tree-sitter-silverscript.wasm !queries/** -!node_modules/web-tree-sitter/** +!node_modules/web-tree-sitter/package.json +!node_modules/web-tree-sitter/LICENSE +!node_modules/web-tree-sitter/web-tree-sitter.cjs +!node_modules/web-tree-sitter/web-tree-sitter.wasm diff --git a/extensions/vscode/CHANGELOG.md b/extensions/vscode/CHANGELOG.md index 4069c280..919f23c1 100644 --- a/extensions/vscode/CHANGELOG.md +++ b/extensions/vscode/CHANGELOG.md @@ -7,3 +7,5 @@ Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how ## [Unreleased] - Initial release +- Added DAP-based contract debugging and a quick launch panel. +- Bundle the native debug adapter into platform-specific VSIX packages. diff --git a/extensions/vscode/README.md b/extensions/vscode/README.md index ec38b2bb..96c56b91 100644 --- a/extensions/vscode/README.md +++ b/extensions/vscode/README.md @@ -1,6 +1,19 @@ ### Pre-release -`npm exec vsce package -- --pre-release` +Build a platform-specific VSIX that bundles the native Rust debug adapter: + +```bash +npm run package:vsix +``` + +By default this packages the adapter for the current host platform into `bin//`. + +For CI or cross-target release jobs, set: + +- `SILVERSCRIPT_VSCODE_TARGET`, for example `darwin-arm64` or `linux-x64` +- `SILVERSCRIPT_CARGO_TARGET`, for example `aarch64-apple-darwin` + +Then run `npm run package:vsix`. ### Development @@ -9,6 +22,7 @@ - Build the extension once with `npm run compile` (or keep it rebuilding with `npm run watch`). - Open `extensions/vscode` in VS Code. - Press `F5` and run `Run Extension` to start an Extension Development Host with this extension loaded. +- In a full repo checkout, the extension can still auto-build `debugger-dap` on demand for local development. Published VSIX builds should use the bundled adapter path instead. #### Live Grammar Changes @@ -22,3 +36,60 @@ npm run build:vscode This also refreshes shared highlighting queries (`extensions/vscode/queries/highlights.scm`). Then in the Extension Development Host, press `Ctrl+R` to reload and apply parser/query updates. + +### Contract Debugging + +This extension provides a lean DAP-based contract debugger. + +#### Launch Flow + +- Run `SilverScript: Run / Debug Contract` on an open `.sil` file, or press `F5`. +- The extension opens a small runner panel with constructor args, entrypoint args, and `Run` / `Debug` buttons. +- The panel also includes a key helper that can generate a keypair and insert `secret_key`, `pubkey`, or `pkh` into the currently focused field. +- The panel persists the latest values into an adjacent `*.debug.json` file so the next run opens with the same inputs. +- The debugger launches through the bundled Rust DAP adapter when available, with repo checkouts falling back to a local workspace build. + +Use `SilverScript: Open Debug Params` if you still want to edit the sidecar file directly. + +If you need a custom adapter build, set `silverscript.debugAdapterPath` to an absolute path and the extension will use that binary instead. + +#### Parameters + +Launch configurations can provide: + +```json +{ + "type": "silverscript", + "request": "launch", + "name": "SilverScript: Debug Contract", + "scriptPath": "${file}", + "function": "main", + "constructorArgs": ["3", "10"], + "args": ["5", "5"], + "stopOnEntry": true +} +``` + +If `function`, `constructorArgs`, or `args` are omitted, the debugger also looks for an adjacent `*.debug.json` file next to the `.sil` file: + +```json +{ + "function": "main", + "constructorArgs": { + "x": 3, + "y": 10 + }, + "args": { + "a": 5, + "b": 5 + } +} +``` + +The sidecar file also accepts arrays when needed, but keyed objects are easier to read and edit because names stay attached to values. + +Launch configuration values override the sidecar file. + +#### Transaction Context + +The debugger now runs against a small synthetic transaction context by default so `sig` arguments can be auto-signed from a 32-byte secret key. Advanced users can override that runtime context by adding a `tx` object to `launch.json` or `*.debug.json`; this is intentionally kept out of the panel UI. diff --git a/extensions/vscode/bin/.gitignore b/extensions/vscode/bin/.gitignore new file mode 100644 index 00000000..d6b7ef32 --- /dev/null +++ b/extensions/vscode/bin/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/extensions/vscode/package.json b/extensions/vscode/package.json index 96ea69fe..2641649c 100644 --- a/extensions/vscode/package.json +++ b/extensions/vscode/package.json @@ -2,12 +2,18 @@ "name": "silverscript", "publisher": "IzioDev", "displayName": "SilverScript", - "description": "Kaspa SilverScript", + "description": "SilverScript language support and contract debugging for VS Code", "version": "0.1.3", + "preview": true, "repository": { + "type": "git", "url": "https://github.com/kaspanet/silverscript", "directory": "extensions/vscode" }, + "homepage": "https://github.com/kaspanet/silverscript/tree/main/extensions/vscode", + "bugs": { + "url": "https://github.com/kaspanet/silverscript/issues" + }, "license": "ISC", "author": { "name": "Kaspa Developers" @@ -15,14 +21,133 @@ "engines": { "vscode": "^1.108.0" }, + "extensionKind": [ + "workspace" + ], + "capabilities": { + "untrustedWorkspaces": { + "supported": "limited" + }, + "virtualWorkspaces": { + "supported": false + } + }, "categories": [ - "Other" + "Programming Languages", + "Debuggers" + ], + "keywords": [ + "silverscript", + "kaspa", + "smart contracts", + "debugger" ], "activationEvents": [ - "onLanguage:silverscript" + "onLanguage:silverscript", + "onDebug:silverscript" ], "main": "./dist/extension.js", "contributes": { + "commands": [ + { + "command": "silverscript.debug.configureLaunch", + "category": "SilverScript", + "title": "SilverScript: Run / Debug Contract" + }, + { + "command": "silverscript.debug.openParamsFile", + "category": "SilverScript", + "title": "SilverScript: Open Debug Params" + } + ], + "keybindings": [ + { + "command": "silverscript.debug.configureLaunch", + "key": "f5", + "when": "editorLangId == silverscript && !inDebugMode" + } + ], + "debuggers": [ + { + "type": "silverscript", + "label": "SilverScript Debug", + "languages": [ + "silverscript" + ], + "breakpoints": [ + { + "language": "silverscript" + } + ], + "configurationAttributes": { + "launch": { + "properties": { + "scriptPath": { + "type": "string", + "description": "Path to SilverScript source file" + }, + "paramsFile": { + "type": "string", + "description": "Optional path to a sidecar params file (*.debug.json)" + }, + "function": { + "type": "string", + "description": "Entrypoint function name" + }, + "constructorArgs": { + "oneOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "object" + } + ], + "default": [] + }, + "args": { + "oneOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "object" + } + ], + "default": [] + }, + "tx": { + "type": "object", + "description": "Optional advanced transaction context override used by the Rust debugger runtime" + }, + "noDebug": { + "type": "boolean", + "default": false + }, + "stopOnEntry": { + "type": "boolean", + "default": true + } + } + } + }, + "initialConfigurations": [ + { + "type": "silverscript", + "request": "launch", + "name": "SilverScript: Debug Contract", + "scriptPath": "${file}", + "stopOnEntry": true + } + ] + } + ], "languages": [ { "id": "silverscript", @@ -36,6 +161,11 @@ "configuration": "./language-configuration.json" } ], + "breakpoints": [ + { + "language": "silverscript" + } + ], "configuration": { "type": "object", "title": "SilverScript", @@ -45,11 +175,6 @@ "default": true, "description": "Enable covenants-only opcodes." }, - "silverscript.withoutSelector": { - "type": "boolean", - "default": false, - "description": "Compile without function selector (single entrypoint)." - }, "silverscript.maxDiagnostics": { "type": "number", "default": 200, @@ -59,17 +184,31 @@ "type": "boolean", "default": true, "description": "Enable semantic tokens from the LSP." + }, + "silverscript.debugAdapterPath": { + "type": "string", + "default": "", + "scope": "machine", + "markdownDescription": "Optional path to a custom `debugger-dap` binary. When set, SilverScript uses it instead of the bundled adapter." + }, + "silverscript.autoBuildDebuggerAdapter": { + "type": "boolean", + "default": true, + "description": "When running from a SilverScript repo checkout, build the Rust debug adapter automatically if no compatible binary is available." } } } }, "scripts": { - "vscode:prepublish": "npm run package", + "vscode:prepublish": "npm run prepare-release", + "bundle:adapter": "node scripts/prepare-adapter.mjs", "compile": "npm run check-types && npm run lint && node esbuild.js", "watch": "npm-run-all -p watch:*", "watch:esbuild": "node esbuild.js --watch", "watch:tsc": "tsc --noEmit --watch --project tsconfig.json", "package": "npm run check-types && npm run lint && node esbuild.js --production", + "prepare-release": "npm run bundle:adapter && npm run package", + "package:vsix": "npm exec vsce package -- --pre-release", "compile-tests": "tsc -p . --outDir out", "watch-tests": "tsc -p . -w --outDir out", "pretest": "npm run compile-tests && npm run compile && npm run lint", diff --git a/extensions/vscode/scripts/prepare-adapter.mjs b/extensions/vscode/scripts/prepare-adapter.mjs new file mode 100644 index 00000000..ae2430c5 --- /dev/null +++ b/extensions/vscode/scripts/prepare-adapter.mjs @@ -0,0 +1,88 @@ +import { spawnSync } from "node:child_process"; +import { + chmodSync, + copyFileSync, + existsSync, + mkdirSync, +} from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +function readFlag(flagName) { + const index = process.argv.indexOf(flagName); + if (index < 0) { + return undefined; + } + return process.argv[index + 1]; +} + +function resolveCurrentTarget() { + return `${process.platform}-${process.arch}`; +} + +function resolveExecutableName(target) { + return target.startsWith("win32-") + ? "debugger-dap.exe" + : "debugger-dap"; +} + +function absoluteFrom(base, value) { + return path.isAbsolute(value) ? value : path.resolve(base, value); +} + +const extensionRoot = path.resolve(__dirname, ".."); +const repoRoot = path.resolve(extensionRoot, "..", ".."); +const vscodeTarget = + readFlag("--target") ?? + process.env.SILVERSCRIPT_VSCODE_TARGET ?? + resolveCurrentTarget(); +const cargoTarget = + readFlag("--cargo-target") ?? + process.env.SILVERSCRIPT_CARGO_TARGET; +const explicitBinary = + readFlag("--binary") ?? + process.env.SILVERSCRIPT_DEBUGGER_DAP_BIN; +const executableName = resolveExecutableName(vscodeTarget); + +let builtBinary; +if (explicitBinary) { + builtBinary = absoluteFrom(process.cwd(), explicitBinary); +} else { + const args = ["build", "--release", "-p", "debugger-dap"]; + if (cargoTarget) { + args.push("--target", cargoTarget); + } + + const result = spawnSync("cargo", args, { + cwd: repoRoot, + stdio: "inherit", + }); + if (result.status !== 0) { + process.exit(result.status ?? 1); + } + + const buildDir = cargoTarget + ? path.join(repoRoot, "target", cargoTarget, "release") + : path.join(repoRoot, "target", "release"); + builtBinary = path.join(buildDir, executableName); +} + +if (!existsSync(builtBinary)) { + throw new Error(`debugger-dap binary not found: ${builtBinary}`); +} + +const destinationDir = path.join(extensionRoot, "bin", vscodeTarget); +const destinationBinary = path.join(destinationDir, executableName); +mkdirSync(destinationDir, { recursive: true }); +copyFileSync(builtBinary, destinationBinary); + +if (!vscodeTarget.startsWith("win32-")) { + chmodSync(destinationBinary, 0o755); +} + +console.log( + `[bundle] copied ${builtBinary} -> ${destinationBinary}`, +); diff --git a/extensions/vscode/src/contractModel.ts b/extensions/vscode/src/contractModel.ts new file mode 100644 index 00000000..5fe31549 --- /dev/null +++ b/extensions/vscode/src/contractModel.ts @@ -0,0 +1,201 @@ +import * as fs from "fs/promises"; +import * as path from "path"; + +export type ContractParam = { name: string; type: string }; +export type Entrypoint = { name: string; params: ContractParam[] }; +export type ContractModel = { + name: string; + constructorParams: ContractParam[]; + entrypoints: Entrypoint[]; +}; + +export type DebugArgObject = Record; +export type DebugArgInput = unknown[] | DebugArgObject; +export type DebugTxInput = { + prev_txid?: string; + prev_index?: number; + sequence?: number; + sig_op_count?: number; + utxo_value: number; + covenant_id?: string; + constructor_args?: DebugArgInput; + signature_script_hex?: string; + utxo_script_hex?: string; +}; +export type DebugTxOutput = { + value: number; + covenant_id?: string; + authorizing_input?: number; + constructor_args?: DebugArgInput; + script_hex?: string; + p2pk_pubkey?: string; +}; +export type DebugTxScenario = { + version?: number; + lock_time?: number; + active_input_index?: number; + inputs: DebugTxInput[]; + outputs: DebugTxOutput[]; +}; + +export type DebugParamsFile = { + function?: string; + constructorArgs?: DebugArgInput; + args?: DebugArgInput; + tx?: DebugTxScenario; +}; + +export function inferDebugParamsPath(scriptPath: string): string { + const base = path.basename(scriptPath, path.extname(scriptPath)); + return path.join(path.dirname(scriptPath), `${base}.debug.json`); +} + +function stripComments(source: string): string { + return source + .replace(/\/\*[\s\S]*?\*\//g, "") + .replace(/\/\/.*$/gm, ""); +} + +function parseParams(raw: string): ContractParam[] { + return raw + .split(",") + .map((value) => value.trim()) + .filter(Boolean) + .map((part, index) => { + const [typeName, name] = part.split(/\s+/).filter(Boolean); + return { type: typeName ?? "int", name: name ?? `arg${index}` }; + }); +} + +export function parseContractModel(source: string): ContractModel { + const clean = stripComments(source); + const header = clean.match( + /contract\s+([A-Za-z_]\w*)\s*\(([^)]*)\)/m, + ); + const name = header?.[1] ?? "Unknown"; + const constructorParams = header?.[2]?.trim() + ? parseParams(header[2]) + : []; + const entrypoints: Entrypoint[] = []; + const re = + /entrypoint\s+function\s+([A-Za-z_]\w*)\s*\(([^)]*)\)/g; + for (let match; (match = re.exec(clean)); ) { + entrypoints.push({ + name: match[1], + params: parseParams(match[2]), + }); + } + return { name, constructorParams, entrypoints }; +} + +function hex(n: number): string { + return `0x${"00".repeat(Math.max(0, n))}`; +} + +export function defaultForType(typeName: string): unknown { + const normalized = typeName.trim(); + if (normalized.endsWith("[]")) { + return []; + } + switch (normalized) { + case "int": + return 0; + case "bool": + return false; + case "string": + return ""; + case "byte": + return hex(1); + case "bytes": + return hex(0); + case "pubkey": + return hex(32); + case "sig": + return hex(65); + case "datasig": + return hex(64); + } + let match = normalized.match(/^bytes(\d+)$/); + if (match) { + return hex(Number(match[1])); + } + match = normalized.match(/^byte\[(\d+)\]$/); + if (match) { + return hex(Number(match[1])); + } + return 0; +} + +export function defaultsFromParams(params: ContractParam[]): unknown[] { + return params.map((param) => defaultForType(param.type)); +} + +export function defaultsObjectFromParams( + params: ContractParam[], +): DebugArgObject { + return Object.fromEntries( + params.map((param) => [param.name, defaultForType(param.type)]), + ); +} + +function isDebugArgObject(value: unknown): value is DebugArgObject { + return ( + value !== null && + typeof value === "object" && + !Array.isArray(value) + ); +} + +export async function readDebugParams( + scriptPath: string, + explicitPath?: string, +): Promise { + const paramsPath = explicitPath ?? inferDebugParamsPath(scriptPath); + try { + const parsed = JSON.parse( + await fs.readFile(paramsPath, "utf8"), + ) as Record; + return { + function: + typeof parsed.function === "string" + ? parsed.function + : undefined, + constructorArgs: + Array.isArray(parsed.constructorArgs) || + isDebugArgObject(parsed.constructorArgs) + ? (parsed.constructorArgs as DebugArgInput) + : Array.isArray(parsed.constructor_args) || + isDebugArgObject(parsed.constructor_args) + ? (parsed.constructor_args as DebugArgInput) + : undefined, + args: + Array.isArray(parsed.args) || isDebugArgObject(parsed.args) + ? (parsed.args as DebugArgInput) + : undefined, + tx: + parsed.tx && typeof parsed.tx === "object" + ? (parsed.tx as DebugTxScenario) + : undefined, + }; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + return undefined; + } + throw error; + } +} + +export async function writeDebugParams( + scriptPath: string, + params: DebugParamsFile, + explicitPath?: string, +): Promise { + const paramsPath = explicitPath ?? inferDebugParamsPath(scriptPath); + await fs.writeFile( + paramsPath, + JSON.stringify(params, null, 2) + "\n", + "utf8", + ); + return paramsPath; +} diff --git a/extensions/vscode/src/debug.ts b/extensions/vscode/src/debug.ts new file mode 100644 index 00000000..7e873ae9 --- /dev/null +++ b/extensions/vscode/src/debug.ts @@ -0,0 +1,315 @@ +import * as fs from "fs"; +import * as path from "path"; +import * as vscode from "vscode"; +import { + defaultsObjectFromParams, + type DebugArgInput, + inferDebugParamsPath, + parseContractModel, + readDebugParams, +} from "./contractModel"; +import { ensureDebuggerAdapterBinary } from "./debugAdapter"; + +function isDebugArgInput(value: unknown): value is DebugArgInput { + return ( + Array.isArray(value) || + (value !== null && typeof value === "object") + ); +} + +function resolveActiveScriptUri(uri?: vscode.Uri): vscode.Uri | undefined { + if (uri) { + return uri; + } + const activeDoc = vscode.window.activeTextEditor?.document; + if (activeDoc?.languageId === "silverscript") { + return activeDoc.uri; + } + return undefined; +} + +function expandActiveFileVariable(raw: string): string | undefined { + if (!raw.includes("${file}")) { + return raw; + } + const active = resolveActiveScriptUri(); + return active?.fsPath + ? raw.replaceAll("${file}", active.fsPath) + : undefined; +} + +function ensureTrustedWorkspace(feature: string): boolean { + if (vscode.workspace.isTrusted) { + return true; + } + + void vscode.window.showWarningMessage( + `SilverScript ${feature} requires a trusted workspace.`, + ); + return false; +} + +async function ensureDebugParamsFile( + scriptUri: vscode.Uri, +): Promise<{ + path: string; + created: boolean; +}> { + const scriptPath = scriptUri.fsPath; + const paramsPath = inferDebugParamsPath(scriptPath); + if (fs.existsSync(paramsPath)) { + return { path: paramsPath, created: false }; + } + + const source = await fs.promises.readFile(scriptPath, "utf8"); + const model = parseContractModel(source); + const functionName = model.entrypoints[0]?.name; + const entrypoint = functionName + ? model.entrypoints.find((item) => item.name === functionName) + : undefined; + const template = { + function: functionName, + constructorArgs: defaultsObjectFromParams(model.constructorParams), + args: defaultsObjectFromParams(entrypoint?.params ?? []), + }; + + await fs.promises.writeFile( + paramsPath, + JSON.stringify(template, null, 2) + "\n", + "utf8", + ); + return { path: paramsPath, created: true }; +} + +async function openDebugParamsFile( + scriptUri: vscode.Uri, +): Promise { + const result = await ensureDebugParamsFile(scriptUri); + const doc = await vscode.workspace.openTextDocument(result.path); + await vscode.window.showTextDocument(doc, { + preview: false, + preserveFocus: false, + viewColumn: vscode.ViewColumn.Beside, + }); + + if (result.created) { + void vscode.window.showInformationMessage( + `Created ${path.basename(result.path)} for debug arguments.`, + ); + } +} + +class SilverScriptDebugAdapterFactory + implements vscode.DebugAdapterDescriptorFactory +{ + constructor( + private readonly ctx: vscode.ExtensionContext, + private readonly out: vscode.OutputChannel, + ) {} + + async createDebugAdapterDescriptor(): Promise { + if (!ensureTrustedWorkspace("debugging")) { + throw new Error("SilverScript debugging requires a trusted workspace."); + } + + const { root, bin, source } = await ensureDebuggerAdapterBinary( + this.ctx, + this.out, + ); + this.out.appendLine(`[debug] launching ${bin} [${source}]`); + return new vscode.DebugAdapterExecutable(bin, [], { + cwd: root, + }); + } +} + +class SilverScriptConfigProvider + implements vscode.DebugConfigurationProvider +{ + private makeDefaultLaunchConfig(): + | vscode.DebugConfiguration + | undefined { + const scriptUri = resolveActiveScriptUri(); + if (!scriptUri) { + return undefined; + } + return { + type: "silverscript", + request: "launch", + name: "SilverScript: Debug Contract", + scriptPath: scriptUri.fsPath, + stopOnEntry: true, + }; + } + + private async applyContractDefaults( + config: vscode.DebugConfiguration, + ): Promise { + if (typeof config.scriptPath !== "string" || !config.scriptPath.trim()) { + return; + } + + const source = await fs.promises.readFile(config.scriptPath, "utf8"); + const model = parseContractModel(source); + const paramsFile = + typeof config.paramsFile === "string" && config.paramsFile.trim() + ? config.paramsFile + : undefined; + const debugParams = await readDebugParams( + config.scriptPath, + paramsFile, + ); + + if (!config.function && debugParams?.function) { + config.function = debugParams.function; + } + + const hasCtorArgs = isDebugArgInput(config.constructorArgs); + if (!hasCtorArgs && debugParams?.constructorArgs !== undefined) { + config.constructorArgs = debugParams.constructorArgs; + } + if (!isDebugArgInput(config.constructorArgs)) { + config.constructorArgs = defaultsObjectFromParams( + model.constructorParams, + ); + } + + if (!config.function && model.entrypoints.length > 0) { + config.function = model.entrypoints[0].name; + } + + const hasArgs = isDebugArgInput(config.args); + if (!hasArgs && debugParams?.args !== undefined) { + config.args = debugParams.args; + } + if (!isDebugArgInput(config.args) && config.function) { + const entrypoint = model.entrypoints.find( + (item) => item.name === config.function, + ); + if (entrypoint) { + config.args = defaultsObjectFromParams( + entrypoint.params, + ); + } + } + } + async resolveDebugConfiguration( + _folder: vscode.WorkspaceFolder | undefined, + config: vscode.DebugConfiguration, + ): Promise { + if (!ensureTrustedWorkspace("debugging")) { + return null; + } + + if (!config.type && !config.request) { + const defaultConfig = this.makeDefaultLaunchConfig(); + if (!defaultConfig) { + return undefined; + } + config = defaultConfig; + } + + if ( + config.type !== "silverscript" || + config.request !== "launch" + ) { + return config; + } + + for (const key of ["scriptPath", "paramsFile"] as const) { + if (typeof config[key] === "string") { + const expanded = expandActiveFileVariable(config[key] as string); + if (expanded === undefined) { + vscode.window.showErrorMessage( + "No active file to resolve ${file}.", + ); + return null; + } + config[key] = expanded; + } + } + + if (!config.scriptPath) { + const active = resolveActiveScriptUri(); + if (active) { + config.scriptPath = active.fsPath; + } + } + + try { + await this.applyContractDefaults(config); + } catch (error) { + vscode.window.showErrorMessage( + `SilverScript debug configuration failed: ${(error as Error).message}`, + ); + return null; + } + + config.noDebug ??= false; + config.stopOnEntry ??= true; + return config; + } +} + +export function registerSilverScriptDebugger( + ctx: vscode.ExtensionContext, + out: vscode.OutputChannel, +): void { + const configProvider = new SilverScriptConfigProvider(); + + ctx.subscriptions.push( + vscode.debug.registerDebugAdapterDescriptorFactory( + "silverscript", + new SilverScriptDebugAdapterFactory(ctx, out), + ), + ); + ctx.subscriptions.push( + vscode.debug.registerDebugConfigurationProvider( + "silverscript", + configProvider, + ), + ); + ctx.subscriptions.push( + vscode.debug.registerDebugAdapterTrackerFactory( + "silverscript", + { + createDebugAdapterTracker: () => ({ + onWillStartSession: () => + out.appendLine("[debug] session starting"), + onError: (error: Error) => + out.appendLine(`[debug] error: ${error}`), + onExit: ( + code: number | undefined, + signal: string | undefined, + ) => { + out.appendLine( + `[debug] exit: code=${code}, signal=${signal}`, + ); + }, + }), + }, + ), + ); + ctx.subscriptions.push( + vscode.commands.registerCommand( + "silverscript.debug.openParamsFile", + async (uri?: vscode.Uri) => { + const scriptUri = resolveActiveScriptUri(uri); + if (!scriptUri) { + vscode.window.showErrorMessage( + "Open a .sil file to edit SilverScript debug arguments.", + ); + return; + } + + try { + await openDebugParamsFile(scriptUri); + } catch (error) { + vscode.window.showErrorMessage( + `Failed to open debug params: ${(error as Error).message}`, + ); + } + }, + ), + ); +} diff --git a/extensions/vscode/src/debugAdapter.ts b/extensions/vscode/src/debugAdapter.ts new file mode 100644 index 00000000..24993207 --- /dev/null +++ b/extensions/vscode/src/debugAdapter.ts @@ -0,0 +1,294 @@ +import * as childProcess from "child_process"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import * as vscode from "vscode"; + +const autoBuildAttempted = new Set(); +const ADAPTER_BASENAME = + process.platform === "win32" ? "debugger-dap.exe" : "debugger-dap"; + +function findWorkspaceRoot(): string | undefined { + const activeUri = vscode.window.activeTextEditor?.document.uri; + if (activeUri) { + const folder = vscode.workspace.getWorkspaceFolder(activeUri); + if (folder) { + return folder.uri.fsPath; + } + } + return vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; +} + +function hasDebuggerWorkspaceLayout(root: string): boolean { + return ( + fs.existsSync(path.join(root, "Cargo.toml")) && + fs.existsSync(path.join(root, "debugger", "dap", "Cargo.toml")) + ); +} + +function currentPlatformTarget(): string { + return `${process.platform}-${process.arch}`; +} + +function findExistingFile(candidates: string[]): string | undefined { + return candidates.find((candidate) => fs.existsSync(candidate)); +} + +function workspaceBinaryCandidates(root: string): string[] { + return ["release", "debug"].map((profile) => + path.join(root, "target", profile, ADAPTER_BASENAME), + ); +} + +function bundledBinaryCandidates( + ctx: vscode.ExtensionContext, +): string[] { + return [ + path.join( + ctx.extensionPath, + "bin", + currentPlatformTarget(), + ADAPTER_BASENAME, + ), + ]; +} + +function expandUserPath(raw: string): string { + if (raw === "~") { + return os.homedir(); + } + if (raw.startsWith("~/") || raw.startsWith("~\\")) { + return path.join(os.homedir(), raw.slice(2)); + } + return raw; +} + +function configuredAdapterCandidates( + ctx: vscode.ExtensionContext, +): string[] { + const configured = vscode.workspace + .getConfiguration("silverscript") + .get("debugAdapterPath", "") + .trim(); + + if (!configured) { + return []; + } + + const raw = expandUserPath(configured); + if (path.isAbsolute(raw)) { + return [raw]; + } + + const workspaceRoot = findWorkspaceRoot(); + const candidates = [ + workspaceRoot ? path.resolve(workspaceRoot, raw) : undefined, + path.resolve(ctx.extensionPath, raw), + ].filter((candidate): candidate is string => Boolean(candidate)); + + return [...new Set(candidates)]; +} + +export function resolveRepoRoot( + ctx: vscode.ExtensionContext, +): string { + const candidates: string[] = []; + const workspaceRoot = findWorkspaceRoot(); + if (workspaceRoot) { + candidates.push(workspaceRoot); + } + candidates.push(path.resolve(ctx.extensionPath, "..", "..")); + + for (const candidate of candidates) { + if (hasDebuggerWorkspaceLayout(candidate)) { + return candidate; + } + } + + return candidates[0] ?? path.resolve(ctx.extensionPath, "..", ".."); +} + +export function summarizeCommandFailure( + command: string, + args: string[], + result: { + stdout?: string; + stderr?: string; + error?: Error; + status?: number | null; + }, +): string { + const cmd = [command, ...args].join(" "); + const stdout = (result.stdout ?? "").trim(); + const stderr = (result.stderr ?? "").trim(); + const details = [stderr, stdout].filter(Boolean).slice(0, 2).join(" | "); + + if (result.error) { + return `${cmd} failed: ${result.error.message}`; + } + if (result.status !== 0) { + return `${cmd} exited with code ${result.status}${details ? `: ${details}` : ""}`; + } + return `${cmd} failed`; +} + +async function spawnCommand( + command: string, + args: string[], + cwd: string, +): Promise<{ stdout: string; stderr: string; status: number | null }> { + return new Promise((resolve, reject) => { + const child = childProcess.spawn(command, args, { + cwd, + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + + child.stdout?.on("data", (chunk: Buffer | string) => { + stdout += chunk.toString(); + }); + child.stderr?.on("data", (chunk: Buffer | string) => { + stderr += chunk.toString(); + }); + child.on("error", reject); + child.on("close", (status) => { + resolve({ stdout, stderr, status }); + }); + }); +} + +export async function ensureDebuggerAdapterBinary( + ctx: vscode.ExtensionContext, + out?: vscode.OutputChannel, +): Promise<{ root: string; bin: string; source: string }> { + const root = resolveRepoRoot(ctx); + + const configuredCandidates = configuredAdapterCandidates(ctx); + if (configuredCandidates.length > 0) { + const configured = findExistingFile(configuredCandidates); + if (!configured) { + throw new Error( + `Configured debug adapter path not found. Checked: ${configuredCandidates.join(", ")}`, + ); + } + return { + root: path.dirname(configured), + bin: configured, + source: "configured", + }; + } + + const bundled = findExistingFile(bundledBinaryCandidates(ctx)); + if (bundled) { + return { + root: path.dirname(bundled), + bin: bundled, + source: "bundled", + }; + } + + const hasWorkspaceLayout = hasDebuggerWorkspaceLayout(root); + const existingWorkspaceBinary = hasWorkspaceLayout + ? findExistingFile(workspaceBinaryCandidates(root)) + : undefined; + if (existingWorkspaceBinary) { + return { + root, + bin: existingWorkspaceBinary, + source: "workspace", + }; + } + + const allowAutoBuild = vscode.workspace + .getConfiguration("silverscript") + .get("autoBuildDebuggerAdapter", true); + + if ( + hasWorkspaceLayout && + allowAutoBuild && + !autoBuildAttempted.has(root) + ) { + autoBuildAttempted.add(root); + const cmd = "cargo"; + const args = ["build", "-p", "debugger-dap"]; + const result = await vscode.window.withProgress({ + location: vscode.ProgressLocation.Notification, + title: "Building SilverScript debugger adapter", + cancellable: false, + }, async (progress) => { + progress.report({ + message: `${cmd} ${args.join(" ")}`, + }); + out?.appendLine( + `[debug] adapter missing, running: ${cmd} ${args.join(" ")} (cwd=${root})`, + ); + return spawnCommand(cmd, args, root); + }); + if (result.stdout) { + out?.appendLine(result.stdout); + } + if (result.stderr) { + out?.appendLine(result.stderr); + } + if (result.status !== 0) { + throw new Error(summarizeCommandFailure(cmd, args, result)); + } + } + + const builtWorkspaceBinary = hasWorkspaceLayout + ? findExistingFile(workspaceBinaryCandidates(root)) + : undefined; + if (builtWorkspaceBinary) { + return { + root, + bin: builtWorkspaceBinary, + source: autoBuildAttempted.has(root) ? "workspace-built" : "workspace", + }; + } + + const target = currentPlatformTarget(); + const installMessage = + `No bundled SilverScript debug adapter was found for ${target}. ` + + "Package a platform-specific VSIX that includes bin//debugger-dap, " + + "or set `silverscript.debugAdapterPath` to a compatible binary."; + + if (!hasWorkspaceLayout) { + throw new Error(installMessage); + } + + if (!allowAutoBuild) { + throw new Error( + `${installMessage} Auto-build is disabled, so build it manually with: cargo build -p debugger-dap`, + ); + } + + throw new Error( + `${installMessage} Development fallback also failed. Run: cargo build -p debugger-dap`, + ); +} + +export async function runDebuggerAdapterCommand( + ctx: vscode.ExtensionContext, + args: string[], + out?: vscode.OutputChannel, +): Promise { + const { root, bin, source } = await ensureDebuggerAdapterBinary( + ctx, + out, + ); + out?.appendLine(`[debug] running ${bin} ${args.join(" ")} [${source}]`); + + const result = await spawnCommand(bin, args, root); + if (result.stdout) { + out?.appendLine(result.stdout); + } + if (result.stderr) { + out?.appendLine(result.stderr); + } + if (result.status !== 0) { + throw new Error(summarizeCommandFailure(bin, args, result)); + } + return (result.stdout ?? "").trim(); +} diff --git a/extensions/vscode/src/extension.ts b/extensions/vscode/src/extension.ts index a82a576c..f64d31f5 100644 --- a/extensions/vscode/src/extension.ts +++ b/extensions/vscode/src/extension.ts @@ -3,6 +3,8 @@ import * as path from "path"; import * as fs from "fs/promises"; import { Language, Parser, Query } from "web-tree-sitter"; import type { QueryCapture } from "web-tree-sitter"; +import { registerSilverScriptDebugger } from "./debug"; +import { registerSilverScriptQuickLaunchPanel } from "./quickLaunchPanel"; const TOKEN_TYPES = [ "comment", @@ -26,7 +28,7 @@ const legend = new vscode.SemanticTokensLegend( [...TOKEN_MODIFIERS], ); -const LOG_DEBUG = true; +let logDebugEnabled = false; let outputChannel: vscode.OutputChannel | null = null; function logInfo(message: string) { @@ -37,7 +39,7 @@ function logInfo(message: string) { } function logDebug(message: string) { - if (!LOG_DEBUG) { + if (!logDebugEnabled) { return; } logInfo(message); @@ -366,9 +368,34 @@ class SilverScriptSemanticTokensProvider } export function activate(context: vscode.ExtensionContext) { + logDebugEnabled = + context.extensionMode !== vscode.ExtensionMode.Production; + outputChannel = vscode.window.createOutputChannel("SilverScript"); + const debugOutputChannel = vscode.window.createOutputChannel( + "SilverScript Debugger", + ); context.subscriptions.push(outputChannel); + context.subscriptions.push(debugOutputChannel); logInfo("SilverScript extension activated."); + logInfo( + `mode=${vscode.ExtensionMode[context.extensionMode]} id=${context.extension.id} path=${context.extensionPath}`, + ); + + const semanticEnabled = vscode.workspace + .getConfiguration("silverscript") + .get("enableSemanticTokens", true); + logInfo(`enableSemanticTokens=${semanticEnabled}`); + + const activeDoc = vscode.window.activeTextEditor?.document; + if (activeDoc) { + logInfo( + `activeDoc=${activeDoc.uri.fsPath} languageId=${activeDoc.languageId}`, + ); + } + + registerSilverScriptDebugger(context, debugOutputChannel); + registerSilverScriptQuickLaunchPanel(context, debugOutputChannel); // TODO: add LSP (LanguageClient + LanguageServer) diff --git a/extensions/vscode/src/quickLaunchPanel.ts b/extensions/vscode/src/quickLaunchPanel.ts new file mode 100644 index 00000000..f47deded --- /dev/null +++ b/extensions/vscode/src/quickLaunchPanel.ts @@ -0,0 +1,919 @@ +import * as fs from "fs"; +import * as vscode from "vscode"; +import { + defaultForType, + type DebugArgInput, + type DebugParamsFile, + parseContractModel, + readDebugParams, + writeDebugParams, + type ContractModel, + type ContractParam, + type DebugArgObject, +} from "./contractModel"; +import { runDebuggerAdapterCommand } from "./debugAdapter"; + +type LaunchPanelMessage = { + kind: "run" | "debug"; + function: string; + constructorArgs: DebugArgObject; + args: DebugArgObject; +}; +type KeygenPanelMessage = { kind: "generateKeyMaterial" }; +type PanelMessage = LaunchPanelMessage | KeygenPanelMessage; + +type GeneratedKeyMaterial = { + pubkey: string; + secret_key: string; + pkh: string; +}; + +type WebviewState = { + function: string; + constructorArgs: Record; + argsByFunction: Record>; + keys: GeneratedKeyMaterial[]; +}; + +type WebviewMessage = + | { kind: "keyMaterial"; keyMaterial: GeneratedKeyMaterial } + | { kind: "error"; message: string }; + +let panel: vscode.WebviewPanel | undefined; +let activeScriptUri: vscode.Uri | undefined; + +function resolveActiveScriptUri(uri?: vscode.Uri): vscode.Uri | undefined { + if (uri) { + return uri; + } + const activeDoc = vscode.window.activeTextEditor?.document; + if (activeDoc?.languageId === "silverscript") { + return activeDoc.uri; + } + return undefined; +} + +function ensureTrustedWorkspace(): boolean { + if (vscode.workspace.isTrusted) { + return true; + } + + void vscode.window.showWarningMessage( + "SilverScript run/debug requires a trusted workspace.", + ); + return false; +} + +function getNonce(): string { + const alphabet = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + let value = ""; + for (let index = 0; index < 32; index += 1) { + value += alphabet.charAt( + Math.floor(Math.random() * alphabet.length), + ); + } + return value; +} + +function stringifyForInlineScript(value: unknown): string { + return JSON.stringify(value) + .replace(//g, "\\u003e") + .replace(/&/g, "\\u0026") + .replace(/\u2028/g, "\\u2028") + .replace(/\u2029/g, "\\u2029"); +} + +function stringifyLaunchArg(value: unknown): string { + if (typeof value === "string") { + return value; + } + if (Array.isArray(value) || (value !== null && typeof value === "object")) { + return JSON.stringify(value); + } + return String(value); +} + +function defaultsForParams(params: ContractParam[]): Record { + return Object.fromEntries( + params.map((param) => [param.name, stringifyLaunchArg(defaultForType(param.type))]), + ); +} + +function valuesForParams( + params: ContractParam[], + input: DebugArgInput | undefined, +): Record { + const defaults = defaultsForParams(params); + if (Array.isArray(input)) { + for (const [index, param] of params.entries()) { + if (index < input.length) { + defaults[param.name] = stringifyLaunchArg(input[index]); + } + } + return defaults; + } + if (input && typeof input === "object") { + for (const param of params) { + if (Object.prototype.hasOwnProperty.call(input, param.name)) { + defaults[param.name] = stringifyLaunchArg(input[param.name]); + } + } + } + return defaults; +} + +async function openPanel( + context: vscode.ExtensionContext, + out: vscode.OutputChannel, + uri?: vscode.Uri, + initialFunction?: string, +): Promise { + if (!ensureTrustedWorkspace()) { + return; + } + + const scriptUri = resolveActiveScriptUri(uri); + if (!scriptUri) { + vscode.window.showErrorMessage("Open a .sil file first."); + return; + } + + const source = await fs.promises.readFile(scriptUri.fsPath, "utf8"); + const model = parseContractModel(source); + const debugParams = await readDebugParams(scriptUri.fsPath); + const selectedFunction = + initialFunction && model.entrypoints.some((entry) => entry.name === initialFunction) + ? initialFunction + : debugParams?.function && model.entrypoints.some((entry) => entry.name === debugParams.function) + ? debugParams.function + : model.entrypoints[0]?.name ?? ""; + + const entrypointValues: Record> = {}; + for (const entrypoint of model.entrypoints) { + const argsInput = + entrypoint.name === selectedFunction ? debugParams?.args : undefined; + entrypointValues[entrypoint.name] = valuesForParams( + entrypoint.params, + argsInput, + ); + } + + activeScriptUri = scriptUri; + + if (!panel) { + panel = vscode.window.createWebviewPanel( + "silverscriptRunner", + model.name, + vscode.ViewColumn.Beside, + { + enableScripts: true, + localResourceRoots: [], + retainContextWhenHidden: true, + }, + ); + + panel.webview.onDidReceiveMessage( + async (msg: PanelMessage) => { + if (!activeScriptUri) { + return; + } + + if (msg.kind === "generateKeyMaterial") { + try { + const raw = await runDebuggerAdapterCommand( + context, + ["--keygen"], + out, + ); + const keyMaterial = JSON.parse(raw) as GeneratedKeyMaterial; + await panel?.webview.postMessage({ + kind: "keyMaterial", + keyMaterial, + } satisfies WebviewMessage); + } catch (error) { + await panel?.webview.postMessage({ + kind: "error", + message: `Failed to generate key material: ${(error as Error).message}`, + } satisfies WebviewMessage); + } + return; + } + + const existingParams = + (await readDebugParams(activeScriptUri.fsPath)) ?? {}; + const nextParams: DebugParamsFile = { + ...existingParams, + function: msg.function, + constructorArgs: msg.constructorArgs, + args: msg.args, + }; + await writeDebugParams(activeScriptUri.fsPath, nextParams); + + const folder = + vscode.workspace.getWorkspaceFolder(activeScriptUri) ?? + vscode.workspace.workspaceFolders?.[0]; + const config: vscode.DebugConfiguration = { + type: "silverscript", + request: "launch", + name: `SilverScript: ${msg.function}`, + scriptPath: activeScriptUri.fsPath, + function: msg.function, + constructorArgs: msg.constructorArgs, + args: msg.args, + noDebug: msg.kind === "run", + stopOnEntry: msg.kind === "debug", + }; + + await vscode.debug.startDebugging(folder, config, { + noDebug: msg.kind === "run", + }); + }, + undefined, + [], + ); + + panel.onDidDispose(() => { + panel = undefined; + activeScriptUri = undefined; + }); + } else { + panel.reveal(vscode.ViewColumn.Beside); + } + + panel.title = model.name; + panel.webview.html = buildHtml(model, scriptUri.fsPath, { + function: selectedFunction, + constructorArgs: valuesForParams( + model.constructorParams, + debugParams?.constructorArgs, + ), + argsByFunction: entrypointValues, + keys: [], + }); +} + +function escHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(//g, ">"); +} + +function buildHtml( + model: ContractModel, + scriptPath: string, + initialState: WebviewState, +): string { + const nonce = getNonce(); + const modelJson = stringifyForInlineScript(model); + const stateJson = stringifyForInlineScript(initialState); + + return ` + + + + + + + + +

${escHtml(model.name)}

+
${escHtml(scriptPath)}
+ +
+

Constructor

+
+
+ +
+

Entrypoint

+ +
+ +
+

Function Args

+
+
+ +
+ + +
+ +
+ Keys +
+ + Click a crypto field to fill it directly from a generated key. +
+
+
+
+ + + +`; +} + +export function registerSilverScriptQuickLaunchPanel( + context: vscode.ExtensionContext, + out: vscode.OutputChannel, +): void { + context.subscriptions.push( + vscode.commands.registerCommand( + "silverscript.debug.configureLaunch", + (uri?: vscode.Uri, initialFunction?: string) => + openPanel(context, out, uri, initialFunction), + ), + ); +} From 10f9a7a2e95d5ffc506ef33907c0589045681fb0 Mon Sep 17 00:00:00 2001 From: Manyfestation <240733973+Manyfestation@users.noreply.github.com> Date: Tue, 10 Mar 2026 23:14:07 +0200 Subject: [PATCH 2/6] Fix parallel debuggers bug --- Cargo.toml | 1 - debugger/dap/src/launch_config.rs | 4 + debugger/dap/src/main.rs | 36 ++++++ extensions/vscode/package.json | 4 +- extensions/vscode/src/quickLaunchPanel.ts | 139 +++++++++++++++++----- 5 files changed, 153 insertions(+), 31 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 374104ec..7d03d56e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,6 @@ members = [ "debugger/session", "debugger/cli", "debugger/dap", - "covenants/sdk", ] exclude = ["tree-sitter"] resolver = "2" diff --git a/debugger/dap/src/launch_config.rs b/debugger/dap/src/launch_config.rs index 7a2e28d2..ae8794be 100644 --- a/debugger/dap/src/launch_config.rs +++ b/debugger/dap/src/launch_config.rs @@ -53,6 +53,10 @@ struct ParamsFileConfig { impl LaunchConfig { pub fn from_launch_args(args: &LaunchRequestArguments) -> Result { let value = args.additional_data.clone().unwrap_or(Value::Null); + Self::from_value(value) + } + + pub fn from_value(value: Value) -> Result { let config: Self = serde_json::from_value(value).map_err(|err| format!("invalid launch config: {err}"))?; if config.script_path.is_none() { diff --git a/debugger/dap/src/main.rs b/debugger/dap/src/main.rs index 1a72bb6e..50e35040 100644 --- a/debugger/dap/src/main.rs +++ b/debugger/dap/src/main.rs @@ -1,7 +1,9 @@ use std::io::{BufReader, BufWriter}; use dap::prelude::Server; +use debugger_session::format_failure_report; use secp256k1::{Keypair, Secp256k1, rand::thread_rng}; +use serde_json::Value; mod adapter; mod launch_config; @@ -9,8 +11,21 @@ mod refs; mod runtime_builder; use adapter::DapAdapter; +use launch_config::LaunchConfig; +use runtime_builder::build_launch; fn main() -> Result<(), Box> { + let mut args = std::env::args().skip(1); + if let Some(arg) = args.next() { + if arg == "--keygen" { + return keygen(); + } + if arg == "--run-config-json" { + let raw = args.next().ok_or("--run-config-json requires a JSON argument")?; + return run_config_json(&raw); + } + } + if std::env::args().any(|a| a == "--keygen") { return keygen(); } @@ -45,6 +60,27 @@ fn main() -> Result<(), Box> { Ok(()) } +fn run_config_json(raw: &str) -> Result<(), Box> { + let value: Value = serde_json::from_str(raw)?; + let launch = LaunchConfig::from_value(value)?; + let mut built = build_launch(launch.resolve(None)?)?; + let session = built.runtime.session_mut(); + + session.run_to_first_executed_statement()?; + match session.continue_to_breakpoint() { + Ok(Some(_)) | Ok(None) => { + println!("Execution completed successfully."); + Ok(()) + } + Err(err) => { + let report = session.build_failure_report(&err); + let formatted = format_failure_report(&report, &|type_name, value| session.format_value(type_name, value)); + eprintln!("{formatted}"); + std::process::exit(1); + } + } +} + fn keygen() -> Result<(), Box> { let secp = Secp256k1::new(); let kp = Keypair::new(&secp, &mut thread_rng()); diff --git a/extensions/vscode/package.json b/extensions/vscode/package.json index 2641649c..1c3b932c 100644 --- a/extensions/vscode/package.json +++ b/extensions/vscode/package.json @@ -62,9 +62,9 @@ ], "keybindings": [ { - "command": "silverscript.debug.configureLaunch", + "command": "silverscript.debug.f5", "key": "f5", - "when": "editorLangId == silverscript && !inDebugMode" + "when": "!inDebugMode && (editorLangId == silverscript || webviewId == 'silverscriptRunner')" } ], "debuggers": [ diff --git a/extensions/vscode/src/quickLaunchPanel.ts b/extensions/vscode/src/quickLaunchPanel.ts index f47deded..fb62db8b 100644 --- a/extensions/vscode/src/quickLaunchPanel.ts +++ b/extensions/vscode/src/quickLaunchPanel.ts @@ -38,9 +38,19 @@ type WebviewState = { type WebviewMessage = | { kind: "keyMaterial"; keyMaterial: GeneratedKeyMaterial } | { kind: "error"; message: string }; +type PanelControlMessage = { + kind: "triggerLaunch"; + launchKind: "run" | "debug"; +}; let panel: vscode.WebviewPanel | undefined; let activeScriptUri: vscode.Uri | undefined; +let launchInProgress = false; + +function activeSilverScriptSession(): vscode.DebugSession | undefined { + const session = vscode.debug.activeDebugSession; + return session?.type === "silverscript" ? session : undefined; +} function resolveActiveScriptUri(uri?: vscode.Uri): vscode.Uri | undefined { if (uri) { @@ -201,34 +211,72 @@ async function openPanel( return; } - const existingParams = - (await readDebugParams(activeScriptUri.fsPath)) ?? {}; - const nextParams: DebugParamsFile = { - ...existingParams, - function: msg.function, - constructorArgs: msg.constructorArgs, - args: msg.args, - }; - await writeDebugParams(activeScriptUri.fsPath, nextParams); - - const folder = - vscode.workspace.getWorkspaceFolder(activeScriptUri) ?? - vscode.workspace.workspaceFolders?.[0]; - const config: vscode.DebugConfiguration = { - type: "silverscript", - request: "launch", - name: `SilverScript: ${msg.function}`, - scriptPath: activeScriptUri.fsPath, - function: msg.function, - constructorArgs: msg.constructorArgs, - args: msg.args, - noDebug: msg.kind === "run", - stopOnEntry: msg.kind === "debug", - }; - - await vscode.debug.startDebugging(folder, config, { - noDebug: msg.kind === "run", - }); + if (launchInProgress) { + void vscode.window.showWarningMessage( + "A SilverScript run/debug launch is already in progress.", + ); + return; + } + + const existingSession = activeSilverScriptSession(); + if (existingSession) { + await vscode.commands.executeCommand( + "workbench.action.debug.continue", + ); + return; + } + + try { + launchInProgress = true; + const existingParams = + (await readDebugParams(activeScriptUri.fsPath)) ?? {}; + const nextParams: DebugParamsFile = { + ...existingParams, + function: msg.function, + constructorArgs: msg.constructorArgs, + args: msg.args, + }; + await writeDebugParams(activeScriptUri.fsPath, nextParams); + + const folder = + vscode.workspace.getWorkspaceFolder(activeScriptUri) ?? + vscode.workspace.workspaceFolders?.[0]; + const config: vscode.DebugConfiguration = { + type: "silverscript", + request: "launch", + name: `SilverScript: ${msg.function}`, + scriptPath: activeScriptUri.fsPath, + function: msg.function, + constructorArgs: msg.constructorArgs, + args: msg.args, + noDebug: msg.kind === "run", + stopOnEntry: msg.kind === "debug", + }; + + if (msg.kind === "run") { + const output = await runDebuggerAdapterCommand( + context, + ["--run-config-json", JSON.stringify(config)], + out, + ); + out.show(true); + void vscode.window.showInformationMessage( + output || "Execution completed successfully.", + ); + return; + } + + await vscode.debug.startDebugging(folder, config, { + noDebug: false, + }); + } catch (error) { + out.show(true); + void vscode.window.showErrorMessage( + `SilverScript ${msg.kind} failed: ${(error as Error).message}`, + ); + } finally { + launchInProgress = false; + } }, undefined, [], @@ -237,6 +285,7 @@ async function openPanel( panel.onDidDispose(() => { panel = undefined; activeScriptUri = undefined; + launchInProgress = false; }); } else { panel.reveal(vscode.ViewColumn.Beside); @@ -254,6 +303,29 @@ async function openPanel( }); } +async function triggerPanelLaunch(kind: "run" | "debug"): Promise { + if (!panel) { + return; + } + await panel.webview.postMessage({ + kind: "triggerLaunch", + launchKind: kind, + } satisfies PanelControlMessage); +} + +async function handlePanelF5( + context: vscode.ExtensionContext, + out: vscode.OutputChannel, + uri?: vscode.Uri, + initialFunction?: string, +): Promise { + if (panel && activeScriptUri) { + await triggerPanelLaunch("debug"); + return; + } + await openPanel(context, out, uri, initialFunction); +} + function escHtml(value: string): string { return value .replace(/&/g, "&") @@ -877,6 +949,10 @@ function buildHtml( if (!message || typeof message !== "object") { return; } + if (message.kind === "triggerLaunch" && (message.launchKind === "run" || message.launchKind === "debug")) { + send(message.launchKind); + return; + } if (message.kind === "keyMaterial") { const key = message.keyMaterial; state.keys.push(key); @@ -916,4 +992,11 @@ export function registerSilverScriptQuickLaunchPanel( openPanel(context, out, uri, initialFunction), ), ); + context.subscriptions.push( + vscode.commands.registerCommand( + "silverscript.debug.f5", + (uri?: vscode.Uri, initialFunction?: string) => + handlePanelF5(context, out, uri, initialFunction), + ), + ); } From 71f75dde36174bcc0bf27ded7edb6f6da7cece66 Mon Sep 17 00:00:00 2001 From: Manyfestation <240733973+Manyfestation@users.noreply.github.com> Date: Wed, 11 Mar 2026 10:24:03 +0200 Subject: [PATCH 3/6] clippy fix --- debugger/dap/src/launch_config.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/debugger/dap/src/launch_config.rs b/debugger/dap/src/launch_config.rs index ae8794be..67881cac 100644 --- a/debugger/dap/src/launch_config.rs +++ b/debugger/dap/src/launch_config.rs @@ -70,7 +70,7 @@ impl LaunchConfig { let script_path = self.resolve_script_path(workspace_root)?; let params_file = self.resolve_params_file(workspace_root, &script_path)?; - let function = self.function.or_else(|| params_file.function); + let function = self.function.or(params_file.function); let constructor_args = self.constructor_args.or(params_file.constructor_args); let args = self.args.or(params_file.args); let tx = self.tx.or(params_file.tx).map(resolve_tx_scenario).transpose()?; From 265be15e81d66299de1e2d0db616b24289a90460 Mon Sep 17 00:00:00 2001 From: Manyfestation <240733973+Manyfestation@users.noreply.github.com> Date: Wed, 11 Mar 2026 16:07:56 +0200 Subject: [PATCH 4/6] Add CodeLens --- debugger/dap/src/launch_config.rs | 53 +- debugger/dap/src/main.rs | 27 - debugger/dap/src/runtime_builder.rs | 185 ++- debugger/dap/tests/test_launch.rs | 269 ++-- extensions/vscode/README.md | 45 +- extensions/vscode/package.json | 14 +- extensions/vscode/src/codeLens.ts | 157 +++ extensions/vscode/src/contractModel.ts | 69 - extensions/vscode/src/debug.ts | 183 +-- extensions/vscode/src/debugAdapter.ts | 98 +- extensions/vscode/src/extension.ts | 2 + extensions/vscode/src/launchConfigs.ts | 254 ++++ extensions/vscode/src/quickLaunchPanel.ts | 1551 +++++++++++++++------ 13 files changed, 2078 insertions(+), 829 deletions(-) create mode 100644 extensions/vscode/src/codeLens.ts create mode 100644 extensions/vscode/src/launchConfigs.ts diff --git a/debugger/dap/src/launch_config.rs b/debugger/dap/src/launch_config.rs index 67881cac..5f13c8cb 100644 --- a/debugger/dap/src/launch_config.rs +++ b/debugger/dap/src/launch_config.rs @@ -11,7 +11,6 @@ use serde_json::Value; #[serde(rename_all = "camelCase")] pub struct LaunchConfig { pub script_path: Option, - pub params_file: Option, pub function: Option, pub constructor_args: Option, pub args: Option, @@ -38,18 +37,6 @@ pub enum ArgInput { Named(BTreeMap), } -#[derive(Debug, Clone, Default, Deserialize)] -#[serde(rename_all = "camelCase")] -struct ParamsFileConfig { - pub function: Option, - #[serde(default, alias = "constructor_args")] - pub constructor_args: Option, - #[serde(default)] - pub args: Option, - #[serde(default)] - pub tx: Option, -} - impl LaunchConfig { pub fn from_launch_args(args: &LaunchRequestArguments) -> Result { let value = args.additional_data.clone().unwrap_or(Value::Null); @@ -68,18 +55,13 @@ impl LaunchConfig { pub fn resolve(self, workspace_root: Option<&Path>) -> Result { let script_path = self.resolve_script_path(workspace_root)?; - let params_file = self.resolve_params_file(workspace_root, &script_path)?; - - let function = self.function.or(params_file.function); - let constructor_args = self.constructor_args.or(params_file.constructor_args); - let args = self.args.or(params_file.args); - let tx = self.tx.or(params_file.tx).map(resolve_tx_scenario).transpose()?; + let tx = self.tx.map(resolve_tx_scenario).transpose()?; Ok(ResolvedLaunchConfig { script_path, - function, - constructor_args, - args, + function: self.function, + constructor_args: self.constructor_args, + args: self.args, tx, no_debug: self.no_debug.unwrap_or(false), stop_on_entry: self.stop_on_entry.unwrap_or(!self.no_debug.unwrap_or(false)), @@ -90,33 +72,6 @@ impl LaunchConfig { let raw = self.script_path.as_deref().ok_or_else(|| "scriptPath is required".to_string())?; canonicalize_with_workspace(raw, workspace_root) } - - fn resolve_params_file(&self, workspace_root: Option<&Path>, script_path: &Path) -> Result { - if let Some(raw) = self.params_file.as_deref() { - let path = canonicalize_with_workspace(raw, workspace_root)?; - return read_params_file(&path); - } - - let inferred = infer_params_file_path(script_path)?; - if inferred.exists() { - return read_params_file(&inferred); - } - - Ok(ParamsFileConfig::default()) - } -} - -fn read_params_file(path: &Path) -> Result { - let raw = std::fs::read_to_string(path).map_err(|err| format!("failed to read params file '{}': {err}", path.display()))?; - serde_json::from_str::(&raw).map_err(|err| format!("invalid params file '{}': {err}", path.display())) -} - -fn infer_params_file_path(script_path: &Path) -> Result { - let stem = script_path - .file_stem() - .and_then(|stem| stem.to_str()) - .ok_or_else(|| format!("failed to derive stem from '{}'", script_path.display()))?; - Ok(script_path.with_file_name(format!("{stem}.debug.json"))) } fn canonicalize_with_workspace(raw: &str, workspace_root: Option<&Path>) -> Result { diff --git a/debugger/dap/src/main.rs b/debugger/dap/src/main.rs index 50e35040..50a76b2b 100644 --- a/debugger/dap/src/main.rs +++ b/debugger/dap/src/main.rs @@ -2,7 +2,6 @@ use std::io::{BufReader, BufWriter}; use dap::prelude::Server; use debugger_session::format_failure_report; -use secp256k1::{Keypair, Secp256k1, rand::thread_rng}; use serde_json::Value; mod adapter; @@ -17,19 +16,12 @@ use runtime_builder::build_launch; fn main() -> Result<(), Box> { let mut args = std::env::args().skip(1); if let Some(arg) = args.next() { - if arg == "--keygen" { - return keygen(); - } if arg == "--run-config-json" { let raw = args.next().ok_or("--run-config-json requires a JSON argument")?; return run_config_json(&raw); } } - if std::env::args().any(|a| a == "--keygen") { - return keygen(); - } - let input = BufReader::new(std::io::stdin()); let output = BufWriter::new(std::io::stdout()); let mut server = Server::new(input, output); @@ -80,22 +72,3 @@ fn run_config_json(raw: &str) -> Result<(), Box> { } } } - -fn keygen() -> Result<(), Box> { - let secp = Secp256k1::new(); - let kp = Keypair::new(&secp, &mut thread_rng()); - let (xonly, _parity) = kp.x_only_public_key(); - let secret_bytes = kp.secret_key().secret_bytes(); - let pubkey_bytes = xonly.serialize(); - let pkh = blake2b_simd::Params::new().hash_length(32).hash(&pubkey_bytes); - - let hex = |bytes: &[u8]| -> String { format!("0x{}", bytes.iter().map(|b| format!("{b:02x}")).collect::()) }; - let payload = serde_json::json!({ - "pubkey": hex(&pubkey_bytes), - "secret_key": hex(&secret_bytes), - "pkh": hex(pkh.as_bytes()), - }); - - println!("{}", serde_json::to_string(&payload)?); - Ok(()) -} diff --git a/debugger/dap/src/runtime_builder.rs b/debugger/dap/src/runtime_builder.rs index 52765a49..ff10fd20 100644 --- a/debugger/dap/src/runtime_builder.rs +++ b/debugger/dap/src/runtime_builder.rs @@ -17,11 +17,12 @@ use kaspa_txscript::caches::Cache; use kaspa_txscript::covenants::CovenantsContext; use kaspa_txscript::script_builder::ScriptBuilder; use kaspa_txscript::{EngineCtx, EngineFlags, SigCacheKey, pay_to_script_hash_script}; -use secp256k1::{Keypair, Message, Secp256k1, SecretKey}; +use secp256k1::{Keypair, Message, Secp256k1, SecretKey, rand::thread_rng}; +use serde_json::Value; use silverscript_lang::ast::{ContractAst, parse_contract_ast}; use silverscript_lang::compiler::{CompileOptions, compile_contract}; -use crate::launch_config::{ResolvedLaunchConfig, resolve_arg_input}; +use crate::launch_config::{ArgInput, ResolvedLaunchConfig, resolve_arg_input}; pub struct BuiltLaunch { pub runtime: OwnedRuntime, @@ -31,7 +32,9 @@ pub struct BuiltLaunch { pub no_debug: bool, } -pub fn build_launch(config: ResolvedLaunchConfig) -> Result { +pub fn build_launch(mut config: ResolvedLaunchConfig) -> Result { + resolve_launch_identities(&mut config)?; + let source_owned = fs::read_to_string(&config.script_path) .map_err(|err| format!("failed to read source '{}': {err}", config.script_path.display()))?; let source_box = source_owned.into_boxed_str(); @@ -226,6 +229,182 @@ fn resolve_auto_sign_args( Ok(resolved) } +#[derive(Debug, Clone)] +struct IdentityMaterial { + pubkey: String, + secret: String, + pkh: String, +} + +#[derive(Default)] +struct IdentityResolver { + cache: HashMap, +} + +impl IdentityResolver { + fn resolve_string(&mut self, raw: &str) -> Result { + let Some((index, field)) = parse_identity_token(raw)? else { + return Ok(raw.to_string()); + }; + let identity = self.cache.entry(index).or_insert_with(generate_identity_material); + Ok(match field { + IdentityField::Pubkey => identity.pubkey.clone(), + IdentityField::Secret => identity.secret.clone(), + IdentityField::Pkh => identity.pkh.clone(), + }) + } +} + +#[derive(Debug, Clone, Copy)] +enum IdentityField { + Pubkey, + Secret, + Pkh, +} + +fn resolve_launch_identities(config: &mut ResolvedLaunchConfig) -> Result<(), String> { + let mut resolver = IdentityResolver::default(); + + if let Some(input) = config.constructor_args.as_mut() { + resolve_arg_input_identities(input, &mut resolver)?; + } + if let Some(input) = config.args.as_mut() { + resolve_arg_input_identities(input, &mut resolver)?; + } + if let Some(tx) = config.tx.as_mut() { + resolve_tx_identities(tx, &mut resolver)?; + } + + Ok(()) +} + +fn resolve_arg_input_identities(input: &mut ArgInput, resolver: &mut IdentityResolver) -> Result<(), String> { + match input { + ArgInput::Values(values) => { + for value in values { + resolve_json_value_identities(value, resolver)?; + } + } + ArgInput::Named(named) => { + for value in named.values_mut() { + resolve_json_value_identities(value, resolver)?; + } + } + } + Ok(()) +} + +fn resolve_json_value_identities(value: &mut Value, resolver: &mut IdentityResolver) -> Result<(), String> { + match value { + Value::String(raw) => { + *raw = resolver.resolve_string(raw)?; + } + Value::Array(items) => { + for item in items { + resolve_json_value_identities(item, resolver)?; + } + } + Value::Object(entries) => { + for entry in entries.values_mut() { + resolve_json_value_identities(entry, resolver)?; + } + } + Value::Null | Value::Bool(_) | Value::Number(_) => {} + } + Ok(()) +} + +fn resolve_tx_identities(tx: &mut TestTxScenarioResolved, resolver: &mut IdentityResolver) -> Result<(), String> { + for input in &mut tx.inputs { + resolve_optional_string(&mut input.prev_txid, resolver)?; + resolve_optional_string(&mut input.covenant_id, resolver)?; + resolve_optional_strings(&mut input.constructor_args, resolver)?; + resolve_optional_string(&mut input.signature_script_hex, resolver)?; + resolve_optional_string(&mut input.utxo_script_hex, resolver)?; + } + + for output in &mut tx.outputs { + resolve_optional_string(&mut output.covenant_id, resolver)?; + resolve_optional_strings(&mut output.constructor_args, resolver)?; + resolve_optional_string(&mut output.script_hex, resolver)?; + resolve_optional_string(&mut output.p2pk_pubkey, resolver)?; + } + + Ok(()) +} + +fn resolve_optional_string(raw: &mut Option, resolver: &mut IdentityResolver) -> Result<(), String> { + if let Some(value) = raw.as_mut() { + *value = resolver.resolve_string(value)?; + } + Ok(()) +} + +fn resolve_optional_strings(values: &mut Option>, resolver: &mut IdentityResolver) -> Result<(), String> { + if let Some(entries) = values.as_mut() { + for value in entries { + *value = resolver.resolve_string(value)?; + } + } + Ok(()) +} + +fn parse_identity_token(raw: &str) -> Result, String> { + let trimmed = raw.trim(); + if !trimmed.starts_with("keypair") && !trimmed.starts_with("identity") { + return Ok(None); + } + + let Some((head, suffix)) = trimmed.split_once('.') else { + return Err(format!("invalid identity token '{raw}'; expected keypair.pubkey, keypair.secret, or keypair.pkh")); + }; + + let index_raw = if let Some(value) = head.strip_prefix("keypair") { + value + } else if let Some(value) = head.strip_prefix("identity") { + value + } else { + return Err(format!("invalid identity token '{raw}'; expected keypair.pubkey, keypair.secret, or keypair.pkh")); + }; + + if index_raw.is_empty() { + return Err(format!("invalid identity token '{raw}'; expected keypair.pubkey, keypair.secret, or keypair.pkh")); + } + + let index = index_raw + .parse::() + .map_err(|_| format!("invalid identity token '{raw}'; expected keypair.pubkey, keypair.secret, or keypair.pkh"))?; + if index == 0 { + return Err(format!("invalid identity token '{raw}'; keypair index must be >= 1")); + } + + let field = match suffix { + "pubkey" => IdentityField::Pubkey, + "secret" => IdentityField::Secret, + "pkh" => IdentityField::Pkh, + _ => { + return Err(format!("invalid identity token '{raw}'; expected keypair.pubkey, keypair.secret, or keypair.pkh")); + } + }; + + Ok(Some((index, field))) +} + +fn generate_identity_material() -> IdentityMaterial { + let secp = Secp256k1::new(); + let keypair = Keypair::new(&secp, &mut thread_rng()); + let (xonly, _parity) = keypair.x_only_public_key(); + let secret_bytes = keypair.secret_key().secret_bytes(); + let pubkey_bytes = xonly.serialize(); + let pkh = blake2b_simd::Params::new().hash_length(32).hash(&pubkey_bytes); + + IdentityMaterial { + pubkey: format!("0x{}", encode_hex(&pubkey_bytes)), + secret: format!("0x{}", encode_hex(&secret_bytes)), + pkh: format!("0x{}", encode_hex(pkh.as_bytes())), + } +} + struct BuiltTxContext { transaction: NonNull, populated_tx: &'static PopulatedTransaction<'static>, diff --git a/debugger/dap/tests/test_launch.rs b/debugger/dap/tests/test_launch.rs index 6de7901a..3a94e65b 100644 --- a/debugger/dap/tests/test_launch.rs +++ b/debugger/dap/tests/test_launch.rs @@ -83,6 +83,16 @@ contract CheckSig(pubkey pk) { } "#; +const P2PKH_SCRIPT: &str = r#"pragma silverscript ^0.1.0; + +contract P2PKH(byte[32] pkh) { + entrypoint function spend(pubkey pk, sig s) { + require(blake2b(pk) == pkh); + require(checkSig(s, pk)); + } +} +"#; + struct TempScript { path: PathBuf, } @@ -99,21 +109,11 @@ impl TempScript { fn path_str(&self) -> String { self.path.to_string_lossy().to_string() } - - fn debug_params_path(&self) -> PathBuf { - let stem = self.path.file_stem().and_then(|value| value.to_str()).unwrap_or("script"); - self.path.with_file_name(format!("{stem}.debug.json")) - } - - fn write_debug_params(&self, contents: &str) { - fs::write(self.debug_params_path(), contents).expect("failed to write debug params"); - } } impl Drop for TempScript { fn drop(&mut self) { let _ = fs::remove_file(&self.path); - let _ = fs::remove_file(self.debug_params_path()); } } @@ -245,15 +245,6 @@ fn launch_auto_signs_sig_argument_from_secret_key() { let script = TempScript::new(CHECKSIG_SCRIPT); let script_path = script.path_str(); - let keygen = std::process::Command::new(harness::resolve_debugger_dap_binary()) - .arg("--keygen") - .output() - .expect("failed to run debugger-dap --keygen"); - assert!(keygen.status.success(), "keygen failed: {}", String::from_utf8_lossy(&keygen.stderr)); - let key_material: serde_json::Value = serde_json::from_slice(&keygen.stdout).expect("keygen should emit valid json"); - let pubkey = key_material.get("pubkey").and_then(|v| v.as_str()).expect("missing pubkey"); - let secret_key = key_material.get("secret_key").and_then(|v| v.as_str()).expect("missing secret_key"); - let mut client = TestClient::spawn(); client.send_request( "initialize", @@ -275,8 +266,8 @@ fn launch_auto_signs_sig_argument_from_secret_key() { json!({ "scriptPath": script_path, "function": "main", - "constructorArgs": [pubkey], - "args": [secret_key], + "constructorArgs": ["keypair1.pubkey"], + "args": ["keypair1.secret"], "stopOnEntry": true }), ); @@ -316,12 +307,11 @@ fn launch_auto_signs_sig_argument_from_secret_key() { } #[test] -fn continue_hits_breakpoint_in_second_entrypoint() { - let script = TempScript::new(MULTIFUNCTION_IF_STATEMENTS_SCRIPT); +fn launch_resolves_symbolic_pkh_tokens() { + let script = TempScript::new(P2PKH_SCRIPT); let script_path = script.path_str(); let mut client = TestClient::spawn(); - client.send_request( "initialize", json!({ @@ -341,30 +331,17 @@ fn continue_hits_breakpoint_in_second_entrypoint() { "launch", json!({ "scriptPath": script_path, - "function": "timeout", - "constructorArgs": ["100", "9"], - "args": ["9"], + "function": "spend", + "constructorArgs": ["keypair1.pkh"], + "args": ["keypair1.pubkey", "keypair1.secret"], "stopOnEntry": true }), ); client.expect_response_success("launch"); - - client.send_request( - "setBreakpoints", - json!({ - "source": {"path": script_path}, - "breakpoints": [{"line": 26}] - }), - ); - let set_bp = client.expect_response_success("setBreakpoints"); - let breakpoints = set_bp.get("body").and_then(|v| v.get("breakpoints")).and_then(|v| v.as_array()).cloned().unwrap_or_default(); - assert_eq!(breakpoints.len(), 1, "expected one breakpoint response: {set_bp:#}"); - let verified = breakpoints.first().and_then(|v| v.get("verified")).and_then(|v| v.as_bool()).unwrap_or(false); - assert!(verified, "breakpoint should be verified: {set_bp:#}"); - + client.send_request("setBreakpoints", json!({"source": {"path": script_path}, "breakpoints": []})); + client.expect_response_success("setBreakpoints"); client.send_request("setExceptionBreakpoints", json!({"filters": []})); client.expect_response_success("setExceptionBreakpoints"); - client.send_request("configurationDone", serde_json::Value::Null); client.expect_response_success("configurationDone"); let entry_stop = client.expect_event("stopped"); @@ -374,55 +351,30 @@ fn continue_hits_breakpoint_in_second_entrypoint() { client.send_request("continue", json!({"threadId": 1})); client.expect_response_success("continue"); - let mut stopped_reason: Option = None; - let mut stopped_line: Option = None; - let mut terminated_seen = false; - for _ in 0..16 { + let mut terminated = false; + for _ in 0..8 { let msg = client.read_message(); - if msg.get("type") == Some(&serde_json::Value::String("event".to_string())) { - let event = msg.get("event").and_then(|v| v.as_str()).unwrap_or_default(); - if event == "stopped" { - stopped_reason = msg.get("body").and_then(|v| v.get("reason")).and_then(|v| v.as_str()).map(|v| v.to_string()); - - client.send_request("stackTrace", json!({"threadId": 1})); - let stack = client.expect_response_success("stackTrace"); - stopped_line = stack - .get("body") - .and_then(|v| v.get("stackFrames")) - .and_then(|v| v.as_array()) - .and_then(|frames| frames.first()) - .and_then(|frame| frame.get("line")) - .and_then(|v| v.as_i64()); - break; - } - if event == "terminated" { - terminated_seen = true; - break; - } + if msg.get("type") != Some(&serde_json::Value::String("event".to_string())) { + continue; + } + if msg.get("event").and_then(|v| v.as_str()) == Some("terminated") { + terminated = true; + break; + } + if msg.get("event").and_then(|v| v.as_str()) == Some("stopped") { + panic!("expected successful termination, got stop event: {msg:#}"); } } - assert!( - stopped_reason.as_deref() == Some("breakpoint"), - "expected breakpoint stop after continue; stopped_reason={stopped_reason:?}, terminated_seen={terminated_seen}" - ); - assert!(stopped_line.is_some(), "expected stack frame line to be present when stopped"); + assert!(terminated, "expected debug session to terminate successfully after resolving keypair.pkh"); client.send_request("disconnect", json!({})); client.expect_response_success("disconnect"); } #[test] -fn adjacent_debug_params_file_supplies_function_and_args() { +fn continue_hits_breakpoint_in_second_entrypoint() { let script = TempScript::new(MULTIFUNCTION_IF_STATEMENTS_SCRIPT); - script.write_debug_params( - r#"{ - "function": "timeout", - "constructorArgs": ["100", "9"], - "args": ["9"] -} -"#, - ); let script_path = script.path_str(); let mut client = TestClient::spawn(); @@ -446,6 +398,9 @@ fn adjacent_debug_params_file_supplies_function_and_args() { "launch", json!({ "scriptPath": script_path, + "function": "timeout", + "constructorArgs": ["100", "9"], + "args": ["9"], "stopOnEntry": true }), ); @@ -476,57 +431,129 @@ fn adjacent_debug_params_file_supplies_function_and_args() { client.send_request("continue", json!({"threadId": 1})); client.expect_response_success("continue"); + let mut stopped_reason: Option = None; let mut stopped_line: Option = None; + let mut terminated_seen = false; for _ in 0..16 { let msg = client.read_message(); - if msg.get("type") != Some(&serde_json::Value::String("event".to_string())) { - continue; - } - - let event = msg.get("event").and_then(|v| v.as_str()).unwrap_or_default(); - if event == "terminated" { - break; - } - if event == "stopped" { - let reason = msg.get("body").and_then(|v| v.get("reason")).and_then(|v| v.as_str()).unwrap_or_default(); - assert_eq!(reason, "breakpoint", "expected breakpoint stop event: {msg:#}"); + if msg.get("type") == Some(&serde_json::Value::String("event".to_string())) { + let event = msg.get("event").and_then(|v| v.as_str()).unwrap_or_default(); + if event == "stopped" { + stopped_reason = msg.get("body").and_then(|v| v.get("reason")).and_then(|v| v.as_str()).map(|v| v.to_string()); - client.send_request("stackTrace", json!({"threadId": 1})); - let stack = client.expect_response_success("stackTrace"); - stopped_line = stack - .get("body") - .and_then(|v| v.get("stackFrames")) - .and_then(|v| v.as_array()) - .and_then(|frames| frames.first()) - .and_then(|frame| frame.get("line")) - .and_then(|v| v.as_i64()); - break; + client.send_request("stackTrace", json!({"threadId": 1})); + let stack = client.expect_response_success("stackTrace"); + stopped_line = stack + .get("body") + .and_then(|v| v.get("stackFrames")) + .and_then(|v| v.as_array()) + .and_then(|frames| frames.first()) + .and_then(|frame| frame.get("line")) + .and_then(|v| v.as_i64()); + break; + } + if event == "terminated" { + terminated_seen = true; + break; + } } } - let stopped_line = stopped_line.expect("expected sidecar-selected breakpoint stop"); - assert!((18..=27).contains(&stopped_line), "expected breakpoint inside timeout entrypoint, got line {stopped_line}",); + assert!( + stopped_reason.as_deref() == Some("breakpoint"), + "expected breakpoint stop after continue; stopped_reason={stopped_reason:?}, terminated_seen={terminated_seen}" + ); + assert!(stopped_line.is_some(), "expected stack frame line to be present when stopped"); client.send_request("disconnect", json!({})); client.expect_response_success("disconnect"); } #[test] -fn adjacent_named_debug_params_file_supplies_function_and_args() { - let script = TempScript::new(MULTIFUNCTION_IF_STATEMENTS_SCRIPT); - script.write_debug_params( - r#"{ - "function": "timeout", - "constructorArgs": { - "x": 100, - "y": 9 - }, - "args": { - "b": 9 - } +fn run_config_json_resolves_symbolic_identities() { + let script = TempScript::new(P2PKH_SCRIPT); + let config = json!({ + "scriptPath": script.path_str(), + "function": "spend", + "constructorArgs": ["keypair1.pkh"], + "args": ["keypair1.pubkey", "keypair1.secret"] + }); + + let output = std::process::Command::new(harness::resolve_debugger_dap_binary()) + .arg("--run-config-json") + .arg(config.to_string()) + .output() + .expect("failed to run debugger-dap --run-config-json"); + + assert!( + output.status.success(), + "run-config-json failed: stdout={}, stderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + String::from_utf8_lossy(&output.stdout).contains("Execution completed successfully."), + "unexpected stdout: {}", + String::from_utf8_lossy(&output.stdout) + ); } -"#, + +#[test] +fn run_config_json_accepts_identity_tokens() { + let script = TempScript::new(P2PKH_SCRIPT); + let config = json!({ + "scriptPath": script.path_str(), + "function": "spend", + "constructorArgs": ["identity1.pkh"], + "args": ["identity1.pubkey", "identity1.secret"] + }); + + let output = std::process::Command::new(harness::resolve_debugger_dap_binary()) + .arg("--run-config-json") + .arg(config.to_string()) + .output() + .expect("failed to run debugger-dap --run-config-json"); + + assert!( + output.status.success(), + "identity token run-config-json failed: stdout={}, stderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +fn run_config_json_rejects_invalid_identity_tokens() { + let script = TempScript::new(CHECKSIG_SCRIPT); + let config = json!({ + "scriptPath": script.path_str(), + "function": "main", + "constructorArgs": ["keypair1.pubkey"], + "args": ["keypair1.invalid"] + }); + + let output = std::process::Command::new(harness::resolve_debugger_dap_binary()) + .arg("--run-config-json") + .arg(config.to_string()) + .output() + .expect("failed to run debugger-dap --run-config-json"); + + assert!( + !output.status.success(), + "expected invalid identity token failure: stdout={}, stderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) ); + assert!( + String::from_utf8_lossy(&output.stderr).contains("invalid identity token"), + "unexpected stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +fn named_launch_arguments_select_breakpoint() { + let script = TempScript::new(MULTIFUNCTION_IF_STATEMENTS_SCRIPT); let script_path = script.path_str(); let mut client = TestClient::spawn(); @@ -550,6 +577,14 @@ fn adjacent_named_debug_params_file_supplies_function_and_args() { "launch", json!({ "scriptPath": script_path, + "function": "timeout", + "constructorArgs": { + "x": 100, + "y": 9 + }, + "args": { + "b": 9 + }, "stopOnEntry": true }), ); @@ -595,7 +630,7 @@ fn adjacent_named_debug_params_file_supplies_function_and_args() { } } - let stopped_line = stopped_line.expect("expected named sidecar-selected breakpoint stop"); + let stopped_line = stopped_line.expect("expected named launch-config breakpoint stop"); assert!((18..=27).contains(&stopped_line), "expected breakpoint inside timeout entrypoint, got line {stopped_line}",); client.send_request("disconnect", json!({})); diff --git a/extensions/vscode/README.md b/extensions/vscode/README.md index 96c56b91..3dacac6d 100644 --- a/extensions/vscode/README.md +++ b/extensions/vscode/README.md @@ -44,13 +44,12 @@ This extension provides a lean DAP-based contract debugger. #### Launch Flow - Run `SilverScript: Run / Debug Contract` on an open `.sil` file, or press `F5`. -- The extension opens a small runner panel with constructor args, entrypoint args, and `Run` / `Debug` buttons. -- The panel also includes a key helper that can generate a keypair and insert `secret_key`, `pubkey`, or `pkh` into the currently focused field. -- The panel persists the latest values into an adjacent `*.debug.json` file so the next run opens with the same inputs. +- The extension opens a lightweight runner panel for the current `.sil` file. +- The panel owns the current run/debug session state for constructor args and function args. +- Use `Load Saved` to pull an existing `silverscript` launch config for the current file into the panel. +- Use `Save Scenario` to write the current panel state back to `launch.json`. - The debugger launches through the bundled Rust DAP adapter when available, with repo checkouts falling back to a local workspace build. -Use `SilverScript: Open Debug Params` if you still want to edit the sidecar file directly. - If you need a custom adapter build, set `silverscript.debugAdapterPath` to an absolute path and the extension will use that binary instead. #### Parameters @@ -64,32 +63,42 @@ Launch configurations can provide: "name": "SilverScript: Debug Contract", "scriptPath": "${file}", "function": "main", - "constructorArgs": ["3", "10"], - "args": ["5", "5"], + "constructorArgs": { + "x": "3", + "y": "10" + }, + "args": { + "a": "5", + "b": "5" + }, "stopOnEntry": true } ``` -If `function`, `constructorArgs`, or `args` are omitted, the debugger also looks for an adjacent `*.debug.json` file next to the `.sil` file: +The panel does not live-edit `launch.json`. It edits the current session state and can load/save named launch configs when you want persistence. Advanced fields such as `tx` stay in `launch.json` and are preserved when a saved scenario is loaded and updated through the panel. + +For contracts that need identity-like values, launch args can use symbolic tokens instead of concrete key material: ```json { - "function": "main", - "constructorArgs": { - "x": 3, - "y": 10 - }, + "function": "spend", "args": { - "a": 5, - "b": 5 + "pk": "keypair1.pubkey", + "s": "keypair1.secret" } } ``` -The sidecar file also accepts arrays when needed, but keyed objects are easier to read and edit because names stay attached to values. +Supported identity tokens are: + +- `keypair.pubkey` +- `keypair.secret` +- `keypair.pkh` + +They are resolved lazily by the Rust runtime and stay consistent within a single launch/run only. -Launch configuration values override the sidecar file. +The panel includes an `Identities` helper that fills these tokens directly into `pubkey`, `sig`, and `pkh`-style fields. #### Transaction Context -The debugger now runs against a small synthetic transaction context by default so `sig` arguments can be auto-signed from a 32-byte secret key. Advanced users can override that runtime context by adding a `tx` object to `launch.json` or `*.debug.json`; this is intentionally kept out of the panel UI. +The debugger runs against a small synthetic transaction context by default so `sig` arguments can be auto-signed from a 32-byte secret key. Advanced users can override that runtime context by adding a `tx` object to `launch.json`; this is intentionally kept out of the panel UI. diff --git a/extensions/vscode/package.json b/extensions/vscode/package.json index 1c3b932c..b1f2a35d 100644 --- a/extensions/vscode/package.json +++ b/extensions/vscode/package.json @@ -53,11 +53,6 @@ "command": "silverscript.debug.configureLaunch", "category": "SilverScript", "title": "SilverScript: Run / Debug Contract" - }, - { - "command": "silverscript.debug.openParamsFile", - "category": "SilverScript", - "title": "SilverScript: Open Debug Params" } ], "keybindings": [ @@ -86,10 +81,6 @@ "type": "string", "description": "Path to SilverScript source file" }, - "paramsFile": { - "type": "string", - "description": "Optional path to a sidecar params file (*.debug.json)" - }, "function": { "type": "string", "description": "Entrypoint function name" @@ -195,6 +186,11 @@ "type": "boolean", "default": true, "description": "When running from a SilverScript repo checkout, build the Rust debug adapter automatically if no compatible binary is available." + }, + "silverscript.debuggerTrace": { + "type": "boolean", + "default": false, + "description": "Enable verbose SilverScript debugger adapter lifecycle logging in the output channel." } } } diff --git a/extensions/vscode/src/codeLens.ts b/extensions/vscode/src/codeLens.ts new file mode 100644 index 00000000..863127fc --- /dev/null +++ b/extensions/vscode/src/codeLens.ts @@ -0,0 +1,157 @@ +import * as vscode from "vscode"; +import { countSilverScriptSavedScenarios } from "./launchConfigs"; +import { + hasOpenSilverScriptPanelForUri, + onDidChangeSilverScriptPanelState, +} from "./quickLaunchPanel"; + +const CONTRACT_RE = /^\s*contract\s+([A-Za-z_]\w*)\s*\(/; +const ENTRYPOINT_RE = + /^\s*entrypoint\s+function\s+([A-Za-z_]\w*)\s*\(/; + +type EntrypointTarget = { + functionName: string; + range: vscode.Range; +}; + +function findContractRange( + document: vscode.TextDocument, +): vscode.Range | undefined { + for (let line = 0; line < document.lineCount; line += 1) { + const text = document.lineAt(line).text; + const contractMatch = CONTRACT_RE.exec(text); + if (!contractMatch) { + continue; + } + + const start = new vscode.Position( + line, + contractMatch[0].search(/\S|$/), + ); + return new vscode.Range(start, start); + } + + return undefined; +} + +function findEntrypointTargets( + document: vscode.TextDocument, +): EntrypointTarget[] { + const targets: EntrypointTarget[] = []; + + for (let line = 0; line < document.lineCount; line += 1) { + const text = document.lineAt(line).text; + const entrypointMatch = ENTRYPOINT_RE.exec(text); + if (!entrypointMatch) { + continue; + } + + const start = new vscode.Position( + line, + entrypointMatch[0].search(/\S|$/), + ); + targets.push({ + functionName: entrypointMatch[1], + range: new vscode.Range(start, start), + }); + } + + return targets; +} + +function savedLensTitle(count: number): string { + return count === 1 + ? "1 scenario saved" + : `${count} scenarios saved`; +} + +function primaryLensTitle(document: vscode.TextDocument): string { + return hasOpenSilverScriptPanelForUri(document.uri) + ? "Run" + : "Open Debug Panel..."; +} + +class SilverScriptCodeLensProvider + implements vscode.CodeLensProvider +{ + private readonly onDidChangeEmitter = + new vscode.EventEmitter(); + + readonly onDidChangeCodeLenses = this.onDidChangeEmitter.event; + + triggerRefresh(): void { + this.onDidChangeEmitter.fire(); + } + + provideCodeLenses( + document: vscode.TextDocument, + ): vscode.CodeLens[] { + if (document.languageId !== "silverscript") { + return []; + } + + const contractRange = findContractRange(document); + const entrypointTargets = findEntrypointTargets(document); + if (!contractRange && entrypointTargets.length === 0) { + return []; + } + + const counts = countSilverScriptSavedScenarios(document.uri); + const lenses: vscode.CodeLens[] = []; + + if (contractRange) { + lenses.push( + new vscode.CodeLens(contractRange, { + title: primaryLensTitle(document), + command: "silverscript.debug.primaryCodeLensAction", + arguments: [document.uri], + }), + ); + } + + for (const target of entrypointTargets) { + const count = counts.byFunction[target.functionName] ?? 0; + lenses.push( + new vscode.CodeLens(target.range, { + title: savedLensTitle(count), + command: "silverscript.debug.showSavedScenarios", + arguments: [document.uri, target.functionName, count > 0], + }), + ); + } + + return lenses; + } +} + +export function registerSilverScriptCodeLens( + context: vscode.ExtensionContext, +): void { + const provider = new SilverScriptCodeLensProvider(); + + context.subscriptions.push( + vscode.languages.registerCodeLensProvider( + { language: "silverscript" }, + provider, + ), + ); + context.subscriptions.push( + onDidChangeSilverScriptPanelState(() => { + provider.triggerRefresh(); + }), + ); + context.subscriptions.push( + vscode.workspace.onDidChangeTextDocument((event) => { + if (event.document.languageId === "silverscript") { + provider.triggerRefresh(); + } + }), + ); + context.subscriptions.push( + vscode.workspace.onDidChangeConfiguration((event) => { + if (event.affectsConfiguration("launch")) { + provider.triggerRefresh(); + } + }), + ); +} diff --git a/extensions/vscode/src/contractModel.ts b/extensions/vscode/src/contractModel.ts index 5fe31549..0b232da0 100644 --- a/extensions/vscode/src/contractModel.ts +++ b/extensions/vscode/src/contractModel.ts @@ -1,6 +1,3 @@ -import * as fs from "fs/promises"; -import * as path from "path"; - export type ContractParam = { name: string; type: string }; export type Entrypoint = { name: string; params: ContractParam[] }; export type ContractModel = { @@ -38,18 +35,6 @@ export type DebugTxScenario = { outputs: DebugTxOutput[]; }; -export type DebugParamsFile = { - function?: string; - constructorArgs?: DebugArgInput; - args?: DebugArgInput; - tx?: DebugTxScenario; -}; - -export function inferDebugParamsPath(scriptPath: string): string { - const base = path.basename(scriptPath, path.extname(scriptPath)); - return path.join(path.dirname(scriptPath), `${base}.debug.json`); -} - function stripComments(source: string): string { return source .replace(/\/\*[\s\S]*?\*\//g, "") @@ -145,57 +130,3 @@ function isDebugArgObject(value: unknown): value is DebugArgObject { !Array.isArray(value) ); } - -export async function readDebugParams( - scriptPath: string, - explicitPath?: string, -): Promise { - const paramsPath = explicitPath ?? inferDebugParamsPath(scriptPath); - try { - const parsed = JSON.parse( - await fs.readFile(paramsPath, "utf8"), - ) as Record; - return { - function: - typeof parsed.function === "string" - ? parsed.function - : undefined, - constructorArgs: - Array.isArray(parsed.constructorArgs) || - isDebugArgObject(parsed.constructorArgs) - ? (parsed.constructorArgs as DebugArgInput) - : Array.isArray(parsed.constructor_args) || - isDebugArgObject(parsed.constructor_args) - ? (parsed.constructor_args as DebugArgInput) - : undefined, - args: - Array.isArray(parsed.args) || isDebugArgObject(parsed.args) - ? (parsed.args as DebugArgInput) - : undefined, - tx: - parsed.tx && typeof parsed.tx === "object" - ? (parsed.tx as DebugTxScenario) - : undefined, - }; - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === "ENOENT") { - return undefined; - } - throw error; - } -} - -export async function writeDebugParams( - scriptPath: string, - params: DebugParamsFile, - explicitPath?: string, -): Promise { - const paramsPath = explicitPath ?? inferDebugParamsPath(scriptPath); - await fs.writeFile( - paramsPath, - JSON.stringify(params, null, 2) + "\n", - "utf8", - ); - return paramsPath; -} diff --git a/extensions/vscode/src/debug.ts b/extensions/vscode/src/debug.ts index 7e873ae9..75ff884a 100644 --- a/extensions/vscode/src/debug.ts +++ b/extensions/vscode/src/debug.ts @@ -1,14 +1,15 @@ import * as fs from "fs"; -import * as path from "path"; import * as vscode from "vscode"; import { defaultsObjectFromParams, type DebugArgInput, - inferDebugParamsPath, parseContractModel, - readDebugParams, } from "./contractModel"; -import { ensureDebuggerAdapterBinary } from "./debugAdapter"; +import { + debuggerTraceEnabled, + ensureDebuggerAdapterBinary, +} from "./debugAdapter"; +import { resolveLaunchScriptPath } from "./launchConfigs"; function isDebugArgInput(value: unknown): value is DebugArgInput { return ( @@ -49,54 +50,23 @@ function ensureTrustedWorkspace(feature: string): boolean { return false; } -async function ensureDebugParamsFile( - scriptUri: vscode.Uri, -): Promise<{ - path: string; - created: boolean; -}> { - const scriptPath = scriptUri.fsPath; - const paramsPath = inferDebugParamsPath(scriptPath); - if (fs.existsSync(paramsPath)) { - return { path: paramsPath, created: false }; - } - - const source = await fs.promises.readFile(scriptPath, "utf8"); - const model = parseContractModel(source); - const functionName = model.entrypoints[0]?.name; - const entrypoint = functionName - ? model.entrypoints.find((item) => item.name === functionName) - : undefined; - const template = { - function: functionName, - constructorArgs: defaultsObjectFromParams(model.constructorParams), - args: defaultsObjectFromParams(entrypoint?.params ?? []), - }; - - await fs.promises.writeFile( - paramsPath, - JSON.stringify(template, null, 2) + "\n", - "utf8", +function resolveConfigScriptPath( + raw: string, + folder: vscode.WorkspaceFolder | undefined, +): string | undefined { + return resolveLaunchScriptPath( + raw, + folder, + resolveActiveScriptUri(), ); - return { path: paramsPath, created: true }; } -async function openDebugParamsFile( - scriptUri: vscode.Uri, -): Promise { - const result = await ensureDebugParamsFile(scriptUri); - const doc = await vscode.workspace.openTextDocument(result.path); - await vscode.window.showTextDocument(doc, { - preview: false, - preserveFocus: false, - viewColumn: vscode.ViewColumn.Beside, - }); - - if (result.created) { - void vscode.window.showInformationMessage( - `Created ${path.basename(result.path)} for debug arguments.`, - ); - } +function isBenignAdapterShutdownError( + error: Error, + exitCode: number | undefined, +): boolean { + const isReadError = error.message.trim().toLowerCase() === "read error"; + return isReadError && (exitCode === 0 || !debuggerTraceEnabled()); } class SilverScriptDebugAdapterFactory @@ -116,7 +86,9 @@ class SilverScriptDebugAdapterFactory this.ctx, this.out, ); - this.out.appendLine(`[debug] launching ${bin} [${source}]`); + if (debuggerTraceEnabled()) { + this.out.appendLine(`[debug] launching ${bin} [${source}]`); + } return new vscode.DebugAdapterExecutable(bin, [], { cwd: root, }); @@ -143,31 +115,25 @@ class SilverScriptConfigProvider } private async applyContractDefaults( + folder: vscode.WorkspaceFolder | undefined, config: vscode.DebugConfiguration, ): Promise { if (typeof config.scriptPath !== "string" || !config.scriptPath.trim()) { return; } - const source = await fs.promises.readFile(config.scriptPath, "utf8"); - const model = parseContractModel(source); - const paramsFile = - typeof config.paramsFile === "string" && config.paramsFile.trim() - ? config.paramsFile - : undefined; - const debugParams = await readDebugParams( + const resolvedScriptPath = resolveConfigScriptPath( config.scriptPath, - paramsFile, + folder, ); - - if (!config.function && debugParams?.function) { - config.function = debugParams.function; + if (!resolvedScriptPath) { + throw new Error(`Unable to resolve scriptPath '${config.scriptPath}'.`); } - const hasCtorArgs = isDebugArgInput(config.constructorArgs); - if (!hasCtorArgs && debugParams?.constructorArgs !== undefined) { - config.constructorArgs = debugParams.constructorArgs; - } + config.scriptPath = resolvedScriptPath; + const source = await fs.promises.readFile(config.scriptPath, "utf8"); + const model = parseContractModel(source); + if (!isDebugArgInput(config.constructorArgs)) { config.constructorArgs = defaultsObjectFromParams( model.constructorParams, @@ -178,10 +144,6 @@ class SilverScriptConfigProvider config.function = model.entrypoints[0].name; } - const hasArgs = isDebugArgInput(config.args); - if (!hasArgs && debugParams?.args !== undefined) { - config.args = debugParams.args; - } if (!isDebugArgInput(config.args) && config.function) { const entrypoint = model.entrypoints.find( (item) => item.name === config.function, @@ -216,17 +178,15 @@ class SilverScriptConfigProvider return config; } - for (const key of ["scriptPath", "paramsFile"] as const) { - if (typeof config[key] === "string") { - const expanded = expandActiveFileVariable(config[key] as string); - if (expanded === undefined) { - vscode.window.showErrorMessage( - "No active file to resolve ${file}.", - ); - return null; - } - config[key] = expanded; + if (typeof config.scriptPath === "string") { + const expanded = expandActiveFileVariable(config.scriptPath); + if (expanded === undefined) { + vscode.window.showErrorMessage( + "No active file to resolve ${file}.", + ); + return null; } + config.scriptPath = expanded; } if (!config.scriptPath) { @@ -237,7 +197,7 @@ class SilverScriptConfigProvider } try { - await this.applyContractDefaults(config); + await this.applyContractDefaults(_folder, config); } catch (error) { vscode.window.showErrorMessage( `SilverScript debug configuration failed: ${(error as Error).message}`, @@ -273,42 +233,35 @@ export function registerSilverScriptDebugger( vscode.debug.registerDebugAdapterTrackerFactory( "silverscript", { - createDebugAdapterTracker: () => ({ - onWillStartSession: () => - out.appendLine("[debug] session starting"), - onError: (error: Error) => - out.appendLine(`[debug] error: ${error}`), - onExit: ( - code: number | undefined, - signal: string | undefined, - ) => { - out.appendLine( - `[debug] exit: code=${code}, signal=${signal}`, - ); - }, - }), - }, - ), - ); - ctx.subscriptions.push( - vscode.commands.registerCommand( - "silverscript.debug.openParamsFile", - async (uri?: vscode.Uri) => { - const scriptUri = resolveActiveScriptUri(uri); - if (!scriptUri) { - vscode.window.showErrorMessage( - "Open a .sil file to edit SilverScript debug arguments.", - ); - return; - } + createDebugAdapterTracker: () => { + let exitCode: number | undefined; - try { - await openDebugParamsFile(scriptUri); - } catch (error) { - vscode.window.showErrorMessage( - `Failed to open debug params: ${(error as Error).message}`, - ); - } + return { + onWillStartSession: () => { + if (debuggerTraceEnabled()) { + out.appendLine("[debug] session starting"); + } + }, + onError: (error: Error) => { + if (isBenignAdapterShutdownError(error, exitCode)) { + return; + } + out.appendLine(`[debug] error: ${error}`); + }, + onExit: ( + code: number | undefined, + signal: string | undefined, + ) => { + exitCode = code; + if (code === 0 && !signal && !debuggerTraceEnabled()) { + return; + } + out.appendLine( + `[debug] exit: code=${code}, signal=${signal}`, + ); + }, + }; + }, }, ), ); diff --git a/extensions/vscode/src/debugAdapter.ts b/extensions/vscode/src/debugAdapter.ts index 24993207..0f460f5e 100644 --- a/extensions/vscode/src/debugAdapter.ts +++ b/extensions/vscode/src/debugAdapter.ts @@ -7,6 +7,13 @@ import * as vscode from "vscode"; const autoBuildAttempted = new Set(); const ADAPTER_BASENAME = process.platform === "win32" ? "debugger-dap.exe" : "debugger-dap"; +const SUCCESS_MESSAGE = "Execution completed successfully."; + +export function debuggerTraceEnabled(): boolean { + return vscode.workspace + .getConfiguration("silverscript") + .get("debuggerTrace", false); +} function findWorkspaceRoot(): string | undefined { const activeUri = vscode.window.activeTextEditor?.document.uri; @@ -40,6 +47,45 @@ function workspaceBinaryCandidates(root: string): string[] { ); } +function newestMtimeInPath(targetPath: string): number { + if (!fs.existsSync(targetPath)) { + return 0; + } + + const stat = fs.statSync(targetPath); + if (!stat.isDirectory()) { + return stat.mtimeMs; + } + + let newest = stat.mtimeMs; + for (const entry of fs.readdirSync(targetPath)) { + newest = Math.max( + newest, + newestMtimeInPath(path.join(targetPath, entry)), + ); + } + return newest; +} + +function newestDebuggerSourceMtime(root: string): number { + return Math.max( + newestMtimeInPath(path.join(root, "Cargo.toml")), + newestMtimeInPath(path.join(root, "debugger", "dap", "Cargo.toml")), + newestMtimeInPath(path.join(root, "debugger", "dap", "src")), + ); +} + +function workspaceBinaryNeedsBuild( + root: string, + binaryPath: string | undefined, +): boolean { + if (!binaryPath || !fs.existsSync(binaryPath)) { + return true; + } + + return fs.statSync(binaryPath).mtimeMs < newestDebuggerSourceMtime(root); +} + function bundledBinaryCandidates( ctx: vscode.ExtensionContext, ): string[] { @@ -164,6 +210,7 @@ export async function ensureDebuggerAdapterBinary( out?: vscode.OutputChannel, ): Promise<{ root: string; bin: string; source: string }> { const root = resolveRepoRoot(ctx); + const hasWorkspaceLayout = hasDebuggerWorkspaceLayout(root); const configuredCandidates = configuredAdapterCandidates(ctx); if (configuredCandidates.length > 0) { @@ -180,20 +227,17 @@ export async function ensureDebuggerAdapterBinary( }; } - const bundled = findExistingFile(bundledBinaryCandidates(ctx)); - if (bundled) { - return { - root: path.dirname(bundled), - bin: bundled, - source: "bundled", - }; - } + const allowAutoBuild = vscode.workspace + .getConfiguration("silverscript") + .get("autoBuildDebuggerAdapter", true); - const hasWorkspaceLayout = hasDebuggerWorkspaceLayout(root); const existingWorkspaceBinary = hasWorkspaceLayout ? findExistingFile(workspaceBinaryCandidates(root)) : undefined; - if (existingWorkspaceBinary) { + if ( + existingWorkspaceBinary && + !workspaceBinaryNeedsBuild(root, existingWorkspaceBinary) + ) { return { root, bin: existingWorkspaceBinary, @@ -201,10 +245,6 @@ export async function ensureDebuggerAdapterBinary( }; } - const allowAutoBuild = vscode.workspace - .getConfiguration("silverscript") - .get("autoBuildDebuggerAdapter", true); - if ( hasWorkspaceLayout && allowAutoBuild && @@ -222,7 +262,7 @@ export async function ensureDebuggerAdapterBinary( message: `${cmd} ${args.join(" ")}`, }); out?.appendLine( - `[debug] adapter missing, running: ${cmd} ${args.join(" ")} (cwd=${root})`, + `[debug] adapter missing or stale, running: ${cmd} ${args.join(" ")} (cwd=${root})`, ); return spawnCommand(cmd, args, root); }); @@ -248,6 +288,15 @@ export async function ensureDebuggerAdapterBinary( }; } + const bundled = findExistingFile(bundledBinaryCandidates(ctx)); + if (bundled) { + return { + root: path.dirname(bundled), + bin: bundled, + source: "bundled", + }; + } + const target = currentPlatformTarget(); const installMessage = `No bundled SilverScript debug adapter was found for ${target}. ` + @@ -278,17 +327,26 @@ export async function runDebuggerAdapterCommand( ctx, out, ); - out?.appendLine(`[debug] running ${bin} ${args.join(" ")} [${source}]`); + const traceEnabled = debuggerTraceEnabled(); + if (traceEnabled) { + out?.appendLine(`[debug] running ${bin} ${args.join(" ")} [${source}]`); + } const result = await spawnCommand(bin, args, root); - if (result.stdout) { - out?.appendLine(result.stdout); - } if (result.stderr) { out?.appendLine(result.stderr); } + const stdout = (result.stdout ?? "").trim(); + if ( + result.stdout && + (result.status !== 0 || + traceEnabled || + stdout !== SUCCESS_MESSAGE) + ) { + out?.appendLine(result.stdout); + } if (result.status !== 0) { throw new Error(summarizeCommandFailure(bin, args, result)); } - return (result.stdout ?? "").trim(); + return stdout; } diff --git a/extensions/vscode/src/extension.ts b/extensions/vscode/src/extension.ts index f64d31f5..76890910 100644 --- a/extensions/vscode/src/extension.ts +++ b/extensions/vscode/src/extension.ts @@ -3,6 +3,7 @@ import * as path from "path"; import * as fs from "fs/promises"; import { Language, Parser, Query } from "web-tree-sitter"; import type { QueryCapture } from "web-tree-sitter"; +import { registerSilverScriptCodeLens } from "./codeLens"; import { registerSilverScriptDebugger } from "./debug"; import { registerSilverScriptQuickLaunchPanel } from "./quickLaunchPanel"; @@ -396,6 +397,7 @@ export function activate(context: vscode.ExtensionContext) { registerSilverScriptDebugger(context, debugOutputChannel); registerSilverScriptQuickLaunchPanel(context, debugOutputChannel); + registerSilverScriptCodeLens(context); // TODO: add LSP (LanguageClient + LanguageServer) diff --git a/extensions/vscode/src/launchConfigs.ts b/extensions/vscode/src/launchConfigs.ts new file mode 100644 index 00000000..41f07f33 --- /dev/null +++ b/extensions/vscode/src/launchConfigs.ts @@ -0,0 +1,254 @@ +import * as path from "path"; +import * as vscode from "vscode"; + +export type RawLaunchConfiguration = vscode.DebugConfiguration & Record; + +export type SilverScriptLaunchConfigRecord = { + id: string; + folder: vscode.WorkspaceFolder; + index: number; + config: RawLaunchConfiguration; + scriptPathValue: string; + resolvedScriptPath: string; +}; + +export type SilverScriptSavedScenarioCounts = { + total: number; + byFunction: Record; +}; + +function normalizePath(fsPath: string): string { + const normalized = path.normalize(fsPath); + return process.platform === "win32" + ? normalized.toLowerCase() + : normalized; +} + +function expandPathVariables( + raw: string, + folder?: vscode.WorkspaceFolder, + activeScriptUri?: vscode.Uri, +): string | undefined { + let expanded = raw; + + if (folder) { + expanded = expanded.replaceAll("${workspaceFolder}", folder.uri.fsPath); + expanded = expanded.replaceAll( + "${workspaceFolderBasename}", + path.basename(folder.uri.fsPath), + ); + } + + if (activeScriptUri?.fsPath) { + expanded = expanded.replaceAll("${file}", activeScriptUri.fsPath); + } + + if (expanded.includes("${")) { + return undefined; + } + + return expanded; +} + +export function resolveLaunchScriptPath( + raw: string, + folder?: vscode.WorkspaceFolder, + activeScriptUri?: vscode.Uri, +): string | undefined { + const expanded = expandPathVariables(raw, folder, activeScriptUri); + if (!expanded) { + return undefined; + } + + const candidate = path.isAbsolute(expanded) + ? expanded + : folder + ? path.resolve(folder.uri.fsPath, expanded) + : path.resolve(expanded); + return path.normalize(candidate); +} + +export function launchConfigMatchesScript( + record: SilverScriptLaunchConfigRecord, + scriptUri: vscode.Uri, +): boolean { + return normalizePath(record.resolvedScriptPath) === normalizePath(scriptUri.fsPath); +} + +export function defaultLaunchScriptPathValue( + scriptUri: vscode.Uri, + folder: vscode.WorkspaceFolder, +): string { + const relative = path.relative(folder.uri.fsPath, scriptUri.fsPath); + if ( + !relative || + relative.startsWith("..") || + path.isAbsolute(relative) + ) { + return scriptUri.fsPath; + } + return relative; +} + +function readFolderLaunchConfigurations( + folder: vscode.WorkspaceFolder, +): RawLaunchConfiguration[] { + return vscode.workspace + .getConfiguration("launch", folder.uri) + .get("configurations", []); +} + +async function writeFolderLaunchConfigurations( + folder: vscode.WorkspaceFolder, + configs: RawLaunchConfiguration[], +): Promise { + await vscode.workspace + .getConfiguration("launch", folder.uri) + .update( + "configurations", + configs, + vscode.ConfigurationTarget.WorkspaceFolder, + ); +} + +export function listSilverScriptLaunchConfigs( + activeScriptUri?: vscode.Uri, +): SilverScriptLaunchConfigRecord[] { + const folders = vscode.workspace.workspaceFolders ?? []; + const records: SilverScriptLaunchConfigRecord[] = []; + + for (const folder of folders) { + const configs = readFolderLaunchConfigurations(folder); + configs.forEach((config, index) => { + if ( + config.type !== "silverscript" || + config.request !== "launch" || + typeof config.scriptPath !== "string" + ) { + return; + } + + const scriptPathValue = config.scriptPath.trim(); + if (!scriptPathValue) { + return; + } + + const resolvedScriptPath = resolveLaunchScriptPath( + scriptPathValue, + folder, + activeScriptUri, + ); + if (!resolvedScriptPath) { + return; + } + + records.push({ + id: `${folder.uri.toString()}::${index}`, + folder, + index, + config, + scriptPathValue, + resolvedScriptPath, + }); + }); + } + + return records; +} + +export function listMatchingSilverScriptLaunchConfigs( + scriptUri: vscode.Uri, +): SilverScriptLaunchConfigRecord[] { + return listSilverScriptLaunchConfigs(scriptUri).filter((record) => + launchConfigMatchesScript(record, scriptUri), + ); +} + +export function countSilverScriptSavedScenarios( + scriptUri: vscode.Uri, +): SilverScriptSavedScenarioCounts { + const records = listMatchingSilverScriptLaunchConfigs(scriptUri); + const byFunction: Record = {}; + + for (const record of records) { + const functionName = + typeof record.config.function === "string" + ? record.config.function.trim() + : ""; + if (!functionName) { + continue; + } + + byFunction[functionName] = (byFunction[functionName] ?? 0) + 1; + } + + return { + total: records.length, + byFunction, + }; +} + +export async function updateSilverScriptLaunchConfig( + record: SilverScriptLaunchConfigRecord, + nextConfig: RawLaunchConfiguration, +): Promise { + const configs = [...readFolderLaunchConfigurations(record.folder)]; + if (record.index >= configs.length) { + throw new Error(`Launch config '${record.config.name ?? record.id}' no longer exists.`); + } + + configs[record.index] = nextConfig; + await writeFolderLaunchConfigurations(record.folder, configs); + + return { + ...record, + config: nextConfig, + scriptPathValue: + typeof nextConfig.scriptPath === "string" + ? nextConfig.scriptPath + : record.scriptPathValue, + resolvedScriptPath: resolveLaunchScriptPath( + String(nextConfig.scriptPath ?? record.scriptPathValue), + record.folder, + ) ?? record.resolvedScriptPath, + }; +} + +export async function createSilverScriptLaunchConfig( + folder: vscode.WorkspaceFolder, + config: RawLaunchConfiguration, +): Promise { + const configs = [...readFolderLaunchConfigurations(folder), config]; + const index = configs.length - 1; + await writeFolderLaunchConfigurations(folder, configs); + + const scriptPathValue = String(config.scriptPath ?? "").trim(); + const resolvedScriptPath = resolveLaunchScriptPath( + scriptPathValue, + folder, + ); + if (!resolvedScriptPath) { + throw new Error(`Unable to resolve scriptPath '${scriptPathValue}'.`); + } + + return { + id: `${folder.uri.toString()}::${index}`, + folder, + index, + config, + scriptPathValue, + resolvedScriptPath, + }; +} + +export async function deleteSilverScriptLaunchConfig( + record: SilverScriptLaunchConfigRecord, +): Promise { + const configs = [...readFolderLaunchConfigurations(record.folder)]; + if (record.index >= configs.length) { + throw new Error(`Launch config '${record.config.name ?? record.id}' no longer exists.`); + } + + configs.splice(record.index, 1); + await writeFolderLaunchConfigurations(record.folder, configs); +} diff --git a/extensions/vscode/src/quickLaunchPanel.ts b/extensions/vscode/src/quickLaunchPanel.ts index fb62db8b..c9cb1564 100644 --- a/extensions/vscode/src/quickLaunchPanel.ts +++ b/extensions/vscode/src/quickLaunchPanel.ts @@ -1,57 +1,106 @@ import * as fs from "fs"; +import * as path from "path"; import * as vscode from "vscode"; import { defaultForType, type DebugArgInput, - type DebugParamsFile, + type DebugArgObject, parseContractModel, - readDebugParams, - writeDebugParams, type ContractModel, type ContractParam, - type DebugArgObject, } from "./contractModel"; import { runDebuggerAdapterCommand } from "./debugAdapter"; - -type LaunchPanelMessage = { - kind: "run" | "debug"; +import { + countSilverScriptSavedScenarios, + createSilverScriptLaunchConfig, + defaultLaunchScriptPathValue, + listMatchingSilverScriptLaunchConfigs, + type RawLaunchConfiguration, + type SilverScriptLaunchConfigRecord, + updateSilverScriptLaunchConfig, +} from "./launchConfigs"; + +type LaunchKind = "run" | "debug"; +type IdentityLabels = Record; + +type PanelFormState = { function: string; - constructorArgs: DebugArgObject; - args: DebugArgObject; + constructorArgs: Record; + argsByFunction: Record>; + keyAliases: string[]; + identityLabels: IdentityLabels; }; -type KeygenPanelMessage = { kind: "generateKeyMaterial" }; -type PanelMessage = LaunchPanelMessage | KeygenPanelMessage; -type GeneratedKeyMaterial = { - pubkey: string; - secret_key: string; - pkh: string; +type PanelHostState = { + scriptUri: vscode.Uri; + model: ContractModel; + form: PanelFormState; + baseConfig: RawLaunchConfiguration; + record?: SilverScriptLaunchConfigRecord; + loadedConfigName: string | null; +}; + +type PanelMessage = + | { kind: "run"; form: PanelFormState } + | { kind: "debug"; form: PanelFormState } + | { kind: "loadSaved"; form: PanelFormState } + | { kind: "saveSaved"; form: PanelFormState }; + +type PanelControlMessage = { + kind: "triggerLaunch"; + launchKind: LaunchKind; }; type WebviewState = { function: string; constructorArgs: Record; argsByFunction: Record>; - keys: GeneratedKeyMaterial[]; + keyAliases: string[]; + identityLabels: IdentityLabels; + loadedConfigName: string | null; + savedCountsByFunction: Record; + savedTotalCount: number; }; -type WebviewMessage = - | { kind: "keyMaterial"; keyMaterial: GeneratedKeyMaterial } - | { kind: "error"; message: string }; -type PanelControlMessage = { - kind: "triggerLaunch"; - launchKind: "run" | "debug"; -}; +const RUN_SUCCESS_MESSAGE = "Execution completed successfully."; let panel: vscode.WebviewPanel | undefined; -let activeScriptUri: vscode.Uri | undefined; +let activeState: PanelHostState | undefined; let launchInProgress = false; +let restoringPrimaryEditor = false; +const panelStateEmitter = new vscode.EventEmitter(); +const IDENTITY_ALIAS_RE = + /^(?:keypair|identity)([1-9]\d*)(?:\.(pubkey|secret|pkh))?$/; + +function emitPanelStateChanged(): void { + panelStateEmitter.fire(); +} + +export const onDidChangeSilverScriptPanelState = + panelStateEmitter.event; + +function isDebugArgInput(value: unknown): value is DebugArgInput { + return ( + Array.isArray(value) || + (value !== null && typeof value === "object") + ); +} function activeSilverScriptSession(): vscode.DebugSession | undefined { const session = vscode.debug.activeDebugSession; return session?.type === "silverscript" ? session : undefined; } +export function hasOpenSilverScriptPanelForUri( + uri?: vscode.Uri, +): boolean { + if (!panel || !activeState || !uri) { + return false; + } + + return activeState.scriptUri.fsPath === uri.fsPath; +} + function resolveActiveScriptUri(uri?: vscode.Uri): vscode.Uri | undefined { if (uri) { return uri; @@ -99,15 +148,23 @@ function stringifyLaunchArg(value: unknown): string { if (typeof value === "string") { return value; } - if (Array.isArray(value) || (value !== null && typeof value === "object")) { + if ( + Array.isArray(value) || + (value !== null && typeof value === "object") + ) { return JSON.stringify(value); } return String(value); } -function defaultsForParams(params: ContractParam[]): Record { +function defaultsForParams( + params: ContractParam[], +): Record { return Object.fromEntries( - params.map((param) => [param.name, stringifyLaunchArg(defaultForType(param.type))]), + params.map((param) => [ + param.name, + stringifyLaunchArg(defaultForType(param.type)), + ]), ); } @@ -134,6 +191,644 @@ function valuesForParams( return defaults; } +function defaultLaunchName( + model: ContractModel, + scriptPath: string, +): string { + return model.name && model.name !== "Unknown" + ? `SilverScript: ${model.name}` + : `SilverScript: ${path.basename(scriptPath)}`; +} + +function normalizeKeyAliases( + aliases: readonly string[], + constructorArgs: Record, + argsByFunction: Record>, +): string[] { + const found = new Map(); + + const consider = (raw: string | undefined) => { + if (!raw) { + return; + } + const match = IDENTITY_ALIAS_RE.exec(raw.trim()); + if (!match) { + return; + } + const index = Number(match[1]); + if (!found.has(index)) { + found.set(index, `keypair${index}`); + } + }; + + aliases.forEach(consider); + Object.values(constructorArgs).forEach((value) => consider(value)); + Object.values(argsByFunction).forEach((args) => { + Object.values(args).forEach((value) => consider(value)); + }); + + const normalized = [...found.entries()] + .sort((left, right) => left[0] - right[0]) + .map(([, alias]) => alias); + return normalized; +} + +function normalizeIdentityLabels( + aliases: readonly string[], + labels: IdentityLabels, +): IdentityLabels { + const normalized: IdentityLabels = {}; + for (const alias of aliases) { + const label = labels[alias]?.trim(); + if (label && label !== alias) { + normalized[alias] = label; + } + } + return normalized; +} + +async function focusPrimaryEditor( + scriptUri: vscode.Uri, +): Promise { + if (restoringPrimaryEditor) { + return; + } + + restoringPrimaryEditor = true; + try { + const document = await vscode.workspace.openTextDocument(scriptUri); + await vscode.window.showTextDocument(document, { + viewColumn: vscode.ViewColumn.One, + preview: false, + preserveFocus: false, + }); + } finally { + restoringPrimaryEditor = false; + } +} + +async function keepSilverScriptEditorOnPrimary( + editor: vscode.TextEditor | undefined, +): Promise { + if ( + restoringPrimaryEditor || + !panel || + !editor || + editor.document.languageId !== "silverscript" || + editor.viewColumn === vscode.ViewColumn.One || + panel.viewColumn === undefined || + editor.viewColumn !== panel.viewColumn + ) { + return; + } + + await focusPrimaryEditor(editor.document.uri); + if (panel) { + panel.reveal(vscode.ViewColumn.Beside, true); + } +} + +async function followActiveSilverScript( + editor: vscode.TextEditor | undefined, +): Promise { + if ( + !panel || + !editor || + editor.document.languageId !== "silverscript" || + !activeState || + activeState.scriptUri.fsPath === editor.document.uri.fsPath + ) { + return; + } + + activeState = await buildInitialState(editor.document.uri); + emitPanelStateChanged(); + await renderActiveState(); +} + +async function handleActiveEditorChange( + editor: vscode.TextEditor | undefined, +): Promise { + await keepSilverScriptEditorOnPrimary(editor); + await followActiveSilverScript(editor); +} + +function defaultPanelFormState( + model: ContractModel, + initialFunction?: string, +): PanelFormState { + const selectedFunction = + initialFunction && + model.entrypoints.some((entry) => entry.name === initialFunction) + ? initialFunction + : model.entrypoints[0]?.name ?? ""; + + const argsByFunction: Record> = {}; + for (const entrypoint of model.entrypoints) { + argsByFunction[entrypoint.name] = defaultsForParams( + entrypoint.params, + ); + } + + return { + function: selectedFunction, + constructorArgs: defaultsForParams(model.constructorParams), + argsByFunction, + keyAliases: normalizeKeyAliases( + [], + defaultsForParams(model.constructorParams), + argsByFunction, + ), + identityLabels: {}, + }; +} + +function formFromLaunchConfig( + model: ContractModel, + config: RawLaunchConfiguration, + initialFunction: string | undefined, + keyAliases: string[], + identityLabels: IdentityLabels, +): PanelFormState { + const configuredFunction = + typeof config.function === "string" ? config.function : undefined; + const selectedFunction = + initialFunction && + model.entrypoints.some((entry) => entry.name === initialFunction) + ? initialFunction + : configuredFunction && + model.entrypoints.some( + (entry) => entry.name === configuredFunction, + ) + ? configuredFunction + : model.entrypoints[0]?.name ?? ""; + + const constructorArgs = isDebugArgInput(config.constructorArgs) + ? config.constructorArgs + : undefined; + const configuredArgs = + configuredFunction === selectedFunction && + isDebugArgInput(config.args) + ? config.args + : undefined; + + const argsByFunction: Record> = {}; + for (const entrypoint of model.entrypoints) { + argsByFunction[entrypoint.name] = valuesForParams( + entrypoint.params, + entrypoint.name === selectedFunction ? configuredArgs : undefined, + ); + } + + const constructorValues = valuesForParams( + model.constructorParams, + constructorArgs, + ); + const normalizedAliases = normalizeKeyAliases( + keyAliases, + constructorValues, + argsByFunction, + ); + const form = { + function: selectedFunction, + constructorArgs: constructorValues, + argsByFunction, + keyAliases: normalizedAliases, + identityLabels: normalizeIdentityLabels( + normalizedAliases, + identityLabels, + ), + }; + return form; +} + +function currentArgs( + form: PanelFormState, +): DebugArgObject { + return { ...(form.argsByFunction[form.function] ?? {}) }; +} + +function applyMessageState( + state: PanelHostState, + form: PanelFormState, +): void { + const normalizedAliases = normalizeKeyAliases( + form.keyAliases, + form.constructorArgs, + form.argsByFunction, + ); + state.form = { + function: form.function, + constructorArgs: { ...form.constructorArgs }, + argsByFunction: Object.fromEntries( + Object.entries(form.argsByFunction).map( + ([entrypoint, args]) => [entrypoint, { ...args }], + ), + ), + keyAliases: normalizedAliases, + identityLabels: normalizeIdentityLabels( + normalizedAliases, + form.identityLabels, + ), + }; +} + +function matchingLaunchConfigs( + scriptUri: vscode.Uri, +): SilverScriptLaunchConfigRecord[] { + return listMatchingSilverScriptLaunchConfigs(scriptUri); +} + +async function readModel( + scriptUri: vscode.Uri, +): Promise { + const source = await fs.promises.readFile(scriptUri.fsPath, "utf8"); + return parseContractModel(source); +} + +async function buildInitialState( + scriptUri: vscode.Uri, + initialFunction?: string, + keyAliases: string[] = [], + identityLabels: IdentityLabels = {}, +): Promise { + const model = await readModel(scriptUri); + const record = matchingLaunchConfigs(scriptUri)[0]; + + if (record) { + return { + scriptUri, + model, + form: formFromLaunchConfig( + model, + record.config, + initialFunction, + keyAliases, + identityLabels, + ), + baseConfig: { ...record.config }, + record, + loadedConfigName: + typeof record.config.name === "string" + ? record.config.name + : null, + }; + } + + return { + scriptUri, + model, + form: defaultPanelFormState(model, initialFunction), + baseConfig: { + type: "silverscript", + request: "launch", + name: defaultLaunchName(model, scriptUri.fsPath), + stopOnEntry: true, + }, + loadedConfigName: null, + }; +} + +function launchConfigForPanel( + state: PanelHostState, + noDebug: boolean, +): RawLaunchConfiguration { + return { + ...state.baseConfig, + type: "silverscript", + request: "launch", + name: + typeof state.baseConfig.name === "string" && + state.baseConfig.name.trim() + ? state.baseConfig.name + : defaultLaunchName(state.model, state.scriptUri.fsPath), + scriptPath: state.scriptUri.fsPath, + function: state.form.function, + constructorArgs: { ...state.form.constructorArgs }, + args: currentArgs(state.form), + noDebug, + stopOnEntry: !noDebug, + }; +} + +function savedLaunchConfigForPanel( + state: PanelHostState, + name: string, +): RawLaunchConfiguration { + const folder = vscode.workspace.getWorkspaceFolder(state.scriptUri); + if (!folder) { + throw new Error( + "SilverScript launch configs require the script to be inside a workspace folder.", + ); + } + + const config: RawLaunchConfiguration = { + ...state.baseConfig, + type: "silverscript", + request: "launch", + name, + scriptPath: defaultLaunchScriptPathValue(state.scriptUri, folder), + function: state.form.function, + constructorArgs: { ...state.form.constructorArgs }, + args: currentArgs(state.form), + }; + delete config.paramsFile; + delete config.noDebug; + return config; +} + +async function loadSavedScenario( + keyAliases: string[], + identityLabels: IdentityLabels, + functionName?: string, + suppressEmptyMessage = false, +): Promise { + if (!activeState) { + return; + } + + const records = matchingLaunchConfigs(activeState.scriptUri).filter( + (record) => { + if (!functionName) { + return true; + } + return record.config.function === functionName; + }, + ); + if (records.length === 0) { + if (!suppressEmptyMessage) { + void vscode.window.showInformationMessage( + functionName + ? `No saved SilverScript launch configs were found for '${functionName}'.` + : "No saved SilverScript launch configs were found for this file.", + ); + } + return; + } + + const picked = await vscode.window.showQuickPick( + functionName + ? records.map((record) => ({ + label: + typeof record.config.name === "string" + ? record.config.name + : path.basename(record.resolvedScriptPath), + description: record.scriptPathValue, + record, + })) + : (() => { + const groups = new Map(); + for (const record of records) { + const group = + typeof record.config.function === "string" && + record.config.function.trim() + ? record.config.function.trim() + : "Other"; + const existing = groups.get(group) ?? []; + existing.push(record); + groups.set(group, existing); + } + + const orderedGroups: string[] = []; + for (const entrypoint of activeState.model.entrypoints) { + if (groups.has(entrypoint.name)) { + orderedGroups.push(entrypoint.name); + } + } + for (const group of [...groups.keys()].sort()) { + if (!orderedGroups.includes(group)) { + orderedGroups.push(group); + } + } + + return orderedGroups.flatMap((group) => { + const groupRecords = groups.get(group) ?? []; + return [ + { + kind: vscode.QuickPickItemKind.Separator, + label: group, + }, + ...groupRecords.map((record) => ({ + label: + typeof record.config.name === "string" + ? record.config.name + : path.basename(record.resolvedScriptPath), + description: record.scriptPathValue, + record, + })), + ]; + }); + })(), + { + title: functionName + ? `Load Saved Scenario for '${functionName}'` + : "Load SilverScript Launch Config", + placeHolder: functionName + ? `Select a saved launch config for '${functionName}'` + : "Select a saved launch config for this contract, grouped by entrypoint", + }, + ); + + if (!picked || !("record" in picked) || !picked.record) { + return; + } + + const model = await readModel(activeState.scriptUri); + activeState = { + scriptUri: activeState.scriptUri, + model, + form: formFromLaunchConfig( + model, + picked.record.config, + undefined, + keyAliases, + identityLabels, + ), + baseConfig: { ...picked.record.config }, + record: picked.record, + loadedConfigName: + typeof picked.record.config.name === "string" + ? picked.record.config.name + : null, + }; + await renderActiveState(); +} + +function selectEntrypoint( + state: PanelHostState, + initialFunction?: string, +): void { + if (!initialFunction) { + return; + } + + const entrypoint = state.model.entrypoints.find( + (item) => item.name === initialFunction, + ); + if (!entrypoint) { + return; + } + + state.form.function = initialFunction; + if (!state.form.argsByFunction[initialFunction]) { + state.form.argsByFunction[initialFunction] = defaultsForParams( + entrypoint.params, + ); + } +} + +async function saveScenario(): Promise { + if (!activeState) { + return; + } + + const folder = vscode.workspace.getWorkspaceFolder(activeState.scriptUri); + if (!folder) { + void vscode.window.showErrorMessage( + "SilverScript launch configs require the script to be inside a workspace folder.", + ); + return; + } + + if (activeState.record) { + const name = + typeof activeState.baseConfig.name === "string" && + activeState.baseConfig.name.trim() + ? activeState.baseConfig.name + : defaultLaunchName( + activeState.model, + activeState.scriptUri.fsPath, + ); + const config = savedLaunchConfigForPanel(activeState, name); + const updated = await updateSilverScriptLaunchConfig( + activeState.record, + config, + ); + activeState.baseConfig = config; + activeState.record = updated; + activeState.loadedConfigName = name; + await renderActiveState(); + void vscode.window.showInformationMessage( + `Updated '${name}' in launch.json.`, + ); + return; + } + + const name = await vscode.window.showInputBox({ + title: "Save SilverScript Launch Config", + prompt: "Name for this saved debugger scenario", + value: defaultLaunchName( + activeState.model, + activeState.scriptUri.fsPath, + ), + ignoreFocusOut: true, + validateInput: (value) => + value.trim() ? null : "Name is required.", + }); + + if (!name) { + return; + } + + const config = savedLaunchConfigForPanel(activeState, name.trim()); + const record = await createSilverScriptLaunchConfig(folder, config); + activeState.baseConfig = config; + activeState.record = record; + activeState.loadedConfigName = name.trim(); + await renderActiveState(); + void vscode.window.showInformationMessage( + `Saved '${name.trim()}' to launch.json.`, + ); +} + +async function launchFromPanel( + context: vscode.ExtensionContext, + out: vscode.OutputChannel, + kind: LaunchKind, +): Promise { + if (!activeState) { + return; + } + + if (launchInProgress) { + void vscode.window.showWarningMessage( + "A SilverScript run/debug launch is already in progress.", + ); + return; + } + + const existingSession = activeSilverScriptSession(); + if (existingSession) { + await vscode.commands.executeCommand( + "workbench.action.debug.continue", + ); + return; + } + + try { + launchInProgress = true; + const config = launchConfigForPanel(activeState, kind === "run"); + const folder = + vscode.workspace.getWorkspaceFolder(activeState.scriptUri) ?? + vscode.workspace.workspaceFolders?.[0]; + + if (kind === "run") { + const output = await runDebuggerAdapterCommand( + context, + ["--run-config-json", JSON.stringify(config)], + out, + ); + if (output && output !== RUN_SUCCESS_MESSAGE) { + out.show(true); + } + void vscode.window.showInformationMessage( + RUN_SUCCESS_MESSAGE, + ); + return; + } + + await vscode.debug.startDebugging(folder, config, { + noDebug: false, + }); + } catch (error) { + out.show(true); + void vscode.window.showErrorMessage( + `SilverScript ${kind} failed: ${(error as Error).message}`, + ); + } finally { + launchInProgress = false; + } +} + +async function renderActiveState(): Promise { + if (!panel || !activeState) { + return; + } + + const savedCounts = countSilverScriptSavedScenarios( + activeState.scriptUri, + ); + panel.title = activeState.model.name; + panel.webview.html = buildHtml( + activeState.model, + activeState.scriptUri.fsPath, + { + function: activeState.form.function, + constructorArgs: { ...activeState.form.constructorArgs }, + argsByFunction: Object.fromEntries( + Object.entries(activeState.form.argsByFunction).map( + ([entrypoint, args]) => [entrypoint, { ...args }], + ), + ), + keyAliases: [...activeState.form.keyAliases], + identityLabels: { ...activeState.form.identityLabels }, + loadedConfigName: activeState.loadedConfigName, + savedCountsByFunction: savedCounts.byFunction, + savedTotalCount: savedCounts.total, + }, + ); +} + async function openPanel( context: vscode.ExtensionContext, out: vscode.OutputChannel, @@ -150,32 +845,25 @@ async function openPanel( return; } - const source = await fs.promises.readFile(scriptUri.fsPath, "utf8"); - const model = parseContractModel(source); - const debugParams = await readDebugParams(scriptUri.fsPath); - const selectedFunction = - initialFunction && model.entrypoints.some((entry) => entry.name === initialFunction) - ? initialFunction - : debugParams?.function && model.entrypoints.some((entry) => entry.name === debugParams.function) - ? debugParams.function - : model.entrypoints[0]?.name ?? ""; - - const entrypointValues: Record> = {}; - for (const entrypoint of model.entrypoints) { - const argsInput = - entrypoint.name === selectedFunction ? debugParams?.args : undefined; - entrypointValues[entrypoint.name] = valuesForParams( - entrypoint.params, - argsInput, - ); + if ( + panel && + activeState && + activeState.scriptUri.fsPath === scriptUri.fsPath + ) { + selectEntrypoint(activeState, initialFunction); + await renderActiveState(); + panel.reveal(vscode.ViewColumn.Beside, true); + await focusPrimaryEditor(scriptUri); + return; } - activeScriptUri = scriptUri; + activeState = await buildInitialState(scriptUri, initialFunction); + emitPanelStateChanged(); if (!panel) { panel = vscode.window.createWebviewPanel( "silverscriptRunner", - model.name, + activeState.model.name, vscode.ViewColumn.Beside, { enableScripts: true, @@ -185,97 +873,31 @@ async function openPanel( ); panel.webview.onDidReceiveMessage( - async (msg: PanelMessage) => { - if (!activeScriptUri) { - return; - } - - if (msg.kind === "generateKeyMaterial") { - try { - const raw = await runDebuggerAdapterCommand( - context, - ["--keygen"], - out, - ); - const keyMaterial = JSON.parse(raw) as GeneratedKeyMaterial; - await panel?.webview.postMessage({ - kind: "keyMaterial", - keyMaterial, - } satisfies WebviewMessage); - } catch (error) { - await panel?.webview.postMessage({ - kind: "error", - message: `Failed to generate key material: ${(error as Error).message}`, - } satisfies WebviewMessage); - } + async (message: PanelMessage) => { + if (!activeState) { return; } - if (launchInProgress) { - void vscode.window.showWarningMessage( - "A SilverScript run/debug launch is already in progress.", - ); - return; - } + applyMessageState(activeState, message.form); - const existingSession = activeSilverScriptSession(); - if (existingSession) { - await vscode.commands.executeCommand( - "workbench.action.debug.continue", - ); - return; - } - - try { - launchInProgress = true; - const existingParams = - (await readDebugParams(activeScriptUri.fsPath)) ?? {}; - const nextParams: DebugParamsFile = { - ...existingParams, - function: msg.function, - constructorArgs: msg.constructorArgs, - args: msg.args, - }; - await writeDebugParams(activeScriptUri.fsPath, nextParams); - - const folder = - vscode.workspace.getWorkspaceFolder(activeScriptUri) ?? - vscode.workspace.workspaceFolders?.[0]; - const config: vscode.DebugConfiguration = { - type: "silverscript", - request: "launch", - name: `SilverScript: ${msg.function}`, - scriptPath: activeScriptUri.fsPath, - function: msg.function, - constructorArgs: msg.constructorArgs, - args: msg.args, - noDebug: msg.kind === "run", - stopOnEntry: msg.kind === "debug", - }; - - if (msg.kind === "run") { - const output = await runDebuggerAdapterCommand( - context, - ["--run-config-json", JSON.stringify(config)], - out, - ); - out.show(true); - void vscode.window.showInformationMessage( - output || "Execution completed successfully.", + switch (message.kind) { + case "loadSaved": + await loadSavedScenario( + message.form.keyAliases, + message.form.identityLabels, ); return; - } - - await vscode.debug.startDebugging(folder, config, { - noDebug: false, - }); - } catch (error) { - out.show(true); - void vscode.window.showErrorMessage( - `SilverScript ${msg.kind} failed: ${(error as Error).message}`, - ); - } finally { - launchInProgress = false; + case "saveSaved": + await saveScenario(); + return; + case "run": + await launchFromPanel(context, out, "run"); + return; + case "debug": + await launchFromPanel(context, out, "debug"); + return; + default: + return; } }, undefined, @@ -284,32 +906,62 @@ async function openPanel( panel.onDidDispose(() => { panel = undefined; - activeScriptUri = undefined; + activeState = undefined; launchInProgress = false; + emitPanelStateChanged(); }); } else { - panel.reveal(vscode.ViewColumn.Beside); + panel.reveal(vscode.ViewColumn.Beside, true); } - panel.title = model.name; - panel.webview.html = buildHtml(model, scriptUri.fsPath, { - function: selectedFunction, - constructorArgs: valuesForParams( - model.constructorParams, - debugParams?.constructorArgs, - ), - argsByFunction: entrypointValues, - keys: [], - }); + await renderActiveState(); + await focusPrimaryEditor(scriptUri); +} + +async function showSavedScenarios( + context: vscode.ExtensionContext, + out: vscode.OutputChannel, + uri?: vscode.Uri, + initialFunction?: string, + showPicker = true, +): Promise { + await openPanel(context, out, uri, initialFunction); + if (!showPicker || !activeState) { + return; + } + + await loadSavedScenario( + activeState.form.keyAliases, + activeState.form.identityLabels, + initialFunction, + true, + ); +} + +async function handlePrimaryCodeLensAction( + context: vscode.ExtensionContext, + out: vscode.OutputChannel, + uri?: vscode.Uri, +): Promise { + const scriptUri = resolveActiveScriptUri(uri); + if (scriptUri && hasOpenSilverScriptPanelForUri(scriptUri)) { + await triggerPanelLaunch("run"); + return; + } + + await openPanel(context, out, uri); } -async function triggerPanelLaunch(kind: "run" | "debug"): Promise { +async function triggerPanelLaunch( + launchKind: LaunchKind, +): Promise { if (!panel) { return; } + await panel.webview.postMessage({ kind: "triggerLaunch", - launchKind: kind, + launchKind, } satisfies PanelControlMessage); } @@ -319,10 +971,16 @@ async function handlePanelF5( uri?: vscode.Uri, initialFunction?: string, ): Promise { - if (panel && activeScriptUri) { + const scriptUri = resolveActiveScriptUri(uri); + if ( + panel && + activeState && + (!scriptUri || activeState.scriptUri.fsPath === scriptUri.fsPath) + ) { await triggerPanelLaunch("debug"); return; } + await openPanel(context, out, uri, initialFunction); } @@ -359,9 +1017,14 @@ function buildHtml( --input-bg: var(--vscode-input-background); --input-fg: var(--vscode-input-foreground); --input-border: var(--vscode-input-border, transparent); + --panel-bg: rgba(127, 127, 127, 0.03); + --panel-hover: var(--vscode-toolbar-hoverBackground, rgba(128, 128, 128, 0.1)); --btn: var(--vscode-button-background); --btn-fg: var(--vscode-button-foreground); --btn-hover: var(--vscode-button-hoverBackground); + --btn-secondary-bg: transparent; + --btn-secondary-fg: var(--fg); + --btn-secondary-hover: var(--panel-hover); --focus: var(--vscode-focusBorder); --muted: rgba(127, 127, 127, 0.75); --sep: var(--vscode-widget-border, rgba(128, 128, 128, 0.25)); @@ -371,26 +1034,39 @@ function buildHtml( * { box-sizing: border-box; } body { margin: 0; - padding: 18px; + padding: 16px; color: var(--fg); background: var(--bg); font: 13px/1.45 var(--vscode-font-family, system-ui, sans-serif); } + .header-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin: 0 0 14px; + } h1 { - margin: 0 0 4px; + flex: 1; + min-width: 0; + margin: 0; font-size: 16px; font-weight: 600; - } - .path { - margin: 0 0 18px; - color: var(--muted); - font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .topbar { + display: flex; + align-items: center; + flex: none; + } + .topbar-actions { + display: flex; + gap: 6px; + } section { - margin-bottom: 18px; + margin-bottom: 14px; } h2 { margin: 0 0 8px; @@ -422,7 +1098,8 @@ function buildHtml( } input, select { width: 100%; - padding: 7px 8px; + min-height: 34px; + padding: 6px 10px; border-radius: 4px; border: 1px solid var(--input-border); background: var(--input-bg); @@ -440,14 +1117,15 @@ function buildHtml( } .actions { display: flex; - gap: 10px; - margin-top: 24px; + gap: 8px; + margin-top: 16px; } button { flex: 1; - padding: 10px 0; + min-height: 36px; + padding: 8px 0; border: 0; - border-radius: 5px; + border-radius: 4px; background: var(--btn); color: var(--btn-fg); font-size: 13px; @@ -457,11 +1135,34 @@ function buildHtml( button:hover { background: var(--btn-hover); } + .secondary-button { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: auto; + min-width: 0; + min-height: 32px; + padding: 0 12px; + border: 1px solid var(--sep); + border-radius: 4px; + background: var(--btn-secondary-bg); + color: var(--btn-secondary-fg); + font-size: 11px; + font-weight: 600; + line-height: 1; + } + .secondary-button:hover { + background: var(--btn-secondary-hover); + } + .compact-button { + flex: none; + } .field-row { position: relative; display: flex; align-items: center; - gap: 6px; + gap: 4px; } .field-row input { flex: 1; @@ -469,82 +1170,21 @@ function buildHtml( .field-row input.crypto-input { cursor: pointer; } - .field-row button { - flex: none; - width: 58px; - padding: 7px 0; - background: transparent; - border: 1px solid var(--sep); - color: var(--fg); - font-size: 12px; - font-weight: 600; - } - .field-row button:hover { - background: rgba(128, 128, 128, 0.12); + .field-row .field-action { + min-width: 64px; + padding: 0 10px; } - .key-wallet { - margin-top: 16px; - border-top: 1px solid var(--sep); - padding-top: 14px; - } - .key-wallet summary { - cursor: pointer; - font-size: 12px; - font-weight: 700; - color: var(--muted); - text-transform: uppercase; - letter-spacing: 0.06em; - } - .key-wallet-actions { + .section-head { display: flex; - gap: 8px; align-items: center; - margin-top: 10px; - } - .key-wallet-actions button { - flex: none; - width: auto; - min-width: 160px; - padding: 8px 14px; - } - .key-help { - color: var(--muted); - font-size: 11px; - } - .key-list { - display: grid; + justify-content: space-between; gap: 8px; - margin-top: 10px; - } - .key-row { - display: grid; - grid-template-columns: 76px 1fr auto; - gap: 8px; - align-items: center; - padding: 8px 10px; - border: 1px solid var(--sep); - border-radius: 6px; - background: rgba(128, 128, 128, 0.05); - font: 12px var(--vscode-editor-font-family, monospace); - } - .key-name { - font-weight: 700; - } - .key-value { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + margin-bottom: 8px; } - .key-row button { - flex: none; - width: 64px; - padding: 6px 0; - background: transparent; - border: 1px solid var(--sep); - color: var(--fg); - font-size: 11px; + .section-head h2 { + margin: 0; } - .key-dropdown { + .identity-dropdown { position: absolute; z-index: 100; top: calc(100% + 4px); @@ -556,21 +1196,29 @@ function buildHtml( background: var(--input-bg); box-shadow: 0 6px 18px rgba(0, 0, 0, 0.28); } - .key-choice { + .identity-choice { + display: flex; + align-items: center; + gap: 8px; + justify-content: space-between; margin: 0 4px; padding: 7px 10px; border-radius: 4px; cursor: pointer; } - .key-choice:hover { + .identity-choice:hover { background: var(--btn); color: var(--btn-fg); } - .key-choice-name { + .identity-choice-name { display: block; font-weight: 700; } - .key-choice-value { + .identity-choice-main { + flex: 1; + min-width: 0; + } + .identity-choice-value { display: block; overflow: hidden; text-overflow: ellipsis; @@ -578,24 +1226,38 @@ function buildHtml( font-size: 11px; opacity: 0.72; } - .key-divider { - margin: 4px 0; - border-top: 1px solid var(--sep); - } - .status { - min-height: 16px; - margin-top: 10px; + .identity-choice-delete { + flex: none; + min-width: 18px; + min-height: 18px; + padding: 0; + border: 0; + border-radius: 3px; + background: transparent; color: var(--muted); - font-size: 11px; + font-size: 14px; + line-height: 1; } - .status.error { - color: var(--vscode-errorForeground); + .identity-choice-delete:hover { + background: var(--panel-hover); + color: var(--vscode-errorForeground, var(--fg)); + } + .identity-divider { + margin: 4px 0; + border-top: 1px solid var(--sep); } -

${escHtml(model.name)}

-
${escHtml(scriptPath)}
+
+

${escHtml(model.name)}

+
+
+ + +
+
+

Constructor

@@ -617,16 +1279,6 @@ function buildHtml( -
- Keys -
- - Click a crypto field to fill it directly from a generated key. -
-
-
-
- @@ -999,4 +1717,33 @@ export function registerSilverScriptQuickLaunchPanel( handlePanelF5(context, out, uri, initialFunction), ), ); + context.subscriptions.push( + vscode.commands.registerCommand( + "silverscript.debug.primaryCodeLensAction", + (uri?: vscode.Uri) => + handlePrimaryCodeLensAction(context, out, uri), + ), + ); + context.subscriptions.push( + vscode.commands.registerCommand( + "silverscript.debug.showSavedScenarios", + ( + uri?: vscode.Uri, + initialFunction?: string, + showPicker?: boolean, + ) => + showSavedScenarios( + context, + out, + uri, + initialFunction, + showPicker ?? true, + ), + ), + ); + context.subscriptions.push( + vscode.window.onDidChangeActiveTextEditor((editor) => { + void handleActiveEditorChange(editor); + }), + ); } From ffcc8fe99eaa5fc7dc7825a9346860c160817084 Mon Sep 17 00:00:00 2001 From: Manyfestation <240733973+Manyfestation@users.noreply.github.com> Date: Thu, 12 Mar 2026 12:07:06 +0200 Subject: [PATCH 5/6] refactor panel into dedicated folder --- debugger/dap/src/adapter.rs | 26 +- debugger/dap/src/launch_config.rs | 15 +- debugger/dap/src/main.rs | 4 +- debugger/dap/tests/test_launch.rs | 143 ++ debugger/session/src/session.rs | 7 +- extensions/vscode/.vscodeignore | 1 + extensions/vscode/src/codeLens.ts | 2 +- extensions/vscode/src/debug.ts | 2 +- extensions/vscode/src/extension.ts | 2 +- extensions/vscode/src/quickLaunch/panel.ts | 1023 ++++++++++ extensions/vscode/src/quickLaunch/view.ts | 88 + extensions/vscode/src/quickLaunchPanel.ts | 1749 ----------------- .../vscode/webviews/quickLaunch/panel.css | 262 +++ .../vscode/webviews/quickLaunch/panel.html | 47 + .../vscode/webviews/quickLaunch/panel.js | 511 +++++ 15 files changed, 2110 insertions(+), 1772 deletions(-) create mode 100644 extensions/vscode/src/quickLaunch/panel.ts create mode 100644 extensions/vscode/src/quickLaunch/view.ts delete mode 100644 extensions/vscode/src/quickLaunchPanel.ts create mode 100644 extensions/vscode/webviews/quickLaunch/panel.css create mode 100644 extensions/vscode/webviews/quickLaunch/panel.html create mode 100644 extensions/vscode/webviews/quickLaunch/panel.js diff --git a/debugger/dap/src/adapter.rs b/debugger/dap/src/adapter.rs index 7d87944d..bb07a0d9 100644 --- a/debugger/dap/src/adapter.rs +++ b/debugger/dap/src/adapter.rs @@ -125,19 +125,6 @@ impl DapAdapter { } .unwrap_or_default(); - if !runtime.stop_on_entry { - runtime.breakpoints_by_source.insert(source_key, HashSet::new()); - let breakpoints = requested_lines - .into_iter() - .map(|line| Breakpoint { verified: false, line: Some(line), ..Default::default() }) - .collect(); - return Ok(AdapterResult { - response: req.success(ResponseBody::SetBreakpoints(SetBreakpointsResponse { breakpoints })), - events: vec![], - should_exit: false, - }); - } - let runtime_source_key = canonical_source_key(&runtime.source_path); if source_key != runtime_source_key { // This adapter session executes one script file. Keep breakpoints @@ -193,7 +180,18 @@ impl DapAdapter { .run_to_first_executed_statement() .map_err(|err| format!("failed to start session: {err}"))?; - let events = if runtime.stop_on_entry { + let events = if runtime.no_debug { + match runtime.runtime.session_mut().run_to_completion() { + Ok(()) => vec![self.output_stdout("Execution completed successfully."), Event::Terminated(None)], + Err(err) => { + let report = runtime.runtime.session().build_failure_report(&err); + let formatted = format_failure_report(&report, &|type_name, value| { + runtime.runtime.session().format_value(type_name, value) + }); + vec![self.output_stderr(formatted), Event::Terminated(None)] + } + } + } else if runtime.stop_on_entry { vec![self.make_stopped_event(StoppedEventReason::Entry, None)] } else { match runtime.runtime.session_mut().continue_to_breakpoint() { diff --git a/debugger/dap/src/launch_config.rs b/debugger/dap/src/launch_config.rs index 5f13c8cb..0c9f97ec 100644 --- a/debugger/dap/src/launch_config.rs +++ b/debugger/dap/src/launch_config.rs @@ -5,7 +5,7 @@ use dap::requests::LaunchRequestArguments; use debugger_session::args::values_to_args; use debugger_session::test_runner::{TestTxScenario, TestTxScenarioResolved, resolve_tx_scenario}; use serde::Deserialize; -use serde_json::Value; +use serde_json::{Map, Value}; #[derive(Debug, Clone, Default, Deserialize)] #[serde(rename_all = "camelCase")] @@ -39,8 +39,17 @@ pub enum ArgInput { impl LaunchConfig { pub fn from_launch_args(args: &LaunchRequestArguments) -> Result { - let value = args.additional_data.clone().unwrap_or(Value::Null); - Self::from_value(value) + let mut launch_data = match args.additional_data.clone() { + Some(Value::Object(map)) => map, + Some(Value::Null) | None => Map::new(), + Some(_) => return Err("invalid launch config: expected launch arguments to deserialize into an object".to_string()), + }; + + if let Some(no_debug) = args.no_debug { + launch_data.insert("noDebug".to_string(), Value::Bool(no_debug)); + } + + Self::from_value(Value::Object(launch_data)) } pub fn from_value(value: Value) -> Result { diff --git a/debugger/dap/src/main.rs b/debugger/dap/src/main.rs index 50a76b2b..90360599 100644 --- a/debugger/dap/src/main.rs +++ b/debugger/dap/src/main.rs @@ -59,8 +59,8 @@ fn run_config_json(raw: &str) -> Result<(), Box> { let session = built.runtime.session_mut(); session.run_to_first_executed_statement()?; - match session.continue_to_breakpoint() { - Ok(Some(_)) | Ok(None) => { + match session.run_to_completion() { + Ok(()) => { println!("Execution completed successfully."); Ok(()) } diff --git a/debugger/dap/tests/test_launch.rs b/debugger/dap/tests/test_launch.rs index 3a94e65b..c1836dcf 100644 --- a/debugger/dap/tests/test_launch.rs +++ b/debugger/dap/tests/test_launch.rs @@ -637,6 +637,149 @@ fn named_launch_arguments_select_breakpoint() { client.expect_response_success("disconnect"); } +#[test] +fn launch_without_stop_on_entry_still_stops_on_breakpoint() { + let script = TempScript::new(SIMPLE_SCRIPT); + let script_path = script.path_str(); + + let mut client = TestClient::spawn(); + + client.send_request( + "initialize", + json!({ + "adapterID": "silverscript", + "pathFormat": "path", + "linesStartAt1": true, + "columnsStartAt1": true, + "supportsVariableType": true, + "supportsVariablePaging": false, + "supportsRunInTerminalRequest": false + }), + ); + client.expect_response_success("initialize"); + client.expect_event("initialized"); + + client.send_request( + "launch", + json!({ + "scriptPath": script_path, + "stopOnEntry": false + }), + ); + client.expect_response_success("launch"); + + client.send_request( + "setBreakpoints", + json!({ + "source": {"path": script_path}, + "breakpoints": [{"line": 6}] + }), + ); + let set_bp = client.expect_response_success("setBreakpoints"); + let breakpoint = set_bp + .get("body") + .and_then(|v| v.get("breakpoints")) + .and_then(|v| v.as_array()) + .and_then(|items| items.first()) + .cloned() + .expect("expected breakpoint response"); + assert_eq!(breakpoint.get("verified").and_then(|v| v.as_bool()), Some(true), "expected verified breakpoint: {set_bp:#}"); + + client.send_request("setExceptionBreakpoints", json!({"filters": []})); + client.expect_response_success("setExceptionBreakpoints"); + + client.send_request("configurationDone", serde_json::Value::Null); + client.expect_response_success("configurationDone"); + + let stopped = client.expect_event("stopped"); + let reason = stopped.get("body").and_then(|v| v.get("reason")).and_then(|v| v.as_str()).unwrap_or_default(); + assert_eq!(reason, "breakpoint"); + + client.send_request("stackTrace", json!({"threadId": 1})); + let stack = client.expect_response_success("stackTrace"); + let line = stack + .get("body") + .and_then(|v| v.get("stackFrames")) + .and_then(|v| v.as_array()) + .and_then(|frames| frames.first()) + .and_then(|frame| frame.get("line")) + .and_then(|v| v.as_i64()) + .expect("expected stopped stack frame"); + assert_eq!(line, 6); + + client.send_request("disconnect", json!({})); + client.expect_response_success("disconnect"); +} + +#[test] +fn no_debug_launch_ignores_breakpoints_and_does_not_stop_on_entry() { + let script = TempScript::new(SIMPLE_SCRIPT); + let script_path = script.path_str(); + + let mut client = TestClient::spawn(); + + client.send_request( + "initialize", + json!({ + "adapterID": "silverscript", + "pathFormat": "path", + "linesStartAt1": true, + "columnsStartAt1": true, + "supportsVariableType": true, + "supportsVariablePaging": false, + "supportsRunInTerminalRequest": false + }), + ); + client.expect_response_success("initialize"); + client.expect_event("initialized"); + + client.send_request( + "launch", + json!({ + "scriptPath": script_path, + "noDebug": true, + "stopOnEntry": true + }), + ); + client.expect_response_success("launch"); + + client.send_request( + "setBreakpoints", + json!({ + "source": {"path": script_path}, + "breakpoints": [{"line": 6}] + }), + ); + client.expect_response_success("setBreakpoints"); + + client.send_request("setExceptionBreakpoints", json!({"filters": []})); + client.expect_response_success("setExceptionBreakpoints"); + + client.send_request("configurationDone", serde_json::Value::Null); + client.expect_response_success("configurationDone"); + + let mut terminated = false; + for _ in 0..8 { + let msg = client.read_message(); + if msg.get("type") != Some(&serde_json::Value::String("event".to_string())) { + continue; + } + match msg.get("event").and_then(|v| v.as_str()) { + Some("terminated") => { + terminated = true; + break; + } + Some("stopped") => panic!("expected no-debug launch to terminate, got stop event: {msg:#}"), + _ => {} + } + } + + assert!(terminated, "expected no-debug launch to terminate without stop events"); + + client.send_request("disconnect", json!({})); + client.expect_response_success("disconnect"); +} + #[test] fn scopes_expose_variables_and_stacks() { let script = TempScript::new( diff --git a/debugger/session/src/session.rs b/debugger/session/src/session.rs index 903ea92d..d783a927 100644 --- a/debugger/session/src/session.rs +++ b/debugger/session/src/session.rs @@ -325,7 +325,7 @@ impl<'a, 'i> DebugSession<'a, 'i> { /// Continues execution until a breakpoint is hit or script completes. pub fn continue_to_breakpoint(&mut self) -> Result>, kaspa_txscript_errors::TxScriptError> { if self.breakpoints.is_empty() { - while self.step_opcode()?.is_some() {} + self.run_to_completion()?; return Ok(None); } loop { @@ -340,6 +340,11 @@ impl<'a, 'i> DebugSession<'a, 'i> { } } + pub fn run_to_completion(&mut self) -> Result<(), kaspa_txscript_errors::TxScriptError> { + while self.step_opcode()?.is_some() {} + Ok(()) + } + /// Returns the current execution state snapshot. pub fn state(&self) -> SessionState<'i> { let executed = self.pc.saturating_sub(1); diff --git a/extensions/vscode/.vscodeignore b/extensions/vscode/.vscodeignore index cd71f7af..2e6ede86 100644 --- a/extensions/vscode/.vscodeignore +++ b/extensions/vscode/.vscodeignore @@ -18,6 +18,7 @@ vsc-extension-quickstart.md # keep these !assets/tree-sitter-silverscript.wasm !queries/** +!webviews/** !node_modules/web-tree-sitter/package.json !node_modules/web-tree-sitter/LICENSE !node_modules/web-tree-sitter/web-tree-sitter.cjs diff --git a/extensions/vscode/src/codeLens.ts b/extensions/vscode/src/codeLens.ts index 863127fc..9e00d351 100644 --- a/extensions/vscode/src/codeLens.ts +++ b/extensions/vscode/src/codeLens.ts @@ -3,7 +3,7 @@ import { countSilverScriptSavedScenarios } from "./launchConfigs"; import { hasOpenSilverScriptPanelForUri, onDidChangeSilverScriptPanelState, -} from "./quickLaunchPanel"; +} from "./quickLaunch/panel"; const CONTRACT_RE = /^\s*contract\s+([A-Za-z_]\w*)\s*\(/; const ENTRYPOINT_RE = diff --git a/extensions/vscode/src/debug.ts b/extensions/vscode/src/debug.ts index 75ff884a..4e6bb3c0 100644 --- a/extensions/vscode/src/debug.ts +++ b/extensions/vscode/src/debug.ts @@ -206,7 +206,7 @@ class SilverScriptConfigProvider } config.noDebug ??= false; - config.stopOnEntry ??= true; + config.stopOnEntry ??= !config.noDebug; return config; } } diff --git a/extensions/vscode/src/extension.ts b/extensions/vscode/src/extension.ts index 76890910..2ce25c1f 100644 --- a/extensions/vscode/src/extension.ts +++ b/extensions/vscode/src/extension.ts @@ -5,7 +5,7 @@ import { Language, Parser, Query } from "web-tree-sitter"; import type { QueryCapture } from "web-tree-sitter"; import { registerSilverScriptCodeLens } from "./codeLens"; import { registerSilverScriptDebugger } from "./debug"; -import { registerSilverScriptQuickLaunchPanel } from "./quickLaunchPanel"; +import { registerSilverScriptQuickLaunchPanel } from "./quickLaunch/panel"; const TOKEN_TYPES = [ "comment", diff --git a/extensions/vscode/src/quickLaunch/panel.ts b/extensions/vscode/src/quickLaunch/panel.ts new file mode 100644 index 00000000..6afe6d2c --- /dev/null +++ b/extensions/vscode/src/quickLaunch/panel.ts @@ -0,0 +1,1023 @@ +import * as fs from "fs"; +import * as path from "path"; +import * as vscode from "vscode"; +import { + defaultForType, + type DebugArgInput, + type DebugArgObject, + parseContractModel, + type ContractModel, + type ContractParam, +} from "../contractModel"; +import { runDebuggerAdapterCommand } from "../debugAdapter"; +import { + countSilverScriptSavedScenarios, + createSilverScriptLaunchConfig, + defaultLaunchScriptPathValue, + listMatchingSilverScriptLaunchConfigs, + type RawLaunchConfiguration, + type SilverScriptLaunchConfigRecord, + updateSilverScriptLaunchConfig, +} from "../launchConfigs"; +import { + buildQuickLaunchHtml, + quickLaunchWebviewRoot, + type QuickLaunchWebviewState, +} from "./view"; + +type LaunchKind = "run" | "debug"; +type IdentityLabels = Record; + +type PanelFormState = { + function: string; + constructorArgs: Record; + argsByFunction: Record>; + keyAliases: string[]; + identityLabels: IdentityLabels; +}; + +type PanelHostState = { + scriptUri: vscode.Uri; + model: ContractModel; + form: PanelFormState; + baseConfig: RawLaunchConfiguration; + record?: SilverScriptLaunchConfigRecord; +}; + +type PanelMessage = + | { kind: "run"; form: PanelFormState } + | { kind: "debug"; form: PanelFormState } + | { kind: "loadSaved"; form: PanelFormState } + | { kind: "saveSaved"; form: PanelFormState }; + +type PanelControlMessage = { + kind: "triggerLaunch"; + launchKind: LaunchKind; +}; + +const RUN_SUCCESS_MESSAGE = "Execution completed successfully."; + +let panel: vscode.WebviewPanel | undefined; +let activeState: PanelHostState | undefined; +let launchInProgress = false; +let restoringPrimaryEditor = false; +let extensionContext: vscode.ExtensionContext | undefined; +const panelStateEmitter = new vscode.EventEmitter(); +const IDENTITY_ALIAS_RE = + /^(?:keypair|identity)([1-9]\d*)(?:\.(pubkey|secret|pkh))?$/; + +function emitPanelStateChanged(): void { + panelStateEmitter.fire(); +} + +export const onDidChangeSilverScriptPanelState = + panelStateEmitter.event; + +function isDebugArgInput(value: unknown): value is DebugArgInput { + return ( + Array.isArray(value) || + (value !== null && typeof value === "object") + ); +} + +function activeSilverScriptSession(): vscode.DebugSession | undefined { + const session = vscode.debug.activeDebugSession; + return session?.type === "silverscript" ? session : undefined; +} + +export function hasOpenSilverScriptPanelForUri( + uri?: vscode.Uri, +): boolean { + if (!panel || !activeState || !uri) { + return false; + } + + return activeState.scriptUri.fsPath === uri.fsPath; +} + +function resolveActiveScriptUri(uri?: vscode.Uri): vscode.Uri | undefined { + if (uri) { + return uri; + } + const activeDoc = vscode.window.activeTextEditor?.document; + if (activeDoc?.languageId === "silverscript") { + return activeDoc.uri; + } + return undefined; +} + +function ensureTrustedWorkspace(): boolean { + if (vscode.workspace.isTrusted) { + return true; + } + + void vscode.window.showWarningMessage( + "SilverScript run/debug requires a trusted workspace.", + ); + return false; +} + +function stringifyLaunchArg(value: unknown): string { + if (typeof value === "string") { + return value; + } + if ( + Array.isArray(value) || + (value !== null && typeof value === "object") + ) { + return JSON.stringify(value); + } + return String(value); +} + +function defaultsForParams( + params: ContractParam[], +): Record { + return Object.fromEntries( + params.map((param) => [ + param.name, + stringifyLaunchArg(defaultForType(param.type)), + ]), + ); +} + +function valuesForParams( + params: ContractParam[], + input: DebugArgInput | undefined, +): Record { + const defaults = defaultsForParams(params); + if (Array.isArray(input)) { + for (const [index, param] of params.entries()) { + if (index < input.length) { + defaults[param.name] = stringifyLaunchArg(input[index]); + } + } + return defaults; + } + if (input && typeof input === "object") { + for (const param of params) { + if (Object.prototype.hasOwnProperty.call(input, param.name)) { + defaults[param.name] = stringifyLaunchArg(input[param.name]); + } + } + } + return defaults; +} + +function defaultLaunchName( + model: ContractModel, + scriptPath: string, +): string { + return model.name && model.name !== "Unknown" + ? `SilverScript: ${model.name}` + : `SilverScript: ${path.basename(scriptPath)}`; +} + +function resolvedLaunchName( + state: PanelHostState, +): string { + return typeof state.baseConfig.name === "string" && + state.baseConfig.name.trim() + ? state.baseConfig.name + : defaultLaunchName(state.model, state.scriptUri.fsPath); +} + +function normalizeKeyAliases( + aliases: readonly string[], + constructorArgs: Record, + argsByFunction: Record>, +): string[] { + const found = new Map(); + + const consider = (raw: string | undefined) => { + if (!raw) { + return; + } + const match = IDENTITY_ALIAS_RE.exec(raw.trim()); + if (!match) { + return; + } + const index = Number(match[1]); + if (!found.has(index)) { + found.set(index, `keypair${index}`); + } + }; + + aliases.forEach(consider); + Object.values(constructorArgs).forEach((value) => consider(value)); + Object.values(argsByFunction).forEach((args) => { + Object.values(args).forEach((value) => consider(value)); + }); + + const normalized = [...found.entries()] + .sort((left, right) => left[0] - right[0]) + .map(([, alias]) => alias); + return normalized; +} + +function normalizeIdentityLabels( + aliases: readonly string[], + labels: IdentityLabels, +): IdentityLabels { + const normalized: IdentityLabels = {}; + for (const alias of aliases) { + const label = labels[alias]?.trim(); + if (label && label !== alias) { + normalized[alias] = label; + } + } + return normalized; +} + +async function focusPrimaryEditor( + scriptUri: vscode.Uri, +): Promise { + if (restoringPrimaryEditor) { + return; + } + + restoringPrimaryEditor = true; + try { + const document = await vscode.workspace.openTextDocument(scriptUri); + await vscode.window.showTextDocument(document, { + viewColumn: vscode.ViewColumn.One, + preview: false, + preserveFocus: false, + }); + } finally { + restoringPrimaryEditor = false; + } +} + +async function keepSilverScriptEditorOnPrimary( + editor: vscode.TextEditor | undefined, +): Promise { + if ( + restoringPrimaryEditor || + !panel || + !editor || + editor.document.languageId !== "silverscript" || + editor.viewColumn === vscode.ViewColumn.One || + panel.viewColumn === undefined || + editor.viewColumn !== panel.viewColumn + ) { + return; + } + + await focusPrimaryEditor(editor.document.uri); + if (panel) { + panel.reveal(vscode.ViewColumn.Beside, true); + } +} + +async function followActiveSilverScript( + editor: vscode.TextEditor | undefined, +): Promise { + if ( + !panel || + !editor || + editor.document.languageId !== "silverscript" || + !activeState || + activeState.scriptUri.fsPath === editor.document.uri.fsPath + ) { + return; + } + + activeState = await buildInitialState(editor.document.uri); + emitPanelStateChanged(); + await renderActiveState(); +} + +async function handleActiveEditorChange( + editor: vscode.TextEditor | undefined, +): Promise { + await keepSilverScriptEditorOnPrimary(editor); + await followActiveSilverScript(editor); +} + +function defaultPanelFormState( + model: ContractModel, + initialFunction?: string, +): PanelFormState { + const constructorDefaults = defaultsForParams( + model.constructorParams, + ); + const selectedFunction = + initialFunction && + model.entrypoints.some((entry) => entry.name === initialFunction) + ? initialFunction + : model.entrypoints[0]?.name ?? ""; + + const argsByFunction: Record> = {}; + for (const entrypoint of model.entrypoints) { + argsByFunction[entrypoint.name] = defaultsForParams( + entrypoint.params, + ); + } + + return { + function: selectedFunction, + constructorArgs: constructorDefaults, + argsByFunction, + keyAliases: normalizeKeyAliases( + [], + constructorDefaults, + argsByFunction, + ), + identityLabels: {}, + }; +} + +function formFromLaunchConfig( + model: ContractModel, + config: RawLaunchConfiguration, + initialFunction: string | undefined, + keyAliases: string[], + identityLabels: IdentityLabels, +): PanelFormState { + const configuredFunction = + typeof config.function === "string" ? config.function : undefined; + const selectedFunction = + initialFunction && + model.entrypoints.some((entry) => entry.name === initialFunction) + ? initialFunction + : configuredFunction && + model.entrypoints.some( + (entry) => entry.name === configuredFunction, + ) + ? configuredFunction + : model.entrypoints[0]?.name ?? ""; + + const constructorArgs = isDebugArgInput(config.constructorArgs) + ? config.constructorArgs + : undefined; + const configuredArgs = + configuredFunction === selectedFunction && + isDebugArgInput(config.args) + ? config.args + : undefined; + + const argsByFunction: Record> = {}; + for (const entrypoint of model.entrypoints) { + argsByFunction[entrypoint.name] = valuesForParams( + entrypoint.params, + entrypoint.name === selectedFunction ? configuredArgs : undefined, + ); + } + + const constructorValues = valuesForParams( + model.constructorParams, + constructorArgs, + ); + const normalizedAliases = normalizeKeyAliases( + keyAliases, + constructorValues, + argsByFunction, + ); + const form = { + function: selectedFunction, + constructorArgs: constructorValues, + argsByFunction, + keyAliases: normalizedAliases, + identityLabels: normalizeIdentityLabels( + normalizedAliases, + identityLabels, + ), + }; + return form; +} + +function currentArgs( + form: PanelFormState, +): DebugArgObject { + return { ...(form.argsByFunction[form.function] ?? {}) }; +} + +function cloneArgsByFunction( + argsByFunction: Record>, +): Record> { + return Object.fromEntries( + Object.entries(argsByFunction).map( + ([entrypoint, args]) => [entrypoint, { ...args }], + ), + ); +} + +function applyMessageState( + state: PanelHostState, + form: PanelFormState, +): void { + const normalizedAliases = normalizeKeyAliases( + form.keyAliases, + form.constructorArgs, + form.argsByFunction, + ); + state.form = { + function: form.function, + constructorArgs: { ...form.constructorArgs }, + argsByFunction: cloneArgsByFunction(form.argsByFunction), + keyAliases: normalizedAliases, + identityLabels: normalizeIdentityLabels( + normalizedAliases, + form.identityLabels, + ), + }; +} + +function launchConfigLabel( + record: SilverScriptLaunchConfigRecord, +): string { + return typeof record.config.name === "string" + ? record.config.name + : path.basename(record.resolvedScriptPath); +} + +type SavedScenarioPickItem = vscode.QuickPickItem & { + record?: SilverScriptLaunchConfigRecord; +}; + +function buildSavedScenarioPickItems( + model: ContractModel, + records: SilverScriptLaunchConfigRecord[], + functionName?: string, +): SavedScenarioPickItem[] { + if (functionName) { + return records.map((record) => ({ + label: launchConfigLabel(record), + description: record.scriptPathValue, + record, + })); + } + + const groups = new Map(); + for (const record of records) { + const group = + typeof record.config.function === "string" && + record.config.function.trim() + ? record.config.function.trim() + : "Other"; + const existing = groups.get(group) ?? []; + existing.push(record); + groups.set(group, existing); + } + + const orderedGroups: string[] = []; + for (const entrypoint of model.entrypoints) { + if (groups.has(entrypoint.name)) { + orderedGroups.push(entrypoint.name); + } + } + for (const group of [...groups.keys()].sort()) { + if (!orderedGroups.includes(group)) { + orderedGroups.push(group); + } + } + + return orderedGroups.flatMap((group) => { + const groupRecords = groups.get(group) ?? []; + return [ + { + kind: vscode.QuickPickItemKind.Separator, + label: group, + }, + ...groupRecords.map((record) => ({ + label: launchConfigLabel(record), + description: record.scriptPathValue, + record, + })), + ]; + }); +} + +async function readModel( + scriptUri: vscode.Uri, +): Promise { + const source = await fs.promises.readFile(scriptUri.fsPath, "utf8"); + return parseContractModel(source); +} + +async function buildInitialState( + scriptUri: vscode.Uri, + initialFunction?: string, + keyAliases: string[] = [], + identityLabels: IdentityLabels = {}, +): Promise { + const model = await readModel(scriptUri); + const record = listMatchingSilverScriptLaunchConfigs(scriptUri)[0]; + + if (record) { + return { + scriptUri, + model, + form: formFromLaunchConfig( + model, + record.config, + initialFunction, + keyAliases, + identityLabels, + ), + baseConfig: { ...record.config }, + record, + }; + } + + return { + scriptUri, + model, + form: defaultPanelFormState(model, initialFunction), + baseConfig: { + type: "silverscript", + request: "launch", + name: defaultLaunchName(model, scriptUri.fsPath), + stopOnEntry: true, + }, + }; +} + +function launchConfigForPanel( + state: PanelHostState, + noDebug: boolean, +): RawLaunchConfiguration { + return { + ...state.baseConfig, + type: "silverscript", + request: "launch", + name: resolvedLaunchName(state), + scriptPath: state.scriptUri.fsPath, + function: state.form.function, + constructorArgs: { ...state.form.constructorArgs }, + args: currentArgs(state.form), + noDebug, + stopOnEntry: !noDebug, + }; +} + +function savedLaunchConfigForPanel( + state: PanelHostState, + name: string, +): RawLaunchConfiguration { + const folder = vscode.workspace.getWorkspaceFolder(state.scriptUri); + if (!folder) { + throw new Error( + "SilverScript launch configs require the script to be inside a workspace folder.", + ); + } + + const config: RawLaunchConfiguration = { + ...state.baseConfig, + type: "silverscript", + request: "launch", + name, + scriptPath: defaultLaunchScriptPathValue(state.scriptUri, folder), + function: state.form.function, + constructorArgs: { ...state.form.constructorArgs }, + args: currentArgs(state.form), + }; + delete config.paramsFile; + delete config.noDebug; + return config; +} + +function buildWebviewState( + state: PanelHostState, +): QuickLaunchWebviewState { + const savedCounts = countSilverScriptSavedScenarios( + state.scriptUri, + ); + return { + function: state.form.function, + constructorArgs: { ...state.form.constructorArgs }, + argsByFunction: cloneArgsByFunction(state.form.argsByFunction), + keyAliases: [...state.form.keyAliases], + identityLabels: { ...state.form.identityLabels }, + savedCountsByFunction: savedCounts.byFunction, + savedTotalCount: savedCounts.total, + }; +} + +async function loadSavedScenario( + keyAliases: string[], + identityLabels: IdentityLabels, + functionName?: string, + suppressEmptyMessage = false, +): Promise { + if (!activeState) { + return; + } + + const records = listMatchingSilverScriptLaunchConfigs( + activeState.scriptUri, + ).filter( + (record) => { + if (!functionName) { + return true; + } + return record.config.function === functionName; + }, + ); + if (records.length === 0) { + if (!suppressEmptyMessage) { + void vscode.window.showInformationMessage( + functionName + ? `No saved SilverScript launch configs were found for '${functionName}'.` + : "No saved SilverScript launch configs were found for this file.", + ); + } + return; + } + + const picked = await vscode.window.showQuickPick( + buildSavedScenarioPickItems( + activeState.model, + records, + functionName, + ), + { + title: functionName + ? `Load Saved Scenario for '${functionName}'` + : "Load SilverScript Launch Config", + placeHolder: functionName + ? `Select a saved launch config for '${functionName}'` + : "Select a saved launch config for this contract, grouped by entrypoint", + }, + ); + + if (!picked?.record) { + return; + } + + const model = await readModel(activeState.scriptUri); + activeState = { + scriptUri: activeState.scriptUri, + model, + form: formFromLaunchConfig( + model, + picked.record.config, + undefined, + keyAliases, + identityLabels, + ), + baseConfig: { ...picked.record.config }, + record: picked.record, + }; + await renderActiveState(); +} + +function selectEntrypoint( + state: PanelHostState, + initialFunction?: string, +): void { + if (!initialFunction) { + return; + } + + const entrypoint = state.model.entrypoints.find( + (item) => item.name === initialFunction, + ); + if (!entrypoint) { + return; + } + + state.form.function = initialFunction; + if (!state.form.argsByFunction[initialFunction]) { + state.form.argsByFunction[initialFunction] = defaultsForParams( + entrypoint.params, + ); + } +} + +async function saveScenario(): Promise { + if (!activeState) { + return; + } + + const folder = vscode.workspace.getWorkspaceFolder(activeState.scriptUri); + if (!folder) { + void vscode.window.showErrorMessage( + "SilverScript launch configs require the script to be inside a workspace folder.", + ); + return; + } + + if (activeState.record) { + const name = resolvedLaunchName(activeState); + const config = savedLaunchConfigForPanel(activeState, name); + const updated = await updateSilverScriptLaunchConfig( + activeState.record, + config, + ); + activeState.baseConfig = config; + activeState.record = updated; + await renderActiveState(); + void vscode.window.showInformationMessage( + `Updated '${name}' in launch.json.`, + ); + return; + } + + const name = await vscode.window.showInputBox({ + title: "Save SilverScript Launch Config", + prompt: "Name for this saved debugger scenario", + value: defaultLaunchName( + activeState.model, + activeState.scriptUri.fsPath, + ), + ignoreFocusOut: true, + validateInput: (value) => + value.trim() ? null : "Name is required.", + }); + + if (!name) { + return; + } + + const config = savedLaunchConfigForPanel(activeState, name.trim()); + const record = await createSilverScriptLaunchConfig(folder, config); + activeState.baseConfig = config; + activeState.record = record; + await renderActiveState(); + void vscode.window.showInformationMessage( + `Saved '${name.trim()}' to launch.json.`, + ); +} + +async function launchFromPanel( + context: vscode.ExtensionContext, + out: vscode.OutputChannel, + kind: LaunchKind, +): Promise { + if (!activeState) { + return; + } + + if (launchInProgress) { + void vscode.window.showWarningMessage( + "A SilverScript run/debug launch is already in progress.", + ); + return; + } + + const existingSession = activeSilverScriptSession(); + if (existingSession) { + await vscode.commands.executeCommand( + "workbench.action.debug.continue", + ); + return; + } + + try { + launchInProgress = true; + const config = launchConfigForPanel(activeState, kind === "run"); + const folder = + vscode.workspace.getWorkspaceFolder(activeState.scriptUri) ?? + vscode.workspace.workspaceFolders?.[0]; + + if (kind === "run") { + const output = await runDebuggerAdapterCommand( + context, + ["--run-config-json", JSON.stringify(config)], + out, + ); + if (output && output !== RUN_SUCCESS_MESSAGE) { + out.show(true); + } + void vscode.window.showInformationMessage( + RUN_SUCCESS_MESSAGE, + ); + return; + } + + await vscode.debug.startDebugging(folder, config, { + noDebug: false, + }); + } catch (error) { + out.show(true); + void vscode.window.showErrorMessage( + `SilverScript ${kind} failed: ${(error as Error).message}`, + ); + } finally { + launchInProgress = false; + } +} + +async function renderActiveState(): Promise { + if (!panel || !activeState) { + return; + } + if (!extensionContext) { + throw new Error("SilverScript quick launch panel is not initialized."); + } + + panel.title = activeState.model.name; + panel.webview.html = await buildQuickLaunchHtml( + extensionContext, + panel.webview, + activeState.model, + buildWebviewState(activeState), + ); +} + +async function openPanel( + context: vscode.ExtensionContext, + out: vscode.OutputChannel, + uri?: vscode.Uri, + initialFunction?: string, +): Promise { + if (!ensureTrustedWorkspace()) { + return; + } + + const scriptUri = resolveActiveScriptUri(uri); + if (!scriptUri) { + vscode.window.showErrorMessage("Open a .sil file first."); + return; + } + + if ( + panel && + activeState && + activeState.scriptUri.fsPath === scriptUri.fsPath + ) { + selectEntrypoint(activeState, initialFunction); + await renderActiveState(); + panel.reveal(vscode.ViewColumn.Beside, true); + await focusPrimaryEditor(scriptUri); + return; + } + + activeState = await buildInitialState(scriptUri, initialFunction); + emitPanelStateChanged(); + + if (!panel) { + panel = vscode.window.createWebviewPanel( + "silverscriptRunner", + activeState.model.name, + vscode.ViewColumn.Beside, + { + enableScripts: true, + localResourceRoots: [quickLaunchWebviewRoot(context)], + retainContextWhenHidden: true, + }, + ); + + panel.webview.onDidReceiveMessage( + async (message: PanelMessage) => { + if (!activeState) { + return; + } + + applyMessageState(activeState, message.form); + + switch (message.kind) { + case "loadSaved": + await loadSavedScenario( + message.form.keyAliases, + message.form.identityLabels, + ); + return; + case "saveSaved": + await saveScenario(); + return; + case "run": + await launchFromPanel(context, out, "run"); + return; + case "debug": + await launchFromPanel(context, out, "debug"); + return; + default: + return; + } + }, + undefined, + [], + ); + + panel.onDidDispose(() => { + panel = undefined; + activeState = undefined; + launchInProgress = false; + emitPanelStateChanged(); + }); + } else { + panel.reveal(vscode.ViewColumn.Beside, true); + } + + await renderActiveState(); + await focusPrimaryEditor(scriptUri); +} + +async function showSavedScenarios( + context: vscode.ExtensionContext, + out: vscode.OutputChannel, + uri?: vscode.Uri, + initialFunction?: string, + showPicker = true, +): Promise { + await openPanel(context, out, uri, initialFunction); + if (!showPicker || !activeState) { + return; + } + + await loadSavedScenario( + activeState.form.keyAliases, + activeState.form.identityLabels, + initialFunction, + true, + ); +} + +async function handlePrimaryCodeLensAction( + context: vscode.ExtensionContext, + out: vscode.OutputChannel, + uri?: vscode.Uri, +): Promise { + const scriptUri = resolveActiveScriptUri(uri); + if (scriptUri && hasOpenSilverScriptPanelForUri(scriptUri)) { + await triggerPanelLaunch("run"); + return; + } + + await openPanel(context, out, uri); +} + +async function triggerPanelLaunch( + launchKind: LaunchKind, +): Promise { + if (!panel) { + return; + } + + await panel.webview.postMessage({ + kind: "triggerLaunch", + launchKind, + } satisfies PanelControlMessage); +} + +async function handlePanelF5( + context: vscode.ExtensionContext, + out: vscode.OutputChannel, + uri?: vscode.Uri, + initialFunction?: string, +): Promise { + const scriptUri = resolveActiveScriptUri(uri); + if ( + panel && + activeState && + (!scriptUri || activeState.scriptUri.fsPath === scriptUri.fsPath) + ) { + await triggerPanelLaunch("debug"); + return; + } + + await openPanel(context, out, uri, initialFunction); +} + +export function registerSilverScriptQuickLaunchPanel( + context: vscode.ExtensionContext, + out: vscode.OutputChannel, +): void { + extensionContext = context; + context.subscriptions.push( + vscode.commands.registerCommand( + "silverscript.debug.configureLaunch", + (uri?: vscode.Uri, initialFunction?: string) => + openPanel(context, out, uri, initialFunction), + ), + ); + context.subscriptions.push( + vscode.commands.registerCommand( + "silverscript.debug.f5", + (uri?: vscode.Uri, initialFunction?: string) => + handlePanelF5(context, out, uri, initialFunction), + ), + ); + context.subscriptions.push( + vscode.commands.registerCommand( + "silverscript.debug.primaryCodeLensAction", + (uri?: vscode.Uri) => + handlePrimaryCodeLensAction(context, out, uri), + ), + ); + context.subscriptions.push( + vscode.commands.registerCommand( + "silverscript.debug.showSavedScenarios", + ( + uri?: vscode.Uri, + initialFunction?: string, + showPicker?: boolean, + ) => + showSavedScenarios( + context, + out, + uri, + initialFunction, + showPicker ?? true, + ), + ), + ); + context.subscriptions.push( + vscode.window.onDidChangeActiveTextEditor((editor) => { + void handleActiveEditorChange(editor); + }), + ); +} diff --git a/extensions/vscode/src/quickLaunch/view.ts b/extensions/vscode/src/quickLaunch/view.ts new file mode 100644 index 00000000..0bf85c20 --- /dev/null +++ b/extensions/vscode/src/quickLaunch/view.ts @@ -0,0 +1,88 @@ +import * as fs from "fs/promises"; +import * as path from "path"; +import * as vscode from "vscode"; +import type { ContractModel } from "../contractModel"; + +export type QuickLaunchWebviewState = { + function: string; + constructorArgs: Record; + argsByFunction: Record>; + keyAliases: string[]; + identityLabels: Record; + savedCountsByFunction: Record; + savedTotalCount: number; +}; + +const QUICK_LAUNCH_TEMPLATE = ["webviews", "quickLaunch", "panel.html"]; +const QUICK_LAUNCH_SCRIPT = ["webviews", "quickLaunch", "panel.js"]; +const QUICK_LAUNCH_STYLE = ["webviews", "quickLaunch", "panel.css"]; + +let quickLaunchTemplatePromise: Promise | undefined; + +function webviewAssetUri( + context: vscode.ExtensionContext, + ...segments: string[] +): vscode.Uri { + return vscode.Uri.joinPath(context.extensionUri, ...segments); +} + +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(//g, ">"); +} + +function stringifyForHtml(value: unknown): string { + return JSON.stringify(value) + .replace(//g, "\\u003e") + .replace(/&/g, "\\u0026") + .replace(/\u2028/g, "\\u2028") + .replace(/\u2029/g, "\\u2029"); +} + +async function loadTemplate( + context: vscode.ExtensionContext, +): Promise { + if (!quickLaunchTemplatePromise) { + quickLaunchTemplatePromise = fs.readFile( + path.join(context.extensionPath, ...QUICK_LAUNCH_TEMPLATE), + "utf8", + ); + } + return quickLaunchTemplatePromise; +} + +export function quickLaunchWebviewRoot( + context: vscode.ExtensionContext, +): vscode.Uri { + return webviewAssetUri(context, "webviews", "quickLaunch"); +} + +export async function buildQuickLaunchHtml( + context: vscode.ExtensionContext, + webview: vscode.Webview, + model: ContractModel, + initialState: QuickLaunchWebviewState, +): Promise { + const template = await loadTemplate(context); + const replacements = { + "{{CSP_SOURCE}}": webview.cspSource, + "{{STYLE_URI}}": webview + .asWebviewUri(webviewAssetUri(context, ...QUICK_LAUNCH_STYLE)) + .toString(), + "{{SCRIPT_URI}}": webview + .asWebviewUri(webviewAssetUri(context, ...QUICK_LAUNCH_SCRIPT)) + .toString(), + "{{TITLE}}": escapeHtml(model.name), + "{{MODEL_JSON}}": stringifyForHtml(model), + "{{STATE_JSON}}": stringifyForHtml(initialState), + } as const; + + return Object.entries(replacements).reduce( + (html, [needle, value]) => html.replaceAll(needle, value), + template, + ); +} diff --git a/extensions/vscode/src/quickLaunchPanel.ts b/extensions/vscode/src/quickLaunchPanel.ts deleted file mode 100644 index c9cb1564..00000000 --- a/extensions/vscode/src/quickLaunchPanel.ts +++ /dev/null @@ -1,1749 +0,0 @@ -import * as fs from "fs"; -import * as path from "path"; -import * as vscode from "vscode"; -import { - defaultForType, - type DebugArgInput, - type DebugArgObject, - parseContractModel, - type ContractModel, - type ContractParam, -} from "./contractModel"; -import { runDebuggerAdapterCommand } from "./debugAdapter"; -import { - countSilverScriptSavedScenarios, - createSilverScriptLaunchConfig, - defaultLaunchScriptPathValue, - listMatchingSilverScriptLaunchConfigs, - type RawLaunchConfiguration, - type SilverScriptLaunchConfigRecord, - updateSilverScriptLaunchConfig, -} from "./launchConfigs"; - -type LaunchKind = "run" | "debug"; -type IdentityLabels = Record; - -type PanelFormState = { - function: string; - constructorArgs: Record; - argsByFunction: Record>; - keyAliases: string[]; - identityLabels: IdentityLabels; -}; - -type PanelHostState = { - scriptUri: vscode.Uri; - model: ContractModel; - form: PanelFormState; - baseConfig: RawLaunchConfiguration; - record?: SilverScriptLaunchConfigRecord; - loadedConfigName: string | null; -}; - -type PanelMessage = - | { kind: "run"; form: PanelFormState } - | { kind: "debug"; form: PanelFormState } - | { kind: "loadSaved"; form: PanelFormState } - | { kind: "saveSaved"; form: PanelFormState }; - -type PanelControlMessage = { - kind: "triggerLaunch"; - launchKind: LaunchKind; -}; - -type WebviewState = { - function: string; - constructorArgs: Record; - argsByFunction: Record>; - keyAliases: string[]; - identityLabels: IdentityLabels; - loadedConfigName: string | null; - savedCountsByFunction: Record; - savedTotalCount: number; -}; - -const RUN_SUCCESS_MESSAGE = "Execution completed successfully."; - -let panel: vscode.WebviewPanel | undefined; -let activeState: PanelHostState | undefined; -let launchInProgress = false; -let restoringPrimaryEditor = false; -const panelStateEmitter = new vscode.EventEmitter(); -const IDENTITY_ALIAS_RE = - /^(?:keypair|identity)([1-9]\d*)(?:\.(pubkey|secret|pkh))?$/; - -function emitPanelStateChanged(): void { - panelStateEmitter.fire(); -} - -export const onDidChangeSilverScriptPanelState = - panelStateEmitter.event; - -function isDebugArgInput(value: unknown): value is DebugArgInput { - return ( - Array.isArray(value) || - (value !== null && typeof value === "object") - ); -} - -function activeSilverScriptSession(): vscode.DebugSession | undefined { - const session = vscode.debug.activeDebugSession; - return session?.type === "silverscript" ? session : undefined; -} - -export function hasOpenSilverScriptPanelForUri( - uri?: vscode.Uri, -): boolean { - if (!panel || !activeState || !uri) { - return false; - } - - return activeState.scriptUri.fsPath === uri.fsPath; -} - -function resolveActiveScriptUri(uri?: vscode.Uri): vscode.Uri | undefined { - if (uri) { - return uri; - } - const activeDoc = vscode.window.activeTextEditor?.document; - if (activeDoc?.languageId === "silverscript") { - return activeDoc.uri; - } - return undefined; -} - -function ensureTrustedWorkspace(): boolean { - if (vscode.workspace.isTrusted) { - return true; - } - - void vscode.window.showWarningMessage( - "SilverScript run/debug requires a trusted workspace.", - ); - return false; -} - -function getNonce(): string { - const alphabet = - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; - let value = ""; - for (let index = 0; index < 32; index += 1) { - value += alphabet.charAt( - Math.floor(Math.random() * alphabet.length), - ); - } - return value; -} - -function stringifyForInlineScript(value: unknown): string { - return JSON.stringify(value) - .replace(//g, "\\u003e") - .replace(/&/g, "\\u0026") - .replace(/\u2028/g, "\\u2028") - .replace(/\u2029/g, "\\u2029"); -} - -function stringifyLaunchArg(value: unknown): string { - if (typeof value === "string") { - return value; - } - if ( - Array.isArray(value) || - (value !== null && typeof value === "object") - ) { - return JSON.stringify(value); - } - return String(value); -} - -function defaultsForParams( - params: ContractParam[], -): Record { - return Object.fromEntries( - params.map((param) => [ - param.name, - stringifyLaunchArg(defaultForType(param.type)), - ]), - ); -} - -function valuesForParams( - params: ContractParam[], - input: DebugArgInput | undefined, -): Record { - const defaults = defaultsForParams(params); - if (Array.isArray(input)) { - for (const [index, param] of params.entries()) { - if (index < input.length) { - defaults[param.name] = stringifyLaunchArg(input[index]); - } - } - return defaults; - } - if (input && typeof input === "object") { - for (const param of params) { - if (Object.prototype.hasOwnProperty.call(input, param.name)) { - defaults[param.name] = stringifyLaunchArg(input[param.name]); - } - } - } - return defaults; -} - -function defaultLaunchName( - model: ContractModel, - scriptPath: string, -): string { - return model.name && model.name !== "Unknown" - ? `SilverScript: ${model.name}` - : `SilverScript: ${path.basename(scriptPath)}`; -} - -function normalizeKeyAliases( - aliases: readonly string[], - constructorArgs: Record, - argsByFunction: Record>, -): string[] { - const found = new Map(); - - const consider = (raw: string | undefined) => { - if (!raw) { - return; - } - const match = IDENTITY_ALIAS_RE.exec(raw.trim()); - if (!match) { - return; - } - const index = Number(match[1]); - if (!found.has(index)) { - found.set(index, `keypair${index}`); - } - }; - - aliases.forEach(consider); - Object.values(constructorArgs).forEach((value) => consider(value)); - Object.values(argsByFunction).forEach((args) => { - Object.values(args).forEach((value) => consider(value)); - }); - - const normalized = [...found.entries()] - .sort((left, right) => left[0] - right[0]) - .map(([, alias]) => alias); - return normalized; -} - -function normalizeIdentityLabels( - aliases: readonly string[], - labels: IdentityLabels, -): IdentityLabels { - const normalized: IdentityLabels = {}; - for (const alias of aliases) { - const label = labels[alias]?.trim(); - if (label && label !== alias) { - normalized[alias] = label; - } - } - return normalized; -} - -async function focusPrimaryEditor( - scriptUri: vscode.Uri, -): Promise { - if (restoringPrimaryEditor) { - return; - } - - restoringPrimaryEditor = true; - try { - const document = await vscode.workspace.openTextDocument(scriptUri); - await vscode.window.showTextDocument(document, { - viewColumn: vscode.ViewColumn.One, - preview: false, - preserveFocus: false, - }); - } finally { - restoringPrimaryEditor = false; - } -} - -async function keepSilverScriptEditorOnPrimary( - editor: vscode.TextEditor | undefined, -): Promise { - if ( - restoringPrimaryEditor || - !panel || - !editor || - editor.document.languageId !== "silverscript" || - editor.viewColumn === vscode.ViewColumn.One || - panel.viewColumn === undefined || - editor.viewColumn !== panel.viewColumn - ) { - return; - } - - await focusPrimaryEditor(editor.document.uri); - if (panel) { - panel.reveal(vscode.ViewColumn.Beside, true); - } -} - -async function followActiveSilverScript( - editor: vscode.TextEditor | undefined, -): Promise { - if ( - !panel || - !editor || - editor.document.languageId !== "silverscript" || - !activeState || - activeState.scriptUri.fsPath === editor.document.uri.fsPath - ) { - return; - } - - activeState = await buildInitialState(editor.document.uri); - emitPanelStateChanged(); - await renderActiveState(); -} - -async function handleActiveEditorChange( - editor: vscode.TextEditor | undefined, -): Promise { - await keepSilverScriptEditorOnPrimary(editor); - await followActiveSilverScript(editor); -} - -function defaultPanelFormState( - model: ContractModel, - initialFunction?: string, -): PanelFormState { - const selectedFunction = - initialFunction && - model.entrypoints.some((entry) => entry.name === initialFunction) - ? initialFunction - : model.entrypoints[0]?.name ?? ""; - - const argsByFunction: Record> = {}; - for (const entrypoint of model.entrypoints) { - argsByFunction[entrypoint.name] = defaultsForParams( - entrypoint.params, - ); - } - - return { - function: selectedFunction, - constructorArgs: defaultsForParams(model.constructorParams), - argsByFunction, - keyAliases: normalizeKeyAliases( - [], - defaultsForParams(model.constructorParams), - argsByFunction, - ), - identityLabels: {}, - }; -} - -function formFromLaunchConfig( - model: ContractModel, - config: RawLaunchConfiguration, - initialFunction: string | undefined, - keyAliases: string[], - identityLabels: IdentityLabels, -): PanelFormState { - const configuredFunction = - typeof config.function === "string" ? config.function : undefined; - const selectedFunction = - initialFunction && - model.entrypoints.some((entry) => entry.name === initialFunction) - ? initialFunction - : configuredFunction && - model.entrypoints.some( - (entry) => entry.name === configuredFunction, - ) - ? configuredFunction - : model.entrypoints[0]?.name ?? ""; - - const constructorArgs = isDebugArgInput(config.constructorArgs) - ? config.constructorArgs - : undefined; - const configuredArgs = - configuredFunction === selectedFunction && - isDebugArgInput(config.args) - ? config.args - : undefined; - - const argsByFunction: Record> = {}; - for (const entrypoint of model.entrypoints) { - argsByFunction[entrypoint.name] = valuesForParams( - entrypoint.params, - entrypoint.name === selectedFunction ? configuredArgs : undefined, - ); - } - - const constructorValues = valuesForParams( - model.constructorParams, - constructorArgs, - ); - const normalizedAliases = normalizeKeyAliases( - keyAliases, - constructorValues, - argsByFunction, - ); - const form = { - function: selectedFunction, - constructorArgs: constructorValues, - argsByFunction, - keyAliases: normalizedAliases, - identityLabels: normalizeIdentityLabels( - normalizedAliases, - identityLabels, - ), - }; - return form; -} - -function currentArgs( - form: PanelFormState, -): DebugArgObject { - return { ...(form.argsByFunction[form.function] ?? {}) }; -} - -function applyMessageState( - state: PanelHostState, - form: PanelFormState, -): void { - const normalizedAliases = normalizeKeyAliases( - form.keyAliases, - form.constructorArgs, - form.argsByFunction, - ); - state.form = { - function: form.function, - constructorArgs: { ...form.constructorArgs }, - argsByFunction: Object.fromEntries( - Object.entries(form.argsByFunction).map( - ([entrypoint, args]) => [entrypoint, { ...args }], - ), - ), - keyAliases: normalizedAliases, - identityLabels: normalizeIdentityLabels( - normalizedAliases, - form.identityLabels, - ), - }; -} - -function matchingLaunchConfigs( - scriptUri: vscode.Uri, -): SilverScriptLaunchConfigRecord[] { - return listMatchingSilverScriptLaunchConfigs(scriptUri); -} - -async function readModel( - scriptUri: vscode.Uri, -): Promise { - const source = await fs.promises.readFile(scriptUri.fsPath, "utf8"); - return parseContractModel(source); -} - -async function buildInitialState( - scriptUri: vscode.Uri, - initialFunction?: string, - keyAliases: string[] = [], - identityLabels: IdentityLabels = {}, -): Promise { - const model = await readModel(scriptUri); - const record = matchingLaunchConfigs(scriptUri)[0]; - - if (record) { - return { - scriptUri, - model, - form: formFromLaunchConfig( - model, - record.config, - initialFunction, - keyAliases, - identityLabels, - ), - baseConfig: { ...record.config }, - record, - loadedConfigName: - typeof record.config.name === "string" - ? record.config.name - : null, - }; - } - - return { - scriptUri, - model, - form: defaultPanelFormState(model, initialFunction), - baseConfig: { - type: "silverscript", - request: "launch", - name: defaultLaunchName(model, scriptUri.fsPath), - stopOnEntry: true, - }, - loadedConfigName: null, - }; -} - -function launchConfigForPanel( - state: PanelHostState, - noDebug: boolean, -): RawLaunchConfiguration { - return { - ...state.baseConfig, - type: "silverscript", - request: "launch", - name: - typeof state.baseConfig.name === "string" && - state.baseConfig.name.trim() - ? state.baseConfig.name - : defaultLaunchName(state.model, state.scriptUri.fsPath), - scriptPath: state.scriptUri.fsPath, - function: state.form.function, - constructorArgs: { ...state.form.constructorArgs }, - args: currentArgs(state.form), - noDebug, - stopOnEntry: !noDebug, - }; -} - -function savedLaunchConfigForPanel( - state: PanelHostState, - name: string, -): RawLaunchConfiguration { - const folder = vscode.workspace.getWorkspaceFolder(state.scriptUri); - if (!folder) { - throw new Error( - "SilverScript launch configs require the script to be inside a workspace folder.", - ); - } - - const config: RawLaunchConfiguration = { - ...state.baseConfig, - type: "silverscript", - request: "launch", - name, - scriptPath: defaultLaunchScriptPathValue(state.scriptUri, folder), - function: state.form.function, - constructorArgs: { ...state.form.constructorArgs }, - args: currentArgs(state.form), - }; - delete config.paramsFile; - delete config.noDebug; - return config; -} - -async function loadSavedScenario( - keyAliases: string[], - identityLabels: IdentityLabels, - functionName?: string, - suppressEmptyMessage = false, -): Promise { - if (!activeState) { - return; - } - - const records = matchingLaunchConfigs(activeState.scriptUri).filter( - (record) => { - if (!functionName) { - return true; - } - return record.config.function === functionName; - }, - ); - if (records.length === 0) { - if (!suppressEmptyMessage) { - void vscode.window.showInformationMessage( - functionName - ? `No saved SilverScript launch configs were found for '${functionName}'.` - : "No saved SilverScript launch configs were found for this file.", - ); - } - return; - } - - const picked = await vscode.window.showQuickPick( - functionName - ? records.map((record) => ({ - label: - typeof record.config.name === "string" - ? record.config.name - : path.basename(record.resolvedScriptPath), - description: record.scriptPathValue, - record, - })) - : (() => { - const groups = new Map(); - for (const record of records) { - const group = - typeof record.config.function === "string" && - record.config.function.trim() - ? record.config.function.trim() - : "Other"; - const existing = groups.get(group) ?? []; - existing.push(record); - groups.set(group, existing); - } - - const orderedGroups: string[] = []; - for (const entrypoint of activeState.model.entrypoints) { - if (groups.has(entrypoint.name)) { - orderedGroups.push(entrypoint.name); - } - } - for (const group of [...groups.keys()].sort()) { - if (!orderedGroups.includes(group)) { - orderedGroups.push(group); - } - } - - return orderedGroups.flatMap((group) => { - const groupRecords = groups.get(group) ?? []; - return [ - { - kind: vscode.QuickPickItemKind.Separator, - label: group, - }, - ...groupRecords.map((record) => ({ - label: - typeof record.config.name === "string" - ? record.config.name - : path.basename(record.resolvedScriptPath), - description: record.scriptPathValue, - record, - })), - ]; - }); - })(), - { - title: functionName - ? `Load Saved Scenario for '${functionName}'` - : "Load SilverScript Launch Config", - placeHolder: functionName - ? `Select a saved launch config for '${functionName}'` - : "Select a saved launch config for this contract, grouped by entrypoint", - }, - ); - - if (!picked || !("record" in picked) || !picked.record) { - return; - } - - const model = await readModel(activeState.scriptUri); - activeState = { - scriptUri: activeState.scriptUri, - model, - form: formFromLaunchConfig( - model, - picked.record.config, - undefined, - keyAliases, - identityLabels, - ), - baseConfig: { ...picked.record.config }, - record: picked.record, - loadedConfigName: - typeof picked.record.config.name === "string" - ? picked.record.config.name - : null, - }; - await renderActiveState(); -} - -function selectEntrypoint( - state: PanelHostState, - initialFunction?: string, -): void { - if (!initialFunction) { - return; - } - - const entrypoint = state.model.entrypoints.find( - (item) => item.name === initialFunction, - ); - if (!entrypoint) { - return; - } - - state.form.function = initialFunction; - if (!state.form.argsByFunction[initialFunction]) { - state.form.argsByFunction[initialFunction] = defaultsForParams( - entrypoint.params, - ); - } -} - -async function saveScenario(): Promise { - if (!activeState) { - return; - } - - const folder = vscode.workspace.getWorkspaceFolder(activeState.scriptUri); - if (!folder) { - void vscode.window.showErrorMessage( - "SilverScript launch configs require the script to be inside a workspace folder.", - ); - return; - } - - if (activeState.record) { - const name = - typeof activeState.baseConfig.name === "string" && - activeState.baseConfig.name.trim() - ? activeState.baseConfig.name - : defaultLaunchName( - activeState.model, - activeState.scriptUri.fsPath, - ); - const config = savedLaunchConfigForPanel(activeState, name); - const updated = await updateSilverScriptLaunchConfig( - activeState.record, - config, - ); - activeState.baseConfig = config; - activeState.record = updated; - activeState.loadedConfigName = name; - await renderActiveState(); - void vscode.window.showInformationMessage( - `Updated '${name}' in launch.json.`, - ); - return; - } - - const name = await vscode.window.showInputBox({ - title: "Save SilverScript Launch Config", - prompt: "Name for this saved debugger scenario", - value: defaultLaunchName( - activeState.model, - activeState.scriptUri.fsPath, - ), - ignoreFocusOut: true, - validateInput: (value) => - value.trim() ? null : "Name is required.", - }); - - if (!name) { - return; - } - - const config = savedLaunchConfigForPanel(activeState, name.trim()); - const record = await createSilverScriptLaunchConfig(folder, config); - activeState.baseConfig = config; - activeState.record = record; - activeState.loadedConfigName = name.trim(); - await renderActiveState(); - void vscode.window.showInformationMessage( - `Saved '${name.trim()}' to launch.json.`, - ); -} - -async function launchFromPanel( - context: vscode.ExtensionContext, - out: vscode.OutputChannel, - kind: LaunchKind, -): Promise { - if (!activeState) { - return; - } - - if (launchInProgress) { - void vscode.window.showWarningMessage( - "A SilverScript run/debug launch is already in progress.", - ); - return; - } - - const existingSession = activeSilverScriptSession(); - if (existingSession) { - await vscode.commands.executeCommand( - "workbench.action.debug.continue", - ); - return; - } - - try { - launchInProgress = true; - const config = launchConfigForPanel(activeState, kind === "run"); - const folder = - vscode.workspace.getWorkspaceFolder(activeState.scriptUri) ?? - vscode.workspace.workspaceFolders?.[0]; - - if (kind === "run") { - const output = await runDebuggerAdapterCommand( - context, - ["--run-config-json", JSON.stringify(config)], - out, - ); - if (output && output !== RUN_SUCCESS_MESSAGE) { - out.show(true); - } - void vscode.window.showInformationMessage( - RUN_SUCCESS_MESSAGE, - ); - return; - } - - await vscode.debug.startDebugging(folder, config, { - noDebug: false, - }); - } catch (error) { - out.show(true); - void vscode.window.showErrorMessage( - `SilverScript ${kind} failed: ${(error as Error).message}`, - ); - } finally { - launchInProgress = false; - } -} - -async function renderActiveState(): Promise { - if (!panel || !activeState) { - return; - } - - const savedCounts = countSilverScriptSavedScenarios( - activeState.scriptUri, - ); - panel.title = activeState.model.name; - panel.webview.html = buildHtml( - activeState.model, - activeState.scriptUri.fsPath, - { - function: activeState.form.function, - constructorArgs: { ...activeState.form.constructorArgs }, - argsByFunction: Object.fromEntries( - Object.entries(activeState.form.argsByFunction).map( - ([entrypoint, args]) => [entrypoint, { ...args }], - ), - ), - keyAliases: [...activeState.form.keyAliases], - identityLabels: { ...activeState.form.identityLabels }, - loadedConfigName: activeState.loadedConfigName, - savedCountsByFunction: savedCounts.byFunction, - savedTotalCount: savedCounts.total, - }, - ); -} - -async function openPanel( - context: vscode.ExtensionContext, - out: vscode.OutputChannel, - uri?: vscode.Uri, - initialFunction?: string, -): Promise { - if (!ensureTrustedWorkspace()) { - return; - } - - const scriptUri = resolveActiveScriptUri(uri); - if (!scriptUri) { - vscode.window.showErrorMessage("Open a .sil file first."); - return; - } - - if ( - panel && - activeState && - activeState.scriptUri.fsPath === scriptUri.fsPath - ) { - selectEntrypoint(activeState, initialFunction); - await renderActiveState(); - panel.reveal(vscode.ViewColumn.Beside, true); - await focusPrimaryEditor(scriptUri); - return; - } - - activeState = await buildInitialState(scriptUri, initialFunction); - emitPanelStateChanged(); - - if (!panel) { - panel = vscode.window.createWebviewPanel( - "silverscriptRunner", - activeState.model.name, - vscode.ViewColumn.Beside, - { - enableScripts: true, - localResourceRoots: [], - retainContextWhenHidden: true, - }, - ); - - panel.webview.onDidReceiveMessage( - async (message: PanelMessage) => { - if (!activeState) { - return; - } - - applyMessageState(activeState, message.form); - - switch (message.kind) { - case "loadSaved": - await loadSavedScenario( - message.form.keyAliases, - message.form.identityLabels, - ); - return; - case "saveSaved": - await saveScenario(); - return; - case "run": - await launchFromPanel(context, out, "run"); - return; - case "debug": - await launchFromPanel(context, out, "debug"); - return; - default: - return; - } - }, - undefined, - [], - ); - - panel.onDidDispose(() => { - panel = undefined; - activeState = undefined; - launchInProgress = false; - emitPanelStateChanged(); - }); - } else { - panel.reveal(vscode.ViewColumn.Beside, true); - } - - await renderActiveState(); - await focusPrimaryEditor(scriptUri); -} - -async function showSavedScenarios( - context: vscode.ExtensionContext, - out: vscode.OutputChannel, - uri?: vscode.Uri, - initialFunction?: string, - showPicker = true, -): Promise { - await openPanel(context, out, uri, initialFunction); - if (!showPicker || !activeState) { - return; - } - - await loadSavedScenario( - activeState.form.keyAliases, - activeState.form.identityLabels, - initialFunction, - true, - ); -} - -async function handlePrimaryCodeLensAction( - context: vscode.ExtensionContext, - out: vscode.OutputChannel, - uri?: vscode.Uri, -): Promise { - const scriptUri = resolveActiveScriptUri(uri); - if (scriptUri && hasOpenSilverScriptPanelForUri(scriptUri)) { - await triggerPanelLaunch("run"); - return; - } - - await openPanel(context, out, uri); -} - -async function triggerPanelLaunch( - launchKind: LaunchKind, -): Promise { - if (!panel) { - return; - } - - await panel.webview.postMessage({ - kind: "triggerLaunch", - launchKind, - } satisfies PanelControlMessage); -} - -async function handlePanelF5( - context: vscode.ExtensionContext, - out: vscode.OutputChannel, - uri?: vscode.Uri, - initialFunction?: string, -): Promise { - const scriptUri = resolveActiveScriptUri(uri); - if ( - panel && - activeState && - (!scriptUri || activeState.scriptUri.fsPath === scriptUri.fsPath) - ) { - await triggerPanelLaunch("debug"); - return; - } - - await openPanel(context, out, uri, initialFunction); -} - -function escHtml(value: string): string { - return value - .replace(/&/g, "&") - .replace(/"/g, """) - .replace(//g, ">"); -} - -function buildHtml( - model: ContractModel, - scriptPath: string, - initialState: WebviewState, -): string { - const nonce = getNonce(); - const modelJson = stringifyForInlineScript(model); - const stateJson = stringifyForInlineScript(initialState); - - return ` - - - - - - - - -
-

${escHtml(model.name)}

-
-
- - -
-
-
- -
-

Constructor

-
-
- -
-

Entrypoint

- -
- -
-

Function Args

-
-
- -
- - -
- - - -`; -} - -export function registerSilverScriptQuickLaunchPanel( - context: vscode.ExtensionContext, - out: vscode.OutputChannel, -): void { - context.subscriptions.push( - vscode.commands.registerCommand( - "silverscript.debug.configureLaunch", - (uri?: vscode.Uri, initialFunction?: string) => - openPanel(context, out, uri, initialFunction), - ), - ); - context.subscriptions.push( - vscode.commands.registerCommand( - "silverscript.debug.f5", - (uri?: vscode.Uri, initialFunction?: string) => - handlePanelF5(context, out, uri, initialFunction), - ), - ); - context.subscriptions.push( - vscode.commands.registerCommand( - "silverscript.debug.primaryCodeLensAction", - (uri?: vscode.Uri) => - handlePrimaryCodeLensAction(context, out, uri), - ), - ); - context.subscriptions.push( - vscode.commands.registerCommand( - "silverscript.debug.showSavedScenarios", - ( - uri?: vscode.Uri, - initialFunction?: string, - showPicker?: boolean, - ) => - showSavedScenarios( - context, - out, - uri, - initialFunction, - showPicker ?? true, - ), - ), - ); - context.subscriptions.push( - vscode.window.onDidChangeActiveTextEditor((editor) => { - void handleActiveEditorChange(editor); - }), - ); -} diff --git a/extensions/vscode/webviews/quickLaunch/panel.css b/extensions/vscode/webviews/quickLaunch/panel.css new file mode 100644 index 00000000..c045da0e --- /dev/null +++ b/extensions/vscode/webviews/quickLaunch/panel.css @@ -0,0 +1,262 @@ +:root { + --bg: var(--vscode-editor-background); + --fg: var(--vscode-editor-foreground); + --input-bg: var(--vscode-input-background); + --input-fg: var(--vscode-input-foreground); + --input-border: var(--vscode-input-border, transparent); + --panel-bg: rgba(127, 127, 127, 0.03); + --panel-hover: var(--vscode-toolbar-hoverBackground, rgba(128, 128, 128, 0.1)); + --btn: var(--vscode-button-background); + --btn-fg: var(--vscode-button-foreground); + --btn-hover: var(--vscode-button-hoverBackground); + --btn-secondary-bg: transparent; + --btn-secondary-fg: var(--fg); + --btn-secondary-hover: var(--panel-hover); + --focus: var(--vscode-focusBorder); + --muted: rgba(127, 127, 127, 0.75); + --sep: var(--vscode-widget-border, rgba(128, 128, 128, 0.25)); + --badge: var(--vscode-badge-background); + --badge-fg: var(--vscode-badge-foreground); +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + padding: 16px; + color: var(--fg); + background: var(--bg); + font: 13px/1.45 var(--vscode-font-family, system-ui, sans-serif); +} + +.header-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin: 0 0 14px; +} + +h1 { + flex: 1; + min-width: 0; + margin: 0; + font-size: 16px; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.topbar { + display: flex; + align-items: center; + flex: none; +} + +.topbar-actions { + display: flex; + gap: 6px; +} + +section { + margin-bottom: 14px; +} + +h2 { + margin: 0 0 8px; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--muted); +} + +label { + display: block; + margin: 10px 0 4px; + font-size: 12px; +} + +.meta { + color: var(--muted); + font-size: 11px; + margin-left: 6px; +} + +.badge { + margin-left: 6px; + padding: 1px 5px; + border-radius: 3px; + background: var(--badge); + color: var(--badge-fg); + font-size: 10px; + font-weight: 600; + vertical-align: middle; +} + +input, +select { + width: 100%; + min-height: 34px; + padding: 6px 10px; + border-radius: 4px; + border: 1px solid var(--input-border); + background: var(--input-bg); + color: var(--input-fg); + outline: none; + font: 13px var(--vscode-editor-font-family, monospace); +} + +input:focus, +select:focus { + border-color: var(--focus); +} + +.empty { + margin: 0; + color: var(--muted); + font-style: italic; +} + +.actions { + display: flex; + gap: 8px; + margin-top: 16px; +} + +button { + flex: 1; + min-height: 36px; + padding: 8px 0; + border: 0; + border-radius: 4px; + background: var(--btn); + color: var(--btn-fg); + font-size: 13px; + font-weight: 600; + cursor: pointer; +} + +button:hover { + background: var(--btn-hover); +} + +.secondary-button { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: auto; + min-width: 0; + min-height: 32px; + padding: 0 12px; + border: 1px solid var(--sep); + border-radius: 4px; + background: var(--btn-secondary-bg); + color: var(--btn-secondary-fg); + font-size: 11px; + font-weight: 600; + line-height: 1; +} + +.secondary-button:hover { + background: var(--btn-secondary-hover); +} + +.compact-button { + flex: none; +} + +.field-row { + position: relative; + display: flex; + align-items: center; + gap: 4px; +} + +.field-row input { + flex: 1; +} + +.field-row input.crypto-input { + cursor: pointer; +} + +.field-row .field-action { + min-width: 64px; + padding: 0 10px; +} + +.identity-dropdown { + position: absolute; + z-index: 100; + top: calc(100% + 4px); + left: 0; + right: 0; + padding: 4px 0; + border: 1px solid var(--sep); + border-radius: 6px; + background: var(--input-bg); + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.28); +} + +.identity-choice { + display: flex; + align-items: center; + gap: 8px; + justify-content: space-between; + margin: 0 4px; + padding: 7px 10px; + border-radius: 4px; + cursor: pointer; +} + +.identity-choice:hover { + background: var(--btn); + color: var(--btn-fg); +} + +.identity-choice-name { + display: block; + font-weight: 700; +} + +.identity-choice-main { + flex: 1; + min-width: 0; +} + +.identity-choice-value { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 11px; + opacity: 0.72; +} + +.identity-choice-delete { + flex: none; + min-width: 18px; + min-height: 18px; + padding: 0; + border: 0; + border-radius: 3px; + background: transparent; + color: var(--muted); + font-size: 14px; + line-height: 1; +} + +.identity-choice-delete:hover { + background: var(--panel-hover); + color: var(--vscode-errorForeground, var(--fg)); +} + +.identity-divider { + margin: 4px 0; + border-top: 1px solid var(--sep); +} diff --git a/extensions/vscode/webviews/quickLaunch/panel.html b/extensions/vscode/webviews/quickLaunch/panel.html new file mode 100644 index 00000000..13eee626 --- /dev/null +++ b/extensions/vscode/webviews/quickLaunch/panel.html @@ -0,0 +1,47 @@ + + + + + + + + + +
+

{{TITLE}}

+
+
+ + +
+
+
+ +
+

Constructor

+
+
+ +
+

Entrypoint

+ +
+ +
+

Function Args

+
+
+ +
+ + +
+ + + + + + diff --git a/extensions/vscode/webviews/quickLaunch/panel.js b/extensions/vscode/webviews/quickLaunch/panel.js new file mode 100644 index 00000000..558d7035 --- /dev/null +++ b/extensions/vscode/webviews/quickLaunch/panel.js @@ -0,0 +1,511 @@ +(function () { + const modelElement = document.getElementById("quick-launch-model"); + const stateElement = document.getElementById("quick-launch-state"); + const ctorFields = document.getElementById("constructor-fields"); + const argFields = document.getElementById("arg-fields"); + const functionSelect = document.getElementById("function-select"); + const loadButton = document.getElementById("load-button"); + + if ( + !modelElement || + !stateElement || + !ctorFields || + !argFields || + !functionSelect || + !loadButton + ) { + throw new Error("Quick launch panel failed to initialize."); + } + + const vscode = acquireVsCodeApi(); + const model = JSON.parse(modelElement.textContent || "null"); + const state = JSON.parse(stateElement.textContent || "null"); + + state.identityLabels = + state.identityLabels && typeof state.identityLabels === "object" + ? state.identityLabels + : {}; + state.savedCountsByFunction = + state.savedCountsByFunction && + typeof state.savedCountsByFunction === "object" + ? state.savedCountsByFunction + : {}; + state.savedTotalCount = Number(state.savedTotalCount) || 0; + + function fieldValue(defaultValue) { + return typeof defaultValue === "string" + ? defaultValue + : String(defaultValue ?? ""); + } + + function escapeHtml(value) { + return String(value ?? "") + .replace(/&/g, "&") + .replace(/"/g, """) + .replace(//g, ">"); + } + + function normalizedType(typeName) { + return String(typeName ?? "").trim().toLowerCase(); + } + + function helperSlot(param) { + const typeName = normalizedType(param.type); + const name = String(param.name ?? "").toLowerCase(); + if (typeName === "pubkey") { + return "pubkey"; + } + if (typeName === "sig") { + return "secret"; + } + if ( + (typeName === "bytes32" || + typeName === "byte[32]" || + typeName === "bytes") && + name.includes("pkh") + ) { + return "pkh"; + } + return null; + } + + function tokenFor(alias, slot) { + return alias + "." + slot; + } + + function canonicalIdentityToken(raw) { + const trimmed = String(raw ?? "").trim(); + const match = + /^(?:keypair|identity)([1-9][0-9]*)(?:[.](pubkey|secret|pkh))?$/.exec( + trimmed, + ); + if (!match) { + return null; + } + const index = match[1]; + const slot = match[2]; + return slot ? "keypair" + index + "." + slot : "keypair" + index; + } + + function displayLabelFor(alias) { + const label = state.identityLabels[alias]; + return typeof label === "string" && label.trim() + ? label.trim() + : alias; + } + + function syncAliasesFromFields() { + state.constructorArgs = collectFields(ctorFields, "constructor"); + syncCurrentArgState(); + + const found = new Map(); + const consider = (raw) => { + const canonical = canonicalIdentityToken(raw); + if (!canonical) { + return; + } + const index = Number(canonical.slice("keypair".length).split(".")[0]); + if (!found.has(index)) { + found.set(index, "keypair" + index); + } + }; + + state.keyAliases.forEach(consider); + Object.values(state.constructorArgs).forEach(consider); + Object.values(state.argsByFunction).forEach((args) => { + Object.values(args).forEach(consider); + }); + + state.keyAliases = [...found.entries()] + .sort((left, right) => left[0] - right[0]) + .map((entry) => entry[1]); + state.identityLabels = Object.fromEntries( + state.keyAliases + .map((alias) => [alias, state.identityLabels[alias]]) + .filter( + ([alias, label]) => + typeof label === "string" && + label.trim() && + label.trim() !== alias, + ) + .map(([alias, label]) => [alias, label.trim()]), + ); + } + + function nextAlias() { + syncAliasesFromFields(); + let max = 0; + state.keyAliases.forEach((alias) => { + const match = /^keypair([0-9]+)$/.exec(String(alias).trim()); + if (match) { + max = Math.max(max, Number(match[1])); + } + }); + return "keypair" + (max + 1); + } + + function fillFieldWithToken(input, slot, alias) { + input.value = tokenFor(alias, slot); + input.dispatchEvent(new Event("input", { bubbles: true })); + input.focus(); + } + + function addAlias(fillInput, fillSlot) { + syncAliasesFromFields(); + const alias = nextAlias(); + state.keyAliases.push(alias); + syncAliasesFromFields(); + if (fillInput && fillSlot) { + fillFieldWithToken(fillInput, fillSlot, alias); + } + return alias; + } + + function renderFields(container, params, values, group) { + if (!params.length) { + container.innerHTML = '

No parameters

'; + return; + } + + container.innerHTML = params + .map((param) => { + const value = fieldValue(values[param.name]); + const helper = helperSlot(param); + const escapedValue = escapeHtml(value); + return ( + '" + + '
' + + '" + + (helper + ? '' + : "") + + "
" + ); + }) + .join(""); + } + + function currentEntrypoint() { + return ( + model.entrypoints.find((entry) => entry.name === functionSelect.value) || + model.entrypoints[0] + ); + } + + function ensureArgState(functionName) { + if (!state.argsByFunction[functionName]) { + state.argsByFunction[functionName] = {}; + } + return state.argsByFunction[functionName]; + } + + function collectFields(container, group) { + const out = {}; + container + .querySelectorAll('input[data-group="' + group + '"]') + .forEach((input) => { + out[input.dataset.name] = + canonicalIdentityToken(input.value) ?? input.value; + }); + return out; + } + + function syncCurrentArgState() { + const entrypoint = currentEntrypoint(); + if (!entrypoint) { + return; + } + state.argsByFunction[entrypoint.name] = collectFields(argFields, "args"); + } + + function currentForm() { + syncAliasesFromFields(); + state.function = functionSelect.value; + return { + function: state.function, + constructorArgs: state.constructorArgs, + argsByFunction: state.argsByFunction, + keyAliases: state.keyAliases, + identityLabels: state.identityLabels, + }; + } + + function renderFunctionOptions() { + functionSelect.innerHTML = model.entrypoints + .map((entry) => { + const signature = entry.params + .map((param) => param.type + " " + param.name) + .join(", "); + const selected = entry.name === state.function ? " selected" : ""; + return ( + '" + ); + }) + .join(""); + } + + function renderArgs() { + const entrypoint = currentEntrypoint(); + if (!entrypoint) { + argFields.innerHTML = '

No entrypoints

'; + return; + } + renderFields( + argFields, + entrypoint.params, + ensureArgState(entrypoint.name), + "args", + ); + } + + function renderLoadButton() { + const functionName = String(functionSelect.value || state.function || ""); + const currentCount = Number(state.savedCountsByFunction[functionName] ?? 0); + loadButton.textContent = + currentCount > 0 ? "Load (" + currentCount + ")" : "Load"; + + if (state.savedTotalCount === 0) { + loadButton.title = "No saved scenarios for this contract yet."; + return; + } + + if (functionName && currentCount !== state.savedTotalCount) { + loadButton.title = + currentCount > 0 + ? currentCount + + " saved for " + + functionName + + ", " + + state.savedTotalCount + + " total for this contract." + : "No saved scenarios for " + + functionName + + ". " + + state.savedTotalCount + + " saved for this contract."; + return; + } + + loadButton.title = state.savedTotalCount + " saved for this contract."; + } + + function renderAllFields() { + renderFields( + ctorFields, + model.constructorParams, + state.constructorArgs, + "constructor", + ); + renderArgs(); + } + + function closeDropdowns() { + document + .querySelectorAll(".identity-dropdown") + .forEach((node) => node.remove()); + } + + function clearAliasTokens(alias) { + const tokens = new Set( + ["pubkey", "secret", "pkh"].map((slot) => tokenFor(alias, slot)), + ); + const clearValues = (values) => + Object.fromEntries( + Object.entries(values).map(([name, raw]) => { + const canonical = canonicalIdentityToken(raw); + return [name, canonical && tokens.has(canonical) ? "" : raw]; + }), + ); + + state.constructorArgs = clearValues(state.constructorArgs); + state.argsByFunction = Object.fromEntries( + Object.entries(state.argsByFunction).map(([name, values]) => [ + name, + clearValues(values), + ]), + ); + } + + function deleteAlias(alias) { + state.keyAliases = state.keyAliases.filter((entry) => entry !== alias); + delete state.identityLabels[alias]; + clearAliasTokens(alias); + renderAllFields(); + closeDropdowns(); + } + + function showDropdown(input, slot) { + syncAliasesFromFields(); + const fieldRow = input.closest(".field-row"); + if (!fieldRow) { + return; + } + closeDropdowns(); + + const dropdown = document.createElement("div"); + dropdown.className = "identity-dropdown"; + + state.keyAliases.forEach((alias) => { + const item = document.createElement("div"); + item.className = "identity-choice"; + const main = document.createElement("div"); + main.className = "identity-choice-main"; + const name = document.createElement("span"); + name.className = "identity-choice-name"; + name.textContent = displayLabelFor(alias); + const value = document.createElement("span"); + value.className = "identity-choice-value"; + value.textContent = tokenFor(alias, slot); + const remove = document.createElement("button"); + remove.type = "button"; + remove.className = "identity-choice-delete"; + remove.textContent = "X"; + remove.title = "Delete " + displayLabelFor(alias); + remove.addEventListener("click", (event) => { + event.stopPropagation(); + deleteAlias(alias); + }); + main.append(name, value); + item.append(main, remove); + item.addEventListener("click", () => { + fillFieldWithToken(input, slot, alias); + closeDropdowns(); + }); + dropdown.appendChild(item); + }); + + if (state.keyAliases.length) { + const divider = document.createElement("div"); + divider.className = "identity-divider"; + dropdown.appendChild(divider); + } + + const add = document.createElement("div"); + add.className = "identity-choice"; + const next = nextAlias(); + const addName = document.createElement("span"); + addName.className = "identity-choice-name"; + addName.textContent = "Add " + next; + const addValue = document.createElement("span"); + addValue.className = "identity-choice-value"; + addValue.textContent = tokenFor(next, slot); + add.append(addName, addValue); + add.addEventListener("click", () => { + addAlias(input, slot); + closeDropdowns(); + }); + dropdown.appendChild(add); + + fieldRow.appendChild(dropdown); + } + + function send(kind) { + vscode.postMessage({ + kind, + form: currentForm(), + }); + } + + functionSelect.addEventListener("change", () => { + syncCurrentArgState(); + state.function = functionSelect.value; + renderArgs(); + renderLoadButton(); + closeDropdowns(); + }); + + renderFunctionOptions(); + renderAllFields(); + renderLoadButton(); + + document.addEventListener("click", (event) => { + const target = + event.target instanceof Element + ? event.target + : event.target?.parentElement ?? null; + if (!target) { + return; + } + + const button = target.closest(".key-button"); + if (button) { + const row = button.closest(".field-row"); + const input = row?.querySelector("input.crypto-input"); + const slot = button.dataset.helperSlot; + if (input && slot) { + event.stopPropagation(); + showDropdown(input, slot); + } + return; + } + + const input = target.closest("input.crypto-input"); + if (input && input.dataset.helperSlot) { + event.stopPropagation(); + showDropdown(input, input.dataset.helperSlot); + return; + } + + if (!target.closest(".identity-dropdown")) { + closeDropdowns(); + } + }); + + document + .getElementById("load-button") + .addEventListener("click", () => send("loadSaved")); + document + .getElementById("save-button") + .addEventListener("click", () => send("saveSaved")); + document + .getElementById("run-button") + .addEventListener("click", () => send("run")); + document + .getElementById("debug-button") + .addEventListener("click", () => send("debug")); + + window.addEventListener("message", (event) => { + const message = event.data; + if (!message || typeof message !== "object") { + return; + } + if ( + message.kind === "triggerLaunch" && + (message.launchKind === "run" || message.launchKind === "debug") + ) { + send(message.launchKind); + } + }); +})(); From 8d108b0596e0c0c83badd2f95642f5ae3fb09963 Mon Sep 17 00:00:00 2001 From: Manyfestation <240733973+Manyfestation@users.noreply.github.com> Date: Tue, 28 Apr 2026 17:15:02 +0900 Subject: [PATCH 6/6] WIP DAP covenant migration --- debugger/dap/src/adapter.rs | 36 +- debugger/dap/src/main.rs | 4 +- debugger/dap/src/runtime_builder.rs | 776 ++++++++++--- debugger/dap/tests/harness.rs | 2 +- debugger/dap/tests/test_launch.rs | 44 +- .../01-init-kcc20-minter-branch.json | 71 ++ .../02-create-tokens-from-minter.json | 96 ++ .../03-burn-tokens-from-minter.json | 71 ++ .../04-transfer-created-tokens.json | 89 ++ debugger/fixtures/kcc20-flow/README.md | 14 + docs/dap-layer-migration-plan.md | 105 ++ ...vscode-debugger-extension-redesign-plan.md | 75 ++ extensions/vscode/.gitignore | 3 - extensions/vscode/.vscodeignore | 12 +- extensions/vscode/CHANGELOG.md | 2 - extensions/vscode/README.md | 82 +- extensions/vscode/bin/.gitignore | 2 - extensions/vscode/package.json | 153 +-- extensions/vscode/scripts/prepare-adapter.mjs | 88 -- extensions/vscode/src/codeLens.ts | 157 --- extensions/vscode/src/contractModel.ts | 132 --- extensions/vscode/src/debug.ts | 268 ----- extensions/vscode/src/debugAdapter.ts | 352 ------ extensions/vscode/src/extension.ts | 33 +- extensions/vscode/src/launchConfigs.ts | 254 ---- extensions/vscode/src/quickLaunch/panel.ts | 1023 ----------------- extensions/vscode/src/quickLaunch/view.ts | 88 -- .../vscode/webviews/quickLaunch/panel.css | 262 ----- .../vscode/webviews/quickLaunch/panel.html | 47 - .../vscode/webviews/quickLaunch/panel.js | 511 -------- 30 files changed, 1192 insertions(+), 3660 deletions(-) create mode 100644 debugger/fixtures/kcc20-flow/01-init-kcc20-minter-branch.json create mode 100644 debugger/fixtures/kcc20-flow/02-create-tokens-from-minter.json create mode 100644 debugger/fixtures/kcc20-flow/03-burn-tokens-from-minter.json create mode 100644 debugger/fixtures/kcc20-flow/04-transfer-created-tokens.json create mode 100644 debugger/fixtures/kcc20-flow/README.md create mode 100644 docs/dap-layer-migration-plan.md create mode 100644 docs/vscode-debugger-extension-redesign-plan.md delete mode 100644 extensions/vscode/bin/.gitignore delete mode 100644 extensions/vscode/scripts/prepare-adapter.mjs delete mode 100644 extensions/vscode/src/codeLens.ts delete mode 100644 extensions/vscode/src/contractModel.ts delete mode 100644 extensions/vscode/src/debug.ts delete mode 100644 extensions/vscode/src/debugAdapter.ts delete mode 100644 extensions/vscode/src/launchConfigs.ts delete mode 100644 extensions/vscode/src/quickLaunch/panel.ts delete mode 100644 extensions/vscode/src/quickLaunch/view.ts delete mode 100644 extensions/vscode/webviews/quickLaunch/panel.css delete mode 100644 extensions/vscode/webviews/quickLaunch/panel.html delete mode 100644 extensions/vscode/webviews/quickLaunch/panel.js diff --git a/debugger/dap/src/adapter.rs b/debugger/dap/src/adapter.rs index bb07a0d9..85c0523f 100644 --- a/debugger/dap/src/adapter.rs +++ b/debugger/dap/src/adapter.rs @@ -11,8 +11,8 @@ use dap::types::{ Breakpoint, Capabilities, OutputEventCategory, Scope, ScopePresentationhint, Source, StackFrame, StoppedEventReason, Thread, Variable, }; -use debugger_session::format_failure_report; use debugger_session::session::{DebugSession, VariableOrigin}; +use debugger_session::{format_failure_report, format_value}; use crate::launch_config::LaunchConfig; use crate::refs::{RefAllocator, RefTarget, ScopeKind}; @@ -185,9 +185,7 @@ impl DapAdapter { Ok(()) => vec![self.output_stdout("Execution completed successfully."), Event::Terminated(None)], Err(err) => { let report = runtime.runtime.session().build_failure_report(&err); - let formatted = format_failure_report(&report, &|type_name, value| { - runtime.runtime.session().format_value(type_name, value) - }); + let formatted = format_failure_report(&report, &format_value); vec![self.output_stderr(formatted), Event::Terminated(None)] } } @@ -199,9 +197,7 @@ impl DapAdapter { Ok(None) => vec![self.output_stdout("Execution completed successfully."), Event::Terminated(None)], Err(err) => { let report = runtime.runtime.session().build_failure_report(&err); - let formatted = format_failure_report(&report, &|type_name, value| { - runtime.runtime.session().format_value(type_name, value) - }); + let formatted = format_failure_report(&report, &format_value); if runtime.no_debug { vec![self.output_stderr(formatted), Event::Terminated(None)] } else { @@ -233,12 +229,8 @@ impl DapAdapter { path: Some(runtime.source_path.to_string_lossy().to_string()), ..Default::default() }; - let current_function_name = runtime - .runtime - .session() - .current_function_name() - .map(ToOwned::to_owned) - .unwrap_or_else(|| "".to_string()); + let current_function_name = + runtime.runtime.session().current_function_name().unwrap_or_else(|| "".to_string()); let call_stack = runtime.runtime.session().call_stack_with_spans(); (span, current_step, source, current_function_name, call_stack) }; @@ -362,8 +354,10 @@ impl DapAdapter { let mut bindings = vars; bindings.sort_by_key(|item| { let rank = match item.origin { - VariableOrigin::Param | VariableOrigin::Local => 0, - VariableOrigin::Constant => 1, + VariableOrigin::Param => 0, + VariableOrigin::Local => 1, + VariableOrigin::ContractField | VariableOrigin::ConstructorArg => 2, + VariableOrigin::Constant => 3, }; (rank, item.name.clone()) }); @@ -371,7 +365,7 @@ impl DapAdapter { .into_iter() .map(|item| Variable { name: binding_name(&item), - value: runtime.runtime.session().format_value(&item.type_name, &item.value), + value: format_value(&item.type_name, &item.value), type_field: Some(item.type_name), evaluate_name: Some(item.name), variables_reference: 0, @@ -410,9 +404,7 @@ impl DapAdapter { } Err(err) => { let report = runtime.runtime.session().build_failure_report(&err); - let formatted = format_failure_report(&report, &|type_name, value| { - runtime.runtime.session().format_value(type_name, value) - }); + let formatted = format_failure_report(&report, &format_value); events.push(self.output_stderr(formatted.clone())); if no_debug { events.push(Event::Terminated(None)); @@ -475,8 +467,7 @@ impl DapAdapter { } Err(err) => { let report = runtime.runtime.session().build_failure_report(&err); - let formatted = - format_failure_report(&report, &|type_name, value| runtime.runtime.session().format_value(type_name, value)); + let formatted = format_failure_report(&report, &format_value); events.push(self.output_stderr(formatted.clone())); events.push(self.make_stopped_event(StoppedEventReason::Exception, Some(formatted))); } @@ -547,7 +538,8 @@ fn scope_target(kind: ScopeKind, frame_meta: &FrameMeta) -> RefTarget { fn binding_name(variable: &debugger_session::session::Variable) -> String { match variable.origin { - VariableOrigin::Param | VariableOrigin::Local => variable.name.clone(), + VariableOrigin::Param | VariableOrigin::Local | VariableOrigin::ContractField => variable.name.clone(), + VariableOrigin::ConstructorArg => format!("{} (ctor)", variable.name), VariableOrigin::Constant => format!("{} (const)", variable.name), } } diff --git a/debugger/dap/src/main.rs b/debugger/dap/src/main.rs index 90360599..5c6fb710 100644 --- a/debugger/dap/src/main.rs +++ b/debugger/dap/src/main.rs @@ -1,7 +1,7 @@ use std::io::{BufReader, BufWriter}; use dap::prelude::Server; -use debugger_session::format_failure_report; +use debugger_session::{format_failure_report, format_value}; use serde_json::Value; mod adapter; @@ -66,7 +66,7 @@ fn run_config_json(raw: &str) -> Result<(), Box> { } Err(err) => { let report = session.build_failure_report(&err); - let formatted = format_failure_report(&report, &|type_name, value| session.format_value(type_name, value)); + let formatted = format_failure_report(&report, &format_value); eprintln!("{formatted}"); std::process::exit(1); } diff --git a/debugger/dap/src/runtime_builder.rs b/debugger/dap/src/runtime_builder.rs index ff10fd20..ed1fa773 100644 --- a/debugger/dap/src/runtime_builder.rs +++ b/debugger/dap/src/runtime_builder.rs @@ -3,15 +3,16 @@ use std::fs; use std::path::PathBuf; use std::ptr::NonNull; -use debugger_session::args::{parse_call_args, parse_ctor_args, parse_hex_bytes}; -use debugger_session::session::{DebugEngine, DebugSession, ShadowTxContext}; +use debugger_session::args::{parse_call_args, parse_call_args_with_prefix, parse_ctor_args, parse_hex_bytes, parse_state_value}; +use debugger_session::covenant::{CovenantBinding as DebugCovenantBinding, ResolvedCovenantCallTarget, resolve_covenant_call_target}; +use debugger_session::session::{DebugEngine, DebugSession, DebugValue, ShadowTxContext}; use debugger_session::test_runner::{TestTxInputScenarioResolved, TestTxOutputScenarioResolved, TestTxScenarioResolved}; use kaspa_consensus_core::Hash; use kaspa_consensus_core::hashing::sighash::{SigHashReusedValuesUnsync, calc_schnorr_signature_hash}; use kaspa_consensus_core::hashing::sighash_type::SIG_HASH_ALL; use kaspa_consensus_core::tx::{ CovenantBinding, PopulatedTransaction, ScriptPublicKey, Transaction, TransactionId, TransactionInput, TransactionOutpoint, - TransactionOutput, UtxoEntry, VerifiableTransaction, + TransactionOutput, TxInputMass, UtxoEntry, VerifiableTransaction, }; use kaspa_txscript::caches::Cache; use kaspa_txscript::covenants::CovenantsContext; @@ -19,8 +20,8 @@ use kaspa_txscript::script_builder::ScriptBuilder; use kaspa_txscript::{EngineCtx, EngineFlags, SigCacheKey, pay_to_script_hash_script}; use secp256k1::{Keypair, Message, Secp256k1, SecretKey, rand::thread_rng}; use serde_json::Value; -use silverscript_lang::ast::{ContractAst, parse_contract_ast}; -use silverscript_lang::compiler::{CompileOptions, compile_contract}; +use silverscript_lang::ast::{ContractAst, Expr, ExprKind, StateFieldExpr, TypeBase, TypeRef, parse_contract_ast}; +use silverscript_lang::compiler::{CompileOptions, CompiledContract, compile_contract, compile_contract_ast}; use crate::launch_config::{ArgInput, ResolvedLaunchConfig, resolve_arg_input}; @@ -43,77 +44,305 @@ pub fn build_launch(mut config: ResolvedLaunchConfig) -> Result>(); - let raw_ctor_args = resolve_arg_input(config.constructor_args.as_ref(), &ctor_param_names, "constructor arguments")?; + let mut raw_ctor_args = resolve_arg_input(config.constructor_args.as_ref(), &ctor_param_names, "constructor arguments")?; + let tx = config.tx.unwrap_or_else(default_tx_scenario); + if raw_ctor_args.is_empty() + && let Some(active_input_ctor_args) = tx.inputs.get(tx.active_input_index).and_then(|input| input.constructor_args.clone()) + { + raw_ctor_args = active_input_ctor_args; + } let ctor_args = parse_ctor_args(&parsed_contract, &raw_ctor_args)?; let compile_opts = CompileOptions { record_debug_infos: true, ..Default::default() }; let compiled = compile_contract(source, &ctor_args, compile_opts).map_err(|err| format!("compile error: {err}"))?; let selected_name = resolve_entrypoint_name(&compiled.abi, config.function)?; - let entry = compiled - .abi + let selected_function = parsed_contract + .functions .iter() - .find(|entry| entry.name == selected_name) - .ok_or_else(|| format!("function '{}' not found", selected_name))?; - - let input_types = entry.inputs.iter().map(|input| input.type_name.clone()).collect::>(); - let input_names = entry.inputs.iter().map(|input| input.name.clone()).collect::>(); + .find(|function| function.name == selected_name) + .ok_or_else(|| format!("function '{selected_name}' not found"))?; + let input_names = selected_function.params.iter().map(|param| param.name.clone()).collect::>(); let raw_args = resolve_arg_input(config.args.as_ref(), &input_names, "function arguments")?; - let tx = config.tx.unwrap_or_else(default_tx_scenario); + + if tx.inputs.is_empty() { + return Err("tx.inputs must contain at least one input".to_string()); + } + if tx.active_input_index >= tx.inputs.len() { + return Err(format!("tx.active_input_index {} out of range for {} inputs", tx.active_input_index, tx.inputs.len())); + } + + let covenant_target = resolve_covenant_call_target(&parsed_contract, &compiled, &selected_name); + let covenant_binding = covenant_target.as_ref().map(|target| target.binding); + let enable_covenant_session_mode = covenant_target.is_some(); let mut ctor_script_cache = HashMap::, Vec>::new(); + let mut ctor_state_cache = HashMap::, DebugValue>::new(); + let mut explicit_state_cache = HashMap::::new(); ctor_script_cache.insert(raw_ctor_args.clone(), compiled.script.clone()); + if !parsed_contract.fields.is_empty() { + let root_state = resolve_state_for_ctor_args(&parsed_contract, &raw_ctor_args, &mut ctor_state_cache)?; + ctor_state_cache.insert(raw_ctor_args.clone(), root_state); + } + + let mut input_prev_outpoints = Vec::with_capacity(tx.inputs.len()); + let mut input_sequences = Vec::with_capacity(tx.inputs.len()); + let mut input_sig_op_counts = Vec::with_capacity(tx.inputs.len()); + let mut explicit_input_sigs = Vec::with_capacity(tx.inputs.len()); + let mut utxo_specs = Vec::with_capacity(tx.inputs.len()); + let mut input_covenant_ids = Vec::with_capacity(tx.inputs.len()); + let mut input_covenant_states = Vec::with_capacity(tx.inputs.len()); + let mut input_redeem_scripts = Vec::with_capacity(tx.inputs.len()); + for (input_idx, input) in tx.inputs.iter().enumerate() { + let mut default_prev_txid = [0u8; 32]; + default_prev_txid.fill(input_idx as u8); + let prev_txid = if let Some(raw_txid) = input.prev_txid.as_deref() { + parse_txid32(raw_txid)? + } else { + TransactionId::from_bytes(default_prev_txid) + }; - let resolved_raw_args = resolve_auto_sign_args( - &input_types, - &input_names, - &raw_args, - source, - &parsed_contract, - &raw_ctor_args, - &tx, - &mut ctor_script_cache, - )?; - let typed_args = parse_call_args(&input_types, &resolved_raw_args)?; - let sigscript = - compiled.build_sig_script(&selected_name, typed_args).map_err(|err| format!("failed to build sigscript: {err}"))?; - - let tx_context = build_tx_context(source, &parsed_contract, &raw_ctor_args, &tx, Some(&sigscript), &mut ctor_script_cache)?; - let BuiltTxContext { - transaction, - populated_tx, - populated_tx_ptr, - covenants_ctx, - covenants_ctx_ptr, - active_input, - active_utxo, - reused_values, - reused_values_ptr, - } = tx_context; + let input_ctor_raw = input.constructor_args.clone().unwrap_or_else(|| raw_ctor_args.clone()); + let input_covenant_state = if let Some(raw_state) = input.state.as_deref() { + Some(resolve_state_from_raw(&parsed_contract, raw_state, &mut explicit_state_cache)?) + } else if input.utxo_script_hex.is_none() || input.constructor_args.is_some() { + Some(resolve_state_for_ctor_args(&parsed_contract, &input_ctor_raw, &mut ctor_state_cache)?) + } else { + None + }; + let redeem_script = if input.utxo_script_hex.is_none() { + if let Some(raw_state) = input.state.as_deref() { + Some(materialize_script_for_explicit_state(source, &parsed_contract, &input_ctor_raw, raw_state)?) + } else { + Some(compile_script_for_ctor_args(source, &parsed_contract, &input_ctor_raw, &mut ctor_script_cache)?) + } + } else { + None + }; + + let utxo_spk = if let Some(raw_script) = input.utxo_script_hex.as_deref() { + ScriptPublicKey::new(0, parse_hex_bytes(raw_script)?.into()) + } else { + let redeem = redeem_script + .as_ref() + .ok_or_else(|| "internal error: missing redeem script for tx input without utxo_script_hex".to_string())?; + pay_to_script_hash_script(redeem) + }; + + let covenant_id = input.covenant_id.as_deref().map(parse_hash32).transpose()?; + input_prev_outpoints.push(TransactionOutpoint { transaction_id: prev_txid, index: input.prev_index }); + input_sequences.push(input.sequence); + input_sig_op_counts.push(input.sig_op_count); + explicit_input_sigs.push(input.signature_script_hex.as_deref().map(parse_hex_bytes).transpose()?); + utxo_specs.push((input.utxo_value, utxo_spk, covenant_id)); + input_covenant_ids.push(covenant_id); + input_covenant_states.push(input_covenant_state); + input_redeem_scripts.push(redeem_script); + } + + let mut tx_outputs = Vec::with_capacity(tx.outputs.len()); + let mut output_covenant_ids = Vec::with_capacity(tx.outputs.len()); + let mut output_covenant_states = Vec::with_capacity(tx.outputs.len()); + for output in tx.outputs.iter() { + let output_ctor_raw = output.constructor_args.clone().unwrap_or_else(|| raw_ctor_args.clone()); + let output_state = if let Some(raw_state) = output.state.as_deref() { + Some(resolve_state_from_raw(&parsed_contract, raw_state, &mut explicit_state_cache)?) + } else if output.script_hex.is_none() || output.constructor_args.is_some() { + Some(resolve_state_for_ctor_args(&parsed_contract, &output_ctor_raw, &mut ctor_state_cache)?) + } else { + None + }; + let script_public_key = if let Some(raw_script) = output.script_hex.as_deref() { + ScriptPublicKey::new(0, parse_hex_bytes(raw_script)?.into()) + } else if let Some(raw_pubkey) = output.p2pk_pubkey.as_deref() { + let pubkey_bytes = parse_hex_bytes(raw_pubkey)?; + ScriptPublicKey::new(0, build_p2pk_script(&pubkey_bytes).into()) + } else { + let output_script = if let Some(raw_state) = output.state.as_deref() { + materialize_script_for_explicit_state(source, &parsed_contract, &output_ctor_raw, raw_state)? + } else { + compile_script_for_ctor_args(source, &parsed_contract, &output_ctor_raw, &mut ctor_script_cache)? + }; + pay_to_script_hash_script(&output_script) + }; + + let covenant = output + .covenant_id + .as_deref() + .map(|raw| -> Result { + Ok(CovenantBinding { + authorizing_input: output.authorizing_input.unwrap_or(tx.active_input_index as u16), + covenant_id: parse_hash32(raw)?, + }) + }) + .transpose()?; + let output_covenant_id = covenant.as_ref().map(|binding| binding.covenant_id); + tx_outputs.push(TransactionOutput { value: output.value, script_public_key, covenant }); + output_covenant_ids.push(output_covenant_id); + output_covenant_states.push(output_state); + } + + let active_covenant_id = input_covenant_ids.get(tx.active_input_index).copied().flatten(); + let companion_leader_index = if covenant_target.as_ref().is_some_and(|target| target.binding == DebugCovenantBinding::Cov) { + active_covenant_id.and_then(|covenant_id| { + input_covenant_ids + .iter() + .enumerate() + .filter_map(|(index, input_covenant_id)| (*input_covenant_id == Some(covenant_id)).then_some(index)) + .min() + }) + } else { + None + }; + let active_authorized_output_states = tx + .outputs + .iter() + .zip(output_covenant_states.iter()) + .filter_map(|(output, output_state)| { + (output.authorizing_input.unwrap_or(tx.active_input_index as u16) == tx.active_input_index as u16) + .then_some(output_state.clone()) + }) + .collect::>>(); + let covenant_group_output_states = active_covenant_id.and_then(|covenant_id| { + output_covenant_ids + .iter() + .zip(output_covenant_states.iter()) + .filter_map(|(output_covenant_id, output_state)| { + (*output_covenant_id == Some(covenant_id)).then_some(output_state.clone()) + }) + .collect::>>() + }); + + let active_input_ctor_raw = tx.inputs[tx.active_input_index].constructor_args.clone().unwrap_or_else(|| raw_ctor_args.clone()); + let active_compiled = compile_contract_for_raw_ctor_args(source, &parsed_contract, &active_input_ctor_raw)?; + let active_is_cov_leader = companion_leader_index.map(|index| index == tx.active_input_index).unwrap_or(true); + let active_sigscript = if let Some(target) = covenant_target.as_ref() { + match target.binding { + DebugCovenantBinding::Auth => { + build_covenant_input_sigscript(&active_compiled, target, true, &raw_args, active_authorized_output_states.as_deref())? + } + DebugCovenantBinding::Cov => build_covenant_input_sigscript( + &active_compiled, + target, + active_is_cov_leader, + &raw_args, + covenant_group_output_states.as_deref(), + )?, + } + } else { + let active_raw_args = + resolve_auto_sign_args(&selected_name, &raw_args, source, &parsed_contract, &raw_ctor_args, &tx, &mut ctor_script_cache)?; + let typed_args = parse_call_args(&parsed_contract, &selected_name, &active_raw_args)?; + active_compiled.build_sig_script(&selected_name, typed_args).map_err(|err| format!("failed to build sigscript: {err}"))? + }; + + let mut tx_inputs = Vec::with_capacity(tx.inputs.len()); + for input_idx in 0..tx.inputs.len() { + let signature_script = if let Some(signature_script) = explicit_input_sigs[input_idx].clone() { + signature_script + } else if input_idx == tx.active_input_index { + if let Some(redeem) = input_redeem_scripts[input_idx].as_ref() { + combine_action_and_redeem(&active_sigscript, redeem)? + } else { + active_sigscript.clone() + } + } else if let Some(target) = covenant_target.as_ref() + && target.binding == DebugCovenantBinding::Cov + && input_covenant_ids[input_idx] == active_covenant_id + && input_redeem_scripts[input_idx].is_some() + { + let is_leader = Some(input_idx) == companion_leader_index; + let input_ctor_raw = tx.inputs[input_idx].constructor_args.clone().unwrap_or_else(|| raw_ctor_args.clone()); + let input_compiled = compile_contract_for_raw_ctor_args(source, &parsed_contract, &input_ctor_raw)?; + let auto_action = build_covenant_input_sigscript( + &input_compiled, + target, + is_leader, + &raw_args, + covenant_group_output_states.as_deref(), + )?; + combine_action_and_redeem(&auto_action, input_redeem_scripts[input_idx].as_ref().expect("checked is_some above"))? + } else if let Some(redeem) = input_redeem_scripts[input_idx].as_ref() { + sigscript_push_script(redeem) + } else { + vec![] + }; + + tx_inputs.push(TransactionInput { + previous_outpoint: input_prev_outpoints[input_idx], + signature_script, + sequence: input_sequences[input_idx], + mass: TxInputMass::SigopCount(input_sig_op_counts[input_idx].into()), + }); + } + + let transaction = + Box::into_raw(Box::new(Transaction::new(tx.version, tx_inputs, tx_outputs, tx.lock_time, Default::default(), 0, vec![]))); + let transaction_ref = unsafe { &*transaction }; + let reused_values = Box::into_raw(Box::new(SigHashReusedValuesUnsync::new())); + let reused_values_ref = unsafe { &*reused_values }; + let utxos = utxo_specs + .into_iter() + .map(|(value, spk, covenant_id)| UtxoEntry::new(value, spk, 0, transaction_ref.is_coinbase(), covenant_id)) + .collect::>(); + let populated_tx = Box::into_raw(Box::new(PopulatedTransaction::new(transaction_ref, utxos))); + let populated_tx_ref = unsafe { &*populated_tx }; + let covenants_ctx = Box::into_raw(Box::new( + CovenantsContext::from_tx(populated_tx_ref).map_err(|err| format!("failed to build covenant context: {err}"))?, + )); + let covenants_ctx_ref = unsafe { &*covenants_ctx }; + let active_input = transaction_ref + .inputs + .get(tx.active_input_index) + .ok_or_else(|| format!("missing tx input at index {}", tx.active_input_index))?; + let active_utxo = populated_tx_ref + .utxo(tx.active_input_index) + .ok_or_else(|| format!("missing utxo entry for input {}", tx.active_input_index))?; + let active_covenant_input_state = input_covenant_states.get(tx.active_input_index).cloned().flatten(); + let active_lockscript = + input_redeem_scripts.get(tx.active_input_index).cloned().flatten().unwrap_or_else(|| compiled.script.clone()); + let covenant_input_states = active_utxo.covenant_id.and_then(|covenant_id| { + let mut values = Vec::new(); + for (input_covenant_id, covenant_input_state) in input_covenant_ids.iter().zip(input_covenant_states.iter()) { + if *input_covenant_id != Some(covenant_id) { + continue; + } + values.push(covenant_input_state.clone()?); + } + Some(values) + }); + let covenant_param_value = match covenant_binding { + Some(DebugCovenantBinding::Auth) => active_covenant_input_state.clone(), + Some(DebugCovenantBinding::Cov) => covenant_input_states.clone().map(DebugValue::Array), + None => None, + }; let cache_ptr = Box::into_raw(Box::new(Cache::new(10_000))); let cache = unsafe { &*cache_ptr }; - let flags = EngineFlags { covenants_enabled: true }; - let ctx = EngineCtx::new(cache).with_reused(reused_values).with_covenants_ctx(covenants_ctx); - let engine = DebugEngine::from_transaction_input(populated_tx, active_input, tx.active_input_index, active_utxo, ctx, flags); + let flags = EngineFlags { covenants_enabled: true, ..Default::default() }; + let ctx = EngineCtx::new(cache).with_reused(reused_values_ref).with_covenants_ctx(covenants_ctx_ref); + let engine = DebugEngine::from_transaction_input(populated_tx_ref, active_input, tx.active_input_index, active_utxo, ctx, flags); let shadow_tx_context = ShadowTxContext { - tx: populated_tx, + tx: populated_tx_ref, input: active_input, input_index: tx.active_input_index, utxo_entry: active_utxo, - covenants_ctx, + covenants_ctx: covenants_ctx_ref, }; - let session = DebugSession::full(&sigscript, &compiled.script, source, compiled.debug_info.clone(), engine) + let mut session = DebugSession::full(&active_sigscript, &active_lockscript, source, compiled.debug_info.clone(), engine) .map_err(|err| format!("failed to create debug session: {err}"))? .with_shadow_tx_context(shadow_tx_context); + if enable_covenant_session_mode { + session = session.with_covenant_mode(covenant_param_value, covenant_target); + } let runtime = OwnedRuntime { session, _backing: RuntimeBacking { source: Some(unsafe { NonNull::new_unchecked(source_ptr) }), cache: Some(unsafe { NonNull::new_unchecked(cache_ptr) }), - transaction, - populated_tx: populated_tx_ptr, - covenants_ctx: covenants_ctx_ptr, - reused_values: reused_values_ptr, + transaction: unsafe { NonNull::new_unchecked(transaction) }, + populated_tx: unsafe { NonNull::new_unchecked(populated_tx) }, + covenants_ctx: unsafe { NonNull::new_unchecked(covenants_ctx) }, + reused_values: unsafe { NonNull::new_unchecked(reused_values) }, }, }; @@ -164,6 +393,7 @@ fn default_tx_scenario() -> TestTxScenarioResolved { utxo_value: 5000, covenant_id: None, constructor_args: None, + state: None, signature_script_hex: None, utxo_script_hex: None, }], @@ -172,6 +402,7 @@ fn default_tx_scenario() -> TestTxScenarioResolved { covenant_id: None, authorizing_input: None, constructor_args: None, + state: None, script_hex: None, p2pk_pubkey: None, }], @@ -179,8 +410,7 @@ fn default_tx_scenario() -> TestTxScenarioResolved { } fn resolve_auto_sign_args( - input_types: &[String], - input_names: &[String], + function_name: &str, raw_args: &[String], source: &str, parsed_contract: &ContractAst<'_>, @@ -190,8 +420,14 @@ fn resolve_auto_sign_args( ) -> Result, String> { let mut resolved = raw_args.to_vec(); let mut has_secret_sig = false; + let function = parsed_contract + .functions + .iter() + .find(|function| function.name == function_name) + .ok_or_else(|| format!("function '{function_name}' not found"))?; - for (index, (type_name, raw)) in input_types.iter().zip(raw_args.iter()).enumerate() { + for (param, raw) in function.params.iter().zip(raw_args.iter()) { + let type_name = param.type_ref.type_name(); if type_name != "sig" && type_name != "datasig" { continue; } @@ -203,7 +439,7 @@ fn resolve_auto_sign_args( if type_name == "datasig" && bytes.len() == 32 { return Err(format!( "function argument '{}' uses a 32-byte secret key for datasig, but debugger launch only auto-signs 'sig' arguments", - input_names[index] + param.name )); } } @@ -212,9 +448,12 @@ fn resolve_auto_sign_args( return Ok(resolved); } - let signing_tx = build_tx_context(source, parsed_contract, raw_ctor_args, tx, None, ctor_script_cache)?; + let (signing_transaction, signing_utxos, signing_reused_values) = + build_signing_tx_parts(source, parsed_contract, raw_ctor_args, tx, ctor_script_cache)?; + let signing_populated = PopulatedTransaction::new(&signing_transaction, signing_utxos); - for (index, type_name) in input_types.iter().enumerate() { + for (index, param) in function.params.iter().enumerate() { + let type_name = param.type_ref.type_name(); if type_name != "sig" { continue; } @@ -222,8 +461,8 @@ fn resolve_auto_sign_args( if secret_bytes.len() != 32 { continue; } - resolved[index] = sign_tx_input(&secret_bytes, signing_tx.populated_tx, tx.active_input_index, signing_tx.reused_values) - .map_err(|err| format!("failed to auto-sign argument '{}': {err}", input_names[index]))?; + resolved[index] = sign_tx_input(&secret_bytes, &signing_populated, tx.active_input_index, &signing_reused_values) + .map_err(|err| format!("failed to auto-sign argument '{}': {err}", param.name))?; } Ok(resolved) @@ -405,16 +644,246 @@ fn generate_identity_material() -> IdentityMaterial { } } -struct BuiltTxContext { - transaction: NonNull, - populated_tx: &'static PopulatedTransaction<'static>, - populated_tx_ptr: NonNull>, - covenants_ctx: &'static CovenantsContext, - covenants_ctx_ptr: NonNull, - active_input: &'static TransactionInput, - active_utxo: &'static UtxoEntry, - reused_values: &'static SigHashReusedValuesUnsync, - reused_values_ptr: NonNull, +fn expr_to_debug_value(expr: &Expr<'_>) -> Result { + match &expr.kind { + ExprKind::Int(value) => Ok(DebugValue::Int(*value)), + ExprKind::Bool(value) => Ok(DebugValue::Bool(*value)), + ExprKind::Byte(value) => Ok(DebugValue::Bytes(vec![*value])), + ExprKind::String(value) => Ok(DebugValue::String(value.clone())), + ExprKind::Array(values) => { + if values.iter().all(|value| matches!(value.kind, ExprKind::Byte(_))) { + return Ok(DebugValue::Bytes( + values + .iter() + .map(|value| match value.kind { + ExprKind::Byte(byte) => byte, + _ => unreachable!("checked"), + }) + .collect(), + )); + } + Ok(DebugValue::Array(values.iter().map(expr_to_debug_value).collect::, _>>()?)) + } + ExprKind::StateObject(fields) => Ok(DebugValue::Object( + fields + .iter() + .map(|field| Ok((field.name.clone(), expr_to_debug_value(&field.expr)?))) + .collect::, String>>()?, + )), + other => Err(format!("unsupported resolved state expression in debugger: {other:?}")), + } +} + +fn debug_value_to_expr(value: &DebugValue) -> Option> { + Some(match value { + DebugValue::Int(value) => Expr::int(*value), + DebugValue::Bool(value) => Expr::new(ExprKind::Bool(*value), Default::default()), + DebugValue::Bytes(bytes) => Expr::new( + ExprKind::Array(bytes.iter().map(|byte| Expr::new(ExprKind::Byte(*byte), Default::default())).collect()), + Default::default(), + ), + DebugValue::String(value) => Expr::new(ExprKind::String(value.clone()), Default::default()), + DebugValue::Array(values) => { + Expr::new(ExprKind::Array(values.iter().map(debug_value_to_expr).collect::>>()?), Default::default()) + } + DebugValue::Object(fields) => Expr::new( + ExprKind::StateObject( + fields + .iter() + .map(|(name, value)| { + Some(StateFieldExpr { + name: name.clone(), + expr: debug_value_to_expr(value)?, + span: Default::default(), + name_span: Default::default(), + }) + }) + .collect::>>()?, + ), + Default::default(), + ), + DebugValue::Unknown(_) => return None, + }) +} + +fn is_state_type_ref(type_ref: &TypeRef) -> bool { + !type_ref.is_array() && matches!(&type_ref.base, TypeBase::Custom(name) if name == "State") +} + +fn is_state_array_type_ref(type_ref: &TypeRef) -> bool { + type_ref.is_array() && matches!(&type_ref.base, TypeBase::Custom(name) if name == "State") +} + +fn synthesized_covenant_prefix_args( + compiled: &CompiledContract<'_>, + entrypoint_name: &str, + target: &ResolvedCovenantCallTarget, + output_states: Option<&[DebugValue]>, +) -> Result>, String> { + if target.binding == DebugCovenantBinding::Cov && entrypoint_name.starts_with("__delegate_") { + return Ok(Vec::new()); + } + + let function = compiled + .ast + .functions + .iter() + .find(|function| function.name == entrypoint_name) + .ok_or_else(|| "generated covenant entrypoint not found".to_string())?; + let Some(first_param) = function.params.first() else { + return Ok(Vec::new()); + }; + + let states = + output_states.ok_or_else(|| "missing output states needed to synthesize covenant verification arguments".to_string())?; + if is_state_type_ref(&first_param.type_ref) { + if states.len() != 1 { + return Err(format!("expected exactly 1 output State for '{entrypoint_name}', got {}", states.len())); + } + return Ok(vec![debug_value_to_expr(&states[0]).ok_or_else(|| "failed to materialize synthesized output State".to_string())?]); + } + if is_state_array_type_ref(&first_param.type_ref) { + return Ok(vec![Expr::new( + ExprKind::Array( + states + .iter() + .map(debug_value_to_expr) + .collect::>>() + .ok_or_else(|| "failed to materialize synthesized output State[]".to_string())?, + ), + Default::default(), + )]); + } + + Ok(Vec::new()) +} + +fn build_covenant_input_sigscript<'i>( + compiled: &CompiledContract<'i>, + target: &ResolvedCovenantCallTarget, + is_leader: bool, + raw_args: &[String], + output_states: Option<&[DebugValue]>, +) -> Result, String> { + let entrypoint_name = target.generated_entrypoint_name_for(is_leader); + let typed_args = if target.binding == DebugCovenantBinding::Cov && !is_leader { + Vec::new() + } else { + let function = compiled + .ast + .functions + .iter() + .find(|function| function.name == entrypoint_name) + .ok_or_else(|| "generated covenant entrypoint not found".to_string())?; + if raw_args.len() == function.params.len() { + parse_call_args(&compiled.ast, &entrypoint_name, raw_args)? + } else { + let prefix_args = synthesized_covenant_prefix_args(compiled, &entrypoint_name, target, output_states)?; + parse_call_args_with_prefix(&compiled.ast, &entrypoint_name, prefix_args, raw_args)? + } + }; + compiled.build_sig_script(&entrypoint_name, typed_args).map_err(|err| format!("failed to build covenant sigscript: {err}")) +} + +fn resolve_state_for_ctor_args( + parsed_contract: &ContractAst<'_>, + raw_ctor_args: &[String], + cache: &mut HashMap, DebugValue>, +) -> Result { + if let Some(value) = cache.get(raw_ctor_args) { + return Ok(value.clone()); + } + + let ctor_args = parse_ctor_args(parsed_contract, raw_ctor_args)?; + let state_fields = parsed_contract.resolve_contract_state_values(&ctor_args).map_err(|err| err.to_string())?; + let value = DebugValue::Object( + state_fields + .iter() + .map(|field| Ok((field.name.clone(), expr_to_debug_value(&field.value)?))) + .collect::, String>>()?, + ); + cache.insert(raw_ctor_args.to_vec(), value.clone()); + Ok(value) +} + +fn resolve_state_from_raw( + parsed_contract: &ContractAst<'_>, + raw_state: &str, + cache: &mut HashMap, +) -> Result { + if let Some(value) = cache.get(raw_state) { + return Ok(value.clone()); + } + + let expr = parse_state_value(parsed_contract, raw_state)?; + let value = expr_to_debug_value(&expr)?; + cache.insert(raw_state.to_string(), value.clone()); + Ok(value) +} + +fn materialize_script_for_explicit_state( + source: &str, + parsed_contract: &ContractAst<'_>, + raw_instance_args: &[String], + raw_state: &str, +) -> Result, String> { + let instance_args = parse_ctor_args(parsed_contract, raw_instance_args)?; + let state = parse_state_value(parsed_contract, raw_state)?; + let compile_opts = CompileOptions { record_debug_infos: true, ..Default::default() }; + let base_compiled = compile_contract(source, &instance_args, compile_opts).map_err(|err| format!("compile error: {err}"))?; + let materialized_contract = contract_with_explicit_state(parsed_contract, &state)?; + let materialized = + compile_contract_ast(&materialized_contract, &instance_args, compile_opts).map_err(|err| format!("compile error: {err}"))?; + + let base_start = base_compiled.state_layout.start; + let base_end = base_start + base_compiled.state_layout.len; + let materialized_start = materialized.state_layout.start; + let materialized_end = materialized_start + materialized.state_layout.len; + if base_compiled.state_layout.len != materialized.state_layout.len { + return Err("explicit state changes encoded script size; provide raw script_hex instead".to_string()); + } + if base_compiled.script.len() < base_end || materialized.script.len() < materialized_end { + return Err("state layout exceeds compiled script length".to_string()); + } + if base_compiled.script[..base_start] != materialized.script[..materialized_start] + || base_compiled.script[base_end..] != materialized.script[materialized_end..] + { + return Err("explicit state changed non-state bytecode; provide raw script_hex instead".to_string()); + } + + let mut script = base_compiled.script; + script[base_start..base_end].copy_from_slice(&materialized.script[materialized_start..materialized_end]); + Ok(script) +} + +fn contract_with_explicit_state<'i>(contract: &ContractAst<'i>, state: &Expr<'i>) -> Result, String> { + let ExprKind::StateObject(entries) = &state.kind else { + return Err("State value must be an object literal".to_string()); + }; + + let mut provided = entries.iter().map(|entry| (entry.name.as_str(), entry.expr.clone())).collect::>(); + if provided.len() != contract.fields.len() { + return Err("State value must include all contract fields exactly once".to_string()); + } + + let mut materialized = contract.clone(); + for field in &mut materialized.fields { + field.expr = provided.remove(field.name.as_str()).ok_or_else(|| format!("missing state field '{}'", field.name))?; + } + if let Some(extra) = provided.keys().next() { + return Err(format!("unknown state field '{}'", extra)); + } + Ok(materialized) +} + +fn compile_contract_for_raw_ctor_args<'i>( + source: &'i str, + parsed_contract: &ContractAst<'i>, + raw_ctor_args: &[String], +) -> Result, String> { + let ctor_args = parse_ctor_args(parsed_contract, raw_ctor_args)?; + compile_contract(source, &ctor_args, CompileOptions { record_debug_infos: true, ..Default::default() }) + .map_err(|err| format!("compile error: {err}")) } pub struct OwnedRuntime { @@ -458,23 +927,31 @@ impl Drop for RuntimeBacking { } } -fn build_tx_context( +fn compile_script_for_ctor_args( source: &str, parsed_contract: &ContractAst<'_>, raw_ctor_args: &[String], - tx: &TestTxScenarioResolved, - active_sigscript: Option<&[u8]>, - ctor_script_cache: &mut HashMap, Vec>, -) -> Result { - if tx.inputs.is_empty() { - return Err("tx.inputs must contain at least one input".to_string()); - } - if tx.active_input_index >= tx.inputs.len() { - return Err(format!("tx.active_input_index {} out of range for {} inputs", tx.active_input_index, tx.inputs.len())); + cache: &mut HashMap, Vec>, +) -> Result, String> { + if let Some(script) = cache.get(raw_ctor_args) { + return Ok(script.clone()); } + let ctor_args = parse_ctor_args(parsed_contract, raw_ctor_args)?; + let compiled = compile_contract(source, &ctor_args, CompileOptions::default()).map_err(|err| format!("compile error: {err}"))?; + cache.insert(raw_ctor_args.to_vec(), compiled.script.clone()); + Ok(compiled.script) +} +fn build_signing_tx_parts( + source: &str, + parsed_contract: &ContractAst<'_>, + raw_ctor_args: &[String], + tx: &TestTxScenarioResolved, + ctor_script_cache: &mut HashMap, Vec>, +) -> Result<(Transaction, Vec, SigHashReusedValuesUnsync), String> { let mut tx_inputs = Vec::with_capacity(tx.inputs.len()); let mut utxo_specs = Vec::with_capacity(tx.inputs.len()); + let mut explicit_state_cache = HashMap::::new(); for (input_idx, input) in tx.inputs.iter().enumerate() { let mut default_prev_txid = [0u8; 32]; @@ -484,28 +961,16 @@ fn build_tx_context( } else { TransactionId::from_bytes(default_prev_txid) }; - let input_ctor_raw = input.constructor_args.clone().unwrap_or_else(|| raw_ctor_args.to_vec()); let redeem_script = if input.utxo_script_hex.is_none() { - Some(compile_script_for_ctor_args(source, parsed_contract, &input_ctor_raw, ctor_script_cache)?) - } else { - None - }; - - let signature_script = if let Some(raw_sig) = input.signature_script_hex.as_deref() { - parse_hex_bytes(raw_sig)? - } else if input_idx == tx.active_input_index { - match (active_sigscript, redeem_script.as_ref()) { - (Some(action), Some(redeem)) => combine_action_and_redeem(action, redeem)?, - (Some(action), None) => action.to_vec(), - (None, _) => vec![], + if let Some(raw_state) = input.state.as_deref() { + Some(materialize_script_for_explicit_state(source, parsed_contract, &input_ctor_raw, raw_state)?) + } else { + Some(compile_script_for_ctor_args(source, parsed_contract, &input_ctor_raw, ctor_script_cache)?) } - } else if let Some(redeem) = redeem_script.as_ref() { - sigscript_push_script(redeem) } else { - vec![] + None }; - let utxo_spk = if let Some(raw_script) = input.utxo_script_hex.as_deref() { ScriptPublicKey::new(0, parse_hex_bytes(raw_script)?.into()) } else { @@ -514,105 +979,54 @@ fn build_tx_context( .ok_or_else(|| "internal error: missing redeem script for tx input without utxo_script_hex".to_string())?; pay_to_script_hash_script(redeem) }; - let covenant_id = input.covenant_id.as_deref().map(parse_hash32).transpose()?; - tx_inputs.push(TransactionInput { previous_outpoint: TransactionOutpoint { transaction_id: prev_txid, index: input.prev_index }, - signature_script, + signature_script: vec![], sequence: input.sequence, - sig_op_count: input.sig_op_count, + mass: TxInputMass::SigopCount(input.sig_op_count.into()), }); utxo_specs.push((input.utxo_value, utxo_spk, covenant_id)); + if let Some(raw_state) = input.state.as_deref() { + let _ = resolve_state_from_raw(parsed_contract, raw_state, &mut explicit_state_cache)?; + } } let mut tx_outputs = Vec::with_capacity(tx.outputs.len()); - for output in tx.outputs.iter() { - tx_outputs.push(build_output(source, parsed_contract, output, raw_ctor_args, tx.active_input_index, ctor_script_cache)?); + for output in &tx.outputs { + let output_ctor_raw = output.constructor_args.clone().unwrap_or_else(|| raw_ctor_args.to_vec()); + let script_public_key = if let Some(raw_script) = output.script_hex.as_deref() { + ScriptPublicKey::new(0, parse_hex_bytes(raw_script)?.into()) + } else if let Some(raw_pubkey) = output.p2pk_pubkey.as_deref() { + let pubkey_bytes = parse_hex_bytes(raw_pubkey)?; + ScriptPublicKey::new(0, build_p2pk_script(&pubkey_bytes).into()) + } else { + let output_script = if let Some(raw_state) = output.state.as_deref() { + materialize_script_for_explicit_state(source, parsed_contract, &output_ctor_raw, raw_state)? + } else { + compile_script_for_ctor_args(source, parsed_contract, &output_ctor_raw, ctor_script_cache)? + }; + pay_to_script_hash_script(&output_script) + }; + let covenant = output + .covenant_id + .as_deref() + .map(|raw| -> Result { + Ok(CovenantBinding { + authorizing_input: output.authorizing_input.unwrap_or(tx.active_input_index as u16), + covenant_id: parse_hash32(raw)?, + }) + }) + .transpose()?; + tx_outputs.push(TransactionOutput { value: output.value, script_public_key, covenant }); } - let transaction = - Box::into_raw(Box::new(Transaction::new(tx.version, tx_inputs, tx_outputs, tx.lock_time, Default::default(), 0, vec![]))); - let transaction_ref = unsafe { &*transaction }; - let reused_values = Box::into_raw(Box::new(SigHashReusedValuesUnsync::new())); - let reused_values_ref = unsafe { &*reused_values }; + let transaction = Transaction::new(tx.version, tx_inputs, tx_outputs, tx.lock_time, Default::default(), 0, vec![]); let utxos = utxo_specs .into_iter() - .map(|(value, spk, covenant_id)| UtxoEntry::new(value, spk, 0, transaction_ref.is_coinbase(), covenant_id)) + .map(|(value, spk, covenant_id)| UtxoEntry::new(value, spk, 0, transaction.is_coinbase(), covenant_id)) .collect::>(); - let populated_tx = Box::into_raw(Box::new(PopulatedTransaction::new(transaction_ref, utxos))); - let populated_tx_ref = unsafe { &*populated_tx }; - let covenants_ctx = Box::into_raw(Box::new( - CovenantsContext::from_tx(populated_tx_ref).map_err(|err| format!("failed to build covenant context: {err}"))?, - )); - let covenants_ctx_ref = unsafe { &*covenants_ctx }; - let active_input = transaction_ref - .inputs - .get(tx.active_input_index) - .ok_or_else(|| format!("missing tx input at index {}", tx.active_input_index))?; - let active_utxo = populated_tx_ref - .utxo(tx.active_input_index) - .ok_or_else(|| format!("missing utxo entry for input {}", tx.active_input_index))?; - - Ok(BuiltTxContext { - transaction: unsafe { NonNull::new_unchecked(transaction) }, - populated_tx: populated_tx_ref, - populated_tx_ptr: unsafe { NonNull::new_unchecked(populated_tx) }, - covenants_ctx: covenants_ctx_ref, - covenants_ctx_ptr: unsafe { NonNull::new_unchecked(covenants_ctx) }, - active_input, - active_utxo, - reused_values: reused_values_ref, - reused_values_ptr: unsafe { NonNull::new_unchecked(reused_values) }, - }) -} - -fn build_output( - source: &str, - parsed_contract: &ContractAst<'_>, - output: &TestTxOutputScenarioResolved, - raw_ctor_args: &[String], - active_input_index: usize, - ctor_script_cache: &mut HashMap, Vec>, -) -> Result { - let script_public_key = if let Some(raw_script) = output.script_hex.as_deref() { - ScriptPublicKey::new(0, parse_hex_bytes(raw_script)?.into()) - } else if let Some(raw_pubkey) = output.p2pk_pubkey.as_deref() { - let pubkey_bytes = parse_hex_bytes(raw_pubkey)?; - ScriptPublicKey::new(0, build_p2pk_script(&pubkey_bytes).into()) - } else { - let output_ctor_raw = output.constructor_args.clone().unwrap_or_else(|| raw_ctor_args.to_vec()); - let output_script = compile_script_for_ctor_args(source, parsed_contract, &output_ctor_raw, ctor_script_cache)?; - pay_to_script_hash_script(&output_script) - }; - - let covenant = output - .covenant_id - .as_deref() - .map(|raw| -> Result { - Ok(CovenantBinding { - authorizing_input: output.authorizing_input.unwrap_or(active_input_index as u16), - covenant_id: parse_hash32(raw)?, - }) - }) - .transpose()?; - - Ok(TransactionOutput { value: output.value, script_public_key, covenant }) -} - -fn compile_script_for_ctor_args( - source: &str, - parsed_contract: &ContractAst<'_>, - raw_ctor_args: &[String], - cache: &mut HashMap, Vec>, -) -> Result, String> { - if let Some(script) = cache.get(raw_ctor_args) { - return Ok(script.clone()); - } - let ctor_args = parse_ctor_args(parsed_contract, raw_ctor_args)?; - let compiled = compile_contract(source, &ctor_args, CompileOptions::default()).map_err(|err| format!("compile error: {err}"))?; - cache.insert(raw_ctor_args.to_vec(), compiled.script.clone()); - Ok(compiled.script) + Ok((transaction, utxos, SigHashReusedValuesUnsync::new())) } fn parse_hash32(raw: &str) -> Result { @@ -742,6 +1156,7 @@ contract Simple() { utxo_value: 5000, covenant_id: None, constructor_args: None, + state: None, signature_script_hex: None, utxo_script_hex: None, }], @@ -750,6 +1165,7 @@ contract Simple() { covenant_id: None, authorizing_input: None, constructor_args: None, + state: None, script_hex: None, p2pk_pubkey: None, }], diff --git a/debugger/dap/tests/harness.rs b/debugger/dap/tests/harness.rs index d359e6e8..1945727e 100644 --- a/debugger/dap/tests/harness.rs +++ b/debugger/dap/tests/harness.rs @@ -7,7 +7,7 @@ use std::time::Duration; use serde_json::{Value, json}; -const MESSAGE_TIMEOUT: Duration = Duration::from_secs(10); +const MESSAGE_TIMEOUT: Duration = Duration::from_secs(30); pub struct TestClient { child: Child, diff --git a/debugger/dap/tests/test_launch.rs b/debugger/dap/tests/test_launch.rs index c1836dcf..6b0201d8 100644 --- a/debugger/dap/tests/test_launch.rs +++ b/debugger/dap/tests/test_launch.rs @@ -522,6 +522,48 @@ fn run_config_json_accepts_identity_tokens() { ); } +#[test] +fn run_config_json_executes_kcc20_flow_fixtures() { + let fixture_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../fixtures/kcc20-flow"); + let fixture_names = [ + "01-init-kcc20-minter-branch.json", + "02-create-tokens-from-minter.json", + "03-burn-tokens-from-minter.json", + "04-transfer-created-tokens.json", + ]; + + for fixture_name in fixture_names { + let fixture_path = fixture_dir.join(fixture_name); + let raw = fs::read_to_string(&fixture_path).unwrap_or_else(|err| panic!("failed to read {}: {err}", fixture_path.display())); + let mut config = serde_json::from_str::(&raw).expect("fixture JSON parses"); + config["scriptPath"] = serde_json::Value::String( + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../silverscript-lang/tests/examples/kcc20.sil") + .to_string_lossy() + .to_string(), + ); + let output = std::process::Command::new(harness::resolve_debugger_dap_binary()) + .arg("--run-config-json") + .arg(config.to_string()) + .output() + .unwrap_or_else(|err| panic!("failed to run debugger-dap for {}: {err}", fixture_path.display())); + + assert!( + output.status.success(), + "KCC20 fixture {} failed: stdout={}, stderr={}", + fixture_name, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + assert!( + String::from_utf8_lossy(&output.stdout).contains("Execution completed successfully."), + "unexpected stdout for {}: {}", + fixture_name, + String::from_utf8_lossy(&output.stdout) + ); + } +} + #[test] fn run_config_json_rejects_invalid_identity_tokens() { let script = TempScript::new(CHECKSIG_SCRIPT); @@ -888,7 +930,7 @@ contract ScopeTest(int threshold) { .into_iter() .filter_map(|item| item.get("name").and_then(|value| value.as_str()).map(ToOwned::to_owned)) .collect::>(); - assert_eq!(variable_names, vec!["a".to_string(), "b".to_string(), "local".to_string(), "threshold (const)".to_string()]); + assert_eq!(variable_names, vec!["a".to_string(), "b".to_string(), "local".to_string(), "threshold (ctor)".to_string()]); client.send_request("variables", json!({"variablesReference": dstack_ref})); let dstack = client.expect_response_success("variables"); diff --git a/debugger/fixtures/kcc20-flow/01-init-kcc20-minter-branch.json b/debugger/fixtures/kcc20-flow/01-init-kcc20-minter-branch.json new file mode 100644 index 00000000..4bf55086 --- /dev/null +++ b/debugger/fixtures/kcc20-flow/01-init-kcc20-minter-branch.json @@ -0,0 +1,71 @@ +{ + "name": "KCC20 flow 01 - initialize token minter branch", + "type": "silverscript", + "request": "launch", + "scriptPath": "silverscript-lang/tests/examples/kcc20.sil", + "function": "transfer", + "constructorArgs": [ + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + 0, + 2, + true, + 2, + 2 + ], + "args": [ + [ + { + "ownerIdentifier": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "identifierType": 2, + "amount": 0, + "isMinter": true + } + ], + [], + [0] + ], + "tx": { + "active_input_index": 0, + "inputs": [ + { + "utxo_value": 1000, + "covenant_id": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "constructor_args": [ + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + 0, + 2, + true, + 2, + 2 + ], + "state": { + "ownerIdentifier": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "identifierType": 2, + "amount": 0, + "isMinter": true + } + } + ], + "outputs": [ + { + "value": 1000, + "covenant_id": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "authorizing_input": 0, + "constructor_args": [ + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + 0, + 2, + true, + 2, + 2 + ], + "state": { + "ownerIdentifier": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "identifierType": 2, + "amount": 0, + "isMinter": true + } + } + ] + } +} diff --git a/debugger/fixtures/kcc20-flow/02-create-tokens-from-minter.json b/debugger/fixtures/kcc20-flow/02-create-tokens-from-minter.json new file mode 100644 index 00000000..3cd85b25 --- /dev/null +++ b/debugger/fixtures/kcc20-flow/02-create-tokens-from-minter.json @@ -0,0 +1,96 @@ +{ + "name": "KCC20 flow 02 - create tokens from minter branch", + "type": "silverscript", + "request": "launch", + "scriptPath": "silverscript-lang/tests/examples/kcc20.sil", + "function": "transfer", + "constructorArgs": [ + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + 0, + 2, + true, + 2, + 2 + ], + "args": [ + [ + { + "ownerIdentifier": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "identifierType": 2, + "amount": 900, + "isMinter": true + }, + { + "ownerIdentifier": "0x2222222222222222222222222222222222222222222222222222222222222222", + "identifierType": 2, + "amount": 100, + "isMinter": false + } + ], + [], + [0] + ], + "tx": { + "active_input_index": 0, + "inputs": [ + { + "utxo_value": 1000, + "covenant_id": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "constructor_args": [ + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + 0, + 2, + true, + 2, + 2 + ], + "state": { + "ownerIdentifier": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "identifierType": 2, + "amount": 0, + "isMinter": true + } + } + ], + "outputs": [ + { + "value": 1000, + "covenant_id": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "authorizing_input": 0, + "constructor_args": [ + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + 900, + 2, + true, + 2, + 2 + ], + "state": { + "ownerIdentifier": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "identifierType": 2, + "amount": 900, + "isMinter": true + } + }, + { + "value": 1000, + "covenant_id": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "authorizing_input": 0, + "constructor_args": [ + "0x2222222222222222222222222222222222222222222222222222222222222222", + 100, + 2, + false, + 2, + 2 + ], + "state": { + "ownerIdentifier": "0x2222222222222222222222222222222222222222222222222222222222222222", + "identifierType": 2, + "amount": 100, + "isMinter": false + } + } + ] + } +} diff --git a/debugger/fixtures/kcc20-flow/03-burn-tokens-from-minter.json b/debugger/fixtures/kcc20-flow/03-burn-tokens-from-minter.json new file mode 100644 index 00000000..099567aa --- /dev/null +++ b/debugger/fixtures/kcc20-flow/03-burn-tokens-from-minter.json @@ -0,0 +1,71 @@ +{ + "name": "KCC20 flow 03 - burn tokens from minter branch", + "type": "silverscript", + "request": "launch", + "scriptPath": "silverscript-lang/tests/examples/kcc20.sil", + "function": "transfer", + "constructorArgs": [ + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + 900, + 2, + true, + 2, + 2 + ], + "args": [ + [ + { + "ownerIdentifier": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "identifierType": 2, + "amount": 500, + "isMinter": true + } + ], + [], + [0] + ], + "tx": { + "active_input_index": 0, + "inputs": [ + { + "utxo_value": 1000, + "covenant_id": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "constructor_args": [ + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + 900, + 2, + true, + 2, + 2 + ], + "state": { + "ownerIdentifier": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "identifierType": 2, + "amount": 900, + "isMinter": true + } + } + ], + "outputs": [ + { + "value": 1000, + "covenant_id": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "authorizing_input": 0, + "constructor_args": [ + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + 500, + 2, + true, + 2, + 2 + ], + "state": { + "ownerIdentifier": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "identifierType": 2, + "amount": 500, + "isMinter": true + } + } + ] + } +} diff --git a/debugger/fixtures/kcc20-flow/04-transfer-created-tokens.json b/debugger/fixtures/kcc20-flow/04-transfer-created-tokens.json new file mode 100644 index 00000000..18fa3114 --- /dev/null +++ b/debugger/fixtures/kcc20-flow/04-transfer-created-tokens.json @@ -0,0 +1,89 @@ +{ + "name": "KCC20 flow 04 - transfer created non-minter tokens", + "type": "silverscript", + "request": "launch", + "scriptPath": "silverscript-lang/tests/examples/kcc20.sil", + "function": "transfer", + "constructorArgs": [ + "0x2222222222222222222222222222222222222222222222222222222222222222", + 100, + 2, + false, + 2, + 2 + ], + "args": [ + [ + { + "ownerIdentifier": "0x3333333333333333333333333333333333333333333333333333333333333333", + "identifierType": 2, + "amount": 100, + "isMinter": false + } + ], + [], + [1] + ], + "tx": { + "active_input_index": 0, + "inputs": [ + { + "utxo_value": 1000, + "covenant_id": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "constructor_args": [ + "0x2222222222222222222222222222222222222222222222222222222222222222", + 100, + 2, + false, + 2, + 2 + ], + "state": { + "ownerIdentifier": "0x2222222222222222222222222222222222222222222222222222222222222222", + "identifierType": 2, + "amount": 100, + "isMinter": false + } + }, + { + "utxo_value": 1000, + "covenant_id": "0x2222222222222222222222222222222222222222222222222222222222222222", + "constructor_args": [ + "0x2222222222222222222222222222222222222222222222222222222222222222", + 0, + 2, + true, + 2, + 2 + ], + "state": { + "ownerIdentifier": "0x2222222222222222222222222222222222222222222222222222222222222222", + "identifierType": 2, + "amount": 0, + "isMinter": true + } + } + ], + "outputs": [ + { + "value": 1000, + "covenant_id": "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "authorizing_input": 0, + "constructor_args": [ + "0x3333333333333333333333333333333333333333333333333333333333333333", + 100, + 2, + false, + 2, + 2 + ], + "state": { + "ownerIdentifier": "0x3333333333333333333333333333333333333333333333333333333333333333", + "identifierType": 2, + "amount": 100, + "isMinter": false + } + } + ] + } +} diff --git a/debugger/fixtures/kcc20-flow/README.md b/debugger/fixtures/kcc20-flow/README.md new file mode 100644 index 00000000..27c83f42 --- /dev/null +++ b/debugger/fixtures/kcc20-flow/README.md @@ -0,0 +1,14 @@ +# KCC20 Debug Flow Fixtures + +These launch JSON files define the first debugger target for the rebased DAP work. + +They intentionally focus on the `KCC20` token covenant in `silverscript-lang/tests/examples/kcc20.sil` and avoid pubkey signatures by using covenant-ID ownership. That makes the flow deterministic while still exercising covenant state, generated covenant entrypoints, `State[]` arguments, witness inputs, minting, burning, and non-minter transfer checks. + +Flow: + +1. `01-init-kcc20-minter-branch.json` initializes a zero-amount minter branch. +2. `02-create-tokens-from-minter.json` creates token supply from that minter branch. +3. `03-burn-tokens-from-minter.json` burns part of the minter branch supply. +4. `04-transfer-created-tokens.json` transfers a created non-minter token branch through a covenant-ID witness input. + +The final DAP goal is to launch each file, stop in source-level covenant code, inspect `prevStates`/`newStates`, continue to completion, and report success. diff --git a/docs/dap-layer-migration-plan.md b/docs/dap-layer-migration-plan.md new file mode 100644 index 00000000..be5d4b69 --- /dev/null +++ b/docs/dap-layer-migration-plan.md @@ -0,0 +1,105 @@ +# DAP Layer Migration Plan + +## Current Git State + +- Working branch: `dap-and-vsc-debugger` +- New base: `origin/covpp-reset2` at `efd4293` (`Fix State[] field access for array fields (#111)`) +- Backup branch before rebasing: `codex/dap-and-vsc-debugger-pre-covpp-reset2-rebase` +- Preserved pre-rebase dirty worktree: `stash@{0}` (`pre-rebase dap-vscode dirty worktree`) +- The rebased branch still contains the historical combined DAP and VS Code extension changes. The next cleanup step should split the PR surface before implementation continues. + +## Goal + +Prepare a first PR that reintroduces only the DAP layer on top of the covenant-aware debugger/session work from `covpp-reset2`. + +The VS Code extension should be a later PR that consumes the stable DAP binary/protocol behavior after the DAP layer has landed. + +The concrete end-to-end target for this migration is debugging the full KCC20 token flow from an initialized minter branch, through token creation, token burn, and created-token transfer. The JSON launch fixtures for that flow live under `debugger/fixtures/kcc20-flow/`. + +## Scope For The DAP PR + +Keep: + +- `debugger/dap/**` +- Workspace wiring needed to build the DAP crate: + - `Cargo.toml` + - `Cargo.lock` +- Minimal shared debugger/session API changes that are required by the DAP layer and are not VS Code specific. +- DAP tests that exercise the adapter and launch/runtime behavior. + +Defer: + +- `extensions/vscode/**` +- Extension packaging, CodeLens, quick launch UI, webviews, and adapter bootstrap scripts. +- Any UX or editor-specific launch configuration migration. + +## Important Upstream Changes To Preserve + +The reset branch has deliberate covenant support inside the debugger/session layer. The DAP migration should reuse it instead of duplicating older launch/runtime logic. + +Relevant upstream pieces: + +- `debugger/session/src/covenant.rs` + - Resolves source covenant functions to generated entrypoints. + - Tracks auth/cov binding, verification/transition mode, generated names, and source binding metadata. +- `DebugSession::with_covenant_mode` + - Activates covenant display names and synthetic binding overlays. + - Preserves source-level stepping behavior by hiding generated covenant internals. +- `debugger/session/src/args.rs` + - Parses constructor/call args from the contract AST rather than ABI strings. + - Supports structured `State`, `State[]`, custom structs, fixed byte arrays, and explicit state values. +- `debugger/cli/src/main.rs` + - Is the current reference implementation for covenant launch behavior, including generated covenant entrypoint selection, synthesized prefix args, and explicit state materialization. + +## Migration Plan + +1. Split the branch surface. + - Create or keep a DAP-only branch based on the current rebased `dap-and-vsc-debugger`. + - Remove VS Code extension commits/files from the DAP PR branch. + - Keep the pre-rebase backup branch and stash until both DAP and VS Code follow-up work are accounted for. + +2. Rebase-normalize the DAP crate against covenant-aware APIs. + - Replace older ABI-string argument parsing in `debugger/dap/src/runtime_builder.rs` with the contract-AST based parser used by the CLI. + - Resolve user-selected source covenant functions through `resolve_covenant_call_target`. + - Launch generated covenant entrypoints where appropriate, while displaying source covenant function names through `DebugSession`. + - Pass `with_covenant_mode(...)` and covenant state values into the session when debugging covenant flows. + +3. Align transaction and state setup with CLI behavior. + - Reuse the CLI’s covenant transaction semantics rather than adding a parallel DAP-only interpretation. + - Support `prev_state`/`prev_states`, generated leader/delegate entrypoints, and explicit state scripts consistently with the CLI/test runner. + - Keep DAP launch JSON as a transport format only; do not make it an alternate contract semantics layer. + +4. Rework DAP tests around the new base. + - Keep adapter protocol tests focused on DAP behavior: launch, breakpoints, stack trace, scopes, variables, stepping, and errors. + - Add covenant-oriented DAP launch cases only after the runtime path is using the upstream covenant session support. + - Avoid importing VS Code fixtures or extension behavior into DAP tests. + +5. Verify the DAP-only PR. + - `cargo fmt` + - `cargo check -p debugger-dap` + - `cargo test -p debugger-dap` + - Relevant `debugger-session` tests if shared session APIs are touched. + +## Known Cleanup Before Implementation + +- The rebased diff still includes `extensions/vscode/**`; those files must be removed from the DAP PR branch before opening the first PR. +- `debugger/dap/src/runtime_builder.rs` still reflects the older pre-reset launch path and must be reconciled with the covenant-aware CLI/session path. +- `debugger/session/src/args.rs` currently keeps a small `values_to_args` helper for the DAP launch config. If the DAP launch parser is refactored, either keep this as shared utility or move it into DAP-local config parsing. + +## Resolved Build Blockers + +The initial post-rebase `cargo check -p debugger-dap` failures have been resolved: + +- `DebugSession::format_value` no longer exists as a session method; DAP formatting should use the current `debugger_session::format_value` helper pattern used by the CLI. +- `DebugSession::current_function_name` now returns `Option`, so DAP stack frame naming needs to drop the old borrowed-string conversion. +- `parse_call_args` now takes `(&ContractAst, function_name, raw_args)` rather than ABI input type strings. +- `EngineFlags` gained `sigop_script_units`. +- `TestTxInputScenarioResolved` and `TestTxOutputScenarioResolved` gained `state`. +- `TransactionInput` uses `mass` rather than `sig_op_count`. +- `VariableOrigin` now includes `ContractField` and `ConstructorArg`, and DAP variable presentation must account for both. + +Verification now passes with: + +- `cargo check -p debugger-dap` +- `cargo test -p debugger-dap` +- all `debugger/fixtures/kcc20-flow/*.json` through `debugger-dap --run-config-json` diff --git a/docs/vscode-debugger-extension-redesign-plan.md b/docs/vscode-debugger-extension-redesign-plan.md new file mode 100644 index 00000000..5fc12660 --- /dev/null +++ b/docs/vscode-debugger-extension-redesign-plan.md @@ -0,0 +1,75 @@ +# VS Code Debugger Extension Redesign Plan + +## Positioning + +The VS Code extension should be rebuilt after the DAP layer lands. It should treat `debugger-dap` as the product boundary and avoid duplicating compiler, covenant, transaction, or state semantics in TypeScript. + +The extension PR should not be part of the DAP PR. + +## Product Goal + +Make it easy to debug real covenant flows, including KCC20, without asking users to hand-author large launch objects from memory. + +The initial extension success case should be: + +- open `kcc20.sil` +- choose a saved KCC20 fixture/run profile +- launch the DAP adapter +- stop in source-level covenant code +- inspect `prevStates`, `newStates`, constructor args, contract fields, locals, and stack scopes +- run to completion or failure with the same error report as the CLI/DAP layer + +## Design Direction + +1. Keep the adapter boring. + - Use the DAP binary directly. + - Do not embed a second debug adapter in TypeScript. + - Do not reimplement transaction/state construction in the extension. + +2. Make launch configuration file-first. + - Support opening and running JSON launch files like `debugger/fixtures/kcc20-flow/*.json`. + - Keep VS Code `launch.json` support, but do not make it the only workflow. + - Let users save named run profiles next to the contract or in a workspace debug folder. + +3. Build a covenant-aware run profile editor. + - Inspect the contract for constructor params and source covenant functions. + - Present structured `State` and `State[]` editors as JSON objects/arrays. + - Provide transaction input/output sections with covenant IDs, authorizing input, constructor args, and explicit state. + - Show generated entrypoint details only as advanced/debug information. + +4. Prefer validation over generation magic. + - Validate JSON shape before launch. + - Validate missing function, constructor arg count, active input index, and state object shape by asking the DAP/CLI validation path where possible. + - Surface errors in the VS Code UI without rewriting them. + +5. Keep KCC20 as the acceptance fixture. + - Ship or document the KCC20 flow fixtures as examples. + - Add extension tests that launch those profiles through the actual DAP binary. + +## Proposed Extension PR Slices + +1. Minimal adapter host. + - Register the SilverScript debug type. + - Resolve or build `debugger-dap`. + - Launch existing JSON configs. + +2. Run profile explorer. + - Discover `*.debug.json` or selected fixture files. + - Provide run/debug buttons for saved profiles. + - Avoid custom webview UI initially unless native VS Code tree/detail views are insufficient. + +3. Covenant profile editor. + - Add a focused editor or webview only for editing structured tx/state JSON. + - Keep it backed by the same JSON file on disk. + +4. KCC20 workflow polish. + - Add commands for the four KCC20 flow profiles. + - Make failures navigable to source locations reported by DAP. + +## Non-Goals + +- No TypeScript implementation of covenant state materialization. +- No extension-specific transaction semantics. +- No bundled fork of the DAP protocol. +- No custom UI before the JSON profile workflow is stable. + diff --git a/extensions/vscode/.gitignore b/extensions/vscode/.gitignore index 910a5fa6..f06235c4 100644 --- a/extensions/vscode/.gitignore +++ b/extensions/vscode/.gitignore @@ -1,5 +1,2 @@ node_modules dist -bin/* -!bin/.gitignore -*.vsix diff --git a/extensions/vscode/.vscodeignore b/extensions/vscode/.vscodeignore index 2e6ede86..ba7a4413 100644 --- a/extensions/vscode/.vscodeignore +++ b/extensions/vscode/.vscodeignore @@ -3,9 +3,7 @@ out/** node_modules/** src/** -scripts/** -**/.gitignore -**/.gitkeep +.gitignore .yarnrc esbuild.js vsc-extension-quickstart.md @@ -16,10 +14,6 @@ vsc-extension-quickstart.md **/.vscode-test.* # keep these -!assets/tree-sitter-silverscript.wasm +!assets/** !queries/** -!webviews/** -!node_modules/web-tree-sitter/package.json -!node_modules/web-tree-sitter/LICENSE -!node_modules/web-tree-sitter/web-tree-sitter.cjs -!node_modules/web-tree-sitter/web-tree-sitter.wasm +!node_modules/web-tree-sitter/** diff --git a/extensions/vscode/CHANGELOG.md b/extensions/vscode/CHANGELOG.md index 919f23c1..4069c280 100644 --- a/extensions/vscode/CHANGELOG.md +++ b/extensions/vscode/CHANGELOG.md @@ -7,5 +7,3 @@ Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how ## [Unreleased] - Initial release -- Added DAP-based contract debugging and a quick launch panel. -- Bundle the native debug adapter into platform-specific VSIX packages. diff --git a/extensions/vscode/README.md b/extensions/vscode/README.md index 3dacac6d..ec38b2bb 100644 --- a/extensions/vscode/README.md +++ b/extensions/vscode/README.md @@ -1,19 +1,6 @@ ### Pre-release -Build a platform-specific VSIX that bundles the native Rust debug adapter: - -```bash -npm run package:vsix -``` - -By default this packages the adapter for the current host platform into `bin//`. - -For CI or cross-target release jobs, set: - -- `SILVERSCRIPT_VSCODE_TARGET`, for example `darwin-arm64` or `linux-x64` -- `SILVERSCRIPT_CARGO_TARGET`, for example `aarch64-apple-darwin` - -Then run `npm run package:vsix`. +`npm exec vsce package -- --pre-release` ### Development @@ -22,7 +9,6 @@ Then run `npm run package:vsix`. - Build the extension once with `npm run compile` (or keep it rebuilding with `npm run watch`). - Open `extensions/vscode` in VS Code. - Press `F5` and run `Run Extension` to start an Extension Development Host with this extension loaded. -- In a full repo checkout, the extension can still auto-build `debugger-dap` on demand for local development. Published VSIX builds should use the bundled adapter path instead. #### Live Grammar Changes @@ -36,69 +22,3 @@ npm run build:vscode This also refreshes shared highlighting queries (`extensions/vscode/queries/highlights.scm`). Then in the Extension Development Host, press `Ctrl+R` to reload and apply parser/query updates. - -### Contract Debugging - -This extension provides a lean DAP-based contract debugger. - -#### Launch Flow - -- Run `SilverScript: Run / Debug Contract` on an open `.sil` file, or press `F5`. -- The extension opens a lightweight runner panel for the current `.sil` file. -- The panel owns the current run/debug session state for constructor args and function args. -- Use `Load Saved` to pull an existing `silverscript` launch config for the current file into the panel. -- Use `Save Scenario` to write the current panel state back to `launch.json`. -- The debugger launches through the bundled Rust DAP adapter when available, with repo checkouts falling back to a local workspace build. - -If you need a custom adapter build, set `silverscript.debugAdapterPath` to an absolute path and the extension will use that binary instead. - -#### Parameters - -Launch configurations can provide: - -```json -{ - "type": "silverscript", - "request": "launch", - "name": "SilverScript: Debug Contract", - "scriptPath": "${file}", - "function": "main", - "constructorArgs": { - "x": "3", - "y": "10" - }, - "args": { - "a": "5", - "b": "5" - }, - "stopOnEntry": true -} -``` - -The panel does not live-edit `launch.json`. It edits the current session state and can load/save named launch configs when you want persistence. Advanced fields such as `tx` stay in `launch.json` and are preserved when a saved scenario is loaded and updated through the panel. - -For contracts that need identity-like values, launch args can use symbolic tokens instead of concrete key material: - -```json -{ - "function": "spend", - "args": { - "pk": "keypair1.pubkey", - "s": "keypair1.secret" - } -} -``` - -Supported identity tokens are: - -- `keypair.pubkey` -- `keypair.secret` -- `keypair.pkh` - -They are resolved lazily by the Rust runtime and stay consistent within a single launch/run only. - -The panel includes an `Identities` helper that fills these tokens directly into `pubkey`, `sig`, and `pkh`-style fields. - -#### Transaction Context - -The debugger runs against a small synthetic transaction context by default so `sig` arguments can be auto-signed from a 32-byte secret key. Advanced users can override that runtime context by adding a `tx` object to `launch.json`; this is intentionally kept out of the panel UI. diff --git a/extensions/vscode/bin/.gitignore b/extensions/vscode/bin/.gitignore deleted file mode 100644 index d6b7ef32..00000000 --- a/extensions/vscode/bin/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/extensions/vscode/package.json b/extensions/vscode/package.json index b1f2a35d..96ea69fe 100644 --- a/extensions/vscode/package.json +++ b/extensions/vscode/package.json @@ -2,18 +2,12 @@ "name": "silverscript", "publisher": "IzioDev", "displayName": "SilverScript", - "description": "SilverScript language support and contract debugging for VS Code", + "description": "Kaspa SilverScript", "version": "0.1.3", - "preview": true, "repository": { - "type": "git", "url": "https://github.com/kaspanet/silverscript", "directory": "extensions/vscode" }, - "homepage": "https://github.com/kaspanet/silverscript/tree/main/extensions/vscode", - "bugs": { - "url": "https://github.com/kaspanet/silverscript/issues" - }, "license": "ISC", "author": { "name": "Kaspa Developers" @@ -21,124 +15,14 @@ "engines": { "vscode": "^1.108.0" }, - "extensionKind": [ - "workspace" - ], - "capabilities": { - "untrustedWorkspaces": { - "supported": "limited" - }, - "virtualWorkspaces": { - "supported": false - } - }, "categories": [ - "Programming Languages", - "Debuggers" - ], - "keywords": [ - "silverscript", - "kaspa", - "smart contracts", - "debugger" + "Other" ], "activationEvents": [ - "onLanguage:silverscript", - "onDebug:silverscript" + "onLanguage:silverscript" ], "main": "./dist/extension.js", "contributes": { - "commands": [ - { - "command": "silverscript.debug.configureLaunch", - "category": "SilverScript", - "title": "SilverScript: Run / Debug Contract" - } - ], - "keybindings": [ - { - "command": "silverscript.debug.f5", - "key": "f5", - "when": "!inDebugMode && (editorLangId == silverscript || webviewId == 'silverscriptRunner')" - } - ], - "debuggers": [ - { - "type": "silverscript", - "label": "SilverScript Debug", - "languages": [ - "silverscript" - ], - "breakpoints": [ - { - "language": "silverscript" - } - ], - "configurationAttributes": { - "launch": { - "properties": { - "scriptPath": { - "type": "string", - "description": "Path to SilverScript source file" - }, - "function": { - "type": "string", - "description": "Entrypoint function name" - }, - "constructorArgs": { - "oneOf": [ - { - "type": "array", - "items": { - "type": "string" - } - }, - { - "type": "object" - } - ], - "default": [] - }, - "args": { - "oneOf": [ - { - "type": "array", - "items": { - "type": "string" - } - }, - { - "type": "object" - } - ], - "default": [] - }, - "tx": { - "type": "object", - "description": "Optional advanced transaction context override used by the Rust debugger runtime" - }, - "noDebug": { - "type": "boolean", - "default": false - }, - "stopOnEntry": { - "type": "boolean", - "default": true - } - } - } - }, - "initialConfigurations": [ - { - "type": "silverscript", - "request": "launch", - "name": "SilverScript: Debug Contract", - "scriptPath": "${file}", - "stopOnEntry": true - } - ] - } - ], "languages": [ { "id": "silverscript", @@ -152,11 +36,6 @@ "configuration": "./language-configuration.json" } ], - "breakpoints": [ - { - "language": "silverscript" - } - ], "configuration": { "type": "object", "title": "SilverScript", @@ -166,6 +45,11 @@ "default": true, "description": "Enable covenants-only opcodes." }, + "silverscript.withoutSelector": { + "type": "boolean", + "default": false, + "description": "Compile without function selector (single entrypoint)." + }, "silverscript.maxDiagnostics": { "type": "number", "default": 200, @@ -175,36 +59,17 @@ "type": "boolean", "default": true, "description": "Enable semantic tokens from the LSP." - }, - "silverscript.debugAdapterPath": { - "type": "string", - "default": "", - "scope": "machine", - "markdownDescription": "Optional path to a custom `debugger-dap` binary. When set, SilverScript uses it instead of the bundled adapter." - }, - "silverscript.autoBuildDebuggerAdapter": { - "type": "boolean", - "default": true, - "description": "When running from a SilverScript repo checkout, build the Rust debug adapter automatically if no compatible binary is available." - }, - "silverscript.debuggerTrace": { - "type": "boolean", - "default": false, - "description": "Enable verbose SilverScript debugger adapter lifecycle logging in the output channel." } } } }, "scripts": { - "vscode:prepublish": "npm run prepare-release", - "bundle:adapter": "node scripts/prepare-adapter.mjs", + "vscode:prepublish": "npm run package", "compile": "npm run check-types && npm run lint && node esbuild.js", "watch": "npm-run-all -p watch:*", "watch:esbuild": "node esbuild.js --watch", "watch:tsc": "tsc --noEmit --watch --project tsconfig.json", "package": "npm run check-types && npm run lint && node esbuild.js --production", - "prepare-release": "npm run bundle:adapter && npm run package", - "package:vsix": "npm exec vsce package -- --pre-release", "compile-tests": "tsc -p . --outDir out", "watch-tests": "tsc -p . -w --outDir out", "pretest": "npm run compile-tests && npm run compile && npm run lint", diff --git a/extensions/vscode/scripts/prepare-adapter.mjs b/extensions/vscode/scripts/prepare-adapter.mjs deleted file mode 100644 index ae2430c5..00000000 --- a/extensions/vscode/scripts/prepare-adapter.mjs +++ /dev/null @@ -1,88 +0,0 @@ -import { spawnSync } from "node:child_process"; -import { - chmodSync, - copyFileSync, - existsSync, - mkdirSync, -} from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -function readFlag(flagName) { - const index = process.argv.indexOf(flagName); - if (index < 0) { - return undefined; - } - return process.argv[index + 1]; -} - -function resolveCurrentTarget() { - return `${process.platform}-${process.arch}`; -} - -function resolveExecutableName(target) { - return target.startsWith("win32-") - ? "debugger-dap.exe" - : "debugger-dap"; -} - -function absoluteFrom(base, value) { - return path.isAbsolute(value) ? value : path.resolve(base, value); -} - -const extensionRoot = path.resolve(__dirname, ".."); -const repoRoot = path.resolve(extensionRoot, "..", ".."); -const vscodeTarget = - readFlag("--target") ?? - process.env.SILVERSCRIPT_VSCODE_TARGET ?? - resolveCurrentTarget(); -const cargoTarget = - readFlag("--cargo-target") ?? - process.env.SILVERSCRIPT_CARGO_TARGET; -const explicitBinary = - readFlag("--binary") ?? - process.env.SILVERSCRIPT_DEBUGGER_DAP_BIN; -const executableName = resolveExecutableName(vscodeTarget); - -let builtBinary; -if (explicitBinary) { - builtBinary = absoluteFrom(process.cwd(), explicitBinary); -} else { - const args = ["build", "--release", "-p", "debugger-dap"]; - if (cargoTarget) { - args.push("--target", cargoTarget); - } - - const result = spawnSync("cargo", args, { - cwd: repoRoot, - stdio: "inherit", - }); - if (result.status !== 0) { - process.exit(result.status ?? 1); - } - - const buildDir = cargoTarget - ? path.join(repoRoot, "target", cargoTarget, "release") - : path.join(repoRoot, "target", "release"); - builtBinary = path.join(buildDir, executableName); -} - -if (!existsSync(builtBinary)) { - throw new Error(`debugger-dap binary not found: ${builtBinary}`); -} - -const destinationDir = path.join(extensionRoot, "bin", vscodeTarget); -const destinationBinary = path.join(destinationDir, executableName); -mkdirSync(destinationDir, { recursive: true }); -copyFileSync(builtBinary, destinationBinary); - -if (!vscodeTarget.startsWith("win32-")) { - chmodSync(destinationBinary, 0o755); -} - -console.log( - `[bundle] copied ${builtBinary} -> ${destinationBinary}`, -); diff --git a/extensions/vscode/src/codeLens.ts b/extensions/vscode/src/codeLens.ts deleted file mode 100644 index 9e00d351..00000000 --- a/extensions/vscode/src/codeLens.ts +++ /dev/null @@ -1,157 +0,0 @@ -import * as vscode from "vscode"; -import { countSilverScriptSavedScenarios } from "./launchConfigs"; -import { - hasOpenSilverScriptPanelForUri, - onDidChangeSilverScriptPanelState, -} from "./quickLaunch/panel"; - -const CONTRACT_RE = /^\s*contract\s+([A-Za-z_]\w*)\s*\(/; -const ENTRYPOINT_RE = - /^\s*entrypoint\s+function\s+([A-Za-z_]\w*)\s*\(/; - -type EntrypointTarget = { - functionName: string; - range: vscode.Range; -}; - -function findContractRange( - document: vscode.TextDocument, -): vscode.Range | undefined { - for (let line = 0; line < document.lineCount; line += 1) { - const text = document.lineAt(line).text; - const contractMatch = CONTRACT_RE.exec(text); - if (!contractMatch) { - continue; - } - - const start = new vscode.Position( - line, - contractMatch[0].search(/\S|$/), - ); - return new vscode.Range(start, start); - } - - return undefined; -} - -function findEntrypointTargets( - document: vscode.TextDocument, -): EntrypointTarget[] { - const targets: EntrypointTarget[] = []; - - for (let line = 0; line < document.lineCount; line += 1) { - const text = document.lineAt(line).text; - const entrypointMatch = ENTRYPOINT_RE.exec(text); - if (!entrypointMatch) { - continue; - } - - const start = new vscode.Position( - line, - entrypointMatch[0].search(/\S|$/), - ); - targets.push({ - functionName: entrypointMatch[1], - range: new vscode.Range(start, start), - }); - } - - return targets; -} - -function savedLensTitle(count: number): string { - return count === 1 - ? "1 scenario saved" - : `${count} scenarios saved`; -} - -function primaryLensTitle(document: vscode.TextDocument): string { - return hasOpenSilverScriptPanelForUri(document.uri) - ? "Run" - : "Open Debug Panel..."; -} - -class SilverScriptCodeLensProvider - implements vscode.CodeLensProvider -{ - private readonly onDidChangeEmitter = - new vscode.EventEmitter(); - - readonly onDidChangeCodeLenses = this.onDidChangeEmitter.event; - - triggerRefresh(): void { - this.onDidChangeEmitter.fire(); - } - - provideCodeLenses( - document: vscode.TextDocument, - ): vscode.CodeLens[] { - if (document.languageId !== "silverscript") { - return []; - } - - const contractRange = findContractRange(document); - const entrypointTargets = findEntrypointTargets(document); - if (!contractRange && entrypointTargets.length === 0) { - return []; - } - - const counts = countSilverScriptSavedScenarios(document.uri); - const lenses: vscode.CodeLens[] = []; - - if (contractRange) { - lenses.push( - new vscode.CodeLens(contractRange, { - title: primaryLensTitle(document), - command: "silverscript.debug.primaryCodeLensAction", - arguments: [document.uri], - }), - ); - } - - for (const target of entrypointTargets) { - const count = counts.byFunction[target.functionName] ?? 0; - lenses.push( - new vscode.CodeLens(target.range, { - title: savedLensTitle(count), - command: "silverscript.debug.showSavedScenarios", - arguments: [document.uri, target.functionName, count > 0], - }), - ); - } - - return lenses; - } -} - -export function registerSilverScriptCodeLens( - context: vscode.ExtensionContext, -): void { - const provider = new SilverScriptCodeLensProvider(); - - context.subscriptions.push( - vscode.languages.registerCodeLensProvider( - { language: "silverscript" }, - provider, - ), - ); - context.subscriptions.push( - onDidChangeSilverScriptPanelState(() => { - provider.triggerRefresh(); - }), - ); - context.subscriptions.push( - vscode.workspace.onDidChangeTextDocument((event) => { - if (event.document.languageId === "silverscript") { - provider.triggerRefresh(); - } - }), - ); - context.subscriptions.push( - vscode.workspace.onDidChangeConfiguration((event) => { - if (event.affectsConfiguration("launch")) { - provider.triggerRefresh(); - } - }), - ); -} diff --git a/extensions/vscode/src/contractModel.ts b/extensions/vscode/src/contractModel.ts deleted file mode 100644 index 0b232da0..00000000 --- a/extensions/vscode/src/contractModel.ts +++ /dev/null @@ -1,132 +0,0 @@ -export type ContractParam = { name: string; type: string }; -export type Entrypoint = { name: string; params: ContractParam[] }; -export type ContractModel = { - name: string; - constructorParams: ContractParam[]; - entrypoints: Entrypoint[]; -}; - -export type DebugArgObject = Record; -export type DebugArgInput = unknown[] | DebugArgObject; -export type DebugTxInput = { - prev_txid?: string; - prev_index?: number; - sequence?: number; - sig_op_count?: number; - utxo_value: number; - covenant_id?: string; - constructor_args?: DebugArgInput; - signature_script_hex?: string; - utxo_script_hex?: string; -}; -export type DebugTxOutput = { - value: number; - covenant_id?: string; - authorizing_input?: number; - constructor_args?: DebugArgInput; - script_hex?: string; - p2pk_pubkey?: string; -}; -export type DebugTxScenario = { - version?: number; - lock_time?: number; - active_input_index?: number; - inputs: DebugTxInput[]; - outputs: DebugTxOutput[]; -}; - -function stripComments(source: string): string { - return source - .replace(/\/\*[\s\S]*?\*\//g, "") - .replace(/\/\/.*$/gm, ""); -} - -function parseParams(raw: string): ContractParam[] { - return raw - .split(",") - .map((value) => value.trim()) - .filter(Boolean) - .map((part, index) => { - const [typeName, name] = part.split(/\s+/).filter(Boolean); - return { type: typeName ?? "int", name: name ?? `arg${index}` }; - }); -} - -export function parseContractModel(source: string): ContractModel { - const clean = stripComments(source); - const header = clean.match( - /contract\s+([A-Za-z_]\w*)\s*\(([^)]*)\)/m, - ); - const name = header?.[1] ?? "Unknown"; - const constructorParams = header?.[2]?.trim() - ? parseParams(header[2]) - : []; - const entrypoints: Entrypoint[] = []; - const re = - /entrypoint\s+function\s+([A-Za-z_]\w*)\s*\(([^)]*)\)/g; - for (let match; (match = re.exec(clean)); ) { - entrypoints.push({ - name: match[1], - params: parseParams(match[2]), - }); - } - return { name, constructorParams, entrypoints }; -} - -function hex(n: number): string { - return `0x${"00".repeat(Math.max(0, n))}`; -} - -export function defaultForType(typeName: string): unknown { - const normalized = typeName.trim(); - if (normalized.endsWith("[]")) { - return []; - } - switch (normalized) { - case "int": - return 0; - case "bool": - return false; - case "string": - return ""; - case "byte": - return hex(1); - case "bytes": - return hex(0); - case "pubkey": - return hex(32); - case "sig": - return hex(65); - case "datasig": - return hex(64); - } - let match = normalized.match(/^bytes(\d+)$/); - if (match) { - return hex(Number(match[1])); - } - match = normalized.match(/^byte\[(\d+)\]$/); - if (match) { - return hex(Number(match[1])); - } - return 0; -} - -export function defaultsFromParams(params: ContractParam[]): unknown[] { - return params.map((param) => defaultForType(param.type)); -} - -export function defaultsObjectFromParams( - params: ContractParam[], -): DebugArgObject { - return Object.fromEntries( - params.map((param) => [param.name, defaultForType(param.type)]), - ); -} - -function isDebugArgObject(value: unknown): value is DebugArgObject { - return ( - value !== null && - typeof value === "object" && - !Array.isArray(value) - ); -} diff --git a/extensions/vscode/src/debug.ts b/extensions/vscode/src/debug.ts deleted file mode 100644 index 4e6bb3c0..00000000 --- a/extensions/vscode/src/debug.ts +++ /dev/null @@ -1,268 +0,0 @@ -import * as fs from "fs"; -import * as vscode from "vscode"; -import { - defaultsObjectFromParams, - type DebugArgInput, - parseContractModel, -} from "./contractModel"; -import { - debuggerTraceEnabled, - ensureDebuggerAdapterBinary, -} from "./debugAdapter"; -import { resolveLaunchScriptPath } from "./launchConfigs"; - -function isDebugArgInput(value: unknown): value is DebugArgInput { - return ( - Array.isArray(value) || - (value !== null && typeof value === "object") - ); -} - -function resolveActiveScriptUri(uri?: vscode.Uri): vscode.Uri | undefined { - if (uri) { - return uri; - } - const activeDoc = vscode.window.activeTextEditor?.document; - if (activeDoc?.languageId === "silverscript") { - return activeDoc.uri; - } - return undefined; -} - -function expandActiveFileVariable(raw: string): string | undefined { - if (!raw.includes("${file}")) { - return raw; - } - const active = resolveActiveScriptUri(); - return active?.fsPath - ? raw.replaceAll("${file}", active.fsPath) - : undefined; -} - -function ensureTrustedWorkspace(feature: string): boolean { - if (vscode.workspace.isTrusted) { - return true; - } - - void vscode.window.showWarningMessage( - `SilverScript ${feature} requires a trusted workspace.`, - ); - return false; -} - -function resolveConfigScriptPath( - raw: string, - folder: vscode.WorkspaceFolder | undefined, -): string | undefined { - return resolveLaunchScriptPath( - raw, - folder, - resolveActiveScriptUri(), - ); -} - -function isBenignAdapterShutdownError( - error: Error, - exitCode: number | undefined, -): boolean { - const isReadError = error.message.trim().toLowerCase() === "read error"; - return isReadError && (exitCode === 0 || !debuggerTraceEnabled()); -} - -class SilverScriptDebugAdapterFactory - implements vscode.DebugAdapterDescriptorFactory -{ - constructor( - private readonly ctx: vscode.ExtensionContext, - private readonly out: vscode.OutputChannel, - ) {} - - async createDebugAdapterDescriptor(): Promise { - if (!ensureTrustedWorkspace("debugging")) { - throw new Error("SilverScript debugging requires a trusted workspace."); - } - - const { root, bin, source } = await ensureDebuggerAdapterBinary( - this.ctx, - this.out, - ); - if (debuggerTraceEnabled()) { - this.out.appendLine(`[debug] launching ${bin} [${source}]`); - } - return new vscode.DebugAdapterExecutable(bin, [], { - cwd: root, - }); - } -} - -class SilverScriptConfigProvider - implements vscode.DebugConfigurationProvider -{ - private makeDefaultLaunchConfig(): - | vscode.DebugConfiguration - | undefined { - const scriptUri = resolveActiveScriptUri(); - if (!scriptUri) { - return undefined; - } - return { - type: "silverscript", - request: "launch", - name: "SilverScript: Debug Contract", - scriptPath: scriptUri.fsPath, - stopOnEntry: true, - }; - } - - private async applyContractDefaults( - folder: vscode.WorkspaceFolder | undefined, - config: vscode.DebugConfiguration, - ): Promise { - if (typeof config.scriptPath !== "string" || !config.scriptPath.trim()) { - return; - } - - const resolvedScriptPath = resolveConfigScriptPath( - config.scriptPath, - folder, - ); - if (!resolvedScriptPath) { - throw new Error(`Unable to resolve scriptPath '${config.scriptPath}'.`); - } - - config.scriptPath = resolvedScriptPath; - const source = await fs.promises.readFile(config.scriptPath, "utf8"); - const model = parseContractModel(source); - - if (!isDebugArgInput(config.constructorArgs)) { - config.constructorArgs = defaultsObjectFromParams( - model.constructorParams, - ); - } - - if (!config.function && model.entrypoints.length > 0) { - config.function = model.entrypoints[0].name; - } - - if (!isDebugArgInput(config.args) && config.function) { - const entrypoint = model.entrypoints.find( - (item) => item.name === config.function, - ); - if (entrypoint) { - config.args = defaultsObjectFromParams( - entrypoint.params, - ); - } - } - } - async resolveDebugConfiguration( - _folder: vscode.WorkspaceFolder | undefined, - config: vscode.DebugConfiguration, - ): Promise { - if (!ensureTrustedWorkspace("debugging")) { - return null; - } - - if (!config.type && !config.request) { - const defaultConfig = this.makeDefaultLaunchConfig(); - if (!defaultConfig) { - return undefined; - } - config = defaultConfig; - } - - if ( - config.type !== "silverscript" || - config.request !== "launch" - ) { - return config; - } - - if (typeof config.scriptPath === "string") { - const expanded = expandActiveFileVariable(config.scriptPath); - if (expanded === undefined) { - vscode.window.showErrorMessage( - "No active file to resolve ${file}.", - ); - return null; - } - config.scriptPath = expanded; - } - - if (!config.scriptPath) { - const active = resolveActiveScriptUri(); - if (active) { - config.scriptPath = active.fsPath; - } - } - - try { - await this.applyContractDefaults(_folder, config); - } catch (error) { - vscode.window.showErrorMessage( - `SilverScript debug configuration failed: ${(error as Error).message}`, - ); - return null; - } - - config.noDebug ??= false; - config.stopOnEntry ??= !config.noDebug; - return config; - } -} - -export function registerSilverScriptDebugger( - ctx: vscode.ExtensionContext, - out: vscode.OutputChannel, -): void { - const configProvider = new SilverScriptConfigProvider(); - - ctx.subscriptions.push( - vscode.debug.registerDebugAdapterDescriptorFactory( - "silverscript", - new SilverScriptDebugAdapterFactory(ctx, out), - ), - ); - ctx.subscriptions.push( - vscode.debug.registerDebugConfigurationProvider( - "silverscript", - configProvider, - ), - ); - ctx.subscriptions.push( - vscode.debug.registerDebugAdapterTrackerFactory( - "silverscript", - { - createDebugAdapterTracker: () => { - let exitCode: number | undefined; - - return { - onWillStartSession: () => { - if (debuggerTraceEnabled()) { - out.appendLine("[debug] session starting"); - } - }, - onError: (error: Error) => { - if (isBenignAdapterShutdownError(error, exitCode)) { - return; - } - out.appendLine(`[debug] error: ${error}`); - }, - onExit: ( - code: number | undefined, - signal: string | undefined, - ) => { - exitCode = code; - if (code === 0 && !signal && !debuggerTraceEnabled()) { - return; - } - out.appendLine( - `[debug] exit: code=${code}, signal=${signal}`, - ); - }, - }; - }, - }, - ), - ); -} diff --git a/extensions/vscode/src/debugAdapter.ts b/extensions/vscode/src/debugAdapter.ts deleted file mode 100644 index 0f460f5e..00000000 --- a/extensions/vscode/src/debugAdapter.ts +++ /dev/null @@ -1,352 +0,0 @@ -import * as childProcess from "child_process"; -import * as fs from "fs"; -import * as os from "os"; -import * as path from "path"; -import * as vscode from "vscode"; - -const autoBuildAttempted = new Set(); -const ADAPTER_BASENAME = - process.platform === "win32" ? "debugger-dap.exe" : "debugger-dap"; -const SUCCESS_MESSAGE = "Execution completed successfully."; - -export function debuggerTraceEnabled(): boolean { - return vscode.workspace - .getConfiguration("silverscript") - .get("debuggerTrace", false); -} - -function findWorkspaceRoot(): string | undefined { - const activeUri = vscode.window.activeTextEditor?.document.uri; - if (activeUri) { - const folder = vscode.workspace.getWorkspaceFolder(activeUri); - if (folder) { - return folder.uri.fsPath; - } - } - return vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; -} - -function hasDebuggerWorkspaceLayout(root: string): boolean { - return ( - fs.existsSync(path.join(root, "Cargo.toml")) && - fs.existsSync(path.join(root, "debugger", "dap", "Cargo.toml")) - ); -} - -function currentPlatformTarget(): string { - return `${process.platform}-${process.arch}`; -} - -function findExistingFile(candidates: string[]): string | undefined { - return candidates.find((candidate) => fs.existsSync(candidate)); -} - -function workspaceBinaryCandidates(root: string): string[] { - return ["release", "debug"].map((profile) => - path.join(root, "target", profile, ADAPTER_BASENAME), - ); -} - -function newestMtimeInPath(targetPath: string): number { - if (!fs.existsSync(targetPath)) { - return 0; - } - - const stat = fs.statSync(targetPath); - if (!stat.isDirectory()) { - return stat.mtimeMs; - } - - let newest = stat.mtimeMs; - for (const entry of fs.readdirSync(targetPath)) { - newest = Math.max( - newest, - newestMtimeInPath(path.join(targetPath, entry)), - ); - } - return newest; -} - -function newestDebuggerSourceMtime(root: string): number { - return Math.max( - newestMtimeInPath(path.join(root, "Cargo.toml")), - newestMtimeInPath(path.join(root, "debugger", "dap", "Cargo.toml")), - newestMtimeInPath(path.join(root, "debugger", "dap", "src")), - ); -} - -function workspaceBinaryNeedsBuild( - root: string, - binaryPath: string | undefined, -): boolean { - if (!binaryPath || !fs.existsSync(binaryPath)) { - return true; - } - - return fs.statSync(binaryPath).mtimeMs < newestDebuggerSourceMtime(root); -} - -function bundledBinaryCandidates( - ctx: vscode.ExtensionContext, -): string[] { - return [ - path.join( - ctx.extensionPath, - "bin", - currentPlatformTarget(), - ADAPTER_BASENAME, - ), - ]; -} - -function expandUserPath(raw: string): string { - if (raw === "~") { - return os.homedir(); - } - if (raw.startsWith("~/") || raw.startsWith("~\\")) { - return path.join(os.homedir(), raw.slice(2)); - } - return raw; -} - -function configuredAdapterCandidates( - ctx: vscode.ExtensionContext, -): string[] { - const configured = vscode.workspace - .getConfiguration("silverscript") - .get("debugAdapterPath", "") - .trim(); - - if (!configured) { - return []; - } - - const raw = expandUserPath(configured); - if (path.isAbsolute(raw)) { - return [raw]; - } - - const workspaceRoot = findWorkspaceRoot(); - const candidates = [ - workspaceRoot ? path.resolve(workspaceRoot, raw) : undefined, - path.resolve(ctx.extensionPath, raw), - ].filter((candidate): candidate is string => Boolean(candidate)); - - return [...new Set(candidates)]; -} - -export function resolveRepoRoot( - ctx: vscode.ExtensionContext, -): string { - const candidates: string[] = []; - const workspaceRoot = findWorkspaceRoot(); - if (workspaceRoot) { - candidates.push(workspaceRoot); - } - candidates.push(path.resolve(ctx.extensionPath, "..", "..")); - - for (const candidate of candidates) { - if (hasDebuggerWorkspaceLayout(candidate)) { - return candidate; - } - } - - return candidates[0] ?? path.resolve(ctx.extensionPath, "..", ".."); -} - -export function summarizeCommandFailure( - command: string, - args: string[], - result: { - stdout?: string; - stderr?: string; - error?: Error; - status?: number | null; - }, -): string { - const cmd = [command, ...args].join(" "); - const stdout = (result.stdout ?? "").trim(); - const stderr = (result.stderr ?? "").trim(); - const details = [stderr, stdout].filter(Boolean).slice(0, 2).join(" | "); - - if (result.error) { - return `${cmd} failed: ${result.error.message}`; - } - if (result.status !== 0) { - return `${cmd} exited with code ${result.status}${details ? `: ${details}` : ""}`; - } - return `${cmd} failed`; -} - -async function spawnCommand( - command: string, - args: string[], - cwd: string, -): Promise<{ stdout: string; stderr: string; status: number | null }> { - return new Promise((resolve, reject) => { - const child = childProcess.spawn(command, args, { - cwd, - stdio: ["ignore", "pipe", "pipe"], - }); - - let stdout = ""; - let stderr = ""; - - child.stdout?.on("data", (chunk: Buffer | string) => { - stdout += chunk.toString(); - }); - child.stderr?.on("data", (chunk: Buffer | string) => { - stderr += chunk.toString(); - }); - child.on("error", reject); - child.on("close", (status) => { - resolve({ stdout, stderr, status }); - }); - }); -} - -export async function ensureDebuggerAdapterBinary( - ctx: vscode.ExtensionContext, - out?: vscode.OutputChannel, -): Promise<{ root: string; bin: string; source: string }> { - const root = resolveRepoRoot(ctx); - const hasWorkspaceLayout = hasDebuggerWorkspaceLayout(root); - - const configuredCandidates = configuredAdapterCandidates(ctx); - if (configuredCandidates.length > 0) { - const configured = findExistingFile(configuredCandidates); - if (!configured) { - throw new Error( - `Configured debug adapter path not found. Checked: ${configuredCandidates.join(", ")}`, - ); - } - return { - root: path.dirname(configured), - bin: configured, - source: "configured", - }; - } - - const allowAutoBuild = vscode.workspace - .getConfiguration("silverscript") - .get("autoBuildDebuggerAdapter", true); - - const existingWorkspaceBinary = hasWorkspaceLayout - ? findExistingFile(workspaceBinaryCandidates(root)) - : undefined; - if ( - existingWorkspaceBinary && - !workspaceBinaryNeedsBuild(root, existingWorkspaceBinary) - ) { - return { - root, - bin: existingWorkspaceBinary, - source: "workspace", - }; - } - - if ( - hasWorkspaceLayout && - allowAutoBuild && - !autoBuildAttempted.has(root) - ) { - autoBuildAttempted.add(root); - const cmd = "cargo"; - const args = ["build", "-p", "debugger-dap"]; - const result = await vscode.window.withProgress({ - location: vscode.ProgressLocation.Notification, - title: "Building SilverScript debugger adapter", - cancellable: false, - }, async (progress) => { - progress.report({ - message: `${cmd} ${args.join(" ")}`, - }); - out?.appendLine( - `[debug] adapter missing or stale, running: ${cmd} ${args.join(" ")} (cwd=${root})`, - ); - return spawnCommand(cmd, args, root); - }); - if (result.stdout) { - out?.appendLine(result.stdout); - } - if (result.stderr) { - out?.appendLine(result.stderr); - } - if (result.status !== 0) { - throw new Error(summarizeCommandFailure(cmd, args, result)); - } - } - - const builtWorkspaceBinary = hasWorkspaceLayout - ? findExistingFile(workspaceBinaryCandidates(root)) - : undefined; - if (builtWorkspaceBinary) { - return { - root, - bin: builtWorkspaceBinary, - source: autoBuildAttempted.has(root) ? "workspace-built" : "workspace", - }; - } - - const bundled = findExistingFile(bundledBinaryCandidates(ctx)); - if (bundled) { - return { - root: path.dirname(bundled), - bin: bundled, - source: "bundled", - }; - } - - const target = currentPlatformTarget(); - const installMessage = - `No bundled SilverScript debug adapter was found for ${target}. ` + - "Package a platform-specific VSIX that includes bin//debugger-dap, " + - "or set `silverscript.debugAdapterPath` to a compatible binary."; - - if (!hasWorkspaceLayout) { - throw new Error(installMessage); - } - - if (!allowAutoBuild) { - throw new Error( - `${installMessage} Auto-build is disabled, so build it manually with: cargo build -p debugger-dap`, - ); - } - - throw new Error( - `${installMessage} Development fallback also failed. Run: cargo build -p debugger-dap`, - ); -} - -export async function runDebuggerAdapterCommand( - ctx: vscode.ExtensionContext, - args: string[], - out?: vscode.OutputChannel, -): Promise { - const { root, bin, source } = await ensureDebuggerAdapterBinary( - ctx, - out, - ); - const traceEnabled = debuggerTraceEnabled(); - if (traceEnabled) { - out?.appendLine(`[debug] running ${bin} ${args.join(" ")} [${source}]`); - } - - const result = await spawnCommand(bin, args, root); - if (result.stderr) { - out?.appendLine(result.stderr); - } - const stdout = (result.stdout ?? "").trim(); - if ( - result.stdout && - (result.status !== 0 || - traceEnabled || - stdout !== SUCCESS_MESSAGE) - ) { - out?.appendLine(result.stdout); - } - if (result.status !== 0) { - throw new Error(summarizeCommandFailure(bin, args, result)); - } - return stdout; -} diff --git a/extensions/vscode/src/extension.ts b/extensions/vscode/src/extension.ts index 2ce25c1f..a82a576c 100644 --- a/extensions/vscode/src/extension.ts +++ b/extensions/vscode/src/extension.ts @@ -3,9 +3,6 @@ import * as path from "path"; import * as fs from "fs/promises"; import { Language, Parser, Query } from "web-tree-sitter"; import type { QueryCapture } from "web-tree-sitter"; -import { registerSilverScriptCodeLens } from "./codeLens"; -import { registerSilverScriptDebugger } from "./debug"; -import { registerSilverScriptQuickLaunchPanel } from "./quickLaunch/panel"; const TOKEN_TYPES = [ "comment", @@ -29,7 +26,7 @@ const legend = new vscode.SemanticTokensLegend( [...TOKEN_MODIFIERS], ); -let logDebugEnabled = false; +const LOG_DEBUG = true; let outputChannel: vscode.OutputChannel | null = null; function logInfo(message: string) { @@ -40,7 +37,7 @@ function logInfo(message: string) { } function logDebug(message: string) { - if (!logDebugEnabled) { + if (!LOG_DEBUG) { return; } logInfo(message); @@ -369,35 +366,9 @@ class SilverScriptSemanticTokensProvider } export function activate(context: vscode.ExtensionContext) { - logDebugEnabled = - context.extensionMode !== vscode.ExtensionMode.Production; - outputChannel = vscode.window.createOutputChannel("SilverScript"); - const debugOutputChannel = vscode.window.createOutputChannel( - "SilverScript Debugger", - ); context.subscriptions.push(outputChannel); - context.subscriptions.push(debugOutputChannel); logInfo("SilverScript extension activated."); - logInfo( - `mode=${vscode.ExtensionMode[context.extensionMode]} id=${context.extension.id} path=${context.extensionPath}`, - ); - - const semanticEnabled = vscode.workspace - .getConfiguration("silverscript") - .get("enableSemanticTokens", true); - logInfo(`enableSemanticTokens=${semanticEnabled}`); - - const activeDoc = vscode.window.activeTextEditor?.document; - if (activeDoc) { - logInfo( - `activeDoc=${activeDoc.uri.fsPath} languageId=${activeDoc.languageId}`, - ); - } - - registerSilverScriptDebugger(context, debugOutputChannel); - registerSilverScriptQuickLaunchPanel(context, debugOutputChannel); - registerSilverScriptCodeLens(context); // TODO: add LSP (LanguageClient + LanguageServer) diff --git a/extensions/vscode/src/launchConfigs.ts b/extensions/vscode/src/launchConfigs.ts deleted file mode 100644 index 41f07f33..00000000 --- a/extensions/vscode/src/launchConfigs.ts +++ /dev/null @@ -1,254 +0,0 @@ -import * as path from "path"; -import * as vscode from "vscode"; - -export type RawLaunchConfiguration = vscode.DebugConfiguration & Record; - -export type SilverScriptLaunchConfigRecord = { - id: string; - folder: vscode.WorkspaceFolder; - index: number; - config: RawLaunchConfiguration; - scriptPathValue: string; - resolvedScriptPath: string; -}; - -export type SilverScriptSavedScenarioCounts = { - total: number; - byFunction: Record; -}; - -function normalizePath(fsPath: string): string { - const normalized = path.normalize(fsPath); - return process.platform === "win32" - ? normalized.toLowerCase() - : normalized; -} - -function expandPathVariables( - raw: string, - folder?: vscode.WorkspaceFolder, - activeScriptUri?: vscode.Uri, -): string | undefined { - let expanded = raw; - - if (folder) { - expanded = expanded.replaceAll("${workspaceFolder}", folder.uri.fsPath); - expanded = expanded.replaceAll( - "${workspaceFolderBasename}", - path.basename(folder.uri.fsPath), - ); - } - - if (activeScriptUri?.fsPath) { - expanded = expanded.replaceAll("${file}", activeScriptUri.fsPath); - } - - if (expanded.includes("${")) { - return undefined; - } - - return expanded; -} - -export function resolveLaunchScriptPath( - raw: string, - folder?: vscode.WorkspaceFolder, - activeScriptUri?: vscode.Uri, -): string | undefined { - const expanded = expandPathVariables(raw, folder, activeScriptUri); - if (!expanded) { - return undefined; - } - - const candidate = path.isAbsolute(expanded) - ? expanded - : folder - ? path.resolve(folder.uri.fsPath, expanded) - : path.resolve(expanded); - return path.normalize(candidate); -} - -export function launchConfigMatchesScript( - record: SilverScriptLaunchConfigRecord, - scriptUri: vscode.Uri, -): boolean { - return normalizePath(record.resolvedScriptPath) === normalizePath(scriptUri.fsPath); -} - -export function defaultLaunchScriptPathValue( - scriptUri: vscode.Uri, - folder: vscode.WorkspaceFolder, -): string { - const relative = path.relative(folder.uri.fsPath, scriptUri.fsPath); - if ( - !relative || - relative.startsWith("..") || - path.isAbsolute(relative) - ) { - return scriptUri.fsPath; - } - return relative; -} - -function readFolderLaunchConfigurations( - folder: vscode.WorkspaceFolder, -): RawLaunchConfiguration[] { - return vscode.workspace - .getConfiguration("launch", folder.uri) - .get("configurations", []); -} - -async function writeFolderLaunchConfigurations( - folder: vscode.WorkspaceFolder, - configs: RawLaunchConfiguration[], -): Promise { - await vscode.workspace - .getConfiguration("launch", folder.uri) - .update( - "configurations", - configs, - vscode.ConfigurationTarget.WorkspaceFolder, - ); -} - -export function listSilverScriptLaunchConfigs( - activeScriptUri?: vscode.Uri, -): SilverScriptLaunchConfigRecord[] { - const folders = vscode.workspace.workspaceFolders ?? []; - const records: SilverScriptLaunchConfigRecord[] = []; - - for (const folder of folders) { - const configs = readFolderLaunchConfigurations(folder); - configs.forEach((config, index) => { - if ( - config.type !== "silverscript" || - config.request !== "launch" || - typeof config.scriptPath !== "string" - ) { - return; - } - - const scriptPathValue = config.scriptPath.trim(); - if (!scriptPathValue) { - return; - } - - const resolvedScriptPath = resolveLaunchScriptPath( - scriptPathValue, - folder, - activeScriptUri, - ); - if (!resolvedScriptPath) { - return; - } - - records.push({ - id: `${folder.uri.toString()}::${index}`, - folder, - index, - config, - scriptPathValue, - resolvedScriptPath, - }); - }); - } - - return records; -} - -export function listMatchingSilverScriptLaunchConfigs( - scriptUri: vscode.Uri, -): SilverScriptLaunchConfigRecord[] { - return listSilverScriptLaunchConfigs(scriptUri).filter((record) => - launchConfigMatchesScript(record, scriptUri), - ); -} - -export function countSilverScriptSavedScenarios( - scriptUri: vscode.Uri, -): SilverScriptSavedScenarioCounts { - const records = listMatchingSilverScriptLaunchConfigs(scriptUri); - const byFunction: Record = {}; - - for (const record of records) { - const functionName = - typeof record.config.function === "string" - ? record.config.function.trim() - : ""; - if (!functionName) { - continue; - } - - byFunction[functionName] = (byFunction[functionName] ?? 0) + 1; - } - - return { - total: records.length, - byFunction, - }; -} - -export async function updateSilverScriptLaunchConfig( - record: SilverScriptLaunchConfigRecord, - nextConfig: RawLaunchConfiguration, -): Promise { - const configs = [...readFolderLaunchConfigurations(record.folder)]; - if (record.index >= configs.length) { - throw new Error(`Launch config '${record.config.name ?? record.id}' no longer exists.`); - } - - configs[record.index] = nextConfig; - await writeFolderLaunchConfigurations(record.folder, configs); - - return { - ...record, - config: nextConfig, - scriptPathValue: - typeof nextConfig.scriptPath === "string" - ? nextConfig.scriptPath - : record.scriptPathValue, - resolvedScriptPath: resolveLaunchScriptPath( - String(nextConfig.scriptPath ?? record.scriptPathValue), - record.folder, - ) ?? record.resolvedScriptPath, - }; -} - -export async function createSilverScriptLaunchConfig( - folder: vscode.WorkspaceFolder, - config: RawLaunchConfiguration, -): Promise { - const configs = [...readFolderLaunchConfigurations(folder), config]; - const index = configs.length - 1; - await writeFolderLaunchConfigurations(folder, configs); - - const scriptPathValue = String(config.scriptPath ?? "").trim(); - const resolvedScriptPath = resolveLaunchScriptPath( - scriptPathValue, - folder, - ); - if (!resolvedScriptPath) { - throw new Error(`Unable to resolve scriptPath '${scriptPathValue}'.`); - } - - return { - id: `${folder.uri.toString()}::${index}`, - folder, - index, - config, - scriptPathValue, - resolvedScriptPath, - }; -} - -export async function deleteSilverScriptLaunchConfig( - record: SilverScriptLaunchConfigRecord, -): Promise { - const configs = [...readFolderLaunchConfigurations(record.folder)]; - if (record.index >= configs.length) { - throw new Error(`Launch config '${record.config.name ?? record.id}' no longer exists.`); - } - - configs.splice(record.index, 1); - await writeFolderLaunchConfigurations(record.folder, configs); -} diff --git a/extensions/vscode/src/quickLaunch/panel.ts b/extensions/vscode/src/quickLaunch/panel.ts deleted file mode 100644 index 6afe6d2c..00000000 --- a/extensions/vscode/src/quickLaunch/panel.ts +++ /dev/null @@ -1,1023 +0,0 @@ -import * as fs from "fs"; -import * as path from "path"; -import * as vscode from "vscode"; -import { - defaultForType, - type DebugArgInput, - type DebugArgObject, - parseContractModel, - type ContractModel, - type ContractParam, -} from "../contractModel"; -import { runDebuggerAdapterCommand } from "../debugAdapter"; -import { - countSilverScriptSavedScenarios, - createSilverScriptLaunchConfig, - defaultLaunchScriptPathValue, - listMatchingSilverScriptLaunchConfigs, - type RawLaunchConfiguration, - type SilverScriptLaunchConfigRecord, - updateSilverScriptLaunchConfig, -} from "../launchConfigs"; -import { - buildQuickLaunchHtml, - quickLaunchWebviewRoot, - type QuickLaunchWebviewState, -} from "./view"; - -type LaunchKind = "run" | "debug"; -type IdentityLabels = Record; - -type PanelFormState = { - function: string; - constructorArgs: Record; - argsByFunction: Record>; - keyAliases: string[]; - identityLabels: IdentityLabels; -}; - -type PanelHostState = { - scriptUri: vscode.Uri; - model: ContractModel; - form: PanelFormState; - baseConfig: RawLaunchConfiguration; - record?: SilverScriptLaunchConfigRecord; -}; - -type PanelMessage = - | { kind: "run"; form: PanelFormState } - | { kind: "debug"; form: PanelFormState } - | { kind: "loadSaved"; form: PanelFormState } - | { kind: "saveSaved"; form: PanelFormState }; - -type PanelControlMessage = { - kind: "triggerLaunch"; - launchKind: LaunchKind; -}; - -const RUN_SUCCESS_MESSAGE = "Execution completed successfully."; - -let panel: vscode.WebviewPanel | undefined; -let activeState: PanelHostState | undefined; -let launchInProgress = false; -let restoringPrimaryEditor = false; -let extensionContext: vscode.ExtensionContext | undefined; -const panelStateEmitter = new vscode.EventEmitter(); -const IDENTITY_ALIAS_RE = - /^(?:keypair|identity)([1-9]\d*)(?:\.(pubkey|secret|pkh))?$/; - -function emitPanelStateChanged(): void { - panelStateEmitter.fire(); -} - -export const onDidChangeSilverScriptPanelState = - panelStateEmitter.event; - -function isDebugArgInput(value: unknown): value is DebugArgInput { - return ( - Array.isArray(value) || - (value !== null && typeof value === "object") - ); -} - -function activeSilverScriptSession(): vscode.DebugSession | undefined { - const session = vscode.debug.activeDebugSession; - return session?.type === "silverscript" ? session : undefined; -} - -export function hasOpenSilverScriptPanelForUri( - uri?: vscode.Uri, -): boolean { - if (!panel || !activeState || !uri) { - return false; - } - - return activeState.scriptUri.fsPath === uri.fsPath; -} - -function resolveActiveScriptUri(uri?: vscode.Uri): vscode.Uri | undefined { - if (uri) { - return uri; - } - const activeDoc = vscode.window.activeTextEditor?.document; - if (activeDoc?.languageId === "silverscript") { - return activeDoc.uri; - } - return undefined; -} - -function ensureTrustedWorkspace(): boolean { - if (vscode.workspace.isTrusted) { - return true; - } - - void vscode.window.showWarningMessage( - "SilverScript run/debug requires a trusted workspace.", - ); - return false; -} - -function stringifyLaunchArg(value: unknown): string { - if (typeof value === "string") { - return value; - } - if ( - Array.isArray(value) || - (value !== null && typeof value === "object") - ) { - return JSON.stringify(value); - } - return String(value); -} - -function defaultsForParams( - params: ContractParam[], -): Record { - return Object.fromEntries( - params.map((param) => [ - param.name, - stringifyLaunchArg(defaultForType(param.type)), - ]), - ); -} - -function valuesForParams( - params: ContractParam[], - input: DebugArgInput | undefined, -): Record { - const defaults = defaultsForParams(params); - if (Array.isArray(input)) { - for (const [index, param] of params.entries()) { - if (index < input.length) { - defaults[param.name] = stringifyLaunchArg(input[index]); - } - } - return defaults; - } - if (input && typeof input === "object") { - for (const param of params) { - if (Object.prototype.hasOwnProperty.call(input, param.name)) { - defaults[param.name] = stringifyLaunchArg(input[param.name]); - } - } - } - return defaults; -} - -function defaultLaunchName( - model: ContractModel, - scriptPath: string, -): string { - return model.name && model.name !== "Unknown" - ? `SilverScript: ${model.name}` - : `SilverScript: ${path.basename(scriptPath)}`; -} - -function resolvedLaunchName( - state: PanelHostState, -): string { - return typeof state.baseConfig.name === "string" && - state.baseConfig.name.trim() - ? state.baseConfig.name - : defaultLaunchName(state.model, state.scriptUri.fsPath); -} - -function normalizeKeyAliases( - aliases: readonly string[], - constructorArgs: Record, - argsByFunction: Record>, -): string[] { - const found = new Map(); - - const consider = (raw: string | undefined) => { - if (!raw) { - return; - } - const match = IDENTITY_ALIAS_RE.exec(raw.trim()); - if (!match) { - return; - } - const index = Number(match[1]); - if (!found.has(index)) { - found.set(index, `keypair${index}`); - } - }; - - aliases.forEach(consider); - Object.values(constructorArgs).forEach((value) => consider(value)); - Object.values(argsByFunction).forEach((args) => { - Object.values(args).forEach((value) => consider(value)); - }); - - const normalized = [...found.entries()] - .sort((left, right) => left[0] - right[0]) - .map(([, alias]) => alias); - return normalized; -} - -function normalizeIdentityLabels( - aliases: readonly string[], - labels: IdentityLabels, -): IdentityLabels { - const normalized: IdentityLabels = {}; - for (const alias of aliases) { - const label = labels[alias]?.trim(); - if (label && label !== alias) { - normalized[alias] = label; - } - } - return normalized; -} - -async function focusPrimaryEditor( - scriptUri: vscode.Uri, -): Promise { - if (restoringPrimaryEditor) { - return; - } - - restoringPrimaryEditor = true; - try { - const document = await vscode.workspace.openTextDocument(scriptUri); - await vscode.window.showTextDocument(document, { - viewColumn: vscode.ViewColumn.One, - preview: false, - preserveFocus: false, - }); - } finally { - restoringPrimaryEditor = false; - } -} - -async function keepSilverScriptEditorOnPrimary( - editor: vscode.TextEditor | undefined, -): Promise { - if ( - restoringPrimaryEditor || - !panel || - !editor || - editor.document.languageId !== "silverscript" || - editor.viewColumn === vscode.ViewColumn.One || - panel.viewColumn === undefined || - editor.viewColumn !== panel.viewColumn - ) { - return; - } - - await focusPrimaryEditor(editor.document.uri); - if (panel) { - panel.reveal(vscode.ViewColumn.Beside, true); - } -} - -async function followActiveSilverScript( - editor: vscode.TextEditor | undefined, -): Promise { - if ( - !panel || - !editor || - editor.document.languageId !== "silverscript" || - !activeState || - activeState.scriptUri.fsPath === editor.document.uri.fsPath - ) { - return; - } - - activeState = await buildInitialState(editor.document.uri); - emitPanelStateChanged(); - await renderActiveState(); -} - -async function handleActiveEditorChange( - editor: vscode.TextEditor | undefined, -): Promise { - await keepSilverScriptEditorOnPrimary(editor); - await followActiveSilverScript(editor); -} - -function defaultPanelFormState( - model: ContractModel, - initialFunction?: string, -): PanelFormState { - const constructorDefaults = defaultsForParams( - model.constructorParams, - ); - const selectedFunction = - initialFunction && - model.entrypoints.some((entry) => entry.name === initialFunction) - ? initialFunction - : model.entrypoints[0]?.name ?? ""; - - const argsByFunction: Record> = {}; - for (const entrypoint of model.entrypoints) { - argsByFunction[entrypoint.name] = defaultsForParams( - entrypoint.params, - ); - } - - return { - function: selectedFunction, - constructorArgs: constructorDefaults, - argsByFunction, - keyAliases: normalizeKeyAliases( - [], - constructorDefaults, - argsByFunction, - ), - identityLabels: {}, - }; -} - -function formFromLaunchConfig( - model: ContractModel, - config: RawLaunchConfiguration, - initialFunction: string | undefined, - keyAliases: string[], - identityLabels: IdentityLabels, -): PanelFormState { - const configuredFunction = - typeof config.function === "string" ? config.function : undefined; - const selectedFunction = - initialFunction && - model.entrypoints.some((entry) => entry.name === initialFunction) - ? initialFunction - : configuredFunction && - model.entrypoints.some( - (entry) => entry.name === configuredFunction, - ) - ? configuredFunction - : model.entrypoints[0]?.name ?? ""; - - const constructorArgs = isDebugArgInput(config.constructorArgs) - ? config.constructorArgs - : undefined; - const configuredArgs = - configuredFunction === selectedFunction && - isDebugArgInput(config.args) - ? config.args - : undefined; - - const argsByFunction: Record> = {}; - for (const entrypoint of model.entrypoints) { - argsByFunction[entrypoint.name] = valuesForParams( - entrypoint.params, - entrypoint.name === selectedFunction ? configuredArgs : undefined, - ); - } - - const constructorValues = valuesForParams( - model.constructorParams, - constructorArgs, - ); - const normalizedAliases = normalizeKeyAliases( - keyAliases, - constructorValues, - argsByFunction, - ); - const form = { - function: selectedFunction, - constructorArgs: constructorValues, - argsByFunction, - keyAliases: normalizedAliases, - identityLabels: normalizeIdentityLabels( - normalizedAliases, - identityLabels, - ), - }; - return form; -} - -function currentArgs( - form: PanelFormState, -): DebugArgObject { - return { ...(form.argsByFunction[form.function] ?? {}) }; -} - -function cloneArgsByFunction( - argsByFunction: Record>, -): Record> { - return Object.fromEntries( - Object.entries(argsByFunction).map( - ([entrypoint, args]) => [entrypoint, { ...args }], - ), - ); -} - -function applyMessageState( - state: PanelHostState, - form: PanelFormState, -): void { - const normalizedAliases = normalizeKeyAliases( - form.keyAliases, - form.constructorArgs, - form.argsByFunction, - ); - state.form = { - function: form.function, - constructorArgs: { ...form.constructorArgs }, - argsByFunction: cloneArgsByFunction(form.argsByFunction), - keyAliases: normalizedAliases, - identityLabels: normalizeIdentityLabels( - normalizedAliases, - form.identityLabels, - ), - }; -} - -function launchConfigLabel( - record: SilverScriptLaunchConfigRecord, -): string { - return typeof record.config.name === "string" - ? record.config.name - : path.basename(record.resolvedScriptPath); -} - -type SavedScenarioPickItem = vscode.QuickPickItem & { - record?: SilverScriptLaunchConfigRecord; -}; - -function buildSavedScenarioPickItems( - model: ContractModel, - records: SilverScriptLaunchConfigRecord[], - functionName?: string, -): SavedScenarioPickItem[] { - if (functionName) { - return records.map((record) => ({ - label: launchConfigLabel(record), - description: record.scriptPathValue, - record, - })); - } - - const groups = new Map(); - for (const record of records) { - const group = - typeof record.config.function === "string" && - record.config.function.trim() - ? record.config.function.trim() - : "Other"; - const existing = groups.get(group) ?? []; - existing.push(record); - groups.set(group, existing); - } - - const orderedGroups: string[] = []; - for (const entrypoint of model.entrypoints) { - if (groups.has(entrypoint.name)) { - orderedGroups.push(entrypoint.name); - } - } - for (const group of [...groups.keys()].sort()) { - if (!orderedGroups.includes(group)) { - orderedGroups.push(group); - } - } - - return orderedGroups.flatMap((group) => { - const groupRecords = groups.get(group) ?? []; - return [ - { - kind: vscode.QuickPickItemKind.Separator, - label: group, - }, - ...groupRecords.map((record) => ({ - label: launchConfigLabel(record), - description: record.scriptPathValue, - record, - })), - ]; - }); -} - -async function readModel( - scriptUri: vscode.Uri, -): Promise { - const source = await fs.promises.readFile(scriptUri.fsPath, "utf8"); - return parseContractModel(source); -} - -async function buildInitialState( - scriptUri: vscode.Uri, - initialFunction?: string, - keyAliases: string[] = [], - identityLabels: IdentityLabels = {}, -): Promise { - const model = await readModel(scriptUri); - const record = listMatchingSilverScriptLaunchConfigs(scriptUri)[0]; - - if (record) { - return { - scriptUri, - model, - form: formFromLaunchConfig( - model, - record.config, - initialFunction, - keyAliases, - identityLabels, - ), - baseConfig: { ...record.config }, - record, - }; - } - - return { - scriptUri, - model, - form: defaultPanelFormState(model, initialFunction), - baseConfig: { - type: "silverscript", - request: "launch", - name: defaultLaunchName(model, scriptUri.fsPath), - stopOnEntry: true, - }, - }; -} - -function launchConfigForPanel( - state: PanelHostState, - noDebug: boolean, -): RawLaunchConfiguration { - return { - ...state.baseConfig, - type: "silverscript", - request: "launch", - name: resolvedLaunchName(state), - scriptPath: state.scriptUri.fsPath, - function: state.form.function, - constructorArgs: { ...state.form.constructorArgs }, - args: currentArgs(state.form), - noDebug, - stopOnEntry: !noDebug, - }; -} - -function savedLaunchConfigForPanel( - state: PanelHostState, - name: string, -): RawLaunchConfiguration { - const folder = vscode.workspace.getWorkspaceFolder(state.scriptUri); - if (!folder) { - throw new Error( - "SilverScript launch configs require the script to be inside a workspace folder.", - ); - } - - const config: RawLaunchConfiguration = { - ...state.baseConfig, - type: "silverscript", - request: "launch", - name, - scriptPath: defaultLaunchScriptPathValue(state.scriptUri, folder), - function: state.form.function, - constructorArgs: { ...state.form.constructorArgs }, - args: currentArgs(state.form), - }; - delete config.paramsFile; - delete config.noDebug; - return config; -} - -function buildWebviewState( - state: PanelHostState, -): QuickLaunchWebviewState { - const savedCounts = countSilverScriptSavedScenarios( - state.scriptUri, - ); - return { - function: state.form.function, - constructorArgs: { ...state.form.constructorArgs }, - argsByFunction: cloneArgsByFunction(state.form.argsByFunction), - keyAliases: [...state.form.keyAliases], - identityLabels: { ...state.form.identityLabels }, - savedCountsByFunction: savedCounts.byFunction, - savedTotalCount: savedCounts.total, - }; -} - -async function loadSavedScenario( - keyAliases: string[], - identityLabels: IdentityLabels, - functionName?: string, - suppressEmptyMessage = false, -): Promise { - if (!activeState) { - return; - } - - const records = listMatchingSilverScriptLaunchConfigs( - activeState.scriptUri, - ).filter( - (record) => { - if (!functionName) { - return true; - } - return record.config.function === functionName; - }, - ); - if (records.length === 0) { - if (!suppressEmptyMessage) { - void vscode.window.showInformationMessage( - functionName - ? `No saved SilverScript launch configs were found for '${functionName}'.` - : "No saved SilverScript launch configs were found for this file.", - ); - } - return; - } - - const picked = await vscode.window.showQuickPick( - buildSavedScenarioPickItems( - activeState.model, - records, - functionName, - ), - { - title: functionName - ? `Load Saved Scenario for '${functionName}'` - : "Load SilverScript Launch Config", - placeHolder: functionName - ? `Select a saved launch config for '${functionName}'` - : "Select a saved launch config for this contract, grouped by entrypoint", - }, - ); - - if (!picked?.record) { - return; - } - - const model = await readModel(activeState.scriptUri); - activeState = { - scriptUri: activeState.scriptUri, - model, - form: formFromLaunchConfig( - model, - picked.record.config, - undefined, - keyAliases, - identityLabels, - ), - baseConfig: { ...picked.record.config }, - record: picked.record, - }; - await renderActiveState(); -} - -function selectEntrypoint( - state: PanelHostState, - initialFunction?: string, -): void { - if (!initialFunction) { - return; - } - - const entrypoint = state.model.entrypoints.find( - (item) => item.name === initialFunction, - ); - if (!entrypoint) { - return; - } - - state.form.function = initialFunction; - if (!state.form.argsByFunction[initialFunction]) { - state.form.argsByFunction[initialFunction] = defaultsForParams( - entrypoint.params, - ); - } -} - -async function saveScenario(): Promise { - if (!activeState) { - return; - } - - const folder = vscode.workspace.getWorkspaceFolder(activeState.scriptUri); - if (!folder) { - void vscode.window.showErrorMessage( - "SilverScript launch configs require the script to be inside a workspace folder.", - ); - return; - } - - if (activeState.record) { - const name = resolvedLaunchName(activeState); - const config = savedLaunchConfigForPanel(activeState, name); - const updated = await updateSilverScriptLaunchConfig( - activeState.record, - config, - ); - activeState.baseConfig = config; - activeState.record = updated; - await renderActiveState(); - void vscode.window.showInformationMessage( - `Updated '${name}' in launch.json.`, - ); - return; - } - - const name = await vscode.window.showInputBox({ - title: "Save SilverScript Launch Config", - prompt: "Name for this saved debugger scenario", - value: defaultLaunchName( - activeState.model, - activeState.scriptUri.fsPath, - ), - ignoreFocusOut: true, - validateInput: (value) => - value.trim() ? null : "Name is required.", - }); - - if (!name) { - return; - } - - const config = savedLaunchConfigForPanel(activeState, name.trim()); - const record = await createSilverScriptLaunchConfig(folder, config); - activeState.baseConfig = config; - activeState.record = record; - await renderActiveState(); - void vscode.window.showInformationMessage( - `Saved '${name.trim()}' to launch.json.`, - ); -} - -async function launchFromPanel( - context: vscode.ExtensionContext, - out: vscode.OutputChannel, - kind: LaunchKind, -): Promise { - if (!activeState) { - return; - } - - if (launchInProgress) { - void vscode.window.showWarningMessage( - "A SilverScript run/debug launch is already in progress.", - ); - return; - } - - const existingSession = activeSilverScriptSession(); - if (existingSession) { - await vscode.commands.executeCommand( - "workbench.action.debug.continue", - ); - return; - } - - try { - launchInProgress = true; - const config = launchConfigForPanel(activeState, kind === "run"); - const folder = - vscode.workspace.getWorkspaceFolder(activeState.scriptUri) ?? - vscode.workspace.workspaceFolders?.[0]; - - if (kind === "run") { - const output = await runDebuggerAdapterCommand( - context, - ["--run-config-json", JSON.stringify(config)], - out, - ); - if (output && output !== RUN_SUCCESS_MESSAGE) { - out.show(true); - } - void vscode.window.showInformationMessage( - RUN_SUCCESS_MESSAGE, - ); - return; - } - - await vscode.debug.startDebugging(folder, config, { - noDebug: false, - }); - } catch (error) { - out.show(true); - void vscode.window.showErrorMessage( - `SilverScript ${kind} failed: ${(error as Error).message}`, - ); - } finally { - launchInProgress = false; - } -} - -async function renderActiveState(): Promise { - if (!panel || !activeState) { - return; - } - if (!extensionContext) { - throw new Error("SilverScript quick launch panel is not initialized."); - } - - panel.title = activeState.model.name; - panel.webview.html = await buildQuickLaunchHtml( - extensionContext, - panel.webview, - activeState.model, - buildWebviewState(activeState), - ); -} - -async function openPanel( - context: vscode.ExtensionContext, - out: vscode.OutputChannel, - uri?: vscode.Uri, - initialFunction?: string, -): Promise { - if (!ensureTrustedWorkspace()) { - return; - } - - const scriptUri = resolveActiveScriptUri(uri); - if (!scriptUri) { - vscode.window.showErrorMessage("Open a .sil file first."); - return; - } - - if ( - panel && - activeState && - activeState.scriptUri.fsPath === scriptUri.fsPath - ) { - selectEntrypoint(activeState, initialFunction); - await renderActiveState(); - panel.reveal(vscode.ViewColumn.Beside, true); - await focusPrimaryEditor(scriptUri); - return; - } - - activeState = await buildInitialState(scriptUri, initialFunction); - emitPanelStateChanged(); - - if (!panel) { - panel = vscode.window.createWebviewPanel( - "silverscriptRunner", - activeState.model.name, - vscode.ViewColumn.Beside, - { - enableScripts: true, - localResourceRoots: [quickLaunchWebviewRoot(context)], - retainContextWhenHidden: true, - }, - ); - - panel.webview.onDidReceiveMessage( - async (message: PanelMessage) => { - if (!activeState) { - return; - } - - applyMessageState(activeState, message.form); - - switch (message.kind) { - case "loadSaved": - await loadSavedScenario( - message.form.keyAliases, - message.form.identityLabels, - ); - return; - case "saveSaved": - await saveScenario(); - return; - case "run": - await launchFromPanel(context, out, "run"); - return; - case "debug": - await launchFromPanel(context, out, "debug"); - return; - default: - return; - } - }, - undefined, - [], - ); - - panel.onDidDispose(() => { - panel = undefined; - activeState = undefined; - launchInProgress = false; - emitPanelStateChanged(); - }); - } else { - panel.reveal(vscode.ViewColumn.Beside, true); - } - - await renderActiveState(); - await focusPrimaryEditor(scriptUri); -} - -async function showSavedScenarios( - context: vscode.ExtensionContext, - out: vscode.OutputChannel, - uri?: vscode.Uri, - initialFunction?: string, - showPicker = true, -): Promise { - await openPanel(context, out, uri, initialFunction); - if (!showPicker || !activeState) { - return; - } - - await loadSavedScenario( - activeState.form.keyAliases, - activeState.form.identityLabels, - initialFunction, - true, - ); -} - -async function handlePrimaryCodeLensAction( - context: vscode.ExtensionContext, - out: vscode.OutputChannel, - uri?: vscode.Uri, -): Promise { - const scriptUri = resolveActiveScriptUri(uri); - if (scriptUri && hasOpenSilverScriptPanelForUri(scriptUri)) { - await triggerPanelLaunch("run"); - return; - } - - await openPanel(context, out, uri); -} - -async function triggerPanelLaunch( - launchKind: LaunchKind, -): Promise { - if (!panel) { - return; - } - - await panel.webview.postMessage({ - kind: "triggerLaunch", - launchKind, - } satisfies PanelControlMessage); -} - -async function handlePanelF5( - context: vscode.ExtensionContext, - out: vscode.OutputChannel, - uri?: vscode.Uri, - initialFunction?: string, -): Promise { - const scriptUri = resolveActiveScriptUri(uri); - if ( - panel && - activeState && - (!scriptUri || activeState.scriptUri.fsPath === scriptUri.fsPath) - ) { - await triggerPanelLaunch("debug"); - return; - } - - await openPanel(context, out, uri, initialFunction); -} - -export function registerSilverScriptQuickLaunchPanel( - context: vscode.ExtensionContext, - out: vscode.OutputChannel, -): void { - extensionContext = context; - context.subscriptions.push( - vscode.commands.registerCommand( - "silverscript.debug.configureLaunch", - (uri?: vscode.Uri, initialFunction?: string) => - openPanel(context, out, uri, initialFunction), - ), - ); - context.subscriptions.push( - vscode.commands.registerCommand( - "silverscript.debug.f5", - (uri?: vscode.Uri, initialFunction?: string) => - handlePanelF5(context, out, uri, initialFunction), - ), - ); - context.subscriptions.push( - vscode.commands.registerCommand( - "silverscript.debug.primaryCodeLensAction", - (uri?: vscode.Uri) => - handlePrimaryCodeLensAction(context, out, uri), - ), - ); - context.subscriptions.push( - vscode.commands.registerCommand( - "silverscript.debug.showSavedScenarios", - ( - uri?: vscode.Uri, - initialFunction?: string, - showPicker?: boolean, - ) => - showSavedScenarios( - context, - out, - uri, - initialFunction, - showPicker ?? true, - ), - ), - ); - context.subscriptions.push( - vscode.window.onDidChangeActiveTextEditor((editor) => { - void handleActiveEditorChange(editor); - }), - ); -} diff --git a/extensions/vscode/src/quickLaunch/view.ts b/extensions/vscode/src/quickLaunch/view.ts deleted file mode 100644 index 0bf85c20..00000000 --- a/extensions/vscode/src/quickLaunch/view.ts +++ /dev/null @@ -1,88 +0,0 @@ -import * as fs from "fs/promises"; -import * as path from "path"; -import * as vscode from "vscode"; -import type { ContractModel } from "../contractModel"; - -export type QuickLaunchWebviewState = { - function: string; - constructorArgs: Record; - argsByFunction: Record>; - keyAliases: string[]; - identityLabels: Record; - savedCountsByFunction: Record; - savedTotalCount: number; -}; - -const QUICK_LAUNCH_TEMPLATE = ["webviews", "quickLaunch", "panel.html"]; -const QUICK_LAUNCH_SCRIPT = ["webviews", "quickLaunch", "panel.js"]; -const QUICK_LAUNCH_STYLE = ["webviews", "quickLaunch", "panel.css"]; - -let quickLaunchTemplatePromise: Promise | undefined; - -function webviewAssetUri( - context: vscode.ExtensionContext, - ...segments: string[] -): vscode.Uri { - return vscode.Uri.joinPath(context.extensionUri, ...segments); -} - -function escapeHtml(value: string): string { - return value - .replace(/&/g, "&") - .replace(/"/g, """) - .replace(//g, ">"); -} - -function stringifyForHtml(value: unknown): string { - return JSON.stringify(value) - .replace(//g, "\\u003e") - .replace(/&/g, "\\u0026") - .replace(/\u2028/g, "\\u2028") - .replace(/\u2029/g, "\\u2029"); -} - -async function loadTemplate( - context: vscode.ExtensionContext, -): Promise { - if (!quickLaunchTemplatePromise) { - quickLaunchTemplatePromise = fs.readFile( - path.join(context.extensionPath, ...QUICK_LAUNCH_TEMPLATE), - "utf8", - ); - } - return quickLaunchTemplatePromise; -} - -export function quickLaunchWebviewRoot( - context: vscode.ExtensionContext, -): vscode.Uri { - return webviewAssetUri(context, "webviews", "quickLaunch"); -} - -export async function buildQuickLaunchHtml( - context: vscode.ExtensionContext, - webview: vscode.Webview, - model: ContractModel, - initialState: QuickLaunchWebviewState, -): Promise { - const template = await loadTemplate(context); - const replacements = { - "{{CSP_SOURCE}}": webview.cspSource, - "{{STYLE_URI}}": webview - .asWebviewUri(webviewAssetUri(context, ...QUICK_LAUNCH_STYLE)) - .toString(), - "{{SCRIPT_URI}}": webview - .asWebviewUri(webviewAssetUri(context, ...QUICK_LAUNCH_SCRIPT)) - .toString(), - "{{TITLE}}": escapeHtml(model.name), - "{{MODEL_JSON}}": stringifyForHtml(model), - "{{STATE_JSON}}": stringifyForHtml(initialState), - } as const; - - return Object.entries(replacements).reduce( - (html, [needle, value]) => html.replaceAll(needle, value), - template, - ); -} diff --git a/extensions/vscode/webviews/quickLaunch/panel.css b/extensions/vscode/webviews/quickLaunch/panel.css deleted file mode 100644 index c045da0e..00000000 --- a/extensions/vscode/webviews/quickLaunch/panel.css +++ /dev/null @@ -1,262 +0,0 @@ -:root { - --bg: var(--vscode-editor-background); - --fg: var(--vscode-editor-foreground); - --input-bg: var(--vscode-input-background); - --input-fg: var(--vscode-input-foreground); - --input-border: var(--vscode-input-border, transparent); - --panel-bg: rgba(127, 127, 127, 0.03); - --panel-hover: var(--vscode-toolbar-hoverBackground, rgba(128, 128, 128, 0.1)); - --btn: var(--vscode-button-background); - --btn-fg: var(--vscode-button-foreground); - --btn-hover: var(--vscode-button-hoverBackground); - --btn-secondary-bg: transparent; - --btn-secondary-fg: var(--fg); - --btn-secondary-hover: var(--panel-hover); - --focus: var(--vscode-focusBorder); - --muted: rgba(127, 127, 127, 0.75); - --sep: var(--vscode-widget-border, rgba(128, 128, 128, 0.25)); - --badge: var(--vscode-badge-background); - --badge-fg: var(--vscode-badge-foreground); -} - -* { - box-sizing: border-box; -} - -body { - margin: 0; - padding: 16px; - color: var(--fg); - background: var(--bg); - font: 13px/1.45 var(--vscode-font-family, system-ui, sans-serif); -} - -.header-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 10px; - margin: 0 0 14px; -} - -h1 { - flex: 1; - min-width: 0; - margin: 0; - font-size: 16px; - font-weight: 600; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.topbar { - display: flex; - align-items: center; - flex: none; -} - -.topbar-actions { - display: flex; - gap: 6px; -} - -section { - margin-bottom: 14px; -} - -h2 { - margin: 0 0 8px; - font-size: 11px; - font-weight: 700; - letter-spacing: 0.06em; - text-transform: uppercase; - color: var(--muted); -} - -label { - display: block; - margin: 10px 0 4px; - font-size: 12px; -} - -.meta { - color: var(--muted); - font-size: 11px; - margin-left: 6px; -} - -.badge { - margin-left: 6px; - padding: 1px 5px; - border-radius: 3px; - background: var(--badge); - color: var(--badge-fg); - font-size: 10px; - font-weight: 600; - vertical-align: middle; -} - -input, -select { - width: 100%; - min-height: 34px; - padding: 6px 10px; - border-radius: 4px; - border: 1px solid var(--input-border); - background: var(--input-bg); - color: var(--input-fg); - outline: none; - font: 13px var(--vscode-editor-font-family, monospace); -} - -input:focus, -select:focus { - border-color: var(--focus); -} - -.empty { - margin: 0; - color: var(--muted); - font-style: italic; -} - -.actions { - display: flex; - gap: 8px; - margin-top: 16px; -} - -button { - flex: 1; - min-height: 36px; - padding: 8px 0; - border: 0; - border-radius: 4px; - background: var(--btn); - color: var(--btn-fg); - font-size: 13px; - font-weight: 600; - cursor: pointer; -} - -button:hover { - background: var(--btn-hover); -} - -.secondary-button { - flex: none; - display: inline-flex; - align-items: center; - justify-content: center; - width: auto; - min-width: 0; - min-height: 32px; - padding: 0 12px; - border: 1px solid var(--sep); - border-radius: 4px; - background: var(--btn-secondary-bg); - color: var(--btn-secondary-fg); - font-size: 11px; - font-weight: 600; - line-height: 1; -} - -.secondary-button:hover { - background: var(--btn-secondary-hover); -} - -.compact-button { - flex: none; -} - -.field-row { - position: relative; - display: flex; - align-items: center; - gap: 4px; -} - -.field-row input { - flex: 1; -} - -.field-row input.crypto-input { - cursor: pointer; -} - -.field-row .field-action { - min-width: 64px; - padding: 0 10px; -} - -.identity-dropdown { - position: absolute; - z-index: 100; - top: calc(100% + 4px); - left: 0; - right: 0; - padding: 4px 0; - border: 1px solid var(--sep); - border-radius: 6px; - background: var(--input-bg); - box-shadow: 0 6px 18px rgba(0, 0, 0, 0.28); -} - -.identity-choice { - display: flex; - align-items: center; - gap: 8px; - justify-content: space-between; - margin: 0 4px; - padding: 7px 10px; - border-radius: 4px; - cursor: pointer; -} - -.identity-choice:hover { - background: var(--btn); - color: var(--btn-fg); -} - -.identity-choice-name { - display: block; - font-weight: 700; -} - -.identity-choice-main { - flex: 1; - min-width: 0; -} - -.identity-choice-value { - display: block; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: 11px; - opacity: 0.72; -} - -.identity-choice-delete { - flex: none; - min-width: 18px; - min-height: 18px; - padding: 0; - border: 0; - border-radius: 3px; - background: transparent; - color: var(--muted); - font-size: 14px; - line-height: 1; -} - -.identity-choice-delete:hover { - background: var(--panel-hover); - color: var(--vscode-errorForeground, var(--fg)); -} - -.identity-divider { - margin: 4px 0; - border-top: 1px solid var(--sep); -} diff --git a/extensions/vscode/webviews/quickLaunch/panel.html b/extensions/vscode/webviews/quickLaunch/panel.html deleted file mode 100644 index 13eee626..00000000 --- a/extensions/vscode/webviews/quickLaunch/panel.html +++ /dev/null @@ -1,47 +0,0 @@ - - - - - - - - - -
-

{{TITLE}}

-
-
- - -
-
-
- -
-

Constructor

-
-
- -
-

Entrypoint

- -
- -
-

Function Args

-
-
- -
- - -
- - - - - - diff --git a/extensions/vscode/webviews/quickLaunch/panel.js b/extensions/vscode/webviews/quickLaunch/panel.js deleted file mode 100644 index 558d7035..00000000 --- a/extensions/vscode/webviews/quickLaunch/panel.js +++ /dev/null @@ -1,511 +0,0 @@ -(function () { - const modelElement = document.getElementById("quick-launch-model"); - const stateElement = document.getElementById("quick-launch-state"); - const ctorFields = document.getElementById("constructor-fields"); - const argFields = document.getElementById("arg-fields"); - const functionSelect = document.getElementById("function-select"); - const loadButton = document.getElementById("load-button"); - - if ( - !modelElement || - !stateElement || - !ctorFields || - !argFields || - !functionSelect || - !loadButton - ) { - throw new Error("Quick launch panel failed to initialize."); - } - - const vscode = acquireVsCodeApi(); - const model = JSON.parse(modelElement.textContent || "null"); - const state = JSON.parse(stateElement.textContent || "null"); - - state.identityLabels = - state.identityLabels && typeof state.identityLabels === "object" - ? state.identityLabels - : {}; - state.savedCountsByFunction = - state.savedCountsByFunction && - typeof state.savedCountsByFunction === "object" - ? state.savedCountsByFunction - : {}; - state.savedTotalCount = Number(state.savedTotalCount) || 0; - - function fieldValue(defaultValue) { - return typeof defaultValue === "string" - ? defaultValue - : String(defaultValue ?? ""); - } - - function escapeHtml(value) { - return String(value ?? "") - .replace(/&/g, "&") - .replace(/"/g, """) - .replace(//g, ">"); - } - - function normalizedType(typeName) { - return String(typeName ?? "").trim().toLowerCase(); - } - - function helperSlot(param) { - const typeName = normalizedType(param.type); - const name = String(param.name ?? "").toLowerCase(); - if (typeName === "pubkey") { - return "pubkey"; - } - if (typeName === "sig") { - return "secret"; - } - if ( - (typeName === "bytes32" || - typeName === "byte[32]" || - typeName === "bytes") && - name.includes("pkh") - ) { - return "pkh"; - } - return null; - } - - function tokenFor(alias, slot) { - return alias + "." + slot; - } - - function canonicalIdentityToken(raw) { - const trimmed = String(raw ?? "").trim(); - const match = - /^(?:keypair|identity)([1-9][0-9]*)(?:[.](pubkey|secret|pkh))?$/.exec( - trimmed, - ); - if (!match) { - return null; - } - const index = match[1]; - const slot = match[2]; - return slot ? "keypair" + index + "." + slot : "keypair" + index; - } - - function displayLabelFor(alias) { - const label = state.identityLabels[alias]; - return typeof label === "string" && label.trim() - ? label.trim() - : alias; - } - - function syncAliasesFromFields() { - state.constructorArgs = collectFields(ctorFields, "constructor"); - syncCurrentArgState(); - - const found = new Map(); - const consider = (raw) => { - const canonical = canonicalIdentityToken(raw); - if (!canonical) { - return; - } - const index = Number(canonical.slice("keypair".length).split(".")[0]); - if (!found.has(index)) { - found.set(index, "keypair" + index); - } - }; - - state.keyAliases.forEach(consider); - Object.values(state.constructorArgs).forEach(consider); - Object.values(state.argsByFunction).forEach((args) => { - Object.values(args).forEach(consider); - }); - - state.keyAliases = [...found.entries()] - .sort((left, right) => left[0] - right[0]) - .map((entry) => entry[1]); - state.identityLabels = Object.fromEntries( - state.keyAliases - .map((alias) => [alias, state.identityLabels[alias]]) - .filter( - ([alias, label]) => - typeof label === "string" && - label.trim() && - label.trim() !== alias, - ) - .map(([alias, label]) => [alias, label.trim()]), - ); - } - - function nextAlias() { - syncAliasesFromFields(); - let max = 0; - state.keyAliases.forEach((alias) => { - const match = /^keypair([0-9]+)$/.exec(String(alias).trim()); - if (match) { - max = Math.max(max, Number(match[1])); - } - }); - return "keypair" + (max + 1); - } - - function fillFieldWithToken(input, slot, alias) { - input.value = tokenFor(alias, slot); - input.dispatchEvent(new Event("input", { bubbles: true })); - input.focus(); - } - - function addAlias(fillInput, fillSlot) { - syncAliasesFromFields(); - const alias = nextAlias(); - state.keyAliases.push(alias); - syncAliasesFromFields(); - if (fillInput && fillSlot) { - fillFieldWithToken(fillInput, fillSlot, alias); - } - return alias; - } - - function renderFields(container, params, values, group) { - if (!params.length) { - container.innerHTML = '

No parameters

'; - return; - } - - container.innerHTML = params - .map((param) => { - const value = fieldValue(values[param.name]); - const helper = helperSlot(param); - const escapedValue = escapeHtml(value); - return ( - '" + - '
' + - '" + - (helper - ? '' - : "") + - "
" - ); - }) - .join(""); - } - - function currentEntrypoint() { - return ( - model.entrypoints.find((entry) => entry.name === functionSelect.value) || - model.entrypoints[0] - ); - } - - function ensureArgState(functionName) { - if (!state.argsByFunction[functionName]) { - state.argsByFunction[functionName] = {}; - } - return state.argsByFunction[functionName]; - } - - function collectFields(container, group) { - const out = {}; - container - .querySelectorAll('input[data-group="' + group + '"]') - .forEach((input) => { - out[input.dataset.name] = - canonicalIdentityToken(input.value) ?? input.value; - }); - return out; - } - - function syncCurrentArgState() { - const entrypoint = currentEntrypoint(); - if (!entrypoint) { - return; - } - state.argsByFunction[entrypoint.name] = collectFields(argFields, "args"); - } - - function currentForm() { - syncAliasesFromFields(); - state.function = functionSelect.value; - return { - function: state.function, - constructorArgs: state.constructorArgs, - argsByFunction: state.argsByFunction, - keyAliases: state.keyAliases, - identityLabels: state.identityLabels, - }; - } - - function renderFunctionOptions() { - functionSelect.innerHTML = model.entrypoints - .map((entry) => { - const signature = entry.params - .map((param) => param.type + " " + param.name) - .join(", "); - const selected = entry.name === state.function ? " selected" : ""; - return ( - '" - ); - }) - .join(""); - } - - function renderArgs() { - const entrypoint = currentEntrypoint(); - if (!entrypoint) { - argFields.innerHTML = '

No entrypoints

'; - return; - } - renderFields( - argFields, - entrypoint.params, - ensureArgState(entrypoint.name), - "args", - ); - } - - function renderLoadButton() { - const functionName = String(functionSelect.value || state.function || ""); - const currentCount = Number(state.savedCountsByFunction[functionName] ?? 0); - loadButton.textContent = - currentCount > 0 ? "Load (" + currentCount + ")" : "Load"; - - if (state.savedTotalCount === 0) { - loadButton.title = "No saved scenarios for this contract yet."; - return; - } - - if (functionName && currentCount !== state.savedTotalCount) { - loadButton.title = - currentCount > 0 - ? currentCount + - " saved for " + - functionName + - ", " + - state.savedTotalCount + - " total for this contract." - : "No saved scenarios for " + - functionName + - ". " + - state.savedTotalCount + - " saved for this contract."; - return; - } - - loadButton.title = state.savedTotalCount + " saved for this contract."; - } - - function renderAllFields() { - renderFields( - ctorFields, - model.constructorParams, - state.constructorArgs, - "constructor", - ); - renderArgs(); - } - - function closeDropdowns() { - document - .querySelectorAll(".identity-dropdown") - .forEach((node) => node.remove()); - } - - function clearAliasTokens(alias) { - const tokens = new Set( - ["pubkey", "secret", "pkh"].map((slot) => tokenFor(alias, slot)), - ); - const clearValues = (values) => - Object.fromEntries( - Object.entries(values).map(([name, raw]) => { - const canonical = canonicalIdentityToken(raw); - return [name, canonical && tokens.has(canonical) ? "" : raw]; - }), - ); - - state.constructorArgs = clearValues(state.constructorArgs); - state.argsByFunction = Object.fromEntries( - Object.entries(state.argsByFunction).map(([name, values]) => [ - name, - clearValues(values), - ]), - ); - } - - function deleteAlias(alias) { - state.keyAliases = state.keyAliases.filter((entry) => entry !== alias); - delete state.identityLabels[alias]; - clearAliasTokens(alias); - renderAllFields(); - closeDropdowns(); - } - - function showDropdown(input, slot) { - syncAliasesFromFields(); - const fieldRow = input.closest(".field-row"); - if (!fieldRow) { - return; - } - closeDropdowns(); - - const dropdown = document.createElement("div"); - dropdown.className = "identity-dropdown"; - - state.keyAliases.forEach((alias) => { - const item = document.createElement("div"); - item.className = "identity-choice"; - const main = document.createElement("div"); - main.className = "identity-choice-main"; - const name = document.createElement("span"); - name.className = "identity-choice-name"; - name.textContent = displayLabelFor(alias); - const value = document.createElement("span"); - value.className = "identity-choice-value"; - value.textContent = tokenFor(alias, slot); - const remove = document.createElement("button"); - remove.type = "button"; - remove.className = "identity-choice-delete"; - remove.textContent = "X"; - remove.title = "Delete " + displayLabelFor(alias); - remove.addEventListener("click", (event) => { - event.stopPropagation(); - deleteAlias(alias); - }); - main.append(name, value); - item.append(main, remove); - item.addEventListener("click", () => { - fillFieldWithToken(input, slot, alias); - closeDropdowns(); - }); - dropdown.appendChild(item); - }); - - if (state.keyAliases.length) { - const divider = document.createElement("div"); - divider.className = "identity-divider"; - dropdown.appendChild(divider); - } - - const add = document.createElement("div"); - add.className = "identity-choice"; - const next = nextAlias(); - const addName = document.createElement("span"); - addName.className = "identity-choice-name"; - addName.textContent = "Add " + next; - const addValue = document.createElement("span"); - addValue.className = "identity-choice-value"; - addValue.textContent = tokenFor(next, slot); - add.append(addName, addValue); - add.addEventListener("click", () => { - addAlias(input, slot); - closeDropdowns(); - }); - dropdown.appendChild(add); - - fieldRow.appendChild(dropdown); - } - - function send(kind) { - vscode.postMessage({ - kind, - form: currentForm(), - }); - } - - functionSelect.addEventListener("change", () => { - syncCurrentArgState(); - state.function = functionSelect.value; - renderArgs(); - renderLoadButton(); - closeDropdowns(); - }); - - renderFunctionOptions(); - renderAllFields(); - renderLoadButton(); - - document.addEventListener("click", (event) => { - const target = - event.target instanceof Element - ? event.target - : event.target?.parentElement ?? null; - if (!target) { - return; - } - - const button = target.closest(".key-button"); - if (button) { - const row = button.closest(".field-row"); - const input = row?.querySelector("input.crypto-input"); - const slot = button.dataset.helperSlot; - if (input && slot) { - event.stopPropagation(); - showDropdown(input, slot); - } - return; - } - - const input = target.closest("input.crypto-input"); - if (input && input.dataset.helperSlot) { - event.stopPropagation(); - showDropdown(input, input.dataset.helperSlot); - return; - } - - if (!target.closest(".identity-dropdown")) { - closeDropdowns(); - } - }); - - document - .getElementById("load-button") - .addEventListener("click", () => send("loadSaved")); - document - .getElementById("save-button") - .addEventListener("click", () => send("saveSaved")); - document - .getElementById("run-button") - .addEventListener("click", () => send("run")); - document - .getElementById("debug-button") - .addEventListener("click", () => send("debug")); - - window.addEventListener("message", (event) => { - const message = event.data; - if (!message || typeof message !== "object") { - return; - } - if ( - message.kind === "triggerLaunch" && - (message.launchKind === "run" || message.launchKind === "debug") - ) { - send(message.launchKind); - } - }); -})();