swift-syntaxに入門してみた

# SwiftSyntax に入門してみた

* [swift-syntax](https://github.com/apple/swift-syntax) を利用することで、Swiftのソースコードを静的解析し、検査、生成、および変換する事ができます
* [SwiftLint](https://github.com/realm/SwiftLint) や [mockolo](https://github.com/uber/mockolo) に利用されています
* 依存関係~~の問題が起きる~~先として、よく見るライブラリですが、自分でSwiftSyntaxのAPIを叩いた事がなかったので入門してみました
* SwiftSyntax を利用してソースコードを静的解析し、BuildToolPlugin を実装してみました。

# 最終的に作成したもの

* `DontUseHogePlugin` というBuildToolPluginを作成してみました
* このプラグインは `hoge` という名前の変数を見つけるとビルド時にエラーにします
* SwiftSyntax で構文解析し、変数の定義の箇所を見つけ、その名前が `hoge` の箇所がエラーとなるようにしています

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214313/6de1b00a-ee3b-a78a-44f3-6e7b94f991e1.png)

# SwiftSyntax に入門してみた

## Package の作成

* `dont-use-hoge` という名前の executable target を作成し、Package.swift を開きます

```shell
$ swift package init --type=executable --name=dont-use-hoge
Creating executable package: dont-use-hoge
Creating Package.swift
Creating README.md
Creating .gitignore
Creating Sources/
Creating Sources/dont-use-hoge/main.swift
Creating Tests/
Creating Tests/dont-use-hogeTests/
Creating Tests/dont-use-hogeTests/dont_use_hogeTests.swift
```

## swift-syntax の追加

* Package の dependencies に swift-syntax を追加します
* また、executableTarget の dependencies に SwiftSyntax と SwiftSyntaxParser を追加します

```Package.swift
let package = Package(
    name: "dont-use-hoge",
    dependencies: [
        .package(url: "https://github.com/apple/swift-syntax.git", exact: "0.50600.1"),
    ],
    targets: [
        .executableTarget(
            name: "dont-use-hoge",
            dependencies: [
                .product(name: "SwiftSyntax", package: "swift-syntax"),
                .product(name: "SwiftSyntaxParser", package: "swift-syntax"),
            ]),
    ]
)
```

## SwiftSyntax を使った構文解析

* Sources/dont-use-hoge/main.swift に実装していきます
* SyntaxParser.parse を利用して　SourceFileSyntax を作成します
* source として、適当な class を定義する文字列を渡します

```main.swift
import SwiftSyntax
import SwiftSyntaxParser

let source = """
class Example {
    public private(set) var hoge = "Hello, World!"

    public init() {
    }
}
"""

let sourceFile: SourceFileSyntax = try SyntaxParser.parse(source: source)
```

## 木構造を眺めてみる

* SwiftSyntax を使って構文解析された結果は、構文木として表現されます
* まずは、この構文木を眺めてみました
* この作成された木構造の解析のために、Visitor パターンを利用します
* 探索する node が TokenSyntax の場合は、そのnodeの文字列を表示し、 TokenSyntax 以外の場合は、Node Type を表示します
* その node の深さを記録しておき、深さ分のスペースをつけました

```main.swift
class MySyntaxRewriter: SyntaxRewriter {
    private var nest: Int = 0
    override func visitPre(_ node: Syntax) {
        let space = Array(repeating: " ", count: nest).joined(separator: "|") + "+"
        if "\(node.syntaxNodeType)" == "TokenSyntax" {
            let text = "\(node)".trimmingCharacters(in: .newlines).trimmingCharacters(in: .whitespaces)
            print("\(space)-> \(text)")
        } else {
            print("\(space)\(node.syntaxNodeType)")
        }
        nest += 1
    }
    override func visitPost(_ node: Syntax) {
        nest -= 1
    }
}
_ = MySyntaxRewriter().visit(sourceFile._syntaxNode)
```

* 結果がこちらです
* 例えば、ClassDeclSyntax には、`class` と `Example` の２つの TokenSyntax と MemberDeclBlockSyntax が含まれているようです
* 今回検出したい `hoge` という変数の定義は IdentifierPatternSyntax に分類されているようです

```
+SourceFileSyntax
 +CodeBlockItemListSyntax
 | +CodeBlockItemSyntax
 | | +ClassDeclSyntax
 | | | +-> class
 | | | +-> Example
 | | | +MemberDeclBlockSyntax
 | | | | +-> {
 | | | | +MemberDeclListSyntax
 | | | | | +MemberDeclListItemSyntax
 | | | | | | +VariableDeclSyntax
 | | | | | | | +ModifierListSyntax
 | | | | | | | | +DeclModifierSyntax
 | | | | | | | | | +-> public
 | | | | | | | | +DeclModifierSyntax
 | | | | | | | | | +-> private
 | | | | | | | | | +-> (
 | | | | | | | | | +-> set
 | | | | | | | | | +-> )
 | | | | | | | +-> var
 | | | | | | | +PatternBindingListSyntax
 | | | | | | | | +PatternBindingSyntax
 | | | | | | | | | +IdentifierPatternSyntax
 | | | | | | | | | | +-> hoge
 | | | | | | | | | +InitializerClauseSyntax
 | | | | | | | | | | +-> =
 | | | | | | | | | | +StringLiteralExprSyntax
 | | | | | | | | | | | +-> "
 | | | | | | | | | | | +StringLiteralSegmentsSyntax
 | | | | | | | | | | | | +StringSegmentSyntax
 | | | | | | | | | | | | | +-> Hello, World!
 | | | | | | | | | | | +-> "
 | | | | | +MemberDeclListItemSyntax
 | | | | | | +InitializerDeclSyntax
 | | | | | | | +ModifierListSyntax
 | | | | | | | | +DeclModifierSyntax
 | | | | | | | | | +-> public
 | | | | | | | +-> init
 | | | | | | | +ParameterClauseSyntax
 | | | | | | | | +-> (
 | | | | | | | | +FunctionParameterListSyntax
 | | | | | | | | +-> )
 | | | | | | | +CodeBlockSyntax
 | | | | | | | | +-> {
 | | | | | | | | +CodeBlockItemListSyntax
 | | | | | | | | +-> }
 | | | | +-> }
 +->
```

## SyntaxRewriter を使って書き換える

* 前の章で `hoge` という変数の定義は IdentifierPatternSyntax に分類されている事がわかりました
* せっかく Syntax**Rewriter** なので、`hoge` の IdentifierPatternSyntax を `fuga` に書き換えてみます
* 今回は `visit(_ node: IdentifierPatternSyntax)` で IdentifierPatternSyntax が探索された時の処理を実装します
* IdentifierPatternSyntax が `hoge` の場合は、`fuga` に書き換え、それ以外の場合はそのまま返します
* `SyntaxFactory.makeIdentifier` を使って TokenSyntax を作成しています

```main.swift
class HogeToFugaSyntaxRewriter: SyntaxRewriter {
    override func visit(_ node: IdentifierPatternSyntax) -> PatternSyntax {
        if node.identifier.text == "hoge" {
            return super.visit(node.withIdentifier(SyntaxFactory.makeIdentifier("fuga", trailingTrivia: .spaces(1))))
        } else {
            return super.visit(node)
        }
    }
}
let res = HogeToFugaSyntaxRewriter().visit(sourceFile._syntaxNode)
print(res.description)
```

* print された結果がこちらです
* `hoge` が `fuga` に書き変わっている事が確認できます

```Example.swift
class Example {
    public private(set) var fuga = "Hello, World!"

    public init() {
    }
}
```

## SwiftSyntax を使って変数 `hoge` の位置を検出する

* 書き換えが不要な場合は、SyntaxVisitor を利用して探索する事ができました
* IdentifierPatternSyntax が `hoge` の場合、SourceLocationConverter を利用して、行と何文字目かを記録します

```main.swift
class HogeDetectorSyntaxVisitor: SyntaxVisitor {
    var locations: [(line: Int, column: Int)] = []
    private let locationConverter: SourceLocationConverter
    init(tree: SourceFileSyntax) {
        locationConverter = SourceLocationConverter(file: "", tree: tree)
    }
    override func visit(_ node: IdentifierPatternSyntax) -> SyntaxVisitorContinueKind {
        if node.identifier.text != "hoge" { return .visitChildren }
        let location: SourceLocation = node.startLocation(converter: locationConverter)
        locations.append((line: location.line!, column: location.column!))
        return .visitChildren
    }
}
let hogeDetector = HogeDetectorSyntaxVisitor(tree: sourceFile)
hogeDetector.walk(sourceFile)
for location in hogeDetector.locations {
    print("hoge location line: \(location.line), column: \(location.column)")
}
```

* 実行した結果がこちらです
* ２行目の29文字目で、期待する結果と合致します

```
hoge location line: 2, column: 29
```

* これで、`hoge` という名前の変数が定義された場合にその位置を検出できるようになりました

# BuildToolPlugin を作成してみた

* これまでの実装を利用して、`hoge` という名前の変数が定義された場合にその位置をエラーにする BuildToolPlugin を作成します

## BuildToolPlugin の実装

* Package.swift を編集して、DontUseHogePlugin を追加します

```Package.swift
let package = Package(
    name: "dont-use-hoge",
    dependencies: [
        .package(url: "https://github.com/apple/swift-syntax.git", exact: "0.50600.1"),
    ],
    targets: [
        .executableTarget(
            name: "dont-use-hoge",
            dependencies: [
                .product(name: "SwiftSyntax", package: "swift-syntax"),
                .product(name: "SwiftSyntaxParser", package: "swift-syntax"),
            ]),
        .plugin(name: "DontUseHogePlugin",
                capability: .buildTool(),
                dependencies: [
                    .target(name: "dont-use-hoge"),
                ]),
    ]
)
```

* Plugins/DontUseHogePlugin/DontUseHogePlugin.swift を作成し、`dont-use-hoge` を呼び出すようにします
* 引数にはファイルのパスの配列を渡します

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214313/a461841f-ce58-c3af-9b70-c788550a854c.png)

```DontUseHogePlugin.swift
@main struct DontUseHogePlugin: BuildToolPlugin {
    func createBuildCommands(context: PluginContext, target: Target) async throws -> [Command] {
        let tool = try context.tool(named: "dont-use-hoge")
        let target = target as! SwiftSourceModuleTarget
        let pathListt = target.sourceFiles.map { $0.path }
        return [
            .buildCommand(displayName: "Run dont-use-hoge",
                          executable: tool.path,
                          arguments: pathListt)
        ]
    }
}
```

## dont-use-hoge の実装

* Sources/dont-use-hoge/main.swift を編集して引数のファイルを検査するようにします
* また、`hoge` を検出した場合、エラーを出力します

```main.swift
import SwiftSyntax
import SwiftSyntaxParser

class HogeDetectorSyntaxVisitor: SyntaxVisitor {
    var locations: [(line: Int, column: Int)] = []
    private let locationConverter: SourceLocationConverter
    init(tree: SourceFileSyntax) {
        locationConverter = SourceLocationConverter(file: "", tree: tree)
    }
    override func visit(_ node: IdentifierPatternSyntax) -> SyntaxVisitorContinueKind {
        if node.identifier.text != "hoge" { return .visitChildren }
        let location: SourceLocation = node.startLocation(converter: locationConverter)
        locations.append((line: location.line!, column: location.column!))
        return .visitChildren
    }
}

let url = URL(fileURLWithPath: CommandLine.arguments[1])
let sourceFile: SourceFileSyntax = try SyntaxParser.parse(url)
let hogeDetector = HogeDetectorSyntaxVisitor(tree: sourceFile)
hogeDetector.walk(sourceFile)
for location in hogeDetector.locations {
    // hoge を検出した箇所にエラーを表示する
    print("\(url.path):\(location.line):\(location.column): error: don't use hoge")
}
```

## BuildToolPlugin を利用するサンプル

* Example のターゲットを追加し、plugins に DontUseHogePlugin を追加しました

```Package.swift
let package = Package(
    name: "dont-use-hoge",
    products: [
        .executable(name: "dont-use-hoge", targets: ["dont-use-hoge"]),
        .plugin(name: "DontUseHogePlugin", targets: ["DontUseHogePlugin"]),
    ],
    dependencies: [
        .package(url: "https://github.com/apple/swift-syntax.git", exact: "0.50600.1"),
    ],
    targets: [
        .executableTarget(
            name: "dont-use-hoge",
            dependencies: [
                .product(name: "SwiftSyntax", package: "swift-syntax"),
                .product(name: "SwiftSyntaxParser", package: "swift-syntax"),
            ]),
        .plugin(
            name: "DontUseHogePlugin",
            capability: .buildTool(),
            dependencies: [
                .target(name: "dont-use-hoge"),
            ]),
        .target(
            name: "Example",
            plugins: [
                .plugin(name: "DontUseHogePlugin"),
            ]),
    ]
)
```

* Sources/Example/Example.swift を作成し、`hoge` の変数を含むクラスを実装しました

```Example.swift
import Foundation

class Example {
    public private(set) var hoge = "Hello, World!"

    public init() {
    }
}
```

* しかし、ターゲットを Example に変更してビルドすると `lib_InternalSwiftSyntaxParser.dylib` のロードに失敗したとエラーが出てました

## rpath の指定
* `lib_InternalSwiftSyntaxParser.dylib` のロードに失敗するので、明示的に指定しました
* unsafeFlags を使うと他のプロジェクトから利用できなくなるため、Plugin を公開する場合は、他の解決策を検討する必要がありそうです

```Package.swift
let package = Package(
    name: "dont-use-hoge",
    products: [
        .executable(name: "dont-use-hoge", targets: ["dont-use-hoge"]),
        .plugin(name: "DontUseHogePlugin", targets: ["DontUseHogePlugin"]),
    ],
    dependencies: [
        .package(url: "https://github.com/apple/swift-syntax.git", exact: "0.50600.1"),
    ],
    targets: [
        .executableTarget(
            name: "dont-use-hoge",
            dependencies: [
                .product(name: "SwiftSyntax", package: "swift-syntax"),
                .product(name: "SwiftSyntaxParser", package: "swift-syntax"),
            ],
            linkerSettings: [
                // 追加↓
                .unsafeFlags(["-Xlinker", "-rpath", "-Xlinker", "@executable_path"]),
            ]),
        .plugin(
            name: "DontUseHogePlugin",
            capability: .buildTool(),
            dependencies: [
                .target(name: "dont-use-hoge"),
            ]),
        .target(
            name: "Example",
            plugins: [
                .plugin(name: "DontUseHogePlugin"),
            ]),
        .testTarget(
            name: "dont-use-hogeTests",
            dependencies: ["dont-use-hoge"]),
    ]
)
```

## 結果

* ターゲットを Example に変更して、ビルドすると、`hoge` にエラーが表示されました
* hoge を rename したり、コメントアウトするとエラーがなくなり、他にも hoge 変数を追加すると、エラーが増える事が確認できます
* 他にも `hoge` という名前の関数やhogeに代入するだけでは、エラーになっていないことも確認できます

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/214313/38043a31-2cf5-a092-b3ca-33bf7f21d644.png)

# まとめ
* SwiftSyntax に入門してみて、BuildToolPlugin を実装してみました
* 今までなんとなくイメージだけで SwiftSyntax と接してましたが、入門して他のOSSがどのような挙動をしているか想像つくようになった気分に少しだけなりました