All Downloads are FREE. Search and download functionalities are using the official Maven repository.

jvmMain.androidx.compose.ui.tooling.data.SlotTree.jvm.kt Maven / Gradle / Ivy

Go to download

Compose tooling library data. This library provides data about compose for different tooling purposes.

The newest version!
/*
 * 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.
 */

@file:JvmName("SlotTreeKt")

package androidx.compose.ui.tooling.data

import androidx.compose.runtime.tooling.CompositionData
import androidx.compose.runtime.tooling.CompositionGroup
import androidx.compose.ui.layout.LayoutInfo
import androidx.compose.ui.layout.ModifierInfo
import androidx.compose.ui.layout.positionInWindow
import androidx.compose.ui.unit.IntRect
import java.lang.reflect.Field
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt

/** A group in the slot table. Represents either a call or an emitted node. */
@UiToolingDataApi
sealed class Group(
    /** The key is the key generated for the group */
    val key: Any?,

    /** The name of the function called, if provided */
    val name: String?,

    /** The source location that produce the group if it can be determined */
    val location: SourceLocation?,

    /**
     * An optional value that identifies a Group independently of movement caused by recompositions.
     */
    val identity: Any?,

    /** The bounding layout box for the group. */
    val box: IntRect,

    /** Any data that was stored in the slot table for the group */
    val data: Collection,

    /** The child groups of this group */
    val children: Collection,

    /** True if the group is for an inline function call */
    val isInline: Boolean,
) {
    /** Modifier information for the Group, or empty list if there isn't any. */
    open val modifierInfo: List
        get() = emptyList()

    /** Parameter information for Groups that represent calls */
    open val parameters: List
        get() = emptyList()
}

@UiToolingDataApi
data class ParameterInformation(
    val name: String,
    val value: Any?,
    val fromDefault: Boolean,
    val static: Boolean,
    val compared: Boolean,
    val inlineClass: String?,
    val stable: Boolean
)

/** Source location of the call that produced the call group. */
@UiToolingDataApi
data class SourceLocation(
    /** A 0 offset line number of the source location. */
    val lineNumber: Int,

    /**
     * Offset into the file. The offset is calculated as the number of UTF-16 code units from the
     * beginning of the file to the first UTF-16 code unit of the call that produced the group.
     */
    val offset: Int,

    /**
     * The length of the source code. The length is calculated as the number of UTF-16 code units
     * that that make up the call expression.
     */
    val length: Int,

    /**
     * The file name (without path information) of the source file that contains the call that
     * produced the group. A source file names are not guaranteed to be unique, [packageHash] is
     * included to help disambiguate files with duplicate names.
     */
    val sourceFile: String?,

    /**
     * A hash code of the package name of the file. This hash is calculated by,
     *
     * `packageName.fold(0) { hash, current -> hash * 31 + current.toInt() }?.absoluteValue`
     *
     * where the package name is the dotted name of the package. This can be used to disambiguate
     * which file is referenced by [sourceFile]. This number is -1 if there was no package hash
     * information generated such as when the file does not contain a package declaration.
     */
    val packageHash: Int
)

/** A group that represents the invocation of a component */
@UiToolingDataApi
class CallGroup(
    key: Any?,
    name: String?,
    box: IntRect,
    location: SourceLocation?,
    identity: Any?,
    override val parameters: List,
    data: Collection,
    children: Collection,
    isInline: Boolean
) : Group(key, name, location, identity, box, data, children, isInline)

/** A group that represents an emitted node */
@UiToolingDataApi
class NodeGroup(
    key: Any?,

    /** An emitted node */
    val node: Any,
    box: IntRect,
    data: Collection,
    override val modifierInfo: List,
    children: Collection
) : Group(key, null, null, null, box, data, children, false)

@UiToolingDataApi
private object EmptyGroup :
    Group(
        key = null,
        name = null,
        location = null,
        identity = null,
        box = emptyBox,
        data = emptyList(),
        children = emptyList(),
        isInline = false
    )

/** A key that has being joined together to form one key. */
@UiToolingDataApi data class JoinedKey(val left: Any?, val right: Any?)

internal val emptyBox = IntRect(0, 0, 0, 0)

private val tokenizer = Regex("(\\d+)|([,])|([*])|([:])|L|(P\\([^)]*\\))|(C(\\(([^)]*)\\))?)|@")

private fun MatchResult.isNumber() = groups[1] != null

private fun MatchResult.number() = groupValues[1].parseToInt()

private val MatchResult.text
    get() = groupValues[0]

private fun MatchResult.isChar(c: String) = text == c

private fun MatchResult.isFileName() = groups[4] != null

private fun MatchResult.isParameterInformation() = groups[5] != null

private fun MatchResult.isCallWithName() = groups[6] != null

private fun MatchResult.callName() = groupValues[8]

private class SourceLocationInfo(val lineNumber: Int?, val offset: Int?, val length: Int?)

@UiToolingDataApi
private class SourceInformationContext(
    val name: String?,
    val sourceFile: String?,
    val packageHash: Int,
    val locations: List,
    val repeatOffset: Int,
    val parameters: List?,
    val isCall: Boolean,
    val isInline: Boolean
) {
    private var nextLocation = 0

    fun nextSourceLocation(): SourceLocation? {
        if (nextLocation >= locations.size && repeatOffset >= 0) {
            nextLocation = repeatOffset
        }
        if (nextLocation < locations.size) {
            val location = locations[nextLocation++]
            return SourceLocation(
                location.lineNumber ?: -1,
                location.offset ?: -1,
                location.length ?: -1,
                sourceFile,
                packageHash
            )
        }
        return null
    }

    fun sourceLocation(callIndex: Int, parentContext: SourceInformationContext?): SourceLocation? {
        var locationIndex = callIndex
        if (locationIndex >= locations.size && repeatOffset >= 0 && repeatOffset < locations.size) {
            locationIndex =
                (callIndex - repeatOffset) % (locations.size - repeatOffset) + repeatOffset
        }
        if (locationIndex < locations.size) {
            val location = locations[locationIndex]
            return SourceLocation(
                location.lineNumber ?: -1,
                location.offset ?: -1,
                location.length ?: -1,
                sourceFile ?: parentContext?.sourceFile,
                (if (sourceFile == null) parentContext?.packageHash else packageHash) ?: -1
            )
        }
        return null
    }
}

private val parametersInformationTokenizer = Regex("(\\d+)|,|[!P()]|:([^,!)]+)")
private val MatchResult.isANumber
    get() = groups[1] != null
private val MatchResult.isClassName
    get() = groups[2] != null

private class ParseError : Exception()

private class Parameter(val sortedIndex: Int, val inlineClass: String? = null)

private fun String.parseToInt(): Int =
    try {
        toInt()
    } catch (_: NumberFormatException) {
        throw ParseError()
    }

private fun String.parseToInt(radix: Int): Int =
    try {
        toInt(radix)
    } catch (_: NumberFormatException) {
        throw ParseError()
    }

// The parameter information follows the following grammar:
//
//   parameters: (parameter|run) ("," parameter | run)*
//   parameter: sorted-index [":" inline-class]
//   sorted-index: 
//   inline-class: 
//   run: "!" 
//
// The full description of this grammar can be found in the ComposableFunctionBodyTransformer of the
// compose compiler plugin.
private fun parseParameters(parameters: String): List {
    var currentResult = parametersInformationTokenizer.find(parameters)
    val expectedSortedIndex = mutableListOf(0, 1, 2, 3)
    var lastAdded = expectedSortedIndex.size - 1
    val result = mutableListOf()
    fun next(): MatchResult? {
        currentResult?.let { currentResult = it.next() }
        return currentResult
    }

    fun expectNumber(): Int {
        val mr = currentResult
        if (mr == null || !mr.isANumber) throw ParseError()
        next()
        return mr.text.parseToInt()
    }

    fun expectClassName(): String {
        val mr = currentResult
        if (mr == null || !mr.isClassName) throw ParseError()
        next()
        return mr.text.substring(1).replacePrefix("c#", "androidx.compose.")
    }

    fun expect(value: String) {
        val mr = currentResult
        if (mr == null || mr.text != value) throw ParseError()
        next()
    }

    fun isChar(value: String): Boolean {
        val mr = currentResult
        return mr == null || mr.text == value
    }

    fun isClassName(): Boolean {
        val mr = currentResult
        return mr != null && mr.isClassName
    }

    fun ensureIndexes(index: Int) {
        val missing = index - lastAdded
        if (missing > 0) {
            val minAddAmount = 4
            val amountToAdd = if (missing < minAddAmount) minAddAmount else missing
            repeat(amountToAdd) { expectedSortedIndex.add(it + lastAdded + 1) }
            lastAdded += amountToAdd
        }
    }

    try {
        expect("P")
        expect("(")
        loop@ while (!isChar(")")) {
            when {
                isChar("!") -> {
                    // run
                    next()
                    val count = expectNumber()
                    ensureIndexes(result.size + count)
                    repeat(count) {
                        result.add(Parameter(expectedSortedIndex.first()))
                        expectedSortedIndex.removeAt(0)
                    }
                }
                isChar(",") -> next()
                else -> {
                    val index = expectNumber()
                    val inlineClass =
                        if (isClassName()) {
                            expectClassName()
                        } else null
                    result.add(Parameter(index, inlineClass))
                    ensureIndexes(index)
                    expectedSortedIndex.remove(index)
                }
            }
        }
        expect(")")

        // Ensure there are at least as many entries as the highest referenced index.
        while (expectedSortedIndex.size > 0) {
            result.add(Parameter(expectedSortedIndex.first()))
            expectedSortedIndex.removeAt(0)
        }
        return result
    } catch (_: ParseError) {
        return emptyList()
    } catch (_: NumberFormatException) {
        return emptyList()
    }
}

@UiToolingDataApi
private fun sourceInformationContextOf(
    information: String,
    parent: SourceInformationContext? = null
): SourceInformationContext? {
    var currentResult = tokenizer.find(information)

    fun next(): MatchResult? {
        currentResult?.let { currentResult = it.next() }
        return currentResult
    }

    fun parseLocation(): SourceLocationInfo? {
        var lineNumber: Int? = null
        var offset: Int? = null
        var length: Int? = null

        try {
            var mr = currentResult
            if (mr != null && mr.isNumber()) {
                // Offsets are 0 based in the data, we need 1 based.
                lineNumber = mr.number() + 1
                mr = next()
            }
            if (mr != null && mr.isChar("@")) {
                // Offset
                mr = next()
                if (mr == null || !mr.isNumber()) {
                    return null
                }
                offset = mr.number()
                mr = next()
                if (mr != null && mr.isChar("L")) {
                    mr = next()
                    if (mr == null || !mr.isNumber()) {
                        return null
                    }
                    length = mr.number()
                }
            }
            if (lineNumber != null && offset != null && length != null)
                return SourceLocationInfo(lineNumber, offset, length)
        } catch (_: ParseError) {
            return null
        }
        return null
    }
    val sourceLocations = mutableListOf()
    var repeatOffset = -1
    var isCall = false
    var isInline = false
    var name: String? = null
    var parameters: List? = null
    var sourceFile: String? = null
    var packageHash = -1
    loop@ while (currentResult != null) {
        val mr = currentResult!!
        when {
            mr.isNumber() || mr.isChar("@") -> {
                parseLocation()?.let { sourceLocations.add(it) }
            }
            mr.isChar("C") -> {
                // A redundant call marker is placed in inline functions
                if (isCall) isInline = true
                isCall = true
                next()
            }
            mr.isCallWithName() -> {
                // A redundant call marker is placed in inline functions
                if (isCall) isInline = true
                isCall = true
                name = mr.callName()
                next()
            }
            mr.isParameterInformation() -> {
                parameters = parseParameters(mr.text)
                next()
            }
            mr.isChar("*") -> {
                repeatOffset = sourceLocations.size
                next()
            }
            mr.isChar(",") -> next()
            mr.isFileName() -> {
                sourceFile = information.substring(mr.range.last + 1)
                val hashText = sourceFile.substringAfterLast("#", "")
                if (hashText.isNotEmpty()) {
                    // Remove the hash information
                    sourceFile =
                        sourceFile.substring(0 until sourceFile.length - hashText.length - 1)
                    packageHash =
                        try {
                            hashText.parseToInt(36)
                        } catch (_: NumberFormatException) {
                            -1
                        }
                }
                break@loop
            }
            else -> break@loop
        }
        if (mr == currentResult) return null
    }

    return SourceInformationContext(
        name = name,
        sourceFile = sourceFile ?: parent?.sourceFile,
        packageHash = if (sourceFile != null) packageHash else parent?.packageHash ?: packageHash,
        locations = sourceLocations,
        repeatOffset = repeatOffset,
        parameters = parameters,
        isCall = isCall,
        isInline = isInline
    )
}

/** Iterate the slot table and extract a group tree that corresponds to the content of the table. */
@UiToolingDataApi
private fun CompositionGroup.getGroup(parentContext: SourceInformationContext?): Group {
    val key = key
    val context = sourceInfo?.let { sourceInformationContextOf(it, parentContext) }
    val node = node
    val data = mutableListOf()
    val children = mutableListOf()
    data.addAll(this.data)
    for (child in compositionGroups) children.add(child.getGroup(context))

    val modifierInfo =
        if (node is LayoutInfo) {
            node.getModifierInfo()
        } else {
            emptyList()
        }

    // Calculate bounding box
    val box =
        when (node) {
            is LayoutInfo -> boundsOfLayoutNode(node)
            else ->
                if (children.isEmpty()) emptyBox
                else children.map { g -> g.box }.reduce { acc, box -> box.union(acc) }
        }
    val location =
        if (context?.isCall == true) {
            parentContext?.nextSourceLocation()
        } else {
            null
        }
    return if (node != null) NodeGroup(key, node, box, data, modifierInfo, children)
    else
        CallGroup(
            key,
            context?.name,
            box,
            location,
            identity =
                if (
                    !context?.name.isNullOrEmpty() &&
                        (box.bottom - box.top > 0 || box.right - box.left > 0)
                ) {
                    this.identity
                } else {
                    null
                },
            extractParameterInfo(data, context),
            data,
            children,
            context?.isInline == true
        )
}

private fun boundsOfLayoutNode(node: LayoutInfo): IntRect {
    val coordinates = node.coordinates
    if (!node.isAttached || !coordinates.isAttached) {
        return IntRect(left = 0, top = 0, right = node.width, bottom = node.height)
    }
    val position = coordinates.positionInWindow()
    val size = coordinates.size
    val left = position.x.roundToInt()
    val top = position.y.roundToInt()
    val right = left + size.width
    val bottom = top + size.height
    return IntRect(left = left, top = top, right = right, bottom = bottom)
}

@UiToolingDataApi
private class CompositionCallStack(
    private val factory: (CompositionGroup, SourceContext, List) -> T?,
    private val contexts: MutableMap
) : SourceContext {
    private val stack = ArrayDeque()
    private var currentCallIndex = 0

    fun convert(group: CompositionGroup, callIndex: Int, out: MutableList): IntRect {
        val children = mutableListOf()
        var box = emptyBox
        push(group)
        var childCallIndex = 0
        group.compositionGroups.forEach { child ->
            box = box.union(convert(child, childCallIndex, children))
            if (isCall(child)) {
                childCallIndex++
            }
        }
        box = (group.node as? LayoutInfo)?.let { boundsOfLayoutNode(it) } ?: box
        currentCallIndex = callIndex
        bounds = box
        factory(group, this, children)?.let { out.add(it) }
        pop()
        return box
    }

    override val name: String?
        get() {
            val info = current.sourceInfo ?: return null
            val startIndex =
                when {
                    info.startsWith("CC(") -> 3
                    info.startsWith("C(") -> 2
                    else -> return null
                }
            val endIndex = info.indexOf(')')
            return if (endIndex > 2) info.substring(startIndex, endIndex) else null
        }

    override val isInline: Boolean
        get() = current.sourceInfo?.startsWith("CC") == true

    override var bounds: IntRect = emptyBox
        private set

    override val location: SourceLocation?
        get() {
            val context = parentGroup(1)?.sourceInfo?.let { contextOf(it) } ?: return null
            var parentContext: SourceInformationContext? = context
            var index = 2
            while (index < stack.size && parentContext?.sourceFile == null) {
                parentContext = parentGroup(index++)?.sourceInfo?.let { contextOf(it) }
            }
            return context.sourceLocation(currentCallIndex, parentContext)
        }

    override val parameters: List
        get() {
            val group = current
            val context = group.sourceInfo?.let { contextOf(it) } ?: return emptyList()
            val data = mutableListOf()
            data.addAll(group.data)
            return extractParameterInfo(data, context)
        }

    override val depth: Int
        get() = stack.size

    private fun push(group: CompositionGroup) = stack.addLast(group)

    private fun pop() = stack.removeLast()

    private val current: CompositionGroup
        get() = stack.last()

    private fun parentGroup(parentDepth: Int): CompositionGroup? =
        if (stack.size > parentDepth) stack[stack.size - parentDepth - 1] else null

    private fun contextOf(information: String): SourceInformationContext? =
        contexts.getOrPut(information) { sourceInformationContextOf(information) }
            as? SourceInformationContext

    private fun isCall(group: CompositionGroup): Boolean =
        group.sourceInfo?.startsWith("C") ?: false
}

/** A cache of [SourceInformationContext] that optionally can be specified when using [mapTree]. */
@UiToolingDataApi
class ContextCache {
    /** Clears the cache. */
    fun clear() {
        contexts.clear()
    }

    internal val contexts = mutableMapOf()
}

/**
 * Context with data for creating group nodes.
 *
 * See the factory argument of [mapTree].
 */
@UiToolingDataApi
interface SourceContext {
    /** The name of the Composable or null if not applicable. */
    val name: String?

    /** The bounds of the Composable if known. */
    val bounds: IntRect

    /** The [SourceLocation] of where the Composable was called. */
    val location: SourceLocation?

    /** The parameters of the Composable. */
    val parameters: List

    /** The current depth into the [CompositionGroup] tree. */
    val depth: Int

    /** The source context is for a call to an inline composable function */
    val isInline: Boolean
        get() = false
}

/**
 * Return a tree of custom nodes for the slot table.
 *
 * The [factory] method will be called for every [CompositionGroup] in the slot tree and can be used
 * to create custom nodes based on the passed arguments. The [SourceContext] argument gives access
 * to additional information encoded in the [CompositionGroup.sourceInfo]. A return of null from
 * [factory] means that the entire subtree will be ignored.
 *
 * A [cache] can optionally be specified. If a client is calling [mapTree] multiple times, this can
 * save some time if the values of [CompositionGroup.sourceInfo] are not unique.
 */
@UiToolingDataApi
fun  CompositionData.mapTree(
    factory: (CompositionGroup, SourceContext, List) -> T?,
    cache: ContextCache = ContextCache()
): T? {
    val group = compositionGroups.firstOrNull() ?: return null
    val callStack = CompositionCallStack(factory, cache.contexts)
    val out = mutableListOf()
    callStack.convert(group, 0, out)
    return out.firstOrNull()
}

/** Return the parameters found for this [CompositionGroup]. */
@UiToolingDataApi
fun CompositionGroup.findParameters(cache: ContextCache? = null): List {
    val information = sourceInfo ?: return emptyList()
    val context =
        if (cache == null) sourceInformationContextOf(information)
        else
            cache.contexts.getOrPut(information) { sourceInformationContextOf(information) }
                as? SourceInformationContext
    val data = mutableListOf()
    data.addAll(this.data)
    return extractParameterInfo(data, context)
}

/**
 * Return a group tree for for the slot table that represents the entire content of the slot table.
 */
@UiToolingDataApi
fun CompositionData.asTree(): Group = compositionGroups.firstOrNull()?.getGroup(null) ?: EmptyGroup

internal fun IntRect.union(other: IntRect): IntRect {
    if (this == emptyBox) return other else if (other == emptyBox) return this

    return IntRect(
        left = min(left, other.left),
        top = min(top, other.top),
        bottom = max(bottom, other.bottom),
        right = max(right, other.right)
    )
}

@UiToolingDataApi
private fun keyPosition(key: Any?): String? =
    when (key) {
        is String -> key
        is JoinedKey -> keyPosition(key.left) ?: keyPosition(key.right)
        else -> null
    }

private const val parameterPrefix = "${'$'}"
private const val internalFieldPrefix = parameterPrefix + parameterPrefix
private const val defaultFieldName = "${internalFieldPrefix}default"
private const val changedFieldName = "${internalFieldPrefix}changed"
private const val jacocoDataField = "${parameterPrefix}jacoco"
private const val recomposeScopeNameSuffix = ".RecomposeScopeImpl"

@UiToolingDataApi
private fun extractParameterInfo(
    data: List,
    context: SourceInformationContext?
): List {
    if (data.isNotEmpty()) {
        val recomposeScope =
            data.firstOrNull { it != null && it.javaClass.name.endsWith(recomposeScopeNameSuffix) }
        if (recomposeScope != null) {
            try {
                val blockField = recomposeScope.javaClass.accessibleField("block")
                if (blockField != null) {
                    val block = blockField.get(recomposeScope)
                    if (block != null) {
                        val blockClass = block.javaClass
                        val defaultsField = blockClass.accessibleField(defaultFieldName)
                        val changedField = blockClass.accessibleField(changedFieldName)
                        val default =
                            if (defaultsField != null) defaultsField.get(block) as Int else 0
                        val changed =
                            if (changedField != null) changedField.get(block) as Int else 0
                        val fields =
                            blockClass.declaredFields
                                .filter {
                                    it.name.startsWith(parameterPrefix) &&
                                        !it.name.startsWith(internalFieldPrefix) &&
                                        !it.name.startsWith(jacocoDataField)
                                }
                                .sortedBy { it.name }
                        val parameters = mutableListOf()
                        val parametersMetadata = context?.parameters ?: emptyList()
                        repeat(fields.size) { index ->
                            val metadata =
                                if (index < parametersMetadata.size) parametersMetadata[index]
                                else Parameter(index)
                            if (metadata.sortedIndex >= fields.size) return@repeat
                            val field = fields[metadata.sortedIndex]
                            field.isAccessible = true
                            val value = field.get(block)
                            val fromDefault = (1 shl index) and default != 0
                            val changedOffset = index * BITS_PER_SLOT + 1
                            val parameterChanged =
                                ((SLOT_MASK shl changedOffset) and changed) shr changedOffset
                            val static = parameterChanged and STATIC_BITS == STATIC_BITS
                            val compared = parameterChanged and STATIC_BITS == 0
                            val stable = parameterChanged and STABLE_BITS == 0
                            parameters.add(
                                ParameterInformation(
                                    name = field.name.substring(1),
                                    value = value,
                                    fromDefault = fromDefault,
                                    static = static,
                                    compared = compared && !fromDefault,
                                    inlineClass = metadata.inlineClass,
                                    stable = stable
                                )
                            )
                        }
                        return parameters
                    }
                }
            } catch (_: Throwable) {}
        }
    }
    return emptyList()
}

private const val BITS_PER_SLOT = 3
private const val SLOT_MASK = 0b111
private const val STATIC_BITS = 0b011
private const val STABLE_BITS = 0b100

/** The source position of the group extracted from the key, if one exists for the group. */
@UiToolingDataApi
val Group.position: String?
    @Suppress("OPT_IN_MARKER_ON_WRONG_TARGET") @UiToolingDataApi get() = keyPosition(key)

private fun Class<*>.accessibleField(name: String): Field? =
    declaredFields.firstOrNull { it.name == name }?.apply { isAccessible = true }

private fun String.replacePrefix(prefix: String, replacement: String) =
    if (startsWith(prefix)) replacement + substring(prefix.length) else this




© 2015 - 2025 Weber Informatics LLC | Privacy Policy