From d91bb5b779ce6ca69058549d58195a9e82b7c8d1 Mon Sep 17 00:00:00 2001 From: Aaron Casanova <32409546+aaronccasanova@users.noreply.github.com> Date: Thu, 10 Nov 2022 16:22:43 -0800 Subject: [PATCH] Add support to `checkAgainstRule` with custom rules (#6460) Co-authored-by: Masafumi Koba <473530+ybiquitous@users.noreply.github.com> --- .changeset/little-forks-burn.md | 5 ++ docs/developer-guide/plugins.md | 6 +- lib/__tests__/normalizeRuleSettings.test.js | 50 +++++++----- lib/lintPostcssResult.js | 4 +- lib/normalizeAllRuleSettings.js | 10 +-- lib/normalizeRuleSettings.js | 30 ++----- lib/utils/__tests__/checkAgainstRule.test.js | 82 ++++++++++++++++++++ lib/utils/checkAgainstRule.js | 41 ++++------ lib/utils/getStylelintRule.js | 10 +++ types/stylelint/index.d.ts | 7 +- 10 files changed, 165 insertions(+), 80 deletions(-) create mode 100644 .changeset/little-forks-burn.md create mode 100644 lib/utils/getStylelintRule.js diff --git a/.changeset/little-forks-burn.md b/.changeset/little-forks-burn.md new file mode 100644 index 0000000000..e8b1703792 --- /dev/null +++ b/.changeset/little-forks-burn.md @@ -0,0 +1,5 @@ +--- +"stylelint": minor +--- + +Added: support to `checkAgainstRule` with custom rules diff --git a/docs/developer-guide/plugins.md b/docs/developer-guide/plugins.md index 3b9318833d..11b11245c1 100644 --- a/docs/developer-guide/plugins.md +++ b/docs/developer-guide/plugins.md @@ -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`. @@ -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({ diff --git a/lib/__tests__/normalizeRuleSettings.test.js b/lib/__tests__/normalizeRuleSettings.test.js index 6f57e77aa4..ceea8c5037 100644 --- a/lib/__tests__/normalizeRuleSettings.test.js +++ b/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); @@ -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); @@ -101,8 +111,7 @@ 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' }]; @@ -110,14 +119,14 @@ describe('rules whose primary option CAN BE an array', () => { }); 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); @@ -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' }]; diff --git a/lib/lintPostcssResult.js b/lib/lintPostcssResult.js index 0cde1a0af9..9e09c74b00 100644 --- a/lib/lintPostcssResult.js +++ b/lib/lintPostcssResult.js @@ -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 */ @@ -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( diff --git a/lib/normalizeAllRuleSettings.js b/lib/normalizeAllRuleSettings.js index 9f2adb198b..e9ea800c90 100644 --- a/lib/normalizeAllRuleSettings.js +++ b/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 */ @@ -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] = []; } diff --git a/lib/normalizeRuleSettings.js b/lib/normalizeRuleSettings.js index e2d31512dd..1169259a8a 100644 --- a/lib/normalizeRuleSettings.js +++ b/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. @@ -19,17 +18,10 @@ const { isPlainObject } = require('./utils/validateTypes'); * @template T * @template {Object} O * @param {import('stylelint').ConfigRuleSettings} 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} [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; } @@ -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; } diff --git a/lib/utils/__tests__/checkAgainstRule.test.js b/lib/utils/__tests__/checkAgainstRule.test.js index 13a2ee22a6..fffaef6833 100644 --- a/lib/utils/__tests__/checkAgainstRule.test.js +++ b/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 {}'); @@ -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`); + }); }); diff --git a/lib/utils/checkAgainstRule.js b/lib/utils/checkAgainstRule.js index 83706c5d3e..062a1ebdcb 100644 --- a/lib/utils/checkAgainstRule.js +++ b/lib/utils/checkAgainstRule.js @@ -2,40 +2,33 @@ const normalizeRuleSettings = require('../normalizeRuleSettings'); const Result = require('postcss/lib/result'); -const rules = require('../rules'); +const { isPlainObject } = require('./validateTypes'); +const getStylelintRule = require('./getStylelintRule'); /** * Useful for third-party code (e.g. plugins) to run a PostCSS Root * against a specific rule and do something with the warnings - * @template T - * @template {Object} O - * @param {{ - ruleName: string, - ruleSettings: import('stylelint').ConfigRuleSettings, - root: import('postcss').Root, - }} options - * @param {(warning: import('postcss').Warning) => void} callback - * @returns {void} + * + * @type {typeof import('stylelint').utils.checkAgainstRule} */ function checkAgainstRule(options, callback) { - if (!options) - throw new Error( - "checkAgainstRule requires an options object with 'ruleName', 'ruleSettings', and 'root' properties", - ); + if (!isPlainObject(options)) throw new Error('Expected an options object'); - if (!callback) throw new Error('checkAgainstRule requires a callback'); + if (!callback) throw new Error('Expected a callback function'); - if (!options.ruleName) throw new Error("checkAgainstRule requires a 'ruleName' option"); + const { ruleName, ruleSettings, root, result } = options; - const rule = rules[options.ruleName]; + if (!ruleName) throw new Error('Expected a "ruleName" option'); - if (!rule) throw new Error(`Rule '${options.ruleName}' does not exist`); + const rule = getStylelintRule(ruleName, result && result.stylelint.config); - if (!options.ruleSettings) throw new Error("checkAgainstRule requires a 'ruleSettings' option"); + if (!rule) throw new Error(`Rule "${ruleName}" does not exist`); - if (!options.root) throw new Error("checkAgainstRule requires a 'root' option"); + if (!ruleSettings) throw new Error('Expected a "ruleSettings" option'); - const settings = normalizeRuleSettings(options.ruleSettings, options.ruleName); + if (!root) throw new Error('Expected a "root" option'); + + const settings = normalizeRuleSettings(ruleSettings, rule); if (!settings) { return; @@ -44,11 +37,9 @@ function checkAgainstRule(options, callback) { // @ts-expect-error - this error should not occur with PostCSS 8 const tmpPostcssResult = new Result(); - rule(settings[0], /** @type {O} */ (settings[1]), {})(options.root, tmpPostcssResult); + rule(settings[0], /** @type {Object} */ (settings[1]), {})(root, tmpPostcssResult); for (const warning of tmpPostcssResult.warnings()) callback(warning); } -module.exports = /** @type {typeof import('stylelint').utils.checkAgainstRule} */ ( - checkAgainstRule -); +module.exports = checkAgainstRule; diff --git a/lib/utils/getStylelintRule.js b/lib/utils/getStylelintRule.js new file mode 100644 index 0000000000..401cf5a52d --- /dev/null +++ b/lib/utils/getStylelintRule.js @@ -0,0 +1,10 @@ +const rules = require('../rules'); + +/** + * @param {string} ruleName + * @param {import('stylelint').Config | undefined} [config] + * @returns {import('stylelint').Rule | undefined} + */ +module.exports = function getStylelintRule(ruleName, config) { + return rules[ruleName] || (config && config.pluginFunctions && config.pluginFunctions[ruleName]); +}; diff --git a/types/stylelint/index.d.ts b/types/stylelint/index.d.ts index 291ff9cb0b..d9344d822e 100644 --- a/types/stylelint/index.d.ts +++ b/types/stylelint/index.d.ts @@ -513,7 +513,12 @@ declare module 'stylelint' { * against a specific rule and do something with the warnings */ checkAgainstRule: ( - options: { ruleName: string; ruleSettings: ConfigRuleSettings; root: PostCSS.Root }, + options: { + ruleName: string; + ruleSettings: ConfigRuleSettings; + root: PostCSS.Root; + result?: PostcssResult; + }, callback: (warning: PostCSS.Warning) => void, ) => void; };