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

Support auto-generated rule options lists #481

Merged
merged 1 commit into from
Oct 12, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 generate 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
Loading