Skip to content

KotlinToJsCompiler.kt

src/jvmMain/kotlin/afield/lang/remotejs/KotlinToJsCompiler.kt · 627 lines

package afield.lang.remotejs

import afield.runtime.kotlin.FieldKtParser
import afield.app.diagnostics.logging.lprintln
import org.jetbrains.kotlin.cli.common.ExitCode
import org.jetbrains.kotlin.cli.common.arguments.K2JSCompilerArguments
import org.jetbrains.kotlin.cli.common.messages.CompilerMessageSeverity
import org.jetbrains.kotlin.cli.common.messages.CompilerMessageSourceLocation
import org.jetbrains.kotlin.cli.common.messages.MessageCollector
import org.jetbrains.kotlin.cli.js.K2JSCompiler
import org.jetbrains.kotlin.config.Services
import org.jetbrains.kotlin.com.intellij.psi.util.PsiTreeUtil
import org.jetbrains.kotlin.lexer.KtTokens
import org.jetbrains.kotlin.psi.KtCallExpression
import org.jetbrains.kotlin.psi.KtDotQualifiedExpression
import org.jetbrains.kotlin.psi.KtElement
import org.jetbrains.kotlin.psi.KtModifierListOwner
import org.jetbrains.kotlin.psi.KtNamedFunction
import org.jetbrains.kotlin.psi.KtObjectDeclaration
import org.jetbrains.kotlin.psi.KtProperty
import org.jetbrains.kotlin.psi.KtScriptInitializer
import org.jetbrains.kotlin.psi.KtTypeAlias
import java.io.File
import java.nio.file.Files

SPIKE — Kotlin source -> JavaScript, for RemoteJS boxes.

This mirrors what JetBrains' kotlin-compiler-server (KotlinToJSTranslator) does: a two-phase K2 compile of a self-contained Kotlin snippet into a single, eval-able JS module that the existing RemoteJS transport ships to the browser / visionOS "graphics head" over the WebSocket bus.

phase 1:  source.kt      --( -Xir-produce-klib-file )-->                 <module>.klib
phase 2:  <module>.klib  --( -Xir-produce-js, -Xinclude, -Xir-dce )-->   <module>.js

What this buys a box author is Kotlin: real types, completion, and native operator fun plus vector and matrix math, so a Kotlin box needs none of the operator rewriting Asta.kt does for a JavaScript one. The result runs unchanged in the head via executeRemoteJS with doASTA = false.

Output is a plain module that self-invokes main, so the head's global.eval(code) runs it with no changes. Dead-code elimination is what keeps that module small enough to ship on every compile: it tree-shakes the stdlib, taking a trivial program well under the ~600 KB an unshaken one contributes, and KotlinToJsSpikeTest holds the emitted JS under 50 000 characters.

Two execution modes (Mode): - IN_PROCESS: calls K2JSCompiler().exec(...) directly, with no JVM launch to pay for. The K2 REPL (KotlinInterface2) already holds a live IntelliJ application environment in this JVM and K2JSCompiler reuses the process-wide getOrCreateApplicationEnvironment; the two coexist, and a throw from that call falls back to SUBPROCESS rather than failing the compile. - SUBPROCESS: spawns java -cp <this app's classpath> K2JSCompiler. Fully isolated, and reuses the embeddable compiler already on the runtime classpath. Slower, by a cold JVM per compile.

Follow-up (see dev_docs/kotlin_to_js_spike.md): incremental compiles via JsIrCompilerWithIC, and a typed external façade over the head runtime (THREE / A-Frame / Field JS).

object KotlinToJsCompiler

The compiler itself: compile is the entry point, Mode picks how it runs and Profile picks the shape of what comes out.

Errors arrive on Result as Diagnostic rows carrying a line and column, so a box can decorate the source line that caused them, with the compiler's own log kept alongside in rawLog. The severities the compiler uses for progress chatter are dropped from those rows unless they carry a location. Those line numbers take one more step to become usable: a compiled box has its head declarations and an implicit-main wrapper prepended first, so the line the compiler reports is not the line the author is looking at, and the preparation step keeps a map back to the editor's own numbering for the diagnostics to be rewritten through.

object KotlinToJsCompiler {

Compiler-fork version. The Kotlin/JS stdlib klib MUST match the compiler exactly (the fork hard-fails linking against a mismatched stdlib), which is why we vendor it next to the jars.

    private const val FORK_VERSION = "2.5.255-SNAPSHOT"

enum class Mode { IN_PROCESS, SUBPROCESS }

enum class Profile { PLAIN_DCE, ESM_SHARED }

Output shape. - PLAIN_DCE: one self-contained, tree-shaken plain module (~5 KB for a trivial program). Eval-able as-is; DCE runs the whole-program reachability pass on every compile. - ESM_SHARED: per-module ES output — a tiny box module (~2 KB) that imports from a kotlin-kotlin-stdlib.mjs shipped to the head once. No per-compile DCE over the stdlib.

Default mode; override with -Dafield.kotlinjs.mode=subprocess.

    private val defaultMode: Mode =
        if (System.getProperty("afield.kotlinjs.mode").equals("subprocess", ignoreCase = true))
            Mode.SUBPROCESS else Mode.IN_PROCESS

data class Diagnostic(val severity: String, val message: String, val line: Int, val column: Int)

data class Result

    data class Result(
        val js: String?,
        val diagnostics: List<Diagnostic>,
        val success: Boolean,
        /** which path actually produced this result */
        val mode: Mode,
        /** raw compiler log, kept for spike-time debugging */
        val rawLog: String,
        /** The v3 source map of [js] (generated JS → prepared Kotlin lines), when the compiler wrote one. */
        val sourceMap: String? = null,
    ) {

internal fun remapDiagnostics(source: PreparedSource): Result

        internal fun remapDiagnostics(source: PreparedSource): Result = copy(
            diagnostics = diagnostics.map { diagnostic ->
                diagnostic.copy(line = source.userLineFor(diagnostic.line))
            },
        )
    }

internal data class PreparedSource

Compiler input plus the line map back to the editor source. Generated wrapper/declaration lines map to null; user-authored lines retain their original one-based line number.

    internal data class PreparedSource(
        val text: String,
        private val generatedToUserLine: List<Int?>,
    ) {

fun userLineFor(generatedLine: Int): Int

        fun userLineFor(generatedLine: Int): Int {
            val index = generatedLine - 1
            return if (index in generatedToUserLine.indices) generatedToUserLine[index] ?: 0 else generatedLine
        }

fun appendUnmapped(suffix: String): PreparedSource

        fun appendUnmapped(suffix: String): PreparedSource {
            if (suffix.isBlank()) return this
            val separator = if (text.endsWith('\n')) "\n" else "\n\n"
            val appended = separator + suffix
            return PreparedSource(
                text + appended,
                generatedToUserLine + List(appended.count { it == '\n' }) { null },
            )
        }

companion object

fun identity(source: String): PreparedSource
            fun identity(source: String): PreparedSource =
                PreparedSource(source, source.split('\n').indices.map { it + 1 })
        }
    }

The fork-matched Kotlin/JS stdlib klib. Override with -Dafield.kotlinjs.stdlibJs=.

    private val stdlibJsKlib: File by lazy {
        System.getProperty("afield.kotlinjs.stdlibJs")?.let { File(it) }?.takeIf { it.exists() }
            ?: locateVendoredKlib()
            ?: error(
                "Kotlin/JS stdlib klib not found. Expected libs/kotlin-stdlib-js-$FORK_VERSION.klib " +
                    "(git-lfs) or -Dafield.kotlinjs.stdlibJs=<path>."
            )
    }

    private fun locateVendoredKlib(): File? {
        val name = "kotlin-stdlib-js-$FORK_VERSION.klib"
        return listOf(File("libs/$name"), File(System.getProperty("user.dir"), "libs/$name"))
            .firstOrNull { it.exists() }
    }

    private val HAS_MAIN = Regex("""(?m)^\s*(?:(?:public|private|internal|suspend|expect|actual|external)\s+)*fun\s+main\s*\(""")
    private val FILE_SCOPE_MODIFIERS = listOf(
        KtTokens.EXTERNAL_KEYWORD,
        KtTokens.EXPECT_KEYWORD,
        KtTokens.ACTUAL_KEYWORD,
        KtTokens.CONST_KEYWORD,
        KtTokens.SEALED_KEYWORD,
        KtTokens.ENUM_KEYWORD,
        KtTokens.ANNOTATION_KEYWORD,
        KtTokens.PUBLIC_KEYWORD,
        KtTokens.PRIVATE_KEYWORD,
        KtTokens.PROTECTED_KEYWORD,
        KtTokens.INTERNAL_KEYWORD,
    )

    private fun mustStayAtFileScope(element: KtElement): Boolean =
        element is KtObjectDeclaration ||
            element is KtTypeAlias ||
            (element as? KtModifierListOwner)?.let { owner ->
                FILE_SCOPE_MODIFIERS.any(owner::hasModifier)
            } == true

internal fun prepareImplicitMain

Lower Field's script-like box syntax to a normal Kotlin/JS file. PSI gives us complete top-level declarations (including multiline properties, functions/classes, annotations and expression-bodied declarations), which stay at file scope. Script initializers become the synthesized main body.

activeUserLines is used for fragment execution: all declarations from the current box remain compiler-visible, while only selected script initializers run. This is the Kotlin/JS analogue of a REPL fragment seeing names established by the box, without replaying unrelated print, drawing, or lifecycle statements.

body — 107 lines, from line 191
    internal fun prepareImplicitMain(
        source: String,
        activeUserLines: IntRange? = null,
        stateKey: String? = null,
    ): PreparedSource {
        if (source.isBlank()) return PreparedSource.identity(source)
        if (HAS_MAIN.containsMatchIn(source)) {
            return PreparedSource.identity(runCatching { instrumentEditorPrints(source) }.getOrDefault(source))
        }

        return try {
            val instrumentedSource = instrumentEditorPrints(source)
            val file = FieldKtParser.parse(instrumentedSource)
            val script = file.script ?: return PreparedSource.identity(source)
            val lineStarts = buildList {
                add(0)
                instrumentedSource.forEachIndexed { index, c -> if (c == '\n') add(index + 1) }
            }
            fun lineAt(offset: Int): Int {
                val found = lineStarts.binarySearch(offset)
                return if (found >= 0) found + 1 else -found - 1
            }
            val lines = mutableListOf<String>()
            val lineMap = mutableListOf<Int?>()

            fun appendGenerated(line: String) {
                lines += line
                lineMap += null
            }

            fun appendElement(text: String, sourceStartLine: Int) {
                text.split('\n').forEachIndexed { index, line ->
                    lines += line
                    lineMap += sourceStartLine + index
                }
            }

            listOfNotNull(file.fileAnnotationList, file.packageDirective?.takeIf { it.text.isNotBlank() }, file.importList)
                .filter { it.text.isNotBlank() }
                .forEach {
                    appendElement(it.text, lineAt(it.textRange.startOffset))
                    appendGenerated("")
                }

            val statements = script.blockExpression.statements
            val declarations = statements.filterNot { it is KtScriptInitializer }
            val hoisted = if (activeUserLines == null) {
                declarations.filter(::mustStayAtFileScope)
            } else {
                declarations
            }
            hoisted.forEach {
                val text = if (activeUserLines != null && stateKey != null && it is KtProperty) {
                    statefulProperty(it) ?: it.text
                } else {
                    it.text
                }
                appendElement(text, lineAt(it.textRange.startOffset))
                appendGenerated("")
            }

            appendGenerated("fun main() {")
            if (activeUserLines == null && stateKey != null) appendGenerated("__fieldKotlinStateClear()")
            statements
                .filter { statement ->
                    if (activeUserLines == null) {
                        statement !in hoisted
                    } else {
                        statement is KtScriptInitializer && run {
                            val start = lineAt(statement.textRange.startOffset)
                            val end = lineAt(
                                (statement.textRange.endOffset - 1).coerceAtLeast(statement.textRange.startOffset),
                            )
                            start <= activeUserLines.last && end >= activeUserLines.first
                        }
                    }
                }
                .forEach {
                    appendElement(it.text, lineAt(it.textRange.startOffset))
                }
            if (stateKey != null) {
                declarations.filterIsInstance<KtProperty>().mapNotNull { it.name }.forEach { name ->
                    appendGenerated("""__fieldKotlinStateSet("$name", $name)""")
                }
            }
            appendGenerated("}")

            var prepared = PreparedSource(lines.joinToString("\n", postfix = "\n"), lineMap)
            if (stateKey != null) prepared = prepared.appendUnmapped(stateSupport(stateKey))
            prepared
        } catch (t: Throwable) {
            // Let K2 report malformed source rather than replacing a useful compiler diagnostic
            // with a ktfmt parser exception. The simple wrapper still carries the common +1 map.
            val bodyLines = source.split('\n')
            PreparedSource(
                buildString {
                    append("fun main() {\n")
                    append(source)
                    if (!source.endsWith('\n')) append('\n')
                    append("}\n")
                },
                listOf(null) + bodyLines.indices.map { it + 1 } + listOf(null),
            )
        }
    }

    private data class SourceReplacement(val start: Int, val end: Int, val text: String)

Carry direct Kotlin print/println call sites through the generated JavaScript. Remote heads accept the optional second argument and echo it in the execution response, allowing Monaco to render the output beside a real source line.

source — 87 lines, from line 304
    private fun instrumentEditorPrints(source: String): String {
        val file = FieldKtParser.parse(source)
        val shadowedNames = PsiTreeUtil.collectElementsOfType(file, KtNamedFunction::class.java)
            .mapNotNullTo(mutableSetOf()) { it.name }
        val lineStarts = buildList {
            add(0)
            source.forEachIndexed { index, c -> if (c == '\n') add(index + 1) }
        }
        fun lineAt(offset: Int): Int {
            val found = lineStarts.binarySearch(offset)
            return if (found >= 0) found + 1 else -found - 1
        }

        val replacements = mutableListOf<SourceReplacement>()
        PsiTreeUtil.collectElementsOfType(file, KtCallExpression::class.java).forEach { call ->
            val callee = call.calleeExpression ?: return@forEach
            if (callee.text != "print" && callee.text != "println") return@forEach
            if (callee.text in shadowedNames) return@forEach
            val parent = call.parent
            if (parent is KtDotQualifiedExpression && parent.selectorExpression == call) return@forEach
            val arguments = call.valueArgumentList ?: return@forEach
            val leftParen = arguments.leftParenthesis ?: return@forEach
            val line = lineAt(callee.textRange.startOffset)
            replacements += SourceReplacement(
                callee.textRange.startOffset,
                callee.textRange.endOffset,
                "__fieldPrintAt",
            )
            replacements += SourceReplacement(
                leftParen.textRange.endOffset,
                leftParen.textRange.endOffset,
                if (arguments.arguments.isEmpty()) "$line" else "$line, ",
            )
        }
        if (replacements.isEmpty()) return source

        return StringBuilder(source).apply {
            replacements.sortedWith(compareByDescending<SourceReplacement> { it.start }.thenByDescending { it.end })
                .forEach { replace(it.start, it.end, it.text) }
        }.toString()
    }

    private fun statefulProperty(property: KtProperty): String? {
        val name = property.name ?: return null
        val initializer = property.initializer?.text ?: return null
        if (property.delegate != null) return null
        val keyword = if (property.isVar) "var" else "val"
        val type = property.typeReference?.text?.let { ": $it" }.orEmpty()
        return """$keyword $name$type = __fieldKotlinStateRestore("$name") { $initializer }"""
    }

    private fun stateSupport(stateKey: String): String {
        require(stateKey.matches(Regex("[A-Za-z0-9_]+"))) { "Invalid Kotlin/JS state key '$stateKey'" }
        return """
            private fun __fieldKotlinStateRoot(): dynamic =
                js("(globalThis.__fieldKotlinStates || (globalThis.__fieldKotlinStates = Object.create(null)))")

            private fun __fieldKotlinState(): dynamic {
                val root = __fieldKotlinStateRoot()
                var state = root["$stateKey"]
                if (state == null) {
                    state = js("Object.create(null)")
                    root["$stateKey"] = state
                }
                return state
            }

            private fun __fieldKotlinStateHas(name: String): Boolean {
                val state = __fieldKotlinState()
                return js("Object.prototype.hasOwnProperty.call(state, name)")
            }

            private fun __fieldKotlinStateGet(name: String): dynamic = __fieldKotlinState()[name]

            private fun __fieldKotlinStateSet(name: String, value: Any?) {
                __fieldKotlinState()[name] = value
            }

            private fun __fieldKotlinStateClear() {
                __fieldKotlinStateRoot()["$stateKey"] = js("Object.create(null)")
            }

            @Suppress("UNCHECKED_CAST")
            private inline fun <T> __fieldKotlinStateRestore(name: String, otherwise: () -> T): T =
                if (__fieldKotlinStateHas(name)) __fieldKotlinStateGet(name) as T else otherwise()
        """.trimIndent()
    }

internal fun ensureImplicitMain(source: String): String = prepareImplicitMain(source).text

fun compile

Compile kotlinSource to a single plain-module, DCE'd JS string.

The snippet is a normal Kotlin file: it may declare external values/functions to reach the head's runtime (THREE, A-Frame, Field browser JS), and should contain a fun main() — the plain module self-invokes main on eval.

@param cacheDir persistent incremental-compilation cache. When set, the stdlib IC cache is built once and reused across compiles (and the box cache is reused when moduleName is stable, i.e. re-editing the same box) — the main per-edit latency lever. Pass a stable, per-session directory; null disables caching.

    fun compile(
        kotlinSource: String,
        moduleName: String = "field_box",
        mode: Mode = defaultMode,
        profile: Profile = Profile.PLAIN_DCE,
        cacheDir: File? = null,
        wrapImplicitMain: Boolean = true,
    ): Result {
        val work = Files.createTempDirectory("field-ktjs").toFile()
        return try {
            val srcDir = File(work, "src").apply { mkdirs() }
            val prepared =
                if (wrapImplicitMain) prepareImplicitMain(kotlinSource)
                else PreparedSource.identity(kotlinSource)
            val src = File(srcDir, "$moduleName.kt").apply { writeText(prepared.text) }
            val klibDir = File(work, "klib").apply { mkdirs() }
            val jsDir = File(work, "js").apply { mkdirs() }
            val klib = File(klibDir, "$moduleName.klib")
            // per-module ES output is <name>.mjs; plain output is <name>.js
            val js = File(jsDir, if (profile == Profile.ESM_SHARED) "$moduleName.mjs" else "$moduleName.js")

            if (mode == Mode.IN_PROCESS) {
                try {
                    val result = compileInProcess(moduleName, src, klibDir, jsDir, klib, js, profile, cacheDir)
                    return result.remapDiagnostics(prepared)
                } catch (t: Throwable) {
                    lprintln("warning,remotejs", "in-process Kotlin->JS failed (${t.message}); falling back to subprocess")
                    klibDir.deleteRecursively(); klibDir.mkdirs()
                    jsDir.deleteRecursively(); jsDir.mkdirs()
                }
            }
            compileSubprocess(moduleName, src, klibDir, jsDir, klib, js, profile, cacheDir)
                .remapDiagnostics(prepared)
        } finally {
            work.deleteRecursively()
        }
    }

in-process

source — 55 lines, from line 446
    private fun compileInProcess(
        moduleName: String, src: File, klibDir: File, jsDir: File, klib: File, js: File,
        profile: Profile, cacheDir: File?,
    ): Result {
        val collector = CollectingMessageCollector()

        val phase1 = K2JSCompilerArguments().apply {
            libraries = stdlibJsKlib.absolutePath
            outputDir = klibDir.absolutePath
            this.moduleName = moduleName
            freeArgs = listOf(src.absolutePath)
        }
        val e1 = K2JSCompiler().exec(collector, Services.EMPTY, phase1)
        if (e1 != ExitCode.OK || !klib.exists()) {
            return Result(null, collector.diagnostics, false, Mode.IN_PROCESS, collector.log())
        }

        val phase2 = K2JSCompilerArguments().apply {
            irProduceJs = true
            includes = klib.absolutePath
            libraries = stdlibJsKlib.absolutePath
            outputDir = jsDir.absolutePath
            this.moduleName = moduleName
            irModuleName = moduleName
            // Kept so runtime errors and console.log in the head map back to the editor line.
            sourceMap = true
            if (cacheDir != null) {
                cacheDir.mkdirs()
                cacheDirectory = cacheDir.absolutePath
                irBuildCache = true
            }
            applyProfile(profile)
        }
        val e2 = K2JSCompiler().exec(collector, Services.EMPTY, phase2)
        if (e2 != ExitCode.OK || !js.exists()) {
            return Result(null, collector.diagnostics, false, Mode.IN_PROCESS, collector.log())
        }
        return Result(js.readText(), collector.diagnostics, true, Mode.IN_PROCESS, collector.log(), sourceMapOf(js))
    }

    private fun sourceMapOf(js: File): String? = File(js.path + ".map").takeIf { it.exists() }?.readText()

    private fun K2JSCompilerArguments.applyProfile(profile: Profile) {
        when (profile) {
            Profile.PLAIN_DCE -> {
                moduleKind = "plain"
                irDce = true            // whole-program tree-shake; ~600 KB -> ~5 KB
            }
            Profile.ESM_SHARED -> {
                moduleKind = "es"
                irPerModule = true      // emit a tiny box module that imports the shared stdlib
                irDce = false           // no per-compile DCE over the stdlib
            }
        }
    }

fun emitSharedStdlibMjs(@Suppress("UNUSED_PARAMETER") destDir: File): File

OPEN BLOCKER — producing the shared kotlin-kotlin-stdlib.mjs for ESM_SHARED.

ESM_SHARED box modules import { … } from './kotlin-kotlin-stdlib.mjs'. For "ship once" to work, that shared file must export the stdlib's COMPLETE public surface, so any box resolves against it. This is not something we can emit as a side effect of compiling a small program: Kotlin/JS per-module output prunes the stdlib module's exports to what the current compilation references (verified: a fun main(){} stub yields a 0-export stdlib; Field's own build yields a 369–502-alias, afield-usage-scoped one). Compiling the stdlib klib as its own main module (-Xinclude=<stdlib.klib>) throws in the backend — a full-surface stdlib is the stdlib's dedicated build path (-Xstdlib-compilation), not a flag we can flip here.

Candidate resolutions (see dev_docs/kotlin_to_js_spike.md): 1. Run the stdlib's own JS build once in ~/temp/kotlin to produce the canonical full-surface kotlin-kotlin-stdlib.mjs, vendor it next to the klib, and serve it statically. 2. Per-session "growing" stdlib: recompile the shared stdlib when a box imports a symbol it doesn't yet export (import-resolution miss → superset rebuild). 3. Fall back to PLAIN_DCE (self-contained, no shared stdlib) for boxes that miss.

Until one of those lands, this throws rather than emit a silently-incomplete stdlib.

    fun emitSharedStdlibMjs(@Suppress("UNUSED_PARAMETER") destDir: File): File =
        error(
            "Shared full-surface kotlin-kotlin-stdlib.mjs generation is not implemented — per-module " +
                "stdlib exports are usage-scoped. See KotlinToJsCompiler.emitSharedStdlibMjs docs / " +
                "dev_docs/kotlin_to_js_spike.md for the candidate resolutions.",
        )

    private class CollectingMessageCollector : MessageCollector {
        val diagnostics = mutableListOf<Diagnostic>()
        private val raw = StringBuilder()
        private var errors = false

        override fun clear() {}
        override fun hasErrors() = errors

        override fun report(
            severity: CompilerMessageSeverity,
            message: String,
            location: CompilerMessageSourceLocation?,
        ) {
            val sev = when {
                severity == CompilerMessageSeverity.ERROR || severity == CompilerMessageSeverity.EXCEPTION -> "error"
                severity == CompilerMessageSeverity.WARNING || severity == CompilerMessageSeverity.STRONG_WARNING -> "warning"
                else -> "info"
            }
            if (sev == "error") errors = true
            // Keep only real diagnostics; skip the compiler's chatty logging/output severities.
            if (sev != "info" || location != null) {
                diagnostics.add(Diagnostic(sev, message, location?.line ?: -1, location?.column ?: -1))
            }
            raw.append(sev).append(": ").append(message)
            if (location != null) raw.append(" (").append(location.line).append(':').append(location.column).append(')')
            raw.append('\n')
        }

        fun log(): String = raw.toString()
    }

subprocess (isolated fallback)

source — 53 lines, from line 563
    private fun compileSubprocess(
        moduleName: String, src: File, klibDir: File, jsDir: File, klib: File, js: File,
        profile: Profile, cacheDir: File?,
    ): Result {
        val classpath = System.getProperty("java.class.path")
        val javaBin = File(System.getProperty("java.home"), "bin/java").absolutePath

        val log1 = runCompiler(
            javaBin, classpath,
            "-Xir-produce-klib-file",
            "-libraries", stdlibJsKlib.absolutePath,
            "-ir-output-dir", klibDir.absolutePath,
            "-ir-output-name", moduleName,
            src.absolutePath,
        )
        if (!klib.exists()) return Result(null, parseDiagnostics(log1), false, Mode.SUBPROCESS, log1)

        val profileArgs = when (profile) {
            Profile.PLAIN_DCE -> arrayOf("-module-kind", "plain", "-Xir-dce")
            Profile.ESM_SHARED -> arrayOf("-module-kind", "es", "-Xir-per-module")
        }
        val cacheArgs =
            if (cacheDir != null) { cacheDir.mkdirs(); arrayOf("-Xcache-directory=${cacheDir.absolutePath}", "-Xir-build-cache") }
            else emptyArray()
        val log2 = runCompiler(
            javaBin, classpath,
            "-Xir-produce-js",
            "-Xinclude=${klib.absolutePath}",
            "-libraries", stdlibJsKlib.absolutePath,
            "-ir-output-dir", jsDir.absolutePath,
            "-ir-output-name", moduleName,
            "-Xir-module-name=$moduleName",
            "-source-map",
            *profileArgs,
            *cacheArgs,
        )
        val log = log1 + log2
        if (!js.exists()) return Result(null, parseDiagnostics(log), false, Mode.SUBPROCESS, log)
        return Result(js.readText(), parseDiagnostics(log), true, Mode.SUBPROCESS, log, sourceMapOf(js))
    }

    private fun runCompiler(javaBin: String, classpath: String, vararg compilerArgs: String): String {
        val cmd = buildList {
            add(javaBin); add("-cp"); add(classpath)
            add("org.jetbrains.kotlin.cli.js.K2JSCompiler")
            addAll(compilerArgs)
        }
        val proc = ProcessBuilder(cmd).redirectErrorStream(true).start()
        val out = proc.inputStream.bufferedReader().readText()
        proc.waitFor()
        if (proc.exitValue() != 0) lprintln("debug,remotejs", "K2JSCompiler exit=${proc.exitValue()}\n$out")
        return out
    }

Compiler stderr diagnostics look like: "path.kt:LINE:COL: error: message".

    private val diagnosticLine = Regex("""^.*?\.kt:(\d+):(\d+):\s+(error|warning|info):\s+(.*)$""")

    private fun parseDiagnostics(log: String): List<Diagnostic> =
        log.lineSequence().mapNotNull { line ->
            diagnosticLine.matchEntire(line.trim())?.let { m ->
                Diagnostic(m.groupValues[3], m.groupValues[4], m.groupValues[1].toInt(), m.groupValues[2].toInt())
            }
        }.toList()
}