Skip to content

Browser. Generate suspend extensions instead of @JsAsync (#2449, #2761) #2801

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jul 9, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package karakum.browser

import karakum.common.SuspendExtensionsCollector

internal class BrowserSuspendExtensionsCollector(
parentName: String,
parentTypeParameters: String?,
) : SuspendExtensionsCollector(parentName, parentTypeParameters) {

override fun getResult(): String {
val extensions = super.getResult()
return when {
parentName == "CustomElementRegistry"
-> extensions.replace(
"CustomElementRegistry.whenDefined(name: String): CustomElementConstructor",
"<T : HTMLElement> CustomElementRegistry.whenDefined(name: TagName<T>): CustomElementConstructor<T>"
)

else -> extensions
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ private fun eventPlaceholders(
strict: Boolean = false,
): List<ConversionResult> {
if (strict) {
val eventNames = Regex("""interface ([\w\d]+Event) extends """)
val eventNames = Regex("""interface (\w+Event) extends """)
.findAll(source)
.map { it.groupValues[1] }
.filter { it !in EXCLUDED }
Expand Down Expand Up @@ -166,10 +166,11 @@ private fun event(
.substringAfter("{\n")
.trimIndent()

val initExtensionsCollector = BrowserSuspendExtensionsCollector(name, null)
val members = if (membersSource.isNotEmpty()) {
membersSource
.splitToSequence(";\n")
.mapNotNull { convertMember(it, typeProvider) }
.mapNotNull { convertMember(it, typeProvider, initExtensionsCollector) }
.joinToString("\n")
} else ""

Expand All @@ -182,7 +183,7 @@ private fun event(
"external interface $declaration {",
members,
"}",
).joinToString("\n")
).joinToString("\n") + initExtensionsCollector.getResult()
} else ""

val eventSource = source
Expand All @@ -208,10 +209,11 @@ private fun event(

val typeProvider = TypeProvider(name)

val eventExtensionsCollector = BrowserSuspendExtensionsCollector(name, eventParent)
val eventMembers = eventSource.substringAfter(" {\n")
.trimIndent()
.splitToSequence(";\n")
.mapNotNull { convertMember(it, typeProvider) }
.mapNotNull { convertMember(it, typeProvider, eventExtensionsCollector) }
.joinToString("\n")
// Event
.replace("val type: String", " // val type: String")
Expand Down Expand Up @@ -265,10 +267,11 @@ private fun event(
val companionSource = eventClassBody
.substringAfter("\n", "")

val companionExtensionsCollector = BrowserSuspendExtensionsCollector(name, null)
val companionMembers = if (companionSource.isNotEmpty()) {
companionSource
.splitToSequence(";\n")
.mapNotNull { convertMember(it, typeProvider) }
.mapNotNull { convertMember(it, typeProvider, companionExtensionsCollector) }
.joinToString("\n")
} else null

Expand Down Expand Up @@ -303,7 +306,9 @@ private fun event(
$modifier external class $name$typeParameters $eventConstructor $eventParentDeclaration {
$body
}
""".trimIndent()
""".trimIndent() +
eventExtensionsCollector.getResult() +
companionExtensionsCollector.getResult()

eventBody = eventBody
.withComment(
Expand Down Expand Up @@ -356,7 +361,7 @@ private fun event(
private class EventDataMap(
content: String,
) {
private val map = Regex("""interface .+?EventMap \{\n "[\s\S]+?\n\}""")
private val map = Regex("""interface .+?EventMap \{\n {4}"[\s\S]+?\n\}""")
.findAll(content)
.flatMap { parseEvents(it.value) }
.filter { it.name != "orientationchange" }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ private val DEFAULT_IMPORTS = Imports(
"js.core.JsUInt53",
"js.core.UInt53",
"js.core.Void",
"js.core.JsPrimitives.toInt",
"js.core.JsPrimitives.toBoolean",
"js.date.Date",
"js.errors.JsError",
"js.errors.JsErrorName",
Expand All @@ -61,6 +63,8 @@ private val DEFAULT_IMPORTS = Imports(
"js.objects.unsafeJso",
"js.promise.Promise",
"js.promise.PromiseLike",
"js.promise.internal.awaitPromiseLike",
"js.promise.internal.awaitOptionalPromiseLike",
"js.reflect.JsClass",
"js.reflect.JsExternalInheritorsOnly",
"js.reflect.unsafeCast",
Expand Down Expand Up @@ -89,6 +93,9 @@ private val DEFAULT_IMPORTS = Imports(

"web.abort.AbortSignal",
"web.abort.Abortable",
"web.abort.AbortController",
"web.abort.internal.patchAbortOptions",
"web.abort.internal.awaitPromiseLike",
"web.animations.Animation",
"web.animations.DocumentTimeline",
"web.animations.Keyframe",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
package karakum.browser

import karakum.common.ExtensionsCollector
import karakum.common.TYPED_ARRAYS
import karakum.common.withSuspendAdapter
import karakum.common.withSuspendExtensions
import karakum.events.EventDataRegistry

internal const val VIDEO_FRAME_REQUEST_ID = "VideoFrameRequestId"
Expand Down Expand Up @@ -579,7 +580,7 @@ private val REPORTING_TYPES = listOf(
"ReportingObserverOptions",
)

private val XSLT_PROCESSOR = "XSLTProcessor"
private const val XSLT_PROCESSOR = "XSLTProcessor"

internal fun htmlDeclarations(
source: String,
Expand Down Expand Up @@ -1015,19 +1016,22 @@ internal fun convertInterface(
.substringAfter("<", "")
.substringBeforeLast(">", "")

var newTypeParameters: String? = null
if (typeParameters.isNotEmpty() && "<" !in typeParameters) {
val newTypeParameters = typeParameters
newTypeParameters = typeParameters
.splitToSequence(",")
.map { if (":" !in it) "$it : JsAny?" else it }
.joinToString(",")

declaration = declaration.replaceFirst("<$typeParameters>", "<$newTypeParameters>")
}

val extensionsCollector = BrowserSuspendExtensionsCollector(name, newTypeParameters)

var members = if (memberSource.isNotEmpty()) {
var result = memberSource
.splitToSequence(";\n")
.mapNotNull { convertMember(it, typeProvider) }
.mapNotNull { convertMember(it, typeProvider, extensionsCollector) }
.joinToString("\n")

result = when (name) {
Expand Down Expand Up @@ -1370,9 +1374,10 @@ internal fun convertInterface(
else -> "sealed"
}

val companionExtensionsCollector = BrowserSuspendExtensionsCollector("$name.Companion", null)
val idDeclaration = RenderingContextRegistry.getIdDeclaration(name)
val companion = if (staticSource != null) {
val companionContent = getCompanion(name, staticSource)
val companionContent = getCompanion(name, staticSource, companionExtensionsCollector)
when {
name == DOM_EXCEPTION -> "companion object" // leave it empty, add extensions below

Expand Down Expand Up @@ -1416,7 +1421,9 @@ internal fun convertInterface(
companion,
additionalAliases,
"}",
extensions
extensions,
extensionsCollector.getResult(),
companionExtensionsCollector.getResult()
).filter { it.isNotEmpty() }
.joinToString("\n")

Expand Down Expand Up @@ -1723,6 +1730,7 @@ private fun getConstructors(
private fun getCompanion(
name: String,
source: String,
extensionCollector: ExtensionsCollector,
): String {
val content = source
.substringAfterLast("\nnew(")
Expand All @@ -1733,7 +1741,7 @@ private fun getCompanion(
val typeProvider = TypeProvider(name)
val members = content
.splitToSequence(";\n")
.mapNotNull { convertMember(it, typeProvider) }
.mapNotNull { convertMember(it, typeProvider, extensionCollector) }
.joinToString("\n")
.trim()
.ifEmpty { return "" }
Expand Down Expand Up @@ -1761,6 +1769,8 @@ private fun convertConstructor(
internal fun convertMember(
source: String,
typeProvider: TypeProvider,
extensionsCollector: ExtensionsCollector,
outerComment: String? = null,
): String? {
if ("\n" in source) {
val comment = source.substringBeforeLast("\n")
Expand All @@ -1770,7 +1780,8 @@ internal fun convertMember(
if ("@deprecated" in comment)
return null

val member = convertMember(source.substringAfterLast("\n"), typeProvider)
val member = convertMember(source.substringAfterLast("\n"), typeProvider, extensionsCollector, comment)
.takeIf { it?.trim()?.isNotEmpty() == true }
?: return null

return comment + "\n" + member
Expand Down Expand Up @@ -1913,7 +1924,7 @@ internal fun convertMember(
val result = convertFunction(source, typeProvider)
?: return null

return withSuspendAdapter(result)
return withSuspendExtensions(result, outerComment, extensionsCollector)
.joinToString("\n\n")
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package karakum.common

internal val ASYNC_FUNCTION_REGEX = Regex(
"""^((operator)?\s*)(fun.*[ >])([a-zA-Z\d]+)(\(.*\)): Promise<(.+)>(\?)?( = definedExternally)?$""",
RegexOption.DOT_MATCHES_ALL
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
package karakum.common

private const val CONTROLLER = "controller"

private val ABORTABLE_TYPES = setOf(
"AddEventListenerOptions",
"CredentialCreationOptions",
"CredentialRequestOptions",
"StreamPipeOptions",
"SubscribeOptions",
"RequestInit",
"LockOptions",
)

interface ExtensionsCollector {
fun add(
functionName: String,
functionSignature: String,
parameters: String,
returnType: String,
optionalPromise: Boolean,
docs: String?,
)

fun getResult(): String
}

// Example: "name: String, age: Int" -> listOf("name", "age")
private fun parseParameterNames(parameters: String): List<String> {
require("(" in parameters) { parameters }
return parameters
.substringAfter("(")
.split(",")
.filter { ":" in it }
.map { it.substringBefore(":") }
}

// Example: "name: String, age: Int" -> "Int"
private fun parseLastParameterType(parameters: String): String? {
if (":" !in parameters) return null
return parameters
.substringAfterLast(":")
.substringBefore(")")
.trim()
}

// Example: "T : JsAny?, W : JsString" -> listOf("T", "W")
private fun parseTypeParametersNames(typeParameters: String): List<String> {
return typeParameters
.split(",")
.map { it.substringBefore(":").trim() }
}

internal open class SuspendExtensionsCollector(
val parentName: String,
val parentTypeParameters: String?,
) : ExtensionsCollector {

init {
require(parentTypeParameters?.startsWith("<")?.not() ?: true) {
"Type parameters should be extracted before: $parentTypeParameters"
}
}

private val extensions = mutableListOf<String>()

override fun getResult(): String {
return extensions.joinToString("\n\n")
}

override fun add(
functionName: String,
functionSignature: String,
parameters: String,
returnType: String,
optionalPromise: Boolean,
docs: String?,
) {
require(returnType.isEmpty() || returnType.startsWith(":")) {
"Return type should start with colon: $returnType in $parentName.$functionName"
}
require("<" !in functionSignature || parentTypeParameters == null) {
"Can't generate extension with parent type parameters and own generics."
}

val functionParameters = parseParameterNames(parameters)
val lastParameterType = parseLastParameterType(parameters)
val isAbortable = lastParameterType?.let { it in ABORTABLE_TYPES } == true

val callParameters = functionParameters.mapIndexed { index, param ->
if (index == functionParameters.lastIndex && isAbortable) {
"patchAbortOptions($param, $CONTROLLER)"
} else param
}.joinToString(", ")
val promiseCall = "${functionName}Async($callParameters)"

val resultCast = when (returnType) {
": Boolean" -> ".toBoolean()"
": String" -> ".toString()"
": Int" -> ".toInt()"
else -> ""
}
val returnKeyword = when {
(returnType.isEmpty() || returnType.contains(": Unit")) -> ""
else -> "return "
}

val body = when {
isAbortable -> """
val $CONTROLLER = AbortController()
${returnKeyword}awaitPromiseLike($promiseCall, $CONTROLLER)$resultCast
""".trimIndent()

optionalPromise -> "${returnKeyword}awaitOptionalPromiseLike($promiseCall)$resultCast"
else -> "${returnKeyword}awaitPromiseLike($promiseCall)$resultCast"
}
val comment = docs?.let { "$it\n" }.orEmpty()

val funTypeParameters = parentTypeParameters?.let { "<$it>" }.orEmpty()
val parentGenerics = parentTypeParameters
?.let { parseTypeParametersNames(it) }
?.joinToString(",")
?.let { "<$it>" }
.orEmpty()

var newParameters = parameters
functionParameters.filter { it.contains("callback", ignoreCase = true) }.forEach {
newParameters = newParameters.replace(it, "noinline $it")
}

val extension = """
${comment}suspend inline $functionSignature $funTypeParameters $parentName$parentGenerics.$functionName$newParameters$returnType {
$body
}
""".trimIndent()

extensions.add(extension)
}
}
Loading