17-Dec

App Development, Development

Cross platform CLIs with Swift

Swift is mostly known as «that language people use to make iPhone apps». And while the two certainly are a good match, Swift is not really tied to the iPhone anymore. Let’s see how it performs as a language for making cross platform CLIs!

5 min read

·

By Bendik Solheim

·

December 17, 2021

I love Swift. It hits that sweet spot between ergonomically pleasing and good safety features. It’s not too unforgiving (I’m looking at you, Rust), and at the same time manages to produce relatively safe code. It can certainly crash, that’s for sure, but the compiler catches enough of the errors I usually make to make me confident that my code just might work.

Let’s take Swift out of its safe harbor called iOS, and instead throw it into the ancient land of terminals. We’ll end up with a small CLI where we convert input text to a hash. We will support MD5, Sha1 and Sha256 hashes.

I have managed to run this on macOS and Windows through WSL2, so it’s cross platform enough for me. Swift wasn’t exactly born cross platform though, so there are no guarantees that it will run on your platform. If you are unsure, just try!

Now, enough with the warnings. I’m a big fan of git style CLIs with sub commands, so let’s make something like this:

λ hsh
Usage: ...

λ hsh md5 my-input-text
d5ccf501b14c22743fdfd4218595a922

λ hsh sha1 my-input-text
5a77099f226d020a4c7f674e178d515c6c8490cc

In other words, the format of our CLI is <command> <subcommand> <input>.

Let’s get straight to it. The only thing you need installed is Swift Package Manager. Start up your terminal and generate a project

λ cd your/project/folder

λ mkdir hsh && cd hsh

λ swift package init --type=executable

This gives you a bare bones project, and if you run it with swift run, you’ll be presented with the age old "Hello, world!".

Swift Argument Parser

We could of course parse arguments manually, but I’d rather let a library take care of the nitty gritty argument parsing details, and end up with readable and understandable code instead. Luckily, Apple has a library called swift-argument-parser which is both pleasant to use and supports a wide range of CLI styles: sub commands, options, arguments and flags. To install it, simply open up Package.swift, add it to your package dependencies and your target dependencies. Your Package.swift file should look something like this

// swift-tools-version:5.5
// The swift-tools-version declares the minimum version of Swift required to build this package.

import PackageDescription

let package = Package(
    name: "hsh",
    dependencies: [
        .package(url: "https://github.com/apple/swift-argument-parser", from: "1.0.0"),
    ],
    targets: [
        .executableTarget(
            name: "hsh",
            dependencies: [
              .product(name: "ArgumentParser", package: "swift-argument-parser"),
            ]),
        .testTarget(
            name: "hshTests",
            dependencies: ["hsh"]),
    ]
)

This is all we need – the next time we use swift run, our library is installed. Now, open up Sources/hsh/main.swift and wipe away everything. Replace it with

import ArgumentParser

struct Hsh: ParsableCommand {
    mutating func run() {
      print("Hello, world!")
    }
}

Hsh.main()

Run it with swift run – and be amazed! You have just installed a library, put it to use, and got the exact same output as you did before you started – amazing! That is job security right there!

Luckily, there’s more to it. If you run swift run hsh --help, you will get automatically generated help for your little CLI. If you run it with some unexpected argument, you’ll also be told so, and told what to do instead.

Let’s take it one step further. We want our CLI to support the sub commands md5 and sha1 – this is, appropriately enough, implemented by configuring our command with sub commands, which refer to two new structs:

struct Hsh: ParsableCommand {
    static var configuration = CommandConfiguration(
        subcommands: [Md5.self, Sha1.self]
    )
}

struct Md5: ParsableCommand {
    mutating func run() {
        print("md5")
    }
}

struct Sha1: ParsableCommand {
    mutating func run() {
        print("sha1")
    }
}

There are a couple of things to note here:

  • We removed the run() function in our Hsh struct. This makes the help text we get from --help the default output. I like this – it makes our CLI more discoverable and helpful!
  • The sub commands themselves are ParsableCommands as well. I don’t know why, but this really resonates with me.

Pop open your terminal once again, and run swift run hsh. This time, you’ll see your shiny new commands under the SUBCOMMANDS section. Try running them as well to see that everything is working properly.

Swift-crypto

Sooo. Where do we go from here? Well, we could always make md5 and sha1 actually do something useful. I have absolutely no intention what-so-ever of implementing these hashing algorithms myself (I could try to convince you I know how, but.. yeah, no), so let’s add a cross platform library which does this for us. Open up Package.swift and add this line to your topmost dependencies block

.package(url: "https://github.com/apple/swift-crypto.git", "1.0.0" ..< "3.0.0"),

And this line to the dependencies block inside the executableTarget block

.product(name: "Crypto", package: "swift-crypto"),

Let’s start by printing the md5 hash of a static value. To do so, we first need to import the Crypto library by adding import Crypto to the top of Sources/hsh/main.swift. We then replace the run() function in the Md5 struct with this

mutating func run() {
    let data = "my-string".data(using: .utf8)
    let hash = Insecure.MD5.hash(data: data!)
    print(hash.map { String(format: "%02hhx", $0) }.joined())
}

As you can see, Crypto really does all the hard work here. The only weird thing going on is the convertion to a string on the last line, which is due to the way Crypto represents the hash internally. It turns out there are better ways of storing hashes than as strings, which is what I have done since I started programming 15 years ago. Who could have known.

Try it with swift run hsh md5 if you want, or keep on coding if you’d rather hash an argument passed to your CLI! This is really easy with swift-argument-parser. First, add these two lines to the top of your Md5 struct

@Argument(help: "String to calculate MD5 hash from")
var value: String

And then change "my-string" to value in the run() function. Congratulations – you now have the Md5 generating CLI of your dreams! Try it with swift run hsh md5 my-string (or something cooler – I don’t know). Try it without giving it an argument as well and see what happens!

Adding Sha1 support is just as easy. Start by adding the same @Argument... property to the Sha1 struct as well, and change its run() function into

mutating func run() {
    let data = value.data(using: .utf8)
    let hash = Insecure.SHA1.hash(data: data!)
    print(hash.map { String(format: "%02hhx", $0) }.joined())
}

(side note: seeing this while I write this blog post, the two run functions are.. strikingly similar. Could we perhaps generalize this somehow? )

Now, try running swift run hsh sha1 my-string as well, and see your wonderful CLI present you with an amazing sha1 hash as well!

For completeness, I have a fully working Sources/hsh/main.swift file here. Take a look if your stuck on something, or my wonderful explanation skills didn’t quite make it.

If you enjoyed this, there are plenty more hashing algorithms you could implement in the Crypto library. Or perhaps you could make your CLI more general, and implement other useful developer tools as well?