I don’t know about you, but my company has been utilising bash scripts for internal tooling for its entire history, and it makes sense; back in the day we were a team of Linux zealots, and for a start-up, being able to move & adapt quickly is critical to success.
In 2019, however, a lot of these tools have become stale and the people who wrote them have moved on. Also, as our company has matured & grown, their usage has dwindled but the demand for the functionality it provided has remained, leading to decreasing efficiency across the board. This is where Go comes in.
I’ve completed one fairly large project this year, which has proven to be a huge success and has demonstrated how Go is going to help us going forward. Now my focus has turned to update our internal bash tools, to bring them into the modern age and allow our team to work better, and faster.
When looking through our existing scripts, one of my first thoughts was how I was going to write them in Go without simply abusing the os/exec package, and one of the first hurdles I had was that we were using the select command to build menus. I knew that I was going to be using Promptui for gathering and processing menu-input, but unfortunately, it will only output a selection menu in a single column. For a program with 30+ options, the vertical cost to the terminal means I needed a way to columnise the output.
My journey began with the text/tabwriter package, which would format my menu options into columns. The challenge for me was that one of my goals was to make this program easily expandable, so it must be able to handle an arbitrary number of options. Here is the algorithm I eventually wrote, with a main function to demonstrate calling it;
func columnise(w *tabwriter.Writer, opt []string) {
for i := 0; i < len(opt); i = i+2 {
if i == len(opt)-1 {
fmt.Fprintln(w, fmt.Sprintf("%s\t", opt[i]))
} else {
fmt.Fprintln(w, fmt.Sprintf("%s\t%s\t", opt[i], opt[i+1]))
}
}
}
func main() {
// Init
w := new(tabwriter.Writer)
w.Init(os.Stdout, 0, 0, 3, ' ', 0)
// Columnise
var options []string
options = append(options, "(a) Exit", "(b) Enter test mode", "(c) Calibrate", "(d) Detect load")
columnise(w, options)
// Write the buffer
w.Flush()
}
This will output the following menu;
(a) Exit (b) Enter test mode
(c) Calibrate (d) Detect load
We can also add one more option to options and it will look like this (Note spacing is all handled by text/tabwriter, hassle-free!);
(a) Exit (b) Enter test mode
(c) Calibrate (d) Detect load
(e) Set throttle
The key thing to solving this problem was to understand the following;
- The index of options can be either odd, or even.
- options(length) == options(index+1)
- In the final iteration of the for loop, the index can either be options(length-1), or options(length-2), when options(length) is odd or even respecitvely.
If we try to process an odd number of options with the following line...
fmt.Fprintln(w, fmt.Sprintf("%s\t%s\t", opt[i], opt[i+1]))
...we will encounter an index out of range error as we'll be trying to process options(index+1), which obviously doesn't exist. So, we just need to add behaviour to process an odd number of options, and since the last iteration of the for loop will always be options(length-1), we can just do this;
if i == len(opt)-1 {
fmt.Fprintln(w, fmt.Sprintf("%s\t", opt[i]))
} else {
fmt.Fprintln(w, fmt.Sprintf("%s\t%s\t", opt[i], opt[i+1]))
}
Now we can process a slice of any length, which will be separated into two columns. The good thing about the columnise function is that it's simplicity allow us to modify it for more columns very easily, for example, if we wanted three columns, we can do this;
func columnise(w *tabwriter.Writer, opt []string) {
for i := 0; i < len(opt); i = i+3 {
if i == len(opt)-1 {
fmt.Fprintln(w, fmt.Sprintf("%s\t", opt[i]))
} else if i == len(opt)-2 {
fmt.Fprintln(w, fmt.Sprintf("%s\t%s\t", opt[i], opt[i+1]))
} else {
fmt.Fprintln(w, fmt.Sprintf("%s\t%s\t%s\t", opt[i], opt[i+1], opt[i+2]))
}
}
So now I can columnise all my output, with the added benefit of not needing an external dependancy. Thanks, Go!
Update
Previously, I wrote about my "columnise" function, which was designed to help me format output for my CLI applications into columns. This code works for my immediate needs, but it occurred to me that I hadn't really put any effort into trying to make this a proper algorithm, specifically to handle (i) items and (n) columns. I think deep down that I doubted my ability to get the math down.
I began to think about the problem on Christmas eve, while on my 3-hour journey to my home town. I knew that I wasn't going to be able to relax until I'd sorted this out, so while that evening I was obliged to go to the pub for some social-time (mostly people I didn't know, or care to know), it didn't stop me from scribbling some thoughts down on a piece of scrap paper. I semi-regret doing it this way though, because I received some questioning looks, as if I was just scribbling nonsense to project some intellectual superiority. My dad covered for me though and once I'd finished, I had something I could implement later, so I was finally able to relax.
Anyway, this is what I came up with in a little pub in the country;
// Columnise formats the values (opt) into specified number of columns (cc) and writes to a tabwriter.Writer interface (w).
func Columnise(w *tabwriter.Writer, opt []string, cc int){
mod := len(opt) % cc
// Separate full divisible from the mod.
divList := opt[:len(opt)-mod]
modList := opt[len(divList):]
for i := 0; i < len(divList); i=i+cc {
var row string
for t := 0; t < cc; t++ {
row = row + fmt.Sprintf("%s\t", divList[i+t])
}
fmt.Fprintln(w, row)
}
if len(modList) != 0 {
var row string
for i := range modList {
row = row + fmt.Sprintf("%s\t", modList[i])
}
fmt.Fprintln(w, row)
}
}
I'm super proud of this compared to my previous attempts, and it has really been a good boost to my confidence as a programmer. I was able to solve a problem totally "offline" with a pen and paper, which validates the progress I've made this year.