alef 0.107.2

Opinionated polyglot binding generator for Rust libraries
Documentation
use alef::backends::java::JavaBackend;
use alef::core::backend::Backend;
use alef::core::config::{NewAlefConfig, ResolvedCrateConfig};
use alef::core::ir::{
    ApiSurface, EnumDef, EnumVariant, FieldDef, FunctionDef, MethodDef, ParamDef, ReceiverKind, TypeDef, TypeRef,
};
use regex::Regex;
use std::fs;
use std::path::Path;

fn java_config(crate_name: &str, package: &str, extra: &str) -> ResolvedCrateConfig {
    let config: NewAlefConfig = toml::from_str(&format!(
        r#"
[workspace]
languages = ["java", "ffi"]

[[crates]]
name = "{crate_name}"
sources = ["src/lib.rs"]

[crates.ffi]
prefix = "test"

[crates.java]
package = "{package}"

{extra}
"#,
    ))
    .expect("valid Java config");
    config.resolve().expect("resolved Java config").remove(0)
}

fn path_field(name: &str) -> FieldDef {
    FieldDef {
        name: name.into(),
        ty: TypeRef::Path,
        ..Default::default()
    }
}

fn record(crate_name: &str, name: &str) -> TypeDef {
    TypeDef {
        name: name.into(),
        rust_path: format!("{crate_name}::{name}"),
        fields: vec![path_field("path")],
        has_serde: true,
        ..Default::default()
    }
}

fn write_file(root: &Path, relative: &str, contents: &str) {
    let path = root.join(relative);
    fs::create_dir_all(path.parent().expect("fixture parent")).expect("create fixture directory");
    fs::write(path, contents).expect("write fixture file");
}

fn write_generated_package(root: &Path, package: &str, api: &ApiSurface, config: &ResolvedCrateConfig) {
    let package_dir = package.replace('.', "/");
    for file in JavaBackend
        .generate_bindings(api, config)
        .expect("generate Java package")
    {
        if file.path.extension().and_then(|extension| extension.to_str()) != Some("java") {
            continue;
        }
        let file_name = file
            .path
            .file_name()
            .expect("generated Java filename")
            .to_string_lossy();
        write_file(root, &format!("src/main/java/{package_dir}/{file_name}"), &file.content);
    }
}

fn normal_api() -> ApiSurface {
    ApiSurface {
        crate_name: "normal_lib".into(),
        version: "0.1.0".into(),
        types: vec![record("normal_lib", "Config")],
        functions: vec![FunctionDef {
            name: "create".into(),
            rust_path: "normal_lib::create".into(),
            params: vec![ParamDef {
                name: "config".into(),
                ty: TypeRef::Named("Config".into()),
                ..Default::default()
            }],
            ..Default::default()
        }],
        ..Default::default()
    }
}

fn streaming_api() -> ApiSurface {
    ApiSurface {
        crate_name: "stream_lib".into(),
        version: "0.1.0".into(),
        types: vec![
            TypeDef {
                name: "EventSource".into(),
                rust_path: "stream_lib::EventSource".into(),
                is_opaque: true,
                ..Default::default()
            },
            record("stream_lib", "EventRequest"),
            record("stream_lib", "Event"),
        ],
        ..Default::default()
    }
}

fn callback_api() -> ApiSurface {
    ApiSurface {
        crate_name: "callback_lib".into(),
        version: "0.1.0".into(),
        types: vec![
            record("callback_lib", "Config"),
            TypeDef {
                name: "Renderer".into(),
                rust_path: "callback_lib::Renderer".into(),
                methods: vec![MethodDef {
                    name: "render".into(),
                    receiver: Some(ReceiverKind::Ref),
                    return_type: TypeRef::Named("Config".into()),
                    error_type: Some("RenderError".into()),
                    ..Default::default()
                }],
                is_opaque: true,
                is_trait: true,
                ..Default::default()
            },
        ],
        ..Default::default()
    }
}

fn newtype_field(ty: TypeRef) -> FieldDef {
    FieldDef {
        name: "0".into(),
        ty,
        ..Default::default()
    }
}

fn union_api() -> ApiSurface {
    ApiSurface {
        crate_name: "union_lib".into(),
        version: "0.1.0".into(),
        enums: vec![
            EnumDef {
                name: "Choice".into(),
                rust_path: "union_lib::Choice".into(),
                has_serde: true,
                serde_tag: Some("type".into()),
                serde_content: Some("content".into()),
                variants: vec![EnumVariant {
                    name: "Local".into(),
                    fields: vec![path_field("path")],
                    ..Default::default()
                }],
                ..Default::default()
            },
            EnumDef {
                name: "AssistantContent".into(),
                rust_path: "union_lib::AssistantContent".into(),
                has_serde: true,
                serde_untagged: true,
                variants: vec![
                    EnumVariant {
                        name: "Text".into(),
                        fields: vec![newtype_field(TypeRef::String)],
                        is_tuple: true,
                        ..Default::default()
                    },
                    EnumVariant {
                        name: "Items".into(),
                        fields: vec![newtype_field(TypeRef::Vec(Box::new(TypeRef::String)))],
                        is_tuple: true,
                        ..Default::default()
                    },
                ],
                ..Default::default()
            },
        ],
        ..Default::default()
    }
}

#[test]
fn generated_user_value_mappers_serialize_native_paths() {
    let directory = tempfile::tempdir().expect("temporary Maven project");
    write_generated_package(
        directory.path(),
        "com.test.normal",
        &normal_api(),
        &java_config("normal_lib", "com.test.normal", ""),
    );
    write_generated_package(
        directory.path(),
        "com.test.stream",
        &streaming_api(),
        &java_config(
            "stream_lib",
            "com.test.stream",
            r#"
[[crates.adapters]]
name = "events"
pattern = "streaming"
core_path = "events"
owner_type = "EventSource"
item_type = "Event"
error_type = "StreamError"
request_type = "stream_lib::EventRequest"

[[crates.adapters.params]]
name = "request"
type = "EventRequest"
"#,
        ),
    );
    write_generated_package(
        directory.path(),
        "com.test.callback",
        &callback_api(),
        &java_config(
            "callback_lib",
            "com.test.callback",
            r#"
[[crates.trait_bridges]]
trait_name = "Renderer"
register_fn = "register_renderer"
"#,
        ),
    );
    write_generated_package(
        directory.path(),
        "com.test.union",
        &union_api(),
        &java_config("union_lib", "com.test.union", ""),
    );
    write_file(directory.path(), "pom.xml", POM);
    write_file(
        directory.path(),
        "src/test/java/com/test/probe/PathSerializationTest.java",
        JAVA_TEST,
    );

    let output = alef::core::tool_command("mvn")
        .args(["-q", "test"])
        .current_dir(directory.path())
        .output()
        .expect("run generated Java path serialization regression");
    assert!(
        output.status.success(),
        "generated Java packages must compile and pass their path probes:\nstdout:\n{}\nstderr:\n{}",
        String::from_utf8_lossy(&output.stdout),
        String::from_utf8_lossy(&output.stderr)
    );

    let report = fs::read_to_string(
        directory
            .path()
            .join("target/surefire-reports/TEST-com.test.probe.PathSerializationTest.xml"),
    )
    .expect("Surefire XML report");
    let suite = Regex::new(r#"<testsuite\b[^>]*\btests="6"[^>]*>"#)
        .expect("testsuite regex")
        .find(&report)
        .expect("exactly six Java tests executed")
        .as_str();
    for attribute in [r#"errors="0""#, r#"failures="0""#, r#"skipped="0""#] {
        assert!(
            suite.contains(attribute),
            "Surefire suite must contain {attribute}: {suite}"
        );
    }
}

const POM: &str = r#"<project xmlns="http://maven.apache.org/POM/4.0.0">
  <modelVersion>4.0.0</modelVersion>
  <groupId>com.test</groupId>
  <artifactId>path-serialization-test</artifactId>
  <version>1.0.0</version>
  <properties>
    <maven.compiler.release>25</maven.compiler.release>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  </properties>
  <dependencies>
    <dependency>
      <groupId>com.fasterxml.jackson.core</groupId>
      <artifactId>jackson-databind</artifactId>
      <version>2.22.3</version>
    </dependency>
    <dependency>
      <groupId>com.fasterxml.jackson.datatype</groupId>
      <artifactId>jackson-datatype-jdk8</artifactId>
      <version>2.22.3</version>
    </dependency>
    <dependency>
      <groupId>org.jspecify</groupId>
      <artifactId>jspecify</artifactId>
      <version>1.0.1</version>
    </dependency>
    <dependency>
      <groupId>org.junit.jupiter</groupId>
      <artifactId>junit-jupiter</artifactId>
      <version>6.1.3</version>
      <scope>test</scope>
    </dependency>
  </dependencies>
  <build>
    <plugins>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-compiler-plugin</artifactId>
        <version>3.16.0</version>
        <configuration>
          <release>25</release>
          <compilerArgs><arg>--enable-preview</arg></compilerArgs>
        </configuration>
      </plugin>
      <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-surefire-plugin</artifactId>
        <version>3.6.0</version>
        <configuration>
          <failIfNoTests>true</failIfNoTests>
          <argLine>--enable-preview --enable-native-access=ALL-UNNAMED</argLine>
        </configuration>
      </plugin>
    </plugins>
  </build>
</project>
"#;

const JAVA_TEST: &str = r#"package com.test.probe;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jdk8.Jdk8Module;
import java.lang.reflect.Field;
import java.nio.file.Path;
import org.junit.jupiter.api.Test;

final class PathSerializationTest {
  private static final Path PATH = Path.of("fixtures", "a path");

  @Test
  void normalFfiMapperUsesNativePath() throws Exception {
    assertNativePath(mapper("com.test.normal.NormalLibRs", "MAPPER"));
  }

  @Test
  void streamingMapperUsesNativePath() throws Exception {
    assertNativePath(mapper("com.test.stream.EventSource", "STREAM_MAPPER"));
  }

  @Test
  void callbackMapperUsesNativePath() throws Exception {
    assertNativePath(mapper("com.test.callback.RendererBridge", "JSON"));
  }

  @Test
  void sealedUnionSerializerUsesNativePath() throws Exception {
    var choice = new com.test.union.Choice.Local(PATH);
    var json = new ObjectMapper().writeValueAsString(choice);
    assertFalse(json.contains("file:"), json);
    assertEquals(PATH.toString(), new ObjectMapper().readTree(json).at("/content/path").textValue());
  }

  @Test
  void untaggedUnionMapperUsesNativePath() {
    var content = com.test.union.AssistantContent.ofObject(PATH);
    assertEquals(PATH.toString(), content.value().textValue());
  }

  @Test
  void negativeControlProvesDefaultJacksonWritesFileUri() throws Exception {
    var mapper = new ObjectMapper().registerModule(new Jdk8Module()).findAndRegisterModules();
    assertEquals("\"" + PATH.toAbsolutePath().toUri() + "\"", mapper.writeValueAsString(PATH));
  }

  private static ObjectMapper mapper(String className, String fieldName) throws Exception {
    Field field = Class.forName(className).getDeclaredField(fieldName);
    field.setAccessible(true);
    return (ObjectMapper) field.get(null);
  }

  private static void assertNativePath(ObjectMapper mapper) throws Exception {
    var json = mapper.writeValueAsString(PATH);
    assertEquals(PATH.toString(), mapper.readTree(json).textValue());
  }
}
"#;