diff --git a/Sources/Basics/Version+Extensions.swift b/Sources/Basics/Version+Extensions.swift index ed03330ba56..421bf05c458 100644 --- a/Sources/Basics/Version+Extensions.swift +++ b/Sources/Basics/Version+Extensions.swift @@ -23,4 +23,12 @@ extension Version { try? self.init(versionString: tag, usesLenientParsing: true) } } + + package func literalEqual(to other: Version) -> Bool { + self.major == other.major && + self.minor == other.minor && + self.patch == other.patch && + self.prereleaseIdentifiers == other.prereleaseIdentifiers && + self.buildMetadataIdentifiers == other.buildMetadataIdentifiers + } } diff --git a/Sources/PackageGraph/PackageGraphRoot.swift b/Sources/PackageGraph/PackageGraphRoot.swift index 3f2c9ac5cb3..28b00310077 100644 --- a/Sources/PackageGraph/PackageGraphRoot.swift +++ b/Sources/PackageGraph/PackageGraphRoot.swift @@ -210,6 +210,8 @@ extension PackageDependency.SourceControl.Requirement { return .revision(name) case .exact(let version): return .versionSet(.exact(version)) + case .exactLiteral(let version): + return .versionSet(.exactLiteral(version)) } } } @@ -222,7 +224,8 @@ extension PackageDependency.Registry.Requirement { return .versionSet(.range(range)) case .exact(let version): return .versionSet(.exact(version)) + case .exactLiteral(let version): + return .versionSet(.exactLiteral(version)) } } } - diff --git a/Sources/PackageGraph/Resolution/PubGrub/DiagnosticReportBuilder.swift b/Sources/PackageGraph/Resolution/PubGrub/DiagnosticReportBuilder.swift index c1b6275dcee..7b87cb5efa1 100644 --- a/Sources/PackageGraph/Resolution/PubGrub/DiagnosticReportBuilder.swift +++ b/Sources/PackageGraph/Resolution/PubGrub/DiagnosticReportBuilder.swift @@ -275,7 +275,7 @@ struct DiagnosticReportBuilder { switch term.requirement { case .any: return true - case .empty, .exact, .ranges: + case .empty, .exact, .exactLiteral, .ranges: return false case .range(let range): // container expected to be cached at this point @@ -321,6 +321,11 @@ struct DiagnosticReportBuilder { return "root" } return "\(name) \(version)" + case .exactLiteral(let version): + if term.node == self.rootNode { + return "root" + } + return "\(name) \(version)" case .range(let range): // container expected to be cached at this point guard normalizeRange, let container = try? provider.getCachedContainer(for: term.node.package) else { diff --git a/Sources/PackageGraph/Resolution/PubGrub/PartialSolution.swift b/Sources/PackageGraph/Resolution/PubGrub/PartialSolution.swift index 8785d77bb3a..99d29385387 100644 --- a/Sources/PackageGraph/Resolution/PubGrub/PartialSolution.swift +++ b/Sources/PackageGraph/Resolution/PubGrub/PartialSolution.swift @@ -62,9 +62,18 @@ public struct PartialSolution { /// Create a new decision assignment and add it to the partial solution's /// list of known assignments. - public mutating func decide(_ node: DependencyResolutionNode, at version: Version) { + public mutating func decide( + _ node: DependencyResolutionNode, + at version: Version, + requirement: VersionSetSpecifier? = nil + ) { self.decisions[node] = version - let term = Term(node, .exact(version)) + let decisionRequirement = requirement ?? .exact(version) + precondition( + decisionRequirement.isExact, + "Cannot create a decision assignment with a non-exact version selection: \(decisionRequirement)" + ) + let term = Term(node, decisionRequirement) let decision = Assignment.decision(term, decisionLevel: self.decisionLevel) self.assignments.append(decision) self.register(decision) diff --git a/Sources/PackageGraph/Resolution/PubGrub/PubGrubDependencyResolver.swift b/Sources/PackageGraph/Resolution/PubGrub/PubGrubDependencyResolver.swift index 762795b3a32..28d9521f694 100644 --- a/Sources/PackageGraph/Resolution/PubGrub/PubGrubDependencyResolver.swift +++ b/Sources/PackageGraph/Resolution/PubGrub/PubGrubDependencyResolver.swift @@ -80,11 +80,16 @@ public struct PubGrubDependencyResolver { } } - func decide(_ node: DependencyResolutionNode, at version: Version) { - let term = Term(node, .exact(version)) + func decide( + _ node: DependencyResolutionNode, + at version: Version, + requirement: VersionSetSpecifier? = nil + ) { + let decisionRequirement = requirement ?? .exact(version) + let term = Term(node, decisionRequirement) self.lock.withLock { assert(term.isValidDecision(for: self.solution)) - self.solution.decide(node, at: version) + self.solution.decide(node, at: version, requirement: decisionRequirement) } } @@ -250,6 +255,8 @@ public struct PubGrubDependencyResolver { switch assignment.term.requirement { case .exact(let version): boundVersion = .version(version) + case .exactLiteral(let version): + boundVersion = .version(version) case .range, .any, .empty, .ranges: throw InternalError("unexpected requirement value for assignment \(assignment.term)") } @@ -764,7 +771,13 @@ public struct PubGrubDependencyResolver { // Decide this version if there was no conflict with its dependencies. if !haveConflict { self.delegate?.didResolve(term: pkgTerm, version: version, duration: start.distance(to: .now())) - state.decide(pkgTerm.node, at: version) + let decisionRequirement: VersionSetSpecifier = switch pkgTerm.requirement { + case .exactLiteral: + .exactLiteral(version) + case .exact, .range, .ranges, .any, .empty: + .exact(version) + } + state.decide(pkgTerm.node, at: version, requirement: decisionRequirement) } return pkgTerm.node diff --git a/Sources/PackageGraph/Resolution/PubGrub/PubGrubPackageContainer.swift b/Sources/PackageGraph/Resolution/PubGrub/PubGrubPackageContainer.swift index f82fa5d6d81..ee5f3d139c1 100644 --- a/Sources/PackageGraph/Resolution/PubGrub/PubGrubPackageContainer.swift +++ b/Sources/PackageGraph/Resolution/PubGrub/PubGrubPackageContainer.swift @@ -84,10 +84,14 @@ final class PubGrubPackageContainer { if let pinnedVersion = self.pinnedVersion { if versionSet.contains(pinnedVersion) { if !self.underlying.shouldInvalidatePinnedVersions { - versionSet = .exact(pinnedVersion) + versionSet = .exactLiteral(pinnedVersion) } else { // Make sure the pinned version is still available - let version = try await self.underlying.versionsDescending().first { pinnedVersion == $0 } + let version = try await self.underlying.versionsDescending().first { + pinnedVersion.buildMetadataIdentifiers.isEmpty + ? pinnedVersion == $0 + : pinnedVersion.description == $0.description + } if version != nil { return version } diff --git a/Sources/PackageGraph/VersionSetSpecifier.swift b/Sources/PackageGraph/VersionSetSpecifier.swift index 6a20f9c81f3..87ffc2f0032 100644 --- a/Sources/PackageGraph/VersionSetSpecifier.swift +++ b/Sources/PackageGraph/VersionSetSpecifier.swift @@ -10,6 +10,7 @@ // //===----------------------------------------------------------------------===// +import Basics import struct TSCUtility.Version /// An abstract definition for a set of versions. @@ -26,6 +27,9 @@ public enum VersionSetSpecifier: Hashable { /// The exact version that is required. case exact(Version) + /// The exact version that is required, including build metadata. + case exactLiteral(Version) + /// A range of disjoint versions (sorted). case ranges([Range]) } @@ -42,6 +46,8 @@ extension VersionSetSpecifier: Equatable { return lhsRange == rhsRange case (let .exact(lhsExact), let .exact(rhsExact)): return lhsExact == rhsExact + case (let .exactLiteral(lhsExact), .exactLiteral(let rhsExact)): + return lhsExact.literalEqual(to: rhsExact) case (let .ranges(lhsRanges), let .ranges(rhsRanges)): return lhsRanges == rhsRanges @@ -81,15 +87,54 @@ extension VersionSetSpecifier: Equatable { } } +extension VersionSetSpecifier { + public func hash(into hasher: inout Hasher) { + switch self { + case .any: + hasher.combine(0) + case .empty: + hasher.combine(1) + case .range(let range): + hasher.combine(2) + hasher.combine(range.lowerBound) + hasher.combine(range.upperBound) + case .exact(let version): + hasher.combine(3) + hasher.combine(version) + case .exactLiteral(let version): + hasher.combine(4) + hasher.combine(version.major) + hasher.combine(version.minor) + hasher.combine(version.patch) + hasher.combine(version.prereleaseIdentifiers) + hasher.combine(version.buildMetadataIdentifiers) + case .ranges(let ranges): + hasher.combine(5) + hasher.combine(ranges.count) + for range in ranges { + hasher.combine(range.lowerBound) + hasher.combine(range.upperBound) + } + } + } +} + extension VersionSetSpecifier { var isExact: Bool { switch self { case .any, .empty, .range, .ranges: return false - case .exact: + case .exact, .exactLiteral: return true } } + + var isExactLiteral: Bool { + if case .exactLiteral = self { + return true + } + return false + } } extension VersionSetSpecifier { @@ -157,13 +202,38 @@ extension VersionSetSpecifier { return self } return VersionSetSpecifier.union(from: [v1..]() @@ -312,6 +421,14 @@ extension VersionSetSpecifier { } } return .union(from: result) + case (.ranges(let ranges), .exactLiteral(let exact)): + if !VersionSetSpecifier.ranges(ranges).contains(exact) { + return .ranges(ranges) + } + + // As with a single range, subtracting one literal variant from a semantic ranges set + // would require a more precise representation than we currently have. + return .ranges(ranges) case (.exact(let exact), .ranges(let ranges)): for range in ranges { @@ -320,6 +437,13 @@ extension VersionSetSpecifier { } } return self + case (.exactLiteral(let exact), .ranges(let ranges)): + for range in ranges { + if range.contains(version: exact) { + return .empty + } + } + return self case (.range(let lhs), .range(let rhs)): if lhs == rhs { return .empty } @@ -351,6 +475,8 @@ extension VersionSetSpecifier { fatalError("unexpected any result") case .exact(let v): lhs = v.. Bool { + switch self { + case .exactLiteral(let candidate): + candidate.literalEqual(to: version) + default: + self.contains(version) } } } @@ -473,6 +613,8 @@ extension VersionSetSpecifier { false case .exact(let version): version.supportsPrerelease + case .exactLiteral(let version): + version.supportsPrerelease case .range(let range): range.supportsPrereleases case .ranges(let ranges): @@ -494,6 +636,8 @@ extension VersionSetSpecifier { .ranges(ranges.map { $0.withoutPrerelease }) case .exact(let version): .exact(version.withoutPrerelease) + case .exactLiteral(let version): + .exactLiteral(version.withoutPrerelease) } } } @@ -525,6 +669,8 @@ extension VersionSetSpecifier: CustomStringConvertible { return range.lowerBound.description + "..<" + upperBound.description case .exact(let version): return version.description + case .exactLiteral(let version): + return version.description } } } diff --git a/Sources/PackageLoading/ManifestJSONParser.swift b/Sources/PackageLoading/ManifestJSONParser.swift index 07ca0e6eacc..401a8e00338 100644 --- a/Sources/PackageLoading/ManifestJSONParser.swift +++ b/Sources/PackageLoading/ManifestJSONParser.swift @@ -277,6 +277,8 @@ extension PackageDependency.SourceControl.Requirement { switch requirement { case .exact(let version): self = .exact(.init(version)) + case .exactLiteral(let version): + self = .exactLiteral(.init(version)) case .range(let lowerBound, let upperBound): let lower: TSCUtility.Version = .init(lowerBound) let upper: TSCUtility.Version = .init(upperBound) @@ -294,6 +296,8 @@ extension PackageDependency.Registry.Requirement { switch requirement { case .exact(let version): self = .exact(.init(version)) + case .exactLiteral(let version): + self = .exactLiteral(.init(version)) case .range(let lowerBound, let upperBound): let lower: TSCUtility.Version = .init(lowerBound) let upper: TSCUtility.Version = .init(upperBound) diff --git a/Sources/PackageModel/DependencyMapper.swift b/Sources/PackageModel/DependencyMapper.swift index 2d0245e12df..44cc064e232 100644 --- a/Sources/PackageModel/DependencyMapper.swift +++ b/Sources/PackageModel/DependencyMapper.swift @@ -272,6 +272,8 @@ fileprivate extension PackageDependency.Registry.Requirement { throw DependencyMappingError.invalidMapping("mapping of source control (\(location)) to registry (\(identity)) is invalid due to requirement information mismatch: cannot map branch or revision based dependencies to registry.") case .exact(let value): self = .exact(value) + case .exactLiteral(let value): + self = .exactLiteral(value) case .range(let value): self = .range(value) } @@ -283,6 +285,8 @@ fileprivate extension PackageDependency.SourceControl.Requirement { switch requirement { case .exact(let value): self = .exact(value) + case .exactLiteral(let value): + self = .exactLiteral(value) case .range(let value): self = .range(value) } diff --git a/Sources/PackageModel/Manifest/PackageDependencyDescription.swift b/Sources/PackageModel/Manifest/PackageDependencyDescription.swift index 0e75e4c0283..2dc1164cebc 100644 --- a/Sources/PackageModel/Manifest/PackageDependencyDescription.swift +++ b/Sources/PackageModel/Manifest/PackageDependencyDescription.swift @@ -115,6 +115,7 @@ public enum PackageDependency: Equatable, Hashable, Sendable { public enum Requirement: Equatable, Hashable, Sendable { case exact(Version) + case exactLiteral(Version) case range(Range) case revision(String) case branch(String) @@ -135,6 +136,7 @@ public enum PackageDependency: Equatable, Hashable, Sendable { /// The dependency requirement. public enum Requirement: Equatable, Hashable, Sendable { case exact(Version) + case exactLiteral(Version) case range(Range) } } @@ -432,6 +434,8 @@ extension PackageDependency.SourceControl.Requirement: CustomStringConvertible { switch self { case .exact(let version): return version.description + case .exactLiteral(let version): + return "exactLiteral[\(version)]" case .range(let range): return range.description case .revision(let revision): @@ -442,17 +446,103 @@ extension PackageDependency.SourceControl.Requirement: CustomStringConvertible { } } +extension PackageDependency.SourceControl.Requirement { + public static func == ( + lhs: PackageDependency.SourceControl.Requirement, + rhs: PackageDependency.SourceControl.Requirement + ) -> Bool { + switch (lhs, rhs) { + case (.exact(let lhs), .exact(let rhs)): + lhs == rhs + case (.exactLiteral(let lhs), .exactLiteral(let rhs)): + lhs.literalEqual(to: rhs) + case (.range(let lhs), .range(let rhs)): + lhs == rhs + case (.revision(let lhs), .revision(let rhs)): + lhs == rhs + case (.branch(let lhs), .branch(let rhs)): + lhs == rhs + default: + false + } + } + + public func hash(into hasher: inout Hasher) { + switch self { + case .exact(let version): + hasher.combine(0) + hasher.combine(version) + case .exactLiteral(let version): + hasher.combine(1) + hasher.combine(version.major) + hasher.combine(version.minor) + hasher.combine(version.patch) + hasher.combine(version.prereleaseIdentifiers) + hasher.combine(version.buildMetadataIdentifiers) + case .range(let range): + hasher.combine(2) + hasher.combine(range.lowerBound) + hasher.combine(range.upperBound) + case .revision(let revision): + hasher.combine(3) + hasher.combine(revision) + case .branch(let branch): + hasher.combine(4) + hasher.combine(branch) + } + } +} + extension PackageDependency.Registry.Requirement: CustomStringConvertible { public var description: String { switch self { case .exact(let version): return version.description + case .exactLiteral(let version): + return "exactLiteral[\(version)]" case .range(let range): return range.description } } } +extension PackageDependency.Registry.Requirement { + public static func == ( + lhs: PackageDependency.Registry.Requirement, + rhs: PackageDependency.Registry.Requirement + ) -> Bool { + switch (lhs, rhs) { + case (.exact(let lhs), .exact(let rhs)): + lhs == rhs + case (.exactLiteral(let lhs), .exactLiteral(let rhs)): + lhs.literalEqual(to: rhs) + case (.range(let lhs), .range(let rhs)): + lhs == rhs + default: + false + } + } + + public func hash(into hasher: inout Hasher) { + switch self { + case .exact(let version): + hasher.combine(0) + hasher.combine(version) + case .exactLiteral(let version): + hasher.combine(1) + hasher.combine(version.major) + hasher.combine(version.minor) + hasher.combine(version.patch) + hasher.combine(version.prereleaseIdentifiers) + hasher.combine(version.buildMetadataIdentifiers) + case .range(let range): + hasher.combine(2) + hasher.combine(range.lowerBound) + hasher.combine(range.upperBound) + } + } +} + extension PackageDependency: Encodable { private enum CodingKeys: String, CodingKey { case local, fileSystem, scm, sourceControl, registry @@ -476,7 +566,7 @@ extension PackageDependency: Encodable { extension PackageDependency.SourceControl.Requirement: Encodable { private enum CodingKeys: String, CodingKey { - case exact, range, revision, branch + case exact, exactLiteral, range, revision, branch } public func encode(to encoder: Encoder) throws { @@ -485,6 +575,9 @@ extension PackageDependency.SourceControl.Requirement: Encodable { case let .exact(a1): var unkeyedContainer = container.nestedUnkeyedContainer(forKey: .exact) try unkeyedContainer.encode(a1) + case .exactLiteral(let a1): + var unkeyedContainer = container.nestedUnkeyedContainer(forKey: .exactLiteral) + try unkeyedContainer.encode(a1) case let .range(a1): var unkeyedContainer = container.nestedUnkeyedContainer(forKey: .range) try unkeyedContainer.encode(CodableRange(a1)) @@ -518,7 +611,7 @@ extension PackageDependency.SourceControl.Location: Encodable { extension PackageDependency.Registry.Requirement: Encodable { private enum CodingKeys: String, CodingKey { - case exact, range + case exact, exactLiteral, range } public func encode(to encoder: Encoder) throws { @@ -527,6 +620,9 @@ extension PackageDependency.Registry.Requirement: Encodable { case let .exact(a1): var unkeyedContainer = container.nestedUnkeyedContainer(forKey: .exact) try unkeyedContainer.encode(a1) + case .exactLiteral(let a1): + var unkeyedContainer = container.nestedUnkeyedContainer(forKey: .exactLiteral) + try unkeyedContainer.encode(a1) case let .range(a1): var unkeyedContainer = container.nestedUnkeyedContainer(forKey: .range) try unkeyedContainer.encode(CodableRange(a1)) diff --git a/Sources/PackageModel/ManifestSourceGeneration.swift b/Sources/PackageModel/ManifestSourceGeneration.swift index 9c9a19a3a2d..0108fa74e7f 100644 --- a/Sources/PackageModel/ManifestSourceGeneration.swift +++ b/Sources/PackageModel/ManifestSourceGeneration.swift @@ -181,6 +181,8 @@ fileprivate extension SourceCodeFragment { switch settings.requirement { case .exact(let version): params.append(SourceCodeFragment(enum: "exact", string: "\(version)")) + case .exactLiteral(let version): + params.append(SourceCodeFragment(enum: "exactLiteral", string: "\(version)")) case .range(let range): params.append(SourceCodeFragment("\"\(range.lowerBound)\"..<\"\(range.upperBound)\"")) case .revision(let revision): @@ -189,10 +191,12 @@ fileprivate extension SourceCodeFragment { params.append(SourceCodeFragment(enum: "branch", string: branch)) } case .registry(let settings): - params.append(SourceCodeFragment(key: "identity", string: settings.identity.description)) + params.append(SourceCodeFragment(key: "id", string: settings.identity.description)) switch settings.requirement { case .exact(let version): params.append(SourceCodeFragment(enum: "exact", string: "\(version)")) + case .exactLiteral(let version): + params.append(SourceCodeFragment(enum: "exactLiteral", string: "\(version)")) case .range(let range): params.append(SourceCodeFragment("\"\(range.lowerBound)\"..<\"\(range.upperBound)\"")) } diff --git a/Sources/Runtimes/PackageDescription/PackageDependency.swift b/Sources/Runtimes/PackageDescription/PackageDependency.swift index c61729bc397..6d264506eb1 100644 --- a/Sources/Runtimes/PackageDescription/PackageDependency.swift +++ b/Sources/Runtimes/PackageDescription/PackageDependency.swift @@ -109,6 +109,8 @@ extension Package { return .branchItem(branch) case .exact(let version): return .exactItem(version) + case .exactLiteral(let version): + return .exactItem(version) case .range(let range): return .rangeItem(range) case .revision(let revision): @@ -118,6 +120,8 @@ extension Package { switch requirement { case .exact(let version): return .exactItem(version) + case .exactLiteral(let version): + return .exactItem(version) case .range(let range): return .rangeItem(range) } @@ -803,6 +807,39 @@ extension Package.Dependency { return .package(url: url, requirement: .exact(version), traits: traits) } + /// Adds a remote package dependency with a version requirement. + /// + /// - Parameters: + /// - url: The valid Git URL of the package. + /// - requirement: A dependency requirement. + /// + /// - Returns: A `Package.Dependency` instance. + @available(_PackageDescription, introduced: 999) + public static func package( + url: String, + _ requirement: Package.Dependency.SourceControlRequirement + ) -> Package.Dependency { + .package(url: url, requirement: requirement) + } + + /// Adds a remote package dependency with a version requirement. + /// + /// - Parameters: + /// - url: The valid Git URL of the package. + /// - requirement: A dependency requirement. + /// - traits: The trait configuration of this dependency. The default value enables the default traits of the + /// package. + /// + /// - Returns: A `Package.Dependency` instance. + @available(_PackageDescription, introduced: 999) + public static func package( + url: String, + _ requirement: Package.Dependency.SourceControlRequirement, + traits: Set = [.defaults] + ) -> Package.Dependency { + .package(url: url, requirement: requirement, traits: traits) + } + /// Adds a remote package dependency given a version requirement. /// /// - Parameters: @@ -980,6 +1017,39 @@ extension Package.Dependency { return .package(id: id, requirement: .exact(version), traits: traits) } + /// Adds a remote package dependency with a registry requirement. + /// + /// - Parameters: + /// - id: The identity of the package. + /// - requirement: A dependency requirement. + /// + /// - Returns: A `Package.Dependency` instance. + @available(_PackageDescription, introduced: 999) + public static func package( + id: String, + _ requirement: Package.Dependency.RegistryRequirement + ) -> Package.Dependency { + .package(id: id, requirement: requirement, traits: nil) + } + + /// Adds a remote package dependency with a registry requirement. + /// + /// - Parameters: + /// - id: The identity of the package. + /// - requirement: A dependency requirement. + /// - traits: The trait configuration of this dependency. The default value enables the default traits of the + /// package. + /// + /// - Returns: A `Package.Dependency` instance. + @available(_PackageDescription, introduced: 999) + public static func package( + id: String, + _ requirement: Package.Dependency.RegistryRequirement, + traits: Set = [.defaults] + ) -> Package.Dependency { + .package(id: id, requirement: requirement, traits: traits) + } + /// Adds a remote package dependency starting with a specific minimum version, up to /// but not including a specified maximum version. /// diff --git a/Sources/Runtimes/PackageDescription/PackageDescriptionSerialization.swift b/Sources/Runtimes/PackageDescription/PackageDescriptionSerialization.swift index 8ade7137333..2ab8655819b 100644 --- a/Sources/Runtimes/PackageDescription/PackageDescriptionSerialization.swift +++ b/Sources/Runtimes/PackageDescription/PackageDescriptionSerialization.swift @@ -127,6 +127,7 @@ enum Serialization { } enum SourceControlRequirement: Codable { case exact(Version) + case exactLiteral(Version) case range(lowerBound: Version, upperBound: Version) case revision(String) case branch(String) @@ -134,6 +135,7 @@ enum Serialization { enum RegistryRequirement: Codable { case exact(Version) + case exactLiteral(Version) case range(lowerBound: Version, upperBound: Version) } diff --git a/Sources/Runtimes/PackageDescription/PackageDescriptionSerializationConversion.swift b/Sources/Runtimes/PackageDescription/PackageDescriptionSerializationConversion.swift index 09d4f73ebf3..16998695bac 100644 --- a/Sources/Runtimes/PackageDescription/PackageDescriptionSerializationConversion.swift +++ b/Sources/Runtimes/PackageDescription/PackageDescriptionSerializationConversion.swift @@ -136,6 +136,8 @@ extension Serialization.PackageDependency.SourceControlRequirement { self = .range(lowerBound: .init(range.lowerBound), upperBound: .init(range.upperBound)) case .exact(let version): self = .exact(.init(version)) + case .exactLiteral(let version): + self = .exactLiteral(.init(version)) case .revision(let revision): self = .revision(revision) case .branch(let branch): @@ -149,6 +151,8 @@ extension Serialization.PackageDependency.RegistryRequirement { switch requirement { case .exact(let version): self = .exact(.init(version)) + case .exactLiteral(let version): + self = .exactLiteral(.init(version)) case .range(let range): self = .range(lowerBound: .init(range.lowerBound), upperBound: .init(range.upperBound)) } diff --git a/Sources/Runtimes/PackageDescription/PackageRequirement.swift b/Sources/Runtimes/PackageDescription/PackageRequirement.swift index dd507334eb8..67c14746b05 100644 --- a/Sources/Runtimes/PackageDescription/PackageRequirement.swift +++ b/Sources/Runtimes/PackageDescription/PackageRequirement.swift @@ -181,6 +181,9 @@ extension Package.Dependency { public enum SourceControlRequirement { /// An exact version based requirement. case exact(Version) + /// An exact version based requirement with literal matching. + @available(_PackageDescription, introduced: 999) + case exactLiteral(Version) /// A requirement based on a range of versions. case range(Range) /// A commit based requirement. @@ -207,6 +210,8 @@ extension Package.Dependency { public enum RegistryRequirement { /// A requirement based on an exact version. case exact(Version) + /// A requirement based on an exact literal version. + case exactLiteral(Version) /// A requirement based on a range of versions. case range(Range) } diff --git a/Sources/Workspace/PackageContainer/RegistryPackageContainer.swift b/Sources/Workspace/PackageContainer/RegistryPackageContainer.swift index 7b42c5c6907..a005505d19f 100644 --- a/Sources/Workspace/PackageContainer/RegistryPackageContainer.swift +++ b/Sources/Workspace/PackageContainer/RegistryPackageContainer.swift @@ -86,7 +86,7 @@ public class RegistryPackageContainer: PackageContainer { package: self.package.identity, observabilityScope: self.observabilityScope ) - return metadata.versions.sorted(by: >) + return metadata.versions.sorted(by: Self.versionDescending) } } @@ -219,3 +219,17 @@ extension RegistryPackageContainer: CustomStringConvertible { return "RegistryPackageContainer(\(package.identity))" } } + +extension RegistryPackageContainer { + private static func versionDescending(_ lhs: Version, _ rhs: Version) -> Bool { + if lhs == rhs { + if lhs.buildMetadataIdentifiers == rhs.buildMetadataIdentifiers { + return lhs.description > rhs.description + } + return rhs.buildMetadataIdentifiers.lexicographicallyPrecedes( + lhs.buildMetadataIdentifiers + ) + } + return lhs > rhs + } +} diff --git a/Sources/Workspace/PackageContainer/SourceControlPackageContainer.swift b/Sources/Workspace/PackageContainer/SourceControlPackageContainer.swift index 70b8affa0a9..ab93e48ded0 100644 --- a/Sources/Workspace/PackageContainer/SourceControlPackageContainer.swift +++ b/Sources/Workspace/PackageContainer/SourceControlPackageContainer.swift @@ -22,7 +22,6 @@ import SourceControl import struct TSCBasic.RegEx -import enum TSCUtility.Git import struct TSCUtility.Version /// Adaptor to expose an individual repository as a package container. @@ -69,13 +68,13 @@ internal final class SourceControlPackageContainer: PackageContainer, CustomStri private var dependenciesCache = [String: [ProductFilter: (Manifest, [Constraint])]]() private var dependenciesCacheLock = NSLock() - private var knownVersionsCache = ThreadSafeBox<[Version: String]?>() + private var knownVersionsCache = ThreadSafeBox<[KnownVersionTag]?>() private var manifestsCache = ThrowingAsyncKeyValueMemoizer() - private var toolsVersionsCache = ThreadSafeKeyValueStore() + private var toolsVersionsCache = ThreadSafeKeyValueStore() /// This is used to remember if tools version of a particular version is /// valid or not. - internal var validToolsVersionsCache = ThreadSafeKeyValueStore() + var validToolsVersionsCache = ThreadSafeKeyValueStore() init( package: PackageReference, @@ -104,37 +103,26 @@ internal final class SourceControlPackageContainer: PackageContainer, CustomStri } // Compute the map of known versions. - private func knownVersions() throws -> [Version: String] { + private func knownVersions() throws -> [KnownVersionTag] { try self.knownVersionsCache.memoize { - let knownVersionsWithDuplicates = Git.convertTagsToVersionMap(tags: try repository.getTags(), toolsVersion: self.currentToolsVersion) - - return knownVersionsWithDuplicates.mapValues { tags -> String in - if tags.count > 1 { - // FIXME: Warn if the two tags point to different git references. - - // If multiple tags are present with the same semantic version (e.g. v1.0.0, 1.0.0, 1.0) reconcile which one we prefer. - // Prefer the most specific tag, e.g. 1.0.0 is preferred over 1.0. - // Sort the tags so the most specific tag is first, order is ascending so the most specific tag will be last - let tagsSortedBySpecificity = tags.sorted { - let componentCounts = ($0.components(separatedBy: ".").count, $1.components(separatedBy: ".").count) - if componentCounts.0 == componentCounts.1 { - // if they are both have the same number of components, favor the one without a v prefix. - // this matches previously defined behavior - // this assumes we can only enter this situation because one tag has a v prefix and the other does not. - return $0.hasPrefix("v") - } - return componentCounts.0 < componentCounts.1 - } - return tagsSortedBySpecificity.last! - } - assert(tags.count == 1, "Unexpected number of tags") - return tags[0] + let knownVersionsWithDuplicates = try Self.convertTagsToVersionMap( + tags: repository.getTags(), + toolsVersion: self.currentToolsVersion + ) + + return knownVersionsWithDuplicates.values.map { value in + KnownVersionTag( + version: value.version, + tag: Self.reconcilePreferredTag(value.tags) + ) } } } public func versionsAscending() throws -> [Version] { - [Version](try self.knownVersions().keys).sorted() + try self.knownVersions() + .map(\.version) + .sorted(by: Self.versionAscending) } /// The available version list (in reverse order). @@ -142,19 +130,19 @@ internal final class SourceControlPackageContainer: PackageContainer, CustomStri let reversedVersions = try await self.versionsDescending() return reversedVersions.lazy.filter { // If we have the result cached, return that. - if let result = self.validToolsVersionsCache[$0] { + if let result = self.validToolsVersionsCache[$0.description] { return result } // Otherwise, compute and cache the result. let isValid = (try? self.toolsVersion(for: $0)).flatMap(self.isValidToolsVersion(_:)) ?? false - self.validToolsVersionsCache[$0] = isValid + self.validToolsVersionsCache[$0.description] = isValid return isValid } } public func getTag(for version: Version) -> String? { - return try? self.knownVersions()[version] + return try? self.tag(for: version) } func checkIntegrity(version: Version, revision: Revision) throws { @@ -230,8 +218,8 @@ internal final class SourceControlPackageContainer: PackageContainer, CustomStri /// Returns the tools version of the given version of the package. public func toolsVersion(for version: Version) throws -> ToolsVersion { - try self.toolsVersionsCache.memoize(version) { - guard let tag = try self.knownVersions()[version] else { + try self.toolsVersionsCache.memoize(version.description) { + guard let tag = try self.tag(for: version) else { throw StringError("unknown tag \(version)") } let fileSystem = try repository.openFileView(tag: tag) @@ -244,7 +232,7 @@ internal final class SourceControlPackageContainer: PackageContainer, CustomStri public func getDependencies(at version: Version, productFilter: ProductFilter, _ enabledTraits: EnabledTraits = ["default"]) async throws -> [Constraint] { do { return try await self.getCachedDependencies(forIdentifier: version.description, productFilter: productFilter) { - guard let tag = try self.knownVersions()[version] else { + guard let tag = try self.tag(for: version) else { throw StringError("unknown tag \(version)") } return try await self.loadDependencies(tag: tag, version: version, productFilter: productFilter, enabledTraits: enabledTraits) @@ -350,7 +338,7 @@ internal final class SourceControlPackageContainer: PackageContainer, CustomStri var version: Version? switch boundVersion { case .version(let v): - guard let tag = try self.knownVersions()[v] else { + guard let tag = try self.tag(for: v) else { throw StringError("unknown tag \(v)") } version = v @@ -412,34 +400,63 @@ internal final class SourceControlPackageContainer: PackageContainer, CustomStri ) } - public var isRemoteContainer: Bool? { - return true + private func tag(for version: Version) throws -> String? { + let knownVersions = try self.knownVersions() + if !version.buildMetadataIdentifiers.isEmpty { + if let exact = knownVersions.first(where: { $0.version.literalEqual(to: version) }) { + return exact.tag + } + } + + let semanticMatches = knownVersions.filter { $0.version == version } + guard !semanticMatches.isEmpty else { + return nil + } + if semanticMatches.count == 1 { + return semanticMatches[0].tag + } + + return semanticMatches.sorted(by: { lhs, rhs in + Self.versionAscending(lhs.version, rhs.version) + }).first?.tag } - public var description: String { - return "SourceControlPackageContainer(\(self.repositorySpecifier))" + private static func versionAscending(_ lhs: Version, _ rhs: Version) -> Bool { + if lhs == rhs { + return lhs.literalSortKey < rhs.literalSortKey + } + return lhs < rhs } -} -extension Git { - fileprivate static func convertTagsToVersionMap(tags: [String], toolsVersion: ToolsVersion) -> [Version: [String]] { + private static func convertTagsToVersionMap( + tags: [String], + toolsVersion: ToolsVersion + ) -> [LiteralVersionKey: (version: Version, tags: [String])] { // First, check if we need to restrict the tag set to version-specific tags. - var knownVersions: [Version: [String]] = [:] - var versionSpecificKnownVersions: [Version: [String]] = [:] + var knownVersions: [LiteralVersionKey: (version: Version, tags: [String])] = [:] + var versionSpecificKnownVersions: [LiteralVersionKey: (version: Version, tags: [String])] = [:] for tag in tags { for versionSpecificKey in toolsVersion.versionSpecificKeys { if tag.hasSuffix(versionSpecificKey) { let trimmedTag = String(tag.dropLast(versionSpecificKey.count)) if let version = Version(tag: trimmedTag) { - versionSpecificKnownVersions[version, default: []].append(tag) + let key = LiteralVersionKey(version) + if versionSpecificKnownVersions[key] == nil { + versionSpecificKnownVersions[key] = (version, []) + } + versionSpecificKnownVersions[key]?.tags.append(tag) } break } } if let version = Version(tag: tag) { - knownVersions[version, default: []].append(tag) + let key = LiteralVersionKey(version) + if knownVersions[key] == nil { + knownVersions[key] = (version, []) + } + knownVersions[key]?.tags.append(tag) } } // Check if any version specific tags were found. @@ -451,4 +468,64 @@ extension Git { return knownVersions } } + + private static func reconcilePreferredTag(_ tags: [String]) -> String { + if tags.count > 1 { + // FIXME: Warn if the two tags point to different git references. + // If multiple tags are present with the same semantic version (e.g. v1.0.0, 1.0.0, 1.0) reconcile which one + // we prefer. + // Prefer the most specific tag, e.g. 1.0.0 is preferred over 1.0. + let tagsSortedBySpecificity = tags.sorted { + let componentCounts = ($0.components(separatedBy: ".").count, $1.components(separatedBy: ".").count) + if componentCounts.0 == componentCounts.1 { + // If they have the same number of components, favor the one without a `v` prefix. + // This matches previously defined behavior. + return $0.hasPrefix("v") + } + return componentCounts.0 < componentCounts.1 + } + return tagsSortedBySpecificity.last! + } + + assert(tags.count == 1, "Unexpected number of tags") + return tags[0] + } + + public var isRemoteContainer: Bool? { + true + } + + public var description: String { + "SourceControlPackageContainer(\(self.repositorySpecifier))" + } +} + +private struct KnownVersionTag { + let version: Version + let tag: String +} + +private struct LiteralVersionKey: Hashable { + let major: Int + let minor: Int + let patch: Int + let prereleaseIdentifiers: [String] + let buildMetadataIdentifiers: [String] + + init(_ version: Version) { + self.major = version.major + self.minor = version.minor + self.patch = version.patch + self.prereleaseIdentifiers = version.prereleaseIdentifiers + self.buildMetadataIdentifiers = version.buildMetadataIdentifiers + } +} + +extension Version { + fileprivate var literalSortKey: String { + if self.buildMetadataIdentifiers.isEmpty { + return "" + } + return self.buildMetadataIdentifiers.joined(separator: ".") + } } diff --git a/Sources/Workspace/Workspace+Registry.swift b/Sources/Workspace/Workspace+Registry.swift index c9b3feebed1..a43b484335a 100644 --- a/Sources/Workspace/Workspace+Registry.swift +++ b/Sources/Workspace/Workspace+Registry.swift @@ -199,7 +199,7 @@ extension Workspace { // this helps de-dupe across source control and registry dependencies // and also encourages use of registry over source control switch settings.requirement { - case .exact, .range: + case .exact, .exactLiteral, .range: let requirement = try settings.requirement.asRegistryRequirement() observabilityScope .emit( @@ -381,6 +381,8 @@ extension PackageDependency.SourceControl.Requirement { return .range(versions) case .exact(let version): return .exact(version) + case .exactLiteral(let version): + return .exactLiteral(version) case .branch, .revision: throw InternalError("invalid source control to registry requirement transformation") } diff --git a/Sources/_InternalTestSupport/MockDependency.swift b/Sources/_InternalTestSupport/MockDependency.swift index 8e9f9f0dbef..80986462e76 100644 --- a/Sources/_InternalTestSupport/MockDependency.swift +++ b/Sources/_InternalTestSupport/MockDependency.swift @@ -77,6 +77,8 @@ public struct MockDependency { throw StringError("invalid mapping of source control to registry, requirement information mismatch.") case .exact(let value): requirement = .exact(value) + case .exactLiteral(let value): + requirement = .exactLiteral(value) case .range(let value): requirement = .range(value) } @@ -116,6 +118,8 @@ public struct MockDependency { switch _requirement { case .exact(let value): requirement = .exact(value) + case .exactLiteral(let value): + requirement = .exactLiteral(value) case .range(let value): requirement = .range(value) } diff --git a/Tests/PackageGraphTests/PubGrubTests.swift b/Tests/PackageGraphTests/PubGrubTests.swift index 7b0c2a13fba..b359f9b96d9 100644 --- a/Tests/PackageGraphTests/PubGrubTests.swift +++ b/Tests/PackageGraphTests/PubGrubTests.swift @@ -1487,6 +1487,38 @@ final class PubGrubTests: XCTestCase { ]) } + func testPinnedResolvedPackagePreservesMetadataVariant() async throws { + try builder.serve("a", at: v1, with: ["a": ["b": (.versionSet(.exact(v1)), .specific(["b"]))]]) + try builder.serve("b", at: "1.0.0+debug") + try builder.serve("b", at: "1.0.0+release") + + let dependencies = try builder.create(dependencies: [ + "a": (.versionSet(.exact(v1)), .specific(["a"])), + "b": (.versionSet(.exact(v1)), .specific(["b"])), + ]) + + let resolvedPackagesStore = try builder.create(resolvedPackages: [ + "a": (.version(v1), .specific(["a"])), + "b": (.version("1.0.0+debug"), .specific(["b"])), + ]) + + let resolver = builder.create(resolvedPackages: resolvedPackagesStore.resolvedPackages) + let result = try await resolver.solve(root: rootNode, constraints: dependencies) + + AssertResult(Result.success(result.bindings), [ + ("a", .version(v1)), + ("b", .version("1.0.0+debug")), + ]) + + let pinnedBinding = try XCTUnwrap(result.bindings.first { $0.package.identity == .plain("b") }) + switch pinnedBinding.boundVersion { + case .version(let version): + XCTAssertEqual(version.description, "1.0.0+debug") + default: + XCTFail("Expected version binding for pinned package") + } + } + func testBranchBasedResolvedPackage() async throws { // This test ensures that we get the SHA listed in Package.resolved for branch-based // dependencies. @@ -3040,6 +3072,86 @@ final class PubGrubBacktrackTests: XCTestCase { ("b", .version("1.9.9-prerelease-20240702")) ]) } + + func testExactLiteralChoosesRequestedMetadataVariant() async throws { + try builder.serve("foo", at: "1.0.0+debug") + try builder.serve("foo", at: "1.0.0+release") + + let resolver = builder.create() + let dependencies = try builder.create(dependencies: [ + "foo": (.versionSet(.exactLiteral("1.0.0+debug")), .specific(["foo"])), + ]) + + let result = await resolver.solve(constraints: dependencies) + AssertResult(result, [ + ("foo", .version("1.0.0+debug")), + ]) + + let bindings = try XCTUnwrap(result.get()) + let fooBinding = try XCTUnwrap(bindings.first { $0.package.identity == .plain("foo") }) + switch fooBinding.boundVersion { + case .version(let version): + XCTAssertEqual(version.description, "1.0.0+debug") + default: + XCTFail("Expected version binding for foo") + } + } + + func testExactRequirementCompatibleWithExactLiteralSelectsLiteralVariant() async throws { + try builder.serve("a", at: "1.0.0", with: [ + "a": [ + "c": (.versionSet(.exactLiteral("1.0.0+debug")), .specific(["c"])), + ], + ]) + try builder.serve("c", at: "1.0.0+debug") + try builder.serve("c", at: "1.0.0+release") + + let resolver = builder.create() + let dependencies = try builder.create(dependencies: [ + "a": (.versionSet(.exact("1.0.0")), .specific(["a"])), + "c": (.versionSet(.exact("1.0.0")), .specific(["c"])), + ]) + + let result = await resolver.solve(constraints: dependencies) + AssertResult(result, [ + ("a", .version("1.0.0")), + ("c", .version("1.0.0+debug")), + ]) + + let bindings = try XCTUnwrap(result.get()) + let cBinding = try XCTUnwrap(bindings.first { $0.package.identity == .plain("c") }) + switch cBinding.boundVersion { + case .version(let version): + XCTAssertEqual(version.description, "1.0.0+debug") + default: + XCTFail("Expected version binding for c") + } + } + + func testExactLiteralConflictsForDifferentMetadataVariants() async throws { + try builder.serve("a", at: "1.0.0", with: [ + "a": [ + "c": (.versionSet(.exactLiteral("1.0.0+debug")), .specific(["c"])), + ], + ]) + try builder.serve("b", at: "1.0.0", with: [ + "b": [ + "c": (.versionSet(.exactLiteral("1.0.0+release")), .specific(["c"])), + ], + ]) + try builder.serve("c", at: "1.0.0+debug") + try builder.serve("c", at: "1.0.0+release") + + let resolver = builder.create() + let dependencies = try builder.create(dependencies: [ + "a": (.versionSet(.exact("1.0.0")), .specific(["a"])), + "b": (.versionSet(.exact("1.0.0")), .specific(["b"])), + ]) + + let result = await resolver.solve(constraints: dependencies) + XCTAssertMatch(result.errorMsg, .contains("1.0.0+debug")) + XCTAssertMatch(result.errorMsg, .contains("1.0.0+release")) + } } fileprivate extension ResolvedPackagesStore.ResolutionState { diff --git a/Tests/PackageGraphTests/VersionSetSpecifierTests.swift b/Tests/PackageGraphTests/VersionSetSpecifierTests.swift index 6fa280c8815..6e5abc7c214 100644 --- a/Tests/PackageGraphTests/VersionSetSpecifierTests.swift +++ b/Tests/PackageGraphTests/VersionSetSpecifierTests.swift @@ -14,7 +14,7 @@ import Foundation import TSCUtility import XCTest -import PackageGraph +@testable import PackageGraph final class VersionSetSpecifierTests: XCTestCase { func testUnion() { @@ -153,6 +153,41 @@ final class VersionSetSpecifierTests: XCTestCase { XCTAssertTrue(VersionSetSpecifier.ranges(["2.0.1"..<"2.0.2"]) == VersionSetSpecifier.range("2.0.1"..<"2.0.2")) } + func testExactLiteral() { + let literalDebug: VersionSetSpecifier = .exactLiteral("1.0.0+debug") + let literalRelease: VersionSetSpecifier = .exactLiteral("1.0.0+release") + + XCTAssertTrue(literalDebug.contains("1.0.0+debug")) + XCTAssertFalse(literalDebug.contains("1.0.0+release")) + XCTAssertFalse(literalDebug.contains("1.0.0")) + + XCTAssertEqual(literalDebug.intersection(.exact("1.0.0")), literalDebug) + XCTAssertEqual(VersionSetSpecifier.exact("1.0.0").intersection(literalDebug), literalDebug) + XCTAssertEqual(VersionSetSpecifier.range("1.0.0" ..< "2.0.0").intersection(literalDebug), literalDebug) + XCTAssertEqual(literalDebug.intersection(literalRelease), .empty) + XCTAssertEqual(literalDebug.union(literalRelease), .exact("1.0.0")) + XCTAssertEqual(VersionSetSpecifier.exact("1.0.0").difference(literalDebug), .exact("1.0.0")) + XCTAssertEqual(literalDebug.difference(.exact("1.0.0")), .empty) + XCTAssertEqual(literalDebug.difference(literalRelease), literalDebug) + XCTAssertEqual( + VersionSetSpecifier.range("1.0.0" ..< "2.0.0").difference(literalDebug), + .range("1.0.0" ..< "2.0.0") + ) + XCTAssertEqual( + VersionSetSpecifier.ranges(["1.0.0" ..< "2.0.0", "3.0.0" ..< "4.0.0"]).difference(literalDebug), + .ranges(["1.0.0" ..< "2.0.0", "3.0.0" ..< "4.0.0"]) + ) + + XCTAssertTrue(VersionSetSpecifier.exact("1.0.0").contains("1.0.0+debug")) + XCTAssertNotEqual(VersionSetSpecifier.exact("1.0.0"), literalDebug) + + var set = Set() + set.insert(literalDebug) + set.insert(.exactLiteral("1.0.0+debug")) + set.insert(literalRelease) + XCTAssertEqual(set.count, 2) + } + func testPrereleases() { XCTAssertFalse(VersionSetSpecifier.any.supportsPrereleases) XCTAssertFalse(VersionSetSpecifier.empty.supportsPrereleases) @@ -176,5 +211,79 @@ final class VersionSetSpecifierTests: XCTestCase { "0.0.1" ..< "0.0.2", "0.0.1" ..< "2.0.0", ]).supportsPrereleases) + + XCTAssertFalse(VersionSetSpecifier.exactLiteral("1.0.0+debug").supportsPrereleases) + XCTAssertTrue(VersionSetSpecifier.exactLiteral("1.0.0-beta+debug").supportsPrereleases) + XCTAssertEqual( + VersionSetSpecifier.exactLiteral("1.0.0-beta+debug").withoutPrereleases, + .exactLiteral("1.0.0+debug") + ) + XCTAssertEqual( + VersionSetSpecifier.exactLiteral("1.0.0+debug").withoutPrereleases, + .exactLiteral("1.0.0+debug") + ) + } + + func testIsExactAndIsExactLiteral() { + XCTAssertFalse(VersionSetSpecifier.any.isExact) + XCTAssertFalse(VersionSetSpecifier.empty.isExact) + XCTAssertFalse(VersionSetSpecifier.range("1.0.0" ..< "2.0.0").isExact) + XCTAssertFalse(VersionSetSpecifier.ranges(["1.0.0" ..< "2.0.0"]).isExact) + XCTAssertTrue(VersionSetSpecifier.exact("1.0.0").isExact) + XCTAssertTrue(VersionSetSpecifier.exactLiteral("1.0.0+debug").isExact) + + XCTAssertFalse(VersionSetSpecifier.any.isExactLiteral) + XCTAssertFalse(VersionSetSpecifier.empty.isExactLiteral) + XCTAssertFalse(VersionSetSpecifier.exact("1.0.0").isExactLiteral) + XCTAssertFalse(VersionSetSpecifier.range("1.0.0" ..< "2.0.0").isExactLiteral) + XCTAssertTrue(VersionSetSpecifier.exactLiteral("1.0.0+debug").isExactLiteral) + XCTAssertTrue(VersionSetSpecifier.exactLiteral("1.0.0").isExactLiteral) + } + + func testExactLiteralDescription() { + XCTAssertEqual(VersionSetSpecifier.exactLiteral("1.2.3+debug").description, "1.2.3+debug") + XCTAssertEqual(VersionSetSpecifier.exactLiteral("2.0.0+vendor.42").description, "2.0.0+vendor.42") + XCTAssertEqual(VersionSetSpecifier.exactLiteral("1.0.0").description, "1.0.0") + } + + func testExactLiteralEdgeCases() { + let debug: VersionSetSpecifier = .exactLiteral("1.0.0+debug") + let release: VersionSetSpecifier = .exactLiteral("1.0.0+release") + let exact100: VersionSetSpecifier = .exact("1.0.0") + let exact200: VersionSetSpecifier = .exact("2.0.0") + + XCTAssertEqual(debug.intersection(.any), debug) + XCTAssertEqual(VersionSetSpecifier.any.intersection(debug), debug) + + XCTAssertEqual(debug.intersection(.empty), .empty) + XCTAssertEqual(VersionSetSpecifier.empty.intersection(debug), .empty) + + XCTAssertEqual(debug.intersection(debug), debug) + XCTAssertEqual(debug.intersection(.exactLiteral("1.0.0+debug")), debug) + + let literal200debug: VersionSetSpecifier = .exactLiteral("2.0.0+debug") + let unionResult = debug.union(literal200debug) + XCTAssertTrue(unionResult.contains("1.0.0+debug")) + XCTAssertTrue(unionResult.contains("2.0.0+debug")) + + let unionExact = debug.union(exact200) + XCTAssertTrue(unionExact.contains("1.0.0")) + XCTAssertTrue(unionExact.contains("2.0.0")) + + XCTAssertEqual(debug.difference(debug), .empty) + XCTAssertEqual(debug.difference(.exactLiteral("1.0.0+debug")), .empty) + + let wideRanges: VersionSetSpecifier = .ranges(["0.5.0" ..< "1.5.0", "2.0.0" ..< "3.0.0"]) + XCTAssertEqual(debug.difference(wideRanges), .empty) + + let missRanges: VersionSetSpecifier = .ranges(["2.0.0" ..< "3.0.0"]) + XCTAssertEqual(debug.difference(missRanges), debug) + + XCTAssertTrue(exact100.contains("1.0.0+debug")) + XCTAssertTrue(exact100.contains("1.0.0+release")) + + XCTAssertNotEqual(debug, exact100) + XCTAssertNotEqual(exact100, debug) + XCTAssertNotEqual(debug, release) } } diff --git a/Tests/PackageLoadingTests/PD_Next_LoadingTests.swift b/Tests/PackageLoadingTests/PD_Next_LoadingTests.swift index d254a47112d..2b0174efc4f 100644 --- a/Tests/PackageLoadingTests/PD_Next_LoadingTests.swift +++ b/Tests/PackageLoadingTests/PD_Next_LoadingTests.swift @@ -39,4 +39,39 @@ final class PackageDescriptionNextLoadingTests: PackageDescriptionLoadingTests { } } } + + func testExactLiteralDependencies() async throws { + let content = """ + import PackageDescription + let package = Package( + name: "MyPackage", + dependencies: [ + .package(url: "http://localhost/foo", .exactLiteral("1.1.1+debug")), + .package(id: "org.foo", .exactLiteral("1.1.1+debug")), + ] + ) + """ + + let observability = ObservabilitySystem.makeForTesting() + let (manifest, validationDiagnostics) = try await loadAndValidateManifest( + content, + observabilityScope: observability.topScope + ) + XCTAssertNoDiagnostics(observability.diagnostics) + XCTAssertNoDiagnostics(validationDiagnostics) + + let deps = Dictionary(uniqueKeysWithValues: manifest.dependencies.map { ($0.identity.description, $0) }) + XCTAssertEqual( + deps["foo"], + .remoteSourceControl( + identity: .plain("foo"), + url: "http://localhost/foo", + requirement: .exactLiteral("1.1.1+debug") + ) + ) + XCTAssertEqual( + deps["org.foo"], + .registry(identity: "org.foo", requirement: .exactLiteral("1.1.1+debug")) + ) + } } diff --git a/Tests/PackageModelTests/PackageModelTests.swift b/Tests/PackageModelTests/PackageModelTests.swift index 5c5643a108d..822c25027bf 100644 --- a/Tests/PackageModelTests/PackageModelTests.swift +++ b/Tests/PackageModelTests/PackageModelTests.swift @@ -60,6 +60,42 @@ final class PackageModelTests: XCTestCase { }() } + func testPackageDependencyExactLiteralRequirementEqualityHashingAndEncoding() throws { + let sourceDebug: PackageDependency.SourceControl.Requirement = .exactLiteral("1.2.3+debug") + let sourceDebugDuplicate: PackageDependency.SourceControl.Requirement = .exactLiteral("1.2.3+debug") + let sourceRelease: PackageDependency.SourceControl.Requirement = .exactLiteral("1.2.3+release") + + XCTAssertEqual(sourceDebug, sourceDebugDuplicate) + XCTAssertNotEqual(sourceDebug, sourceRelease) + + var sourceRequirements: Set = [] + sourceRequirements.insert(sourceDebug) + sourceRequirements.insert(sourceDebugDuplicate) + sourceRequirements.insert(sourceRelease) + XCTAssertEqual(sourceRequirements.count, 2) + + let encodedSource = try String(decoding: JSONEncoder.makeWithDefaults().encode(sourceDebug), as: UTF8.self) + XCTAssertMatch(encodedSource, .contains(#""exactLiteral""#)) + XCTAssertMatch(encodedSource, .contains(#""1.2.3+debug""#)) + + let registryDebug: PackageDependency.Registry.Requirement = .exactLiteral("1.2.3+debug") + let registryDebugDuplicate: PackageDependency.Registry.Requirement = .exactLiteral("1.2.3+debug") + let registryRelease: PackageDependency.Registry.Requirement = .exactLiteral("1.2.3+release") + + XCTAssertEqual(registryDebug, registryDebugDuplicate) + XCTAssertNotEqual(registryDebug, registryRelease) + + var registryRequirements: Set = [] + registryRequirements.insert(registryDebug) + registryRequirements.insert(registryDebugDuplicate) + registryRequirements.insert(registryRelease) + XCTAssertEqual(registryRequirements.count, 2) + + let encodedRegistry = try String(decoding: JSONEncoder.makeWithDefaults().encode(registryDebug), as: UTF8.self) + XCTAssertMatch(encodedRegistry, .contains(#""exactLiteral""#)) + XCTAssertMatch(encodedRegistry, .contains(#""1.2.3+debug""#)) + } + func testAndroidCompilerFlags() throws { let triple = try Triple("x86_64-unknown-linux-android") let fileSystem = InMemoryFileSystem() diff --git a/Tests/WorkspaceTests/ManifestSourceGenerationTests.swift b/Tests/WorkspaceTests/ManifestSourceGenerationTests.swift index 1b27b65ed04..b9ff761eb59 100644 --- a/Tests/WorkspaceTests/ManifestSourceGenerationTests.swift +++ b/Tests/WorkspaceTests/ManifestSourceGenerationTests.swift @@ -1008,4 +1008,25 @@ final class ManifestSourceGenerationTests: XCTestCase { """ try await testManifestWritingRoundTrip(manifestContents: manifestContents, toolsVersion: .v6_1) } + + func testExactLiteralDependencyRoundTrip() async throws { + let manifestContents = """ + // swift-tools-version: 999.0 + import PackageDescription + + let package = Package( + name: "MyPackage", + dependencies: [ + .package(url: "https://example.com/MyPkg1", .exactLiteral("1.2.3+debug")), + .package(id: "org.foo", .exactLiteral("1.2.3+debug")), + ] + ) + """ + + let generatedContents = try await testManifestWritingRoundTrip( + manifestContents: manifestContents, + toolsVersion: .vNext + ) + XCTAssertMatch(generatedContents, .contains(#".exactLiteral("1.2.3+debug")"#)) + } } diff --git a/Tests/WorkspaceTests/RegistryPackageContainerTests.swift b/Tests/WorkspaceTests/RegistryPackageContainerTests.swift index d7eb6a20461..3fc6334ec21 100644 --- a/Tests/WorkspaceTests/RegistryPackageContainerTests.swift +++ b/Tests/WorkspaceTests/RegistryPackageContainerTests.swift @@ -340,6 +340,55 @@ final class RegistryPackageContainerTests: XCTestCase { } } + func testMetadataDistinctVersionsAreDeterministicallyOrdered() async throws { + let fs = InMemoryFileSystem() + try fs.createMockToolchain() + + let packageIdentity = PackageIdentity.plain("org.foo") + let packagePath = AbsolutePath.root + + let registryClient = try makeRegistryClient( + packageIdentity: packageIdentity, + packageVersion: "1.0.0+debug", + packagePath: packagePath, + fileSystem: fs, + releasesRequestHandler: { _, _ in + let metadata = RegistryClient.Serialization.PackageMetadata( + releases: [ + "1.0.0+release": .init(url: .none, problem: .none), + "1.0.0+debug": .init(url: .none, problem: .none), + "1.0.1": .init(url: .none, problem: .none), + ] + ) + return HTTPClientResponse( + statusCode: 200, + headers: [ + "Content-Version": "1", + "Content-Type": "application/json", + ], + body: try! JSONEncoder.makeWithDefaults().encode(metadata) + ) + } + ) + + let provider = try Workspace._init( + fileSystem: fs, + environment: .mockEnvironment, + location: .init(forRootPackage: packagePath, fileSystem: fs), + customHostToolchain: .mockHostToolchain(fs), + customManifestLoader: MockManifestLoader(manifests: [:]), + customRegistryClient: registryClient + ) + + let ref = PackageReference.registry(identity: packageIdentity) + let container = try await provider.getContainer(for: ref) + let versions = try await container.versionsDescending() + XCTAssertEqual(versions, ["1.0.1", "1.0.0+release", "1.0.0+debug"]) + + let selected = versions.first { VersionSetSpecifier.exactLiteral("1.0.0+release").contains($0) } + XCTAssertEqual(selected, "1.0.0+release") + } + func makeRegistryClient( packageIdentity: PackageIdentity, packageVersion: Version, diff --git a/Tests/WorkspaceTests/ResolvedPackagesStoreTests.swift b/Tests/WorkspaceTests/ResolvedPackagesStoreTests.swift index e0e27681123..b3c66907cd7 100644 --- a/Tests/WorkspaceTests/ResolvedPackagesStoreTests.swift +++ b/Tests/WorkspaceTests/ResolvedPackagesStoreTests.swift @@ -153,6 +153,78 @@ final class ResolvedPackagesStoreTests: XCTestCase { XCTAssertEqual(resolution.state, .version("1.2.3", revision: .none)) XCTAssertEqual(resolution.state.description, "1.2.3") } + + // Test registry resolution with SemVer build metadata. + + do { + let identity = PackageIdentity.plain("baz.meta") // FIXME: use scope identifier + + var store = try ResolvedPackagesStore( + packageResolvedFile: packageResolvedFile, + workingDirectory: .root, + fileSystem: fs, + mirrors: .init() + ) + store.track( + packageRef: .registry(identity: identity), + state: .version("1.2.3+debug", revision: .none) + ) + try store.saveState(toolsVersion: ToolsVersion.current, originHash: .none) + + let resolvedFileContents = try fs.readFileContents(packageResolvedFile).cString + XCTAssertMatch(resolvedFileContents, .contains(#""identity" : "baz.meta""#)) + XCTAssertMatch(resolvedFileContents, .contains(#""kind" : "registry""#)) + XCTAssertMatch(resolvedFileContents, .contains(#""version" : "1.2.3+debug""#)) + + store = try ResolvedPackagesStore( + packageResolvedFile: packageResolvedFile, + workingDirectory: .root, + fileSystem: fs, + mirrors: .init() + ) + + let resolution = store.resolvedPackages[identity]! + XCTAssertEqual(resolution.packageRef, .registry(identity: identity)) + XCTAssertEqual(resolution.state, .version("1.2.3+debug", revision: .none)) + XCTAssertEqual(resolution.state.description, "1.2.3+debug") + } + + // Test source control version resolution with SemVer build metadata. + + do { + let path = AbsolutePath("/foo-meta") + let identity = PackageIdentity(path: path) + let revision = UUID().uuidString + + var store = try ResolvedPackagesStore( + packageResolvedFile: packageResolvedFile, + workingDirectory: .root, + fileSystem: fs, + mirrors: .init() + ) + store.track( + packageRef: .localSourceControl(identity: identity, path: path), + state: .version("1.2.3+debug", revision: revision) + ) + try store.saveState(toolsVersion: ToolsVersion.current, originHash: .none) + + let resolvedFileContents = try fs.readFileContents(packageResolvedFile).cString + XCTAssertMatch(resolvedFileContents, .contains(#""identity" : "foo-meta""#)) + XCTAssertMatch(resolvedFileContents, .contains(#""kind" : "localSourceControl""#)) + XCTAssertMatch(resolvedFileContents, .contains(#""version" : "1.2.3+debug""#)) + + store = try ResolvedPackagesStore( + packageResolvedFile: packageResolvedFile, + workingDirectory: .root, + fileSystem: fs, + mirrors: .init() + ) + + let resolution = store.resolvedPackages[identity]! + XCTAssertEqual(resolution.packageRef, .localSourceControl(identity: identity, path: path)) + XCTAssertEqual(resolution.state, .version("1.2.3+debug", revision: revision)) + XCTAssertEqual(resolution.state.description, "1.2.3+debug") + } } func testLoadingSchema1() throws { diff --git a/Tests/WorkspaceTests/SourceControlPackageContainerTests.swift b/Tests/WorkspaceTests/SourceControlPackageContainerTests.swift index b193c501dc5..e558826e4bb 100644 --- a/Tests/WorkspaceTests/SourceControlPackageContainerTests.swift +++ b/Tests/WorkspaceTests/SourceControlPackageContainerTests.swift @@ -324,6 +324,164 @@ final class SourceControlPackageContainerTests: XCTestCase { XCTAssertEqual(v, ["1.0.4-alpha", "1.0.2-dev.2", "1.0.2-dev", "1.0.1", "1.0.0", "1.0.0-beta.1", "1.0.0-alpha.1"]) } + func testMetadataDistinctTagsAreDiscoverable() async throws { + try XCTSkipOnWindows(because: """ + https://github.com/swiftlang/swift-package-manager/issues/8578 + """) + + let fs = InMemoryFileSystem() + try fs.createMockToolchain() + + let repoPath = AbsolutePath.root + let filePath = repoPath.appending("Package.swift") + + let specifier = RepositorySpecifier(path: repoPath) + let repo = InMemoryGitRepository(path: repoPath, fs: fs) + try repo.createDirectory(repoPath, recursive: true) + try repo.writeFileContents(filePath, string: "// swift-tools-version:\(ToolsVersion.current)\n") + try repo.commit() + try repo.tag(name: "1.0.0+debug") + try repo.tag(name: "1.0.0+release") + + let inMemRepoProvider = InMemoryGitRepositoryProvider() + inMemRepoProvider.add(specifier: specifier, repository: repo) + + let p = AbsolutePath.root.appending("repoManager") + try fs.createDirectory(p, recursive: true) + let repositoryManager = RepositoryManager( + fileSystem: fs, + path: p, + provider: inMemRepoProvider, + delegate: MockRepositoryManagerDelegate() + ) + + let provider = try Workspace._init( + fileSystem: fs, + environment: .mockEnvironment, + location: .init(forRootPackage: repoPath, fileSystem: fs), + customHostToolchain: .mockHostToolchain(fs), + customManifestLoader: MockManifestLoader(manifests: [:]), + customRepositoryManager: repositoryManager + ) + + let ref = PackageReference.localSourceControl(identity: PackageIdentity(path: repoPath), path: repoPath) + let container = try await provider.getContainer(for: ref) as! SourceControlPackageContainer + let versions = try await container.versionsDescending() + XCTAssertEqual(versions.count, 2) + XCTAssertTrue(versions.contains("1.0.0+debug")) + XCTAssertTrue(versions.contains("1.0.0+release")) + XCTAssertEqual(container.getTag(for: "1.0.0+debug"), "1.0.0+debug") + XCTAssertEqual(container.getTag(for: "1.0.0+release"), "1.0.0+release") + } + + func testMetadataLiteralTagLookupPrefersExactMatch() async throws { + try XCTSkipOnWindows(because: """ + https://github.com/swiftlang/swift-package-manager/issues/8578 + """) + + let fs = InMemoryFileSystem() + try fs.createMockToolchain() + + let repoPath = AbsolutePath.root + let filePath = repoPath.appending("Package.swift") + + let specifier = RepositorySpecifier(path: repoPath) + let repo = InMemoryGitRepository(path: repoPath, fs: fs) + try repo.createDirectory(repoPath, recursive: true) + + try repo.writeFileContents(filePath, string: "// swift-tools-version:\(ToolsVersion.current)\n") + try repo.commit() + try repo.tag(name: "1.0.0+debug") + + try repo.writeFileContents(filePath, string: "// swift-tools-version:\(ToolsVersion.current)\n// updated\n") + try repo.commit() + try repo.tag(name: "1.0.0+release") + + let inMemRepoProvider = InMemoryGitRepositoryProvider() + inMemRepoProvider.add(specifier: specifier, repository: repo) + + let p = AbsolutePath.root.appending("repoManager") + try fs.createDirectory(p, recursive: true) + let repositoryManager = RepositoryManager( + fileSystem: fs, + path: p, + provider: inMemRepoProvider, + delegate: MockRepositoryManagerDelegate() + ) + + let provider = try Workspace._init( + fileSystem: fs, + environment: .mockEnvironment, + location: .init(forRootPackage: repoPath, fileSystem: fs), + customHostToolchain: .mockHostToolchain(fs), + customManifestLoader: MockManifestLoader(manifests: [:]), + customRepositoryManager: repositoryManager + ) + + let ref = PackageReference.localSourceControl(identity: PackageIdentity(path: repoPath), path: repoPath) + let container = try await provider.getContainer(for: ref) as! SourceControlPackageContainer + let debugTag = try XCTUnwrap(container.getTag(for: "1.0.0+debug")) + let releaseTag = try XCTUnwrap(container.getTag(for: "1.0.0+release")) + XCTAssertEqual(debugTag, "1.0.0+debug") + XCTAssertEqual(releaseTag, "1.0.0+release") + + let debugRevision = try container.getRevision(forTag: debugTag) + let releaseRevision = try container.getRevision(forTag: releaseTag) + XCTAssertNotEqual(debugRevision.identifier, releaseRevision.identifier) + } + + func testPlainTagLookupCoexistingWithMetadataVariants() async throws { + try XCTSkipOnWindows(because: """ + https://github.com/swiftlang/swift-package-manager/issues/8578 + """) + + let fs = InMemoryFileSystem() + try fs.createMockToolchain() + + let repoPath = AbsolutePath.root + let filePath = repoPath.appending("Package.swift") + + let specifier = RepositorySpecifier(path: repoPath) + let repo = InMemoryGitRepository(path: repoPath, fs: fs) + try repo.createDirectory(repoPath, recursive: true) + try repo.writeFileContents(filePath, string: "// swift-tools-version:\(ToolsVersion.current)\n") + try repo.commit() + try repo.tag(name: "1.0.0") + try repo.tag(name: "1.0.0+debug") + + let inMemRepoProvider = InMemoryGitRepositoryProvider() + inMemRepoProvider.add(specifier: specifier, repository: repo) + + let p = AbsolutePath.root.appending("repoManager") + try fs.createDirectory(p, recursive: true) + let repositoryManager = RepositoryManager( + fileSystem: fs, + path: p, + provider: inMemRepoProvider, + delegate: MockRepositoryManagerDelegate() + ) + + let provider = try Workspace._init( + fileSystem: fs, + environment: .mockEnvironment, + location: .init(forRootPackage: repoPath, fileSystem: fs), + customHostToolchain: .mockHostToolchain(fs), + customManifestLoader: MockManifestLoader(manifests: [:]), + customRepositoryManager: repositoryManager + ) + + let ref = PackageReference.localSourceControl(identity: PackageIdentity(path: repoPath), path: repoPath) + let container = try await provider.getContainer(for: ref) as! SourceControlPackageContainer + let versions = try await container.versionsDescending() + + XCTAssertEqual(versions.count, 2) + XCTAssertTrue(versions.contains("1.0.0")) + XCTAssertTrue(versions.contains("1.0.0+debug")) + + XCTAssertEqual(container.getTag(for: "1.0.0"), "1.0.0") + XCTAssertEqual(container.getTag(for: "1.0.0+debug"), "1.0.0+debug") + } + func testSimultaneousVersions() async throws { try XCTSkipOnWindows(because: """ https://github.com/swiftlang/swift-package-manager/issues/8578