Currently, there's a non-trivial amount of boilerplate and repetition for many simple and complex cases with SwiftPM, and it seems like we could make the experience slicker by taking inspiration from other package managers that have more conventions to facilitate low-configuration packages.
SwiftPM now
The manifest of GitHub - apple/example-package-deckofplayingcards: Example package for use with the Swift Package Manager currently looks like:
// swift-tools-version:4.0
/*
This source file is part of the Swift.org open source project
Copyright 2015 Apple Inc. and the Swift project authors
Licensed under Apache License v2.0 with Runtime Library Exception
See http://swift.org/LICENSE.txt for license information
See http://swift.org/CONTRIBUTORS.txt for Swift project authors
*/
import PackageDescription
let package = Package(
name: "DeckOfPlayingCards",
products: [
.library(name: "DeckOfPlayingCards", targets: ["DeckOfPlayingCards"]),
],
dependencies: [
.package(url: "https://github.com/apple/example-package-fisheryates.git", from: "2.0.0"),
.package(url: "https://github.com/apple/example-package-playingcard.git", from: "3.0.0"),
],
targets: [
.target(
name: "DeckOfPlayingCards",
dependencies: ["FisherYates", "PlayingCard"]),
.testTarget(
name: "DeckOfPlayingCardsTests",
dependencies: ["DeckOfPlayingCards"]),
]
)
This is a fairly common case: a package that provides a library and has tests, and yet it requires saying the name DeckOfPlayingCards
... 6 times, along with fairly tautological things like specifying that the tests depend on the main code!
Cargo's approach
The equivalent with, say, Rust's cargo would be a Cargo.toml
manifest like the following, along with the main library code in src/lib.rs
and tests as arbitrary files in tests/*.rs
:
# ... copyright header, I guess ...
[package]
name = "DeckOfPlayingCards"
version = "0.1.0"
authors = ["Swift Project <you@example.com>"]
[dependencies]
# If the dependencies are published in a registry:
FisherYates = "2.0.0"
PlayingCard = "3.0.0"
# or,
# FisherYates = { git = "https://github.com/apple/example-package-fisheryates.git" }
# PlayingCard = { git = "https://github.com/apple/example-package-playingcard.git" }
This mentions the name just once, but if customisation of location etc. is required, a [lib]
section can control the library product and, similarly, [[test]]
and [[bin]]
(executables) and [[example]]
("test" executables). There's a few details that mean this works particularly well with Cargo:
- a package corresponds to products of zero or one libraries (i.e. things that one can import), and any number of binaries/tests/examples, etc.
- the latter implicitly/automatically have a dependency on the library if it exists
- all of the targets (both library and the others) automatically have access to all the dependencies, plus tests and examples depend on an extra set of "development dependencies" (Swift Package Manager Evolution Ideas)
The first means that depending on a package is unambiguous: it is a dependency on the single library product of that package, and the second two mean that there's no need to explicitly list out what each target depends on.
This definitely has some downsides:
- a project that consists of many internal libraries needs quite a few
Cargo.toml
and coordinating dependency versions across them requires using a workspace - the exact dependencies that a target/product has aren't listed (e.g. imagine a package that depends on external libraries
A
andB
and has two productsX
andY
: it might be thatX
only depends onA
andY
onlyB
) - it is a little less clear exactly what a package provides from just the manifest (this isn't so bad with Cargo's one-library-per-package rule: it is clear what library a package can provide)
But it also has a variety of upsides:
-
publishing/creating a package is step that "weighs" less, mentally, as things Just Work and feel slick
-
there's much less repetition, meaning people aren't tempted to write non-declarative things like
let name = "DeckOfPlayingCards" let package = Package( name: name, // ... .testTarget(name: name + "Tests", ...) // ...
SwiftPM in future?
With some conventions similar to cargo's the Package
could look more like the following:
let package = Package(
name: "DeckOfPlayingCards",
dependencies: [
.package(url: "https://github.com/apple/example-package-fisheryates.git", from: "2.0.0"),
.package(url: "https://github.com/apple/example-package-playingcard.git", from: "3.0.0"),
]
)
I think it's worth noting that SwiftPM does already have some convention-based configuration: the original configuration (essentially) uses the conventions for the path
target property: Sources/FisherYates
and Tests/FisherYatesTest
. Thus, adding more conventions seems more of a (large) extension of the current behaviour than a fundamental change in philosophy.
Possible conventions
An initial set of conventions might be something like
-
Sources/X
creates a targetX
that depends on all library products of all thedependencies
. There's also some inference of other dependencies and products from the contents of the directory:- if it's not a Swift product (e.g. contains C files), there's no product, but the target is considered an (importable) module and it doesn't depend on anything within the current package
- if there's no
main.swift
, it's a library product.library(name: "X", targets: ["X"])
and (importable) module, and also doesn't depend on anything within the current package - if there is one, it's an executable product
.executable(name: "X", targets: ["X"])
, and the target depends on the modules (i.e. the previous two points) of the current package
-
Tests/X
creates a test targetX
that depends on all the dependencies as above, and the modules of the current package.
One can still explicitly specifying things, which would stop the conventions applying to that particular target/product, but it would not stop the conventions applying to other things that match the patterns above, e.g. if one wants to tighten the dependencies of a single target, or have modules that depend on another modules within the same package.
Real-world example: NIO libraries
For swift-nio-http2, I suspect the rules above mean it reduces to:
let package = Package(
name: "swift-nio-http2",
dependencies: [
.package(url: "https://github.com/apple/swift-nio.git", from: "1.7.0"),
.package(url: "https://github.com/apple/swift-nio-nghttp2-support.git", from: "1.0.0"),
]
)
The NIOHTTP2Server
executable and target is inferred from Sources/NIOHTTP2Server
existing and having main.swift
, and the NIOHTTP2
library and target is inferred from Sources/NIOHTTP2
existing without a main.swift
, etc. The only dependencies
this changes is NIOTLS
becomes a direct dependency of NIOHTTP2Server
(not a transitive one) and NIOHTTP2Tests
, and CNIONghttp2
depends on the two external dependencies too.
Similarly, swift-nio-ssl could likely become:
let package = Package(
name: "swift-nio-ssl",
dependencies: [
.package(url: "https://github.com/apple/swift-nio.git", from: "1.0.0"),
.package(url: "https://github.com/apple/swift-nio-ssl-support.git", from: "1.0.0"),
]
)
On the other hand, the current set up of swift-nio wouldn't completely disappear, but it would shrink dramatically (the target array would have 6 elements, instead of 25). One could still remove all the executable
products and their targets, the tests ones, as well as the dependency-less importable ones, however there's dependencies between the library targets/products that mean they'd have to be explicitly specified:
import PackageDescription
let package = Package(
name: "swift-nio",
products: [
.library(name: "NIO", targets: ["NIO"]),
.library(name: "NIOTLS", targets: ["NIOTLS"]),
.library(name: "NIOHTTP1", targets: ["NIOHTTP1"]),
.library(name: "NIOConcurrencyHelpers", targets: ["NIOConcurrencyHelpers"]),
.library(name: "NIOFoundationCompat", targets: ["NIOFoundationCompat"]),
.library(name: "NIOWebSocket", targets: ["NIOWebSocket"]),
],
dependencies: [
.package(url: "https://github.com/apple/swift-nio-zlib-support.git", from: "1.0.0"),
],
targets: [
.target(name: "NIO",
dependencies: ["CNIOLinux",
"CNIODarwin",
"NIOConcurrencyHelpers",
"CNIOAtomics",
"NIOPriorityQueue",
"CNIOSHA1"]),
.target(name: "NIOFoundationCompat", dependencies: ["NIO"]),
.target(name: "NIOConcurrencyHelpers",
dependencies: ["CNIOAtomics"]),
.target(name: "NIOHTTP1",
dependencies: ["NIO", "NIOConcurrencyHelpers", "CNIOHTTPParser", "CNIOZlib"]),
.target(name: "NIOTLS", dependencies: ["NIO"]),
.target(name: "NIOWebSocket",
dependencies: ["NIO", "NIOHTTP1", "CNIOSHA1"]),
]
)
(Benefit: the targets array is now small enough that it's more reasonable to have it directly in the package, rather than using a (slightly) non-declarative separate var
.)
Arguably, removing some but not all of the importable targets like this it hard to understand, because some of the importable targets in this package are only deduced from the filesystem, which differs to cargo: due to the one-library-per-package rule, any "module" dependencies would appear as explicit external dependencies in the [dependencies]
section of the manifest. The conventions I proposed above could easily instead require that dependencies
within explicit .target
s can only refer to non-inferred targets, and similarly for targets
within an explicit product
. With this rule, the above would have to also write .target(name: "CNIOLinux")
and .target(name: "CNIODarwin")
etc.
An alternative that avoids the confusion above, and satisfies that stricter rule, would be to move things into other packages, e.g. all the C*
packages and lower-level NIOConcurrencyHelpers
and NIOPriorityQueue
:
import PackageDescription
let package = Package(
name: "swift-nio",
products: [
.library(name: "NIO", targets: ["NIO"]),
.library(name: "NIOTLS", targets: ["NIOTLS"]),
.library(name: "NIOHTTP1", targets: ["NIOHTTP1"]),
.library(name: "NIOFoundationCompat", targets: ["NIOFoundationCompat"]),
.library(name: "NIOWebSocket", targets: ["NIOWebSocket"]),
],
dependencies: [
.package(url: "https://github.com/apple/swift-nio-zlib-support.git", from: "1.0.0"),
// hypothetical URLs:
.package(url: "https://github.com/apple/swift-nio-c-support-libraries.git", from: "1.0.0"),
.package(url: "https://github.com/apple/swift-nio-concurrency-helpers.git", from: "1.0.0"),
.package(url: "https://github.com/apple/swift-nio-priority-queue", from: "1.0.0"),
],
targets: [
.target(name: "NIO"),
.target(name: "NIOFoundationCompat", dependencies: ["NIO"]),
.target(name: "NIOHTTP1",
dependencies: ["NIO", "NIOConcurrencyHelpers", "CNIOHTTPParser", "CNIOZlib"]),
.target(name: "NIOTLS", dependencies: ["NIO"]),
.target(name: "NIOWebSocket",
dependencies: ["NIO", "NIOHTTP1", "CNIOSHA1"]),
]
)
Thoughts?
(To be clear, while I work on Swift, I do not work on the package manager. I'm purely speaking from my previous experience using Cargo here.)