TL;DR
- Context: internalスコープのSwiftソース定義を一括で
public
化したい場合(モジュール切出しなどの文脈) - Problem: 正規表現によるコード変換では対応しきるのは難しい
- Solution: SwiftSyntaxのSyntaxRewriterをつかうことで、セマンティックなコード変換を比較的容易に実装できる
- Result: https://github.com/hrfmmr/swift-public-rewriter
Context
Swiftプロジェクトで、モデル類のソースを別のモジュールに切り出すといった作業が嵩むことがあり、効率的に行いたいというモチベーションがあった
単体のソースとしてはいずれも以下のようなもの
struct Foo {
let x: Int
let bar: Bar
init(x: Int, bar: Bar) {
self.x = x
self.bar = bar
}
func doSomething() {
let a = ...
var b = ...
}
}
extension Foo {
struct InnerFoo {}
}
enum Bar {
case x
var hoge: Int {
switch self {
case .x: ...
}
}
}
class Baz {
private(set) var x: Int = ...
lazy var y: Int = ...
}
こういったソースをモジュールに切り出した場合、外部のモジュールから参照できるようにするには、public
修飾子を付与する必要がある
public struct Foo {
public let x: Int
public let bar: Bar
public init(x: Int, bar: Bar) {
self.x = x
self.bar = bar
}
public func doSomething() {
let a = ...
var b = ...
}
}
public extension Foo {
struct InnerFoo {}
}
public enum Bar {
case x
public var hoge: Int {
switch self {
case .x: ...
}
}
}
public class Baz {
public private (set) var x: Int = ...
public lazy var y: Int = ...
}
このとき、手作業でのpublic
付与は非効率なので、スクリプトで一括変換を行いたい
たとえば、public
を付与するコード変換を正規表現をつかって行おうとすると、いくつか課題が生じてくる
let
やvar
によるプロパティ宣言とメソッドローカル変数宣言の区別がつかない
struct Foo {
let x: Int // ✅ `public let`
var y: Int
}
func foo() {
let x = ... // ❌ `public let`
var y = ...
}
public
付与の正規表現ロジックとして「行頭文字列がlet
ないしvar
から始まる」を条件とした場合、上記コードの場合func foo()
内の変数宣言部分もpublic
が付与され、コンパイルエラーになる
同様に、以下のようなケースも然り
struct Foo { // ✅ `public struct`
struct InnerFoo1 {} // ✅ `public struct`
}
private extension Foo {
struct InnerFoo2 {} // ❌ `public struct`
}
public
付与の正規表現ロジックとして「行頭文字列がstruct
から始まる」を条件とした場合、このケースだとstruct InnerFoo2
に対してもpublic
が付与されるprivate extension
内にpublic
修飾子はつけることができないため、コンパイルエラーとなる
正規表現ロジックでは一様に扱うことが難しく、セマンティックに沿ったカタチでコード変換を行う必要がある
どうやるか
SwiftSyntaxをつかうことで、セマンティックなコード変換を行うことができる
SwiftSyntaxはSwiftコードを解析して構文木(AST)に変換するライブラリで、出力されるASTはコードのセマンティックを反映しているので、この情報をベースにコードの変更や解析を行うことができる
https://github.com/apple/swift-syntax
以下のようなSwiftコードをSwiftParserによるAST解析を行うと、
import SwiftParser
let source = """
struct Foo {
let x: Int
var y: Int
}
"""
let node = Parser.parse(source: source)
print(node.debugDescription)
結果以下のような出力が得られる
SourceFileSyntax
├─statements: CodeBlockItemListSyntax
│ ╰─[0]: CodeBlockItemSyntax
│ ╰─item: StructDeclSyntax
│ ├─attributes: AttributeListSyntax
│ ├─modifiers: DeclModifierListSyntax
│ ├─structKeyword: keyword(SwiftSyntax.Keyword.struct)
│ ├─name: identifier("Foo")
│ ╰─memberBlock: MemberBlockSyntax
│ ├─leftBrace: leftBrace
│ ├─members: MemberBlockItemListSyntax
│ │ ├─[0]: MemberBlockItemSyntax
│ │ │ ╰─decl: VariableDeclSyntax
│ │ │ ├─attributes: AttributeListSyntax
│ │ │ ├─modifiers: DeclModifierListSyntax
│ │ │ ├─bindingSpecifier: keyword(SwiftSyntax.Keyword.let)
│ │ │ ╰─bindings: PatternBindingListSyntax
│ │ │ ╰─[0]: PatternBindingSyntax
│ │ │ ├─pattern: IdentifierPatternSyntax
│ │ │ │ ╰─identifier: identifier("x")
│ │ │ ╰─typeAnnotation: TypeAnnotationSyntax
│ │ │ ├─colon: colon
│ │ │ ╰─type: IdentifierTypeSyntax
│ │ │ ╰─name: identifier("Int")
│ │ ╰─[1]: MemberBlockItemSyntax
│ │ ╰─decl: VariableDeclSyntax
│ │ ├─attributes: AttributeListSyntax
│ │ ├─modifiers: DeclModifierListSyntax
│ │ ├─bindingSpecifier: keyword(SwiftSyntax.Keyword.var)
│ │ ╰─bindings: PatternBindingListSyntax
│ │ ╰─[0]: PatternBindingSyntax
│ │ ├─pattern: IdentifierPatternSyntax
│ │ │ ╰─identifier: identifier("y")
│ │ ╰─typeAnnotation: TypeAnnotationSyntax
│ │ ├─colon: colon
│ │ ╰─type: IdentifierTypeSyntax
│ │ ╰─name: identifier("Int")
│ ╰─rightBrace: rightBrace
╰─endOfFileToken: endOfFile
関数コードの場合
import SwiftParser
let source = """
func foo() {
let x = ""
var y = 0
}
"""
SourceFileSyntax
├─statements: CodeBlockItemListSyntax
│ ╰─[0]: CodeBlockItemSyntax
│ ╰─item: FunctionDeclSyntax
│ ├─attributes: AttributeListSyntax
│ ├─modifiers: DeclModifierListSyntax
│ ├─funcKeyword: keyword(SwiftSyntax.Keyword.func)
│ ├─name: identifier("foo")
│ ├─signature: FunctionSignatureSyntax
│ │ ╰─parameterClause: FunctionParameterClauseSyntax
│ │ ├─leftParen: leftParen
│ │ ├─parameters: FunctionParameterListSyntax
│ │ ╰─rightParen: rightParen
│ ╰─body: CodeBlockSyntax
│ ├─leftBrace: leftBrace
│ ├─statements: CodeBlockItemListSyntax
│ │ ├─[0]: CodeBlockItemSyntax
│ │ │ ╰─item: VariableDeclSyntax
│ │ │ ├─attributes: AttributeListSyntax
│ │ │ ├─modifiers: DeclModifierListSyntax
│ │ │ ├─bindingSpecifier: keyword(SwiftSyntax.Keyword.let)
│ │ │ ╰─bindings: PatternBindingListSyntax
│ │ │ ╰─[0]: PatternBindingSyntax
│ │ │ ├─pattern: IdentifierPatternSyntax
│ │ │ │ ╰─identifier: identifier("x")
│ │ │ ╰─initializer: InitializerClauseSyntax
│ │ │ ├─equal: equal
│ │ │ ╰─value: StringLiteralExprSyntax
│ │ │ ├─openingQuote: stringQuote
│ │ │ ├─segments: StringLiteralSegmentListSyntax
│ │ │ │ ╰─[0]: StringSegmentSyntax
│ │ │ │ ╰─content: stringSegment("")
│ │ │ ╰─closingQuote: stringQuote
│ │ ╰─[1]: CodeBlockItemSyntax
│ │ ╰─item: VariableDeclSyntax
│ │ ├─attributes: AttributeListSyntax
│ │ ├─modifiers: DeclModifierListSyntax
│ │ ├─bindingSpecifier: keyword(SwiftSyntax.Keyword.var)
│ │ ╰─bindings: PatternBindingListSyntax
│ │ ╰─[0]: PatternBindingSyntax
│ │ ├─pattern: IdentifierPatternSyntax
│ │ │ ╰─identifier: identifier("y")
│ │ ╰─initializer: InitializerClauseSyntax
│ │ ├─equal: equal
│ │ ╰─value: IntegerLiteralExprSyntax
│ │ ╰─literal: integerLiteral("0")
│ ╰─rightBrace: rightBrace
╰─endOfFileToken: endOfFile
正規表現では区別のつかなかった、「struct内のプロパティ宣言」と「関数ローカルな変数宣言」が、ASTベースでみると区別がつくことがわかる
「struct内のプロパティ宣言」は、StructDeclSyntax
下のMemberBlockItemSyntax
の一部として存在しており、
│ ╰─item: StructDeclSyntax
...
│ ├─members: MemberBlockItemListSyntax
│ │ ├─[0]: MemberBlockItemSyntax
│ │ │ ╰─decl: VariableDeclSyntax
│ │ │ ├─attributes: AttributeListSyntax
│ │ │ ├─modifiers: DeclModifierListSyntax
│ │ │ ├─bindingSpecifier: keyword(SwiftSyntax.Keyword.let)
...
「関数ローカルな変数宣言」はFunctionDeclSyntax
下のCodeBlockItemSyntax
の一部として存在している
│ ╰─item: FunctionDeclSyntax
...
│ ├─statements: CodeBlockItemListSyntax
│ │ ├─[0]: CodeBlockItemSyntax
│ │ │ ╰─item: VariableDeclSyntax
│ │ │ ├─attributes: AttributeListSyntax
│ │ │ ├─modifiers: DeclModifierListSyntax
│ │ │ ├─bindingSpecifier: keyword(SwiftSyntax.Keyword.let)
セマンティックな区別はつけられるので、これに沿ってコード変換を行えばよい
SwiftSyntaxには、ASTを走査する過程で特定のシンタックスパターンに対して任意のコード変換処理を加えることができるSyntaxRewriter
classが提供されている
これのサブクラスを実装することで、セマンティックなコード変換が行える
https://github.com/apple/swift-syntax/blob/main/Sources/SwiftSyntax/generated/SyntaxRewriter.swift
SyntaxRewriterによるコード変換の基本的な実装をみていく上で、以下のようなPackage.swiftを用意する
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "swiftsyntax-sandbox",
platforms: [
.macOS(.v13),
],
dependencies: [
.package(url: "https://github.com/apple/swift-syntax.git", from: "509.0.2"),
],
targets: [
.executableTarget(
name: "main",
dependencies: [
.product(name: "SwiftSyntax", package: "swift-syntax"),
.product(name: "SwiftSyntaxBuilder", package: "swift-syntax"),
]
),
]
)
.
├── Package.swift
└── Sources
└── main
└── main.swift
たとえば、SyntaxRewriterで関数名をリネームする実装は以下のようになる
// main.swift
import SwiftSyntax
import SwiftParser
class FunctionRenameWriter: SyntaxRewriter {
override func visit(_ node: FunctionDeclSyntax) -> DeclSyntax {
let newNode = node.with(\.name, .identifier("bar"))
return super.visit(newNode)
}
}
let source = """
func foo() {
let x = ""
var y = 0
}
"""
let sourceFile = Parser.parse(source: source)
let rewriter = FunctionRenameWriter()
let updatedSource = rewriter.visit(sourceFile)
print(updatedSource)
実行結果
func bar() {
let x = ""
var y = 0
}
実装のキモとなっているのが、以下のwith
メソッドによるASTノードの置換
node.with(\.name, .identifier("bar"))
with
は、SwiftSyntaxにおけるノードが一様に準拠しているSyntaxProtocolに備わるメソッドで、以下のようなシグネチャで定義されている
public extension SyntaxProtocol {
/// Returns a new syntax node that has the child at `keyPath` replaced by
/// `value`.
func with<T>(_ keyPath: WritableKeyPath<Self, T>, _ value: T) -> Self {
var copy = self
copy[keyPath: keyPath] = value
return copy
}
- 第一引数に変更対象となるKeyPathを指定し、第二引数に置き換える値を与える
- 今回変更したいのは関数名なので、先程みてきたAST構造では以下に相当する部分なので、
\.name
を変更するということになる
│ ╰─item: FunctionDeclSyntax
│ ├─attributes: AttributeListSyntax
│ ├─modifiers: DeclModifierListSyntax
│ ├─funcKeyword: keyword(SwiftSyntax.Keyword.func)
│ ├─name: identifier("foo") // 👈👈👈
public
修飾子をclass/struct/enum定義やプロパティやイニシャライザや関数/メソッド/extensionにどうやって付与していけばよいか
実際にpublic
付与された定義のASTをみてみるのがはやい
ところで、SwiftSyntaxによるソースコードのAST解析は、SyntaxProtocolのdebugDescription
で逐一確認せずとも、インタラクティブにチェックできる便利ツールがある
@k_katsumi氏が運営しているSwift AST ExplorerというWebサイトでSwiftソースコードに対応するAST構造をカンタンに把握できる
各シンタックスノードにどんな親子ノードがぶら下がっているかや、選択ノードに関する詳細な情報がポップオーバーで表示される
↑のスクリーンショットでフォーカスしている以下のメソッドに関するASTをみると、
override public func doSomething() {
}
public
修飾子はメソッド定義の大元となるFunctionDecl
にぶら下がるDeclModifierList
要素の一部として、DeclModifier
ノードによってpublic
が付与されていることがわかる
別の例でみるとどうか
public struct Foo {}
structに対してpublic
修飾子がついている場合も、さきほどのメソッド定義のASTと同様に、大元となるStructDecl
シンタックス配下のDeclModifierList
要素の一部として、DeclModifier
ノードによってpublic
が付与されていることがわかる
つまり、public
修飾子を付与したい対象のノードに対し、その配下にあるDeclModifierListSyntax
ノードを変更すればよいことがわかる
また、DeclModifierListSyntax
は、FunctionDeclSyntax
やStructSyntaxNode
において、modifiers
というKeyPathでアクセスできることがわかる(↑ポップオーバー部分表示参照)
およその方向性がわかったので、対応するSyntaxRewriterを素朴に実装してみる
class PublicReWriter: SyntaxRewriter {
override func visit(_ node: FunctionDeclSyntax) -> DeclSyntax {
let newNode = node.with(\.modifiers, DeclModifierListSyntax([
DeclModifierSyntax.init(name: .keyword(.public))
]))
return super.visit(newNode)
}
}
これを用いたコード変換をさきほどみてきたfoo関数が定義されたソースに対して実行すると
let source = """
func foo() {
let x = ""
var y = 0
}
"""
let sourceFile = Parser.parse(source: source)
let rewriter = PublicReWriter()
let updatedSource = rewriter.visit(sourceFile)
print(updatedSource)
実行結果は以下のようになる
publicfunc foo() {
let x = ""
var y = 0
}
public
は付与できているが、func
とのスペースがなく、コンパイルエラーになってしまう
スペースはASTではどのように扱われているか?
たとえばfunc
部分にフォーカスしてみると、
trailingTrivia
に1つ分のスペースが付与されていることがわかる
SwiftSyntax ASTではスペースや改行などはTriviaというノードで扱われるTriviaの構成要素としては、.space
の他、.newline
, .tab
, .lineComment
, .blockComment
, .docLineComment
などがある
ref. https://github.com/kishikawakatsumi/SwiftSyntax/blob/master/Sources/SwiftSyntax/Trivia.swift
publicの後ろにスペースを1つ空けたいので、trailingTrivia
に.spaces(1)
を足す
class PublicReWriter: SyntaxRewriter {
override func visit(_ node: FunctionDeclSyntax) -> DeclSyntax {
let newNode = node.with(\.modifiers, DeclModifierListSyntax([
DeclModifierSyntax.init(name: .keyword(.public), trailingTrivia: .spaces(1))
]))
return super.visit(newNode)
}
}
実行結果は以下のようになり、コンパイルが通るカタチでpublic
付与が行えた
public func foo() {
let x = ""
var y = 0
}
関数/メソッドに対してはFunctionDeclSyntax
に対するRewriter実装を施してきたが、他のSyntaxノードについてはそれぞれ以下のRewriter実装を施せばよい
// プロパティ宣言
override func visit(_ node: VariableDeclSyntax) -> DeclSyntax {...}
// struct定義
override func visit(_ node: StructDeclSyntax) -> DeclSyntax {...}
// enum定義
override func visit(_ node: EnumDeclSyntax) -> DeclSyntax {...}
// class定義
override func visit(_ node: ClassDeclSyntax) -> DeclSyntax {...}
// イニシャライザ定義
override func visit(_ node: InitializerDeclSyntax) -> DeclSyntax {...}
// extension定義
override func visit(_ node: ExtensionDeclSyntax) -> DeclSyntax {...}
// protocol定義
override func visit(_ node: ProtocolDeclSyntax) -> DeclSyntax {...}
今回これらに対応したコードは以下
https://github.com/hrfmmr/swift-public-rewriter/blob/main/Sources/PublicRewriterCore/PublicModifierRewriter.swift
- コード量こそやや多くなってしまっているものの、「
\.modifiers
にpublic
用のDeclModifierSyntax
を生やす」対処を一貫して適用しているだけに、ロジック部分の多くは再利用可能なカタチにできるのがうれしい - ※冒頭で上げた「正規表現ではむずかしい」部分に対応している箇所のコード
まとめ
- SwiftSyntaxをつかうと、セマンティックなコード変換が行える
- SwiftSyntaxのASTを把握する上でSwift AST Explorerは大変便利