SwiftSyntaxをつかったセマンティックなコード変換

Posted on | 1134 words | ~6 mins

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を付与するコード変換を正規表現をつかって行おうとすると、いくつか課題が生じてくる

letvarによるプロパティ宣言とメソッドローカル変数宣言の区別がつかない

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は、FunctionDeclSyntaxStructSyntaxNodeにおいて、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

まとめ

  • SwiftSyntaxをつかうと、セマンティックなコード変換が行える
  • SwiftSyntaxのASTを把握する上でSwift AST Explorerは大変便利

参考