Build a custom Go linter in 5 minutes
Tags: go
Published: 2022-09-23 14:37:29

Creating a custom linter can be a great way to enforce coding standards and detect code smells. In this tutorial, we'll use Sylver's, a source code query engine to build a custom Golang linter in just a few lines of code.

Sylver's main interface is a REPL console, in which we can load the source code of our project to query it using a SQL-like query language called `SYLQ`. Once we'll have authored `SYLQ` queries expressing our linting rules, we'll be able to save them into a ruleset that can be run like a traditional linter.

# Installation

If `sylver --version` doesn't output a version number >= `0.1.8`, go to [https://sylver.dev](https://sylver.dev) to download a fresh copy of the software.

# Starting the REPL

Starting the REPL is as simple as invoking the following command at the root of your project:
```
sylver query --files="**/*.go" --spec=https://github.com/sylver-dev/golang.git#golang.yaml
```

The REPL can be exited by pressing `Ctrl+C` or typing `:quit` at the prompt.

We can now execute `SYLQ` queries by typing the code of the query, followed by a `;`.
For instance: to retrieve all the struct declarations:
```
match StructType;
```

The results of the query will be formatted as follow:
```
[...]
$359 [StructType association.go:323:17-327:1]
$360 [StructType schema/index.go:10:12-18:1]
$361 [StructType schema/index.go:20:18-27:1]
$362 [StructType tests/group_by_test.go:70:12-73:2]
$363 [StructType schema/check.go:11:12-15:1]
```

The code of a given struct declaration can be displayed by typing `:print` followed by the node alias (for instance: `:print $362`). The parse tree can be displayed using the `:print_ast` command (for instance: `:print_ast $362`).

## Rule1: detect struct declarations with too many fields
For our first rule, we'd like to flag struct declarations that have more than 10 fields.
The first step is to get familiar with the tree structure of struct declarations, so let's print a `StructType` along with its ast:

```
λ> :print $362

struct {
		Name  string
		Total int64
	}

λ> :print_ast $362

StructType {
. ● fields: List<FieldSpec> {
. . FieldSpec {
. . . ● names: List<Identifier> {
. . . . Identifier { Name }
. . . }
. . . ● type: TypeIdent {
. . . . ● name: Identifier { string }
. . . }
. . }
. . FieldSpec {
. . . ● names: List<Identifier> {
. . . . Identifier { Total }
. . . }
. . . ● type: TypeIdent {
. . . . ● name: Identifier { int64 }
. . . }
. . }
. }
}
```

The fields of the struct are stored in a field aptly named `fields` that holds a list of `FieldSpec` nodes. This means that the nodes violating our rule are all the `StructType` nodes for which the `fields` list has a length higher than 10.
This can be easily expressed in `SYLQ`:

```
 match StructType s when s.fields.length > 10;
```

## Rule2: suggest the usage of assignment operators

For our second linting rule, we'd like to identify assignments that could be simplified by using an assignment operator (like `+=`) such as: 
```go
x = x + 1
```

Let's explore the parse tree of a simple assignment:

```
λ> :print $5750

err = nil

λ> :print_ast $5750

AssignStmt {
. ● lhs: List<Expr> {
. . Identifier { err }
. }
. ● rhs: List<Expr> {
. . NilLit { nil }
. }
}
```

So we want to retrieve the `AssignStmt` nodes for which the `rhs` field contains a `Binop` that has `lhs` as its left operand. Also, the left-hand side of the assignment must contain a single expression. This can be written as:
```
match AssignStmt a when
      a.lhs.length == 1
   && a.rhs[0] is { BinOp b when b.left.text == a.lhs[0].text };
```

## Rule3: incorrect usage of the `make` builtin function

For our last linting rule, we want to identify incorrect usage of the `make` function, where the length is higher than the capacity, as this probably indicates a programming error.

Here is the parse tree of a call to make:
```
λ> :print $16991

make([]string, 0, len(value))

λ> :print_ast $16991

CallExpr {
. ● fun: Identifier { make }
. ● args: List<GoNode> {
. . SliceType {
. . . ● elemsType: TypeIdent {
. . . . ● name: Identifier { string }
. . . }
. . }
. . IntLit { 0 }
. . CallExpr {
. . . ● fun: Identifier { len }
. . . ● args: List<GoNode> {
. . . . Identifier { value }
. . . }
. . }
. }
}

```

Here are the conditions that violating nodes will meet:
* The test of `fun` is `make`
* The args list contains 3 elements
* The last two arguments are int literals
* The third argument (capacity) is smaller than the second (length)

Let's encode this in `SYLQ`:
```
match CallExpr c when
      c.fun.text == 'make'
   && c.args.length == 3
   && c.args[1] is IntLit
   && c.args[2] is IntLit
   && c.args[2].text.to_int() < c.args[1].text.to_int();
```

## Creating the ruleset

The following ruleset uses our linting rules:
```yaml
id: customLinter

language: "https://github.com/sylver-dev/golang.git#golang.yaml"

rules:
    - id: largeStruct
      message: struct has many fields
      severity: info

      query:  match StructType s when s.fields.length > 10


    - id: assignOp
      message: assignment should use an assignment operator
      severity: warning
      note: According to our style guide, assignment operators should be preferred.

      query: >
        match AssignStmt a when
             a.lhs.length == 1
          && a.rhs[0] is { BinOp b when b.left.text == a.lhs[0].text }

    - id: makeCapacityErr 
      message: capacity should be higher than length     
      severity: error

      query: >
        match CallExpr c when
              c.fun.text == 'make'
          && c.args.length == 3
          && c.args[1] is IntLit
          && c.args[2] is IntLit
          && c.args[2].text.to_int() < c.args[1].text.to_int()
```

Assuming that it is stored in a file called `custom_linter.yaml` at the root of our project, we can run it with the following command:
```
sylver ruleset run --files="**/*.go" --rulesets=custom_linter.yaml
```