Developing the which(1) utility in Go
Go can work with your operating system through a set of packages. A good way of learning a new programming language is by trying to implement simple versions of traditional UNIX utilities—in general, the only efficient way to learn a programming language is by writing lots of code in that language. In this section, you will see a Go version of the which(1)
utility, which will help you understand the way Go interacts with the underlying OS and reads environment variables.
The presented code, which will implement the functionality of which(1)
, can be divided into three logical parts. The first part is about reading the input argument, which is the name of the executable file that the utility will be searching for. The second part is about reading the value stored in the PATH
environment variable, splitting it, and iterating over the directories of the PATH
variable. The third part is about looking for the desired binary file in these directories and determining whether it can be found or not, whether it is a regular file, and whether it is an executable file. If the desired executable file is found, the program terminates with the help of the return
statement. Otherwise, it will terminate after the for
loop ends and the main()
function exits.
The presented source file is called which.go
and is located under the ch01
directory of the GitHub repository of the book. Now, let us see the code, beginning with the logical preamble that usually includes the package name, the import
statements, and other definitions with a global scope:
package main
import (
"fmt"
"os"
"path/filepath"
)
The fmt
package is used for printing onscreen, the os
package is for interacting with the underlying operating system, and the path/filepath
package is used for working with the contents of the PATH
variable that is read as a long string, depending on the number of directories it contains.
The second logical part of the utility is the following:
func main() {
arguments := os.Args
if len(arguments) == 1 {
fmt.Println("Please provide an argument!")
return
}
file := arguments[1]
path := os.Getenv("PATH")
pathSplit := filepath.SplitList(path)
for _, directory := range pathSplit {
First, we read the command line arguments of the program (os.Args
) and save the first command line argument into the file
variable. Then, we get the contents of the PATH
environment variable and split it using filepath.SplitList()
, which offers a portable way of separating a list of paths. Lastly, we iterate over all the directories of the PATH
variable using a for
loop with range
, as filepath.SplitList()
returns a slice.
The rest of the utility contains the following code:
fullPath := filepath.Join(directory, file)
// Does it exist?
fileInfo, err := os.Stat(fullPath)
if err != nil {
continue
}
mode := fileInfo.Mode()
// Is it a regular file?
if !mode.IsRegular() {
continue
}
// Is it executable?
if mode&0111 != 0 {
fmt.Println(fullPath)
return
}
}
}
We construct the full path that we examine using filepath.Join()
, which is used for concatenating the different parts of a path using an OS-specific separator—this makes filepath.Join()
work on all supported operating systems. In this part, we also get some lower-level information about the file—keep in mind that UNIX considers everything as a file, which means that we want to make sure that we are dealing with a regular file that is also executable.
Executing which.go
generates the following kind of output:
$ go run which.go which
/usr/bin/which
$ go run which.go doesNotExist
The last command could not find the doesNotExist
executable—according to the UNIX philosophy and the way UNIX pipes work, utilities generate no output onscreen if they have nothing to say.
Although it is useful to print error messages onscreen, there are times that you need to keep all error messages together and be able to search for them later when it is convenient for you. In this case, you need to use one or more log files.
The next section discusses logging in Go.