7
Help us understand the problem. What are the problem?

posted at

updated at

swift-syntaxに入門してみた

SwiftSyntax に入門してみた

  • swift-syntax を利用することで、Swiftのソースコードを静的解析し、検査、生成、および変換する事ができます
  • SwiftLintmockolo に利用されています
  • 依存関係の問題が起きる先として、よく見るライブラリですが、自分でSwiftSyntaxのAPIを叩いた事がなかったので入門してみました
  • SwiftSyntax を利用してソースコードを静的解析し、BuildToolPlugin を実装してみました。

最終的に作成したもの

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

image.png

SwiftSyntax に入門してみた

Package の作成

  • dont-use-hoge という名前の executable target を作成し、Package.swift を開きます
$ 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 には、classExample の2つの 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 に分類されている事がわかりました
  • せっかく SyntaxRewriter なので、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 された結果がこちらです
  • hogefuga に書き変わっている事が確認できます
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)")
}
  • 実行した結果がこちらです
  • 2行目の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

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

まとめ

  • SwiftSyntax に入門してみて、BuildToolPlugin を実装してみました
  • 今までなんとなくイメージだけで SwiftSyntax と接してましたが、入門して他のOSSがどのような挙動をしているか想像つくようになった気分に少しだけなりました
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Sign upLogin
7
Help us understand the problem. What are the problem?