Skip to content

Commit

Permalink
Add support to checkAgainstRule with custom rules (#6460)
Browse files Browse the repository at this point in the history
Co-authored-by: Masafumi Koba <473530+ybiquitous@users.noreply.github.com>
  • Loading branch information
aaronccasanova and ybiquitous committed Nov 11, 2022
1 parent e65d244 commit d91bb5b
Show file tree
Hide file tree
Showing 10 changed files with 165 additions and 80 deletions.
5 changes: 5 additions & 0 deletions .changeset/little-forks-burn.md
@@ -0,0 +1,5 @@
---
"stylelint": minor
---

Added: support to `checkAgainstRule` with custom rules
6 changes: 4 additions & 2 deletions docs/developer-guide/plugins.md
Expand Up @@ -232,13 +232,14 @@ Validates the options for your rule.

### `stylelint.utils.checkAgainstRule`

Checks CSS against a standard Stylelint rule _within your own rule_. This function provides power and flexibility for plugins authors who wish to modify, constrain, or extend the functionality of existing Stylelint rules.
Checks CSS against a standard or custom Stylelint rule _within your own rule_. This function provides power and flexibility for plugins authors who wish to modify, constrain, or extend the functionality of existing Stylelint rules.

It accepts an options object and a callback that is invoked with warnings from the specified rule. The options are:

- `ruleName`: the name of the rule you are invoking
- `ruleSettings`: settings for the rule you are invoking
- `root`: the root node to run this rule against
- `result?`: the PostCSS result for resolving and invoking custom rules

Use the warning to create a _new_ warning _from your plugin rule_ that you report with `stylelint.utils.report`.

Expand All @@ -259,7 +260,8 @@ function myPluginRule(primaryOption, secondaryOptionObject) {
{
ruleName: "at-rule-no-unknown",
ruleSettings: [primaryOption, defaultedOptions],
root: postcssRoot
root: postcssRoot,
result: postcssResult
},
(warning) => {
stylelint.utils.report({
Expand Down
50 changes: 29 additions & 21 deletions lib/__tests__/normalizeRuleSettings.test.js
@@ -1,74 +1,84 @@
'use strict';

const rules = require('../rules');
const createPlugin = require('../createPlugin');
const normalizeRuleSettings = require('../normalizeRuleSettings');

const mockRule = createPlugin('mock-rule', () => () => {});
const mockRuleWithPrimaryOptionArray = createPlugin(
'mock-rule-with-primary-option-array',
() => () => {},
);

mockRuleWithPrimaryOptionArray.primaryOptionArray = true;

describe('rules whose primary option IS NOT an array', () => {
it('solo null returns null', () => {
expect(normalizeRuleSettings(null, 'foo')).toBeNull();
expect(normalizeRuleSettings(null, mockRule)).toBeNull();
});

it('arrayed null returns null', () => {
expect(normalizeRuleSettings([null], 'foo')).toBeNull();
expect(normalizeRuleSettings([null], mockRule)).toBeNull();
});

it('solo number returns arrayed number', () => {
const actual = normalizeRuleSettings(2, 'foo');
const actual = normalizeRuleSettings(2, mockRule);
const expected = [2];

expect(actual).toEqual(expected);
});

it('arrayed number returns arrayed number if rule is not special', () => {
const actual = normalizeRuleSettings([2], 'foo');
const actual = normalizeRuleSettings([2], mockRule);
const expected = [2];

expect(actual).toEqual(expected);
});

it('arrayed number with secondary options returns same', () => {
const actual = normalizeRuleSettings([2, { severity: 'warning' }], 'block-no-empty');
const actual = normalizeRuleSettings([2, { severity: 'warning' }], rules['block-no-empty']);
const expected = [2, { severity: 'warning' }];

expect(actual).toEqual(expected);
});

it('solo string returns arrayed string', () => {
const actual = normalizeRuleSettings('always', 'foo');
const actual = normalizeRuleSettings('always', mockRule);
const expected = ['always'];

expect(actual).toEqual(expected);
});

it('arrayed string returns arrayed string', () => {
const actual = normalizeRuleSettings(['always'], 'foo');
const actual = normalizeRuleSettings(['always'], mockRule);
const expected = ['always'];

expect(actual).toEqual(expected);
});

it('arrayed string with secondary options returns same', () => {
const actual = normalizeRuleSettings(['always', { severity: 'warning' }], 'foo');
const actual = normalizeRuleSettings(['always', { severity: 'warning' }], mockRule);
const expected = ['always', { severity: 'warning' }];

expect(actual).toEqual(expected);
});

it('solo boolean returns arrayed boolean', () => {
const actual = normalizeRuleSettings(true, 'foo');
const actual = normalizeRuleSettings(true, mockRule);
const expected = [true];

expect(actual).toEqual(expected);
});

it('arrayed boolean returns arrayed boolean if rule is not special', () => {
const actual = normalizeRuleSettings([false], 'foo');
const actual = normalizeRuleSettings([false], mockRule);
const expected = [false];

expect(actual).toEqual(expected);
});

it('arrayed boolean with secondary options returns same', () => {
const actual = normalizeRuleSettings([true, { severity: 'warning' }], 'block-no-empty');
const actual = normalizeRuleSettings([true, { severity: 'warning' }], rules['block-no-empty']);
const expected = [true, { severity: 'warning' }];

expect(actual).toEqual(expected);
Expand All @@ -77,22 +87,22 @@ describe('rules whose primary option IS NOT an array', () => {

describe('rules whose primary option CAN BE an array', () => {
it('solo null returns null', () => {
expect(normalizeRuleSettings(null, 'foo')).toBeNull();
expect(normalizeRuleSettings(null, mockRule)).toBeNull();
});

it('arrayed null returns null', () => {
expect(normalizeRuleSettings([null], 'foo')).toBeNull();
expect(normalizeRuleSettings([null], mockRule)).toBeNull();
});

it('solo primary option array is nested within an array', () => {
const actual = normalizeRuleSettings(['calc', 'rgba'], 'function-allowed-list', true);
const actual = normalizeRuleSettings(['calc', 'rgba'], rules['function-allowed-list']);
const expected = [['calc', 'rgba']];

expect(actual).toEqual(expected);
});

it('primary option array in an array', () => {
const actual = normalizeRuleSettings([['calc', 'rgba']], 'function-allowed-list', true);
const actual = normalizeRuleSettings([['calc', 'rgba']], rules['function-allowed-list']);
const expected = [['calc', 'rgba']];

expect(actual).toEqual(expected);
Expand All @@ -101,23 +111,22 @@ describe('rules whose primary option CAN BE an array', () => {
it('nested primary option array returns same', () => {
const actual = normalizeRuleSettings(
[['calc', 'rgba'], { severity: 'warning' }],
'function-allowed-list',
true,
rules['function-allowed-list'],
);
const expected = [['calc', 'rgba'], { severity: 'warning' }];

expect(actual).toEqual(expected);
});

it('string as first primary option returns same', () => {
const actual = normalizeRuleSettings(['alphabetical', { severity: 'warning' }], 'rulename-bar');
const actual = normalizeRuleSettings(['alphabetical', { severity: 'warning' }], mockRule);
const expected = ['alphabetical', { severity: 'warning' }];

expect(actual).toEqual(expected);
});

it('primary option array with length of 2', () => {
const actual = normalizeRuleSettings([{ foo: 1 }, { foo: 2 }], 'rulename-bar', true);
const actual = normalizeRuleSettings([{ foo: 1 }, { foo: 2 }], mockRuleWithPrimaryOptionArray);
const expected = [[{ foo: 1 }, { foo: 2 }]];

expect(actual).toEqual(expected);
Expand All @@ -126,8 +135,7 @@ describe('rules whose primary option CAN BE an array', () => {
it('primary option array with length of 2 and secondary options', () => {
const actual = normalizeRuleSettings(
[[{ foo: 1 }, { foo: 2 }], { severity: 'warning' }],
'rulename-bar',
true,
mockRuleWithPrimaryOptionArray,
);
const expected = [[{ foo: 1 }, { foo: 2 }], { severity: 'warning' }];

Expand Down
4 changes: 2 additions & 2 deletions lib/lintPostcssResult.js
Expand Up @@ -4,6 +4,7 @@ const assignDisabledRanges = require('./assignDisabledRanges');
const getOsEol = require('./utils/getOsEol');
const reportUnknownRuleNames = require('./reportUnknownRuleNames');
const rules = require('./rules');
const getStylelintRule = require('./utils/getStylelintRule');

/** @typedef {import('stylelint').LinterOptions} LinterOptions */
/** @typedef {import('stylelint').PostcssResult} PostcssResult */
Expand Down Expand Up @@ -62,8 +63,7 @@ function lintPostcssResult(stylelintOptions, postcssResult, config) {
: [];

for (const ruleName of ruleNames) {
const ruleFunction =
rules[ruleName] || (config.pluginFunctions && config.pluginFunctions[ruleName]);
const ruleFunction = getStylelintRule(ruleName, config);

if (ruleFunction === undefined) {
performRules.push(
Expand Down
10 changes: 3 additions & 7 deletions lib/normalizeAllRuleSettings.js
@@ -1,7 +1,7 @@
'use strict';

const normalizeRuleSettings = require('./normalizeRuleSettings');
const rules = require('./rules');
const getStylelintRule = require('./utils/getStylelintRule');

/** @typedef {import('stylelint').ConfigRules} StylelintConfigRules */
/** @typedef {import('stylelint').Config} StylelintConfig */
Expand All @@ -17,14 +17,10 @@ function normalizeAllRuleSettings(config) {
const normalizedRules = {};

for (const [ruleName, rawRuleSettings] of Object.entries(config.rules)) {
const rule = rules[ruleName] || (config.pluginFunctions && config.pluginFunctions[ruleName]);
const rule = getStylelintRule(ruleName, config);

if (rule) {
normalizedRules[ruleName] = normalizeRuleSettings(
rawRuleSettings,
ruleName,
rule.primaryOptionArray,
);
normalizedRules[ruleName] = normalizeRuleSettings(rawRuleSettings, rule);
} else {
normalizedRules[ruleName] = [];
}
Expand Down
30 changes: 8 additions & 22 deletions lib/normalizeRuleSettings.js
@@ -1,6 +1,5 @@
'use strict';

const rules = require('./rules');
const { isPlainObject } = require('./utils/validateTypes');

// Rule settings can take a number of forms, e.g.
Expand All @@ -19,17 +18,10 @@ const { isPlainObject } = require('./utils/validateTypes');
* @template T
* @template {Object} O
* @param {import('stylelint').ConfigRuleSettings<T, O>} rawSettings
* @param {string} ruleName
* @param {boolean} [primaryOptionArray] If primaryOptionArray is not provided, we try to get it from the rules themselves, which will not work for plugins
* @param {import('stylelint').Rule<T, O>} [rule]
* @return {[T] | [T, O] | null}
*/
module.exports = function normalizeRuleSettings(
rawSettings,
ruleName,
// If primaryOptionArray is not provided, we try to get it from the
// rules themselves, which will not work for plugins
primaryOptionArray,
) {
module.exports = function normalizeRuleSettings(rawSettings, rule) {
if (rawSettings === null || rawSettings === undefined) {
return null;
}
Expand All @@ -39,30 +31,24 @@ module.exports = function normalizeRuleSettings(
}
// Everything below is an array ...

if (rawSettings.length > 0 && (rawSettings[0] === null || rawSettings[0] === undefined)) {
return null;
}
const [primary, secondary] = rawSettings;

if (primaryOptionArray === undefined) {
const rule = rules[ruleName];

if (rule && 'primaryOptionArray' in rule) {
primaryOptionArray = rule.primaryOptionArray;
}
if (rawSettings.length > 0 && (primary === null || primary === undefined)) {
return null;
}

if (!primaryOptionArray) {
if (rule && !rule.primaryOptionArray) {
return rawSettings;
}
// Everything below is a rule that CAN have an array for a primary option ...
// (they might also have something else, e.g. rule-properties-order can
// have the string "alphabetical")

if (rawSettings.length === 1 && Array.isArray(rawSettings[0])) {
if (rawSettings.length === 1 && Array.isArray(primary)) {
return rawSettings;
}

if (rawSettings.length === 2 && !isPlainObject(rawSettings[0]) && isPlainObject(rawSettings[1])) {
if (rawSettings.length === 2 && !isPlainObject(primary) && isPlainObject(secondary)) {
return rawSettings;
}

Expand Down
82 changes: 82 additions & 0 deletions lib/utils/__tests__/checkAgainstRule.test.js
@@ -1,8 +1,34 @@
'use strict';

const report = require('../report');
const validateOptions = require('../validateOptions');
const checkAgainstRule = require('../checkAgainstRule');
const postcss = require('postcss');

const mockRuleName = 'custom/no-empty-source';
const mockResult = {
stylelint: {
config: {
pluginFunctions: {
[mockRuleName]: (primary) => (root, result) => {
const validOptions = validateOptions(result, mockRuleName, {
actual: primary,
});

if (!validOptions || root.source.input.css) return;

report({
result,
message: 'Unexpected empty source',
ruleName: mockRuleName,
node: root,
});
},
},
},
},
};

describe('checkAgainstRule', () => {
it('does nothing with no errors', () => {
const root = postcss.parse('a {} @media {}');
Expand Down Expand Up @@ -60,4 +86,60 @@ describe('checkAgainstRule', () => {
expect(warnings[0].line).toBe(3);
expect(warnings[0].column).toBe(1);
});

it('checks against custom rule (passing)', () => {
const root = postcss.parse('.not-empty {}');

const warnings = [];

checkAgainstRule(
{
ruleName: mockRuleName,
result: mockResult,
ruleSettings: true,
root,
},
(warning) => warnings.push(warning),
);

expect(warnings).toHaveLength(0);
});

it('checks against custom rule (failing)', () => {
const root = postcss.parse('');

const warnings = [];

checkAgainstRule(
{
ruleName: mockRuleName,
result: mockResult,
ruleSettings: true,
root,
},
(warning) => warnings.push(warning),
);

expect(warnings).toHaveLength(1);
expect(warnings[0].rule).toBe(mockRuleName);
expect(warnings[0].line).toBe(1);
expect(warnings[0].column).toBe(1);
});

test('throws when checking against custom rule without result object', () => {
expect(() => {
const root = postcss.parse('');

const warnings = [];

checkAgainstRule(
{
ruleName: mockRuleName,
ruleSettings: true,
root,
},
(warning) => warnings.push(warning),
);
}).toThrow(`Rule "${mockRuleName}" does not exist`);
});
});

0 comments on commit d91bb5b

Please sign in to comment.