DEV Community

Manuel Doncel Martos
Manuel Doncel Martos

Posted on

Create Your First Linter in Go

What is a linter

A linter is a static code analysis tool that scans source code without executing it. It flags programming errors, bugs, security vulnerabilities, and stylistic inconsistencies.

Linters help developers maintain clean, error-free, and standardized codebases.

A linter in Go works by analyzing the Abstract Syntax Tree produced by the source code, and flagging the potential issues after traversing it.

In this tutorial, we will build a simple stylistic linter from scratch, so then you can learn how it works and then build more complex ones in the future.

Why build a custom linter?

Generic linters catch common issues, but every team eventually develops its own conventions and architectural rules.

Custom linters are useful for enforcing many project or team rules and reduce the developer cognitive load.

Because Go exposes its AST and analysis tooling through standard libraries, building custom linters is surprisingly easy (and enjoyable).

Tooling

Not only are we going to build a linter, but also we will integrate it (as a module plugin) in golangci-lint.

To do that, we need two key libraries:

Here's what our go.mod should look like:

module github.com/{yourusername}/unexportedconstantcheck

go 1.25.0

require (
    github.com/golangci/plugin-module-register v0.1.2
    golang.org/x/tools v0.45.0
)

require (
    golang.org/x/mod v0.36.0 // indirect
    golang.org/x/sync v0.20.0 // indirect
)
Enter fullscreen mode Exit fullscreen mode

Understanding the AST

Before starting, it is useful to check how the AST of a Go file looks like. For that we can use the following code:

package main

import (
    "go/ast"

    "golang.org/x/tools/go/analysis"
    "golang.org/x/tools/go/analysis/passes/inspect"
    "golang.org/x/tools/go/analysis/singlechecker"
)

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

func NewAnalyzer() *analysis.Analyzer {
    return &analysis.Analyzer{
        Name:     "astexample",
        Doc:      "astexample to show hierarchy",
        Run:      run,
        Requires: []*analysis.Analyzer{inspect.Analyzer},
    }
}

func run(pass *analysis.Pass) (any, error) {
    for _, file := range pass.Files {
        _ = ast.Print(pass.Fset, file)
    }
    return nil, nil
}
Enter fullscreen mode Exit fullscreen mode

And then we can run it with:

go run . ./...
Enter fullscreen mode Exit fullscreen mode

To check how the AST of the file we just created looks like. In the standard output we can see the printed version of the AST for that file.

Linter implementation

The linter we are going to implement is a very simple stylistic linter to get acquainted with the library and how to traverse the nodes.

It's going to be based on one of the Go Uber style guidelines, prefix unexported globals with _.

The business rules of the linter are quite easy then:

  • Unexported constants need to start with _.
  • Unless the constant starts with err.

First steps

There are four concepts that we need to know when using the tools library to create a linter:

  • Node
  • Analyzer
  • Pass
  • Diagnosis

A Node is an element of the Abstract Syntax Tree (AST) generated by the Go parser. Nodes represent Go language constructs defined in the Go specification, such as imports, functions, constants, or expressions. As an example, ImportSpec, for imports, ValueSpec, for constants, etc.

An Analyzer is where the business logic of our linter is going to be implemented, in a run function.

Pass is the parameter that our run function receives and it contains the files, with the ASTs, to analyze. that we are going to traverse to "Diagnoses" (flag) our linting issues.

This is an example on how an analyzer looks like:

func NewAnalyzer() *analysis.Analyzer {
    return &analysis.Analyzer{
        Name:     "unexportedconstantscheck",
        Doc:      "unexportedconstantscheck checks if unexported constants starts with _",
        Run:      run,
        Requires: []*analysis.Analyzer{inspect.Analyzer},
    }
}

func run(pass *analysis.Pass) (any, error) {
    for _, file := range pass.Files { // go through all the files of the package
        for _, decl := range file.Decls { // get the array of nodes for that file
        // TODO: implement business logic
        ... 
    }
}
Enter fullscreen mode Exit fullscreen mode

Let's create a file called analyzer.go that contains the previous code snippet, and we will implement the business logic in the next section.

Test cases

First, let's create some test cases to check how we want our linter to behave. Let's create a folder testdata/src/simple/main.go in which we are going to add the following:

package simple

const myconstant = "myconstant" // want `unexported constant "myconstant" should be prefixed with _`

const Rate = 0

const errNotFound = "not found"

const (
    group        = "group" // want `unexported constant "group" should be prefixed with _`
    Of           = "Of"
    errConstants = "error"
    _yeah        = "yeah"
)

func aFunction(input int) int {
    const m = "hello"
    output := input * 2
    return output
}
Enter fullscreen mode Exit fullscreen mode

Pay special attention on the lines that contains comments like // want .... This is how we define the expected diagnostics for our linter tests.
In this test case, we cover several scenarios, some of them in which we want the linter to act, and some others in which we don't.

For example we have:

const myConstant = "myConstant" // want `unexported constant "myconstant" should be prefixed with _`
Enter fullscreen mode Exit fullscreen mode

In which we are expecting it to be flagged with unexported constant "group" should be prefixed with _.

But we also have other test case like:

func aFunction(input int) int {
    const m = "hello"
    output := input * 2
    return output
}
Enter fullscreen mode Exit fullscreen mode

in which the constant declaration is not a global one, so it should ont be flagged.

Once we have the test cases, let's create a test file, analyzer_test.go for it:

package unexportedconstantscheck

import (
    "testing"

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

func TestAnalyzer(t *testing.T) {
    testCases := []struct {
        desc     string
        patterns string
    }{
        {
            desc:     "simple",
            patterns: "simple",
        },
    }

    for _, test := range testCases {
        t.Run(test.desc, func(t *testing.T) {
            a := NewAnalyzer()

            analysistest.Run(t, analysistest.TestData(), a, test.patterns)
        })
    }
}
Enter fullscreen mode Exit fullscreen mode

The analysistest package compiles the files under testdata and compares the reported diagnostics with the // want comments automatically.

If we run the test with:

go test ./...
Enter fullscreen mode Exit fullscreen mode

The tests will fail because the analyzer does not yet report any diagnostics.

So let's fix it!

Business logic implementation

We are only interested in the "top level" ValueSpec nodes. So we could use the following code in analyzer.go to filter out those nodes:

package unexportedconstantscheck

import (
    "fmt"
    "go/ast"

    "golang.org/x/tools/go/analysis"
    "golang.org/x/tools/go/analysis/passes/inspect"
)

func NewAnalyzer() *analysis.Analyzer {
    return &analysis.Analyzer{
        Name:     "unexportedconstantscheck",
        Doc:      "unexportedconstantscheck checks if unexported constants starts with _",
        Run:      run,
        Requires: []*analysis.Analyzer{inspect.Analyzer},
    }
}

func run(pass *analysis.Pass) (any, error) {
    for _, file := range pass.Files {
        for _, decl := range file.Decls {
            genDecl, isGenDecl := decl.(*ast.GenDecl)
            if !isGenDecl {
                continue
            }

            for _, spec := range genDecl.Specs {
                valueSpec, isValueSpec := spec.(*ast.ValueSpec)
                if !isValueSpec {
                    continue
                }
                // TODO: lint unexported constant not starting with _.
            }
        }
    }

    return nil, nil
}
Enter fullscreen mode Exit fullscreen mode

The previous piece of code will filter the global constants (ValueSpec) nodes.

And then we need to implement the business logic:

for _, name := range valueSpec.Names {
  if name.IsExported() {
    continue
  }

  if strings.HasPrefix(name.Name, "err") {
    continue
  }
  // global unexported constant that does not start with `_`.  
  pass.Report(newUnexportedConstantsCheckDiag(name))
}
Enter fullscreen mode Exit fullscreen mode

And the diagnosis function, that receives the offending identifier:

func newUnexportedConstantsCheckDiag(i *ast.Ident) analysis.Diagnostic {
    msg := fmt.Sprintf("unexported constant %q should be prefixed with _",
        i.Name)

    return analysis.Diagnostic{
        Pos:     i.Pos(),
        End:     i.End(),
        Message: msg,
    }
}
Enter fullscreen mode Exit fullscreen mode

And now, if we run the tests we should see them passing.

Integrate it as a plugin in golangci-lint

If we want to add our custom linter to golangci-lint, there are several options, the recommended one is to add it as a [module plugin][https://golangci-lint.run/docs/plugins/module-plugins/].

This way works by creating our custom golangci-lint binary, with our linter added, and then using that custom binary to lint our application.

We first need to register the linter as a module plugin with the following code in plugin.go:

func init() {
    register.Plugin("unexportedconstantscheck", New)
}

func New(_ any) (register.LinterPlugin, error) {
    return &unexportedConstantsCheckPlugin{}, nil
}

var _ register.LinterPlugin = new(unexportedConstantsCheckPlugin)

type unexportedConstantsCheckPlugin struct{}

func (u unexportedConstantsCheckPlugin) BuildAnalyzers() ([]*analysis.Analyzer, error) {
    return []*analysis.Analyzer{
        NewAnalyzer(),
    }, nil
}

func (u unexportedConstantsCheckPlugin) GetLoadMode() string {
    return register.LoadModeSyntax
}
Enter fullscreen mode Exit fullscreen mode

We, then need to add our .golangci.yml and also create a custom-gcl.yaml file like the following:

version: v2.12.2
plugins:
  - module: 'github.com/{yourusername}/unexportedconstantcheck'
    import: 'github.com/{yourusername}/unexportedconstantcheck'
    version: v0.1.0
Enter fullscreen mode Exit fullscreen mode

And then run the following command:

# this will create our custom golangci-lint binary.
golangci-lint custom -v
# we run our custom binary through the project.
./custom-gcl run --fix ./...
Enter fullscreen mode Exit fullscreen mode

Full project structure

This is how the project should look like:

unexportedconstantcheck/
├── analyzer.go
├── analyzer_test.go
├── go.mod
├── go.sum
│── plugin.go
└── testdata/
    └── src/
        └── simple/
            └── main.go
Enter fullscreen mode Exit fullscreen mode

Conclusion

You've now built a custom linter that enforces a specific naming convention, tested it with real Go code, and integrated it as a golangci-lint plugin.

This pattern can be extended to enforce any rule you need and can help to reduce the cognitive load in your pull requests.

Top comments (0)