Skip to content

Commit

Permalink
Support remapping imports at link time (#47)
Browse files Browse the repository at this point in the history
* Maps work - struggling with the facade, though

* success!

* add back previuos tests

* Dont fold over ir files
  • Loading branch information
Quafadas authored Jan 30, 2024
1 parent ad7c968 commit ade123e
Show file tree
Hide file tree
Showing 5 changed files with 202 additions and 10 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
/.bsp/
out/
.idea/
.metals
.vscode
.bloop
6 changes: 5 additions & 1 deletion build.sc
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
43 changes: 38 additions & 5 deletions cli/src/org/scalajs/cli/Scalajsld.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand All @@ -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(
Expand Down Expand Up @@ -134,6 +139,12 @@ object Scalajsld {
.valueName("<dir>")
.action { (x, c) => c.copy(outputDir = Some(x)) }
.text("Output directory of linker (required)")
opt[File]("importmap")
.valueName("<path/to/file>.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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
41 changes: 41 additions & 0 deletions cli/src/org/scalajs/cli/internal/ImportMap.scala
Original file line number Diff line number Diff line change
@@ -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)}
}
}
119 changes: 115 additions & 4 deletions tests/test/src/org/scalajs/cli/tests/Tests.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand All @@ -48,7 +48,7 @@ class Tests extends munit.FunSuite {
os.proc(
"cs",
"launch",
"scalac:2.13.6",
"scalac:2.13.12",
"--",
"-classpath",
scalaJsLibraryCp,
Expand Down Expand Up @@ -134,7 +134,7 @@ class Tests extends munit.FunSuite {
os.proc(
"cs",
"launch",
"scalac:2.13.6",
"scalac:2.13.12",
"--",
"-classpath",
scalaJsLibraryCp,
Expand Down Expand Up @@ -188,7 +188,7 @@ class Tests extends munit.FunSuite {
os.proc(
"cs",
"launch",
"scalac:2.13.6",
"scalac:2.13.12",
"--",
"-classpath",
scalaJsLibraryCp,
Expand Down Expand Up @@ -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", """<html><head><script type="module" src="main.js"></script></head><body></body></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))
}
}

0 comments on commit ade123e

Please sign in to comment.