diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bb7aaaa1b..c6702ca63 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -4,6 +4,7 @@ on: [push, pull_request] jobs: build: + name: 'Java ${{ matrix.java-version }} | KSP ${{ matrix.use-ksp }}' runs-on: ubuntu-latest strategy: @@ -11,6 +12,7 @@ jobs: matrix: java-version: - 16 + use-ksp: [ true, false ] steps: - name: Checkout @@ -36,7 +38,7 @@ jobs: java-version: ${{ matrix.java-version }} - name: Test - run: ./gradlew build check --stacktrace + run: ./gradlew build check --stacktrace -PuseKsp=${{ matrix.use-ksp }} - name: Publish (default branch only) if: github.repository == 'square/moshi' && github.ref == 'refs/heads/master' && matrix.java-version == '16' diff --git a/gradle.properties b/gradle.properties index b38e0dfe2..fbe858a68 100644 --- a/gradle.properties +++ b/gradle.properties @@ -16,34 +16,32 @@ # For Dokka https://github.com/Kotlin/dokka/issues/1405 org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 \ - --add-opens=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED \ + --add-opens=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \ + --add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED \ + --add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED \ --add-opens=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED \ - --add-opens=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED \ --add-opens=jdk.compiler/com.sun.tools.javac.jvm=ALL-UNNAMED \ + --add-opens=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED \ + --add-opens=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED \ --add-opens=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED \ - --add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED \ --add-opens=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED \ - --add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \ - --add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED \ - --add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED \ - --add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED \ - --add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED + --add-opens=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED -# TODO move this to DSL in Kotlin 1.5.30 https://youtrack.jetbrains.com/issue/KT-44266 kotlin.daemon.jvmargs=-Dfile.encoding=UTF-8 \ - --add-opens=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED \ + --add-opens=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \ + --add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED \ + --add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED \ --add-opens=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED \ - --add-opens=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED \ --add-opens=jdk.compiler/com.sun.tools.javac.jvm=ALL-UNNAMED \ + --add-opens=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED \ + --add-opens=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED \ --add-opens=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED \ - --add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED \ --add-opens=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED \ - --add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED \ - --add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED \ - --add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED \ - --add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED \ - --add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED + --add-opens=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED +# Kapt doesn't read the above jvm args when workers are used +# https://youtrack.jetbrains.com/issue/KT-48402 +kapt.use.worker.api=false kapt.include.compile.classpath=false GROUP=com.squareup.moshi diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e092775ea..041af8e18 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,16 +15,17 @@ [versions] autoService = "1.0" gjf = "1.11.0" -incap = "0.3" jvmTarget = "1.8" -kotlin = "1.5.21" -kotlinCompileTesting = "1.4.3" -kotlinpoet = "1.10.0" +kotlin = "1.5.31" +kotlinCompileTesting = "1.4.4" +kotlinpoet = "1.10.1" +ksp = "1.5.31-1.0.0" ktlint = "0.41.0" [plugins] -dokka = { id = "org.jetbrains.dokka", version = "1.5.0" } +dokka = { id = "org.jetbrains.dokka", version = "1.5.30" } japicmp = { id = "me.champeau.gradle.japicmp", version = "0.2.9" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } mavenPublish = { id = "com.vanniktech.maven.publish", version = "0.17.0" } mavenShadow = { id = "com.github.johnrengelman.shadow", version = "7.0.0" } spotless = { id = "com.diffplug.spotless", version = "5.14.2" } @@ -33,20 +34,22 @@ spotless = { id = "com.diffplug.spotless", version = "5.14.2" } asm = "org.ow2.asm:asm:9.2" autoCommon = "com.google.auto:auto-common:1.1" autoService = { module = "com.google.auto.service:auto-service-annotations", version.ref = "autoService" } -autoService-processor = { module = "com.google.auto.service:auto-service", version.ref = "autoService" } -guava = { module = "com.google.guava:guava", version = "30.1.1-jre" } -incap = { module = "net.ltgt.gradle.incap:incap", version.ref = "incap" } -incap-processor = { module = "net.ltgt.gradle.incap:incap-processor", version.ref = "incap" } +autoService-ksp = "dev.zacsweers.autoservice:auto-service-ksp:1.0.0" +guava = "com.google.guava:guava:30.1.1-jre" jsr305 = "com.google.code.findbugs:jsr305:3.0.2" kotlin-compilerEmbeddable = { module = "org.jetbrains.kotlin:kotlin-compiler-embeddable", version.ref = "kotlin" } kotlin-reflect = { module = "org.jetbrains.kotlin:kotlin-reflect", version.ref = "kotlin" } kotlinpoet = { module = "com.squareup:kotlinpoet", version.ref = "kotlinpoet" } kotlinpoet-metadata = { module = "com.squareup:kotlinpoet-metadata", version.ref = "kotlinpoet" } +kotlinpoet-ksp = { module = "com.squareup:kotlinpoet-ksp", version.ref = "kotlinpoet" } kotlinxMetadata = "org.jetbrains.kotlinx:kotlinx-metadata-jvm:0.3.0" +ksp = { module = "com.google.devtools.ksp:symbol-processing", version.ref = "ksp" } +ksp-api = { module = "com.google.devtools.ksp:symbol-processing-api", version.ref = "ksp" } okio = "com.squareup.okio:okio:2.10.0" # Test libs assertj = "org.assertj:assertj-core:3.11.1" junit = "junit:junit:4.13.2" kotlinCompileTesting = { module = "com.github.tschuchortdev:kotlin-compile-testing", version.ref = "kotlinCompileTesting" } +kotlinCompileTesting-ksp = { module = "com.github.tschuchortdev:kotlin-compile-testing-ksp", version.ref ="kotlinCompileTesting" } truth = "com.google.truth:truth:1.1.3" diff --git a/kotlin/codegen/build.gradle.kts b/kotlin/codegen/build.gradle.kts index 67a354470..d6fa53c6f 100644 --- a/kotlin/codegen/build.gradle.kts +++ b/kotlin/codegen/build.gradle.kts @@ -20,7 +20,7 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { kotlin("jvm") - kotlin("kapt") + alias(libs.plugins.ksp) id("com.vanniktech.maven.publish") alias(libs.plugins.mavenShadow) } @@ -31,12 +31,12 @@ tasks.withType().configureEach { freeCompilerArgs += listOf( "-Xopt-in=kotlin.RequiresOptIn", "-Xopt-in=com.squareup.kotlinpoet.metadata.KotlinPoetMetadataPreview", + "-Xopt-in=com.squareup.kotlinpoet.ksp.KotlinPoetKspPreview", ) } } tasks.withType().configureEach { - // For kapt to work with kotlin-compile-testing jvmArgs( "--add-opens=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED", "--add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED", @@ -47,17 +47,15 @@ tasks.withType().configureEach { "--add-opens=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED", "--add-opens=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED", "--add-opens=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED", - "--add-opens=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED", + "--add-opens=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED" ) } val shade: Configuration = configurations.maybeCreate("compileShaded") configurations.getByName("compileOnly").extendsFrom(shade) dependencies { - // Use `api` because kapt will not resolve `runtime` dependencies without it, only `compile` - // https://youtrack.jetbrains.com/issue/KT-41702 - api(project(":moshi")) - api(kotlin("reflect")) + implementation(project(":moshi")) + implementation(kotlin("reflect")) shade(libs.kotlinxMetadata) { exclude(group = "org.jetbrains.kotlin", module = "kotlin-stdlib") } @@ -67,16 +65,30 @@ dependencies { exclude(group = "com.squareup", module = "kotlinpoet") exclude(group = "com.google.guava") } - api(libs.guava) - api(libs.asm) + shade(libs.kotlinpoet.ksp) { + exclude(group = "org.jetbrains.kotlin") + exclude(group = "com.squareup", module = "kotlinpoet") + } + implementation(libs.guava) + implementation(libs.asm) + + implementation(libs.autoService) + ksp(libs.autoService.ksp) - api(libs.autoService) - kapt(libs.autoService.processor) - api(libs.incap) - kapt(libs.incap.processor) + // KSP deps + compileOnly(libs.ksp) + compileOnly(libs.ksp.api) + compileOnly(libs.kotlin.compilerEmbeddable) + // Always force the latest KSP version to match the one we're compiling against + testImplementation(libs.ksp) + testImplementation(libs.kotlin.compilerEmbeddable) + testImplementation(libs.kotlinCompileTesting.ksp) // Copy these again as they're not automatically included since they're shaded + testImplementation(project(":moshi")) + testImplementation(kotlin("reflect")) testImplementation(libs.kotlinpoet.metadata) + testImplementation(libs.kotlinpoet.ksp) testImplementation(libs.junit) testImplementation(libs.truth) testImplementation(libs.kotlinCompileTesting) diff --git a/kotlin/codegen/src/main/java/com/squareup/moshi/kotlin/codegen/api/Options.kt b/kotlin/codegen/src/main/java/com/squareup/moshi/kotlin/codegen/api/Options.kt new file mode 100644 index 000000000..05ff2e6db --- /dev/null +++ b/kotlin/codegen/src/main/java/com/squareup/moshi/kotlin/codegen/api/Options.kt @@ -0,0 +1,43 @@ +/* + * Copyright (C) 2021 Square, Inc. + * + * 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. + */ +package com.squareup.moshi.kotlin.codegen.api + +import com.squareup.kotlinpoet.ClassName + +internal object Options { + /** + * This processing option can be specified to have a `@Generated` annotation + * included in the generated code. It is not encouraged unless you need it for static analysis + * reasons and not enabled by default. + * + * Note that this can only be one of the following values: + * * `"javax.annotation.processing.Generated"` (JRE 9+) + * * `"javax.annotation.Generated"` (JRE <9) + */ + const val OPTION_GENERATED: String = "moshi.generated" + + /** + * This boolean processing option can disable proguard rule generation. + * Normally, this is not recommended unless end-users build their own JsonAdapter look-up tool. + * This is enabled by default. + */ + const val OPTION_GENERATE_PROGUARD_RULES: String = "moshi.generateProguardRules" + + val POSSIBLE_GENERATED_NAMES = arrayOf( + ClassName("javax.annotation.processing", "Generated"), + ClassName("javax.annotation", "Generated") + ).associateBy { it.canonicalName } +} diff --git a/kotlin/codegen/src/main/java/com/squareup/moshi/kotlin/codegen/api/ProguardRules.kt b/kotlin/codegen/src/main/java/com/squareup/moshi/kotlin/codegen/api/ProguardRules.kt index 057b97054..1f91e17ff 100644 --- a/kotlin/codegen/src/main/java/com/squareup/moshi/kotlin/codegen/api/ProguardRules.kt +++ b/kotlin/codegen/src/main/java/com/squareup/moshi/kotlin/codegen/api/ProguardRules.kt @@ -16,9 +16,6 @@ package com.squareup.moshi.kotlin.codegen.api import com.squareup.kotlinpoet.ClassName -import javax.annotation.processing.Filer -import javax.lang.model.element.Element -import javax.tools.StandardLocation /** * Represents a proguard configuration for a given spec. This covers three main areas: @@ -42,16 +39,11 @@ internal data class ProguardConfig( val targetConstructorParams: List, val qualifierProperties: Set ) { - private val outputFile = "META-INF/proguard/moshi-${targetClass.canonicalName}.pro" - - /** Writes this to `filer`. */ - fun writeTo(filer: Filer, vararg originatingElements: Element) { - filer.createResource(StandardLocation.CLASS_OUTPUT, "", outputFile, *originatingElements) - .openWriter() - .use(::writeTo) + fun outputFilePathWithoutExtension(canonicalName: String): String { + return "META-INF/proguard/moshi-$canonicalName" } - private fun writeTo(out: Appendable): Unit = out.run { + fun writeTo(out: Appendable): Unit = out.run { // // -if class {the target class} // -keepnames class {the target class} diff --git a/kotlin/codegen/src/main/java/com/squareup/moshi/kotlin/codegen/api/kotlintypes.kt b/kotlin/codegen/src/main/java/com/squareup/moshi/kotlin/codegen/api/kotlintypes.kt index 078fccf5f..680c760d7 100644 --- a/kotlin/codegen/src/main/java/com/squareup/moshi/kotlin/codegen/api/kotlintypes.kt +++ b/kotlin/codegen/src/main/java/com/squareup/moshi/kotlin/codegen/api/kotlintypes.kt @@ -217,8 +217,12 @@ internal fun List.toTypeVariableResolver( // replacement later that may add bounds referencing this. val id = typeVar.name parametersMap[id] = TypeVariableName(id) + } + + for (typeVar in this) { + check(typeVar is TypeVariableName) // Now replace it with the full version. - parametersMap[id] = typeVar.deepCopy(null) { it.stripTypeVarVariance(resolver) } + parametersMap[typeVar.name] = typeVar.deepCopy(null) { it.stripTypeVarVariance(resolver) } } return resolver diff --git a/kotlin/codegen/src/main/java/com/squareup/moshi/kotlin/codegen/api/typeAliasUnwrapping.kt b/kotlin/codegen/src/main/java/com/squareup/moshi/kotlin/codegen/api/typeAliasUnwrapping.kt new file mode 100644 index 000000000..87dd39865 --- /dev/null +++ b/kotlin/codegen/src/main/java/com/squareup/moshi/kotlin/codegen/api/typeAliasUnwrapping.kt @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2021 Square, Inc. + * + * 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. + */ +package com.squareup.moshi.kotlin.codegen.api + +import com.squareup.kotlinpoet.AnnotationSpec +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.LambdaTypeName +import com.squareup.kotlinpoet.ParameterizedTypeName +import com.squareup.kotlinpoet.TypeName +import com.squareup.kotlinpoet.TypeVariableName +import com.squareup.kotlinpoet.WildcardTypeName +import com.squareup.kotlinpoet.tag +import com.squareup.kotlinpoet.tags.TypeAliasTag +import java.util.TreeSet + +internal fun TypeName.unwrapTypeAliasReal(): TypeName { + return tag()?.abbreviatedType?.let { unwrappedType -> + // If any type is nullable, then the whole thing is nullable + var isAnyNullable = isNullable + // Keep track of all annotations across type levels. Sort them too for consistency. + val runningAnnotations = TreeSet(compareBy { it.toString() }).apply { + addAll(annotations) + } + val nestedUnwrappedType = unwrappedType.unwrapTypeAlias() + runningAnnotations.addAll(nestedUnwrappedType.annotations) + isAnyNullable = isAnyNullable || nestedUnwrappedType.isNullable + nestedUnwrappedType.copy(nullable = isAnyNullable, annotations = runningAnnotations.toList()) + } ?: this +} + +internal fun TypeName.unwrapTypeAlias(): TypeName { + return when (this) { + is ClassName -> unwrapTypeAliasReal() + is ParameterizedTypeName -> { + if (TypeAliasTag::class in tags) { + unwrapTypeAliasReal() + } else { + deepCopy(TypeName::unwrapTypeAlias) + } + } + is TypeVariableName -> { + if (TypeAliasTag::class in tags) { + unwrapTypeAliasReal() + } else { + deepCopy(transform = TypeName::unwrapTypeAlias) + } + } + is WildcardTypeName -> { + if (TypeAliasTag::class in tags) { + unwrapTypeAliasReal() + } else { + deepCopy(TypeName::unwrapTypeAlias) + } + } + is LambdaTypeName -> { + if (TypeAliasTag::class in tags) { + unwrapTypeAliasReal() + } else { + deepCopy(TypeName::unwrapTypeAlias) + } + } + else -> throw UnsupportedOperationException("Type '${javaClass.simpleName}' is illegal. Only classes, parameterized types, wildcard types, or type variables are allowed.") + } +} diff --git a/kotlin/codegen/src/main/java/com/squareup/moshi/kotlin/codegen/apt/JsonClassCodegenProcessor.kt b/kotlin/codegen/src/main/java/com/squareup/moshi/kotlin/codegen/apt/JsonClassCodegenProcessor.kt index 7ed9bebe4..88643847a 100644 --- a/kotlin/codegen/src/main/java/com/squareup/moshi/kotlin/codegen/apt/JsonClassCodegenProcessor.kt +++ b/kotlin/codegen/src/main/java/com/squareup/moshi/kotlin/codegen/apt/JsonClassCodegenProcessor.kt @@ -21,9 +21,11 @@ import com.squareup.kotlinpoet.ClassName import com.squareup.kotlinpoet.metadata.classinspectors.ElementsClassInspector import com.squareup.moshi.JsonClass import com.squareup.moshi.kotlin.codegen.api.AdapterGenerator +import com.squareup.moshi.kotlin.codegen.api.Options.OPTION_GENERATED +import com.squareup.moshi.kotlin.codegen.api.Options.OPTION_GENERATE_PROGUARD_RULES +import com.squareup.moshi.kotlin.codegen.api.Options.POSSIBLE_GENERATED_NAMES +import com.squareup.moshi.kotlin.codegen.api.ProguardConfig import com.squareup.moshi.kotlin.codegen.api.PropertyGenerator -import net.ltgt.gradle.incap.IncrementalAnnotationProcessor -import net.ltgt.gradle.incap.IncrementalAnnotationProcessorType.ISOLATING import javax.annotation.processing.AbstractProcessor import javax.annotation.processing.Filer import javax.annotation.processing.Messager @@ -31,10 +33,12 @@ import javax.annotation.processing.ProcessingEnvironment import javax.annotation.processing.Processor import javax.annotation.processing.RoundEnvironment import javax.lang.model.SourceVersion +import javax.lang.model.element.Element import javax.lang.model.element.TypeElement import javax.lang.model.util.Elements import javax.lang.model.util.Types import javax.tools.Diagnostic +import javax.tools.StandardLocation /** * An annotation processor that reads Kotlin data classes and generates Moshi JsonAdapters for them. @@ -45,34 +49,8 @@ import javax.tools.Diagnostic * adapter will also be internal). */ @AutoService(Processor::class) -@IncrementalAnnotationProcessor(ISOLATING) public class JsonClassCodegenProcessor : AbstractProcessor() { - public companion object { - /** - * This annotation processing argument can be specified to have a `@Generated` annotation - * included in the generated code. It is not encouraged unless you need it for static analysis - * reasons and not enabled by default. - * - * Note that this can only be one of the following values: - * * `"javax.annotation.processing.Generated"` (JRE 9+) - * * `"javax.annotation.Generated"` (JRE <9) - */ - public const val OPTION_GENERATED: String = "moshi.generated" - - /** - * This boolean processing option can control proguard rule generation. - * Normally, this is not recommended unless end-users build their own JsonAdapter look-up tool. - * This is enabled by default. - */ - public const val OPTION_GENERATE_PROGUARD_RULES: String = "moshi.generateProguardRules" - - private val POSSIBLE_GENERATED_NAMES = arrayOf( - ClassName("javax.annotation.processing", "Generated"), - ClassName("javax.annotation", "Generated") - ).associateBy { it.canonicalName } - } - private lateinit var types: Types private lateinit var elements: Elements private lateinit var filer: Filer @@ -190,3 +168,10 @@ public class JsonClassCodegenProcessor : AbstractProcessor() { return AdapterGenerator(type, sortedProperties) } } + +/** Writes this config to a [filer]. */ +private fun ProguardConfig.writeTo(filer: Filer, vararg originatingElements: Element) { + filer.createResource(StandardLocation.CLASS_OUTPUT, "", "${outputFilePathWithoutExtension(targetClass.canonicalName)}.pro", *originatingElements) + .openWriter() + .use(::writeTo) +} diff --git a/kotlin/codegen/src/main/java/com/squareup/moshi/kotlin/codegen/apt/metadata.kt b/kotlin/codegen/src/main/java/com/squareup/moshi/kotlin/codegen/apt/metadata.kt index bfd301237..a081df2f6 100644 --- a/kotlin/codegen/src/main/java/com/squareup/moshi/kotlin/codegen/apt/metadata.kt +++ b/kotlin/codegen/src/main/java/com/squareup/moshi/kotlin/codegen/apt/metadata.kt @@ -19,12 +19,10 @@ import com.squareup.kotlinpoet.AnnotationSpec import com.squareup.kotlinpoet.ClassName import com.squareup.kotlinpoet.DelicateKotlinPoetApi import com.squareup.kotlinpoet.KModifier -import com.squareup.kotlinpoet.LambdaTypeName import com.squareup.kotlinpoet.ParameterizedTypeName import com.squareup.kotlinpoet.TypeName import com.squareup.kotlinpoet.TypeSpec import com.squareup.kotlinpoet.TypeVariableName -import com.squareup.kotlinpoet.WildcardTypeName import com.squareup.kotlinpoet.asClassName import com.squareup.kotlinpoet.asTypeName import com.squareup.kotlinpoet.metadata.KotlinPoetMetadataPreview @@ -37,7 +35,6 @@ import com.squareup.kotlinpoet.metadata.isLocal import com.squareup.kotlinpoet.metadata.isPublic import com.squareup.kotlinpoet.metadata.isSealed import com.squareup.kotlinpoet.tag -import com.squareup.kotlinpoet.tags.TypeAliasTag import com.squareup.moshi.Json import com.squareup.moshi.JsonQualifier import com.squareup.moshi.kotlin.codegen.api.DelegateKey @@ -46,15 +43,14 @@ import com.squareup.moshi.kotlin.codegen.api.TargetConstructor import com.squareup.moshi.kotlin.codegen.api.TargetParameter import com.squareup.moshi.kotlin.codegen.api.TargetProperty import com.squareup.moshi.kotlin.codegen.api.TargetType -import com.squareup.moshi.kotlin.codegen.api.deepCopy import com.squareup.moshi.kotlin.codegen.api.rawType +import com.squareup.moshi.kotlin.codegen.api.unwrapTypeAlias import kotlinx.metadata.KmConstructor import kotlinx.metadata.jvm.signature import java.lang.annotation.ElementType import java.lang.annotation.Retention import java.lang.annotation.RetentionPolicy import java.lang.annotation.Target -import java.util.TreeSet import javax.annotation.processing.Messager import javax.lang.model.element.AnnotationMirror import javax.lang.model.element.Element @@ -516,30 +512,6 @@ private fun String.escapeDollarSigns(): String { return replace("\$", "\${\'\$\'}") } -internal fun TypeName.unwrapTypeAlias(): TypeName { - return when (this) { - is ClassName -> { - tag()?.abbreviatedType?.let { unwrappedType -> - // If any type is nullable, then the whole thing is nullable - var isAnyNullable = isNullable - // Keep track of all annotations across type levels. Sort them too for consistency. - val runningAnnotations = TreeSet(compareBy { it.toString() }).apply { - addAll(annotations) - } - val nestedUnwrappedType = unwrappedType.unwrapTypeAlias() - runningAnnotations.addAll(nestedUnwrappedType.annotations) - isAnyNullable = isAnyNullable || nestedUnwrappedType.isNullable - nestedUnwrappedType.copy(nullable = isAnyNullable, annotations = runningAnnotations.toList()) - } ?: this - } - is ParameterizedTypeName -> deepCopy(TypeName::unwrapTypeAlias) - is TypeVariableName -> deepCopy(transform = TypeName::unwrapTypeAlias) - is WildcardTypeName -> deepCopy(TypeName::unwrapTypeAlias) - is LambdaTypeName -> deepCopy(TypeName::unwrapTypeAlias) - else -> throw UnsupportedOperationException("Type '${javaClass.simpleName}' is illegal. Only classes, parameterized types, wildcard types, or type variables are allowed.") - } -} - internal val TypeElement.metadata: Metadata get() { return getAnnotation(Metadata::class.java) diff --git a/kotlin/codegen/src/main/java/com/squareup/moshi/kotlin/codegen/ksp/AppliedType.kt b/kotlin/codegen/src/main/java/com/squareup/moshi/kotlin/codegen/ksp/AppliedType.kt new file mode 100644 index 000000000..1038baede --- /dev/null +++ b/kotlin/codegen/src/main/java/com/squareup/moshi/kotlin/codegen/ksp/AppliedType.kt @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2021 Square, Inc. + * + * 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. + */ +package com.squareup.moshi.kotlin.codegen.ksp + +import com.google.devtools.ksp.getAllSuperTypes +import com.google.devtools.ksp.processing.Resolver +import com.google.devtools.ksp.symbol.ClassKind.CLASS +import com.google.devtools.ksp.symbol.KSClassDeclaration +import com.squareup.kotlinpoet.ANY +import com.squareup.kotlinpoet.TypeName +import com.squareup.kotlinpoet.asClassName +import com.squareup.kotlinpoet.ksp.toClassName + +private val OBJECT_CLASS = java.lang.Object::class.asClassName() + +/** + * A concrete type like `List` with enough information to know how to resolve its type + * variables. + */ +internal class AppliedType private constructor( + val type: KSClassDeclaration, + val typeName: TypeName = type.toClassName() +) { + + /** Returns all supertypes of this, recursively. Includes both interface and class supertypes. */ + fun supertypes( + resolver: Resolver, + ): LinkedHashSet { + val result: LinkedHashSet = LinkedHashSet() + result.add(this) + for (supertype in type.getAllSuperTypes()) { + val decl = supertype.declaration + check(decl is KSClassDeclaration) + if (decl.classKind != CLASS) { + // Don't load properties for interface types. + continue + } + val qualifiedName = decl.qualifiedName + val superTypeKsClass = resolver.getClassDeclarationByName(qualifiedName!!)!! + val typeName = decl.toClassName() + if (typeName == ANY || typeName == OBJECT_CLASS) { + // Don't load properties for kotlin.Any/java.lang.Object. + continue + } + result.add(AppliedType(superTypeKsClass, typeName)) + } + return result + } + + override fun toString() = type.qualifiedName!!.asString() + + companion object { + fun get(type: KSClassDeclaration): AppliedType { + return AppliedType(type) + } + } +} diff --git a/kotlin/codegen/src/main/java/com/squareup/moshi/kotlin/codegen/ksp/JsonClassSymbolProcessorProvider.kt b/kotlin/codegen/src/main/java/com/squareup/moshi/kotlin/codegen/ksp/JsonClassSymbolProcessorProvider.kt new file mode 100644 index 000000000..9776957d0 --- /dev/null +++ b/kotlin/codegen/src/main/java/com/squareup/moshi/kotlin/codegen/ksp/JsonClassSymbolProcessorProvider.kt @@ -0,0 +1,167 @@ +/* + * Copyright (C) 2021 Square, Inc. + * + * 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. + */ +package com.squareup.moshi.kotlin.codegen.ksp + +import com.google.auto.service.AutoService +import com.google.devtools.ksp.processing.CodeGenerator +import com.google.devtools.ksp.processing.Dependencies +import com.google.devtools.ksp.processing.KSPLogger +import com.google.devtools.ksp.processing.Resolver +import com.google.devtools.ksp.processing.SymbolProcessor +import com.google.devtools.ksp.processing.SymbolProcessorEnvironment +import com.google.devtools.ksp.processing.SymbolProcessorProvider +import com.google.devtools.ksp.symbol.KSAnnotated +import com.google.devtools.ksp.symbol.KSDeclaration +import com.google.devtools.ksp.symbol.KSFile +import com.squareup.kotlinpoet.AnnotationSpec +import com.squareup.kotlinpoet.ksp.addOriginatingKSFile +import com.squareup.kotlinpoet.ksp.toClassName +import com.squareup.kotlinpoet.ksp.writeTo +import com.squareup.moshi.JsonClass +import com.squareup.moshi.kotlin.codegen.api.AdapterGenerator +import com.squareup.moshi.kotlin.codegen.api.Options.OPTION_GENERATED +import com.squareup.moshi.kotlin.codegen.api.Options.OPTION_GENERATE_PROGUARD_RULES +import com.squareup.moshi.kotlin.codegen.api.Options.POSSIBLE_GENERATED_NAMES +import com.squareup.moshi.kotlin.codegen.api.ProguardConfig +import com.squareup.moshi.kotlin.codegen.api.PropertyGenerator +import java.io.OutputStreamWriter +import java.nio.charset.StandardCharsets + +@AutoService(SymbolProcessorProvider::class) +public class JsonClassSymbolProcessorProvider : SymbolProcessorProvider { + override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor { + return JsonClassSymbolProcessor(environment) + } +} + +private class JsonClassSymbolProcessor( + environment: SymbolProcessorEnvironment +) : SymbolProcessor { + + private companion object { + val JSON_CLASS_NAME = JsonClass::class.qualifiedName!! + } + + private val codeGenerator = environment.codeGenerator + private val logger = environment.logger + private val generatedOption = environment.options[OPTION_GENERATED]?.also { + logger.check(it in POSSIBLE_GENERATED_NAMES) { + "Invalid option value for $OPTION_GENERATED. Found $it, allowable values are ${POSSIBLE_GENERATED_NAMES.keys}." + } + } + private val generateProguardRules = environment.options[OPTION_GENERATE_PROGUARD_RULES]?.toBooleanStrictOrNull() ?: true + + override fun process(resolver: Resolver): List { + val generatedAnnotation = generatedOption?.let { + val annotationType = resolver.getClassDeclarationByName(resolver.getKSNameFromString(it)) + ?: run { + logger.error("Generated annotation type doesn't exist: $it") + return emptyList() + } + AnnotationSpec.builder(annotationType.toClassName()) + .addMember("value = [%S]", JsonClassSymbolProcessor::class.java.canonicalName) + .addMember("comments = %S", "https://github.com/square/moshi") + .build() + } + + resolver.getSymbolsWithAnnotation(JSON_CLASS_NAME) + .asSequence() + .forEach { type -> + // For the smart cast + if (type !is KSDeclaration) { + logger.error("@JsonClass can't be applied to $type: must be a Kotlin class", type) + return@forEach + } + + val jsonClassAnnotation = type.findAnnotationWithType() ?: return@forEach + + val generator = jsonClassAnnotation.generator + + if (generator.isNotEmpty()) return@forEach + + if (!jsonClassAnnotation.generateAdapter) return@forEach + + val originatingFile = type.containingFile!! + val adapterGenerator = adapterGenerator(logger, resolver, type) ?: return emptyList() + try { + val preparedAdapter = adapterGenerator + .prepare(generateProguardRules) { spec -> + spec.toBuilder() + .apply { + generatedAnnotation?.let(::addAnnotation) + } + .addOriginatingKSFile(originatingFile) + .build() + } + preparedAdapter.spec.writeTo(codeGenerator, aggregating = false) + preparedAdapter.proguardConfig?.writeTo(codeGenerator, originatingFile) + } catch (e: Exception) { + logger.error( + "Error preparing ${type.simpleName.asString()}: ${e.stackTrace.joinToString("\n")}" + ) + } + } + return emptyList() + } + + private fun adapterGenerator( + logger: KSPLogger, + resolver: Resolver, + originalType: KSDeclaration, + ): AdapterGenerator? { + val type = targetType(originalType, resolver, logger) ?: return null + + val properties = mutableMapOf() + for (property in type.properties.values) { + val generator = property.generator(logger, resolver, originalType) + if (generator != null) { + properties[property.name] = generator + } + } + + for ((name, parameter) in type.constructor.parameters) { + if (type.properties[parameter.name] == null && !parameter.hasDefault) { + // TODO would be nice if we could pass the parameter node directly? + logger.error("No property for required constructor parameter $name", originalType) + return null + } + } + + // Sort properties so that those with constructor parameters come first. + val sortedProperties = properties.values.sortedBy { + if (it.hasConstructorParameter) { + it.target.parameterIndex + } else { + Integer.MAX_VALUE + } + } + + return AdapterGenerator(type, sortedProperties) + } +} + +/** Writes this config to a [codeGenerator]. */ +private fun ProguardConfig.writeTo(codeGenerator: CodeGenerator, originatingKSFile: KSFile) { + val file = codeGenerator.createNewFile( + dependencies = Dependencies(aggregating = false, originatingKSFile), + packageName = "", + fileName = outputFilePathWithoutExtension(targetClass.canonicalName), + extensionName = "pro" + ) + // Don't use writeTo(file) because that tries to handle directories under the hood + OutputStreamWriter(file, StandardCharsets.UTF_8) + .use(::writeTo) +} diff --git a/kotlin/codegen/src/main/java/com/squareup/moshi/kotlin/codegen/ksp/KspUtil.kt b/kotlin/codegen/src/main/java/com/squareup/moshi/kotlin/codegen/ksp/KspUtil.kt new file mode 100644 index 000000000..ce1e0f17a --- /dev/null +++ b/kotlin/codegen/src/main/java/com/squareup/moshi/kotlin/codegen/ksp/KspUtil.kt @@ -0,0 +1,126 @@ +/* + * Copyright (C) 2021 Square, Inc. + * + * 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. + */ +package com.squareup.moshi.kotlin.codegen.ksp + +import com.google.devtools.ksp.processing.KSPLogger +import com.google.devtools.ksp.processing.Resolver +import com.google.devtools.ksp.symbol.ClassKind +import com.google.devtools.ksp.symbol.KSAnnotated +import com.google.devtools.ksp.symbol.KSAnnotation +import com.google.devtools.ksp.symbol.KSClassDeclaration +import com.google.devtools.ksp.symbol.KSName +import com.google.devtools.ksp.symbol.KSNode +import com.google.devtools.ksp.symbol.KSType +import com.google.devtools.ksp.symbol.KSTypeAlias +import com.google.devtools.ksp.symbol.Origin.KOTLIN +import com.google.devtools.ksp.symbol.Origin.KOTLIN_LIB +import com.squareup.kotlinpoet.AnnotationSpec +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.CodeBlock +import com.squareup.kotlinpoet.ksp.toClassName + +internal fun KSClassDeclaration.asType() = asType(emptyList()) + +internal fun KSClassDeclaration.isKotlinClass(): Boolean { + return origin == KOTLIN || + origin == KOTLIN_LIB || + isAnnotationPresent(Metadata::class) +} + +internal inline fun KSAnnotated.findAnnotationWithType(): T? { + return getAnnotationsByType(T::class).firstOrNull() +} + +internal fun KSType.unwrapTypeAlias(): KSType { + return if (this.declaration is KSTypeAlias) { + (this.declaration as KSTypeAlias).type.resolve() + } else { + this + } +} + +internal fun KSAnnotation.toAnnotationSpec(resolver: Resolver): AnnotationSpec { + val element = annotationType.resolve().unwrapTypeAlias().declaration as KSClassDeclaration + val builder = AnnotationSpec.builder(element.toClassName()) + for (argument in arguments) { + val member = CodeBlock.builder() + val name = argument.name!!.getShortName() + member.add("%L = ", name) + addValueToBlock(argument.value!!, resolver, member) + builder.addMember(member.build()) + } + return builder.build() +} + +private fun addValueToBlock(value: Any, resolver: Resolver, member: CodeBlock.Builder) { + when (value) { + is List<*> -> { + // Array type + member.add("[⇥⇥") + value.forEachIndexed { index, innerValue -> + if (index > 0) member.add(", ") + addValueToBlock(innerValue!!, resolver, member) + } + member.add("⇤⇤]") + } + is KSType -> { + val unwrapped = value.unwrapTypeAlias() + val isEnum = (unwrapped.declaration as KSClassDeclaration).classKind == ClassKind.ENUM_ENTRY + if (isEnum) { + val parent = unwrapped.declaration.parentDeclaration as KSClassDeclaration + val entry = unwrapped.declaration.simpleName.getShortName() + member.add("%T.%L", parent.toClassName(), entry) + } else { + member.add("%T::class", unwrapped.toClassName()) + } + } + is KSName -> + member.add( + "%T.%L", ClassName.bestGuess(value.getQualifier()), + value.getShortName() + ) + is KSAnnotation -> member.add("%L", value.toAnnotationSpec(resolver)) + else -> member.add(memberForValue(value)) + } +} + +/** + * Creates a [CodeBlock] with parameter `format` depending on the given `value` object. + * Handles a number of special cases, such as appending "f" to `Float` values, and uses + * `%L` for other types. + */ +internal fun memberForValue(value: Any) = when (value) { + is Class<*> -> CodeBlock.of("%T::class", value) + is Enum<*> -> CodeBlock.of("%T.%L", value.javaClass, value.name) + is String -> CodeBlock.of("%S", value) + is Float -> CodeBlock.of("%Lf", value) + is Double -> CodeBlock.of("%L", value) + is Char -> CodeBlock.of("$value.toChar()") + is Byte -> CodeBlock.of("$value.toByte()") + is Short -> CodeBlock.of("$value.toShort()") + // Int or Boolean + else -> CodeBlock.of("%L", value) +} + +internal inline fun KSPLogger.check(condition: Boolean, message: () -> String) { + check(condition, null, message) +} + +internal inline fun KSPLogger.check(condition: Boolean, element: KSNode?, message: () -> String) { + if (!condition) { + error(message(), element) + } +} diff --git a/kotlin/codegen/src/main/java/com/squareup/moshi/kotlin/codegen/ksp/MoshiApiUtil.kt b/kotlin/codegen/src/main/java/com/squareup/moshi/kotlin/codegen/ksp/MoshiApiUtil.kt new file mode 100644 index 000000000..747389e55 --- /dev/null +++ b/kotlin/codegen/src/main/java/com/squareup/moshi/kotlin/codegen/ksp/MoshiApiUtil.kt @@ -0,0 +1,119 @@ +/* + * Copyright (C) 2021 Square, Inc. + * + * 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. + */ +package com.squareup.moshi.kotlin.codegen.ksp + +import com.google.devtools.ksp.processing.KSPLogger +import com.google.devtools.ksp.processing.Resolver +import com.google.devtools.ksp.symbol.KSClassDeclaration +import com.google.devtools.ksp.symbol.KSDeclaration +import com.squareup.kotlinpoet.AnnotationSpec +import com.squareup.kotlinpoet.KModifier +import com.squareup.kotlinpoet.asClassName +import com.squareup.moshi.JsonQualifier +import com.squareup.moshi.kotlin.codegen.api.DelegateKey +import com.squareup.moshi.kotlin.codegen.api.PropertyGenerator +import com.squareup.moshi.kotlin.codegen.api.TargetProperty +import com.squareup.moshi.kotlin.codegen.api.rawType + +private val VISIBILITY_MODIFIERS = setOf( + KModifier.INTERNAL, + KModifier.PRIVATE, + KModifier.PROTECTED, + KModifier.PUBLIC +) + +internal fun Collection.visibility(): KModifier { + return find { it in VISIBILITY_MODIFIERS } ?: KModifier.PUBLIC +} + +private val TargetProperty.isTransient get() = propertySpec.annotations.any { it.typeName == Transient::class.asClassName() } +private val TargetProperty.isSettable get() = propertySpec.mutable || parameter != null +private val TargetProperty.isVisible: Boolean + get() { + return visibility == KModifier.INTERNAL || + visibility == KModifier.PROTECTED || + visibility == KModifier.PUBLIC + } + +/** + * Returns a generator for this property, or null if either there is an error and this property + * cannot be used with code gen, or if no codegen is necessary for this property. + */ +internal fun TargetProperty.generator( + logger: KSPLogger, + resolver: Resolver, + originalType: KSDeclaration +): PropertyGenerator? { + if (isTransient) { + if (!hasDefault) { + logger.error( + "No default value for transient property $name", + originalType + ) + return null + } + return PropertyGenerator(this, DelegateKey(type, emptyList()), true) + } + + if (!isVisible) { + logger.error( + "property $name is not visible", + originalType + ) + return null + } + + if (!isSettable) { + return null // This property is not settable. Ignore it. + } + + // Merge parameter and property annotations + val qualifiers = parameter?.qualifiers.orEmpty() + propertySpec.annotations + for (jsonQualifier in qualifiers) { + val qualifierRawType = jsonQualifier.typeName.rawType() + // Check Java types since that covers both Java and Kotlin annotations. + resolver.getClassDeclarationByName(qualifierRawType.canonicalName)?.let { annotationElement -> + annotationElement.findAnnotationWithType()?.let { + if (it.value != AnnotationRetention.RUNTIME) { + logger.error( + "JsonQualifier @${qualifierRawType.simpleName} must have RUNTIME retention" + ) + } + } + annotationElement.findAnnotationWithType()?.let { + if (AnnotationTarget.FIELD !in it.allowedTargets) { + logger.error( + "JsonQualifier @${qualifierRawType.simpleName} must support FIELD target" + ) + } + } + } + } + + val jsonQualifierSpecs = qualifiers.map { + it.toBuilder() + .useSiteTarget(AnnotationSpec.UseSiteTarget.FIELD) + .build() + } + + return PropertyGenerator( + this, + DelegateKey(type, jsonQualifierSpecs) + ) +} + +internal val KSClassDeclaration.isJsonQualifier: Boolean + get() = isAnnotationPresent(JsonQualifier::class) diff --git a/kotlin/codegen/src/main/java/com/squareup/moshi/kotlin/codegen/ksp/TargetTypes.kt b/kotlin/codegen/src/main/java/com/squareup/moshi/kotlin/codegen/ksp/TargetTypes.kt new file mode 100644 index 000000000..52d90c801 --- /dev/null +++ b/kotlin/codegen/src/main/java/com/squareup/moshi/kotlin/codegen/ksp/TargetTypes.kt @@ -0,0 +1,280 @@ +/* + * Copyright (C) 2021 Square, Inc. + * + * 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. + */ +package com.squareup.moshi.kotlin.codegen.ksp + +import com.google.devtools.ksp.KspExperimental +import com.google.devtools.ksp.getDeclaredProperties +import com.google.devtools.ksp.getVisibility +import com.google.devtools.ksp.isInternal +import com.google.devtools.ksp.isLocal +import com.google.devtools.ksp.isPublic +import com.google.devtools.ksp.processing.KSPLogger +import com.google.devtools.ksp.processing.Resolver +import com.google.devtools.ksp.symbol.ClassKind +import com.google.devtools.ksp.symbol.ClassKind.CLASS +import com.google.devtools.ksp.symbol.KSAnnotated +import com.google.devtools.ksp.symbol.KSClassDeclaration +import com.google.devtools.ksp.symbol.KSDeclaration +import com.google.devtools.ksp.symbol.KSPropertyDeclaration +import com.google.devtools.ksp.symbol.KSType +import com.google.devtools.ksp.symbol.KSTypeParameter +import com.google.devtools.ksp.symbol.Modifier +import com.google.devtools.ksp.symbol.Origin +import com.squareup.kotlinpoet.AnnotationSpec +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.KModifier +import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy +import com.squareup.kotlinpoet.PropertySpec +import com.squareup.kotlinpoet.TypeName +import com.squareup.kotlinpoet.ksp.TypeParameterResolver +import com.squareup.kotlinpoet.ksp.toClassName +import com.squareup.kotlinpoet.ksp.toKModifier +import com.squareup.kotlinpoet.ksp.toTypeName +import com.squareup.kotlinpoet.ksp.toTypeParameterResolver +import com.squareup.kotlinpoet.ksp.toTypeVariableName +import com.squareup.moshi.Json +import com.squareup.moshi.JsonQualifier +import com.squareup.moshi.kotlin.codegen.api.TargetConstructor +import com.squareup.moshi.kotlin.codegen.api.TargetParameter +import com.squareup.moshi.kotlin.codegen.api.TargetProperty +import com.squareup.moshi.kotlin.codegen.api.TargetType +import com.squareup.moshi.kotlin.codegen.api.unwrapTypeAlias + +/** Returns a target type for `element`, or null if it cannot be used with code gen. */ +internal fun targetType( + type: KSDeclaration, + resolver: Resolver, + logger: KSPLogger, +): TargetType? { + if (type !is KSClassDeclaration) { + logger.error("@JsonClass can't be applied to ${type.qualifiedName?.asString()}: must be a Kotlin class", type) + return null + } + logger.check(type.classKind != ClassKind.ENUM_CLASS, type) { + "@JsonClass with 'generateAdapter = \"true\"' can't be applied to ${type.qualifiedName?.asString()}: code gen for enums is not supported or necessary" + } + logger.check(type.classKind == CLASS && type.origin == Origin.KOTLIN, type) { + "@JsonClass can't be applied to ${type.qualifiedName?.asString()}: must be a Kotlin class" + } + logger.check(Modifier.INNER !in type.modifiers, type) { + "@JsonClass can't be applied to ${type.qualifiedName?.asString()}: must not be an inner class" + } + logger.check(Modifier.SEALED !in type.modifiers, type) { + "@JsonClass can't be applied to ${type.qualifiedName?.asString()}: must not be sealed" + } + logger.check(Modifier.ABSTRACT !in type.modifiers, type) { + "@JsonClass can't be applied to ${type.qualifiedName?.asString()}: must not be abstract" + } + logger.check(!type.isLocal(), type) { + "@JsonClass can't be applied to ${type.qualifiedName?.asString()}: must not be local" + } + logger.check(type.isPublic() || type.isInternal(), type) { + "@JsonClass can't be applied to ${type.qualifiedName?.asString()}: must be internal or public" + } + + val classTypeParamsResolver = type.typeParameters.toTypeParameterResolver( + sourceTypeHint = type.qualifiedName!!.asString() + ) + val typeVariables = type.typeParameters.map { it.toTypeVariableName(classTypeParamsResolver) } + val appliedType = AppliedType.get(type) + + val constructor = primaryConstructor(resolver, type, classTypeParamsResolver, logger) + ?: run { + logger.error("No primary constructor found on $type", type) + return null + } + if (constructor.visibility != KModifier.INTERNAL && constructor.visibility != KModifier.PUBLIC) { + logger.error( + "@JsonClass can't be applied to $type: " + + "primary constructor is not internal or public", + type + ) + return null + } + + val properties = mutableMapOf() + + val originalType = appliedType.type + for (supertype in appliedType.supertypes(resolver)) { + val classDecl = supertype.type + if (!classDecl.isKotlinClass()) { + logger.error( + """ + @JsonClass can't be applied to $type: supertype $supertype is not a Kotlin type. + Origin=${classDecl.origin} + Annotations=${classDecl.annotations.joinToString(prefix = "[", postfix = "]") { it.shortName.getShortName() }} + """.trimIndent(), + type + ) + return null + } + val supertypeProperties = declaredProperties( + constructor = constructor, + originalType = originalType, + classDecl = classDecl, + resolver = resolver, + typeParameterResolver = classDecl.typeParameters + .toTypeParameterResolver(classTypeParamsResolver) + ) + for ((name, property) in supertypeProperties) { + properties.putIfAbsent(name, property) + } + } + val visibility = type.getVisibility().toKModifier() ?: KModifier.PUBLIC + // If any class in the enclosing class hierarchy is internal, they must all have internal + // generated adapters. + val resolvedVisibility = if (visibility == KModifier.INTERNAL) { + // Our nested type is already internal, no need to search + visibility + } else { + // Implicitly public, so now look up the hierarchy + val forceInternal = generateSequence(type) { it.parentDeclaration } + .filterIsInstance() + .any { it.isInternal() } + if (forceInternal) KModifier.INTERNAL else visibility + } + return TargetType( + typeName = type.toClassName().withTypeArguments(typeVariables), + constructor = constructor, + properties = properties, + typeVariables = typeVariables, + isDataClass = Modifier.DATA in type.modifiers, + visibility = resolvedVisibility + ) +} + +private fun ClassName.withTypeArguments(arguments: List): TypeName { + return if (arguments.isEmpty()) { + this + } else { + this.parameterizedBy(arguments) + } +} + +@OptIn(KspExperimental::class) +internal fun primaryConstructor( + resolver: Resolver, + targetType: KSClassDeclaration, + typeParameterResolver: TypeParameterResolver, + logger: KSPLogger +): TargetConstructor? { + val primaryConstructor = targetType.primaryConstructor ?: return null + + val parameters = LinkedHashMap() + for ((index, parameter) in primaryConstructor.parameters.withIndex()) { + val name = parameter.name!!.getShortName() + parameters[name] = TargetParameter( + name = name, + index = index, + type = parameter.type.toTypeName(typeParameterResolver), + hasDefault = parameter.hasDefault, + qualifiers = parameter.qualifiers(resolver), + jsonName = parameter.jsonName() + ) + } + + val kmConstructorSignature: String = resolver.mapToJvmSignature(primaryConstructor) + ?: run { + logger.error("No primary constructor found.", primaryConstructor) + return null + } + return TargetConstructor( + parameters, + primaryConstructor.getVisibility().toKModifier() ?: KModifier.PUBLIC, + kmConstructorSignature + ) +} + +private fun KSAnnotated?.qualifiers(resolver: Resolver): Set { + if (this == null) return setOf() + return annotations + .filter { + it.annotationType.resolve().declaration.isAnnotationPresent(JsonQualifier::class) + } + .mapTo(mutableSetOf()) { + it.toAnnotationSpec(resolver) + } +} + +private fun KSAnnotated?.jsonName(): String? { + return this?.findAnnotationWithType()?.name +} + +private fun declaredProperties( + constructor: TargetConstructor, + originalType: KSClassDeclaration, + classDecl: KSClassDeclaration, + resolver: Resolver, + typeParameterResolver: TypeParameterResolver, +): Map { + + val result = mutableMapOf() + for (property in classDecl.getDeclaredProperties()) { + val initialType = property.type.resolve() + val resolvedType = if (initialType.declaration is KSTypeParameter) { + property.asMemberOf(originalType.asType()) + } else { + initialType + } + val propertySpec = property.toPropertySpec(resolver, resolvedType, typeParameterResolver) + val name = propertySpec.name + val parameter = constructor.parameters[name] + result[name] = TargetProperty( + propertySpec = propertySpec, + parameter = parameter, + visibility = property.getVisibility().toKModifier() ?: KModifier.PUBLIC, + jsonName = parameter?.jsonName ?: property.jsonName() + ?: name.escapeDollarSigns() + ) + } + + return result +} + +private fun KSPropertyDeclaration.toPropertySpec( + resolver: Resolver, + resolvedType: KSType, + typeParameterResolver: TypeParameterResolver, +): PropertySpec { + return PropertySpec.builder( + name = simpleName.getShortName(), + type = resolvedType.toTypeName(typeParameterResolver).unwrapTypeAlias() + ) + .mutable(isMutable) + .addModifiers(modifiers.map { KModifier.valueOf(it.name) }) + .apply { + if (isAnnotationPresent(Transient::class)) { + addAnnotation(Transient::class) + } + addAnnotations( + this@toPropertySpec.annotations + .mapNotNull { + if ((it.annotationType.resolve().unwrapTypeAlias().declaration as KSClassDeclaration).isJsonQualifier + ) { + it.toAnnotationSpec(resolver) + } else { + null + } + } + .asIterable() + ) + } + .build() +} + +private fun String.escapeDollarSigns(): String { + return replace("\$", "\${\'\$\'}") +} diff --git a/kotlin/codegen/src/main/java/com/squareup/moshi/kotlin/codegen/ksp/shadedUtil.kt b/kotlin/codegen/src/main/java/com/squareup/moshi/kotlin/codegen/ksp/shadedUtil.kt new file mode 100644 index 000000000..3d480d59b --- /dev/null +++ b/kotlin/codegen/src/main/java/com/squareup/moshi/kotlin/codegen/ksp/shadedUtil.kt @@ -0,0 +1,185 @@ +/* + * Copyright (C) 2020 Square, Inc. + * + * 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. + */ +package com.squareup.moshi.kotlin.codegen.ksp + +import com.google.devtools.ksp.processing.Resolver +import com.google.devtools.ksp.symbol.KSAnnotated +import com.google.devtools.ksp.symbol.KSAnnotation +import com.google.devtools.ksp.symbol.KSClassDeclaration +import com.google.devtools.ksp.symbol.KSType +import com.google.devtools.ksp.symbol.KSValueArgument +import java.lang.reflect.InvocationHandler +import java.lang.reflect.Method +import java.lang.reflect.Proxy +import java.util.concurrent.ConcurrentHashMap +import kotlin.reflect.KClass + +/* + * Copied experimental utilities from KSP. + */ + +/** + * Find a class in the compilation classpath for the given name. + * + * @param name fully qualified name of the class to be loaded; using '.' as separator. + * @return a KSClassDeclaration, or null if not found. + */ +internal fun Resolver.getClassDeclarationByName(name: String): KSClassDeclaration? = + getClassDeclarationByName(getKSNameFromString(name)) + +internal fun KSAnnotated.getAnnotationsByType(annotationKClass: KClass): Sequence { + return this.annotations.filter { + it.shortName.getShortName() == annotationKClass.simpleName && it.annotationType.resolve().declaration + .qualifiedName?.asString() == annotationKClass.qualifiedName + }.map { it.toAnnotation(annotationKClass.java) } +} + +internal fun KSAnnotated.isAnnotationPresent(annotationKClass: KClass): Boolean = + getAnnotationsByType(annotationKClass).firstOrNull() != null + +@Suppress("UNCHECKED_CAST") +private fun KSAnnotation.toAnnotation(annotationClass: Class): T { + return Proxy.newProxyInstance( + annotationClass.classLoader, + arrayOf(annotationClass), + createInvocationHandler(annotationClass) + ) as T +} + +@Suppress("TooGenericExceptionCaught") +private fun KSAnnotation.createInvocationHandler(clazz: Class<*>): InvocationHandler { + val cache = ConcurrentHashMap, Any>, Any>(arguments.size) + return InvocationHandler { proxy, method, _ -> + if (method.name == "toString" && arguments.none { it.name?.asString() == "toString" }) { + clazz.canonicalName + + arguments.map { argument: KSValueArgument -> + // handles default values for enums otherwise returns null + val methodName = argument.name?.asString() + val value = proxy.javaClass.methods.find { m -> m.name == methodName }?.invoke(proxy) + "$methodName=$value" + }.toList() + } else { + val argument = try { + arguments.first { it.name?.asString() == method.name } + } catch (e: NullPointerException) { + throw IllegalArgumentException("This is a bug using the default KClass for an annotation", e) + } + when (val result = argument.value ?: method.defaultValue) { + is Proxy -> result + is List<*> -> { + val value = { result.asArray(method) } + cache.getOrPut(Pair(method.returnType, result), value) + } + else -> { + when { + method.returnType.isEnum -> { + val value = { result.asEnum(method.returnType) } + cache.getOrPut(Pair(method.returnType, result), value) + } + method.returnType.isAnnotation -> { + val value = { (result as KSAnnotation).asAnnotation(method.returnType) } + cache.getOrPut(Pair(method.returnType, result), value) + } + method.returnType.name == "java.lang.Class" -> { + val value = { (result as KSType).asClass() } + cache.getOrPut(Pair(method.returnType, result), value) + } + method.returnType.name == "byte" -> { + val value = { result.asByte() } + cache.getOrPut(Pair(method.returnType, result), value) + } + method.returnType.name == "short" -> { + val value = { result.asShort() } + cache.getOrPut(Pair(method.returnType, result), value) + } + else -> result // original value + } + } + } + } + } +} + +@Suppress("UNCHECKED_CAST") +private fun KSAnnotation.asAnnotation( + annotationInterface: Class<*>, +): Any { + return Proxy.newProxyInstance( + this.javaClass.classLoader, arrayOf(annotationInterface), + this.createInvocationHandler(annotationInterface) + ) as Proxy +} + +@Suppress("UNCHECKED_CAST") +private fun List<*>.asArray(method: Method) = + when (method.returnType.componentType.name) { + "boolean" -> (this as List).toBooleanArray() + "byte" -> (this as List).toByteArray() + "short" -> (this as List).toShortArray() + "char" -> (this as List).toCharArray() + "double" -> (this as List).toDoubleArray() + "float" -> (this as List).toFloatArray() + "int" -> (this as List).toIntArray() + "long" -> (this as List).toLongArray() + "java.lang.Class" -> (this as List).map { + Class.forName(it.declaration.qualifiedName!!.asString()) + }.toTypedArray() + "java.lang.String" -> (this as List).toTypedArray() + else -> { // arrays of enums or annotations + when { + method.returnType.componentType.isEnum -> { + this.toArray(method) { result -> result.asEnum(method.returnType.componentType) } + } + method.returnType.componentType.isAnnotation -> { + this.toArray(method) { result -> + (result as KSAnnotation).asAnnotation(method.returnType.componentType) + } + } + else -> throw IllegalStateException("Unable to process type ${method.returnType.componentType.name}") + } + } + } + +@Suppress("UNCHECKED_CAST") +private fun List<*>.toArray(method: Method, valueProvider: (Any) -> Any): Array { + val array: Array = java.lang.reflect.Array.newInstance( + method.returnType.componentType, + this.size + ) as Array + for (r in 0 until this.size) { + array[r] = this[r]?.let { valueProvider.invoke(it) } + } + return array +} + +@Suppress("UNCHECKED_CAST") +private fun Any.asEnum(returnType: Class): T = + returnType.getDeclaredMethod("valueOf", String::class.java) + .invoke( + null, + // Change from upstream KSP - https://github.com/google/ksp/pull/685 + if (this is KSType) { + this.declaration.simpleName.getShortName() + } else { + this.toString() + } + ) as T + +private fun Any.asByte(): Byte = if (this is Int) this.toByte() else this as Byte + +private fun Any.asShort(): Short = if (this is Int) this.toShort() else this as Short + +private fun KSType.asClass() = Class.forName(this.declaration.qualifiedName!!.asString()) diff --git a/kotlin/codegen/src/main/resources/META-INF/gradle/incremental.annotation.processors b/kotlin/codegen/src/main/resources/META-INF/gradle/incremental.annotation.processors new file mode 100644 index 000000000..6605eb357 --- /dev/null +++ b/kotlin/codegen/src/main/resources/META-INF/gradle/incremental.annotation.processors @@ -0,0 +1 @@ +com.squareup.moshi.kotlin.codegen.apt.JsonClassCodegenProcessor,ISOLATING diff --git a/kotlin/codegen/src/test/java/com/squareup/moshi/kotlin/codegen/apt/JsonClassCodegenProcessorTest.kt b/kotlin/codegen/src/test/java/com/squareup/moshi/kotlin/codegen/apt/JsonClassCodegenProcessorTest.kt index abf411959..1da2e092b 100644 --- a/kotlin/codegen/src/test/java/com/squareup/moshi/kotlin/codegen/apt/JsonClassCodegenProcessorTest.kt +++ b/kotlin/codegen/src/test/java/com/squareup/moshi/kotlin/codegen/apt/JsonClassCodegenProcessorTest.kt @@ -18,6 +18,9 @@ package com.squareup.moshi.kotlin.codegen.apt import com.google.common.truth.Truth.assertThat import com.squareup.moshi.JsonAdapter import com.squareup.moshi.JsonReader +import com.squareup.moshi.kotlin.codegen.api.Options +import com.squareup.moshi.kotlin.codegen.api.Options.OPTION_GENERATED +import com.squareup.moshi.kotlin.codegen.api.Options.OPTION_GENERATE_PROGUARD_RULES import com.tschuchort.compiletesting.KotlinCompilation import com.tschuchort.compiletesting.SourceFile import com.tschuchort.compiletesting.SourceFile.Companion.kotlin @@ -324,7 +327,6 @@ class JsonClassCodegenProcessorTest { """ import com.squareup.moshi.JsonClass - import com.squareup.moshi.JsonClass @JsonClass(generateAdapter = true) class NonPropertyConstructorParameter(a: Int, val b: Int) """ @@ -349,10 +351,10 @@ class JsonClassCodegenProcessorTest { """ ) ).apply { - kaptArgs[JsonClassCodegenProcessor.OPTION_GENERATED] = "javax.annotation.GeneratedBlerg" + kaptArgs[OPTION_GENERATED] = "javax.annotation.GeneratedBlerg" }.compile() assertThat(result.messages).contains( - "Invalid option value for ${JsonClassCodegenProcessor.OPTION_GENERATED}" + "Invalid option value for $OPTION_GENERATED" ) } @Test @@ -368,7 +370,7 @@ class JsonClassCodegenProcessorTest { """ ) ).apply { - kaptArgs[JsonClassCodegenProcessor.OPTION_GENERATE_PROGUARD_RULES] = "false" + kaptArgs[OPTION_GENERATE_PROGUARD_RULES] = "false" }.compile() assertThat(result.generatedFiles.filter { it.endsWith(".pro") }).isEmpty() } diff --git a/kotlin/codegen/src/test/java/com/squareup/moshi/kotlin/codegen/ksp/JsonClassSymbolProcessorTest.kt b/kotlin/codegen/src/test/java/com/squareup/moshi/kotlin/codegen/ksp/JsonClassSymbolProcessorTest.kt new file mode 100644 index 000000000..74f4dc0c3 --- /dev/null +++ b/kotlin/codegen/src/test/java/com/squareup/moshi/kotlin/codegen/ksp/JsonClassSymbolProcessorTest.kt @@ -0,0 +1,824 @@ +/* + * Copyright (C) 2021 Square, Inc. + * + * 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. + */ +package com.squareup.moshi.kotlin.codegen.ksp + +import com.google.common.truth.Truth.assertThat +import com.squareup.moshi.kotlin.codegen.api.Options.OPTION_GENERATED +import com.squareup.moshi.kotlin.codegen.api.Options.OPTION_GENERATE_PROGUARD_RULES +import com.tschuchort.compiletesting.KotlinCompilation +import com.tschuchort.compiletesting.SourceFile +import com.tschuchort.compiletesting.SourceFile.Companion.java +import com.tschuchort.compiletesting.SourceFile.Companion.kotlin +import com.tschuchort.compiletesting.kspArgs +import com.tschuchort.compiletesting.kspIncremental +import com.tschuchort.compiletesting.kspSourcesDir +import com.tschuchort.compiletesting.symbolProcessorProviders +import org.junit.Ignore +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import kotlin.reflect.KClassifier +import kotlin.reflect.KType +import kotlin.reflect.KTypeProjection +import kotlin.reflect.KVariance +import kotlin.reflect.KVariance.INVARIANT +import kotlin.reflect.full.createType + +/** Execute kotlinc to confirm that either files are generated or errors are printed. */ +class JsonClassSymbolProcessorTest { + + @Rule @JvmField var temporaryFolder: TemporaryFolder = TemporaryFolder() + + @Test + fun privateConstructor() { + val result = compile( + kotlin( + "source.kt", + """ + package test + import com.squareup.moshi.JsonClass + + @JsonClass(generateAdapter = true) + class PrivateConstructor private constructor(var a: Int, var b: Int) { + fun a() = a + fun b() = b + companion object { + fun newInstance(a: Int, b: Int) = PrivateConstructor(a, b) + } + } + """ + ) + ) + assertThat(result.exitCode).isEqualTo(KotlinCompilation.ExitCode.COMPILATION_ERROR) + assertThat(result.messages).contains("constructor is not internal or public") + } + + @Test + fun privateConstructorParameter() { + val result = compile( + kotlin( + "source.kt", + """ + package test + import com.squareup.moshi.JsonClass + + @JsonClass(generateAdapter = true) + class PrivateConstructorParameter(private var a: Int) + """ + ) + ) + assertThat(result.exitCode).isEqualTo(KotlinCompilation.ExitCode.COMPILATION_ERROR) + assertThat(result.messages).contains("property a is not visible") + } + + @Test + fun privateProperties() { + val result = compile( + kotlin( + "source.kt", + """ + package test + import com.squareup.moshi.JsonClass + + @JsonClass(generateAdapter = true) + class PrivateProperties { + private var a: Int = -1 + private var b: Int = -1 + } + """ + ) + ) + assertThat(result.exitCode).isEqualTo(KotlinCompilation.ExitCode.COMPILATION_ERROR) + assertThat(result.messages).contains("property a is not visible") + } + + @Test + fun interfacesNotSupported() { + val result = compile( + kotlin( + "source.kt", + """ + package test + import com.squareup.moshi.JsonClass + + @JsonClass(generateAdapter = true) + interface Interface + """ + ) + ) + assertThat(result.exitCode).isEqualTo(KotlinCompilation.ExitCode.COMPILATION_ERROR) + assertThat(result.messages).contains( + "@JsonClass can't be applied to test.Interface: must be a Kotlin class" + ) + } + + @Test + fun interfacesDoNotErrorWhenGeneratorNotSet() { + val result = compile( + kotlin( + "source.kt", + """ + package test + import com.squareup.moshi.JsonClass + + @JsonClass(generateAdapter = true, generator="customGenerator") + interface Interface + """ + ) + ) + assertThat(result.exitCode).isEqualTo(KotlinCompilation.ExitCode.OK) + } + + @Test + fun abstractClassesNotSupported() { + val result = compile( + kotlin( + "source.kt", + """ + package test + import com.squareup.moshi.JsonClass + + @JsonClass(generateAdapter = true) + abstract class AbstractClass(val a: Int) + """ + ) + ) + assertThat(result.exitCode).isEqualTo(KotlinCompilation.ExitCode.COMPILATION_ERROR) + assertThat(result.messages).contains( + "@JsonClass can't be applied to test.AbstractClass: must not be abstract" + ) + } + + @Test + fun sealedClassesNotSupported() { + val result = compile( + kotlin( + "source.kt", + """ + package test + import com.squareup.moshi.JsonClass + + @JsonClass(generateAdapter = true) + sealed class SealedClass(val a: Int) + """ + ) + ) + assertThat(result.exitCode).isEqualTo(KotlinCompilation.ExitCode.COMPILATION_ERROR) + assertThat(result.messages).contains( + "@JsonClass can't be applied to test.SealedClass: must not be sealed" + ) + } + + @Test + fun innerClassesNotSupported() { + val result = compile( + kotlin( + "source.kt", + """ + package test + import com.squareup.moshi.JsonClass + + class Outer { + @JsonClass(generateAdapter = true) + inner class InnerClass(val a: Int) + } + """ + ) + ) + assertThat(result.exitCode).isEqualTo(KotlinCompilation.ExitCode.COMPILATION_ERROR) + assertThat(result.messages).contains( + "@JsonClass can't be applied to test.Outer.InnerClass: must not be an inner class" + ) + } + + @Test + fun enumClassesNotSupported() { + val result = compile( + kotlin( + "source.kt", + """ + package test + import com.squareup.moshi.JsonClass + + @JsonClass(generateAdapter = true) + enum class KotlinEnum { + A, B + } + """ + ) + ) + assertThat(result.exitCode).isEqualTo(KotlinCompilation.ExitCode.COMPILATION_ERROR) + assertThat(result.messages).contains( + "@JsonClass with 'generateAdapter = \"true\"' can't be applied to test.KotlinEnum: code gen for enums is not supported or necessary" + ) + } + + // Annotation processors don't get called for local classes, so we don't have the opportunity to + @Ignore + @Test + fun localClassesNotSupported() { + val result = compile( + kotlin( + "source.kt", + """ + package test + import com.squareup.moshi.JsonClass + + fun outer() { + @JsonClass(generateAdapter = true) + class LocalClass(val a: Int) + } + """ + ) + ) + assertThat(result.exitCode).isEqualTo(KotlinCompilation.ExitCode.COMPILATION_ERROR) + assertThat(result.messages).contains( + "@JsonClass can't be applied to LocalClass: must not be local" + ) + } + + @Test + fun privateClassesNotSupported() { + val result = compile( + kotlin( + "source.kt", + """ + package test + import com.squareup.moshi.JsonClass + + @JsonClass(generateAdapter = true) + private class PrivateClass(val a: Int) + """ + ) + ) + assertThat(result.exitCode).isEqualTo(KotlinCompilation.ExitCode.COMPILATION_ERROR) + assertThat(result.messages).contains( + "@JsonClass can't be applied to test.PrivateClass: must be internal or public" + ) + } + + @Test + fun objectDeclarationsNotSupported() { + val result = compile( + kotlin( + "source.kt", + """ + package test + import com.squareup.moshi.JsonClass + + @JsonClass(generateAdapter = true) + object ObjectDeclaration { + var a = 5 + } + """ + ) + ) + assertThat(result.exitCode).isEqualTo(KotlinCompilation.ExitCode.COMPILATION_ERROR) + assertThat(result.messages).contains( + "@JsonClass can't be applied to test.ObjectDeclaration: must be a Kotlin class" + ) + } + + @Test + fun objectExpressionsNotSupported() { + val result = compile( + kotlin( + "source.kt", + """ + package test + import com.squareup.moshi.JsonClass + + @JsonClass(generateAdapter = true) + val expression = object : Any() { + var a = 5 + } + """ + ) + ) + assertThat(result.exitCode).isEqualTo(KotlinCompilation.ExitCode.COMPILATION_ERROR) + assertThat(result.messages).contains( + "@JsonClass can't be applied to test.expression: must be a Kotlin class" + ) + } + + @Test + fun requiredTransientConstructorParameterFails() { + val result = compile( + kotlin( + "source.kt", + """ + package test + import com.squareup.moshi.JsonClass + + @JsonClass(generateAdapter = true) + class RequiredTransientConstructorParameter(@Transient var a: Int) + """ + ) + ) + assertThat(result.exitCode).isEqualTo(KotlinCompilation.ExitCode.COMPILATION_ERROR) + assertThat(result.messages).contains( + "No default value for transient property a" + ) + } + + @Test + fun nonPropertyConstructorParameter() { + val result = compile( + kotlin( + "source.kt", + """ + package test + import com.squareup.moshi.JsonClass + + @JsonClass(generateAdapter = true) + class NonPropertyConstructorParameter(a: Int, val b: Int) + """ + ) + ) + assertThat(result.exitCode).isEqualTo(KotlinCompilation.ExitCode.COMPILATION_ERROR) + assertThat(result.messages).contains( + "No property for required constructor parameter a" + ) + } + + @Test + fun badGeneratedAnnotation() { + val result = prepareCompilation( + kotlin( + "source.kt", + """ + package test + import com.squareup.moshi.JsonClass + + @JsonClass(generateAdapter = true) + data class Foo(val a: Int) + """ + ) + ).apply { + kspArgs[OPTION_GENERATED] = "javax.annotation.GeneratedBlerg" + }.compile() + assertThat(result.messages).contains( + "Invalid option value for $OPTION_GENERATED" + ) + } + + @Test + fun disableProguardGeneration() { + val compilation = prepareCompilation( + kotlin( + "source.kt", + """ + package test + import com.squareup.moshi.JsonClass + + @JsonClass(generateAdapter = true) + data class Foo(val a: Int) + """ + ) + ).apply { + kspArgs[OPTION_GENERATE_PROGUARD_RULES] = "false" + } + val result = compilation.compile() + assertThat(result.exitCode).isEqualTo(KotlinCompilation.ExitCode.OK) + assertThat(compilation.kspSourcesDir.walkTopDown().filter { it.extension == "pro" }.toList()).isEmpty() + } + + @Test + fun multipleErrors() { + val result = compile( + kotlin( + "source.kt", + """ + package test + import com.squareup.moshi.JsonClass + + @JsonClass(generateAdapter = true) + class Class1(private var a: Int, private var b: Int) + + @JsonClass(generateAdapter = true) + class Class2(private var c: Int) + """ + ) + ) + assertThat(result.exitCode).isEqualTo(KotlinCompilation.ExitCode.COMPILATION_ERROR) + assertThat(result.messages).contains("property a is not visible") + // TODO we throw eagerly currently and don't collect +// assertThat(result.messages).contains("property c is not visible") + } + + @Test + fun extendPlatformType() { + val result = compile( + kotlin( + "source.kt", + """ + package test + import com.squareup.moshi.JsonClass + import java.util.Date + + @JsonClass(generateAdapter = true) + class ExtendsPlatformClass(var a: Int) : Date() + """ + ) + ) + assertThat(result.messages).contains("supertype java.util.Date is not a Kotlin type") + } + + @Test + fun extendJavaType() { + val result = compile( + kotlin( + "source.kt", + """ + package test + import com.squareup.moshi.JsonClass + import com.squareup.moshi.kotlin.codegen.JavaSuperclass + + @JsonClass(generateAdapter = true) + class ExtendsJavaType(var b: Int) : JavaSuperclass() + """ + ), + java( + "JavaSuperclass.java", + """ + package com.squareup.moshi.kotlin.codegen; + public class JavaSuperclass { + public int a = 1; + } + """ + ) + ) + assertThat(result.exitCode).isEqualTo(KotlinCompilation.ExitCode.COMPILATION_ERROR) + assertThat(result.messages) + .contains("supertype com.squareup.moshi.kotlin.codegen.JavaSuperclass is not a Kotlin type") + } + + @Test + fun nonFieldApplicableQualifier() { + val result = compile( + kotlin( + "source.kt", + """ + package test + import com.squareup.moshi.JsonClass + import com.squareup.moshi.JsonQualifier + import kotlin.annotation.AnnotationRetention.RUNTIME + import kotlin.annotation.AnnotationTarget.PROPERTY + import kotlin.annotation.Retention + import kotlin.annotation.Target + + @Retention(RUNTIME) + @Target(PROPERTY) + @JsonQualifier + annotation class UpperCase + + @JsonClass(generateAdapter = true) + class ClassWithQualifier(@UpperCase val a: Int) + """ + ) + ) + assertThat(result.exitCode).isEqualTo(KotlinCompilation.ExitCode.COMPILATION_ERROR) + assertThat(result.messages).contains("JsonQualifier @UpperCase must support FIELD target") + } + + @Test + fun nonRuntimeQualifier() { + val result = compile( + kotlin( + "source.kt", + """ + package test + import com.squareup.moshi.JsonClass + import com.squareup.moshi.JsonQualifier + import kotlin.annotation.AnnotationRetention.BINARY + import kotlin.annotation.AnnotationTarget.FIELD + import kotlin.annotation.AnnotationTarget.PROPERTY + import kotlin.annotation.Retention + import kotlin.annotation.Target + + @Retention(BINARY) + @Target(PROPERTY, FIELD) + @JsonQualifier + annotation class UpperCase + + @JsonClass(generateAdapter = true) + class ClassWithQualifier(@UpperCase val a: Int) + """ + ) + ) + assertThat(result.exitCode).isEqualTo(KotlinCompilation.ExitCode.COMPILATION_ERROR) + assertThat(result.messages).contains("JsonQualifier @UpperCase must have RUNTIME retention") + } + + @Test + fun `TypeAliases with the same backing type should share the same adapter`() { + val result = compile( + kotlin( + "source.kt", + """ + package test + import com.squareup.moshi.JsonClass + + typealias FirstName = String + typealias LastName = String + + @JsonClass(generateAdapter = true) + data class Person(val firstName: FirstName, val lastName: LastName, val hairColor: String) + """ + ) + ) + assertThat(result.exitCode).isEqualTo(KotlinCompilation.ExitCode.OK) + + // We're checking here that we only generate one `stringAdapter` that's used for both the + // regular string properties as well as the the aliased ones. + // TODO loading compiled classes from results not supported in KSP yet +// val adapterClass = result.classLoader.loadClass("PersonJsonAdapter").kotlin +// assertThat(adapterClass.declaredMemberProperties.map { it.returnType }).containsExactly( +// JsonReader.Options::class.createType(), +// JsonAdapter::class.parameterizedBy(String::class) +// ) + } + + @Test + fun `Processor should generate comprehensive proguard rules`() { + val compilation = prepareCompilation( + kotlin( + "source.kt", + """ + package testPackage + import com.squareup.moshi.JsonClass + import com.squareup.moshi.JsonQualifier + + typealias FirstName = String + typealias LastName = String + + @JsonClass(generateAdapter = true) + data class Aliases(val firstName: FirstName, val lastName: LastName, val hairColor: String) + + @JsonClass(generateAdapter = true) + data class Simple(val firstName: String) + + @JsonClass(generateAdapter = true) + data class Generic(val firstName: T, val lastName: String) + + @JsonQualifier + annotation class MyQualifier + + @JsonClass(generateAdapter = true) + data class UsingQualifiers(val firstName: String, @MyQualifier val lastName: String) + + @JsonClass(generateAdapter = true) + data class MixedTypes(val firstName: String, val otherNames: MutableList) + + @JsonClass(generateAdapter = true) + data class DefaultParams(val firstName: String = "") + + @JsonClass(generateAdapter = true) + data class Complex(val firstName: FirstName = "", @MyQualifier val names: MutableList, val genericProp: T) + + object NestedType { + @JsonQualifier + annotation class NestedQualifier + + @JsonClass(generateAdapter = true) + data class NestedSimple(@NestedQualifier val firstName: String) + } + + @JsonClass(generateAdapter = true) + class MultipleMasks( + val arg0: Long = 0, + val arg1: Long = 1, + val arg2: Long = 2, + val arg3: Long = 3, + val arg4: Long = 4, + val arg5: Long = 5, + val arg6: Long = 6, + val arg7: Long = 7, + val arg8: Long = 8, + val arg9: Long = 9, + val arg10: Long = 10, + val arg11: Long, + val arg12: Long = 12, + val arg13: Long = 13, + val arg14: Long = 14, + val arg15: Long = 15, + val arg16: Long = 16, + val arg17: Long = 17, + val arg18: Long = 18, + val arg19: Long = 19, + @Suppress("UNUSED_PARAMETER") arg20: Long = 20, + val arg21: Long = 21, + val arg22: Long = 22, + val arg23: Long = 23, + val arg24: Long = 24, + val arg25: Long = 25, + val arg26: Long = 26, + val arg27: Long = 27, + val arg28: Long = 28, + val arg29: Long = 29, + val arg30: Long = 30, + val arg31: Long = 31, + val arg32: Long = 32, + val arg33: Long = 33, + val arg34: Long = 34, + val arg35: Long = 35, + val arg36: Long = 36, + val arg37: Long = 37, + val arg38: Long = 38, + @Transient val arg39: Long = 39, + val arg40: Long = 40, + val arg41: Long = 41, + val arg42: Long = 42, + val arg43: Long = 43, + val arg44: Long = 44, + val arg45: Long = 45, + val arg46: Long = 46, + val arg47: Long = 47, + val arg48: Long = 48, + val arg49: Long = 49, + val arg50: Long = 50, + val arg51: Long = 51, + val arg52: Long = 52, + @Transient val arg53: Long = 53, + val arg54: Long = 54, + val arg55: Long = 55, + val arg56: Long = 56, + val arg57: Long = 57, + val arg58: Long = 58, + val arg59: Long = 59, + val arg60: Long = 60, + val arg61: Long = 61, + val arg62: Long = 62, + val arg63: Long = 63, + val arg64: Long = 64, + val arg65: Long = 65 + ) + """ + ) + ) + val result = compilation.compile() + assertThat(result.exitCode).isEqualTo(KotlinCompilation.ExitCode.OK) + + compilation.kspSourcesDir.walkTopDown().filter { it.extension == "pro" }.forEach { generatedFile -> + when (generatedFile.nameWithoutExtension) { + "moshi-testPackage.Aliases" -> assertThat(generatedFile.readText()).contains( + """ + -if class testPackage.Aliases + -keepnames class testPackage.Aliases + -if class testPackage.Aliases + -keep class testPackage.AliasesJsonAdapter { + public (com.squareup.moshi.Moshi); + } + """.trimIndent() + ) + "moshi-testPackage.Simple" -> assertThat(generatedFile.readText()).contains( + """ + -if class testPackage.Simple + -keepnames class testPackage.Simple + -if class testPackage.Simple + -keep class testPackage.SimpleJsonAdapter { + public (com.squareup.moshi.Moshi); + } + """.trimIndent() + ) + "moshi-testPackage.Generic" -> assertThat(generatedFile.readText()).contains( + """ + -if class testPackage.Generic + -keepnames class testPackage.Generic + -if class testPackage.Generic + -keep class testPackage.GenericJsonAdapter { + public (com.squareup.moshi.Moshi,java.lang.reflect.Type[]); + } + """.trimIndent() + ) + "moshi-testPackage.UsingQualifiers" -> assertThat(generatedFile.readText()).contains( + """ + -if class testPackage.UsingQualifiers + -keepnames class testPackage.UsingQualifiers + -if class testPackage.UsingQualifiers + -keep class testPackage.UsingQualifiersJsonAdapter { + public (com.squareup.moshi.Moshi); + private com.squareup.moshi.JsonAdapter stringAtMyQualifierAdapter; + } + -if class testPackage.UsingQualifiers + -keep @interface testPackage.MyQualifier + """.trimIndent() + ) + "moshi-testPackage.MixedTypes" -> assertThat(generatedFile.readText()).contains( + """ + -if class testPackage.MixedTypes + -keepnames class testPackage.MixedTypes + -if class testPackage.MixedTypes + -keep class testPackage.MixedTypesJsonAdapter { + public (com.squareup.moshi.Moshi); + } + """.trimIndent() + ) + "moshi-testPackage.DefaultParams" -> assertThat(generatedFile.readText()).contains( + """ + -if class testPackage.DefaultParams + -keepnames class testPackage.DefaultParams + -if class testPackage.DefaultParams + -keep class testPackage.DefaultParamsJsonAdapter { + public (com.squareup.moshi.Moshi); + } + -if class testPackage.DefaultParams + -keepnames class kotlin.jvm.internal.DefaultConstructorMarker + -if class testPackage.DefaultParams + -keepclassmembers class testPackage.DefaultParams { + public synthetic (java.lang.String,int,kotlin.jvm.internal.DefaultConstructorMarker); + } + """.trimIndent() + ) + "moshi-testPackage.Complex" -> assertThat(generatedFile.readText()).contains( + """ + -if class testPackage.Complex + -keepnames class testPackage.Complex + -if class testPackage.Complex + -keep class testPackage.ComplexJsonAdapter { + public (com.squareup.moshi.Moshi,java.lang.reflect.Type[]); + private com.squareup.moshi.JsonAdapter mutableListOfStringAtMyQualifierAdapter; + } + -if class testPackage.Complex + -keep @interface testPackage.MyQualifier + -if class testPackage.Complex + -keepnames class kotlin.jvm.internal.DefaultConstructorMarker + -if class testPackage.Complex + -keepclassmembers class testPackage.Complex { + public synthetic (java.lang.String,java.util.List,java.lang.Object,int,kotlin.jvm.internal.DefaultConstructorMarker); + } + """.trimIndent() + ) + "moshi-testPackage.MultipleMasks" -> assertThat(generatedFile.readText()).contains( + """ + -if class testPackage.MultipleMasks + -keepnames class testPackage.MultipleMasks + -if class testPackage.MultipleMasks + -keep class testPackage.MultipleMasksJsonAdapter { + public (com.squareup.moshi.Moshi); + } + -if class testPackage.MultipleMasks + -keepnames class kotlin.jvm.internal.DefaultConstructorMarker + -if class testPackage.MultipleMasks + -keepclassmembers class testPackage.MultipleMasks { + public synthetic (long,long,long,long,long,long,long,long,long,long,long,long,long,long,long,long,long,long,long,long,long,long,long,long,long,long,long,long,long,long,long,long,long,long,long,long,long,long,long,long,long,long,long,long,long,long,long,long,long,long,long,long,long,long,long,long,long,long,long,long,long,long,long,long,long,long,int,int,int,kotlin.jvm.internal.DefaultConstructorMarker); + } + """.trimIndent() + ) + "moshi-testPackage.NestedType.NestedSimple" -> assertThat(generatedFile.readText()).contains( + """ + -if class testPackage.NestedType${'$'}NestedSimple + -keepnames class testPackage.NestedType${'$'}NestedSimple + -if class testPackage.NestedType${'$'}NestedSimple + -keep class testPackage.NestedType_NestedSimpleJsonAdapter { + public (com.squareup.moshi.Moshi); + private com.squareup.moshi.JsonAdapter stringAtNestedQualifierAdapter; + } + -if class testPackage.NestedType${'$'}NestedSimple + -keep @interface testPackage.NestedType${'$'}NestedQualifier + """.trimIndent() + ) + else -> error("Unexpected proguard file! ${generatedFile.name}") + } + } + } + + private fun prepareCompilation(vararg sourceFiles: SourceFile): KotlinCompilation { + return KotlinCompilation() + .apply { + workingDir = temporaryFolder.root + inheritClassPath = true + symbolProcessorProviders = listOf(JsonClassSymbolProcessorProvider()) + sources = sourceFiles.asList() + verbose = false + kspIncremental = true // The default now + } + } + + private fun compile(vararg sourceFiles: SourceFile): KotlinCompilation.Result { + return prepareCompilation(*sourceFiles).compile() + } + + private fun KClassifier.parameterizedBy(vararg types: KType): KType { + return createType( + types.map { it.asProjection() } + ) + } + + private fun KType.asProjection(variance: KVariance? = INVARIANT): KTypeProjection { + return KTypeProjection(variance, this) + } +} diff --git a/kotlin/tests/build.gradle.kts b/kotlin/tests/build.gradle.kts index 907a36381..475bd3606 100644 --- a/kotlin/tests/build.gradle.kts +++ b/kotlin/tests/build.gradle.kts @@ -18,7 +18,15 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { kotlin("jvm") - kotlin("kapt") + kotlin("kapt") apply false + alias(libs.plugins.ksp) apply false +} + +val useKsp = hasProperty("useKsp") +if (useKsp) { + apply(plugin = "com.google.devtools.ksp") +} else { + apply(plugin = "org.jetbrains.kotlin.kapt") } tasks.withType().configureEach { @@ -37,9 +45,14 @@ tasks.withType().configureEach { } dependencies { - kaptTest(project(":kotlin:codegen")) + if (useKsp) { + "kspTest"(project(":kotlin:codegen")) + } else { + "kaptTest"(project(":kotlin:codegen")) + } testImplementation(project(":moshi")) testImplementation(project(":kotlin:reflect")) + testImplementation(project(":kotlin:tests:extra-moshi-test-module")) testImplementation(kotlin("reflect")) testImplementation(libs.junit) testImplementation(libs.assertj) diff --git a/kotlin/tests/extra-moshi-test-module/build.gradle.kts b/kotlin/tests/extra-moshi-test-module/build.gradle.kts new file mode 100644 index 000000000..d5dcb4693 --- /dev/null +++ b/kotlin/tests/extra-moshi-test-module/build.gradle.kts @@ -0,0 +1,19 @@ +/* + * Copyright (C) 2021 Square, Inc. + * + * 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 { + kotlin("jvm") +} diff --git a/kotlin/tests/extra-moshi-test-module/src/main/kotlin/com/squareup/moshi/kotlin/codegen/test/extra/AbstractClassInModuleA.kt b/kotlin/tests/extra-moshi-test-module/src/main/kotlin/com/squareup/moshi/kotlin/codegen/test/extra/AbstractClassInModuleA.kt new file mode 100644 index 000000000..2445e2e32 --- /dev/null +++ b/kotlin/tests/extra-moshi-test-module/src/main/kotlin/com/squareup/moshi/kotlin/codegen/test/extra/AbstractClassInModuleA.kt @@ -0,0 +1,18 @@ +/* + * Copyright (C) 2021 Square, Inc. + * + * 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. + */ +package com.squareup.moshi.kotlin.codegen.test.extra + +public abstract class AbstractClassInModuleA diff --git a/kotlin/tests/src/test/kotlin/com/squareup/moshi/kotlin/DualKotlinTest.kt b/kotlin/tests/src/test/kotlin/com/squareup/moshi/kotlin/DualKotlinTest.kt index 7cb0c3554..82d20a271 100644 --- a/kotlin/tests/src/test/kotlin/com/squareup/moshi/kotlin/DualKotlinTest.kt +++ b/kotlin/tests/src/test/kotlin/com/squareup/moshi/kotlin/DualKotlinTest.kt @@ -22,12 +22,13 @@ import com.squareup.moshi.JsonAdapter import com.squareup.moshi.JsonAdapter.Factory import com.squareup.moshi.JsonClass import com.squareup.moshi.JsonDataException +import com.squareup.moshi.JsonQualifier import com.squareup.moshi.Moshi import com.squareup.moshi.ToJson +import com.squareup.moshi.Types import com.squareup.moshi.adapter +import com.squareup.moshi.kotlin.codegen.test.extra.AbstractClassInModuleA import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory -import com.squareup.moshi.rawType -import com.squareup.moshi.supertypeOf import org.intellij.lang.annotations.Language import org.junit.Assert.fail import org.junit.Test @@ -63,11 +64,11 @@ class DualKotlinTest(useReflection: Boolean) { object : Factory { override fun create( type: Type, - annotations: Set, + annotations: MutableSet, moshi: Moshi ): JsonAdapter<*>? { // Prevent falling back to generated adapter lookup - val rawType = type.rawType + val rawType = Types.getRawType(type) val metadataClass = Class.forName("kotlin.Metadata") as Class check(rawType.isEnum || !rawType.isAnnotationPresent(metadataClass)) { "Unhandled Kotlin type in reflective test! $rawType" @@ -248,21 +249,21 @@ class DualKotlinTest(useReflection: Boolean) { val hasNonNullConstructorParameterAdapter = localMoshi.adapter() assertThat( + //language=JSON hasNonNullConstructorParameterAdapter -//language=JSON .fromJson("{\"a\":null}") ).isEqualTo(HasNonNullConstructorParameter("fallback")) val hasNullableConstructorParameterAdapter = localMoshi.adapter() assertThat( + //language=JSON hasNullableConstructorParameterAdapter -//language=JSON .fromJson("{\"a\":null}") ).isEqualTo(HasNullableConstructorParameter("fallback")) + //language=JSON assertThat( hasNullableConstructorParameterAdapter -//language=JSON .toJson(HasNullableConstructorParameter(null)) ).isEqualTo("{\"a\":\"fallback\"}") } @@ -284,7 +285,7 @@ class DualKotlinTest(useReflection: Boolean) { assertThat(decoded.a).isEqualTo(null) } - @Test fun inlineClass() { + @Test fun valueClass() { val adapter = moshi.adapter() val inline = ValueClass(5) @@ -297,6 +298,13 @@ class DualKotlinTest(useReflection: Boolean) { """{"i":6}""" val result = adapter.fromJson(testJson)!! assertThat(result.i).isEqualTo(6) + + // TODO doesn't work yet. + // need to invoke the constructor_impl$default static method, invoke constructor with result +// val testEmptyJson = +// """{}""" +// val result2 = adapter.fromJson(testEmptyJson)!! +// assertThat(result2.i).isEqualTo(0) } @JsonClass(generateAdapter = true) @@ -339,6 +347,50 @@ class DualKotlinTest(useReflection: Boolean) { abstract class Asset> abstract class AssetMetaData> + // Regression test for https://github.com/ZacSweers/MoshiX/issues/125 + @Test fun selfReferencingTypeVars() { + val adapter = moshi.adapter() + + val data = StringNodeNumberNode().also { + it.t = StringNodeNumberNode().also { + it.text = "child 1" + } + it.text = "root" + it.r = NumberStringNode().also { + it.number = 0 + it.t = NumberStringNode().also { + it.number = 1 + } + it.r = StringNodeNumberNode().also { + it.text = "grand child 1" + } + } + } + assertThat(adapter.toJson(data)) + //language=JSON + .isEqualTo( + """ + {"text":"root","t":{"text":"child 1"},"r":{"number":0,"t":{"number":1},"r":{"text":"grand child 1"}}} + """.trimIndent() + ) + } + + @JsonClass(generateAdapter = true) + open class Node, R : Node> { + var t: T? = null + var r: R? = null + } + + @JsonClass(generateAdapter = true) + class StringNodeNumberNode : Node() { + var text: String = "" + } + + @JsonClass(generateAdapter = true) + class NumberStringNode : Node() { + var number: Int = 0 + } + // Regression test for https://github.com/square/moshi/issues/968 @Test fun abstractSuperProperties() { val adapter = moshi.adapter() @@ -447,7 +499,7 @@ class DualKotlinTest(useReflection: Boolean) { @Test fun typeAliasUnwrapping() { val adapter = moshi .newBuilder() - .add(supertypeOf(), moshi.adapter()) + .add(Types.supertypeOf(Int::class.javaObjectType), moshi.adapter()) .build() .adapter() @@ -587,8 +639,7 @@ class DualKotlinTest(useReflection: Boolean) { @JsonClass(generateAdapter = true) data class OutDeclaration(val input: T) - // Regression test for https://github.com/square/moshi/issues/1244 - @Test fun backwardReferencingTypeVarsAndIntersectionTypes() { + @Test fun intersectionTypes() { val adapter = moshi.adapter>() @Language("JSON") @@ -625,7 +676,7 @@ data class GenericClass(val value: T) // Has to be outside since value classes are only allowed on top level @JvmInline @JsonClass(generateAdapter = true) -value class ValueClass(val i: Int) +value class ValueClass(val i: Int = 0) typealias A = Int typealias NullableA = A? @@ -634,3 +685,23 @@ typealias NullableB = B? typealias C = NullableA typealias D = C typealias E = D + +// Regression test for enum constants in annotations and array types +// https://github.com/ZacSweers/MoshiX/issues/103 +@Retention(RUNTIME) +@JsonQualifier +annotation class UpperCase(val foo: Array) + +enum class Foo { BAR } + +@JsonClass(generateAdapter = true) +data class ClassWithQualifier( + @UpperCase(foo = [Foo.BAR]) + val a: Int +) + +// Regression for https://github.com/ZacSweers/MoshiX/issues/120 +@JsonClass(generateAdapter = true) +data class DataClassInModuleB( + val id: String +) : AbstractClassInModuleA() diff --git a/kotlin/tests/src/test/kotlin/com/squareup/moshi/kotlin/codegen/MoshiKspTest.kt b/kotlin/tests/src/test/kotlin/com/squareup/moshi/kotlin/codegen/MoshiKspTest.kt new file mode 100644 index 000000000..01884b4cb --- /dev/null +++ b/kotlin/tests/src/test/kotlin/com/squareup/moshi/kotlin/codegen/MoshiKspTest.kt @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2021 Square, Inc. + * + * 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. + */ +package com.squareup.moshi.kotlin.codegen + +import com.google.common.truth.Truth.assertThat +import com.squareup.moshi.JsonClass +import com.squareup.moshi.Moshi +import com.squareup.moshi.adapter +import org.junit.Test + +// Regression tests specific to Moshi-KSP +class MoshiKspTest { + private val moshi = Moshi.Builder().build() + + // Regression test for https://github.com/ZacSweers/MoshiX/issues/44 + @Test + fun onlyInterfaceSupertypes() { + val adapter = moshi.adapter() + //language=JSON + val json = """{"a":"aValue","b":"bValue"}""" + val expected = SimpleImpl("aValue", "bValue") + val instance = adapter.fromJson(json)!! + assertThat(instance).isEqualTo(expected) + val encoded = adapter.toJson(instance) + assertThat(encoded).isEqualTo(json) + } + + interface SimpleInterface { + val a: String + } + + // NOTE the Any() superclass is important to test that we're detecting the farthest parent class + // correct.y + @JsonClass(generateAdapter = true) + data class SimpleImpl(override val a: String, val b: String) : Any(), SimpleInterface +} diff --git a/settings.gradle.kts b/settings.gradle.kts index def4049a0..f3a138f76 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -31,5 +31,6 @@ include(":examples") include(":kotlin:reflect") include(":kotlin:codegen") include(":kotlin:tests") +include(":kotlin:tests:extra-moshi-test-module") enableFeaturePreview("VERSION_CATALOGS")