🍁

Swift: SwiftSyntaxでソースコードの抽出と部分書き換えする

2023/08/01に公開

SwiftSyntaxを用いるとSwiftコードの構文解析ができる。具体例を用いて抽出と部分書き換えの仕方をまとめる。(サンプルコード

以下のPackage.swiftファイルを対象として構文解析する。

Package.swift
// swift-tools-version: 5.8
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
    name: "SwiftSyntaxSample",
    platforms: [
        .macOS(.v13)
    ],
    products: [
        .executable(
            name: "sss",
            targets: ["SwiftSyntaxSample"]
        )
    ],
    dependencies: [
        .package(url: "https://github.com/apple/swift-argument-parser.git", exact: "1.2.2"),
        .package(url: "https://github.com/apple/swift-syntax.git", exact: "508.0.0")
    ],
    targets: [
        .executableTarget(
            name: "SwiftSyntaxSample",
            dependencies: [
                .product(name: "ArgumentParser", package: "swift-argument-parser"),
                .product(name: "SwiftSyntax", package: "swift-syntax"),
                .product(name: "SwiftSyntaxParser", package: "swift-syntax")
            ]
        )
    ]
)

下準備

SwiftSyntaxの構文解析エンジンにSwiftのソースコードを渡して、抽象構文木(AST)を生成する。

import Foundation
import SwiftSyntax
import SwiftSyntaxParser

let url = URL(fileURLWithPath: "./Package.swift") // Package.swiftへのパス
let packageSwift: SourceFileSyntax = try SyntaxParser.parse(url)

抽出

Package.swiftから依存しているパッケージのURL一覧を抽出してみる。

まずはSwift AST ExplorerPackage.swiftのソースをまるまる投げて目的箇所がどんなSyntaxなのか、そのSyntaxに辿り着くにはどのようにSyntaxを辿ればいいか確認する。

すると、だいたいこんな感じ。

.
└── 中略
   └── FunctionCallExprSyntax 🐮
       ├── IdentifierExprSyntax
       │   └── identifier = "Package"
       └── TupleExprElementListSyntax
           └── TupleExprElementSyntax
               ├── label = "dependencies"
               └── ArrayExprSyntax
                   └── ArrayElementListSyntax
                       └── ArrayElementSyntax
                           └── FunctionCallExprSyntax 🐸
                               ├── MemberAccessExpr
                               │   └── name = "package"
                               └── TupleExprElementListSyntax
                                   └── TupleExprElementSyntax
                                       ├── label = "url"
                                       └── StringLiteralExprSyntax
                                           └── StringSegmentSyntax
                                               └── content = "https://github.com/..."

なので、FunctionCallExprSyntaxIdentifierExprSyntaxPackageになっている箇所を見つけて、構造を深掘っていけば良い。

構文木を探索するには、SyntaxVisitorを用いる。基本的には構文木の中でも確認したい構文のルートとなるノード(Syntax)からvisit()関数を定義して子ノードを深掘ってゆく。

SyntaxVisitorのざっくり使い方
final class MySyntaxVisitor: SyntaxVisitor {
    override func visit(_ node: FunctionCallExprSyntax) -> SyntaxVisitorContinueKind {
        if 子ノードを深掘りたい場合 {
            return .visitChildren
        } else {
            return .skipChildren
        }
    }

    // nodeの種類ごとに数多くのvisit()メソッドが用意されている
    override func visit(_ node: ArrayElementSyntax) -> SyntaxVisitorContinueKind {}
    override func visit(_ node: TupleTypeElementListSyntax) -> SyntaxVisitorContinueKind {}
}

// 走査する
MySyntaxVisitor(viewMode: .all).walk(packageSwift)

深掘りに使う道具

  • .parent: 親ノードを取得する
  • .argumentList, .elements, .identifier, .label, expression, .calledExpressionなど: 子ノードへのアクセス方法
  • .as(〇〇Syntax.self): ノードのSyntaxキャストを試みる
  • .is(〇〇Syntax.self): ノードのSyntax判定

探索する方法は大きく3通りある。

1 ルートノードから目的のノードまで一気に深掘る

1回のvisit()で一気に探索するパターン。

VISITOR_PATTERN_1
import SwiftSyntax
import SwiftSyntaxParser

final class PackageDetectorSyntaxVisitor: SyntaxVisitor {
    override func visit(_ node: FunctionCallExprSyntax) -> SyntaxVisitorContinueKind {
        // 🐮のFunctionCallExprSyntaxから探索開始
        if node.calledExpression.as(IdentifierExprSyntax.self)?.identifier.text == "Package",
           let tuple = node.argumentList.first(where: { $0.label?.text == "dependencies" }),
           let array = tuple.expression.as(ArrayExprSyntax.self) {

            array.elements
                .compactMap { element in
                    element.expression.as(FunctionCallExprSyntax.self)
                }
                .filter { funcCall in
                    funcCall.calledExpression.as(MemberAccessExprSyntax.self)?.name.text == "package"
                }
                .forEach { funcCall in
                    let urlText = funcCall.argumentList
                        .first { $0.label?.text == "url" }?
                        .expression.as(StringLiteralExprSyntax.self)?
                        .segments
                        .compactMap { element -> String? in
                            if case .stringSegment(let segment) = element {
                                return segment.content.text
                            }
                            return nil
                        }
                        .first
                    if let urlText {
                        Swift.print(urlText)
                    }
                }
        }
        return .skipChildren
    }
}

2 ルートノードから目的のノードへ少しずつ深掘る

いくつかのvisit()に責務を分けて探索するパターン。

VISITOR_PATTERN_2
import SwiftSyntax
import SwiftSyntaxParser

final class PackageDetectorSyntaxVisitor: SyntaxVisitor {
    override func visit(_ node: FunctionCallExprSyntax) -> SyntaxVisitorContinueKind {
        // 🐮の方のFunctionCallExprSyntaxの場合
        if node.calledExpression.as(IdentifierExprSyntax.self)?.identifier.text == "Package" {
            return .visitChildren
        }
        // 🐸の方のFunctionCallExprSyntaxの場合
        if node.calledExpression.as(MemberAccessExprSyntax.self)?.name.text == "package" {
            let urlText = node.argumentList
                .first { $0.label?.text == "url" }?
                .expression.as(StringLiteralExprSyntax.self)?
                .segments
                .compactMap { element -> String? in
                    if case .stringSegment(let segment) = element {
                        return segment.content.text
                    }
                    return nil
                }
                .first
            if let urlText {
                Swift.print(urlText)
            }
        }
        return .skipChildren
    }

    override func visit(_ node: TupleExprElementSyntax) -> SyntaxVisitorContinueKind {
        if node.label?.text == "dependencies", node.expression.is(ArrayExprSyntax.self) {
            return .visitChildren
        }
        return .skipChildren
    }
}

3 目的のノードからルートノードまでを一気に辿る

1回のvisit()で親ノード方向に一気に探索するパターン。ただし、この手法ではreturn .visitChildrenする必要があり、不要な深掘りが実行される可能性があるため場合によっては探索効率が悪くなる。

VISITOR_PATTERN_3
import SwiftSyntax
import SwiftSyntaxParser

final class PackageDetectorSyntaxVisitor: SyntaxVisitor {
    override func visit(_ node: FunctionCallExprSyntax) -> SyntaxVisitorContinueKind {
        // 🐸のFunctionCallExprSyntaxから探索開始
        // 目的のノードかどうかを確認する
        if node.calledExpression.as(MemberAccessExprSyntax.self)?.name.text == "package" {
            // ルートノードまでの構文を一気に確認していく
            if let element = node.parent?.as(ArrayElementSyntax.self),
               let list = element.parent?.as(ArrayElementListSyntax.self),
               let array = list.parent?.as(ArrayExprSyntax.self),
               let tuple = array.parent?.as(TupleExprElementSyntax.self),
               tuple.label?.text == "dependencies",
               let tupleList = tuple.parent?.as(TupleExprElementListSyntax.self),
               let funcCall = tupleList.parent?.as(FunctionCallExprSyntax.self),
               funcCall.calledExpression.as(IdentifierExprSyntax.self)?.identifier.text == "Package" {
                let urlText = node.argumentList
                    .first { $0.label?.text == "url" }?
                    .expression.as(StringLiteralExprSyntax.self)?
                    .segments
                    .compactMap { element -> String? in
                        if case .stringSegment(let segment) = element {
                            return segment.content.text
                        }
                        return nil
                    }
                    .first
                if let urlText {
                    Swift.print(urlText)
                }
            }
        }
        return .visitChildren
    }
}
抽出した結果
https://github.com/apple/swift-argument-parser.git
https://github.com/apple/swift-syntax.git

部分書き換え

Package.swiftで依存しているパッケージのURLをHello World!!に書き換えてみる。

部分書き換えを行うには、SyntaxRewriterを用いる。書き換えたいノード(Syntax)のvisit()関数を定義して、そのノード以下の構文木を再構成して返す。

SyntaxRewriterのざっくり使い方
final class MySyntaxRewriter: SyntaxRewriter {
    override func visit(_ node: FunctionCallExprSyntax) -> ExprSyntax {
        // nodeを加工する
        return super.visit(modified_node)
    }
}

// 書き換えた構文木を取得する
MySyntaxRewriter().visit(packageSwift)

書き換えの方法は大きく分けて2通りある。

1 ルートノードから書き換え対象のノードまで構文チェックしながら深掘りしてゆき、書き換えられた構文木を再構成する

REWRITER_PATTERN_1
import SwiftSyntax
import SwiftSyntaxParser

final class PackageUpdaterSyntaxRewriter: SyntaxRewriter {
    private let package: String
    private let newText: String

    init(package: String, to newText: String) {
        self.package = package
        self.newText = newText
    }

    override func visit(_ node: FunctionCallExprSyntax) -> ExprSyntax {
        // 🐮のFunctionCallExprSyntaxから書き換える
	guard node.calledExpression.as(IdentifierExprSyntax.self)?.identifier.text == "Package" else {
            return super.visit(node)
        }
        let newArgumentList = node.argumentList.map { item -> TupleExprElementSyntax in
            guard item.label?.text == "dependencies",
                  let array = item.expression.as(ArrayExprSyntax.self) else {
                return item
            }
            let newArray = array.elements.map { item -> ArrayElementSyntax in
                guard let funcCall = item.expression.as(FunctionCallExprSyntax.self),
                      funcCall.calledExpression.as(MemberAccessExprSyntax.self)?.name.text == "package" else {
                    return item
                }
                let newArgumentList = funcCall.argumentList.map { item -> TupleExprElementSyntax in
                    guard item.label?.text == "url",
                          let literal = item.expression.as(StringLiteralExprSyntax.self) else {
                        return item
                    }
                    let urlText = item
                        .expression.as(StringLiteralExprSyntax.self)?
                        .segments
                        .compactMap { element -> String? in
                            if case .stringSegment(let segment) = element {
                                return segment.content.text
                            }
                            return nil
                        }
                        .first
                    guard urlText == package else { return item }
                    return TupleExprElementSyntax(
                        leadingTrivia: item.leadingTrivia,
                        item.unexpectedBeforeLabel,
                        label: item.label,
                        item.unexpectedBetweenLabelAndColon,
                        colon: item.colon,
                        item.unexpectedBetweenColonAndExpression,
                        expression: literal.withSegments(
                            StringLiteralSegmentsSyntax([
                                .stringSegment(StringSegmentSyntax(content: TokenSyntax.stringSegment(newText)))
                            ])
                        ),
                        item.unexpectedBetweenExpressionAndTrailingComma,
                        trailingComma: item.trailingComma,
                        item.unexpectedAfterTrailingComma,
                        trailingTrivia: item.trailingTrivia

                    )
                }
                return item.withExpression(ExprSyntax(funcCall.withArgumentList(
                    TupleExprElementListSyntax(newArgumentList)
                )))
            }
            return item.withExpression(ExprSyntax(
                ArrayExprSyntax(
                    leadingTrivia: array.leadingTrivia,
                    array.unexpectedBeforeLeftSquare,
                    leftSquare: array.leftSquare,
                    array.unexpectedBetweenLeftSquareAndElements,
                    elements: ArrayElementListSyntax(newArray),
                    array.unexpectedBetweenElementsAndRightSquare,
                    rightSquare: array.rightSquare,
                    array.unexpectedAfterRightSquare,
                    trailingTrivia: array.trailingTrivia
                )
            ))
        }
        return super.visit(node.withArgumentList(TupleExprElementListSyntax(newArgumentList)))
    }
}

書き換えたい箇所以外はそのままに構文木を再構成するための工夫

  • .mapを使って条件に当てはまった場合だけノードを書き換える
  • 〇〇ExprSyntax.init()でノードを作るときは、デフォルト引数を使わずに元のノードの持つ属性を引き継ぐようにする

このようにしないと、空白やカンマ、改行なども改変してしまう。

2 対象のノードからルートノードまでを遡って辿りながら構文チェックをし、対象のノードだけを書き換える

REWRITER_PATTERN_2
import SwiftSyntax
import SwiftSyntaxParser

final class PackageUpdaterSyntaxRewriter: SyntaxRewriter {
    private let package: String
    private let newText: String

    init(package: String, to newText: String) {
        self.package = package
        self.newText = newText
    }

    override func visit(_ node: FunctionCallExprSyntax) -> ExprSyntax {
        // 🐸のFunctionCallExprSyntaxから書き換える
        // 目的のノードかどうかを確認する
        if node.calledExpression.as(MemberAccessExprSyntax.self)?.name.text == "package" {
            // ルートノードまでの構文を一気に確認していく
            if let element = node.parent?.as(ArrayElementSyntax.self),
               let list = element.parent?.as(ArrayElementListSyntax.self),
               let array = list.parent?.as(ArrayExprSyntax.self),
               let tuple = array.parent?.as(TupleExprElementSyntax.self),
               tuple.label?.text == "dependencies",
               let tupleList = tuple.parent?.as(TupleExprElementListSyntax.self),
               let funcCall = tupleList.parent?.as(FunctionCallExprSyntax.self),
               funcCall.calledExpression.as(IdentifierExprSyntax.self)?.identifier.text == "Package" {
                let newArgumentList = node.argumentList.map { item -> TupleExprElementSyntax in
                    guard item.label?.text == "url",
                          let literal = item.expression.as(StringLiteralExprSyntax.self) else {
                        return item
                    }
                    let urlText = item
                        .expression.as(StringLiteralExprSyntax.self)?
                        .segments
                        .compactMap { element -> String? in
                            if case .stringSegment(let segment) = element {
                                return segment.content.text
                            }
                            return nil
                        }
                        .first
                    guard urlText == package else { return item }
                    return TupleExprElementSyntax(
                        leadingTrivia: item.leadingTrivia,
                        item.unexpectedBeforeLabel,
                        label: item.label,
                        item.unexpectedBetweenLabelAndColon,
                        colon: item.colon,
                        item.unexpectedBetweenColonAndExpression,
                        expression: literal.withSegments(
                            StringLiteralSegmentsSyntax([
                                .stringSegment(StringSegmentSyntax(content: TokenSyntax.stringSegment(newText)))
                            ])
                        ),
                        item.unexpectedBetweenExpressionAndTrailingComma,
                        trailingComma: item.trailingComma,
                        item.unexpectedAfterTrailingComma,
                        trailingTrivia: item.trailingTrivia

                    )
                }
                return super.visit(node.withArgumentList(TupleExprElementListSyntax(newArgumentList)))
            }
        }
        return super.visit(node)
    }
}

こちらの方が変更範囲が狭くなるのでいいかも。

書き換えた結果
Package.swift
// swift-tools-version: 5.8
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
    name: "SwiftSyntaxSample",
    platforms: [
        .macOS(.v13)
    ],
    products: [
        .executable(
            name: "sss",
            targets: ["SwiftSyntaxSample"]
        )
    ],
    dependencies: [
-       .package(url: "https://github.com/apple/swift-argument-parser.git", exact: "1.2.2"),
+       .package(url: "Hello World!!", exact: "1.2.2"),
        .package(url: "https://github.com/apple/swift-syntax.git", exact: "508.0.0")
    ],
    targets: [
        .executableTarget(
            name: "SwiftSyntaxSample",
            dependencies: [
                .product(name: "ArgumentParser", package: "swift-argument-parser"),
                .product(name: "SwiftSyntax", package: "swift-syntax"),
                .product(name: "SwiftSyntaxParser", package: "swift-syntax")
            ]
        )
    ]
)

参考

Discussion