Understanding which(1)

To lookup the full path of a shell command, you'll probably use which. But, how does it actually work?

To find that out I decided to build my own, analyze, and just look at the history of this interesting little command-line application that I've used for years, but never really understood what it did.

Consulting the man pages is probably a good first step for anyone looking to get a little bit more information about a particular command. So, that's what I did; and I stumbled across this interesting bit of trivia — at least hidden within the macOS version of which:

HISTORY
The which command first appeared in FreeBSD 2.1.

AUTHORS
The which utility was originally written in Perl and was contributed by Wolfram Schneider <wosch@FreeBSD.org>. The current version of which was rewritten in C by Daniel Papasian <dpapasia@andrew.cmu.edu>.

The version of which on macOS also only supports two command-line flags: -a (for all found paths, not just the first) and -s (for no output).

If we were to look, for example, a CentOS 7 box, we'd see something quite different: flags like --skip-dot, --show-dot, --read-alias and plenty more that would be tedious to go over I assure you, but are very cool nonetheless! If you're on Ubuntu you'll see that which only supports -a out of the box.

From what I've been able to tell, -a is the only flag supported by most unix like operating systems for which that you can even rely on.

I had no idea which supported so many different command-line flags depending on the operating system you're working on. There's even a windows port or two out there that exist.

But, I still haven't answered: how does it work? Well, the macOS man page actually spells it out pretty clearly:

which -- locate a program file in the user's path

To clarify further: What they mean by the "user's path" is the $PATH environment variable of the shell.

🐱 Searching for cats in your $PATH

You see, when working in the context of a shell and you type in:

$ cat cool.txt

If felt so inclined, we could call the cat program directly using its full path, wherever it is on your system:

$ /bin/cat cool.txt

Our system can find the full path to cat to call without us having to know the full path to the cat file. This is a feature that is widely used and easily overlooked, at least in terms of its implementation details.

Unix-like shells have a $PATH environment variable (amongst many other environment variables, which can even be manipulated by the user): and when we try to run something, our shell can go looking for it in all of the directories available in $PATH — that can be any number of directories depending on your system and what you've installed.

On a Ubuntu vagrant box that I spun up, the default $PATH variable was this:

/home/ubuntu/bin:/home/ubuntu/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin

To make that a bit cleaner, we could split up the available directories by the ":" character in whatever programming of choice you'd like, I'll use awk:

$ echo $PATH | awk 'BEGIN{RS=":"}{$1=$1}1'
/home/ubuntu/bin
/home/ubuntu/.local/bin
/usr/local/sbin
/usr/local/bin
/usr/sbin
/usr/bin
/sbin
/bin
/usr/games
/usr/local/games
/snap/bin

All of those paths could have files stored in them that the shell could use. Moreover, order matters in many cases (even with the which command)! So, fun-fact: this can be a really nasty thing to mess with.

🍺 Brewing up our own which(1)

With all the inconsistencies of which that I discovered, I figured I could make my own! If all these other people made their own, with their weird flavor added to it — I could do the exact same thing and make it weirder!

I'll make one that, basically, always runs the -a flag to show all available paths.

We could arbitrarily pick any language for this task really. Ruby, Python, Crystal, JavaScript — but I ended up implementing this in Go!

With Go, I can easily build many cross-platform tools that can compile to a neat little binary for whatever system you'd like: mac, linux, windows — easy!

The first thing we can do is write a simple function that'll give us a slice of strings much like we did earlier with echo and a bit of awk:

func GetPathSlice() []string {
    return strings.Split(os.Getenv("PATH"), ":")
}

You would need to import the os and strings packages.

We'll also need to be able to check if the program we're looking for exists in the path at some point:

func IsFile(path string) bool {
    if stat, err := os.Stat(path); err == nil && !stat.IsDir() {
        return true
    }
    return false
}

I've chosen to use the Stat function within the os package to check if the given path is a file.

Then we're going to want loop over the possibilities and then we'll go ahead and consume the results through a channel (which I'll call "messages"):

func FindInPath(args []string) {
    messages := make(chan string)
    
    for _, arg := range args {
        go func(program string) {
            for _, path := range GetPathSlice() {
                fullPath := filepath.Join(path, program)
                if IsFile(fullPath) {
                    messages <- fullPath
                }
            }
            messages <- ""
        }(arg)
    }
    
    for range args {
        for {
            message := <-messages
            if message == "" {
                break
            }
            fmt.Println(message)
        }
    }
}

The messages channel is sort of like a stream or mailbox for messages to go into and be taken out of.

Now, within our main() function, we just need to grab the content from the command-line, check it; and then pass the arguments as a slice to our FindInPath() function.

func main() {
    if len(os.Args) < 2 {
        fmt.Println("Usage: which <program> [program2] [program3] ...")
        os.Exit(1)
    }
    
    FindInPath(os.Args[1:])
}

All together, we have our full program!

Complete which(1) implementation in Go
package main

import (
    "fmt"
    "os"
    "path/filepath"
    "strings"
)

func GetPathSlice() []string {
    return strings.Split(os.Getenv("PATH"), ":")
}

func IsFile(path string) bool {
    if stat, err := os.Stat(path); err == nil && !stat.IsDir() {
        return true
    }
    return false
}

func FindInPath(args []string) {
    messages := make(chan string)
    
    for _, arg := range args {
        go func(program string) {
            for _, path := range GetPathSlice() {
                fullPath := filepath.Join(path, program)
                if IsFile(fullPath) {
                    messages <- fullPath
                }
            }
            messages <- ""
        }(arg)
    }
    
    for range args {
        for {
            message := <-messages
            if message == "" {
                break
            }
            fmt.Println(message)
        }
    }
}

func main() {
    if len(os.Args) < 2 {
        fmt.Println("Usage: which <program> [program2] [program3] ...")
        os.Exit(1)
    }
    
    FindInPath(os.Args[1:])
}

Not too shabby!

In just over 50 lines of code, we've implemented a working example of the which command-line utility (that always runs the -a option)!

💡 Just imagine, with a bit more code — we could even implement the other flags!

Moving forward when I find the time, I think I'd like to implement the rest of the flags and make it fully compatible with the actual which command-line utility; and hopefully support windows? That'd be really neat.

👋 Conclusion

So, I think that's where I'm going to end this post — hope you learned something!

Understanding how fundamental command-line utilities work under the hood can be both educational and fun. The which command is a perfect example of how a simple concept (searching through directories in $PATH) can be implemented in various ways across different systems.

Building your own version not only helps you understand the internals better, but also gives you the freedom to add your own features and improvements. Plus, Go makes it incredibly easy to create cross-platform binaries that work everywhere!


Continue Reading