diff --git a/Sources/PackageModel/PackageIdentity.swift b/Sources/PackageModel/PackageIdentity.swift index 0c4dd2e026b..53e6779b97e 100644 --- a/Sources/PackageModel/PackageIdentity.swift +++ b/Sources/PackageModel/PackageIdentity.swift @@ -94,6 +94,163 @@ extension PackageIdentity: JSONMappable, JSONSerializable { // MARK: - +extension PackageIdentity { + /// Provides a namespace for related packages within a package registry. + public struct Scope: LosslessStringConvertible, Hashable, Equatable, Comparable, ExpressibleByStringLiteral { + public let description: String + + public init(validating description: String) throws { + guard !description.isEmpty else { + throw StringError("The minimum length of a package scope is 1 character.") + } + + guard description.count <= 39 else { + throw StringError("The maximum length of a package scope is 39 characters.") + } + + for (index, character) in zip(description.indices, description) { + guard character.isASCII, + character.isLetter || + character.isNumber || + character == "-" + else { + throw StringError("A package scope consists of alphanumeric characters and hyphens.") + } + + if character.isPunctuation { + switch (index, description.index(after: index)) { + case (description.startIndex, _): + throw StringError("Hyphens may not occur at the beginning of a scope.") + case (_, description.endIndex): + throw StringError("Hyphens may not occur at the end of a scope.") + case (_, let nextIndex) where description[nextIndex].isPunctuation: + throw StringError("Hyphens may not occur consecutively within a scope.") + default: + continue + } + } + } + + self.description = description + } + + public init?(_ description: String) { + guard let scope = try? Scope(validating: description) else { return nil } + self = scope + } + + // MARK: - Equatable & Comparable + + private func compare(to other: Scope) -> ComparisonResult { + // Package scopes are case-insensitive (for example, `mona` ≍ `MONA`). + return self.description.caseInsensitiveCompare(other.description) + } + + public static func == (lhs: Scope, rhs: Scope) -> Bool { + return lhs.compare(to: rhs) == .orderedSame + } + + public static func < (lhs: Scope, rhs: Scope) -> Bool { + return lhs.compare(to: rhs) == .orderedAscending + } + + public static func > (lhs: Scope, rhs: Scope) -> Bool { + return lhs.compare(to: rhs) == .orderedDescending + } + + // MARK: - Hashable + + public func hash(into hasher: inout Hasher) { + hasher.combine(description.lowercased()) + } + + // MARK: - ExpressibleByStringLiteral + + public init(stringLiteral value: StringLiteralType) { + try! self.init(validating: value) + } + } + + /// Uniquely identifies a package in a scope + public struct Name: LosslessStringConvertible, Hashable, Equatable, Comparable, ExpressibleByStringLiteral { + public let description: String + + public init(validating description: String) throws { + guard !description.isEmpty else { + throw StringError("The minimum length of a package name is 1 character.") + } + + guard description.count <= 100 else { + throw StringError("The maximum length of a package name is 100 characters.") + } + + for (index, character) in zip(description.indices, description) { + guard character.isASCII, + character.isLetter || + character.isNumber || + character == "-" || + character == "_" + else { + throw StringError("A package name consists of alphanumeric characters, underscores, and hyphens.") + } + + if character.isPunctuation { + switch (index, description.index(after: index)) { + case (description.startIndex, _): + throw StringError("Hyphens and underscores may not occur at the beginning of a name.") + case (_, description.endIndex): + throw StringError("Hyphens and underscores may not occur at the end of a name.") + case (_, let nextIndex) where description[nextIndex].isPunctuation: + throw StringError("Hyphens and underscores may not occur consecutively within a name.") + default: + continue + } + } + } + + self.description = description + } + + public init?(_ description: String) { + guard let name = try? Name(validating: description) else { return nil } + self = name + } + + // MARK: - Equatable & Comparable + + private func compare(to other: Name) -> ComparisonResult { + // Package scopes are case-insensitive (for example, `LinkedList` ≍ `LINKEDLIST`). + return self.description.caseInsensitiveCompare(other.description) + } + + public static func == (lhs: Name, rhs: Name) -> Bool { + return lhs.compare(to: rhs) == .orderedSame + } + + public static func < (lhs: Name, rhs: Name) -> Bool { + return lhs.compare(to: rhs) == .orderedAscending + } + + public static func > (lhs: Name, rhs: Name) -> Bool { + return lhs.compare(to: rhs) == .orderedDescending + } + + // MARK: - Hashable + + public func hash(into hasher: inout Hasher) { + hasher.combine(description.lowercased()) + } + + // MARK: - ExpressibleByStringLiteral + + public init(stringLiteral value: StringLiteralType) { + try! self.init(validating: value) + } + } +} + +// MARK: - + struct LegacyPackageIdentity: PackageIdentityProvider, Equatable { /// A textual representation of this instance. public let description: String diff --git a/Tests/PackageModelTests/PackageIdentityNameTests.swift b/Tests/PackageModelTests/PackageIdentityNameTests.swift new file mode 100644 index 00000000000..71ac640d573 --- /dev/null +++ b/Tests/PackageModelTests/PackageIdentityNameTests.swift @@ -0,0 +1,86 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See http://swift.org/LICENSE.txt for license information + See http://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +import XCTest +import Basics +import TSCBasic +import PackageModel + +class PackageIdentityNameTests: XCTestCase { + func testValidNames() throws { + XCTAssertNoThrow(try PackageIdentity.Name(validating: "LinkedList")) + XCTAssertNoThrow(try PackageIdentity.Name(validating: "Linked-List")) + XCTAssertNoThrow(try PackageIdentity.Name(validating: "Linked_List")) + XCTAssertNoThrow(try PackageIdentity.Name(validating: "A")) + XCTAssertNoThrow(try PackageIdentity.Name(validating: "1")) + XCTAssertNoThrow(try PackageIdentity.Name(validating: String(repeating: "A", count: 100))) + } + + func testInvalidNames() throws { + XCTAssertThrowsError(try PackageIdentity.Name(validating: "")) { error in + XCTAssertEqual(error.localizedDescription, "The minimum length of a package name is 1 character.") + } + + XCTAssertThrowsError(try PackageIdentity.Name(validating: String(repeating: "a", count: 101))) { error in + XCTAssertEqual(error.localizedDescription, "The maximum length of a package name is 100 characters.") + } + + XCTAssertThrowsError(try PackageIdentity.Name(validating: "!")) { error in + XCTAssertEqual(error.localizedDescription, "A package name consists of alphanumeric characters, underscores, and hyphens.") + } + + XCTAssertThrowsError(try PackageIdentity.Name(validating: "あ")) { error in + XCTAssertEqual(error.localizedDescription, "A package name consists of alphanumeric characters, underscores, and hyphens.") + } + + XCTAssertThrowsError(try PackageIdentity.Name(validating: "🧍")) { error in + XCTAssertEqual(error.localizedDescription, "A package name consists of alphanumeric characters, underscores, and hyphens.") + } + + XCTAssertThrowsError(try PackageIdentity.Name(validating: "-a")) { error in + XCTAssertEqual(error.localizedDescription, "Hyphens and underscores may not occur at the beginning of a name.") + } + + XCTAssertThrowsError(try PackageIdentity.Name(validating: "_a")) { error in + XCTAssertEqual(error.localizedDescription, "Hyphens and underscores may not occur at the beginning of a name.") + } + + XCTAssertThrowsError(try PackageIdentity.Name(validating: "a-")) { error in + XCTAssertEqual(error.localizedDescription, "Hyphens and underscores may not occur at the end of a name.") + } + + XCTAssertThrowsError(try PackageIdentity.Name(validating: "a_")) { error in + XCTAssertEqual(error.localizedDescription, "Hyphens and underscores may not occur at the end of a name.") + } + + XCTAssertThrowsError(try PackageIdentity.Name(validating: "a_-a")) { error in + XCTAssertEqual(error.localizedDescription, "Hyphens and underscores may not occur consecutively within a name.") + } + + XCTAssertThrowsError(try PackageIdentity.Name(validating: "a-_a")) { error in + XCTAssertEqual(error.localizedDescription, "Hyphens and underscores may not occur consecutively within a name.") + } + + XCTAssertThrowsError(try PackageIdentity.Name(validating: "a--a")) { error in + XCTAssertEqual(error.localizedDescription, "Hyphens and underscores may not occur consecutively within a name.") + } + + XCTAssertThrowsError(try PackageIdentity.Name(validating: "a__a")) { error in + XCTAssertEqual(error.localizedDescription, "Hyphens and underscores may not occur consecutively within a name.") + } + } + + func testNamesAreCaseInsensitive() throws { + let lowercase: PackageIdentity.Name = "linkedlist" + let uppercase: PackageIdentity.Name = "LINKEDLIST" + + XCTAssertEqual(lowercase, uppercase) + } +} diff --git a/Tests/PackageModelTests/PackageIdentityScopeTests.swift b/Tests/PackageModelTests/PackageIdentityScopeTests.swift new file mode 100644 index 00000000000..c3ad3513cce --- /dev/null +++ b/Tests/PackageModelTests/PackageIdentityScopeTests.swift @@ -0,0 +1,65 @@ +/* + This source file is part of the Swift.org open source project + + Copyright (c) 2021 Apple Inc. and the Swift project authors + Licensed under Apache License v2.0 with Runtime Library Exception + + See http://swift.org/LICENSE.txt for license information + See http://swift.org/CONTRIBUTORS.txt for Swift project authors +*/ + +import XCTest +import Basics +import TSCBasic +import PackageModel + +class PackageIdentityScopeTests: XCTestCase { + func testValidScopes() throws { + XCTAssertNoThrow(try PackageIdentity.Scope(validating: "mona")) + XCTAssertNoThrow(try PackageIdentity.Scope(validating: "m-o-n-a")) + XCTAssertNoThrow(try PackageIdentity.Scope(validating: "a")) + XCTAssertNoThrow(try PackageIdentity.Scope(validating: "1")) + XCTAssertNoThrow(try PackageIdentity.Scope(validating: String(repeating: "a", count: 39))) + } + + func testInvalidScopes() throws { + XCTAssertThrowsError(try PackageIdentity.Scope(validating: "")) { error in + XCTAssertEqual(error.localizedDescription, "The minimum length of a package scope is 1 character.") + } + + XCTAssertThrowsError(try PackageIdentity.Scope(validating: String(repeating: "a", count: 100))) { error in + XCTAssertEqual(error.localizedDescription, "The maximum length of a package scope is 39 characters.") + } + + XCTAssertThrowsError(try PackageIdentity.Scope(validating: "!")) { error in + XCTAssertEqual(error.localizedDescription, "A package scope consists of alphanumeric characters and hyphens.") + } + + XCTAssertThrowsError(try PackageIdentity.Scope(validating: "あ")) { error in + XCTAssertEqual(error.localizedDescription, "A package scope consists of alphanumeric characters and hyphens.") + } + + XCTAssertThrowsError(try PackageIdentity.Scope(validating: "🧍")) { error in + XCTAssertEqual(error.localizedDescription, "A package scope consists of alphanumeric characters and hyphens.") + } + + XCTAssertThrowsError(try PackageIdentity.Scope(validating: "-a")) { error in + XCTAssertEqual(error.localizedDescription, "Hyphens may not occur at the beginning of a scope.") + } + + XCTAssertThrowsError(try PackageIdentity.Scope(validating: "a-")) { error in + XCTAssertEqual(error.localizedDescription, "Hyphens may not occur at the end of a scope.") + } + + XCTAssertThrowsError(try PackageIdentity.Scope(validating: "a--a")) { error in + XCTAssertEqual(error.localizedDescription, "Hyphens may not occur consecutively within a scope.") + } + } + + func testScopesAreCaseInsensitive() throws { + let lowercase: PackageIdentity.Scope = "mona" + let uppercase: PackageIdentity.Scope = "MONA" + + XCTAssertEqual(lowercase, uppercase) + } +}