Bash Command-line Completion with Go

Command-line completion is a helpful feature in many modern shells, providing users with a helpful means of typing potentially complex commands quickly and accurately. In this article I show how to add completion to Go programs when run from the Bash shell.

Command-line Completion in Bash

Command-line completion allows users of command line programs to quickly complete partially entered command options by hitting the TAB key which will cause the shell to either complete the partially entered word or display a list of possible completions the current word.

By default Bash provides command name completion for executables in PATH as well as path completion on most commands, allowing users to more quickly (and accurately) type paths of directories and filenames as arguments to commands. Bash also provides the ability to customize the completion logic on a per command basis. This allows one to provide completion for command flags and even non-path arguments that are unique to a given command.

Within Bash, completion is defined by the complete command. Each command with completion will have a call to complete to define its completion behavior, known as its completion specification or comspec. These command calls are typically saved in files within /etc/bash_completion.d/ or /usr/share/bash-completion/completions but can be in any file sourced during shell initialization (eg. ~/.bashrc). If one is curious, you can see the currently defined completions by running the command, complete -p.

The complete command has a number of options (see Programmable Completion Builtins in the GNU Bash Reference Manual) that provide the flexibility for handling completion for many different scenarios. Some common options are: -f to enable filename completion, -d to enable directory name completion, -w to use a static wordlist, and -F to use a Bash function to generate dynamic completion results.

While it is certainly possible to use these options to define a comspec for a Go program, one very useful feature of Go is that it statically compiles programs by default. This allows one to easily deploy Go programs because they are typically self-contained in a single file. If we used the -F or other options to complete, it would mean having to modify local file(s) if our program ever changed its command line syntax (for example, adding or removing an option flag). This would make deploying and updating our program more complex.

Rather than taking this approach, I will demonstrate using the -C option of complete to define a command which Bash will call to generate the completion wordlist. An external completion command can be any executable on the system, including the actual command the user is trying to execute. Thus, we can embed our completion logic directly within our Go executable.

Using an External Completion Command

When attempting completion for a command with a comspec using the -C flag, Bash calls the specified program with the following arguments:

In addition, Bash sets the following environment variables before executing the command, allowing additional information to be passed along:

With these six data points, it is possible to write completion logic for most situations without too much effort as I hope to demonstrate.

To show how to incorporate Bash completion into a Go program, I will start with a simple example program, sleeper, that has a couple of command line options defined using the standard flag package:

package main

import (
	"flag"
	"fmt"
	"os"
	"time"
)

func main() {

	debug := flag.Bool("debug", false, "enable debugging messages")
	dur := flag.Duration("duration", 5*time.Second, "duration to sleep")

	flag.Parse()

	if *debug {
		fmt.Printf("Running '%s' with parameters:\n", os.Args[0])
		fmt.Printf("  debug:    %v\n", *debug)
		fmt.Printf("  duration: %v\n", *dur)
	}

	time.Sleep(*dur)
}

This program will sleep for a given duration (configurable via the -duration flag) and optionally print some simple debug messages if the -debug flag is set.

To compile into a program called sleeper we run the go build command:

$ go build -o sleeper *.go

Now to provide shell completion for the two optional flags we create a new file complete.go providing a new complete() function:

package main

import (
	"flag"
	"fmt"
	"os"
	"strings"
)

// complete performs bash command line completion for defined flags
func complete() {

	// when Bash calls the command to perform completion it will
	// set several environment variables including COMP_LINE.
	// If this variable is not set, then command is being invoked
	// normally and we can return.
	if _, ok := os.LookupEnv("COMP_LINE"); !ok {
		return
	}

	// Get the current partial word to be completed
	partial := os.Args[2]

	// strip leading '-' from partial, if present
	partial = strings.TrimLeft(partial, "-")

	// Loop through all defined flags and find any that start
	// with partial (or return all flags if partial is empty string)
	// Matching words are returned to Bash via stdout
	flag.VisitAll(func(f *flag.Flag) {
		if partial == "" || strings.HasPrefix(f.Name, partial) {
			fmt.Println("-" + f.Name)
		}
	})

	os.Exit(0)
}

This function starts by first detecting if the environment variable COMP_LINE is set. If not, we assume the program has been called normally so we return so sleeper can run. Next we determine the current word being completed to understand what we are trying to complete. As mentioned before this is the the second positional argument (or os.Args[2] as os.Args starts with the command name then the positional args).

If partial is a flag it will start with - or -- so we strip these characters before attempting to compare to the defined flags. This comparison is done via the flag.VisitAll() function which allows us to define a function to operate on each defined flag. In our function, defined as a closure, we look to see if the flag starts with partial, in which case we print the flag name to stdout for Bash to use as a completion possibility. Once flag.VisitAll() completes, we exit the program rather than returning.

Now to actually incorporate our complete() function into our program, we simply add a call to it within main(), prior to calling flag.Parse() (see full code here). We then recompile via go build and have a Go program capable of command line completion!

Trying It Out

To actually enable completion we need to run the following command:

complete -C `pwd`/sleeper ./sleeper

Within the same terminal you typed the above command, type ./sleeper followed by a SPACE and then TAB. If all is working correctly, Bash should automatically type -d on the command line as this is the common root for both -debug and -duration. To select one of the two options, we simply need to type enough characters to uniquely identify the desired flag (ie. type e for -debug or u for -duration). After typing either of these characters, pressing the TAB key will complete the flag in its entirety.

But what if you don't know what the options are so you can type e or u? Going back to the example above, after pressing TAB to complete the -d root, pressing TAB twice more will show the full names of all the matching flags:

$ ./sleeper -d[TAB][TAB]
-debug     -duration

Next Steps

By adding a pretty simple function, we are able to auto-complete any flags defined in our main(). In fact, if we add a new flag option and recompile, our completion will automatically work for the new flag!

In spite of this success, there are a few issues with our implementation in its current form:

I will show how to address these issues in part 2 of this series (coming soon).