State of Golang linters and the differences between them

State of Golang linters and the differences between them


title: State of Golang linters and the differences between them published: true date: 2022-01-18 10:45:27 UTC description: Curious about which linters are available in Golang ecosystem? Here's an introduction of how to get started. tags: golang, linters, golint, staticcheck canonical_url: sourcelevel.io/blog/state-of-golang-linters..

cover_image: sourcelevel.io/wp-content/uploads/golang_li..

Golang is full of tools to help us on developing securer, reliable, and useful apps. And there is a category that I would like to talk about: Static Analysis through Linters.

What is a linter?

Linter is a tool that analyzes source code without the need to compile/run your app or install any dependencies. It will perform many checks in the static code (the code that you write) of your app.

It is useful to help software developers ensure coding styles, identify tech debt, small issues, bugs, and suspicious constructs. Helping you and your team in the entire development flow.

Linters are available for many languages, but let us take a look at the Golang ecosystem.

First things first: how do linters analyze code?

Most linters analyzes the result of two phases:

Lexer

Also known as tokenizing/scanning is the phase in which we convert the source code statements into tokens. So each keyword, constant, variable in our code will produce a token.

Parser

It will take the tokens produced in the previous phase and try to determine whether these statements are semantically correct.

Golang packages

In Golang we have scanner, token, parser, and ast (Abstract Syntax Tree) packages. Let's jump straight to a practical example by checking this simple snippet:

package main

func main() {
    println("Hello, SourceLevel!")
}

Okay, nothing new here. Now we'll use Golang standard library packages to visualize the ast generated by the code above:

import (
    "go/ast"
    "go/parser"
    "go/token"
)

func main() {
    // src is the input for which we want to print the AST.
    src := `our-hello-world-code`

    // Create the AST by parsing src.
    fset := token.NewFileSet() // positions are relative to fset
    f, err := parser.ParseFile(fset, "", src, 0)
    if err != nil {
        panic(err)
    }

    // Print the AST.
    ast.Print(fset, f)
}

Now let's run this code and look the generated AST:

 0  *ast.File {
 1  .  Package: 2:1
 2  .  Name: *ast.Ident {
 3  .  .  NamePos: 2:9
 4  .  .  Name: "main"
 5  .  }
 6  .  Decls: []ast.Decl (len = 1) {
 7  .  .  0: *ast.FuncDecl {
 8  .  .  .  Name: *ast.Ident {
 9  .  .  .  .  // Name content
16  .  .  .  }
17  .  .  .  Type: *ast.FuncType {
18  .  .  .  .  // Type content
23  .  .  .  }
24  .  .  .  Body: *ast.BlockStmt {
25  .  .  .  .  // Body content
47  .  .  .  }
48  .  .  }
49  .  }
50  .  Scope: *ast.Scope {
51  .  .  Objects: map[string]*ast.Object (len = 1) {
52  .  .  .  "main": *(obj @ 11)
53  .  .  }
54  .  }
55  .  Unresolved: []*ast.Ident (len = 1) {
56  .  .  0: *(obj @ 29)
57  .  }
58  }

As you can see, the AST describes the previous block in a struct called ast.Filewhich is compound by the following structure:

type File struct {
    Doc        *CommentGroup   // associated documentation; or nil
    Package    token.Pos       // position of "package" keyword
    Name       *Ident          // package name
    Decls      []Decl          // top-level declarations; or nil
    Scope      *Scope          // package scope (this file only)
    Imports    []*ImportSpec   // imports in this file
    Unresolved []*Ident        // unresolved identifiers in this file
    Comments   []*CommentGroup // list of all comments in the source file
}

To understand more about lexical scanning and how this struct is filled, I would recommend Rob Pike talk.

Using AST is possible to check the formatting, code complexity, bug risk, unused variables, and a lot more.

Code Formatting

To format code in Golang, we can use the gofmt package, which is already present in the installation, so you can run it to automatically indent and format your code. Note that it uses tabs for indentation and blanks for alignment.

Here is a simple snippet from Go by Examples unformatted:

package main

import "fmt"
func intSeq() func() int {
    i := 0

    return func() int {
        i++
        return i
    }
}

func main() {

    nextInt := intSeq()

        fmt.Println(nextInt())
    fmt.Println(nextInt())
          fmt.Println(nextInt())

    newInts := intSeq()
    fmt.Println(newInts())

}

Then it will be formatted this way:

package main

import "fmt"

func intSeq() func() int {
    i := 0

    return func() int {
        i++
        return i
    }
}

func main() {

    nextInt := intSeq()

    fmt.Println(nextInt())
    fmt.Println(nextInt())
    fmt.Println(nextInt())

    newInts := intSeq()
    fmt.Println(newInts())

}

So we can observe that import earned an extra linebreak but the empty line after main function declaration is still there. So we can assume that we shouldn’t transfer the responsibility of keeping your code readable to the gofmt: consider it as a helper on accomplishing readable and maintainable code.

It’s highly recommended to run gofmt before you commit your changes, you can even configure a precommit hook for that. If you want to overwrite the changes instead of printing them, you should use gofmt -w.

Simplify option

gofmt has a -s as Simplify command, when running with this option it considers the following:

An array, slice, or map composite literal of the form:

    []T{T{}, T{}}

will be simplified to:

    []T{{}, {}}

A slice expression of the form:

s[a:len(s)]

will be simplified to:

s[a:]

A range of the form:

for x, _ = range v {...}

will be simplified to:

for x = range v {...}

Note that for this example, if you think that variable is important for other collaborators, maybe instead of just dropping it with _ I would recommend using _meaningfulName instead.

A range of the form:

for _ = range v {...}

will be simplified to:

for range v {...}

Note that it could be incompatible with earlier versions of Go.

Check unused imports

On some occasions, we can find ourselves trying different packages during implementation and just give up on using them. By using [goimports package](pkg.go.dev/golang.org/x/tools/cmd/goimports) we can identify which packages are being imported and unreferenced in our code and also add missing ones:

go install golang.org/x/tools/cmd/goimports@latest

Then use it by running with -l option to specify a path, in our case we’re doing a recursive search in the project:

go imports -l ./..
../my-project/vendor/github.com/robfig/cron/doc.go

So it identified that cron/doc is unreferenced in our code and it’s safe to remove it from our code.

Code Complexity

Linters can be also used to identify how complex your implementation is, using some methodologies as example, let’s start by exploring ABC Metrics.

ABC Metrics

It’s common nowadays to refer to how large a codebase is by referring to the LoC (Lines of Code) it contains. To have an alternate metric to LoC, Jerry Fitzpatrick proposed a concept called ABC Metric, which are compounded by the following:

  • (A) Assignment counts: = , *= , /=, %=, +=, <<=, >>=, &=, ^=, ++, and --
  • (B) Branch counts when: Function is called
  • (C) Conditionals counts: Booleans or logic test (?, <, >, <=, >=, !=, else, and case)

Caution: This metric should not be used as a “score” to decrease, consider it as just an indicator of your codebase or current file being analyzed.

To have this indicator in Golang, you can use [abcgo package](github.com/droptheplot/abcgo):

$ go get -u github.com/droptheplot/abcgo
$ (cd $GOPATH/src/github.com/droptheplot/abcgo && go install)

Give the following Golang snippet:

package main

import (
    "fmt"
    "os"

    "my_app/persistence"
    service "my_app/services"

    flag "github.com/ogier/pflag"
)

// flags
var (
    filepath string
)

func main() {
    flag.Parse()

    if flag.NFlag() == 0 {
        printUsage()
    }

    persistence.Prepare()
    service.Compare(filepath)
}

func init() {
    flag.StringVarP(&filepath, "filepath", "f", "", "Load CSV to lookup for data")
}

func printUsage() {
    fmt.Printf("Usage: %s [options]\n", os.Args[0])
    fmt.Println("Options:")
    flag.PrintDefaults()
    os.Exit(1)
}

Then let’s analyze this example using abcgo:

$ abcgo -path main.go
Source            Func         Score   A   B   C
/tmp/main.go:18   main         5       0   5   1
/tmp/main.go:29   init         1       0   1   0
/tmp/main.go:33   printUsage   4       0   4   0

As you can see, it will print the Score based on each function found in the file. This metric can help new collaborators identify files that a pair programming session would be required during the onboarding period.

Cyclomatic Complexity

Cyclomatic Complexity in another hand, besides the complex name, has a simple explanation: it calculates how many paths your code has. It is useful to indicate that you may break your implementation in separate abstractions or give some code smells and insights.

To analyze our Golang code let use [gocyclo package](github.com/fzipp/gocyclo):

$ go install github.com/fzipp/gocyclo/cmd/gocyclo@latest

Then let’s check the same piece of code that we’ve analyzed in the ABC Metrics section:

$ gocyclo main.go
2 main main main.go:18:1
1 main printUsage main.go:33:1
1 main init main.go:29:1

It also breaks the output based on function name, so we can see that the main function has 2 paths since we’re using if conditional there.

Style and Patterns Checking

To verify code style and patterns in your codebase, Golang already came with [golint](https://github.com/golang/lint) installed. Which was a linter that offer no customization but it was performing recommended checks from the Golang development team. It was archived in mid-2021 and it is being recommended Staticcheck be used as a replacement.

Golint vs Staticcheck vs revive

Before Staticcheck was recommended, we had revive, which for me sounds more like a community alternative linter.

As revive states how different it is from archived golint:

  • Allows us to enable or disable rules using a configuration file.
  • Allows us to configure the linting rules with a TOML file.
  • 2x faster running the same rules as golint.
  • Provides functionality for disabling a specific rule or the entire linter for a file or a range of lines.
  • golint allows this only for generated files.
  • Optional type checking. Most rules in golint do not require type checking. If you disable them in the config file, revive will run over 6x faster than golint.
  • Provides multiple formatters which let us customize the output.
  • Allows us to customize the return code for the entire linter or based on the failure of only some rules.
  • Everyone can extend it easily with custom rules or formatters.
  • Revive provides more rules compared to golint.

Testing revive linter

I think the extra point goes for revive at the point of creating custom rules or formatters. Wanna try it?

$ go install github.com/mgechev/revive@latest

Then you can run it with the following command:

$ revive -exclude vendor/... -formatter friendly ./...

I often exclude my vendor directory since my dependencies are there. If you want to customize the checks to be used, you can supply a configuration file:

# Ignores files with "GENERATED" header, similar to golint
ignoreGeneratedHeader = true

# Sets the default severity to "warning"
severity = "warning"

# Sets the default failure confidence. The semantics behind this property
# is that revive ignores all failures with a confidence level below 0.8.
confidence = 0.8

# Sets the error code for failures with severity "error"
errorCode = 0

# Sets the error code for failures with severity "warning"
warningCode = 0

# Configuration of the `cyclomatic` rule. Here we specify that
# the rule should fail if it detects code with higher complexity than 10.
[rule.cyclomatic]
  arguments = [10]

# Sets the severity of the `package-comments` rule to "error".
[rule.package-comments]
  severity = "error"

Then you should pass it on running revive:

$ revive -exclude vendor/... -config revive.toml -formatter friendly ./...

What else?

As I’ve shown, you can use linters for many possibilities, you can also focus on:

  • Performance
  • Unused code
  • Reports
  • Outdated packages
  • Code without tests (no coverage)
  • Magic number detector

Feel free to try new linters that I didn’t mention here, I’d recommend the archived repository awesome-go-linters.

Where to start?

To start, consider using gofmt before each commit or whenever you remember to run, then try revive. Which linters are you using?