diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cad2066..6594745 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,11 +11,12 @@ jobs: strategy: fail-fast: false matrix: - include: + include: # Test against all LTS + latest - java: 8 + - java: 11 + - java: 17 - java: 21 - java: 22-ea - - java: 17 steps: - uses: actions/checkout@v4 - name: Setup JDK ${{ matrix.java }} @@ -27,4 +28,4 @@ jobs: - name: Build and test shell: bash run: | - sbt -v clean compile \ No newline at end of file + sbt -v clean +publishLocal scripted diff --git a/README.md b/README.md new file mode 100644 index 0000000..79a6ebe --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ +golem-scala +=========== + +Avoid any boilerplate in your project by using just one annotation to export your Golem worker from Scala to JS. + +Setup +----- + +Add golem-scala as a dependency in `project/plugins.sbt`: + +```scala +addSbtPlugin("cloud.golem" % "golem-scala" % "x.y.z") +``` + +Usage +----- + +Golem-scala is automatically loaded, it just needs to be enabled with `enablePlugins(GolemScalaPlugin)` in your `build.sbt`: + +```scala +ThisBuild / version := "0.1.0-SNAPSHOT" +ThisBuild / scalaVersion := "2.13.13" + +lazy val root = (project in file(".")) + .enablePlugins(GolemScalaPlugin) +``` + +Then you will be able to annotate your Golem worker object with the `@cloud.golem.Worker` annotation: + +```scala +package example + +@cloud.golem.Worker +object ShoppingCart { self => + + def initializeCart(userId: String): String = { + println(s"Initializing cart for user $userId") + if (math.random() > 0.1) userId + else "Error while initializing cart" + } + + // ... + +} + +``` + +Once done that, it will be enough to run `sbt fullLinkJS` and the plugin will take care of exporting your worker in JS. + diff --git a/build.sbt b/build.sbt index 86cb970..19be5ab 100755 --- a/build.sbt +++ b/build.sbt @@ -1 +1,22 @@ +import Settings.* + +ThisBuild / scalaVersion := Versions.scala2_12 ThisBuild / organization := "cloud.golem" + +lazy val root = (project in file(".")) + .settings( + name := "golem-scala", + addSbtPlugin("org.scala-js" % "sbt-scalajs" % Versions.scalaJS) + ) + .settings(scriptedLaunchOpts += s"-Dplugin.version=${version.value}") + .enablePlugins(SbtPlugin) + .dependsOn(macros) + .aggregate(macros) + +lazy val macros = project + .settings( + name := "golem-scala-macros", + crossScalaVersions += Versions.scala2_13, + libraryDependencies += "org.scala-lang" % "scala-reflect" % scalaVersion.value, + ) + .macroParadiseSettings diff --git a/macros/src/main/scala/cloud/golem/Worker.scala b/macros/src/main/scala/cloud/golem/Worker.scala new file mode 100755 index 0000000..4c7007f --- /dev/null +++ b/macros/src/main/scala/cloud/golem/Worker.scala @@ -0,0 +1,49 @@ +package cloud.golem + +import scala.annotation.{StaticAnnotation, compileTimeOnly} +import scala.language.experimental.macros +import scala.reflect.macros.whitebox + +@compileTimeOnly("Enable macro paradise to expand macro annotations") +final class Worker extends StaticAnnotation { + def macroTransform(annottees: Any*): Any = macro WorkerExport.impl +} + +object WorkerExport { + def impl(c: whitebox.Context)(annottees: c.Expr[Any]*): c.Expr[Any] = { + import c.universe._ + + val result = annottees.head.tree match { + case q"$mods object $name extends $parent { ..$self => ..$stats }" => + val topLevelName = { + val parentFullName = parent.toString + // Define export top level as superclass name or "api", if missing + var n = + parentFullName.split("\\.").lastOption.getOrElse(parentFullName) + n = n.replaceAll("\\$", "") + n = if (n == "AnyRef") "api" else n + n.toLowerCase + } + c.info( + NoPosition, + s"Exporting worker object $name to $topLevelName", + force = false + ) + val newMods = Modifiers( + mods.flags | Flag.FINAL, + mods.privateWithin, + mods.annotations :+ q"new scala.scalajs.js.annotation.JSExportAll" :+ q"new scala.scalajs.js.annotation.JSExportTopLevel(${s"$topLevelName"})" + ) + q""" + $newMods object $name extends $parent { $self => + ..$stats + } + """ + + case _ => + c.abort(c.enclosingPosition, "Failed to export worker object") + } + + c.Expr[Any](result) + } +} diff --git a/project/Settings.scala b/project/Settings.scala new file mode 100644 index 0000000..29ea680 --- /dev/null +++ b/project/Settings.scala @@ -0,0 +1,27 @@ +import sbt.* +import sbt.Keys.* + +object Settings { + + implicit final class ProjectSettings(project: sbt.Project) { + + def macroParadiseSettings: sbt.Project = + project.settings( + scalacOptions ++= { + if (scalaVersion.value.startsWith("2.13")) Seq("-Ymacro-annotations") + else Nil + }, + libraryDependencies ++= { + if (scalaVersion.value.startsWith("2.12")) { + Seq( + compilerPlugin( + "org.scalamacros" % "paradise" % Versions.scalaMacrosParadise cross CrossVersion.full + ) + ) + } else Nil + } + ) + + } + +} diff --git a/project/Versions.scala b/project/Versions.scala new file mode 100644 index 0000000..55d5bb9 --- /dev/null +++ b/project/Versions.scala @@ -0,0 +1,6 @@ +object Versions { + val scala2_12 = "2.12.19" + val scala2_13 = "2.13.13" + val scalaMacrosParadise = "2.1.1" + val scalaJS = "1.14.0" +} diff --git a/src/main/scala/cloud/golem/GolemScalaPlugin.scala b/src/main/scala/cloud/golem/GolemScalaPlugin.scala new file mode 100644 index 0000000..52838b8 --- /dev/null +++ b/src/main/scala/cloud/golem/GolemScalaPlugin.scala @@ -0,0 +1,39 @@ +package cloud.golem + +import sbt.* +import sbt.Keys.* +import org.scalajs.sbtplugin.ScalaJSPlugin +import org.scalajs.sbtplugin.ScalaJSPlugin.autoImport.* +import sbt.plugins.JvmPlugin + +object GolemScalaPlugin extends AutoPlugin { + private object Versions { + val macros = "0.1.0" + val scalaMacrosParadise = "2.1.1" + } + + override def trigger: PluginTrigger = allRequirements + + override def requires: Plugins = JvmPlugin && ScalaJSPlugin + + override lazy val projectSettings: Seq[Setting[?]] = Seq( + scalaJSLinkerConfig ~= { _.withModuleKind(ModuleKind.ESModule) }, + libraryDependencies += "cloud.golem" %% "golem-scala-macros" % Versions.macros + ) ++ macroParadiseSettings + + private lazy val macroParadiseSettings = Seq( + scalacOptions ++= { + if (scalaVersion.value.startsWith("2.13")) Seq("-Ymacro-annotations") + else Nil + }, + libraryDependencies ++= { + if (scalaVersion.value.startsWith("2.12")) { + Seq( + compilerPlugin( + "org.scalamacros" % "paradise" % Versions.scalaMacrosParadise cross CrossVersion.full + ) + ) + } else Nil + } + ) +} diff --git a/src/sbt-test/golem-scala/example1/build.sbt b/src/sbt-test/golem-scala/example1/build.sbt new file mode 100644 index 0000000..a0fd24c --- /dev/null +++ b/src/sbt-test/golem-scala/example1/build.sbt @@ -0,0 +1,6 @@ +ThisBuild / version := "0.1" +ThisBuild / scalaVersion := "2.13.13" +ThisBuild / crossScalaVersions += "2.12.19" + +lazy val root = (project in file(".")) + .enablePlugins(GolemScalaPlugin) diff --git a/src/sbt-test/golem-scala/example1/project/build.properties b/src/sbt-test/golem-scala/example1/project/build.properties new file mode 100644 index 0000000..4d5f78c --- /dev/null +++ b/src/sbt-test/golem-scala/example1/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.9.9 \ No newline at end of file diff --git a/src/sbt-test/golem-scala/example1/project/plugins.sbt b/src/sbt-test/golem-scala/example1/project/plugins.sbt new file mode 100644 index 0000000..ab9a6c0 --- /dev/null +++ b/src/sbt-test/golem-scala/example1/project/plugins.sbt @@ -0,0 +1,6 @@ +sys.props.get("plugin.version") match { + case Some(version) => addSbtPlugin("cloud.golem" % "golem-scala" % version) + case _ => + sys.error("""|The system property 'plugin.version' is not defined. + |Specify this property using the scriptedLaunchOpts -D.""".stripMargin) +} diff --git a/src/sbt-test/golem-scala/example1/src/main/scala/example/ShoppingCart.scala b/src/sbt-test/golem-scala/example1/src/main/scala/example/ShoppingCart.scala new file mode 100644 index 0000000..a9d3931 --- /dev/null +++ b/src/sbt-test/golem-scala/example1/src/main/scala/example/ShoppingCart.scala @@ -0,0 +1,12 @@ +package example + +@cloud.golem.Worker +object ShoppingCart { self => + + def initializeCart(userId: String): String = { + println(s"Initializing cart for user $userId") + if (math.random() > 0.1) userId + else "Error while initializing cart" + } + +} diff --git a/src/sbt-test/golem-scala/example1/test b/src/sbt-test/golem-scala/example1/test new file mode 100644 index 0000000..fb5bbf1 --- /dev/null +++ b/src/sbt-test/golem-scala/example1/test @@ -0,0 +1,12 @@ +> +clean +> +fullLinkJS +$ exists target/scala-2.12/root-opt/main.js +$ exists target/scala-2.12/root-opt/main.js.map +$ exists target/scala-2.13/root-opt/main.js +$ exists target/scala-2.13/root-opt/main.js.map +> +clean +> +fastLinkJS +$ exists target/scala-2.12/root-fastopt/main.js +$ exists target/scala-2.12/root-fastopt/main.js.map +$ exists target/scala-2.13/root-fastopt/main.js +$ exists target/scala-2.13/root-fastopt/main.js.map diff --git a/src/sbt-test/golem-scala/example2/build.sbt b/src/sbt-test/golem-scala/example2/build.sbt new file mode 100644 index 0000000..a0fd24c --- /dev/null +++ b/src/sbt-test/golem-scala/example2/build.sbt @@ -0,0 +1,6 @@ +ThisBuild / version := "0.1" +ThisBuild / scalaVersion := "2.13.13" +ThisBuild / crossScalaVersions += "2.12.19" + +lazy val root = (project in file(".")) + .enablePlugins(GolemScalaPlugin) diff --git a/src/sbt-test/golem-scala/example2/project/build.properties b/src/sbt-test/golem-scala/example2/project/build.properties new file mode 100644 index 0000000..4d5f78c --- /dev/null +++ b/src/sbt-test/golem-scala/example2/project/build.properties @@ -0,0 +1 @@ +sbt.version=1.9.9 \ No newline at end of file diff --git a/src/sbt-test/golem-scala/example2/project/plugins.sbt b/src/sbt-test/golem-scala/example2/project/plugins.sbt new file mode 100644 index 0000000..ab9a6c0 --- /dev/null +++ b/src/sbt-test/golem-scala/example2/project/plugins.sbt @@ -0,0 +1,6 @@ +sys.props.get("plugin.version") match { + case Some(version) => addSbtPlugin("cloud.golem" % "golem-scala" % version) + case _ => + sys.error("""|The system property 'plugin.version' is not defined. + |Specify this property using the scriptedLaunchOpts -D.""".stripMargin) +} diff --git a/src/sbt-test/golem-scala/example2/src/main/scala/example/Api.scala b/src/sbt-test/golem-scala/example2/src/main/scala/example/Api.scala new file mode 100644 index 0000000..5ab0fbf --- /dev/null +++ b/src/sbt-test/golem-scala/example2/src/main/scala/example/Api.scala @@ -0,0 +1,17 @@ +package example + +trait Api { + import scala.scalajs.js + import scala.scalajs.js.JSConverters._ + + type WitResult[+Ok, +Err] = Ok + object WitResult { + def ok[Ok](value: Ok): WitResult[Ok, Nothing] = value + + def err[Err](value: Err): WitResult[Nothing, Err] = throw js.JavaScriptException(value) + + val unit: WitResult[Unit, Nothing] = () + } + + def initializeCart(userId: String): WitResult[String, String] +} \ No newline at end of file diff --git a/src/sbt-test/golem-scala/example2/src/main/scala/example/ShoppingCart.scala b/src/sbt-test/golem-scala/example2/src/main/scala/example/ShoppingCart.scala new file mode 100644 index 0000000..c1c77f6 --- /dev/null +++ b/src/sbt-test/golem-scala/example2/src/main/scala/example/ShoppingCart.scala @@ -0,0 +1,12 @@ +package example + +@cloud.golem.Worker +object ShoppingCart extends Api { self => + + def initializeCart(userId: String): WitResult[String, String] = { + println(s"Initializing cart for user $userId") + if (math.random() > 0.1) WitResult.ok(userId) + else WitResult.err("Error while initializing cart") + } + +} diff --git a/src/sbt-test/golem-scala/example2/test b/src/sbt-test/golem-scala/example2/test new file mode 100644 index 0000000..fb5bbf1 --- /dev/null +++ b/src/sbt-test/golem-scala/example2/test @@ -0,0 +1,12 @@ +> +clean +> +fullLinkJS +$ exists target/scala-2.12/root-opt/main.js +$ exists target/scala-2.12/root-opt/main.js.map +$ exists target/scala-2.13/root-opt/main.js +$ exists target/scala-2.13/root-opt/main.js.map +> +clean +> +fastLinkJS +$ exists target/scala-2.12/root-fastopt/main.js +$ exists target/scala-2.12/root-fastopt/main.js.map +$ exists target/scala-2.13/root-fastopt/main.js +$ exists target/scala-2.13/root-fastopt/main.js.map