CLI apps in Elixir. Part 2


In this post, we'll explore each tool described in Part 1 to see for ourselves the benefits and limitations of each alternative with the hope we'll end up with enough knowledge to decide which one fits best for each use case.
A nice approach to easily compare alternatives is building the same app with each tool. That way you can easily spot the similarities and differences between them.
For the sake of simplicity, you are going to build a simplified version of the wc command. The wc
command is short of "word count" and allows counting new lines, words, characters and a few more. But the core features we'll implement are:
Parse command line arguments.
Read from
stdin
and output tostdout
.Support reading a single file when provided as an argument.
Return the stats for newline, word and grapheme (this is not standard but we'll do it this way because it is nicer with UTF-8 files).
We'll ignore showing comprehensive help, well-formatted error messages, reading multiple files when provided and any other feature defined in its man page.
Now let's get into the code ๐งโ๐ป
Business logic
To make things easier to read and understand let's create a POEM (Plain Old Elixir Module(*)). We'll be able to use this module across implementations to focus on the differences.
(\) I've just made this up but took some inspiration from the Java world where classes holding only business logic are called POJOs (Plain Old Java Objects).*
Here's the definition of WC
, a module that holds the logic to perform a subset of features offered by wc
, specifically it counts graphemes, words and lines.
defmodule WC do
def run(args) do
args
|> parse_options()
|> execute()
end
def parse_options(args) do
OptionParser.parse(args,
aliases: [l: :lines, w: :words, c: :chars],
switches: [chars: :boolean, words: :boolean, lines: :boolean]
)
end
def execute(options) do
{file, opts} =
case options do
{opts, [], _} ->
{:stdio, opts}
{opts, [file | _], _} ->
{file, opts}
end
case read_file(file) do
{:ok, content} ->
content
|> count_content()
|> print_results(file, opts)
{:error, :file_not_found} ->
IO.puts("File not found: #{file}")
System.halt(1)
end
end
@default_opts [lines: true, words: true, chars: true]
def print_results(results, file, []) do
print_results(results, file, @default_opts)
end
def print_results(results, file, opts) do
result =
Enum.reduce(@default_opts, "", fn {key, _}, acc ->
if opts[key] do
acc <> "\t#{results[key]}"
else
acc
end
end)
if file == :stdio do
IO.puts(result <> " " <> "\n")
else
IO.puts(result <> " " <> file <> "\n")
end
end
def count_content(content) do
content
|> String.graphemes()
|> Enum.reduce(%{lines: 0, words: 0, chars: 0}, fn char, acc ->
cond do
char == "\n" ->
%{acc | lines: acc.lines + 1, chars: acc.chars + 1, words: acc.words + 1}
char in [" ", "\t"] ->
%{acc | words: acc.words + 1, chars: acc.chars + 1}
true ->
%{acc | chars: acc.chars + 1}
end
end)
end
def read_file(:stdio) do
{:ok, IO.read(:stdio, :all)}
end
def read_file(file) do
if File.exists?(file) do
File.read(file)
else
{:error, :file_not_found}
end
end
end
Here's a summary of its features:
Supports
-l
to count lines,-w
to count words and-c
to count graphemes.The first argument after the options should be a path to an existing file.
When no file is provided it reads from
stdin
.When no option is provided it assumes the caller wants all stats (all switches are on).
Returns an error code of
1
when the file doesn't exist and0
if the execution was successful.
Note: This is a naive implementation that takes some shortcuts to simplify the code for readability while still having some utility when running some examples.
Implementations
For testing purposes let's create a file named sample.txt
with the following content:
This is one
simple text
file
1234
end line is this
From here on we'll focus only on the differences of each alternative. The full code can be found in this Github repo.
Also, will be using the $
character before a shell command to indicate it runs as a non-root user, but most importantly to differentiate a command from its output within the same code block.
Elixir Scripts
# Assume the previous WC module is included here. E.g.
# defmodule WC do
# ...
args = System.argv()
WC.run(args)
Let's call this file wc.exs
and run a few examples:
Default run
$ elixir wc.exs sample.txt
5 11 51 sample.txt
- Use a CLI pipe
$ cat sample.txt | elixir wc.exs
5 11 51 sample.txt
- Pass specific parameters
$ elixir wc.exs -l sample.txt
5 sample.txt
Here you can see how the script gets interpreted by the elixir
cli app and passes its arguments by taking everything after the wc.exs
file. Notice how elixir
needs to be installed as well as having the source code to run the app.
Mix Run
Once the project starts requiring more structure and code distribution the defacto standard tool to use is Mix. So let's create an app using mix
and reuse wc.exs
by promoting to a .ex
file. Also copy the sample.txt
file within the project only for convenience.
mix new app1
cp wc.exs app1/lib/wc.ex
cp sample.txt app1/
cd app1
You should edit app1/lib/wc.ex
by removing the last two lines and placing them in a new file called run.exs
:
args = System.argv()
WC.run(args)
Now let's run the application:
$ mix run run.exs sample.txt
5 11 51 sample.txt
Awesome! You can leverage Mix features to easily organize and improve your projects. You still need the source code to run it this way but this is a quick way to run scripts from a Mix project. Let's improve this by using Mix releases.
Mix Releases
Create another mix project like you did before but call it app2
to have a fresh start.
mix new app2
cp wc.exs app2/lib/wc.ex
cp sample.txt app2/
cd app2
Remove the last 2 lines of wc.ex
as before and create a module under lib/cli.ex
with the following content:
defmodule CLI do
def run do
args = System.argv()
WC.run(args)
end
end
This module will be the starting point for the app.
Next, you need to configure the project. For demo purposes, you'll create a tarball file of the project to be able to distribute it as a single file. So let's edit mix.exs
and add:
def project do
[
...
releases: releases()
]
end
def releases do
[
app2: [
include_executables_for: [:unix],
applications: [runtime_tools: :permanent],
steps: [:assemble, :tar]
]
]
end
To build a release run:
MIX_ENV=prod mix release
We provided MIX_ENV=prod
to build a release optimized for production use. If you don't pass the environment variable it will use dev
by default.
The app is ready. Let's use eval
and pass the Module.Function
as the first argument and the rest will be provided to the CLI app as its arguments.
$ _build/prod/rel/app2/bin/app2 eval "CLI.run" -l sample.txt
5 sample.txt
Even stdin
will work:
$ cat sample.txt | _build/dev/rel/app2/bin/app2 eval "CLI.run" -lw
5 11
Note: There's no filename in the output because it uses stdin
as the source of information to parse.
This is all great and you can find the tarball containing the CLI app in _build/prod/app2-0.1.0.tar.gz
. However, the person who will run this in their host still needs to uncompress and untar it (i.e. tar xvzf _build/prod/app2-0.1.0.tar.gz
) to use it. In other words it isn't a single executable that you can pass around.
Let's check the next two options to address this final limitation while maintaining all the great features you collected so far.
Escript
Once again create a new project and reuse wc.exs
like you did so far:
mix new app3
cp wc.exs app3/lib/wc.ex
cp sample.txt app3/
cd app3
Note: Remember to remove the last 2 lines used to execute the module's function.
Next, set up the project to use escript
and instruct which module should be used to kick off the app. Modify mix.exs
to include:
def project do
[
...
escript: escript()
]
end
def escript do
[main_module: CLI]
end
Create a file under lib/cli.ex
with:
defmodule CLI do
def main(args) do
# No need to call System.argv() as it is provided by escript
# as an argument to this function
WC.run(args)
end
end
To build the project use the escript.build
task:
$ MIX_ENV=prod mix escript.build
Generated app3 app
Generated escript app3 with MIX_ENV=prod
Success! You have a single binary file representing your CLI app. Let's check its type and then test it!
$ file app3
app3: a /usr/bin/env escript script executable (binary data)
./app3 sample.txt
5 11 51 sample.txt
Very cool! This single file can be easily distributed as long as the limitations described in Part 1 don't affect your use case. In case some do then prepare your hot sauce because you'll need it for the next tasty solution ๐ฅ๐ฏ.
Burrito
Until now all alternatives were part of the standard Elixir distribution but thanks to the great work of the community and Burrito maintainers we now have a full-featured solution to build and distribute single binary apps for Elixir: https://github.com/burrito-elixir/burrito
Burrito requires Zig to be installed as well as xz
so make sure you have them installed:
$ whereis xz
$
Let's set up a fresh app and reuse the WC
module:
mix new app4
cp wc.exs app4/lib/wc.ex
cp sample.txt app4/
cd app4
Burrito is an external dependency so you'll need to add it to mix.exs
under deps
:
defp deps do
[
{:burrito, github: "burrito-elixir/burrito"}
]
end
And then fetch the dependency package using mix:
mix deps.get
Now let's set it up in mix.exs
def project do
[
# ... other project configuration
releases: releases()
]
end
def releases do
[
app4: [
steps: [:assemble, &Burrito.wrap/1],
burrito: [
targets: [
macos: [os: :darwin, cpu: :x86_64],
linux: [os: :linux, cpu: :x86_64]
]
]
]
]
end
Sweet! Burrito leverages Mix releases which means you get all their benefits plus the ones from Burrito.
Next You need to define a starting point for the app, so edit mix.exs
but this time add the following change to it:
def application do
[
...
mod: {CLI, []}
]
end
CLI
is just a module name so let's create it under lib/cli.ex
defmodule CLI do
use Application
def start(_type, _args) do
args = Burrito.Util.Args.get_arguments()
WC.run(args)
System.halt(0)
end
end
To build the artifact let's run:
MIX_ENV=prod mix release
The targets can be found under the burrito_out
directory within the current project. Without specifying a target you end up building all of them listed in your mix configuration file.
To test the app run:
$ ./burrito_out/app4_macos -l sample.txt
5 sample.txt
Awesome! Let's check the file's type:
$ file burrito_out/*
burrito_out/app4_linux: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.0.0, stripped
burrito_out/app4_macos: Mach-O 64-bit executable x86_64
Beautiful! That looks like executables for specific OS and architectures. For more details check out the Preparation and Requirements section of their readme.
๐จ Important: Burrito will install the app based on its mix version. If you perform a change to your code and run mix release
without uninstalling the app you'll get the previous version executed not the current one. So make sure you either:
a. Bump the version in mix.exs
b. Uninstall the current version: burrito_out/app4_macos maintenance uninstall
Hope this last tip saves you some time or headaches ๐
Summary
All options are valid and useful but in general, Escript or Burrito solutions are what you want to use when building non-trivial single binaries CLI apps in Elixir. But if in doubt then start with a single .exs
file and see how far you can get until you start needing more sophisticated solutions.
This concludes the second part of this series. Hope you enjoyed it and found it useful! ๐บ
Subscribe to my newsletter
Read articles from Brewing Elixir directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

Brewing Elixir
Brewing Elixir
Looking forward to learn through teaching, building and sharing!