Build a custom Go linter in 5 minutes

Search for a command to run...

No comments yet. Be the first to comment.
Up to this point, we've been using simple test databases where the data for each row fits within a single page. However, in the wild, it is quite common for a row to be larger than a single page (typically 4096 bytes), especially when using variable-...

The frontend part of our compiler is complete, and we can parse the source code of a pylite program into an AST. This leaves us with a final task: translating the program described by the AST into assembly code. Technically, we could generate assembl...

It has become common practice to start with a "Hello World" program when learning a new programming language. This is a simple program that outputs the text "Hello, World!" to the screen. While writing such a program is a trivial task with most prog...

Compilers are frustrating. Two decades ago, during a boring afternoon in my teenage room, I set out to discover how the software and games I spent so many hours with were made. I plunged into a Google Search rabbit hole that would yield a puzzling a...

In the previous posts, we've explored the SQLite file format and built a simple SQL parser. It's time to put these pieces together and implement a query evaluator! In this post, we'll lay the groundwork for evaluating SQL queries and build a query ev...

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.
If sylver --version doesn't output a version number >= 0.1.8, go to https://sylver.dev to download a fresh copy of the software.
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).
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;
For our second linting rule, we'd like to identify assignments that could be simplified by using an assignment operator (like +=) such as:
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 };
make builtin functionFor 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:
fun is makeLet'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();
The following ruleset uses our linting rules:
id: customLinter
language: "https://github.com/sylver-dev/golang.git#golang.yaml"
rules:
- id: largeStruct
message: struct has many fields
category: style
query: match StructType s when s.fields.length > 10
- id: assignOp
message: assignment should use an assignment operator
category: style
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
category: bug
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