Skip to content

Commit d7f8408

Browse files
committed
Allow package identity to be configurable via dependency injection
Rename PackageIdentityTests to LegacyPackageIdentityTests
1 parent 63481c7 commit d7f8408

File tree

5 files changed

+647
-32
lines changed

5 files changed

+647
-32
lines changed

Sources/PackageModel/PackageIdentity.swift

Lines changed: 274 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,30 @@ import Foundation
1313
import TSCBasic
1414
import TSCUtility
1515

16+
//@warn_unqualified_access
17+
/// When set to `false`,
18+
/// `PackageIdentity` uses the canonical location of package dependencies as its identity.
19+
/// Otherwise, only the last path component is used to identify package dependencies.
20+
public var _useLegacyIdentities: Bool = true {
21+
willSet {
22+
PackageIdentity.provider = newValue ? LegacyPackageIdentity.self : CanonicalPackageIdentity.self
23+
}
24+
}
25+
26+
internal protocol PackageIdentityProvider: LosslessStringConvertible {
27+
init(_ string: String)
28+
}
29+
1630
/// The canonical identifier for a package, based on its source location.
1731
public struct PackageIdentity: LosslessStringConvertible, Hashable {
32+
internal static var provider: PackageIdentityProvider.Type = LegacyPackageIdentity.self
33+
1834
/// A textual representation of this instance.
1935
public let description: String
2036

2137
/// Instantiates an instance of the conforming type from a string representation.
22-
public init(_ url: String) {
23-
self.description = PackageReference.computeDefaultName(fromURL: url).lowercased()
38+
public init(_ string: String) {
39+
self.description = Self.provider.init(string).description
2440
}
2541
}
2642

@@ -56,3 +72,259 @@ extension PackageIdentity: JSONMappable, JSONSerializable {
5672
return .string(self.description)
5773
}
5874
}
75+
76+
// MARK: -
77+
78+
struct LegacyPackageIdentity: PackageIdentityProvider, Equatable {
79+
/// A textual representation of this instance.
80+
public let description: String
81+
82+
/// Instantiates an instance of the conforming type from a string representation.
83+
public init(_ string: String) {
84+
self.description = PackageReference.computeDefaultName(fromURL: string).lowercased()
85+
}
86+
}
87+
88+
/// A canonicalized package identity.
89+
///
90+
/// A package may declare external packages as dependencies in its manifest.
91+
/// Each external package is uniquely identified by the location of its source code.
92+
///
93+
/// An external package dependency may itself have one or more external package dependencies,
94+
/// known as _transitive dependencies_.
95+
/// When multiple packages have dependencies in common,
96+
/// Swift Package Manager determines which version of that package should be used
97+
/// (if any exist that satisfy all specified requirements)
98+
/// in a process called package resolution.
99+
///
100+
/// External package dependencies are located by a URL
101+
/// (which may be an implicit `file://` URL in the form of a file path).
102+
/// For the purposes of package resolution,
103+
/// package URLs are case-insensitive (mona ≍ MONA)
104+
/// and normalization-insensitive (n + ◌̃ ≍ ñ).
105+
/// Swift Package Manager takes additional steps to canonicalize URLs
106+
/// to resolve insignificant differences between URLs.
107+
/// For example,
108+
/// the URLs `https://example.com/Mona/LinkedList` and `[email protected]:mona/linkedlist`
109+
/// are equivalent, in that they both resolve to the same source code repository,
110+
/// despite having different scheme, authority, and path components.
111+
///
112+
/// The `PackageIdentity` type canonicalizes package locations by
113+
/// performing the following operations:
114+
///
115+
/// * Removing the scheme component, if present
116+
/// ```
117+
/// https://example.com/mona/LinkedList → example.com/mona/LinkedList
118+
/// ```
119+
/// * Removing the userinfo component (preceded by `@`), if present:
120+
/// ```
121+
/// [email protected]/mona/LinkedList → example.com/mona/LinkedList
122+
/// ```
123+
/// * Removing the port subcomponent, if present:
124+
/// ```
125+
/// example.com:443/mona/LinkedList → example.com/mona/LinkedList
126+
/// ```
127+
/// * Replacing the colon (`:`) preceding the path component in "`scp`-style" URLs:
128+
/// ```
129+
/// [email protected]:mona/LinkedList.git → example.com/mona/LinkedList
130+
/// ```
131+
/// * Expanding the tilde (`~`) to the provided user, if applicable:
132+
/// ```
133+
/// ssh://[email protected]/~/LinkedList.git → example.com/~mona/LinkedList
134+
/// ```
135+
/// * Removing percent-encoding from the path component, if applicable:
136+
/// ```
137+
/// example.com/mona/%F0%9F%94%97List → example.com/mona/🔗List
138+
/// ```
139+
/// * Removing the `.git` file extension from the path component, if present:
140+
/// ```
141+
/// example.com/mona/LinkedList.git → example.com/mona/LinkedList
142+
/// ```
143+
/// * Removing the trailing slash (`/`) in the path component, if present:
144+
/// ```
145+
/// example.com/mona/LinkedList/ → example.com/mona/LinkedList
146+
/// ```
147+
/// * Removing the fragment component (preceded by `#`), if present:
148+
/// ```
149+
/// example.com/mona/LinkedList#installation → example.com/mona/LinkedList
150+
/// ```
151+
/// * Removing the query component (preceded by `?`), if present:
152+
/// ```
153+
/// example.com/mona/LinkedList?utm_source=forums.swift.org → example.com/mona/LinkedList
154+
/// ```
155+
/// * Adding a leading slash (`/`) for `file://` URLs and absolute file paths:
156+
/// ```
157+
/// file:///Users/mona/LinkedList → /Users/mona/LinkedList
158+
/// ```
159+
struct CanonicalPackageIdentity: PackageIdentityProvider, Equatable {
160+
/// A textual representation of this instance.
161+
public let description: String
162+
163+
/// Instantiates an instance of the conforming type from a string representation.
164+
public init(_ string: String) {
165+
var description = string.precomposedStringWithCanonicalMapping.lowercased()
166+
167+
// Remove the scheme component, if present.
168+
let detectedScheme = description.dropSchemeComponentPrefixIfPresent()
169+
170+
// Remove the userinfo subcomponent (user / password), if present.
171+
if case (let user, _)? = description.dropUserinfoSubcomponentPrefixIfPresent() {
172+
// If a user was provided, perform tilde expansion, if applicable.
173+
description.replaceFirstOccurenceIfPresent(of: "/~/", with: "/~\(user)/")
174+
}
175+
176+
// Remove the port subcomponent, if present.
177+
description.removePortComponentIfPresent()
178+
179+
// Remove the fragment component, if present.
180+
description.removeFragmentComponentIfPresent()
181+
182+
// Remove the query component, if present.
183+
description.removeQueryComponentIfPresent()
184+
185+
// Accomodate "`scp`-style" SSH URLs
186+
if detectedScheme == nil || detectedScheme == "ssh" {
187+
description.replaceFirstOccurenceIfPresent(of: ":", before: description.firstIndex(of: "/"), with: "/")
188+
}
189+
190+
// Split the remaining string into path components,
191+
// filtering out empty path components and removing valid percent encodings.
192+
var components = description.split(omittingEmptySubsequences: true, whereSeparator: isSeparator)
193+
.compactMap { $0.removingPercentEncoding ?? String($0) }
194+
195+
// Remove the `.git` suffix from the last path component.
196+
var lastPathComponent = components.popLast() ?? ""
197+
lastPathComponent.removeSuffixIfPresent(".git")
198+
components.append(lastPathComponent)
199+
200+
description = components.joined(separator: "/")
201+
202+
// Prepend a leading slash for file URLs and paths
203+
if detectedScheme == "file" || string.first.flatMap(isSeparator) ?? false {
204+
description.insert("/", at: description.startIndex)
205+
}
206+
207+
self.description = description
208+
}
209+
}
210+
211+
#if os(Windows)
212+
fileprivate let isSeparator: (Character) -> Bool = { $0 == "/" || $0 == "\\" }
213+
#else
214+
fileprivate let isSeparator: (Character) -> Bool = { $0 == "/" }
215+
#endif
216+
217+
private extension Character {
218+
var isDigit: Bool {
219+
switch self {
220+
case "0", "1", "2", "3", "4", "5", "6", "7", "8", "9":
221+
return true
222+
default:
223+
return false
224+
}
225+
}
226+
227+
var isAllowedInURLScheme: Bool {
228+
return isLetter || self.isDigit || self == "+" || self == "-" || self == "."
229+
}
230+
}
231+
232+
private extension String {
233+
@discardableResult
234+
mutating func removePrefixIfPresent<T: StringProtocol>(_ prefix: T) -> Bool {
235+
guard hasPrefix(prefix) else { return false }
236+
removeFirst(prefix.count)
237+
return true
238+
}
239+
240+
@discardableResult
241+
mutating func removeSuffixIfPresent<T: StringProtocol>(_ suffix: T) -> Bool {
242+
guard hasSuffix(suffix) else { return false }
243+
removeLast(suffix.count)
244+
return true
245+
}
246+
247+
@discardableResult
248+
mutating func dropSchemeComponentPrefixIfPresent() -> String? {
249+
if let rangeOfDelimiter = range(of: "://"),
250+
self[startIndex].isLetter,
251+
self[..<rangeOfDelimiter.lowerBound].allSatisfy({ $0.isAllowedInURLScheme })
252+
{
253+
defer { self.removeSubrange(..<rangeOfDelimiter.upperBound) }
254+
255+
return String(self[..<rangeOfDelimiter.lowerBound])
256+
}
257+
258+
return nil
259+
}
260+
261+
@discardableResult
262+
mutating func dropUserinfoSubcomponentPrefixIfPresent() -> (user: String, password: String?)? {
263+
if let indexOfAtSign = firstIndex(of: "@"),
264+
let indexOfFirstPathComponent = firstIndex(where: isSeparator),
265+
indexOfAtSign < indexOfFirstPathComponent
266+
{
267+
defer { self.removeSubrange(...indexOfAtSign) }
268+
269+
let userinfo = self[..<indexOfAtSign]
270+
var components = userinfo.split(separator: ":", maxSplits: 2, omittingEmptySubsequences: false)
271+
guard components.count > 0 else { return nil }
272+
let user = String(components.removeFirst())
273+
let password = components.last.map(String.init)
274+
275+
return (user, password)
276+
}
277+
278+
return nil
279+
}
280+
281+
@discardableResult
282+
mutating func removePortComponentIfPresent() -> Bool {
283+
if let indexOfFirstPathComponent = firstIndex(where: isSeparator),
284+
let startIndexOfPort = firstIndex(of: ":"),
285+
startIndexOfPort < endIndex,
286+
let endIndexOfPort = self[index(after: startIndexOfPort)...].lastIndex(where: { $0.isDigit }),
287+
endIndexOfPort <= indexOfFirstPathComponent
288+
{
289+
self.removeSubrange(startIndexOfPort ... endIndexOfPort)
290+
return true
291+
}
292+
293+
return false
294+
}
295+
296+
@discardableResult
297+
mutating func removeFragmentComponentIfPresent() -> Bool {
298+
if let index = firstIndex(of: "#") {
299+
self.removeSubrange(index...)
300+
}
301+
302+
return false
303+
}
304+
305+
@discardableResult
306+
mutating func removeQueryComponentIfPresent() -> Bool {
307+
if let index = firstIndex(of: "?") {
308+
self.removeSubrange(index...)
309+
}
310+
311+
return false
312+
}
313+
314+
@discardableResult
315+
mutating func replaceFirstOccurenceIfPresent<T: StringProtocol, U: StringProtocol>(
316+
of string: T,
317+
before index: Index? = nil,
318+
with replacement: U
319+
) -> Bool {
320+
guard let range = range(of: string) else { return false }
321+
322+
if let index = index, range.lowerBound >= index {
323+
return false
324+
}
325+
326+
self.replaceSubrange(range, with: replacement)
327+
return true
328+
}
329+
}
330+

0 commit comments

Comments
 (0)