diff --git a/docs/modules/ROOT/pages/kotlinlib/web-examples.adoc b/docs/modules/ROOT/pages/kotlinlib/web-examples.adoc index 9cc89e14164..6c79de38993 100644 --- a/docs/modules/ROOT/pages/kotlinlib/web-examples.adoc +++ b/docs/modules/ROOT/pages/kotlinlib/web-examples.adoc @@ -12,7 +12,12 @@ It covers setting up a basic backend server with a variety of server frameworks == Ktor Hello World App include::partial$example/kotlinlib/web/1-hello-ktor.adoc[] + == Ktor TodoMvc App include::partial$example/kotlinlib/web/2-todo-ktor.adoc[] +== (Work In Progress) Simple KotlinJS Module + +include::partial$example/kotlinlib/web/3-hello-kotlinjs.adoc[] + diff --git a/example/kotlinlib/web/3-hello-kotlinjs/build.mill b/example/kotlinlib/web/3-hello-kotlinjs/build.mill new file mode 100644 index 00000000000..950fec1eb2a --- /dev/null +++ b/example/kotlinlib/web/3-hello-kotlinjs/build.mill @@ -0,0 +1,43 @@ +// KotlinJS support on Mill is still Work In Progress (WIP). As of time of writing it +// does not support third-party dependencies, Kotlin 2.x with KMP KLIB files, Node.js/Webpack +// test runners and reporting, etc. +// +// The example below demonstrates only the minimal compilation, running, and testing of a single KotlinJS +// module. For more details in fully developing KotlinJS support, see the following ticket: +// +// * https://github.com/com-lihaoyi/mill/issues/3611 + +package build +import mill._, kotlinlib._, kotlinlib.js._ + +object foo extends KotlinJSModule { + def moduleKind = ModuleKind.ESModule + def kotlinVersion = "1.9.25" + def kotlinJSRunTarget = Some(RunTarget.Node) + object test extends KotlinJSModule with KotlinJSKotlinXTests +} + + +/** Usage + +> mill foo.run +Compiling 1 Kotlin sources to .../out/foo/compile.dest/classes... +Hello, world +stringifiedJsObject: ["hello","world","!"] + +> mill foo.test # Test is incorrect, `foo.test`` fails +Compiling 1 Kotlin sources to .../out/foo/test/compile.dest/classes... +Linking IR to .../out/foo/test/linkBinary.dest/binaries +produce executable: .../out/foo/test/linkBinary.dest/binaries +... +error: AssertionError: Expected , actual . + +> cat out/foo/test/linkBinary.dest/binaries/test.js # Generated javascript on disk +...assertEquals_0(getString(), 'Not hello, world');... +... + +> sed -i.bak 's/Not hello, world/Hello, world/g' foo/test/src/foo/HelloTests.kt + +> mill foo.test # passes after fixing test + +*/ diff --git a/example/kotlinlib/web/3-hello-kotlinjs/foo/src/foo/Hello.kt b/example/kotlinlib/web/3-hello-kotlinjs/foo/src/foo/Hello.kt new file mode 100644 index 00000000000..09f3ccd16af --- /dev/null +++ b/example/kotlinlib/web/3-hello-kotlinjs/foo/src/foo/Hello.kt @@ -0,0 +1,11 @@ +package foo + +fun getString() = "Hello, world" + +fun main() { + println(getString()) + + val parsedJsonStr: dynamic = JSON.parse("""{"helloworld": ["hello", "world", "!"]}""") + val stringifiedJsObject = JSON.stringify(parsedJsonStr.helloworld) + println("stringifiedJsObject: " + stringifiedJsObject) +} diff --git a/example/kotlinlib/web/3-hello-kotlinjs/foo/test/src/foo/HelloTests.kt b/example/kotlinlib/web/3-hello-kotlinjs/foo/test/src/foo/HelloTests.kt new file mode 100644 index 00000000000..7526f739947 --- /dev/null +++ b/example/kotlinlib/web/3-hello-kotlinjs/foo/test/src/foo/HelloTests.kt @@ -0,0 +1,13 @@ +package foo + +import kotlin.test.Test +import kotlin.test.assertEquals + +class HelloTests { + + @Test + fun failure() { + assertEquals(getString(), "Not hello, world") + } +} + diff --git a/kotlinlib/src/mill/kotlinlib/KotlinModule.scala b/kotlinlib/src/mill/kotlinlib/KotlinModule.scala index 81e74148746..4afb1c81a12 100644 --- a/kotlinlib/src/mill/kotlinlib/KotlinModule.scala +++ b/kotlinlib/src/mill/kotlinlib/KotlinModule.scala @@ -7,7 +7,7 @@ package kotlinlib import mill.api.{Loose, PathRef, Result} import mill.define.{Command, ModuleRef, Task} -import mill.kotlinlib.worker.api.KotlinWorker +import mill.kotlinlib.worker.api.{KotlinWorker, KotlinWorkerTarget} import mill.scalalib.api.{CompilationResult, ZincWorkerApi} import mill.scalalib.{JavaModule, Lib, ZincWorkerModule} import mill.util.Jvm @@ -92,11 +92,6 @@ trait KotlinModule extends JavaModule { outer => */ def kotlinCompilerIvyDeps: T[Agg[Dep]] = Task { Agg(ivy"org.jetbrains.kotlin:kotlin-compiler:${kotlinCompilerVersion()}") ++ -// ( -// if (Seq("1.0.", "1.1.", "1.2").exists(prefix => kotlinVersion().startsWith(prefix))) -// Agg(ivy"org.jetbrains.kotlin:kotlin-runtime:${kotlinCompilerVersion()}") -// else Seq() -// ) ++ ( if ( !Seq("1.0.", "1.1.", "1.2.0", "1.2.1", "1.2.2", "1.2.3", "1.2.4").exists(prefix => @@ -106,15 +101,8 @@ trait KotlinModule extends JavaModule { outer => Agg(ivy"org.jetbrains.kotlin:kotlin-scripting-compiler:${kotlinCompilerVersion()}") else Seq() ) -// ivy"org.jetbrains.kotlin:kotlin-scripting-compiler-impl:${kotlinCompilerVersion()}", -// ivy"org.jetbrains.kotlin:kotlin-scripting-common:${kotlinCompilerVersion()}", } -// @Deprecated("Use kotlinWorkerTask instead, as this does not need to be cached as Worker") -// def kotlinWorker: Worker[KotlinWorker] = Task.Worker { -// kotlinWorkerTask() -// } - def kotlinWorkerTask: Task[KotlinWorker] = Task.Anon { kotlinWorkerRef().kotlinWorkerManager().get(kotlinCompilerClasspath()) } @@ -264,7 +252,7 @@ trait KotlinModule extends JavaModule { outer => (kotlinSourceFiles ++ javaSourceFiles).map(_.toIO.getAbsolutePath()) ).flatten - val workerResult = kotlinWorkerTask().compile(compilerArgs: _*) + val workerResult = kotlinWorkerTask().compile(KotlinWorkerTarget.Jvm, compilerArgs: _*) val analysisFile = dest / "kotlin.analysis.dummy" os.write(target = analysisFile, data = "", createFolders = true) diff --git a/kotlinlib/src/mill/kotlinlib/js/KotlinJSModule.scala b/kotlinlib/src/mill/kotlinlib/js/KotlinJSModule.scala new file mode 100644 index 00000000000..3540c099642 --- /dev/null +++ b/kotlinlib/src/mill/kotlinlib/js/KotlinJSModule.scala @@ -0,0 +1,512 @@ +package mill.kotlinlib.js + +import mainargs.arg +import mill.api.{PathRef, Result} +import mill.define.{Command, Segment, Task} +import mill.kotlinlib.worker.api.{KotlinWorker, KotlinWorkerTarget} +import mill.kotlinlib.{Dep, DepSyntax, KotlinModule} +import mill.scalalib.Lib +import mill.scalalib.api.CompilationResult +import mill.testrunner.TestResult +import mill.util.Jvm +import mill.{Agg, Args, T} +import upickle.default.{macroRW, ReadWriter => RW} + +import java.io.File +import java.util.zip.ZipFile + +/** + * This module is very experimental. Don't use it, it is still under the development, APIs can change. + */ +trait KotlinJSModule extends KotlinModule { outer => + + // region Kotlin/JS configuration + + /** The kind of JS module generated by the compiler */ + def moduleKind: T[ModuleKind] = ModuleKind.PlainModule + + /** Call main function upon execution. */ + def callMain: T[Boolean] = true + + /** Binary type (if any) to produce. If [[BinaryKind.Executable]] is selected, then .js file(s) will be produced. */ + def kotlinJSBinaryKind: T[Option[BinaryKind]] = Some(BinaryKind.Executable) + + /** Whether to emit a source map. */ + def kotlinJSSourceMap: T[Boolean] = true + + /** Whether to embed sources into source map. */ + def kotlinJSSourceMapEmbedSources: T[SourceMapEmbedSourcesKind] = SourceMapEmbedSourcesKind.Never + + /** ES target to use. List of the supported ones depends on the Kotlin version. If not provided, default is used. */ + def kotlinJSESTarget: T[Option[String]] = None + + /** + * Add variable and function names that you declared in Kotlin code into the source map. See + * [[https://kotlinlang.org/docs/compiler-reference.html#source-map-names-policy-simple-names-fully-qualified-names-no Kotlin docs]] for more details + */ + def kotlinJSSourceMapNamesPolicy: T[SourceMapNamesPolicy] = SourceMapNamesPolicy.No + + /** Split generated .js per-module. Effective only if [[BinaryKind.Executable]] is selected. */ + def splitPerModule: T[Boolean] = true + + /** Run target for the executable (if [[BinaryKind.Executable]] is set). */ + def kotlinJSRunTarget: T[Option[RunTarget]] = None + + // endregion + + // region parent overrides + + override def allSourceFiles: T[Seq[PathRef]] = Task { + Lib.findSourceFiles(allSources(), Seq("kt")).map(PathRef(_)) + } + + override def mandatoryIvyDeps: T[Agg[Dep]] = Task { + Agg( + ivy"org.jetbrains.kotlin:kotlin-stdlib-js:${kotlinVersion()}" + ) + } + + override def transitiveCompileClasspath: T[Agg[PathRef]] = Task { + T.traverse(transitiveModuleCompileModuleDeps)(m => + Task.Anon { + val transitiveModuleArtifactPath = + (if (m.isInstanceOf[KotlinJSModule]) { + m.asInstanceOf[KotlinJSModule].createKlib(T.dest, m.compile().classes) + } else m.compile().classes) + m.localCompileClasspath() ++ Agg(transitiveModuleArtifactPath) + } + )().flatten + } + + /** + * Compiles all the sources to the IR representation. + */ + override def compile: T[CompilationResult] = Task { + kotlinJsCompile( + outputMode = OutputMode.KlibDir, + irClasspath = None, + allKotlinSourceFiles = allKotlinSourceFiles(), + librariesClasspath = compileClasspath(), + callMain = callMain(), + moduleKind = moduleKind(), + produceSourceMaps = kotlinJSSourceMap(), + sourceMapEmbedSourcesKind = kotlinJSSourceMapEmbedSources(), + sourceMapNamesPolicy = kotlinJSSourceMapNamesPolicy(), + splitPerModule = splitPerModule(), + esTarget = kotlinJSESTarget(), + kotlinVersion = kotlinVersion(), + destinationRoot = T.dest, + extraKotlinArgs = kotlincOptions(), + worker = kotlinWorkerTask() + ) + } + + override def runLocal(args: Task[Args] = Task.Anon(Args())): Command[Unit] = + Task.Command { run(args)() } + + override def run(args: Task[Args] = Task.Anon(Args())): Command[Unit] = Task.Command { + val binaryKind = kotlinJSBinaryKind() + if (binaryKind.isEmpty || binaryKind.get != BinaryKind.Executable) { + T.log.error("Run action is only allowed for the executable binary") + } + + val moduleKind = this.moduleKind() + + val linkResult = linkBinary().classes + if ( + moduleKind == ModuleKind.NoModule + && linkResult.path.toIO.listFiles().count(_.getName.endsWith(".js")) > 1 + ) { + T.log.info("No module type is selected for the executable, but multiple .js files found in the output folder." + + " This will probably lead to the dependency resolution failure.") + } + + kotlinJSRunTarget() match { + case Some(RunTarget.Node) => Jvm.runSubprocess( + commandArgs = Seq( + "node", + (linkResult.path / s"${moduleName()}.${moduleKind.extension}").toIO.getAbsolutePath + ) ++ args().value, + envArgs = T.env, + workingDir = T.dest + ) + case Some(x) => + T.log.error(s"Run target $x is not supported") + case None => + throw new IllegalArgumentException("Executable binary should have a run target selected.") + } + + } + + override def runMainLocal( + @arg(positional = true) mainClass: String, + args: String* + ): Command[Unit] = Task.Command[Unit] { + mill.api.Result.Failure("runMain is not supported in Kotlin/JS.") + } + + override def runMain(@arg(positional = true) mainClass: String, args: String*): Command[Unit] = + Task.Command[Unit] { + mill.api.Result.Failure("runMain is not supported in Kotlin/JS.") + } + + /** + * The actual Kotlin compile task (used by [[compile]] and [[kotlincHelp]]). + */ + protected override def kotlinCompileTask( + extraKotlinArgs: Seq[String] = Seq.empty[String] + ): Task[CompilationResult] = Task.Anon { + kotlinJsCompile( + outputMode = OutputMode.KlibDir, + allKotlinSourceFiles = allKotlinSourceFiles(), + irClasspath = None, + librariesClasspath = compileClasspath(), + callMain = callMain(), + moduleKind = moduleKind(), + produceSourceMaps = kotlinJSSourceMap(), + sourceMapEmbedSourcesKind = kotlinJSSourceMapEmbedSources(), + sourceMapNamesPolicy = kotlinJSSourceMapNamesPolicy(), + splitPerModule = splitPerModule(), + esTarget = kotlinJSESTarget(), + kotlinVersion = kotlinVersion(), + destinationRoot = T.dest, + extraKotlinArgs = kotlincOptions() ++ extraKotlinArgs, + worker = kotlinWorkerTask() + ) + } + + /** + * Creates final executable. + */ + def linkBinary: T[CompilationResult] = Task { + kotlinJsCompile( + outputMode = binaryKindToOutputMode(kotlinJSBinaryKind()), + irClasspath = Some(compile().classes), + allKotlinSourceFiles = Seq.empty, + librariesClasspath = compileClasspath(), + callMain = callMain(), + moduleKind = moduleKind(), + produceSourceMaps = kotlinJSSourceMap(), + sourceMapEmbedSourcesKind = kotlinJSSourceMapEmbedSources(), + sourceMapNamesPolicy = kotlinJSSourceMapNamesPolicy(), + splitPerModule = splitPerModule(), + esTarget = kotlinJSESTarget(), + kotlinVersion = kotlinVersion(), + destinationRoot = T.dest, + extraKotlinArgs = kotlincOptions(), + worker = kotlinWorkerTask() + ) + } + + // endregion + + // region private + + private def createKlib(destFolder: os.Path, irPathRef: PathRef): PathRef = { + val outputPath = destFolder / s"${moduleName()}.klib" + Jvm.createJar( + outputPath, + Agg(irPathRef.path), + mill.api.JarManifest.MillDefault, + fileFilter = (_, _) => true + ) + PathRef(outputPath) + } + + private[kotlinlib] def kotlinJsCompile( + outputMode: OutputMode, + allKotlinSourceFiles: Seq[PathRef], + irClasspath: Option[PathRef], + librariesClasspath: Agg[PathRef], + callMain: Boolean, + moduleKind: ModuleKind, + produceSourceMaps: Boolean, + sourceMapEmbedSourcesKind: SourceMapEmbedSourcesKind, + sourceMapNamesPolicy: SourceMapNamesPolicy, + splitPerModule: Boolean, + esTarget: Option[String], + kotlinVersion: String, + destinationRoot: os.Path, + extraKotlinArgs: Seq[String], + worker: KotlinWorker + )(implicit ctx: mill.api.Ctx): Result[CompilationResult] = { + val versionAllowed = kotlinVersion.split("\\.").map(_.toInt) match { + case Array(1, 8, z) => z >= 20 + case Array(1, y, _) => y >= 9 + case Array(2, _, _) => false + case _ => false + } + if (!versionAllowed) { + // have to put this restriction, because for older versions some compiler options either didn't exist or + // had different names. It is possible to go to the lower version supported with a certain effort. + ctx.log.error("Minimum supported Kotlin version for JS target is 1.8.20, maximum is 1.9.25") + return Result.Aborted + } + + // compiler options references: + // * https://kotlinlang.org/docs/compiler-reference.html#kotlin-js-compiler-options + // * https://github.com/JetBrains/kotlin/blob/v1.9.25/compiler/cli/cli-common/src/org/jetbrains/kotlin/cli/common/arguments/K2JSCompilerArguments.kt + + val inputFiles = irClasspath match { + case Some(x) => Seq(s"-Xinclude=${x.path.toIO.getAbsolutePath}") + case None => allKotlinSourceFiles.map(_.path.toIO.getAbsolutePath) + } + + // TODO: Cannot support Kotlin 2+, because it doesn't publish .jar anymore, but .klib files only. Coursier is not + // able to work with that (unlike Gradle, which can leverage .module metadata). + // https://repo1.maven.org/maven2/org/jetbrains/kotlin/kotlin-stdlib-js/2.0.20/ + val librariesCp = librariesClasspath.map(_.path) + .filter(os.exists) + .filter(isKotlinJsLibrary) + + val innerCompilerArgs = Seq.newBuilder[String] + // classpath + innerCompilerArgs ++= Seq("-libraries", librariesCp.iterator.mkString(File.pathSeparator)) + innerCompilerArgs ++= Seq("-main", if (callMain) "call" else "noCall") + innerCompilerArgs += "-meta-info" + if (moduleKind != ModuleKind.NoModule) { + innerCompilerArgs ++= Seq( + "-module-kind", + moduleKind match { + case ModuleKind.AMDModule => "amd" + case ModuleKind.UMDModule => "umd" + case ModuleKind.PlainModule => "plain" + case ModuleKind.ESModule => "es" + case ModuleKind.CommonJSModule => "commonjs" + } + ) + } + // what is the better way to find a module simple name, without root path? + innerCompilerArgs ++= Seq("-ir-output-name", moduleName()) + if (produceSourceMaps) { + innerCompilerArgs += "-source-map" + innerCompilerArgs ++= Seq( + "-source-map-embed-sources", + sourceMapEmbedSourcesKind match { + case SourceMapEmbedSourcesKind.Always => "always" + case SourceMapEmbedSourcesKind.Never => "never" + case SourceMapEmbedSourcesKind.Inlining => "inlining" + } + ) + innerCompilerArgs ++= Seq( + "-source-map-names-policy", + sourceMapNamesPolicy match { + case SourceMapNamesPolicy.No => "no" + case SourceMapNamesPolicy.SimpleNames => "simple-names" + case SourceMapNamesPolicy.FullyQualifiedNames => "fully-qualified-names" + } + ) + } + innerCompilerArgs += "-Xir-only" + if (splitPerModule) { + innerCompilerArgs += s"-Xir-per-module" + innerCompilerArgs += s"-Xir-per-module-output-name=${fullModuleName()}" + } + val outputArgs = outputMode match { + case OutputMode.KlibFile => + Seq( + "-Xir-produce-klib-file", + "-ir-output-dir", + (destinationRoot / "libs").toIO.getAbsolutePath + ) + case OutputMode.KlibDir => + Seq( + "-Xir-produce-klib-dir", + "-ir-output-dir", + (destinationRoot / "classes").toIO.getAbsolutePath + ) + case OutputMode.Js => + Seq( + "-Xir-produce-js", + "-ir-output-dir", + (destinationRoot / "binaries").toIO.getAbsolutePath + ) + } + + innerCompilerArgs ++= outputArgs + innerCompilerArgs += s"-Xir-module-name=${moduleName()}" + innerCompilerArgs ++= (esTarget match { + case Some(x) => Seq("-target", x) + case None => Seq.empty + }) + + val compilerArgs: Seq[String] = Seq( + innerCompilerArgs.result(), + extraKotlinArgs, + // parameters + inputFiles + ).flatten + + val compileDestination = os.Path(outputArgs.last) + if (irClasspath.isEmpty) { + T.log.info( + s"Compiling ${allKotlinSourceFiles.size} Kotlin sources to $compileDestination ..." + ) + } else { + T.log.info(s"Linking IR to $compileDestination") + } + val workerResult = worker.compile(KotlinWorkerTarget.Js, compilerArgs: _*) + + val analysisFile = T.dest / "kotlin.analysis.dummy" + if (!os.exists(analysisFile)) { + os.write(target = analysisFile, data = "", createFolders = true) + } + + val artifactLocation = outputMode match { + case OutputMode.KlibFile => compileDestination / s"${moduleName()}.klib" + case OutputMode.KlibDir => compileDestination + case OutputMode.Js => compileDestination + } + + workerResult match { + case Result.Success(_) => + CompilationResult(analysisFile, PathRef(artifactLocation)) + case Result.Failure(reason, _) => + Result.Failure(reason, Some(CompilationResult(analysisFile, PathRef(artifactLocation)))) + case e: Result.Exception => e + case Result.Aborted => Result.Aborted + case Result.Skipped => Result.Skipped + } + } + + private def binaryKindToOutputMode(binaryKind: Option[BinaryKind]): OutputMode = + binaryKind match { + // still produce IR classes, but they won't be yet linked + case None => OutputMode.KlibDir + case Some(BinaryKind.Library) => OutputMode.KlibFile + case Some(BinaryKind.Executable) => OutputMode.Js + } + + // these 2 exist to ignore values added to the display name in case of the cross-modules + // we already have cross-modules in the paths, so we don't need them here + private def moduleName() = millModuleSegments.value + .filter(_.isInstanceOf[Segment.Label]) + .map(_.asInstanceOf[Segment.Label]) + .last + .value + + private def fullModuleName() = millModuleSegments.value + .filter(_.isInstanceOf[Segment.Label]) + .map(_.asInstanceOf[Segment.Label].value) + .mkString("-") + + // **NOTE**: This logic may (and probably is) be incomplete + private def isKotlinJsLibrary(path: os.Path)(implicit ctx: mill.api.Ctx): Boolean = { + if (os.isDir(path)) { + true + } else if (path.ext == "klib") { + true + } else if (path.ext == "jar") { + try { + // TODO cache these lookups. May be a big performance penalty. + val zipFile = new ZipFile(path.toIO) + zipFile.stream() + .anyMatch(entry => entry.getName.endsWith(".meta.js") || entry.getName.endsWith(".kjsm")) + } catch { + case e: Throwable => + T.log.error(s"Couldn't open ${path.toIO.getAbsolutePath} as archive.\n${e.toString}") + false + } + } else { + T.log.debug(s"${path.toIO.getAbsolutePath} is not a Kotlin/JS library, ignoring it.") + false + } + } + + // endregion + + // region Tests module + + trait KotlinJSTests extends KotlinTests with KotlinJSModule { + + override def testFramework = "" + + override def kotlinJSBinaryKind: T[Option[BinaryKind]] = Some(BinaryKind.Executable) + + override def splitPerModule = false + + override def testLocal(args: String*): Command[(String, Seq[TestResult])] = + Task.Command { + this.test(args: _*)() + } + + override protected def testTask( + args: Task[Seq[String]], + globSelectors: Task[Seq[String]] + ): Task[(String, Seq[TestResult])] = Task.Anon { + // This is a terrible hack, but it works + run()() + ("", Seq.empty[TestResult]) + } + } + + trait KotlinJSKotlinXTests extends KotlinJSTests { + override def ivyDeps = Agg( + ivy"org.jetbrains.kotlin:kotlin-test-js:${kotlinVersion()}" + ) + override def kotlinJSRunTarget: T[Option[RunTarget]] = Some(RunTarget.Node) + } + + // endregion +} + +sealed trait ModuleKind { def extension: String } + +object ModuleKind { + object NoModule extends ModuleKind { val extension = "js" } + implicit val rwNoModule: RW[NoModule.type] = macroRW + object UMDModule extends ModuleKind { val extension = "js" } + implicit val rwUMDModule: RW[UMDModule.type] = macroRW + object CommonJSModule extends ModuleKind { val extension = "js" } + implicit val rwCommonJSModule: RW[CommonJSModule.type] = macroRW + object AMDModule extends ModuleKind { val extension = "js" } + implicit val rwAMDModule: RW[AMDModule.type] = macroRW + object ESModule extends ModuleKind { val extension = "mjs" } + implicit val rwESModule: RW[ESModule.type] = macroRW + object PlainModule extends ModuleKind { val extension = "js" } + implicit val rwPlainModule: RW[PlainModule.type] = macroRW +} + +sealed trait SourceMapEmbedSourcesKind +object SourceMapEmbedSourcesKind { + object Always extends SourceMapEmbedSourcesKind + implicit val rwAlways: RW[Always.type] = macroRW + object Never extends SourceMapEmbedSourcesKind + implicit val rwNever: RW[Never.type] = macroRW + object Inlining extends SourceMapEmbedSourcesKind + implicit val rwInlining: RW[Inlining.type] = macroRW +} + +sealed trait SourceMapNamesPolicy +object SourceMapNamesPolicy { + object SimpleNames extends SourceMapNamesPolicy + implicit val rwSimpleNames: RW[SimpleNames.type] = macroRW + object FullyQualifiedNames extends SourceMapNamesPolicy + implicit val rwFullyQualifiedNames: RW[FullyQualifiedNames.type] = macroRW + object No extends SourceMapNamesPolicy + implicit val rwNo: RW[No.type] = macroRW +} + +sealed trait BinaryKind +object BinaryKind { + object Library extends BinaryKind + implicit val rwLibrary: RW[Library.type] = macroRW + object Executable extends BinaryKind + implicit val rwExecutable: RW[Executable.type] = macroRW + implicit val rw: RW[BinaryKind] = macroRW +} + +sealed trait RunTarget +object RunTarget { + // TODO rely on the node version installed in the env or fetch a specific one? + object Node extends RunTarget + implicit val rwNode: RW[Node.type] = macroRW + implicit val rw: RW[RunTarget] = macroRW +} + +private[kotlinlib] sealed trait OutputMode +private[kotlinlib] object OutputMode { + object Js extends OutputMode + object KlibDir extends OutputMode + object KlibFile extends OutputMode +} diff --git a/kotlinlib/test/resources/kotlin-js/bar/src/bar/Provider.kt b/kotlinlib/test/resources/kotlin-js/bar/src/bar/Provider.kt new file mode 100644 index 00000000000..f1f9580e0d7 --- /dev/null +++ b/kotlinlib/test/resources/kotlin-js/bar/src/bar/Provider.kt @@ -0,0 +1,3 @@ +package bar + +fun getString() = "Hello, world" diff --git a/kotlinlib/test/resources/kotlin-js/foo/src/foo/Hello.kt b/kotlinlib/test/resources/kotlin-js/foo/src/foo/Hello.kt new file mode 100644 index 00000000000..b9193c50092 --- /dev/null +++ b/kotlinlib/test/resources/kotlin-js/foo/src/foo/Hello.kt @@ -0,0 +1,7 @@ +package foo + +import bar.getString + +fun main() { + println(getString()) +} diff --git a/kotlinlib/test/resources/kotlin-js/foo/test/src/foo/HelloTests.kt b/kotlinlib/test/resources/kotlin-js/foo/test/src/foo/HelloTests.kt new file mode 100644 index 00000000000..c55f18f2ec6 --- /dev/null +++ b/kotlinlib/test/resources/kotlin-js/foo/test/src/foo/HelloTests.kt @@ -0,0 +1,19 @@ +package foo + +import bar.getString +import kotlin.test.Test +import kotlin.test.assertEquals + +class HelloTests { + + @Test + fun success() { + assertEquals(getString(), "Hello, world") + } + + @Test + fun failure() { + assertEquals(getString(), "Not hello, world") + } +} + diff --git a/kotlinlib/test/src/mill/kotlinlib/js/KotlinJSCompileTests.scala b/kotlinlib/test/src/mill/kotlinlib/js/KotlinJSCompileTests.scala new file mode 100644 index 00000000000..1d54740d0cf --- /dev/null +++ b/kotlinlib/test/src/mill/kotlinlib/js/KotlinJSCompileTests.scala @@ -0,0 +1,60 @@ +package mill +package kotlinlib +package js + +import mill.testkit.{TestBaseModule, UnitTester} +import utest.{TestSuite, Tests, assert, test} + +object KotlinJSCompileTests extends TestSuite { + + private val kotlinVersion = "1.9.25" + + private val resourcePath = os.Path(sys.env("MILL_TEST_RESOURCE_DIR")) / "kotlin-js" + + object module extends TestBaseModule { + + object bar extends KotlinJSModule { + def kotlinVersion = KotlinJSCompileTests.kotlinVersion + } + + object foo extends KotlinJSModule { + override def kotlinVersion = KotlinJSCompileTests.kotlinVersion + override def moduleDeps = Seq(module.bar) + } + } + + private def testEval() = UnitTester(module, resourcePath) + + def tests: Tests = Tests { + test("compile") { + val eval = testEval() + + val Right(result) = eval.apply(module.foo.compile) + + val irDir = result.value.classes.path + assert( + os.isDir(irDir), + os.exists(irDir / "default" / "manifest"), + os.exists(irDir / "default" / "linkdata" / "package_foo"), + !os.walk(irDir).exists(_.ext == "klib") + ) + } + + test("failures") { + val eval = testEval() + + val compilationUnit = module.foo.millSourcePath / "src" / "foo" / "Hello.kt" + + val Right(_) = eval.apply(module.foo.compile) + + os.write.over(compilationUnit, os.read(compilationUnit) + "}") + + val Left(_) = eval.apply(module.foo.compile) + + os.write.over(compilationUnit, os.read(compilationUnit).dropRight(1)) + + val Right(_) = eval.apply(module.foo.compile) + } + } + +} diff --git a/kotlinlib/test/src/mill/kotlinlib/js/KotlinJSKotlinVersionsTests.scala b/kotlinlib/test/src/mill/kotlinlib/js/KotlinJSKotlinVersionsTests.scala new file mode 100644 index 00000000000..1fc6f381c4e --- /dev/null +++ b/kotlinlib/test/src/mill/kotlinlib/js/KotlinJSKotlinVersionsTests.scala @@ -0,0 +1,49 @@ +package mill +package kotlinlib +package js + +import mill.testkit.{TestBaseModule, UnitTester} +import mill.Cross +import utest.{TestSuite, Tests, test} + +object KotlinJSKotlinVersionsTests extends TestSuite { + + private val resourcePath = os.Path(sys.env("MILL_TEST_RESOURCE_DIR")) / "kotlin-js" + private val kotlinLowestVersion = "1.8.20" + // TODO: Cannot support Kotlin 2+, because it doesn't publish .jar anymore, but .klib files only. Coursier is not + // able to work with that (unlike Gradle, which can leverage .module metadata). + // https://repo1.maven.org/maven2/org/jetbrains/kotlin/kotlin-stdlib-js/2.0.20/ + private val kotlinHighestVersion = "1.9.25" + private val kotlinVersions = Seq(kotlinLowestVersion, kotlinHighestVersion) + + trait KotlinJSCrossModule extends KotlinJSModule with Cross.Module[String] { + def kotlinVersion = crossValue + } + + trait KotlinJSFooCrossModule extends KotlinJSCrossModule { + override def moduleDeps = Seq(module.bar(crossValue)) + } + + object module extends TestBaseModule { + + object bar extends Cross[KotlinJSCrossModule](kotlinVersions) + object foo extends Cross[KotlinJSFooCrossModule](kotlinVersions) + } + + private def testEval() = UnitTester(module, resourcePath) + + def tests: Tests = Tests { + test("compile with lowest Kotlin version") { + val eval = testEval() + + val Right(_) = eval.apply(module.foo(kotlinLowestVersion).compile) + } + + test("compile with highest Kotlin version") { + val eval = testEval() + + val Right(_) = eval.apply(module.foo(kotlinHighestVersion).compile) + } + } + +} diff --git a/kotlinlib/test/src/mill/kotlinlib/js/KotlinJSLinkTests.scala b/kotlinlib/test/src/mill/kotlinlib/js/KotlinJSLinkTests.scala new file mode 100644 index 00000000000..928a3fcb49e --- /dev/null +++ b/kotlinlib/test/src/mill/kotlinlib/js/KotlinJSLinkTests.scala @@ -0,0 +1,69 @@ +package mill.kotlinlib.js + +import mill.testkit.{TestBaseModule, UnitTester} +import mill.{Cross, T} +import utest.{TestSuite, Tests, assert, test} + +import scala.util.Random + +object KotlinJSLinkTests extends TestSuite { + + private val kotlinVersion = "1.9.25" + + private val resourcePath = os.Path(sys.env("MILL_TEST_RESOURCE_DIR")) / "kotlin-js" + + trait KotlinJSCrossModule extends KotlinJSModule with Cross.Module[Boolean] { + override def kotlinVersion = KotlinJSLinkTests.kotlinVersion + override def splitPerModule: T[Boolean] = crossValue + override def kotlinJSBinaryKind: T[Option[BinaryKind]] = Some(BinaryKind.Executable) + override def moduleDeps = Seq(module.bar) + } + + object module extends TestBaseModule { + + object bar extends KotlinJSModule { + def kotlinVersion = KotlinJSLinkTests.kotlinVersion + } + + object foo extends Cross[KotlinJSCrossModule](Seq(true, false)) + } + + private def testEval() = UnitTester(module, resourcePath) + + def tests: Tests = Tests { + test("link { per module }") { + val eval = testEval() + + val Right(result) = eval.apply(module.foo(true).linkBinary) + + val binariesDir = result.value.classes.path + assert( + os.isDir(binariesDir), + os.exists(binariesDir / "foo.js"), + os.exists(binariesDir / "foo.js.map"), + os.exists(binariesDir / "bar.js"), + os.exists(binariesDir / "bar.js.map"), + os.exists(binariesDir / "kotlin-kotlin-stdlib.js"), + os.exists(binariesDir / "kotlin-kotlin-stdlib.js.map") + ) + } + + test("link { fat }") { + val eval = testEval() + + val Right(result) = eval.apply(module.foo(false).linkBinary) + + val binariesDir = result.value.classes.path + assert( + os.isDir(binariesDir), + os.exists(binariesDir / "foo.js"), + os.exists(binariesDir / "foo.js.map"), + !os.exists(binariesDir / "bar.js"), + !os.exists(binariesDir / "bar.js.map"), + !os.exists(binariesDir / "kotlin-kotlin-stdlib.js"), + !os.exists(binariesDir / "kotlin-kotlin-stdlib.js.map") + ) + } + } + +} diff --git a/kotlinlib/test/src/mill/kotlinlib/js/KotlinJSNodeRunTests.scala b/kotlinlib/test/src/mill/kotlinlib/js/KotlinJSNodeRunTests.scala new file mode 100644 index 00000000000..dbc3adc31c2 --- /dev/null +++ b/kotlinlib/test/src/mill/kotlinlib/js/KotlinJSNodeRunTests.scala @@ -0,0 +1,163 @@ +package mill +package kotlinlib +package js + +import mill.eval.EvaluatorPaths +import mill.testkit.{TestBaseModule, UnitTester} +import utest.{TestSuite, Tests, test} + +object KotlinJSNodeRunTests extends TestSuite { + + private val resourcePath = os.Path(sys.env("MILL_TEST_RESOURCE_DIR")) / "kotlin-js" + private val kotlinVersion = "1.9.25" + private val expectedSuccessOutput = "Hello, world" + + object module extends TestBaseModule { + + private val matrix = for { + splits <- Seq(true, false) + modules <- Seq("no", "plain", "es", "amd", "commonjs", "umd") + } yield (splits, modules) + + trait KotlinJsModuleKindCross extends KotlinJSModule with Cross.Module2[Boolean, String] { + + def kotlinVersion = KotlinJSNodeRunTests.kotlinVersion + + override def moduleKind = crossValue2 match { + case "no" => ModuleKind.NoModule + case "plain" => ModuleKind.PlainModule + case "es" => ModuleKind.ESModule + case "amd" => ModuleKind.AMDModule + case "commonjs" => ModuleKind.CommonJSModule + case "umd" => ModuleKind.UMDModule + } + + override def moduleDeps = Seq(module.bar) + override def splitPerModule = crossValue + override def kotlinJSRunTarget = Some(RunTarget.Node) + } + + object bar extends KotlinJSModule { + def kotlinVersion = KotlinJSNodeRunTests.kotlinVersion + } + + object foo extends Cross[KotlinJsModuleKindCross](matrix) + } + + private def testEval() = UnitTester(module, resourcePath) + + def tests: Tests = Tests { + // region with split per module + + test("run { split per module / plain module }") { + val eval = testEval() + + // plain modules cannot handle the dependencies, so if there are multiple js files, it will fail + val Left(_) = eval.apply(module.foo(true, "plain").run()) + } + + test("run { split per module / es module }") { + val eval = testEval() + + val command = module.foo(true, "es").run() + val Right(_) = eval.apply(command) + + assertLogContains(eval, command, expectedSuccessOutput) + } + + test("run { split per module / amd module }") { + val eval = testEval() + + // amd modules have "define" method, it is not known by Node.js + val Left(_) = eval.apply(module.foo(true, "amd").run()) + } + + test("run { split per module / commonjs module }") { + val eval = testEval() + + val command = module.foo(true, "commonjs").run() + val Right(_) = eval.apply(command) + + assertLogContains(eval, command, expectedSuccessOutput) + } + + test("run { split per module / umd module }") { + val eval = testEval() + + val command = module.foo(true, "umd").run() + val Right(_) = eval.apply(command) + + assertLogContains(eval, command, expectedSuccessOutput) + } + + test("run { split per module / no module }") { + val eval = testEval() + + val Left(_) = eval.apply(module.foo(true, "no").run()) + } + + // endregion + + // region without split per module + + test("run { no split per module / plain module }") { + val eval = testEval() + + val command = module.foo(false, "plain").run() + val Right(_) = eval.apply(command) + + assertLogContains(eval, command, expectedSuccessOutput) + } + + test("run { no split per module / es module }") { + val eval = testEval() + + val command = module.foo(false, "es").run() + val Right(_) = eval.apply(command) + + assertLogContains(eval, command, expectedSuccessOutput) + } + + test("run { no split per module / amd module }") { + val eval = testEval() + + // amd modules have "define" method, it is not known by Node.js + val Left(_) = eval.apply(module.foo(false, "amd").run()) + } + + test("run { no split per module / commonjs module }") { + val eval = testEval() + + val command = module.foo(false, "commonjs").run() + val Right(_) = eval.apply(command) + + assertLogContains(eval, command, expectedSuccessOutput) + } + + test("run { no split per module / umd module }") { + val eval = testEval() + + val command = module.foo(false, "umd").run() + val Right(_) = eval.apply(command) + + assertLogContains(eval, command, expectedSuccessOutput) + } + + test("run { no split per module / no module }") { + val eval = testEval() + + val command = module.foo(false, "no").run() + val Right(_) = eval.apply(command) + + assertLogContains(eval, command, expectedSuccessOutput) + } + + // endregion + } + + private def assertLogContains(eval: UnitTester, command: Command[Unit], text: String): Unit = { + val log = EvaluatorPaths.resolveDestPaths(eval.outPath, command).log + assert(os.read(log).contains(text)) + } + +} diff --git a/kotlinlib/test/src/mill/kotlinlib/js/KotlinJSTestModuleTests.scala b/kotlinlib/test/src/mill/kotlinlib/js/KotlinJSTestModuleTests.scala new file mode 100644 index 00000000000..37e2dd138ad --- /dev/null +++ b/kotlinlib/test/src/mill/kotlinlib/js/KotlinJSTestModuleTests.scala @@ -0,0 +1,47 @@ +package mill +package kotlinlib +package js + +import mill.eval.EvaluatorPaths +import mill.testkit.{TestBaseModule, UnitTester} +import utest.{TestSuite, Tests, test} + +object KotlinJSTestModuleTests extends TestSuite { + + private val resourcePath = os.Path(sys.env("MILL_TEST_RESOURCE_DIR")) / "kotlin-js" + + private val kotlinVersion = "1.9.25" + + object module extends TestBaseModule { + + object bar extends KotlinJSModule { + def kotlinVersion = KotlinJSTestModuleTests.kotlinVersion + } + + object foo extends KotlinJSModule { + def kotlinVersion = KotlinJSTestModuleTests.kotlinVersion + override def moduleDeps = Seq(module.bar) + + object test extends KotlinJSModule with KotlinJSKotlinXTests + } + } + + private def testEval() = UnitTester(module, resourcePath) + + def tests: Tests = Tests { + + test("run tests") { + val eval = testEval() + + val command = module.foo.test.test() + val Left(_) = eval.apply(command) + + // temporary, because we are running run() task, it won't be test.log, but run.log + val log = EvaluatorPaths.resolveDestPaths(eval.outPath, command).log / ".." / "run.log" + assert( + os.read(log).contains("AssertionError: Expected , actual .") + ) + } + } + +} diff --git a/kotlinlib/worker/impl/src/mill/kotlinlib/worker/impl/KotlinWorkerImpl.scala b/kotlinlib/worker/impl/src/mill/kotlinlib/worker/impl/KotlinWorkerImpl.scala index 99fcb41f6e7..86804b8943b 100644 --- a/kotlinlib/worker/impl/src/mill/kotlinlib/worker/impl/KotlinWorkerImpl.scala +++ b/kotlinlib/worker/impl/src/mill/kotlinlib/worker/impl/KotlinWorkerImpl.scala @@ -5,18 +5,22 @@ package mill.kotlinlib.worker.impl import mill.api.{Ctx, Result} -import mill.kotlinlib.worker.api.KotlinWorker +import mill.kotlinlib.worker.api.{KotlinWorker, KotlinWorkerTarget} +import org.jetbrains.kotlin.cli.js.K2JsIrCompiler import org.jetbrains.kotlin.cli.jvm.K2JVMCompiler class KotlinWorkerImpl extends KotlinWorker { - def compile(args: String*)(implicit ctx: Ctx): Result[Unit] = { + def compile(target: KotlinWorkerTarget, args: String*)(implicit ctx: Ctx): Result[Unit] = { ctx.log.debug("Using kotlin compiler arguments: " + args.map(v => s"'${v}'").mkString(" ")) - val compiler = new K2JVMCompiler() + val compiler = target match { + case KotlinWorkerTarget.Jvm => new K2JVMCompiler() + case KotlinWorkerTarget.Js => new K2JsIrCompiler() + } val exitCode = compiler.exec(ctx.log.errorStream, args: _*) - if (exitCode.getCode() != 0) { - Result.Failure(s"Kotlin compiler failed with exit code ${exitCode.getCode()} (${exitCode})") + if (exitCode.getCode != 0) { + Result.Failure(s"Kotlin compiler failed with exit code ${exitCode.getCode} ($exitCode)") } else { Result.Success(()) } diff --git a/kotlinlib/worker/src/mill/kotlinlib/worker/api/KotlinWorker.scala b/kotlinlib/worker/src/mill/kotlinlib/worker/api/KotlinWorker.scala index 0c323d1e88b..2fa79895f1b 100644 --- a/kotlinlib/worker/src/mill/kotlinlib/worker/api/KotlinWorker.scala +++ b/kotlinlib/worker/src/mill/kotlinlib/worker/api/KotlinWorker.scala @@ -8,6 +8,12 @@ import mill.api.{Ctx, Result} trait KotlinWorker { - def compile(args: String*)(implicit ctx: Ctx): Result[Unit] + def compile(target: KotlinWorkerTarget, args: String*)(implicit ctx: Ctx): Result[Unit] } + +sealed class KotlinWorkerTarget +object KotlinWorkerTarget { + case object Jvm extends KotlinWorkerTarget + case object Js extends KotlinWorkerTarget +}