Arg Parse in Go
Context
I'm making a CLI tool to check adb logs in terminal without having to open Android Studio.
I'm aware there are pre-made tools for this such as rustycat & pidcat, but as I was looking through the source code of both repos, I felt like this should be pretty easy to implement.
In this blog, we'll only talk about parsing the flags and arguements. Since, I really don't know go
. I used AI to generate an initial version.
This was the output.
package main
import (
"flag"
"fmt"
)
type Config struct {
packages []string
tagWidth int
minLevel string
currentApp bool
deviceSerial string
useDevice bool
useEmulator bool
version bool
}
func parseArgs() Config {
config := Config{}
flag.StringVar(&config.minLevel, "min-level", "V", "Minimum level to be displayed")
flag.StringVar(&config.minLevel, "l", "V", "Minimum level to be displayed")
flag.IntVar(&config.tagWidth, "tag-width", 23, "Width of log tag")
flag.IntVar(&config.tagWidth, "w", 23, "Width of log tag")
flag.BoolVar(&config.currentApp, "current", false, "Filter logcat by current running app")
flag.StringVar(&config.deviceSerial, "serial", "", "Device serial number")
flag.StringVar(&config.deviceSerial, "s", "", "Device serial number")
flag.BoolVar(&config.useDevice, "device", false, "Use first device for log input")
flag.BoolVar(&config.useDevice, "d", false, "Use first device for log input")
flag.BoolVar(&config.useEmulator, "emulator", false, "Use first emulator for log input")
flag.BoolVar(&config.useEmulator, "e", false, "Use first emulator for log input")
flag.BoolVar(&config.version, "version", false, "Print version and exit")
flag.BoolVar(&config.version, "v", false, "Print version and exit")
flag.Usage = func() {
fmt.Println(colorize("\nUsage:", Blue, ""), colorize("loggo [options] [package ...]", Yellow, ""))
fmt.Println(colorize("\nOptions:", Yellow, ""))
options := []struct {
flags string
arg string
desc string
}{
{"-min-level, -l", "<level>", "Minimum log level to display (V, D, I, W, E, F)"},
{"-tag-width, -w", "<n>", "Width of log tag (default 23)"},
{"-current", "", "Filter logcat by current running app"},
{"-serial, -s", "<serial>", "Device serial number"},
{"-device, -d", "", "Use first device for log input"},
{"-emulator, -e", "", "Use first emulator for log input"},
{"-version, -v", "", "Print version and exit"},
{"-h, --help", "", "Show this help message"},
}
for _, opt := range options {
fmt.Printf(" %s %s %s\n",
colorize(fmt.Sprintf("%-18s", opt.flags), Cyan, ""),
colorize(fmt.Sprintf("%-10s", opt.arg), Magenta, ""),
colorize(opt.desc, White, ""),
)
}
}
flag.Parse()
config.packages = flag.Args()
return config
}
Honestly, this is good and exactly 69 lines.
Problems
- The command-line options are declared directly inside a function, which is defined inline, making the structure a bit cluttered.
- There’s no single source of truth; whenever an option needs updating, changes must be made in multiple places.
- The code suffers from a lot of repetitive declarations, even with short and long flag registration.
- It makes me scratch my head thinking about how messy this code is, and it won’t let me sleep until I figure out a better way to fix it.
Solutions
- Define
options
separately and improvise on its type to include long and short flag name, type of arguement it takes. - Eliminate the repetition caused by separately handling short and long flag names.
- Implement a single
registerFlag
function that registers flags correctly based on their type. - Overall, have a single source of truth.
Improved Option
type Option struct {
long string
short string
arg string
desc string
defaultVal any
configField any
}
I looked into using generics
, but that would require me to create separate option object of each type like intOptions
, boolOptions
, etc.
Tbh, we have separate register function for each type like flag.StringVar
, flag.IntFlag
and such but I think for the current scope of project, this approach will provide me to have a single source of truth. (dirty and risky but cleaner for now)
Also, handling options based on the type of defaultVal
seems like a better idea than to have a field just to define a type.
Now our options
object will look like
config := Config{}
options := []Option{
{
long: "min-level",
short: "l",
arg: "level",
desc: "Minimum log level to display (V, D, I, W, E, F)",
defaultVal: "V",
configField: &config.minLevel,
},
{
long: "tag-width",
short: "w",
arg: "n",
desc: "Width of log tag (default 23)",
defaultVal: 23,
configField: &config.tagWidth,
},
{
long: "current",
short: "",
arg: "",
desc: "Filter logcat by current running app",
defaultVal: false,
configField: &config.currentApp,
},
// ... and other options
}
Improved Flag Registration
func assertPointer[T any](field any, flagName string) (*T, bool) {
ptr, ok := field.(*T)
if !ok {
fmt.Printf("Error: configField for '%s' must be *%T, got %T\n", flagName, *new(T), field)
}
return ptr, ok
}
func registerFlag(opt Option) {
switch def := opt.defaultVal.(type) {
case string:
if ptr, ok := assertPointer[string](opt.configField, opt.long); ok {
flag.StringVar(ptr, opt.long, def, opt.desc)
if opt.short != "" {
flag.StringVar(ptr, opt.short, def, opt.desc)
}
}
case int:
if ptr, ok := assertPointer[int](opt.configField, opt.long); ok {
flag.IntVar(ptr, opt.long, def, opt.desc)
if opt.short != "" {
flag.IntVar(ptr, opt.short, def, opt.desc)
}
}
case bool:
if ptr, ok := assertPointer[bool](opt.configField, opt.long); ok {
flag.BoolVar(ptr, opt.long, def, opt.desc)
if opt.short != "" {
flag.BoolVar(ptr, opt.short, def, opt.desc)
}
}
default:
fmt.Printf("Unsupported flag type for '%s'\n", opt.long)
}
}
Even though, we sacrificed compile time validation, we still have runtime validation with assertPointer
function. This also abstracts type specific function call and smartly handles according to type of defaultVal
Improved Help Output
Now all the work pays off, we can just iterate over options
and print for each line.
func printHelpLine(opt Option) {
flags := fmt.Sprintf("--%s", opt.long)
if opt.short != "" {
flags += fmt.Sprintf(", -%s", opt.short)
}
arg := ""
if opt.arg != "" {
arg = fmt.Sprintf("<%s>", opt.arg)
}
fmt.Printf(" %s %s %s\n",
colorize(fmt.Sprintf("%-18s", flags), Cyan, ""),
colorize(fmt.Sprintf("%-10s", arg), Magenta, ""),
colorize(opt.desc, White, ""),
)
}
flag.Usage = func() {
fmt.Println(colorize("\nUsage:", Blue, ""), colorize("loggo [options] [package ...]", Yellow, ""))
fmt.Println(colorize("\nOptions:", Yellow, ""))
for _, opt := range options {
printHelpLine(opt)
}
}
The output of loggo -h
is the same, but the dx has been improved. If i ever want to add a new flag, i can just add a option with appropriate fields and it's done.
Honestly, I think it'd be better to handle options
and config
separately but for now I'm fine with the final output.