This is a practical guide for integrating a custom linter into your Go project.
The first two sections briefly describe writing and running a custom linter — a single, atomic rule you want to enforce.
The third section describes using your custom linter with Go’s
most common metalinter, golangci-lint
: at the command line,
in your editor, and in your continuous integration environment
(CI).
Finally, this is a rant about tool complexity and my defense of a
simpler Go metalinter: glint
.
analysis
The golang.org/x/tools/go/analysis
package is the rich soil to Go’s linter ecosystem. A simple linter
typically defines an analysis.Analyzer
— or, for a complex
linter, a graph of several mutually dependent
Analyzer
s — that passes over files line-by-line and
publishes diagnostics about them. The package’s published overview is
short and very clear; if you want to write or debug lint rule, go read
that overview.
nilinterface
defines a single Analyzer
without any dependencies: it
inspects each node in each file’s AST, and uses
pass.Reportf
to alert if any given node is the literal
nil
and serving as an interface-typed argument to
a function.
Note where the Analyzer
is defined: in its own
package, pkg/analyzer
. All the code therein is strictly
independent of what tools you use to run the analysis (whether a
standalone command-line binary or a metalinter like
golangci-lint
); the package naturally separates concerns
between the analysis itself and the means of invoking it.
The analysistest
subpackage lets you test your
Analyzer
by feeding it real code samples, with
expected diagnostics as inline code comments, from a testdata
directory. See analyzer_test.go
for an example.
One tip: LLM code generation is really useful for writing
Analyzer
s, so long as you can precisely describe the linter
behavior you want. Let the AI massage the polymorphic AST types so you
can focus on the specific diagnostic conditions. Test-driven development
works well here: write your analysistest
examples (start
with true-positive cases, issues you want the linter to detect) so you
can validate the AI-generated contributions.
The simplest means of invoking an analysis.Analyzer
is
to build it into a standalone binary you can run from the command-line.
The analysis
package kindly provides a one-liner utility
for that: just create a package main
and call
singlechecker.Main
.
package main
import (
"github.com/lukasschwab/nilinterface/pkg/analyzer"
"golang.org/x/tools/go/analysis/singlechecker"
)
// main is an entry-point for running the nilinterface analyzer as a standalone
// binary. The analyzer process exposed by [singlechecker.Main] is additionally
// compatible with `go vet -vettool`.
func main() {
.Main(analyzer.Analyzer)
singlechecker}
Congratulations! Before, you just had an analyzer; now you
have a linter. You can install this main
package and run it
like you would any other command-line tool:
go install github.com/lukasschwab/nilinterface/cmd/nilinterface@latest
nilinterface --help
nilinterface ./...
Moreover, this singlechecker.Main
command line interface
is compatible with go vet
:
go vet --vettool=$(which nilinterface) ./...
This is comfortable enough for one or two custom rules, but building
and invoking so many individual binaries is tedious for a complex
project. That’s where metalinters like golangci-lint
come
in: they coordinate a mass of smaller linters. Perhaps you’re already
using golangci-lint
for the bundle of linters it provides
out of the box. How do you incorporate your custom linter into that
bundle?
golangci-lint
First, a rant. The amount of space dedicated to
golangci-lint
in this article is not to that project’s
credit.
Before this weekend, I naturally conceived of metalinters as
coordinators in an ecosystem of lightweight modular linters.
That’s not quite right. golangci-lint
, out of the box, is a
gatekept1 fixed set of linters, baked
into the tool at build time. Your massive .golangci.yml
config? That’s not adding anything new — just picking rules
from among what golangci-lint
sees fit to offer.
Its list of
linters belies an uncomfortable but inevitable tension. On one hand,
there’s pressure to keep the list short: each linter makes the
golangci-lint
executable bigger; each one enabled by
default makes it slower by default; linting with too many rules is
unhelpful; and extending the list, especially with redundant options,
makes selecting linters tedious. On the other hand, there’s reason to
keep extending the list: there are lots of conceivably useful lint rules
to provide.
The individual linters yield to this tension by being over-capable
and configurable (i.e. non-atomic). Some (e.g. revive
and
staticcheck
) are practically metalinters in their own
right. These complex linters have complex configuration spaces — making
them work for your project’s particular needs requires wading into the
ls .*.yml
mire you try not to think about.2
The solution to this tension is proper modularity among
atomic linters with minimal configurability. The only thing in
your way is the trickiness of golangci-lint
’s plugins
system. It works — more below — but with rough docs and scant examples.
It’s opaque.
The first thing to understand is that golangci-lint
documents two different plugin systems: there’s the new module plugin
system,3 which I’ll use here, and an older
“Go plugin” system based on using cgo
to compile linters as
*.so
files. Many custom-linter discussions online don’t
explicitly specify one or the other; often, these are discussions about
the Go plugin system because it came first.
If one red herring wasn’t enough, the module plugin system suggests
there are two ways to add custom linters: “The Automatic Way”
and “The Manual Way.” These are two ways to do the same thing: build a
custom golangci-lint
binary with some additional linters
rolled in. You don’t need to learn them both. The “automatic way”
specifies the new linters in a YAML file, then uses a build tool
included in golangci-lint
to integrate them; the
“manual way” has you explicitly clone, modify, and build
golangci-lint
from source. I’ll use the “automatic
way.”
There are tens of thousands of public GitHub repositories using
golangci-lint
; at time of writing, only 34
of them customize it with module plugins.
Analyzer
golangci-lint
doesn’t integrate with
Analyzer
s directly, or through some
analysis
-provided interface. It needs to pass on the
linter’s share of YAML settings, and it needs to understand what
information about a program the linter analyzes.
It does both through a registration pattern defined in golangci/plugin-module-register.
Wrap your analyzer to implement the interface
register.LinterPlugin
.
In practice, you want to return one of the two string constants in
register
. There’s scant documentation on the difference
between LoadModeSyntax
and LoadModeTypesInfo
—
analyzers with LoadModeTypesInfo
receive strictly more
data, but may run a little slower.4 If
you provide any other string, golangci-lint
defaults
to the data-rich mode.
Define a factory function (this type is confusingly
named NewPlugin
) that receives untyped configuration from
.golangci.yml
and returns the LinterPlugin
defined in step 1.
Define an init
function that calls register.Plugin
to register the
factory function defined in step 2.
I won’t get into the implementation details — for an atomic linter
without configuration variables, this adapter package should be short
and the logic largely reusable between linters. Here’s the adapter
for nilinterface
; I isolated all the
golangci
-specific logic in its own package so it doesn’t
muddle the analysis.
At this point you can shift your focus from linter to
lintee — the project you’re checking with
golangci-lint
. If you’re content without customization,
linting this project is simple: you go install
a command
line tool, optionally provide it a configuration file, and you’re off to
the races.
The “fixed set of linters” available in this installation are
actually baked into the installed executable. Even if you
haven’t enabled tagliatele
, for example, it’s
lurking in there somewhere.
Your new custom linter, unfortunately, is not — at least not until
the maintainer accepts a PR adding it. The clever idea behind
golangci
’s module plugin system is that the linter
executable you installed also includes a build tool,
golangci-lint custom
, for building another version of
itself from scratch, with additional Go modules — Go modules that
register LinterPlugin
s — rolled in!
You define custom modules in the .custom-gcl.yml
file,
distinct and upstream from the familiar .golangci.yml file.
Running the build tool yields a custom linter binary, which itself reads
configuration from .golangci.yml like golangci-lint
did
before.
Don’t worry, this is nondestructive: the original
golangci-lint
executable you installed is retained
unchanged. If you want to rip out custom linting, you can delete
custom-gcl
and .custom-gcl.yml
and you’re back
to the way things were.
For example, here’s a configuration file for a version of
golangci-lint
with nilinterface
:
version: v1.63.4
plugins:
- module: 'github.com/lukasschwab/nilinterface'
import: 'github.com/lukasschwab/nilinterface/pkg/golangci-linter'
version: v0.0.6
The module
field defines a Go module, the same way
you would import
it in a go file. import
identifies a package inside that module, the adapter package
described above (see “Adapt your
Analyzer
”). You could include several custom linters by
defining plugins beyond this one.
Running custom-gcl
is exactly the same as running
bog-standard golangci-lint
: the command-line interface and
YAML settings are the same. Keep in mind, baking a linter into
golangci-lint
doesn’t mean it’ll run by default;
you may need to update .golangci.yml
to explicitly enable
it. Using nilinterface
as an example:
# This example file is grossly simplified.
linters-settings:
custom:
nilinterface:
type: 'module'
description: 'forbids passing `nil` as an interface argument to function calls'
# If you have `disable-all: true`, add nilinterface to your enabled linters.
linters:
disable-all: true
enable:
- nilinterface
With the preceding changes to our YAML files, we can go from a clean
golangci-lint
to our custom analysis with this pair of
shell commands:
golangci-lint custom -v
./custom-gcl run ./...
The first command, the build step, is way too slow to run every time
you need to lint your project. golangci-lint custom
downloads and builds golangci-lint
from source — not a fast
process. Of course, it doesn’t make sense to commit a binary built for
your system into shared source control either… so sometimes
you’re gonna have to suck it up and rebuild the thing.
Thankfully, you only need to rebuild this custom linter binary when
you change .custom-gcl.yml
. Makefile
s are
great for conditioning builds on file changes, and lazily building only
what’s definitely necessary. If you define targets in your project
Makefile
like so —
.PHONY: lint
lint: custom-gcl
./custom-gcl run
custom-gcl: .custom-gcl.yml
golangci-lint custom -v
— you can run make lint
in lieu of the pair of shell
commands above. The slow golangci-lint custom
process will
only run when custom-gcl
(the executable it builds) is out
of date.
golangci-lint
in VS CodeThere’s a silver lining to building a drop-in replacement for
golangci-lint
: tools that integrate tightly with
golangci-lint
can integrate tightly with your
custom-gcl
instead. If you use VS Code, specify it as an
alternate lint tool to see your custom linter applied in the editor:
{
"go.lintTool": "golangci-lint",
"go.alternateTools": {
"golangci-lint": "${workspaceFolder}/custom-gcl"
}
}
${workspaceFolder}
dynamically evaluates to your
current VS Code workspace. If you’re sharing a single linter across
several workspaces, specify the binary’s absolute path
instead.
golangci-lint
in GitHub Actions
The
golangci-lint
team maintains an official GitHub Action
for running their linter, but it has no support for custom
linter modules as I write this: it’ll use your
.golangci.yml
, but totally ignore the custom linters pinned
in .custom-gcl.yml
.
I built an alternative: golangci-lint-custom-plugins-action
should tide you over until the official Action supports
the same. This alternative Action follows the steps diagrammed
above: download golangci-lint
, use it to build a customized
version of itself, then run that customized version. Like the official
Action, this one persists linter binaries and caches to save time.
The foregoing complexity stems from an unforced but fundamental
aspect of golangci-lint
’s design: the decision to ship a
binary, not a library, customizable through a baroque system of
configuration files rather than as code. The underlying code here, the
analyzers themselves, have reasonable APIs with unambiguous
documentation.
Consider the problem of determining whether a linter config is valid.
To ship a closed binary, golangci-lint
reads configuration
from a file — typically .golangci.yml
— with no
application-specific types. This is a big declarative hullaballoo for a
process that, when you configure an Analyzer
imperatively
in Go, is entirely guaranteed by the type system.
We can — and should — return our focus from the tool to the code. We should consider custom extensibility a first-class priority in a metalinter.
You should be able to set up a linter to your Go project by adding a
standalone cmd/lint/main.go
“metalinter” program:
Explicitly imports and constructs only the analyzers you want to run, using their exported types to configure them.
Runs those analyzers as a group, outputting their diagnostics for consumption by other tools.
Why are we all still using golangci-lint
?
The most important answer is performance, and the most
significant factor is golangci-lint
’s cache: if a package
hasn’t changed, neither have the lint results. The metalinter stores
per-package results indexed by hashes of the package contents. Luckily,
there’s nothing really special going on: golangci-lint
forks the native
Go build cache and wraps
it in package-hashing logic.
Less importantly, golangci-lint
has a first-movers
advantage: it filled the vacuum left by go lint
’s 2020
deprecation, and Go engineers have been building (and squabbling over)
its gatekept linter-set ever since.
glint
In glint
, I’m
solving those problems for myself. That project aims to provide a
minimal harness for running sets of analyzers defined in Go, with
analysis.Analyzer
typeFocusing on analysis.Analyzer
means drop-in
compatibility with the major golangci-lint
linters: pkg/golangci
describes them, and cmd/glint
demos wrapping them in the harness.
Adding a custom linter is a matter of defining an analyzer and adding
it to the list passed to glint.Main
. These programs work
with VS Code and go vet -vettool
, and yield annotations in
GitHub Actions.
glint
over
analysis/multichecker
?multichecker
is the package Go ships to help you solve this
metalinter problem: “[t]his package makes it easy for anyone to build an
analysis tool containing just the analyzers they need.”
analysis/multichecker
emits diagnostics to
os.Stderr
, which VS Code takes as an indication the lint
run is erroring rather than revealing issues. Its public API
also isn’t easy to extend — it calls os.Exit
and depends on
a complex group of internal packages.
glint
?golangci-lint
).
go vet
results in the Go build cache and stressing
usage with go vet -vettool
is an interesting idea, but
might require new workarounds for use with VS Code.
Reference | Description |
---|---|
analysis |
Go’s static analysis builtins:
Analyzer , singlechecker , etc. Strong
docs. |
nilinterface |
A model custom linter. |
golangci-lint-custom-plugins-action |
GitHub Action for building, caching, and
running golangci-lint with a custom linter. |
tiir @5fe5d30 |
Diff upgrading a project from standard
golangci-lint to a custom golangci-lint . |
glint |
An experimental Go-defined metalinter. |