From cf1e65ad38fd51feeaf688143a3869bd6e749c60 Mon Sep 17 00:00:00 2001 From: "v.karamyshev" Date: Fri, 6 Mar 2026 11:51:17 +0500 Subject: [PATCH] Add WASM support: --wasm flag with Node.js and Deno runtimes Implements scala-cli issue #3316: integrate WebAssembly with Scala CLI. - `--wasm` CLI flag and `//> using wasm` directive to enable WASM output - `--wasm-runtime ` option and `//> using wasmRuntime` directive Supported values: node (default), deno, wasmtime, wasmedge, wasmer - `--deno-version`, `--wasmtime-version`, `--wasmer-version` options and corresponding directives for pinning runtime versions - **Node.js** (default): runs Scala.js WASM output with `--experimental-wasm-exnref` flag, requires Node.js >= 22 - **Deno**: runs Scala.js WASM output; if not found on PATH, downloads from GitHub releases via Coursier cache - **Wasmtime / WasmEdge / Wasmer**: return UnsupportedWasmRuntimeError pending upstream Scala.js standalone WASM support (scala-js/scala-js#4991) --- build.mill | 2 + .../scala/scala/build/internal/Runner.scala | 146 ++++- .../DirectivesPreprocessingUtils.scala | 3 +- .../scala/cli/commands/fix/BuiltInRules.scala | 1 + .../scala/scala/cli/commands/run/Run.scala | 514 +++++++++++------- .../cli/commands/shared/HelpGroups.scala | 3 +- .../cli/commands/shared/SharedOptions.scala | 41 +- .../cli/commands/shared/WasmOptions.scala | 32 ++ .../cli/internal/WasmRuntimeDownloader.scala | 104 ++++ .../build/errors/DenoNotFoundError.scala | 5 + .../errors/UnsupportedWasmRuntimeError.scala | 3 + .../build/preprocessing/directives/Wasm.scala | 52 ++ .../RunScalaJsTestDefinitions.scala | 277 +++++++++- .../scala/build/options/BuildOptions.scala | 1 + .../scala/build/options/WasmOptions.scala | 26 + .../scala/build/options/WasmRuntime.scala | 54 ++ website/docs/reference/cli-options.md | 26 + website/docs/reference/directives.md | 21 + 18 files changed, 1086 insertions(+), 225 deletions(-) create mode 100644 modules/cli/src/main/scala/scala/cli/commands/shared/WasmOptions.scala create mode 100644 modules/cli/src/main/scala/scala/cli/internal/WasmRuntimeDownloader.scala create mode 100644 modules/core/src/main/scala/scala/build/errors/DenoNotFoundError.scala create mode 100644 modules/core/src/main/scala/scala/build/errors/UnsupportedWasmRuntimeError.scala create mode 100644 modules/directives/src/main/scala/scala/build/preprocessing/directives/Wasm.scala create mode 100644 modules/options/src/main/scala/scala/build/options/WasmOptions.scala create mode 100644 modules/options/src/main/scala/scala/build/options/WasmRuntime.scala diff --git a/build.mill b/build.mill index f98bd81673..5fe6eacd96 100644 --- a/build.mill +++ b/build.mill @@ -520,6 +520,8 @@ trait Core extends ScalaCliCrossSbtModule | def toolkitVersionForNative04 = "${Deps.toolkitVersionForNative04}" | def toolkitVersionForNative05 = "${Deps.toolkitVersionForNative05}" | + | def defaultDenoVersion = "2.1.4" + | | def typelevelOrganization = "${Deps.typelevelToolkit.dep.module.organization.value}" | def typelevelToolkitDefaultVersion = "${Deps.typelevelToolkitVersion}" | def typelevelToolkitMaxScalaNative = "${Deps.Versions.maxScalaNativeForTypelevelToolkit}" 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 2309922eb2..24c196dc1b 100644 --- a/modules/build/src/main/scala/scala/build/internal/Runner.scala +++ b/modules/build/src/main/scala/scala/build/internal/Runner.scala @@ -186,6 +186,60 @@ 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 + } + + // Node 24+ (V8 13+) has wasm-exnref enabled by default; older versions need --experimental-wasm-exnref. + private def nodeNeedsWasmFlag: Boolean = + nodeMajorVersion.forall(_ < 24) // true if unknown or < 24 + + // Detects the major version of Deno on PATH; cached for the JVM lifetime (lazy val). + // Returns None if deno is not found or version cannot be parsed. + private lazy val denoMajorVersion: Option[Int] = + try { + val process = new ProcessBuilder("deno", "--version") + .redirectErrorStream(true) + .start() + val output = new String(process.getInputStream.readAllBytes()).trim + process.waitFor() + // Deno version format: "deno 2.1.0 (release, aarch64-apple-darwin)\nv8 13.x\ntypescript 5.x" + // Extract major from first line + val firstLine = output.linesIterator.nextOption().getOrElse("") + val versionStr = firstLine.stripPrefix("deno ").takeWhile(c => c.isDigit || c == '.') + versionStr.takeWhile(_.isDigit) match { + case s if s.nonEmpty => Some(s.toInt) + case _ => None + } + } + catch { + case _: Exception => None + } + + // Deno 2.x+ bundles V8 13+ which has wasm-exnref enabled by default; no flag needed. + private def denoNeedsWasmFlag: Boolean = + denoMajorVersion.flatMap { major => + if (major >= 2) Some(false) // Deno 2.x+ has V8 13+ with wasm-exnref by default + else Some(true) + }.getOrElse(true) // true if unknown + private def endsWithCaseInsensitive(s: String, suffix: String): Boolean = s.length >= suffix.length && s.regionMatches(true, s.length - suffix.length, suffix, 0, suffix.length) @@ -218,11 +272,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. @@ -239,14 +295,16 @@ 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 !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(" ")}", @@ -262,12 +320,25 @@ object Runner { ) sys.error("should not happen") } + else if (emitWasm) { + // For WASM mode with ES modules, run node directly instead of NodeJSEnv. + // NodeJSEnv's stdin piping with "-" doesn't work with Input.ESModule. + val command = Seq(nodePath) ++ nodeFlags ++ Seq(entrypoint.getAbsolutePath) ++ args + + logger.log( + s"Running ${command.mkString(" ")}", + " Running" + System.lineSeparator() + + command.iterator.map(_ + System.lineSeparator()).mkString + ) + + new ProcessBuilder(command: _*).inheritIO().start() + } else { val nodeArgs = // 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( @@ -304,6 +375,69 @@ object Runner { } } + def denoCommand( + entrypoint: File, + args: Seq[String], + denoPathOpt: Option[String] = None + ): Seq[String] = { + val denoPath = denoPathOpt.getOrElse(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, + denoPathOpt: Option[String] = None + ): Either[BuildException, Process] = either { + val denoPath: String = denoPathOpt.getOrElse { + value(findInPath("deno") + .map(_.toString) + .toRight(DenoNotFoundError())) + } + val denoFlags = Seq("run", "--allow-read") + val extraEnv = + if (emitWasm && denoNeedsWasmFlag) Map("DENO_V8_FLAGS" -> "--experimental-wasm-exnref") + else Map.empty + + 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 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 96c9edc30a..e613a556d9 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 @@ -31,7 +31,8 @@ object DirectivesPreprocessingUtils { directives.ScalaNative.handler, directives.ScalaVersion.handler, directives.Sources.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 b453f326b0..a2008d4579 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 @@ -12,13 +12,13 @@ import java.util.concurrent.atomic.AtomicReference import scala.build.* import scala.build.EitherCps.{either, value} import scala.build.Ops.* -import scala.build.errors.{BuildException, CompositeBuildException} +import scala.build.errors.{BuildException, CompositeBuildException, UnsupportedWasmRuntimeError} import scala.build.input.* 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 @@ -28,7 +28,7 @@ import scala.cli.commands.util.BuildCommandHelpers.* import scala.cli.commands.util.{BuildCommandHelpers, RunHadoop, RunSpark} import scala.cli.commands.{CommandUtils, ScalaCommand, SpecificationLevel, WatchUtil} import scala.cli.config.Keys -import scala.cli.internal.ProcUtil +import scala.cli.internal.{ProcUtil, WasmRuntimeDownloader} import scala.cli.packaging.Library.fullClassPathMaybeAsJar import scala.cli.util.ArgHelpers.* import scala.cli.util.ConfigDbUtils @@ -474,228 +474,332 @@ 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 wasmOpts = build.options.wasmOptions + + // Check if WASM mode is requested + if wasmOpts.enabled then { + val runtime = wasmOpts.runtime + + if runtime.isJsBased then { + // JS-based WASM path - uses Scala.js WASM with JavaScript helpers (Node.js or Deno) + val esModule = true // WASM backend uses ES modules + scratchDirOpt.foreach(os.makeDir.all(_)) + val jsDest = os.temp( + dir = scratchDirOpt.orNull, + prefix = "main", + suffix = ".mjs", + deleteOnExit = scratchDirOpt.isEmpty + ) + + // Resolve Deno binary: check PATH first, download if needed + val denoPathOpt: Option[String] = runtime match { + case WasmRuntime.Deno => + val denoCmd = value(WasmRuntimeDownloader.denoCommand( + wasmOpts.finalDenoVersion, + build.options.archiveCache, + logger + )) + Some(denoCmd.head) + case _ => None } - 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 - ) - } - 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 + + val linkerConfig = build.options.scalaJsOptions.linkerConfig(logger) + .copy(emitWasm = true, moduleKind = ScalaJsLinkerConfig.ModuleKind.ESModule) + + 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 => + if showCommand then + runtime match { + case WasmRuntime.Deno => + Left(Runner.denoCommand(outputPath.toIO, args, denoPathOpt = denoPathOpt)) + case _ => + Left(Runner.jsCommand(outputPath.toIO, args, jsDom = false, emitWasm = true)) } - // 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 process = value { + runtime match { + case WasmRuntime.Deno => + Runner.runDeno( + outputPath.toIO, + args, + logger, + allowExecve = effectiveAllowExecve, + emitWasm = true, + denoPathOpt = denoPathOpt + ) + case _ => + Runner.runJs( + outputPath.toIO, + args, + logger, + allowExecve = effectiveAllowExecve, + jsDom = false, + sourceMap = build.options.scalaJsOptions.emitSourceMaps, + esModule = esModule, + emitWasm = true + ) + } } + process.onExit().thenApply(_ => if os.exists(jsDest) then os.remove(jsDest)) + Right((process, None)) } - 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 + } + value(res) + } + else { + // Standalone WASM runtimes - not yet supported. + // Scala.js currently produces JS-dependent WASM output. + // Standalone support requires upstream Scala.js changes (scala-js/scala-js#4991). + val runtimeName = runtime.name + val extraNote = runtime match { + case WasmRuntime.Wasmer => + " Note: Wasmer does not yet support WasmGC, which is required for Scala WASM output." + case _ => "" + } + value(Left(new UnsupportedWasmRuntimeError( + s"Standalone WASM runtime '$runtimeName' is not yet supported." + + s"$extraNote" + + " Scala.js currently produces JavaScript-dependent WASM output." + + " Standalone WASM support is tracked at: https://github.com/scala-js/scala-js/issues/4991" + + " Use --wasm-runtime node (default) or --wasm-runtime deno for JS-based WASM execution." + ))) + } + } + 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 s: OnDisk => fwd(s.path.toString) - case s => s.getClass.getName - }.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 s: OnDisk => fwd(s.path.toString) + case s => s.getClass.getName + }.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/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/SharedOptions.scala b/modules/cli/src/main/scala/scala/cli/commands/shared/SharedOptions.scala index fe4b8903d9..aeba071f4b 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 @@ -57,6 +57,8 @@ final case class SharedOptions( js: ScalaJsOptions = ScalaJsOptions(), @Recurse native: ScalaNativeOptions = ScalaNativeOptions(), + @Recurse + wasmOptions: WasmOptions = WasmOptions(), @Recurse compilationServer: SharedCompilationServerOptions = SharedCompilationServerOptions(), @Recurse @@ -283,6 +285,16 @@ final case class SharedOptions( ) } + private def buildWasmOptions(opts: WasmOptions): options.WasmOptions = { + import opts._ + options.WasmOptions( + enabled = wasm, + runtime = + wasmRuntime.flatMap(options.WasmRuntime.parse).getOrElse(options.WasmRuntime.default), + denoVersion = denoVersion + ) + } + lazy val scalacOptionsFromFiles: List[String] = scalac.argsFiles.flatMap(argFile => ArgSplitter.splitToArgs(os.read(os.Path(argFile.file, os.pwd))) @@ -304,21 +316,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 = wasmOptions.wasm + 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) = @@ -405,6 +423,7 @@ final case class SharedOptions( ), scalaJsOptions = scalaJsOptions(js), scalaNativeOptions = snOpts, + wasmOptions = buildWasmOptions(wasmOptions), javaOptions = value(scala.cli.commands.util.JvmUtils.javaOptions(jvm)), jmhOptions = scala.build.options.JmhOptions( jmhVersion = benchmarking.jmhVersion, diff --git a/modules/cli/src/main/scala/scala/cli/commands/shared/WasmOptions.scala b/modules/cli/src/main/scala/scala/cli/commands/shared/WasmOptions.scala new file mode 100644 index 0000000000..d8793afaa2 --- /dev/null +++ b/modules/cli/src/main/scala/scala/cli/commands/shared/WasmOptions.scala @@ -0,0 +1,32 @@ +package scala.cli.commands.shared + +import caseapp.* +import com.github.plokhotnyuk.jsoniter_scala.core.* +import com.github.plokhotnyuk.jsoniter_scala.macros.* + +import scala.cli.commands.tags + +// format: off +final case class WasmOptions( + @Group(HelpGroup.Scala.toString) + @Tag(tags.experimental) + @HelpMessage("Enable WebAssembly output (Scala.js WASM backend). Uses Node.js by default. To show more options for WASM pass `--help-wasm`") + wasm: Boolean = false, + + @Group(HelpGroup.Wasm.toString) + @Tag(tags.experimental) + @HelpMessage("WASM runtime to use: node (default), deno. Standalone runtimes (wasmtime, wasmedge) planned for future releases.") + wasmRuntime: Option[String] = None, + + @Group(HelpGroup.Wasm.toString) + @Tag(tags.experimental) + @HelpMessage("Version of Deno to use. If Deno is not found on PATH, it will be downloaded automatically.") + denoVersion: Option[String] = None +) +// format: on + +object WasmOptions { + implicit lazy val parser: Parser[WasmOptions] = Parser.derive + implicit lazy val help: Help[WasmOptions] = Help.derive + implicit lazy val jsonCodec: JsonValueCodec[WasmOptions] = JsonCodecMaker.make +} diff --git a/modules/cli/src/main/scala/scala/cli/internal/WasmRuntimeDownloader.scala b/modules/cli/src/main/scala/scala/cli/internal/WasmRuntimeDownloader.scala new file mode 100644 index 0000000000..751e087034 --- /dev/null +++ b/modules/cli/src/main/scala/scala/cli/internal/WasmRuntimeDownloader.scala @@ -0,0 +1,104 @@ +package scala.cli.internal + +import coursier.cache.ArchiveCache +import coursier.util.Task + +import java.util.Locale + +import scala.build.EitherCps.{either, value} +import scala.build.Logger +import scala.build.errors.BuildException +import scala.build.internal.FetchExternalBinary +import scala.util.Properties + +/** Resolves Deno binary for WASM execution. + * + * Deno is first looked up on the system PATH. If not found, it is downloaded from GitHub releases + * and cached via Coursier's ArchiveCache. + */ +object WasmRuntimeDownloader { + + /** Returns the command to run Deno. + * + * First checks system PATH, otherwise downloads the binary. + */ + def denoCommand( + version: String, + archiveCache: ArchiveCache[Task], + logger: Logger + ): Either[BuildException, Seq[String]] = either { + findOnPath("deno") match { + case Some(path) => + logger.debug(s"Using system deno at: $path") + Seq(path) + case None => + logger.message(s"Deno not found on PATH, downloading v$version...") + val binary = value(fetchDeno(version, archiveCache, logger)) + Seq(binary.toString) + } + } + + /** Find an executable on the system PATH */ + private def findOnPath(name: String): Option[String] = { + val exeName = if (Properties.isWin) s"$name.exe" else name + sys.env.get("PATH").flatMap { pathEnv => + pathEnv.split(java.io.File.pathSeparator).view.map { dir => + val file = new java.io.File(dir, exeName) + if (file.exists() && file.canExecute) Some(file.getAbsolutePath) + else None + }.find(_.isDefined).flatten + } + } + + private def detectOs(win: String, linux: String, mac: String): Either[BuildException, String] = + if (Properties.isWin) Right(win) + else if (Properties.isLinux) Right(linux) + else if (Properties.isMac) Right(mac) + else Left(new WasmRuntimeDownloadError(s"Unsupported OS: ${sys.props("os.name")}")) + + private def detectArch64(x86_64: String, aarch64: String): Either[BuildException, String] = + sys.props("os.arch").toLowerCase(Locale.ROOT) match { + case "amd64" | "x86_64" => Right(x86_64) + case "aarch64" | "arm64" => Right(aarch64) + case other => Left(new WasmRuntimeDownloadError(s"Unsupported architecture: $other")) + } + + /** Fetches Deno binary for the current platform. + * + * Deno releases are at: + * https://github.com/denoland/deno/releases/download/v{version}/deno-{platform}.zip + */ + private def fetchDeno( + version: String, + archiveCache: ArchiveCache[Task], + logger: Logger + ): Either[BuildException, os.Path] = either { + val platform = value(denoPlatform) + val url = s"https://github.com/denoland/deno/releases/download/v$version/deno-$platform.zip" + + val binaryOpt = value { + FetchExternalBinary.fetchLauncher( + url = url, + changing = false, + archiveCache = archiveCache, + logger = logger, + launcherPrefix = "deno", + launcherPathOpt = None, + makeExecutable = true + ) + } + + binaryOpt.getOrElse { + value(Left(new WasmRuntimeDownloadError(s"Could not download Deno v$version for $platform"))) + } + } + + /** Platform suffix for Deno downloads */ + private def denoPlatform: Either[BuildException, String] = either { + val arch = value(detectArch64("x86_64", "aarch64")) + val os = value(detectOs("pc-windows-msvc", "unknown-linux-gnu", "apple-darwin")) + s"$arch-$os" + } +} + +class WasmRuntimeDownloadError(message: String) extends BuildException(message) 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..4566e346a4 --- /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 --wasm-runtime node" + ) diff --git a/modules/core/src/main/scala/scala/build/errors/UnsupportedWasmRuntimeError.scala b/modules/core/src/main/scala/scala/build/errors/UnsupportedWasmRuntimeError.scala new file mode 100644 index 0000000000..b663b6a956 --- /dev/null +++ b/modules/core/src/main/scala/scala/build/errors/UnsupportedWasmRuntimeError.scala @@ -0,0 +1,3 @@ +package scala.build.errors + +final class UnsupportedWasmRuntimeError(message: String) extends BuildException(message) 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..361fcc32ab --- /dev/null +++ b/modules/directives/src/main/scala/scala/build/preprocessing/directives/Wasm.scala @@ -0,0 +1,52 @@ +package scala.build.preprocessing.directives + +import scala.build.Positioned +import scala.build.directives.* +import scala.build.errors.BuildException +import scala.build.options.{BuildOptions, Platform, ScalaOptions, WasmOptions, WasmRuntime} +import scala.cli.commands.SpecificationLevel + +@DirectiveGroupName("WASM options") +@DirectiveExamples("//> using wasm") +@DirectiveExamples("//> using wasmRuntime node") +@DirectiveExamples("//> using wasmRuntime deno") +@DirectiveExamples("//> using denoVersion 2.1.4") +@DirectiveUsage( + "//> using wasm|wasmRuntime|denoVersion _value_", + """ + |`//> using wasm` _true|false_ + | + |`//> using wasm` + | + |`//> using wasmRuntime` _node|deno|wasmtime|wasmedge|wasmer_ + | + |`//> using denoVersion` _value_ + |""".stripMargin +) +@DirectiveDescription("Add WebAssembly options") +@DirectiveLevel(SpecificationLevel.EXPERIMENTAL) +final case class Wasm( + wasm: Option[Boolean] = None, + wasmRuntime: Option[String] = None, + denoVersion: Option[String] = None +) extends HasBuildOptions { + def buildOptions: Either[BuildException, BuildOptions] = { + val parsedRuntime = wasmRuntime.flatMap(WasmRuntime.parse) + val wasmOptions = WasmOptions( + enabled = wasm.getOrElse(false), + runtime = parsedRuntime.getOrElse(WasmRuntime.default), + denoVersion = denoVersion + ) + // When WASM is enabled, force Platform.JS (Scala.js WASM backend requires JS compilation) + val scalaOptions = + if (wasm.getOrElse(false)) + ScalaOptions(platform = Some(Positioned.none(Platform.JS))) + else + ScalaOptions() + Right(BuildOptions(scalaOptions = scalaOptions, wasmOptions = wasmOptions)) + } +} + +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..8436bc2e0f 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,286 @@ trait RunScalaJsTestDefinitions { this: RunTestDefinitions => .call(cwd = root).out.trim() val path = absOutDir / "main.wasm" expect(os.exists(path)) + } + } + + test("Run with --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", + "--wasm", + "--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 --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", + "--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", + "--wasm", + "--wasm-runtime", + "node", + extraOptions, + "--", + "foo", + "bar", + "baz" + ).call(cwd = root).out.trim() + expect(output == "foo bar baz") + } + } + + for (runtime <- Seq("wasmtime", "wasmedge", "wasmer")) + test(s"Unsupported WASM runtime '$runtime' gives clear error") { + val inputs = TestInputs( + os.rel / "Hello.scala" -> + """object Hello { + | def main(args: Array[String]): Unit = println("Hello!") + |} + |""".stripMargin + ) + inputs.fromRoot { root => + val res = os.proc( + TestUtil.cli, + "--power", + "run", + "Hello.scala", + "--wasm", + "--wasm-runtime", + runtime, + extraOptions + ).call(cwd = root, check = false, mergeErrIntoOut = true) + expect(res.exitCode != 0) + expect(res.out.trim().contains("not yet supported")) + expect(res.out.trim().contains("scala-js/scala-js/issues/4991")) + } + } + + if (TestUtil.fromPath("deno").isDefined) + test("Run with --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", + "--wasm", + "--wasm-runtime", + "deno", + extraOptions + ).call(cwd = root).out.trim() + expect(output == "Hello from Deno 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", + "--wasm", + "--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", + "--wasm", + "--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", + "--wasm", + "--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", + "--wasm", + "--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/BuildOptions.scala b/modules/options/src/main/scala/scala/build/options/BuildOptions.scala index 2375f28840..47dc23728e 100644 --- a/modules/options/src/main/scala/scala/build/options/BuildOptions.scala +++ b/modules/options/src/main/scala/scala/build/options/BuildOptions.scala @@ -37,6 +37,7 @@ final case class BuildOptions( scalaOptions: ScalaOptions = ScalaOptions(), scalaJsOptions: ScalaJsOptions = ScalaJsOptions(), scalaNativeOptions: ScalaNativeOptions = ScalaNativeOptions(), + wasmOptions: WasmOptions = WasmOptions(), internalDependencies: InternalDependenciesOptions = InternalDependenciesOptions(), javaOptions: JavaOptions = JavaOptions(), jmhOptions: JmhOptions = JmhOptions(), diff --git a/modules/options/src/main/scala/scala/build/options/WasmOptions.scala b/modules/options/src/main/scala/scala/build/options/WasmOptions.scala new file mode 100644 index 0000000000..f96d697803 --- /dev/null +++ b/modules/options/src/main/scala/scala/build/options/WasmOptions.scala @@ -0,0 +1,26 @@ +package scala.build.options + +import scala.build.internal.Constants + +/** Options for WebAssembly compilation and execution. + * + * @param enabled + * If true, enable WASM output (Scala.js WASM backend) + * @param runtime + * The WASM runtime to use for execution (node, deno, wasmtime, wasmedge, wasmer) + * @param denoVersion + * Version of Deno to download (if not found on PATH) + */ +final case class WasmOptions( + enabled: Boolean = false, + runtime: WasmRuntime = WasmRuntime.default, + denoVersion: Option[String] = None +) { + def finalDenoVersion: String = + denoVersion.filter(_.nonEmpty).getOrElse(Constants.defaultDenoVersion) +} + +object WasmOptions { + implicit val hasHashData: HasHashData[WasmOptions] = HasHashData.derive + implicit val monoid: ConfigMonoid[WasmOptions] = ConfigMonoid.derive +} 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..f88f3028ab --- /dev/null +++ b/modules/options/src/main/scala/scala/build/options/WasmRuntime.scala @@ -0,0 +1,54 @@ +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 + * + * Standalone runtimes (future, requires upstream Scala.js standalone WASM support): + * - Wasmtime: Primary standalone target, full WasmGC + Component Model + * - WasmEdge: Secondary standalone target, CNCF cloud-native runtime + * - Wasmer: Placeholder, no WasmGC support yet + */ +sealed abstract class WasmRuntime(val name: String) { + def isJsBased: Boolean = this match { + case WasmRuntime.Node | WasmRuntime.Deno => true + case _ => false + } + def isStandalone: Boolean = !isJsBased +} + +object WasmRuntime { + // JS-based runtimes (work now) + case object Node extends WasmRuntime("node") + case object Deno extends WasmRuntime("deno") + // Standalone runtimes (future - requires upstream Scala.js standalone WASM support) + case object Wasmtime extends WasmRuntime("wasmtime") + case object WasmEdge extends WasmRuntime("wasmedge") + case object Wasmer extends WasmRuntime("wasmer") + + val all: Seq[WasmRuntime] = Seq(Node, Deno, Wasmtime, WasmEdge, Wasmer) + + 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 "wasmtime" => Some(Wasmtime) + case "wasmedge" => Some(WasmEdge) + case "wasmer" => Some(Wasmer) + 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 056df024c6..ecec91b721 100644 --- a/website/docs/reference/cli-options.md +++ b/website/docs/reference/cli-options.md @@ -1937,6 +1937,32 @@ A github token used to access GitHub. Not needed in most cases. Don't check for the newest available Scala CLI version upstream +## WebAssembly options + +Available in commands: + +[`run`](./commands.md#run), [`shebang`](./commands.md#shebang) + + + +### `--wasm` + +[Experimental] + +Enable WebAssembly output (Scala.js WASM backend). Uses Node.js by default. To show more options for WASM pass `--help-wasm` + +### `--wasm-runtime` + +[Experimental] + +WASM runtime to use: node (default), deno. Standalone runtimes (wasmtime, wasmedge) planned for future releases. + +### `--deno-version` + +[Experimental] + +Version of Deno to use. If Deno is not found on PATH, it will be downloaded automatically. + ## Watch options Available in commands: diff --git a/website/docs/reference/directives.md b/website/docs/reference/directives.md index 9a9f0456bb..4d6acc3fa2 100644 --- a/website/docs/reference/directives.md +++ b/website/docs/reference/directives.md @@ -668,6 +668,27 @@ Add Scala.js options `//> using jsEmitWasm` +### WebAssembly + +Add WebAssembly options + +`//> using wasm` _true|false_ + +`//> using wasm` + +`//> using wasmRuntime` _node|deno|wasmtime|wasmedge|wasmer_ + +`//> using denoVersion` _value_ + +#### Examples +`//> using wasm` + +`//> using wasmRuntime node` + +`//> using wasmRuntime deno` + +`//> using denoVersion 2.1.4` + ### Test framework Set the test framework