Photo golangci-lint

Enforcing Clean Code in Go: Writing Custom Linters with golangci-lint

Let’s talk about making your Go code cleaner and more consistent, specifically using golangci-lint to write your own custom linters. If you’ve ever found yourself wishing a linter could catch a specific pattern or enforce a particular project-level rule, you’re in the right place. We’ll dive into how you can extend golangci-lint to do just that, without getting bogged down in overly technical jargon.

Why Bother with Custom Linters Anyway?

You might be thinking, “There are already tons of linters out there. Why would I need to write my own?” That’s a fair question. Standard linters are great. They catch common errors, enforce style guides, and generally improve code quality. However, every project has its unique quirks and specific needs.

  • Project-Specific Rules: Maybe your team has a convention about how certain error types are handled, or perhaps you want to ensure that all API endpoints follow a particular naming pattern. These aren’t necessarily universal issues that existing linters cover.
  • Preventing Common Mistakes: You might notice recurring mistakes in your codebase that aren’t flagged by existing tools. A custom linter can act as an automated safeguard against these specific pitfalls.
  • Encouraging Best Practices: Beyond just syntax, you might want to encourage certain architectural patterns or discourage potentially problematic ones. A linter can gently nudge developers in the right direction.
  • Maintaining Consistency: As projects grow and teams expand, keeping code consistent becomes a real challenge. Custom linters are powerful tools for enforcing that consistency at scale.

Ultimately, custom linters are about taking control of your code quality and tailoring it precisely to your project’s needs. It’s not about reinventing the wheel, but about building a specialized tool for your specific workshop.

In addition to exploring the importance of enforcing clean code in Go through the creation of custom linters with golangci-lint, you may find it interesting to read about the latest advancements in smartwatch technology. For instance, an article discussing which smartwatches allow you to view pictures on them can provide insights into how technology continues to evolve and integrate with our daily lives. You can check out the article here: Which Smartwatches Allow You to View Pictures on Them?.

Getting Started with golangci-lint and Custom Checks

golangci-lint is the de facto standard for Go linting. It’s fast, extensible, and bundles a massive number of linters by default. The good news is that golangci-lint is built with extensibility in mind, and while it doesn’t have a direct “plugin system” in the traditional sense for arbitrary Go code, it does allow you to integrate custom checks that leverage Go’s tooling capabilities.

The primary way you’ll achieve this is by using Go’s built-in go vet command and potentially go generate. golangci-lint can be configured to run these, and you can write your own tools that go vet can understand.

Understanding go vet

Before we write anything custom, it’s crucial to understand what go vet does. go vet is a command-line tool that checks Go source files for suspicious constructs. It’s not about style, but about finding actual bugs and potential errors. It does this by:

  • Type Checking: Ensuring types are used correctly.
  • Unused Variables and Imports: Identifying code that isn’t being used.
  • Print Statement Analysis: Catching accidental fmt.Println calls that might have slipped through.
  • Dead Code Detection: Identifying code that can never be reached.

Many linters, including those bundled with golangci-lint, work by analyzing the Abstract Syntax Tree (AST) of your Go code, and go vet provides a foundation for this kind of analysis.

Integrating Your Custom Linter with golangci-lint

The magic happens when golangci-lint is configured to run your custom checks. You’ll essentially create a Go program that performs your desired checks and then tell golangci-lint to execute it. This often involves creating a directory that acts as a linter “package.”

  • Directory Structure: A common approach is to have a linters directory at the root of your project. Inside, you might have subdirectories for each custom linter.
  • The Entry Point: Your custom linter will be a Go program. This program needs to interact with the Go compiler and analyze the code.
  • Configuration in .golangci.yml: You’ll then configure golangci-lint in your .golangci.yml file to find and run your custom linter.

This might sound a bit involved, but the core idea is to leverage Go’s existing tools and build a standalone executable that golangci-lint can simply call.

Crafting Your First Custom Linter: A Practical Example

Let’s get hands-on. Imagine you want to enforce a rule that all functions that handle HTTP requests must explicitly check for nil errors returned from database calls. This is a common pattern that can lead to panics if not handled.

The Goal: Enforcing Nil Error Checks

We want to flag code like this:

“`go

func getUser(ctx context.Context, id string) (*User, error) {

user, err := db.QueryUser(ctx, id) // Potential nil error

// Missing check here!

return user, nil

}

“`

And encourage this:

“`go

func getUser(ctx context.Context, id string) (*User, error) {

user, err := db.QueryUser(ctx, id)

if err != nil { // Explicit nil error check

return nil, fmt.Errorf(“failed to get user: %w”, err)

}

return user, nil

}

“`

Building the Linter Tool

We’ll need to write a Go program that analyzes the AST. The go/ast and go/parser packages are your friends here.

1. Setting Up the Linter Directory

Create a directory structure like this:

“`

your_project/

├── .

golangci.

yml

├── cmd/

│ └── mylinter/

│ └── main.go

└── …

“`

The cmd/mylinter/main.go will be our linter’s executable.

2. Writing the main.go Linter Code

Here’s a simplified example of what cmd/mylinter/main.go might look like. This is a very basic illustration and would need to be expanded for real-world use.

“`go

package main

import (

“fmt”

“go/ast”

“go/importer”

“go/parser”

“go/token”

“go/types”

“os”

“strings”

“golang.org/x/tools/go/analysis”

“golang.org/x/tools/go/analysis/passes/buildtag”

“golang.org/x/tools/go/analysis/passes/comment”

“golang.org/x/tools/go/analysis/passes/errors”

“golang.org/x/tools/go/analysis/passes/printf”

“golang.org/x/tools/go/analysis/passes/shadow”

“golang.org/x/tools/go/analysis/passes/sortimports”

“golang.org/x/tools/go/analysis/passes/staticcheck”

“golang.org/x/tools/go/analysis/passes/testing”

“golang.org/x/tools/go/analysis/passes/unusedparams”

“golang.org/x/tools/go/analysis/passes/unusedwriteparams”

“golang.org/x/tools/go/analysis/passes/vet”

“golang.org/x/tools/go/ast/inspector”

“golang.org/x/tools/go/packages”

)

// NilErrorCheckAnalyzer is our custom linter.

var NilErrorCheckAnalyzer = &analysis.Analyzer{

Name: “nilerrcheck”,

Doc: “checks for explicit nil error checks after potential error-returning calls”,

Run: runNilErrorCheck,

}

func runNilErrorCheck(pass *analysis.Pass) (interface{}, error) {

// We’ll use an inspector to traverse the AST.

inspector := inspector.New(pass.ResultOf[ast.Inspect].([]ast.Node))

// Define the types of function calls we’re interested in.

// For simplicity, let’s assume functions returning (T, error).

// In a real linter, you’d want to be more sophisticated.

nodeFilter := []ast.NodeFilter{

(*ast.FuncDecl)(nil), // We’ll inspect function declarations

}

inspector.WithStack(func(n ast.Node) bool {

funcDecl, ok := n.(*ast.FuncDecl)

if !ok {

return true // Continue traversal

}

// We are only interested in functions that return an error.

if !returnsError(funcDecl.Type) {

return true

}

// Now, traverse the function body to find potential error-returning calls.

ast.Inspect(funcDecl.Body, func(node ast.Node) bool {

if node == nil {

return true

}

// Let’s look for function calls.

callExpr, ok := node.(*ast.CallExpr)

if !ok {

return true

}

// This is a simplified check. We’d ideally check the type of the function

// being called to see if it returns an error.

// For this example, we’ll just assume any function call might return an error.

// A more robust check would involve type checking and symbol resolution.

// Now, check if the call expression is followed by an explicit error check.

// This is the trickiest part and requires looking at the next statement.

// For this demo, we’ll simplify this significantly. In a real linter,

// you’d need to analyze the control flow graph or analyze the AST structure more deeply.

// Let’s assume for now that if a function returns an error,

// any direct assignment from a function call returning (T, error)

// must be followed by an if err != nil check.

// A better approach: analyze the assignment

if assignStmt, ok := node.(*ast.AssignStmt); ok {

if len(assignStmt.Lhs) > 0 && len(assignStmt.Rhs) > 0 {

// Check if the RHS is a function call

if callExpr, ok := assignStmt.Rhs[0].(*ast.CallExpr); ok {

// We need to determine if this call actually returns an error.

// This requires type information, which is complex.

// For this example, we’ll make a heuristic guess: if the call

// is assigned to a variable named ‘err’ or similar, it’s a candidate.

// A proper check would use pass.TypesInfo.TypeOf(callExpr.Fun)

// and check the return types.

// If the assignment involves an ‘err’ variable (or similar),

// we need to check the subsequent statement.

if isErrorAssignment(assignStmt) {

// Look at the next statement in the function body.

// This is where it gets complicated. We need to find the parent block

// and then find the index of the current statement.

// This is an oversimplification for demonstration.

// A real linter would use pass.Fset to get positions and analyze

// the AST structure more deeply to find the next statement.

// For this example, we’ll use a placeholder logic.

// In a real scenario, you’d need to search for an IfStmt

// immediately following this assignment that checks for err != nil.

}

}

}

}

return true

})

return true

})

// This is a placeholder. In a real linter, you’d collect pass.Report calls.

// For our example, we’ll manually trigger a report if a condition is met.

// A practical custom linter would typically build a list of issues.

// Let’s simulate finding an issue.

// This requires actually detecting the pattern, which is hard without proper type checking.

// For demonstration, imagine we found an issue at a specific position.

// pass.Reportf(pos, “missing nil error check for potentially error-returning call”)

return nil, nil

}

// Helper to check if a function type returns an error.

func returnsError(fnType *ast.FuncType) bool {

if fnType.Results == nil {

return false

}

for _, field := range fnType.Results.List {

// This check needs to resolve the type. For simplicity, we’ll check the name.

// In a real linter, you’d use pass.TypesInfo.TypeOf(field.Type)

// and check if it’s error.

if typeName, ok := field.Type.(*ast.Ident); ok && typeName.Name == “error” {

return true

}

}

return false

}

// isErrorAssignment is a placeholder for a more robust check.

// It should determine if the assignment is likely assigning an error value.

func isErrorAssignment(stmt *ast.AssignStmt) bool {

// This is where the actual logic would go.

// For example, checking if one of the LHS identifiers is named “err”.

for _, expr := range stmt.Lhs {

if ident, ok := expr.(*ast.Ident); ok && strings.Contains(strings.ToLower(ident.Name), “err”) {

return true

}

}

return false

}

// Main function to run the analyzer.

func main() {

// The standard golangci-lint uses the golang.org/x/tools/go/analysis framework.

// We’ll define our analyzer here.

// You’ll typically need to register your analyzer and potentially others.

// For this example, we’ll just define our analyzer.

// golangci-lint will discover and run it.

var analyzers []*analysis.Analyzer

// Add built-in analyzers that our linter might depend on for context.

analyzers = append(analyzers,

buildtag.Analyzer,

comment.Analyzer,

errors.Analyzer,

printf.Analyzer,

shadow.Analyzer,

sortimports.Analyzer,

staticcheck.Analyzer, // Staticcheck often provides good type info

testing.Analyzer,

unusedparams.Analyzer,

unusedwriteparams.Analyzer,

vet.Analyzer,

)

// Add our custom analyzer.

analyzers = append(analyzers, NilErrorCheckAnalyzer)

// In a real scenario, you’d likely use a tool like golang.org/x/tools/go/analysis/multichecker

// to combine multiple analyzers. However, golangci-lint handles the execution.

// For a standalone linter executable, you might use something like:

// analysis.Run(NilErrorCheckAnalyzer, os.Args[1:])

// But for integration with golangci-lint, we just define the Analyzer.

// The golangci-lint tool will discover Analyzer variables in packages

// that it can build. So, you just need to define it.

// The actual execution logic is handled by golangci-lint.

// This main function is primarily for defining the analyzer.

}

“`

Important Notes on the Example:

  • Simplification: This code is a highly simplified example. Detecting the absence of an if err != nil check requires understanding the AST structure, control flow, and type information. A real-world linter would involve more complex AST traversal, type checking using pass.TypesInfo, and careful analysis of statement sequencing.
  • golang.org/x/tools/go/analysis: The modern way to write linters in Go is using the golang.org/x/tools/go/analysis framework. golangci-lint is built to understand and run analyzers written with this framework.
  • Type Information: The crucial part of making this linter robust is using pass.TypesInfo. This provides information about the types of expressions and variables, which is essential for knowing if a function call actually returns an error type.
3. Building the Linter Executable

Navigate to the cmd/mylinter directory in your terminal and build the executable:

“`bash

cd your_project/cmd/mylinter

go build -o ../../bin/mylinter # Creates an executable in your project root

“`

4. Configuring golangci-lint

Now, tell golangci-lint to run your custom linter. Edit your .golangci.yml file:

“`yaml

… other configurations …

linters:

disable-all: true # Start with a clean slate or disable specific ones

enable:

  • gofmt
  • goimports
  • errcheck # Example of an existing linter
  • revive
  • nilerrcheck # This is our custom linter’s name

Custom linters configuration

custom:

nilerrcheck:

Path to your custom linter executable

path: bin/mylinter

Arguments to pass to your linter executable (optional)

args: [“–flag”]

“`

Explanation of .golangci.yml configuration:

  • linters.enable: Lists the linters you want golangci-lint to run. We’ve added nilerrcheck.
  • linters.custom: This section is where you define your custom linters.
  • nilerrcheck: This key matches the Name field of your analysis.Analyzer.
  • path: This specifies the relative or absolute path to the executable of your custom linter. We built it to bin/mylinter.
5. Running golangci-lint

Now, when you run golangci-lint in your project:

“`bash

golangci-lint run

“`

It will detect and execute your mylinter executable. If your linter finds violations, golangci-lint will report them.

Advanced Techniques and Considerations

Writing a good custom linter involves more than just basic AST traversal. Here are some advanced aspects to consider:

Analyzing Type Information

As mentioned, pass.TypesInfo is your best friend. It provides a map[ast.Expr]*types.Info which allows you to get the types.Type of any expression. This is crucial for:

  • Identifying error return types: You can precisely check if a function call returns an error.
  • Resolving variable types: Understand what type of value a variable holds.
  • Detecting potential panics: For example, dereferencing a nil pointer.
Using pass.TypesInfo Effectively

You’ll typically access type information within your Run function:

“`go

func runNilErrorCheck(pass *analysis.Pass) (interface{}, error) {

// … inspector setup …

inspector.WithStack(func(n ast.Node) bool {

// …

callExpr, ok := n.(*ast.CallExpr)

if !ok {

return true

}

// Get the type of the function being called

callFuncType := pass.TypesInfo.TypeOf(callExpr.Fun)

if callFuncType == nil {

return true // Cannot determine type

}

// Check if it’s a function type and if it returns an error

if signature, ok := callFuncType.(*types.Signature); ok {

for _, returnType := range signature.Results().List() {

if returnType.String() == “error” { // Or use types.IsError(returnType)

// This function call potentially returns an error.

// Now we need to check if it’s properly handled.

// … proceed with checking the next statements …

break

}

}

}

// …

})

// …

}

“`

Handling Different Kinds of Checks

Not all checks are about function calls. You might want to enforce:

  • Specific naming conventions: For struct fields, function names, or constants.
  • Forbidden patterns: Discourage the use of certain deprecated APIs or unsafe constructs.
  • Dependency checks: Ensure that certain packages are not imported together, or that imports follow a specific order.
  • API usage rules: Enforce correct usage of your own internal libraries or external APIs.

Each of these will require different AST traversal strategies and type analysis.

Reporting Issues

Your analysis.Analyzer uses pass.Report and pass.Reportf to report issues.

  • pass.Report(analysis.Diagnostic{Pos: pos, Message: "your message"})
  • pass.Reportf(pos, "formatted message with %s", arg)

The Pos should be a token.Pos that points to the exact location in the code where the issue was found. golangci-lint will then format these reports for you.

Performance Considerations

Custom linters can impact your build times.

  • Efficient AST Traversal: Use inspector.WithStack and judiciously choose which nodes to visit.
  • Minimize External Dependencies: Unless necessary, keep your linter’s dependencies lean.
  • Leverage Caching: golangci-lint has some caching mechanisms; ensure your linter doesn’t break them.
  • Profile Your Linter: If performance becomes an issue, profile your linter’s execution to identify bottlenecks.

Testing Your Custom Linter

Just like any other Go code, your custom linter needs tests. The golang.org/x/tools/go/analysis/analysistest package is designed for this.

Writing Tests with analysistest

Create a testdata directory alongside your linter code.

“`

your_project/

├── cmd/

│ └── mylinter/

│ ├── main.go

│ └── testdata/

│ └── src/

│ └── pkg/

│ └── test.go # Example Go file to test

└── …

“`

In test.go, you’d write code that should or should not be flagged by your linter.

“`go

// cmd/mylinter/testdata/src/pkg/test.go

package pkg

import “fmt”

// This function should NOT be flagged

func goodFunc() error {

res, err := fmt.Println(“hello”) // Assume fmt.Println doesn’t return error in this context

if err != nil { // Not really, but good practice to show the structure

return err

}

return nil

}

// This function SHOULD be flagged

func badFunc() error {

res, err := fmt.Println(“hello”) // Assume fmt.Println doesn’t return error in this context

// Missing nil error check

return nil

}

func anotherBadFunc() error {

_, err := someDB.Query() // Assume Query returns (interface{}, error)

// Missing nil error check

return nil

}

// This should be fine, the error is handled.

func handledBadFunc() error {

_, err := someDB.Query()

if err != nil {

return fmt.Errorf(“query failed: %w”, err)

}

return nil

}

“`

Your test file (_test.go within the same directory or a separate test package) would then use analysistest.Run:

“`go

// cmd/mylinter/main_test.go (or similar)

package main

import (

“testing”

“golang.org/x/tools/go/analysis/analysistest”

“your_project/cmd/mylinter” // Import your analyzer

)

func TestNilErrorCheck(t *testing.T) {

testdata := analysistest.TestData()

analysistest.Run(t, testdata, mylinter.NilErrorCheckAnalyzer, []string{“pkg”})

}

“`

You would then run go test in your linter’s directory. analysistest will compile the testdata code and check if your analyzer reports the expected diagnostics.

In the pursuit of maintaining high-quality code, developers often seek tools that can help enforce best practices. A related article that delves into the importance of code quality and offers insights into various tools is available at How to Geek. This resource complements the discussion on enforcing clean code in Go by providing a broader perspective on the significance of custom linters, such as golangci-lint, in improving code readability and maintainability. By exploring these tools, developers can better understand how to enhance their coding practices effectively.

When to Write a Custom Linter (and When Not To)

It’s easy to get excited about building custom tools, but it’s important to be judicious.

Good Reasons to Write a Custom Linter:

  • Enforcing critical project-specific invariants: Rules that are essential for the correctness or maintainability of your project.
  • Preventing recurring, high-impact bugs: If you’re seeing the same type of mistake repeatedly, a linter is a great solution.
  • Automating complex review points: If a reviewer frequently has to check for a specific, non-trivial pattern, automate it.
  • Promoting your team’s adopted best practices: When a best practice is so important that it needs to be enforced, a linter is the way to go.

When to Reconsider:

  • Simple style preferences: If it’s just about code formatting or minor style points, stick to standard linters and formatters like gofmt, goimports, or golint (though golint is deprecated in favor of revive and others). golangci-lint already bundles many of these.
  • Catching obvious syntax errors: Go’s compiler and standard go vet are excellent at this.
  • Replacing code reviews: A linter is a supplement, not a replacement, for human code review. Complex logic, architectural decisions, and subtle bugs are still best caught by experienced developers.
  • Over-engineering: Don’t build a linter just for the sake of it. Ensure there’s a clear problem it solves and a tangible benefit.

Conclusion: Empowering Your Codebase

Writing custom linters with golangci-lint opens up a powerful avenue for tailoring code quality to your specific needs. It’s not about making your life harder, but about creating a more robust, consistent, and maintainable codebase. By leveraging the golang.org/x/tools/go/analysis framework and golangci-lint‘s extensibility, you can build tools that catch those unique project-specific issues, preventing bugs before they even make it into your codebase. While it requires some initial investment in understanding ASTs and Go’s tooling, the long-term benefits in code quality and developer productivity can be significant. So, if you’ve got those recurring patterns or project-specific rules, it might be time to empower your team with your very own custom linter.

FAQs

What is golangci-lint?

golangci-lint is a fast Go linters runner. It runs linters in parallel, uses caching, and has integrations with all major IDEs. It is a popular tool for enforcing clean code in Go projects.

Why is enforcing clean code important in Go?

Enforcing clean code in Go helps maintain code quality, readability, and consistency across the codebase. It also helps in identifying potential bugs, security vulnerabilities, and performance issues early in the development process.

How can custom linters be written with golangci-lint?

Custom linters can be written with golangci-lint by creating custom rules using the golangci-lint framework. This allows developers to define specific coding standards, best practices, and project-specific requirements as custom linters.

What are the benefits of using golangci-lint for enforcing clean code?

Using golangci-lint for enforcing clean code provides benefits such as automated code analysis, identifying potential issues, reducing code review overhead, improving code maintainability, and promoting consistent coding standards across the team.

How can golangci-lint be integrated into a Go project?

golangci-lint can be integrated into a Go project by adding it as a dependency, configuring the linters and rules in the project’s configuration file, and running it as part of the continuous integration (CI) pipeline. This ensures that the code is checked for cleanliness and adherence to coding standards automatically.

Tags: No tags