[Swift] CommandPlugin でクラス図を作る (swift-syntax/ Hatch を使用した Swift ファイル解析)

SwiftPackageManagerEyeCatch

     
⌛️ 4 min.
Swift Package で Command Plugin を作っていきます。
最終的には、Swift のコードをパースして、mermaid の classDiagram を出力するのがゴールです。

環境&対象

以下の環境で動作確認を行なっています。

  • macOS14.3 Beta
  • Xcode 15.2 beta
  • iOS 17.2
  • Swift 5.9

作りたい Plugin

指定した Package/Project のクラス構造を mermaid 形式で出力するプラグインを作ってみます。

全体の予定

全体のステップは、以下の予定です。

swift-syntax

Swift のコードを自前でパースするのは大変なので、swift-syntax を使用してパースすることを考えます。

swift-syntax をそのまま使うと色々なことができる反面、さまざまなことを考慮して実装しなければいけないので、swift-syntax の使用例として公開されている Hatch を使用して実装していきます。

以下が、参照するライブラリです。
swift-syntax
Hatch

CommandPlugin 構造更新

CommandPlugin に直接実装していくと、デバッグが難しいので、以下のような構造にして実装していくことにします。

CommandPlugin は Executable(CLI) を実行する。
Executable は、swift-syntax を使用して指定されたファイル群をパースするライブラリを使用する。

ライブラリ名は、CommandPluginExampleLib として設定します。
Executable を設定するまで、CommandPlugin と CommandPluginExampleLib は無関係ですが、1つの package に収めておくことはできます。

Package.swift への設定追加

新しい library の追加も、Package.swift に行います。

以下のように、products, targets の2箇所に追記していきます。

Library に対して テストを追加していく予定なので、testTarget も追加します。

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

import PackageDescription

let package = Package(
    name: "CommandPluginExample",
    platforms: [
        .macOS(.v14)
    ],
    products: [
        .library(name: "CommandPluginExampleLib", targets: ["CommandPluginExampleLib"]),
        .plugin(
            name: "CommandPluginExample",
            targets: ["CommandPluginExample"]),
    ],
    targets: [
        .target(name: "CommandPluginExampleLib",
               dependencies: []),
        .plugin(
            name: "CommandPluginExample",
            capability: .command(intent: .custom( verb: "CommandPluginExample",
                                                  description: "prints hello world"),
                                 permissions: [.writeToPackageDirectory(reason: "store result info into file")])
        ),
        .testTarget(name: "CommandPluginExampleLibTests",
                    dependencies: []),
    ]
)

新しい Product/library としての CommandPluginExampleLib と target としての CommandPluginExampleLib を追加し、新しく test として testTarget を追加しているだけで、特殊なことはしていません。

フォルダ構造修正

作成した プロジェクト/パッケージは、Plugin 用のテンプレートから作成したために、Plugins フォルダしかありませんでした。
しかし、Library を追加すると、Library 用のフォルダの追加が必要となります。Library のソースコード用フォルダだけではなく、テスト用コードのフォルダも追加します。

ソースコードフォルダ追加

Library は、Sources/CommandPluginExampleLib というフォルダ下のファイルを使用して構築されるようになりますので、そのようなフォルダをプロジェクトに追加します。

とりあえず、ダミーの File.swift ファイルも追加しています。(後で置き換える予定です)

以下は、追加した後のフォルダ構成です。

addFolderFileForLib

テストコードフォルダ追加

テスト用コードは、”UnderstandingTests” という名前で、UnitTest のテンプレートを使用して作成します。

ここまですると、”⌘-U” とすると UnitTest が実行されるようになります。テストコードを書いていないので、意味はありませんが・・・

swift-syntax/Hatch を使ってみる

swift-syntax を使用した Hatch というライブラリを使っていきます。

依存関係追加

swift-syntax を直接使うのではなく、Hatch を使う予定なので、package の dependencies に Hatch を追加し、target ごとの個別の dependencies にも Hatch を追加します。

以下のような Package.swift になりました。

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

import PackageDescription

let package = Package(
    name: "CommandPluginExample",
    platforms: [
        .macOS(.v14)
    ],
    products: [
        .library(name: "CommandPluginExampleLib", targets: ["CommandPluginExampleLib"]),
        .plugin(
            name: "CommandPluginExample",
            targets: ["CommandPluginExample"]),
    ],
    dependencies: [
        .package(url: "https://github.com/sdidla/Hatch", from: "509.0.2")
    ],
    targets: [
        .target(name: "CommandPluginExampleLib",
               dependencies: [ .product(name: "Hatch", package: "Hatch")]
                ),
        .plugin(
            name: "CommandPluginExample",
            capability: .command(intent: .custom( verb: "CommandPluginExample",
                                                  description: "prints hello world"),
                                 permissions: [.writeToPackageDirectory(reason: "store result info into file")])
        ),
        .testTarget(name: "CommandPluginExampleLibTests",
                    dependencies: ["CommandPluginExampleLib"]),
    ]
)

上記のように記述すると、Xcode が Package.swift を解析し Hatch と (Hatch が依存する swift-syntax )が読み込まれるようになります。

以下のように Package Dependencies に追加されていることが確認できます。

ALTTEXT

テストコードで、使い方を理解する

テストコードを用いて、Hatch(swift-syntax) の使い方を見てみます。

以下は、Hatch が提供する SymbolParser を用いて、与えられた文字列を parse し、 struct の名称を確認するテストコードです。

//
//  UnderstandingTests.swift
//
//  Created by : Tomoaki Yagishita on 2024/01/17
//  © 2024  SmallDeskSoftware
//

import XCTest
import Hatch

final class UnderstandingTests: XCTestCase {
    func test_Parse_Struct() async throws {
        let codeString = """
                         struct A1 {}
                         """
        let symbols = SymbolParser.parse(source: codeString)
        XCTAssertEqual(symbols.count, 1)
        let symbol = try XCTUnwrap(symbols.first)
        let structSymbol = try XCTUnwrap(symbol as? Hatch.Struct)
        XCTAssertEqual(structSymbol.name, "A1")
    }
}

SymbolParser .parse は、与えられた文字列を parse して、Hatch.Struct 等の struct の配列を返して来ます。

ここでは、Hatch.Struct という struct が 1つ返されて来て、name に A1 が設定されていることをテストしています。
swift-parser/Hatch/SymbolParser を使用するとこのように 定義されている struct の名称を取得することが確認できました。

これは、Hatch (と swift-syntax) が提供している機能ですので、テストしているというより、動作を理解するためのコードです。

この機能を使用することで、.swift ファイルから、struct/class 等の情報を抽出しようとしています。

フォルダ指定で、parse する

String として与えられたものを parse することができたので、次は、フォルダ指定されたときにパースできるようにしたくなります。

フォルダ指定parse のテストを作る

パース対象が文字列であれば、テストを書くことは容易です。

ですが、今回は フォルダを対象にパースする機能を作る必要があります。

フォルダを対象にパースすることをテストするのは少し手間です。

難しい点は、以下です。
・テスト時に パース対象のフォルダが存在しなければいけません。
・そのフォルダ内容は、既知でなければいけません。(念のため、テストごとに設定される方が望ましいです)

さまざまな解決方法があると思いますが、ここでは、リソースとしてフォルダとその内部のファイルをテストの一部として持たせることとし、テスト時には そのフォルダを使ったテストとします。

テストにファイル/フォルダを含める

Swift Package では、Package.swift でパッケージに含まれるファイルの処理を指定することができます。

今回のケースでは、ファイルをコピーしてくれれば良いので、以下のように記述することで “Resources”フォルダにあるリソース(ファイル/フォルダ)をコピーしてくれるようになります。

    targets: [
       ...snip...
        .testTarget(name: "CommandPluginExampleLibTests",
                    dependencies: ["CommandPluginExampleLib"],
                    resources: [
                        .copy("Resources")
                    ]),
    ]

パース対象ファイルをリソースとして設定する

以下を、テスト対象のファイルとして持つように、Resources フォルダ下に以下のファイルを配置しました。

Protocol.swift

//
//  Protocol.swift
//
//  Created by : Tomoaki Yagishita on 2024/01/17
//  © 2024  SmallDeskSoftware
//

import Foundation

protocol Protocol1 {}

Enum.swift

//
//  Enum.swift
//
//  Created by : Tomoaki Yagishita on 2024/01/17
//  © 2024  SmallDeskSoftware
//

import Foundation

enum Enum1 {}

Struct.swift

//
//  Struct.swift
//
//  Created by : Tomoaki Yagishita on 2024/01/17
//  © 2024  SmallDeskSoftware
//

import Foundation

struct Struct1 {}

Class.swift

//
//  Class.swift
//
//  Created by : Tomoaki Yagishita on 2024/01/17
//  © 2024  SmallDeskSoftware
//

import Foundation

class Class1 {}

Actor.swift

//
//  Actor.swift
//
//  Created by : Tomoaki Yagishita on 2024/01/17
//  © 2024  SmallDeskSoftware
//

import Foundation

actor Actor1 {}

以下のような配置になっています。

FilesForTest

パースする API を決める

まだ、コードは何も書いていませんが、テストするためにも、指定されたフォルダに含まれるファイルを parse する API を決めます。

CommandPluginExampleLib に static なメソッドとして parseProject を作ることにします。
まだ書いていないメソッドで、途中で問題が見つかったら修正していきますが、今時点では以下のAPIを持つものとします。

public struct CommandPluginExampleLib {
    public static func parseProject(_ url: URL) throws -> [URL: [Symbol]] {
        // need to be implemented
        return [:]
    }
}

フォルダの URL を受け取り、Key: ファイルURL, Value: [Hatch.Symbol] なる Dictionary を返すメソッドです。

テストを書く

準備が整ったので、テストを書きます。

いま、対象フォルダには、5つのファイルがあり それぞれで protocol, enum, struct, class, actor を1つづつ定義されているので、それらをきちんと取得できるかをテストします。

以下では、”きちんと取得できる” = “適切なタイプとして取得でき、それぞれ定義された名前を正しく持っている” としています。

//
//  CommandPluginExampleParserTests.swift
//
//  Created by : Tomoaki Yagishita on 2024/01/17
//  © 2024  SmallDeskSoftware
//

import XCTest
@testable import CommandPluginExampleLib
import Hatch

final class CommandPluginExampleLibParserTests: XCTestCase {
    func test_parseFolder() async throws {
        let testBundleURL = URL(fileURLWithPath: Bundle(for: type(of: self)).bundlePath)
        let result = try XCTUnwrap(CommandPluginExampleLib.parseProject(testBundleURL))
        XCTAssertEqual(result.count, 5)
        
        let protocolKey = try XCTUnwrap(result.keys.first(where: { $0.lastPathComponent == "Protocol.swift" }))
        let protocolResults = try XCTUnwrap(result[protocolKey])
        let protocolSymbol = try XCTUnwrap(protocolResults.first as? Hatch.ProtocolType)
        XCTAssertEqual(protocolSymbol.name, "Protocol1")
        
        let classKey = try XCTUnwrap(result.keys.first(where: { $0.lastPathComponent == "Class.swift" }))
        let classResults = try XCTUnwrap(result[classKey])
        let classSymbol = try XCTUnwrap(classResults.first as? Hatch.Class)
        XCTAssertEqual(classSymbol.name, "Class1")
        
        let structKey = try XCTUnwrap(result.keys.first(where: { $0.lastPathComponent == "Struct.swift" }))
        let structResults = try XCTUnwrap(result[structKey])
        let structSymbol = try XCTUnwrap(structResults.first as? Hatch.Struct)
        XCTAssertEqual(structSymbol.name, "Struct1")

        let enumKey = try XCTUnwrap(result.keys.first(where: { $0.lastPathComponent == "Enum.swift" }))
        let enumResults = try XCTUnwrap(result[enumKey])
        let enumSymbol = try XCTUnwrap(enumResults.first as? Hatch.Enum)
        XCTAssertEqual(enumSymbol.name, "Enum1")

        let actorKey = try XCTUnwrap(result.keys.first(where: { $0.lastPathComponent == "Actor.swift" }))
        let actorResults = try XCTUnwrap(result[actorKey])
        let actorSymbol = try XCTUnwrap(actorResults.first as? Hatch.Actor)
        XCTAssertEqual(actorSymbol.name, "Actor1")
    }
}

テストを作っている途中で、Dictionary の Key にファイルのURLを持たせるのは 後処理で煩雑になりそうな気がしましたが、とりあえず 大きな問題になるまではそのままにしています。

MEMO

テストを最初に書くことで、API を使用した際の懸念点を初期に見つけられるのは TDD の良い点の1つです。
ここでは、そのまま実装に進めていますが、”テストが書きにくい” = “使いにくい” でもあるので、大きな問題と感じたときには、実装を待たずに API の再検討をする方が良いです。

parseProject を実装する

空の実装で、test が fail することを確認したら(?)、実装を開始します。

難しいことはしていないので、詳細は省きますが、おおよその方針は以下のとおりです。
・FileManager を使って、与えられたフォルダ中のファイルについて 個別に処理
・拡張子”swift” を持つファイルのみを対象として処理
・個別のファイルをパースするための関数 parseFile も作成

以下のような実装になりました。

//
//  CommandPluginExampleLib.swift
//
//  Created by : Tomoaki Yagishita on 2024/01/17
//  © 2024  SmallDeskSoftware
//

import Foundation
import Hatch

public struct CommandPluginExampleLib {
    public static func parseProject(_ url: URL) throws -> [URL: [Symbol]] {
        var results: [URL: [Symbol]] = [:]
        let enumerator = FileManager.default.enumerator(at: url, includingPropertiesForKeys: [URLResourceKey.isDirectoryKey],
                                                        options: [.skipsHiddenFiles])
        while let fileURL = enumerator?.nextObject() as? URL {
            let resources = try fileURL.resourceValues(forKeys: [.isDirectoryKey])
            guard let isDir = resources.isDirectory,
                  isDir == false else { continue }
            guard fileURL.pathExtension == "swift" else { continue }
            let symbols = try Self.parseFile(fileURL)
            results[fileURL] = symbols
        }
        return results
    }
    
    public static func parseFile(_ fileURL: URL) throws -> [Symbol] {
        let codeString = try String(contentsOf: fileURL, encoding: .utf8)
        return SymbolParser.parse(source: codeString)
    }
}

上記のコードで、テストはパスします。

まとめ

swift-syntax を使って、Swift コードを解析する

swift-syntax を使って、Swift コードを解析する
  • swift-syntax を使うと Swift コードをパースできる
  • swift-syntax を直接使うと 細かい制御ができる反面、コードも複雑化する
  • swift-syntax は複数の使用例 Project が公開されている
  • 使用例 Project の1つ Hatch を使用すると、簡単にシンボルを取得できる

説明は以上です。
不明な点やおかしな点ありましたら、こちらまで。

SwiftUI おすすめ本

SwiftUI を理解するには、以下の本がおすすめです。

SwiftUI ViewMatery

SwiftUI で開発していくときに、ViewやLayoutのための適切なmodifierを探すのが大変です。
英語での説明になってしまいますが、以下の”SwiftUI Views Mastery Bundle”という本がビジュアル的に確認して探せるので、便利です。

英語ではありますが、1ページに コードと画面が並んでいるので、非常にわかりやすいです。

View に適用できる modifier もわかりやすく説明されているので、ビューの理解だけではなく、どのような装飾ができるかも簡単にわかります。

超便利です

SwiftUIViewsMastery

販売元のページは、こちらです。

SwiftUI 徹底入門

# SwiftUI は、毎年大きく改善されていますので、少し古くなってしまいましたが、いまでも 定番本です。

Swift学習におすすめの本

詳解Swift

Swift の学習には、詳解 Swift という書籍が、おすすめです。

著者は、Swift の初期から書籍を出していますし、Swift の前に主力言語だった Objective-C という言語についても同様の書籍を出しています。

最新版を購入するのがおすすめです。

現時点では、上記の Swift 5 に対応した第5版が最新版です。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です