From 17ad0e4841688586e3c0e360c89d2c965d66d5af Mon Sep 17 00:00:00 2001 From: Holger Wiedemann Date: Mon, 23 Sep 2024 00:27:20 +0200 Subject: [PATCH] Initial commit. --- .gitignore | 8 + LICENSE.txt | 21 +++ Package.resolved | 15 ++ Package.swift | 38 +++++ README.md | 89 +++++++++++ Sources/EnumCaseLabeling/CaseLabeled.swift | 42 ++++++ .../EnumCaseLabeling/EnumCaseLabeling.swift | 18 +++ .../EnumCaseLabelingMacro.swift | 139 ++++++++++++++++++ .../CaseLabeledTests.swift | 42 ++++++ .../EnumCaseLabelingTests.swift | 58 ++++++++ 10 files changed, 470 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE.txt create mode 100644 Package.resolved create mode 100644 Package.swift create mode 100644 README.md create mode 100644 Sources/EnumCaseLabeling/CaseLabeled.swift create mode 100644 Sources/EnumCaseLabeling/EnumCaseLabeling.swift create mode 100644 Sources/EnumCaseLabelingMacros/EnumCaseLabelingMacro.swift create mode 100644 Tests/EnumCaseLabelingTests/CaseLabeledTests.swift create mode 100644 Tests/EnumCaseLabelingTests/EnumCaseLabelingTests.swift diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d0d122c --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/ +.netrc diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..e9ff711 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Holger Wiedemann + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..9dcdaf4 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "fbff988136e52a81e6f2cde5496a6535651ce4b7339649b7fc2ecc79119926dd", + "pins" : [ + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-syntax.git", + "state" : { + "revision" : "2bc86522d115234d1f588efe2bcb4ce4be8f8b82", + "version" : "510.0.3" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..4a2cabc --- /dev/null +++ b/Package.swift @@ -0,0 +1,38 @@ +// swift-tools-version: 5.10 + +import CompilerPluginSupport +import PackageDescription + +let package = Package( + name: "EnumCaseLabeling", + platforms: [.macOS(.v10_15), .iOS(.v13), .tvOS(.v13), .watchOS(.v6), .macCatalyst(.v13)], + products: [ + .library( + name: "EnumCaseLabeling", + targets: ["EnumCaseLabeling"] + ), + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-syntax.git", from: "510.0.0"), + ], + targets: [ + .macro( + name: "EnumCaseLabelingMacros", + dependencies: [ + .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), + .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), + ] + ), + + .target(name: "EnumCaseLabeling", dependencies: ["EnumCaseLabelingMacros"]), + + .testTarget( + name: "EnumCaseLabelingTests", + dependencies: [ + "EnumCaseLabeling", + "EnumCaseLabelingMacros", + .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"), + ] + ), + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..bda2e42 --- /dev/null +++ b/README.md @@ -0,0 +1,89 @@ +# EnumCaseLabeling + +**EnumCaseLabeling** is an open source package providing macros and types to extend enumerations having cases with associated values. + +## Getting Started + +Swift 5.10 is required as a minimum version. + +To use the `EnumCaseLabeling` library in a SwiftPM project, add the following line to the dependencies in your `Package.swift` file: + +```swift +.package(url: "https://github.com/wiedem/enum-case-labeling", .upToNextMajor(from: "1.0.0")), +``` + +Include `"EnumCaseLabeling"` as a dependency for your executable target: + +```swift +dependencies: [ + .product(name: "EnumCaseLabeling", package: "EnumCaseLabeling"), +] +``` + +## Usage + +Start by importing the module into your Swift code with `import EnumCaseLabeling`. + +### Extend Enumerations with the `CaseLabeled` Macro +Apply the macro `CaseLabeled` to your enumeration: + +```swift +@CaseLabeled +enum MyEnum: Equatable { + case intValue(Int) + case stringValue(string: String) +} +``` + +The macro automatically declares a `CaseLabel` enumeration conforming to the `Equatable` protocol without associated values. +A `caseLabel` property returns a value of `CaseLabel` for each case of the enumeration. + +### Using Case Labels +Case labels of enumeration values can be used to identify values with an identical label, even if their associated values are not identical: +```swift +let value1: MyEnum = .intValue(1) +let value2: MyEnum = .intValue(2) + +// value1 and value2 are not equal because their associated values are not equal ... +print("value1 and value2 are equal: \(value1 == value2)") +// ... but they share a common case label +print("value1 and value2 have a common case label: \(value1.caseLabel == value2.caseLabel)") +``` + +The `CaseLabeled` protocol also provides a convenience operator `~=` for the label comparison: +```swift +print("value1 and value2 have a common case label: \(value1 ~= value2)") +``` + +Enumeration values can also be directly compared with case label values: +```swift +print("value1 is an 'intValue': \(value1 ~= .intValue)") +``` + +This makes it possible, for example, to easily extend collections with methods that make use of the labels: + +```swift +@CaseLabeled +enum MyEnum: Hashable { + case intValue(Int) + case stringValue(string: String) +} + +extension Set where Element: CaseLabeled { + func remove(_ labeled: Element.CaseLabel) -> Self { + filter { + $0.caseLabel != labeled + } + } +} + +let values: Set = [ + .stringValue(string: "Text1"), + .intValue(1), + .intValue(2), + .stringValue(string: "Text2"), +] + +// This removes all enumeration values with the `intValue` label. +let filtered = values.remove(.intValue) +``` diff --git a/Sources/EnumCaseLabeling/CaseLabeled.swift b/Sources/EnumCaseLabeling/CaseLabeled.swift new file mode 100644 index 0000000..422dc97 --- /dev/null +++ b/Sources/EnumCaseLabeling/CaseLabeled.swift @@ -0,0 +1,42 @@ +/// A type that provides a case label. +/// +/// Conformance to this protocol makes a case label available to other APIs that fulfill the [Equatable](https://developer.apple.com/documentation/swift/equatable) protocol. +/// +/// Case labels offer a way of grouping different values of a type and making them comparable. +/// The protocol is primarily intended for use with enumerations having cases with associated types. +/// +/// The `~=` operator can be used to compare types implementing `CaseLabeled`; if the ``caseLabel-swift.property`` value is the same, the operator returns true: +/// +/// ```swift +/// @CaseLabeled +/// enum MyEnum { +/// case `default`, simpleCase +/// case intValue(Int) +/// case stringValue(string: String?) +/// } +/// +/// let value1: MyEnum = .intValue(1) +/// let value2: MyEnum = .intValue(2) +/// +/// if value1 ~= value2 { +/// print("Enum values '\(value1)' and '\(value2)' have a common case label") +/// } +/// ``` +public protocol CaseLabeled { + associatedtype CaseLabel: Equatable + var caseLabel: CaseLabel { get } +} + +public extension CaseLabeled { + static func ~= (lhs: Self, rhs: Self) -> Bool { + lhs.caseLabel == rhs.caseLabel + } + + static func ~= (lhs: Self, rhs: CaseLabel) -> Bool { + lhs.caseLabel == rhs + } + + static func ~= (lhs: CaseLabel, rhs: Self) -> Bool { + lhs == rhs.caseLabel + } +} diff --git a/Sources/EnumCaseLabeling/EnumCaseLabeling.swift b/Sources/EnumCaseLabeling/EnumCaseLabeling.swift new file mode 100644 index 0000000..80409fe --- /dev/null +++ b/Sources/EnumCaseLabeling/EnumCaseLabeling.swift @@ -0,0 +1,18 @@ +/// A macro that implements labels for enum cases and conformance to the CaseLabeled protocol. +/// +/// This macro adds labels to enum cases and conforms the type to the ``CaseLabeled`` protocol. +/// +/// Case labels allow more convenient handling and comparison of enumeration cases with associated values. +/// The following code shows how to add case labels to a custom enum type: +/// +/// ```swift +/// @CaseLabeled +/// enum MyEnum: Hashable, Sendable { +/// case `default`, simpleCase +/// case intValue(Int) +/// case stringValue(string: String?) +/// } +/// ``` +@attached(member, names: arbitrary) +@attached(extension, conformances: CaseLabeled) +public macro CaseLabeled() = #externalMacro(module: "EnumCaseLabelingMacros", type: "EnumCaseLabelingMacro") diff --git a/Sources/EnumCaseLabelingMacros/EnumCaseLabelingMacro.swift b/Sources/EnumCaseLabelingMacros/EnumCaseLabelingMacro.swift new file mode 100644 index 0000000..0e9fa4f --- /dev/null +++ b/Sources/EnumCaseLabelingMacros/EnumCaseLabelingMacro.swift @@ -0,0 +1,139 @@ +import SwiftCompilerPlugin +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros + +public struct EnumCaseLabelingMacro: MemberMacro { + public static func expansion( + of _: AttributeSyntax, + providingMembersOf declaration: some DeclGroupSyntax, + in _: some MacroExpansionContext + ) throws -> [DeclSyntax] { + guard let enumDecl = declaration.as(EnumDeclSyntax.self) else { + return [] + } + + let caseElements = enumDecl.memberBlock.members + .compactMap { $0.decl.as(EnumCaseDeclSyntax.self) } + .map { + $0.elements.map { + EnumCaseElementSyntax(name: $0.name) + } + } + .flatMap { $0 } + + guard caseElements.isEmpty == false else { + return [] + } + + // Create the CaseLabel declaration. + let enumCaseElementList = EnumCaseElementListSyntax.init { + for element in caseElements { + element + } + } + let enumCase = EnumCaseDeclSyntax(elements: enumCaseElementList) + + let inheritanceTypeList = InheritedTypeListSyntax { + InheritedTypeSyntax(type: IdentifierTypeSyntax(name: .identifier("Hashable"))) + InheritedTypeSyntax(type: IdentifierTypeSyntax(name: .identifier("CaseIterable"))) + InheritedTypeSyntax(type: IdentifierTypeSyntax(name: .identifier("Sendable"))) + } + + let labelEnumDecl = EnumDeclSyntax( + name: .identifier("CaseLabel"), + inheritanceClause: .init(inheritedTypes: inheritanceTypeList) + ) { + enumCase + } + + let labelCaseList = caseElements.map { enumCaseElement in + SwitchCaseSyntax( + label: .case(.init( + caseItems: .init { + .init(pattern: ExpressionPatternSyntax( + expression: MemberAccessExprSyntax( + declName: .init(baseName: enumCaseElement.name) + ) + )) + } + )), + statements: .init { + MemberAccessExprSyntax( + declName: .init( + baseName: enumCaseElement.name + ) + ) + } + ) + } + + // Create the caseLabel var declaration. + let labelVarDecl = VariableDeclSyntax( + bindingSpecifier: .keyword(.var), + bindings: .init { + PatternBindingSyntax( + pattern: IdentifierPatternSyntax(identifier: .identifier("caseLabel")), + typeAnnotation: .init( + type: IdentifierTypeSyntax( + name: .identifier("CaseLabel") + ) + ), + accessorBlock: AccessorBlockSyntax( + accessors: .init( + CodeBlockItemListSyntax { + CodeBlockItemSyntax(item: .init(ExpressionStmtSyntax( + expression: SwitchExprSyntax( + subject: DeclReferenceExprSyntax(baseName: .keyword(.self)), + cases: SwitchCaseListSyntax { + for switchCase in labelCaseList { + switchCase + } + } + ) + ))) + } + ) + ) + ) + } + ) + + return [ + DeclSyntax(labelEnumDecl), + DeclSyntax(labelVarDecl), + ] + } +} + +extension EnumCaseLabelingMacro: ExtensionMacro { + public static func expansion( + of _: AttributeSyntax, + attachedTo _: some DeclGroupSyntax, + providingExtensionsOf type: some TypeSyntaxProtocol, + conformingTo protocols: [TypeSyntax], + in _: some MacroExpansionContext + ) throws -> [ExtensionDeclSyntax] { + guard protocols.isEmpty == false else { + return [] + } + + let labeledExtension: DeclSyntax = + """ + extension \(type.trimmed): \(raw: protocols.first!.trimmed) {} + """ + + guard let extensionDecl = labeledExtension.as(ExtensionDeclSyntax.self) else { + return [] + } + + return [extensionDecl] + } +} + +@main +struct EnumCaseLabelingPlugin: CompilerPlugin { + let providingMacros: [Macro.Type] = [ + EnumCaseLabelingMacro.self, + ] +} diff --git a/Tests/EnumCaseLabelingTests/CaseLabeledTests.swift b/Tests/EnumCaseLabelingTests/CaseLabeledTests.swift new file mode 100644 index 0000000..3c93028 --- /dev/null +++ b/Tests/EnumCaseLabelingTests/CaseLabeledTests.swift @@ -0,0 +1,42 @@ +import EnumCaseLabeling +import XCTest + +@CaseLabeled +private enum MyEnum { + case simpleCase + case intValue(Int) + case stringValue(string: String?) +} + +final class CaseLabeledTests: XCTestCase { + func testSameLabelComparisonReturnsTrue() throws { + let value1 = MyEnum.simpleCase + let value2 = MyEnum.simpleCase + XCTAssertTrue(value1 ~= value2) + XCTAssertTrue(value2 ~= value1) + } + + func testSameLabelComparisonWithAssociatedValuesReturnsTrue() { + let value1 = MyEnum.intValue(1) + let value2 = MyEnum.intValue(2) + XCTAssertTrue(value1 ~= value2) + XCTAssertTrue(value2 ~= value1) + } + + func testDifferentLabelComparisonReturnsFalse() { + let value1 = MyEnum.simpleCase + let value2 = MyEnum.intValue(1) + XCTAssertFalse(value1 ~= value2) + XCTAssertFalse(value2 ~= value1) + } + + func testComparisonWithMatchingLabelReturnsTrue() throws { + XCTAssertTrue(MyEnum.intValue(1) ~= .intValue) + XCTAssertTrue(.intValue ~= MyEnum.intValue(1)) + } + + func testComparisonWithNonMatchingLabelReturnsFalse() throws { + XCTAssertFalse(MyEnum.simpleCase ~= .intValue) + XCTAssertFalse(.intValue ~= MyEnum.simpleCase) + } +} diff --git a/Tests/EnumCaseLabelingTests/EnumCaseLabelingTests.swift b/Tests/EnumCaseLabelingTests/EnumCaseLabelingTests.swift new file mode 100644 index 0000000..6d9912c --- /dev/null +++ b/Tests/EnumCaseLabelingTests/EnumCaseLabelingTests.swift @@ -0,0 +1,58 @@ +import EnumCaseLabeling +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros +import SwiftSyntaxMacrosTestSupport +import XCTest + +#if canImport(EnumCaseLabelingMacros) + import EnumCaseLabelingMacros + + let testMacros: [String: Macro.Type] = [ + "CaseLabeled": EnumCaseLabelingMacro.self, + ] +#endif + +final class EnumCaseLabelingTests: XCTestCase { + func testMacroExpansionAddsRequiredCode() throws { + #if canImport(EnumCaseLabelingMacros) + assertMacroExpansion( + """ + @CaseLabeled + enum MyEnum: Equatable, Sendable { + case `default`, simpleCase + case intValue(Int) + case stringValue(string: String?) + } + """, + expandedSource: """ + enum MyEnum: Equatable, Sendable { + case `default`, simpleCase + case intValue(Int) + case stringValue(string: String?) + + enum CaseLabel: Hashable, CaseIterable, Sendable { + case `default`, simpleCase, intValue, stringValue + } + + var caseLabel: CaseLabel { + switch self { + case .`default`: + .`default` + case .simpleCase: + .simpleCase + case .intValue: + .intValue + case .stringValue: + .stringValue + } + } + } + """, + macros: testMacros + ) + #else + throw XCTSkip("macros are only supported when running tests for the host platform") + #endif + } +}