Background and motivation
Swift Package Manager (here after SwiftPM) was released when Swift was made open source in 2016. SwiftPM uses a file named Package.swift
with which users describe the package's source structure, the build artifacts such as any executables or libraries the build produces, and any dependencies on other packages.
SwiftPM’s manifest is a Swift program (a script of sorts) which SwiftPM builds and executes as a separate process in a security sandbox to generate a static data model representing the desired package configuration. Currently, the static representation is based on JSON and the exact format of that JSON is an internal implementation detail. The JSON model is later deserialized and loaded into the parent process memory space, driving SwiftPM’s workflows such as the dependency resolution, build, test , etc.
SwiftPM’s manifest is a simple Swift script, with several requirements and constraints::
- The manifest must import the
PackageDescription
module to gain access to the relevant APIs required to construct the package model. - The manifest must construct a
package
instance of typePackage
which is defined by thePackageDescription
module. - The manifest has access to Swift’s standard library, Dispatch and Foundation but cannot use any other packages or libraries.
- The security sandbox limits access to network and file system.
Consider the following example of a manifest file:
import PackageDescription
let package = Package(
name: "MyPackage",
products: [
.executable(
name: "MyExecutable",
target: ["MyExecutable"]
),
.library(
name: "MyLibrary",
targets: ["MyLibrary", "MyDataModel"]
),
],
dependencies: [
.package(url: "https://git-service.com/foo/some-package", from: "1.0.0"),
.package(url: "https://git-service.com/foo/some-other-package", from: "1.0.0"),
],
targets: [
.executableTarget(
name: "MyExecutable",
dependencies: [
"MyDataModel",
"MyUtilities",
.product(name: "SomeModule", package: "some-package")
]
),
.target(
name: "MyLibrary",
dependencies: [
"MyDataModel",
.product(name: "SomeOtherModule", package: "some-other-package")
]
),
.target(
name: "MyDataModel",
dependencies: []
),
.target(
name: "MyUtilities",
dependencies: []
),
.target(
name: "MyTestUtilities",
dependencies: []
),
testTarget(
name: "MyExecutableTests",
dependencies: ["MyExecutable", "MyTestUtilities"]
),
testTarget(
name: "MyLibraryTests",
dependencies: ["MyLibrary"]
)
]
)
Breaking down the manifest example above shed lights on several key issues:
- The manifest defines 2 publicly available build artifacts, an executable named
MyExecutable
and a library namedMyLibrary
. To define these artifacts, the manifest requires definition of two abstractions: aTarget
and aProduct
, having theTarget
representing a source module and theProduct
representing a public build artifact. Worth noting that in many cases, eachProduct
points to exactly oneTarget
. - The manifest also defines 3 internal targets (modules) that are not exposed outside the package:
MyDataModel,
MyUtilities
andMyTestUtilities
. TheMyLibrary
product (public build artifact) exports both theMyLibrary
andMyDataModel
targets (modules). - Finally the manifest defines 2 test targets that are not exposed outside the package:
MyExecutableTests
andMyLibraryTests
, designed to test theMyExecutable
andMyLibrary
targets respectively. TheMyExecutableTests
target also depends on theMyTestUtilities
target (an internal module). - The manifest defines dependencies on two external packages:
https://git-service.com/foo/some-package
andhttps://git-service.com/foo/some-other-package
.some-package
is used by theMyExecutable
target, whilesome-other-package
is used by theMyLibrary
target. - The manifest is a “giant initializer expression" for constructing a
Package
instance. The impact of this design is that it is very hard to get the manifest right when typing it from scratch as mistakes are syntax errors that can be hard to decipher. For the same reason, auto-complete tools struggle with such expression and IDEs offer little help to that end. In practice, the most effective way to write a manifest is to copy/paste a working one and then change it, which is indicative of the usability problem. - Worth mentioning that the
Package
instance can be constructed empty, and then built up line by line imperatively which helps mitigate the usability problem mentioned above. That said, the “giant initializer” is the more common form of the manifest, arguably because it looks to be declarative and as such easier to read at a glance. - The “giant initializer” design also prevents the use of conditionals such as
#if
which can be useful in some cases, for example multi-platform support (some caveats apply about this usefulness). - The “giant initializer” API makes the manifest fairly verbose.
Proposed solution
At the time the manifest was designed Swift’s Result Builders were not part of the language, leading to the “giant initializer” design mentioned above. With Result Builders in place, we can potentially use this technique to solve some of the problems mentioned above.
Consider the following example of a manifest file, replicating the example above with a Result Builders based API:
Alternative 1, script based:
import PackageManifest
let package = Package()
.modules {
Executable("MyExecutable", public: true)
.include {
Internal("MyDataModel")
Internal("MyUtilities")
External("SomeModule", from: "some-package")
}
Library("MyLibrary", public: true)
.include {
Internal("MyDataModel", public: true)
External("SomeOtherModule", from: "some-other-package")
}
Library("MyDataModel")
Library("MyUtilities")
Library("MyTestUtilities")
Test("MyExecutableTests", for: "MyExecutable")
.include("MyTestUtilities")
Test("MyLibraryTests", for: "MyLibrary")
}
.dependencies {
SourceControl(at: "https://git-service.com/foo/some-package", upToNextMajor: "1.0.0")
SourceControl(at: "https://git-service.com/foo/some-other-package", upToNextMajor: "1.0.0")
}
Breaking down the manifest example above shed lights on several changes:
- The Package instance is constructed gradually using Result Builders, making it significantly easier to type a manifest from scratch and allowing IDEs to produce useful auto-complete hints.
- Target and module are collapsed into a single “module” entity. Modules that need to be exposed publicly (as build artifacts) use a “public” attribute. Dependencies between modules (internal or external) is expressed as “include”.
- Test module relationship with the module it is testing is expressed as “for”, and any additional dependencies is expressed as “include”.
- Use of
#if
conditional is possible. - The manifest is more concise.
Using @main
A slight variation on this could be to define a Package
protocol similar to SwiftUI’s App
protocol and use a @main
entry-point to define the package:
import PackageManifest
@main
struct MyPackage: Package {
func init(context: Context) {
self.modules = some Modules {
... (same as above)
}
self.dependencies = some Dependencies {
... (same as above)
}
}
}
public protocol Package {
@ModulesBuilder var modules: Modules { get }
@DependenciesBuilder var dependencies: Dependencies { get }
init(context: Context)
}
Additional modifiers
The above example focused on key constructs of package: dependencies and modules. Real-world modules tend to be more complex than the example above and include large amount of settings that are applicable to specific module types. Using Result Builders based API allows us to define module specific type modifiers, for example:
let package = Package()
.modules {
Executable("MyProgram", public: true)
.customPath("custom/path")
.swiftSettings("swiftSettings")
.include {
Internal("CSQLite3")
}
System("CSQLite3")
.providers {
Apt("libsqlite3-dev")
Yum("sqlite-devel")
Brew("sqlite3")
}
}
This example demonstrates the use of Result Builders modifiers to apply settings. The modifiers are module type specific, for example an Executable
module has different set of modifiers (e.g. customPath
, swiftSettings
) than a System
module (e.g. providers
). The full list of modifiers matches the existing capabilities in SwiftPM’s current manifest configuration options.
Impact on existing packages
The new format would be supported side-by-side to the existing one for a long transition period. Users would be encouraged (with warnings) to use the new format when updating a manifest to a tools-version greater than the one in which the new format was introduced. New features will be added exclusively to the new format.
Open questions
- Should we use the
@main
or the script-like variant?@main
is more structures and allows for passing context but more verbose / complicated? - If choosing the script variant over the
@main
one, could we get rid of the need tolet package = ...
in fully declarative use cases? It is needed for the imperative use case but feels unnecessary in the fully declarative version. - Is “modules” a good term to replace targets and products? Alternatively we can group them by type, e.g. “libraries”, "executables", "tests", "plugins", etc
- Is “include” a good term for expressing module <> module relationship?
- Is test module relationship with the module it is testing expressed as “for” and additional dependencies as “include” good terminology? should they be collapsed into one "include" section instead (more similar to the current manliest)?
- Is “public: true” on Internal dependency the right way to express the dependency is to be exposed publicly? (this replaces product → target relationship for multi-target products)
- Should we continue to use a
tools-version
comment at the top of the file? Is there a better technique or name we should consider to express the manifest's schema/api version? For example, wouldschemaVersion
orschemaAPIVersion
be a more accurate name?
Reference implementation
Diff view: Comparing apple:main...tomerd:feature/manifest2-poc · apple/swift-package-manager · GitHub
Future directions
Orthogonal to this proposal, an interesting usability improvement could be to remove the need to explicitly declare dependencies on targets. Target dependencies are used to compute the minimal dependency closure and avoid pulling non-required dependencies (see SE-0226). Declaring them explicitly as done today makes the computation easier, but puts the burden on the user, and leads to a verbose and non-esthetic manifest syntax. Since SwiftPM has access to the package and dependencies sources it could potentially deduce this information from the code itself by parsing the import statements.
Alternative considered
The most obvious alternative is to use a static declarative format such as JSON, YAML or TOML to describe packages. The main advantage of using static declarative format is that it is faster and safer to load - Swift based manifest must be compiled and run to extract the underlying information, while a static file can be parsed which is faster and safer. On the flip side, a sufficiently advanced package needs to go beyond what is possible in a static deceleration and often tap into the programatic capabilities offered by the manifest API. This is true not only in the Swift ecosystem but also in other ecosystems, where some ecosystems use a fully static declarative format with escape hatches for things like running scripts, while others use a programatic API / DSL similar to SwiftPM’s manifest API, for example:
- CocoaPods uses a programatic script based on ruby (CocoaPods Guides - Podspec Syntax Reference <span>v1.11.2</span>)
- Ruby’s bundle uses a programatic script based on ruby (https://bundler.io/gemfile.html)
- Node.js npm uses a static declarative format based on JSON with programmatic escape hatches in the form of arbitrary scripts (scripts | npm Docs)
- Rust’s cargo uses a static declarative format based on TOML with programmatic escape hatches in the form of arbitrary build script (Build Scripts - The Cargo Book)
- Java’s Maven uses a static declarative format based on XML composing a collection of programatic plugins (Maven – Available Plugins)
- Java’s Gradle uses a DSL based on Groovy or Kotlin (Gradle DSL Version 7.5.1)
From a broad security and performance perspective, escape hatches such as custom scripts and plugins are equivalent to a manifest based on a programatic API or DSL, since the system need to compile and run code in either case. As such, we do not feel that at this time transitioning to a static declarative format with escape hatches would give a significant benefit.
One could argue "why not both?": Static declarative format for simple packages and programatic API for advanced ones. The benefit of such approach is that at least some packages can benefit from better performance and simplified security profile, and the implications are that users would need to learn 2 manifest formats (and languages) and a maintenance burden that can lead to asymmetry in features and quality.