Skip to content

Commit

Permalink
feat: support auto-generated rule options lists
Browse files Browse the repository at this point in the history
  • Loading branch information
bmish committed Oct 11, 2023
1 parent 22a0754 commit af5ca49
Show file tree
Hide file tree
Showing 11 changed files with 596 additions and 24 deletions.
30 changes: 29 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ Generates the following documentation covering a [wide variety](#column-and-noti
- `README.md` rules table
- `README.md` configs table
- Rule doc titles and notices
- Rule doc options lists

Also performs [configurable](#configuration-options) section consistency checks on rule docs:

Expand All @@ -18,11 +19,16 @@ Also performs [configurable](#configuration-options) section consistency checks

- [Motivation](#motivation)
- [Setup](#setup)
- [Scripts](#scripts)
- [Update `README.md`](#update-readmemd)
- [Update rule docs](#update-rule-docs)
- [Configure linting](#configure-linting)
- [Usage](#usage)
- [Examples](#examples)
- [Rules list table](#rules-list-table)
- [Configs list table](#configs-list-table)
- [Rule doc notices](#rule-doc-notices)
- [Rule doc options lists](#rule-doc-options-lists)
- [Users](#users)
- [Configuration options](#configuration-options)
- [Column and notice types](#column-and-notice-types)
Expand Down Expand Up @@ -52,6 +58,8 @@ Install it:
npm i --save-dev eslint-doc-generator
```

### Scripts

Add scripts to `package.json`:

- Both a lint script to ensure everything is up-to-date in CI and an update script for contributors to run locally
Expand All @@ -70,30 +78,46 @@ Add scripts to `package.json`:
}
```

### Update `README.md`

Delete any old rules list from your `README.md`. A new one will be automatically added to your `## Rules` section (along with the following marker comments if they don't already exist):

```md
<!-- begin auto-generated rules list -->
<!-- end auto-generated rules list -->
```

Optionally, add these marker comments to your `README.md` where you would like the configs list to go (uses the `description` property exported by each config if available):
Optionally, add these marker comments to your `README.md` in a `## Configs` section or similar location (uses the `description` property exported by each config if available):

```md
<!-- begin auto-generated configs list -->
<!-- end auto-generated configs list -->
```

### Update rule docs

Delete any old recommended/fixable/etc. notices from your rule docs. A new title and notices will be automatically added to the top of each rule doc (along with a marker comment if it doesn't already exist).

```md
<!-- end auto-generated rule header -->
```

Optionally, add these marker comments to your rule docs in an `## Options` section or similar location:

```md
<!-- begin auto-generated rule options list -->
<!-- end auto-generated rule options list -->
```

Note that rule option lists are subject-to-change as we add support for more kinds and properties of schemas. To fully take advantage of them, you'll want to ensure your rules have the `meta.schema` property fleshed out with properties like `description`, `type`, `enum`, `default`, `required`, `deprecated`.

### Configure linting

And be sure to enable the `recommended` rules from [eslint-plugin-eslint-plugin](https://github.com/eslint-community/eslint-plugin-eslint-plugin) as well as:

- [eslint-plugin/require-meta-docs-description](https://github.com/eslint-community/eslint-plugin-eslint-plugin/blob/main/docs/rules/require-meta-docs-description.md) to ensure your rules have consistent descriptions for use in the generated docs
- [eslint-plugin/require-meta-docs-url](https://github.com/eslint-community/eslint-plugin-eslint-plugin/blob/main/docs/rules/require-meta-docs-url.md) to ensure your rule docs are linked to by editors on highlighted violations
- [eslint-plugin/require-meta-schema](https://github.com/eslint-community/eslint-plugin-eslint-plugin/blob/main/docs/rules/require-meta-schema.md) to ensure your rules have schemas for use in determining options

## Usage

Expand All @@ -119,6 +143,10 @@ See the generated configs table in our example [`README.md`](./docs/examples/esl

See the generated rule doc title and notices in our example rule docs [`no-foo.md`](./docs/examples/eslint-plugin-test/docs/rules/no-foo.md), [`prefer-bar.md`](./docs/examples/eslint-plugin-test/docs/rules/prefer-bar.md), [`require-baz.md`](./docs/examples/eslint-plugin-test/docs/rules/require-baz.md).

### Rule doc options lists

See the generated rule doc options lists in our example rule doc [`no-foo.md`](./docs/examples/eslint-plugin-test/docs/rules/no-foo.md).

### Users

This tool is used by popular ESLint plugins like:
Expand Down
33 changes: 32 additions & 1 deletion docs/examples/eslint-plugin-test/docs/rules/no-foo.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,35 @@ Examples would normally go here.

## Options

Config options would normally go here.
<!-- begin auto-generated rule options list -->

| Name | Description | Type | Choices | Default | Required | Deprecated |
| :---- | :---------------------------- | :------ | :---------------- | :------- | :------- | :--------- |
| `bar` | Choose how to use the rule. | String | `always`, `never` | `always` | Yes | |
| `foo` | Enable some kind of behavior. | Boolean | | `false` | | Yes |

<!-- end auto-generated rule options list -->

For the purpose of this example, below is the `meta.schema` that would be generated for the above rule options table:

```json
[{
"type": "object",
"properties": {
"foo": {
"type": "boolean",
"description": "Enable some kind of behavior.",
"deprecated": true,
"default": false
},
"bar": {
"description": "Choose how to use the rule.",
"type": "string",
"enum": ["always", "never"],
"default": "always"
}
},
"required": ["bar"],
"additionalProperties": false
}]
```
6 changes: 6 additions & 0 deletions lib/comment-markers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,9 @@ export const BEGIN_CONFIG_LIST_MARKER =
'<!-- begin auto-generated configs list -->';
export const END_CONFIG_LIST_MARKER =
'<!-- end auto-generated configs list -->';

// Markers so that the rule options table list can be automatically updated.
export const BEGIN_RULE_OPTIONS_LIST_MARKER =
'<!-- begin auto-generated rule options list -->';
export const END_RULE_OPTIONS_LIST_MARKER =
'<!-- end auto-generated rule options list -->';
8 changes: 6 additions & 2 deletions lib/generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { diff } from 'jest-diff';
import type { GenerateOptions } from './types.js';
import { OPTION_TYPE, RuleModule } from './types.js';
import { replaceRulePlaceholder } from './rule-link.js';
import { updateRuleOptionsList } from './rule-options-list.js';

function stringOrArrayWithFallback<T extends string | readonly string[]>(
stringOrArray: undefined | T,
Expand Down Expand Up @@ -180,7 +181,10 @@ export async function generate(path: string, options?: GenerateOptions) {

const contents = readFileSync(pathToDoc).toString();
const contentsNew = await postprocess(
replaceOrCreateHeader(contents, newHeaderLines, END_RULE_HEADER_MARKER),
updateRuleOptionsList(
replaceOrCreateHeader(contents, newHeaderLines, END_RULE_HEADER_MARKER),
rule
),
resolve(pathToDoc)
);

Expand Down Expand Up @@ -229,7 +233,7 @@ export async function generate(path: string, options?: GenerateOptions) {
['Options', 'Config'],
hasOptions(schema)
);
for (const namedOption of getAllNamedOptions(schema)) {
for (const { name: namedOption } of getAllNamedOptions(schema)) {
expectContentOrFail(
`\`${name}\` rule doc`,
'rule option',
Expand Down
150 changes: 150 additions & 0 deletions lib/rule-options-list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import {
BEGIN_RULE_OPTIONS_LIST_MARKER,
END_RULE_OPTIONS_LIST_MARKER,
} from './comment-markers.js';
import { markdownTable } from 'markdown-table';
import type { RuleModule } from './types.js';
import { RuleOption, getAllNamedOptions } from './rule-options.js';
import { capitalizeOnlyFirstLetter } from './string.js';

export enum COLUMN_TYPE {
// Alphabetical order.
DEFAULT = 'default',
DEPRECATED = 'deprecated',
DESCRIPTION = 'description',
ENUM = 'enum',
NAME = 'name',
REQUIRED = 'required',
TYPE = 'type',
}

const HEADERS: {
[key in COLUMN_TYPE]: string;
} = {
// Alphabetical order.
[COLUMN_TYPE.DEFAULT]: 'Default',
[COLUMN_TYPE.DEPRECATED]: 'Deprecated',
[COLUMN_TYPE.DESCRIPTION]: 'Description',
[COLUMN_TYPE.ENUM]: 'Choices',
[COLUMN_TYPE.NAME]: 'Name',
[COLUMN_TYPE.REQUIRED]: 'Required',
[COLUMN_TYPE.TYPE]: 'Type',
};

const COLUMN_TYPE_DEFAULT_PRESENCE_AND_ORDERING: {
[key in COLUMN_TYPE]: boolean;
} = {
// Object keys ordered in display order.
// Object values indicate whether the column is displayed by default.
[COLUMN_TYPE.NAME]: true,
[COLUMN_TYPE.DESCRIPTION]: true,
[COLUMN_TYPE.TYPE]: true,
[COLUMN_TYPE.ENUM]: true,
[COLUMN_TYPE.DEFAULT]: true,
[COLUMN_TYPE.REQUIRED]: true,
[COLUMN_TYPE.DEPRECATED]: true,
};

function ruleOptionToColumnValues(ruleOption: RuleOption): {
[key in COLUMN_TYPE]: string | undefined;
} {
const columns: {
[key in COLUMN_TYPE]: string | undefined;
} = {
// Alphabetical order.
[COLUMN_TYPE.DEFAULT]:
ruleOption.default === undefined
? undefined
: `\`${String(ruleOption.default)}\``,
[COLUMN_TYPE.DEPRECATED]: ruleOption.deprecated ? 'Yes' : undefined,
[COLUMN_TYPE.DESCRIPTION]: ruleOption.description,
[COLUMN_TYPE.ENUM]:
ruleOption.enum && ruleOption.enum.length > 0
? `\`${ruleOption.enum.join('`, `')}\``
: undefined,
[COLUMN_TYPE.NAME]: `\`${ruleOption.name}\``,
[COLUMN_TYPE.REQUIRED]: ruleOption.required ? 'Yes' : undefined,
[COLUMN_TYPE.TYPE]: ruleOption.type
? capitalizeOnlyFirstLetter(ruleOption.type)
: undefined,
};

return columns;
}

function ruleOptionsToColumnsToDisplay(ruleOptions: readonly RuleOption[]): {
[key in COLUMN_TYPE]: boolean;
} {
const columnsToDisplay: {
[key in COLUMN_TYPE]: boolean;
} = {
// Alphabetical order.
[COLUMN_TYPE.DEFAULT]: ruleOptions.some((ruleOption) => ruleOption.default),
[COLUMN_TYPE.DEPRECATED]: ruleOptions.some(
(ruleOption) => ruleOption.deprecated
),
[COLUMN_TYPE.DESCRIPTION]: ruleOptions.some(
(ruleOption) => ruleOption.description
),
[COLUMN_TYPE.ENUM]: ruleOptions.some((ruleOption) => ruleOption.enum),
[COLUMN_TYPE.NAME]: true,
[COLUMN_TYPE.REQUIRED]: ruleOptions.some(
(ruleOption) => ruleOption.required
),
[COLUMN_TYPE.TYPE]: ruleOptions.some((ruleOption) => ruleOption.type),
};
return columnsToDisplay;
}

function generateRuleOptionsListMarkdown(rule: RuleModule): string {
const ruleOptions = getAllNamedOptions(rule.meta.schema);

if (ruleOptions.length === 0) {
return '';
}

const columnsToDisplay = ruleOptionsToColumnsToDisplay(ruleOptions);
const listHeaderRow = Object.keys(COLUMN_TYPE_DEFAULT_PRESENCE_AND_ORDERING)
.filter((type) => columnsToDisplay[type as COLUMN_TYPE])
.map((type) => HEADERS[type as COLUMN_TYPE]);

const rows = [...ruleOptions]
.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()))
.map((ruleOption) => {
const ruleOptionColumnValues = ruleOptionToColumnValues(ruleOption);

// Recreate object using correct ordering and presence of columns.
return Object.keys(COLUMN_TYPE_DEFAULT_PRESENCE_AND_ORDERING)
.filter((type) => columnsToDisplay[type as COLUMN_TYPE])
.map((type) => ruleOptionColumnValues[type as COLUMN_TYPE]);
});

return markdownTable(
[listHeaderRow, ...rows],
{ align: 'l' } // Left-align headers.
);
}

export function updateRuleOptionsList(
markdown: string,
rule: RuleModule
): string {
const listStartIndex = markdown.indexOf(BEGIN_RULE_OPTIONS_LIST_MARKER);
let listEndIndex = markdown.indexOf(END_RULE_OPTIONS_LIST_MARKER);

if (listStartIndex === -1 || listEndIndex === -1) {
// No rule options list found.
return markdown;
}

// Account for length of pre-existing marker.
listEndIndex += END_RULE_OPTIONS_LIST_MARKER.length;

const preList = markdown.slice(0, Math.max(0, listStartIndex));
const postList = markdown.slice(Math.max(0, listEndIndex));

// New rule options list.
const list = generateRuleOptionsListMarkdown(rule);

return `${preList}${BEGIN_RULE_OPTIONS_LIST_MARKER}\n\n${list}\n\n${END_RULE_OPTIONS_LIST_MARKER}${postList}`;
}
39 changes: 34 additions & 5 deletions lib/rule-options.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,28 @@
import traverse from 'json-schema-traverse';
import type { JSONSchema } from '@typescript-eslint/utils';

export type RuleOption = {
name: string;
type?: string;
description?: string;
required?: boolean;
enum?: readonly JSONSchema.JSONSchema4Type[];
default?: JSONSchema.JSONSchema4Type;
deprecated?: boolean;
};

/**
* Gather a list of named options from a rule schema.
* @param jsonSchema - the JSON schema to check
* @returns - list of named options we could detect from the schema
*/
export function getAllNamedOptions(
jsonSchema: JSONSchema.JSONSchema4 | undefined | null
): readonly string[] {
jsonSchema:
| JSONSchema.JSONSchema4
| readonly JSONSchema.JSONSchema4[]
| undefined
| null
): readonly RuleOption[] {
if (!jsonSchema) {
return [];
}
Expand All @@ -19,10 +33,23 @@ export function getAllNamedOptions(
);
}

const options: string[] = [];
const options: RuleOption[] = [];
traverse(jsonSchema, (js: JSONSchema.JSONSchema4) => {
if (js.properties) {
options.push(...Object.keys(js.properties));
options.push(
...Object.entries(js.properties).map(([key, value]) => ({
name: key,
type: value.type ? value.type.toString() : undefined,
description: value.description,
default: value.default,
enum: value.enum,
required:
typeof value.required === 'boolean'
? value.required
: Array.isArray(js.required) && js.required.includes(key),
deprecated: value.deprecated, // eslint-disable-line @typescript-eslint/no-unsafe-assignment -- property exists on future JSONSchema version but we can let it be used anyway.
}))
);
}
});
return options;
Expand All @@ -33,7 +60,9 @@ export function getAllNamedOptions(
* @param jsonSchema - the JSON schema to check
* @returns - whether the schema has options
*/
export function hasOptions(jsonSchema: JSONSchema.JSONSchema4): boolean {
export function hasOptions(
jsonSchema: JSONSchema.JSONSchema4 | readonly JSONSchema.JSONSchema4[]
): boolean {
return (
(Array.isArray(jsonSchema) && jsonSchema.length > 0) ||
(typeof jsonSchema === 'object' && Object.keys(jsonSchema).length > 0)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`generate (comment markers) README missing rule list markers and no rules section throws an error 1`] = `"README.md is missing rules list markers: <!-- begin auto-generated rules list --><!-- end auto-generated rules list -->"`;
exports[`generate (comment markers) README missing rule list markers and no rules section throws an error 1`] = `"Unable to determine plugin entry point."`;

exports[`generate (comment markers) README missing rule list markers but with rules section adds rule list markers to rule section 1`] = `
"# eslint-plugin-test
Expand Down

0 comments on commit af5ca49

Please sign in to comment.