🪵

swift-syntaxを用いて、簡単なコマンドラインツールを作ってみる

2023/12/23に公開

本記事は、SwiftWednesday Advent Calendar 2023 23日目の記事です。
本記事では、swift-syntaxを用いて、簡単なコマンドラインツールを作成する方法について解説します。

swift-syntaxとは

The swift-syntax package is a set of libraries that work on a source-accurate tree representation of Swift source code, called the SwiftSyntax tree. The SwiftSyntax tree forms the backbone of Swift’s macro system – the macro expansion nodes are represented as SwiftSyntax nodes and a macro generates a SwiftSyntax tree to be inserted into the source file.
https://github.com/apple/swift-syntax

swift-syntaxは、Swiftのソースコードの構文解析や、Swiftのソースコードの生成、変換を行うことができるライブラリ群です。
Swiftのソースコードを解析し抽象構文木を構築します。
realm/SwiftLintapple/swift-formatなどで利用されています。

抽象構文木(AST : Abstract Syntax Tree)とは

抽象構文木(以下、AST)とは、プログラムの構文の各要素をノードとする木構造のデータ構造です。これはプログラムの構造を抽象的に表現したものであり、コンパイラや静的解析器などで利用されます。
例えば、struct PersonをASTに変換すると、以下のようになります。

struct Person {
    var name = "hoge"
}

構造体の宣言、変数の宣言など、それぞれの要素がノードとして表現されます。全体の構造体はStructDeclというノードで表現され、その中にMemberDeclBlockというノードが含まれます。このMemberDeclBlockは、構造体の中にあるメンバの宣言を表しています。
また、nameという変数は、VariableDeclというノードで表現されます。このノードは変数宣言を表しています。

実装

実際に、swift-syntaxを用いて、コマンドラインツールを作成してみます。
今回は、関数の呼び出し引数にSwiftという文字列が含まれているかどうかを検出するコマンドラインツールを作成します。

はじめに、以下のコマンドで、find-swiftという名前のPackageを作成します。

❯ swift package init --type=executable --name=find-swift
Creating executable package: find-swift
Creating Package.swift
Creating .gitignore
Creating Sources/
Creating Sources/main.swift

次に、swift-syntaxを追加します。

import PackageDescription

let package = Package(
    name: "no-print",
    platforms: [
          .macOS(.v10_15)
        ],
    dependencies: [
        .package(url: "https://github.com/apple/swift-syntax", from: "509.0.2")
    ],
    targets: [
        .executableTarget(
            name: "no-print",
            dependencies: [
                .product(name: "SwiftSyntax", package: "swift-syntax"),
                .product(name: "SwiftParser", package: "swift-syntax")
            ],
            path: "Sources"),
    ]
)


swift-syntaxの追加が完了したら、Sources/main.swiftで処理の実装を始めます。
はじめに、swift-syntaxライブラリから、SwiftSyntaxSwiftParserをimportします。
次に、今回解析を行う対象となる適当なソースコードを文字列で定義していきます。

import SwiftSyntax
import SwiftParser

let source = """
test(100, true, "Swift", false)
"""

func main() throws {
    let sourceFile = Parser.parse(source: source)
    let visitor = FindSwiftSyntaxVisitor(viewMode: .fixedUp) // FindSwiftSyntaxVisitorについては、後ほど定義します。
    visitor.walk(sourceFile) // 構文木を走査する
}

実装を進めていく上で、Swift AST Explorerを活用しましょう。

Swift AST Explorerとは、SwiftソースコードのASTを視覚化するためのツールです。
各ノードがソースコードのどの部分に対応しているのかを確認したり、各ノードの構成を表示することができます。
Swift AST Explorerを使って、test(100, true, "Swift", false)のコードを見てみましょう。
すると、以下のような構造になっています。

今回検出したい関数呼び出しのノードは、FunctionCallExprSyntaxであることがわかります。

ここまでの情報をもとに、コードディングを行います。
新しくFindSwiftSyntaxVisitorクラスを定義し、SyntaxVisitorクラスを継承するようにします。

class FindSwiftSyntaxVisitor: SyntaxVisitor {
}

SyntaxVisitorクラスは、構文木を走査するためのクラスであり、Visitorパターンで実装されています。
Visitorパターンとは、GoFのデザインパターンの一つです。
Visitorパターンには、オブジェクトの構造とその操作を分離することで、既存のコードを変更せずに新しい動作を追加することができるという特徴があります。
関数呼び出しは、FunctionCallExprノードであるため、型がFunctionCallExprSyntaxであるvisit関数をオーバーライドします。
SyntaxVisitorがVisitorパターンで実装されているため、ASTの特定のノードに対して独自の動作を簡単に追加することができます。

class FindSwiftSyntaxVisitor: SyntaxVisitor {
    override func visit(_ node: FunctionCallExprSyntax) -> SyntaxVisitorContinueKind {

    }
}

次に、関数呼び出しの引数部分に注目します。Swift AST Explorerで見てみると、メンバ変数argumentsで、関数の引数リストLabeledExprListにアクセスできることがわかります。

次に、LabeledExprListの要素である、LabeledExprに注目します。

LabeledExprSyntaxのメンバ変数expressionで、StringLiteralExprSyntaxにアクセスできることがわかります。
ここで、関数呼び出しの他の引数である 100, true, false について見てみると、IntegerLiterarlExprBooleanLiteralExprとなっていることがわかります。
今回は、関数呼び出しの引数が文字列である時のみ処理を行いたいため、ノードの型を変換する必要があります。
ノードの型を変換する際には、swift-syntaxで用意されている型変換関数を使用するようにします。

class FindSwiftSyntaxVisitor: SyntaxVisitor {
    override func visit(_ node: FunctionCallExprSyntax) -> SyntaxVisitorContinueKind {
        let arguments = node.arguments
        arguments.forEach {
            let stringLiteral = $0.expression.as(StringLiteralExprSyntax.self) // 型変換する
        }
        return super.visit(node)
    }
}

最後に、segments.trimmedDescriptionによって、文字列を取得し、その文字列が"Swift"であるがどうかのチェックを行うことで、検出をすることができます。
以下が、最終的なコードになります。

import SwiftSyntax
import SwiftParser

let source = """
test(100, true, "Swift", false)
"""

class FindSwiftSyntaxVisitor: SyntaxVisitor {
    override func visit(_ node: FunctionCallExprSyntax) -> SyntaxVisitorContinueKind {
        let arguments = node.arguments
        arguments.forEach {
            if let stringLiteral = $0.expression.as(StringLiteralExprSyntax.self) {
                if stringLiteral.segments.trimmedDescription == "Swift" {
                    print("found!")
                }
            }
        }
        return super.visit(node)
    }
}

func main() throws {
    let sourceFile = Parser.parse(source: source)
    let visitor = FindSwiftSyntaxVisitor(viewMode: .fixedUp)
    visitor.walk(sourceFile)
}

try main()

おわりに

本記事では、swift-syntaxを用いて、簡単なコマンドラインツールを作成する方法を解説しました。
今回は、SyntaxRewriterについては触れませんでしたが、SyntaxRewriterによってコードの生成や変換も可能になります。
実際にswift-syntaxを使用して簡単なコマンドラインツールを作成することで、少しずつですがswift-syntaxについて理解できるようになりました。
この記事が、swift-syntaxに入門する際の手助けとなれば幸いです。

参考資料

Discussion