Building a Command-Line Application in Crystal
One of the things I do quite frequently as a developer is setting up a
development environment. I do lots of prototyping, so I’m always creating new
applications that have differing environment requirements. Typically, I use
foreman to run an application in an environment defined in a .env
file (e.g. foreman run mix phoenix.server
), and I define an application’s
environment needs in an app.json file. This works well, but filling
in a .env file from the requirements outlined in an app.json file is really
tedious.
In order to solve this, I decided to build a command-line application to help with filling in .env files. The application would need to:
- Parse the “env” section in the app.json file
- Merge the parsed “env” with values from the “environments” section, if needed
- Merge any existing values from an existing .env file
- Prompt the user to optionally override any values
- Write the values back to the .env file
I wanted the application to be easy to install with as few requirements as
possible. For me, that narrowed the choices down to Crystal and
Go. I decided to go with Crystal, because although I appreciate that
it’s very easy to cross-compile Go with no needed dependencies, I like Crystal’s
Ruby-like syntax and useful built-in command-line option
parser. If you like, you can skip to the end result at
jclem/bstrap (brew tap jclem/bstrap && brew install bstrap
on a
Mac). In this post, I’m going to reflect on what I liked about building this
small application with Crystal and what was difficult.
Parsing Command-Line Options
One of the first things that I found useful was, as I mentioned before, Crystal’s command-line option parser that comes as part of its standard library. I wanted to have the commonly seen sort of command line options where a user can pass a full option name or an alias. This was just as easy to do in Crystal as it is in Ruby:
With just a handful of lines and some other simple code to call this class, a
user can mycli -p file.txt
, mycli --path=file.txt
, and mycli --help
. I was
really happy with how easy this was.
If you’ve ever used Ruby’s OptionParser
class before,
you’ll notice that the Crystal equivalent is almost identical. A more complete
example of option parsing in Crystal is in the bstrap
repo, or you can try out similar code in a Crystal
playground.
Parsing JSON
The next hurdle for me, parsing the JSON contents of an app.json file, was the one I knew I’d have the most trouble with. Crystal is a statically type-checked language, so I knew that there might be a good deal of boilerplate involved in parsing an app.json file and ensuring that I’m working with the types I expect to be working with. Further complicating things is the fact that the app.json specification allows multiple different types for many values. For example, an entry in “env” can be either a string representing the default value of that environment variable or an object describing the environment variable, e.g.:
The first step in parsing JSON was to write a simple function to read the
app.json file, parse it, and return a hash or raise if the root of the JSON
document is not an object. This was relatively straightforward—I’ll define a
function called parse_app_json_env
(we’ll add the “env” parsing to it soon):
The basic form of this function was relatively straightforward. First, we read
the file at the given path and parse it as JSON (notice that we rescue
invalid
JSON and return our custom exception). Then, we check whether the parsed JSON is
an object. We do this because in JSON, a document may be an object, an array, or
a scalar value. Obviously, we want to ensure that the contents of our app.json
aren’t, for example, an array or simply an integer.
It took me a little bit of getting used to, but Crystal actually makes this
checking pretty easy. The JSON.parse
class method returns a type
called JSON::Any
. This type is simply a wrapper around all
possible JSON types, and provides some useful methods to ensure we’re wrapping
the type we want. In the above example, you’ll see #as_h?
called. This method returns the type Hash(String, JSON::Type)?
meaning either
nil
or a hash with string keys and JSON type values. Putting things together,
we can check #as_h?
and either return that hash or raise an error because the
contents of our app.json file was something other than an object.
This was relatively straightforward, but remember that I want this method to
return the parsed “env” object, not just the raw parsed app.json file. I
updated my parse_app_json_env
to call a new method called parse_env
that
would take care of this:
The parse_env
method had a tricky job, because as I said before, the
app.json schema allows values in “env” to be either strings or objects. For
the sake of programming ease, I wanted to ensure that this method was always
returning a hash whose values were other hashes, regardless of what was parsed.
To express this, I defined a couple of new type aliases:
I first defined JSONHash
simply to refer to a hash whose keys are strings
and whose values are JSON types. I could have been more specific to the “env”
format and created a type whose keys are strings and whose values are either
strings or booleans (for the “required” key from the app.json schema), but
this didn’t seem necessary.
The ParsedEnv
type refers to a hash whose keys are strings and whose values
are JSONHash
es.
With these new types in hand, I could create the parse_env
function that would
read the “env” from an app.json hash and return a ParsedEnv
:
I think that the above isn’t particularly pretty, but it does the job. I fetch
the “env” value and assert that it’s an object (we just return an empty
ParsedEnv
if it’s not present, which is acceptable), and then iterate over its
key-value pairs. For each pair, we then have to check the value and ensure that
it’s a string or a hash, and raise otherwise.
The above example also introduces some of the things that are still mysteries to
me about the Crystal type system: ParsedEnv
is an alias for Hash(String, JSONHash)
, and JSONHash
is an alias for Hash(String, JSON::Type)
.
JSON::Type
, in turn, is an alias for a number of other types, including
String
. Why, then is it necessary for me to restrict the type of value
to
JSON::Type
when the compiler already knows that it is a String
?
Another thing that wasn’t apparent to me in the example above at first is that I
could call ParsedEnv.new
. If I were to declare parsed = {}
, the compiler
would complain that I should declare an empty hash in a way that includes the
expected key-value types, e.g. parsed = {} of String => JSONHash
. I have a
lot of these in bstrap, still, and didn’t realize until someone told me that I
could call .new
on a type alias, instead, and get the same result.
Overall, I found JSON parsing a little bit easier than I’ve found it with other type-checked languages such as Go. The weirdness of type restrictions and the tedium of checking everything as early as possible is a little tiresome, but helps prevent bugs.
Exceptions
Elixir has spoiled me. This command-line application has file reading, JSON parsing, and file writing, so there is plenty of opportunity for exceptions to be thrown. Given an imaginary program that reads a file, parses its JSON, and then writes “ok” to the file, an error-handled Elixir program might look like this:
In Crystal, the same error handling might look like this:
I greatly prefer the Elixir way, not only because I think that it reads better,
but also because in Crystal, I’m frequently having to look up and see what
possible exceptions a particular method might raise (or whether it might raise
any at all). Elixir indicates this clearly by either suffixing a function name
with a bang, e.g. Poison.parse!
or by returning a tagged tuple, where {:ok, value}
means success, and {:error, error}
indicates the error that occurred.
For some reason, this makes me feel much more assured that I am properly
handling errors, rather than ending every method with a rescue
clause.
There is a similar pattern available in the Bluebird Promises library for JavaScript. Bluebird allows me to chain a set of promises, and then pattern match my error handling based on a predicate (which may be a function or an error constructor):
I really like this pattern and was happy when the with
keyword made its way
into Elixir, which feels very similar. I wish Crystal/Ruby had something like
this.
Wrap-up
Overall, I really enjoyed writing bstrap in Crystal, and I think I’ll continue to use it for building command-line applications. It can’t do fancy things like statically link libraries like Go can so that I can just send someone a binary (I have to install some libs with Homebrew, instead), but the ease of programming in an extremely fast Ruby-like language with static type checking definitely makes up for that.