Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support to checkAgainstRule with custom rules #6460

Merged
merged 12 commits into from Nov 11, 2022
Merged
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) {
ybiquitous marked this conversation as resolved.
Show resolved Hide resolved
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`);
});
});