Custom Go AST Analyzer for Static Code Checks
Static analysis is a cornerstone of modern Go development. By inspecting source code before it runs, you can surface bugs, enforce style conventions, and even codify architectural rules. While tools like go vet
and golint
cover many use‑cases, teams often need bespoke checks that reflect their unique codebase. In this post we’ll walk through building a custom Go AST analyzer using the go/ast
, go/analysis
, and golang.org/x/tools/go/ast/astutil
packages. You’ll learn how to traverse the syntax tree, write reusable lint rules, integrate the analyzer with the Go toolchain, and run it as part of your CI pipeline.
1. Why Build a Custom Analyzer?
Situation | Built‑in tool | Need for custom analyzer |
---|---|---|
Enforce a naming scheme for context keys | go vet does not check naming | Detect any type that implements contextKey and enforce camelCase |
Prevent use of fmt.Printf in production code | golint warns on formatting but not on fmt.Printf used in non‑test files | Flag fmt.Printf calls outside *_test.go |
Verify that all exported functions have a comment that starts with the function name | go vet checks comments but not the exact prefix rule | Ensure doc comments match the exported identifier |
These kinds of rules are easy to express once you can inspect the abstract syntax tree (AST) of every compiled package.
2. Core Packages
Package | Purpose | Key Types / Functions |
---|---|---|
go/ast | Represents the Go source code as a tree of nodes. | ast.Node , ast.Inspect , ast.File , ast.CallExpr |
golang.org/x/tools/go/analysis | Provides a framework for building static analysis checks that can be run by go vet , go list , or a custom driver. | analysis.Analyzer , analysis.Pass , analysis.Diagnostic |
golang.org/x/tools/go/ast/astutil | Helpful utilities for modifying or querying the AST (e.g., Apply , PathEnclosingInterval ). | astutil.PathEnclosingInterval , astutil.AddImport |
golang.org/x/tools/go/packages | Loads packages with type information (required by go/analysis ). | packages.Load , packages.Config |
Docs:
go/ast
docs – https://pkg.go.dev/go/astgo/analysis
docs – https://pkg.go.dev/golang.org/x/tools/go/analysis
3. Anatomy of a Minimal Analyzer
Below is a skeleton that you can copy‑paste into a new module (github.com/yourorg/ast-analyzer
).
// file: analyzer.go
package analyzer
import (
"go/ast"
"golang.org/x/tools/go/analysis"
)
var Analyzer = &analysis.Analyzer{
Name: "contextkey",
Doc: "enforces camelCase naming for context key types",
Run: run,
}
// run is invoked for each package that the driver passes to the analyzer.
func run(pass *analysis.Pass) (interface{}, error) {
for _, file := range pass.Files {
// Walk the AST of each file.
ast.Inspect(file, func(n ast.Node) bool {
// Look for type declarations.
td, ok := n.(*ast.TypeSpec)
if !ok {
return true
}
// Simple heuristic: if the type implements the empty interface `contextKey`.
// In real code you would use type‑checking via pass.TypesInfo.
if isContextKey(td) && !isCamelCase(td.Name.Name) {
pass.Reportf(td.Pos(),
"context key type %q should be camelCase", td.Name.Name)
}
return true
})
}
return nil, nil
}
// isContextKey is a placeholder – replace with a proper check using pass.TypesInfo.
func isContextKey(ts *ast.TypeSpec) bool {
// Example: name ends with “Key”.
return strings.HasSuffix(ts.Name.Name, "Key")
}
func isCamelCase(name string) bool {
// Very naive check: first character must be lower‑case.
r, _ := utf8.DecodeRuneInString(name)
return unicode.IsLower(r)
}
Key points:
Analyzer
embeds metadata (Name
,Doc
) and theRun
entry point.pass.Files
contains the parsed AST for each source file in the target package.pass.Reportf
records a diagnostic that the driver will surface to the user.
You can compile the analyzer as a stand‑alone command using the unitchecker
driver:
go get golang.org/x/tools/go/analysis/unitchecker
go build -o contextkey ./cmd/contextkey
Now you can run it just like any other vet analyzer:
go vet -vettool=./contextkey ./...
4. Creating a Real‑World Rule: Disallow fmt.Printf
in Production
4.1 Goal
Detect any call to fmt.Printf
, fmt.Fprintf
, or fmt.Printf
that appears in non‑test files.
4.2 Implementation
// file: nofmtprintf.go
package nofmtprintf
import (
"go/ast"
"golang.org/x/tools/go/analysis"
)
var Analyzer = &analysis.Analyzer{
Name: "nofmtprintf",
Doc: "flags fmt.Printf‑style calls in non‑test files",
Run: run,
}
func run(pass *analysis.Pass) (interface{}, error) {
for _, file := range pass.Files {
// Skip *_test.go files.
if strings.HasSuffix(pass.Fset.File(file.Pos()).Name(), "_test.go") {
continue
}
ast.Inspect(file, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return true
}
ident, ok := sel.X.(*ast.Ident)
if !ok || ident.Name != "fmt" {
return true
}
// fmt.Printf, fmt.Printf etc.
if strings.HasPrefix(sel.Sel.Name, "Print") {
pass.Reportf(call.Pos(),
"avoid fmt.%s in production code; use logging package instead", sel.Sel.Name)
}
return true
})
}
return nil, nil
}
4.3 Testing the Analyzer
The analysistest
package provides a convenient way to write golden‑file tests.
// file: nofmtprintf_test.go
package nofmtprintf_test
import (
"testing"
"golang.org/x/tools/go/analysis/analysistest"
"github.com/yourorg/ast-analyzer/nofmtprintf"
)
func TestNoFmtPrintf(t *testing.T) {
testdata := analysistest.TestData()
analysistest.Run(t, testdata, nofmtprintf.Analyzer, "a")
}
Create a directory testdata/src/a
with a source file a.go
containing:
package a
import "fmt"
func hello() {
fmt.Printf("hello %s", "world") // want "avoid fmt.Printf in production code; use logging package instead"
}
Running go test ./...
will verify that the diagnostic appears exactly where the // want
comment resides.
5. Hooking the Analyzer Into the Go Toolchain
5.1 As a go vet
Plugin
If you ship your analyzer as a binary (as shown earlier), developers can simply add it to their toolchain:
# Install the analyzer binary once
go install github.com/yourorg/ast-analyzer/cmd/nofmtprintf@latest
# Run go vet with the custom checker
go vet -vettool=$(go env GOPATH)/bin/nofmtprintf ./...
5.2 Multi‑Checker Driver
For projects with multiple linters, the multichecker
package lets you bundle them:
package main
import (
"golang.org/x/tools/go/analysis/multichecker"
"github.com/yourorg/ast-analyzer/nofmtprintf"
"github.com/yourorg/ast-analyzer/contextkey"
)
func main() {
multichecker.Main(
nofmtprintf.Analyzer,
contextkey.Analyzer,
)
}
Running the compiled binary works like go vet
but executes every bundled check.
5.3 CI Integration
Add a step to your CI YAML (GitHub Actions example):
jobs:
static-analysis:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install custom analyzers
run: |
go install github.com/yourorg/ast-analyzer/cmd/customlint@latest
- name: Run analyzers
run: |
go vet -vettool=$(go env GOPATH)/bin/customlint ./...
The CI will now fail the build if any of your custom rules are violated.
6. Advanced Techniques
6.1 Using Type Information
The examples above rely on syntactic patterns, but the analysis.Pass
also provides type info (pass.TypesInfo
). This lets you:
- Resolve the actual type of an expression (
typeAndValue.Type
). - Detect interface implementations (
types.Implements
). - Filter based on the imported package’s
Path
(e.g., ensure a call is to an external vendored package).
6.2 AST Transformations
If you need to rewrite code (e.g., automatically replace fmt.Printf
with log.Printf
), use astutil.Apply
together with a custom astutil.Rewrite
function. The transformed source can be emitted via go/format
.
6.3 Performance Tips
- Cache analyses: Most analyzers are pure functions; the driver already caches results across files.
- Limit the traversal: Use
ast.Inspect
only on nodes you care about (e.g., start at*ast.File.Decls
). - Parallelize: The
analysis
framework can run multiple analyzers in parallel if they declare no dependencies.
Conclusion
Building a custom Go AST analyzer gives you surgical‑grade control over static code quality. By leveraging the go/ast
syntax tree, the go/analysis
framework, and utilities like astutil
, you can enforce domain‑specific conventions, prevent risky patterns, and embed the checks directly into go vet
and your CI pipeline. The modular design of analysis.Analyzer
means you can grow a suite of checks over time, share them across teams, and even ship them as plugins for editors (VS Code, GoLand, etc.).
Start by cloning the minimal skeleton, add the rule(s) you need, and watch your codebase become self‑policing—the future of Go development is static analysis, and now you have the tools to shape it.
Resources
Topic | Link |
---|---|
go/ast package | https://pkg.go.dev/go/ast |
go/analysis framework | https://pkg.go.dev/golang.org/x/tools/go/analysis |
astutil utilities | https://pkg.go.dev/golang.org/x/tools/go/ast/astutil |
Writing a custom linter tutorial (Arslan) | https://arslan.io/2019/06/13/using-go-analysis-to-write-a-custom-linter/ |
Medium walkthrough – “Cool Stuff With Go’s AST Package” | https://medium.com/swlh/cool-stuff-with-gos-ast-package-pt-1-981460cddcd7 |
analysistest documentation | https://pkg.go.dev/golang.org/x/tools/go/analysis/analysistest |
multichecker driver | https://pkg.go.dev/golang.org/x/tools/go/analysis/multichecker |
Go vet integration guide | https://go.dev/blog/vet |