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.
Table of Contents
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 moreHello 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 Int
s, 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 print
s 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
- I wrote a post on Ruby Scripting, it has some nice tips on making your scripts work more like any Unix tool should behave. https://rderik.com/blog/shell-friendly-ruby-scripts/
- When you search for Swift scripting, you might find all documentation that uses
launch
that has been deprecated, us the official docs to guide you and try to update the code. Apple's official documentation on Process - Apple's official documentation on Pipe
- You can assign a closure to
terminationHandler
of Process, it runs when the process completes. Apple documentation on terminationHandler
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()
}
- Article on
#!
wikipedia entry check the portability section. - Simplistic argument parsing technique:
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")
}
}
- Scripts should be simple, but when you want to do crazy stuff and import other libraries or frameworks it might be worth a look at these tools:
- Swift-sh Third-party manager for swift scripts.
- Marathon, a command-line tool to help you manage dependencies and run your swift scripts.
- Beak a command-line tool to interface with your swift scripts functions. It also manages dependencies:
- NSHispter article on Swift-sh
- Paul Hudson article on Beak
When we want to read async have a look at
readabilityHandler
. Apple documentation And have a look at this StackOverflow answerWhen you want to build more complex CLI tools using Swift, have a look at Vapor ConsoleKit. If you've used Vapor before you've probably noticed the very slick tool they built. They used ConsoleKit to create it.
Jonas one of the project core contributors suggested (on reddit) to have a look at this Demo. He also said: "The example is from the current master branch which is for the new Vapor 4, which is in Alpha, the 3.3.0 tag is for Vapor 3"