Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support remapping imports at link time #47

Merged
merged 4 commits into from
Jan 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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?
Gedochao marked this conversation as resolved.
Show resolved Hide resolved
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)
Gedochao marked this conversation as resolved.
Show resolved Hide resolved
assert(os.exists(dir / "out" / "main.js"))
val rawJs = os.read.lines(dir / "out" / "main.js")
assert(rawJs(1).contains(substTo))
}
}
Loading