Understanding Swift Packages and Dependency Declarations

Swift Package Manager

Swift 5.2 brought some awesome changes to the package manager thanks to SE-0226 that massively improved the handling of dependencies. Going forward no longer would you face the spinning resolution of doom if you had dependency conflicts. And no longer would you have to download all transitive dependencies if some were only used in testing of your dependencies.

This is great, so you take your Swift 5.1 project, open the Package.swift manifest and edit the first line to read

// swift-tools-version:5.2

Easy. You then open the project in Xcode, or run swift run and are suddenly faced with:

Multiple Swift Packages in an Xcode Workspace

Wait, what. To be fair, at least SwiftPM now offers advice. Earlier versions of Swift 5.2 would just say "Unknown dependency Vapor". So what's going on here?

The Manifest

To understand why we're seeing these errors, we need to understand the three parts of a package SwiftPM uses for resolution:

  • The name of package. You define this in your manifest using the name parameter of the Package initialiser. E.g.
// swift-tools-version:5.2
import PackageDescription

let package = Package(
    name: "ConsumerWebsite",
    // ...
)
  • The name of the target, defined when you declare a target:
.target(
    name: "App",
    // ...
)
  • The final path component in the Git URL. ## Swift Package Manager's Dependency Resolution

SwiftPM uses a combination of the package name, target name and Git path when resolving dependencies. Note that it's also case sensitive, which can trip you up. This produces four different scenarios.

Everything matches

In the ideal world, the dependency you're pulling in has a target whose name matches the package name. The package name also matches the Git path (in case as well). A good example of this is Vapor Security Headers. It defines a single target named VaporSecurityHeaders in a single package named VaporSecurityHeaders. The URL is also VaporSecurityHeaders.git. Because everything matches, you can include it as a dependency the old fashioned way:

// swift-tools-version:5.2
import PackageDescription

let package = Package(
    name: "DependencyExamples",
    dependencies: [
        .package(url: "https://github.com/brokenhandsio/VaporSecurityHeaders.git", from: "3.0.0")
        // ...
    ],
    targets: [
        .target(
            name: "App",
            dependencies: [
                "VaporSecurityHeaders",
                // ...
            ],
        ),
        // ...
    ]
)

Different Git URL and Package Name

Unfortunately, some repositories don't match the name of the package. Most use a kebab- or lisp-case for the URL and then CamelCase for the package name. In this case, you need to qualify the name of the package in the dependencies array. For example, if you integrate Vapor CSRF, you'd do something like:

// swift-tools-version:5.2
import PackageDescription

let package = Package(
    name: "DependencyExamples",
    dependencies: [
        .package(name: "VaporCSRF", url: "https://github.com/brokenhandsio/vapor-csrf.git", from: "1.0.0")
        // ...
    ],
    targets: [
        .target(
            name: "App",
            dependencies: [
                "VaporCSRF",
                // ...
            ],
        ),
        // ...
    ]
)

Notice how you specify the package name in the dependencies array using .package(name:url:from:). This is because the package name is VaporCSRF but the Git URL is vapor-csrf.

Different Package Name and Target Name

A recent convention (at least in the server-side Swift world) seems to be using kebab case for package names. This is following what SwiftNIO does. However using kebab case for a target name would mean you end up doing import swift-nio, which Swift doesn't support (and looks weird!). So you using camel case for the target name. So you need to qualify which package the target comes from in your target's dependency array. For example, when including Vapor, it would look like:

// swift-tools-version:5.2
import PackageDescription

let package = Package(
    name: "DependencyExamples",
    dependencies: [
        .package(url: "https://github.com/vapor/vapor.git", from: "4.0.0"),
        // ...
    ],
    targets: [
        .target(
            name: "App",
            dependencies: [
                .product(name: "Vapor", package: "vapor"),
                // ...
            ],
        ),
        // ...
    ]
)

Notice how you specify where the target Vapor comes from with .product(name: "Vapor", package: "vapor"). Vapor's package name is vapor, which matches the Git URL. But the target name is Vapor so it needs specifying. This would also be the case if a dependency exposes multiple targets in the package. If you want to depend on one where the name is different to the package name, you must specify where the dependency comes from.

Nothing matches

The final situation is where nothing matches. The Git URL, package name and target name are all different. I haven't actually seen any examples of this in the wild. If you do come across this, you need to combine the two different qualifications, like so:

// swift-tools-version:5.2
import PackageDescription

let package = Package(
    name: "DependencyExamples",
    dependencies: [
        .package(name: "MySuperPackage", url: "https://github.com/brokenhandsio/my-super-package.git", from: "1.0.0"),
        // ...
    ],
    targets: [
        .target(
            name: "App",
            dependencies: [
                .product(name: "MySuperTarget", package: "MySuperPackage"),
                // ...
            ],
        ),
        // ...
    ]
)

This this case, the Git URL is my-super-package, the package name is MySuperPackage and the target name is MySuperTarget. To help the dependency resolver work it all out, you need to qualify everything.

Where to go from here

Hopefully this post helps you understand some of the problems you get when adding dependencies and how to resolve them. You might also find it useful when writing your own libraries to make it easy for people to consume them and write your docs correctly!

The changes were made in Swift Package Manager to allow for better dependency resolution. This helps avoid the resolver getting stuck in dependency hell and never returning. It also means you don't need to build large packages that your dependencies rely on just for tests. Finally, it sets SwiftPM up for future improvements, like dependency caching with the new Llbuild2 build system. I'm looking forward to that coming!