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:
- Name of the command being completed
- Current word being completed
- Word preceding the current word
In addition, Bash sets the following environment variables before executing the command, allowing additional information to be passed along:
COMP_LINE
– The current command line being edited.COMP_POINT
– The index of the cursor relative to the beginning of the command line.COMP_TYPE
– An integer value corresponding to the type of completion being attempted (see Bash Manual for additional info).
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:
-
For flags taking arguments (like
-duration
), pressingTAB
will display-d
once again, as if it expects another flag when an argument is actually needed. -
Bash completion only works after running the
complete
command, and only in the terminal the command was run within. -
While multiple duplicate flags are allowed, ideally we should not be completing flags that have already been entered.
I will show how to address these issues in part 2 of this series (coming soon).