diff --git a/.README/rules/require-template.md b/.README/rules/require-template.md new file mode 100644 index 00000000..73bff6b8 --- /dev/null +++ b/.README/rules/require-template.md @@ -0,0 +1,54 @@ +# `require-template` + +Checks to see that `@template` tags are present for any detected type +parameters. + +Currently checks `TSTypeAliasDeclaration` such as: + +```ts +export type Pairs = [D, V | undefined]; +``` + +or + +```js +/** + * @typedef {[D, V | undefined]} Pairs + */ +``` + +Note that in the latter TypeScript-flavor JavaScript example, there is no way +for us to firmly distinguish between `D` and `V` as type parameters or as some +other identifiers, so we use an algorithm that any single capital letters +are assumed to be templates. + +## Options + +### `requireSeparateTemplates` + +Requires that each template have its own separate line, i.e., preventing +templates of this format: + +```js +/** + * @template T, U, V + */ +``` + +Defaults to `false`. + +||| +|---|---| +|Context|everywhere| +|Tags|`template`| +|Recommended|true| +|Settings|| +|Options|`requireSeparateTemplates`| + +## Failing examples + + + +## Passing examples + + diff --git a/.ncurc.cjs b/.ncurc.cjs index 503643a4..203f1755 100644 --- a/.ncurc.cjs +++ b/.ncurc.cjs @@ -2,7 +2,7 @@ module.exports = { reject: [ - // Todo: When package converted to ESM only + // Todo: When our package converted to ESM only 'escape-string-regexp', // todo[engine:node@>=20]: Can reenable diff --git a/docs/rules/require-template.md b/docs/rules/require-template.md new file mode 100644 index 00000000..12ab3d4c --- /dev/null +++ b/docs/rules/require-template.md @@ -0,0 +1,35 @@ + + +# require-template + +||| +|---|---| +|Context|everywhere| +|Tags|``| +|Recommended|true| +|Settings|| +|Options|| + + + +## Failing examples + +The following patterns are considered problems: + +````js + +// Message: +```` + + + + + +## Passing examples + +The following patterns are not considered problems: + +````js + +```` + diff --git a/src/index.js b/src/index.js index 535ed0b6..8f2159a0 100644 --- a/src/index.js +++ b/src/index.js @@ -45,6 +45,7 @@ import requireReturns from './rules/requireReturns.js'; import requireReturnsCheck from './rules/requireReturnsCheck.js'; import requireReturnsDescription from './rules/requireReturnsDescription.js'; import requireReturnsType from './rules/requireReturnsType.js'; +import requireTemplate from './rules/requireTemplate.js'; import requireThrows from './rules/requireThrows.js'; import requireYields from './rules/requireYields.js'; import requireYieldsCheck from './rules/requireYieldsCheck.js'; @@ -118,6 +119,7 @@ const index = { 'require-returns-check': requireReturnsCheck, 'require-returns-description': requireReturnsDescription, 'require-returns-type': requireReturnsType, + 'require-template': requireTemplate, 'require-throws': requireThrows, 'require-yields': requireYields, 'require-yields-check': requireYieldsCheck, @@ -191,6 +193,7 @@ const createRecommendedRuleset = (warnOrError, flatName) => { 'jsdoc/require-returns-check': warnOrError, 'jsdoc/require-returns-description': warnOrError, 'jsdoc/require-returns-type': warnOrError, + 'jsdoc/require-template': warnOrError, 'jsdoc/require-throws': 'off', 'jsdoc/require-yields': warnOrError, 'jsdoc/require-yields-check': warnOrError, diff --git a/src/rules/requireTemplate.js b/src/rules/requireTemplate.js new file mode 100644 index 00000000..015902eb --- /dev/null +++ b/src/rules/requireTemplate.js @@ -0,0 +1,119 @@ +import { + parse as parseType, + traverse, + tryParse as tryParseType, +} from '@es-joy/jsdoccomment'; +import iterateJsdoc from '../iterateJsdoc.js'; + +export default iterateJsdoc(({ + context, + utils, + node, + settings, + report, +}) => { + const { + requireSeparateTemplates = false, + } = context.options[0] || {}; + + const { + mode + } = settings; + + const usedNames = new Set(); + const templateTags = utils.getTags('template'); + const templateNames = templateTags.flatMap(({name}) => { + return name.split(/,\s*/); + }); + + for (const tag of templateTags) { + const {name} = tag; + const names = name.split(/,\s*/); + if (requireSeparateTemplates && names.length > 1) { + report(`Missing separate @template for ${names[1]}`, null, tag); + } + } + + /** + * @param {import('@typescript-eslint/types').TSESTree.TSTypeAliasDeclaration} aliasDeclaration + */ + const checkParameters = (aliasDeclaration) => { + /* c8 ignore next -- Guard */ + const {params} = aliasDeclaration.typeParameters ?? {params: []}; + for (const {name: {name}} of params) { + usedNames.add(name); + } + for (const usedName of usedNames) { + if (!templateNames.includes(usedName)) { + report(`Missing @template ${usedName}`); + } + } + }; + + const handleTypeAliases = () => { + const nde = /** @type {import('@typescript-eslint/types').TSESTree.Node} */ ( + node + ); + if (!nde) { + return; + } + switch (nde.type) { + case 'ExportNamedDeclaration': + if (nde.declaration?.type === 'TSTypeAliasDeclaration') { + checkParameters(nde.declaration); + } + break; + case 'TSTypeAliasDeclaration': + checkParameters(nde); + break; + } + }; + + const typedefTags = utils.getTags('typedef'); + if (!typedefTags.length || typedefTags.length >= 2) { + handleTypeAliases(); + return; + } + + const potentialType = typedefTags[0].type; + const parsedType = mode === 'permissive' ? + tryParseType(/** @type {string} */ (potentialType)) : + parseType(/** @type {string} */ (potentialType), mode) + + traverse(parsedType, (nde) => { + const { + type, + value, + } = /** @type {import('jsdoc-type-pratt-parser').NameResult} */ (nde); + if (type === 'JsdocTypeName' && (/^[A-Z]$/).test(value)) { + usedNames.add(value); + } + }); + + // Could check against whitelist/blacklist + for (const usedName of usedNames) { + if (!templateNames.includes(usedName)) { + report(`Missing @template ${usedName}`, null, typedefTags[0]); + } + } +}, { + iterateAllJsdocs: true, + meta: { + docs: { + description: 'Requires template tags for each generic type parameter', + url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/require-template.md#repos-sticky-header', + }, + schema: [ + { + additionalProperties: false, + properties: { + requireSeparateTemplates: { + type: 'boolean' + } + }, + type: 'object', + }, + ], + type: 'suggestion', + }, +}); diff --git a/test/rules/assertions/requireTemplate.js b/test/rules/assertions/requireTemplate.js new file mode 100644 index 00000000..6bb092b4 --- /dev/null +++ b/test/rules/assertions/requireTemplate.js @@ -0,0 +1,209 @@ +import {parser as typescriptEslintParser} from 'typescript-eslint'; + +export default { + invalid: [ + { + code: ` + /** + * + */ + type Pairs = [D, V | undefined]; + `, + errors: [ + { + line: 2, + message: 'Missing @template D', + }, + { + line: 2, + message: 'Missing @template V', + }, + ], + languageOptions: { + parser: typescriptEslintParser + }, + }, + { + code: ` + /** + * + */ + export type Pairs = [D, V | undefined]; + `, + errors: [ + { + line: 2, + message: 'Missing @template D', + }, + { + line: 2, + message: 'Missing @template V', + }, + ], + languageOptions: { + parser: typescriptEslintParser + }, + }, + { + code: ` + /** + * @typedef {[D, V | undefined]} Pairs + */ + `, + errors: [ + { + line: 3, + message: 'Missing @template D', + }, + { + line: 3, + message: 'Missing @template V', + }, + ], + }, + { + code: ` + /** + * @typedef {[D, V | undefined]} Pairs + */ + `, + errors: [ + { + line: 3, + message: 'Missing @template D', + }, + { + line: 3, + message: 'Missing @template V', + }, + ], + settings: { + jsdoc: { + mode: 'permissive', + }, + }, + }, + { + code: ` + /** + * @template D, U + */ + export type Extras = [D, U, V | undefined]; + `, + errors: [ + { + line: 2, + message: 'Missing @template V', + }, + ], + languageOptions: { + parser: typescriptEslintParser + }, + }, + { + code: ` + /** + * @template D, U + * @typedef {[D, U, V | undefined]} Extras + */ + `, + errors: [ + { + line: 4, + message: 'Missing @template V', + }, + ], + }, + { + code: ` + /** + * @template D, V + */ + export type Pairs = [D, V | undefined]; + `, + errors: [ + { + line: 3, + message: 'Missing separate @template for V', + }, + ], + languageOptions: { + parser: typescriptEslintParser + }, + options: [ + { + requireSeparateTemplates: true, + } + ], + }, + { + code: ` + /** + * @template D, V + * @typedef {[D, V | undefined]} Pairs + */ + `, + errors: [ + { + line: 3, + message: 'Missing separate @template for V', + }, + ], + options: [ + { + requireSeparateTemplates: true, + } + ], + }, + ], + valid: [ + { + code: ` + /** + * @template D + * @template V + */ + export type Pairs = [D, V | undefined]; + `, + languageOptions: { + parser: typescriptEslintParser + }, + }, + { + code: ` + /** + * @template D + * @template V + * @typedef {[D, V | undefined]} Pairs + */ + `, + }, + { + code: ` + /** + * @template D, U, V + */ + export type Extras = [D, U, V | undefined]; + `, + languageOptions: { + parser: typescriptEslintParser + }, + }, + { + code: ` + /** + * @template D, U, V + * @typedef {[D, U, V | undefined]} Extras + */ + `, + }, + { + code: ` + /** + * @typedef {[D, U, V | undefined]} Extras + * @typedef {[D, U, V | undefined]} Extras + */ + `, + }, + ], +}; diff --git a/test/rules/ruleNames.json b/test/rules/ruleNames.json index 15418c0d..c158f48e 100644 --- a/test/rules/ruleNames.json +++ b/test/rules/ruleNames.json @@ -46,6 +46,7 @@ "require-returns-check", "require-returns-description", "require-returns-type", + "require-template", "require-throws", "require-yields", "require-yields-check",