[Swift] CommandPlugin で クラス図を作る(swift-syntax/Hatch で解析した swift を mermaid フォーマットに変換する)

SwiftPackageManagerEyeCatch

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

環境&対象

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

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

作りたい Plugin

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

全体の予定

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

Hatch.Symbol から mermaid フォーマットへの変換

前回作成したメソッドで、Swift のコードから、class/struct/enum/protocol/actor の名称を取得することができるようになりました。

今回は、名称を取り出して mermaid フォーマットに変換するようにしてみます。

変換方針

将来的には 名称以外にも、メソッドや継承関係を取り出したくなりますが、まずは、シンプルに 定義されている class/struct/enum/protocol/actor をクラス図に表示することを目指します。

具体的には、”Class1″ という名称のクラスが見つかったときに、以下のようなクラス図を作りたいということです。

classDiagram
class Class1

上記を mermaid で書くとすると以下のようになります。(使用する場所によって、適切な HTML タグで囲ったりすることが必要となります。)

classDiagram
  class Class1

ただ、mermaid (というか クラス図)には、class/struct等 が基本的に同じ矩形で表現されるため、わかりにくいです。

ということで、追加情報(annotation) を使って、class であることを記述することにします。
class である Class1 に対しては、以下のように class であることを annotation を使って、表示します。

class@Mermaid

## Web 上で Mermaidを使ってうまく表示できなかったので、レンダリング後のイメージを貼ってます・・・ WordPress 難しい・・・

struct/enum/protocol/actor についても同様に annotation を使って、struct/enum/protocol/actor である旨 追記します。

実装方針

Hatch.Symbol は、protocol であって、データを表す struct としては、ProtocolType, Class, Actor, Struct, Enum に分かれています。

Hatch の実装を見てみると、名称を持つ Symbol は、InheriteingSymbol というプロトコルに conform していることがわかります。

Hatch.Symbol, Hatch.InheritingSymbol に extension を用いて追加実装することで対応してみます。

テストを作る

例によって、テストから書きます。

Struct についてのテストは以下のようにしました。

    func test_Struct() async throws {
        let struct1 = Hatch.Struct(name: "Struct1", children: [], inheritedTypes: [])
        XCTAssertEqual(struct1.mermaidString(), """
                                                class Struct1 {
                                                 <<struct>>
                                                }
                                                
                                                """)
    }

Struct 型のデータから、mermaidString というメソッドで、該当する mermaid フォーマットされた文字列が取得できることとしてテストしています。
同様に、Class / Enum / Protocol / Enum 型について、適切な文字列出力がなされるかをテストします。

    func test_Class() async throws {
        let class1 = Hatch.Class(name: "Class1", children: [], inheritedTypes: [])
        XCTAssertEqual(class1.mermaidString(), """
                                                class Class1 {
                                                 <<class>>
                                                }
                                                
                                                """)
    }

    func test_Enum() async throws {
        let enum1 = Hatch.Enum(name: "Enum1", children: [], inheritedTypes: [])
        XCTAssertEqual(enum1.mermaidString(), """
                                              class Enum1 {
                                               <<enum>>
                                              }
                                                
                                              """)
    }

    func test_Actor() async throws {
        let actor1 = Hatch.Actor(name: "Actor1", children: [], inheritedTypes: [])
        XCTAssertEqual(actor1.mermaidString(), """
                                               class Actor1 {
                                                <<actor>>
                                               }
                                               
                                               """)
    }

    func test_Protocol() async throws {
        let protocol1 = Hatch.ProtocolType(name: "Protocol1", children: [], inheritedTypes: [])
        XCTAssertEqual(protocol1.mermaidString(), """
                                                  class Protocol1 {
                                                   <<protocol>>
                                                  }
                                                  
                                                  """)
    }

実装する

Hatch.Symbol に extension でmermaidString を定義していきます。
そして、Symbol が Class/Struct/Enum/Actor/ProtocolType のいずれかであるかを文字列で返すような swiftTypeName も定義して 使用しています。

extension Hatch.Symbol {
    func mermaidString() -> String {
        guard let namedSymbol = self as? Hatch.InheritingSymbol else { return "unsupported type" }
        return """
               class \(namedSymbol.name) {
                <<\(self.swiftTypeName())>>
               }
               
               """
    }
    func swiftTypeName() -> String {
        switch self {
        case is Hatch.Struct: return "struct"
        case is Hatch.Class: return "class"
        case is Hatch.Enum: return "enum"
        case is Hatch.Actor: return "actor"
        case is Hatch.ProtocolType: return "protocol"
        default:
            return "UnsupportedType"
        }
    }
}
¥

コード自体は、特にコメントする必要がないほど、シンプルな実装です。

このコードで、テストをパスするようになりました。

フォルダを変換

前回作成した CommandPluginExampleLib.parseProject というメソッドは、そのフォルダに含まれる Swift コードを解析して、[URL: [Symbol]] というデータを返して来ます。

実装方針

個別の Symbol は、mermaid フォーマットに変換できるようになりましたので、あとは、つなげれば OK のハズです。
ちなみに、mermaid では、クラス図であることを示す “classDiagram” というキーワードを入れることが必要となりますので、先頭に追加しておきます。

さらに、テストしやすさ/デバッグしやすさ(?)も兼ねて、与えられた [URL] に含まれていた URL の lastPathComponent 順に mermaid フォーマットに変換出力するようにしてみます。
さらに デバッグしやすくするために、URL の lastPathComponent も合わせて、出力するようにしてみます。

mermaid では、%% を行頭につけることでコメントになりますので、%% lastPathComponent というような行を追加するということです。

テスト実装

ということで、以下のようなテストにしました。

    func test_mermaidString_fromSymbols() async throws {
        let testBundleURL = URL(fileURLWithPath: Bundle(for: type(of: self)).bundlePath)
        let result = try XCTUnwrap(CommandPluginExampleLib.parseProject(testBundleURL))
        XCTAssertEqual(result.count, 5)

        let resultString = CommandPluginExampleLib.mermaidString(result)
        XCTAssertEqual(resultString, """
                                                  classDiagram
                                                  %% Actor.swift
                                                  class Actor1 {
                                                   >
                                                  }
                                                  %% Class.swift
                                                  class Class1 {
                                                   >
                                                  }
                                                  %% Enum.swift
                                                  class Enum1 {
                                                   >
                                                  }
                                                  %% Protocol.swift
                                                  class Protocol1 {
                                                   >
                                                  }
                                                  %% Struct.swift
                                                  class Struct1 {
                                                   >
                                                  }

                                                  """)
    }

実装

特に複雑なことを実装するわけではないので、コードは”そのまま”で書けます。

ほとんど1~2行でかけるものもありますが、以下のようにメソッドを分けました。
・フォルダのURLを渡されると、含まれるファイルをパースして [URL:[Symbol] を返す parseProject メソッド
・ファイルのURLを渡されると、パースして含まれるシンボルを返す parseFile メソッド
・[URL: [Symbol]] を与えられると、含まれる シンボルを mermaid 形式の String へ変換する mermaidString メソッド

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)
    }
    
    public static func mermaidString(_ symbolDic: [URL: [Symbol]]) -> String {
        var string = "classDiagram\n"
        for fileURL in symbolDic.keys.sorted(by: { $0.lastPathComponent  $1.lastPathComponent }) {
            let fileName = fileURL.lastPathComponent
            string += "%% \(fileName)\n"
            guard let symbols = symbolDic[fileURL] else { continue }
            for symbol in symbols {
                string += symbol.mermaidString()
            }
        }
        return string
    }
}

変換結果を表示してみる

特に意味はありませんが、結果を表示してみると以下のようになります。

resultMermaid

まとめ

swift-syntax/Hatchを使って解析したSwift コードを mermaid フォーマットに変換する

swift-syntax/Hatchを使って解析したSwift コードを mermaid フォーマットに変換する
  • Hatch で用意されている Symbol/InheritingSymbol を使用すると 型の名称が簡単に取得できる
  • mermaid の classDiagram の annotation を使用すると 見た目に class 等の見分けがつきやすい

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

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版が最新版です。

コメントを残す

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