Skip to content

MahdiBM/enumerator-macro

Repository files navigation

Tests Badge Code Coverage Latest/Minimum Swift Version

EnumeratorMacro

A utility for creating case-by-case code for your Swift enums using the Mustache templating engine.
EnumeratorMacro uses swift-mustache's flavor.

The macro will parse your enum code, and pass different info of your enum to the mustache template renderer.
Then you can access each case-name, case-parameters etc.. in the template, and create code based on that.

How Does Mustache Templating Work?

It's rather simple.

  • Inject variables using the {{variableName}} syntax.
  • Loop though arrays using the {{#array}} {{/array}} syntax.
  • Apply if conditions using the {{#boolean}} {{/boolean}} syntax.
  • Apply inverted if conditions using the {{^boolean}} {{/boolean}} syntax.
  • Apply transformations using the "function call" syntax: snakedCased(variable).
    • Available transformations are mentioned below.
  • Use {{! comment here }} syntax to write comments in the template.
  • See the reference and the swift-mustache docs for more info.

General Behavior

Click to expand

EnumeratorMacro will:

Examples

Note

The examples will get more advanced as you scroll.

Derive Case Names

@Enumerator("""
var caseName: String {
    switch self {
    {{#cases}}
    case .{{name}}: "{{snakeCased(name)}}"
    {{/cases}}
    }
}
""")
enum TestEnum {
    case a(val1: String, val2: Int)
    case b
    case testCase(testValue: String)
}

Is expanded to:

enum TestEnum {
    case a(val1: String, val2: Int)
    case b
    case testCase(testValue: String)

+    var caseName: String {
+        switch self {
+        case .a: "a"
+        case .b: "b"
+        case .testCase: "test_case"
+        }
+    }
}

Create a Subtype Enum

Click to expand
@Enumerator("""
enum Subtype: String {
    {{#cases}}
    case {{name}}
    {{/cases}}
}
""",
"""
var subtype: Subtype {
    switch self {
    {{#cases}}
    case .{{name}}:
        .{{name}}
    {{/cases}}
    }
}
""")
enum TestEnum {
    case a(val1: String, val2: Int)
    case b
    case testCase(testValue: String)
}

Is expanded to:

enum TestEnum {
    case a(val1: String, val2: Int)
    case b
    case testCase(testValue: String)

+    enum Subtype: String {
+        case a
+        case b
+        case testCase
+    }

+    var subtype: Subtype {
+        switch self {
+        case .a:
+            .a
+        case .b:
+            .b
+        case .testCase:
+            .testCase
+        }
+    }
}

Create Is-Case Properties

Click to expand
@Enumerator("""
{{#cases}}
var is{{capitalized(name)}}: Bool {
    switch self {
    case .{{name}}: return true
    default: return false
    }
}
{{/cases}}
""")
enum TestEnum {
    case a(val1: String, val2: Int)
    case b
    case testCase(testValue: String)
}

Is expanded to:

enum TestEnum {
    case a(val1: String, val2: Int)
    case b
    case testCase(testValue: String)

+    var isA: Bool {
+        switch self {
+        case .a: return true
+        default: return false
+        }
+    }

+    var isB: Bool {
+        switch self {
+        case .b: return true
+        default: return false
+        }
+    }

+    var isTestCase: Bool {
+        switch self {
+        case .testCase: return true
+        default: return false
+        }
+    }
}

Create Get-Case-Value Functions

Click to expand
@Enumerator("""
{{#cases}}
{{^isEmpty(parameters)}}
func get{{capitalized(name)}}() -> ({{joined(tupleValue(parameters))}})? {
    switch self {
    case let .{{name}}{{withParens(joined(names(parameters)))}}:
        return {{withParens(joined(names(parameters)))}}
    default:
        return nil
    }
}
{{/isEmpty(parameters)}}
{{/cases}}
""")
enum TestEnum {
    case a(val1: String, Int)
    case b
    case testCase(testValue: String)
}

Is expanded to:

enum TestEnum {
    case a(val1: String, val2: Int)
    case b
    case testCase(testValue: String)


+    func getA() -> (val1: String, param2: Int)? {
+        switch self {
+        case let .a(val1, param2):
+            return (val1, param2)
+        default:
+            return nil
+        }
+    }

+    func getTestCase() -> (String)? {
+        switch self {
+        case let .testCase(testValue):
+            return (testValue)
+        default:
+            return nil
+        }
+    }
}

Create Functions For Each Case

Click to expand
@Enumerator("""
{{#cases}}
{{^isEmpty(parameters)}}
func get{{capitalized(name)}}() -> ({{joined(tupleValue(parameters))}})? {
    switch self {
    case let .{{name}}{{withParens(joined(names(parameters)))}}:
        return {{withParens(joined(names(parameters)))}}
    default:
        return nil
    }
}
{{/isEmpty(parameters)}}
{{/cases}}
""")
enum TestEnum {
    case a(val1: String, Int)
    case b
    case testCase(testValue: String)
}

Is expanded to:

enum TestEnum {
    case a(val1: String, Int)
    case b
    case testCase(testValue: String)

+    func getA() -> (val1: String, param2: Int)? {
+        switch self {
+        case let .a(val1, param2):
+            return (val1, param2)
+        default:
+            return nil
+        }
+    }

+    func getTestCase() -> (String)? {
+        switch self {
+        case let .testCase(testValue):
+            return (testValue)
+        default:
+            return nil
+        }
+    }
}

Using Comments For Code Generation

Note

You can use comments in front of each case, as values for EnumeratorMacro to process.
Use ; to divide the comments, and use : to separate the key and the possible value.
Example: myKey1; myKey2: value; myKey3.

Tip

You should declare a allowedComments argument to enforce that only certain comments are used, so you avoid typo bugs.

@Enumerator(
    allowedComments: ["business_error", "l8n_params"],
    """
    package var isBusinessError: Bool {
        switch self {
        case
        {{#cases}}{{#bool(business_error(comments))}}
        .{{name}},
        {{/bool(business_error(comments))}}{{/cases}}
        :
            return true
        default:
            return false
        }
    }
    """
)
public enum ErrorMessage {
    case case1 // business_error
    case case2 // business_error: true
    case case3 // business_error: false
    case case4 // business_error: adfasdfdsff
    case somethingSomething(value: String)
    case otherCase(error: Error, isViolation: Bool) // business_error; l8n_params:
}

Is expanded to:

public enum ErrorMessage {
    case case1 // business_error
    case case2 // business_error: true
    case case3 // business_error: false
    case case4 // business_error: adfasdfdsff
    case somethingSomething(value: String)
    case otherCase(error: Error, isViolation: Bool) // business_error; l8n_params:

+    package var isBusinessError: Bool {
+        switch self {
+        case
+        .case1,
+        .case2,
+        .otherCase
+        :
+            return true
+        default:
+            return false
+        }
+    }
}

Advanced Using Comments For Code Generation

Click to expand
@Enumerator(
    allowedComments: ["business_error", "l8n_params"],
    """
    private var localizationParameters: [Any] {
        switch self {
        {{#cases}}
    {{! Only create a case for enum cases that have any parameters at all: }}
        {{^isEmpty(parameters)}}
    
    {{! Create a case for those who have non-empty 'l8n_params' comment: }}
        {{^isEmpty(l8n_params(comments))}}
        case let .{{name}}{{withParens(joined(names(parameters)))}}:
            [{{l8n_params(comments)}}]
        {{/isEmpty(l8n_params(comments))}}
    
    {{! Create a case for those who don't have 'l8n_params' comment at all: }}
        {{^exists(l8n_params(comments))}}
        case let .{{name}}{{withParens(joined(names(parameters)))}}:
            [
                {{#parameters}}
                {{name}}{{#isOptional}} as Any{{/isOptional}},
                {{/parameters}}
            ]
        {{/exists(l8n_params(comments))}}
    
        {{/isEmpty(parameters)}}
        {{/cases}}
        default:
            []
        }
    }
    """
)
public enum ErrorMessage {
    case case1 // business_error
    case case2 // business_error: true
    case case3 // business_error: false
    case case4 // business_error: adfasdfdsff
    case somethingSomething(value1: String, Int) // l8n_params: value
    case otherCase(error: Error, isViolation: Bool) // business_error; l8n_params:
}

Is expanded to:

public enum ErrorMessage {
    case case1 // business_error
    case case2 // business_error: true
    case case3 // business_error: false
    case case4 // business_error: adfasdfdsff
    case somethingSomething(value1: String, Int) // l8n_params: value
    case otherCase(error: Error, isViolation: Bool) // business_error; l8n_params:

    private var localizationParameters: [Any] {
        switch self {
        case .somethingSomething:
            [value]
        default:
            []
        }
    }
}

Available Context Values

Here's a sample context object:

{
    "cases": [
        {
            "name": "somethingWentWrong",
            "parameters": [
                {
                    "name": "error",
                    "type": "Error?",
                    "isOptional": true,
                    "index": 0,
                    "isFirst": true,
                    "isLast": false
                },
                {
                    "name": "statusCode",
                    "type": "String",
                    "isOptional": false,
                    "index": 1,
                    "isFirst": false,
                    "isLast": true
                }
            ],
            "comments": [
                {
                    "key": "business_error",
                    "value": ""
                },
                {
                    "key": "l8n_params",
                    "value": "error as Any, statusCode"
                }
            ],
            "index": 0,
            "isFirst": true,
            "isLast": true
        }
    ]
}

Available Functions

Although not visible when writing templates, each underlying value that is passed to the template engine has an actual type.

EnumeratorMacro supports these transformations for each type:

  • String:
    • capitalized() -> String: Capitalizes the first letter.
    • dropFirst() -> String: Equivalent to Swift's .dropFirst().
    • dropLast() -> String: Equivalent to Swift's .dropLast().
    • hash() -> String: The hash of the string using the CRC32 algorithm.
    • sha() -> String: The SHA256 hash of the string using the SHA256 algorithm.
      • DO NOT rely on this to be cryptographically secure.
    • snakeCased() -> String: Converts the string from camelCase to snake_case.
    • camelCased() -> String: Converts the string from snake_case to camelCase.
    • withParens() -> String: If the string is not empty, surrounds it in parenthesis.
  • Int:
    • plusOne() -> Int: Add one to the integer.
    • minusOne() -> Int: Subtract one from the integer.
    • equalsZero() -> Bool: Returns whether the integer is equal to zero.
    • isOdd() -> Bool: Returns whether the integer is odd or not.
    • isEven() -> Bool: Returns whether the integer is even or not.
    • hash() -> String: The hash of the string representation of the integer using the CRC32 algorithm.
    • sha() -> String: The hash of the string representation of the integer using the SHA256 algorithm.
      • DO NOT rely on this to be cryptographically secure.
  • Array:
    • first() -> Element?: Returns the first element of the array.
    • last() -> Element?: Returns the last element of the array.
    • count() -> Int: Returns the number of the elements in the array.
    • isEmpty() -> Bool: Returns whether the array is empty or not.
    • reversed() -> Self: Returns a reversed array.
    • sorted() -> Self: Sorts the elements, if the elements of the array are comparable.
    • joined() -> String: Equivalent to Swift's .joined(separator: ", ").
    • keyValues() -> [KeyValue]: Parses the elements of the array as key-value pairs separated by ':'.
  • Optional:
    • exists() -> Bool: Returns whether this optional value contains anything.
    • isEmpty() -> Bool: Returns whether this optional value contains anything.
      • If a value exists, the call will be forwarded to that.
      • For example Optional<String>.some("") will return true for isEmpty because although the optional exists, the string value is empty.
    • Optional is a see-through value like in Swift. You can use any functions that are available for the wrapperd type, when you're sure the value exists.
  • KeyValue:
    • key() -> String: Returns the key. You could use Mustache-native {{key}} syntax as well.
    • value() -> String: Returns the value. You could use Mustache-native {{value}} syntax as well.
  • [KeyValue] (comments):
    • All Array functions are applicable to [KeyValue] as well.
    • Imagine a [KeyValue] as a Dictionary<String, String>.
    • You can use function names as a way of subscripting.
    • For example business_error(myKeyValues) will find an element in myKeyValues where key == business_error, and will return the value as an String?.
  • [Parameter] (parameters):
    • names() -> [String]: Returns the names of the parameters.
      • names(parameters) -> [param1, param2, param3].
    • types() -> [String]: Returns the types of the parameters.
      • Use with joined: joined(types(parameters)) -> (String, Int, Double).
    • namesAndTypes() -> [String]: Returns an array where each element is equivalent to "\(name): \(type)".
      • Use with joined: joined(namesAndTypes(parameters)) -> (key: String) or (key: String, value: Int).
    • tupleValue() -> String: Suitable to be used for making tuples from the parameters.
      • Use with withParens: withParens(tupleValue(parameters)) -> (String) or (key: String, value: Int).

Feel free to suggest a function if you think it'll solve a problem.

Error Handling

Click to expand

In case there is an error in the expanded macro code, or in any other step of the code generation, EnumeratorMacro will try to emit diagnostics pointing to the line of the code which is the source of the issue.

For example, EnumeratorMacro will properly forward template render errors from the template engine to your source code. In the example below, I've mistakenly written {{cases} instead of {{cases}}:

Screenshot 2024-07-13 at 12 09 16 AM

Or here, the expanded Swift code would result in a code with syntax error and the macro is preemprively reporting the error. Here, I've supposedly forgot to write the : between caseName and String.

Screenshot 2024-07-13 at 12 10 19 AM

EnumeratorMacro can also catch an invalid function being used:

Screenshot 2024-07-17 at 6 44 41 PM

How To Add EnumeratorMacro To Your Project

To use the EnumeratorMacro library in a SwiftPM project, add the following line to the dependencies in your Package.swift file:

.package(url: "https://github.com/MahdiBM/enumerator-macro", branch: "main"),

Include EnumeratorMacro as a dependency for your targets:

.target(name: "<target>", dependencies: [
    .product(name: "EnumeratorMacro", package: "enumerator-macro"),
]),

Finally, add import EnumeratorMacro to your source code.