uniffi-bindgen-java 0.4.1

a java bindings generator for uniffi rust
Documentation
/* This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

use anyhow::{Context, Result, bail};
use camino::{Utf8Path, Utf8PathBuf};
use cargo_metadata::{CrateType, MetadataCommand, Package, Target};
use std::io::{Read, Write};
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
use std::{env, fs};
use uniffi_bindgen::{BindgenLoader, BindgenPaths};
use uniffi_bindgen_java::{GenerateOptions, generate};
use uniffi_testing::UniFFITestHelper;

/// Run the test fixtures from UniFFI
fn run_test(fixture_name: &str, test_file: &str) -> Result<()> {
    let test_path = Utf8Path::new(".").join("tests").join(test_file);
    let test_helper = UniFFITestHelper::new(fixture_name)?;
    let out_dir = test_helper.create_out_dir(env!("CARGO_TARGET_TMPDIR"), &test_path)?;
    let cdylib_path = test_helper.cdylib_path()?;

    // This whole block in designed to create a new TOML file if there is one in the fixture or a uniffi-extras.toml as a sibling of the test. The extras
    // will be concatenated to the end of the base with extra if available.
    let maybe_new_uniffi_toml_filename = {
        let maybe_base_uniffi_toml_string =
            find_uniffi_toml(fixture_name)?.and_then(read_file_contents);
        let maybe_extra_uniffi_toml_string =
            read_file_contents(test_path.with_file_name("uniffi-extras.toml"));

        // final_string will be "" if there aren't any toml files to read.
        let final_string: String = itertools::Itertools::intersperse(
            vec![
                maybe_base_uniffi_toml_string,
                maybe_extra_uniffi_toml_string,
            ]
            .into_iter()
            .flatten(),
            "\n".to_string(),
        )
        .collect();

        // If there wasn't anything read from the files, just return none so the default config file can be used.
        if final_string.is_empty() {
            None
        } else {
            //Create a unique(ish) filename for the fixture. We'll just accept that nanosecond uniqueness is good enough per fixture_name.
            let current_time = SystemTime::now()
                .duration_since(UNIX_EPOCH)
                .expect("Time went backwards")
                .as_nanos();
            let new_filename =
                out_dir.with_file_name(format!("{}-{}.toml", fixture_name, current_time));
            write_file_contents(&new_filename, &final_string)?;
            Some(new_filename)
        }
    };

    // Create BindgenPaths with cargo metadata layer and optional config override
    let mut paths = BindgenPaths::default();
    if let Some(config_path) = &maybe_new_uniffi_toml_filename {
        paths.add_config_override_layer(config_path.clone());
    }
    paths.add_cargo_metadata_layer(false)?;
    let loader = BindgenLoader::new(paths);

    // generate the fixture bindings
    generate(
        &loader,
        &GenerateOptions {
            source: cdylib_path.clone(),
            out_dir: out_dir.clone(),
            format: true,
            crate_filter: None,
        },
    )?;

    // Copy the cdylib to a flat directory for java.library.path.
    // System.loadLibrary expects "lib<name>.dylib" (macOS) or "lib<name>.so" (Linux).
    // The cdylib from cargo has a hash suffix, so we create a symlink with the
    // expected name that System.loadLibrary will find.
    let native_lib_dir = out_dir.join("native");
    fs::create_dir_all(&native_lib_dir)?;
    let cdylib_filename = cdylib_path.file_name().unwrap();
    let cdylib_dest = native_lib_dir.join(cdylib_filename);
    fs::copy(&cdylib_path, &cdylib_dest)?;

    let extension = cdylib_path.extension().unwrap(); // "dylib" or "so"
    let lib_base_name = cdylib_filename
        .strip_prefix("lib")
        .unwrap_or(cdylib_filename)
        .split('-')
        .next()
        .unwrap_or(cdylib_filename);
    let expected_lib_name = format!("lib{}.{}", lib_base_name, extension);
    let symlink_path = native_lib_dir.join(&expected_lib_name);
    if !symlink_path.exists() {
        std::os::unix::fs::symlink(cdylib_dest.file_name().unwrap(), &symlink_path)?;
    }

    // compile generated bindings and form jar
    let jar_file = build_jar(fixture_name, &out_dir)?;

    // compile test
    let status = Command::new("javac")
        .arg("-classpath")
        .arg(calc_classpath(vec![&out_dir, &jar_file]))
        // Our tests should not produce any warnings.
        .arg("-Werror")
        .arg(&test_path)
        .spawn()
        .context("Failed to spawn `javac` to compile Java test")?
        .wait()
        .context("Failed to wait for `javac` when compiling Java test")?;
    if !status.success() {
        anyhow::bail!("running `javac` failed when compiling the Java test")
    }

    // run resulting test
    let compiled_path = test_path.file_stem().unwrap();
    let run_status = Command::new("java")
        // allow for runtime assertions
        .arg("-ea")
        // Enable FFM native access
        .arg("--enable-native-access=ALL-UNNAMED")
        // Set native library path so System.loadLibrary can find the cdylib
        .arg(format!("-Djava.library.path={}", native_lib_dir))
        .arg("-classpath")
        .arg(calc_classpath(vec![
            &out_dir,
            &jar_file,
            &test_path.parent().unwrap().to_path_buf(),
        ]))
        .arg(compiled_path)
        .spawn()
        .context("Failed to spawn `java` to run Java test")?
        .wait()
        .context("Failed to wait for `java` when running Java test")?;
    if !run_status.success() {
        anyhow::bail!("Running the `java` test failed.")
    }

    Ok(())
}

/// Run a test using an absolute path library override instead of java.library.path.
/// This validates that the generated loadLibrary() code uses System.load() for absolute paths.
fn run_test_with_library_override(
    fixture_name: &str,
    test_file: &str,
    namespace: &str,
) -> Result<()> {
    let test_path = Utf8Path::new(".").join("tests").join(test_file);
    let test_helper = UniFFITestHelper::new(fixture_name)?;
    // Use a synthetic path for out_dir so it doesn't collide with run_test's out_dir
    // (create_out_dir is deterministic based on the path).
    let out_dir_key = Utf8Path::new(".")
        .join("tests")
        .join("library_override")
        .join(test_file);
    let out_dir = test_helper.create_out_dir(env!("CARGO_TARGET_TMPDIR"), &out_dir_key)?;
    let cdylib_path = test_helper.cdylib_path()?;

    let mut paths = BindgenPaths::default();
    paths.add_cargo_metadata_layer(false)?;
    let loader = BindgenLoader::new(paths);

    generate(
        &loader,
        &GenerateOptions {
            source: cdylib_path.clone(),
            out_dir: out_dir.clone(),
            format: true,
            crate_filter: None,
        },
    )?;

    // Copy the cdylib to a known absolute path (no symlink needed since we pass the full path)
    let native_lib_dir = out_dir.join("native");
    fs::create_dir_all(&native_lib_dir)?;
    let cdylib_filename = cdylib_path.file_name().unwrap();
    let extension = cdylib_path.extension().unwrap();
    let lib_base_name = cdylib_filename
        .strip_prefix("lib")
        .unwrap_or(cdylib_filename)
        .split('-')
        .next()
        .unwrap_or(cdylib_filename);
    let canonical_lib_name = format!("lib{}.{}", lib_base_name, extension);
    let lib_absolute_path = native_lib_dir.join(&canonical_lib_name);
    fs::copy(&cdylib_path, &lib_absolute_path)?;

    let jar_file = build_jar(fixture_name, &out_dir)?;

    let status = Command::new("javac")
        .arg("-classpath")
        .arg(calc_classpath(vec![&out_dir, &jar_file]))
        .arg("-Werror")
        .arg(&test_path)
        .spawn()
        .context("Failed to spawn `javac` to compile Java test")?
        .wait()
        .context("Failed to wait for `javac` when compiling Java test")?;
    if !status.success() {
        anyhow::bail!("running `javac` failed when compiling the Java test")
    }

    // Run with library override set to an absolute path and NO java.library.path,
    // so this can only work if the generated code uses System.load() for absolute paths.
    let compiled_path = test_path.file_stem().unwrap();
    let run_status = Command::new("java")
        .arg("-ea")
        .arg("--enable-native-access=ALL-UNNAMED")
        .arg(format!(
            "-Duniffi.component.{}.libraryOverride={}",
            namespace, lib_absolute_path
        ))
        // Deliberately NOT setting -Djava.library.path
        .arg("-classpath")
        .arg(calc_classpath(vec![
            &out_dir,
            &jar_file,
            &test_path.parent().unwrap().to_path_buf(),
        ]))
        .arg(compiled_path)
        .spawn()
        .context("Failed to spawn `java` to run Java test")?
        .wait()
        .context("Failed to wait for `java` when running Java test")?;
    if !run_status.success() {
        anyhow::bail!("Running the `java` test with library override failed.")
    }

    Ok(())
}

/// Get the uniffi_toml of the fixture if it exists.
/// It looks for it in the root directory of the project `name`.
fn find_uniffi_toml(name: &str) -> Result<Option<Utf8PathBuf>> {
    let metadata = MetadataCommand::new()
        .exec()
        .expect("error running cargo metadata");
    let matching: Vec<&Package> = metadata
        .packages
        .iter()
        .filter(|p| p.name == name)
        .collect();
    let package = match matching.len() {
        1 => matching[0].clone(),
        n => bail!("cargo metadata return {n} packages named {name}"),
    };
    let cdylib_targets: Vec<&Target> = package
        .targets
        .iter()
        .filter(|t| t.crate_types.iter().any(|t| t == &CrateType::CDyLib))
        .collect();
    let target = match cdylib_targets.len() {
        1 => cdylib_targets[0],
        n => bail!("Found {n} cdylib targets for {}", package.name),
    };
    let maybe_uniffi_toml = target
        .src_path
        .parent()
        .map(|uniffi_toml_dir| uniffi_toml_dir.with_file_name("uniffi.toml"));
    Ok(maybe_uniffi_toml)
}

/// Generate java bindings for the given namespace, then use the Java
/// command-line tools to compile them into a .jar file.
fn build_jar(fixture_name: &str, out_dir: &Utf8PathBuf) -> Result<Utf8PathBuf> {
    let mut jar_file = Utf8PathBuf::from(out_dir);
    jar_file.push(format!("{}.jar", fixture_name));
    let staging_dir = out_dir.join("staging");

    let status = Command::new("javac")
        // Our generated bindings should not produce any warnings; fail tests if they do.
        .arg("-Werror")
        .arg("-d")
        .arg(&staging_dir)
        .arg("-classpath")
        .arg(calc_classpath(vec![]))
        .args(
            glob::glob(&out_dir.join("**/*.java").into_string())?
                .flatten()
                .map(|p| String::from(p.to_string_lossy())),
        )
        .spawn()
        .context("Failed to spawn `javac` to compile the bindings")?
        .wait()
        .context("Failed to wait for `javac` when compiling the bindings")?;
    if !status.success() {
        bail!("running `javac` failed when compiling the bindings")
    }

    let jar_status = Command::new("jar")
        .current_dir(out_dir)
        .arg("cf")
        .arg(jar_file.file_name().unwrap())
        .arg("-C")
        .arg(&staging_dir)
        .arg(".")
        .spawn()
        .context("Failed to spawn `jar` to package the bindings")?
        .wait()
        .context("Failed to wait for `jar` when packaging the bindings")?;
    if !jar_status.success() {
        bail!("running `jar` failed")
    }

    Ok(jar_file)
}

fn calc_classpath(extra_paths: Vec<&Utf8PathBuf>) -> String {
    extra_paths
        .into_iter()
        .map(|p| p.to_string())
        // Add the system classpath as a component, using the fact that env::var returns an Option,
        // which implement Iterator
        .chain(env::var("CLASSPATH"))
        .collect::<Vec<String>>()
        .join(":")
}

/// Read the contents of the file. Any errors will be turned into None.
fn read_file_contents(path: Utf8PathBuf) -> Option<String> {
    if let Ok(metadata) = fs::metadata(&path) {
        if metadata.is_file() {
            let mut content = String::new();
            std::fs::File::open(path)
                .ok()?
                .read_to_string(&mut content)
                .ok()?;
            Some(content)
        } else {
            None
        }
    } else {
        None
    }
}

fn write_file_contents(path: &Utf8PathBuf, contents: &str) -> Result<()> {
    std::fs::File::create(path)?.write_all(contents.as_bytes())?;
    Ok(())
}

macro_rules! fixture_tests {
    {
        $(($test_name:ident, $fixture_name:expr, $test_script:expr),)*
    } => {
    $(
        #[test]
        fn $test_name() -> Result<()> {
            run_test($fixture_name, $test_script)
        }
    )*
    }
}

fixture_tests! {
    (test_arithmetic, "uniffi-example-arithmetic", "scripts/TestArithmetic.java"),
    (test_geometry, "uniffi-example-geometry", "scripts/TestGeometry.java"),
    (test_rondpoint, "uniffi-example-rondpoint", "scripts/TestRondpoint.java"),
    // todolist: namespace class `Todolist` and object class `TodoList` produce filenames that
    // collide on case-insensitive filesystems (macOS).
    // (test_todolist, "uniffi-example-todolist", "scripts/TestTodolist.java"),
    (test_sprites, "uniffi-example-sprites", "scripts/TestSprites.java"),
    (test_coverall, "uniffi-fixture-coverall", "scripts/TestFixtureCoverall.java"),
    (test_chronological, "uniffi-fixture-time", "scripts/TestChronological.java"),
    (test_custom_types, "uniffi-example-custom-types", "scripts/TestCustomTypes/TestCustomTypes.java"),
    (test_external_types, "uniffi-fixture-ext-types", "scripts/TestImportedTypes/TestImportedTypes.java"),
    (test_futures, "uniffi-example-futures", "scripts/TestFutures.java"),
    (test_futures_fixtures, "uniffi-fixture-futures", "scripts/TestFixtureFutures/TestFixtureFutures.java"),
    (test_trait_methods, "uniffi-fixture-trait-methods", "scripts/TestTraitMethods.java"),
    (test_omit_checksums, "uniffi-example-arithmetic", "scripts/TestOmitChecksums/TestOmitChecksums.java"),
    (test_proc_macro, "uniffi-fixture-proc-macro", "scripts/TestProcMacro.java"),
    (test_rename, "uniffi-fixture-rename", "scripts/TestRename/TestRename.java"),
    (test_primitive_arrays, "uniffi-fixture-primitive-arrays", "scripts/TestPrimitiveArrays.java"),
}

#[test]
fn test_library_override_absolute_path() -> Result<()> {
    run_test_with_library_override(
        "uniffi-example-arithmetic",
        "scripts/TestArithmetic.java",
        "arithmetic",
    )
}