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:
- Prevents function names longer than 20 characters.
- Flags functions with more than 60 statements.
- Ensures all float variables have a prefix of
f32orf64.
Implementation
Setup
- Create a folder called
demo. - Create a file called
foo.goinsidedemowith 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
ProcessAllMessagesgo 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