Swift: SwiftSyntaxでソースコードの抽出と部分書き換えする
SwiftSyntaxを用いると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 ExplorerにPackage.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/..."
なので、FunctionCallExprSyntax
のIdentifierExprSyntax
がPackage
になっている箇所を見つけて、構造を深掘っていけば良い。
構文木を探索するには、SyntaxVisitor
を用いる。基本的には構文木の中でも確認したい構文のルートとなるノード(Syntax
)からvisit()
関数を定義して子ノードを深掘ってゆく。
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()
で一気に探索するパターン。
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()
に責務を分けて探索するパターン。
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
する必要があり、不要な深掘りが実行される可能性があるため場合によっては探索効率が悪くなる。
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()
関数を定義して、そのノード以下の構文木を再構成して返す。
final class MySyntaxRewriter: SyntaxRewriter {
override func visit(_ node: FunctionCallExprSyntax) -> ExprSyntax {
// nodeを加工する
return super.visit(modified_node)
}
}
// 書き換えた構文木を取得する
MySyntaxRewriter().visit(packageSwift)
書き換えの方法は大きく分けて2通りある。
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 対象のノードからルートノードまでを遡って辿りながら構文チェックをし、対象のノードだけを書き換える
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)
}
}
こちらの方が変更範囲が狭くなるのでいいかも。
書き換えた結果
// 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