All about the Swift Package Manager and the Swift toolchain
Learn everything about the SPM architecture. I'll also teach you how to integrate your binary executable into the Swift toolchain.
If you don't know too much about the Swift Package Manager, but you are looking for the basics please read my tutorial about SPM that explains pretty much everything. The aim of this article is to go deep into the SPM architecture, also before you start reading this I'd recommend to also read my article about frameworks and tools. 📖
Ready? Go! I mean Swift! 😂
Swift Package Manager
Have you ever wondered about how does SPM parse it's manifest file in order to install your packages? Well, the Package.swift manifest is a strange beast. Let me show you an quick example of a regular package description file:
// swift-tools-version:4.2 import PackageDescription let package = Package( name: "HelloSwift", dependencies: [ // .package(url: /* package url */, from: "1.0.0"), ], targets: [ .target( name: "HelloSwift", dependencies: ), .testTarget( name: "HelloSwiftTests", dependencies: ["HelloSwift"]), ] )
The first line contains the version information, next we have to import the
PackageDescription module which contains all the required elements to properly describe a Swift package. If you run for example
swift package update all your dependencies in this manifest file will be resolved & you can use them inside your own code files. ✅
But how the heck are they doing this magic? 💫
That question was bugging me for a while, so I did a little research. First I was trying to replicate this behavior without looking at the original implementation of the Swift Package Manager at GitHub. I knew I shouldn't parse the Swift file, because that'd be a horrible thing to do - Swift files are messy - so let's try to import it somehow... 🙃
Dynamic library loading approach
I searched for the "dynamic swift library" keywords and found an interesting forum topic on swift.org. Yeah, I'm making some progress I thought. WRONG! I was way further from the actual solution than I though, but it was fun, so I was looking into the implementation details of how to open a compiled
.dylib file using
dlsym from Swift. How does one create a
.dylib file? Ah, I already know this! 👍
I always wanted to understand this topic better, so I started to read more and more both about static and dynamic libraries. Long story short, you can create a dynamic (or static) library with the following product definition:
// swift-tools-version:4.2 import PackageDescription let package = Package( name: "example", products: [ .library(name: "myStaticLib", type: .static, targets: ["myStaticLib"]), .library(name: "myDynamicLib", type: .dynamic, targets: ["myDynamicLib"]), ], targets: [ .target( name: "myStaticLib", dependencies: ), .target( name: "myDynamicLib", dependencies: ), ] )
The important files are going to be located inside the
.build/debug folder. The
.swiftmodule is basically the public header file, this contains all the available API for your library. The
.swiftdoc file contains the documentation for the compiled module, and depending on the type you'll also get a
.dylib or a
.a file. Guess which one is which.
So I could load the
.dylib file by using
dlopen & dlsym (some @_cdecl magic involved to get constant names instead of the "fuzzy" ones), but I was constantly receiving the same warning over and over again. The dynamic loading worked well, but I wanted to get rid of the warning, so I tried to remove the embedded the lib dependency from my executable target. (Hint: not really possible... afaik. anyone? 🙄)
I was messing around with rpaths & the
install_name_tool for like hours, but even after I succesfully removed my library from the executable, "libSwift*things" were still embedded into it. So that's the sad state of an unstable ABI, I thought... anyway at least I've learned something very important during the way here:
Importing Swift code into Swift!
Yes, you heard that. It's possible to import compiled Swift libraries into Swift, but not a lot of people heard about this (I assume). It's not a popular topic amongs iOS / UIKit developers, but SPM does this all the time behind the scenes. 😅
How the heck can we import the pre-built libraries? Well, it's pretty simple.
// using swiftc with compiler flags swiftc dynamic_main.swift -I ./.build/debug -lmyDynamicLib -L ./.build/debug swiftc static_main.swift -I ./.build/debug -lmyStaticLib -L ./.build/debug // using the Swift Package Manager with compiler flags swift build -Xswiftc -I -Xswiftc ./.build/debug -Xswiftc -L -Xswiftc ./.build/debug -Xswiftc -lmyStaticLib swift build -Xswiftc -I -Xswiftc ./.build/debug -Xswiftc -L -Xswiftc ./.build/debug -Xswiftc -lmyDynamicLib
You just have to append a few compiler flags. The
-I stands for the import search path,
-L is the library search path,
-l links the given library. Check
swiftc -h for more details and flags you won't regret it! Voilá now you can distribute closed source Swift packages. At least it was good to know how SPM does the "trick". 🤓
Please note that until Swift 5 & ABI stability arrives you can use the precompiled libraries with the same Swift version only! So if you compile a lib with Swift 4.2, your executable also needs to be compiled with 4.2., but this will change pretty soon. 👏
The Swift Package Manager method
After 2 days of research & learning I really wanted to solve this, so I've started to check the source code of SPM. The first thing I've tried was adding the
--verbose flag after the
swift build command. Here is the important thing:
/Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swiftc \ --driver-mode=swift \ -L /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/pm/4_2 \ -lPackageDescription \ -suppress-warnings \ -swift-version 4.2 \ -I /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/lib/swift/pm/4_2 \ -target x86_64-apple-macosx10.10 \ -sdk /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX10.14.sdk \ /Users/tib/example/Package.swift \ -fileno 5
Whoa, this spits out a JSON based on my
Package.swift file!!! 🎉
How the hell are they doing this?
It turns out, if you change the
-fileno parameter value to 1 (that's the standard output) you can see the results of this command on the console. Now the trick here is that SPM simply compiles the Package.swift and if there is a
-fileno flag present in the command line arguments, well it prints out the encoded JSON representation of the Package object after the process exits. That's it, fuckn' easy, but it took 1 more day for me to figure this out... parenting 2 kids & coding is a hard combination. 🤷♂️
If you open the
usr/lib/swift/pm/4_2 folder you'll see 3 familiar files there. Exactly. I also looked at the source of the Package.swift file from the SPM repository, and followed the
registerExitHandler method. After a successful
Package initialization it simply registers an exit handler if a
-fileno argument is present encodes itself & dumps the result by using the file handler number. Sweet! 😎
Since I was pretty much in the finish lap, I wanted to figure out one more thing: how did they manage to put the
swift package command under the
I just entered
swift lol into my terminal. This is what happened:
tib@~: swift lol error: unable to invoke subcommand: /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/swift-lol (No such file or directory)
Got ya! The toolchain is the key to everything:
- Apple is compiling the PackageDescription library from the Swift Package Manager and puts the
.dylibfiles into the proper places under Xcode's default toolchain library path.
- The swift build, run, test subcommands are just another Swift binary executables placed inside the toolchain's binary path. (Named like: swift-package, swift-build, swift-run, swift-test)
- The swift command tries to invoke the proper subcommand if there is any and it's a valid (Swift) binary. (Tried with a shell script, it failed miserably...)
- SPM uses the PackageDescription library from the toolchain in order to compile & turn the manifest file into JSON output.
- The rest is history. 🤐
Swift can resolve subcommands from anywhere "inside" the
PATH variable. You just have to prefix your Swift script with
swift- and you're good to go.
SwiftCI - a task runner for Swift
I had this idea that it'd be nice to have a grunt / gulp like task runner also a continuous integration service on a long term by using this technique I explained above. So I've made a similar extension wired into the heart of the Swift toolchain: SwiftCI. ❤️
You can grab the proof-of-concept implementation of SwiftCI from GitHub. After installing it you can create your own
CI.swift files and run your workflows.
import CI let buildWorkflow = Workflow( name: "default", tasks: [ Task(name: "HelloWorld", url: "[email protected]:BinaryBirds/HelloWorld.git", version: "1.0.0", inputs: [:]), Task(name: "OutputGenerator", url: "~/ci/Tasks/OutputGenerator", version: "1.0.0", inputs: [:]), Task(name: "SampleTask", url: "[email protected]:BinaryBirds/SampleTask.git", version: "1.0.1", inputs: ["task-input-parameter": "Hello SampleTask!"]), ]) let testWorkflow = Workflow( name: "linux", tasks: [ Task(name: "SampleTask", url: "https://github.com/BinaryBirds/SampleTask.git", version: "1.0.0", inputs: ["task-input-parameter": "Hello SampleTask!"]), ]) let project = Project(name: "Example", url: "[email protected]:BinaryBirds/Example.git", workflows: [buildWorkflow, testWorkflow])
The code above is a sample from a
CI.swift file, you can simply run any workflow with the swift CI run workflow-name command. Everything is 100% written in Swift, even the CI workflow descriptor file. I'm planning to extend my CI namespace with some helpful sub-commands later on. PR's are more than welcomed!
I'm very happy with the result, not just because of the final product (that's only a proof of concept implementation), but mostly because of the things I've learned during the creation process.