Writing rules
Please help us create, enhance, and debug our rules!
Set up
Clone the repository and install its dependencies with pnpm:
pnpm ciEvery task goes through the Makefile; make help lists the targets with their flags. The ones a rule needs:
make test FILE=lib/rules/color-hex-case/index.test.ts # one rule's tests
make lint LINT_FLAGS=--fix # lint and format the code
make verify # everything CI runsmake verify is what CI runs, and the pre-push hook runs it too.
Add a rule
Define the rule
A rule must be:
- for standard CSS syntax only
- generally useful; not tied to idiosyncratic patterns
And have a:
- unambiguous finished state
- singular purpose that doesn't overlap with other rules
Its name is split into two parts:
- the thing the rule applies to, e.g.
at-rule - what the rule is checking, e.g.
name-case
Unless it applies to the whole source, then there is no first part.
A rule is written once for plain CSS and serves every namespace: @stylistic/scss/, @stylistic/less/ and @stylistic/styled/ are built out of the same module. What a preprocessor spells is answered under lib/syntaxes/, and a rule reaches it only through the syntax it is handed.
Write the rule
A rule lives in lib/rules/<rule-name>/: index.ts, index.test.ts and README.md. Follow lib/rules/color-hex-case/index.ts; abridged, a rule module looks like this:
import stylelint from "stylelint"
import { css } from "../../syntaxes/css/index.ts"
import { defineMessages, defineRule, type RuleScope } from "../../utils/defineRule/index.ts"
import { getRuleDocUrl } from "../../utils/getRuleDocUrl/index.ts"
import { report } from "../../utils/report/index.ts"
import type { RuleCheck } from "../../utils/ruleCheck/index.ts"
let { utils: { validateOptions } } = stylelint
let shortName = `color-hex-case`
const MESSAGES = defineMessages({
expected: (actual, expected) => `Expected "${actual}" to be "${expected}"`,
})
export let meta = {
url: getRuleDocUrl(shortName),
fixable: true,
}
function rule ({ ruleName, messages, syntax }: RuleScope<typeof MESSAGES>, primary: `lower` | `upper`): RuleCheck {
return (root, result) => {
let validOptions = validateOptions(result, ruleName, {
actual: primary,
possible: [`lower`, `upper`],
})
if (!validOptions) return
root.walkDecls((decl) => {
let value = syntax.read(decl)
/* … */
report({
message: messages.expected,
messageArgs: [actual, expected],
node: decl,
index,
endIndex,
result,
ruleName,
fix () { /* … */ },
})
})
}
}
export let createRule = defineRule({ shortName, meta, messages: MESSAGES, rule })
export let { ruleName, messages } = createRule(css)defineRulebuilds the rule once per namespace, so the rule takes itsruleName,messagesandsyntaxfrom its first parameter rather than from the module.- Every question about the stylesheet's language goes through
syntax: reading and writing text, comments,syntax.isStandardAtRuleand its siblings. Nothing specific to SCSS or Less goes into the rule. - Report through
reportoflib/utils/report, never through Stylelint's own. The autofix is thefixcallback, andfixable: trueinmetamarks the rule as fixable. - Make the rule strict by default, and add secondary
ignoreoptions to make it more permissive.
Use the PostCSS API to walk the tree, preferring the walk iterators (e.g. walkDecls) to forEach, and check a node's type before reading its other properties. For values and selectors, use postcss-value-parser and postcss-selector-parser rather than regular expressions. A regular expression that is needed all the same goes into lib/regexps.ts under a name saying what it matches.
Look through lib/utils/ before writing new traversal logic: most whitespace rules are thin wrappers over whitespaceChecker and the *SpaceChecker families.
Add options
Each rule can accept a primary and an optional secondary option.
Only add an option to a rule if it addresses a requested use case to avoid polluting the tool with unused features.
Primary
Every rule must have a primary option. For example, in:
"color-hex-case": "lower", the primary option is"lower""max-empty-lines": [2, { "ignore": ["comments"] }], the primary option is2
Rules are named to encourage explicit primary options. For example, color-hex-case: "lower"|"upper" rather than color-hex-uppercase: "always"|"never", since "never" only implies lower case, whereas "lower" makes it explicit.
Secondary
Some rules require extra flexibility to address edge cases. These can use an optional secondary options object. The most typical secondary options are "ignore": [] and "except": []:
"ignore"skips over a particular pattern"except"inverts the primary option for a particular pattern
Both accept an array of predefined keywords, e.g. ["comments"]. Some rules accept a user-defined list of things to ignore instead, in the form of "ignore<Things>": [], e.g. "ignoreFunctions": [], which lets users ignore non-standard syntax at the configuration level rather than the rule carrying code for it.
No rule of this plugin takes an array as its primary option, so defineRule has no field telling Stylelint so yet; the first rule that needs one adds it there.
Add problem messages
Add problem messages in form of:
- "Expected [installsomething] [in some context]"
- "Unexpected [something] [in some context]"
If the rule has autofix use:
- 'Expected "[unfixed]" to be "[fixed]"' for short strings
- 'Expected "[primary]" ... notation' for long strings
Write tests
Tests run on Vitest with @morev/stylelint-testing-library. A test file imports messages and ruleName from its rule and calls testRule with accept and reject cases; a reject case asserts what --fix writes (fixed), the message, the line and the column:
import { messages, ruleName } from "./index.ts"
let testRule = createTestRule({ ruleName })
testRule({
ruleName,
config: [`lower`],
accept: [
{
description: `a keyword, which carries no hash at all`,
code: `a { color: pink; }`,
},
],
reject: [
{
description: `an upper-case color`,
code: `a { color: #ABC; }`,
fixed: `a { color: #abc; }`,
line: 1,
column: 12,
message: messages.expected(`#ABC`, `#abc`),
},
],
})A description names what the fixture is, continuing the sentence the block starts: “accepts a keyword…”, “rejects an upper-case color”. Cases for a custom syntax of its own go into lib/syntaxes/<name>/rules/<rule-name>/index.test.ts.
You should add test cases for all patterns that are considered problems and all that are not. You should use:
- realistic CSS, avoiding the use of ellipses
- the minimum amount of code possible, e.g. use an empty rule if targetting selectors
{}for empty rules, rather than{ }- the
atype selector by default - the
@mediaat-rules by default - the
colorproperty by default - the
redvalue by default - the
(min-)widthmedia feature by default - foo, bar and baz for names, e.g.
.foo,#bar,--baz
You should:
- vary column and line positions across your tests
- include at least one test that has 2 warnings
Commonly overlooked edge-cases
You should ask yourself how does your rule handle:
- variables (e.g.
var(--custom-property))? - CSS strings (e.g.
content: "anything goes";)? - CSS comments (e.g.
/* anything goes */)? - empty functions (e.g.
var())? url()functions, including data URIs (e.g.url(anything/goes.jpg))?- vendor prefixes (e.g.
@-webkit-keyframes name {})? - case sensitivity (e.g.
@KEYFRAMES name {})? - a pseudo-class combined with a pseudo-element (e.g.
a:hover::before)? - nesting (e.g. do you resolve
& a {}, or check it as is?)? - whitespace and punctuation (e.g. comparing
rgb(0,0,0)withrgb(0, 0, 0))? - the rules writing the same whitespace (e.g.
value-list-comma-newline-afterbesidevalue-list-comma-space-after)?
Write the README
Each rule is accompanied by a README in the following format:
- Rule name.
- Single-line description.
- Prototypical code example.
- Expanded description (if necessary).
- Options.
- Example patterns that are considered problems (for each option value).
- Example patterns that are not considered problems (for each option value).
- Optional options (if applicable).
The single-line description is in the form of:
- "Disallow ..." for
norules - "Limit ..." for
maxrules - "Require ..." for rules that accept
"always"and"never"options - "Specify ..." for everything else
The expanded description says what the rule does, not how it gets there. A fixable rule carries the line naming the fix option, and a rule reporting with message arguments the line naming the message secondary option; the tests of lib/rules/index.test.ts check both.
You should:
- pick examples from the tests
- only use standard CSS syntax in examples and options
- add the fewest examples possible to communicate the intent of the rule, rather than show edge cases
- use "this rule" to refer to the rule, e.g. "This rule ignores ..."
- align the arrows within the prototypical code example with the beginning of the highlighted construct
- align the text within the prototypical code example as far to the left as possible
For example:
@media screen and (min-width: 768px) {}
/** ↑ ↑
* These names and values */Look at the READMEs of other rules to glean more conventional patterns.
Wire up the rule
A new rule is active only once it is added to these places:
lib/rules/index.ts, the registry;- the list of rules, under its group, with
(Autofixable).closing the line of a fixable rule; - the
Unreleasedsection ofCHANGELOG.md; scripts/oracles/options.ts, with every primary option the rule takes;- for a rule taking a
-single-lineor-multi-lineoption, theLINENESS_RULEStable oflib/utils/defersToRunEnd/index.ts; - for a rule speaking of whitespace another rule speaks of already, where no spelling satisfies both: the table of
lib/utils/conflictingSettings/index.ts, its test, the type beside it, whichmake types-checkholds in step with the table, and Conflicting settings.
Add an option to a rule
You should:
- Add new unit tests to test the option.
- Change the rule's validation to allow for the new option.
- Add (as little as possible) logic to the rule to make the tests pass.
- Add documentation about the new option.
- Add an entry to the
Unreleasedsection ofCHANGELOG.md.
Fix a bug in a rule
You should:
- Write failing unit tests that exemplify the bug.
- Fiddle with the rule until those new tests pass.
- Run
make verify.
Deprecate a rule
Deprecating rules doesn't happen very often. When you do, you must:
- Add
deprecated: trueto the rule'smeta. - Open its README with a
> **Warning**block saying what to use instead; a test checks that the block is there. - Add an entry to the
Unreleasedsection ofCHANGELOG.md.