Tech News
← Back to articles

We built an interpreter for Swift (a compiled language)

read original related products more articles

Bitrig dynamically generates and runs Swift apps on your phone. Normally this would require compiling and signing with Xcode, and you can’t do that on an iPhone.

To make it possible to instantly run your app, we built a Swift interpreter. But it’s an unusual interpreter, since it interprets from Swift… to Swift. One of the top questions we’ve gotten is how it’s implemented, so we wanted to share how it works. To make this more accessible and interesting, we simplified some of the more esoteric details. But we hope you’ll come away with a high-level picture of how the interpreter works.

The Swift project helpfully provides a way to reuse all of the parsing logic from the compiler: SwiftSyntax. This made our job a lot easier. We can easily take some Swift code and get a parsed tree out of it, which we can use to evaluate and call into to get dynamic runtime values. Let’s dig deeper.

We can start with generating the simplest kind of runtime values. For any literals (strings, floating point numbers, integers, and booleans), we can create corresponding Swift instances ( String , Double , Int , Bool ) to represent them. Since we’re not compiling this, we don’t know ahead of time what all the types will be, so we need to type erase all instances. Let’s make an enum to represent these runtime interpreter values (since we'll have multiple kinds soon):

enum InterpreterValue { case nativeValue ( Any ) }

Next, we'll expand our interpreter runtime values to be able to represent developer-defined types, too. Let’s say we have a struct with two fields: a string and an integer. We’ll store it as a type that has a dictionary mapping from the property name to the runtime value. When an initializer gets called, we simply need to map the arguments to the property names and populate the dictionary.

enum InterpreterValue { case nativeValue ( Any ) case customInstance ( CustomInstance ) } struct CustomInstance { var type : InterpreterType var values : [ String : InterpreterValue ] }

But, what happens when we want to call an API that comes from a framework, like SwiftUI? For example, let’s say we have a call to Text("Hello World") . We don’t want to rewrite all of the APIs, the whole benefit of making a native app is being able to call into those implementations! Well, those APIs are available for us to call into since the interpreter is also written in Swift (naturally!). We just need to change from a dynamic call to a compiled one. We can do that by pre-compiling a call to the Text initializer that can take dynamic arguments. Something like this:

func evaluateTextInitializer ( arguments : [ Argument ] ) -> Text { Text ( arguments . first ? . value . stringValue ?? "" ) }

But of course, we need more than just the Text initializer, so we'll generalize this to any initializer we might be called with:

... continue reading