Skip to content

KotlinJsBinding.kt

src/jvmMain/kotlin/afield/lang/remotejs/KotlinJsBinding.kt · 365 lines

package afield.lang.remotejs

import afield.geometry.path.FLine
import afield.geometry.path.div
import afield.geometry.path.minus
import afield.geometry.path.plus
import afield.geometry.path.times
import afield.ui.visualization.describe

One source of truth for a Kotlin/JS façade surface.

A KotlinJS head profile needs the same API described twice: once as compiler-facing external declarations (the JS target the box compiles against) and once as a JVM-analysis-safe mirror (what the out-of-process K2 Analysis-API completion sidecar analyzes — it has no dynamic, and external declarations behave differently there). Maintaining those two by hand drifts — e.g. a property that's var in one and val in the other, or a member completion offers that compilation rejects.

So describe the surface ONCE with jsBinding and let JsBindingModel.compilerDeclarations and JsBindingModel.completionDeclarations render both. The two rendered forms differ only mechanically:

aspect compiler (external) completion (plain, JVM-analysis)
declaration external object/class plain object/class
member fun body none = TODO()
property init none = <init> (default TODO())
ctor param default = definedExternally = <real default>
supertype : Base : Base()
dynamic dynamic Any?
callback type () -> Unit Function0<Unit>
operator/fun body real body = TODO()

A field that is both a constructor argument and a mutable property (THREE's Vector3(x,y,z) with mutable .x/.y/.z, or Mesh(geometry, material) with mutable .geometry/.material) is one TypeBuilder.field call — it emits a plain ctor param plus a var property in BOTH renderings, so the two can never disagree on mutability or on whether the property exists.

class JsType(val compiler: String, val completion: String = compiler)

A type reference that may render differently for the JS compiler vs the JVM completion session.

companion object

val DYNAMIC = JsType("dynamic", "Any?")

val CALLBACK = JsType("() -> Unit", "Function0<Unit>")

A no-arg callback — () -> Unit compiles for JS; the AA session prefers the Function0 form.

fun of(name: String) = JsType(name)

class JsParam

class JsParam(
    val name: String,
    val type: JsType,
    /** Completion/real default; the compiler uses `definedExternally` inside `external` declarations. */
    val default: String? = null,
    val vararg: Boolean = false,
)

class JsProp(val name: String, val type: JsType, val mutable: Boolean, val completionInit: String = "TODO()")

class JsProp(val name: String, val type: JsType, val mutable: Boolean, val completionInit: String = "TODO()")

class JsFun

class JsFun(
    val name: String,
    val params: List<JsParam>,
    val ret: JsType,
    /** Real body for top-level funs / operators; member funs of external classes carry none. */
    val compilerBody: String? = null,
    /** A top-level installed global (`external fun`, no body) rather than a wrapper with a body. */
    val external: Boolean = false,
    /** Member `operator fun` (e.g. a stage `get`/`set` indexer). */
    val operator: Boolean = false,
    /** Rendered into the compiler façade ONLY (never the completion mirror). Used for raw runtime
     *  members like FLine's `__PLUS__`/`__MINUS__` that a public operator delegates to but that
     *  should not surface as completion suggestions. */
    val compilerOnly: Boolean = false,
)

class JsTypeDecl

class JsTypeDecl(
    val name: String,
    val isObject: Boolean,
    val open: Boolean,
    val constructible: Boolean,
    val superType: String?,
    val ctor: List<JsParam>,
    val props: List<JsProp>,
    val funs: List<JsFun>,
    val nested: List<JsTypeDecl>,
    /** Emitted only into the completion mirror — lets a value be `dynamic` for the JS compiler
     *  (maximally permissive) yet a real type for completion (so its members are suggested). */
    val completionOnly: Boolean = false,
)

class JsTopVal

class JsTopVal(
    val name: String,
    val type: JsType,
    val mutable: Boolean,
    /** `external val` (compiler) vs a computed `get() = <compilerGetter>` when [compilerGetter] is set. */
    val compilerGetter: String? = null,
)

class JsExtFun

class JsExtFun(
    val receiver: String,
    val operator: Boolean,
    val name: String,
    val params: List<JsParam>,
    val ret: JsType,
    val compilerBody: String,
)

class JsBindingModel

class JsBindingModel(
    val types: List<JsTypeDecl>,
    val topVals: List<JsTopVal>,
    val topFuns: List<JsFun>,
    val extFuns: List<JsExtFun>,
    /** Compiler-only glue emitted verbatim (private `@JsName` externals the public funs delegate to). */
    val rawCompiler: String,
) {

val compilerDeclarations: String get() = JsBindingRenderer(external = true).render(this)

val completionDeclarations: String get() = JsBindingRenderer(external = false).render(this)

DSL builders

fun jsBinding(build: BindingBuilder.() -> Unit): JsBindingModel = BindingBuilder().apply(build).build()

fun jsBinding(build: BindingBuilder.() -> Unit): JsBindingModel = BindingBuilder().apply(build).build()

class BindingBuilder

class BindingBuilder {
    private val types = mutableListOf<JsTypeDecl>()
    private val topVals = mutableListOf<JsTopVal>()
    private val topFuns = mutableListOf<JsFun>()
    private val extFuns = mutableListOf<JsExtFun>()
    private var raw = ""

fun obj(name: String, build: TypeBuilder.() -> Unit) {

external object Name { … } — a namespace like THREE.

    fun obj(name: String, build: TypeBuilder.() -> Unit) {
        types += TypeBuilder(name, isObject = true).apply(build).build()
    }

fun cls(name: String, build: TypeBuilder.() -> Unit) {

A top-level external class Name { … } (e.g. FLine).

    fun cls(name: String, build: TypeBuilder.() -> Unit) {
        types += TypeBuilder(name, isObject = false).apply(build).build()
    }

fun completionCls(name: String, build: TypeBuilder.() -> Unit) {

A class emitted ONLY into the completion mirror — used to give a dynamic compiler value (e.g. the stage proxy) real, suggestable members for completion without constraining the permissive JS-target type.

    fun completionCls(name: String, build: TypeBuilder.() -> Unit) {
        types += TypeBuilder(name, isObject = false, completionOnly = true).apply(build).build()
    }

fun externalFun(name: String, ret: JsType = JsType.of("Unit"), build: ParamsBuilder.() -> Unit

An installed JS global function (external fun, no body).

    fun externalFun(name: String, ret: JsType = JsType.of("Unit"), build: ParamsBuilder.() -> Unit = {}) {
        topFuns += JsFun(name, ParamsBuilder().apply(build).params, ret, external = true)
    }

fun externalFun(name: String, ret: String, build: ParamsBuilder.() -> Unit

    fun externalFun(name: String, ret: String, build: ParamsBuilder.() -> Unit = {}) =
        externalFun(name, JsType.of(ret), build)

fun externalVal(name: String, type: JsType, mutable: Boolean = false)

    fun externalVal(name: String, type: JsType, mutable: Boolean = false) {
        topVals += JsTopVal(name, type, mutable)
    }

fun externalVal(name: String, type: String, mutable: Boolean = false)

    fun externalVal(name: String, type: String, mutable: Boolean = false) = externalVal(name, JsType.of(type), mutable)

fun computedVal(name: String, type: String, compiler: String)

A read-only alias resolved to compiler in the JS target (get() = compiler).

    fun computedVal(name: String, type: String, compiler: String) {
        topVals += JsTopVal(name, JsType.of(type), mutable = false, compilerGetter = compiler)
    }

fun function(name: String, ret: JsType = JsType.of("Unit"), compilerBody: String, build: ParamsBuilder.() -> Unit

    fun function(name: String, ret: JsType = JsType.of("Unit"), compilerBody: String, build: ParamsBuilder.() -> Unit = {}) {
        topFuns += JsFun(name, ParamsBuilder().apply(build).params, ret, compilerBody)
    }

fun operator(receiver: String, name: String, ret: String, compilerBody: String, build: ParamsBuilder.() -> Unit) {

    fun operator(receiver: String, name: String, ret: String, compilerBody: String, build: ParamsBuilder.() -> Unit) {
        extFuns += JsExtFun(receiver, operator = true, name, ParamsBuilder().apply(build).params, JsType.of(ret), compilerBody)
    }

fun rawCompiler(text: String) { raw = text.trimIndent() }

Compiler-only glue (private @JsName externals). Not part of the completion surface.

fun build() = JsBindingModel(types, topVals, topFuns, extFuns, raw)

class TypeBuilder

class TypeBuilder(
    private val name: String,
    private val isObject: Boolean = false,
    private val completionOnly: Boolean = false,
) {
    private var open = false
    private var constructible = true
    private var superType: String? = null
    private val ctor = mutableListOf<JsParam>()
    private val props = mutableListOf<JsProp>()
    private val funs = mutableListOf<JsFun>()
    private val nested = mutableListOf<JsTypeDecl>()

fun open() { open = true }

fun privateConstructor() { constructible = false }

Model a runtime-owned singleton/result type that user code may receive but cannot construct.

fun extends(base: String) { superType = base }

fun ctor(build: ParamsBuilder.() -> Unit) { ctor += ParamsBuilder().apply(build).params }

fun varProp(name: String, type: String, completionInit: String = "TODO()")

    fun varProp(name: String, type: String, completionInit: String = "TODO()") {
        props += JsProp(name, JsType.of(type), mutable = true, completionInit)
    }

fun varProp(name: String, type: JsType, completionInit: String = "TODO()")

Mutable property whose compiler/completion types differ (e.g. a dynamic ↔ typed value).

    fun varProp(name: String, type: JsType, completionInit: String = "TODO()") {
        props += JsProp(name, type, mutable = true, completionInit)
    }

fun valProp(name: String, type: String, completionInit: String = "TODO()")

    fun valProp(name: String, type: String, completionInit: String = "TODO()") {
        props += JsProp(name, JsType.of(type), mutable = false, completionInit)
    }

fun valProp(name: String, type: JsType, completionInit: String = "TODO()")

Property whose compiler/completion types differ (e.g. dynamic proxy ↔ typed indexer).

    fun valProp(name: String, type: JsType, completionInit: String = "TODO()") {
        props += JsProp(name, type, mutable = false, completionInit)
    }

fun field(name: String, type: String, default: String? = null)

A mutable field that is BOTH a ctor argument and a property — the single call keeps the two renderings in lockstep (plain ctor param + var property in each).

    fun field(name: String, type: String, default: String? = null) {
        ctor += JsParam(name, JsType.of(type), default)
        props += JsProp(name, JsType.of(type), mutable = true)
    }

fun fn(name: String, ret: String = "Unit", build: ParamsBuilder.() -> Unit

    fun fn(name: String, ret: String = "Unit", build: ParamsBuilder.() -> Unit = {}) {
        funs += JsFun(name, ParamsBuilder().apply(build).params, JsType.of(ret))
    }

fun fn(name: String, ret: JsType, build: ParamsBuilder.() -> Unit

Member fun whose return type differs across façades (e.g. dynamic ↔ Any?).

    fun fn(name: String, ret: JsType, build: ParamsBuilder.() -> Unit = {}) {
        funs += JsFun(name, ParamsBuilder().apply(build).params, ret)
    }

fun operatorFn(name: String, ret: JsType, build: ParamsBuilder.() -> Unit

A member operator fun (e.g. get/set indexers). Param/return types may be JsType.

    fun operatorFn(name: String, ret: JsType, build: ParamsBuilder.() -> Unit = {}) {
        funs += JsFun(name, ParamsBuilder().apply(build).params, ret, operator = true)
    }

fun compilerFn(name: String, ret: JsType, build: ParamsBuilder.() -> Unit

A member fun rendered into the COMPILER façade only — a raw runtime method (e.g. FLine's __PLUS__) that a public operator/extension delegates to, kept out of the completion surface.

    fun compilerFn(name: String, ret: JsType, build: ParamsBuilder.() -> Unit = {}) {
        funs += JsFun(name, ParamsBuilder().apply(build).params, ret, compilerOnly = true)
    }

fun compilerFn(name: String, ret: String, build: ParamsBuilder.() -> Unit

    fun compilerFn(name: String, ret: String, build: ParamsBuilder.() -> Unit = {}) =
        compilerFn(name, JsType.of(ret), build)

fun cls(name: String, build: TypeBuilder.() -> Unit

Nested class inside this object/class.

    fun cls(name: String, build: TypeBuilder.() -> Unit = {}) {
        nested += TypeBuilder(name).apply(build).build()
    }

fun build()

    fun build() = JsTypeDecl(name, isObject, open, constructible, superType, ctor, props, funs, nested, completionOnly)
}

class ParamsBuilder

val params = mutableListOf<JsParam>()

fun param(name: String, type: String, default: String? = null)

    fun param(name: String, type: String, default: String? = null) { params += JsParam(name, JsType.of(type), default) }

fun param(name: String, type: JsType, default: String? = null)

    fun param(name: String, type: JsType, default: String? = null) { params += JsParam(name, type, default) }

fun vararg(name: String, type: String)

    fun vararg(name: String, type: String) { params += JsParam(name, JsType.of(type), vararg = true) }
}

Renderer

source — 100 lines, from line 265
private class JsBindingRenderer(private val external: Boolean) {
    fun render(model: JsBindingModel): String {
        val out = StringBuilder()
        model.types
            .filterNot { external && it.completionOnly } // completion-only types never enter the JS target
            .forEach { out.append(renderType(it, top = true, indent = "")).append("\n\n") }
        model.topVals.forEach { out.append(renderTopVal(it)).append('\n') }
        if (model.topVals.isNotEmpty()) out.append('\n')
        model.topFuns.forEach { out.append(renderTopFun(it)).append('\n') }
        if (model.topFuns.isNotEmpty()) out.append('\n')
        model.extFuns.forEach { out.append(renderExtFun(it)).append('\n') }
        if (external && model.rawCompiler.isNotBlank()) out.append('\n').append(model.rawCompiler).append('\n')
        return out.toString().trimEnd() + "\n"
    }

    private fun typeName(t: JsType) = if (external) t.compiler else t.completion

    private fun renderParam(p: JsParam): String {
        val prefix = if (p.vararg) "vararg " else ""
        val def = p.default?.let { if (external) " = definedExternally" else " = $it" } ?: ""
        return "$prefix${p.name}: ${typeName(p.type)}$def"
    }

    private fun renderType(t: JsTypeDecl, top: Boolean, indent: String): String {
        val sb = StringBuilder()
        val kw = buildString {
            // `external` is only written at the top level; nested declarations inherit it.
            if (external && top) append("external ")
            if (t.open) append("open ")
            append(if (t.isObject) "object " else "class ")
            append(t.name)
        }
        sb.append(indent).append(kw)
        if (!t.isObject && !t.constructible) {
            sb.append(" private constructor()")
        } else if (!t.isObject && t.ctor.isNotEmpty()) {
            sb.append("(").append(t.ctor.joinToString(", ") { renderParam(it) }).append(")")
        }
        t.superType?.let { sb.append(" : ").append(it).append(if (external) "" else "()") }

        val bodyLines = buildList {
            t.props.forEach { add(renderProp(it)) }
            t.funs
                .filterNot { !external && it.compilerOnly } // compiler-only members never enter completion
                .forEach { add(renderMemberFun(it)) }
            t.nested.forEach { add(renderType(it, top = false, indent = "").trimEnd()) }
        }
        if (bodyLines.isEmpty()) return sb.toString()
        sb.append(" {\n")
        bodyLines.forEach { line ->
            line.split("\n").forEach { sb.append(indent).append("    ").append(it).append('\n') }
        }
        sb.append(indent).append("}")
        return sb.toString()
    }

    private fun renderProp(p: JsProp): String {
        val decl = "${if (p.mutable) "var" else "val"} ${p.name}: ${typeName(p.type)}"
        return if (external) decl else "$decl = ${p.completionInit}"
    }

    private fun renderMemberFun(f: JsFun): String {
        val op = if (f.operator) "operator " else ""
        val sig = "${op}fun ${f.name}(${f.params.joinToString(", ") { renderParam(it) }}): ${typeName(f.ret)}"
        return if (external) sig else "$sig = TODO()"
    }

    private fun renderTopVal(v: JsTopVal): String {
        val decl = "${if (v.mutable) "var" else "val"} ${v.name}: ${typeName(v.type)}"
        return when {
            !external -> "$decl = TODO()"
            v.compilerGetter != null -> "$decl get() = ${v.compilerGetter}"
            else -> "external $decl"
        }
    }

    private fun renderTopFun(f: JsFun): String {
        val params = f.params.joinToString(", ") { p ->
            // installed globals are `external fun` in the compiler → defaults are `definedExternally`.
            val def = p.default?.let { if (external && f.external) " = definedExternally" else " = $it" } ?: ""
            "${if (p.vararg) "vararg " else ""}${p.name}: ${typeName(p.type)}$def"
        }
        val sig = "fun ${f.name}($params): ${typeName(f.ret)}"
        return when {
            external && f.external -> "external $sig"                    // installed global, no body
            external -> "$sig = ${f.compilerBody ?: "TODO()"}"           // wrapper with a real body
            else -> "$sig = TODO()"                                       // completion mirror
        }
    }

    private fun renderExtFun(f: JsExtFun): String {
        val params = f.params.joinToString(", ") { p ->
            val def = p.default?.let { " = $it" } ?: ""
            "${p.name}: ${typeName(p.type)}$def"
        }
        val op = if (f.operator) "operator " else ""
        val sig = "${op}fun ${f.receiver}.${f.name}($params): ${typeName(f.ret)}"
        return "$sig = ${if (external) f.compilerBody else "TODO()"}"
    }
}