A few days ago I introduced SwiftBash — a sandboxed bash interpreter written in pure Swift. At the end of the four-green-checkmarks post I promised the next instalment would be about something else: SwiftScript, the same idea but for Swift itself.
It’s exactly that. Real Swift syntax, walked by a tree-walking interpreter, no LLVM, no codegen, no Process/fork/exec — meant for the places where Swift as a compiled binary isn’t an option.
After this success with an AST for bash, I figured, let’s up the game and try the same with Swift Syntax. My Claude Opus has proven time again that it has the necessary tenacity to make any stupid idea come true.
Let me just say it outright: I get very sad every time somebody insists that TypeScript is the future of agentic coding. So I kept sending my wish to the universe that Swift should be also in the race for that. Now – finally – I was able to manifest the missing piece: an interpreter for the language I love.
Why a Swift interpreter
Swift can already be a scripting language. #!/usr/bin/env swift works today, and the toolchain even reads command-line arguments and links dynamic modules along the way. That covers the case where you have the toolchain installed and you’re allowed to compile-and-run.
What it doesn’t cover:
- iOS apps. App Sandbox forbids spawning processes and forbids running JIT-compiled code.
- macOS sandboxes — same constraint.
- Server-side hosts where you don’t ship the compiler for size or security reasons.
Swift is too beautiful a language to leave unavailable everywhere a compile-and-exec pipeline isn’t. The same intuition that drove SwiftBash applies here: take the language an LLM (or a human) already wants to write, and make it run inside the host process, in a deterministic sandbox, with no shell-out.
What it’s built on
The foundation is swift-syntax — Apple’s official, source-of-truth Swift parser, which also underpins the modern Swift compiler frontend. I’d already been using it for two earlier projects:
- SwiftButler was an early experiment with reading Swift source and reasoning about it.
- SwiftMCP‘s macros lean heavily on AST-walking to expose Swift functions to MCP clients.
Once you trust swift-syntax to give you the AST, “interpret it” stops sounding ridiculous and starts sounding like an afternoon project. SwiftScript has matured well past proof-of-concept since then: an interpreter walks the AST, evaluates expressions to real Swift values, and at the leaves — function calls, property accesses, initialisers — actually invokes the real system functions.
The repo is about 30,000 lines of Swift today. Almost half of that is auto-generated bridge code. More on that in a second.
Boxing and unboxing
The hard part is the seam between interpreted Swift values and real Swift values. Inside the interpreter, every value is a Value — a single enum that knows how to be every shape Swift cares about:
public indirect enum Value: @unchecked Sendable {
case int(Int)
case double(Double)
case string(String)
case bool(Bool)
case void
case function(Function)
case range(lower: Int, upper: Int, closed: Bool)
case array([Value])
case optional(Value?)
case tuple([Value], labels: [String?])
case dict([DictEntry])
case set([Value])
/// Opaque carrier for a host-Swift value (CharacterSet, URL, Date,
/// Data, …) that we don't model structurally.
case opaque(typeName: String, value: Any)
case structValue(typeName: String, fields: [StructField])
case classInstance(ClassInstance)
case enumValue(typeName: String, caseName: String, associatedValues: [Value])
}
Int, String, Double, Bool get their own cases because they’re so common. Everything Foundation hands us that we can’t decompose — URL, Date, CharacterSet, Data, Calendar, JSONEncoder — lives inside .opaque(typeName:, value: Any). The typeName is a string we use for runtime type checks; the value is the actual host instance held as Any.
Calling URL.absoluteString from inside a script means crossing that seam:
"var URL.absoluteString: String": .computed { receiver in
let recv: URL = try unboxOpaque(receiver, as: URL.self, typeName: "URL")
return .string(recv.absoluteString)
},
Three things to notice. First, the bridge is keyed by a string that’s a one-line summary of the Swift declaration — "var URL.absoluteString: String". The same shape works for init, func, static let, etc. It’s greppable, it’s declarative, and it matches the Swift you’d write by hand. Second, the closure is the only piece of executable logic — receive a Value, return a Value. Third, the two helpers — unboxOpaque and boxOpaque — carry the entire seam between interpreter values and host values:
/// Wrap a host-Swift value of type `T` as a `Value.opaque`.
func boxOpaque<T>(_ value: T, typeName: String) -> Value {
return .opaque(typeName: typeName, value: value)
}
/// Recover a host-Swift value from a `Value.opaque`. Verifies the boxed
/// `typeName` matches — a type-name mismatch throws rather than risking
/// a bad downcast.
func unboxOpaque<T>(_ value: Value, as: T.Type, typeName expectedName: String) throws -> T {
guard case .opaque(let actualName, let any) = value else {
throw RuntimeError.invalid("expected \(expectedName), got \(typeName(value))")
}
guard actualName == expectedName else {
throw RuntimeError.invalid("expected \(expectedName), got \(actualName)")
}
guard let cast = any as? T else {
throw RuntimeError.invalid("opaque value of type \(actualName) failed to cast")
}
return cast
}
A method with arguments looks the same in both directions:
"func URL.appendingPathComponent()": .method { receiver, args in
let recv: URL = try unboxOpaque(receiver, as: URL.self, typeName: "URL")
return boxOpaque(recv.appendingPathComponent(try unboxString(args[0])),
typeName: "URL")
},
unboxString(args[0]) pulls a String back out of Value.string; the call returns a real URL; boxOpaque packs it back as Value.opaque(typeName: "URL", …). The script never sees the URL instance directly, but every operation on it is the actual Foundation method, with all its real behaviour — its NSURL bridging, its .fileURL quirks, its percent-encoding rules. We’re not reimplementing Foundation; we’re routing through it.
The bridge generator
You don’t write 13,000 lines of those entries by hand. Or at least: I didn’t, after writing the first two by hand, swearing audibly, and writing BridgeGeneratorTool/main.swift instead.
It’s a 2,200-line command-line tool that:
- Takes one or more symbol graphs — Apple’s machine-readable JSON dump of a module’s public surface, which Xcode emits during DocC builds. Stdlib’s symbol graph and Foundation’s symbol graph give us every public type, every public function, every initialiser, every protocol conformance, every generic constraint.
- Walks those graphs, classifies each symbol by its shape (computed property, instance method, static method, init, failable init, throwing init, throwing async method with generics, …), and emits the appropriate
.method/.computed/.staticMethodbridge entry for each. - Writes two output files — one for stdlib, one for Foundation — each containing tens of thousands of entries.
The generator handles a long tail of cases that would otherwise have eaten a month of debugging:
- Optional-returning functions unbox their arguments, call, then box the result as
.optional(boxOpaque(...))if non-nil,.optional(nil)otherwise. - Throwing initialisers (
init?(string:)) get an explicit failure-path bridge — return.optional(nil)when the host call returns nil. - Generic functions with type constraints emit a generic check at call time. The interpreter has a built-in protocol-predicate table that decides whether a
Value“is”Encodable/Comparable/Sequence/etc. without trying to actually conform it:
"Encodable": { _ in true }, // ScriptCodable wraps any Value
"Hashable": { _ in true },
"Comparable": { v in
switch v { case .int, .double, .string: return true; default: return false }
},
"Sequence": { v in
switch v {
case .array, .set, .range, .string, .dict: return true
default: return false
}
},
Encodable returning true for everything sounds like a cheat. It isn’t — the next paragraph explains.
Codable round-trips through real Foundation
Script code does this all the time:
let user = User(name: "Bob", age: 42)
let data = try JSONEncoder().encode(user)
print(String(data: data, encoding: .utf8)!)
User is a struct defined inside the script. The interpreter has a Value.structValue(typeName: "User", fields: [...]) for it. JSONEncoder().encode(user) is a host call into real Foundation — and Foundation has no idea how to encode Value.
The trick is a thin Codable adapter: a ScriptCodable wrapper that conforms to Codable and walks the Value tree itself, asking the encoder for the right container kind at each step (single-value for primitives, keyed for structs/dicts, unkeyed for arrays). Encoding is symmetric and needs no type context. Decoding is the harder direction — JSON’s {} could be any struct; null could be any optional — so the decoder reads the script type name and a back-reference to the interpreter from decoder.userInfo:
public init(from decoder: Decoder) throws {
guard let interp = decoder.userInfo[.scriptInterpreter] as? Interpreter,
let typeName = decoder.userInfo[.scriptTargetType] as? String
else { … }
self.value = try Self.decodeValue(
from: decoder, typeName: typeName, interp: interp
)
}
What this buys: the script is using the real JSONEncoder with the real strategies (.iso8601, .convertToSnakeCase, …). We don’t reimplement the format. We don’t need to. Every Date/URL/Data quirk is Foundation’s quirk, not ours.
The same wrapper handles PropertyListEncoder, custom Encoders the user writes, JSONDecoder from a network response — anything in the Codable ecosystem. One adapter, ~340 lines, extends to the entire serialisation surface of the standard library.
Mirror works too
Swift’s Mirror(reflecting: x) walks the structural shape of any value. Script code can do that on its own values:
"init Mirror(reflecting:)": .`init` { args in
let box = MirrorBox(reflected: args[0])
return .opaque(typeName: "Mirror", value: box)
},
"var Mirror.children": .computed { recv in
guard case .opaque(_, let any) = recv,
let box = any as? MirrorBox else { … }
return .array(MirrorModule.childrenOf(box.reflected))
},
MirrorModule.childrenOf switches on the Value enum and returns a [Value] of (label: String?, value: Value) tuples — .struct returns its fields, .classInstance walks its property cells, .array enumerates its elements with nil labels, .dict returns key/value pairs. So generic dump helpers, debug printers, and data-driven serialisers — all the patterns that lean on Mirror.children — port directly into script code with the same surface.
KeyPaths are synthesised closures
people.map(\.age) in real Swift uses a KeyPath<Person, Int>. We don’t model that. Instead:
func evaluate(keyPath: KeyPathExprSyntax) throws -> Value {
var steps: [String] = []
for component in keyPath.components {
switch component.component {
case .property(let prop):
steps.append(prop.declName.baseName.text)
...
}
}
// Synthesise `{ $0.steps[0].steps[1]... }` as a closure
...
}
\.age becomes a one-arg closure { $0.age }. \.address.city becomes { $0.address.city }. people.map(\.age) is then just people.map { $0.age }. The host signature map(_: (Element) -> T) accepts a Function value, and we run it under the interpreter the same way as any user-written closure. Subscript- and optional-chaining components in keypaths are surfaced as runtime-unsupported errors rather than being silently mistranslated — they’re rare in script code and faking them would be worse than rejecting them.
Iteration, both directions
Two adapters bridge iteration between host-Swift and script-Swift:
ScriptSequence is a Sequence over any iterable Value — array, set, dict, string, range. Wrapping a script value gives host-Swift code something to pass to Array(_:), Set(_:), zip, prefix, and the rest of the stdlib’s algorithm surface. Bridge code that needs to walk a Value no longer has to switch on every shape.
AsyncStreamBox goes the other way — a reference-typed carrier for an asynchronous element source, surfaced into the interpreter as .opaque("AsyncStream", box). A registered builtin captures a host AsyncIterator‘s .next() in a closure; the for-loop adapter in the interpreter drives it via try await stream.next(). That’s how URLSession.bytes(for:) becomes a script-side for await byte in stream { … } without any per-Foundation-API glue.
Concurrency, with one honest cheat
The interpreter’s evaluation graph is fully async throws from top to bottom — every evaluate(...) call signature suspends. So await in script code lands on Swift’s real concurrency runtime: bridged async leaves (URLSession.shared.data(...), the sleep builtin) genuinely suspend and resume.
The cheat is Task { … }. In real Swift, Task { closure } spawns a new concurrent task and returns a handle. In SwiftScript, Task { closure } runs the closure body inline and returns .void. Why: the interpreter mutates shared state (scopes, classDefs, the bridge table, …) that isn’t Sendable. Spawning real concurrent Swift tasks would race. Inline execution + real await on leaves is the best of both worlds — script code calls bridged async APIs and gets real suspension, but the interpreter keeps its single-threaded mutation guarantee.
actor Foo { … } declarations are registered as classes for the same reason. The single-threaded runtime has nothing to isolate, so the await keyword on actor methods is a no-op at the expression level and method dispatch goes through the same path as classes.
Cross-platform classification, automatically
This one is my favourite engineering touch in the project, and the reason five checkmarks light up rather than three.
The Apple symbol graph for Foundation is huge. It includes a lot of Apple-only stuff — NSCoding, AppKit-bridged classes, things that simply don’t ship in swift-corelibs-foundation. A naive bridge generator would emit entries for those and the Linux/Windows/Android builds would fail to link.
Hand-curating an “Apple-only” list would be tedious and inevitably stale. Instead there’s a tiny companion tool, SCLSymbolExtractor, which parses the actual source tree of swift-corelibs-foundation with swift-syntax and emits a flat list of every public type member it declares:
Type.memberName (cross-platform member)
Type.memberName UNAVAILABLE (declared but @available(*, unavailable))
Type. (cross-platform type marker; member name empty)
.topLevelFunc (cross-platform free function)
The resulting file (Resources/foundation-symbols-scl.txt, ~8000 lines) is consumed by the bridge generator, which then automatically wraps every Apple-only entry in #if canImport(Darwin). No hand-curated +Apple.swift companions; no merge conflicts when Linux’s Foundation gets a new method; the whole policy is a regenerate-from-source step.
The companion handles the gnarly cases too: @available(*, unavailable) declarations stay marked Apple-only because Linux declares the symbol but throws at runtime; entries the generator can’t classify (no owning type, no signature) are conservatively wrapped.
What it’s good for
The natural niche is the place where bash starts to creak — anything that wants real numbers, structured data, or a local function with named parameters:
struct Sample { let label: String; let values: [Double] }
func mean(_ xs: [Double]) -> Double {
xs.reduce(0, +) / Double(xs.count)
}
let samples = [
Sample(label: "alpha", values: [12.1, 13.4, 11.9]),
Sample(label: "beta", values: [9.5, 8.7, 10.2]),
]
for s in samples {
print("\(s.label): \(mean(s.values))")
}
That’s a swift-script one-liner away from running. No compile step, no toolchain on the runtime host, no shell-out. The same source loaded by an iOS app, evaluated in-process, sandboxed.
The Examples/llm_probes/ folder in the repo is a set of ten small programs an LLM might typically write — mean/stddev, primes, quadratic formula, Fibonacci, Simpson’s rule numerical integration, compound interest. They all run unmodified.
The shebang case works:
#!/usr/bin/env swift-script
import Foundation
let nums = [3, 1, 4, 1, 5, 9, 2, 6, 5, 3]
let sorted = nums.sorted()
print("sorted:", sorted)
print("mean: ", String(format: "%.2f", Double(nums.reduce(0, +)) / Double(nums.count)))
chmod +x it, run it directly, the binary on $PATH is swift-script instead of swift. The script never gets compiled. It runs entirely inside swift-script‘s process.
Where it falls short
Two big honest caveats up front:
- No type checking ahead of time. SwiftScript evaluates types when the call happens — when
unboxOpaque(receiver, as: URL.self, typeName: "URL")runs and either gets back a URL or throws. There’s no compile pass to tell you “that’s anInt, you can’t pass it toURL.appendingPathComponent” before the script starts running. swift-syntax already gives us the structural information; the interpreter just doesn’t take advantage of it for type analysis yet. That’s the next obvious frontier. - Class inheritance is approximated rather than walked through a real vtable. A subclass is registered with a recorded superclass name; method dispatch walks the chain at call time;
overrideis checked structurally (declaringoverrideon a method that doesn’t actually shadow a superclass method is a compile-error-equivalent runtime error). It supports the everyday patterns — override, store fields, share state via reference semantics — and even allows inheriting from bridged parents (a script class inheriting fromURLorDatewraps a real native instance and falls through to the bridged surface for unmodelled members). What it doesn’t perfectly mirror is every corner of Swift’s class semantics:superchains across multiple levels behave correctly for normal calls, but exotic patterns (initialiser inheritance withrequired, dynamic dispatch throughSelf-typed return) are best-effort.
Neither limitation is fundamental — both are work, not impossibilities.
A few numbers
For anyone curious about the shape of the code:
| LOC | |
|---|---|
| Total Swift in repo | ~30,000 |
| Auto-generated stdlib bridges | ~1,100 |
| Auto-generated Foundation bridges | ~13,500 |
| Bridge generator tool | ~2,200 |
| Interpreter (everything else) | ~13,000 |
| Test suite | 69 test files |
The bridge generator is the single highest-leverage piece of code in the project. Every new Foundation type Apple ships becomes available to script code by re-running the generator against the updated symbol graph; no per-type human work.
The Resources/ directory holds three lists that drive the generation: a 126-line allowlist of types we always include, a 200-line blocklist of types/members the auto-bridge can’t handle (and where a hand-rolled bridge in Modules/ takes over), and the 8000-line foundation-symbols-scl.txt cross-platform classifier described above.
Bitrig’s Compiler
I would be amiss if I didn’t tip my hat to Bitrig who tackled this problem slightly differently. Instead of walking the AST tree they invented a compiler that compiles the Swift code into a form of byte code first. Then the second step executes those commands in something similar to a virtual machine. But at the end it still needs to transition to the binary world. This approach optimizes for performance because it avoids having to navigate around the tree and boxing and unboxing values.
But premature optimization is the death of many a project, so I concentrated on making it work first. We can still worry about performance later. Bitrig is focussing on SwiftUI code that gets written on-device. My primary goal is to use Swift as first class scripting language, so performance is a lesser concern.
What I’d love to see
It would be wonderful if the official Swift project leaned into safe, embedded scripting as a first-class use case — a sanctioned interpreter mode, blessed bridges over Foundation and the standard library, and a clear answer to “I want to ship a Swift script that runs inside an iOS app’s sandbox without compiling code.”
Until then, SwiftScript is what I have. The repo is over here, the README has the install line, and the same five-checkmark CI as SwiftBash now keeps it honest on macOS, iOS, Linux, Windows, and Android. As usual, I am very much interested in your thoughts in this and any of my other OSS projects.
Categories: Updates