🛠️

Swift Package Manager で Command Line Tool を作る【応用】

に公開

基礎編でCommand Line Toolの作り方の概要がわかったと思うので、作りをしっかりするための応用を紹介する。

swift-argument-parserを使う

Swift Packageで簡単にリッチなCommand Line Toolを作るためのライブラリ swift-argument-parser
がAppleのOSSで公開されている。

  1. Package.swiftdependenciesに追加する
    Package.swiftの例
    let package = Package(
        name: "LineCounter",
        products: [
            .executable(
                name: "lc",
                targets: ["LineCounter"]
            )
        ],
    +   dependencies: [
    +       .package(url: "https://github.com/apple/swift-argument-parser.git", exact: "1.5.0")
    +   ],
        targets: [
            .executableTarget(
                name: "LineCounter",
    +           dependencies: [
    +               .product(name: "ArgumentParser", package: "swift-argument-parser"),
    +           ]
            ),
        ]
    )
    
  2. ParsableCommandに準拠したstructを実装する
    main.swiftの例
    import ArgumentParser
    import Foundation
    
    struct LC: ParsableCommand {
        static let configuration = CommandConfiguration(
            commandName: "lc",
            abstract: "A tool to count the number of lines in the specified file.",
            version: "0.0.1"
        )
    
        @Argument(help: "The absolute path of a file.")
        var path: String
    
        mutating func run() throws {
            let fileURL = URL(fileURLWithPath: path)
            guard let file = try? String(contentsOf: fileURL, encoding: .utf8) else {
                print("Error: could not read \(fileURL.absoluteString)")
                return
            }
    
            let count = file.components(separatedBy: CharacterSet.newlines).count
            print("\(count)\t\(path)")
        }
    }
    
    LC.main() // これで実行される
    

前回同様、以下のコマンドで動作確認ができる。

$ swift run lc ${PWD}/Package.swift

swift-argument-parserを使うことでコマンドライン引数の取り扱いを安全にでき、さらに自動的にヘルプのオプションが実装される。

$ swift run lc --help
OVERVIEW: A tool to count the number of lines in the specified file.

USAGE: lc <path>

ARGUMENTS:
  <path>                  The absolute path of a file.

OPTIONS:
  --version               Show the version.
  -h, --help              Show help information.

また、コマンドライン引数の種類もいくつかあり、よくある柔軟なインターフェースを提供できる。

  • @Argument: 必須の引数
  • @Option: 任意の引数
  • @Flag: 加えると有効(true)扱いになる引数

詳しくは公式のドキュメントを参照。

テスタブルにする

executableTargetはmacの実行環境で実際にコマンドを叩く形式でテストを書くことも可能だが(例:LicenseCheckerTests)、Processを用いたり実行ファイルをビルド成果物のディレクトリから探したりとテクいことをしなければならない。そのため、Unit Testでなるべく機能試験を担保できるようにしたい。

executableTargetではCommand Line Toolとしてのインターフェースだけを担い、targetにコア機能は切り出してしまい、通常のtestTargetでUnit Testを実装しよう。

  1. Package.swifttargettestTargetを追加する
    Package.swiftの例
    let package = Package(
        name: "LineCountPlugin",
        products: [
            .executable(
                name: "lc",
                targets: ["LineCounter"]
            ),
        ],
        dependencies: [
            .package(url: "https://github.com/apple/swift-argument-parser.git", exact: "1.5.0"),
        ],
        targets: [
    +       .target(name: "LineCounterCore"),
            .executableTarget(
                name: "LineCounter",
                dependencies: [
    +               "LineCounterCore",
                    .product(name: "ArgumentParser", package: "swift-argument-parser"),
                ]
            ),
    +       .testTarget(
    +           name: "LineCounterCoreTests",
    +           dependencies: ["LineCounterCore"]
    +       ),
        ]
    )
    
  2. 追加したtargettestTargetのディレクトリを追加し、中にファイルも配置する
    .
    ├── Sources
    │   ├── LineCounter
    │   │   └── main.swift
    │   └── LineCounterCore
    │       └── LineCounterCore.swift
    └── Tests
        └── LineCounterCoreTests
            └── LineCounterCoreTests.swift
    
  3. コア機能を切り出してtargetを実装する
    LineCounterCore.swiftの例
    import Foundation
    
    public struct LineCounterCore {
        var fileURL: URL
    
        public init(path: String) {
            fileURL = URL(fileURLWithPath: path)
        }
    
        public func count() throws {
            guard let file = try? String(contentsOf: fileURL, encoding: .utf8) else {
                throw LineCounterError.couldNotRead(fileURL.absoluteString)
            }
    
            return file.components(separatedBy: CharacterSet.newlines).count
        }
    }
    
    // ついでにエラーも独自型を定義して`throw`できるようにする
    enum LineCounterError: LocalizedError {
        case couldNotRead(String)
    
        var errorDescription: String? {
            switch self {
            case let .couldNotRead(path):
                "Could not read \(path)"
            }
        }
    }
    
  4. 切り出したコア機能を使う
    LineCounterCore.swiftの例
    import ArgumentParser
    import Foundation
    + import LineCounterCore
    
    struct LC: ParsableCommand {
        static let configuration = CommandConfiguration(
            commandName: "lc",
            abstract: "A tool to count the number of lines in the specified file.",
            version: "0.0.1"
        )
    
        @Argument(help: "The absolute path of a file.")
        var path: String
    
        mutating func run() throws {
    +       let count = try LineCounterCore(path: path).count()
    +       print("\(count)\t\(path)")
        }
    }
    
    LC.main()
    
  5. コア機能のテストを実装する
    • 今回の場合、テストに実際のファイルが必要なので、Resourcesディレクトリを作って中に適当なファイルを配置してPackageが読み込めるようにする必要がある
      Package.swiftの例
      .testTarget(
          name: "LineCounterCoreTests",
          dependencies: ["LineCounterCore"],
          resources: [.process("Resources")]
      ),
      
      .
      └── Tests
          └── LineCounterCoreTests
              ├── LineCounterCoreTests.swift
              └── Resources
                  └── test.txt
      
    • テストを実装する(せっかくなのでswift-testingを使う)
      LineCounterCoreTests.swiftの例
      import Foundation
      import Testing
      
      @testable import LineCounterCore
      
      struct LineCounterCoreTests {
          @Test func initializer() {
              let sut = LineCounterCore(path: "/Users/user/test.txt")
              let actual = sut.fileURL
              #expect(actual == URL(fileURLWithPath: "/Users/user/test.txt"))
          }
      
          @Test func count_success() throws {
              let testFileURL = Bundle.module.url(forResource: "test", withExtension: "txt")!
              let sut = LineCounterCore(path: testFileURL.path())
              let actual = try sut.count()
              #expect(actual == 3)
          }
      
          @Test func count_failure() throws {
              let notExistsFileURL = URL(fileURLWithPath: "/test.txt")
              let sut = LineCounterCore(path: notExistsFileURL.path())
              #expect(throws: LineCounterError.self) {
                  try sut.count()
              }
          }
      }
      

本例のソースコード

https://github.com/Kyome22/LineCountPlugin/tree/clt-advanced

Discussion