From f8d4200b79b71351fc373c2e222215445b031eff Mon Sep 17 00:00:00 2001 From: AJ Date: Sat, 27 Apr 2024 11:15:54 -0700 Subject: [PATCH] Add wasm mpp implementations --- clikt/build.gradle.kts | 12 ++ .../com/github/ajalt/clikt/mpp/MppImpl.kt | 64 +++++++ .../github/ajalt/clikt/output/EditorWasm.kt | 158 ++++++++++++++++++ 3 files changed, 234 insertions(+) create mode 100644 clikt/src/wasmJsMain/kotlin/com/github/ajalt/clikt/mpp/MppImpl.kt create mode 100644 clikt/src/wasmJsMain/kotlin/com/github/ajalt/clikt/output/EditorWasm.kt diff --git a/clikt/build.gradle.kts b/clikt/build.gradle.kts index 8779fa4e..f935cc56 100644 --- a/clikt/build.gradle.kts +++ b/clikt/build.gradle.kts @@ -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 { @@ -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().apply { + nodeVersion = "22.0.0-nightly202404032241e8c5b3" + nodeDownloadBaseUrl = "https://nodejs.org/download/nightly" +} + +tasks.withType().configureEach { + args.add("--ignore-engines") +} diff --git a/clikt/src/wasmJsMain/kotlin/com/github/ajalt/clikt/mpp/MppImpl.kt b/clikt/src/wasmJsMain/kotlin/com/github/ajalt/clikt/mpp/MppImpl.kt new file mode 100644 index 00000000..42ee49b8 --- /dev/null +++ b/clikt/src/wasmJsMain/kotlin/com/github/ajalt/clikt/mpp/MppImpl.kt @@ -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) diff --git a/clikt/src/wasmJsMain/kotlin/com/github/ajalt/clikt/output/EditorWasm.kt b/clikt/src/wasmJsMain/kotlin/com/github/ajalt/clikt/output/EditorWasm.kt new file mode 100644 index 00000000..44bafe96 --- /dev/null +++ b/clikt/src/wasmJsMain/kotlin/com/github/ajalt/clikt/output/EditorWasm.kt @@ -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 : 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, 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, + 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, + 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 { + return getEditorPath().trim().split(" ").toTypedArray() + } + + private fun editFileWithEditor(editorCmd: Array, filename: String) { + val cmd = editorCmd[0] + val args = JsArray() + (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()?.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): JsObject { + val obj = JsObject() + for ((k, v) in pairs) { + obj[k.toJsString()] = v + } + return obj +}