Skip to content

Add nested PackgeIdentity.Scope and PackageIdentity.Name types #3658

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Aug 13, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 157 additions & 0 deletions Sources/PackageModel/PackageIdentity.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we use regex patterns to do the validation?

Copy link
Contributor Author

@mattt mattt Aug 11, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could, but I can think of a few drawbacks to that approach:

  • Less precise errors (a regex match is all or nothing, so you can't know why it's invalid)
  • Somewhat worse performance (a regex engine is slower than Swift string APIs)
  • More coupling (it creates a dependency on a regex engine, which could complicate cross-platform deployment)

On the plus side, a regex is a more obvious representation than string manipulation code. But with comprehensive testing, that's probably less of a concern.

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
Expand Down
86 changes: 86 additions & 0 deletions Tests/PackageModelTests/PackageIdentityNameTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
65 changes: 65 additions & 0 deletions Tests/PackageModelTests/PackageIdentityScopeTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}