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:
- a
ruleproperty holding its metadata and default configuration, and - one linting method:
lintToken,lintLine, orlintAST.
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 exampleIDENTIFIER,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 callinglintAST), creating each one with@astApi.createError(attrs). - Return
undefinedfromlintAST. IflintASTreturns a value, the AST pass stops immediately and returns that single value, discarding the other AST findings. - Set
lineNumberyourself. 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
- Create a package with
npm init. - Put the rule in its own file, for example
no_forbidden_names.coffee. -
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
coffeescriptto the package's dependencies. -
Add the keyword
coffeelintruletopackage.jsonso users can find the rule on npm. - 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
ignoreorwarn; useerroronly when the rule demonstrably prevents bugs. - Name rules consistently.
no_foofor rules that forbid a construct,foofor 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.