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:
- Get the
inspectorinstance created by theinspectpass. - Specify that we're interested in
ast.CallExprnodes, which represent function calls. - Use
insp.Preorderto visit everyCallExprin the AST. - Inside our callback, we check if the function call is of the form
http.Getorhttp.Post. - 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
- Official Go Blog Post: Go Static Analysis Tooling
- Package Documentation:
golang.org/x/tools/go/analysis - AST Package Docs:
go/ast - A great talk by Alan Donovan: Building Static Analysis Tools