diff --git a/README.md b/README.md index e72dd86..d00bcab 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,8 @@ AutoValue Kotlin ================ -auto-value-kotlin (AVK) is an [AutoValue](https://github.com/google/auto) extension that generates -binary-and-source-compatible, equivalent Kotlin `data` classes. This is intended to help migrations -by doing 95% of the work and just letting the developer come through and clean up the generated file -as-needed. +auto-value-kotlin (AVK) is an [AutoValue](https://github.com/google/auto) extension + processor +that generates binary-and-source-compatible, equivalent Kotlin `data` classes. The intended use of this project is to ease migration from AutoValue classes to Kotlin data classes and should be used ad-hoc rather than continuously. The idea is that it does 95% of the work for you @@ -38,9 +36,10 @@ kapt { arg("avkTargets", "ClassOne:ClassTwo") // Boolean option to ignore nested classes. By default, AVK will error out when it encounters - // a nested AutoValue class as it has no means of safely converting the class since its + // a nested non-AutoValue class as it has no means of safely converting the class since its // references are always qualified. This option can be set to true to make AVK just skip them // and emit a warning. + // AVK will automatically convert nested AutoValue and enum classes along the way. // OPTIONAL. False by default. arg("avkIgnoreNested", "true") } @@ -50,8 +49,8 @@ kapt { ## Workflow _Pre-requisites_ -* Move any nested AutoValue classes to top-level first (even if just temporarily for the migration). - * You can optionally choose to ignore nested classes or only specific targets per the configuration +* Move any nested non-AutoValue/non-enum classes to top-level first (even if just temporarily for the migration). + * You can optionally choose to ignore nested non-AutoValue classes or only specific targets per the configuration options detailed in the Installation section above. * Ensure no classes outside of the original AutoValue class accesses its generated `AutoValue_` class. * Clean once to clear up any generated file references. diff --git a/build.gradle.kts b/build.gradle.kts index 84b37cc..7e73741 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -114,6 +114,7 @@ dependencies { implementation("com.squareup.moshi:moshi:$moshiVersion") implementation("com.google.auto.service:auto-service:1.0") implementation("com.squareup:kotlinpoet:1.10.1") + implementation("com.squareup.okio:okio:3.0.0") implementation("com.google.auto.value:auto-value:1.8.2") implementation("com.google.auto.value:auto-value-annotations:1.8.2") testImplementation("junit:junit:4.13.2") diff --git a/src/main/kotlin/com/slack/auto/value/kotlin/AutoValueKotlinExtension.kt b/src/main/kotlin/com/slack/auto/value/kotlin/AutoValueKotlinExtension.kt index b3a27fd..88f1821 100644 --- a/src/main/kotlin/com/slack/auto/value/kotlin/AutoValueKotlinExtension.kt +++ b/src/main/kotlin/com/slack/auto/value/kotlin/AutoValueKotlinExtension.kt @@ -17,7 +17,9 @@ package com.slack.auto.value.kotlin -import com.google.auto.service.AutoService +import com.google.auto.common.MoreElements +import com.google.auto.common.MoreElements.isAnnotationPresent +import com.google.auto.value.AutoValue import com.google.auto.value.extension.AutoValueExtension import com.google.auto.value.extension.AutoValueExtension.BuilderContext import com.slack.auto.value.kotlin.AvkBuilder.BuilderProperty @@ -29,13 +31,17 @@ import com.squareup.kotlinpoet.FunSpec import com.squareup.kotlinpoet.KModifier import com.squareup.kotlinpoet.PropertySpec import com.squareup.kotlinpoet.TypeName +import com.squareup.kotlinpoet.TypeSpec import com.squareup.kotlinpoet.asClassName import com.squareup.kotlinpoet.asTypeVariableName import com.squareup.kotlinpoet.joinToCode import com.squareup.moshi.Json import java.util.Locale +import java.util.concurrent.ConcurrentHashMap +import javax.annotation.processing.Messager import javax.annotation.processing.ProcessingEnvironment import javax.lang.model.element.Element +import javax.lang.model.element.ElementKind import javax.lang.model.element.ExecutableElement import javax.lang.model.element.Modifier import javax.lang.model.element.NestingKind @@ -44,8 +50,7 @@ import javax.lang.model.util.Elements import javax.lang.model.util.Types import javax.tools.Diagnostic -@AutoService(AutoValueExtension::class) -public class AutoValueKotlinExtension : AutoValueExtension() { +public class AutoValueKotlinExtension(private val realMessager: Messager) : AutoValueExtension() { public companion object { // Options @@ -54,6 +59,8 @@ public class AutoValueKotlinExtension : AutoValueExtension() { public const val OPT_IGNORE_NESTED: String = "avkIgnoreNested" } + internal val collectedKclassees = ConcurrentHashMap() + internal val collectedEnums = ConcurrentHashMap() private lateinit var elements: Elements private lateinit var types: Types @@ -74,14 +81,7 @@ public class AutoValueKotlinExtension : AutoValueExtension() { } private fun FunSpec.Builder.withDocsFrom(e: Element): FunSpec.Builder { - return withDocsFrom(e) { parseDocs() } - } - - @Suppress("ReturnCount") - private fun Element.parseDocs(): String? { - val doc = elements.getDocComment(this)?.trim() ?: return null - if (doc.isBlank()) return null - return cleanUpDoc(doc) + return withDocsFrom(e) { parseDocs(elements) } } @Suppress("DEPRECATION", "LongMethod", "ComplexMethod", "NestedBlockDepth", "ReturnCount") @@ -91,33 +91,36 @@ public class AutoValueKotlinExtension : AutoValueExtension() { classToExtend: String, isFinal: Boolean ): String? { - val targetClasses = context.processingEnvironment().options[OPT_TARGETS] - ?.splitToSequence(":") - ?.toSet() - ?: emptySet() + val options = Options(context.processingEnvironment().options) val ignoreNested = context.processingEnvironment().options[OPT_IGNORE_NESTED]?.toBoolean() ?: false - if (targetClasses.isNotEmpty() && context.autoValueClass().simpleName.toString() !in targetClasses) { + if (options.targets.isNotEmpty() && context.autoValueClass().simpleName.toString() !in options.targets) { return null } val avClass = context.autoValueClass() - if (avClass.nestingKind != NestingKind.TOP_LEVEL) { - val diagnosticKind = if (ignoreNested) { - Diagnostic.Kind.WARNING - } else { - Diagnostic.Kind.ERROR + val isTopLevel = avClass.nestingKind == NestingKind.TOP_LEVEL + if (!isTopLevel) { + val isParentAv = isAnnotationPresent( + MoreElements.asType(avClass.enclosingElement), + AutoValue::class.java + ) + if (!isParentAv) { + val diagnosticKind = if (ignoreNested) { + Diagnostic.Kind.WARNING + } else { + Diagnostic.Kind.ERROR + } + realMessager + .printMessage( + diagnosticKind, + "Cannot convert nested classes to Kotlin safely. Please move this to top-level first.", + avClass + ) } - context.processingEnvironment().messager - .printMessage( - diagnosticKind, - "Cannot convert nested classes to Kotlin safely. Please move this to top-level first.", - avClass - ) - return null } // Check for non-builder nested classes, which cannot be converted with this @@ -128,19 +131,32 @@ public class AutoValueKotlinExtension : AutoValueExtension() { .orElse(false) } - if (nonBuilderNestedTypes.isNotEmpty()) { - nonBuilderNestedTypes.forEach { - context.processingEnvironment().messager + val (enums, nonEnums) = nonBuilderNestedTypes.partition { it.kind == ElementKind.ENUM } + + val (nestedAvClasses, remainingTypes) = nonEnums.partition { isAnnotationPresent(it, AutoValue::class.java) } + + if (remainingTypes.isNotEmpty()) { + remainingTypes.forEach { + realMessager .printMessage( Diagnostic.Kind.ERROR, - "Cannot convert nested classes to Kotlin safely. Please move this to top-level first.", + "Cannot convert non-autovalue nested classes to Kotlin safely. Please move this to top-level first.", it ) } return null } - val classDoc = avClass.parseDocs() + for (enumType in enums) { + val (cn, spec) = EnumConversion.convert( + elements, + realMessager, + enumType + ) ?: continue + collectedEnums[cn] = spec + } + + val classDoc = avClass.parseDocs(elements) var redactedClassName: ClassName? = null @@ -189,7 +205,7 @@ public class AutoValueKotlinExtension : AutoValueExtension() { isOverride = isAnOverride, isRedacted = isRedacted, visibility = if (Modifier.PUBLIC in method.modifiers) KModifier.PUBLIC else KModifier.INTERNAL, - doc = method.parseDocs() + doc = method.parseDocs(elements) ) } @@ -229,14 +245,13 @@ public class AutoValueKotlinExtension : AutoValueExtension() { // Note we don't use context.propertyTypes() here because it doesn't contain nullability // info, which we did capture val propertyTypes = properties.mapValues { it.value.type } - avkBuilder = AvkBuilder.from(builder, propertyTypes) { parseDocs() } + avkBuilder = AvkBuilder.from(builder, propertyTypes) { parseDocs(elements) } builderFactories += builder.builderMethods() builderFactorySpecs += builder.builderMethods() .map { FunSpec.copyOf(it) .withDocsFrom(it) - .addModifiers(avkBuilder.visibility) .addStatement("TODO(%S)", "Replace this with the implementation from the source class") .build() } @@ -387,7 +402,7 @@ public class AutoValueKotlinExtension : AutoValueExtension() { initializer("TODO()") } - field.parseDocs()?.let { addKdoc(it) } + field.parseDocs(elements)?.let { addKdoc(it) } } .build() } @@ -395,14 +410,11 @@ public class AutoValueKotlinExtension : AutoValueExtension() { val superclass = avClass.superclass.asSafeTypeName() .takeUnless { it == ClassName("java.lang", "Object") } - val srcDir = - context.processingEnvironment().options[OPT_SRC] ?: error("Missing src dir option") - - KotlinClass( + val kClass = KotlinClass( packageName = context.packageName(), doc = classDoc, name = avClass.simpleName.toString(), - visibility = if (Modifier.PUBLIC in avClass.modifiers) KModifier.PUBLIC else KModifier.INTERNAL, + visibility = avClass.visibility, isRedacted = isClassRedacted, isParcelable = isParcelable, superClass = superclass, @@ -417,8 +429,14 @@ public class AutoValueKotlinExtension : AutoValueExtension() { remainingMethods = remainingMethods, classAnnotations = avClass.classAnnotations(), redactedClassName = redactedClassName, - staticConstants = staticConstants - ).writeTo(srcDir, context.processingEnvironment().messager) + staticConstants = staticConstants, + isTopLevel = isTopLevel, + children = nestedAvClasses + .mapTo(LinkedHashSet()) { it.asClassName() } + .plus(collectedEnums.keys) + ) + + collectedKclassees[context.autoValueClass().asClassName()] = kClass return null } @@ -464,11 +482,7 @@ private fun AvkBuilder.Companion.from( return AvkBuilder( name = builderContext.builderType().simpleName.toString(), doc = builderContext.builderType().parseDocs(), - visibility = if (Modifier.PUBLIC in builderContext.builderType().modifiers) { - KModifier.PUBLIC - } else { - KModifier.INTERNAL - }, + visibility = builderContext.builderType().visibility, builderProps = props, buildFun = builderContext.buildMethod() .map { diff --git a/src/main/kotlin/com/slack/auto/value/kotlin/AutoValueKotlinProcessor.kt b/src/main/kotlin/com/slack/auto/value/kotlin/AutoValueKotlinProcessor.kt new file mode 100644 index 0000000..1c5b1a1 --- /dev/null +++ b/src/main/kotlin/com/slack/auto/value/kotlin/AutoValueKotlinProcessor.kt @@ -0,0 +1,248 @@ +/* + * Copyright (C) 2021 Slack Technologies, LLC + * + * 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.slack.auto.value.kotlin + +import com.google.auto.service.AutoService +import com.google.auto.value.AutoValue +import com.google.auto.value.extension.AutoValueExtension +import com.google.auto.value.processor.AutoValueProcessor +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.TypeSpec +import okio.Buffer +import okio.blackholeSink +import okio.buffer +import java.io.InputStream +import java.io.OutputStream +import java.io.Reader +import java.io.Writer +import java.net.URI +import java.util.ServiceLoader +import java.util.concurrent.ConcurrentHashMap +import javax.annotation.processing.AbstractProcessor +import javax.annotation.processing.Filer +import javax.annotation.processing.Messager +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.AnnotationMirror +import javax.lang.model.element.AnnotationValue +import javax.lang.model.element.Element +import javax.lang.model.element.Modifier +import javax.lang.model.element.Modifier.PUBLIC +import javax.lang.model.element.NestingKind +import javax.lang.model.element.NestingKind.TOP_LEVEL +import javax.lang.model.element.TypeElement +import javax.tools.Diagnostic.Kind +import javax.tools.FileObject +import javax.tools.JavaFileManager.Location +import javax.tools.JavaFileObject +import javax.tools.JavaFileObject.Kind.CLASS +import javax.tools.JavaFileObject.Kind.OTHER + +@AutoService(Processor::class) +public class AutoValueKotlinProcessor : AbstractProcessor() { + + private val collectedClasses: MutableMap = ConcurrentHashMap() + private val collectedEnums: MutableMap = ConcurrentHashMap() + + override fun getSupportedAnnotationTypes(): Set { + return setOf(AutoValue::class.java.canonicalName) + } + + override fun getSupportedSourceVersion(): SourceVersion { + return SourceVersion.latest() + } + + override fun process( + annotations: Set, + roundEnv: RoundEnvironment + ): Boolean { + // Load extensions ourselves + @Suppress("TooGenericExceptionCaught", "SwallowedException") + val extensions = try { + ServiceLoader.load(AutoValueExtension::class.java).toList() + } catch (e: Exception) { + emptyList() + } + + // Make our extension + val avkExtension = AutoValueKotlinExtension(processingEnv.messager) + + // Create an in-memory av processor and run it + val avProcessor = AutoValueProcessor(extensions + avkExtension) + avProcessor.init(object : ProcessingEnvironment by processingEnv { + override fun getMessager(): Messager = NoOpMessager + + override fun getFiler(): Filer = NoOpFiler + }) + avProcessor.process(annotations, roundEnv) + + // Save off our extracted classes + collectedClasses += avkExtension.collectedKclassees + collectedEnums += avkExtension.collectedEnums + + // We're done processing, write all our collected classes down + if (roundEnv.processingOver()) { + val srcDir = + processingEnv.options[AutoValueKotlinExtension.OPT_SRC] ?: error("Missing src dir option") + val roots = collectedClasses.filterValues { it.isTopLevel } + .toMutableMap() + for ((_, root) in roots) { + val spec = composeTypeSpec(root) + spec.writeCleanlyTo(root.packageName, srcDir) + } + } + return false + } + + private fun composeTypeSpec(kotlinClass: KotlinClass): TypeSpec { + val spec = kotlinClass.toTypeSpec(processingEnv.messager) + return spec.toBuilder() + .apply { + for (child in kotlinClass.children) { + val enumChild = collectedEnums.remove(child) + if (enumChild != null) { + addType(enumChild) + continue + } + val childKotlinClass = collectedClasses.remove(child) + ?: error("Missing child class $child for parent ${kotlinClass.name}") + addType(composeTypeSpec(childKotlinClass)) + } + } + .build() + } +} + +private object NoOpMessager : Messager { + override fun printMessage(kind: Kind?, msg: CharSequence?) { + // Do nothing + } + + override fun printMessage( + kind: Kind, + msg: CharSequence, + element: Element? + ) { + // Do nothing + } + + override fun printMessage(kind: Kind?, msg: CharSequence?, e: Element?, a: AnnotationMirror?) { + // Do nothing + } + + override fun printMessage( + kind: Kind?, + msg: CharSequence?, + e: Element?, + a: AnnotationMirror?, + v: AnnotationValue? + ) { + // Do nothing + } +} + +@Suppress("TooManyFunctions") +private class NoOpJfo( + private val name: String, + private val kind: JavaFileObject.Kind = JavaFileObject.Kind.SOURCE +) : JavaFileObject { + override fun toUri(): URI { + return URI("/dev/null") + } + + override fun getName(): String { + return name + } + + override fun openInputStream(): InputStream { + return Buffer().inputStream() + } + + override fun openOutputStream(): OutputStream { + return blackholeSink().buffer().outputStream() + } + + override fun openReader(ignoreEncodingErrors: Boolean): Reader { + return openInputStream().reader() + } + + override fun getCharContent(ignoreEncodingErrors: Boolean): CharSequence { + return "" + } + + override fun openWriter(): Writer { + return openOutputStream().writer() + } + + override fun getLastModified(): Long { + return -1L + } + + override fun delete(): Boolean { + return false + } + + override fun getKind(): JavaFileObject.Kind { + return kind + } + + override fun isNameCompatible(simpleName: String?, kind: JavaFileObject.Kind?): Boolean { + return true + } + + override fun getNestingKind(): NestingKind { + return TOP_LEVEL + } + + override fun getAccessLevel(): Modifier { + return PUBLIC + } +} + +private object NoOpFiler : Filer { + override fun createSourceFile( + name: CharSequence, + vararg originatingElements: Element? + ): JavaFileObject { + return NoOpJfo(name.toString()) + } + + override fun createClassFile( + name: CharSequence, + vararg originatingElements: Element? + ): JavaFileObject { + return NoOpJfo(name.toString(), CLASS) + } + + override fun createResource( + location: Location?, + moduleAndPkg: CharSequence?, + relativeName: CharSequence?, + vararg originatingElements: Element? + ): FileObject { + return NoOpJfo(relativeName.toString(), OTHER) + } + + override fun getResource( + location: Location?, + moduleAndPkg: CharSequence?, + relativeName: CharSequence? + ): FileObject { + return NoOpJfo(relativeName.toString(), OTHER) + } +} diff --git a/src/main/kotlin/com/slack/auto/value/kotlin/EnumConversion.kt b/src/main/kotlin/com/slack/auto/value/kotlin/EnumConversion.kt new file mode 100644 index 0000000..d2ee45b --- /dev/null +++ b/src/main/kotlin/com/slack/auto/value/kotlin/EnumConversion.kt @@ -0,0 +1,88 @@ +/* + * Copyright (C) 2021 Slack Technologies, LLC + * + * 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.slack.auto.value.kotlin + +import com.squareup.kotlinpoet.AnnotationSpec +import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.DelicateKotlinPoetApi +import com.squareup.kotlinpoet.TypeSpec +import com.squareup.kotlinpoet.asClassName +import javax.annotation.processing.Messager +import javax.lang.model.element.ElementKind.ENUM +import javax.lang.model.element.ElementKind.ENUM_CONSTANT +import javax.lang.model.element.TypeElement +import javax.lang.model.util.ElementFilter +import javax.lang.model.util.Elements +import javax.tools.Diagnostic.Kind.ERROR + +/** + * Simple utility to convert enums from Java to Kotlin. + * + * Can handle nested enums but will error out when encountering anything else. + */ +@ExperimentalAvkApi +public object EnumConversion { + @Suppress("ReturnCount") + @OptIn(DelicateKotlinPoetApi::class) + public fun convert( + elements: Elements, + messager: Messager, + element: TypeElement + ): Pair? { + val className = element.asClassName() + val docs = element.parseDocs(elements) + return className to TypeSpec.enumBuilder(className.simpleName) + .addModifiers(element.visibility) + .apply { + docs?.let { + addKdoc(it) + } + for (field in ElementFilter.fieldsIn(element.enclosedElements)) { + if (field.kind == ENUM_CONSTANT) { + val annotations = field.annotationMirrors + .map { AnnotationSpec.get(it) } + addEnumConstant( + field.simpleName.toString(), + TypeSpec.anonymousClassBuilder() + .addAnnotations(annotations) + .apply { + field.parseDocs(elements)?.let { + addKdoc(it) + } + } + .build() + ) + } + } + + for (nestedType in ElementFilter.typesIn(element.enclosedElements)) { + if (nestedType.kind == ENUM) { + convert(elements, messager, nestedType)?.second?.let(::addType) + } else { + messager.printMessage(ERROR, "Nested types in enums can only be other enums", nestedType) + return null + } + } + + for (method in ElementFilter.methodsIn(element.enclosedElements)) { + if (method.simpleName.toString() in setOf("values", "valueOf")) continue + messager.printMessage(ERROR, "Cannot convert nested enums with methods", method) + return null + } + } + .build() + } +} diff --git a/src/main/kotlin/com/slack/auto/value/kotlin/KotlinClass.kt b/src/main/kotlin/com/slack/auto/value/kotlin/KotlinClass.kt index 6067e4d..651a894 100644 --- a/src/main/kotlin/com/slack/auto/value/kotlin/KotlinClass.kt +++ b/src/main/kotlin/com/slack/auto/value/kotlin/KotlinClass.kt @@ -19,7 +19,6 @@ import com.squareup.kotlinpoet.AnnotationSpec import com.squareup.kotlinpoet.AnnotationSpec.UseSiteTarget.GET import com.squareup.kotlinpoet.ClassName import com.squareup.kotlinpoet.CodeBlock -import com.squareup.kotlinpoet.FileSpec import com.squareup.kotlinpoet.FunSpec import com.squareup.kotlinpoet.KModifier import com.squareup.kotlinpoet.KModifier.DATA @@ -31,15 +30,7 @@ import com.squareup.kotlinpoet.PropertySpec import com.squareup.kotlinpoet.TypeName import com.squareup.kotlinpoet.TypeSpec import com.squareup.kotlinpoet.TypeVariableName -import java.io.File -import java.io.OutputStreamWriter -import java.nio.charset.StandardCharsets -import java.nio.file.Files -import java.nio.file.Path import javax.annotation.processing.Messager -import kotlin.io.path.ExperimentalPathApi -import kotlin.io.path.readText -import kotlin.io.path.writeText @ExperimentalAvkApi public data class KotlinClass( @@ -62,10 +53,11 @@ public data class KotlinClass( val classAnnotations: List, val redactedClassName: ClassName?, val staticConstants: List, + val isTopLevel: Boolean, + val children: Set ) { @Suppress("LongMethod", "ComplexMethod") - @OptIn(ExperimentalPathApi::class) - public fun writeTo(dir: String, messager: Messager) { + public fun toTypeSpec(messager: Messager): TypeSpec { val typeBuilder = TypeSpec.classBuilder(name) .addModifiers(DATA) .addAnnotations(classAnnotations) @@ -217,69 +209,7 @@ public data class KotlinClass( typeBuilder.addType(companionObjectBuilder.build()) } - val file = File(dir).toPath() - val outputPath = FileSpec.get(packageName, typeBuilder.build()) - .writeToLocal(file) - val text = outputPath.readText() - // Post-process to remove any kotlin intrinsic types - // Is this wildly inefficient? yes. Does it really matter in our cases? nah - var prevWasBlank = false - outputPath.writeText( - text - .lineSequence() - .filterNot { it in INTRINSIC_IMPORTS } - .mapNotNull { - if (it.trimStart().startsWith("public ")) { - prevWasBlank = false - val indent = it.substringBefore("public ") - it.removePrefix(indent).removePrefix("public ").prependIndent(indent) - } else if (it.isKotlinPackageImport) { - // Ignore kotlin implicit imports - null - } else if (it.isBlank()) { - if (prevWasBlank) { - null - } else { - prevWasBlank = true - it - } - } else { - prevWasBlank = false - it - } - } - .joinToString("\n") - ) - } - - /** Best-effort checks if the string is an import from `kotlin.*` */ - @Suppress("MagicNumber") - private val String.isKotlinPackageImport: Boolean get() = startsWith("import kotlin.") && - // Looks like a class - // 14 is the length of `import kotlin.` - get(14).isUpperCase() && - // Exclude if it's importing a nested element - '.' !in removePrefix("import kotlin.") - - private fun FileSpec.writeToLocal(directory: Path): Path { - require(Files.notExists(directory) || Files.isDirectory(directory)) { - "path $directory exists but is not a directory." - } - var srcDirectory = directory - if (packageName.isNotEmpty()) { - for (packageComponent in packageName.split('.').dropLastWhile { it.isEmpty() }) { - srcDirectory = srcDirectory.resolve(packageComponent) - } - } - - Files.createDirectories(srcDirectory) - - val outputPath = srcDirectory.resolve("$name.kt") - OutputStreamWriter( - Files.newOutputStream(outputPath), - StandardCharsets.UTF_8 - ).use { writer -> writeTo(writer) } - return outputPath + return typeBuilder.build() } // Public for extension diff --git a/src/main/kotlin/com/slack/auto/value/kotlin/Options.kt b/src/main/kotlin/com/slack/auto/value/kotlin/Options.kt new file mode 100644 index 0000000..75db0cc --- /dev/null +++ b/src/main/kotlin/com/slack/auto/value/kotlin/Options.kt @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2021 Slack Technologies, LLC + * + * 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.slack.auto.value.kotlin + +import java.io.File + +public class Options(optionsMap: Map) { + + public val srcDir: File = optionsMap[OPT_SRC]?.let { File(it) } ?: error("Missing src dir option") + + public val targets: Set = optionsMap[OPT_TARGETS]?.splitToSequence(":") + ?.toSet() + ?: emptySet() + + public val ignoreNested: Boolean = optionsMap[OPT_IGNORE_NESTED]?.toBooleanStrict() ?: false + + public companion object { + public const val OPT_SRC: String = "avkSrc" + public const val OPT_TARGETS: String = "avkTargets" + public const val OPT_IGNORE_NESTED: String = "avkIgnoreNested" + + internal val ALL = setOf(OPT_SRC, OPT_TARGETS, OPT_IGNORE_NESTED) + } +} diff --git a/src/main/kotlin/com/slack/auto/value/kotlin/utils.kt b/src/main/kotlin/com/slack/auto/value/kotlin/utils.kt index df55ac8..71dbef4 100644 --- a/src/main/kotlin/com/slack/auto/value/kotlin/utils.kt +++ b/src/main/kotlin/com/slack/auto/value/kotlin/utils.kt @@ -17,6 +17,11 @@ @file:OptIn(DelicateKotlinPoetApi::class) package com.slack.auto.value.kotlin +import com.google.auto.common.Visibility +import com.google.auto.common.Visibility.DEFAULT +import com.google.auto.common.Visibility.PRIVATE +import com.google.auto.common.Visibility.PROTECTED +import com.google.auto.common.Visibility.PUBLIC import com.squareup.kotlinpoet.AnnotationSpec import com.squareup.kotlinpoet.BOOLEAN import com.squareup.kotlinpoet.BYTE @@ -26,6 +31,7 @@ import com.squareup.kotlinpoet.CodeBlock import com.squareup.kotlinpoet.DOUBLE import com.squareup.kotlinpoet.DelicateKotlinPoetApi import com.squareup.kotlinpoet.FLOAT +import com.squareup.kotlinpoet.FileSpec import com.squareup.kotlinpoet.FunSpec import com.squareup.kotlinpoet.INT import com.squareup.kotlinpoet.KModifier @@ -37,12 +43,18 @@ import com.squareup.kotlinpoet.ParameterizedTypeName.Companion.parameterizedBy import com.squareup.kotlinpoet.SHORT import com.squareup.kotlinpoet.STRING import com.squareup.kotlinpoet.TypeName +import com.squareup.kotlinpoet.TypeSpec import com.squareup.kotlinpoet.TypeVariableName import com.squareup.kotlinpoet.UNIT import com.squareup.kotlinpoet.asClassName import com.squareup.kotlinpoet.asTypeName import com.squareup.kotlinpoet.asTypeVariableName import com.squareup.moshi.Json +import java.io.File +import java.io.OutputStreamWriter +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.Path import javax.annotation.processing.ProcessingEnvironment import javax.lang.model.element.Element import javax.lang.model.element.ExecutableElement @@ -51,7 +63,11 @@ import javax.lang.model.element.TypeElement import javax.lang.model.element.VariableElement import javax.lang.model.type.TypeMirror import javax.lang.model.type.TypeVariable +import javax.lang.model.util.Elements import javax.lang.model.util.Types +import kotlin.io.path.ExperimentalPathApi +import kotlin.io.path.readText +import kotlin.io.path.writeText internal val NONNULL_ANNOTATIONS = setOf( "NonNull", @@ -171,6 +187,7 @@ public fun FunSpec.Companion.copyOf(method: ExecutableElement): FunSpec.Builder val methodName = method.simpleName.toString() val funBuilder = builder(methodName) + .addModifiers(method.visibility) modifiers = modifiers.toMutableSet() modifiers.remove(Modifier.ABSTRACT) @@ -214,7 +231,6 @@ public fun ParameterSpec.Companion.getWithNullability(element: VariableElement): element.annotationMirrors.any { (it.annotationType.asElement() as TypeElement).simpleName.toString() == "Nullable" } val type = element.asType().asSafeTypeName().copy(nullable = isNullable) return builder(name, type) - .jvmModifiers(element.modifiers) .build() } @@ -281,3 +297,88 @@ public fun ProcessingEnvironment.isParcelable(element: TypeElement): Boolean { private fun TypeMirror.isClassOfType(types: Types, other: TypeMirror?) = types.isAssignable(this, other) + +@ExperimentalAvkApi +public val Element.visibility: KModifier + get() = when (Visibility.effectiveVisibilityOfElement(this)!!) { + PRIVATE -> KModifier.PRIVATE + DEFAULT -> KModifier.INTERNAL + PROTECTED -> KModifier.PROTECTED + PUBLIC -> KModifier.PUBLIC + } + +@ExperimentalAvkApi +@OptIn(ExperimentalPathApi::class) +public fun TypeSpec.writeCleanlyTo(packageName: String, dir: String) { + val file = File(dir).toPath() + val outputPath = FileSpec.get(packageName, this) + .writeToLocal(file) + val text = outputPath.readText() + // Post-process to remove any kotlin intrinsic types + // Is this wildly inefficient? yes. Does it really matter in our cases? nah + var prevWasBlank = false + outputPath.writeText( + text + .lineSequence() + .filterNot { it in INTRINSIC_IMPORTS } + .mapNotNull { + if (it.trimStart().startsWith("public ")) { + prevWasBlank = false + val indent = it.substringBefore("public ") + it.removePrefix(indent).removePrefix("public ").prependIndent(indent) + } else if (it.isKotlinPackageImport) { + // Ignore kotlin implicit imports + null + } else if (it.isBlank()) { + if (prevWasBlank) { + null + } else { + prevWasBlank = true + it + } + } else { + prevWasBlank = false + it + } + } + .joinToString("\n") + ) +} + +/** Best-effort checks if the string is an import from `kotlin.*` */ +@Suppress("MagicNumber") +private val String.isKotlinPackageImport: Boolean get() = startsWith("import kotlin.") && + // Looks like a class + // 14 is the length of `import kotlin.` + get(14).isUpperCase() && + // Exclude if it's importing a nested element + '.' !in removePrefix("import kotlin.") + +private fun FileSpec.writeToLocal(directory: Path): Path { + require(Files.notExists(directory) || Files.isDirectory(directory)) { + "path $directory exists but is not a directory." + } + var srcDirectory = directory + if (packageName.isNotEmpty()) { + for (packageComponent in packageName.split('.').dropLastWhile { it.isEmpty() }) { + srcDirectory = srcDirectory.resolve(packageComponent) + } + } + + Files.createDirectories(srcDirectory) + + val outputPath = srcDirectory.resolve("$name.kt") + OutputStreamWriter( + Files.newOutputStream(outputPath), + StandardCharsets.UTF_8 + ).use { writer -> writeTo(writer) } + return outputPath +} + +@ExperimentalAvkApi +@Suppress("ReturnCount") +public fun Element.parseDocs(elements: Elements): String? { + val doc = elements.getDocComment(this)?.trim() ?: return null + if (doc.isBlank()) return null + return cleanUpDoc(doc) +} diff --git a/src/test/kotlin/com/slack/auto/value/kotlin/AutoValueKotlinExtensionTest.kt b/src/test/kotlin/com/slack/auto/value/kotlin/AutoValueKotlinExtensionTest.kt index fc887eb..9ff9b58 100644 --- a/src/test/kotlin/com/slack/auto/value/kotlin/AutoValueKotlinExtensionTest.kt +++ b/src/test/kotlin/com/slack/auto/value/kotlin/AutoValueKotlinExtensionTest.kt @@ -15,8 +15,6 @@ */ package com.slack.auto.value.kotlin -import com.gabrielittner.auto.value.with.AutoValueWithExtension -import com.google.auto.value.processor.AutoValueProcessor import com.google.common.truth.Truth.assertAbout import com.google.common.truth.Truth.assertThat import com.google.testing.compile.CompilationSubject @@ -24,9 +22,6 @@ import com.google.testing.compile.CompilationSubject.compilations import com.google.testing.compile.Compiler import com.google.testing.compile.Compiler.javac import com.google.testing.compile.JavaFileObjects.forSourceString -import com.ryanharter.auto.value.moshi.AutoValueMoshiExtension -import com.ryanharter.auto.value.parcel.AutoValueParcelExtension -import com.squareup.auto.value.redacted.AutoValueRedactedExtension import org.junit.Before import org.junit.Rule import org.junit.Test @@ -104,6 +99,12 @@ class AutoValueKotlinExtensionTest { return null; } + enum ExampleEnum { + ENUM_VALUE, + @Redacted + ANNOTATED_ENUM_VALUE + } + @AutoValue.Builder abstract static class Builder { abstract Builder value(String value); @@ -394,6 +395,12 @@ class AutoValueKotlinExtensionTest { TODO("Replace this with the implementation from the source class") } } + + internal enum class ExampleEnum { + ENUM_VALUE, + @Redacted + ANNOTATED_ENUM_VALUE, + } } """.trimIndent() @@ -979,10 +986,99 @@ class AutoValueKotlinExtensionTest { } @Test - fun nestedError() { + fun nestedClasses() { val result = compile( forSourceString( - "test.Example", + "test.Outer", + """ + package test; + + import com.google.auto.value.AutoValue; + + @AutoValue + abstract class Outer { + + abstract String outerValue(); + + @AutoValue + abstract static class Inner { + + abstract String value(); + + abstract String withValue(); + } + } + """.trimIndent() + ) + ) + + result.succeeded() + val generated = File(srcDir, "test/Outer.kt") + assertThat(generated.exists()).isTrue() + assertThat(generated.readText()) + .isEqualTo( + """ + package test + + import kotlin.jvm.JvmName + import kotlin.jvm.JvmSynthetic + + data class Outer( + @get:JvmName("outerValue") + val outerValue: String + ) { + @JvmSynthetic + @JvmName("-outerValue") + @Deprecated( + message = "Use the property", + replaceWith = ReplaceWith("outerValue") + ) + fun outerValue(): String { + outerValue() + TODO("Remove this function. Use the above line to auto-migrate.") + } + + data class Inner( + @get:JvmName("value") + val `value`: String, + @get:JvmName("withValue") + val withValue: String + ) { + @JvmSynthetic + @JvmName("-value") + @Deprecated( + message = "Use the property", + replaceWith = ReplaceWith("value") + ) + fun `value`(): String { + `value`() + TODO("Remove this function. Use the above line to auto-migrate.") + } + + @JvmSynthetic + @JvmName("-withValue") + @Deprecated( + message = "Use the property", + replaceWith = ReplaceWith("withValue") + ) + fun withValue(): String { + withValue() + TODO("Remove this function. Use the above line to auto-migrate.") + } + + fun withValue(`value`: String): Inner = copy(`value` = `value`) + } + } + + """.trimIndent() + ) + } + + @Test + fun nestedError_outerNonAuto() { + val result = compile( + forSourceString( + "test.Outer", """ package test; @@ -1006,11 +1102,40 @@ class AutoValueKotlinExtensionTest { result.hadErrorContaining("Cannot convert nested classes to Kotlin safely. Please move this to top-level first.") } + @Test + fun nestedError_innerNonAuto() { + val result = compile( + forSourceString( + "test.Outer", + """ + package test; + + import com.google.auto.value.AutoValue; + + @AutoValue + abstract class Outer { + + abstract String value(); + + abstract String withValue(); + + static class Inner { + + } + } + """.trimIndent() + ) + ) + + result.failed() + result.hadErrorContaining("Cannot convert non-autovalue nested classes to Kotlin safely. Please move this to top-level first.") + } + @Test fun nestedWarning() { val result = compile( forSourceString( - "test.Example", + "test.Outer", """ package test; @@ -1169,17 +1294,7 @@ class AutoValueKotlinExtensionTest { private fun compile(vararg sourceFiles: JavaFileObject, compilerBody: Compiler.() -> Compiler = { this }): CompilationSubject { val compilation = javac() .withOptions(compilerSrcOption()) - .withProcessors( - AutoValueProcessor( - listOf( - AutoValueKotlinExtension(), - AutoValueMoshiExtension(), - AutoValueWithExtension(), - AutoValueRedactedExtension(), - AutoValueParcelExtension() - ) - ) - ) + .withProcessors(AutoValueKotlinProcessor()) .let(compilerBody) .compile(*sourceFiles) return assertAbout(compilations())