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
inspector
instance created by theinspect
pass. - Specify that we're interested in
ast.CallExpr
nodes, which represent function calls. - Use
insp.Preorder
to visit everyCallExpr
in the AST. - Inside our callback, we check if the function call is of the form
http.Get
orhttp.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