Building static analysis CLI tool in Golang with Cobra

In this article, I will show how to write simple static analysis CLI-tool using Go’s standard library for AST parsing and analysis and Cobra package for CLI.
The thing we will be building is simple static analysis for duration inconsistency case (it is usually better to write 5 * time.Minute than time.Minute * 5).

First, we need to get the cobra package to create our application CLI boilerplate.
Run go get -u github.com/spf13/cobra/cobra to get the package.
Then you will be able to invoke cobra command from the command line.
Enter cobra in your terminal to see if it downloaded successfully.
You should see something like this:

cobra command output

Write cobra init --pkg-name <YOUR_APP_NAME> in your terminal to create new cobra CLI boilerplate.
In my case, it is cobra init --pkg-name durationAnalyzer

Next, we will create a simple command to analyze the file.
It is done through cobra add command.
cobra add parseFile will do the trick.
Now we should have 2 files in cmd directory in the root of our project: root.go, which is the main file of cobra CLI and parseFile.go, which is boilerplate for our new command.

For our command, we will need one flag: --file for the filename to analyze.
We can add it by adding some lines to our init() function in parseFile.go:

func init() {
   rootCmd.AddCommand(parseFileCmd)
   parseFileCmd.Flags().StringP("file", "f", "", "filename to analyze")
   parseFileCmd.MarkFlagFilename("file")
}

We can now pass filename using --file or -f flag in our cli, also we mark the flag as the filename for some bash/zsh completion, which is automatically provided by cobra package.

Now we need to do some work when invoking the cli function:
Rewrite Run to RunE in parseFileCmd and we should get something like that:

// parseFileCmd represents the parseFile command
var parseFileCmd = &cobra.Command{
   Use:   "parseFile",
   Short: "A brief description of your command",
   Long: `A longer description that spans multiple lines and likely contains examples
and usage of using your command. For example:

Cobra is a CLI library for Go that empowers applications.
This application is a tool to generate the needed files
to quickly create a Cobra application.`,
   RunE: func(cmd *cobra.Command, args []string) error {
      // Get filename from flag
      filename, err := cmd.Flags().GetString("file")
      if err != nil {
         return err
      }
      // Make file path absolute
      filenameAbsPath, err := filepath.Abs(filename)
      if err != nil {
         return err
      }
      // Analyze file
      return analyze(filenameAbsPath)
   },
}

Now we need to write analyze function. Create a separate file called analyze.go

To parse the file to ast we will need to use some packages in the standard library: token, parser and ast.
Let’s get started with it line by line:
First, we need fileset: fs := token.NewFileSet()
Then we can parse the file to nodes by calling parser.ParseFile(fs, filename, nil, parser.AllErrors).The first argument is fileset, filename is the name of the file to parse to AST and parser.AllErrors mean that we want to parse a full file.

Parsing the ast is done through ast.Walk function, which takes 2 arguments: a Visitor interface and a ast.Node. Function parser.ParseFile return ast.Node, so we need to create a Visitor.

Let’s make it super simple and just implement interface Visitor, we should implement Visit method with the node as an argument. This function returns ast.Visitor to continue traversing the AST tree. If we just return v, we will fully traverse the tree and do nothing.

type visitor struct{}

func (v visitor) Visit(node ast.Node) ast.Visitor {
   return v
}

Now, our main login: finding duration inconsistencies:

func checkIfUnitOfTime(packageName string, name string) bool {
	return packageName == "time" && 
	    (name == "Nanosecond" || name == "Microsecond" ||
		name == "Millisecond" || name == "Second" || 
		name == "Minute" || name == "Hour")
}

func (v visitor) Visit(node ast.Node) ast.Visitor {
   // ast.Walk is depth-first, so if node is nil, we don't need to look further down.
   if node == nil {
      return nil
   }
   // With this line we will find all binary expressions like 5*5
   if n, ok := node.(*ast.BinaryExpr); ok {
      if !n.Op.IsOperator() {
         return v
      }
      // Check if second argument is basic literal and is int
      if _, ok := n.Y.(*ast.BasicLit); !ok {
         return v
      }
      y := n.Y.(*ast.BasicLit)
      if y.Kind != token.INT {
         return v
      }
      // Check if first argument is some unit of time
      if _, ok := n.X.(*ast.SelectorExpr); !ok {
         return v
      }
      x := n.X.(*ast.SelectorExpr)
      if _, ok := x.X.(*ast.Ident); !ok {
         return v
      }
      packageName := x.X.(*ast.Ident).Name
      name := x.Sel.Name
      if checkIfUnitOfTime(packageName, name) {
         i, _ := strconv.ParseInt(y.Value, 10, 64)
         // Write error to console
         fmt.Printf("Incorrect duration order: %s.%s %s %d.\n" +
         "Suggested: %d %s %s.%s.\n" +
         "Pos: %d-%d\n", 
         packageName, name, n.Op.String(), i, i, n.Op.String(),
         packageName, name, n.Pos(), n.End()) 
   }
   return v
}
Now you can do go install in the root of app and durationAnalyzer command will be available in the terminal.
You can invoke it by writing durationAnalyzer --file ./testfile in terminal.
You should see the following output for testfile:

File PATH/durationAnalyzer/testfile
Incorrect duration order: time.Minute * 5.
Suggested: 5 * time.Minute.
Pos: 77-92

P.S All code from this article with testfile is available on GitHub.