diff options
Diffstat (limited to 'sbt-plugin/src/main/scala')
30 files changed, 3516 insertions, 0 deletions
diff --git a/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/AbstractJSDeps.scala b/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/AbstractJSDeps.scala new file mode 100644 index 0000000..9eb7f69 --- /dev/null +++ b/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/AbstractJSDeps.scala @@ -0,0 +1,81 @@ +package scala.scalajs.sbtplugin + +import sbt._ + +import StringUtilities.nonEmpty + +import scala.scalajs.tools.jsdep.JSDependency + +/** Something JavaScript related a project may depend on. Either a JavaScript + * module/library, or the DOM at runtime. */ +sealed trait AbstractJSDep { + def configurations: Option[String] + + protected def withConfigs(configs: Option[String]): AbstractJSDep + + def %(configurations: Configuration): AbstractJSDep = %(configurations.name) + def %(configurations: String): AbstractJSDep = { + require(this.configurations.isEmpty, + "Configurations already specified for jsModule " + this) + nonEmpty(configurations, "Configurations") + withConfigs(Some(configurations)) + } + +} + +/** A JavaScript module/library a Scala.js project may depend on */ +sealed trait JSModuleID extends AbstractJSDep { + def jsDep: JSDependency + + protected def withJSDep(jsDep: JSDependency): JSModuleID + + def commonJSName(name: String): JSModuleID = + withJSDep(jsDep = jsDep.commonJSName(name)) + + def dependsOn(names: String*): JSModuleID = + withJSDep(jsDep = jsDep.dependsOn(names: _*)) +} + +/** A JavaScript module that resides inside a jar (probably webjar) */ +final case class JarJSModuleID( + module: ModuleID, + jsDep: JSDependency) extends JSModuleID { + + def configurations: Option[String] = module.configurations + + protected def withConfigs(configs: Option[String]): JSModuleID = + copy(module = module.copy(configurations = configs)) + protected def withJSDep(jsDep: JSDependency): JSModuleID = + copy(jsDep = jsDep) +} + +object JarJSModuleID { + def apply(module: ModuleID, name: String): JarJSModuleID = + JarJSModuleID(module, new JSDependency(name, Nil)) +} + +/** A JavaScript module that we depend on, but is provided externally or + * by the project itself */ +final case class ProvidedJSModuleID( + jsDep: JSDependency, + configurations: Option[String]) extends JSModuleID { + + protected def withConfigs(configs: Option[String]): JSModuleID = + copy(configurations = configs) + protected def withJSDep(jsDep: JSDependency): JSModuleID = + copy(jsDep = jsDep) +} + +object ProvidedJSModuleID { + def apply(name: String, configurations: Option[String]): ProvidedJSModuleID = + ProvidedJSModuleID(new JSDependency(name, Nil), configurations) +} + +sealed case class RuntimeDOM( + configurations: Option[String]) extends AbstractJSDep { + + protected def withConfigs(configs: Option[String]): RuntimeDOM = + copy(configurations = configs) +} + +object RuntimeDOM extends RuntimeDOM(None) diff --git a/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/Implicits.scala b/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/Implicits.scala new file mode 100644 index 0000000..0c1559f --- /dev/null +++ b/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/Implicits.scala @@ -0,0 +1,34 @@ +package scala.scalajs.sbtplugin + +import scala.language.implicitConversions + +import scala.scalajs.tools.logging._ +import sbt.{Logger => SbtLogger, Level => SbtLevel} + +object Implicits { + private class SbtLoggerWrapper(underlying: SbtLogger) extends Logger { + def log(level: Level, message: => String): Unit = + underlying.log(level, message) + def success(message: => String): Unit = + underlying.success(message) + def trace(t: => Throwable): Unit = + underlying.trace(t) + } + + implicit def sbtLogger2ToolsLogger(logger: SbtLogger): Logger = + new SbtLoggerWrapper(logger) + + implicit def sbtLevel2ToolsLevel(level: SbtLevel.Value): Level = level match { + case SbtLevel.Error => Level.Error + case SbtLevel.Warn => Level.Warn + case SbtLevel.Info => Level.Info + case SbtLevel.Debug => Level.Debug + } + + implicit def toolsLevel2sbtLevel(level: Level): SbtLevel.Value = level match { + case Level.Error => SbtLevel.Error + case Level.Warn => SbtLevel.Warn + case Level.Info => SbtLevel.Info + case Level.Debug => SbtLevel.Debug + } +} diff --git a/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/JSUtils.scala b/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/JSUtils.scala new file mode 100644 index 0000000..a59f105 --- /dev/null +++ b/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/JSUtils.scala @@ -0,0 +1,35 @@ +package scala.scalajs.sbtplugin + +object JSUtils { + def listToJS(xs: List[String]): String = + xs.map(toJSstr _).mkString("[",",","]") + + /** (almost) stolen from scala.scalajs.compiler.JSPrinters */ + def toJSstr(str: String): String = { + /* Note that Java and JavaScript happen to use the same encoding for + * Unicode, namely UTF-16, which means that 1 char from Java always equals + * 1 char in JavaScript. */ + val builder = new StringBuilder() + builder.append('"') + str foreach { + case '\\' => builder.append("\\\\") + case '"' => builder.append("\\\"") + case '\u0007' => builder.append("\\a") + case '\u0008' => builder.append("\\b") + case '\u0009' => builder.append("\\t") + case '\u000A' => builder.append("\\n") + case '\u000B' => builder.append("\\v") + case '\u000C' => builder.append("\\f") + case '\u000D' => builder.append("\\r") + case c => + if (c >= 32 && c <= 126) builder.append(c.toChar) // ASCII printable characters + else builder.append(f"\\u$c%04x") + } + builder.append('"') + builder.result() + } + + def dot2bracket(name: String): String = { + name.split('.').map(s => s"""[${toJSstr(s)}]""").mkString + } +} diff --git a/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/LoggerJSConsole.scala b/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/LoggerJSConsole.scala new file mode 100644 index 0000000..ecfb546 --- /dev/null +++ b/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/LoggerJSConsole.scala @@ -0,0 +1,18 @@ +/* __ *\ +** ________ ___ / / ___ __ ____ Scala.js sbt plugin ** +** / __/ __// _ | / / / _ | __ / // __/ (c) 2013, LAMP/EPFL ** +** __\ \/ /__/ __ |/ /__/ __ |/_// /_\ \ http://scala-js.org/ ** +** /____/\___/_/ |_/____/_/ | |__/ /____/ ** +** |/____/ ** +\* */ + + +package scala.scalajs.sbtplugin + +import sbt.Logger +import scala.scalajs.tools.env.JSConsole + +/** A proxy for a Logger that looks like a Mozilla console object */ +class LoggerJSConsole(logger: Logger) extends JSConsole { + def log(msg: Any): Unit = logger.info(msg.toString) +} diff --git a/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/OptimizerOptions.scala b/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/OptimizerOptions.scala new file mode 100644 index 0000000..25d6178 --- /dev/null +++ b/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/OptimizerOptions.scala @@ -0,0 +1,74 @@ +/* __ *\ +** ________ ___ / / ___ __ ____ Scala.js sbt plugin ** +** / __/ __// _ | / / / _ | __ / // __/ (c) 2013, LAMP/EPFL ** +** __\ \/ /__/ __ |/ /__/ __ |/_// /_\ \ http://scala-js.org/ ** +** /____/\___/_/ |_/____/_/ | |__/ /____/ ** +** |/____/ ** +\* */ + + +package scala.scalajs.sbtplugin + +import OptimizerOptions._ + +/** Various options for the Scala.js optimizer tool chain + * + * This is not a case class and does have a private constructor so that we + * can add fields in a binary-compatible manner. + * + * Use [[OptimizerOptions.apply]] and the `with` methods to create a configured + * instance. + */ +final class OptimizerOptions private ( + /** Whether to parallelize the optimizer (currently fastOptJS only) **/ + val parallel: Boolean = true, + /** Whether to run the optimizer in batch (i.e. non-incremental) mode */ + val batchMode: Boolean = false, + /** Whether to run the Scala.js optimizer */ + val disableOptimizer: Boolean = false, + /** Whether to pretty-print in fullOptJS */ + val prettyPrintFullOptJS: Boolean = false, + /** Perform expensive checks of the sanity of the Scala.js IR */ + val checkScalaJSIR: Boolean = false +) { + + def withParallel(parallel: Boolean): OptimizerOptions = { + new OptimizerOptions(parallel, batchMode, + disableOptimizer, prettyPrintFullOptJS, checkScalaJSIR) + } + + def withBatchMode(batchMode: Boolean): OptimizerOptions = { + new OptimizerOptions(parallel, batchMode, + disableOptimizer, prettyPrintFullOptJS, checkScalaJSIR) + } + + def withDisableOptimizer(disableOptimizer: Boolean): OptimizerOptions = { + new OptimizerOptions(parallel, batchMode, + disableOptimizer, prettyPrintFullOptJS, checkScalaJSIR) + } + + def withPrettyPrintFullOptJS(prettyPrintFullOptJS: Boolean): OptimizerOptions = { + new OptimizerOptions(parallel, batchMode, + disableOptimizer, prettyPrintFullOptJS, checkScalaJSIR) + } + + def withCheckScalaJSIR(checkScalaJSIR: Boolean): OptimizerOptions = { + new OptimizerOptions(parallel, batchMode, + disableOptimizer, prettyPrintFullOptJS, checkScalaJSIR) + } + + override def toString: String = { + s"""OptimizerOptions( + | parallel = $parallel + | batchMode = $batchMode + | disableOptimizer = $disableOptimizer + | prettyPrintFullOptJS = $prettyPrintFullOptJS + | checkScalaJSIR = $checkScalaJSIR + |)""".stripMargin + } + +} + +object OptimizerOptions { + def apply() = new OptimizerOptions() +} diff --git a/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/ScalaJSCrossVersion.scala b/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/ScalaJSCrossVersion.scala new file mode 100644 index 0000000..d813622 --- /dev/null +++ b/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/ScalaJSCrossVersion.scala @@ -0,0 +1,48 @@ +/* __ *\ +** ________ ___ / / ___ __ ____ Scala.js sbt plugin ** +** / __/ __// _ | / / / _ | __ / // __/ (c) 2013, LAMP/EPFL ** +** __\ \/ /__/ __ |/ /__/ __ |/_// /_\ \ http://scala-js.org/ ** +** /____/\___/_/ |_/____/_/ | |__/ /____/ ** +** |/____/ ** +\* */ + + +package scala.scalajs.sbtplugin + +import sbt._ + +import scala.scalajs.ir.ScalaJSVersions + +object ScalaJSCrossVersion { + private val scalaJSVersionUnmapped: String => String = + _ => s"sjs$currentBinaryVersion" + + private val scalaJSVersionMap: String => String = + version => s"sjs${currentBinaryVersion}_$version" + + private final val ReleaseVersion = + raw"""(\d+)\.(\d+)\.(\d+)""".r + private final val MinorSnapshotVersion = + raw"""(\d+)\.(\d+)\.([1-9]\d*)-SNAPSHOT""".r + + val currentBinaryVersion = binaryScalaJSVersion(ScalaJSVersions.current) + + def binaryScalaJSVersion(full: String): String = full match { + case ReleaseVersion(major, minor, release) => s"$major.$minor" + case MinorSnapshotVersion(major, minor, _) => s"$major.$minor" + case _ => full + } + + def scalaJSMapped(cross: CrossVersion): CrossVersion = cross match { + case CrossVersion.Disabled => + CrossVersion.binaryMapped(scalaJSVersionUnmapped) + case cross: CrossVersion.Binary => + CrossVersion.binaryMapped(cross.remapVersion andThen scalaJSVersionMap) + case cross: CrossVersion.Full => + CrossVersion.fullMapped(cross.remapVersion andThen scalaJSVersionMap) + } + + def binary: CrossVersion = scalaJSMapped(CrossVersion.binary) + + def full: CrossVersion = scalaJSMapped(CrossVersion.full) +} diff --git a/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/ScalaJSPlugin.scala b/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/ScalaJSPlugin.scala new file mode 100644 index 0000000..b33e2fb --- /dev/null +++ b/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/ScalaJSPlugin.scala @@ -0,0 +1,179 @@ +/* __ *\ +** ________ ___ / / ___ __ ____ Scala.js sbt plugin ** +** / __/ __// _ | / / / _ | __ / // __/ (c) 2013, LAMP/EPFL ** +** __\ \/ /__/ __ |/ /__/ __ |/_// /_\ \ http://scala-js.org/ ** +** /____/\___/_/ |_/____/_/ | |__/ /____/ ** +** |/____/ ** +\* */ + + +package scala.scalajs.sbtplugin + +import sbt._ + +import scala.scalajs.tools.sem.Semantics +import scala.scalajs.tools.classpath._ +import scala.scalajs.tools.io.VirtualJSFile +import scala.scalajs.tools.env.{JSEnv, JSConsole} +import scala.scalajs.tools.optimizer.ScalaJSOptimizer + +import scala.scalajs.ir.ScalaJSVersions + +import scala.scalajs.sbtplugin.env.nodejs.NodeJSEnv +import scala.scalajs.sbtplugin.env.phantomjs.PhantomJSEnv + +object ScalaJSPlugin extends AutoPlugin { + override def requires = plugins.JvmPlugin + + object autoImport extends impl.DependencyBuilders { + import KeyRanks._ + + // Some constants + val scalaJSVersion = ScalaJSVersions.current + val scalaJSIsSnapshotVersion = ScalaJSVersions.currentIsSnapshot + val scalaJSBinaryVersion = ScalaJSCrossVersion.currentBinaryVersion + + // Stage values + val PreLinkStage = Stage.PreLink + val FastOptStage = Stage.FastOpt + val FullOptStage = Stage.FullOpt + + // Factory methods for JSEnvs + + /** + * Creates a [[Def.Initialize]] for a NodeJSEnv. Use this to explicitly + * specify in your build that you would like to run with Node.js: + * + * {{{ + * postLinkJSEnv := NodeJSEnv().value + * }}} + * + * Note that the resulting [[Setting]] is not scoped at all, but must be + * scoped in a project that has the ScalaJSPlugin enabled to work properly. + * Therefore, either put the upper line in your project settings (common + * case) or scope it manually, using [[Project.inScope]]. + */ + def NodeJSEnv( + executable: String = "node", + args: Seq[String] = Seq.empty, + env: Map[String, String] = Map.empty + ): Def.Initialize[Task[NodeJSEnv]] = Def.task { + new NodeJSEnv(executable, args, env) + } + + /** + * Creates a [[Def.Initialize]] for a PhantomJSEnv. Use this to explicitly + * specify in your build that you would like to run with PhantomJS: + * + * {{{ + * postLinkJSEnv := PhantomJSEnv().value + * }}} + * + * Note that the resulting [[Setting]] is not scoped at all, but must be + * scoped in a project that has the ScalaJSPlugin enabled to work properly. + * Therefore, either put the upper line in your project settings (common + * case) or scope it manually, using [[Project.inScope]]. + */ + def PhantomJSEnv( + executable: String = "phantomjs", + args: Seq[String] = Seq.empty, + env: Map[String, String] = Map.empty, + autoExit: Boolean = true + ): Def.Initialize[Task[PhantomJSEnv]] = Def.task { + val loader = scalaJSPhantomJSClassLoader.value + new PhantomJSEnv(executable, args, env, autoExit, loader) + } + + // All our public-facing keys + + val fastOptJS = TaskKey[Attributed[File]]("fastOptJS", + "Quickly link all compiled JavaScript into a single file", APlusTask) + val fullOptJS = TaskKey[Attributed[File]]("fullOptJS", + "Link all compiled JavaScript into a single file and fully optimize", APlusTask) + + val scalaJSStage = SettingKey[Stage]("scalaJSStage", + "The optimization stage at which run and test are executed", APlusSetting) + + val packageScalaJSLauncher = TaskKey[Attributed[File]]("packageScalaJSLauncher", + "Writes the persistent launcher file. Fails if the mainClass is ambigous", CTask) + + val packageJSDependencies = TaskKey[File]("packageJSDependencies", + "Packages all dependencies of the preLink classpath in a single file. " + + "Set skip in packageJSDependencies := false to run automatically", AMinusTask) + + val jsDependencyManifest = TaskKey[File]("jsDependencyManifest", + "Writes the JS_DEPENDENCIES file.", DTask) + + val scalaJSPreLinkClasspath = TaskKey[IRClasspath]("scalaJSPreLinkClasspath", + "Completely resolved classpath just after compilation", DTask) + + val scalaJSExecClasspath = TaskKey[CompleteClasspath]("scalaJSExecClasspath", + "The classpath used for running and testing", DTask) + + val scalaJSLauncher = TaskKey[Attributed[VirtualJSFile]]("scalaJSLauncher", + "Code used to run. (Attributed with used class name)", DTask) + + val scalaJSConsole = TaskKey[JSConsole]("scalaJSConsole", + "The JS console used by the Scala.js runner/tester", DTask) + + val preLinkJSEnv = TaskKey[JSEnv]("preLinkJSEnv", + "The jsEnv used to execute before linking (packaging / optimizing) Scala.js files", BSetting) + val postLinkJSEnv = TaskKey[JSEnv]("postLinkJSEnv", + "The jsEnv used to execute after linking (packaging / optimizing) Scala.js files", AMinusSetting) + + val jsEnv = TaskKey[JSEnv]("jsEnv", + "A JVM-like environment where Scala.js files can be run and tested", DTask) + + val requiresDOM = SettingKey[Boolean]("requiresDOM", + "Whether this projects needs the DOM. Overrides anything inherited through dependencies.", AMinusSetting) + + val scalaJSTestFramework = SettingKey[String]("scalaJSTestFramework", + "The Scala.js class that is used as a test framework, for example a class that wraps Jasmine", ASetting) + + val relativeSourceMaps = SettingKey[Boolean]("relativeSourceMaps", + "Make the referenced paths on source maps relative to target path", BPlusSetting) + + val emitSourceMaps = SettingKey[Boolean]("emitSourceMaps", + "Whether package and optimize stages should emit source maps at all", BPlusSetting) + + val jsDependencies = SettingKey[Seq[AbstractJSDep]]("jsDependencies", + "JavaScript libraries this project depends upon. Also used to depend on the DOM.", APlusSetting) + + val scalaJSSemantics = SettingKey[Semantics]("scalaJSSemantics", + "Configurable semantics of Scala.js.", BPlusSetting) + + val jsDependencyFilter = SettingKey[PartialClasspath.DependencyFilter]("jsDependencyFilter", + "The filter applied to the raw JavaScript dependencies before execution", CSetting) + + val checkScalaJSSemantics = SettingKey[Boolean]("checkScalaJSSemantics", + "Whether to check that the current semantics meet compliance " + + "requirements of dependencies.", CSetting) + + val persistLauncher = SettingKey[Boolean]("persistLauncher", + "Tell optimize/package tasks to write the laucher file to disk. " + + "If this is set, your project may only have a single mainClass or you must explicitly set it", AMinusSetting) + + val scalaJSOptimizerOptions = SettingKey[OptimizerOptions]("scalaJSOptimizerOptions", + "All kinds of options for the Scala.js optimizer stages", DSetting) + + /** Class loader for PhantomJSEnv. Used to load jetty8. */ + val scalaJSPhantomJSClassLoader = TaskKey[ClassLoader]("scalaJSPhantomJSClassLoader", + "Private class loader to load jetty8 without polluting classpath. Only use this " + + "as the `jettyClassLoader` argument of the PhantomJSEnv", + KeyRanks.Invisible) + } + + import autoImport._ + import ScalaJSPluginInternal._ + + override def globalSettings: Seq[Setting[_]] = { + super.globalSettings ++ Seq( + scalaJSStage := Stage.PreLink + ) + } + + override def projectSettings: Seq[Setting[_]] = ( + scalaJSAbstractSettings ++ + scalaJSEcosystemSettings + ) +} diff --git a/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/ScalaJSPluginInternal.scala b/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/ScalaJSPluginInternal.scala new file mode 100644 index 0000000..fe97f0b --- /dev/null +++ b/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/ScalaJSPluginInternal.scala @@ -0,0 +1,598 @@ +package scala.scalajs.sbtplugin + +import sbt._ +import sbt.inc.{IncOptions, ClassfileManager} +import Keys._ + +import Implicits._ +import JSUtils._ + +import scala.scalajs.tools.sem.Semantics +import scala.scalajs.tools.io.{IO => toolsIO, _} +import scala.scalajs.tools.classpath._ +import scala.scalajs.tools.classpath.builder._ +import scala.scalajs.tools.jsdep._ +import scala.scalajs.tools.optimizer.{ + ScalaJSOptimizer, + ScalaJSClosureOptimizer, + IncOptimizer, + ParIncOptimizer +} +import scala.scalajs.tools.corelib.CoreJSLibs + +import scala.scalajs.tools.env._ +import scala.scalajs.sbtplugin.env.rhino.RhinoJSEnv +import scala.scalajs.sbtplugin.env.nodejs.NodeJSEnv +import scala.scalajs.sbtplugin.env.phantomjs.{PhantomJSEnv, PhantomJettyClassLoader} + +import scala.scalajs.ir.ScalaJSVersions + +import scala.scalajs.sbtplugin.testing.{TestFramework, JSClasspathLoader} + +import scala.util.Try + +import java.nio.charset.Charset +import java.net.URLClassLoader + +/** Contains settings used by ScalaJSPlugin that should not be automatically + * be in the *.sbt file's scope. + */ +object ScalaJSPluginInternal { + + import ScalaJSPlugin.autoImport._ + + /** Dummy setting to ensure we do not fork in Scala.js run & test. */ + val scalaJSEnsureUnforked = SettingKey[Boolean]("ensureUnforked", + "Scala.js internal: Fails if fork is true.", KeyRanks.Invisible) + + /** Dummy setting to persist Scala.js optimizer */ + val scalaJSOptimizer = SettingKey[ScalaJSOptimizer]("scalaJSOptimizer", + "Scala.js internal: Setting to persist the optimizer", KeyRanks.Invisible) + + /** Internal task to calculate whether a project requests the DOM + * (through jsDependencies or requiresDOM) */ + val scalaJSRequestsDOM = TaskKey[Boolean]("scalaJSRequestsDOM", + "Scala.js internal: Whether a project really wants the DOM. " + + "Calculated using requiresDOM and jsDependencies", KeyRanks.Invisible) + + /** Default post link environment */ + val scalaJSDefaultPostLinkJSEnv = TaskKey[JSEnv]("scalaJSDefaultPostLinkJSEnv", + "Scala.js internal: Default for postLinkJSEnv", KeyRanks.Invisible) + + /** Lookup key for CompleteClasspath in attribute maps */ + val scalaJSCompleteClasspath = + AttributeKey[CompleteClasspath]("scalaJSCompleteClasspath") + + /** Patches the IncOptions so that .sjsir files are pruned as needed. + * + * This complicated logic patches the ClassfileManager factory of the given + * IncOptions with one that is aware of .sjsir files emitted by the Scala.js + * compiler. This makes sure that, when a .class file must be deleted, the + * corresponding .sjsir file are also deleted. + */ + def scalaJSPatchIncOptions(incOptions: IncOptions): IncOptions = { + val inheritedNewClassfileManager = incOptions.newClassfileManager + val newClassfileManager = () => new ClassfileManager { + private[this] val inherited = inheritedNewClassfileManager() + + def delete(classes: Iterable[File]): Unit = { + inherited.delete(classes flatMap { classFile => + val scalaJSFiles = if (classFile.getPath endsWith ".class") { + val f = FileVirtualFile.withExtension(classFile, ".class", ".sjsir") + if (f.exists) List(f) + else Nil + } else Nil + classFile :: scalaJSFiles + }) + } + + def generated(classes: Iterable[File]): Unit = inherited.generated(classes) + def complete(success: Boolean): Unit = inherited.complete(success) + } + incOptions.copy(newClassfileManager = newClassfileManager) + } + + private def scalaJSOptimizerSetting(key: TaskKey[_]): Setting[_] = ( + scalaJSOptimizer in key := { + val semantics = (scalaJSSemantics in key).value + if ((scalaJSOptimizerOptions in key).value.parallel) + new ScalaJSOptimizer(semantics, new ParIncOptimizer(_)) + else + new ScalaJSOptimizer(semantics, new IncOptimizer(_)) + } + ) + + val scalaJSConfigSettings: Seq[Setting[_]] = Seq( + incOptions ~= scalaJSPatchIncOptions + ) ++ Seq( + + scalaJSPreLinkClasspath := { + val cp = fullClasspath.value + val pcp = PartialClasspathBuilder.build(Attributed.data(cp).toList) + val ccp = pcp.resolve(jsDependencyFilter.value) + + if (checkScalaJSSemantics.value) + ccp.checkCompliance(scalaJSSemantics.value) + + ccp + }, + + artifactPath in fastOptJS := + ((crossTarget in fastOptJS).value / + ((moduleName in fastOptJS).value + "-fastopt.js")), + + scalaJSOptimizerSetting(fastOptJS), + + fastOptJS := { + val s = streams.value + val output = (artifactPath in fastOptJS).value + val taskCache = + WritableFileVirtualTextFile(s.cacheDirectory / "fastopt-js") + + IO.createDirectory(output.getParentFile) + + val relSourceMapBase = + if ((relativeSourceMaps in fastOptJS).value) + Some(output.getParentFile.toURI()) + else None + + val opts = (scalaJSOptimizerOptions in fastOptJS).value + + import ScalaJSOptimizer._ + val outCP = (scalaJSOptimizer in fastOptJS).value.optimizeCP( + Inputs(input = (scalaJSPreLinkClasspath in fastOptJS).value), + OutputConfig( + output = WritableFileVirtualJSFile(output), + cache = Some(taskCache), + wantSourceMap = (emitSourceMaps in fastOptJS).value, + relativizeSourceMapBase = relSourceMapBase, + checkIR = opts.checkScalaJSIR, + disableOptimizer = opts.disableOptimizer, + batchMode = opts.batchMode), + s.log) + + Attributed.blank(output).put(scalaJSCompleteClasspath, outCP) + }, + fastOptJS <<= + fastOptJS.dependsOn(packageJSDependencies, packageScalaJSLauncher), + + artifactPath in fullOptJS := + ((crossTarget in fullOptJS).value / + ((moduleName in fullOptJS).value + "-opt.js")), + + scalaJSSemantics in fullOptJS := + (scalaJSSemantics in fastOptJS).value.optimized, + + scalaJSOptimizerSetting(fullOptJS), + + fullOptJS := { + val s = streams.value + val output = (artifactPath in fullOptJS).value + val taskCache = + WritableFileVirtualTextFile(s.cacheDirectory / "fullopt-js") + + IO.createDirectory(output.getParentFile) + + val relSourceMapBase = + if ((relativeSourceMaps in fullOptJS).value) + Some(output.getParentFile.toURI()) + else None + + val opts = (scalaJSOptimizerOptions in fullOptJS).value + + val semantics = (scalaJSSemantics in fullOptJS).value + + import ScalaJSClosureOptimizer._ + val outCP = new ScalaJSClosureOptimizer(semantics).optimizeCP( + (scalaJSOptimizer in fullOptJS).value, + Inputs(ScalaJSOptimizer.Inputs( + input = (scalaJSPreLinkClasspath in fullOptJS).value)), + OutputConfig( + output = WritableFileVirtualJSFile(output), + cache = Some(taskCache), + wantSourceMap = (emitSourceMaps in fullOptJS).value, + relativizeSourceMapBase = relSourceMapBase, + checkIR = opts.checkScalaJSIR, + disableOptimizer = opts.disableOptimizer, + batchMode = opts.batchMode, + prettyPrint = opts.prettyPrintFullOptJS), + s.log) + + Attributed.blank(output).put(scalaJSCompleteClasspath, outCP) + }, + + artifactPath in packageScalaJSLauncher := + ((crossTarget in packageScalaJSLauncher).value / + ((moduleName in packageScalaJSLauncher).value + "-launcher.js")), + + skip in packageScalaJSLauncher := !persistLauncher.value, + + packageScalaJSLauncher <<= Def.taskDyn { + if ((skip in packageScalaJSLauncher).value) + Def.task(Attributed.blank((artifactPath in packageScalaJSLauncher).value)) + else Def.task { + mainClass.value map { mainCl => + val file = (artifactPath in packageScalaJSLauncher).value + IO.write(file, launcherContent(mainCl), Charset.forName("UTF-8")) + + // Attach the name of the main class used, (ab?)using the name key + Attributed(file)(AttributeMap.empty.put(name.key, mainCl)) + } getOrElse { + sys.error("Cannot write launcher file, since there is no or multiple mainClasses") + } + } + }, + + artifactPath in packageJSDependencies := + ((crossTarget in packageJSDependencies).value / + ((moduleName in packageJSDependencies).value + "-jsdeps.js")), + + packageJSDependencies <<= Def.taskDyn { + if ((skip in packageJSDependencies).value) + Def.task((artifactPath in packageJSDependencies).value) + else Def.task { + val cp = scalaJSPreLinkClasspath.value + val output = (artifactPath in packageJSDependencies).value + val taskCache = WritableFileVirtualJSFile( + streams.value.cacheDirectory / "package-js-deps") + + IO.createDirectory(output.getParentFile) + + val outFile = WritableFileVirtualTextFile(output) + CacheUtils.cached(cp.version, outFile, Some(taskCache)) { + toolsIO.concatFiles(outFile, cp.jsLibs.map(_.lib)) + } + + output + } + }, + + jsDependencyManifest := { + val myModule = thisProject.value.id + val config = configuration.value.name + + // Collect all libraries + val jsDeps = jsDependencies.value.collect { + case dep: JSModuleID if dep.configurations.forall(_ == config) => + dep.jsDep + } + + val requiresDOM = jsDependencies.value.exists { + case RuntimeDOM(configurations) => + configurations.forall(_ == config) + case _ => false + } + + val compliantSemantics = scalaJSSemantics.value.compliants + + val manifest = new JSDependencyManifest(new Origin(myModule, config), + jsDeps.toList, requiresDOM, compliantSemantics) + + // Write dependency file to class directory + val targetDir = classDirectory.value + IO.createDirectory(targetDir) + + val file = targetDir / JSDependencyManifest.ManifestFileName + val vfile = WritableFileVirtualTextFile(file) + + // Prevent writing if unnecessary to not invalidate dependencies + val needWrite = !vfile.exists || { + Try { + val readManifest = JSDependencyManifest.read(vfile) + readManifest != manifest + } getOrElse true + } + + if (needWrite) + JSDependencyManifest.write(manifest, vfile) + + file + }, + + products <<= products.dependsOn(jsDependencyManifest), + + console <<= console.dependsOn(Def.task( + streams.value.log.warn("Scala REPL doesn't work with Scala.js. You " + + "are running a JVM REPL. JavaScript things won't work.") + )), + + // Give tasks ability to check we are not forking at build reading time + scalaJSEnsureUnforked := { + if (fork.value) + sys.error("Scala.js cannot be run in a forked JVM") + else + true + }, + + scalaJSRequestsDOM := + requiresDOM.?.value.getOrElse(scalaJSExecClasspath.value.requiresDOM), + + // Default jsEnv + jsEnv <<= Def.taskDyn { + scalaJSStage.value match { + case Stage.PreLink => + Def.task { + preLinkJSEnv.?.value.getOrElse { + new RhinoJSEnv(scalaJSSemantics.value, + withDOM = scalaJSRequestsDOM.value) + } + } + case Stage.FastOpt | Stage.FullOpt => + Def.task(scalaJSDefaultPostLinkJSEnv.value) + } + }, + + // Wire jsEnv and sources for other stages + scalaJSDefaultPostLinkJSEnv := postLinkJSEnv.?.value.getOrElse { + if (scalaJSRequestsDOM.value) + new PhantomJSEnv(jettyClassLoader = scalaJSPhantomJSClassLoader.value) + else + new NodeJSEnv + }, + + scalaJSExecClasspath <<= Def.taskDyn { + scalaJSStage.value match { + case Stage.PreLink => + Def.task { scalaJSPreLinkClasspath.value } + case Stage.FastOpt => + Def.task { fastOptJS.value.get(scalaJSCompleteClasspath).get } + case Stage.FullOpt => + Def.task { fullOptJS.value.get(scalaJSCompleteClasspath).get } + } + } + ) + + /** Run a class in a given environment using a given launcher */ + private def jsRun(env: JSEnv, cp: CompleteClasspath, mainCl: String, + launcher: VirtualJSFile, jsConsole: JSConsole, log: Logger) = { + + log.info("Running " + mainCl) + log.debug(s"with JSEnv of type ${env.getClass()}") + log.debug(s"with classpath of type ${cp.getClass}") + + // Actually run code + env.jsRunner(cp, launcher, log, jsConsole).run() + } + + private def launcherContent(mainCl: String) = { + // If we are running in Node.js, we need to bracket select on + // global rather than this + """((typeof global === "object" && global && + global["Object"] === Object) ? global : this)""" + + s"${dot2bracket(mainCl)}().main();\n" + } + + private def memLauncher(mainCl: String) = { + new MemVirtualJSFile("Generated launcher file") + .withContent(launcherContent(mainCl)) + } + + // These settings will be filtered by the stage dummy tasks + val scalaJSRunSettings = Seq( + mainClass in scalaJSLauncher := (mainClass in run).value, + scalaJSLauncher <<= Def.taskDyn { + if (persistLauncher.value) + Def.task(packageScalaJSLauncher.value.map(FileVirtualJSFile)) + else Def.task { + (mainClass in scalaJSLauncher).value map { mainClass => + val memLaunch = memLauncher(mainClass) + Attributed[VirtualJSFile](memLaunch)( + AttributeMap.empty.put(name.key, mainClass)) + } getOrElse { + sys.error("No main class detected.") + } + } + }, + + /* We do currently not discover objects containing a + * + * def main(args: Array[String]): Unit + * + * Support will be added again, as soon as we can run them + * reliably (e.g. without implicitly requiring that an exported + * + * def main(): Unit + * + * exists alongside. + */ + discoveredMainClasses := { + import xsbt.api.{Discovered, Discovery} + + val jsApp = "scala.scalajs.js.JSApp" + + def isJSApp(discovered: Discovered) = + discovered.isModule && discovered.baseClasses.contains(jsApp) + + Discovery(Set(jsApp), Set.empty)(Tests.allDefs(compile.value)) collect { + case (definition, discovered) if isJSApp(discovered) => + definition.name + } + }, + + run <<= Def.inputTask { + // use assert to prevent warning about pure expr in stat pos + assert(scalaJSEnsureUnforked.value) + + val launch = scalaJSLauncher.value + val className = launch.get(name.key).getOrElse("<unknown class>") + jsRun(jsEnv.value, scalaJSExecClasspath.value, className, + launch.data, scalaJSConsole.value, streams.value.log) + }, + + runMain <<= { + // Implicits for parsing + import sbinary.DefaultProtocol.StringFormat + import Cache.seqFormat + + val parser = Defaults.loadForParser(discoveredMainClasses)((s, names) => + Defaults.runMainParser(s, names getOrElse Nil)) + + Def.inputTask { + // use assert to prevent warning about pure expr in stat pos + assert(scalaJSEnsureUnforked.value) + + val mainCl = parser.parsed._1 + jsRun(jsEnv.value, scalaJSExecClasspath.value, mainCl, + memLauncher(mainCl), scalaJSConsole.value, streams.value.log) + } + } + ) + + val scalaJSCompileSettings = ( + scalaJSConfigSettings ++ + scalaJSRunSettings + ) + + val scalaJSTestFrameworkSettings = Seq( + // Copied from Defaults, but scoped. We need a JVM loader in + // loadedTestFrameworks to find out whether the framework exists. + testLoader in loadedTestFrameworks := { + TestFramework.createTestLoader( + Attributed.data(fullClasspath.value), + scalaInstance.value, + IO.createUniqueDirectory(taskTemporaryDirectory.value)) + }, + + loadedTestFrameworks := { + // use assert to prevent warning about pure expr in stat pos + assert(scalaJSEnsureUnforked.value) + + val loader = (testLoader in loadedTestFrameworks).value + val isTestFrameworkDefined = try { + Class.forName(scalaJSTestFramework.value, false, loader) + true + } catch { + case _: ClassNotFoundException => false + } + if (isTestFrameworkDefined) { + loadedTestFrameworks.value.updated( + sbt.TestFramework(classOf[TestFramework].getName), + new TestFramework( + environment = jsEnv.value, + jsConsole = scalaJSConsole.value, + testFramework = scalaJSTestFramework.value) + ) + } else { + loadedTestFrameworks.value + } + }, + + // Pseudo loader to pass classpath to test framework + testLoader := JSClasspathLoader(scalaJSExecClasspath.value) + ) + + val scalaJSTestBuildSettings = ( + scalaJSConfigSettings + ) ++ ( + Seq(fastOptJS, fullOptJS, packageScalaJSLauncher, + packageJSDependencies) map { packageJSTask => + moduleName in packageJSTask := moduleName.value + "-test" + } + ) + + val scalaJSTestSettings = ( + scalaJSTestBuildSettings ++ + scalaJSTestFrameworkSettings + ) + + val scalaJSDependenciesSettings = Seq( + // add all the webjars your jsDependencies depend upon + libraryDependencies ++= jsDependencies.value.collect { + case JarJSModuleID(module, _) => module + } + ) + + val scalaJSDefaultBuildConfigs = ( + inConfig(Compile)(scalaJSConfigSettings) ++ // build settings for Compile + inConfig(Test)(scalaJSTestBuildSettings) ++ + scalaJSDependenciesSettings + ) + + val scalaJSDefaultConfigs = ( + inConfig(Compile)(scalaJSCompileSettings) ++ + inConfig(Test)(scalaJSTestSettings) ++ + scalaJSDependenciesSettings + ) + + val phantomJSJettyModules = Seq( + "org.eclipse.jetty" % "jetty-websocket" % "8.1.16.v20140903", + "org.eclipse.jetty" % "jetty-server" % "8.1.16.v20140903" + ) + + val scalaJSProjectBaseSettings = Seq( + relativeSourceMaps := false, + persistLauncher := false, + + skip in packageJSDependencies := true, + + scalaJSTestFramework := "org.scalajs.jasminetest.JasmineTestFramework", + + emitSourceMaps := true, + + scalaJSOptimizerOptions := OptimizerOptions(), + + jsDependencies := Seq(), + jsDependencyFilter := identity, + + scalaJSSemantics := Semantics.Defaults, + checkScalaJSSemantics := true, + + scalaJSConsole := ConsoleJSConsole, + + clean <<= clean.dependsOn(Def.task { + // have clean reset incremental optimizer state + (scalaJSOptimizer in (Compile, fastOptJS)).value.clean() + (scalaJSOptimizer in (Test, fastOptJS)).value.clean() + }), + + /* Depend on jetty artifacts in dummy configuration to be able to inject + * them into the PhantomJS runner if necessary. + * See scalaJSPhantomJSClassLoader + */ + ivyConfigurations += config("phantom-js-jetty").hide, + libraryDependencies ++= phantomJSJettyModules.map(_ % "phantom-js-jetty"), + scalaJSPhantomJSClassLoader := { + val report = update.value + val jars = report.select(configurationFilter("phantom-js-jetty")) + + val jettyLoader = + new URLClassLoader(jars.map(_.toURI.toURL).toArray, null) + + new PhantomJettyClassLoader(jettyLoader, getClass.getClassLoader) + } + ) + + val scalaJSAbstractSettings: Seq[Setting[_]] = ( + scalaJSProjectBaseSettings ++ + scalaJSDefaultConfigs + ) + + val scalaJSAbstractBuildSettings: Seq[Setting[_]] = ( + scalaJSProjectBaseSettings ++ + scalaJSDefaultBuildConfigs + ) + + val scalaJSReleasesResolver = Resolver.url("scala-js-releases", + url("http://dl.bintray.com/content/scala-js/scala-js-releases"))( + Resolver.ivyStylePatterns) + val scalaJSSnapshotsResolver = Resolver.url("scala-js-snapshots", + url("http://repo.scala-js.org/repo/snapshots/"))( + Resolver.ivyStylePatterns) + + val scalaJSEcosystemSettings = Seq( + // the resolver to find the compiler and library (and others) + resolvers ++= Seq(scalaJSReleasesResolver, scalaJSSnapshotsResolver), + + // you will need the Scala.js compiler plugin + autoCompilerPlugins := true, + addCompilerPlugin( + "org.scala-lang.modules.scalajs" % "scalajs-compiler" % scalaJSVersion cross CrossVersion.full), + + // and of course the Scala.js library + libraryDependencies += "org.scala-lang.modules.scalajs" %% "scalajs-library" % scalaJSVersion, + + // and you will want to be cross-compiled on the Scala.js binary version + crossVersion := ScalaJSCrossVersion.binary + ) + +} diff --git a/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/Stage.scala b/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/Stage.scala new file mode 100644 index 0000000..7f7b916 --- /dev/null +++ b/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/Stage.scala @@ -0,0 +1,18 @@ +/* __ *\ +** ________ ___ / / ___ __ ____ Scala.js sbt plugin ** +** / __/ __// _ | / / / _ | __ / // __/ (c) 2013, LAMP/EPFL ** +** __\ \/ /__/ __ |/ /__/ __ |/_// /_\ \ http://scala-js.org/ ** +** /____/\___/_/ |_/____/_/ | |__/ /____/ ** +** |/____/ ** +\* */ + + +package scala.scalajs.sbtplugin + +sealed trait Stage + +object Stage { + case object PreLink extends Stage + case object FullOpt extends Stage + case object FastOpt extends Stage +} diff --git a/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/env/ExternalJSEnv.scala b/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/env/ExternalJSEnv.scala new file mode 100644 index 0000000..e0aa557 --- /dev/null +++ b/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/env/ExternalJSEnv.scala @@ -0,0 +1,200 @@ +package scala.scalajs.sbtplugin.env + +import scala.scalajs.tools.io._ +import scala.scalajs.tools.classpath._ +import scala.scalajs.tools.env._ +import scala.scalajs.tools.logging._ + +import scala.scalajs.sbtplugin.JSUtils._ + +import java.io.{ Console => _, _ } +import scala.io.Source + +import scala.concurrent.{Future, Promise} +import scala.util.Try + +abstract class ExternalJSEnv( + final protected val additionalArgs: Seq[String], + final protected val additionalEnv: Map[String, String]) extends AsyncJSEnv { + + /** Printable name of this VM */ + protected def vmName: String + + /** Command to execute (on shell) for this VM */ + protected def executable: String + + protected class AbstractExtRunner(protected val classpath: CompleteClasspath, + protected val code: VirtualJSFile, protected val logger: Logger, + protected val console: JSConsole) { + + /** JS files used to setup VM */ + protected def initFiles(): Seq[VirtualJSFile] = Nil + + /** Sends required data to VM Stdin (can throw) */ + protected def sendVMStdin(out: OutputStream): Unit = {} + + /** VM arguments excluding executable. Override to adapt. + * Overrider is responsible to add additionalArgs. + */ + protected def getVMArgs(): Seq[String] = additionalArgs + + /** VM environment. Override to adapt. + * + * Default is `sys.env` and [[additionalEnv]] + */ + protected def getVMEnv(): Map[String, String] = + sys.env ++ additionalEnv + + /** Get files that are a library (i.e. that do not run anything) */ + protected def getLibJSFiles(): Seq[VirtualJSFile] = + initFiles() ++ classpath.allCode + + /** Get all files that are passed to VM (libraries and code) */ + protected def getJSFiles(): Seq[VirtualJSFile] = + getLibJSFiles() :+ code + + /** write a single JS file to a writer using an include fct if appropriate */ + protected def writeJSFile(file: VirtualJSFile, writer: Writer): Unit = { + // The only platform-independent way to do this in JS is to dump the file. + writer.write(file.content) + writer.write('\n') + } + + /** Pipe stdin and stdout from/to VM */ + final protected def pipeVMData(vmInst: Process): Unit = { + // Send stdin to VM. + val out = vmInst.getOutputStream() + try { sendVMStdin(out) } + finally { out.close() } + + // Pipe stdout to console + pipeToConsole(vmInst.getInputStream(), console) + + // We are probably done (stdin is closed). Report any errors + val errSrc = Source.fromInputStream(vmInst.getErrorStream(), "UTF-8") + try { errSrc.getLines.foreach(err => logger.error(err)) } + finally { errSrc.close } + } + + /** Wait for the VM to terminate, verify exit code */ + final protected def waitForVM(vmInst: Process): Unit = { + // Make sure we are done. + vmInst.waitFor() + + // Get return value and return + val retVal = vmInst.exitValue + if (retVal != 0) + sys.error(s"$vmName exited with code $retVal") + } + + protected def startVM(): Process = { + val vmArgs = getVMArgs() + val vmEnv = getVMEnv() + + val allArgs = executable +: vmArgs + val pBuilder = new ProcessBuilder(allArgs: _*) + + pBuilder.environment().clear() + for ((name, value) <- vmEnv) + pBuilder.environment().put(name, value) + + pBuilder.start() + } + + /** send a bunch of JS files to an output stream */ + final protected def sendJS(files: Seq[VirtualJSFile], + out: OutputStream): Unit = { + val writer = new BufferedWriter(new OutputStreamWriter(out, "UTF-8")) + try sendJS(files, writer) + finally writer.close() + } + + /** send a bunch of JS files to a writer */ + final protected def sendJS(files: Seq[VirtualJSFile], out: Writer): Unit = + files.foreach { writeJSFile(_, out) } + + /** pipe lines from input stream to JSConsole */ + final protected def pipeToConsole(in: InputStream, console: JSConsole) = { + val source = Source.fromInputStream(in, "UTF-8") + try { source.getLines.foreach(console.log _) } + finally { source.close() } + } + + } + + protected class ExtRunner(classpath: CompleteClasspath, code: VirtualJSFile, + logger: Logger, console: JSConsole + ) extends AbstractExtRunner(classpath, code, logger, console) + with JSRunner { + + def run(): Unit = { + val vmInst = startVM() + + pipeVMData(vmInst) + waitForVM(vmInst) + } + } + + protected class AsyncExtRunner(classpath: CompleteClasspath, + code: VirtualJSFile, logger: Logger, console: JSConsole + ) extends AbstractExtRunner(classpath, code, logger, console) + with AsyncJSRunner { + + private[this] var vmInst: Process = null + private[this] var ioThreadEx: Throwable = null + private[this] val promise = Promise[Unit] + + private[this] val thread = new Thread { + override def run(): Unit = { + // This thread should not be interrupted, so it is safe to use Trys + val pipeResult = Try(pipeVMData(vmInst)) + val vmComplete = Try(waitForVM(vmInst)) + + // Store IO exception + pipeResult recover { + case e => ioThreadEx = e + } + + // Chain Try's the other way: We want VM failure first, then IO failure + promise.complete(pipeResult orElse vmComplete) + } + } + + def start(): Future[Unit] = { + require(vmInst == null, "start() may only be called once") + vmInst = startVM() + thread.start() + promise.future + } + + def stop(): Unit = { + require(vmInst != null, "start() must have been called") + vmInst.destroy() + } + + def isRunning(): Boolean = { + require(vmInst != null, "start() must have been called") + // Emulate JDK 8 Process.isAlive + try { + vmInst.exitValue() + false + } catch { + case e: IllegalThreadStateException => + true + } + } + + def await(): Unit = { + require(vmInst != null, "start() must have been called") + thread.join() + waitForVM(vmInst) + + // At this point, the VM itself didn't fail. We need to check if + // anything bad happened while piping the data from the VM + + if (ioThreadEx != null) + throw ioThreadEx + } + } + +} diff --git a/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/env/VirtualFileMaterializer.scala b/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/env/VirtualFileMaterializer.scala new file mode 100644 index 0000000..fca1c47 --- /dev/null +++ b/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/env/VirtualFileMaterializer.scala @@ -0,0 +1,67 @@ +package scala.scalajs.sbtplugin.env + +import scala.scalajs.tools.io.{IO => _, _} + +import sbt.IO + +import java.io.File + +/** A helper class to temporarily store virtual files to the filesystem. + * + * Can be used with tools that require real files. + * @param singleDir if true, forces files to be copied into + * [[cacheDir]]. Useful to setup include directories for + * example. + */ +final class VirtualFileMaterializer(singleDir: Boolean = false) { + + val cacheDir = { + val dir = IO.createTemporaryDirectory + dir.deleteOnExit() + dir + } + + /** Create a target file to write/copy to. Will also call + * deleteOnExit on the file. + */ + private def trgFile(name: String): File = { + val f = new File(cacheDir, name) + f.deleteOnExit() + f + } + + private def materializeFileVF(vf: FileVirtualFile): File = { + if (!singleDir) vf.file + else { + val trg = trgFile(vf.name) + IO.copyFile(vf.file, trg) + trg + } + } + + def materialize(vf: VirtualTextFile): File = vf match { + case vf: FileVirtualFile => materializeFileVF(vf) + case _ => + val trg = trgFile(vf.name) + IO.write(trg, vf.content) + trg + } + + def materialize(vf: VirtualBinaryFile): File = vf match { + case vf: FileVirtualFile => materializeFileVF(vf) + case _ => + val trg = trgFile(vf.name) + IO.write(trg, vf.content) + trg + } + + /** Removes the cache directory. Any operation on this + * VirtualFileMaterializer is invalid after [[close]] has been + * called. + */ + def close(): Unit = { + cacheDir.listFiles().foreach(_.delete) + cacheDir.delete() + } + +} diff --git a/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/env/nodejs/NodeJSEnv.scala b/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/env/nodejs/NodeJSEnv.scala new file mode 100644 index 0000000..dfabe23 --- /dev/null +++ b/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/env/nodejs/NodeJSEnv.scala @@ -0,0 +1,306 @@ +/* __ *\ +** ________ ___ / / ___ __ ____ Scala.js sbt plugin ** +** / __/ __// _ | / / / _ | __ / // __/ (c) 2013, LAMP/EPFL ** +** __\ \/ /__/ __ |/ /__/ __ |/_// /_\ \ http://scala-js.org/ ** +** /____/\___/_/ |_/____/_/ | |__/ /____/ ** +** |/____/ ** +\* */ + + +package scala.scalajs.sbtplugin.env.nodejs + +import scala.scalajs.sbtplugin.env._ +import scala.scalajs.sbtplugin.JSUtils.toJSstr + +import scala.scalajs.tools.io._ +import scala.scalajs.tools.classpath._ +import scala.scalajs.tools.env._ +import scala.scalajs.tools.jsdep._ +import scala.scalajs.tools.logging._ + +import scala.scalajs.sbtplugin.JSUtils._ + +import java.io.{ Console => _, _ } +import java.net._ + +import scala.io.Source + +class NodeJSEnv( + nodejsPath: String = "node", + addArgs: Seq[String] = Seq.empty, + addEnv: Map[String, String] = Map.empty +) extends ExternalJSEnv(addArgs, addEnv) with ComJSEnv { + + protected def vmName: String = "node.js" + protected def executable: String = nodejsPath + + override def jsRunner(classpath: CompleteClasspath, code: VirtualJSFile, + logger: Logger, console: JSConsole): JSRunner = { + new NodeRunner(classpath, code, logger, console) + } + + override def asyncRunner(classpath: CompleteClasspath, code: VirtualJSFile, + logger: Logger, console: JSConsole): AsyncJSRunner = { + new AsyncNodeRunner(classpath, code, logger, console) + } + + override def comRunner(classpath: CompleteClasspath, code: VirtualJSFile, + logger: Logger, console: JSConsole): ComJSRunner = { + new ComNodeRunner(classpath, code, logger, console) + } + + protected class NodeRunner(classpath: CompleteClasspath, + code: VirtualJSFile, logger: Logger, console: JSConsole + ) extends ExtRunner(classpath, code, logger, console) + with AbstractNodeRunner + + protected class AsyncNodeRunner(classpath: CompleteClasspath, + code: VirtualJSFile, logger: Logger, console: JSConsole + ) extends AsyncExtRunner(classpath, code, logger, console) + with AbstractNodeRunner + + protected class ComNodeRunner(classpath: CompleteClasspath, + code: VirtualJSFile, logger: Logger, console: JSConsole + ) extends AsyncNodeRunner(classpath, code, logger, console) + with ComJSRunner { + + /** Retry-timeout to wait for the JS VM to connect */ + private final val acceptTimeout = 1000 + + private[this] val serverSocket = + new ServerSocket(0, 0, InetAddress.getByName(null)) // Loopback address + private[this] var comSocket: Socket = _ + private[this] var jvm2js: DataOutputStream = _ + private[this] var js2jvm: DataInputStream = _ + + private def comSetup = new MemVirtualJSFile("comSetup.js").withContent( + s""" + (function() { + // The socket for communication + var socket = null; + // The callback where received messages go + var recvCallback = null; + + // Buffers received data + var inBuffer = new Buffer(0); + + function onData(data) { + inBuffer = Buffer.concat([inBuffer, data]); + tryReadMsg(); + } + + function tryReadMsg() { + if (inBuffer.length < 4) return; + var msgLen = inBuffer.readInt32BE(0); + var byteLen = 4 + msgLen * 2; + + if (inBuffer.length < byteLen) return; + var res = ""; + + for (var i = 0; i < msgLen; ++i) + res += String.fromCharCode(inBuffer.readInt16BE(4 + i * 2)); + + inBuffer = inBuffer.slice(byteLen); + + recvCallback(res); + } + + global.scalajsCom = { + init: function(recvCB) { + if (socket !== null) throw new Error("Com already open"); + + var net = require('net'); + recvCallback = recvCB; + socket = net.connect(${serverSocket.getLocalPort}); + socket.on('data', onData); + }, + send: function(msg) { + if (socket === null) throw new Error("Com not open"); + + var len = msg.length; + var buf = new Buffer(4 + len * 2); + buf.writeInt32BE(len, 0); + for (var i = 0; i < len; ++i) + buf.writeInt16BE(msg.charCodeAt(i), 4 + i * 2); + socket.write(buf); + }, + close: function() { + if (socket === null) throw new Error("Com not open"); + socket.end(); + } + } + }).call(this); + """ + ) + + def send(msg: String): Unit = { + if (awaitConnection()) { + jvm2js.writeInt(msg.length) + jvm2js.writeChars(msg) + jvm2js.flush() + } + } + + def receive(): String = { + if (!awaitConnection()) + throw new ComJSEnv.ComClosedException + try { + val len = js2jvm.readInt() + val carr = Array.fill(len)(js2jvm.readChar()) + String.valueOf(carr) + } catch { + case e: EOFException => + throw new ComJSEnv.ComClosedException + } + } + + def close(): Unit = { + serverSocket.close() + if (jvm2js != null) + jvm2js.close() + if (js2jvm != null) + js2jvm.close() + if (comSocket != null) + comSocket.close() + } + + override def stop(): Unit = { + close() + super.stop() + } + + /** Waits until the JS VM has established a connection or terminates + * @return true if the connection was established + */ + private def awaitConnection(): Boolean = { + serverSocket.setSoTimeout(acceptTimeout) + while (comSocket == null && isRunning) { + try { + comSocket = serverSocket.accept() + jvm2js = new DataOutputStream( + new BufferedOutputStream(comSocket.getOutputStream())) + js2jvm = new DataInputStream( + new BufferedInputStream(comSocket.getInputStream())) + } catch { + case to: SocketTimeoutException => + } + } + + comSocket != null + } + + override protected def initFiles(): Seq[VirtualJSFile] = + super.initFiles :+ comSetup + + override protected def finalize(): Unit = close() + } + + protected trait AbstractNodeRunner extends AbstractExtRunner { + + protected[this] val libCache = new VirtualFileMaterializer(true) + + /** File(s) to automatically install source-map-support. + * Is used by [[initFiles]], override to change/disable. + */ + protected def installSourceMap(): Seq[VirtualJSFile] = Seq( + new MemVirtualJSFile("sourceMapSupport.js").withContent( + """ + try { + require('source-map-support').install(); + } catch (e) {} + """ + ) + ) + + /** File(s) to hack console.log to prevent if from changing `%%` to `%`. + * Is used by [[initFiles]], override to change/disable. + */ + protected def fixPercentConsole(): Seq[VirtualJSFile] = Seq( + new MemVirtualJSFile("nodeConsoleHack.js").withContent( + """ + // Hack console log to duplicate double % signs + (function() { + var oldLog = console.log; + var newLog = function() { + var args = arguments; + if (args.length >= 1 && args[0] !== void 0 && args[0] !== null) { + args[0] = args[0].toString().replace(/%/g, "%%"); + } + oldLog.apply(console, args); + }; + console.log = newLog; + })(); + """ + ) + ) + + /** File(s) to define `__ScalaJSEnv`. Defines `exitFunction`. + * Is used by [[initFiles]], override to change/disable. + */ + protected def runtimeEnv(): Seq[VirtualJSFile] = Seq( + new MemVirtualJSFile("scalaJSEnvInfo.js").withContent( + """ + __ScalaJSEnv = { + exitFunction: function(status) { process.exit(status); } + }; + """ + ) + ) + + /** Concatenates results from [[installSourceMap]], [[fixPercentConsole]] and + * [[runtimeEnv]] (in this order). + */ + override protected def initFiles(): Seq[VirtualJSFile] = + installSourceMap() ++ fixPercentConsole() ++ runtimeEnv() + + /** Libraries are loaded via require in Node.js */ + override protected def getLibJSFiles(): Seq[VirtualJSFile] = { + initFiles() ++ + classpath.jsLibs.map(requireLibrary) :+ + classpath.scalaJSCode + } + + /** Rewrites a library virtual file to a require statement if possible */ + protected def requireLibrary(dep: ResolvedJSDependency): VirtualJSFile = { + dep.info.commonJSName.fold(dep.lib) { varname => + val fname = dep.lib.name + libCache.materialize(dep.lib) + new MemVirtualJSFile(s"require-$fname").withContent( + s"""$varname = require(${toJSstr(fname)});""" + ) + } + } + + // Send code to Stdin + override protected def sendVMStdin(out: OutputStream): Unit = { + sendJS(getJSFiles(), out) + } + + /** write a single JS file to a writer using an include fct if appropriate + * uses `require` if the file exists on the filesystem + */ + override protected def writeJSFile(file: VirtualJSFile, + writer: Writer): Unit = { + file match { + case file: FileVirtualJSFile => + val fname = toJSstr(file.file.getAbsolutePath) + writer.write(s"require($fname);\n") + case _ => + super.writeJSFile(file, writer) + } + } + + // Node.js specific (system) environment + override protected def getVMEnv(): Map[String, String] = { + val baseNodePath = sys.env.get("NODE_PATH").filter(_.nonEmpty) + val nodePath = libCache.cacheDir.getAbsolutePath + + baseNodePath.fold("")(p => File.pathSeparator + p) + + sys.env ++ Seq( + "NODE_MODULE_CONTEXTS" -> "0", + "NODE_PATH" -> nodePath + ) ++ additionalEnv + } + } + +} diff --git a/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/env/phantomjs/JettyWebsocketManager.scala b/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/env/phantomjs/JettyWebsocketManager.scala new file mode 100644 index 0000000..3dec79c --- /dev/null +++ b/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/env/phantomjs/JettyWebsocketManager.scala @@ -0,0 +1,126 @@ +package scala.scalajs.sbtplugin.env.phantomjs + +import javax.servlet.http.HttpServletRequest + +import org.eclipse.jetty.server.Server +import org.eclipse.jetty.server.nio.SelectChannelConnector +import org.eclipse.jetty.websocket.{WebSocket, WebSocketHandler} +import org.eclipse.jetty.util.component.{LifeCycle, AbstractLifeCycle} +import org.eclipse.jetty.util.log + +private[phantomjs] final class JettyWebsocketManager( + wsListener: WebsocketListener) extends WebsocketManager { thisMgr => + + private[this] var webSocketConn: WebSocket.Connection = null + private[this] var closed = false + + // We can just set the logger here, since we are supposed to be protected by + // the private ClassLoader that loads us reflectively. + log.Log.setLog(new WSLogger("root")) + + private[this] val connector = new SelectChannelConnector + + connector.setHost("localhost") + connector.setPort(0) + + private[this] val server = new Server() + + server.addConnector(connector) + server.setHandler(new WebSocketHandler { + // Support Hixie 76 for Phantom.js + getWebSocketFactory().setMinVersion(-1) + + override def doWebSocketConnect( + request: HttpServletRequest, protocol: String): WebSocket = + new ComWebSocketListener + }) + + server.addLifeCycleListener(new AbstractLifeCycle.AbstractLifeCycleListener { + override def lifeCycleStarted(event: LifeCycle): Unit = { + if (event.isRunning()) + wsListener.onRunning() + } + }) + + private class ComWebSocketListener extends WebSocket.OnTextMessage { + override def onOpen(connection: WebSocket.Connection): Unit = { + thisMgr.synchronized { + if (isConnected) + throw new IllegalStateException("Client connected twice") + webSocketConn = connection + } + wsListener.onOpen() + } + + override def onClose(statusCode: Int, reason: String): Unit = { + thisMgr.synchronized { + webSocketConn = null + closed = true + } + wsListener.onClose() + server.stop() + + if (statusCode != 1000) { + throw new Exception("Abnormal closing of connection. " + + s"Code: $statusCode, Reason: $reason") + } + } + + override def onMessage(message: String): Unit = + wsListener.onMessage(message) + } + + private class WSLogger(fullName: String) extends log.AbstractLogger { + private[this] var debugEnabled = false + + def debug(msg: String, args: Object*): Unit = + if (debugEnabled) log("DEBUG", msg, args) + + def debug(msg: String, thrown: Throwable): Unit = + if (debugEnabled) log("DEBUG", msg, thrown) + + def debug(thrown: Throwable): Unit = + if (debugEnabled) log("DEBUG", thrown) + + def getName(): String = fullName + + def ignore(ignored: Throwable): Unit = () + + def info(msg: String, args: Object*): Unit = log("INFO", msg, args) + def info(msg: String, thrown: Throwable): Unit = log("INFO", msg, thrown) + def info(thrown: Throwable): Unit = log("INFO", thrown) + + def warn(msg: String, args: Object*): Unit = log("WARN", msg, args) + def warn(msg: String, thrown: Throwable): Unit = log("WARN", msg, thrown) + def warn(thrown: Throwable): Unit = log("WARN", thrown) + + def isDebugEnabled(): Boolean = debugEnabled + def setDebugEnabled(enabled: Boolean): Unit = debugEnabled = enabled + + private def log(lvl: String, msg: String, args: Object*): Unit = + wsListener.log(s"$lvl: $msg " + args.mkString(", ")) + + private def log(lvl: String, msg: String, thrown: Throwable): Unit = + wsListener.log(s"$lvl: $msg $thrown\n{$thrown.getStackStrace}") + + private def log(lvl: String, thrown: Throwable): Unit = + wsListener.log(s"$lvl: $thrown\n{$thrown.getStackStrace}") + + protected def newLogger(fullName: String) = new WSLogger(fullName) + } + + def start(): Unit = server.start() + + def stop(): Unit = server.stop() + + def isConnected: Boolean = webSocketConn != null && !closed + def isClosed: Boolean = closed + + def localPort: Int = connector.getLocalPort() + + def sendMessage(msg: String) = synchronized { + if (webSocketConn != null) + webSocketConn.sendMessage(msg) + } + +} diff --git a/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/env/phantomjs/PhantomJSEnv.scala b/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/env/phantomjs/PhantomJSEnv.scala new file mode 100644 index 0000000..7bb47d2 --- /dev/null +++ b/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/env/phantomjs/PhantomJSEnv.scala @@ -0,0 +1,466 @@ +/* __ *\ +** ________ ___ / / ___ __ ____ Scala.js sbt plugin ** +** / __/ __// _ | / / / _ | __ / // __/ (c) 2013, LAMP/EPFL ** +** __\ \/ /__/ __ |/ /__/ __ |/_// /_\ \ http://scala-js.org/ ** +** /____/\___/_/ |_/____/_/ | |__/ /____/ ** +** |/____/ ** +\* */ + + +package scala.scalajs.sbtplugin.env.phantomjs + +import scala.scalajs.sbtplugin.env._ + +import scala.scalajs.tools.io._ +import scala.scalajs.tools.classpath._ +import scala.scalajs.tools.env._ +import scala.scalajs.tools.logging._ + +import scala.scalajs.sbtplugin.JSUtils._ + +import java.io.{ Console => _, _ } +import java.net._ + +import scala.io.Source +import scala.collection.mutable +import scala.annotation.tailrec + +class PhantomJSEnv( + phantomjsPath: String = "phantomjs", + addArgs: Seq[String] = Seq.empty, + addEnv: Map[String, String] = Map.empty, + val autoExit: Boolean = true, + jettyClassLoader: ClassLoader = getClass().getClassLoader() +) extends ExternalJSEnv(addArgs, addEnv) with ComJSEnv { + + import PhantomJSEnv._ + + protected def vmName: String = "PhantomJS" + protected def executable: String = phantomjsPath + + override def jsRunner(classpath: CompleteClasspath, code: VirtualJSFile, + logger: Logger, console: JSConsole): JSRunner = { + new PhantomRunner(classpath, code, logger, console) + } + + override def asyncRunner(classpath: CompleteClasspath, code: VirtualJSFile, + logger: Logger, console: JSConsole): AsyncJSRunner = { + new AsyncPhantomRunner(classpath, code, logger, console) + } + + override def comRunner(classpath: CompleteClasspath, code: VirtualJSFile, + logger: Logger, console: JSConsole): ComJSRunner = { + new ComPhantomRunner(classpath, code, logger, console) + } + + protected class PhantomRunner(classpath: CompleteClasspath, + code: VirtualJSFile, logger: Logger, console: JSConsole + ) extends ExtRunner(classpath, code, logger, console) + with AbstractPhantomRunner + + protected class AsyncPhantomRunner(classpath: CompleteClasspath, + code: VirtualJSFile, logger: Logger, console: JSConsole + ) extends AsyncExtRunner(classpath, code, logger, console) + with AbstractPhantomRunner + + protected class ComPhantomRunner(classpath: CompleteClasspath, + code: VirtualJSFile, logger: Logger, console: JSConsole + ) extends AsyncPhantomRunner(classpath, code, logger, console) + with ComJSRunner with WebsocketListener { + + private def loadMgr() = { + val clazz = jettyClassLoader.loadClass( + "scala.scalajs.sbtplugin.env.phantomjs.JettyWebsocketManager") + + val ctors = clazz.getConstructors() + assert(ctors.length == 1, "JettyWebsocketManager may only have one ctor") + + val mgr = ctors.head.newInstance(this) + + mgr.asInstanceOf[WebsocketManager] + } + + val mgr: WebsocketManager = loadMgr() + + def onRunning(): Unit = synchronized(notifyAll()) + def onOpen(): Unit = synchronized(notifyAll()) + def onClose(): Unit = synchronized(notifyAll()) + + def onMessage(msg: String): Unit = synchronized { + recvBuf.enqueue(msg) + notifyAll() + } + + def log(msg: String): Unit = logger.debug(s"PhantomJS WS Jetty: $msg") + + private[this] val recvBuf = mutable.Queue.empty[String] + + mgr.start() + + /** The websocket server starts asynchronously, but we need the port it is + * running on. This method waits until the port is non-negative and + * returns its value. + */ + private def waitForPort(): Int = { + while (mgr.localPort < 0) + wait() + mgr.localPort + } + + private def comSetup = { + def maybeExit(code: Int) = + if (autoExit) + s"window.callPhantom({ action: 'exit', returnValue: $code });" + else + "" + + val serverPort = waitForPort() + + val code = s""" + |(function() { + | var MaxPayloadSize = $MaxCharPayloadSize; + | + | // The socket for communication + | var websocket = null; + | + | // Buffer for messages sent before socket is open + | var outMsgBuf = null; + | + | function sendImpl(msg) { + | var frags = (msg.length / MaxPayloadSize) | 0; + | + | for (var i = 0; i < frags; ++i) { + | var payload = msg.substring( + | i * MaxPayloadSize, (i + 1) * MaxPayloadSize); + | websocket.send("1" + payload); + | } + | + | websocket.send("0" + msg.substring(frags * MaxPayloadSize)); + | } + | + | function recvImpl(recvCB) { + | var recvBuf = ""; + | + | return function(evt) { + | var newData = recvBuf + evt.data.substring(1); + | if (evt.data.charAt(0) == "0") { + | recvBuf = ""; + | recvCB(newData); + | } else if (evt.data.charAt(0) == "1") { + | recvBuf = newData; + | } else { + | throw new Error("Bad fragmentation flag in " + evt.data); + | } + | }; + | } + | + | window.scalajsCom = { + | init: function(recvCB) { + | if (websocket !== null) throw new Error("Com already open"); + | + | outMsgBuf = []; + | + | websocket = new WebSocket("ws://localhost:$serverPort"); + | + | websocket.onopen = function(evt) { + | for (var i = 0; i < outMsgBuf.length; ++i) + | sendImpl(outMsgBuf[i]); + | outMsgBuf = null; + | }; + | websocket.onclose = function(evt) { + | websocket = null; + | ${maybeExit(0)} + | }; + | websocket.onmessage = recvImpl(recvCB); + | websocket.onerror = function(evt) { + | websocket = null; + | throw new Error("Websocket failed: " + evt); + | }; + | + | // Take over responsibility to auto exit + | window.callPhantom({ + | action: 'setAutoExit', + | autoExit: false + | }); + | }, + | send: function(msg) { + | if (websocket === null) + | return; // we are closed already. ignore message + | + | if (outMsgBuf !== null) + | outMsgBuf.push(msg); + | else + | sendImpl(msg); + | }, + | close: function() { + | if (websocket === null) + | return; // we are closed already. all is well. + | + | if (outMsgBuf !== null) + | // Reschedule ourselves to give onopen a chance to kick in + | window.setTimeout(window.scalajsCom.close, 10); + | else + | websocket.close(); + | } + | } + |}).call(this);""".stripMargin + + new MemVirtualJSFile("comSetup.js").withContent(code) + } + + def send(msg: String): Unit = synchronized { + if (awaitConnection()) { + val fragParts = msg.length / MaxCharPayloadSize + + for (i <- 0 until fragParts) { + val payload = msg.substring( + i * MaxCharPayloadSize, (i + 1) * MaxCharPayloadSize) + mgr.sendMessage("1" + payload) + } + + mgr.sendMessage("0" + msg.substring(fragParts * MaxCharPayloadSize)) + } + } + + def receive(): String = synchronized { + if (recvBuf.isEmpty && !awaitConnection()) + throw new ComJSEnv.ComClosedException + + @tailrec + def loop(acc: String): String = { + val frag = receiveFrag() + val newAcc = acc + frag.substring(1) + + if (frag(0) == '0') + newAcc + else if (frag(0) == '1') + loop(newAcc) + else + throw new AssertionError("Bad fragmentation flag in " + frag) + } + + loop("") + } + + private def receiveFrag(): String = { + while (recvBuf.isEmpty && !mgr.isClosed) + wait() + + if (recvBuf.isEmpty) + throw new ComJSEnv.ComClosedException + else + recvBuf.dequeue() + } + + def close(): Unit = mgr.stop() + + override def stop(): Unit = { + close() + super.stop() + } + + /** Waits until the JS VM has established a connection, or the VM + * terminated. Returns true if a connection was established. + */ + private def awaitConnection(): Boolean = { + while (!mgr.isConnected && !mgr.isClosed && isRunning) + wait(200) // We sleep-wait for isRunning + + mgr.isConnected + } + + override protected def initFiles(): Seq[VirtualJSFile] = + super.initFiles :+ comSetup + } + + protected trait AbstractPhantomRunner extends AbstractExtRunner { + + override protected def getVMArgs() = + // Add launcher file to arguments + additionalArgs :+ createTmpLauncherFile().getAbsolutePath + + /** In phantom.js, we include JS using HTML */ + override protected def writeJSFile(file: VirtualJSFile, writer: Writer) = { + file match { + case file: FileVirtualJSFile => + val fname = htmlEscape(file.file.getAbsolutePath) + writer.write( + s"""<script type="text/javascript" src="$fname"></script>""" + "\n") + case _ => + writer.write("""<script type="text/javascript">""" + "\n") + writer.write(s"// Virtual File: ${file.path}\n") + writer.write(file.content) + writer.write("</script>\n") + } + } + + /** + * PhantomJS doesn't support Function.prototype.bind. We polyfill it. + * https://github.com/ariya/phantomjs/issues/10522 + */ + override protected def initFiles(): Seq[VirtualJSFile] = Seq( + new MemVirtualJSFile("bindPolyfill.js").withContent( + """ + |// Polyfill for Function.bind from Facebook react: + |// https://github.com/facebook/react/blob/3dc10749080a460e48bee46d769763ec7191ac76/src/test/phantomjs-shims.js + |// Originally licensed under Apache 2.0 + |(function() { + | + | var Ap = Array.prototype; + | var slice = Ap.slice; + | var Fp = Function.prototype; + | + | if (!Fp.bind) { + | // PhantomJS doesn't support Function.prototype.bind natively, so + | // polyfill it whenever this module is required. + | Fp.bind = function(context) { + | var func = this; + | var args = slice.call(arguments, 1); + | + | function bound() { + | var invokedAsConstructor = func.prototype && (this instanceof func); + | return func.apply( + | // Ignore the context parameter when invoking the bound function + | // as a constructor. Note that this includes not only constructor + | // invocations using the new keyword but also calls to base class + | // constructors such as BaseClass.call(this, ...) or super(...). + | !invokedAsConstructor && context || this, + | args.concat(slice.call(arguments)) + | ); + | } + | + | // The bound function must share the .prototype of the unbound + | // function so that any object created by one constructor will count + | // as an instance of both constructors. + | bound.prototype = func.prototype; + | + | return bound; + | }; + | } + | + |})(); + |""".stripMargin + ), + new MemVirtualJSFile("scalaJSEnvInfo.js").withContent( + """ + |__ScalaJSEnv = { + | exitFunction: function(status) { + | window.callPhantom({ + | action: 'exit', + | returnValue: status | 0 + | }); + | } + |}; + """.stripMargin + ) + ) + + protected def writeWebpageLauncher(out: Writer): Unit = { + out.write("<html>\n<head>\n<title>Phantom.js Launcher</title>\n") + sendJS(getLibJSFiles(), out) + writeCodeLauncher(code, out) + out.write("</head>\n<body></body>\n</html>\n") + } + + protected def createTmpLauncherFile(): File = { + val webF = createTmpWebpage() + + val launcherTmpF = File.createTempFile("phantomjs-launcher", ".js") + launcherTmpF.deleteOnExit() + + val out = new FileWriter(launcherTmpF) + + try { + out.write( + s"""// Scala.js Phantom.js launcher + |var page = require('webpage').create(); + |var url = ${toJSstr(webF.getAbsolutePath)}; + |var autoExit = $autoExit; + |page.onConsoleMessage = function(msg) { + | console.log(msg); + |}; + |page.onError = function(msg, trace) { + | console.error(msg); + | if (trace && trace.length) { + | console.error(''); + | trace.forEach(function(t) { + | console.error(' ' + t.file + ':' + t.line + (t.function ? ' (in function "' + t.function +'")' : '')); + | }); + | } + | + | phantom.exit(2); + |}; + |page.onCallback = function(data) { + | if (!data.action) { + | console.error('Called callback without action'); + | phantom.exit(3); + | } else if (data.action === 'exit') { + | phantom.exit(data.returnValue || 0); + | } else if (data.action === 'setAutoExit') { + | if (typeof(data.autoExit) === 'boolean') + | autoExit = data.autoExit; + | else + | autoExit = true; + | } else { + | console.error('Unknown callback action ' + data.action); + | phantom.exit(4); + | } + |}; + |page.open(url, function (status) { + | if (autoExit || status !== 'success') + | phantom.exit(status !== 'success'); + |}); + |""".stripMargin) + } finally { + out.close() + } + + logger.debug( + "PhantomJS using launcher at: " + launcherTmpF.getAbsolutePath()) + + launcherTmpF + } + + protected def createTmpWebpage(): File = { + val webTmpF = File.createTempFile("phantomjs-launcher-webpage", ".html") + webTmpF.deleteOnExit() + + val out = new BufferedWriter(new FileWriter(webTmpF)) + try { + writeWebpageLauncher(out) + } finally { + out.close() + } + + logger.debug( + "PhantomJS using webpage launcher at: " + webTmpF.getAbsolutePath()) + + webTmpF + } + + protected def writeCodeLauncher(code: VirtualJSFile, out: Writer): Unit = { + out.write("""<script type="text/javascript">""" + "\n") + out.write("// Phantom.js code launcher\n") + out.write(s"// Origin: ${code.path}\n") + out.write("window.addEventListener('load', function() {\n") + out.write(code.content) + out.write("}, false);\n") + out.write("</script>\n") + } + } + + protected def htmlEscape(str: String): String = str.flatMap { + case '<' => "<" + case '>' => ">" + case '"' => """ + case '&' => "&" + case c => c :: Nil + } + +} + +object PhantomJSEnv { + private final val MaxByteMessageSize = 32768 // 32 KB + private final val MaxCharMessageSize = MaxByteMessageSize / 2 // 2B per char + private final val MaxCharPayloadSize = MaxCharMessageSize - 1 // frag flag +} diff --git a/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/env/phantomjs/PhantomJettyClassLoader.scala b/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/env/phantomjs/PhantomJettyClassLoader.scala new file mode 100644 index 0000000..02c229b --- /dev/null +++ b/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/env/phantomjs/PhantomJettyClassLoader.scala @@ -0,0 +1,63 @@ +package scala.scalajs.sbtplugin.env.phantomjs + +import scala.scalajs.tools.io.IO + +/** A special [[ClassLoader]] to load the Jetty 8 dependency of [[PhantomJSEnv]] + * in a private space. + * + * It loads everything that belongs to [[JettyWebsocketManager]] itself (while + * retrieving the requested class file from its parent. + * For all other classes, it first tries to load them from [[jettyLoader]], + * which should only contain the Jetty 8 classpath. + * If this fails, it delegates to its parent. + * + * The rationale is, that [[JettyWebsocketManager]] and its dependees can use + * the classes on the Jetty 8 classpath, while they remain hidden from the rest + * of the Java world. This allows to load another version of Jetty in the same + * JVM for the rest of the project. + */ +private[sbtplugin] class PhantomJettyClassLoader(jettyLoader: ClassLoader, + parent: ClassLoader) extends ClassLoader(parent) { + + def this(loader: ClassLoader) = + this(loader, ClassLoader.getSystemClassLoader()) + + /** Classes needed to bridge private jetty classpath and public PhantomJS + * Basically everything defined in JettyWebsocketManager. + */ + private val bridgeClasses = Set( + "scala.scalajs.sbtplugin.env.phantomjs.JettyWebsocketManager", + "scala.scalajs.sbtplugin.env.phantomjs.JettyWebsocketManager$WSLogger", + "scala.scalajs.sbtplugin.env.phantomjs.JettyWebsocketManager$ComWebSocketListener", + "scala.scalajs.sbtplugin.env.phantomjs.JettyWebsocketManager$$anon$1", + "scala.scalajs.sbtplugin.env.phantomjs.JettyWebsocketManager$$anon$2" + ) + + override protected def loadClass(name: String, resolve: Boolean): Class[_] = { + if (bridgeClasses.contains(name)) { + // Load bridgeClasses manually since they must be associated to this + // class loader, rather than the parent class loader in order to find the + // jetty classes + + // First check if we have loaded it already + Option(findLoadedClass(name)) getOrElse { + val wsManager = + parent.getResourceAsStream(name.replace('.', '/') + ".class") + + if (wsManager == null) { + throw new ClassNotFoundException(name) + } else { + val buf = IO.readInputStreamToByteArray(wsManager) + defineClass(name, buf, 0, buf.length) + } + } + } else { + try { + jettyLoader.loadClass(name) + } catch { + case _: ClassNotFoundException => + super.loadClass(name, resolve) + } + } + } +} diff --git a/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/env/phantomjs/WebsocketListener.scala b/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/env/phantomjs/WebsocketListener.scala new file mode 100644 index 0000000..4faac64 --- /dev/null +++ b/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/env/phantomjs/WebsocketListener.scala @@ -0,0 +1,10 @@ +package scala.scalajs.sbtplugin.env.phantomjs + +private[phantomjs] trait WebsocketListener { + def onRunning(): Unit + def onOpen(): Unit + def onClose(): Unit + def onMessage(msg: String): Unit + + def log(msg: String): Unit +} diff --git a/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/env/phantomjs/WebsocketManager.scala b/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/env/phantomjs/WebsocketManager.scala new file mode 100644 index 0000000..a466841 --- /dev/null +++ b/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/env/phantomjs/WebsocketManager.scala @@ -0,0 +1,10 @@ +package scala.scalajs.sbtplugin.env.phantomjs + +private[phantomjs] trait WebsocketManager { + def start(): Unit + def stop(): Unit + def sendMessage(msg: String): Unit + def localPort: Int + def isConnected: Boolean + def isClosed: Boolean +} diff --git a/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/env/rhino/LazyScalaJSScope.scala b/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/env/rhino/LazyScalaJSScope.scala new file mode 100644 index 0000000..d4cdaee --- /dev/null +++ b/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/env/rhino/LazyScalaJSScope.scala @@ -0,0 +1,96 @@ +/* __ *\ +** ________ ___ / / ___ __ ____ Scala.js sbt plugin ** +** / __/ __// _ | / / / _ | __ / // __/ (c) 2013, LAMP/EPFL ** +** __\ \/ /__/ __ |/ /__/ __ |/_// /_\ \ http://scala-js.org/ ** +** /____/\___/_/ |_/____/_/ | |__/ /____/ ** +** |/____/ ** +\* */ + + +package scala.scalajs.sbtplugin.env.rhino + +import scala.collection.mutable + +import org.mozilla.javascript.Scriptable + +/** A proxy for a ScalaJS "scope" field that loads scripts lazily + * + * E.g., ScalaJS.c, which is a scope with the Scala.js classes, can be + * turned to a LazyScalaJSScope. Upon first access to a field of ScalaJS.c, + * say ScalaJS.c.scala_Option, the script defining that particular + * field will be loaded. + * This is possible because the relative path to the script can be derived + * from the name of the property being accessed. + * + * It is immensely useful, because it allows to load lazily only the scripts + * that are actually needed. + */ +class LazyScalaJSScope( + coreLib: ScalaJSCoreLib, + globalScope: Scriptable, + base: Scriptable, + isModule: Boolean = false, + isTraitImpl: Boolean = false) extends Scriptable { + + private val fields = mutable.HashMap.empty[String, Any] + private var prototype: Scriptable = _ + private var parentScope: Scriptable = _ + + { + // Pre-fill fields with the properties of `base` + for (id <- base.getIds()) { + (id.asInstanceOf[Any]: @unchecked) match { + case name: String => put(name, this, base.get(name, base)) + case index: Int => put(index, this, base.get(index, base)) + } + } + } + + private def load(name: String): Unit = + coreLib.load(globalScope, propNameToEncodedName(name)) + + private def propNameToEncodedName(name: String): String = { + if (isTraitImpl) name.split("__")(0) + else if (isModule) name + "$" + else name + } + + override def getClassName() = "LazyScalaJSScope" + + override def get(name: String, start: Scriptable) = { + fields.getOrElse(name, { + load(name) + fields.getOrElse(name, Scriptable.NOT_FOUND) + }).asInstanceOf[AnyRef] + } + override def get(index: Int, start: Scriptable) = + get(index.toString, start) + + override def has(name: String, start: Scriptable) = + fields.contains(name) + override def has(index: Int, start: Scriptable) = + has(index.toString, start) + + override def put(name: String, start: Scriptable, value: Any) = { + fields(name) = value + } + override def put(index: Int, start: Scriptable, value: Any) = + put(index.toString, start, value) + + override def delete(name: String) = () + override def delete(index: Int) = () + + override def getPrototype() = prototype + override def setPrototype(value: Scriptable) = prototype = value + + override def getParentScope() = parentScope + override def setParentScope(value: Scriptable) = parentScope = value + + override def getIds() = fields.keys.toArray + + override def getDefaultValue(hint: java.lang.Class[_]) = { + base.getDefaultValue(hint) + } + + override def hasInstance(instance: Scriptable) = false +} diff --git a/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/env/rhino/RhinoJSEnv.scala b/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/env/rhino/RhinoJSEnv.scala new file mode 100644 index 0000000..cd35ff6 --- /dev/null +++ b/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/env/rhino/RhinoJSEnv.scala @@ -0,0 +1,303 @@ +/* __ *\ +** ________ ___ / / ___ __ ____ Scala.js sbt plugin ** +** / __/ __// _ | / / / _ | __ / // __/ (c) 2013, LAMP/EPFL ** +** __\ \/ /__/ __ |/ /__/ __ |/_// /_\ \ http://scala-js.org/ ** +** /____/\___/_/ |_/____/_/ | |__/ /____/ ** +** |/____/ ** +\* */ + + +package scala.scalajs.sbtplugin.env.rhino + +import scala.scalajs.tools.sem.Semantics +import scala.scalajs.tools.io._ +import scala.scalajs.tools.classpath._ +import scala.scalajs.tools.env._ +import scala.scalajs.tools.logging._ + +import scala.io.Source + +import scala.collection.mutable + +import scala.concurrent.{Future, Promise, Await} +import scala.concurrent.duration.Duration + +import org.mozilla.javascript._ + +class RhinoJSEnv(semantics: Semantics, + withDOM: Boolean = false) extends ComJSEnv { + + import RhinoJSEnv._ + + /** Executes code in an environment where the Scala.js library is set up to + * load its classes lazily. + * + * Other .js scripts in the inputs are executed eagerly before the provided + * `code` is called. + */ + override def jsRunner(classpath: CompleteClasspath, code: VirtualJSFile, + logger: Logger, console: JSConsole): JSRunner = { + new Runner(classpath, code, logger, console) + } + + private class Runner(classpath: CompleteClasspath, code: VirtualJSFile, + logger: Logger, console: JSConsole) extends JSRunner { + def run(): Unit = internalRunJS(classpath, code, logger, console, None) + } + + override def asyncRunner(classpath: CompleteClasspath, code: VirtualJSFile, + logger: Logger, console: JSConsole): AsyncJSRunner = { + new AsyncRunner(classpath, code, logger, console) + } + + private class AsyncRunner(classpath: CompleteClasspath, code: VirtualJSFile, + logger: Logger, console: JSConsole) extends AsyncJSRunner { + + private[this] val promise = Promise[Unit] + + private[this] val thread = new Thread { + override def run(): Unit = { + try { + internalRunJS(classpath, code, logger, console, optChannel) + promise.success(()) + } catch { + case t: Throwable => + promise.failure(t) + } + } + } + + def start(): Future[Unit] = { + thread.start() + promise.future + } + + def stop(): Unit = thread.interrupt() + + def isRunning(): Boolean = !promise.isCompleted + + def await(): Unit = Await.result(promise.future, Duration.Inf) + + protected def optChannel(): Option[Channel] = None + } + + override def comRunner(classpath: CompleteClasspath, code: VirtualJSFile, + logger: Logger, console: JSConsole): ComJSRunner = { + new ComRunner(classpath, code, logger, console) + } + + private class ComRunner(classpath: CompleteClasspath, code: VirtualJSFile, + logger: Logger, console: JSConsole) + extends AsyncRunner(classpath, code, logger, console) with ComJSRunner { + + private[this] val channel = new Channel + + override protected def optChannel(): Option[Channel] = Some(channel) + + def send(msg: String): Unit = { + try { + channel.sendToJS(msg) + } catch { + case _: ChannelClosedException => + throw new ComJSEnv.ComClosedException + } + } + + def receive(): String = { + try { + channel.recvJVM() + } catch { + case _: ChannelClosedException => + throw new ComJSEnv.ComClosedException + } + } + + def close(): Unit = channel.close() + + override def stop(): Unit = { + close() + super.stop() + } + + } + + private def internalRunJS(classpath: CompleteClasspath, code: VirtualJSFile, + logger: Logger, console: JSConsole, optChannel: Option[Channel]): Unit = { + + val context = Context.enter() + try { + val scope = context.initStandardObjects() + + if (withDOM) { + // Fetch env.rhino.js from webjar + val name = "env.rhino.js" + val path = "/META-INF/resources/webjars/envjs/1.2/" + name + val resource = getClass.getResource(path) + assert(resource != null, s"need $name as resource") + + // Rhino can't optimize envjs + context.setOptimizationLevel(-1) + + // Don't print envjs header + scope.addFunction("print", args => ()) + + // Pipe file to Rhino + val reader = Source.fromURL(resource).bufferedReader + context.evaluateReader(scope, reader, name, 1, null); + + // No need to actually define print here: It is captured by envjs to + // implement console.log, which we'll override in the next statement + } + + // Make sure Rhino does not do its magic for JVM top-level packages (#364) + val PackagesObject = + ScriptableObject.getProperty(scope, "Packages").asInstanceOf[Scriptable] + val topLevelPackageIds = ScriptableObject.getPropertyIds(PackagesObject) + for (id <- topLevelPackageIds) (id: Any) match { + case name: String => ScriptableObject.deleteProperty(scope, name) + case index: Int => ScriptableObject.deleteProperty(scope, index) + case _ => // should not happen, I think, but with Rhino you never know + } + + // Setup console.log + val jsconsole = context.newObject(scope) + jsconsole.addFunction("log", _.foreach(console.log _)) + ScriptableObject.putProperty(scope, "console", jsconsole) + + // Optionally setup scalaJSCom + var recvCallback: Option[String => Unit] = None + for (channel <- optChannel) { + val comObj = context.newObject(scope) + + comObj.addFunction("send", s => + channel.sendToJVM(Context.toString(s(0)))) + + comObj.addFunction("init", s => s(0) match { + case f: Function => + val cb: String => Unit = + msg => f.call(context, scope, scope, Array(msg)) + recvCallback = Some(cb) + case _ => + sys.error("First argument to init must be a function") + }) + + comObj.addFunction("close", _ => { + // Tell JVM side we won't send anything + channel.close() + // Internally register that we're done + recvCallback = None + }) + + ScriptableObject.putProperty(scope, "scalajsCom", comObj) + } + + try { + // Make the classpath available. Either through lazy loading or by + // simply inserting + classpath match { + case cp: IRClasspath => + // Setup lazy loading classpath and source mapper + val optLoader = if (cp.scalaJSIR.nonEmpty) { + val loader = new ScalaJSCoreLib(semantics, cp) + + // Setup sourceMapper + val scalaJSenv = context.newObject(scope) + + scalaJSenv.addFunction("sourceMapper", args => { + val trace = Context.toObject(args(0), scope) + loader.mapStackTrace(trace, context, scope) + }) + + ScriptableObject.putProperty(scope, "__ScalaJSEnv", scalaJSenv) + + Some(loader) + } else { + None + } + + // Load JS libraries + cp.jsLibs.foreach(dep => context.evaluateFile(scope, dep.lib)) + + optLoader.foreach(_.insertInto(context, scope)) + case cp => + cp.allCode.foreach(context.evaluateFile(scope, _)) + } + + context.evaluateFile(scope, code) + + // Callback the com channel if necessary (if comCallback = None, channel + // wasn't initialized on the client) + for ((channel, callback) <- optChannel zip recvCallback) { + try { + while (recvCallback.isDefined) + callback(channel.recvJS()) + } catch { + case _: ChannelClosedException => + // the JVM side closed the connection + } + } + + // Enusre the channel is closed to release JVM side + optChannel.foreach(_.close) + + } catch { + case e: RhinoException => + // Trace here, since we want to be in the context to trace. + logger.trace(e) + sys.error(s"Exception while running JS code: ${e.getMessage}") + } + } finally { + Context.exit() + } + } + +} + +object RhinoJSEnv { + + /** Communication channel between the Rhino thread and the rest of the JVM */ + private class Channel { + private[this] var _closed = false + private[this] val js2jvm = mutable.Queue.empty[String] + private[this] val jvm2js = mutable.Queue.empty[String] + + def sendToJS(msg: String): Unit = synchronized { + jvm2js.enqueue(msg) + notify() + } + + def sendToJVM(msg: String): Unit = synchronized { + js2jvm.enqueue(msg) + notify() + } + + def recvJVM(): String = synchronized { + while (js2jvm.isEmpty && ensureOpen()) + wait() + + js2jvm.dequeue() + } + + def recvJS(): String = synchronized { + while (jvm2js.isEmpty && ensureOpen()) + wait() + + jvm2js.dequeue() + } + + def close(): Unit = synchronized { + _closed = true + notify() + } + + /** Throws if the channel is closed and returns true */ + private def ensureOpen(): Boolean = { + if (_closed) + throw new ChannelClosedException + true + } + } + + private class ChannelClosedException extends Exception + +} diff --git a/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/env/rhino/ScalaJSCoreLib.scala b/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/env/rhino/ScalaJSCoreLib.scala new file mode 100644 index 0000000..e937e5b --- /dev/null +++ b/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/env/rhino/ScalaJSCoreLib.scala @@ -0,0 +1,173 @@ +/* __ *\ +** ________ ___ / / ___ __ ____ Scala.js sbt plugin ** +** / __/ __// _ | / / / _ | __ / // __/ (c) 2013, LAMP/EPFL ** +** __\ \/ /__/ __ |/ /__/ __ |/_// /_\ \ http://scala-js.org/ ** +** /____/\___/_/ |_/____/_/ | |__/ /____/ ** +** |/____/ ** +\* */ + + +package scala.scalajs.sbtplugin.env.rhino + +import scala.collection.mutable + +import org.mozilla.javascript.{Context, Scriptable} + +import scala.scalajs.ir + +import scala.scalajs.tools.sem.Semantics +import scala.scalajs.tools.javascript.{Printers, ScalaJSClassEmitter} +import scala.scalajs.tools.io._ +import scala.scalajs.tools.classpath._ +import scala.scalajs.tools.corelib._ + +class ScalaJSCoreLib(semantics: Semantics, classpath: IRClasspath) { + import ScalaJSCoreLib._ + + private val (providers, exportedSymbols) = { + val providers = mutable.Map.empty[String, VirtualScalaJSIRFile] + val exportedSymbols = mutable.ListBuffer.empty[String] + + for (irFile <- classpath.scalaJSIR) { + val info = irFile.roughInfo + providers += info.encodedName -> irFile + if (info.isExported) + exportedSymbols += info.encodedName + } + + (providers, exportedSymbols) + } + + def insertInto(context: Context, scope: Scriptable) = { + CoreJSLibs.libs(semantics).foreach(context.evaluateFile(scope, _)) + lazifyScalaJSFields(scope) + + // Make sure exported symbols are loaded + val ScalaJS = Context.toObject(scope.get("ScalaJS", scope), scope) + val c = Context.toObject(ScalaJS.get("c", ScalaJS), scope) + for (encodedName <- exportedSymbols) + c.get(encodedName, c) + } + + /** Source maps the given stack trace (where possible) */ + def mapStackTrace(stackTrace: Scriptable, + context: Context, scope: Scriptable): Scriptable = { + val count = Context.toNumber(stackTrace.get("length", stackTrace)).toInt + + // Maps file -> max line (0-based) + val neededMaps = mutable.Map.empty[String, Int] + + // Collect required line counts + for (i <- 0 until count) { + val elem = Context.toObject(stackTrace.get(i, stackTrace), scope) + val fileName = Context.toString(elem.get("fileName", elem)) + + if (fileName.endsWith(PseudoFileSuffix) && + providers.contains(fileName.stripSuffix(PseudoFileSuffix))) { + + val curMaxLine = neededMaps.getOrElse(fileName, -1) + val reqLine = Context.toNumber(elem.get("lineNumber", elem)).toInt - 1 + + if (reqLine > curMaxLine) + neededMaps.put(fileName, reqLine) + } + } + + // Map required files + val maps = + for ((fileName, maxLine) <- neededMaps) + yield (fileName, getSourceMapper(fileName, maxLine)) + + // Create new stack trace to return + val res = context.newArray(scope, count) + + for (i <- 0 until count) { + val elem = Context.toObject(stackTrace.get(i, stackTrace), scope) + val fileName = Context.toString(elem.get("fileName", elem)) + val line = Context.toNumber(elem.get("lineNumber", elem)).toInt - 1 + + val pos = maps.get(fileName).fold(ir.Position.NoPosition)(_(line)) + + val newElem = + if (pos.isDefined) newPosElem(scope, context, elem, pos) + else elem + + res.put(i, res, newElem) + } + + res + } + + private def getSourceMapper(fileName: String, untilLine: Int) = { + val irFile = providers(fileName.stripSuffix(PseudoFileSuffix)) + val mapper = new Printers.ReverseSourceMapPrinter(untilLine) + val classDef = irFile.tree + val desugared = new ScalaJSClassEmitter(semantics).genClassDef(classDef) + mapper.reverseSourceMap(desugared) + mapper + } + + private def newPosElem(scope: Scriptable, context: Context, + origElem: Scriptable, pos: ir.Position): Scriptable = { + assert(pos.isDefined) + + val elem = context.newObject(scope) + + elem.put("declaringClass", elem, origElem.get("declaringClass", origElem)) + elem.put("methodName", elem, origElem.get("methodName", origElem)) + elem.put("fileName", elem, pos.source.toString) + elem.put("lineNumber", elem, pos.line + 1) + elem.put("columnNumber", elem, pos.column + 1) + + elem + } + + private val scalaJSLazyFields = Seq( + Info("d"), + Info("c"), + Info("h"), + Info("i", isTraitImpl = true), + Info("n", isModule = true), + Info("m", isModule = true), + Info("is"), + Info("as"), + Info("isArrayOf"), + Info("asArrayOf")) + + private def lazifyScalaJSFields(scope: Scriptable) = { + val ScalaJS = Context.toObject(scope.get("ScalaJS", scope), scope) + + def makeLazyScalaJSScope(base: Scriptable, isModule: Boolean, isTraitImpl: Boolean) = + new LazyScalaJSScope(this, scope, base, isModule, isTraitImpl) + + for (Info(name, isModule, isTraitImpl) <- scalaJSLazyFields) { + val base = ScalaJS.get(name, ScalaJS).asInstanceOf[Scriptable] + val lazified = makeLazyScalaJSScope(base, isModule, isTraitImpl) + ScalaJS.put(name, ScalaJS, lazified) + } + } + + private[rhino] def load(scope: Scriptable, encodedName: String): Unit = { + providers.get(encodedName) foreach { irFile => + val codeWriter = new java.io.StringWriter + val printer = new Printers.JSTreePrinter(codeWriter) + val classDef = irFile.tree + val desugared = new ScalaJSClassEmitter(semantics).genClassDef(classDef) + printer.printTopLevelTree(desugared) + printer.complete() + val ctx = Context.getCurrentContext() + val fakeFileName = encodedName + PseudoFileSuffix + ctx.evaluateString(scope, codeWriter.toString(), + fakeFileName, 1, null) + } + } +} + +object ScalaJSCoreLib { + private case class Info(name: String, + isModule: Boolean = false, isTraitImpl: Boolean = false) + + private val EncodedNameLine = raw""""encodedName": *"([^"]+)"""".r.unanchored + + private final val PseudoFileSuffix = ".sjsir" +} diff --git a/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/env/rhino/package.scala b/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/env/rhino/package.scala new file mode 100644 index 0000000..926fbb2 --- /dev/null +++ b/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/env/rhino/package.scala @@ -0,0 +1,42 @@ +/* __ *\ +** ________ ___ / / ___ __ ____ Scala.js sbt plugin ** +** / __/ __// _ | / / / _ | __ / // __/ (c) 2013, LAMP/EPFL ** +** __\ \/ /__/ __ |/ /__/ __ |/_// /_\ \ http://scala-js.org/ ** +** /____/\___/_/ |_/____/_/ | |__/ /____/ ** +** |/____/ ** +\* */ + + +package scala.scalajs.sbtplugin.env + +import org.mozilla.javascript._ + +import scala.scalajs.tools.io._ + +package object rhino { + + implicit class ContextOps(val self: Context) extends AnyVal { + def evaluateFile(scope: Scriptable, file: VirtualJSFile, + securityDomain: AnyRef = null): Any = { + self.evaluateString(scope, file.content, file.path, 1, securityDomain) + } + } + + implicit class ScriptableObjectOps(val self: Scriptable) { + def addFunction(name: String, function: Array[AnyRef] => Any) = { + val rhinoFunction = + new BaseFunction { + ScriptRuntime.setFunctionProtoAndParent(this, self) + override def call(context: Context, scope: Scriptable, + thisObj: Scriptable, args: Array[AnyRef]): AnyRef = { + function(args) match { + case () => Undefined.instance + case r => r.asInstanceOf[AnyRef] + } + } + } + + ScriptableObject.putProperty(self, name, rhinoFunction) + } + } +} diff --git a/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/impl/DependencyBuilders.scala b/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/impl/DependencyBuilders.scala new file mode 100644 index 0000000..32ffb94 --- /dev/null +++ b/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/impl/DependencyBuilders.scala @@ -0,0 +1,99 @@ +/* __ *\ +** ________ ___ / / ___ __ ____ Scala.js sbt plugin ** +** / __/ __// _ | / / / _ | __ / // __/ (c) 2013, LAMP/EPFL ** +** __\ \/ /__/ __ |/ /__/ __ |/_// /_\ \ http://scala-js.org/ ** +** /____/\___/_/ |_/____/_/ | |__/ /____/ ** +** |/____/ ** +\* */ + + +package scala.scalajs.sbtplugin +package impl + +import scala.language.implicitConversions +import scala.language.experimental.macros + +import sbt._ + +import StringUtilities.nonEmpty + +trait DependencyBuilders { + final implicit def toScalaJSGroupID(groupID: String): ScalaJSGroupID = { + nonEmpty(groupID, "Group ID") + new ScalaJSGroupID(groupID) + } + + /** Builder to allow for stuff like: + * + * ProvidedJS / "foo.js" + * ProvidedJS / "foo.js" % "test" + * + */ + object ProvidedJS { + def /(name: String): ProvidedJSModuleID = ProvidedJSModuleID(name, None) + } + + /** Builder to allow for stuff like: + * + * "org.webjars" % "jquery" % "1.10.2" / "jquery.js" + * "org.webjars" % "jquery" % "1.10.2" / "jquery.js" % "test" + * + */ + implicit class JSModuleIDBuilder(module: ModuleID) { + def /(name: String): JarJSModuleID = JarJSModuleID(module, name) + } +} + +final class ScalaJSGroupID private[sbtplugin] (private val groupID: String) { + def %%%(artifactID: String): CrossGroupArtifactID = + macro ScalaJSGroupID.auto_impl + + def %%%!(artifactID: String): CrossGroupArtifactID = + ScalaJSGroupID.withCross(this, artifactID, ScalaJSCrossVersion.binary) +} + +object ScalaJSGroupID { + import scala.reflect.macros.Context + + /** Internal. Used by the macro implementing [[ScalaJSGroupID.%%%]]. Use: + * {{{ + * ("a" % artifactID % revision).cross(cross) + * }}} + * instead. + */ + def withCross(groupID: ScalaJSGroupID, artifactID: String, + cross: CrossVersion): CrossGroupArtifactID = { + nonEmpty(artifactID, "Artifact ID") + new CrossGroupArtifactID(groupID.groupID, artifactID, cross) + } + + def auto_impl(c: Context { type PrefixType = ScalaJSGroupID })( + artifactID: c.Expr[String]): c.Expr[CrossGroupArtifactID] = { + import c.universe._ + + // Hack to work around bug in sbt macros (wrong way of collecting local + // definitions) + val keysSym = rootMirror.staticModule( + "_root_.scala.scalajs.sbtplugin.ScalaJSPlugin.autoImport") + val keys = c.Expr[ScalaJSPlugin.autoImport.type](Ident(keysSym)) + + reify { + val cross = { + if (keys.splice.jsDependencies.?.value.isDefined) + ScalaJSCrossVersion.binary + else + CrossVersion.binary + } + ScalaJSGroupID.withCross(c.prefix.splice, artifactID.splice, cross) + } + } + +} + +final class CrossGroupArtifactID(groupID: String, + artifactID: String, crossVersion: CrossVersion) { + def %(revision: String): ModuleID = { + nonEmpty(revision, "Revision") + ModuleID(groupID, artifactID, revision).cross(crossVersion) + } +} diff --git a/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/testing/Events.scala b/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/testing/Events.scala new file mode 100644 index 0000000..f13c195 --- /dev/null +++ b/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/testing/Events.scala @@ -0,0 +1,35 @@ +/* __ *\ +** ________ ___ / / ___ __ ____ Scala.js sbt plugin ** +** / __/ __// _ | / / / _ | __ / // __/ (c) 2013, LAMP/EPFL ** +** __\ \/ /__/ __ |/ /__/ __ |/_// /_\ \ http://scala-js.org/ ** +** /____/\___/_/ |_/____/_/ | |__/ /____/ ** +** |/____/ ** +\* */ + + +package scala.scalajs.sbtplugin.testing + +import sbt.testing.{Event => SbtEvent, _} + +class Events(taskDef: TaskDef) { + + abstract class Event(val status: Status, + val throwable: OptionalThrowable = new OptionalThrowable) extends SbtEvent { + val fullyQualifiedName = taskDef.fullyQualifiedName + val fingerprint = taskDef.fingerprint + val selector = taskDef.selectors.headOption.getOrElse(new SuiteSelector) + val duration = -1L + } + + case class Error(exception: Throwable) extends Event( + Status.Error, new OptionalThrowable(exception)) + + case class Failure(exception: Throwable) extends Event( + Status.Failure, new OptionalThrowable(exception)) + + case object Succeeded extends Event(Status.Success) + case object Skipped extends Event(Status.Skipped) + case object Pending extends Event(Status.Pending) + case object Ignored extends Event(Status.Ignored) + case object Canceled extends Event(Status.Canceled) +} diff --git a/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/testing/JSClasspathLoader.scala b/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/testing/JSClasspathLoader.scala new file mode 100644 index 0000000..bfe0ffc --- /dev/null +++ b/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/testing/JSClasspathLoader.scala @@ -0,0 +1,15 @@ +/* __ *\ +** ________ ___ / / ___ __ ____ Scala.js sbt plugin ** +** / __/ __// _ | / / / _ | __ / // __/ (c) 2013, LAMP/EPFL ** +** __\ \/ /__/ __ |/ /__/ __ |/_// /_\ \ http://scala-js.org/ ** +** /____/\___/_/ |_/____/_/ | |__/ /____/ ** +** |/____/ ** +\* */ + + +package scala.scalajs.sbtplugin.testing + +import scala.scalajs.tools.classpath.CompleteClasspath + +/** A dummy ClassLoader to pass on Scala.js ClasspathContents to tests */ +final case class JSClasspathLoader(cp: CompleteClasspath) extends ClassLoader diff --git a/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/testing/SbtTestLoggerAccWrapper.scala b/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/testing/SbtTestLoggerAccWrapper.scala new file mode 100644 index 0000000..dfebe00 --- /dev/null +++ b/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/testing/SbtTestLoggerAccWrapper.scala @@ -0,0 +1,22 @@ +package scala.scalajs.sbtplugin.testing + +import scala.scalajs.tools.logging._ +import sbt.testing.{ Logger => SbtTestLogger } + +class SbtTestLoggerAccWrapper(logger: Seq[SbtTestLogger]) extends Logger { + + import scala.scalajs.sbtplugin.Implicits._ + import Level._ + + def log(level: Level, message: => String): Unit = level match { + case Error => logger.foreach(_.error(message)) + case Warn => logger.foreach(_.warn(message)) + case Info => logger.foreach(_.info(message)) + case Debug => logger.foreach(_.debug(message)) + } + + def success(message: => String): Unit = logger.foreach(_.info(message)) + + def trace(t: => Throwable): Unit = logger.foreach(_.trace(t)) + +} diff --git a/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/testing/TestException.scala b/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/testing/TestException.scala new file mode 100644 index 0000000..b4cb09b --- /dev/null +++ b/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/testing/TestException.scala @@ -0,0 +1,9 @@ +package scala.scalajs.sbtplugin.testing + +/** Dummy Exception to wrap stack traces passed to SBT */ +class TestException( + message: String, + stackTrace: Array[StackTraceElement] +) extends Exception(message) { + override def getStackTrace = stackTrace +} diff --git a/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/testing/TestFramework.scala b/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/testing/TestFramework.scala new file mode 100644 index 0000000..ab43bfe --- /dev/null +++ b/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/testing/TestFramework.scala @@ -0,0 +1,52 @@ +/* __ *\ +** ________ ___ / / ___ __ ____ Scala.js sbt plugin ** +** / __/ __// _ | / / / _ | __ / // __/ (c) 2013, LAMP/EPFL ** +** __\ \/ /__/ __ |/ /__/ __ |/_// /_\ \ http://scala-js.org/ ** +** /____/\___/_/ |_/____/_/ | |__/ /____/ ** +** |/____/ ** +\* */ + + +package scala.scalajs.sbtplugin.testing + +import scala.scalajs.tools.env._ +import scala.scalajs.tools.classpath._ + +import sbt._ +import sbt.testing._ +import sbt.classpath.ClasspathFilter + +import java.net.URLClassLoader + +class TestFramework( + environment: JSEnv, + jsConsole: JSConsole, + testFramework: String) extends Framework { + + val name = "Scala.js Test Framework" + + lazy val fingerprints = Array[Fingerprint](f1) + + private val f1 = new SubclassFingerprint { + val isModule = true + val superclassName = "scala.scalajs.testbridge.Test" + val requireNoArgConstructor = true + } + + def runner(args: Array[String], remoteArgs: Array[String], + testClassLoader: ClassLoader): Runner = { + + val jsClasspath = extractClasspath(testClassLoader) + new TestRunner(environment, jsClasspath, jsConsole, + testFramework, args, remoteArgs) + } + + /** extract classpath from ClassLoader (which must be a JSClasspathLoader) */ + private def extractClasspath(cl: ClassLoader) = cl match { + case cl: JSClasspathLoader => cl.cp + case _ => + sys.error("The Scala.js framework only works with a class loader of " + + s"type JSClasspathLoader (${cl.getClass} given)") + } + +} diff --git a/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/testing/TestOutputConsole.scala b/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/testing/TestOutputConsole.scala new file mode 100644 index 0000000..9aad956 --- /dev/null +++ b/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/testing/TestOutputConsole.scala @@ -0,0 +1,190 @@ +/* __ *\ +** ________ ___ / / ___ __ ____ Scala.js sbt plugin ** +** / __/ __// _ | / / / _ | __ / // __/ (c) 2013, LAMP/EPFL ** +** __\ \/ /__/ __ |/ /__/ __ |/_// /_\ \ http://scala-js.org/ ** +** /____/\___/_/ |_/____/_/ | |__/ /____/ ** +** |/____/ ** +\* */ + + +package scala.scalajs.sbtplugin.testing + +import sbt.testing.Logger +import sbt.testing.EventHandler + +import scala.scalajs.tools.env.JSConsole +import scala.scalajs.tools.sourcemap.SourceMapper +import scala.scalajs.tools.classpath.{CompleteClasspath, IRClasspath} + +import scala.collection.mutable.ArrayBuffer + +import scala.util.Try + +import java.util.regex._ + +/** This parses the messages sent from the test bridge and forwards + * the calls to SBT. It also buffers all log messages and allows to + * pipe them to multiple loggers in a synchronized fashion. This + * ensures that log messages aren't interleaved due to parallelism. + */ +class TestOutputConsole( + base: JSConsole, + handler: EventHandler, + events: Events, + classpath: CompleteClasspath, + noSourceMap: Boolean) extends JSConsole { + + import TestOutputConsole._ + import events._ + + private val traceBuf = new ArrayBuffer[StackTraceElement] + private val logBuffer = new ArrayBuffer[LogElement] + + /* See #727: source mapping does not work with CompleteIRClasspath, so + * don't bother to try. + */ + private val ignoreSourceMapping = + noSourceMap || classpath.isInstanceOf[IRClasspath] + + private lazy val sourceMapper = new SourceMapper(classpath) + + override def log(msg: Any): Unit = { + val data = msg.toString + val sepPos = data.indexOf("|") + + if (sepPos == -1) + log(_.error, s"Malformed message: $data") + else { + val op = data.substring(0, sepPos) + val message = unescape(data.substring(sepPos + 1)) + + op match { + case "console-log" => + base.log(message) + case "error" => + val trace = getTrace() + logWithEvent(_.error, + messageWithStack(message, trace), + Error(new TestException(message, trace)) + ) + case "failure" => + val trace = getTrace() + logWithEvent(_.error, + messageWithStack(message, trace), + Failure(new TestException(message, trace)) + ) + case "succeeded" => + noTrace() + logWithEvent(_.info, message, Succeeded) + case "skipped" => + noTrace() + logWithEvent(_.info, message, Skipped) + case "pending" => + noTrace() + logWithEvent(_.info, message, Pending) + case "ignored" => + noTrace() + logWithEvent(_.info, message, Ignored) + case "canceled" => + noTrace() + logWithEvent(_.info, message, Canceled) + case "error-log" => + noTrace() + log(_.error, message) + case "info" => + noTrace() + log(_.info, message) + case "warn" => + noTrace() + log(_.warn, message) + case "trace" => + val Array(className, methodName, fileName, + lineNumberStr, columnNumberStr) = message.split('|') + + def tryParse(num: String, name: String) = Try(num.toInt).getOrElse { + log(_.warn, s"Couldn't parse $name number in StackTrace: $num") + -1 + } + + val lineNumber = tryParse(lineNumberStr, "line") + val columnNumber = tryParse(columnNumberStr, "column") + + val ste = + new StackTraceElement(className, methodName, fileName, lineNumber) + + if (ignoreSourceMapping) + traceBuf += ste + else + traceBuf += sourceMapper.map(ste, columnNumber) + case _ => + noTrace() + log(_.error, s"Unknown op: $op. Originating log message: $data") + } + } + } + + private def noTrace() = { + if (traceBuf.nonEmpty) + log(_.warn, s"Discarding ${traceBuf.size} stack elements") + traceBuf.clear() + } + + private def getTrace() = { + val res = traceBuf.toArray + traceBuf.clear() + res + } + + private def messageWithStack(message: String, stack: Array[StackTraceElement]): String = + message + stack.mkString("\n", "\n", "") + + private def log(method: LogMethod, message: String): Unit = + logBuffer.append(LogElement(method, message)) + + private def logWithEvent(method: LogMethod, + message: String, event: Event): Unit = { + handler handle event + log(method, message) + } + + def pipeLogsTo(loggers: Array[Logger]): Unit = { + TestOutputConsole.synchronized { + for { + LogElement(method, message) <- logBuffer + logger <- loggers + } method(logger) { + if (logger.ansiCodesSupported) message + else removeColors(message) + } + } + } + + def allLogs: List[LogElement] = logBuffer.toList + + private val colorPattern = raw"\033\[\d{1,2}m" + + private def removeColors(message: String): String = + message.replaceAll(colorPattern, "") + + private val unEscPat = Pattern.compile("(\\\\\\\\|\\\\n|\\\\r)") + private def unescape(message: String): String = { + val m = unEscPat.matcher(message) + val res = new StringBuffer() + while (m.find()) { + val repl = m.group() match { + case "\\\\" => "\\\\" + case "\\n" => "\n" + case "\\r" => "\r" + } + m.appendReplacement(res, repl); + } + m.appendTail(res); + res.toString + } + +} + +object TestOutputConsole { + type LogMethod = Logger => (String => Unit) + case class LogElement(method: LogMethod, message: String) +} diff --git a/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/testing/TestRunner.scala b/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/testing/TestRunner.scala new file mode 100644 index 0000000..e5ca2a2 --- /dev/null +++ b/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/testing/TestRunner.scala @@ -0,0 +1,37 @@ +/* __ *\ +** ________ ___ / / ___ __ ____ Scala.js sbt plugin ** +** / __/ __// _ | / / / _ | __ / // __/ (c) 2013, LAMP/EPFL ** +** __\ \/ /__/ __ |/ /__/ __ |/_// /_\ \ http://scala-js.org/ ** +** /____/\___/_/ |_/____/_/ | |__/ /____/ ** +** |/____/ ** +\* */ + + +package scala.scalajs.sbtplugin.testing + +import sbt.testing._ + +import scala.scalajs.tools.env._ +import scala.scalajs.tools.classpath._ + +class TestRunner( + environment: JSEnv, + classpath: CompleteClasspath, + jsConsole: JSConsole, + testFramework: String, + val args: Array[String], + val remoteArgs: Array[String]) extends Runner { + + def tasks(taskDefs: Array[TaskDef]): Array[Task] = if (_done) { + throw new IllegalStateException("Done has already been called") + } else { + taskDefs.map(TestTask(environment, classpath, jsConsole, testFramework, args)) + } + + def done(): String = { + _done = true + "" + } + + private var _done = false +} diff --git a/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/testing/TestTask.scala b/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/testing/TestTask.scala new file mode 100644 index 0000000..b1cabb9 --- /dev/null +++ b/sbt-plugin/src/main/scala/scala/scalajs/sbtplugin/testing/TestTask.scala @@ -0,0 +1,110 @@ +/* __ *\ +** ________ ___ / / ___ __ ____ Scala.js sbt plugin ** +** / __/ __// _ | / / / _ | __ / // __/ (c) 2013, LAMP/EPFL ** +** __\ \/ /__/ __ |/ /__/ __ |/_// /_\ \ http://scala-js.org/ ** +** /____/\___/_/ |_/____/_/ | |__/ /____/ ** +** |/____/ ** +\* */ + + +package scala.scalajs.sbtplugin.testing + +import sbt.testing._ + +import scala.scalajs.tools.io._ +import scala.scalajs.tools.classpath._ +import scala.scalajs.tools.env._ + +import scala.scalajs.sbtplugin.JSUtils._ + +import scala.annotation.tailrec +import scala.util.control.NonFatal + +class TestTask( + env: JSEnv, + classpath: CompleteClasspath, + jsConsole: JSConsole, + testFramework: String, + args: Array[String], + val taskDef: TaskDef) extends Task { + + import TestTask._ + + val tags = Array.empty[String] + val options = readArgs(args.toList) + + def execute(eventHandler: EventHandler, + loggers: Array[Logger]): Array[Task] = { + + val runnerFile = testRunnerFile(options.frameworkArgs) + val testConsole = new TestOutputConsole(jsConsole, eventHandler, + new Events(taskDef), classpath, options.noSourceMap) + val logger = new SbtTestLoggerAccWrapper(loggers) + + // Actually execute test + env.jsRunner(classpath, runnerFile, logger, testConsole).run() + + testConsole.pipeLogsTo(loggers) + + Array.empty + } + + private def testRunnerFile(args: List[String]) = { + val testKey = taskDef.fullyQualifiedName + + // Note that taskDef does also have the selector, fingerprint and + // explicitlySpecified value we could pass to the framework. However, we + // believe that these are only moderately useful. Therefore, we'll silently + // ignore them. + + val jsArgArray = listToJS(args) + new MemVirtualJSFile("Generated test launcher file"). + withContent(s"""this${dot2bracket(testFramework)}().safeRunTest( + | scala.scalajs.testbridge.internal.ConsoleTestOutput(), + | $jsArgArray, + | this${dot2bracket(testKey)});""".stripMargin) + } + + +} + +object TestTask { + + def apply(environment: JSEnv, classpath: CompleteClasspath, + jsConsole: JSConsole, testFramework: String, args: Array[String] + )(taskDef: TaskDef) = + new TestTask(environment, classpath, jsConsole, + testFramework, args, taskDef) + + case class ArgOptions( + noSourceMap: Boolean, + frameworkArgs: List[String] + ) + + private def readArgs(args0: List[String]) = { + // State for each option + var noSourceMap = false + + def mkOptions(frameworkArgs: List[String]) = + ArgOptions(noSourceMap, frameworkArgs) + + @tailrec + def read0(args: List[String]): ArgOptions = args match { + case "-no-source-map" :: xs => + noSourceMap = true + read0(xs) + + // Explicitly end our argument list + case "--" :: xs => + mkOptions(xs) + + // Unknown argument + case xs => + mkOptions(xs) + + } + + read0(args0) + } + +} |