Go

Simple CLI tool (Go)

Simple CLI tool (Go)

Simple CLI made with Golang

This is the first part of a series of articles in which I'll go over building CLI tools using mostly Go (Cobra package).

If you are wondering why would someone want to build a CLI in the year 2020 - the answer is effectiveness. At least it was my personal experience. Whenever I saw an occurring action happening (more than once), I've built at least a bash script for it, if not a complete CLI tool. To this day I never had a regret about such an approach.

In case you don't know what CLI (Command Line Interface) is, then DuckDuckGo is your best friend, and this article probably isn't for you 🍻

Prerequisites

You won't need much Golang knowledge to follow this series. Understanding basic concepts should suffice.

  1. Go (at the moment of writing I'm using go1.13.4 linux/amd64)
  2. Cobra (github.com/spf13/cobra/cobra)

If you want to learn more about Cobra, it's best to start here. 👋👋👋

Cobra is a library providing a simple interface to create powerful modern CLI interfaces similar to git & go tools.

Now, let's get straight to building a CLI that will cover the usage of cmd, args, and flags. If you aren't familiar with those terms, lean back because soon you'll master them.

Realistic CLI tool

Because I like, even for demo purposes, to build tools that are useful I'll use this chance for that as well.

Recently I've had to search through the history of a file with a combined usage of cat <FILE> | grep <TERM>. Now, let's get fancy and build a tool to do that for us.

We'll call it grepper.

If you don't want to follow along,code for this tool can be found here.

Start the project

Initialize git repo:

mkdir grepper
cd grepper
git init
touch README.md

Initialize Go modules, install Cobra & initialize Cobra project:

go mod init grepper
go get -u github.com/spf13/cobra/cobra
cobra init --pkg-name=grepper

After these steps, the folder structure should look like this:

.
├── cmd
│   └── root.go
├── go.mod
├── go.sum
├── LICENSE
├── main.go
└── README.md

Add first Cobra command

As described in the requirements section, we want to be able to search for a term (sentence, character or number). In order to achieve that we first have to read the file that is located in a given path.

So our command should look similar to this:

grepper search -f <FILE/PATH> -s <SEARCH_TERM>

To generate search part of the query, it's as easy as:

cobra add search

This will, in turn, create search.go in the cmd folder.

Note: This isn't the only way (nor the best) to create a sub-command. But it's most convenient for the purpose of this guide. Along with the series, we'll explore all options - be patient.

Modify Root command

We won't get into Root at this point. It's enough for you to know that Root command is that grepper part, with following sub-commands, arguments or flags.

Within var rootCmd you can modify short/long descriptions - those are primarily used for verbose help output.

To check if everything is working so far, type:

go install
grepper search

And as output, you should see search called.

Modify Search sub-command

Add Flags

We want to start by defining two flags we will be needing. So in the init() function of the search.go file, add the following:

func init() {
    rootCmd.AddCommand(searchCmd)

    searchCmd.Flags().StringP("file", "f", "", "Filename | Path to a File")
    searchCmd.Flags().StringP("sterm", "s", "", "Search Term")
}

These are considered local flags, available only to this scope.

Parse flag arguments

In order to get values passed after those flags, we have to modify searchCmd:

var searchCmd = &cobra.Command{
	Use:   "search",
	Short: "Search for a term in given file",
	Long:  ``,
	Run: func(cmd *cobra.Command, args []string) {
		filename, _ := cmd.Flags().GetString("file")
        sTerm, _ := cmd.Flags().GetString("sterm")
	},
}

Where filename and sTerm will hold its corresponding values. In this case, we can ignore errors with _, because Cobra will handle that for us.

Open File

We will need the ability to open a file based on a path.

func openFile(path string) (*bufio.Scanner, error) {
	f, err := os.Open(path)
	if err != nil {
		return nil, err
	}

	return bufio.NewScanner(f), nil
}

Search function

When all of this is incorporated with the final searchFile function, search.go should look like this:

package cmd

import (
	"bufio"
	"errors"
	"fmt"
	"os"
	"strings"

	"github.com/spf13/cobra"
)

// searchCmd represents the search command
var searchCmd = &cobra.Command{
	Use:   "search",
	Short: "Search for a term in given file",
	Long:  ``,
	Run: func(cmd *cobra.Command, args []string) {
		// Get values of flag arguments
		filename, _ := cmd.Flags().GetString("file")
		sTerm, _ := cmd.Flags().GetString("sterm")

		res, _ := searchFile(filename, sTerm)
		fmt.Println(res)
	},
}

func init() {
	rootCmd.AddCommand(searchCmd)

	searchCmd.Flags().StringP("file", "f", "", "Filename | Path to a file")
	searchCmd.Flags().StringP("sterm", "s", "", "Search Term")
}

func searchFile(path, sTerm string) (string, error) {
	scanner, err := openFile(path)
	if err != nil {
		return "", err
	}

	line := 1
	var lines int
	res := make([]string, lines)
	for scanner.Scan() {
		// if the search term is found on the current line, append it to the resulting slice
		if strings.Contains(scanner.Text(), sTerm) {
			res = append(res, scanner.Text())
		}

		line++
	}

	if err := scanner.Err(); err != nil {
		return "", errors.New("an error occurred: ", err)
	}

	if len(res) < 1 {
		return "", errors.New("nothing found by that search term")
	}

	return buildStrFromSlice(res), nil
}

func openFile(path string) (*bufio.Scanner, error) {
	f, err := os.Open(path)
	if err != nil {
		return nil, err
	}

	return bufio.NewScanner(f), nil
}

// Build response as a single string from a slice of strings
func buildStrFromSlice(ss []string) string {
	var sb strings.Builder
	for _, str := range ss {
		sb.WriteString(str)
		sb.WriteString("\n")
	}
	return sb.String()
}

Where the last function is self-explanatory. It's used only to concatenate strings from the input slice of strings.

Now the only thing left to do is check if our solution is working. In my case it's:

go install
grepper search --file ~/.zsh_history -s ovpn

Conclusion

In this part one of the CLI series you've successfully written Go CLI with Cobra. You've learned:

  • How to add commands, sub-commands, and flags
  • How to parse arguments and flag arguments
  • How to open a file with bufio
  • Bonus: You've seen effective string concatenation

With this part one is concluded.

In the following articles, you'll learn how to adequately test your CLI applications and some more advanced Cobra use cases.


Have any comments or suggestions? What would you like to read about next? Drop the comment down below and let me know

comments powered by Disqus

Get Notified

Leave your email if you wish to get notified each time I post an article