diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 28cc327e..fed11253 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -42,6 +42,8 @@ spotless = { id = "com.diffplug.spotless", version = "6.25.0" } [libraries] agp = { module = "com.android.tools.build:gradle", version.ref = "agp" } +assertj = "org.assertj:assertj-core:3.25.3" + autoCommon = { module = "com.google.auto:auto-common", version = "1.2.2" } autoService = { module = "com.google.auto.service:auto-service-annotations", version.ref = "autoService" } @@ -58,6 +60,7 @@ kotlin-metadata = { module = "org.jetbrains.kotlinx:kotlinx-metadata-jvm", versi kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } kotlin-gradlePlugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } kotlin-gradlePlugin-api = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin-api", version.ref = "kotlin" } +kotlinx-immutable = "org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.7" kotlinpoet = { module = "com.squareup:kotlinpoet", version.ref = "kotlinpoet" } kotlinpoet-metadata = { module = "com.squareup:kotlinpoet-metadata", version.ref = "kotlinpoet" } diff --git a/moshi-adapters/build.gradle.kts b/moshi-adapters/build.gradle.kts index ea9ebcc5..c5a79354 100644 --- a/moshi-adapters/build.gradle.kts +++ b/moshi-adapters/build.gradle.kts @@ -14,17 +14,13 @@ * limitations under the License. */ -import org.jetbrains.kotlin.gradle.tasks.KotlinCompile - plugins { alias(libs.plugins.kotlinJvm) alias(libs.plugins.ksp) alias(libs.plugins.mavenPublish) } -tasks.named("compileTestKotlin") { - compilerOptions { freeCompilerArgs.add("-opt-in=kotlin.ExperimentalStdlibApi") } -} +tasks.compileTestKotlin { compilerOptions { optIn.add("kotlin.ExperimentalStdlibApi") } } dependencies { implementation(libs.moshi) diff --git a/moshi-immutable-adapters/README.md b/moshi-immutable-adapters/README.md new file mode 100644 index 00000000..be6b6dac --- /dev/null +++ b/moshi-immutable-adapters/README.md @@ -0,0 +1,30 @@ +# moshi-adapters + +A collection of Moshi adapters for [kotlinx.collections.immutable](https://github.com/Kotlin/kotlinx.collections.immutable). + +## Usage + +Gradle dependency + +```kotlin +dependencies { + implementation("dev.zacsweers.moshix:moshi-immutable-adapters:") +} +``` + +In code + +```kotlin +val moshi = Moshi.Builder().add(ImmutableCollectionsJsonAdapterFactory()).build() +``` + +**Supported types** + +- `ImmutableCollection` +- `ImmutableList` +- `ImmutableSet` +- `ImmutableMap` +- `PersistentCollection` +- `PersistentList` +- `PersistentSet` +- `PersistentMap` diff --git a/moshi-immutable-adapters/api/moshi-immutable-adapters.api b/moshi-immutable-adapters/api/moshi-immutable-adapters.api new file mode 100644 index 00000000..e68b0159 --- /dev/null +++ b/moshi-immutable-adapters/api/moshi-immutable-adapters.api @@ -0,0 +1,5 @@ +public final class dev/zacsweers/moshix/adapters/immutable/ImmutableCollectionsJsonAdapterFactory : com/squareup/moshi/JsonAdapter$Factory { + public fun ()V + public fun create (Ljava/lang/reflect/Type;Ljava/util/Set;Lcom/squareup/moshi/Moshi;)Lcom/squareup/moshi/JsonAdapter; +} + diff --git a/moshi-immutable-adapters/build.gradle.kts b/moshi-immutable-adapters/build.gradle.kts new file mode 100644 index 00000000..f97e73fe --- /dev/null +++ b/moshi-immutable-adapters/build.gradle.kts @@ -0,0 +1,32 @@ +/* + * Copyright (C) 2024 Zac Sweers + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +plugins { + alias(libs.plugins.kotlinJvm) + alias(libs.plugins.ksp) + alias(libs.plugins.mavenPublish) +} + +tasks.compileTestKotlin { compilerOptions { optIn.add("kotlin.ExperimentalStdlibApi") } } + +dependencies { + api(libs.kotlinx.immutable) + api(libs.moshi) + kspTest(libs.moshi.codegen) + testImplementation(libs.moshi.kotlin) + testImplementation(libs.junit) + testImplementation(libs.truth) +} diff --git a/moshi-immutable-adapters/gradle.properties b/moshi-immutable-adapters/gradle.properties new file mode 100644 index 00000000..a6a756ba --- /dev/null +++ b/moshi-immutable-adapters/gradle.properties @@ -0,0 +1,19 @@ +# +# Copyright (c) 2024 Zac Sweers +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +POM_NAME=Moshi Immutable Adapters +POM_ARTIFACT_ID=moshi-immutable-adapters +POM_PACKAGING=jar diff --git a/moshi-immutable-adapters/src/main/kotlin/dev/zacsweers/moshix/adapters/immutable/ImmutableCollectionsJsonAdapterFactory.kt b/moshi-immutable-adapters/src/main/kotlin/dev/zacsweers/moshix/adapters/immutable/ImmutableCollectionsJsonAdapterFactory.kt new file mode 100644 index 00000000..42a3aea0 --- /dev/null +++ b/moshi-immutable-adapters/src/main/kotlin/dev/zacsweers/moshix/adapters/immutable/ImmutableCollectionsJsonAdapterFactory.kt @@ -0,0 +1,145 @@ +package dev.zacsweers.moshix.adapters.immutable + +import com.squareup.moshi.JsonAdapter +import com.squareup.moshi.JsonDataException +import com.squareup.moshi.JsonReader +import com.squareup.moshi.JsonWriter +import com.squareup.moshi.Moshi +import java.lang.reflect.ParameterizedType +import java.lang.reflect.Type +import kotlinx.collections.immutable.ImmutableCollection +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.PersistentCollection +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.PersistentMap +import kotlinx.collections.immutable.PersistentSet +import kotlinx.collections.immutable.mutate +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.collections.immutable.persistentSetOf + +/** + * A [JsonAdapter.Factory] that creates immutable collection adapters for the following supported + * types: + * - [ImmutableCollection] + * - [ImmutableList] + * - [ImmutableSet] + * - [ImmutableMap] + * - [PersistentCollection] + * - [PersistentList] + * - [PersistentSet] + * - [PersistentMap] + */ +public class ImmutableCollectionsJsonAdapterFactory : JsonAdapter.Factory { + override fun create(type: Type, annotations: Set, moshi: Moshi): JsonAdapter<*>? { + if (type !is ParameterizedType) return null + when (type.rawType) { + ImmutableList::class.java, + ImmutableCollection::class.java, + PersistentList::class.java, + PersistentCollection::class.java -> { + val elementType = type.actualTypeArguments[0] + val elementAdapter = moshi.adapter(elementType, annotations) + return ImmutableListAdapter(elementAdapter) + } + ImmutableSet::class.java, + PersistentSet::class.java -> { + val elementType = type.actualTypeArguments[0] + val elementAdapter = moshi.adapter(elementType, annotations) + return ImmutableSetAdapter(elementAdapter) + } + ImmutableMap::class.java, + PersistentMap::class.java -> { + val keyType = type.actualTypeArguments[0] + val valueType = type.actualTypeArguments[1] + val keyAdapter = moshi.adapter(keyType, annotations) + val valueAdapter = moshi.adapter(valueType, annotations) + return ImmutableMapAdapter(keyAdapter, valueAdapter) + } + else -> return null + } + } +} + +private sealed class ImmutableCollectionAdapter, E>( + private val elementAdapter: JsonAdapter +) : JsonAdapter() { + + abstract fun buildCollection(body: (MutableCollection) -> Unit): C + + override fun fromJson(reader: JsonReader): C? { + reader.beginArray() + val collection = buildCollection { builder -> + while (reader.hasNext()) { + builder += elementAdapter.fromJson(reader) ?: error("Null element at ${reader.path}") + } + } + reader.endArray() + return collection + } + + override fun toJson(writer: JsonWriter, value: C?) { + if (value == null) { + writer.nullValue() + } else { + writer.beginArray() + for (element in value) { + elementAdapter.toJson(writer, element) + } + writer.endArray() + } + } +} + +private class ImmutableListAdapter(elementAdapter: JsonAdapter) : + ImmutableCollectionAdapter, E>(elementAdapter) { + override fun buildCollection(body: (MutableCollection) -> Unit) = + persistentListOf().mutate(body) +} + +private class ImmutableSetAdapter(elementAdapter: JsonAdapter) : + ImmutableCollectionAdapter, E>(elementAdapter) { + override fun buildCollection(body: (MutableCollection) -> Unit) = + persistentSetOf().mutate(body) +} + +private class ImmutableMapAdapter( + private val keyAdapter: JsonAdapter, + private val valueAdapter: JsonAdapter, +) : JsonAdapter>() { + + override fun fromJson(reader: JsonReader): ImmutableMap { + reader.beginObject() + return persistentMapOf() + .mutate { + while (reader.hasNext()) { + reader.promoteNameToValue() + val key = keyAdapter.fromJson(reader) ?: error("Null key at ${reader.path}") + val value = valueAdapter.fromJson(reader) ?: error("Null value at ${reader.path}") + val replaced = it.put(key, value) + if (replaced != null) { + throw JsonDataException( + "Duplicate element '$key' with value '$replaced' at ${reader.path}" + ) + } + } + } + .also { reader.endObject() } + } + + override fun toJson(writer: JsonWriter, value: ImmutableMap?) { + if (value == null) { + writer.nullValue() + } else { + writer.beginObject() + for ((k, v) in value) { + writer.promoteValueToName() + keyAdapter.toJson(writer, k) + valueAdapter.toJson(writer, v) + } + writer.endObject() + } + } +} diff --git a/moshi-immutable-adapters/src/test/kotlin/dev/zacsweers/moshix/adapters/immutable/ImmutableCollectionsJsonAdapterFactoryTest.kt b/moshi-immutable-adapters/src/test/kotlin/dev/zacsweers/moshix/adapters/immutable/ImmutableCollectionsJsonAdapterFactoryTest.kt new file mode 100644 index 00000000..49b4827c --- /dev/null +++ b/moshi-immutable-adapters/src/test/kotlin/dev/zacsweers/moshix/adapters/immutable/ImmutableCollectionsJsonAdapterFactoryTest.kt @@ -0,0 +1,101 @@ +package dev.zacsweers.moshix.adapters.immutable + +import com.squareup.moshi.Moshi +import com.squareup.moshi.adapter +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import kotlinx.collections.immutable.ImmutableCollection +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.PersistentCollection +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.PersistentMap +import kotlinx.collections.immutable.PersistentSet +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentMapOf +import kotlinx.collections.immutable.persistentSetOf +import org.junit.Test + +class ImmutableCollectionsJsonAdapterFactoryTest { + private val moshi = + Moshi.Builder() + .add(ImmutableCollectionsJsonAdapterFactory()) + .addLast(KotlinJsonAdapterFactory()) + .build() + + // language=JSON + private val json = + """ + { + "list": [1, 2, 3], + "set": [1, 2, 3], + "collection": [1, 2, 3], + "map": { + "a": 1, + "b": 2, + "c": 3 + }, + "nested": { + "a": [1, 2, 3], + "b": [1, 2, 3], + "c": [1, 2, 3] + }, + "persistentList": [1, 2, 3], + "persistentSet": [1, 2, 3], + "persistentCollection": [1, 2, 3], + "persistentMap": { + "a": 1, + "b": 2, + "c": 3 + }, + "persistentNested": { + "a": [1, 2, 3], + "b": [1, 2, 3], + "c": [1, 2, 3] + } + } + """ + .trimIndent() + + @Test + fun smokeTest() { + val adapter = moshi.adapter() + val instance = adapter.fromJson(json)!! + val expectedInstance = + ClassWithImmutables( + list = persistentListOf(1, 2, 3), + set = persistentSetOf(1, 2, 3), + collection = persistentListOf(1, 2, 3), + map = persistentMapOf("a" to 1, "b" to 2, "c" to 3), + nested = + persistentMapOf( + "a" to persistentListOf(1, 2, 3), + "b" to persistentListOf(1, 2, 3), + "c" to persistentListOf(1, 2, 3), + ), + persistentList = persistentListOf(1, 2, 3), + persistentSet = persistentSetOf(1, 2, 3), + persistentCollection = persistentListOf(1, 2, 3), + persistentMap = persistentMapOf("a" to 1, "b" to 2, "c" to 3), + persistentNested = + persistentMapOf( + "a" to persistentListOf(1, 2, 3), + "b" to persistentListOf(1, 2, 3), + "c" to persistentListOf(1, 2, 3), + ), + ) + } + + class ClassWithImmutables( + val list: ImmutableList, + val set: ImmutableSet, + val collection: ImmutableCollection, + val map: ImmutableMap, + val nested: ImmutableMap>, + val persistentList: PersistentList, + val persistentSet: PersistentSet, + val persistentCollection: PersistentCollection, + val persistentMap: PersistentMap, + val persistentNested: PersistentMap>, + ) +} diff --git a/moshi-metadata-reflect/build.gradle.kts b/moshi-metadata-reflect/build.gradle.kts index 60c460e1..f5000c83 100644 --- a/moshi-metadata-reflect/build.gradle.kts +++ b/moshi-metadata-reflect/build.gradle.kts @@ -14,23 +14,19 @@ * limitations under the License. */ -import org.jetbrains.kotlin.gradle.tasks.KotlinCompile - plugins { kotlin("jvm") id("com.google.devtools.ksp") id("com.vanniktech.maven.publish") } -tasks.named("compileTestKotlin") { - compilerOptions { freeCompilerArgs.add("-opt-in=kotlin.ExperimentalStdlibApi") } -} +tasks.compileTestKotlin { compilerOptions { optIn.add("kotlin.ExperimentalStdlibApi") } } dependencies { implementation(libs.kotlin.metadata) implementation(libs.moshi) kspTest(libs.moshi.codegen) - testImplementation("org.assertj:assertj-core:3.25.3") + testImplementation(libs.assertj) testImplementation(libs.junit) testImplementation(libs.truth) } diff --git a/settings.gradle.kts b/settings.gradle.kts index aeef358d..df28f755 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -66,6 +66,7 @@ rootProject.name = "moshix-root" include( ":moshi-adapters", + ":moshi-immutable-adapters", ":moshi-ir:moshi-compiler-plugin", ":moshi-ir:moshi-kotlin-tests", ":moshi-ir:moshi-kotlin-tests:extra-moshi-test-module",