Flargs
While working on a small CLI for encrypting and decrypting files, I found myself unstatisfied with the practice of writing, and testing, and reasoning about a CLI in Go. The flag package was not pleasant to work with. Testing was hard and inelegant, and the whole concept of a CLI is leaky. So I pulled out my hair, and cursed at the sky.
Then, I distilled the problem down its constiuent parts, and wrapped them in Flargs. At it’s heart, Flargs is a very simple framework that forces you to do a few things, and in return gives you ease, power, and reproducibility. This is the Hermetic promise: If you never touch the world, you will become divine.
Flargs has three run-time phases:
-
Parsing. This execution phase gives you no access to your command or the environment it will run against. All you have is a “Konf”, a
[]string{}
, and the logic to turn the latter into the former. If you return an error here, the program stops. -
Loading. In this phase you do have access to the environment. Here you can decide if you Konf makes sense in this environment. For instance, you can check to see that a file actually exists. If you return an error, execution stops.
-
Running. This is actually what makes your CLI useful. The run phase has no access to the original
[]string{}
. It has a Konf and an Environment. Flargs has 3 basic concepts:
Konf
The custom structure that represents inputs transformed by Parsing and Loading. You control what it looks like. The sky’s the limit. But you need to embed flargs.StateMachine
like so:
type MyKonf struct {
host string
port int
flargs.StateMachine
}
State Machine
This is a struct with a few properties, allowing for chainability. It also contains all the methods required to satisy this interface:
type Flarger interface {
Parse([]string) error
Load(*Environment) error
Run(*Environment) error
}
And here is the struct that implements Flarger
:
type StateMachine struct {
RemainingArgs []string
Phase Phase
}
// no op
func (s *StateMachine) Parse(a []string) error {
...
return nil
}
// no op
func (s *StateMachine) Load(_ *Environment) error {
...
return nil
}
// no op
func (s *StateMachine) Run(_ *Environment) error {
...
return nil
}
Since you embdedded this struct into your struct, you get these methods for free. But they are all no-ops. So to get interesting functionality you’ll want to write your own Run()
method at least. To accept paramers, you’ll want Parse()
at least. You may decide that Load()
is not needed. You can simply ignore it.
Environment
It looks like this:
type Environment struct {
InputStream io.ReadWriter
OutputStream io.ReadWriter
ErrorStream io.ReadWriter
Randomness io.Reader
Filesystem fs.FS
Variables map[string]string
}
Command
A command is a Konf with and Environment, and contains enough information to run and be useful.
Getting Started
Get the package:
go get github.com/sean9999/go-flargs
Let’s say we want to create a CLI that performs rot13 on text passed to it via stdin, ignoring any arguments or flags. Our Konf becomes simple, because it doesn’t need to hold and data:
import (
"github.com/sean9999/go-flargs"
)
type RotKonf struct {
flargs.StateMachine
}
We don’t need to define a Parse()
or Load()
method. But we do want Run()
:
func (r *RotKonf) Run(env *flargs.Environment) error {
// rotate a rune
rot13 := func(r rune) rune {
switch {
case r >= 'A' && r <= 'Z':
return 'A' + (((r - 'A') + 13) % 26)
case r >= 'a' && r <= 'z':
return 'a' + (((r - 'a') + 13) % 26)
default:
return r
}
}
// read in input rune by rune
runeStream := bufio.NewReader(env.InputStream)
result := []byte{}
for {
if c, _, err := runeStream.ReadRune(); err != nil {
break
} else {
result = append(result, byte(rot13(c)))
}
}
// write result
env.OutputStream.Write(result)
return nil
}
The alogorithm isn’t very interesting. What’s noteworthy is that there is no reference os.Stdin
, and there is no fmt.Println()
call. This is important. Every flarg app needs to be hermetic. No matter, we still have a fully functional CLI:
k := new(RotKonf)
env := flargs.NewCLIEnvironment("") // no filesytem needed
cmd := flargs.NewCommand(env, k)
cmd.ParseAndLoad(nil) // no arguments needed
cmd.Run()
That’s our CLI in it’s “real” form. A utility that can be built and run in a terminal. Let’s build and run:
go build -o rot13
cat /etc/passwd | ./rot13
That worked! Or at least it did for me. But we want more. Let’s write a test:
func TestRot13(t *testing.T) {
inputText := []byte("neon craal nowhere germ, pening ebbs balk.")
want := []byte("arba penny abjurer trez, cravat roof onyx.")
k := new(rot13.RotKonf)
env := flargs.NewTestingEnvironment(nil) // no randomness needed
cmd := flargs.NewCommand(k, env)
cmd.ParseAndLoad(nil)
// send some text to "stdin"
env.InputStream.Write(inputText)
err := cmd.Run()
if err != nil {
t.Error(err)
}
got := env.GetOutput()
if !bytes.Equal(got, want) {
t.Errorf("wanted %s but got %s", want, got)
}
}
Here we used a convenience method to get at the values in env.OutputStream
. This pattern has served me well and has resulted in less hair-pulling.
In conclusion, flargs provides pleasant, fragrant, parsimonious parsing of flags and arguments, resulting in clean, testable, hermetic apps that provide feasible breeze and are easy to reason about.