Using Swift for scripting Jul 23 2019

Swift is a powerful language. It can be used to create command-line tools, iOS apps, watch OS apps, macOS apps and server-side applications. However, sometimes, we only need to complete a small task, maybe do some automation on our local setup or build a simple script to process data and then send it to another tool. Using Swift for small tasks is what I want to share in this post, not how to build command-line interfaces (CLIs) but how to use the language you already know, Swift, and use it for scripting.

I'll start with the basics, creating your first "Hello, World!" script, I'll move fast from the basics, but it is always good to start with a good base.


Bash Beyond Basics Increase your efficiency and understanding of the shell

If you are interested in this topic you might enjoy my course Bash Byond Basics. This course helps you level up your bash skills. This is not a course on shell-scripting, is a course on improving your efficiency by showing you the features of bash that are seldom discussed and often ignored.

Every day you spend many hours working in the shell, every little improvement in your worklflows will pay dividends many fold!

Learn more

Hello World!

Let's start by creating our first script, the "Hello, world!" script. If you are not familiar with scripting, you might not know the term "magic comments". Magic comments are comments placed at the top of your file. These comments can be interpreted by the command reading the file and make decisions based on the comment. The most famous magic comment is probably the #! (also known as the shebang, sha-bang, or hashbang), when an executable file has this magic comment the program loader interprets it and tries to use the command after the #! to run the file. For example:

1
2
#!/usr/local/bin/bash
echo "hello, world!"

In the previous script, the magic comment tells the program loader to use bash as the interpreter. Then it passes the content of the file to that bash to execute it. If you notice the script uses bash installed in an unconventional path (I installed bash using brew). We can't be 100% sure that the interpreter is always in the same place, but we can make use of the env command. The env command is likely to always be in the same location, /usr/bin/env, so it is safer to use it to set up the environment and run the command. We can now rewrite our script as:

1
2
#!/usr/bin/env bash
echo "hello, world!"

That command uses the bash found on the user's path. Now let's make the script executable, that way we can run it. Using the command-line change the file permissions:

1
$ chmod u+x myfile.sh

With that done, we can execute it:

1
$ ./myfile.sh

You should see the message "hello, world!" on screen. Good, that should cover the basics of general scripting. Now let's do the same "hello, world!" script but using Swift.

Our first Swift script

Create a new file for your script (you can name it, script.swift if you want) in your favourite editor. Add the following content:

1
2
#!/usr/bin/env swift
print("hello, world!")

Now give it execution permissions:

1
$ chmod u+x script.swift

Run it:

1
$ ./script.swift

You should see the "hello, world!" message on your screen. You now know how to create and execute your own Swift scripts. With that out of the way, we can move to review some problems that you might encounter on your Swift scripting career.

Scripts are meant to be simple. This simplicity allows us to easily compose command-line tools together, having the output of one tool be the input of the next tool, and so on. This composition of tools is what allows us to build more complex workflows out of simple tools. Before we can compose other commands, let's first see how to execute a command from our scripts.

Executing other commands from our scripts

To execute a command, we need to spawn a subprocess. We do that by instantiating Process on our script. Let's see an example of a Swift script that calls the ls command.

1
2
3
4
5
6
7
8
9
#!/usr/bin/env swift
import Foundation

let ls = Process()
ls.executableURL = URL(fileURLWithPath: "/usr/bin/env")
ls.arguments = ["ls", "-al"]
do{
  try ls.run()
} catch { //TODO: write catch for executing command}

As you can see, we are running the env command and sending the parameters ls and -al. You should see a listing of the current directory. If we want to call ls directly, we need to find out where ls is. We do this by using the which command:

1
2
$ which ls
#/bin/ls

Now that we know ls's path, we could rewrite our script like the following:

1
2
3
4
5
6
7
8
9
#!/usr/bin/env swift
import Foundation

let ls = Process()
ls.executableURL = URL(fileURLWithPath: "/bin/ls")
ls.arguments = ["-al"]
do{
  try ls.run()
} catch { //TODO: write catch for executing command}

The run method can throw an exception, so that is why we have a try-catch pair, be sure to fill it up.

That should give you a good idea on how to run commands. Sometimes we not only want to run a command but also want to capture the output of a command. Let's see how to do that.

Capturing the output of a command

As with any process, we have a Standard Output, Standard Error, and Standard Input. The first two are used to output data and the last one to receive data. If we want to capture the Process standard output, we can redirect it to a Pipe and read from the pipe. The same idea like how we do it in bash using the | (pipe operator).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#!/usr/bin/env swift
import Foundation

let ls = Process()
ls.executableURL = URL(fileURLWithPath: "/bin/ls")
ls.arguments = ["-al"]

var pipe = Pipe()

ls.standardOutput = pipe
do{
  try ls.run()
  let data = pipe.fileHandleForReading.readDataToEndOfFile()
  if let output = String(data: data, encoding: String.Encoding.utf8) {
    print(output)
  }
} catch {}

You can then process the output however you need.

A very common workflow is composing commands together, when using the shell we normally do this using the Pipe operator, we take the output of a command and send it as input for the next. Here is an example of how to pass the output of the ls command as the input for the sort command:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#!/usr/bin/env swift
import Foundation

let ls = Process()
ls.executableURL = URL(fileURLWithPath: "/bin/ls")
ls.arguments = ["-al"]

var pipe = Pipe()

ls.standardOutput = pipe

let sort = Process()
let completePipe = Pipe()

sort.executableURL = URL(fileURLWithPath: "/usr/bin/env")
sort.arguments = ["sort"]
sort.standardInput = pipe
sort.standardOutput = completePipe


do{
  try ls.run()
  try sort.run()
  let data = completePipe.fileHandleForReading.readDataToEndOfFile()
  if let output = String(data: data, encoding: String.Encoding.utf8) {
    print(output)
  }
} catch {}

As you can see, we set the stdout (Standard Output) of ls to be pipe, and we set the stdin (Standard Input) of sort to be that same pipe, that way we create the data flow. Pipes are very useful to create those data flows. One problem we can encounter is that the first process takes too long, so we want to wait until the process is finished to start reading. For those cases, you can use waitUntilExit to make this happen:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#!/usr/bin/env swift
import Foundation

let ls = Process()
ls.executableURL = URL(fileURLWithPath: "/bin/ls")
ls.arguments = ["-al"]

var pipe = Pipe()

ls.standardOutput = pipe

let sort = Process()
let completePipe = Pipe()

sort.executableURL = URL(fileURLWithPath: "/usr/bin/env")
sort.arguments = ["sort"]
sort.standardInput = pipe
sort.standardOutput = completePipe


do{
  try ls.run()
  try sort.run()
  ls.waitUntilExit()
  sort.waitUntilExit()
  let data = completePipe.fileHandleForReading.readDataToEndOfFile()
  if let output = String(data: data, encoding: String.Encoding.utf8) {
    print(output)
  }
} catch {}

Perfect! That should get you started with composing different commands. We've seen only the "happy path", everything working out, but there are many cases that things don’t go well. We want to make sure that our scripts can play well with other commands. We also want our scripts to be easy to compose with other commands. A critical aspect of this is providing the correct exit codes and correctly using stderr (Standard error). We'll start with exit codes.

When things go well and when things go wrong

Let's begin by creating a script that shows a list of users. We can name the file list.swift:

1
2
3
4
5
6
7
8
#!/usr/bin/env swift
import Foundation

var users = ["zoe", "joe", "albert", "james"]

for user in users {
  print(user)
}

It's a simple script, but we can use it to explore a few concepts. Let's compose that command with the sort command on the shell:

1
$ ./list.swift | sort

You should see a sorted list of the users. That’s good, but what happens if our script produces an error?

First, let's make sure that we don't have empty names. If there is an empty name, the script displays an error.

1
2
3
4
5
6
7
8
9
10
11
12
#!/usr/bin/env swift
import Foundation

var users = ["zoe", "joe", "albert", "james", ""]

if users.contains(""){
    print("Error: there is an empty name")
} else {
  for user in users {
    print(user.capitalized)
  }
}

If we run that script, the script displays an error to the screen. If we compose the script with another command using the && operator, the second command still executes. Let's look at the && operator to compose commands in the shell:

1
$ ./list.swift && echo "Users validated successfully."

Running that command, you'll see Error: there is an empty name in the screen, and the Users validated successfully. That is not what we want to happen. We want to tell the shell that our script completed but with an error code. Error codes are Ints, where 0 indicates success and anything else error. We can view the exit code of our previous command using the variable $? in bash.

1
2
3
$ ./list.swift
$ echo $?
# you'll see 0

We are returning 0 no matter what returning. That is the default behaviour unless we explicitly tell the script to exit with another code. Let's fix that and exit with the correct exit code.

1
2
3
4
5
6
7
8
9
10
11
12
13
#!/usr/bin/env swift
import Foundation

var users = ["zoe", "joe", "albert", "james", ""]

if users.contains(""){
    print("Error: there is an empty name")
    exit(1)
} else {
  for user in users {
    print(user.capitalized)
  }
}

Now if we execute the command composition again:

1
$ ./list.swift && echo "Users validated successfully."

This time, we only see the error, not the message: Users validated successfully..

Another important aspect of being a friendly script is that we use the stdout (Standard Output) and stderr (Standard error) correctly. Displaying errors on the stderr is expected behaviour, it is common to want to save the errors to a log file, or pipe our scripts stdout to another command. Let's see how to make our script's output work as expected.

STDOUT and STDERR

Remember, at the beginning of the post, when we redirected the stdout of one command to the stdin of another command. We want our scripts to support that. In bash we can redirect the stderr of a command using the redirect operator 2>. The stdout and stderr have a file descriptor number (1 and 2 respectively) we use the file descriptor with the redirect operator to indicate which stream we want to redirect. For example, if we try to list a file that doesn't exist in the current directory with the following command:

1
$ ls non_existant_file.txt

We get an error. We can redirect that error to a log file:

1
$ ls non_existant_file.txt 2> error.log

Nothing shows on the screen because the error message was redirected to the error.log file. If we see the content of the error.log, we see the error:

1
2
$ cat error.log
#ls: non_existant_file.txt: No such file or directory

We want to do the same with our script, it currently shows an error, so we want to redirect that to the error.log file. Let's try to do the same:

1
$ ./list.swift 2> error.log

It didn't work. We still see the error on the screen. What happened?

What happened is that we are not using the stderr to display our errors. We are only using print, and print by default displays to the stdin. Let's fix that:

1
2
3
4
5
6
7
8
9
10
11
12
13
#!/usr/bin/env swift
import Foundation

var users = ["zoe", "joe", "albert", "james", ""]

if users.contains(""){
    FileHandle.standardError.write("Error: there is an empty name".data(using: .utf8)!)
    exit(1)
} else {
  for user in users {
    print(user.capitalized)
  }
}

Now we can rerun the command:

1
$ ./list.swift 2> error.log

Nothing is displayed, and the errors are inside error.log. Great! If we want to be strict, we should change our prints for FileHandle.standardOutput instead of print, but you get the idea.

We saw how to handle output from your scripts, let's see how to handle input.

A glance at handling input in our scripts

There are two primary input sources for your scripts, arguments from the command-line and data form the stdin(Standard input). Let's first have a look at arguments.

Handling command-line arguments

Our Swift script has access to the CommandLine object, and from it, we can read the arguments passed to our script. Let's create a script that displays our arguments:

1
2
3
4
#!/usr/bin/env swift
import Foundation

print(CommandLine.arguments)

Now let's call our script with the following arguments:

1
$ ./input.swift these are the arguments

We see the arguments on the screen:

1
["./input.swift", "hello", "this", "are", "arguments"]

As you can see the first element of our arguments is the name of the script, followed by the rest of the strings separated by spaces. We can also check the number of arguments with argc.

1
2
3
4
5
#!/usr/bin/env swift
import Foundation

print(CommandLine.arguments)
print("Number of arguments:\(CommandLine.argc)")

Now is the time when we would parse the parameters and make sense of them, but this post has gone longer than I expected just as it is, so we'll leave it for next time.

We can now move to how to read from the stdin.

Reading form stdin

Reading from the standard input is quite easy. We make use of the function readLine(strippingNewline:). The function returns a String optional, let's look at an example:

1
2
3
4
5
6
#!/usr/bin/env swift
import Foundation

print("Enter your name:")
var name = readLine(strippingNewline: true)
print("Hello \(name ?? "anonymous")")

So if we run our script directly on the command line we are prompted for our name, try it out:

1
2
3
4
$ ./input.swift
Enter your name:
# I enter Derik, hit return
Hello Derik

That gives you a good idea on how to read from the stdin. Rember that we are reading form stdin, this means that if we send anything to the stdin, our program reads it. Look at this:

1
$ echo "Derik" | ./input.swift

You don't have to type anything the script gathers the stdout of the previous command echo "Derik" and pipes it to our script's stdin. Beautiful.

Now you know a good deal about scripts and especially on Swift scripting. Go on and have fun!

Final Thoughts

I love scripting. There's something beautiful in hacking a quick script to automate a task, or just for the sake of making a process less error-prone. Writing apps is like writing novels, but writing scripts is like writing poems.

Writing scripts in Swift is not as simple as writing them in an interpreted/dynamic language like BashScript, Ruby or Perl. However, there is no reason not to use the language you know to build your scripts. Use the tools you already know and don’t fall into the trap of spending all the time learning, use what you know and build stuff.

We covered the basics of scripting in Swift, there are many topics I would like to write about and share with you, but this post has gone longer than I was expecting already. As always let me know if it was useful, send me your comments or questions I'll be glad to improve the post to help more people. Also, remember to read the notes at the end of the post for more information on related topics.

Related topics/notes of interest

1
2
3
4
5
6
7
8
9
10
11
12
#!/usr/bin/env swift
import Foundation

let ls = Process()
ls.executableURL = URL(fileURLWithPath: "/bin/ls")
ls.arguments = ["-al"]
ls.terminationHandler = { (process) in
   print("Completed with exit code: \(process.terminationStatus)")
}
do {
  try ls.run()
}
1
2
3
4
5
6
7
8
9
10
for argument in CommandLine.arguments {
  switch argument {
    case "option1":
      print("do task one")
    case "option2":
        print("do task two")
    default:
          print("do the default behaviour")
  }
}

** If you want to check what else I'm currently doing, be sure to follow me on twitter @rderik or subscribe to the newsletter. If you want to send me a direct message, you can send it to derik@rderik.com.