numcodecs_wasm_builder/
main.rs

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