DEV Community

Cover image for Building ls in Go, Best Two Weeks So Far
bramwelM
bramwelM

Posted on

Building ls in Go, Best Two Weeks So Far

There are projects that teach you a programming language, and then there are projects that teach you how computers actually work. Building my own implementation of the Unix ls command in Go belongs firmly in the second category.

Going into it, I thought I was building a small command-line utility. After all, ls simply lists files in a directory. How difficult could it be?
It turned out to be the most technically challenging project I've worked on so far, and easily the most rewarding. This is why I am writing this article.

The project took my teammate and me two weeks to complete. Surprisingly, nearly half of that time was spent debugging. Every time we solved one issue, three more seemed to emerge. We'd celebrate matching the output of the system's ls, only to notice that a single column had shifted by one space or that a directory had the wrong color. It was frustrating at times, but looking back, those debugging sessions taught me more than writing the initial code ever did.

Complexity

One of the first lessons was that even something as familiar as ls hides an incredible amount of complexity. Supporting flags like -a, -l, and combinations such as -la wasn't just a matter of checking whether a character appeared on the command line. Each flag represented a different behavior, and those behaviors had to work independently while still interacting correctly with one another. Parsing the flags cleanly, deciding what each one enabled, and keeping the implementation maintainable became an exercise in software design rather than simply writing conditionals.

Architecture

As the project grew, we naturally separated responsibilities. One part of the program handled configuration and flags, another dealt with traversing the file system, while another focused entirely on formatting the output. That separation made debugging significantly easier because we could reason about one piece of the program without worrying about everything else breaking at the same time. It reinforced the importance of designing software around clear responsibilities instead of letting everything accumulate inside a single file.

The biggest revelation, however, came from exploring the Unix file system itself. Before this project, I'd heard the phrase "everything in Unix is a file" countless times. I understood it intellectually, but I don't think I truly appreciated it until I spent hours working with directories like /dev, which I now know is where device files are.

I wasn't just looking at regular files and folders anymore. Character devices, block devices, symbolic links, sockets and named pipes all appeared through the same file system interface. The operating system wasn't hiding behind layers of abstraction but rather exposing itself through files. Understanding why /dev/null exists (operating systems and automation scripts frequently need a way to discard unwanted data or simulate a completely empty file), why device files have major and minor numbers, or why symbolic links behave differently from regular files made Unix feel far less mysterious than it had before.

Trying to replicate the behavior of GNU ls introduced an entirely different set of challenges. It wasn't enough for the program to produce similar output. We wanted it to match the system utility as closely as possible. That meant paying attention to details most users never notice. Permissions had to be formatted exactly right. Symbolic links needed to display their targets correctly. Device files had to show major and minor numbers instead of file sizes. Columns had to line up perfectly regardless of file type. Even the tiny + symbol that indicates an Access Control List turned out to be surprisingly difficult because adding a single character without accounting for spacing threw the entire output out of alignment.

Coloring the output became another unexpected rabbit hole. At first, it seemed sufficient to color every directory blue, but that assumption quickly fell apart. Certain directories, such as mqueue and shm, receive entirely different colors because they are sticky, world-writable directories. Learning that even terminal colors follow specific Unix conventions reminded me that mature software rarely contains arbitrary decisions. There is usually a reason hidden beneath every detail.

Linux System

My Key Takeaway

What made the experience so valuable wasn't simply solving those problems but learning how to approach them. We spent countless hours comparing our output against the system's ls, reading documentation, testing edge cases and questioning every discrepancy instead of accepting "close enough." It was the first project where I truly appreciated that engineering isn't just about making software work. It's about making software behave correctly under conditions you didn't originally think about.

This project also changed the way I think about debugging. I used to see debugging as something standing between me and finishing a project. Now I see it as part of the project itself. Every bug forced us to understand another aspect of the operating system or our own implementation. The solution was rarely to write more code. More often, it was to understand the problem more deeply.

Looking back, I can confidently say this has been my favorite project so far. Not because it was easy, but because it wasn't. It challenged the way I think, exposed gaps in my understanding, and pushed me to learn concepts I probably wouldn't have encountered otherwise. Those are exactly the kinds of problems I hope to keep solving as I continue working towards backend engineering, DevOps and payment infrastructure.

If this project taught me anything, it's that I genuinely enjoy technically demanding work. The more a problem forces me to understand what's happening beneath the surface, the more satisfying it becomes to solve.

The complete project is available on GitHub. Find it here to compare notes.

Top comments (0)