From ade123e9b05a0e71397a904fa4086c061e1c5225 Mon Sep 17 00:00:00 2001 From: Simon Parten Date: Tue, 30 Jan 2024 09:50:48 +0100 Subject: [PATCH] Support remapping imports at link time (#47) * Maps work - struggling with the facade, though * success! * add back previuos tests * Dont fold over ir files --- .gitignore | 3 + build.sc | 6 +- cli/src/org/scalajs/cli/Scalajsld.scala | 43 ++++++- .../org/scalajs/cli/internal/ImportMap.scala | 41 ++++++ .../src/org/scalajs/cli/tests/Tests.scala | 119 +++++++++++++++++- 5 files changed, 202 insertions(+), 10 deletions(-) create mode 100644 cli/src/org/scalajs/cli/internal/ImportMap.scala diff --git a/.gitignore b/.gitignore index 5c8c7c1..5ea7f48 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ /.bsp/ out/ .idea/ +.metals +.vscode +.bloop diff --git a/build.sc b/build.sc index 08ec264..0f00932 100644 --- a/build.sc +++ b/build.sc @@ -25,7 +25,11 @@ trait Cli extends ScalaModule with ScalaJsCliPublishModule { def artifactName = "scalajs" + super.artifactName() def ivyDeps = super.ivyDeps() ++ Seq( ivy"org.scala-js::scalajs-linker:$scalaJsVersion", - ivy"com.github.scopt::scopt:4.1.0" + ivy"com.github.scopt::scopt:4.1.0", + ivy"com.lihaoyi::os-lib:0.9.2", + ivy"com.github.plokhotnyuk.jsoniter-scala::jsoniter-scala-core:2.13.5.2", // This is the java8 version of jsoniter, according to scala-cli build + ivy"com.github.plokhotnyuk.jsoniter-scala::jsoniter-scala-macros:2.13.5.2", // This is the java8 version of jsoniter, according to scala-cli build + ivy"com.armanbilge::scalajs-importmap:0.1.1" ) def mainClass = Some("org.scalajs.cli.Scalajsld") diff --git a/cli/src/org/scalajs/cli/Scalajsld.scala b/cli/src/org/scalajs/cli/Scalajsld.scala index 25b9515..ee3ab76 100644 --- a/cli/src/org/scalajs/cli/Scalajsld.scala +++ b/cli/src/org/scalajs/cli/Scalajsld.scala @@ -26,6 +26,10 @@ import java.net.URI import java.nio.file.Path import java.lang.NoClassDefFoundError import org.scalajs.cli.internal.{EsVersionParser, ModuleSplitStyleParser} +import org.scalajs.cli.internal.ImportMapJsonIr.ImportMap + +import com.github.plokhotnyuk.jsoniter_scala.core._ +import org.scalajs.cli.internal.ImportMapJsonIr object Scalajsld { @@ -48,7 +52,8 @@ object Scalajsld { checkIR: Boolean = false, stdLib: Seq[File] = Nil, jsHeader: String = "", - logLevel: Level = Level.Info + logLevel: Level = Level.Info, + importMap: Option[File] = None ) private def moduleInitializer( @@ -134,6 +139,12 @@ object Scalajsld { .valueName("") .action { (x, c) => c.copy(outputDir = Some(x)) } .text("Output directory of linker (required)") + opt[File]("importmap") + .valueName(".json") + .action { (x, c) => c.copy(importMap = Some(x)) } + .text("""Absolute path to an existing json file, e.g. importmap.json the contents of which respect + | https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type/importmap#import_map_json_representation + | e.g. {"imports": {"square": "./module/shapes/square.js"},"scopes": {"/modules/customshapes/": {"square": "https://example.com/modules/shapes/square.js"}}}""") opt[Unit]('f', "fastOpt") .action { (_, c) => c.copy(noOpt = false, fullOpt = false) @@ -247,10 +258,26 @@ object Scalajsld { ) } - if (c.outputDir.isDefined == c.output.isDefined) + val outputCheck = if (c.outputDir.isDefined == c.output.isDefined) failure("exactly one of --output or --outputDir have to be defined") else success + + val importMapCheck = c.importMap match { + case None => success + case Some(value) => { + if (!value.exists()) { + failure(s"importmap file at path ${value} does not exist.") + } else { + success + } + } + } + val allValidations = Seq(outputCheck, importMapCheck) + allValidations.forall(_.isRight) match { + case true => success + case false => failure(allValidations.filter(_.isLeft).map(_.left.get).mkString("\n\n")) + } } override def showUsageOnError = Some(true) @@ -291,19 +318,25 @@ object Scalajsld { val result = PathIRContainer .fromClasspath(classpath) .flatMap(containers => cache.cached(containers._1)) - .flatMap { irFiles => + .flatMap { irFiles: Seq[IRFile] => + + val irImportMappedFiles = options.importMap match { + case None => irFiles + case Some(importMap) => ImportMapJsonIr.remapImports(importMap, irFiles) + } + (options.output, options.outputDir) match { case (Some(jsFile), None) => (DeprecatedLinkerAPI: DeprecatedLinkerAPI).link( linker, - irFiles.toList, + irImportMappedFiles.toList, moduleInitializers, jsFile, logger ) case (None, Some(outputDir)) => linker.link( - irFiles, + irImportMappedFiles, moduleInitializers, PathOutputDirectory(outputDir.toPath()), logger diff --git a/cli/src/org/scalajs/cli/internal/ImportMap.scala b/cli/src/org/scalajs/cli/internal/ImportMap.scala new file mode 100644 index 0000000..7469df3 --- /dev/null +++ b/cli/src/org/scalajs/cli/internal/ImportMap.scala @@ -0,0 +1,41 @@ +package org.scalajs.cli.internal + +import com.github.plokhotnyuk.jsoniter_scala.core._ +import com.github.plokhotnyuk.jsoniter_scala.macros._ +import org.scalajs.linker.interface.IRFile +import java.io.File +import com.armanbilge.sjsimportmap.ImportMappedIRFile + +object ImportMapJsonIr { + + type Scope = Map[String, String] + + final case class ImportMap( + val imports: Map[String, String], + val scopes: Option[Map[String, Scope]] + ) + + object ImportMap { + implicit val codec: JsonValueCodec[ImportMap] = JsonCodecMaker.make + } + + def remapImports(pathToImportPath: File, irFiles: Seq[IRFile]): Seq[IRFile] = { + val path = os.Path(pathToImportPath) + val importMapJson = if(os.exists(path))( + readFromString[ImportMap](os.read(path)) + ) else { + throw new AssertionError(s"importmap file at path ${path} does not exist.") + } + if (importMapJson.scopes.nonEmpty) { + throw new AssertionError("importmap scopes are not supported.") + } + val importsOnly : Map[String, String] = importMapJson.imports + + val remapFct = importsOnly.toSeq.foldLeft((in: String) => in){ case(fct, (s1, s2)) => + val fct2 : (String => String) = (in => in.replace(s1, s2)) + (in => fct(fct2(in))) + } + + irFiles.map{ImportMappedIRFile.fromIRFile(_)(remapFct)} + } +} \ No newline at end of file diff --git a/tests/test/src/org/scalajs/cli/tests/Tests.scala b/tests/test/src/org/scalajs/cli/tests/Tests.scala index 222218e..0fc0d64 100644 --- a/tests/test/src/org/scalajs/cli/tests/Tests.scala +++ b/tests/test/src/org/scalajs/cli/tests/Tests.scala @@ -24,7 +24,7 @@ class Tests extends munit.FunSuite { .out .trim() - def getScalaJsCompilerPlugin(cwd: os.Path) = os.proc("cs", "fetch", "--intransitive", s"org.scala-js:scalajs-compiler_2.13.6:$scalaJsVersion") + def getScalaJsCompilerPlugin(cwd: os.Path) = os.proc("cs", "fetch", "--intransitive", s"org.scala-js:scalajs-compiler_2.13.12:$scalaJsVersion") .call(cwd = cwd).out.trim() test("tests") { @@ -48,7 +48,7 @@ class Tests extends munit.FunSuite { os.proc( "cs", "launch", - "scalac:2.13.6", + "scalac:2.13.12", "--", "-classpath", scalaJsLibraryCp, @@ -134,7 +134,7 @@ class Tests extends munit.FunSuite { os.proc( "cs", "launch", - "scalac:2.13.6", + "scalac:2.13.12", "--", "-classpath", scalaJsLibraryCp, @@ -188,7 +188,7 @@ class Tests extends munit.FunSuite { os.proc( "cs", "launch", - "scalac:2.13.6", + "scalac:2.13.12", "--", "-classpath", scalaJsLibraryCp, @@ -223,4 +223,115 @@ class Tests extends munit.FunSuite { assert(runRes.err.trim().contains("UndefinedBehaviorError")) } + + test("import map") { + val dir = os.temp.dir() + os.write( + dir / "foo.scala", + """ + |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(linspace(-10.0, 10.0, 10)) + | } + |} + | + |@js.native + |@JSImport("@stdlib/linspace", JSImport.Default) + |object linspace extends js.Object { + | def apply(start: Double, stop: Double, num: Int): Float64Array = js.native + |} + |""".stripMargin + ) + + val scalaJsLibraryCp = getScalaJsLibraryCp(dir) + + os.makeDir.all(dir / "bin") + os.proc( + "cs", + "launch", + "scalac:2.13.12", + "--", + "-classpath", + scalaJsLibraryCp, + s"-Xplugin:${getScalaJsCompilerPlugin(dir)}", + "-d", + "bin", + "foo.scala" + ).call(cwd = dir, stdin = os.Inherit, stdout = os.Inherit) + + val notThereYet = dir / "no-worky.json" + val launcherRes = os.proc( + launcher, + "--stdlib", + scalaJsLibraryCp, + "--fastOpt", + "-s", + "-o", + "test.js", + "-mm", + "Foo.main", + "bin", + "--importmap", + notThereYet + ) + .call(cwd = dir, mergeErrIntoOut = true) + + assert(launcherRes.exitCode == 0) // as far as I can tell launcher returns code 0 for failed validation? + assert(launcherRes.out.trim().contains(s"importmap file at path ${notThereYet} does not exist")) + + os.write(notThereYet, "...") + + val failToParse = os.proc( + launcher, + "--stdlib", + scalaJsLibraryCp, + "--fastOpt", + "-s", + "-o", + "test.js", + "-mm", + "Foo.main", + "bin", + "--importmap", + notThereYet + ) + .call(cwd = dir, check = false, mergeErrIntoOut = true, stderr = os.Pipe) + + assert(failToParse.out.text().contains("com.github.plokhotnyuk.jsoniter_scala.core.JsonReaderException")) + + val importmap = dir / "importmap.json" + val substTo = "https://cdn.jsdelivr.net/gh/stdlib-js/array-base-linspace@esm/index.mjs" + os.write(importmap, s"""{ "imports": {"@stdlib/linspace":"$substTo"}}""") + + val out = os.makeDir.all(dir / "out") + + val worky = os.proc( + launcher, + "--stdlib", + scalaJsLibraryCp, + "--fastOpt", + "-s", + "--outputDir", + "out", + "-mm", + "Foo.main", + "bin", + "--moduleKind", + "ESModule", + "--importmap", + importmap + ) + .call(cwd = dir, check = false, mergeErrIntoOut = true, stderr = os.Pipe) + os.write( dir / "out" / "index.html", """""") + + // You can serve the HTML file here and check the console output of the index.html file, hosted in a simple webserver to prove the concept + //println(dir) + assert(os.exists(dir / "out" / "main.js")) + val rawJs = os.read.lines(dir / "out" / "main.js") + assert(rawJs(1).contains(substTo)) + } }