diff --git a/.gitignore b/.gitignore index 9621b9c957..f866b7a4df 100644 --- a/.gitignore +++ b/.gitignore @@ -9,5 +9,8 @@ out/ dest/ target/ +*/scoverage.coverage + # ignore vim backup files *.sw[op] + 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 6d6916cc42..5c0b8a6467 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 @@ -37,44 +37,58 @@ final case class ScalaJsOptions( @Group(HelpGroup.ScalaJs.toString) @Tag(tags.should) - jsCheckIr: Option[Boolean] = None, + jsCheckIr: Option[Boolean] = None, @Group(HelpGroup.ScalaJs.toString) @HelpMessage("Emit source maps") @Tag(tags.should) jsEmitSourceMaps: Boolean = false, + @Group(HelpGroup.ScalaJs.toString) @HelpMessage("Set the destination path of source maps") @Tag(tags.should) jsSourceMapsPath: Option[String] = None, + + @Group(HelpGroup.ScalaJs.toString) + @HelpMessage("A file relative to the root directory containing import maps for ES module imports") + @Tag(tags.restricted) + jsEsModuleImportMap: Option[String] = None, + @Group(HelpGroup.ScalaJs.toString) @Tag(tags.should) @HelpMessage("Enable jsdom") jsDom: Option[Boolean] = None, + @Group(HelpGroup.ScalaJs.toString) @Tag(tags.should) @HelpMessage("A header that will be added at the top of generated .js files") jsHeader: Option[String] = None, + @Group(HelpGroup.ScalaJs.toString) @Tag(tags.implementation) @HelpMessage("Primitive Longs *may* be compiled as primitive JavaScript bigints") jsAllowBigIntsForLongs: Option[Boolean] = None, + @Group(HelpGroup.ScalaJs.toString) @Tag(tags.implementation) @HelpMessage("Avoid class'es when using functions and prototypes has the same observable semantics.") jsAvoidClasses: Option[Boolean] = None, + @Group(HelpGroup.ScalaJs.toString) @Tag(tags.implementation) @HelpMessage("Avoid lets and consts when using vars has the same observable semantics.") jsAvoidLetsAndConsts: Option[Boolean] = None, + @Group(HelpGroup.ScalaJs.toString) @Tag(tags.implementation) @HelpMessage("The Scala.js module split style: fewestmodules, smallestmodules, smallmodulesfor") jsModuleSplitStyle: Option[String] = None, + @Group(HelpGroup.ScalaJs.toString) @Tag(tags.implementation) @HelpMessage("Create as many small modules as possible for the classes in the passed packages and their subpackages.") jsSmallModuleForPackage: List[String] = Nil, + @Group(HelpGroup.ScalaJs.toString) @Tag(tags.should) @HelpMessage("The Scala.js ECMA Script version: es5_1, es2015, es2016, es2017, es2018, es2019, es2020, es2021") @@ -86,18 +100,21 @@ final case class ScalaJsOptions( @Tag(tags.implementation) @Hidden jsLinkerPath: Option[String] = None, + @Group(HelpGroup.ScalaJs.toString) @HelpMessage(s"Scala.js CLI version to use for linking (${Constants.scalaJsCliVersion} by default).") @ValueDescription("version") @Tag(tags.implementation) @Hidden jsCliVersion: Option[String] = None, + @Group(HelpGroup.ScalaJs.toString) @HelpMessage("Scala.js CLI Java options") @Tag(tags.implementation) @ValueDescription("option") @Hidden jsCliJavaArg: List[String] = Nil, + @Group(HelpGroup.ScalaJs.toString) @HelpMessage("Whether to run the Scala.js CLI on the JVM or using a native executable") @Tag(tags.implementation) 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 ab319af5fb..6e92307fcc 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 @@ -244,7 +244,8 @@ final case class SharedOptions( moduleSplitStyleStr = jsModuleSplitStyle, smallModuleForPackage = jsSmallModuleForPackage, esVersionStr = jsEsVersion, - noOpt = jsNoOpt + noOpt = jsNoOpt, + remapEsModuleImportMap = jsEsModuleImportMap.filter(_.trim.nonEmpty).map(os.Path(_, Os.pwd)) ) } diff --git a/modules/directives/src/main/scala/scala/build/preprocessing/directives/ScalaJs.scala b/modules/directives/src/main/scala/scala/build/preprocessing/directives/ScalaJs.scala index 172ac0f393..add41fcd62 100644 --- a/modules/directives/src/main/scala/scala/build/preprocessing/directives/ScalaJs.scala +++ b/modules/directives/src/main/scala/scala/build/preprocessing/directives/ScalaJs.scala @@ -1,11 +1,15 @@ package scala.build.preprocessing.directives +import os.Path + import scala.build.EitherCps.{either, value} +import scala.build.Ops.EitherOptOps import scala.build.directives.* import scala.build.errors.BuildException import scala.build.options.{BuildOptions, JavaOpt, ScalaJsMode, ScalaJsOptions, ShadowingSeq} import scala.build.{Logger, Positioned, options} import scala.cli.commands.SpecificationLevel +import scala.util.Try @DirectiveGroupName("Scala.js options") @DirectiveExamples("//> using jsModuleKind common") @@ -39,6 +43,8 @@ import scala.cli.commands.SpecificationLevel |`//> using jsModuleSplitStyleStr` _value_ | |`//> using jsEsVersionStr` _value_ + | + |`//> using jsEsModuleImportMap` _value_ |""".stripMargin ) @DirectiveDescription("Add Scala.js options") @@ -51,6 +57,7 @@ final case class ScalaJs( jsModuleKind: Option[String] = None, jsCheckIr: Option[Boolean] = None, jsEmitSourceMaps: Option[Boolean] = None, + jsEsModuleImportMap: Option[String] = None, jsSmallModuleForPackage: List[String] = Nil, jsDom: Option[Boolean] = None, jsHeader: Option[String] = None, @@ -61,7 +68,7 @@ final case class ScalaJs( jsEsVersionStr: Option[String] = None ) extends HasBuildOptions { // format: on - def buildOptions: Either[BuildException, BuildOptions] = either { + def buildOptions: Either[BuildException, BuildOptions] = val scalaJsOptions = ScalaJsOptions( version = jsVersion, mode = ScalaJsMode(jsMode), @@ -76,14 +83,31 @@ final case class ScalaJs( avoidLetsAndConsts = jsAvoidLetsAndConsts, moduleSplitStyleStr = jsModuleSplitStyleStr, esVersionStr = jsEsVersionStr, - noOpt = jsNoOpt + noOpt = jsNoOpt, ) - BuildOptions( - scalaJsOptions = scalaJsOptions + + def absFilePath(pathStr: String): Either[ImportMapNotFound, Path] = { + Try(os.Path(pathStr, os.pwd)).toEither.fold(ex => + Left(ImportMapNotFound(s"""Invalid path to EsImportMap. Please check your "using jsEsModuleImportMap xxxx" directive. Does this file exist $pathStr ?""", ex)), + path => + os.isFile(path) && os.exists(path) match { + case false => Left(ImportMapNotFound(s"""Invalid path to EsImportMap. Please check your "using jsEsModuleImportMap xxxx" directive. Does this file exist $pathStr ?""", null)) + case true => Right(path) + } + ) + } + val jsImportMapAsPath = jsEsModuleImportMap.map(absFilePath).sequence + jsImportMapAsPath.map( _ match + case None => BuildOptions(scalaJsOptions = scalaJsOptions) + case Some(importmap) => + BuildOptions( + scalaJsOptions = scalaJsOptions.copy(remapEsModuleImportMap = Some(importmap)) + ) ) - } } +class ImportMapNotFound(message: String, cause: Throwable) extends BuildException(message, cause = cause) + object ScalaJs { val handler: DirectiveHandler[ScalaJs] = DirectiveHandler.derive -} +} \ No newline at end of file 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 39a05630b9..0f58c9e386 100644 --- a/modules/integration/src/test/scala/scala/cli/integration/RunScalaJsTestDefinitions.scala +++ b/modules/integration/src/test/scala/scala/cli/integration/RunScalaJsTestDefinitions.scala @@ -291,6 +291,149 @@ trait RunScalaJsTestDefinitions { _: RunTestDefinitions => } } + test("remap imports directive") { + val importmapFile = "importmap.json" + val outDir = "out" + val fileName = os.rel / "run.scala" + + val inputs = TestInputs( + fileName -> + s"""//> using jsEsModuleImportMap $importmapFile + | //> using jsModuleKind es + | //> using jsMode fastLinkJS + | //> using platform js + | + |import scala.scalajs.js + |import scala.scalajs.js.annotation.JSImport + |import scala.scalajs.js.typedarray.Float64Array + | + |object Foo { + | def main(args: Array[String]): Unit = { + | println(Array(-10.0, 10.0, 10).mkString(", ")) + | println(linspace(0, 10, 10).mkString(", ")) + | } + |} + | + |@js.native + |@JSImport("@stdlib/linspace", JSImport.Default) + |object linspace extends js.Object { + | def apply(start: Double, stop: Double, num: Int): Float64Array = js.native + |}""".stripMargin, + os.rel / importmapFile -> """{"imports": {"@stdlib/linspace": "https://cdn.skypack.dev/@stdlib/linspace"}}""".stripMargin + ) + inputs.fromRoot { root => + val absOutDir = root / outDir + val outFile = absOutDir / "main.js" + os.makeDir.all(absOutDir) + os.proc( + TestUtil.cli, + "--power", + "package", + fileName, + "--js", + "--js-module-kind", "ESModule", + "-o", outFile, + "-f" + ).call(cwd = root).out.trim() + expect(os.read(outFile).contains("https://cdn.skypack.dev/@stdlib/linspace")) + } + } + + test("remap imports directive error") { + val fileName = os.rel / "run.scala" + val notexist = "I_DONT_EXIST.json" + val inputs = TestInputs( + fileName -> + s"""//> using jsEsModuleImportMap $notexist + | //> using jsModuleKind es + | //> using jsMode fastLinkJS + | //> using platform js + | + |import scala.scalajs.js + |import scala.scalajs.js.annotation.JSImport + |import scala.scalajs.js.typedarray.Float64Array + | + |object Foo { + | def main(args: Array[String]): Unit = { + | println(Array(-10.0, 10.0, 10).mkString(", ")) + | println(linspace(0, 10, 10).mkString(", ")) + | } + |} + | + |@js.native + |@JSImport("@stdlib/linspace", JSImport.Default) + |object linspace extends js.Object { + | def apply(start: Double, stop: Double, num: Int): Float64Array = js.native + |}""".stripMargin + ) + inputs.fromRoot { root => + val absOutDir = root / "outDir" + val outFile = absOutDir / "main.js" + os.makeDir.all(absOutDir) + val result = os.proc( + TestUtil.cli, + "--power", + "package", + fileName, + "--js", + "--js-module-kind", "ESModule", + "-o", outFile, + "-f" + ).call(cwd = root,check = false, mergeErrIntoOut = true).out.trim() + expect(result.contains(notexist)) + expect(result.contains("Invalid path to EsImportMap.")) + } + } + + test("remap imports cmd") { + val importmapFile = "importmap.json" + val outDir = "out" + val fileName = os.rel / "run.scala" + + val inputs = TestInputs( + fileName -> + s""" + | //> using jsModuleKind es + | //> using jsMode fastLinkJS + | //> using platform js + | + |import scala.scalajs.js + |import scala.scalajs.js.annotation.JSImport + |import scala.scalajs.js.typedarray.Float64Array + | + |object Foo { + | def main(args: Array[String]): Unit = { + | println(Array(-10.0, 10.0, 10).mkString(", ")) + | println(linspace(0, 10, 10).mkString(", ")) + | } + |} + | + |@js.native + |@JSImport("@stdlib/linspace", JSImport.Default) + |object linspace extends js.Object { + | def apply(start: Double, stop: Double, num: Int): Float64Array = js.native + |}""".stripMargin, + os.rel / importmapFile -> """{"imports": {"@stdlib/linspace": "https://cdn.skypack.dev/@stdlib/linspace"}}""".stripMargin + ) + inputs.fromRoot { root => + val absOutDir = root / outDir + val outFile = absOutDir / "main.js" + os.makeDir.all(absOutDir) + os.proc( + TestUtil.cli, + "--power", + "package", + fileName, + "--js", + "--js-module-kind", "ESModule", + "-o", outFile, + "-f", + "--js-es-module-import-map", importmapFile + ).call(cwd = root, stdout = os.Inherit).out.trim() + expect(os.read(outFile).contains("https://cdn.skypack.dev/@stdlib/linspace")) + } + } + test("js defaults & toolkit default") { val msg = "Hello" TestInputs( diff --git a/modules/options/src/main/scala/scala/build/internal/ScalaJsLinkerConfig.scala b/modules/options/src/main/scala/scala/build/internal/ScalaJsLinkerConfig.scala index 7a68aa09e2..708bf58053 100644 --- a/modules/options/src/main/scala/scala/build/internal/ScalaJsLinkerConfig.scala +++ b/modules/options/src/main/scala/scala/build/internal/ScalaJsLinkerConfig.scala @@ -10,7 +10,8 @@ final case class ScalaJsLinkerConfig( esFeatures: ScalaJsLinkerConfig.ESFeatures = ScalaJsLinkerConfig.ESFeatures(), jsHeader: Option[String] = None, prettyPrint: Boolean = false, - relativizeSourceMapBase: Option[String] = None + relativizeSourceMapBase: Option[String] = None, + remapEsModuleImportMap: Option[os.Path] = None, ) { def linkerCliArgs: Seq[String] = { val moduleKindArgs = Seq("--moduleKind", moduleKind) @@ -30,6 +31,8 @@ final case class ScalaJsLinkerConfig( if (prettyPrint) Seq("--prettyPrint") else Nil val jsHeaderArg = if (jsHeader.nonEmpty) Seq("--jsHeader", jsHeader.getOrElse("")) else Nil + val jsEsModuleImportMap = if(remapEsModuleImportMap.nonEmpty) Seq("--importmap", remapEsModuleImportMap.getOrElse(os.pwd / "importmap.json").toString) else Nil + val configArgs = Seq[os.Shellable]( moduleKindArgs, moduleSplitStyleArgs, @@ -39,7 +42,8 @@ final case class ScalaJsLinkerConfig( sourceMapArgs, relativizeSourceMapBaseArgs, jsHeaderArg, - prettyPrintArgs + prettyPrintArgs, + jsEsModuleImportMap ) configArgs.flatMap(_.value) 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 beb81d7c6b..8c56c0f0ba 100644 --- a/modules/options/src/main/scala/scala/build/options/ScalaJsOptions.scala +++ b/modules/options/src/main/scala/scala/build/options/ScalaJsOptions.scala @@ -16,6 +16,7 @@ final case class ScalaJsOptions( checkIr: Option[Boolean] = None, emitSourceMaps: Boolean = false, sourceMapsDest: Option[os.Path] = None, + remapEsModuleImportMap: Option[os.Path] = None, dom: Option[Boolean] = None, header: Option[String] = None, allowBigIntsForLongs: Option[Boolean] = None, @@ -148,7 +149,8 @@ final case class ScalaJsOptions( moduleSplitStyle = moduleSplitStyle(logger), smallModuleForPackage = smallModuleForPackage, esFeatures = esFeatures, - jsHeader = header + jsHeader = header, + remapEsModuleImportMap = remapEsModuleImportMap ) } } diff --git a/website/docs/reference/cli-options.md b/website/docs/reference/cli-options.md index b3596e6ea4..d5bc4ecb9f 100644 --- a/website/docs/reference/cli-options.md +++ b/website/docs/reference/cli-options.md @@ -1276,6 +1276,10 @@ Emit source maps Set the destination path of source maps +### `--js-es-module-import-map` + +A file relative to the root directory containing import maps for ES module imports + ### `--js-dom` Enable jsdom diff --git a/website/docs/reference/directives.md b/website/docs/reference/directives.md index 251a99d447..1c50992b21 100644 --- a/website/docs/reference/directives.md +++ b/website/docs/reference/directives.md @@ -397,6 +397,8 @@ Add Scala.js options `//> using jsEsVersionStr` _value_ +`//> using jsEsModuleImportMap` _value_ + #### Examples `//> using jsModuleKind common` diff --git a/website/docs/reference/scala-command/directives.md b/website/docs/reference/scala-command/directives.md index e479da18aa..7a6c705f18 100644 --- a/website/docs/reference/scala-command/directives.md +++ b/website/docs/reference/scala-command/directives.md @@ -283,6 +283,8 @@ Add Scala.js options `//> using jsEsVersionStr` _value_ +`//> using jsEsModuleImportMap` _value_ + #### Examples `//> using jsModuleKind common`