[Pitch] Disjoint Package Dependency Version Ranges
- Authors: Jairon Terrero
- Status: Pitch
- Implementation: Add union-of-versions package dependency requirement by CrownedPhoenix · Pull Request #10339 · swiftlang/swift-package-manager · GitHub
Introduction
This proposal adds the ability to describe the allowable versions as the union of several version ranges and/or exact versions.
.package(
url: "https://example.com/example-package.git",
versions: "1.1.0"..<"2.0.0", "2.1.0"..<"3.0.0", "3.3.0", "5.1.3"
)
Motivation
Enabling specification of disjoint ranges is a major tool for library developers wanting to maintain compatibility with as many versions as possible without inadvertently falsely advertising support for broken ranges.
Real dependency graphs sometimes need to accept versions that do not form a single contiguous range:
- Excluding a known-bad window.
Suppose2.0.0 ..< 2.1.0of a dependency shipped a regression that breaks your package, but1.xand everything from2.1.0onward are fine. Today you cannot say "any1.1.0 ..< 2.0.0or any2.1.0 ..< 3.0.0" in a single dependency declaration — you must either pin more narrowly than you'd like or accept the broken window. - Supporting multiple compatible major lines.
A package may remain compatible with two separate major versions of a dependency (e.g. a1.xline and a3.xline) while a2.xline is intentionally unsupported.
Importantly: The dependency resolver internal to SPM already models these situations, the public package dependency API simply doesn't expose this functionality.
Proposed Solution
As described in the introduction - a new Package.Dependency factory methods accept a union of version ranges and exact versions.
//Sources/Runtimes/PackageDescription/PackageDependency.swift
@available(_PackageDescription, introduced: 999.0)
public static func package(
url: String,
versions: Range<Version>...,
traits: Set<Trait> = [.defaults]
) -> Package.Dependency {
...
}
Detailed Design
A new requirement case is threaded from the manifest API to the resolver:
Sources/Runtimes/PackageDescription/PackageRequirement.swift
// Sources/Runtimes/PackageDescription/PackageRequirement.swift (RegistryRequirement mirrors this)
public enum SourceControlRequirement {
/// An exact version based requirement.
case exact(Version)
/// A requirement based on a range of versions.
case range(Range<Version>)
+ /// A requirement based on the union of a list of version ranges.
+ case ranges([Range<Version>])
/// A commit based requirement.
case revision(String)
/// A branch based requirement.
case branch(String)
}
Sources/PackageModel/Manifest/PackageDependencyDescription.swift
// Sources/PackageModel/Manifest/PackageDependencyDescription.swift (Registry.Requirement mirrors this)
public enum RegistryRequirement {
/// A requirement based on an exact version.
case exact(Version)
/// A requirement based on a range of versions.
case range(Range<Version>)
+ /// A requirement based on the union of a list of version ranges.
+ case ranges([Range<Version>])
}
Sources/PackageGraph/PackageGraphRoot.swift
// Sources/PackageGraph/PackageGraphRoot.swift
extension PackageDependency.SourceControl.Requirement {
/// Returns the constraint requirement representation.
public func toConstraintRequirement() throws -> PackageRequirement {
switch self {
case .range(let range):
return .versionSet(.range(range))
+ case .ranges(let ranges):
+ return .versionSet(.union(from: ranges))
case .revision(let identifier):
return .revision(identifier)
case .branch(let name):
return .revision(name)
case .exact(let version):
return .versionSet(.exact(version))
}
}
}
Representing exact ranges in range unions
Version range sets will sometimes have exact tags intermixed with ranges (like 3.3.0 and 5.1.3 in the example below).
.package(
url: "https://example.com/example-package.git",
versions: "1.1.0"..<"2.0.0", "3.3.0", "5.1.3", "6.0.0" ..< "7.0.0"
)
A bare version literal (i.e. "5.1.3") is enabled by a retroactive Range<Version>: ExpressibleByStringLiteral conformance.
Within the existing SPM dependency resolver, VersionSetSpecifier already has a mapping from ranges like "3.0.0" ..< "3.0.1" to an exact "3.0.0".
Sources/PackageGraph/VersionSetSpecifier.swift
extension VersionSetSpecifier {
public static func union(from range: Swift.Range<Version>) -> VersionSetSpecifier {
return .union(from: [range])
}
public static func union(from ranges: [Swift.Range<Version>]) -> VersionSetSpecifier {
switch ranges.count {
case 0:
return .empty
case 1:
let range = ranges[0]
// FIXME: Can we avoid this? testConflict1 goes into a loop if we don't do this.
if range.lowerBound.nextPatch() == range.upperBound {
return .exact(range.lowerBound) // <-- Collapsed
}
return .range(range)
...
Aside: It's unclear to me how the outstanding FIXME complicates this decision
Resolution behavior (PubGrub)
A union requirement is not special-cased in the resolver. PubGrub already operates on VersionSetSpecifier "terms", and a union is simply a VersionSetSpecifier whose set is several ranges. No new resolution logic is introduced.
The two examples below were run against the actual PubGrubDependencyResolver. In both, root requires lib as the union {1.0.0..<2.0.0, 3.0.0..<4.0.0} (deliberately excluding 2.x).
Compatible — union intersected with a transitive range.
root also depends on other, and other depends on lib 1.5.0..<3.5.0. PubGrub intersects the two lib requirements to {1.5.0..<2.0.0, 3.0.0..<3.5.0} and selects the highest available version:
resolved: lib @ 3.4.0
resolved: other @ 1.0.0
Conflict — the union excludes a version a transitive dependency requires.
Now other depends on lib 2.0.0..<3.0.0. The intersection is empty, so resolution fails, and the union renders legibly in the diagnostic:
Dependencies could not be resolved because root depends on 'lib' {1.0.0..<2.0.0, 3.0.0..<4.0.0} and root depends on 'other' 1.0.0..<2.0.0.
'other' >= 1.0.0 practically depends on 'lib' 2.0.0..<3.0.0 because no versions of 'other' match the requirement 1.0.1..<2.0.0 and 'other' 1.0.0 depends on 'lib' 2.0.0..<3.0.0.
The set is printed as {1.0.0..<2.0.0, 3.0.0..<4.0.0} by VersionSetSpecifier's existing description, so messages stay readable as unions grow.
Security
This change has no impact on security, safety, or privacy. It only broadens how an already-supported concept — the set of acceptable versions for a dependency — is expressed in the manifest; version selection and fetching are unchanged.
Impact on existing packages
From the perspective of a Package.swift file, this change is purely additive and gated on the tools version:
- No existing API is removed or changed; the new factory methods are new overloads.
- The single-range and convenience overloads continue to win overload resolution for existing call sites (a scalar overload is preferred over a variadic one).
- The feature is unavailable below the introducing
swift-tools-version, so existing manifests are entirely unaffected and older toolchains cannot reference the new API.
Besides this, the PackageDescription manifest API is a long-lived public interface even though SwiftPM's libraries are not ABI-stable the way the standard library is:
- The new
.ranges([Range<Version>])case on the two PackageDescription requirement enums and the two PackageModel requirement enums, plus new factory methods. - Commits to a retroactive
Range<Version>: ExpressibleByStringLiteralconformance. This is convenient but "spooky" — a bare"1.0.0"becomes a range anywhere aRange<Version>is expected inside a manifest — and retroactive conformances on standard library types are generally discouraged.
Future directions
- Exclusion syntax. Many union use cases are really "everything in this range except a bad window." A dedicated spelling (e.g. an
excluding:parameter) could express that more directly and lower to the same underlying union/difference algebra. - Richer introspection. Surfacing the union faithfully through the public
Package.Dependencymodel for tools that read manifests.
Alternatives considered
API spelling
Mixed variadic (bare versions and ranges together)
.package(url: "https://example.com/example-package.git",
"1.1.0"..<"2.0.0", "2.1.0"..<"3.0.0", "3.3.0", "5.1.3")
A bare version literal ("3.3.0") denotes an exact version and can be mixed freely with ranges. This is enabled by a retroactive Range<Version>: ExpressibleByStringLiteral conformance (see Detailed design).
Labeled `ranges:`
.package(url: "https://example.com/example-package.git",
ranges: "1.1.0"..<"2.0.0", "2.1.0"..<"3.0.0")
Explicit `versions:` constraints
.package(url: "https://example.com/example-package.git",
versions: .range("1.1.0"..<"2.0.0"), .exact("3.3.0"))
Array of ranges
.package(url: "https://example.com/example-package.git",
ranges: ["1.1.0"..<"2.0.0", "2.1.0"..<"3.0.0"])
Scope: source-control only
Supporting only url: dependencies would roughly halve the added surface, but leaves registry dependencies asymmetric. This proposal supports both.
Do nothing
Users can sometimes work around the lack of unions by narrowing to a single range, but that is strictly less expressive and cannot exclude an interior range.
Disclosure: This pitch was scaffolded by AI and then reviewed and edited by me for clarity. I've read through it several times to make sure it's consumable.