|
| 1 | +--- |
| 2 | +layout: blog-detail |
| 3 | +post-type: blog |
| 4 | +by: Sébastien Doeraene |
| 5 | +title: "Implementing Scala.js Support for Scala 3" |
| 6 | +--- |
| 7 | + |
| 8 | +Today, the JS platform has become an integral part of the language. |
| 9 | +Yet, until last August, support for Scala.js in Scala 3 was close to non-existent. |
| 10 | +A first preview was shipped in Dotty 0.27.0-RC1, with support for the portable subset of Scala and native JS types. |
| 11 | +Since then, support for non-native JS types was added and will ship as part of Scala 3.0.0-M1. |
| 12 | +The only missing feature left is JS exports, which we will implement by the next milestone. |
| 13 | + |
| 14 | +What did it take to add Scala.js support in Scala 3? |
| 15 | +How come we managed to do the necessary work in less than 3 months, whereas Scala.js for Scala 2 took 7 years to mature? |
| 16 | +Those are questions we aim to explore in this post. |
| 17 | +After some background on the architecture of Scala.js for Scala 2, we will cover which components had to be rewritten for Scala 2, and which ones we could reuse. |
| 18 | +We will see that only a small fraction required a rewrite. |
| 19 | + |
| 20 | +## Background: the Scala.js architecture |
| 21 | + |
| 22 | +When we use Scala.js, it can sometimes appear a bit magical. |
| 23 | +In a simple sbt project, the only difference between a JVM project and a JS project can be as short as this one-liner: |
| 24 | + |
| 25 | +```scala |
| 26 | +enablePlugins(ScalaJSPlugin) |
| 27 | +``` |
| 28 | + |
| 29 | +although for an application with a main method, we will also have |
| 30 | + |
| 31 | +```scala |
| 32 | +scalaJSUseMainModuleInitializer := true |
| 33 | +``` |
| 34 | + |
| 35 | +Two settings, and suddenly `run` or `test` will compile the codebase to JavaScript and execute it on a JS engine such as Node.js, with high fidelity compared to Scala/JVM. |
| 36 | + |
| 37 | +There are many components that contribute to this behavior. |
| 38 | +Just looking at the compilation pipeline, there are already 4 components involved: the compiler plugin for scalac, the linker, and the artifacts for the Scala standard library and the JDK subset that is implemented. |
| 39 | + |
| 40 | +### A compiler plugin |
| 41 | + |
| 42 | +When compiling a Scala.js codebase, the .scala source files are compiled with scalac, augmented with the Scala.js compiler plugin. |
| 43 | +From scalac, we receive parsing, type checking, elaboration (type inference, implicit resolution, overload resolution, etc.), as well as many transformation phases. |
| 44 | +The compiler plugin, comparatively to the rest of scalac, is small: it takes the internal compiler ASTs that have been lowered to contain JVM-style classes, interfaces, methods and instructions, and turns them into Scala.js IR (.sjsir files). |
| 45 | + |
| 46 | +Thanks to everything it receives from scalac for free, the Scala.js compiler plugin does not have to deal with a lot of Scala-specific features. |
| 47 | +Traits have been lowered to interfaces; lazy vals have been desugared; nested classes, defs, and functions have been translated to top-level classes with methods; etc. |
| 48 | +This is how Scala.js is able to keep up-to-date with all the versions of Scala 2, and be available on the same day as new Scala releases. |
| 49 | +Not all compile-to-JS languages have that property! |
| 50 | + |
| 51 | +### `.sjsir`files |
| 52 | + |
| 53 | +.sjsir files are similar to .class files, although they are AST-based (instead of stack-machine-based) and contain features dedicated to JavaScript interoperability. |
| 54 | +The .sjsir format and specification is independent of Scala, its compiler or its standard library. |
| 55 | +In fact, there is nothing Scala-specific in the entire linker, other than a few optimizations targeted at Scala-like code. |
| 56 | +This is important because it means that the linker is independent of the version of Scala: we use the same linker irrespective of the version of Scala that was used to create the .sjsir files. |
| 57 | +In theory, we could even compile other JVM languages like Kotlin or Java to the Scala.js IR, then use our linker to emit JavaScript code. |
| 58 | +While we have a proof-of-concept for Java, it is nowhere near usable, however. |
| 59 | + |
| 60 | +.sjsir files have binary compatibility guarantees similar to those of .class files. |
| 61 | +With a few exceptions related to JS interop annotations, if changes to a .class files are binary compatible, then the changes applied to the corresponding .sjsir files are also binary compatible. |
| 62 | +This allows Scala.js codebase to meaningfully use MiMa to check that they do not break their binary API. |
| 63 | + |
| 64 | +The Scala.js linker (optimizer) takes the .sjsir files from the application and from all the transitive dependencies. |
| 65 | +In particular, the .sjsir files that are built for the Scala standard library and for the JDK subset. |
| 66 | +The linker optimizes all of them together, then emits one .js file for the entire codebase. |
| 67 | + |
| 68 | +### Components of Scala.js core |
| 69 | + |
| 70 | +The components involved here have the following sizes (not counting test files): |
| 71 | + |
| 72 | + |
| 73 | + |
| 74 | +The two biggest components, "scalac compiler" and "Scala standard library", are not part of Scala.js itself. |
| 75 | +They are reused from the core Scala repository. |
| 76 | + |
| 77 | +Among the components of Scala.js itself, the linker/optimizer and the subset of the JDK are the biggest, and account for 74% of the total. |
| 78 | +In comparison, the 8,000 lines of code of the Scala.js compiler plugin represent only 11% of the total 69 kLoC. |
| 79 | + |
| 80 | +Add to that about 68,000 lines of testing code, and we get close to 140,000 lines of code for [the Scala.js core repository](https://github.com/scala-js/scala-js). |
| 81 | +For fun, we can compare that to the sizes of the repositories for Scala 2 and Scala 3: |
| 82 | + |
| 83 | + |
| 84 | + |
| 85 | +It's worth pointing out that Scala 3 reuses the Scala standard library of Scala 2, so basically those two repositories have the same size. |
| 86 | + |
| 87 | +## Supporting Scala.js in Scala 3 |
| 88 | + |
| 89 | +Scala 3 has an entirely new compiler codebase, reimplemented from scratch. |
| 90 | +While many internal concepts such as Trees, Types and Symbols exist in both compilers, their design, semantics, and internal APIs are quite different. |
| 91 | +The language itself has of course changed a lot as well. |
| 92 | +What does it mean for Scala.js support? |
| 93 | + |
| 94 | +### A replacement for the compiler plugin |
| 95 | + |
| 96 | +Clearly, we had to entirely replace the Scala.js compiler plugin to work with Scala 3. |
| 97 | +For convenience, we chose to implement the Scala.js support directly inside the Scala 3 compiler (dotc) rather than as an external compiler plugin. |
| 98 | + |
| 99 | +Remember the 8,000 lines of code of the compiler plugin from above? |
| 100 | +Just like the compiler had been rewritten, we had to rewrite them for dotc. |
| 101 | +As of this writing, the amount of code related to Scala.js in dotc is about 6,000 lines. |
| 102 | +They cover virtually everything from the scalac compiler plugin; only JS exports support is missing. |
| 103 | +The implementation is easier (and shorter) in dotc due to several changes we made earlier in the internal data structures of dotc to make it more friendly to Scala.js. |
| 104 | + |
| 105 | +This is essentially what we have been doing at the Scala Center for the past few months in order to add Scala.js support in Scala 3. |
| 106 | +As we will see, everything else was already there for us to reuse. |
| 107 | + |
| 108 | +### Reusing the linker |
| 109 | + |
| 110 | +We mentioned earlier that the Scala.js linker, written in Scala 2, links .sjsir files independently of the version of Scala that produced them. |
| 111 | +This is also true for Scala 3! |
| 112 | +As long as the Scala 3 compiler generates .sjsir files that respect their specification, they can be linked by the existing Scala.js linker published on Maven Central. |
| 113 | + |
| 114 | +### Reusing the .sjsir files of the Scala standard library and of the JDK subset |
| 115 | + |
| 116 | +As you may already know, a Scala 3 codebase is able to depend on libraries compiled by Scala 2. |
| 117 | +This is even exploited down to the standard library level: Scala 3 codebases use the standard library compiled by Scala 2.13. |
| 118 | +This is possible because the dotc compiler can read the "pickled" signatures in Scala 2 .class files, and it generates .class files that are binary compatible with those produced by Scala 2. |
| 119 | + |
| 120 | +Recall that, if .class files are binary compatible, corresponding .sjsir files are binary compatible as well. |
| 121 | +Combined with the above properties, it means that Scala 3/JS codebases can reuse libraries compiled by Scala 2/JS. |
| 122 | +And among them, the Scala standard library and the JDK subset. |
| 123 | +Therefore, can also reuse the artifacts already published on Maven Central. |
| 124 | + |
| 125 | +That property extends to the JUnit implementation, to the test bridging infrastructure, and even to any 3rd party library! |
| 126 | + |
| 127 | +### Reusing the sbt plugin |
| 128 | + |
| 129 | +Without too much detail, the Scala.js sbt plugin has 3 responsibilities: |
| 130 | + |
| 131 | +* Adding the scalajs-library.jar on the classpath of the project and the Scala.js compiler plugin |
| 132 | +* Provide tasks to call the linker on the .sjsir files to produce .js files |
| 133 | +* Set up `run` and `test` to use the produced .js file and the testing infrastructure bridge |
| 134 | + |
| 135 | +The last 2 of those responsibilities are independent of the Scala version. |
| 136 | +They are only concerned with .sjsir files, the linker, and the testing infrastructure, all of which can be reused. |
| 137 | + |
| 138 | +As of v1.3.0, sbt-scalajs implements the first task for Scala 2 only. |
| 139 | +sbt-dotty v0.4.2 and later adapts it to Scala 3, so that users need not care about anything. |
| 140 | +For example, it removes the Scala 2 compiler plugin that sbt-scalajs adds, and instead adds the compiler flag `-scalajs`. |
| 141 | +When the functionality of sbt-dotty gets merged into sbt itself, sbt-scalajs will be adapted to directly handle the setup of Scala 3. |
| 142 | + |
| 143 | +Thanks to the combination of sbt-scalajs and sbt-dotty, a straightforward build works out of the box with Scala 3 and Scala.js. |
| 144 | +A small example would be: |
| 145 | + |
| 146 | +```scala |
| 147 | +// project/plugins.sbt |
| 148 | +addSbtPlugin("ch.epfl.lamp" % "sbt-dotty" % "0.4.3") |
| 149 | +addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.3.0") |
| 150 | +``` |
| 151 | + |
| 152 | +```scala |
| 153 | +// build.sbt |
| 154 | +ThisBuild / scalaVersion := "0.27.0-RC1" |
| 155 | +val myScala3JSProject = project |
| 156 | + .enablePlugins(ScalaJSPlugin) |
| 157 | + .settings( |
| 158 | + // Scala.js project with a main method |
| 159 | + scalaJSUseMainModuleInitializer := true, |
| 160 | + ) |
| 161 | +``` |
| 162 | + |
| 163 | +### Reusing the tests |
| 164 | + |
| 165 | +While reusing all of the above was definitely a huge time saver (several person·years), it is the reuse of tests that I valued the most during this work. |
| 166 | +Have you ever dreamed about implementing a significant system from scratch without having to write the tests, because the tests already exist? |
| 167 | +If yes, you understand what I mean! |
| 168 | + |
| 169 | +In the dotty codebase, we pull the sources of the Scala.js test suite from the scala-js/scala-js repo, and we compile them with dotc with Scala.js support. |
| 170 | +We then link them and run the tests using the sbt plugin we mentioned above. |
| 171 | +This ensures that Scala.js in Scala 3 is compliant to all the test cases that we have for Scala 2 (and there are a lot of those). |
| 172 | + |
| 173 | +Of course, we did have to make some changes here: |
| 174 | + |
| 175 | +* Some tests used syntax or other features not available in Scala 3 anymore. |
| 176 | + As long as using that syntax/feature was not the point of the test, we changed the test in the Scala.js core repo to be compilable with Scala 3. |
| 177 | +* Tests that were specifically testing a Scala 2 feature or quirky behavior that does not exist in Scala 3 anymore were moved to a separate directory of Scala 2-only tests. |
| 178 | + Some of those received Scala 3-specific variants in the dotty repository (for example, reflective calls). |
| 179 | + |
| 180 | +## Conclusion |
| 181 | + |
| 182 | +We have explored how the various components of Scala.js fit in the Scala 3 landscape. |
| 183 | +In particular, we saw that we can reuse most of the hard work that went into Scala.js for Scala 2. |
| 184 | +We only had to reimplement the compiler plugin, which is comparatively small. |
0 commit comments