[Pitch] Union of version ranges for package dependencies
- Authors: Jairon Terrero
- Status: Pitch
- Implementation: Add union-of-versions package dependency requirement by CrownedPhoenix · Pull Request #10339 · swiftlang/swift-package-manager · GitHub
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.
Introduction
A package dependency's allowable versions can currently only be described as a single contiguous requirement — one Range<Version>, one exact version, or a convenience such as from: / .upToNextMajor(from:). 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",
"1.1.0"..<"2.0.0", "2.1.0"..<"3.0.0", "3.3.0", "5.1.3"
)
Motivation
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. - Combining a broad range with specific point versions.
"Any1.1.0up to2.0.0, plus exactly3.3.0and5.1.3."
The dependency resolver already models these situations.
Internally, VersionSetSpecifier has a .ranges([Range<Version>]) case with full union / intersection / difference algebra. VersionSetSpecifier.union(from:) normalizes a list of ranges (merging overlaps and collapsing single-version ranges to an exact requirement).
As such, this capability is present end to end except that the manifest API and the requirement model between the manifest and the resolver only carry a single Range<Version>.
This proposal closes that gap.
/// An abstract definition for a set of versions.
public enum VersionSetSpecifier: Hashable {
/// The universal set.
case any
/// The empty set.
case empty
/// A non-empty range of version.
case range(Range<Version>)
/// The exact version that is required.
case exact(Version)
/// A range of disjoint versions (sorted).
case ranges([Range<Version>]) // <--------- This capability already exists
}
Proposed solution
New Package.Dependency factory methods accept a union of version ranges and exact versions. The reference implementation provides four candidate API shapes so they can be evaluated side by side. Exactly one (or a refinement) will be retained in the final proposal.
Shape 1 — 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).
Shape 2 — labeled ranges:
.package(url: "https://example.com/example-package.git",
ranges: "1.1.0"..<"2.0.0", "2.1.0"..<"3.0.0")
An exact version is written as a single-version range, e.g. "3.3.0"..<"3.3.1".
Shape 3 — explicit versions: constraints
.package(url: "https://example.com/example-package.git",
versions: .range("1.1.0"..<"2.0.0"), .exact("3.3.0"))
Each element is an explicit Package.Dependency.VersionConstraint case (.range(_:) or .exact(_:)).
Shape 4 — array of ranges
.package(url: "https://example.com/example-package.git",
ranges: ["1.1.0"..<"2.0.0", "2.1.0"..<"3.0.0"])
The same as Shape 2 but taking a plain [Range<Version>] array instead of a variadic (an exact version is a single-version range). This is the lowest-footprint spelling: it reuses Range<Version> as-is, adds no new type and no conformance, and — being a single array argument — has no interaction with the existing single-range and convenience overloads.
All four shapes are provided for both source-control and registry dependencies (.package(id:...)).
Detailed design
Requirement representation
A new requirement case is threaded from the manifest API to the resolver:
Package.Dependency.SourceControlRequirementandRegistryRequirement(PackageDescription) gaincase ranges([Range<Version>]):
// 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)
}
- The corresponding PackageModel model enums (
PackageDependency.SourceControl.Requirement,PackageDependency.Registry.Requirement) gain the same case:
// 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>])
}
toConstraintRequirement()maps.ranges(rs)to.versionSet(.union(from: rs)), reusing the existing normalization (no resolver change is required):
// 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
This new feature represents a range union as a list of ranges: [Range<version>]. This requires a decision on how to represent range unions that include exact versions.
For example:
{
1.1.0 ..< 2.0.0,
2.1.3, <-- Exact version intermingled with ranges
3.0.0 ..< 4.0.0
}
The reference implementation represents an exact version v as the range v ..< v.nextPatch.
VersionSetSpecifier.union(from:) recognizes a single-version range and collapses it back to .exact(v) during resolution, so exact requirements retain exact semantics. This keeps the wire format and model to a single new .ranges case rather than a mix of range and exact elements.
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
Serialization and round-tripping
The manifest → build-system boundary (the internal Serialization mirror) and ManifestJSONParser gain a matching ranges case. swift package dump-package emits the union, and manifest source generation regenerates it, so union dependencies round-trip.
Availability / tools-version gating
The new API is gated behind a future swift-tools-version (the reference implementation uses the development 999.0, matching how the existing RegistryRequirement is staged). Manifests using an older tools version are unaffected and cannot reference the new API.
I'm not sure if this is preferred.
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
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.
The ongoing public-API commitment differs by chosen shape (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):
- Shared by all shapes: the new
.ranges([Range<Version>])case on the two PackageDescription requirement enums and the two PackageModel requirement enums, plus new factory methods. - Shapes 2 and 4 add nothing beyond that shared floor — no new public type and no new conformance.
- Shape 1 additionally 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. - Shape 3 additionally commits to a new public type,
Package.Dependency.VersionConstraint.
Ranked from smallest to largest additional public commitment: Shape 4 ≈ Shape 2 < Shape 1 < Shape 3.
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
The four shapes in Proposed solution are alternatives to one another; the final proposal would keep one (or a refinement). The choice trades call-site ergonomics against public-API surface — see the ranking in Impact on existing packages.
Representing exact ranges in range unions
This is an implementation choice, not a call-site one: whichever API shape is chosen, an exact version needs a representation inside the .ranges requirement.
The reference implementation represents an exact version v as the single-version range v ..< v.nextPatch, so the requirement is always a uniform list of ranges. During resolution VersionSetSpecifier.union(from:) recognizes such a range and collapses it back to an exact requirement, so resolution behaves identically to a first-class exact.
The alternative is a mixed list, where each element is explicitly either a range or an exact version, carried as-is through serialization and the model. Its only advantage is introspection fidelity — for example swift package dump-package would print exact 3.3.0 instead of the range 3.3.0 ..< 3.3.1. The cost is a larger, non-uniform wire format and model for no difference in resolution behavior, so the uniform-list representation was chosen.
The uniform representation has one known edge case: an exact pre-release version placed in a multi-element union (its range would be, e.g., 1.2.3-beta ..< 1.2.4) is slightly over-inclusive, because that range also admits the 1.2.3 release. Exact release versions, and exact pre-releases used on their own, are unaffected.
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.