|
| 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 | +These days, 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 did we manage 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 | + |
| 18 | +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. |
| 19 | +We will see that only a small fraction of the codebase required a rewrite, namely the compiler plugin (weighing about 11% of the total implementation code of Scala.js). |
| 20 | +Other components like the JDK implementation, the linker and optimizer are reused as is. |
| 21 | +Last but not least, we could reuse the test suite as well. |
| 22 | + |
| 23 | +This post explores how Scala.js is implemented, and not so much how it is used, although no compiler knowledge is required to enjoy this post. |
| 24 | +For readers mostly interested on what Scala 3 has to offer to Scala.js *users*, stay tuned for an upcoming blog post. |
| 25 | + |
| 26 | +## Background: the Scala.js architecture |
| 27 | + |
| 28 | +In a simple sbt project, the only difference between a JVM project and a JS project can be as short as this one-liner: |
| 29 | + |
| 30 | +```scala |
| 31 | +enablePlugins(ScalaJSPlugin) |
| 32 | +``` |
| 33 | + |
| 34 | +although for an application with a main method, we will also have |
| 35 | + |
| 36 | +```scala |
| 37 | +scalaJSUseMainModuleInitializer := true |
| 38 | +``` |
| 39 | + |
| 40 | +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. |
| 41 | + |
| 42 | +There are many components that contribute to this behavior. |
| 43 | +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. |
| 44 | + |
| 45 | + |
| 46 | + |
| 47 | +### A compiler plugin |
| 48 | + |
| 49 | +When compiling a Scala.js codebase, the .scala source files are compiled with scalac, augmented with the Scala.js compiler plugin. |
| 50 | +From scalac, we receive parsing, type checking, elaboration (type inference, implicit resolution, overload resolution, etc.), as well as many transformation phases. |
| 51 | +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). |
| 52 | + |
| 53 | +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. |
| 54 | +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. |
| 55 | +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. |
| 56 | +Not all compile-to-JS languages have that property! |
| 57 | + |
| 58 | +### `.sjsir` files |
| 59 | + |
| 60 | +.sjsir files are similar to .class files, although they are AST-based (instead of stack-machine-based) and contain features dedicated to JavaScript interoperability. |
| 61 | +The .sjsir format and specification is independent of Scala, its compiler or its standard library. |
| 62 | +In fact, there is nothing Scala-specific in the entire linker, other than a few optimizations targeted at Scala-like code. |
| 63 | +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. |
| 64 | +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. |
| 65 | +While we have a proof-of-concept for Java, it is nowhere near usable, however. |
| 66 | + |
| 67 | +.sjsir files have binary compatibility guarantees similar to those of .class files. |
| 68 | +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. |
| 69 | +This allows Scala.js codebases to meaningfully use MiMa to check that they do not break their binary API. |
| 70 | + |
| 71 | +The Scala.js linker (optimizer) takes the .sjsir files from the application and from all the transitive dependencies. |
| 72 | +In particular, the .sjsir files that are built for the Scala standard library and for the JDK subset. |
| 73 | +The linker optimizes all of them together, then emits one .js file for the entire codebase. |
| 74 | + |
| 75 | +### Aside: What about TASTy? |
| 76 | + |
| 77 | +Inevitably, talking about the Scala.js IR raises questions about TASTy. |
| 78 | +TASTy has in fact no impact on the implementation of Scala.js for Scala 3; nothing good, nothing bad. |
| 79 | +And yet: |
| 80 | + |
| 81 | +* `.sjsir` files are an intermediate typed AST-based language for compiling Scala code. |
| 82 | +* `.tasty` files are an intermediate typed AST-based language for compiling Scala code. |
| 83 | + |
| 84 | +When described as above, one may wonder what is the relationship between the Scala.js IR and TASTy. |
| 85 | +Could we not just compile from TASTy to JavaScript? |
| 86 | + |
| 87 | +The answer is no, we could not. |
| 88 | +TASTy has a very different level of abstraction than the Scala.js IR. |
| 89 | + |
| 90 | +| TASTy | Scala.js IR | |
| 91 | +|-------|-------------| |
| 92 | +| full Scala type system | erased type system (like the JVM) | |
| 93 | +| no interoperability knowledge | specific JS interop features | |
| 94 | +| complex Scala features (traits, inner/local classes, lazy vals, etc.) | flat classes and interfaces, no fields in interfaces, simple fields in classes, etc. | |
| 95 | + |
| 96 | +During the compilation pipeline, the compiler first type-checks and elaborates Scala source code into a TASTy-level representation (even in Scala 2, although it is not TASTy itself). |
| 97 | +Then, a few dozens of phases successively transform that representation to eliminate Scala features and erase the type system. |
| 98 | +It is only at the end of that process that Scala/JVM produces `.class` files while Scala.js procudes `.sjsir` files. |
| 99 | + |
| 100 | +We *can* compile from TASTy to JavaScript, but that does not take away the fact that we have to perform all those phases again. |
| 101 | +There is no shortcut. |
| 102 | +The best way to do that is to reuse the phases that are already implemented in the compiler, of course. |
| 103 | +From there, we still need to compile the lowered representation of the compiler into JavaScript, and the best way to do that is through the Scala.js IR. |
| 104 | + |
| 105 | +Therefore, from the point of view of implementing Scala.js for Scala 3, the presence or absence of TASTy is irrelevant. |
| 106 | + |
| 107 | +### Components of Scala.js core |
| 108 | + |
| 109 | +The components involved here have the following sizes (not counting test files): |
| 110 | + |
| 111 | + |
| 112 | + |
| 113 | +The two biggest components, "scalac compiler" and "Scala standard library", are not part of Scala.js itself. |
| 114 | +They are reused from the core Scala repository. |
| 115 | + |
| 116 | +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. |
| 117 | +In comparison, the 8,000 lines of code of the Scala.js compiler plugin represent only 11% of the total 69 kLoC. |
| 118 | + |
| 119 | +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). |
| 120 | +For fun, we can compare that to the sizes of the repositories for Scala 2 and Scala 3: |
| 121 | + |
| 122 | + |
| 123 | + |
| 124 | +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. |
| 125 | + |
| 126 | +## Supporting Scala.js in Scala 3 |
| 127 | + |
| 128 | +Scala 3 has an entirely new compiler codebase, reimplemented from scratch. |
| 129 | +While many internal concepts such as Trees, Types and Symbols exist in both compilers, their design, semantics, and internal APIs are quite different. |
| 130 | +The language itself has of course changed a lot as well. |
| 131 | +What does it mean for Scala.js support? |
| 132 | + |
| 133 | +### A replacement for the compiler plugin |
| 134 | + |
| 135 | +Clearly, we had to entirely replace the Scala.js compiler plugin to work with Scala 3. |
| 136 | +For convenience, we chose to implement the Scala.js support directly inside the Scala 3 compiler (dotc) rather than as an external compiler plugin. |
| 137 | + |
| 138 | +Remember the 8,000 lines of code of the compiler plugin from above? |
| 139 | +Just like the compiler had been rewritten, we had to rewrite them for dotc. |
| 140 | +As of this writing, the amount of code related to Scala.js in dotc is about 6,000 lines. |
| 141 | +They cover virtually everything from the scalac compiler plugin; only JS exports support is missing. |
| 142 | +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. |
| 143 | + |
| 144 | +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. |
| 145 | +As we will see, everything else was already there for us to reuse. |
| 146 | + |
| 147 | +### Reusing the linker |
| 148 | + |
| 149 | +We mentioned earlier that the Scala.js linker, written in Scala 2, links .sjsir files independently of the version of Scala that produced them. |
| 150 | +This is also true for Scala 3! |
| 151 | +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. |
| 152 | + |
| 153 | +### Reusing the .sjsir files of the Scala standard library and of the JDK subset |
| 154 | + |
| 155 | +As you may already know, a Scala 3 codebase is able to depend on libraries compiled by Scala 2. |
| 156 | +This is even exploited down to the standard library level: Scala 3 codebases use the standard library compiled by Scala 2.13. |
| 157 | +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. |
| 158 | + |
| 159 | +Recall that, if .class files are binary compatible, corresponding .sjsir files are binary compatible as well. |
| 160 | +Combined with the above properties, it means that Scala 3/JS codebases can reuse libraries compiled by Scala 2/JS. |
| 161 | +And among them, the Scala standard library and the JDK subset. |
| 162 | +Therefore, can also reuse the artifacts already published on Maven Central. |
| 163 | + |
| 164 | +That property extends to the JUnit implementation, to the test bridging infrastructure, and even to any 3rd party library! |
| 165 | + |
| 166 | +### Reusing the sbt plugin |
| 167 | + |
| 168 | +Without too much detail, the Scala.js sbt plugin has 3 responsibilities: |
| 169 | + |
| 170 | +* Adding the scalajs-library.jar on the classpath of the project and the Scala.js compiler plugin |
| 171 | +* Provide tasks to call the linker on the .sjsir files to produce .js files |
| 172 | +* Set up `run` and `test` to use the produced .js file and the testing infrastructure bridge |
| 173 | + |
| 174 | +The last 2 of those responsibilities are independent of the Scala version. |
| 175 | +They are only concerned with .sjsir files, the linker, and the testing infrastructure, all of which can be reused. |
| 176 | + |
| 177 | +As of v1.3.0, sbt-scalajs implements the first task for Scala 2 only. |
| 178 | +sbt-dotty v0.4.2 and later adapts it to Scala 3, so that users need not care about anything. |
| 179 | +For example, it removes the Scala 2 compiler plugin that sbt-scalajs adds, and instead adds the compiler flag `-scalajs`. |
| 180 | +When the functionality of sbt-dotty gets merged into sbt itself, sbt-scalajs will be adapted to directly handle the setup of Scala 3. |
| 181 | + |
| 182 | +Thanks to the combination of sbt-scalajs and sbt-dotty, a straightforward build works out of the box with Scala 3 and Scala.js. |
| 183 | +A small example would be: |
| 184 | + |
| 185 | +```scala |
| 186 | +// project/plugins.sbt |
| 187 | +addSbtPlugin("ch.epfl.lamp" % "sbt-dotty" % "0.4.3") |
| 188 | +addSbtPlugin("org.scala-js" % "sbt-scalajs" % "1.3.0") |
| 189 | +``` |
| 190 | + |
| 191 | +```scala |
| 192 | +// build.sbt |
| 193 | +ThisBuild / scalaVersion := "0.27.0-RC1" |
| 194 | +val myScala3JSProject = project |
| 195 | + .enablePlugins(ScalaJSPlugin) |
| 196 | + .settings( |
| 197 | + // Scala.js project with a main method |
| 198 | + scalaJSUseMainModuleInitializer := true, |
| 199 | + ) |
| 200 | +``` |
| 201 | + |
| 202 | +### Reusing the tests |
| 203 | + |
| 204 | +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. |
| 205 | +Have you ever dreamed about implementing a significant system from scratch without having to write the tests, because the tests already exist? |
| 206 | +If yes, you understand what I mean! |
| 207 | + |
| 208 | +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. |
| 209 | +We then link them and run the tests using the sbt plugin we mentioned above. |
| 210 | +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). |
| 211 | + |
| 212 | +Of course, we did have to make some changes here: |
| 213 | + |
| 214 | +* Some tests used syntax or other features not available in Scala 3 anymore. |
| 215 | + 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. |
| 216 | +* 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. |
| 217 | + Some of those received Scala 3-specific variants in the dotty repository (for example, reflective calls). |
| 218 | + |
| 219 | +## Conclusion |
| 220 | + |
| 221 | +We have explored how the various components of Scala.js fit in the Scala 3 landscape. |
| 222 | +In particular, we saw that we can reuse most of the hard work that went into Scala.js for Scala 2. |
| 223 | +We only had to reimplement the compiler plugin, which is comparatively small. |
0 commit comments