numcodecs_wasm_builder/
main.rs

1#![expect(missing_docs)] // FIXME
2
3use std::{
4    collections::HashMap,
5    fs, io,
6    path::{Path, PathBuf},
7    process::Command,
8    str::FromStr,
9};
10
11use clap::Parser;
12use semver::Version;
13
14#[derive(Parser, Debug)]
15#[command()]
16struct Args {
17    /// Name of the numcodecs codec crate to compile
18    #[arg(name = "crate", long)]
19    crate_: String,
20
21    /// Version of the numcodecs codec crate to compile
22    #[arg(long)]
23    version: Version,
24
25    /// Path to the codec type to export, without the leading crate name
26    #[arg(long)]
27    codec: String,
28
29    /// Path to which the wasm file is output
30    #[arg(long, short)]
31    output: PathBuf,
32}
33
34fn main() -> io::Result<()> {
35    let args = Args::parse();
36
37    let scratch_dir = scratch::path(concat!(
38        env!("CARGO_PKG_NAME"),
39        "-",
40        env!("CARGO_PKG_VERSION"),
41    ));
42    eprintln!("scratch_dir={}", scratch_dir.display());
43
44    let target_dir = scratch_dir.join("target");
45    eprintln!("target_dir={}", target_dir.display());
46    eprintln!("creating {}", target_dir.display());
47    fs::create_dir_all(&target_dir)?;
48
49    let crate_dir =
50        create_codec_wasm_component_crate(&scratch_dir, &args.crate_, &args.version, &args.codec)?;
51    copy_buildenv_to_crate(&crate_dir)?;
52
53    let nix_env = NixEnv::new(&crate_dir)?;
54
55    let wasm = build_wasm_codec(
56        &nix_env,
57        &target_dir,
58        &crate_dir,
59        &format!("{}-wasm", args.crate_),
60    )?;
61    let wasm = optimize_wasm_codec(&wasm, &nix_env)?;
62    let wasm = adapt_wasi_snapshot_to_preview2(&wasm)?;
63
64    fs::copy(wasm, args.output)?;
65
66    Ok(())
67}
68
69fn create_codec_wasm_component_crate(
70    scratch_dir: &Path,
71    crate_: &str,
72    version: &Version,
73    codec: &str,
74) -> io::Result<PathBuf> {
75    let crate_dir = scratch_dir.join(format!("{crate_}-wasm-{version}"));
76    eprintln!("crate_dir={}", crate_dir.display());
77    eprintln!("creating {}", crate_dir.display());
78    if crate_dir.exists() {
79        fs::remove_dir_all(&crate_dir)?;
80    }
81    fs::create_dir_all(&crate_dir)?;
82
83    fs::write(
84        crate_dir.join("Cargo.toml"),
85        format!(
86            r#"
87[workspace]
88
89[package]
90name = "{crate_}-wasm"
91version = "{version}"
92edition = "2024"
93
94# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
95
96[dependencies]
97numcodecs-wasm-logging = {{ version = "0.1", default-features = false }}
98numcodecs-wasm-guest = {{ version = "0.2", default-features = false }}
99numcodecs-my-codec = {{ package = "{crate_}", version = "{version}", default-features = false }}
100    "#
101        ),
102    )?;
103
104    fs::create_dir_all(crate_dir.join("src"))?;
105
106    fs::write(
107        crate_dir.join("src").join("lib.rs"),
108        format!(
109            "
110#![cfg_attr(not(test), no_main)]
111
112numcodecs_wasm_guest::export_codec!(
113    numcodecs_wasm_logging::LoggingCodec<numcodecs_my_codec::{codec}>
114);
115    "
116        ),
117    )?;
118
119    Ok(crate_dir)
120}
121
122fn copy_buildenv_to_crate(crate_dir: &Path) -> io::Result<()> {
123    fs::write(
124        crate_dir.join("flake.nix"),
125        include_str!("../buildenv/flake.nix"),
126    )?;
127    fs::write(
128        crate_dir.join("flake.lock"),
129        include_str!("../buildenv/flake.lock"),
130    )?;
131
132    fs::write(
133        crate_dir.join("include.hpp"),
134        include_str!("../buildenv/include.hpp"),
135    )?;
136
137    fs::write(
138        crate_dir.join("rust-toolchain"),
139        include_str!("../buildenv/rust-toolchain"),
140    )?;
141
142    Ok(())
143}
144
145struct NixEnv {
146    llvm_version: String,
147    ar: PathBuf,
148    clang: PathBuf,
149    libclang: PathBuf,
150    lld: PathBuf,
151    nm: PathBuf,
152    wasi_sysroot: PathBuf,
153    wasm_opt: PathBuf,
154}
155
156impl NixEnv {
157    pub fn new(flake_parent_dir: &Path) -> io::Result<Self> {
158        fn try_read_env<T: FromStr<Err: std::error::Error>>(
159            env: &HashMap<&str, &str>,
160            key: &str,
161        ) -> Result<T, io::Error> {
162            let Some(var) = env.get(key).copied() else {
163                return Err(io::Error::new(
164                    io::ErrorKind::InvalidData,
165                    format!("missing flake env key: {key}"),
166                ));
167            };
168
169            T::from_str(var).map_err(|err| {
170                io::Error::new(
171                    io::ErrorKind::InvalidData,
172                    format!("invalid flake env variable {key}={var}: {err}"),
173                )
174            })
175        }
176
177        let mut env = Command::new("nix");
178        env.current_dir(flake_parent_dir);
179        env.arg("develop");
180        // env.arg("--store");
181        // env.arg(nix_store_path);
182        env.arg("path:.");
183        env.arg("--no-update-lock-file");
184        env.arg("--ignore-environment");
185        env.arg("--command");
186        env.arg("env");
187        eprintln!("executing {env:?}");
188        let env = env.output()?;
189        eprintln!(
190            "{}\n{}",
191            String::from_utf8_lossy(&env.stdout),
192            String::from_utf8_lossy(&env.stderr)
193        );
194        let env = std::str::from_utf8(&env.stdout).map_err(|err| {
195            io::Error::new(
196                io::ErrorKind::InvalidData,
197                format!("invalid flake env output: {err}"),
198            )
199        })?;
200        let env = env
201            .lines()
202            .filter_map(|line| line.split_once('='))
203            .collect::<HashMap<_, _>>();
204
205        Ok(Self {
206            llvm_version: try_read_env(&env, "MY_LLVM_VERSION")?,
207            ar: try_read_env(&env, "MY_AR")?,
208            clang: try_read_env(&env, "MY_CLANG")?,
209            libclang: try_read_env(&env, "MY_LIBCLANG")?,
210            lld: try_read_env(&env, "MY_LLD")?,
211            nm: try_read_env(&env, "MY_NM")?,
212            wasi_sysroot: try_read_env(&env, "MY_WASI_SYSROOT")?,
213            wasm_opt: try_read_env(&env, "MY_WASM_OPT")?,
214        })
215    }
216}
217
218#[expect(clippy::too_many_lines)]
219fn configure_cargo_cmd(nix_env: &NixEnv, target_dir: &Path, crate_dir: &Path) -> Command {
220    let NixEnv {
221        llvm_version,
222        ar,
223        clang,
224        libclang,
225        lld,
226        nm,
227        wasi_sysroot,
228        ..
229    } = nix_env;
230
231    let mut cmd = Command::new("nix");
232    cmd.current_dir(crate_dir);
233    cmd.arg("develop");
234    // cmd.arg("--store");
235    // cmd.arg(nix_store_path);
236    cmd.arg("--no-update-lock-file");
237    cmd.arg("--ignore-environment");
238    cmd.arg("path:.");
239    cmd.arg("--command");
240    cmd.arg("env");
241    cmd.arg(format!("CC={clang}", clang = clang.join("clang").display()));
242    cmd.arg(format!(
243        "CXX={clang}",
244        clang = clang.join("clang++").display()
245    ));
246    cmd.arg(format!("LD={lld}", lld = lld.join("lld").display()));
247    cmd.arg(format!("LLD={lld}", lld = lld.join("lld").display()));
248    cmd.arg(format!("AR={ar}", ar = ar.display()));
249    cmd.arg(format!("NM={nm}", nm = nm.display()));
250    cmd.arg(format!(
251        "LIBCLANG_PATH={libclang}",
252        libclang = libclang.display()
253    ));
254    cmd.arg(format!(
255        "CFLAGS=--target=wasm32-wasip1 -nodefaultlibs -resource-dir {resource_dir} \
256         --sysroot={wasi_sysroot} -isystem {clang_include} -isystem {wasi32_wasi_include} \
257         -isystem {include} -B {lld} -D_WASI_EMULATED_PROCESS_CLOCKS -O3",
258        resource_dir = libclang.join("clang").join(llvm_version).display(),
259        wasi_sysroot = wasi_sysroot.display(),
260        clang_include = libclang
261            .join("clang")
262            .join(llvm_version)
263            .join("include")
264            .display(),
265        wasi32_wasi_include = wasi_sysroot.join("include").join("wasm32-wasip1").display(),
266        include = wasi_sysroot.join("include").display(),
267        lld = lld.display(),
268    ));
269    cmd.arg(format!(
270        "CXXFLAGS=--target=wasm32-wasip1 -nodefaultlibs -resource-dir {resource_dir} \
271         --sysroot={wasi_sysroot} -isystem {wasm32_wasi_cxx_include} -isystem {cxx_include} \
272         -isystem {clang_include} -isystem {wasi32_wasi_include} -isystem {include} -B {lld} \
273         -D_WASI_EMULATED_PROCESS_CLOCKS -include {cpp_include_path} -O3",
274        resource_dir = libclang.join("clang").join(llvm_version).display(),
275        wasi_sysroot = wasi_sysroot.display(),
276        wasm32_wasi_cxx_include = wasi_sysroot
277            .join("include")
278            .join("wasm32-wasip1")
279            .join("c++")
280            .join("v1")
281            .display(),
282        cxx_include = wasi_sysroot
283            .join("include")
284            .join("c++")
285            .join("v1")
286            .display(),
287        clang_include = libclang
288            .join("clang")
289            .join(llvm_version)
290            .join("include")
291            .display(),
292        wasi32_wasi_include = wasi_sysroot.join("include").join("wasm32-wasip1").display(),
293        include = wasi_sysroot.join("include").display(),
294        lld = lld.display(),
295        cpp_include_path = crate_dir.join("include.hpp").display(),
296    ));
297    cmd.arg(format!(
298        "BINDGEN_EXTRA_CLANG_ARGS=--target=wasm32-wasip1 -nodefaultlibs -resource-dir \
299         {resource_dir} --sysroot={wasi_sysroot} -isystem {wasm32_wasi_cxx_include} -isystem \
300         {cxx_include} -isystem {clang_include} -isystem {wasi32_wasi_include} -isystem {include} \
301         -B {lld} -D_WASI_EMULATED_PROCESS_CLOCKS -fvisibility=default",
302        resource_dir = libclang.join("clang").join(llvm_version).display(),
303        wasi_sysroot = wasi_sysroot.display(),
304        wasm32_wasi_cxx_include = wasi_sysroot
305            .join("include")
306            .join("wasm32-wasip1")
307            .join("c++")
308            .join("v1")
309            .display(),
310        cxx_include = wasi_sysroot
311            .join("include")
312            .join("c++")
313            .join("v1")
314            .display(),
315        clang_include = libclang
316            .join("clang")
317            .join(llvm_version)
318            .join("include")
319            .display(),
320        wasi32_wasi_include = wasi_sysroot.join("include").join("wasm32-wasip1").display(),
321        include = wasi_sysroot.join("include").display(),
322        lld = lld.display(),
323    ));
324    cmd.arg("CXXSTDLIB=c++");
325    // disable default flags from cc
326    cmd.arg("CRATE_CC_NO_DEFAULTS=1");
327    cmd.arg("LDFLAGS=-lc -lwasi-emulated-process-clocks");
328    cmd.arg(format!(
329        "RUSTFLAGS=-C panic=abort -C strip=symbols -C link-arg=-L{wasm32_wasi_lib}",
330        wasm32_wasi_lib = wasi_sysroot.join("lib").join("wasm32-wasip1").display(),
331    ));
332    cmd.arg(format!(
333        "CARGO_TARGET_DIR={target_dir}",
334        target_dir = target_dir.display()
335    ));
336
337    // we don't need nightly Rust features but need to compile std with immediate
338    // panic abort instead of compiling with nightly, we fake it and forbid the
339    // unstable_features lint
340    cmd.arg("RUSTC_BOOTSTRAP=1");
341
342    cmd.arg("cargo");
343
344    cmd
345}
346
347fn build_wasm_codec(
348    nix_env: &NixEnv,
349    target_dir: &Path,
350    crate_dir: &Path,
351    crate_name: &str,
352) -> io::Result<PathBuf> {
353    let mut cmd = configure_cargo_cmd(nix_env, target_dir, crate_dir);
354    cmd.arg("rustc")
355        .arg("--crate-type=cdylib")
356        .arg("-Z")
357        .arg("build-std=std,panic_abort")
358        .arg("-Z")
359        .arg("build-std-features=panic_immediate_abort")
360        .arg("--release")
361        .arg("--target=wasm32-wasip1");
362
363    eprintln!("executing {cmd:?}");
364
365    let status = cmd.status()?;
366    if !status.success() {
367        return Err(io::Error::other(format!("cargo exited with code {status}")));
368    }
369
370    Ok(target_dir
371        .join("wasm32-wasip1")
372        .join("release")
373        .join(crate_name.replace('-', "_"))
374        .with_extension("wasm"))
375}
376
377fn optimize_wasm_codec(wasm: &Path, nix_env: &NixEnv) -> io::Result<PathBuf> {
378    let NixEnv { wasm_opt, .. } = nix_env;
379
380    let opt_out = wasm.with_extension("opt.wasm");
381
382    let mut cmd = Command::new(wasm_opt);
383
384    cmd.arg("--enable-sign-ext")
385        .arg("--disable-threads")
386        .arg("--enable-mutable-globals")
387        .arg("--enable-nontrapping-float-to-int")
388        .arg("--enable-simd")
389        .arg("--enable-bulk-memory")
390        .arg("--disable-exception-handling")
391        .arg("--disable-tail-call")
392        .arg("--disable-reference-types")
393        .arg("--enable-multivalue")
394        .arg("--disable-gc")
395        .arg("--disable-memory64")
396        .arg("--disable-relaxed-simd")
397        .arg("--disable-extended-const")
398        .arg("--disable-strings")
399        .arg("--disable-multimemory");
400
401    cmd.arg("-O4").arg("-o").arg(&opt_out).arg(wasm);
402
403    eprintln!("executing {cmd:?}");
404
405    let status = cmd.status()?;
406    if !status.success() {
407        return Err(io::Error::other(format!(
408            "wasm-opt exited with code {status}"
409        )));
410    }
411
412    Ok(opt_out)
413}
414
415fn adapt_wasi_snapshot_to_preview2(wasm: &Path) -> io::Result<PathBuf> {
416    let wasm_preview2 = wasm.with_extension("preview2.wasm");
417
418    eprintln!("reading from {}", wasm.display());
419    let wasm = fs::read(wasm)?;
420
421    let mut encoder = wit_component::ComponentEncoder::default()
422        .module(&wasm)
423        .map_err(|err| {
424            io::Error::other(
425                // FIXME: better error reporting in the build script
426                format!("wit_component::ComponentEncoder::module failed: {err:#}"),
427            )
428        })?
429        .adapter(
430            wasi_preview1_component_adapter_provider::WASI_SNAPSHOT_PREVIEW1_ADAPTER_NAME,
431            wasi_preview1_component_adapter_provider::WASI_SNAPSHOT_PREVIEW1_REACTOR_ADAPTER,
432        )
433        .map_err(|err| {
434            io::Error::other(
435                // FIXME: better error reporting in the build script
436                format!("wit_component::ComponentEncoder::adapter failed: {err:#}"),
437            )
438        })?;
439
440    let wasm = encoder.encode().map_err(|err| {
441        io::Error::other(
442            // FIXME: better error reporting in the build script
443            format!("wit_component::ComponentEncoder::encode failed: {err:#}"),
444        )
445    })?;
446
447    eprintln!("writing to {}", wasm_preview2.display());
448    fs::write(&wasm_preview2, wasm)?;
449
450    Ok(wasm_preview2)
451}