CLI apps in Elixir. Part 2

CLI apps in Elixir. Part 2

Exploring Elixir Scripts, Mix releases, Escript, and Burrito

ยท

9 min read

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 to stdout.

  • 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 and 0 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:

  1. Default run

$ elixir wc.exs sample.txt
    5    11    51 sample.txt
  1. Use a CLI pipe
$ cat sample.txt | elixir wc.exs
    5    11    51 sample.txt
  1. 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! ๐Ÿบ

ย