Skip to content

KotlinScriptEngine.kt

src/jvmMain/kotlin/afield/runtime/kotlin/KotlinScriptEngine.kt · 850 lines

package and imports — 47 lines, from line 1
package afield.runtime.kotlin

import afield.app.diagnostics.named
import afield.app.protocol.Completion
import afield.data.table.div
import afield.data.table.get
import afield.data.table.minus
import afield.data.table.rem
import afield.data.table.times
import afield.document.context.ContextResolution
import afield.document.graph.box
import afield.document.graph.intro
import afield.document.graph.makeKI
import afield.document.graph.preamble
import afield.runtime.completion.AaCompletionClient
import afield.runtime.lifecycle.Runloop
import afield.runtime.lifecycle.forever
import afield.runtime.lifecycle.loop
import afield.runtime.lifecycle.rem
import afield.runtime.lifecycle.then
import afield.ui.notation.with
import afield.ui.output.history
import afield.ui.visualization.table
import afield.ui.visualization.typed

import afield.app.server.currentFieldPreferences
import afield.audio.synth.*
import afield.data.table.plus
import afield.document.graph.Box
import afield.document.workspace.metadata
import afield.graphics.vulkan.*
import afield.media.stream.*
import afield.media.video.*
import afield.runtime.execution.plus
import afield.runtime.lifecycle.end
import afield.runtime.lifecycle.map
import afield.runtime.lifecycle.plus
import afield.ui.output.templates.filter
import afield.ui.visualization.ToHTML

import afield.app.diagnostics.logging.lprintln
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import org.jetbrains.kotlin.com.intellij.psi.PsiElement
import java.util.*
import kotlin.reflect.*
import kotlin.script.experimental.api.*

The common interface every box's Kotlin engine implements, and the shared pieces both engines use.

KotlinScriptEngine is what a box's code runs through: compiling, evaluating, completion and context tracking. createKotlinScriptEngine returns the active implementation, KotlinInterface2 (K2) or the older KotlinInterface (K1). The file also holds the types both engines return, such as CompletionResult, KotlinContext and KotlinDebugScriptTracker, and the compiler options they share.

internal const val fieldKotlinLanguageVersion = "2.5"

internal val fieldKotlinExperimentalCompilerOptions

At language 2.5 name-based destructuring's full form and [a, b] are the default and the short form stays off, so -Xname-based-destructuring=only-syntax would only add a "redundant" warning to every box.

internal val fieldKotlinExperimentalCompilerOptions = listOf(
    "-Xcollection-literals",
)

internal val fieldKotlinJvmCompilerOptions

internal val fieldKotlinJvmCompilerOptions = listOf("-jvm-target", "24") + fieldKotlinExperimentalCompilerOptions

interface KotlinScriptEngine

Common interface for Kotlin scripting engines used by Field4.

Two implementations exist: - KotlinInterface: Uses the legacy K1 compiler and KJvmReplCompilerWithIdeServices (well-tested, deprecated backend) - KotlinInterface2: Uses the K2 compiler and Analysis API (future-proof, targeting Kotlin 2.4+)

Use createKotlinScriptEngine to obtain the active implementation based on compile-time configuration.

interface KotlinScriptEngine {

val context: KotlinContext

The mutable Kotlin REPL context for this engine: tracked variables and functions, its generation counter, and debug-script history.

val implicitReceivers: List<KotlinType> get() = emptyList()

The types whose members this engine's scripts can name without a receiver — Box for every box (see makeKI). Completion needs this: at a caret with no dot, a bare frame is either a local or a member of one of these, and nothing else in the reply says so.

var associatedBox: Any?

var compilerConfig: ScriptCompilationConfiguration

var classLoader: ClassLoader?

var execNumber: Int

var sourceProperty: String

val compilationDispatcher: CoroutineDispatcher

Dispatcher for CPU-bound compilation and macro-expansion work. K1 returns its single KotlinInterface.compilationThread (K1 is not thread-safe). K2 returns Dispatchers.Default, with each K2 REPL state serializing its own compiles.

    val compilationDispatcher: CoroutineDispatcher
        get() = Dispatchers.Default

suspend fun exec

    suspend fun exec(
        s: String,
        onCompileError: (List<ScriptDiagnostic>) -> Unit = {},
        onEvalError: (List<ScriptDiagnostic>) -> Unit = {},
        onEvalException: (Throwable) -> Unit = { throw it },
        implicits: List<Any> = emptyList(),
    ): ResultValue?

fun execAndWait

    fun execAndWait(
        s: String,
        onCompileError: (List<ScriptDiagnostic>) -> Unit = {},
        onEvalError: (List<ScriptDiagnostic>) -> Unit = {},
        onEvalException: (Throwable) -> Unit = {},
        implicits: List<Any> = emptyList()
    ): ResultValue?

suspend fun bootstrap

Run the stdlib bootstrap (s, the intro imports) as this engine's first evaluation.

An engine that gates ordinary exec/evaluate on bootstrap completion (so no snippet evaluates before the intro that defines the K2 REPL ReplState) MUST run the bootstrap itself without that gate and release the gate afterwards — otherwise the bootstrap would wait on itself. Engines with no such gate can just exec it (the default).

    suspend fun bootstrap(
        s: String,
        implicits: List<Any> = emptyList(),
        onCompileError: (List<ScriptDiagnostic>) -> Unit = {},
        onEvalError: (List<ScriptDiagnostic>) -> Unit = {},
        onEvalException: (Throwable) -> Unit = { throw it },
    ): ResultValue? = exec(s, onCompileError, onEvalError, onEvalException, implicits)

fun armBootstrapGate() {}

Arm the engine's bootstrap-readiness gate (make it pending) so ordinary exec/evaluate block until bootstrap (or releaseBootstrapGate) opens it. Call this right after building an engine that will be bootstrapped asynchronously. Engines left un-armed (the default) never block — used directly in tests and ad-hoc snippet execution. No-op for engines without such a gate.

fun releaseBootstrapGate() {}

Open the engine's bootstrap-readiness gate unconditionally, in case bootstrap never ran (e.g. the box was tombstoned before it could) — otherwise a later exec/evaluate would wait forever. Idempotent; a no-op for engines without such a gate.

suspend fun complete(s: String, cursor: Int): CompletionResult

suspend fun warmCompletion() {}

Eagerly build whatever machinery complete needs (for K2 this is the K1 completion delegate — a full intro+preamble compile). Doing it up front, on the same main-runloop dispatcher complete uses, gets the one heavy compile out of the way outside any re-entrant runloop pump, so later completions are light and single-threaded-safe. Idempotent; a no-op for engines with no lazy completion bootstrap.

suspend fun warmCompletionBackend(timeoutMs: Long = 120_000): String = "unsupported"

Build the serving completion backend, not just the fallback.

warmCompletion warms the K1 delegate, which is the safety net; when the AA backend is selected the first requests are still served by K1, because the sidecar answers "session building (warming up; using K1 meanwhile)" until its Analysis-API session exists. A person sees that as one K1 completion and then AA — but a capture sees it as a picture of the fallback, which is why the docs harness calls this before a completion figure. Blocks until the serving backend answers or timeoutMs passes; returns a short status for the log. No-op ("unsupported") for engines with no second backend.

fun warmCompletionAsync() {}

Fire-and-forget proactive warm: schedule warmCompletion on the main-runloop dispatcher so the one heavy K1 bootstrap happens before the first completion/error-suggestion request rather than blocking it. Idempotent and self-de-duplicating (a no-op while a warm is already in flight, and cheap when already warm for the current preamble). Called on interactive code edits so the intelligent-error path is warm by run time. No-op for engines with no bootstrap.

fun completeAndWait(s: String, cursor: Int = s.length): CompletionResult

fun applyMacros(box: Box, s: String, skipMacros: Set<String> = emptySet(), previewOnly: Boolean = false): String

    fun applyMacros(box: Box, s: String, skipMacros: Set<String> = emptySet(), previewOnly: Boolean = false): String

fun parseAndRewrite(s: String, rules: List<(PsiElement, String) -> String>): String

fun findLastImport(s: String): Int

suspend fun compileSnippet

Compile s against this engine's current REPL state without evaluating it.

The returned PreparedSnippet can be evaluated repeatedly without recompiling s. implicits become its default implicit receivers; a non-empty list passed to PreparedSnippet.eval overrides them for that evaluation. Returns null after reporting compiler diagnostics through onCompileError.

    suspend fun compileSnippet(
        s: String,
        implicits: List<Any> = emptyList(),
        onCompileError: (List<ScriptDiagnostic>) -> Unit = {}
    ): PreparedSnippet?

fun compileSnippetAndWait

Blocking wrapper around compileSnippet.

    fun compileSnippetAndWait(
        s: String,
        implicits: List<Any> = emptyList(),
        onCompileError: (List<ScriptDiagnostic>) -> Unit = {}
    ): PreparedSnippet?

fun dispose() {}

Dispose any resources held by this engine. Not all implementations require disposal; default is a no-op.

val isDisposed: Boolean get() = false

Whether dispose has run on this engine. Lets a caller that replaced or retired an engine — Box.replaceKi, Box.disposeKi — be checked for having actually released the one it dropped, which is the leak those two exist to close. False for an implementation with nothing to dispose.

private fun sanitizeFieldSourceProperty(property: String): String {
    val sanitized = property.ifBlank { "code" }.map {
        if (it.isLetterOrDigit() || it == '_' || it == '-') it else '_'
    }.joinToString("")
    return sanitized.ifBlank { "code" }
}

internal fun fieldSourceName(filePrefix: String, property: String, number: Int): String

internal fun fieldSourceName(filePrefix: String, property: String, number: Int): String =
    "${filePrefix}__${sanitizeFieldSourceProperty(property)}_\$$number"

internal fun fieldSourcePropertyFromFileName(fileName: String?): String?

internal fun fieldSourcePropertyFromFileName(fileName: String?): String? {
    val name = fileName ?: return null
    val marker = "__"
    val at = name.indexOf(marker)
    if (at < 0) return null
    val rest = name.substring(at + marker.length)
    val end = rest.lastIndexOf("_$")
    return (if (end >= 0) rest.substring(0, end) else rest).ifBlank { null }
}

suspend fun <T> KotlinScriptEngine.withSourceProperty(property: String, block: suspend () -> T): T {

suspend fun <T> KotlinScriptEngine.withSourceProperty(property: String, block: suspend () -> T): T {
    val previous = sourceProperty
    sourceProperty = property.ifBlank { "code" }
    try {
        return block()
    } finally {
        sourceProperty = previous
    }
}

interface PreparedSnippet

Code compiled against one KotlinScriptEngine REPL state and reusable within that state.

Evaluation observes changes subsequently made to variables in the parent engine. With updateContext false, declarations and assignments made by the snippet are not committed to later REPL evaluations; use true when the snippet intentionally advances shared state.

The suspending eval rethrows runtime exceptions by default. The blocking evalAndWait instead has a no-op default exception callback, matching the other blocking engine helpers. In either form, a non-empty implicits list replaces the defaults captured at compile time.

suspend fun eval

    suspend fun eval(
        updateContext: Boolean = false,
        implicits: List<Any> = emptyList(),
        onEvalError: (List<ScriptDiagnostic>) -> Unit = {},
        onEvalException: (Throwable) -> Unit = { throw it }
    ): ResultValue?

fun evalAndWait

    fun evalAndWait(
        updateContext: Boolean = false,
        implicits: List<Any> = emptyList(),
        onEvalError: (List<ScriptDiagnostic>) -> Unit = {},
        onEvalException: (Throwable) -> Unit = {}
    ): ResultValue?
}

internal fun translateKotlinEvalException(throwable: Throwable, source: String? = null): Throwable

internal fun translateKotlinEvalException(throwable: Throwable, source: String? = null): Throwable {
    val isMissingReplState = throwable.causeChain().any {
        val message = it.message.orEmpty().replace('/', '.')
        (it is NoClassDefFoundError || it is ClassNotFoundException) &&
            (message == "ReplState" || message.endsWith(".ReplState"))
    }
    if (!isMissingReplState) return throwable

    val packageDeclaration = source
        ?.lineSequence()
        ?.map { it.trim() }
        ?.firstOrNull { it.startsWith("package ") }

    val packageMessage = packageDeclaration?.let {
        " Remove `$it`; use `import ...` lines instead."
    } ?: " This usually means the snippet contains a `package ...` declaration; use `import ...` lines instead."

    return IllegalArgumentException(
        "Field code properties are evaluated as Kotlin REPL snippets, not standalone Kotlin source files." +
            packageMessage,
        throwable
    )
}

private fun Throwable.causeChain(): Sequence<Throwable> = sequence {
    val seen = Collections.newSetFromMap(IdentityHashMap<Throwable, Boolean>())
    var current: Throwable? = this@causeChain
    while (current != null && seen.add(current)) {
        yield(current)
        current = current.cause
    }
}

Shared types previously duplicated in KotlinInterface and KotlinInterface2

annotation class exported

Annotation to mark functions/values that should be automatically exported to the box's preamble. When a declaration is annotated with @exported, its fully-typed signature will be added to the box's preamble after execution, making it available as a declared property.

@Target(
    AnnotationTarget.FUNCTION,
    AnnotationTarget.PROPERTY,
    AnnotationTarget.FIELD,
    AnnotationTarget.PROPERTY_GETTER
)
@Retention(AnnotationRetention.RUNTIME)
annotation class exported

annotation class rootExported

@Target(
    AnnotationTarget.FUNCTION,
    AnnotationTarget.PROPERTY,
    AnnotationTarget.FIELD,
    AnnotationTarget.PROPERTY_GETTER
)
@Retention(AnnotationRetention.RUNTIME)
annotation class rootExported

annotation class metarootExported

@Target(
    AnnotationTarget.FUNCTION,
    AnnotationTarget.PROPERTY,
    AnnotationTarget.FIELD,
    AnnotationTarget.PROPERTY_GETTER
)
@Retention(AnnotationRetention.RUNTIME)
annotation class metarootExported

enum class CompletionStatus(private val value: String)

enum class CompletionStatus(private val value: String) {
    OK("ok"), ERROR("error");

override fun toString(): String = value

abstract class CompletionResult(private val status: CompletionStatus)

abstract class CompletionResult(private val status: CompletionStatus) {

open class Success

    open class Success(
        val matches: List<String>,
        val bounds: CompletionTokenBounds,
        val metadata: List<SourceCodeCompletionVariant>,
        val text: String,
        val cursor: Int
    ) : CompletionResult(CompletionStatus.OK) {

init

        init {
            assert(matches.size == metadata.size)
        }
    }

class Empty(text: String, cursor: Int) :

    class Empty(text: String, cursor: Int) :
        Success(emptyList(), CompletionTokenBounds(cursor, cursor), emptyList(), text, cursor)

class Error(val errorName: String, val errorValue: String, val traceBack: String) :

    class Error(val errorName: String, val errorValue: String, val traceBack: String) :
        CompletionResult(CompletionStatus.ERROR)
}

data class CompletionTokenBounds(val start: Int, val end: Int)

class KotlinContext

Mutable bookkeeping for one Kotlin scripting context.

vars and functions are lookup tables populated from declarations introduced or exported during script execution; generation changes when the visible context is updated. vars is a LinkedHashMap (declaration order), so a context walk that meets two same-typed variables in one box picks the same one every time — see afield.document.context.ContextResolution.

exportedVariables names the entries of vars that are @exported values — this box's own, or one exported into it by @rootExported / @metarootExported. InheritanceFromContext reads only those from the asking box's own table, so an ordinary leftover variable cannot shadow an inherited value.

class KotlinContext(
    val vars: HashMap<String, KotlinVariableInfo> = LinkedHashMap(),
    val functions: HashMap<String, KotlinFunctionInfo> = HashMap(),
    var generation: Int = 0,
    val debugScripts: KotlinDebugScriptTracker = KotlinDebugScriptTracker(),
    val exportedVariables: MutableSet<String> = java.util.concurrent.ConcurrentHashMap.newKeySet(),
)

data class KotlinDebugScriptSnapshot

data class KotlinDebugScriptSnapshot(
    val sourceName: String,
    val sourceText: String,
    val normalizedSourceText: String = normalizeDebugSourceText(sourceText),
    val sourceHash: Int = normalizedSourceText.hashCode(),
    var className: String? = null,
    var updatedAtMs: Long = System.currentTimeMillis()
)

class KotlinDebugScriptTracker(private val maxEntries: Int = 64)

class KotlinDebugScriptTracker(private val maxEntries: Int = 64) {
    private val snapshots = ArrayDeque<KotlinDebugScriptSnapshot>()

fun recordCompile(sourceName: String, sourceText: String): KotlinDebugScriptSnapshot

    @Synchronized
    fun recordCompile(sourceName: String, sourceText: String): KotlinDebugScriptSnapshot {
        val snapshot = KotlinDebugScriptSnapshot(
            sourceName = sourceName,
            sourceText = sourceText
        )
        snapshots.addLast(snapshot)
        trim()
        return snapshot
    }

fun discard(snapshot: KotlinDebugScriptSnapshot)

    @Synchronized
    fun discard(snapshot: KotlinDebugScriptSnapshot) {
        snapshots.remove(snapshot)
    }

fun attachLatestRuntimeClass(className: String?)

    @Synchronized
    fun attachLatestRuntimeClass(className: String?) {
        if (className.isNullOrBlank()) return
        snapshots.lastOrNull()?.let {
            it.className = className
            it.updatedAtMs = System.currentTimeMillis()
        }
    }

fun latestMatchingSource(sourceText: String): KotlinDebugScriptSnapshot?

    @Synchronized
    fun latestMatchingSource(sourceText: String): KotlinDebugScriptSnapshot? {
        val normalized = normalizeDebugSourceText(sourceText)
        val hash = normalized.hashCode()
        return snapshots.lastOrNull { it.sourceHash == hash && it.normalizedSourceText == normalized }
    }

fun latest(): KotlinDebugScriptSnapshot?

    @Synchronized
    fun latest(): KotlinDebugScriptSnapshot? = snapshots.lastOrNull()

fun all(): List<KotlinDebugScriptSnapshot>

    @Synchronized
    fun all(): List<KotlinDebugScriptSnapshot> = snapshots.toList()

    private fun trim() {
        while (snapshots.size > maxEntries) {
            snapshots.removeFirst()
        }
    }
}

private fun normalizeDebugSourceText(sourceText: String): String =
    sourceText
        .replace("\r\n", "\n")
        .replace('\r', '\n')
        .trimEnd()

class KotlinFunctionInfo(val function: KFunction<*>, val line: Any) : Comparable<KotlinFunctionInfo>, ToHTML

class KotlinFunctionInfo(val function: KFunction<*>, val line: Any) : Comparable<KotlinFunctionInfo>, ToHTML {

val name: String

    val name: String
        get() = function.name

    private fun parameterLabel(parameter: KParameter): String {
        val typeText = shortenType(parameter.type.toString())
        val base = when (parameter.kind) {
            KParameter.Kind.EXTENSION_RECEIVER -> "<receiver>"
            KParameter.Kind.INSTANCE -> "<instance>"
            else -> parameter.name ?: "<arg>"
        }
        val flags = buildList {
            if (parameter.isVararg) add("vararg")
            if (parameter.isOptional) add("optional")
        }
        return if (flags.isEmpty()) "$base: $typeText" else "$base: $typeText [${flags.joinToString(", ")}]"
    }

override fun toHTML(): String

body — 52 lines, from line 493
    override fun toHTML(): String {
        val signature = shortenType(functionSignature(function))
        val returnType = shortenType(function.returnType.toString())
        val scriptClass = kotlinInfoClip(shortenType(line.javaClass.name))
        val valueParams = function.parameters.filter { it.kind == KParameter.Kind.VALUE }
        val extensionReceiver = function.parameters.firstOrNull { it.kind == KParameter.Kind.EXTENSION_RECEIVER }
        val instanceReceiver = function.parameters.firstOrNull { it.kind == KParameter.Kind.INSTANCE }

        return buildString {
            append("<div style='display:inline-block; max-width: 620px;'>")
            append("<div><span style='color:#f6c;'>fun</span> <span style='color:#fff;font-style:italic;'>")
            append(kotlinInfoEscapeHtml(name))
            append("</span></div>")

            append("<div style='color:#aaa; overflow-wrap:anywhere;'>")
            append(kotlinInfoEscapeHtml(signature))
            append("</div>")

            append("<div style='color:#999;'>returns <span style='color:#8cf;'>")
            append(kotlinInfoEscapeHtml(returnType))
            append("</span></div>")

            extensionReceiver?.let {
                append("<div style='color:#999;'>extension receiver <span style='color:#8cf;'>")
                append(kotlinInfoEscapeHtml(shortenType(it.type.toString())))
                append("</span></div>")
            }
            instanceReceiver?.let {
                append("<div style='color:#999;'>instance receiver <span style='color:#8cf;'>")
                append(kotlinInfoEscapeHtml(shortenType(it.type.toString())))
                append("</span></div>")
            }

            if (valueParams.isNotEmpty()) {
                append("<div style='color:#999;'>params (")
                append(valueParams.size)
                append(")</div>")
                valueParams.forEach { parameter ->
                    append("<div style='margin-left:12px; color:#bbb; overflow-wrap:anywhere;'>• ")
                    append(kotlinInfoEscapeHtml(parameterLabel(parameter)))
                    append("</div>")
                }
            } else {
                append("<div style='color:#777;'>params (0)</div>")
            }

            append("<div style='color:#777; overflow-wrap:anywhere;'>defined in ")
            append(kotlinInfoEscapeHtml(scriptClass))
            append("</div>")
            append("</div>")
        }
    }

fun toString(shortenTypes: Boolean): String

    fun toString(shortenTypes: Boolean): String {
        return if (shortenTypes) {
            shortenType(toString())
        } else toString()
    }

override fun toString(): String

    override fun toString(): String {
        return functionSignature(function)
    }

override fun compareTo(other: KotlinFunctionInfo): Int

    override fun compareTo(other: KotlinFunctionInfo): Int {
        return this.toString().compareTo(other.toString())
    }

override fun hashCode(): Int

    override fun hashCode(): Int {
        return this.toString().hashCode()
    }

override fun equals(other: Any?): Boolean

    override fun equals(other: Any?): Boolean {
        return if (other is KotlinFunctionInfo) {
            this.toString() == other.toString()
        } else false
    }
}

class KotlinVariableInfo(val value: Any?, val descriptor: KProperty<*>, val line: Any) : ToHTML

class KotlinVariableInfo(val value: Any?, val descriptor: KProperty<*>, val line: Any) : ToHTML {

val name: String

    val name: String
        get() = descriptor.name

val type: String

    @Suppress("MemberVisibilityCanBePrivate")
    val type: String
        get() = descriptor.toString()

fun toString(shortenTypes: Boolean): String

    fun toString(shortenTypes: Boolean): String {
        var type: String = type
        if (shortenTypes) {
            type = shortenType(type)
        }
        return "$name: $type = $value"
    }

override fun toString(): String

    override fun toString(): String {
        return toString(false)
    }

    private fun valuePreview(): String {
        return when (value) {
            null -> "<span style='color:#888;'>null</span>"
            is Number -> "<span style='color:#8cf;'>${kotlinInfoEscapeHtml(value.toString())}</span>"
            is Boolean -> "<span style='color:#f88;'>${kotlinInfoEscapeHtml(value.toString())}</span>"
            is Char -> "<span style='color:#8f8;'>'${kotlinInfoEscapeHtml(value.toString())}'</span>"
            is String -> "<span style='color:#8f8;'>${kotlinInfoEscapeHtml(kotlinInfoClip(value))}</span>"
            else -> {
                val runtime = shortenType(kotlinInfoTypeName(value))
                val text = kotlinInfoClip(value.toString())
                "<span style='color:#bbb;'>${kotlinInfoEscapeHtml(runtime)}</span> <span style='color:#777;'>= ${kotlinInfoEscapeHtml(text)}</span>"
            }
        }
    }

override fun toHTML(): String

    override fun toHTML(): String {
        val declaration = shortenType(descriptor.toString())
        val declaredType = runCatching { shortenType(descriptor.returnType.toString()) }.getOrDefault(shortenType(type))
        val runtimeType = shortenType(kotlinInfoTypeName(value))
        val scriptClass = kotlinInfoClip(shortenType(line.javaClass.name))
        val kind = if (descriptor is KMutableProperty<*>) "var" else "val"

        return buildString {
            append("<div style='display:inline-block; max-width: 620px;'>")
            append("<div><span style='color:#f6c;'>")
            append(kind)
            append("</span> <span style='color:#fff;font-style:italic;'>")
            append(kotlinInfoEscapeHtml(name))
            append("</span></div>")

            append("<div style='color:#aaa; overflow-wrap:anywhere;'>")
            append(kotlinInfoEscapeHtml(declaration))
            append("</div>")

            append("<div style='color:#999;'>declared type <span style='color:#8cf;'>")
            append(kotlinInfoEscapeHtml(declaredType))
            append("</span></div>")

            append("<div style='color:#999;'>runtime type <span style='color:#8cf;'>")
            append(kotlinInfoEscapeHtml(runtimeType))
            append("</div>")

            append("<div style='margin-top:2px; color:#999;'>value ")
            append(valuePreview())
            append("</div>")

            append("<div style='color:#777; overflow-wrap:anywhere;'>defined in ")
            append(kotlinInfoEscapeHtml(scriptClass))
            append("</div>")
            append("</div>")
        }
    }
}

Shared utility functions

fun functionSignature(function: KFunction<*>): String

fun functionSignature(function: KFunction<*>): String =
    function.toString().replace("Line_\\d+\\.".toRegex(), "")

fun shortenType(name: String): String

fun shortenType(name: String): String =
    name.replace("(\\b[_a-zA-Z$][_a-zA-Z0-9$]*\\b\\.)+".toRegex(), "")

fun getTokenBounds(buf: String, cursor: Int): CompletionTokenBounds

fun getTokenBounds(buf: String, cursor: Int): CompletionTokenBounds {
    require(cursor <= buf.length) { "Position $cursor does not exist in code snippet <$buf>" }
    val startSubstring = buf.substring(0, cursor)
    val filter = { c: Char -> !c.isLetterOrDigit() && c != '_' }
    val start = startSubstring.indexOfLast(filter) + 1
    return CompletionTokenBounds(start, cursor)
}

val kotlinCompletionBlacklistPatterns: List<String>

val kotlinCompletionBlacklistPatterns: List<String>
    get() = currentFieldPreferences().importClassBlacklist

fun kotlinCompletionBlacklistPatternMatches(pattern: String, candidate: String): Boolean

fun kotlinCompletionBlacklistPatternMatches(pattern: String, candidate: String): Boolean {
    val normalizedPattern = pattern.trim().lowercase(Locale.ROOT)
    val normalizedCandidate = candidate.trim().lowercase(Locale.ROOT)
    if (normalizedPattern.isBlank() || normalizedCandidate.isBlank()) return false
    if ('*' !in normalizedPattern) return normalizedCandidate == normalizedPattern

    val parts = normalizedPattern.split('*')
    var offset = 0
    val first = parts.first()
    if (first.isNotEmpty()) {
        if (!normalizedCandidate.startsWith(first)) return false
        offset = first.length
    }
    for (part in parts.drop(1).dropLast(1)) {
        if (part.isEmpty()) continue
        val next = normalizedCandidate.indexOf(part, offset)
        if (next < 0) return false
        offset = next + part.length
    }
    val last = parts.last()
    if (last.isEmpty()) return true
    val next = normalizedCandidate.indexOf(last, offset)
    return next >= 0 && normalizedCandidate.endsWith(last)
}

fun isBlacklistedKotlinCompletionName

fun isBlacklistedKotlinCompletionName(
    name: String,
    blacklistPatterns: List<String> = kotlinCompletionBlacklistPatterns
): Boolean {
    val normalized = name.trim()
        .removePrefix("import ")
        .replace('$', '.')
        .lowercase(Locale.ROOT)
    if (normalized.isBlank()) return false

    return normalized.split(Regex("\\s+")).any { token ->
        val candidate = token.trim('`', '"', '\'', '(', ')', '[', ']', '{', '}', ',', ';', ':')
        blacklistPatterns.any { pattern ->
            kotlinCompletionBlacklistPatternMatches(pattern, candidate)
        }
    }
}

fun isBlacklistedKotlinCompletionCandidate

fun isBlacklistedKotlinCompletionCandidate(
    variant: SourceCodeCompletionVariant,
    blacklistPatterns: List<String> = kotlinCompletionBlacklistPatterns
): Boolean {
    return sequenceOf(
        variant.text,
        variant.displayText,
        variant.tail
    ).plus(
        variant.extraInfo.values.asSequence().mapNotNull { it as? String }
    ).any { isBlacklistedKotlinCompletionName(it, blacklistPatterns) }
}

fun getCompletionResult

fun getCompletionResult(
    code: String, cursor: Int, completions: List<SourceCodeCompletionVariant>
): CompletionResult.Success {
    val blacklistPatterns = kotlinCompletionBlacklistPatterns
    val filteredCompletions = completions.filterNot { isBlacklistedKotlinCompletionCandidate(it, blacklistPatterns) }
        .filter { !it.text.contains("$", ignoreCase = true) }
    val bounds = getTokenBounds(code, cursor)
    return CompletionResult.Success(
        filteredCompletions.map { it.text },
        bounds,
        filteredCompletions,
        code,
        cursor
    )
}

Internal HTML helpers shared by both implementations

internal fun kotlinInfoEscapeHtml(text: String): String

internal fun kotlinInfoEscapeHtml(text: String): String {
    return text.replace("&", "&amp;")
        .replace("<", "&lt;")
        .replace(">", "&gt;")
        .replace("\"", "&quot;")
        .replace("'", "&#39;")
}

internal fun kotlinInfoClip(text: String, maxChars: Int = 220): String

internal fun kotlinInfoClip(text: String, maxChars: Int = 220): String {
    if (text.length <= maxChars) return text
    return text.take(maxChars - 1) + "…"
}

internal fun kotlinInfoTypeName(value: Any?): String

internal fun kotlinInfoTypeName(value: Any?): String {
    if (value == null) return "null"
    return value::class.qualifiedName ?: value::class.simpleName ?: value::class.java.name
}

Factory / compile-time switching

object KotlinEngineConfig

Controls which Kotlin scripting backend to use.

Set via the system property afield.kotlin.engine: - k1: Legacy KJvmReplCompilerWithIdeServices — well-tested, deprecated compiler - k2 (default): K2 compiler + Analysis API — future-proof, targeting Kotlin 2.4+

Can also be set programmatically before any KotlinScriptEngine is created.

object KotlinEngineConfig {

var rerunCompileCache: Boolean

Re-evaluate, rather than recompile, an exact rerun of a snippet the engine already compiled (RerunCompileCache). On by default; -Dafield.kotlin.rerunCompileCache=false turns it off.

    @Volatile
    var rerunCompileCache: Boolean = System.getProperty("afield.kotlin.rerunCompileCache", "true").toBoolean()

enum class Backend { K1, K2 }

var activeBackend: Backend

    var activeBackend: Backend = when (System.getProperty("afield.kotlin.engine", "k2").lowercase()) {
        "k2" -> Backend.K2
        else -> Backend.K1
    }

enum class CompletionBackend { K1, AA }

Completion candidate-generation backend, independent of the execution activeBackend. AA is the out-of-process K2 Analysis-API sidecar (AaCompletionClient → /complete) and is the DEFAULT (Stage D cutover): it matches-or-beats K1 recall, passes all anti-goals, and adds named-arg completion. It only engages when a sidecar URL is configured (afield.completion.sidecarUrl / AFIELD_CODE_USE_SEMANTIC_SIDECAR_URL); otherwise, and on any timeout / error / empty result, it falls through to the compiler-grade K1 delegate, so AA is never worse than K1. Force the legacy path with -Dafield.kotlin.completion=k1.

var activeCompletionBackend: CompletionBackend

    var activeCompletionBackend: CompletionBackend = when (System.getProperty("afield.kotlin.completion", "aa").lowercase()) {
        "k1" -> CompletionBackend.K1
        else -> CompletionBackend.AA
    }

var offloadCompilation: Boolean

When true (default), compilation runs on a background dispatcher so the Runloop frame loop is not blocked while the Kotlin compiler works.

K2 dispatches to kotlinx.coroutines.Dispatchers.Default, but serializes compiles per REPL state. K1 dispatches to its single KotlinInterface.compilationThread (K1 is not thread-safe).

Set to false to force compilation on the calling dispatcher (legacy behaviour).

    var offloadCompilation: Boolean = System.getProperty("afield.kotlin.offloadCompilation", "true").toBoolean()

var logExpandedMacros: Boolean

When true (the default), KotlinScriptEngine.applyMacros prints the post-macro-expansion source — the code actually handed to the Kotlin compiler — to the terminal whenever macro expansion changed the code (execute path only, never editor previews). Invaluable for debugging rewrite {}/unquote {}/builtin-macro output. Disable with -Dafield.macros.logExpanded=false.

    var logExpandedMacros: Boolean = System.getProperty("afield.macros.logExpanded", "true").toBoolean()
}

fun createKotlinScriptEngine

Creates a KotlinScriptEngine using the currently configured backend.

@see KotlinEngineConfig.activeBackend

fun createKotlinScriptEngine(
    propertyClasses: Map<String, KClass<*>> = emptyMap(),
    properties: Map<String, Any?> = emptyMap(),
    implicitReceivers: List<KotlinType> = emptyList(),
    classLoader: ClassLoader? = null,
    filePrefix: String = "EVAL_"
): KotlinScriptEngine {
    val backend = KotlinEngineConfig.activeBackend
    lprintln(
        "debug,ki,startup",
        "create KotlinScriptEngine backend=$backend filePrefix=$filePrefix implicitReceivers=${implicitReceivers.size} properties=${properties.keys}"
    )
    return when (backend) {
        KotlinEngineConfig.Backend.K1 -> KotlinInterface(propertyClasses, properties, implicitReceivers, classLoader, filePrefix)
        KotlinEngineConfig.Backend.K2 -> KotlinInterface2(propertyClasses, properties, implicitReceivers, classLoader, filePrefix)
    }
}