Writing custom linters for golangci-lint

Wileward Ethelbert Coyote (known to his friends as Wile E. Coyote) works together with Road Runner (known to his friends as Road Runner) for ACME Inc.
They are collaborating on a very important project using the ocm-sdk library.

RoadRunner%20Looks

Wile E.
Why is it that every time I send you a PR for review, just a few seconds later you send it back with a bunch of errors about how I'm using ocm-logger?

Road
Because I read at the speed of light, Wile!

Wile E.
Hey, stop joking with me. You run really fast, but I can't believe you read that quickly! Please, share your secret!

Road
Have you ever heard of something called a linter?

Wile E.
Sure I have! But the vet linter doesn't detect issues with ocm-logger, since its method names don’t end with 'f' and the first parameter isn’t a format string. How come you can use the linter then?

Road
Alright, got it! I think it’s time for another lesson. Let’s dive into custom linters!

RoadRunner%20Teaches

Road
When you are going to create a new linter, you need to create a golangci-lint plugin, which is a struct that implements the LinterPlugin interface. Let's create a file with this content.

package linters

import (
    "github.com/golangci/plugin-module-register/register"
    "golang.org/x/tools/go/analysis"
)

var _ register.LinterPlugin = (*OcmLoggerLinter)(nil)

type OcmLoggerLinter struct{}

func (o *OcmLoggerLinter) BuildAnalyzers() ([]*analysis.Analyzer, error) {
    return nil, nil
}

func (o *OcmLoggerLinter) GetLoadMode() string {
    return ""
}

Wile E.
What is the LoadMode?

Road
The LoadMode can be one of register.LoadModeSyntax and register.LoadModeTypesInfo:

  • LoadModeSyntax: loads only the abstract syntax tree (AST) of the Go source code. It provides the raw syntactic structure of the code without detailed type information. It is useful when your analysis only needs to inspect the code’s syntax (e.g., its statements, expressions) but does not require understanding of types, type-checking, or type info.
  • LoadModeTypesInfo: loads both the abstract syntax tree and the type information for the code. This means the analysis has access not only to the syntactic structure but also to detailed information about types, resolved identifiers, type-checking results, and more. It enables more advanced and precise analyses that depend on understanding how types relate in the code.

In our case, we want to perform the check only when the type is github.com/openshift-online/ocm-sdk-go/logging.Logger so we need LoadModeTypesInfo.

Wile E.
And what about the analyzers?

Road
The Analyzers are the objects that analyses the AST structure of a list of files and report any detected error.

Wile E.
Gotcha! So the Analyzer is where we are going to do our linting, correct?

Road
Yeah! You got the point!

Let's start with a simple implementation.

type OcmLoggerLinter struct{}

func (l *OcmLoggerLinter) GetLoadMode() string { return register.LoadModeTypesInfo }

func (l *OcmLoggerLinter) BuildAnalyzers() ([]*analysis.Analyzer, error) {
    return []*analysis.Analyzer{{
        Name: "ocmlogger",
        Doc:  "find ocm logging usage errors",
        Run:  l.run,
    }}, nil
}

func (l *OcmLoggerLinter) run(pass *analysis.Pass) (any, error) {
    return nil, nil
}

Willy%20Question

Wile E.
Hey! Slow down! You just added a new function! What does this new run function do? What is the analysis.Pass? And what does the run function return?

Road
I've been waiting for those questions!
The code that performs the analysis goes inside the run function, which takes a parameter of type *analysis.Pass and returns a value of any type along with an error.
The analyzer processes the code in multiple passes, each pass analyzing an entire package. The pass *analysis.Pass contains information about the package being analyzed in that iteration.
As we explained earlier, a linter returns a list of analyzers, and the output of one analyzer is passed to the next.
If any analyzer returns an error, the analysis stops immediately.
For our linter, we only need one analyzer, so we'll simply return nil.

Let's now start our linting. We want to analyze only function calls whose name is any of "Debug", "Info", "Warn", "Error" or "Fatal" on objects of type github.com/openshift-online/ocm-sdk-go/logging.Logger or *github.com/openshift-online/ocm-sdk-go/logging.Logger.

func (l *OcmLoggerLinter) run(pass *analysis.Pass) (any, error) {
    loggerMethods := map[string]struct{}{
        "Debug": {}, "Info": {}, "Warn": {}, "Error": {}, "Fatal": {},
    }

    for _, file := range pass.Files {
        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
            }
            if _, ok := loggerMethods[sel.Sel.Name]; !ok {
                return true
            }

            // Check static type of the receiver.
            recvType := pass.TypesInfo.TypeOf(sel.X)
            if recvType == nil {
                return true
            }
            ts := recvType.String()
            if ts != "github.com/openshift-online/ocm-sdk-go/logging.Logger" &&
                ts != "*github.com/openshift-online/ocm-sdk-go/logging.Logger" {
                return true
            }

            return true
        })
    }
    return nil, nil
}

Wile E.
Hey What..

Road
Be quiet! Let me explain, then you can ask your questions!


In this code, we enumerate the method names we want to check. We use a map instead of an array because map lookups are faster than array searches.

    loggerMethods := map[string]struct{}{
        "Debug": {}, "Info": {}, "Warn": {}, "Error": {}, "Fatal": {},
    }

Next, we loop through each file in the package:

for _, file := range pass.Files {

We pass each file to ast.Inspect, which parses it and calls our function for every ast.Node. If our function returns true, it recursively inspects the children of that node.

ast.Inspect(file, func(n ast.Node) bool {

Now, the fun begins! We are interested only in function calls, represented by ast.CallExpr. If the node is not a CallExpr, we return and continue with the next node.

            call, ok := n.(*ast.CallExpr)
            if !ok {
                return true
            }

Since the current node is a function call, we narrow down to method calls only (calls on objects), not generic function calls:

            sel, ok := call.Fun.(*ast.SelectorExpr)
            if !ok {
                return true
            }

The current node is a method call. We check if its method name matches one of the logger methods:

            if _, ok := loggerMethods[sel.Sel.Name]; !ok {
                return true
            }

At this point, we are confident we found calls to one of the methods "Debug", "Info", "Warn", "Error", or "Fatal" on the github.com/openshift-online/ocm-sdk-go/logging.Logger type.

            // Check static type of the receiver.
            recvType := pass.TypesInfo.TypeOf(sel.X)
            if recvType == nil {
                return true
            }
            ts := recvType.String()
            if ts != "github.com/openshift-online/ocm-sdk-go/logging.Logger" &&
                ts != "*github.com/openshift-online/ocm-sdk-go/logging.Logger" {
                return true
            }

Willy%20Smart

Wile E.
That's way easier than I thought! Please go on!

Road
The logger method can be invoked in any of these forms:

logger.Info("%s outsmarts %s", "Road", "Wile")

logger.Info("%s out" + "smarts %s", "Road", "Wile")

logger.Info(format, "Road", "Wile") // the variable `format` is defined elsewhere

We can only validate the first two forms. We can't validate the third case since the value of format isn't known at analysis time. Let's extract the format string.

Here's a method to handle this:

// extractString resolves a string literal or a concatenation of string literals.
func (l *OcmLoggerLinter) extractString(expr ast.Expr) (string, bool) {
    switch e := expr.(type) {
    case *ast.BasicLit:
        if e.Kind != token.STRING {
            return "", false
        }
        s, err := strconv.Unquote(e.Value)
        return s, err == nil
    case *ast.BinaryExpr:
        if e.Op != token.ADD {
            return "", false
        }
        lhs, ok1 := l.extractString(e.X)
        rhs, ok2 := l.extractString(e.Y)
        if ok1 && ok2 {
            return lhs + rhs, true
        }
    }
    return "", false
}

The extractString function extracts the actual string value from an AST expression, handling both simple string literals and concatenations:

  • For ast.BasicLit (basic literal): The function checks if it's a string literal (not a number or character). If so, it removes the surrounding quotes and returns the unquoted string with true to indicate success. For example, it extracts %s outsmarts %s from logger.Info("%s outsmarts %s", "Road", "Wile").

  • For ast.BinaryExpr (binary expression): The function checks if the operator is +, indicating string concatenation like "%s out" + "smarts %s". It recursively calls itself on both the left (e.X) and right (e.Y) expressions. If both succeed, it concatenates the strings and returns the combined result.

  • Otherwise: If neither case matches or extraction fails, the function returns an empty string and false.

This recursive approach handles both simple literals and concatenated string expressions seamlessly.

Wile E.
I love this AST thing! If I understood correctly, we now need to add this code to the run method to extract the format string:

            // Resolve format string at analysis time.
            fmtStr, ok := l.extractString(call.Args[10])
            if !ok {
                return true
            }

Road
Correct!

Willy%20Confuso

Wile E.
That's been easy. However I still don't understand how we notify errors. We just parsed the code so far.

Road
We're almost there!

The ocm-logger methods follow the signature function(ctx context.Context, format string, parameters ...any). We need to ensure that the number of placeholders in the format string matches the number of variadic parameters.

Let's start by counting the placeholders in the format string:

// countPlaceholders counts single '%' placeholders and skips '%%'.
func (l *OcmLoggerLinter) countPlaceholders(s string) int {
    n := 0
    for i := 0; i < len(s); i++ {
        if s[i] != '%' {
            continue
        }
        // skip "%%"
        if i+1 < len(s) && s[i+1] == '%' {
            i++
            continue
        }
        n++
    }
    return n
}

Now we can verify that the number of arguments passed to the logger matches the number of placeholders in the format string:

func (l *OcmLoggerLinter) run(pass *analysis.Pass) (any, error) {
    loggerMethods := map[string]struct{}{
        "Debug": {}, "Info": {}, "Warn": {}, "Error": {}, "Fatal": {},
    }

    for _, file := range pass.Files {
        ast.Inspect(file, func(n ast.Node) bool {
            // .. cut ..
            // ...
            // ...
            // Compare placeholders vs variadic args after ctx and format.
            want := l.countPlaceholders(fmtStr)
            // Don't count the context and format string parameters
            got := len(call.Args) - 2

            if want != got {
                pass.Reportf(call.Pos(),
                    "number of format placeholders (%d) does not match number of arguments (%d)",
                    want, got)
            }
            return true
        })
    }
    return nil, nil
}

Wile E
Is that everything?

Road
Not yet. We still need to register the plugin, which is straightforward. We create a builder function and register it in the plugin module using an init method:

func NewOcmLinter(any) (register.LinterPlugin, error) { return &OcmLoggerLinter{}, nil }

func init() { register.Plugin("ocmlogger", NewOcmLinter) }

That's it for the Go code. Now we need to configure golangci-lint. First, create a .custom-gcl.yml file in the project root with the following content:

version: v2.1.6

name: ocm-lint

destination: ./bin

plugins:
  - module: 'github.com/openshift-online/ocm-sdk-go'
    import: 'github.com/openshift-online/ocm-sdk-go/linters'
    path: .

Let me explain the content of the configuration file:

  • version: The version of golangci-lint being used
  • name: The name of the new golangci-lint executable to create. This executable will embed our custom plugin
  • destination: The directory where the generated binary (ocm-lint) will be placed
  • plugins: The list of custom plugins to include in the build
    • module: The Go module that contains our plugin
    • import: The package containing our plugin implementation
    • path: The filesystem path where the plugin source code can be found

Next, we need to activate the plugin into the .golangci.yml file by editing the linters section.

linters:
  settings:
    custom:
      ocmlogger:
        type: "module"
        description: >
          Verifies that calls to the OCM logger (such as logger.Info, logger.Warn, etc.) use format strings correctly.
          It ensures that the number of format specifiers (e.g. %s, %v, %d, ...) in the log message matches the number of
          arguments passed. The check only applies to receivers of type
          github.com/openshift-online/ocm-sdk-go/logging.Logger (or a pointer to it).
          You can disable the warning on a specific line using either //nolint:ocmlogger or // ocm-linter:ignore.
  enable:
    - ocmlogger

Wile E.
Can we finally run the new plugin?

Road
It seems like a lot of things to do, but it is very simple.. Now we just have to build the new binary with the command

golangci-lint custom

That command will place an executable called ocm-lint in the ./bin folder.

Now we can run  ./bin/ocm-lint  instead of  golangci-lint . It includes all the standard features plus our custom plugin.

Wile E.
Let me try!

./bin/ocm-lint run

leadership/flag.go:325:6: number of format placeholders (1) does not match number of arguments (3) (ocmlogger)
                                        f.logger.Error(
                                        ^
leadership/flag.go:385:4: number of format placeholders (2) does not match number of arguments (3) (ocmlogger)
                        f.logger.Error(
                        ^
leadership/flag.go:466:4: number of format placeholders (3) does not match number of arguments (2) (ocmlogger)
                        f.logger.Error(
                        ^
3 issues:
* ocmlogger: 3

That’s fantastic, it detected all the issues! But now I have another question: how do I write unit tests for a linter?

Road
Hey Wile, I’m quite tired now. Let’s talk about that tomorrow.

Wile E.
Ok… Can you at least explain how to handle comments to disable the linter on certain lines?

Road
That’s another interesting topic. Let’s discuss that in the near future.

That's all, folks!

Want to see the complete implementation? The full source code for this custom linter is available on GitHub:

🔗 github.com/ziccardi/GOLANG-BLOG/tree/main/custom-linter

Clone the repository and follow the instructions in the README to build and run the linter on your own projects.