Skip to content

Commit e667b58

Browse files
authored
Support @composable functions (#83)
Download required runtime lib and compiler plugin if code is Compose.
1 parent 60492aa commit e667b58

File tree

7 files changed

+190
-15
lines changed

7 files changed

+190
-15
lines changed
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/*
2+
* Copyright (C) 2025 Romain Guy
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package dev.romainguy.kotlin.explorer
17+
18+
import java.io.FileNotFoundException
19+
import java.net.URL
20+
import java.nio.file.Path
21+
import java.nio.file.StandardOpenOption.*
22+
import java.util.zip.ZipInputStream
23+
import kotlin.io.path.createParentDirectories
24+
import kotlin.io.path.notExists
25+
import kotlin.io.path.outputStream
26+
27+
private const val MAVEN_REPO = "https://repo1.maven.org/maven2"
28+
private const val GOOGLE_REPO = "https://maven.google.com"
29+
private val repos = listOf(MAVEN_REPO, GOOGLE_REPO)
30+
31+
private const val BASE_PATH = "%1\$s/%2\$s/%3\$s/%2\$s-%3\$s"
32+
33+
class DependencyCache(private val root: Path) {
34+
35+
fun getDependency(group: String, name: String, version: String, onOutput: (String) -> Unit): Path {
36+
val basePath = BASE_PATH.format(group.replace('.', '/'), name, version)
37+
val dst = root.resolve("$basePath.jar")
38+
if (dst.notExists()) {
39+
onOutput("Downloading artifact $group:$name:$version")
40+
repos.forEach {
41+
if (getDependency(it, basePath, dst, onOutput)) {
42+
return dst
43+
}
44+
}
45+
onOutput("Could not find artifact $group:$name:$version")
46+
}
47+
return dst
48+
}
49+
50+
/**
51+
* Try to download a jar from a repo.
52+
*
53+
* First tries to download the `jar` file directly. If the `jar` is not found, will try to download an `aar` and
54+
* extract the `classes.ja` file from it.
55+
*
56+
* @return true if the repo owns the artifact.
57+
*/
58+
private fun getDependency(repo: String, basePath: String, dst: Path, onOutput: (String) -> Unit): Boolean {
59+
try {
60+
// Does the artifact exist in repo?
61+
URL("$repo/$basePath.pom").openStream().reader().close()
62+
} catch (_: FileNotFoundException) {
63+
return false
64+
}
65+
dst.createParentDirectories()
66+
dst.outputStream(CREATE, WRITE, TRUNCATE_EXISTING).use { outputStream ->
67+
try {
68+
URL("$repo/$basePath.jar").openStream().use {
69+
it.copyTo(outputStream)
70+
}
71+
} catch (_: FileNotFoundException) {
72+
val aar = "$repo/$basePath.aar"
73+
74+
ZipInputStream(URL(aar).openStream()).use {
75+
while (true) {
76+
val entry = it.nextEntry
77+
if (entry == null) {
78+
onOutput("Could not find 'classes.jar' in $aar")
79+
return true
80+
}
81+
if (entry.name == "classes.jar") {
82+
it.copyTo(outputStream)
83+
break
84+
}
85+
}
86+
}
87+
88+
}
89+
}
90+
return true
91+
}
92+
}

src/jvmMain/kotlin/dev/romainguy/kotlin/explorer/Disassembly.kt

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ suspend fun buildAndRun(
4747
toolPaths: ToolPaths,
4848
kotlinOnlyConsumers: Boolean,
4949
compilerFlags: String,
50+
settingsDirectory: Path,
51+
composeVersion: String,
5052
source: String,
5153
onLogs: (AnnotatedString) -> Unit,
5254
onStatusUpdate: (String, Float) -> Unit
@@ -65,7 +67,7 @@ suspend fun buildAndRun(
6567
Files.writeString(path, source)
6668
writeSupportFiles(directory)
6769

68-
val kotlinc = KotlinCompiler(toolPaths, directory).compile(kotlinOnlyConsumers, compilerFlags, path)
70+
val kotlinc = KotlinCompiler(toolPaths, settingsDirectory, directory).compile(kotlinOnlyConsumers, compilerFlags, composeVersion, path)
6971

7072
if (kotlinc.exitCode != 0) {
7173
withContext(ui) {
@@ -102,6 +104,8 @@ suspend fun buildAndDisassemble(
102104
compilerFlags: String,
103105
r8rules: String,
104106
minApi: Int,
107+
settingsDirectory: Path,
108+
composeVersion: String,
105109
instructionSets: Map<ISA, Boolean>,
106110
onByteCode: (CodeContent) -> Unit,
107111
onDex: (CodeContent) -> Unit,
@@ -127,7 +131,7 @@ suspend fun buildAndDisassemble(
127131
Files.writeString(path, source)
128132
writeSupportFiles(directory)
129133

130-
val kotlinc = KotlinCompiler(toolPaths, directory).compile(kotlinOnlyConsumers, compilerFlags, path)
134+
val kotlinc = KotlinCompiler(toolPaths, settingsDirectory, directory).compile(kotlinOnlyConsumers, compilerFlags, composeVersion, path)
131135

132136
if (kotlinc.exitCode != 0) {
133137
updater.addJob(launch(ui) {

src/jvmMain/kotlin/dev/romainguy/kotlin/explorer/KotlinExplorer.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,8 @@ private fun FrameWindowScope.MainMenu(
532532
explorerState.compilerFlags,
533533
explorerState.r8Rules,
534534
explorerState.minApi,
535+
explorerState.directory,
536+
explorerState.composeVersion,
535537
instructionSets,
536538
onByteCodeUpdate,
537539
onDexUpdate,
@@ -549,6 +551,8 @@ private fun FrameWindowScope.MainMenu(
549551
explorerState.toolPaths,
550552
explorerState.kotlinOnlyConsumers,
551553
explorerState.compilerFlags,
554+
explorerState.directory,
555+
explorerState.composeVersion,
552556
sourceTextArea.text,
553557
onLogsUpdate,
554558
onStatusUpdate

src/jvmMain/kotlin/dev/romainguy/kotlin/explorer/Settings.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ fun Settings(
4444
val kotlinOnlyConsumers = remember { mutableStateOf(state.kotlinOnlyConsumers) }
4545
val compilerFlags = rememberTextFieldState(state.compilerFlags)
4646
val r8rules = rememberTextFieldState(state.r8Rules)
47+
val composeVersion = rememberTextFieldState(state.composeVersion)
4748
val minApi = rememberTextFieldState(state.minApi.toString())
4849
val indent = rememberTextFieldState(state.indent.toString())
4950
val lineNumberWidth = rememberTextFieldState(state.lineNumberWidth.toString())
@@ -55,6 +56,7 @@ fun Settings(
5556
kotlinOnlyConsumers.value,
5657
compilerFlags.text.toString(),
5758
r8rules.text.toString(),
59+
composeVersion.text.toString(),
5860
minApi.text.toString(),
5961
indent.text.toString(),
6062
lineNumberWidth.text.toString(),
@@ -71,6 +73,7 @@ fun Settings(
7173
IntSetting("Line number column width: ", lineNumberWidth, minValue = 1)
7274
StringSetting("Kotlin compiler flags: ", compilerFlags)
7375
MultiLineStringSetting("R8 rules: ", r8rules)
76+
StringSetting("Compose version: ", composeVersion) {composeVersion.text.isNotEmpty()}
7477
IntSetting("Min API: ", minApi, minValue = 1)
7578
BooleanSetting("Kotlin only consumers", kotlinOnlyConsumers)
7679
BooleanSetting("Decompile hidden instruction sets", decompileHiddenIsa)
@@ -101,6 +104,7 @@ private fun ExplorerState.saveState(
101104
kotlinOnlyConsumers: Boolean,
102105
compilerFlags: String,
103106
r8Rules: String,
107+
composeVersion: String,
104108
minApi: String,
105109
indent: String,
106110
lineNumberWidth: String,
@@ -111,6 +115,7 @@ private fun ExplorerState.saveState(
111115
this.kotlinOnlyConsumers = kotlinOnlyConsumers
112116
this.compilerFlags = compilerFlags
113117
this.r8Rules = r8Rules
118+
this.composeVersion = composeVersion
114119
this.minApi = minApi.toIntOrNull() ?: 21
115120
this.indent = indent.toIntOrNull() ?: 4
116121
this.lineNumberWidth = lineNumberWidth.toIntOrNull() ?: 4

src/jvmMain/kotlin/dev/romainguy/kotlin/explorer/State.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ private const val KeepEverything = "KEEP_EVERYTHING"
3232
private const val KotlinOnlyConsumers = "KOTLIN_ONLY_CONSUMERS"
3333
private const val CompilerFlags = "COMPILER_FLAGS"
3434
private const val R8Rules = "R8_RULES"
35+
private const val ComposeVersion = "COMPOSE_VERSION"
3536
private const val MinApi = "MIN_API"
3637
private const val AutoBuildOnStartup = "AUTO_BUILD_ON_STARTUP"
3738
private const val Presentation = "PRESENTATION"
@@ -63,6 +64,7 @@ class ExplorerState {
6364
var kotlinOnlyConsumers by BooleanState(KotlinOnlyConsumers, true)
6465
var compilerFlags by StringState(CompilerFlags, "")
6566
var r8Rules by StringState(R8Rules, "")
67+
var composeVersion by StringState(ComposeVersion, "1.8.1")
6668
var minApi by IntState(MinApi, 21)
6769
var autoBuildOnStartup by BooleanState(AutoBuildOnStartup, false)
6870
var presentationMode by BooleanState(Presentation, false)

src/jvmMain/kotlin/dev/romainguy/kotlin/explorer/build/KolinCompiler.kt

Lines changed: 79 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,28 +16,72 @@
1616

1717
package dev.romainguy.kotlin.explorer.build
1818

19+
import dev.romainguy.kotlin.explorer.DependencyCache
20+
import dev.romainguy.kotlin.explorer.ProcessResult
1921
import dev.romainguy.kotlin.explorer.ToolPaths
2022
import dev.romainguy.kotlin.explorer.process
2123
import java.io.File
2224
import java.nio.file.Path
25+
import kotlin.io.path.useLines
2326

2427
private val BuiltInFiles = listOf(
2528
"Keep.kt",
2629
"NeverInline.kt"
2730
)
2831

29-
class KotlinCompiler(private val toolPaths: ToolPaths, private val outputDirectory: Path) {
30-
suspend fun compile(kotlinOnlyConsumers: Boolean, compilerFlags: String, source: Path) =
31-
process(*buildCompileCommand(kotlinOnlyConsumers, compilerFlags, source), directory = outputDirectory)
32+
class KotlinCompiler(
33+
private val toolPaths: ToolPaths,
34+
settingsDirectory: Path,
35+
private val outputDirectory: Path,
36+
) {
37+
private val dependencyCache = DependencyCache(settingsDirectory.resolve("dependency-cache"))
3238

33-
private fun buildCompileCommand(kotlinOnlyConsumers: Boolean, compilerFlags: String, file: Path): Array<String> {
34-
val classpath = (toolPaths.kotlinLibs + listOf(toolPaths.platform)).joinToString(File.pathSeparator) { jar -> jar.toString() }
35-
val command = mutableListOf(
36-
toolPaths.kotlinc.toString(),
37-
"-Xmulti-platform",
38-
"-classpath",
39-
"\"$classpath\""
40-
).apply {
39+
suspend fun compile(
40+
kotlinOnlyConsumers: Boolean,
41+
compilerFlags: String,
42+
composeVersion: String,
43+
source: Path
44+
): ProcessResult {
45+
val sb = StringBuilder()
46+
val result = process(
47+
*buildCompileCommand(
48+
kotlinOnlyConsumers,
49+
compilerFlags,
50+
composeVersion,
51+
source
52+
) { sb.appendLine(it) }, directory = outputDirectory
53+
)
54+
return ProcessResult(result.exitCode, "$sb\n${result.output}")
55+
}
56+
57+
private suspend fun buildCompileCommand(
58+
kotlinOnlyConsumers: Boolean,
59+
compilerFlags: String,
60+
composeVersion: String,
61+
file: Path,
62+
onOutput: (String) -> Unit
63+
): Array<String> {
64+
val isCompose = file.isCompose()
65+
val classpath = buildList {
66+
addAll(toolPaths.kotlinLibs)
67+
add(toolPaths.platform)
68+
if (isCompose) {
69+
add(
70+
dependencyCache.getDependency(
71+
"androidx.compose.runtime",
72+
"runtime-android",
73+
composeVersion,
74+
onOutput,
75+
)
76+
)
77+
}
78+
}.joinToString((File.pathSeparator)) { it.toString() }
79+
80+
val command = buildList {
81+
add(toolPaths.kotlinc.toString())
82+
add("-Xmulti-platform")
83+
add("-classpath")
84+
add(classpath)
4185
if (kotlinOnlyConsumers) {
4286
this += "-Xno-param-assertions"
4387
this += "-Xno-call-assertions"
@@ -47,6 +91,15 @@ class KotlinCompiler(private val toolPaths: ToolPaths, private val outputDirecto
4791
// TODO: Do something smarter in case a flag looks like -foo="something with space"
4892
addAll(compilerFlags.split(' '))
4993
}
94+
if (isCompose) {
95+
val composePlugin = dependencyCache.getDependency(
96+
"org.jetbrains.kotlin",
97+
"kotlin-compose-compiler-plugin",
98+
getKotlinVersion(),
99+
onOutput,
100+
)
101+
add("-Xplugin=$composePlugin")
102+
}
50103
// Source code to compile
51104
this += file.toString()
52105
for (fileName in BuiltInFiles) {
@@ -56,4 +109,19 @@ class KotlinCompiler(private val toolPaths: ToolPaths, private val outputDirecto
56109

57110
return command.toTypedArray()
58111
}
112+
113+
private suspend fun getKotlinVersion(): String {
114+
val command = mutableListOf(
115+
toolPaths.kotlinc.toString(),
116+
"-version",
117+
).toTypedArray()
118+
return process(*command).output.substringAfter("kotlinc-jvm ").substringBefore(" ")
119+
}
59120
}
121+
122+
private fun Path.isCompose(): Boolean {
123+
return useLines { lines ->
124+
lines.filter { it.trim().startsWith("import") }
125+
.any { it.split(" ").last() == "androidx.compose.runtime.Composable" }
126+
}
127+
}

src/jvmTest/kotlin/dev/romainguy/kotlin/explorer/testing/Builder.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ class LocalBuilder(private val outputDirectory: Path) : Builder {
5454
private val cwd = Path.of(System.getProperty("user.dir"))
5555
private val testData = cwd.resolve("src/jvmTest/kotlin/testData")
5656
private val toolPaths = createToolsPath()
57-
private val kotlinCompiler = KotlinCompiler(toolPaths, outputDirectory)
57+
private val kotlinCompiler = KotlinCompiler(toolPaths, outputDirectory, outputDirectory)
5858
private val byteCodeDecompiler = ByteCodeDecompiler()
5959

6060
override fun generateByteCode(testFile: String): String {
@@ -82,7 +82,7 @@ class LocalBuilder(private val outputDirectory: Path) : Builder {
8282
}
8383

8484
private suspend fun kotlinCompile(path: Path) {
85-
val result = kotlinCompiler.compile(true, "", path)
85+
val result = kotlinCompiler.compile(true, "", "not-used", path)
8686
if (result.exitCode != 0) {
8787
System.err.println(result.output)
8888
fail("kotlinc error")

0 commit comments

Comments
 (0)