Skip to content

Commit

Permalink
Add wasm mpp implementations
Browse files Browse the repository at this point in the history
  • Loading branch information
ajalt committed Apr 27, 2024
1 parent 3f0877a commit f8d4200
Show file tree
Hide file tree
Showing 3 changed files with 234 additions and 0 deletions.
12 changes: 12 additions & 0 deletions clikt/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
@file:Suppress("UNUSED_VARIABLE", "KotlinRedundantDiagnosticSuppress")

import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.targets.js.nodejs.NodeJsRootExtension


plugins {
Expand Down Expand Up @@ -47,3 +48,14 @@ kotlin {
}
}
}

// https://youtrack.jetbrains.com/issue/KT-63014
// https://github.com/Kotlin/kotlin-wasm-examples/blob/1b007347bf9f8a1ec3d420d30de1815768d5df02/nodejs-example/build.gradle.kts#L22
rootProject.the<NodeJsRootExtension>().apply {
nodeVersion = "22.0.0-nightly202404032241e8c5b3"
nodeDownloadBaseUrl = "https://nodejs.org/download/nightly"
}

tasks.withType<org.jetbrains.kotlin.gradle.targets.js.npm.tasks.KotlinNpmInstallTask>().configureEach {
args.add("--ignore-engines")
}
64 changes: 64 additions & 0 deletions clikt/src/wasmJsMain/kotlin/com/github/ajalt/clikt/mpp/MppImpl.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package com.github.ajalt.clikt.mpp


private external interface FsModule {
fun readFileSync(path: String, encoding: String): String
}

@Suppress("ClassName")
private external object process {
val platform: String
fun exit(status: Int)
}


@Suppress("RedundantNullableReturnType") // invalid diagnostic due to KTIJ-28239
private fun nodeReadEnvvar(@Suppress("UNUSED_PARAMETER") key: String): String? =
js("process.env[key]")

private interface JsMppImpls {
fun readEnvvar(key: String): String?
fun isWindowsMpp(): Boolean
fun exitProcessMpp(status: Int)
fun readFileIfExists(filename: String): String?
}

private object BrowserMppImpls : JsMppImpls {
override fun readEnvvar(key: String): String? = null
override fun isWindowsMpp(): Boolean = false
override fun exitProcessMpp(status: Int) {}
override fun readFileIfExists(filename: String): String? = null
}

private class NodeMppImpls(private val fs: FsModule) : JsMppImpls {
override fun readEnvvar(key: String): String? = nodeReadEnvvar(key)
override fun isWindowsMpp(): Boolean = process.platform == "win32"
override fun exitProcessMpp(status: Int): Unit = process.exit(status)
override fun readFileIfExists(filename: String): String? {
return try {
fs.readFileSync(filename, "utf-8")
} catch (e: Throwable) {
null
}
}
}

// See jsMain/MppImpl.kt for the details of node detection
private fun runningOnNode(): Boolean =
js("Object.prototype.toString.call(typeof process !== 'undefined' ? process : 0) === '[object process]'")


private fun importNodeFsModule(): FsModule =
js("""require("fs")""")

private val impls: JsMppImpls = when {
runningOnNode() -> NodeMppImpls(importNodeFsModule())
else -> BrowserMppImpls
}

internal actual val String.graphemeLengthMpp: Int get() = replace(ANSI_CODE_RE, "").length

internal actual fun readEnvvar(key: String): String? = impls.readEnvvar(key)
internal actual fun isWindowsMpp(): Boolean = impls.isWindowsMpp()
internal actual fun exitProcessMpp(status: Int): Unit = impls.exitProcessMpp(status)
internal actual fun readFileIfExists(filename: String): String? = impls.readFileIfExists(filename)
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
package com.github.ajalt.clikt.output

import com.github.ajalt.clikt.core.CliktError
import com.github.ajalt.clikt.mpp.readEnvvar

@JsName("Object")
external class JsObject : JsAny {
operator fun get(key: JsString): JsAny?
operator fun set(key: JsString, value: JsAny?)
}

// Can't use the wasm-interop definition because it only has get and set methods
@JsName("Array")
external class JsArray<T : JsAny?> : JsAny {
fun push(value: T)
}


private external object Buffer {
fun toString(encoding: String): String
}

private external interface FsStats {
val mtimeMs: Double
}

private external interface FsModule {
fun statSync(path: String): FsStats
fun writeFileSync(path: String, data: String)
fun readFileSync(path: String, encoding: String): String
fun unlinkSync(path: String)
}

private external interface CryptoModule {
fun randomBytes(size: Int): Buffer
}

private external interface ChildProcessModule {
fun execSync(command: String, options: JsObject): Int
fun spawnSync(command: String, args: JsArray<JsString>, options: JsAny?): JsObject
}

private fun importNodeFsModule(): FsModule =
js("""require("fs")""")

private fun importNodeCryptoModule(): CryptoModule =
js("""require("crypto")""")

private fun importNodeChildProcessModule(): ChildProcessModule =
js("""require("child_process")""")

internal actual fun createEditor(
editorPath: String?,
env: Map<String, String>,
requireSave: Boolean,
extension: String,
): Editor {
try {
val fs = importNodeFsModule()
val crypto = importNodeCryptoModule()
val childProcess = importNodeChildProcessModule()
return NodeJsEditor(fs, crypto, childProcess, editorPath, env, requireSave, extension)
} catch (e: Exception) {
throw IllegalStateException("Cannot edit files on this platform", e)
}
}

private class NodeJsEditor(
private val fs: FsModule,
private val crypto: CryptoModule,
private val childProcess: ChildProcessModule,
private val editorPath: String?,
private val env: Map<String, String>,
private val requireSave: Boolean,
private val extension: String,
) : Editor {
private fun getEditorPath(): String {
return editorPath ?: inferEditorPath { editor ->
val options = jsObject(
"timeout" to 100.toJsNumber(),
"windowsHide" to true.toJsBoolean(),
"stdio" to "ignore".toJsString(),
)
childProcess.execSync("${getWhichCommand()} $editor", options) == 0
}
}

private fun getEditorCommand(): Array<String> {
return getEditorPath().trim().split(" ").toTypedArray()
}

private fun editFileWithEditor(editorCmd: Array<String>, filename: String) {
val cmd = editorCmd[0]
val args = JsArray<JsString>()
(editorCmd.drop(1) + filename).forEach { args.push(it.toJsString()) }
val options = jsObject(
"stdio" to "inherit".toJsString(),
"env" to jsObject(*env.map { (k, v) -> k to v.toJsString() }.toTypedArray())
)
try {
val exitCode = childProcess.spawnSync(cmd, args, options)
if (exitCode["status".toJsString()]?.unsafeCast<JsNumber>()?.toInt() != 0) {
throw CliktError("$cmd: Editing failed!")
}
} catch (err: CliktError) {
throw err
} catch (err: Throwable) {
throw CliktError("Error staring editor")
}
}

override fun editFile(filename: String) {
editFileWithEditor(getEditorCommand(), filename)
}

private fun getTmpFileName(extension: String): String {
val rand = crypto.randomBytes(8).toString("hex")
val dir = readEnvvar("TMP") ?: "."
return "$dir/clikt_tmp_$rand.${extension.trimStart { it == '.' }}"
}

private fun getLastModified(path: String): Int {
return (fs.statSync(path).mtimeMs as Number).toInt()
}

override fun edit(text: String): String? {
val editorCmd = getEditorCommand()
val textToEdit = normalizeEditorText(editorCmd[0], text)
val tmpFilename = getTmpFileName(extension)
try {
fs.writeFileSync(tmpFilename, textToEdit)
try {
val lastModified = getLastModified(tmpFilename)
editFileWithEditor(editorCmd, tmpFilename)
if (requireSave && getLastModified(tmpFilename) == lastModified) {
return null
}
val readFileSync = fs.readFileSync(tmpFilename, "utf8")
return (readFileSync as String?)?.replace("\r\n", "\n")
} finally {
try {
fs.unlinkSync(tmpFilename)
} catch (ignored: Throwable) {
}
}
} catch (e: Throwable) {
throw CliktError("Error staring editing text: ${e.message}")
}
}
}

private fun jsObject(vararg pairs: Pair<String, JsAny?>): JsObject {
val obj = JsObject()
for ((k, v) in pairs) {
obj[k.toJsString()] = v
}
return obj
}

0 comments on commit f8d4200

Please sign in to comment.