Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docker/Dockerfile-build
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,18 @@ RUN mkdir -p /tmp/setup-jna \
RUN mkdir -p /tmp/setup-kotlinx \
&& cd /tmp/setup-kotlinx \
&& curl -o kotlinx-coroutines-core-jvm.jar https://repo1.maven.org/maven2/org/jetbrains/kotlinx/kotlinx-coroutines-core-jvm/1.6.4/kotlinx-coroutines-core-jvm-1.6.4.jar \
&& curl -o kotlinx-serialization-core.jar https://repo1.maven.org/maven2/org/jetbrains/kotlinx/kotlinx-serialization-core-jvm/1.6.3/kotlinx-serialization-core-jvm-1.6.3.jar \
&& curl -o kotlinx-serialization-json.jar https://repo1.maven.org/maven2/org/jetbrains/kotlinx/kotlinx-serialization-json-jvm/1.6.3/kotlinx-serialization-json-jvm-1.6.3.jar \
# XXX TODO: should check a sha256sum or something here...
&& sudo mv kotlinx-coroutines-core-jvm.jar /opt \
&& echo "export CLASSPATH=\"\$CLASSPATH:/opt/kotlinx-coroutines-core-jvm.jar\"" >> /home/circleci/.bashrc \
&& echo "export CLASSPATH=\"\$CLASSPATH:/opt/kotlinx-coroutines-core-jvm.jar\"" >> /home/circleci/.profile \
&& sudo mv kotlinx-serialization-core.jar /opt \
&& echo "export CLASSPATH=\"\$CLASSPATH:/opt/kotlinx-serialization-core.jar\"" >> /home/circleci/.bashrc \
&& echo "export CLASSPATH=\"\$CLASSPATH:/opt/kotlinx-serialization-core.jar\"" >> /home/circleci/.profile \
&& sudo mv kotlinx-serialization-json.jar /opt \
&& echo "export CLASSPATH=\"\$CLASSPATH:/opt/kotlinx-serialization-json.jar\"" >> /home/circleci/.bashrc \
&& echo "export CLASSPATH=\"\$CLASSPATH:/opt/kotlinx-serialization-json.jar\"" >> /home/circleci/.profile \
&& cd ../ \
&& rm -rf ./setup-kotlinx

Expand Down
6 changes: 4 additions & 2 deletions docs/contributing.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,10 @@ test suite you will need:
* Kotlin:
* `kotlinc`, the [Kotlin command-line compiler](https://kotlinlang.org/docs/command-line.html).
* `ktlint`, the [Kotlin linter used to format the generated bindings](https://ktlint.github.io/).
* The [Java Native Access](https://github.com/java-native-access/jna#download) JAR downloaded and its path
added to your `$CLASSPATH` environment variable.
* Several JARs downloaded and their path added to your `$CLASSPATH` environment variable:
* [Java Native Access](https://github.com/java-native-access/jna#download)
* [KotlinX Serialization Core runtime](https://repo1.maven.org/maven2/org/jetbrains/kotlinx/kotlinx-serialization-core-jvm/1.6.3/kotlinx-serialization-core-jvm-1.6.3.jar)
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The latest version of KotlinX Serialization is currently 1.9.0. Since the CI is also using KotlinX Coroutines 1.6.3, whose latest version is also 1.10.2, I chose a version released at the time, when Kotlin 2.0 had not been released yet.

Should we fix the Kotlin version and upgrade the dependencies here?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That makes sense to me. Also, here I think it might be worth mentioning that the serialization libs are optional - eg, something like "Optionally, if you choose to use the generate_serializable_types option so that serializable types are generated, you will need ..."?

* [KotlinX Serialization JSON runtime](https://repo1.maven.org/maven2/org/jetbrains/kotlinx/kotlinx-serialization-json-jvm/1.6.3/kotlinx-serialization-json-jvm-1.6.3.jar)
* Swift:
* `swift` and `swiftc`, the [Swift command-line tools](https://swift.org/download/).
* The Swift `Foundation` package.
Expand Down
62 changes: 62 additions & 0 deletions fixtures/swift-codable/tests/bindings/test_codable.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it makes sense to rename this parent directory now? maybe something like "generated_serialization" or similar - this each test having a unique name would be ok (eg, "test_codable" for swift and "test_serialization" for kotlin.

I'm not too bothered if you don't want to do this here or would prefer it be done in a followup etc.

(The name "codable" always gets me - I don't immediately think "serialization" when I see it :)

* 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 kotlinx.serialization.*
import kotlinx.serialization.json.*
import uniffi.codable_test.*

val simpleRecord = SimpleRecord(
string = "Test",
boolean = true,
integer = 1,
floatVar = 1.1,
vec = listOf(true),
)

val jsonSimpleRecord = Json.encodeToString(simpleRecord)
val deserializedSimpleRecord = Json.decodeFromString<SimpleRecord>(jsonSimpleRecord)
assert(deserializedSimpleRecord.string == "Test")
assert(deserializedSimpleRecord.boolean)
assert(deserializedSimpleRecord.integer == 1)
assert(deserializedSimpleRecord.floatVar == 1.1)
assert(deserializedSimpleRecord.vec == listOf(true))

// MultiLayerRecord
val multilayer = MultiLayerRecord(
simpleEnum = SimpleEnum.ONE,
reprU8 = ReprU8.TWO,
simpleRecord = simpleRecord,
)

val jsonMultiLayerRecord = Json.encodeToString(multilayer)
val deserializedMultiLayerRecord = Json.decodeFromString<MultiLayerRecord>(jsonMultiLayerRecord)
assert(deserializedMultiLayerRecord.simpleEnum == SimpleEnum.ONE)
assert(deserializedMultiLayerRecord.reprU8 == ReprU8.TWO)
assert(deserializedMultiLayerRecord.simpleRecord == simpleRecord)

// RecordWithOptionals
val optionals = RecordWithOptionals(
string = null,
boolean = null,
integer = null,
floatVar = null,
vec = listOf("A", "B"),
)

val implicitNulls = Json { explicitNulls = false }
val jsonRecordWithOptionals = implicitNulls.encodeToString(optionals)
assert(jsonRecordWithOptionals == """{"vec":["A","B"]}""")

// ComplexEnum
val withClassDiscriminator = Json { classDiscriminator = "#class" }
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will embed "#class": "ComplexEnum.String" into the resulting JSON string, but some may want to customize the value of that property as well. We can provide this via the @kotlinx.serialization.SerialName annotation, but I'm not sure there is an elegant way to customize it via the configuration file. I'm thinking of something like the custom type configuration, e.g.:

[bindings.kotlin.serialization.ComplexEnum.String]
serial_name = "complex_enum_string"

which will put "#class": "complex_enum_string" to the resulting string.

Copy link
Copy Markdown
Member

@mhammond mhammond Dec 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assuming you don't intend blocking this PR on that tweak, it might be worth opening a PR where we can discuss this, then add a short comment in the fixture itself which notes what you said and links to the PR, so people exploring this fixture also discovers this.

Re the idea for customizing, that seems good - another alternative might be a format similar to what we did for renaming - https://mozilla.github.io/uniffi-rs/next/renaming.html - which would be more like:

[bindings.kotlin.serialization.serial_names]
"ComplexEnum.String" = "complex_enum_string"

(I've no idea if that example makes actual sense in this context, but some degree of consistency makes some sense)

val complexEnum = ComplexEnum.String("test")
val jsonComplexEnum = withClassDiscriminator.encodeToString<ComplexEnum>(complexEnum)
val deserializedComplexEnum = withClassDiscriminator.decodeFromString<ComplexEnum>(jsonComplexEnum)
assert(deserializedComplexEnum == ComplexEnum.String("test"))

// SimpleError: Exceptions are not serializable in Kotlin
// val simpleException = SimpleException.OsException()
// val jsonSimpleException = Json.encodeToString(simpleException)
// val deserializedSimpleException = Json.decodeFromString<SimpleException>(jsonSimpleException)
// assert(deserializedSimpleException == SimpleException.OsException())
5 changes: 4 additions & 1 deletion fixtures/swift-codable/tests/test_generated_bindings.rs
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
uniffi::build_foreign_language_testcases!("tests/bindings/test_codable.swift",);
uniffi::build_foreign_language_testcases!(
"tests/bindings/test_codable.kts",
"tests/bindings/test_codable.swift",
);
3 changes: 3 additions & 0 deletions fixtures/swift-codable/uniffi.toml
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
[bindings.kotlin]
generate_serializable_types = true

[bindings.swift]
generate_codable_conformance = true
74 changes: 74 additions & 0 deletions uniffi_bindgen/src/bindings/kotlin/gen_kotlin/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ pub struct Config {
disable_java_cleaner: bool,
#[serde(default)]
pub(super) rename: toml::Table,
#[serde(default)]
generate_serializable_types: bool,
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are there any better names for this?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe generate_serializable_annotation? That seems to better reflect what actually changes here (ie, it doesn't change how we render the type itself)?

}

impl Config {
Expand Down Expand Up @@ -903,6 +905,78 @@ mod filters {
let spaces = usize::try_from(*spaces).unwrap_or_default();
Ok(textwrap::indent(&wrapped, &" ".repeat(spaces)))
}

fn serializable_type(ty: &Type, ci: &ComponentInterface) -> Result<bool, askama::Error> {
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be okay to make ComponentInterface::iter_types_in_item public and use it here?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, that would be fine and better than copying it

Ok(match ty {
Type::Object { .. } | Type::CallbackInterface { .. } => false,
Type::Record { name, .. } => serializable_record(
ci.get_record_definition(name)
.ok_or_else(|| to_askama_error(&format!("could not find record '{name}'")))?,
ci,
)?,
Type::Enum { name, .. } => serializable_enum(
ci.get_enum_definition(name)
.ok_or_else(|| to_askama_error(&format!("could not find enum '{name}'")))?,
ci,
)?,
Type::Optional { inner_type } | Type::Sequence { inner_type } => {
serializable_type(inner_type, ci)?
}
Type::Map {
key_type,
value_type,
} => serializable_type(key_type, ci)? && serializable_type(value_type, ci)?,
// Assume a custom type using a serializable type is also serializable.
Type::Custom { builtin, .. } => serializable_type(builtin, ci)?,
_ => true,
})
}

pub fn serializable_record(
record: &Record,
ci: &ComponentInterface,
) -> Result<bool, askama::Error> {
for field in record.fields() {
if !serializable_type(&field.as_type(), ci)? {
return Ok(false);
}
}
Ok(true)
}

pub fn serializable_enum(e: &Enum, ci: &ComponentInterface) -> Result<bool, askama::Error> {
if ci.is_name_used_as_error(e.name()) {
return Ok(false);
}

if e.is_flat() {
let Some(variant_discr_type) = e.variant_discr_type() else {
return Ok(true);
};
return serializable_type(variant_discr_type, ci);
}

// Unlike records or enum variants, if any of the variants are serializable, the
// enum can be marked as serializable.
for variant in e.variants() {
if serializable_enum_variant(variant, ci)? {
return Ok(true);
}
}
Ok(false)
}

pub fn serializable_enum_variant(
variant: &Variant,
ci: &ComponentInterface,
) -> Result<bool, askama::Error> {
for field in variant.fields() {
if !serializable_type(&field.as_type(), ci)? {
return Ok(false);
}
}
Ok(true)
}
}

#[cfg(test)]
Expand Down
14 changes: 14 additions & 0 deletions uniffi_bindgen/src/bindings/kotlin/templates/EnumTemplate.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,15 @@
// and `sealed class` for the general case.
#}

{%- let should_generate_serializable = config.generate_serializable_types && e|serializable_enum(ci) %}
{%- if e.is_flat() %}

{%- call kt::docstring(e, 0) %}
{% match e.variant_discr_type() %}
{% when None %}
{%- if should_generate_serializable -%}
@kotlinx.serialization.Serializable
{%- endif %}
enum class {{ type_name }} {
{% for variant in e.variants() -%}
{%- call kt::docstring(variant, 4) %}
Expand All @@ -26,6 +30,9 @@ enum class {{ type_name }} {
companion object
}
{% when Some(variant_discr_type) %}
{%- if should_generate_serializable -%}
@kotlinx.serialization.Serializable
{%- endif %}
enum class {{ type_name }}(val value: {{ variant_discr_type|type_name(ci) }}) {
{% for variant in e.variants() -%}
{%- call kt::docstring(variant, 4) %}
Expand Down Expand Up @@ -67,13 +74,20 @@ public object {{ e|ffi_converter_name }}: FfiConverterRustBuffer<{{ type_name }}
{% else %}

{%- call kt::docstring(e, 0) %}
{%- if should_generate_serializable -%}
@kotlinx.serialization.Serializable
{%- endif %}
sealed class {{ type_name }}{% if contains_object_references %}: Disposable {% endif %}
{%- let uniffi_trait_methods = e.uniffi_trait_methods() -%}
{%- if uniffi_trait_methods.ord_cmp.is_some() -%}
{% if contains_object_references %}, {% else %} : {% endif %}Comparable<{{ type_name }}>
{%- endif %} {
{% for variant in e.variants() -%}
{%- let should_generate_variant_serializable = config.generate_serializable_types && variant|serializable_enum_variant(ci) -%}
{%- call kt::docstring(variant, 4) %}
{%- if should_generate_variant_serializable %}
@kotlinx.serialization.Serializable
{%- endif %}
{% if !variant.has_fields() -%}
object {{ variant|type_name(ci) }} : {{ type_name }}()
{% else -%}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
{%- let rec = ci.get_record_definition(name).unwrap() %}
{%- let should_generate_serializable = config.generate_serializable_types && rec|serializable_record(ci) %}

{%- if rec.has_fields() %}
{%- call kt::docstring(rec, 0) %}
{%- if should_generate_serializable -%}
@kotlinx.serialization.Serializable
{%- endif %}
data class {{ type_name }} (
{%- for field in rec.fields() %}
{%- call kt::docstring(field, 4) %}
Expand Down Expand Up @@ -32,6 +36,9 @@ data class {{ type_name }} (
}
{%- else -%}
{%- call kt::docstring(rec, 0) %}
{%- if should_generate_serializable -%}
@kotlinx.serialization.Serializable
{%- endif %}
class {{ type_name }} {
override fun equals(other: Any?): Boolean {
return other is {{ type_name }}
Expand Down
57 changes: 57 additions & 0 deletions uniffi_bindgen/src/bindings/kotlin/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,14 @@ fn build_jar(
bail!("No kotlin sources found in {out_dir}")
}

let installation = find_kotlinc_installation()?;
// kotlin-serialization-compiler-plugin.jar (no x after kotlin!) was not present until Kotlin 1.9.
let kotlinx_serialization_plugin =
installation.join("lib/kotlinx-serialization-compiler-plugin.jar");

let mut command = kotlinc_command(options);
command
.arg(format!("-Xplugin={kotlinx_serialization_plugin}"))
// Our generated bindings should not produce any warnings; fail tests if they do.
.arg("-Werror")
.arg("-d")
Expand Down Expand Up @@ -120,6 +126,57 @@ fn kotlinc_command(options: &RunScriptOptions) -> Command {
command
}

fn find_kotlinc_installation() -> Result<Utf8PathBuf> {
let Some(path_var) = env::var_os("PATH") else {
bail!("Environment variable PATH not present")
};
let kotlinc_executable_name = if cfg!(target_os = "windows") {
"kotlinc.bat"
} else {
"kotlinc"
};
if let Some(installation) = env::split_paths(&path_var)
.flat_map(|path| {
// If we find <path>/kotlinc or <path>/kotlinc.bat,
let compiler_path = path.join(kotlinc_executable_name);
compiler_path
.try_exists()
.is_ok_and(|e| e)
.then_some((path, compiler_path))
})
.flat_map(|(path, compiler_path)| {
// Scan <path> and the parent of the symbolic link target of <path>/kotlinc,
let link_resolved_path = compiler_path
.canonicalize()
.ok()
.and_then(|p| p.parent().map(ToOwned::to_owned));
std::iter::once(path).chain(link_resolved_path)
})
.filter(|path| {
// Make sure <path> is <installation>/bin,
path.file_name()
.and_then(|f| f.to_str())
.is_some_and(|s| s == "bin")
})
.flat_map(|path| {
// Collect <installation>,
path.parent().map(ToOwned::to_owned)
})
.filter(|installation| {
// And if <installation>/lib exists,
installation.join("lib").try_exists().is_ok_and(|e| e)
})
// Try converting it into Utf8PathBuf and,
.flat_map(Utf8PathBuf::from_path_buf)
.next()
{
// Return the first occurrence of such <installation>.
return Ok(installation);
}

bail!("Could not find a directory containing the kotlinc executable and the required JAR files")
}

fn calc_classpath(extra_paths: Vec<&Utf8Path>) -> String {
extra_paths
.into_iter()
Expand Down