コマンドラインツール作成に入門できる数年前の英語ブログを現在の Swift 環境で動く状態に整理した。
アップル公式のブートストラップにて、扱われていない引数処理やエラーハンドリング、テスト検証も含めて理解できる。
初手
以下のコマンドをもってコマンドラインツール構築を始める。
$ mkdir CommandLineTool
$ cd CommandLineTool
$ swift package init --type executable
ここでオプション指定している --type executable
は、SPM に対してフレームワークではなくコマンドラインツールを構築することを教えるためのもの。
ここで出来上がったもの
ディレクトリ内に次のアイテムが出来上がる。
.
├── Package.swift
├── Sources
│ └── main.swift
├── Tests // (ここでは出来上がると書いてあるが、ないので別途作成する。 *1)
...
└── .gitignore
-
Package.swift
は SPM のマニフェストファイルで、依存関係のようなプロジェクトのメタデータが記載される。 -
Sources
フォルダにはソースコードを記載する。初期化時にmain.swift
を含んでおり、これがコマンドラインツールのエントリーポイントとなる(このファイルをリネームすることはできない)。 -
Tests
ファルダにはテストコードを記載する。 -
.gitignore
は SPM のビルドフォルダ(*2
)と Xcode プロジェクトをソースコントロールから無視するように記載されている(*3
)。
-
*1
Tests
を作成する。↓$ mkdir Tests $ cd Tests $ touch tests.swift
CommandLineTool/. ├── Package.swift ├── Sources │ └── main.swift ├── Tests │ └── tests.swift ... └── .gitignore
-
*2
.swiftpm
と.build
というフォルダが生成されている。
linux のtree
コマンドなどで確認できる。$ brew install tree $ tree -a
-
*3
.gitignore
の中身を確認すると分かりやすい。.gitignore.DS_Store /.build /Packages xcuserdata/ DerivedData/ .swiftpm/configuration/registries.json .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata .netrc
コードをフレームワークと実行ファイルに分割する
このタイミングで、ソースコードをフレームワークと実行ファイルに分割することを推奨したい。そうすると、テストを書くのが簡単になり、またコマンドラインツールを他のツールから依存関係として使用できるようになる。
そのために、Sources
にフレームワークと実行ファイルのフォルダをそれぞれ作成する。
$ cd Sources
$ mkdir CommandLineTool
$ mv main.swift CommandLineTool/
$ mkdir CommandLineToolCore
.
├── Package.swift
├── Sources
│ ├── CommandLineTool
│ │ └── main.swift
│ └── CommandLineToolCore
├── Tests
... └── tests.swift
SPM のひとつの利点は、ファイルシステムを source of truth に扱う所にある。これにより、単に新しいフォルダを作成するだけで新たなモジュールを定義することができる。
では、Package.swift
を CommandLineTool
と CommandLineToolCore
の2つをターゲットとするように編集する。
import PackageDescription
let package = Package(
name: "CommandLineTool",
targets: [
.executableTarget( // *4
name: "CommandLineTool",
dependencies: ["CommandLineToolCore"]
),
.target(name: "CommandLineToolCore")
]
)
これで、フレームワークに依存関係を持つ実行ファイルを作成することができた。
-
*4
元記事では.target()
を使用している。swift 5.4 以降、実行可能ファイルのターゲットは.executableTarget()
とするのが正しい。
Xcode プロジェクトを作成する
SPM ではファイルシステムに基づいて簡単に Xcode プロジェクトを生成できる。また、すでに見た通り Xcode プロジェクトは git ignore されているので、コンフリクトや更新について対処する必要はなく、単に必要な時に再生成すればいい。
Xcode プロジェクトを生成するため、以下のコマンドをルートフォルダで実行する。(*5
)
$ swift package generate-xcodeproj
以下の warning が出るが、後ほど解決するので無視して問題ない。
warning: 'commandlinetool': Source files for target CommandLineToolCore should be located under 'Sources/CommandLineToolCore', or a custom sources path can be set with the 'path' property in Package.swift
-
*5
この手順は飛ばして問題ない。
swift package generate-xcodeproj
コマンドは、Swift 5.4 以降で非推奨となり、最終的には削除された。代わりに、Xcode がネイティブで SPM をサポートするようになっている。ちなみに上記の warning は以下で確認できる。
$ swift build
エントリーポイントを手動で定義する
コマンドラインとテストどちらからもツールを呼べるようにするために、main.swift
に責務を持たせすぎないようにするといいだろう。代わりに、ツールを関数で呼び出せるようにする。
そのために、フレームワークとして用意した Sources/CommandLineToolCore
に CommandLineTool.swift
を以下の様に作成する。
import Foundation
public final class CommandLineTool {
private let arguments: [String]
public init(arguments: [String] = CommandLine.arguments) {
self.arguments = arguments
}
public func run() throws {
print("Hello, world")
}
}
で、単にこの run()
を main.swift
から呼ぶようにする。
import CommandLineToolCore
let tool = CommandLineTool()
do {
try tool.run()
} catch {
print("Whoops! An error occurred: \(error)")
}
Hello World
ここで一度コマンドラインツールを実行しよう。その前に、コンパイルを行う必要がある。コマンドラインツールを作成したルートフォルダで swift build
、そして swift run
で実行できる。
$ swift build
$ swift run
Hello, world
実は swift run
は必要であればコンパイルも行うので、単に swift run
としても同じ結果が得られる。ただ、水面下で実行されている手順として把握しておくのがよいだろう。
依存関係を追加する
作っているコマンドラインツールが少し複雑になってくると、依存関係を追加する必要が出てくることがあるだろう。これらは Package.swift
に依存関係として指定すればよい。
import PackageDescription
let package = Package(
name: "CommandLineTool",
dependencies: [
.package(
url: "https://github.com/johnsundell/files.git",
from: "4.0.0"
)
],
targets: [
.executableTarget(
name: "CommandLineTool",
dependencies: ["CommandLineToolCore"]
),
.target(
name: "CommandLineToolCore",
dependencies: [
.product(name: "Files", package: "files") // *6
]
)
]
)
ここでは Files を追加した。これは Swift でファイルとフォルダを簡単に編集できるようにするフレームワークで、今回はこれをカレントフォルダにファイルを作成するのに使う。
-
*6
元記事では単に... dependencies: ["Files"] ...
として、ターゲットが依存するパッケージのプロダクトを暗黙的に解決している。swift 5.2 以降、
.product(name: "プロダクト名", package: "パッケージ名")
としてプロダクトの名前やパッケージを明示的に指定するのが正しい。ちなみに、元記事のままで
swift build
すると以下のエラーが出る。error: 'commandlinetool': dependency 'Files' in target 'CommandLineToolCore' requires explicit declaration; reference the package in the target dependency with '.product(name: "Files", package: "files")
依存関係をインストールする
新しい依存関係を宣言したら、SPM に依存関係の解決とインストールをさせ、Xcode プロジェクトを再生成しよう。(*7
)
$ swift package update
$ swift package generate-xcodeproj
-
*7
swift package update
は、Package.resolved
ファイルに記載されている依存パッケージのバージョンを最新に更新するために使用する。Package.resolved
は、いまプロジェクトが依存している全てのパッケージのバージョンを追跡している。
例えば、依存関係のバージョン指定をfrom: "1.0.0"
からfrom: "1.2.0"
のように更新した場合、その変更を反映させるためにswift package update
を実行する。swift package generate-xcodeproj
は前述*5
の通り、Swift 5.4 以降で不要。
コマンドラインに引数を取る
CommandLineTool.swift
を編集していこう。Hello, world
を出力させる代わりに、コマンドラインツールの引数に取ったテキストを名前とするファイルを作成させる様にする。
import Foundation
import Files
public final class CommandLineTool {
private let arguments: [String]
public init(arguments: [String] = CommandLine.arguments) {
self.arguments = arguments
}
public func run() throws {
guard arguments.count > 1 else {
throw Error.missingFileName
}
// The first argument is the execution path
let fileName = arguments[1]
do {
try Folder.current.createFile(at: fileName)
} catch {
throw Error.failedToCreateFile
}
}
}
public extension CommandLineTool {
enum Error: Swift.Error {
case missingFileName
case failedToCreateFile
}
}
上にみる様に、Folder.current.createFile()
を do
, try
, catch
で括っているが、これは unified error API をユーザーに提供するためのものである。
元の記事では指示されていないが、ここで swift run
してみると、実際に引数で指定した名前でファイルが作成されていることを確認できる。
$ swift run CommandLineTool example.swift
*2
で触れた様に、tree
でファイルが作成されたことを簡単に確認できる。
$ tree
.
├── Package.resolved
├── Package.swift
├── Sources
│ ├── CommandLineTool
│ │ └── main.swift
│ └── CommandLineToolCore
│ └── CommandLineTool.swift
├── Tests
│ └── tests.swift
└── example.swift
テストを書く
いよいよ新しいコマンドラインツールをリリースする準備が整ったが、その前にちゃんと動くかテストを書いておきたい。
ツールを早めにフレームワークと実行ファイルに分けておいたおかげで、テストはかなり簡単になっている。あとはプログラムで実行して、指定した名前のファイルができているかどうか確認するだけ。
まず、Package.swift
ファイルの targets
にテスト用のモジュールを追加する。
let package = Package(
//...
targets: [
//...
.testTarget(
name: "CommandLineToolTests",
dependencies: [
"CommandLineToolCore",
.product(name: "Files", package: "files")
]
)
]
)
元記事の文脈に合わせて、test のディレクトリ構成とファイル名を変更しておく。
.
├── Package.resolved
├── Package.swift
├── Sources
│ ├── CommandLineTool
│ │ └── main.swift
│ └── CommandLineToolCore
│ └── CommandLineTool.swift
├── Tests
│ └── Tests.swift
...
↓
.
├── Package.resolved
├── Package.swift
├── Sources
│ ├── CommandLineTool
│ │ └── main.swift
│ ├── CommandLineToolCore
│ │ └── CommandLineTool.swift
│ └── CommandLineToolTests
│ └── CommandLineToolTests.swift
...
次に、CommandLineToolTests.swift
にテストコードを書いていく。
import Foundation
import XCTest
import Files
import CommandLineToolCore
class CommandLineToolTests: XCTestCase {
func testCreatingFile() throws {
// Setup a temp test folder that can be used as a sandbox
let tempFolder = Folder.temporary
let testFolder = try tempFolder.createSubfolderIfNeeded(
withName: "CommandLineToolTests"
)
// Empty the test folder to ensure a clean state
try testFolder.empty()
// Make the temp folder the current working folder
let fileManager = FileManager.default
fileManager.changeCurrentDirectoryPath(testFolder.path)
// Create an instance of the command line tool
let arguments = [testFolder.path, "Hello.swift"]
let tool = CommandLineTool(arguments: arguments)
// Run the tool and assert that the file was created
try tool.run()
XCTAssertNotNil(try? testFolder.file(named: "Hello.swift"))
}
}
ファイル名が与えられていなかったり、ファイル作成が失敗していたりした場合に、適切なエラーが返されているかどうかを検証するテストを追加するのもよいだろう。
テストを実行するには、swift test
を呼べばよい。
$ swift test
Building for debugging...
[1/1] Write swift-version--7BD29E5430B547BF.txt
Build complete! (0.11s)
Test Suite 'All tests' started at 2024-08-19 08:53:12.814.
Test Suite 'CommandLineToolPackageTests.xctest' started at 2024-08-19 08:53:12.815.
Test Suite 'CommandLineToolTests' started at 2024-08-19 08:53:12.815.
Test Case '-[CommandLineToolTests.CommandLineToolTests testCreatingFile]' started.
Test Case '-[CommandLineToolTests.CommandLineToolTests testCreatingFile]' passed (0.003 seconds).
Test Suite 'CommandLineToolTests' passed at 2024-08-19 08:53:12.818.
Executed 1 test, with 0 failures (0 unexpected) in 0.003 (0.003) seconds
Test Suite 'CommandLineToolPackageTests.xctest' passed at 2024-08-19 08:53:12.818.
Executed 1 test, with 0 failures (0 unexpected) in 0.003 (0.003) seconds
Test Suite 'All tests' passed at 2024-08-19 08:53:12.818.
Executed 1 test, with 0 failures (0 unexpected) in 0.003 (0.004) seconds
コマンドラインツールをインストールする
以上でコマンドラインツールの実装とテストを終えたので、このコマンドをどこからでも呼び出せる様にしよう。
そのためにリリース設定でツールをビルドして、コンパイルされたバイナリを /usr/local/bin
に移す。
$ swift build -c release
$ cd .build/release
$ cp -f CommandLineTool /usr/local/bin/commandlinetool
完成