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?

SituationBuilt‑in toolNeed for custom analyzer
Enforce a naming scheme for context keysgo vet does not check namingDetect any type that implements contextKey and enforce camelCase
Prevent use of fmt.Printf in production codegolint warns on formatting but not on fmt.Printf used in non‑test filesFlag fmt.Printf calls outside *_test.go
Verify that all exported functions have a comment that starts with the function namego vet checks comments but not the exact prefix ruleEnsure 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

PackagePurposeKey Types / Functions
go/astRepresents the Go source code as a tree of nodes.ast.Node, ast.Inspect, ast.File, ast.CallExpr
golang.org/x/tools/go/analysisProvides 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/astutilHelpful utilities for modifying or querying the AST (e.g., Apply, PathEnclosingInterval).astutil.PathEnclosingInterval, astutil.AddImport
golang.org/x/tools/go/packagesLoads packages with type information (required by go/analysis).packages.Load, packages.Config

Docs:


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 the Run 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

TopicLink
go/ast packagehttps://pkg.go.dev/go/ast
go/analysis frameworkhttps://pkg.go.dev/golang.org/x/tools/go/analysis
astutil utilitieshttps://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 documentationhttps://pkg.go.dev/golang.org/x/tools/go/analysis/analysistest
multichecker driverhttps://pkg.go.dev/golang.org/x/tools/go/analysis/multichecker
Go vet integration guidehttps://go.dev/blog/vet
← Back to golang tutorials