ciaraobrien / dottytags   1.1.0

GitHub

An experimental reimplementation of ScalaTags in (extremely meta) Scala 3.

Scala versions: 3.x
Scala.js versions: 1.x

DottyTags

dottytags Scala version support

Finally released on Maven Central!

libraryDependencies += "io.github.ciaraobrien" %% "dottytags" % "1.1.0"

An experimental reimplementation of ScalaTags in (extremely meta) Scala 3. It is a more-or-less working clone of ScalaTags from the user's perspective, with most of the surface syntax being nearly identical, but the internals are radically different, as Scala 3's metaprogramming capabilities are leveraged to automatically reduce the tree as much as possible to simple serial concatenation of strings at compile-time. Therefore, the code that actually runs is, in many cases, basially an array of string literals interspersed with string expressions that are evaluated at runtime and then appended with the literal spans in a single linear StringBuilder loop. By the nature of the way this system works, it is not feasible to implement post-hoc mutation of the tree, however ordering is manipulated in the initial tree: duplicate attributes combine, styles combine and override, etc. in the same way as Scalatags. The primary differences are:

  • No chained Tag applications like div(cls := "header", backgroundColor := "blue")(divContents). This would require either compromising on the flattening performance or re-parsing the already-generated syntax tree at runtime in order to make changes, which is a highly unappealing idea. In my opinion this is of little use anyway, especially since the point of the library is to do as much as possible at compile-time.
  • Sequences of elements generated by for-loops and the like must be explicitly deconstructed into a Frag with the bind macro (though there is an implicit conversion for this in dottytags.syntax). The way this has to be implemented is rather slow compared to the performance of the system in most other situations, though still faster than Scalatags even on loop-heavy code. Wrapping elements up with the frag macro incurs no such performance hit, and does not disrupt the system's ability to achieve the optimal splicing completeness, but it can only be used with true varargs, vararg ascription doesn't help. Use frag for when you want to group up some content in a lightweight fashion.

Performance

Based on my very unprofessional benchmark comparisons, DottyTags is between 2 and 6 times faster than ScalaTags when the comparison is roughly fair (complex HTML trees involving loops, external variables, etc., like those used in ScalaTags' own benchmarks), and significantly faster in less-fair comparisons involving mostly-static tree generation, in which DottyTags has an absurd advantage since entirely-static trees get flattened into single string literals at compile-time, and trees with only a few dynamic elements pretty much boil down to a small Array[String] being appended to a StringBuilder, where most of the elements of the array are string literals. This speed disparity carries over more or less directly to Scala.JS, when comparing DottyTags to ScalaTags' text backend - Scalatags' JS DOM backend is far, far slower in my benchmarks for some reason.

Example

As a quick example, this:

println(html(cls := "foo", href := "bar", css("baz1") := "qux", "quux",
  System.currentTimeMillis.toString, css("baz2") := "qux", raw("a")
).toString)

Boils down to something like:

println(Tag.apply(
  dottytags.spliceString(
    Array[String](
      "<html class=\"foo\" href=\"bar\" style=\"baz: qux;\">quux",
      dottytags.escapeString(scala.Long.box(System.currentTimeMillis()).toString()), 
      "a</html>"
    )
  )
).toString)

Which, when run, yields:

<html class="foo" href="bar" style="baz1: qux; baz2: qux;">quux1608810396295a</html>

For comparison, ScalaTags' interpretation of the same code (by swapping out the imports, since the syntax is broadly compatible in most cases):

scalatags.Text.all.html().asInstanceOf[scalatags.Text.Text$TypedTag].apply(
  scala.runtime.ScalaRunTime.wrapRefArray([
    scalatags.Text.all.cls().:=("foo",
      scalatags.Text.all.stringAttr()
    ),
    scalatags.Text.all.href().:=("bar",
      scalatags.Text.all.stringAttr()
    ),
    scalatags.Text.all.css("baz1").:=("qux",
      scalatags.Text.all.stringStyle()
    ),
    scalatags.Text.all.stringFrag("quux"),
    scalatags.Text.all.stringFrag(
      scala.Long.box(System.currentTimeMillis()).toString()
    ),
    scalatags.Text.all.css("baz2").:=("qux",
      scalatags.Text.all.stringStyle()
    ),
    scalatags.Text.all.raw("a") : scalatags.generic.Modifier
  ])
).render()

Recent Developments

I recently completely overhauled the entire library and rewrote it from scratch (which I decided to make 1.0.0), including the underlying metametaprogramming system used to make implementing the library less hellish, which I have traditionally called Phaser despite the fact that "Stage" is the correct term for compile/macro-time vs runtime, while "Phase" refers properly to parts of the compilation process. The internals of the library are far nicer than they used to be, and it can now do more, i.e. sort attributes and styles according to the order in which they are present in the tag body. The metametaprogramming facilities are much better-developed this time around, consisting of Phaser.scala and Splice.scala, both of which are amenable to use elsewhere, and I will probably break them back out into a revived Phaser library for more general usage.