Skip to content

Commit

Permalink
Implement Golem Scala plugin and exporter macro (#1)
Browse files Browse the repository at this point in the history
* Add modules and settings
* Implement worker macro
* Implement golem scala plugin
* Add example1 test
* Add example2 test
* Run SBT tests on CI
* Add JvmPlugin requirement
* Add README
* Add JDK 11 LTS on CI
  • Loading branch information
danieletorelli authored Apr 1, 2024
1 parent 49a1b62 commit 6a0d0d2
Show file tree
Hide file tree
Showing 18 changed files with 286 additions and 3 deletions.
7 changes: 4 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand All @@ -27,4 +28,4 @@ jobs:
- name: Build and test
shell: bash
run: |
sbt -v clean compile
sbt -v clean +publishLocal scripted
49 changes: 49 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.

21 changes: 21 additions & 0 deletions build.sbt
Original file line number Diff line number Diff line change
@@ -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
49 changes: 49 additions & 0 deletions macros/src/main/scala/cloud/golem/Worker.scala
Original file line number Diff line number Diff line change
@@ -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)
}
}
27 changes: 27 additions & 0 deletions project/Settings.scala
Original file line number Diff line number Diff line change
@@ -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
}
)

}

}
6 changes: 6 additions & 0 deletions project/Versions.scala
Original file line number Diff line number Diff line change
@@ -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"
}
39 changes: 39 additions & 0 deletions src/main/scala/cloud/golem/GolemScalaPlugin.scala
Original file line number Diff line number Diff line change
@@ -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
}
)
}
6 changes: 6 additions & 0 deletions src/sbt-test/golem-scala/example1/build.sbt
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions src/sbt-test/golem-scala/example1/project/build.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
sbt.version=1.9.9
6 changes: 6 additions & 0 deletions src/sbt-test/golem-scala/example1/project/plugins.sbt
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
@@ -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"
}

}
12 changes: 12 additions & 0 deletions src/sbt-test/golem-scala/example1/test
Original file line number Diff line number Diff line change
@@ -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
6 changes: 6 additions & 0 deletions src/sbt-test/golem-scala/example2/build.sbt
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions src/sbt-test/golem-scala/example2/project/build.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
sbt.version=1.9.9
6 changes: 6 additions & 0 deletions src/sbt-test/golem-scala/example2/project/plugins.sbt
Original file line number Diff line number Diff line change
@@ -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)
}
17 changes: 17 additions & 0 deletions src/sbt-test/golem-scala/example2/src/main/scala/example/Api.scala
Original file line number Diff line number Diff line change
@@ -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]
}
Original file line number Diff line number Diff line change
@@ -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")
}

}
12 changes: 12 additions & 0 deletions src/sbt-test/golem-scala/example2/test
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit 6a0d0d2

Please sign in to comment.