diff --git a/scripts/hello.hub.kts b/scripts/hello.hub.kts index c8338d7..b3ab673 100644 --- a/scripts/hello.hub.kts +++ b/scripts/hello.hub.kts @@ -1,7 +1,7 @@ // @desc: hello // @timeout: 10s // @param: name | default=world | desc=hello | required=false -// @param: upper | default=false | desc=upper text | required=true +// @param: upper | default=true | desc=upper text | required=false import java.time.LocalDateTime val args: Array = emptyArray() @@ -13,7 +13,7 @@ val kv = }.toMap() val name = kv["name"] ?: "world" -val upper = (kv["upper"]!!).toBoolean() +val upper = (kv["upper"] ?: "false").toBoolean() val message = "Hello, $name @ ${LocalDateTime.now()}" println(if (upper) message.uppercase() else message) diff --git a/src/main/kotlin/work/slhaf/hub/ScriptEngine.kt b/src/main/kotlin/work/slhaf/hub/ScriptEngine.kt index a7b6ce1..7324dc2 100644 --- a/src/main/kotlin/work/slhaf/hub/ScriptEngine.kt +++ b/src/main/kotlin/work/slhaf/hub/ScriptEngine.kt @@ -20,6 +20,7 @@ import kotlin.script.experimental.jvmhost.BasicJvmScriptingHost private val scriptingHost = BasicJvmScriptingHost() private val evalLock = Any() private val metadataCache = ConcurrentHashMap>() // key -> stamp, metadata +private val compiledScriptCache = ConcurrentHashMap>() // key -> stamp, compiled script private val resolver = CompoundDependenciesResolver(FileSystemDependenciesResolver(), MavenDependenciesResolver()) private val argsDeclarationRegex = Regex("""^\s*val\s+args\s*:\s*Array\s*=\s*emptyArray\(\)\s*$""") @@ -81,27 +82,11 @@ private fun compilationConfiguration(explicitCp: List?): ScriptCompilation } } -private fun escapeKotlinString(value: String): String = buildString(value.length) { - value.forEach { ch -> - when (ch) { - '\\' -> append("\\\\") - '"' -> append("\\\"") - '\n' -> append("\\n") - '\r' -> append("\\r") - '\t' -> append("\\t") - else -> append(ch) - } - } -} - -private fun argsInitializer(args: List): String = - "val args: Array = arrayOf(${args.joinToString(",") { "\"${escapeKotlinString(it)}\"" }})" - private fun scriptStamp(file: File): String = "${file.length()}-${file.lastModified()}" -private fun injectArgsDeclaration(scriptContent: String, args: List): String { +private fun injectArgsBridgeDeclaration(scriptContent: String): String { val lines = scriptContent.lines() - val injected = argsInitializer(args) + val injected = "val args: Array = hostArgs" var replaced = false val result = lines.map { line -> if (!replaced && argsDeclarationRegex.matches(line)) { @@ -114,6 +99,24 @@ private fun injectArgsDeclaration(scriptContent: String, args: List): St return result.joinToString("\n") } +private fun parseArgEntry(raw: String): Pair? { + val idx = raw.indexOf('=') + if (idx <= 0) return null + return raw.substring(0, idx) to raw.substring(idx + 1) +} + +private fun applyDefaultArgs(metadata: ScriptMetadata, requestArgs: List): List { + val existingKeys = requestArgs.mapNotNull { parseArgEntry(it)?.first }.toMutableSet() + val merged = requestArgs.toMutableList() + metadata.params.forEach { param -> + if (!existingKeys.contains(param.name) && param.defaultValue != null) { + merged += "${param.name}=${param.defaultValue}" + existingKeys += param.name + } + } + return merged +} + private fun parseMetadataFromComments(scriptContent: String): ScriptMetadata { var description: String? = null var timeoutMs = DEFAULT_SCRIPT_TIMEOUT_MS @@ -259,9 +262,35 @@ private fun metadataForFile(scriptFile: File, scriptContent: String): ScriptMeta return parsed } -private fun evalSource(source: SourceCode): ResultWithDiagnostics { +private fun compileSource(source: SourceCode): ResultWithDiagnostics { val explicitCp = explicitClasspathFromEnv() - return scriptingHost.eval(source, compilationConfiguration(explicitCp), null) + return runBlocking { + scriptingHost.compiler(source, compilationConfiguration(explicitCp)) + } +} + +private fun evalCompiled(compiledScript: CompiledScript, requestContext: ScriptRequestContext): ResultWithDiagnostics { + val evaluationConfiguration = ScriptEvaluationConfiguration { + constructorArgs(requestContext.args.toTypedArray()) + } + return runBlocking { + scriptingHost.evaluator(compiledScript, evaluationConfiguration) + } +} + +private fun compiledScriptFor(scriptFile: File, preparedContent: String): ResultWithDiagnostics { + val key = scriptFile.canonicalPath + val stamp = scriptStamp(scriptFile) + val cached = compiledScriptCache[key] + if (cached != null && cached.first == stamp) { + return cached.second.asSuccess() + } + + val compiled = compileSource(preparedContent.toScriptSource(scriptFile.name)) + if (compiled is ResultWithDiagnostics.Success) { + compiledScriptCache[key] = stamp to compiled.value + } + return compiled } data class ScriptExecutionResult( @@ -281,6 +310,7 @@ fun cachedMetadata(scriptFile: File): ScriptMetadata? { fun removeCachedMetadata(scriptFile: File) { metadataCache.remove(scriptFile.canonicalPath) + compiledScriptCache.remove(scriptFile.canonicalPath) } fun loadMetadataFromComments(scriptFile: File): ScriptMetadata { @@ -316,16 +346,23 @@ fun evalAndCapture( } } .map { it.name } + val effectiveArgs = applyDefaultArgs(metadata, requestContext.args) - val injected = injectArgsDeclaration(original, requestContext.args) - val result = evalSource(injected.toScriptSource(scriptFile.name)) - val returnValueError = (result as? ResultWithDiagnostics.Success) + val injected = injectArgsBridgeDeclaration(original) + val compilationResult = compiledScriptFor(scriptFile, injected) + val evaluationResult = if (compilationResult is ResultWithDiagnostics.Success) { + evalCompiled(compilationResult.value, requestContext.copy(args = effectiveArgs)) + } else { + null + } + val reports = evaluationResult?.reports ?: compilationResult.reports + val returnValueError = (evaluationResult as? ResultWithDiagnostics.Success) ?.value ?.returnValue as? ResultValue.Error - val hasErrorDiagnostics = result.reports.any { + val hasErrorDiagnostics = reports.any { it.severity == ScriptDiagnostic.Severity.ERROR || it.severity == ScriptDiagnostic.Severity.FATAL } - val diagnostics = result.reports + val diagnostics = reports .filter { it.severity > ScriptDiagnostic.Severity.DEBUG } .joinToString("\n") { val ex = it.exception?.let { e -> ": ${e::class.simpleName}: ${e.message}" } ?: "" @@ -343,7 +380,8 @@ fun evalAndCapture( }.trim() ScriptExecutionResult( - ok = result is ResultWithDiagnostics.Success && + ok = compilationResult is ResultWithDiagnostics.Success && + evaluationResult is ResultWithDiagnostics.Success && !hasErrorDiagnostics && returnValueError == null && (!enforceRequiredParams || missingRequired.isEmpty()), diff --git a/src/main/kotlin/work/slhaf/hub/SimpleScriptDef.kt b/src/main/kotlin/work/slhaf/hub/SimpleScriptDef.kt index 4b141fc..4e14d6b 100644 --- a/src/main/kotlin/work/slhaf/hub/SimpleScriptDef.kt +++ b/src/main/kotlin/work/slhaf/hub/SimpleScriptDef.kt @@ -3,4 +3,6 @@ package work.slhaf.hub import kotlin.script.experimental.annotations.KotlinScript @KotlinScript(fileExtension = "hub.kts") -abstract class SimpleScript +abstract class SimpleScript( + val hostArgs: Array = emptyArray(), +) diff --git a/src/test/kotlin/work/slhaf/hub/WebHostApiTest.kt b/src/test/kotlin/work/slhaf/hub/WebHostApiTest.kt index b19746c..4c880ca 100644 --- a/src/test/kotlin/work/slhaf/hub/WebHostApiTest.kt +++ b/src/test/kotlin/work/slhaf/hub/WebHostApiTest.kt @@ -263,6 +263,34 @@ class WebHostApiTest { assertTrue(runtimeErrorBody.contains("boom (required=false")) } + @Test + fun defaultParamValueIsInjectedIntoHostArgs() = withApp { _ -> + val create = client.post("/scripts/defaults") { + bearerRoot() + setBody( + """ + // @desc: default args test + // @param: name | required=false | default=world | desc=Name fallback + val args: Array = emptyArray() + val kv = args.mapNotNull { + val i = it.indexOf('=') + if (i <= 0) null else it.substring(0, i) to it.substring(i + 1) + }.toMap() + println("name=" + (kv["name"] ?: "missing")) + """.trimIndent() + ) + } + assertEquals(HttpStatusCode.Created, create.status) + + val runWithoutArg = client.get("/run/defaults") { bearerRoot() } + assertEquals(HttpStatusCode.OK, runWithoutArg.status) + assertTrue(runWithoutArg.bodyAsText().contains("name=world")) + + val runWithArg = client.get("/run/defaults?name=alice") { bearerRoot() } + assertEquals(HttpStatusCode.OK, runWithArg.status) + assertTrue(runWithArg.bodyAsText().contains("name=alice")) + } + private fun withApp(testBlock: suspend io.ktor.server.testing.ApplicationTestBuilder.(java.nio.file.Path) -> Unit) { val scriptsDir = createTempDirectory("webhost-api-test-") tempDirs.add(scriptsDir)