🛠️
Swift Package Manager で Command Line Tool を作る【応用】
基礎編でCommand Line Toolの作り方の概要がわかったと思うので、作りをしっかりするための応用を紹介する。
swift-argument-parserを使う
Swift Packageで簡単にリッチなCommand Line Toolを作るためのライブラリ swift-argument-parser
がAppleのOSSで公開されている。
-
Package.swift
のdependencies
に追加する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"), + ] ), ] )
-
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を実装しよう。
-
Package.swift
にtarget
とtestTarget
を追加する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"] + ), ] )
- 追加した
target
とtestTarget
のディレクトリを追加し、中にファイルも配置する. ├── Sources │ ├── LineCounter │ │ └── main.swift │ └── LineCounterCore │ └── LineCounterCore.swift └── Tests └── LineCounterCoreTests └── LineCounterCoreTests.swift
- コア機能を切り出して
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)" } } }
- 切り出したコア機能を使う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()
- コア機能のテストを実装する
- 今回の場合、テストに実際のファイルが必要なので、
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() } } }
- 今回の場合、テストに実際のファイルが必要なので、
本例のソースコード
Discussion