From 832b7809e551177e1cdbd00c6ba86264ee4c6bcd Mon Sep 17 00:00:00 2001 From: Murph Murphy Date: Fri, 7 Aug 2026 19:14:52 -0600 Subject: [PATCH 1/9] Core of 0.32 upgrade --- .github/workflows/rust-ci.yml | 3 + CHANGELOG.md | 12 + Cargo.lock | 320 ++++++++++++++--------- Cargo.toml | 38 +-- README.md | 2 +- benches/benchmarks.rs | 4 +- benches/bindings/RunBenchmarks.java | 97 ++++++- fixtures/primitive-arrays/Cargo.toml | 2 +- src/gen_java/compounds.rs | 32 +++ src/gen_java/mod.rs | 149 +++++++++-- src/lib.rs | 35 ++- src/templates/EnumTemplate.java | 38 +-- src/templates/ErrorTemplate.java | 30 +-- src/templates/Interface.java | 6 +- src/templates/ObjectTemplate.java | 14 +- src/templates/RecordTemplate.java | 22 +- src/templates/SetTemplate.java | 32 +++ src/templates/Types.java | 3 + src/templates/macros.java | 50 ++-- src/templates/wrapper.java | 4 +- tests/scripts/TestProcMacro.java | 8 + tests/scripts/TestRename/TestRename.java | 12 +- tests/tests.rs | 22 +- 23 files changed, 661 insertions(+), 274 deletions(-) create mode 100644 src/templates/SetTemplate.java diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml index caa1ee6..d9e1058 100644 --- a/.github/workflows/rust-ci.yml +++ b/.github/workflows/rust-ci.yml @@ -13,5 +13,8 @@ jobs: with: run_clippy: true minimum_coverage: "0" + # `cargo test` does not build bench targets, so benches/ can break against a uniffi upgrade + # without CI noticing. Build with --all-targets to compile them. + test_matrix_include: '[{"rust_version": "", "build_only": false, "build_args": "--all-targets"}]' cargo_command_env_vars: "PATH=$JAVA_HOME_25_X64/bin:$PATH JDK_JAVA_OPTIONS=--enable-native-access=ALL-UNNAMED" secrets: inherit diff --git a/CHANGELOG.md b/CHANGELOG.md index 9350c97..4c1bafe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +## Unreleased + +- updated to UniFFI 0.32.0 (and Askama 0.16). +- added support for `HashSet`, which UniFFI 0.32 exposes to proc-macros. Rust sets map to + `java.util.Set`, preserving insertion order on the way back from Rust. + +### Breaking + +- `--config` now expects a UniFFI [global config file](https://mozilla.github.io/uniffi-rs/next/bindings.html#global-configuration) + with `[defaults]`, `[crates.]` and/or `[crate-roots]` sections, rather than a flat + `uniffi.toml`-style override. Old-style files are ignored with a warning. + ## 0.4.2 - Added `nullness_annotations` config option to emit JSpecify `@NullMarked` and diff --git a/Cargo.lock b/Cargo.lock index 55e201d..3748f56 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -90,11 +90,11 @@ checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" [[package]] name = "askama" -version = "0.14.0" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f75363874b771be265f4ffe307ca705ef6f3baa19011c149da8674a87f1b75c4" +checksum = "f1bf825125edd887a019d0a3a837dcc5499a68b0d034cc3eb594070c3e18addc" dependencies = [ - "askama_derive", + "askama_macros", "itoa", "percent-encoding", "serde", @@ -103,31 +103,42 @@ dependencies = [ [[package]] name = "askama_derive" -version = "0.14.0" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "129397200fe83088e8a68407a8e2b1f826cf0086b21ccdb866a722c8bcd3a94f" +checksum = "e1c7065972a130eafa84215f21352ae15b4a7393da48c1f5e103904490736738" dependencies = [ "askama_parser", "basic-toml", + "glob", "memchr", "proc-macro2", "quote", "rustc-hash", "serde", "serde_derive", - "syn", + "syn 2.0.101", +] + +[[package]] +name = "askama_macros" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e23b1d2c4bd39a41971f6124cef4cc6fd0540913ecb90919b69ab3bbe44ae1a" +dependencies = [ + "askama_derive", ] [[package]] name = "askama_parser" -version = "0.14.0" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6ab5630b3d5eaf232620167977f95eb51f3432fc76852328774afbd242d4358" +checksum = "7db09fde9143e7ac4513358fb32ee32847125b63b18ea715afd487956da715da" dependencies = [ - "memchr", + "rustc-hash", "serde", "serde_derive", - "winnow 0.7.10", + "unicode-ident", + "winnow 1.0.0", ] [[package]] @@ -265,7 +276,7 @@ checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.101", ] [[package]] @@ -337,11 +348,11 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "camino" -version = "1.1.9" +version = "1.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b96ec4966b5813e2c0507c1f86115c8c5abaadc3980879c3424042a02fd1ad3" +checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d" dependencies = [ - "serde", + "serde_core", ] [[package]] @@ -353,6 +364,16 @@ dependencies = [ "serde", ] +[[package]] +name = "cargo-platform" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0061da739915fae12ea00e16397555ed4371a6bb285431aab930f61b0aa4ba" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "cargo_metadata" version = "0.19.2" @@ -360,7 +381,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" dependencies = [ "camino", - "cargo-platform", + "cargo-platform 0.1.9", + "semver", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "cargo_metadata" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef987d17b0a113becdd19d3d0022d04d7ef41f9efe4f3fb63ac44ba61df3ade9" +dependencies = [ + "camino", + "cargo-platform 0.3.3", "semver", "serde", "serde_json", @@ -446,7 +481,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.101", ] [[package]] @@ -545,7 +580,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.101", ] [[package]] @@ -614,9 +649,9 @@ dependencies = [ [[package]] name = "fs-err" -version = "2.11.0" +version = "3.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88a41f105fe1d5b6b34b2055e3dc59bb79b46b48b2040b9e6c7b4b5de097aa41" +checksum = "b91aa448ca50d7e79433bdf3ee8d99215430d2ec02ade5aefab2a073a1822e8a" dependencies = [ "autocfg", ] @@ -690,7 +725,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.101", ] [[package]] @@ -743,9 +778,9 @@ checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" [[package]] name = "glob" -version = "0.3.2" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" [[package]] name = "gloo-timers" @@ -1280,12 +1315,6 @@ version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" -[[package]] -name = "ryu" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" - [[package]] name = "same-file" version = "1.0.6" @@ -1312,7 +1341,7 @@ checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.101", ] [[package]] @@ -1326,34 +1355,45 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.219" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] name = "serde_json" -version = "1.0.140" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", - "ryu", "serde", + "serde_core", + "zmij", ] [[package]] @@ -1421,6 +1461,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "synstructure" version = "0.13.2" @@ -1429,7 +1480,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.101", ] [[package]] @@ -1473,7 +1524,7 @@ checksum = "7f7cf42b4507d8ea322120659672cf1b9dbb93f8f2d4ecfd6e51350ff5b17a1d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.101", ] [[package]] @@ -1581,18 +1632,18 @@ checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" [[package]] name = "uniffi" -version = "0.31.0" -source = "git+https://github.com/mozilla/uniffi-rs.git?tag=v0.31.0#309762f55db3f0548194a9ceba3027fa64b18a93" +version = "0.32.0" +source = "git+https://github.com/mozilla/uniffi-rs.git?tag=v0.32.0#5c7b73906358e1a7acdc1bdc7bf5cd86fb27e44c" dependencies = [ "anyhow", "camino", - "cargo_metadata", + "cargo_metadata 0.23.1", "clap", - "uniffi_bindgen 0.31.0 (git+https://github.com/mozilla/uniffi-rs.git?tag=v0.31.0)", + "uniffi_bindgen 0.32.0 (git+https://github.com/mozilla/uniffi-rs.git?tag=v0.32.0)", "uniffi_build", "uniffi_core", "uniffi_macros", - "uniffi_pipeline 0.31.0 (git+https://github.com/mozilla/uniffi-rs.git?tag=v0.31.0)", + "uniffi_pipeline 0.32.0 (git+https://github.com/mozilla/uniffi-rs.git?tag=v0.32.0)", ] [[package]] @@ -1602,7 +1653,7 @@ dependencies = [ "anyhow", "askama", "camino", - "cargo_metadata", + "cargo_metadata 0.19.2", "clap", "glob", "heck", @@ -1629,15 +1680,15 @@ dependencies = [ "uniffi-fixture-rename", "uniffi-fixture-time", "uniffi-fixture-trait-methods", - "uniffi_bindgen 0.31.0 (registry+https://github.com/rust-lang/crates.io-index)", - "uniffi_meta 0.31.0 (registry+https://github.com/rust-lang/crates.io-index)", + "uniffi_bindgen 0.32.0 (registry+https://github.com/rust-lang/crates.io-index)", + "uniffi_meta 0.32.0 (registry+https://github.com/rust-lang/crates.io-index)", "uniffi_testing", ] [[package]] name = "uniffi-example-arithmetic" version = "0.22.0" -source = "git+https://github.com/mozilla/uniffi-rs.git?tag=v0.31.0#309762f55db3f0548194a9ceba3027fa64b18a93" +source = "git+https://github.com/mozilla/uniffi-rs.git?tag=v0.32.0#5c7b73906358e1a7acdc1bdc7bf5cd86fb27e44c" dependencies = [ "thiserror", "uniffi", @@ -1646,7 +1697,7 @@ dependencies = [ [[package]] name = "uniffi-example-custom-types" version = "0.22.0" -source = "git+https://github.com/mozilla/uniffi-rs.git?tag=v0.31.0#309762f55db3f0548194a9ceba3027fa64b18a93" +source = "git+https://github.com/mozilla/uniffi-rs.git?tag=v0.32.0#5c7b73906358e1a7acdc1bdc7bf5cd86fb27e44c" dependencies = [ "anyhow", "bytes", @@ -1657,7 +1708,7 @@ dependencies = [ [[package]] name = "uniffi-example-futures" version = "0.22.0" -source = "git+https://github.com/mozilla/uniffi-rs.git?tag=v0.31.0#309762f55db3f0548194a9ceba3027fa64b18a93" +source = "git+https://github.com/mozilla/uniffi-rs.git?tag=v0.32.0#5c7b73906358e1a7acdc1bdc7bf5cd86fb27e44c" dependencies = [ "async-std", "thiserror", @@ -1667,7 +1718,7 @@ dependencies = [ [[package]] name = "uniffi-example-geometry" version = "0.22.0" -source = "git+https://github.com/mozilla/uniffi-rs.git?tag=v0.31.0#309762f55db3f0548194a9ceba3027fa64b18a93" +source = "git+https://github.com/mozilla/uniffi-rs.git?tag=v0.32.0#5c7b73906358e1a7acdc1bdc7bf5cd86fb27e44c" dependencies = [ "uniffi", ] @@ -1675,7 +1726,7 @@ dependencies = [ [[package]] name = "uniffi-example-rondpoint" version = "0.22.0" -source = "git+https://github.com/mozilla/uniffi-rs.git?tag=v0.31.0#309762f55db3f0548194a9ceba3027fa64b18a93" +source = "git+https://github.com/mozilla/uniffi-rs.git?tag=v0.32.0#5c7b73906358e1a7acdc1bdc7bf5cd86fb27e44c" dependencies = [ "uniffi", ] @@ -1683,7 +1734,7 @@ dependencies = [ [[package]] name = "uniffi-example-sprites" version = "0.22.0" -source = "git+https://github.com/mozilla/uniffi-rs.git?tag=v0.31.0#309762f55db3f0548194a9ceba3027fa64b18a93" +source = "git+https://github.com/mozilla/uniffi-rs.git?tag=v0.32.0#5c7b73906358e1a7acdc1bdc7bf5cd86fb27e44c" dependencies = [ "uniffi", ] @@ -1691,7 +1742,7 @@ dependencies = [ [[package]] name = "uniffi-example-todolist" version = "0.22.0" -source = "git+https://github.com/mozilla/uniffi-rs.git?tag=v0.31.0#309762f55db3f0548194a9ceba3027fa64b18a93" +source = "git+https://github.com/mozilla/uniffi-rs.git?tag=v0.32.0#5c7b73906358e1a7acdc1bdc7bf5cd86fb27e44c" dependencies = [ "once_cell", "thiserror", @@ -1701,11 +1752,15 @@ dependencies = [ [[package]] name = "uniffi-fixture-benchmarks" version = "0.22.0" -source = "git+https://github.com/mozilla/uniffi-rs.git?tag=v0.31.0#309762f55db3f0548194a9ceba3027fa64b18a93" +source = "git+https://github.com/mozilla/uniffi-rs.git?tag=v0.32.0#5c7b73906358e1a7acdc1bdc7bf5cd86fb27e44c" dependencies = [ + "anyhow", + "camino", "clap", "criterion", + "indexmap", "regex", + "serde_json", "thiserror", "uniffi", ] @@ -1713,7 +1768,7 @@ dependencies = [ [[package]] name = "uniffi-fixture-coverall" version = "0.22.0" -source = "git+https://github.com/mozilla/uniffi-rs.git?tag=v0.31.0#309762f55db3f0548194a9ceba3027fa64b18a93" +source = "git+https://github.com/mozilla/uniffi-rs.git?tag=v0.32.0#5c7b73906358e1a7acdc1bdc7bf5cd86fb27e44c" dependencies = [ "once_cell", "thiserror", @@ -1723,7 +1778,7 @@ dependencies = [ [[package]] name = "uniffi-fixture-ext-types" version = "0.22.0" -source = "git+https://github.com/mozilla/uniffi-rs.git?tag=v0.31.0#309762f55db3f0548194a9ceba3027fa64b18a93" +source = "git+https://github.com/mozilla/uniffi-rs.git?tag=v0.32.0#5c7b73906358e1a7acdc1bdc7bf5cd86fb27e44c" dependencies = [ "anyhow", "bytes", @@ -1739,7 +1794,7 @@ dependencies = [ [[package]] name = "uniffi-fixture-ext-types-custom-types" version = "0.22.0" -source = "git+https://github.com/mozilla/uniffi-rs.git?tag=v0.31.0#309762f55db3f0548194a9ceba3027fa64b18a93" +source = "git+https://github.com/mozilla/uniffi-rs.git?tag=v0.32.0#5c7b73906358e1a7acdc1bdc7bf5cd86fb27e44c" dependencies = [ "anyhow", "bytes", @@ -1750,12 +1805,12 @@ dependencies = [ [[package]] name = "uniffi-fixture-ext-types-external-crate" version = "0.22.0" -source = "git+https://github.com/mozilla/uniffi-rs.git?tag=v0.31.0#309762f55db3f0548194a9ceba3027fa64b18a93" +source = "git+https://github.com/mozilla/uniffi-rs.git?tag=v0.32.0#5c7b73906358e1a7acdc1bdc7bf5cd86fb27e44c" [[package]] name = "uniffi-fixture-ext-types-lib-one" version = "0.22.0" -source = "git+https://github.com/mozilla/uniffi-rs.git?tag=v0.31.0#309762f55db3f0548194a9ceba3027fa64b18a93" +source = "git+https://github.com/mozilla/uniffi-rs.git?tag=v0.32.0#5c7b73906358e1a7acdc1bdc7bf5cd86fb27e44c" dependencies = [ "anyhow", "bytes", @@ -1766,7 +1821,7 @@ dependencies = [ [[package]] name = "uniffi-fixture-ext-types-sub-lib" version = "0.22.0" -source = "git+https://github.com/mozilla/uniffi-rs.git?tag=v0.31.0#309762f55db3f0548194a9ceba3027fa64b18a93" +source = "git+https://github.com/mozilla/uniffi-rs.git?tag=v0.32.0#5c7b73906358e1a7acdc1bdc7bf5cd86fb27e44c" dependencies = [ "anyhow", "uniffi", @@ -1776,7 +1831,7 @@ dependencies = [ [[package]] name = "uniffi-fixture-futures" version = "0.21.0" -source = "git+https://github.com/mozilla/uniffi-rs.git?tag=v0.31.0#309762f55db3f0548194a9ceba3027fa64b18a93" +source = "git+https://github.com/mozilla/uniffi-rs.git?tag=v0.32.0#5c7b73906358e1a7acdc1bdc7bf5cd86fb27e44c" dependencies = [ "async-trait", "futures", @@ -1796,7 +1851,7 @@ dependencies = [ [[package]] name = "uniffi-fixture-proc-macro" version = "0.22.0" -source = "git+https://github.com/mozilla/uniffi-rs.git?tag=v0.31.0#309762f55db3f0548194a9ceba3027fa64b18a93" +source = "git+https://github.com/mozilla/uniffi-rs.git?tag=v0.32.0#5c7b73906358e1a7acdc1bdc7bf5cd86fb27e44c" dependencies = [ "lazy_static", "thiserror", @@ -1806,16 +1861,17 @@ dependencies = [ [[package]] name = "uniffi-fixture-rename" version = "0.22.0" -source = "git+https://github.com/mozilla/uniffi-rs.git?tag=v0.31.0#309762f55db3f0548194a9ceba3027fa64b18a93" +source = "git+https://github.com/mozilla/uniffi-rs.git?tag=v0.32.0#5c7b73906358e1a7acdc1bdc7bf5cd86fb27e44c" dependencies = [ "thiserror", "uniffi", + "url", ] [[package]] name = "uniffi-fixture-time" version = "0.22.0" -source = "git+https://github.com/mozilla/uniffi-rs.git?tag=v0.31.0#309762f55db3f0548194a9ceba3027fa64b18a93" +source = "git+https://github.com/mozilla/uniffi-rs.git?tag=v0.32.0#5c7b73906358e1a7acdc1bdc7bf5cd86fb27e44c" dependencies = [ "chrono", "thiserror", @@ -1825,7 +1881,7 @@ dependencies = [ [[package]] name = "uniffi-fixture-trait-methods" version = "0.22.0" -source = "git+https://github.com/mozilla/uniffi-rs.git?tag=v0.31.0#309762f55db3f0548194a9ceba3027fa64b18a93" +source = "git+https://github.com/mozilla/uniffi-rs.git?tag=v0.32.0#5c7b73906358e1a7acdc1bdc7bf5cd86fb27e44c" dependencies = [ "once_cell", "thiserror", @@ -1834,14 +1890,14 @@ dependencies = [ [[package]] name = "uniffi_bindgen" -version = "0.31.0" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ed0150801958d4825da56a41c71f000a457ac3a4613fa9647df78ac4b6b6881" +checksum = "533b0312c73e3b54eb78a4b257ceae390962dd4767995778309a74644643f9ac" dependencies = [ "anyhow", "askama", "camino", - "cargo_metadata", + "cargo_metadata 0.23.1", "fs-err", "glob", "goblin", @@ -1852,21 +1908,21 @@ dependencies = [ "tempfile", "textwrap", "toml", - "uniffi_internal_macros 0.31.0 (registry+https://github.com/rust-lang/crates.io-index)", - "uniffi_meta 0.31.0 (registry+https://github.com/rust-lang/crates.io-index)", - "uniffi_pipeline 0.31.0 (registry+https://github.com/rust-lang/crates.io-index)", - "uniffi_udl 0.31.0 (registry+https://github.com/rust-lang/crates.io-index)", + "uniffi_internal_macros 0.32.0 (registry+https://github.com/rust-lang/crates.io-index)", + "uniffi_meta 0.32.0 (registry+https://github.com/rust-lang/crates.io-index)", + "uniffi_pipeline 0.32.0 (registry+https://github.com/rust-lang/crates.io-index)", + "uniffi_udl 0.32.0 (registry+https://github.com/rust-lang/crates.io-index)", ] [[package]] name = "uniffi_bindgen" -version = "0.31.0" -source = "git+https://github.com/mozilla/uniffi-rs.git?tag=v0.31.0#309762f55db3f0548194a9ceba3027fa64b18a93" +version = "0.32.0" +source = "git+https://github.com/mozilla/uniffi-rs.git?tag=v0.32.0#5c7b73906358e1a7acdc1bdc7bf5cd86fb27e44c" dependencies = [ "anyhow", "askama", "camino", - "cargo_metadata", + "cargo_metadata 0.23.1", "fs-err", "glob", "goblin", @@ -1877,26 +1933,26 @@ dependencies = [ "tempfile", "textwrap", "toml", - "uniffi_internal_macros 0.31.0 (git+https://github.com/mozilla/uniffi-rs.git?tag=v0.31.0)", - "uniffi_meta 0.31.0 (git+https://github.com/mozilla/uniffi-rs.git?tag=v0.31.0)", - "uniffi_pipeline 0.31.0 (git+https://github.com/mozilla/uniffi-rs.git?tag=v0.31.0)", - "uniffi_udl 0.31.0 (git+https://github.com/mozilla/uniffi-rs.git?tag=v0.31.0)", + "uniffi_internal_macros 0.32.0 (git+https://github.com/mozilla/uniffi-rs.git?tag=v0.32.0)", + "uniffi_meta 0.32.0 (git+https://github.com/mozilla/uniffi-rs.git?tag=v0.32.0)", + "uniffi_pipeline 0.32.0 (git+https://github.com/mozilla/uniffi-rs.git?tag=v0.32.0)", + "uniffi_udl 0.32.0 (git+https://github.com/mozilla/uniffi-rs.git?tag=v0.32.0)", ] [[package]] name = "uniffi_build" -version = "0.31.0" -source = "git+https://github.com/mozilla/uniffi-rs.git?tag=v0.31.0#309762f55db3f0548194a9ceba3027fa64b18a93" +version = "0.32.0" +source = "git+https://github.com/mozilla/uniffi-rs.git?tag=v0.32.0#5c7b73906358e1a7acdc1bdc7bf5cd86fb27e44c" dependencies = [ "anyhow", "camino", - "uniffi_bindgen 0.31.0 (git+https://github.com/mozilla/uniffi-rs.git?tag=v0.31.0)", + "uniffi_bindgen 0.32.0 (git+https://github.com/mozilla/uniffi-rs.git?tag=v0.32.0)", ] [[package]] name = "uniffi_core" -version = "0.31.0" -source = "git+https://github.com/mozilla/uniffi-rs.git?tag=v0.31.0#309762f55db3f0548194a9ceba3027fa64b18a93" +version = "0.32.0" +source = "git+https://github.com/mozilla/uniffi-rs.git?tag=v0.32.0#5c7b73906358e1a7acdc1bdc7bf5cd86fb27e44c" dependencies = [ "anyhow", "async-compat", @@ -1907,33 +1963,33 @@ dependencies = [ [[package]] name = "uniffi_internal_macros" -version = "0.31.0" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98f51ebca0d9a4b2aa6c644d5ede45c56f73906b96403c08a1985e75ccb64a01" +checksum = "84ae78069a5e6772ef694fd5bdb628532c88d2c2f0e7142bf6a384636eadb1af" dependencies = [ "anyhow", "indexmap", "proc-macro2", "quote", - "syn", + "syn 2.0.101", ] [[package]] name = "uniffi_internal_macros" -version = "0.31.0" -source = "git+https://github.com/mozilla/uniffi-rs.git?tag=v0.31.0#309762f55db3f0548194a9ceba3027fa64b18a93" +version = "0.32.0" +source = "git+https://github.com/mozilla/uniffi-rs.git?tag=v0.32.0#5c7b73906358e1a7acdc1bdc7bf5cd86fb27e44c" dependencies = [ "anyhow", "indexmap", "proc-macro2", "quote", - "syn", + "syn 2.0.101", ] [[package]] name = "uniffi_macros" -version = "0.31.0" -source = "git+https://github.com/mozilla/uniffi-rs.git?tag=v0.31.0#309762f55db3f0548194a9ceba3027fa64b18a93" +version = "0.32.0" +source = "git+https://github.com/mozilla/uniffi-rs.git?tag=v0.32.0#5c7b73906358e1a7acdc1bdc7bf5cd86fb27e44c" dependencies = [ "camino", "fs-err", @@ -1941,92 +1997,92 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn", + "syn 2.0.101", "toml", - "uniffi_meta 0.31.0 (git+https://github.com/mozilla/uniffi-rs.git?tag=v0.31.0)", + "uniffi_meta 0.32.0 (git+https://github.com/mozilla/uniffi-rs.git?tag=v0.32.0)", ] [[package]] name = "uniffi_meta" -version = "0.31.0" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9df6d413db2827c68588f8149d30d49b71d540d46539e435b23a7f7dbd4d4f86" +checksum = "78de021f5547e56ab16c665a49d67d4fd3d31e77422f7739a2e9359d328cd9e7" dependencies = [ "anyhow", "siphasher", - "uniffi_internal_macros 0.31.0 (registry+https://github.com/rust-lang/crates.io-index)", - "uniffi_pipeline 0.31.0 (registry+https://github.com/rust-lang/crates.io-index)", + "uniffi_internal_macros 0.32.0 (registry+https://github.com/rust-lang/crates.io-index)", + "uniffi_pipeline 0.32.0 (registry+https://github.com/rust-lang/crates.io-index)", ] [[package]] name = "uniffi_meta" -version = "0.31.0" -source = "git+https://github.com/mozilla/uniffi-rs.git?tag=v0.31.0#309762f55db3f0548194a9ceba3027fa64b18a93" +version = "0.32.0" +source = "git+https://github.com/mozilla/uniffi-rs.git?tag=v0.32.0#5c7b73906358e1a7acdc1bdc7bf5cd86fb27e44c" dependencies = [ "anyhow", "siphasher", - "uniffi_internal_macros 0.31.0 (git+https://github.com/mozilla/uniffi-rs.git?tag=v0.31.0)", - "uniffi_pipeline 0.31.0 (git+https://github.com/mozilla/uniffi-rs.git?tag=v0.31.0)", + "uniffi_internal_macros 0.32.0 (git+https://github.com/mozilla/uniffi-rs.git?tag=v0.32.0)", + "uniffi_pipeline 0.32.0 (git+https://github.com/mozilla/uniffi-rs.git?tag=v0.32.0)", ] [[package]] name = "uniffi_pipeline" -version = "0.31.0" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a806dddc8208f22efd7e95a5cdf88ed43d0f3271e8f63b47e757a8bbdb43b63a" +checksum = "3f8201bb1907ed8a42d80e11cbc25c8a033e7a31c3cff1d911f56eedb81d4948" dependencies = [ "anyhow", "heck", "indexmap", "tempfile", - "uniffi_internal_macros 0.31.0 (registry+https://github.com/rust-lang/crates.io-index)", + "uniffi_internal_macros 0.32.0 (registry+https://github.com/rust-lang/crates.io-index)", ] [[package]] name = "uniffi_pipeline" -version = "0.31.0" -source = "git+https://github.com/mozilla/uniffi-rs.git?tag=v0.31.0#309762f55db3f0548194a9ceba3027fa64b18a93" +version = "0.32.0" +source = "git+https://github.com/mozilla/uniffi-rs.git?tag=v0.32.0#5c7b73906358e1a7acdc1bdc7bf5cd86fb27e44c" dependencies = [ "anyhow", "heck", "indexmap", "tempfile", - "uniffi_internal_macros 0.31.0 (git+https://github.com/mozilla/uniffi-rs.git?tag=v0.31.0)", + "uniffi_internal_macros 0.32.0 (git+https://github.com/mozilla/uniffi-rs.git?tag=v0.32.0)", ] [[package]] name = "uniffi_testing" -version = "0.31.0" -source = "git+https://github.com/mozilla/uniffi-rs.git?tag=v0.31.0#309762f55db3f0548194a9ceba3027fa64b18a93" +version = "0.32.0" +source = "git+https://github.com/mozilla/uniffi-rs.git?tag=v0.32.0#5c7b73906358e1a7acdc1bdc7bf5cd86fb27e44c" dependencies = [ "anyhow", "camino", - "cargo_metadata", + "cargo_metadata 0.23.1", "fs-err", "once_cell", ] [[package]] name = "uniffi_udl" -version = "0.31.0" +version = "0.32.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d1a7339539bf6f6fa3e9b534dece13f778bda2d54b1a6d4e40b4d6090ac26e7" +checksum = "a6e57996bc58009cc29bf04845d627ae313c2547b87171c1c349d6c51a1656c0" dependencies = [ "anyhow", "textwrap", - "uniffi_meta 0.31.0 (registry+https://github.com/rust-lang/crates.io-index)", + "uniffi_meta 0.32.0 (registry+https://github.com/rust-lang/crates.io-index)", "weedle2 5.0.0 (registry+https://github.com/rust-lang/crates.io-index)", ] [[package]] name = "uniffi_udl" -version = "0.31.0" -source = "git+https://github.com/mozilla/uniffi-rs.git?tag=v0.31.0#309762f55db3f0548194a9ceba3027fa64b18a93" +version = "0.32.0" +source = "git+https://github.com/mozilla/uniffi-rs.git?tag=v0.32.0#5c7b73906358e1a7acdc1bdc7bf5cd86fb27e44c" dependencies = [ "anyhow", "textwrap", - "uniffi_meta 0.31.0 (git+https://github.com/mozilla/uniffi-rs.git?tag=v0.31.0)", - "weedle2 5.0.0 (git+https://github.com/mozilla/uniffi-rs.git?tag=v0.31.0)", + "uniffi_meta 0.32.0 (git+https://github.com/mozilla/uniffi-rs.git?tag=v0.32.0)", + "weedle2 5.0.0 (git+https://github.com/mozilla/uniffi-rs.git?tag=v0.32.0)", ] [[package]] @@ -2099,7 +2155,7 @@ dependencies = [ "log", "proc-macro2", "quote", - "syn", + "syn 2.0.101", "wasm-bindgen-shared", ] @@ -2134,7 +2190,7 @@ checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.101", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -2170,7 +2226,7 @@ dependencies = [ [[package]] name = "weedle2" version = "5.0.0" -source = "git+https://github.com/mozilla/uniffi-rs.git?tag=v0.31.0#309762f55db3f0548194a9ceba3027fa64b18a93" +source = "git+https://github.com/mozilla/uniffi-rs.git?tag=v0.32.0#5c7b73906358e1a7acdc1bdc7bf5cd86fb27e44c" dependencies = [ "nom", ] @@ -2277,15 +2333,15 @@ name = "winnow" version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c06928c8748d81b05c9be96aad92e1b6ff01833332f281e8cfca3be4b35fc9ec" -dependencies = [ - "memchr", -] [[package]] name = "winnow" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" +dependencies = [ + "memchr", +] [[package]] name = "wit-bindgen-rt" @@ -2322,7 +2378,7 @@ checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.101", "synstructure", ] @@ -2343,7 +2399,7 @@ checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.101", ] [[package]] @@ -2363,7 +2419,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.101", "synstructure", ] @@ -2397,5 +2453,11 @@ checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.101", ] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index 42c5e9e..556c1ed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,7 @@ bench = false [dependencies] anyhow = "1" -askama = { version = "0.14", default-features = false, features = ["config", "derive"] } +askama = { version = "0.16", default-features = false, features = ["config", "derive", "alloc"] } camino = "1.1.6" cargo_metadata = "0.19" clap = { version = "4", default-features = false, features = [ @@ -39,29 +39,29 @@ regex = "1.10.4" serde = "1" textwrap = "0.16.1" toml = ">=0.8, <=0.9" # must match uniffi_bindgen's toml version -uniffi_bindgen = "0.31.0" -uniffi_meta = "0.31.0" +uniffi_bindgen = "0.32.0" +uniffi_meta = "0.32.0" [dev-dependencies] glob = "0.3" itertools = "0.14.0" -uniffi-example-arithmetic = { git = "https://github.com/mozilla/uniffi-rs.git", tag = "v0.31.0" } -uniffi-example-custom-types = { git = "https://github.com/mozilla/uniffi-rs.git", tag = "v0.31.0" } -uniffi-example-futures = { git = "https://github.com/mozilla/uniffi-rs.git", tag = "v0.31.0" } -uniffi-example-geometry = { git = "https://github.com/mozilla/uniffi-rs.git", tag = "v0.31.0" } -uniffi-example-rondpoint = { git = "https://github.com/mozilla/uniffi-rs.git", tag = "v0.31.0" } -uniffi-example-sprites = { git = "https://github.com/mozilla/uniffi-rs.git", tag = "v0.31.0" } -uniffi-example-todolist = { git = "https://github.com/mozilla/uniffi-rs.git", tag = "v0.31.0" } -uniffi-fixture-benchmarks = { git = "https://github.com/mozilla/uniffi-rs.git", tag = "v0.31.0" } -uniffi-fixture-coverall = { git = "https://github.com/mozilla/uniffi-rs.git", tag = "v0.31.0" } -uniffi-fixture-ext-types = { git = "https://github.com/mozilla/uniffi-rs.git", tag = "v0.31.0" } -uniffi-fixture-futures = { git = "https://github.com/mozilla/uniffi-rs.git", tag = "v0.31.0" } +uniffi-example-arithmetic = { git = "https://github.com/mozilla/uniffi-rs.git", tag = "v0.32.0" } +uniffi-example-custom-types = { git = "https://github.com/mozilla/uniffi-rs.git", tag = "v0.32.0" } +uniffi-example-futures = { git = "https://github.com/mozilla/uniffi-rs.git", tag = "v0.32.0" } +uniffi-example-geometry = { git = "https://github.com/mozilla/uniffi-rs.git", tag = "v0.32.0" } +uniffi-example-rondpoint = { git = "https://github.com/mozilla/uniffi-rs.git", tag = "v0.32.0" } +uniffi-example-sprites = { git = "https://github.com/mozilla/uniffi-rs.git", tag = "v0.32.0" } +uniffi-example-todolist = { git = "https://github.com/mozilla/uniffi-rs.git", tag = "v0.32.0" } +uniffi-fixture-benchmarks = { git = "https://github.com/mozilla/uniffi-rs.git", tag = "v0.32.0" } +uniffi-fixture-coverall = { git = "https://github.com/mozilla/uniffi-rs.git", tag = "v0.32.0" } +uniffi-fixture-ext-types = { git = "https://github.com/mozilla/uniffi-rs.git", tag = "v0.32.0" } +uniffi-fixture-futures = { git = "https://github.com/mozilla/uniffi-rs.git", tag = "v0.32.0" } uniffi-fixture-primitive-arrays = { path = "fixtures/primitive-arrays" } -uniffi-fixture-proc-macro = { git = "https://github.com/mozilla/uniffi-rs.git", tag = "v0.31.0" } -uniffi-fixture-rename = { git = "https://github.com/mozilla/uniffi-rs.git", tag = "v0.31.0" } -uniffi-fixture-time = { git = "https://github.com/mozilla/uniffi-rs.git", tag = "v0.31.0" } -uniffi-fixture-trait-methods = { git = "https://github.com/mozilla/uniffi-rs.git", tag = "v0.31.0" } -uniffi_testing = { git = "https://github.com/mozilla/uniffi-rs.git", tag = "v0.31.0" } +uniffi-fixture-proc-macro = { git = "https://github.com/mozilla/uniffi-rs.git", tag = "v0.32.0" } +uniffi-fixture-rename = { git = "https://github.com/mozilla/uniffi-rs.git", tag = "v0.32.0" } +uniffi-fixture-time = { git = "https://github.com/mozilla/uniffi-rs.git", tag = "v0.32.0" } +uniffi-fixture-trait-methods = { git = "https://github.com/mozilla/uniffi-rs.git", tag = "v0.32.0" } +uniffi_testing = { git = "https://github.com/mozilla/uniffi-rs.git", tag = "v0.32.0" } [[bench]] name = "benchmarks" diff --git a/README.md b/README.md index 423f26a..38a90a1 100644 --- a/README.md +++ b/README.md @@ -246,7 +246,7 @@ scope). ## Testing -We pull down the pinned examples directly from Uniffi (currently v0.31.0) and run Java tests using the generated bindings. Run `cargo t` to run all of them. +We pull down the pinned examples directly from Uniffi (currently v0.32.0) and run Java tests using the generated bindings. Run `cargo t` to run all of them. Note that if you need additional toml entries for your test, you can put a `uniffi-extras.toml` as a sibling of the test and it will be read in addition to the base `uniffi.toml` for the example. See [CustomTypes](./tests/scripts/TestCustomTypes/) for an example. Settings in `uniffi-extras.toml` apply across all namespaces. diff --git a/benches/benchmarks.rs b/benches/benchmarks.rs index de9e7e1..bb2323f 100644 --- a/benches/benchmarks.rs +++ b/benches/benchmarks.rs @@ -19,7 +19,7 @@ use std::env; use std::fs; use std::path::PathBuf; use std::process::Command; -use uniffi_bindgen::{BindgenLoader, BindgenPaths}; +use uniffi_bindgen::{BindgenLoader, BindgenPaths, GlobalConfig}; use uniffi_bindgen_java::{GenerateOptions, generate}; use uniffi_testing::UniFFITestHelper; @@ -45,7 +45,7 @@ fn main() -> Result<()> { let mut paths = BindgenPaths::default(); paths.add_cargo_metadata_layer(false)?; - let loader = BindgenLoader::new(paths); + let loader = BindgenLoader::new(paths, GlobalConfig::default()); generate( &loader, diff --git a/benches/bindings/RunBenchmarks.java b/benches/bindings/RunBenchmarks.java index 8f0b29a..356387d 100644 --- a/benches/bindings/RunBenchmarks.java +++ b/benches/bindings/RunBenchmarks.java @@ -12,10 +12,28 @@ class TestData { static final String testLargeString2 = "b".repeat(1500); static final TestRecord testRec1 = new TestRecord(-1, 1L, 1.5); static final TestRecord testRec2 = new TestRecord(-2, 2L, 4.5); + static final TestLargeRecord testLargeRec1 = + new TestLargeRecord((byte) 1, (short) 2, 3, 4L, 1.0f, 2.0, true); + static final TestLargeRecord testLargeRec2 = + new TestLargeRecord((byte) -1, (short) -2, -3, -4L, -1.0f, -2.0, false); static final TestEnum testEnum1 = new TestEnum.One(-1, 0L); static final TestEnum testEnum2 = new TestEnum.Two(1.5); static final int[] testVec1 = new int[]{0, 1}; static final int[] testVec2 = new int[]{2, 4, 6}; + static final byte[] testBytes = new byte[256]; + static final int[] testPrimitiveList = new int[1025]; + static final List testRecordList = java.util.stream.IntStream + .rangeClosed(0, 1024) + .mapToObj(i -> new TestRecord(i, (long) i * 2, i / 2.0)) + .toList(); + static { + for (int i = 0; i < testBytes.length; i++) { + testBytes[i] = (byte) i; + } + for (int i = 0; i < testPrimitiveList.length; i++) { + testPrimitiveList[i] = i; + } + } static final Map testMap1 = Map.of(0, 1, 1, 2); static final Map testMap2 = Map.of(2, 4); static final TestInterface testInterface = new TestInterface(); @@ -62,6 +80,19 @@ public TestRecord records(TestRecord a, TestRecord b) { return new TestRecord(a.a() + b.a(), a.b() + b.b(), a.c() + b.c()); } + @Override + public TestLargeRecord largeRecords(TestLargeRecord a, TestLargeRecord b) { + return new TestLargeRecord( + (byte) (a.a() + b.a()), + (short) (a.b() + b.b()), + a.c() + b.c(), + a.d() + b.d(), + a.e() + b.e(), + a.f() + b.f(), + a.g() && b.g() + ); + } + @Override public TestEnum enums(TestEnum a, TestEnum b) { double aSum = switch (a) { @@ -76,13 +107,40 @@ public TestEnum enums(TestEnum a, TestEnum b) { } @Override - public int[] vecs(int[] a, int[] b) { + public int[] vecSmall(int[] a, int[] b) { int[] result = new int[a.length + b.length]; System.arraycopy(a, 0, result, 0, a.length); System.arraycopy(b, 0, result, a.length, b.length); return result; } + @Override + public int[] vecPrimitives(int[] v) { + return v; + } + + @Override + public List vecRecords(List v) { + return v; + } + + @Override + public int optionals(Integer a, Boolean b, String c) { + int sum = a == null ? 0 : a; + if (Boolean.TRUE.equals(b)) { + sum *= 2; + } + if (c != null) { + sum += c.length(); + } + return sum; + } + + @Override + public byte[] bytes(byte[] v) { + return v; + } + @Override public Map hashMaps(Map a, Map b) { var result = new HashMap<>(a); @@ -144,9 +202,39 @@ public long runTest(TestCase testCase, long count) { Benchmarks.testCaseEnums(TestData.testEnum1, TestData.testEnum2); } } - case VECS -> { + case LARGE_RECORDS -> { + for (long i = 0; i < count; i++) { + Benchmarks.testCaseLargeRecords(TestData.testLargeRec1, TestData.testLargeRec2); + } + } + case OPTIONALS -> { + for (long i = 0; i < count; i++) { + Benchmarks.testCaseOptionals(10, null, "testing-123"); + } + } + case BYTES -> { + for (long i = 0; i < count; i++) { + Benchmarks.testCaseBytes(TestData.testBytes); + } + } + case VEC_SMALL -> { + for (long i = 0; i < count; i++) { + Benchmarks.testCaseVecSmall(TestData.testVec1, TestData.testVec2); + } + } + case VEC_PRIMITIVES -> { + for (long i = 0; i < count; i++) { + Benchmarks.testCaseVecPrimitives(TestData.testPrimitiveList); + } + } + case VEC_RECORDS -> { + for (long i = 0; i < count; i++) { + Benchmarks.testCaseVecRecords(TestData.testRecordList); + } + } + case METHODS -> { for (long i = 0; i < count; i++) { - Benchmarks.testCaseVecs(TestData.testVec1, TestData.testVec2); + TestData.testInterface.noopMethod(); } } case HASHMAPS -> { @@ -178,6 +266,9 @@ public long runTest(TestCase testCase, long count) { } } } + // Without this an unhandled case reports ~0ns rather than failing, which is how this + // runner silently drifted behind the fixture across a uniffi upgrade. + default -> throw new IllegalStateException("unhandled TestCase: " + testCase); } return System.nanoTime() - start; } diff --git a/fixtures/primitive-arrays/Cargo.toml b/fixtures/primitive-arrays/Cargo.toml index c1191d8..c262c20 100644 --- a/fixtures/primitive-arrays/Cargo.toml +++ b/fixtures/primitive-arrays/Cargo.toml @@ -8,4 +8,4 @@ crate-type = ["cdylib", "lib"] name = "uniffi_fixture_primitive_arrays" [dependencies] -uniffi = { git = "https://github.com/mozilla/uniffi-rs.git", tag = "v0.31.0" } +uniffi = { git = "https://github.com/mozilla/uniffi-rs.git", tag = "v0.32.0" } diff --git a/src/gen_java/compounds.rs b/src/gen_java/compounds.rs index a9c1ed9..22641da 100644 --- a/src/gen_java/compounds.rs +++ b/src/gen_java/compounds.rs @@ -72,6 +72,38 @@ impl CodeType for SequenceCodeType { } } +#[derive(Debug)] +pub struct SetCodeType { + inner: Type, +} + +impl SetCodeType { + pub fn new(inner: Type) -> Self { + Self { inner } + } + fn inner(&self) -> &Type { + &self.inner + } +} + +impl CodeType for SetCodeType { + fn type_label(&self, ci: &ComponentInterface, config: &Config) -> String { + format!( + "java.util.Set<{}>", + super::JavaCodeOracle + .find(self.inner()) + .type_label(ci, config) + ) + } + + fn canonical_name(&self) -> String { + format!( + "Set{}", + super::JavaCodeOracle.find(self.inner()).canonical_name() + ) + } +} + #[derive(Debug)] pub struct MapCodeType { key: Type, diff --git a/src/gen_java/mod.rs b/src/gen_java/mod.rs index 23dec63..faff681 100644 --- a/src/gen_java/mod.rs +++ b/src/gen_java/mod.rs @@ -279,8 +279,24 @@ impl CustomTypeConfig { } } +/// Askama inlines every `include` into one generated `render_into`, and as of 0.16 the resulting +/// frame overruns the 2 MiB a spawned thread gets by default in unoptimized builds. Rendering on +/// an explicitly sized thread keeps that independent of whatever stack the caller happens to have. +const RENDER_STACK_SIZE: usize = 32 * 1024 * 1024; + // Generate Java bindings for the given ComponentInterface, as a string. pub fn generate_bindings(config: &Config, ci: &ComponentInterface) -> Result { + std::thread::scope(|scope| { + std::thread::Builder::new() + .stack_size(RENDER_STACK_SIZE) + .spawn_scoped(scope, || render_bindings(config, ci)) + .context("failed to spawn the bindings render thread")? + .join() + .map_err(|_| anyhow::anyhow!("the bindings render thread panicked"))? + }) +} + +fn render_bindings(config: &Config, ci: &ComponentInterface) -> Result { let output = JavaWrapper::new(config.clone(), ci) .render() .context("failed to render java bindings")?; @@ -738,6 +754,9 @@ impl AsCodeType for Type { // Int8/UInt8 sequences still use SequenceCodeType; the separate Bytes type handles byte[] _ => Box::new(compounds::SequenceCodeType::new((*inner_type).clone())), }, + Type::Set { inner_type } => { + Box::new(compounds::SetCodeType::new((*inner_type).clone())) + } Type::Map { key_type, value_type, @@ -746,6 +765,8 @@ impl AsCodeType for Type { (*value_type).clone(), )), Type::Custom { name, .. } => Box::new(custom::CustomCodeType::new(name.clone())), + // `Box` only exists for scaffolding; it's transparent to the bindings. + Type::Box { inner_type } => inner_type.as_codetype(), } } } @@ -800,8 +821,9 @@ mod filters { use super::*; use uniffi_meta::AsType; - // Askama 0.14 passes a Values parameter to all filters. We use `_v` to accept but ignore it. + // Askama requires a Values parameter on every filter. We use `_v` to accept but ignore it. + #[askama::filter_fn] pub(super) fn ffi_type( type_: &impl AsType, _v: &dyn askama::Values, @@ -809,6 +831,7 @@ mod filters { Ok(type_.as_type().into()) } + #[askama::filter_fn] pub(super) fn type_name( as_ct: &impl AsCodeType, _v: &dyn askama::Values, @@ -821,6 +844,7 @@ mod filters { /// Generate a fully qualified type name including the package. /// This is needed for enum variant fields to avoid naming collisions /// when a variant field type has the same name as the enum itself. + #[askama::filter_fn] pub(super) fn qualified_type_name( as_type: &T, _v: &dyn askama::Values, @@ -918,6 +942,7 @@ mod filters { } } + #[askama::filter_fn] pub(super) fn canonical_name( as_ct: &impl AsCodeType, _v: &dyn askama::Values, @@ -926,6 +951,7 @@ mod filters { } /// Check if a type is external (from another crate) + #[askama::filter_fn] pub(super) fn is_external( as_type: &impl AsType, _v: &dyn askama::Values, @@ -934,6 +960,7 @@ mod filters { Ok(ci.is_external(&as_type.as_type())) } + #[askama::filter_fn] pub(super) fn ffi_converter_instance( as_ct: &impl AsCodeType, _v: &dyn askama::Values, @@ -943,6 +970,7 @@ mod filters { Ok(as_ct.as_codetype().ffi_converter_instance(config, ci)) } + #[askama::filter_fn] pub(super) fn ffi_converter_name( as_ct: &impl AsCodeType, _v: &dyn askama::Values, @@ -950,6 +978,7 @@ mod filters { Ok(as_ct.as_codetype().ffi_converter_name()) } + #[askama::filter_fn] pub(super) fn lower_fn( as_ct: &impl AsCodeType, _v: &dyn askama::Values, @@ -962,6 +991,7 @@ mod filters { )) } + #[askama::filter_fn] pub(super) fn allocation_size_fn( as_ct: &impl AsCodeType, _v: &dyn askama::Values, @@ -974,6 +1004,7 @@ mod filters { )) } + #[askama::filter_fn] pub(super) fn write_fn( as_ct: &impl AsCodeType, _v: &dyn askama::Values, @@ -986,6 +1017,7 @@ mod filters { )) } + #[askama::filter_fn] pub(super) fn lift_fn( as_ct: &impl AsCodeType, _v: &dyn askama::Values, @@ -998,6 +1030,7 @@ mod filters { )) } + #[askama::filter_fn] pub(super) fn read_fn( as_ct: &impl AsCodeType, _v: &dyn askama::Values, @@ -1027,6 +1060,7 @@ mod filters { } // Get the idiomatic Java rendering of an individual enum variant's discriminant + #[askama::filter_fn] pub fn variant_discr_literal( e: &Enum, _v: &dyn askama::Values, @@ -1043,6 +1077,7 @@ mod filters { } /// FFI type name (primitive for scalars, MemorySegment for everything else) + #[askama::filter_fn] pub fn ffi_type_name( type_: &FfiType, _v: &dyn askama::Values, @@ -1055,6 +1090,7 @@ mod filters { /// Returns the primitive call suffix (e.g. "Long", "Int") for primitive-specialized /// uniffiRustCall variants. Returns empty string for types where the high-level Java /// primitive doesn't match the FFI primitive (e.g. Boolean→byte) or non-primitive types. + #[askama::filter_fn] pub fn primitive_call_suffix( as_ct: &impl AsCodeType, _v: &dyn askama::Values, @@ -1078,6 +1114,7 @@ mod filters { /// Returns true if the argument's FFI type is a primitive where the Java type matches /// the FFI type directly (no conversion needed). Used to skip lower_fn for primitive args. /// Excludes boolean (Java `boolean` vs FFI `byte`). + #[askama::filter_fn] pub fn has_primitive_ffi_type( as_ct: &impl AsCodeType, _v: &dyn askama::Values, @@ -1089,6 +1126,7 @@ mod filters { } /// Maps FfiType to ValueLayout constant for FunctionDescriptor + #[askama::filter_fn] pub fn ffi_value_layout( type_: &FfiType, _v: &dyn askama::Values, @@ -1097,6 +1135,7 @@ mod filters { } /// Generate the full structLayout body for an FfiStruct, with computed padding + #[askama::filter_fn] pub fn ffi_struct_layout_body( ffi_struct: &uniffi_bindgen::interface::FfiStruct, _v: &dyn askama::Values, @@ -1136,6 +1175,7 @@ mod filters { } /// Maps FfiType to UNALIGNED ValueLayout for struct field access + #[askama::filter_fn] pub fn ffi_value_layout_unaligned( type_: &FfiType, _v: &dyn askama::Values, @@ -1144,6 +1184,7 @@ mod filters { } /// Cast prefix for invokeExact() return values + #[askama::filter_fn] pub fn ffi_invoke_exact_cast( type_: &FfiType, _v: &dyn askama::Values, @@ -1152,6 +1193,7 @@ mod filters { } /// Returns true if the FFI return type is a struct needing SegmentAllocator + #[askama::filter_fn] pub fn ffi_type_is_struct( type_: &FfiType, _v: &dyn askama::Values, @@ -1160,6 +1202,7 @@ mod filters { } /// Returns true if this is an embedded struct (slice-based access in struct fields) + #[askama::filter_fn] pub fn ffi_type_is_embedded_struct( type_: &FfiType, _v: &dyn askama::Values, @@ -1168,6 +1211,7 @@ mod filters { } /// Get the struct class name for an FFI struct type + #[askama::filter_fn] pub fn ffi_struct_type_name( type_: &FfiType, _v: &dyn askama::Values, @@ -1176,6 +1220,7 @@ mod filters { } /// FFI type name using boxed types for generic contexts (accepts high-level Type) + #[askama::filter_fn] pub fn ffi_type_name_boxed( type_: &impl AsType, _v: &dyn askama::Values, @@ -1185,10 +1230,18 @@ mod filters { } /// Get the interface name for a trait implementation (for external trait interfaces). + #[askama::filter_fn] pub fn trait_interface_name( trait_ty: &Type, _v: &dyn askama::Values, ci: &ComponentInterface, + ) -> Result { + trait_interface_name_for(trait_ty, ci) + } + + pub(super) fn trait_interface_name_for( + trait_ty: &Type, + ci: &ComponentInterface, ) -> Result { let Some(module_path) = trait_ty.module_path() else { return Err(to_askama_error(&format!( @@ -1229,6 +1282,7 @@ mod filters { } /// Get the idiomatic Java rendering of a class name from a string. + #[askama::filter_fn] pub fn class_name>( nm: S, _v: &dyn askama::Values, @@ -1238,6 +1292,7 @@ mod filters { } /// Get the idiomatic Java rendering of a class name from a Type. + #[askama::filter_fn] pub fn class_name_from_type( as_type: &impl AsType, _v: &dyn askama::Values, @@ -1261,11 +1316,13 @@ mod filters { } /// Get the idiomatic Java rendering of a function name. + #[askama::filter_fn] pub fn fn_name>(nm: S, _v: &dyn askama::Values) -> Result { Ok(JavaCodeOracle.fn_name(nm.as_ref())) } /// Get the idiomatic Java rendering of a variable name. + #[askama::filter_fn] pub fn var_name>( nm: S, _v: &dyn askama::Values, @@ -1274,6 +1331,7 @@ mod filters { } /// Get the idiomatic Java rendering of a variable name, without altering reserved words. + #[askama::filter_fn] pub fn var_name_raw>( nm: S, _v: &dyn askama::Values, @@ -1282,11 +1340,13 @@ mod filters { } /// Get the idiomatic Java setter method name. + #[askama::filter_fn] pub fn setter>(nm: S, _v: &dyn askama::Values) -> Result { Ok(JavaCodeOracle.setter(nm.as_ref())) } /// Get a String representing the name used for an individual enum variant. + #[askama::filter_fn] pub fn variant_name( variant: &Variant, _v: &dyn askama::Values, @@ -1294,6 +1354,7 @@ mod filters { Ok(JavaCodeOracle.enum_variant_name(variant.name())) } + #[askama::filter_fn] pub fn error_variant_name( variant: &Variant, _v: &dyn askama::Values, @@ -1303,6 +1364,7 @@ mod filters { } /// Get the idiomatic Java rendering of an FFI callback function name + #[askama::filter_fn] pub fn ffi_callback_name>( nm: S, _v: &dyn askama::Values, @@ -1311,6 +1373,7 @@ mod filters { } /// Get the idiomatic Java rendering of an FFI struct name + #[askama::filter_fn] pub fn ffi_struct_name>( nm: S, _v: &dyn askama::Values, @@ -1318,6 +1381,7 @@ mod filters { Ok(JavaCodeOracle.ffi_struct_name(nm.as_ref())) } + #[askama::filter_fn] pub fn object_names( obj: &Object, _v: &dyn askama::Values, @@ -1326,28 +1390,38 @@ mod filters { Ok(JavaCodeOracle.object_names(ci, obj)) } + // `#[askama::filter_fn]` turns each filter into a struct, so filters can't call each other + // directly; shared logic lives in a plain fn. + fn inner_return_type( + callable: &impl Callable, + ci: &ComponentInterface, + config: &Config, + ) -> String { + callable.return_type().map_or_else( + || "java.lang.Void".to_string(), + |t| t.as_codetype().type_label(ci, config), + ) + } + + #[askama::filter_fn] pub fn async_inner_return_type( callable: impl Callable, _v: &dyn askama::Values, ci: &ComponentInterface, config: &Config, ) -> Result { - callable - .return_type() - .map_or(Ok("java.lang.Void".to_string()), |t| { - type_name(t, _v, ci, config) - }) + Ok(inner_return_type(&callable, ci, config)) } + #[askama::filter_fn] pub fn async_return_type( callable: impl Callable, _v: &dyn askama::Values, ci: &ComponentInterface, config: &Config, ) -> Result { - let is_async = callable.is_async(); - let inner_type = async_inner_return_type(callable, _v, ci, config)?; - if is_async { + let inner_type = inner_return_type(&callable, ci, config); + if callable.is_async() { Ok(format!( "java.util.concurrent.CompletableFuture<{inner_type}>" )) @@ -1356,6 +1430,7 @@ mod filters { } } + #[askama::filter_fn] pub fn async_poll( callable: impl Callable, _v: &dyn askama::Values, @@ -1367,11 +1442,12 @@ mod filters { )) } + #[askama::filter_fn] pub fn async_complete( callable: impl Callable, _v: &dyn askama::Values, ci: &ComponentInterface, - _config: &Config, + config: &Config, ) -> Result { let ffi_func = callable.ffi_rust_future_complete(ci); // The complete function returns a RustBuffer for types that use RustBuffer FFI, @@ -1385,6 +1461,7 @@ mod filters { Ok(format!("(_allocator, future, continuation) -> {call}")) } + #[askama::filter_fn] pub fn async_free( callable: impl Callable, _v: &dyn askama::Values, @@ -1399,11 +1476,13 @@ mod filters { /// These are used to avoid name clashes with java identifiers, but sometimes you want to /// render the name unquoted. One example is the message property for errors where we want to /// display the name for the user. + #[askama::filter_fn] pub fn unquote>(nm: S, _v: &dyn askama::Values) -> Result { Ok(nm.as_ref().trim_matches('`').to_string()) } /// Get the idiomatic Java rendering of docstring + #[askama::filter_fn] pub fn docstring>( docstring: S, _v: &dyn askama::Values, @@ -1419,6 +1498,7 @@ mod filters { /// Returns the type name suitable for use in field declarations, method parameters, and return types. /// For non-optional primitives, returns the primitive type (int, long, boolean, etc.). /// For optional types and all other types, returns the boxed/object type. + #[askama::filter_fn] pub fn type_name_for_field( as_ct: &impl AsCodeType, _v: &dyn askama::Values, @@ -1436,6 +1516,7 @@ mod filters { /// Always returns the boxed type name, for use in generic contexts like CompletableFuture. /// This is the same as type_name but with a clearer name for template readability. + #[askama::filter_fn] pub fn boxed_type_name( as_ct: &impl AsCodeType, _v: &dyn askama::Values, @@ -1448,6 +1529,7 @@ mod filters { /// Generates an equality expression for comparing two values of a field's type. /// For primitives: returns "left == right" /// For objects: returns "java.util.Objects.equals(left, right)" + #[askama::filter_fn] pub fn equals_expr( field: &T, _v: &dyn askama::Values, @@ -1465,6 +1547,7 @@ mod filters { /// Generates a hash code expression for a field value. /// For primitives: returns "Type.hashCode(value)" (e.g., "java.lang.Integer.hashCode(value)") /// For objects: returns "java.util.Objects.hashCode(value)" + #[askama::filter_fn] pub fn hash_code_expr( field: &T, _v: &dyn askama::Values, @@ -1490,8 +1573,8 @@ mod tests { use uniffi_meta::{ CallbackInterfaceMetadata, EnumMetadata, EnumShape, FieldMetadata, FnMetadata, FnParamMetadata, Metadata, MetadataGroup, MethodMetadata, NamespaceMetadata, ObjectImpl, - ObjectMetadata, ObjectTraitImplMetadata, RecordMetadata, TraitMethodMetadata, Type, - VariantMetadata, + ObjectMetadata, ObjectTraitImplMetadata, RecordMetadata, TraitKind, TraitMethodMetadata, + Type, VariantMetadata, }; #[test] @@ -1507,11 +1590,13 @@ mod tests { items: Default::default(), }; group.add_item(Metadata::Enum(EnumMetadata { + orig_name: None, module_path: "test".to_string(), name: "Error".to_string(), shape: EnumShape::Error { flat: true }, remote: false, variants: vec![VariantMetadata { + orig_name: None, name: "Oops".to_string(), discr: None, fields: vec![], @@ -1522,6 +1607,7 @@ mod tests { docstring: None, })); group.add_item(Metadata::Func(FnMetadata { + orig_name: None, module_path: "test".to_string(), name: "always_fails".to_string(), is_async: false, @@ -1591,6 +1677,7 @@ mod tests { for (name, inner_type) in primitive_types { group.add_item(Metadata::Func(FnMetadata { + orig_name: None, module_path: "test".to_string(), name: name.to_string(), is_async: false, @@ -1779,6 +1866,7 @@ mod tests { items: Default::default(), }; group.add_item(Metadata::Func(FnMetadata { + orig_name: None, module_path: "test".to_string(), name: "noop".to_string(), is_async: false, @@ -1834,15 +1922,17 @@ mod tests { // A trait object defined in a submodule group.add_item(Metadata::Object(ObjectMetadata { + orig_name: None, module_path: submodule_path.to_string(), name: "MyTrait".to_string(), remote: false, - imp: ObjectImpl::CallbackTrait, + imp: ObjectImpl::Trait(TraitKind::ForeignOnly), docstring: None, })); // A concrete object that implements the trait, also in the submodule group.add_item(Metadata::Object(ObjectMetadata { + orig_name: None, module_path: submodule_path.to_string(), name: "MyObj".to_string(), remote: false, @@ -1859,7 +1949,7 @@ mod tests { trait_ty: Type::Object { module_path: submodule_path.to_string(), name: "MyTrait".to_string(), - imp: ObjectImpl::CallbackTrait, + imp: ObjectImpl::Trait(TraitKind::ForeignOnly), }, })); @@ -1890,6 +1980,7 @@ mod tests { items: Default::default(), }; group.add_item(Metadata::Object(ObjectMetadata { + orig_name: None, module_path: "test".to_string(), name: "DefaultMetricsRecorder".to_string(), remote: false, @@ -1916,12 +2007,11 @@ mod tests { let mut ci = ComponentInterface::from_metadata(group).unwrap(); ci.derive_ffi_funcs().unwrap(); - let interface_name = super::filters::trait_interface_name( + let interface_name = super::filters::trait_interface_name_for( &Type::CallbackInterface { module_path: "test::metrics".to_string(), name: "MetricsRecorder".to_string(), }, - &(), &ci, ) .unwrap(); @@ -2010,6 +2100,7 @@ mod tests { fn nullness_annotations_disabled_by_default() { let mut group = test_group(); group.add_item(Metadata::Func(FnMetadata { + orig_name: None, module_path: "test".to_string(), name: "maybe_string".to_string(), is_async: false, @@ -2043,6 +2134,7 @@ mod tests { fn nullness_function_with_optional_param_and_return() { let mut group = test_group(); group.add_item(Metadata::Func(FnMetadata { + orig_name: None, module_path: "test".to_string(), name: "foo".to_string(), is_async: false, @@ -2094,17 +2186,20 @@ mod tests { fn nullness_record_with_optional_field() { let mut group = test_group(); group.add_item(Metadata::Record(RecordMetadata { + orig_name: None, module_path: "test".to_string(), name: "Person".to_string(), remote: false, fields: vec![ FieldMetadata { + orig_name: None, name: "name".to_string(), ty: Type::String, default: None, docstring: None, }, FieldMetadata { + orig_name: None, name: "nickname".to_string(), ty: Type::Optional { inner_type: Box::new(Type::String), @@ -2117,6 +2212,7 @@ mod tests { })); // Need a function to make the record reachable group.add_item(Metadata::Func(FnMetadata { + orig_name: None, module_path: "test".to_string(), name: "get_person".to_string(), is_async: false, @@ -2152,17 +2248,20 @@ mod tests { fn nullness_immutable_record_with_optional_field() { let mut group = test_group(); group.add_item(Metadata::Record(RecordMetadata { + orig_name: None, module_path: "test".to_string(), name: "Person".to_string(), remote: false, fields: vec![ FieldMetadata { + orig_name: None, name: "name".to_string(), ty: Type::String, default: None, docstring: None, }, FieldMetadata { + orig_name: None, name: "nickname".to_string(), ty: Type::Optional { inner_type: Box::new(Type::String), @@ -2174,6 +2273,7 @@ mod tests { docstring: None, })); group.add_item(Metadata::Func(FnMetadata { + orig_name: None, module_path: "test".to_string(), name: "get_person".to_string(), is_async: false, @@ -2210,6 +2310,7 @@ mod tests { fn nullness_object_method_with_optional_param() { let mut group = test_group(); group.add_item(Metadata::Object(ObjectMetadata { + orig_name: None, module_path: "test".to_string(), name: "MyObj".to_string(), remote: false, @@ -2217,6 +2318,7 @@ mod tests { docstring: None, })); group.add_item(Metadata::Method(MethodMetadata { + orig_name: None, module_path: "test".to_string(), self_name: "MyObj".to_string(), name: "do_thing".to_string(), @@ -2254,6 +2356,7 @@ mod tests { fn nullness_object_cleanable_field_annotated() { let mut group = test_group(); group.add_item(Metadata::Object(ObjectMetadata { + orig_name: None, module_path: "test".to_string(), name: "MyObj".to_string(), remote: false, @@ -2279,6 +2382,7 @@ mod tests { fn nullness_object_cleanable_field_not_annotated_by_default() { let mut group = test_group(); group.add_item(Metadata::Object(ObjectMetadata { + orig_name: None, module_path: "test".to_string(), name: "MyObj".to_string(), remote: false, @@ -2302,14 +2406,17 @@ mod tests { fn nullness_enum_variant_with_optional_field() { let mut group = test_group(); group.add_item(Metadata::Enum(EnumMetadata { + orig_name: None, module_path: "test".to_string(), name: "MyEnum".to_string(), shape: EnumShape::Enum, remote: false, variants: vec![VariantMetadata { + orig_name: None, name: "WithOptional".to_string(), discr: None, fields: vec![FieldMetadata { + orig_name: None, name: "value".to_string(), ty: Type::Optional { inner_type: Box::new(Type::String), @@ -2324,6 +2431,7 @@ mod tests { docstring: None, })); group.add_item(Metadata::Func(FnMetadata { + orig_name: None, module_path: "test".to_string(), name: "get_enum".to_string(), is_async: false, @@ -2353,14 +2461,17 @@ mod tests { fn nullness_error_variant_with_optional_field() { let mut group = test_group(); group.add_item(Metadata::Enum(EnumMetadata { + orig_name: None, module_path: "test".to_string(), name: "MyError".to_string(), shape: EnumShape::Error { flat: false }, remote: false, variants: vec![VariantMetadata { + orig_name: None, name: "BadInput".to_string(), discr: None, fields: vec![FieldMetadata { + orig_name: None, name: "detail".to_string(), ty: Type::Optional { inner_type: Box::new(Type::String), @@ -2375,6 +2486,7 @@ mod tests { docstring: None, })); group.add_item(Metadata::Func(FnMetadata { + orig_name: None, module_path: "test".to_string(), name: "do_stuff".to_string(), is_async: false, @@ -2404,6 +2516,7 @@ mod tests { fn nullness_async_function_with_optional_return() { let mut group = test_group(); group.add_item(Metadata::Func(FnMetadata { + orig_name: None, module_path: "test".to_string(), name: "fetch".to_string(), is_async: true, @@ -2433,6 +2546,7 @@ mod tests { fn nullness_non_optional_types_never_nullable() { let mut group = test_group(); group.add_item(Metadata::Func(FnMetadata { + orig_name: None, module_path: "test".to_string(), name: "identity".to_string(), is_async: false, @@ -2476,6 +2590,7 @@ mod tests { fn nullness_nested_optional_in_map_value() { let mut group = test_group(); group.add_item(Metadata::Func(FnMetadata { + orig_name: None, module_path: "test".to_string(), name: "process_map".to_string(), is_async: false, @@ -2509,6 +2624,7 @@ mod tests { fn nullness_nested_optional_in_list() { let mut group = test_group(); group.add_item(Metadata::Func(FnMetadata { + orig_name: None, module_path: "test".to_string(), name: "process_list".to_string(), is_async: false, @@ -2558,6 +2674,7 @@ mod tests { docstring: None, })); group.add_item(Metadata::TraitMethod(TraitMethodMetadata { + orig_name: None, module_path: "test".to_string(), trait_name: "Histogram".to_string(), index: 0, diff --git a/src/lib.rs b/src/lib.rs index d444b97..2e31224 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -4,7 +4,7 @@ use clap::{Parser, Subcommand}; use std::collections::HashMap; use std::fs; use uniffi_bindgen::{ - BindgenLoader, BindgenPaths, Component, ComponentInterface, interface::rename, + BindgenLoader, BindgenPaths, Component, ComponentInterface, GlobalConfig, interface::rename, }; mod gen_java; @@ -147,24 +147,31 @@ fn apply_renames_and_external_packages(components: &mut Vec>) } } -/// Create BindgenPaths with cargo metadata layer and optional config override fn create_bindgen_paths( - config_override: Option<&Utf8Path>, + global_config_path: Option<&Utf8Path>, metadata_no_deps: bool, -) -> Result { +) -> Result<(BindgenPaths, GlobalConfig)> { let mut paths = BindgenPaths::default(); - // Add config override layer first (takes precedence) - if let Some(config_path) = config_override { - paths.add_config_override_layer(config_path.to_path_buf()); - } + // `BindgenPaths` resolves through layers first-added-wins, so `[crate-roots]` has to land + // before cargo metadata to override it. + let global_config = match global_config_path { + Some(path) => { + let (config, crate_roots) = GlobalConfig::from_file(path) + .with_context(|| format!("Failed to load global config: {path}"))?; + if let Some(layer) = crate_roots { + paths.add_layer(layer); + } + config + } + None => GlobalConfig::default(), + }; - // Add cargo metadata layer for finding crate configs paths .add_cargo_metadata_layer(metadata_no_deps) .context("Failed to load cargo metadata")?; - Ok(paths) + Ok((paths, global_config)) } #[derive(Parser)] @@ -189,7 +196,8 @@ enum Commands { #[clap(long, short)] no_format: bool, - /// Path to optional uniffi config file. This config is merged with the `uniffi.toml` config present in each crate, with its values taking precedence. + /// Path to an optional uniffi global config file, with `[defaults]`, `[crates.]` + /// and/or `[crate-roots]` sections. Merged with each crate's `uniffi.toml`. #[clap(long, short)] config: Option, @@ -248,9 +256,8 @@ pub fn run_main() -> Result<()> { .unwrap_or_else(|| Utf8PathBuf::from(".")) }); - // Create BindgenPaths with cargo metadata and optional config override - let paths = create_bindgen_paths(config.as_deref(), metadata_no_deps)?; - let loader = BindgenLoader::new(paths); + let (paths, global_config) = create_bindgen_paths(config.as_deref(), metadata_no_deps)?; + let loader = BindgenLoader::new(paths, global_config); fs::create_dir_all(&out_dir)?; diff --git a/src/templates/EnumTemplate.java b/src/templates/EnumTemplate.java index a975ab2..5fa35ab 100644 --- a/src/templates/EnumTemplate.java +++ b/src/templates/EnumTemplate.java @@ -2,25 +2,25 @@ package {{ config.package_name() }}; {%- if e.is_flat() %} -{% call java::docstring(e, 0) %} +{% call java::docstring(e, 0) %}{% endcall %} {% match e.variant_discr_type() %} {% when None %} public enum {{ type_name }} { {%- for variant in e.variants() -%} - {%- call java::docstring(variant, 4) %} + {%- call java::docstring(variant, 4) %}{% endcall %} {{ variant|variant_name}}{% if loop.last %};{% else %},{% endif %} {%- endfor %} {% for meth in e.methods() -%} - {%- call java::func_decl("public", "", meth, 4) %} + {%- call java::func_decl("public", "", meth, 4) %}{% endcall %} {% endfor %} {# Add trait implementations for flat enums #} - {% call java::uniffi_trait_impls(uniffi_trait_methods) %} + {% call java::uniffi_trait_impls(uniffi_trait_methods) %}{% endcall %} } {% when Some with (variant_discr_type) %} public enum {{ type_name }} { {% for variant in e.variants() -%} - {%- call java::docstring(variant, 4) %} + {%- call java::docstring(variant, 4) %}{% endcall %} {{ variant|variant_name}}({{ e|variant_discr_literal(loop.index0)}}){% if loop.last %};{% else %},{% endif %} {%- endfor %} @@ -30,10 +30,10 @@ public enum {{ type_name }} { } {% for meth in e.methods() -%} - {%- call java::func_decl("public", "", meth, 4) %} + {%- call java::func_decl("public", "", meth, 4) %}{% endcall %} {% endfor %} {# Add trait implementations for flat enums with discriminant #} - {% call java::uniffi_trait_impls(uniffi_trait_methods) %} + {% call java::uniffi_trait_impls(uniffi_trait_methods) %}{% endcall %} } {% endmatch %} @@ -64,10 +64,10 @@ public void write({{ type_name }} value, java.nio.ByteBuffer buf) { {% else %} -{%- call java::docstring(e, 0) %} +{%- call java::docstring(e, 0) %}{% endcall %} public sealed interface {{ type_name }}{% if uniffi_trait_methods.ord_cmp.is_some() %}{% if contains_object_references %} extends AutoCloseable, Comparable<{{ type_name }}>{% else %} extends Comparable<{{ type_name }}>{% endif %}{% else %}{% if contains_object_references %} extends AutoCloseable{% endif %}{% endif %} { {% for variant in e.variants() -%} - {%- call java::docstring(variant, 4) %} + {%- call java::docstring(variant, 4) %}{% endcall %} {% if !variant.has_fields() -%} record {{ variant|type_name(ci, config)}}() implements {{ type_name }} { {% if contains_object_references %} @@ -78,30 +78,30 @@ public void close() { {% endif %} {# Re-get trait methods for each variant to avoid move issues #} {%- let variant_trait_methods = e.uniffi_trait_methods() %} - {% call java::uniffi_trait_impls(variant_trait_methods) %} + {% call java::uniffi_trait_impls(variant_trait_methods) %}{% endcall %} } {% else -%} record {{ variant|type_name(ci, config)}}( {%- for field in variant.fields() -%} - {%- call java::docstring(field, 8) %} - {{ field|qualified_type_name(ci, config)}} {% call java::field_name(field, loop.index) %}{% if loop.last %}{% else %}, {% endif %} + {%- call java::docstring(field, 8) %}{% endcall %} + {{ field|qualified_type_name(ci, config)}} {% call java::field_name(field, loop.index) %}{% endcall %}{% if loop.last %}{% else %}, {% endif %} {%- endfor -%} ) implements {{ type_name }} { {% if contains_object_references %} @Override public void close() { - {% call java::destroy_fields(variant) %} + {% call java::destroy_fields(variant) %}{% endcall %} } {% endif %} {# Re-get trait methods for each variant to avoid move issues #} {%- let variant_trait_methods = e.uniffi_trait_methods() %} - {% call java::uniffi_trait_impls(variant_trait_methods) %} + {% call java::uniffi_trait_impls(variant_trait_methods) %}{% endcall %} } {%- endif %} {% endfor %} {% for meth in e.methods() -%} - {%- call java::func_decl("default", "", meth, 4) %} + {%- call java::func_decl("default", "", meth, 4) %}{% endcall %} {% endfor %} } @@ -130,10 +130,10 @@ public enum {{ e|ffi_converter_name}} implements FfiConverterRustBuffer<{{ type_ public long allocationSize({{ type_name }} value) { return switch (value) { {%- for variant in e.variants() %} - case {{ type_name }}.{{ variant|type_name(ci, config) }}({%- for field in variant.fields() %}var {% call java::field_name(field, loop.index) -%}{% if !loop.last%}, {% endif %}{% endfor %}) -> + case {{ type_name }}.{{ variant|type_name(ci, config) }}({%- for field in variant.fields() %}var {% call java::field_name(field, loop.index) %}{% endcall -%}{% if !loop.last%}, {% endif %}{% endfor %}) -> (4L {%- for field in variant.fields() %} - + {{ field|allocation_size_fn(config, ci) }}({%- call java::field_name(field, loop.index) -%}) + + {{ field|allocation_size_fn(config, ci) }}({%- call java::field_name(field, loop.index) %}{% endcall -%}) {%- endfor %}); {%- endfor %} }; @@ -143,10 +143,10 @@ public long allocationSize({{ type_name }} value) { public void write({{ type_name }} value, java.nio.ByteBuffer buf) { switch (value) { {%- for variant in e.variants() %} - case {{ type_name }}.{{ variant|type_name(ci, config) }}({%- for field in variant.fields() %}var {% call java::field_name(field, loop.index) -%}{% if !loop.last%}, {% endif %}{% endfor %}) -> { + case {{ type_name }}.{{ variant|type_name(ci, config) }}({%- for field in variant.fields() %}var {% call java::field_name(field, loop.index) %}{% endcall -%}{% if !loop.last%}, {% endif %}{% endfor %}) -> { buf.putInt({{ loop.index }}); {%- for field in variant.fields() %} - {{ field|write_fn(config, ci) }}({%- call java::field_name(field, loop.index) -%}, buf); + {{ field|write_fn(config, ci) }}({%- call java::field_name(field, loop.index) %}{% endcall -%}, buf); {%- endfor %} } {%- endfor %} diff --git a/src/templates/ErrorTemplate.java b/src/templates/ErrorTemplate.java index fe13136..c3740c3 100644 --- a/src/templates/ErrorTemplate.java +++ b/src/templates/ErrorTemplate.java @@ -5,14 +5,14 @@ {%- let canonical_type_name = type_|canonical_name %} {% if e.is_flat() %} -{%- call java::docstring(e, 0) %} +{%- call java::docstring(e, 0) %}{% endcall %} public class {{ type_name }} extends java.lang.Exception { private {{ type_name }}(java.lang.String message) { super(message); } {% for variant in e.variants() -%} - {%- call java::docstring(variant, 4) %} + {%- call java::docstring(variant, 4) %}{% endcall %} public static class {{ variant|error_variant_name }} extends {{ type_name }}{% if contains_object_references %}, AutoCloseable{% endif %} { public {{ variant|error_variant_name }}(java.lang.String message) { super(message); @@ -23,43 +23,43 @@ public static class {{ variant|error_variant_name }} extends {{ type_name }}{% i {%- else %} -{%- call java::docstring(e, 0) %} +{%- call java::docstring(e, 0) %}{% endcall %} public class {{ type_name }} extends java.lang.Exception { private {{ type_name }}(java.lang.String message) { super(message); } {% for variant in e.variants() -%} - {%- call java::docstring(variant, 4) %} + {%- call java::docstring(variant, 4) %}{% endcall %} {%- let variant_name = variant|error_variant_name %} public static class {{ variant_name }} extends {{ type_name }}{% if contains_object_references %}, AutoCloseable{% endif %} { {% for field in variant.fields() -%} - {%- call java::docstring(field, 8) %} - {{ field|type_name(ci, config) }} {% call java::field_name(field, loop.index) %}; + {%- call java::docstring(field, 8) %}{% endcall %} + {{ field|type_name(ci, config) }} {% call java::field_name(field, loop.index) %}{% endcall %}; {% endfor -%} public {{ variant_name }}( {%- for field in variant.fields() -%} - {{ field|type_name(ci, config)}} {% call java::field_name(field, loop.index) %}{% if loop.last %}{% else %}, {% endif %} + {{ field|type_name(ci, config)}} {% call java::field_name(field, loop.index) %}{% endcall %}{% if loop.last %}{% else %}, {% endif %} {%- endfor -%} ) { super(new StringBuilder() {%- for field in variant.fields() %} - .append("{% call java::field_name_unquoted(field, loop.index) %}=") - .append({% call java::field_name(field, loop.index) %}) + .append("{% call java::field_name_unquoted(field, loop.index) %}{% endcall %}=") + .append({% call java::field_name(field, loop.index) %}{% endcall %}) {% if !loop.last %} .append(", ") {% endif %} {% endfor %} .toString()); {% for field in variant.fields() -%} - this.{% call java::field_name(field, loop.index) %} = {% call java::field_name(field, loop.index) %}; + this.{% call java::field_name(field, loop.index) %}{% endcall %} = {% call java::field_name(field, loop.index) %}{% endcall %}; {% endfor -%} } {% for field in variant.fields() -%} - public {{ field|type_name(ci, config) }} {% call java::field_name(field, loop.index) %}() { - return this.{% call java::field_name(field, loop.index) %}; + public {{ field|type_name(ci, config) }} {% call java::field_name(field, loop.index) %}{% endcall %}() { + return this.{% call java::field_name(field, loop.index) %}{% endcall %}; } {% endfor %} @@ -67,7 +67,7 @@ public static class {{ variant_name }} extends {{ type_name }}{% if contains_obj @Override void close() { {%- if variant.has_fields() %} - {% call java::destroy_fields(variant) %} + {% call java::destroy_fields(variant) %}{% endcall %} {% else -%} // Nothing to destroy {%- endif %} @@ -127,7 +127,7 @@ public long allocationSize({{ type_name }} value) { // Add the size for the Int that specifies the variant plus the size needed for all fields 4L {%- for field in variant.fields() %} - + {{ field|allocation_size_fn(config, ci) }}(x.{% call java::field_name(field, loop.index) %}) + + {{ field|allocation_size_fn(config, ci) }}(x.{% call java::field_name(field, loop.index) %}{% endcall %}) {%- endfor %} ); {%- endfor %} @@ -143,7 +143,7 @@ public void write({{ type_name }} value, java.nio.ByteBuffer buf) { case {{ type_name }}.{{ variant|error_variant_name }} x -> { buf.putInt({{ loop.index }}); {%- for field in variant.fields() %} - {{ field|write_fn(config, ci) }}(x.{% call java::field_name(field, loop.index) %}, buf); + {{ field|write_fn(config, ci) }}(x.{% call java::field_name(field, loop.index) %}{% endcall %}, buf); {%- endfor %} } {%- endfor %} diff --git a/src/templates/Interface.java b/src/templates/Interface.java index 2fd1575..46a8d86 100644 --- a/src/templates/Interface.java +++ b/src/templates/Interface.java @@ -1,10 +1,10 @@ package {{ config.package_name() }}; -{%- call java::docstring_value(interface_docstring, 0) %} +{%- call java::docstring_value(interface_docstring, 0) %}{% endcall %} public interface {{ interface_name }} { {% for meth in methods.iter() -%} - {%- call java::docstring(meth, 4) %} + {%- call java::docstring(meth, 4) %}{% endcall %} {#- Async methods use CompletableFuture which requires boxed types -#} - public {% if meth.is_async() %}java.util.concurrent.CompletableFuture<{% endif %}{% match meth.return_type() -%}{%- when Some with (return_type) %}{% if meth.is_async() %}{{ return_type|boxed_type_name(ci, config) }}{% else %}{{ return_type|type_name_for_field(ci, config) }}{% endif %}{%- else -%}{% if meth.is_async() %}java.lang.Void{% else %}void{% endif %}{%- endmatch %}{% if meth.is_async() %}>{% endif %} {{ meth.name()|fn_name }}({% call java::arg_list(meth, true) %}){% match meth.throws_type() %}{% when Some(throwable) %} {% if !meth.is_async() %}throws {{ throwable|type_name(ci, config) }}{% endif %}{% else %}{% endmatch %}; + public {% if meth.is_async() %}java.util.concurrent.CompletableFuture<{% endif %}{% match meth.return_type() -%}{%- when Some with (return_type) %}{% if meth.is_async() %}{{ return_type|boxed_type_name(ci, config) }}{% else %}{{ return_type|type_name_for_field(ci, config) }}{% endif %}{%- else -%}{% if meth.is_async() %}java.lang.Void{% else %}void{% endif %}{%- endmatch %}{% if meth.is_async() %}>{% endif %} {{ meth.name()|fn_name }}({% call java::arg_list(meth, true) %}{% endcall %}){% match meth.throws_type() %}{% when Some(throwable) %} {% if !meth.is_async() %}throws {{ throwable|type_name(ci, config) }}{% endif %}{% else %}{% endmatch %}; {% endfor %} } diff --git a/src/templates/ObjectTemplate.java b/src/templates/ObjectTemplate.java index 4f26cd3..28ebd28 100644 --- a/src/templates/ObjectTemplate.java +++ b/src/templates/ObjectTemplate.java @@ -111,7 +111,7 @@ package {{ config.package_name() }}; -{%- call java::docstring(obj, 0) %} +{%- call java::docstring(obj, 0) %}{% endcall %} {% if (is_error) %} public class {{ impl_class_name }} extends Exception implements AutoCloseable, {{ interface_name }}{% for t in obj.trait_impls() %}, {{ t.trait_ty|trait_interface_name(ci) }}{% endfor %}{% if uniffi_trait_methods.ord_cmp.is_some() %}, Comparable<{{ impl_class_name }}>{% endif %} { {% else -%} @@ -147,9 +147,9 @@ public class {{ impl_class_name }} implements AutoCloseable, {{ interface_name } {%- if cons.is_async() %} // Note no constructor generated for this object as it is async. {%- else %} - {%- call java::docstring(cons, 4) %} - public {{ impl_class_name }}({% call java::arg_list(cons, true) -%}) {% match cons.throws_type() %}{% when Some(throwable) %}throws {{ throwable|type_name(ci, config) }}{% else %}{% endmatch %}{ - this(UniffiWithHandle.INSTANCE, (long){%- call java::to_ffi_call(cons) -%}); + {%- call java::docstring(cons, 4) %}{% endcall %} + public {{ impl_class_name }}({% call java::arg_list(cons, true) %}{% endcall -%}) {% match cons.throws_type() %}{% when Some(throwable) %}throws {{ throwable|type_name(ci, config) }}{% else %}{% endmatch %}{ + this(UniffiWithHandle.INSTANCE, (long){%- call java::to_ffi_call(cons) %}{% endcall -%}); } {%- endif %} {%- when None %} @@ -231,14 +231,14 @@ long uniffiCloneHandle() { } {% for meth in obj.methods() -%} - {%- call java::func_decl("public", "Override", meth, 4) %} + {%- call java::func_decl("public", "Override", meth, 4) %}{% endcall %} {% endfor %} - {% call java::uniffi_trait_impls(uniffi_trait_methods) %} + {% call java::uniffi_trait_impls(uniffi_trait_methods) %}{% endcall %} {% if !obj.alternate_constructors().is_empty() -%} {% for cons in obj.alternate_constructors() -%} - {% call java::func_decl("public static", "", cons, 4) %} + {% call java::func_decl("public static", "", cons, 4) %}{% endcall %} {% endfor %} {% endif %} } diff --git a/src/templates/RecordTemplate.java b/src/templates/RecordTemplate.java index 092a0a9..0eb20b6 100644 --- a/src/templates/RecordTemplate.java +++ b/src/templates/RecordTemplate.java @@ -2,12 +2,12 @@ {%- let uniffi_trait_methods = rec.uniffi_trait_methods() %} package {{ config.package_name() }}; -{%- call java::docstring(rec, 0) %} +{%- call java::docstring(rec, 0) %}{% endcall %} {%- if rec.has_fields() %} {%- if config.generate_immutable_records() %} public record {{ type_name }}( {%- for field in rec.fields() %} - {%- call java::docstring(field, 4) %} + {%- call java::docstring(field, 4) %}{% endcall %} {{ field|type_name_for_field(ci, config) }} {{ field.name()|var_name -}} {% if !loop.last %}, {% endif %} {%- endfor %} @@ -15,19 +15,19 @@ public record {{ type_name }}( {% if contains_object_references %} @Override public void close() { - {% call java::destroy_fields(rec) %} + {% call java::destroy_fields(rec) %}{% endcall %} } {% endif %} {% for meth in rec.methods() -%} - {%- call java::func_decl("public", "", meth, 4) %} + {%- call java::func_decl("public", "", meth, 4) %}{% endcall %} {% endfor %} {# Add trait implementations for immutable records - these override record's auto-generated methods #} - {% call java::uniffi_trait_impls(uniffi_trait_methods) %} + {% call java::uniffi_trait_impls(uniffi_trait_methods) %}{% endcall %} } {% else %} public class {{ type_name }} {% if contains_object_references %}implements AutoCloseable{% if uniffi_trait_methods.ord_cmp.is_some() %}, Comparable<{{ type_name }}>{% endif %}{% else %}{% if uniffi_trait_methods.ord_cmp.is_some() %}implements Comparable<{{ type_name }}> {% endif %}{% endif %}{ {%- for field in rec.fields() %} - {%- call java::docstring(field, 4) %} + {%- call java::docstring(field, 4) %}{% endcall %} private {{ field|type_name_for_field(ci, config) }} {{ field.name()|var_name -}}; {%- endfor %} @@ -60,7 +60,7 @@ public class {{ type_name }} {% if contains_object_references %}implements AutoC {% if contains_object_references %} @Override public void close() { - {% call java::destroy_fields(rec) %} + {% call java::destroy_fields(rec) %}{% endcall %} } {% endif %} @@ -91,10 +91,10 @@ public int hashCode() { {%- endif %} {% for meth in rec.methods() -%} - {%- call java::func_decl("public", "", meth, 4) %} + {%- call java::func_decl("public", "", meth, 4) %}{% endcall %} {% endfor %} {# Add trait implementations #} - {% call java::uniffi_trait_impls(uniffi_trait_methods) %} + {% call java::uniffi_trait_impls(uniffi_trait_methods) %}{% endcall %} } {% endif %} {%- else %} @@ -114,10 +114,10 @@ public int hashCode() { {%- endif %} {% for meth in rec.methods() -%} - {%- call java::func_decl("public", "", meth, 4) %} + {%- call java::func_decl("public", "", meth, 4) %}{% endcall %} {% endfor %} {# Add trait implementations #} - {% call java::uniffi_trait_impls(uniffi_trait_methods) %} + {% call java::uniffi_trait_impls(uniffi_trait_methods) %}{% endcall %} } {%- endif %} diff --git a/src/templates/SetTemplate.java b/src/templates/SetTemplate.java new file mode 100644 index 0000000..13beb20 --- /dev/null +++ b/src/templates/SetTemplate.java @@ -0,0 +1,32 @@ +{%- let inner_type_name = inner_type|type_name(ci, config) %} +/* 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/. */ +package {{ config.package_name() }}; + +public enum {{ ffi_converter_name }} implements FfiConverterRustBuffer> { + INSTANCE; + + @Override + public java.util.Set<{{ inner_type_name }}> read(java.nio.ByteBuffer buf) { + int len = buf.getInt(); + java.util.Set<{{ inner_type_name }}> set = java.util.LinkedHashSet.newLinkedHashSet(len); + for (int _i = 0; _i < len; _i++) { + set.add({{ inner_type|read_fn(config, ci) }}(buf)); + } + return set; + } + + @Override + public long allocationSize(java.util.Set<{{ inner_type_name }}> value) { + long sizeForLength = 4L; + long sizeForItems = value.stream().mapToLong(inner -> {{ inner_type|allocation_size_fn(config, ci) }}(inner)).sum(); + return sizeForLength + sizeForItems; + } + + @Override + public void write(java.util.Set<{{ inner_type_name }}> value, java.nio.ByteBuffer buf) { + buf.putInt(value.size()); + value.forEach(inner -> {{ inner_type|write_fn(config, ci) }}(inner, buf)); + } +} diff --git a/src/templates/Types.java b/src/templates/Types.java index 330cbe6..b97e542 100644 --- a/src/templates/Types.java +++ b/src/templates/Types.java @@ -170,6 +170,9 @@ private UniffiWithHandle() {} {% include "SequenceTemplate.java" %} {%- endmatch %} +{%- when Type::Set { inner_type } %} +{% include "SetTemplate.java" %} + {%- when Type::String %} {%- include "StringHelper.java" %} diff --git a/src/templates/macros.java b/src/templates/macros.java index bf7bd03..a2506c8 100644 --- a/src/templates/macros.java +++ b/src/templates/macros.java @@ -10,16 +10,16 @@ callWithHandle(uniffiHandle -> { try { {% if func.return_type().is_some() %} - return {%- call to_raw_ffi_call(func) %}; + return {%- call to_raw_ffi_call(func) %}{% endcall %}; {% else %} - {%- call to_raw_ffi_call(func) %}; + {%- call to_raw_ffi_call(func) %}{% endcall %}; {% endif %} } catch (java.lang.Exception _uniffi_ex) { throw new java.lang.RuntimeException(_uniffi_ex); } }) {% else %} - {%- call to_raw_ffi_call(func) %} + {%- call to_raw_ffi_call(func) %}{% endcall %} {% endmatch %} {%- endmacro %} @@ -52,13 +52,13 @@ {%- when Some(t) %}{{ t|lower_fn(config, ci) }}(this), {%- when None %} {%- endmatch %} - {% if func.arguments().len() != 0 %}{% call arg_list_lowered(func) -%}, {% endif -%} + {% if func.arguments().len() != 0 %}{% call arg_list_lowered(func) %}{% endcall -%}, {% endif -%} _status); }) {%- endmacro -%} {%- macro func_decl(func_decl, annotation, callable, indent) %} - {%- call docstring(callable, indent) %} + {%- call docstring(callable, indent) %}{% endcall %} {%- if annotation != "" %} @{{ annotation }} {% endif %} @@ -66,21 +66,21 @@ {#- Async methods use CompletableFuture which requires boxed types -#} {#- No-executor overload — defaults to ForkJoinPool.commonPool(), delegates to Executor version -#} {{ func_decl }} java.util.concurrent.CompletableFuture<{% match callable.return_type() -%}{%- when Some with (return_type) -%}{{ return_type|boxed_type_name(ci, config) }}{%- when None %}java.lang.Void{%- endmatch %}> {{ callable.name()|fn_name }}( - {%- call arg_list(callable, !callable.self_type().is_some()) -%} + {%- call arg_list(callable, !callable.self_type().is_some()) %}{% endcall -%} ){ - return {{ callable.name()|fn_name }}({% call arg_name_list(callable) %}{% if !callable.arguments().is_empty() %}, {% endif %}java.util.concurrent.ForkJoinPool.commonPool()); + return {{ callable.name()|fn_name }}({% call arg_name_list(callable) %}{% endcall %}{% if !callable.arguments().is_empty() %}, {% endif %}java.util.concurrent.ForkJoinPool.commonPool()); } {#- With-executor overload — does the actual async work -#} {{ func_decl }} java.util.concurrent.CompletableFuture<{% match callable.return_type() -%}{%- when Some with (return_type) -%}{{ return_type|boxed_type_name(ci, config) }}{%- when None %}java.lang.Void{%- endmatch %}> {{ callable.name()|fn_name }}( - {%- call arg_list(callable, !callable.self_type().is_some()) -%}{% if !callable.arguments().is_empty() %}, {% endif %}java.util.concurrent.Executor uniffiExecutor + {%- call arg_list(callable, !callable.self_type().is_some()) %}{% endcall -%}{% if !callable.arguments().is_empty() %}, {% endif %}java.util.concurrent.Executor uniffiExecutor ){ - return {% call call_async(callable) %}; + return {% call call_async(callable) %}{% endcall %}; } {%- else -%} {#- Sync methods can use primitives for return types -#} {{ func_decl }} {% match callable.return_type() -%}{%- when Some with (return_type) -%}{{ return_type|type_name_for_field(ci, config) }}{%- when None %}void{%- endmatch %} {{ callable.name()|fn_name }}( - {%- call arg_list(callable, !callable.self_type().is_some()) -%} + {%- call arg_list(callable, !callable.self_type().is_some()) %}{% endcall -%} ) {% match callable.throws_type() -%} {%- when Some(throwable) -%} throws {{ throwable|type_name(ci, config) }} @@ -90,11 +90,11 @@ {% match callable.return_type() -%} {%- when Some with (return_type) -%} {%- if return_type|has_primitive_ffi_type -%} - return {% call to_ffi_call(callable) %} + return {% call to_ffi_call(callable) %}{% endcall %} {%- else -%} - return {{ return_type|lift_fn(config, ci) }}({% call to_ffi_call(callable) %}) + return {{ return_type|lift_fn(config, ci) }}({% call to_ffi_call(callable) %}{% endcall %}) {%- endif -%} - {%- when None %}{% call to_ffi_call(callable) %}{%- endmatch %}; + {%- when None %}{% call to_ffi_call(callable) %}{% endcall %}{%- endmatch %}; } catch (java.lang.RuntimeException _uniffi_ex) { {% match callable.throws_type() %} {% when Some(throwable) %} @@ -120,16 +120,16 @@ callWithHandle(uniffiHandle -> { return UniffiLib.{{ callable.ffi_func().name() }}( uniffiHandle{% if callable.arguments().len() != 0 %},{% endif %} - {% call arg_list_lowered(callable) %} + {% call arg_list_lowered(callable) %}{% endcall %} ); }), {%- when Some(t) %} UniffiLib.{{ callable.ffi_func().name() }}( {{ t|lower_fn(config, ci) }}(this){% if callable.arguments().len() != 0 %},{% endif %} - {% call arg_list_lowered(callable) %} + {% call arg_list_lowered(callable) %}{% endcall %} ), {%- when None %} - UniffiLib.{{ callable.ffi_func().name() }}({% call arg_list_lowered(callable) %}), + UniffiLib.{{ callable.ffi_func().name() }}({% call arg_list_lowered(callable) %}{% endcall %}), {%- endmatch %} {{ callable|async_poll(ci) }}, {{ callable|async_complete(ci, config) }}, @@ -233,7 +233,7 @@ {%- endmacro %} {%- macro docstring(defn, indent_spaces) %} -{%- call docstring_value(defn.docstring(), indent_spaces) %} +{%- call docstring_value(defn.docstring(), indent_spaces) %}{% endcall %} {%- endmacro %} {# Macro for uniffi_trait implementations - Display, Eq, Hash, Ord #} @@ -242,7 +242,7 @@ {%- if let Some(fmt) = uniffi_trait_methods.display_fmt.or(uniffi_trait_methods.debug_fmt.clone()) %} @Override public java.lang.String toString() { - return {{ fmt.return_type().unwrap()|lift_fn(config, ci) }}({% call to_ffi_call(fmt) %}); + return {{ fmt.return_type().unwrap()|lift_fn(config, ci) }}({% call to_ffi_call(fmt) %}{% endcall %}); } {%- endif %} {%- if let Some(eq) = uniffi_trait_methods.eq_eq %} @@ -251,20 +251,20 @@ public boolean equals(java.lang.Object obj) { if (this == obj) return true; if (!(obj instanceof {{ eq.object_name()|class_name(ci) }})) return false; {{ eq.object_name()|class_name(ci) }} other = ({{ eq.object_name()|class_name(ci) }}) obj; - return {{ eq.return_type().unwrap()|lift_fn(config, ci) }}({% call to_ffi_call(eq) %}); + return {{ eq.return_type().unwrap()|lift_fn(config, ci) }}({% call to_ffi_call(eq) %}{% endcall %}); } {%- endif %} {%- if let Some(hash) = uniffi_trait_methods.hash_hash %} @Override public int hashCode() { - return {{ hash.return_type().unwrap()|lift_fn(config, ci) }}({%- call to_ffi_call(hash) %}).intValue(); + return {{ hash.return_type().unwrap()|lift_fn(config, ci) }}({%- call to_ffi_call(hash) %}{% endcall %}).intValue(); } {%- endif %} {%- if let Some(cmp) = uniffi_trait_methods.ord_cmp %} @Override public int compareTo({{ cmp.object_name()|class_name(ci) }} other) { if (other == null) throw new java.lang.NullPointerException(); - return {{ cmp.return_type().unwrap()|lift_fn(config, ci) }}({%- call to_ffi_call(cmp) %}).intValue(); + return {{ cmp.return_type().unwrap()|lift_fn(config, ci) }}({%- call to_ffi_call(cmp) %}{% endcall %}).intValue(); } {%- endif %} {%- endmacro %} @@ -274,7 +274,7 @@ public int compareTo({{ cmp.object_name()|class_name(ci) }} other) { {# Prefer Display, fall back to Debug #} {%- if let Some(fmt) = uniffi_trait_methods.display_fmt.or(uniffi_trait_methods.debug_fmt.clone()) %} default java.lang.String toStringTrait() { - return {{ fmt.return_type().unwrap()|lift_fn(config, ci) }}({% call to_ffi_call(fmt) %}); + return {{ fmt.return_type().unwrap()|lift_fn(config, ci) }}({% call to_ffi_call(fmt) %}{% endcall %}); } {%- endif %} {%- if let Some(eq) = uniffi_trait_methods.eq_eq %} @@ -282,18 +282,18 @@ default boolean equalsTrait(java.lang.Object obj) { if (this == obj) return true; if (!(obj instanceof {{ eq.object_name()|class_name(ci) }})) return false; {{ eq.object_name()|class_name(ci) }} other = ({{ eq.object_name()|class_name(ci) }}) obj; - return {{ eq.return_type().unwrap()|lift_fn(config, ci) }}({% call to_ffi_call(eq) %}); + return {{ eq.return_type().unwrap()|lift_fn(config, ci) }}({% call to_ffi_call(eq) %}{% endcall %}); } {%- endif %} {%- if let Some(hash) = uniffi_trait_methods.hash_hash %} default int hashCodeTrait() { - return {{ hash.return_type().unwrap()|lift_fn(config, ci) }}({%- call to_ffi_call(hash) %}).intValue(); + return {{ hash.return_type().unwrap()|lift_fn(config, ci) }}({%- call to_ffi_call(hash) %}{% endcall %}).intValue(); } {%- endif %} {%- if let Some(cmp) = uniffi_trait_methods.ord_cmp %} default int compareTo({{ cmp.object_name()|class_name(ci) }} other) { if (other == null) throw new java.lang.NullPointerException(); - return {{ cmp.return_type().unwrap()|lift_fn(config, ci) }}({%- call to_ffi_call(cmp) %}).intValue(); + return {{ cmp.return_type().unwrap()|lift_fn(config, ci) }}({%- call to_ffi_call(cmp) %}{% endcall %}).intValue(); } {%- endif %} {%- endmacro %} diff --git a/src/templates/wrapper.java b/src/templates/wrapper.java index e3ad334..b1051dc 100644 --- a/src/templates/wrapper.java +++ b/src/templates/wrapper.java @@ -38,10 +38,10 @@ import java.util.Map; import java.util.concurrent.CompletableFuture; -{%- call java::docstring_value(ci.namespace_docstring(), 0) %} +{%- call java::docstring_value(ci.namespace_docstring(), 0) %}{% endcall %} public class {{ self.namespace_class_name() }} { {%- for func in ci.function_definitions() %} - {% call java::func_decl("public static", "", func, 4) %} + {% call java::func_decl("public static", "", func, 4) %}{% endcall %} {%- endfor %} } {%- endif %} diff --git a/tests/scripts/TestProcMacro.java b/tests/scripts/TestProcMacro.java index 03029df..6cc33ff 100644 --- a/tests/scripts/TestProcMacro.java +++ b/tests/scripts/TestProcMacro.java @@ -31,5 +31,13 @@ public static void main(String[] args) { assert swt.concatStrings("foo", "bar").equals("test: foobar") : "StructWithTrait.concatStrings failed"; assert swt instanceof TraitInterface : "StructWithTrait should implement TraitInterface"; + // Test HashSet <-> java.util.Set + java.util.Set madeSet = ProcMacro.makeHashSet("solo"); + assert madeSet.equals(java.util.Set.of("solo")) : "makeHashSet should return {solo}"; + + java.util.Set sent = new java.util.LinkedHashSet<>(java.util.List.of("a", "b", "c")); + assert ProcMacro.returnHashSet(sent).equals(sent) : "HashSet roundtrip failed"; + + assert ProcMacro.returnHashSet(java.util.Set.of()).isEmpty() : "empty HashSet roundtrip failed"; } } diff --git a/tests/scripts/TestRename/TestRename.java b/tests/scripts/TestRename/TestRename.java index c1f4d6a..965af22 100644 --- a/tests/scripts/TestRename/TestRename.java +++ b/tests/scripts/TestRename/TestRename.java @@ -11,9 +11,9 @@ public static void main(String[] args) throws Exception { // These apply to ALL languages // - // Test renamed record + // Test renamed record with a renamed field RenamedRecord record = new RenamedRecord(42); - assert record.item() == 42 : "RenamedRecord.item should be 42"; + assert record.renamedField() == 42 : "RenamedRecord.renamedField should be 42"; // Test renamed enum with renamed variant (sealed interface) RenamedEnum enum1 = new RenamedEnum.RenamedVariant(); @@ -23,7 +23,13 @@ public static void main(String[] args) throws Exception { // Test renamed function (in namespace class) RenamedEnum result = UniffiFixtureRename.renamedFunction(record); assert result instanceof RenamedEnum.Record : "renamedFunction should return RenamedEnum.Record"; - assert ((RenamedEnum.Record) result).v1().item() == 42 : "Record item should be 42"; + assert ((RenamedEnum.Record) result).v1().renamedField() == 42 : "Record renamedField should be 42"; + + // Test renamed variant field + RenamedEnumWithFields withRenamedFields = + new RenamedEnumWithFields.RenamedVariantWithFields(7); + assert ((RenamedEnumWithFields.RenamedVariantWithFields) withRenamedFields) + .renamedVariantField() == 7 : "renamedVariantField should be 7"; // Test renamed object with renamed constructor and method RenamedObject obj = RenamedObject.renamedConstructor(123); diff --git a/tests/tests.rs b/tests/tests.rs index 334c874..ce92634 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -9,10 +9,22 @@ 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::{BindgenLoader, BindgenPaths, BindgenPathsLayer, GlobalConfig}; use uniffi_bindgen_java::{GenerateOptions, generate}; use uniffi_testing::UniFFITestHelper; +/// Points every crate at one merged `uniffi.toml`, so a fixture's config plus its test's +/// `uniffi-extras.toml` apply across all the namespaces the fixture pulls in. +struct ConfigOverrideLayer { + path: Utf8PathBuf, +} + +impl BindgenPathsLayer for ConfigOverrideLayer { + fn get_config_path(&self, _crate_name: &str) -> Option { + Some(self.path.clone()) + } +} + /// 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); @@ -59,10 +71,12 @@ fn run_test(fixture_name: &str, test_file: &str) -> Result<()> { // 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_layer(ConfigOverrideLayer { + path: config_path.clone(), + }); } paths.add_cargo_metadata_layer(false)?; - let loader = BindgenLoader::new(paths); + let loader = BindgenLoader::new(paths, GlobalConfig::default()); // generate the fixture bindings generate( @@ -163,7 +177,7 @@ fn run_test_with_library_override( let mut paths = BindgenPaths::default(); paths.add_cargo_metadata_layer(false)?; - let loader = BindgenLoader::new(paths); + let loader = BindgenLoader::new(paths, GlobalConfig::default()); generate( &loader, From a9aa9d59d27b903e697c0588649fe250995a2caf Mon Sep 17 00:00:00 2001 From: Murph Murphy Date: Tue, 11 Aug 2026 10:54:23 -0600 Subject: [PATCH 2/9] Pull zero-copy bytes through --- CHANGELOG.md | 11 +- Cargo.lock | 8 ++ Cargo.toml | 1 + README.md | 1 + benches/bindings/RunBenchmarks.java | 4 +- fixtures/zero-copy/Cargo.toml | 11 ++ fixtures/zero-copy/src/lib.rs | 34 +++++++ src/gen_java/mod.rs | 139 ++++++++++++++++++++++++-- src/templates/RustBufferTemplate.java | 40 +++++++- src/templates/macros.java | 5 +- tests/scripts/TestZeroCopy.java | 65 ++++++++++++ tests/tests.rs | 1 + 12 files changed, 297 insertions(+), 23 deletions(-) create mode 100644 fixtures/zero-copy/Cargo.toml create mode 100644 fixtures/zero-copy/src/lib.rs create mode 100644 tests/scripts/TestZeroCopy.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c1bafe..98b43af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,14 +1,13 @@ -## Unreleased +## 0.5.0 - updated to UniFFI 0.32.0 (and Askama 0.16). -- added support for `HashSet`, which UniFFI 0.32 exposes to proc-macros. Rust sets map to - `java.util.Set`, preserving insertion order on the way back from Rust. +- added support for `HashSet`, which UniFFI 0.32 exposes to proc-macros. Rust sets map to `java.util.Set`, preserving insertion order on the way back from Rust. +- added zero-copy `&[u8]` / `[ByRef] bytes` arguments. Rust borrows the caller's buffer for the duration of the call instead of copying it into a `RustBuffer`, which also removes the separate FFI round-trip that allocating that buffer required. Measured 3x (64B) to 20x (1MB) faster than the owned path. Synchronous foreign-to-Rust and argument position only. ### Breaking -- `--config` now expects a UniFFI [global config file](https://mozilla.github.io/uniffi-rs/next/bindings.html#global-configuration) - with `[defaults]`, `[crates.]` and/or `[crate-roots]` sections, rather than a flat - `uniffi.toml`-style override. Old-style files are ignored with a warning. +- `--config` now expects a UniFFI [global config file](https://mozilla.github.io/uniffi-rs/next/bindings.html#global-configuration) with `[defaults]`, `[crates.]` and/or `[crate-roots]` sections, rather than a flat `uniffi.toml`-style override. Old-style files are ignored with a warning. +- `&[u8]` / `[ByRef] bytes` arguments now take a **direct** `java.nio.ByteBuffer` rather than `byte[]`, matching Kotlin and Swift. Migrate with `ByteBuffer.allocateDirect(arr.length).put(arr).flip()`; a heap buffer throws `IllegalArgumentException`. Reuse the buffer across calls where you can - allocating a direct buffer per call is slower than reusing one, though still well ahead of the old owned path. Rust reads the buffer during the call, so it must not be mutated by another thread meanwhile. ## 0.4.2 diff --git a/Cargo.lock b/Cargo.lock index 3748f56..beed32d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1680,6 +1680,7 @@ dependencies = [ "uniffi-fixture-rename", "uniffi-fixture-time", "uniffi-fixture-trait-methods", + "uniffi-fixture-zero-copy", "uniffi_bindgen 0.32.0 (registry+https://github.com/rust-lang/crates.io-index)", "uniffi_meta 0.32.0 (registry+https://github.com/rust-lang/crates.io-index)", "uniffi_testing", @@ -1888,6 +1889,13 @@ dependencies = [ "uniffi", ] +[[package]] +name = "uniffi-fixture-zero-copy" +version = "0.1.0" +dependencies = [ + "uniffi", +] + [[package]] name = "uniffi_bindgen" version = "0.32.0" diff --git a/Cargo.toml b/Cargo.toml index 556c1ed..b21adda 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,6 +61,7 @@ uniffi-fixture-proc-macro = { git = "https://github.com/mozilla/uniffi-rs.git", uniffi-fixture-rename = { git = "https://github.com/mozilla/uniffi-rs.git", tag = "v0.32.0" } uniffi-fixture-time = { git = "https://github.com/mozilla/uniffi-rs.git", tag = "v0.32.0" } uniffi-fixture-trait-methods = { git = "https://github.com/mozilla/uniffi-rs.git", tag = "v0.32.0" } +uniffi-fixture-zero-copy = { path = "fixtures/zero-copy" } uniffi_testing = { git = "https://github.com/mozilla/uniffi-rs.git", tag = "v0.32.0" } [[bench]] diff --git a/README.md b/README.md index 38a90a1..11007be 100644 --- a/README.md +++ b/README.md @@ -234,6 +234,7 @@ scope). ## Notes +- a Rust `&[u8]` argument is borrowed by Rust for the duration of the call rather than copied, and takes a **direct** `java.nio.ByteBuffer` (as in Kotlin) instead of `byte[]`. Build one with `ByteBuffer.allocateDirect(arr.length).put(arr).flip()`; a heap buffer throws `IllegalArgumentException`. Reuse the buffer across calls where you can, and don't let another thread write to it while a call is in flight. `Vec` is unaffected and still maps to `byte[]`. - failures in CompletableFutures will cause them to `completeExceptionally`. The error that caused the failure can be checked with `e.getCause()`. When implementing an async Rust trait in Java, you'll need to `completeExceptionally` instead of throwing. See `TestFixtureFutures.java` for an example trait implementation with errors. - all primitives are signed in Java by default. Rust correctly interprets the a signed primitive value from Java as unsigned when told to. Callers of Uniffi functions need to be aware when making comparisons (`compareUnsigned`) or printing when a value is actually unsigned to code around footguns on this side. - this is an internal note for development but because Enum variants are not cases/hanging off their parent in Java, their named standalone, they can conflict with any/all `java.lang` types. We could do extensive checking and forced renaming around this, but instead we use fully qualified names for all `java.lang` types in all templates. Ensure that when you're making changes you're not dropping those qualified names or adding generated code without them. diff --git a/benches/bindings/RunBenchmarks.java b/benches/bindings/RunBenchmarks.java index 356387d..13db854 100644 --- a/benches/bindings/RunBenchmarks.java +++ b/benches/bindings/RunBenchmarks.java @@ -266,8 +266,8 @@ public long runTest(TestCase testCase, long count) { } } } - // Without this an unhandled case reports ~0ns rather than failing, which is how this - // runner silently drifted behind the fixture across a uniffi upgrade. + // An arrow switch statement isn't exhaustiveness-checked, so without this a TestCase + // added upstream would silently benchmark nothing and report ~0ns. default -> throw new IllegalStateException("unhandled TestCase: " + testCase); } return System.nanoTime() - start; diff --git a/fixtures/zero-copy/Cargo.toml b/fixtures/zero-copy/Cargo.toml new file mode 100644 index 0000000..d1de4e6 --- /dev/null +++ b/fixtures/zero-copy/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "uniffi-fixture-zero-copy" +version = "0.1.0" +edition = "2024" + +[lib] +crate-type = ["cdylib", "lib"] +name = "uniffi_fixture_zero_copy" + +[dependencies] +uniffi = { git = "https://github.com/mozilla/uniffi-rs.git", tag = "v0.32.0" } diff --git a/fixtures/zero-copy/src/lib.rs b/fixtures/zero-copy/src/lib.rs new file mode 100644 index 0000000..9d1bf1a --- /dev/null +++ b/fixtures/zero-copy/src/lib.rs @@ -0,0 +1,34 @@ +/* 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/. */ + +uniffi::setup_scaffolding!("zero_copy"); + +/// Borrowed bytes: crosses the FFI as `ForeignBytes` (pointer + length), no copy. +#[uniffi::export] +fn checksum_borrowed(data: &[u8]) -> u64 { + data.iter().map(|b| *b as u64).sum() +} + +/// Same work over owned bytes, which copies through a `RustBuffer`. The baseline to measure +/// `checksum_borrowed` against. +#[uniffi::export] +fn checksum_owned(data: Vec) -> u64 { + data.iter().map(|b| *b as u64).sum() +} + +/// Proves the borrow really is the foreign buffer rather than a copy: the first byte is +/// reported back, so a caller can mutate its buffer between calls and observe the change. +#[uniffi::export] +fn first_byte_borrowed(data: &[u8]) -> u8 { + data.first().copied().unwrap_or(0) +} + +#[uniffi::export] +fn len_borrowed(data: &[u8]) -> u32 { + data.len() as u32 +} + +// An `async fn` taking `&[u8]` does not compile: `ForeignBytes` holds a `*const u8`, so the +// generated future isn't `Send` and fails `rust_future_new`'s bound. Zero-copy is sync-only, +// and enforced by rustc rather than by us. diff --git a/src/gen_java/mod.rs b/src/gen_java/mod.rs index faff681..0a56c56 100644 --- a/src/gen_java/mod.rs +++ b/src/gen_java/mod.rs @@ -284,8 +284,46 @@ impl CustomTypeConfig { /// an explicitly sized thread keeps that independent of whatever stack the caller happens to have. const RENDER_STACK_SIZE: usize = 32 * 1024 * 1024; +/// `ForeignBytes` is a borrow that only survives one inbound call, so it works for arguments +/// travelling foreign -> Rust and nowhere else. UniFFI nevertheless puts it in the vtable for a +/// foreign-implemented method taking `&[u8]`, where it would arrive as a pointer the JVM never +/// owned; fail the build rather than emit bindings that read it. +fn reject_borrowed_bytes_in_callbacks(ci: &ComponentInterface) -> Result<()> { + let offenders = ci + .callback_interface_definitions() + .iter() + .flat_map(|cbi| cbi.methods().into_iter().map(|m| (cbi.name(), m))) + .chain( + ci.object_definitions() + .iter() + .filter(|obj| obj.has_callback_interface()) + .flat_map(|obj| obj.methods().into_iter().map(|m| (obj.name(), m))), + ) + .filter_map(|(owner, method)| { + let args = method + .arguments() + .iter() + .filter(|arg| arg.is_borrowed_bytes()) + .map(|arg| arg.name().to_string()) + .collect::>(); + (!args.is_empty()).then(|| format!("{}.{}: {}", owner, method.name(), args.join(", "))) + }) + .collect::>(); + + if offenders.is_empty() { + Ok(()) + } else { + anyhow::bail!( + "zero-copy `&[u8]` is only supported for arguments passed into Rust, but it appears on \ + foreign-implemented method(s): {}. Use `Vec` for these arguments.", + offenders.join("; ") + ) + } +} + // Generate Java bindings for the given ComponentInterface, as a string. pub fn generate_bindings(config: &Config, ci: &ComponentInterface) -> Result { + reject_borrowed_bytes_in_callbacks(ci)?; std::thread::scope(|scope| { std::thread::Builder::new() .stack_size(RENDER_STACK_SIZE) @@ -821,7 +859,7 @@ mod filters { use super::*; use uniffi_meta::AsType; - // Askama requires a Values parameter on every filter. We use `_v` to accept but ignore it. + // Askama passes a Values parameter to every filter, hence the unused `_v` throughout. #[askama::filter_fn] pub(super) fn ffi_type( @@ -1390,8 +1428,7 @@ mod filters { Ok(JavaCodeOracle.object_names(ci, obj)) } - // `#[askama::filter_fn]` turns each filter into a struct, so filters can't call each other - // directly; shared logic lives in a plain fn. + // `#[askama::filter_fn]` turns each filter into a struct, so filters can't call each other. fn inner_return_type( callable: &impl Callable, ci: &ComponentInterface, @@ -1505,13 +1542,51 @@ mod filters { ci: &ComponentInterface, config: &Config, ) -> Result { - // Check if the codetype has a primitive label available + Ok(field_type_label(as_ct, ci, config)) + } + + fn field_type_label( + as_ct: &impl AsCodeType, + ci: &ComponentInterface, + config: &Config, + ) -> String { let codetype = as_ct.as_codetype(); - if let Some(primitive) = codetype.type_label_primitive() { - return Ok(primitive); + codetype + .type_label_primitive() + .unwrap_or_else(|| codetype.type_label(ci, config)) + } + + /// Java type for an argument being passed *to* Rust. A zero-copy `&[u8]` takes a direct + /// `java.nio.ByteBuffer`, the only Java type with a stable native address. + #[askama::filter_fn] + pub fn lower_type_name_for_arg( + arg: &Argument, + _v: &dyn askama::Values, + ci: &ComponentInterface, + config: &Config, + ) -> Result { + if arg.is_borrowed_bytes() { + Ok("java.nio.ByteBuffer".to_string()) + } else { + Ok(field_type_label(&arg, ci, config)) + } + } + + #[askama::filter_fn] + pub fn lower_fn_for_arg( + arg: &Argument, + _v: &dyn askama::Values, + config: &Config, + ci: &ComponentInterface, + ) -> Result { + if arg.is_borrowed_bytes() { + Ok("FfiConverterByRefBytes.lower".to_string()) + } else { + Ok(format!( + "{}.lower", + arg.as_codetype().ffi_converter_instance(config, ci) + )) } - // Otherwise use the standard boxed type label - Ok(codetype.type_label(ci, config)) } /// Always returns the boxed type name, for use in generic contexts like CompletableFuture. @@ -2658,6 +2733,54 @@ mod tests { ); } + #[test] + fn borrowed_bytes_on_a_callback_method_is_rejected() { + // Without this the vtable carries a ForeignBytes that Java has no way to lift, and the + // generated bindings read a pointer they never owned. + let mut group = MetadataGroup { + namespace: NamespaceMetadata { + crate_name: "test".to_string(), + name: "test".to_string(), + }, + namespace_docstring: None, + items: Default::default(), + }; + group.add_item(Metadata::CallbackInterface(CallbackInterfaceMetadata { + module_path: "test".to_string(), + name: "Sink".to_string(), + docstring: None, + })); + group.add_item(Metadata::TraitMethod(TraitMethodMetadata { + orig_name: None, + module_path: "test".to_string(), + trait_name: "Sink".to_string(), + index: 0, + name: "write".to_string(), + is_async: false, + inputs: vec![FnParamMetadata { + name: "data".to_string(), + ty: Type::Bytes, + by_ref: true, + optional: false, + default: None, + }], + return_type: None, + throws: None, + takes_self_by_arc: false, + checksum: None, + docstring: None, + })); + + let mut ci = ComponentInterface::from_metadata(group).unwrap(); + ci.derive_ffi_funcs().unwrap(); + + let err = generate_bindings(&Config::default(), &ci) + .expect_err("borrowed bytes on a callback method should not generate"); + let msg = err.to_string(); + assert!(msg.contains("Sink.write"), "should name the method: {msg}"); + assert!(msg.contains("data"), "should name the argument: {msg}"); + } + #[test] fn callback_interface_helpers_use_class_style_names() { let mut group = MetadataGroup { diff --git a/src/templates/RustBufferTemplate.java b/src/templates/RustBufferTemplate.java index a4b01ce..0cfcc92 100644 --- a/src/templates/RustBufferTemplate.java +++ b/src/templates/RustBufferTemplate.java @@ -84,11 +84,8 @@ public static java.nio.ByteBuffer asWriteByteBuffer(java.lang.foreign.MemorySegm package {{ config.package_name() }}; -// This is a helper for safely passing byte references into the rust code. -// It's not actually used at the moment, because there aren't many things that you -// can take a direct pointer to in the JVM, and if we're going to copy something -// then we might as well copy it into a `RustBuffer`. But it's here for API -// completeness. +// Pointer + length for bytes owned by the JVM and borrowed by Rust for the duration of one call. +// Used for `&[u8]` / `[ByRef] bytes` arguments; see FfiConverterByRefBytes. public final class ForeignBytes { public static final java.lang.foreign.StructLayout LAYOUT = java.lang.foreign.MemoryLayout.structLayout( java.lang.foreign.ValueLayout.JAVA_INT.withName("len"), @@ -117,3 +114,36 @@ public static void setData(java.lang.foreign.MemorySegment seg, java.lang.foreig seg.set(java.lang.foreign.ValueLayout.ADDRESS_UNALIGNED, OFFSET_DATA, value); } } + +package {{ config.package_name() }}; + +// Lowers `&[u8]` / `[ByRef] bytes` arguments, which Rust borrows for the duration of the call +// rather than taking ownership of a RustBuffer. +// +// Only lowering exists: zero-copy bytes flow foreign -> Rust, in argument position only. There is +// no lift/read/write because a borrow can't outlive the call that created it, which is also why +// the buffer must not be mutated by another thread while a call is in flight. +final class FfiConverterByRefBytes { + // The struct is read by Rust during the call, so each one needs its own slice; see + // UniffiSlabAllocator. + private static final UniffiSlabAllocator ALLOCATOR = new UniffiSlabAllocator(ForeignBytes.LAYOUT, 1024); + + private FfiConverterByRefBytes() {} + + static java.lang.foreign.MemorySegment lower(java.nio.ByteBuffer value) { + if (!value.isDirect()) { + throw new java.lang.IllegalArgumentException( + "UniFFI zero-copy &[u8] requires a direct ByteBuffer, so Rust can borrow it without " + + "a copy. Convert with: ByteBuffer.allocateDirect(arr.length).put(arr).flip()"); + } + java.lang.foreign.MemorySegment fb = ALLOCATOR.allocate(ForeignBytes.LAYOUT); + int remaining = value.remaining(); + ForeignBytes.setLen(fb, remaining); + // A zero-length direct buffer has no meaningful address; Rust reads (null, 0) as `&[]`. + // Otherwise ofBuffer honours position/limit, so Rust sees exactly the remaining slice. + ForeignBytes.setData(fb, remaining == 0 + ? java.lang.foreign.MemorySegment.NULL + : java.lang.foreign.MemorySegment.ofBuffer(value)); + return fb; + } +} diff --git a/src/templates/macros.java b/src/templates/macros.java index a2506c8..72a0aa3 100644 --- a/src/templates/macros.java +++ b/src/templates/macros.java @@ -160,7 +160,7 @@ {%- if arg|has_primitive_ffi_type -%} {{- arg.name()|var_name }} {%- else -%} - {{- arg|lower_fn(config, ci) }}({{ arg.name()|var_name }}) + {{- arg|lower_fn_for_arg(config, ci) }}({{ arg.name()|var_name }}) {%- endif -%} {%- if !loop.last %}, {% endif -%} {%- endfor %} @@ -182,9 +182,10 @@ // Note the var_name and type_name filters. -#} +{#- Declaration side of a call into Rust, so zero-copy `&[u8]` shows as a ByteBuffer. -#} {% macro arg_list(func, is_decl) %} {%- for arg in func.arguments() -%} - {{ arg|type_name_for_field(ci, config) }} {{ arg.name()|var_name }} + {{ arg|lower_type_name_for_arg(ci, config) }} {{ arg.name()|var_name }} {%- if !loop.last %}, {% endif -%} {%- endfor %} {%- endmacro %} diff --git a/tests/scripts/TestZeroCopy.java b/tests/scripts/TestZeroCopy.java new file mode 100644 index 0000000..480b29e --- /dev/null +++ b/tests/scripts/TestZeroCopy.java @@ -0,0 +1,65 @@ +/* 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/. */ + +import java.nio.ByteBuffer; +import uniffi.zero_copy.*; + +public class TestZeroCopy { + static ByteBuffer direct(byte[] bytes) { + ByteBuffer buf = ByteBuffer.allocateDirect(bytes.length); + buf.put(bytes).flip(); + return buf; + } + + public static void main(String[] args) { + byte[] bytes = new byte[]{1, 2, 3, 4, 5}; + + assert ZeroCopy.checksumBorrowed(direct(bytes)) == 15 : "borrowed checksum should be 15"; + assert ZeroCopy.lenBorrowed(direct(bytes)) == 5 : "borrowed len should be 5"; + assert ZeroCopy.firstByteBorrowed(direct(bytes)) == 1 : "borrowed first byte should be 1"; + + assert ZeroCopy.checksumOwned(bytes) == ZeroCopy.checksumBorrowed(direct(bytes)) + : "owned and borrowed checksums should agree"; + + ByteBuffer reused = direct(bytes); + assert ZeroCopy.firstByteBorrowed(reused) == 1 : "expected original first byte"; + reused.put(0, (byte) 99); + assert ZeroCopy.firstByteBorrowed(reused) == 99 : "mutation should be visible to Rust"; + reused.put(0, (byte) 1); + + ByteBuffer sliced = direct(bytes); + sliced.position(2); + assert ZeroCopy.lenBorrowed(sliced) == 3 : "should borrow only the remaining bytes"; + sliced.position(2); + assert ZeroCopy.checksumBorrowed(sliced) == 12 : "3+4+5 = 12"; + sliced.position(2); + assert ZeroCopy.firstByteBorrowed(sliced) == 3 : "slice should start at index 2"; + + // Empty is (null, 0) on the Rust side, not a crash. + assert ZeroCopy.lenBorrowed(ByteBuffer.allocateDirect(0)) == 0 : "empty buffer has len 0"; + assert ZeroCopy.checksumBorrowed(ByteBuffer.allocateDirect(0)) == 0 : "empty buffer sums to 0"; + + // A heap buffer has no stable native address, so it must be rejected rather than + // silently lowered as a null pointer. + boolean threw = false; + try { + ZeroCopy.checksumBorrowed(ByteBuffer.wrap(bytes)); + } catch (IllegalArgumentException e) { + threw = true; + assert e.getMessage().contains("direct ByteBuffer") : "message should say what to do"; + } + assert threw : "heap ByteBuffer should be rejected"; + + // Larger payload, exercising the slab across many lowerings. + byte[] big = new byte[64 * 1024]; + for (int i = 0; i < big.length; i++) big[i] = (byte) (i & 0x7F); + long want = 0; + for (byte b : big) want += (b & 0xFF); + ByteBuffer bigBuf = direct(big); + for (int i = 0; i < 10_000; i++) { + bigBuf.rewind(); + assert ZeroCopy.checksumBorrowed(bigBuf) == want : "large borrowed checksum mismatch"; + } + } +} diff --git a/tests/tests.rs b/tests/tests.rs index ce92634..1eff5a6 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -390,6 +390,7 @@ fixture_tests! { (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_zero_copy, "uniffi-fixture-zero-copy", "scripts/TestZeroCopy.java"), } #[test] From 7bb7cb843de090dee9d9dc8da16d6de3cd1697c1 Mon Sep 17 00:00:00 2001 From: Murph Murphy Date: Tue, 11 Aug 2026 12:36:11 -0600 Subject: [PATCH 3/9] Pull in changes from the other attempted branch --- README.md | 4 +- benches/README.md | 6 +- fixtures/zero-copy/src/lib.rs | 22 ++++--- src/gen_java/mod.rs | 82 +++++++++++++++++++++++- src/templates/CallbackInterfaceImpl.java | 2 +- src/templates/ExternalTypeTemplate.java | 2 +- src/templates/Helpers.java | 4 +- src/templates/ObjectCleanerHelper.java | 4 +- src/templates/RustBufferTemplate.java | 42 ++++++++++-- src/templates/macros.java | 14 +++- tests/scripts/TestFixtureCoverall.java | 6 +- tests/scripts/TestZeroCopy.java | 7 +- 12 files changed, 161 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 11007be..e4a9de8 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ Arguments: Options: -o, --out-dir Directory in which to write generated files. Default is same folder as .udl file -n, --no-format Do not try to format the generated bindings - -c, --config Path to optional uniffi config file. This config is merged with the `uniffi.toml` config present in each crate, with its values taking precedence + -c, --config Path to an optional uniffi global config file, with `[defaults]`, `[crates.]` and/or `[crate-roots]` sections. Merged with each crate's `uniffi.toml` --crate When a library is passed as SOURCE, only generate bindings for this crate. When a UDL file is passed, use this as the crate name instead of attempting to locate and parse Cargo.toml --metadata-no-deps Whether we should exclude dependencies when running "cargo metadata". This will mean external types may not be resolved if they are implemented in crates outside of this workspace. This can be used in environments when all types are in the namespace and fetching all sub-dependencies causes obscure platform specific problems -h, --help Print help @@ -217,7 +217,7 @@ dependencies { ``` -There is no runtime dependency — the JVM ignores annotation classes that are not present at +There is no runtime dependency - the JVM ignores annotation classes that are not present at runtime. ### Kotlin Interop diff --git a/benches/README.md b/benches/README.md index 0edd359..a265b6b 100644 --- a/benches/README.md +++ b/benches/README.md @@ -4,7 +4,7 @@ Criterion-based benchmarks measuring FFI call overhead for the generated Java bi ## Prerequisites -Rust toolchain and JDK 21+ — all provided by `nix develop`. +Rust toolchain and JDK 21+ - all provided by `nix develop`. ## Running @@ -28,8 +28,8 @@ Criterion runs inside the Rust fixture library. The Java side implements `TestCa ## Benchmark Groups -- **function-calls** — Java calling Rust functions across 12 type categories -- **callbacks** — Rust calling Java callback methods across the same 12 categories +- **function-calls** - Java calling Rust functions across 12 type categories +- **callbacks** - Rust calling Java callback methods across the same 12 categories ## Results diff --git a/fixtures/zero-copy/src/lib.rs b/fixtures/zero-copy/src/lib.rs index 9d1bf1a..fa54aaf 100644 --- a/fixtures/zero-copy/src/lib.rs +++ b/fixtures/zero-copy/src/lib.rs @@ -2,23 +2,27 @@ * 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/. */ +//! Borrowed `&[u8]` arguments, which cross the FFI as `ForeignBytes` (pointer + length) instead of +//! being copied through a `RustBuffer`. +//! +//! There is deliberately no async function here: one taking `&[u8]` does not compile, because +//! `ForeignBytes` holds a `*const u8` so the generated future isn't `Send` and fails +//! `rust_future_new`'s bound. + uniffi::setup_scaffolding!("zero_copy"); -/// Borrowed bytes: crosses the FFI as `ForeignBytes` (pointer + length), no copy. #[uniffi::export] fn checksum_borrowed(data: &[u8]) -> u64 { data.iter().map(|b| *b as u64).sum() } -/// Same work over owned bytes, which copies through a `RustBuffer`. The baseline to measure -/// `checksum_borrowed` against. +/// Baseline for `checksum_borrowed`. #[uniffi::export] fn checksum_owned(data: Vec) -> u64 { data.iter().map(|b| *b as u64).sum() } -/// Proves the borrow really is the foreign buffer rather than a copy: the first byte is -/// reported back, so a caller can mutate its buffer between calls and observe the change. +/// Lets a caller mutate its buffer between calls and see the change, which a copy would hide. #[uniffi::export] fn first_byte_borrowed(data: &[u8]) -> u8 { data.first().copied().unwrap_or(0) @@ -29,6 +33,8 @@ fn len_borrowed(data: &[u8]) -> u32 { data.len() as u32 } -// An `async fn` taking `&[u8]` does not compile: `ForeignBytes` holds a `*const u8`, so the -// generated future isn't `Send` and fails `rust_future_new`'s bound. Zero-copy is sync-only, -// and enforced by rustc rather than by us. +/// Argument order has to survive the two `bytes` taking different FFI paths. +#[uniffi::export] +fn concat_borrowed_and_owned(borrowed: &[u8], owned: Vec) -> Vec { + [borrowed, &owned].concat() +} diff --git a/src/gen_java/mod.rs b/src/gen_java/mod.rs index 0a56c56..88d00f2 100644 --- a/src/gen_java/mod.rs +++ b/src/gen_java/mod.rs @@ -1204,7 +1204,7 @@ mod filters { if size > 0 { offset += size; } else { - // Unknown size (e.g., user-defined FfiStruct) — can't compute further padding + // Unknown size (e.g., user-defined FfiStruct) - can't compute further padding // but alignment was already handled offset = 0; // reset; further padding may be wrong but this is rare } @@ -1572,6 +1572,37 @@ mod filters { } } + #[askama::filter_fn] + pub fn has_borrowed_bytes_args( + callable: impl Callable, + _v: &dyn askama::Values, + ) -> Result { + Ok(callable.arguments().iter().any(|a| a.is_borrowed_bytes())) + } + + /// Rust reads the buffer during the call while nothing in the generated Java touches it again, + /// so without a fence the JIT is free to treat it as dead and let the buffer be collected - + /// freeing the memory Rust is reading. The lambda that lowers the argument happens to keep it + /// reachable today; the fence stops that being load-bearing. + #[askama::filter_fn] + pub fn reachability_fences( + callable: impl Callable, + _v: &dyn askama::Values, + ) -> Result { + Ok(callable + .arguments() + .iter() + .filter(|a| a.is_borrowed_bytes()) + .map(|a| { + format!( + "java.lang.ref.Reference.reachabilityFence({});", + JavaCodeOracle.var_name(a.name()) + ) + }) + .collect::>() + .join("\n ")) + } + #[askama::filter_fn] pub fn lower_fn_for_arg( arg: &Argument, @@ -1580,7 +1611,7 @@ mod filters { ci: &ComponentInterface, ) -> Result { if arg.is_borrowed_bytes() { - Ok("FfiConverterByRefBytes.lower".to_string()) + Ok("FfiConverterByRefBytes.INSTANCE.lower".to_string()) } else { Ok(format!( "{}.lower", @@ -1817,6 +1848,53 @@ mod tests { ); } + #[test] + fn box_renders_as_its_inner_type() { + // `Box` exists only in scaffolding, so leaking it into a signature would name a Java + // type that was never generated. + let mut group = MetadataGroup { + namespace: NamespaceMetadata { + crate_name: "test".to_string(), + name: "test".to_string(), + }, + namespace_docstring: None, + items: Default::default(), + }; + group.add_item(Metadata::Func(FnMetadata { + orig_name: None, + module_path: "test".to_string(), + name: "unwrap_box".to_string(), + is_async: false, + inputs: vec![FnParamMetadata { + name: "data".to_string(), + ty: Type::Box { + inner_type: Box::new(Type::String), + }, + by_ref: false, + optional: false, + default: None, + }], + return_type: Some(Type::String), + throws: None, + checksum: None, + docstring: None, + })); + + let mut ci = ComponentInterface::from_metadata(group).unwrap(); + ci.derive_ffi_funcs().unwrap(); + let bindings = generate_bindings(&Config::default(), &ci).unwrap(); + + assert!( + bindings.contains("public static java.lang.String unwrapBox(java.lang.String data)"), + "expected Box to render as java.lang.String:\n{}", + bindings + .lines() + .filter(|line| line.contains("unwrapBox")) + .collect::>() + .join("\n") + ); + } + #[test] fn generates_int32_primitive_array() { let group = create_primitive_array_test_group(); diff --git a/src/templates/CallbackInterfaceImpl.java b/src/templates/CallbackInterfaceImpl.java index 66c8bd1..0a263f2 100644 --- a/src/templates/CallbackInterfaceImpl.java +++ b/src/templates/CallbackInterfaceImpl.java @@ -10,7 +10,7 @@ public class {{ trait_impl }} { java.lang.foreign.MemorySegment vtable; {{ trait_impl }}() { - // Use Arena.global() for vtable and upcall stubs — they live for the program lifetime. + // Use Arena.global() for vtable and upcall stubs - they live for the program lifetime. // Arena.ofAuto() stubs can be GC'd since storing an address in a struct doesn't // prevent the Arena from being collected. vtable = java.lang.foreign.Arena.global().allocate({{ vtable|ffi_struct_type_name }}.LAYOUT); diff --git a/src/templates/ExternalTypeTemplate.java b/src/templates/ExternalTypeTemplate.java index bfa0a69..0149d16 100644 --- a/src/templates/ExternalTypeTemplate.java +++ b/src/templates/ExternalTypeTemplate.java @@ -8,7 +8,7 @@ public class {{ class_name }}ExternalErrorHandler implements UniffiRustCallStatusErrorHandler<{{ external_package_name }}.{{ class_name }}> { @Override public {{ external_package_name }}.{{ class_name }} lift(java.lang.foreign.MemorySegment errorBuf) { - // In FFM, RustBuffer is already a java.lang.foreign.MemorySegment — pass directly to external package + // In FFM, RustBuffer is already a java.lang.foreign.MemorySegment - pass directly to external package return new {{ external_package_name }}.{{ class_name }}ErrorHandler().lift(errorBuf); } } diff --git a/src/templates/Helpers.java b/src/templates/Helpers.java index 3dea923..e7ea060 100644 --- a/src/templates/Helpers.java +++ b/src/templates/Helpers.java @@ -97,14 +97,14 @@ public interface UniffiRustCallStatusErrorHandler // adds up fast (e.g. 100k calls × 24-32 bytes = 2.4-3.2 MB never reclaimed). // - Arena.ofAuto() per call: correct but creates a new Arena + PhantomReference // per call, adding ~50-100ns of GC pressure to every call. -// - Thread-local reusable segment: zero overhead but causes SIGABRT — the FFM +// - Thread-local reusable segment: zero overhead but causes SIGABRT - the FFM // runtime retains internal references to allocator-provided segments, so reusing // the same segment across calls corrupts FFM's internal state. // // This slab approach: allocate a batch of slots from one Arena.ofAuto(), then hand // out slices. Each call gets a unique slice (avoiding the FFM reuse crash). When the // slab is exhausted, a new one is allocated and the old one becomes GC-eligible once -// all its slices are consumed (which is immediate — callers read struct fields before +// all its slices are consumed (which is immediate - callers read struct fields before // the next call). Amortized cost: one Arena + one native malloc per `slots` calls. class UniffiSlabAllocator implements java.lang.foreign.SegmentAllocator { private final long slabBytes; diff --git a/src/templates/ObjectCleanerHelper.java b/src/templates/ObjectCleanerHelper.java index 041f565..dcf754a 100644 --- a/src/templates/ObjectCleanerHelper.java +++ b/src/templates/ObjectCleanerHelper.java @@ -30,7 +30,7 @@ public static UniffiCleaner create() { // calling thread. queue.poll() is ~nanoseconds when empty, so the overhead is // negligible, but under sustained allocation pressure it provides continuous // backpressure that prevents the cleanup backlog from growing unboundedly. -// 4. Explicit close()/clean() is idempotent — manual clean and GC-triggered clean +// 4. Explicit close()/clean() is idempotent - manual clean and GC-triggered clean // race via a volatile CAS, so the action runs at most once. // 5. clean() is idempotent via VarHandle CAS. It synchronizes on the list sentinel // to unlink itself, preventing dead entries from accumulating. @@ -101,7 +101,7 @@ private static class CleanableRef extends java.lang.ref.PhantomReference q, java.lang.Runnable action) { super(referent, q); this.action = action; diff --git a/src/templates/RustBufferTemplate.java b/src/templates/RustBufferTemplate.java index 0cfcc92..afa2705 100644 --- a/src/templates/RustBufferTemplate.java +++ b/src/templates/RustBufferTemplate.java @@ -120,17 +120,21 @@ public static void setData(java.lang.foreign.MemorySegment seg, java.lang.foreig // Lowers `&[u8]` / `[ByRef] bytes` arguments, which Rust borrows for the duration of the call // rather than taking ownership of a RustBuffer. // -// Only lowering exists: zero-copy bytes flow foreign -> Rust, in argument position only. There is -// no lift/read/write because a borrow can't outlive the call that created it, which is also why -// the buffer must not be mutated by another thread while a call is in flight. -final class FfiConverterByRefBytes { +// Only `lower` is reachable: zero-copy bytes flow foreign -> Rust in argument position only, so a +// borrow can never be lifted or serialized. `FfiConverter` is implemented anyway so the compiler +// enforces the full set. +// +// The buffer must not be mutated by another thread while a call is in flight, since Rust is reading +// it directly. +public enum FfiConverterByRefBytes implements FfiConverter { + INSTANCE; + // The struct is read by Rust during the call, so each one needs its own slice; see // UniffiSlabAllocator. private static final UniffiSlabAllocator ALLOCATOR = new UniffiSlabAllocator(ForeignBytes.LAYOUT, 1024); - private FfiConverterByRefBytes() {} - - static java.lang.foreign.MemorySegment lower(java.nio.ByteBuffer value) { + @Override + public java.lang.foreign.MemorySegment lower(java.nio.ByteBuffer value) { if (!value.isDirect()) { throw new java.lang.IllegalArgumentException( "UniFFI zero-copy &[u8] requires a direct ByteBuffer, so Rust can borrow it without " @@ -146,4 +150,28 @@ static java.lang.foreign.MemorySegment lower(java.nio.ByteBuffer value) { : java.lang.foreign.MemorySegment.ofBuffer(value)); return fb; } + + @Override + public java.nio.ByteBuffer lift(java.lang.foreign.MemorySegment value) { + throw new java.lang.UnsupportedOperationException( + "ByRef bytes cannot be lifted: zero-copy &[u8] only flows foreign to Rust"); + } + + @Override + public java.nio.ByteBuffer read(java.nio.ByteBuffer buf) { + throw new java.lang.UnsupportedOperationException( + "ByRef bytes cannot be read from a buffer: zero-copy &[u8] is only supported in argument position"); + } + + @Override + public void write(java.nio.ByteBuffer value, java.nio.ByteBuffer buf) { + throw new java.lang.UnsupportedOperationException( + "ByRef bytes cannot be written to a buffer: zero-copy &[u8] is only supported in argument position"); + } + + @Override + public long allocationSize(java.nio.ByteBuffer value) { + throw new java.lang.UnsupportedOperationException( + "ByRef bytes have no RustBuffer allocation size: zero-copy &[u8] is only supported in argument position"); + } } diff --git a/src/templates/macros.java b/src/templates/macros.java index 72a0aa3..0efec97 100644 --- a/src/templates/macros.java +++ b/src/templates/macros.java @@ -40,6 +40,11 @@ {%- else %} UniffiHelpers.uniffiRustCall{% match func.return_type() %}{%- when Some(return_type) %}{{ return_type|primitive_call_suffix }}{% when None %}{% endmatch %}( {%- endmatch %} (_allocator, _status) -> { + {#- Only the wrapper is conditional: askama inlines a `call` at each site and emits both `if` + branches, so duplicating the invocation here doubles it in every generated call site. -#} + {%- if func|has_borrowed_bytes_args %} + try { + {%- endif %} {% if func.return_type().is_some() %}return {% endif %}UniffiLib.{{ func.ffi_func().name() }}( {%- match func.return_type() %} {%- when Some(return_type) %} @@ -54,6 +59,11 @@ {%- endmatch %} {% if func.arguments().len() != 0 %}{% call arg_list_lowered(func) %}{% endcall -%}, {% endif -%} _status); + {%- if func|has_borrowed_bytes_args %} + } finally { + {{ func|reachability_fences }} + } + {%- endif %} }) {%- endmacro -%} @@ -64,14 +74,14 @@ {% endif %} {%- if callable.is_async() %} {#- Async methods use CompletableFuture which requires boxed types -#} - {#- No-executor overload — defaults to ForkJoinPool.commonPool(), delegates to Executor version -#} + {#- No-executor overload - defaults to ForkJoinPool.commonPool(), delegates to Executor version -#} {{ func_decl }} java.util.concurrent.CompletableFuture<{% match callable.return_type() -%}{%- when Some with (return_type) -%}{{ return_type|boxed_type_name(ci, config) }}{%- when None %}java.lang.Void{%- endmatch %}> {{ callable.name()|fn_name }}( {%- call arg_list(callable, !callable.self_type().is_some()) %}{% endcall -%} ){ return {{ callable.name()|fn_name }}({% call arg_name_list(callable) %}{% endcall %}{% if !callable.arguments().is_empty() %}, {% endif %}java.util.concurrent.ForkJoinPool.commonPool()); } - {#- With-executor overload — does the actual async work -#} + {#- With-executor overload - does the actual async work -#} {{ func_decl }} java.util.concurrent.CompletableFuture<{% match callable.return_type() -%}{%- when Some with (return_type) -%}{{ return_type|boxed_type_name(ci, config) }}{%- when None %}java.lang.Void{%- endmatch %}> {{ callable.name()|fn_name }}( {%- call arg_list(callable, !callable.self_type().is_some()) %}{% endcall -%}{% if !callable.arguments().is_empty() %}, {% endif %}java.util.concurrent.Executor uniffiExecutor ){ diff --git a/tests/scripts/TestFixtureCoverall.java b/tests/scripts/TestFixtureCoverall.java index 13fd6b5..5473fcf 100644 --- a/tests/scripts/TestFixtureCoverall.java +++ b/tests/scripts/TestFixtureCoverall.java @@ -553,7 +553,7 @@ public List getRepairs() { try (Coveralls coveralls = new Coveralls("test_reentrant_errors")) { // Interleaved success/error/panic/complex-error to verify the thread-local // RustCallStatus isn't corrupted across different outcome types. - // Only a few iterations — the bug is deterministic and panics spam stderr. + // Only a few iterations - the bug is deterministic and panics spam stderr. for (int i = 0; i < 5; i++) { assert coveralls.maybeThrow(false); try { @@ -647,7 +647,7 @@ public List getRepairs() { // Regression test: struct return allocator must not permanently leak native memory. // Each struct-returning FFI call (e.g., String/record/list returns) allocates a 24-byte - // RustBuffer metadata segment. With Arena.global() this leaked permanently — 100k calls + // RustBuffer metadata segment. With Arena.global() this leaked permanently - 100k calls // would leak ~2.4 MB that GC could never reclaim. // // This test runs two 100k-call batches with GC between them and measures growth. @@ -786,7 +786,7 @@ public List getRepairs() { try { coveralls.getName(); } catch (IllegalStateException e) { - // Expected when close() beats us — object already destroyed + // Expected when close() beats us - object already destroyed } })); } diff --git a/tests/scripts/TestZeroCopy.java b/tests/scripts/TestZeroCopy.java index 480b29e..d204478 100644 --- a/tests/scripts/TestZeroCopy.java +++ b/tests/scripts/TestZeroCopy.java @@ -36,7 +36,12 @@ public static void main(String[] args) { sliced.position(2); assert ZeroCopy.firstByteBorrowed(sliced) == 3 : "slice should start at index 2"; - // Empty is (null, 0) on the Rust side, not a crash. + byte[] tail = new byte[]{6, 7}; + assert java.util.Arrays.equals( + ZeroCopy.concatBorrowedAndOwned(direct(bytes), tail), + new byte[]{1, 2, 3, 4, 5, 6, 7}) : "borrowed and owned args should keep their order"; + + // Empty lowers to (null, 0), which Rust reads as an empty slice rather than crashing. assert ZeroCopy.lenBorrowed(ByteBuffer.allocateDirect(0)) == 0 : "empty buffer has len 0"; assert ZeroCopy.checksumBorrowed(ByteBuffer.allocateDirect(0)) == 0 : "empty buffer sums to 0"; From db22eb15d6ee248a2a3746f285658540c8a57d99 Mon Sep 17 00:00:00 2001 From: Colt Frederickson Date: Tue, 11 Aug 2026 15:24:05 -0600 Subject: [PATCH 4/9] Add failing test about Set type qualification --- src/gen_java/mod.rs | 105 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/src/gen_java/mod.rs b/src/gen_java/mod.rs index 88d00f2..542abb2 100644 --- a/src/gen_java/mod.rs +++ b/src/gen_java/mod.rs @@ -1895,6 +1895,111 @@ mod tests { ); } + #[test] + fn set_field_on_an_enum_variant_is_package_qualified() { + // A variant record shadows a top-level type of the same name, so variant field types are + // package-qualified. `Vec` is the control: it already recurses, `HashSet` does not. + let mut group = test_group(); + group.add_item(Metadata::Record(RecordMetadata { + orig_name: None, + module_path: "test".to_string(), + name: "Point".to_string(), + remote: false, + fields: vec![FieldMetadata { + orig_name: None, + name: "x".to_string(), + ty: Type::Int32, + default: None, + docstring: None, + }], + docstring: None, + })); + group.add_item(Metadata::Enum(EnumMetadata { + orig_name: None, + module_path: "test".to_string(), + name: "Shape".to_string(), + shape: EnumShape::Enum, + remote: false, + variants: vec![ + VariantMetadata { + orig_name: None, + name: "Point".to_string(), + discr: None, + fields: vec![], + docstring: None, + }, + VariantMetadata { + orig_name: None, + name: "Group".to_string(), + discr: None, + fields: vec![ + FieldMetadata { + orig_name: None, + name: "members".to_string(), + ty: Type::Set { + inner_type: Box::new(Type::Record { + module_path: "test".to_string(), + name: "Point".to_string(), + }), + }, + default: None, + docstring: None, + }, + FieldMetadata { + orig_name: None, + name: "ordered".to_string(), + ty: Type::Sequence { + inner_type: Box::new(Type::Record { + module_path: "test".to_string(), + name: "Point".to_string(), + }), + }, + default: None, + docstring: None, + }, + ], + docstring: None, + }, + ], + discr_type: None, + non_exhaustive: false, + docstring: None, + })); + group.add_item(Metadata::Func(FnMetadata { + orig_name: None, + module_path: "test".to_string(), + name: "get_shape".to_string(), + is_async: false, + inputs: vec![], + return_type: Some(Type::Enum { + module_path: "test".to_string(), + name: "Shape".to_string(), + }), + throws: None, + checksum: None, + docstring: None, + })); + + let mut ci = ComponentInterface::from_metadata(group).unwrap(); + ci.derive_ffi_funcs().unwrap(); + let bindings = generate_bindings(&Config::default(), &ci).unwrap(); + + let variant_decl = bindings + .lines() + .find(|line| line.contains("record Group(")) + .unwrap_or_else(|| panic!("no Group variant in:\n{bindings}")); + + assert!( + variant_decl.contains("java.util.List"), + "Vec should be qualified, got: {variant_decl}" + ); + assert!( + variant_decl.contains("java.util.Set"), + "HashSet should be qualified too, but the nested `record Point` shadows the \ + top-level one, got: {variant_decl}" + ); + } + #[test] fn generates_int32_primitive_array() { let group = create_primitive_array_test_group(); From b97da2253172fd8149c9a8b18516745309a34134 Mon Sep 17 00:00:00 2001 From: Murph Murphy Date: Tue, 11 Aug 2026 15:43:49 -0600 Subject: [PATCH 5/9] hoist shared runtime blocks, drop include_once --- src/gen_java/mod.rs | 22 +--------------------- src/templates/CallbackInterfaceImpl.java | 2 -- src/templates/ObjectTemplate.java | 4 ---- src/templates/Types.java | 10 ++++++++++ 4 files changed, 11 insertions(+), 27 deletions(-) diff --git a/src/gen_java/mod.rs b/src/gen_java/mod.rs index 542abb2..03d70fb 100644 --- a/src/gen_java/mod.rs +++ b/src/gen_java/mod.rs @@ -6,7 +6,6 @@ use once_cell::sync::Lazy; use serde::{Deserialize, Serialize}; use std::{ borrow::Borrow, - cell::RefCell, collections::{HashMap, HashSet}, }; use uniffi_bindgen::{interface::*, to_askama_error}; @@ -402,39 +401,20 @@ impl<'a> JavaWrapper<'a> { } /// Renders Java helper code for all types -/// -/// This template is a bit different than others in that it stores internal state from the render -/// process. Make sure to only call `render()` once. #[derive(Template)] #[template(syntax = "java", escape = "none", path = "Types.java")] pub struct TypeRenderer<'a> { config: &'a Config, ci: &'a ComponentInterface, - // Track included modules for the `include_once()` macro - include_once_names: RefCell>, } impl<'a> TypeRenderer<'a> { fn new(config: &'a Config, ci: &'a ComponentInterface) -> Self { - Self { - config, - ci, - include_once_names: RefCell::new(HashSet::new()), - } + Self { config, ci } } // The following methods are used by the `Types.java` macros. - // Helper for the including a template, but only once. - // - // The first time this is called with a name it will return true, indicating that we should - // include the template. Subsequent calls will return false. - fn include_once_check(&self, name: &str) -> bool { - self.include_once_names - .borrow_mut() - .insert(name.to_string()) - } - // Get the package name for an external type (used by ExternalTypeTemplate.java) fn external_type_package_name(&self, module_path: &str, namespace: &str) -> String { self.config diff --git a/src/templates/CallbackInterfaceImpl.java b/src/templates/CallbackInterfaceImpl.java index 0a263f2..69da779 100644 --- a/src/templates/CallbackInterfaceImpl.java +++ b/src/templates/CallbackInterfaceImpl.java @@ -1,5 +1,3 @@ -{% if self.include_once_check("CallbackInterfaceRuntime.java") %}{% include "CallbackInterfaceRuntime.java" %}{% endif %} - package {{ config.package_name() }}; {%- let trait_impl=format!("UniffiCallbackInterface{}", name) %} diff --git a/src/templates/ObjectTemplate.java b/src/templates/ObjectTemplate.java index 28ebd28..30b2308 100644 --- a/src/templates/ObjectTemplate.java +++ b/src/templates/ObjectTemplate.java @@ -95,10 +95,6 @@ // [1] https://stackoverflow.com/questions/24376768/can-java-finalize-an-object-when-it-is-still-in-scope/24380219 // -{%- if self.include_once_check("interface-support") %} - {%- include "ObjectCleanerHelper.java" %} -{%- endif %} - {%- let obj = ci.get_object_definition(name).unwrap() %} {%- let (interface_name, impl_class_name) = obj|object_names(ci) %} {%- let methods = obj.methods() %} diff --git a/src/templates/Types.java b/src/templates/Types.java index b97e542..1db21dd 100644 --- a/src/templates/Types.java +++ b/src/templates/Types.java @@ -79,6 +79,16 @@ private UniffiWithHandle() {} public static final UniffiWithHandle INSTANCE = new UniffiWithHandle(); } +{#- Runtime support shared by every callback interface / object, so it is emitted once here + rather than from inside the per-type templates. -#} +{%- if ci.has_callback_definitions() %} +{% include "CallbackInterfaceRuntime.java" %} +{%- endif %} + +{%- if ci.has_object_definitions() %} +{% include "ObjectCleanerHelper.java" %} +{%- endif %} + {%- for type_ in ci.iter_local_types() %} {%- let type_name = type_|type_name(ci, config) %} {%- let ffi_converter_name = type_|ffi_converter_name %} From 7f8e12dbc36d23d3a9945d5e7bda66d08a77c006 Mon Sep 17 00:00:00 2001 From: Murph Murphy Date: Tue, 11 Aug 2026 20:14:12 -0600 Subject: [PATCH 6/9] Split `Types` up to make for faster and less memory intensive compilation --- src/gen_java/mod.rs | 354 ++++++++++++++++++- src/templates/CallbackInterfaceImpl.java | 2 +- src/templates/CallbackInterfaceTemplate.java | 5 +- src/templates/CustomTypeTemplate.java | 3 +- src/templates/EnumTemplate.java | 3 +- src/templates/ErrorTemplate.java | 3 +- src/templates/ObjectTemplate.java | 5 +- src/templates/RecordTemplate.java | 3 +- src/templates/Types.java | 111 ------ 9 files changed, 356 insertions(+), 133 deletions(-) diff --git a/src/gen_java/mod.rs b/src/gen_java/mod.rs index 03d70fb..a1647ce 100644 --- a/src/gen_java/mod.rs +++ b/src/gen_java/mod.rs @@ -361,8 +361,7 @@ pub struct JavaWrapper<'a> { impl<'a> JavaWrapper<'a> { pub fn new(config: Config, ci: &'a ComponentInterface) -> Self { - let type_renderer = TypeRenderer::new(&config, ci); - let type_helper_code = type_renderer.render().unwrap(); + let type_helper_code = render_type_helpers(&config, ci).unwrap(); Self { config, ci, @@ -400,7 +399,7 @@ impl<'a> JavaWrapper<'a> { } } -/// Renders Java helper code for all types +/// Renders the fixed helper classes and the runtime support shared by all types. #[derive(Template)] #[template(syntax = "java", escape = "none", path = "Types.java")] pub struct TypeRenderer<'a> { @@ -408,14 +407,333 @@ pub struct TypeRenderer<'a> { ci: &'a ComponentInterface, } -impl<'a> TypeRenderer<'a> { - fn new(config: &'a Config, ci: &'a ComponentInterface) -> Self { - Self { config, ci } +/// Askama inlines an `include` into the including template's `render_into`, so a single template +/// covering every type compiles into one function large enough to dominate this crate's build: +/// roughly 10 GB of rustc memory and 110s, against 380 MB and 1s for everything else combined. +/// Giving each type its own template keeps those functions small. +/// +/// There is a companion match in [`JavaCodeOracle::create_code_type`]; both need an arm when a +/// type is added. +fn render_type_helpers(config: &Config, ci: &ComponentInterface) -> Result { + let mut out = TypeRenderer { config, ci } + .render() + .context("failed to render shared type helpers")?; + for type_ in ci.iter_local_types() { + out.push_str(&render_one_type(type_, config, ci)?); + } + for type_ in ci.iter_external_types() { + let name = type_ + .name() + .ok_or_else(|| anyhow::anyhow!("external type {type_:?} has no name"))?; + let module_path = type_ + .module_path() + .ok_or_else(|| anyhow::anyhow!("external type {type_:?} has no module path"))?; + out.push_str( + &ExternalTypeRenderer { + config, + ci, + name, + module_path, + } + .render() + .with_context(|| format!("failed to render external type {name}"))?, + ); } + Ok(out) +} + +fn render_one_type(type_: &Type, config: &Config, ci: &ComponentInterface) -> Result { + let type_name = JavaCodeOracle.find(type_).type_label(ci, config); + let ffi_converter_name = JavaCodeOracle.find(type_).ffi_converter_name(); + let contains_object_references = ci.item_contains_object_references(type_); + + let rendered = match type_ { + Type::Boolean => BooleanHelperRenderer { config }.render(), + Type::Bytes => ByteArrayHelperRenderer { config }.render(), + Type::Duration => DurationHelperRenderer { config }.render(), + Type::String => StringHelperRenderer { config }.render(), + Type::Timestamp => TimestampHelperRenderer { config }.render(), + Type::Int8 | Type::UInt8 => Int8HelperRenderer { config }.render(), + Type::Int16 | Type::UInt16 => Int16HelperRenderer { config }.render(), + Type::Int32 | Type::UInt32 => Int32HelperRenderer { config }.render(), + Type::Int64 | Type::UInt64 => Int64HelperRenderer { config }.render(), + Type::Float32 => Float32HelperRenderer { config }.render(), + Type::Float64 => Float64HelperRenderer { config }.render(), + + Type::CallbackInterface { name, .. } => CallbackInterfaceTypeRenderer { + config, + ci, + ffi_converter_name, + name: name.clone(), + cbi: ci + .get_callback_interface_definition(name) + .ok_or_else(|| anyhow::anyhow!("callback interface not found: {name}"))?, + } + .render(), + + Type::Custom { name, builtin, .. } => { + if ci.is_external(type_) { + Ok(String::new()) + } else { + CustomTypeRenderer { + config, + ci, + type_name, + ffi_converter_name, + name: name.clone(), + builtin, + } + .render() + } + } - // The following methods are used by the `Types.java` macros. + Type::Enum { name, .. } => { + let e = ci + .get_enum_definition(name) + .ok_or_else(|| anyhow::anyhow!("enum not found: {name}"))?; + if ci.is_name_used_as_error(name) { + ErrorTypeRenderer { + config, + ci, + type_, + contains_object_references, + e, + } + .render() + } else { + EnumTypeRenderer { + config, + ci, + type_name, + contains_object_references, + e, + } + .render() + } + } + + Type::Map { + key_type, + value_type, + } => MapTypeRenderer { + config, + ci, + ffi_converter_name, + key_type, + value_type, + } + .render(), - // Get the package name for an external type (used by ExternalTypeTemplate.java) + Type::Optional { inner_type } => OptionalTypeRenderer { + config, + ci, + ffi_converter_name, + inner_type, + } + .render(), + + Type::Object { name, .. } => ObjectTypeRenderer { + config, + ci, + type_name, + ffi_converter_instance: JavaCodeOracle + .find(type_) + .ffi_converter_instance(config, ci), + name: name.clone(), + obj: ci + .get_object_definition(name) + .ok_or_else(|| anyhow::anyhow!("object not found: {name}"))?, + is_error: ci.is_name_used_as_error(name), + } + .render(), + + Type::Record { name, .. } => RecordTypeRenderer { + config, + ci, + type_name, + contains_object_references, + name, + } + .render(), + + Type::Sequence { inner_type } => match inner_type.as_ref() { + Type::Int16 | Type::UInt16 => Int16ArrayHelperRenderer { config }.render(), + Type::Int32 | Type::UInt32 => Int32ArrayHelperRenderer { config }.render(), + Type::Int64 | Type::UInt64 => Int64ArrayHelperRenderer { config }.render(), + Type::Float32 => Float32ArrayHelperRenderer { config }.render(), + Type::Float64 => Float64ArrayHelperRenderer { config }.render(), + Type::Boolean => BooleanArrayHelperRenderer { config }.render(), + _ => SequenceTypeRenderer { + config, + ci, + ffi_converter_name, + inner_type, + } + .render(), + }, + + Type::Set { inner_type } => SetTypeRenderer { + config, + ci, + ffi_converter_name, + inner_type, + } + .render(), + + Type::Box { .. } => Ok(String::new()), + }; + + rendered.with_context(|| format!("failed to render type {type_:?}")) +} + +/// Templates whose only input is the package name. +macro_rules! simple_type_renderer { + ($($name:ident => $path:literal),* $(,)?) => {$( + #[derive(Template)] + #[template(syntax = "java", escape = "none", path = $path)] + struct $name<'a> { + config: &'a Config, + } + )*}; +} + +simple_type_renderer! { + BooleanHelperRenderer => "BooleanHelper.java", + ByteArrayHelperRenderer => "ByteArrayHelper.java", + DurationHelperRenderer => "DurationHelper.java", + StringHelperRenderer => "StringHelper.java", + TimestampHelperRenderer => "TimestampHelper.java", + Int8HelperRenderer => "Int8Helper.java", + Int16HelperRenderer => "Int16Helper.java", + Int32HelperRenderer => "Int32Helper.java", + Int64HelperRenderer => "Int64Helper.java", + Float32HelperRenderer => "Float32Helper.java", + Float64HelperRenderer => "Float64Helper.java", + Int16ArrayHelperRenderer => "Int16ArrayHelper.java", + Int32ArrayHelperRenderer => "Int32ArrayHelper.java", + Int64ArrayHelperRenderer => "Int64ArrayHelper.java", + Float32ArrayHelperRenderer => "Float32ArrayHelper.java", + Float64ArrayHelperRenderer => "Float64ArrayHelper.java", + BooleanArrayHelperRenderer => "BooleanArrayHelper.java", +} + +#[derive(Template)] +#[template( + syntax = "java", + escape = "none", + path = "CallbackInterfaceTemplate.java" +)] +struct CallbackInterfaceTypeRenderer<'a> { + config: &'a Config, + ci: &'a ComponentInterface, + ffi_converter_name: String, + // Used by the nested CallbackInterfaceImpl.java + name: String, + cbi: &'a CallbackInterface, +} + +#[derive(Template)] +#[template(syntax = "java", escape = "none", path = "CustomTypeTemplate.java")] +struct CustomTypeRenderer<'a> { + config: &'a Config, + ci: &'a ComponentInterface, + type_name: String, + ffi_converter_name: String, + name: String, + builtin: &'a Type, +} + +#[derive(Template)] +#[template(syntax = "java", escape = "none", path = "EnumTemplate.java")] +struct EnumTypeRenderer<'a> { + config: &'a Config, + ci: &'a ComponentInterface, + type_name: String, + contains_object_references: bool, + e: &'a uniffi_bindgen::interface::Enum, +} + +#[derive(Template)] +#[template(syntax = "java", escape = "none", path = "ErrorTemplate.java")] +struct ErrorTypeRenderer<'a> { + config: &'a Config, + ci: &'a ComponentInterface, + type_: &'a Type, + contains_object_references: bool, + e: &'a uniffi_bindgen::interface::Enum, +} + +#[derive(Template)] +#[template(syntax = "java", escape = "none", path = "MapTemplate.java")] +struct MapTypeRenderer<'a> { + config: &'a Config, + ci: &'a ComponentInterface, + ffi_converter_name: String, + key_type: &'a Type, + value_type: &'a Type, +} + +#[derive(Template)] +#[template(syntax = "java", escape = "none", path = "OptionalTemplate.java")] +struct OptionalTypeRenderer<'a> { + config: &'a Config, + ci: &'a ComponentInterface, + ffi_converter_name: String, + inner_type: &'a Type, +} + +#[derive(Template)] +#[template(syntax = "java", escape = "none", path = "ObjectTemplate.java")] +struct ObjectTypeRenderer<'a> { + config: &'a Config, + ci: &'a ComponentInterface, + type_name: String, + ffi_converter_instance: String, + // Used by the nested CallbackInterfaceImpl.java + name: String, + obj: &'a Object, + is_error: bool, +} + +#[derive(Template)] +#[template(syntax = "java", escape = "none", path = "RecordTemplate.java")] +struct RecordTypeRenderer<'a> { + config: &'a Config, + ci: &'a ComponentInterface, + type_name: String, + contains_object_references: bool, + name: &'a str, +} + +#[derive(Template)] +#[template(syntax = "java", escape = "none", path = "SequenceTemplate.java")] +struct SequenceTypeRenderer<'a> { + config: &'a Config, + ci: &'a ComponentInterface, + ffi_converter_name: String, + inner_type: &'a Type, +} + +#[derive(Template)] +#[template(syntax = "java", escape = "none", path = "SetTemplate.java")] +struct SetTypeRenderer<'a> { + config: &'a Config, + ci: &'a ComponentInterface, + ffi_converter_name: String, + inner_type: &'a Type, +} + +#[derive(Template)] +#[template(syntax = "java", escape = "none", path = "ExternalTypeTemplate.java")] +struct ExternalTypeRenderer<'a> { + config: &'a Config, + ci: &'a ComponentInterface, + name: &'a str, + module_path: &'a str, +} + +impl ExternalTypeRenderer<'_> { + // Used by ExternalTypeTemplate.java fn external_type_package_name(&self, module_path: &str, namespace: &str) -> String { self.config .external_type_package_name(module_path, namespace) @@ -902,6 +1220,10 @@ mod filters { fully_qualified_type_label(inner_type, ci, config)? )), }, + Type::Set { inner_type } => Ok(format!( + "java.util.Set<{}>", + fully_qualified_type_label(inner_type, ci, config)? + )), Type::Map { key_type, value_type, @@ -1964,10 +2286,20 @@ mod tests { ci.derive_ffi_funcs().unwrap(); let bindings = generate_bindings(&Config::default(), &ci).unwrap(); - let variant_decl = bindings - .lines() - .find(|line| line.contains("record Group(")) + // The record header and its fields land on separate lines, so match the whole decl. + let lines: Vec<&str> = bindings.lines().collect(); + let start = lines + .iter() + .position(|line| line.contains("record Group(")) .unwrap_or_else(|| panic!("no Group variant in:\n{bindings}")); + let variant_decl = lines[start..] + .iter() + .take_while(|line| !line.contains("implements")) + .chain(lines[start..].iter().find(|l| l.contains("implements"))) + .copied() + .collect::>() + .join(" "); + let variant_decl = variant_decl.as_str(); assert!( variant_decl.contains("java.util.List"), diff --git a/src/templates/CallbackInterfaceImpl.java b/src/templates/CallbackInterfaceImpl.java index 69da779..a8b1824 100644 --- a/src/templates/CallbackInterfaceImpl.java +++ b/src/templates/CallbackInterfaceImpl.java @@ -1,6 +1,6 @@ package {{ config.package_name() }}; -{%- let trait_impl=format!("UniffiCallbackInterface{}", name) %} +{%- let trait_impl=format!("UniffiCallbackInterface{}", self.name) %} // Put the implementation in an object so we don't pollute the top-level namespace public class {{ trait_impl }} { diff --git a/src/templates/CallbackInterfaceTemplate.java b/src/templates/CallbackInterfaceTemplate.java index 322dcfb..a0f05c8 100644 --- a/src/templates/CallbackInterfaceTemplate.java +++ b/src/templates/CallbackInterfaceTemplate.java @@ -1,4 +1,4 @@ -{%- let cbi = ci.get_callback_interface_definition(name).unwrap() %} +{%- import "macros.java" as java %} {%- let ffi_init_callback = cbi.ffi_init_callback() %} {%- let interface_name = cbi|type_name(ci, config) %} {%- let interface_docstring = cbi.docstring() %} @@ -16,5 +16,4 @@ public final class {{ ffi_converter_name }} extends FfiConverterCallbackInterfac static final {{ ffi_converter_name }} INSTANCE = new {{ ffi_converter_name }}(); private {{ ffi_converter_name }}() {} -} - +} \ No newline at end of file diff --git a/src/templates/CustomTypeTemplate.java b/src/templates/CustomTypeTemplate.java index 2346013..e4608f7 100644 --- a/src/templates/CustomTypeTemplate.java +++ b/src/templates/CustomTypeTemplate.java @@ -1,3 +1,4 @@ +{%- import "macros.java" as java %} {%- let package_name = config.package_name() %} {%- let ffi_type_name=builtin|ffi_type|ref|ffi_type_name(config, ci) %} {%- let ffi_type_name_boxed=builtin|ffi_type_name_boxed %} @@ -127,4 +128,4 @@ public void write({{ type_name }} value, java.nio.ByteBuffer buf) { } } } -{%- endmatch %} +{%- endmatch %} \ No newline at end of file diff --git a/src/templates/EnumTemplate.java b/src/templates/EnumTemplate.java index 5fa35ab..f73a93f 100644 --- a/src/templates/EnumTemplate.java +++ b/src/templates/EnumTemplate.java @@ -1,3 +1,4 @@ +{%- import "macros.java" as java %} {%- let uniffi_trait_methods = e.uniffi_trait_methods() %} package {{ config.package_name() }}; @@ -154,4 +155,4 @@ public void write({{ type_name }} value, java.nio.ByteBuffer buf) { } } -{% endif %} +{% endif %} \ No newline at end of file diff --git a/src/templates/ErrorTemplate.java b/src/templates/ErrorTemplate.java index c3740c3..668749e 100644 --- a/src/templates/ErrorTemplate.java +++ b/src/templates/ErrorTemplate.java @@ -1,3 +1,4 @@ +{%- import "macros.java" as java %} package {{ config.package_name() }}; {%- let type_name = type_|type_name(ci, config) %} @@ -150,4 +151,4 @@ public void write({{ type_name }} value, java.nio.ByteBuffer buf) { default -> throw new java.lang.RuntimeException("invalid error enum value, something is very wrong!!"); }; } -} +} \ No newline at end of file diff --git a/src/templates/ObjectTemplate.java b/src/templates/ObjectTemplate.java index 30b2308..b1604a6 100644 --- a/src/templates/ObjectTemplate.java +++ b/src/templates/ObjectTemplate.java @@ -1,3 +1,4 @@ +{%- import "macros.java" as java %} // This template implements a class for working with a Rust struct via a handle // to the live Rust struct on the other side of the FFI. // @@ -95,12 +96,10 @@ // [1] https://stackoverflow.com/questions/24376768/can-java-finalize-an-object-when-it-is-still-in-scope/24380219 // -{%- let obj = ci.get_object_definition(name).unwrap() %} {%- let (interface_name, impl_class_name) = obj|object_names(ci) %} {%- let methods = obj.methods() %} {%- let uniffi_trait_methods = obj.uniffi_trait_methods() %} {%- let interface_docstring = obj.docstring() %} -{%- let is_error = ci.is_name_used_as_error(name) %} {%- let ffi_converter_name = obj|ffi_converter_name %} {%- include "Interface.java" %} @@ -352,4 +351,4 @@ public void write({{ type_name }} value, java.nio.ByteBuffer buf) { buf.putLong(lower(value)); } } -{%- endif %} +{%- endif %} \ No newline at end of file diff --git a/src/templates/RecordTemplate.java b/src/templates/RecordTemplate.java index 0eb20b6..3d88095 100644 --- a/src/templates/RecordTemplate.java +++ b/src/templates/RecordTemplate.java @@ -1,3 +1,4 @@ +{%- import "macros.java" as java %} {%- let rec = ci.get_record_definition(name).unwrap() %} {%- let uniffi_trait_methods = rec.uniffi_trait_methods() %} package {{ config.package_name() }}; @@ -158,4 +159,4 @@ public void write({{ type_name }} value, java.nio.ByteBuffer buf) { {{ field|write_fn(config, ci) }}(value.{{ field.name()|var_name }}(), buf); {%- endfor %} } -} +} \ No newline at end of file diff --git a/src/templates/Types.java b/src/templates/Types.java index 1db21dd..2d2f402 100644 --- a/src/templates/Types.java +++ b/src/templates/Types.java @@ -88,114 +88,3 @@ private UniffiWithHandle() {} {%- if ci.has_object_definitions() %} {% include "ObjectCleanerHelper.java" %} {%- endif %} - -{%- for type_ in ci.iter_local_types() %} -{%- let type_name = type_|type_name(ci, config) %} -{%- let ffi_converter_name = type_|ffi_converter_name %} -{%- let ffi_converter_instance = type_|ffi_converter_instance(config, ci) %} -{%- let canonical_type_name = type_|canonical_name %} -{%- let contains_object_references = ci.item_contains_object_references(type_) %} - -{# - # Map `Type` instances to an include statement for that type. - # - # There is a companion match in `JavaCodeOracle::create_code_type()` which performs a similar function for the - # Rust code. - # - # - When adding additional types here, make sure to also add a match arm to that function. - # - To keep things manageable, let's try to limit ourselves to these 2 mega-matches - #} -{%- match type_ %} - -{%- when Type::Boolean %} -{%- include "BooleanHelper.java" %} - -{%- when Type::Bytes %} -{%- include "ByteArrayHelper.java" %} - -{%- when Type::CallbackInterface { module_path, name } %} -{% include "CallbackInterfaceTemplate.java" %} - -{%- when Type::Custom { module_path, name, builtin } %} -{%- if !ci.is_external(type_) %} -{% include "CustomTypeTemplate.java" %} -{%- endif %} - -{%- when Type::Duration %} -{% include "DurationHelper.java" %} - -{%- when Type::Enum { name, module_path } %} -{%- let e = ci.get_enum_definition(name).unwrap() %} -{%- if !ci.is_name_used_as_error(name) %} -{% include "EnumTemplate.java" %} -{%- else %} -{% include "ErrorTemplate.java" %} -{%- endif -%} - -{%- when Type::Int64 or Type::UInt64 %} -{%- include "Int64Helper.java" %} - -{%- when Type::Int8 or Type::UInt8 %} -{%- include "Int8Helper.java" %} - -{%- when Type::Int16 or Type::UInt16 %} -{%- include "Int16Helper.java" %} - -{%- when Type::Int32 or Type::UInt32 %} -{%- include "Int32Helper.java" %} - -{%- when Type::Float32 %} -{%- include "Float32Helper.java" %} - -{%- when Type::Float64 %} -{%- include "Float64Helper.java" %} - -{%- when Type::Map { key_type, value_type } %} -{% include "MapTemplate.java" %} - -{%- when Type::Optional { inner_type } %} -{% include "OptionalTemplate.java" %} - -{%- when Type::Object { module_path, name, imp } %} -{% include "ObjectTemplate.java" %} - -{%- when Type::Record { name, module_path } %} -{% include "RecordTemplate.java" %} - -{%- when Type::Sequence { inner_type } %} -{%- match inner_type.as_ref() %} -{%- when Type::Int16 or Type::UInt16 %} -{%- include "Int16ArrayHelper.java" %} -{%- when Type::Int32 or Type::UInt32 %} -{%- include "Int32ArrayHelper.java" %} -{%- when Type::Int64 or Type::UInt64 %} -{%- include "Int64ArrayHelper.java" %} -{%- when Type::Float32 %} -{%- include "Float32ArrayHelper.java" %} -{%- when Type::Float64 %} -{%- include "Float64ArrayHelper.java" %} -{%- when Type::Boolean %} -{%- include "BooleanArrayHelper.java" %} -{%- else %} -{% include "SequenceTemplate.java" %} -{%- endmatch %} - -{%- when Type::Set { inner_type } %} -{% include "SetTemplate.java" %} - -{%- when Type::String %} -{%- include "StringHelper.java" %} - -{%- when Type::Timestamp %} -{% include "TimestampHelper.java" %} - -{%- else %} -{%- endmatch %} -{%- endfor %} - -{#- Generate external error handlers for external types used as errors -#} -{%- for type_ in ci.iter_external_types() %} -{%- let name = type_.name().unwrap() %} -{%- let module_path = type_.module_path().unwrap() %} -{% include "ExternalTypeTemplate.java" %} -{%- endfor %} From 991a66f748a010fabb47c18a9f4f661dd0ecc9eb Mon Sep 17 00:00:00 2001 From: Murph Murphy Date: Tue, 11 Aug 2026 22:13:45 -0600 Subject: [PATCH 7/9] Add test for a bug upstream encountered in zero-copy --- tests/scripts/TestZeroCopy.java | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/scripts/TestZeroCopy.java b/tests/scripts/TestZeroCopy.java index d204478..2df6eff 100644 --- a/tests/scripts/TestZeroCopy.java +++ b/tests/scripts/TestZeroCopy.java @@ -44,6 +44,20 @@ public static void main(String[] args) { // Empty lowers to (null, 0), which Rust reads as an empty slice rather than crashing. assert ZeroCopy.lenBorrowed(ByteBuffer.allocateDirect(0)) == 0 : "empty buffer has len 0"; assert ZeroCopy.checksumBorrowed(ByteBuffer.allocateDirect(0)) == 0 : "empty buffer sums to 0"; + assert ZeroCopy.firstByteBorrowed(ByteBuffer.allocateDirect(0)) == 0 : "empty buffer has no first byte"; + + // Lowering keys off `remaining`, not `capacity`, so a drained buffer takes the same + // (null, 0) path while its backing store is still very much alive. + ByteBuffer drained = direct(bytes); + drained.position(drained.limit()); + assert ZeroCopy.lenBorrowed(drained) == 0 : "drained buffer has len 0"; + drained.position(drained.limit()); + assert ZeroCopy.checksumBorrowed(drained) == 0 : "drained buffer sums to 0"; + + // A null pointer in argument position must not disturb the argument that follows it. + assert java.util.Arrays.equals( + ZeroCopy.concatBorrowedAndOwned(ByteBuffer.allocateDirect(0), tail), + tail) : "empty borrowed arg should leave the owned arg intact"; // A heap buffer has no stable native address, so it must be rejected rather than // silently lowered as a null pointer. From 22b9d7f904a88663e7ab19df5f74a563e13e06f5 Mon Sep 17 00:00:00 2001 From: Murph Murphy Date: Mon, 24 Aug 2026 21:25:33 -0600 Subject: [PATCH 8/9] Edge case bugfixes from code review This ended up getting longer to make properly recursive hashable types work. I think hashables in `ByteArray`s in Kotlin may have a similar problem hanging out as a latent bug, should probably make them an issue with a regression test to show it. --- Cargo.lock | 10 + Cargo.toml | 1 + fixtures/primitive-arrays/src/lib.rs | 68 +++ fixtures/zero-copy/src/lib.rs | 19 + src/gen_java/compounds.rs | 153 ++++- src/gen_java/mod.rs | 758 ++++++++++++++++++++++--- src/templates/CustomTypeTemplate.java | 12 + src/templates/EnumTemplate.java | 26 + src/templates/ErrorTemplate.java | 7 +- src/templates/Helpers.java | 56 ++ src/templates/RecordTemplate.java | 26 +- src/templates/macros.java | 7 +- tests/scripts/TestEnumTypes.java | 60 ++ tests/scripts/TestPrimitiveArrays.java | 91 +++ tests/scripts/TestZeroCopy.java | 14 + tests/tests.rs | 1 + 16 files changed, 1213 insertions(+), 96 deletions(-) create mode 100644 tests/scripts/TestEnumTypes.java diff --git a/Cargo.lock b/Cargo.lock index beed32d..5a2703a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1673,6 +1673,7 @@ dependencies = [ "uniffi-example-todolist", "uniffi-fixture-benchmarks", "uniffi-fixture-coverall", + "uniffi-fixture-enum-types", "uniffi-fixture-ext-types", "uniffi-fixture-futures", "uniffi-fixture-primitive-arrays", @@ -1776,6 +1777,15 @@ dependencies = [ "uniffi", ] +[[package]] +name = "uniffi-fixture-enum-types" +version = "0.22.0" +source = "git+https://github.com/mozilla/uniffi-rs.git?tag=v0.32.0#5c7b73906358e1a7acdc1bdc7bf5cd86fb27e44c" +dependencies = [ + "thiserror", + "uniffi", +] + [[package]] name = "uniffi-fixture-ext-types" version = "0.22.0" diff --git a/Cargo.toml b/Cargo.toml index b21adda..2f7d84a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,6 +54,7 @@ uniffi-example-sprites = { git = "https://github.com/mozilla/uniffi-rs.git", tag uniffi-example-todolist = { git = "https://github.com/mozilla/uniffi-rs.git", tag = "v0.32.0" } uniffi-fixture-benchmarks = { git = "https://github.com/mozilla/uniffi-rs.git", tag = "v0.32.0" } uniffi-fixture-coverall = { git = "https://github.com/mozilla/uniffi-rs.git", tag = "v0.32.0" } +uniffi-fixture-enum-types = { git = "https://github.com/mozilla/uniffi-rs.git", tag = "v0.32.0" } uniffi-fixture-ext-types = { git = "https://github.com/mozilla/uniffi-rs.git", tag = "v0.32.0" } uniffi-fixture-futures = { git = "https://github.com/mozilla/uniffi-rs.git", tag = "v0.32.0" } uniffi-fixture-primitive-arrays = { path = "fixtures/primitive-arrays" } diff --git a/fixtures/primitive-arrays/src/lib.rs b/fixtures/primitive-arrays/src/lib.rs index 3d6d16f..c5a6dcc 100644 --- a/fixtures/primitive-arrays/src/lib.rs +++ b/fixtures/primitive-arrays/src/lib.rs @@ -2,6 +2,8 @@ * 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 std::collections::{HashMap, HashSet}; + uniffi::setup_scaffolding!("primitive_arrays"); // Float32 (float[]) operations @@ -85,3 +87,69 @@ fn roundtrip_uint32(data: Vec) -> Vec { fn roundtrip_uint64(data: Vec) -> Vec { data } + +// Hashed positions, where Java has to keep the boxed `List` rendering to preserve value equality. + +#[uniffi::export] +fn roundtrip_int32_set(data: HashSet>) -> HashSet> { + data +} + +#[uniffi::export] +fn roundtrip_int32_keyed_map(data: HashMap, String>) -> HashMap, String> { + data +} + +/// Values are not hashed, so `Vec` keeps the `double[]` rendering here. +#[uniffi::export] +fn roundtrip_float64_valued_map(data: HashMap>) -> HashMap> { + data +} + +// Hashed positions reached through nesting, which must render boxed at every depth. + +#[uniffi::export] +fn roundtrip_nested_int32_set(data: HashSet>>) -> HashSet>> { + data +} + +#[uniffi::export] +fn roundtrip_optional_int32_set(data: HashSet>>) -> HashSet>> { + data +} + +// Array-holding fields, whose Java equals/hashCode must compare by value. + +#[derive(uniffi::Record, PartialEq, Eq, Hash)] +pub struct IntsHolder { + pub label: String, + pub data: Vec, + pub nested: Vec>, +} + +#[uniffi::export] +fn roundtrip_holder_set(data: HashSet) -> HashSet { + data +} + +#[derive(uniffi::Enum)] +pub enum IntsEnum { + Empty, + Ints { values: Vec }, +} + +#[uniffi::export] +fn roundtrip_ints_enum(data: IntsEnum) -> IntsEnum { + data +} + +/// A custom newtype over an array-rendering builtin; its Java wrapper record must also compare +/// by value. +#[derive(PartialEq, Eq, Hash)] +pub struct IntsKey(pub Vec); +uniffi::custom_newtype!(IntsKey, Vec); + +#[uniffi::export] +fn roundtrip_key_set(data: HashSet) -> HashSet { + data +} diff --git a/fixtures/zero-copy/src/lib.rs b/fixtures/zero-copy/src/lib.rs index fa54aaf..4c01b26 100644 --- a/fixtures/zero-copy/src/lib.rs +++ b/fixtures/zero-copy/src/lib.rs @@ -9,6 +9,8 @@ //! `ForeignBytes` holds a `*const u8` so the generated future isn't `Send` and fails //! `rust_future_new`'s bound. +use std::sync::Arc; + uniffi::setup_scaffolding!("zero_copy"); #[uniffi::export] @@ -38,3 +40,20 @@ fn len_borrowed(data: &[u8]) -> u32 { fn concat_borrowed_and_owned(borrowed: &[u8], owned: Vec) -> Vec { [borrowed, &owned].concat() } + +/// Lets the same borrowed-bytes call be made as a method, which reaches the FFI through Java's +/// `callWithHandle` instead of calling it directly. +#[derive(uniffi::Object)] +pub struct Checksummer; + +#[uniffi::export] +impl Checksummer { + #[uniffi::constructor] + fn new() -> Arc { + Arc::new(Self) + } + + fn checksum_borrowed(&self, data: &[u8]) -> u64 { + data.iter().map(|b| *b as u64).sum() + } +} diff --git a/src/gen_java/compounds.rs b/src/gen_java/compounds.rs index 22641da..4db6554 100644 --- a/src/gen_java/compounds.rs +++ b/src/gen_java/compounds.rs @@ -90,17 +90,12 @@ impl CodeType for SetCodeType { fn type_label(&self, ci: &ComponentInterface, config: &Config) -> String { format!( "java.util.Set<{}>", - super::JavaCodeOracle - .find(self.inner()) - .type_label(ci, config) + Hashed(self.inner()).as_codetype().type_label(ci, config) ) } fn canonical_name(&self) -> String { - format!( - "Set{}", - super::JavaCodeOracle.find(self.inner()).canonical_name() - ) + format!("Set{}", Hashed(self.inner()).as_codetype().canonical_name()) } } @@ -128,9 +123,7 @@ impl CodeType for MapCodeType { fn type_label(&self, ci: &ComponentInterface, config: &Config) -> String { format!( "java.util.Map<{}, {}>", - super::JavaCodeOracle - .find(self.key()) - .type_label(ci, config), + Hashed(self.key()).as_codetype().type_label(ci, config), super::JavaCodeOracle .find(self.value()) .type_label(ci, config), @@ -140,12 +133,150 @@ impl CodeType for MapCodeType { fn canonical_name(&self) -> String { format!( "Map{}{}", - self.key().as_codetype().canonical_name(), + Hashed(self.key()).as_codetype().canonical_name(), self.value().as_codetype().canonical_name(), ) } } +/// A type in a position Java will hash: a `Set` element or a `Map` key. +/// +/// Java arrays hash and compare by identity, so the primitive-array lens would leave +/// `Set.contains`/`Map.get` never matching, and would let value-equal entries coexist that Rust +/// deduplicates. Hashed positions render every array-producing type on the `Sequence`/`Optional` +/// spine as boxed `java.util.List` instead, however deep, and `bytes` as +/// `java.util.List`. That spine is exhaustive: Rust's `Hash + Eq` bounds keep +/// maps, sets, and float vectors out of hashed positions entirely. +/// +/// `bytes` and `Vec` share a wire format (i32 length + raw bytes), so a hashed `bytes` can +/// borrow the generic `Sequence` converter unchanged. +/// +/// [`super::render_one_type`] emits the hashed converter variants alongside the plain ones for +/// any type this applies to. +#[derive(Debug)] +pub struct Hashed<'a>(pub &'a Type); + +impl AsCodeType for Hashed<'_> { + fn as_codetype(&self) -> Box { + match self.0 { + ty if !needs_hashed_rendering(ty) => ty.as_codetype(), + Type::Sequence { inner_type } => { + Box::new(HashedSequenceCodeType::new((**inner_type).clone())) + } + Type::Optional { inner_type } => { + Box::new(HashedOptionalCodeType::new((**inner_type).clone())) + } + Type::Bytes => Box::new(SequenceCodeType::new(Type::Int8)), + _ => unreachable!("needs_hashed_rendering matches only Sequence, Optional, and Bytes"), + } + } +} + +/// Whether `ty`'s plain rendering puts a Java array anywhere value equality would consult it: +/// directly, or under the `Sequence`/`Optional` wrappers [`Hashed`] recurses through. +pub fn needs_hashed_rendering(ty: &Type) -> bool { + match ty { + Type::Bytes => true, + Type::Sequence { inner_type } => { + renders_as_primitive_array(inner_type) || needs_hashed_rendering(inner_type) + } + Type::Optional { inner_type } => needs_hashed_rendering(inner_type), + _ => false, + } +} + +/// [`SequenceCodeType`] with the inner type rendered through [`Hashed`]. +#[derive(Debug)] +pub struct HashedSequenceCodeType { + inner: Type, +} + +impl HashedSequenceCodeType { + pub fn new(inner: Type) -> Self { + Self { inner } + } +} + +impl CodeType for HashedSequenceCodeType { + fn type_label(&self, ci: &ComponentInterface, config: &Config) -> String { + format!( + "java.util.List<{}>", + Hashed(&self.inner).as_codetype().type_label(ci, config) + ) + } + + fn canonical_name(&self) -> String { + format!( + "Sequence{}", + Hashed(&self.inner).as_codetype().canonical_name() + ) + } +} + +/// [`OptionalCodeType`] with the inner type rendered through [`Hashed`]. +#[derive(Debug)] +pub struct HashedOptionalCodeType { + inner: Type, +} + +impl HashedOptionalCodeType { + pub fn new(inner: Type) -> Self { + Self { inner } + } +} + +impl CodeType for HashedOptionalCodeType { + fn type_label(&self, ci: &ComponentInterface, config: &Config) -> String { + let inner = Hashed(&self.inner).as_codetype().type_label(ci, config); + if config.nullness_annotations() { + super::nullable_type_label(&inner) + } else { + inner + } + } + + fn canonical_name(&self) -> String { + format!( + "Optional{}", + Hashed(&self.inner).as_codetype().canonical_name() + ) + } +} + +/// Whether `ty`'s rendering holds a Java array anywhere `equals`/`hashCode` would visit: +/// directly, or inside `Sequence`/`Optional` wrappers or `Map` values. Fields for which this +/// holds compare via `UniffiDeepValue` instead of `Objects.equals`. `Set` elements and `Map` +/// keys need no recursion: they are hashed positions, already array-free. +pub fn contains_array_rendering(ty: &Type) -> bool { + match ty { + Type::Bytes => true, + Type::Sequence { inner_type } => { + renders_as_primitive_array(inner_type) || contains_array_rendering(inner_type) + } + Type::Optional { inner_type } => contains_array_rendering(inner_type), + Type::Map { value_type, .. } => contains_array_rendering(value_type), + _ => false, + } +} + +/// Whether `Vec` renders as a Java primitive array. +/// +/// `Int8`/`UInt8` are absent because the separate `Bytes` type owns `byte[]`. +pub fn renders_as_primitive_array(inner: &Type) -> bool { + matches!( + inner, + Type::Int16 + | Type::UInt16 + | Type::Int32 + | Type::UInt32 + | Type::Int64 + | Type::UInt64 + | Type::Float32 + | Type::Float64 + | Type::Boolean + ) +} + // Primitive array types for sequences of primitives. // These generate Java primitive arrays (e.g., float[], int[]) instead of List. diff --git a/src/gen_java/mod.rs b/src/gen_java/mod.rs index a1647ce..51aaae7 100644 --- a/src/gen_java/mod.rs +++ b/src/gen_java/mod.rs @@ -442,6 +442,74 @@ fn render_type_helpers(config: &Config, ci: &ComponentInterface) -> Result bool { + fn on_spine(root: &Type, target: &Type) -> bool { + root == target + || match root { + Type::Sequence { inner_type } | Type::Optional { inner_type } => { + on_spine(inner_type, target) + } + _ => false, + } + } + ci.iter_local_types() + .chain(ci.iter_external_types()) + .any(|t| match t { + Type::Set { inner_type } => on_spine(inner_type, type_), + Type::Map { key_type, .. } => on_spine(key_type, type_), + _ => false, + }) +} + +/// Whether some type in `ci` already renders a converter with this canonical name. The +/// [`compounds::Hashed`] `bytes` aliasing can make a hashed variant coincide with a plain +/// converter; emitting both would duplicate the class. +fn universe_has_canonical(ci: &ComponentInterface, canonical: &str) -> bool { + ci.iter_local_types() + .chain(ci.iter_external_types()) + .any(|t| JavaCodeOracle.find(t).canonical_name() == canonical) +} + +/// The hashed converter variant for `type_`, rendered if a hashed position anywhere in `ci` +/// needs it and no plain converter already has its name. Empty otherwise. +fn render_hashed_variant(type_: &Type, config: &Config, ci: &ComponentInterface) -> Result { + if !compounds::needs_hashed_rendering(type_) || !used_in_hashed_position(ci, type_) { + return Ok(String::new()); + } + let hashed = compounds::Hashed(type_).as_codetype(); + if universe_has_canonical(ci, &hashed.canonical_name()) { + return Ok(String::new()); + } + let ffi_converter_name = hashed.ffi_converter_name(); + match type_ { + Type::Sequence { inner_type } => SequenceTypeRenderer { + config, + ci, + ffi_converter_name, + inner_type: compounds::Hashed(inner_type), + } + .render(), + Type::Optional { inner_type } => OptionalTypeRenderer { + config, + ci, + ffi_converter_name, + inner_type: compounds::Hashed(inner_type), + } + .render(), + Type::Bytes => SequenceTypeRenderer { + config, + ci, + ffi_converter_name, + inner_type: Type::Int8, + } + .render(), + _ => unreachable!("needs_hashed_rendering matches only Sequence, Optional, and Bytes"), + } + .map_err(Into::into) +} + fn render_one_type(type_: &Type, config: &Config, ci: &ComponentInterface) -> Result { let type_name = JavaCodeOracle.find(type_).type_label(ci, config); let ffi_converter_name = JavaCodeOracle.find(type_).ffi_converter_name(); @@ -519,7 +587,7 @@ fn render_one_type(type_: &Type, config: &Config, ci: &ComponentInterface) -> Re config, ci, ffi_converter_name, - key_type, + key_type: compounds::Hashed(key_type), value_type, } .render(), @@ -576,14 +644,15 @@ fn render_one_type(type_: &Type, config: &Config, ci: &ComponentInterface) -> Re config, ci, ffi_converter_name, - inner_type, + inner_type: compounds::Hashed(inner_type), } .render(), Type::Box { .. } => Ok(String::new()), }; - rendered.with_context(|| format!("failed to render type {type_:?}")) + let rendered = rendered.with_context(|| format!("failed to render type {type_:?}"))?; + Ok(rendered + &render_hashed_variant(type_, config, ci)?) } /// Templates whose only input is the package name. @@ -669,17 +738,17 @@ struct MapTypeRenderer<'a> { config: &'a Config, ci: &'a ComponentInterface, ffi_converter_name: String, - key_type: &'a Type, + key_type: compounds::Hashed<'a>, value_type: &'a Type, } #[derive(Template)] #[template(syntax = "java", escape = "none", path = "OptionalTemplate.java")] -struct OptionalTypeRenderer<'a> { +struct OptionalTypeRenderer<'a, T: AsCodeType> { config: &'a Config, ci: &'a ComponentInterface, ffi_converter_name: String, - inner_type: &'a Type, + inner_type: T, } #[derive(Template)] @@ -707,11 +776,11 @@ struct RecordTypeRenderer<'a> { #[derive(Template)] #[template(syntax = "java", escape = "none", path = "SequenceTemplate.java")] -struct SequenceTypeRenderer<'a> { +struct SequenceTypeRenderer<'a, T: AsCodeType> { config: &'a Config, ci: &'a ComponentInterface, ffi_converter_name: String, - inner_type: &'a Type, + inner_type: T, } #[derive(Template)] @@ -720,7 +789,7 @@ struct SetTypeRenderer<'a> { config: &'a Config, ci: &'a ComponentInterface, ffi_converter_name: String, - inner_type: &'a Type, + inner_type: compounds::Hashed<'a>, } #[derive(Template)] @@ -1222,16 +1291,18 @@ mod filters { }, Type::Set { inner_type } => Ok(format!( "java.util.Set<{}>", - fully_qualified_type_label(inner_type, ci, config)? + hashed_fully_qualified_type_label(inner_type, ci, config)? )), Type::Map { key_type, value_type, } => Ok(format!( "java.util.Map<{}, {}>", - fully_qualified_type_label(key_type, ci, config)?, + hashed_fully_qualified_type_label(key_type, ci, config)?, fully_qualified_type_label(value_type, ci, config)? )), + // `Box` exists only in scaffolding; the bindings name `T`. + Type::Box { inner_type } => fully_qualified_type_label(inner_type, ci, config), Type::Enum { .. } | Type::Record { .. } | Type::Object { .. } @@ -1248,6 +1319,34 @@ mod filters { } } + /// As [`fully_qualified_type_label`], for a `Set` element or `Map` key. See + /// [`compounds::Hashed`]. + fn hashed_fully_qualified_type_label( + ty: &Type, + ci: &ComponentInterface, + config: &Config, + ) -> anyhow::Result { + if !compounds::needs_hashed_rendering(ty) { + return fully_qualified_type_label(ty, ci, config); + } + match ty { + Type::Sequence { inner_type } => Ok(format!( + "java.util.List<{}>", + hashed_fully_qualified_type_label(inner_type, ci, config)? + )), + Type::Optional { inner_type } => { + let inner = hashed_fully_qualified_type_label(inner_type, ci, config)?; + Ok(if config.nullness_annotations() { + nullable_type_label(&inner) + } else { + inner + }) + } + Type::Bytes => Ok("java.util.List".to_string()), + _ => unreachable!("needs_hashed_rendering matches only Sequence, Optional, and Bytes"), + } + } + fn package_for_type( ty: &Type, ci: &ComponentInterface, @@ -1391,7 +1490,9 @@ mod filters { Type::Int8 | Type::UInt8 => Ok(format!("(byte){}", base10)), Type::Int16 | Type::UInt16 => Ok(format!("(short){}", base10)), Type::Int32 | Type::UInt32 => Ok(base10), - Type::Int64 | Type::UInt64 => Ok(base10), + // Without the suffix an `int` literal is parsed first, and anything past + // `i32::MAX` fails to compile. + Type::Int64 | Type::UInt64 => Ok(format!("{}L", base10)), _ => Err(to_askama_error("Only ints are supported.")), } } else { @@ -1936,40 +2037,89 @@ mod filters { /// Generates an equality expression for comparing two values of a field's type. /// For primitives: returns "left == right" + /// For array-holding types (see [`compounds::contains_array_rendering`]): "UniffiDeepValue.equals(left, right)" /// For objects: returns "java.util.Objects.equals(left, right)" #[askama::filter_fn] - pub fn equals_expr( + pub fn equals_expr( field: &T, _v: &dyn askama::Values, left: L, right: R, ) -> Result { - // Check if this type has a primitive label (meaning it's a primitive) if field.as_codetype().type_label_primitive().is_some() { Ok(format!("{} == {}", left, right)) + } else if compounds::contains_array_rendering(&field.as_type()) { + Ok(format!("UniffiDeepValue.equals({}, {})", left, right)) } else { Ok(format!("java.util.Objects.equals({}, {})", left, right)) } } - /// Generates a hash code expression for a field value. - /// For primitives: returns "Type.hashCode(value)" (e.g., "java.lang.Integer.hashCode(value)") - /// For objects: returns "java.util.Objects.hashCode(value)" + /// A field's element expression for a `java.util.Objects.hash(...)` call: the value itself, + /// or its `UniffiDeepValue` hash when arrays are reachable (an `Integer` hashes to itself, + /// so pre-hashing composes). #[askama::filter_fn] - pub fn hash_code_expr( + pub fn hash_element_expr( field: &T, _v: &dyn askama::Values, value: V, ) -> Result { - match field.as_type() { - Type::Boolean => Ok(format!("java.lang.Boolean.hashCode({})", value)), - Type::Int8 | Type::UInt8 => Ok(format!("java.lang.Byte.hashCode({})", value)), - Type::Int16 | Type::UInt16 => Ok(format!("java.lang.Short.hashCode({})", value)), - Type::Int32 | Type::UInt32 => Ok(format!("java.lang.Integer.hashCode({})", value)), - Type::Int64 | Type::UInt64 => Ok(format!("java.lang.Long.hashCode({})", value)), - Type::Float32 => Ok(format!("java.lang.Float.hashCode({})", value)), - Type::Float64 => Ok(format!("java.lang.Double.hashCode({})", value)), - _ => Ok(format!("java.util.Objects.hashCode({})", value)), + if compounds::contains_array_rendering(&field.as_type()) { + Ok(format!("UniffiDeepValue.hashCode({})", value)) + } else { + Ok(value.to_string()) + } + } + + /// As [`equals_expr`], for positions whose components are reference types (enum variant + /// records box their primitives, so `==` would compare boxed identities). + #[askama::filter_fn] + pub fn boxed_equals_expr( + field: &T, + _v: &dyn askama::Values, + left: L, + right: R, + ) -> Result { + if compounds::contains_array_rendering(&field.as_type()) { + Ok(format!("UniffiDeepValue.equals({}, {})", left, right)) + } else { + Ok(format!("java.util.Objects.equals({}, {})", left, right)) + } + } + + /// See [`compounds::contains_array_rendering`]. + #[askama::filter_fn] + pub fn contains_array_rendering( + as_type: &impl AsType, + _v: &dyn askama::Values, + ) -> Result { + Ok(compounds::contains_array_rendering(&as_type.as_type())) + } + + /// Whether any field's rendering needs `UniffiDeepValue` equality. See + /// [`compounds::contains_array_rendering`]. + #[askama::filter_fn] + pub fn has_array_rendered_field( + fields: &[Field], + _v: &dyn askama::Values, + ) -> Result { + Ok(fields + .iter() + .any(|f| compounds::contains_array_rendering(&f.as_type()))) + } + + /// The Java name of a field: `v{index}` for a tuple variant's positional fields. Mirrors the + /// `field_name` template macro for use in expression positions. + #[askama::filter_fn] + pub fn field_java_name( + field: &Field, + _v: &dyn askama::Values, + index: &usize, + ) -> Result { + if field.name().is_empty() { + Ok(format!("v{index}")) + } else { + Ok(JavaCodeOracle.var_name(field.name())) } } } @@ -1979,12 +2129,84 @@ mod tests { use super::*; use uniffi_bindgen::interface::ComponentInterface; use uniffi_meta::{ - CallbackInterfaceMetadata, EnumMetadata, EnumShape, FieldMetadata, FnMetadata, - FnParamMetadata, Metadata, MetadataGroup, MethodMetadata, NamespaceMetadata, ObjectImpl, - ObjectMetadata, ObjectTraitImplMetadata, RecordMetadata, TraitKind, TraitMethodMetadata, - Type, VariantMetadata, + CallbackInterfaceMetadata, CustomTypeMetadata, EnumMetadata, EnumShape, FieldMetadata, + FnMetadata, FnParamMetadata, Metadata, MetadataGroup, MethodMetadata, NamespaceMetadata, + ObjectImpl, ObjectMetadata, ObjectTraitImplMetadata, RecordMetadata, TraitKind, + TraitMethodMetadata, Type, VariantMetadata, }; + #[test] + fn error_variant_holding_an_object_is_closeable() { + let mut group = test_group(); + group.add_item(Metadata::Object(ObjectMetadata { + module_path: "test".to_string(), + name: "Thing".to_string(), + orig_name: None, + remote: false, + imp: ObjectImpl::Struct, + docstring: None, + })); + group.add_item(Metadata::Enum(EnumMetadata { + orig_name: None, + module_path: "test".to_string(), + name: "BoomError".to_string(), + shape: EnumShape::Error { flat: false }, + remote: false, + variants: vec![VariantMetadata { + orig_name: None, + name: "Boom".to_string(), + discr: None, + fields: vec![field( + "thing", + Type::Object { + module_path: "test".to_string(), + name: "Thing".to_string(), + imp: ObjectImpl::Struct, + }, + )], + docstring: None, + }], + discr_type: None, + non_exhaustive: false, + docstring: None, + })); + group.add_item(Metadata::Func(FnMetadata { + orig_name: None, + module_path: "test".to_string(), + name: "boom".to_string(), + is_async: false, + inputs: vec![], + return_type: None, + throws: Some(Type::Enum { + module_path: "test".to_string(), + name: "BoomError".to_string(), + }), + checksum: None, + docstring: None, + })); + let mut ci = ComponentInterface::from_metadata(group).unwrap(); + ci.derive_ffi_funcs().unwrap(); + let bindings = generate_bindings(&Config::default(), &ci).unwrap(); + assert!( + bindings.contains("class Boom extends BoomException implements AutoCloseable"), + "a class cannot `extends A, B`:\n{}", + error_variant_lines(&bindings) + ); + assert!( + bindings.contains("public void close()"), + "close() cannot narrow AutoCloseable's access:\n{}", + error_variant_lines(&bindings) + ); + } + + fn error_variant_lines(bindings: &str) -> String { + bindings + .lines() + .filter(|l| l.contains("class Boom") || l.contains("close()")) + .collect::>() + .join("\n") + } + #[test] fn preserves_error_type_named_error() { // create a metadata group with an error enum named only "Error" and a @@ -2197,23 +2419,37 @@ mod tests { ); } - #[test] - fn set_field_on_an_enum_variant_is_package_qualified() { - // A variant record shadows a top-level type of the same name, so variant field types are - // package-qualified. `Vec` is the control: it already recurses, `HashSet` does not. + fn point_type() -> Type { + Type::Record { + module_path: "test".to_string(), + name: "Point".to_string(), + } + } + + fn field(name: &str, ty: Type) -> FieldMetadata { + FieldMetadata { + orig_name: None, + name: name.to_string(), + ty, + default: None, + docstring: None, + } + } + + /// Renders `enum Shape { Point, Group { ..fields } }` alongside a `Point` record, and returns + /// the `Group` variant's declaration on one line. + /// + /// The variant named `Point` shadows the top-level `Point` from inside the sealed interface, + /// so any field type that is not package-qualified resolves to the wrong `Point` and fails to + /// compile. + fn group_variant_decl(fields: Vec) -> String { let mut group = test_group(); group.add_item(Metadata::Record(RecordMetadata { orig_name: None, module_path: "test".to_string(), name: "Point".to_string(), remote: false, - fields: vec![FieldMetadata { - orig_name: None, - name: "x".to_string(), - ty: Type::Int32, - default: None, - docstring: None, - }], + fields: vec![field("x", Type::Int32)], docstring: None, })); group.add_item(Metadata::Enum(EnumMetadata { @@ -2234,32 +2470,7 @@ mod tests { orig_name: None, name: "Group".to_string(), discr: None, - fields: vec![ - FieldMetadata { - orig_name: None, - name: "members".to_string(), - ty: Type::Set { - inner_type: Box::new(Type::Record { - module_path: "test".to_string(), - name: "Point".to_string(), - }), - }, - default: None, - docstring: None, - }, - FieldMetadata { - orig_name: None, - name: "ordered".to_string(), - ty: Type::Sequence { - inner_type: Box::new(Type::Record { - module_path: "test".to_string(), - name: "Point".to_string(), - }), - }, - default: None, - docstring: None, - }, - ], + fields, docstring: None, }, ], @@ -2292,23 +2503,418 @@ mod tests { .iter() .position(|line| line.contains("record Group(")) .unwrap_or_else(|| panic!("no Group variant in:\n{bindings}")); - let variant_decl = lines[start..] + lines[start..] .iter() .take_while(|line| !line.contains("implements")) .chain(lines[start..].iter().find(|l| l.contains("implements"))) .copied() .collect::>() - .join(" "); - let variant_decl = variant_decl.as_str(); + .join(" ") + } + + #[test] + fn set_field_on_an_enum_variant_is_package_qualified() { + let decl = group_variant_decl(vec![ + field( + "members", + Type::Set { + inner_type: Box::new(point_type()), + }, + ), + field( + "ordered", + Type::Sequence { + inner_type: Box::new(point_type()), + }, + ), + ]); + + assert!( + decl.contains("java.util.List"), + "Vec should be qualified, got: {decl}" + ); + assert!( + decl.contains("java.util.Set"), + "HashSet should be qualified too, got: {decl}" + ); + } + + #[test] + fn box_field_on_an_enum_variant_is_package_qualified() { + let decl = group_variant_decl(vec![field( + "boxed", + Type::Box { + inner_type: Box::new(point_type()), + }, + )]); + + assert!( + decl.contains("uniffi.Point boxed"), + "Box should be qualified, got: {decl}" + ); + } + + #[test] + fn hashed_field_on_an_enum_variant_keeps_the_boxed_list() { + let decl = group_variant_decl(vec![ + field( + "members", + Type::Set { + inner_type: Box::new(Type::Sequence { + inner_type: Box::new(Type::Int32), + }), + }, + ), + field( + "keyed", + Type::Map { + key_type: Box::new(Type::Sequence { + inner_type: Box::new(Type::Int32), + }), + value_type: Box::new(Type::Sequence { + inner_type: Box::new(Type::Int32), + }), + }, + ), + ]); + + assert!( + decl.contains("java.util.Set>"), + "Set element should stay boxed, got: {decl}" + ); + assert!( + decl.contains("java.util.Map, int[]>"), + "only the Map key is hashed, got: {decl}" + ); + } + + #[test] + fn hashed_primitive_array_stays_boxed_in_a_signature() { + let mut group = test_group(); + group.add_item(Metadata::Func(FnMetadata { + orig_name: None, + module_path: "test".to_string(), + name: "hashed".to_string(), + is_async: false, + inputs: vec![ + FnParamMetadata { + name: "set".to_string(), + ty: Type::Set { + inner_type: Box::new(Type::Sequence { + inner_type: Box::new(Type::Int32), + }), + }, + by_ref: false, + optional: false, + default: None, + }, + FnParamMetadata { + name: "map".to_string(), + ty: Type::Map { + key_type: Box::new(Type::Sequence { + inner_type: Box::new(Type::Int32), + }), + value_type: Box::new(Type::Sequence { + inner_type: Box::new(Type::Float64), + }), + }, + by_ref: false, + optional: false, + default: None, + }, + ], + return_type: None, + throws: None, + checksum: None, + docstring: None, + })); + let mut ci = ComponentInterface::from_metadata(group).unwrap(); + ci.derive_ffi_funcs().unwrap(); + let bindings = generate_bindings(&Config::default(), &ci).unwrap(); + + assert!( + bindings.contains( + "hashed(java.util.Set> set, \ + java.util.Map, double[]> map)" + ), + "hashed positions should stay boxed, values should not:\n{}", + bindings + .lines() + .filter(|l| l.contains("hashed(")) + .collect::>() + .join("\n") + ); + // The boxed rendering needs a converter the array helper does not provide. + assert!( + bindings.contains("enum FfiConverterSequenceInteger"), + "expected the generic sequence converter alongside FfiConverterInt32Array" + ); + assert!( + bindings.contains("enum FfiConverterInt32Array"), + "expected the array helper to still be emitted" + ); + } + + fn seq(inner: Type) -> Type { + Type::Sequence { + inner_type: Box::new(inner), + } + } + + fn param(name: &str, ty: Type) -> FnParamMetadata { + FnParamMetadata { + name: name.to_string(), + ty, + by_ref: false, + optional: false, + default: None, + } + } + + /// Bindings for an interface holding a single function of these parameters. + fn bindings_for_fn(inputs: Vec, config: &Config) -> String { + let mut group = test_group(); + group.add_item(Metadata::Func(FnMetadata { + orig_name: None, + module_path: "test".to_string(), + name: "subject".to_string(), + is_async: false, + inputs, + return_type: None, + throws: None, + checksum: None, + docstring: None, + })); + let mut ci = ComponentInterface::from_metadata(group).unwrap(); + ci.derive_ffi_funcs().unwrap(); + generate_bindings(config, &ci).unwrap() + } + + #[test] + fn nested_hashed_sequences_stay_boxed_at_every_depth() { + let bindings = bindings_for_fn( + vec![param( + "nested", + Type::Set { + inner_type: Box::new(seq(seq(Type::Int32))), + }, + )], + &Config::default(), + ); + + assert!( + bindings.contains( + "java.util.Set>> nested" + ), + "the inner array must stay boxed too, or contains() breaks one level down:\n{}", + signature_lines(&bindings) + ); + assert!( + bindings.contains("enum FfiConverterSequenceSequenceInteger"), + "expected the hashed converter for the outer sequence" + ); + assert!( + bindings.contains("enum FfiConverterSequenceInteger"), + "expected the hashed converter for the inner sequence" + ); + } + + #[test] + fn optional_wrapped_hashed_element_stays_boxed() { + let bindings = bindings_for_fn( + vec![param( + "opt", + Type::Set { + inner_type: Box::new(Type::Optional { + inner_type: Box::new(seq(Type::Int32)), + }), + }, + )], + &Config::default(), + ); + + assert!( + bindings.contains("java.util.Set> opt"), + "an optional element is invisibly nullable but must stay boxed:\n{}", + signature_lines(&bindings) + ); + assert!( + bindings.contains("enum FfiConverterOptionalSequenceInteger"), + "expected the hashed optional converter" + ); + assert!( + bindings.contains("enum FfiConverterSequenceInteger"), + "expected the hashed converter for the wrapped sequence" + ); + } + + #[test] + fn bytes_map_key_stays_boxed() { + let bindings = bindings_for_fn( + vec![param( + "keyed", + Type::Map { + key_type: Box::new(Type::Bytes), + value_type: Box::new(Type::String), + }, + )], + &Config::default(), + ); + + assert!( + bindings + .contains("java.util.Map, java.lang.String> keyed"), + "byte[] keys never match on lookup:\n{}", + signature_lines(&bindings) + ); + assert!( + bindings.contains("enum FfiConverterSequenceByte"), + "expected the byte sequence converter for the hashed key" + ); + } + + #[test] + fn hashed_bytes_reuses_an_existing_byte_sequence_converter() { + let bindings = bindings_for_fn( + vec![ + param( + "hashed", + Type::Set { + inner_type: Box::new(Type::Bytes), + }, + ), + param("plain", seq(Type::Int8)), + ], + &Config::default(), + ); + + assert_eq!( + bindings.matches("enum FfiConverterSequenceByte ").count(), + 1, + "hashed bytes and Vec share a converter; two copies would not compile" + ); + } + + fn signature_lines(bindings: &str) -> String { + bindings + .lines() + .filter(|l| l.contains("subject(")) + .collect::>() + .join("\n") + } + + fn holder_group() -> MetadataGroup { + let mut group = test_group(); + group.add_item(Metadata::Record(RecordMetadata { + orig_name: None, + module_path: "test".to_string(), + name: "Holder".to_string(), + remote: false, + fields: vec![ + field("label", Type::String), + field("data", seq(Type::Int32)), + field("nested", seq(seq(Type::Int32))), + ], + docstring: None, + })); + group + } + + #[test] + fn record_with_array_fields_gets_value_equality() { + let mut ci = ComponentInterface::from_metadata(holder_group()).unwrap(); + ci.derive_ffi_funcs().unwrap(); + let bindings = generate_bindings(&Config::default(), &ci).unwrap(); + + assert!( + bindings.contains("UniffiDeepValue.equals(data, t.data)"), + "array fields must compare by value:\n{bindings}" + ); + assert!( + bindings.contains( + "java.util.Objects.hash(label, UniffiDeepValue.hashCode(data), \ + UniffiDeepValue.hashCode(nested))" + ), + "array fields must hash by value:\n{bindings}" + ); + } + + #[test] + fn immutable_record_with_array_fields_overrides_equality() { + let mut ci = ComponentInterface::from_metadata(holder_group()).unwrap(); + ci.derive_ffi_funcs().unwrap(); + let config = Config { + generate_immutable_records: Some(true), + ..Config::default() + }; + let bindings = generate_bindings(&config, &ci).unwrap(); + + assert!( + bindings.contains("public record Holder("), + "expected an immutable record:\n{bindings}" + ); + assert!( + bindings.contains("UniffiDeepValue.equals(data, t.data)"), + "the record-generated equals sees array components by identity:\n{bindings}" + ); + } + + #[test] + fn custom_type_wrapper_over_arrays_gets_value_equality() { + let mut group = test_group(); + group.add_item(Metadata::CustomType(CustomTypeMetadata { + module_path: "test".to_string(), + name: "IntsKey".to_string(), + orig_name: None, + builtin: seq(Type::Int32), + docstring: None, + })); + let mut ci = ComponentInterface::from_metadata(group).unwrap(); + ci.derive_ffi_funcs().unwrap(); + let bindings = generate_bindings(&Config::default(), &ci).unwrap(); + + assert!( + bindings.contains("UniffiDeepValue.equals(value, t.value)"), + "the wrapper record's array component must compare by value:\n{bindings}" + ); + assert!( + bindings.contains("UniffiDeepValue.hashCode(value)"), + "the wrapper record's hashCode must match its equals:\n{bindings}" + ); + } + + #[test] + fn enum_variant_with_array_field_overrides_equality() { + let mut group = test_group(); + group.add_item(Metadata::Enum(EnumMetadata { + orig_name: None, + module_path: "test".to_string(), + name: "Payload".to_string(), + shape: EnumShape::Enum, + remote: false, + variants: vec![VariantMetadata { + orig_name: None, + name: "Ints".to_string(), + discr: None, + // A tuple variant, so the positional v1 name has to thread through. + fields: vec![field("", seq(Type::Int32))], + docstring: None, + }], + discr_type: None, + non_exhaustive: false, + docstring: None, + })); + let mut ci = ComponentInterface::from_metadata(group).unwrap(); + ci.derive_ffi_funcs().unwrap(); + let bindings = generate_bindings(&Config::default(), &ci).unwrap(); assert!( - variant_decl.contains("java.util.List"), - "Vec should be qualified, got: {variant_decl}" + bindings.contains("UniffiDeepValue.equals(v1, t.v1)"), + "the variant record's equals sees array components by identity:\n{bindings}" ); assert!( - variant_decl.contains("java.util.Set"), - "HashSet should be qualified too, but the nested `record Point` shadows the \ - top-level one, got: {variant_decl}" + bindings.contains("java.util.Objects.hash(UniffiDeepValue.hashCode(v1))"), + "the variant record's hashCode must match its equals:\n{bindings}" ); } diff --git a/src/templates/CustomTypeTemplate.java b/src/templates/CustomTypeTemplate.java index e4608f7..d55e0d3 100644 --- a/src/templates/CustomTypeTemplate.java +++ b/src/templates/CustomTypeTemplate.java @@ -11,6 +11,18 @@ public record {{ type_name }}( {{ builtin|type_name(ci, config) }} value ) { + {%- if builtin|contains_array_rendering %} + {#- The record-generated equals/hashCode compare array components by identity. -#} + @Override + public boolean equals(java.lang.Object other) { + return other instanceof {{ type_name }} t && UniffiDeepValue.equals(value, t.value); + } + + @Override + public int hashCode() { + return UniffiDeepValue.hashCode(value); + } + {%- endif %} } package {{ package_name }}; diff --git a/src/templates/EnumTemplate.java b/src/templates/EnumTemplate.java index f73a93f..f6539e5 100644 --- a/src/templates/EnumTemplate.java +++ b/src/templates/EnumTemplate.java @@ -67,6 +67,12 @@ public void write({{ type_name }} value, java.nio.ByteBuffer buf) { {%- call java::docstring(e, 0) %}{% endcall %} public sealed interface {{ type_name }}{% if uniffi_trait_methods.ord_cmp.is_some() %}{% if contains_object_references %} extends AutoCloseable, Comparable<{{ type_name }}>{% else %} extends Comparable<{{ type_name }}>{% endif %}{% else %}{% if contains_object_references %} extends AutoCloseable{% endif %}{% endif %} { + {%- if contains_object_references %} + {#- Redeclared to drop `throws Exception`, so try-with-resources on the interface type does not + force callers to handle a checked exception no variant can throw. -#} + @Override + void close(); + {% endif %} {% for variant in e.variants() -%} {%- call java::docstring(variant, 4) %}{% endcall %} {% if !variant.has_fields() -%} @@ -96,6 +102,26 @@ public void close() { {% endif %} {# Re-get trait methods for each variant to avoid move issues #} {%- let variant_trait_methods = e.uniffi_trait_methods() %} + {#- The record-generated equals/hashCode compare array components by identity. -#} + {%- if variant_trait_methods.eq_eq.is_none() && variant.fields()|has_array_rendered_field %} + @Override + public boolean equals(java.lang.Object other) { + if (other instanceof {{ variant|type_name(ci, config) }}) { + {{ variant|type_name(ci, config) }} t = ({{ variant|type_name(ci, config) }}) other; + return ({% for field in variant.fields() %}{% let fname = field|field_java_name(loop.index) %} + {{ field|boxed_equals_expr(fname, "t." ~ fname) }}{% if !loop.last %} && {% endif %} + {% endfor %} + ); + }; + return false; + } + {%- endif %} + {%- if variant_trait_methods.hash_hash.is_none() && variant.fields()|has_array_rendered_field %} + @Override + public int hashCode() { + return java.util.Objects.hash({% for field in variant.fields() %}{% let fname = field|field_java_name(loop.index) %}{{ field|hash_element_expr(fname) }}{% if !loop.last %}, {% endif %}{% endfor %}); + } + {%- endif %} {% call java::uniffi_trait_impls(variant_trait_methods) %}{% endcall %} } {%- endif %} diff --git a/src/templates/ErrorTemplate.java b/src/templates/ErrorTemplate.java index 668749e..6a36f9a 100644 --- a/src/templates/ErrorTemplate.java +++ b/src/templates/ErrorTemplate.java @@ -14,7 +14,8 @@ public class {{ type_name }} extends java.lang.Exception { {% for variant in e.variants() -%} {%- call java::docstring(variant, 4) %}{% endcall %} - public static class {{ variant|error_variant_name }} extends {{ type_name }}{% if contains_object_references %}, AutoCloseable{% endif %} { + {#- A flat variant carries only a message, so it never owns an object to close. -#} + public static class {{ variant|error_variant_name }} extends {{ type_name }} { public {{ variant|error_variant_name }}(java.lang.String message) { super(message); } @@ -33,7 +34,7 @@ public class {{ type_name }} extends java.lang.Exception { {% for variant in e.variants() -%} {%- call java::docstring(variant, 4) %}{% endcall %} {%- let variant_name = variant|error_variant_name %} - public static class {{ variant_name }} extends {{ type_name }}{% if contains_object_references %}, AutoCloseable{% endif %} { + public static class {{ variant_name }} extends {{ type_name }}{% if contains_object_references %} implements AutoCloseable{% endif %} { {% for field in variant.fields() -%} {%- call java::docstring(field, 8) %}{% endcall %} {{ field|type_name(ci, config) }} {% call java::field_name(field, loop.index) %}{% endcall %}; @@ -66,7 +67,7 @@ public static class {{ variant_name }} extends {{ type_name }}{% if contains_obj {% if contains_object_references %} @Override - void close() { + public void close() { {%- if variant.has_fields() %} {% call java::destroy_fields(variant) %}{% endcall %} {% else -%} diff --git a/src/templates/Helpers.java b/src/templates/Helpers.java index e7ea060..769f1e4 100644 --- a/src/templates/Helpers.java +++ b/src/templates/Helpers.java @@ -294,3 +294,59 @@ static void uniffiTraitInterfaceCallWithError } } } + +package {{ config.package_name() }}; + +// Value equality for generated fields whose rendering holds a Java array at some depth: +// primitive arrays from `Vec`, `byte[]` from `bytes`, possibly under lists, maps, or null. +// Arrays compare and hash by identity, so `Objects.equals` is wrong for them anywhere it would +// reach one. Map keys never hold arrays (hashed positions render boxed), so key lookups here +// match by value. +final class UniffiDeepValue { + private UniffiDeepValue() {} + + static boolean equals(java.lang.Object a, java.lang.Object b) { + if (a == b) return true; + if (a instanceof java.util.List x && b instanceof java.util.List y) { + if (x.size() != y.size()) return false; + java.util.Iterator i = x.iterator(); + java.util.Iterator j = y.iterator(); + while (i.hasNext()) { + if (!equals(i.next(), j.next())) return false; + } + return true; + } + if (a instanceof java.util.Map x && b instanceof java.util.Map y) { + if (x.size() != y.size()) return false; + for (java.util.Map.Entry e : x.entrySet()) { + if (!y.containsKey(e.getKey()) || !equals(e.getValue(), y.get(e.getKey()))) { + return false; + } + } + return true; + } + // Covers every primitive array type, so no per-type Arrays.equals arms are needed. + return java.util.Objects.deepEquals(a, b); + } + + static int hashCode(java.lang.Object o) { + if (o instanceof java.util.List x) { + // The List.hashCode contract, with array-aware element hashes. + int result = 1; + for (java.lang.Object e : x) { + result = 31 * result + hashCode(e); + } + return result; + } + if (o instanceof java.util.Map x) { + // The Map.hashCode contract, with array-aware value hashes. + int result = 0; + for (java.util.Map.Entry e : x.entrySet()) { + result += java.util.Objects.hashCode(e.getKey()) ^ hashCode(e.getValue()); + } + return result; + } + // The single-element wrapper covers null and every primitive array type. + return java.util.Arrays.deepHashCode(new java.lang.Object[] { o }); + } +} diff --git a/src/templates/RecordTemplate.java b/src/templates/RecordTemplate.java index 3d88095..c47e44c 100644 --- a/src/templates/RecordTemplate.java +++ b/src/templates/RecordTemplate.java @@ -19,6 +19,26 @@ public void close() { {% call java::destroy_fields(rec) %}{% endcall %} } {% endif %} + {#- The record-generated equals/hashCode compare array components by identity. -#} + {%- if uniffi_trait_methods.eq_eq.is_none() && rec.fields()|has_array_rendered_field %} + @Override + public boolean equals(java.lang.Object other) { + if (other instanceof {{ type_name }}) { + {{ type_name }} t = ({{ type_name }}) other; + return ({% for field in rec.fields() %}{% let field_var_name = field.name()|var_name %} + {{ field|equals_expr(field_var_name, "t." ~ field_var_name) }}{% if !loop.last%} && {% endif %} + {% endfor %} + ); + }; + return false; + } + {%- endif %} + {%- if uniffi_trait_methods.hash_hash.is_none() && rec.fields()|has_array_rendered_field %} + @Override + public int hashCode() { + return java.util.Objects.hash({% for field in rec.fields() %}{{ field|hash_element_expr(field.name()|var_name) }}{% if !loop.last %}, {% endif %}{% endfor %}); + } + {%- endif %} {% for meth in rec.methods() -%} {%- call java::func_decl("public", "", meth, 4) %}{% endcall %} {% endfor %} @@ -83,11 +103,7 @@ public boolean equals(java.lang.Object other) { {%- if uniffi_trait_methods.hash_hash.is_none() %} @Override public int hashCode() { - int result = 17; - {%- for field in rec.fields() %} - result = 31 * result + {{ field|hash_code_expr(field.name()|var_name) }}; - {%- endfor %} - return result; + return java.util.Objects.hash({% for field in rec.fields() %}{{ field|hash_element_expr(field.name()|var_name) }}{% if !loop.last %}, {% endif %}{% endfor %}); } {%- endif %} diff --git a/src/templates/macros.java b/src/templates/macros.java index 0efec97..57de17f 100644 --- a/src/templates/macros.java +++ b/src/templates/macros.java @@ -14,6 +14,11 @@ {% else %} {%- call to_raw_ffi_call(func) %}{% endcall %}; {% endif %} + {#- The wrap below exists only to launder a declared error, which is checked, across + `Function.apply`. Rethrowing unchecked first keeps a method's exceptions identical to the + same call made as a free function, which has no wrap at all. -#} + } catch (java.lang.RuntimeException _uniffi_ex) { + throw _uniffi_ex; } catch (java.lang.Exception _uniffi_ex) { throw new java.lang.RuntimeException(_uniffi_ex); } @@ -231,7 +236,7 @@ {%- macro destroy_fields(member) %} AutoCloseableHelper.close( {%- for field in member.fields() %} - this.{{ field.name()|var_name }}{%- if !loop.last %}, {% endif -%} + this.{% call field_name(field, loop.index) %}{% endcall %}{%- if !loop.last %}, {% endif -%} {% endfor -%}); {%- endmacro -%} diff --git a/tests/scripts/TestEnumTypes.java b/tests/scripts/TestEnumTypes.java new file mode 100644 index 0000000..db3420f --- /dev/null +++ b/tests/scripts/TestEnumTypes.java @@ -0,0 +1,60 @@ +/* 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/. */ + +import uniffi.enum_types.*; + +public class TestEnumTypes { + public static void main(String[] args) { + testFlatEnums(); + testDiscriminants(); + testFieldedEnums(); + testBoxedVariant(); + + System.out.println("All enum type tests passed!"); + } + + static void testFlatEnums() { + assert EnumTypes.getAnimal(Animal.CAT) == Animal.CAT : "flat enum roundtrip failed"; + assert EnumTypes.getAnimal(null) == Animal.DOG : "absent optional should fall back to Dog"; + } + + static void testDiscriminants() { + // A repr wider than int has to carry an `L` suffix through to the Java literal. + assert AnimalLargeUInt.values().length == 2 : "expected two large-uint variants"; + assert AnimalSignedInt.values().length == 5 : "expected five signed variants"; + assert AnimalUInt.valueOf("DOG") == AnimalUInt.DOG : "unsigned repr enum should name DOG"; + assert AnimalNoReprInt.values().length == 2 : "expected two no-repr variants"; + } + + static void testFieldedEnums() { + // Tuple variants get positional component names, which the generated close() has to use. + AnimalEnum cat = EnumTypes.getAnimalEnum(Animal.CAT); + assert cat instanceof AnimalEnum.Cat : "expected the Cat variant"; + assert ((AnimalEnum.Cat) cat).v1().name().equals("cat") : "unexpected Cat payload"; + + // Closing through the interface, which has to redeclare close() without `throws Exception` + // for this to compile. + try (AnimalEnum dog = EnumTypes.getAnimalEnum(Animal.DOG)) { + assert dog instanceof AnimalEnum.Dog : "expected the Dog variant"; + assert ((AnimalEnum.Dog) dog).v1().getRecord().name().equals("dog") + : "unexpected Dog payload"; + } + } + + static void testBoxedVariant() { + EnumWithBoxedVariant boxed = EnumTypes.createBoxedEnum("hello"); + assert boxed instanceof EnumWithBoxedVariant.Boxed : "expected the Boxed variant"; + + // Box exists only in the scaffolding, so the variant field is typed as plain T. + BoxedContent content = ((EnumWithBoxedVariant.Boxed) boxed).v1(); + assert content.value().equals("hello") : "unexpected boxed payload"; + + assert EnumTypes.getBoxedEnumValue(boxed).equals("hello") : "boxed enum value roundtrip failed"; + assert EnumTypes.getBoxedEnumValue(new EnumWithBoxedVariant.Empty()).equals("empty") + : "empty variant should report empty"; + + assert EnumTypes.roundtripBoxedRecord(new BoxedContent("direct")).value().equals("direct") + : "Box roundtrip failed"; + } +} diff --git a/tests/scripts/TestPrimitiveArrays.java b/tests/scripts/TestPrimitiveArrays.java index f2dd079..28fc4bf 100644 --- a/tests/scripts/TestPrimitiveArrays.java +++ b/tests/scripts/TestPrimitiveArrays.java @@ -3,7 +3,13 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import uniffi.primitive_arrays.*; +import java.util.ArrayList; import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; public class TestPrimitiveArrays { public static void main(String[] args) { @@ -16,10 +22,95 @@ public static void main(String[] args) { testUnsignedArrays(); testEmptyArrays(); testLargeArrays(); + testHashedPositions(); + testNestedHashedPositions(); + testValueEquality(); System.out.println("All primitive array tests passed!"); } + static void testNestedHashedPositions() { + Set>> nested = new HashSet<>(List.of( + List.of(List.of(1, 2), List.of(3)), + List.of(List.of(4)))); + Set>> nestedResult = PrimitiveArrays.roundtripNestedInt32Set(nested); + assert nestedResult.equals(nested) : "Set>> roundtrip failed"; + assert nestedResult.contains(List.of(List.of(1, 2), List.of(3))) + : "nested contains should match by value"; + + Set> optional = new HashSet<>(); + optional.add(List.of(1, 2, 3)); + optional.add(null); + Set> optionalResult = PrimitiveArrays.roundtripOptionalInt32Set(optional); + assert optionalResult.equals(optional) : "Set with an absent element roundtrip failed"; + assert optionalResult.contains(List.of(1, 2, 3)) : "optional contains should match by value"; + assert optionalResult.contains(null) : "the absent element should survive the roundtrip"; + } + + static void testValueEquality() { + // Guards identity-based equals/hashCode on generated types holding arrays, which would + // let value-equal instances coexist in sets and never match on contains. + IntsHolder a1 = holder(); + IntsHolder a2 = holder(); + assert a1.equals(a2) : "holders with equal arrays should be equal"; + assert a1.hashCode() == a2.hashCode() : "equal holders should hash alike"; + + Set holders = new HashSet<>(List.of(a1, a2)); + assert holders.size() == 1 : "value-equal holders should collapse"; + Set holderResult = PrimitiveArrays.roundtripHolderSet(holders); + assert holderResult.equals(holders) : "Set roundtrip failed"; + assert holderResult.contains(holder()) : "contains should match holders by value"; + + IntsEnum ints = new IntsEnum.Ints(new int[] { 5, 6 }); + assert ints.equals(new IntsEnum.Ints(new int[] { 5, 6 })) + : "variants with equal arrays should be equal"; + assert ints.hashCode() == new IntsEnum.Ints(new int[] { 5, 6 }).hashCode() + : "equal variants should hash alike"; + assert PrimitiveArrays.roundtripIntsEnum(ints).equals(ints) : "IntsEnum roundtrip failed"; + assert !ints.equals(new IntsEnum.Empty()) : "different variants should not be equal"; + + IntsKey k1 = new IntsKey(new int[] { 7, 8 }); + assert k1.equals(new IntsKey(new int[] { 7, 8 })) + : "custom wrappers with equal arrays should be equal"; + Set keys = new HashSet<>(List.of(k1, new IntsKey(new int[] { 7, 8 }))); + assert keys.size() == 1 : "value-equal custom wrappers should collapse"; + Set keyResult = PrimitiveArrays.roundtripKeySet(keys); + assert keyResult.equals(keys) : "Set roundtrip failed"; + assert keyResult.contains(new IntsKey(new int[] { 7, 8 })) + : "contains should match custom wrappers by value"; + } + + static IntsHolder holder() { + return new IntsHolder("a", new int[] { 1, 2 }, List.of(new int[] { 3, 4 })); + } + + static void testHashedPositions() { + // Guards the `int[]` lens being applied here too, which compiles but never matches on + // lookup and lets Java hold duplicates Rust collapsed. + Set> set = new HashSet<>(List.of(List.of(1, 2, 3), List.of(4, 5))); + Set> setResult = PrimitiveArrays.roundtripInt32Set(set); + assert setResult.equals(set) : "Set> roundtrip failed"; + assert setResult.contains(List.of(1, 2, 3)) : "contains should match by value"; + + Set> duplicated = new HashSet<>( + List.of(List.of(1, 2, 3), new ArrayList<>(List.of(1, 2, 3)))); + assert duplicated.size() == 1 : "value-equal lists should already collapse in Java"; + + Map, String> map = new HashMap<>(); + map.put(List.of(1, 2, 3), "first"); + map.put(List.of(4, 5), "second"); + Map, String> mapResult = PrimitiveArrays.roundtripInt32KeyedMap(map); + assert mapResult.equals(map) : "Map, String> roundtrip failed"; + assert "first".equals(mapResult.get(List.of(1, 2, 3))) : "get should match by value"; + + // A map *value* is never hashed, so it keeps the double[] rendering. + Map valued = new HashMap<>(); + valued.put("a", new double[] { 1.5, 2.5 }); + Map valuedResult = PrimitiveArrays.roundtripFloat64ValuedMap(valued); + assert Arrays.equals(valuedResult.get("a"), new double[] { 1.5, 2.5 }) + : "double[] map value roundtrip failed"; + } + static void testFloat32Arrays() { // Test float[] roundtrip float[] floats = new float[] { 1.0f, 2.5f, 3.14159f, -0.5f, Float.MAX_VALUE, Float.MIN_VALUE }; diff --git a/tests/scripts/TestZeroCopy.java b/tests/scripts/TestZeroCopy.java index 2df6eff..29acad7 100644 --- a/tests/scripts/TestZeroCopy.java +++ b/tests/scripts/TestZeroCopy.java @@ -70,6 +70,20 @@ public static void main(String[] args) { } assert threw : "heap ByteBuffer should be rejected"; + // A method routes through callWithHandle, whose wrap must not relabel the same misuse. + try (Checksummer checksummer = new Checksummer()) { + assert checksummer.checksumBorrowed(direct(bytes)) == 15 : "method checksum should be 15"; + + boolean methodThrew = false; + try { + checksummer.checksumBorrowed(ByteBuffer.wrap(bytes)); + } catch (IllegalArgumentException e) { + methodThrew = true; + assert e.getMessage().contains("direct ByteBuffer") : "message should say what to do"; + } + assert methodThrew : "heap ByteBuffer should be rejected from a method too"; + } + // Larger payload, exercising the slab across many lowerings. byte[] big = new byte[64 * 1024]; for (int i = 0; i < big.length; i++) big[i] = (byte) (i & 0x7F); diff --git a/tests/tests.rs b/tests/tests.rs index 1eff5a6..894d0a0 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -380,6 +380,7 @@ fixture_tests! { // (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_enum_types, "uniffi-fixture-enum-types", "scripts/TestEnumTypes.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"), From 468b3aa29814ae6cba32b02ec286e34af97b695f Mon Sep 17 00:00:00 2001 From: Murph Murphy Date: Mon, 24 Aug 2026 22:50:30 -0600 Subject: [PATCH 9/9] Undo some of the DRY attempts that introduced allocations to otherwise allocation free paths. --- fixtures/primitive-arrays/src/lib.rs | 12 ++ src/gen_java/compounds.rs | 31 ++-- src/gen_java/mod.rs | 187 ++++++++++++++++++------- src/templates/EnumTemplate.java | 7 +- src/templates/ErrorTemplate.java | 12 +- src/templates/Helpers.java | 13 +- src/templates/RecordTemplate.java | 12 +- src/templates/macros.java | 6 +- tests/scripts/TestPrimitiveArrays.java | 9 ++ 9 files changed, 216 insertions(+), 73 deletions(-) diff --git a/fixtures/primitive-arrays/src/lib.rs b/fixtures/primitive-arrays/src/lib.rs index c5a6dcc..d40ae22 100644 --- a/fixtures/primitive-arrays/src/lib.rs +++ b/fixtures/primitive-arrays/src/lib.rs @@ -143,6 +143,18 @@ fn roundtrip_ints_enum(data: IntsEnum) -> IntsEnum { data } +/// `f64` keeps Java's `==` out of the generated equals: NaN fields must stay reflexively equal. +#[derive(uniffi::Record, PartialEq)] +pub struct FloatHolder { + pub ratio: f64, + pub data: Vec, +} + +#[uniffi::export] +fn roundtrip_float_holder(data: FloatHolder) -> FloatHolder { + data +} + /// A custom newtype over an array-rendering builtin; its Java wrapper record must also compare /// by value. #[derive(PartialEq, Eq, Hash)] diff --git a/src/gen_java/compounds.rs b/src/gen_java/compounds.rs index 4db6554..43db271 100644 --- a/src/gen_java/compounds.rs +++ b/src/gen_java/compounds.rs @@ -146,7 +146,8 @@ impl CodeType for MapCodeType { /// deduplicates. Hashed positions render every array-producing type on the `Sequence`/`Optional` /// spine as boxed `java.util.List` instead, however deep, and `bytes` as /// `java.util.List`. That spine is exhaustive: Rust's `Hash + Eq` bounds keep -/// maps, sets, and float vectors out of hashed positions entirely. +/// `HashMap`/`HashSet` and float vectors out of hashed positions, and uniffi 0.32.0 has no +/// converters for the `BTree` collections that would otherwise qualify. /// /// `bytes` and `Vec` share a wire format (i32 length + raw bytes), so a hashed `bytes` can /// borrow the generic `Sequence` converter unchanged. @@ -259,22 +260,24 @@ pub fn contains_array_rendering(ty: &Type) -> bool { } } -/// Whether `Vec` renders as a Java primitive array. +/// The code type for `Vec` when it renders as a Java primitive array. /// /// `Int8`/`UInt8` are absent because the separate `Bytes` type owns `byte[]`. +pub fn primitive_array_code_type(inner: &Type) -> Option> { + match inner { + Type::Int16 | Type::UInt16 => Some(Box::new(Int16ArrayCodeType)), + Type::Int32 | Type::UInt32 => Some(Box::new(Int32ArrayCodeType)), + Type::Int64 | Type::UInt64 => Some(Box::new(Int64ArrayCodeType)), + Type::Float32 => Some(Box::new(Float32ArrayCodeType)), + Type::Float64 => Some(Box::new(Float64ArrayCodeType)), + Type::Boolean => Some(Box::new(BooleanArrayCodeType)), + _ => None, + } +} + +/// Whether `Vec` renders as a Java primitive array. pub fn renders_as_primitive_array(inner: &Type) -> bool { - matches!( - inner, - Type::Int16 - | Type::UInt16 - | Type::Int32 - | Type::UInt32 - | Type::Int64 - | Type::UInt64 - | Type::Float32 - | Type::Float64 - | Type::Boolean - ) + primitive_array_code_type(inner).is_some() } // Primitive array types for sequences of primitives. diff --git a/src/gen_java/mod.rs b/src/gen_java/mod.rs index 51aaae7..827728b 100644 --- a/src/gen_java/mod.rs +++ b/src/gen_java/mod.rs @@ -1149,16 +1149,12 @@ impl AsCodeType for Type { Type::Optional { inner_type } => { Box::new(compounds::OptionalCodeType::new((*inner_type).clone())) } - Type::Sequence { inner_type } => match inner_type.as_ref() { - Type::Int16 | Type::UInt16 => Box::new(compounds::Int16ArrayCodeType), - Type::Int32 | Type::UInt32 => Box::new(compounds::Int32ArrayCodeType), - Type::Int64 | Type::UInt64 => Box::new(compounds::Int64ArrayCodeType), - Type::Float32 => Box::new(compounds::Float32ArrayCodeType), - Type::Float64 => Box::new(compounds::Float64ArrayCodeType), - Type::Boolean => Box::new(compounds::BooleanArrayCodeType), - // Int8/UInt8 sequences still use SequenceCodeType; the separate Bytes type handles byte[] - _ => Box::new(compounds::SequenceCodeType::new((*inner_type).clone())), - }, + Type::Sequence { inner_type } => { + match compounds::primitive_array_code_type(&inner_type) { + Some(array_type) => array_type, + None => Box::new(compounds::SequenceCodeType::new((*inner_type).clone())), + } + } Type::Set { inner_type } => { Box::new(compounds::SetCodeType::new((*inner_type).clone())) } @@ -1277,18 +1273,16 @@ mod filters { Ok(inner) } } - Type::Sequence { inner_type } => match inner_type.as_ref() { - Type::Int16 | Type::UInt16 => Ok("short[]".to_string()), - Type::Int32 | Type::UInt32 => Ok("int[]".to_string()), - Type::Int64 | Type::UInt64 => Ok("long[]".to_string()), - Type::Float32 => Ok("float[]".to_string()), - Type::Float64 => Ok("double[]".to_string()), - Type::Boolean => Ok("boolean[]".to_string()), - _ => Ok(format!( - "java.util.List<{}>", - fully_qualified_type_label(inner_type, ci, config)? - )), - }, + Type::Sequence { inner_type } => { + // Primitive array labels are already unqualified. + match compounds::primitive_array_code_type(inner_type) { + Some(array_type) => Ok(array_type.type_label(ci, config)), + None => Ok(format!( + "java.util.List<{}>", + fully_qualified_type_label(inner_type, ci, config)? + )), + } + } Type::Set { inner_type } => Ok(format!( "java.util.Set<{}>", hashed_fully_qualified_type_label(inner_type, ci, config)? @@ -1489,10 +1483,21 @@ mod filters { // Byte and Short need explicit casts in Java Type::Int8 | Type::UInt8 => Ok(format!("(byte){}", base10)), Type::Int16 | Type::UInt16 => Ok(format!("(short){}", base10)), - Type::Int32 | Type::UInt32 => Ok(base10), - // Without the suffix an `int` literal is parsed first, and anything past - // `i32::MAX` fails to compile. - Type::Int64 | Type::UInt64 => Ok(format!("{}L", base10)), + Type::Int32 => Ok(base10), + // Java literals are signed and unsuffixed ones parse as `int`, so longs need the + // `L` suffix, and the upper half of an unsigned repr only fits as hex, which + // carries the bit pattern into the signed type. + Type::UInt32 => match base10.parse::() { + Ok(v) if v > i32::MAX as u32 => Ok(format!("0x{:X}", v)), + Ok(_) => Ok(base10), + Err(_) => Err(to_askama_error(&format!("invalid u32 literal: {base10}"))), + }, + Type::Int64 => Ok(format!("{}L", base10)), + Type::UInt64 => match base10.parse::() { + Ok(v) if v > i64::MAX as u64 => Ok(format!("0x{:X}L", v)), + Ok(_) => Ok(format!("{}L", base10)), + Err(_) => Err(to_askama_error(&format!("invalid u64 literal: {base10}"))), + }, _ => Err(to_askama_error("Only ints are supported.")), } } else { @@ -2037,6 +2042,9 @@ mod filters { /// Generates an equality expression for comparing two values of a field's type. /// For primitives: returns "left == right" + /// For floats: "Type.compare(left, right) == 0", which agrees with `Type.hashCode` on NaN + /// and signed zero where `==` does not + /// For other primitives: "left == right" /// For array-holding types (see [`compounds::contains_array_rendering`]): "UniffiDeepValue.equals(left, right)" /// For objects: returns "java.util.Objects.equals(left, right)" #[askama::filter_fn] @@ -2046,20 +2054,49 @@ mod filters { left: L, right: R, ) -> Result { - if field.as_codetype().type_label_primitive().is_some() { - Ok(format!("{} == {}", left, right)) - } else if compounds::contains_array_rendering(&field.as_type()) { - Ok(format!("UniffiDeepValue.equals({}, {})", left, right)) - } else { - Ok(format!("java.util.Objects.equals({}, {})", left, right)) + match field.as_type() { + Type::Float32 => Ok(format!("java.lang.Float.compare({}, {}) == 0", left, right)), + Type::Float64 => Ok(format!( + "java.lang.Double.compare({}, {}) == 0", + left, right + )), + _ if field.as_codetype().type_label_primitive().is_some() => { + Ok(format!("{} == {}", left, right)) + } + ty if compounds::contains_array_rendering(&ty) => { + Ok(format!("UniffiDeepValue.equals({}, {})", left, right)) + } + _ => Ok(format!("java.util.Objects.equals({}, {})", left, right)), } } - /// A field's element expression for a `java.util.Objects.hash(...)` call: the value itself, - /// or its `UniffiDeepValue` hash when arrays are reachable (an `Integer` hashes to itself, - /// so pre-hashing composes). + /// A field's contribution to a hash accumulation. Primitives dispatch to their boxed type's + /// static hashCode so nothing boxes; array-holding types (see + /// [`compounds::contains_array_rendering`]) go through `UniffiDeepValue`. #[askama::filter_fn] - pub fn hash_element_expr( + pub fn hash_code_expr( + field: &T, + _v: &dyn askama::Values, + value: V, + ) -> Result { + match field.as_type() { + Type::Boolean => Ok(format!("java.lang.Boolean.hashCode({})", value)), + Type::Int8 | Type::UInt8 => Ok(format!("java.lang.Byte.hashCode({})", value)), + Type::Int16 | Type::UInt16 => Ok(format!("java.lang.Short.hashCode({})", value)), + Type::Int32 | Type::UInt32 => Ok(format!("java.lang.Integer.hashCode({})", value)), + Type::Int64 | Type::UInt64 => Ok(format!("java.lang.Long.hashCode({})", value)), + Type::Float32 => Ok(format!("java.lang.Float.hashCode({})", value)), + Type::Float64 => Ok(format!("java.lang.Double.hashCode({})", value)), + ty if compounds::contains_array_rendering(&ty) => { + Ok(format!("UniffiDeepValue.hashCode({})", value)) + } + _ => Ok(format!("java.util.Objects.hashCode({})", value)), + } + } + + /// As [`hash_code_expr`], for positions whose components are reference types. + #[askama::filter_fn] + pub fn boxed_hash_code_expr( field: &T, _v: &dyn askama::Values, value: V, @@ -2067,7 +2104,7 @@ mod filters { if compounds::contains_array_rendering(&field.as_type()) { Ok(format!("UniffiDeepValue.hashCode({})", value)) } else { - Ok(value.to_string()) + Ok(format!("java.util.Objects.hashCode({})", value)) } } @@ -2130,9 +2167,9 @@ mod tests { use uniffi_bindgen::interface::ComponentInterface; use uniffi_meta::{ CallbackInterfaceMetadata, CustomTypeMetadata, EnumMetadata, EnumShape, FieldMetadata, - FnMetadata, FnParamMetadata, Metadata, MetadataGroup, MethodMetadata, NamespaceMetadata, - ObjectImpl, ObjectMetadata, ObjectTraitImplMetadata, RecordMetadata, TraitKind, - TraitMethodMetadata, Type, VariantMetadata, + FnMetadata, FnParamMetadata, LiteralMetadata, Metadata, MetadataGroup, MethodMetadata, + NamespaceMetadata, ObjectImpl, ObjectMetadata, ObjectTraitImplMetadata, Radix, + RecordMetadata, TraitKind, TraitMethodMetadata, Type, VariantMetadata, }; #[test] @@ -2188,8 +2225,15 @@ mod tests { ci.derive_ffi_funcs().unwrap(); let bindings = generate_bindings(&Config::default(), &ci).unwrap(); assert!( - bindings.contains("class Boom extends BoomException implements AutoCloseable"), - "a class cannot `extends A, B`:\n{}", + bindings.contains( + "public class BoomException extends java.lang.Exception implements AutoCloseable" + ), + "callers catch the base type, so try-with-resources must work there:\n{}", + error_variant_lines(&bindings) + ); + assert!( + bindings.contains("public static class Boom extends BoomException {"), + "a class cannot `extends A, B`; the base provides AutoCloseable:\n{}", error_variant_lines(&bindings) ); assert!( @@ -2812,6 +2856,7 @@ mod tests { remote: false, fields: vec![ field("label", Type::String), + field("ratio", Type::Float64), field("data", seq(Type::Int32)), field("nested", seq(seq(Type::Int32))), ], @@ -2831,12 +2876,17 @@ mod tests { "array fields must compare by value:\n{bindings}" ); assert!( - bindings.contains( - "java.util.Objects.hash(label, UniffiDeepValue.hashCode(data), \ - UniffiDeepValue.hashCode(nested))" - ), + bindings.contains("java.lang.Double.compare(ratio, t.ratio) == 0"), + "`==` on a double breaks reflexivity for NaN:\n{bindings}" + ); + assert!( + bindings.contains("31 * result + UniffiDeepValue.hashCode(data)"), "array fields must hash by value:\n{bindings}" ); + assert!( + bindings.contains("31 * result + java.lang.Double.hashCode(ratio)"), + "double fields must hash without boxing:\n{bindings}" + ); } #[test] @@ -2857,6 +2907,10 @@ mod tests { bindings.contains("UniffiDeepValue.equals(data, t.data)"), "the record-generated equals sees array components by identity:\n{bindings}" ); + assert!( + bindings.contains("java.lang.Double.compare(ratio, t.ratio) == 0"), + "the override must keep the record default's NaN reflexivity:\n{bindings}" + ); } #[test] @@ -2913,11 +2967,50 @@ mod tests { "the variant record's equals sees array components by identity:\n{bindings}" ); assert!( - bindings.contains("java.util.Objects.hash(UniffiDeepValue.hashCode(v1))"), + bindings.contains("31 * result + UniffiDeepValue.hashCode(v1)"), "the variant record's hashCode must match its equals:\n{bindings}" ); } + #[test] + fn u64_discriminant_above_signed_max_uses_the_hex_form() { + let mut group = test_group(); + group.add_item(Metadata::Enum(EnumMetadata { + orig_name: None, + module_path: "test".to_string(), + name: "Big".to_string(), + shape: EnumShape::Enum, + remote: false, + variants: vec![VariantMetadata { + orig_name: None, + name: "Hi".to_string(), + discr: Some(LiteralMetadata::UInt( + u64::MAX, + Radix::Decimal, + Type::UInt64, + )), + fields: vec![], + docstring: None, + }], + discr_type: Some(Type::UInt64), + non_exhaustive: false, + docstring: None, + })); + let mut ci = ComponentInterface::from_metadata(group).unwrap(); + ci.derive_ffi_funcs().unwrap(); + let bindings = generate_bindings(&Config::default(), &ci).unwrap(); + + assert!( + bindings.contains("HI(0xFFFFFFFFFFFFFFFFL)"), + "a decimal literal for u64::MAX does not compile:\n{}", + bindings + .lines() + .filter(|l| l.contains("HI(")) + .collect::>() + .join("\n") + ); + } + #[test] fn generates_int32_primitive_array() { let group = create_primitive_array_test_group(); diff --git a/src/templates/EnumTemplate.java b/src/templates/EnumTemplate.java index f6539e5..f38b26d 100644 --- a/src/templates/EnumTemplate.java +++ b/src/templates/EnumTemplate.java @@ -119,7 +119,12 @@ public boolean equals(java.lang.Object other) { {%- if variant_trait_methods.hash_hash.is_none() && variant.fields()|has_array_rendered_field %} @Override public int hashCode() { - return java.util.Objects.hash({% for field in variant.fields() %}{% let fname = field|field_java_name(loop.index) %}{{ field|hash_element_expr(fname) }}{% if !loop.last %}, {% endif %}{% endfor %}); + int result = 17; + {%- for field in variant.fields() %} + {%- let fname = field|field_java_name(loop.index) %} + result = 31 * result + {{ field|boxed_hash_code_expr(fname) }}; + {%- endfor %} + return result; } {%- endif %} {% call java::uniffi_trait_impls(variant_trait_methods) %}{% endcall %} diff --git a/src/templates/ErrorTemplate.java b/src/templates/ErrorTemplate.java index 6a36f9a..cd597d7 100644 --- a/src/templates/ErrorTemplate.java +++ b/src/templates/ErrorTemplate.java @@ -26,15 +26,23 @@ public static class {{ variant|error_variant_name }} extends {{ type_name }} { {%- else %} {%- call java::docstring(e, 0) %}{% endcall %} -public class {{ type_name }} extends java.lang.Exception { +public class {{ type_name }} extends java.lang.Exception{% if contains_object_references %} implements AutoCloseable{% endif %} { private {{ type_name }}(java.lang.String message) { super(message); } + {% if contains_object_references %} + {#- Callers catch and hold the base type, so try-with-resources has to work there; the + object-holding variants override this. Redeclared without `throws Exception` as on the + enum interface. -#} + @Override + public void close() {} + {% endif %} + {% for variant in e.variants() -%} {%- call java::docstring(variant, 4) %}{% endcall %} {%- let variant_name = variant|error_variant_name %} - public static class {{ variant_name }} extends {{ type_name }}{% if contains_object_references %} implements AutoCloseable{% endif %} { + public static class {{ variant_name }} extends {{ type_name }} { {% for field in variant.fields() -%} {%- call java::docstring(field, 8) %}{% endcall %} {{ field|type_name(ci, config) }} {% call java::field_name(field, loop.index) %}{% endcall %}; diff --git a/src/templates/Helpers.java b/src/templates/Helpers.java index 769f1e4..123dd5a 100644 --- a/src/templates/Helpers.java +++ b/src/templates/Helpers.java @@ -329,7 +329,17 @@ static boolean equals(java.lang.Object a, java.lang.Object b) { return java.util.Objects.deepEquals(a, b); } + // Per-type arms rather than an Arrays.deepHashCode wrapper: equals has an allocation-free + // JDK entry point in Objects.deepEquals, hashCode does not. static int hashCode(java.lang.Object o) { + if (o == null) return 0; + if (o instanceof byte[] x) return java.util.Arrays.hashCode(x); + if (o instanceof short[] x) return java.util.Arrays.hashCode(x); + if (o instanceof int[] x) return java.util.Arrays.hashCode(x); + if (o instanceof long[] x) return java.util.Arrays.hashCode(x); + if (o instanceof float[] x) return java.util.Arrays.hashCode(x); + if (o instanceof double[] x) return java.util.Arrays.hashCode(x); + if (o instanceof boolean[] x) return java.util.Arrays.hashCode(x); if (o instanceof java.util.List x) { // The List.hashCode contract, with array-aware element hashes. int result = 1; @@ -346,7 +356,6 @@ static int hashCode(java.lang.Object o) { } return result; } - // The single-element wrapper covers null and every primitive array type. - return java.util.Arrays.deepHashCode(new java.lang.Object[] { o }); + return o.hashCode(); } } diff --git a/src/templates/RecordTemplate.java b/src/templates/RecordTemplate.java index c47e44c..e5a08c7 100644 --- a/src/templates/RecordTemplate.java +++ b/src/templates/RecordTemplate.java @@ -36,7 +36,11 @@ public boolean equals(java.lang.Object other) { {%- if uniffi_trait_methods.hash_hash.is_none() && rec.fields()|has_array_rendered_field %} @Override public int hashCode() { - return java.util.Objects.hash({% for field in rec.fields() %}{{ field|hash_element_expr(field.name()|var_name) }}{% if !loop.last %}, {% endif %}{% endfor %}); + int result = 17; + {%- for field in rec.fields() %} + result = 31 * result + {{ field|hash_code_expr(field.name()|var_name) }}; + {%- endfor %} + return result; } {%- endif %} {% for meth in rec.methods() -%} @@ -103,7 +107,11 @@ public boolean equals(java.lang.Object other) { {%- if uniffi_trait_methods.hash_hash.is_none() %} @Override public int hashCode() { - return java.util.Objects.hash({% for field in rec.fields() %}{{ field|hash_element_expr(field.name()|var_name) }}{% if !loop.last %}, {% endif %}{% endfor %}); + int result = 17; + {%- for field in rec.fields() %} + result = 31 * result + {{ field|hash_code_expr(field.name()|var_name) }}; + {%- endfor %} + return result; } {%- endif %} diff --git a/src/templates/macros.java b/src/templates/macros.java index 57de17f..4804d37 100644 --- a/src/templates/macros.java +++ b/src/templates/macros.java @@ -217,11 +217,7 @@ {%- endmacro -%} {% macro field_name(field, field_num) %} -{%- if field.name().is_empty() -%} -v{{- field_num -}} -{%- else -%} -{{ field.name()|var_name }} -{%- endif -%} +{{- field|field_java_name(field_num) -}} {%- endmacro %} {% macro field_name_unquoted(field, field_num) %} diff --git a/tests/scripts/TestPrimitiveArrays.java b/tests/scripts/TestPrimitiveArrays.java index 28fc4bf..da59c7a 100644 --- a/tests/scripts/TestPrimitiveArrays.java +++ b/tests/scripts/TestPrimitiveArrays.java @@ -69,6 +69,15 @@ static void testValueEquality() { assert PrimitiveArrays.roundtripIntsEnum(ints).equals(ints) : "IntsEnum roundtrip failed"; assert !ints.equals(new IntsEnum.Empty()) : "different variants should not be equal"; + FloatHolder nan = new FloatHolder(Double.NaN, new int[] { 1 }); + assert nan.equals(new FloatHolder(Double.NaN, new int[] { 1 })) + : "NaN fields should stay reflexively equal"; + assert nan.hashCode() == new FloatHolder(Double.NaN, new int[] { 1 }).hashCode() + : "equal NaN holders should hash alike"; + assert !new FloatHolder(0.0, new int[] { 1 }).equals(new FloatHolder(-0.0, new int[] { 1 })) + : "0.0 and -0.0 should stay distinct, matching Double.hashCode"; + assert PrimitiveArrays.roundtripFloatHolder(nan).equals(nan) : "FloatHolder roundtrip failed"; + IntsKey k1 = new IntsKey(new int[] { 7, 8 }); assert k1.equals(new IntsKey(new int[] { 7, 8 })) : "custom wrappers with equal arrays should be equal";