enumsswift5

Can an enum be an array type?


I often have something like ..

enum DotNetType: String, CaseIterable {
    case guid = "Guid"
    case int = "Int32"
    case float = "Single"
    case dateTime = "DateTime"
    case string = "String"
    
    init?(_ s: String) {
        for v in DotNetType.allCases {
            if v.rawValue == s {
                self = v
                return
            }
        }
        return nil
    }

however, often,

    case guid = "Guid" // oh no, turns out may be "uuid", "id" or "specialId"

Obviously you can solve this by just having a switch, but it occurred to me it would be elegant if

enum DotNetType: [String], CaseIterable {
    case guid = ["Guid","uuid","id"]
    case int = ["Int32", "int", "Int", "integer"]
    case float = ["Single", "Float", "decimal"]
    case dateTime = ["DateTime", "ts", "timestamp"]
    case string = "String"

Unfortunately when I try this, the errors are far beyond my understanding of the guts of enum so I cannot make it work.

Is there a way to have the type of an enum, an array?

I can see many cases (other than just parsing string tags) where it would be handy to have an enum as an array.


Solution

  • If you just want to associate multiple strings to each case of an enum, and have an initialiser that can return the correct case according to those strings, you can write a macro for that.

    One macro for generating the initialiser, and another for annotating each case. The usage would look like this:

    @RawNamesEnum
    enum DotNetType {
        @RawNames("Guid", "uuid", "id")
        case guid
        @RawNames("Int32", "int", "Int", "integer")
        case int
        @RawNames("Single", "float", "decimal")
        case float
    }
    
    print(DotNetType("id"))
    

    Here's the implementation:

    // declarations:
    @attached(member, names: named(init))
    public macro RawNamesEnum() = #externalMacro(module: "...", type: "RawNamesEnum")
    @attached(peer)
    public macro RawNames(_ names: String...) = #externalMacro(module: "...", type: "RawNames")
    
    // implementation:
    
    // this is kind of dirty, but is very convenient for throwing compile time errors
    extension String: @retroactive Error {}
    
    enum RawNamesEnum: MemberMacro {
        static func expansion(
            of node: AttributeSyntax,
            providingMembersOf declaration: some DeclGroupSyntax,
            in context: some MacroExpansionContext
        ) throws -> [DeclSyntax] {
            guard let enumDecl = declaration.as(EnumDeclSyntax.self) else {
                throw "RawNamesEnum must be attached to an enum!"
            }
            var allNames: Set<String> = []
            var namesByCase: [String: Set<String>] = [:]
            let caseDecls = enumDecl.memberBlock.members.compactMap { $0.decl.as(EnumCaseDeclSyntax.self) }
            for caseDecl in caseDecls {
                let names = try getNames(fromCase: caseDecl)
                if !allNames.intersection(names).isEmpty {
                    throw "Cannot contain duplicate names!"
                }
                allNames.formUnion(names)
                namesByCase[caseDecl.elements.first!.name.text] = names
            }
            
            let initDecl = InitializerDeclSyntax(optionalMark: "?", signature: .init(parameterClause: .init {
                "_ name: String"
            })) {
                
                for (caseName, rawNames) in namesByCase {
                    let arrayLiteral = ArrayExprSyntax(expressions: rawNames.map { ExprSyntax(StringLiteralExprSyntax(content: $0)) })
                    """
                    if \(arrayLiteral).contains(name) {
                        self = .\(raw: caseName)
                        return
                    }
                    """
                }
                "return nil"
            }
            return [DeclSyntax(initDecl)]
        }
        
        static func getNames(fromCase caseDecl: EnumCaseDeclSyntax) throws -> Set<String> {
            var names: Set<String> = []
            for case let .attribute(attrSyntax) in caseDecl.attributes {
                guard attrSyntax.attributeName.as(IdentifierTypeSyntax.self)?.name.text == "RawNames",
                    case let .argumentList(argList) = attrSyntax.arguments else { continue }
                for name in argList.compactMap({ $0.expression.as(StringLiteralExprSyntax.self)?.representedLiteralValue }) {
                    if !names.insert(name).inserted {
                        throw "Cannot contain duplicate names!"
                    }
                }
            }
            if names.isEmpty {
                names = [caseDecl.elements.first!.name.text]
            }
            return names
        }
    }
    
    enum RawNames: PeerMacro {
        static func expansion(
            of node: AttributeSyntax,
            providingPeersOf declaration: some DeclSyntaxProtocol,
            in context: some MacroExpansionContext
        ) throws -> [DeclSyntax] {
            guard let caseDecl = declaration.as(EnumCaseDeclSyntax.self) else {
                throw "RawNames can only be applied to enum cases"
            }
            guard caseDecl.elements.count == 1 else {
                throw "RawNames can only be applied to a single enum case declaration"
            }
            guard let firstCase = caseDecl.elements.first, firstCase.parameterClause == nil else {
                throw "RawNames cannot be applied to cases with associated values"
            }
            guard case let .argumentList(argList) = node.arguments,
                  argList.allSatisfy({
                      $0.expression.as(StringLiteralExprSyntax.self)?.representedLiteralValue != nil
                  }) else {
                throw "Names must all be string literals"
            }
            return []
        }
    }
    
    @main
    struct MyMacroPlugin: CompilerPlugin {
        let providingMacros: [Macro.Type] = [
            RawNamesEnum.self,
            RawNames.self
        ]
    }
    

    The raw value type of an enum cannot be an array. From the documentation,

    The type of these values is specified in the raw-value type and must represent an integer, floating-point number, string, or single character. In particular, the raw-value type must conform to the Equatable protocol and one of the following protocols: ExpressibleByIntegerLiteral for integer literals, ExpressibleByFloatLiteral for floating-point literals, ExpressibleByStringLiteral for string literals that contain any number of characters, and ExpressibleByUnicodeScalarLiteral or ExpressibleByExtendedGraphemeClusterLiteral for string literals that contain only a single character.

    You can still manually conform to RawRepresentable and set the RawValue type to an array type. I'm not sure why you want to do this though...

    enum Foo: RawRepresentable {
        typealias RawValue = [String]
        
        init?(rawValue: [String]) {
            // you need to write this by hand...
        }
        
        var rawValue: [String] {
            // you need to write this by hand...
        }
    }
    

    Technically, you can extend the @RawNamesEnum macro to also be an ExtensionMacro that generates a RawRepresentable conformance like the above, but I don't find that particularly useful.