diff --git a/build.gradle.kts b/build.gradle.kts index d741f34e..42a2cea6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,12 +3,17 @@ import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar plugins { kotlin("jvm") version "1.2.70" id("com.github.johnrengelman.shadow") version "2.0.4" + application +} + +application { + mainClassName = "kscript.app.KscriptKt" } group = "com.github.holgerbrandl.kscript.launcher" dependencies { - compileOnly("org.jetbrains.kotlin:kotlin-stdlib") + compile("org.jetbrains.kotlin:kotlin-stdlib") compile("com.offbytwo:docopt:0.6.0.20150202") @@ -50,4 +55,4 @@ val jar by tasks.getting { // Build shadowJar when val assemble by tasks.getting { dependsOn(shadowJar) -} \ No newline at end of file +} diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index e6a30918..ae45383b 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-4.7-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-4.10.3-all.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/src/kscript b/src/kscript index 1fbede4a..277ac67a 100755 --- a/src/kscript +++ b/src/kscript @@ -22,13 +22,38 @@ abs_kscript_path=$(resolve_symlink $(absolute_path $0)) ## resolve application jar path from script location and convert to windows path when using cygwin jarPath=$(dirname $abs_kscript_path)/kscript.jar -if [[ $(uname) == CYGWIN* ]]; then jarPath=$(cygpath -w ${jarPath}); fi - -## prefer KOTLIN_HOME instead of PATH to resolve `kotlin` location (see #145) -if [[ -z "$KOTLIN_HOME" ]]; then KOTLIN_EXEC="kotlin"; else KOTLIN_EXEC="$KOTLIN_HOME/bin/kotlin"; fi ## expose the name of the script being run to the script itself export KSCRIPT_FILE="$1" -## run it using command substitution to have just the user process once kscript is done -eval "exec $(${KOTLIN_EXEC} -classpath ${jarPath} kscript.app.KscriptKt "$@")" +if [[ $(uname) == CYGWIN* ]]; then + jarPath=$(cygpath -w ${jarPath}) + [[ ! -z $KOTLIN_HOME ]] && KOTLIN_HOME=$(cygpath -w ${KOTLIN_HOME}) +fi + +# from 'kotlinc' + +[ -n "$JAVA_OPTS" ] || JAVA_OPTS="-Xmx256M -Xms32M" + +declare -a java_args +declare -a kscript_args + +while [ $# -gt 0 ]; do + case "$1" in + -D*) + java_args=("${java_args[@]}" "$1") + shift + ;; + -J*) + java_args=("${java_args[@]}" "${1:2}") + shift + ;; + *) + kscript_args=("${kscript_args[@]}" "$1") + shift + ;; + esac +done + +## run it +exec ${JAVACMD:=java} "${java_args[@]}" -jar ${jarPath} "${kscript_args[@]}" diff --git a/src/main/kotlin/kscript/app/AppHelpers.kt b/src/main/kotlin/kscript/app/AppHelpers.kt index 50b036fe..f2b7fdfc 100644 --- a/src/main/kotlin/kscript/app/AppHelpers.kt +++ b/src/main/kotlin/kscript/app/AppHelpers.kt @@ -9,6 +9,11 @@ import java.security.MessageDigest import java.util.function.Consumer import kotlin.system.exitProcess +val IS_WINDOWS = System.getProperty("os.name").toLowerCase().contains("windows") + +fun joinToPathString(vararg parts: String) = parts.joinToString("${File.separatorChar}") + +fun argsToList(args: String): List = if (args.isEmpty()) listOf() else args.split("\\s+".toRegex()) data class ProcessResult(val command: String, val exitCode: Int, val stdout: String, val stderr: String) { @@ -116,7 +121,6 @@ fun errorIf(value: Boolean, lazyMessage: () -> Any) { } fun quit(status: Int): Nothing { - print(if (status == 0) "true" else "false") exitProcess(status) } @@ -282,7 +286,7 @@ private fun createSymLink(link: File, target: File) { * Create and use a temporary gradle project to package the compiled script using capsule. * See https://github.com/puniverse/capsule */ -fun packageKscript(scriptJar: File, wrapperClassName: String, dependencies: List, customRepos: List, runtimeOptions: String, appName: String) { +fun packageKscript(scriptJar: File, wrapperClassName: String, dependencies: List, customRepos: List, runtimeOptions: List, appName: String) { requireInPath("gradle", "gradle is required to package kscripts") infoMsg("Packaging script '$appName' into standalone executable...") @@ -295,7 +299,7 @@ fun packageKscript(scriptJar: File, wrapperClassName: String, dependencies: List val stringifiedDeps = dependencies.map { " compile \"$it\"" }.joinToString("\n") val stringifiedRepos = customRepos.map { " maven {\n url '${it.url}'\n }\n" }.joinToString("\n") - val jvmOptions = runtimeOptions.split(" ") + val jvmOptions = runtimeOptions .filter { it.startsWith("-J") } .map { it.removePrefix("-J") } .map { '"' + it + '"' } diff --git a/src/main/kotlin/kscript/app/DependencyUtil.kt b/src/main/kotlin/kscript/app/DependencyUtil.kt index 94a24c50..54610715 100644 --- a/src/main/kotlin/kscript/app/DependencyUtil.kt +++ b/src/main/kotlin/kscript/app/DependencyUtil.kt @@ -11,7 +11,7 @@ import java.io.File val DEP_LOOKUP_CACHE_FILE = File(KSCRIPT_CACHE_DIR, "dependency_cache.txt") -val CP_SEPARATOR_CHAR = if (System.getProperty("os.name").toLowerCase().contains("windows")) ";" else ":" +val CP_SEPARATOR_CHAR = if (IS_WINDOWS) ";" else ":" fun resolveDependencies(depIds: List, customRepos: List = emptyList(), loggingEnabled: Boolean): String? { diff --git a/src/main/kotlin/kscript/app/JarFileLoader.kt b/src/main/kotlin/kscript/app/JarFileLoader.kt new file mode 100644 index 00000000..fb14c23d --- /dev/null +++ b/src/main/kotlin/kscript/app/JarFileLoader.kt @@ -0,0 +1,14 @@ +package kscript.app + +import java.io.File +import java.net.URL +import java.net.URLClassLoader + +// https://dzone.com/articles/add-jar-file-java-load-path + +class JarFileLoader(urls: Array = arrayOf()) : URLClassLoader(urls) { + fun addFile(file: File) { + addURL(file.toURI().toURL()) + } +} + diff --git a/src/main/kotlin/kscript/app/KotlinRunner.kt b/src/main/kotlin/kscript/app/KotlinRunner.kt new file mode 100644 index 00000000..232095cd --- /dev/null +++ b/src/main/kotlin/kscript/app/KotlinRunner.kt @@ -0,0 +1,103 @@ +package kscript.app + +import java.io.File + +class KotlinRunner(private val kotlinHome: String) { + companion object { + private const val PRELOADER_CLASS = "org.jetbrains.kotlin.preloading.Preloader" + private const val RUNNER_MAIN_CLASS = "org.jetbrains.kotlin.runner.Main" + } + + private val jarFileLoader by lazy { JarFileLoader() } + + private val compilerBaseArgs = listOf("-cp", joinToPathString(kotlinHome, "lib", "kotlin-compiler.jar"), + "org.jetbrains.kotlin.cli.jvm.K2JVMCompiler") + + + private val preloaderMainMethod by lazy { + val preloaderJar = File(joinToPathString(kotlinHome, "lib", "kotlin-preloader.jar")) + jarFileLoader.addFile(preloaderJar) + jarFileLoader.loadClass(PRELOADER_CLASS).getDeclaredMethod("main", Array::class.java) + } + private val runnerMainMethod by lazy { + System.getProperties().setProperty("kotlin.home", kotlinHome) + jarFileLoader.addFile(File(joinToPathString(kotlinHome, "lib", "kotlin-runner.jar"))) + jarFileLoader.loadClass(RUNNER_MAIN_CLASS).getDeclaredMethod("main", Array::class.java) + } + + fun compile(compilerOpts: List, targetJarFile: File, sourceFiles: List, classpath: String?) { + val baseArgs = listOf("-Xskip-runtime-version-check") + + compilerOpts + listOf("-d", targetJarFile.absolutePath) + val cpArgs = classPathArgs(classpath) + runKotlinc(baseArgs + sourceFiles.map { it.absolutePath } + cpArgs) + } + + fun runScript(scriptClassPath: String, execClassName: String, userArgs: List, kotlinOpts: List): Int { + val runnerArgs = listOf("-cp", scriptClassPath, execClassName) + userArgs + return if (kotlinOpts.isNotEmpty()) { + execKotlin(kotlinOpts, runnerArgs) + } else { + runKotlin(runnerArgs) + // it would be unreachable if the script calls exitProcess() + 0 + } + } + + fun interactiveShell(jarFile: File, classpath: String?, compilerOpts: List, kotlinOpts: List) { + val jarPath = if (jarFile.isFile) jarFile.absolutePath else null + val args = compilerOpts + classPathArgs(classpath, jarPath) + if (kotlinOpts.isNotEmpty()) { + execKotlinc(kotlinOpts, args) + } else { + runKotlinc(args) + } + } + + private fun classPathArgs(vararg paths: String?): List { + val combinedClasspath = listOfNotNull(*paths).filter { it.isNotEmpty() }.joinToString(CP_SEPARATOR_CHAR) + return if (combinedClasspath.isNotEmpty()) { + listOf("-cp", combinedClasspath) + } else listOf() + } + + private fun runKotlinc(args: Collection) { + preloaderMainMethod.invoke(jarFileLoader, (compilerBaseArgs + args).toTypedArray()) + } + + private fun execKotlinc(kotlinOpts: List, args: List): Int { + return if (IS_WINDOWS) { + execJava(kotlinOpts, compilerBaseArgs, args) + } else { + exec(listOf("kotlinc") + kotlinOpts + args) + } + } + + private fun runKotlin(args: Collection) { + runnerMainMethod.invoke(jarFileLoader, args.toTypedArray()) + } + + private fun execKotlin(kotlinOpts: List, args: List): Int { + return if (IS_WINDOWS) { + val runnerJar = joinToPathString(kotlinHome, "lib", "kotlin-runner.jar") + val kotlinAppArgs = listOf("-Dkotlin.home=$kotlinHome", "-cp", runnerJar, RUNNER_MAIN_CLASS) + execJava(kotlinOpts, kotlinAppArgs, args) + } else { + exec(listOf("kotlin") + kotlinOpts + args) + } + } + + private fun execJava(kotlinOpts: List, kotlinAppArgs: List, args: List): Int { + val (javaOpts, pureKotlinOpts) = kotlinOpts.partition { it.startsWith("-J") || it.startsWith("-D") } + val pureJavaOpts = javaOpts.map { if (it.startsWith("-J")) it.substring(2) else it } + val javaCmd = System.getenv("JAVACMD") ?: "java" + return exec(listOf(javaCmd) + pureJavaOpts + kotlinAppArgs + pureKotlinOpts + args) + } + + private fun exec(commandList: List) = + ProcessBuilder(commandList) + .inheritIO() + .apply { environment()["KOTLIN_RUNNER"] = "" } + .start() + .waitFor() +} + diff --git a/src/main/kotlin/kscript/app/Kscript.kt b/src/main/kotlin/kscript/app/Kscript.kt index b6542255..0ec3ef1f 100644 --- a/src/main/kotlin/kscript/app/Kscript.kt +++ b/src/main/kotlin/kscript/app/Kscript.kt @@ -1,6 +1,5 @@ package kscript.app -import kscript.app.ShellUtils.requireInPath import org.docopt.DocOptWrapper import org.intellij.lang.annotations.Language import java.io.BufferedReader @@ -45,7 +44,8 @@ Options: -s --silent Suppress status logging to stderr --package Package script and dependencies into self-dependent binary --add-bootstrap-header Prepend bash header that installs kscript if necessary - + -J -J is stripped and passed to Java as-is (if no KOTLIN_OPTS is given) + -Dname=value Set a system JVM property (if no KOTLIN_OPTS is given) Copyright : 2017 Holger Brandl License : MIT @@ -106,28 +106,7 @@ fun main(args: Array) { // optionally self-update kscript ot the newest version // (if not local copy is not being maintained by sdkman) if (docopt.getBoolean(("self-update"))) { - if (true || evalBash("which kscript | grep .sdkman").stdout.isNotBlank()) { - info("Installing latest version of kscript...") - // println("sdkman_auto_answer=true && sdk install kscript") - - // create update script - val updateScript = File(KSCRIPT_CACHE_DIR, "self_update.sh").apply { - writeText(""" - #!/usr/bin/env bash - export SDKMAN_DIR="${"$"}{HOME}/.sdkman" - source "${"$"}{SDKMAN_DIR}/bin/sdkman-init.sh" - sdkman_auto_answer=true && sdk install kscript - """.trimIndent()) - setExecutable(true) - } - - println(updateScript.absolutePath) - } else { - info("Self-update is currently just supported via sdkman.") - info("Please download a new release from https://github.com/holgerbrandl/kscript") - // todo port sdkman-indpendent self-update - } - + selfUpdate() quit(0) } @@ -181,28 +160,16 @@ fun main(args: Array) { // Create temporary dev environment if (docopt.getBoolean("idea")) { - println(launchIdeaWithKscriptlet(rawScript, dependencies, customRepos, includeURLs)) + evalBash(launchIdeaWithKscriptlet(rawScript, dependencies, customRepos, includeURLs)) exitProcess(0) } val classpath = resolveDependencies(dependencies, customRepos, loggingEnabled) ?: "" - val optionalCpArg = if (classpath.isNotEmpty()) "-classpath '${classpath}'" else "" // Extract kotlin arguments - val kotlinOpts = script.collectRuntimeOptions() - val compilerOpts = script.collectCompilerOptions() - - - // Optionally enter interactive mode - if (docopt.getBoolean("interactive")) { - infoMsg("Creating REPL from ${scriptFile}") - // System.err.println("kotlinc ${kotlinOpts} -classpath '${classpath}'") - - println("kotlinc ${compilerOpts} ${kotlinOpts} ${optionalCpArg}") - - exitProcess(0) - } + val kotlinOpts = argsToList(script.collectRuntimeOptions()) + val compilerOpts = argsToList(script.collectCompilerOptions()) val scriptFileExt = scriptFile.extension val scriptCheckSum = md5(scriptFile) @@ -242,12 +209,12 @@ fun main(args: Array) { // infer KOTLIN_HOME if not set - val KOTLIN_HOME = System.getenv("KOTLIN_HOME") ?: guessKotlinHome() - - errorIf(KOTLIN_HOME == null) { - "KOTLIN_HOME is not set and could not be inferred from context" + val KOTLIN_HOME = System.getenv("KOTLIN_HOME") ?: guessKotlinHome() ?: run { + errorMsg("KOTLIN_HOME is not set and could not be inferred from context") + quit(1) } + val kotlinRunner = KotlinRunner(KOTLIN_HOME) // If scriplet jar ist not cached yet, build it if (!jarFile.isFile) { @@ -261,12 +228,9 @@ fun main(args: Array) { // }).forEach { it.delete() } - requireInPath("kotlinc") - - // create main-wrapper for kts scripts - val wrapperSrcArg = if (scriptFileExt == "kts") { + val wrapperFile = if (scriptFileExt == "kts") { val mainKotlin = File(createTempDir("kscript"), execClassName + ".kt") val classReference = (script.pckg ?: "") + className @@ -282,22 +246,12 @@ fun main(args: Array) { } } """.trimIndent()) - - "'${mainKotlin.absolutePath}'" - } else { - "" - } - - val scriptCompileResult = evalBash("kotlinc ${compilerOpts} ${optionalCpArg} -d '${jarFile.absolutePath}' '${scriptFile.absolutePath}' ${wrapperSrcArg}") - with(scriptCompileResult) { - errorIf(exitCode != 0) { "compilation of '$scriptResource' failed\n$stderr" } - } + mainKotlin + } else null + val sourceFiles = listOfNotNull(scriptFile, wrapperFile) + kotlinRunner.compile(compilerOpts, jarFile, sourceFiles, classpath) } - - // print the final command to be run by eval+exec - val joinedUserArgs = userArgs.map { "\"${it.replace("\"", "\\\"")}\"" }.joinToString(" ") - //if requested try to package the into a standalone binary if (docopt.getBoolean("package")) { val binaryName = if (File(scriptResource).run { canRead() && listOf("kts", "kt").contains(extension) }) { @@ -311,11 +265,38 @@ fun main(args: Array) { quit(0) } - var extClassPath = "${jarFile}${CP_SEPARATOR_CHAR}${KOTLIN_HOME}${File.separatorChar}lib${File.separatorChar}kotlin-script-runtime.jar" - if (classpath.isNotEmpty()) - extClassPath += kscript.app.CP_SEPARATOR_CHAR + classpath + // Optionally enter interactive mode + if (docopt.getBoolean("interactive")) { + System.err.println("Creating REPL from ${scriptFile}") + kotlinRunner.interactiveShell(jarFile, classpath, compilerOpts, kotlinOpts) + exitProcess(0) + } + + val scriptClassPath = listOf(jarFile, joinToPathString(KOTLIN_HOME, "lib", "kotlin-script-runtime.jar"), classpath).joinToString(CP_SEPARATOR_CHAR) - println("kotlin ${kotlinOpts} -classpath \"${extClassPath}\" ${execClassName} ${joinedUserArgs} ") + exitProcess(kotlinRunner.runScript(scriptClassPath, execClassName, userArgs, kotlinOpts)) +} + +private fun selfUpdate() { + if (true || evalBash("which kscript | grep .sdkman").stdout.isNotBlank()) { + info("Installing latest version of kscript...") + // create update script + val updateScript = File(KSCRIPT_CACHE_DIR, "self_update.sh").apply { + writeText(""" + #!/usr/bin/env bash + export SDKMAN_DIR="${"$"}{HOME}/.sdkman" + source "${"$"}{SDKMAN_DIR}/bin/sdkman-init.sh" + sdkman_auto_answer=true && sdk install kscript + """.trimIndent()) + setExecutable(true) + } + + evalBash(updateScript.absolutePath) + } else { + info("Self-update is currently just supported via sdkman.") + info("Please download a new release from https://github.com/holgerbrandl/kscript") + // todo port sdkman-indpendent self-update + } } diff --git a/test/test_suite.sh b/test/test_suite.sh index 06d79aac..6aeee418 100755 --- a/test/test_suite.sh +++ b/test/test_suite.sh @@ -133,10 +133,10 @@ export -f resolve_deps assert_stderr "resolve_deps log4j:log4j:1.2.14" "${HOME}/.m2/repository/log4j/log4j/1.2.14/log4j-1.2.14.jar" ## impossible version -assert "resolve_deps log4j:log4j:9.8.76" "false" +assert_raises "resolve_deps log4j:log4j:9.8.76" 1 ## wrong format should exit with 1 -assert "resolve_deps log4j:1.0" "false" +assert_raises "resolve_deps log4j:1.0" 1 assert_stderr "resolve_deps log4j:1.0" "[ERROR] Invalid dependency locator: 'log4j:1.0'. Expected format is groupId:artifactId:version[:classifier][@type]"