Skip to content

Commit 6fa1943

Browse files
authored
Merge pull request #1169 from sjrd/blog-scalajs-in-scala-3
Add blog post about the implementation of Scala.js support for Scala 3.
2 parents a96952c + cc8e0e3 commit 6fa1943

File tree

4 files changed

+1316
-0
lines changed

4 files changed

+1316
-0
lines changed
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
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+
![Scala.js compilation pipeline](/resources/img/blog/scalajs-for-scala-3/compilation-pipeline.svg)
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+
![Sizes of the components of Scala.js](/resources/img/blog/scalajs-for-scala-3/components-sizes.svg)
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+
![Sizes of the core Scala repositories](/resources/img/blog/scalajs-for-scala-3/repositories-sizes.svg)
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

Comments
 (0)