[Pitch] Disjoint Package Dependency Version Ranges

[Pitch] Disjoint Package Dependency Version Ranges

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.
    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.

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>: 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.

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

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.

4 Likes

Interesting idea. I do think you need to take the Detail Design a bit further into how this affects the version resolution in PubGrub. What happens when two dependencies cause a conflict, for example.

So far as I can figure, this change doesn't change any of the input types to PubGrub. PubGrub is already operating on the VersionSetSpecifier type which already has case support for lists of ranges.

I'll add a section to detailed design about this that at least calls out what the error case looks like.