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:?}");
43
44    let target_dir = scratch_dir.join("target");
45    eprintln!("target_dir={target_dir:?}");
46    eprintln!("creating {target_dir:?}");
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:?}");
77    eprintln!("creating {crate_dir:?}");
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 = "2021"
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::new(
368            io::ErrorKind::Other,
369            format!("cargo exited with code {status}"),
370        ));
371    }
372
373    Ok(target_dir
374        .join("wasm32-wasip1")
375        .join("release")
376        .join(crate_name.replace('-', "_"))
377        .with_extension("wasm"))
378}
379
380fn optimize_wasm_codec(wasm: &Path, nix_env: &NixEnv) -> io::Result<PathBuf> {
381    let NixEnv { wasm_opt, .. } = nix_env;
382
383    let opt_out = wasm.with_extension("opt.wasm");
384
385    let mut cmd = Command::new(wasm_opt);
386
387    cmd.arg("--enable-sign-ext")
388        .arg("--disable-threads")
389        .arg("--enable-mutable-globals")
390        .arg("--enable-nontrapping-float-to-int")
391        .arg("--enable-simd")
392        .arg("--enable-bulk-memory")
393        .arg("--disable-exception-handling")
394        .arg("--disable-tail-call")
395        .arg("--disable-reference-types")
396        .arg("--enable-multivalue")
397        .arg("--disable-gc")
398        .arg("--disable-memory64")
399        .arg("--disable-relaxed-simd")
400        .arg("--disable-extended-const")
401        .arg("--disable-strings")
402        .arg("--disable-multimemory");
403
404    cmd.arg("-O4").arg("-o").arg(&opt_out).arg(wasm);
405
406    eprintln!("executing {cmd:?}");
407
408    let status = cmd.status()?;
409    if !status.success() {
410        return Err(io::Error::new(
411            io::ErrorKind::Other,
412            format!("wasm-opt exited with code {status}"),
413        ));
414    }
415
416    Ok(opt_out)
417}
418
419fn adapt_wasi_snapshot_to_preview2(wasm: &Path) -> io::Result<PathBuf> {
420    let wasm_preview2 = wasm.with_extension("preview2.wasm");
421
422    eprintln!("reading from {wasm:?}");
423    let wasm = fs::read(wasm)?;
424
425    let mut encoder = wit_component::ComponentEncoder::default()
426        .module(&wasm)
427        .map_err(|err| {
428            io::Error::new(
429                io::ErrorKind::Other,
430                // FIXME: better error reporting in the build script
431                format!("wit_component::ComponentEncoder::module failed: {err:#}"),
432            )
433        })?
434        .adapter(
435            wasi_preview1_component_adapter_provider::WASI_SNAPSHOT_PREVIEW1_ADAPTER_NAME,
436            wasi_preview1_component_adapter_provider::WASI_SNAPSHOT_PREVIEW1_REACTOR_ADAPTER,
437        )
438        .map_err(|err| {
439            io::Error::new(
440                io::ErrorKind::Other,
441                // FIXME: better error reporting in the build script
442                format!("wit_component::ComponentEncoder::adapter failed: {err:#}"),
443            )
444        })?;
445
446    let wasm = encoder.encode().map_err(|err| {
447        io::Error::new(
448            io::ErrorKind::Other,
449            // FIXME: better error reporting in the build script
450            format!("wit_component::ComponentEncoder::encode failed: {err:#}"),
451        )
452    })?;
453
454    eprintln!("writing to {wasm_preview2:?}");
455    fs::write(&wasm_preview2, wasm)?;
456
457    Ok(wasm_preview2)
458}