Skip to main content Alex Collie's blog

Making a Linter From Scratch

Overview

The usefulness of linters should not be understated, they act as a lightweight form of PR review.
In fact, the Google SRE guide suggests that “nitpick” comments should be left to linters.
Linters help prevent obvious bugs and serve as guardians of code quality.

Basics of the Lesson

Linters are usually language-specific. For example, Revive for Go.
They often rely on the Abstract Syntax Tree (AST) exposed by the compiler.
In fact, it’s the same AST the Go compiler uses to compile Go code (and yes, the Go compiler itself is written in Go).

Each function in your source code is broken down into syntax nodes, which can then be cast to specific types and analyzed.

In this lesson, we’ll build a simple linter that:

  1. Prevents function names longer than 20 characters.
  2. Flags functions with more than 60 statements.
  3. Ensures all float variables have a prefix of f32 or f64.

Implementation

Setup

  1. Create a folder called demo.
  2. Create a file called foo.go inside demo with the following content:

go code snippet start

package demo

import (
	"fmt"
	"time"
)

var (
	waitTime = 10
)

func messageProcessor(message string, currentTime float32) error {
	fmt.Println("CurrentTime", currentTime)
	fmt.Println("message", message)
	return nil
}

func ProcessAllMessages(inputs []string) {
	for _, value := range inputs {
		err := messageProcessor(value, float32(time.Now().Unix()))
		if err != nil {
			fmt.Println("Err %w", err)
		}
	}
}

go code snippet end

This demo is a bit abstract, but it’ll do for our purposes.

go code snippet start

	fSet := token.NewFileSet()
	file, err := parser.ParseFile(fSet, "demo/foo.go", nil, parser.ParseComments)
	if err != nil {
		panic(err)
	}

go code snippet end

This simply allows the AST to read the file and parse the results. If the file contains invalid Go code, it will panic here — obviously, in a real example, you’d include proper error handling and logging.

Next, we’re going to walk the AST tree. You’ll often hear the term walking the tree when working with ASTs — it just means visiting each node in the tree.

go code snippet start

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

go code snippet end

This is the most basic structure for walking a tree.

However, let’s look at all the function definitions first:

go code snippet start

	ast.Inspect(file, func(node ast.Node) bool {
		switch node.(type) {
		case *ast.FuncDecl:
			funcCall, ok := node.(*ast.FuncDecl)
			if ok {
				fmt.Println(funcCall.Name)
			}
		}
		return true
	})

}

go code snippet end

Output

go code snippet start

messageProcessor
ProcessAllMessages

go code snippet end

I hope this example helps you see the power of ASTs. For example, you can build a dependency graph from this. Now, adding logic for function definitions becomes trivial.

go code snippet start

func checkFunctionNameLength(funcDecl *ast.FuncDecl, maxLength int) bool {
	return len(funcDecl.Name.Name) > maxLength
}

go code snippet end

What about checking the number of statements in a function? Luckily, a function exposes its statements directly, so we can simply count them.

go code snippet start

func checkFunctionLength(funcDecl *ast.FuncDecl, maxLength int) bool {
	return len(funcDecl.Body.List) > maxLength
}

go code snippet end

The next check is a little arbitrary, but it shows how to deal with different types of nodes.

go code snippet start

func ensureSuffixForFloatVariables(node ast.Node) bool {
	assignStmt, ok := node.(*ast.AssignStmt)
	if !ok {
		return true
	}
	callExpr, ok := assignStmt.Rhs[0].(*ast.CallExpr)
	if !ok {
		return true
	}

	ident, ok := callExpr.Fun.(*ast.Ident)
	if !ok {
		return true
	}

	switch ident.Name {
	case "float32", "float64":
		fmt.Printf("Found assignment with float type conversion at position %d\n", callExpr.Pos())
		// You could add logic here to check variable name suffixes on the LHS
		if lhsIdent, ok := assignStmt.Lhs[0].(*ast.Ident); ok {
			fmt.Printf("  Variable name: %s\n", lhsIdent.Name)
		}
	}
	return false
}

go code snippet end

This is a little crude, but it shows how complex it can get to handle types. Additionally, this should ideally inspect the actual types rather than just identifier names.

Next steps

These examples are quite basic and don’t handle many edge cases (for example, variables declared inside function parameters). However, they’re a good starting point for understanding how ASTs work and how to build a simple linter.

If you’re curious, I submitted a pull request to the Revive linter here