quill-mlx-sys 0.2.2

Low-level FFI bindings for Apple's MLX machine learning framework
extern crate cmake;

use cmake::Config;
use std::{env, fs, path::PathBuf, process::Command};

/// Find the clang runtime library path dynamically using xcrun
fn find_clang_rt_path() -> Option<String> {
    // Use xcrun to find the active toolchain path
    let output = Command::new("xcrun")
        .args(["--show-sdk-platform-path"])
        .output()
        .ok()?;

    if !output.status.success() {
        return None;
    }

    // Get the developer directory which contains the toolchain
    let output = Command::new("xcode-select")
        .args(["--print-path"])
        .output()
        .ok()?;

    if !output.status.success() {
        return None;
    }

    let developer_dir = String::from_utf8_lossy(&output.stdout).trim().to_string();
    let toolchain_base = format!(
        "{}/Toolchains/XcodeDefault.xctoolchain/usr/lib/clang",
        developer_dir
    );

    // Find the clang version directory (it varies by Xcode version)
    let clang_dir = std::fs::read_dir(&toolchain_base).ok()?;
    for entry in clang_dir.flatten() {
        let darwin_path = entry.path().join("lib/darwin");
        let clang_rt_lib = darwin_path.join("libclang_rt.osx.a");
        if clang_rt_lib.exists() {
            return Some(darwin_path.to_string_lossy().to_string());
        }
    }

    None
}

/// Determine a stable directory for the MLX metallib that survives `cargo install`
/// temp dir cleanup. Uses `~/.mlx/lib/v{version}/`.
///
/// When `cargo install` builds a crate, it uses a temporary directory that is
/// deleted after the binary is copied. The CMake build bakes the metallib path
/// into the binary via `-DMETAL_PATH=...`. If that path points to the temp dir,
/// the binary fails at runtime with "Failed to load the default metallib".
///
/// By setting MLX_METAL_PATH to a stable home-directory location (and creating
/// it before CMake runs so CMake can output the metallib there directly), the
/// compiled-in METAL_PATH remains valid after the temp dir is cleaned up.
#[cfg(feature = "metal")]
fn stable_metallib_dir() -> PathBuf {
    let version = env!("CARGO_PKG_VERSION");
    let home = env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
    PathBuf::from(home)
        .join(".mlx")
        .join("lib")
        .join(format!("v{}", version))
}

fn build_and_link_mlx_c() {
    let mut config = Config::new("src/mlx-c");
    config.very_verbose(true);
    config.define("CMAKE_INSTALL_PREFIX", ".");

    // Use Xcode's clang to ensure compatibility with the macOS SDK
    config.define("CMAKE_C_COMPILER", "/usr/bin/cc");
    config.define("CMAKE_CXX_COMPILER", "/usr/bin/c++");

    #[cfg(debug_assertions)]
    {
        config.define("CMAKE_BUILD_TYPE", "Debug");
    }

    #[cfg(not(debug_assertions))]
    {
        config.define("CMAKE_BUILD_TYPE", "Release");
    }

    config.define("MLX_BUILD_METAL", "OFF");
    config.define("MLX_BUILD_ACCELERATE", "OFF");

    #[cfg(feature = "metal")]
    {
        config.define("MLX_BUILD_METAL", "ON");

        // Point MLX_METAL_PATH to a stable location so the compiled-in
        // METAL_PATH survives `cargo install` temp dir cleanup.
        // Must create the directory BEFORE CMake runs because CMake
        // outputs the compiled metallib directly to MLX_METAL_PATH.
        let metallib_dir = stable_metallib_dir();
        fs::create_dir_all(&metallib_dir).expect("Failed to create stable metallib directory");
        config.define("MLX_METAL_PATH", metallib_dir.to_str().unwrap());
    }

    #[cfg(feature = "accelerate")]
    {
        config.define("MLX_BUILD_ACCELERATE", "ON");
    }

    // build the mlx-c project
    let dst = config.build();

    println!("cargo:rustc-link-search=native={}/build/lib", dst.display());
    println!("cargo:rustc-link-lib=static=mlx");
    println!("cargo:rustc-link-lib=static=mlxc");

    println!("cargo:rustc-link-lib=c++");
    println!("cargo:rustc-link-lib=dylib=objc");
    println!("cargo:rustc-link-lib=framework=Foundation");

    #[cfg(feature = "metal")]
    {
        println!("cargo:rustc-link-lib=framework=Metal");

        // Verify CMake output the metallib to the stable location.
        let metallib_dir = stable_metallib_dir();
        let target_metallib = metallib_dir.join("mlx.metallib");
        if !target_metallib.exists() {
            eprintln!(
                "cargo:warning=mlx.metallib not found at {}. \
                 Runtime Metal operations may fail.",
                target_metallib.display()
            );
        }
    }

    #[cfg(feature = "accelerate")]
    {
        println!("cargo:rustc-link-lib=framework=Accelerate");
    }

    // Link against Xcode's clang runtime for ___isPlatformVersionAtLeast symbol
    // This is needed on macOS 26+ where the bundled LLVM runtime may be outdated
    // See: https://github.com/conda-forge/llvmdev-feedstock/issues/244
    if let Some(clang_rt_path) = find_clang_rt_path() {
        println!("cargo:rustc-link-search={}", clang_rt_path);
        println!("cargo:rustc-link-lib=static=clang_rt.osx");
    }
}

fn main() {
    build_and_link_mlx_c();

    // generate bindings
    let mut builder = bindgen::Builder::default()
        .rust_target("1.73.0".parse().expect("rust-version"))
        .header("src/mlx-c/mlx/c/mlx.h")
        .header("src/mlx-c/mlx/c/linalg.h")
        .header("src/mlx-c/mlx/c/error.h")
        .header("src/mlx-c/mlx/c/transforms_impl.h")
        .clang_arg("-Isrc/mlx-c")
        .parse_callbacks(Box::new(bindgen::CargoCallbacks::new()));

    // Add macOS SDK include path so bindgen can find stdlib.h
    if let Ok(output) = Command::new("xcrun").args(["--show-sdk-path"]).output() {
        if output.status.success() {
            let sdk_path = String::from_utf8_lossy(&output.stdout).trim().to_string();
            builder = builder.clang_arg(format!("-isysroot{}", sdk_path));
        }
    }

    let bindings = builder
        .generate()
        .expect("Unable to generate bindings");

    // Write the bindings to the $OUT_DIR/bindings.rs file.
    let out_path = PathBuf::from(env::var("OUT_DIR").unwrap());
    bindings
        .write_to_file(out_path.join("bindings.rs"))
        .expect("Couldn't write bindings!");
}