From db9c9984b96fd736d9bee7a657039b198764951c Mon Sep 17 00:00:00 2001 From: Bryan Mishkin <698306+bmish@users.noreply.github.com> Date: Mon, 19 Dec 2022 19:50:17 -0500 Subject: [PATCH] feat: support functions in --rule-list-split option --- README.md | 33 +++- lib/cli.ts | 30 +++- lib/generator.ts | 11 +- lib/rule-list.ts | 71 +++++++- lib/types.ts | 25 ++- test/lib/cli-test.ts | 89 +++++++++- .../option-rule-list-split-test.ts.snap | 34 ++++ .../generate/option-rule-list-split-test.ts | 163 ++++++++++++++++++ 8 files changed, 428 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index be8a21a8..a163ed88 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,7 @@ There's also a `postprocess` option that's only available via a [config file](#c | `--rule-doc-section-options` | Whether to require an "Options" or "Config" rule doc section and mention of any named options for rules with options. Default: `true`. | | `--rule-doc-title-format` | The format to use for rule doc titles. Defaults to `desc-parens-prefix-name`. See choices in below [table](#--rule-doc-title-format). | | `--rule-list-columns` | Ordered, comma-separated list of columns to display in rule list. Empty columns will be hidden. See choices in below [table](#column-and-notice-types). Default: `name,description,configsError,configsWarn,configsOff,fixable,hasSuggestions,requiresTypeChecking,deprecated`. | -| `--rule-list-split` | Rule property(s) to split the rules list by. A separate list and header will be created for each value. Example: `meta.type`. | +| `--rule-list-split` | Rule property(s) to split the rules list by. A separate list and header will be created for each value. Example: `meta.type`. A function can also be provided for this option via a [config file](#configuration-file). | | `--url-configs` | Link to documentation about the ESLint configurations exported by the plugin. | | `--url-rule-doc` | Link to documentation for each rule. Useful when it differs from the rule doc path on disk (e.g. custom documentation site in use). Use `{name}` placeholder for the rule name. | @@ -187,7 +187,10 @@ There are a few ways to create a config file (as an alternative to passing the o Config files support all the [CLI options](#configuration-options) but in camelCase. -Using a JavaScript-based config file also allows you to provide a `postprocess` function to be called with the generated content and file path for each processed file. This is useful for applying custom transformations such as formatting with tools like prettier (see [prettier example](#prettier)). +Some options are exclusive to a JavaScript-based config file: + +- `postprocess` - A function-only option useful for applying custom transformations such as formatting with tools like prettier. See [prettier example](#prettier). +- [`ruleListSplit`](#configuration-options) with a function - This is useful for customizing the grouping of rules into lists. Example `.eslint-doc-generatorrc.js`: @@ -200,6 +203,32 @@ const config = { module.exports = config; ``` +Example `.eslint-doc-generatorrc.js` with `ruleListSplit` function: + +```js +/** @type {import('eslint-doc-generator').GenerateOptions} */ +const config = { + ruleListSplit(rules) { + return [ + { + // No header for this list. + rules: rules.filter(([name, rule]) => !rule.meta.someProp), + }, + { + title: 'Foo', + rules: rules.filter(([name, rule]) => rule.meta.someProp === 'foo'), + }, + { + title: 'Bar', + rules: rules.filter(([name, rule]) => rule.meta.someProp === 'bar'), + }, + ]; + }, +}; + +module.exports = config; +``` + ### Badges While config emojis are the recommended representations of configs that a rule belongs to (see [`--config-emoji`](#configuration-options)), you can alternatively define badges for configs at the bottom of your `README.md`. diff --git a/lib/cli.ts b/lib/cli.ts index 09f170a8..302f5a35 100644 --- a/lib/cli.ts +++ b/lib/cli.ts @@ -105,7 +105,14 @@ async function loadConfigFileOptions(): Promise { ruleDocSectionOptions: { type: 'boolean' }, ruleDocTitleFormat: { type: 'string' }, ruleListColumns: schemaStringArray, - ruleListSplit: { anyOf: [{ type: 'string' }, schemaStringArray] }, + ruleListSplit: + /* istanbul ignore next -- TODO: haven't tested JavaScript config files yet https://github.com/bmish/eslint-doc-generator/issues/366 */ + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + typeof explorerResults.config.ruleListSplit === 'function' + ? { + /* Functions are allowed but JSON Schema can't validate them so no-op in this case. */ + } + : { anyOf: [{ type: 'string' }, schemaStringArray] }, urlConfigs: { type: 'string' }, urlRuleDoc: { type: 'string' }, }; @@ -130,23 +137,22 @@ async function loadConfigFileOptions(): Promise { const config = explorerResults.config; // eslint-disable-line @typescript-eslint/no-unsafe-assignment -- Rules are disabled because we haven't applied the GenerateOptions type until after we finish validating/normalizing. // Additional validation that couldn't be handled by ajv. - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + /* eslint-disable @typescript-eslint/no-unsafe-member-access -- disabled for same reason above */ if (config.postprocess && typeof config.postprocess !== 'function') { - throw new Error('postprocess must be a function'); + throw new Error('postprocess must be a function.'); } // Perform any normalization. - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access if (typeof config.pathRuleList === 'string') { - config.pathRuleList = [config.pathRuleList]; // eslint-disable-line @typescript-eslint/no-unsafe-member-access + config.pathRuleList = [config.pathRuleList]; } - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access if (typeof config.ruleListSplit === 'string') { - config.ruleListSplit = [config.ruleListSplit]; // eslint-disable-line @typescript-eslint/no-unsafe-member-access + config.ruleListSplit = [config.ruleListSplit]; } return explorerResults.config as GenerateOptions; } + /* eslint-enable @typescript-eslint/no-unsafe-member-access */ return {}; } @@ -264,7 +270,7 @@ export async function run( ) .option( '--rule-list-split ', - '(optional) Rule property(s) to split the rules list by. A separate list and header will be created for each value. Example: `meta.type`.', + '(optional) Rule property(s) to split the rules list by. A separate list and header will be created for each value. Example: `meta.type`. To specify a function, use a JavaScript-based config file.', collectCSV, [] ) @@ -285,6 +291,14 @@ export async function run( const generateOptions = merge(configFileOptions, options); // Recursive merge. + // Options with both a CLI/config-file variant will lose the function value during the merge, so restore it here. + // TODO: figure out a better way to handle this. + /* istanbul ignore next -- TODO: haven't tested JavaScript config files yet https://github.com/bmish/eslint-doc-generator/issues/366 */ + if (typeof configFileOptions.ruleListSplit === 'function') { + // @ts-expect-error -- The array is supposed to be read-only at this point. + generateOptions.ruleListSplit = configFileOptions.ruleListSplit; + } + // Invoke callback. await cb(path, generateOptions); }) diff --git a/lib/generator.ts b/lib/generator.ts index 46489ef4..e00fabff 100644 --- a/lib/generator.ts +++ b/lib/generator.ts @@ -156,10 +156,13 @@ export async function generate(path: string, options?: GenerateOptions) { options?.ruleDocTitleFormat ?? OPTION_DEFAULTS[OPTION_TYPE.RULE_DOC_TITLE_FORMAT]; const ruleListColumns = parseRuleListColumnsOption(options?.ruleListColumns); - const ruleListSplit = stringOrArrayToArrayWithFallback( - options?.ruleListSplit, - OPTION_DEFAULTS[OPTION_TYPE.RULE_LIST_SPLIT] - ); + const ruleListSplit = + typeof options?.ruleListSplit === 'function' + ? options.ruleListSplit + : stringOrArrayToArrayWithFallback( + options?.ruleListSplit, + OPTION_DEFAULTS[OPTION_TYPE.RULE_LIST_SPLIT] + ); const urlConfigs = options?.urlConfigs ?? OPTION_DEFAULTS[OPTION_TYPE.URL_CONFIGS]; const urlRuleDoc = diff --git a/lib/rule-list.ts b/lib/rule-list.ts index 2bbb60a1..7c45714e 100644 --- a/lib/rule-list.ts +++ b/lib/rule-list.ts @@ -15,7 +15,12 @@ import { findSectionHeader, findFinalHeaderLevel } from './markdown.js'; import { getPluginRoot } from './package-json.js'; import { generateLegend } from './rule-list-legend.js'; import { relative } from 'node:path'; -import { COLUMN_TYPE, RuleModule, SEVERITY_TYPE } from './types.js'; +import { + COLUMN_TYPE, + RuleListSplitFunction, + RuleModule, + SEVERITY_TYPE, +} from './types.js'; import { markdownTable } from 'markdown-table'; import type { Plugin, @@ -30,6 +35,7 @@ import { capitalizeOnlyFirstLetter } from './string.js'; import { noCase } from 'no-case'; import { getProperty } from 'dot-prop'; import { boolean, isBooleanable } from 'boolean'; +import Ajv from 'ajv'; function isBooleanableTrue(value: unknown): boolean { return isBooleanable(value) && boolean(value); @@ -219,7 +225,7 @@ function generateRulesListMarkdown( ); } -type RulesAndHeaders = { header?: string; rules: RuleNamesAndRules }[]; +type RulesAndHeaders = { title?: string; rules: RuleNamesAndRules }[]; type RulesAndHeadersReadOnly = Readonly; function generateRuleListMarkdownForRulesAndHeaders( @@ -237,9 +243,9 @@ function generateRuleListMarkdownForRulesAndHeaders( ): string { const parts: string[] = []; - for (const { header, rules } of rulesAndHeaders) { - if (header) { - parts.push(`${'#'.repeat(headerLevel)} ${header}`); + for (const { title, rules } of rulesAndHeaders) { + if (title) { + parts.push(`${'#'.repeat(headerLevel)} ${title}`); } parts.push( generateRulesListMarkdown( @@ -338,7 +344,7 @@ function getRulesAndHeadersForSplit( // Add a list for the rules with property set to this value. rulesAndHeadersForThisSplit.push({ - header: String(isBooleanableTrue(value) ? ruleListSplitTitle : value), + title: String(isBooleanableTrue(value) ? ruleListSplitTitle : value), rules: rulesForThisValue, }); @@ -372,7 +378,7 @@ export function updateRulesList( configEmojis: ConfigEmojis, ignoreConfig: readonly string[], ruleListColumns: readonly COLUMN_TYPE[], - ruleListSplit: readonly string[], + ruleListSplit: readonly string[] | RuleListSplitFunction, urlConfigs?: string, urlRuleDoc?: string ): string { @@ -440,7 +446,56 @@ export function updateRulesList( // Determine the pairs of rules and headers based on any split property. const rulesAndHeaders: RulesAndHeaders = []; - if (ruleListSplit.length > 0) { + if (typeof ruleListSplit === 'function') { + const userDefinedLists = ruleListSplit(ruleNamesAndRules); + + // Schema for the user-defined lists. + const schema = { + // Array of rule lists. + type: 'array', + items: { + type: 'object', + properties: { + title: { type: 'string' }, + rules: { + type: 'array', + items: { + type: 'array', + items: [ + { type: 'string' }, // The rule name. + { type: 'object' }, // The rule object (won't bother trying to validate deeper than this). + ], + minItems: 2, + maxItems: 2, + }, + minItems: 1, + uniqueItems: true, + }, + }, + required: ['rules'], + additionalProperties: false, + }, + minItems: 1, + uniqueItems: true, + }; + + // Validate the user-defined lists. + const ajv = new Ajv(); + const validate = ajv.compile(schema); + const valid = validate(userDefinedLists); + if (!valid) { + throw new Error( + validate.errors + ? ajv.errorsText(validate.errors, { + dataVar: 'ruleListSplit return value', + }) + : /* istanbul ignore next -- this shouldn't happen */ + 'Invalid ruleListSplit return value' + ); + } + + rulesAndHeaders.push(...userDefinedLists); + } else if (ruleListSplit.length > 0) { rulesAndHeaders.push( ...getRulesAndHeadersForSplit(ruleNamesAndRules, plugin, ruleListSplit) ); diff --git a/lib/types.ts b/lib/types.ts index c78b0f54..af4cec33 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -36,9 +36,12 @@ export const SEVERITY_TYPE_TO_SET: { export type ConfigsToRules = Record; /** - * Convenient way to pass around a list of rules (as tuples). + * List of rules in the form of tuples (rule name and the actual rule). */ -export type RuleNamesAndRules = readonly [name: string, rule: RuleModule][]; +export type RuleNamesAndRules = readonly (readonly [ + name: string, + rule: RuleModule +])[]; /** * The emoji for each config that has one after option parsing and defaults have been applied. @@ -101,6 +104,17 @@ export enum OPTION_TYPE { URL_RULE_DOC = 'urlRuleDoc', } +/** + * Function for splitting the rule list into multiple sections. + * Can be provided via a JavaScript-based config file using the `ruleListSplit` option. + * @param rules - all rules from the plugin + * @returns an array of sections, each with a title (optional) and list of rules + */ +export type RuleListSplitFunction = (rules: RuleNamesAndRules) => readonly { + title?: string; + rules: RuleNamesAndRules; +}[]; + // JSDocs for options should be kept in sync with README.md and the CLI runner in cli.ts. /** The type for the config file (e.g. `.eslint-doc-generatorrc.js`) and internal `generate()` function. */ export type GenerateOptions = { @@ -129,7 +143,7 @@ export type GenerateOptions = { /** * Function to be called with the generated content and file path for each processed file. * Useful for applying custom transformations such as formatting with tools like prettier. - * Only available via a JavaScript config file. + * Only available via a JavaScript-based config file. */ readonly postprocess?: ( content: string, @@ -158,11 +172,12 @@ export type GenerateOptions = { */ readonly ruleListColumns?: readonly `${COLUMN_TYPE}`[]; /** - * Rule property to split the rules list by. + * Rule property(s) or function to split the rules list by. * A separate list and header will be created for each value. * Example: `meta.type`. */ - readonly ruleListSplit?: string | readonly string[]; + readonly ruleListSplit?: string | readonly string[] | RuleListSplitFunction; + /** Link to documentation about the ESLint configurations exported by the plugin. */ readonly urlConfigs?: string; /** diff --git a/test/lib/cli-test.ts b/test/lib/cli-test.ts index b16e460c..c0dc0190 100644 --- a/test/lib/cli-test.ts +++ b/test/lib/cli-test.ts @@ -377,7 +377,94 @@ describe('cli', function () { ], stub ) - ).rejects.toThrow('postprocess must be a function'); + ).rejects.toThrow('postprocess must be a function.'); + }); + + it('ruleListSplit is the wrong primitive type', async function () { + mockFs({ + 'package.json': JSON.stringify({ + name: 'eslint-plugin-test', + main: 'index.js', + type: 'module', + version: '1.0.0', + }), + + '.eslint-doc-generatorrc.json': JSON.stringify({ + // Doesn't match schema. + ruleListSplit: 123, + }), + }); + + const stub = sinon.stub().resolves(); + await expect( + run( + [ + 'node', // Path to node. + 'eslint-doc-generator.js', // Path to this binary. + ], + stub + ) + ).rejects.toThrow( + 'config file/ruleListSplit must be string, config file/ruleListSplit must be array, config file/ruleListSplit must match a schema in anyOf' + ); + }); + + it('ruleListSplit is the wrong array type', async function () { + mockFs({ + 'package.json': JSON.stringify({ + name: 'eslint-plugin-test', + main: 'index.js', + type: 'module', + version: '1.0.0', + }), + + '.eslint-doc-generatorrc.json': JSON.stringify({ + // Doesn't match schema. + ruleListSplit: [123], + }), + }); + + const stub = sinon.stub().resolves(); + await expect( + run( + [ + 'node', // Path to node. + 'eslint-doc-generator.js', // Path to this binary. + ], + stub + ) + ).rejects.toThrow( + 'config file/ruleListSplit must be string, config file/ruleListSplit/0 must be string, config file/ruleListSplit must match a schema in anyOf' + ); + }); + + it('ruleListSplit is an empty array', async function () { + mockFs({ + 'package.json': JSON.stringify({ + name: 'eslint-plugin-test', + main: 'index.js', + type: 'module', + version: '1.0.0', + }), + + '.eslint-doc-generatorrc.json': JSON.stringify({ + // Doesn't match schema. + ruleListSplit: [], + }), + }); + + const stub = sinon.stub().resolves(); + await expect( + run( + [ + 'node', // Path to node. + 'eslint-doc-generator.js', // Path to this binary. + ], + stub + ) + ).rejects.toThrow( + 'config file/ruleListSplit must be string, config file/ruleListSplit must NOT have fewer than 1 items, config file/ruleListSplit must match a schema in anyOf' + ); }); }); }); diff --git a/test/lib/generate/__snapshots__/option-rule-list-split-test.ts.snap b/test/lib/generate/__snapshots__/option-rule-list-split-test.ts.snap index 4cdc167c..90e7f966 100644 --- a/test/lib/generate/__snapshots__/option-rule-list-split-test.ts.snap +++ b/test/lib/generate/__snapshots__/option-rule-list-split-test.ts.snap @@ -1,5 +1,39 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`generate (--rule-list-split) as a function generates the documentation 1`] = ` +"## Rules + + +❌ Deprecated. + +| Name | ❌ | +| :----------------------------- | :- | +| [no-biz](docs/rules/no-biz.md) | | + +### Not Deprecated + +| Name | ❌ | +| :----------------------------- | :- | +| [no-bar](docs/rules/no-bar.md) | | +| [no-baz](docs/rules/no-baz.md) | | +| [no-biz](docs/rules/no-biz.md) | | + +### Deprecated + +| Name | ❌ | +| :----------------------------- | :- | +| [no-foo](docs/rules/no-foo.md) | ❌ | + +### Name = "no-baz" + +| Name | ❌ | +| :----------------------------- | :- | +| [no-baz](docs/rules/no-baz.md) | | + + +" +`; + exports[`generate (--rule-list-split) by nested property meta.docs.category splits the list 1`] = ` "## Rules diff --git a/test/lib/generate/option-rule-list-split-test.ts b/test/lib/generate/option-rule-list-split-test.ts index b9db39c4..ea7d21b4 100644 --- a/test/lib/generate/option-rule-list-split-test.ts +++ b/test/lib/generate/option-rule-list-split-test.ts @@ -700,4 +700,167 @@ describe('generate (--rule-list-split)', function () { ); }); }); + + describe('as a function', function () { + beforeEach(function () { + mockFs({ + 'package.json': JSON.stringify({ + name: 'eslint-plugin-test', + exports: 'index.js', + type: 'module', + }), + + 'index.js': ` + export default { + rules: { + 'no-foo': { meta: { deprecated: true, }, create(context) {} }, + 'no-bar': { meta: { deprecated: false, }, create(context) {} }, + 'no-baz': { meta: { deprecated: false, }, create(context) {} }, + 'no-biz': { meta: { type: 'suggestion' }, create(context) {} }, + }, + };`, + + 'README.md': '## Rules\n', + + 'docs/rules/no-foo.md': '', + 'docs/rules/no-bar.md': '', + 'docs/rules/no-baz.md': '', + 'docs/rules/no-biz.md': '', + + // Needed for some of the test infrastructure to work. + node_modules: mockFs.load(PATH_NODE_MODULES), + }); + }); + + afterEach(function () { + mockFs.restore(); + jest.resetModules(); + }); + + it('generates the documentation', async function () { + await generate('.', { + ruleListSplit: (rules) => { + const list1 = { + rules: rules.filter(([, rule]) => rule.meta.type === 'suggestion'), + }; + const list2 = { + title: 'Not Deprecated', + rules: rules.filter(([, rule]) => !rule.meta.deprecated), + }; + const list3 = { + title: 'Deprecated', + rules: rules.filter(([, rule]) => rule.meta.deprecated), + }; + const list4 = { + title: 'Name = "no-baz"', + rules: rules.filter(([name]) => name === 'no-baz'), + }; + return [list1, list2, list3, list4]; + }, + }); + expect(readFileSync('README.md', 'utf8')).toMatchSnapshot(); + }); + }); + + describe('as a function but invalid return value', function () { + beforeEach(function () { + mockFs({ + 'package.json': JSON.stringify({ + name: 'eslint-plugin-test', + exports: 'index.js', + type: 'module', + }), + + 'index.js': ` + export default { + rules: { + 'no-foo': { meta: { deprecated: true, }, create(context) {} }, + }, + };`, + + 'README.md': '## Rules\n', + + 'docs/rules/no-foo.md': '', + + // Needed for some of the test infrastructure to work. + node_modules: mockFs.load(PATH_NODE_MODULES), + }); + }); + + afterEach(function () { + mockFs.restore(); + jest.resetModules(); + }); + + it('throws an error when no return value', async function () { + await expect( + generate('.', { + // @ts-expect-error -- intentionally invalid return value + ruleListSplit: () => { + return null; // eslint-disable-line unicorn/no-null -- intentionally invalid return value + }, + }) + ).rejects.toThrow('ruleListSplit return value must be array'); + }); + + it('throws an error when returning an empty array', async function () { + await expect( + generate('.', { + ruleListSplit: () => { + return []; + }, + }) + ).rejects.toThrow( + 'ruleListSplit return value must NOT have fewer than 1 items' + ); + }); + + it('throws an error when a sub-list has wrong type for rules', async function () { + await expect( + generate('.', { + // @ts-expect-error -- intentionally invalid return value + ruleListSplit: () => { + return [{ title: 'Foo', rules: null }]; // eslint-disable-line unicorn/no-null -- intentionally invalid return value + }, + }) + ).rejects.toThrow('ruleListSplit return value/0/rules must be array'); + }); + + it('throws an error when a sub-list has no rules', async function () { + await expect( + generate('.', { + ruleListSplit: () => { + return [{ title: 'Foo', rules: [] }]; + }, + }) + ).rejects.toThrow( + 'ruleListSplit return value/0/rules must NOT have fewer than 1 items' + ); + }); + + it('throws an error when a sub-list has a non-string title', async function () { + await expect( + generate('.', { + // @ts-expect-error -- intentionally invalid type + ruleListSplit: () => { + return [{ title: 123, rules: [{}] }]; + }, + }) + ).rejects.toThrow('ruleListSplit return value/0/title must be string'); + }); + + it('throws an error when same rule in list twice', async function () { + const rule = ['no-foo', {}]; + await expect( + generate('.', { + // @ts-expect-error -- intentionally invalid type + ruleListSplit: () => { + return [{ title: 'Foo', rules: [rule, rule] }]; + }, + }) + ).rejects.toThrow( + 'ruleListSplit return value/0/rules must NOT have duplicate items (items ## 0 and 1 are identical)' + ); + }); + }); });