Writing Custom Rules

When the built-in rules do not cover a convention your team cares about, you can write your own. Built-in rules use exactly the same API as third-party rules, so anything CoffeeLint can check internally, your rule can check too. This guide covers the anatomy of a rule, the three kinds of rules, the APIs they receive, and how to test, package, and load them.

Anatomy of a Rule

A rule is a class with:

  1. a rule property holding its metadata and default configuration, and
  2. one linting method: lintToken, lintLine, or lintAST.
module.exports = class NoForbiddenNames

  rule:
    name: 'no_forbidden_names'
    level: 'warn'
    message: 'Forbidden variable name'
    names: ['voldemort']
    description: '''
      Disallows a configurable list of identifiers.
      '''

  tokens: ['IDENTIFIER']

  lintToken: (token, tokenApi) ->
    names = tokenApi.config[@rule.name].names
    if token[1] in names
      return { context: "found '#{token[1]}'" }

The rule Object

Key Required Description
name yes Unique rule name in snake_case. Users configure the rule under this key.
level yes Default level: ignore, warn, or error.
message yes Short description of the problem, shown in every report.
description yes Longer documentation (HTML allowed). Used by documentation generators.
anything else no Default values of your options, such as names above. Users override them in their configuration.

In the linting methods, read the merged configuration through the API object (tokenApi.config[@rule.name], lineApi.config[...], astApi.config[...]) rather than @rule, so user overrides take effect.

Return Values

Token and line rules report problems through their return value:

Return value Meaning
null, undefined, or false No problem.
true A problem, reported with the rule's defaults.
An object A problem; its keys are merged into the finding. Use context for details, and optionally lineNumber or level to override.

Only rules whose configured level is warn or error are instantiated at all, so a rule set to ignore costs nothing.

Token Rules (lintToken)

Token rules inspect the output of the CoffeeScript lexer. Declare which token types you care about in tokens; lintToken(token, tokenApi) is called for each matching token.

A token is an array:

[type, value, locationData]
  • type: for example IDENTIFIER, PROPERTY, STRING, CLASS, CALL_START, INDENT, TERMINATOR, ->, =.
  • value: the token's text or value.
  • locationData: { first_line, first_column, last_line, last_column }, zero-based.

Tokens may also carry flags that built-in rules rely on: spaced (followed by whitespace), newLine (followed by a newline), generated (inserted by the compiler, such as implicit parentheses and braces), and origin.

TokenApi

Member Description
tokens All tokens of the file.
i Index of the current token.
peek(n = 1) The token n positions away (negative looks back), or null.
lines The source split into lines.
lineNumber Zero-based line of the current token.
config The merged configuration, keyed by rule name.
tokensByLine Tokens grouped by line, built up as linting progresses.

A rule instance lives for the whole file, so you can keep state across tokens, as duplicate_key does with a stack of brace scopes.

Line Rules (lintLine)

Line rules look at raw text, one line at a time. Use them for checks that are about formatting rather than syntax:

module.exports = class NoTodoComments

  rule:
    name: 'no_todo_comments'
    level: 'warn'
    message: 'TODO comment found'
    description: 'Flags comments that contain TODO.'

  lintLine: (line, lineApi) ->
    if /#\s*TODO/.test(line)
      return { context: line.trim() }

LineApi

Member Description
line The current line's text.
lines All lines.
lineNumber Zero-based index of the current line.
lineCount Total number of lines.
config The merged configuration.
tokensByLine Tokens grouped by line number.
isLiterate() Whether the file is Literate CoffeeScript.
isLastLine() Whether this is the last line.
lineHasToken(type = null, lineNumber = null) Whether the line has any token, or one of the given type. Useful to skip comment-only lines and multi-line strings.
getLineTokens() Tokens on the current line.
context.class Class tracking: inClass, lastUnemptyLineInClass, classIndents.

AST Rules (lintAST)

AST rules receive the root node of the syntax tree produced by CoffeeScript.nodes(source) and walk it themselves. Use them for structural checks: function bodies, parameters, class membership, complexity.

module.exports = class MaxParams

  rule:
    name: 'max_params'
    level: 'warn'
    value: 4
    message: 'Too many function parameters'
    description: 'Limits the number of parameters a function can declare.'

  lintAST: (node, @astApi) ->
    @lintNode node
    undefined

  lintNode: (node) ->
    if @astApi.getNodeName(node) is 'Code'
      max = @astApi.config[@rule.name].value
      if node.params.length > max
        @errors.push @astApi.createError
          lineNumber: node.locationData.first_line + 1
          context: "#{node.params.length} parameters, max is #{max}"
    node.eachChild (child) => @lintNode child

Conventions that all built-in AST rules follow:

  • Push findings onto @errors (CoffeeLint assigns this array to the rule before calling lintAST), creating each one with @astApi.createError(attrs).
  • Return undefined from lintAST. If lintAST returns a value, the AST pass stops immediately and returns that single value, discarding the other AST findings.
  • Set lineNumber yourself. AST locations are zero-based, so add 1.

ASTApi

Member Description
config The merged configuration.
getNodeName(node) A stable name for the node type: Class, Code (a function), For, If, Obj, Op, Switch, Try, Value, or While; otherwise undefined.
createError(attrs) Builds a finding for the current rule, or returns null when the rule is ignored.

Nodes come from the CoffeeScript compiler, so you can use its methods such as eachChild, traverseChildren, and contains, and properties such as params, body, bound (fat arrow), and locationData.

Testing a Rule

Register the rule and lint strings directly:

assert = require 'assert'
coffeelint = require 'coffeelint'

coffeelint.registerRule require './no_forbidden_names'

config = { no_forbidden_names: { level: 'error' } }

errors = coffeelint.lint 'voldemort = 1', config
assert.equal errors.length, 1
assert.equal errors[0].rule, 'no_forbidden_names'
assert.equal errors[0].lineNumber, 1

assert.equal coffeelint.lint('harry = 1', config).length, 0

To explore the tokens and AST CoffeeScript produces for a snippet, the project's contributors recommend the online tool clfiddle.

Loading a Rule

From the Command Line

coffeelint --rules ./lint-rules/no_forbidden_names.coffee src
coffeelint --rules coffeelint-no-forbidden-names src

The module may export a single rule class or an array of rule classes.

From Configuration

Add a module key to the rule's entry. The key must equal the rule's name:

{
  "no_forbidden_names": {
    "module": "coffeelint-no-forbidden-names",
    "level": "error",
    "names": ["voldemort", "tom_riddle"]
  }
}

The module value is resolved relative to the linted file (for discovered configuration) or the current directory, so install the rule in your project:

npm install --save-dev coffeelint-no-forbidden-names

This per-project approach has been the recommended way since CoffeeLint 1.4; earlier versions required global installs.

Publishing a Rule on npm

  1. Create a package with npm init.
  2. Put the rule in its own file, for example no_forbidden_names.coffee.
  3. Make the package's main file export the rule. Either publish compiled JavaScript, or register the CoffeeScript compiler in a small index.js:

    require('coffeescript/register');
    module.exports = require('./no_forbidden_names.coffee');

    In that case, add coffeescript to the package's dependencies.

  4. Add the keyword coffeelintrule to package.json so users can find the rule on npm.

  5. In your README, document the rule name (for configuration) and the package name (for module), which by npm convention usually uses dashes where the rule name uses underscores.

To check that Node can load your package (for example after npm link):

node -p "require('coffeelint-no-forbidden-names')"
[Function: NoForbiddenNames]

Guidelines

  • Choose a conservative default level. New rules in CoffeeLint itself default to ignore or warn; use error only when the rule demonstrably prevents bugs.
  • Name rules consistently. no_foo for rules that forbid a construct, foo for rules that enforce formatting.
  • Ignore generated tokens (token.generated) unless the rule is specifically about implicit syntax.
  • Give useful context. Say what was found and what was expected.
  • Document options in description, including their defaults.

For how rules fit into the overall linting process, see Architecture.