[Pitch] Union of Version Ranges for Package Dependencies

[Pitch] Union of version ranges for package dependencies

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.
    Suppose 2.0.0 ..< 2.1.0 of a dependency shipped a regression that breaks your package, but 1.x and everything from 2.1.0 onward are fine. Today you cannot say "any 1.1.0 ..< 2.0.0 or any 2.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. a 1.x line and a 3.x line) while a 2.x line is intentionally unsupported.
  • Combining a broad range with specific point versions.
    "Any 1.1.0 up to 2.0.0, plus exactly 3.3.0 and 5.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.SourceControlRequirement and RegistryRequirement (PackageDescription) gain case 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>: ExpressibleByStringLiteral conformance. This is convenient but "spooky" — a bare "1.0.0" becomes a range anywhere a Range<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.Dependency model 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.