polpiellaDEV

October 6, 2022

Platform specific code in Swift Packages

7 minute read

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):

Networking.swift
#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:

ReadingTime.swift
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:

Error where byWords is not available in non-Apple platforms

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:

ReadingTime.swift
#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 allow ReadingTime 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:

ReadingTime.swift
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:

ReadingTime.swift
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.

Package.swift
.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:

MarkdownRewriter.swift
#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 🥴:

Git error where paths with colons on them can not be resolved.

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:

Package.swift
// ...
#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! 🎉