diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ec860734..8a8ff54e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -95,6 +95,9 @@ jobs: with: merge-multiple: true + - name: Generate checksums + run: sha256sum simc-* > SHA256SUMS + - name: Create GitHub release uses: softprops/action-gh-release@v2 with: @@ -106,3 +109,4 @@ jobs: simc-macos-x86_64.tar.gz simc-macos-aarch64.tar.gz simc-windows-x86_64.zip + SHA256SUMS diff --git a/src/debug.rs b/src/debug.rs index 610e0b15..2cb63d57 100644 --- a/src/debug.rs +++ b/src/debug.rs @@ -219,3 +219,150 @@ impl DebugValue { &self.value } } + +#[cfg(feature = "serde")] +mod serde_impl { + //! JSON form of [`DebugSymbols`]: a map from CMR (hex) to `{text, name}`, with + //! types carried as their string form (like the ABI), so external tooling can + //! re-attach the symbols to a program whose nodes it resolves by CMR. + + use std::collections::{BTreeMap, HashMap}; + use std::str::FromStr; + use std::sync::Arc; + + use serde::de::Error as _; + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + use simplicity::Cmr; + + use super::{DebugSymbols, TrackedCall, TrackedCallName}; + use crate::parse::ParseFromStr; + use crate::types::ResolvedType; + + /// Wire form of [`TrackedCallName`]: unit variants as strings, type-carrying + /// variants as single-key objects holding the type's string form. + #[derive(Serialize, Deserialize)] + #[serde(rename_all = "snake_case")] + enum NameRepr { + Assert, + Panic, + Jet, + UnwrapLeft(String), + UnwrapRight(String), + Unwrap, + Debug(String), + } + + #[derive(Serialize, Deserialize)] + struct CallRepr { + text: String, + name: NameRepr, + } + + impl From<&TrackedCallName> for NameRepr { + fn from(name: &TrackedCallName) -> Self { + match name { + TrackedCallName::Assert => Self::Assert, + TrackedCallName::Panic => Self::Panic, + TrackedCallName::Jet => Self::Jet, + TrackedCallName::UnwrapLeft(ty) => Self::UnwrapLeft(ty.to_string()), + TrackedCallName::UnwrapRight(ty) => Self::UnwrapRight(ty.to_string()), + TrackedCallName::Unwrap => Self::Unwrap, + TrackedCallName::Debug(ty) => Self::Debug(ty.to_string()), + } + } + } + + impl TryFrom for TrackedCallName { + type Error = String; + + fn try_from(name: NameRepr) -> Result { + let ty = |s: String| { + ResolvedType::parse_from_str(&s).map_err(|error| format!("type `{s}`: {error}")) + }; + Ok(match name { + NameRepr::Assert => Self::Assert, + NameRepr::Panic => Self::Panic, + NameRepr::Jet => Self::Jet, + NameRepr::UnwrapLeft(s) => Self::UnwrapLeft(ty(s)?), + NameRepr::UnwrapRight(s) => Self::UnwrapRight(ty(s)?), + NameRepr::Unwrap => Self::Unwrap, + NameRepr::Debug(s) => Self::Debug(ty(s)?), + }) + } + } + + impl Serialize for TrackedCall { + fn serialize(&self, serializer: S) -> Result { + CallRepr { + text: self.text.to_string(), + name: NameRepr::from(&self.name), + } + .serialize(serializer) + } + } + + impl<'de> Deserialize<'de> for TrackedCall { + fn deserialize>(deserializer: D) -> Result { + let repr = CallRepr::deserialize(deserializer)?; + Ok(TrackedCall { + text: Arc::from(repr.text.as_str()), + name: TrackedCallName::try_from(repr.name).map_err(D::Error::custom)?, + }) + } + } + + impl Serialize for DebugSymbols { + fn serialize(&self, serializer: S) -> Result { + // BTreeMap for deterministic field order in the emitted JSON. + let map: BTreeMap = self + .0 + .iter() + .map(|(cmr, call)| (cmr.to_string(), call)) + .collect(); + map.serialize(serializer) + } + } + + impl<'de> Deserialize<'de> for DebugSymbols { + fn deserialize>(deserializer: D) -> Result { + let map = BTreeMap::::deserialize(deserializer)?; + let inner = map + .into_iter() + .map(|(cmr, call)| { + Cmr::from_str(&cmr) + .map(|cmr| (cmr, call)) + .map_err(|error| D::Error::custom(format!("CMR `{cmr}`: {error}"))) + }) + .collect::, D::Error>>()?; + Ok(DebugSymbols(inner)) + } + } + + #[cfg(test)] + mod tests { + use super::*; + use crate::error::Span; + + #[test] + fn debug_symbols_roundtrip() { + let file = "assert!(jet::is_zero_32(x)); dbg!(y)"; + let mut symbols = DebugSymbols::default(); + symbols.insert( + Span::new_in_default_file(0..28), + Cmr::from_byte_array([1; 32]), + TrackedCallName::Assert, + file, + ); + symbols.insert( + Span::new_in_default_file(29..36), + Cmr::from_byte_array([2; 32]), + TrackedCallName::Debug(ResolvedType::from(crate::types::UIntType::U32)), + file, + ); + + let json = serde_json::to_string(&symbols).expect("serialize"); + let back: DebugSymbols = serde_json::from_str(&json).expect("deserialize"); + assert_eq!(symbols, back); + } + } +} diff --git a/src/lib.rs b/src/lib.rs index a96c6f0b..49ab17e8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -65,7 +65,9 @@ pub struct TemplateProgram { impl TemplateProgram { /// Parses and flattens a multi-file program into a single enriched [`parse::Program`] - /// with all imports resolved and each file wrapped in a `unit_N` module. + /// with all imports resolved: the entry file's items stay at the top level and + /// each dependency file is wrapped in a `unit_N` module, so the flattened + /// output is itself a valid entry file. /// /// ## Errors /// @@ -357,6 +359,33 @@ impl CompiledProgram { version::SimcDirective::current_version() } + /// The witness layout of the program: the name and Simplicity value type of + /// every witness node, in the post order in which the nodes occur in the + /// Simplicity target code. + /// + /// This is the contract for external tooling that satisfies a program without + /// this compiler's frontend: witness nodes are never shared at commitment + /// time, and [`Self::commit`] preserves node order, so the `k`-th witness + /// node encountered in post order takes a value of the type described by the + /// `k`-th entry of this layout. A name can appear in several entries when the + /// program references the same witness more than once; every occurrence takes + /// the same value, exactly as [`Self::satisfy`] assigns it. + pub fn witness_layout(&self) -> Vec<(crate::str::WitnessName, Arc)> { + use simplicity::dag::DagLike as _; + + self.simplicity + .as_ref() + .post_order_iter::() + .filter_map(|item| match item.node.inner() { + simplicity::node::Inner::Witness(name) => Some(( + name.shallow_clone(), + Arc::clone(&item.node.cached_data().arrow().target), + )), + _ => None, + }) + .collect() + } + /// Satisfy the SimplicityHL program with the given `witness_values`. /// /// ## Errors @@ -1579,6 +1608,149 @@ fn main() { err ); } + + /// A program with two witnesses of distinct types, for the layout tests. + fn two_witness_program() -> CompiledProgram { + let src = "fn main() { + let a: u16 = witness::A; + let b: u32 = witness::B; + assert!(jet::eq_16(a, 7)); + assert!(jet::eq_32(b, 9)); + }"; + CompiledProgram::new( + src, + Arguments::default(), + false, + Box::new(crate::ast::ElementsJetHinter::new()), + ) + .unwrap() + } + + /// The witness values matching [`two_witness_program`]. + fn two_witness_values() -> WitnessValues { + let mut values = std::collections::HashMap::new(); + values.insert( + crate::str::WitnessName::from_str_unchecked("A"), + Value::from(crate::value::UIntValue::U16(7)), + ); + values.insert( + crate::str::WitnessName::from_str_unchecked("B"), + Value::from(crate::value::UIntValue::U32(9)), + ); + WitnessValues::from(values) + } + + /// The layout lists every witness node with its name and Simplicity type, + /// in the order the nodes occur in the target code. + #[test] + fn witness_layout_names_and_types() { + let layout = two_witness_program().witness_layout(); + let entries: Vec<(&str, String)> = layout + .iter() + .map(|(name, ty)| (name.as_inner(), ty.to_string())) + .collect(); + assert_eq!( + entries, + [("A", "2^16".to_string()), ("B", "2^32".to_string())] + ); + } + + /// The layout order is the satisfaction order: assigning values to witness + /// nodes *by layout index* — on the name-free commit program, through the + /// consensus encode/decode round-trip — yields the same redeem program as + /// [`CompiledProgram::satisfy`] assigning them by name. This is the contract + /// external tooling relies on to satisfy a program without the compiler's + /// frontend. + #[test] + fn witness_layout_populates_by_index() { + use simplicity::dag::PostOrderIterItem; + use simplicity::node::{self, Converter, Inner, NoWitness}; + + /// Assigns the `k`-th witness node the `k`-th of the given values. + struct IndexPopulator { + values: Vec, + next: usize, + } + + impl Converter for IndexPopulator { + type Error = String; + + fn convert_witness( + &mut self, + _: &PostOrderIterItem<&CommitNode>, + _: &NoWitness, + ) -> Result { + let value = self + .values + .get(self.next) + .cloned() + .ok_or("more witness nodes than layout entries")?; + self.next += 1; + Ok(value) + } + + fn convert_disconnect( + &mut self, + _: &PostOrderIterItem<&CommitNode>, + _: Option<&Arc>, + _: &node::NoDisconnect, + ) -> Result, Self::Error> { + unreachable!("SimplicityHL does not use disconnect right now") + } + + fn convert_data( + &mut self, + data: &PostOrderIterItem<&CommitNode>, + inner: Inner<&Arc, &Arc, &simplicity::Value>, + ) -> Result, Self::Error> { + let inner = inner + .map(|node| node.cached_data()) + .map_disconnect(|node| node.cached_data()) + .map_witness(simplicity::Value::shallow_clone); + Ok(Arc::new(node::RedeemData::new( + data.node.cached_data().arrow().shallow_clone(), + inner, + ))) + } + } + + let compiled = two_witness_program(); + let witness_values = two_witness_values(); + let expected = compiled + .satisfy(witness_values.shallow_clone()) + .unwrap() + .redeem() + .to_vec_with_witness(); + + // Order the values per the layout, converted exactly as satisfaction does. + let ordered: Vec = compiled + .witness_layout() + .iter() + .map(|(name, _)| { + simplicity::Value::from(crate::value::StructuralValue::from( + witness_values.get(name).unwrap(), + )) + }) + .collect(); + + // Round-trip the name-free program through the consensus serialization, + // as tooling that holds only the program bytes would. + let bytes = compiled.commit().to_vec_without_witness(); + let decoded = CommitNode::decode::<_, simplicity::jet::Elements>( + simplicity::BitIter::from(&bytes[..]), + ) + .unwrap(); + + let mut populator = IndexPopulator { + values: ordered, + next: 0, + }; + let redeem = decoded + .convert::(&mut populator) + .unwrap(); + assert_eq!(populator.next, populator.values.len()); + assert_eq!(redeem.to_vec_with_witness(), expected); + } } #[cfg(test)] diff --git a/src/main.rs b/src/main.rs index 0b1e0a74..28650066 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,12 +6,38 @@ use simplicityhl::ast::ElementsJetHinter; use simplicityhl::version::SimcDirective; use simplicityhl::{ resolution::DependencyMapBuilder, source::CanonPath, source::CanonSourceFile, AbiMeta, - CompiledProgram, + CompiledProgram, TemplateProgram, }; use simplicityhl::{UnstableFeature, UnstableFeatures}; use std::path::Path; use std::{env, fmt}; +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +/// One witness node of the program: its name and the Simplicity type of the +/// value it expects. See [`Output::witness_layout`]. +struct WitnessLayoutEntry { + name: String, + ty: String, +} + +#[cfg_attr(feature = "serde", derive(serde::Serialize))] +/// The output of `--abi-only`: a program's declared parameter and witness types, +/// obtained without compiling it and without requiring arguments (so a parametric +/// program can be typed out of process). +struct AbiOnlyOutput { + /// Declared parameter and witness types of the program. + abi_meta: AbiMeta, + /// Version of the compiler that produced this ABI. + compiler_version: &'static str, +} + +impl fmt::Display for AbiOnlyOutput { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!(f, "Compiler version:\n{}", self.compiler_version)?; + writeln!(f, "ABI meta:\n{:?}", self.abi_meta) + } +} + #[cfg_attr(feature = "serde", derive(serde::Serialize))] /// The compilation output. struct Output { @@ -27,6 +53,17 @@ struct Output { /// versions can produce different CMRs from the same source, so the version /// travels with the artifact as metadata (it is not part of the program). compiler_version: &'static str, + /// Every witness node of the program, in the post order in which the nodes + /// occur in the target code — the order in which tooling must assign values + /// to satisfy the program without recompiling it. + /// See `CompiledProgram::witness_layout`. + witness_layout: Vec, + /// Debug symbols of the program, keyed by node CMR (hex). Present only when + /// compiled with `--debug`: the debug instrumentation changes the program + /// (and thus its CMR), so the symbols always describe exactly the program in + /// this output. + #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))] + debug_symbols: Option, } impl fmt::Display for Output { @@ -37,9 +74,18 @@ impl fmt::Display for Output { if let Some(witness) = &self.witness { writeln!(f, "Witness:\n{}", witness)?; } + if !self.witness_layout.is_empty() { + writeln!(f, "Witness layout:")?; + for (index, entry) in self.witness_layout.iter().enumerate() { + writeln!(f, "{index}: {}: {}", entry.name, entry.ty)?; + } + } if let Some(witness) = &self.abi_meta { writeln!(f, "ABI meta:\n{:?}", witness)?; } + if let Some(symbols) = &self.debug_symbols { + writeln!(f, "Debug symbols:\n{:?}", symbols)?; + } Ok(()) } } @@ -47,6 +93,11 @@ impl fmt::Display for Output { fn main() -> Result<(), Box> { let command = { Command::new(env!("CARGO_BIN_NAME")) + // `--version` is the machine handshake for tooling that drives `simc` + // as a subprocess: the CLI is additive-only from 0.7.0 on (flags and + // output fields are never removed or reshaped), so the compiler + // version is the only version there is. + .version(env!("CARGO_PKG_VERSION")) .about( "\ Compile the given SimplicityHL program and print the resulting Simplicity base64 string.\n\ @@ -102,6 +153,12 @@ fn main() -> Result<(), Box> { .action(ArgAction::SetTrue) .help("Additional ABI .simf contract types"), ) + .arg( + Arg::new("abi_only") + .long("abi-only") + .action(ArgAction::SetTrue) + .help("Emit only the program's ABI (parameter/witness types), without compiling it or requiring arguments"), + ) .arg( Arg::new("unstable_features") .long("unstable-feature") @@ -125,6 +182,7 @@ fn main() -> Result<(), Box> { let include_debug_symbols = matches.get_flag("debug"); let output_json = matches.get_flag("json"); let abi_param = matches.get_flag("abi"); + let abi_only = matches.get_flag("abi_only"); let unstable_features = matches .get_many::("unstable_features") @@ -195,6 +253,48 @@ fn main() -> Result<(), Box> { }; let source = CanonSourceFile::new(main_path.clone(), std::sync::Arc::from(main_text)); + + // `--abi-only`: type the program without compiling it. Uses the args-free + // `TemplateProgram` path, so it works for parametric programs (no `--args` needed) — + // the case that plain `--abi` cannot serve. + if abi_only { + let template = match TemplateProgram::new_with_dep( + source, + &dependencies, + &unstable_features, + Box::new(ElementsJetHinter::new()), + ) { + Ok(template) => template, + Err(e) => { + eprintln!("{e}"); + std::process::exit(1); + } + }; + let output = AbiOnlyOutput { + abi_meta: template.generate_abi_meta()?, + compiler_version: SimcDirective::current_version(), + }; + + if output_json { + #[cfg(not(feature = "serde"))] + { + return Err( + "Program was compiled without the 'serde' feature and cannot output JSON." + .into(), + ); + } + + #[cfg(feature = "serde")] + { + println!("{}", serde_json::to_string(&output)?); + return Ok(()); + } + } + + println!("{}", output); + return Ok(()); + } + let compiled = match CompiledProgram::new_with_dep( source, &dependencies, @@ -249,12 +349,22 @@ fn main() -> Result<(), Box> { }; let cmr_hex = compiled.commit().cmr().to_string(); + let witness_layout = compiled + .witness_layout() + .iter() + .map(|(name, ty)| WitnessLayoutEntry { + name: name.to_string(), + ty: ty.to_string(), + }) + .collect(); let output = Output { program: Base64Display::new(&program_bytes, &STANDARD).to_string(), witness: witness_bytes.map(|bytes| Base64Display::new(&bytes, &STANDARD).to_string()), abi_meta: abi_opt, cmr: cmr_hex, compiler_version: compiled.compiler_version(), + witness_layout, + debug_symbols: include_debug_symbols.then(|| compiled.debug_symbols().clone()), }; if output_json { diff --git a/src/version.rs b/src/version.rs index 7dd7bdf6..e66dc05d 100644 --- a/src/version.rs +++ b/src/version.rs @@ -82,6 +82,18 @@ impl SimcDirective { } } + /// The byte range of a file's leading directive, for external tooling that needs + /// to strip it (e.g. to feed the source to a directive-unaware parser). Returns + /// `Ok(None)` when the file declares no directive, and an error when the + /// directive is malformed. + pub fn span_of(content: &str) -> Result>, String> { + match Self::scan(content) { + DirectiveScan::Found { span, .. } => Ok(Some(span)), + DirectiveScan::Malformed { .. } => Err(Error::MalformedSimcDirective.to_string()), + DirectiveScan::Absent => Ok(None), + } + } + /// The CLI advisory for a file with no `simc` directive, or `None` when one is /// present (a malformed directive counts as present). The suggestion names the /// compiler's base version — ranges cannot contain pre-release tags. @@ -394,4 +406,16 @@ mod tests { assert!(SimcDirective::requirement_of("simc \"*\"\nfn main() {}").is_err()); assert!(SimcDirective::requirement_of("simc \"not-a-version\";").is_err()); } + + /// `span_of` reports the exact directive bytes — including with leading trivia + /// that itself contains `simc` — so tooling can strip by span, not by search. + #[test] + fn span_of_reports_directive_bytes() { + let src = "// simc; note\nsimc \">=0.1.0\";\nfn main() {}"; + let span = SimcDirective::span_of(src).unwrap().unwrap(); + assert_eq!(&src[span], "simc \">=0.1.0\";"); + + assert_eq!(SimcDirective::span_of("fn main() {}"), Ok(None)); + assert!(SimcDirective::span_of("simc \"*\"\nfn main() {}").is_err()); + } } diff --git a/tests/cli.rs b/tests/cli.rs index 609df9a7..85f390cb 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -285,3 +285,129 @@ fn cli_dependency_version_mismatch_rejected() { "expected an incompatible-version error pointing at the dependency, got:\n{stderr}" ); } + +/// `--version` prints the compiler version and exits, without requiring a +/// program file — the handshake tooling performs before driving `simc` as a +/// subprocess (the CLI is additive-only, so the compiler version is the only +/// version there is). +#[test] +fn cli_version() { + let output = Command::new(env!("CARGO_BIN_EXE_simc")) + .arg("--version") + .output() + .expect("failed to run simc"); + assert!(output.status.success(), "simc --version must succeed"); + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!( + stdout.trim(), + format!("simc {}", env!("CARGO_PKG_VERSION")), + "got:\n{stdout}" + ); +} + +/// `--abi-only` types a program without compiling it and without requiring +/// arguments — so it works even for a program that declares a `param`, which plain +/// `--abi` (which fully compiles) cannot. JSON output requires `serde`. +#[cfg(feature = "serde")] +#[test] +fn cli_abi_only_types_parametric_program() { + let file = Path::new(env!("CARGO_TARGET_TMPDIR")).join("abi_only.simf"); + std::fs::write( + &file, + "fn main() {\n let a: u16 = witness::A;\n assert!(jet::eq_16(a, param::LIMIT));\n}\n", + ) + .expect("failed to write source file"); + + let output = Command::new(env!("CARGO_BIN_EXE_simc")) + .arg(&file) + .arg("--abi-only") + .arg("--json") + .output() + .expect("failed to run simc"); + assert!( + output.status.success(), + "simc --abi-only must succeed on a parametric program: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("\"witness_types\":{\"A\":\"u16\"}"), + "expected the witness type in the ABI output, got:\n{stdout}" + ); + assert!( + stdout.contains("\"parameter_types\":{\"LIMIT\":\"u16\"}"), + "expected the parameter type in the ABI output, got:\n{stdout}" + ); +} + +/// The output lists every witness node with its name and Simplicity type, in +/// the order tooling must use to satisfy the program without recompiling it. +/// JSON output requires `serde`. +#[cfg(feature = "serde")] +#[test] +fn cli_output_includes_witness_layout() { + let file = Path::new(env!("CARGO_TARGET_TMPDIR")).join("witness_layout.simf"); + std::fs::write( + &file, + "fn main() {\n let a: u16 = witness::A;\n assert!(jet::eq_16(a, 7));\n}\n", + ) + .expect("failed to write source file"); + let output = Command::new(env!("CARGO_BIN_EXE_simc")) + .arg(&file) + .arg("--json") + .output() + .expect("failed to run simc"); + assert!(output.status.success(), "simc --json must succeed"); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!( + stdout.contains("\"witness_layout\":[{\"name\":\"A\",\"ty\":\"2^16\"}]"), + "expected the witness layout in the JSON output, got:\n{stdout}" + ); +} + +/// `--debug` adds a `debug_symbols` map to the JSON output — CMR-keyed entries for +/// the instrumented calls — and without the flag the field is absent entirely, so +/// existing consumers see byte-identical output. The instrumentation changes the +/// program (and its CMR), which is why the symbols only ever travel with a `--debug` +/// compile: they always describe exactly the program in the same output. +#[cfg(feature = "serde")] +#[test] +fn cli_debug_symbols_in_json_output() { + let file = Path::new(env!("CARGO_TARGET_TMPDIR")).join("debug_symbols.simf"); + std::fs::write( + &file, + "fn main() {\n let x: u32 = dbg!(0);\n assert!(jet::is_zero_32(x));\n}\n", + ) + .expect("failed to write source file"); + + let plain = Command::new(env!("CARGO_BIN_EXE_simc")) + .arg(&file) + .arg("--json") + .output() + .expect("failed to run simc"); + assert!(plain.status.success(), "simc --json must succeed"); + let plain_stdout = String::from_utf8_lossy(&plain.stdout); + assert!( + !plain_stdout.contains("debug_symbols"), + "without --debug the field must be absent, got:\n{plain_stdout}" + ); + + let debug = Command::new(env!("CARGO_BIN_EXE_simc")) + .arg(&file) + .arg("--json") + .arg("--debug") + .output() + .expect("failed to run simc"); + assert!(debug.status.success(), "simc --json --debug must succeed"); + let debug_stdout = String::from_utf8_lossy(&debug.stdout); + assert!( + debug_stdout.contains("\"debug_symbols\":{"), + "expected a debug_symbols map in the JSON output, got:\n{debug_stdout}" + ); + assert!( + debug_stdout.contains("\"name\":{\"debug\":\"u32\"}"), + "expected the dbg! entry with its type, got:\n{debug_stdout}" + ); +}