Writing Custom Go Analyzers

Go’s powerful tooling is one of its most beloved features, and go vet is a prime example. It catches common mistakes and suspicious constructs, but what if you could extend it to enforce your project’s specific conventions or detect niche bugs? You can. Go’s analysis package provides a rich framework for writing your own static analysis tools. This post will guide you through the process of creating custom Go analyzers, diving into the Abstract Syntax Tree (AST), and leveraging the go/analysis package to build powerful, custom linting tools for your codebase.

What is Static Analysis?

Static analysis is the process of analyzing code without executing it. This approach is fundamental to compilers, linters, and formatters. In Go, tools like gofmt, golint, and go vet perform static analysis to enforce style, suggest improvements, and catch errors before they become runtime problems. By writing a custom analyzer, you are essentially creating a new rule for the go vet tool, tailored to your own needs.

The Foundation: Abstract Syntax Trees (ASTs)

Before we can analyze code, we need a way to represent it. When the Go compiler reads your source code, it first performs a lexical analysis (breaking the code into tokens) and then a parsing step. The output of parsing is an Abstract Syntax Tree (AST).

Think of the AST as a tree-like representation of your code's structure. Every element—every declaration, statement, expression, and literal—becomes a "node" in this tree. For example, a simple function declaration would be a parent node, with child nodes for its name, parameters, and body.

The go/parser and go/ast packages are your primary tools for working with ASTs. You don't need to be an expert in compiler theory to use them, but understanding this basic concept is key. Your analyzer will work by "walking" this tree and inspecting nodes that are relevant to the check you want to perform.

Introducing the go/analysis Package

The official golang.org/x/tools/go/analysis package is the standard library for writing static analyzers. It provides the structure and boilerplate, letting you focus on the analysis logic itself. It's designed to create analyzers that can be easily integrated into a larger framework, like go vet or other third-party tools.

An analyzer is defined by the analysis.Analyzer struct, which has several fields:

  • Name: The name of the analyzer (e.g., mycoolchecker).
  • Doc: A string explaining what the analyzer does. This is user-facing documentation.
  • Run: The core of the analyzer—a function that performs the analysis on a single package.
  • Requires: A list of other analyzers that this one depends on. For example, an analyzer that inspects types will require the results of the type-checking analyzer.

Building Our First Analyzer: nohttp

Let's create a simple analyzer that reports direct usage of http.Get and http.Post, encouraging the use of a custom http.Client with timeouts instead.

1. Setting up the Analyzer

First, we define our Analyzer struct.

package nohttp

import (
    "go/ast"
    "golang.org/x/tools/go/analysis"
    "golang.org/x/tools/go/analysis/passes/inspect"
    "golang.org/x/tools/go/ast/inspector"
)

var Analyzer = &analysis.Analyzer{
    Name:     "nohttp",
    Doc:      "reports direct use of http.Get and http.Post",
    Run:      run,
    Requires: []*analysis.Analyzer{inspect.Analyzer},
}

We require inspect.Analyzer, which provides an ast.Inspector. This inspector is a utility that makes it easy to traverse the AST and find specific types of nodes.

2. Implementing the run Function

The run function receives an analysis.Pass object. This object contains all the information about the package being analyzed, including its AST, type information, and the results from required analyzers.

func run(pass *analysis.Pass) (interface{}, error) {
    // Get the inspector from the results of the 'inspect' analyzer.
    insp := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)

    // Define the types of nodes we want to visit.
    nodeFilter := []ast.Node{
        (*ast.CallExpr)(nil), // We are looking for function calls.
    }

    // The inspector's Preorder method traverses the AST.
    // It calls our anonymous function for each node in the nodeFilter.
    insp.Preorder(nodeFilter, func(n ast.Node) {
        // Cast the node to a CallExpr
        call := n.(*ast.CallExpr)

        // The Fun field of a CallExpr is the function being called.
        // We need to check if it's a SelectorExpr (like "pkg.Func")
        sel, ok := call.Fun.(*ast.SelectorExpr)
        if !ok {
            return
        }

        // Now, let's check if the package is "net/http" and the function is "Get" or "Post".
        // For simplicity, we'll check the names directly. A more robust analyzer
        // would use type information to be certain.
        ident, ok := sel.X.(*ast.Ident)
        if !ok || ident.Name != "http" {
            return
        }

        if sel.Sel.Name == "Get" || sel.Sel.Name == "Post" {
            pass.Reportf(n.Pos(), "direct use of http.%s is discouraged; use a custom http.Client", sel.Sel.Name)
        }
    })

    return nil, nil
}

In this function, we:

  1. Get the inspector instance created by the inspect pass.
  2. Specify that we're interested in ast.CallExpr nodes, which represent function calls.
  3. Use insp.Preorder to visit every CallExpr in the AST.
  4. Inside our callback, we check if the function call is of the form http.Get or http.Post.
  5. If it is, we report a diagnostic using pass.Reportf, which flags the line of code and provides a message.

3. Creating a main Package

To make the analyzer runnable, we need a main package that invokes it. The singlechecker package makes this easy.

// cmd/nohttp/main.go
package main

import (
    "golang.org/x/tools/go/analysis/singlechecker"
    "your/module/path/nohttp"
)

func main() {
    singlechecker.Main(nohttp.Analyzer)
}

4. Running the Analyzer

Now, you can build and run your analyzer on a file or package:

# Build the analyzer
go build ./cmd/nohttp

# Run it on a package
./nohttp ./path/to/your/package

If any file in that package uses http.Get or http.Post, the analyzer will print a warning to the console.

Testing Your Analyzer

The go/analysis framework also includes a testing package: analysistest. It allows you to write simple tests to verify your analyzer's behavior.

You create a test file in your analyzer's package (nohttp_test.go) and use analysistest.Run to execute it. The testdata is typically a .go file with special comments // want "..." indicating where a diagnostic is expected.

Here's an example test file testdata/src/a/a.go:

package a

import "net/http"

func good() {
    client := &http.Client{}
    _, _ = client.Get("https://example.com") // OK
}

func bad() {
    _, _ = http.Get("https://example.com") // want "direct use of http.Get is discouraged..."
    _, _ = http.Post("https://example.com", "", nil) // want "direct use of http.Post is discouraged..."
}

And the corresponding test:

// nohttp_test.go
package nohttp

import (
    "testing"
    "golang.org/x/tools/go/analysis/analysistest"
)

func TestMyAnalyzer(t *testing.T) {
    testdata := analysistest.TestData()
    analysistest.Run(t, testdata, Analyzer, "a") // "a" is the package to test
}

Conclusion

Writing custom Go analyzers is a powerful way to elevate your code quality, enforce best practices, and prevent bugs specific to your domain. By understanding the basics of Abstract Syntax Trees and using the golang.org/x/tools/go/analysis package, you can create sophisticated tools with relatively little code. This investment in tooling pays dividends by automating code reviews, ensuring consistency, and allowing your team to focus on building features rather than hunting for common errors.

Resources

← Back to golang tutorials

Author

Efe Omoregie

Efe Omoregie

Software engineer with a passion for computer science, programming and cloud computing