diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3446258058..578a2acbe5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -158,6 +158,9 @@ jobs: if: env.SHOULD_RUN == 'true' with: jvm: "temurin:17" + - uses: actions/setup-node@v6 + with: + node-version: 24 - name: JVM integration tests if: env.SHOULD_RUN == 'true' run: ./mill -i integration.test.jvmBootstrapped @@ -196,6 +199,9 @@ jobs: if: env.SHOULD_RUN == 'true' with: jvm: "temurin:17" + - uses: actions/setup-node@v6 + with: + node-version: 24 - name: JVM integration tests if: env.SHOULD_RUN == 'true' run: ./mill -i integration.test.jvm @@ -234,6 +240,9 @@ jobs: if: env.SHOULD_RUN == 'true' with: jvm: "temurin:17" + - uses: actions/setup-node@v6 + with: + node-version: 24 - name: JVM integration tests if: env.SHOULD_RUN == 'true' run: ./mill -i integration.test.jvm @@ -272,6 +281,9 @@ jobs: if: env.SHOULD_RUN == 'true' with: jvm: "temurin:17" + - uses: actions/setup-node@v6 + with: + node-version: 24 - name: JVM integration tests if: env.SHOULD_RUN == 'true' run: ./mill -i integration.test.jvm @@ -310,6 +322,9 @@ jobs: if: env.SHOULD_RUN == 'true' with: jvm: "temurin:17" + - uses: actions/setup-node@v6 + with: + node-version: 24 - name: JVM integration tests if: env.SHOULD_RUN == 'true' run: ./mill -i integration.test.jvm @@ -348,6 +363,9 @@ jobs: if: env.SHOULD_RUN == 'true' with: jvm: "temurin:17" + - uses: actions/setup-node@v6 + with: + node-version: 24 - name: JVM integration tests if: env.SHOULD_RUN == 'true' run: ./mill -i integration.test.jvm @@ -436,6 +454,9 @@ jobs: with: name: linux-launchers path: artifacts/ + - uses: actions/setup-node@v6 + with: + node-version: 24 - name: Native integration tests if: env.SHOULD_RUN == 'true' run: ./mill -i nativeIntegrationTests @@ -482,6 +503,9 @@ jobs: with: name: linux-launchers path: artifacts/ + - uses: actions/setup-node@v6 + with: + node-version: 24 - name: Native integration tests if: env.SHOULD_RUN == 'true' run: ./mill -i nativeIntegrationTests @@ -528,6 +552,9 @@ jobs: with: name: linux-launchers path: artifacts/ + - uses: actions/setup-node@v6 + with: + node-version: 24 - name: Native integration tests if: env.SHOULD_RUN == 'true' run: ./mill -i nativeIntegrationTests @@ -574,6 +601,9 @@ jobs: with: name: linux-launchers path: artifacts/ + - uses: actions/setup-node@v6 + with: + node-version: 24 - name: Native integration tests if: env.SHOULD_RUN == 'true' run: ./mill -i nativeIntegrationTests @@ -620,6 +650,9 @@ jobs: with: name: linux-launchers path: artifacts/ + - uses: actions/setup-node@v6 + with: + node-version: 24 - name: Native integration tests if: env.SHOULD_RUN == 'true' run: ./mill -i nativeIntegrationTests @@ -713,6 +746,9 @@ jobs: with: name: linux-aarch64-launchers path: artifacts/ + - uses: actions/setup-node@v6 + with: + node-version: 24 - name: Native integration tests if: env.SHOULD_RUN == 'true' run: ./mill -i nativeIntegrationTests @@ -759,6 +795,9 @@ jobs: with: name: linux-aarch64-launchers path: artifacts/ + - uses: actions/setup-node@v6 + with: + node-version: 24 - name: Native integration tests if: env.SHOULD_RUN == 'true' run: ./mill -i nativeIntegrationTests @@ -1417,6 +1456,9 @@ jobs: - name: Build slim docker image if: env.SHOULD_RUN == 'true' run: .github/scripts/generate-slim-docker-image.sh + - uses: actions/setup-node@v6 + with: + node-version: 24 - name: Native integration tests if: env.SHOULD_RUN == 'true' run: ./mill -i integration.test.nativeMostlyStatic @@ -1475,6 +1517,9 @@ jobs: with: name: mostly-static-launchers path: artifacts/ + - uses: actions/setup-node@v6 + with: + node-version: 24 - name: Native integration tests if: env.SHOULD_RUN == 'true' run: ./mill -i integration.test.nativeMostlyStatic @@ -1563,6 +1608,9 @@ jobs: - name: Build docker image if: env.SHOULD_RUN == 'true' run: .github/scripts/generate-docker-image.sh + - uses: actions/setup-node@v6 + with: + node-version: 24 - name: Native integration tests if: env.SHOULD_RUN == 'true' run: ./mill -i integration.test.nativeStatic @@ -1624,6 +1672,9 @@ jobs: - name: Build docker image if: env.SHOULD_RUN == 'true' run: .github/scripts/generate-docker-image.sh + - uses: actions/setup-node@v6 + with: + node-version: 24 - name: Native integration tests if: env.SHOULD_RUN == 'true' run: ./mill -i integration.test.nativeStatic diff --git a/modules/build/src/main/scala/scala/build/internal/Runner.scala b/modules/build/src/main/scala/scala/build/internal/Runner.scala index 72c1b685d3..b524ac705f 100644 --- a/modules/build/src/main/scala/scala/build/internal/Runner.scala +++ b/modules/build/src/main/scala/scala/build/internal/Runner.scala @@ -189,6 +189,37 @@ object Runner { run(command, logger, cwd = cwd, extraEnv = extraEnv) } + // Detects the major version of Node.js on PATH; cached for the JVM lifetime (lazy val). + // Returns None if node is not found or version cannot be parsed. + private lazy val nodeMajorVersion: Option[Int] = + try { + val process = new ProcessBuilder("node", "--version") + .redirectErrorStream(true) + .start() + val output = new String(process.getInputStream.readAllBytes()).trim + process.waitFor() + // Node version format: "v22.5.0" -> extract 22 + if (output.startsWith("v")) + output.drop(1).takeWhile(_.isDigit) match { + case s if s.nonEmpty => Some(s.toInt) + case _ => None + } + else None + } + catch { + case _: Exception => None + } + + // Pre-V8 13.x runtimes need --experimental-wasm-exnref for the Scala.js Wasm exception model. + // V8 13.x ships in Node 25+ (Node 24 is still on V8 12.x where exnref is gated behind the flag). + // In Node 26+, the flag may be removed from the CLI. Only pass it when Node < 25. + // None.forall(_ < 25) == true — safe fallback when version detection fails. + private def nodeNeedsWasmFlag: Boolean = nodeMajorVersion.forall(_ < 25) + + // Deno 2.x bundles V8 12.x where wasm-exnref is gated behind a flag; symmetrical reasoning to Node. + // We always set DENO_V8_FLAGS=--experimental-wasm-exnref on Wasm output until V8 13.x lands in Deno. + private def denoNeedsWasmFlag: Boolean = true + private def endsWithCaseInsensitive(s: String, suffix: String): Boolean = s.length >= suffix.length && s.regionMatches(true, s.length - suffix.length, suffix, 0, suffix.length) @@ -221,11 +252,13 @@ object Runner { def jsCommand( entrypoint: File, args: Seq[String], - jsDom: Boolean = false + jsDom: Boolean = false, + emitWasm: Boolean = false ): Seq[String] = { - val nodePath = findInPath("node").fold("node")(_.toString) - val command = Seq(nodePath, entrypoint.getAbsolutePath) ++ args + val nodePath = findInPath("node").fold("node")(_.toString) + val nodeFlags = if (emitWasm && nodeNeedsWasmFlag) List("--experimental-wasm-exnref") else Nil + val command = Seq(nodePath) ++ nodeFlags ++ Seq(entrypoint.getAbsolutePath) ++ args if (jsDom) // FIXME We'd need to replicate what JSDOMNodeJSEnv does under-the-hood to get the command in that case. @@ -242,14 +275,20 @@ object Runner { allowExecve: Boolean = false, jsDom: Boolean = false, sourceMap: Boolean = false, - esModule: Boolean = false + esModule: Boolean = false, + emitWasm: Boolean = false ): Either[BuildException, Process] = either { val nodePath: String = value(findInPath("node") .map(_.toString) .toRight(NodeNotFoundError())) + val nodeFlags = if (emitWasm && nodeNeedsWasmFlag) List("--experimental-wasm-exnref") else Nil + if (emitWasm && nodeFlags.nonEmpty) + logger.log( + s"Wasm: adding ${nodeFlags.mkString(" ")} (required for Wasm exception handling on Node.js < 25)" + ) if !jsDom && allowExecve && Execve.available() then { - val command = Seq(nodePath, entrypoint.getAbsolutePath) ++ args + val command = Seq(nodePath) ++ nodeFlags ++ Seq(entrypoint.getAbsolutePath) ++ args logger.log( s"Running ${command.mkString(" ")}", @@ -270,7 +309,7 @@ object Runner { // Scala.js runs apps by piping JS to node. // If we need to pass arguments, we must first make the piped input explicit // with "-", and we pass the user's arguments after that. - if args.isEmpty then Nil else "-" :: args.toList + nodeFlags ++ (if args.isEmpty then Nil else "-" :: args.toList) val envJs = if jsDom then new JSDOMNodeJSEnv( @@ -307,6 +346,123 @@ object Runner { } } + def denoCommand( + entrypoint: File, + args: Seq[String] + ): Seq[String] = { + val denoPath = findInPath("deno").fold("deno")(_.toString) + val denoFlags = Seq("run", "--allow-read") + Seq(denoPath) ++ denoFlags ++ Seq(entrypoint.getAbsolutePath) ++ args + } + + def runDeno( + entrypoint: File, + args: Seq[String], + logger: Logger, + allowExecve: Boolean = false, + emitWasm: Boolean = false + ): Either[BuildException, Process] = either { + val denoPath: String = + value(findInPath("deno") + .map(_.toString) + .toRight(DenoNotFoundError())) + val denoFlags = Seq("run", "--allow-read") + val wasmFlag = "--experimental-wasm-exnref" + val extraEnv = + if (emitWasm && denoNeedsWasmFlag) { + // Append to any existing DENO_V8_FLAGS rather than replacing them. + val existing = sys.env.get("DENO_V8_FLAGS").filter(_.nonEmpty) + val merged = existing.fold(wasmFlag)(f => s"$f $wasmFlag") + logger.log( + s"Wasm: setting DENO_V8_FLAGS=$merged (required for Wasm exception handling)" + ) + Map("DENO_V8_FLAGS" -> merged) + } + else Map.empty[String, String] + + if (allowExecve && Execve.available()) { + val command = Seq(denoPath) ++ denoFlags ++ Seq(entrypoint.getAbsolutePath) ++ args + + logger.log( + s"Running ${command.mkString(" ")}", + " Running" + System.lineSeparator() + + command.iterator.map(_ + System.lineSeparator()).mkString + ) + + logger.debug("execve available") + Execve.execve( + command.head, + "deno" +: command.tail.toArray, + (sys.env ++ extraEnv).toArray.sorted.map { case (k, v) => s"$k=$v" } + ) + sys.error("should not happen") + } + else { + val command = Seq(denoPath) ++ denoFlags ++ Seq(entrypoint.getAbsolutePath) ++ args + + logger.log( + s"Running ${command.mkString(" ")}", + " Running" + System.lineSeparator() + + command.iterator.map(_ + System.lineSeparator()).mkString + ) + + val builder = new ProcessBuilder(command*) + .inheritIO() + val env = builder.environment() + for ((k, v) <- extraEnv) + env.put(k, v) + builder.start() + } + } + + def bunCommand( + entrypoint: File, + args: Seq[String] + ): Seq[String] = { + val bunPath = findInPath("bun").fold("bun")(_.toString) + Seq(bunPath, "run", entrypoint.getAbsolutePath) ++ args + } + + def runBun( + entrypoint: File, + args: Seq[String], + logger: Logger, + allowExecve: Boolean = false + ): Either[BuildException, Process] = either { + val bunPath: String = + value(findInPath("bun") + .map(_.toString) + .toRight(BunNotFoundError())) + val command = Seq(bunPath, "run", entrypoint.getAbsolutePath) ++ args + + if (allowExecve && Execve.available()) { + logger.log( + s"Running ${command.mkString(" ")}", + " Running" + System.lineSeparator() + + command.iterator.map(_ + System.lineSeparator()).mkString + ) + + logger.debug("execve available") + Execve.execve( + command.head, + "bun" +: command.tail.toArray, + sys.env.toArray.sorted.map { case (k, v) => s"$k=$v" } + ) + sys.error("should not happen") + } + else { + logger.log( + s"Running ${command.mkString(" ")}", + " Running" + System.lineSeparator() + + command.iterator.map(_ + System.lineSeparator()).mkString + ) + + new ProcessBuilder(command*) + .inheritIO() + .start() + } + } + def runNative( launcher: File, args: Seq[String], diff --git a/modules/build/src/main/scala/scala/build/preprocessing/directives/DirectivesPreprocessingUtils.scala b/modules/build/src/main/scala/scala/build/preprocessing/directives/DirectivesPreprocessingUtils.scala index 5b439b07fc..dfacd593fa 100644 --- a/modules/build/src/main/scala/scala/build/preprocessing/directives/DirectivesPreprocessingUtils.scala +++ b/modules/build/src/main/scala/scala/build/preprocessing/directives/DirectivesPreprocessingUtils.scala @@ -32,7 +32,8 @@ object DirectivesPreprocessingUtils { directives.ScalaVersion.handler, directives.Sources.handler, directives.Watching.handler, - directives.Tests.handler + directives.Tests.handler, + directives.Wasm.handler ).map(_.mapE(_.buildOptions)) val usingDirectiveWithReqsHandlers diff --git a/modules/cli/src/main/scala/scala/cli/commands/fix/BuiltInRules.scala b/modules/cli/src/main/scala/scala/cli/commands/fix/BuiltInRules.scala index 5c34b3368b..a2f92d2e42 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/fix/BuiltInRules.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/fix/BuiltInRules.scala @@ -387,6 +387,7 @@ object BuiltInRules extends CommandHelpers { JavaHome.handler.keys, ScalaNative.handler.keys, ScalaJs.handler.keys, + Wasm.handler.keys, ScalacOptions.handler.keys, JavaOptions.handler.keys, JavacOptions.handler.keys, diff --git a/modules/cli/src/main/scala/scala/cli/commands/run/Run.scala b/modules/cli/src/main/scala/scala/cli/commands/run/Run.scala index e22e6bedc8..b4ba60a090 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/run/Run.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/run/Run.scala @@ -18,7 +18,7 @@ import scala.build.internal.{Constants, Runner, ScalaJsLinkerConfig} import scala.build.internals.ConsoleUtils.ScalaCliConsole import scala.build.internals.ConsoleUtils.ScalaCliConsole.warnPrefix import scala.build.internals.EnvVar -import scala.build.options.{BuildOptions, JavaOpt, PackageType, Platform, Scope} +import scala.build.options.{BuildOptions, JavaOpt, PackageType, Platform, Scope, WasmRuntime} import scala.cli.CurrentParams import scala.cli.commands.package0.Package import scala.cli.commands.setupide.SetupIde @@ -474,229 +474,307 @@ object Run extends ScalaCommand[RunOptions] with BuildCommandHelpers { if shouldLogCrossInfo then logger.debug(s"Running build for ${crossBuildParams.asString}") val build = builds.head either { - build.options.platform.value match { - case Platform.JS => - val esModule = - build.options.scalaJsOptions.moduleKindStr.exists(m => m == "es" || m == "esmodule") - - val linkerConfig = builds.head.options.scalaJsOptions.linkerConfig(logger) - val jsDest = { - val delete = scratchDirOpt.isEmpty - scratchDirOpt.foreach(os.makeDir.all(_)) - os.temp( - dir = scratchDirOpt.orNull, - prefix = "main", - suffix = if esModule then ".mjs" else ".js", - deleteOnExit = delete - ) - } - val res = - Package.linkJs( - builds = builds, - dest = jsDest, - mainClassOpt = Some(mainClass), - addTestInitializer = false, - config = linkerConfig, - fullOpt = value(build.options.scalaJsOptions.fullOpt), - noOpt = build.options.scalaJsOptions.noOpt.getOrElse(false), - logger = logger, - scratchDirOpt = scratchDirOpt - ).map { outputPath => - val jsDom = build.options.scalaJsOptions.dom.getOrElse(false) - if showCommand then Left(Runner.jsCommand(outputPath.toIO, args, jsDom = jsDom)) - else { - val process = value { + val jsOpts = build.options.scalaJsOptions + + // Check if Wasm mode is requested + if jsOpts.jsEmitWasm then { + val runtime = jsOpts.wasmRuntime + val esModule = true // Wasm backend uses ES modules + logger.log("Wasm mode enabled: using ES module output on JS platform") + scratchDirOpt.foreach(os.makeDir.all(_)) + val jsDest = os.temp( + dir = scratchDirOpt.orNull, + prefix = "main", + suffix = ".mjs", + deleteOnExit = scratchDirOpt.isEmpty + ) + + val linkerConfig = jsOpts.linkerConfig(logger) + + val res = Package.linkJs( + builds = builds, + dest = jsDest, + mainClassOpt = Some(mainClass), + addTestInitializer = false, + config = linkerConfig, + fullOpt = value(jsOpts.fullOpt), + noOpt = jsOpts.noOpt.getOrElse(false), + logger = logger, + scratchDirOpt = scratchDirOpt + ).map { outputPath => + if showCommand then + runtime match { + case WasmRuntime.Deno => + Left(Runner.denoCommand(outputPath.toIO, args)) + case WasmRuntime.Node => + Left(Runner.jsCommand(outputPath.toIO, args, jsDom = false, emitWasm = true)) + case WasmRuntime.Bun => + Left(Runner.bunCommand(outputPath.toIO, args)) + } + else { + val process = value { + runtime match { + case WasmRuntime.Deno => + Runner.runDeno( + outputPath.toIO, + args, + logger, + allowExecve = effectiveAllowExecve, + emitWasm = true + ) + case WasmRuntime.Node => Runner.runJs( outputPath.toIO, args, logger, allowExecve = effectiveAllowExecve, - jsDom = jsDom, - sourceMap = build.options.scalaJsOptions.emitSourceMaps, - esModule = esModule + jsDom = false, + sourceMap = jsOpts.emitSourceMaps, + esModule = esModule, + emitWasm = true + ) + case WasmRuntime.Bun => + Runner.runBun( + outputPath.toIO, + args, + logger, + allowExecve = effectiveAllowExecve ) - } - process.onExit().thenApply(_ => if os.exists(jsDest) then os.remove(jsDest)) - Right((process, None)) - } - } - value(res) - case Platform.Native => - val setupPython = build.options.notForBloopOptions.doSetupPython.getOrElse(false) - val (pythonExecutable, pythonLibraryPaths, pythonExtraEnv) = - if setupPython then { - val (exec, libPaths) = value { - val python = value(createPythonInstance().orPythonDetectionError) - val pythonPropertiesOrError = for { - paths <- python.nativeLibraryPaths - executable <- python.executable - } yield (Some(executable), paths) - logger.debug( - s"Python executable and native library paths: $pythonPropertiesOrError" - ) - pythonPropertiesOrError.orPythonDetectionError - } - // Putting the workspace in PYTHONPATH, see - // https://github.com/VirtusLab/scala-cli/pull/1616#issuecomment-1333283174 - // for context. - (exec, libPaths, pythonPathEnv(builds.head.inputs.workspace)) - } - else - (None, Nil, Map()) - // seems conda doesn't add the lib directory to LD_LIBRARY_PATH (see conda/conda#308), - // which prevents apps from finding libpython for example, so we update it manually here - val libraryPathsEnv = - if pythonLibraryPaths.isEmpty then Map.empty - else { - val prependTo = - if Properties.isWin then EnvVar.Misc.path.name - else if Properties.isMac then EnvVar.Misc.dyldLibraryPath.name - else EnvVar.Misc.ldLibraryPath.name - val currentOpt = Option(System.getenv(prependTo)) - val currentEntries = currentOpt - .map(_.split(File.pathSeparator).toSet) - .getOrElse(Set.empty) - val additionalEntries = pythonLibraryPaths.filter(!currentEntries.contains(_)) - if additionalEntries.isEmpty then Map.empty - else { - val newValue = (additionalEntries.iterator ++ currentOpt.iterator).mkString( - File.pathSeparator - ) - Map(prependTo -> newValue) } } - val programNameEnv = - pythonExecutable.fold(Map.empty)(py => Map("SCALAPY_PYTHON_PROGRAMNAME" -> py)) - val extraEnv = libraryPathsEnv ++ programNameEnv ++ pythonExtraEnv - val maybeResult = withNativeLauncher( - builds, - mainClass, - logger - ) { launcher => - if showCommand then - Left( - extraEnv.toVector.sorted.map { case (k, v) => s"$k=$v" } ++ - Seq(launcher.toString) ++ - args + process.onExit().thenApply(_ => if os.exists(jsDest) then os.remove(jsDest)) + Right((process, None)) + } + } + value(res) + } + else + build.options.platform.value match { + case Platform.JS => + val esModule = + build.options.scalaJsOptions.moduleKindStr.exists(m => + m == "es" || m == "esmodule" ) - else { - val proc = Runner.runNative( - launcher = launcher.toIO, - args = args, - logger = logger, - allowExecve = effectiveAllowExecve, - extraEnv = extraEnv + + val linkerConfig = build.options.scalaJsOptions.linkerConfig(logger) + val jsDest = { + val delete = scratchDirOpt.isEmpty + scratchDirOpt.foreach(os.makeDir.all(_)) + os.temp( + dir = scratchDirOpt.orNull, + prefix = "main", + suffix = if esModule then ".mjs" else ".js", + deleteOnExit = delete ) - Right((proc, None)) } - } - value(maybeResult) - case Platform.JVM => - def fwd(s: String): String = s.replace('\\', '/') - def base(s: String): String = fwd(s).replaceAll(".*/", "") - runMode match { - case RunMode.Default => - val sourceFiles = builds.head.inputs.sourceFiles().map { - case s: ScalaFile => fwd(s.path.toString) - case s: Script => fwd(s.path.toString) - case s: MarkdownFile => fwd(s.path.toString) - case _: SbtFile => "" - case s: OnDisk => fwd(s.path.toString) - case null => "" - }.filter(_.nonEmpty).distinct - val sources = sourceFiles.mkString(File.pathSeparator) - val sourceNames = sourceFiles.map(base).mkString(File.pathSeparator) - - val baseJavaProps = build.options.javaOptions.javaOpts.toSeq.map(_.value.value) - ++ Seq(s"-Dscala.sources=$sources", s"-Dscala.source.names=$sourceNames") - val setupPython = build.options.notForBloopOptions.doSetupPython.getOrElse(false) - val (pythonJavaProps, pythonExtraEnv) = - if setupPython then { - val scalapyProps = value { - val python = value(createPythonInstance().orPythonDetectionError) - val propsOrError = python.scalapyProperties - logger.debug(s"Python Java properties: $propsOrError") - propsOrError.orPythonDetectionError - } - val props = scalapyProps.toVector.sorted.map { - case (k, v) => s"-D$k=$v" + val res = + Package.linkJs( + builds = builds, + dest = jsDest, + mainClassOpt = Some(mainClass), + addTestInitializer = false, + config = linkerConfig, + fullOpt = value(build.options.scalaJsOptions.fullOpt), + noOpt = build.options.scalaJsOptions.noOpt.getOrElse(false), + logger = logger, + scratchDirOpt = scratchDirOpt + ).map { outputPath => + val jsDom = build.options.scalaJsOptions.dom.getOrElse(false) + if showCommand then Left(Runner.jsCommand(outputPath.toIO, args, jsDom = jsDom)) + else { + val process = value { + Runner.runJs( + outputPath.toIO, + args, + logger, + allowExecve = effectiveAllowExecve, + jsDom = jsDom, + sourceMap = build.options.scalaJsOptions.emitSourceMaps, + esModule = esModule + ) } - // Putting the workspace in PYTHONPATH, see - // https://github.com/VirtusLab/scala-cli/pull/1616#issuecomment-1333283174 - // for context. - (props, pythonPathEnv(build.inputs.workspace)) + process.onExit().thenApply(_ => if os.exists(jsDest) then os.remove(jsDest)) + Right((process, None)) } - else - (Nil, Map.empty[String, String]) - val allJavaOpts = pythonJavaProps ++ baseJavaProps - if showCommand then - Left { - Runner.jvmCommand( - build.options.javaHome().value.javaCommand, - allJavaOpts, - builds.flatMap(_.fullClassPathMaybeAsJar(asJar)).distinct, - mainClass, - args, - extraEnv = pythonExtraEnv, - useManifest = build.options.notForBloopOptions.runWithManifest, - scratchDirOpt = scratchDirOpt + } + value(res) + case Platform.Native => + val setupPython = build.options.notForBloopOptions.doSetupPython.getOrElse(false) + val (pythonExecutable, pythonLibraryPaths, pythonExtraEnv) = + if setupPython then { + val (exec, libPaths) = value { + val python = value(createPythonInstance().orPythonDetectionError) + val pythonPropertiesOrError = for { + paths <- python.nativeLibraryPaths + executable <- python.executable + } yield (Some(executable), paths) + logger.debug( + s"Python executable and native library paths: $pythonPropertiesOrError" ) + pythonPropertiesOrError.orPythonDetectionError } - else { - val proc = Runner.runJvm( - javaCommand = build.options.javaHome().value.javaCommand, - javaArgs = allJavaOpts, - classPath = builds.flatMap(_.fullClassPathMaybeAsJar(asJar)).distinct, - mainClass = mainClass, - args = args, - logger = logger, - allowExecve = effectiveAllowExecve, - extraEnv = pythonExtraEnv, - useManifest = build.options.notForBloopOptions.runWithManifest, - scratchDirOpt = scratchDirOpt - ) - Right((proc, None)) + // Putting the workspace in PYTHONPATH, see + // https://github.com/VirtusLab/scala-cli/pull/1616#issuecomment-1333283174 + // for context. + (exec, libPaths, pythonPathEnv(builds.head.inputs.workspace)) } - case mode: RunMode.SparkSubmit => - value { - RunSpark.run( - builds = builds, - mainClass = mainClass, - args = args, - submitArgs = mode.submitArgs, - logger = logger, - allowExecve = effectiveAllowExecve, - showCommand = showCommand, - scratchDirOpt = scratchDirOpt - ) + else + (None, Nil, Map()) + // seems conda doesn't add the lib directory to LD_LIBRARY_PATH (see conda/conda#308), + // which prevents apps from finding libpython for example, so we update it manually here + val libraryPathsEnv = + if pythonLibraryPaths.isEmpty then Map.empty + else { + val prependTo = + if Properties.isWin then EnvVar.Misc.path.name + else if Properties.isMac then EnvVar.Misc.dyldLibraryPath.name + else EnvVar.Misc.ldLibraryPath.name + val currentOpt = Option(System.getenv(prependTo)) + val currentEntries = currentOpt + .map(_.split(File.pathSeparator).toSet) + .getOrElse(Set.empty) + val additionalEntries = pythonLibraryPaths.filter(!currentEntries.contains(_)) + if additionalEntries.isEmpty then Map.empty + else { + val newValue = (additionalEntries.iterator ++ currentOpt.iterator).mkString( + File.pathSeparator + ) + Map(prependTo -> newValue) + } } - case mode: RunMode.StandaloneSparkSubmit => - value { - RunSpark.runStandalone( - builds = builds, - mainClass = mainClass, - args = args, - submitArgs = mode.submitArgs, - logger = logger, - allowExecve = effectiveAllowExecve, - showCommand = showCommand, - scratchDirOpt = scratchDirOpt + val programNameEnv = + pythonExecutable.fold(Map.empty)(py => Map("SCALAPY_PYTHON_PROGRAMNAME" -> py)) + val extraEnv = libraryPathsEnv ++ programNameEnv ++ pythonExtraEnv + val maybeResult = withNativeLauncher( + builds, + mainClass, + logger + ) { launcher => + if showCommand then + Left( + extraEnv.toVector.sorted.map { case (k, v) => s"$k=$v" } ++ + Seq(launcher.toString) ++ + args ) - } - case RunMode.HadoopJar => - value { - RunHadoop.run( - builds = builds, - mainClass = mainClass, + else { + val proc = Runner.runNative( + launcher = launcher.toIO, args = args, logger = logger, allowExecve = effectiveAllowExecve, - showCommand = showCommand, - scratchDirOpt = scratchDirOpt + extraEnv = extraEnv ) + Right((proc, None)) } - } - } + } + value(maybeResult) + case Platform.JVM => + def fwd(s: String): String = s.replace('\\', '/') + def base(s: String): String = fwd(s).replaceAll(".*/", "") + runMode match { + case RunMode.Default => + val sourceFiles = builds.head.inputs.sourceFiles().map { + case s: ScalaFile => fwd(s.path.toString) + case s: Script => fwd(s.path.toString) + case s: MarkdownFile => fwd(s.path.toString) + case _: SbtFile => "" + case s: OnDisk => fwd(s.path.toString) + case null => "" + }.filter(_.nonEmpty).distinct + val sources = sourceFiles.mkString(File.pathSeparator) + val sourceNames = sourceFiles.map(base).mkString(File.pathSeparator) + + val baseJavaProps = build.options.javaOptions.javaOpts.toSeq.map(_.value.value) + ++ Seq(s"-Dscala.sources=$sources", s"-Dscala.source.names=$sourceNames") + val setupPython = + build.options.notForBloopOptions.doSetupPython.getOrElse(false) + val (pythonJavaProps, pythonExtraEnv) = + if setupPython then { + val scalapyProps = value { + val python = value(createPythonInstance().orPythonDetectionError) + val propsOrError = python.scalapyProperties + logger.debug(s"Python Java properties: $propsOrError") + propsOrError.orPythonDetectionError + } + val props = scalapyProps.toVector.sorted.map { + case (k, v) => s"-D$k=$v" + } + // Putting the workspace in PYTHONPATH, see + // https://github.com/VirtusLab/scala-cli/pull/1616#issuecomment-1333283174 + // for context. + (props, pythonPathEnv(build.inputs.workspace)) + } + else + (Nil, Map.empty[String, String]) + val allJavaOpts = pythonJavaProps ++ baseJavaProps + if showCommand then + Left { + Runner.jvmCommand( + build.options.javaHome().value.javaCommand, + allJavaOpts, + builds.flatMap(_.fullClassPathMaybeAsJar(asJar)).distinct, + mainClass, + args, + extraEnv = pythonExtraEnv, + useManifest = build.options.notForBloopOptions.runWithManifest, + scratchDirOpt = scratchDirOpt + ) + } + else { + val proc = Runner.runJvm( + javaCommand = build.options.javaHome().value.javaCommand, + javaArgs = allJavaOpts, + classPath = builds.flatMap(_.fullClassPathMaybeAsJar(asJar)).distinct, + mainClass = mainClass, + args = args, + logger = logger, + allowExecve = effectiveAllowExecve, + extraEnv = pythonExtraEnv, + useManifest = build.options.notForBloopOptions.runWithManifest, + scratchDirOpt = scratchDirOpt + ) + Right((proc, None)) + } + case mode: RunMode.SparkSubmit => + value { + RunSpark.run( + builds = builds, + mainClass = mainClass, + args = args, + submitArgs = mode.submitArgs, + logger = logger, + allowExecve = effectiveAllowExecve, + showCommand = showCommand, + scratchDirOpt = scratchDirOpt + ) + } + case mode: RunMode.StandaloneSparkSubmit => + value { + RunSpark.runStandalone( + builds = builds, + mainClass = mainClass, + args = args, + submitArgs = mode.submitArgs, + logger = logger, + allowExecve = effectiveAllowExecve, + showCommand = showCommand, + scratchDirOpt = scratchDirOpt + ) + } + case RunMode.HadoopJar => + value { + RunHadoop.run( + builds = builds, + mainClass = mainClass, + args = args, + logger = logger, + allowExecve = effectiveAllowExecve, + showCommand = showCommand, + scratchDirOpt = scratchDirOpt + ) + } + } + } } } .sequence diff --git a/modules/cli/src/main/scala/scala/cli/commands/shared/HelpGroupOptions.scala b/modules/cli/src/main/scala/scala/cli/commands/shared/HelpGroupOptions.scala index ef012e22f0..76d78dcb19 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/shared/HelpGroupOptions.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/shared/HelpGroupOptions.scala @@ -49,7 +49,13 @@ case class HelpGroupOptions( @Name("fmtHelp") @Tag(tags.implementation) @Tag(tags.inShortHelp) - helpScalafmt: Boolean = false + helpScalafmt: Boolean = false, + @Group(HelpGroup.Help.toString) + @HelpMessage("Show options for WebAssembly") + @Name("wasmHelp") + @Tag(tags.implementation) + @Tag(tags.inShortHelp) + helpWasm: Boolean = false ) { private def printHelpWithGroup(help: Help[?], helpFormat: HelpFormat, group: String): Nothing = { @@ -68,6 +74,7 @@ case class HelpGroupOptions( def maybePrintGroupHelp(help: Help[?], helpFormat: HelpFormat): Unit = { if (helpJs) printHelpWithGroup(help, helpFormat, HelpGroup.ScalaJs.toString) else if (helpNative) printHelpWithGroup(help, helpFormat, HelpGroup.ScalaNative.toString) + else if (helpWasm) printHelpWithGroup(help, helpFormat, HelpGroup.Wasm.toString) } } diff --git a/modules/cli/src/main/scala/scala/cli/commands/shared/HelpGroups.scala b/modules/cli/src/main/scala/scala/cli/commands/shared/HelpGroups.scala index 8f6099a324..c943c3c904 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/shared/HelpGroups.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/shared/HelpGroups.scala @@ -17,7 +17,7 @@ enum HelpGroup: Scala, ScalaJs, ScalaNative, Secret, Signing, SuppressWarnings, SourceGenerator, Test, Uninstall, Update, - Watch, Windows, + Wasm, Watch, Windows, Version override def toString: String = this match @@ -30,6 +30,7 @@ enum HelpGroup: case SuppressWarnings => "Suppress warnings" case SourceGenerator => "Source generator" case ProjectVersion => "Project version" + case Wasm => "WebAssembly" case e => e.productPrefix enum HelpCommandGroup: diff --git a/modules/cli/src/main/scala/scala/cli/commands/shared/ScalaJsOptions.scala b/modules/cli/src/main/scala/scala/cli/commands/shared/ScalaJsOptions.scala index 96045c4eec..2a8aae990e 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/shared/ScalaJsOptions.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/shared/ScalaJsOptions.scala @@ -59,11 +59,16 @@ final case class ScalaJsOptions( @HelpMessage("Enable jsdom") jsDom: Option[Boolean] = None, - @Group(HelpGroup.ScalaJs.toString) + @Group(HelpGroup.Wasm.toString) @Tag(tags.experimental) - @HelpMessage("Emit WASM") + @HelpMessage("Enable Wasm output (Scala.js Wasm backend). Uses Node.js by default. To show more options for Wasm pass `--help-wasm`") jsEmitWasm: Option[Boolean] = None, + @Group(HelpGroup.Wasm.toString) + @Tag(tags.experimental) + @HelpMessage("Wasm runtime to use: node (default), deno, bun") + jsWasmRuntime: Option[String] = None, + @Group(HelpGroup.ScalaJs.toString) @Tag(tags.should) @HelpMessage("A header that will be added at the top of generated .js files") diff --git a/modules/cli/src/main/scala/scala/cli/commands/shared/SharedOptions.scala b/modules/cli/src/main/scala/scala/cli/commands/shared/SharedOptions.scala index 71dd29d1f3..d81a8b8f48 100644 --- a/modules/cli/src/main/scala/scala/cli/commands/shared/SharedOptions.scala +++ b/modules/cli/src/main/scala/scala/cli/commands/shared/SharedOptions.scala @@ -247,26 +247,40 @@ final case class SharedOptions( ) .getOrElse(true) - private def scalaJsOptions(opts: ScalaJsOptions): options.ScalaJsOptions = { + private def scalaJsOptions( + opts: ScalaJsOptions + ): Either[BuildException, options.ScalaJsOptions] = { import opts._ - options.ScalaJsOptions( - version = jsVersion, - mode = options.ScalaJsMode(jsMode), - moduleKindStr = jsModuleKind, - checkIr = jsCheckIr, - emitSourceMaps = jsEmitSourceMaps, - sourceMapsDest = jsSourceMapsPath.filter(_.trim.nonEmpty).map(os.Path(_, Os.pwd)), - dom = jsDom, - header = jsHeader, - allowBigIntsForLongs = jsAllowBigIntsForLongs, - avoidClasses = jsAvoidClasses, - avoidLetsAndConsts = jsAvoidLetsAndConsts, - moduleSplitStyleStr = jsModuleSplitStyle, - smallModuleForPackage = jsSmallModuleForPackage, - esVersionStr = jsEsVersion, - noOpt = jsNoOpt, - remapEsModuleImportMap = jsEsModuleImportMap.filter(_.trim.nonEmpty).map(os.Path(_, Os.pwd)), - jsEmitWasm = jsEmitWasm.getOrElse(false) + val parsedWasmRuntime = jsWasmRuntime.fold( + Right(options.WasmRuntime.default): Either[BuildException, options.WasmRuntime] + ) { rt => + options.WasmRuntime.parse(rt).toRight { + val validValues = options.WasmRuntime.all.map(_.name).mkString(", ") + new scala.build.errors.UnrecognizedWasmRuntimeError(rt, validValues) + } + } + parsedWasmRuntime.map(wasmRuntime => + options.ScalaJsOptions( + version = jsVersion, + mode = options.ScalaJsMode(jsMode), + moduleKindStr = jsModuleKind, + checkIr = jsCheckIr, + emitSourceMaps = jsEmitSourceMaps, + sourceMapsDest = jsSourceMapsPath.filter(_.trim.nonEmpty).map(os.Path(_, Os.pwd)), + dom = jsDom, + header = jsHeader, + allowBigIntsForLongs = jsAllowBigIntsForLongs, + avoidClasses = jsAvoidClasses, + avoidLetsAndConsts = jsAvoidLetsAndConsts, + moduleSplitStyleStr = jsModuleSplitStyle, + smallModuleForPackage = jsSmallModuleForPackage, + esVersionStr = jsEsVersion, + noOpt = jsNoOpt, + remapEsModuleImportMap = + jsEsModuleImportMap.filter(_.trim.nonEmpty).map(os.Path(_, Os.pwd)), + jsEmitWasm = jsEmitWasm.getOrElse(false), + wasmRuntime = wasmRuntime + ) ) } @@ -335,21 +349,27 @@ final case class SharedOptions( case _ => } val parsedPlatform = platform.map(Platform.normalize).flatMap(Platform.parse) - val platformOpt = value { - (parsedPlatform, js.js, native.native) match { - case (Some(p: Platform.JS.type), _, false) => Right(Some(p)) - case (Some(p: Platform.Native.type), false, _) => Right(Some(p)) - case (Some(p: Platform.JVM.type), false, false) => Right(Some(p)) - case (Some(p), _, _) => - val jsSeq = if (js.js) Seq(Platform.JS) else Seq.empty + // Wasm mode requires Scala.js platform for compilation + val wasmEnabled = js.jsEmitWasm.getOrElse(false) || js.jsWasmRuntime.isDefined + val platformOpt = value { + (parsedPlatform, js.js, native.native, wasmEnabled) match { + case (Some(p: Platform.JS.type), _, false, _) => Right(Some(p)) + case (Some(p: Platform.Native.type), false, _, false) => Right(Some(p)) + case (Some(p: Platform.JVM.type), false, false, false) => Right(Some(p)) + case (Some(p), _, _, _) => + val jsSeq = if (js.js || wasmEnabled) Seq(Platform.JS) else Seq.empty val nativeSeq = if (native.native) Seq(Platform.Native) else Seq.empty val platformsSeq = Seq(p) ++ jsSeq ++ nativeSeq Left(new AmbiguousPlatformError(platformsSeq.distinct.map(_.toString))) - case (_, true, true) => + case (_, true, true, _) => Left(new AmbiguousPlatformError(Seq(Platform.JS.toString, Platform.Native.toString))) - case (_, true, _) => Right(Some(Platform.JS)) - case (_, _, true) => Right(Some(Platform.Native)) - case _ => Right(None) + case (_, _, true, true) => + Left(new AmbiguousPlatformError(Seq(Platform.Native.toString, "Wasm (requires JS)"))) + case (_, true, _, _) => Right(Some(Platform.JS)) + case (_, _, _, true) => + Right(Some(Platform.JS)) // Wasm requires JS compilation (Scala.js Wasm backend) + case (_, _, true, _) => Right(Some(Platform.Native)) + case _ => Right(None) } } val (assumedSourceJars, extraRegularJarsAndClasspath) = @@ -434,7 +454,7 @@ final case class SharedOptions( scriptOptions = scala.build.options.ScriptOptions( forceObjectWrapper = objectWrapper ), - scalaJsOptions = scalaJsOptions(js), + scalaJsOptions = value(scalaJsOptions(js)), scalaNativeOptions = snOpts, javaOptions = value(scala.cli.commands.util.JvmUtils.javaOptions(jvm)), jmhOptions = scala.build.options.JmhOptions( diff --git a/modules/core/src/main/scala/scala/build/errors/BunNotFoundError.scala b/modules/core/src/main/scala/scala/build/errors/BunNotFoundError.scala new file mode 100644 index 0000000000..a67aaa586b --- /dev/null +++ b/modules/core/src/main/scala/scala/build/errors/BunNotFoundError.scala @@ -0,0 +1,5 @@ +package scala.build.errors + +final class BunNotFoundError extends BuildException( + "Bun was not found on the PATH. Install Bun from https://bun.sh/ or use --js-wasm-runtime node" + ) diff --git a/modules/core/src/main/scala/scala/build/errors/DenoNotFoundError.scala b/modules/core/src/main/scala/scala/build/errors/DenoNotFoundError.scala new file mode 100644 index 0000000000..cf9dc42b78 --- /dev/null +++ b/modules/core/src/main/scala/scala/build/errors/DenoNotFoundError.scala @@ -0,0 +1,5 @@ +package scala.build.errors + +final class DenoNotFoundError extends BuildException( + "Deno was not found on the PATH. Install Deno from https://deno.land/ or use --js-wasm-runtime node" + ) diff --git a/modules/core/src/main/scala/scala/build/errors/UnrecognizedWasmRuntimeError.scala b/modules/core/src/main/scala/scala/build/errors/UnrecognizedWasmRuntimeError.scala new file mode 100644 index 0000000000..2d7b01db36 --- /dev/null +++ b/modules/core/src/main/scala/scala/build/errors/UnrecognizedWasmRuntimeError.scala @@ -0,0 +1,4 @@ +package scala.build.errors + +class UnrecognizedWasmRuntimeError(runtime: String, validValues: String) + extends BuildException(s"Unrecognized Wasm runtime: '$runtime'. Valid values: $validValues") diff --git a/modules/directives/src/main/scala/scala/build/preprocessing/directives/Wasm.scala b/modules/directives/src/main/scala/scala/build/preprocessing/directives/Wasm.scala new file mode 100644 index 0000000000..e465f799cc --- /dev/null +++ b/modules/directives/src/main/scala/scala/build/preprocessing/directives/Wasm.scala @@ -0,0 +1,57 @@ +package scala.build.preprocessing.directives + +import scala.build.Positioned +import scala.build.directives.* +import scala.build.errors.{BuildException, UnrecognizedWasmRuntimeError} +import scala.build.options.{BuildOptions, Platform, ScalaJsOptions, ScalaOptions, WasmRuntime} +import scala.cli.commands.SpecificationLevel + +@DirectiveGroupName("Wasm options") +@DirectiveExamples("//> using wasm") +@DirectiveExamples("//> using wasmRuntime node") +@DirectiveExamples("//> using wasmRuntime deno") +@DirectiveExamples("//> using wasmRuntime bun") +@DirectiveUsage( + "//> using wasm|wasmRuntime _value_", + """ + |`//> using wasm` _true|false_ + | + |`//> using wasm` + | + |`//> using wasmRuntime` _node|deno|bun_ + |""".stripMargin +) +@DirectiveDescription("Add WebAssembly options") +@DirectiveLevel(SpecificationLevel.EXPERIMENTAL) +final case class Wasm( + wasm: Option[Boolean] = None, + wasmRuntime: Option[String] = None +) extends HasBuildOptions { + def buildOptions: Either[BuildException, BuildOptions] = { + val parsedRuntime = + wasmRuntime.fold(Right(WasmRuntime.default): Either[BuildException, WasmRuntime]) { rt => + WasmRuntime.parse(rt).toRight { + val validValues = WasmRuntime.all.map(_.name).mkString(", ") + new UnrecognizedWasmRuntimeError(rt, validValues) + } + } + parsedRuntime.map { runtime => + val wasmEnabled = wasm.getOrElse(false) || wasmRuntime.isDefined + // Scala.js Wasm backend requires JS platform. When --platform native is also + // specified alongside --wasm, an AmbiguousPlatformError is raised in platform resolution. + val scalaOptions = + if (wasmEnabled) + ScalaOptions(platform = Some(Positioned.none(Platform.JS))) + else + ScalaOptions() + BuildOptions( + scalaOptions = scalaOptions, + scalaJsOptions = ScalaJsOptions(jsEmitWasm = wasmEnabled, wasmRuntime = runtime) + ) + } + } +} + +object Wasm { + val handler: DirectiveHandler[Wasm] = DirectiveHandler.derive +} diff --git a/modules/integration/src/test/scala/scala/cli/integration/RunScalaJsTestDefinitions.scala b/modules/integration/src/test/scala/scala/cli/integration/RunScalaJsTestDefinitions.scala index 4745787924..ba747a7a8b 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/RunScalaJsTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/RunScalaJsTestDefinitions.scala @@ -325,11 +325,284 @@ trait RunScalaJsTestDefinitions { this: RunTestDefinitions => .call(cwd = root).out.trim() val path = absOutDir / "main.wasm" expect(os.exists(path)) + } + } + + test("Run with --js-emit-wasm flag") { + val inputs = TestInputs( + os.rel / "Hello.scala" -> + """object Hello { + | def main(args: Array[String]): Unit = println("Hello from WASM!") + |} + |""".stripMargin + ) + inputs.fromRoot { root => + val output = os.proc( + TestUtil.cli, + "--power", + "run", + "Hello.scala", + "--js-emit-wasm", + "--js-wasm-runtime", + "node", + extraOptions + ).call(cwd = root).out.trim() + expect(output == "Hello from WASM!") + } + } - // TODO : Run WASM using node. Requires node 22. + test("Run with --js-emit-wasm uses Node.js by default") { + val inputs = TestInputs( + os.rel / "Hello.scala" -> + """object Hello { + | def main(args: Array[String]): Unit = println("Hello default WASM!") + |} + |""".stripMargin + ) + inputs.fromRoot { root => + val output = os.proc( + TestUtil.cli, + "--power", + "run", + "Hello.scala", + "--js-emit-wasm", + extraOptions + ).call(cwd = root).out.trim() + expect(output == "Hello default WASM!") } } + test("Run with //> using wasm directive") { + val inputs = TestInputs( + os.rel / "Hello.scala" -> + """//> using wasm + |//> using wasmRuntime node + |object Hello { + | def main(args: Array[String]): Unit = println("Hello from WASM directive!") + |} + |""".stripMargin + ) + inputs.fromRoot { root => + val output = os.proc( + TestUtil.cli, + "--power", + "run", + "Hello.scala", + extraOptions + ).call(cwd = root).out.trim() + expect(output == "Hello from WASM directive!") + } + } + + test("Wasm passes arguments to program") { + // Scala.js always passes an empty Array[String] to main(args), + // so we must read process.argv directly via JS interop. + val inputs = TestInputs( + os.rel / "Hello.scala" -> + """import scala.scalajs.js + |import scala.scalajs.js.Dynamic.global + |object Hello { + | def main(args: Array[String]): Unit = { + | val argv = global.process.argv.asInstanceOf[js.Array[String]].drop(2).toSeq + | println(argv.mkString(" ")) + | } + |} + |""".stripMargin + ) + inputs.fromRoot { root => + val output = os.proc( + TestUtil.cli, + "--power", + "run", + "Hello.scala", + "--js-emit-wasm", + "--js-wasm-runtime", + "node", + extraOptions, + "--", + "foo", + "bar", + "baz" + ).call(cwd = root).out.trim() + expect(output == "foo bar baz") + } + } + + if (TestUtil.fromPath("deno").isDefined) + test("Run with --js-wasm-runtime deno") { + val inputs = TestInputs( + os.rel / "Hello.scala" -> + """object Hello { + | def main(args: Array[String]): Unit = println("Hello from Deno WASM!") + |} + |""".stripMargin + ) + inputs.fromRoot { root => + val output = os.proc( + TestUtil.cli, + "--power", + "run", + "Hello.scala", + "--js-emit-wasm", + "--js-wasm-runtime", + "deno", + extraOptions + ).call(cwd = root).out.trim() + expect(output == "Hello from Deno WASM!") + } + } + + if (TestUtil.fromPath("bun").isDefined) + test("Run with --js-wasm-runtime bun") { + val inputs = TestInputs( + os.rel / "Hello.scala" -> + """object Hello { + | def main(args: Array[String]): Unit = println("Hello from Bun WASM!") + |} + |""".stripMargin + ) + inputs.fromRoot { root => + val output = os.proc( + TestUtil.cli, + "--power", + "run", + "Hello.scala", + "--js-emit-wasm", + "--js-wasm-runtime", + "bun", + extraOptions + ).call(cwd = root).out.trim() + expect(output == "Hello from Bun WASM!") + } + } + + test("Wasm multiple source files") { + val inputs = TestInputs( + os.rel / "Greeter.scala" -> + """trait Greeter { + | def greet(name: String): String + |} + | + |object EnthusiasticGreeter extends Greeter { + | def greet(name: String): String = s"Hello, $name!" + |} + |""".stripMargin, + os.rel / "Main.scala" -> + """object Main { + | def main(args: Array[String]): Unit = { + | println(EnthusiasticGreeter.greet("WASM")) + | } + |} + |""".stripMargin + ) + inputs.fromRoot { root => + val output = os.proc( + TestUtil.cli, + "--power", + "run", + "Main.scala", + "Greeter.scala", + "--js-emit-wasm", + "--js-wasm-runtime", + "node", + extraOptions + ).call(cwd = root).out.trim() + expect(output == "Hello, WASM!") + } + } + + test("Wasm exception handling") { + val inputs = TestInputs( + os.rel / "Hello.scala" -> + """object Hello { + | def riskyOp(x: Int): Int = + | if (x == 0) throw new IllegalArgumentException("zero!") + | else 100 / x + | + | def main(args: Array[String]): Unit = { + | val ok = try riskyOp(5).toString catch { case e: Exception => s"err: ${e.getMessage}" } + | val caught = try riskyOp(0).toString catch { case e: Exception => s"caught: ${e.getMessage}" } + | println(ok) + | println(caught) + | } + |} + |""".stripMargin + ) + inputs.fromRoot { root => + val output = os.proc( + TestUtil.cli, + "--power", + "run", + "Hello.scala", + "--js-emit-wasm", + "--js-wasm-runtime", + "node", + extraOptions + ).call(cwd = root).out.trim() + val lines = output.linesIterator.toSeq + expect(lines.contains("20")) + expect(lines.contains("caught: zero!")) + } + } + + test("Wasm collections and higher-order functions") { + val inputs = TestInputs( + os.rel / "Hello.scala" -> + """object Hello { + | def fib(n: Int): Int = if (n <= 1) n else fib(n - 1) + fib(n - 2) + | + | def main(args: Array[String]): Unit = { + | val fibs = (0 to 7).map(fib).toList + | println(fibs.mkString(", ")) + | println(fibs.filter(_ % 2 == 0).sum) + | println(fibs.foldLeft(0)(_ + _)) + | } + |} + |""".stripMargin + ) + inputs.fromRoot { root => + val output = os.proc( + TestUtil.cli, + "--power", + "run", + "Hello.scala", + "--js-emit-wasm", + "--js-wasm-runtime", + "node", + extraOptions + ).call(cwd = root).out.trim() + val lines = output.linesIterator.toSeq + expect(lines.contains("0, 1, 1, 2, 3, 5, 8, 13")) + expect(lines.contains("10")) // 0 + 2 + 8 = 10 + expect(lines.contains("33")) // sum of first 8 fibs + } + } + + if (!actualScalaVersion.startsWith("2")) + test("Wasm @main annotation (Scala 3)") { + // Scala.js always passes empty args to main, so @main with parameters won't work. + // Test @main without parameters instead. + val inputs = TestInputs( + os.rel / "Hello.scala" -> + """@main def hello(): Unit = + | println("Hello, Scala3!") + |""".stripMargin + ) + inputs.fromRoot { root => + val output = os.proc( + TestUtil.cli, + "--power", + "run", + "Hello.scala", + "--js-emit-wasm", + "--js-wasm-runtime", + "node", + extraOptions + ).call(cwd = root).out.trim() + expect(output == "Hello, Scala3!") + } + } + test("remap imports directive") { val importmapFile = "importmap.json" val outDir = "out" diff --git a/modules/options/src/main/scala/scala/build/options/ScalaJsOptions.scala b/modules/options/src/main/scala/scala/build/options/ScalaJsOptions.scala index ccaef26c33..f162de8526 100644 --- a/modules/options/src/main/scala/scala/build/options/ScalaJsOptions.scala +++ b/modules/options/src/main/scala/scala/build/options/ScalaJsOptions.scala @@ -26,7 +26,8 @@ final case class ScalaJsOptions( smallModuleForPackage: List[String] = Nil, esVersionStr: Option[String] = None, noOpt: Option[Boolean] = None, - jsEmitWasm: Boolean = false + jsEmitWasm: Boolean = false, + wasmRuntime: WasmRuntime = WasmRuntime.default ) { def fullOpt: Either[UnrecognizedJsOptModeError, Boolean] = if (mode.isValid) @@ -154,8 +155,13 @@ final case class ScalaJsOptions( esVersion = esVersion(logger) ) + if (jsEmitWasm && moduleKindStr.isDefined) + logger.message( + s"[${Console.YELLOW}warn${Console.RESET}] Wasm mode forces ES module output; --js-module-kind is ignored" + ) ScalaJsLinkerConfig( - moduleKind = moduleKind(logger), + moduleKind = + if (jsEmitWasm) ScalaJsLinkerConfig.ModuleKind.ESModule else moduleKind(logger), checkIR = checkIr.getOrElse(false), // meh sourceMap = emitSourceMaps, moduleSplitStyle = moduleSplitStyle(logger), diff --git a/modules/options/src/main/scala/scala/build/options/WasmRuntime.scala b/modules/options/src/main/scala/scala/build/options/WasmRuntime.scala new file mode 100644 index 0000000000..5afae2d49f --- /dev/null +++ b/modules/options/src/main/scala/scala/build/options/WasmRuntime.scala @@ -0,0 +1,38 @@ +package scala.build.options + +import java.util.Locale + +/** Represents available WebAssembly runtimes for execution. + * + * JS-based runtimes (work now with Scala.js Wasm backend): + * - Node: Uses Node.js (V8 engine) with JavaScript loader + * - Deno: Uses Deno (V8 engine) with ES module support + * - Bun: Uses Bun (JavaScriptCore engine) with ES module support + */ +sealed abstract class WasmRuntime(val name: String) + +object WasmRuntime { + case object Node extends WasmRuntime("node") + case object Deno extends WasmRuntime("deno") + case object Bun extends WasmRuntime("bun") + + val all: Seq[WasmRuntime] = Seq(Node, Deno, Bun) + + def default: WasmRuntime = Node + + def parse(s: String): Option[WasmRuntime] = + s.trim.toLowerCase(Locale.ROOT) match { + case "node" | "nodejs" => Some(Node) + case "deno" => Some(Deno) + case "bun" => Some(Bun) + case _ => None + } + + implicit val hashedType: HashedType[WasmRuntime] = runtime => runtime.name + + implicit val hasHashData: HasHashData[WasmRuntime] = HasHashData.asIs + + implicit val monoid: ConfigMonoid[WasmRuntime] = ConfigMonoid.instance[WasmRuntime](default) { + (a, b) => if (b == default) a else b + } +} diff --git a/website/docs/reference/cli-options.md b/website/docs/reference/cli-options.md index b817707754..b462cbc84d 100644 --- a/website/docs/reference/cli-options.md +++ b/website/docs/reference/cli-options.md @@ -591,6 +591,12 @@ Aliases: `--fmt-help`, `--help-fmt`, `--scalafmt-help` Show options for Scalafmt +### `--help-wasm` + +Aliases: `--wasm-help` + +Show options for WebAssembly + ## Install completions options Available in commands: @@ -1373,7 +1379,11 @@ Enable jsdom ### `--js-emit-wasm` -Emit WASM +Enable Wasm output (Scala.js Wasm backend). Uses Node.js by default. To show more options for Wasm pass `--help-wasm` + +### `--js-wasm-runtime` + +Wasm runtime to use: node (default), deno, bun ### `--js-header` diff --git a/website/docs/reference/directives.md b/website/docs/reference/directives.md index d12254bca1..02dfb2f135 100644 --- a/website/docs/reference/directives.md +++ b/website/docs/reference/directives.md @@ -707,6 +707,27 @@ Use a toolkit as dependency (not supported in Scala 2.12), 'default' version for `//> using test.toolkit default` +### Wasm options + +Add WebAssembly options + + +`//> using wasm` _true|false_ + +`//> using wasm` + +`//> using wasmRuntime` _node|deno|bun_ + + +#### Examples +`//> using wasm` + +`//> using wasmRuntime node` + +`//> using wasmRuntime deno` + +`//> using wasmRuntime bun` + ### Watch additional inputs Watch additional files or directories when using watch mode diff --git a/website/docs/reference/scala-command/cli-options.md b/website/docs/reference/scala-command/cli-options.md index 74cba5642f..91916db057 100644 --- a/website/docs/reference/scala-command/cli-options.md +++ b/website/docs/reference/scala-command/cli-options.md @@ -498,6 +498,14 @@ Aliases: `--fmt-help`, `--help-fmt`, `--scalafmt-help` Show options for Scalafmt +### `--help-wasm` + +Aliases: `--wasm-help` + +`IMPLEMENTATION specific` per Scala Runner specification + +Show options for WebAssembly + ## Install completions options Available in commands: diff --git a/website/docs/reference/scala-command/runner-specification.md b/website/docs/reference/scala-command/runner-specification.md index d0a3a55050..4be6903a23 100644 --- a/website/docs/reference/scala-command/runner-specification.md +++ b/website/docs/reference/scala-command/runner-specification.md @@ -636,6 +636,12 @@ Show options for Scalafmt Aliases: `--help-fmt` ,`--scalafmt-help` ,`--fmt-help` +**--help-wasm** + +Show options for WebAssembly + +Aliases: `--wasm-help` + **--strict-bloop-json-check** @@ -1445,6 +1451,12 @@ Show options for Scalafmt Aliases: `--help-fmt` ,`--scalafmt-help` ,`--fmt-help` +**--help-wasm** + +Show options for WebAssembly + +Aliases: `--wasm-help` + **--strict-bloop-json-check** @@ -2070,6 +2082,12 @@ Show options for Scalafmt Aliases: `--help-fmt` ,`--scalafmt-help` ,`--fmt-help` +**--help-wasm** + +Show options for WebAssembly + +Aliases: `--wasm-help` + **--strict-bloop-json-check** @@ -2735,6 +2753,12 @@ Show options for Scalafmt Aliases: `--help-fmt` ,`--scalafmt-help` ,`--fmt-help` +**--help-wasm** + +Show options for WebAssembly + +Aliases: `--wasm-help` + **--strict-bloop-json-check** @@ -3399,6 +3423,12 @@ Show options for Scalafmt Aliases: `--help-fmt` ,`--scalafmt-help` ,`--fmt-help` +**--help-wasm** + +Show options for WebAssembly + +Aliases: `--wasm-help` + **--strict-bloop-json-check** @@ -4021,6 +4051,12 @@ Show options for Scalafmt Aliases: `--help-fmt` ,`--scalafmt-help` ,`--fmt-help` +**--help-wasm** + +Show options for WebAssembly + +Aliases: `--wasm-help` + **--strict-bloop-json-check** @@ -4721,6 +4757,12 @@ Show options for Scalafmt Aliases: `--help-fmt` ,`--scalafmt-help` ,`--fmt-help` +**--help-wasm** + +Show options for WebAssembly + +Aliases: `--wasm-help` + **--strict-bloop-json-check** @@ -5431,6 +5473,12 @@ Show options for Scalafmt Aliases: `--help-fmt` ,`--scalafmt-help` ,`--fmt-help` +**--help-wasm** + +Show options for WebAssembly + +Aliases: `--wasm-help` + **--strict-bloop-json-check** @@ -6424,6 +6472,12 @@ Show options for Scalafmt Aliases: `--help-fmt` ,`--scalafmt-help` ,`--fmt-help` +**--help-wasm** + +Show options for WebAssembly + +Aliases: `--wasm-help` + **--strict-bloop-json-check**