Understanding which(1)
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.txtIf 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!
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!