9
16

カスタムSwift Macroマクロの作成 (例:SF Symbol / URLバリデータ / iCloudキーバリューストアバインド)

Last updated at Posted at 2023-10-05

この記事では、独自のカスタムSwiftマクロの作成について説明します。2種類のマクロを取り上げます:ExpressionMacroは、式を展開し、式の記述を助けるものです;AccessorMacroは、変数にsetとgetを追加します。以下はいくつかの例です:

  • SF Symbolの名前を検証し、それをSwiftUI Imageに変換します(名前が無効の場合はコンパイルエラー)
  • 文字列が有効なURLであるか検証し、URLオブジェクトに変換します(URLが無効の場合はコンパイル時エラー)
  • 変数の値をiCloudキーバリューストレージにバインドします。

スクリーンショット 2023-10-05 17.02.52.png

Swiftマクロは、WWDC 2023で発表された新機能です。これをテストするにはXcode 15が必要です。ただし、古いiOSおよびMacOSアプリとの下位互換性があります(Xcodeはコンパイル時にマクロを展開するため)。

新しいMacro Swiftパッケージの作成

コマンドラインで、パッケージを配置したいディレクトリに移動します。次に、以下のように入力します:

mkdir ExampleSwiftMacro
cd ExampleSwiftMacro
swift package init --type macro

これで、Package.swiftファイルをダブルクリックして、XcodeでSwiftパッケージを開くことができます。

ターミナルからこのSwiftパッケージを作成する以外にも、Xcode内で作成することもできます。Fileメニューをクリックし、Newをクリックし、Packageをクリックしてから、「Swift Macro」を選択します。

スクリーンショット 2023-10-05 16.10.49.png

重要なファイルは2つあります:ExampleSwiftMacro.swiftは、Macroの定義を提供します。そして、ExampleSwiftMacroMacro.swiftファイルは、Macroの実装を提供します。

マクロの実装

public struct StringifyMacro: ExpressionMacro {
    public static func expansion(
        of node: some FreestandingMacroExpansionSyntax,
        in context: some MacroExpansionContext
    ) -> ExprSyntax {
        guard let argument = node.argumentList.first?.expression else {
            fatalError("compiler bug: the macro does not have any arguments")
        }

        return "(\(argument), \(literal: argument.description))"
    }
}

この例のマクロでは、node.argumentListを読み取ろうとしています。これは、このマクロへの入力パラメータです。

また、#アノテーションを使用して上記のマクロをコードで呼び出すことができるように、マクロの定義を定義する必要があります。

@freestanding(expression)
public macro stringify<T>(_ value: T) -> (T, String) = #externalMacro(module: "ExampleSwiftMacroMacros", type: "StringifyMacro")

例えば、次の関数でマクロを呼び出す場合:

let (result, code) = #stringify(17 + 25)

print("The value \(result) was produced by the code \"\(code)\"")

node.argumentList.first?.expressionは、式a + bになります。
上記のコードの出力は以下のとおりです:

The value 42 was produced by the code "17 + 25"

上記の出力は、マクロ実装のreturn文によって生成されます:

return "(\(argument), \(literal: argument.description))"

(argument) は、操作17 + 25の結果を生成しargument.descriptionはその操作の元のコードを出力します。

別の例

上記の例のために変数aとbも設定できます。

let a = 17
let b = 25

let (result, code) = #stringify(a + b)

print("The value \(result) was produced by the code \"\(code)\"")

出力は次のとおりです:

The value 42 was produced by the code "a + b"

ExpressionMacroはマクロは、元のコードを展開されたコードに書き換えると理解できます。

パッケージ内の提供されるマクロのリストの定義

上記のマクロをこのSwiftパッケージに含まれるマクロのリストに追加できます:

@main
struct ExampleSwiftMacroPlugin: CompilerPlugin {
    let providingMacros: [Macro.Type] = [
        StringifyMacro.self,
    ]
}

文字列をURLに変換するマクロの実装

文字列をURLに変換するマクロも実装できます。通常、以下のコードを使用する必要があります:

if let url = URL(string: "https://example.com") { ... }

URL文字列が無効の場合、コンパイル時にエラーは発生しませんが、実行時には動作しません。
しかし、Swift Macroの助けを借りて、コンパイル時にURLの有効性をチェックできます。以下のコードは、プロトコルがhttpまたはhttpsであり、有効なホスト名が存在するかどうかを確認します。

import Foundation
import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros

public struct URLMacro: ExpressionMacro {
    
    enum URLMacroError: Error {
        case missingArgument
        case argumentNotString
        case invalidURL
        case invalidURL_Scheme
        case invalidURL_Host
    }
    
    public static func expansion(
        of node: some FreestandingMacroExpansionSyntax,
        in context: some MacroExpansionContext
    ) throws -> ExprSyntax {
        guard let argument = node.argumentList.first?.expression else {
            throw URLMacroError.missingArgument
        }
        
        guard let stringLiteralExpr = argument.as(StringLiteralExprSyntax.self),
              let segment = stringLiteralExpr.segments.first?.as(StringSegmentSyntax.self),
              stringLiteralExpr.segments.count == 1
        else {
            throw URLMacroError.argumentNotString
        }
        
        let text = segment.content.text
        
        guard let url = URL(string: text) else {
            throw URLMacroError.invalidURL
        }
        
        guard let scheme = url.scheme,
              ["http", "https"].contains(scheme) else {
            throw URLMacroError.invalidURL_Scheme
        }
        
        guard let host = url.host,
              !host.isEmpty else {
            throw URLMacroError.invalidURL_Host
        }
        
        return #"URL(string: "\#(raw: text)")!"#
    }
    
}

上記のSwift Macroはまず、入力の最初の引数を取得しようとします。次に、入力が有効な文字列であるかどうかを確認します(したがって、このマクロに整数を入力すると、Xcodeはコードをコンパイルしません)。次に、文字列をURLに変換しようとします。成功した場合、マクロコードをURL(string:)コードで置き換えます。それ以外の場合、コンパイルされません。
上記のコードでは、カスタムエラータイプも定義しています。コードにエラーがある場合、Xcodeはエラーコードを通知します。
次に、コード内で#urlFromStringを呼び出すためのマクロ定義を追加します:

@freestanding(expression)
public macro urlFromString<T>(_ value: T) -> (T, String) = #externalMacro(module: "ExampleSwiftMacroMacros", type: "URLMacro")

moduleはMacro定義のコードファイルを含むフォルダの名前であり、typeはExpressionMacroに準拠するstructの名前です。
これで、コード内で#urlFromStringを呼び出すことができます:

let goodURL = #urlFromString("https://apple.com")
print(goodURL.host ?? "")

間違ったURLを提供すると、コンパイラはエラーをスローします(コンパイル時にコードに無効なURLがあることがわかります):

スクリーンショット 2023-10-05 13.31.16.png

スクリーンショット 2023-10-05 13.31.32.png

コンパイル時にSwiftUI Image SF Symbolsを検証する

Swift のマクロを使用して、コンパイル時に SF Symbol のコードが有効かどうかを検証できます。したがって、存在しない SF Symbol のコードを入力すると、コンパイル時にエラーが発生します。
まず、このマクロを実装します:

public struct SwiftUISystemImageMacro: ExpressionMacro {
    
    enum SFSymbolMacroError: Error {
        case missingArgument
        case argumentNotString
        case invalidSFSymbolName
    }
    
    public static func expansion(
        of node: some FreestandingMacroExpansionSyntax,
        in context: some MacroExpansionContext
    ) throws -> ExprSyntax {
        guard let argument = node.argumentList.first?.expression else {
            throw SFSymbolMacroError.missingArgument
        }
        
        guard let stringLiteralExpr = argument.as(StringLiteralExprSyntax.self),
              let segment = stringLiteralExpr.segments.first?.as(StringSegmentSyntax.self),
              stringLiteralExpr.segments.count == 1
        else {
            throw SFSymbolMacroError.argumentNotString
        }
        
        let text = segment.content.text
        
        #if os(iOS)
        guard UIImage(systemName: text) != nil else {
            throw SFSymbolMacroError.invalidSFSymbolName
        }
        #elseif os(macOS)
        guard NSImage(systemSymbolName: text, accessibilityDescription: nil) != nil else {
            throw SFSymbolMacroError.invalidSFSymbolName
        }
        #endif
        
        return #"Image(systemName: "\#(raw: text)")"#
    }
}

上記のコードは、入力された画像名がSF Symbolの画像に存在するかどうかをチェックします(その画像名を使用してUIImageまたはNSImageを作成することで);もしその画像名が存在すれば、その画像を持つSwiftUI Imageオブジェクトを返します。存在しない場合はエラーをスローします。
次に、パッケージに提供されるマクロのリストにそれを追加します:

@main
struct ExampleSwiftMacroPlugin: CompilerPlugin {
    let providingMacros: [Macro.Type] = [
        StringifyMacro.self,
        URLMacro.self,
        SwiftUISystemImageMacro.self
    ]
}

そして、このマクロのショートカットを定義します:

@freestanding(expression)
public macro systemImage(_ str: String) -> Image = #externalMacro(module: "ExampleSwiftMacroMacros", type: "SwiftUISystemImageMacro")

今度は、SwiftUIのビューで、間違った画像名を入力した場合にランタイムで空の画像を表示するImage(systemImage:)の代わりに、コンパイル時にエラーが出ます。

スクリーンショット 2023-10-05 13.40.22.png

変数に修飾子を追加するマクロの作成(例:iCloud キー値ストレージへのアクセス)

変数の値がiCloudキー値ストレージからアクセスされるように、読み取りおよび書き込み修飾子を変数に追加するマクロを持つことができます。

スクリーンショット 2023-10-05 15.39.01.png

上は展開された値の画像です。このマクロで、この変数にgetとsetの修飾子を追加し、デフォルト値も提供していることがわかります。

public struct NSUbiquitousKeyValueStoreMacro: AccessorMacro {
    
    enum NSUbiquitousKeyValueStoreMacroError: Error {
        case noTypeDefined
        case cannotGetBinding
        case cannotGetVariableName
    }
    
    public static func expansion(of node: AttributeSyntax,
                                 providingAccessorsOf declaration: some DeclSyntaxProtocol,
                                 in context: some MacroExpansionContext) throws -> [AccessorDeclSyntax] {
        
        let typeAttribute = node.attributeName.as(IdentifierTypeSyntax.self)
        guard let dataType = typeAttribute?.type else {
            throw NSUbiquitousKeyValueStoreMacroError.noTypeDefined
        }
        
        guard let varDecl = declaration.as(VariableDeclSyntax.self) else {
            return []
        }
        
        guard let binding = varDecl.bindings.first?.as(PatternBindingSyntax.self)else {
            throw NSUbiquitousKeyValueStoreMacroError.cannotGetBinding
        }
        
        guard let identifier = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier.text else {
            throw NSUbiquitousKeyValueStoreMacroError.cannotGetVariableName
        }
        
        var defaultValue = ""
        if let value = binding.initializer?.value {
            defaultValue = " ?? \(value)"
        }
        
        let getAccessor: AccessorDeclSyntax =
          """
          get {
              (NSUbiquitousKeyValueStore.default.object(forKey: "\(raw: identifier)") as? \(raw: dataType))\(raw: defaultValue)
          }
          """
        
        let setAccessor: AccessorDeclSyntax =
          """
          set {
              NSUbiquitousKeyValueStore.default.set(newValue, forKey: "\(raw: identifier)")
          }
          """
        return [getAccessor, setAccessor]
    }
    
}

extension IdentifierTypeSyntax {
    var type: SyntaxProtocol? {
        genericArgumentClause?.arguments.first?.as(GenericArgumentSyntax.self)?.argument.as(OptionalTypeSyntax.self)?.wrappedType
        ?? genericArgumentClause?.arguments.first?.as(GenericArgumentSyntax.self)
    }
}

このマクロを実装するには、<>記号内に含めたタイプを読み取るためにextension IdentifierTypeSyntax.typeを使用します。

extension IdentifierTypeSyntax {
    var type: SyntaxProtocol? {
        genericArgumentClause?.arguments.first?.as(GenericArgumentSyntax.self)?.argument.as(OptionalTypeSyntax.self)?.wrappedType
        ?? genericArgumentClause?.arguments.first?.as(GenericArgumentSyntax.self)
    }
}

let typeAttribute = node.attributeName.as(IdentifierTypeSyntax.self)
guard let dataType = typeAttribute?.type else {
  throw NSUbiquitousKeyValueStoreMacroError.noTypeDefined
}
...

@iCloudKeyValueと入力しているため、タイプはStringになります。
識別子(キー値ペアのキー)は変数名と同じになり、以下のコードを使用して読み取ることができます:

 guard let identifier = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier.text else {
            throw NSUbiquitousKeyValueStoreMacroError.cannotGetVariableName
        }

さらに、ユーザーが提供したデフォルト値も読み取ることができます:

        guard let binding = varDecl.bindings.first?.as(PatternBindingSyntax.self)else {
            throw NSUbiquitousKeyValueStoreMacroError.cannotGetBinding
        }

                var defaultValue = ""
        if let value = binding.initializer?.value {
            defaultValue = " ?? \(value)"
        }

次に、setとgetのコードを生成します。

let getAccessor: AccessorDeclSyntax =
          """
          get {
              (NSUbiquitousKeyValueStore.default.object(forKey: "\(raw: identifier)") as? \(raw: dataType))\(raw: defaultValue)
          }
          """
        
        let setAccessor: AccessorDeclSyntax =
          """
          set {
              NSUbiquitousKeyValueStore.default.set(newValue, forKey: "\(raw: identifier)")
          }
          """
        return [getAccessor, setAccessor]

カスタムコンパイラエラーメッセージの設定

エラーが発生する理由を示すカスタムのコンパイラエラーメッセージを提供することもできます。例えば、上記のStringからURLへの関数で、プロトコル(URLスキーム)がhttpまたはhttpsでない場合に、カスタムメッセージを表示できます:

スクリーンショット 2023-10-05 16.42.23.png

まず、エラーのケースを持つenumを作成する必要があります。ケースは問題をより詳しく説明するための追加のパラメータを含むことができることに注意してください。たとえば、ユーザーが間違ったプロトコルを使用した場合、使用されたプロトコルを含めることができます。

public enum CodingKeysMacroDiagnostic {
    case missingArgument
    case argumentNotString
    case invalidURL
    case invalidURL_Scheme(String)
    case invalidURL_Host
}

次に、カスタムメッセージを生成する関数を提供するために、上記のenumの拡張を書きます:

extension CodingKeysMacroDiagnostic: DiagnosticMessage {
    func diagnose(at node: some SyntaxProtocol) -> Diagnostic {
        Diagnostic(node: Syntax(node), message: self)
    }
    
    public var message: String {
        switch self {
            case .missingArgument:
                return "You need to provide the argument in the parameter"
            case .argumentNotString:
                return "The argument you provided is not a String"
            case .invalidURL:
                return "Cannot initialize an URL from your provided string"
            case .invalidURL_Scheme(let scheme):
                return "\(scheme) is not a supported protocol"
            case .invalidURL_Host:
                return "The hostname of this URL is invalid"
        }
    }
    
    public var severity: DiagnosticSeverity { .error }
    
    public var diagnosticID: MessageID {
        MessageID(domain: "Swift", id: "CodingKeysMacro.\(self)")
    }
}

ご覧の通り、messageプロパティをオーバーライドすることでカスタムメッセージを定義しています。
Swift Macroの実装において、診断情報をコンテキストに返すコード行を追加します:

public struct URLMacro: ExpressionMacro {
    
    enum URLMacroError: Error {
        case missingArgument
        case argumentNotString
        case invalidURL
        case invalidURL_Scheme
        case invalidURL_Host
    }
    
    public static func expansion(
        of node: some FreestandingMacroExpansionSyntax,
        in context: some MacroExpansionContext
    ) throws -> ExprSyntax {
        guard let argument = node.argumentList.first?.expression else {
+            context.diagnose(CodingKeysMacroDiagnostic.missingArgument.diagnose(at: node))
            throw URLMacroError.missingArgument
        }
        
        guard let stringLiteralExpr = argument.as(StringLiteralExprSyntax.self),
              let segment = stringLiteralExpr.segments.first?.as(StringSegmentSyntax.self),
              stringLiteralExpr.segments.count == 1
        else {
+            context.diagnose(CodingKeysMacroDiagnostic.argumentNotString.diagnose(at: node))
            throw URLMacroError.argumentNotString
        }
        
        let text = segment.content.text
        
        guard let url = URL(string: text) else {
+            context.diagnose(CodingKeysMacroDiagnostic.invalidURL.diagnose(at: node))
            throw URLMacroError.invalidURL
        }
        
        guard let scheme = url.scheme,
              ["http", "https"].contains(scheme) else {
+            context.diagnose(CodingKeysMacroDiagnostic.invalidURL_Scheme(url.scheme ?? "?").diagnose(at: node))
            throw URLMacroError.invalidURL_Scheme
        }
        
        guard let host = url.host,
              !host.isEmpty else {
+            context.diagnose(CodingKeysMacroDiagnostic.invalidURL_Host.diagnose(at: node))
            throw URLMacroError.invalidURL_Host
        }
        
        return #"URL(string: "\#(raw: text)")!"#
    }
    
}

これをテストすると、診断メッセージが表示されます。

Macroのその他の用途

この記事では、式を完成させるのを助けるExpressionMacroと、変数に修飾子を追加するのを助けるAccessorMacroの2種類のMacroを紹介します。
この記事のソースコードは以下のリンクから参照できます:

他にもいくつかの種類のMacrosがあります:

----------2023-10-05-15.45.10.png

また、Macrosの使用方法についてもっと学べる興味深いGithubリポジトリもあります:


WWDC 2023


お読みいただき、ありがとうございました。

ニュースレター: https://blog.mszpro.com

Mastodon/MissKey: @me@mszpro.com https://sns.mszpro.com

:relaxed: Twitter @MszPro

:relaxed: 個人ウェブサイト https://MszPro.com


9
16
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
16