Platform specific code in Swift Packages
Helm Pro yearly subscribers now get a 30% discount on RocketSim thanks to contingent pricing on the App Store.
I have recently been trying to get a command line tool which uses my Swift Package Reading Time to run in different environments and platforms.
The tool takes in the path to a markdown file as an input and outputs the estimated average time it would take to read it.
When compiling for multiple platforms and architectures, I have faced challenges and errors of all kinds. In this article I will explain how I leveraged the power of compiler directives in Swift to get past some of these.
What are compiler directives?
Compiler directives, also referred to as Compiler Control Statements in Apple’s documentation, allow developers to make changes to the compiler’s behaviour directly from Swift code.
In this article I am going to focus on conditional statements (#if/#endif
blocks) which can be used to provide multiple compilation routes based on a given value. These conditional statements can use Boolean literals (true
or false
), a custom value defined via Xcode’s Active Compilation Conditions
build setting or one of the platform conditions listed by Apple in the Swift Book.
For example, conditional compiler statements can be used to perform different logic based on a scheme’s configuration (commonly using the built-in DEBUG
condition):
#if DEBUG
networking.logLevel = .info
#else
networking.logLevel = .error
#endif
APIs not available
A common use case of compiler directives is to provide alternative implementations, or even entirely bypass APIs which are unavailable under a certain set of conditions, such as on specific operating systems or architectures.
This becomes incredibly important when cross-compiling code, as toolchains will usually differ across different environments.
ByWords option
I faced two major issues when compiling the ReadingTime
library for platforms outside the Apple ecosystem. The library uses the following code to get the number of words from a string:
private static func count(wordsIn string: String) -> Int {
var count = 0
let range = string.startIndex..<string.endIndex
string.enumerateSubstrings(in: range, options: [.byWords, .substringNotRequired, .localized], { _, _, _, _ -> () in
count += 1
})
return count
}
This code works very well as it uses a smart mechanism to make the program compatible with languages which don’t use common punctuation or whitespace characters to split words.
This code compiled with no errors on macOS and iOS but, as soon as I tried to compile for any other platforms or architectures, I faced the following error:
It turns out that the byWords
option does not seem to work for any platform outside the Apple ecosystem. But worry not, because this is where compiler statements can shine.
I was able to provide an alternative implementation (albeit not the best but still functional in most cases) for platforms I wanted to compile to but didn’t support the .byWords
option:
#if !os(Linux) && !os(Windows) && !arch(wasm32)
private static func count(wordsIn string: String) -> Int {
var count = 0
let range = string.startIndex..<string.endIndex
string.enumerateSubstrings(in: range, options: [.byWords, .substringNotRequired, .localized], { _, _, _, _ -> () in
count += 1
})
return count
}
#else
private static func count(wordsIn string: String) -> Int {
let chararacterSet = CharacterSet.whitespacesAndNewlines.union(.punctuationCharacters)
let components = string.components(separatedBy: chararacterSet)
let words = components.filter { !$0.isEmpty }
return words.count
}
#endif
In the code snippet above I am preventing the compiler from reaching the code with the
.byWords
option whenever the operating system is either Linux or Windows or when the architecture is wasm32, which will allowReadingTime
to be made available to Web Assembly too.
DateComponentsFormatter
Another API which does not work outside the Apple platforms is DateComponentsFormatter
. The ReadingTime CLI product uses it to print a formatted string of the estimated reading time:
func formattedString(from timeInterval: TimeInterval) -> String? {
let dateFormatter: DateComponentsFormatter = {
let formatter = DateComponentsFormatter()
formatter.unitsStyle = .full
formatter.allowedUnits = [.minute, .second]
return formatter
}()
return dateFormatter.string(from: timeInterval)
}
Again, the solution is to apply a compiler directive and, in this case return nil
if the API cannot be used. The optionality will be handled outside of the method, where a fallback implementation can be provided:
func formattedString(from timeInterval: TimeInterval) -> String? {
#if !os(Linux) && !os(Windows)
let dateFormatter: DateComponentsFormatter = {
let formatter = DateComponentsFormatter()
formatter.unitsStyle = .full
formatter.allowedUnits = [.minute, .second]
return formatter
}()
return dateFormatter.string(from: timeInterval)
#else
return nil
#endif
}
PThreads and Wasm
I spent a while trying to get Apple’s swift-markdown package to compile for Web Assembly (wasm32
architecture) so I could run my library on the browser. The main problem I faced was with the library’s use of FileManager
, which is not available on the SwiftWasm toolchain.
I decided to fork the package and remove any mention of FileManager
to get it to compile. I did not need the library to read from disk, so I was happy to remove all that code. I then replaced swift-markdown’s dependency with my forked version and successfully got a .wasm
file that I could put on a browser.
I was so happy! But… as soon as the code executed, I was greeted with an error saying that, since pthreads
(which swift-markdown uses under the hood) are not supported by the SwiftWasm toolchain, a FakePthread
shim implementation had been provided instead. This meant that the library would not work for Web Assembly as long as it had swift-markdown
as a dependency.
I had to find an alternative, so I went back to importing Apple’s own swift-markdown package (instead of my fork) and proceeded to use a combination of compiler directives and dependency conditions in the project’s Package.swift
to get around the problem.
.target(
name: "ReadingTime",
dependencies: [
.product(
name: "Markdown",
package: "swift-markdown",
condition: .when(platforms: [
.linux,
.macOS,
.windows,
.iOS,
.watchOS
])
)
]
),
Through the modification to my Package.swift
above, I prevented the swift-markdown
package from being imported in any platform not included in the .when
condition. Since I made the dependency not available for wasm32
, I then had to remove all of its usages from ReadingTime
’s code as well as provide an alternative implementation using compiler directives again:
#if !arch(wasm32)
import Markdown
struct Rewriter: MarkupRewriter {
mutating func visitImage(_ image: Image) -> Markup? {
nil
}
func visitLink(_ link: Link) -> Markup? {
guard let linkTitle = link.children.first(where: { $0 is Text }) else { return link }
return linkTitle
}
}
#endif
public struct MarkdownRewriter {
public let text: String
public init(text: String) {
self.text = text
}
public func rewrite() -> String {
#if !arch(wasm32)
let document = Document(parsing: text)
var rewriter = Rewriter()
let updatedDocument = rewriter.visitDocument(document)
return updatedDocument!.format()
#else
return RegexParser.rewrite()
#endif
}
}
Windows: Preventing packages from being fetched
A stranger issue I faced was, not surprisingly, when I tried to build for Windows 😅.
I have a single Swift Package with a number of targets that acts as a monorepo. This means that, in order to build or test any target, all dependencies must be fetched and checked out by the swift build system. They then might or might not be built depending on whether the target or product being built uses them.
This is usually fine, unless Windows decides to throw a spanner in the works 😅. On my first build I was greeted with the following error 🥴:
It turns out that on Windows, git can not check out files with paths containing a colon. Danger, one of the dependencies I use for my CI has a couple of markdown files with colons in their paths.
After trying a number of potential solutions with no luck, I decided to take a different approach to solve the build problem. Since Danger was not going to be used at all in the Windows builds, I decided to remove it entirely for that operating system.
I used compiler directives again but in the Package.swift
this time:
// ...
#if !os(Windows)
dependencies.append(.package(url: "https://github.com/danger/swift.git", from: "3.12.1"))
targets.append(.target(name: "DangerDeps", dependencies: [.product(name: "Danger", package: "swift")]))
products.append(.library(name: "DangerDeps", type: .dynamic, targets: ["DangerDeps"]))
#endif
let package = Package(
name: "ReadingTime",
platforms: [.iOS(.v8), .macOS(.v12)],
products: products,
dependencies: dependencies,
targets: targets
)
Note that compiler directives in the
Package.swift
will only work for checks on the ‘host’ machine. This is the machine building the code rather than the one running it - known as the ‘target’.
This solution, albeit not ideal, got me past the problem and I now have a successful Windows build! 🎉