Section 5/101 menit

5. Cara Membuat Swift Macro

5. Cara Membuat Swift Macro

Step 1: Buat Swift Package untuk Macro

Gunakan Xcode File menu atau command line:

swift
# Di terminal:
mkdir MyMacros && cd MyMacros
swift package init --type macro

Atau di Xcode: File → New → Package → Swift Macro

Step 2: Struktur Folder

swift
MyMacros/
├── Package.swift
├── Sources/
│   ├── MyMacros/           ← Public API (definisi macro)
│   │   └── MyMacros.swift
│   └── MyMacrosMacros/     ← Implementasi (plugin)
│       └── MyMacrosMacro.swift
└── Tests/
    └── MyMacrosTests/
        └── MyMacrosTests.swift

Step 3: Konfigurasi Package.swift

swift
// swift-tools-version: 5.9
import PackageDescription
import CompilerPluginSupport  // ← wajib untuk macro

let package = Package(
    name: "MyMacros",
    platforms: [
        .macOS(.v13),
        .iOS(.v13),
    ],
    dependencies: [
        .package(
            url: "https://github.com/apple/swift-syntax.git",
            from: "510.0.0"  // sesuaikan dengan versi Swift toolchain
        ),
    ],
    targets: [
        // Target implementasi macro (plugin)
        .macro(
            name: "MyMacrosMacros",
            dependencies: [
                .product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
                .product(name: "SwiftCompilerPlugin", package: "swift-syntax"),
            ]
        ),
        // Target public API yang digunakan konsumen
        .target(
            name: "MyMacros",
            dependencies: ["MyMacrosMacros"]
        ),
        // Target test
        .testTarget(
            name: "MyMacrosTests",
            dependencies: [
                "MyMacros",
                .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"),
            ]
        ),
    ]
)

Step 4: Implementasi Macro — Contoh @AutoEquatable

File: Sources/MyMacros/MyMacros.swift (Public API)

swift
import Foundation

// Deklarasi public — ini yang digunakan oleh konsumen macro
@attached(conformance, conformances: Equatable)
@attached(member, names: named(==))
public macro AutoEquatable() = #externalMacro(
    module: "MyMacrosMacros",
    type: "AutoEquatableMacro"
)

File: Sources/MyMacrosMacros/AutoEquatableMacro.swift (Implementasi)

swift
import SwiftCompilerPlugin
import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros

public struct AutoEquatableMacro: MemberMacro, ConformanceMacro {

    // MARK: - ConformanceMacro: tambahkan Equatable conformance
    public static func expansion(
        of node: AttributeSyntax,
        providingConformancesOf declaration: some DeclGroupSyntax,
        in context: some MacroExpansionContext
    ) throws -> [(TypeSyntax, GenericWhereClauseSyntax?)] {
        return [("Equatable", nil)]
    }

    // MARK: - MemberMacro: generate fungsi ==
    public static func expansion(
        of node: AttributeSyntax,
        providingMembersOf declaration: some DeclGroupSyntax,
        conformingTo protocols: [TypeSyntax],
        in context: some MacroExpansionContext
    ) throws -> [DeclSyntax] {

        // Ambil nama type
        guard let typeName = declaration.asProtocol(NamedDeclSyntax.self)?.name.text else {
            throw MacroError.notApplicable("@AutoEquatable hanya untuk named types")
        }

        // Ambil semua stored property
        let storedVars = declaration.memberBlock.members
            .compactMap { $0.decl.as(VariableDeclSyntax.self) }
            .filter { decl in
                // Hanya stored property (bukan computed)
                decl.bindings.first?.accessorBlock == nil
            }

        if storedVars.isEmpty {
            // Jika tidak ada property, return == trivial
            return [
                """
                public static func == (lhs: \(raw: typeName), rhs: \(raw: typeName)) -> Bool {
                    return true
                }
                """
            ]
        }

        // Generate perbandingan untuk setiap property
        let comparisons = storedVars.compactMap { variable -> String? in
            guard let propName = variable.bindings.first?.pattern.description else { return nil }
            return "lhs.\(propName) == rhs.\(propName)"
        }.joined(separator: " &&\n        ")

        return [
            """
            public static func == (lhs: \(raw: typeName), rhs: \(raw: typeName)) -> Bool {
                return \(raw: comparisons)
            }
            """
        ]
    }
}

// MARK: - Custom Error
enum MacroError: Error, CustomStringConvertible {
    case notApplicable(String)

    var description: String {
        switch self {
        case .notApplicable(let msg): return msg
        }
    }
}

// MARK: - Plugin Registration
@main
struct MyMacrosPlugin: CompilerPlugin {
    let providingMacros: [Macro.Type] = [
        AutoEquatableMacro.self,
    ]
}

Step 5: Testing Macro

Testing macro menggunakan assertMacroExpansion untuk memverifikasi hasil expansion:

swift
import XCTest
import SwiftSyntaxMacrosTestSupport
@testable import MyMacrosMacros

final class AutoEquatableMacroTests: XCTestCase {

    let testMacros: [String: Macro.Type] = [
        "AutoEquatable": AutoEquatableMacro.self,
    ]

    func testAutoEquatableOnStruct() throws {
        assertMacroExpansion(
            // Input: kode sebelum expansion
            """
            @AutoEquatable
            struct Point {
                var x: Double
                var y: Double
            }
            """,
            // Expected output: kode setelah expansion
            expandedSource: """
            struct Point {
                var x: Double
                var y: Double

                public static func == (lhs: Point, rhs: Point) -> Bool {
                    return lhs.x == rhs.x &&
                        lhs.y == rhs.y
                }
            }

            extension Point: Equatable {}
            """,
            macros: testMacros
        )
    }

    func testAutoEquatableOnEmptyStruct() throws {
        assertMacroExpansion(
            """
            @AutoEquatable
            struct Empty {}
            """,
            expandedSource: """
            struct Empty {

                public static func == (lhs: Empty, rhs: Empty) -> Bool {
                    return true
                }
            }

            extension Empty: Equatable {}
            """,
            macros: testMacros
        )
    }
}

Step 6: Integrasi ke Xcode Project

Opsi A: Local Package (development)

Drag folder MyMacros ke Xcode project navigator. Lalu di target Build Phases → Link Binary With Libraries, tambahkan MyMacros.framework.

Opsi B: Swift Package Dependency (distribusi)

Di Package.swift aplikasi atau di Xcode → Add Package Dependencies:

swift
dependencies: [
    .package(path: "../MyMacros"),  // local
    // atau
    .package(url: "https://github.com/username/MyMacros", from: "1.0.0"),  // remote
]

Kemudian gunakan di kode aplikasi:

swift
import MyMacros

@AutoEquatable
struct User {
    let id: UUID
    let name: String
}

// Sekarang User.== tersedia otomatis
let u1 = User(id: UUID(), name: "Alice")
let u2 = User(id: UUID(), name: "Bob")
print(u1 == u2)  // false