diff --git a/hot-reload-analysis/src/main/kotlin/org/jetbrains/compose/reload/analysis/RuntimeInstructionToken.kt b/hot-reload-analysis/src/main/kotlin/org/jetbrains/compose/reload/analysis/RuntimeInstructionToken.kt index cadcf6f7..a1b247d4 100644 --- a/hot-reload-analysis/src/main/kotlin/org/jetbrains/compose/reload/analysis/RuntimeInstructionToken.kt +++ b/hot-reload-analysis/src/main/kotlin/org/jetbrains/compose/reload/analysis/RuntimeInstructionToken.kt @@ -54,6 +54,24 @@ sealed class RuntimeInstructionToken { } } + data class StartSourceInfoMarker( + val info: SourceInfo, + val key: ComposeGroupKey, + override val instructions: List + ) : RuntimeInstructionToken() { + override fun toString(): String { + return "StartSourceInfoMarker(${info.fileName}#${info.functionName})" + } + } + + data class EndSourceInfoMarker( + override val instructions: List + ) : RuntimeInstructionToken() { + override fun toString(): String { + return "EndSourceInfoMarker()" + } + } + data class JumpToken( val jumpInsn: JumpInsnNode ) : RuntimeInstructionToken() { @@ -88,6 +106,7 @@ sealed class RuntimeInstructionToken { return "BlockToken(${instructions.size})" } } + } internal sealed class RuntimeInstructionTokenizer { @@ -137,6 +156,8 @@ private val priorityTokenizer by lazy { EndRestartGroupTokenizer, StartReplaceGroupTokenizer, EndReplaceGroupTokenizer, + StartSourceInfoMarkerTokenizer, + EndSourceInfoMarkerTokenizer ) } @@ -233,6 +254,43 @@ private object EndReplaceGroupTokenizer : RuntimeInstructionTokenizer() { } } +private object StartSourceInfoMarkerTokenizer : RuntimeInstructionTokenizer() { + override fun nextToken(context: TokenizerContext): Either? { + val expectedKey = context[-3] ?: return null + val expectedLdc = context[-2] ?: return null + val expectedMethodInsn = context[1] ?: return null + + if (expectedMethodInsn is MethodInsnNode && + MethodId(expectedMethodInsn) == MethodIds.Composer.sourceInformationMarkerStart + ) { + val groupKey = expectedKey.intValueOrNull() ?: return null + val info = expectedLdc.stringValueOrNull() ?: return Failure( + "Failed parsing StartSourceInfoMarker token: expected LDC string value" + ).toRight() + val parsedInfo = parseSourceInfo(info) + + return RuntimeInstructionToken.StartSourceInfoMarker( + parsedInfo, + ComposeGroupKey(groupKey), + listOf(expectedLdc, expectedMethodInsn) + ).toLeft() + } + + return null + } +} + +private object EndSourceInfoMarkerTokenizer : RuntimeInstructionTokenizer() { + override fun nextToken(context: TokenizerContext): Either? { + val expectedMethodIns = context[0] as? MethodInsnNode ?: return null + if (MethodId(expectedMethodIns) == MethodIds.Composer.sourceInformationMarkerEnd) { + return RuntimeInstructionToken.EndSourceInfoMarker(listOf(expectedMethodIns)).toLeft() + } + + return null + } +} + private object BlockTokenizer : RuntimeInstructionTokenizer() { override fun nextToken(context: TokenizerContext): Either? { diff --git a/hot-reload-analysis/src/main/kotlin/org/jetbrains/compose/reload/analysis/RuntimeInstructionTree.kt b/hot-reload-analysis/src/main/kotlin/org/jetbrains/compose/reload/analysis/RuntimeInstructionTree.kt index ddc9579d..be20bfbb 100644 --- a/hot-reload-analysis/src/main/kotlin/org/jetbrains/compose/reload/analysis/RuntimeInstructionTree.kt +++ b/hot-reload-analysis/src/main/kotlin/org/jetbrains/compose/reload/analysis/RuntimeInstructionTree.kt @@ -206,6 +206,27 @@ private fun parseRuntimeInstructionTree( break } + + is StartSourceInfoMarker -> { + val child = parseRuntimeInstructionTree( + group = currentToken.key, + type = RuntimeScopeType.SourceInformationMarker, + tokens = tokens, + startIndex = currentIndex + 2, + endIndex = endIndex, + consumed = listOf(currentToken) + ).leftOr { return it } + children += child + index = child.lastIndex + 2 + } + + is EndSourceInfoMarker -> { + if (type != RuntimeScopeType.SourceInformationMarker) { + return Failure("EndSourceInfoMarker is not allowed in $type scope").toRight() + } + consumed += currentToken + break + } } } diff --git a/hot-reload-analysis/src/main/kotlin/org/jetbrains/compose/reload/analysis/SourceInfo.kt b/hot-reload-analysis/src/main/kotlin/org/jetbrains/compose/reload/analysis/SourceInfo.kt new file mode 100644 index 00000000..9ede51f6 --- /dev/null +++ b/hot-reload-analysis/src/main/kotlin/org/jetbrains/compose/reload/analysis/SourceInfo.kt @@ -0,0 +1,168 @@ +package org.jetbrains.compose.reload.analysis + +import org.jetbrains.compose.reload.core.createLogger + +data class SourceInfo( + val functionName: String, + val isLambda: Boolean, + val isInline: Boolean, + val parameters: Parameters?, + val locations: List, + val fileName: String, + val hash: String? +) { + data class Location(val line: Int, val start: Int, val offset: Int = -1) + data class Parameters(val data: String, val offset: Int) +} + +internal fun SourceInfo.Parameters.asSequence(): Sequence { + return this.data.asSequence() +} + +/** + * Parses parameters sequence: + * - `1,2,3` > `[1, 2, 3]` + * - `` > `[]` + * - `!3` > `[0, 1, 2]` + * - `1,!3` > `[1, 0, 1, 2]` + */ +internal fun String.asSequence(): Sequence { + return if (this.isEmpty()) emptySequence() + else this.split(",").asSequence().flatMap { + if (it.startsWith("!")) { + val fact = it.drop(1).toIntOrDefault(0) + List(fact) { n -> n } + } else listOf(it.toIntOrDefault(-1)) + } +} + +private val stringNameRegex = Regex("""^\(([^)]+)\)""") +private val intNameRegex = Regex("^\\d+") +private val paramsRegex = Regex("""^P\(([^)]+)\)(\d+)""") +private val regexLocations = Regex("""@(\d+)[Ll](\d+),?(\d+)?(?=@|:)""") + +/** + * Source info consists of the next parts: + * 1. Optional `C` which indicates that function is inlined + * 2. Function name, always starts from `C` and follow one of the formats: + * * `C(NAME)`, where `NAME` is any string, for example `C(remember)` + * * `CNAME`, where `NAME` is number, for example `C55`. Also such name indicates that the function is lambda + * * Or no name. + * 3. Optional parameters info, which have format `P(INFO)OFFSET`, where `INFO` is list of integers and factorials and `OFFSET` is number. + * For example `P(1,!2,3)222`. See [asSequence] + * 4. List of locations where every location has format `@LINELSTART,OFFSET` where: + * * every location starts with `@` + * * followed by `LINE` which represent number + * * `L` char + * * `START` - any number + * * `,` char + * * Optional `OFFSET` + * 5. Then `#` char + * 6. Optional string, which represents hash + * + * See [org.jetbrains.compose.reload.analysis.tests.SourceInfoParsingTest] for detailed examples. + */ +internal fun parseSourceInfo(rawInfo: String): SourceInfo { + + var info = rawInfo + var isInline = false + var isLambda = false + var functionName = "" + var parameters: SourceInfo.Parameters? = null + val locations = mutableListOf() + var fileName: String? + var hash: String? = null + + info = info.dropFirst("CC") { isInline = it != null } + + if (!isInline) info = info.dropFirst("C") + + if (!info.startsWith('(')) isLambda = true + + if (isLambda) { + info = info.dropFirst(intNameRegex) { lambdaId -> + functionName = lambdaId ?: "" + } + } else { + info = info.dropFirst(stringNameRegex) { + val dropLast = it?.drop(1)?.dropLast(1) + functionName = if (dropLast != null) dropLast else { + logger.warn("Unable to parse source info function name: $info") + "invalid-named-function-name" + } + } + } + + info = info.dropFirstGroup(paramsRegex) { + val params = it.getOrNull(1) + val paramsN = it.getOrNull(2)?.toIntOrNull() + if (params != null && paramsN != null) { + parameters = SourceInfo.Parameters(params, paramsN) + } + } + + info = info.dropAllGroups(regexLocations) { groups -> + groups.chunked(4).forEach { loc -> + loc[1].toIntOrNull() + locations.add( + SourceInfo.Location( + line = loc.getOrNull(1).toIntOrDefault(-1), + start = loc.getOrNull(2).toIntOrDefault(-1), + offset = loc.getOrNull(3).toIntOrDefault(-1), + ) + ) + } + } + + info = info.dropFirst(":") + + info.split('#').run { + fileName = this.getOrNull(0) + if (this.size == 2) hash = this.getOrNull(1) + } + + return SourceInfo( + functionName = functionName, + isLambda = isLambda, + isInline = isInline, + parameters = parameters, + locations = locations, + fileName = fileName ?: "invalid-file-name", + hash = hash + ) + +} + +private fun String.dropFirst(str: String, andGet: (String?) -> Unit = {}): String { + return if (this.startsWith(str)) this.replaceFirst(str, "").also { andGet(str) } + else this.also { andGet(null) } +} + +private fun String.dropFirst(regex: Regex, andGet: (String?) -> Unit = {}): String { + val match = regex.find(this) + return if (match != null) this.removeRange(match.range).also { andGet(match.value) } + else this.also { andGet(null) } +} + +private fun String.dropFirstGroup(regex: Regex, andGet: (List) -> Unit = {}): String { + val matchResult = regex.find(this) + return if (matchResult != null) { + regex.replace(this, "").also { andGet(matchResult.groupValues) } + } else { + this.also { andGet(emptyList()) } + } +} + +private fun String.dropAllGroups(regex: Regex, andGet: (List) -> Unit = {}): String { + val matchResult = regex.findAll(this).toList() + return if (matchResult.isNotEmpty()) { + regex.replace(this, "") + .also { andGet(matchResult.flatMap { match -> match.groups.map { group -> group?.value ?: "" } }) } + } else { + this.also { andGet(emptyList()) } + } +} + +private fun String?.toIntOrDefault(i: Int = 0): Int = this?.toIntOrNull() ?: i + +private val logger = createLogger() \ No newline at end of file diff --git a/hot-reload-analysis/src/main/kotlin/org/jetbrains/compose/reload/analysis/asmUtils.kt b/hot-reload-analysis/src/main/kotlin/org/jetbrains/compose/reload/analysis/asmUtils.kt index baf0c872..331ccfd9 100644 --- a/hot-reload-analysis/src/main/kotlin/org/jetbrains/compose/reload/analysis/asmUtils.kt +++ b/hot-reload-analysis/src/main/kotlin/org/jetbrains/compose/reload/analysis/asmUtils.kt @@ -27,6 +27,10 @@ internal fun AbstractInsnNode.intValueOrNull(): Int? { } } +internal fun AbstractInsnNode.stringValueOrNull(): String? { + return (this as? LdcInsnNode)?.cst as? String +} + internal fun MethodNode.readFunctionKeyMetaAnnotation(): ComposeGroupKey? { val functionKey = visibleAnnotations.orEmpty().find { annotationNode -> annotationNode.desc == functionKeyMetaConstructorDescriptor diff --git a/hot-reload-analysis/src/main/kotlin/org/jetbrains/compose/reload/analysis/ids.kt b/hot-reload-analysis/src/main/kotlin/org/jetbrains/compose/reload/analysis/ids.kt index 2417bc58..0324e205 100644 --- a/hot-reload-analysis/src/main/kotlin/org/jetbrains/compose/reload/analysis/ids.kt +++ b/hot-reload-analysis/src/main/kotlin/org/jetbrains/compose/reload/analysis/ids.kt @@ -32,9 +32,9 @@ internal object MethodIds { ) val sourceInformationMarkerStart = MethodId( - composerClazzId, + composerKtClazzId, methodName = sourceInformationMarkerStartMethodName, - methodDescriptor = "(ILjava/lang/String;)V" + methodDescriptor = "(Landroidx/compose/runtime/Composer;ILjava/lang/String;)V" ) val endReplaceGroup = MethodId( @@ -50,9 +50,9 @@ internal object MethodIds { ) val sourceInformationMarkerEnd = MethodId( - composerClazzId, + composerKtClazzId, methodName = sourceInformationMarkerEndMethodName, - methodDescriptor = "()V" + methodDescriptor = "(Landroidx/compose/runtime/Composer;)V" ) } diff --git a/hot-reload-analysis/src/test/kotlin/org/jetbrains/compose/reload/analysis/tests/SourceInfoParsingTest.kt b/hot-reload-analysis/src/test/kotlin/org/jetbrains/compose/reload/analysis/tests/SourceInfoParsingTest.kt new file mode 100644 index 00000000..2c70c494 --- /dev/null +++ b/hot-reload-analysis/src/test/kotlin/org/jetbrains/compose/reload/analysis/tests/SourceInfoParsingTest.kt @@ -0,0 +1,106 @@ +package org.jetbrains.compose.reload.analysis.tests + +import org.jetbrains.compose.reload.analysis.SourceInfo.Location +import org.jetbrains.compose.reload.analysis.SourceInfo.Parameters +import org.jetbrains.compose.reload.analysis.asSequence +import org.jetbrains.compose.reload.analysis.parseSourceInfo +import kotlin.test.Test +import kotlin.test.assertEquals + +class SourceInfoParsingTest { + + @Test + fun `test - top level marker`() { + val info = parseSourceInfo("C:Foo.kt") + assertEquals("", info.functionName) + assertEquals(true, info.isLambda) + assertEquals(false, info.isInline) + assertEquals(null, info.parameters) + assertEquals(emptyList(), info.locations) + assertEquals("Foo.kt", info.fileName) + assertEquals(null, info.hash) + } + + @Test + fun `test - remember`() { + val info = parseSourceInfo("CC(remember):Foo.kt#9igjgp") + assertEquals("remember", info.functionName) + assertEquals(false, info.isLambda) + assertEquals(true, info.isInline) + assertEquals(null, info.parameters) + assertEquals(emptyList(), info.locations) + assertEquals("Foo.kt", info.fileName) + assertEquals("9igjgp", info.hash) + } + + @Test + fun `test - Layout`() { + val info = parseSourceInfo("CC(Layout)P(!1,2)79@3208L23,82@3359L411:Layout.kt#80mrfh") + assertEquals("Layout", info.functionName) + assertEquals(false, info.isLambda) + assertEquals(true, info.isInline) + assertEquals(Parameters("!1,2", 79), info.parameters) + assertEquals( + listOf( + Location(3208, 23, 82), + Location(3359, 411) + ), info.locations + ) + assertEquals("Layout.kt", info.fileName) + assertEquals("80mrfh", info.hash) + } + + @Test + fun `test - Column`() { + val info = parseSourceInfo("C88@4444L9:Column.kt#2w3rfo") + assertEquals("88", info.functionName) + assertEquals(true, info.isLambda) + assertEquals(false, info.isInline) + assertEquals(null, info.parameters) + assertEquals(listOf(Location(4444, 9, -1)), info.locations) + assertEquals("Column.kt", info.fileName) + assertEquals("2w3rfo", info.hash) + } + + @Test + fun `test - lambda with locations`() { + val info = parseSourceInfo("C15@399L24,16@432L22,18@464L40,20@531L30,20@514L89:Foo.kt") + + assertEquals("15", info.functionName) + assertEquals(true, info.isLambda) + assertEquals(false, info.isInline) + assertEquals(null, info.parameters) + assertEquals( + listOf( + Location(399, 24, 16), + Location(432, 22, 18), + Location(464, 40, 20), + Location(531, 30, 20), + Location(514, 89), + ), + info.locations + ) + assertEquals("Foo.kt", info.fileName) + assertEquals(null, info.hash) + } + + @Test + fun `test - empty string parsed as empty sequence`() { + assertEquals(emptyList(), "".asSequence().toList()) + } + + @Test + fun `test - 1,2,3 parsed as sequence`() { + assertEquals(listOf(1, 2, 3), "1,2,3".asSequence().toList()) + } + + @Test + fun `test - !5 parsed as sequence`() { + assertEquals(listOf(0, 1, 2, 3, 4), "!5".asSequence().toList()) + } + + @Test + fun `test - mixed sequence parsed as sequence`() { + assertEquals(listOf(1, 0, 1, 2, 3, 4, 3, 0, 1), "1,!5,3,!2".asSequence().toList()) + } +} \ No newline at end of file diff --git a/hot-reload-core/src/testFixtures/kotlin/org/jetbrains/compose/reload/core/testFixtures/CompilerOptions.kt b/hot-reload-core/src/testFixtures/kotlin/org/jetbrains/compose/reload/core/testFixtures/CompilerOptions.kt index 4c2fca9e..67f54bfe 100644 --- a/hot-reload-core/src/testFixtures/kotlin/org/jetbrains/compose/reload/core/testFixtures/CompilerOptions.kt +++ b/hot-reload-core/src/testFixtures/kotlin/org/jetbrains/compose/reload/core/testFixtures/CompilerOptions.kt @@ -4,7 +4,8 @@ import org.jetbrains.compose.reload.core.testFixtures.CompilerOption.entries enum class CompilerOption(val default: Boolean) { OptimizeNonSkippingGroups(true), - GenerateFunctionKeyMetaAnnotations(true); + GenerateFunctionKeyMetaAnnotations(true), + SourceInformation(false); } object CompilerOptions { diff --git a/hot-reload-core/src/testFixtures/kotlin/org/jetbrains/compose/reload/core/testFixtures/compileCode.kt b/hot-reload-core/src/testFixtures/kotlin/org/jetbrains/compose/reload/core/testFixtures/compileCode.kt index 83b3d530..2e3c9b1e 100644 --- a/hot-reload-core/src/testFixtures/kotlin/org/jetbrains/compose/reload/core/testFixtures/compileCode.kt +++ b/hot-reload-core/src/testFixtures/kotlin/org/jetbrains/compose/reload/core/testFixtures/compileCode.kt @@ -84,7 +84,9 @@ private class CompilerImpl( "plugin:androidx.compose.compiler.plugins.kotlin:featureFlag=OptimizeNonSkippingGroups" .takeIf { options[CompilerOption.OptimizeNonSkippingGroups] == true }, "plugin:androidx.compose.compiler.plugins.kotlin:generateFunctionKeyMetaAnnotations=function" - .takeIf { options[CompilerOption.GenerateFunctionKeyMetaAnnotations] == true } + .takeIf { options[CompilerOption.GenerateFunctionKeyMetaAnnotations] == true }, + "plugin:androidx.compose.compiler.plugins.kotlin:sourceInformation=true" + .takeIf { options[CompilerOption.SourceInformation] == true } ).toTypedArray() arguments.freeArgs = code.keys.map { path -> workingDir.resolve(path).absolutePathString() } diff --git a/hot-reload-gradle-plugin/src/functionalTest/kotlin/org/jetbrains/compose/reload/utils/DefaultBuildGradleKts.kt b/hot-reload-gradle-plugin/src/functionalTest/kotlin/org/jetbrains/compose/reload/utils/DefaultBuildGradleKts.kt index 86c5dc3b..6efdabed 100644 --- a/hot-reload-gradle-plugin/src/functionalTest/kotlin/org/jetbrains/compose/reload/utils/DefaultBuildGradleKts.kt +++ b/hot-reload-gradle-plugin/src/functionalTest/kotlin/org/jetbrains/compose/reload/utils/DefaultBuildGradleKts.kt @@ -92,6 +92,12 @@ private class DefaultBuildGradleKtsExtension() : BeforeTestExecutionCallback { error("Unsupported compiler option: $key") } } + + CompilerOption.SourceInformation -> { + if (enabled != CompilerOption.SourceInformation.default) { + error("Unsupported compiler option: $key") + } + } } } }