Skip to content
← Go back

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.

loggo help

This was the output.

go
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

go
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

go
 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

go

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.

go
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.

Enjoy your stay here <3