Writing a Linter

Source   Edit  

A linter is a Nim module that declares one or more types: Check and exports a types: Linter value. Like any other Nim module, it can sit next to the config that uses it in your project or be installed with Nimble.

A minimal linter

This is an example linter from the tests:

import std/strutils

import compiler/ast

import lint

#startSettings
type
  VarNamesSettings* = object
    minLength*: int = 3
    maxLength*: int = 12

  ConstNamesSettings* = object
    prefix*: string = "k"
#endSettings

proc runVarNames(ast: PNode, s: VarNamesSettings): seq[Finding] =
  for node in ast.sons:
    if node.kind == nkVarSection:
      for def in node.sons:
        if def[0].kind != nkIdent:
          continue
        let name = def[0].ident.s

        if name == "killTheLinter":
          raise newException(ValueError, "There is a bug in this check")

        if name.len < s.minLength:
          result.add def[0].toFinding(
            "variable `" & name & "` is shorter than " & $s.minLength & " characters"
          )
        elif name.len > s.maxLength:
          result.add def[0].toFinding(
            "variable `" & name & "` is longer than " & $s.maxLength & " characters"
          )

#startRunConstNames
proc runConstNames(ast: PNode, s: ConstNamesSettings): seq[Finding] =
  if s.prefix.len == 0:
    return
  for node in ast.sons:
    if node.kind == nkConstSection:
      for def in node.sons:
        if def[0].kind != nkIdent:
          continue
        let name = def[0].ident.s
        if not name.startsWith(s.prefix):
          result.add def[0].toFinding(
            "constant `" & name & "` should start with `" & s.prefix & "`"
          )
#endRunConstNames

let
  nameLinter* = Linter()
  varNames* = nameLinter.addCheck(runVarNames)
  constNames* = nameLinter.addCheck(runConstNames)

Three things make this a linter:

Checks receive the parsed file as a PNode from Nim's own compiler AST module (compiler/ast). Lint neither converts the tree nor provides a tree type of its own: the node a check walks is exactly the node the parser produced.

Writing a check means importing compiler/ast and matching on nodes. A check doesn't modify the tree: the same AST is shared by every step that runs against the file.

Important: To make compiler/ast importable in your linter and the Lint config, you have to add the Nim compiler source path to the compiler config:
--path:"$nim/"

Put that in a config file affecting your linter and Lint config, e.g. linter.nims for linter.nim.

Settings

Settings are a plain object. Give the fields a default value in the type definition.

type
  VarNamesSettings* = object
    minLength*: int = 3
    maxLength*: int = 12

  ConstNamesSettings* = object
    prefix*: string = "k"

A config that provides settings has its keys checked against this type, so a misspelled key is a compile error.

Findings

A types: Finding carries a position and a message, and nothing else.

A check does not know the file it runs against, the linter it belongs to, or the severity its findings are reported with. The runner attaches all of that when it collects findings, which is what lets the same check run at different severities in different steps.

Use types: toFinding to build one from an AST node; it extracts the position from the node:

proc runConstNames(ast: PNode, s: ConstNamesSettings): seq[Finding] =
  if s.prefix.len == 0:
    return
  for node in ast.sons:
    if node.kind == nkConstSection:
      for def in node.sons:
        if def[0].kind != nkIdent:
          continue
        let name = def[0].ident.s
        if not name.startsWith(s.prefix):
          result.add def[0].toFinding(
            "constant `" & name & "` should start with `" & s.prefix & "`"
          )