LayoutInspectionProcessingStep.kt

/*
 * Copyright 2021 The Android Open Source Project
 *
 * 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
 *
 *      http://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 androidx.resourceinspection.processor

import androidx.annotation.ColorInt
import androidx.annotation.ColorLong
import androidx.annotation.GravityInt
import androidx.resourceinspection.annotation.Attribute
import com.google.auto.common.AnnotationMirrors.getAnnotationValue
import com.google.auto.common.BasicAnnotationProcessor
import com.google.auto.common.GeneratedAnnotationSpecs.generatedAnnotationSpec
import com.google.auto.common.MoreElements.asExecutable
import com.google.auto.common.MoreElements.asType
import com.google.auto.common.MoreElements.getAnnotationMirror
import com.google.auto.common.MoreElements.isAnnotationPresent
import com.google.auto.common.Visibility
import com.google.auto.common.Visibility.effectiveVisibilityOfElement
import com.google.common.collect.SetMultimap
import com.squareup.javapoet.AnnotationSpec
import javax.annotation.processing.ProcessingEnvironment
import javax.annotation.processing.Processor
import javax.lang.model.element.AnnotationMirror
import javax.lang.model.element.AnnotationValue
import javax.lang.model.element.Element
import javax.lang.model.element.ExecutableElement
import javax.lang.model.element.TypeElement
import javax.lang.model.type.TypeKind
import javax.lang.model.type.TypeMirror
import javax.tools.Diagnostic

/** Processing step for generating layout inspection companions from [Attribute] annotations. */
internal class LayoutInspectionProcessingStep(
    private val processingEnv: ProcessingEnvironment,
    processorClass: Class<out Processor>
) : BasicAnnotationProcessor.ProcessingStep {
    private val generatedAnnotation: AnnotationSpec? = generatedAnnotationSpec(
        processingEnv.elementUtils,
        processingEnv.sourceVersion,
        processorClass
    ).orElse(null)

    override fun annotations(): Set<Class<out Annotation>> {
        return setOf(Attribute::class.java)
    }

    override fun process(
        elementsByAnnotation: SetMultimap<Class<out Annotation>, Element>
    ): Set<Element> {
        // TODO(b/180039277): Validate that linked APIs (e.g. InspectionCompanion) are present
        elementsByAnnotation[Attribute::class.java]
            .map { asExecutable(it) }
            .groupBy { asType(it.enclosingElement) }
            .forEach { (type, getters) ->
                parseView(type, getters)?.let { view ->
                    generateInspectionCompanion(view, generatedAnnotation)
                        .writeTo(processingEnv.filer)
                }
            }
        return emptySet()
    }

    /** Parse the annotated getters of a view class into a [ViewIR]. */
    private fun parseView(type: TypeElement, getters: Iterable<ExecutableElement>): ViewIR? {
        if (!type.asType().isAssignableTo("android.view.View")) {
            getters.forEach { getter ->
                printError(
                    "@Attribute must only annotate subclasses of android.view.View",
                    getter,
                    getAnnotationMirror(getter, Attribute::class.java).get()
                )
            }
            return null
        }

        val attributes = getters.map(::parseAttribute)

        val duplicateAttributes = attributes
            .filterNotNull()
            .groupBy { it.qualifiedName }
            .values
            .filter { it.size > 1 }

        if (duplicateAttributes.any()) {
            duplicateAttributes.forEach { duplicates ->
                duplicates.forEach { attribute ->
                    val qualifiedName = attribute.qualifiedName
                    val otherGetters = duplicates
                        .filter { it.getter != attribute.getter }
                        .joinToString { it.getter.toString() }

                    printError(
                        "Duplicate attribute $qualifiedName is also present on $otherGetters",
                        attribute.getter,
                        attribute.annotation
                    )
                }
            }
            return null
        }

        if (attributes.any { it == null }) {
            return null
        }

        return ViewIR(type, attributes = attributes.filterNotNull().sortedBy { it.qualifiedName })
    }

    /** Get an [AttributeIR] from a method known to have an [Attribute] annotation. */
    private fun parseAttribute(getter: ExecutableElement): AttributeIR? {
        val annotation = getAnnotationMirror(getter, Attribute::class.java).get()
        val annotationValue = getAnnotationValue(annotation, "value")
        val value = annotationValue.value as String

        if (getter.parameters.isNotEmpty() || getter.returnType.kind == TypeKind.VOID) {
            printError("@Attribute must annotate a getter", getter, annotation)
            return null
        }

        if (effectiveVisibilityOfElement(getter) != Visibility.PUBLIC) {
            printError("@Attribute getter must be public", getter, annotation)
            return null
        }

        val match = ATTRIBUTE_VALUE.matchEntire(value)

        return if (match != null) {
            val (namespace, name) = match.destructured
            val intMapping = parseIntMapping(annotation)
            val type = inferAttributeType(getter, intMapping)

            // TODO(b/180041203): Verify attribute ID or at least existence of R files
            // TODO(b/180041633): Validate consistency of int mapping

            AttributeIR(getter, annotation, namespace, name, type, intMapping)
        } else if (!value.contains(':')) {
            printError("Attribute name must include namespace", getter, annotation, annotationValue)
            null
        } else {
            printError("Invalid attribute name", getter, annotation, annotationValue)
            null
        }
    }

    /** Parse [Attribute.intMapping]. */
    private fun parseIntMapping(annotation: AnnotationMirror): List<IntMapIR> {
        return (getAnnotationValue(annotation, "intMapping").value as List<*>).map { entry ->
            val intMapAnnotation = (entry as AnnotationValue).value as AnnotationMirror

            IntMapIR(
                name = getAnnotationValue(intMapAnnotation, "name").value as String,
                value = getAnnotationValue(intMapAnnotation, "value").value as Int,
                mask = getAnnotationValue(intMapAnnotation, "mask").value as Int,
            )
        }.sortedBy { it.value }
    }

    /** Map the getter's annotations and return type to the internal attribute type. */
    private fun inferAttributeType(
        getter: ExecutableElement,
        intMapping: List<IntMapIR>
    ): AttributeTypeIR {
        return when (getter.returnType.kind) {
            TypeKind.BOOLEAN -> AttributeTypeIR.BOOLEAN
            TypeKind.BYTE -> AttributeTypeIR.BYTE
            TypeKind.CHAR -> AttributeTypeIR.CHAR
            TypeKind.DOUBLE -> AttributeTypeIR.DOUBLE
            TypeKind.FLOAT -> AttributeTypeIR.FLOAT
            TypeKind.SHORT -> AttributeTypeIR.SHORT
            TypeKind.INT -> when {
                isAnnotationPresent(getter, ColorInt::class.java) -> AttributeTypeIR.COLOR
                isAnnotationPresent(getter, GravityInt::class.java) -> AttributeTypeIR.GRAVITY
                getter.hasResourceIdAnnotation() -> AttributeTypeIR.RESOURCE_ID
                intMapping.any { it.mask != 0 } -> AttributeTypeIR.INT_FLAG
                intMapping.isNotEmpty() -> AttributeTypeIR.INT_ENUM
                else -> AttributeTypeIR.INT
            }
            TypeKind.LONG ->
                if (isAnnotationPresent(getter, ColorLong::class.java)) {
                    AttributeTypeIR.COLOR
                } else {
                    AttributeTypeIR.LONG
                }
            TypeKind.DECLARED ->
                if (getter.returnType.isAssignableTo("android.graphics.Color")) {
                    AttributeTypeIR.COLOR
                } else {
                    // TODO(b/180041034): Validate object types and unbox primitives
                    AttributeTypeIR.OBJECT
                }
            else -> throw IllegalArgumentException("Unexpected attribute type")
        }
    }

    private fun Element.hasResourceIdAnnotation(): Boolean {
        return this.annotationMirrors.any {
            asType(it.annotationType.asElement()).qualifiedName matches RESOURCE_ID_ANNOTATION
        }
    }

    private fun TypeMirror.isAssignableTo(typeName: String): Boolean {
        val assignableType = requireNotNull(processingEnv.elementUtils.getTypeElement(typeName)) {
            "Expected $typeName to exist"
        }
        return processingEnv.typeUtils.isAssignable(this, assignableType.asType())
    }

    /** Convenience wrapper for [javax.annotation.processing.Messager.printMessage]. */
    private fun printError(
        message: String,
        element: Element,
        annotation: AnnotationMirror? = null,
        value: AnnotationValue? = null
    ) {
        processingEnv.messager.printMessage(
            Diagnostic.Kind.ERROR,
            message,
            element,
            annotation,
            value
        )
    }

    private companion object {
        /** Regex for validating and parsing attribute name and namespace. */
        val ATTRIBUTE_VALUE = """(\w+(?:\.\w+)*):(\w+)""".toRegex()

        /** Regex for matching resource ID annotations. */
        val RESOURCE_ID_ANNOTATION = """androidx?\.annotation\.[A-Z]\w+Res""".toRegex()
    }
}