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:
-
golang.org/x/tools — provides the
go/analysisframework and Abstract Syntax Tree (AST) inspection utilities. - github.com/golangci/plugin-module-register — allows golangci-lint to load our linter as a plugin.
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
)
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
}
And then we can run it with:
go run . ./...
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
...
}
}
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
}
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 _`
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
}
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)
})
}
}
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 ./...
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
}
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))
}
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,
}
}
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
}
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
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 ./...
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
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)