Swift Macros: Simplifying Repetitive Boilerplate Code Generation.

Swift Macros: Simplifying Repetitive Boilerplate Code Generation.

Consider a scenario where we encounter multiple APIs with different endpoints. To handle this, we can create an Enum with cases that represent the endpoints. By implementing a function within the Enum, we can retrieve the respective endpoint URL for each case.

In Swift development, it is common to use camel case for naming properties, where the first letter is lowercase and subsequent words begin with a capitalized letter (e.g., nowPlaying or topRated). However, many web APIs prefer snake case, where words are separated by underscores (e.g., now_playing or top_rated). To bridge this naming convention gap, we can design our Enum to extract the respective endpoints using the following approach:

No alt text provided for this image

To address this, we can create a function that converts camel case to snake case, which allows for automatic generation of endpoint URLs based on the case names. However, this approach may not be suitable if we require custom endpoints for specific cases. In such scenarios, modifying the function every time we add a new case becomes impractical for long-term maintenance.

Therefore, when dealing with a larger number of endpoints or the potential for future additions, it is worth considering alternative strategies that can facilitate the generation of endpoint URLs without significant manual effort.

The Alternative Strategy - Swift Macros

Swift macros allow us to generate the repetitive boiler plate code at compile time, making our app's codebases more expressive and easier to read.

Allow me to illustrate this with a compelling example that truly captures the essence of the concept. There's no better way to grasp it than experiencing it firsthand.

No alt text provided for this image

In the above example, we can observe the use of the Swift macro @APIEndpoints(), which automates the generation of boilerplate code and handles the conversion from camel case to snake case. This macro significantly simplifies the process and eliminates the need for manually writing individual cases, even when dealing with a large number of endpoints.

By leveraging the @APIEndpoints() macro, we can eliminate the concerns of maintaining and updating cases for numerous endpoints. It streamlines the codebase, making it more concise and manageable. This approach ensures that we no longer need to invest time and effort in writing and modifying cases as new endpoints are added, making it suitable for scenarios with two dozen or more endpoints.

Without further ado, let's delve into the implementation details of how we accomplished this convenient solution.

Implementation Details - Swift Macros

Step 1 - Creating a package

To create Swift macros, we need to start by creating a new package. When creating a new package, we will notice a convenient macro template available in the package-choose window. For our example, let's give the package a meaningful name like "APIEndpoints".

No alt text provided for this image

Step 2 - Declaring Macro

To dynamically determine the endpoint URL based on specific cases, we utilize the "endpointUrl" variable. In order to declare this variable as an attached member macro, we employ the "@attached(member)" attribute.

@attached(member) - Adds new declarations inside the type/extension it’s applied to
No alt text provided for this image

Here APIEndpointsMacro will contain the expansion performed by our macro, which resides in APIEndpointsMacroMacros.

The APIEndpoints macro contains default parameter

No alt text provided for this image
No alt text provided for this image

In this context, we will discuss scenarios where it is unnecessary to convert specific camel case URL endpoints to snake case and situations where we may want to modify a particular endpoint to a custom value. However, for the present discussion, we will adhere to the default implementation of the APIEndpoints Macro.

Step 2 - Implementing Macro

To begin, let's navigate to the APIEndpointsMacro.swift file and make the following modifications. Firstly, we'll add a public structure named APIEndpointsMacro. Since we have declared APIEndpoints as an attached member macro, the implementation for this structure should conform to the MemberMacro protocol. This protocol specifies a single requirement: the expansion function, which is similar to the ExpressionMacro demonstrated in the macro #Stringify.

The expansion function accepts the node attribute used to apply the macro to a declaration, as well as the declaration itself to which the macro is being applied. In our case, this declaration will be an enum.

No alt text provided for this image

In order to ensure the desired behavior of our macro, we intend to follow a test-driven development approach. Consequently, we will initially keep the implementation of the macro empty until we create a corresponding test case. Once we have defined the expected behavior of the macro through the test case, we will proceed to write the implementation that aligns with that specific test case.

In the provided template, we utilize the 'assertMacroExpansion' function to verify the behavior of our macro. At this stage, the macro doesn't perform any actions, so we expect the expanded code to be identical to the input code, excluding the '@APIEndpoints' attribute.

No alt text provided for this image

To ensure that the test case expands the 'APIEndpoints' macro using the 'APIEndpointsMacro' implementation, we need to create a mapping between the macro name and its implementing type in the 'testMacros' dictionary. This dictionary is then passed to the assertion function.

No alt text provided for this image

Now, let's proceed to run our tests and validate the functionality we have implemented thus far. Upon running the tests, we can confirm that the code functions as expected.

No alt text provided for this image

However, our primary objective is to verify that the macro generates the variable endpointUrl, rather than solely removing the attribute. To achieve this, we will extend our test to examine the output generated by applying the macro to the Enum. This modification will serve as the input for our test case, reflecting the desired outcome that we anticipate the plugin to produce.

No alt text provided for this image

Upon rerunning the test, it fails because our macro does not currently generate the initializer. To address this issue, we need to implement the generation of the initializer.

No alt text provided for this image

The variable iterates through all the elements declared in the Paths enum. To begin, we need to extract these enum elements from the declaration. Since enum elements can only be declared within enum declarations, we cast the 'declaration' variable to an enum declaration.

If the macro is associated with a type that is not an enum, we should generate an error. Let's add a TODO to address this issue later, and for now, return an empty array.

No alt text provided for this image

Next, we must retrieve all the elements declared within the enum. To accomplish this, we examine the syntactic structure of our enum in the SwiftSyntax tree.

Given that the macro's implementation is essentially a regular Swift program, we have the convenience of utilizing familiar debugging tools from Xcode to troubleshoot our code. One such approach is setting a breakpoint within the expansion function and running test cases that trigger the breakpoint.

By doing so, we can effectively pause the debugger within the macro's implementation. At this point, the Paths enum, for instance, becomes accessible as 'enumDecl'. To examine its contents, we can print it in the debugger by typing 'po enumDecl'.

No alt text provided for this image

The Output -

No alt text provided for this image
This code snippet provides a structured representation of the EnumDeclSyntax object, showcasing its various components and their relationships within the Swift syntax tree.

Here's a breakdown of the above Swift syntax tree:

  • attributes: An AttributeListSyntax representing the attributes applied to the enum declaration.
  • enumKeyword: A keyword token (enumKeyword) indicating that this is an enum declaration.
  • identifier: An identifier token (identifier("Paths")) representing the name of the enum.
  • inheritanceClause: There is one InheritedTypeSyntax with a SimpleTypeIdentifierSyntax representing the inherited type, which is "String".
  • memberBlock: A MemberDeclBlockSyntax containing the members of the enum declaration. It includes a members list (MemberDeclListSyntax) containing the individual members of the enum.

To access the members from the members list, we can begin with enumDecl.memberBlock.members. These members encompass the actual declarations that define the enum cases. By utilizing compactMap, we can obtain a list of all the member declarations that specifically represent enum cases. It's important to note that case declaration has multiple elements. To retrieve all of these elements, we can make use of flatMap.

No alt text provided for this image

Now that we have obtained all the necessary elements, we can proceed with the construction of the variable that we intend to add to the Paths Enum.

To achieve this, we define an initializer declaration with a single item: a switch expression. This switch expression incorporates a case for each element within the enum and returns the corresponding required string. In order to accomplish this, we need to create syntax nodes for each case.

No alt text provided for this image

Here's a breakdown of the code:

  1. let variable = try VariableDeclSyntax("var endpointUrl: String"): To begin our implementation, we utilize the VariableDeclSyntax to construct a variable declaration. We start by building the body using a result builder and specifying the header, which consists of the 'var' keyword and the return type, which in this case is String.This enables us to utilize a for loop within the result builder, allowing us to iterate over all the elements efficiently and effectively.In this particular scenario, we simply copied the line "var endpointUrl: String" from our test case, which serves as a template for the structure we want to generate.
  2. try SwitchExprSyntax("switch self"): This line creates a SwitchExprSyntax object by passing the string "switch self". It represents a switch statement where the switch expression is "self". The try keyword suggests that it might involve throwing errors during initialization.
  3. for element in elements { SwitchCaseSyntax(...) }: This part is a loop that iterates over the elements collection. For each element, it creates a SwitchCaseSyntax object, representing a case within the switch statement.
  4. The SwitchCaseSyntax(...) line is where each individual case is constructed. It uses a multi-line string literal to define the case pattern and the associated return value. The pattern matches the enum case .element.identifier, and the associated value returned is the result of calling text.camelToSnakeCase() on element.identifier.

And finally, we can return the variable.

Let's run the tests to see if we are indeed generating the correct variable.

No alt text provided for this image

Now that we have verified the functionality of our macro, we can begin integrating it into our application.

To add our macro package to our Xcode project seamlessly, we can follow these steps:

  1. Right-click on our project in Xcode and select "Add Package Dependencies".
  2. From the presented options, choose the local package that we created.
  3. Add the APIEndpoints target as a dependency.

With the package and dependency set up correctly, we can now import the APIEndpoints module into our code. This allows us to access the @APIEndpoints() macro and utilize it conveniently within our project.

To import the module and apply the macro to the Paths enum, we simply:

  1. Add the import statement for the APIEndpoints module in our source file.
  2. Apply the @APIEndpoints() macro to the Paths enum.

So if we want to see what the macro actually generated, we can right-click on @APIEndpoints() and click Expand Macro.

No alt text provided for this image

I have made some enhancements to the macro to ensure it can handle SnakeCasedOptions effectively.

No alt text provided for this image

Additionally, I have implemented error handling for scenarios where the macro is used on types other than an enum or if invalid snake case options are provided.

No alt text provided for this image

To delve deeper into the complete code, I encourage you to explore my GitHub repository. Feel free to examine the code and provide any feedback or suggestions you may have.




?????????????? ??????????

???????????? ???????????????? ???????????????? | ?????? | ???????? | ?????????? & ?????????????????? | ?????? | ??????

1 年

Great explanation ??

要查看或添加评论,请登录

Abhinav Jha的更多文章

社区洞察

其他会员也浏览了