diff --git a/Package.swift b/Package.swift index e8b0b98..67267ee 100644 --- a/Package.swift +++ b/Package.swift @@ -4,7 +4,7 @@ import CompilerPluginSupport import PackageDescription let package = Package( - name: "StaticMemberIterable", + name: "swift-iterable-macros", platforms: [ .iOS(.v13), .macOS(.v10_15), @@ -13,17 +13,49 @@ let package = Package( .watchOS(.v6), ], products: [ + .library(name: "IterableMacros", targets: ["IterableMacros"]), .library(name: "StaticMemberIterable", targets: ["StaticMemberIterable"]), + .library(name: "CaseIterable", targets: ["CaseIterable"]), ], targets: [ - .target(name: "StaticMemberIterable", dependencies: ["StaticMemberIterableMacro"]), + .target( + name: "IterableMacros", + dependencies: [ + "StaticMemberIterable", + "CaseIterable", + ], + ), + + .target( + name: "StaticMemberIterable", + dependencies: [ + "IterableSupport", + "StaticMemberIterableMacro", + ], + ), + + .target( + name: "CaseIterable", + dependencies: [ + "IterableSupport", + "CaseIterableMacro", + ], + ), .macro( name: "StaticMemberIterableMacro", dependencies: [ .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), - ] + ], + ), + + .macro( + name: "CaseIterableMacro", + dependencies: [ + .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), + .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), + ], ), .testTarget( @@ -35,9 +67,23 @@ let package = Package( // For some reason, with Swift Syntax prebuilts enabled, we need to depend on SwiftCompilerPlugin here to work around error: // Compilation search paths unable to resolve module dependency: 'SwiftCompilerPlugin' .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), - ] + ], ), - ] + + .testTarget( + name: "CaseIterableTests", + dependencies: [ + "CaseIterable", + "CaseIterableMacro", + .product(name: "MacroTesting", package: "swift-macro-testing"), + // For some reason, with Swift Syntax prebuilts enabled, we need to depend on SwiftCompilerPlugin here to work around error: + // Compilation search paths unable to resolve module dependency: 'SwiftCompilerPlugin' + .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), + ], + ), + + .target(name: "IterableSupport"), + ], ) package.dependencies += [ diff --git a/README.md b/README.md index def6efb..ecd3797 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,9 @@ -# StaticMemberIterable +# swift-iterable-macros -StaticMemberIterable is a Swift macro that synthesizes collections describing every `static let` defined in a struct, enum, or class. +swift-iterable-macros hosts Swift macros that generate iterable collections for your types: + +- `@StaticMemberIterable` synthesizes collections describing every `static let` defined in a struct, enum, or class. +- `@CaseIterable` mirrors Swift’s `CaseIterable` but keeps a case’s name, value, and presentation metadata. This is handy for building fixtures, demo data, menus, or anywhere you want a single source of truth for a handful of well-known static members. @@ -9,14 +12,21 @@ This is handy for building fixtures, demo data, menus, or anywhere you want a si Add the dependency and product to your `Package.swift`: ```swift -.package(url: "https://github.com/davdroman/StaticMemberIterable", from: "0.1.0"), +.package(url: "https://github.com/davdroman/swift-iterable-macros", from: "0.2.0"), +``` + +```swift +.product(name: "IterableMacros", package: "swift-iterable-macros"), ``` +`IterableMacros` re-exports both modules. If you only need one macro, depend on it explicitly instead: + ```swift -.product(name: "StaticMemberIterable", package: "StaticMemberIterable"), +.product(name: "StaticMemberIterable", package: "swift-iterable-macros"), +.product(name: "CaseIterable", package: "swift-iterable-macros"), ``` -## Usage +## Static members (`@StaticMemberIterable`) ```swift import StaticMemberIterable @@ -29,7 +39,7 @@ enum ColorPalette { static let stardust: Color = Color(red: 0.68, green: 0.51, blue: 0.78) } -ColorPalette.allStaticMembers.map(\.value) // [.orange, .indigo, .purple] as [Color] +ColorPalette.allStaticMembers.map(\.value) // [Color(red: 1.00, ...), ...] ColorPalette.allStaticMembers.map(\.title) // ["Sunrise", "Moonlight", "Stardust"] ColorPalette.allStaticMembers.map(\.keyPath) // [\ColorPalette.sunrise, ...] as [KeyPath] ``` @@ -40,6 +50,7 @@ Each synthesized entry is a `StaticMember`: an `Identifiable` ```swift ForEach(ColorPalette.allStaticMembers) { $color in + let color = $color.value RoundedRectangle(cornerRadius: 12) .fill(color) .overlay(Text($color.title)) @@ -56,6 +67,26 @@ ForEach(ColorPalette.allStaticMembers) { $color in Because it is a property wrapper, you can also project (`$member`) when you use it on your own properties, and `Identifiable` conformance makes it slot neatly into `ForEach`. +## Enum cases (`@CaseIterable`) + +```swift +import CaseIterable + +@CaseIterable +enum MenuSection { + case breakfast + case lunch + case dinner +} + +ForEach(MenuSection.allCases) { $section in + Text($section.title) + .tag($section.id) +} +``` + +`@CaseIterable` produces an explicit `allCases: [CaseOf]`. `CaseOf` is also a property wrapper, exposing the case name, a title-cased variant, the enum value, and a stable `id` derived from the name. + ### Access control Need public-facing lists? Pass the desired access modifier: @@ -63,6 +94,9 @@ Need public-facing lists? Pass the desired access modifier: ```swift @StaticMemberIterable(.public) struct Coffee { ... } + +@CaseIterable(.public) +enum MenuSection { ... } ``` Supported modifiers: diff --git a/Sources/CaseIterable/CaseIterable.swift b/Sources/CaseIterable/CaseIterable.swift new file mode 100644 index 0000000..0354497 --- /dev/null +++ b/Sources/CaseIterable/CaseIterable.swift @@ -0,0 +1,18 @@ +@attached( + member, + names: named(allCases), named(subscript(dynamicMember:)) +) +public macro CaseIterable( + _ access: CaseIterableAccess? = nil, +) = #externalMacro( + module: "CaseIterableMacro", + type: "CaseIterableMacro", +) + +public enum CaseIterableAccess { + case `public` + case `internal` + case `package` + case `fileprivate` + case `private` +} diff --git a/Sources/CaseIterable/CaseOf.swift b/Sources/CaseIterable/CaseOf.swift new file mode 100644 index 0000000..52212fe --- /dev/null +++ b/Sources/CaseIterable/CaseOf.swift @@ -0,0 +1,28 @@ +import IterableSupport + +@propertyWrapper +public struct CaseOf { + public let name: String + + private let storage: Enum + + public var wrappedValue: Enum { storage } + public var projectedValue: CaseOf { self } + public var value: Enum { storage } + public var title: String { name.memberIdentifierTitle() } + + public init(name: String, value: Enum) { + self.name = name + self.storage = value + } + + public init(projectedValue: CaseOf) { + self.init(name: projectedValue.name, value: projectedValue.value) + } +} + +extension CaseOf: Identifiable { + public var id: String { name } +} + +extension CaseOf: @unchecked Sendable where Enum: Sendable {} diff --git a/Sources/CaseIterableMacro/CaseIterableMacro.swift b/Sources/CaseIterableMacro/CaseIterableMacro.swift new file mode 100644 index 0000000..eba0d9f --- /dev/null +++ b/Sources/CaseIterableMacro/CaseIterableMacro.swift @@ -0,0 +1,379 @@ +import Foundation +import SwiftDiagnostics +import SwiftSyntax +import SwiftSyntaxMacros + +struct CaseIterableMacro: MemberMacro { + static func expansion( + of attribute: AttributeSyntax, + providingMembersOf declaration: some DeclGroupSyntax, + conformingTo protocols: [TypeSyntax], + in context: some MacroExpansionContext, + ) throws -> [DeclSyntax] { + let options = AttributeOptions(attribute: attribute) + + guard let enumDecl = declaration.as(EnumDeclSyntax.self) else { + throw DiagnosticsError( + diagnostics: [ + Diagnostic(node: Syntax(attribute), message: NotAnEnumError()), + ], + ) + } + + let access = AccessSpecifier(keyword: options.accessModifier) + + let conflictingMembers = CaseEmitter.synthesizedMemberNames + .filter { enumDecl.declaresMember(named: $0) } + + guard conflictingMembers.isEmpty else { + throw DiagnosticsError( + diagnostics: conflictingMembers.map { + Diagnostic( + node: Syntax(attribute), + message: ConflictingMemberError(memberName: $0), + ) + }, + ) + } + + let caseElements = enumDecl.caseElements + let unsupportedCases = enumDecl.associatedValueCases + + if unsupportedCases.isEmpty == false { + throw DiagnosticsError( + diagnostics: unsupportedCases.map { + Diagnostic( + node: Syntax($0), + message: AssociatedValueCaseError(caseName: $0.name.text.trimmingBackticks()), + ) + }, + ) + } + + guard caseElements.isEmpty == false else { + context.diagnose( + Diagnostic( + node: Syntax(attribute), + message: NoEnumCasesWarning(), + ), + ) + return [] + } + + let hasDynamicMemberLookup = enumDecl.hasDynamicMemberLookupAttribute + + let emitter = CaseEmitter( + access: access, + containerType: enumDecl.declaredTypeName, + cases: caseElements.map(EnumCaseInfo.init), + dynamicMemberAccess: hasDynamicMemberLookup ? enumDecl.propertiesStructAccessSpecifier : nil, + ) + + return emitter.makeDeclarations() + } +} + +// MARK: Diagnostics + +struct NotAnEnumError: DiagnosticMessage { + var message: String { + "`@CaseIterable` only works on enums" + } + + var diagnosticID: MessageID { + .init(domain: "CaseIterableMacro", id: "NotAnEnumError") + } + + var severity: DiagnosticSeverity { .error } +} + +struct NoEnumCasesWarning: DiagnosticMessage { + var message: String { + "'@CaseIterable' does not generate members when there are no enum cases" + } + + var diagnosticID: MessageID { + .init(domain: "CaseIterableMacro", id: "NoEnumCasesWarning") + } + + var severity: DiagnosticSeverity { .warning } +} + +struct AssociatedValueCaseError: DiagnosticMessage { + let caseName: String + + var message: String { + "'@CaseIterable' does not support cases with associated values ('\(caseName)')" + } + + var diagnosticID: MessageID { + .init(domain: "CaseIterableMacro", id: "AssociatedValueCaseError") + } + + var severity: DiagnosticSeverity { .error } +} + +struct ConflictingMemberError: DiagnosticMessage { + let memberName: String + + var message: String { + "'@CaseIterable' cannot generate '\(memberName)' because it already exists" + } + + var diagnosticID: MessageID { + .init(domain: "CaseIterableMacro", id: "ConflictingMemberError") + } + + var severity: DiagnosticSeverity { .error } +} + +// MARK: Helpers + +struct CaseEmitter { + let access: AccessSpecifier + let containerType: String + let cases: [EnumCaseInfo] + let dynamicMemberAccess: AccessSpecifier? + + static let synthesizedMemberNames = [ + "allCases", + ] + + func makeDeclarations() -> [DeclSyntax] { + let entries = cases + .map(\.initializer) + .joined(separator: ",\n") + + let allCasesDecl: DeclSyntax = + """ + \(raw: access.prefix)static let allCases: [CaseOf<\(raw: containerType)>] = [ + \(raw: entries) + ] + """ + + var declarations: [DeclSyntax] = [allCasesDecl] + + if let dynamicMemberAccess { + let subscriptDecl: DeclSyntax = + """ + \(raw: dynamicMemberAccess.prefix)subscript(dynamicMember keyPath: KeyPath) -> T { + properties[keyPath: keyPath] + } + """ + declarations.append(subscriptDecl) + } + + return declarations + } +} + +struct EnumCaseInfo { + let reference: String + let literal: String + + init(element: EnumCaseElementSyntax) { + let identifierText = element.name.text.trimmingCharacters(in: .whitespacesAndNewlines) + self.reference = identifierText + self.literal = "\"\(identifierText.trimmingBackticks())\"" + } + + var initializer: String { + """ + CaseOf( + name: \(literal), + value: .\(reference) + ) + """ + } +} + +struct AccessSpecifier { + let prefix: String + + init(keyword: String?) { + self.prefix = keyword.map { "\($0) " } ?? "" + } +} + +struct AttributeOptions { + let accessModifier: String? + + init(attribute: AttributeSyntax) { + var access: String? + + if case let .argumentList(arguments)? = attribute.arguments { + for argument in arguments { + if access == nil { + access = accessKeyword(from: argument.expression) + } + } + } + + self.accessModifier = access + } +} + +// MARK: Syntax helpers + +extension EnumDeclSyntax { + var caseElements: [EnumCaseElementSyntax] { + memberBlock.members.flatMap(\.simpleEnumCaseElements) + } + + var associatedValueCases: [EnumCaseElementSyntax] { + memberBlock.members.flatMap(\.associatedValueEnumCaseElements) + } + + var declaredTypeName: String { + var name = self.name.text + + if let generics = genericParameterClause, !generics.parameters.isEmpty { + let parameters = generics.parameters + .map(\.name.text) + .joined(separator: ", ") + name += "<\(parameters)>" + } + + return name + } + + func declaresMember(named name: String) -> Bool { + memberBlock.members.contains { $0.declaresVariable(named: name) } + } + + var propertiesStructAccessSpecifier: AccessSpecifier? { + guard let properties = propertiesStruct else { + return nil + } + return AccessSpecifier(keyword: properties.modifiers.accessModifierKeyword) + } + + var hasDynamicMemberLookupAttribute: Bool { + attributes.containsAttribute(named: "dynamicMemberLookup") + } + + private var propertiesStruct: StructDeclSyntax? { + for member in memberBlock.members { + if let structDecl = member.decl.as(StructDeclSyntax.self), + structDecl.name.text.trimmingBackticks() == "Properties" + { + return structDecl + } + } + return nil + } +} + +extension MemberBlockItemSyntax { + var simpleEnumCaseElements: [EnumCaseElementSyntax] { + guard + let enumCase = decl.as(EnumCaseDeclSyntax.self) + else { + return [] + } + + return enumCase.elements.compactMap { element in + element.parameterClause == nil ? element : nil + } + } + + var associatedValueEnumCaseElements: [EnumCaseElementSyntax] { + guard + let enumCase = decl.as(EnumCaseDeclSyntax.self) + else { + return [] + } + + return enumCase.elements.compactMap { element in + element.parameterClause == nil ? nil : element + } + } + + func declaresVariable(named name: String) -> Bool { + guard let variable = decl.as(VariableDeclSyntax.self) else { + return false + } + + return variable.declaredNames.contains(name) + } +} + +extension VariableDeclSyntax { + var declaredNames: [String] { + bindings.compactMap { + $0.pattern.as(IdentifierPatternSyntax.self)?.identifier.text.trimmingBackticks() + } + } +} + +func accessKeyword(from expr: ExprSyntax) -> String? { + if let member = expr.as(MemberAccessExprSyntax.self) { + return member.declName.baseName.text + } + return nil +} + +extension String { + func trimmingBackticks() -> String { + guard hasPrefix("`"), hasSuffix("`"), count >= 2 else { + return self + } + return String(dropFirst().dropLast()) + } +} + +extension SyntaxProtocol { + var trimmedDescription: String { + self.trimmed.description + } +} + +extension AttributeListSyntax { + func containsAttribute(named name: String) -> Bool { + for attribute in self { + guard let attribute = attribute.as(AttributeSyntax.self) else { + continue + } + + if attribute.attributeName.trimmedDescription == name { + return true + } + } + return false + } +} + +extension DeclModifierListSyntax { + var accessModifierKeyword: String? { + for modifier in self { + if let keyword = modifier.accessKeywordText { + return keyword + } + } + return nil + } +} + +extension DeclModifierSyntax { + var accessKeywordText: String? { + if case let .keyword(keyword) = name.tokenKind { + switch keyword { + case .public: + return "public" + case .package: + return "package" + case .internal: + return "internal" + case .fileprivate: + return "fileprivate" + case .private: + return "private" + case .open: + return "public" + default: + return nil + } + } + return nil + } +} diff --git a/Sources/CaseIterableMacro/Plugin.swift b/Sources/CaseIterableMacro/Plugin.swift new file mode 100644 index 0000000..64221bb --- /dev/null +++ b/Sources/CaseIterableMacro/Plugin.swift @@ -0,0 +1,11 @@ +import SwiftCompilerPlugin +import SwiftSyntaxMacros + +@main +struct CaseIterablePlugin: CompilerPlugin { + var providingMacros: [any Macro.Type] { + [ + CaseIterableMacro.self, + ] + } +} diff --git a/Sources/IterableMacros/Exports.swift b/Sources/IterableMacros/Exports.swift new file mode 100644 index 0000000..5a72ba6 --- /dev/null +++ b/Sources/IterableMacros/Exports.swift @@ -0,0 +1,2 @@ +@_exported import CaseIterable +@_exported import StaticMemberIterable diff --git a/Sources/IterableSupport/MemberIdentifierTitle.swift b/Sources/IterableSupport/MemberIdentifierTitle.swift new file mode 100644 index 0000000..f2d1376 --- /dev/null +++ b/Sources/IterableSupport/MemberIdentifierTitle.swift @@ -0,0 +1,94 @@ +import Foundation + +extension String { + package func memberIdentifierTitle() -> String { + let words = memberIdentifierWords() + guard !words.isEmpty else { return self } + return words + .map { word in + if word == word.uppercased() { + return word + } + + guard let first = word.first else { return word } + let remainder = word.dropFirst().lowercased() + return String(first).uppercased() + remainder + } + .joined(separator: " ") + } + + package func memberIdentifierWords() -> [String] { + guard !isEmpty else { return [] } + + var words: [String] = [] + var current = "" + let characters = Array(self) + + func flush() { + if !current.isEmpty { + words.append(current) + current.removeAll(keepingCapacity: true) + } + } + + for index in characters.indices { + let character = characters[index] + + if character.isWordSeparator { + flush() + continue + } + + if index > 0 { + let previous = characters[index - 1] + let next = index + 1 < characters.count ? characters[index + 1] : nil + if character.shouldInsertBreak(before: previous, next: next) { + flush() + } + } + + current.append(character) + } + + flush() + + return words + } +} + +extension Character { + package var isLetter: Bool { + unicodeScalars.allSatisfy(CharacterSet.letters.contains) + } + + package var isUppercaseLetter: Bool { + unicodeScalars.allSatisfy(CharacterSet.uppercaseLetters.contains) + } + + package var isLowercaseLetter: Bool { + unicodeScalars.allSatisfy(CharacterSet.lowercaseLetters.contains) + } + + package var isNumber: Bool { + unicodeScalars.allSatisfy(CharacterSet.decimalDigits.contains) + } + + package var isWordSeparator: Bool { + self == "_" || self == "-" || self == " " + } + + package func shouldInsertBreak(before previous: Character, next: Character?) -> Bool { + switch true { + case previous.isLowercaseLetter && isUppercaseLetter: + true + case previous.isLowercaseLetter && isNumber: + true + case previous.isNumber && isLetter: + true + case previous.isUppercaseLetter && isUppercaseLetter && (next?.isLowercaseLetter ?? false): + true + default: + false + } + } +} diff --git a/Sources/StaticMemberIterable/StaticMember.swift b/Sources/StaticMemberIterable/StaticMember.swift index 0378bef..cbe9771 100644 --- a/Sources/StaticMemberIterable/StaticMember.swift +++ b/Sources/StaticMemberIterable/StaticMember.swift @@ -1,4 +1,5 @@ import Foundation +import IterableSupport public typealias StaticMemberOf = StaticMember @@ -36,96 +37,3 @@ public struct StaticMember: Identifiable { } extension StaticMember: @unchecked Sendable where Value: Sendable {} - -extension String { - fileprivate func memberIdentifierTitle() -> String { - let words = memberIdentifierWords() - guard !words.isEmpty else { return self } - return words - .map { word in - if word == word.uppercased() { - return word - } - - guard let first = word.first else { return word } - let remainder = word.dropFirst().lowercased() - return String(first).uppercased() + remainder - } - .joined(separator: " ") - } - - private func memberIdentifierWords() -> [String] { - guard !isEmpty else { return [] } - - var words: [String] = [] - var current = "" - let characters = Array(self) - - func flush() { - if !current.isEmpty { - words.append(current) - current.removeAll(keepingCapacity: true) - } - } - - for index in characters.indices { - let character = characters[index] - - if character.isWordSeparator { - flush() - continue - } - - if index > 0 { - let previous = characters[index - 1] - let next = index + 1 < characters.count ? characters[index + 1] : nil - if character.shouldInsertBreak(before: previous, next: next) { - flush() - } - } - - current.append(character) - } - - flush() - - return words - } -} - -extension Character { - private var isLetter: Bool { - unicodeScalars.allSatisfy(CharacterSet.letters.contains) - } - - private var isUppercaseLetter: Bool { - unicodeScalars.allSatisfy(CharacterSet.uppercaseLetters.contains) - } - - private var isLowercaseLetter: Bool { - unicodeScalars.allSatisfy(CharacterSet.lowercaseLetters.contains) - } - - private var isNumber: Bool { - unicodeScalars.allSatisfy(CharacterSet.decimalDigits.contains) - } - - fileprivate var isWordSeparator: Bool { - self == "_" || self == "-" || self == " " - } - - fileprivate func shouldInsertBreak(before previous: Character, next: Character?) -> Bool { - switch true { - case previous.isLowercaseLetter && isUppercaseLetter: - true - case previous.isLowercaseLetter && isNumber: - true - case previous.isNumber && isLetter: - true - case previous.isUppercaseLetter && isUppercaseLetter && (next?.isLowercaseLetter ?? false): - true - default: - false - } - } -} diff --git a/Sources/StaticMemberIterable/StaticMemberIterable.swift b/Sources/StaticMemberIterable/StaticMemberIterable.swift index 560377c..7bbd4e3 100644 --- a/Sources/StaticMemberIterable/StaticMemberIterable.swift +++ b/Sources/StaticMemberIterable/StaticMemberIterable.swift @@ -12,10 +12,10 @@ public protocol StaticMemberIterable { ) public macro StaticMemberIterable( _ access: StaticMemberIterableAccess? = nil, - ofType memberType: Any.Type? = nil + ofType memberType: Any.Type? = nil, ) = #externalMacro( module: "StaticMemberIterableMacro", - type: "StaticMemberIterableMacro" + type: "StaticMemberIterableMacro", ) public enum StaticMemberIterableAccess { diff --git a/Sources/StaticMemberIterableMacro/StaticMemberIterableMacro.swift b/Sources/StaticMemberIterableMacro/StaticMemberIterableMacro.swift index 728c8ef..a855c66 100644 --- a/Sources/StaticMemberIterableMacro/StaticMemberIterableMacro.swift +++ b/Sources/StaticMemberIterableMacro/StaticMemberIterableMacro.swift @@ -8,7 +8,7 @@ struct StaticMemberIterableMacro: MemberMacro { of node: AttributeSyntax, providingMembersOf declaration: some DeclGroupSyntax, conformingTo protocols: [TypeSyntax], - in context: some MacroExpansionContext + in context: some MacroExpansionContext, ) throws -> [DeclSyntax] { let options = AttributeOptions(attribute: node) @@ -16,7 +16,7 @@ struct StaticMemberIterableMacro: MemberMacro { throw DiagnosticsError( diagnostics: [ Diagnostic(node: Syntax(node), message: NotATypeError()), - ] + ], ) } @@ -24,7 +24,7 @@ struct StaticMemberIterableMacro: MemberMacro { guard !members.isEmpty else { context.diagnose( - Diagnostic(node: Syntax(node), message: NoStaticMembersWarning()) + Diagnostic(node: Syntax(node), message: NoStaticMembersWarning()), ) return [] } @@ -34,7 +34,7 @@ struct StaticMemberIterableMacro: MemberMacro { typeAccess: AccessSpecifier(keyword: declaration.explicitAccessModifier), members: members, containerType: declaration.memberContainerType, - valueType: options.memberType ?? declaration.memberValueType + valueType: options.memberType ?? declaration.memberValueType, ) let conflicts = StaticMemberEmitter.synthesizedMemberNames @@ -44,7 +44,7 @@ struct StaticMemberIterableMacro: MemberMacro { throw DiagnosticsError( diagnostics: conflicts.map { Diagnostic(node: Syntax(node), message: ConflictingMemberError(memberName: $0)) - } + }, ) } @@ -58,7 +58,7 @@ extension StaticMemberIterableMacro: ExtensionMacro { attachedTo declaration: some DeclGroupSyntax, providingExtensionsOf type: some TypeSyntaxProtocol, conformingTo protocols: [TypeSyntax], - in context: some MacroExpansionContext + in context: some MacroExpansionContext, ) throws -> [ExtensionDeclSyntax] { if protocols.isEmpty { return [] @@ -68,7 +68,7 @@ extension StaticMemberIterableMacro: ExtensionMacro { throw DiagnosticsError( diagnostics: [ Diagnostic(node: Syntax(node), message: NotATypeError()), - ] + ], ) } diff --git a/Tests/CaseIterableTests/CaseIterableMacroTests.swift b/Tests/CaseIterableTests/CaseIterableMacroTests.swift new file mode 100644 index 0000000..6ee021a --- /dev/null +++ b/Tests/CaseIterableTests/CaseIterableMacroTests.swift @@ -0,0 +1,319 @@ +#if canImport(CaseIterableMacro) +import MacroTesting +import SwiftSyntax +import Testing + +@testable import CaseIterableMacro + +@Suite( + .macros( + [CaseIterableMacro.self], + indentationWidth: .tab, + record: .missing, + ), +) +struct CaseIterableMacroTests { + @Test func defaultAccessInternal() { + assertMacro { + """ + @CaseIterable + enum Beverage { + case still + case sparkling + case sparklingWater + } + """ + } expansion: { + """ + enum Beverage { + case still + case sparkling + case sparklingWater + + static let allCases: [CaseOf] = [ + CaseOf( + name: "still", + value: .still + ), + CaseOf( + name: "sparkling", + value: .sparkling + ), + CaseOf( + name: "sparklingWater", + value: .sparklingWater + ) + ] + } + """ + } + } + + @Test func multiCaseDeclarations() { + assertMacro { + """ + @CaseIterable + enum Meal { + case breakfast, lunch + case dinner + } + """ + } expansion: { + """ + enum Meal { + case breakfast, lunch + case dinner + + static let allCases: [CaseOf] = [ + CaseOf( + name: "breakfast", + value: .breakfast + ), + CaseOf( + name: "lunch", + value: .lunch + ), + CaseOf( + name: "dinner", + value: .dinner + ) + ] + } + """ + } + } + + @Test func rawValueCases() { + assertMacro { + """ + @CaseIterable + enum Flavor: String { + case vanilla = "vanilla" + case chocolate = "chocolate" + } + """ + } expansion: { + """ + enum Flavor: String { + case vanilla = "vanilla" + case chocolate = "chocolate" + + static let allCases: [CaseOf] = [ + CaseOf( + name: "vanilla", + value: .vanilla + ), + CaseOf( + name: "chocolate", + value: .chocolate + ) + ] + } + """ + } + } + + // MARK: Access control + + @Test(arguments: [ + ("(.public)", "public "), + ("(.internal)", "internal "), + ("", ""), + ("(.package)", "package "), + ("(.fileprivate)", "fileprivate "), + ("(.private)", "private "), + ]) + func macroAccessSetsAllCases(macroModifier: String, membersModifier: String) { + assertMacro { + """ + @CaseIterable\(macroModifier) + enum AccessControlled { + case sample + } + """ + } expansion: { + """ + enum AccessControlled { + case sample + + \(membersModifier)static let allCases: [CaseOf] = [ + CaseOf( + name: "sample", + value: .sample + ) + ] + } + """ + } + } + + // MARK: Diagnostics + + @Test func notAnEnumError() { + assertMacro { + """ + @CaseIterable + struct NotAnEnum {} + """ + } diagnostics: { + """ + @CaseIterable + ┬──────────── + ╰─ 🛑 `@CaseIterable` only works on enums + struct NotAnEnum {} + """ + } + } + + @Test func noEnumCasesWarning() { + assertMacro { + """ + @CaseIterable + enum Empty {} + """ + } diagnostics: { + """ + @CaseIterable + ┬──────────── + ╰─ ⚠️ '@CaseIterable' does not generate members when there are no enum cases + enum Empty {} + """ + } expansion: { + """ + enum Empty {} + """ + } + } + + @Test func associatedValueCaseError() { + assertMacro { + """ + @CaseIterable + enum CoffeeOrder { + case espresso + case latte(size: Int) + } + """ + } diagnostics: { + """ + @CaseIterable + enum CoffeeOrder { + case espresso + case latte(size: Int) + ┬─────────────── + ╰─ 🛑 '@CaseIterable' does not support cases with associated values ('latte') + } + """ + } + } + + // MARK: Dynamic member lookup + + @Test func dynamicMemberLookupSynthesizesSubscript() { + assertMacro { + """ + @dynamicMemberLookup + @CaseIterable + enum Palette { + case sunrise + + struct Properties {} + + var properties: Properties { Properties() } + } + """ + } expansion: { + """ + @dynamicMemberLookup + enum Palette { + case sunrise + + struct Properties {} + + var properties: Properties { Properties() } + + static let allCases: [CaseOf] = [ + CaseOf( + name: "sunrise", + value: .sunrise + ) + ] + + subscript (dynamicMember keyPath: KeyPath) -> T { + properties[keyPath: keyPath] + } + } + """ + } + } + + @Test(arguments: ["public ", "internal ", "", "package ", "fileprivate ", "private "]) + func dynamicMemberSubscriptMatchesPropertiesAccess(accessLevel: String) { + assertMacro { + """ + @dynamicMemberLookup + @CaseIterable + enum Palette { + case sunrise + + \(accessLevel)struct Properties {} + + var properties: Properties { Properties() } + } + """ + } expansion: { + """ + @dynamicMemberLookup + enum Palette { + case sunrise + + \(accessLevel)struct Properties {} + + var properties: Properties { Properties() } + + static let allCases: [CaseOf] = [ + CaseOf( + name: "sunrise", + value: .sunrise + ) + ] + + \(accessLevel)subscript (dynamicMember keyPath: KeyPath) -> T { + properties[keyPath: keyPath] + } + } + """ + } + } + + @Test func dynamicMemberLookupWithoutPropertiesSkipsSubscript() { + assertMacro { + """ + @dynamicMemberLookup + @CaseIterable + enum Palette { + case sunrise + + var properties: Int { 0 } + } + """ + } expansion: { + """ + @dynamicMemberLookup + enum Palette { + case sunrise + + var properties: Int { 0 } + + static let allCases: [CaseOf] = [ + CaseOf( + name: "sunrise", + value: .sunrise + ) + ] + } + """ + } + } +} +#endif diff --git a/Tests/CaseIterableTests/CaseIterableRuntimeTests.swift b/Tests/CaseIterableTests/CaseIterableRuntimeTests.swift new file mode 100644 index 0000000..6b3ee76 --- /dev/null +++ b/Tests/CaseIterableTests/CaseIterableRuntimeTests.swift @@ -0,0 +1,53 @@ +import CaseIterable +import Testing + +@MainActor +@Suite +struct CaseIterableRuntimeTests { + @CaseIterable + enum CoffeeKind: Equatable { + case espresso + case latte + case pourOver + } + + @CaseIterable(.public) + enum MenuSection { + case breakfast, lunch, dinner + } + + @CaseIterable + @dynamicMemberLookup + enum Palette { + case sunrise + case midnight + + struct Properties { + let description: String + } + + var properties: Properties { + switch self { + case .sunrise: + Properties(description: "Sunrise") + case .midnight: + Properties(description: "Midnight") + } + } + } + + @Test func caseIterableMembers() { + let cases = CoffeeKind.allCases + + #expect(cases.count == 3) + #expect(cases.map(\.name) == ["espresso", "latte", "pourOver"]) + #expect(cases.map(\.title) == ["Espresso", "Latte", "Pour Over"]) + #expect(cases.map(\.value) == [.espresso, .latte, .pourOver]) + #expect(cases.map(\.id) == ["espresso", "latte", "pourOver"]) + } + + @Test func dynamicMemberLookupForwardsToProperties() { + #expect(Palette.sunrise.description == "Sunrise") + #expect(Palette.midnight.description == "Midnight") + } +} diff --git a/Tests/StaticMemberIterableTests/MacroExpansionTests.swift b/Tests/StaticMemberIterableTests/MacroExpansionTests.swift index c2eb0b7..6157ae3 100644 --- a/Tests/StaticMemberIterableTests/MacroExpansionTests.swift +++ b/Tests/StaticMemberIterableTests/MacroExpansionTests.swift @@ -8,8 +8,8 @@ import Testing .macros( [StaticMemberIterableMacro.self], indentationWidth: .tab, - record: .missing - ) + record: .missing, + ), ) struct StaticMemberIterableMacroTests { // MARK: Successful expansions diff --git a/Tests/StaticMemberIterableTests/StaticMemberTests.swift b/Tests/StaticMemberIterableTests/StaticMemberTests.swift index 759855b..5927b6f 100644 --- a/Tests/StaticMemberIterableTests/StaticMemberTests.swift +++ b/Tests/StaticMemberIterableTests/StaticMemberTests.swift @@ -11,13 +11,13 @@ struct StaticMemberPatternMatchingTests { private let alphaMember = StaticMember( keyPath: \Fixture.Type.alpha, name: "alpha", - value: Fixture.alpha + value: Fixture.alpha, ) private let betaMember = StaticMember( keyPath: \Fixture.Type.beta, name: "beta", - value: Fixture.beta + value: Fixture.beta, ) @Test func keyPathPatternMatchesMember() { diff --git a/Tests/StaticMemberIterableTests/StaticMemberTitleTests.swift b/Tests/StaticMemberIterableTests/StaticMemberTitleTests.swift index 2954d26..ece9f19 100644 --- a/Tests/StaticMemberIterableTests/StaticMemberTitleTests.swift +++ b/Tests/StaticMemberIterableTests/StaticMemberTitleTests.swift @@ -11,7 +11,7 @@ struct StaticMemberTitleTests { StaticMember( keyPath: \Fixture.Type.reference, name: name, - value: Fixture.reference + value: Fixture.reference, ) }