diff --git a/CHANGELOG.md b/CHANGELOG.md index e980cc50e..de7609d97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### `cargo-miden` + +- Added `cargo miden package-cache`, which prints the project's fingerprinted package-cache + directory, the number of direct dependencies resolved from that cache, and the input paths a + contract build script must watch #1298 +- Contract templates and the repository examples now include a `build.rs` that populates the + package cache for builds `midenc` does not drive: outside a midenc-driven build it locates + the cache with `cargo miden package-cache`, fills it with a nested + `cargo miden build --release` when the project has source dependencies, and exports + `MIDENC_PACKAGE_CACHE` to macro expansion. Plain `cargo check` and IDE analysis now resolve + dependency packages instead of reporting missing packages (#1215). The script uses + `cargo miden` from `PATH`, or the binary named by the `CARGO_MIDEN` environment variable + #1298 +- Fixed the contract templates' `miden-project.toml` manifests, which were missing the + `[lib].path` key the VM v0.25 project model requires; projects generated from the templates + failed both `cargo miden build` and macro expansion with "unable to parse project manifest: + missing field `path`" + ### Rust SDK - The FPI macro diagnostic for a dependency package missing from a midenc-driven build now names @@ -18,6 +36,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - FPI expansions record `option_env!("MIDENC_PACKAGE_CACHE")`, so a consumer crate recompiles — and re-reads its dependency procedure roots — whenever the compiler's fingerprinted package cache path rotates, even if a stale cache directory survives on disk #1302 +- BREAKING: The component WIT generated by `#[component]` is now embedded in the compiled Miden + package (a `wit` section of the `.masp`) instead of being written to `target/generated-wit/`, + and the SDK macros read dependency WIT from the dependency's compiled package. The + `wit = "..."` keys in `miden-project.toml` are now only a fallback for dependency packages + without embedded WIT (e.g. produced by other toolchains): setting the key for a package that + embeds WIT is an error, and packages built by older Miden toolchains are rejected unless the + key supplies their WIT. See the [migration guide](./sdk/sdk/MIGRATION.md) for the manifest + edits and rebuild steps #1248 +- BREAKING: The SDK macros now read dependency packages only from the `MIDENC_PACKAGE_CACHE` + directory (or from a manifest path that names a `.masp` file directly). The previous search + of `target/miden/` output directories — the dependency's own, surrounding + workspaces', and ambient (`CARGO_TARGET_DIR`, `OUT_DIR`, working-directory) targets — was + removed, along with its freshest-first selection and macro-side package id and version + checks; the fingerprinted cache is rewritten by every build and its contents are trusted. + Builds driven by `cargo miden build` export the variable already; plain `cargo build`, + `cargo check`, and IDE analysis need the contract `build.rs`. An expansion without a + configured cache now fails with instructions instead of searching the filesystem #1298 ## [0.10.0-rc.1] diff --git a/Cargo.lock b/Cargo.lock index 596f64253..6602acd5b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2847,6 +2847,7 @@ name = "miden-base-macros" version = "0.14.0-rc.1" dependencies = [ "heck", + "miden-assembly", "miden-assembly-syntax", "miden-debug-types", "miden-field", @@ -3661,6 +3662,7 @@ dependencies = [ "midenc-dialect-scf", "midenc-frontend-masm", "midenc-frontend-wasm", + "midenc-frontend-wasm-metadata", "midenc-hir", "midenc-hir-transform", "midenc-session", @@ -3981,6 +3983,7 @@ dependencies = [ "midenc-compile", "midenc-expect-test", "midenc-frontend-wasm", + "midenc-frontend-wasm-metadata", "midenc-hir", "midenc-session", "proptest", @@ -4011,6 +4014,7 @@ dependencies = [ "midenc-dialect-wasm", "midenc-expect-test", "midenc-frontend-wasm", + "midenc-frontend-wasm-metadata", "midenc-hir", "midenc-hir-eval", "midenc-integration-test-support", diff --git a/README.md b/README.md index 4fba7bb2a..997401320 100644 --- a/README.md +++ b/README.md @@ -58,11 +58,11 @@ This will run all of the unit tests in the workspace, as well as all of our `lit (comma-delimited), where `PATH` is treated either as folder e.g. `MIDENC_EMIT=ir=target/emit` or file `MIDENC_EMIT=hir=my_name.hir`. - `MIDENC_EMIT_MACRO_EXPAND[=]`: When set, integration tests dump `cargo expand` output for Rust fixtures to `.expanded.rs` files in `` (or the CWD if empty/`1`). -- `MIDENC_EMIT_WIT[=]`: When set, integration tests emit public component WIT as - `.wit` and resolved macro-generated inline worlds as `..inline.wit` in - `` (or the CWD if empty/`1`). Resolved FPI worlds include their injected synthetic packages - and `fpi-*` functions. Generated SDK integration fixtures enable the internal WIT-printer - feature in their Cargo manifests. +- `MIDENC_EMIT_WIT[=]`: When set, integration tests emit the public component WIT embedded + in each compiled package as `.wit` and resolved macro-generated inline worlds as + `..inline.wit` in `` (or the CWD if empty/`1`). Resolved FPI worlds include + their injected synthetic packages and `fpi-*` functions. Generated SDK integration fixtures + enable the internal WIT-printer feature in their Cargo manifests. ## Docs diff --git a/examples/auth-component-no-auth/build.rs b/examples/auth-component-no-auth/build.rs new file mode 100644 index 000000000..6c3cee049 --- /dev/null +++ b/examples/auth-component-no-auth/build.rs @@ -0,0 +1,121 @@ +//! Populates the Miden package cache for builds that `midenc` does not drive. +//! +//! Plain `cargo check`, `cargo build`, and IDE analysis expand the Miden SDK macros without a +//! surrounding `cargo miden build`. Those macros read compiled dependency packages from the +//! directory named by `MIDENC_PACKAGE_CACHE`. This script asks `cargo-miden` for that +//! directory, populates it with a nested build when the project has source dependencies, and +//! exports the variable to the compilation of this crate. +//! +//! See . + +use std::{ + env, fs, + path::{Path, PathBuf}, + process::{Command, Output}, +}; + +fn main() { + // Re-evaluate this script when the build mode or the tool selection changes. + println!("cargo:rerun-if-env-changed=MIDENC_PACKAGE_CACHE"); + println!("cargo:rerun-if-env-changed=CARGO_MIDEN"); + // These inputs shape the compiler's package-cache fingerprint. + println!("cargo:rerun-if-env-changed=RUSTFLAGS"); + println!("cargo:rerun-if-env-changed=RUSTUP_TOOLCHAIN"); + + // Inside a midenc-driven build the compiler owns the package cache, macro expansion + // already sees the variable, and a nested build would recurse into this script forever. + if env::var_os("MIDENC_PACKAGE_CACHE").is_some() { + return; + } + + let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); + + // Ask the compiler where this project's package cache lives and which inputs shape it. + let query = run_cargo_miden(&manifest_dir, "package-cache"); + if !query.status.success() { + panic!( + "`cargo miden package-cache` failed ({}).\nInstall cargo-miden (`cargo install \ + cargo-miden`) or point the CARGO_MIDEN environment variable at a cargo-miden \ + binary.\n--- stderr ---\n{}", + query.status, + String::from_utf8_lossy(&query.stderr), + ); + } + + let stdout = + String::from_utf8(query.stdout).expect("cargo miden package-cache output is UTF-8"); + let mut cache_dir = None; + let mut source_dependencies = 0usize; + for line in stdout.lines() { + if let Some(value) = line.strip_prefix("cache-dir=") { + cache_dir = Some(PathBuf::from(value)); + } else if let Some(value) = line.strip_prefix("source-dependencies=") { + source_dependencies = value.parse().expect("source-dependencies is a number"); + } else if let Some(value) = line.strip_prefix("watch=") { + println!("cargo:rerun-if-changed={value}"); + } + } + let cache_dir = cache_dir.expect("cargo miden package-cache printed no cache-dir"); + + if source_dependencies > 0 { + // Populate the cache. Dependency packages publish before the root target compiles, so + // even a failing build (for example, this crate is mid-edit) usually leaves the + // dependency packages usable; the macros report anything that is genuinely missing. + let build = run_cargo_miden(&manifest_dir, "build"); + if !build.status.success() { + println!( + "cargo:warning=`cargo miden build --release` failed ({}); dependency packages \ + may be stale or missing: {}", + build.status, + last_stderr_line(&build.stderr), + ); + } + } + + // The macros treat a missing directory as an empty cache; create it so the exported + // variable always points at a real location. Watching the directory re-runs this script + // when another build rewrites or prunes the cache (cargo re-runs unconditionally while a + // watched path is missing), which keeps the exported path and its packages live. + fs::create_dir_all(&cache_dir).expect("failed to create the Miden package cache directory"); + println!("cargo:rerun-if-changed={}", cache_dir.display()); + println!("cargo:rustc-env=MIDENC_PACKAGE_CACHE={}", cache_dir.display()); +} + +/// Runs `cargo miden --release` for the project in `manifest_dir`. +/// +/// `CARGO_MIDEN` selects a specific `cargo-miden` binary; otherwise the `cargo miden` plugin +/// is resolved through the `cargo` that drives this build. The nested build gets its own +/// cargo target directory: the outer cargo holds a lock on this build's target directory +/// while build scripts run, and a nested build against the same directory would deadlock. +/// `--release` keeps the cache fingerprint identical to a `cargo miden build --release` run +/// by hand, so both share one cache. +fn run_cargo_miden(manifest_dir: &Path, subcommand: &str) -> Output { + let mut command = match env::var_os("CARGO_MIDEN") { + Some(cargo_miden) => Command::new(cargo_miden), + None => Command::new(env::var_os("CARGO").unwrap_or_else(|| "cargo".into())), + }; + command + .args(["miden", subcommand, "--release"]) + .current_dir(manifest_dir) + .env( + "CARGO_TARGET_DIR", + manifest_dir.join("target").join("miden").join("build-script"), + ); + command.output().unwrap_or_else(|err| { + panic!( + "failed to run `cargo miden {subcommand}`: {err}.\nInstall cargo-miden (`cargo \ + install cargo-miden`) or point the CARGO_MIDEN environment variable at a \ + cargo-miden binary." + ) + }) +} + +/// Returns the last non-empty stderr line for a compact warning. +fn last_stderr_line(stderr: &[u8]) -> String { + String::from_utf8_lossy(stderr) + .lines() + .rev() + .find(|line| !line.trim().is_empty()) + .unwrap_or("no error output") + .to_string() +} diff --git a/examples/auth-component-rpo-falcon512/build.rs b/examples/auth-component-rpo-falcon512/build.rs new file mode 100644 index 000000000..6c3cee049 --- /dev/null +++ b/examples/auth-component-rpo-falcon512/build.rs @@ -0,0 +1,121 @@ +//! Populates the Miden package cache for builds that `midenc` does not drive. +//! +//! Plain `cargo check`, `cargo build`, and IDE analysis expand the Miden SDK macros without a +//! surrounding `cargo miden build`. Those macros read compiled dependency packages from the +//! directory named by `MIDENC_PACKAGE_CACHE`. This script asks `cargo-miden` for that +//! directory, populates it with a nested build when the project has source dependencies, and +//! exports the variable to the compilation of this crate. +//! +//! See . + +use std::{ + env, fs, + path::{Path, PathBuf}, + process::{Command, Output}, +}; + +fn main() { + // Re-evaluate this script when the build mode or the tool selection changes. + println!("cargo:rerun-if-env-changed=MIDENC_PACKAGE_CACHE"); + println!("cargo:rerun-if-env-changed=CARGO_MIDEN"); + // These inputs shape the compiler's package-cache fingerprint. + println!("cargo:rerun-if-env-changed=RUSTFLAGS"); + println!("cargo:rerun-if-env-changed=RUSTUP_TOOLCHAIN"); + + // Inside a midenc-driven build the compiler owns the package cache, macro expansion + // already sees the variable, and a nested build would recurse into this script forever. + if env::var_os("MIDENC_PACKAGE_CACHE").is_some() { + return; + } + + let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); + + // Ask the compiler where this project's package cache lives and which inputs shape it. + let query = run_cargo_miden(&manifest_dir, "package-cache"); + if !query.status.success() { + panic!( + "`cargo miden package-cache` failed ({}).\nInstall cargo-miden (`cargo install \ + cargo-miden`) or point the CARGO_MIDEN environment variable at a cargo-miden \ + binary.\n--- stderr ---\n{}", + query.status, + String::from_utf8_lossy(&query.stderr), + ); + } + + let stdout = + String::from_utf8(query.stdout).expect("cargo miden package-cache output is UTF-8"); + let mut cache_dir = None; + let mut source_dependencies = 0usize; + for line in stdout.lines() { + if let Some(value) = line.strip_prefix("cache-dir=") { + cache_dir = Some(PathBuf::from(value)); + } else if let Some(value) = line.strip_prefix("source-dependencies=") { + source_dependencies = value.parse().expect("source-dependencies is a number"); + } else if let Some(value) = line.strip_prefix("watch=") { + println!("cargo:rerun-if-changed={value}"); + } + } + let cache_dir = cache_dir.expect("cargo miden package-cache printed no cache-dir"); + + if source_dependencies > 0 { + // Populate the cache. Dependency packages publish before the root target compiles, so + // even a failing build (for example, this crate is mid-edit) usually leaves the + // dependency packages usable; the macros report anything that is genuinely missing. + let build = run_cargo_miden(&manifest_dir, "build"); + if !build.status.success() { + println!( + "cargo:warning=`cargo miden build --release` failed ({}); dependency packages \ + may be stale or missing: {}", + build.status, + last_stderr_line(&build.stderr), + ); + } + } + + // The macros treat a missing directory as an empty cache; create it so the exported + // variable always points at a real location. Watching the directory re-runs this script + // when another build rewrites or prunes the cache (cargo re-runs unconditionally while a + // watched path is missing), which keeps the exported path and its packages live. + fs::create_dir_all(&cache_dir).expect("failed to create the Miden package cache directory"); + println!("cargo:rerun-if-changed={}", cache_dir.display()); + println!("cargo:rustc-env=MIDENC_PACKAGE_CACHE={}", cache_dir.display()); +} + +/// Runs `cargo miden --release` for the project in `manifest_dir`. +/// +/// `CARGO_MIDEN` selects a specific `cargo-miden` binary; otherwise the `cargo miden` plugin +/// is resolved through the `cargo` that drives this build. The nested build gets its own +/// cargo target directory: the outer cargo holds a lock on this build's target directory +/// while build scripts run, and a nested build against the same directory would deadlock. +/// `--release` keeps the cache fingerprint identical to a `cargo miden build --release` run +/// by hand, so both share one cache. +fn run_cargo_miden(manifest_dir: &Path, subcommand: &str) -> Output { + let mut command = match env::var_os("CARGO_MIDEN") { + Some(cargo_miden) => Command::new(cargo_miden), + None => Command::new(env::var_os("CARGO").unwrap_or_else(|| "cargo".into())), + }; + command + .args(["miden", subcommand, "--release"]) + .current_dir(manifest_dir) + .env( + "CARGO_TARGET_DIR", + manifest_dir.join("target").join("miden").join("build-script"), + ); + command.output().unwrap_or_else(|err| { + panic!( + "failed to run `cargo miden {subcommand}`: {err}.\nInstall cargo-miden (`cargo \ + install cargo-miden`) or point the CARGO_MIDEN environment variable at a \ + cargo-miden binary." + ) + }) +} + +/// Returns the last non-empty stderr line for a compact warning. +fn last_stderr_line(stderr: &[u8]) -> String { + String::from_utf8_lossy(stderr) + .lines() + .rev() + .find(|line| !line.trim().is_empty()) + .unwrap_or("no error output") + .to_string() +} diff --git a/examples/basic-wallet-tx-script/build.rs b/examples/basic-wallet-tx-script/build.rs new file mode 100644 index 000000000..6c3cee049 --- /dev/null +++ b/examples/basic-wallet-tx-script/build.rs @@ -0,0 +1,121 @@ +//! Populates the Miden package cache for builds that `midenc` does not drive. +//! +//! Plain `cargo check`, `cargo build`, and IDE analysis expand the Miden SDK macros without a +//! surrounding `cargo miden build`. Those macros read compiled dependency packages from the +//! directory named by `MIDENC_PACKAGE_CACHE`. This script asks `cargo-miden` for that +//! directory, populates it with a nested build when the project has source dependencies, and +//! exports the variable to the compilation of this crate. +//! +//! See . + +use std::{ + env, fs, + path::{Path, PathBuf}, + process::{Command, Output}, +}; + +fn main() { + // Re-evaluate this script when the build mode or the tool selection changes. + println!("cargo:rerun-if-env-changed=MIDENC_PACKAGE_CACHE"); + println!("cargo:rerun-if-env-changed=CARGO_MIDEN"); + // These inputs shape the compiler's package-cache fingerprint. + println!("cargo:rerun-if-env-changed=RUSTFLAGS"); + println!("cargo:rerun-if-env-changed=RUSTUP_TOOLCHAIN"); + + // Inside a midenc-driven build the compiler owns the package cache, macro expansion + // already sees the variable, and a nested build would recurse into this script forever. + if env::var_os("MIDENC_PACKAGE_CACHE").is_some() { + return; + } + + let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); + + // Ask the compiler where this project's package cache lives and which inputs shape it. + let query = run_cargo_miden(&manifest_dir, "package-cache"); + if !query.status.success() { + panic!( + "`cargo miden package-cache` failed ({}).\nInstall cargo-miden (`cargo install \ + cargo-miden`) or point the CARGO_MIDEN environment variable at a cargo-miden \ + binary.\n--- stderr ---\n{}", + query.status, + String::from_utf8_lossy(&query.stderr), + ); + } + + let stdout = + String::from_utf8(query.stdout).expect("cargo miden package-cache output is UTF-8"); + let mut cache_dir = None; + let mut source_dependencies = 0usize; + for line in stdout.lines() { + if let Some(value) = line.strip_prefix("cache-dir=") { + cache_dir = Some(PathBuf::from(value)); + } else if let Some(value) = line.strip_prefix("source-dependencies=") { + source_dependencies = value.parse().expect("source-dependencies is a number"); + } else if let Some(value) = line.strip_prefix("watch=") { + println!("cargo:rerun-if-changed={value}"); + } + } + let cache_dir = cache_dir.expect("cargo miden package-cache printed no cache-dir"); + + if source_dependencies > 0 { + // Populate the cache. Dependency packages publish before the root target compiles, so + // even a failing build (for example, this crate is mid-edit) usually leaves the + // dependency packages usable; the macros report anything that is genuinely missing. + let build = run_cargo_miden(&manifest_dir, "build"); + if !build.status.success() { + println!( + "cargo:warning=`cargo miden build --release` failed ({}); dependency packages \ + may be stale or missing: {}", + build.status, + last_stderr_line(&build.stderr), + ); + } + } + + // The macros treat a missing directory as an empty cache; create it so the exported + // variable always points at a real location. Watching the directory re-runs this script + // when another build rewrites or prunes the cache (cargo re-runs unconditionally while a + // watched path is missing), which keeps the exported path and its packages live. + fs::create_dir_all(&cache_dir).expect("failed to create the Miden package cache directory"); + println!("cargo:rerun-if-changed={}", cache_dir.display()); + println!("cargo:rustc-env=MIDENC_PACKAGE_CACHE={}", cache_dir.display()); +} + +/// Runs `cargo miden --release` for the project in `manifest_dir`. +/// +/// `CARGO_MIDEN` selects a specific `cargo-miden` binary; otherwise the `cargo miden` plugin +/// is resolved through the `cargo` that drives this build. The nested build gets its own +/// cargo target directory: the outer cargo holds a lock on this build's target directory +/// while build scripts run, and a nested build against the same directory would deadlock. +/// `--release` keeps the cache fingerprint identical to a `cargo miden build --release` run +/// by hand, so both share one cache. +fn run_cargo_miden(manifest_dir: &Path, subcommand: &str) -> Output { + let mut command = match env::var_os("CARGO_MIDEN") { + Some(cargo_miden) => Command::new(cargo_miden), + None => Command::new(env::var_os("CARGO").unwrap_or_else(|| "cargo".into())), + }; + command + .args(["miden", subcommand, "--release"]) + .current_dir(manifest_dir) + .env( + "CARGO_TARGET_DIR", + manifest_dir.join("target").join("miden").join("build-script"), + ); + command.output().unwrap_or_else(|err| { + panic!( + "failed to run `cargo miden {subcommand}`: {err}.\nInstall cargo-miden (`cargo \ + install cargo-miden`) or point the CARGO_MIDEN environment variable at a \ + cargo-miden binary." + ) + }) +} + +/// Returns the last non-empty stderr line for a compact warning. +fn last_stderr_line(stderr: &[u8]) -> String { + String::from_utf8_lossy(stderr) + .lines() + .rev() + .find(|line| !line.trim().is_empty()) + .unwrap_or("no error output") + .to_string() +} diff --git a/examples/basic-wallet-tx-script/miden-project.toml b/examples/basic-wallet-tx-script/miden-project.toml index 851fcf5a8..1baa9df3a 100644 --- a/examples/basic-wallet-tx-script/miden-project.toml +++ b/examples/basic-wallet-tx-script/miden-project.toml @@ -11,6 +11,3 @@ path = "src/lib.rs" miden-core = "*" miden-protocol = "*" basic-wallet = { path = "../basic-wallet" } - -[package.metadata.miden.dependencies] -basic-wallet = { wit = "../basic-wallet/target/generated-wit/" } diff --git a/examples/basic-wallet/build.rs b/examples/basic-wallet/build.rs new file mode 100644 index 000000000..6c3cee049 --- /dev/null +++ b/examples/basic-wallet/build.rs @@ -0,0 +1,121 @@ +//! Populates the Miden package cache for builds that `midenc` does not drive. +//! +//! Plain `cargo check`, `cargo build`, and IDE analysis expand the Miden SDK macros without a +//! surrounding `cargo miden build`. Those macros read compiled dependency packages from the +//! directory named by `MIDENC_PACKAGE_CACHE`. This script asks `cargo-miden` for that +//! directory, populates it with a nested build when the project has source dependencies, and +//! exports the variable to the compilation of this crate. +//! +//! See . + +use std::{ + env, fs, + path::{Path, PathBuf}, + process::{Command, Output}, +}; + +fn main() { + // Re-evaluate this script when the build mode or the tool selection changes. + println!("cargo:rerun-if-env-changed=MIDENC_PACKAGE_CACHE"); + println!("cargo:rerun-if-env-changed=CARGO_MIDEN"); + // These inputs shape the compiler's package-cache fingerprint. + println!("cargo:rerun-if-env-changed=RUSTFLAGS"); + println!("cargo:rerun-if-env-changed=RUSTUP_TOOLCHAIN"); + + // Inside a midenc-driven build the compiler owns the package cache, macro expansion + // already sees the variable, and a nested build would recurse into this script forever. + if env::var_os("MIDENC_PACKAGE_CACHE").is_some() { + return; + } + + let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); + + // Ask the compiler where this project's package cache lives and which inputs shape it. + let query = run_cargo_miden(&manifest_dir, "package-cache"); + if !query.status.success() { + panic!( + "`cargo miden package-cache` failed ({}).\nInstall cargo-miden (`cargo install \ + cargo-miden`) or point the CARGO_MIDEN environment variable at a cargo-miden \ + binary.\n--- stderr ---\n{}", + query.status, + String::from_utf8_lossy(&query.stderr), + ); + } + + let stdout = + String::from_utf8(query.stdout).expect("cargo miden package-cache output is UTF-8"); + let mut cache_dir = None; + let mut source_dependencies = 0usize; + for line in stdout.lines() { + if let Some(value) = line.strip_prefix("cache-dir=") { + cache_dir = Some(PathBuf::from(value)); + } else if let Some(value) = line.strip_prefix("source-dependencies=") { + source_dependencies = value.parse().expect("source-dependencies is a number"); + } else if let Some(value) = line.strip_prefix("watch=") { + println!("cargo:rerun-if-changed={value}"); + } + } + let cache_dir = cache_dir.expect("cargo miden package-cache printed no cache-dir"); + + if source_dependencies > 0 { + // Populate the cache. Dependency packages publish before the root target compiles, so + // even a failing build (for example, this crate is mid-edit) usually leaves the + // dependency packages usable; the macros report anything that is genuinely missing. + let build = run_cargo_miden(&manifest_dir, "build"); + if !build.status.success() { + println!( + "cargo:warning=`cargo miden build --release` failed ({}); dependency packages \ + may be stale or missing: {}", + build.status, + last_stderr_line(&build.stderr), + ); + } + } + + // The macros treat a missing directory as an empty cache; create it so the exported + // variable always points at a real location. Watching the directory re-runs this script + // when another build rewrites or prunes the cache (cargo re-runs unconditionally while a + // watched path is missing), which keeps the exported path and its packages live. + fs::create_dir_all(&cache_dir).expect("failed to create the Miden package cache directory"); + println!("cargo:rerun-if-changed={}", cache_dir.display()); + println!("cargo:rustc-env=MIDENC_PACKAGE_CACHE={}", cache_dir.display()); +} + +/// Runs `cargo miden --release` for the project in `manifest_dir`. +/// +/// `CARGO_MIDEN` selects a specific `cargo-miden` binary; otherwise the `cargo miden` plugin +/// is resolved through the `cargo` that drives this build. The nested build gets its own +/// cargo target directory: the outer cargo holds a lock on this build's target directory +/// while build scripts run, and a nested build against the same directory would deadlock. +/// `--release` keeps the cache fingerprint identical to a `cargo miden build --release` run +/// by hand, so both share one cache. +fn run_cargo_miden(manifest_dir: &Path, subcommand: &str) -> Output { + let mut command = match env::var_os("CARGO_MIDEN") { + Some(cargo_miden) => Command::new(cargo_miden), + None => Command::new(env::var_os("CARGO").unwrap_or_else(|| "cargo".into())), + }; + command + .args(["miden", subcommand, "--release"]) + .current_dir(manifest_dir) + .env( + "CARGO_TARGET_DIR", + manifest_dir.join("target").join("miden").join("build-script"), + ); + command.output().unwrap_or_else(|err| { + panic!( + "failed to run `cargo miden {subcommand}`: {err}.\nInstall cargo-miden (`cargo \ + install cargo-miden`) or point the CARGO_MIDEN environment variable at a \ + cargo-miden binary." + ) + }) +} + +/// Returns the last non-empty stderr line for a compact warning. +fn last_stderr_line(stderr: &[u8]) -> String { + String::from_utf8_lossy(stderr) + .lines() + .rev() + .find(|line| !line.trim().is_empty()) + .unwrap_or("no error output") + .to_string() +} diff --git a/examples/collatz/build.rs b/examples/collatz/build.rs new file mode 100644 index 000000000..6c3cee049 --- /dev/null +++ b/examples/collatz/build.rs @@ -0,0 +1,121 @@ +//! Populates the Miden package cache for builds that `midenc` does not drive. +//! +//! Plain `cargo check`, `cargo build`, and IDE analysis expand the Miden SDK macros without a +//! surrounding `cargo miden build`. Those macros read compiled dependency packages from the +//! directory named by `MIDENC_PACKAGE_CACHE`. This script asks `cargo-miden` for that +//! directory, populates it with a nested build when the project has source dependencies, and +//! exports the variable to the compilation of this crate. +//! +//! See . + +use std::{ + env, fs, + path::{Path, PathBuf}, + process::{Command, Output}, +}; + +fn main() { + // Re-evaluate this script when the build mode or the tool selection changes. + println!("cargo:rerun-if-env-changed=MIDENC_PACKAGE_CACHE"); + println!("cargo:rerun-if-env-changed=CARGO_MIDEN"); + // These inputs shape the compiler's package-cache fingerprint. + println!("cargo:rerun-if-env-changed=RUSTFLAGS"); + println!("cargo:rerun-if-env-changed=RUSTUP_TOOLCHAIN"); + + // Inside a midenc-driven build the compiler owns the package cache, macro expansion + // already sees the variable, and a nested build would recurse into this script forever. + if env::var_os("MIDENC_PACKAGE_CACHE").is_some() { + return; + } + + let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); + + // Ask the compiler where this project's package cache lives and which inputs shape it. + let query = run_cargo_miden(&manifest_dir, "package-cache"); + if !query.status.success() { + panic!( + "`cargo miden package-cache` failed ({}).\nInstall cargo-miden (`cargo install \ + cargo-miden`) or point the CARGO_MIDEN environment variable at a cargo-miden \ + binary.\n--- stderr ---\n{}", + query.status, + String::from_utf8_lossy(&query.stderr), + ); + } + + let stdout = + String::from_utf8(query.stdout).expect("cargo miden package-cache output is UTF-8"); + let mut cache_dir = None; + let mut source_dependencies = 0usize; + for line in stdout.lines() { + if let Some(value) = line.strip_prefix("cache-dir=") { + cache_dir = Some(PathBuf::from(value)); + } else if let Some(value) = line.strip_prefix("source-dependencies=") { + source_dependencies = value.parse().expect("source-dependencies is a number"); + } else if let Some(value) = line.strip_prefix("watch=") { + println!("cargo:rerun-if-changed={value}"); + } + } + let cache_dir = cache_dir.expect("cargo miden package-cache printed no cache-dir"); + + if source_dependencies > 0 { + // Populate the cache. Dependency packages publish before the root target compiles, so + // even a failing build (for example, this crate is mid-edit) usually leaves the + // dependency packages usable; the macros report anything that is genuinely missing. + let build = run_cargo_miden(&manifest_dir, "build"); + if !build.status.success() { + println!( + "cargo:warning=`cargo miden build --release` failed ({}); dependency packages \ + may be stale or missing: {}", + build.status, + last_stderr_line(&build.stderr), + ); + } + } + + // The macros treat a missing directory as an empty cache; create it so the exported + // variable always points at a real location. Watching the directory re-runs this script + // when another build rewrites or prunes the cache (cargo re-runs unconditionally while a + // watched path is missing), which keeps the exported path and its packages live. + fs::create_dir_all(&cache_dir).expect("failed to create the Miden package cache directory"); + println!("cargo:rerun-if-changed={}", cache_dir.display()); + println!("cargo:rustc-env=MIDENC_PACKAGE_CACHE={}", cache_dir.display()); +} + +/// Runs `cargo miden --release` for the project in `manifest_dir`. +/// +/// `CARGO_MIDEN` selects a specific `cargo-miden` binary; otherwise the `cargo miden` plugin +/// is resolved through the `cargo` that drives this build. The nested build gets its own +/// cargo target directory: the outer cargo holds a lock on this build's target directory +/// while build scripts run, and a nested build against the same directory would deadlock. +/// `--release` keeps the cache fingerprint identical to a `cargo miden build --release` run +/// by hand, so both share one cache. +fn run_cargo_miden(manifest_dir: &Path, subcommand: &str) -> Output { + let mut command = match env::var_os("CARGO_MIDEN") { + Some(cargo_miden) => Command::new(cargo_miden), + None => Command::new(env::var_os("CARGO").unwrap_or_else(|| "cargo".into())), + }; + command + .args(["miden", subcommand, "--release"]) + .current_dir(manifest_dir) + .env( + "CARGO_TARGET_DIR", + manifest_dir.join("target").join("miden").join("build-script"), + ); + command.output().unwrap_or_else(|err| { + panic!( + "failed to run `cargo miden {subcommand}`: {err}.\nInstall cargo-miden (`cargo \ + install cargo-miden`) or point the CARGO_MIDEN environment variable at a \ + cargo-miden binary." + ) + }) +} + +/// Returns the last non-empty stderr line for a compact warning. +fn last_stderr_line(stderr: &[u8]) -> String { + String::from_utf8_lossy(stderr) + .lines() + .rev() + .find(|line| !line.trim().is_empty()) + .unwrap_or("no error output") + .to_string() +} diff --git a/examples/counter-contract/build.rs b/examples/counter-contract/build.rs new file mode 100644 index 000000000..6c3cee049 --- /dev/null +++ b/examples/counter-contract/build.rs @@ -0,0 +1,121 @@ +//! Populates the Miden package cache for builds that `midenc` does not drive. +//! +//! Plain `cargo check`, `cargo build`, and IDE analysis expand the Miden SDK macros without a +//! surrounding `cargo miden build`. Those macros read compiled dependency packages from the +//! directory named by `MIDENC_PACKAGE_CACHE`. This script asks `cargo-miden` for that +//! directory, populates it with a nested build when the project has source dependencies, and +//! exports the variable to the compilation of this crate. +//! +//! See . + +use std::{ + env, fs, + path::{Path, PathBuf}, + process::{Command, Output}, +}; + +fn main() { + // Re-evaluate this script when the build mode or the tool selection changes. + println!("cargo:rerun-if-env-changed=MIDENC_PACKAGE_CACHE"); + println!("cargo:rerun-if-env-changed=CARGO_MIDEN"); + // These inputs shape the compiler's package-cache fingerprint. + println!("cargo:rerun-if-env-changed=RUSTFLAGS"); + println!("cargo:rerun-if-env-changed=RUSTUP_TOOLCHAIN"); + + // Inside a midenc-driven build the compiler owns the package cache, macro expansion + // already sees the variable, and a nested build would recurse into this script forever. + if env::var_os("MIDENC_PACKAGE_CACHE").is_some() { + return; + } + + let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); + + // Ask the compiler where this project's package cache lives and which inputs shape it. + let query = run_cargo_miden(&manifest_dir, "package-cache"); + if !query.status.success() { + panic!( + "`cargo miden package-cache` failed ({}).\nInstall cargo-miden (`cargo install \ + cargo-miden`) or point the CARGO_MIDEN environment variable at a cargo-miden \ + binary.\n--- stderr ---\n{}", + query.status, + String::from_utf8_lossy(&query.stderr), + ); + } + + let stdout = + String::from_utf8(query.stdout).expect("cargo miden package-cache output is UTF-8"); + let mut cache_dir = None; + let mut source_dependencies = 0usize; + for line in stdout.lines() { + if let Some(value) = line.strip_prefix("cache-dir=") { + cache_dir = Some(PathBuf::from(value)); + } else if let Some(value) = line.strip_prefix("source-dependencies=") { + source_dependencies = value.parse().expect("source-dependencies is a number"); + } else if let Some(value) = line.strip_prefix("watch=") { + println!("cargo:rerun-if-changed={value}"); + } + } + let cache_dir = cache_dir.expect("cargo miden package-cache printed no cache-dir"); + + if source_dependencies > 0 { + // Populate the cache. Dependency packages publish before the root target compiles, so + // even a failing build (for example, this crate is mid-edit) usually leaves the + // dependency packages usable; the macros report anything that is genuinely missing. + let build = run_cargo_miden(&manifest_dir, "build"); + if !build.status.success() { + println!( + "cargo:warning=`cargo miden build --release` failed ({}); dependency packages \ + may be stale or missing: {}", + build.status, + last_stderr_line(&build.stderr), + ); + } + } + + // The macros treat a missing directory as an empty cache; create it so the exported + // variable always points at a real location. Watching the directory re-runs this script + // when another build rewrites or prunes the cache (cargo re-runs unconditionally while a + // watched path is missing), which keeps the exported path and its packages live. + fs::create_dir_all(&cache_dir).expect("failed to create the Miden package cache directory"); + println!("cargo:rerun-if-changed={}", cache_dir.display()); + println!("cargo:rustc-env=MIDENC_PACKAGE_CACHE={}", cache_dir.display()); +} + +/// Runs `cargo miden --release` for the project in `manifest_dir`. +/// +/// `CARGO_MIDEN` selects a specific `cargo-miden` binary; otherwise the `cargo miden` plugin +/// is resolved through the `cargo` that drives this build. The nested build gets its own +/// cargo target directory: the outer cargo holds a lock on this build's target directory +/// while build scripts run, and a nested build against the same directory would deadlock. +/// `--release` keeps the cache fingerprint identical to a `cargo miden build --release` run +/// by hand, so both share one cache. +fn run_cargo_miden(manifest_dir: &Path, subcommand: &str) -> Output { + let mut command = match env::var_os("CARGO_MIDEN") { + Some(cargo_miden) => Command::new(cargo_miden), + None => Command::new(env::var_os("CARGO").unwrap_or_else(|| "cargo".into())), + }; + command + .args(["miden", subcommand, "--release"]) + .current_dir(manifest_dir) + .env( + "CARGO_TARGET_DIR", + manifest_dir.join("target").join("miden").join("build-script"), + ); + command.output().unwrap_or_else(|err| { + panic!( + "failed to run `cargo miden {subcommand}`: {err}.\nInstall cargo-miden (`cargo \ + install cargo-miden`) or point the CARGO_MIDEN environment variable at a \ + cargo-miden binary." + ) + }) +} + +/// Returns the last non-empty stderr line for a compact warning. +fn last_stderr_line(stderr: &[u8]) -> String { + String::from_utf8_lossy(stderr) + .lines() + .rev() + .find(|line| !line.trim().is_empty()) + .unwrap_or("no error output") + .to_string() +} diff --git a/examples/counter-note/build.rs b/examples/counter-note/build.rs new file mode 100644 index 000000000..6c3cee049 --- /dev/null +++ b/examples/counter-note/build.rs @@ -0,0 +1,121 @@ +//! Populates the Miden package cache for builds that `midenc` does not drive. +//! +//! Plain `cargo check`, `cargo build`, and IDE analysis expand the Miden SDK macros without a +//! surrounding `cargo miden build`. Those macros read compiled dependency packages from the +//! directory named by `MIDENC_PACKAGE_CACHE`. This script asks `cargo-miden` for that +//! directory, populates it with a nested build when the project has source dependencies, and +//! exports the variable to the compilation of this crate. +//! +//! See . + +use std::{ + env, fs, + path::{Path, PathBuf}, + process::{Command, Output}, +}; + +fn main() { + // Re-evaluate this script when the build mode or the tool selection changes. + println!("cargo:rerun-if-env-changed=MIDENC_PACKAGE_CACHE"); + println!("cargo:rerun-if-env-changed=CARGO_MIDEN"); + // These inputs shape the compiler's package-cache fingerprint. + println!("cargo:rerun-if-env-changed=RUSTFLAGS"); + println!("cargo:rerun-if-env-changed=RUSTUP_TOOLCHAIN"); + + // Inside a midenc-driven build the compiler owns the package cache, macro expansion + // already sees the variable, and a nested build would recurse into this script forever. + if env::var_os("MIDENC_PACKAGE_CACHE").is_some() { + return; + } + + let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); + + // Ask the compiler where this project's package cache lives and which inputs shape it. + let query = run_cargo_miden(&manifest_dir, "package-cache"); + if !query.status.success() { + panic!( + "`cargo miden package-cache` failed ({}).\nInstall cargo-miden (`cargo install \ + cargo-miden`) or point the CARGO_MIDEN environment variable at a cargo-miden \ + binary.\n--- stderr ---\n{}", + query.status, + String::from_utf8_lossy(&query.stderr), + ); + } + + let stdout = + String::from_utf8(query.stdout).expect("cargo miden package-cache output is UTF-8"); + let mut cache_dir = None; + let mut source_dependencies = 0usize; + for line in stdout.lines() { + if let Some(value) = line.strip_prefix("cache-dir=") { + cache_dir = Some(PathBuf::from(value)); + } else if let Some(value) = line.strip_prefix("source-dependencies=") { + source_dependencies = value.parse().expect("source-dependencies is a number"); + } else if let Some(value) = line.strip_prefix("watch=") { + println!("cargo:rerun-if-changed={value}"); + } + } + let cache_dir = cache_dir.expect("cargo miden package-cache printed no cache-dir"); + + if source_dependencies > 0 { + // Populate the cache. Dependency packages publish before the root target compiles, so + // even a failing build (for example, this crate is mid-edit) usually leaves the + // dependency packages usable; the macros report anything that is genuinely missing. + let build = run_cargo_miden(&manifest_dir, "build"); + if !build.status.success() { + println!( + "cargo:warning=`cargo miden build --release` failed ({}); dependency packages \ + may be stale or missing: {}", + build.status, + last_stderr_line(&build.stderr), + ); + } + } + + // The macros treat a missing directory as an empty cache; create it so the exported + // variable always points at a real location. Watching the directory re-runs this script + // when another build rewrites or prunes the cache (cargo re-runs unconditionally while a + // watched path is missing), which keeps the exported path and its packages live. + fs::create_dir_all(&cache_dir).expect("failed to create the Miden package cache directory"); + println!("cargo:rerun-if-changed={}", cache_dir.display()); + println!("cargo:rustc-env=MIDENC_PACKAGE_CACHE={}", cache_dir.display()); +} + +/// Runs `cargo miden --release` for the project in `manifest_dir`. +/// +/// `CARGO_MIDEN` selects a specific `cargo-miden` binary; otherwise the `cargo miden` plugin +/// is resolved through the `cargo` that drives this build. The nested build gets its own +/// cargo target directory: the outer cargo holds a lock on this build's target directory +/// while build scripts run, and a nested build against the same directory would deadlock. +/// `--release` keeps the cache fingerprint identical to a `cargo miden build --release` run +/// by hand, so both share one cache. +fn run_cargo_miden(manifest_dir: &Path, subcommand: &str) -> Output { + let mut command = match env::var_os("CARGO_MIDEN") { + Some(cargo_miden) => Command::new(cargo_miden), + None => Command::new(env::var_os("CARGO").unwrap_or_else(|| "cargo".into())), + }; + command + .args(["miden", subcommand, "--release"]) + .current_dir(manifest_dir) + .env( + "CARGO_TARGET_DIR", + manifest_dir.join("target").join("miden").join("build-script"), + ); + command.output().unwrap_or_else(|err| { + panic!( + "failed to run `cargo miden {subcommand}`: {err}.\nInstall cargo-miden (`cargo \ + install cargo-miden`) or point the CARGO_MIDEN environment variable at a \ + cargo-miden binary." + ) + }) +} + +/// Returns the last non-empty stderr line for a compact warning. +fn last_stderr_line(stderr: &[u8]) -> String { + String::from_utf8_lossy(stderr) + .lines() + .rev() + .find(|line| !line.trim().is_empty()) + .unwrap_or("no error output") + .to_string() +} diff --git a/examples/counter-note/miden-project.toml b/examples/counter-note/miden-project.toml index cd38ce615..a74eaf6de 100644 --- a/examples/counter-note/miden-project.toml +++ b/examples/counter-note/miden-project.toml @@ -11,6 +11,3 @@ path = "src/lib.rs" miden-core = "*" miden-protocol = "*" counter-contract = { path = "../counter-contract" } - -[package.metadata.miden.dependencies] -counter-contract = { wit = "../counter-contract/target/generated-wit/" } diff --git a/examples/fibonacci/build.rs b/examples/fibonacci/build.rs new file mode 100644 index 000000000..6c3cee049 --- /dev/null +++ b/examples/fibonacci/build.rs @@ -0,0 +1,121 @@ +//! Populates the Miden package cache for builds that `midenc` does not drive. +//! +//! Plain `cargo check`, `cargo build`, and IDE analysis expand the Miden SDK macros without a +//! surrounding `cargo miden build`. Those macros read compiled dependency packages from the +//! directory named by `MIDENC_PACKAGE_CACHE`. This script asks `cargo-miden` for that +//! directory, populates it with a nested build when the project has source dependencies, and +//! exports the variable to the compilation of this crate. +//! +//! See . + +use std::{ + env, fs, + path::{Path, PathBuf}, + process::{Command, Output}, +}; + +fn main() { + // Re-evaluate this script when the build mode or the tool selection changes. + println!("cargo:rerun-if-env-changed=MIDENC_PACKAGE_CACHE"); + println!("cargo:rerun-if-env-changed=CARGO_MIDEN"); + // These inputs shape the compiler's package-cache fingerprint. + println!("cargo:rerun-if-env-changed=RUSTFLAGS"); + println!("cargo:rerun-if-env-changed=RUSTUP_TOOLCHAIN"); + + // Inside a midenc-driven build the compiler owns the package cache, macro expansion + // already sees the variable, and a nested build would recurse into this script forever. + if env::var_os("MIDENC_PACKAGE_CACHE").is_some() { + return; + } + + let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); + + // Ask the compiler where this project's package cache lives and which inputs shape it. + let query = run_cargo_miden(&manifest_dir, "package-cache"); + if !query.status.success() { + panic!( + "`cargo miden package-cache` failed ({}).\nInstall cargo-miden (`cargo install \ + cargo-miden`) or point the CARGO_MIDEN environment variable at a cargo-miden \ + binary.\n--- stderr ---\n{}", + query.status, + String::from_utf8_lossy(&query.stderr), + ); + } + + let stdout = + String::from_utf8(query.stdout).expect("cargo miden package-cache output is UTF-8"); + let mut cache_dir = None; + let mut source_dependencies = 0usize; + for line in stdout.lines() { + if let Some(value) = line.strip_prefix("cache-dir=") { + cache_dir = Some(PathBuf::from(value)); + } else if let Some(value) = line.strip_prefix("source-dependencies=") { + source_dependencies = value.parse().expect("source-dependencies is a number"); + } else if let Some(value) = line.strip_prefix("watch=") { + println!("cargo:rerun-if-changed={value}"); + } + } + let cache_dir = cache_dir.expect("cargo miden package-cache printed no cache-dir"); + + if source_dependencies > 0 { + // Populate the cache. Dependency packages publish before the root target compiles, so + // even a failing build (for example, this crate is mid-edit) usually leaves the + // dependency packages usable; the macros report anything that is genuinely missing. + let build = run_cargo_miden(&manifest_dir, "build"); + if !build.status.success() { + println!( + "cargo:warning=`cargo miden build --release` failed ({}); dependency packages \ + may be stale or missing: {}", + build.status, + last_stderr_line(&build.stderr), + ); + } + } + + // The macros treat a missing directory as an empty cache; create it so the exported + // variable always points at a real location. Watching the directory re-runs this script + // when another build rewrites or prunes the cache (cargo re-runs unconditionally while a + // watched path is missing), which keeps the exported path and its packages live. + fs::create_dir_all(&cache_dir).expect("failed to create the Miden package cache directory"); + println!("cargo:rerun-if-changed={}", cache_dir.display()); + println!("cargo:rustc-env=MIDENC_PACKAGE_CACHE={}", cache_dir.display()); +} + +/// Runs `cargo miden --release` for the project in `manifest_dir`. +/// +/// `CARGO_MIDEN` selects a specific `cargo-miden` binary; otherwise the `cargo miden` plugin +/// is resolved through the `cargo` that drives this build. The nested build gets its own +/// cargo target directory: the outer cargo holds a lock on this build's target directory +/// while build scripts run, and a nested build against the same directory would deadlock. +/// `--release` keeps the cache fingerprint identical to a `cargo miden build --release` run +/// by hand, so both share one cache. +fn run_cargo_miden(manifest_dir: &Path, subcommand: &str) -> Output { + let mut command = match env::var_os("CARGO_MIDEN") { + Some(cargo_miden) => Command::new(cargo_miden), + None => Command::new(env::var_os("CARGO").unwrap_or_else(|| "cargo".into())), + }; + command + .args(["miden", subcommand, "--release"]) + .current_dir(manifest_dir) + .env( + "CARGO_TARGET_DIR", + manifest_dir.join("target").join("miden").join("build-script"), + ); + command.output().unwrap_or_else(|err| { + panic!( + "failed to run `cargo miden {subcommand}`: {err}.\nInstall cargo-miden (`cargo \ + install cargo-miden`) or point the CARGO_MIDEN environment variable at a \ + cargo-miden binary." + ) + }) +} + +/// Returns the last non-empty stderr line for a compact warning. +fn last_stderr_line(stderr: &[u8]) -> String { + String::from_utf8_lossy(stderr) + .lines() + .rev() + .find(|line| !line.trim().is_empty()) + .unwrap_or("no error output") + .to_string() +} diff --git a/examples/is-prime/build.rs b/examples/is-prime/build.rs new file mode 100644 index 000000000..6c3cee049 --- /dev/null +++ b/examples/is-prime/build.rs @@ -0,0 +1,121 @@ +//! Populates the Miden package cache for builds that `midenc` does not drive. +//! +//! Plain `cargo check`, `cargo build`, and IDE analysis expand the Miden SDK macros without a +//! surrounding `cargo miden build`. Those macros read compiled dependency packages from the +//! directory named by `MIDENC_PACKAGE_CACHE`. This script asks `cargo-miden` for that +//! directory, populates it with a nested build when the project has source dependencies, and +//! exports the variable to the compilation of this crate. +//! +//! See . + +use std::{ + env, fs, + path::{Path, PathBuf}, + process::{Command, Output}, +}; + +fn main() { + // Re-evaluate this script when the build mode or the tool selection changes. + println!("cargo:rerun-if-env-changed=MIDENC_PACKAGE_CACHE"); + println!("cargo:rerun-if-env-changed=CARGO_MIDEN"); + // These inputs shape the compiler's package-cache fingerprint. + println!("cargo:rerun-if-env-changed=RUSTFLAGS"); + println!("cargo:rerun-if-env-changed=RUSTUP_TOOLCHAIN"); + + // Inside a midenc-driven build the compiler owns the package cache, macro expansion + // already sees the variable, and a nested build would recurse into this script forever. + if env::var_os("MIDENC_PACKAGE_CACHE").is_some() { + return; + } + + let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); + + // Ask the compiler where this project's package cache lives and which inputs shape it. + let query = run_cargo_miden(&manifest_dir, "package-cache"); + if !query.status.success() { + panic!( + "`cargo miden package-cache` failed ({}).\nInstall cargo-miden (`cargo install \ + cargo-miden`) or point the CARGO_MIDEN environment variable at a cargo-miden \ + binary.\n--- stderr ---\n{}", + query.status, + String::from_utf8_lossy(&query.stderr), + ); + } + + let stdout = + String::from_utf8(query.stdout).expect("cargo miden package-cache output is UTF-8"); + let mut cache_dir = None; + let mut source_dependencies = 0usize; + for line in stdout.lines() { + if let Some(value) = line.strip_prefix("cache-dir=") { + cache_dir = Some(PathBuf::from(value)); + } else if let Some(value) = line.strip_prefix("source-dependencies=") { + source_dependencies = value.parse().expect("source-dependencies is a number"); + } else if let Some(value) = line.strip_prefix("watch=") { + println!("cargo:rerun-if-changed={value}"); + } + } + let cache_dir = cache_dir.expect("cargo miden package-cache printed no cache-dir"); + + if source_dependencies > 0 { + // Populate the cache. Dependency packages publish before the root target compiles, so + // even a failing build (for example, this crate is mid-edit) usually leaves the + // dependency packages usable; the macros report anything that is genuinely missing. + let build = run_cargo_miden(&manifest_dir, "build"); + if !build.status.success() { + println!( + "cargo:warning=`cargo miden build --release` failed ({}); dependency packages \ + may be stale or missing: {}", + build.status, + last_stderr_line(&build.stderr), + ); + } + } + + // The macros treat a missing directory as an empty cache; create it so the exported + // variable always points at a real location. Watching the directory re-runs this script + // when another build rewrites or prunes the cache (cargo re-runs unconditionally while a + // watched path is missing), which keeps the exported path and its packages live. + fs::create_dir_all(&cache_dir).expect("failed to create the Miden package cache directory"); + println!("cargo:rerun-if-changed={}", cache_dir.display()); + println!("cargo:rustc-env=MIDENC_PACKAGE_CACHE={}", cache_dir.display()); +} + +/// Runs `cargo miden --release` for the project in `manifest_dir`. +/// +/// `CARGO_MIDEN` selects a specific `cargo-miden` binary; otherwise the `cargo miden` plugin +/// is resolved through the `cargo` that drives this build. The nested build gets its own +/// cargo target directory: the outer cargo holds a lock on this build's target directory +/// while build scripts run, and a nested build against the same directory would deadlock. +/// `--release` keeps the cache fingerprint identical to a `cargo miden build --release` run +/// by hand, so both share one cache. +fn run_cargo_miden(manifest_dir: &Path, subcommand: &str) -> Output { + let mut command = match env::var_os("CARGO_MIDEN") { + Some(cargo_miden) => Command::new(cargo_miden), + None => Command::new(env::var_os("CARGO").unwrap_or_else(|| "cargo".into())), + }; + command + .args(["miden", subcommand, "--release"]) + .current_dir(manifest_dir) + .env( + "CARGO_TARGET_DIR", + manifest_dir.join("target").join("miden").join("build-script"), + ); + command.output().unwrap_or_else(|err| { + panic!( + "failed to run `cargo miden {subcommand}`: {err}.\nInstall cargo-miden (`cargo \ + install cargo-miden`) or point the CARGO_MIDEN environment variable at a \ + cargo-miden binary." + ) + }) +} + +/// Returns the last non-empty stderr line for a compact warning. +fn last_stderr_line(stderr: &[u8]) -> String { + String::from_utf8_lossy(stderr) + .lines() + .rev() + .find(|line| !line.trim().is_empty()) + .unwrap_or("no error output") + .to_string() +} diff --git a/examples/p2id-note/build.rs b/examples/p2id-note/build.rs new file mode 100644 index 000000000..6c3cee049 --- /dev/null +++ b/examples/p2id-note/build.rs @@ -0,0 +1,121 @@ +//! Populates the Miden package cache for builds that `midenc` does not drive. +//! +//! Plain `cargo check`, `cargo build`, and IDE analysis expand the Miden SDK macros without a +//! surrounding `cargo miden build`. Those macros read compiled dependency packages from the +//! directory named by `MIDENC_PACKAGE_CACHE`. This script asks `cargo-miden` for that +//! directory, populates it with a nested build when the project has source dependencies, and +//! exports the variable to the compilation of this crate. +//! +//! See . + +use std::{ + env, fs, + path::{Path, PathBuf}, + process::{Command, Output}, +}; + +fn main() { + // Re-evaluate this script when the build mode or the tool selection changes. + println!("cargo:rerun-if-env-changed=MIDENC_PACKAGE_CACHE"); + println!("cargo:rerun-if-env-changed=CARGO_MIDEN"); + // These inputs shape the compiler's package-cache fingerprint. + println!("cargo:rerun-if-env-changed=RUSTFLAGS"); + println!("cargo:rerun-if-env-changed=RUSTUP_TOOLCHAIN"); + + // Inside a midenc-driven build the compiler owns the package cache, macro expansion + // already sees the variable, and a nested build would recurse into this script forever. + if env::var_os("MIDENC_PACKAGE_CACHE").is_some() { + return; + } + + let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); + + // Ask the compiler where this project's package cache lives and which inputs shape it. + let query = run_cargo_miden(&manifest_dir, "package-cache"); + if !query.status.success() { + panic!( + "`cargo miden package-cache` failed ({}).\nInstall cargo-miden (`cargo install \ + cargo-miden`) or point the CARGO_MIDEN environment variable at a cargo-miden \ + binary.\n--- stderr ---\n{}", + query.status, + String::from_utf8_lossy(&query.stderr), + ); + } + + let stdout = + String::from_utf8(query.stdout).expect("cargo miden package-cache output is UTF-8"); + let mut cache_dir = None; + let mut source_dependencies = 0usize; + for line in stdout.lines() { + if let Some(value) = line.strip_prefix("cache-dir=") { + cache_dir = Some(PathBuf::from(value)); + } else if let Some(value) = line.strip_prefix("source-dependencies=") { + source_dependencies = value.parse().expect("source-dependencies is a number"); + } else if let Some(value) = line.strip_prefix("watch=") { + println!("cargo:rerun-if-changed={value}"); + } + } + let cache_dir = cache_dir.expect("cargo miden package-cache printed no cache-dir"); + + if source_dependencies > 0 { + // Populate the cache. Dependency packages publish before the root target compiles, so + // even a failing build (for example, this crate is mid-edit) usually leaves the + // dependency packages usable; the macros report anything that is genuinely missing. + let build = run_cargo_miden(&manifest_dir, "build"); + if !build.status.success() { + println!( + "cargo:warning=`cargo miden build --release` failed ({}); dependency packages \ + may be stale or missing: {}", + build.status, + last_stderr_line(&build.stderr), + ); + } + } + + // The macros treat a missing directory as an empty cache; create it so the exported + // variable always points at a real location. Watching the directory re-runs this script + // when another build rewrites or prunes the cache (cargo re-runs unconditionally while a + // watched path is missing), which keeps the exported path and its packages live. + fs::create_dir_all(&cache_dir).expect("failed to create the Miden package cache directory"); + println!("cargo:rerun-if-changed={}", cache_dir.display()); + println!("cargo:rustc-env=MIDENC_PACKAGE_CACHE={}", cache_dir.display()); +} + +/// Runs `cargo miden --release` for the project in `manifest_dir`. +/// +/// `CARGO_MIDEN` selects a specific `cargo-miden` binary; otherwise the `cargo miden` plugin +/// is resolved through the `cargo` that drives this build. The nested build gets its own +/// cargo target directory: the outer cargo holds a lock on this build's target directory +/// while build scripts run, and a nested build against the same directory would deadlock. +/// `--release` keeps the cache fingerprint identical to a `cargo miden build --release` run +/// by hand, so both share one cache. +fn run_cargo_miden(manifest_dir: &Path, subcommand: &str) -> Output { + let mut command = match env::var_os("CARGO_MIDEN") { + Some(cargo_miden) => Command::new(cargo_miden), + None => Command::new(env::var_os("CARGO").unwrap_or_else(|| "cargo".into())), + }; + command + .args(["miden", subcommand, "--release"]) + .current_dir(manifest_dir) + .env( + "CARGO_TARGET_DIR", + manifest_dir.join("target").join("miden").join("build-script"), + ); + command.output().unwrap_or_else(|err| { + panic!( + "failed to run `cargo miden {subcommand}`: {err}.\nInstall cargo-miden (`cargo \ + install cargo-miden`) or point the CARGO_MIDEN environment variable at a \ + cargo-miden binary." + ) + }) +} + +/// Returns the last non-empty stderr line for a compact warning. +fn last_stderr_line(stderr: &[u8]) -> String { + String::from_utf8_lossy(stderr) + .lines() + .rev() + .find(|line| !line.trim().is_empty()) + .unwrap_or("no error output") + .to_string() +} diff --git a/examples/p2id-note/miden-project.toml b/examples/p2id-note/miden-project.toml index 184a72f5a..47f9d3980 100644 --- a/examples/p2id-note/miden-project.toml +++ b/examples/p2id-note/miden-project.toml @@ -11,6 +11,3 @@ path = "src/lib.rs" miden-core = "*" miden-protocol = "*" basic-wallet = { path = "../basic-wallet" } - -[package.metadata.miden.dependencies] -basic-wallet = { wit = "../basic-wallet/target/generated-wit/" } diff --git a/examples/p2ide-note/build.rs b/examples/p2ide-note/build.rs new file mode 100644 index 000000000..6c3cee049 --- /dev/null +++ b/examples/p2ide-note/build.rs @@ -0,0 +1,121 @@ +//! Populates the Miden package cache for builds that `midenc` does not drive. +//! +//! Plain `cargo check`, `cargo build`, and IDE analysis expand the Miden SDK macros without a +//! surrounding `cargo miden build`. Those macros read compiled dependency packages from the +//! directory named by `MIDENC_PACKAGE_CACHE`. This script asks `cargo-miden` for that +//! directory, populates it with a nested build when the project has source dependencies, and +//! exports the variable to the compilation of this crate. +//! +//! See . + +use std::{ + env, fs, + path::{Path, PathBuf}, + process::{Command, Output}, +}; + +fn main() { + // Re-evaluate this script when the build mode or the tool selection changes. + println!("cargo:rerun-if-env-changed=MIDENC_PACKAGE_CACHE"); + println!("cargo:rerun-if-env-changed=CARGO_MIDEN"); + // These inputs shape the compiler's package-cache fingerprint. + println!("cargo:rerun-if-env-changed=RUSTFLAGS"); + println!("cargo:rerun-if-env-changed=RUSTUP_TOOLCHAIN"); + + // Inside a midenc-driven build the compiler owns the package cache, macro expansion + // already sees the variable, and a nested build would recurse into this script forever. + if env::var_os("MIDENC_PACKAGE_CACHE").is_some() { + return; + } + + let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); + + // Ask the compiler where this project's package cache lives and which inputs shape it. + let query = run_cargo_miden(&manifest_dir, "package-cache"); + if !query.status.success() { + panic!( + "`cargo miden package-cache` failed ({}).\nInstall cargo-miden (`cargo install \ + cargo-miden`) or point the CARGO_MIDEN environment variable at a cargo-miden \ + binary.\n--- stderr ---\n{}", + query.status, + String::from_utf8_lossy(&query.stderr), + ); + } + + let stdout = + String::from_utf8(query.stdout).expect("cargo miden package-cache output is UTF-8"); + let mut cache_dir = None; + let mut source_dependencies = 0usize; + for line in stdout.lines() { + if let Some(value) = line.strip_prefix("cache-dir=") { + cache_dir = Some(PathBuf::from(value)); + } else if let Some(value) = line.strip_prefix("source-dependencies=") { + source_dependencies = value.parse().expect("source-dependencies is a number"); + } else if let Some(value) = line.strip_prefix("watch=") { + println!("cargo:rerun-if-changed={value}"); + } + } + let cache_dir = cache_dir.expect("cargo miden package-cache printed no cache-dir"); + + if source_dependencies > 0 { + // Populate the cache. Dependency packages publish before the root target compiles, so + // even a failing build (for example, this crate is mid-edit) usually leaves the + // dependency packages usable; the macros report anything that is genuinely missing. + let build = run_cargo_miden(&manifest_dir, "build"); + if !build.status.success() { + println!( + "cargo:warning=`cargo miden build --release` failed ({}); dependency packages \ + may be stale or missing: {}", + build.status, + last_stderr_line(&build.stderr), + ); + } + } + + // The macros treat a missing directory as an empty cache; create it so the exported + // variable always points at a real location. Watching the directory re-runs this script + // when another build rewrites or prunes the cache (cargo re-runs unconditionally while a + // watched path is missing), which keeps the exported path and its packages live. + fs::create_dir_all(&cache_dir).expect("failed to create the Miden package cache directory"); + println!("cargo:rerun-if-changed={}", cache_dir.display()); + println!("cargo:rustc-env=MIDENC_PACKAGE_CACHE={}", cache_dir.display()); +} + +/// Runs `cargo miden --release` for the project in `manifest_dir`. +/// +/// `CARGO_MIDEN` selects a specific `cargo-miden` binary; otherwise the `cargo miden` plugin +/// is resolved through the `cargo` that drives this build. The nested build gets its own +/// cargo target directory: the outer cargo holds a lock on this build's target directory +/// while build scripts run, and a nested build against the same directory would deadlock. +/// `--release` keeps the cache fingerprint identical to a `cargo miden build --release` run +/// by hand, so both share one cache. +fn run_cargo_miden(manifest_dir: &Path, subcommand: &str) -> Output { + let mut command = match env::var_os("CARGO_MIDEN") { + Some(cargo_miden) => Command::new(cargo_miden), + None => Command::new(env::var_os("CARGO").unwrap_or_else(|| "cargo".into())), + }; + command + .args(["miden", subcommand, "--release"]) + .current_dir(manifest_dir) + .env( + "CARGO_TARGET_DIR", + manifest_dir.join("target").join("miden").join("build-script"), + ); + command.output().unwrap_or_else(|err| { + panic!( + "failed to run `cargo miden {subcommand}`: {err}.\nInstall cargo-miden (`cargo \ + install cargo-miden`) or point the CARGO_MIDEN environment variable at a \ + cargo-miden binary." + ) + }) +} + +/// Returns the last non-empty stderr line for a compact warning. +fn last_stderr_line(stderr: &[u8]) -> String { + String::from_utf8_lossy(stderr) + .lines() + .rev() + .find(|line| !line.trim().is_empty()) + .unwrap_or("no error output") + .to_string() +} diff --git a/examples/p2ide-note/miden-project.toml b/examples/p2ide-note/miden-project.toml index 1415632f9..b229458d2 100644 --- a/examples/p2ide-note/miden-project.toml +++ b/examples/p2ide-note/miden-project.toml @@ -11,6 +11,3 @@ path = "src/lib.rs" miden-core = "*" miden-protocol = "*" basic-wallet = { path = "../basic-wallet" } - -[package.metadata.miden.dependencies] -basic-wallet = { wit = "../basic-wallet/target/generated-wit/" } diff --git a/examples/storage-example/build.rs b/examples/storage-example/build.rs new file mode 100644 index 000000000..6c3cee049 --- /dev/null +++ b/examples/storage-example/build.rs @@ -0,0 +1,121 @@ +//! Populates the Miden package cache for builds that `midenc` does not drive. +//! +//! Plain `cargo check`, `cargo build`, and IDE analysis expand the Miden SDK macros without a +//! surrounding `cargo miden build`. Those macros read compiled dependency packages from the +//! directory named by `MIDENC_PACKAGE_CACHE`. This script asks `cargo-miden` for that +//! directory, populates it with a nested build when the project has source dependencies, and +//! exports the variable to the compilation of this crate. +//! +//! See . + +use std::{ + env, fs, + path::{Path, PathBuf}, + process::{Command, Output}, +}; + +fn main() { + // Re-evaluate this script when the build mode or the tool selection changes. + println!("cargo:rerun-if-env-changed=MIDENC_PACKAGE_CACHE"); + println!("cargo:rerun-if-env-changed=CARGO_MIDEN"); + // These inputs shape the compiler's package-cache fingerprint. + println!("cargo:rerun-if-env-changed=RUSTFLAGS"); + println!("cargo:rerun-if-env-changed=RUSTUP_TOOLCHAIN"); + + // Inside a midenc-driven build the compiler owns the package cache, macro expansion + // already sees the variable, and a nested build would recurse into this script forever. + if env::var_os("MIDENC_PACKAGE_CACHE").is_some() { + return; + } + + let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); + + // Ask the compiler where this project's package cache lives and which inputs shape it. + let query = run_cargo_miden(&manifest_dir, "package-cache"); + if !query.status.success() { + panic!( + "`cargo miden package-cache` failed ({}).\nInstall cargo-miden (`cargo install \ + cargo-miden`) or point the CARGO_MIDEN environment variable at a cargo-miden \ + binary.\n--- stderr ---\n{}", + query.status, + String::from_utf8_lossy(&query.stderr), + ); + } + + let stdout = + String::from_utf8(query.stdout).expect("cargo miden package-cache output is UTF-8"); + let mut cache_dir = None; + let mut source_dependencies = 0usize; + for line in stdout.lines() { + if let Some(value) = line.strip_prefix("cache-dir=") { + cache_dir = Some(PathBuf::from(value)); + } else if let Some(value) = line.strip_prefix("source-dependencies=") { + source_dependencies = value.parse().expect("source-dependencies is a number"); + } else if let Some(value) = line.strip_prefix("watch=") { + println!("cargo:rerun-if-changed={value}"); + } + } + let cache_dir = cache_dir.expect("cargo miden package-cache printed no cache-dir"); + + if source_dependencies > 0 { + // Populate the cache. Dependency packages publish before the root target compiles, so + // even a failing build (for example, this crate is mid-edit) usually leaves the + // dependency packages usable; the macros report anything that is genuinely missing. + let build = run_cargo_miden(&manifest_dir, "build"); + if !build.status.success() { + println!( + "cargo:warning=`cargo miden build --release` failed ({}); dependency packages \ + may be stale or missing: {}", + build.status, + last_stderr_line(&build.stderr), + ); + } + } + + // The macros treat a missing directory as an empty cache; create it so the exported + // variable always points at a real location. Watching the directory re-runs this script + // when another build rewrites or prunes the cache (cargo re-runs unconditionally while a + // watched path is missing), which keeps the exported path and its packages live. + fs::create_dir_all(&cache_dir).expect("failed to create the Miden package cache directory"); + println!("cargo:rerun-if-changed={}", cache_dir.display()); + println!("cargo:rustc-env=MIDENC_PACKAGE_CACHE={}", cache_dir.display()); +} + +/// Runs `cargo miden --release` for the project in `manifest_dir`. +/// +/// `CARGO_MIDEN` selects a specific `cargo-miden` binary; otherwise the `cargo miden` plugin +/// is resolved through the `cargo` that drives this build. The nested build gets its own +/// cargo target directory: the outer cargo holds a lock on this build's target directory +/// while build scripts run, and a nested build against the same directory would deadlock. +/// `--release` keeps the cache fingerprint identical to a `cargo miden build --release` run +/// by hand, so both share one cache. +fn run_cargo_miden(manifest_dir: &Path, subcommand: &str) -> Output { + let mut command = match env::var_os("CARGO_MIDEN") { + Some(cargo_miden) => Command::new(cargo_miden), + None => Command::new(env::var_os("CARGO").unwrap_or_else(|| "cargo".into())), + }; + command + .args(["miden", subcommand, "--release"]) + .current_dir(manifest_dir) + .env( + "CARGO_TARGET_DIR", + manifest_dir.join("target").join("miden").join("build-script"), + ); + command.output().unwrap_or_else(|err| { + panic!( + "failed to run `cargo miden {subcommand}`: {err}.\nInstall cargo-miden (`cargo \ + install cargo-miden`) or point the CARGO_MIDEN environment variable at a \ + cargo-miden binary." + ) + }) +} + +/// Returns the last non-empty stderr line for a compact warning. +fn last_stderr_line(stderr: &[u8]) -> String { + String::from_utf8_lossy(stderr) + .lines() + .rev() + .find(|line| !line.trim().is_empty()) + .unwrap_or("no error output") + .to_string() +} diff --git a/extra/templates/project/CLAUDE.md b/extra/templates/project/CLAUDE.md index e20175059..b92fbcbd1 100644 --- a/extra/templates/project/CLAUDE.md +++ b/extra/templates/project/CLAUDE.md @@ -17,6 +17,11 @@ Contracts are built individually with cargo-miden (not `cargo build`): cargo miden build --manifest-path contracts//Cargo.toml --release ``` +Each contract has a `build.rs` that populates the Miden package cache, so plain `cargo check` +and IDE analysis resolve dependency packages without a manual `cargo miden build` first. The +script needs `cargo miden` on `PATH` (or a binary named by the `CARGO_MIDEN` environment +variable). + Tests run via the workspace: ``` cargo test -p integration --release diff --git a/extra/templates/project/README.md b/extra/templates/project/README.md index 7d1cedde2..b11ff3db5 100644 --- a/extra/templates/project/README.md +++ b/extra/templates/project/README.md @@ -92,6 +92,11 @@ cd contracts/counter-account miden build ``` +Each contract also has a `build.rs` that keeps plain `cargo check` and IDE analysis working: +it populates the Miden package cache with the contract's compiled dependencies, so the SDK +macros resolve them without a manual build. The script needs `cargo miden` on `PATH` (or a +binary named by the `CARGO_MIDEN` environment variable). + ### Run a Binary ```bash diff --git a/extra/templates/project/contracts/counter-account/build.rs b/extra/templates/project/contracts/counter-account/build.rs new file mode 100644 index 000000000..6c3cee049 --- /dev/null +++ b/extra/templates/project/contracts/counter-account/build.rs @@ -0,0 +1,121 @@ +//! Populates the Miden package cache for builds that `midenc` does not drive. +//! +//! Plain `cargo check`, `cargo build`, and IDE analysis expand the Miden SDK macros without a +//! surrounding `cargo miden build`. Those macros read compiled dependency packages from the +//! directory named by `MIDENC_PACKAGE_CACHE`. This script asks `cargo-miden` for that +//! directory, populates it with a nested build when the project has source dependencies, and +//! exports the variable to the compilation of this crate. +//! +//! See . + +use std::{ + env, fs, + path::{Path, PathBuf}, + process::{Command, Output}, +}; + +fn main() { + // Re-evaluate this script when the build mode or the tool selection changes. + println!("cargo:rerun-if-env-changed=MIDENC_PACKAGE_CACHE"); + println!("cargo:rerun-if-env-changed=CARGO_MIDEN"); + // These inputs shape the compiler's package-cache fingerprint. + println!("cargo:rerun-if-env-changed=RUSTFLAGS"); + println!("cargo:rerun-if-env-changed=RUSTUP_TOOLCHAIN"); + + // Inside a midenc-driven build the compiler owns the package cache, macro expansion + // already sees the variable, and a nested build would recurse into this script forever. + if env::var_os("MIDENC_PACKAGE_CACHE").is_some() { + return; + } + + let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); + + // Ask the compiler where this project's package cache lives and which inputs shape it. + let query = run_cargo_miden(&manifest_dir, "package-cache"); + if !query.status.success() { + panic!( + "`cargo miden package-cache` failed ({}).\nInstall cargo-miden (`cargo install \ + cargo-miden`) or point the CARGO_MIDEN environment variable at a cargo-miden \ + binary.\n--- stderr ---\n{}", + query.status, + String::from_utf8_lossy(&query.stderr), + ); + } + + let stdout = + String::from_utf8(query.stdout).expect("cargo miden package-cache output is UTF-8"); + let mut cache_dir = None; + let mut source_dependencies = 0usize; + for line in stdout.lines() { + if let Some(value) = line.strip_prefix("cache-dir=") { + cache_dir = Some(PathBuf::from(value)); + } else if let Some(value) = line.strip_prefix("source-dependencies=") { + source_dependencies = value.parse().expect("source-dependencies is a number"); + } else if let Some(value) = line.strip_prefix("watch=") { + println!("cargo:rerun-if-changed={value}"); + } + } + let cache_dir = cache_dir.expect("cargo miden package-cache printed no cache-dir"); + + if source_dependencies > 0 { + // Populate the cache. Dependency packages publish before the root target compiles, so + // even a failing build (for example, this crate is mid-edit) usually leaves the + // dependency packages usable; the macros report anything that is genuinely missing. + let build = run_cargo_miden(&manifest_dir, "build"); + if !build.status.success() { + println!( + "cargo:warning=`cargo miden build --release` failed ({}); dependency packages \ + may be stale or missing: {}", + build.status, + last_stderr_line(&build.stderr), + ); + } + } + + // The macros treat a missing directory as an empty cache; create it so the exported + // variable always points at a real location. Watching the directory re-runs this script + // when another build rewrites or prunes the cache (cargo re-runs unconditionally while a + // watched path is missing), which keeps the exported path and its packages live. + fs::create_dir_all(&cache_dir).expect("failed to create the Miden package cache directory"); + println!("cargo:rerun-if-changed={}", cache_dir.display()); + println!("cargo:rustc-env=MIDENC_PACKAGE_CACHE={}", cache_dir.display()); +} + +/// Runs `cargo miden --release` for the project in `manifest_dir`. +/// +/// `CARGO_MIDEN` selects a specific `cargo-miden` binary; otherwise the `cargo miden` plugin +/// is resolved through the `cargo` that drives this build. The nested build gets its own +/// cargo target directory: the outer cargo holds a lock on this build's target directory +/// while build scripts run, and a nested build against the same directory would deadlock. +/// `--release` keeps the cache fingerprint identical to a `cargo miden build --release` run +/// by hand, so both share one cache. +fn run_cargo_miden(manifest_dir: &Path, subcommand: &str) -> Output { + let mut command = match env::var_os("CARGO_MIDEN") { + Some(cargo_miden) => Command::new(cargo_miden), + None => Command::new(env::var_os("CARGO").unwrap_or_else(|| "cargo".into())), + }; + command + .args(["miden", subcommand, "--release"]) + .current_dir(manifest_dir) + .env( + "CARGO_TARGET_DIR", + manifest_dir.join("target").join("miden").join("build-script"), + ); + command.output().unwrap_or_else(|err| { + panic!( + "failed to run `cargo miden {subcommand}`: {err}.\nInstall cargo-miden (`cargo \ + install cargo-miden`) or point the CARGO_MIDEN environment variable at a \ + cargo-miden binary." + ) + }) +} + +/// Returns the last non-empty stderr line for a compact warning. +fn last_stderr_line(stderr: &[u8]) -> String { + String::from_utf8_lossy(stderr) + .lines() + .rev() + .find(|line| !line.trim().is_empty()) + .unwrap_or("no error output") + .to_string() +} diff --git a/extra/templates/project/contracts/counter-account/miden-project.toml b/extra/templates/project/contracts/counter-account/miden-project.toml index c36d01aa6..42f65950a 100644 --- a/extra/templates/project/contracts/counter-account/miden-project.toml +++ b/extra/templates/project/contracts/counter-account/miden-project.toml @@ -7,6 +7,7 @@ kind = "account-component" # Full `miden:/@` id. The interface segment is the # kebab-cased component trait name (`CounterContract` -> `counter-contract`). namespace = "miden:counter-account/counter-contract@0.1.0" +path = "src/lib.rs" [dependencies] miden-core = "*" diff --git a/extra/templates/project/contracts/increment-note/build.rs b/extra/templates/project/contracts/increment-note/build.rs new file mode 100644 index 000000000..6c3cee049 --- /dev/null +++ b/extra/templates/project/contracts/increment-note/build.rs @@ -0,0 +1,121 @@ +//! Populates the Miden package cache for builds that `midenc` does not drive. +//! +//! Plain `cargo check`, `cargo build`, and IDE analysis expand the Miden SDK macros without a +//! surrounding `cargo miden build`. Those macros read compiled dependency packages from the +//! directory named by `MIDENC_PACKAGE_CACHE`. This script asks `cargo-miden` for that +//! directory, populates it with a nested build when the project has source dependencies, and +//! exports the variable to the compilation of this crate. +//! +//! See . + +use std::{ + env, fs, + path::{Path, PathBuf}, + process::{Command, Output}, +}; + +fn main() { + // Re-evaluate this script when the build mode or the tool selection changes. + println!("cargo:rerun-if-env-changed=MIDENC_PACKAGE_CACHE"); + println!("cargo:rerun-if-env-changed=CARGO_MIDEN"); + // These inputs shape the compiler's package-cache fingerprint. + println!("cargo:rerun-if-env-changed=RUSTFLAGS"); + println!("cargo:rerun-if-env-changed=RUSTUP_TOOLCHAIN"); + + // Inside a midenc-driven build the compiler owns the package cache, macro expansion + // already sees the variable, and a nested build would recurse into this script forever. + if env::var_os("MIDENC_PACKAGE_CACHE").is_some() { + return; + } + + let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); + + // Ask the compiler where this project's package cache lives and which inputs shape it. + let query = run_cargo_miden(&manifest_dir, "package-cache"); + if !query.status.success() { + panic!( + "`cargo miden package-cache` failed ({}).\nInstall cargo-miden (`cargo install \ + cargo-miden`) or point the CARGO_MIDEN environment variable at a cargo-miden \ + binary.\n--- stderr ---\n{}", + query.status, + String::from_utf8_lossy(&query.stderr), + ); + } + + let stdout = + String::from_utf8(query.stdout).expect("cargo miden package-cache output is UTF-8"); + let mut cache_dir = None; + let mut source_dependencies = 0usize; + for line in stdout.lines() { + if let Some(value) = line.strip_prefix("cache-dir=") { + cache_dir = Some(PathBuf::from(value)); + } else if let Some(value) = line.strip_prefix("source-dependencies=") { + source_dependencies = value.parse().expect("source-dependencies is a number"); + } else if let Some(value) = line.strip_prefix("watch=") { + println!("cargo:rerun-if-changed={value}"); + } + } + let cache_dir = cache_dir.expect("cargo miden package-cache printed no cache-dir"); + + if source_dependencies > 0 { + // Populate the cache. Dependency packages publish before the root target compiles, so + // even a failing build (for example, this crate is mid-edit) usually leaves the + // dependency packages usable; the macros report anything that is genuinely missing. + let build = run_cargo_miden(&manifest_dir, "build"); + if !build.status.success() { + println!( + "cargo:warning=`cargo miden build --release` failed ({}); dependency packages \ + may be stale or missing: {}", + build.status, + last_stderr_line(&build.stderr), + ); + } + } + + // The macros treat a missing directory as an empty cache; create it so the exported + // variable always points at a real location. Watching the directory re-runs this script + // when another build rewrites or prunes the cache (cargo re-runs unconditionally while a + // watched path is missing), which keeps the exported path and its packages live. + fs::create_dir_all(&cache_dir).expect("failed to create the Miden package cache directory"); + println!("cargo:rerun-if-changed={}", cache_dir.display()); + println!("cargo:rustc-env=MIDENC_PACKAGE_CACHE={}", cache_dir.display()); +} + +/// Runs `cargo miden --release` for the project in `manifest_dir`. +/// +/// `CARGO_MIDEN` selects a specific `cargo-miden` binary; otherwise the `cargo miden` plugin +/// is resolved through the `cargo` that drives this build. The nested build gets its own +/// cargo target directory: the outer cargo holds a lock on this build's target directory +/// while build scripts run, and a nested build against the same directory would deadlock. +/// `--release` keeps the cache fingerprint identical to a `cargo miden build --release` run +/// by hand, so both share one cache. +fn run_cargo_miden(manifest_dir: &Path, subcommand: &str) -> Output { + let mut command = match env::var_os("CARGO_MIDEN") { + Some(cargo_miden) => Command::new(cargo_miden), + None => Command::new(env::var_os("CARGO").unwrap_or_else(|| "cargo".into())), + }; + command + .args(["miden", subcommand, "--release"]) + .current_dir(manifest_dir) + .env( + "CARGO_TARGET_DIR", + manifest_dir.join("target").join("miden").join("build-script"), + ); + command.output().unwrap_or_else(|err| { + panic!( + "failed to run `cargo miden {subcommand}`: {err}.\nInstall cargo-miden (`cargo \ + install cargo-miden`) or point the CARGO_MIDEN environment variable at a \ + cargo-miden binary." + ) + }) +} + +/// Returns the last non-empty stderr line for a compact warning. +fn last_stderr_line(stderr: &[u8]) -> String { + String::from_utf8_lossy(stderr) + .lines() + .rev() + .find(|line| !line.trim().is_empty()) + .unwrap_or("no error output") + .to_string() +} diff --git a/extra/templates/project/contracts/increment-note/miden-project.toml b/extra/templates/project/contracts/increment-note/miden-project.toml index 6ff70c952..28277c928 100644 --- a/extra/templates/project/contracts/increment-note/miden-project.toml +++ b/extra/templates/project/contracts/increment-note/miden-project.toml @@ -6,12 +6,9 @@ version = "0.1.0" kind = "note" # Notes export a package-derived interface (`miden-`), matching the `#[note]` macro. namespace = "miden:increment-note/miden-increment-note@0.1.0" +path = "src/lib.rs" [dependencies] miden-core = "*" miden-protocol = "*" counter-account = { path = "../counter-account" } - -# WIT for the account component this note calls, produced by building counter-account. -[package.metadata.miden.dependencies] -counter-account = { wit = "../counter-account/target/generated-wit/" } diff --git a/extra/templates/rust/account/template/build.rs b/extra/templates/rust/account/template/build.rs new file mode 100644 index 000000000..6c3cee049 --- /dev/null +++ b/extra/templates/rust/account/template/build.rs @@ -0,0 +1,121 @@ +//! Populates the Miden package cache for builds that `midenc` does not drive. +//! +//! Plain `cargo check`, `cargo build`, and IDE analysis expand the Miden SDK macros without a +//! surrounding `cargo miden build`. Those macros read compiled dependency packages from the +//! directory named by `MIDENC_PACKAGE_CACHE`. This script asks `cargo-miden` for that +//! directory, populates it with a nested build when the project has source dependencies, and +//! exports the variable to the compilation of this crate. +//! +//! See . + +use std::{ + env, fs, + path::{Path, PathBuf}, + process::{Command, Output}, +}; + +fn main() { + // Re-evaluate this script when the build mode or the tool selection changes. + println!("cargo:rerun-if-env-changed=MIDENC_PACKAGE_CACHE"); + println!("cargo:rerun-if-env-changed=CARGO_MIDEN"); + // These inputs shape the compiler's package-cache fingerprint. + println!("cargo:rerun-if-env-changed=RUSTFLAGS"); + println!("cargo:rerun-if-env-changed=RUSTUP_TOOLCHAIN"); + + // Inside a midenc-driven build the compiler owns the package cache, macro expansion + // already sees the variable, and a nested build would recurse into this script forever. + if env::var_os("MIDENC_PACKAGE_CACHE").is_some() { + return; + } + + let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); + + // Ask the compiler where this project's package cache lives and which inputs shape it. + let query = run_cargo_miden(&manifest_dir, "package-cache"); + if !query.status.success() { + panic!( + "`cargo miden package-cache` failed ({}).\nInstall cargo-miden (`cargo install \ + cargo-miden`) or point the CARGO_MIDEN environment variable at a cargo-miden \ + binary.\n--- stderr ---\n{}", + query.status, + String::from_utf8_lossy(&query.stderr), + ); + } + + let stdout = + String::from_utf8(query.stdout).expect("cargo miden package-cache output is UTF-8"); + let mut cache_dir = None; + let mut source_dependencies = 0usize; + for line in stdout.lines() { + if let Some(value) = line.strip_prefix("cache-dir=") { + cache_dir = Some(PathBuf::from(value)); + } else if let Some(value) = line.strip_prefix("source-dependencies=") { + source_dependencies = value.parse().expect("source-dependencies is a number"); + } else if let Some(value) = line.strip_prefix("watch=") { + println!("cargo:rerun-if-changed={value}"); + } + } + let cache_dir = cache_dir.expect("cargo miden package-cache printed no cache-dir"); + + if source_dependencies > 0 { + // Populate the cache. Dependency packages publish before the root target compiles, so + // even a failing build (for example, this crate is mid-edit) usually leaves the + // dependency packages usable; the macros report anything that is genuinely missing. + let build = run_cargo_miden(&manifest_dir, "build"); + if !build.status.success() { + println!( + "cargo:warning=`cargo miden build --release` failed ({}); dependency packages \ + may be stale or missing: {}", + build.status, + last_stderr_line(&build.stderr), + ); + } + } + + // The macros treat a missing directory as an empty cache; create it so the exported + // variable always points at a real location. Watching the directory re-runs this script + // when another build rewrites or prunes the cache (cargo re-runs unconditionally while a + // watched path is missing), which keeps the exported path and its packages live. + fs::create_dir_all(&cache_dir).expect("failed to create the Miden package cache directory"); + println!("cargo:rerun-if-changed={}", cache_dir.display()); + println!("cargo:rustc-env=MIDENC_PACKAGE_CACHE={}", cache_dir.display()); +} + +/// Runs `cargo miden --release` for the project in `manifest_dir`. +/// +/// `CARGO_MIDEN` selects a specific `cargo-miden` binary; otherwise the `cargo miden` plugin +/// is resolved through the `cargo` that drives this build. The nested build gets its own +/// cargo target directory: the outer cargo holds a lock on this build's target directory +/// while build scripts run, and a nested build against the same directory would deadlock. +/// `--release` keeps the cache fingerprint identical to a `cargo miden build --release` run +/// by hand, so both share one cache. +fn run_cargo_miden(manifest_dir: &Path, subcommand: &str) -> Output { + let mut command = match env::var_os("CARGO_MIDEN") { + Some(cargo_miden) => Command::new(cargo_miden), + None => Command::new(env::var_os("CARGO").unwrap_or_else(|| "cargo".into())), + }; + command + .args(["miden", subcommand, "--release"]) + .current_dir(manifest_dir) + .env( + "CARGO_TARGET_DIR", + manifest_dir.join("target").join("miden").join("build-script"), + ); + command.output().unwrap_or_else(|err| { + panic!( + "failed to run `cargo miden {subcommand}`: {err}.\nInstall cargo-miden (`cargo \ + install cargo-miden`) or point the CARGO_MIDEN environment variable at a \ + cargo-miden binary." + ) + }) +} + +/// Returns the last non-empty stderr line for a compact warning. +fn last_stderr_line(stderr: &[u8]) -> String { + String::from_utf8_lossy(stderr) + .lines() + .rev() + .find(|line| !line.trim().is_empty()) + .unwrap_or("no error output") + .to_string() +} diff --git a/extra/templates/rust/account/template/miden-project.toml b/extra/templates/rust/account/template/miden-project.toml index ceb412184..94017d1bb 100644 --- a/extra/templates/rust/account/template/miden-project.toml +++ b/extra/templates/rust/account/template/miden-project.toml @@ -5,6 +5,7 @@ version = "0.1.0" [lib] kind = "account-component" namespace = "miden:{{crate_name | replace: "_", "-" }}/{{crate_name | replace: "_", "-" }}@0.1.0" +path = "src/lib.rs" [dependencies] miden-core = "*" diff --git a/extra/templates/rust/auth-component/template/build.rs b/extra/templates/rust/auth-component/template/build.rs new file mode 100644 index 000000000..6c3cee049 --- /dev/null +++ b/extra/templates/rust/auth-component/template/build.rs @@ -0,0 +1,121 @@ +//! Populates the Miden package cache for builds that `midenc` does not drive. +//! +//! Plain `cargo check`, `cargo build`, and IDE analysis expand the Miden SDK macros without a +//! surrounding `cargo miden build`. Those macros read compiled dependency packages from the +//! directory named by `MIDENC_PACKAGE_CACHE`. This script asks `cargo-miden` for that +//! directory, populates it with a nested build when the project has source dependencies, and +//! exports the variable to the compilation of this crate. +//! +//! See . + +use std::{ + env, fs, + path::{Path, PathBuf}, + process::{Command, Output}, +}; + +fn main() { + // Re-evaluate this script when the build mode or the tool selection changes. + println!("cargo:rerun-if-env-changed=MIDENC_PACKAGE_CACHE"); + println!("cargo:rerun-if-env-changed=CARGO_MIDEN"); + // These inputs shape the compiler's package-cache fingerprint. + println!("cargo:rerun-if-env-changed=RUSTFLAGS"); + println!("cargo:rerun-if-env-changed=RUSTUP_TOOLCHAIN"); + + // Inside a midenc-driven build the compiler owns the package cache, macro expansion + // already sees the variable, and a nested build would recurse into this script forever. + if env::var_os("MIDENC_PACKAGE_CACHE").is_some() { + return; + } + + let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); + + // Ask the compiler where this project's package cache lives and which inputs shape it. + let query = run_cargo_miden(&manifest_dir, "package-cache"); + if !query.status.success() { + panic!( + "`cargo miden package-cache` failed ({}).\nInstall cargo-miden (`cargo install \ + cargo-miden`) or point the CARGO_MIDEN environment variable at a cargo-miden \ + binary.\n--- stderr ---\n{}", + query.status, + String::from_utf8_lossy(&query.stderr), + ); + } + + let stdout = + String::from_utf8(query.stdout).expect("cargo miden package-cache output is UTF-8"); + let mut cache_dir = None; + let mut source_dependencies = 0usize; + for line in stdout.lines() { + if let Some(value) = line.strip_prefix("cache-dir=") { + cache_dir = Some(PathBuf::from(value)); + } else if let Some(value) = line.strip_prefix("source-dependencies=") { + source_dependencies = value.parse().expect("source-dependencies is a number"); + } else if let Some(value) = line.strip_prefix("watch=") { + println!("cargo:rerun-if-changed={value}"); + } + } + let cache_dir = cache_dir.expect("cargo miden package-cache printed no cache-dir"); + + if source_dependencies > 0 { + // Populate the cache. Dependency packages publish before the root target compiles, so + // even a failing build (for example, this crate is mid-edit) usually leaves the + // dependency packages usable; the macros report anything that is genuinely missing. + let build = run_cargo_miden(&manifest_dir, "build"); + if !build.status.success() { + println!( + "cargo:warning=`cargo miden build --release` failed ({}); dependency packages \ + may be stale or missing: {}", + build.status, + last_stderr_line(&build.stderr), + ); + } + } + + // The macros treat a missing directory as an empty cache; create it so the exported + // variable always points at a real location. Watching the directory re-runs this script + // when another build rewrites or prunes the cache (cargo re-runs unconditionally while a + // watched path is missing), which keeps the exported path and its packages live. + fs::create_dir_all(&cache_dir).expect("failed to create the Miden package cache directory"); + println!("cargo:rerun-if-changed={}", cache_dir.display()); + println!("cargo:rustc-env=MIDENC_PACKAGE_CACHE={}", cache_dir.display()); +} + +/// Runs `cargo miden --release` for the project in `manifest_dir`. +/// +/// `CARGO_MIDEN` selects a specific `cargo-miden` binary; otherwise the `cargo miden` plugin +/// is resolved through the `cargo` that drives this build. The nested build gets its own +/// cargo target directory: the outer cargo holds a lock on this build's target directory +/// while build scripts run, and a nested build against the same directory would deadlock. +/// `--release` keeps the cache fingerprint identical to a `cargo miden build --release` run +/// by hand, so both share one cache. +fn run_cargo_miden(manifest_dir: &Path, subcommand: &str) -> Output { + let mut command = match env::var_os("CARGO_MIDEN") { + Some(cargo_miden) => Command::new(cargo_miden), + None => Command::new(env::var_os("CARGO").unwrap_or_else(|| "cargo".into())), + }; + command + .args(["miden", subcommand, "--release"]) + .current_dir(manifest_dir) + .env( + "CARGO_TARGET_DIR", + manifest_dir.join("target").join("miden").join("build-script"), + ); + command.output().unwrap_or_else(|err| { + panic!( + "failed to run `cargo miden {subcommand}`: {err}.\nInstall cargo-miden (`cargo \ + install cargo-miden`) or point the CARGO_MIDEN environment variable at a \ + cargo-miden binary." + ) + }) +} + +/// Returns the last non-empty stderr line for a compact warning. +fn last_stderr_line(stderr: &[u8]) -> String { + String::from_utf8_lossy(stderr) + .lines() + .rev() + .find(|line| !line.trim().is_empty()) + .unwrap_or("no error output") + .to_string() +} diff --git a/extra/templates/rust/auth-component/template/miden-project.toml b/extra/templates/rust/auth-component/template/miden-project.toml index 5660b6469..56de7b10d 100644 --- a/extra/templates/rust/auth-component/template/miden-project.toml +++ b/extra/templates/rust/auth-component/template/miden-project.toml @@ -5,6 +5,7 @@ version = "0.1.0" [lib] kind = "account-component" namespace = "miden:{{crate_name | replace: "_", "-" }}/auth-component@0.1.0" +path = "src/lib.rs" [dependencies] miden-core = "*" diff --git a/extra/templates/rust/note/template/build.rs b/extra/templates/rust/note/template/build.rs new file mode 100644 index 000000000..6c3cee049 --- /dev/null +++ b/extra/templates/rust/note/template/build.rs @@ -0,0 +1,121 @@ +//! Populates the Miden package cache for builds that `midenc` does not drive. +//! +//! Plain `cargo check`, `cargo build`, and IDE analysis expand the Miden SDK macros without a +//! surrounding `cargo miden build`. Those macros read compiled dependency packages from the +//! directory named by `MIDENC_PACKAGE_CACHE`. This script asks `cargo-miden` for that +//! directory, populates it with a nested build when the project has source dependencies, and +//! exports the variable to the compilation of this crate. +//! +//! See . + +use std::{ + env, fs, + path::{Path, PathBuf}, + process::{Command, Output}, +}; + +fn main() { + // Re-evaluate this script when the build mode or the tool selection changes. + println!("cargo:rerun-if-env-changed=MIDENC_PACKAGE_CACHE"); + println!("cargo:rerun-if-env-changed=CARGO_MIDEN"); + // These inputs shape the compiler's package-cache fingerprint. + println!("cargo:rerun-if-env-changed=RUSTFLAGS"); + println!("cargo:rerun-if-env-changed=RUSTUP_TOOLCHAIN"); + + // Inside a midenc-driven build the compiler owns the package cache, macro expansion + // already sees the variable, and a nested build would recurse into this script forever. + if env::var_os("MIDENC_PACKAGE_CACHE").is_some() { + return; + } + + let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); + + // Ask the compiler where this project's package cache lives and which inputs shape it. + let query = run_cargo_miden(&manifest_dir, "package-cache"); + if !query.status.success() { + panic!( + "`cargo miden package-cache` failed ({}).\nInstall cargo-miden (`cargo install \ + cargo-miden`) or point the CARGO_MIDEN environment variable at a cargo-miden \ + binary.\n--- stderr ---\n{}", + query.status, + String::from_utf8_lossy(&query.stderr), + ); + } + + let stdout = + String::from_utf8(query.stdout).expect("cargo miden package-cache output is UTF-8"); + let mut cache_dir = None; + let mut source_dependencies = 0usize; + for line in stdout.lines() { + if let Some(value) = line.strip_prefix("cache-dir=") { + cache_dir = Some(PathBuf::from(value)); + } else if let Some(value) = line.strip_prefix("source-dependencies=") { + source_dependencies = value.parse().expect("source-dependencies is a number"); + } else if let Some(value) = line.strip_prefix("watch=") { + println!("cargo:rerun-if-changed={value}"); + } + } + let cache_dir = cache_dir.expect("cargo miden package-cache printed no cache-dir"); + + if source_dependencies > 0 { + // Populate the cache. Dependency packages publish before the root target compiles, so + // even a failing build (for example, this crate is mid-edit) usually leaves the + // dependency packages usable; the macros report anything that is genuinely missing. + let build = run_cargo_miden(&manifest_dir, "build"); + if !build.status.success() { + println!( + "cargo:warning=`cargo miden build --release` failed ({}); dependency packages \ + may be stale or missing: {}", + build.status, + last_stderr_line(&build.stderr), + ); + } + } + + // The macros treat a missing directory as an empty cache; create it so the exported + // variable always points at a real location. Watching the directory re-runs this script + // when another build rewrites or prunes the cache (cargo re-runs unconditionally while a + // watched path is missing), which keeps the exported path and its packages live. + fs::create_dir_all(&cache_dir).expect("failed to create the Miden package cache directory"); + println!("cargo:rerun-if-changed={}", cache_dir.display()); + println!("cargo:rustc-env=MIDENC_PACKAGE_CACHE={}", cache_dir.display()); +} + +/// Runs `cargo miden --release` for the project in `manifest_dir`. +/// +/// `CARGO_MIDEN` selects a specific `cargo-miden` binary; otherwise the `cargo miden` plugin +/// is resolved through the `cargo` that drives this build. The nested build gets its own +/// cargo target directory: the outer cargo holds a lock on this build's target directory +/// while build scripts run, and a nested build against the same directory would deadlock. +/// `--release` keeps the cache fingerprint identical to a `cargo miden build --release` run +/// by hand, so both share one cache. +fn run_cargo_miden(manifest_dir: &Path, subcommand: &str) -> Output { + let mut command = match env::var_os("CARGO_MIDEN") { + Some(cargo_miden) => Command::new(cargo_miden), + None => Command::new(env::var_os("CARGO").unwrap_or_else(|| "cargo".into())), + }; + command + .args(["miden", subcommand, "--release"]) + .current_dir(manifest_dir) + .env( + "CARGO_TARGET_DIR", + manifest_dir.join("target").join("miden").join("build-script"), + ); + command.output().unwrap_or_else(|err| { + panic!( + "failed to run `cargo miden {subcommand}`: {err}.\nInstall cargo-miden (`cargo \ + install cargo-miden`) or point the CARGO_MIDEN environment variable at a \ + cargo-miden binary." + ) + }) +} + +/// Returns the last non-empty stderr line for a compact warning. +fn last_stderr_line(stderr: &[u8]) -> String { + String::from_utf8_lossy(stderr) + .lines() + .rev() + .find(|line| !line.trim().is_empty()) + .unwrap_or("no error output") + .to_string() +} diff --git a/extra/templates/rust/note/template/miden-project.toml b/extra/templates/rust/note/template/miden-project.toml index a6a0c8bc8..991a78d63 100644 --- a/extra/templates/rust/note/template/miden-project.toml +++ b/extra/templates/rust/note/template/miden-project.toml @@ -5,13 +5,10 @@ version = "0.1.0" [lib] kind = "note" namespace = "miden:{{crate_name | replace: "_", "-" }}/miden-{{crate_name | replace: "_", "-" }}@0.1.0" +path = "src/lib.rs" [dependencies] miden-core = "*" miden-protocol = "*" # TODO: Add your account contract dependencies here add-contract = { path = "../add-contract" } - -[package.metadata.miden.dependencies] -# TODO: Add your account contract WIT dependencies here -add-contract = { wit = "../add-contract/target/generated-wit/" } diff --git a/extra/templates/rust/program/template/build.rs b/extra/templates/rust/program/template/build.rs new file mode 100644 index 000000000..6c3cee049 --- /dev/null +++ b/extra/templates/rust/program/template/build.rs @@ -0,0 +1,121 @@ +//! Populates the Miden package cache for builds that `midenc` does not drive. +//! +//! Plain `cargo check`, `cargo build`, and IDE analysis expand the Miden SDK macros without a +//! surrounding `cargo miden build`. Those macros read compiled dependency packages from the +//! directory named by `MIDENC_PACKAGE_CACHE`. This script asks `cargo-miden` for that +//! directory, populates it with a nested build when the project has source dependencies, and +//! exports the variable to the compilation of this crate. +//! +//! See . + +use std::{ + env, fs, + path::{Path, PathBuf}, + process::{Command, Output}, +}; + +fn main() { + // Re-evaluate this script when the build mode or the tool selection changes. + println!("cargo:rerun-if-env-changed=MIDENC_PACKAGE_CACHE"); + println!("cargo:rerun-if-env-changed=CARGO_MIDEN"); + // These inputs shape the compiler's package-cache fingerprint. + println!("cargo:rerun-if-env-changed=RUSTFLAGS"); + println!("cargo:rerun-if-env-changed=RUSTUP_TOOLCHAIN"); + + // Inside a midenc-driven build the compiler owns the package cache, macro expansion + // already sees the variable, and a nested build would recurse into this script forever. + if env::var_os("MIDENC_PACKAGE_CACHE").is_some() { + return; + } + + let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); + + // Ask the compiler where this project's package cache lives and which inputs shape it. + let query = run_cargo_miden(&manifest_dir, "package-cache"); + if !query.status.success() { + panic!( + "`cargo miden package-cache` failed ({}).\nInstall cargo-miden (`cargo install \ + cargo-miden`) or point the CARGO_MIDEN environment variable at a cargo-miden \ + binary.\n--- stderr ---\n{}", + query.status, + String::from_utf8_lossy(&query.stderr), + ); + } + + let stdout = + String::from_utf8(query.stdout).expect("cargo miden package-cache output is UTF-8"); + let mut cache_dir = None; + let mut source_dependencies = 0usize; + for line in stdout.lines() { + if let Some(value) = line.strip_prefix("cache-dir=") { + cache_dir = Some(PathBuf::from(value)); + } else if let Some(value) = line.strip_prefix("source-dependencies=") { + source_dependencies = value.parse().expect("source-dependencies is a number"); + } else if let Some(value) = line.strip_prefix("watch=") { + println!("cargo:rerun-if-changed={value}"); + } + } + let cache_dir = cache_dir.expect("cargo miden package-cache printed no cache-dir"); + + if source_dependencies > 0 { + // Populate the cache. Dependency packages publish before the root target compiles, so + // even a failing build (for example, this crate is mid-edit) usually leaves the + // dependency packages usable; the macros report anything that is genuinely missing. + let build = run_cargo_miden(&manifest_dir, "build"); + if !build.status.success() { + println!( + "cargo:warning=`cargo miden build --release` failed ({}); dependency packages \ + may be stale or missing: {}", + build.status, + last_stderr_line(&build.stderr), + ); + } + } + + // The macros treat a missing directory as an empty cache; create it so the exported + // variable always points at a real location. Watching the directory re-runs this script + // when another build rewrites or prunes the cache (cargo re-runs unconditionally while a + // watched path is missing), which keeps the exported path and its packages live. + fs::create_dir_all(&cache_dir).expect("failed to create the Miden package cache directory"); + println!("cargo:rerun-if-changed={}", cache_dir.display()); + println!("cargo:rustc-env=MIDENC_PACKAGE_CACHE={}", cache_dir.display()); +} + +/// Runs `cargo miden --release` for the project in `manifest_dir`. +/// +/// `CARGO_MIDEN` selects a specific `cargo-miden` binary; otherwise the `cargo miden` plugin +/// is resolved through the `cargo` that drives this build. The nested build gets its own +/// cargo target directory: the outer cargo holds a lock on this build's target directory +/// while build scripts run, and a nested build against the same directory would deadlock. +/// `--release` keeps the cache fingerprint identical to a `cargo miden build --release` run +/// by hand, so both share one cache. +fn run_cargo_miden(manifest_dir: &Path, subcommand: &str) -> Output { + let mut command = match env::var_os("CARGO_MIDEN") { + Some(cargo_miden) => Command::new(cargo_miden), + None => Command::new(env::var_os("CARGO").unwrap_or_else(|| "cargo".into())), + }; + command + .args(["miden", subcommand, "--release"]) + .current_dir(manifest_dir) + .env( + "CARGO_TARGET_DIR", + manifest_dir.join("target").join("miden").join("build-script"), + ); + command.output().unwrap_or_else(|err| { + panic!( + "failed to run `cargo miden {subcommand}`: {err}.\nInstall cargo-miden (`cargo \ + install cargo-miden`) or point the CARGO_MIDEN environment variable at a \ + cargo-miden binary." + ) + }) +} + +/// Returns the last non-empty stderr line for a compact warning. +fn last_stderr_line(stderr: &[u8]) -> String { + String::from_utf8_lossy(stderr) + .lines() + .rev() + .find(|line| !line.trim().is_empty()) + .unwrap_or("no error output") + .to_string() +} diff --git a/extra/templates/rust/tx-script/template/build.rs b/extra/templates/rust/tx-script/template/build.rs new file mode 100644 index 000000000..6c3cee049 --- /dev/null +++ b/extra/templates/rust/tx-script/template/build.rs @@ -0,0 +1,121 @@ +//! Populates the Miden package cache for builds that `midenc` does not drive. +//! +//! Plain `cargo check`, `cargo build`, and IDE analysis expand the Miden SDK macros without a +//! surrounding `cargo miden build`. Those macros read compiled dependency packages from the +//! directory named by `MIDENC_PACKAGE_CACHE`. This script asks `cargo-miden` for that +//! directory, populates it with a nested build when the project has source dependencies, and +//! exports the variable to the compilation of this crate. +//! +//! See . + +use std::{ + env, fs, + path::{Path, PathBuf}, + process::{Command, Output}, +}; + +fn main() { + // Re-evaluate this script when the build mode or the tool selection changes. + println!("cargo:rerun-if-env-changed=MIDENC_PACKAGE_CACHE"); + println!("cargo:rerun-if-env-changed=CARGO_MIDEN"); + // These inputs shape the compiler's package-cache fingerprint. + println!("cargo:rerun-if-env-changed=RUSTFLAGS"); + println!("cargo:rerun-if-env-changed=RUSTUP_TOOLCHAIN"); + + // Inside a midenc-driven build the compiler owns the package cache, macro expansion + // already sees the variable, and a nested build would recurse into this script forever. + if env::var_os("MIDENC_PACKAGE_CACHE").is_some() { + return; + } + + let manifest_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap()); + + // Ask the compiler where this project's package cache lives and which inputs shape it. + let query = run_cargo_miden(&manifest_dir, "package-cache"); + if !query.status.success() { + panic!( + "`cargo miden package-cache` failed ({}).\nInstall cargo-miden (`cargo install \ + cargo-miden`) or point the CARGO_MIDEN environment variable at a cargo-miden \ + binary.\n--- stderr ---\n{}", + query.status, + String::from_utf8_lossy(&query.stderr), + ); + } + + let stdout = + String::from_utf8(query.stdout).expect("cargo miden package-cache output is UTF-8"); + let mut cache_dir = None; + let mut source_dependencies = 0usize; + for line in stdout.lines() { + if let Some(value) = line.strip_prefix("cache-dir=") { + cache_dir = Some(PathBuf::from(value)); + } else if let Some(value) = line.strip_prefix("source-dependencies=") { + source_dependencies = value.parse().expect("source-dependencies is a number"); + } else if let Some(value) = line.strip_prefix("watch=") { + println!("cargo:rerun-if-changed={value}"); + } + } + let cache_dir = cache_dir.expect("cargo miden package-cache printed no cache-dir"); + + if source_dependencies > 0 { + // Populate the cache. Dependency packages publish before the root target compiles, so + // even a failing build (for example, this crate is mid-edit) usually leaves the + // dependency packages usable; the macros report anything that is genuinely missing. + let build = run_cargo_miden(&manifest_dir, "build"); + if !build.status.success() { + println!( + "cargo:warning=`cargo miden build --release` failed ({}); dependency packages \ + may be stale or missing: {}", + build.status, + last_stderr_line(&build.stderr), + ); + } + } + + // The macros treat a missing directory as an empty cache; create it so the exported + // variable always points at a real location. Watching the directory re-runs this script + // when another build rewrites or prunes the cache (cargo re-runs unconditionally while a + // watched path is missing), which keeps the exported path and its packages live. + fs::create_dir_all(&cache_dir).expect("failed to create the Miden package cache directory"); + println!("cargo:rerun-if-changed={}", cache_dir.display()); + println!("cargo:rustc-env=MIDENC_PACKAGE_CACHE={}", cache_dir.display()); +} + +/// Runs `cargo miden --release` for the project in `manifest_dir`. +/// +/// `CARGO_MIDEN` selects a specific `cargo-miden` binary; otherwise the `cargo miden` plugin +/// is resolved through the `cargo` that drives this build. The nested build gets its own +/// cargo target directory: the outer cargo holds a lock on this build's target directory +/// while build scripts run, and a nested build against the same directory would deadlock. +/// `--release` keeps the cache fingerprint identical to a `cargo miden build --release` run +/// by hand, so both share one cache. +fn run_cargo_miden(manifest_dir: &Path, subcommand: &str) -> Output { + let mut command = match env::var_os("CARGO_MIDEN") { + Some(cargo_miden) => Command::new(cargo_miden), + None => Command::new(env::var_os("CARGO").unwrap_or_else(|| "cargo".into())), + }; + command + .args(["miden", subcommand, "--release"]) + .current_dir(manifest_dir) + .env( + "CARGO_TARGET_DIR", + manifest_dir.join("target").join("miden").join("build-script"), + ); + command.output().unwrap_or_else(|err| { + panic!( + "failed to run `cargo miden {subcommand}`: {err}.\nInstall cargo-miden (`cargo \ + install cargo-miden`) or point the CARGO_MIDEN environment variable at a \ + cargo-miden binary." + ) + }) +} + +/// Returns the last non-empty stderr line for a compact warning. +fn last_stderr_line(stderr: &[u8]) -> String { + String::from_utf8_lossy(stderr) + .lines() + .rev() + .find(|line| !line.trim().is_empty()) + .unwrap_or("no error output") + .to_string() +} diff --git a/extra/templates/rust/tx-script/template/miden-project.toml b/extra/templates/rust/tx-script/template/miden-project.toml index 0ef9808b2..8dcc17df6 100644 --- a/extra/templates/rust/tx-script/template/miden-project.toml +++ b/extra/templates/rust/tx-script/template/miden-project.toml @@ -5,13 +5,10 @@ version = "0.1.0" [lib] kind = "tx-script" namespace = "miden:base/transaction-script@1.0.0" +path = "src/lib.rs" [dependencies] miden-core = "*" miden-protocol = "*" # TODO: Add your account contract dependencies here add-contract = { path = "../add-contract" } - -[package.metadata.miden.dependencies] -# TODO: Add your account contract WIT dependencies here -add-contract = { wit = "../add-contract/target/generated-wit/" } diff --git a/frontend/wasm/src/component/translator.rs b/frontend/wasm/src/component/translator.rs index e517d8168..4c18b22f2 100644 --- a/frontend/wasm/src/component/translator.rs +++ b/frontend/wasm/src/component/translator.rs @@ -33,7 +33,8 @@ use crate::{ build_ir::build_ir_module, instance::ModuleArgument, module_env::{ - ParsedModule, merge_frontend_metadata, validate_lifted_frontend_metadata_exports, + ParsedModule, collect_package_sections, merge_frontend_metadata, + validate_lifted_frontend_metadata_exports, }, module_translation_state::ModuleTranslationState, types::{EntityIndex, FuncIndex}, @@ -173,21 +174,12 @@ impl<'a> ComponentTranslator<'a> { &self.lifted_export_names, )?; - let account_component_metadata_bytes_vec: Vec> = self - .nested_modules - .into_iter() - .flat_map(|t| t.1.account_component_metadata_bytes.map(|slice| slice.to_vec())) - .collect(); - assert!( - account_component_metadata_bytes_vec.len() <= 1, - "unexpected multiple core Wasm module to have account component metadata section", - ); - let account_component_metadata_bytes = - account_component_metadata_bytes_vec.first().map(ToOwned::to_owned); + let sections = + collect_package_sections(self.nested_modules.iter().map(|(_, module)| module))?; let output = FrontendOutput { component: self.result.component, - account_component_metadata_bytes, + sections, }; Ok(output) } diff --git a/frontend/wasm/src/lib.rs b/frontend/wasm/src/lib.rs index f98b17237..985085704 100644 --- a/frontend/wasm/src/lib.rs +++ b/frontend/wasm/src/lib.rs @@ -27,6 +27,7 @@ use alloc::rc::Rc; use component::build_ir::translate_component; use error::WasmResult; +use midenc_frontend_wasm_metadata::PackageSections; use midenc_hir::{Context, dialects::builtin}; use module::build_ir::translate_module_as_component; use wasmparser::WasmFeatures; @@ -39,8 +40,8 @@ pub use self::{config::*, emit::WatEmit, error::WasmError}; pub struct FrontendOutput { /// The IR component translated from the Wasm pub component: builtin::ComponentRef, - /// The serialized AccountComponentMetadata (name, description, storage layout, etc.) - pub account_component_metadata_bytes: Option>, + /// Out-of-band payloads destined for the compiled package's sections. + pub sections: PackageSections, } /// Translate a valid Wasm core module or Wasm Component Model binary into Miden @@ -53,11 +54,7 @@ pub fn translate( if wasm[4..8] == [0x01, 0x00, 0x00, 0x00] { // Wasm core module // see https://github.com/WebAssembly/component-model/blob/main/design/mvp/Binary.md#component-definitions - let component = translate_module_as_component(wasm, config, context)?; - Ok(FrontendOutput { - component, - account_component_metadata_bytes: None, - }) + translate_module_as_component(wasm, config, context) } else { translate_component(wasm, config, context) } diff --git a/frontend/wasm/src/module/build_ir.rs b/frontend/wasm/src/module/build_ir.rs index b2ed0bb1c..b155fcbf2 100644 --- a/frontend/wasm/src/module/build_ir.rs +++ b/frontend/wasm/src/module/build_ir.rs @@ -5,9 +5,7 @@ use midenc_hir::{ Builder, BuilderExt, Context, FunctionIdent, FxHashMap, Ident, Op, OpBuilder, SymbolPath, Visibility, constants::ConstantData, - dialects::builtin::{ - self, BuiltinOpBuilder, ComponentBuilder, ModuleBuilder, World, WorldBuilder, - }, + dialects::builtin::{BuiltinOpBuilder, ComponentBuilder, ModuleBuilder, World, WorldBuilder}, version::Version, }; use midenc_session::diagnostics::{DiagnosticsHandler, IntoDiagnostic, Severity, SourceSpan}; @@ -18,14 +16,14 @@ use super::{ module_translation_state::ModuleTranslationState, types::ModuleTypesBuilder, }; use crate::{ - WasmTranslationConfig, + FrontendOutput, WasmTranslationConfig, error::WasmResult, intrinsics::Intrinsic, module::{ DefinedFuncIndex, func_translator::FuncTranslator, linker_stubs::{is_unreachable_stub, maybe_lower_linker_stub}, - module_env::{FunctionBodyData, ModuleEnvironment, ParsedModule}, + module_env::{FunctionBodyData, ModuleEnvironment, ParsedModule, collect_package_sections}, types::ir_type, }, }; @@ -40,7 +38,7 @@ pub fn translate_module_as_component( wasm: &[u8], config: &WasmTranslationConfig, context: Rc, -) -> WasmResult { +) -> WasmResult { let mut validator = Validator::new_with_features(crate::supported_features()); let parser = wasmparser::Parser::new(0); let mut module_types_builder = Default::default(); @@ -54,6 +52,7 @@ pub fn translate_module_as_component( if let Some(name_override) = config.override_name.as_ref() { parsed_module.module.set_name_override(name_override.clone()); } + let sections = collect_package_sections(core::iter::once(&parsed_module))?; let module_types = module_types_builder; // If a world wasn't provided to us, create one @@ -82,7 +81,10 @@ pub fn translate_module_as_component( )?; build_ir_module(&mut parsed_module, &module_types, &mut module_state, config, context)?; - Ok(component_ref) + Ok(FrontendOutput { + component: component_ref, + sections, + }) } pub fn build_ir_module( diff --git a/frontend/wasm/src/module/module_env.rs b/frontend/wasm/src/module/module_env.rs index 1d118f5ad..25e08fe39 100644 --- a/frontend/wasm/src/module/module_env.rs +++ b/frontend/wasm/src/module/module_env.rs @@ -4,7 +4,9 @@ use std::path::PathBuf; use cranelift_entity::{PrimaryMap, packed_option::ReservedValue}; use midenc_frontend_wasm_metadata::{ - FrontendMetadata, WASM_FRONTEND_METADATA_CUSTOM_SECTION_NAME, decode_section, + FrontendMetadata, PackageSections, WASM_ACCOUNT_COMPONENT_METADATA_CUSTOM_SECTION_NAME, + WASM_COMPONENT_WIT_CUSTOM_SECTION_NAME, WASM_FRONTEND_METADATA_CUSTOM_SECTION_NAME, + decode_section, }; use midenc_hir::{FxHashMap, FxHashSet, Ident, interner::Symbol}; use midenc_session::diagnostics::{DiagnosticsHandler, IntoDiagnostic, Report, Severity}; @@ -86,10 +88,145 @@ pub struct ParsedModule<'data> { /// The serialized AccountComponentMetadata (name, description, storage layout, etc.) pub account_component_metadata_bytes: Option<&'data [u8]>, + /// The component's public WIT source emitted by the `#[component]` macro. + pub component_wit_bytes: Option<&'data [u8]>, /// Frontend-only component metadata entries emitted by SDK macros (empty when none present). pub component_frontend_metadata: Vec, } +/// Validates that a component WIT custom section holds exactly one top-level WIT package. +/// +/// Linking two `#[component]` implementations into one binary concatenates their identically +/// named custom sections into a single section whose merged text is not valid WIT, and a section +/// without any package declaration cannot be consumed either; name the actual cause here, at the +/// producing crate, instead of surfacing an opaque parse error in dependent crates. +fn validate_component_wit_section( + bytes: &[u8], + diagnostics: &DiagnosticsHandler, +) -> WasmResult<()> { + let Ok(wit) = core::str::from_utf8(bytes) else { + return Err(diagnostics + .diagnostic(Severity::Error) + .with_message(format!( + "wasm error: the '{WASM_COMPONENT_WIT_CUSTOM_SECTION_NAME}' custom section does \ + not contain valid UTF-8 WIT source" + )) + .into_report()); + }; + + match count_top_level_wit_packages(wit) { + 1 => Ok(()), + 0 => Err(diagnostics + .diagnostic(Severity::Error) + .with_message(format!( + "wasm error: the '{WASM_COMPONENT_WIT_CUSTOM_SECTION_NAME}' custom section does \ + not contain a top-level WIT package declaration" + )) + .into_report()), + package_declarations => Err(diagnostics + .diagnostic(Severity::Error) + .with_message(format!( + "wasm error: found {package_declarations} top-level WIT package declarations in \ + the '{WASM_COMPONENT_WIT_CUSTOM_SECTION_NAME}' custom section; a linked binary \ + may contain at most one `#[component]` implementation" + )) + .into_report()), + } +} + +/// Counts top-level `package ;` declarations in WIT source. +/// +/// Comments — including nested `/* */` block comments — are stripped first so commented-out +/// declarations are not counted, and nested package declarations (`package { ... }`, whose +/// `{` precedes any `;`) are excluded. WIT is whitespace-insensitive, so other items may follow +/// the declaration on the same line. A well-formed embedded WIT source contains exactly one +/// top-level declaration; a higher count indicates concatenated sections from multiple component +/// implementations. +fn count_top_level_wit_packages(wit: &str) -> usize { + strip_wit_comments(wit) + .lines() + .map(str::trim) + .filter(|line| { + line.starts_with("package ") + && line.find(';').is_some_and(|semi| !line[..semi].contains('{')) + }) + .count() +} + +/// Strips `//` line comments and nested `/* */` block comments, preserving line structure. +fn strip_wit_comments(wit: &str) -> String { + let mut stripped = String::with_capacity(wit.len()); + let mut chars = wit.chars().peekable(); + let mut block_depth = 0usize; + while let Some(ch) = chars.next() { + match ch { + '/' if block_depth == 0 && chars.peek() == Some(&'/') => { + for next in chars.by_ref() { + if next == '\n' { + stripped.push('\n'); + break; + } + } + } + '/' if chars.peek() == Some(&'*') => { + chars.next(); + block_depth += 1; + } + '*' if block_depth > 0 && chars.peek() == Some(&'/') => { + chars.next(); + block_depth -= 1; + } + '\n' => stripped.push('\n'), + _ if block_depth == 0 => stripped.push(ch), + _ => {} + } + } + stripped +} + +/// Merges the package-section payloads of all core modules that feed one component. +/// +/// Each payload is a singleton of the final package, so at most one module may supply it. +pub(crate) fn collect_package_sections<'a, 'data: 'a>( + modules: impl Iterator>, +) -> WasmResult { + let mut account_component_metadata = None; + let mut component_wit = None; + for module in modules { + merge_section_payload( + &mut account_component_metadata, + module.account_component_metadata_bytes, + WASM_ACCOUNT_COMPONENT_METADATA_CUSTOM_SECTION_NAME, + )?; + merge_section_payload( + &mut component_wit, + module.component_wit_bytes, + WASM_COMPONENT_WIT_CUSTOM_SECTION_NAME, + )?; + } + Ok(PackageSections { + account_component_metadata: account_component_metadata.map(<[u8]>::to_vec), + component_wit: component_wit.map(<[u8]>::to_vec), + }) +} + +/// Records a module's section payload, rejecting a second module supplying the same section. +fn merge_section_payload<'data>( + merged: &mut Option<&'data [u8]>, + payload: Option<&'data [u8]>, + section_name: &str, +) -> WasmResult<()> { + if let Some(payload) = payload + && merged.replace(payload).is_some() + { + return Err(Report::from(WasmError::Unsupported(format!( + "found multiple '{section_name}' custom sections across the component's core modules; \ + only one is allowed per component" + )))); + } + Ok(()) +} + /// Collects the frontend metadata entries emitted by all core modules of one component. /// /// A component's metadata is single-kind by construction: the SDK macros emit either one @@ -310,8 +447,31 @@ impl<'a, 'data> ModuleEnvironment<'a, 'data> { } } Payload::CustomSection(s) if s.name().starts_with(".debug_") => self.dwarf_section(&s), - Payload::CustomSection(s) if s.name() == "rodata,miden_account" => { - self.result.account_component_metadata_bytes = Some(s.data()); + Payload::CustomSection(s) + if s.name() == WASM_ACCOUNT_COMPONENT_METADATA_CUSTOM_SECTION_NAME => + { + if self.result.account_component_metadata_bytes.replace(s.data()).is_some() { + return Err(diagnostics + .diagnostic(Severity::Error) + .with_message(format!( + "wasm error: multiple \ + '{WASM_ACCOUNT_COMPONENT_METADATA_CUSTOM_SECTION_NAME}' custom \ + sections were found; only one is allowed per core Wasm module" + )) + .into_report()); + } + } + Payload::CustomSection(s) if s.name() == WASM_COMPONENT_WIT_CUSTOM_SECTION_NAME => { + validate_component_wit_section(s.data(), diagnostics)?; + if self.result.component_wit_bytes.replace(s.data()).is_some() { + return Err(diagnostics + .diagnostic(Severity::Error) + .with_message(format!( + "wasm error: multiple '{WASM_COMPONENT_WIT_CUSTOM_SECTION_NAME}' \ + custom sections were found; only one is allowed per core Wasm module" + )) + .into_report()); + } } Payload::CustomSection(s) if s.name() == WASM_FRONTEND_METADATA_CUSTOM_SECTION_NAME => { let metadata = decode_section(s.data()).map_err(|err| { diff --git a/frontend/wasm/src/module/module_env/tests.rs b/frontend/wasm/src/module/module_env/tests.rs index b600355f3..fcf07689d 100644 --- a/frontend/wasm/src/module/module_env/tests.rs +++ b/frontend/wasm/src/module/module_env/tests.rs @@ -1,5 +1,104 @@ use super::*; +/// Ensures a single embedded component WIT source is recognized as one package. +#[test] +fn component_wit_counts_a_single_package_declaration() { + let wit = r#"// This file is auto-generated by the `#[component]` macro. +package miden:basic-wallet@0.1.0; + +use miden:base/core-types@1.0.0; + +interface basic-wallet { + receive-asset: func(); +} + +world basic-wallet-world { + export basic-wallet; +} +"#; + + assert_eq!(count_top_level_wit_packages(wit), 1); +} + +/// Ensures concatenated WIT sections (two linked `#[component]` implementations) are detected. +#[test] +fn component_wit_detects_concatenated_package_declarations() { + let wit = r#"package miden:first@0.1.0; + +world first-world { +} +package miden:second@0.1.0; + +world second-world { +} +"#; + + assert_eq!(count_top_level_wit_packages(wit), 2); +} + +/// Ensures nested package declarations and comments are not counted as top-level packages. +#[test] +fn component_wit_ignores_nested_packages_and_comments() { + let wit = r#"// package miden:commented@0.1.0; +package miden:outer@0.1.0; + +package miden:nested@0.1.0 { + interface api { + get: func() -> u64; + } +} +"#; + + assert_eq!(count_top_level_wit_packages(wit), 1); +} + +/// Ensures a one-line package declaration followed by other items on the same line is counted. +/// +/// WIT is whitespace-insensitive; the `{` of a following item must not disqualify the +/// declaration, while a nested `package { ... }` (whose `{` precedes any `;`) still must. +#[test] +fn component_wit_counts_one_line_package_declarations() { + let wit = + "package miden:x@1.0.0; interface api { get: func() -> u64; }\n\nworld w { export api; }\n"; + assert_eq!(count_top_level_wit_packages(wit), 1); + + let nested_one_liner = "package miden:nested@0.1.0 { interface api { get: func() -> u64; } }\n"; + assert_eq!(count_top_level_wit_packages(nested_one_liner), 0); + + let concatenated = "package miden:first@0.1.0; world first-world { }\npackage \ + miden:second@0.1.0; world second-world { }\n"; + assert_eq!(count_top_level_wit_packages(concatenated), 2); +} + +/// Ensures declarations inside (nested) block comments are not counted as top-level packages. +#[test] +fn component_wit_ignores_block_commented_packages() { + let wit = r#"/* legacy: +package miden:old@0.1.0; +/* nested comment with package miden:inner@0.1.0; */ +still commented +*/ +package miden:new@0.1.0; +"#; + + assert_eq!(count_top_level_wit_packages(wit), 1); +} + +/// Documents the detector's boundary: byte-wise gluing without a newline hides the second +/// declaration. The producers therefore wrap every embedded WIT payload in boundary newlines +/// (`normalize_embedded_wit` in the SDK macros), which keeps concatenated sections detectable. +#[test] +fn component_wit_concatenation_needs_boundary_newlines() { + let first_without_trailing_newline = "package miden:first@0.1.0;\n\nworld first-world {\n}"; + let second = "package miden:second@0.1.0;\n\nworld second-world {\n}\n"; + + let glued = format!("{first_without_trailing_newline}{second}"); + assert_eq!(count_top_level_wit_packages(&glued), 1); + + let normalized = format!("\n{first_without_trailing_newline}\n\n{second}\n"); + assert_eq!(count_top_level_wit_packages(&normalized), 2); +} + /// Ensures the frontend metadata entries emitted across a component's core modules are collected /// into one list — in particular the several `#[account_procedure]` entries of an account /// component. diff --git a/midenc-compile/Cargo.toml b/midenc-compile/Cargo.toml index a2930968f..95c03398f 100644 --- a/midenc-compile/Cargo.toml +++ b/midenc-compile/Cargo.toml @@ -39,6 +39,7 @@ miden-assembly-syntax.workspace = true miden-mast-package.workspace = true miden-package-registry.workspace = true midenc-frontend-wasm.workspace = true +midenc-frontend-wasm-metadata.workspace = true midenc-frontend-masm.workspace = true midenc-dialect-scf.workspace = true midenc-dialect-hir.workspace = true diff --git a/midenc-compile/src/pipeline/artifacts.rs b/midenc-compile/src/pipeline/artifacts.rs index b022fc195..6119443b2 100644 --- a/midenc-compile/src/pipeline/artifacts.rs +++ b/midenc-compile/src/pipeline/artifacts.rs @@ -11,17 +11,19 @@ //! frontend invents. The distinction is why [`CompiledArtifact`] carries the longer name; it //! is the *finished* artifact of one whole compilation, not the artifact of a checkpoint. -use alloc::{sync::Arc, vec::Vec}; +use alloc::sync::Arc; use miden_mast_package::Package; use midenc_codegen_masm::MasmComponent; +use midenc_frontend_wasm_metadata::PackageSections; use midenc_hir::dialects::builtin; /// A parsed Miden component, together with everything assembly will need from the parse. pub struct MidenComponent { pub world: builtin::WorldRef, pub component: Option, - pub account_component_metadata_bytes: Option>, + /// Out-of-band payloads destined for the compiled package's sections. + pub sections: PackageSections, #[cfg(feature = "std")] pub source_provenance: miden_assembly::ProjectSourceProvenanceInputs, } @@ -31,7 +33,7 @@ impl Clone for MidenComponent { Self { world: self.world, component: self.component, - account_component_metadata_bytes: self.account_component_metadata_bytes.clone(), + sections: self.sections.clone(), #[cfg(feature = "std")] source_provenance: miden_assembly::ProjectSourceProvenanceInputs { root: miden_assembly::SourceFileProvenance { @@ -55,8 +57,8 @@ impl Clone for MidenComponent { /// The Miden Assembly a component was lowered to, ready to be assembled. pub struct CodegenOutput { pub component: Arc, - /// The serialized AccountComponentMetadata (name, description, storage layout, etc.) - pub account_component_metadata_bytes: Option>, + /// Out-of-band payloads destined for the compiled package's sections. + pub sections: PackageSections, #[cfg(feature = "std")] pub source_provenance: miden_assembly::ProjectSourceProvenanceInputs, } @@ -87,7 +89,7 @@ impl Clone for CodegenOutput { fn clone(&self) -> Self { Self { component: self.component.clone(), - account_component_metadata_bytes: self.account_component_metadata_bytes.clone(), + sections: self.sections.clone(), source_provenance: self.source_provenance(), } } diff --git a/midenc-compile/src/pipeline/assembly.rs b/midenc-compile/src/pipeline/assembly.rs index ebb8e0497..11f23b0bd 100644 --- a/midenc-compile/src/pipeline/assembly.rs +++ b/midenc-compile/src/pipeline/assembly.rs @@ -59,7 +59,7 @@ pub(crate) fn prepare_assembler( pub(crate) fn post_process_package( package: &mut Package, component: &MasmComponent, - account_component_metadata_bytes: Option<&[u8]>, + sections: &midenc_frontend_wasm_metadata::PackageSections, target: &midenc_session::miden_project::Target, registry: &dyn miden_package_registry::PackageRegistryAndProvider, ) -> Result<(), Report> { @@ -67,7 +67,8 @@ pub(crate) fn post_process_package( use miden_mast_package::{Section, SectionId}; use midenc_session::miden_project::TargetType; - attach_account_component_metadata(package, account_component_metadata_bytes); + attach_account_component_metadata(package, sections.account_component_metadata.as_deref()); + attach_component_wit(package, sections.component_wit.as_deref()); extend_rodata_advice_map(package, &component.rodata); // Embed the kernel in note/transaction script packages, if not already embedded @@ -101,6 +102,16 @@ fn attach_account_component_metadata( } } +/// Attach the component's public WIT source to the assembled package. +fn attach_component_wit(package: &mut Package, component_wit_bytes: Option<&[u8]>) { + use miden_mast_package::{Section, SectionId}; + if let Some(bytes) = component_wit_bytes { + let id = SectionId::custom(midenc_frontend_wasm_metadata::PACKAGE_WIT_SECTION_ID) + .expect("the WIT section id must be a valid custom section id"); + package.sections.push(Section::new(id, bytes.to_vec())); + } +} + /// Extend the package advice map with the component's rodata segments. fn extend_rodata_advice_map(package: &mut Package, rodata: &[midenc_codegen_masm::Rodata]) { if rodata.is_empty() { diff --git a/midenc-compile/src/pipeline/backend.rs b/midenc-compile/src/pipeline/backend.rs index ece10f883..1db71fdc8 100644 --- a/midenc-compile/src/pipeline/backend.rs +++ b/midenc-compile/src/pipeline/backend.rs @@ -14,12 +14,13 @@ //! goal (see [`StopFlag`](super::StopFlag)) rather than checked at a phase boundary. A phase //! called directly simply runs. -use alloc::{boxed::Box, rc::Rc, sync::Arc, vec::Vec}; +use alloc::{boxed::Box, rc::Rc, sync::Arc}; use miden_assembly::{ProjectSourceInputs, ProjectSourceProvenanceInputs}; use midenc_codegen_masm::{LegalizeForMasm, MasmComponent, ToMasmComponent}; use midenc_dialect_hir::transforms::{Local2Reg, TransformSpills}; use midenc_dialect_scf::transforms::LiftControlFlowToSCF; +use midenc_frontend_wasm_metadata::PackageSections; use midenc_hir::{ Context, Op, OperationRef, dialects::builtin, @@ -49,8 +50,8 @@ use crate::{CodegenOutput, CompilerResult, MidenComponent}; /// section but a silent miscompile: the lowered code pushes each segment's commitment and /// asks the advice provider for the data behind it, so a package assembled without the /// advice map fails at run time, in the VM, with nothing in the build to point at. -/// - [`account_component_metadata_bytes`](LoweredTarget::account_component_metadata_bytes) -/// becomes the package's account-component metadata section. +/// - [`sections`](LoweredTarget::sections) carries the out-of-band payloads — the serialized +/// account-component metadata and the component's public WIT — that become package sections. /// - [`source_provenance`](LoweredTarget::source_provenance) is what the assembler hashes to /// decide whether a cached build of this target is still current. /// @@ -64,8 +65,8 @@ pub struct LoweredTarget { pub sources: ProjectSourceInputs, /// The lowered component, whose rodata becomes the package's advice map. pub component: Arc, - /// The serialized account-component metadata, if this target has any. - pub account_component_metadata_bytes: Option>, + /// Out-of-band payloads destined for the compiled package's sections. + pub sections: PackageSections, /// The provenance of the sources this target was built from. pub source_provenance: ProjectSourceProvenanceInputs, } @@ -123,7 +124,7 @@ pub fn masm_from_transformed_hir( let context = hir.world.borrow().as_operation().context_rc(); let CodegenOutput { component, - account_component_metadata_bytes, + sections, source_provenance, } = codegen(hir, context)?; let session = cx.session(); @@ -133,7 +134,7 @@ pub fn masm_from_transformed_hir( let lowered = LoweredTarget { sources, component, - account_component_metadata_bytes, + sections, source_provenance, }; // After the checkpoint, so that a run stopping at `masm.lowered` does not reach it: @@ -294,7 +295,7 @@ pub fn codegen(hir: MidenComponent, context: Rc) -> CompilerResult) -> CompilerResult lowered, @@ -361,7 +361,7 @@ impl Frontend for HirFrontend { cx.target_key(), CodegenOutput { component, - account_component_metadata_bytes, + sections, source_provenance, }, ); @@ -392,7 +392,7 @@ impl Frontend for HirFrontend { crate::pipeline::assembly::post_process_package( package, &found.component, - found.account_component_metadata_bytes.as_deref(), + &found.sections, cx.assembly().target, cx.assembly().package_registry, ) @@ -467,7 +467,7 @@ pub fn extract_miden_component_or_bail( Ok(MidenComponent { world, component: None, - account_component_metadata_bytes: None, + sections: Default::default(), source_provenance, }) } else if let Ok(component) = op.try_downcast_op::() { @@ -475,7 +475,7 @@ pub fn extract_miden_component_or_bail( Ok(MidenComponent { world, component: Some(component), - account_component_metadata_bytes: None, + sections: Default::default(), source_provenance, }) } else if let Ok(module) = op.try_downcast_op::() { @@ -485,14 +485,14 @@ pub fn extract_miden_component_or_bail( Ok(MidenComponent { world, component: Some(component), - account_component_metadata_bytes: None, + sections: Default::default(), source_provenance, }) } else if let Ok(world) = parent.try_downcast_op::() { Ok(MidenComponent { world, component: None, - account_component_metadata_bytes: None, + sections: Default::default(), source_provenance, }) } else { @@ -506,7 +506,7 @@ pub fn extract_miden_component_or_bail( Ok(MidenComponent { world, component: None, - account_component_metadata_bytes: None, + sections: Default::default(), source_provenance, }) } diff --git a/midenc-compile/src/pipeline/frontends/rust.rs b/midenc-compile/src/pipeline/frontends/rust.rs index c2206154e..1e7c2f9b1 100644 --- a/midenc-compile/src/pipeline/frontends/rust.rs +++ b/midenc-compile/src/pipeline/frontends/rust.rs @@ -1182,7 +1182,7 @@ impl Frontend for RustProjectFrontend { crate::pipeline::assembly::post_process_package( package, &found.component, - found.account_component_metadata_bytes.as_deref(), + &found.sections, cx.assembly().target, cx.assembly().package_registry, ) @@ -2140,7 +2140,7 @@ mod tests { stack_pointer: None, modules: Vec::new(), }), - account_component_metadata_bytes: None, + sections: Default::default(), source_provenance: ProjectSourceProvenanceInputs { root: SourceFileProvenance { path: std::path::PathBuf::from("seeded.wat").into_boxed_path(), diff --git a/midenc-compile/src/pipeline/frontends/wasm.rs b/midenc-compile/src/pipeline/frontends/wasm.rs index 305836851..af3d2bd7a 100644 --- a/midenc-compile/src/pipeline/frontends/wasm.rs +++ b/midenc-compile/src/pipeline/frontends/wasm.rs @@ -469,7 +469,7 @@ impl WasmFrontend { let FrontendOutput { component, - account_component_metadata_bytes, + sections, } = midenc_frontend_wasm::translate(&source.wasm, &config, context.clone())?; log::debug!( "parsed hir component from wasm bytes with first module name: {}", @@ -481,7 +481,7 @@ impl WasmFrontend { Ok(MidenComponent { world, component: Some(component), - account_component_metadata_bytes, + sections, source_provenance, }) } @@ -526,7 +526,7 @@ impl WasmFrontend { let LoweredTarget { sources, component, - account_component_metadata_bytes, + sections, source_provenance, } = match backend::hir_to_masm(cx, hir)? { Flow::Continue(lowered) => lowered, @@ -536,7 +536,7 @@ impl WasmFrontend { cx.target_key(), CodegenOutput { component, - account_component_metadata_bytes, + sections, source_provenance, }, ); @@ -590,7 +590,7 @@ impl Frontend for WasmFrontend { crate::pipeline::assembly::post_process_package( package, &found.component, - found.account_component_metadata_bytes.as_deref(), + &found.sections, cx.assembly().target, cx.assembly().package_registry, ) diff --git a/midenc-compile/src/pipeline/seed.rs b/midenc-compile/src/pipeline/seed.rs index d82fa3a0d..afed7bedb 100644 --- a/midenc-compile/src/pipeline/seed.rs +++ b/midenc-compile/src/pipeline/seed.rs @@ -393,7 +393,7 @@ impl Frontend for SeedFrontend { let LoweredTarget { sources, component, - account_component_metadata_bytes, + sections, source_provenance, } = match self.resume(cx, hir)? { Flow::Continue(lowered) => lowered, @@ -403,7 +403,7 @@ impl Frontend for SeedFrontend { cx.target_key(), CodegenOutput { component, - account_component_metadata_bytes, + sections, source_provenance, }, ); @@ -437,7 +437,7 @@ impl Frontend for SeedFrontend { crate::pipeline::assembly::post_process_package( package, &found.component, - found.account_component_metadata_bytes.as_deref(), + &found.sections, cx.assembly().target, cx.assembly().package_registry, ) @@ -555,7 +555,7 @@ mod tests { let LoweredTarget { sources, component, - account_component_metadata_bytes, + sections, source_provenance, } = match backend::hir_to_masm(cx, hir)? { Flow::Continue(lowered) => lowered, @@ -565,7 +565,7 @@ mod tests { cx.target_key(), CodegenOutput { component, - account_component_metadata_bytes, + sections, source_provenance, }, ); @@ -604,7 +604,7 @@ mod tests { crate::pipeline::assembly::post_process_package( package, &found.component, - found.account_component_metadata_bytes.as_deref(), + &found.sections, cx.assembly().target, cx.assembly().package_registry, ) diff --git a/midenc-compile/src/pipeline/testing.rs b/midenc-compile/src/pipeline/testing.rs index 5c32513f4..bad2f5c6d 100644 --- a/midenc-compile/src/pipeline/testing.rs +++ b/midenc-compile/src/pipeline/testing.rs @@ -212,7 +212,10 @@ pub(crate) fn component_in_namespace( crate::MidenComponent { world, component: Some(component), - account_component_metadata_bytes: metadata, + sections: midenc_frontend_wasm_metadata::PackageSections { + account_component_metadata: metadata, + component_wit: None, + }, source_provenance: ProjectSourceProvenanceInputs { root: SourceFileProvenance { path: FsPath::new(file!()).to_path_buf().into_boxed_path(), diff --git a/midenc-compile/tests/codegen_legalization.rs b/midenc-compile/tests/codegen_legalization.rs index 853368747..9fadb6f78 100644 --- a/midenc-compile/tests/codegen_legalization.rs +++ b/midenc-compile/tests/codegen_legalization.rs @@ -78,7 +78,7 @@ fn build_test_component( MidenComponent { world, component: Some(component), - account_component_metadata_bytes: None, + sections: Default::default(), source_provenance: ProjectSourceProvenanceInputs { root: SourceFileProvenance { path: Path::new(file!()).to_path_buf().into_boxed_path(), diff --git a/midenc-session/src/lib.rs b/midenc-session/src/lib.rs index 1c6ad8bfb..26f564bba 100644 --- a/midenc-session/src/lib.rs +++ b/midenc-session/src/lib.rs @@ -48,6 +48,8 @@ pub use miden_package_registry; pub use miden_project; use midenc_hir_symbol::Symbol; +#[cfg(feature = "std")] +pub use self::package_cache::PackageCacheBuildInputs; pub use self::{ color::ColorChoice, diagnostics::{DiagnosticsHandler, Emitter, Report, SourceManager}, @@ -395,21 +397,7 @@ impl Session { /// `Cargo.toml` input, and a rebuilt package has no manifest path — so an executable project /// silently got no filesystem cache at all, while a library project of the same shape got one. pub fn filesystem_package_cache_dir(&self) -> Option { - let input = self.input.as_ref()?; - if !matches!(input.file_type(), FileType::Toml) { - return None; - } - let project_dir = input.as_path()?.parent()?; - let project_dir = if project_dir.is_absolute() { - project_dir.to_path_buf() - } else { - self.options.current_dir.join(project_dir) - }; - // Canonicalized because the loaded manifest path this replaces was: the cache directory - // is compared by path across nested builds, so `.`-relative and symlinked spellings of - // one directory must not resolve to two caches. - #[cfg(feature = "std")] - let project_dir = project_dir.canonicalize().unwrap_or(project_dir); + let project_dir = self.package_cache_project_dir()?; #[cfg(feature = "std")] let package_cache_dir = package_cache::package_cache_parent(&project_dir); #[cfg(not(feature = "std"))] @@ -436,6 +424,41 @@ impl Session { } } + /// The project directory whose `target/miden/packages` tree holds this session's cache. + /// + /// `None` unless this session's input is a project locator, mirroring + /// [`Session::filesystem_package_cache_dir`]. + fn package_cache_project_dir(&self) -> Option { + let input = self.input.as_ref()?; + if !matches!(input.file_type(), FileType::Toml) { + return None; + } + let project_dir = input.as_path()?.parent()?; + let project_dir = if project_dir.is_absolute() { + project_dir.to_path_buf() + } else { + self.options.current_dir.join(project_dir) + }; + // Canonicalized because the loaded manifest path this replaces was: the cache directory + // is compared by path across nested builds, so `.`-relative and symlinked spellings of + // one directory must not resolve to two caches. + #[cfg(feature = "std")] + let project_dir = project_dir.canonicalize().unwrap_or(project_dir); + Some(project_dir) + } + + /// Build-script inputs of this session's project package cache. + /// + /// `None` under the same condition as [`Session::filesystem_package_cache_dir`]: the + /// session input must be a project locator. The watch list and the dependency count let a + /// contract build script re-run its nested build exactly when the cache contents could + /// change; `cargo miden package-cache` is the consumer. + #[cfg(feature = "std")] + pub fn package_cache_build_inputs(&self) -> Option { + let project_dir = self.package_cache_project_dir()?; + Some(package_cache::build_script_inputs(&project_dir)) + } + /// Get the [OutputFile] to write the assembled MAST output to pub fn out_file(&self) -> OutputFile { let out_file = self.output_files.output_file(OutputType::Masp, None); diff --git a/midenc-session/src/package_cache.rs b/midenc-session/src/package_cache.rs index 19465cdba..32547e7ce 100644 --- a/midenc-session/src/package_cache.rs +++ b/midenc-session/src/package_cache.rs @@ -360,7 +360,7 @@ pub(crate) fn fingerprint( record_options(&mut transcript, options, inherited_rustflags, inherited_rustup_toolchain); let source_manager = DefaultSourceManager::default(); - let mut manifests = ManifestClosure::new(&mut transcript, &source_manager); + let mut manifests = ManifestClosure::new(&mut transcript, &source_manager, None); manifests.visit_project(project_dir, None); let digest = Blake3_256::hash(transcript.as_bytes()); @@ -373,6 +373,74 @@ pub(crate) fn fingerprint( fingerprint } +/// Build-script inputs of a project's package cache. +/// +/// Contract build scripts consume this through `cargo miden package-cache`: the watch list +/// drives their `cargo:rerun-if-changed` directives, and the dependency count decides whether +/// a nested `cargo miden build` is required at all. +#[derive(Debug, Default)] +pub struct PackageCacheBuildInputs { + /// Manifest, source, and package paths whose changes require a new nested build. + /// + /// Only paths that exist are listed: cargo re-runs a build script unconditionally while a + /// watched path is missing, which would turn every check into a nested build. + pub watch_paths: Vec, + /// The number of direct dependencies whose packages a build compiles into the cache. + /// + /// Registry dependencies and explicit `.masp` file paths are excluded: the assembler + /// resolves the former, and macros read the latter straight from the manifest's path. + pub source_dependency_count: usize, +} + +/// Collects the build-script inputs of the project at `project_dir`. +/// +/// The watch list covers the manifest closure the fingerprint walks: every project's +/// manifests, each dependency project's `src` and `wit` directories, and each preassembled +/// package file. The root project's own sources are deliberately excluded — they do not +/// change dependency packages, and watching them would re-run the nested build on every edit. +pub(crate) fn build_script_inputs(project_dir: &Path) -> PackageCacheBuildInputs { + let source_manager = DefaultSourceManager::default(); + let mut transcript = Transcript::new(); + let mut watch_paths = BTreeSet::new(); + let mut manifests = + ManifestClosure::new(&mut transcript, &source_manager, Some(&mut watch_paths)); + manifests.visit_project(project_dir, None); + + PackageCacheBuildInputs { + watch_paths: watch_paths.into_iter().collect(), + source_dependency_count: source_dependency_count(project_dir, &source_manager), + } +} + +/// Counts the root project's direct dependencies whose packages a build compiles into the cache. +fn source_dependency_count(project_dir: &Path, source_manager: &dyn SourceManager) -> usize { + let Ok(project) = Project::load(project_dir, source_manager) else { + return 0; + }; + project + .package() + .dependencies() + .iter() + .filter(|dependency| match dependency.scheme() { + DependencyVersionScheme::Registry(_) => false, + DependencyVersionScheme::Path { path, .. } + | DependencyVersionScheme::WorkspacePath { path, .. } => { + !is_package_file_uri(path.inner()) + } + DependencyVersionScheme::Workspace { .. } | DependencyVersionScheme::Git { .. } => true, + }) + .count() +} + +/// Returns true when a path dependency's URI names a preassembled `.masp` package file. +/// +/// Extension-classified like the fingerprint walk, before any canonicalization. +fn is_package_file_uri(uri: &miden_project::Uri) -> bool { + Path::new(uri.path()) + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case(Package::EXTENSION)) +} + /// A length-prefixed, domain-separated byte transcript. struct Transcript { bytes: Vec, @@ -559,17 +627,33 @@ struct ManifestClosure<'a> { visited_projects: BTreeSet, visited_packages: BTreeSet, visited_workspace_roots: BTreeSet, + /// Existing filesystem inputs collected for build scripts, when a collector is attached. + watch_paths: Option<&'a mut BTreeSet>, } impl<'a> ManifestClosure<'a> { /// Creates an empty manifest-closure walk. - fn new(transcript: &'a mut Transcript, source_manager: &'a dyn SourceManager) -> Self { + fn new( + transcript: &'a mut Transcript, + source_manager: &'a dyn SourceManager, + watch_paths: Option<&'a mut BTreeSet>, + ) -> Self { Self { transcript, source_manager, visited_projects: BTreeSet::new(), visited_packages: BTreeSet::new(), visited_workspace_roots: BTreeSet::new(), + watch_paths, + } + } + + /// Records a path for build-script watching when collection is active and the path exists. + fn watch(&mut self, path: &Path) { + if let Some(watch_paths) = self.watch_paths.as_deref_mut() + && path.exists() + { + watch_paths.insert(path.to_path_buf()); } } @@ -618,6 +702,15 @@ impl<'a> ManifestClosure<'a> { }; self.transcript.field("project.load", b"succeeded"); + // Dependency sources feed dependency packages, so build scripts watch them. The root + // project's sources do not: its package is not read back by its own macro expansion, + // and watching them would re-run the nested build on every edit. The root is the one + // project visited without an expected dependency name. + if expected_name.is_some() { + self.watch(&project_dir.join("src")); + self.watch(&project_dir.join("wit")); + } + let package = project.package(); let workspace = match &project { Project::WorkspacePackage { workspace, .. } => Some(workspace.as_ref()), @@ -648,6 +741,7 @@ impl<'a> ManifestClosure<'a> { self.transcript.field("manifest.name", name.as_bytes()); match std::fs::read(path) { Ok(bytes) => { + self.watch(path); self.transcript.field("manifest.state", b"present"); self.transcript.field("manifest.bytes", &bytes); } @@ -769,6 +863,7 @@ impl<'a> ManifestClosure<'a> { self.transcript.field("package.file", b"begin"); match std::fs::read(path) { Ok(bytes) => { + self.watch(path); self.transcript.field("package.file.state", b"present"); let digest = Blake3_256::hash(&bytes); self.transcript.field("package.file.digest", digest.as_bytes()); @@ -1119,6 +1214,44 @@ mod tests { fingerprint(options, project_dir, None, None, version, rev) } + #[test] + fn build_script_inputs_watch_dependency_sources_but_not_root_sources() { + let temp = TempDir::new().unwrap(); + let root = temp.path().join("root"); + let dependency = temp.path().join("dependency"); + let prebuilt = temp.path().join("prebuilt.masp"); + write_project( + &root, + "root", + "\n[dependencies]\nregistry-dep = \"*\"\ndependency = { path = \"../dependency\" \ + }\nprebuilt = { path = \"../prebuilt.masp\" }\n", + ); + write_project(&dependency, "dependency", ""); + fs::create_dir_all(root.join("src")).unwrap(); + fs::create_dir_all(dependency.join("src")).unwrap(); + fs::create_dir_all(dependency.join("wit")).unwrap(); + fs::write(&prebuilt, b"package bytes").unwrap(); + + let inputs = build_script_inputs(&root); + + // Paths may carry `..` components from manifest-relative joins; compare by suffix. + let watched = |suffix: &str| inputs.watch_paths.iter().any(|path| path.ends_with(suffix)); + assert!(watched("root/miden-project.toml"), "the root manifests must be watched"); + assert!(watched("root/Cargo.toml"), "the root manifests must be watched"); + assert!(watched("dependency/miden-project.toml")); + assert!(watched("dependency/Cargo.toml")); + assert!(watched("dependency/src"), "dependency sources must be watched"); + assert!(watched("dependency/wit"), "dependency WIT must be watched"); + assert!(watched("prebuilt.masp"), "preassembled packages must be watched"); + assert!(!watched("root/src"), "root sources must not re-run the nested build"); + assert!(!watched("root/wit"), "a nonexistent path must never be watched"); + + assert_eq!( + inputs.source_dependency_count, 1, + "only the source-project dependency counts; registry and `.masp` deps do not" + ); + } + #[test] fn fingerprint_is_stable_for_unchanged_inputs() { let temp = TempDir::new().unwrap(); diff --git a/sdk/base-macros/Cargo.toml b/sdk/base-macros/Cargo.toml index fb6b864b1..88ae061b8 100644 --- a/sdk/base-macros/Cargo.toml +++ b/sdk/base-macros/Cargo.toml @@ -39,6 +39,7 @@ wit-component = { workspace = true, optional = true } [dev-dependencies] # NOTE: Use local paths for dev-only dependency to avoid relying on crates.io during packaging +miden-assembly = { workspace = true, features = ["std"] } miden-protocol = { workspace = true, features = ["std"] } miden-field.workspace = true miden-field-repr.workspace = true diff --git a/sdk/base-macros/src/component_macro/generate_wit.rs b/sdk/base-macros/src/component_macro/generate_wit.rs index ac5b4a76c..cf77f34ba 100644 --- a/sdk/base-macros/src/component_macro/generate_wit.rs +++ b/sdk/base-macros/src/component_macro/generate_wit.rs @@ -1,8 +1,4 @@ -use std::{ - collections::{BTreeSet, HashSet}, - fs, - io::ErrorKind, -}; +use std::collections::{BTreeSet, HashSet}; use proc_macro::Span; use semver::Version; @@ -11,7 +7,6 @@ use syn::spanned::Spanned; use crate::{ component_macro::{CORE_TYPES_PACKAGE, ComponentMethod, MethodReturn, to_kebab_case}, types::{ExportedTypeDef, ExportedTypeKind, ensure_custom_type_defined}, - util::generated_wit_folder, wit_builder::WitBuilder, wit_world::write_world_block, }; @@ -36,69 +31,6 @@ pub(super) struct ComponentWitSpec<'a> { pub(super) exported_types: &'a [ExportedTypeDef], } -/// Writes the generated component WIT to the crate's `wit` directory so that dependent targets can -/// reference it via manifest metadata. -pub fn write_component_wit_file( - call_site_span: Span, - wit_source: &str, - package_name: &str, -) -> Result<(), syn::Error> { - let sanitized_package_name = sanitize_package_name(package_name); - let autogenerated_wit_folder = generated_wit_folder()?; - let wit_path = autogenerated_wit_folder.join(format!("{sanitized_package_name}.wit")); - - for entry in fs::read_dir(&autogenerated_wit_folder).map_err(|err| { - syn::Error::new( - call_site_span.into(), - format!( - "failed to read generated WIT directory '{}': {err}", - autogenerated_wit_folder.display() - ), - ) - })? { - let entry = entry.map_err(|err| { - syn::Error::new( - call_site_span.into(), - format!( - "failed to inspect generated WIT directory '{}': {err}", - autogenerated_wit_folder.display() - ), - ) - })?; - let path = entry.path(); - if path != wit_path && path.extension().and_then(|ext| ext.to_str()) == Some("wit") { - fs::remove_file(&path).map_err(|err| { - syn::Error::new( - call_site_span.into(), - format!("failed to remove stale WIT file '{}': {err}", path.display()), - ) - })?; - } - } - - let needs_write = match fs::read_to_string(&wit_path) { - Ok(existing) => existing != wit_source, - Err(err) if err.kind() == ErrorKind::NotFound => true, - Err(err) => { - return Err(syn::Error::new( - call_site_span.into(), - format!("failed to read existing WIT file '{}': {err}", wit_path.display()), - )); - } - }; - - if needs_write { - fs::write(&wit_path, wit_source).map_err(|err| { - syn::Error::new( - call_site_span.into(), - format!("failed to write WIT file '{}': {err}", wit_path.display()), - ) - })?; - } - - Ok(()) -} - /// Renders the WIT source describing the component interface exported by the `impl` block. pub(super) fn build_component_wit(spec: ComponentWitSpec<'_>) -> Result { let exported_type_names: HashSet = @@ -230,19 +162,3 @@ fn component_method_signature( Ok(signature) } - -fn sanitize_package_name(package_name: &str) -> String { - let mut sanitized = package_name - .chars() - .map(|ch| match ch { - 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_' => ch, - _ => '-', - }) - .collect::(); - - if sanitized.is_empty() { - sanitized.push_str("component"); - } - - sanitized -} diff --git a/sdk/base-macros/src/component_macro/mod.rs b/sdk/base-macros/src/component_macro/mod.rs index aa9432ad3..9dbebdd4c 100644 --- a/sdk/base-macros/src/component_macro/mod.rs +++ b/sdk/base-macros/src/component_macro/mod.rs @@ -6,7 +6,9 @@ use std::{ use heck::{ToKebabCase, ToSnakeCase}; use miden_project::TargetType; use miden_protocol::utils::serde::Serializable; -use midenc_frontend_wasm_metadata::FrontendMetadata; +use midenc_frontend_wasm_metadata::{ + FrontendMetadata, WASM_ACCOUNT_COMPONENT_METADATA_CUSTOM_SECTION_NAME, +}; use proc_macro::Span; use proc_macro2::{Ident, Literal, Span as Span2, TokenStream as TokenStream2}; use quote::{format_ident, quote}; @@ -20,14 +22,14 @@ use crate::{ account_component_metadata::AccountComponentMetadataBuilder, boilerplate::runtime_boilerplate, component_macro::{ - generate_wit::{ComponentWitSpec, build_component_wit, write_component_wit_file}, + generate_wit::{ComponentWitSpec, build_component_wit}, storage::process_storage_fields, }, dependency_ref::{DependencyRef, DependencyRefArgs}, types::{ ExportedTypeDef, ExportedTypeKind, TypeRef, map_type_to_type_ref, registered_export_types, }, - util::generate_frontend_link_section, + util::{generate_frontend_link_section, generate_wit_link_section}, }; mod generate_wit; @@ -689,8 +691,8 @@ fn expand_component_trait_impl( exported_types: &exported_types, })?; // Dependency imports are only needed while generating this crate's bindings. The public WIT - // file stays export-only so downstream crates can depend on this account without also - // materializing all of its transitive FPI dependencies next to the generated WIT. + // stays export-only so downstream crates can depend on this account without also + // materializing all of its transitive FPI dependencies. let public_wit_source = build_component_wit(ComponentWitSpec { component_package: &package_name, component_version: metadata.package.version().inner(), @@ -701,7 +703,9 @@ fn expand_component_trait_impl( methods: &methods, exported_types: &exported_types, })?; - write_component_wit_file(call_site_span, &public_wit_source, &package_name)?; + // The public WIT is embedded into a Wasm custom section, carried by the compiler into the + // Miden package (`.masp`), where dependent crates' macros read it back during expansion. + let wit_link_section = generate_wit_link_section(&public_wit_source); let inline_literal = Literal::string(&inline_wit_source); let interface_path = @@ -745,6 +749,7 @@ fn expand_component_trait_impl( // Use the fully-qualified component type here so the export macro works even when // the impl block was declared through a module-qualified path (e.g. `impl Foo for super::Bar`). self::bindings::export!(#component_type); + #wit_link_section }) } @@ -1391,7 +1396,7 @@ fn account_procedure_frontend_metadata( } } -/// Emits the static metadata blob inside the `rodata,miden_account` link section. +/// Emits the static metadata blob inside the account-component metadata link section. fn generate_link_section(metadata_bytes: &[u8]) -> proc_macro2::TokenStream { let link_section_bytes_len = metadata_bytes.len(); let encoded_bytes_str = Literal::byte_string(metadata_bytes); @@ -1400,7 +1405,7 @@ fn generate_link_section(metadata_bytes: &[u8]) -> proc_macro2::TokenStream { #[unsafe( // to test it in the integration(this crate) tests the section name needs to make mach-o section // specifier happy and to have "segment and section separated by comma" - link_section = "rodata,miden_account" + link_section = #WASM_ACCOUNT_COMPONENT_METADATA_CUSTOM_SECTION_NAME )] #[doc(hidden)] #[allow(clippy::octal_escapes)] diff --git a/sdk/base-macros/src/component_macro/sibling.rs b/sdk/base-macros/src/component_macro/sibling.rs index 368875590..ee5695020 100644 --- a/sdk/base-macros/src/component_macro/sibling.rs +++ b/sdk/base-macros/src/component_macro/sibling.rs @@ -5,8 +5,8 @@ //! expands to a generated Rust trait named after the interface whose default methods call the //! wit-bindgen imports of the sibling's WIT interface. Those imports lower to direct //! cross-context `call`s — the same mechanism note scripts use to call the account — and resolve -//! at link time against the dependency package, so unlike FPI no `.masp` artifact is read at -//! macro expansion time. +//! at link time against the dependency package. During macro expansion the sibling's compiled +//! `.masp` supplies only its embedded WIT; unlike FPI, no procedure roots are read from it. //! //! The generated traits attach to the component's storage struct through an empty blanket impl //! bound on [`NativeAccount`](https://docs.rs/miden), which `#[component_storage]` implements: @@ -59,8 +59,7 @@ pub(super) fn expand_sibling_traits( &inline_wit, SIBLING_BINDINGS_WORLD, &with_entries, - ) - .map_err(|err| augment_missing_sibling_wit(err, &dependencies))?; + )?; let file: syn::File = syn::parse2(bindings)?; let modules = fpi::collect_import_modules(&file.items, &fpi::is_plain_import_function)?; @@ -97,61 +96,6 @@ pub(super) fn expand_sibling_traits( }) } -/// Rewrites a missing-package failure from sibling binding generation into actionable guidance. -/// -/// A sibling reference is selected by reading the dependency's generated WIT (which -/// `wit_world::collect_miden_dependencies` finds under `target/generated-wit`), but the inline -/// `generate!` resolves imports against `manifest_paths::resolve_wit_paths`, which only puts a -/// dependency's WIT on the search path when `[package.metadata.miden.dependencies]..wit` is -/// declared (or a `wit/` directory sits at the dependency root). Without that manifest entry the -/// reference selects successfully and then fails here with a bare wit-parser "package not found". -/// This maps that case to a diagnostic naming the dependencies and the manifest entry to add. -fn augment_missing_sibling_wit(err: syn::Error, dependencies: &[SelectedDependency]) -> syn::Error { - let message = err.to_string(); - if !message.contains("not found") { - return err; - } - - // Only the "package '' not found" portion names the missing package; wit-parser appends a - // `known packages:` list of the packages it *did* resolve. Matching against the whole message - // would blame a resolved sibling that happens to appear in that list, so restrict the search to - // the text before it. Within that, match up to the version boundary (`@`) so a package id - // that is a prefix of another (`miden:counter` vs `miden:counter-contract`) is not over-matched. - let not_found = message.split("known packages").next().unwrap_or(message.as_str()); - let missing = dependencies - .iter() - .filter(|dependency| { - let package = dependency.import().split('/').next().unwrap_or(dependency.import()); - not_found.contains(&format!("{package}@")) - }) - .collect::>(); - if missing.is_empty() { - return err; - } - - let hints = missing - .iter() - .map(|dependency| { - format!( - " [package.metadata.miden.dependencies]\n \"{}\" = {{ wit = \"{}\" }}", - dependency.name, - dependency.root.join("target/generated-wit").display(), - ) - }) - .collect::>() - .join("\n"); - - Error::new( - Span2::call_site(), - format!( - "could not resolve the WIT for sibling component dependencies; their generated WIT is \ - not on the macro's WIT search path. Declare each sibling dependency's generated WIT \ - in `miden-project.toml` so `#[component(...)]` can resolve \ - it:\n{hints}\n\nunderlying error: {message}" - ), - ) -} - /// Rejects a sibling reference whose generated trait would shadow the component trait itself. /// /// The generated `pub trait ` is emitted next to the user's component trait, so a @@ -332,8 +276,10 @@ mod tests { fn test_dependency() -> SelectedDependency { SelectedDependency { - name: "pausable".to_string(), - root: std::path::PathBuf::from("/tmp/pausable"), + package_path: std::path::PathBuf::from( + "/tmp/pausable/target/miden/debug/pausable.masp", + ), + package: crate::test_support::build_package("pausable", None), interface: crate::wit_world::DependencyInterface { name: "pausable".to_string(), import: "miden:pausable/pausable@0.1.0".to_string(), @@ -454,101 +400,4 @@ mod tests { let ident = sibling_bindings_module_ident(&format_ident!("MyComponent")); assert_eq!(ident.to_string(), "__miden_sibling_bindings_my_component"); } - - #[test] - fn augments_missing_wit_package_error_with_manifest_guidance() { - let dependency = SelectedDependency { - name: "counter-contract".to_string(), - root: std::path::PathBuf::from("/tmp/counter"), - interface: crate::wit_world::DependencyInterface { - name: "counter-contract".to_string(), - import: "miden:counter-contract/counter-contract@0.1.0".to_string(), - types: Vec::new(), - }, - }; - let raw = Error::new( - Span2::call_site(), - "package 'miden:counter-contract@0.1.0' not found. known packages: miden:base@1.0.0", - ); - - let message = - augment_missing_sibling_wit(raw, std::slice::from_ref(&dependency)).to_string(); - - assert!(message.contains("[package.metadata.miden.dependencies]"), "message: {message}"); - assert!(message.contains("\"counter-contract\""), "message: {message}"); - assert!(message.contains("target/generated-wit"), "message: {message}"); - // The original wit-parser detail is preserved, not masked. - assert!(message.contains("underlying error"), "message: {message}"); - } - - #[test] - fn leaves_unrelated_generation_errors_untouched() { - let raw = Error::new(Span2::call_site(), "some unrelated macro error"); - let augmented = augment_missing_sibling_wit(raw, std::slice::from_ref(&test_dependency())); - assert_eq!(augmented.to_string(), "some unrelated macro error"); - } - - #[test] - fn does_not_over_match_a_prefix_package_id() { - // A `miden:counter` dependency must not be flagged when the error names the distinct - // `miden:counter-contract` package, even though the former id is a prefix of the latter. - let dependency = SelectedDependency { - name: "counter".to_string(), - root: std::path::PathBuf::from("/tmp/counter"), - interface: crate::wit_world::DependencyInterface { - name: "counter".to_string(), - import: "miden:counter/counter@0.1.0".to_string(), - types: Vec::new(), - }, - }; - let raw = Error::new( - Span2::call_site(), - "package 'miden:counter-contract@0.1.0' not found. known packages: miden:base@1.0.0", - ); - - // No dependency matches the error's package id, so the original error passes through. - let augmented = - augment_missing_sibling_wit(raw, std::slice::from_ref(&dependency)).to_string(); - assert!(augmented.starts_with("package 'miden:counter-contract@0.1.0' not found")); - assert!(!augmented.contains("[package.metadata.miden.dependencies]")); - } - - #[test] - fn does_not_blame_a_sibling_listed_under_known_packages() { - // `first` resolved (it appears in the error's `known packages` list); only `second` is - // missing. The hint must name only `second`, not the healthy `first`. - let first = SelectedDependency { - name: "first-counter".to_string(), - root: std::path::PathBuf::from("/tmp/first"), - interface: crate::wit_world::DependencyInterface { - name: "first-counter".to_string(), - import: "miden:first-counter/first-counter@0.1.0".to_string(), - types: Vec::new(), - }, - }; - let second = SelectedDependency { - name: "second-counter".to_string(), - root: std::path::PathBuf::from("/tmp/second"), - interface: crate::wit_world::DependencyInterface { - name: "second-counter".to_string(), - import: "miden:second-counter/second-counter@0.1.0".to_string(), - types: Vec::new(), - }, - }; - let raw = Error::new( - Span2::call_site(), - "package 'miden:second-counter@0.1.0' not found. known packages: miden:base@1.0.0, \ - miden:first-counter@0.1.0", - ); - - let deps = [first, second]; - let message = augment_missing_sibling_wit(raw, &deps).to_string(); - // The hint wraps the dependency name in quotes; the resolved sibling appears only in the - // verbatim underlying error (unquoted), so a quoted match isolates the hint. - assert!(message.contains("\"second-counter\""), "message: {message}"); - assert!( - !message.contains("\"first-counter\""), - "must not blame the resolved sibling: {message}" - ); - } } diff --git a/sdk/base-macros/src/dependency_package.rs b/sdk/base-macros/src/dependency_package.rs new file mode 100644 index 000000000..168becc03 --- /dev/null +++ b/sdk/base-macros/src/dependency_package.rs @@ -0,0 +1,628 @@ +//! Locating and reading compiled Miden dependency packages (`.masp`) at macro-expansion time. +//! +//! A Miden path dependency is consumed through its compiled package: the `.masp` carries both the +//! dependency's embedded component WIT (read here) and its procedure roots (read by [`crate::fpi`]). +//! +//! Every dependency package comes from the build-owned package cache named by +//! `MIDENC_PACKAGE_CACHE`. A midenc-driven build compiles the dependencies, publishes them into +//! the fingerprinted cache, and exports the variable to its nested cargo builds; the contract +//! `build.rs` does the same for plain `cargo build`/`cargo check` and IDE analysis. The macros +//! never search the filesystem for packages themselves — the one exception is a dependency whose +//! manifest path names a `.masp` file directly, which is read from that explicit location. + +use std::{ + env, fs, + path::{Path, PathBuf}, + sync::Arc, +}; + +use miden_mast_package::{Package, SectionId}; +use midenc_frontend_wasm_metadata::PACKAGE_WIT_SECTION_ID; +use proc_macro2::Span; +use syn::Error; + +/// WIT source extracted from a compiled Miden dependency package. +pub(crate) struct DependencyWitSource { + /// Manifest key used for this dependency. + pub(crate) name: String, + /// Canonical project root or precompiled package path. + pub(crate) root: PathBuf, + /// Path of the compiled `.masp` package the WIT was read from. + pub(crate) package_path: PathBuf, + /// The deserialized package, shared so later consumers (FPI procedure-root extraction) reuse + /// the exact read the package id was verified against. + pub(crate) package: Arc, + /// The component WIT source: embedded in the package, or supplied by the dependency's `wit` + /// manifest key when the package embeds none. + pub(crate) wit: String, +} + +/// Reads the WIT of every Miden path dependency's compiled package. +/// +/// Embedded WIT is authoritative. A dependency whose package embeds none may supply it manually +/// through the `package.metadata.miden.dependencies..wit` key in `miden-project.toml` — the +/// escape hatch for packages produced by toolchains that do not embed WIT. Setting the key for a +/// package that embeds WIT is an error. +pub(crate) fn collect_dependency_wit_sources( + manifest_dir: &Path, + package: &miden_project::Package, +) -> Result, Error> { + let error_span = Span::call_site(); + let mut sources = Vec::new(); + + for dependency in package.dependencies() { + match dependency.scheme() { + miden_project::DependencyVersionScheme::Path { path, .. } => { + let absolute_path = manifest_dir.join(path.path()); + let dependency_root = fs::canonicalize(&absolute_path).map_err(|err| { + Error::new( + error_span, + format!( + "failed to canonicalize dependency '{}' path '{}': {err}", + dependency.name(), + absolute_path.display() + ), + ) + })?; + let resolved = + resolve_dependency_package(dependency.name().as_ref(), &dependency_root)?; + let wit_override = dependency_wit_override(package, dependency.name().as_ref())?; + let wit = match (package_wit(&resolved.package, &resolved.path)?, wit_override) { + (Some(_), Some(_)) => { + return Err(Error::new( + error_span, + format!( + "dependency '{}': package '{}' embeds component WIT, but \ + miden-project.toml also sets \ + package.metadata.miden.dependencies.{}.wit; remove the `wit` key \ + — embedded WIT is authoritative", + dependency.name(), + resolved.path.display(), + dependency.name(), + ), + )); + } + (Some(wit), None) => wit, + (None, Some(wit_override)) => { + read_wit_override(&wit_override, manifest_dir, dependency.name().as_ref())? + } + (None, None) => { + return Err(Error::new( + error_span, + missing_embedded_wit_message( + &resolved.path, + dependency.name().as_ref(), + ), + )); + } + }; + sources.push(DependencyWitSource { + name: dependency.name().to_string(), + root: dependency_root, + package_path: resolved.path, + package: resolved.package, + wit, + }); + } + // Registry dependencies are MASM base libraries (`miden-core`, `miden-protocol`) + // consumed at link time only, so they carry no component WIT. Git and workspace + // schemes are not yet supported at macro expansion time (TODO(pauls)). + _ => continue, + } + } + + Ok(sources) +} + +/// Returns the raw WIT override path from `package.metadata.miden.dependencies..wit`. +fn dependency_wit_override( + package: &miden_project::Package, + dependency_name: &str, +) -> Result, Error> { + let Some(wit_value) = package + .metadata() + .get("miden") + .and_then(|meta| meta.get("dependencies")) + .and_then(|value| value.as_table()) + .and_then(|dependencies| dependencies.get(dependency_name)) + .and_then(|config| config.as_table()) + .and_then(|config| config.get("wit")) + else { + return Ok(None); + }; + let wit_path = wit_value.as_str().ok_or_else(|| { + Error::new( + Span::call_site(), + format!( + "invalid miden-project.toml configuration: expected \ + package.metadata.miden.dependencies.{dependency_name}.wit to be a string" + ), + ) + })?; + Ok(Some(wit_path.to_string())) +} + +/// Reads a dependency's manually provided WIT from a `.wit` file or a directory containing +/// exactly one top-level `.wit` file. +/// +/// The override is validated like embedded WIT: it must resolve against the bundled SDK WIT alone +/// and export an interface, so every macro flow gets the accurate diagnostic at the source. +fn read_wit_override( + wit_path: &str, + manifest_dir: &Path, + dependency_name: &str, +) -> Result { + let error_span = Span::call_site(); + let raw_path = Path::new(wit_path); + let absolute_path = if raw_path.is_absolute() { + raw_path.to_path_buf() + } else { + manifest_dir.join(raw_path) + }; + let path = fs::canonicalize(&absolute_path).map_err(|err| { + Error::new( + error_span, + format!( + "failed to resolve the WIT override for dependency '{dependency_name}' from \ + package.metadata.miden.dependencies.{dependency_name}.wit = '{wit_path}': '{}': \ + {err}", + absolute_path.display() + ), + ) + })?; + + let file = if path.is_dir() { + let mut wit_files = fs::read_dir(&path) + .map_err(|err| { + Error::new( + error_span, + format!( + "failed to read the WIT override directory '{}' for dependency \ + '{dependency_name}': {err}", + path.display() + ), + ) + })? + .collect::, _>>() + .map_err(|err| { + Error::new( + error_span, + format!( + "failed to iterate the WIT override directory '{}' for dependency \ + '{dependency_name}': {err}", + path.display() + ), + ) + })? + .into_iter() + .map(|entry| entry.path()) + .filter(|path| { + path.is_file() && path.extension().is_some_and(|extension| extension == "wit") + }) + .collect::>(); + wit_files.sort(); + match wit_files.len() { + 1 => wit_files.remove(0), + count => { + return Err(Error::new( + error_span, + format!( + "the WIT override directory '{}' for dependency '{dependency_name}' \ + contains {count} `.wit` files; point \ + package.metadata.miden.dependencies.{dependency_name}.wit at a single \ + self-contained `.wit` file", + path.display() + ), + )); + } + } + } else { + path.to_path_buf() + }; + + let wit = fs::read_to_string(&file).map_err(|err| { + Error::new( + error_span, + format!( + "failed to read the WIT override '{}' for dependency '{dependency_name}': {err}", + file.display() + ), + ) + })?; + crate::wit_world::parse_dependency_wit_source(&wit).map_err(|details| { + Error::new( + error_span, + format!( + "invalid WIT override for dependency '{dependency_name}' at '{}': {details}. The \ + override must be self-contained apart from the bundled SDK WIT (`miden:base`) \ + and export an interface.", + file.display() + ), + ) + })?; + Ok(wit) +} + +/// Formats the diagnostic for a dependency package that embeds no WIT and has no override. +fn missing_embedded_wit_message(package_path: &Path, dependency_name: &str) -> String { + format!( + "dependency package '{}' does not embed component WIT (missing package section \ + '{PACKAGE_WIT_SECTION_ID}'); it was likely built with an older Miden toolchain. Rebuild \ + the dependency with the current `cargo miden build`, or provide the WIT manually via \ + package.metadata.miden.dependencies.{dependency_name}.wit in miden-project.toml. For \ + manually authored components (a hand-written `wit/` directory with a bare \ + `miden::generate!()`), the WIT is embedded only when the `wit/` directory contains \ + exactly one `.wit` file that is self-contained and exports an interface.", + package_path.display() + ) +} + +/// Returns the package section id carrying the embedded component WIT. +pub(crate) fn wit_section_id() -> SectionId { + SectionId::custom(PACKAGE_WIT_SECTION_ID) + .expect("the WIT section id must be a valid custom section id") +} + +/// Reads and deserializes a compiled Miden package. +pub(crate) fn read_package(package_path: &Path) -> Result, Error> { + let error_span = Span::call_site(); + let package_bytes = fs::read(package_path).map_err(|err| { + Error::new( + error_span, + format!("failed to read dependency package '{}': {err}", package_path.display()), + ) + })?; + Package::read_from_bytes_unchecked(&package_bytes).map(Arc::new).map_err(|err| { + Error::new( + error_span, + format!( + "failed to deserialize dependency package '{}': {err}. The package may have been \ + produced by a different Miden toolchain version; rebuild the dependency with the \ + current `cargo miden build`.", + package_path.display() + ), + ) + }) +} + +/// Extracts the component WIT embedded in a compiled Miden package. +/// +/// Returns `Ok(None)` when the package has no WIT section; a section that is present but not +/// valid UTF-8 is an error (the package claims its own WIT, so nothing may substitute it). +fn package_wit(package: &Package, package_path: &Path) -> Result, Error> { + let error_span = Span::call_site(); + let wit_section_id = wit_section_id(); + let Some(section) = package.sections.iter().find(|section| section.id == wit_section_id) else { + return Ok(None); + }; + + String::from_utf8(section.data.to_vec()).map(Some).map_err(|err| { + Error::new( + error_span, + format!( + "dependency package '{}' contains an invalid component WIT section (not UTF-8): \ + {err}", + package_path.display() + ), + ) + }) +} + +/// A located, deserialized, and identity-checked dependency package. +pub(crate) struct ResolvedDependencyPackage { + /// Path of the `.masp` file the package was read from. + pub(crate) path: PathBuf, + /// The deserialized package. + pub(crate) package: Arc, +} + +// Manual impl: required by `expect_err` in tests, without requiring `Package: Debug` (which +// would dump the whole MAST forest). +impl core::fmt::Debug for ResolvedDependencyPackage { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("ResolvedDependencyPackage") + .field("path", &self.path) + .finish_non_exhaustive() + } +} + +/// Finds and reads the `.masp` package artifact for the dependency named `name` rooted at `root`. +/// +/// A `root` that is itself a `.masp` file is the manifest's explicit choice and is read from +/// that location (the manifest key need not equal the prebuilt package's id). Every other +/// dependency package is read from the `MIDENC_PACKAGE_CACHE` directory under its package name, +/// trying the hyphen/underscore stem spellings the cache writers use. The cache is fingerprinted +/// by the build inputs and rewritten by every build, so the package found under the dependency's +/// name is trusted as-is; id, version, and digest verification belong to the compiler's project +/// resolution. Without a configured cache the dependency cannot be resolved at all. +pub(crate) fn resolve_dependency_package( + name: &str, + root: &Path, +) -> Result { + if root.is_file() { + return Ok(ResolvedDependencyPackage { + path: root.to_path_buf(), + package: read_package(root)?, + }); + } + + let Some(filesystem_cache_dir) = package_cache_dir() else { + return Err(Error::new(Span::call_site(), missing_package_cache_message(name, root))); + }; + + let package_stems = dependency_package_stems(name, root); + for stem in &package_stems { + let candidate = filesystem_cache_dir.join(format!("{stem}.{}", Package::EXTENSION)); + if candidate.is_file() { + return Ok(ResolvedDependencyPackage { + package: read_package(&candidate)?, + path: candidate, + }); + } + } + + Err(Error::new( + Span::call_site(), + missing_cached_dependency_package_message( + name, + root, + &package_stems, + &filesystem_cache_dir, + ), + )) +} + +/// Returns the package cache directory of this expansion, when one is configured. +fn package_cache_dir() -> Option { + #[cfg(test)] + if let Some(overridden) = TEST_PACKAGE_CACHE_DIR.with(|dir| dir.borrow().clone()) { + return overridden; + } + env::var_os("MIDENC_PACKAGE_CACHE").map(PathBuf::from) +} + +#[cfg(test)] +thread_local! { + /// Test override for the package cache directory. + /// + /// The process environment is global, so parallel unit tests cannot use it to point each + /// expansion at its own fixture cache. `Some(None)` simulates an unset variable. + static TEST_PACKAGE_CACHE_DIR: core::cell::RefCell>> = + const { core::cell::RefCell::new(None) }; +} + +/// Runs `run` with the package cache directory overridden for the current thread. +/// +/// `None` simulates a build without a configured cache. +#[cfg(test)] +pub(crate) fn with_test_package_cache_dir( + cache_dir: Option<&Path>, + run: impl FnOnce() -> R, +) -> R { + TEST_PACKAGE_CACHE_DIR.with(|dir| { + *dir.borrow_mut() = Some(cache_dir.map(Path::to_path_buf)); + }); + let result = run(); + TEST_PACKAGE_CACHE_DIR.with(|dir| { + *dir.borrow_mut() = None; + }); + result +} + +/// Formats the diagnostic for a dependency package missing from the build-owned package cache. +fn missing_cached_dependency_package_message( + name: &str, + root: &Path, + package_stems: &[String], + filesystem_cache_dir: &Path, +) -> String { + let expected_files = package_stems + .iter() + .map(|stem| format!("'{stem}.masp'")) + .collect::>() + .join(", "); + + format!( + "could not find a built `.masp` package for Miden dependency '{name}' (root '{}'). The \ + SDK macros need the dependency package during Rust macro expansion to read its embedded \ + WIT and procedure roots. Expected one of these package names: {expected_files}. Searched \ + MIDENC_PACKAGE_CACHE directory '{}'. The cache is populated by a midenc-driven build \ + (`cargo miden build`), and by the contract `build.rs` for plain cargo builds; rebuild \ + through either so the dependency package is available during macro expansion.", + root.display(), + filesystem_cache_dir.display(), + ) +} + +/// Formats the diagnostic for an expansion without a configured package cache. +fn missing_package_cache_message(name: &str, root: &Path) -> String { + format!( + "the Miden package cache is not configured (MIDENC_PACKAGE_CACHE is not set), so the \ + compiled package for Miden dependency '{name}' (root '{}') cannot be resolved during \ + Rust macro expansion. Build through `cargo miden build`, which exports the variable to \ + its nested builds, or add the contract `build.rs` from a generated template so plain \ + `cargo build`/`cargo check` and IDE analysis populate and export the cache.", + root.display(), + ) +} + +/// Returns likely `.masp` filename stems for a dependency. +fn dependency_package_stems(name: &str, root: &Path) -> Vec { + let mut stems = Vec::new(); + + if let Some(package_name) = dependency_manifest_package_name(root) { + push_dependency_stem(&mut stems, &package_name); + } + + if let Some(name) = name.split([':', '/']).next_back() { + push_dependency_stem(&mut stems, name); + } + + if let Some(name) = root.file_name().and_then(|name| name.to_str()) { + push_dependency_stem(&mut stems, name); + } + + stems +} + +/// Reads the Cargo package name for dependency directories. +fn dependency_manifest_package_name(root: &Path) -> Option { + let manifest_path = root.join("Cargo.toml"); + let manifest = fs::read_to_string(manifest_path).ok()?; + let manifest = manifest.parse::().ok()?; + manifest + .get("package") + .and_then(toml::Value::as_table) + .and_then(|package| package.get("name")) + .and_then(toml::Value::as_str) + .map(ToOwned::to_owned) +} + +/// Adds Miden package stem candidates if they have not already been added. +fn push_dependency_stem(stems: &mut Vec, name: &str) { + if !name.is_empty() && !stems.iter().any(|existing| existing == name) { + stems.push(name.to_owned()); + } + + let normalized = name.replace('-', "_"); + if !normalized.is_empty() && !stems.iter().any(|existing| existing == &normalized) { + stems.push(normalized); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::write_masp_fixture; + + /// Creates a unique fixture root under the temp dir. + fn fixture_root(name: &str) -> PathBuf { + let root = + env::temp_dir().join(format!("midenc-dep-package-{name}-{}", std::process::id())); + std::fs::create_dir_all(&root).unwrap(); + root + } + + #[test] + fn dependency_stem_preserves_package_filename_before_legacy_alias() { + let mut stems = Vec::new(); + + push_dependency_stem(&mut stems, "no-arg-account"); + + assert_eq!(stems, ["no-arg-account", "no_arg_account"]); + } + + #[test] + fn resolves_the_dependency_package_from_the_cache_by_stem() { + let temp_root = fixture_root("cache-hit"); + let cache_dir = temp_root.join("package-cache"); + let dependency_root = temp_root.join("dep-fixture"); + std::fs::create_dir_all(&dependency_root).unwrap(); + // The underscore spelling exercises the stem aliases: the hyphen probe misses first. + let package_path = cache_dir.join("dep_fixture.masp"); + write_masp_fixture(&package_path, "dep-fixture", None); + + let resolved = with_test_package_cache_dir(Some(&cache_dir), || { + resolve_dependency_package("dep-fixture", &dependency_root) + }) + .unwrap(); + + assert_eq!(resolved.path, package_path); + + std::fs::remove_dir_all(temp_root).unwrap(); + } + + #[test] + fn explicit_package_file_dependency_bypasses_the_cache() { + let temp_root = fixture_root("explicit-file"); + let package_path = temp_root.join("prebuilt/renamed.masp"); + write_masp_fixture(&package_path, "dep-fixture", None); + + let resolved = with_test_package_cache_dir(None, || { + resolve_dependency_package("dep-fixture", &package_path) + }) + .unwrap(); + + assert_eq!(resolved.path, package_path); + + std::fs::remove_dir_all(temp_root).unwrap(); + } + + #[test] + fn missing_package_cache_reports_actionable_error() { + let temp_root = fixture_root("no-cache"); + let dependency_root = temp_root.join("dep-fixture"); + std::fs::create_dir_all(&dependency_root).unwrap(); + + let error = with_test_package_cache_dir(None, || { + resolve_dependency_package("dep-fixture", &dependency_root) + }) + .expect_err("resolution without a configured cache must fail"); + let message = error.to_string(); + + assert!( + message.contains("MIDENC_PACKAGE_CACHE is not set"), + "unexpected error: {message}" + ); + assert!(message.contains("cargo miden build"), "unexpected error: {message}"); + assert!(message.contains("build.rs"), "unexpected error: {message}"); + + std::fs::remove_dir_all(temp_root).unwrap(); + } + + #[test] + fn missing_cached_dependency_package_reports_the_cache_contract() { + let temp_root = fixture_root("cache-miss"); + let cache_dir = temp_root.join("package-cache"); + std::fs::create_dir_all(&cache_dir).unwrap(); + let dependency_root = temp_root.join("dep-fixture"); + std::fs::create_dir_all(&dependency_root).unwrap(); + + let error = with_test_package_cache_dir(Some(&cache_dir), || { + resolve_dependency_package("dep-fixture", &dependency_root) + }) + .expect_err("an empty cache must fail resolution"); + let message = error.to_string(); + + assert!( + message.contains("could not find a built `.masp` package"), + "unexpected error: {message}" + ); + assert!(message.contains("'dep-fixture.masp'"), "unexpected error: {message}"); + assert!(message.contains("'dep_fixture.masp'"), "unexpected error: {message}"); + assert!( + message.contains(&cache_dir.display().to_string()), + "unexpected error: {message}" + ); + assert!(message.contains("cargo miden build"), "unexpected error: {message}"); + assert!(message.contains("build.rs"), "unexpected error: {message}"); + assert!(!message.contains("target/miden/"), "unexpected error: {message}"); + + std::fs::remove_dir_all(temp_root).unwrap(); + } + + #[test] + fn corrupt_dependency_package_reports_rebuild_hint() { + let temp_root = fixture_root("corrupt"); + let cache_dir = temp_root.join("package-cache"); + std::fs::create_dir_all(&cache_dir).unwrap(); + let dependency_root = temp_root.join("dep-fixture"); + std::fs::create_dir_all(&dependency_root).unwrap(); + std::fs::write(cache_dir.join("dep-fixture.masp"), b"garbage").unwrap(); + + let error = with_test_package_cache_dir(Some(&cache_dir), || { + resolve_dependency_package("dep-fixture", &dependency_root) + }) + .expect_err("a corrupt dependency package must fail resolution"); + let message = error.to_string(); + + assert!(message.contains("failed to deserialize"), "unexpected error: {message}"); + assert!( + message.contains("different Miden toolchain version"), + "unexpected error: {message}" + ); + assert!(message.contains("cargo miden build"), "unexpected error: {message}"); + + std::fs::remove_dir_all(temp_root).unwrap(); + } +} diff --git a/sdk/base-macros/src/fpi.rs b/sdk/base-macros/src/fpi.rs index 64a325a47..11f8be212 100644 --- a/sdk/base-macros/src/fpi.rs +++ b/sdk/base-macros/src/fpi.rs @@ -2,15 +2,13 @@ use std::{ collections::{HashMap, HashSet}, - env, fmt::Write as _, - fs, - path::{Path, PathBuf}, + path::PathBuf, }; use heck::{ToKebabCase, ToSnakeCase}; use miden_assembly_syntax::ast::{Path as MasmPath, PathComponent}; -use miden_mast_package::{Package, PackageExport}; +use miden_mast_package::PackageExport; use miden_protocol::crypto::hash::blake::Blake3_256; use proc_macro2::{Span, TokenStream as TokenStream2}; use quote::{ToTokens, quote}; @@ -25,8 +23,6 @@ use wit_bindgen_core::wit_parser::{ TypeOwner, WorldId, WorldItem, WorldKey, }; -#[cfg(test)] -use crate::wit_world::DependencyInterface; use crate::{ dependency_ref::DependencyRef, generate::{ @@ -1496,26 +1492,18 @@ fn procedure_root_tokens(root: ProcedureRoot) -> TokenStream2 { quote!(::miden::Word::new([#(#felts),*])) } -/// Loads a single dependency package and extracts exported procedure roots. +/// Extracts exported procedure roots from a selected dependency's package. +/// +/// The package was deserialized (and identity-checked) once during dependency resolution and is +/// reused here rather than re-read from disk. fn load_dependency( dependency: SelectedDependency, trait_ident: syn::Ident, ) -> syn::Result { let import = dependency.import().to_owned(); let module_path = import_module_path(&import); - let package_path = resolve_dependency_package_path(&dependency)?; - let package_bytes = fs::read(&package_path).map_err(|err| { - Error::new( - Span::call_site(), - format!("failed to read dependency package '{}': {err}", package_path.display()), - ) - })?; - let package = Package::read_from_bytes_unchecked(&package_bytes).map_err(|err| { - Error::new( - Span::call_site(), - format!("failed to deserialize dependency package '{}': {err}", package_path.display()), - ) - })?; + let package_path = dependency.package_path.clone(); + let package = dependency.package.clone(); let mut roots = HashMap::new(); for export in package.manifest.exports() { @@ -1577,278 +1565,6 @@ pub(crate) fn import_module_path(import: &str) -> String { .join("::") } -/// Finds the `.masp` package artifact corresponding to a manifest dependency entry. -fn resolve_dependency_package_path(dependency: &SelectedDependency) -> syn::Result { - if dependency.root.is_file() { - return Ok(dependency.root.clone()); - } - - let package_stems = dependency_package_stems(dependency); - if let Some(filesystem_cache_dir) = std::env::var_os("MIDENC_PACKAGE_CACHE") { - let filesystem_cache_dir = PathBuf::from(filesystem_cache_dir); - if let Some(package) = - find_dependency_package_in_dir(&filesystem_cache_dir, &package_stems)? - { - return Ok(package.clone()); - } - - Err(Error::new( - Span::call_site(), - missing_cached_dependency_package_message( - dependency, - &package_stems, - &filesystem_cache_dir, - ), - )) - } else { - let preferred_profile = env::var("PROFILE").unwrap_or_else(|_| "debug".to_string()); - let mut profiles = vec![preferred_profile.clone()]; - if preferred_profile != "release" { - profiles.push("release".to_string()); - } - if preferred_profile != "debug" { - profiles.push("debug".to_string()); - } - let output_dirs = dependency_output_dirs(dependency, &profiles); - for dir in &output_dirs { - if let Some(package) = find_dependency_package_in_dir(dir, &package_stems)? { - return Ok(package.clone()); - } - } - Err(Error::new( - Span::call_site(), - missing_dependency_package_message(dependency, &package_stems, &output_dirs, &profiles), - )) - } -} - -/// Formats the diagnostic for a missing dependency in the build-owned package cache. -fn missing_cached_dependency_package_message( - dependency: &SelectedDependency, - package_stems: &[String], - filesystem_cache_dir: &Path, -) -> String { - let expected_files = package_stems - .iter() - .map(|stem| format!("'{stem}.masp'")) - .collect::>() - .join(", "); - - format!( - "miden::generate! could not find a built `.masp` package for FPI dependency '{}' (import \ - '{}', root '{}'). FPI wrappers need the dependency package during Rust macro expansion \ - to read procedure roots. Expected one of these package names: {expected_files}. Searched \ - MIDENC_PACKAGE_CACHE directory '{}'. This cache is populated by the enclosing \ - midenc-driven build; compile this crate as part of that build so its dependency packages \ - are available during macro expansion.", - dependency.name, - dependency.import(), - dependency.root.display(), - filesystem_cache_dir.display(), - ) -} - -/// Formats the diagnostic emitted when FPI wrapper generation cannot load a dependency package. -fn missing_dependency_package_message( - dependency: &SelectedDependency, - package_stems: &[String], - output_dirs: &[PathBuf], - profiles: &[String], -) -> String { - let searched = output_dirs - .iter() - .map(|dir| format!("'{}'", dir.display())) - .collect::>() - .join(", "); - let expected_files = package_stems - .iter() - .flat_map(|stem| profiles.iter().map(move |profile| format!("{stem}.masp in {profile}"))) - .collect::>() - .join(", "); - let build_hint = dependency_build_hint(dependency); - - format!( - "miden::generate! could not find a built `.masp` package for FPI dependency '{}' (import \ - '{}', root '{}'). FPI wrappers need the dependency package during Rust macro expansion \ - to read procedure roots. Expected one of: {expected_files}. Searched: {searched}. \ - {build_hint}", - dependency.name, - dependency.import(), - dependency.root.display(), - ) -} - -/// Returns a command hint for building a dependency package before generating FPI wrappers. -fn dependency_build_hint(dependency: &SelectedDependency) -> String { - let manifest_path = dependency.root.join("Cargo.toml"); - if manifest_path.is_file() { - format!( - "Build the dependency first with `cargo miden build --manifest-path {} --release`, or \ - persist the compiled package to '{}/target/miden/' before compiling this \ - crate.", - manifest_path.display(), - dependency.root.display(), - ) - } else { - format!( - "Build the dependency first with `cargo miden build`, or persist the compiled package \ - to '{}/target/miden/' before compiling this crate.", - dependency.root.display(), - ) - } -} - -/// Returns candidate output directories where a dependency `.masp` may have been written. -fn dependency_output_dirs(dependency: &SelectedDependency, profiles: &[String]) -> Vec { - let mut dirs = Vec::new(); - - // The dependency root is the most precise location for path dependencies. Prefer it over - // ambient target directories so restored or previously built artifacts cannot shadow the - // package that belongs to the dependency being wrapped. - push_profile_dirs(&mut dirs, dependency.root.join("target"), profiles); - push_manifest_ancestor_target_profile_dirs(&mut dirs, &dependency.root, profiles); - push_ancestor_target_profile_dirs(&mut dirs, &dependency.root, profiles); - - if let Ok(target_dir) = env::var("CARGO_TARGET_DIR") { - push_profile_dirs(&mut dirs, PathBuf::from(target_dir), profiles); - } - - if let Ok(out_dir) = env::var("OUT_DIR") { - for ancestor in Path::new(&out_dir).ancestors() { - push_profile_dirs(&mut dirs, ancestor.to_path_buf(), profiles); - } - } - - if let Ok(current_dir) = env::current_dir() { - push_profile_dirs(&mut dirs, current_dir.join("target"), profiles); - push_manifest_ancestor_target_profile_dirs(&mut dirs, ¤t_dir, profiles); - push_ancestor_target_profile_dirs(&mut dirs, ¤t_dir, profiles); - } - - dirs -} - -/// Adds `target/miden/` directories while preserving insertion order. -fn push_profile_dirs(dirs: &mut Vec, target_root: PathBuf, profiles: &[String]) { - for profile in profiles { - let dir = target_root.join("miden").join(profile); - if !dirs.iter().any(|existing| existing == &dir) { - dirs.push(dir); - } - } -} - -/// Adds `target/miden/` directories found in ancestors of `path`. -fn push_ancestor_target_profile_dirs(dirs: &mut Vec, path: &Path, profiles: &[String]) { - for ancestor in path.ancestors() { - if ancestor.file_name().is_some_and(|name| name == "target") { - push_profile_dirs(dirs, ancestor.to_path_buf(), profiles); - } - } -} - -/// Adds `target/miden/` directories for Cargo manifest ancestors. -fn push_manifest_ancestor_target_profile_dirs( - dirs: &mut Vec, - path: &Path, - profiles: &[String], -) { - for ancestor in path.ancestors() { - if ancestor.join("Cargo.toml").is_file() || ancestor.join("Cargo.lock").is_file() { - push_profile_dirs(dirs, ancestor.join("target"), profiles); - } - } -} - -/// Finds a dependency package in `dir`, preferring filenames that match the package name. -fn find_dependency_package_in_dir( - dir: &Path, - package_stems: &[String], -) -> syn::Result> { - if !dir.is_dir() { - return Ok(None); - } - - let mut packages = fs::read_dir(dir) - .map_err(|err| { - Error::new( - Span::call_site(), - format!("failed to read dependency output directory '{}': {err}", dir.display()), - ) - })? - .collect::, _>>() - .map_err(|err| { - Error::new( - Span::call_site(), - format!("failed to iterate dependency output directory '{}': {err}", dir.display()), - ) - })? - .into_iter() - .map(|entry| entry.path()) - .filter(|path| { - path.extension() - .is_some_and(|extension| extension.eq_ignore_ascii_case(Package::EXTENSION)) - }) - .collect::>(); - packages.sort(); - - for stem in package_stems { - if let Some(package) = packages.iter().find(|path| { - path.file_stem() - .and_then(|value| value.to_str()) - .is_some_and(|file_stem| file_stem == stem) - }) { - return Ok(Some(package.clone())); - } - } - - Ok(None) -} - -/// Returns likely `.masp` filename stems for a dependency. -fn dependency_package_stems(dependency: &SelectedDependency) -> Vec { - let mut stems = Vec::new(); - - if let Some(package_name) = dependency_manifest_package_name(&dependency.root) { - push_dependency_stem(&mut stems, &package_name); - } - - if let Some(name) = dependency.name.split([':', '/']).next_back() { - push_dependency_stem(&mut stems, name); - } - - if let Some(name) = dependency.root.file_name().and_then(|name| name.to_str()) { - push_dependency_stem(&mut stems, name); - } - - stems -} - -/// Reads the Cargo package name for dependency directories. -fn dependency_manifest_package_name(root: &Path) -> Option { - let manifest_path = root.join("Cargo.toml"); - let manifest = fs::read_to_string(manifest_path).ok()?; - let manifest = manifest.parse::().ok()?; - manifest - .get("package") - .and_then(toml::Value::as_table) - .and_then(|package| package.get("name")) - .and_then(toml::Value::as_str) - .map(ToOwned::to_owned) -} - -/// Adds Miden package stem candidates if they have not already been added. -fn push_dependency_stem(stems: &mut Vec, name: &str) { - if !name.is_empty() && !stems.iter().any(|existing| existing == name) { - stems.push(name.to_owned()); - } - - let normalized = name.replace('-', "_"); - if !normalized.is_empty() && !stems.iter().any(|existing| existing == &normalized) { - stems.push(normalized); - } -} - /// Extracts the WIT interface/function key encoded in a package procedure export path. fn procedure_root_key_from_export_path(path: &MasmPath) -> Option { let interface = single_non_root_path_component(path.parent()?)?; @@ -2035,99 +1751,6 @@ interface api { ); } - #[test] - fn dependency_stem_preserves_package_filename_before_legacy_alias() { - let mut stems = Vec::new(); - - push_dependency_stem(&mut stems, "no-arg-account"); - - assert_eq!(stems, ["no-arg-account", "no_arg_account"]); - } - - #[test] - fn dependency_output_dirs_include_manifest_ancestor_targets() { - let temp_root = env::temp_dir() - .join(format!("midenc-fpi-dependency-output-dirs-{}", std::process::id())); - let workspace_root = temp_root.join("workspace"); - let dependency_root = workspace_root.join("tests/fixtures/dependency"); - std::fs::create_dir_all(&dependency_root).unwrap(); - std::fs::write(workspace_root.join("Cargo.lock"), "").unwrap(); - std::fs::write(dependency_root.join("Cargo.toml"), "").unwrap(); - - let mut dirs = Vec::new(); - push_manifest_ancestor_target_profile_dirs( - &mut dirs, - &dependency_root, - &[String::from("release")], - ); - - assert_eq!(dirs[0], dependency_root.join("target/miden/release")); - assert!( - dirs.contains(&workspace_root.join("target/miden/release")), - "expected workspace target in {dirs:?}" - ); - - std::fs::remove_dir_all(temp_root).unwrap(); - } - - #[test] - fn missing_dependency_package_message_explains_macro_time_requirement() { - let temp_root = - env::temp_dir().join(format!("midenc-fpi-missing-package-{}", std::process::id())); - std::fs::create_dir_all(&temp_root).unwrap(); - std::fs::write(temp_root.join("Cargo.toml"), "[package]\nname = \"counter\"\n").unwrap(); - - let dependency = SelectedDependency { - name: "counter".to_string(), - root: temp_root.clone(), - interface: DependencyInterface { - name: "counter".to_string(), - import: "miden:counter/counter@0.0.1".to_string(), - types: Vec::new(), - }, - }; - let profiles = vec!["release".to_string(), "debug".to_string()]; - let stems = vec!["counter".to_string(), "counter_component".to_string()]; - let output_dirs = - vec![temp_root.join("target/miden/release"), temp_root.join("target/miden/debug")]; - - let message = - missing_dependency_package_message(&dependency, &stems, &output_dirs, &profiles); - - assert!(message.contains("miden::generate! could not find a built `.masp` package")); - assert!(message.contains("FPI wrappers need the dependency package during Rust macro")); - assert!(message.contains("counter.masp in release")); - assert!(message.contains("counter_component.masp in debug")); - assert!(message.contains("cargo miden build --manifest-path")); - assert!(message.contains(&temp_root.display().to_string())); - - std::fs::remove_dir_all(temp_root).unwrap(); - } - - #[test] - fn missing_cached_dependency_package_message_describes_the_cache_contract() { - let dependency = SelectedDependency { - name: "counter".to_string(), - root: PathBuf::from("/projects/counter"), - interface: DependencyInterface { - name: "counter".to_string(), - import: "miden:counter/counter@0.0.1".to_string(), - types: Vec::new(), - }, - }; - let stems = vec!["counter".to_string(), "counter_component".to_string()]; - let cache_dir = Path::new("/target/miden/packages/0123456789abcdef"); - - let message = missing_cached_dependency_package_message(&dependency, &stems, cache_dir); - - assert!(message.contains("'counter.masp'")); - assert!(message.contains("'counter_component.masp'")); - assert!(message.contains(&cache_dir.display().to_string())); - assert!(message.contains("populated by the enclosing midenc-driven build")); - assert!(!message.contains(" in release")); - assert!(!message.contains("target/miden/")); - } - #[test] fn procedure_root_key_rejects_nested_non_wit_export_path() { let path = MasmPath::validate( diff --git a/sdk/base-macros/src/generate.rs b/sdk/base-macros/src/generate.rs index 762828c8b..27f586e32 100644 --- a/sdk/base-macros/src/generate.rs +++ b/sdk/base-macros/src/generate.rs @@ -114,16 +114,6 @@ pub(crate) fn expand(input: proc_macro::TokenStream) -> proc_macro::TokenStream match manifest_paths::resolve_wit_paths(resolve_opts) { Ok(config) => { - if config.paths.is_empty() { - return Error::new( - Span::call_site(), - "no WIT dependencies declared under \ - [package.metadata.component.target.dependencies]", - ) - .to_compile_error() - .into(); - } - let inline_world = args .inline .as_ref() @@ -139,6 +129,14 @@ pub(crate) fn expand(input: proc_macro::TokenStream) -> proc_macro::TokenStream .into(); } + // A bare `generate!()` over a local `wit/` directory is the manual component-authoring + // flow, so embed the crate's WIT the same way the `#[component]` macro does — the + // compiled package must carry it for dependent crates' macros to read. + let wit_link_section = match local_wit_link_section(&args, &config) { + Ok(tokens) => tokens, + Err(err) => return err.to_compile_error().into(), + }; + match generate_bindings(&args, &config, world_value.as_deref()) { Ok(raw_bindings) => quote! { // Wrap the bindings in the `bindings` module since `generate!` makes a top level @@ -149,6 +147,7 @@ pub(crate) fn expand(input: proc_macro::TokenStream) -> proc_macro::TokenStream pub mod bindings { #raw_bindings } + #wit_link_section } .into(), Err(err) => err.to_compile_error().into(), @@ -158,6 +157,40 @@ pub(crate) fn expand(input: proc_macro::TokenStream) -> proc_macro::TokenStream } } +/// Embeds the crate's local WIT into the component WIT custom section for bare `generate!()`. +/// +/// Inline invocations come from other SDK macros (which embed the WIT themselves when the crate +/// is a component), and multi-file `wit/` directories cannot be embedded verbatim, so both yield +/// no section. +/// +/// The WIT is embedded verbatim, and consumers resolve it against the bundled SDK WIT alone — so +/// a file that is not self-contained (it imports other packages, e.g. from `wit/deps/`) or that +/// exports no interface is skipped as well: embedding it would produce a package whose WIT no +/// consumer can parse, while skipping routes consumers to the accurate "does not embed component +/// WIT" diagnostic. +fn local_wit_link_section( + args: &GenerateArgs, + config: &manifest_paths::ResolvedWit, +) -> Result { + if args.inline.is_some() { + return Ok(TokenStream2::new()); + } + let Some(local_wit_path) = &config.embeddable_local_wit else { + return Ok(TokenStream2::new()); + }; + + let wit_source = fs::read_to_string(local_wit_path).map_err(|err| { + Error::new( + Span::call_site(), + format!("failed to read WIT file '{}': {err}", local_wit_path.display()), + ) + })?; + if crate::wit_world::parse_dependency_wit_source(&wit_source).is_err() { + return Ok(TokenStream2::new()); + } + Ok(crate::util::generate_wit_link_section(&wit_source)) +} + /// Generates WIT bindings using `wit-bindgen` directly instead of the `generate!` macro. /// /// The `world` parameter specifies which world to generate bindings for. This should already @@ -169,7 +202,7 @@ fn generate_bindings( world: Option<&str>, ) -> Result { generate_bindings_from_sources( - &config.paths, + config, args.inline.as_ref().map(|src| src.value()).as_deref(), world, &args.with_entries, @@ -187,7 +220,7 @@ pub(crate) fn generate_inline_fpi_bindings( with_entries: &[(String, WithOption)], ) -> Result { generate_bindings_from_sources( - &config.paths, + config, Some(inline_source), Some(world), with_entries, @@ -207,7 +240,7 @@ pub(crate) fn generate_inline_import_bindings( with_entries: &[(String, WithOption)], ) -> Result { generate_bindings_from_sources( - &config.paths, + config, Some(inline_source), Some(world), with_entries, @@ -218,14 +251,14 @@ pub(crate) fn generate_inline_import_bindings( /// Generates WIT bindings from resolved source paths and optional inline source. fn generate_bindings_from_sources( - paths: &[String], + config: &manifest_paths::ResolvedWit, inline_source: Option<&str>, world: Option<&str>, with_entries: &[(String, WithOption)], fpi_imports: &[fpi::FpiImportSpec], scope_component_type_sections: bool, ) -> Result { - let mut wit_sources = load_wit_sources(paths, inline_source)?; + let mut wit_sources = load_wit_sources(config, inline_source)?; let world_id = wit_sources .resolve @@ -468,16 +501,21 @@ struct LoadedWitSources { /// The resolved WIT definitions containing all types, interfaces, and worlds. resolve: Resolve, /// Package IDs to use for world selection. When inline source is provided, this contains - /// only the inline package; otherwise it contains all packages from file paths. + /// only the inline package; otherwise it contains the packages of every loaded source (the + /// SDK prelude paths, the dependency packages' embedded WIT, and the local `wit/` directory). packages: Vec, /// File paths that were read during WIT parsing. Used to generate dummy `include_bytes!` /// calls so rustc knows to recompile when these files change. files_read: Vec, } -/// Loads WIT sources from file paths and optionally an inline source. +/// Loads WIT sources from file paths, dependency packages, and optionally an inline source. +/// +/// Sources are pushed in dependency order — SDK prelude paths, then WIT embedded in dependency +/// packages, then the crate's local `wit/` directory — because the resolver eagerly resolves each +/// pushed package against the ones already present, and local WIT may import dependency packages. fn load_wit_sources( - paths: &[String], + config: &manifest_paths::ResolvedWit, inline_source: Option<&str>, ) -> Result { let manifest_dir = env::var("CARGO_MANIFEST_DIR").map_err(|err| { @@ -489,14 +527,15 @@ fn load_wit_sources( let mut packages = Vec::new(); let mut files = Vec::new(); - // Load WIT definitions from file paths. These are always loaded to populate the resolver - // with type definitions that the inline source may depend on. - for path in paths { - let path_buf = PathBuf::from(path); - let absolute = if path_buf.is_absolute() { - path_buf + let push_path = |resolve: &mut Resolve, + packages: &mut Vec, + files: &mut Vec, + path: PathBuf| + -> Result<(), Error> { + let absolute = if path.is_absolute() { + path } else { - manifest_dir.join(path_buf) + manifest_dir.join(path) }; let normalized = fs::canonicalize(&absolute).unwrap_or(absolute); let (pkg, sources) = resolve.push_path(normalized.clone()).map_err(|err| { @@ -507,6 +546,35 @@ fn load_wit_sources( })?; packages.push(pkg); files.extend(sources.paths().map(|p| p.to_owned())); + Ok(()) + }; + + // Load WIT definitions from file paths (the SDK prelude). These are always loaded to + // populate the resolver with type definitions the other sources may depend on. + for path in &config.paths { + push_path(&mut resolve, &mut packages, &mut files, PathBuf::from(path))?; + } + + // Load WIT definitions embedded in the compiled packages of Miden path dependencies. The + // `.masp` paths are recorded like read files so rustc recompiles when a dependency package + // changes. + for source in &config.dependency_sources { + let pkg = resolve.push_str(format!("{}.wit", source.name), &source.wit).map_err(|err| { + Error::new( + Span::call_site(), + format!( + "failed to load WIT embedded in dependency package '{}': {err}", + source.package_path.display() + ), + ) + })?; + packages.push(pkg); + files.push(source.package_path.clone()); + } + + // Load the crate's own `wit/` directory last so it can reference the dependency packages. + if let Some(local_wit_root) = &config.local_wit_root { + push_path(&mut resolve, &mut packages, &mut files, local_wit_root.clone())?; } if let Some(src) = inline_source { @@ -1633,4 +1701,67 @@ interface api { (resolve, world) } + + /// Builds a `ResolvedWit` whose embeddable local WIT is a temp file with the given source. + fn resolved_wit_with_local_file(name: &str, wit: &str) -> manifest_paths::ResolvedWit { + let dir = env::temp_dir() + .join(format!("miden-base-macros-generate-{name}-{}", std::process::id())); + fs::create_dir_all(&dir).expect("local WIT fixture directory must be created"); + let path = dir.join("component.wit"); + fs::write(&path, wit).expect("local WIT fixture must be written"); + manifest_paths::ResolvedWit { + paths: Vec::new(), + dependency_sources: Vec::new(), + local_wit_root: Some(dir), + world: None, + embeddable_local_wit: Some(path), + } + } + + #[test] + fn local_wit_link_section_embeds_self_contained_wit() { + let config = resolved_wit_with_local_file( + "self-contained", + r#"package miden:self-contained@0.1.0; + +interface api { + get: func() -> u64; +} + +world api-world { + export api; +} +"#, + ); + + let tokens = local_wit_link_section(&GenerateArgs::default(), &config).unwrap(); + + assert!(!tokens.is_empty(), "self-contained local WIT must be embedded"); + + fs::remove_dir_all(config.local_wit_root.expect("fixture has a local wit root")) + .expect("temporary fixture directory must be removed"); + } + + #[test] + fn local_wit_link_section_skips_non_self_contained_wit() { + // Consumers resolve embedded WIT against the SDK prelude alone, so a file importing + // another package must not be embedded — its package would be unusable as a dependency + // with a misleading parse error. + let config = resolved_wit_with_local_file( + "importing", + r#"package miden:importing@0.1.0; + +world importer { + import miden:not-embedded/api@0.1.0; +} +"#, + ); + + let tokens = local_wit_link_section(&GenerateArgs::default(), &config).unwrap(); + + assert!(tokens.is_empty(), "non-self-contained local WIT must not be embedded"); + + fs::remove_dir_all(config.local_wit_root.expect("fixture has a local wit root")) + .expect("temporary fixture directory must be removed"); + } } diff --git a/sdk/base-macros/src/lib.rs b/sdk/base-macros/src/lib.rs index 25cc8272a..1a3577b31 100644 --- a/sdk/base-macros/src/lib.rs +++ b/sdk/base-macros/src/lib.rs @@ -67,6 +67,7 @@ extern crate proc_macro; mod account_component_metadata; mod boilerplate; mod component_macro; +mod dependency_package; mod dependency_ref; mod export_type; mod foreign_account; @@ -75,6 +76,8 @@ mod generate; mod manifest_paths; mod note; mod script; +#[cfg(test)] +mod test_support; mod types; mod util; mod wit_builder; diff --git a/sdk/base-macros/src/manifest_paths.rs b/sdk/base-macros/src/manifest_paths.rs index b307cdcf1..cf8d5445f 100644 --- a/sdk/base-macros/src/manifest_paths.rs +++ b/sdk/base-macros/src/manifest_paths.rs @@ -10,6 +10,7 @@ use proc_macro2::Span; use syn::Error; use crate::{ + dependency_package::{DependencyWitSource, collect_dependency_wit_sources}, util::{bundled_wit_folder, strip_line_comment}, wit_world::ProjectPackageMetadata, }; @@ -22,8 +23,19 @@ pub(crate) const SDK_WIT_SOURCE: &str = include_str!("../wit/miden.wit"); /// WIT metadata extracted from the consuming crate. pub(crate) struct ResolvedWit { + /// WIT search paths loaded before any dependency source (the SDK prelude). pub paths: Vec, + /// WIT sources read from the compiled packages of Miden path dependencies. + pub dependency_sources: Vec, + /// The crate's local `wit/` directory, loaded after the dependency sources so its WIT can + /// import the dependency packages. + pub local_wit_root: Option, + /// The `package/world` id detected in the local WIT directory, used for wit-bindgen world + /// selection. pub world: Option, + /// The world-defining local WIT file, present when it is the crate's only WIT file and can + /// therefore be embedded verbatim as the component's public WIT. + pub embeddable_local_wit: Option, } #[derive(Default)] @@ -51,135 +63,32 @@ pub(crate) fn resolve_wit_paths(options: ResolveOptions) -> Result { - let raw_path = Path::new(path.path()).join("wit"); - let absolute = if raw_path.is_absolute() { - raw_path.to_path_buf() - } else { - Path::new(&manifest.manifest_dir).join(raw_path) - }; - let canonical = - fs::canonicalize(&absolute).unwrap_or_else(|_| absolute.clone()); - let Ok(metadata) = fs::metadata(&canonical) else { - continue; - }; - if !metadata.is_dir() { - continue; - } - let Some(path_str) = canonical.to_str() else { - continue; - }; - if !resolved.iter().any(|existing| existing == path_str) { - resolved.push(path_str.to_owned()); - } - } - // TODO(pauls): We should also handle git dependencies at some point - _ => continue, - } - } - - for (dependency, config) in dependencies { - let Some(table) = config.as_table() else { - return Err(Error::new( - Span::call_site(), - format!( - "invalid miden-project.toml configuration: expected \ - metadata.dependencies.{dependency} to be a table" - ), - )); - }; - let Some(wit) = table.get("wit") else { - continue; - }; - let Some(wit_path) = wit.as_str() else { - return Err(Error::new( - Span::call_site(), - format!( - "invalid miden-project.toml configuration: expected \ - metadata.dependencies.{dependency}.wit to be a string" - ), - )); - }; - let raw_path = Path::new(wit_path); - let absolute = if raw_path.is_absolute() { - raw_path.to_path_buf() - } else { - Path::new(&manifest.manifest_dir).join(raw_path) - }; - let canonical = fs::canonicalize(&absolute).unwrap_or_else(|_| absolute.clone()); - let metadata = fs::metadata(&canonical).map_err(|err| { - Error::new( - Span::call_site(), - format!( - "failed to read metadata for dependency '{dependency}' path '{}': {err}", - canonical.display() - ), - ) - })?; - - let search_path = if metadata.is_dir() { - canonical - } else if let Some(parent) = canonical.parent() { - parent.to_path_buf() - } else { - return Err(Error::new( - Span::call_site(), - format!( - "dependency '{dependency}' path '{}' does not have a parent directory", - canonical.display() - ), - )); - }; - - let path_str = search_path.to_str().ok_or_else(|| { - Error::new( - Span::call_site(), - format!("dependency '{dependency}' path contains invalid UTF-8"), - ) - })?; - - if !resolved.iter().any(|existing| existing == path_str) { - resolved.push(path_str.to_owned()); - } - } - } + // Dependency WIT is read from each path dependency's compiled `.masp` package rather than + // from files on disk; the sources are pushed into the wit-bindgen resolver alongside the + // file-based paths collected here. + let dependency_sources = + collect_dependency_wit_sources(&manifest.manifest_dir, &manifest.package)?; - let local_wit_root = Path::new(&manifest.manifest_dir).join("wit"); + let raw_local_wit_root = Path::new(&manifest.manifest_dir).join("wit"); + let mut local_wit_root = None; let mut world = None; + let mut embeddable_local_wit = None; - if local_wit_root.exists() && !options.allow_missing_local_wit { - let local_root = fs::canonicalize(&local_wit_root).unwrap_or(local_wit_root); - let local_root_str = local_root.to_str().ok_or_else(|| { - Error::new( - Span::call_site(), - format!("path '{}' contains invalid UTF-8", local_root.display()), - ) - })?; - if !resolved.iter().any(|existing| existing == local_root_str) { - resolved.push(local_root_str.to_owned()); + if raw_local_wit_root.exists() && !options.allow_missing_local_wit { + let local_root = fs::canonicalize(&raw_local_wit_root).unwrap_or(raw_local_wit_root); + if let Some(local_world) = detect_world(&local_root)? { + world = Some(local_world.world); + embeddable_local_wit = local_world.embeddable_file; } - world = detect_world_name(&local_root)?; + local_wit_root = Some(local_root); } Ok(ResolvedWit { paths: resolved, + dependency_sources, + local_wit_root, world, + embeddable_local_wit, }) } @@ -216,8 +125,17 @@ fn ensure_sdk_wit() -> Result { Ok(fs::canonicalize(&autogenerated_wit_folder).unwrap_or(autogenerated_wit_folder)) } +/// A world detected in the crate's local `wit` directory. +struct LocalWorld { + /// `package/world` id used for wit-bindgen world selection. + world: String, + /// The world-defining WIT file, present when it is the directory's only WIT file and can + /// therefore be embedded verbatim as the component's public WIT. + embeddable_file: Option, +} + /// Scans the component's `wit` directory to find the default world. -fn detect_world_name(wit_root: &Path) -> Result, Error> { +fn detect_world(wit_root: &Path) -> Result, Error> { let mut entries = fs::read_dir(wit_root) .map_err(|err| { Error::new(Span::call_site(), format!("failed to read '{}': {err}", wit_root.display())) @@ -231,20 +149,23 @@ fn detect_world_name(wit_root: &Path) -> Result, Error> { })?; entries.sort_by_key(|entry| entry.file_name()); - for entry in entries { - let path = entry.path(); - if path.file_name().is_some_and(|name| name == "deps") { - continue; - } - if path.is_dir() { - continue; - } - if path.extension().and_then(|ext| ext.to_str()) != Some("wit") { - continue; - } - - if let Some((package, world)) = parse_package_and_world(&path)? { - return Ok(Some(format!("{package}/{world}"))); + let wit_files = entries + .into_iter() + .map(|entry| entry.path()) + .filter(|path| { + !path.file_name().is_some_and(|name| name == "deps") + && !path.is_dir() + && path.extension().and_then(|ext| ext.to_str()) == Some("wit") + }) + .collect::>(); + + for path in &wit_files { + if let Some((package, world)) = parse_package_and_world(path)? { + let embeddable_file = (wit_files.len() == 1).then(|| path.clone()); + return Ok(Some(LocalWorld { + world: format!("{package}/{world}"), + embeddable_file, + })); } } diff --git a/sdk/base-macros/src/test_support.rs b/sdk/base-macros/src/test_support.rs new file mode 100644 index 000000000..0d9d4021e --- /dev/null +++ b/sdk/base-macros/src/test_support.rs @@ -0,0 +1,40 @@ +//! Shared fixtures for base-macros unit tests. + +use std::{fs, path::Path, sync::Arc}; + +use miden_assembly::{Assembler, DefaultSourceManager, ModuleParser, ast::ModuleKind}; +use miden_mast_package::Package; +use miden_protocol::utils::serde::Serializable; + +/// Builds a minimal package fixture with the given package id, optionally embedding `wit` in the +/// WIT section. The fixture version is `0.1.0`. +pub(crate) fn build_package(package_id: &str, wit: Option<&str>) -> Arc { + let source_manager = Arc::new(DefaultSourceManager::default()); + let module = ModuleParser::new(Some(ModuleKind::Library)) + .parse_str( + Some(miden_assembly::Path::new("dep")), + "pub proc callee(a: felt) -> felt\n add.1\nend", + source_manager.clone(), + ) + .expect("fixture module must parse"); + let mut package = Assembler::new(source_manager) + .assemble_library(package_id, module, None::>) + .expect("fixture library must assemble"); + package.version = "0.1.0".parse().expect("fixture version must parse"); + if let Some(wit) = wit { + package.sections.push(miden_mast_package::Section::new( + crate::dependency_package::wit_section_id(), + wit.as_bytes().to_vec(), + )); + } + Arc::from(package) +} + +/// Writes a minimal `.masp` package fixture with the given package id, optionally embedding +/// `wit` in the WIT section. +pub(crate) fn write_masp_fixture(package_path: &Path, package_id: &str, wit: Option<&str>) { + let package = build_package(package_id, wit); + fs::create_dir_all(package_path.parent().expect("package path must have a parent")) + .expect("package directory must be created"); + fs::write(package_path, package.to_bytes()).expect("package fixture must be written"); +} diff --git a/sdk/base-macros/src/util.rs b/sdk/base-macros/src/util.rs index d7e4fbf1e..b5965c396 100644 --- a/sdk/base-macros/src/util.rs +++ b/sdk/base-macros/src/util.rs @@ -1,7 +1,8 @@ use std::{env, fs, path::PathBuf}; use midenc_frontend_wasm_metadata::{ - FrontendMetadata, WASM_FRONTEND_METADATA_CUSTOM_SECTION_NAME, encode_section, + FrontendMetadata, WASM_COMPONENT_WIT_CUSTOM_SECTION_NAME, + WASM_FRONTEND_METADATA_CUSTOM_SECTION_NAME, encode_section, }; use proc_macro2::{Literal, Span, TokenStream as TokenStream2}; use quote::{format_ident, quote}; @@ -10,8 +11,6 @@ use syn::Error; /// Folder within a project that holds bundled WIT files const BUNDLED_WIT_DEPS_DIR: &str = "bundled-miden-wit"; -/// The prefix for the folder within a project that holds autogenerated WIT files -const GENERATED_WIT_DIR: &str = "generated-wit"; /// Rust item name used for the emitted frontend metadata bytes blob. const FRONTEND_METADATA_BYTES_STATIC_IDENT: &str = "__miden_frontend_metadata_bytes"; /// Linker symbol used to reject multiple frontend-marked procedures in one project. @@ -39,21 +38,6 @@ pub fn bundled_wit_folder() -> Result { Ok(wit_deps_dir) } -pub fn generated_wit_folder() -> Result { - let out_dir = target_folder(); - let wit_deps_dir = out_dir.join(GENERATED_WIT_DIR); - fs::create_dir_all(&wit_deps_dir).map_err(|err| { - Error::new( - Span::call_site(), - format!( - "failed to create WIT dependencies directory '{}': {err}", - wit_deps_dir.display() - ), - ) - })?; - Ok(wit_deps_dir) -} - /// Emits frontend-only metadata into the shared component frontend custom section. /// /// A component may need several entries (an optional `#[auth_script]` entry plus one entry per @@ -85,6 +69,49 @@ pub(crate) fn generate_frontend_link_section(entries: &[FrontendMetadata]) -> To } } +/// Embeds the component's public WIT source into the dedicated Wasm custom section. +/// +/// No linker uniqueness guard is emitted: custom-section bytes never reach executable data, so a +/// guard export would be the only runtime cost of WIT embedding. Linking two component +/// implementations concatenates their identically named sections instead, which the Wasm frontend +/// rejects with a dedicated diagnostic when it parses the section. +pub(crate) fn generate_wit_link_section(wit_source: &str) -> TokenStream2 { + let wit_source = normalize_embedded_wit(wit_source); + let wit_bytes = wit_source.as_bytes(); + let wit_len = wit_bytes.len(); + let encoded_bytes = Literal::byte_string(wit_bytes); + + quote! { + #[unsafe( + // Keep the Mach-O-friendly `segment,section` naming scheme used by the other metadata + // sections so the linker preserves these bytes in test and release builds. + link_section = #WASM_COMPONENT_WIT_CUSTOM_SECTION_NAME + )] + #[doc(hidden)] + #[allow(clippy::octal_escapes)] + pub static __MIDEN_COMPONENT_WIT: [u8; #wit_len] = *#encoded_bytes; + } +} + +/// Wraps embedded WIT in newlines so section boundaries stay line boundaries. +/// +/// The linker concatenates identically named custom sections byte-wise; without the padding a +/// blob missing a trailing newline would glue the next blob's `package ...;` declaration onto its +/// last line, hiding the concatenation from the frontend's duplicate-implementation detector +/// (`count_top_level_wit_packages` in `midenc-frontend-wasm`), which scans line-wise. +fn normalize_embedded_wit(wit_source: &str) -> String { + let mut normalized = + String::with_capacity(wit_source.len() + 2 - usize::from(wit_source.starts_with('\n'))); + if !wit_source.starts_with('\n') { + normalized.push('\n'); + } + normalized.push_str(wit_source); + if !wit_source.ends_with('\n') { + normalized.push('\n'); + } + normalized +} + /// Strips line comments starting with `//` from the provided source line. /// /// Returns the portion of the line before the comment, or the entire line if no comment exists. @@ -97,3 +124,21 @@ pub fn strip_line_comment(line: &str) -> &str { None => line, } } + +#[cfg(test)] +mod tests { + use super::normalize_embedded_wit; + + #[test] + fn embedded_wit_gains_boundary_newlines() { + assert_eq!(normalize_embedded_wit("package miden:a@0.1.0;"), "\npackage miden:a@0.1.0;\n"); + } + + #[test] + fn embedded_wit_with_boundary_newlines_is_unchanged() { + assert_eq!( + normalize_embedded_wit("\npackage miden:a@0.1.0;\n"), + "\npackage miden:a@0.1.0;\n" + ); + } +} diff --git a/sdk/base-macros/src/wit_world.rs b/sdk/base-macros/src/wit_world.rs index 9d69181c2..8f90f1c12 100644 --- a/sdk/base-macros/src/wit_world.rs +++ b/sdk/base-macros/src/wit_world.rs @@ -17,7 +17,10 @@ use wit_bindgen_core::wit_parser::{ InterfaceId, PackageId, Resolve, Type as WitType, TypeDefKind, TypeOwner, WorldItem, }; -use crate::wit_builder::WitBuilder; +use crate::{ + dependency_package::{DependencyWitSource, collect_dependency_wit_sources}, + wit_builder::WitBuilder, +}; /// Parsed package metadata from the consuming crate's manifest. pub struct ManifestPackage { @@ -226,7 +229,8 @@ impl ManifestPackage { self.project_kind.as_deref() == Some("authentication-component") } - /// Resolves fully-qualified imports exported by `package.metadata.miden.dependencies`. + /// Resolves fully-qualified imports exported by the compiled packages of the + /// `miden-project.toml` path dependencies. pub(crate) fn collect_miden_dependency_imports( &self, error_span: Span, @@ -260,8 +264,11 @@ impl ManifestPackage { pub(crate) struct MidenDependency { /// Manifest key used for this dependency. pub(crate) name: String, - /// Canonical project root or precompiled package path. - pub(crate) root: PathBuf, + /// Path of the compiled `.masp` package the dependency metadata was read from. + pub(crate) package_path: PathBuf, + /// The deserialized package, shared so FPI procedure-root extraction reuses the exact read + /// the package identity was verified against. + pub(crate) package: Arc, /// Exported WIT interfaces loaded from the dependency metadata. pub(crate) interfaces: Vec, } @@ -273,8 +280,8 @@ impl MidenDependency { .iter() .find(|interface| interface.name == interface_name) .map(|interface| SelectedDependency { - name: self.name.clone(), - root: self.root.clone(), + package_path: self.package_path.clone(), + package: self.package.clone(), interface: interface.clone(), }) } @@ -291,10 +298,10 @@ impl MidenDependency { /// `pkg::Interface` macro argument resolves to one `SelectedDependency`. #[derive(Debug)] pub(crate) struct SelectedDependency { - /// Manifest key used for this dependency. - pub(crate) name: String, - /// Canonical project root or precompiled package path. - pub(crate) root: PathBuf, + /// Path of the compiled `.masp` package the dependency metadata was read from. + pub(crate) package_path: PathBuf, + /// The deserialized package the metadata was read from. + pub(crate) package: Arc, /// The selected exported WIT interface. pub(crate) interface: DependencyInterface, } @@ -358,6 +365,10 @@ pub(crate) fn write_world_block( } /// Collects dependency metadata needed for SDK-generated dependency imports. +/// +/// The dependency's exported interfaces are read from the component WIT embedded in its compiled +/// `.masp` package, which cargo-miden materializes before the dependent crate's macros expand +/// (or from the dependency's `wit` manifest key when the package embeds none). fn collect_miden_dependencies( manifest_dir: &Path, package: &miden_project::Package, @@ -365,204 +376,65 @@ fn collect_miden_dependencies( ) -> Result, syn::Error> { let mut dependencies = Vec::new(); - for dependency in package.dependencies() { - match dependency.scheme() { - miden_project::DependencyVersionScheme::Path { path, .. } => { - let absolute_path = manifest_dir.join(path.path()); - let dependency_root = fs::canonicalize(&absolute_path).map_err(|err| { - syn::Error::new( - error_span, - format!( - "failed to canonicalize dependency '{}' path '{}': {err}", - dependency.name(), - absolute_path.display() - ), - ) - })?; - let wit_root = - dependency_wit_root(manifest_dir, package, dependency, &dependency_root)?; - - let dependency_wit = parse_dependency_wit(&wit_root).map_err(|msg| { - syn::Error::new( - error_span, - dependency_wit_error_message(dependency, &dependency_root, &wit_root, &msg), - ) - })?; - - dependencies.push(MidenDependency { - name: dependency.name().to_string(), - root: dependency_root, - interfaces: dependency_wit.interfaces, - }); - } - _ => continue, - } - } - - dependencies.sort_by(|a, b| a.name.cmp(&b.name)); - - Ok(dependencies) -} - -/// Returns the WIT root for a dependency, honoring explicit Miden project metadata. -fn dependency_wit_root( - manifest_dir: &Path, - package: &miden_project::Package, - dependency: &miden_project::Dependency, - dependency_root: &Path, -) -> Result { - let error_span = Span::call_site(); - if let Some(wit_path) = package - .metadata() - .get("miden") - .and_then(|meta| meta.get("dependencies")) - .and_then(|value| value.as_table()) - .and_then(|dependencies| dependencies.get(dependency.name().as_ref())) - .and_then(|config| config.as_table()) - .and_then(|config| config.get("wit")) - { - let wit_path = wit_path.as_str().ok_or_else(|| { - syn::Error::new( - error_span, - format!( - "invalid miden-project.toml configuration: expected \ - package.metadata.miden.dependencies.{}.wit to be a string", - dependency.name() - ), - ) + for source in collect_dependency_wit_sources(manifest_dir, package)? { + let dependency_wit = parse_dependency_wit_source(&source.wit).map_err(|msg| { + syn::Error::new(error_span, dependency_wit_error_message(&source, &msg)) })?; - return canonicalize_dependency_wit_path(manifest_dir, dependency, wit_path, error_span); - } - - if dependency_root.is_file() { - return Err(syn::Error::new( - error_span, - format!( - "dependency '{}' points to file '{}', which can be used as a `.masp` package \ - artifact but cannot supply dependency WIT metadata; add a matching \ - package.metadata.miden.dependencies entry with a `wit` path to the dependency's \ - generated WIT", - dependency.name(), - dependency_root.display() - ), - )); - } - Ok(dependency_root.to_path_buf()) -} - -/// Resolves an explicit dependency WIT path from manifest metadata. -fn canonicalize_dependency_wit_path( - manifest_dir: &Path, - dependency: &miden_project::Dependency, - path: &str, - error_span: Span, -) -> Result { - let raw_path = Path::new(path); - let absolute_path = if raw_path.is_absolute() { - raw_path.to_path_buf() - } else { - manifest_dir.join(raw_path) - }; - fs::canonicalize(&absolute_path).map_err(|err| { - syn::Error::new( - error_span, - format!( - "failed to resolve dependency WIT metadata for dependency '{}' from \ - package.metadata.miden.dependencies.{}.wit = '{}': '{}': {err}. The SDK macro \ - needs the dependency's generated WIT file or directory during Rust macro \ - expansion; generate the dependency WIT or update the `wit` path.", - dependency.name(), - dependency.name(), - path, - absolute_path.display() - ), - ) - }) -} - -/// Parses the first exported WIT interface exposed by a dependency root or WIT file. -fn parse_dependency_wit(root: &Path) -> Result { - if root.is_file() { - return parse_dependency_wit_path(root)?.ok_or_else(|| { - format!("WIT file '{}' does not contain a world export", root.display()) + dependencies.push(MidenDependency { + name: source.name, + package_path: source.package_path, + package: source.package, + interfaces: dependency_wit.interfaces, }); } - let wit_dirs = dependency_wit_candidate_paths(root); - let mut parser_errors = Vec::new(); - for path in &wit_dirs { - if !path.exists() { - continue; - } - match parse_dependency_wit_path(path) { - Ok(Some(info)) => return Ok(info), - Ok(None) => {} - Err(err) => { - parser_errors.push(format!("'{}': {err}", path.display())); - } - } - } - - if parser_errors.is_empty() { - Err("no WIT world definition found".to_string()) - } else { - Err(format!( - "no WIT world definition found; parser errors: {}", - parser_errors.join("; ") - )) - } -} + dependencies.sort_by(|a, b| a.name.cmp(&b.name)); -/// Returns the WIT paths searched for generated dependency metadata. -fn dependency_wit_candidate_paths(root: &Path) -> Vec { - if root.is_file() { - vec![root.to_path_buf()] - } else { - vec![root.to_path_buf(), root.join("wit"), root.join("target/generated-wit")] - } + Ok(dependencies) } /// Formats the dependency WIT diagnostic emitted by SDK macros. -fn dependency_wit_error_message( - dependency: &miden_project::Dependency, - dependency_root: &Path, - wit_root: &Path, - details: &str, -) -> String { - let candidates = dependency_wit_candidate_paths(wit_root) - .into_iter() - .map(|path| format!("'{}'", path.display())) - .collect::>() - .join(", "); +fn dependency_wit_error_message(source: &DependencyWitSource, details: &str) -> String { + // A "package not found" from wit-parser means the embedded WIT itself references another + // package: the rebuild advice cannot fix that, so name the self-containment requirement. + let guidance = if details.contains("not found") { + "The dependency's embedded WIT references a package that is not embedded alongside it; \ + embedded WIT must be self-contained apart from the bundled SDK WIT (`miden:base`)." + } else { + "The SDK macros read the dependency's component WIT embedded in the `.masp` package during \ + Rust macro expansion to construct dependency imports; rebuild the dependency with the \ + current `cargo miden build`." + }; format!( - "failed to load dependency WIT metadata for dependency '{}' (dependency root '{}', WIT \ - root '{}'): {details}. The SDK macro needs the dependency's generated WIT during Rust \ - macro expansion to construct dependency imports. Searched for WIT world definitions in: \ - {candidates}. Generate the dependency WIT by compiling the dependency component, or set \ - package.metadata.miden.dependencies.{}.wit to the generated WIT file or directory.", - dependency.name(), - dependency_root.display(), - wit_root.display(), - dependency.name() + "failed to load dependency WIT metadata for dependency '{}' (root '{}') from its compiled \ + package '{}': {details}. {guidance}", + source.name, + source.root.display(), + source.package_path.display(), ) } /// WIT metadata extracted from a dependency package. -struct DependencyWit { +#[derive(Debug)] +pub(crate) struct DependencyWit { interfaces: Vec, } -/// Parses one WIT path and returns dependency metadata when it exports at least one interface. -fn parse_dependency_wit_path(path: &Path) -> Result, String> { +/// Parses dependency WIT source and returns metadata for its exported interfaces. +/// +/// The source is resolved against the bundled SDK WIT alone, which makes this doubly useful: it +/// extracts the exported interfaces of a dependency's embedded WIT, and it is the self-containment +/// check a WIT source must pass before being embedded in the first place. +pub(crate) fn parse_dependency_wit_source(wit_source: &str) -> Result { let mut resolve = Resolve::default(); resolve .push_str("miden.wit", crate::manifest_paths::SDK_WIT_SOURCE) .map_err(|err| format!("failed to load bundled Miden WIT: {err}"))?; - let (package_id, _) = resolve - .push_path(path) - .map_err(|err| format!("failed to parse WIT path '{}': {err}", path.display()))?; + let package_id = resolve + .push_str("package.wit", wit_source) + .map_err(|err| format!("failed to parse embedded dependency WIT: {err}"))?; // Skip exported interfaces that cannot be turned into a referenceable import id (anonymous // inline interfaces, or interfaces in an unversioned package) rather than failing the whole @@ -574,10 +446,10 @@ fn parse_dependency_wit_path(path: &Path) -> Result, Strin .filter_map(|interface_id| dependency_interface_metadata(&resolve, interface_id).ok()) .collect::>(); if interfaces.is_empty() { - return Ok(None); + return Err("no exported WIT interface found in the embedded dependency WIT".to_string()); } - Ok(Some(DependencyWit { interfaces })) + Ok(DependencyWit { interfaces }) } /// Returns the interfaces exported by the worlds of the parsed package, in declaration order. @@ -660,7 +532,7 @@ fn is_dependency_interface_type( mod tests { use std::{ fs, - path::PathBuf, + path::{Path, PathBuf}, sync::Arc, time::{SystemTime, UNIX_EPOCH}, }; @@ -670,9 +542,9 @@ mod tests { use proc_macro2::Span; use toml::{Value, value::Table}; - use super::{ProjectPackageMetadata, collect_miden_dependencies, parse_dependency_wit}; + use super::{ProjectPackageMetadata, collect_miden_dependencies, parse_dependency_wit_source}; - // This WIT is generated for the basic wallet example at examples/basic-wallet/target/generated-wit/miden-basic-wallet.wit + // This WIT matches what the `#[component]` macro embeds into the basic wallet example package. const BASIC_WALLET_GENERATED_WIT: &str = r#"// This file is auto-generated by the `#[component]` macro. // Do not edit this file manually. @@ -708,16 +580,36 @@ world basic-wallet-world { format!("{pid}-{nanos}-{count}") } - fn basic_wallet_fixture_root() -> PathBuf { + /// Writes a minimal `.masp` package fixture named after the fixture dependency. + fn write_masp_fixture(package_path: &Path, wit: Option<&str>) { + crate::test_support::write_masp_fixture(package_path, "wit-world-fixture-dep", wit); + } + + /// Creates a dependency project root with a compiled package in the fixture package cache. + fn dependency_fixture_root() -> PathBuf { let unique = unique_fixture_suffix(); let root = std::env::temp_dir().join(format!("miden-base-macros-wit-world-{unique}")); - let generated_wit_dir = root.join("target/generated-wit"); - fs::create_dir_all(&generated_wit_dir).expect("generated-wit directory must be created"); - fs::write(generated_wit_dir.join("miden-basic-wallet.wit"), BASIC_WALLET_GENERATED_WIT) - .expect("basic wallet fixture must be written"); + write_masp_fixture( + &root.join("package-cache/wit_world_fixture_dep.masp"), + Some(BASIC_WALLET_GENERATED_WIT), + ); root } + /// Collects dependencies with the fixture's `package-cache` directory active. + /// + /// The macros read dependency packages only from the `MIDENC_PACKAGE_CACHE` directory; the + /// thread-local test override stands in for the process environment. + fn collect_with_cache( + fixture_root: &Path, + package: &miden_project::Package, + ) -> Result, syn::Error> { + crate::dependency_package::with_test_package_cache_dir( + Some(&fixture_root.join("package-cache")), + || collect_miden_dependencies(fixture_root, package, proc_macro2::Span::call_site()), + ) + } + fn empty_fixture_root() -> PathBuf { let unique = unique_fixture_suffix(); let root = std::env::temp_dir().join(format!("miden-base-macros-empty-wit-world-{unique}")); @@ -725,10 +617,7 @@ world basic-wallet-world { root } - fn package_with_dependency( - package_path: PathBuf, - wit_path: Option, - ) -> Box { + fn package_with_dependency(package_path: PathBuf) -> Box { let target = miden_project::Target::new( miden_project::TargetType::Library, "default", @@ -736,24 +625,26 @@ world basic-wallet-world { Uri::new("lib/src.rs"), ); let dependency = miden_project::Dependency::new( - MidenSpan::unknown(Arc::::from("basic-wallet")), + MidenSpan::unknown(Arc::::from("wit-world-fixture-dep")), miden_project::DependencyVersionScheme::Path { path: MidenSpan::unknown(miden_project::Uri::new(package_path.to_string_lossy())), version: None, }, miden_project::Linkage::Dynamic, ); - let package = - miden_project::Package::new("consumer", target).with_dependencies([dependency]); - - if let Some(wit_path) = wit_path { - package.with_metadata(miden_metadata_dependencies( - "basic-wallet", - wit_path.to_string_lossy().as_ref(), - )) - } else { - package - } + miden_project::Package::new("consumer", target).with_dependencies([dependency]) + } + + /// Like [`package_with_dependency`], but with the dependency's WIT override key set to + /// `wit_path` (`package.metadata.miden.dependencies.wit-world-fixture-dep.wit`). + fn package_with_dependency_and_wit_key( + package_path: PathBuf, + wit_path: &Path, + ) -> Box { + package_with_dependency(package_path).with_metadata(miden_metadata_dependencies( + "wit-world-fixture-dep", + wit_path.to_string_lossy().as_ref(), + )) } fn miden_metadata_dependencies( @@ -828,9 +719,6 @@ path = "src/lib.rs" #[test] fn parses_exported_interface_type_names_with_wit_parser() { - let fixture_root = empty_fixture_root(); - let wit_dir = fixture_root.join("target/generated-wit"); - fs::create_dir_all(&wit_dir).expect("generated-wit directory must be created"); let wit = r#" package miden:typed-account@0.0.1; @@ -855,9 +743,8 @@ world typed-account-world { export typed-account; } "#; - fs::write(wit_dir.join("typed-account.wit"), wit).expect("typed WIT fixture"); - let dependency_wit = parse_dependency_wit(&fixture_root).unwrap(); + let dependency_wit = parse_dependency_wit_source(wit).unwrap(); assert_eq!(dependency_wit.interfaces.len(), 1); assert_eq!(dependency_wit.interfaces[0].name, "typed-account"); @@ -866,15 +753,10 @@ world typed-account-world { dependency_wit.interfaces[0].types, vec!["mixed-scalar-record", "options", "amount"] ); - - fs::remove_dir_all(fixture_root).expect("temporary fixture directory must be removed"); } #[test] fn parses_all_exported_interfaces_and_selects_by_name() { - let fixture_root = empty_fixture_root(); - let wit_dir = fixture_root.join("target/generated-wit"); - fs::create_dir_all(&wit_dir).expect("generated-wit directory must be created"); let wit = r#" package miden:multi-account@0.0.1; @@ -891,12 +773,12 @@ world multi-account-world { export second-api; } "#; - fs::write(wit_dir.join("multi-account.wit"), wit).expect("multi-interface WIT fixture"); - let dependency_wit = parse_dependency_wit(&fixture_root).unwrap(); + let dependency_wit = parse_dependency_wit_source(wit).unwrap(); let dependency = super::MidenDependency { name: "multi-account".to_string(), - root: fixture_root.clone(), + package_path: PathBuf::from("/tmp/multi-account/target/miden/debug/multi_account.masp"), + package: crate::test_support::build_package("multi-account", None), interfaces: dependency_wit.interfaces, }; @@ -904,18 +786,13 @@ world multi-account-world { let selected = dependency.select("second-api").expect("interface must be selectable"); assert_eq!(selected.import(), "miden:multi-account/second-api@0.0.1"); - assert_eq!(selected.name, "multi-account"); + assert_eq!(selected.package_path, dependency.package_path); assert!(dependency.select("missing-api").is_none()); - - fs::remove_dir_all(fixture_root).expect("temporary fixture directory must be removed"); } #[test] fn skips_anonymous_exported_interfaces() { - let fixture_root = empty_fixture_root(); - let wit_dir = fixture_root.join("target/generated-wit"); - fs::create_dir_all(&wit_dir).expect("generated-wit directory must be created"); // The world exports a named interface plus an inline (anonymous) one; the anonymous export // must be skipped rather than failing the whole dependency parse. let wit = r#" @@ -932,62 +809,69 @@ world mixed-export-world { } } "#; - fs::write(wit_dir.join("mixed-export.wit"), wit).expect("mixed-export WIT fixture"); - let dependency_wit = parse_dependency_wit(&fixture_root).unwrap(); + let dependency_wit = parse_dependency_wit_source(wit).unwrap(); assert_eq!(dependency_wit.interfaces.len(), 1); assert_eq!(dependency_wit.interfaces[0].name, "named-api"); - - fs::remove_dir_all(fixture_root).expect("temporary fixture directory must be removed"); } #[test] - fn parses_generated_component_world_from_dependency_root() { - let fixture_root = basic_wallet_fixture_root(); - let dependency_wit = parse_dependency_wit(&fixture_root).unwrap(); + fn dependency_wit_without_exported_interfaces_reports_error() { + // An embedded WIT whose world exports nothing referenceable must produce a parse error + // rather than an empty dependency. + let wit = r#" +package miden:empty-export@0.0.1; - assert_eq!(dependency_wit.interfaces.len(), 1); - assert_eq!(dependency_wit.interfaces[0].import, "miden:basic-wallet/basic-wallet@0.1.0"); - assert!(dependency_wit.interfaces[0].types.is_empty()); +world empty-export-world { +} +"#; - fs::remove_dir_all(fixture_root).expect("temporary fixture directory must be removed"); + let err = parse_dependency_wit_source(wit).unwrap_err(); + + assert!(err.contains("no exported WIT interface found"), "unexpected error: {err}"); } #[test] - fn parses_direct_generated_wit_directory() { - let fixture_root = basic_wallet_fixture_root(); - let dependency_wit = - parse_dependency_wit(&fixture_root.join("target/generated-wit")).unwrap(); + fn collects_dependency_interfaces_from_compiled_package() { + let fixture_root = dependency_fixture_root(); + let dependency_root = fixture_root.clone(); - assert_eq!(dependency_wit.interfaces.len(), 1); - assert_eq!(dependency_wit.interfaces[0].import, "miden:basic-wallet/basic-wallet@0.1.0"); - assert!(dependency_wit.interfaces[0].types.is_empty()); + let package = package_with_dependency(dependency_root.clone()); + + let dependencies = collect_with_cache(&fixture_root, &package).unwrap(); + + assert_eq!(dependencies.len(), 1); + assert_eq!(dependencies[0].interface_names(), vec!["basic-wallet"]); + assert_eq!(dependencies[0].interfaces[0].import, "miden:basic-wallet/basic-wallet@0.1.0"); + assert!(dependencies[0].interfaces[0].types.is_empty()); + assert!( + dependencies[0] + .package_path + .ends_with("package-cache/wit_world_fixture_dep.masp"), + "unexpected package path: {}", + dependencies[0].package_path.display() + ); fs::remove_dir_all(fixture_root).expect("temporary fixture directory must be removed"); } #[test] - fn miden_file_dependency_uses_project_wit_metadata() { - let fixture_root = basic_wallet_fixture_root(); - let package_path = fixture_root.join("target/release/basic_wallet.masp"); - fs::create_dir_all(package_path.parent().expect("package path must have a parent")) - .expect("package directory must be created"); - fs::write(&package_path, b"package bytes").expect("package fixture must be written"); - - let package = package_with_dependency( - package_path.clone(), - Some(fixture_root.join("target/generated-wit")), - ); + fn file_dependency_reads_wit_from_masp_package() { + // A dependency that points directly at a `.masp` file is self-contained: the embedded WIT + // is read from that package with no additional manifest metadata. + let fixture_root = empty_fixture_root(); + let package_path = fixture_root.join("prebuilt/wit_world_fixture_dep.masp"); + write_masp_fixture(&package_path, Some(BASIC_WALLET_GENERATED_WIT)); + + let package = package_with_dependency(package_path.clone()); - let dependencies = - collect_miden_dependencies(&fixture_root, &package, proc_macro2::Span::call_site()) - .unwrap(); + let dependencies = collect_with_cache(&fixture_root, &package).unwrap(); let package_path = fs::canonicalize(package_path).expect("package path fixture must canonicalize"); assert_eq!(dependencies.len(), 1); - assert_eq!(dependencies[0].root, package_path); + assert_eq!(dependencies[0].package_path, package_path); assert_eq!(dependencies[0].interface_names(), vec!["basic-wallet"]); assert_eq!(dependencies[0].interfaces[0].import, "miden:basic-wallet/basic-wallet@0.1.0"); @@ -995,98 +879,232 @@ world mixed-export-world { } #[test] - fn missing_dependency_wit_reports_actionable_sdk_macro_error() { + fn missing_dependency_package_reports_actionable_error() { let fixture_root = empty_fixture_root(); - let dependency_root = fixture_root.join("basic-wallet"); + let dependency_root = fixture_root.join("wit-world-fixture-dep"); fs::create_dir_all(&dependency_root).expect("dependency fixture directory must be created"); - let package = package_with_dependency(dependency_root, None); + let package = package_with_dependency(dependency_root); - let error = - collect_miden_dependencies(&fixture_root, &package, proc_macro2::Span::call_site()) - .expect_err("dependency without generated WIT must fail dependency metadata load"); + let error = collect_with_cache(&fixture_root, &package) + .expect_err("dependency without a compiled package must fail metadata load"); let message = error.to_string(); assert!( - message - .contains("failed to load dependency WIT metadata for dependency 'basic-wallet'"), + message.contains("could not find a built `.masp` package"), "unexpected error: {message}" ); assert!( - message.contains("The SDK macro needs the dependency's generated WIT"), + message.contains("Miden dependency 'wit-world-fixture-dep'"), "unexpected error: {message}" ); - assert!(message.contains("target/generated-wit"), "unexpected error: {message}"); + assert!(message.contains("during Rust macro expansion"), "unexpected error: {message}"); + assert!(message.contains("cargo miden build"), "unexpected error: {message}"); + + fs::remove_dir_all(fixture_root).expect("temporary fixture directory must be removed"); + } + + #[test] + fn package_without_wit_section_reports_rebuild_error() { + let fixture_root = empty_fixture_root(); + let dependency_root = fixture_root.join("wit-world-fixture-dep"); + fs::create_dir_all(&dependency_root).expect("dependency fixture directory must be created"); + write_masp_fixture(&fixture_root.join("package-cache/wit_world_fixture_dep.masp"), None); + let package = package_with_dependency(dependency_root); + + let error = collect_with_cache(&fixture_root, &package) + .expect_err("package without an embedded WIT section must fail metadata load"); + let message = error.to_string(); + + assert!(message.contains("does not embed component WIT"), "unexpected error: {message}"); + assert!(message.contains("older Miden toolchain"), "unexpected error: {message}"); + assert!(message.contains("cargo miden build"), "unexpected error: {message}"); + assert!(message.contains("provide the WIT manually via"), "unexpected error: {message}"); assert!( - message.contains("package.metadata.miden.dependencies.basic-wallet.wit"), + message.contains("package.metadata.miden.dependencies.wit-world-fixture-dep.wit"), "unexpected error: {message}" ); - assert!(message.contains("Generate the dependency WIT"), "unexpected error: {message}"); fs::remove_dir_all(fixture_root).expect("temporary fixture directory must be removed"); } #[test] - fn missing_explicit_dependency_wit_reports_actionable_sdk_macro_error() { + fn wit_override_file_supplies_missing_embedded_wit() { + // The escape hatch: a package without a WIT section takes its WIT from the `.wit` file + // named by the dependency's `wit` key in miden-project.toml. + let fixture_root = empty_fixture_root(); + let dependency_root = fixture_root.join("wit-world-fixture-dep"); + fs::create_dir_all(&dependency_root).expect("dependency fixture directory must be created"); + write_masp_fixture(&fixture_root.join("package-cache/wit_world_fixture_dep.masp"), None); + let override_path = fixture_root.join("overrides/basic-wallet.wit"); + fs::create_dir_all(override_path.parent().unwrap()) + .expect("override fixture directory must be created"); + fs::write(&override_path, BASIC_WALLET_GENERATED_WIT) + .expect("override fixture must be written"); + let package = package_with_dependency_and_wit_key(dependency_root, &override_path); + + let dependencies = collect_with_cache(&fixture_root, &package).unwrap(); + + assert_eq!(dependencies.len(), 1); + assert_eq!(dependencies[0].interface_names(), vec!["basic-wallet"]); + assert_eq!(dependencies[0].interfaces[0].import, "miden:basic-wallet/basic-wallet@0.1.0"); + + fs::remove_dir_all(fixture_root).expect("temporary fixture directory must be removed"); + } + + #[test] + fn wit_override_directory_supplies_missing_embedded_wit() { + // The `wit` key may name a directory holding exactly one top-level `.wit` file. + let fixture_root = empty_fixture_root(); + let dependency_root = fixture_root.join("wit-world-fixture-dep"); + fs::create_dir_all(&dependency_root).expect("dependency fixture directory must be created"); + write_masp_fixture(&fixture_root.join("package-cache/wit_world_fixture_dep.masp"), None); + let override_dir = fixture_root.join("overrides"); + fs::create_dir_all(&override_dir).expect("override fixture directory must be created"); + fs::write(override_dir.join("basic-wallet.wit"), BASIC_WALLET_GENERATED_WIT) + .expect("override fixture must be written"); + let package = package_with_dependency_and_wit_key(dependency_root, &override_dir); + + let dependencies = collect_with_cache(&fixture_root, &package).unwrap(); + + assert_eq!(dependencies.len(), 1); + assert_eq!(dependencies[0].interface_names(), vec!["basic-wallet"]); + + fs::remove_dir_all(fixture_root).expect("temporary fixture directory must be removed"); + } + + #[test] + fn wit_override_conflicting_with_embedded_wit_reports_error() { + // A `wit` key set for a package that embeds WIT is a configuration conflict, reported + // even before the key's path is inspected (the path here does not exist). let fixture_root = empty_fixture_root(); - let dependency_root = fixture_root.join("basic-wallet"); + let dependency_root = fixture_root.join("wit-world-fixture-dep"); fs::create_dir_all(&dependency_root).expect("dependency fixture directory must be created"); - let package = package_with_dependency( - dependency_root, - Some(PathBuf::from("target/generated-wit/missing.wit")), + write_masp_fixture( + &fixture_root.join("package-cache/wit_world_fixture_dep.masp"), + Some(BASIC_WALLET_GENERATED_WIT), ); + let override_path = fixture_root.join("overrides/does-not-exist.wit"); + let package = package_with_dependency_and_wit_key(dependency_root, &override_path); - let error = - collect_miden_dependencies(&fixture_root, &package, proc_macro2::Span::call_site()) - .expect_err( - "missing explicit dependency WIT path must fail dependency metadata load", - ); + let error = collect_with_cache(&fixture_root, &package) + .expect_err("a wit key alongside embedded WIT must fail metadata load"); + let message = error.to_string(); + + assert!(message.contains("embeds component WIT"), "unexpected error: {message}"); + assert!(message.contains("remove the `wit` key"), "unexpected error: {message}"); + + fs::remove_dir_all(fixture_root).expect("temporary fixture directory must be removed"); + } + + #[test] + fn wit_override_with_missing_path_reports_error() { + let fixture_root = empty_fixture_root(); + let dependency_root = fixture_root.join("wit-world-fixture-dep"); + fs::create_dir_all(&dependency_root).expect("dependency fixture directory must be created"); + write_masp_fixture(&fixture_root.join("package-cache/wit_world_fixture_dep.masp"), None); + let override_path = fixture_root.join("overrides/does-not-exist.wit"); + let package = package_with_dependency_and_wit_key(dependency_root, &override_path); + + let error = collect_with_cache(&fixture_root, &package) + .expect_err("a wit key pointing at a missing path must fail metadata load"); let message = error.to_string(); assert!( - message.contains( - "failed to resolve dependency WIT metadata for dependency 'basic-wallet'" - ), - "unexpected error: {message}" - ); - assert!( - message.contains("package.metadata.miden.dependencies.basic-wallet.wit"), + message.contains("failed to resolve the WIT override"), "unexpected error: {message}" ); assert!( - message.contains("target/generated-wit/missing.wit"), + message.contains("package.metadata.miden.dependencies.wit-world-fixture-dep.wit"), "unexpected error: {message}" ); + + fs::remove_dir_all(fixture_root).expect("temporary fixture directory must be removed"); + } + + #[test] + fn wit_override_directory_with_multiple_wit_files_reports_error() { + let fixture_root = empty_fixture_root(); + let dependency_root = fixture_root.join("wit-world-fixture-dep"); + fs::create_dir_all(&dependency_root).expect("dependency fixture directory must be created"); + write_masp_fixture(&fixture_root.join("package-cache/wit_world_fixture_dep.masp"), None); + let override_dir = fixture_root.join("overrides"); + fs::create_dir_all(&override_dir).expect("override fixture directory must be created"); + fs::write(override_dir.join("first.wit"), BASIC_WALLET_GENERATED_WIT) + .expect("override fixture must be written"); + fs::write(override_dir.join("second.wit"), BASIC_WALLET_GENERATED_WIT) + .expect("override fixture must be written"); + let package = package_with_dependency_and_wit_key(dependency_root, &override_dir); + + let error = collect_with_cache(&fixture_root, &package) + .expect_err("an override directory with two .wit files must fail metadata load"); + let message = error.to_string(); + + assert!(message.contains("contains 2 `.wit` files"), "unexpected error: {message}"); assert!( - message - .contains("The SDK macro needs the dependency's generated WIT file or directory"), + message.contains("a single self-contained `.wit` file"), "unexpected error: {message}" ); - assert!(message.contains("update the `wit` path"), "unexpected error: {message}"); fs::remove_dir_all(fixture_root).expect("temporary fixture directory must be removed"); } #[test] - fn miden_file_dependency_without_project_wit_reports_typed_fpi_error() { - let fixture_root = basic_wallet_fixture_root(); - let package_path = fixture_root.join("target/release/basic_wallet.masp"); - fs::create_dir_all(package_path.parent().expect("package path must have a parent")) - .expect("package directory must be created"); - fs::write(&package_path, b"package bytes").expect("package fixture must be written"); - - let package = package_with_dependency(package_path, None); - - let error = - collect_miden_dependencies(&fixture_root, &package, proc_macro2::Span::call_site()) - .expect_err("artifact-only dependency must not provide dependency WIT metadata"); + fn non_self_contained_wit_override_reports_error() { + // The override obeys the same self-containment rule as embedded WIT. + let importing_wit = r#"package miden:importing@0.1.0; + +world importer { + import miden:not-embedded/api@0.1.0; +} +"#; + let fixture_root = empty_fixture_root(); + let dependency_root = fixture_root.join("wit-world-fixture-dep"); + fs::create_dir_all(&dependency_root).expect("dependency fixture directory must be created"); + write_masp_fixture(&fixture_root.join("package-cache/wit_world_fixture_dep.masp"), None); + let override_path = fixture_root.join("overrides/importing.wit"); + fs::create_dir_all(override_path.parent().unwrap()) + .expect("override fixture directory must be created"); + fs::write(&override_path, importing_wit).expect("override fixture must be written"); + let package = package_with_dependency_and_wit_key(dependency_root, &override_path); + + let error = collect_with_cache(&fixture_root, &package) + .expect_err("an override referencing a foreign package must fail metadata load"); + let message = error.to_string(); + + assert!(message.contains("invalid WIT override"), "unexpected error: {message}"); + assert!(message.contains("must be self-contained"), "unexpected error: {message}"); + + fs::remove_dir_all(fixture_root).expect("temporary fixture directory must be removed"); + } + + #[test] + fn non_self_contained_embedded_wit_reports_self_containment_error() { + // A foreign-produced package may embed WIT that imports another package; the diagnostic + // must name the self-containment requirement instead of suggesting a rebuild. + let importing_wit = r#"package miden:importing@0.1.0; + +world importer { + import miden:not-embedded/api@0.1.0; +} +"#; + let fixture_root = empty_fixture_root(); + let dependency_root = fixture_root.join("wit-world-fixture-dep"); + fs::create_dir_all(&dependency_root).expect("dependency fixture directory must be created"); + write_masp_fixture( + &fixture_root.join("package-cache/wit_world_fixture_dep.masp"), + Some(importing_wit), + ); + let package = package_with_dependency(dependency_root); + + let error = collect_with_cache(&fixture_root, &package) + .expect_err("embedded WIT referencing a foreign package must fail metadata load"); let message = error.to_string(); - assert!(message.contains("points to file"), "unexpected error: {message}"); assert!( - message.contains("package.metadata.miden.dependencies"), + message.contains("references a package that is not embedded alongside it"), "unexpected error: {message}" ); - assert!(message.contains("dependency WIT metadata"), "unexpected error: {message}"); + assert!(message.contains("must be self-contained"), "unexpected error: {message}"); fs::remove_dir_all(fixture_root).expect("temporary fixture directory must be removed"); } diff --git a/sdk/sdk/MIGRATION.md b/sdk/sdk/MIGRATION.md index db40de48d..759686243 100644 --- a/sdk/sdk/MIGRATION.md +++ b/sdk/sdk/MIGRATION.md @@ -8,10 +8,33 @@ The most recent migration is at the top. When cutting a new release, add its mig directly below this paragraph, above the previous one (newest first, like the [CHANGELOG](./CHANGELOG.md)). - + ## Unreleased + + +### Contract crates gain a `build.rs` for IDE and plain-cargo builds + +New projects created by `cargo miden new` include a `build.rs` in each contract crate. The +script makes plain `cargo check`, `cargo build`, and IDE analysis (rust-analyzer) resolve +compiled dependency packages: outside a `cargo miden build`, it runs +`cargo miden package-cache` to locate the project's package cache, populates the cache with a +nested `cargo miden build --release` when the project has source dependencies, and exports +`MIDENC_PACKAGE_CACHE` to the crate's macro expansion. Inside a midenc-driven build the script +does nothing. + +The build script is now required for plain cargo builds of crates with Miden source +dependencies. The SDK macros read dependency packages only from the `MIDENC_PACKAGE_CACHE` +directory (or from a manifest path naming a `.masp` file directly); they no longer search +`target/miden/` output directories, so a plain `cargo check` without the script fails +with instructions instead of finding previously built artifacts. Copy `build.rs` from a +freshly generated template contract (for example `cargo miden new --account demo`) or from any +example in the compiler repository (for example `examples/p2id-note/build.rs`) into each +contract crate, next to its `Cargo.toml`. The script needs `cargo miden` on `PATH`; set the +`CARGO_MIDEN` environment variable to use a specific `cargo-miden` binary instead. A missing +tool fails the build script with an install hint. + ### Kernel scalars are typed instead of `Felt` (counts, block heights, nonces, attachments) Binding surfaces whose values are counts now return `u32`: `tx::get_num_input_notes`, @@ -242,6 +265,52 @@ let asset = miden::native_account::get_initial_asset(asset_key); context (runtime-enforced). Tx/note scripts must create notes through an account component wrapper method (see the `basic-wallet` example's `create_note`). +### Component WIT is embedded in the compiled package + +The component WIT generated by `#[component]` is now embedded in the compiled Miden package (a +`wit` section of the `.masp`) instead of being written to `target/generated-wit/`. The +`#[account(...)]`, sibling `#[component(pkg::Interface)]`, `#[note]`, and `#[tx_script]` macros +read dependency WIT from the dependency's compiled `.masp`, and every Miden path dependency must +be a built package (cargo-miden builds path dependencies automatically; a dependency that points +directly at a prebuilt `.masp` file is self-contained). + +Remove the `wit = "..."` entries from `[package.metadata.miden.dependencies]` in +`miden-project.toml` for dependencies whose packages embed WIT — a leftover key is now an error +("remove the `wit` key"). The key remains available as an escape hatch for dependency packages +*without* embedded WIT (e.g. produced by another toolchain); it must point at a single +self-contained `.wit` file, or a directory containing exactly one top-level `.wit` file. + +Before: + +```toml +[dependencies] +basic-wallet = { path = "../basic-wallet" } + +[package.metadata.miden.dependencies] +basic-wallet = { wit = "../basic-wallet/target/generated-wit/" } +``` + +After: + +```toml +[dependencies] +basic-wallet = { path = "../basic-wallet" } +``` + +A `.masp` built by an older SDK has no embedded WIT and is rejected during macro expansion with a +"does not embed component WIT" error; rebuild each dependency with the current toolchain +(`cargo miden build`), or supply the WIT manually via the `wit` key as above. Components written +without the `#[component]` macro — a hand-written `wit/` directory and a bare +`miden::generate!()` — embed their WIT automatically when the `wit/` directory contains a single +`.wit` file, so no changes are needed there. WIT split across multiple files, or referencing +packages under `wit/deps/` other than the bundled SDK WIT, cannot be embedded verbatim yet; +consolidate it into one self-contained file if the component is consumed as a Miden dependency. + +Note for the editor workflow: previously, `cargo check` (or rust-analyzer) of the dependency crate +regenerated its WIT under `target/generated-wit` as a macro side effect. Dependency WIT now comes +from the compiled package, so run `cargo miden build` for the dependency once (and again after +changing its interface) before checking a dependent crate. + ## 0.13.0 -> 0.13.1 ### `*_note::get_metadata` returns a single-word `NoteMetadata` diff --git a/sdk/wasm-metadata/src/lib.rs b/sdk/wasm-metadata/src/lib.rs index d18fce703..f5ad44e8f 100644 --- a/sdk/wasm-metadata/src/lib.rs +++ b/sdk/wasm-metadata/src/lib.rs @@ -1,4 +1,6 @@ -//! Shared encoding for frontend-only Wasm metadata emitted by SDK macros. +//! Shared definitions for the out-of-band metadata exchanged between the Miden SDK macros and +//! the compiler: Wasm custom-section names and encodings, and the package-section payloads +//! carried through the compiler pipeline into the compiled Miden package (`.masp`). #![deny(warnings)] #![deny(missing_docs)] @@ -15,6 +17,28 @@ use serde::{Deserialize, Serialize}; pub const WASM_FRONTEND_METADATA_CUSTOM_SECTION_NAME: &str = "rodata,miden_account_component_frontend"; +/// Name of the Wasm custom section used to store the serialized AccountComponentMetadata. +pub const WASM_ACCOUNT_COMPONENT_METADATA_CUSTOM_SECTION_NAME: &str = "rodata,miden_account"; + +/// Name of the Wasm custom section used to store the component's public WIT source. +pub const WASM_COMPONENT_WIT_CUSTOM_SECTION_NAME: &str = "rodata,miden_wit"; + +/// Name of the Miden package (`.masp`) section that carries the component's public WIT source. +pub const PACKAGE_WIT_SECTION_ID: &str = "wit"; + +/// Out-of-band payloads extracted from the input binary and attached to the compiled Miden +/// package as sections. +/// +/// Carried through the compiler pipeline as one unit so that adding a payload does not require +/// threading a new field through every stage. +#[derive(Clone, Debug, Default)] +pub struct PackageSections { + /// The serialized AccountComponentMetadata (name, description, storage layout, etc.). + pub account_component_metadata: Option>, + /// The component's public WIT source emitted by the `#[component]` macro. + pub component_wit: Option>, +} + /// Frontend-only metadata emitted by the SDK macros into a dedicated Wasm custom section. #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(tag = "kind", rename_all = "snake_case")] diff --git a/tests/fixtures/components/component-macros-note/miden-project.toml b/tests/fixtures/components/component-macros-note/miden-project.toml index 7d10612a9..31110cb6e 100644 --- a/tests/fixtures/components/component-macros-note/miden-project.toml +++ b/tests/fixtures/components/component-macros-note/miden-project.toml @@ -9,6 +9,3 @@ path = "src/lib.rs" [dependencies] component_macros = { path = "../component-macros-account" } - -[package.metadata.miden.dependencies] -component_macros = { wit = "../component-macros-account/target/generated-wit" } diff --git a/tests/fixtures/components/cross-ctx-note-word-arg/miden-project.toml b/tests/fixtures/components/cross-ctx-note-word-arg/miden-project.toml index be8a4ab39..702b0c3c9 100644 --- a/tests/fixtures/components/cross-ctx-note-word-arg/miden-project.toml +++ b/tests/fixtures/components/cross-ctx-note-word-arg/miden-project.toml @@ -9,6 +9,3 @@ path = "src/lib.rs" [dependencies] cross-ctx-account-word-arg = { path = "../cross-ctx-account-word-arg" } - -[package.metadata.miden.dependencies] -cross-ctx-account-word-arg = { wit = "../cross-ctx-account-word-arg/wit/cross-ctx-account-word.wit" } diff --git a/tests/fixtures/components/cross-ctx-note-word/miden-project.toml b/tests/fixtures/components/cross-ctx-note-word/miden-project.toml index 99824f7b5..7f33ff0e6 100644 --- a/tests/fixtures/components/cross-ctx-note-word/miden-project.toml +++ b/tests/fixtures/components/cross-ctx-note-word/miden-project.toml @@ -9,6 +9,3 @@ path = "src/lib.rs" [dependencies] cross-ctx-account-word = { path = "../cross-ctx-account-word" } - -[package.metadata.miden.dependencies] -cross-ctx-account-word = { wit = "../cross-ctx-account-word/wit/cross-ctx-account-word.wit" } diff --git a/tests/fixtures/components/cross-ctx-note/miden-project.toml b/tests/fixtures/components/cross-ctx-note/miden-project.toml index 834abadd0..8f35c23c7 100644 --- a/tests/fixtures/components/cross-ctx-note/miden-project.toml +++ b/tests/fixtures/components/cross-ctx-note/miden-project.toml @@ -9,6 +9,3 @@ path = "src/lib.rs" [dependencies] cross-ctx-account = { path = "../cross-ctx-account" } - -[package.metadata.miden.dependencies] -cross-ctx-account = { wit = "../cross-ctx-account/wit/cross-ctx-account.wit" } diff --git a/tests/fixtures/components/swapp-note/miden-project.toml b/tests/fixtures/components/swapp-note/miden-project.toml index 776520703..1e1585672 100644 --- a/tests/fixtures/components/swapp-note/miden-project.toml +++ b/tests/fixtures/components/swapp-note/miden-project.toml @@ -9,6 +9,3 @@ path = "src/lib.rs" [dependencies] basic-wallet = { path = "../../../../examples/basic-wallet" } - -[package.metadata.miden.dependencies] -basic-wallet = { wit = "../../../../examples/basic-wallet/target/generated-wit/" } diff --git a/tests/integration-network/src/mockchain/fpi/common.rs b/tests/integration-network/src/mockchain/fpi/common.rs index c11fddc5e..6c7b713fc 100644 --- a/tests/integration-network/src/mockchain/fpi/common.rs +++ b/tests/integration-network/src/mockchain/fpi/common.rs @@ -404,7 +404,6 @@ fn dependent_account_miden_project_toml( ) -> String { let namespace = account_component_namespace(account_package, "caller-account"); let dependency_name = miden_dependency_name(dependency_package); - let dependency_wit_path = dependency_root.join("target/generated-wit"); format!( r#" [package] @@ -423,12 +422,8 @@ miden-protocol = "*" [package.metadata.miden] supported-types = ["RegularAccountUpdatableCode"] - -[package.metadata.miden.dependencies] -"{dependency_name}" = {{ wit = "{dependency_wit_path}" }} "#, dependency_root = dependency_root.display(), - dependency_wit_path = dependency_wit_path.display(), ) } @@ -440,18 +435,13 @@ fn dependent_account_cargo_toml( dependency_root: &Path, ) -> String { let mut manifest = account_cargo_toml_for(account_name, account_package); - let dependency_wit_path = dependency_root.join("target/generated-wit"); manifest.push_str(&format!( r#" [package.metadata.miden.dependencies] "{dependency_package}" = {{ path = "{dependency_root}" }} - -[package.metadata.component.target.dependencies] -"{dependency_package}" = {{ path = "{dependency_wit_path}" }} "#, dependency_package = dependency_package, dependency_root = dependency_root.display(), - dependency_wit_path = dependency_wit_path.display(), )); manifest } diff --git a/tests/integration-network/src/mockchain/support/projects.rs b/tests/integration-network/src/mockchain/support/projects.rs index bc8dc212f..a68b331fe 100644 --- a/tests/integration-network/src/mockchain/support/projects.rs +++ b/tests/integration-network/src/mockchain/support/projects.rs @@ -169,7 +169,10 @@ debug = false manifest } -/// Appends path dependencies and WIT mappings to a generated Miden project manifest. +/// Appends path dependencies to a generated Miden project manifest. +/// +/// Dependency WIT is read from each dependency's compiled `.masp` package, so no WIT path +/// metadata is emitted. pub(crate) fn append_miden_project_dependencies( manifest: &mut String, dependencies: &[(&str, &Path)], @@ -183,23 +186,6 @@ pub(crate) fn append_miden_project_dependencies( dependency_root = dependency_root.display(), )); } - - manifest.push_str( - r#" -[package.metadata.miden.dependencies] -"#, - ); - - for (dependency_package, dependency_root) in dependencies { - let dependency_name = miden_dependency_name(dependency_package); - let dependency_wit_path = dependency_root.join("target/generated-wit"); - manifest.push_str(&format!( - r#" -"{dependency_name}" = {{ wit = "{dependency_wit_path}" }} -"#, - dependency_wit_path = dependency_wit_path.display(), - )); - } } /// Appends package metadata for dependencies to a generated Cargo manifest. @@ -221,22 +207,6 @@ pub(crate) fn append_cargo_dependency_metadata( dependency_root = dependency_root.display(), )); } - - manifest.push_str( - r#" -[package.metadata.component.target.dependencies] -"#, - ); - for (dependency_package, dependency_root) in dependencies { - let dependency_wit_path = dependency_root.join("target/generated-wit"); - manifest.push_str(&format!( - r#" -"{dependency_package}" = {{ path = "{dependency_wit_path}" }} -"#, - dependency_package = dependency_package, - dependency_wit_path = dependency_wit_path.display(), - )); - } } /// Returns the package-local dependency name accepted by `miden-project.toml`. diff --git a/tests/integration/Cargo.toml b/tests/integration/Cargo.toml index d2a68c665..6a227d181 100644 --- a/tests/integration/Cargo.toml +++ b/tests/integration/Cargo.toml @@ -33,6 +33,7 @@ midenc-dialect-hir.workspace = true midenc-dialect-scf.workspace = true midenc-dialect-wasm.workspace = true midenc-frontend-wasm.workspace = true +midenc-frontend-wasm-metadata.workspace = true midenc-hir = { workspace = true, features = ["logging"] } midenc-hir-eval.workspace = true midenc-session.workspace = true diff --git a/tests/integration/src/sdk/build_script.rs b/tests/integration/src/sdk/build_script.rs new file mode 100644 index 000000000..1bf9f3fc5 --- /dev/null +++ b/tests/integration/src/sdk/build_script.rs @@ -0,0 +1,315 @@ +//! Tests for the contract `build.rs` package-cache population (#1298). +//! +//! The script under test is the file the templates and examples ship, included byte-for-byte +//! from the canonical copy (the account template); [`template_build_scripts_are_identical`] +//! pins every other copy to those bytes, so these tests cover exactly what users get. The script +//! makes plain `cargo check`/`cargo build` and IDE analysis resolve compiled dependency +//! packages: outside a midenc-driven build it locates the fingerprinted package cache with +//! `cargo miden package-cache`, populates it with a nested `cargo miden build --release`, and +//! exports `MIDENC_PACKAGE_CACHE` to macro expansion. + +use std::{ + fs, + path::{Path, PathBuf}, + process::Output, +}; + +use super::basic_wallet_swapp_note_project; +use crate::cargo_proj::project; + +/// The canonical contract build script; every template ships these exact bytes. +const TEMPLATE_BUILD_SCRIPT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../extra/templates/rust/account/template/build.rs" +)); + +/// Returns the repository root of this workspace. +fn workspace_root() -> &'static Path { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(Path::parent) + .expect("the integration tests live under tests/integration") +} + +/// Every template and every Miden example must ship the canonical build script byte-for-byte. +/// +/// This is what entitles the tests in this module to speak for all of them while executing +/// one included copy. +#[test] +fn template_build_scripts_are_identical() { + let templates = workspace_root().join("extra").join("templates"); + let mut copies: Vec = [ + "rust/auth-component/template/build.rs", + "rust/note/template/build.rs", + "rust/tx-script/template/build.rs", + "rust/program/template/build.rs", + "project/contracts/counter-account/build.rs", + "project/contracts/increment-note/build.rs", + ] + .into_iter() + .map(|copy| templates.join(copy)) + .collect(); + + // Every example that is a Miden project must carry the script too, so IDE analysis of the + // examples works the same way it does for generated projects. + let examples = workspace_root().join("examples"); + for entry in fs::read_dir(&examples).expect("failed to list the examples directory") { + let example = entry.expect("failed to read an examples entry").path(); + if example.join("miden-project.toml").is_file() { + copies.push(example.join("build.rs")); + } + } + assert!(copies.len() > 6, "the examples walk must find Miden example projects"); + + for copy_path in copies { + let bytes = fs::read(©_path) + .unwrap_or_else(|err| panic!("missing build.rs copy '{}': {err}", copy_path.display())); + assert_eq!( + bytes, + TEMPLATE_BUILD_SCRIPT.as_bytes(), + "'{}' differs from the canonical rust/account/template/build.rs; keep every build.rs \ + copy byte-identical", + copy_path.display() + ); + } +} + +/// Builds the workspace's `cargo-miden` binary once and returns its path. +fn cargo_miden_binary() -> &'static Path { + static BINARY: std::sync::OnceLock = std::sync::OnceLock::new(); + BINARY.get_or_init(|| { + let workspace_root = workspace_root(); + let output = std::process::Command::new("cargo") + .args(["build", "-p", "cargo-miden", "--bin", "cargo-miden"]) + .current_dir(workspace_root) + .output() + .expect("failed to spawn cargo to build cargo-miden"); + assert!( + output.status.success(), + "failed to build cargo-miden:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + let target_dir = std::env::var_os("CARGO_TARGET_DIR") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from("target")); + // A relative target dir resolves against the build's working directory above. + let target_dir = if target_dir.is_absolute() { + target_dir + } else { + workspace_root.join(target_dir) + }; + target_dir.join("debug").join("cargo-miden") + }) +} + +/// Runs a plain (non-midenc) `cargo check` of `consumer`, the way an IDE does. +fn plain_cargo_check(consumer: &Path) -> Output { + std::process::Command::new("cargo") + .arg("check") + .env("CARGO_MIDEN", cargo_miden_binary()) + .env_remove("MIDENC_PACKAGE_CACHE") + .env_remove("CARGO_TARGET_DIR") + .env_remove("RUSTFLAGS") + .env_remove("CARGO_ENCODED_RUSTFLAGS") + .current_dir(consumer) + .output() + .expect("failed to spawn cargo check") +} + +/// Asserts one check succeeded, with its stderr in the failure message. +#[track_caller] +fn assert_check_succeeded(phase: &str, output: &Output) { + assert!( + output.status.success(), + "{phase}: plain cargo check must succeed with the template build script:\n{}", + String::from_utf8_lossy(&output.stderr) + ); +} + +/// Finds the cached `basic-wallet.masp` under the consumer's fingerprinted package cache. +fn cached_basic_wallet(consumer: &Path) -> Option { + let packages_root = consumer.join("target").join("miden").join("packages"); + fs::read_dir(&packages_root) + .ok()? + .filter_map(|entry| Some(entry.ok()?.path())) + .filter(|path| path.is_dir()) + .map(|fingerprint_dir| fingerprint_dir.join("basic-wallet.masp")) + .find(|path| path.is_file()) +} + +/// A plain `cargo check` (the LSP flow) must resolve dependency packages through the template +/// build script, refresh them when dependency sources change, and recover a pruned cache. +/// +/// Three phases against one generated basic-wallet/swapp-note pair: +/// 1. the first check populates the fingerprinted cache with a nested +/// `cargo miden build --release` and exports `MIDENC_PACKAGE_CACHE` to macro expansion; +/// 2. editing the dependency's source re-runs the script through its `watch=` list (the +/// dependency `src` directory) and republishes a package with different contents; +/// 3. deleting the fingerprint directory re-runs the script through its missing-watched-path +/// rule and repopulates the cache. +#[test] +fn rust_sdk_build_script_populates_package_cache_for_plain_cargo_check() { + let swapp_note_source = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../fixtures/components/swapp-note/src/lib.rs" + )); + let project = basic_wallet_swapp_note_project( + "build_script_package_cache", + swapp_note_source, + Some(TEMPLATE_BUILD_SCRIPT), + ); + let consumer = project.root().join("swapp-note"); + let dependency_source = project.root().join("basic-wallet").join("src").join("lib.rs"); + + // Phase 1: the first check populates the cache. + assert_check_succeeded("initial check", &plain_cargo_check(&consumer)); + let cached = cached_basic_wallet(&consumer).expect( + "the first check must publish basic-wallet.masp into a fingerprint directory of the \ + consumer's package cache", + ); + let original_package = fs::read(&cached).expect("failed to read the cached package"); + + // Phase 2: a dependency source edit must reach the cache through the watch list. + let original_source = fs::read_to_string(&dependency_source).unwrap(); + let mutation_anchor = " self.add_asset(asset);"; + assert_eq!( + original_source.matches(mutation_anchor).count(), + 1, + "the basic-wallet mutation anchor must match exactly once" + ); + let changed_source = original_source.replacen( + mutation_anchor, + " self.add_asset(asset);\n self.remove_asset(asset);\n \ + self.add_asset(asset);", + 1, + ); + fs::write(&dependency_source, changed_source).unwrap(); + + assert_check_succeeded("check after dependency edit", &plain_cargo_check(&consumer)); + let refreshed = cached_basic_wallet(&consumer) + .expect("the cache must still hold basic-wallet.masp after the dependency edit"); + let refreshed_package = fs::read(&refreshed).expect("failed to read the refreshed package"); + assert_ne!( + refreshed_package, original_package, + "editing the dependency source must republish a different basic-wallet package" + ); + + // Phase 3: a pruned cache directory is a missing watched path and must be repopulated. + let fingerprint_dir = refreshed.parent().expect("a cached package lives in a directory"); + fs::remove_dir_all(fingerprint_dir).expect("failed to prune the package cache"); + + assert_check_succeeded("check after cache prune", &plain_cargo_check(&consumer)); + assert!( + cached_basic_wallet(&consumer).is_some(), + "the check after pruning must repopulate the package cache" + ); +} + +/// The p2id-note example must pass an IDE-style plain `cargo check` in place, through its +/// shipped build script, with the basic-wallet dependency package resolved from the cache. +/// +/// Other tests build this example through the driven pipeline concurrently, and cache +/// preparation prunes every unlocked sibling fingerprint directory. The test therefore joins +/// the cache liveness protocol: it resolves its fingerprint directory up front with the same +/// `cargo miden package-cache --release` query the build script runs, and holds the shared +/// sibling lock across the check and the assertion, so concurrent pruners skip this cache the +/// same way they skip any live build's. +#[test] +fn rust_sdk_build_script_p2id_note_plain_cargo_check() { + let consumer = workspace_root().join("examples").join("p2id-note"); + + let query = std::process::Command::new(cargo_miden_binary()) + .args(["miden", "package-cache", "--release"]) + .env_remove("MIDENC_PACKAGE_CACHE") + .env_remove("CARGO_TARGET_DIR") + .env_remove("RUSTFLAGS") + .env_remove("CARGO_ENCODED_RUSTFLAGS") + .current_dir(&consumer) + .output() + .expect("failed to spawn cargo miden package-cache"); + assert!( + query.status.success(), + "the package-cache query must succeed:\n{}", + String::from_utf8_lossy(&query.stderr) + ); + let stdout = String::from_utf8(query.stdout).unwrap(); + let cache_dir = PathBuf::from( + stdout + .lines() + .find_map(|line| line.strip_prefix("cache-dir=")) + .expect("the query must name the cache directory"), + ); + + let lock_path = cache_dir.with_extension("lock"); + fs::create_dir_all(lock_path.parent().expect("a fingerprint lock has a packages parent")) + .expect("failed to create the package cache parent"); + let cache_liveness_lock = fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&lock_path) + .expect("failed to open the cache liveness lock"); + cache_liveness_lock + .lock_shared() + .expect("failed to hold the cache liveness lock"); + + assert_check_succeeded("p2id-note check", &plain_cargo_check(&consumer)); + // In place, a concurrent driven build's cache could also hold basic-wallet.masp, so the + // assertion targets this check's own fingerprint directory. + assert!( + cache_dir.join("basic-wallet.masp").is_file(), + "the check must publish basic-wallet.masp into '{}'", + cache_dir.display() + ); + drop(cache_liveness_lock); +} + +/// A missing `cargo-miden` is a hard build-script error with an actionable message. +#[test] +fn rust_sdk_build_script_fails_without_cargo_miden() { + let project = project("build_script_missing_tool") + .file( + "Cargo.toml", + r#" +[package] +name = "missing-tool" +version = "0.1.0" +edition = "2024" + +[lib] +crate-type = ["rlib"] +"#, + ) + .file( + "miden-project.toml", + r#" +[package] +name = "missing-tool" +version = "0.1.0" + +[lib] +kind = "account-component" +namespace = "miden:missing-tool/missing-tool@0.1.0" +path = "src/lib.rs" +"#, + ) + .file("build.rs", TEMPLATE_BUILD_SCRIPT) + .file("src/lib.rs", "") + .build(); + + let output = std::process::Command::new("cargo") + .arg("check") + .env("CARGO_MIDEN", project.root().join("definitely-missing-cargo-miden")) + .env_remove("MIDENC_PACKAGE_CACHE") + .env_remove("CARGO_TARGET_DIR") + .current_dir(project.root()) + .output() + .expect("failed to spawn cargo check"); + assert!(!output.status.success(), "cargo check must fail without cargo-miden"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("failed to run `cargo miden package-cache`"), + "the build script must name the missing tool, got:\n{stderr}" + ); +} diff --git a/tests/integration/src/sdk/canonabi.rs b/tests/integration/src/sdk/canonabi.rs index c9367e1c5..62d698c4b 100644 --- a/tests/integration/src/sdk/canonabi.rs +++ b/tests/integration/src/sdk/canonabi.rs @@ -1,6 +1,6 @@ //! Integration tests for component-model CanonABI values. -use std::{fs, path::Path}; +use std::path::Path; use midenc_frontend_wasm::WasmTranslationConfig; use midenc_integration_test_support::{ @@ -119,7 +119,6 @@ fn build_note_project( note_body: &str, ) -> Project { let sdk_path = sdk_crate_path(); - let generated_wit = account_root.join("target/generated-wit"); let cargo_toml = format!( r#"cargo-features = ["trim-paths"] @@ -143,9 +142,6 @@ project-kind = "note-script" [package.metadata.miden.dependencies] "miden:{account_slug}" = {{ path = "{account_root}" }} -[package.metadata.component.target.dependencies] -"miden:{account_slug}" = {{ path = "{generated_wit}" }} - [profile.release] trim-paths = ["diagnostics", "object"] @@ -157,7 +153,6 @@ trim-paths = ["diagnostics", "object"] account_slug = names.account_slug, sdk_path = sdk_path.display(), account_root = account_root.display(), - generated_wit = generated_wit.display(), ); let miden_project_toml = format!( r#"[package] @@ -173,15 +168,11 @@ path = "src/lib.rs" miden-core = "*" miden-protocol = "*" {account_crate} = {{ path = "{account_root}" }} - -[package.metadata.miden.dependencies] -{account_crate} = {{ wit = "{generated_wit}" }} "#, note_crate = names.note_crate, note_slug = names.note_slug, account_crate = names.account_crate, account_root = account_root.display(), - generated_wit = generated_wit.display(), ); let source = format!( r#"#![no_std] @@ -222,21 +213,18 @@ fn build_generated_test(root: impl AsRef) -> CompilerTest { builder.build() } -/// Reads the single generated WIT file emitted by the account project. -fn read_generated_wit(project: &Project) -> String { - let generated_wit_dir = project.root().join("target/generated-wit"); - let mut wit_paths = fs::read_dir(&generated_wit_dir) - .unwrap_or_else(|err| { - panic!("failed to read generated WIT dir {}: {err}", generated_wit_dir.display()) - }) - .map(|entry| entry.expect("failed to inspect generated WIT entry").path()) - .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("wit")) - .collect::>(); - wit_paths.sort(); - assert_eq!(wit_paths.len(), 1, "expected one generated WIT file, got {wit_paths:?}"); - fs::read_to_string(&wit_paths[0]).unwrap_or_else(|err| { - panic!("failed to read generated WIT {}: {err}", wit_paths[0].display()) - }) +/// Extracts the component WIT embedded in a compiled account package. +fn package_wit(package: &miden_mast_package::Package) -> String { + let wit_section_id = miden_mast_package::SectionId::custom( + midenc_frontend_wasm_metadata::PACKAGE_WIT_SECTION_ID, + ) + .expect("the WIT section id must be a valid custom section id"); + let section = package + .sections + .iter() + .find(|section| section.id == wit_section_id) + .expect("compiled account package must embed its component WIT"); + String::from_utf8(section.data.to_vec()).expect("embedded WIT must be UTF-8") } /// Runs a generated account/note pair by executing the compiled note script directly. @@ -252,7 +240,7 @@ fn run_canonabi_case( let mut account_test = build_generated_test(&account_root); let account_package = account_test.compile_package(); assert!(account_package.is_library()); - let generated_wit = read_generated_wit(&account_project); + let generated_wit = package_wit(&account_package); assert_generated_wit(&generated_wit); let note_project = build_note_project(&names, &account_root, note_body); diff --git a/tests/integration/src/sdk/macros.rs b/tests/integration/src/sdk/macros.rs index 6264f9483..7163bc852 100644 --- a/tests/integration/src/sdk/macros.rs +++ b/tests/integration/src/sdk/macros.rs @@ -8,6 +8,9 @@ fn cargo_check_miden_target(project: &crate::cargo_proj::Project) -> std::proces .arg("--target") .arg("wasm32-wasip2") .env("RUSTFLAGS", "--cfg miden -C target-feature=+bulk-memory,+wide-arithmetic") + // The macros read dependency packages only from this directory, the way a driven build + // or the contract build script exposes it. + .env("MIDENC_PACKAGE_CACHE", project.root().join("package-cache")) .current_dir(project.root()) .output() .expect("failed to spawn `cargo check` for the component macro regression test") @@ -592,7 +595,7 @@ impl TestComponent for TestComponentStorage { /// Hand-written stand-in for the generated WIT of a sibling component dependency. /// -/// Mirrors the shape the `#[component]` macro writes to `target/generated-wit` so the sibling +/// Mirrors the shape the `#[component]` macro embeds into the compiled package so the sibling /// tests don't have to compile a real dependency project with cargo-miden. const TEST_SIBLING_GENERATED_WIT: &str = r#"package miden:test-sibling@0.0.1; @@ -636,37 +639,52 @@ world test-sibling-world { /// Builds an account component project with one sibling component dependency named `test-sibling`. /// -/// The sibling exists only as its generated WIT (under `dep/target/generated-wit`), which is all -/// the macros need: sibling calls resolve at link time and read no `.masp` during expansion. +/// The sibling exists only as a synthesized `.masp` package in the project's `package-cache` +/// directory embedding its component WIT, which is all the macros need: sibling calls resolve at +/// link time and read no procedure roots during expansion. fn account_component_project_with_sibling_dep( name: &str, lib_rs: &str, ) -> crate::cargo_proj::Project { - account_component_project_with_sibling_dep_inner(name, lib_rs, TEST_SIBLING_GENERATED_WIT, true) + account_component_project_with_sibling_dep_inner(name, lib_rs, Some(TEST_SIBLING_GENERATED_WIT)) } /// Builds an account component project with one sibling component dependency named `test-sibling`. /// -/// `sibling_wit` is the dependency's generated WIT written under `dep/target/generated-wit`. -/// `declare_sibling_wit` controls whether that WIT is declared under -/// `[package.metadata.miden.dependencies]` in `miden-project.toml`. Omitting it reproduces the -/// case where the reference selects (the WIT is read from `target/generated-wit`) but the inline -/// `generate!` cannot resolve the import, so the macro emits the missing-WIT diagnostic. +/// `sibling_wit` is embedded into the WIT section of the dependency's synthesized `.masp` +/// package. Passing `None` omits the section, reproducing a dependency package built by a +/// toolchain that predates embedded WIT. fn account_component_project_with_sibling_dep_inner( name: &str, lib_rs: &str, - sibling_wit: &str, - declare_sibling_wit: bool, + sibling_wit: Option<&str>, +) -> crate::cargo_proj::Project { + let cargo_proj = account_component_project_with_sibling_dep_root(name, lib_rs, None); + write_sibling_package(&cargo_proj, sibling_wit); + cargo_proj +} + +/// Builds the sibling-dependency project skeleton without a compiled dependency package. +/// +/// `sibling_wit_key` optionally sets the dependency's manual WIT path +/// (`package.metadata.miden.dependencies.test-sibling.wit`) in `miden-project.toml`, relative to +/// the project root. +fn account_component_project_with_sibling_dep_root( + name: &str, + lib_rs: &str, + sibling_wit_key: Option<&str>, ) -> crate::cargo_proj::Project { let sdk_path = sdk_crate_path(); let namespace = base::account_component_namespace(name, "test-component"); let component_package = format!("miden:{}", name.replace('_', "-")); - let sibling_wit_entry = if declare_sibling_wit { - "\n[package.metadata.miden.dependencies]\ntest-sibling = { wit = \ - \"dep/target/generated-wit\" }\n" - } else { - "" - }; + let sibling_wit_entry = sibling_wit_key + .map(|wit_path| { + format!( + "\n[package.metadata.miden.dependencies]\ntest-sibling = {{ wit = \"{wit_path}\" \ + }}\n" + ) + }) + .unwrap_or_default(); let miden_project_toml = format!( r#" [package] @@ -701,9 +719,6 @@ miden = {{ path = "{sdk_path}" }} [package.metadata.component] package = "{component_package}" -[package.metadata.component.target.dependencies] -"miden:test-sibling" = {{ path = "dep/target/generated-wit/test-sibling.wit" }} - [package.metadata.miden] project-kind = "account" supported-types = ["RegularAccountUpdatableCode"] @@ -714,11 +729,45 @@ supported-types = ["RegularAccountUpdatableCode"] project(name) .file("miden-project.toml", &miden_project_toml) .file("Cargo.toml", &cargo_toml) - .file("dep/target/generated-wit/test-sibling.wit", sibling_wit) + // The dependency root must exist on disk for the macros to canonicalize it. + .file("dep/.gitkeep", "") .file("src/lib.rs", lib_rs) .build() } +/// Synthesizes the sibling dependency `.masp` package into the project's `package-cache`. +fn write_sibling_package(cargo_proj: &crate::cargo_proj::Project, wit: Option<&str>) { + use miden_assembly::{Assembler, DefaultSourceManager, ModuleParser, ast::ModuleKind}; + use miden_core::serde::Serializable; + + let source_manager = std::sync::Arc::new(DefaultSourceManager::default()); + let module = ModuleParser::new(Some(ModuleKind::Library)) + .parse_str( + Some(miden_assembly::Path::new("dep")), + "pub proc callee(a: felt) -> felt\n add.1\nend", + source_manager.clone(), + ) + .expect("sibling fixture module must parse"); + let mut package = Assembler::new(source_manager) + .assemble_library("test-sibling", module, None::>) + .expect("sibling fixture library must assemble"); + package.version = "0.0.1".parse().expect("sibling fixture version must parse"); + if let Some(wit) = wit { + let wit_section_id = miden_mast_package::SectionId::custom( + midenc_frontend_wasm_metadata::PACKAGE_WIT_SECTION_ID, + ) + .expect("the WIT section id must be a valid custom section id"); + package + .sections + .push(miden_mast_package::Section::new(wit_section_id, wit.as_bytes().to_vec())); + } + + let package_dir = cargo_proj.root().join("package-cache"); + std::fs::create_dir_all(&package_dir).expect("sibling package directory must be created"); + std::fs::write(package_dir.join("test_sibling.masp"), package.to_bytes()) + .expect("sibling package fixture must be written"); +} + #[test] fn component_trait_with_sibling_dependency_compiles() { // The sibling reference generates `trait TestSibling` with default methods calling the @@ -904,10 +953,9 @@ impl TestComponent for TestComponentStorage { } #[test] -fn component_sibling_reports_missing_wit_dependency_manifest_entry() { - // The reference is valid and the dependency WIT exists under `target/generated-wit`, but the - // `[package.metadata.miden.dependencies]` entry that puts it on the macro's WIT search path is - // omitted. Expansion should surface the actionable diagnostic, not a bare wit-parser error. +fn component_sibling_reports_missing_dependency_package() { + // The reference is valid but the dependency has no compiled `.masp` package. Expansion should + // surface the actionable build-the-dependency diagnostic, not a bare wit-parser error. let lib_rs = r#"#![no_std] #![feature(alloc_error_handler)] @@ -929,26 +977,181 @@ impl TestComponent for TestComponentStorage { } "#; - let cargo_proj = account_component_project_with_sibling_dep_inner( - "component_sibling_missing_wit_entry", + let cargo_proj = account_component_project_with_sibling_dep_root( + "component_sibling_missing_dep_package", lib_rs, - TEST_SIBLING_GENERATED_WIT, - false, + None, ); let output = cargo_check_miden_target(&cargo_proj); assert!( !output.status.success(), - "expected the missing sibling WIT entry to fail the build" + "expected the missing dependency package to fail the build" ); let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("could not resolve the WIT for sibling component dependencies"), + stderr.contains("could not find a built `.masp` package"), "unexpected stderr: {stderr}" ); + assert!(stderr.contains("cargo miden build"), "unexpected stderr: {stderr}"); +} + +#[test] +fn component_sibling_reports_dependency_package_without_embedded_wit() { + // The dependency package exists but predates embedded WIT (no `wit` section). Expansion + // should tell the user to rebuild the dependency with the current toolchain. + let lib_rs = r#"#![no_std] +#![feature(alloc_error_handler)] + +use miden::{component, component_storage, felt, native_account::NativeAccount, Felt}; + +#[component_storage] +struct TestComponentStorage; + +#[component(test_sibling::TestSibling)] +trait TestComponent: NativeAccount + TestSibling { + fn value(&mut self) -> Felt; +} + +#[component] +impl TestComponent for TestComponentStorage { + fn value(&mut self) -> Felt { + self.get_value() + } +} +"#; + + let cargo_proj = account_component_project_with_sibling_dep_inner( + "component_sibling_package_without_wit", + lib_rs, + None, + ); + let output = cargo_check_miden_target(&cargo_proj); assert!( - stderr.contains("[package.metadata.miden.dependencies]"), - "unexpected stderr: {stderr}" + !output.status.success(), + "expected a dependency package without embedded WIT to fail the build" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert!(stderr.contains("does not embed component WIT"), "unexpected stderr: {stderr}"); + assert!(stderr.contains("cargo miden build"), "unexpected stderr: {stderr}"); + assert!(stderr.contains("provide the WIT manually"), "unexpected stderr: {stderr}"); +} + +/// The sibling-consumer source shared by the `wit`-key escape-hatch tests. +const SIBLING_WIT_KEY_LIB_RS: &str = r#"#![no_std] +#![feature(alloc_error_handler)] + +use miden::{component, component_storage, felt, native_account::NativeAccount, Felt}; + +#[component_storage] +struct TestComponentStorage; + +#[component(test_sibling::TestSibling)] +trait TestComponent: NativeAccount + TestSibling { + fn value(&mut self) -> Felt; +} + +#[component] +impl TestComponent for TestComponentStorage { + fn value(&mut self) -> Felt { + self.get_value() + } +} +"#; + +#[test] +fn component_sibling_wit_key_supplies_missing_embedded_wit() { + // The escape hatch end-to-end: the dependency package embeds no WIT (e.g. produced by a + // foreign toolchain), so the `wit` key in miden-project.toml supplies it and the build + // succeeds. + let cargo_proj = account_component_project_with_sibling_dep_root( + "component_sibling_wit_key_fallback", + SIBLING_WIT_KEY_LIB_RS, + Some("sibling-wit/test-sibling.wit"), + ); + write_sibling_package(&cargo_proj, None); + let override_path = cargo_proj.root().join("sibling-wit/test-sibling.wit"); + std::fs::create_dir_all(override_path.parent().unwrap()) + .expect("the WIT override directory must be created"); + std::fs::write(&override_path, TEST_SIBLING_GENERATED_WIT) + .expect("the WIT override fixture must be written"); + + let output = cargo_check_miden_target(&cargo_proj); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "expected the wit key to supply the missing embedded WIT: {stderr}" + ); +} + +#[test] +fn component_sibling_wit_key_conflicts_with_embedded_wit() { + // A `wit` key set for a dependency whose package embeds WIT is a configuration conflict. + let cargo_proj = account_component_project_with_sibling_dep_root( + "component_sibling_wit_key_conflict", + SIBLING_WIT_KEY_LIB_RS, + Some("sibling-wit/test-sibling.wit"), + ); + write_sibling_package(&cargo_proj, Some(TEST_SIBLING_GENERATED_WIT)); + + let output = cargo_check_miden_target(&cargo_proj); + assert!( + !output.status.success(), + "expected a wit key alongside embedded WIT to fail the build" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert!(stderr.contains("embeds component WIT"), "unexpected stderr: {stderr}"); + assert!(stderr.contains("remove the `wit` key"), "unexpected stderr: {stderr}"); +} + +#[test] +fn bare_generate_local_wit_imports_dependency_package() { + // A manually authored crate's local `wit/` world may import a Miden dependency's interface. + // The dependency WIT (read from its compiled `.masp`) must be in the resolver before the + // local WIT is parsed, or resolution fails with a bare "package not found". + let lib_rs = r#"#![no_std] + +#[global_allocator] +static ALLOC: miden::BumpAlloc = miden::BumpAlloc::new(); + +#[cfg(not(test))] +#[panic_handler] +fn panic(_info: &core::panic::PanicInfo) -> ! { + loop {} +} + +miden::generate!(); + +pub fn use_import() -> miden::Felt { + crate::bindings::miden::test_sibling::test_sibling::get_value() +} +"#; + + let cargo_proj = account_component_project_with_sibling_dep_inner( + "bare_generate_local_wit_imports_dep", + lib_rs, + Some(TEST_SIBLING_GENERATED_WIT), + ); + let wit_dir = cargo_proj.root().join("wit"); + std::fs::create_dir_all(&wit_dir).expect("local wit directory must be created"); + std::fs::write( + wit_dir.join("consumer.wit"), + r#"package miden:wit-consumer@0.0.1; + +world consumer { + import miden:test-sibling/test-sibling@0.0.1; +} +"#, + ) + .expect("local wit fixture must be written"); + + let output = cargo_check_miden_target(&cargo_proj); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + output.status.success(), + "expected local WIT importing a dependency package to resolve: {stderr}" ); } @@ -985,8 +1188,7 @@ impl TestComponent for TestComponentStorage { let cargo_proj = account_component_project_with_sibling_dep_inner( "component_sibling_owned_record", lib_rs, - TEST_SIBLING_OWNED_TYPE_WIT, - true, + Some(TEST_SIBLING_OWNED_TYPE_WIT), ); let output = cargo_check_miden_target(&cargo_proj); let stderr = String::from_utf8_lossy(&output.stderr); diff --git a/tests/integration/src/sdk/mod.rs b/tests/integration/src/sdk/mod.rs index 1c877e9b2..f14c4aba5 100644 --- a/tests/integration/src/sdk/mod.rs +++ b/tests/integration/src/sdk/mod.rs @@ -14,6 +14,7 @@ use crate::{ }; mod base; +mod build_script; mod canonabi; mod macros; mod stdlib; @@ -128,6 +129,32 @@ fn assert_component_export_signatures_match_wit(package: &miden_mast_package::Pa /// Creates a generated workspace containing the existing basic-wallet/swapp-note FPI pair. #[track_caller] fn fpi_package_cache_regression_project() -> crate::Project { + let original_swapp_note_source = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../fixtures/components/swapp-note/src/lib.rs" + )); + let swapp_note_mutation = " let offered_asset = ¬e_assets[0];"; + assert_eq!( + original_swapp_note_source.matches(swapp_note_mutation).count(), + 1, + "the swapp-note fixture mutation must match exactly once" + ); + let swapp_note_source = original_swapp_note_source.replacen( + swapp_note_mutation, + " let offered_asset = ¬e_assets[0];\n let foreign_wallet = \ + Wallet::new(self.creator);\n foreign_wallet.receive_asset(*offered_asset);", + 1, + ); + basic_wallet_swapp_note_project("fpi_package_cache_stale_root", &swapp_note_source, None) +} + +/// Creates a generated workspace with the basic-wallet/swapp-note pair and optional build script. +#[track_caller] +fn basic_wallet_swapp_note_project( + name: &str, + swapp_note_source: &str, + swapp_note_build_script: Option<&str>, +) -> crate::Project { let sdk_path = sdk_crate_path(); let workspace_manifest = r#" [workspace] @@ -179,9 +206,6 @@ package = "miden:swapp-note" [package.metadata.miden.dependencies] "miden:basic-wallet" = {{ path = "../basic-wallet" }} - -[package.metadata.component.target.dependencies] -"miden:basic-wallet" = {{ path = "../basic-wallet/target/generated-wit/" }} "#, sdk_path.display(), ); @@ -197,28 +221,8 @@ path = "src/lib.rs" [dependencies] basic-wallet = { path = "../basic-wallet" } - -[package.metadata.miden.dependencies] -basic-wallet = { wit = "../basic-wallet/target/generated-wit/" } "#; - let original_swapp_note_source = include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../fixtures/components/swapp-note/src/lib.rs" - )); - let swapp_note_mutation = " let offered_asset = ¬e_assets[0];"; - assert_eq!( - original_swapp_note_source.matches(swapp_note_mutation).count(), - 1, - "the swapp-note fixture mutation must match exactly once" - ); - let swapp_note_source = original_swapp_note_source.replacen( - swapp_note_mutation, - " let offered_asset = ¬e_assets[0];\n let foreign_wallet = \ - Wallet::new(self.creator);\n foreign_wallet.receive_asset(*offered_asset);", - 1, - ); - - project("fpi_package_cache_stale_root") + let mut builder = project(name) .file("Cargo.toml", workspace_manifest) .file( ".cargo/config.toml", @@ -244,8 +248,11 @@ basic-wallet = { wit = "../basic-wallet/target/generated-wit/" } ) .file("swapp-note/Cargo.toml", &swapp_note_cargo) .file("swapp-note/miden-project.toml", swapp_note_miden_manifest) - .file("swapp-note/src/lib.rs", &swapp_note_source) - .build() + .file("swapp-note/src/lib.rs", swapp_note_source); + if let Some(build_script) = swapp_note_build_script { + builder = builder.file("swapp-note/build.rs", build_script); + } + builder.build() } /// Reads the named dependency package from a compiled consumer's filesystem cache. diff --git a/tests/support/Cargo.toml b/tests/support/Cargo.toml index 4178ec012..e77386315 100644 --- a/tests/support/Cargo.toml +++ b/tests/support/Cargo.toml @@ -31,6 +31,7 @@ miden-processor.workspace = true miden-debug = { workspace = true, features = ["proptest", "tui"] } midenc-expect-test.workspace = true midenc-frontend-wasm.workspace = true +midenc-frontend-wasm-metadata.workspace = true midenc-hir = { workspace = true, features = ["logging"] } midenc-session.workspace = true midenc-compile.workspace = true diff --git a/tests/support/src/compiler_test.rs b/tests/support/src/compiler_test.rs index b4b20f74b..38b70d9c4 100644 --- a/tests/support/src/compiler_test.rs +++ b/tests/support/src/compiler_test.rs @@ -331,8 +331,6 @@ impl CompilerTestBuilder { None }; - maybe_dump_cargo_expand(&config, rustflags_env.as_deref()); - argv.extend(self.midenc_flags.iter().cloned()); setup::install_reporting_hooks(); @@ -344,23 +342,23 @@ impl CompilerTestBuilder { argv, ) .unwrap_or_else(|err| err.exit()); - options.rustflags = rustflags_env; + options.rustflags = rustflags_env.clone(); options.link_modules.extend(self.link_masm_modules); let source_manager = Arc::new(DefaultSourceManager::default()); let session = Rc::new(Session::new(input.clone(), options, None, source_manager).unwrap()); + maybe_dump_cargo_expand( + &config, + rustflags_env.as_deref(), + session.filesystem_package_cache_dir().as_deref(), + ); + // The session stays pointed at the `Cargo.toml`, and that is the whole change: // the manifest is compiled as a *project*, so the namespace, target kind and // dependencies the crate declares are the ones the build uses. Extracting the // WebAssembly here and re-entering the compiler with it — which is what this did // — synthesized a project from the session instead, and the two disagreed. - // - // Keep generated WIT available when Cargo fails after macro expansion but before - // producing the final Wasm artifact. It is emitted during the build, which now - // happens inside `compile`, so this is a no-op here for a failure that has not - // occurred yet; it stays because the dump is keyed on the fixture, not on timing. - maybe_dump_public_generated_wit(&config); let artifact_name = config .project_dir @@ -1084,6 +1082,9 @@ impl CompilerTest { Ok(_) => None, Err(err) => Some(Err(format_report(err))), }; + if let Some(Ok(package)) = self.package.as_ref() { + maybe_dump_public_package_wit(&self.artifact_name, package); + } } } @@ -1205,55 +1206,26 @@ fn get_workspace_dir() -> String { compiler_workspace_dir.to_string() } -/// Copies public component WIT for a Cargo test fixture when `MIDENC_EMIT_WIT[=]` is set. +/// Writes the component WIT embedded in a compiled package when `MIDENC_EMIT_WIT[=]` is set. /// -/// An empty value or `1` writes `.wit` to the current working directory. Any other -/// non-empty value is treated as the output directory. -fn maybe_dump_public_generated_wit(test: &CargoTest) { +/// An empty value or `1` writes `.wit` to the current working directory. Any other +/// non-empty value is treated as the output directory. A package without a WIT section (a fixture +/// with no `#[component]`) is skipped. +fn maybe_dump_public_package_wit(artifact_name: &str, package: &miden_mast_package::Package) { let Some(out_dir) = emit_output_dir("MIDENC_EMIT_WIT") else { return; }; - let generated_wit_dir = cargo_test_project_dir(test).join("target/generated-wit"); - let mut wit_files = match fs::read_dir(&generated_wit_dir) { - Ok(entries) => entries - .map(|entry| { - entry.unwrap_or_else(|err| { - panic!( - "failed to inspect generated WIT directory '{}': {err}", - generated_wit_dir.display() - ) - }) - }) - .map(|entry| entry.path()) - .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("wit")) - .collect::>(), - Err(err) if err.kind() == std::io::ErrorKind::NotFound => return, - Err(err) => { - panic!( - "failed to read generated WIT directory '{}': {err}", - generated_wit_dir.display() - ) - } - }; - wit_files.sort(); - - let [wit_file] = wit_files.as_slice() else { - if wit_files.is_empty() { - return; - } - panic!( - "expected one generated WIT file in '{}', found {}", - generated_wit_dir.display(), - wit_files.len() - ); + let wit_section_id = miden_mast_package::SectionId::custom( + midenc_frontend_wasm_metadata::PACKAGE_WIT_SECTION_ID, + ) + .expect("the WIT section id must be a valid custom section id"); + let Some(section) = package.sections.iter().find(|section| section.id == wit_section_id) else { + return; }; - let out_file = out_dir.join(format!("{}.wit", sanitize_filename_component(test.name.as_ref()))); - let wit_source = fs::read(wit_file).unwrap_or_else(|err| { - panic!("failed to read generated WIT file '{}': {err}", wit_file.display()) - }); - fs::write(&out_file, wit_source).unwrap_or_else(|err| { + let out_file = out_dir.join(format!("{}.wit", sanitize_filename_component(artifact_name))); + fs::write(&out_file, section.data.as_ref()).unwrap_or_else(|err| { panic!("failed to write generated WIT to '{}': {err}", out_file.display()) }); eprintln!("wrote generated WIT to '{}'", out_file.display()); @@ -1266,7 +1238,11 @@ fn maybe_dump_public_generated_wit(test: &CargoTest) { /// the current working directory. When set to `1`, it is treated as enabled and also defaults to /// the current working directory. When set to a non-empty value other than `1`, it is treated as /// the output directory. -fn maybe_dump_cargo_expand(test: &CargoTest, rustflags_env: Option<&str>) { +fn maybe_dump_cargo_expand( + test: &CargoTest, + rustflags_env: Option<&str>, + package_cache_dir: Option<&Path>, +) { let Some(out_dir) = emit_output_dir("MIDENC_EMIT_MACRO_EXPAND") else { return; }; @@ -1295,6 +1271,12 @@ fn maybe_dump_cargo_expand(test: &CargoTest, rustflags_env: Option<&str>) { if let Some(rustflags_env) = rustflags_env { cmd.env("RUSTFLAGS", rustflags_env); } + // Point macro expansion at the session's package cache. This is also the contract-build + // script's recursion guard, so a fixture with a `build.rs` expands instead of spawning a + // nested `cargo miden build` from inside `cargo expand`. + if let Some(package_cache_dir) = package_cache_dir { + cmd.env("MIDENC_PACKAGE_CACHE", package_cache_dir); + } let output = cmd.output().unwrap_or_else(|err| { panic!("failed to invoke 'cargo expand' (is cargo-expand installed?): {err}") diff --git a/tests/support/src/testing/setup.rs b/tests/support/src/testing/setup.rs index 4a4e73f85..b76ce05a9 100644 --- a/tests/support/src/testing/setup.rs +++ b/tests/support/src/testing/setup.rs @@ -78,7 +78,7 @@ pub fn build_empty_component_for_test(context: Rc) -> MidenComponent { MidenComponent { world, component: Some(component), - account_component_metadata_bytes: None, + sections: Default::default(), source_provenance: miden_assembly::ProjectSourceProvenanceInputs { root: miden_assembly::SourceFileProvenance { path: Path::new("mod.rs").to_path_buf().into_boxed_path(), diff --git a/tools/cargo-miden/src/cli.rs b/tools/cargo-miden/src/cli.rs index 59a184db8..36dc1325a 100644 --- a/tools/cargo-miden/src/cli.rs +++ b/tools/cargo-miden/src/cli.rs @@ -1,6 +1,6 @@ use clap::{Parser, Subcommand}; -use crate::commands::{BuildCommand, NewCommand, TestCommand}; +use crate::commands::{BuildCommand, NewCommand, PackageCacheCommand, TestCommand}; /// Top-level command-line interface for `cargo-miden`. #[derive(Debug, Parser)] @@ -25,4 +25,9 @@ pub enum CargoMidenCommand { Build(BuildCommand), /// Run the miden-tests in the project. Test(TestCommand), + /// Print the package-cache location and build-script inputs of the current project. + /// + /// Contract build scripts use this to populate `MIDENC_PACKAGE_CACHE` for builds that + /// `midenc` does not drive. + PackageCache(PackageCacheCommand), } diff --git a/tools/cargo-miden/src/commands/mod.rs b/tools/cargo-miden/src/commands/mod.rs index 5a186686a..a7512e438 100644 --- a/tools/cargo-miden/src/commands/mod.rs +++ b/tools/cargo-miden/src/commands/mod.rs @@ -1,7 +1,9 @@ pub mod build; pub mod new_project; +pub mod package_cache; pub mod test; pub use build::BuildCommand; pub use new_project::NewCommand; +pub use package_cache::PackageCacheCommand; pub use test::TestCommand; diff --git a/tools/cargo-miden/src/commands/package_cache.rs b/tools/cargo-miden/src/commands/package_cache.rs new file mode 100644 index 000000000..a8d0e698a --- /dev/null +++ b/tools/cargo-miden/src/commands/package_cache.rs @@ -0,0 +1,65 @@ +use std::rc::Rc; + +use anyhow::{Result, anyhow}; +use clap::Args; +use midenc_compile::Compiler; +use midenc_session::{InputFile, diagnostics::PrintDiagnostic}; + +/// Command-line arguments accepted by `cargo miden package-cache`. +/// +/// All arguments are parsed by the `midenc` compiler's argument parser, exactly like +/// `cargo miden build`. The printed cache directory therefore matches the directory a build +/// with the same arguments uses. +#[derive(Clone, Debug, Args)] +#[command(disable_version_flag = true, trailing_var_arg = true)] +pub struct PackageCacheCommand { + /// Arguments parsed by midenc (includes cargo-compatible options). + #[arg(value_name = "ARG", allow_hyphen_values = true)] + pub args: Vec, +} + +impl PackageCacheCommand { + /// Prints the package-cache location and the build-script inputs of the current project. + /// + /// The output is line oriented, one `key=value` item per line: + /// - `cache-dir=` — the fingerprinted package-cache directory of this project; + /// - `source-dependencies=` — direct dependencies compiled into the cache; + /// - `watch=` — an input a contract build script must watch (repeated). The list + /// ends with this `cargo-miden` binary itself, so a compiler update re-runs the build + /// script and rotates the emitted cache path. + pub fn exec(self) -> Result<()> { + let cwd = std::env::current_dir()?; + let compiler_opts = + Compiler::try_parse_from(cwd.clone(), &self.args).unwrap_or_else(|err| err.exit()); + + let manifest_path = match compiler_opts.manifest_path.as_deref() { + Some(manifest_path) => manifest_path.to_path_buf(), + None => cwd.join("Cargo.toml"), + }; + let input = InputFile::from_path(&manifest_path) + .map_err(|err| anyhow!("failed to read '{}': {err}", manifest_path.display()))?; + let session = Rc::new( + compiler_opts + .into_session(input, None, None) + .map_err(|err| anyhow!("{}", PrintDiagnostic::new(err)))?, + ); + + let cache_dir = session.filesystem_package_cache_dir().ok_or_else(|| { + anyhow!( + "'{}' does not locate a Miden project, so it has no package cache", + manifest_path.display() + ) + })?; + let inputs = session.package_cache_build_inputs().unwrap_or_default(); + + println!("cache-dir={}", cache_dir.display()); + println!("source-dependencies={}", inputs.source_dependency_count); + for path in &inputs.watch_paths { + println!("watch={}", path.display()); + } + if let Ok(current_exe) = std::env::current_exe() { + println!("watch={}", current_exe.display()); + } + Ok(()) + } +} diff --git a/tools/cargo-miden/src/lib.rs b/tools/cargo-miden/src/lib.rs index 6a1ca2eac..d091d916b 100644 --- a/tools/cargo-miden/src/lib.rs +++ b/tools/cargo-miden/src/lib.rs @@ -53,6 +53,10 @@ where cmd.exec()?; Ok(None) } + cli::CargoMidenCommand::PackageCache(cmd) => { + cmd.exec()?; + Ok(None) + } } } diff --git a/tools/cargo-miden/src/template.rs b/tools/cargo-miden/src/template.rs index 7910da1a5..370f64a6f 100644 --- a/tools/cargo-miden/src/template.rs +++ b/tools/cargo-miden/src/template.rs @@ -177,13 +177,6 @@ version = \"{}\" .and_then(|metadata| metadata.get("miden")) .and_then(|miden| miden.get("dependencies")) .and_then(|dependencies| dependencies.as_table_like()); - let component_target_dependencies = cargo_manifest - .get("package") - .and_then(|package| package.get("metadata")) - .and_then(|metadata| metadata.get("component")) - .and_then(|component| component.get("target")) - .and_then(|target| target.get("dependencies")) - .and_then(|dependencies| dependencies.as_table_like()); manifest.push_str("[dependencies]\n"); manifest.push_str("miden-core = \"*\"\n"); @@ -208,43 +201,12 @@ version = \"{}\" .and_then(|package| package.get("metadata")) .and_then(|metadata| metadata.get("miden")) .and_then(|miden| miden.get("supported-types")); - let mut wit_dependencies = Vec::new(); - if let Some(dependencies) = metadata_dependencies { - for (name, dependency) in dependencies.iter() { - if let Some(wit) = dependency.get("wit").and_then(|wit| wit.as_str()) { - wit_dependencies.push((miden_dependency_name(name).to_string(), wit.to_string())); - } - } - } - if let Some(dependencies) = component_target_dependencies { - for (name, dependency) in dependencies.iter() { - let wit = dependency - .get("wit") - .or_else(|| dependency.get("path")) - .and_then(|wit| wit.as_str()); - if let Some(wit) = wit { - wit_dependencies.push((miden_dependency_name(name).to_string(), wit.to_string())); - } - } - } - if supported_types.is_some() || !wit_dependencies.is_empty() { - manifest.push('\n'); - } if let Some(supported_types) = supported_types { + manifest.push('\n'); manifest.push_str("[package.metadata.miden]\n"); manifest.push_str(&format!("supported-types = {supported_types}\n")); } - if !wit_dependencies.is_empty() { - manifest.push_str("\n[package.metadata.miden.dependencies]\n"); - for (name, wit) in wit_dependencies { - manifest.push_str(&format!( - "{} = {{ wit = \"{}\" }}\n", - toml_key(&name), - toml_escape(&wit) - )); - } - } manifest } diff --git a/tools/cargo-miden/tests/mod.rs b/tools/cargo-miden/tests/mod.rs index 54ec0ea2d..80695c5d0 100755 --- a/tools/cargo-miden/tests/mod.rs +++ b/tools/cargo-miden/tests/mod.rs @@ -1,4 +1,5 @@ mod masm_dependency; mod p2id_cargo_miden_build; +mod package_cache_cmd; mod utils; mod workspace; diff --git a/tools/cargo-miden/tests/package_cache_cmd.rs b/tools/cargo-miden/tests/package_cache_cmd.rs new file mode 100644 index 000000000..7f8556221 --- /dev/null +++ b/tools/cargo-miden/tests/package_cache_cmd.rs @@ -0,0 +1,92 @@ +//! Tests for the `cargo miden package-cache` build-script query. + +use std::{env, fs, path::Path, process::Command}; + +/// Writes a minimal Miden project with the given `[dependencies]` tail. +fn write_project(dir: &Path, name: &str, dependencies: &str) { + fs::create_dir_all(dir.join("src")).unwrap(); + fs::write( + dir.join("miden-project.toml"), + format!( + "[package]\nname = \"{name}\"\nversion = \"1.0.0\"\n\n[lib]\npath = \ + \"src/lib.rs\"\n{dependencies}" + ), + ) + .unwrap(); + fs::write( + dir.join("Cargo.toml"), + format!("[package]\nname = \"{name}\"\nversion = \"1.0.0\"\n"), + ) + .unwrap(); + fs::write(dir.join("src/lib.rs"), "").unwrap(); +} + +#[test] +fn package_cache_command_prints_cache_dir_and_build_script_inputs() { + let scratch = + env::temp_dir().join(format!("cargo_miden_package_cache_cmd_{}", std::process::id())); + let _ = fs::remove_dir_all(&scratch); + let root = scratch.join("root"); + let dependency = scratch.join("dependency"); + write_project( + &root, + "root", + "\n[dependencies]\nregistry-dep = \"*\"\ndependency = { path = \"../dependency\" }\n", + ); + write_project(&dependency, "dependency", ""); + + let output = Command::new(env!("CARGO_BIN_EXE_cargo-miden")) + .args(["miden", "package-cache", "--release"]) + .current_dir(&root) + .output() + .expect("failed to run cargo-miden"); + assert!( + output.status.success(), + "package-cache failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8(output.stdout).unwrap(); + + let cache_dir = stdout + .lines() + .find_map(|line| line.strip_prefix("cache-dir=")) + .expect("the output must name the cache directory"); + let cache_dir = Path::new(cache_dir); + let canonical_root = root.canonicalize().unwrap(); + assert!( + cache_dir.starts_with(canonical_root.join("target").join("miden").join("packages")), + "the cache must live in the owned project layout, got '{}'", + cache_dir.display() + ); + let fingerprint = cache_dir.file_name().unwrap().to_str().unwrap(); + assert_eq!(fingerprint.len(), 16, "the cache directory must be a fingerprint"); + assert!( + fingerprint + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)), + "the fingerprint must be lowercase hexadecimal, got '{fingerprint}'" + ); + + let source_dependencies = stdout + .lines() + .find_map(|line| line.strip_prefix("source-dependencies=")) + .expect("the output must report the source-dependency count"); + assert_eq!(source_dependencies, "1", "only the source-project dependency counts"); + + let watches: Vec<&Path> = stdout + .lines() + .filter_map(|line| line.strip_prefix("watch=")) + .map(Path::new) + .collect(); + let watched = |suffix: &str| watches.iter().any(|path| path.ends_with(suffix)); + assert!(watched("root/miden-project.toml"), "watch list: {watches:?}"); + assert!(watched("dependency/miden-project.toml"), "watch list: {watches:?}"); + assert!(watched("dependency/src"), "watch list: {watches:?}"); + assert!(!watched("root/src"), "root sources must not be watched: {watches:?}"); + assert!( + watches.iter().any(|path| path.ends_with("cargo-miden")), + "the tool binary itself must be watched: {watches:?}" + ); + + let _ = fs::remove_dir_all(&scratch); +}