fizzyx-sys 0.1.1

Low-level FFI bindings to the Fizzy WebAssembly interpreter.
Documentation
use std::collections::BTreeSet;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};

/// Subdirectories of the Fizzy source tree required to build the library.
const VENDORED_DIRS: &[&str] = &["include", "lib", "cmake"];
/// Top-level files of the Fizzy source tree required to build the library.
const VENDORED_FILES: &[&str] = &["CMakeLists.txt", "LICENSE"];

fn main() {
    let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap());
    let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());
    // `fizzy/` is the upstream git submodule (present when building inside the
    // fizzyx repo). `fizzy-vendored/` is a committed, `Cargo.toml`-free mirror of
    // just the sources needed to build. Cargo refuses to package the submodule
    // (its root carries a `Cargo.toml`, which makes Cargo treat it as a foreign
    // package), so the mirror is what gets compiled and what ships in the crate.
    let submodule_dir = manifest_dir.join("fizzy");
    let vendored_dir = manifest_dir.join("fizzy-vendored");
    let shim = manifest_dir.join("shim").join("compat_shim.hpp");

    println!("cargo:rerun-if-changed=wrapper.h");
    println!("cargo:rerun-if-changed=build.rs");
    println!("cargo:rerun-if-changed={}", shim.display());

    // When the submodule is checked out, refresh the mirror from it. This keeps
    // `fizzy-vendored/` in lockstep with the submodule automatically; the only
    // manual step after bumping Fizzy is committing the regenerated mirror.
    if submodule_dir.join("CMakeLists.txt").exists() {
        println!("cargo:rerun-if-changed={}", submodule_dir.display());
        sync_vendored(&submodule_dir, &vendored_dir);
    }

    // The mirror must exist either way: synced above (repo build) or shipped in
    // the package tarball (crates.io / docs.rs build).
    if !vendored_dir.join("CMakeLists.txt").exists() {
        panic!(
            "Fizzy sources are missing: neither the submodule `{}` nor the vendored \
             copy `{}` is present.\n\
             Run `git submodule update --init --recursive` and rebuild.",
            submodule_dir.display(),
            vendored_dir.display()
        );
    }

    generate_bindings(&vendored_dir.join("include"), &out_dir);

    // docs.rs builds in a read-only, network-less sandbox: only generate the
    // bindings (done above) and skip building/linking the native library.
    if env::var_os("DOCS_RS").is_some() {
        return;
    }

    build_and_link(&vendored_dir, &shim);
}

/// Mirrors the Fizzy sources needed to build (see [`VENDORED_DIRS`] and
/// [`VENDORED_FILES`]) from the submodule into the committed `fizzy-vendored/`
/// directory, deliberately omitting the upstream `Cargo.toml` (and everything
/// else) so Cargo will package the result.
///
/// Files are copied only when their contents differ, and any stale files left in
/// the mirror are pruned, so an unchanged submodule leaves a clean `git status`.
fn sync_vendored(submodule: &Path, vendored: &Path) {
    let mut kept: BTreeSet<PathBuf> = BTreeSet::new();
    for dir in VENDORED_DIRS {
        mirror_dir(
            &submodule.join(dir),
            &vendored.join(dir),
            Path::new(dir),
            &mut kept,
        );
    }
    for file in VENDORED_FILES {
        let src = submodule.join(file);
        if src.exists() {
            copy_if_different(&src, &vendored.join(file));
            kept.insert(PathBuf::from(file));
        }
    }
    prune_extraneous(vendored, vendored, &kept);
}

/// Recursively mirrors `src` into `dst`, recording every copied file's path
/// (relative to the mirror root) in `kept`.
fn mirror_dir(src: &Path, dst: &Path, rel: &Path, kept: &mut BTreeSet<PathBuf>) {
    let entries =
        fs::read_dir(src).unwrap_or_else(|e| panic!("failed to read `{}`: {e}", src.display()));
    for entry in entries {
        let entry = entry.unwrap();
        let child_src = entry.path();
        let child_rel = rel.join(entry.file_name());
        let child_dst = dst.join(entry.file_name());
        if child_src.is_dir() {
            mirror_dir(&child_src, &child_dst, &child_rel, kept);
        } else {
            copy_if_different(&child_src, &child_dst);
            kept.insert(child_rel);
        }
    }
}

/// Copies `src` to `dst` only if the destination is missing or differs, creating
/// parent directories as needed.
fn copy_if_different(src: &Path, dst: &Path) {
    let src_bytes =
        fs::read(src).unwrap_or_else(|e| panic!("failed to read `{}`: {e}", src.display()));
    if fs::read(dst).is_ok_and(|dst_bytes| dst_bytes == src_bytes) {
        return;
    }
    if let Some(parent) = dst.parent() {
        fs::create_dir_all(parent)
            .unwrap_or_else(|e| panic!("failed to create `{}`: {e}", parent.display()));
    }
    fs::write(dst, &src_bytes)
        .unwrap_or_else(|e| panic!("failed to write `{}`: {e}", dst.display()));
}

/// Removes any file under `dir` whose mirror-relative path is not in `kept`, then
/// deletes directories left empty. Keeps the mirror an exact copy of the sources.
fn prune_extraneous(root: &Path, dir: &Path, kept: &BTreeSet<PathBuf>) {
    let Ok(entries) = fs::read_dir(dir) else {
        return;
    };
    for entry in entries {
        let path = entry.unwrap().path();
        if path.is_dir() {
            prune_extraneous(root, &path, kept);
            let _ = fs::remove_dir(&path); // succeeds only if now empty
        } else {
            let rel = path.strip_prefix(root).unwrap();
            if !kept.contains(rel) {
                let _ = fs::remove_file(&path);
            }
        }
    }
}

/// Builds Fizzy's static library with CMake and emits the link directives.
fn build_and_link(source_dir: &Path, shim: &Path) {
    let dst = cmake::Config::new(source_dir)
        // Only the core `fizzy` library is needed; disabling testing also keeps
        // the Hunter package manager (and its network fetches) out of the build.
        .define("FIZZY_TESTING", "OFF")
        .define("FIZZY_WASI", "OFF")
        .define("HUNTER_ENABLED", "OFF")
        // Force-include a shim that patches up the vendored sources for modern
        // standard libraries: the `<algorithm>` include that libstdc++ 15 no
        // longer provides transitively, and the `std::char_traits<unsigned char>`
        // that libc++ (>= 19) no longer supports out of the box.
        .cxxflag("-include")
        .cxxflag(shim.to_str().expect("shim path is not valid UTF-8"))
        .build_target("fizzy")
        .build();

    // With `build_target`, CMake places artifacts under `<out>/build`; Fizzy in
    // turn writes archives into `<binary-dir>/lib`.
    println!(
        "cargo:rustc-link-search=native={}",
        dst.join("build").join("lib").display()
    );
    println!("cargo:rustc-link-lib=static=fizzy");

    // Fizzy is C++, so the C++ standard library must be linked as well.
    println!("cargo:rustc-link-lib=dylib={}", cpp_stdlib());
}

/// Runs bindgen over the Fizzy C API header into `$OUT_DIR/bindings.rs`.
fn generate_bindings(include_dir: &Path, out_dir: &Path) {
    let bindings = bindgen::Builder::default()
        .header("wrapper.h")
        .clang_arg(format!("-I{}", include_dir.display()))
        .allowlist_function("fizzy_.*")
        .allowlist_type("Fizzy.*")
        .allowlist_var("Fizzy.*")
        .use_core()
        .ctypes_prefix("::core::ffi")
        .prepend_enum_name(false)
        .derive_default(true)
        .derive_debug(true)
        .generate()
        .expect("failed to generate Fizzy bindings");

    bindings
        .write_to_file(out_dir.join("bindings.rs"))
        .expect("failed to write Fizzy bindings");
}

/// Returns the name of the C++ standard library to link against for the target.
fn cpp_stdlib() -> &'static str {
    let target = env::var("TARGET").unwrap_or_default();
    if target.contains("apple") || target.contains("freebsd") || target.contains("openbsd") {
        "c++"
    } else {
        "stdc++"
    }
}