5
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Swift によるコマンドラインツール構築入門

Last updated at Posted at 2024-08-19

コマンドラインツール作成に入門できる数年前の英語ブログを現在の Swift 環境で動く状態に整理した。
アップル公式のブートストラップにて、扱われていない引数処理やエラーハンドリング、テスト検証も含めて理解できる。

初手

以下のコマンドをもってコマンドラインツール構築を始める。

$ mkdir CommandLineTool
$ cd CommandLineTool
$ swift package init --type executable

ここでオプション指定している --type executable は、SPM に対してフレームワークではなくコマンドラインツールを構築することを教えるためのもの。

ここで出来上がったもの

ディレクトリ内に次のアイテムが出来上がる。

CommandLineTool/
.
├── 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
CommandLineTool/
.
├── Package.swift
├── Sources
│   ├── CommandLineTool
│   │   └── main.swift 
│   └── CommandLineToolCore
├── Tests
... └── tests.swift 

SPM のひとつの利点は、ファイルシステムを source of truth に扱う所にある。これにより、単に新しいフォルダを作成するだけで新たなモジュールを定義することができる。

では、Package.swiftCommandLineToolCommandLineToolCore の2つをターゲットとするように編集する。

Package.swift
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/CommandLineToolCoreCommandLineTool.swift を以下の様に作成する。

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 から呼ぶようにする。

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 に依存関係として指定すればよい。

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 を出力させる代わりに、コマンドラインツールの引数に取ったテキストを名前とするファイルを作成させる様にする。

CommandLineTool.swift
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 にテスト用のモジュールを追加する。

Package.swift
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 にテストコードを書いていく。

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

完成 :tada:

5
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?