Facebook Linkedin Twitter
Posted Fri Dec 3, 2021 •  Reading time: 5 minutes

Using x/tools/analysis to Write Your Own Linter

Given enough eyeballs, all bugs are shallow.

Code reviews are important, they are a great way to share knowledge and find mistakes that compilers can’t. Sadly, we humans are lazy and forgetful. Even if you use a code review checklist (or another), you might skip an item.

The solution? Automation! Linters are great at catching common errors. Personally I use golangci-lint, but there are many more out there. Pick one that works for you and use it as part of your tests (make sure to automate this as well :).

However, each team has its own set of rules which are not covered by the available linters. Instead of going back to manual checklists, you can write your own linter.

The golang.org/x/tools/analysis package makes writing linters easy. What I find a nice bonus is that I learn something new about how the Go compiler sees my programs.

Let’s write a linter that forbids the use of the unsafe package and cgo. The rest of this post will follow an abbreviated version of my process developing a linter, just removing the curses reduce the size by a lot :)

We’ll start by defining an Analyzer:

var Analyzer = &analysis.Analyzer{
    Name: "safer",
    Doc:  "Forbid usage import unsafe package",
    Run:  run,
}

We define the name, documentation and a run function. The run function is goes over the code and finds errors. Before we start writing run we need a test file. Here what we’ll use (bad.go):

package main

import (
    "unsafe"
)

/*
#include <stdio.h>

void show(void *val) {
    printf("%p\n", val);
}
*/
import "C"

func bad() {
    var p unsafe.Pointer
    C.show(p)
}

We’d like the go build command of our project to ignore this file we’ll place it in the testdata directory.

Here’s the first implementation of run:

func run(pass *analysis.Pass) (interface{}, error) {
    for _, file := range pass.Files {
        ast.Inspect(file, func(node ast.Node) bool {
            fmt.Println(node)
            return true
        })
    }

    return nil, nil
}

run get a pointer analysis.Pass which contains the files to check and returns a possible value that can be used by other analyzers and an error. Our analyzer is stand-alone so we’ll return nil for the result. Then we call ast.Inspect on every file in pass. ast.Inspect gets the file and a function, this function is called for every node in the AST. This anonymous function is the heart of our linter. It gets an ast.Node and return a bool indicating if we found a problem or not.

Initially, we’ll print the node and return true meaning there are no errors.

Now we need to run our analyzer, x/tools/go/analysis/singlechecker provides a ready made main for us:

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

Now we can run

$ go run main.go testdata/bad.go        
named files must all be in one directory; have ./ and testdata/

Oops, go run thinks that testdata/bad.go a part of the files it should run. The solution is to add -- before the arguments. Using -- to mark “end of options” is a common practice in command line tools.

$ go run main.go -- testdata/bad.go
&{<nil> 1704593 main [0xc0000b8000 0xc0000b8040 0xc0000b8080 0xc0000b9580 0xc0001ae120 0xc0000b9740 0xc0001ae1b0 0xc0000b9b40 0xc0001ae2d0 0xc0001ae3c0 0xc0001ae450 0xc0000b9d40 0xc0000b9e00 0xc0001ae540] scope 0xc00009f150 {
	var __cgofn__cgo_9ed998709373_Cfunc_show
	var _cgo_9ed998709373_Cfunc_show
	var _Cgo_always_false
	func _Cgo_use
	type _Ctype_void

... about 390 more lines ...

Yeah, syntax trees are big :) Let’s try an look only at imports. To do that we’re going to use a type assertion to check if the current node is *ast.ImportSpec. It usually takes some digging in the ast package to find the right type.

func run(pass *analysis.Pass) (interface{}, error) {
    for _, file := range pass.Files {
        ast.Inspect(file, func(node ast.Node) bool {
            imp, ok := node.(*ast.ImportSpec)
            if !ok {
                return true
            }
            fmt.Println(imp)
            return true
        })
    }

    return nil, nil
}

And run it:

$ go run main.go -- testdata/bad.go        
&{<nil> <nil> 0xc0000280a0 <nil> 0}
&{<nil> _ 0xc0000280e0 <nil> 0}
&{<nil> <nil> 0xc000028120 <nil> 0}
&{<nil> <nil> 0xc0000ca080 <nil> 0}
&{<nil> _ 0xc0000ca1e0 <nil> 0}

Hmm, we have two imports in out code but we see 5 here. Let’s print more useful information - the imported path. Again, some poking in the ast package finds the solution - Path.Value:

func run(pass *analysis.Pass) (interface{}, error) {
    for _, file := range pass.Files {
        ast.Inspect(file, func(node ast.Node) bool {
            imp, ok := node.(*ast.ImportSpec)
            if !ok {
                return true
            }
            fmt.Println(imp.Path.Value)
            return true
        })
    }

    return nil, nil
}

And running it shows:

$ go run main.go -- testdata/bad.go 
"unsafe"
"runtime/cgo"
"syscall"
"unsafe"
"unsafe"

We have some unsafe imports, and runtime/cgo which is how Go sees import "C". But still too many, let’s print also the file name. Each node does not have a file name field, we need to use the position

func run(pass *analysis.Pass) (interface{}, error) {
    for _, file := range pass.Files {
        ast.Inspect(file, func(node ast.Node) bool {
            imp, ok := node.(*ast.ImportSpec)
            if !ok {
                return true
            }
            fmt.Println(imp.Path.Value)
            fileName := pass.Fset.Position(imp.Pos()).Filename
            fmt.Println(fileName)
            return true
        })
    }

    return nil, nil
}

And run it:

$ go run main.go -- testdata/bad.go
"unsafe"
/home/miki/.cache/go-build/a2/a29b5d4e41864b50fe133d3534586f838acf1bf603f324a490ff49a921cc7fba-d
"runtime/cgo"
/home/miki/.cache/go-build/a2/a29b5d4e41864b50fe133d3534586f838acf1bf603f324a490ff49a921cc7fba-d
"syscall"
/home/miki/.cache/go-build/a2/a29b5d4e41864b50fe133d3534586f838acf1bf603f324a490ff49a921cc7fba-d
"unsafe"
/home/miki/Projects/gopheradvent.com/site/content/calendar/2021/safer/testdata/bad.go
"unsafe"
/home/miki/Projects/gopheradvent.com/site/content/calendar/2021/safer/testdata/bad.go

Looks like Go is creating a temporary file for the C code. Now we can write the final version of the linter that will check only the Go files (ending with .go) and use pass.Reporft to report the location of the problems:

func run(pass *analysis.Pass) (interface{}, error) {
    for _, file := range pass.Files {
        ast.Inspect(file, func(node ast.Node) bool {
            imp, ok := node.(*ast.ImportSpec)
            if !ok {
                return true
            }

            fileName := pass.Fset.Position(imp.Pos()).Filename
            if !strings.HasSuffix(fileName, ".go") {
                return true
            }

            name := strings.Trim(imp.Path.Value, `"`)
            if name != "unsafe" {
                return true
            }

            pass.Reportf(imp.Pos(), "unsafe import")
            return false
        })
    }

    return nil, nil
}

And when we run it:

$ go run main.go -- testdata/bad.go 
.../testdata/bad.go:4:2: unsafe import
.../testdata/bad.go:14:8: unsafe import
$ echo ?
1

We found both the problems and our linter exited with non-zero value - indicating an error.

Just to make sure, let’s write an ok file and test as well:

package main

import "fmt"

func ok() {
    fmt.Println("OK")
}

And run

$ go run main.go -- testdata/ok.go 
$ echo $?
0

No output and zero exit status - awesome.

In less that 40 lines we created a new linter, fun and useful!

Testing

What about testing? The x/tools/go/analysis/analysistest is here for you. You need to mark your input files with expected output in a special comment, it’ll take care of the rest.

Here’s the modified bad.go with the test annotations (// want ...):

package main

import (
    "unsafe" // want "unsafe import"
)

/*
#include <stdio.h>

void show(void *val) {
    printf("%p\n", val);
}
*/
import "C" // want "unsafe import"

func bad() {
    var p unsafe.Pointer
    C.show(p)
}

And now main_test.go:


package main

import (
    "testing"

    "golang.org/x/tools/go/analysis/analysistest"
)

func TestLinter(t *testing.T) {
    analysistest.Run(t, analysistest.TestData(), Analyzer)
}

That’s it. analysistest.TestData() returns the absolute path to the testdata directory, but you can use other directories if you want. Let’s run:


$ go test -v       
=== RUN   TestLinter
--- PASS: TestLinter (1.20s)
PASS
ok  	github.com/353solutions/safer	1.212s

Summary

The golang.org/x/tools/go/analysis package makes writing custom linter easy. Every time I write a new one, I also learn about the Go code structure.