SwiftSyntax に入門してみた
- swift-syntax を利用することで、Swiftのソースコードを静的解析し、検査、生成、および変換する事ができます
- SwiftLint や mockolo に利用されています
- 依存関係
の問題が起きる先として、よく見るライブラリですが、自分でSwiftSyntaxのAPIを叩いた事がなかったので入門してみました - SwiftSyntax を利用してソースコードを静的解析し、BuildToolPlugin を実装してみました。
最終的に作成したもの
-
DontUseHogePlugin
というBuildToolPluginを作成してみました - このプラグインは
hoge
という名前の変数を見つけるとビルド時にエラーにします - SwiftSyntax で構文解析し、変数の定義の箇所を見つけ、その名前が
hoge
の箇所がエラーとなるようにしています
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 には、
class
とExample
の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 された結果がこちらです
-
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)")
}
- 実行した結果がこちらです
- 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
を呼び出すようにします - 引数にはファイルのパスの配列を渡します
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に代入するだけでは、エラーになっていないことも確認できます
まとめ
- SwiftSyntax に入門してみて、BuildToolPlugin を実装してみました
- 今までなんとなくイメージだけで SwiftSyntax と接してましたが、入門して他のOSSがどのような挙動をしているか想像つくようになった気分に少しだけなりました