Website Structure

This commit is contained in:
supalerk-ar66 2026-01-13 10:46:40 +07:00
parent 62812f2090
commit 71f0676a62
22365 changed files with 4265753 additions and 791 deletions

View file

@ -0,0 +1,34 @@
const WarnSettings = function () {
/** @type {WeakMap<object, Set<string>>} */
const warnedSettings = new WeakMap();
return {
/**
* Warn only once for each context and setting
* @param {import('eslint').Rule.RuleContext} context
* @param {string} setting
* @returns {boolean}
*/
hasBeenWarned (context, setting) {
return warnedSettings.has(context) && /** @type {Set<string>} */ (
warnedSettings.get(context)
).has(setting);
},
/**
* @param {import('eslint').Rule.RuleContext} context
* @param {string} setting
* @returns {void}
*/
markSettingAsWarned (context, setting) {
// c8 ignore else
if (!warnedSettings.has(context)) {
warnedSettings.set(context, new Set());
}
/** @type {Set<string>} */ (warnedSettings.get(context)).add(setting);
},
};
};
export default WarnSettings;

View file

@ -0,0 +1,444 @@
/**
* Transform based on https://github.com/syavorsky/comment-parser/blob/master/src/transforms/align.ts
*
* It contains some customizations to align based on the tags, and some custom options.
*/
import {
// `comment-parser/primitives` export
util,
} from 'comment-parser';
/**
* Detects if a line starts with a markdown list marker
* Supports: -, *, numbered lists (1., 2., etc.)
* This explicitly excludes hyphens that are part of JSDoc tag syntax
* @param {string} text - The text to check
* @param {boolean} isFirstLineOfTag - True if this is the first line (tag line)
* @returns {boolean} - True if the text starts with a list marker
*/
const startsWithListMarker = (text, isFirstLineOfTag = false) => {
// On the first line of a tag, the hyphen is typically the JSDoc separator,
// not a list marker
if (isFirstLineOfTag) {
return false;
}
// Match lines that start with optional whitespace, then a list marker
// - or * followed by a space
// or a number followed by . or ) and a space
return /^\s*(?:[\-*]|\d+(?:\.|\)))\s+/v.test(text);
};
/**
* @typedef {{
* hasNoTypes: boolean,
* maxNamedTagLength: import('./iterateJsdoc.js').Integer,
* maxUnnamedTagLength: import('./iterateJsdoc.js').Integer
* }} TypelessInfo
*/
const {
rewireSource,
} = util;
/**
* @typedef {{
* name: import('./iterateJsdoc.js').Integer,
* start: import('./iterateJsdoc.js').Integer,
* tag: import('./iterateJsdoc.js').Integer,
* type: import('./iterateJsdoc.js').Integer
* }} Width
*/
/** @type {Width} */
const zeroWidth = {
name: 0,
start: 0,
tag: 0,
type: 0,
};
/**
* @param {string[]} tags
* @param {import('./iterateJsdoc.js').Integer} index
* @param {import('comment-parser').Line[]} source
* @returns {boolean}
*/
const shouldAlign = (tags, index, source) => {
const tag = source[index].tokens.tag.replace('@', '');
const includesTag = tags.includes(tag);
if (includesTag) {
return true;
}
if (tag !== '') {
return false;
}
for (let iterator = index; iterator >= 0; iterator--) {
const previousTag = source[iterator].tokens.tag.replace('@', '');
if (previousTag !== '') {
if (tags.includes(previousTag)) {
return true;
}
return false;
}
}
return true;
};
/**
* @param {string[]} tags
* @returns {(
* width: Width,
* line: {
* tokens: import('comment-parser').Tokens
* },
* index: import('./iterateJsdoc.js').Integer,
* source: import('comment-parser').Line[]
* ) => Width}
*/
const getWidth = (tags) => {
return (width, {
tokens,
}, index, source) => {
if (!shouldAlign(tags, index, source)) {
return width;
}
return {
name: Math.max(width.name, tokens.name.length),
start: tokens.delimiter === '/**' ? tokens.start.length : width.start,
tag: Math.max(width.tag, tokens.tag.length),
type: Math.max(width.type, tokens.type.length),
};
};
};
/**
* @param {{
* description: string;
* tags: import('comment-parser').Spec[];
* problems: import('comment-parser').Problem[];
* }} fields
* @returns {TypelessInfo}
*/
const getTypelessInfo = (fields) => {
const hasNoTypes = fields.tags.every(({
type,
}) => {
return !type;
});
const maxNamedTagLength = Math.max(...fields.tags.map(({
name,
tag,
}) => {
return name.length === 0 ? -1 : tag.length;
}).filter((length) => {
return length !== -1;
})) + 1;
const maxUnnamedTagLength = Math.max(...fields.tags.map(({
name,
tag,
}) => {
return name.length === 0 ? tag.length : -1;
}).filter((length) => {
return length !== -1;
})) + 1;
return {
hasNoTypes,
maxNamedTagLength,
maxUnnamedTagLength,
};
};
/**
* @param {import('./iterateJsdoc.js').Integer} len
* @returns {string}
*/
const space = (len) => {
return ''.padStart(len, ' ');
};
/**
* Check if a tag or any of its lines contain list markers
* @param {import('./iterateJsdoc.js').Integer} index - Current line index
* @param {import('comment-parser').Line[]} source - All source lines
* @returns {{hasListMarker: boolean, tagStartIndex: import('./iterateJsdoc.js').Integer}}
*/
const checkForListMarkers = (index, source) => {
let hasListMarker = false;
let tagStartIndex = index;
while (tagStartIndex > 0 && source[tagStartIndex].tokens.tag === '') {
tagStartIndex--;
}
for (let idx = tagStartIndex; idx <= index; idx++) {
const isFirstLine = (idx === tagStartIndex);
if (source[idx]?.tokens?.description && startsWithListMarker(source[idx].tokens.description, isFirstLine)) {
hasListMarker = true;
break;
}
}
return {
hasListMarker,
tagStartIndex,
};
};
/**
* Calculate extra indentation for list items relative to the first continuation line
* @param {import('./iterateJsdoc.js').Integer} index - Current line index
* @param {import('./iterateJsdoc.js').Integer} tagStartIndex - Index of the tag line
* @param {import('comment-parser').Line[]} source - All source lines
* @returns {string} - Extra indentation spaces
*/
const calculateListExtraIndent = (index, tagStartIndex, source) => {
// Find the first continuation line to use as baseline
let firstContinuationIndent = null;
for (let idx = tagStartIndex + 1; idx < source.length; idx++) {
if (source[idx].tokens.description && !source[idx].tokens.tag) {
firstContinuationIndent = source[idx].tokens.postDelimiter.length;
break;
}
}
// Calculate the extra indentation of current line relative to the first continuation line
const currentOriginalIndent = source[index].tokens.postDelimiter.length;
const extraIndent = firstContinuationIndent !== null && currentOriginalIndent > firstContinuationIndent ?
' '.repeat(currentOriginalIndent - firstContinuationIndent) :
'';
return extraIndent;
};
/**
* @param {{
* customSpacings: import('../src/rules/checkLineAlignment.js').CustomSpacings,
* tags: string[],
* indent: string,
* preserveMainDescriptionPostDelimiter: boolean,
* wrapIndent: string,
* disableWrapIndent: boolean,
* }} cfg
* @returns {(
* block: import('comment-parser').Block
* ) => import('comment-parser').Block}
*/
const alignTransform = ({
customSpacings,
disableWrapIndent,
indent,
preserveMainDescriptionPostDelimiter,
tags,
wrapIndent,
}) => {
let intoTags = false;
/** @type {Width} */
let width;
/**
* @param {import('comment-parser').Tokens} tokens
* @param {TypelessInfo} typelessInfo
* @returns {import('comment-parser').Tokens}
*/
const alignTokens = (tokens, typelessInfo) => {
const nothingAfter = {
delim: false,
name: false,
tag: false,
type: false,
};
if (tokens.description === '') {
nothingAfter.name = true;
tokens.postName = '';
if (tokens.name === '') {
nothingAfter.type = true;
tokens.postType = '';
if (tokens.type === '') {
nothingAfter.tag = true;
tokens.postTag = '';
/* c8 ignore next: Never happens because the !intoTags return. But it's here for consistency with the original align transform */
if (tokens.tag === '') {
nothingAfter.delim = true;
}
}
}
}
let untypedNameAdjustment = 0;
let untypedTypeAdjustment = 0;
if (typelessInfo.hasNoTypes) {
nothingAfter.tag = true;
tokens.postTag = '';
if (tokens.name === '') {
untypedNameAdjustment = typelessInfo.maxNamedTagLength - tokens.tag.length;
} else {
untypedNameAdjustment = typelessInfo.maxNamedTagLength > typelessInfo.maxUnnamedTagLength ? 0 :
Math.max(0, typelessInfo.maxUnnamedTagLength - (tokens.tag.length + tokens.name.length + 1));
untypedTypeAdjustment = typelessInfo.maxNamedTagLength - tokens.tag.length;
}
}
// Todo: Avoid fixing alignment of blocks with multiline wrapping of type
if (tokens.tag === '' && tokens.type) {
return tokens;
}
const spacings = {
postDelimiter: customSpacings?.postDelimiter || 1,
postName: customSpacings?.postName || 1,
postTag: customSpacings?.postTag || 1,
postType: customSpacings?.postType || 1,
};
tokens.postDelimiter = nothingAfter.delim ? '' : space(spacings.postDelimiter);
if (!nothingAfter.tag) {
tokens.postTag = space(width.tag - tokens.tag.length + spacings.postTag);
}
if (!nothingAfter.type) {
tokens.postType = space(width.type - tokens.type.length + spacings.postType + untypedTypeAdjustment);
}
if (!nothingAfter.name) {
// If post name is empty for all lines (name width 0), don't add post name spacing.
tokens.postName = width.name === 0 ? '' : space(width.name - tokens.name.length + spacings.postName + untypedNameAdjustment);
}
return tokens;
};
/**
* @param {import('comment-parser').Line} line
* @param {import('./iterateJsdoc.js').Integer} index
* @param {import('comment-parser').Line[]} source
* @param {TypelessInfo} typelessInfo
* @param {string|false} indentTag
* @returns {import('comment-parser').Line}
*/
const update = (line, index, source, typelessInfo, indentTag) => {
/** @type {import('comment-parser').Tokens} */
const tokens = {
...line.tokens,
};
if (tokens.tag !== '') {
intoTags = true;
}
const isEmpty =
tokens.tag === '' &&
tokens.name === '' &&
tokens.type === '' &&
tokens.description === '';
// dangling '*/'
if (tokens.end === '*/' && isEmpty) {
tokens.start = indent + ' ';
return {
...line,
tokens,
};
}
switch (tokens.delimiter) {
case '*':
tokens.start = indent + ' ';
break;
case '/**':
tokens.start = indent;
break;
default:
tokens.delimiter = '';
// compensate delimiter
tokens.start = indent + ' ';
}
if (!intoTags) {
if (tokens.description === '') {
tokens.postDelimiter = '';
} else if (!preserveMainDescriptionPostDelimiter) {
tokens.postDelimiter = ' ';
}
return {
...line,
tokens,
};
}
const postHyphenSpacing = customSpacings?.postHyphen ?? 1;
const hyphenSpacing = /^\s*-\s+/v;
tokens.description = tokens.description.replace(
hyphenSpacing, '-' + ''.padStart(postHyphenSpacing, ' '),
);
// Not align.
if (shouldAlign(tags, index, source)) {
alignTokens(tokens, typelessInfo);
if (!disableWrapIndent && indentTag) {
const {
hasListMarker,
tagStartIndex,
} = checkForListMarkers(index, source);
if (hasListMarker && index > tagStartIndex) {
const extraIndent = calculateListExtraIndent(index, tagStartIndex, source);
tokens.postDelimiter += wrapIndent + extraIndent;
} else {
// Normal case: add wrapIndent after the aligned delimiter
tokens.postDelimiter += wrapIndent;
}
}
}
return {
...line,
tokens,
};
};
return ({
source,
...fields
}) => {
width = source.reduce(getWidth(tags), {
...zeroWidth,
});
const typelessInfo = getTypelessInfo(fields);
let tagIndentMode = false;
return rewireSource({
...fields,
source: source.map((line, index) => {
const indentTag = !disableWrapIndent && tagIndentMode && !line.tokens.tag && line.tokens.description;
const ret = update(line, index, source, typelessInfo, indentTag);
if (!disableWrapIndent && line.tokens.tag) {
tagIndentMode = true;
}
return ret;
}),
});
};
};
export default alignTransform;

View file

@ -0,0 +1,106 @@
import iterateJsdoc from './iterateJsdoc.js';
/**
* @typedef {(string|{
* comment: string,
* context: string,
* message?: string
* })[]} Contexts
*/
/**
* @param {{
* contexts?: Contexts,
* description?: string,
* getContexts?: (
* ctxt: import('eslint').Rule.RuleContext,
* report: import('./iterateJsdoc.js').Report
* ) => Contexts|false,
* contextName?: string,
* modifyContext?: (context: import('eslint').Rule.RuleContext) => import('eslint').Rule.RuleContext,
* schema?: import('eslint').Rule.RuleMetaData['schema']
* url?: string,
* }} cfg
* @returns {import('eslint').Rule.RuleModule}
*/
export const buildForbidRuleDefinition = ({
contextName,
contexts: cntxts,
description,
getContexts,
modifyContext,
schema,
url,
}) => {
return iterateJsdoc(({
context,
info: {
comment,
},
report,
utils,
}) => {
/** @type {Contexts|boolean|undefined} */
let contexts = cntxts;
if (getContexts) {
contexts = getContexts(context, report);
if (!contexts) {
return;
}
}
const {
contextStr,
foundContext,
} = utils.findContext(/** @type {Contexts} */ (contexts), comment);
// We are not on the *particular* matching context/comment, so don't assume
// we need reporting
if (!foundContext) {
return;
}
const message = /** @type {import('./iterateJsdoc.js').ContextObject} */ (
foundContext
)?.message ??
'Syntax is restricted: {{context}}' +
(comment ? ' with {{comment}}' : '');
report(message, null, null, comment ? {
comment,
context: contextStr,
} : {
context: contextStr,
});
}, {
contextSelected: true,
meta: {
docs: {
description: description ?? contextName ?? 'Reports when certain comment structures are present.',
url: url ?? 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/advanced.md#user-content-advanced-creating-your-own-rules',
},
schema: schema ?? [],
type: 'suggestion',
},
modifyContext: modifyContext ?? (getContexts ? undefined : (context) => {
// Reproduce context object with our own `contexts`
const propertyDescriptors = Object.getOwnPropertyDescriptors(context);
return Object.create(
Object.getPrototypeOf(context),
{
...propertyDescriptors,
options: {
...propertyDescriptors.options,
value: [
{
contexts: cntxts,
},
],
},
},
);
}),
nonGlobalSettings: true,
});
};

View file

@ -0,0 +1,481 @@
import iterateJsdoc from './iterateJsdoc.js';
import {
parse,
stringify,
traverse,
tryParse,
} from '@es-joy/jsdoccomment';
/**
* Adjusts the parent type node `meta` for generic matches (or type node
* `type` for `JsdocTypeAny`) and sets the type node `value`.
* @param {string} type The actual type
* @param {string} preferred The preferred type
* @param {boolean} isGenericMatch
* @param {string} typeNodeName
* @param {import('jsdoc-type-pratt-parser').NonRootResult} node
* @param {import('jsdoc-type-pratt-parser').NonRootResult|undefined} parentNode
* @returns {void}
*/
const adjustNames = (type, preferred, isGenericMatch, typeNodeName, node, parentNode) => {
let ret = preferred;
if (isGenericMatch) {
const parentMeta = /** @type {import('jsdoc-type-pratt-parser').GenericResult} */ (
parentNode
).meta;
if (preferred === '[]') {
parentMeta.brackets = 'square';
parentMeta.dot = false;
ret = 'Array';
} else {
const dotBracketEnd = preferred.match(/\.(?:<>)?$/v);
if (dotBracketEnd) {
parentMeta.brackets = 'angle';
parentMeta.dot = true;
ret = preferred.slice(0, -dotBracketEnd[0].length);
} else {
const bracketEnd = preferred.endsWith('<>');
if (bracketEnd) {
parentMeta.brackets = 'angle';
parentMeta.dot = false;
ret = preferred.slice(0, -2);
} else if (
parentMeta?.brackets === 'square' &&
(typeNodeName === '[]' || typeNodeName === 'Array')
) {
parentMeta.brackets = 'angle';
parentMeta.dot = false;
}
}
}
} else if (type === 'JsdocTypeAny') {
node.type = 'JsdocTypeName';
}
/** @type {import('jsdoc-type-pratt-parser').NameResult} */ (
node
).value = ret.replace(/(?:\.|<>|\.<>|\[\])$/v, '');
// For bare pseudo-types like `<>`
if (!ret) {
/** @type {import('jsdoc-type-pratt-parser').NameResult} */ (
node
).value = typeNodeName;
}
};
/**
* @param {boolean} [upperCase]
* @returns {string}
*/
const getMessage = (upperCase) => {
return 'Use object shorthand or index signatures instead of ' +
'`' + (upperCase ? 'O' : 'o') + 'bject`, e.g., `{[key: string]: string}`';
};
/**
* @type {{
* message: string,
* replacement: false
* }}
*/
const info = {
message: getMessage(),
replacement: false,
};
/**
* @type {{
* message: string,
* replacement: false
* }}
*/
const infoUC = {
message: getMessage(true),
replacement: false,
};
/**
* @param {{
* checkNativeTypes?: import('./rules/checkTypes.js').CheckNativeTypes|null
* overrideSettings?: import('./iterateJsdoc.js').Settings['preferredTypes']|null,
* description?: string,
* schema?: import('eslint').Rule.RuleMetaData['schema'],
* typeName?: string,
* url?: string,
* }} cfg
* @returns {import('eslint').Rule.RuleModule}
*/
export const buildRejectOrPreferRuleDefinition = ({
checkNativeTypes = null,
typeName,
description = typeName ?? 'Reports types deemed invalid (customizable and with defaults, for preventing and/or recommending replacements).',
overrideSettings = null,
schema = [],
url = 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/check-types.md#repos-sticky-header',
}) => {
return iterateJsdoc(
({
context,
jsdocNode,
report,
settings,
sourceCode,
utils,
}) => {
const jsdocTagsWithPossibleType = utils.filterTags((tag) => {
return Boolean(utils.tagMightHaveTypePosition(tag.tag));
});
const
/**
* @type {{
* preferredTypes: import('./iterateJsdoc.js').PreferredTypes,
* structuredTags: import('./iterateJsdoc.js').StructuredTags,
* mode: import('./jsdocUtils.js').ParserMode
* }}
*/
{
mode,
preferredTypes: preferredTypesOriginal,
structuredTags,
} = overrideSettings ? {
mode: settings.mode,
preferredTypes: overrideSettings,
structuredTags: {},
} : settings;
const injectObjectPreferredTypes = !overrideSettings &&
!('Object' in preferredTypesOriginal ||
'object' in preferredTypesOriginal ||
'object.<>' in preferredTypesOriginal ||
'Object.<>' in preferredTypesOriginal ||
'object<>' in preferredTypesOriginal);
/** @type {import('./iterateJsdoc.js').PreferredTypes} */
const typeToInject = mode === 'typescript' ?
{
Object: 'object',
'object.<>': info,
'Object.<>': infoUC,
'object<>': info,
'Object<>': infoUC,
} :
{
Object: 'object',
'object.<>': 'Object<>',
'Object.<>': 'Object<>',
'object<>': 'Object<>',
};
/** @type {import('./iterateJsdoc.js').PreferredTypes} */
const preferredTypes = {
...injectObjectPreferredTypes ?
typeToInject :
{},
...preferredTypesOriginal,
};
const
/**
* @type {{
* noDefaults: boolean,
* unifyParentAndChildTypeChecks: boolean,
* exemptTagContexts: ({
* tag: string,
* types: true|string[]
* })[]
* }}
*/ {
exemptTagContexts = [],
noDefaults,
unifyParentAndChildTypeChecks,
} = context.options[0] || {};
/**
* Gets information about the preferred type: whether there is a matching
* preferred type, what the type is, and whether it is a match to a generic.
* @param {string} _type Not currently in use
* @param {string} typeNodeName
* @param {import('jsdoc-type-pratt-parser').NonRootResult|undefined} parentNode
* @param {string|undefined} property
* @returns {[hasMatchingPreferredType: boolean, typeName: string, isGenericMatch: boolean]}
*/
const getPreferredTypeInfo = (_type, typeNodeName, parentNode, property) => {
let hasMatchingPreferredType = false;
let isGenericMatch = false;
let typName = typeNodeName;
const isNameOfGeneric = parentNode !== undefined && parentNode.type === 'JsdocTypeGeneric' && property === 'left';
const brackets = /** @type {import('jsdoc-type-pratt-parser').GenericResult} */ (
parentNode
)?.meta?.brackets;
const dot = /** @type {import('jsdoc-type-pratt-parser').GenericResult} */ (
parentNode
)?.meta?.dot;
if (brackets === 'angle') {
const checkPostFixes = dot ? [
'.', '.<>',
] : [
'<>',
];
isGenericMatch = checkPostFixes.some((checkPostFix) => {
const preferredType = preferredTypes?.[typeNodeName + checkPostFix];
// Does `unifyParentAndChildTypeChecks` need to be checked here?
if (
(unifyParentAndChildTypeChecks || isNameOfGeneric ||
/* c8 ignore next 2 -- If checking `unifyParentAndChildTypeChecks` */
(typeof preferredType === 'object' &&
preferredType?.unifyParentAndChildTypeChecks)
) &&
preferredType !== undefined
) {
typName += checkPostFix;
return true;
}
return false;
});
}
if (
!isGenericMatch && property &&
/** @type {import('jsdoc-type-pratt-parser').NonRootResult} */ (
parentNode
).type === 'JsdocTypeGeneric'
) {
const checkPostFixes = dot ? [
'.', '.<>',
] : [
brackets === 'angle' ? '<>' : '[]',
];
isGenericMatch = checkPostFixes.some((checkPostFix) => {
const preferredType = preferredTypes?.[checkPostFix];
if (
// Does `unifyParentAndChildTypeChecks` need to be checked here?
(unifyParentAndChildTypeChecks || isNameOfGeneric ||
/* c8 ignore next 2 -- If checking `unifyParentAndChildTypeChecks` */
(typeof preferredType === 'object' &&
preferredType?.unifyParentAndChildTypeChecks)) &&
preferredType !== undefined
) {
typName = checkPostFix;
return true;
}
return false;
});
}
const prefType = preferredTypes?.[typeNodeName];
const directNameMatch = prefType !== undefined &&
!Object.values(preferredTypes).includes(typeNodeName);
const specificUnify = typeof prefType === 'object' &&
prefType?.unifyParentAndChildTypeChecks;
const unifiedSyntaxParentMatch = property && directNameMatch && (unifyParentAndChildTypeChecks || specificUnify);
isGenericMatch = isGenericMatch || Boolean(unifiedSyntaxParentMatch);
hasMatchingPreferredType = isGenericMatch ||
directNameMatch && !property;
return [
hasMatchingPreferredType, typName, isGenericMatch,
];
};
/**
* Collect invalid type info.
* @param {string} type
* @param {string} value
* @param {string} tagName
* @param {string} nameInTag
* @param {number} idx
* @param {string|undefined} property
* @param {import('jsdoc-type-pratt-parser').NonRootResult} node
* @param {import('jsdoc-type-pratt-parser').NonRootResult|undefined} parentNode
* @param {(string|false|undefined)[][]} invalidTypes
* @returns {void}
*/
const getInvalidTypes = (type, value, tagName, nameInTag, idx, property, node, parentNode, invalidTypes) => {
let typeNodeName = type === 'JsdocTypeAny' ? '*' : value;
const [
hasMatchingPreferredType,
typName,
isGenericMatch,
] = getPreferredTypeInfo(type, typeNodeName, parentNode, property);
let preferred;
let types;
if (hasMatchingPreferredType) {
const preferredSetting = preferredTypes[typName];
typeNodeName = typName === '[]' ? typName : typeNodeName;
if (!preferredSetting) {
invalidTypes.push([
typeNodeName,
]);
} else if (typeof preferredSetting === 'string') {
preferred = preferredSetting;
invalidTypes.push([
typeNodeName, preferred,
]);
} else if (preferredSetting && typeof preferredSetting === 'object') {
const nextItem = preferredSetting.skipRootChecking && jsdocTagsWithPossibleType[idx + 1];
if (!nextItem || !nextItem.name.startsWith(`${nameInTag}.`)) {
preferred = preferredSetting.replacement;
invalidTypes.push([
typeNodeName,
preferred,
preferredSetting.message,
]);
}
} else {
utils.reportSettings(
'Invalid `settings.jsdoc.preferredTypes`. Values must be falsy, a string, or an object.',
);
return;
}
} else if (Object.entries(structuredTags).some(([
tag,
{
type: typs,
},
]) => {
types = typs;
return tag === tagName &&
Array.isArray(types) &&
!types.includes(typeNodeName);
})) {
invalidTypes.push([
typeNodeName, types,
]);
} else if (checkNativeTypes && !noDefaults && type === 'JsdocTypeName') {
preferred = checkNativeTypes(
preferredTypes, typeNodeName, preferred, parentNode, invalidTypes,
);
}
// For fixer
if (preferred) {
adjustNames(type, preferred, isGenericMatch, typeNodeName, node, parentNode);
}
};
for (const [
idx,
jsdocTag,
] of jsdocTagsWithPossibleType.entries()) {
/** @type {(string|false|undefined)[][]} */
const invalidTypes = [];
let typeAst;
try {
typeAst = mode === 'permissive' ? tryParse(jsdocTag.type) : parse(jsdocTag.type, mode);
} catch {
continue;
}
const {
name: nameInTag,
tag: tagName,
} = jsdocTag;
traverse(typeAst, (node, parentNode, property) => {
const {
type,
value,
} =
/**
* @type {import('jsdoc-type-pratt-parser').NameResult}
*/ (node);
if (![
'JsdocTypeAny', 'JsdocTypeName',
].includes(type)) {
return;
}
getInvalidTypes(type, value, tagName, nameInTag, idx, property, node, parentNode, invalidTypes);
});
if (invalidTypes.length) {
const fixedType = stringify(typeAst);
/**
* @type {import('eslint').Rule.ReportFixer}
*/
const fix = (fixer) => {
return fixer.replaceText(
jsdocNode,
sourceCode.getText(jsdocNode).replace(
`{${jsdocTag.type}}`,
`{${fixedType}}`,
),
);
};
for (const [
badType,
preferredType = '',
msg,
] of invalidTypes) {
const tagValue = jsdocTag.name ? ` "${jsdocTag.name}"` : '';
if (exemptTagContexts.some(({
tag,
types,
}) => {
return tag === tagName &&
(types === true || types.includes(jsdocTag.type));
})) {
continue;
}
report(
msg ||
`Invalid JSDoc @${tagName}${tagValue} type "${badType}"` +
(preferredType ? '; ' : '.') +
(preferredType ? `prefer: ${JSON.stringify(preferredType)}.` : ''),
preferredType ? fix : null,
jsdocTag,
msg ? {
tagName,
tagValue,
} : undefined,
);
}
}
}
},
{
iterateAllJsdocs: true,
meta: {
docs: {
description,
url,
},
...(!overrideSettings || (Object.values(overrideSettings).some((os) => {
return os && typeof os === 'object' ?
/* c8 ignore next -- Ok */
os.replacement :
typeof os === 'string';
})) ?
{
fixable: 'code',
} :
{}
),
schema,
type: 'suggestion',
},
},
);
};

View file

@ -0,0 +1,169 @@
const defaultTagOrder = [
{
tags: [
// Brief descriptions
'summary',
'typeSummary',
// Module/file-level
'module',
'exports',
'file',
'fileoverview',
'overview',
'import',
// Identifying (name, type)
'template',
'typedef',
'interface',
'record',
'name',
'kind',
'type',
'alias',
'external',
'host',
'callback',
'func',
'function',
'method',
'class',
'constructor',
// Relationships
'modifies',
'mixes',
'mixin',
'mixinClass',
'mixinFunction',
'namespace',
'borrows',
'constructs',
'lends',
'implements',
'requires',
// Long descriptions
'desc',
'description',
'classdesc',
'tutorial',
'copyright',
'license',
// Simple annotations
// TypeScript
'internal',
'overload',
'const',
'constant',
'final',
'global',
'readonly',
'abstract',
'virtual',
'var',
'member',
'memberof',
'memberof!',
'inner',
'instance',
'inheritdoc',
'inheritDoc',
'override',
'hideconstructor',
// Core function/object info
'param',
'arg',
'argument',
'prop',
'property',
'return',
'returns',
// Important behavior details
'async',
'generator',
'default',
'defaultvalue',
'enum',
'augments',
'extends',
'throws',
'exception',
'yield',
'yields',
'event',
'fires',
'emits',
'listens',
'this',
// TypeScript
'satisfies',
// Access
'static',
'private',
'protected',
'public',
'access',
'package',
'-other',
// Supplementary descriptions
'see',
'example',
// METADATA
// Other Closure (undocumented) metadata
'closurePrimitive',
'customElement',
'expose',
'hidden',
'idGenerator',
'meaning',
'ngInject',
'owner',
'wizaction',
// Other Closure (documented) metadata
'define',
'dict',
'export',
'externs',
'implicitCast',
'noalias',
'nocollapse',
'nocompile',
'noinline',
'nosideeffects',
'polymer',
'polymerBehavior',
'preserve',
'struct',
'suppress',
'unrestricted',
// @homer0/prettier-plugin-jsdoc metadata
'category',
// Non-Closure metadata
'ignore',
'author',
'version',
'variation',
'since',
'deprecated',
'todo',
],
},
];
export default defaultTagOrder;

View file

@ -0,0 +1,973 @@
import {
findJSDocComment,
} from '@es-joy/jsdoccomment';
import debugModule from 'debug';
const debug = debugModule('requireExportJsdoc');
/**
* @typedef {{
* value: string
* }} ValueObject
*/
/**
* @typedef {{
* type?: string,
* value?: ValueObject|import('eslint').Rule.Node|import('@typescript-eslint/types').TSESTree.Node,
* props: {
* [key: string]: CreatedNode|null,
* },
* special?: true,
* globalVars?: CreatedNode,
* exported?: boolean,
* ANONYMOUS_DEFAULT?: import('eslint').Rule.Node
* }} CreatedNode
*/
/**
* @returns {CreatedNode}
*/
const createNode = function () {
return {
props: {},
};
};
/**
* @param {CreatedNode|null} symbol
* @returns {string|null}
*/
const getSymbolValue = function (symbol) {
/* c8 ignore next 3 */
if (!symbol) {
return null;
}
/* c8 ignore else */
if (symbol.type === 'literal') {
return /** @type {ValueObject} */ (symbol.value).value;
}
/* c8 ignore next 2 */
// eslint-disable-next-line @stylistic/padding-line-between-statements -- c8
return null;
};
/**
*
* @param {import('estree').Identifier} node
* @param {CreatedNode} globals
* @param {CreatedNode} scope
* @param {SymbolOptions} opts
* @returns {CreatedNode|null}
*/
const getIdentifier = function (node, globals, scope, opts) {
if (opts.simpleIdentifier) {
// Type is Identier for noncomputed properties
const identifierLiteral = createNode();
identifierLiteral.type = 'literal';
identifierLiteral.value = {
value: node.name,
};
return identifierLiteral;
}
/* c8 ignore next */
const block = scope || globals;
// As scopes are not currently supported, they are not traversed upwards recursively
if (block.props[node.name]) {
return block.props[node.name];
}
// Seems this will only be entered once scopes added and entered
/* c8 ignore next 3 */
if (globals.props[node.name]) {
return globals.props[node.name];
}
return null;
};
/**
* @callback CreateSymbol
* @param {import('eslint').Rule.Node|null} node
* @param {CreatedNode} globals
* @param {import('eslint').Rule.Node|import('@typescript-eslint/types').TSESTree.Node|null} value
* @param {CreatedNode} [scope]
* @param {boolean|SymbolOptions} [isGlobal]
* @returns {CreatedNode|null}
*/
/** @type {CreateSymbol} */
let createSymbol; // eslint-disable-line prefer-const
/**
* @typedef {{
* simpleIdentifier?: boolean
* }} SymbolOptions
*/
/**
*
* @param {import('eslint').Rule.Node|import('@typescript-eslint/types').TSESTree.Node} node
* @param {CreatedNode} globals
* @param {CreatedNode} scope
* @param {SymbolOptions} [opt]
* @returns {CreatedNode|null}
*/
const getSymbol = function (node, globals, scope, opt) {
const opts = opt || {};
/* c8 ignore next */
switch (node.type) {
/* c8 ignore next 4 -- No longer needed? */
case 'ArrowFunctionExpression':
// Fallthrough
case 'ClassDeclaration':
case 'FunctionDeclaration':
case 'FunctionExpression':
case 'TSEnumDeclaration':
case 'TSInterfaceDeclaration':
case 'TSTypeAliasDeclaration': {
const val = createNode();
val.props.prototype = createNode();
val.props.prototype.type = 'object';
val.type = 'object';
val.value = node;
return val;
}
case 'AssignmentExpression': {
return createSymbol(
/** @type {import('eslint').Rule.Node} */
(node.left),
globals,
/** @type {import('eslint').Rule.Node} */
(node.right),
scope,
opts,
);
}
case 'ClassBody': {
const val = createNode();
for (const method of node.body) {
// StaticBlock
if (!('key' in method)) {
continue;
}
val.props[
/** @type {import('estree').Identifier} */ (
/** @type {import('estree').MethodDefinition} */ (
method
).key
).name
] = createNode();
/** @type {{[key: string]: CreatedNode}} */ (val.props)[
/** @type {import('estree').Identifier} */ (
/** @type {import('estree').MethodDefinition} */ (
method
).key
).name
].type = 'object';
/** @type {{[key: string]: CreatedNode}} */ (val.props)[
/** @type {import('estree').Identifier} */ (
/** @type {import('estree').MethodDefinition} */ (
method
).key
).name
].value = /** @type {import('eslint').Rule.Node} */ (
/** @type {import('estree').MethodDefinition} */ (method).value
);
}
val.type = 'object';
val.value = node.parent;
return val;
}
case 'ClassExpression': {
return getSymbol(
/** @type {import('eslint').Rule.Node} */
(node.body),
globals,
scope,
opts,
);
}
case 'Identifier': {
return getIdentifier(node, globals, scope, opts);
}
case 'Literal': {
const val = createNode();
val.type = 'literal';
val.value = node;
return val;
}
case 'MemberExpression': {
const obj = getSymbol(
/** @type {import('eslint').Rule.Node} */
(node.object),
globals,
scope,
opts,
);
const propertySymbol = getSymbol(
/** @type {import('eslint').Rule.Node} */
(node.property),
globals,
scope,
{
simpleIdentifier: !node.computed,
},
);
const propertyValue = getSymbolValue(propertySymbol);
/* c8 ignore else */
if (obj && propertyValue && obj.props[propertyValue]) {
const block = obj.props[propertyValue];
return block;
}
/* c8 ignore next 11 */
/*
if (opts.createMissingProps && propertyValue) {
obj.props[propertyValue] = createNode();
return obj.props[propertyValue];
}
*/
// eslint-disable-next-line @stylistic/padding-line-between-statements -- c8
debug(`MemberExpression: Missing property ${
/** @type {import('estree').PrivateIdentifier} */ (node.property).name
}`);
/* c8 ignore next 2 */
return null;
}
case 'ObjectExpression': {
const val = createNode();
val.type = 'object';
for (const prop of node.properties) {
if ([
// @babel/eslint-parser
'ExperimentalSpreadProperty',
// typescript-eslint, espree, acorn, etc.
'SpreadElement',
].includes(prop.type)) {
continue;
}
const propVal = getSymbol(
/** @type {import('eslint').Rule.Node} */ (
/** @type {import('estree').Property} */
(prop).value
),
globals,
scope,
opts,
);
/* c8 ignore next 8 */
if (propVal) {
val.props[
/** @type {import('estree').PrivateIdentifier} */
(
/** @type {import('estree').Property} */ (prop).key
).name
] = propVal;
}
}
return val;
}
}
/* c8 ignore next 2 */
// eslint-disable-next-line @stylistic/padding-line-between-statements -- c8
return null;
};
/**
*
* @param {CreatedNode} block
* @param {string} name
* @param {CreatedNode|null} value
* @param {CreatedNode} globals
* @param {boolean|SymbolOptions|undefined} isGlobal
* @returns {void}
*/
const createBlockSymbol = function (block, name, value, globals, isGlobal) {
block.props[name] = value;
if (isGlobal && globals.props.window && globals.props.window.special) {
globals.props.window.props[name] = value;
}
};
createSymbol = function (node, globals, value, scope, isGlobal) {
const block = scope || globals;
/* c8 ignore next 3 */
if (!node) {
return null;
}
let symbol;
switch (node.type) {
case 'ClassDeclaration':
/* c8 ignore next */
// @ts-expect-error TS OK
// Fall through
case 'FunctionDeclaration': case 'TSEnumDeclaration':
/* c8 ignore next */
// @ts-expect-error TS OK
// Fall through
case 'TSInterfaceDeclaration': case 'TSTypeAliasDeclaration': {
const nde = /** @type {import('estree').ClassDeclaration} */ (node);
/* c8 ignore else */
if (nde.id && nde.id.type === 'Identifier') {
return createSymbol(
/** @type {import('eslint').Rule.Node} */ (nde.id),
globals,
node,
globals,
);
}
/* c8 ignore next 3 */
// eslint-disable-next-line @stylistic/padding-line-between-statements -- c8
break;
}
case 'Identifier': {
const nde = /** @type {import('estree').Identifier} */ (node);
if (value) {
const valueSymbol = getSymbol(value, globals, block);
/* c8 ignore else */
if (valueSymbol) {
createBlockSymbol(block, nde.name, valueSymbol, globals, isGlobal);
return block.props[nde.name];
}
/* c8 ignore next 2 */
// eslint-disable-next-line @stylistic/padding-line-between-statements -- c8
debug('Identifier: Missing value symbol for %s', nde.name);
} else {
createBlockSymbol(block, nde.name, createNode(), globals, isGlobal);
return block.props[nde.name];
}
/* c8 ignore next 3 */
// eslint-disable-next-line @stylistic/padding-line-between-statements -- c8
break;
}
case 'MemberExpression': {
const nde = /** @type {import('estree').MemberExpression} */ (node);
symbol = getSymbol(
/** @type {import('eslint').Rule.Node} */ (nde.object), globals, block,
);
const propertySymbol = getSymbol(
/** @type {import('eslint').Rule.Node} */ (nde.property),
globals,
block,
{
simpleIdentifier: !nde.computed,
},
);
const propertyValue = getSymbolValue(propertySymbol);
if (symbol && propertyValue) {
createBlockSymbol(symbol, propertyValue, getSymbol(
/** @type {import('eslint').Rule.Node} */
(value), globals, block,
), globals, isGlobal);
return symbol.props[propertyValue];
}
debug(
'MemberExpression: Missing symbol: %s',
/** @type {import('estree').Identifier} */ (
nde.property
).name,
);
break;
}
}
return null;
};
/**
* Creates variables from variable definitions
* @param {import('eslint').Rule.Node} node
* @param {CreatedNode} globals
* @param {import('./rules/requireJsdoc.js').RequireJsdocOpts} opts
* @returns {void}
*/
const initVariables = function (node, globals, opts) {
switch (node.type) {
case 'ExportNamedDeclaration': {
if (node.declaration) {
initVariables(
/** @type {import('eslint').Rule.Node} */
(node.declaration),
globals,
opts,
);
}
break;
}
case 'ExpressionStatement': {
initVariables(
/** @type {import('eslint').Rule.Node} */
(node.expression),
globals,
opts,
);
break;
}
case 'Program': {
for (const childNode of node.body) {
initVariables(
/** @type {import('eslint').Rule.Node} */
(childNode),
globals,
opts,
);
}
break;
}
case 'VariableDeclaration': {
for (const declaration of node.declarations) {
// let and const
const symbol = createSymbol(
/** @type {import('eslint').Rule.Node} */
(declaration.id),
globals,
null,
globals,
);
if (opts.initWindow && node.kind === 'var' && globals.props.window) {
// If var, also add to window
globals.props.window.props[
/** @type {import('estree').Identifier} */
(declaration.id).name
] = symbol;
}
}
break;
}
}
};
/**
* Populates variable maps using AST
* @param {import('eslint').Rule.Node|import('@typescript-eslint/types').TSESTree.Node} node
* @param {CreatedNode} globals
* @param {import('./rules/requireJsdoc.js').RequireJsdocOpts} opt
* @param {true} [isExport]
* @returns {boolean}
*/
const mapVariables = function (node, globals, opt, isExport) {
/* c8 ignore next */
const opts = opt || {};
/* c8 ignore next */
switch (node.type) {
case 'AssignmentExpression': {
createSymbol(
/** @type {import('eslint').Rule.Node} */
(node.left),
globals,
/** @type {import('eslint').Rule.Node} */
(node.right),
);
break;
}
case 'ClassDeclaration': {
createSymbol(
/** @type {import('eslint').Rule.Node|null} */ (node.id),
globals,
/** @type {import('eslint').Rule.Node} */ (node.body),
globals,
);
break;
}
case 'ExportDefaultDeclaration': {
const symbol = createSymbol(
/** @type {import('eslint').Rule.Node} */
(node.declaration),
globals,
/** @type {import('eslint').Rule.Node} */
(node.declaration),
);
if (symbol) {
symbol.exported = true;
/* c8 ignore next 6 */
} else {
// if (!node.id) {
globals.ANONYMOUS_DEFAULT = /** @type {import('eslint').Rule.Node} */ (
node.declaration
);
}
break;
}
case 'ExportNamedDeclaration': {
if (node.declaration) {
if (node.declaration.type === 'VariableDeclaration') {
mapVariables(
/** @type {import('eslint').Rule.Node} */
(node.declaration),
globals,
opts,
true,
);
} else {
const symbol = createSymbol(
/** @type {import('eslint').Rule.Node} */
(node.declaration),
globals,
/** @type {import('eslint').Rule.Node} */
(node.declaration),
);
/* c8 ignore next 3 */
if (symbol) {
symbol.exported = true;
}
}
}
for (const specifier of node.specifiers) {
mapVariables(
/** @type {import('eslint').Rule.Node} */
(specifier),
globals,
opts,
);
}
break;
}
case 'ExportSpecifier': {
const symbol = getSymbol(
/** @type {import('eslint').Rule.Node} */
(node.local),
globals,
globals,
);
/* c8 ignore next 3 */
if (symbol) {
symbol.exported = true;
}
break;
}
case 'ExpressionStatement': {
mapVariables(
/** @type {import('eslint').Rule.Node} */
(node.expression),
globals,
opts,
);
break;
}
case 'FunctionDeclaration':
case 'TSTypeAliasDeclaration': {
/* c8 ignore next 10 */
if (/** @type {import('estree').Identifier} */ (node.id).type === 'Identifier') {
createSymbol(
/** @type {import('eslint').Rule.Node} */
(node.id),
globals,
node,
globals,
true,
);
}
break;
}
case 'Program': {
if (opts.ancestorsOnly) {
return false;
}
for (const childNode of node.body) {
mapVariables(
/** @type {import('eslint').Rule.Node} */
(childNode),
globals,
opts,
);
}
break;
}
case 'VariableDeclaration': {
for (const declaration of node.declarations) {
const isGlobal = Boolean(opts.initWindow && node.kind === 'var' && globals.props.window);
const symbol = createSymbol(
/** @type {import('eslint').Rule.Node} */
(declaration.id),
globals,
/** @type {import('eslint').Rule.Node} */
(declaration.init),
globals,
isGlobal,
);
if (symbol && isExport) {
symbol.exported = true;
}
}
break;
}
default: {
/* c8 ignore next */
return false;
}
}
return true;
};
/**
*
* @param {import('eslint').Rule.Node} node
* @param {CreatedNode|ValueObject|string|undefined|
* import('eslint').Rule.Node|import('@typescript-eslint/types').TSESTree.Node} block
* @param {(CreatedNode|ValueObject|string|
* import('eslint').Rule.Node|import('@typescript-eslint/types').TSESTree.Node)[]} [cache]
* @returns {boolean}
*/
const findNode = function (node, block, cache) {
let blockCache = cache || [];
if (!block || blockCache.includes(block)) {
return false;
}
blockCache = blockCache.slice();
blockCache.push(block);
if (
typeof block === 'object' &&
'type' in block &&
(block.type === 'object' || block.type === 'MethodDefinition') &&
block.value === node
) {
return true;
}
if (typeof block !== 'object') {
return false;
}
const props = ('props' in block && block.props) || ('body' in block && block.body);
for (const propval of Object.values(props || {})) {
if (Array.isArray(propval)) {
/* c8 ignore next 5 */
if (propval.some((val) => {
return findNode(node, val, blockCache);
})) {
return true;
}
} else if (findNode(node, propval, blockCache)) {
return true;
}
}
return false;
};
const exportTypes = new Set([
'ExportDefaultDeclaration', 'ExportNamedDeclaration',
]);
const ignorableNestedTypes = new Set([
'ArrowFunctionExpression', 'FunctionDeclaration', 'FunctionExpression',
]);
/**
* @param {import('eslint').Rule.Node} nde
* @returns {import('eslint').Rule.Node|false}
*/
const getExportAncestor = function (nde) {
/** @type {import('eslint').Rule.Node|null} */
let node = nde;
let idx = 0;
const ignorableIfDeep = ignorableNestedTypes.has(nde?.type);
while (node) {
// Ignore functions nested more deeply than say `export default function () {}`
if (idx >= 2 && ignorableIfDeep) {
break;
}
if (exportTypes.has(node.type)) {
return node;
}
node = node.parent;
idx++;
}
return false;
};
const canBeExportedByAncestorType = new Set([
'ClassProperty',
'Method',
'PropertyDefinition',
'TSMethodSignature',
'TSPropertySignature',
]);
const canExportChildrenType = new Set([
'ClassBody',
'ClassDeclaration',
'ClassDefinition',
'ClassExpression',
'Program',
'TSInterfaceBody',
'TSInterfaceDeclaration',
'TSTypeAliasDeclaration',
'TSTypeLiteral',
'TSTypeParameterInstantiation',
'TSTypeReference',
]);
/**
* @param {import('eslint').Rule.Node} nde
* @returns {false|import('eslint').Rule.Node}
*/
const isExportByAncestor = function (nde) {
if (!canBeExportedByAncestorType.has(nde.type)) {
return false;
}
let node = nde.parent;
while (node) {
if (exportTypes.has(node.type)) {
return node;
}
if (!canExportChildrenType.has(node.type)) {
return false;
}
node = node.parent;
}
return false;
};
/**
*
* @param {CreatedNode} block
* @param {import('eslint').Rule.Node} node
* @param {CreatedNode[]} [cache] Currently unused
* @returns {boolean}
*/
const findExportedNode = function (block, node, cache) {
/* c8 ignore next 3 */
if (block === null) {
return false;
}
const blockCache = cache || [];
const {
props,
} = block;
for (const propval of Object.values(props)) {
const pval = /** @type {CreatedNode} */ (propval);
blockCache.push(pval);
if (pval.exported && (node === pval.value || findNode(node, pval.value))) {
return true;
}
// No need to check `propval` for exported nodes as ESM
// exports are only global
}
return false;
};
/**
*
* @param {import('eslint').Rule.Node} node
* @param {CreatedNode} globals
* @param {import('./rules/requireJsdoc.js').RequireJsdocOpts} opt
* @returns {boolean}
*/
const isNodeExported = function (node, globals, opt) {
const moduleExports = globals.props.module?.props?.exports;
if (
opt.initModuleExports && moduleExports && findNode(node, moduleExports)
) {
return true;
}
if (opt.initWindow && globals.props.window && findNode(node, globals.props.window)) {
return true;
}
if (opt.esm && findExportedNode(globals, node)) {
return true;
}
return false;
};
/**
*
* @param {import('eslint').Rule.Node} node
* @param {CreatedNode} globalVars
* @param {import('./rules/requireJsdoc.js').RequireJsdocOpts} opts
* @returns {boolean}
*/
const parseRecursive = function (node, globalVars, opts) {
// Iterate from top using recursion - stop at first processed node from top
if (node.parent && parseRecursive(node.parent, globalVars, opts)) {
return true;
}
return mapVariables(node, globalVars, opts);
};
/**
*
* @param {import('eslint').Rule.Node} ast
* @param {import('eslint').Rule.Node} node
* @param {import('./rules/requireJsdoc.js').RequireJsdocOpts} opt
* @returns {CreatedNode}
*/
const parse = function (ast, node, opt) {
/* c8 ignore next 6 */
const opts = opt || {
ancestorsOnly: false,
esm: true,
initModuleExports: true,
initWindow: true,
};
const globalVars = createNode();
if (opts.initModuleExports) {
globalVars.props.module = createNode();
globalVars.props.module.props.exports = createNode();
globalVars.props.exports = globalVars.props.module.props.exports;
}
if (opts.initWindow) {
globalVars.props.window = createNode();
globalVars.props.window.special = true;
}
if (opts.ancestorsOnly) {
parseRecursive(node, globalVars, opts);
} else {
initVariables(ast, globalVars, opts);
mapVariables(ast, globalVars, opts);
}
return {
globalVars,
props: {},
};
};
const accessibilityNodes = new Set([
'MethodDefinition',
'PropertyDefinition',
]);
/**
*
* @param {import('eslint').Rule.Node} node
* @returns {boolean}
*/
const isPrivate = (node) => {
return accessibilityNodes.has(node.type) &&
(
'accessibility' in node &&
node.accessibility !== 'public' && node.accessibility !== undefined
) ||
'key' in node &&
node.key.type === 'PrivateIdentifier';
};
/**
*
* @param {import('eslint').Rule.Node} node
* @param {import('eslint').SourceCode} sourceCode
* @param {import('./rules/requireJsdoc.js').RequireJsdocOpts} opt
* @param {import('./iterateJsdoc.js').Settings} settings
* @returns {boolean}
*/
const isUncommentedExport = function (node, sourceCode, opt, settings) {
// console.log({node});
// Optimize with ancestor check for esm
if (opt.esm) {
if (isPrivate(node) ||
node.parent && isPrivate(node.parent)) {
return false;
}
const exportNode = getExportAncestor(node);
// Is export node comment
if (exportNode && !findJSDocComment(exportNode, sourceCode, settings)) {
return true;
}
/**
* Some typescript types are not in variable map, but inherit exported (interface property and method)
*/
if (
isExportByAncestor(node) &&
!findJSDocComment(node, sourceCode, settings)
) {
return true;
}
}
const ast = /** @type {unknown} */ (sourceCode.ast);
const parseResult = parse(
/** @type {import('eslint').Rule.Node} */
(ast),
node,
opt,
);
return isNodeExported(
node, /** @type {CreatedNode} */ (parseResult.globalVars), opt,
);
};
export default {
isUncommentedExport,
parse,
};

View file

@ -0,0 +1,968 @@
/**
* @typedef {Map<string, Map<string, (string|boolean)>>} TagStructure
*/
/**
* @param {import('./jsdocUtils.js').ParserMode} mode
* @returns {TagStructure}
*/
const getDefaultTagStructureForMode = (mode) => {
const isJsdoc = mode === 'jsdoc';
const isClosure = mode === 'closure';
const isTypescript = mode === 'typescript';
const isPermissive = mode === 'permissive';
const isJsdocOrPermissive = isJsdoc || isPermissive;
const isJsdocOrTypescript = isJsdoc || isTypescript;
const isTypescriptOrClosure = isTypescript || isClosure;
const isClosureOrPermissive = isClosure || isPermissive;
const isJsdocTypescriptOrPermissive = isJsdocOrTypescript || isPermissive;
// Properties:
// `namepathRole` - 'namepath-referencing'|'name-defining'|'namepath-defining'|'namepath-or-url-referencing'|'text'|false
// `typeAllowed` - boolean
// `nameRequired` - boolean
// `typeRequired` - boolean
// `typeOrNameRequired` - boolean
// All of `typeAllowed` have a signature with "type" except for
// `augments`/`extends` ("namepath")
// `param`/`arg`/`argument` (no signature)
// `property`/`prop` (no signature)
// `modifies` (undocumented)
// None of the `namepathRole: 'namepath-defining'` show as having curly
// brackets for their name/namepath
// Among `namepath-defining` and `namepath-referencing`, these do not seem
// to allow curly brackets in their doc signature or examples (`modifies`
// references namepaths within its type brackets)
// Todo: Should support a `tutorialID` type (for `@tutorial` block and
// inline)
/**
* @type {TagStructure}
*/
return new Map([
[
'alias', new Map(/** @type {[string, string|boolean][]} */ ([
// Signature seems to require a "namepath" (and no counter-examples)
[
'namepathRole', 'namepath-defining',
],
// "namepath"
[
'typeOrNameRequired', true,
],
])),
],
[
'arg', new Map(/** @type {[string, string|boolean][]} */ ([
[
'namepathRole', 'namepath-defining',
],
// See `param`
[
'nameRequired', true,
],
// Has no formal signature in the docs but shows curly brackets
// in the examples
[
'typeAllowed', true,
],
])),
],
[
'argument', new Map(/** @type {[string, string|boolean][]} */ ([
[
'namepathRole', 'namepath-defining',
],
// See `param`
[
'nameRequired', true,
],
// Has no formal signature in the docs but shows curly brackets
// in the examples
[
'typeAllowed', true,
],
])),
],
[
'augments', new Map(/** @type {[string, string|boolean][]} */ ([
// Signature seems to require a "namepath" (and no counter-examples)
[
'namepathRole', 'namepath-referencing',
],
// Does not show curly brackets in either the signature or examples
[
'typeAllowed', true,
],
// "namepath"
[
'typeOrNameRequired', true,
],
])),
],
[
'borrows', new Map(/** @type {[string, string|boolean][]} */ ([
// `borrows` has a different format, however, so needs special parsing;
// seems to require both, and as "namepath"'s
[
'namepathRole', 'namepath-referencing',
],
// "namepath"
[
'typeOrNameRequired', true,
],
])),
],
[
'callback', new Map(/** @type {[string, string|boolean][]} */ ([
// Seems to require a "namepath" in the signature (with no
// counter-examples); TypeScript does not enforce but seems
// problematic as not attached so presumably not useable without it
[
'namepathRole', 'namepath-defining',
],
// "namepath"
[
'nameRequired', true,
],
])),
],
[
'class', new Map(/** @type {[string, string|boolean][]} */ ([
// Not in use, but should be this value if using to power `empty-tags`
[
'nameAllowed', true,
],
// Allows for "name"'s in signature, but indicated as optional
[
'namepathRole', 'namepath-defining',
],
[
'typeAllowed', true,
],
])),
],
[
'const', new Map(/** @type {[string, string|boolean][]} */ ([
// Allows for "name"'s in signature, but indicated as optional
[
'namepathRole', 'namepath-defining',
],
[
'typeAllowed', true,
],
])),
],
[
'constant', new Map(/** @type {[string, string|boolean][]} */ ([
// Allows for "name"'s in signature, but indicated as optional
[
'namepathRole', 'namepath-defining',
],
[
'typeAllowed', true,
],
])),
],
[
'constructor', new Map(/** @type {[string, string|boolean][]} */ ([
// Allows for "name"'s in signature, but indicated as optional
[
'namepathRole', 'namepath-defining',
],
[
'typeAllowed', true,
],
])),
],
[
'constructs', new Map(/** @type {[string, string|boolean][]} */ ([
// Allows for "name"'s in signature, but indicated as optional
[
'namepathRole', 'namepath-defining',
],
[
'nameRequired', false,
],
[
'typeAllowed', false,
],
])),
],
[
'define', new Map(/** @type {[string, string|boolean][]} */ ([
[
'typeRequired', isClosure,
],
])),
],
[
'emits', new Map(/** @type {[string, string|boolean][]} */ ([
// Signature seems to require a "name" (of an event) and no counter-examples
[
'namepathRole', 'namepath-referencing',
],
[
'nameRequired', true,
],
[
'typeAllowed', false,
],
])),
],
[
'enum', new Map(/** @type {[string, string|boolean][]} */ ([
[
'namepathRole', 'name-defining',
],
// Has example showing curly brackets but not in doc signature
[
'typeAllowed', true,
],
])),
],
[
'event', new Map(/** @type {[string, string|boolean][]} */ ([
// Appears to require a "name" in its signature, albeit somewhat
// different from other "name"'s (including as described
// at https://jsdoc.app/about-namepaths.html )
[
'namepathRole', 'namepath-defining',
],
// The doc signature of `event` seems to require a "name"
[
'nameRequired', true,
],
])),
],
[
'exception', new Map(/** @type {[string, string|boolean][]} */ ([
// Shows curly brackets in the signature and in the examples
[
'typeAllowed', true,
],
])),
],
// Closure
[
'export', new Map(/** @type {[string, string|boolean][]} */ ([
[
'typeAllowed', isClosureOrPermissive,
],
])),
],
[
'exports', new Map(/** @type {[string, string|boolean][]} */ ([
[
'namepathRole', 'namepath-defining',
],
[
'nameRequired', isJsdoc,
],
[
'typeAllowed', isClosureOrPermissive,
],
])),
],
[
'extends', new Map(/** @type {[string, string|boolean][]} */ ([
// Signature seems to require a "namepath" (and no counter-examples)
[
'namepathRole', 'namepath-referencing',
],
[
'nameRequired', isJsdoc,
],
// Does not show curly brackets in either the signature or examples
[
'typeAllowed', isTypescriptOrClosure || isPermissive,
],
// "namepath"
[
'typeOrNameRequired', isTypescriptOrClosure || isPermissive,
],
])),
],
[
'external', new Map(/** @type {[string, string|boolean][]} */ ([
// Appears to require a "name" in its signature, albeit somewhat
// different from other "name"'s (including as described
// at https://jsdoc.app/about-namepaths.html )
[
'namepathRole', 'namepath-defining',
],
// "name" (and a special syntax for the `external` name)
[
'nameRequired', true,
],
[
'typeAllowed', false,
],
])),
],
[
'fires', new Map(/** @type {[string, string|boolean][]} */ ([
// Signature seems to require a "name" (of an event) and no
// counter-examples
[
'namepathRole', 'namepath-referencing',
],
[
'nameRequired', true,
],
[
'typeAllowed', false,
],
])),
],
[
'func', new Map(/** @type {[string, string|boolean][]} */ ([
// Allows for "name"'s in signature, but indicated as optional
[
'namepathRole', 'namepath-defining',
],
])),
],
[
'function', new Map(/** @type {[string, string|boolean][]} */ ([
// Allows for "name"'s in signature, but indicated as optional
[
'namepathRole', 'namepath-defining',
],
[
'nameRequired', false,
],
[
'typeAllowed', false,
],
])),
],
[
'host', new Map(/** @type {[string, string|boolean][]} */ ([
// Appears to require a "name" in its signature, albeit somewhat
// different from other "name"'s (including as described
// at https://jsdoc.app/about-namepaths.html )
[
'namepathRole', 'namepath-defining',
],
// See `external`
[
'nameRequired', true,
],
[
'typeAllowed', false,
],
])),
],
[
'implements', new Map(/** @type {[string, string|boolean][]} */ ([
// Shows curly brackets in the doc signature and examples
// "typeExpression"
[
'typeRequired', true,
],
])),
],
[
'interface', new Map(/** @type {[string, string|boolean][]} */ ([
// Not in use, but should be this value if using to power `empty-tags`
[
'nameAllowed', isClosure,
],
// Allows for "name" in signature, but indicates as optional
[
'namepathRole',
isJsdocTypescriptOrPermissive ? 'namepath-defining' : false,
],
[
'typeAllowed', false,
],
])),
],
[
'internal', new Map(/** @type {[string, string|boolean][]} */ ([
// Not in use, but should be this value if using to power `empty-tags`
[
'nameAllowed', false,
],
// https://www.typescriptlang.org/tsconfig/#stripInternal
[
'namepathRole', false,
],
])),
],
[
'lends', new Map(/** @type {[string, string|boolean][]} */ ([
// Signature seems to require a "namepath" (and no counter-examples)
[
'namepathRole', 'namepath-referencing',
],
// "namepath"
[
'typeOrNameRequired', true,
],
])),
],
[
'link', new Map(/** @type {[string, string|boolean][]} */ ([
// Signature seems to require a namepath OR URL and might be checked as such.
[
'namepathRole', 'namepath-or-url-referencing',
],
])),
],
[
'linkcode', new Map(/** @type {[string, string|boolean][]} */ ([
// Synonym for "link"
// Signature seems to require a namepath OR URL and might be checked as such.
[
'namepathRole', 'namepath-or-url-referencing',
],
])),
],
[
'linkplain', new Map(/** @type {[string, string|boolean][]} */ ([
// Synonym for "link"
// Signature seems to require a namepath OR URL and might be checked as such.
[
'namepathRole', 'namepath-or-url-referencing',
],
])),
],
[
'listens', new Map(/** @type {[string, string|boolean][]} */ ([
// Signature seems to require a "name" (of an event) and no
// counter-examples
[
'namepathRole', 'namepath-referencing',
],
[
'nameRequired', true,
],
[
'typeAllowed', false,
],
])),
],
[
'member', new Map(/** @type {[string, string|boolean][]} */ ([
// Allows for "name"'s in signature, but indicated as optional
[
'namepathRole', 'namepath-defining',
],
// Has example showing curly brackets but not in doc signature
[
'typeAllowed', true,
],
])),
],
[
'memberof!', new Map(/** @type {[string, string|boolean][]} */ ([
// Signature seems to require a "namepath" (and no counter-examples),
// though it allows an incomplete namepath ending with connecting symbol
[
'namepathRole', 'namepath-referencing',
],
// "namepath"
[
'typeOrNameRequired', true,
],
])),
],
[
'memberof', new Map(/** @type {[string, string|boolean][]} */ ([
// Signature seems to require a "namepath" (and no counter-examples),
// though it allows an incomplete namepath ending with connecting symbol
[
'namepathRole', 'namepath-referencing',
],
// "namepath"
[
'typeOrNameRequired', true,
],
])),
],
[
'method', new Map(/** @type {[string, string|boolean][]} */ ([
// Allows for "name"'s in signature, but indicated as optional
[
'namepathRole', 'namepath-defining',
],
])),
],
[
'mixes', new Map(/** @type {[string, string|boolean][]} */ ([
// Signature seems to require a "OtherObjectPath" with no
// counter-examples
[
'namepathRole', 'namepath-referencing',
],
// "OtherObjectPath"
[
'typeOrNameRequired', true,
],
])),
],
[
'mixin', new Map(/** @type {[string, string|boolean][]} */ ([
// Allows for "name"'s in signature, but indicated as optional
[
'namepathRole', 'namepath-defining',
],
[
'nameRequired', false,
],
[
'typeAllowed', false,
],
])),
],
[
'modifies', new Map(/** @type {[string, string|boolean][]} */ ([
// Has no documentation, but test example has curly brackets, and
// "name" would be suggested rather than "namepath" based on example;
// not sure if name is required
[
'typeAllowed', true,
],
])),
],
[
'module', new Map(/** @type {[string, string|boolean][]} */ ([
// Optional "name" and no curly brackets
// this block impacts `no-undefined-types` and `valid-types` (search for
// "isNameOrNamepathDefiningTag|tagMightHaveNameOrNamepath|tagMightHaveEitherTypeOrNamePosition")
[
'namepathRole', isJsdoc ? 'namepath-defining' : 'text',
],
// Shows the signature with curly brackets but not in the example
[
'typeAllowed', true,
],
])),
],
[
'name', new Map(/** @type {[string, string|boolean][]} */ ([
// Seems to require a "namepath" in the signature (with no
// counter-examples)
[
'namepathRole', 'namepath-defining',
],
// "namepath"
[
'nameRequired', true,
],
// "namepath"
[
'typeOrNameRequired', true,
],
])),
],
[
'namespace', new Map(/** @type {[string, string|boolean][]} */ ([
// Allows for "name"'s in signature, but indicated as optional
[
'namepathRole', 'namepath-defining',
],
// Shows the signature with curly brackets but not in the example
[
'typeAllowed', true,
],
])),
],
[
'package', new Map(/** @type {[string, string|boolean][]} */ ([
// Shows the signature with curly brackets but not in the example
// "typeExpression"
[
'typeAllowed', isClosureOrPermissive,
],
])),
],
[
'param', new Map(/** @type {[string, string|boolean][]} */ ([
[
'namepathRole', 'namepath-defining',
],
// Though no signature provided requiring, per
// https://jsdoc.app/tags-param.html:
// "The @param tag requires you to specify the name of the parameter you
// are documenting."
[
'nameRequired', true,
],
// Has no formal signature in the docs but shows curly brackets
// in the examples
[
'typeAllowed', true,
],
])),
],
[
'private', new Map(/** @type {[string, string|boolean][]} */ ([
// Shows the signature with curly brackets but not in the example
// "typeExpression"
[
'typeAllowed', isClosureOrPermissive,
],
])),
],
[
'prop', new Map(/** @type {[string, string|boolean][]} */ ([
[
'namepathRole', 'namepath-defining',
],
// See `property`
[
'nameRequired', true,
],
// Has no formal signature in the docs but shows curly brackets
// in the examples
[
'typeAllowed', true,
],
])),
],
[
'property', new Map(/** @type {[string, string|boolean][]} */ ([
[
'namepathRole', 'namepath-defining',
],
// No docs indicate required, but since parallel to `param`, we treat as
// such:
[
'nameRequired', true,
],
// Has no formal signature in the docs but shows curly brackets
// in the examples
[
'typeAllowed', true,
],
])),
],
[
'protected', new Map(/** @type {[string, string|boolean][]} */ ([
// Shows the signature with curly brackets but not in the example
// "typeExpression"
[
'typeAllowed', isClosureOrPermissive,
],
])),
],
[
'public', new Map(/** @type {[string, string|boolean][]} */ ([
// Does not show a signature nor show curly brackets in the example
[
'typeAllowed', isClosureOrPermissive,
],
])),
],
[
'requires', new Map(/** @type {[string, string|boolean][]} */ ([
// <someModuleName>
[
'namepathRole', 'namepath-referencing',
],
[
'nameRequired', true,
],
[
'typeAllowed', false,
],
])),
],
[
'return', new Map(/** @type {[string, string|boolean][]} */ ([
// Shows curly brackets in the signature and in the examples
[
'typeAllowed', true,
],
])),
],
[
'returns', new Map(/** @type {[string, string|boolean][]} */ ([
// Shows curly brackets in the signature and in the examples
[
'typeAllowed', true,
],
])),
],
[
'satisfies', new Map(/** @type {[string, string|boolean][]} */ ([
// Shows curly brackets in the doc signature and examples
[
'typeRequired', true,
],
])),
],
[
'see', new Map(/** @type {[string, string|boolean][]} */ ([
// Signature allows for "namepath" or text, so user must configure to
// 'namepath-referencing' to enforce checks
[
'namepathRole', 'text',
],
])),
],
[
'static', new Map(/** @type {[string, string|boolean][]} */ ([
// Does not show a signature nor show curly brackets in the example
[
'typeAllowed', isClosureOrPermissive,
],
])),
],
[
'suppress', new Map(/** @type {[string, string|boolean][]} */ ([
[
'namepathRole', !isClosure,
],
[
'typeRequired', isClosure,
],
])),
],
[
'template', new Map(/** @type {[string, string|boolean][]} */ ([
[
'namepathRole', isJsdoc ? 'text' : 'namepath-referencing',
],
[
'nameRequired', !isJsdoc,
],
// Though defines `namepathRole: 'namepath-defining'` in a sense, it is
// not parseable in the same way for template (e.g., allowing commas),
// so not adding
[
'typeAllowed', isTypescriptOrClosure || isPermissive,
],
])),
],
[
'this', new Map(/** @type {[string, string|boolean][]} */ ([
// Signature seems to require a "namepath" (and no counter-examples)
// Not used with namepath in Closure/TypeScript, however
[
'namepathRole', isJsdoc ? 'namepath-referencing' : false,
],
// namepath
[
'typeOrNameRequired', isJsdoc,
],
[
'typeRequired', isTypescriptOrClosure,
],
])),
],
[
'throws', new Map(/** @type {[string, string|boolean][]} */ ([
// Shows curly brackets in the signature and in the examples
[
'typeAllowed', true,
],
])),
],
[
'tutorial', new Map(/** @type {[string, string|boolean][]} */ ([
// (a tutorial ID)
[
'nameRequired', true,
],
[
'typeAllowed', false,
],
])),
],
[
'type', new Map(/** @type {[string, string|boolean][]} */ ([
// Shows curly brackets in the doc signature and examples
// "typeName"
[
'typeRequired', true,
],
])),
],
[
'typedef', new Map(/** @type {[string, string|boolean][]} */ ([
// Seems to require a "namepath" in the signature (with no
// counter-examples)
[
'namepathRole', 'name-defining',
],
// TypeScript may allow it to be dropped if followed by @property or @member;
// also shown as missing in Closure
// "namepath"
[
'nameRequired', isJsdocOrPermissive,
],
// Is not `typeRequired` for TypeScript because it gives an error:
// JSDoc '@typedef' tag should either have a type annotation or be followed by '@property' or '@member' tags.
// Has example showing curly brackets but not in doc signature
[
'typeAllowed', true,
],
// TypeScript may allow it to be dropped if followed by @property or @member
// "namepath"
[
'typeOrNameRequired', !isTypescript,
],
])),
],
[
'var', new Map(/** @type {[string, string|boolean][]} */ ([
// Allows for "name"'s in signature, but indicated as optional
[
'namepathRole', 'namepath-defining',
],
// Has example showing curly brackets but not in doc signature
[
'typeAllowed', true,
],
])),
],
[
'yield', new Map(/** @type {[string, string|boolean][]} */ ([
// Shows curly brackets in the signature and in the examples
[
'typeAllowed', true,
],
])),
],
[
'yields', new Map(/** @type {[string, string|boolean][]} */ ([
// Shows curly brackets in the signature and in the examples
[
'typeAllowed', true,
],
])),
],
]);
};
export default getDefaultTagStructureForMode;

View file

@ -0,0 +1,5 @@
import {getJsdocProcessorPlugin} from './getJsdocProcessorPlugin.js';
export = {
getJsdocProcessorPlugin: getJsdocProcessorPlugin as typeof getJsdocProcessorPlugin
};

View file

@ -0,0 +1,692 @@
import {
forEachPreferredTag,
getPreferredTagName,
getRegexFromString,
getTagDescription,
hasTag,
} from './jsdocUtils.js';
import {
parseComment,
} from '@es-joy/jsdoccomment';
import * as espree from 'espree';
import {
decode,
} from 'html-entities';
import {
readFileSync,
} from 'node:fs';
import {
join,
} from 'node:path';
/**
* @import {
* Integer,
* JsdocBlockWithInline,
* } from './iterateJsdoc.js';
* @import {
* ESLint,
* Linter,
* } from 'eslint';
*/
const {
version,
} = JSON.parse(
readFileSync(join(import.meta.dirname, '../package.json'), 'utf8'),
);
// const zeroBasedLineIndexAdjust = -1;
const likelyNestedJSDocIndentSpace = 1;
const preTagSpaceLength = 1;
// If a space is present, we should ignore it
const firstLinePrefixLength = preTagSpaceLength;
const hasCaptionRegex = /^\s*<caption>([\s\S]*?)<\/caption>/v;
/**
* @param {string} str
* @returns {string}
*/
const escapeStringRegexp = (str) => {
return str.replaceAll(/[.*+?^$\{\}\(\)\|\[\]\\]/gv, '\\$&');
};
/**
* @param {string} str
* @param {string} ch
* @returns {Integer}
*/
const countChars = (str, ch) => {
return (str.match(new RegExp(escapeStringRegexp(ch), 'gv')) || []).length;
};
/**
* @param {string} text
* @returns {[
* Integer,
* Integer
* ]}
*/
const getLinesCols = (text) => {
const matchLines = countChars(text, '\n');
const colDelta = matchLines ?
text.slice(text.lastIndexOf('\n') + 1).length :
text.length;
return [
matchLines, colDelta,
];
};
/**
* @typedef {number} Integer
*/
/**
* @typedef {object} JsdocProcessorOptions
* @property {boolean} [captionRequired] Require captions for example tags
* @property {Integer} [paddedIndent] See docs
* @property {boolean} [checkDefaults] See docs
* @property {boolean} [checkParams] See docs
* @property {boolean} [checkExamples] See docs
* @property {boolean} [checkProperties] See docs
* @property {string} [matchingFileName] See docs
* @property {string} [matchingFileNameDefaults] See docs
* @property {string} [matchingFileNameParams] See docs
* @property {string} [matchingFileNameProperties] See docs
* @property {string|RegExp} [exampleCodeRegex] See docs
* @property {string|RegExp} [rejectExampleCodeRegex] See docs
* @property {string[]} [allowedLanguagesToProcess] See docs
* @property {"script"|"module"} [sourceType] See docs
* @property {import('eslint').Linter.ESTreeParser|import('eslint').Linter.NonESTreeParser} [parser] See docs
*/
/**
* We use a function for the ability of the user to pass in a config, but
* without requiring all users of the plugin to do so.
* @param {JsdocProcessorOptions} [options]
* @returns {ESLint.Plugin}
*/
export const getJsdocProcessorPlugin = (options = {}) => {
/**
* @typedef {{
* text: string,
* filename: string|null|undefined
* }} TextAndFileName
*/
const {
allowedLanguagesToProcess = [
'js', 'ts', 'javascript', 'typescript',
],
captionRequired = false,
checkDefaults = false,
checkExamples = true,
checkParams = false,
checkProperties = false,
exampleCodeRegex = null,
matchingFileName = null,
matchingFileNameDefaults = null,
matchingFileNameParams = null,
matchingFileNameProperties = null,
paddedIndent = 0,
parser = undefined,
rejectExampleCodeRegex = null,
sourceType = 'module',
} = options;
/** @type {RegExp} */
let exampleCodeRegExp;
/** @type {RegExp} */
let rejectExampleCodeRegExp;
if (exampleCodeRegex) {
exampleCodeRegExp = typeof exampleCodeRegex === 'string' ?
getRegexFromString(exampleCodeRegex) :
exampleCodeRegex;
}
if (rejectExampleCodeRegex) {
rejectExampleCodeRegExp = typeof rejectExampleCodeRegex === 'string' ?
getRegexFromString(rejectExampleCodeRegex) :
rejectExampleCodeRegex;
}
/**
* @type {{
* targetTagName: string,
* ext: string,
* codeStartLine: number,
* codeStartCol: number,
* nonJSPrefacingCols: number,
* commentLineCols: [number, number]
* }[]}
*/
const otherInfo = [];
/** @type {import('eslint').Linter.LintMessage[]} */
let extraMessages = [];
/**
* @param {JsdocBlockWithInline} jsdoc
* @param {string} jsFileName
* @param {[number, number]} commentLineCols
*/
const getTextsAndFileNames = (jsdoc, jsFileName, commentLineCols) => {
/**
* @type {TextAndFileName[]}
*/
const textsAndFileNames = [];
/**
* @param {{
* filename: string|null,
* defaultFileName: string|undefined,
* source: string,
* targetTagName: string,
* rules?: import('eslint').Linter.RulesRecord|undefined,
* lines?: Integer,
* cols?: Integer,
* skipInit?: boolean,
* ext: string,
* sources?: {
* nonJSPrefacingCols: Integer,
* nonJSPrefacingLines: Integer,
* string: string,
* }[],
* tag?: import('comment-parser').Spec & {
* line?: Integer,
* }|{
* line: Integer,
* }
* }} cfg
*/
const checkSource = ({
cols = 0,
defaultFileName,
ext,
filename,
lines = 0,
skipInit,
source,
sources = [],
tag = {
line: 0,
},
targetTagName,
}) => {
if (!skipInit) {
sources.push({
nonJSPrefacingCols: cols,
nonJSPrefacingLines: lines,
string: source,
});
}
/**
* @param {{
* nonJSPrefacingCols: Integer,
* nonJSPrefacingLines: Integer,
* string: string
* }} cfg
*/
const addSourceInfo = function ({
nonJSPrefacingCols,
nonJSPrefacingLines,
string,
}) {
const src = paddedIndent ?
string.replaceAll(new RegExp(`(^|\n) {${paddedIndent}}(?!$)`, 'gv'), '\n') :
string;
// Programmatic ESLint API: https://eslint.org/docs/developer-guide/nodejs-api
const file = filename || defaultFileName;
if (!('line' in tag)) {
tag.line = tag.source[0].number;
}
// NOTE: `tag.line` can be 0 if of form `/** @tag ... */`
const codeStartLine = /**
* @type {import('comment-parser').Spec & {
* line: Integer,
* }}
*/ (tag).line + nonJSPrefacingLines;
const codeStartCol = likelyNestedJSDocIndentSpace;
textsAndFileNames.push({
filename: file,
// See https://github.com/gajus/eslint-plugin-jsdoc/issues/710
text: src.replaceAll(/(?<=\*)\\(?=\\*\/)/gv, '').replaceAll(/&([^\s;]+);/gv, (_, code) => {
// Dec
if ((/^#\d+$/v).test(code)) {
return String.fromCodePoint(Number.parseInt(code.slice(1), 10));
}
// Hex
if ((/^#x\d+$/v).test(code)) {
return String.fromCodePoint(Number.parseInt(code.slice(2), 16));
}
return decode(_, {
level: 'html5',
});
}),
});
otherInfo.push({
codeStartCol,
codeStartLine,
commentLineCols,
ext,
nonJSPrefacingCols,
targetTagName,
});
};
for (const targetSource of sources) {
addSourceInfo(targetSource);
}
};
/**
*
* @param {string|null} filename
* @param {string} [ext] Since `eslint-plugin-markdown` v2, and
* ESLint 7, this is the default which other JS-fenced rules will used.
* Formerly "md" was the default.
* @returns {{
* defaultFileName: string|undefined,
* filename: string|null,
* ext: string
* }}
*/
const getFilenameInfo = (filename, ext = 'md/*.js') => {
let defaultFileName;
if (!filename) {
if (typeof jsFileName === 'string' && jsFileName.includes('.')) {
defaultFileName = jsFileName.replace(/\.[^.]*$/v, `.${ext}`);
} else {
defaultFileName = `dummy.${ext}`;
}
}
return {
defaultFileName,
ext,
filename,
};
};
if (checkDefaults) {
const filenameInfo = getFilenameInfo(matchingFileNameDefaults, 'jsdoc-defaults');
forEachPreferredTag(jsdoc, 'default', (tag, targetTagName) => {
if (!tag.description.trim()) {
return;
}
checkSource({
source: `(${getTagDescription(tag)})`,
targetTagName,
...filenameInfo,
});
});
}
if (checkParams) {
const filenameInfo = getFilenameInfo(matchingFileNameParams, 'jsdoc-params');
forEachPreferredTag(jsdoc, 'param', (tag, targetTagName) => {
if (!tag.default || !tag.default.trim()) {
return;
}
checkSource({
source: `(${tag.default})`,
targetTagName,
...filenameInfo,
});
});
}
if (checkProperties) {
const filenameInfo = getFilenameInfo(matchingFileNameProperties, 'jsdoc-properties');
forEachPreferredTag(jsdoc, 'property', (tag, targetTagName) => {
if (!tag.default || !tag.default.trim()) {
return;
}
checkSource({
source: `(${tag.default})`,
targetTagName,
...filenameInfo,
});
});
}
if (!checkExamples) {
return textsAndFileNames;
}
const tagName = /** @type {string} */ (getPreferredTagName(jsdoc, {
tagName: 'example',
}));
if (!hasTag(jsdoc, tagName)) {
return textsAndFileNames;
}
const matchingFilenameInfo = getFilenameInfo(matchingFileName);
forEachPreferredTag(jsdoc, 'example', (tag, targetTagName) => {
let source = /** @type {string} */ (getTagDescription(tag));
const match = source.match(hasCaptionRegex);
if (captionRequired && (!match || !match[1].trim())) {
extraMessages.push({
column: commentLineCols[1] + 1,
line: 1 + commentLineCols[0] + (tag.line ?? tag.source[0].number),
message: `@${targetTagName} error - Caption is expected for examples.`,
ruleId: 'jsdoc/example-missing-caption',
severity: 2,
});
return;
}
source = source.replace(hasCaptionRegex, '');
const [
lines,
cols,
] = match ? getLinesCols(match[0]) : [
0, 0,
];
if (exampleCodeRegex && !exampleCodeRegExp.test(source) ||
rejectExampleCodeRegex && rejectExampleCodeRegExp.test(source)
) {
return;
}
// If `allowedLanguagesToProcess` is falsy, all languages should be processed.
if (allowedLanguagesToProcess) {
const matches = (/^\s*```(?<language>\S+)([\s\S]*)```\s*$/v).exec(source);
if (matches?.groups && !allowedLanguagesToProcess.includes(
matches.groups.language.toLowerCase(),
)) {
return;
}
}
const sources = [];
let skipInit = false;
if (exampleCodeRegex) {
let nonJSPrefacingCols = 0;
let nonJSPrefacingLines = 0;
let startingIndex = 0;
let lastStringCount = 0;
let exampleCode;
exampleCodeRegExp.lastIndex = 0;
while ((exampleCode = exampleCodeRegExp.exec(source)) !== null) {
const {
'0': n0,
'1': n1,
index,
} = exampleCode;
// Count anything preceding user regex match (can affect line numbering)
const preMatch = source.slice(startingIndex, index);
const [
preMatchLines,
colDelta,
] = getLinesCols(preMatch);
let nonJSPreface;
let nonJSPrefaceLineCount;
if (n1) {
const idx = n0.indexOf(n1);
nonJSPreface = n0.slice(0, idx);
nonJSPrefaceLineCount = countChars(nonJSPreface, '\n');
} else {
nonJSPreface = '';
nonJSPrefaceLineCount = 0;
}
nonJSPrefacingLines += lastStringCount + preMatchLines + nonJSPrefaceLineCount;
// Ignore `preMatch` delta if newlines here
if (nonJSPrefaceLineCount) {
const charsInLastLine = nonJSPreface.slice(nonJSPreface.lastIndexOf('\n') + 1).length;
nonJSPrefacingCols += charsInLastLine;
} else {
nonJSPrefacingCols += colDelta + nonJSPreface.length;
}
const string = n1 || n0;
sources.push({
nonJSPrefacingCols,
nonJSPrefacingLines,
string,
});
startingIndex = exampleCodeRegExp.lastIndex;
lastStringCount = countChars(string, '\n');
if (!exampleCodeRegExp.global) {
break;
}
}
skipInit = true;
}
checkSource({
cols,
lines,
skipInit,
source,
sources,
tag,
targetTagName,
...matchingFilenameInfo,
});
});
return textsAndFileNames;
};
// See https://eslint.org/docs/latest/extend/plugins#processors-in-plugins
// See https://eslint.org/docs/latest/extend/custom-processors
// From https://github.com/eslint/eslint/issues/14745#issuecomment-869457265
/*
{
"files": ["*.js", "*.ts"],
"processor": "jsdoc/example" // a pretended value here
},
{
"files": [
"*.js/*_jsdoc-example.js",
"*.ts/*_jsdoc-example.js",
"*.js/*_jsdoc-example.ts"
],
"rules": {
// specific rules for examples in jsdoc only here
// And other rules for `.js` and `.ts` will also be enabled for them
}
}
*/
return {
meta: {
name: 'eslint-plugin-jsdoc/processor',
version,
},
processors: {
examples: {
meta: {
name: 'eslint-plugin-jsdoc/preprocessor',
version,
},
/**
* @param {import('eslint').Linter.LintMessage[][]} messages
* @param {string} filename
*/
postprocess ([
jsMessages,
...messages
// eslint-disable-next-line no-unused-vars -- Placeholder
], filename) {
for (const [
idx,
message,
] of messages.entries()) {
const {
codeStartCol,
codeStartLine,
commentLineCols,
nonJSPrefacingCols,
targetTagName,
} = otherInfo[idx];
for (const msg of message) {
const {
column,
endColumn,
endLine,
fatal,
line,
message: messageText,
ruleId,
severity,
// Todo: Make fixable
// fix
// fix: {range: [number, number], text: string}
// suggestions: {desc: , messageId:, fix: }[],
} = msg;
delete msg.fix;
const [
codeCtxLine,
codeCtxColumn,
] = commentLineCols;
const startLine = codeCtxLine + codeStartLine + line;
// Seems to need one more now
const startCol = 1 +
codeCtxColumn + codeStartCol + (
// This might not work for line 0, but line 0 is unlikely for examples
line <= 1 ? nonJSPrefacingCols + firstLinePrefixLength : preTagSpaceLength
) + column;
msg.message = '@' + targetTagName + ' ' + (severity === 2 ? 'error' : 'warning') +
(ruleId ? ' (' + ruleId + ')' : '') + ': ' +
(fatal ? 'Fatal: ' : '') +
messageText;
msg.line = startLine;
msg.column = startCol;
msg.endLine = endLine ? startLine + endLine : startLine;
// added `- column` to offset what `endColumn` already seemed to include
msg.endColumn = endColumn ? startCol - column + endColumn : startCol;
}
}
const ret = [
...jsMessages,
].concat(...messages, ...extraMessages);
extraMessages = [];
return ret;
},
/**
* @param {string} text
* @param {string} filename
* @returns {(string | Linter.ProcessorFile)[]}
*/
preprocess (text, filename) {
try {
let ast;
// May be running a second time so catch and ignore
try {
ast = parser ?
// @ts-expect-error Should be present
parser.parseForESLint(text, {
comment: true,
ecmaVersion: 'latest',
sourceType,
}).ast :
espree.parse(text, {
comment: true,
ecmaVersion: 'latest',
sourceType,
});
} catch {
return [
text,
];
}
/** @type {[number, number][]} */
const commentLineCols = [];
const jsdocComments = /** @type {import('estree').Comment[]} */ (
/**
* @type {import('estree').Program & {
* comments?: import('estree').Comment[]
* }}
*/
(ast).comments
).filter((comment) => {
return (/^\*\s/v).test(comment.value);
}).map((comment) => {
const [
start,
/* c8 ignore next -- Unsupporting processors only? */
] = comment.range ?? [];
const textToStart = text.slice(0, start);
const [
lines,
cols,
] = getLinesCols(textToStart);
// const lines = [...textToStart.matchAll(/\n/gv)].length
// const lastLinePos = textToStart.lastIndexOf('\n');
// const cols = lastLinePos === -1
// ? 0
// : textToStart.slice(lastLinePos).length;
commentLineCols.push([
lines, cols,
]);
return parseComment(comment);
});
return [
text,
...jsdocComments.flatMap((jsdoc, idx) => {
return getTextsAndFileNames(
jsdoc,
filename,
commentLineCols[idx],
);
}).filter(
/**
* @param {TextAndFileName} file
* @returns {file is Linter.ProcessorFile}
*/
(file) => {
return file !== null && file !== undefined;
},
),
];
/* c8 ignore next 6 */
} catch (error) {
// eslint-disable-next-line no-console -- Debugging
console.log('err', filename, error);
}
return [];
},
supportsAutofix: true,
},
},
};
};

View file

@ -0,0 +1,720 @@
import {
buildForbidRuleDefinition,
} from './buildForbidRuleDefinition.js';
import {
buildRejectOrPreferRuleDefinition,
} from './buildRejectOrPreferRuleDefinition.js';
import {
getJsdocProcessorPlugin,
} from './getJsdocProcessorPlugin.js';
import checkAccess from './rules/checkAccess.js';
import checkAlignment from './rules/checkAlignment.js';
import checkExamples from './rules/checkExamples.js';
import checkIndentation from './rules/checkIndentation.js';
import checkLineAlignment from './rules/checkLineAlignment.js';
import checkParamNames from './rules/checkParamNames.js';
import checkPropertyNames from './rules/checkPropertyNames.js';
import checkSyntax from './rules/checkSyntax.js';
import checkTagNames from './rules/checkTagNames.js';
import checkTemplateNames from './rules/checkTemplateNames.js';
import checkTypes from './rules/checkTypes.js';
import checkValues from './rules/checkValues.js';
import convertToJsdocComments from './rules/convertToJsdocComments.js';
import emptyTags from './rules/emptyTags.js';
import escapeInlineTags from './rules/escapeInlineTags.js';
import implementsOnClasses from './rules/implementsOnClasses.js';
import importsAsDependencies from './rules/importsAsDependencies.js';
import informativeDocs from './rules/informativeDocs.js';
import linesBeforeBlock from './rules/linesBeforeBlock.js';
import matchDescription from './rules/matchDescription.js';
import matchName from './rules/matchName.js';
import multilineBlocks from './rules/multilineBlocks.js';
import noBadBlocks from './rules/noBadBlocks.js';
import noBlankBlockDescriptions from './rules/noBlankBlockDescriptions.js';
import noBlankBlocks from './rules/noBlankBlocks.js';
import noDefaults from './rules/noDefaults.js';
import noMissingSyntax from './rules/noMissingSyntax.js';
import noMultiAsterisks from './rules/noMultiAsterisks.js';
import noRestrictedSyntax from './rules/noRestrictedSyntax.js';
import noTypes from './rules/noTypes.js';
import noUndefinedTypes from './rules/noUndefinedTypes.js';
import preferImportTag from './rules/preferImportTag.js';
import requireAsteriskPrefix from './rules/requireAsteriskPrefix.js';
import requireDescription from './rules/requireDescription.js';
import requireDescriptionCompleteSentence from './rules/requireDescriptionCompleteSentence.js';
import requireExample from './rules/requireExample.js';
import requireFileOverview from './rules/requireFileOverview.js';
import requireHyphenBeforeParamDescription from './rules/requireHyphenBeforeParamDescription.js';
import requireJsdoc from './rules/requireJsdoc.js';
import requireParam from './rules/requireParam.js';
import requireParamDescription from './rules/requireParamDescription.js';
import requireParamName from './rules/requireParamName.js';
import requireParamType from './rules/requireParamType.js';
import requireProperty from './rules/requireProperty.js';
import requirePropertyDescription from './rules/requirePropertyDescription.js';
import requirePropertyName from './rules/requirePropertyName.js';
import requirePropertyType from './rules/requirePropertyType.js';
import requireRejects from './rules/requireRejects.js';
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 requireTags from './rules/requireTags.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';
import sortTags from './rules/sortTags.js';
import tagLines from './rules/tagLines.js';
import textEscaping from './rules/textEscaping.js';
import tsMethodSignatureStyle from './rules/tsMethodSignatureStyle.js';
import tsNoEmptyObjectType from './rules/tsNoEmptyObjectType.js';
import tsNoUnnecessaryTemplateExpression from './rules/tsNoUnnecessaryTemplateExpression.js';
import tsPreferFunctionType from './rules/tsPreferFunctionType.js';
import typeFormatting from './rules/typeFormatting.js';
import validTypes from './rules/validTypes.js';
/**
* @typedef {"recommended" | "stylistic" | "contents" | "logical" | "requirements"} ConfigGroups
* @typedef {"" | "-typescript" | "-typescript-flavor"} ConfigVariants
* @typedef {"" | "-error"} ErrorLevelVariants
* @type {import('eslint').ESLint.Plugin & {
* configs: Record<
* `flat/${ConfigGroups}${ConfigVariants}${ErrorLevelVariants}`,
* import('eslint').Linter.Config
* > &
* Record<
* "examples"|"default-expressions"|"examples-and-default-expressions",
* import('eslint').Linter.Config[]
* > &
* Record<"flat/recommended-mixed", import('eslint').Linter.Config[]>
* }}
*/
const index = {};
index.configs = {};
index.rules = {
'check-access': checkAccess,
'check-alignment': checkAlignment,
'check-examples': checkExamples,
'check-indentation': checkIndentation,
'check-line-alignment': checkLineAlignment,
'check-param-names': checkParamNames,
'check-property-names': checkPropertyNames,
'check-syntax': checkSyntax,
'check-tag-names': checkTagNames,
'check-template-names': checkTemplateNames,
'check-types': checkTypes,
'check-values': checkValues,
'convert-to-jsdoc-comments': convertToJsdocComments,
'empty-tags': emptyTags,
'escape-inline-tags': escapeInlineTags,
'implements-on-classes': implementsOnClasses,
'imports-as-dependencies': importsAsDependencies,
'informative-docs': informativeDocs,
'lines-before-block': linesBeforeBlock,
'match-description': matchDescription,
'match-name': matchName,
'multiline-blocks': multilineBlocks,
'no-bad-blocks': noBadBlocks,
'no-blank-block-descriptions': noBlankBlockDescriptions,
'no-blank-blocks': noBlankBlocks,
'no-defaults': noDefaults,
'no-missing-syntax': noMissingSyntax,
'no-multi-asterisks': noMultiAsterisks,
'no-restricted-syntax': noRestrictedSyntax,
'no-types': noTypes,
'no-undefined-types': noUndefinedTypes,
'prefer-import-tag': preferImportTag,
'reject-any-type': buildRejectOrPreferRuleDefinition({
description: 'Reports use of `any` or `*` type',
overrideSettings: {
'*': {
message: 'Prefer a more specific type to `*`',
replacement: false,
unifyParentAndChildTypeChecks: true,
},
any: {
message: 'Prefer a more specific type to `any`',
replacement: false,
unifyParentAndChildTypeChecks: true,
},
},
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/reject-any-type.md#repos-sticky-header',
}),
'reject-function-type': buildRejectOrPreferRuleDefinition({
description: 'Reports use of `Function` type',
overrideSettings: {
Function: {
message: 'Prefer a more specific type to `Function`',
replacement: false,
unifyParentAndChildTypeChecks: true,
},
},
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/reject-function-type.md#repos-sticky-header',
}),
'require-asterisk-prefix': requireAsteriskPrefix,
'require-description': requireDescription,
'require-description-complete-sentence': requireDescriptionCompleteSentence,
'require-example': requireExample,
'require-file-overview': requireFileOverview,
'require-hyphen-before-param-description': requireHyphenBeforeParamDescription,
'require-jsdoc': requireJsdoc,
'require-next-description': buildForbidRuleDefinition({
contexts: [
{
comment: 'JsdocBlock:has(JsdocTag[tag=next]:not([name!=""]):not([description!=""]))',
context: 'any',
message: '@next should have a description',
},
],
description: 'Requires a description for `@next` tags',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/require-next-description.md#repos-sticky-header',
}),
'require-next-type': buildForbidRuleDefinition({
contexts: [
{
comment: 'JsdocBlock:has(JsdocTag[tag=next]:not([parsedType.type]))',
context: 'any',
message: '@next should have a type',
},
],
description: 'Requires a type for `@next` tags',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/require-next-type.md#repos-sticky-header',
}),
'require-param': requireParam,
'require-param-description': requireParamDescription,
'require-param-name': requireParamName,
'require-param-type': requireParamType,
'require-property': requireProperty,
'require-property-description': requirePropertyDescription,
'require-property-name': requirePropertyName,
'require-property-type': requirePropertyType,
'require-rejects': requireRejects,
'require-returns': requireReturns,
'require-returns-check': requireReturnsCheck,
'require-returns-description': requireReturnsDescription,
'require-returns-type': requireReturnsType,
'require-tags': requireTags,
'require-template': requireTemplate,
'require-template-description': buildForbidRuleDefinition({
contexts: [
{
comment: 'JsdocBlock:has(JsdocTag[tag=template]:not([description!=""]))',
context: 'any',
message: '@template should have a description',
},
],
description: 'Requires a description for `@template` tags',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/require-template-description.md#repos-sticky-header',
}),
'require-throws': requireThrows,
'require-throws-description': buildForbidRuleDefinition({
contexts: [
{
comment: 'JsdocBlock:has(JsdocTag[tag=/^(?:throws|exception)$/]:not([description!=""]))',
context: 'any',
message: '@throws should have a description',
},
],
description: 'Requires a description for `@throws` tags',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/require-throws-description.md#repos-sticky-header',
}),
'require-throws-type': buildForbidRuleDefinition({
contexts: [
{
comment: 'JsdocBlock:has(JsdocTag[tag=/^(?:throws|exception)$/]:not([parsedType.type]))',
context: 'any',
message: '@throws should have a type',
},
],
description: 'Requires a type for `@throws` tags',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/require-throws-type.md#repos-sticky-header',
}),
'require-yields': requireYields,
'require-yields-check': requireYieldsCheck,
'require-yields-description': buildForbidRuleDefinition({
contexts: [
{
comment: 'JsdocBlock:has(JsdocTag[tag=/^yields?$/]:not([name!=""]):not([description!=""]))',
context: 'any',
message: '@yields should have a description',
},
],
description: 'Requires a description for `@yields` tags',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/require-yields-description.md#repos-sticky-header',
}),
'require-yields-type': buildForbidRuleDefinition({
contexts: [
{
comment: 'JsdocBlock:has(JsdocTag[tag=/^yields?$/]:not([parsedType.type]))',
context: 'any',
message: '@yields should have a type',
},
],
description: 'Requires a type for `@yields` tags',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/require-yields-type.md#repos-sticky-header',
}),
'sort-tags': sortTags,
'tag-lines': tagLines,
'text-escaping': textEscaping,
'ts-method-signature-style': tsMethodSignatureStyle,
'ts-no-empty-object-type': tsNoEmptyObjectType,
'ts-no-unnecessary-template-expression': tsNoUnnecessaryTemplateExpression,
'ts-prefer-function-type': tsPreferFunctionType,
'type-formatting': typeFormatting,
'valid-types': validTypes,
};
/**
* @param {"warn"|"error"} warnOrError
* @param {string} [flatName]
* @returns {import('eslint').Linter.Config}
*/
const createRecommendedRuleset = (warnOrError, flatName) => {
return {
...(flatName ? {
name: 'jsdoc/' + flatName,
} : {}),
// @ts-expect-error ESLint 8 plugins
plugins:
flatName ? {
jsdoc: index,
} : [
'jsdoc',
],
rules: {
'jsdoc/check-access': warnOrError,
'jsdoc/check-alignment': warnOrError,
'jsdoc/check-examples': 'off',
'jsdoc/check-indentation': 'off',
'jsdoc/check-line-alignment': 'off',
'jsdoc/check-param-names': warnOrError,
'jsdoc/check-property-names': warnOrError,
'jsdoc/check-syntax': 'off',
'jsdoc/check-tag-names': warnOrError,
'jsdoc/check-template-names': 'off',
'jsdoc/check-types': warnOrError,
'jsdoc/check-values': warnOrError,
'jsdoc/convert-to-jsdoc-comments': 'off',
'jsdoc/empty-tags': warnOrError,
'jsdoc/escape-inline-tags': warnOrError,
'jsdoc/implements-on-classes': warnOrError,
'jsdoc/imports-as-dependencies': 'off',
'jsdoc/informative-docs': 'off',
'jsdoc/lines-before-block': 'off',
'jsdoc/match-description': 'off',
'jsdoc/match-name': 'off',
'jsdoc/multiline-blocks': warnOrError,
'jsdoc/no-bad-blocks': 'off',
'jsdoc/no-blank-block-descriptions': 'off',
'jsdoc/no-blank-blocks': 'off',
'jsdoc/no-defaults': warnOrError,
'jsdoc/no-missing-syntax': 'off',
'jsdoc/no-multi-asterisks': warnOrError,
'jsdoc/no-restricted-syntax': 'off',
'jsdoc/no-types': 'off',
'jsdoc/no-undefined-types': warnOrError,
'jsdoc/prefer-import-tag': 'off',
'jsdoc/reject-any-type': warnOrError,
'jsdoc/reject-function-type': warnOrError,
'jsdoc/require-asterisk-prefix': 'off',
'jsdoc/require-description': 'off',
'jsdoc/require-description-complete-sentence': 'off',
'jsdoc/require-example': 'off',
'jsdoc/require-file-overview': 'off',
'jsdoc/require-hyphen-before-param-description': 'off',
'jsdoc/require-jsdoc': warnOrError,
'jsdoc/require-next-description': 'off',
'jsdoc/require-next-type': warnOrError,
'jsdoc/require-param': warnOrError,
'jsdoc/require-param-description': warnOrError,
'jsdoc/require-param-name': warnOrError,
'jsdoc/require-param-type': warnOrError,
'jsdoc/require-property': warnOrError,
'jsdoc/require-property-description': warnOrError,
'jsdoc/require-property-name': warnOrError,
'jsdoc/require-property-type': warnOrError,
'jsdoc/require-rejects': 'off',
'jsdoc/require-returns': warnOrError,
'jsdoc/require-returns-check': warnOrError,
'jsdoc/require-returns-description': warnOrError,
'jsdoc/require-returns-type': warnOrError,
'jsdoc/require-tags': 'off',
'jsdoc/require-template': 'off',
'jsdoc/require-template-description': 'off',
'jsdoc/require-throws': 'off',
'jsdoc/require-throws-description': 'off',
'jsdoc/require-throws-type': warnOrError,
'jsdoc/require-yields': warnOrError,
'jsdoc/require-yields-check': warnOrError,
'jsdoc/require-yields-description': 'off',
'jsdoc/require-yields-type': warnOrError,
'jsdoc/sort-tags': 'off',
'jsdoc/tag-lines': warnOrError,
'jsdoc/text-escaping': 'off',
'jsdoc/ts-method-signature-style': 'off',
'jsdoc/ts-no-empty-object-type': warnOrError,
'jsdoc/ts-no-unnecessary-template-expression': 'off',
'jsdoc/ts-prefer-function-type': 'off',
'jsdoc/type-formatting': 'off',
'jsdoc/valid-types': warnOrError,
},
};
};
/**
* @param {"warn"|"error"} warnOrError
* @param {string} [flatName]
* @returns {import('eslint').Linter.Config}
*/
const createRecommendedTypeScriptRuleset = (warnOrError, flatName) => {
const ruleset = createRecommendedRuleset(warnOrError, flatName);
return {
...ruleset,
rules: {
...ruleset.rules,
/* eslint-disable @stylistic/indent -- Extra indent to avoid use by auto-rule-editing */
'jsdoc/check-tag-names': [
warnOrError, {
typed: true,
},
],
'jsdoc/no-types': warnOrError,
'jsdoc/no-undefined-types': 'off',
'jsdoc/require-param-type': 'off',
'jsdoc/require-property-type': 'off',
'jsdoc/require-returns-type': 'off',
/* eslint-enable @stylistic/indent */
},
};
};
/**
* @param {"warn"|"error"} warnOrError
* @param {string} [flatName]
* @returns {import('eslint').Linter.Config}
*/
const createRecommendedTypeScriptFlavorRuleset = (warnOrError, flatName) => {
const ruleset = createRecommendedRuleset(warnOrError, flatName);
return {
...ruleset,
rules: {
...ruleset.rules,
/* eslint-disable @stylistic/indent -- Extra indent to avoid use by auto-rule-editing */
'jsdoc/no-undefined-types': 'off',
/* eslint-enable @stylistic/indent */
},
};
};
/**
* @param {(string | unknown[])[]} ruleNames
*/
const createStandaloneRulesetFactory = (ruleNames) => {
/**
* @param {"warn"|"error"} warnOrError
* @param {string} [flatName]
* @returns {import('eslint').Linter.Config}
*/
return (warnOrError, flatName) => {
return {
name: 'jsdoc/' + flatName,
plugins: {
jsdoc: index,
},
rules: Object.fromEntries(
ruleNames.map(
(ruleName) => {
return (typeof ruleName === 'string' ?
[
ruleName, warnOrError,
] :
[
ruleName[0], [
warnOrError, ...ruleName.slice(1),
],
]);
},
),
),
};
};
};
const contentsRules = [
'jsdoc/informative-docs',
'jsdoc/match-description',
'jsdoc/no-blank-block-descriptions',
'jsdoc/no-blank-blocks',
[
'jsdoc/text-escaping', {
escapeHTML: true,
},
],
];
const createContentsTypescriptRuleset = createStandaloneRulesetFactory(contentsRules);
const createContentsTypescriptFlavorRuleset = createStandaloneRulesetFactory(contentsRules);
const logicalRules = [
'jsdoc/check-access',
'jsdoc/check-param-names',
'jsdoc/check-property-names',
'jsdoc/check-syntax',
'jsdoc/check-tag-names',
'jsdoc/check-template-names',
'jsdoc/check-types',
'jsdoc/check-values',
'jsdoc/empty-tags',
'jsdoc/escape-inline-tags',
'jsdoc/implements-on-classes',
'jsdoc/require-returns-check',
'jsdoc/require-yields-check',
'jsdoc/no-bad-blocks',
'jsdoc/no-defaults',
'jsdoc/no-types',
'jsdoc/no-undefined-types',
'jsdoc/valid-types',
];
const createLogicalTypescriptRuleset = createStandaloneRulesetFactory(logicalRules);
const createLogicalTypescriptFlavorRuleset = createStandaloneRulesetFactory(logicalRules);
const requirementsRules = [
'jsdoc/require-example',
'jsdoc/require-jsdoc',
'jsdoc/require-next-type',
'jsdoc/require-param',
'jsdoc/require-param-description',
'jsdoc/require-param-name',
'jsdoc/require-property',
'jsdoc/require-property-description',
'jsdoc/require-property-name',
'jsdoc/require-returns',
'jsdoc/require-returns-description',
'jsdoc/require-throws-type',
'jsdoc/require-yields',
'jsdoc/require-yields-type',
];
const createRequirementsTypeScriptRuleset = createStandaloneRulesetFactory(requirementsRules);
const createRequirementsTypeScriptFlavorRuleset = createStandaloneRulesetFactory([
...requirementsRules,
'jsdoc/require-param-type',
'jsdoc/require-property-type',
'jsdoc/require-returns-type',
'jsdoc/require-template',
]);
const stylisticRules = [
'jsdoc/check-alignment',
'jsdoc/check-line-alignment',
'jsdoc/lines-before-block',
'jsdoc/multiline-blocks',
'jsdoc/no-multi-asterisks',
'jsdoc/require-asterisk-prefix',
[
'jsdoc/require-hyphen-before-param-description', 'never',
],
'jsdoc/tag-lines',
];
const createStylisticTypeScriptRuleset = createStandaloneRulesetFactory(stylisticRules);
const createStylisticTypeScriptFlavorRuleset = createStandaloneRulesetFactory(stylisticRules);
/* c8 ignore next 3 -- TS */
if (!index.configs) {
throw new Error('TypeScript guard');
}
index.configs.recommended = createRecommendedRuleset('warn');
index.configs['recommended-error'] = createRecommendedRuleset('error');
index.configs['recommended-typescript'] = createRecommendedTypeScriptRuleset('warn');
index.configs['recommended-typescript-error'] = createRecommendedTypeScriptRuleset('error');
index.configs['recommended-typescript-flavor'] = createRecommendedTypeScriptFlavorRuleset('warn');
index.configs['recommended-typescript-flavor-error'] = createRecommendedTypeScriptFlavorRuleset('error');
index.configs['flat/recommended'] = createRecommendedRuleset('warn', 'flat/recommended');
index.configs['flat/recommended-error'] = createRecommendedRuleset('error', 'flat/recommended-error');
index.configs['flat/recommended-typescript'] = createRecommendedTypeScriptRuleset('warn', 'flat/recommended-typescript');
index.configs['flat/recommended-typescript-error'] = createRecommendedTypeScriptRuleset('error', 'flat/recommended-typescript-error');
index.configs['flat/recommended-typescript-flavor'] = createRecommendedTypeScriptFlavorRuleset('warn', 'flat/recommended-typescript-flavor');
index.configs['flat/recommended-typescript-flavor-error'] = createRecommendedTypeScriptFlavorRuleset('error', 'flat/recommended-typescript-flavor-error');
index.configs['flat/contents-typescript'] = createContentsTypescriptRuleset('warn', 'flat/contents-typescript');
index.configs['flat/contents-typescript-error'] = createContentsTypescriptRuleset('error', 'flat/contents-typescript-error');
index.configs['flat/contents-typescript-flavor'] = createContentsTypescriptFlavorRuleset('warn', 'flat/contents-typescript-flavor');
index.configs['flat/contents-typescript-flavor-error'] = createContentsTypescriptFlavorRuleset('error', 'flat/contents-typescript-error-flavor');
index.configs['flat/logical-typescript'] = createLogicalTypescriptRuleset('warn', 'flat/logical-typescript');
index.configs['flat/logical-typescript-error'] = createLogicalTypescriptRuleset('error', 'flat/logical-typescript-error');
index.configs['flat/logical-typescript-flavor'] = createLogicalTypescriptFlavorRuleset('warn', 'flat/logical-typescript-flavor');
index.configs['flat/logical-typescript-flavor-error'] = createLogicalTypescriptFlavorRuleset('error', 'flat/logical-typescript-error-flavor');
index.configs['flat/requirements-typescript'] = createRequirementsTypeScriptRuleset('warn', 'flat/requirements-typescript');
index.configs['flat/requirements-typescript-error'] = createRequirementsTypeScriptRuleset('error', 'flat/requirements-typescript-error');
index.configs['flat/requirements-typescript-flavor'] = createRequirementsTypeScriptFlavorRuleset('warn', 'flat/requirements-typescript-flavor');
index.configs['flat/requirements-typescript-flavor-error'] = createRequirementsTypeScriptFlavorRuleset('error', 'flat/requirements-typescript-error-flavor');
index.configs['flat/stylistic-typescript'] = createStylisticTypeScriptRuleset('warn', 'flat/stylistic-typescript');
index.configs['flat/stylistic-typescript-error'] = createStylisticTypeScriptRuleset('error', 'flat/stylistic-typescript-error');
index.configs['flat/stylistic-typescript-flavor'] = createStylisticTypeScriptFlavorRuleset('warn', 'flat/stylistic-typescript-flavor');
index.configs['flat/stylistic-typescript-flavor-error'] = createStylisticTypeScriptFlavorRuleset('error', 'flat/stylistic-typescript-error-flavor');
index.configs.examples = /** @type {import('eslint').Linter.Config[]} */ ([
{
files: [
'**/*.js',
],
name: 'jsdoc/examples/processor',
plugins: {
examples: getJsdocProcessorPlugin(),
},
processor: 'examples/examples',
},
{
files: [
'**/*.md/*.js',
],
name: 'jsdoc/examples/rules',
rules: {
// "always" newline rule at end unlikely in sample code
'@stylistic/eol-last': 0,
// Often wish to start `@example` code after newline; also may use
// empty lines for spacing
'@stylistic/no-multiple-empty-lines': 0,
// Can generally look nicer to pad a little even if code imposes more stringency
'@stylistic/padded-blocks': 0,
'@typescript-eslint/no-unused-vars': 0,
// "always" newline rule at end unlikely in sample code
'eol-last': 0,
// Wouldn't generally expect example paths to resolve relative to JS file
'import/no-unresolved': 0,
// Snippets likely too short to always include import/export info
'import/unambiguous': 0,
'jsdoc/require-file-overview': 0,
// The end of a multiline comment would end the comment the example is in.
'jsdoc/require-jsdoc': 0,
// See import/no-unresolved
'n/no-missing-import': 0,
'n/no-missing-require': 0,
// Unlikely to have inadvertent debugging within examples
'no-console': 0,
// Often wish to start `@example` code after newline; also may use
// empty lines for spacing
'no-multiple-empty-lines': 0,
// Many variables in examples will be `undefined`
'no-undef': 0,
// Common to define variables for clarity without always using them
'no-unused-vars': 0,
// See import/no-unresolved
'node/no-missing-import': 0,
'node/no-missing-require': 0,
// Can generally look nicer to pad a little even if code imposes more stringency
'padded-blocks': 0,
},
},
]);
index.configs['default-expressions'] = /** @type {import('eslint').Linter.Config[]} */ ([
{
files: [
'**/*.js',
],
name: 'jsdoc/default-expressions/processor',
plugins: {
examples: getJsdocProcessorPlugin({
checkDefaults: true,
checkParams: true,
checkProperties: true,
}),
},
processor: 'examples/examples',
},
{
files: [
'**/*.jsdoc-defaults', '**/*.jsdoc-params', '**/*.jsdoc-properties',
],
name: 'jsdoc/default-expressions/rules',
rules: {
...index.configs.examples[1].rules,
'@stylistic/quotes': [
'error', 'double',
],
'@stylistic/semi': [
'error', 'never',
],
'@typescript-eslint/no-unused-expressions': 0,
'chai-friendly/no-unused-expressions': 0,
'no-empty-function': 0,
'no-new': 0,
'no-unused-expressions': 0,
quotes: [
'error', 'double',
],
semi: [
'error', 'never',
],
strict: 0,
},
},
]);
index.configs['examples-and-default-expressions'] = /** @type {import('eslint').Linter.Config[]} */ ([
{
name: 'jsdoc/examples-and-default-expressions',
plugins: {
examples: getJsdocProcessorPlugin({
checkDefaults: true,
checkParams: true,
checkProperties: true,
}),
},
},
...index.configs.examples.map((config) => {
return {
...config,
plugins: {},
};
}),
...index.configs['default-expressions'].map((config) => {
return {
...config,
plugins: {},
};
}),
]);
index.configs['flat/recommended-mixed'] = [
{
...index.configs['flat/recommended-typescript-flavor'],
files: [
'**/*.{js,jsx,cjs,mjs}',
],
},
{
...index.configs['flat/recommended-typescript'],
files: [
'**/*.{ts,tsx,cts,mts}',
],
},
];
export default index;

View file

@ -0,0 +1,196 @@
/* eslint-disable perfectionist/sort-imports -- For auto-generate; Do not remove */
import {
merge,
} from 'object-deep-merge';
// BEGIN REPLACE
import index from './index-cjs.js';
import {
buildForbidRuleDefinition,
} from './buildForbidRuleDefinition.js';
import {
buildRejectOrPreferRuleDefinition,
} from './buildRejectOrPreferRuleDefinition.js';
// eslint-disable-next-line unicorn/prefer-export-from --- Reusing `index`
export default index;
// END REPLACE
/**
* @type {((
* cfg?: import('eslint').Linter.Config & {
* config?: `flat/${import('./index-cjs.js').ConfigGroups}${import('./index-cjs.js').ConfigVariants}${import('./index-cjs.js').ErrorLevelVariants}`,
* mergeSettings?: boolean,
* settings?: Partial<import('./iterateJsdoc.js').Settings>,
* rules?: {[key in keyof import('./rules.d.ts').Rules]?: import('eslint').Linter.RuleEntry<import('./rules.d.ts').Rules[key]>},
* extraRuleDefinitions?: {
* forbid?: {
* [contextName: string]: {
* description?: string,
* url?: string,
* contexts: (string|{
* message: string,
* context: string,
* comment: string
* })[]
* }
* },
* preferTypes?: {
* [typeName: string]: {
* description: string,
* overrideSettings: {
* [typeNodeName: string]: {
* message: string,
* replacement?: false|string,
* unifyParentAndChildTypeChecks?: boolean,
* }
* },
* url: string,
* }
* }
* }
* }
* ) => import('eslint').Linter.Config)}
*/
export const jsdoc = function (cfg) {
/** @type {import('eslint').Linter.Config} */
let outputConfig = {
plugins: {
jsdoc: index,
},
};
if (cfg) {
if (cfg.config) {
// @ts-expect-error Security check
if (cfg.config === '__proto__') {
throw new TypeError('Disallowed config value');
}
outputConfig = /** @type {import('eslint').Linter.Config} */ (index.configs[cfg.config]);
}
if (cfg.rules) {
outputConfig.rules = {
...outputConfig.rules,
...cfg.rules,
};
}
if (cfg.plugins) {
outputConfig.plugins = {
...outputConfig.plugins,
...cfg.plugins,
};
}
if (cfg.name) {
outputConfig.name = cfg.name;
}
if (cfg.basePath) {
outputConfig.basePath = cfg.basePath;
}
if (cfg.files) {
outputConfig.files = cfg.files;
}
if (cfg.ignores) {
outputConfig.ignores = cfg.ignores;
}
if (cfg.language) {
outputConfig.language = cfg.language;
}
if (cfg.languageOptions) {
outputConfig.languageOptions = cfg.languageOptions;
}
if (cfg.linterOptions) {
outputConfig.linterOptions = cfg.linterOptions;
}
if (cfg.processor) {
outputConfig.processor = cfg.processor;
}
if (cfg.extraRuleDefinitions) {
if (!outputConfig.plugins?.jsdoc?.rules) {
throw new Error('JSDoc plugin required for `extraRuleDefinitions`');
}
if (cfg.extraRuleDefinitions.forbid) {
for (const [
contextName,
{
contexts,
description,
url,
},
] of Object.entries(cfg.extraRuleDefinitions.forbid)) {
outputConfig.plugins.jsdoc.rules[`forbid-${contextName}`] =
buildForbidRuleDefinition({
contextName,
contexts,
description,
url,
});
}
}
if (cfg.extraRuleDefinitions.preferTypes) {
for (const [
typeName,
{
description,
overrideSettings,
url,
},
] of Object.entries(cfg.extraRuleDefinitions.preferTypes)) {
outputConfig.plugins.jsdoc.rules[`prefer-type-${typeName}`] =
buildRejectOrPreferRuleDefinition({
description,
overrideSettings,
typeName,
url,
});
}
}
}
}
outputConfig.settings = {
jsdoc: cfg?.mergeSettings === false ?
cfg.settings :
merge(
{},
cfg?.settings ?? {},
cfg?.config?.includes('recommended') ?
{
// We may need to drop these for "typescript" (non-"flavor") configs,
// if support is later added: https://www.typescriptlang.org/docs/handbook/jsdoc-supported-types.html
structuredTags: {
next: {
required: [
'type',
],
},
rejects: {
required: [
'type',
],
},
},
} :
{},
),
};
return outputConfig;
};
export {
getJsdocProcessorPlugin,
} from './getJsdocProcessorPlugin.js';

View file

@ -0,0 +1,3 @@
import items from './index-cjs.js';
export = items;

View file

@ -0,0 +1,905 @@
/* AUTO-GENERATED BY build SCRIPT */
/* eslint-disable perfectionist/sort-imports -- For auto-generate; Do not remove */
import {
merge,
} from 'object-deep-merge';
import {
buildForbidRuleDefinition,
} from './buildForbidRuleDefinition.js';
import {
buildRejectOrPreferRuleDefinition,
} from './buildRejectOrPreferRuleDefinition.js';
import {
getJsdocProcessorPlugin,
} from './getJsdocProcessorPlugin.js';
import checkAccess from './rules/checkAccess.js';
import checkAlignment from './rules/checkAlignment.js';
import checkExamples from './rules/checkExamples.js';
import checkIndentation from './rules/checkIndentation.js';
import checkLineAlignment from './rules/checkLineAlignment.js';
import checkParamNames from './rules/checkParamNames.js';
import checkPropertyNames from './rules/checkPropertyNames.js';
import checkSyntax from './rules/checkSyntax.js';
import checkTagNames from './rules/checkTagNames.js';
import checkTemplateNames from './rules/checkTemplateNames.js';
import checkTypes from './rules/checkTypes.js';
import checkValues from './rules/checkValues.js';
import convertToJsdocComments from './rules/convertToJsdocComments.js';
import emptyTags from './rules/emptyTags.js';
import escapeInlineTags from './rules/escapeInlineTags.js';
import implementsOnClasses from './rules/implementsOnClasses.js';
import importsAsDependencies from './rules/importsAsDependencies.js';
import informativeDocs from './rules/informativeDocs.js';
import linesBeforeBlock from './rules/linesBeforeBlock.js';
import matchDescription from './rules/matchDescription.js';
import matchName from './rules/matchName.js';
import multilineBlocks from './rules/multilineBlocks.js';
import noBadBlocks from './rules/noBadBlocks.js';
import noBlankBlockDescriptions from './rules/noBlankBlockDescriptions.js';
import noBlankBlocks from './rules/noBlankBlocks.js';
import noDefaults from './rules/noDefaults.js';
import noMissingSyntax from './rules/noMissingSyntax.js';
import noMultiAsterisks from './rules/noMultiAsterisks.js';
import noRestrictedSyntax from './rules/noRestrictedSyntax.js';
import noTypes from './rules/noTypes.js';
import noUndefinedTypes from './rules/noUndefinedTypes.js';
import preferImportTag from './rules/preferImportTag.js';
import requireAsteriskPrefix from './rules/requireAsteriskPrefix.js';
import requireDescription from './rules/requireDescription.js';
import requireDescriptionCompleteSentence from './rules/requireDescriptionCompleteSentence.js';
import requireExample from './rules/requireExample.js';
import requireFileOverview from './rules/requireFileOverview.js';
import requireHyphenBeforeParamDescription from './rules/requireHyphenBeforeParamDescription.js';
import requireJsdoc from './rules/requireJsdoc.js';
import requireParam from './rules/requireParam.js';
import requireParamDescription from './rules/requireParamDescription.js';
import requireParamName from './rules/requireParamName.js';
import requireParamType from './rules/requireParamType.js';
import requireProperty from './rules/requireProperty.js';
import requirePropertyDescription from './rules/requirePropertyDescription.js';
import requirePropertyName from './rules/requirePropertyName.js';
import requirePropertyType from './rules/requirePropertyType.js';
import requireRejects from './rules/requireRejects.js';
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 requireTags from './rules/requireTags.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';
import sortTags from './rules/sortTags.js';
import tagLines from './rules/tagLines.js';
import textEscaping from './rules/textEscaping.js';
import tsMethodSignatureStyle from './rules/tsMethodSignatureStyle.js';
import tsNoEmptyObjectType from './rules/tsNoEmptyObjectType.js';
import tsNoUnnecessaryTemplateExpression from './rules/tsNoUnnecessaryTemplateExpression.js';
import tsPreferFunctionType from './rules/tsPreferFunctionType.js';
import typeFormatting from './rules/typeFormatting.js';
import validTypes from './rules/validTypes.js';
/**
* @typedef {"recommended" | "stylistic" | "contents" | "logical" | "requirements"} ConfigGroups
* @typedef {"" | "-typescript" | "-typescript-flavor"} ConfigVariants
* @typedef {"" | "-error"} ErrorLevelVariants
* @type {import('eslint').ESLint.Plugin & {
* configs: Record<
* `flat/${ConfigGroups}${ConfigVariants}${ErrorLevelVariants}`,
* import('eslint').Linter.Config
* > &
* Record<
* "examples"|"default-expressions"|"examples-and-default-expressions",
* import('eslint').Linter.Config[]
* > &
* Record<"flat/recommended-mixed", import('eslint').Linter.Config[]>
* }}
*/
const index = {};
index.configs = {};
index.rules = {
'check-access': checkAccess,
'check-alignment': checkAlignment,
'check-examples': checkExamples,
'check-indentation': checkIndentation,
'check-line-alignment': checkLineAlignment,
'check-param-names': checkParamNames,
'check-property-names': checkPropertyNames,
'check-syntax': checkSyntax,
'check-tag-names': checkTagNames,
'check-template-names': checkTemplateNames,
'check-types': checkTypes,
'check-values': checkValues,
'convert-to-jsdoc-comments': convertToJsdocComments,
'empty-tags': emptyTags,
'escape-inline-tags': escapeInlineTags,
'implements-on-classes': implementsOnClasses,
'imports-as-dependencies': importsAsDependencies,
'informative-docs': informativeDocs,
'lines-before-block': linesBeforeBlock,
'match-description': matchDescription,
'match-name': matchName,
'multiline-blocks': multilineBlocks,
'no-bad-blocks': noBadBlocks,
'no-blank-block-descriptions': noBlankBlockDescriptions,
'no-blank-blocks': noBlankBlocks,
'no-defaults': noDefaults,
'no-missing-syntax': noMissingSyntax,
'no-multi-asterisks': noMultiAsterisks,
'no-restricted-syntax': noRestrictedSyntax,
'no-types': noTypes,
'no-undefined-types': noUndefinedTypes,
'prefer-import-tag': preferImportTag,
'reject-any-type': buildRejectOrPreferRuleDefinition({
description: 'Reports use of `any` or `*` type',
overrideSettings: {
'*': {
message: 'Prefer a more specific type to `*`',
replacement: false,
unifyParentAndChildTypeChecks: true,
},
any: {
message: 'Prefer a more specific type to `any`',
replacement: false,
unifyParentAndChildTypeChecks: true,
},
},
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/reject-any-type.md#repos-sticky-header',
}),
'reject-function-type': buildRejectOrPreferRuleDefinition({
description: 'Reports use of `Function` type',
overrideSettings: {
Function: {
message: 'Prefer a more specific type to `Function`',
replacement: false,
unifyParentAndChildTypeChecks: true,
},
},
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/reject-function-type.md#repos-sticky-header',
}),
'require-asterisk-prefix': requireAsteriskPrefix,
'require-description': requireDescription,
'require-description-complete-sentence': requireDescriptionCompleteSentence,
'require-example': requireExample,
'require-file-overview': requireFileOverview,
'require-hyphen-before-param-description': requireHyphenBeforeParamDescription,
'require-jsdoc': requireJsdoc,
'require-next-description': buildForbidRuleDefinition({
contexts: [
{
comment: 'JsdocBlock:has(JsdocTag[tag=next]:not([name!=""]):not([description!=""]))',
context: 'any',
message: '@next should have a description',
},
],
description: 'Requires a description for `@next` tags',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/require-next-description.md#repos-sticky-header',
}),
'require-next-type': buildForbidRuleDefinition({
contexts: [
{
comment: 'JsdocBlock:has(JsdocTag[tag=next]:not([parsedType.type]))',
context: 'any',
message: '@next should have a type',
},
],
description: 'Requires a type for `@next` tags',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/require-next-type.md#repos-sticky-header',
}),
'require-param': requireParam,
'require-param-description': requireParamDescription,
'require-param-name': requireParamName,
'require-param-type': requireParamType,
'require-property': requireProperty,
'require-property-description': requirePropertyDescription,
'require-property-name': requirePropertyName,
'require-property-type': requirePropertyType,
'require-rejects': requireRejects,
'require-returns': requireReturns,
'require-returns-check': requireReturnsCheck,
'require-returns-description': requireReturnsDescription,
'require-returns-type': requireReturnsType,
'require-tags': requireTags,
'require-template': requireTemplate,
'require-template-description': buildForbidRuleDefinition({
contexts: [
{
comment: 'JsdocBlock:has(JsdocTag[tag=template]:not([description!=""]))',
context: 'any',
message: '@template should have a description',
},
],
description: 'Requires a description for `@template` tags',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/require-template-description.md#repos-sticky-header',
}),
'require-throws': requireThrows,
'require-throws-description': buildForbidRuleDefinition({
contexts: [
{
comment: 'JsdocBlock:has(JsdocTag[tag=/^(?:throws|exception)$/]:not([description!=""]))',
context: 'any',
message: '@throws should have a description',
},
],
description: 'Requires a description for `@throws` tags',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/require-throws-description.md#repos-sticky-header',
}),
'require-throws-type': buildForbidRuleDefinition({
contexts: [
{
comment: 'JsdocBlock:has(JsdocTag[tag=/^(?:throws|exception)$/]:not([parsedType.type]))',
context: 'any',
message: '@throws should have a type',
},
],
description: 'Requires a type for `@throws` tags',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/require-throws-type.md#repos-sticky-header',
}),
'require-yields': requireYields,
'require-yields-check': requireYieldsCheck,
'require-yields-description': buildForbidRuleDefinition({
contexts: [
{
comment: 'JsdocBlock:has(JsdocTag[tag=/^yields?$/]:not([name!=""]):not([description!=""]))',
context: 'any',
message: '@yields should have a description',
},
],
description: 'Requires a description for `@yields` tags',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/require-yields-description.md#repos-sticky-header',
}),
'require-yields-type': buildForbidRuleDefinition({
contexts: [
{
comment: 'JsdocBlock:has(JsdocTag[tag=/^yields?$/]:not([parsedType.type]))',
context: 'any',
message: '@yields should have a type',
},
],
description: 'Requires a type for `@yields` tags',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/require-yields-type.md#repos-sticky-header',
}),
'sort-tags': sortTags,
'tag-lines': tagLines,
'text-escaping': textEscaping,
'ts-method-signature-style': tsMethodSignatureStyle,
'ts-no-empty-object-type': tsNoEmptyObjectType,
'ts-no-unnecessary-template-expression': tsNoUnnecessaryTemplateExpression,
'ts-prefer-function-type': tsPreferFunctionType,
'type-formatting': typeFormatting,
'valid-types': validTypes,
};
/**
* @param {"warn"|"error"} warnOrError
* @param {string} [flatName]
* @returns {import('eslint').Linter.Config}
*/
const createRecommendedRuleset = (warnOrError, flatName) => {
return {
...(flatName ? {
name: 'jsdoc/' + flatName,
} : {}),
// @ts-expect-error ESLint 8 plugins
plugins:
flatName ? {
jsdoc: index,
} : [
'jsdoc',
],
rules: {
'jsdoc/check-access': warnOrError,
'jsdoc/check-alignment': warnOrError,
'jsdoc/check-examples': 'off',
'jsdoc/check-indentation': 'off',
'jsdoc/check-line-alignment': 'off',
'jsdoc/check-param-names': warnOrError,
'jsdoc/check-property-names': warnOrError,
'jsdoc/check-syntax': 'off',
'jsdoc/check-tag-names': warnOrError,
'jsdoc/check-template-names': 'off',
'jsdoc/check-types': warnOrError,
'jsdoc/check-values': warnOrError,
'jsdoc/convert-to-jsdoc-comments': 'off',
'jsdoc/empty-tags': warnOrError,
'jsdoc/escape-inline-tags': warnOrError,
'jsdoc/implements-on-classes': warnOrError,
'jsdoc/imports-as-dependencies': 'off',
'jsdoc/informative-docs': 'off',
'jsdoc/lines-before-block': 'off',
'jsdoc/match-description': 'off',
'jsdoc/match-name': 'off',
'jsdoc/multiline-blocks': warnOrError,
'jsdoc/no-bad-blocks': 'off',
'jsdoc/no-blank-block-descriptions': 'off',
'jsdoc/no-blank-blocks': 'off',
'jsdoc/no-defaults': warnOrError,
'jsdoc/no-missing-syntax': 'off',
'jsdoc/no-multi-asterisks': warnOrError,
'jsdoc/no-restricted-syntax': 'off',
'jsdoc/no-types': 'off',
'jsdoc/no-undefined-types': warnOrError,
'jsdoc/prefer-import-tag': 'off',
'jsdoc/reject-any-type': warnOrError,
'jsdoc/reject-function-type': warnOrError,
'jsdoc/require-asterisk-prefix': 'off',
'jsdoc/require-description': 'off',
'jsdoc/require-description-complete-sentence': 'off',
'jsdoc/require-example': 'off',
'jsdoc/require-file-overview': 'off',
'jsdoc/require-hyphen-before-param-description': 'off',
'jsdoc/require-jsdoc': warnOrError,
'jsdoc/require-next-description': 'off',
'jsdoc/require-next-type': warnOrError,
'jsdoc/require-param': warnOrError,
'jsdoc/require-param-description': warnOrError,
'jsdoc/require-param-name': warnOrError,
'jsdoc/require-param-type': warnOrError,
'jsdoc/require-property': warnOrError,
'jsdoc/require-property-description': warnOrError,
'jsdoc/require-property-name': warnOrError,
'jsdoc/require-property-type': warnOrError,
'jsdoc/require-rejects': 'off',
'jsdoc/require-returns': warnOrError,
'jsdoc/require-returns-check': warnOrError,
'jsdoc/require-returns-description': warnOrError,
'jsdoc/require-returns-type': warnOrError,
'jsdoc/require-tags': 'off',
'jsdoc/require-template': 'off',
'jsdoc/require-template-description': 'off',
'jsdoc/require-throws': 'off',
'jsdoc/require-throws-description': 'off',
'jsdoc/require-throws-type': warnOrError,
'jsdoc/require-yields': warnOrError,
'jsdoc/require-yields-check': warnOrError,
'jsdoc/require-yields-description': 'off',
'jsdoc/require-yields-type': warnOrError,
'jsdoc/sort-tags': 'off',
'jsdoc/tag-lines': warnOrError,
'jsdoc/text-escaping': 'off',
'jsdoc/ts-method-signature-style': 'off',
'jsdoc/ts-no-empty-object-type': warnOrError,
'jsdoc/ts-no-unnecessary-template-expression': 'off',
'jsdoc/ts-prefer-function-type': 'off',
'jsdoc/type-formatting': 'off',
'jsdoc/valid-types': warnOrError,
},
};
};
/**
* @param {"warn"|"error"} warnOrError
* @param {string} [flatName]
* @returns {import('eslint').Linter.Config}
*/
const createRecommendedTypeScriptRuleset = (warnOrError, flatName) => {
const ruleset = createRecommendedRuleset(warnOrError, flatName);
return {
...ruleset,
rules: {
...ruleset.rules,
/* eslint-disable @stylistic/indent -- Extra indent to avoid use by auto-rule-editing */
'jsdoc/check-tag-names': [
warnOrError, {
typed: true,
},
],
'jsdoc/no-types': warnOrError,
'jsdoc/no-undefined-types': 'off',
'jsdoc/require-param-type': 'off',
'jsdoc/require-property-type': 'off',
'jsdoc/require-returns-type': 'off',
/* eslint-enable @stylistic/indent */
},
};
};
/**
* @param {"warn"|"error"} warnOrError
* @param {string} [flatName]
* @returns {import('eslint').Linter.Config}
*/
const createRecommendedTypeScriptFlavorRuleset = (warnOrError, flatName) => {
const ruleset = createRecommendedRuleset(warnOrError, flatName);
return {
...ruleset,
rules: {
...ruleset.rules,
/* eslint-disable @stylistic/indent -- Extra indent to avoid use by auto-rule-editing */
'jsdoc/no-undefined-types': 'off',
/* eslint-enable @stylistic/indent */
},
};
};
/**
* @param {(string | unknown[])[]} ruleNames
*/
const createStandaloneRulesetFactory = (ruleNames) => {
/**
* @param {"warn"|"error"} warnOrError
* @param {string} [flatName]
* @returns {import('eslint').Linter.Config}
*/
return (warnOrError, flatName) => {
return {
name: 'jsdoc/' + flatName,
plugins: {
jsdoc: index,
},
rules: Object.fromEntries(
ruleNames.map(
(ruleName) => {
return (typeof ruleName === 'string' ?
[
ruleName, warnOrError,
] :
[
ruleName[0], [
warnOrError, ...ruleName.slice(1),
],
]);
},
),
),
};
};
};
const contentsRules = [
'jsdoc/informative-docs',
'jsdoc/match-description',
'jsdoc/no-blank-block-descriptions',
'jsdoc/no-blank-blocks',
[
'jsdoc/text-escaping', {
escapeHTML: true,
},
],
];
const createContentsTypescriptRuleset = createStandaloneRulesetFactory(contentsRules);
const createContentsTypescriptFlavorRuleset = createStandaloneRulesetFactory(contentsRules);
const logicalRules = [
'jsdoc/check-access',
'jsdoc/check-param-names',
'jsdoc/check-property-names',
'jsdoc/check-syntax',
'jsdoc/check-tag-names',
'jsdoc/check-template-names',
'jsdoc/check-types',
'jsdoc/check-values',
'jsdoc/empty-tags',
'jsdoc/escape-inline-tags',
'jsdoc/implements-on-classes',
'jsdoc/require-returns-check',
'jsdoc/require-yields-check',
'jsdoc/no-bad-blocks',
'jsdoc/no-defaults',
'jsdoc/no-types',
'jsdoc/no-undefined-types',
'jsdoc/valid-types',
];
const createLogicalTypescriptRuleset = createStandaloneRulesetFactory(logicalRules);
const createLogicalTypescriptFlavorRuleset = createStandaloneRulesetFactory(logicalRules);
const requirementsRules = [
'jsdoc/require-example',
'jsdoc/require-jsdoc',
'jsdoc/require-next-type',
'jsdoc/require-param',
'jsdoc/require-param-description',
'jsdoc/require-param-name',
'jsdoc/require-property',
'jsdoc/require-property-description',
'jsdoc/require-property-name',
'jsdoc/require-returns',
'jsdoc/require-returns-description',
'jsdoc/require-throws-type',
'jsdoc/require-yields',
'jsdoc/require-yields-type',
];
const createRequirementsTypeScriptRuleset = createStandaloneRulesetFactory(requirementsRules);
const createRequirementsTypeScriptFlavorRuleset = createStandaloneRulesetFactory([
...requirementsRules,
'jsdoc/require-param-type',
'jsdoc/require-property-type',
'jsdoc/require-returns-type',
'jsdoc/require-template',
]);
const stylisticRules = [
'jsdoc/check-alignment',
'jsdoc/check-line-alignment',
'jsdoc/lines-before-block',
'jsdoc/multiline-blocks',
'jsdoc/no-multi-asterisks',
'jsdoc/require-asterisk-prefix',
[
'jsdoc/require-hyphen-before-param-description', 'never',
],
'jsdoc/tag-lines',
];
const createStylisticTypeScriptRuleset = createStandaloneRulesetFactory(stylisticRules);
const createStylisticTypeScriptFlavorRuleset = createStandaloneRulesetFactory(stylisticRules);
/* c8 ignore next 3 -- TS */
if (!index.configs) {
throw new Error('TypeScript guard');
}
index.configs.recommended = createRecommendedRuleset('warn');
index.configs['recommended-error'] = createRecommendedRuleset('error');
index.configs['recommended-typescript'] = createRecommendedTypeScriptRuleset('warn');
index.configs['recommended-typescript-error'] = createRecommendedTypeScriptRuleset('error');
index.configs['recommended-typescript-flavor'] = createRecommendedTypeScriptFlavorRuleset('warn');
index.configs['recommended-typescript-flavor-error'] = createRecommendedTypeScriptFlavorRuleset('error');
index.configs['flat/recommended'] = createRecommendedRuleset('warn', 'flat/recommended');
index.configs['flat/recommended-error'] = createRecommendedRuleset('error', 'flat/recommended-error');
index.configs['flat/recommended-typescript'] = createRecommendedTypeScriptRuleset('warn', 'flat/recommended-typescript');
index.configs['flat/recommended-typescript-error'] = createRecommendedTypeScriptRuleset('error', 'flat/recommended-typescript-error');
index.configs['flat/recommended-typescript-flavor'] = createRecommendedTypeScriptFlavorRuleset('warn', 'flat/recommended-typescript-flavor');
index.configs['flat/recommended-typescript-flavor-error'] = createRecommendedTypeScriptFlavorRuleset('error', 'flat/recommended-typescript-flavor-error');
index.configs['flat/contents-typescript'] = createContentsTypescriptRuleset('warn', 'flat/contents-typescript');
index.configs['flat/contents-typescript-error'] = createContentsTypescriptRuleset('error', 'flat/contents-typescript-error');
index.configs['flat/contents-typescript-flavor'] = createContentsTypescriptFlavorRuleset('warn', 'flat/contents-typescript-flavor');
index.configs['flat/contents-typescript-flavor-error'] = createContentsTypescriptFlavorRuleset('error', 'flat/contents-typescript-error-flavor');
index.configs['flat/logical-typescript'] = createLogicalTypescriptRuleset('warn', 'flat/logical-typescript');
index.configs['flat/logical-typescript-error'] = createLogicalTypescriptRuleset('error', 'flat/logical-typescript-error');
index.configs['flat/logical-typescript-flavor'] = createLogicalTypescriptFlavorRuleset('warn', 'flat/logical-typescript-flavor');
index.configs['flat/logical-typescript-flavor-error'] = createLogicalTypescriptFlavorRuleset('error', 'flat/logical-typescript-error-flavor');
index.configs['flat/requirements-typescript'] = createRequirementsTypeScriptRuleset('warn', 'flat/requirements-typescript');
index.configs['flat/requirements-typescript-error'] = createRequirementsTypeScriptRuleset('error', 'flat/requirements-typescript-error');
index.configs['flat/requirements-typescript-flavor'] = createRequirementsTypeScriptFlavorRuleset('warn', 'flat/requirements-typescript-flavor');
index.configs['flat/requirements-typescript-flavor-error'] = createRequirementsTypeScriptFlavorRuleset('error', 'flat/requirements-typescript-error-flavor');
index.configs['flat/stylistic-typescript'] = createStylisticTypeScriptRuleset('warn', 'flat/stylistic-typescript');
index.configs['flat/stylistic-typescript-error'] = createStylisticTypeScriptRuleset('error', 'flat/stylistic-typescript-error');
index.configs['flat/stylistic-typescript-flavor'] = createStylisticTypeScriptFlavorRuleset('warn', 'flat/stylistic-typescript-flavor');
index.configs['flat/stylistic-typescript-flavor-error'] = createStylisticTypeScriptFlavorRuleset('error', 'flat/stylistic-typescript-error-flavor');
index.configs.examples = /** @type {import('eslint').Linter.Config[]} */ ([
{
files: [
'**/*.js',
],
name: 'jsdoc/examples/processor',
plugins: {
examples: getJsdocProcessorPlugin(),
},
processor: 'examples/examples',
},
{
files: [
'**/*.md/*.js',
],
name: 'jsdoc/examples/rules',
rules: {
// "always" newline rule at end unlikely in sample code
'@stylistic/eol-last': 0,
// Often wish to start `@example` code after newline; also may use
// empty lines for spacing
'@stylistic/no-multiple-empty-lines': 0,
// Can generally look nicer to pad a little even if code imposes more stringency
'@stylistic/padded-blocks': 0,
'@typescript-eslint/no-unused-vars': 0,
// "always" newline rule at end unlikely in sample code
'eol-last': 0,
// Wouldn't generally expect example paths to resolve relative to JS file
'import/no-unresolved': 0,
// Snippets likely too short to always include import/export info
'import/unambiguous': 0,
'jsdoc/require-file-overview': 0,
// The end of a multiline comment would end the comment the example is in.
'jsdoc/require-jsdoc': 0,
// See import/no-unresolved
'n/no-missing-import': 0,
'n/no-missing-require': 0,
// Unlikely to have inadvertent debugging within examples
'no-console': 0,
// Often wish to start `@example` code after newline; also may use
// empty lines for spacing
'no-multiple-empty-lines': 0,
// Many variables in examples will be `undefined`
'no-undef': 0,
// Common to define variables for clarity without always using them
'no-unused-vars': 0,
// See import/no-unresolved
'node/no-missing-import': 0,
'node/no-missing-require': 0,
// Can generally look nicer to pad a little even if code imposes more stringency
'padded-blocks': 0,
},
},
]);
index.configs['default-expressions'] = /** @type {import('eslint').Linter.Config[]} */ ([
{
files: [
'**/*.js',
],
name: 'jsdoc/default-expressions/processor',
plugins: {
examples: getJsdocProcessorPlugin({
checkDefaults: true,
checkParams: true,
checkProperties: true,
}),
},
processor: 'examples/examples',
},
{
files: [
'**/*.jsdoc-defaults', '**/*.jsdoc-params', '**/*.jsdoc-properties',
],
name: 'jsdoc/default-expressions/rules',
rules: {
...index.configs.examples[1].rules,
'@stylistic/quotes': [
'error', 'double',
],
'@stylistic/semi': [
'error', 'never',
],
'@typescript-eslint/no-unused-expressions': 0,
'chai-friendly/no-unused-expressions': 0,
'no-empty-function': 0,
'no-new': 0,
'no-unused-expressions': 0,
quotes: [
'error', 'double',
],
semi: [
'error', 'never',
],
strict: 0,
},
},
]);
index.configs['examples-and-default-expressions'] = /** @type {import('eslint').Linter.Config[]} */ ([
{
name: 'jsdoc/examples-and-default-expressions',
plugins: {
examples: getJsdocProcessorPlugin({
checkDefaults: true,
checkParams: true,
checkProperties: true,
}),
},
},
...index.configs.examples.map((config) => {
return {
...config,
plugins: {},
};
}),
...index.configs['default-expressions'].map((config) => {
return {
...config,
plugins: {},
};
}),
]);
index.configs['flat/recommended-mixed'] = [
{
...index.configs['flat/recommended-typescript-flavor'],
files: [
'**/*.{js,jsx,cjs,mjs}',
],
},
{
...index.configs['flat/recommended-typescript'],
files: [
'**/*.{ts,tsx,cts,mts}',
],
},
];
export default index;
/**
* @type {((
* cfg?: import('eslint').Linter.Config & {
* config?: `flat/${ConfigGroups}${ConfigVariants}${ErrorLevelVariants}`,
* mergeSettings?: boolean,
* settings?: Partial<import('./iterateJsdoc.js').Settings>,
* rules?: {[key in keyof import('./rules.d.ts').Rules]?: import('eslint').Linter.RuleEntry<import('./rules.d.ts').Rules[key]>},
* extraRuleDefinitions?: {
* forbid?: {
* [contextName: string]: {
* description?: string,
* url?: string,
* contexts: (string|{
* message: string,
* context: string,
* comment: string
* })[]
* }
* },
* preferTypes?: {
* [typeName: string]: {
* description: string,
* overrideSettings: {
* [typeNodeName: string]: {
* message: string,
* replacement?: false|string,
* unifyParentAndChildTypeChecks?: boolean,
* }
* },
* url: string,
* }
* }
* }
* }
* ) => import('eslint').Linter.Config)}
*/
export const jsdoc = function (cfg) {
/** @type {import('eslint').Linter.Config} */
let outputConfig = {
plugins: {
jsdoc: index,
},
};
if (cfg) {
if (cfg.config) {
// @ts-expect-error Security check
if (cfg.config === '__proto__') {
throw new TypeError('Disallowed config value');
}
outputConfig = /** @type {import('eslint').Linter.Config} */ (index.configs[cfg.config]);
}
if (cfg.rules) {
outputConfig.rules = {
...outputConfig.rules,
...cfg.rules,
};
}
if (cfg.plugins) {
outputConfig.plugins = {
...outputConfig.plugins,
...cfg.plugins,
};
}
if (cfg.name) {
outputConfig.name = cfg.name;
}
if (cfg.basePath) {
outputConfig.basePath = cfg.basePath;
}
if (cfg.files) {
outputConfig.files = cfg.files;
}
if (cfg.ignores) {
outputConfig.ignores = cfg.ignores;
}
if (cfg.language) {
outputConfig.language = cfg.language;
}
if (cfg.languageOptions) {
outputConfig.languageOptions = cfg.languageOptions;
}
if (cfg.linterOptions) {
outputConfig.linterOptions = cfg.linterOptions;
}
if (cfg.processor) {
outputConfig.processor = cfg.processor;
}
if (cfg.extraRuleDefinitions) {
if (!outputConfig.plugins?.jsdoc?.rules) {
throw new Error('JSDoc plugin required for `extraRuleDefinitions`');
}
if (cfg.extraRuleDefinitions.forbid) {
for (const [
contextName,
{
contexts,
description,
url,
},
] of Object.entries(cfg.extraRuleDefinitions.forbid)) {
outputConfig.plugins.jsdoc.rules[`forbid-${contextName}`] =
buildForbidRuleDefinition({
contextName,
contexts,
description,
url,
});
}
}
if (cfg.extraRuleDefinitions.preferTypes) {
for (const [
typeName,
{
description,
overrideSettings,
url,
},
] of Object.entries(cfg.extraRuleDefinitions.preferTypes)) {
outputConfig.plugins.jsdoc.rules[`prefer-type-${typeName}`] =
buildRejectOrPreferRuleDefinition({
description,
overrideSettings,
typeName,
url,
});
}
}
}
}
outputConfig.settings = {
jsdoc: cfg?.mergeSettings === false ?
cfg.settings :
merge(
{},
cfg?.settings ?? {},
cfg?.config?.includes('recommended') ?
{
// We may need to drop these for "typescript" (non-"flavor") configs,
// if support is later added: https://www.typescriptlang.org/docs/handbook/jsdoc-supported-types.html
structuredTags: {
next: {
required: [
'type',
],
},
rejects: {
required: [
'type',
],
},
},
} :
{},
),
};
return outputConfig;
};
export {
getJsdocProcessorPlugin,
} from './getJsdocProcessorPlugin.js';

View file

@ -0,0 +1,7 @@
import iterateJsdoc, {getSettings, parseComment} from './iterateJsdoc.js';
export = {
default: iterateJsdoc as typeof iterateJsdoc,
getSettings: getSettings as typeof getSettings,
parseComment: parseComment as typeof parseComment
};

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,45 @@
import iterateJsdoc from '../iterateJsdoc.js';
const accessLevels = [
'package', 'private', 'protected', 'public',
];
export default iterateJsdoc(({
report,
utils,
}) => {
utils.forEachPreferredTag('access', (jsdocParameter, targetTagName) => {
const desc = jsdocParameter.name + ' ' + jsdocParameter.description;
if (!accessLevels.includes(desc.trim())) {
report(
`Missing valid JSDoc @${targetTagName} level.`,
null,
jsdocParameter,
);
}
});
const accessLength = utils.getTags('access').length;
const individualTagLength = utils.getPresentTags(accessLevels).length;
if (accessLength && individualTagLength) {
report(
'The @access tag may not be used with specific access-control tags (@package, @private, @protected, or @public).',
);
}
if (accessLength > 1 || individualTagLength > 1) {
report(
'At most one access-control tag may be present on a JSDoc block.',
);
}
}, {
checkPrivate: true,
iterateAllJsdocs: true,
meta: {
docs: {
description: 'Checks that `@access` tags have a valid value.',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/check-access.md#repos-sticky-header',
},
type: 'suggestion',
},
});

View file

@ -0,0 +1,82 @@
import iterateJsdoc from '../iterateJsdoc.js';
export default iterateJsdoc(({
context,
indent,
jsdocNode,
report,
sourceCode,
}) => {
const {
innerIndent = 1,
} = context.options[0] || {};
// `indent` is whitespace from line 1 (`/**`), so slice and account for "/".
const indentLevel = indent.length + innerIndent;
const sourceLines = sourceCode.getText(jsdocNode).split('\n')
.slice(1)
.map((line, number) => {
return {
line: line.split('*')[0],
number,
};
})
.filter(({
line,
}) => {
return !line.trimStart().length;
});
/** @type {import('eslint').Rule.ReportFixer} */
const fix = (fixer) => {
const replacement = sourceCode.getText(jsdocNode).split('\n')
.map((line, index) => {
// Ignore the first line and all lines not starting with `*`
const ignored = !index || line.split('*')[0].trimStart().length;
return ignored ? line : `${indent}${''.padStart(innerIndent, ' ')}${line.trimStart()}`;
})
.join('\n');
return fixer.replaceText(jsdocNode, replacement);
};
sourceLines.some(({
line,
number,
}) => {
if (line.length !== indentLevel) {
report('Expected JSDoc block to be aligned.', fix, {
line: number + 1,
});
return true;
}
return false;
});
}, {
iterateAllJsdocs: true,
meta: {
docs: {
description: 'Reports invalid alignment of JSDoc block asterisks.',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/check-alignment.md#repos-sticky-header',
},
fixable: 'code',
schema: [
{
additionalProperties: false,
properties: {
innerIndent: {
default: 1,
description: `Set to 0 if you wish to avoid the normal requirement for an inner indentation of
one space. Defaults to 1 (one space of normal inner indentation).`,
type: 'integer',
},
},
type: 'object',
},
],
type: 'layout',
},
});

View file

@ -0,0 +1,612 @@
import iterateJsdoc from '../iterateJsdoc.js';
import eslint, {
ESLint,
} from 'eslint';
import semver from 'semver';
const {
// @ts-expect-error Older ESLint
CLIEngine,
} = eslint;
const zeroBasedLineIndexAdjust = -1;
const likelyNestedJSDocIndentSpace = 1;
const preTagSpaceLength = 1;
// If a space is present, we should ignore it
const firstLinePrefixLength = preTagSpaceLength;
const hasCaptionRegex = /^\s*<caption>([\s\S]*?)<\/caption>/v;
/**
* @param {string} str
* @returns {string}
*/
const escapeStringRegexp = (str) => {
return str.replaceAll(/[.*+?^$\{\}\(\)\|\[\]\\]/gv, '\\$&');
};
/**
* @param {string} str
* @param {string} ch
* @returns {import('../iterateJsdoc.js').Integer}
*/
const countChars = (str, ch) => {
return (str.match(new RegExp(escapeStringRegexp(ch), 'gv')) || []).length;
};
/** @type {import('eslint').Linter.RulesRecord} */
const defaultMdRules = {
// "always" newline rule at end unlikely in sample code
'@stylistic/eol-last': 0,
// Often wish to start `@example` code after newline; also may use
// empty lines for spacing
'@stylistic/no-multiple-empty-lines': 0,
// Can generally look nicer to pad a little even if code imposes more stringency
'@stylistic/padded-blocks': 0,
'@typescript-eslint/no-unused-vars': 0,
// "always" newline rule at end unlikely in sample code
'eol-last': 0,
// Wouldn't generally expect example paths to resolve relative to JS file
'import/no-unresolved': 0,
// Snippets likely too short to always include import/export info
'import/unambiguous': 0,
'jsdoc/require-file-overview': 0,
// The end of a multiline comment would end the comment the example is in.
'jsdoc/require-jsdoc': 0,
// See import/no-unresolved
'n/no-missing-import': 0,
'n/no-missing-require': 0,
// Unlikely to have inadvertent debugging within examples
'no-console': 0,
// Often wish to start `@example` code after newline; also may use
// empty lines for spacing
'no-multiple-empty-lines': 0,
// Many variables in examples will be `undefined`
'no-undef': 0,
// Common to define variables for clarity without always using them
'no-unused-vars': 0,
// See import/no-unresolved
'node/no-missing-import': 0,
'node/no-missing-require': 0,
// Can generally look nicer to pad a little even if code imposes more stringency
'padded-blocks': 0,
};
/** @type {import('eslint').Linter.RulesRecord} */
const defaultExpressionRules = {
...defaultMdRules,
'@stylistic/quotes': [
'error', 'double',
],
'@stylistic/semi': [
'error', 'never',
],
'@typescript-eslint/no-unused-expressions': 'off',
'chai-friendly/no-unused-expressions': 'off',
'no-empty-function': 'off',
'no-new': 'off',
'no-unused-expressions': 'off',
quotes: [
'error', 'double',
],
semi: [
'error', 'never',
],
strict: 'off',
};
/**
* @param {string} text
* @returns {[
* import('../iterateJsdoc.js').Integer,
* import('../iterateJsdoc.js').Integer
* ]}
*/
const getLinesCols = (text) => {
const matchLines = countChars(text, '\n');
const colDelta = matchLines ?
text.slice(text.lastIndexOf('\n') + 1).length :
text.length;
return [
matchLines, colDelta,
];
};
export default iterateJsdoc(({
context,
globalState,
report,
utils,
}) => {
if (semver.gte(ESLint.version, '8.0.0')) {
report(
'This rule does not work for ESLint 8+; you should disable this rule and use' +
'the processor mentioned in the docs.',
null,
{
column: 1,
line: 1,
},
);
return;
}
if (!globalState.has('checkExamples-matchingFileName')) {
globalState.set('checkExamples-matchingFileName', new Map());
}
const matchingFileNameMap = /** @type {Map<string, string>} */ (
globalState.get('checkExamples-matchingFileName')
);
const options = context.options[0] || {};
let {
exampleCodeRegex = null,
rejectExampleCodeRegex = null,
} = options;
const {
allowInlineConfig = true,
baseConfig = {},
captionRequired = false,
checkDefaults = false,
checkEslintrc = true,
checkParams = false,
checkProperties = false,
configFile,
matchingFileName = null,
matchingFileNameDefaults = null,
matchingFileNameParams = null,
matchingFileNameProperties = null,
noDefaultExampleRules = false,
paddedIndent = 0,
reportUnusedDisableDirectives = true,
} = options;
// Make this configurable?
/**
* @type {never[]}
*/
const rulePaths = [];
const mdRules = noDefaultExampleRules ? undefined : defaultMdRules;
const expressionRules = noDefaultExampleRules ? undefined : defaultExpressionRules;
if (exampleCodeRegex) {
exampleCodeRegex = utils.getRegexFromString(exampleCodeRegex);
}
if (rejectExampleCodeRegex) {
rejectExampleCodeRegex = utils.getRegexFromString(rejectExampleCodeRegex);
}
/**
* @param {{
* filename: string,
* defaultFileName: string|undefined,
* source: string,
* targetTagName: string,
* rules?: import('eslint').Linter.RulesRecord|undefined,
* lines?: import('../iterateJsdoc.js').Integer,
* cols?: import('../iterateJsdoc.js').Integer,
* skipInit?: boolean,
* sources?: {
* nonJSPrefacingCols: import('../iterateJsdoc.js').Integer,
* nonJSPrefacingLines: import('../iterateJsdoc.js').Integer,
* string: string,
* }[],
* tag?: import('comment-parser').Spec & {
* line?: import('../iterateJsdoc.js').Integer,
* }|{
* line: import('../iterateJsdoc.js').Integer,
* }
* }} cfg
*/
const checkSource = ({
cols = 0,
defaultFileName,
filename,
lines = 0,
rules = expressionRules,
skipInit,
source,
sources = [],
tag = {
line: 0,
},
targetTagName,
}) => {
if (!skipInit) {
sources.push({
nonJSPrefacingCols: cols,
nonJSPrefacingLines: lines,
string: source,
});
}
/**
* @param {{
* nonJSPrefacingCols: import('../iterateJsdoc.js').Integer,
* nonJSPrefacingLines: import('../iterateJsdoc.js').Integer,
* string: string
* }} cfg
*/
const checkRules = function ({
nonJSPrefacingCols,
nonJSPrefacingLines,
string,
}) {
const cliConfig = {
allowInlineConfig,
baseConfig,
configFile,
reportUnusedDisableDirectives,
rulePaths,
rules,
useEslintrc: checkEslintrc,
};
const cliConfigStr = JSON.stringify(cliConfig);
const src = paddedIndent ?
string.replaceAll(new RegExp(`(^|\n) {${paddedIndent}}(?!$)`, 'gv'), '\n') :
string;
// Programmatic ESLint API: https://eslint.org/docs/developer-guide/nodejs-api
const fileNameMapKey = filename ?
'a' + cliConfigStr + filename :
'b' + cliConfigStr + defaultFileName;
const file = filename || defaultFileName;
let cliFile;
if (matchingFileNameMap.has(fileNameMapKey)) {
cliFile = matchingFileNameMap.get(fileNameMapKey);
} else {
const cli = new CLIEngine(cliConfig);
let config;
if (filename || checkEslintrc) {
config = cli.getConfigForFile(file);
}
// We need a new instance to ensure that the rules that may only
// be available to `file` (if it has its own `.eslintrc`),
// will be defined.
cliFile = new CLIEngine({
allowInlineConfig,
baseConfig: {
...baseConfig,
...config,
},
configFile,
reportUnusedDisableDirectives,
rulePaths,
rules,
useEslintrc: false,
});
matchingFileNameMap.set(fileNameMapKey, cliFile);
}
const {
results: [
{
messages,
},
],
} = cliFile.executeOnText(src);
if (!('line' in tag)) {
tag.line = tag.source[0].number;
}
// NOTE: `tag.line` can be 0 if of form `/** @tag ... */`
const codeStartLine = /**
* @type {import('comment-parser').Spec & {
* line: import('../iterateJsdoc.js').Integer,
* }}
*/ (tag).line + nonJSPrefacingLines;
const codeStartCol = likelyNestedJSDocIndentSpace;
for (const {
column,
line,
message,
ruleId,
severity,
} of messages) {
const startLine = codeStartLine + line + zeroBasedLineIndexAdjust;
const startCol = codeStartCol + (
// This might not work for line 0, but line 0 is unlikely for examples
line <= 1 ? nonJSPrefacingCols + firstLinePrefixLength : preTagSpaceLength
) + column;
report(
'@' + targetTagName + ' ' + (severity === 2 ? 'error' : 'warning') +
(ruleId ? ' (' + ruleId + ')' : '') + ': ' +
message,
null,
{
column: startCol,
line: startLine,
},
);
}
};
for (const targetSource of sources) {
checkRules(targetSource);
}
};
/**
*
* @param {string} filename
* @param {string} [ext] Since `eslint-plugin-markdown` v2, and
* ESLint 7, this is the default which other JS-fenced rules will used.
* Formerly "md" was the default.
* @returns {{defaultFileName: string|undefined, filename: string}}
*/
const getFilenameInfo = (filename, ext = 'md/*.js') => {
let defaultFileName;
if (!filename) {
const jsFileName = context.getFilename();
if (typeof jsFileName === 'string' && jsFileName.includes('.')) {
defaultFileName = jsFileName.replace(/\.[^.]*$/v, `.${ext}`);
} else {
defaultFileName = `dummy.${ext}`;
}
}
return {
defaultFileName,
filename,
};
};
if (checkDefaults) {
const filenameInfo = getFilenameInfo(matchingFileNameDefaults, 'jsdoc-defaults');
utils.forEachPreferredTag('default', (tag, targetTagName) => {
if (!tag.description.trim()) {
return;
}
checkSource({
source: `(${utils.getTagDescription(tag)})`,
targetTagName,
...filenameInfo,
});
});
}
if (checkParams) {
const filenameInfo = getFilenameInfo(matchingFileNameParams, 'jsdoc-params');
utils.forEachPreferredTag('param', (tag, targetTagName) => {
if (!tag.default || !tag.default.trim()) {
return;
}
checkSource({
source: `(${tag.default})`,
targetTagName,
...filenameInfo,
});
});
}
if (checkProperties) {
const filenameInfo = getFilenameInfo(matchingFileNameProperties, 'jsdoc-properties');
utils.forEachPreferredTag('property', (tag, targetTagName) => {
if (!tag.default || !tag.default.trim()) {
return;
}
checkSource({
source: `(${tag.default})`,
targetTagName,
...filenameInfo,
});
});
}
const tagName = /** @type {string} */ (utils.getPreferredTagName({
tagName: 'example',
}));
if (!utils.hasTag(tagName)) {
return;
}
const matchingFilenameInfo = getFilenameInfo(matchingFileName);
utils.forEachPreferredTag('example', (tag, targetTagName) => {
let source = /** @type {string} */ (utils.getTagDescription(tag));
const match = source.match(hasCaptionRegex);
if (captionRequired && (!match || !match[1].trim())) {
report('Caption is expected for examples.', null, tag);
}
source = source.replace(hasCaptionRegex, '');
const [
lines,
cols,
] = match ? getLinesCols(match[0]) : [
0, 0,
];
if (exampleCodeRegex && !exampleCodeRegex.test(source) ||
rejectExampleCodeRegex && rejectExampleCodeRegex.test(source)
) {
return;
}
const sources = [];
let skipInit = false;
if (exampleCodeRegex) {
let nonJSPrefacingCols = 0;
let nonJSPrefacingLines = 0;
let startingIndex = 0;
let lastStringCount = 0;
let exampleCode;
exampleCodeRegex.lastIndex = 0;
while ((exampleCode = exampleCodeRegex.exec(source)) !== null) {
const {
'0': n0,
'1': n1,
index,
} = exampleCode;
// Count anything preceding user regex match (can affect line numbering)
const preMatch = source.slice(startingIndex, index);
const [
preMatchLines,
colDelta,
] = getLinesCols(preMatch);
let nonJSPreface;
let nonJSPrefaceLineCount;
if (n1) {
const idx = n0.indexOf(n1);
nonJSPreface = n0.slice(0, idx);
nonJSPrefaceLineCount = countChars(nonJSPreface, '\n');
} else {
nonJSPreface = '';
nonJSPrefaceLineCount = 0;
}
nonJSPrefacingLines += lastStringCount + preMatchLines + nonJSPrefaceLineCount;
// Ignore `preMatch` delta if newlines here
if (nonJSPrefaceLineCount) {
const charsInLastLine = nonJSPreface.slice(nonJSPreface.lastIndexOf('\n') + 1).length;
nonJSPrefacingCols += charsInLastLine;
} else {
nonJSPrefacingCols += colDelta + nonJSPreface.length;
}
const string = n1 || n0;
sources.push({
nonJSPrefacingCols,
nonJSPrefacingLines,
string,
});
startingIndex = exampleCodeRegex.lastIndex;
lastStringCount = countChars(string, '\n');
if (!exampleCodeRegex.global) {
break;
}
}
skipInit = true;
}
checkSource({
cols,
lines,
rules: mdRules,
skipInit,
source,
sources,
tag,
targetTagName,
...matchingFilenameInfo,
});
});
}, {
iterateAllJsdocs: true,
meta: {
docs: {
description: '@deprecated - Use `getJsdocProcessorPlugin` processor; ensures that (JavaScript) samples within `@example` tags adhere to ESLint rules.',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/check-examples.md#repos-sticky-header',
},
schema: [
{
additionalProperties: false,
properties: {
allowInlineConfig: {
default: true,
type: 'boolean',
},
baseConfig: {
type: 'object',
},
captionRequired: {
default: false,
type: 'boolean',
},
checkDefaults: {
default: false,
type: 'boolean',
},
checkEslintrc: {
default: true,
type: 'boolean',
},
checkParams: {
default: false,
type: 'boolean',
},
checkProperties: {
default: false,
type: 'boolean',
},
configFile: {
type: 'string',
},
exampleCodeRegex: {
type: 'string',
},
matchingFileName: {
type: 'string',
},
matchingFileNameDefaults: {
type: 'string',
},
matchingFileNameParams: {
type: 'string',
},
matchingFileNameProperties: {
type: 'string',
},
noDefaultExampleRules: {
default: false,
type: 'boolean',
},
paddedIndent: {
default: 0,
type: 'integer',
},
rejectExampleCodeRegex: {
type: 'string',
},
reportUnusedDisableDirectives: {
default: true,
type: 'boolean',
},
},
type: 'object',
},
],
type: 'suggestion',
},
});

View file

@ -0,0 +1,176 @@
import iterateJsdoc from '../iterateJsdoc.js';
/**
* @param {string} str
* @param {string[]} excludeTags
* @returns {string}
*/
const maskExcludedContent = (str, excludeTags) => {
const regContent = new RegExp(`([ \\t]+\\*)[ \\t]@(?:${excludeTags.join('|')})(?=[ \\n])([\\w\\|\\W]*?\\n)(?=[ \\t]*\\*(?:[ \\t]*@\\w+\\s|\\/))`, 'gv');
return str.replace(regContent, (_match, margin, code) => {
return (margin + '\n').repeat(code.match(/\n/gv).length);
});
};
/**
* @param {string} str
* @returns {string}
*/
const maskCodeBlocks = (str) => {
const regContent = /([ \t]+\*)[ \t]```[^\n]*?([\w\|\W]*?\n)(?=[ \t]*\*(?:[ \t]*(?:```|@\w+\s)|\/))/gv;
return str.replaceAll(regContent, (_match, margin, code) => {
return (margin + '\n').repeat(code.match(/\n/gv).length);
});
};
/**
* @param {string[]} lines
* @param {number} lineIndex
* @returns {number}
*/
const getLineNumber = (lines, lineIndex) => {
const precedingText = lines.slice(0, lineIndex).join('\n');
const lineBreaks = precedingText.match(/\n/gv) || [];
return lineBreaks.length + 1;
};
export default iterateJsdoc(({
context,
jsdocNode,
report,
sourceCode,
}) => {
const options = context.options[0] || {};
const /** @type {{excludeTags: string[], allowIndentedSections: boolean}} */ {
allowIndentedSections = false,
excludeTags = [
'example',
],
} = options;
const textWithoutCodeBlocks = maskCodeBlocks(sourceCode.getText(jsdocNode));
const text = excludeTags.length ? maskExcludedContent(textWithoutCodeBlocks, excludeTags) : textWithoutCodeBlocks;
if (allowIndentedSections) {
// When allowIndentedSections is enabled, only check for indentation on tag lines
// and the very first line of the main description
const lines = text.split('\n');
let hasSeenContent = false;
let currentSectionIndent = null;
for (const [
lineIndex,
line,
] of lines.entries()) {
// Check for indentation (two or more spaces after *)
const indentMatch = line.match(/^(?:\/?\**|[\t ]*)\*([\t ]{2,})/v);
if (indentMatch) {
// Check what comes after the indentation
const afterIndent = line.slice(indentMatch[0].length);
const indentAmount = indentMatch[1].length;
// If this is a tag line with indentation, always report
if (/^@\w+/v.test(afterIndent)) {
report('There must be no indentation.', null, {
line: getLineNumber(lines, lineIndex),
});
return;
}
// If we haven't seen any content yet (main description first line) and there's content, report
if (!hasSeenContent && afterIndent.trim().length > 0) {
report('There must be no indentation.', null, {
line: getLineNumber(lines, lineIndex),
});
return;
}
// For continuation lines, check consistency
if (hasSeenContent && afterIndent.trim().length > 0) {
if (currentSectionIndent === null) {
// First indented line in this section, set the indent level
currentSectionIndent = indentAmount;
} else if (indentAmount < currentSectionIndent) {
// Indentation is less than the established level (inconsistent)
report('There must be no indentation.', null, {
line: getLineNumber(lines, lineIndex),
});
return;
}
}
} else if (/^\s*\*\s+\S/v.test(line)) {
// No indentation on this line, reset section indent tracking
// (unless it's just whitespace or a closing comment)
currentSectionIndent = null;
}
// Track if we've seen any content (non-whitespace after the *)
if (/^\s*\*\s+\S/v.test(line)) {
hasSeenContent = true;
}
// Reset section indent when we encounter a tag
if (/@\w+/v.test(line)) {
currentSectionIndent = null;
}
}
} else {
const reg = /^(?:\/?\**|[ \t]*)\*[ \t]{2}/gmv;
if (reg.test(text)) {
const lineBreaks = text.slice(0, reg.lastIndex).match(/\n/gv) || [];
report('There must be no indentation.', null, {
line: lineBreaks.length,
});
}
}
}, {
iterateAllJsdocs: true,
meta: {
docs: {
description: 'Reports invalid padding inside JSDoc blocks.',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/check-indentation.md#repos-sticky-header',
},
schema: [
{
additionalProperties: false,
properties: {
allowIndentedSections: {
description: 'Allows indentation of nested sections on subsequent lines (like bullet lists)',
type: 'boolean',
},
excludeTags: {
description: `Array of tags (e.g., \`['example', 'description']\`) whose content will be
"hidden" from the \`check-indentation\` rule. Defaults to \`['example']\`.
By default, the whole JSDoc block will be checked for invalid padding.
That would include \`@example\` blocks too, which can get in the way
of adding full, readable examples of code without ending up with multiple
linting issues.
When disabled (by passing \`excludeTags: []\` option), the following code *will*
report a padding issue:
\`\`\`js
/**
* @example
* anArray.filter((a) => {
* return a.b;
* });
*/
\`\`\``,
items: {
pattern: '^\\S+$',
type: 'string',
},
type: 'array',
},
},
type: 'object',
},
],
type: 'layout',
},
});

View file

@ -0,0 +1,453 @@
import alignTransform from '../alignTransform.js';
import iterateJsdoc from '../iterateJsdoc.js';
import {
transforms,
} from 'comment-parser';
const {
flow: commentFlow,
} = transforms;
/**
* Detects if a line starts with a markdown list marker
* Supports: -, *, numbered lists (1., 2., etc.)
* This explicitly excludes hyphens that are part of JSDoc tag syntax
* @param {string} text - The text to check
* @param {boolean} isFirstLineOfTag - True if this is the first line (tag line)
* @returns {boolean} - True if the text starts with a list marker
*/
const startsWithListMarker = (text, isFirstLineOfTag = false) => {
// On the first line of a tag, the hyphen is typically the JSDoc separator,
// not a list marker
if (isFirstLineOfTag) {
return false;
}
// Match lines that start with optional whitespace, then a list marker
// - or * followed by a space
// or a number followed by . or ) and a space
return /^\s*(?:[\-*]|\d+(?:\.|\)))\s+/v.test(text);
};
/**
* Checks if we should allow extra indentation beyond wrapIndent.
* This is true for list continuation lines (lines with more indent than wrapIndent
* that follow a list item).
* @param {import('comment-parser').Spec} tag - The tag being checked
* @param {import('../iterateJsdoc.js').Integer} idx - Current line index (0-based in tag.source.slice(1))
* @returns {boolean} - True if extra indentation should be allowed
*/
const shouldAllowExtraIndent = (tag, idx) => {
// Check if any previous line in this tag had a list marker
// idx is 0-based in the continuation lines (tag.source.slice(1))
// So tag.source[0] is the tag line, tag.source[idx+1] is current line
let hasSeenListMarker = false;
// Check all lines from the tag line onwards
for (let lineIdx = 0; lineIdx <= idx + 1; lineIdx++) {
const line = tag.source[lineIdx];
const isFirstLine = lineIdx === 0;
if (line?.tokens?.description && startsWithListMarker(line.tokens.description, isFirstLine)) {
hasSeenListMarker = true;
break;
}
}
return hasSeenListMarker;
};
/**
* @typedef {{
* postDelimiter: import('../iterateJsdoc.js').Integer,
* postHyphen: import('../iterateJsdoc.js').Integer,
* postName: import('../iterateJsdoc.js').Integer,
* postTag: import('../iterateJsdoc.js').Integer,
* postType: import('../iterateJsdoc.js').Integer,
* }} CustomSpacings
*/
/**
* @param {import('../iterateJsdoc.js').Utils} utils
* @param {import('comment-parser').Spec & {
* line: import('../iterateJsdoc.js').Integer
* }} tag
* @param {CustomSpacings} customSpacings
*/
const checkNotAlignedPerTag = (utils, tag, customSpacings) => {
/*
start +
delimiter +
postDelimiter +
tag +
postTag +
type +
postType +
name +
postName +
description +
end +
lineEnd
*/
/**
* @typedef {"tag"|"type"|"name"|"description"} ContentProp
*/
/** @type {("postDelimiter"|"postTag"|"postType"|"postName")[]} */
let spacerProps;
/** @type {ContentProp[]} */
let contentProps;
const mightHaveNamepath = utils.tagMightHaveNameOrNamepath(tag.tag);
if (mightHaveNamepath) {
spacerProps = [
'postDelimiter', 'postTag', 'postType', 'postName',
];
contentProps = [
'tag', 'type', 'name', 'description',
];
} else {
spacerProps = [
'postDelimiter', 'postTag', 'postType',
];
contentProps = [
'tag', 'type', 'description',
];
}
const {
tokens,
} = tag.source[0];
/**
* @param {import('../iterateJsdoc.js').Integer} idx
* @param {(notRet: boolean, contentProp: ContentProp) => void} [callbck]
*/
const followedBySpace = (idx, callbck) => {
const nextIndex = idx + 1;
return spacerProps.slice(nextIndex).some((spacerProp, innerIdx) => {
const contentProp = contentProps[nextIndex + innerIdx];
const spacePropVal = tokens[spacerProp];
const ret = spacePropVal;
if (callbck) {
callbck(!ret, contentProp);
}
return ret && (callbck || !contentProp);
});
};
const postHyphenSpacing = customSpacings?.postHyphen ?? 1;
const exactHyphenSpacing = new RegExp(`^\\s*-\\s{${postHyphenSpacing},${postHyphenSpacing}}(?!\\s)`, 'v');
const hasNoHyphen = !(/^\s*-(?!$)(?=\s)/v).test(tokens.description);
const hasExactHyphenSpacing = exactHyphenSpacing.test(
tokens.description,
);
// If checking alignment on multiple lines, need to check other `source`
// items
// Go through `post*` spacing properties and exit to indicate problem if
// extra spacing detected
const ok = !spacerProps.some((spacerProp, idx) => {
const contentProp = contentProps[idx];
const contentPropVal = tokens[contentProp];
const spacerPropVal = tokens[spacerProp];
const spacing = customSpacings?.[spacerProp] || 1;
// There will be extra alignment if...
// 1. The spaces don't match the space it should have (1 or custom spacing) OR
return spacerPropVal.length !== spacing && spacerPropVal.length !== 0 ||
// 2. There is a (single) space, no immediate content, and yet another
// space is found subsequently (not separated by intervening content)
spacerPropVal && !contentPropVal && followedBySpace(idx);
}) && (hasNoHyphen || hasExactHyphenSpacing);
if (ok) {
return;
}
const fix = () => {
for (const [
idx,
spacerProp,
] of spacerProps.entries()) {
const contentProp = contentProps[idx];
const contentPropVal = tokens[contentProp];
if (contentPropVal) {
const spacing = customSpacings?.[spacerProp] || 1;
tokens[spacerProp] = ''.padStart(spacing, ' ');
followedBySpace(idx, (hasSpace, contentPrp) => {
if (hasSpace) {
tokens[contentPrp] = '';
}
});
} else {
tokens[spacerProp] = '';
}
}
if (!hasExactHyphenSpacing) {
const hyphenSpacing = /^\s*-\s+/v;
tokens.description = tokens.description.replace(
hyphenSpacing, '-' + ''.padStart(postHyphenSpacing, ' '),
);
}
utils.setTag(tag, tokens);
};
utils.reportJSDoc('Expected JSDoc block lines to not be aligned.', tag, fix, true);
};
/**
* @param {object} cfg
* @param {CustomSpacings} cfg.customSpacings
* @param {string} cfg.indent
* @param {import('comment-parser').Block} cfg.jsdoc
* @param {import('eslint').Rule.Node & {
* range: [number, number]
* }} cfg.jsdocNode
* @param {boolean} cfg.preserveMainDescriptionPostDelimiter
* @param {import('../iterateJsdoc.js').Report} cfg.report
* @param {string[]} cfg.tags
* @param {import('../iterateJsdoc.js').Utils} cfg.utils
* @param {string} cfg.wrapIndent
* @param {boolean} cfg.disableWrapIndent
* @returns {void}
*/
const checkAlignment = ({
customSpacings,
disableWrapIndent,
indent,
jsdoc,
jsdocNode,
preserveMainDescriptionPostDelimiter,
report,
tags,
utils,
wrapIndent,
}) => {
const transform = commentFlow(
alignTransform({
customSpacings,
disableWrapIndent,
indent,
preserveMainDescriptionPostDelimiter,
tags,
wrapIndent,
}),
);
const transformedJsdoc = transform(jsdoc);
const comment = '/*' +
/**
* @type {import('eslint').Rule.Node & {
* range: [number, number], value: string
* }}
*/ (jsdocNode).value + '*/';
const formatted = utils.stringify(transformedJsdoc)
.trimStart();
if (comment !== formatted) {
report(
'Expected JSDoc block lines to be aligned.',
/** @type {import('eslint').Rule.ReportFixer} */ (fixer) => {
return fixer.replaceText(jsdocNode, formatted);
},
);
}
};
export default iterateJsdoc(({
context,
indent,
jsdoc,
jsdocNode,
report,
utils,
}) => {
const {
customSpacings,
disableWrapIndent = false,
preserveMainDescriptionPostDelimiter,
tags: applicableTags = [
'param', 'arg', 'argument', 'property', 'prop', 'returns', 'return', 'template',
],
wrapIndent = '',
} = context.options[1] || {};
if (context.options[0] === 'always') {
// Skip if it contains only a single line.
if (!(
/**
* @type {import('eslint').Rule.Node & {
* range: [number, number], value: string
* }}
*/
(jsdocNode).value.includes('\n')
)) {
return;
}
checkAlignment({
customSpacings,
disableWrapIndent,
indent,
jsdoc,
jsdocNode,
preserveMainDescriptionPostDelimiter,
report,
tags: applicableTags,
utils,
wrapIndent,
});
return;
}
const foundTags = utils.getPresentTags(applicableTags);
if (context.options[0] !== 'any') {
for (const tag of foundTags) {
checkNotAlignedPerTag(
utils,
/**
* @type {import('comment-parser').Spec & {
* line: import('../iterateJsdoc.js').Integer
* }}
*/
(tag),
customSpacings,
);
}
}
for (const tag of foundTags) {
if (tag.source.length > 1) {
let idx = 0;
for (const {
tokens,
// Avoid the tag line
} of tag.source.slice(1)) {
idx++;
if (
!tokens.description ||
// Avoid first lines after multiline type
tokens.type ||
tokens.name
) {
continue;
}
// Don't include a single separating space/tab
const actualIndent = tokens.postDelimiter.slice(1);
const hasCorrectWrapIndent = actualIndent === wrapIndent;
// Allow extra indentation if this line or previous lines contain list markers
// This preserves nested list structure
const hasExtraIndent = actualIndent.length > wrapIndent.length &&
actualIndent.startsWith(wrapIndent);
const isInListContext = shouldAllowExtraIndent(tag, idx - 1);
if (!disableWrapIndent && !hasCorrectWrapIndent &&
!(hasExtraIndent && isInListContext)) {
utils.reportJSDoc('Expected wrap indent', {
line: tag.source[0].number + idx,
}, () => {
tokens.postDelimiter = tokens.postDelimiter.charAt(0) + wrapIndent;
});
return;
}
}
}
}
}, {
iterateAllJsdocs: true,
meta: {
docs: {
description: 'Reports invalid alignment of JSDoc block lines.',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/check-line-alignment.md#repos-sticky-header',
},
fixable: 'whitespace',
schema: [
{
description: `If the string value is
\`"always"\` then a problem is raised when the lines are not aligned.
If it is \`"never"\` then a problem should be raised when there is more than
one space between each line's parts. If it is \`"any"\`, no alignment is made.
Defaults to \`"never"\`.
Note that in addition to alignment, the "never" and "always" options will both
ensure that at least one space is present after the asterisk delimiter.`,
enum: [
'always', 'never', 'any',
],
type: 'string',
},
{
additionalProperties: false,
properties: {
customSpacings: {
additionalProperties: false,
description: `An object with any of the following spacing keys set to an integer.
If a spacing is not defined, it defaults to one.`,
properties: {
postDelimiter: {
description: 'Affects spacing after the asterisk (e.g., `* @param`)',
type: 'integer',
},
postHyphen: {
description: 'Affects spacing after any hyphens in the description (e.g., `* @param {someType} name - A description`)',
type: 'integer',
},
postName: {
description: 'Affects spacing after the name (e.g., `* @param {someType} name `)',
type: 'integer',
},
postTag: {
description: 'Affects spacing after the tag (e.g., `* @param `)',
type: 'integer',
},
postType: {
description: 'Affects spacing after the type (e.g., `* @param {someType} `)',
type: 'integer',
},
},
type: 'object',
},
disableWrapIndent: {
description: 'Disables `wrapIndent`; existing wrap indentation is preserved without changes.',
type: 'boolean',
},
preserveMainDescriptionPostDelimiter: {
default: false,
description: `A boolean to determine whether to preserve the post-delimiter spacing of the
main description. If \`false\` or unset, will be set to a single space.`,
type: 'boolean',
},
tags: {
description: `Use this to change the tags which are sought for alignment changes. Defaults to an array of
\`['param', 'arg', 'argument', 'property', 'prop', 'returns', 'return', 'template']\`.`,
items: {
type: 'string',
},
type: 'array',
},
wrapIndent: {
description: `The indent that will be applied for tag text after the first line.
Default to the empty string (no indent).`,
type: 'string',
},
},
type: 'object',
},
],
type: 'layout',
},
});

View file

@ -0,0 +1,535 @@
import iterateJsdoc from '../iterateJsdoc.js';
/**
* @param {string} targetTagName
* @param {boolean} allowExtraTrailingParamDocs
* @param {boolean} checkDestructured
* @param {boolean} checkRestProperty
* @param {RegExp} checkTypesRegex
* @param {boolean} disableExtraPropertyReporting
* @param {boolean} disableMissingParamChecks
* @param {boolean} enableFixer
* @param {import('../jsdocUtils.js').ParamNameInfo[]} functionParameterNames
* @param {import('comment-parser').Block} jsdoc
* @param {import('../iterateJsdoc.js').Utils} utils
* @param {import('../iterateJsdoc.js').Report} report
* @returns {boolean}
*/
const validateParameterNames = (
targetTagName,
allowExtraTrailingParamDocs,
checkDestructured,
checkRestProperty,
checkTypesRegex,
disableExtraPropertyReporting,
disableMissingParamChecks,
enableFixer,
functionParameterNames, jsdoc, utils, report,
) => {
const paramTags = Object.entries(jsdoc.tags).filter(([
, tag,
]) => {
return tag.tag === targetTagName;
});
const paramTagsNonNested = paramTags.filter(([
, tag,
]) => {
return !tag.name.includes('.');
});
let dotted = 0;
let thisOffset = 0;
return paramTags.some(([
, tag,
// eslint-disable-next-line complexity
], index) => {
/** @type {import('../iterateJsdoc.js').Integer} */
let tagsIndex;
const dupeTagInfo = paramTags.find(([
tgsIndex,
tg,
], idx) => {
tagsIndex = Number(tgsIndex);
return tg.name === tag.name && idx !== index;
});
if (dupeTagInfo) {
utils.reportJSDoc(`Duplicate @${targetTagName} "${tag.name}"`, dupeTagInfo[1], enableFixer ? () => {
utils.removeTag(tagsIndex);
} : null);
return true;
}
if (tag.name.includes('.')) {
dotted++;
return false;
}
let functionParameterName = functionParameterNames[index - dotted + thisOffset];
if (functionParameterName === 'this' && tag.name.trim() !== 'this') {
++thisOffset;
functionParameterName = functionParameterNames[index - dotted + thisOffset];
}
if (!functionParameterName) {
if (allowExtraTrailingParamDocs) {
return false;
}
report(
`@${targetTagName} "${tag.name}" does not match an existing function parameter.`,
null,
tag,
);
return true;
}
if (
typeof functionParameterName === 'object' &&
'name' in functionParameterName &&
Array.isArray(functionParameterName.name)
) {
const actualName = tag.name.trim();
const expectedName = functionParameterName.name[index];
if (actualName === expectedName) {
thisOffset--;
return false;
}
report(
`Expected @${targetTagName} name to be "${expectedName}". Got "${actualName}".`,
null,
tag,
);
return true;
}
if (Array.isArray(functionParameterName)) {
if (!checkDestructured) {
return false;
}
if (tag.type && tag.type.search(checkTypesRegex) === -1) {
return false;
}
const [
parameterName,
{
annotationParamName,
hasPropertyRest,
names: properties,
rests,
},
] =
/**
* @type {[string | undefined, import('../jsdocUtils.js').FlattendRootInfo & {
* annotationParamName?: string | undefined;
}]} */ (functionParameterName);
if (annotationParamName !== undefined) {
const name = tag.name.trim();
if (name !== annotationParamName) {
report(`@${targetTagName} "${name}" does not match parameter name "${annotationParamName}"`, null, tag);
}
}
const tagName = parameterName === undefined ? tag.name.trim() : parameterName;
const expectedNames = properties.map((name) => {
return `${tagName}.${name}`;
});
const actualNames = paramTags.map(([
, paramTag,
]) => {
return paramTag.name.trim();
});
const actualTypes = paramTags.map(([
, paramTag,
]) => {
return paramTag.type;
});
const missingProperties = [];
/** @type {string[]} */
const notCheckingNames = [];
for (const [
idx,
name,
] of expectedNames.entries()) {
if (notCheckingNames.some((notCheckingName) => {
return name.startsWith(notCheckingName);
})) {
continue;
}
const actualNameIdx = actualNames.findIndex((actualName) => {
return utils.comparePaths(name)(actualName);
});
if (actualNameIdx === -1) {
if (!checkRestProperty && rests[idx]) {
continue;
}
const missingIndex = actualNames.findIndex((actualName) => {
return utils.pathDoesNotBeginWith(name, actualName);
});
const line = tag.source[0].number - 1 + (missingIndex > -1 ? missingIndex : actualNames.length);
missingProperties.push({
name,
tagPlacement: {
line: line === 0 ? 1 : line,
},
});
} else if (actualTypes[actualNameIdx].search(checkTypesRegex) === -1 && actualTypes[actualNameIdx] !== '') {
notCheckingNames.push(name);
}
}
const hasMissing = missingProperties.length;
if (hasMissing) {
for (const {
name: missingProperty,
tagPlacement,
} of missingProperties) {
report(`Missing @${targetTagName} "${missingProperty}"`, null, tagPlacement);
}
}
if (!hasPropertyRest || checkRestProperty) {
/** @type {[string, import('comment-parser').Spec][]} */
const extraProperties = [];
for (const [
idx,
name,
] of actualNames.entries()) {
const match = name.startsWith(tag.name.trim() + '.');
if (
match && !expectedNames.some(
utils.comparePaths(name),
) && !utils.comparePaths(name)(tag.name) &&
(!disableExtraPropertyReporting || properties.some((prop) => {
return prop.split('.').length >= name.split('.').length - 1;
}))
) {
extraProperties.push([
name, paramTags[idx][1],
]);
}
}
if (extraProperties.length) {
for (const [
extraProperty,
tg,
] of extraProperties) {
report(`@${targetTagName} "${extraProperty}" does not exist on ${tag.name}`, null, tg);
}
return true;
}
}
return hasMissing;
}
let funcParamName;
if (typeof functionParameterName === 'object') {
const {
name,
} = functionParameterName;
funcParamName = name;
} else {
funcParamName = functionParameterName;
}
if (funcParamName !== tag.name.trim()) {
// Todo: Improve for array or object child items
const actualNames = paramTagsNonNested.map(([
, {
name,
},
]) => {
return name.trim();
});
const expectedNames = functionParameterNames.map((item, idx) => {
if (/**
* @type {[string|undefined, (import('../jsdocUtils.js').FlattendRootInfo & {
* annotationParamName?: string,
})]} */ (item)?.[1]?.names) {
return actualNames[idx];
}
return item;
}).filter((item) => {
return item !== 'this';
});
// When disableMissingParamChecks is true tag names can be omitted.
// Report when the tag names do not match the expected names or they are used out of order.
if (disableMissingParamChecks) {
const usedExpectedNames = expectedNames.map((a) => {
return a?.toString();
}).filter((expectedName) => {
return expectedName && actualNames.includes(expectedName);
});
const usedInOrder = actualNames.every((actualName, idx) => {
return actualName === usedExpectedNames[idx];
});
if (usedInOrder) {
return false;
}
}
report(
`Expected @${targetTagName} names to be "${
expectedNames.map((expectedName) => {
return typeof expectedName === 'object' &&
'name' in expectedName &&
expectedName.restElement ?
'...' + expectedName.name :
expectedName;
}).join(', ')
}". Got "${actualNames.join(', ')}".`,
null,
tag,
);
return true;
}
return false;
});
};
/**
* @param {string} targetTagName
* @param {boolean} _allowExtraTrailingParamDocs
* @param {{
* name: string,
* idx: import('../iterateJsdoc.js').Integer
* }[]} jsdocParameterNames
* @param {import('comment-parser').Block} jsdoc
* @param {import('../iterateJsdoc.js').Report} report
* @returns {boolean}
*/
const validateParameterNamesDeep = (
targetTagName, _allowExtraTrailingParamDocs,
jsdocParameterNames, jsdoc, report,
) => {
/** @type {string} */
let lastRealParameter;
return jsdocParameterNames.some(({
idx,
name: jsdocParameterName,
}) => {
const isPropertyPath = jsdocParameterName.includes('.');
if (isPropertyPath) {
if (!lastRealParameter) {
report(`@${targetTagName} path declaration ("${jsdocParameterName}") appears before any real parameter.`, null, jsdoc.tags[idx]);
return true;
}
let pathRootNodeName = jsdocParameterName.slice(0, jsdocParameterName.indexOf('.'));
if (pathRootNodeName.endsWith('[]')) {
pathRootNodeName = pathRootNodeName.slice(0, -2);
}
if (pathRootNodeName !== lastRealParameter) {
report(
`@${targetTagName} path declaration ("${jsdocParameterName}") root node name ("${pathRootNodeName}") ` +
`does not match previous real parameter name ("${lastRealParameter}").`,
null,
jsdoc.tags[idx],
);
return true;
}
} else {
lastRealParameter = jsdocParameterName;
}
return false;
});
};
const allowedNodes = [
'ArrowFunctionExpression', 'FunctionDeclaration', 'FunctionExpression', 'TSDeclareFunction',
// Add this to above defaults
'TSMethodSignature',
];
export default iterateJsdoc(({
context,
jsdoc,
node,
report,
utils,
}) => {
const {
allowExtraTrailingParamDocs,
checkDestructured = true,
checkRestProperty = false,
checkTypesPattern = '/^(?:[oO]bject|[aA]rray|PlainObject|Generic(?:Object|Array))$/',
disableExtraPropertyReporting = false,
disableMissingParamChecks = false,
enableFixer = false,
useDefaultObjectProperties = false,
} = context.options[0] || {};
// Although we might just remove global settings contexts from applying to
// this rule (as they can cause problems with `getFunctionParameterNames`
// checks if they are not functions but say variables), the user may
// instead wish to narrow contexts in those settings, so this check
// is still useful
if (!allowedNodes.includes(/** @type {import('estree').Node} */ (node).type)) {
return;
}
const checkTypesRegex = utils.getRegexFromString(checkTypesPattern);
const jsdocParameterNamesDeep = utils.getJsdocTagsDeep('param');
if (!jsdocParameterNamesDeep || !jsdocParameterNamesDeep.length) {
return;
}
const functionParameterNames = utils.getFunctionParameterNames(useDefaultObjectProperties);
const targetTagName = /** @type {string} */ (utils.getPreferredTagName({
tagName: 'param',
}));
const isError = validateParameterNames(
targetTagName,
allowExtraTrailingParamDocs,
checkDestructured,
checkRestProperty,
checkTypesRegex,
disableExtraPropertyReporting,
disableMissingParamChecks,
enableFixer,
functionParameterNames,
jsdoc,
utils,
report,
);
if (isError || !checkDestructured) {
return;
}
validateParameterNamesDeep(
targetTagName, allowExtraTrailingParamDocs, jsdocParameterNamesDeep, jsdoc, report,
);
}, {
contextDefaults: allowedNodes,
meta: {
docs: {
description: 'Checks for dupe `@param` names, that nested param names have roots, and that parameter names in function declarations match JSDoc param names.',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/check-param-names.md#repos-sticky-header',
},
fixable: 'code',
schema: [
{
additionalProperties: false,
properties: {
allowExtraTrailingParamDocs: {
description: `If set to \`true\`, this option will allow extra \`@param\` definitions (e.g.,
representing future expected or virtual params) to be present without needing
their presence within the function signature. Other inconsistencies between
\`@param\`'s and present function parameters will still be reported.`,
type: 'boolean',
},
checkDestructured: {
description: 'Whether to check destructured properties. Defaults to `true`.',
type: 'boolean',
},
checkRestProperty: {
description: `If set to \`true\`, will require that rest properties are documented and
that any extraneous properties (which may have been within the rest property)
are documented. Defaults to \`false\`.`,
type: 'boolean',
},
checkTypesPattern: {
description: `Defines a regular expression pattern to indicate which types should be
checked for destructured content (and that those not matched should not
be checked).
When one specifies a type, unless it is of a generic type, like \`object\`
or \`array\`, it may be considered unnecessary to have that object's
destructured components required, especially where generated docs will
link back to the specified type. For example:
\`\`\`js
/**
* @param {SVGRect} bbox - a SVGRect
*/
export const bboxToObj = function ({x, y, width, height}) {
return {x, y, width, height};
};
\`\`\`
By default \`checkTypesPattern\` is set to
\`/^(?:[oO]bject|[aA]rray|PlainObject|Generic(?:Object|Array))$/v\`,
meaning that destructuring will be required only if the type of the \`@param\`
(the text between curly brackets) is a match for "Object" or "Array" (with or
without initial caps), "PlainObject", or "GenericObject", "GenericArray" (or
if no type is present). So in the above example, the lack of a match will
mean that no complaint will be given about the undocumented destructured
parameters.
Note that the \`/\` delimiters are optional, but necessary to add flags.
Defaults to using (only) the \`v\` flag, so to add your own flags, encapsulate
your expression as a string, but like a literal, e.g., \`/^object$/vi\`.
You could set this regular expression to a more expansive list, or you
could restrict it such that even types matching those strings would not
need destructuring.`,
type: 'string',
},
disableExtraPropertyReporting: {
description: `Whether to check for extra destructured properties. Defaults to \`false\`. Change
to \`true\` if you want to be able to document properties which are not actually
destructured. Keep as \`false\` if you expect properties to be documented in
their own types. Note that extra properties will always be reported if another
item at the same level is destructured as destructuring will prevent other
access and this option is only intended to permit documenting extra properties
that are available and actually used in the function.`,
type: 'boolean',
},
disableMissingParamChecks: {
description: 'Whether to avoid checks for missing `@param` definitions. Defaults to `false`. Change to `true` if you want to be able to omit properties.',
type: 'boolean',
},
enableFixer: {
description: `Set to \`true\` to auto-remove \`@param\` duplicates (based on identical
names).
Note that this option will remove duplicates of the same name even if
the definitions do not match in other ways (e.g., the second param will
be removed even if it has a different type or description).`,
type: 'boolean',
},
useDefaultObjectProperties: {
description: `Set to \`true\` if you wish to avoid reporting of child property documentation
where instead of destructuring, a whole plain object is supplied as default
value but you wish its keys to be considered as signalling that the properties
are present and can therefore be documented. Defaults to \`false\`.`,
type: 'boolean',
},
},
type: 'object',
},
],
type: 'suggestion',
},
});

View file

@ -0,0 +1,174 @@
import iterateJsdoc from '../iterateJsdoc.js';
/**
* @param {string} targetTagName
* @param {boolean} enableFixer
* @param {import('comment-parser').Block} jsdoc
* @param {import('../iterateJsdoc.js').Utils} utils
* @returns {boolean}
*/
const validatePropertyNames = (
targetTagName,
enableFixer,
jsdoc, utils,
) => {
const jsdocTypedefs = utils.getJsdocTagsDeep('typedef');
let propertyTagGroups;
if (jsdocTypedefs && jsdocTypedefs.length > 1) {
propertyTagGroups = jsdocTypedefs.map(({
idx,
}, index) => {
return Object.entries(jsdoc.tags).slice(idx, jsdocTypedefs[index + 1]?.idx);
});
} else {
propertyTagGroups = [
Object.entries(jsdoc.tags),
];
}
return propertyTagGroups.some((propertyTagGroup) => {
const propertyTags = propertyTagGroup.filter(([
, tag,
]) => {
return tag.tag === targetTagName;
});
return propertyTags.some(([
, tag,
], index) => {
/** @type {import('../iterateJsdoc.js').Integer} */
let tagsIndex;
const dupeTagInfo = propertyTags.find(([
tgsIndex,
tg,
], idx) => {
tagsIndex = Number(tgsIndex);
return tg.name === tag.name && idx !== index;
});
if (dupeTagInfo) {
utils.reportJSDoc(`Duplicate @${targetTagName} "${tag.name}"`, dupeTagInfo[1], enableFixer ? () => {
utils.removeTag(tagsIndex);
} : null);
return true;
}
return false;
});
});
};
/**
* @param {string} targetTagName
* @param {{
* idx: number;
* name: string;
* type: string;
* }[]} jsdocPropertyNames
* @param {import('comment-parser').Block} jsdoc
* @param {import('../iterateJsdoc.js').Report} report
*/
const validatePropertyNamesDeep = (
targetTagName,
jsdocPropertyNames, jsdoc, report,
) => {
/** @type {string} */
let lastRealProperty;
return jsdocPropertyNames.some(({
idx,
name: jsdocPropertyName,
}) => {
const isPropertyPath = jsdocPropertyName.includes('.');
if (isPropertyPath) {
if (!lastRealProperty) {
report(`@${targetTagName} path declaration ("${jsdocPropertyName}") appears before any real property.`, null, jsdoc.tags[idx]);
return true;
}
let pathRootNodeName = jsdocPropertyName.slice(0, jsdocPropertyName.indexOf('.'));
if (pathRootNodeName.endsWith('[]')) {
pathRootNodeName = pathRootNodeName.slice(0, -2);
}
if (pathRootNodeName !== lastRealProperty) {
report(
`@${targetTagName} path declaration ("${jsdocPropertyName}") root node name ("${pathRootNodeName}") ` +
`does not match previous real property name ("${lastRealProperty}").`,
null,
jsdoc.tags[idx],
);
return true;
}
} else {
lastRealProperty = jsdocPropertyName;
}
return false;
});
};
export default iterateJsdoc(({
context,
jsdoc,
report,
utils,
}) => {
const {
enableFixer = false,
} = context.options[0] || {};
const jsdocPropertyNamesDeep = utils.getJsdocTagsDeep('property');
if (!jsdocPropertyNamesDeep || !jsdocPropertyNamesDeep.length) {
return;
}
const targetTagName = /** @type {string} */ (utils.getPreferredTagName({
tagName: 'property',
}));
const isError = validatePropertyNames(
targetTagName,
enableFixer,
jsdoc,
utils,
);
if (isError) {
return;
}
validatePropertyNamesDeep(
targetTagName, jsdocPropertyNamesDeep, jsdoc, report,
);
}, {
iterateAllJsdocs: true,
meta: {
docs: {
description: 'Ensures that property names in JSDoc are not duplicated on the same block and that nested properties have defined roots.',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/check-property-names.md#repos-sticky-header',
},
fixable: 'code',
schema: [
{
additionalProperties: false,
properties: {
enableFixer: {
description: `Set to \`true\` to auto-remove \`@property\` duplicates (based on
identical names).
Note that this option will remove duplicates of the same name even if
the definitions do not match in other ways (e.g., the second property will
be removed even if it has a different type or description).`,
type: 'boolean',
},
},
type: 'object',
},
],
type: 'suggestion',
},
});

View file

@ -0,0 +1,30 @@
import iterateJsdoc from '../iterateJsdoc.js';
export default iterateJsdoc(({
jsdoc,
report,
settings,
}) => {
const {
mode,
} = settings;
// Don't check for "permissive" and "closure"
if (mode === 'jsdoc' || mode === 'typescript') {
for (const tag of jsdoc.tags) {
if (tag.type.slice(-1) === '=') {
report('Syntax should not be Google Closure Compiler style.', null, tag);
break;
}
}
}
}, {
iterateAllJsdocs: true,
meta: {
docs: {
description: 'Reports against syntax not valid for the mode (e.g., Google Closure Compiler in non-Closure mode).',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/check-syntax.md#repos-sticky-header',
},
type: 'suggestion',
},
});

View file

@ -0,0 +1,406 @@
import iterateJsdoc from '../iterateJsdoc.js';
import escapeStringRegexp from 'escape-string-regexp';
// https://babeljs.io/docs/en/babel-plugin-transform-react-jsx/
const jsxTagNames = new Set([
'jsx',
'jsxFrag',
'jsxImportSource',
'jsxRuntime',
]);
const typedTagsAlwaysUnnecessary = new Set([
'augments',
'callback',
'class',
'enum',
'implements',
'private',
'property',
'protected',
'public',
'readonly',
'this',
'type',
'typedef',
]);
const typedTagsNeedingName = new Set([
'template',
]);
const typedTagsUnnecessaryOutsideDeclare = new Set([
'abstract',
'access',
'class',
'constant',
'constructs',
'default',
'enum',
'export',
'exports',
'function',
'global',
'inherits',
'instance',
'interface',
'member',
'memberof',
'memberOf',
'method',
'mixes',
'mixin',
'module',
'name',
'namespace',
'override',
'property',
'requires',
'static',
'this',
]);
export default iterateJsdoc(({
context,
jsdoc,
jsdocNode,
node,
report,
settings,
sourceCode,
utils,
}) => {
const
/**
* @type {{
* definedTags: string[],
* enableFixer: boolean,
* inlineTags: string[],
* jsxTags: boolean,
* typed: boolean
}} */ {
definedTags = [],
enableFixer = true,
inlineTags = [
'link', 'linkcode', 'linkplain', 'tutorial',
],
jsxTags,
typed,
} = context.options[0] || {};
/** @type {(string|undefined)[]} */
let definedPreferredTags = [];
const {
structuredTags,
tagNamePreference,
} = settings;
const definedStructuredTags = Object.keys(structuredTags);
const definedNonPreferredTags = Object.keys(tagNamePreference);
if (definedNonPreferredTags.length) {
definedPreferredTags = Object.values(tagNamePreference).map((preferredTag) => {
if (typeof preferredTag === 'string') {
// May become an empty string but will be filtered out below
return preferredTag;
}
if (!preferredTag) {
return undefined;
}
if (typeof preferredTag !== 'object') {
utils.reportSettings(
'Invalid `settings.jsdoc.tagNamePreference`. Values must be falsy, a string, or an object.',
);
}
return preferredTag.replacement;
})
.filter(Boolean);
}
/**
* @param {import('eslint').Rule.Node} subNode
* @returns {boolean}
*/
const isInAmbientContext = (subNode) => {
return subNode.type === 'Program' ?
/* c8 ignore next -- Support old ESLint */
(context.filename ?? context.getFilename()).endsWith('.d.ts') :
Boolean(
/** @type {import('@typescript-eslint/types').TSESTree.VariableDeclaration} */ (
subNode
).declare,
) || isInAmbientContext(subNode.parent);
};
/**
* @param {import('comment-parser').Spec} jsdocTag
* @returns {boolean}
*/
const tagIsRedundantWhenTyped = (jsdocTag) => {
if (!typedTagsUnnecessaryOutsideDeclare.has(jsdocTag.tag)) {
return false;
}
if (jsdocTag.tag === 'default') {
return false;
}
if (node === null) {
return false;
}
/* c8 ignore next -- Support old ESLint */
if ((context.filename ?? context.getFilename()).endsWith('.d.ts') && [
null, 'Program', undefined,
].includes(node?.parent?.type)) {
return false;
}
if (isInAmbientContext(/** @type {import('eslint').Rule.Node} */ (node))) {
return false;
}
return true;
};
/**
* @param {string} message
* @param {import('comment-parser').Spec} jsdocTag
* @param {import('../iterateJsdoc.js').Integer} tagIndex
* @param {Partial<import('comment-parser').Tokens>} [additionalTagChanges]
* @returns {void}
*/
const reportWithTagRemovalFixer = (message, jsdocTag, tagIndex, additionalTagChanges) => {
utils.reportJSDoc(message, jsdocTag, enableFixer ? () => {
if (jsdocTag.description.trim()) {
utils.changeTag(jsdocTag, {
postType: '',
type: '',
...additionalTagChanges,
});
} else {
utils.removeTag(tagIndex, {
removeEmptyBlock: true,
});
}
} : null, true);
};
/**
* @param {import('comment-parser').Spec} jsdocTag
* @param {import('../iterateJsdoc.js').Integer} tagIndex
* @returns {boolean}
*/
const checkTagForTypedValidity = (jsdocTag, tagIndex) => {
if (typedTagsAlwaysUnnecessary.has(jsdocTag.tag)) {
reportWithTagRemovalFixer(
`'@${jsdocTag.tag}' is redundant when using a type system.`,
jsdocTag,
tagIndex,
{
postTag: '',
tag: '',
},
);
return true;
}
if (tagIsRedundantWhenTyped(jsdocTag)) {
reportWithTagRemovalFixer(
`'@${jsdocTag.tag}' is redundant outside of ambient (\`declare\`/\`.d.ts\`) contexts when using a type system.`,
jsdocTag,
tagIndex,
);
return true;
}
if (typedTagsNeedingName.has(jsdocTag.tag) && !jsdocTag.name) {
reportWithTagRemovalFixer(
`'@${jsdocTag.tag}' without a name is redundant when using a type system.`,
jsdocTag,
tagIndex,
);
return true;
}
return false;
};
for (let tagIndex = 0; tagIndex < jsdoc.tags.length; tagIndex += 1) {
const jsdocTag = jsdoc.tags[tagIndex];
const tagName = jsdocTag.tag;
if (jsxTags && jsxTagNames.has(tagName)) {
continue;
}
if (typed && checkTagForTypedValidity(jsdocTag, tagIndex)) {
continue;
}
const validTags = [
...definedTags,
...(/** @type {string[]} */ (definedPreferredTags)),
...definedNonPreferredTags,
...definedStructuredTags,
...typed ? typedTagsNeedingName : [],
];
if (utils.isValidTag(tagName, validTags)) {
let preferredTagName = utils.getPreferredTagName({
allowObjectReturn: true,
defaultMessage: `Blacklisted tag found (\`@${tagName}\`)`,
tagName,
});
if (!preferredTagName) {
continue;
}
let message;
if (typeof preferredTagName === 'object') {
({
message,
replacement: preferredTagName,
} = /** @type {{message: string; replacement?: string | undefined;}} */ (
preferredTagName
));
}
if (!message) {
message = `Invalid JSDoc tag (preference). Replace "${tagName}" JSDoc tag with "${preferredTagName}".`;
}
if (preferredTagName !== tagName) {
report(message, (fixer) => {
const replacement = sourceCode.getText(jsdocNode).replace(
new RegExp(`@${escapeStringRegexp(tagName)}\\b`, 'v'),
`@${preferredTagName}`,
);
return fixer.replaceText(jsdocNode, replacement);
}, jsdocTag);
}
} else {
report(`Invalid JSDoc tag name "${tagName}".`, null, jsdocTag);
}
}
for (const inlineTag of utils.getInlineTags()) {
if (!inlineTags.includes(inlineTag.tag)) {
report(`Invalid JSDoc inline tag name "${inlineTag.tag}"`, null, inlineTag);
}
}
}, {
iterateAllJsdocs: true,
meta: {
docs: {
description: 'Reports invalid block tag names.',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/check-tag-names.md#repos-sticky-header',
},
fixable: 'code',
schema: [
{
additionalProperties: false,
properties: {
definedTags: {
description: `Use an array of \`definedTags\` strings to configure additional, allowed tags.
The format is as follows:
\`\`\`json
{
"definedTags": ["note", "record"]
}
\`\`\``,
items: {
type: 'string',
},
type: 'array',
},
enableFixer: {
description: 'Set to `false` to disable auto-removal of types that are redundant with the [`typed` option](#typed).',
type: 'boolean',
},
inlineTags: {
description: `List of tags to allow inline.
Defaults to array of \`'link', 'linkcode', 'linkplain', 'tutorial'\``,
items: {
type: 'string',
},
type: 'array',
},
jsxTags: {
description: `If this is set to \`true\`, all of the following tags used to control JSX output are allowed:
\`\`\`
jsx
jsxFrag
jsxImportSource
jsxRuntime
\`\`\`
For more information, see the [babel documentation](https://babeljs.io/docs/en/babel-plugin-transform-react-jsx).`,
type: 'boolean',
},
typed: {
description: `If this is set to \`true\`, additionally checks for tag names that are redundant when using a type checker such as TypeScript.
These tags are always unnecessary when using TypeScript or similar:
\`\`\`
augments
callback
class
enum
implements
private
property
protected
public
readonly
this
type
typedef
\`\`\`
These tags are unnecessary except when inside a TypeScript \`declare\` context:
\`\`\`
abstract
access
class
constant
constructs
default
enum
export
exports
function
global
inherits
instance
interface
member
memberof
memberOf
method
mixes
mixin
module
name
namespace
override
property
requires
static
this
\`\`\``,
type: 'boolean',
},
},
type: 'object',
},
],
type: 'suggestion',
},
});

View file

@ -0,0 +1,208 @@
import iterateJsdoc, {
parseComment,
} from '../iterateJsdoc.js';
import {
getTags,
} from '../jsdocUtils.js';
import {
getJSDocComment,
parse as parseType,
traverse,
tryParse as tryParseType,
} from '@es-joy/jsdoccomment';
export default iterateJsdoc(({
jsdoc,
node,
report,
settings,
sourceCode,
utils,
}) => {
const {
mode,
} = settings;
const tgName = /** @type {string} */ (utils.getPreferredTagName({
tagName: 'template',
}));
if (!tgName) {
return;
}
const templateTags = utils.getTags(tgName);
const usedNames = new Set();
/**
* @param {string} potentialType
*/
const checkForUsedTypes = (potentialType) => {
let parsedType;
try {
parsedType = mode === 'permissive' ?
tryParseType(/** @type {string} */ (potentialType)) :
parseType(/** @type {string} */ (potentialType), mode);
} catch {
return;
}
traverse(parsedType, (nde) => {
const {
type,
value,
} = /** @type {import('jsdoc-type-pratt-parser').NameResult} */ (nde);
if (type === 'JsdocTypeName') {
usedNames.add(value);
}
});
};
const checkParamsAndReturnsTags = (jsdc = jsdoc) => {
const paramName = /** @type {string} */ (utils.getPreferredTagName({
tagName: 'param',
}));
const paramTags = getTags(jsdc, paramName);
for (const paramTag of paramTags) {
checkForUsedTypes(paramTag.type);
}
const returnsName = /** @type {string} */ (utils.getPreferredTagName({
tagName: 'returns',
}));
const returnsTags = getTags(jsdc, returnsName);
for (const returnsTag of returnsTags) {
checkForUsedTypes(returnsTag.type);
}
};
const checkTemplateTags = () => {
for (const tag of templateTags) {
const names = utils.parseClosureTemplateTag(tag);
for (const nme of names) {
if (!usedNames.has(nme)) {
report(`@${tgName} ${nme} not in use`, null, tag);
}
}
}
};
/**
* @param {import('@typescript-eslint/types').TSESTree.FunctionDeclaration|
* import('@typescript-eslint/types').TSESTree.ClassDeclaration|
* import('@typescript-eslint/types').TSESTree.TSInterfaceDeclaration|
* import('@typescript-eslint/types').TSESTree.TSTypeAliasDeclaration} aliasDeclaration
* @param {boolean} [checkParamsAndReturns]
*/
const checkParameters = (aliasDeclaration, checkParamsAndReturns) => {
/* c8 ignore next -- Guard */
const {
params,
} = aliasDeclaration.typeParameters ?? {
params: [],
};
for (const {
name: {
name,
},
} of params) {
usedNames.add(name);
}
if (checkParamsAndReturns) {
checkParamsAndReturnsTags();
} else if (aliasDeclaration.type === 'ClassDeclaration') {
/* c8 ignore next -- TS */
for (const nde of aliasDeclaration?.body?.body ?? []) {
// @ts-expect-error Should be ok
const commentNode = getJSDocComment(sourceCode, nde, settings);
if (!commentNode) {
continue;
}
const innerJsdoc = parseComment(commentNode, '');
checkParamsAndReturnsTags(innerJsdoc);
const typeName = /** @type {string} */ (utils.getPreferredTagName({
tagName: 'type',
}));
const typeTags = getTags(innerJsdoc, typeName);
for (const typeTag of typeTags) {
checkForUsedTypes(typeTag.type);
}
}
}
checkTemplateTags();
};
const handleTypeAliases = () => {
const nde = /** @type {import('@typescript-eslint/types').TSESTree.Node} */ (
node
);
if (!nde) {
return;
}
switch (nde.type) {
case 'ClassDeclaration':
case 'TSInterfaceDeclaration':
case 'TSTypeAliasDeclaration':
checkParameters(nde);
break;
case 'ExportDefaultDeclaration':
case 'ExportNamedDeclaration':
switch (nde.declaration?.type) {
case 'ClassDeclaration':
case 'TSInterfaceDeclaration':
case 'TSTypeAliasDeclaration':
checkParameters(nde.declaration);
break;
case 'FunctionDeclaration':
checkParameters(nde.declaration, true);
break;
}
break;
case 'FunctionDeclaration':
checkParameters(nde, true);
break;
}
};
const callbackTags = utils.getTags('callback');
const functionTags = utils.getTags('function');
if (callbackTags.length || functionTags.length) {
checkParamsAndReturnsTags();
checkTemplateTags();
return;
}
const typedefTags = utils.getTags('typedef');
if (!typedefTags.length || typedefTags.length >= 2) {
handleTypeAliases();
return;
}
const potentialTypedefType = typedefTags[0].type;
checkForUsedTypes(potentialTypedefType);
const propertyName = /** @type {string} */ (utils.getPreferredTagName({
tagName: 'property',
}));
const propertyTags = utils.getTags(propertyName);
for (const propertyTag of propertyTags) {
checkForUsedTypes(propertyTag.type);
}
checkTemplateTags();
}, {
iterateAllJsdocs: true,
meta: {
docs: {
description: 'Checks that any `@template` names are actually used in the connected `@typedef` or type alias.',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/check-template-names.md#repos-sticky-header',
},
schema: [],
type: 'suggestion',
},
});

View file

@ -0,0 +1,130 @@
import {
buildRejectOrPreferRuleDefinition,
} from '../buildRejectOrPreferRuleDefinition.js';
import {
strictNativeTypes,
} from '../jsdocUtils.js';
/**
* @callback CheckNativeTypes
* Iterates strict types to see if any should be added to `invalidTypes` (and
* the the relevant strict type returned as the new preferred type).
* @param {import('../iterateJsdoc.js').PreferredTypes} preferredTypes
* @param {string} typeNodeName
* @param {string|undefined} preferred
* @param {import('jsdoc-type-pratt-parser').NonRootResult|undefined} parentNode
* @param {(string|false|undefined)[][]} invalidTypes
* @returns {string|undefined} The `preferred` type string, optionally changed
*/
/** @type {CheckNativeTypes} */
const checkNativeTypes = (preferredTypes, typeNodeName, preferred, parentNode, invalidTypes) => {
let changedPreferred = preferred;
for (const strictNativeType of strictNativeTypes) {
if (
strictNativeType === 'object' &&
(
// This is not set to remap with exact type match (e.g.,
// `object: 'Object'`), so can ignore (including if circular)
!preferredTypes?.[typeNodeName] ||
// Although present on `preferredTypes` for remapping, this is a
// parent object without a parent match (and not
// `unifyParentAndChildTypeChecks`) and we don't want
// `object<>` given TypeScript issue https://github.com/microsoft/TypeScript/issues/20555
/**
* @type {import('jsdoc-type-pratt-parser').GenericResult}
*/
(
parentNode
)?.elements?.length && (
/**
* @type {import('jsdoc-type-pratt-parser').GenericResult}
*/
(
parentNode
)?.left?.type === 'JsdocTypeName' &&
/**
* @type {import('jsdoc-type-pratt-parser').GenericResult}
*/
(parentNode)?.left?.value === 'Object'
)
)
) {
continue;
}
if (strictNativeType !== typeNodeName &&
strictNativeType.toLowerCase() === typeNodeName.toLowerCase() &&
// Don't report if user has own map for a strict native type
(!preferredTypes || preferredTypes?.[strictNativeType] === undefined)
) {
changedPreferred = strictNativeType;
invalidTypes.push([
typeNodeName, changedPreferred,
]);
break;
}
}
return changedPreferred;
};
export default buildRejectOrPreferRuleDefinition({
checkNativeTypes,
schema: [
{
additionalProperties: false,
properties: {
exemptTagContexts: {
description: 'Avoids reporting when a bad type is found on a specified tag.',
items: {
additionalProperties: false,
properties: {
tag: {
description: 'Set a key `tag` to the tag to exempt',
type: 'string',
},
types: {
description: `Set to \`true\` to indicate that any types on that tag will be allowed,
or to an array of strings which will only allow specific bad types.
If an array of strings is given, these must match the type exactly,
e.g., if you only allow \`"object"\`, it will not allow
\`"object<string, string>"\`. Note that this is different from the
behavior of \`settings.jsdoc.preferredTypes\`. This option is useful
for normally restricting generic types like \`object\` with
\`preferredTypes\`, but allowing \`typedef\` to indicate that its base
type is \`object\`.`,
oneOf: [
{
type: 'boolean',
},
{
items: {
type: 'string',
},
type: 'array',
},
],
},
},
type: 'object',
},
type: 'array',
},
noDefaults: {
description: `Insists that only the supplied option type
map is to be used, and that the default preferences (such as "string"
over "String") will not be enforced. The option's default is \`false\`.`,
type: 'boolean',
},
unifyParentAndChildTypeChecks: {
description: `@deprecated Use the \`preferredTypes[preferredType]\` setting of the same name instead.
If this option is \`true\`, will currently override \`unifyParentAndChildTypeChecks\` on the \`preferredTypes\` setting.`,
type: 'boolean',
},
},
type: 'object',
},
],
});

View file

@ -0,0 +1,264 @@
import iterateJsdoc from '../iterateJsdoc.js';
import {
parseImportsExports,
} from 'parse-imports-exports';
import semver from 'semver';
import spdxExpressionParse from 'spdx-expression-parse';
const allowedKinds = new Set([
'class',
'constant',
'event',
'external',
'file',
'function',
'member',
'mixin',
'module',
'namespace',
'typedef',
]);
export default iterateJsdoc(({
context,
report,
settings,
utils,
}) => {
const options = context.options[0] || {};
const {
allowedAuthors = null,
allowedLicenses = null,
licensePattern = '/([^\n\r]*)/gv',
numericOnlyVariation = false,
} = options;
utils.forEachPreferredTag('version', (jsdocParameter, targetTagName) => {
const version = /** @type {string} */ (
utils.getTagDescription(jsdocParameter)
).trim();
if (!version) {
report(
`Missing JSDoc @${targetTagName} value.`,
null,
jsdocParameter,
);
} else if (!semver.valid(version)) {
report(
`Invalid JSDoc @${targetTagName}: "${utils.getTagDescription(jsdocParameter)}".`,
null,
jsdocParameter,
);
}
});
utils.forEachPreferredTag('kind', (jsdocParameter, targetTagName) => {
const kind = /** @type {string} */ (
utils.getTagDescription(jsdocParameter)
).trim();
if (!kind) {
report(
`Missing JSDoc @${targetTagName} value.`,
null,
jsdocParameter,
);
} else if (!allowedKinds.has(kind)) {
report(
`Invalid JSDoc @${targetTagName}: "${utils.getTagDescription(jsdocParameter)}"; ` +
`must be one of: ${[
...allowedKinds,
].join(', ')}.`,
null,
jsdocParameter,
);
}
});
if (numericOnlyVariation) {
utils.forEachPreferredTag('variation', (jsdocParameter, targetTagName) => {
const variation = /** @type {string} */ (
utils.getTagDescription(jsdocParameter)
).trim();
if (!variation) {
report(
`Missing JSDoc @${targetTagName} value.`,
null,
jsdocParameter,
);
} else if (
!Number.isInteger(Number(variation)) ||
Number(variation) <= 0
) {
report(
`Invalid JSDoc @${targetTagName}: "${utils.getTagDescription(jsdocParameter)}".`,
null,
jsdocParameter,
);
}
});
}
utils.forEachPreferredTag('since', (jsdocParameter, targetTagName) => {
const version = /** @type {string} */ (
utils.getTagDescription(jsdocParameter)
).trim();
if (!version) {
report(
`Missing JSDoc @${targetTagName} value.`,
null,
jsdocParameter,
);
} else if (!semver.valid(version)) {
report(
`Invalid JSDoc @${targetTagName}: "${utils.getTagDescription(jsdocParameter)}".`,
null,
jsdocParameter,
);
}
});
utils.forEachPreferredTag('license', (jsdocParameter, targetTagName) => {
const licenseRegex = utils.getRegexFromString(licensePattern, 'g');
const matches = /** @type {string} */ (
utils.getTagDescription(jsdocParameter)
).matchAll(licenseRegex);
let positiveMatch = false;
for (const match of matches) {
const license = match[1] || match[0];
if (license) {
positiveMatch = true;
}
if (!license.trim()) {
// Avoid reporting again as empty match
if (positiveMatch) {
return;
}
report(
`Missing JSDoc @${targetTagName} value.`,
null,
jsdocParameter,
);
} else if (allowedLicenses) {
if (allowedLicenses !== true && !allowedLicenses.includes(license)) {
report(
`Invalid JSDoc @${targetTagName}: "${license}"; expected one of ${allowedLicenses.join(', ')}.`,
null,
jsdocParameter,
);
}
} else {
try {
spdxExpressionParse(license);
} catch {
report(
`Invalid JSDoc @${targetTagName}: "${license}"; expected SPDX expression: https://spdx.org/licenses/.`,
null,
jsdocParameter,
);
}
}
}
});
if (settings.mode === 'typescript') {
utils.forEachPreferredTag('import', (tag) => {
const {
description,
name,
type,
} = tag;
const typePart = type ? `{${type}} ` : '';
const imprt = 'import ' + (description ?
`${typePart}${name} ${description}` :
`${typePart}${name}`);
const importsExports = parseImportsExports(imprt.trim());
if (importsExports.errors) {
report(
'Bad @import tag',
null,
tag,
);
}
});
}
utils.forEachPreferredTag('author', (jsdocParameter, targetTagName) => {
const author = /** @type {string} */ (
utils.getTagDescription(jsdocParameter)
).trim();
if (!author) {
report(
`Missing JSDoc @${targetTagName} value.`,
null,
jsdocParameter,
);
} else if (allowedAuthors && !allowedAuthors.includes(author)) {
report(
`Invalid JSDoc @${targetTagName}: "${utils.getTagDescription(jsdocParameter)}"; expected one of ${allowedAuthors.join(', ')}.`,
null,
jsdocParameter,
);
}
});
}, {
iterateAllJsdocs: true,
meta: {
docs: {
description: 'This rule checks the values for a handful of tags: `@version`, `@since`, `@license` and `@author`.',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/check-values.md#repos-sticky-header',
},
schema: [
{
additionalProperties: false,
properties: {
allowedAuthors: {
description: `An array of allowable author values. If absent, only non-whitespace will
be checked for.`,
items: {
type: 'string',
},
type: 'array',
},
allowedLicenses: {
anyOf: [
{
items: {
type: 'string',
},
type: 'array',
},
{
type: 'boolean',
},
],
description: `An array of allowable license values or \`true\` to allow any license text.
If present as an array, will be used in place of [SPDX identifiers](https://spdx.org/licenses/).`,
},
licensePattern: {
description: `A string to be converted into a \`RegExp\` (with \`v\` flag) and whose first
parenthetical grouping, if present, will match the portion of the license
description to check (if no grouping is present, then the whole portion
matched will be used). Defaults to \`/([^\\n\\r]*)/gv\`, i.e., the SPDX expression
is expected before any line breaks.
Note that the \`/\` delimiters are optional, but necessary to add flags.
Defaults to using the \`v\` flag, so to add your own flags, encapsulate
your expression as a string, but like a literal, e.g., \`/^mit$/vi\`.`,
type: 'string',
},
numericOnlyVariation: {
description: `Whether to enable validation that \`@variation\` must be a number. Defaults to
\`false\`.`,
type: 'boolean',
},
},
type: 'object',
},
],
type: 'suggestion',
},
});

View file

@ -0,0 +1,443 @@
import {
getSettings,
} from '../iterateJsdoc.js';
import {
enforcedContexts,
getContextObject,
getIndent,
} from '../jsdocUtils.js';
import {
getDecorator,
getFollowingComment,
getNonJsdocComment,
getReducedASTNode,
} from '@es-joy/jsdoccomment';
/** @type {import('eslint').Rule.RuleModule} */
export default {
create (context) {
/**
* @typedef {import('eslint').AST.Token | import('estree').Comment | {
* type: import('eslint').AST.TokenType|"Line"|"Block"|"Shebang",
* range: [number, number],
* value: string
* }} Token
*/
/**
* @callback AddComment
* @param {boolean|undefined} inlineCommentBlock
* @param {Token} comment
* @param {string} indent
* @param {number} lines
* @param {import('eslint').Rule.RuleFixer} fixer
*/
/* c8 ignore next -- Fallback to deprecated method */
const {
sourceCode = context.getSourceCode(),
} = context;
const settings = getSettings(context);
if (!settings) {
return {};
}
const {
allowedPrefixes = [
'@ts-', 'istanbul ', 'c8 ', 'v8 ', 'eslint', 'prettier-',
],
contexts = settings.contexts || [],
contextsAfter = /** @type {string[]} */ ([]),
contextsBeforeAndAfter = [
'VariableDeclarator', 'TSPropertySignature', 'PropertyDefinition',
],
enableFixer = true,
enforceJsdocLineStyle = 'multi',
lineOrBlockStyle = 'both',
} = context.options[0] ?? {};
let reportingNonJsdoc = false;
/**
* @param {string} messageId
* @param {import('estree').Comment|Token} comment
* @param {import('eslint').Rule.Node} node
* @param {import('eslint').Rule.ReportFixer} fixer
*/
const report = (messageId, comment, node, fixer) => {
const loc = {
end: {
column: 0,
/* c8 ignore next 2 -- Guard */
// @ts-expect-error Ok
line: (comment.loc?.start?.line ?? 1),
},
start: {
column: 0,
/* c8 ignore next 2 -- Guard */
// @ts-expect-error Ok
line: (comment.loc?.start?.line ?? 1),
},
};
context.report({
fix: enableFixer ? fixer : null,
loc,
messageId,
node,
});
};
/**
* @param {import('eslint').Rule.Node} node
* @param {import('eslint').AST.Token | import('estree').Comment | {
* type: import('eslint').AST.TokenType|"Line"|"Block"|"Shebang",
* range: [number, number],
* value: string
* }} comment
* @param {AddComment} addComment
* @param {import('../iterateJsdoc.js').Context[]} ctxts
*/
const getFixer = (node, comment, addComment, ctxts) => {
return /** @type {import('eslint').Rule.ReportFixer} */ (fixer) => {
// Default to one line break if the `minLines`/`maxLines` settings allow
const lines = settings.minLines === 0 && settings.maxLines >= 1 ? 1 : settings.minLines;
let baseNode =
/**
* @type {import('@typescript-eslint/types').TSESTree.Node|import('eslint').Rule.Node}
*/ (
getReducedASTNode(node, sourceCode)
);
const decorator = getDecorator(
/** @type {import('eslint').Rule.Node} */
(baseNode),
);
if (decorator) {
baseNode = /** @type {import('@typescript-eslint/types').TSESTree.Decorator} */ (
decorator
);
}
const indent = getIndent({
text: sourceCode.getText(
/** @type {import('eslint').Rule.Node} */ (baseNode),
/** @type {import('eslint').AST.SourceLocation} */
(
/** @type {import('eslint').Rule.Node} */ (baseNode).loc
).start.column,
),
});
const {
inlineCommentBlock,
} =
/**
* @type {{
* context: string,
* inlineCommentBlock: boolean,
* minLineCount: import('../iterateJsdoc.js').Integer
* }[]}
*/ (ctxts).find((contxt) => {
if (typeof contxt === 'string') {
return false;
}
const {
context: ctxt,
} = contxt;
return ctxt === node.type;
}) || {};
return addComment(inlineCommentBlock, comment, indent, lines, fixer);
};
};
/**
* @param {import('eslint').AST.Token | import('estree').Comment | {
* type: import('eslint').AST.TokenType|"Line"|"Block"|"Shebang",
* range: [number, number],
* value: string
* }} comment
* @param {import('eslint').Rule.Node} node
* @param {AddComment} addComment
* @param {import('../iterateJsdoc.js').Context[]} ctxts
*/
const reportings = (comment, node, addComment, ctxts) => {
const fixer = getFixer(node, comment, addComment, ctxts);
if (comment.type === 'Block') {
if (lineOrBlockStyle === 'line') {
return;
}
report('blockCommentsJsdocStyle', comment, node, fixer);
return;
}
if (comment.type === 'Line') {
if (lineOrBlockStyle === 'block') {
return;
}
report('lineCommentsJsdocStyle', comment, node, fixer);
}
};
/**
* @type {import('../iterateJsdoc.js').CheckJsdoc}
*/
const checkNonJsdoc = (_info, _handler, node) => {
const comment = getNonJsdocComment(sourceCode, node, settings);
if (
!comment ||
/** @type {string[]} */
(allowedPrefixes).some((prefix) => {
return comment.value.trimStart().startsWith(prefix);
})
) {
return;
}
reportingNonJsdoc = true;
/** @type {AddComment} */
const addComment = (inlineCommentBlock, commentToAdd, indent, lines, fixer) => {
const insertion = (
inlineCommentBlock || enforceJsdocLineStyle === 'single' ?
`/** ${commentToAdd.value.trim()} ` :
`/**\n${indent}*${commentToAdd.value.trimEnd()}\n${indent}`
) +
`*/${'\n'.repeat((lines || 1) - 1)}`;
return fixer.replaceText(
/** @type {import('eslint').AST.Token} */
(commentToAdd),
insertion,
);
};
reportings(comment, node, addComment, contexts);
};
/**
* @param {import('eslint').Rule.Node} node
* @param {import('../iterateJsdoc.js').Context[]} ctxts
*/
const checkNonJsdocAfter = (node, ctxts) => {
const comment = getFollowingComment(sourceCode, node);
if (
!comment ||
comment.value.startsWith('*') ||
/** @type {string[]} */
(allowedPrefixes).some((prefix) => {
return comment.value.trimStart().startsWith(prefix);
})
) {
return;
}
/** @type {AddComment} */
const addComment = (inlineCommentBlock, commentToAdd, indent, lines, fixer) => {
const insertion = (
inlineCommentBlock || enforceJsdocLineStyle === 'single' ?
`/** ${commentToAdd.value.trim()} ` :
`/**\n${indent}*${commentToAdd.value.trimEnd()}\n${indent}`
) +
`*/${'\n'.repeat((lines || 1) - 1)}${lines ? `\n${indent.slice(1)}` : ' '}`;
return [
fixer.remove(
/** @type {import('eslint').AST.Token} */
(commentToAdd),
), fixer.insertTextBefore(
node.type === 'VariableDeclarator' ? node.parent : node,
insertion,
),
];
};
reportings(comment, node, addComment, ctxts);
};
// Todo: add contexts to check after (and handle if want both before and after)
return {
...getContextObject(
enforcedContexts(context, true, settings),
checkNonJsdoc,
),
...getContextObject(
contextsAfter,
(_info, _handler, node) => {
checkNonJsdocAfter(node, contextsAfter);
},
),
...getContextObject(
contextsBeforeAndAfter,
(_info, _handler, node) => {
checkNonJsdoc({}, null, node);
if (!reportingNonJsdoc) {
checkNonJsdocAfter(node, contextsBeforeAndAfter);
}
},
),
};
},
meta: {
docs: {
description: 'Converts non-JSDoc comments preceding or following nodes into JSDoc ones',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/convert-to-jsdoc-comments.md#repos-sticky-header',
},
fixable: 'code',
messages: {
blockCommentsJsdocStyle: 'Block comments should be JSDoc-style.',
lineCommentsJsdocStyle: 'Line comments should be JSDoc-style.',
},
schema: [
{
additionalProperties: false,
properties: {
allowedPrefixes: {
description: `An array of prefixes to allow at the beginning of a comment.
Defaults to \`['@ts-', 'istanbul ', 'c8 ', 'v8 ', 'eslint', 'prettier-']\`.
Supplying your own value overrides the defaults.`,
items: {
type: 'string',
},
type: 'array',
},
contexts: {
description: `The contexts array which will be checked for preceding content.
Can either be strings or an object with a \`context\` string and an optional, default \`false\` \`inlineCommentBlock\` boolean.
Defaults to \`ArrowFunctionExpression\`, \`FunctionDeclaration\`,
\`FunctionExpression\`, \`TSDeclareFunction\`.`,
items: {
anyOf: [
{
type: 'string',
},
{
additionalProperties: false,
properties: {
context: {
type: 'string',
},
inlineCommentBlock: {
type: 'boolean',
},
},
type: 'object',
},
],
},
type: 'array',
},
contextsAfter: {
description: `The contexts array which will be checked for content on the same line after.
Can either be strings or an object with a \`context\` string and an optional, default \`false\` \`inlineCommentBlock\` boolean.
Defaults to an empty array.`,
items: {
anyOf: [
{
type: 'string',
},
{
additionalProperties: false,
properties: {
context: {
type: 'string',
},
inlineCommentBlock: {
type: 'boolean',
},
},
type: 'object',
},
],
},
type: 'array',
},
contextsBeforeAndAfter: {
description: `The contexts array which will be checked for content before and on the same
line after.
Can either be strings or an object with a \`context\` string and an optional, default \`false\` \`inlineCommentBlock\` boolean.
Defaults to \`VariableDeclarator\`, \`TSPropertySignature\`, \`PropertyDefinition\`.`,
items: {
anyOf: [
{
type: 'string',
},
{
additionalProperties: false,
properties: {
context: {
type: 'string',
},
inlineCommentBlock: {
type: 'boolean',
},
},
type: 'object',
},
],
},
type: 'array',
},
enableFixer: {
description: 'Set to `false` to disable fixing.',
type: 'boolean',
},
enforceJsdocLineStyle: {
description: `What policy to enforce on the conversion of non-JSDoc comments without
line breaks. (Non-JSDoc (mulitline) comments with line breaks will always
be converted to \`multi\` style JSDoc comments.)
- \`multi\` - Convert to multi-line style
\`\`\`js
/**
* Some text
*/
\`\`\`
- \`single\` - Convert to single-line style
\`\`\`js
/** Some text */
\`\`\`
Defaults to \`multi\`.`,
enum: [
'multi', 'single',
],
type: 'string',
},
lineOrBlockStyle: {
description: `What style of comments to which to apply JSDoc conversion.
- \`block\` - Applies to block-style comments (\`/* ... */\`)
- \`line\` - Applies to line-style comments (\`// ...\`)
- \`both\` - Applies to both block and line-style comments
Defaults to \`both\`.`,
enum: [
'block', 'line', 'both',
],
type: 'string',
},
},
type: 'object',
},
],
type: 'suggestion',
},
};

View file

@ -0,0 +1,106 @@
import iterateJsdoc from '../iterateJsdoc.js';
const defaultEmptyTags = new Set([
'abstract', 'async', 'generator', 'global', 'hideconstructor',
// jsdoc doesn't use this form in its docs, but allow for compatibility with
// TypeScript which allows and Closure which requires
'ignore',
// jsdoc doesn't use but allow for TypeScript
'inheritDoc', 'inner', 'instance',
'internal',
'overload',
'override',
'readonly',
]);
const emptyIfNotClosure = new Set([
// Closure doesn't allow with this casing
'inheritdoc', 'package', 'private', 'protected', 'public',
'static',
]);
const emptyIfClosure = new Set([
'interface',
]);
export default iterateJsdoc(({
jsdoc,
settings,
utils,
}) => {
const emptyTags = utils.filterTags(({
tag: tagName,
}) => {
return defaultEmptyTags.has(tagName) ||
utils.hasOptionTag(tagName) && jsdoc.tags.some(({
tag,
}) => {
return tag === tagName;
}) ||
settings.mode === 'closure' && emptyIfClosure.has(tagName) ||
settings.mode !== 'closure' && emptyIfNotClosure.has(tagName);
});
for (const [
key,
tag,
] of emptyTags.entries()) {
const content = tag.name || tag.description || tag.type;
if (content.trim() && (
// Allow for JSDoc-block final asterisks
key !== emptyTags.length - 1 || !(/^\s*\*+$/v).test(content)
)) {
const fix = () => {
// By time of call in fixer, `tag` will have `line` added
utils.setTag(
/**
* @type {import('comment-parser').Spec & {
* line: import('../iterateJsdoc.js').Integer
* }}
*/ (tag),
);
};
utils.reportJSDoc(`@${tag.tag} should be empty.`, tag, fix, true);
}
}
}, {
checkInternal: true,
checkPrivate: true,
iterateAllJsdocs: true,
meta: {
docs: {
description: 'Checks tags that are expected to be empty (e.g., `@abstract` or `@async`), reporting if they have content',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/empty-tags.md#repos-sticky-header',
},
fixable: 'code',
schema: [
{
additionalProperties: false,
properties: {
tags: {
description: `If you want additional tags to be checked for their descriptions, you may
add them within this option.
\`\`\`js
{
'jsdoc/empty-tags': ['error', {tags: ['event']}]
}
\`\`\``,
items: {
type: 'string',
},
type: 'array',
},
},
type: 'object',
},
],
type: 'suggestion',
},
});

View file

@ -0,0 +1,189 @@
import iterateJsdoc from '../iterateJsdoc.js';
export default iterateJsdoc(({
context,
jsdoc,
settings,
utils,
}) => {
const {
mode,
} = settings;
if (mode !== 'typescript') {
return;
}
const {
allowedInlineTags = [],
enableFixer = false,
fixType = 'backslash',
} = context.options[0] || {};
const {
description,
} = utils.getDescription();
/** @type {string[]} */
const tagNames = [];
/** @type {number[]} */
const indexes = [];
const unescapedInlineTagRegex = /(?:^|\s)@(\w+)/gv;
for (const [
idx,
descLine,
] of (
description.startsWith('\n') ? description.slice(1) : description
).split('\n').entries()
) {
descLine.replaceAll(unescapedInlineTagRegex, (_, tagName) => {
if (allowedInlineTags.includes(tagName)) {
return _;
}
tagNames.push(tagName);
indexes.push(idx);
return _;
});
}
for (const [
idx,
tagName,
] of tagNames.entries()) {
utils.reportJSDoc(
`Unexpected inline JSDoc tag. Did you mean to use {@${tagName}}, \\@${tagName}, or \`@${tagName}\`?`,
{
line: indexes[idx] + 1,
},
enableFixer ?
() => {
utils.setBlockDescription((info, seedTokens, descLines) => {
return descLines.map((desc) => {
const newDesc = desc.replaceAll(
new RegExp(`(^|\\s)@${
// No need to escape, as contains only safe characters
tagName
}`, 'gv'),
fixType === 'backticks' ? '$1`@' + tagName + '`' : '$1\\@' + tagName,
);
return {
number: 0,
source: '',
tokens: seedTokens({
...info,
description: newDesc,
postDelimiter: newDesc.trim() ? ' ' : '',
}),
};
});
});
} :
null,
);
}
/**
* @param {string} tagName
* @returns {[
* RegExp,
* (description: string) => string
* ]}
*/
const escapeInlineTags = (tagName) => {
const regex = new RegExp(`(^|\\s)@${
// No need to escape, as contains only safe characters
tagName
}`, 'gv');
return [
regex,
/**
* @param {string} desc
*/
(desc) => {
return desc.replaceAll(
regex,
fixType === 'backticks' ? '$1`@' + tagName + '`' : '$1\\@' + tagName,
);
},
];
};
for (const tag of jsdoc.tags) {
if (tag.tag === 'example') {
continue;
}
/** @type {string} */
let tagName = '';
while (/** @type {string[]} */ (
utils.getTagDescription(tag, true)
// eslint-disable-next-line no-loop-func -- Safe
).some((desc) => {
tagName = unescapedInlineTagRegex.exec(desc)?.[1] ?? '';
if (allowedInlineTags.includes(tagName)) {
return false;
}
return tagName;
})) {
const line = utils.setTagDescription(tag, ...escapeInlineTags(tagName)) +
tag.source[0].number;
utils.reportJSDoc(
`Unexpected inline JSDoc tag. Did you mean to use {@${tagName}}, \\@${tagName}, or \`@${tagName}\`?`,
{
line,
},
enableFixer ? () => {} : null,
true,
);
}
}
}, {
iterateAllJsdocs: true,
meta: {
docs: {
description: 'Reports use of JSDoc tags in non-tag positions (in the default "typescript" mode).',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/escape-inline-tags.md#repos-sticky-header',
},
fixable: 'code',
schema: [
{
additionalProperties: false,
properties: {
allowedInlineTags: {
description: 'A listing of tags you wish to allow unescaped. Defaults to an empty array.',
items: {
type: 'string',
},
type: 'array',
},
enableFixer: {
description: 'Whether to enable the fixer. Defaults to `false`.',
type: 'boolean',
},
fixType: {
description: `How to escape the inline tag.
May be "backticks" to enclose tags in backticks (treating as code segments), or
"backslash" to escape tags with a backslash, i.e., \`\\@\`
Defaults to "backslash".`,
enum: [
'backticks',
'backslash',
],
type: 'string',
},
},
type: 'object',
},
],
type: 'suggestion',
},
});

View file

@ -0,0 +1,78 @@
import iterateJsdoc from '../iterateJsdoc.js';
export default iterateJsdoc(({
report,
utils,
}) => {
const iteratingFunction = utils.isIteratingFunctionOrVariable();
if (iteratingFunction) {
if (utils.hasATag([
'class',
'constructor',
]) ||
utils.isConstructor()
) {
return;
}
} else if (!utils.isVirtualFunction()) {
return;
}
utils.forEachPreferredTag('implements', (tag) => {
report('@implements used on a non-constructor function', null, tag);
});
}, {
contextDefaults: true,
meta: {
docs: {
description: 'Prohibits use of `@implements` on non-constructor functions (to enforce the tag only being used on classes/constructors).',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/implements-on-classes.md#repos-sticky-header',
},
schema: [
{
additionalProperties: false,
properties: {
contexts: {
description: `Set this to an array of strings representing the AST context (or an object with
\`context\` and \`comment\` properties) where you wish the rule to be applied.
\`context\` defaults to \`any\` and \`comment\` defaults to no specific comment context.
Overrides the default contexts (\`ArrowFunctionExpression\`, \`FunctionDeclaration\`,
\`FunctionExpression\`). Set to \`"any"\` if you want
the rule to apply to any JSDoc block throughout your files (as is necessary
for finding function blocks not attached to a function declaration or
expression, i.e., \`@callback\` or \`@function\` (or its aliases \`@func\` or
\`@method\`) (including those associated with an \`@interface\`).
See the ["AST and Selectors"](../#advanced-ast-and-selectors)
section of our Advanced docs for more on the expected format.`,
items: {
anyOf: [
{
type: 'string',
},
{
additionalProperties: false,
properties: {
comment: {
type: 'string',
},
context: {
type: 'string',
},
},
type: 'object',
},
],
},
type: 'array',
},
},
type: 'object',
},
],
type: 'suggestion',
},
});

View file

@ -0,0 +1,132 @@
import iterateJsdoc from '../iterateJsdoc.js';
import {
parse,
traverse,
tryParse,
} from '@es-joy/jsdoccomment';
import * as resolve from '@es-joy/resolve.exports';
import {
readFileSync,
} from 'node:fs';
import {
isBuiltin as isBuiltinModule,
} from 'node:module';
import {
join,
} from 'node:path';
/**
* @type {Set<string>|null}
*/
let deps;
const setDeps = function () {
try {
const pkg = JSON.parse(
readFileSync(join(process.cwd(), './package.json'), 'utf8'),
);
deps = new Set([
...(pkg.dependencies ?
/* c8 ignore next 2 */
Object.keys(pkg.dependencies) :
[]),
...(pkg.devDependencies ?
/* c8 ignore next 2 */
Object.keys(pkg.devDependencies) :
[]),
]);
/* c8 ignore next -- our package.json exists */
} catch (error) {
/* c8 ignore next -- our package.json exists */
deps = null;
/* c8 ignore next 4 -- our package.json exists */
/* eslint-disable no-console -- Inform user */
console.log(error);
/* eslint-enable no-console -- Inform user */
}
};
const moduleCheck = new Map();
export default iterateJsdoc(({
jsdoc,
settings,
utils,
}) => {
if (deps === undefined) {
setDeps();
}
/* c8 ignore next 3 -- our package.json exists */
if (deps === null) {
return;
}
const {
mode,
} = settings;
for (const tag of jsdoc.tags) {
let typeAst;
try {
typeAst = mode === 'permissive' ? tryParse(tag.type) : parse(tag.type, mode);
} catch {
continue;
}
// eslint-disable-next-line no-loop-func -- Safe
traverse(typeAst, (nde) => {
/* c8 ignore next 3 -- TS guard */
if (deps === null) {
return;
}
if (nde.type === 'JsdocTypeImport') {
let mod = nde.element.value.replace(
/^(@[^\/]+\/[^\/]+|[^\/]+).*$/v, '$1',
);
if ((/^[.\/]/v).test(mod)) {
return;
}
if (isBuiltinModule(mod)) {
// mod = '@types/node';
// moduleCheck.set(mod, !deps.has(mod));
return;
} else if (!moduleCheck.has(mod)) {
let pkg;
try {
pkg = JSON.parse(
readFileSync(join(process.cwd(), 'node_modules', mod, './package.json'), 'utf8'),
);
} catch {
// Ignore
}
if (!pkg || (!pkg.types && !pkg.typings && !resolve.types(pkg))) {
mod = `@types/${mod}`;
}
moduleCheck.set(mod, !deps.has(mod));
}
if (moduleCheck.get(mod)) {
utils.reportJSDoc(
'import points to package which is not found in dependencies',
tag,
);
}
}
});
}
}, {
iterateAllJsdocs: true,
meta: {
docs: {
description: 'Reports if JSDoc `import()` statements point to a package which is not listed in `dependencies` or `devDependencies`',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/imports-as-dependencies.md#repos-sticky-header',
},
type: 'suggestion',
},
});

View file

@ -0,0 +1,228 @@
import iterateJsdoc from '../iterateJsdoc.js';
import {
areDocsInformative,
} from 'are-docs-informative';
const defaultAliases = {
a: [
'an', 'our',
],
};
const defaultUselessWords = [
'a', 'an', 'i', 'in', 'of', 's', 'the',
];
/**
* @param {import('eslint').Rule.Node|import('@typescript-eslint/types').TSESTree.Node|null|undefined} node
* @returns {string[]}
*/
const getNamesFromNode = (node) => {
switch (node?.type) {
case 'AccessorProperty':
case 'MethodDefinition':
case 'PropertyDefinition':
case 'TSAbstractAccessorProperty':
case 'TSAbstractMethodDefinition':
case 'TSAbstractPropertyDefinition':
return [
...getNamesFromNode(
/** @type {import('@typescript-eslint/types').TSESTree.Node} */ (
node.parent
).parent,
),
...getNamesFromNode(
/** @type {import('@typescript-eslint/types').TSESTree.Node} */
(node.key),
),
];
case 'ClassDeclaration':
case 'ClassExpression':
case 'FunctionDeclaration':
case 'FunctionExpression':
case 'TSDeclareFunction':
case 'TSEnumDeclaration':
case 'TSEnumMember':
case 'TSInterfaceDeclaration':
case 'TSMethodSignature':
case 'TSModuleDeclaration':
case 'TSTypeAliasDeclaration':
return getNamesFromNode(
/** @type {import('@typescript-eslint/types').TSESTree.ClassDeclaration} */
(node).id,
);
case 'ExportDefaultDeclaration':
case 'ExportNamedDeclaration':
return getNamesFromNode(
/** @type {import('@typescript-eslint/types').TSESTree.ExportNamedDeclaration} */
(node).declaration,
);
case 'Identifier':
return [
node.name,
];
case 'Property':
return getNamesFromNode(
/** @type {import('@typescript-eslint/types').TSESTree.Node} */
(node.key),
);
case 'VariableDeclaration':
return getNamesFromNode(
/** @type {import('@typescript-eslint/types').TSESTree.Node} */
(node.declarations[0]),
);
case 'VariableDeclarator':
return [
...getNamesFromNode(
/** @type {import('@typescript-eslint/types').TSESTree.Node} */
(node.id),
),
...getNamesFromNode(
/** @type {import('@typescript-eslint/types').TSESTree.Node} */
(node.init),
),
].filter(Boolean);
default:
return [];
}
};
export default iterateJsdoc(({
context,
jsdoc,
node,
report,
utils,
}) => {
const /** @type {{aliases: {[key: string]: string[]}, excludedTags: string[], uselessWords: string[]}} */ {
aliases = defaultAliases,
excludedTags = [],
uselessWords = defaultUselessWords,
} = context.options[0] || {};
const nodeNames = getNamesFromNode(node);
/**
* @param {string} text
* @param {string} extraName
* @returns {boolean}
*/
const descriptionIsRedundant = (text, extraName = '') => {
const textTrimmed = text.trim();
return Boolean(textTrimmed) && !areDocsInformative(textTrimmed, [
extraName, nodeNames,
].filter(Boolean).join(' '), {
aliases,
uselessWords,
});
};
const {
description,
lastDescriptionLine,
} = utils.getDescription();
let descriptionReported = false;
for (const tag of jsdoc.tags) {
if (excludedTags.includes(tag.tag)) {
continue;
}
if (descriptionIsRedundant(tag.description, tag.name)) {
utils.reportJSDoc(
'This tag description only repeats the name it describes.',
tag,
);
}
descriptionReported ||= tag.description === description &&
/** @type {import('comment-parser').Spec & {line: import('../iterateJsdoc.js').Integer}} */
(tag).line === lastDescriptionLine;
}
if (!descriptionReported && descriptionIsRedundant(description)) {
report('This description only repeats the name it describes.');
}
}, {
iterateAllJsdocs: true,
meta: {
docs: {
description:
'This rule reports doc comments that only restate their attached name.',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/informative-docs.md#repos-sticky-header',
},
schema: [
{
additionalProperties: false,
properties: {
aliases: {
description: `The \`aliases\` option allows indicating words as synonyms (aliases) of each other.
For example, with \`{ aliases: { emoji: ["smiley", "winkey"] } }\`, the following comment would be considered uninformative:
\`\`\`js
/** A smiley/winkey. */
let emoji;
\`\`\`
The default \`aliases\` option is:
\`\`\`json
{
"a": ["an", "our"]
}
\`\`\``,
patternProperties: {
'.*': {
items: {
type: 'string',
},
type: 'array',
},
},
},
excludedTags: {
description: `Tags that should not be checked for valid contents.
For example, with \`{ excludedTags: ["category"] }\`, the following comment would not be considered uninformative:
\`\`\`js
/** @category Types */
function computeTypes(node) {
// ...
}
\`\`\`
No tags are excluded by default.`,
items: {
type: 'string',
},
type: 'array',
},
uselessWords: {
description: `Words that are ignored when searching for one that adds meaning.
For example, with \`{ uselessWords: ["our"] }\`, the following comment would be considered uninformative:
\`\`\`js
/** Our text. */
let text;
\`\`\`
The default \`uselessWords\` option is:
\`\`\`json
["a", "an", "i", "in", "of", "s", "the"]
\`\`\``,
items: {
type: 'string',
},
type: 'array',
},
},
type: 'object',
},
],
type: 'suggestion',
},
});

View file

@ -0,0 +1,144 @@
import iterateJsdoc from '../iterateJsdoc.js';
/**
* Punctuators that begin a logical group should not require a line before it skipped. Specifically
* `[` starts an array, `{` starts an object or block, `(` starts a grouping, and `=` starts a
* declaration (like a variable or a type alias).
*/
const startPunctuators = new Set([
'(', '=', '[', '{',
]);
export default iterateJsdoc(({
context,
jsdocNode,
report,
sourceCode,
utils,
}) => {
const {
checkBlockStarts,
excludedTags = [
'type',
],
ignoreSameLine = true,
ignoreSingleLines = true,
lines = 1,
} = context.options[0] || {};
if (utils.hasATag(excludedTags)) {
return;
}
const tokensBefore = sourceCode.getTokensBefore(jsdocNode, {
includeComments: true,
});
const tokenBefore = tokensBefore.at(-1);
if (
!tokenBefore || (
tokenBefore.type === 'Punctuator' &&
!checkBlockStarts &&
startPunctuators.has(tokenBefore.value)
)
) {
return;
}
if (tokenBefore.loc?.end?.line + lines >=
/** @type {number} */
(jsdocNode.loc?.start?.line)
) {
const startLine = jsdocNode.loc?.start?.line;
const sameLine = tokenBefore.loc?.end?.line === startLine;
if (sameLine && ignoreSameLine) {
return;
}
if (ignoreSingleLines && jsdocNode.loc?.start.line === jsdocNode.loc?.end.line) {
return;
}
/** @type {import('eslint').Rule.ReportFixer} */
const fix = (fixer) => {
let indent = '';
if (sameLine) {
const spaceDiff = /** @type {number} */ (jsdocNode.loc?.start?.column) -
/** @type {number} */ (tokenBefore.loc?.end?.column);
// @ts-expect-error Should be a comment
indent = /** @type {import('estree').Comment} */ (
jsdocNode
).value.match(/^\*\n([\t ]*) \*/v)?.[1]?.slice(spaceDiff);
if (!indent) {
/** @type {import('eslint').AST.Token|import('estree').Comment|undefined} */
let tokenPrior = tokenBefore;
let startColumn;
while (tokenPrior && tokenPrior?.loc?.start?.line === startLine) {
startColumn = tokenPrior.loc?.start?.column;
tokenPrior = tokensBefore.pop();
}
indent = ' '.repeat(
/* c8 ignore next */
/** @type {number} */ (startColumn ? startColumn - 1 : 0),
);
}
}
return fixer.insertTextAfter(
/** @type {import('eslint').AST.Token} */
(tokenBefore),
'\n'.repeat(lines) +
(sameLine ? '\n' + indent : ''),
);
};
report(`Required ${lines} line(s) before JSDoc block`, fix);
}
}, {
iterateAllJsdocs: true,
meta: {
docs: {
description: 'Enforces minimum number of newlines before JSDoc comment blocks',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/lines-before-block.md#repos-sticky-header',
},
fixable: 'code',
schema: [
{
additionalProperties: false,
properties: {
checkBlockStarts: {
description: `Whether to additionally check the start of blocks, such as classes or functions.
Defaults to \`false\`.`,
type: 'boolean',
},
excludedTags: {
description: `An array of tags whose presence in the JSDoc block will prevent the
application of the rule. Defaults to \`['type']\` (i.e., if \`@type\` is present,
lines before the block will not be added).`,
items: {
type: 'string',
},
type: 'array',
},
ignoreSameLine: {
description: `This option excludes cases where the JSDoc block occurs on the same line as a
preceding code or comment. Defaults to \`true\`.`,
type: 'boolean',
},
ignoreSingleLines: {
description: `This option excludes cases where the JSDoc block is only one line long.
Defaults to \`true\`.`,
type: 'boolean',
},
lines: {
description: 'The minimum number of lines to require. Defaults to 1.',
type: 'integer',
},
},
type: 'object',
},
],
type: 'suggestion',
},
});

View file

@ -0,0 +1,413 @@
import iterateJsdoc from '../iterateJsdoc.js';
// If supporting Node >= 10, we could loosen the default to this for the
// initial letter: \\p{Upper}
const matchDescriptionDefault = '^\n?([A-Z`\\d_][\\s\\S]*[.?!`\\p{RGI_Emoji}]\\s*)?$';
/**
* @param {string} value
* @param {string} userDefault
* @returns {string}
*/
const stringOrDefault = (value, userDefault) => {
return typeof value === 'string' ?
value :
userDefault || matchDescriptionDefault;
};
export default iterateJsdoc(({
context,
jsdoc,
report,
utils,
}) => {
const {
mainDescription,
matchDescription,
message,
nonemptyTags = true,
tags = {},
} = context.options[0] || {};
/**
* @param {string} desc
* @param {import('comment-parser').Spec} [tag]
* @returns {void}
*/
const validateDescription = (desc, tag) => {
let mainDescriptionMatch = mainDescription;
let errorMessage = message;
if (typeof mainDescription === 'object') {
mainDescriptionMatch = mainDescription.match;
errorMessage = mainDescription.message;
}
if (mainDescriptionMatch === false && (
!tag || !Object.hasOwn(tags, tag.tag))
) {
return;
}
let tagValue = mainDescriptionMatch;
if (tag) {
const tagName = tag.tag;
if (typeof tags[tagName] === 'object') {
tagValue = tags[tagName].match;
errorMessage = tags[tagName].message;
} else {
tagValue = tags[tagName];
}
}
const regex = utils.getRegexFromString(
stringOrDefault(tagValue, matchDescription),
);
if (!regex.test(desc)) {
report(
errorMessage || 'JSDoc description does not satisfy the regex pattern.',
null,
tag || {
// Add one as description would typically be into block
line: jsdoc.source[0].number + 1,
},
);
}
};
const {
description,
} = utils.getDescription();
if (description) {
validateDescription(description);
}
/**
* @param {string} tagName
* @returns {boolean}
*/
const hasNoTag = (tagName) => {
return !tags[tagName];
};
for (const tag of [
'description',
'summary',
'file',
'classdesc',
]) {
utils.forEachPreferredTag(tag, (matchingJsdocTag, targetTagName) => {
const desc = (matchingJsdocTag.name + ' ' + utils.getTagDescription(matchingJsdocTag)).trim();
if (hasNoTag(targetTagName)) {
validateDescription(desc, matchingJsdocTag);
}
}, true);
}
if (nonemptyTags) {
for (const tag of [
'copyright',
'example',
'see',
'todo',
]) {
utils.forEachPreferredTag(tag, (matchingJsdocTag, targetTagName) => {
const desc = (matchingJsdocTag.name + ' ' + utils.getTagDescription(matchingJsdocTag)).trim();
if (hasNoTag(targetTagName) && !(/.+/v).test(desc)) {
report(
'JSDoc description must not be empty.',
null,
matchingJsdocTag,
);
}
});
}
}
if (!Object.keys(tags).length) {
return;
}
/**
* @param {string} tagName
* @returns {boolean}
*/
const hasOptionTag = (tagName) => {
return Boolean(tags[tagName]);
};
const whitelistedTags = utils.filterTags(({
tag: tagName,
}) => {
return hasOptionTag(tagName);
});
const {
tagsWithNames,
tagsWithoutNames,
} = utils.getTagsByType(whitelistedTags);
tagsWithNames.some((tag) => {
const desc = /** @type {string} */ (
utils.getTagDescription(tag)
).replace(/^[\- ]*/v, '')
.trim();
return validateDescription(desc, tag);
});
tagsWithoutNames.some((tag) => {
const desc = (tag.name + ' ' + utils.getTagDescription(tag)).trim();
return validateDescription(desc, tag);
});
}, {
contextDefaults: true,
meta: {
docs: {
description: 'Enforces a regular expression pattern on descriptions.',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/match-description.md#repos-sticky-header',
},
schema: [
{
additionalProperties: false,
properties: {
contexts: {
description: `Set this to an array of strings representing the AST context (or an object with
optional \`context\` and \`comment\` properties) where you wish the rule to be applied (e.g.,
\`ClassDeclaration\` for ES6 classes).
\`context\` defaults to \`any\` and \`comment\` defaults to no specific comment context.
Overrides the default contexts (\`ArrowFunctionExpression\`, \`FunctionDeclaration\`,
\`FunctionExpression\`). Set to \`"any"\` if you want the rule to apply to any
JSDoc block throughout your files.
See the ["AST and Selectors"](../#advanced-ast-and-selectors)
section of our Advanced docs for more on the expected format.`,
items: {
anyOf: [
{
type: 'string',
},
{
additionalProperties: false,
properties: {
comment: {
type: 'string',
},
context: {
type: 'string',
},
},
type: 'object',
},
],
},
type: 'array',
},
mainDescription: {
description: `If you wish to override the main block description without changing the
default \`match-description\` (which can cascade to the \`tags\` with \`true\`),
you may use \`mainDescription\`:
\`\`\`js
{
'jsdoc/match-description': ['error', {
mainDescription: '[A-Z].*\\\\.',
tags: {
param: true,
returns: true
}
}]
}
\`\`\`
There is no need to add \`mainDescription: true\`, as by default, the main
block description (and only the main block description) is linted, though you
may disable checking it by setting it to \`false\`.
You may also provide an object with \`message\`:
\`\`\`js
{
'jsdoc/match-description': ['error', {
mainDescription: {
message: 'Capitalize first word of JSDoc block descriptions',
match: '[A-Z].*\\\\.'
},
tags: {
param: true,
returns: true
}
}]
}
\`\`\``,
oneOf: [
{
format: 'regex',
type: 'string',
},
{
type: 'boolean',
},
{
additionalProperties: false,
properties: {
match: {
oneOf: [
{
format: 'regex',
type: 'string',
},
{
type: 'boolean',
},
],
},
message: {
type: 'string',
},
},
type: 'object',
},
],
},
matchDescription: {
description: `You can supply your own expression to override the default, passing a
\`matchDescription\` string on the options object.
Defaults to using (only) the \`v\` flag, so
to add your own flags, encapsulate your expression as a string, but like a
literal, e.g., \`/[A-Z].*\\./vi\`.
\`\`\`js
{
'jsdoc/match-description': ['error', {matchDescription: '[A-Z].*\\\\.'}]
}
\`\`\``,
format: 'regex',
type: 'string',
},
message: {
description: `You may provide a custom default message by using the following format:
\`\`\`js
{
'jsdoc/match-description': ['error', {
message: 'The default description should begin with a capital letter.'
}]
}
\`\`\`
This can be overridden per tag or for the main block description by setting
\`message\` within \`tags\` or \`mainDescription\`, respectively.`,
type: 'string',
},
nonemptyTags: {
description: `If not set to \`false\`, will enforce that the following tags have at least
some content:
- \`@copyright\`
- \`@example\`
- \`@see\`
- \`@todo\`
If you supply your own tag description for any of the above tags in \`tags\`,
your description will take precedence.`,
type: 'boolean',
},
tags: {
description: `If you want different regular expressions to apply to tags, you may use
the \`tags\` option object:
\`\`\`js
{
'jsdoc/match-description': ['error', {tags: {
param: '\\\\- [A-Z].*\\\\.',
returns: '[A-Z].*\\\\.'
}}]
}
\`\`\`
In place of a string, you can also add \`true\` to indicate that a particular
tag should be linted with the \`matchDescription\` value (or the default).
\`\`\`js
{
'jsdoc/match-description': ['error', {tags: {
param: true,
returns: true
}}]
}
\`\`\`
Alternatively, you may supply an object with a \`message\` property to indicate
the error message for that tag.
\`\`\`js
{
'jsdoc/match-description': ['error', {tags: {
param: {message: 'Begin with a hyphen', match: '\\\\- [A-Z].*\\\\.'},
returns: {message: 'Capitalize for returns (the default)', match: true}
}}]
}
\`\`\`
The tags \`@param\`/\`@arg\`/\`@argument\` and \`@property\`/\`@prop\` will be properly
parsed to ensure that the matched "description" text includes only the text
after the name.
All other tags will treat the text following the tag name, a space, and
an optional curly-bracketed type expression (and another space) as part of
its "description" (e.g., for \`@returns {someType} some description\`, the
description is \`some description\` while for \`@some-tag xyz\`, the description
is \`xyz\`).`,
patternProperties: {
'.*': {
oneOf: [
{
format: 'regex',
type: 'string',
},
{
enum: [
true,
],
type: 'boolean',
},
{
additionalProperties: false,
properties: {
match: {
oneOf: [
{
format: 'regex',
type: 'string',
},
{
enum: [
true,
],
type: 'boolean',
},
],
},
message: {
type: 'string',
},
},
type: 'object',
},
],
},
},
type: 'object',
},
},
type: 'object',
},
],
type: 'suggestion',
},
});

View file

@ -0,0 +1,179 @@
import iterateJsdoc from '../iterateJsdoc.js';
export default iterateJsdoc(({
context,
info: {
lastIndex,
},
jsdoc,
report,
utils,
}) => {
const {
match,
} = context.options[0] || {};
if (!match) {
report('Rule `no-restricted-syntax` is missing a `match` option.');
return;
}
const {
allowName,
disallowName,
replacement,
tags = [
'*',
],
} = match[/** @type {import('../iterateJsdoc.js').Integer} */ (lastIndex)];
const allowNameRegex = allowName && utils.getRegexFromString(allowName);
const disallowNameRegex = disallowName && utils.getRegexFromString(disallowName);
let applicableTags = jsdoc.tags;
if (!tags.includes('*')) {
applicableTags = utils.getPresentTags(tags);
}
let reported = false;
for (const tag of applicableTags) {
const tagName = tag.name.replace(/^\[/v, '').replace(/(=.*)?\]$/v, '');
const allowed = !allowNameRegex || allowNameRegex.test(tagName);
const disallowed = disallowNameRegex && disallowNameRegex.test(tagName);
const hasRegex = allowNameRegex || disallowNameRegex;
if (hasRegex && allowed && !disallowed) {
continue;
}
if (!hasRegex && reported) {
continue;
}
const fixer = () => {
for (const src of tag.source) {
if (src.tokens.name) {
src.tokens.name = src.tokens.name.replace(
disallowNameRegex, replacement,
);
break;
}
}
};
let {
message,
} = match[/** @type {import('../iterateJsdoc.js').Integer} */ (lastIndex)];
if (!message) {
if (hasRegex) {
message = disallowed ?
`Only allowing names not matching \`${disallowNameRegex}\` but found "${tagName}".` :
`Only allowing names matching \`${allowNameRegex}\` but found "${tagName}".`;
} else {
message = `Prohibited context for "${tagName}".`;
}
}
utils.reportJSDoc(
message,
hasRegex ? tag : null,
// We could match up
disallowNameRegex && replacement !== undefined ?
fixer :
null,
false,
{
// Could also supply `context`, `comment`, `tags`
allowName,
disallowName,
name: tagName,
},
);
if (!hasRegex) {
reported = true;
}
}
}, {
matchContext: true,
meta: {
docs: {
description: 'Reports the name portion of a JSDoc tag if matching or not matching a given regular expression.',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/match-name.md#repos-sticky-header',
},
fixable: 'code',
schema: [
{
additionalProperties: false,
properties: {
match: {
description: `\`match\` is a required option containing an array of objects which determine
the conditions whereby a name is reported as being problematic.
These objects can have any combination of the following groups of optional
properties, all of which act to confine one another.
Note that \`comment\`, even if targeting a specific tag, is used to match the
whole block. So if a \`comment\` finds its specific tag, it may still apply
fixes found by the likes of \`disallowName\` even when a different tag has the
disallowed name. An alternative is to ensure that \`comment\` finds the specific
tag of the desired tag and/or name and no \`disallowName\` (or \`allowName\`) is
supplied. In such a case, only one error will be reported, but no fixer will
be applied, however.`,
items: {
additionalProperties: false,
properties: {
allowName: {
description: `Indicates which names are allowed for the given tag (or \`*\`).
Accepts a string regular expression (optionally wrapped between two
\`/\` delimiters followed by optional flags) used to match the name.`,
type: 'string',
},
comment: {
description: 'As with `context` but AST for the JSDoc block comment and types.',
type: 'string',
},
context: {
description: `AST to confine the allowing or disallowing to JSDoc blocks
associated with a particular context. See the
["AST and Selectors"](../#advanced-ast-and-selectors)
section of our Advanced docs for more on the expected format.`,
type: 'string',
},
disallowName: {
description: 'As with `allowName` but indicates names that are not allowed.',
type: 'string',
},
message: {
description: 'An optional custom message to use when there is a match.',
type: 'string',
},
replacement: {
description: `If \`disallowName\` is supplied and this value is present, it
will replace the matched \`disallowName\` text.`,
type: 'string',
},
tags: {
description: `This array should include tag names or \`*\` to indicate the
match will apply for all tags (except as confined by any context
properties). If \`*\` is not used, then these rules will only apply to
the specified tags. If \`tags\` is omitted, then \`*\` is assumed.`,
items: {
type: 'string',
},
type: 'array',
},
},
type: 'object',
},
type: 'array',
},
},
required: [
'match',
],
type: 'object',
},
],
type: 'suggestion',
},
});

View file

@ -0,0 +1,562 @@
import iterateJsdoc from '../iterateJsdoc.js';
/**
* @param {import('@es-joy/jsdoccomment').JsdocBlockWithInline} jsdoc
* @param {import('../iterateJsdoc.js').Utils} utils
* @param {number} requireSingleLineUnderCount
*/
const checkForShortTags = (jsdoc, utils, requireSingleLineUnderCount) => {
if (!requireSingleLineUnderCount || !jsdoc.tags.length) {
return false;
}
let lastLineWithTag = 0;
let isUnderCountLimit = false;
let hasMultiDescOrType = false;
const tagLines = jsdoc.source.reduce((acc, {
tokens: {
delimiter,
description: desc,
name,
postDelimiter,
postName,
postTag,
postType,
start,
tag,
type,
},
}, idx) => {
if (tag.length) {
lastLineWithTag = idx;
if (
start.length + delimiter.length + postDelimiter.length +
type.length + postType.length + name.length + postName.length +
tag.length + postTag.length + desc.length <
requireSingleLineUnderCount
) {
isUnderCountLimit = true;
}
return acc + 1;
} else if (desc.length || type.length) {
hasMultiDescOrType = true;
return acc;
}
return acc;
}, 0);
// Could be tagLines > 1
if (!hasMultiDescOrType && isUnderCountLimit && tagLines === 1) {
const fixer = () => {
const tokens = jsdoc.source[lastLineWithTag].tokens;
jsdoc.source = [
{
number: 0,
source: '',
tokens: utils.seedTokens({
delimiter: '/**',
description: tokens.description.trimEnd() + ' ',
end: '*/',
name: tokens.name,
postDelimiter: ' ',
postName: tokens.postName,
postTag: tokens.postTag,
postType: tokens.postType,
start: jsdoc.source[0].tokens.start,
tag: tokens.tag,
type: tokens.type,
}),
},
];
};
utils.reportJSDoc(
'Description is too short to be multi-line.',
null,
fixer,
);
return true;
}
return false;
};
/**
* @param {import('@es-joy/jsdoccomment').JsdocBlockWithInline} jsdoc
* @param {import('../iterateJsdoc.js').Utils} utils
* @param {number} requireSingleLineUnderCount
*/
const checkForShortDescriptions = (jsdoc, utils, requireSingleLineUnderCount) => {
if (!requireSingleLineUnderCount || jsdoc.tags.length) {
return false;
}
let lastLineWithDesc = 0;
let isUnderCountLimit = false;
const descLines = jsdoc.source.reduce((acc, {
tokens: {
delimiter,
description: desc,
postDelimiter,
start,
},
}, idx) => {
if (desc.length) {
lastLineWithDesc = idx;
if (
start.length + delimiter.length + postDelimiter.length + desc.length <
requireSingleLineUnderCount
) {
isUnderCountLimit = true;
}
return acc + 1;
}
return acc;
}, 0);
// Could be descLines > 1
if (isUnderCountLimit && descLines === 1) {
const fixer = () => {
const desc = jsdoc.source[lastLineWithDesc].tokens.description;
jsdoc.source = [
{
number: 0,
source: '',
tokens: utils.seedTokens({
delimiter: '/**',
description: desc.trimEnd() + ' ',
end: '*/',
postDelimiter: ' ',
start: jsdoc.source[0].tokens.start,
}),
},
];
};
utils.reportJSDoc(
'Description is too short to be multi-line.',
null,
fixer,
);
return true;
}
return false;
};
export default iterateJsdoc(({
context,
jsdoc,
utils,
}) => {
const {
allowMultipleTags = true,
minimumLengthForMultiline = Number.POSITIVE_INFINITY,
multilineTags = [
'*',
],
noFinalLineText = true,
noMultilineBlocks = false,
noSingleLineBlocks = false,
noZeroLineText = true,
requireSingleLineUnderCount = null,
singleLineTags = [
'lends', 'type',
],
} = context.options[0] || {};
const {
source: [
{
tokens,
},
],
} = jsdoc;
const {
description,
tag,
} = tokens;
const sourceLength = jsdoc.source.length;
/**
* @param {string} tagName
* @returns {boolean}
*/
const isInvalidSingleLine = (tagName) => {
return noSingleLineBlocks &&
(!tagName ||
!singleLineTags.includes(tagName) && !singleLineTags.includes('*'));
};
if (sourceLength === 1) {
if (!isInvalidSingleLine(tag.slice(1))) {
return;
}
const fixer = () => {
utils.makeMultiline();
};
utils.reportJSDoc(
'Single line blocks are not permitted by your configuration.',
null,
fixer,
true,
);
return;
}
if (checkForShortDescriptions(jsdoc, utils, requireSingleLineUnderCount)
) {
return;
}
if (checkForShortTags(jsdoc, utils, requireSingleLineUnderCount)
) {
return;
}
const lineChecks = () => {
if (
noZeroLineText &&
(tag || description)
) {
const fixer = () => {
const line = {
...tokens,
};
utils.emptyTokens(tokens);
const {
tokens: {
delimiter,
start,
},
} = jsdoc.source[1];
utils.addLine(1, {
...line,
delimiter,
start,
});
};
utils.reportJSDoc(
'Should have no text on the "0th" line (after the `/**`).',
null,
fixer,
);
return;
}
const finalLine = jsdoc.source[jsdoc.source.length - 1];
const finalLineTokens = finalLine.tokens;
if (
noFinalLineText &&
finalLineTokens.description.trim()
) {
const fixer = () => {
const line = {
...finalLineTokens,
};
line.description = line.description.trimEnd();
const {
delimiter,
} = line;
for (const prop of [
'delimiter',
'postDelimiter',
'tag',
'type',
'lineEnd',
'postType',
'postTag',
'name',
'postName',
'description',
]) {
finalLineTokens[
/**
* @type {"delimiter"|"postDelimiter"|"tag"|"type"|
* "lineEnd"|"postType"|"postTag"|"name"|
* "postName"|"description"}
*/ (
prop
)
] = '';
}
utils.addLine(jsdoc.source.length - 1, {
...line,
delimiter,
end: '',
});
};
utils.reportJSDoc(
'Should have no text on the final line (before the `*/`).',
null,
fixer,
);
}
};
if (noMultilineBlocks) {
if (
jsdoc.tags.length &&
(multilineTags.includes('*') || utils.hasATag(multilineTags))
) {
lineChecks();
return;
}
if (jsdoc.description.length >= minimumLengthForMultiline) {
lineChecks();
return;
}
if (
noSingleLineBlocks &&
(!jsdoc.tags.length ||
!utils.filterTags(({
tag: tg,
}) => {
return !isInvalidSingleLine(tg);
}).length)
) {
utils.reportJSDoc(
'Multiline JSDoc blocks are prohibited by ' +
'your configuration but fixing would result in a single ' +
'line block which you have prohibited with `noSingleLineBlocks`.',
);
return;
}
if (jsdoc.tags.length > 1) {
if (!allowMultipleTags) {
utils.reportJSDoc(
'Multiline JSDoc blocks are prohibited by ' +
'your configuration but the block has multiple tags.',
);
return;
}
} else if (jsdoc.tags.length === 1 && jsdoc.description.trim()) {
if (!allowMultipleTags) {
utils.reportJSDoc(
'Multiline JSDoc blocks are prohibited by ' +
'your configuration but the block has a description with a tag.',
);
return;
}
} else {
const fixer = () => {
jsdoc.source = [
{
number: 1,
source: '',
tokens: jsdoc.source.reduce((obj, {
tokens: {
description: desc,
lineEnd,
name: nme,
postName,
postTag,
postType,
tag: tg,
type: typ,
},
}) => {
if (typ) {
obj.type = typ;
}
if (tg && typ && nme) {
obj.postType = postType;
}
if (nme) {
obj.name += nme;
}
if (nme && desc) {
obj.postName = postName;
}
obj.description += desc;
const nameOrDescription = obj.description || obj.name;
if (
nameOrDescription && nameOrDescription.slice(-1) !== ' '
) {
obj.description += ' ';
}
obj.lineEnd = lineEnd;
// Already filtered for multiple tags
obj.tag += tg;
if (tg) {
obj.postTag = postTag || ' ';
}
return obj;
}, utils.seedTokens({
delimiter: '/**',
end: '*/',
postDelimiter: ' ',
})),
},
];
};
utils.reportJSDoc(
'Multiline JSDoc blocks are prohibited by ' +
'your configuration.',
null,
fixer,
);
return;
}
}
lineChecks();
}, {
iterateAllJsdocs: true,
meta: {
docs: {
description: 'Controls how and whether JSDoc blocks can be expressed as single or multiple line blocks.',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/multiline-blocks.md#repos-sticky-header',
},
fixable: 'code',
schema: [
{
additionalProperties: false,
properties: {
allowMultipleTags: {
description: `If \`noMultilineBlocks\` is set to \`true\` with this option and multiple tags are
found in a block, an error will not be reported.
Since multiple-tagged lines cannot be collapsed into a single line, this option
prevents them from being reported. Set to \`false\` if you really want to report
any blocks.
This option will also be applied when there is a block description and a single
tag (since a description cannot precede a tag on a single line, and also
cannot be reliably added after the tag either).
Defaults to \`true\`.`,
type: 'boolean',
},
minimumLengthForMultiline: {
description: `If \`noMultilineBlocks\` is set with this numeric option, multiline blocks will
be permitted if containing at least the given amount of text.
If not set, multiline blocks will not be permitted regardless of length unless
a relevant tag is present and \`multilineTags\` is set.
Defaults to not being in effect.`,
type: 'integer',
},
multilineTags: {
anyOf: [
{
enum: [
'*',
],
type: 'string',
}, {
items: {
type: 'string',
},
type: 'array',
},
],
description: `If \`noMultilineBlocks\` is set with this option, multiline blocks may be allowed
regardless of length as long as a tag or a tag of a certain type is present.
If \`*\` is included in the array, the presence of a tags will allow for
multiline blocks (but not when without any tags unless the amount of text is
over an amount specified by \`minimumLengthForMultiline\`).
If the array does not include \`*\` but lists certain tags, the presence of
such a tag will cause multiline blocks to be allowed.
You may set this to an empty array to prevent any tag from permitting multiple
lines.
Defaults to \`['*']\`.`,
},
noFinalLineText: {
description: `For multiline blocks, any non-whitespace text preceding the \`*/\` on the final
line will be reported. (Text preceding a newline is not reported.)
\`noMultilineBlocks\` will have priority over this rule if it applies.
Defaults to \`true\`.`,
type: 'boolean',
},
noMultilineBlocks: {
description: `Requires that JSDoc blocks are restricted to single lines only unless impacted
by the options \`minimumLengthForMultiline\`, \`multilineTags\`, or
\`allowMultipleTags\`.
Defaults to \`false\`.`,
type: 'boolean',
},
noSingleLineBlocks: {
description: `If this is \`true\`, any single line blocks will be reported, except those which
are whitelisted in \`singleLineTags\`.
Defaults to \`false\`.`,
type: 'boolean',
},
noZeroLineText: {
description: `For multiline blocks, any non-whitespace text immediately after the \`/**\` and
space will be reported. (Text after a newline is not reported.)
\`noMultilineBlocks\` will have priority over this rule if it applies.
Defaults to \`true\`.`,
type: 'boolean',
},
requireSingleLineUnderCount: {
description: `If this number is set, it indicates a minimum line width for a single line of
JSDoc content spread over a multi-line comment block. If a single line is under
the minimum length, it will be reported so as to enforce single line JSDoc blocks
for such cases. Blocks are not reported which have multi-line descriptions,
multiple tags, a block description and tag, or tags with multi-line types or
descriptions.
Defaults to \`null\`.`,
type: 'number',
},
singleLineTags: {
description: `An array of tags which can nevertheless be allowed as single line blocks when
\`noSingleLineBlocks\` is set. You may set this to a empty array to
cause all single line blocks to be reported. If \`'*'\` is present, then
the presence of a tag will allow single line blocks (but not if a tag is
missing).
Defaults to \`['lends', 'type']\`.`,
items: {
type: 'string',
},
type: 'array',
},
},
type: 'object',
},
],
type: 'suggestion',
},
});

View file

@ -0,0 +1,127 @@
import iterateJsdoc from '../iterateJsdoc.js';
import {
parse as commentParser,
} from 'comment-parser';
// Neither a single nor 3+ asterisks are valid JSDoc per
// https://jsdoc.app/about-getting-started.html#adding-documentation-comments-to-your-code
const commentRegexp = /^\/\*(?!\*)/v;
const extraAsteriskCommentRegexp = /^\/\*{3,}/v;
export default iterateJsdoc(({
allComments,
context,
makeReport,
sourceCode,
}) => {
const [
{
ignore = [
'ts-check',
'ts-expect-error',
'ts-ignore',
'ts-nocheck',
],
preventAllMultiAsteriskBlocks = false,
} = {},
] = context.options;
let extraAsterisks = false;
const nonJsdocNodes = /** @type {import('estree').Node[]} */ (
allComments
).filter((comment) => {
const commentText = sourceCode.getText(comment);
const initialText = commentText.replace(commentRegexp, '').trimStart();
if ([
'eslint',
].some((directive) => {
return initialText.startsWith(directive);
})) {
return false;
}
let sliceIndex = 2;
if (!commentRegexp.test(commentText)) {
const multiline = extraAsteriskCommentRegexp.exec(commentText)?.[0];
if (!multiline) {
return false;
}
sliceIndex = multiline.length;
extraAsterisks = true;
if (preventAllMultiAsteriskBlocks) {
return true;
}
}
const tags = (commentParser(
`${commentText.slice(0, 2)}*${commentText.slice(sliceIndex)}`,
)[0] || {}).tags ?? [];
return tags.length && !tags.some(({
tag,
}) => {
return ignore.includes(tag);
});
});
if (!nonJsdocNodes.length) {
return;
}
for (const node of nonJsdocNodes) {
const report = /** @type {import('../iterateJsdoc.js').MakeReport} */ (
makeReport
)(context, node);
// eslint-disable-next-line no-loop-func
const fix = /** @type {import('eslint').Rule.ReportFixer} */ (fixer) => {
const text = sourceCode.getText(node);
return fixer.replaceText(
node,
extraAsterisks ?
text.replace(extraAsteriskCommentRegexp, '/**') :
text.replace('/*', '/**'),
);
};
report('Expected JSDoc-like comment to begin with two asterisks.', fix);
}
}, {
checkFile: true,
meta: {
docs: {
description: 'This rule checks for multi-line-style comments which fail to meet the criteria of a JSDoc block.',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/no-bad-blocks.md#repos-sticky-header',
},
fixable: 'code',
schema: [
{
additionalProperties: false,
properties: {
ignore: {
description: `An array of directives that will not be reported if present at the beginning of
a multi-comment block and at-sign \`/* @\`.
Defaults to \`['ts-check', 'ts-expect-error', 'ts-ignore', 'ts-nocheck']\`
(some directives [used by TypeScript](https://www.typescriptlang.org/docs/handbook/intro-to-js-ts.html#ts-check)).`,
items: {
type: 'string',
},
type: 'array',
},
preventAllMultiAsteriskBlocks: {
description: `A boolean (defaulting to \`false\`) which if \`true\` will prevent all
JSDoc-like blocks with more than two initial asterisks even those without
apparent tag content.`,
type: 'boolean',
},
},
type: 'object',
},
],
type: 'layout',
},
});

View file

@ -0,0 +1,69 @@
import iterateJsdoc from '../iterateJsdoc.js';
const anyWhitespaceLines = /^\s*$/v;
const atLeastTwoLinesWhitespace = /^[ \t]*\n[ \t]*\n\s*$/v;
export default iterateJsdoc(({
jsdoc,
utils,
}) => {
const {
description,
descriptions,
lastDescriptionLine,
} = utils.getDescription();
const regex = jsdoc.tags.length ?
anyWhitespaceLines :
atLeastTwoLinesWhitespace;
if (descriptions.length && regex.test(description)) {
if (jsdoc.tags.length) {
utils.reportJSDoc(
'There should be no blank lines in block descriptions followed by tags.',
{
line: lastDescriptionLine,
},
() => {
utils.setBlockDescription(() => {
// Remove all lines
return [];
});
},
);
} else {
utils.reportJSDoc(
'There should be no extra blank lines in block descriptions not followed by tags.',
{
line: lastDescriptionLine,
},
() => {
utils.setBlockDescription((info, seedTokens) => {
return [
// Keep the starting line
{
number: 0,
source: '',
tokens: seedTokens({
...info,
description: '',
}),
},
];
});
},
);
}
}
}, {
iterateAllJsdocs: true,
meta: {
docs: {
description: 'If tags are present, this rule will prevent empty lines in the block description. If no tags are present, this rule will prevent extra empty lines in the block description.',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/no-blank-block-descriptions.md#repos-sticky-header',
},
fixable: 'whitespace',
schema: [],
type: 'layout',
},
});

View file

@ -0,0 +1,55 @@
import iterateJsdoc from '../iterateJsdoc.js';
export default iterateJsdoc(({
context,
jsdoc,
utils,
}) => {
if (jsdoc.tags.length) {
return;
}
const {
description,
lastDescriptionLine,
} = utils.getDescription();
if (description.trim()) {
return;
}
const {
enableFixer,
} = context.options[0] || {};
utils.reportJSDoc(
'No empty blocks',
{
line: lastDescriptionLine,
},
enableFixer ? () => {
jsdoc.source.splice(0);
} : null,
);
}, {
iterateAllJsdocs: true,
meta: {
docs: {
description: 'Removes empty blocks with nothing but possibly line breaks',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/no-blank-blocks.md#repos-sticky-header',
},
fixable: 'code',
schema: [
{
additionalProperties: false,
properties: {
enableFixer: {
description: 'Whether or not to auto-remove the blank block. Defaults to `false`.',
type: 'boolean',
},
},
type: 'object',
},
],
type: 'suggestion',
},
});

View file

@ -0,0 +1,104 @@
import iterateJsdoc from '../iterateJsdoc.js';
export default iterateJsdoc(({
context,
utils,
}) => {
const {
noOptionalParamNames,
} = context.options[0] || {};
const paramTags = utils.getPresentTags([
'param', 'arg', 'argument',
]);
for (const tag of paramTags) {
if (noOptionalParamNames && tag.optional) {
utils.reportJSDoc(`Optional param names are not permitted on @${tag.tag}.`, tag, () => {
utils.changeTag(tag, {
name: tag.name.replace(/([^=]*)(=.+)?/v, '$1'),
});
});
} else if (tag.default) {
utils.reportJSDoc(`Defaults are not permitted on @${tag.tag}.`, tag, () => {
utils.changeTag(tag, {
name: tag.name.replace(/([^=]*)(=.+)?/v, '[$1]'),
});
});
}
}
const defaultTags = utils.getPresentTags([
'default', 'defaultvalue',
]);
for (const tag of defaultTags) {
if (tag.description.trim()) {
utils.reportJSDoc(`Default values are not permitted on @${tag.tag}.`, tag, () => {
utils.changeTag(tag, {
description: '',
postTag: '',
});
});
}
}
}, {
contextDefaults: true,
meta: {
docs: {
description: 'This rule reports defaults being used on the relevant portion of `@param` or `@default`.',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/no-defaults.md#repos-sticky-header',
},
fixable: 'code',
schema: [
{
additionalProperties: false,
properties: {
contexts: {
description: `Set this to an array of strings representing the AST context (or an object with
optional \`context\` and \`comment\` properties) where you wish the rule to be applied.
\`context\` defaults to \`any\` and \`comment\` defaults to no specific comment context.
Overrides the default contexts (\`ArrowFunctionExpression\`, \`FunctionDeclaration\`,
\`FunctionExpression\`). Set to \`"any"\` if you want
the rule to apply to any JSDoc block throughout your files (as is necessary
for finding function blocks not attached to a function declaration or
expression, i.e., \`@callback\` or \`@function\` (or its aliases \`@func\` or
\`@method\`) (including those associated with an \`@interface\`).
See the ["AST and Selectors"](../#advanced-ast-and-selectors)
section of our Advanced docs for more on the expected format.`,
items: {
anyOf: [
{
type: 'string',
},
{
additionalProperties: false,
properties: {
comment: {
type: 'string',
},
context: {
type: 'string',
},
},
type: 'object',
},
],
},
type: 'array',
},
noOptionalParamNames: {
description: `Set this to \`true\` to report the presence of optional parameters. May be
used if the project is insisting on optionality being indicated by
the presence of ES6 default parameters (bearing in mind that such
"defaults" are only applied when the supplied value is missing or
\`undefined\` but not for \`null\` or other "falsey" values).`,
type: 'boolean',
},
},
type: 'object',
},
],
type: 'suggestion',
},
});

View file

@ -0,0 +1,215 @@
import iterateJsdoc from '../iterateJsdoc.js';
/**
* @typedef {{
* comment: string,
* context: string,
* message: string,
* minimum: import('../iterateJsdoc.js').Integer
* }} ContextObject
*/
/**
* @typedef {string|ContextObject} Context
*/
/**
* @param {import('../iterateJsdoc.js').StateObject} state
* @returns {void}
*/
const setDefaults = (state) => {
if (!state.selectorMap) {
state.selectorMap = {};
}
};
/**
* @param {import('../iterateJsdoc.js').StateObject} state
* @param {string} selector
* @param {string} comment
* @returns {void}
*/
const incrementSelector = (state, selector, comment) => {
if (!state.selectorMap[selector]) {
state.selectorMap[selector] = {};
}
if (!state.selectorMap[selector][comment]) {
state.selectorMap[selector][comment] = 0;
}
state.selectorMap[selector][comment]++;
};
export default iterateJsdoc(({
context,
info: {
comment,
},
state,
utils,
}) => {
if (!context.options[0]) {
// Handle error later
return;
}
/**
* @type {Context[]}
*/
const contexts = context.options[0].contexts;
const {
contextStr,
} = utils.findContext(contexts, comment);
setDefaults(state);
incrementSelector(state, contextStr, String(comment));
}, {
contextSelected: true,
exit ({
context,
settings,
state,
}) {
if (!context.options.length && !settings.contexts) {
context.report({
loc: {
end: {
column: 1,
line: 1,
},
start: {
column: 1,
line: 1,
},
},
message: 'Rule `no-missing-syntax` is missing a `contexts` option.',
});
return;
}
setDefaults(state);
/**
* @type {Context[]}
*/
const contexts = (context.options[0] ?? {}).contexts ?? settings?.contexts;
// Report when MISSING
contexts.some((cntxt) => {
const contextStr = typeof cntxt === 'object' ? cntxt.context ?? 'any' : cntxt;
const comment = typeof cntxt === 'string' ? '' : cntxt?.comment ?? '';
const contextKey = contextStr === 'any' ? 'undefined' : contextStr;
if (
(!state.selectorMap[contextKey] ||
!state.selectorMap[contextKey][comment] ||
state.selectorMap[contextKey][comment] < (
// @ts-expect-error comment would need an object, not string
cntxt?.minimum ?? 1
)) &&
(contextStr !== 'any' || Object.values(state.selectorMap).every((cmmnt) => {
return !cmmnt[comment] || cmmnt[comment] < (
// @ts-expect-error comment would need an object, not string
cntxt?.minimum ?? 1
);
}))
) {
const message = typeof cntxt === 'string' ?
'Syntax is required: {{context}}' :
cntxt?.message ?? ('Syntax is required: {{context}}' +
(comment ? ' with {{comment}}' : ''));
context.report({
data: {
comment,
context: contextStr,
},
loc: {
end: {
column: 1,
line: 1,
},
start: {
column: 1,
line: 1,
},
},
message,
});
return true;
}
return false;
});
},
matchContext: true,
meta: {
docs: {
description: 'Reports when certain comment structures are always expected.',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/no-missing-syntax.md#repos-sticky-header',
},
schema: [
{
additionalProperties: false,
properties: {
contexts: {
description: `Set this to an array of strings representing the AST context (or an object with
optional \`context\` and \`comment\` properties) where you wish the rule to be applied.
\`context\` defaults to \`any\` and \`comment\` defaults to no specific comment context.
Use the \`minimum\` property (defaults to 1) to indicate how many are required
for the rule to be reported.
Use the \`message\` property to indicate the specific error to be shown when an
error is reported for that context being found missing. You may use
\`{{context}}\` and \`{{comment}}\` with such messages. Defaults to
\`"Syntax is required: {{context}}"\`, or with a comment, to
\`"Syntax is required: {{context}} with {{comment}}"\`.
Set to \`"any"\` if you want the rule to apply to any JSDoc block throughout
your files (as is necessary for finding function blocks not attached to a
function declaration or expression, i.e., \`@callback\` or \`@function\` (or its
aliases \`@func\` or \`@method\`) (including those associated with an \`@interface\`).
See the ["AST and Selectors"](../#advanced-ast-and-selectors)
section of our Advanced docs for more on the expected format.`,
items: {
anyOf: [
{
type: 'string',
},
{
additionalProperties: false,
properties: {
comment: {
type: 'string',
},
context: {
type: 'string',
},
message: {
type: 'string',
},
minimum: {
type: 'integer',
},
},
type: 'object',
},
],
},
type: 'array',
},
},
type: 'object',
},
],
type: 'suggestion',
},
});

View file

@ -0,0 +1,162 @@
import iterateJsdoc from '../iterateJsdoc.js';
const middleAsterisksBlockWS = /^([\t ]|\*(?!\*))+/v;
const middleAsterisksNoBlockWS = /^\*+/v;
const endAsterisksSingleLineBlockWS = /\*((?:\*|(?: |\t))*)\*$/v;
const endAsterisksMultipleLineBlockWS = /((?:\*|(?: |\t))*)\*$/v;
const endAsterisksSingleLineNoBlockWS = /\*(\**)\*$/v;
const endAsterisksMultipleLineNoBlockWS = /(\**)\*$/v;
export default iterateJsdoc(({
context,
jsdoc,
utils,
}) => {
const {
allowWhitespace = false,
preventAtEnd = true,
preventAtMiddleLines = true,
} = context.options[0] || {};
const middleAsterisks = allowWhitespace ? middleAsterisksNoBlockWS : middleAsterisksBlockWS;
jsdoc.source.some(({
number,
tokens,
}) => {
const {
delimiter,
description,
end,
name,
postDelimiter,
tag,
type,
} = tokens;
if (
preventAtMiddleLines &&
!end && !tag && !type && !name &&
(
!allowWhitespace && middleAsterisks.test(description) ||
allowWhitespace && middleAsterisks.test(postDelimiter + description)
)
) {
// console.log('description', JSON.stringify(description));
const fix = () => {
tokens.description = description.replace(middleAsterisks, '');
};
utils.reportJSDoc(
'Should be no multiple asterisks on middle lines.',
{
line: number,
},
fix,
true,
);
return true;
}
if (!preventAtEnd || !end) {
return false;
}
const isSingleLineBlock = delimiter === '/**';
const delim = isSingleLineBlock ? '*' : delimiter;
const endAsterisks = allowWhitespace ?
(isSingleLineBlock ? endAsterisksSingleLineNoBlockWS : endAsterisksMultipleLineNoBlockWS) :
(isSingleLineBlock ? endAsterisksSingleLineBlockWS : endAsterisksMultipleLineBlockWS);
const endingAsterisksAndSpaces = (
allowWhitespace ? postDelimiter + description + delim : description + delim
).match(
endAsterisks,
);
if (
!endingAsterisksAndSpaces ||
!isSingleLineBlock && endingAsterisksAndSpaces[1] && !endingAsterisksAndSpaces[1].trim()
) {
return false;
}
const endFix = () => {
if (!isSingleLineBlock) {
tokens.delimiter = '';
}
tokens.description = (description + delim).replace(endAsterisks, '');
};
utils.reportJSDoc(
'Should be no multiple asterisks on end lines.',
{
line: number,
},
endFix,
true,
);
return true;
});
}, {
iterateAllJsdocs: true,
meta: {
docs: {
description: 'Prevents use of multiple asterisks at the beginning of lines.',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/no-multi-asterisks.md#repos-sticky-header',
},
fixable: 'code',
schema: [
{
additionalProperties: false,
properties: {
allowWhitespace: {
description: `Set to \`true\` if you wish to allow asterisks after a space (as with Markdown):
\`\`\`js
/**
* *bold* text
*/
\`\`\`
Defaults to \`false\`.`,
type: 'boolean',
},
preventAtEnd: {
description: `Prevent the likes of this:
\`\`\`js
/**
*
*
**/
\`\`\`
Defaults to \`true\`.`,
type: 'boolean',
},
preventAtMiddleLines: {
description: `Prevent the likes of this:
\`\`\`js
/**
*
**
*/
\`\`\`
Defaults to \`true\`.`,
type: 'boolean',
},
},
type: 'object',
},
],
type: 'suggestion',
},
});

View file

@ -0,0 +1,72 @@
import {
buildForbidRuleDefinition,
} from '../buildForbidRuleDefinition.js';
export default buildForbidRuleDefinition({
getContexts (context, report) {
if (!context.options.length) {
report('Rule `no-restricted-syntax` is missing a `contexts` option.');
return false;
}
const {
contexts,
} = context.options[0];
return contexts;
},
schema: [
{
additionalProperties: false,
properties: {
contexts: {
description: `Set this to an array of strings representing the AST context (or an object with
\`context\` and \`comment\` properties) where you wish the rule to be applied.
\`context\` defaults to \`any\` and \`comment\` defaults to no specific comment context.
Use the \`message\` property to indicate the specific error to be shown when an
error is reported for that context being found. Defaults to
\`"Syntax is restricted: {{context}}"\`, or with a comment, to
\`"Syntax is restricted: {{context}} with {{comment}}"\`.
Set to \`"any"\` if you want the rule to apply to any JSDoc block throughout
your files (as is necessary for finding function blocks not attached to a
function declaration or expression, i.e., \`@callback\` or \`@function\` (or its
aliases \`@func\` or \`@method\`) (including those associated with an \`@interface\`).
See the ["AST and Selectors"](../#advanced-ast-and-selectors)
section of our Advanced docs for more on the expected format.`,
items: {
anyOf: [
{
type: 'string',
},
{
additionalProperties: false,
properties: {
comment: {
type: 'string',
},
context: {
type: 'string',
},
message: {
type: 'string',
},
},
type: 'object',
},
],
},
type: 'array',
},
},
required: [
'contexts',
],
type: 'object',
},
],
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/no-restricted-syntax.md#repos-sticky-header',
});

View file

@ -0,0 +1,108 @@
import iterateJsdoc from '../iterateJsdoc.js';
/**
* @param {import('comment-parser').Line} line
*/
const removeType = ({
tokens,
}) => {
tokens.postTag = '';
tokens.type = '';
};
export default iterateJsdoc(({
node,
utils,
}) => {
if (!utils.isIteratingFunctionOrVariable() && !utils.isVirtualFunction()) {
return;
}
const tags = utils.getPresentTags([
'param', 'arg', 'argument', 'returns', 'return',
]);
for (const tag of tags) {
if (tag.type) {
utils.reportJSDoc(`Types are not permitted on @${tag.tag}.`, tag, () => {
for (const source of tag.source) {
removeType(source);
}
});
}
}
if (node?.type === 'ClassDeclaration') {
const propertyTags = utils.getPresentTags([
'prop', 'property',
]);
for (const tag of propertyTags) {
if (tag.type) {
utils.reportJSDoc(`Types are not permitted on @${tag.tag} in the supplied context.`, tag, () => {
for (const source of tag.source) {
removeType(source);
}
});
}
}
}
}, {
contextDefaults: [
'ArrowFunctionExpression', 'FunctionDeclaration', 'FunctionExpression', 'TSDeclareFunction',
// Add this to above defaults
'TSMethodSignature', 'ClassDeclaration',
],
meta: {
docs: {
description: 'This rule reports types being used on `@param` or `@returns` (redundant with TypeScript).',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/no-types.md#repos-sticky-header',
},
fixable: 'code',
schema: [
{
additionalProperties: false,
properties: {
contexts: {
description: `Set this to an array of strings representing the AST context (or an object with
optional \`context\` and \`comment\` properties) where you wish the rule to be applied.
\`context\` defaults to \`any\` and \`comment\` defaults to no specific comment context.
Overrides the default contexts (\`ArrowFunctionExpression\`, \`FunctionDeclaration\`,
\`FunctionExpression\`, \`TSDeclareFunction\`, \`TSMethodSignature\`,
\`ClassDeclaration\`). Set to \`"any"\` if you want
the rule to apply to any JSDoc block throughout your files (as is necessary
for finding function blocks not attached to a function declaration or
expression, i.e., \`@callback\` or \`@function\` (or its aliases \`@func\` or
\`@method\`) (including those associated with an \`@interface\`).
See the ["AST and Selectors"](../#advanced-ast-and-selectors)
section of our Advanced docs for more on the expected format.`,
items: {
anyOf: [
{
type: 'string',
},
{
additionalProperties: false,
properties: {
comment: {
type: 'string',
},
context: {
type: 'string',
},
},
type: 'object',
},
],
},
type: 'array',
},
},
type: 'object',
},
],
type: 'suggestion',
},
});

View file

@ -0,0 +1,677 @@
import iterateJsdoc, {
parseComment,
} from '../iterateJsdoc.js';
import {
getJSDocComment,
parse as parseType,
traverse,
tryParse as tryParseType,
} from '@es-joy/jsdoccomment';
import {
parseImportsExports,
} from 'parse-imports-exports';
const extraTypes = [
'null', 'undefined', 'void', 'string', 'boolean', 'object',
'function', 'symbol',
'number', 'bigint', 'NaN', 'Infinity',
'any', '*', 'never', 'unknown', 'const',
'this', 'true', 'false',
'Array', 'Object', 'RegExp', 'Date', 'Function', 'Intl',
];
const globalTypes = [
'globalThis', 'global', 'window', 'self',
];
const typescriptGlobals = [
// https://www.typescriptlang.org/docs/handbook/utility-types.html
'Awaited',
'Partial',
'Required',
'Readonly',
'Record',
'Pick',
'Omit',
'Exclude',
'Extract',
'NonNullable',
'Parameters',
'ConstructorParameters',
'ReturnType',
'InstanceType',
'ThisParameterType',
'OmitThisParameter',
'ThisType',
'Uppercase',
'Lowercase',
'Capitalize',
'Uncapitalize',
];
/**
* @param {string|false|undefined} [str]
* @returns {undefined|string|false}
*/
const stripPseudoTypes = (str) => {
return str && str.replace(/(?:\.|<>|\.<>|\[\])$/v, '');
};
export default iterateJsdoc(({
context,
node,
report,
settings,
sourceCode,
state,
utils,
}) => {
/** @type {string[]} */
const foundTypedefValues = [];
const {
scopeManager,
} = sourceCode;
// When is this ever `null`?
const globalScope = /** @type {import('eslint').Scope.Scope} */ (
scopeManager.globalScope
);
const
/**
* @type {{
* checkUsedTypedefs: boolean
* definedTypes: string[],
* disableReporting: boolean,
* markVariablesAsUsed: boolean,
* }}
*/ {
checkUsedTypedefs = false,
definedTypes = [],
disableReporting = false,
markVariablesAsUsed = true,
} = context.options[0] || {};
/** @type {(string|undefined)[]} */
let definedPreferredTypes = [];
const {
mode,
preferredTypes,
structuredTags,
} = settings;
if (Object.keys(preferredTypes).length) {
definedPreferredTypes = /** @type {string[]} */ (Object.values(preferredTypes).map((preferredType) => {
if (typeof preferredType === 'string') {
// May become an empty string but will be filtered out below
return stripPseudoTypes(preferredType);
}
if (!preferredType) {
return undefined;
}
if (typeof preferredType !== 'object') {
utils.reportSettings(
'Invalid `settings.jsdoc.preferredTypes`. Values must be falsy, a string, or an object.',
);
}
return stripPseudoTypes(preferredType.replacement);
})
.filter(Boolean));
}
const allComments = sourceCode.getAllComments();
const comments = allComments
.filter((comment) => {
return (/^\*(?!\*)/v).test(comment.value);
})
.map((commentNode) => {
return parseComment(commentNode, '');
});
const globals = allComments
.filter((comment) => {
return (/^\s*globals/v).test(comment.value);
}).flatMap((commentNode) => {
return commentNode.value.replace(/^\s*globals/v, '').trim().split(/,\s*/v);
}).concat(Object.keys(context.languageOptions.globals ?? []));
const typedefs = comments
.flatMap((doc) => {
return doc.tags.filter(({
tag,
}) => {
return utils.isNameOrNamepathDefiningTag(tag) && ![
'arg',
'argument',
'param',
'prop',
'property',
].includes(tag);
});
});
const typedefDeclarations = typedefs
.map((tag) => {
return tag.name;
});
const importTags = settings.mode === 'typescript' ? /** @type {string[]} */ (comments.flatMap((doc) => {
return doc.tags.filter(({
tag,
}) => {
return tag === 'import';
});
}).flatMap((tag) => {
const {
description,
name,
type,
} = tag;
const typePart = type ? `{${type}} ` : '';
const imprt = 'import ' + (description ?
`${typePart}${name} ${description}` :
`${typePart}${name}`);
const importsExports = parseImportsExports(imprt.trim());
const types = [];
const namedImports = Object.values(importsExports.namedImports || {})[0]?.[0];
if (namedImports) {
if (namedImports.default) {
types.push(namedImports.default);
}
if (namedImports.names) {
types.push(...Object.keys(namedImports.names));
}
}
const namespaceImports = Object.values(importsExports.namespaceImports || {})[0]?.[0];
if (namespaceImports) {
if (namespaceImports.namespace) {
types.push(namespaceImports.namespace);
}
if (namespaceImports.default) {
types.push(namespaceImports.default);
}
}
return types;
}).filter(Boolean)) : [];
const ancestorNodes = [];
let currentNode = node;
// No need for Program node?
while (currentNode?.parent) {
ancestorNodes.push(currentNode);
currentNode = currentNode.parent;
}
/**
* @param {import('eslint').Rule.Node} ancestorNode
* @returns {import('comment-parser').Spec[]}
*/
const getTemplateTags = function (ancestorNode) {
const commentNode = getJSDocComment(sourceCode, ancestorNode, settings);
if (!commentNode) {
return [];
}
const jsdc = parseComment(commentNode, '');
return jsdc.tags.filter((tag) => {
return tag.tag === 'template';
});
};
// `currentScope` may be `null` or `Program`, so in such a case,
// we look to present tags instead
const templateTags = ancestorNodes.length ?
ancestorNodes.flatMap((ancestorNode) => {
return getTemplateTags(ancestorNode);
}) :
utils.getPresentTags([
'template',
]);
const closureGenericTypes = templateTags.flatMap((tag) => {
return utils.parseClosureTemplateTag(tag);
});
// In modules, including Node, there is a global scope at top with the
// Program scope inside
const cjsOrESMScope = globalScope.childScopes[0]?.block?.type === 'Program';
/**
* @param {import("eslint").Scope.Scope | null} scope
* @returns {Set<string>}
*/
const getValidRuntimeIdentifiers = (scope) => {
const result = new Set();
let scp = scope;
while (scp) {
for (const {
name,
} of scp.variables) {
result.add(name);
}
scp = scp.upper;
}
return result;
};
/**
* We treat imports differently as we can't introspect their children.
* @type {string[]}
*/
const imports = [];
const allDefinedTypes = new Set(globalScope.variables.map(({
name,
}) => {
return name;
})
// If the file is a module, concat the variables from the module scope.
.concat(
cjsOrESMScope ?
globalScope.childScopes.flatMap(({
variables,
}) => {
return variables;
}).flatMap(({
identifiers,
name,
}) => {
const globalItem = /** @type {import('estree').Identifier & {parent: import('@typescript-eslint/types').TSESTree.Node}} */ (
identifiers?.[0]
)?.parent;
switch (globalItem?.type) {
case 'ClassDeclaration':
return [
name,
...globalItem.body.body.map((item) => {
const property = /** @type {import('@typescript-eslint/types').TSESTree.Identifier} */ (
/** @type {import('@typescript-eslint/types').TSESTree.PropertyDefinition} */ (
item)?.key)?.name;
/* c8 ignore next 3 -- Guard */
if (!property) {
return '';
}
return `${name}.${property}`;
}).filter(Boolean),
];
case 'ImportDefaultSpecifier':
case 'ImportNamespaceSpecifier':
case 'ImportSpecifier':
imports.push(name);
break;
case 'TSInterfaceDeclaration':
return [
name,
...globalItem.body.body.map((item) => {
const property = /** @type {import('@typescript-eslint/types').TSESTree.Identifier} */ (
/** @type {import('@typescript-eslint/types').TSESTree.TSPropertySignature} */ (
item)?.key)?.name;
/* c8 ignore next 3 -- Guard */
if (!property) {
return '';
}
return `${name}.${property}`;
}).filter(Boolean),
];
case 'VariableDeclarator':
if (/** @type {import('@typescript-eslint/types').TSESTree.Identifier} */ (
/** @type {import('@typescript-eslint/types').TSESTree.CallExpression} */ (
globalItem?.init
)?.callee)?.name === 'require'
) {
imports.push(/** @type {import('@typescript-eslint/types').TSESTree.Identifier} */ (
globalItem.id
).name);
break;
}
// Module scope names are also defined
return [
name,
];
}
return [
name,
];
/* c8 ignore next */
}) : [],
)
.concat(extraTypes)
.concat(typedefDeclarations)
.concat(importTags)
.concat(definedTypes)
.concat(/** @type {string[]} */ (definedPreferredTypes))
.concat((() => {
// Other methods are not in scope, but we need them, and we grab them here
if (node?.type === 'MethodDefinition') {
return /** @type {import('estree').ClassBody} */ (node.parent).body.flatMap((methodOrProp) => {
if (methodOrProp.type === 'MethodDefinition') {
// eslint-disable-next-line unicorn/no-lonely-if -- Pattern
if (methodOrProp.key.type === 'Identifier') {
return [
methodOrProp.key.name,
`${/** @type {import('estree').ClassDeclaration} */ (
node.parent?.parent
)?.id?.name}.${methodOrProp.key.name}`,
];
}
}
if (methodOrProp.type === 'PropertyDefinition') {
// eslint-disable-next-line unicorn/no-lonely-if -- Pattern
if (methodOrProp.key.type === 'Identifier') {
return [
methodOrProp.key.name,
`${/** @type {import('estree').ClassDeclaration} */ (
node.parent?.parent
)?.id?.name}.${methodOrProp.key.name}`,
];
}
}
/* c8 ignore next 2 -- Not yet built */
return '';
}).filter(Boolean);
}
return [];
})())
.concat(...getValidRuntimeIdentifiers(node && (
(sourceCode.getScope &&
/* c8 ignore next 3 */
sourceCode.getScope(node)) ||
// @ts-expect-error ESLint 8
context.getScope()
)))
.concat(
settings.mode === 'jsdoc' ?
[] :
[
...settings.mode === 'typescript' ? typescriptGlobals : [],
...closureGenericTypes,
],
));
/**
* @typedef {{
* parsedType: import('jsdoc-type-pratt-parser').RootResult;
* tag: import('comment-parser').Spec|import('@es-joy/jsdoccomment').JsdocInlineTagNoType & {
* line?: import('../iterateJsdoc.js').Integer
* }
* }} TypeAndTagInfo
*/
/**
* @param {string} propertyName
* @returns {(tag: (import('@es-joy/jsdoccomment').JsdocInlineTagNoType & {
* name?: string,
* type?: string,
* line?: import('../iterateJsdoc.js').Integer
* })|import('comment-parser').Spec & {
* namepathOrURL?: string
* }
* ) => undefined|TypeAndTagInfo}
*/
const tagToParsedType = (propertyName) => {
return (tag) => {
try {
const potentialType = tag[
/** @type {"type"|"name"|"namepathOrURL"} */ (propertyName)
];
return {
parsedType: mode === 'permissive' ?
tryParseType(/** @type {string} */ (potentialType)) :
parseType(/** @type {string} */ (potentialType), mode),
tag,
};
} catch {
return undefined;
}
};
};
const typeTags = utils.filterTags(({
tag,
}) => {
return tag !== 'import' && utils.tagMightHaveTypePosition(tag) && (tag !== 'suppress' || settings.mode !== 'closure');
}).map(tagToParsedType('type'));
const namepathReferencingTags = utils.filterTags(({
tag,
}) => {
return utils.isNamepathReferencingTag(tag);
}).map(tagToParsedType('name'));
const namepathOrUrlReferencingTags = utils.filterAllTags(({
tag,
}) => {
return utils.isNamepathOrUrlReferencingTag(tag);
}).map(tagToParsedType('namepathOrURL'));
const definedNamesAndNamepaths = new Set(utils.filterTags(({
tag,
}) => {
return utils.isNameOrNamepathDefiningTag(tag);
}).map(({
name,
}) => {
return name;
}));
const tagsWithTypes = /** @type {TypeAndTagInfo[]} */ ([
...typeTags,
...namepathReferencingTags,
...namepathOrUrlReferencingTags,
// Remove types which failed to parse
].filter(Boolean));
for (const {
parsedType,
tag,
} of tagsWithTypes) {
// eslint-disable-next-line complexity -- Refactor
traverse(parsedType, (nde, parentNode) => {
/**
* @type {import('jsdoc-type-pratt-parser').NameResult & {
* _parent?: import('jsdoc-type-pratt-parser').NonRootResult
* }}
*/
// eslint-disable-next-line canonical/id-match -- Avoid clashes
(nde)._parent = parentNode;
const {
type,
value,
} = /** @type {import('jsdoc-type-pratt-parser').NameResult} */ (nde);
let val = value;
/** @type {import('jsdoc-type-pratt-parser').NonRootResult|undefined} */
let currNode = nde;
do {
currNode =
/**
* @type {import('jsdoc-type-pratt-parser').NameResult & {
* _parent?: import('jsdoc-type-pratt-parser').NonRootResult
* }}
*/ (currNode)._parent;
if (
// Avoid appending for imports and globals since we don't want to
// check their properties which may or may not exist
!imports.includes(val) && !globals.includes(val) &&
!importTags.includes(val) &&
!extraTypes.includes(val) &&
!typedefDeclarations.includes(val) &&
!globalTypes.includes(val) &&
currNode && 'right' in currNode &&
currNode.right?.type === 'JsdocTypeProperty'
) {
val = val + '.' + currNode.right.value;
}
} while (currNode?.type === 'JsdocTypeNamePath');
if (type === 'JsdocTypeName') {
const structuredTypes = structuredTags[tag.tag]?.type;
if (!allDefinedTypes.has(val) &&
!definedNamesAndNamepaths.has(val) &&
(!Array.isArray(structuredTypes) || !structuredTypes.includes(val))
) {
const parent =
/**
* @type {import('jsdoc-type-pratt-parser').RootResult & {
* _parent?: import('jsdoc-type-pratt-parser').NonRootResult
* }}
*/ (nde)._parent;
if (parent?.type === 'JsdocTypeTypeParameter') {
return;
}
if (parent?.type === 'JsdocTypeFunction' &&
/** @type {import('jsdoc-type-pratt-parser').FunctionResult} */
(parent)?.typeParameters?.some((typeParam) => {
return value === typeParam.name.value;
})
) {
return;
}
if (!disableReporting) {
report(`The type '${val}' is undefined.`, null, tag);
}
} else if (markVariablesAsUsed && !extraTypes.includes(val)) {
if (sourceCode.markVariableAsUsed) {
sourceCode.markVariableAsUsed(val);
/* c8 ignore next 4 */
} else {
// @ts-expect-error ESLint 8
context.markVariableAsUsed(val);
}
}
if (checkUsedTypedefs && typedefDeclarations.includes(val)) {
foundTypedefValues.push(val);
}
}
});
}
state.foundTypedefValues = foundTypedefValues;
}, {
// We use this method rather than checking at end of handler above because
// in that case, it is invoked too many times and would thus report errors
// too many times.
exit ({
context,
state,
utils,
}) {
const {
checkUsedTypedefs = false,
} = context.options[0] || {};
if (!checkUsedTypedefs) {
return;
}
const allComments = context.sourceCode.getAllComments();
const comments = allComments
.filter((comment) => {
return (/^\*(?!\*)/v).test(comment.value);
})
.map((commentNode) => {
return {
doc: parseComment(commentNode, ''),
loc: commentNode.loc,
};
});
const typedefs = comments
.flatMap(({
doc,
loc,
}) => {
const tags = doc.tags.filter(({
tag,
}) => {
return utils.isNameOrNamepathDefiningTag(tag);
});
if (!tags.length) {
return [];
}
return {
loc,
tags,
};
});
for (const typedef of typedefs) {
if (
!state.foundTypedefValues.includes(typedef.tags[0].name)
) {
context.report({
loc: /** @type {import('@eslint/core').SourceLocation} */ (typedef.loc),
message: 'This typedef was not used within the file',
});
}
}
},
iterateAllJsdocs: true,
meta: {
docs: {
description: 'Besides some expected built-in types, prohibits any types not specified as globals or within `@typedef`.',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/no-undefined-types.md#repos-sticky-header',
},
schema: [
{
additionalProperties: false,
properties: {
checkUsedTypedefs: {
description: 'Whether to check typedefs for use within the file',
type: 'boolean',
},
definedTypes: {
description: `This array can be populated to indicate other types which
are automatically considered as defined (in addition to globals, etc.).
Defaults to an empty array.`,
items: {
type: 'string',
},
type: 'array',
},
disableReporting: {
description: `Whether to disable reporting of errors. Defaults to
\`false\`. This may be set to \`true\` in order to take advantage of only
marking defined variables as used or checking used typedefs.`,
type: 'boolean',
},
markVariablesAsUsed: {
description: `Whether to mark variables as used for the purposes
of the \`no-unused-vars\` rule when they are not found to be undefined.
Defaults to \`true\`. May be set to \`false\` to enforce a practice of not
importing types unless used in code.`,
type: 'boolean',
},
},
type: 'object',
},
],
type: 'suggestion',
},
});

View file

@ -0,0 +1,486 @@
import iterateJsdoc, {
parseComment,
} from '../iterateJsdoc.js';
import {
commentParserToESTree,
estreeToString,
// getJSDocComment,
parse as parseType,
stringify,
traverse,
tryParse as tryParseType,
} from '@es-joy/jsdoccomment';
import {
parseImportsExports,
} from 'parse-imports-exports';
import toValidIdentifier from 'to-valid-identifier';
export default iterateJsdoc(({
context,
indent,
jsdoc,
settings,
sourceCode,
utils,
}) => {
const {
mode,
} = settings;
const {
enableFixer = true,
exemptTypedefs = true,
outputType = 'namespaced-import',
} = context.options[0] || {};
const allComments = sourceCode.getAllComments();
const comments = allComments
.filter((comment) => {
return (/^\*(?!\*)/v).test(comment.value);
})
.map((commentNode) => {
return commentParserToESTree(
parseComment(commentNode, ''), mode === 'permissive' ? 'typescript' : mode,
);
});
const typedefs = comments
.flatMap((doc) => {
return doc.tags.filter(({
tag,
}) => {
return utils.isNameOrNamepathDefiningTag(tag);
});
});
const imports = comments
.flatMap((doc) => {
return doc.tags.filter(({
tag,
}) => {
return tag === 'import';
});
}).map((tag) => {
// Causes problems with stringification otherwise
tag.delimiter = '';
return tag;
});
/**
* @param {import('@es-joy/jsdoccomment').JsdocTagWithInline} tag
*/
const iterateInlineImports = (tag) => {
const potentialType = tag.type;
let parsedType;
try {
parsedType = mode === 'permissive' ?
tryParseType(/** @type {string} */ (potentialType)) :
parseType(/** @type {string} */ (potentialType), mode);
} catch {
return;
}
traverse(parsedType, (nde, parentNode) => {
// @ts-expect-error Adding our own property for use below
nde.parentNode = parentNode;
});
traverse(parsedType, (nde) => {
const {
element,
type,
} = /** @type {import('jsdoc-type-pratt-parser').ImportResult} */ (nde);
if (type !== 'JsdocTypeImport') {
return;
}
let currentNode = nde;
/** @type {string[]} */
const pathSegments = [];
/** @type {import('jsdoc-type-pratt-parser').NamePathResult[]} */
const nodes = [];
/** @type {string[]} */
const extraPathSegments = [];
/** @type {(import('jsdoc-type-pratt-parser').QuoteStyle|undefined)[]} */
const quotes = [];
const propertyOrBrackets = /** @type {import('jsdoc-type-pratt-parser').NamePathResult['pathType'][]} */ ([]);
// @ts-expect-error Referencing our own property added above
while (currentNode && currentNode.parentNode) {
// @ts-expect-error Referencing our own property added above
currentNode = currentNode.parentNode;
/* c8 ignore next 3 -- Guard */
if (currentNode.type !== 'JsdocTypeNamePath') {
break;
}
pathSegments.unshift(
currentNode.right.type === 'JsdocTypeIndexedAccessIndex' ?
stringify(currentNode.right.right) :
currentNode.right.value,
);
nodes.unshift(currentNode);
propertyOrBrackets.unshift(currentNode.pathType);
quotes.unshift(
currentNode.right.type === 'JsdocTypeIndexedAccessIndex' ?
undefined :
currentNode.right.meta.quote,
);
}
/**
* @param {string} name
* @param {string[]} extrPathSegments
*/
const getFixer = (name, extrPathSegments) => {
const matchingName = toValidIdentifier(name);
return () => {
/** @type {import('jsdoc-type-pratt-parser').NamePathResult|undefined} */
let node = nodes.at(0);
if (!node) {
// Not really a NamePathResult, but will be converted later anyways
node = /** @type {import('jsdoc-type-pratt-parser').NamePathResult} */ (
/** @type {unknown} */
(nde)
);
}
const keys = /** @type {(keyof import('jsdoc-type-pratt-parser').NamePathResult)[]} */ (
Object.keys(node)
);
for (const key of keys) {
delete node[key];
}
if (extrPathSegments.length) {
let newNode = /** @type {import('jsdoc-type-pratt-parser').NamePathResult} */ (
/** @type {unknown} */
(node)
);
while (extrPathSegments.length && newNode) {
newNode.type = 'JsdocTypeNamePath';
newNode.right = {
meta: {
quote: quotes.shift(),
},
type: 'JsdocTypeProperty',
value: /** @type {string} */ (extrPathSegments.shift()),
};
newNode.pathType = /** @type {import('jsdoc-type-pratt-parser').NamePathResult['pathType']} */ (
propertyOrBrackets.shift()
);
// @ts-expect-error Temporary
newNode.left = {};
newNode = /** @type {import('jsdoc-type-pratt-parser').NamePathResult} */ (
newNode.left
);
}
const nameNode = /** @type {import('jsdoc-type-pratt-parser').NameResult} */ (
/** @type {unknown} */
(newNode)
);
nameNode.type = 'JsdocTypeName';
nameNode.value = matchingName;
} else {
const newNode = /** @type {import('jsdoc-type-pratt-parser').NameResult} */ (
/** @type {unknown} */
(node)
);
newNode.type = 'JsdocTypeName';
newNode.value = matchingName;
}
for (const src of tag.source) {
if (src.tokens.type) {
src.tokens.type = `{${stringify(parsedType)}}`;
break;
}
}
};
};
/** @type {string[]} */
let unusedPathSegments = [];
const findMatchingTypedef = () => {
// Don't want typedefs to find themselves
if (!exemptTypedefs) {
return undefined;
}
const pthSegments = [
...pathSegments,
];
return typedefs.find((typedef) => {
let typedefNode = typedef.parsedType;
let namepathMatch;
while (typedefNode && typedefNode.type === 'JsdocTypeNamePath') {
const pathSegment = pthSegments.shift();
if (!pathSegment) {
namepathMatch = false;
break;
}
if (
(typedefNode.right.type === 'JsdocTypeIndexedAccessIndex' &&
stringify(typedefNode.right.right) !== pathSegment) ||
(typedefNode.right.type !== 'JsdocTypeIndexedAccessIndex' &&
typedefNode.right.value !== pathSegment)
) {
if (namepathMatch === true) {
// It stopped matching, so stop
break;
}
extraPathSegments.push(pathSegment);
namepathMatch = false;
continue;
}
namepathMatch = true;
unusedPathSegments = pthSegments;
typedefNode = typedefNode.left;
}
return namepathMatch &&
// `import('eslint')` matches
typedefNode &&
typedefNode.type === 'JsdocTypeImport' &&
typedefNode.element.value === element.value;
});
};
// Check @typedef's first as should be longest match, allowing
// for shorter abbreviations
const matchingTypedef = findMatchingTypedef();
if (matchingTypedef) {
utils.reportJSDoc(
'Inline `import()` found; using `@typedef`',
tag,
enableFixer ? getFixer(matchingTypedef.name, [
...extraPathSegments,
...unusedPathSegments.slice(-1),
...unusedPathSegments.slice(0, -1),
]) : null,
);
return;
}
const findMatchingImport = () => {
for (const imprt of imports) {
const parsedImport = parseImportsExports(
estreeToString(imprt).replace(/^\s*@/v, '').trim(),
);
const namedImportsModuleSpecifier = Object.keys(parsedImport.namedImports || {})[0];
const namedImports = Object.values(parsedImport.namedImports || {})[0]?.[0];
const namedImportNames = (namedImports && namedImports.names && Object.keys(namedImports.names)) ?? [];
const namespaceImports = Object.values(parsedImport.namespaceImports || {})[0]?.[0];
const namespaceImportsDefault = namespaceImports && namespaceImports.default;
const namespaceImportsNamespace = namespaceImports && namespaceImports.namespace;
const namespaceImportsModuleSpecifier = Object.keys(parsedImport.namespaceImports || {})[0];
const lastPathSegment = pathSegments.at(-1);
if (
(namespaceImportsDefault &&
namespaceImportsModuleSpecifier === element.value) ||
(element.value === namedImportsModuleSpecifier && (
(lastPathSegment && namedImportNames.includes(lastPathSegment)) ||
lastPathSegment === 'default'
)) ||
(namespaceImportsNamespace &&
namespaceImportsModuleSpecifier === element.value)
) {
return {
namedImportNames,
namedImports,
namedImportsModuleSpecifier,
namespaceImports,
namespaceImportsDefault,
namespaceImportsModuleSpecifier,
namespaceImportsNamespace,
};
}
}
return undefined;
};
const matchingImport = findMatchingImport();
if (matchingImport) {
const {
namedImportNames,
namedImports,
namedImportsModuleSpecifier,
namespaceImportsNamespace,
} = matchingImport;
if (!namedImportNames.length && namedImportsModuleSpecifier && namedImports.default) {
utils.reportJSDoc(
'Inline `import()` found; prefer `@import`',
tag,
enableFixer ? getFixer(namedImports.default, []) : null,
);
return;
}
const lastPthSegment = pathSegments.at(-1);
if (lastPthSegment && namedImportNames.includes(lastPthSegment)) {
utils.reportJSDoc(
'Inline `import()` found; prefer `@import`',
tag,
enableFixer ? getFixer(lastPthSegment, pathSegments.slice(0, -1)) : null,
);
return;
}
if (namespaceImportsNamespace) {
utils.reportJSDoc(
'Inline `import()` found; prefer `@import`',
tag,
enableFixer ? getFixer(namespaceImportsNamespace, [
...pathSegments,
]) : null,
);
return;
}
}
if (!pathSegments.length) {
utils.reportJSDoc(
'Inline `import()` found; prefer `@import`',
tag,
enableFixer ? (fixer) => {
getFixer(element.value, [])();
const programNode = sourceCode.ast;
const commentNodes = sourceCode.getCommentsBefore(programNode);
return fixer.insertTextBefore(
// @ts-expect-error Ok
commentNodes[0] ?? programNode,
`/** @import * as ${toValidIdentifier(element.value)} from '${element.value}'; */${
commentNodes[0] ? '\n' + indent : ''
}`,
);
} : null,
);
return;
}
const lstPathSegment = pathSegments.at(-1);
if (lstPathSegment && lstPathSegment === 'default') {
utils.reportJSDoc(
'Inline `import()` found; prefer `@import`',
tag,
enableFixer ? (fixer) => {
getFixer(element.value, [])();
const programNode = sourceCode.ast;
const commentNodes = sourceCode.getCommentsBefore(programNode);
return fixer.insertTextBefore(
// @ts-expect-error Ok
commentNodes[0] ?? programNode,
`/** @import ${element.value} from '${element.value}'; */${
commentNodes[0] ? '\n' + indent : ''
}`,
);
} : null,
);
return;
}
utils.reportJSDoc(
'Inline `import()` found; prefer `@import`',
tag,
enableFixer ? (fixer) => {
if (outputType === 'namespaced-import') {
getFixer(element.value, [
...pathSegments,
])();
} else {
getFixer(
/** @type {string} */ (pathSegments.at(-1)),
pathSegments.slice(0, -1),
)();
}
const programNode = sourceCode.ast;
const commentNodes = sourceCode.getCommentsBefore(programNode);
return fixer.insertTextBefore(
// @ts-expect-error Ok
commentNodes[0] ?? programNode,
outputType === 'namespaced-import' ?
`/** @import * as ${toValidIdentifier(element.value)} from '${element.value}'; */${
commentNodes[0] ? '\n' + indent : ''
}` :
`/** @import { ${toValidIdentifier(
/* c8 ignore next -- TS */
pathSegments.at(-1) ?? '',
)} } from '${element.value}'; */${
commentNodes[0] ? '\n' + indent : ''
}`,
);
} : null,
);
});
};
for (const tag of jsdoc.tags) {
const mightHaveTypePosition = utils.tagMightHaveTypePosition(tag.tag);
const hasTypePosition = mightHaveTypePosition === true && Boolean(tag.type);
if (hasTypePosition && (!exemptTypedefs || !utils.isNameOrNamepathDefiningTag(tag.tag))) {
iterateInlineImports(tag);
}
}
}, {
iterateAllJsdocs: true,
meta: {
docs: {
description: 'Prefer `@import` tags to inline `import()` statements.',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/prefer-import-tag.md#repos-sticky-header',
},
fixable: 'code',
schema: [
{
additionalProperties: false,
properties: {
enableFixer: {
description: 'Whether or not to enable the fixer to add `@import` tags.',
type: 'boolean',
},
exemptTypedefs: {
description: 'Whether to allow `import()` statements within `@typedef`',
type: 'boolean',
},
// We might add `typedef` and `typedef-local-only`, but also raises
// question of how deep the generated typedef should be
outputType: {
description: 'What kind of `@import` to generate when no matching `@typedef` or `@import` is found',
enum: [
'named-import',
'namespaced-import',
],
type: 'string',
},
},
type: 'object',
},
],
type: 'suggestion',
},
});

View file

@ -0,0 +1,217 @@
import iterateJsdoc from '../iterateJsdoc.js';
export default iterateJsdoc(({
context,
indent,
jsdoc,
utils,
}) => {
const [
defaultRequireValue = 'always',
{
tags: tagMap = {},
} = {},
] = context.options;
const {
source,
} = jsdoc;
const always = defaultRequireValue === 'always';
const never = defaultRequireValue === 'never';
/** @type {string} */
let currentTag;
source.some(({
number,
tokens,
}) => {
const {
delimiter,
description,
end,
tag,
} = tokens;
/**
* @returns {void}
*/
const neverFix = () => {
tokens.delimiter = '';
tokens.postDelimiter = '';
};
/**
* @param {string} checkValue
* @returns {boolean}
*/
const checkNever = (checkValue) => {
if (delimiter && delimiter !== '/**' && (
never && !tagMap.always?.includes(checkValue) ||
tagMap.never?.includes(checkValue)
)) {
utils.reportJSDoc('Expected JSDoc line to have no prefix.', {
column: 0,
line: number,
}, neverFix);
return true;
}
return false;
};
/**
* @returns {void}
*/
const alwaysFix = () => {
if (!tokens.start) {
tokens.start = indent + ' ';
}
tokens.delimiter = '*';
tokens.postDelimiter = tag || description ? ' ' : '';
};
/**
* @param {string} checkValue
* @returns {boolean}
*/
const checkAlways = (checkValue) => {
if (
!delimiter && (
always && !tagMap.never?.includes(checkValue) ||
tagMap.always?.includes(checkValue)
)
) {
utils.reportJSDoc('Expected JSDoc line to have the prefix.', {
column: 0,
line: number,
}, alwaysFix);
return true;
}
return false;
};
if (tag) {
// Remove at sign
currentTag = tag.slice(1);
}
if (
// If this is the end but has a tag, the delimiter will also be
// populated and will be safely ignored later
end && !tag
) {
return false;
}
if (!currentTag) {
if (tagMap.any?.includes('*description')) {
return false;
}
if (checkNever('*description')) {
return true;
}
if (checkAlways('*description')) {
return true;
}
return false;
}
if (tagMap.any?.includes(currentTag)) {
return false;
}
if (checkNever(currentTag)) {
return true;
}
if (checkAlways(currentTag)) {
return true;
}
return false;
});
}, {
iterateAllJsdocs: true,
meta: {
docs: {
description:
'Requires that each JSDoc line starts with an `*`.',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/require-asterisk-prefix.md#repos-sticky-header',
},
fixable: 'code',
schema: [
{
description: `If it is \`"always"\` then a problem is raised when there is no asterisk
prefix on a given JSDoc line. If it is \`"never"\` then a problem is raised
when there is an asterisk present.
The default value is \`"always"\`. You may also set the default to \`"any"\`
and use the \`tags\` option to apply to specific tags only.`,
enum: [
'always', 'never', 'any',
],
type: 'string',
},
{
additionalProperties: false,
properties: {
tags: {
additionalProperties: false,
description: `If you want different values to apply to specific tags, you may use
the \`tags\` option object. The keys are \`always\`, \`never\`, or \`any\` and
the values are arrays of tag names or the special value \`*description\`
which applies to the main JSDoc block description.
\`\`\`js
{
'jsdoc/require-asterisk-prefix': ['error', 'always', {
tags: {
always: ['*description'],
any: ['example', 'license'],
never: ['copyright']
}
}]
}
\`\`\``,
properties: {
always: {
description: `If it is \`"always"\` then a problem is raised when there is no asterisk
prefix on a given JSDoc line.`,
items: {
type: 'string',
},
type: 'array',
},
any: {
description: 'No problem is raised regardless of asterisk presence or non-presence.',
items: {
type: 'string',
},
type: 'array',
},
never: {
description: `If it is \`"never"\` then a problem is raised
when there is an asterisk present.`,
items: {
type: 'string',
},
type: 'array',
},
},
type: 'object',
},
},
type: 'object',
},
],
type: 'layout',
},
});

View file

@ -0,0 +1,190 @@
import iterateJsdoc from '../iterateJsdoc.js';
/**
* @param {string} description
* @returns {import('../iterateJsdoc.js').Integer}
*/
const checkDescription = (description) => {
return description
.trim()
.split('\n')
.filter(Boolean)
.length;
};
export default iterateJsdoc(({
context,
jsdoc,
report,
utils,
}) => {
if (utils.avoidDocs()) {
return;
}
const {
descriptionStyle = 'body',
} = context.options[0] || {};
let targetTagName = utils.getPreferredTagName({
// We skip reporting except when `@description` is essential to the rule,
// so user can block the tag and still meaningfully use this rule
// even if the tag is present (and `check-tag-names` is the one to
// normally report the fact that it is blocked but present)
skipReportingBlockedTag: descriptionStyle !== 'tag',
tagName: 'description',
});
if (!targetTagName) {
return;
}
const isBlocked = typeof targetTagName === 'object' && 'blocked' in targetTagName && targetTagName.blocked;
if (isBlocked) {
targetTagName = /** @type {{blocked: true; tagName: string;}} */ (
targetTagName
).tagName;
}
if (descriptionStyle !== 'tag') {
const {
description,
} = utils.getDescription();
if (checkDescription(description || '')) {
return;
}
if (descriptionStyle === 'body') {
const descTags = utils.getPresentTags([
'desc', 'description',
]);
if (descTags.length) {
const [
{
tag: tagName,
},
] = descTags;
report(`Remove the @${tagName} tag to leave a plain block description or add additional description text above the @${tagName} line.`);
} else {
report('Missing JSDoc block description.');
}
return;
}
}
const functionExamples = isBlocked ?
[] :
jsdoc.tags.filter(({
tag,
}) => {
return tag === targetTagName;
});
if (!functionExamples.length) {
report(
descriptionStyle === 'any' ?
`Missing JSDoc block description or @${targetTagName} declaration.` :
`Missing JSDoc @${targetTagName} declaration.`,
);
return;
}
for (const example of functionExamples) {
if (!checkDescription(`${example.name} ${utils.getTagDescription(example)}`)) {
report(`Missing JSDoc @${targetTagName} description.`, null, example);
}
}
}, {
contextDefaults: true,
meta: {
docs: {
description: 'Requires that all functions (and potentially other contexts) have a description.',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/require-description.md#repos-sticky-header',
},
schema: [
{
additionalProperties: false,
properties: {
checkConstructors: {
default: true,
description: `A value indicating whether \`constructor\`s should be
checked. Defaults to \`true\`.`,
type: 'boolean',
},
checkGetters: {
default: true,
description: `A value indicating whether getters should be checked.
Defaults to \`true\`.`,
type: 'boolean',
},
checkSetters: {
default: true,
description: `A value indicating whether setters should be checked.
Defaults to \`true\`.`,
type: 'boolean',
},
contexts: {
description: `Set to an array of strings representing the AST context
where you wish the rule to be applied (e.g., \`ClassDeclaration\` for ES6
classes).
\`context\` defaults to \`any\` and \`comment\` defaults to no specific comment context.
Overrides the default contexts (\`ArrowFunctionExpression\`,
\`FunctionDeclaration\`, \`FunctionExpression\`). Set to \`"any"\` if you want
the rule to apply to any JSDoc block throughout your files (as is necessary
for finding function blocks not attached to a function declaration or
expression, i.e., \`@callback\` or \`@function\` (or its aliases \`@func\` or
\`@method\`) (including those associated with an \`@interface\`).
See the ["AST and Selectors"](../#advanced-ast-and-selectors)
section of our Advanced docs for more on the expected format.`,
items: {
anyOf: [
{
type: 'string',
},
{
additionalProperties: false,
properties: {
comment: {
type: 'string',
},
context: {
type: 'string',
},
},
type: 'object',
},
],
},
type: 'array',
},
descriptionStyle: {
description: `Whether to accept implicit descriptions (\`"body"\`) or
\`@description\` tags (\`"tag"\`) as satisfying the rule. Set to \`"any"\` to
accept either style. Defaults to \`"body"\`.`,
enum: [
'body', 'tag', 'any',
],
type: 'string',
},
exemptedBy: {
description: `Array of tags (e.g., \`['type']\`) whose presence on the
document block avoids the need for a \`@description\`. Defaults to an
array with \`inheritdoc\`. If you set this array, it will overwrite the
default, so be sure to add back \`inheritdoc\` if you wish its presence
to cause exemption of the rule.`,
items: {
type: 'string',
},
type: 'array',
},
},
type: 'object',
},
],
type: 'suggestion',
},
});

View file

@ -0,0 +1,361 @@
import iterateJsdoc from '../iterateJsdoc.js';
import escapeStringRegexp from 'escape-string-regexp';
const otherDescriptiveTags = new Set([
'classdesc', 'deprecated', 'exception', 'file', 'fileoverview', 'overview',
// 'copyright' and 'see' might be good addition, but as the former may be
// sensitive text, and the latter may have just a link, they are not
// included by default
'summary', 'throws', 'todo', 'yield', 'yields',
]);
/**
* @param {string} text
* @returns {string[]}
*/
const extractParagraphs = (text) => {
return text.split(/(?<![;:])\n\n+/v);
};
/**
* @param {string} text
* @param {string|RegExp} abbreviationsRegex
* @returns {string[]}
*/
const extractSentences = (text, abbreviationsRegex) => {
const txt = text
// Remove all {} tags.
.replaceAll(/(?<!^)\{[\s\S]*?\}\s*/gv, '')
// Remove custom abbreviations
.replace(abbreviationsRegex, '');
const sentenceEndGrouping = /([.?!])(?:\s+|$)/gv;
const puncts = [
...txt.matchAll(sentenceEndGrouping),
].map((sentEnd) => {
return sentEnd[0];
});
return txt
.split(/[.?!](?:\s+|$)/v)
// Re-add the dot.
.map((sentence, idx) => {
return !puncts[idx] && /^\s*$/v.test(sentence) ? sentence : `${sentence}${puncts[idx] || ''}`;
});
};
/**
* @param {string} text
* @returns {boolean}
*/
const isNewLinePrecededByAPeriod = (text) => {
/** @type {boolean} */
let lastLineEndsSentence;
const lines = text.split('\n');
return !lines.some((line) => {
if (lastLineEndsSentence === false && /^[A-Z][a-z]/v.test(line)) {
return true;
}
lastLineEndsSentence = /[.:?!\|]$/v.test(line);
return false;
});
};
/**
* @param {string} str
* @returns {boolean}
*/
const isCapitalized = (str) => {
return str[0] === str[0].toUpperCase();
};
/**
* @param {string} str
* @returns {boolean}
*/
const isTable = (str) => {
return str.charAt(0) === '|';
};
/**
* @param {string} str
* @returns {string}
*/
const capitalize = (str) => {
return str.charAt(0).toUpperCase() + str.slice(1);
};
/**
* @param {string} description
* @param {import('../iterateJsdoc.js').Report} reportOrig
* @param {import('eslint').Rule.Node} jsdocNode
* @param {string|RegExp} abbreviationsRegex
* @param {import('eslint').SourceCode} sourceCode
* @param {import('comment-parser').Spec|{
* line: import('../iterateJsdoc.js').Integer
* }} tag
* @param {boolean} newlineBeforeCapsAssumesBadSentenceEnd
* @returns {boolean}
*/
const validateDescription = (
description, reportOrig, jsdocNode, abbreviationsRegex,
sourceCode, tag, newlineBeforeCapsAssumesBadSentenceEnd,
) => {
if (!description || (/^\n+$/v).test(description)) {
return false;
}
const descriptionNoHeadings = description.replaceAll(/^\s*#[^\n]*(\n|$)/gmv, '');
const paragraphs = extractParagraphs(descriptionNoHeadings).filter(Boolean);
return paragraphs.some((paragraph, parIdx) => {
const sentences = extractSentences(paragraph, abbreviationsRegex);
const fix = /** @type {import('eslint').Rule.ReportFixer} */ (fixer) => {
let text = sourceCode.getText(jsdocNode);
if (!/[.:?!]$/v.test(paragraph)) {
const line = paragraph.split('\n').findLast(Boolean);
text = text.replace(new RegExp(`${escapeStringRegexp(
/** @type {string} */
(line),
)}$`, 'mv'), `${line}.`);
}
for (const sentence of sentences.filter((sentence_) => {
return !(/^\s*$/v).test(sentence_) && !isCapitalized(sentence_) &&
!isTable(sentence_);
})) {
const beginning = sentence.split('\n')[0];
if ('tag' in tag && tag.tag) {
const reg = new RegExp(`(@${escapeStringRegexp(tag.tag)}.*)${escapeStringRegexp(beginning)}`, 'v');
text = text.replace(reg, (_$0, $1) => {
return $1 + capitalize(beginning);
});
} else {
text = text.replace(new RegExp('((?:[.?!]|\\*|\\})\\s*)' + escapeStringRegexp(beginning), 'v'), '$1' + capitalize(beginning));
}
}
return fixer.replaceText(jsdocNode, text);
};
/**
* @param {string} msg
* @param {import('eslint').Rule.ReportFixer | null | undefined} fixer
* @param {{
* line?: number | undefined;
* column?: number | undefined;
* } | (import('comment-parser').Spec & {
* line?: number | undefined;
* column?: number | undefined;
* })} tagObj
* @returns {void}
*/
const report = (msg, fixer, tagObj) => {
if ('line' in tagObj) {
/**
* @type {{
* line: number;
* }}
*/ (tagObj).line += parIdx * 2;
} else {
/** @type {import('comment-parser').Spec} */ (
tagObj
).source[0].number += parIdx * 2;
}
// Avoid errors if old column doesn't exist here
tagObj.column = 0;
reportOrig(msg, fixer, tagObj);
};
if (sentences.some((sentence) => {
return (/^[.?!]$/v).test(sentence);
})) {
report('Sentences must be more than punctuation.', null, tag);
}
if (sentences.some((sentence) => {
return !(/^\s*$/v).test(sentence) && !isCapitalized(sentence) && !isTable(sentence);
})) {
report('Sentences should start with an uppercase character.', fix, tag);
}
const paragraphNoAbbreviations = paragraph.replace(abbreviationsRegex, '');
if (!/(?:[.?!\|]|```)\s*$/v.test(paragraphNoAbbreviations)) {
report('Sentences must end with a period.', fix, tag);
return true;
}
if (newlineBeforeCapsAssumesBadSentenceEnd && !isNewLinePrecededByAPeriod(paragraphNoAbbreviations)) {
report('A line of text is started with an uppercase character, but the preceding line does not end the sentence.', null, tag);
return true;
}
return false;
});
};
export default iterateJsdoc(({
context,
jsdoc,
jsdocNode,
report,
sourceCode,
utils,
}) => {
const /** @type {{abbreviations: string[], newlineBeforeCapsAssumesBadSentenceEnd: boolean}} */ {
abbreviations = [],
newlineBeforeCapsAssumesBadSentenceEnd = false,
} = context.options[0] || {};
const abbreviationsRegex = abbreviations.length ?
new RegExp('\\b' + abbreviations.map((abbreviation) => {
return escapeStringRegexp(abbreviation.replaceAll(/\.$/gv, '') + '.');
}).join('|') + '(?:$|\\s)', 'gv') :
'';
let {
description,
} = utils.getDescription();
const indices = [
...description.matchAll(/```[\s\S]*```/gv),
].map((match) => {
const {
index,
} = match;
const [
{
length,
},
] = match;
return {
index,
length,
};
}).toReversed();
for (const {
index,
length,
} of indices) {
description = description.slice(0, index) +
description.slice(/** @type {import('../iterateJsdoc.js').Integer} */ (
index
) + length);
}
if (validateDescription(description, report, jsdocNode, abbreviationsRegex, sourceCode, {
line: jsdoc.source[0].number + 1,
}, newlineBeforeCapsAssumesBadSentenceEnd)) {
return;
}
utils.forEachPreferredTag('description', (matchingJsdocTag) => {
const desc = `${matchingJsdocTag.name} ${utils.getTagDescription(matchingJsdocTag)}`.trim();
validateDescription(desc, report, jsdocNode, abbreviationsRegex, sourceCode, matchingJsdocTag, newlineBeforeCapsAssumesBadSentenceEnd);
}, true);
const {
tagsWithNames,
} = utils.getTagsByType(jsdoc.tags);
const tagsWithoutNames = utils.filterTags(({
tag: tagName,
}) => {
return otherDescriptiveTags.has(tagName) ||
utils.hasOptionTag(tagName) && !tagsWithNames.some(({
tag,
}) => {
// If user accidentally adds tags with names (or like `returns`
// get parsed as having names), do not add to this list
return tag === tagName;
});
});
tagsWithNames.some((tag) => {
const desc = /** @type {string} */ (
utils.getTagDescription(tag)
).replace(/^- /v, '').trimEnd();
return validateDescription(desc, report, jsdocNode, abbreviationsRegex, sourceCode, tag, newlineBeforeCapsAssumesBadSentenceEnd);
});
tagsWithoutNames.some((tag) => {
const desc = `${tag.name} ${utils.getTagDescription(tag)}`.trim();
return validateDescription(desc, report, jsdocNode, abbreviationsRegex, sourceCode, tag, newlineBeforeCapsAssumesBadSentenceEnd);
});
}, {
iterateAllJsdocs: true,
meta: {
docs: {
description: 'Requires that block description, explicit `@description`, and `@param`/`@returns` tag descriptions are written in complete sentences.',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/require-description-complete-sentence.md#repos-sticky-header',
},
fixable: 'code',
schema: [
{
additionalProperties: false,
properties: {
abbreviations: {
description: `You can provide an \`abbreviations\` options array to avoid such strings of text
being treated as sentence endings when followed by dots. The \`.\` is not
necessary at the end of the array items.`,
items: {
type: 'string',
},
type: 'array',
},
newlineBeforeCapsAssumesBadSentenceEnd: {
description: `When \`false\` (the new default), we will not assume capital letters after
newlines are an incorrect way to end the sentence (they may be proper
nouns, for example).`,
type: 'boolean',
},
tags: {
description: `If you want additional tags to be checked for their descriptions, you may
add them within this option.
\`\`\`js
{
'jsdoc/require-description-complete-sentence': ['error', {
tags: ['see', 'copyright']
}]
}
\`\`\`
The tags \`@param\`/\`@arg\`/\`@argument\` and \`@property\`/\`@prop\` will be properly
parsed to ensure that the checked "description" text includes only the text
after the name.
All other tags will treat the text following the tag name, a space, and
an optional curly-bracketed type expression (and another space) as part of
its "description" (e.g., for \`@returns {someType} some description\`, the
description is \`some description\` while for \`@some-tag xyz\`, the description
is \`xyz\`).`,
items: {
type: 'string',
},
type: 'array',
},
},
type: 'object',
},
],
type: 'suggestion',
},
});

View file

@ -0,0 +1,143 @@
import iterateJsdoc from '../iterateJsdoc.js';
export default iterateJsdoc(({
context,
jsdoc,
report,
utils,
}) => {
if (utils.avoidDocs()) {
return;
}
const {
enableFixer = true,
exemptNoArguments = false,
} = context.options[0] || {};
const targetTagName = 'example';
const functionExamples = jsdoc.tags.filter(({
tag,
}) => {
return tag === targetTagName;
});
if (!functionExamples.length) {
if (exemptNoArguments && utils.isIteratingFunctionOrVariable() &&
!utils.hasParams()
) {
return;
}
utils.reportJSDoc(`Missing JSDoc @${targetTagName} declaration.`, null, () => {
if (enableFixer) {
utils.addTag(targetTagName);
}
});
return;
}
for (const example of functionExamples) {
const exampleContent = `${example.name} ${utils.getTagDescription(example)}`
.trim()
.split('\n')
.filter(Boolean);
if (!exampleContent.length) {
report(`Missing JSDoc @${targetTagName} description.`, null, example);
}
}
}, {
contextDefaults: true,
meta: {
docs: {
description: 'Requires that all functions (and potentially other contexts) have examples.',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/require-example.md#repos-sticky-header',
},
fixable: 'code',
schema: [
{
additionalProperties: false,
properties: {
checkConstructors: {
default: true,
description: `A value indicating whether \`constructor\`s should be checked.
Defaults to \`true\`.`,
type: 'boolean',
},
checkGetters: {
default: false,
description: 'A value indicating whether getters should be checked. Defaults to `false`.',
type: 'boolean',
},
checkSetters: {
default: false,
description: 'A value indicating whether setters should be checked. Defaults to `false`.',
type: 'boolean',
},
contexts: {
description: `Set this to an array of strings representing the AST context (or an object with
optional \`context\` and \`comment\` properties) where you wish the rule to be applied.
(e.g., \`ClassDeclaration\` for ES6 classes).
\`context\` defaults to \`any\` and \`comment\` defaults to no specific comment context.
Overrides the default contexts (\`ArrowFunctionExpression\`, \`FunctionDeclaration\`,
\`FunctionExpression\`). Set to \`"any"\` if you want the rule to apply to any
JSDoc block throughout your files.
See the ["AST and Selectors"](../#advanced-ast-and-selectors)
section of our Advanced docs for more on the expected format.`,
items: {
anyOf: [
{
type: 'string',
},
{
additionalProperties: false,
properties: {
comment: {
type: 'string',
},
context: {
type: 'string',
},
},
type: 'object',
},
],
},
type: 'array',
},
enableFixer: {
default: true,
description: `A boolean on whether to enable the fixer (which adds an empty \`@example\` block).
Defaults to \`true\`.`,
type: 'boolean',
},
exemptedBy: {
description: `Array of tags (e.g., \`['type']\`) whose presence on the document
block avoids the need for an \`@example\`. Defaults to an array with
\`inheritdoc\`. If you set this array, it will overwrite the default,
so be sure to add back \`inheritdoc\` if you wish its presence to cause
exemption of the rule.`,
items: {
type: 'string',
},
type: 'array',
},
exemptNoArguments: {
default: false,
description: `Boolean to indicate that no-argument functions should not be reported for
missing \`@example\` declarations.`,
type: 'boolean',
},
},
type: 'object',
},
],
type: 'suggestion',
},
});

View file

@ -0,0 +1,213 @@
import iterateJsdoc from '../iterateJsdoc.js';
const defaultTags = {
file: {
initialCommentsOnly: true,
mustExist: true,
preventDuplicates: true,
},
};
/**
* @param {import('../iterateJsdoc.js').StateObject} state
* @returns {void}
*/
const setDefaults = (state) => {
// First iteration
if (!state.globalTags) {
state.globalTags = true;
state.hasDuplicates = {};
state.hasTag = {};
state.hasNonCommentBeforeTag = {};
}
};
export default iterateJsdoc(({
context,
jsdocNode,
state,
utils,
}) => {
const {
tags = defaultTags,
} = context.options[0] || {};
setDefaults(state);
for (const tagName of Object.keys(tags)) {
const targetTagName = /** @type {string} */ (utils.getPreferredTagName({
tagName,
}));
const hasTag = Boolean(targetTagName && utils.hasTag(targetTagName));
state.hasTag[tagName] = hasTag || state.hasTag[tagName];
const hasDuplicate = state.hasDuplicates[tagName];
if (hasDuplicate === false) {
// Was marked before, so if a tag now, is a dupe
state.hasDuplicates[tagName] = hasTag;
} else if (!hasDuplicate && hasTag) {
// No dupes set before, but has first tag, so change state
// from `undefined` to `false` so can detect next time
state.hasDuplicates[tagName] = false;
state.hasNonCommentBeforeTag[tagName] = state.hasNonComment &&
state.hasNonComment < jsdocNode.range[0];
}
}
}, {
exit ({
context,
state,
utils,
}) {
setDefaults(state);
const {
tags = defaultTags,
} = context.options[0] || {};
for (const [
tagName,
{
initialCommentsOnly = false,
mustExist = false,
preventDuplicates = false,
},
] of Object.entries(tags)) {
const obj = utils.getPreferredTagNameObject({
tagName,
});
if (obj && typeof obj === 'object' && 'blocked' in obj) {
utils.reportSettings(
`\`settings.jsdoc.tagNamePreference\` cannot block @${obj.tagName} ` +
'for the `require-file-overview` rule',
);
} else {
const targetTagName = (
obj && typeof obj === 'object' && obj.replacement
) || obj;
if (mustExist && !state.hasTag[tagName]) {
utils.reportSettings(`Missing @${targetTagName}`);
}
if (preventDuplicates && state.hasDuplicates[tagName]) {
utils.reportSettings(
`Duplicate @${targetTagName}`,
);
}
if (initialCommentsOnly &&
state.hasNonCommentBeforeTag[tagName]
) {
utils.reportSettings(
`@${targetTagName} should be at the beginning of the file`,
);
}
}
}
},
iterateAllJsdocs: true,
meta: {
docs: {
description: 'Checks that all files have one `@file`, `@fileoverview`, or `@overview` tag at the beginning of the file.',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/require-file-overview.md#repos-sticky-header',
},
schema: [
{
additionalProperties: false,
properties: {
tags: {
description: `The keys of this object are tag names, and the values are configuration
objects indicating what will be checked for these whole-file tags.
Each configuration object has 3 potential boolean keys (which default
to \`false\` when this option is supplied).
1. \`mustExist\` - enforces that all files have a \`@file\`, \`@fileoverview\`, or \`@overview\` tag.
2. \`preventDuplicates\` - enforces that duplicate file overview tags within a given file will be reported
3. \`initialCommentsOnly\` - reports file overview tags which are not, as per
[the docs](https://jsdoc.app/tags-file.html), "at the beginning of
the file"where beginning of the file is interpreted in this rule
as being when the overview tag is not preceded by anything other than
a comment.
When no \`tags\` is present, the default is:
\`\`\`json
{
"file": {
"initialCommentsOnly": true,
"mustExist": true,
"preventDuplicates": true,
}
}
\`\`\`
You can add additional tag names and/or override \`file\` if you supply this
option, e.g., in place of or in addition to \`file\`, giving other potential
file global tags like \`@license\`, \`@copyright\`, \`@author\`, \`@module\` or
\`@exports\`, optionally restricting them to a single use or preventing them
from being preceded by anything besides comments.
For example:
\`\`\`js
{
"license": {
"mustExist": true,
"preventDuplicates": true,
}
}
\`\`\`
This would require one and only one \`@license\` in the file, though because
\`initialCommentsOnly\` is absent and defaults to \`false\`, the \`@license\`
can be anywhere.
In the case of \`@license\`, you can use this rule along with the
\`check-values\` rule (with its \`allowedLicenses\` or \`licensePattern\` options),
to enforce a license whitelist be present on every JS file.
Note that if you choose to use \`preventDuplicates\` with \`license\`, you still
have a way to allow multiple licenses for the whole page by using the SPDX
"AND" expression, e.g., \`@license (MIT AND GPL-3.0)\`.
Note that the tag names are the main JSDoc tag name, so you should use \`file\`
in this configuration object regardless of whether you have configured
\`fileoverview\` instead of \`file\` on \`tagNamePreference\` (i.e., \`fileoverview\`
will be checked, but you must use \`file\` on the configuration object).`,
patternProperties: {
'.*': {
additionalProperties: false,
properties: {
initialCommentsOnly: {
type: 'boolean',
},
mustExist: {
type: 'boolean',
},
preventDuplicates: {
type: 'boolean',
},
},
type: 'object',
},
},
type: 'object',
},
},
type: 'object',
},
],
type: 'suggestion',
},
nonComment ({
node,
state,
}) {
if (!state.hasNonComment) {
state.hasNonComment = /** @type {[number, number]} */ (node.range)?.[0];
}
},
});

View file

@ -0,0 +1,210 @@
import iterateJsdoc from '../iterateJsdoc.js';
export default iterateJsdoc(({
context,
jsdoc,
utils,
}) => {
const [
mainCircumstance,
{
tags = null,
} = {},
] = context.options;
const tgs = /**
* @type {null|"any"|{[key: string]: "always"|"never"}}
*/ (tags);
/**
* @param {import('@es-joy/jsdoccomment').JsdocTagWithInline} jsdocTag
* @param {string} targetTagName
* @param {"always"|"never"} [circumstance]
* @returns {void}
*/
const checkHyphens = (jsdocTag, targetTagName, circumstance = mainCircumstance) => {
const always = !circumstance || circumstance === 'always';
const desc = /** @type {string} */ (utils.getTagDescription(jsdocTag));
if (!desc.trim()) {
return;
}
const startsWithHyphen = (/^\s*-/v).test(desc);
const hyphenNewline = (/^\s*-\n/v).test(desc);
let lines = 0;
for (const {
tokens,
} of jsdocTag.source) {
if (tokens.description) {
break;
}
lines++;
}
if (always && !hyphenNewline) {
if (!startsWithHyphen) {
let fixIt = true;
for (const {
tokens,
} of jsdocTag.source) {
if (tokens.description) {
tokens.description = tokens.description.replace(
/^(\s*)/v, '$1- ',
);
break;
}
// Linebreak after name since has no description
if (tokens.name) {
fixIt = false;
break;
}
}
if (fixIt) {
utils.reportJSDoc(
`There must be a hyphen before @${targetTagName} description.`,
{
line: jsdocTag.source[0].number + lines,
},
() => {},
);
}
}
} else if (startsWithHyphen) {
utils.reportJSDoc(
always ?
`There must be no hyphen followed by newline after the @${targetTagName} name.` :
`There must be no hyphen before @${targetTagName} description.`,
{
line: jsdocTag.source[0].number + lines,
},
() => {
for (const {
tokens,
} of jsdocTag.source) {
if (tokens.description) {
tokens.description = tokens.description.replace(
/^\s*-\s*/v, '',
);
if (hyphenNewline) {
tokens.postName = '';
}
break;
}
}
},
true,
);
}
};
utils.forEachPreferredTag('param', checkHyphens);
if (tgs) {
const tagEntries = Object.entries(tgs);
for (const [
tagName,
circumstance,
] of tagEntries) {
if (tagName === '*') {
const preferredParamTag = utils.getPreferredTagName({
tagName: 'param',
});
for (const {
tag,
} of jsdoc.tags) {
if (tag === preferredParamTag || tagEntries.some(([
tagNme,
]) => {
return tagNme !== '*' && tagNme === tag;
})) {
continue;
}
utils.forEachPreferredTag(tag, (jsdocTag, targetTagName) => {
checkHyphens(
jsdocTag,
targetTagName,
/** @type {"always"|"never"} */ (circumstance),
);
});
}
continue;
}
utils.forEachPreferredTag(tagName, (jsdocTag, targetTagName) => {
checkHyphens(
jsdocTag,
targetTagName,
/** @type {"always"|"never"} */ (circumstance),
);
});
}
}
}, {
iterateAllJsdocs: true,
meta: {
docs: {
description: 'Requires a hyphen before the `@param` description (and optionally before `@property` descriptions).',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/require-hyphen-before-param-description.md#repos-sticky-header',
},
fixable: 'code',
schema: [
{
description: `If the string is \`"always"\` then a problem is raised when there is no hyphen
before the description. If it is \`"never"\` then a problem is raised when there
is a hyphen before the description. The default value is \`"always"\`.
Even if hyphens are set to "always" appear after the tag name, they will
actually be forbidden in the event that they are followed immediately by
the end of a line (this will otherwise cause Visual Studio Code to display
incorrectly).`,
enum: [
'always', 'never',
],
type: 'string',
},
{
additionalProperties: false,
description: `The options object may have the following property to indicate behavior for
other tags besides the \`@param\` tag (or the \`@arg\` tag if so set).`,
properties: {
tags: {
anyOf: [
{
patternProperties: {
'.*': {
enum: [
'always', 'never',
],
type: 'string',
},
},
type: 'object',
},
{
enum: [
'any',
],
type: 'string',
},
],
description: `Object whose keys indicate different tags to check for the
presence or absence of hyphens; the key value should be "always" or "never",
indicating how hyphens are to be applied, e.g., \`{property: 'never'}\`
to ensure \`@property\` never uses hyphens. A key can also be set as \`*\`, e.g.,
\`'*': 'always'\` to apply hyphen checking to any tag (besides the preferred
\`@param\` tag which follows the main string option setting and besides any
other \`tags\` entries).`,
},
},
type: 'object',
},
],
type: 'layout',
},
});

View file

@ -0,0 +1,897 @@
import exportParser from '../exportParser.js';
import {
getSettings,
} from '../iterateJsdoc.js';
import {
enforcedContexts,
exemptSpeciaMethods,
getContextObject,
getFunctionParameterNames,
getIndent,
hasReturnValue,
isConstructor,
} from '../jsdocUtils.js';
import {
getDecorator,
getJSDocComment,
getReducedASTNode,
} from '@es-joy/jsdoccomment';
/**
* @typedef {{
* ancestorsOnly: boolean,
* esm: boolean,
* initModuleExports: boolean,
* initWindow: boolean
* }} RequireJsdocOpts
*/
/**
* @typedef {import('eslint').Rule.Node|
* import('@typescript-eslint/types').TSESTree.Node} ESLintOrTSNode
*/
/** @type {import('json-schema').JSONSchema4} */
const OPTIONS_SCHEMA = {
additionalProperties: false,
description: 'Has the following optional keys.\n',
properties: {
checkAllFunctionExpressions: {
default: false,
description: `Normally, when \`FunctionExpression\` is checked, additional checks are
added to check the parent contexts where reporting is likely to be desired. If you really
want to check *all* function expressions, then set this to \`true\`.`,
type: 'boolean',
},
checkConstructors: {
default: true,
description: `A value indicating whether \`constructor\`s should be checked. Defaults to
\`true\`. When \`true\`, \`exemptEmptyConstructors\` may still avoid reporting when
no parameters or return values are found.`,
type: 'boolean',
},
checkGetters: {
anyOf: [
{
type: 'boolean',
},
{
enum: [
'no-setter',
],
type: 'string',
},
],
default: true,
description: `A value indicating whether getters should be checked. Besides setting as a
boolean, this option can be set to the string \`"no-setter"\` to indicate that
getters should be checked but only when there is no setter. This may be useful
if one only wishes documentation on one of the two accessors. Defaults to
\`false\`.`,
},
checkSetters: {
anyOf: [
{
type: 'boolean',
},
{
enum: [
'no-getter',
],
type: 'string',
},
],
default: true,
description: `A value indicating whether setters should be checked. Besides setting as a
boolean, this option can be set to the string \`"no-getter"\` to indicate that
setters should be checked but only when there is no getter. This may be useful
if one only wishes documentation on one of the two accessors. Defaults to
\`false\`.`,
},
contexts: {
description: `Set this to an array of strings or objects representing the additional AST
contexts where you wish the rule to be applied (e.g., \`Property\` for
properties). If specified as an object, it should have a \`context\` property
and can have an \`inlineCommentBlock\` property which, if set to \`true\`, will
add an inline \`/** */\` instead of the regular, multi-line, indented jsdoc
block which will otherwise be added. Defaults to an empty array. Contexts
may also have their own \`minLineCount\` property which is an integer
indicating a minimum number of lines expected for a node in order
for it to require documentation.
Note that you may need to disable \`require\` items (e.g., \`MethodDefinition\`)
if you are specifying a more precise form in \`contexts\` (e.g., \`MethodDefinition:not([accessibility="private"] > FunctionExpression\`).
See the ["AST and Selectors"](../#advanced-ast-and-selectors)
section of our Advanced docs for more on the expected format.`,
items: {
anyOf: [
{
type: 'string',
},
{
additionalProperties: false,
properties: {
context: {
type: 'string',
},
inlineCommentBlock: {
type: 'boolean',
},
minLineCount: {
type: 'integer',
},
},
type: 'object',
},
],
},
type: 'array',
},
enableFixer: {
default: true,
description: `A boolean on whether to enable the fixer (which adds an empty JSDoc block).
Defaults to \`true\`.`,
type: 'boolean',
},
exemptEmptyConstructors: {
default: false,
description: `When \`true\`, the rule will not report missing JSDoc blocks above constructors
with no parameters or return values (this is enabled by default as the class
name or description should be seen as sufficient to convey intent).
Defaults to \`true\`.`,
type: 'boolean',
},
exemptEmptyFunctions: {
default: false,
description: `When \`true\`, the rule will not report missing JSDoc blocks above
functions/methods with no parameters or return values (intended where
function/method names are sufficient for themselves as documentation).
Defaults to \`false\`.`,
type: 'boolean',
},
exemptOverloadedImplementations: {
default: false,
description: `If set to \`true\` will avoid checking an overloaded function's implementation.
Defaults to \`false\`.`,
type: 'boolean',
},
fixerMessage: {
default: '',
description: `An optional message to add to the inserted JSDoc block. Defaults to the
empty string.`,
type: 'string',
},
minLineCount: {
description: `An integer to indicate a minimum number of lines expected for a node in order
for it to require documentation. Defaults to \`undefined\`. This option will
apply to any context; see \`contexts\` for line counts specific to a context.`,
type: 'integer',
},
publicOnly: {
description: `This option will insist that missing JSDoc blocks are only reported for
function bodies / class declarations that are exported from the module.
May be a boolean or object. If set to \`true\`, the defaults below will be
used. If unset, JSDoc block reporting will not be limited to exports.
This object supports the following optional boolean keys (\`false\` unless
otherwise noted):
- \`ancestorsOnly\` - Optimization to only check node ancestors to check if node is exported
- \`esm\` - ESM exports are checked for JSDoc comments (Defaults to \`true\`)
- \`cjs\` - CommonJS exports are checked for JSDoc comments (Defaults to \`true\`)
- \`window\` - Window global exports are checked for JSDoc comments`,
oneOf: [
{
default: false,
type: 'boolean',
},
{
additionalProperties: false,
default: {},
properties: {
ancestorsOnly: {
type: 'boolean',
},
cjs: {
type: 'boolean',
},
esm: {
type: 'boolean',
},
window: {
type: 'boolean',
},
},
type: 'object',
},
],
},
require: {
additionalProperties: false,
default: {},
description: `An object with the following optional boolean keys which all default to
\`false\` except for \`FunctionDeclaration\` which defaults to \`true\`.`,
properties: {
ArrowFunctionExpression: {
default: false,
description: 'Whether to check arrow functions like `() => {}`',
type: 'boolean',
},
ClassDeclaration: {
default: false,
description: 'Whether to check declarations like `class A {}`',
type: 'boolean',
},
ClassExpression: {
default: false,
description: 'Whether to check class expressions like `const myClass = class {}`',
type: 'boolean',
},
FunctionDeclaration: {
default: true,
description: 'Whether to check function declarations like `function a {}`',
type: 'boolean',
},
FunctionExpression: {
default: false,
description: 'Whether to check function expressions like `const a = function {}`',
type: 'boolean',
},
MethodDefinition: {
default: false,
description: 'Whether to check method definitions like `class A { someMethodDefinition () {} }`',
type: 'boolean',
},
},
type: 'object',
},
skipInterveningOverloadedDeclarations: {
default: true,
description: `If \`true\`, will skip above uncommented overloaded functions to check
for a comment block (e.g., at the top of a set of overloaded functions).
If \`false\`, will force each overloaded function to be checked for a
comment block.
Defaults to \`true\`.`,
type: 'boolean',
},
},
type: 'object',
};
/**
* @param {string} interfaceName
* @param {string} methodName
* @param {import("eslint").Scope.Scope | null} scope
* @returns {import('@typescript-eslint/types').TSESTree.TSMethodSignature|null}
*/
const getMethodOnInterface = (interfaceName, methodName, scope) => {
let scp = scope;
while (scp) {
for (const {
identifiers,
name,
} of scp.variables) {
if (interfaceName !== name) {
continue;
}
for (const identifier of identifiers) {
const interfaceDeclaration = /** @type {import('@typescript-eslint/types').TSESTree.Identifier & {parent: import('@typescript-eslint/types').TSESTree.TSInterfaceDeclaration}} */ (
identifier
).parent;
/* c8 ignore next 3 -- TS */
if (interfaceDeclaration.type !== 'TSInterfaceDeclaration') {
continue;
}
for (const bodyItem of interfaceDeclaration.body.body) {
const methodSig = /** @type {import('@typescript-eslint/types').TSESTree.TSMethodSignature} */ (
bodyItem
);
if (methodName === /** @type {import('@typescript-eslint/types').TSESTree.Identifier} */ (
methodSig.key
).name) {
return methodSig;
}
}
}
}
scp = scp.upper;
}
return null;
};
/**
* @param {import('eslint').Rule.Node} node
* @param {import('eslint').SourceCode} sourceCode
* @param {import('eslint').Rule.RuleContext} context
* @param {import('../iterateJsdoc.js').Settings} settings
*/
const isExemptedImplementer = (node, sourceCode, context, settings) => {
if (node.type === 'FunctionExpression' &&
node.parent.type === 'MethodDefinition' &&
node.parent.parent.type === 'ClassBody' &&
node.parent.parent.parent.type === 'ClassDeclaration' &&
'implements' in node.parent.parent.parent
) {
const implments = /** @type {import('@typescript-eslint/types').TSESTree.TSClassImplements[]} */ (
node.parent.parent.parent.implements
);
const {
name: methodName,
} = /** @type {import('@typescript-eslint/types').TSESTree.Identifier} */ (
node.parent.key
);
for (const impl of implments) {
const {
name: interfaceName,
} = /** @type {import('@typescript-eslint/types').TSESTree.Identifier} */ (
impl.expression
);
const interfaceMethodNode = getMethodOnInterface(interfaceName, methodName, node && (
(sourceCode.getScope &&
/* c8 ignore next 3 */
sourceCode.getScope(node)) ||
// @ts-expect-error ESLint 8
context.getScope()
));
if (interfaceMethodNode) {
// @ts-expect-error Ok
const comment = getJSDocComment(sourceCode, interfaceMethodNode, settings);
if (comment) {
return true;
}
}
}
}
return false;
};
/**
* @param {import('eslint').Rule.RuleContext} context
* @param {import('json-schema').JSONSchema4Object} baseObject
* @param {string} option
* @param {string} key
* @returns {boolean|undefined}
*/
const getOption = (context, baseObject, option, key) => {
if (context.options[0] && option in context.options[0] &&
// Todo: boolean shouldn't be returning property, but
// tests currently require
(typeof context.options[0][option] === 'boolean' ||
key in context.options[0][option])
) {
return context.options[0][option][key];
}
return /** @type {{[key: string]: {default?: boolean|undefined}}} */ (
baseObject.properties
)[key].default;
};
/**
* @param {import('eslint').Rule.RuleContext} context
* @param {import('../iterateJsdoc.js').Settings} settings
* @returns {{
* checkAllFunctionExpressions: boolean,
* contexts: (string|{
* context: string,
* inlineCommentBlock: boolean,
* minLineCount: import('../iterateJsdoc.js').Integer
* })[],
* enableFixer: boolean,
* exemptEmptyConstructors: boolean,
* exemptEmptyFunctions: boolean,
* skipInterveningOverloadedDeclarations: boolean,
* exemptOverloadedImplementations: boolean,
* fixerMessage: string,
* minLineCount: undefined|import('../iterateJsdoc.js').Integer,
* publicOnly: boolean|{[key: string]: boolean|undefined}
* require: {[key: string]: boolean|undefined}
* }}
*/
const getOptions = (context, settings) => {
const {
checkAllFunctionExpressions = false,
contexts = settings.contexts || [],
enableFixer = true,
exemptEmptyConstructors = true,
exemptEmptyFunctions = false,
exemptOverloadedImplementations = false,
fixerMessage = '',
minLineCount = undefined,
publicOnly,
skipInterveningOverloadedDeclarations = true,
} = context.options[0] || {};
return {
checkAllFunctionExpressions,
contexts,
enableFixer,
exemptEmptyConstructors,
exemptEmptyFunctions,
exemptOverloadedImplementations,
fixerMessage,
minLineCount,
publicOnly: ((baseObj) => {
if (!publicOnly) {
return false;
}
/** @type {{[key: string]: boolean|undefined}} */
const properties = {};
for (const prop of Object.keys(
/** @type {import('json-schema').JSONSchema4Object} */ (
/** @type {import('json-schema').JSONSchema4Object} */ (
baseObj
).properties),
)) {
const opt = getOption(
context,
/** @type {import('json-schema').JSONSchema4Object} */ (baseObj),
'publicOnly',
prop,
);
properties[prop] = opt;
}
return properties;
})(
/** @type {import('json-schema').JSONSchema4Object} */
(
/** @type {import('json-schema').JSONSchema4Object} */
(
/** @type {import('json-schema').JSONSchema4Object} */
(
OPTIONS_SCHEMA.properties
).publicOnly
).oneOf
)[1],
),
require: ((baseObj) => {
/** @type {{[key: string]: boolean|undefined}} */
const properties = {};
for (const prop of Object.keys(
/** @type {import('json-schema').JSONSchema4Object} */ (
/** @type {import('json-schema').JSONSchema4Object} */ (
baseObj
).properties),
)) {
const opt = getOption(
context,
/** @type {import('json-schema').JSONSchema4Object} */
(baseObj),
'require',
prop,
);
properties[prop] = opt;
}
return properties;
})(
/** @type {import('json-schema').JSONSchema4Object} */
(OPTIONS_SCHEMA.properties).require,
),
skipInterveningOverloadedDeclarations,
};
};
/**
* @param {ESLintOrTSNode} node
*/
const isFunctionWithOverload = (node) => {
if (node.type !== 'FunctionDeclaration') {
return false;
}
let parent;
let child;
if (node.parent?.type === 'Program') {
parent = node.parent;
child = node;
} else if (node.parent?.type === 'ExportNamedDeclaration' &&
node.parent?.parent.type === 'Program') {
parent = node.parent?.parent;
child = node.parent;
}
if (!child || !parent) {
return false;
}
const functionName = node.id?.name;
const idx = parent.body.indexOf(child);
const prevSibling = parent.body[idx - 1];
return (
// @ts-expect-error Should be ok
(prevSibling?.type === 'TSDeclareFunction' &&
// @ts-expect-error Should be ok
functionName === prevSibling.id.name) ||
(prevSibling?.type === 'ExportNamedDeclaration' &&
// @ts-expect-error Should be ok
prevSibling.declaration?.type === 'TSDeclareFunction' &&
// @ts-expect-error Should be ok
prevSibling.declaration?.id?.name === functionName)
);
};
/** @type {import('eslint').Rule.RuleModule} */
export default {
create (context) {
/* c8 ignore next -- Fallback to deprecated method */
const {
sourceCode = context.getSourceCode(),
} = context;
const settings = getSettings(context);
if (!settings) {
return {};
}
const opts = getOptions(context, settings);
const {
checkAllFunctionExpressions,
contexts,
enableFixer,
exemptEmptyConstructors,
exemptEmptyFunctions,
exemptOverloadedImplementations,
fixerMessage,
minLineCount,
require: requireOption,
skipInterveningOverloadedDeclarations,
} = opts;
const publicOnly =
/**
* @type {{
* [key: string]: boolean | undefined;
* }}
*/ (
opts.publicOnly
);
/**
* @type {import('../iterateJsdoc.js').CheckJsdoc}
*/
const checkJsDoc = (info, _handler, node) => {
if (
// Optimize
minLineCount !== undefined || contexts.some((ctxt) => {
if (typeof ctxt === 'string') {
return false;
}
const {
minLineCount: count,
} = ctxt;
return count !== undefined;
})
) {
/**
* @param {undefined|import('../iterateJsdoc.js').Integer} count
*/
const underMinLine = (count) => {
return count !== undefined && count >
(sourceCode.getText(node).match(/\n/gv)?.length ?? 0) + 1;
};
if (underMinLine(minLineCount)) {
return;
}
const {
minLineCount: contextMinLineCount,
} =
/**
* @type {{
* context: string;
* inlineCommentBlock: boolean;
* minLineCount: number;
* }}
*/ (contexts.find((ctxt) => {
if (typeof ctxt === 'string') {
return false;
}
const {
context: ctx,
} = ctxt;
return ctx === (info.selector || node.type);
})) || {};
if (underMinLine(contextMinLineCount)) {
return;
}
}
if (exemptOverloadedImplementations && isFunctionWithOverload(node)) {
return;
}
const jsDocNode = getJSDocComment(
sourceCode, node, settings, {
checkOverloads: skipInterveningOverloadedDeclarations,
},
);
if (jsDocNode) {
return;
}
// For those who have options configured against ANY constructors (or
// setters or getters) being reported
if (exemptSpeciaMethods(
{
description: '',
inlineTags: [],
problems: [],
source: [],
tags: [],
},
node,
context,
[
OPTIONS_SCHEMA,
],
)) {
return;
}
if (
// Avoid reporting param-less, return-less functions (when
// `exemptEmptyFunctions` option is set)
exemptEmptyFunctions && info.isFunctionContext ||
// Avoid reporting param-less, return-less constructor methods (when
// `exemptEmptyConstructors` option is set)
exemptEmptyConstructors && isConstructor(node)
) {
const functionParameterNames = getFunctionParameterNames(node);
if (!functionParameterNames.length && !hasReturnValue(node)) {
return;
}
}
if (isExemptedImplementer(node, sourceCode, context, settings)) {
return;
}
const fix = /** @type {import('eslint').Rule.ReportFixer} */ (fixer) => {
// Default to one line break if the `minLines`/`maxLines` settings allow
const lines = settings.minLines === 0 && settings.maxLines >= 1 ? 1 : settings.minLines;
/** @type {ESLintOrTSNode|import('@typescript-eslint/types').TSESTree.Decorator} */
let baseNode = getReducedASTNode(node, sourceCode);
const decorator = getDecorator(
/** @type {import('eslint').Rule.Node} */
(baseNode),
);
if (decorator) {
baseNode = decorator;
}
const indent = getIndent({
text: sourceCode.getText(
/** @type {import('eslint').Rule.Node} */ (baseNode),
/** @type {import('eslint').AST.SourceLocation} */
(
/** @type {import('eslint').Rule.Node} */ (baseNode).loc
).start.column,
),
});
const {
inlineCommentBlock,
} =
/**
* @type {{
* context: string,
* inlineCommentBlock: boolean,
* minLineCount: import('../iterateJsdoc.js').Integer
* }}
*/ (contexts.find((contxt) => {
if (typeof contxt === 'string') {
return false;
}
const {
context: ctxt,
} = contxt;
return ctxt === node.type;
})) || {};
const insertion = (inlineCommentBlock ?
`/** ${fixerMessage}` :
`/**\n${indent}*${fixerMessage}\n${indent}`) +
`*/${'\n'.repeat(lines)}${indent.slice(0, -1)}`;
return fixer.insertTextBefore(
/** @type {import('eslint').Rule.Node} */
(baseNode),
insertion,
);
};
const report = () => {
const {
start,
} = /** @type {import('eslint').AST.SourceLocation} */ (node.loc);
const loc = {
end: {
column: 0,
line: start.line + 1,
},
start,
};
context.report({
fix: enableFixer ? fix : null,
loc,
messageId: 'missingJsDoc',
node,
});
};
if (publicOnly) {
/** @type {RequireJsdocOpts} */
const opt = {
ancestorsOnly: Boolean(publicOnly?.ancestorsOnly ?? false),
esm: Boolean(publicOnly?.esm ?? true),
initModuleExports: Boolean(publicOnly?.cjs ?? true),
initWindow: Boolean(publicOnly?.window ?? false),
};
const exported = exportParser.isUncommentedExport(node, sourceCode, opt, settings);
if (exported) {
report();
}
} else {
report();
}
};
/**
* @param {string} prop
* @returns {boolean}
*/
const hasOption = (prop) => {
return requireOption[prop] || contexts.some((ctxt) => {
return typeof ctxt === 'object' ? ctxt.context === prop : ctxt === prop;
});
};
return {
...getContextObject(
enforcedContexts(context, [], settings),
checkJsDoc,
),
ArrowFunctionExpression (node) {
if (!hasOption('ArrowFunctionExpression')) {
return;
}
if (
[
'AssignmentExpression', 'ExportDefaultDeclaration', 'VariableDeclarator',
].includes(node.parent.type) ||
[
'ClassProperty', 'ObjectProperty', 'Property', 'PropertyDefinition',
].includes(node.parent.type) &&
node ===
/**
* @type {import('@typescript-eslint/types').TSESTree.Property|
* import('@typescript-eslint/types').TSESTree.PropertyDefinition
* }
*/
(node.parent).value
) {
checkJsDoc({
isFunctionContext: true,
}, null, node);
}
},
ClassDeclaration (node) {
if (!hasOption('ClassDeclaration')) {
return;
}
checkJsDoc({
isFunctionContext: false,
}, null, node);
},
ClassExpression (node) {
if (!hasOption('ClassExpression')) {
return;
}
checkJsDoc({
isFunctionContext: false,
}, null, node);
},
FunctionDeclaration (node) {
if (!hasOption('FunctionDeclaration')) {
return;
}
checkJsDoc({
isFunctionContext: true,
}, null, node);
},
FunctionExpression (node) {
if (!hasOption('FunctionExpression')) {
return;
}
if (checkAllFunctionExpressions ||
[
'AssignmentExpression', 'ExportDefaultDeclaration', 'VariableDeclarator',
].includes(node.parent.type) ||
[
'ClassProperty', 'ObjectProperty', 'Property', 'PropertyDefinition',
].includes(node.parent.type) &&
node ===
/**
* @type {import('@typescript-eslint/types').TSESTree.Property|
* import('@typescript-eslint/types').TSESTree.PropertyDefinition
* }
*/
(node.parent).value
) {
checkJsDoc({
isFunctionContext: true,
}, null, node);
}
},
MethodDefinition (node) {
if (!hasOption('MethodDefinition')) {
return;
}
checkJsDoc({
isFunctionContext: true,
selector: 'MethodDefinition',
}, null, /** @type {import('eslint').Rule.Node} */ (node.value));
},
};
},
meta: {
docs: {
category: 'Stylistic Issues',
description: 'Checks for presence of JSDoc comments, on functions and potentially other contexts (optionally limited to exports).',
recommended: true,
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/require-jsdoc.md#repos-sticky-header',
},
fixable: 'code',
messages: {
missingJsDoc: 'Missing JSDoc comment.',
},
schema: [
OPTIONS_SCHEMA,
],
type: 'suggestion',
},
};

View file

@ -0,0 +1,848 @@
import iterateJsdoc from '../iterateJsdoc.js';
/**
* @typedef {[string, boolean, () => RootNamerReturn]} RootNamerReturn
*/
/**
* @param {string[]} desiredRoots
* @param {number} currentIndex
* @returns {RootNamerReturn}
*/
const rootNamer = (desiredRoots, currentIndex) => {
/** @type {string} */
let name;
let idx = currentIndex;
const incremented = desiredRoots.length <= 1;
if (incremented) {
const base = desiredRoots[0];
const suffix = idx++;
name = `${base}${suffix}`;
} else {
name = /** @type {string} */ (desiredRoots.shift());
}
return [
name,
incremented,
() => {
return rootNamer(desiredRoots, idx);
},
];
};
/* eslint-disable complexity -- Temporary */
export default iterateJsdoc(({
context,
jsdoc,
node,
utils,
}) => {
/* eslint-enable complexity -- Temporary */
if (utils.avoidDocs()) {
return;
}
// Param type is specified by type in @type
if (utils.hasTag('type')) {
return;
}
const {
autoIncrementBase = 0,
checkDestructured = true,
checkDestructuredRoots = true,
checkRestProperty = false,
checkTypesPattern = '/^(?:[oO]bject|[aA]rray|PlainObject|Generic(?:Object|Array))$/',
enableFixer = true,
enableRestElementFixer = true,
enableRootFixer = true,
ignoreWhenAllParamsMissing = false,
interfaceExemptsParamsCheck = false,
unnamedRootBase = [
'root',
],
useDefaultObjectProperties = false,
} = context.options[0] || {};
if (interfaceExemptsParamsCheck && node &&
node.parent?.type === 'VariableDeclarator' &&
'typeAnnotation' in node.parent.id && node.parent.id.typeAnnotation) {
return;
}
const preferredTagName = /** @type {string} */ (utils.getPreferredTagName({
tagName: 'param',
}));
if (!preferredTagName) {
return;
}
const functionParameterNames = utils.getFunctionParameterNames(useDefaultObjectProperties, interfaceExemptsParamsCheck);
if (!functionParameterNames.length) {
return;
}
const jsdocParameterNames =
/**
* @type {{
* idx: import('../iterateJsdoc.js').Integer;
* name: string;
* type: string;
* }[]}
*/ (utils.getJsdocTagsDeep(preferredTagName));
if (ignoreWhenAllParamsMissing && !jsdocParameterNames.length) {
return;
}
const shallowJsdocParameterNames = jsdocParameterNames.filter((tag) => {
return !tag.name.includes('.');
}).map((tag, idx) => {
return {
...tag,
idx,
};
});
const checkTypesRegex = utils.getRegexFromString(checkTypesPattern);
/**
* @type {{
* functionParameterIdx: import('../iterateJsdoc.js').Integer,
* functionParameterName: string,
* inc: boolean|undefined,
* remove?: true,
* type?: string|undefined
* }[]}
*/
const missingTags = [];
const flattenedRoots = utils.flattenRoots(functionParameterNames).names;
/**
* @type {{
* [key: string]: import('../iterateJsdoc.js').Integer
* }}
*/
const paramIndex = {};
/**
* @param {string} cur
* @returns {boolean}
*/
const hasParamIndex = (cur) => {
return utils.dropPathSegmentQuotes(String(cur)) in paramIndex;
};
/**
*
* @param {string|number|undefined} cur
* @returns {import('../iterateJsdoc.js').Integer}
*/
const getParamIndex = (cur) => {
return paramIndex[utils.dropPathSegmentQuotes(String(cur))];
};
/**
*
* @param {string} cur
* @param {import('../iterateJsdoc.js').Integer} idx
* @returns {void}
*/
const setParamIndex = (cur, idx) => {
paramIndex[utils.dropPathSegmentQuotes(String(cur))] = idx;
};
for (const [
idx,
cur,
] of flattenedRoots.entries()) {
setParamIndex(cur, idx);
}
/**
*
* @param {(import('@es-joy/jsdoccomment').JsdocTagWithInline & {
* newAdd?: boolean
* })[]} jsdocTags
* @param {import('../iterateJsdoc.js').Integer} indexAtFunctionParams
* @returns {{
* foundIndex: import('../iterateJsdoc.js').Integer,
* tagLineCount: import('../iterateJsdoc.js').Integer,
* }}
*/
const findExpectedIndex = (jsdocTags, indexAtFunctionParams) => {
// Get the parameters that come after the current index in the flattened order
const remainingFlattenedRoots = flattenedRoots.slice((indexAtFunctionParams || 0) + 1);
// Find the first existing tag that comes after the current parameter in the flattened order
const foundIndex = jsdocTags.findIndex(({
name,
newAdd,
}) => {
if (newAdd) {
return false;
}
// Check if the tag name matches any of the remaining flattened roots
return remainingFlattenedRoots.some((flattenedRoot) => {
// The flattened roots don't have the root prefix (e.g., "bar", "bar.baz")
// but JSDoc tags do (e.g., "root0", "root0.bar", "root0.bar.baz")
// So we need to check if the tag name ends with the flattened root
// Check if tag name ends with ".<flattenedRoot>"
if (name.endsWith(`.${flattenedRoot}`)) {
return true;
}
// Also check if tag name exactly matches the flattenedRoot
// (for single-level params)
if (name === flattenedRoot) {
return true;
}
return false;
});
});
const tags = foundIndex > -1 ?
jsdocTags.slice(0, foundIndex) :
jsdocTags.filter(({
tag,
}) => {
return tag === preferredTagName;
});
let tagLineCount = 0;
for (const {
source,
} of tags) {
for (const {
tokens: {
end,
},
} of source) {
if (!end) {
tagLineCount++;
}
}
}
return {
foundIndex,
tagLineCount,
};
};
let [
nextRootName,
incremented,
namer,
] = rootNamer([
...unnamedRootBase,
], autoIncrementBase);
const thisOffset = functionParameterNames[0] === 'this' ? 1 : 0;
for (const [
functionParameterIdx,
functionParameterName,
] of functionParameterNames.entries()) {
let inc;
if (Array.isArray(functionParameterName)) {
const matchedJsdoc = shallowJsdocParameterNames[functionParameterIdx - thisOffset];
/** @type {string} */
let rootName;
if (functionParameterName[0]) {
rootName = functionParameterName[0];
} else if (matchedJsdoc && matchedJsdoc.name) {
rootName = matchedJsdoc.name;
if (matchedJsdoc.type && matchedJsdoc.type.search(checkTypesRegex) === -1) {
continue;
}
} else {
rootName = nextRootName;
inc = incremented;
}
[
nextRootName,
incremented,
namer,
] = namer();
const {
hasPropertyRest,
hasRestElement,
names,
rests,
} = /**
* @type {import('../jsdocUtils.js').FlattendRootInfo & {
* annotationParamName?: string | undefined;
* }}
*/ (functionParameterName[1]);
const notCheckingNames = [];
if (!enableRestElementFixer && hasRestElement) {
continue;
}
if (!checkDestructuredRoots) {
continue;
}
for (const [
idx,
paramName,
] of names.entries()) {
// Add root if the root name is not in the docs (and is not already
// in the tags to be fixed)
if (!jsdocParameterNames.find(({
name,
}) => {
return name === rootName;
}) && !missingTags.find(({
functionParameterName: fpn,
}) => {
return fpn === rootName;
})) {
const emptyParamIdx = jsdocParameterNames.findIndex(({
name,
}) => {
return !name;
});
if (emptyParamIdx > -1) {
missingTags.push({
functionParameterIdx: emptyParamIdx,
functionParameterName: rootName,
inc,
remove: true,
});
} else {
missingTags.push({
functionParameterIdx: hasParamIndex(rootName) ?
getParamIndex(rootName) :
getParamIndex(paramName),
functionParameterName: rootName,
inc,
});
}
}
if (!checkDestructured) {
continue;
}
if (!checkRestProperty && rests[idx]) {
continue;
}
const fullParamName = `${rootName}.${paramName}`;
const notCheckingName = jsdocParameterNames.find(({
name,
type: paramType,
}) => {
return utils.comparePaths(name)(fullParamName) && paramType.search(checkTypesRegex) === -1 && paramType !== '';
});
if (notCheckingName !== undefined) {
notCheckingNames.push(notCheckingName.name);
}
if (notCheckingNames.find((name) => {
return fullParamName.startsWith(name);
})) {
continue;
}
if (jsdocParameterNames && !jsdocParameterNames.find(({
name,
}) => {
return utils.comparePaths(name)(fullParamName);
})) {
missingTags.push({
functionParameterIdx: getParamIndex(
functionParameterName[0] ? fullParamName : paramName,
),
functionParameterName: fullParamName,
inc,
type: hasRestElement && !hasPropertyRest ? '{...any}' : undefined,
});
}
}
continue;
}
/** @type {string} */
let funcParamName;
let type;
if (typeof functionParameterName === 'object') {
if (!enableRestElementFixer && functionParameterName.restElement) {
continue;
}
funcParamName = /** @type {string} */ (functionParameterName.name);
type = '{...any}';
} else {
funcParamName = /** @type {string} */ (functionParameterName);
}
if (jsdocParameterNames && !jsdocParameterNames.find(({
name,
}) => {
return name === funcParamName;
}) && funcParamName !== 'this') {
missingTags.push({
functionParameterIdx: getParamIndex(funcParamName),
functionParameterName: funcParamName,
inc,
type,
});
}
}
/**
*
* @param {{
* functionParameterIdx: import('../iterateJsdoc.js').Integer,
* functionParameterName: string,
* remove?: true,
* inc?: boolean,
* type?: string
* }} cfg
*/
const fix = ({
functionParameterIdx,
functionParameterName,
inc,
remove,
type,
}) => {
if (inc && !enableRootFixer) {
return;
}
/**
*
* @param {import('../iterateJsdoc.js').Integer} tagIndex
* @param {import('../iterateJsdoc.js').Integer} sourceIndex
* @param {import('../iterateJsdoc.js').Integer} spliceCount
* @returns {void}
*/
const createTokens = (tagIndex, sourceIndex, spliceCount) => {
// console.log(sourceIndex, tagIndex, jsdoc.tags, jsdoc.source);
const tokens = {
number: sourceIndex + 1,
source: '',
tokens: {
delimiter: '*',
description: '',
end: '',
lineEnd: '',
name: functionParameterName,
newAdd: true,
postDelimiter: ' ',
postName: '',
postTag: ' ',
postType: type ? ' ' : '',
start: jsdoc.source[sourceIndex].tokens.start,
tag: `@${preferredTagName}`,
type: type ?? '',
},
};
/**
* @type {(import('@es-joy/jsdoccomment').JsdocTagWithInline & {
* newAdd?: true
* })[]}
*/ (jsdoc.tags).splice(tagIndex, spliceCount, {
description: '',
inlineTags: [],
name: functionParameterName,
newAdd: true,
optional: false,
problems: [],
source: [
tokens,
],
tag: preferredTagName,
type: type ?? '',
});
const firstNumber = jsdoc.source[0].number;
jsdoc.source.splice(sourceIndex, spliceCount, tokens);
for (const [
idx,
src,
] of jsdoc.source.slice(sourceIndex).entries()) {
src.number = firstNumber + sourceIndex + idx;
}
};
const offset = jsdoc.source.findIndex(({
tokens: {
end,
tag,
},
}) => {
return tag || end;
});
if (remove) {
createTokens(functionParameterIdx, offset + functionParameterIdx, 1);
} else {
const {
foundIndex,
tagLineCount: expectedIdx,
} =
findExpectedIndex(jsdoc.tags, functionParameterIdx);
const firstParamLine = jsdoc.source.findIndex(({
tokens,
}) => {
return tokens.tag === `@${preferredTagName}`;
});
const baseOffset = foundIndex > -1 || firstParamLine === -1 ?
offset :
firstParamLine;
createTokens(expectedIdx, baseOffset + expectedIdx, 0);
}
};
/**
* @returns {void}
*/
const fixer = () => {
for (const missingTag of missingTags) {
fix(missingTag);
}
};
if (missingTags.length && jsdoc.source.length === 1) {
utils.makeMultiline();
}
for (const {
functionParameterName,
} of missingTags) {
utils.reportJSDoc(
`Missing JSDoc @${preferredTagName} "${functionParameterName}" declaration.`,
null,
enableFixer ? fixer : null,
);
}
}, {
contextDefaults: true,
meta: {
docs: {
description: 'Requires that all function parameters are documented with a `@param` tag.',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/require-param.md#repos-sticky-header',
},
fixable: 'code',
schema: [
{
additionalProperties: false,
properties: {
autoIncrementBase: {
default: 0,
description: `Numeric to indicate the number at which to begin auto-incrementing roots.
Defaults to \`0\`.`,
type: 'integer',
},
checkConstructors: {
default: true,
description: `A value indicating whether \`constructor\`s should be checked. Defaults to
\`true\`.`,
type: 'boolean',
},
checkDestructured: {
default: true,
description: 'Whether to require destructured properties. Defaults to `true`.',
type: 'boolean',
},
checkDestructuredRoots: {
default: true,
description: `Whether to check the existence of a corresponding \`@param\` for root objects
of destructured properties (e.g., that for \`function ({a, b}) {}\`, that there
is something like \`@param myRootObj\` defined that can correspond to
the \`{a, b}\` object parameter).
If \`checkDestructuredRoots\` is \`false\`, \`checkDestructured\` will also be
implied to be \`false\` (i.e., the inside of the roots will not be checked
either, e.g., it will also not complain if \`a\` or \`b\` do not have their own
documentation). Defaults to \`true\`.`,
type: 'boolean',
},
checkGetters: {
default: false,
description: 'A value indicating whether getters should be checked. Defaults to `false`.',
type: 'boolean',
},
checkRestProperty: {
default: false,
description: `If set to \`true\`, will report (and add fixer insertions) for missing rest
properties. Defaults to \`false\`.
If set to \`true\`, note that you can still document the subproperties of the
rest property using other jsdoc features, e.g., \`@typedef\`:
\`\`\`js
/**
* @typedef ExtraOptions
* @property innerProp1
* @property innerProp2
*/
/**
* @param cfg
* @param cfg.num
* @param {ExtraOptions} extra
*/
function quux ({num, ...extra}) {
}
\`\`\`
Setting this option to \`false\` (the default) may be useful in cases where
you already have separate \`@param\` definitions for each of the properties
within the rest property.
For example, with the option disabled, this will not give an error despite
\`extra\` not having any definition:
\`\`\`js
/**
* @param cfg
* @param cfg.num
*/
function quux ({num, ...extra}) {
}
\`\`\`
Nor will this:
\`\`\`js
/**
* @param cfg
* @param cfg.num
* @param cfg.innerProp1
* @param cfg.innerProp2
*/
function quux ({num, ...extra}) {
}
\`\`\``,
type: 'boolean',
},
checkSetters: {
default: false,
description: 'A value indicating whether setters should be checked. Defaults to `false`.',
type: 'boolean',
},
checkTypesPattern: {
description: `When one specifies a type, unless it is of a generic type, like \`object\`
or \`array\`, it may be considered unnecessary to have that object's
destructured components required, especially where generated docs will
link back to the specified type. For example:
\`\`\`js
/**
* @param {SVGRect} bbox - a SVGRect
*/
export const bboxToObj = function ({x, y, width, height}) {
return {x, y, width, height};
};
\`\`\`
By default \`checkTypesPattern\` is set to
\`/^(?:[oO]bject|[aA]rray|PlainObject|Generic(?:Object|Array))$/v\`,
meaning that destructuring will be required only if the type of the \`@param\`
(the text between curly brackets) is a match for "Object" or "Array" (with or
without initial caps), "PlainObject", or "GenericObject", "GenericArray" (or
if no type is present). So in the above example, the lack of a match will
mean that no complaint will be given about the undocumented destructured
parameters.
Note that the \`/\` delimiters are optional, but necessary to add flags.
Defaults to using (only) the \`v\` flag, so to add your own flags, encapsulate
your expression as a string, but like a literal, e.g., \`/^object$/vi\`.
You could set this regular expression to a more expansive list, or you
could restrict it such that even types matching those strings would not
need destructuring.`,
type: 'string',
},
contexts: {
description: `Set this to an array of strings representing the AST context (or an object with
optional \`context\` and \`comment\` properties) where you wish the rule to be applied.
\`context\` defaults to \`any\` and \`comment\` defaults to no specific comment context.
Overrides the default contexts (\`ArrowFunctionExpression\`, \`FunctionDeclaration\`,
\`FunctionExpression\`). May be useful for adding such as
\`TSMethodSignature\` in TypeScript or restricting the contexts
which are checked.
See the ["AST and Selectors"](../#advanced-ast-and-selectors)
section of our Advanced docs for more on the expected format.`,
items: {
anyOf: [
{
type: 'string',
},
{
additionalProperties: false,
properties: {
comment: {
type: 'string',
},
context: {
type: 'string',
},
},
type: 'object',
},
],
},
type: 'array',
},
enableFixer: {
description: 'Whether to enable the fixer. Defaults to `true`.',
type: 'boolean',
},
enableRestElementFixer: {
description: `Whether to enable the rest element fixer.
The fixer will automatically report/insert
[JSDoc repeatable parameters](https://jsdoc.app/tags-param.html#multiple-types-and-repeatable-parameters)
if missing.
\`\`\`js
/**
* @param {GenericArray} cfg
* @param {number} cfg."0"
*/
function baar ([a, ...extra]) {
//
}
\`\`\`
...becomes:
\`\`\`js
/**
* @param {GenericArray} cfg
* @param {number} cfg."0"
* @param {...any} cfg."1"
*/
function baar ([a, ...extra]) {
//
}
\`\`\`
Note that the type \`any\` is included since we don't know of any specific
type to use.
Defaults to \`true\`.`,
type: 'boolean',
},
enableRootFixer: {
description: `Whether to enable the auto-adding of incrementing roots.
The default behavior of \`true\` is for "root" to be auto-inserted for missing
roots, followed by a 0-based auto-incrementing number.
So for:
\`\`\`js
function quux ({foo}, {bar}, {baz}) {
}
\`\`\`
...the default JSDoc that would be added if the fixer is enabled would be:
\`\`\`js
/**
* @param root0
* @param root0.foo
* @param root1
* @param root1.bar
* @param root2
* @param root2.baz
*/
\`\`\`
Has no effect if \`enableFixer\` is set to \`false\`.`,
type: 'boolean',
},
exemptedBy: {
description: `Array of tags (e.g., \`['type']\`) whose presence on the document block
avoids the need for a \`@param\`. Defaults to an array with
\`inheritdoc\`. If you set this array, it will overwrite the default,
so be sure to add back \`inheritdoc\` if you wish its presence to cause
exemption of the rule.`,
items: {
type: 'string',
},
type: 'array',
},
ignoreWhenAllParamsMissing: {
description: `Set to \`true\` to ignore reporting when all params are missing. Defaults to
\`false\`.`,
type: 'boolean',
},
interfaceExemptsParamsCheck: {
description: `Set if you wish TypeScript interfaces to exempt checks for the existence of
\`@param\`'s.
Will check for a type defining the function itself (on a variable
declaration) or if there is a single destructured object with a type.
Defaults to \`false\`.`,
type: 'boolean',
},
unnamedRootBase: {
description: `An array of root names to use in the fixer when roots are missing. Defaults
to \`['root']\`. Note that only when all items in the array besides the last
are exhausted will auto-incrementing occur. So, with
\`unnamedRootBase: ['arg', 'config']\`, the following:
\`\`\`js
function quux ({foo}, [bar], {baz}) {
}
\`\`\`
...will get the following JSDoc block added:
\`\`\`js
/**
* @param arg
* @param arg.foo
* @param config0
* @param config0."0" (\`bar\`)
* @param config1
* @param config1.baz
*/
\`\`\``,
items: {
type: 'string',
},
type: 'array',
},
useDefaultObjectProperties: {
description: `Set to \`true\` if you wish to expect documentation of properties on objects
supplied as default values. Defaults to \`false\`.`,
type: 'boolean',
},
},
type: 'object',
},
],
type: 'suggestion',
},
// We cannot cache comment nodes as the contexts may recur with the
// same comment node but a different JS node, and we may need the different
// JS node to ensure we iterate its context
noTracking: true,
});

View file

@ -0,0 +1,110 @@
import iterateJsdoc from '../iterateJsdoc.js';
export default iterateJsdoc(({
context,
report,
settings,
utils,
}) => {
const {
defaultDestructuredRootDescription = 'The root object',
setDefaultDestructuredRootDescription = false,
} = context.options[0] || {};
const functionParameterNames = utils.getFunctionParameterNames();
let rootCount = -1;
utils.forEachPreferredTag('param', (jsdocParameter, targetTagName) => {
rootCount += jsdocParameter.name.includes('.') ? 0 : 1;
if (!jsdocParameter.description.trim()) {
if (Array.isArray(functionParameterNames[rootCount])) {
if (settings.exemptDestructuredRootsFromChecks) {
return;
}
if (setDefaultDestructuredRootDescription) {
utils.reportJSDoc(`Missing root description for @${targetTagName}.`, jsdocParameter, () => {
utils.changeTag(jsdocParameter, {
description: defaultDestructuredRootDescription,
postName: ' ',
});
});
return;
}
}
report(
`Missing JSDoc @${targetTagName} "${jsdocParameter.name}" description.`,
null,
jsdocParameter,
);
}
});
}, {
contextDefaults: true,
meta: {
docs: {
description: 'Requires that each `@param` tag has a `description` value.',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/require-param-description.md#repos-sticky-header',
},
fixable: 'code',
schema: [
{
additionalProperties: false,
properties: {
contexts: {
description: `Set this to an array of strings representing the AST context (or an object with
optional \`context\` and \`comment\` properties) where you wish the rule to be applied.
\`context\` defaults to \`any\` and \`comment\` defaults to no specific comment context.
Overrides the default contexts (\`ArrowFunctionExpression\`, \`FunctionDeclaration\`,
\`FunctionExpression\`). Set to \`"any"\` if you want
the rule to apply to any JSDoc block throughout your files (as is necessary
for finding function blocks not attached to a function declaration or
expression, i.e., \`@callback\` or \`@function\` (or its aliases \`@func\` or
\`@method\`) (including those associated with an \`@interface\`).
See the ["AST and Selectors"](../#advanced-ast-and-selectors)
section of our Advanced docs for more on the expected format.`,
items: {
anyOf: [
{
type: 'string',
},
{
additionalProperties: false,
properties: {
comment: {
type: 'string',
},
context: {
type: 'string',
},
},
type: 'object',
},
],
},
type: 'array',
},
defaultDestructuredRootDescription: {
description: `The description string to set by default for destructured roots. Defaults to
"The root object".`,
type: 'string',
},
setDefaultDestructuredRootDescription: {
description: `Whether to set a default destructured root description. For example, you may
wish to avoid manually having to set the description for a \`@param\`
corresponding to a destructured root object as it should always be the same
type of object. Uses \`defaultDestructuredRootDescription\` for the description
string. Defaults to \`false\`.`,
type: 'boolean',
},
},
type: 'object',
},
],
type: 'suggestion',
},
});

View file

@ -0,0 +1,69 @@
import iterateJsdoc from '../iterateJsdoc.js';
export default iterateJsdoc(({
report,
utils,
}) => {
utils.forEachPreferredTag('param', (jsdocParameter, targetTagName) => {
if (jsdocParameter.tag && jsdocParameter.name === '') {
report(
`There must be an identifier after @${targetTagName} ${jsdocParameter.type === '' ? 'type' : 'tag'}.`,
null,
jsdocParameter,
);
}
});
}, {
contextDefaults: true,
meta: {
docs: {
description: 'Requires that all `@param` tags have names.',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/require-param-name.md#repos-sticky-header',
},
schema: [
{
additionalProperties: false,
properties: {
contexts: {
description: `Set this to an array of strings representing the AST context (or an object with
optional \`context\` and \`comment\` properties) where you wish the rule to be applied.
\`context\` defaults to \`any\` and \`comment\` defaults to no specific comment context.
Overrides the default contexts (\`ArrowFunctionExpression\`, \`FunctionDeclaration\`,
\`FunctionExpression\`). Set to \`"any"\` if you want
the rule to apply to any JSDoc block throughout your files (as is necessary
for finding function blocks not attached to a function declaration or
expression, i.e., \`@callback\` or \`@function\` (or its aliases \`@func\` or
\`@method\`) (including those associated with an \`@interface\`).
See the ["AST and Selectors"](../#advanced-ast-and-selectors)
section of our Advanced docs for more on the expected format.`,
items: {
anyOf: [
{
type: 'string',
},
{
additionalProperties: false,
properties: {
comment: {
type: 'string',
},
context: {
type: 'string',
},
},
type: 'object',
},
],
},
type: 'array',
},
},
type: 'object',
},
],
type: 'suggestion',
},
});

View file

@ -0,0 +1,109 @@
import iterateJsdoc from '../iterateJsdoc.js';
export default iterateJsdoc(({
context,
report,
settings,
utils,
}) => {
const {
defaultDestructuredRootType = 'object',
setDefaultDestructuredRootType = false,
} = context.options[0] || {};
const functionParameterNames = utils.getFunctionParameterNames();
let rootCount = -1;
utils.forEachPreferredTag('param', (jsdocParameter, targetTagName) => {
rootCount += jsdocParameter.name.includes('.') ? 0 : 1;
if (!jsdocParameter.type) {
if (Array.isArray(functionParameterNames[rootCount])) {
if (settings.exemptDestructuredRootsFromChecks) {
return;
}
if (setDefaultDestructuredRootType) {
utils.reportJSDoc(`Missing root type for @${targetTagName}.`, jsdocParameter, () => {
utils.changeTag(jsdocParameter, {
postType: ' ',
type: `{${defaultDestructuredRootType}}`,
});
});
return;
}
}
report(
`Missing JSDoc @${targetTagName} "${jsdocParameter.name}" type.`,
null,
jsdocParameter,
);
}
});
}, {
contextDefaults: true,
meta: {
docs: {
description: 'Requires that each `@param` tag has a type value (in curly brackets).',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/require-param-type.md#repos-sticky-header',
},
fixable: 'code',
schema: [
{
additionalProperties: false,
properties: {
contexts: {
description: `Set this to an array of strings representing the AST context (or an object with
optional \`context\` and \`comment\` properties) where you wish the rule to be applied.
\`context\` defaults to \`any\` and \`comment\` defaults to no specific comment context.
Overrides the default contexts (\`ArrowFunctionExpression\`, \`FunctionDeclaration\`,
\`FunctionExpression\`). Set to \`"any"\` if you want
the rule to apply to any JSDoc block throughout your files (as is necessary
for finding function blocks not attached to a function declaration or
expression, i.e., \`@callback\` or \`@function\` (or its aliases \`@func\` or
\`@method\`) (including those associated with an \`@interface\`).
See the ["AST and Selectors"](../#advanced-ast-and-selectors)
section of our Advanced docs for more on the expected format.`,
items: {
anyOf: [
{
type: 'string',
},
{
additionalProperties: false,
properties: {
comment: {
type: 'string',
},
context: {
type: 'string',
},
},
type: 'object',
},
],
},
type: 'array',
},
defaultDestructuredRootType: {
description: 'The type string to set by default for destructured roots. Defaults to "object".',
type: 'string',
},
setDefaultDestructuredRootType: {
description: `Whether to set a default destructured root type. For example, you may wish
to avoid manually having to set the type for a \`@param\`
corresponding to a destructured root object as it is always going to be an
object. Uses \`defaultDestructuredRootType\` for the type string. Defaults to
\`false\`.`,
type: 'boolean',
},
},
type: 'object',
},
],
type: 'suggestion',
},
});

View file

@ -0,0 +1,48 @@
import iterateJsdoc from '../iterateJsdoc.js';
export default iterateJsdoc(({
utils,
}) => {
const propertyAssociatedTags = utils.filterTags(({
tag,
}) => {
return [
'namespace', 'typedef',
].includes(tag);
});
if (!propertyAssociatedTags.length) {
return;
}
const targetTagName = /** @type {string} */ (utils.getPreferredTagName({
tagName: 'property',
}));
if (utils.hasATag([
targetTagName,
])) {
return;
}
for (const propertyAssociatedTag of propertyAssociatedTags) {
if (![
'object', 'Object', 'PlainObject',
].includes(propertyAssociatedTag.type)) {
continue;
}
utils.reportJSDoc(`Missing JSDoc @${targetTagName}.`, null, () => {
utils.addTag(targetTagName);
});
}
}, {
iterateAllJsdocs: true,
meta: {
docs: {
description: 'Requires that all `@typedef` and `@namespace` tags have `@property` when their type is a plain `object`, `Object`, or `PlainObject`.',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/require-property.md#repos-sticky-header',
},
fixable: 'code',
type: 'suggestion',
},
});

View file

@ -0,0 +1,25 @@
import iterateJsdoc from '../iterateJsdoc.js';
export default iterateJsdoc(({
report,
utils,
}) => {
utils.forEachPreferredTag('property', (jsdoc, targetTagName) => {
if (!jsdoc.description.trim()) {
report(
`Missing JSDoc @${targetTagName} "${jsdoc.name}" description.`,
null,
jsdoc,
);
}
});
}, {
iterateAllJsdocs: true,
meta: {
docs: {
description: 'Requires that each `@property` tag has a `description` value.',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/require-property-description.md#repos-sticky-header',
},
type: 'suggestion',
},
});

View file

@ -0,0 +1,25 @@
import iterateJsdoc from '../iterateJsdoc.js';
export default iterateJsdoc(({
report,
utils,
}) => {
utils.forEachPreferredTag('property', (jsdoc, targetTagName) => {
if (jsdoc.tag && jsdoc.name === '') {
report(
`There must be an identifier after @${targetTagName} ${jsdoc.type === '' ? 'type' : 'tag'}.`,
null,
jsdoc,
);
}
});
}, {
iterateAllJsdocs: true,
meta: {
docs: {
description: 'Requires that all `@property` tags have names.',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/require-property-name.md#repos-sticky-header',
},
type: 'suggestion',
},
});

View file

@ -0,0 +1,25 @@
import iterateJsdoc from '../iterateJsdoc.js';
export default iterateJsdoc(({
report,
utils,
}) => {
utils.forEachPreferredTag('property', (jsdoc, targetTagName) => {
if (!jsdoc.type) {
report(
`Missing JSDoc @${targetTagName} "${jsdoc.name}" type.`,
null,
jsdoc,
);
}
});
}, {
iterateAllJsdocs: true,
meta: {
docs: {
description: 'Requires that each `@property` tag has a type value (in curly brackets).',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/require-property-type.md#repos-sticky-header',
},
type: 'suggestion',
},
});

View file

@ -0,0 +1,246 @@
import iterateJsdoc from '../iterateJsdoc.js';
/**
* Checks if a node or its children contain Promise rejection patterns
* @param {import('eslint').Rule.Node} node
* @param {boolean} [innerFunction]
* @param {boolean} [isAsync]
* @returns {boolean}
*/
// eslint-disable-next-line complexity -- Temporary
const hasRejectValue = (node, innerFunction, isAsync) => {
if (!node) {
return false;
}
switch (node.type) {
case 'ArrowFunctionExpression':
case 'FunctionDeclaration':
case 'FunctionExpression': {
// For inner functions in async contexts, check if they throw
// (they could be called and cause rejection)
if (innerFunction) {
// Check inner functions for throws - if called from async context, throws become rejections
const innerIsAsync = node.async;
// Pass isAsync=true if the inner function is async OR if we're already in an async context
return hasRejectValue(/** @type {import('eslint').Rule.Node} */ (node.body), false, innerIsAsync || isAsync);
}
// This is the top-level function we're checking
return hasRejectValue(/** @type {import('eslint').Rule.Node} */ (node.body), true, node.async);
}
case 'BlockStatement': {
return node.body.some((bodyNode) => {
return hasRejectValue(/** @type {import('eslint').Rule.Node} */ (bodyNode), innerFunction, isAsync);
});
}
case 'CallExpression': {
// Check for Promise.reject()
if (node.callee.type === 'MemberExpression' &&
node.callee.object.type === 'Identifier' &&
node.callee.object.name === 'Promise' &&
node.callee.property.type === 'Identifier' &&
node.callee.property.name === 'reject') {
return true;
}
// Check for reject() call (in Promise executor context)
if (node.callee.type === 'Identifier' && node.callee.name === 'reject') {
return true;
}
// Check if this is calling an inner function that might reject
if (innerFunction && node.callee.type === 'Identifier') {
// We found a function call inside - check if it could be calling a function that rejects
// We'll handle this in function body traversal
return false;
}
return false;
}
case 'DoWhileStatement':
case 'ForInStatement':
case 'ForOfStatement':
case 'ForStatement':
case 'LabeledStatement':
case 'WhileStatement':
case 'WithStatement': {
return hasRejectValue(/** @type {import('eslint').Rule.Node} */ (node.body), innerFunction, isAsync);
}
case 'ExpressionStatement': {
return hasRejectValue(/** @type {import('eslint').Rule.Node} */ (node.expression), innerFunction, isAsync);
}
case 'IfStatement': {
return hasRejectValue(/** @type {import('eslint').Rule.Node} */ (node.consequent), innerFunction, isAsync) || hasRejectValue(/** @type {import('eslint').Rule.Node} */ (node.alternate), innerFunction, isAsync);
}
case 'NewExpression': {
// Check for new Promise((resolve, reject) => { reject(...) })
if (node.callee.type === 'Identifier' && node.callee.name === 'Promise' && node.arguments.length > 0) {
const executor = node.arguments[0];
if (executor.type === 'ArrowFunctionExpression' || executor.type === 'FunctionExpression') {
// Check if the executor has reject() calls
return hasRejectValue(/** @type {import('eslint').Rule.Node} */ (executor.body), false, false);
}
}
return false;
}
case 'ReturnStatement': {
if (node.argument) {
return hasRejectValue(/** @type {import('eslint').Rule.Node} */ (node.argument), innerFunction, isAsync);
}
return false;
}
case 'SwitchStatement': {
return node.cases.some(
(someCase) => {
return someCase.consequent.some((nde) => {
return hasRejectValue(/** @type {import('eslint').Rule.Node} */ (nde), innerFunction, isAsync);
});
},
);
}
// Throw statements in async functions become rejections
case 'ThrowStatement': {
return isAsync === true;
}
case 'TryStatement': {
return hasRejectValue(/** @type {import('eslint').Rule.Node} */ (node.handler && node.handler.body), innerFunction, isAsync) ||
hasRejectValue(/** @type {import('eslint').Rule.Node} */ (node.finalizer), innerFunction, isAsync);
}
default: {
return false;
}
}
};
/**
* We can skip checking for a rejects value, in case the documentation is inherited
* or the method is abstract.
* @param {import('../iterateJsdoc.js').Utils} utils
* @returns {boolean}
*/
const canSkip = (utils) => {
return utils.hasATag([
'abstract',
'virtual',
'type',
]) ||
utils.avoidDocs();
};
export default iterateJsdoc(({
node,
report,
utils,
}) => {
if (canSkip(utils)) {
return;
}
const tagName = /** @type {string} */ (utils.getPreferredTagName({
tagName: 'rejects',
}));
if (!tagName) {
return;
}
const tags = utils.getTags(tagName);
const iteratingFunction = utils.isIteratingFunction();
const [
tag,
] = tags;
const missingRejectsTag = typeof tag === 'undefined' || tag === null;
const shouldReport = () => {
if (!missingRejectsTag) {
return false;
}
// Check if this is an async function or returns a Promise
const isAsync = utils.isAsync();
if (!isAsync && !iteratingFunction) {
return false;
}
// For async functions, check for throw statements
// For regular functions, check for Promise.reject or reject calls
return hasRejectValue(/** @type {import('eslint').Rule.Node} */ (node));
};
if (shouldReport()) {
report('Promise-rejecting function requires `@rejects` tag');
}
}, {
contextDefaults: true,
meta: {
docs: {
description: 'Requires that Promise rejections are documented with `@rejects` tags.',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/require-rejects.md#repos-sticky-header',
},
schema: [
{
additionalProperties: false,
properties: {
contexts: {
description: `Set this to an array of strings representing the AST context
(or objects with optional \`context\` and \`comment\` properties) where you wish
the rule to be applied.
\`context\` defaults to \`any\` and \`comment\` defaults to no specific comment context.
Overrides the default contexts (\`ArrowFunctionExpression\`, \`FunctionDeclaration\`,
\`FunctionExpression\`).`,
items: {
anyOf: [
{
type: 'string',
},
{
additionalProperties: false,
properties: {
comment: {
type: 'string',
},
context: {
type: 'string',
},
},
type: 'object',
},
],
},
type: 'array',
},
exemptedBy: {
description: `Array of tags (e.g., \`['type']\`) whose presence on the
document block avoids the need for a \`@rejects\`. Defaults to an array
with \`abstract\`, \`virtual\`, and \`type\`. If you set this array, it will overwrite the default,
so be sure to add back those tags if you wish their presence to cause
exemption of the rule.`,
items: {
type: 'string',
},
type: 'array',
},
},
type: 'object',
},
],
type: 'suggestion',
},
});

View file

@ -0,0 +1,290 @@
import exportParser from '../exportParser.js';
import iterateJsdoc from '../iterateJsdoc.js';
/**
* We can skip checking for a return value, in case the documentation is inherited
* or the method is either a constructor or an abstract method.
*
* In either of these cases the return value is optional or not defined.
* @param {import('../iterateJsdoc.js').Utils} utils
* a reference to the utils which are used to probe if a tag is present or not.
* @returns {boolean}
* true in case deep checking can be skipped; otherwise false.
*/
const canSkip = (utils) => {
return utils.hasATag([
// inheritdoc implies that all documentation is inherited
// see https://jsdoc.app/tags-inheritdoc.html
//
// Abstract methods are by definition incomplete,
// so it is not an error if it declares a return value but does not implement it.
'abstract',
'virtual',
// Constructors do not have a return value by definition (https://jsdoc.app/tags-class.html)
// So we can bail out here, too.
'class',
'constructor',
// Return type is specified by type in @type
'type',
// This seems to imply a class as well
'interface',
]) ||
utils.avoidDocs();
};
export default iterateJsdoc(({
context,
info: {
comment,
},
node,
report,
settings,
utils,
}) => {
const {
contexts,
enableFixer = false,
forceRequireReturn = false,
forceReturnsWithAsync = false,
publicOnly = false,
} = context.options[0] || {};
// A preflight check. We do not need to run a deep check
// in case the @returns comment is optional or undefined.
if (canSkip(utils)) {
return;
}
/** @type {boolean|undefined} */
let forceRequireReturnContext;
if (contexts) {
const {
foundContext,
} = utils.findContext(contexts, comment);
if (typeof foundContext === 'object') {
forceRequireReturnContext = foundContext.forceRequireReturn;
}
}
const tagName = /** @type {string} */ (utils.getPreferredTagName({
tagName: 'returns',
}));
if (!tagName) {
return;
}
const tags = utils.getTags(tagName);
if (tags.length > 1) {
report(`Found more than one @${tagName} declaration.`);
}
const iteratingFunction = utils.isIteratingFunction();
// In case the code returns something, we expect a return value in JSDoc.
const [
tag,
] = tags;
const missingReturnTag = typeof tag === 'undefined' || tag === null;
const shouldReport = () => {
if (!missingReturnTag) {
return false;
}
if (publicOnly) {
/** @type {import('./requireJsdoc.js').RequireJsdocOpts} */
const opt = {
ancestorsOnly: Boolean(publicOnly?.ancestorsOnly ?? false),
esm: Boolean(publicOnly?.esm ?? true),
initModuleExports: Boolean(publicOnly?.cjs ?? true),
initWindow: Boolean(publicOnly?.window ?? false),
};
/* c8 ignore next -- Fallback to deprecated method */
const {
sourceCode = context.getSourceCode(),
} = context;
const exported = exportParser.isUncommentedExport(
/** @type {import('eslint').Rule.Node} */ (node), sourceCode, opt, settings,
);
if (!exported) {
return false;
}
}
if ((forceRequireReturn || forceRequireReturnContext) && (
iteratingFunction || utils.isVirtualFunction()
)) {
return true;
}
const isAsync = !iteratingFunction && utils.hasTag('async') ||
iteratingFunction && utils.isAsync();
if (forceReturnsWithAsync && isAsync) {
return true;
}
return iteratingFunction && utils.hasValueOrExecutorHasNonEmptyResolveValue(
forceReturnsWithAsync,
);
};
if (shouldReport()) {
utils.reportJSDoc(`Missing JSDoc @${tagName} declaration.`, null, enableFixer ? () => {
utils.addTag(tagName);
} : null);
}
}, {
contextDefaults: true,
meta: {
docs: {
description: 'Requires that returns are documented with `@returns`.',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/require-returns.md#repos-sticky-header',
},
fixable: 'code',
schema: [
{
additionalProperties: false,
properties: {
checkConstructors: {
default: false,
description: `A value indicating whether \`constructor\`s should
be checked for \`@returns\` tags. Defaults to \`false\`.`,
type: 'boolean',
},
checkGetters: {
default: true,
description: `Boolean to determine whether getter methods should
be checked for \`@returns\` tags. Defaults to \`true\`.`,
type: 'boolean',
},
contexts: {
description: `Set this to an array of strings representing the AST context
(or objects with optional \`context\` and \`comment\` properties) where you wish
the rule to be applied.
\`context\` defaults to \`any\` and \`comment\` defaults to no specific comment context.
Overrides the default contexts (\`ArrowFunctionExpression\`, \`FunctionDeclaration\`,
\`FunctionExpression\`). Set to \`"any"\` if you want
the rule to apply to any JSDoc block throughout your files (as is necessary
for finding function blocks not attached to a function declaration or
expression, i.e., \`@callback\` or \`@function\` (or its aliases \`@func\` or
\`@method\`) (including those associated with an \`@interface\`). This
rule will only apply on non-default contexts when there is such a tag
present and the \`forceRequireReturn\` option is set or if the
\`forceReturnsWithAsync\` option is set with a present \`@async\` tag
(since we are not checking against the actual \`return\` values in these
cases).`,
items: {
anyOf: [
{
type: 'string',
},
{
additionalProperties: false,
properties: {
comment: {
type: 'string',
},
context: {
type: 'string',
},
forceRequireReturn: {
type: 'boolean',
},
},
type: 'object',
},
],
},
type: 'array',
},
enableFixer: {
description: `Whether to enable the fixer to add a blank \`@returns\`.
Defaults to \`false\`.`,
type: 'boolean',
},
exemptedBy: {
description: `Array of tags (e.g., \`['type']\`) whose presence on the
document block avoids the need for a \`@returns\`. Defaults to an array
with \`inheritdoc\`. If you set this array, it will overwrite the default,
so be sure to add back \`inheritdoc\` if you wish its presence to cause
exemption of the rule.`,
items: {
type: 'string',
},
type: 'array',
},
forceRequireReturn: {
default: false,
description: `Set to \`true\` to always insist on
\`@returns\` documentation regardless of implicit or explicit \`return\`'s
in the function. May be desired to flag that a project is aware of an
\`undefined\`/\`void\` return. Defaults to \`false\`.`,
type: 'boolean',
},
forceReturnsWithAsync: {
default: false,
description: `By default \`async\` functions that do not explicitly
return a value pass this rule as an \`async\` function will always return a
\`Promise\`, even if the \`Promise\` resolves to void. You can force all
\`async\` functions (including ones with an explicit \`Promise\` but no
detected non-\`undefined\` \`resolve\` value) to require \`@return\`
documentation by setting \`forceReturnsWithAsync\` to \`true\` on the options
object. This may be useful for flagging that there has been consideration
of return type. Defaults to \`false\`.`,
type: 'boolean',
},
publicOnly: {
description: `This option will insist that missing \`@returns\` are only reported for
function bodies / class declarations that are exported from the module.
May be a boolean or object. If set to \`true\`, the defaults below will be
used. If unset, \`@returns\` reporting will not be limited to exports.
This object supports the following optional boolean keys (\`false\` unless
otherwise noted):
- \`ancestorsOnly\` - Optimization to only check node ancestors to check if node is exported
- \`esm\` - ESM exports are checked for \`@returns\` JSDoc comments (Defaults to \`true\`)
- \`cjs\` - CommonJS exports are checked for \`@returns\` JSDoc comments (Defaults to \`true\`)
- \`window\` - Window global exports are checked for \`@returns\` JSDoc comments`,
oneOf: [
{
default: false,
type: 'boolean',
},
{
additionalProperties: false,
default: {},
properties: {
ancestorsOnly: {
type: 'boolean',
},
cjs: {
type: 'boolean',
},
esm: {
type: 'boolean',
},
window: {
type: 'boolean',
},
},
type: 'object',
},
],
},
},
type: 'object',
},
],
type: 'suggestion',
},
});

View file

@ -0,0 +1,180 @@
import iterateJsdoc from '../iterateJsdoc.js';
import {
strictNativeTypes,
} from '../jsdocUtils.js';
/**
* @param {import('../iterateJsdoc.js').Utils} utils
* @param {import('../iterateJsdoc.js').Settings} settings
* @returns {boolean}
*/
const canSkip = (utils, settings) => {
const voidingTags = [
// An abstract function is by definition incomplete
// so it is perfectly fine if a return is documented but
// not present within the function.
// A subclass may inherit the doc and implement the
// missing return.
'abstract',
'virtual',
// A constructor function returns `this` by default, so may be `@returns`
// tag indicating this but no explicit return
'class',
'constructor',
'interface',
];
if (settings.mode === 'closure') {
// Structural Interface in GCC terms, equivalent to @interface tag as far as this rule is concerned
voidingTags.push('record');
}
return utils.hasATag(voidingTags) ||
utils.isConstructor() ||
utils.classHasTag('interface') ||
settings.mode === 'closure' && utils.classHasTag('record');
};
export default iterateJsdoc(({
context,
node,
report,
settings,
utils,
}) => {
const {
exemptAsync = true,
exemptGenerators = settings.mode === 'typescript',
noNativeTypes = true,
reportMissingReturnForUndefinedTypes = false,
} = context.options[0] || {};
if (canSkip(utils, settings)) {
return;
}
const isAsync = utils.isAsync();
if (exemptAsync && isAsync) {
return;
}
const tagName = /** @type {string} */ (utils.getPreferredTagName({
tagName: 'returns',
}));
if (!tagName) {
return;
}
const tags = utils.getTags(tagName);
if (tags.length === 0) {
return;
}
if (tags.length > 1) {
report(`Found more than one @${tagName} declaration.`);
return;
}
const [
tag,
] = tags;
const type = tag.type.trim();
// https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-7.html#assertion-functions
if (/asserts\s/v.test(type)) {
return;
}
const returnNever = type === 'never';
if (returnNever && utils.hasValueOrExecutorHasNonEmptyResolveValue(false)) {
report(`JSDoc @${tagName} declaration set with "never" but return expression is present in function.`);
return;
}
if (noNativeTypes && isAsync && strictNativeTypes.includes(type)) {
report('Function is async or otherwise returns a Promise but the return type is a native type.');
return;
}
// In case a return value is declared in JSDoc, we also expect one in the code.
if (
!returnNever &&
(
reportMissingReturnForUndefinedTypes ||
!utils.mayBeUndefinedTypeTag(tag)
) &&
(tag.type === '' && !utils.hasValueOrExecutorHasNonEmptyResolveValue(
exemptAsync,
) ||
tag.type !== '' && !utils.hasValueOrExecutorHasNonEmptyResolveValue(
exemptAsync,
true,
)) &&
Boolean(
!exemptGenerators || !node ||
!('generator' in /** @type {import('../iterateJsdoc.js').Node} */ (node)) ||
!(/** @type {import('@typescript-eslint/types').TSESTree.FunctionDeclaration} */ (node)).generator,
)
) {
report(`JSDoc @${tagName} declaration present but return expression not available in function.`);
}
}, {
meta: {
docs: {
description: 'Requires a return statement in function body if a `@returns` tag is specified in JSDoc comment(and reports if multiple `@returns` tags are present).',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/require-returns-check.md#repos-sticky-header',
},
schema: [
{
additionalProperties: false,
properties: {
exemptAsync: {
default: true,
description: `By default, functions which return a \`Promise\` that are not
detected as resolving with a non-\`undefined\` value and \`async\` functions
(even ones that do not explicitly return a value, as these are returning a
\`Promise\` implicitly) will be exempted from reporting by this rule.
If you wish to insist that only \`Promise\`'s which resolve to
non-\`undefined\` values or \`async\` functions with explicit \`return\`'s will
be exempted from reporting (i.e., that \`async\` functions can be reported
if they lack an explicit (non-\`undefined\`) \`return\` when a \`@returns\` is
present), you can set \`exemptAsync\` to \`false\` on the options object.`,
type: 'boolean',
},
exemptGenerators: {
description: `Because a generator might be labeled as having a
\`IterableIterator\` \`@returns\` value (along with an iterator type
corresponding to the type of any \`yield\` statements), projects might wish to
leverage \`@returns\` in generators even without a \`return\` statement. This
option is therefore \`true\` by default in \`typescript\` mode (in "jsdoc" mode,
one might be more likely to take advantage of \`@yields\`). Set it to \`false\`
if you wish for a missing \`return\` to be flagged regardless.`,
type: 'boolean',
},
noNativeTypes: {
description: `Whether to check that async functions do not
indicate they return non-native types. Defaults to \`true\`.`,
type: 'boolean',
},
reportMissingReturnForUndefinedTypes: {
default: false,
description: `If \`true\` and no return or
resolve value is found, this setting will even insist that reporting occur
with \`void\` or \`undefined\` (including as an indicated \`Promise\` type).
Unlike \`require-returns\`, with this option in the rule, one can
*discourage* the labeling of \`undefined\` types. Defaults to \`false\`.`,
type: 'boolean',
},
},
type: 'object',
},
],
type: 'suggestion',
},
});

View file

@ -0,0 +1,73 @@
import iterateJsdoc from '../iterateJsdoc.js';
export default iterateJsdoc(({
report,
utils,
}) => {
utils.forEachPreferredTag('returns', (jsdocTag, targetTagName) => {
const type = jsdocTag.type && jsdocTag.type.trim();
if ([
'Promise<undefined>', 'Promise<void>', 'undefined', 'void',
].includes(type)) {
return;
}
if (!jsdocTag.description.trim()) {
report(`Missing JSDoc @${targetTagName} description.`, null, jsdocTag);
}
});
}, {
contextDefaults: true,
meta: {
docs: {
description: 'Requires that the `@returns` tag has a `description` value (not including `void`/`undefined` type returns).',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/require-returns-description.md#repos-sticky-header',
},
schema: [
{
additionalProperties: false,
properties: {
contexts: {
description: `Set this to an array of strings representing the AST context (or an object with
optional \`context\` and \`comment\` properties) where you wish the rule to be applied.
\`context\` defaults to \`any\` and \`comment\` defaults to no specific comment context.
Overrides the default contexts (\`ArrowFunctionExpression\`, \`FunctionDeclaration\`,
\`FunctionExpression\`). Set to \`"any"\` if you want
the rule to apply to any JSDoc block throughout your files (as is necessary
for finding function blocks not attached to a function declaration or
expression, i.e., \`@callback\` or \`@function\` (or its aliases \`@func\` or
\`@method\`) (including those associated with an \`@interface\`).
See the ["AST and Selectors"](../#advanced-ast-and-selectors)
section of our Advanced docs for more on the expected format.`,
items: {
anyOf: [
{
type: 'string',
},
{
additionalProperties: false,
properties: {
comment: {
type: 'string',
},
context: {
type: 'string',
},
},
type: 'object',
},
],
},
type: 'array',
},
},
type: 'object',
},
],
type: 'suggestion',
},
});

View file

@ -0,0 +1,65 @@
import iterateJsdoc from '../iterateJsdoc.js';
export default iterateJsdoc(({
report,
utils,
}) => {
utils.forEachPreferredTag('returns', (jsdocTag, targetTagName) => {
if (!jsdocTag.type) {
report(`Missing JSDoc @${targetTagName} type.`, null, jsdocTag);
}
});
}, {
contextDefaults: true,
meta: {
docs: {
description: 'Requires that `@returns` tag has type value (in curly brackets).',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/require-returns-type.md#repos-sticky-header',
},
schema: [
{
additionalProperties: false,
properties: {
contexts: {
description: `Set this to an array of strings representing the AST context (or an object with
optional \`context\` and \`comment\` properties) where you wish the rule to be applied.
\`context\` defaults to \`any\` and \`comment\` defaults to no specific comment context.
Overrides the default contexts (\`ArrowFunctionExpression\`, \`FunctionDeclaration\`,
\`FunctionExpression\`). Set to \`"any"\` if you want
the rule to apply to any JSDoc block throughout your files (as is necessary
for finding function blocks not attached to a function declaration or
expression, i.e., \`@callback\` or \`@function\` (or its aliases \`@func\` or
\`@method\`) (including those associated with an \`@interface\`).
See the ["AST and Selectors"](../#advanced-ast-and-selectors)
section of our Advanced docs for more on the expected format.`,
items: {
anyOf: [
{
type: 'string',
},
{
additionalProperties: false,
properties: {
comment: {
type: 'string',
},
context: {
type: 'string',
},
},
type: 'object',
},
],
},
type: 'array',
},
},
type: 'object',
},
],
type: 'suggestion',
},
});

View file

@ -0,0 +1,85 @@
import {
buildForbidRuleDefinition,
} from '../buildForbidRuleDefinition.js';
export default buildForbidRuleDefinition({
description: 'Requires tags be present, optionally for specific contexts',
getContexts (context, report) {
// Transformed options to this option in `modifyContext`:
if (!context.options[0].contexts) {
report('Rule `require-tags` is missing a `tags` option.');
return false;
}
const {
contexts,
} = context.options[0];
return contexts;
},
modifyContext (context) {
const tags = /** @type {(string|{tag: string, context: string})[]} */ (
context.options?.[0]?.tags
);
const cntxts = tags?.map((tag) => {
const tagName = typeof tag === 'string' ? tag : tag.tag;
return {
comment: `JsdocBlock:not(*:has(JsdocTag[tag=${
tagName
}]))`,
context: typeof tag === 'string' ? 'any' : tag.context,
message: `Missing required tag "${tagName}"`,
};
});
// Reproduce context object with our own `contexts`
const propertyDescriptors = Object.getOwnPropertyDescriptors(context);
return Object.create(
Object.getPrototypeOf(context),
{
...propertyDescriptors,
options: {
...propertyDescriptors.options,
value: [
{
contexts: cntxts,
},
],
},
},
);
},
schema: [
{
additionalProperties: false,
properties: {
tags: {
description: `May be an array of either strings or objects with
a string \`tag\` property and \`context\` string property.`,
items: {
anyOf: [
{
type: 'string',
},
{
properties: {
context: {
type: 'string',
},
tag: {
type: 'string',
},
},
type: 'object',
},
],
},
type: 'array',
},
},
type: 'object',
},
],
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/require-tags.md#repos-sticky-header',
});

View file

@ -0,0 +1,235 @@
import iterateJsdoc from '../iterateJsdoc.js';
import {
parse as parseType,
traverse,
tryParse as tryParseType,
} from '@es-joy/jsdoccomment';
export default iterateJsdoc(({
context,
node,
report,
settings,
utils,
}) => {
if (utils.avoidDocs()) {
return;
}
const {
requireSeparateTemplates = false,
} = context.options[0] || {};
const {
mode,
} = settings;
const usedNames = new Set();
const tgName = /** @type {string} */ (utils.getPreferredTagName({
tagName: 'template',
}));
if (!tgName) {
return;
}
const templateTags = utils.getTags(tgName);
const templateNames = templateTags.flatMap((tag) => {
return utils.parseClosureTemplateTag(tag);
});
if (requireSeparateTemplates) {
for (const tag of templateTags) {
const names = utils.parseClosureTemplateTag(tag);
if (names.length > 1) {
report(`Missing separate @${tgName} for ${names[1]}`, null, tag);
}
}
}
/**
* @param {import('@typescript-eslint/types').TSESTree.FunctionDeclaration|
* import('@typescript-eslint/types').TSESTree.ClassDeclaration|
* import('@typescript-eslint/types').TSESTree.TSDeclareFunction|
* import('@typescript-eslint/types').TSESTree.TSInterfaceDeclaration|
* import('@typescript-eslint/types').TSESTree.TSTypeAliasDeclaration} aliasDeclaration
*/
const checkTypeParams = (aliasDeclaration) => {
const {
params,
/* c8 ignore next -- Guard */
} = aliasDeclaration.typeParameters ?? {
/* c8 ignore next -- Guard */
params: [],
};
for (const {
name: {
name,
},
} of params) {
usedNames.add(name);
}
for (const usedName of usedNames) {
if (!templateNames.includes(usedName)) {
report(`Missing @${tgName} ${usedName}`);
}
}
};
const handleTypes = () => {
const nde = /** @type {import('@typescript-eslint/types').TSESTree.Node} */ (
node
);
if (!nde) {
return;
}
switch (nde.type) {
case 'ClassDeclaration':
case 'FunctionDeclaration':
case 'TSDeclareFunction':
case 'TSInterfaceDeclaration':
case 'TSTypeAliasDeclaration':
checkTypeParams(nde);
break;
case 'ExportDefaultDeclaration':
switch (nde.declaration?.type) {
case 'ClassDeclaration':
case 'FunctionDeclaration':
case 'TSInterfaceDeclaration':
checkTypeParams(nde.declaration);
break;
}
break;
case 'ExportNamedDeclaration':
switch (nde.declaration?.type) {
case 'ClassDeclaration':
case 'FunctionDeclaration':
case 'TSDeclareFunction':
case 'TSInterfaceDeclaration':
case 'TSTypeAliasDeclaration':
checkTypeParams(nde.declaration);
break;
}
break;
}
};
const usedNameToTag = new Map();
/**
* @param {import('comment-parser').Spec} potentialTag
*/
const checkForUsedTypes = (potentialTag) => {
let parsedType;
try {
parsedType = mode === 'permissive' ?
tryParseType(/** @type {string} */ (potentialTag.type)) :
parseType(/** @type {string} */ (potentialTag.type), mode);
} catch {
return;
}
traverse(parsedType, (nde) => {
const {
type,
value,
} = /** @type {import('jsdoc-type-pratt-parser').NameResult} */ (nde);
if (type === 'JsdocTypeName' && (/^[A-Z]$/v).test(value)) {
usedNames.add(value);
if (!usedNameToTag.has(value)) {
usedNameToTag.set(value, potentialTag);
}
}
});
};
/**
* @param {string[]} tagNames
*/
const checkTagsAndTemplates = (tagNames) => {
for (const tagName of tagNames) {
const preferredTagName = /** @type {string} */ (utils.getPreferredTagName({
tagName,
}));
const matchingTags = utils.getTags(preferredTagName);
for (const matchingTag of matchingTags) {
checkForUsedTypes(matchingTag);
}
}
// Could check against whitelist/blacklist
for (const usedName of usedNames) {
if (!templateNames.includes(usedName)) {
report(`Missing @${tgName} ${usedName}`, null, usedNameToTag.get(usedName));
}
}
};
const callbackTags = utils.getTags('callback');
const functionTags = utils.getTags('function');
if (callbackTags.length || functionTags.length) {
checkTagsAndTemplates([
'param', 'returns',
]);
return;
}
const typedefTags = utils.getTags('typedef');
if (!typedefTags.length || typedefTags.length >= 2) {
handleTypes();
return;
}
const potentialTypedef = typedefTags[0];
checkForUsedTypes(potentialTypedef);
checkTagsAndTemplates([
'property',
]);
}, {
iterateAllJsdocs: true,
meta: {
docs: {
description: 'Requires `@template` tags be present when type parameters are used.',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/require-template.md#repos-sticky-header',
},
schema: [
{
additionalProperties: false,
properties: {
exemptedBy: {
description: `Array of tags (e.g., \`['type']\`) whose presence on the document
block avoids the need for a \`@template\`. Defaults to an array with
\`inheritdoc\`. If you set this array, it will overwrite the default,
so be sure to add back \`inheritdoc\` if you wish its presence to cause
exemption of the rule.`,
items: {
type: 'string',
},
type: 'array',
},
requireSeparateTemplates: {
description: `Requires that each template have its own separate line, i.e., preventing
templates of this format:
\`\`\`js
/**
* @template T, U, V
*/
\`\`\`
Defaults to \`false\`.`,
type: 'boolean',
},
},
type: 'object',
},
],
type: 'suggestion',
},
});

View file

@ -0,0 +1,128 @@
import iterateJsdoc from '../iterateJsdoc.js';
/**
* We can skip checking for a throws value, in case the documentation is inherited
* or the method is either a constructor or an abstract method.
* @param {import('../iterateJsdoc.js').Utils} utils a reference to the utils which are used to probe if a tag is present or not.
* @returns {boolean} true in case deep checking can be skipped; otherwise false.
*/
const canSkip = (utils) => {
return utils.hasATag([
// inheritdoc implies that all documentation is inherited
// see https://jsdoc.app/tags-inheritdoc.html
//
// Abstract methods are by definition incomplete,
// so it is not necessary to document that they throw an error.
'abstract',
'virtual',
// The designated type can itself document `@throws`
'type',
]) ||
utils.avoidDocs();
};
export default iterateJsdoc(({
report,
utils,
}) => {
// A preflight check. We do not need to run a deep check for abstract
// functions.
if (canSkip(utils)) {
return;
}
const tagName = /** @type {string} */ (utils.getPreferredTagName({
tagName: 'throws',
}));
if (!tagName) {
return;
}
const tags = utils.getTags(tagName);
const iteratingFunction = utils.isIteratingFunction();
// In case the code returns something, we expect a return value in JSDoc.
const [
tag,
] = tags;
const missingThrowsTag = typeof tag === 'undefined' || tag === null;
const shouldReport = () => {
if (!missingThrowsTag) {
if (tag.type.trim() === 'never' && iteratingFunction && utils.hasThrowValue()) {
report(`JSDoc @${tagName} declaration set to "never" but throw value found.`);
}
return false;
}
return iteratingFunction && utils.hasThrowValue();
};
if (shouldReport()) {
report(`Missing JSDoc @${tagName} declaration.`);
}
}, {
contextDefaults: true,
meta: {
docs: {
description: 'Requires that throw statements are documented with `@throws` tags.',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/require-throws.md#repos-sticky-header',
},
schema: [
{
additionalProperties: false,
properties: {
contexts: {
description: `Set this to an array of strings representing the AST context
(or objects with optional \`context\` and \`comment\` properties) where you wish
the rule to be applied.
\`context\` defaults to \`any\` and \`comment\` defaults to no specific comment context.
Overrides the default contexts (\`ArrowFunctionExpression\`, \`FunctionDeclaration\`,
\`FunctionExpression\`). Set to \`"any"\` if you want
the rule to apply to any JSDoc block throughout your files (as is necessary
for finding function blocks not attached to a function declaration or
expression, i.e., \`@callback\` or \`@function\` (or its aliases \`@func\` or
\`@method\`) (including those associated with an \`@interface\`).`,
items: {
anyOf: [
{
type: 'string',
},
{
additionalProperties: false,
properties: {
comment: {
type: 'string',
},
context: {
type: 'string',
},
},
type: 'object',
},
],
},
type: 'array',
},
exemptedBy: {
description: `Array of tags (e.g., \`['type']\`) whose presence on the
document block avoids the need for a \`@throws\`. Defaults to an array
with \`inheritdoc\`. If you set this array, it will overwrite the default,
so be sure to add back \`inheritdoc\` if you wish its presence to cause
exemption of the rule.`,
items: {
type: 'string',
},
type: 'array',
},
},
type: 'object',
},
],
type: 'suggestion',
},
});

View file

@ -0,0 +1,265 @@
import iterateJsdoc from '../iterateJsdoc.js';
/**
* We can skip checking for a yield value, in case the documentation is inherited
* or the method has a constructor or abstract tag.
*
* In either of these cases the yield value is optional or not defined.
* @param {import('../iterateJsdoc.js').Utils} utils a reference to the utils which are used to probe if a tag is present or not.
* @returns {boolean} true in case deep checking can be skipped; otherwise false.
*/
const canSkip = (utils) => {
return utils.hasATag([
// inheritdoc implies that all documentation is inherited
// see https://jsdoc.app/tags-inheritdoc.html
//
// Abstract methods are by definition incomplete,
// so it is not an error if it declares a yield value but does not implement it.
'abstract',
'virtual',
// Constructors do not have a yield value
// so we can bail out here, too.
'class',
'constructor',
// Yield (and any `next`) type is specified accompanying the targeted
// @type
'type',
// This seems to imply a class as well
'interface',
]) ||
utils.avoidDocs();
};
/**
* @param {import('../iterateJsdoc.js').Utils} utils
* @param {import('../iterateJsdoc.js').Report} report
* @param {string} tagName
* @returns {[preferredTagName?: string, missingTag?: boolean]}
*/
const checkTagName = (utils, report, tagName) => {
const preferredTagName = /** @type {string} */ (utils.getPreferredTagName({
tagName,
}));
if (!preferredTagName) {
return [];
}
const tags = utils.getTags(preferredTagName);
if (tags.length > 1) {
report(`Found more than one @${preferredTagName} declaration.`);
}
// In case the code yields something, we expect a yields value in JSDoc.
const [
tag,
] = tags;
const missingTag = typeof tag === 'undefined' || tag === null;
return [
preferredTagName, missingTag,
];
};
export default iterateJsdoc(({
context,
report,
utils,
}) => {
const {
forceRequireNext = false,
forceRequireYields = false,
next = false,
nextWithGeneratorTag = false,
withGeneratorTag = true,
} = context.options[0] || {};
// A preflight check. We do not need to run a deep check
// in case the @yield comment is optional or undefined.
if (canSkip(utils)) {
return;
}
const iteratingFunction = utils.isIteratingFunction();
const [
preferredYieldTagName,
missingYieldTag,
] = checkTagName(
utils, report, 'yields',
);
if (preferredYieldTagName) {
const shouldReportYields = () => {
if (!missingYieldTag) {
return false;
}
if (
withGeneratorTag && utils.hasTag('generator') ||
forceRequireYields && iteratingFunction && utils.isGenerator()
) {
return true;
}
return iteratingFunction && utils.isGenerator() && utils.hasYieldValue();
};
if (shouldReportYields()) {
report(`Missing JSDoc @${preferredYieldTagName} declaration.`);
}
}
if (next || nextWithGeneratorTag || forceRequireNext) {
const [
preferredNextTagName,
missingNextTag,
] = checkTagName(
utils, report, 'next',
);
if (!preferredNextTagName) {
return;
}
const shouldReportNext = () => {
if (!missingNextTag) {
return false;
}
if (
nextWithGeneratorTag && utils.hasTag('generator')) {
return true;
}
if (
!next && !forceRequireNext ||
!iteratingFunction ||
!utils.isGenerator()
) {
return false;
}
return forceRequireNext || utils.hasYieldReturnValue();
};
if (shouldReportNext()) {
report(`Missing JSDoc @${preferredNextTagName} declaration.`);
}
}
}, {
contextDefaults: true,
meta: {
docs: {
description: 'Requires yields are documented with `@yields` tags.',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/require-yields.md#repos-sticky-header',
},
schema: [
{
additionalProperties: false,
properties: {
contexts: {
description: `Set this to an array of strings representing the AST context
(or objects with optional \`context\` and \`comment\` properties) where you wish
the rule to be applied.
\`context\` defaults to \`any\` and \`comment\` defaults to no specific comment context.
Overrides the default contexts (\`ArrowFunctionExpression\`, \`FunctionDeclaration\`,
\`FunctionExpression\`). Set to \`"any"\` if you want
the rule to apply to any JSDoc block throughout your files (as is necessary
for finding function blocks not attached to a function declaration or
expression, i.e., \`@callback\` or \`@function\` (or its aliases \`@func\` or
\`@method\`) (including those associated with an \`@interface\`). This
rule will only apply on non-default contexts when there is such a tag
present and the \`forceRequireYields\` option is set or if the
\`withGeneratorTag\` option is set with a present \`@generator\` tag
(since we are not checking against the actual \`yield\` values in these
cases).`,
items: {
anyOf: [
{
type: 'string',
},
{
additionalProperties: false,
properties: {
comment: {
type: 'string',
},
context: {
type: 'string',
},
},
type: 'object',
},
],
},
type: 'array',
},
exemptedBy: {
description: `Array of tags (e.g., \`['type']\`) whose presence on the
document block avoids the need for a \`@yields\`. Defaults to an array
with \`inheritdoc\`. If you set this array, it will overwrite the default,
so be sure to add back \`inheritdoc\` if you wish its presence to cause
exemption of the rule.`,
items: {
type: 'string',
},
type: 'array',
},
forceRequireNext: {
default: false,
description: `Set to \`true\` to always insist on
\`@next\` documentation even if there are no \`yield\` statements in the
function or none return values. May be desired to flag that a project is
aware of the expected yield return being \`undefined\`. Defaults to \`false\`.`,
type: 'boolean',
},
forceRequireYields: {
default: false,
description: `Set to \`true\` to always insist on
\`@yields\` documentation for generators even if there are only
expressionless \`yield\` statements in the function. May be desired to flag
that a project is aware of an \`undefined\`/\`void\` yield. Defaults to
\`false\`.`,
type: 'boolean',
},
next: {
default: false,
description: `If \`true\`, this option will insist that any use of a \`yield\` return
value (e.g., \`const rv = yield;\` or \`const rv = yield value;\`) has a
(non-standard) \`@next\` tag (in addition to any \`@yields\` tag) so as to be
able to document the type expected to be supplied into the iterator
(the \`Generator\` iterator that is returned by the call to the generator
function) to the iterator (e.g., \`it.next(value)\`). The tag will not be
expected if the generator function body merely has plain \`yield;\` or
\`yield value;\` statements without returning the values. Defaults to
\`false\`.`,
type: 'boolean',
},
nextWithGeneratorTag: {
default: false,
description: `If a \`@generator\` tag is present on a block, require
(non-standard ) \`@next\` (see \`next\` option). This will require using \`void\`
or \`undefined\` in cases where generators do not use the \`next()\`-supplied
incoming \`yield\`-returned value. Defaults to \`false\`. See \`contexts\` to
\`any\` if you want to catch \`@generator\` with \`@callback\` or such not
attached to a function.`,
type: 'boolean',
},
withGeneratorTag: {
default: true,
description: `If a \`@generator\` tag is present on a block, require
\`@yields\`/\`@yield\`. Defaults to \`true\`. See \`contexts\` to \`any\` if you want
to catch \`@generator\` with \`@callback\` or such not attached to a function.`,
type: 'boolean',
},
},
type: 'object',
},
],
type: 'suggestion',
},
});

View file

@ -0,0 +1,226 @@
import iterateJsdoc from '../iterateJsdoc.js';
/**
* @param {import('../iterateJsdoc.js').Utils} utils
* @param {import('../iterateJsdoc.js').Settings} settings
* @returns {boolean}
*/
const canSkip = (utils, settings) => {
const voidingTags = [
// An abstract function is by definition incomplete
// so it is perfectly fine if a yield is documented but
// not present within the function.
// A subclass may inherit the doc and implement the
// missing yield.
'abstract',
'virtual',
// Constructor functions do not have a yield value
// so we can bail here, too.
'class',
'constructor',
// This seems to imply a class as well
'interface',
];
if (settings.mode === 'closure') {
// Structural Interface in GCC terms, equivalent to @interface tag as far as this rule is concerned
voidingTags.push('record');
}
return utils.hasATag(voidingTags) ||
utils.isConstructor() ||
utils.classHasTag('interface') ||
settings.mode === 'closure' && utils.classHasTag('record');
};
/**
* @param {import('../iterateJsdoc.js').Utils} utils
* @param {import('../iterateJsdoc.js').Report} report
* @param {string} tagName
* @returns {[]|[preferredTagName: string, tag: import('comment-parser').Spec]}
*/
const checkTagName = (utils, report, tagName) => {
const preferredTagName = /** @type {string} */ (utils.getPreferredTagName({
tagName,
}));
if (!preferredTagName) {
return [];
}
const tags = utils.getTags(preferredTagName);
if (tags.length === 0) {
return [];
}
if (tags.length > 1) {
report(`Found more than one @${preferredTagName} declaration.`);
return [];
}
return [
preferredTagName, tags[0],
];
};
export default iterateJsdoc(({
context,
report,
settings,
utils,
}) => {
if (canSkip(utils, settings)) {
return;
}
const {
checkGeneratorsOnly = false,
next = false,
} = context.options[0] || {};
const [
preferredYieldTagName,
yieldTag,
] = checkTagName(
utils, report, 'yields',
);
if (preferredYieldTagName) {
const shouldReportYields = () => {
if (
/** @type {import('comment-parser').Spec} */ (
yieldTag
).type.trim() === 'never'
) {
if (utils.hasYieldValue()) {
report(`JSDoc @${preferredYieldTagName} declaration set with "never" but yield expression is present in function.`);
}
return false;
}
if (checkGeneratorsOnly && !utils.isGenerator()) {
return true;
}
return !utils.mayBeUndefinedTypeTag(
/** @type {import('comment-parser').Spec} */
(yieldTag),
) && !utils.hasYieldValue();
};
// In case a yield value is declared in JSDoc, we also expect one in the code.
if (shouldReportYields()) {
report(`JSDoc @${preferredYieldTagName} declaration present but yield expression not available in function.`);
}
}
if (next) {
const [
preferredNextTagName,
nextTag,
] = checkTagName(
utils, report, 'next',
);
if (preferredNextTagName) {
const shouldReportNext = () => {
if (
/** @type {import('comment-parser').Spec} */ (
nextTag
).type.trim() === 'never'
) {
if (utils.hasYieldReturnValue()) {
report(`JSDoc @${preferredNextTagName} declaration set with "never" but yield expression with return value is present in function.`);
}
return false;
}
if (checkGeneratorsOnly && !utils.isGenerator()) {
return true;
}
return !utils.mayBeUndefinedTypeTag(
/** @type {import('comment-parser').Spec} */
(nextTag),
) && !utils.hasYieldReturnValue();
};
if (shouldReportNext()) {
report(`JSDoc @${preferredNextTagName} declaration present but yield expression with return value not available in function.`);
}
}
}
}, {
meta: {
docs: {
description: 'Ensures that if a `@yields` is present that a `yield` (or `yield` with a value) is present in the function body (or that if a `@next` is present that there is a yield with a return value present).',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/require-yields-check.md#repos-sticky-header',
},
schema: [
{
additionalProperties: false,
properties: {
checkGeneratorsOnly: {
default: false,
description: `Avoids checking the function body and merely insists
that all generators have \`@yields\`. This can be an optimization with the
ESLint \`require-yield\` rule, as that rule already ensures a \`yield\` is
present in generators, albeit assuming the generator is not empty).
Defaults to \`false\`.`,
type: 'boolean',
},
contexts: {
description: `Set this to an array of strings representing the AST context
(or objects with optional \`context\` and \`comment\` properties) where you wish
the rule to be applied.
\`context\` defaults to \`any\` and \`comment\` defaults to no specific comment context.
Overrides the default contexts (\`ArrowFunctionExpression\`, \`FunctionDeclaration\`,
\`FunctionExpression\`).`,
items: {
anyOf: [
{
type: 'string',
},
{
additionalProperties: false,
properties: {
comment: {
type: 'string',
},
context: {
type: 'string',
},
},
type: 'object',
},
],
},
type: 'array',
},
next: {
default: false,
description: `If \`true\`, this option will insist that any use of a (non-standard)
\`@next\` tag (in addition to any \`@yields\` tag) will be matched by a \`yield\`
which uses a return value in the body of the generator (e.g.,
\`const rv = yield;\` or \`const rv = yield value;\`). This (non-standard)
tag is intended to be used to indicate a type and/or description of
the value expected to be supplied by the user when supplied to the iterator
by its \`next\` method, as with \`it.next(value)\` (with the iterator being
the \`Generator\` iterator that is returned by the call to the generator
function). This option will report an error if the generator function body
merely has plain \`yield;\` or \`yield value;\` statements without returning
the values. Defaults to \`false\`.`,
type: 'boolean',
},
},
type: 'object',
},
],
type: 'suggestion',
},
});

View file

@ -0,0 +1,766 @@
import defaultTagOrder from '../defaultTagOrder.js';
import iterateJsdoc from '../iterateJsdoc.js';
export default iterateJsdoc(({
context,
jsdoc,
utils,
// eslint-disable-next-line complexity -- Temporary
}) => {
const
/**
* @type {{
* linesBetween: import('../iterateJsdoc.js').Integer,
* tagExceptions: Record<string, number>,
* tagSequence: {
* tags: string[]
* }[],
* alphabetizeExtras: boolean,
* reportTagGroupSpacing: boolean,
* reportIntraTagGroupSpacing: boolean,
* }}
*/ {
alphabetizeExtras = false,
linesBetween = 1,
reportIntraTagGroupSpacing = true,
reportTagGroupSpacing = true,
tagExceptions = {},
tagSequence = defaultTagOrder,
} = context.options[0] || {};
const tagList = tagSequence.flatMap((obj) => {
/* typeof obj === 'string' ? obj : */
return obj.tags;
});
const otherPos = tagList.indexOf('-other');
const endPos = otherPos > -1 ? otherPos : tagList.length;
let ongoingCount = 0;
for (const [
idx,
tag,
] of
/**
* @type {(
* import('@es-joy/jsdoccomment').JsdocTagWithInline & {
* originalIndex: import('../iterateJsdoc.js').Integer,
* originalLine: import('../iterateJsdoc.js').Integer,
* }
* )[]}
*/ (jsdoc.tags).entries()) {
tag.originalIndex = idx;
ongoingCount += tag.source.length;
tag.originalLine = ongoingCount;
}
/** @type {import('../iterateJsdoc.js').Integer|undefined} */
let firstChangedTagLine;
/** @type {import('../iterateJsdoc.js').Integer|undefined} */
let firstChangedTagIndex;
/**
* @type {(import('comment-parser').Spec & {
* originalIndex: import('../iterateJsdoc.js').Integer,
* originalLine: import('../iterateJsdoc.js').Integer,
* })[]}
*/
const sortedTags = JSON.parse(JSON.stringify(jsdoc.tags));
sortedTags.sort(({
tag: tagNew,
}, {
originalIndex,
originalLine,
tag: tagOld,
}) => {
// Optimize: Just keep relative positions if the same tag name
if (tagNew === tagOld) {
return 0;
}
const checkOrSetFirstChanged = () => {
if (!firstChangedTagLine || originalLine < firstChangedTagLine) {
firstChangedTagLine = originalLine;
firstChangedTagIndex = originalIndex;
}
};
const newPos = tagList.indexOf(tagNew);
const oldPos = tagList.indexOf(tagOld);
const preferredNewPos = newPos === -1 ? endPos : newPos;
const preferredOldPos = oldPos === -1 ? endPos : oldPos;
if (preferredNewPos < preferredOldPos) {
checkOrSetFirstChanged();
return -1;
}
if (preferredNewPos > preferredOldPos) {
return 1;
}
// preferredNewPos === preferredOldPos
if (
!alphabetizeExtras ||
// Optimize: If tagNew (or tagOld which is the same) was found in the
// priority array, it can maintain its relative position—without need
// of alphabetizing (secondary sorting)
newPos >= 0
) {
return 0;
}
if (tagNew < tagOld) {
checkOrSetFirstChanged();
return -1;
}
// tagNew > tagOld
return 1;
});
if (firstChangedTagLine === undefined) {
// Should be ordered by now
/**
* @type {import('comment-parser').Spec[]}
*/
const lastTagsOfGroup = [];
/**
* @type {[
* import('comment-parser').Spec,
* import('../iterateJsdoc.js').Integer
* ][]}
*/
const badLastTagsOfGroup = [];
/**
* @param {import('comment-parser').Spec} tag
*/
const countTagEmptyLines = (tag) => {
return tag.source.reduce((acc, {
tokens: {
description,
end,
name,
tag: tg,
type,
},
}) => {
const empty = !tg && !type && !name && !description;
// Reset the count so long as there is content
return empty ? acc + Number(empty && !end) : 0;
}, 0);
};
let idx = 0;
for (const {
tags,
} of tagSequence) {
let innerIdx;
/** @type {import('comment-parser').Spec} */
let currentTag;
/** @type {import('comment-parser').Spec|undefined} */
let lastTag;
do {
currentTag = jsdoc.tags[idx];
if (!currentTag) {
idx++;
break;
}
innerIdx = tags.indexOf(currentTag.tag);
if (
innerIdx === -1 &&
// eslint-disable-next-line no-loop-func -- Safe
(!tags.includes('-other') || tagSequence.some(({
tags: tgs,
}) => {
return tgs.includes(currentTag.tag);
}))
) {
idx++;
break;
}
lastTag = currentTag;
idx++;
} while (true);
idx--;
if (lastTag) {
lastTagsOfGroup.push(lastTag);
const ct = countTagEmptyLines(lastTag);
if (
ct !== linesBetween &&
// Use another rule for adding to end (should be of interest outside this rule)
jsdoc.tags[idx]
) {
badLastTagsOfGroup.push([
lastTag, ct,
]);
}
}
}
if (reportTagGroupSpacing && badLastTagsOfGroup.length) {
/**
* @param {import('comment-parser').Spec} tg
* @returns {() => void}
*/
const fixer = (tg) => {
return () => {
// Due to https://github.com/syavorsky/comment-parser/issues/110 ,
// we have to modify `jsdoc.source` rather than just modify tags
// directly
for (const [
currIdx,
{
tokens,
},
] of jsdoc.source.entries()) {
if (tokens.tag !== '@' + tg.tag) {
continue;
}
// Cannot be `tokens.end`, as dropped off last tag, so safe to
// go on
let newIdx = currIdx;
const emptyLine = () => {
return {
number: 0,
source: '',
tokens: utils.seedTokens({
delimiter: '*',
start: jsdoc.source[newIdx - 1].tokens.start,
}),
};
};
let existingEmptyLines = 0;
while (true) {
const nextTokens = jsdoc.source[++newIdx]?.tokens;
/* c8 ignore next 3 -- Guard */
if (!nextTokens) {
return;
}
// Should be no `nextTokens.end` to worry about since ignored
// if not followed by tag
if (nextTokens.tag) {
// Haven't made it to last tag instance yet, so keep looking
if (nextTokens.tag === tokens.tag) {
existingEmptyLines = 0;
continue;
}
const lineDiff = linesBetween - existingEmptyLines;
if (lineDiff > 0) {
const lines = Array.from({
length: lineDiff,
}, () => {
return emptyLine();
});
jsdoc.source.splice(newIdx, 0, ...lines);
} else {
// lineDiff < 0
jsdoc.source.splice(
newIdx + lineDiff,
-lineDiff,
);
}
break;
}
const empty = !nextTokens.type && !nextTokens.name &&
!nextTokens.description;
if (empty) {
existingEmptyLines++;
} else {
// Has content again, so reset empty line count
existingEmptyLines = 0;
}
}
break;
}
for (const [
srcIdx,
src,
] of jsdoc.source.entries()) {
src.number = srcIdx;
}
};
};
for (const [
tg,
] of badLastTagsOfGroup) {
utils.reportJSDoc(
'Tag groups do not have the expected whitespace',
tg,
fixer(tg),
);
}
return;
}
if (!reportIntraTagGroupSpacing) {
return;
}
for (const [
tagIdx,
tag,
] of jsdoc.tags.entries()) {
if (!jsdoc.tags[tagIdx + 1] || lastTagsOfGroup.includes(tag)) {
continue;
}
const ct = countTagEmptyLines(tag);
if (ct && (!tagExceptions[tag.tag] || tagExceptions[tag.tag] < ct)) {
const fixer = () => {
let foundFirstTag = false;
/** @type {string|undefined} */
let currentTag;
for (const [
currIdx,
{
tokens: {
description,
end,
name,
tag: tg,
type,
},
},
] of jsdoc.source.entries()) {
if (tg) {
foundFirstTag = true;
currentTag = tg;
}
if (!foundFirstTag) {
continue;
}
if (currentTag && !tg && !type && !name && !description && !end) {
let nextIdx = currIdx;
let ignore = true;
// Even if a tag of the same name as the last tags in a group,
// could still be an earlier tag in that group
// eslint-disable-next-line no-loop-func -- Safe
if (lastTagsOfGroup.some((lastTagOfGroup) => {
return currentTag === '@' + lastTagOfGroup.tag;
})) {
while (true) {
const nextTokens = jsdoc.source[++nextIdx]?.tokens;
if (!nextTokens) {
break;
}
if (!nextTokens.tag) {
continue;
}
// Followed by the same tag name, so not actually last in group,
// and of interest
if (nextTokens.tag === currentTag) {
ignore = false;
}
}
} else {
while (true) {
const nextTokens = jsdoc.source[++nextIdx]?.tokens;
if (!nextTokens || nextTokens.end) {
break;
}
// Not the very last tag, so don't ignore
if (nextTokens.tag) {
ignore = false;
break;
}
}
}
if (!ignore) {
jsdoc.source.splice(currIdx, 1);
for (const [
srcIdx,
src,
] of jsdoc.source.entries()) {
src.number = srcIdx;
}
}
}
}
};
utils.reportJSDoc(
'Intra-group tags have unexpected whitespace',
tag,
fixer,
);
}
}
return;
}
const firstLine = utils.getFirstLine();
const fix = () => {
const itemsToMoveRange = [
...Array.from({
length: jsdoc.tags.length -
/** @type {import('../iterateJsdoc.js').Integer} */ (
firstChangedTagIndex
),
}).keys(),
];
const unchangedPriorTagDescriptions = jsdoc.tags.slice(
0,
firstChangedTagIndex,
).reduce((ct, {
source,
}) => {
return ct + source.length - 1;
}, 0);
// This offset includes not only the offset from where the first tag
// must begin, and the additional offset of where the first changed
// tag begins, but it must also account for prior descriptions
const initialOffset = /** @type {import('../iterateJsdoc.js').Integer} */ (
firstLine
) + /** @type {import('../iterateJsdoc.js').Integer} */ (firstChangedTagIndex) +
// May be the first tag, so don't try finding a prior one if so
unchangedPriorTagDescriptions;
// Use `firstChangedTagLine` for line number to begin reporting/splicing
for (const idx of itemsToMoveRange) {
utils.removeTag(
idx +
/** @type {import('../iterateJsdoc.js').Integer} */ (
firstChangedTagIndex
),
);
}
const changedTags = sortedTags.slice(firstChangedTagIndex);
let extraTagCount = 0;
for (const idx of itemsToMoveRange) {
const changedTag = changedTags[idx];
utils.addTag(
changedTag.tag,
extraTagCount + initialOffset + idx,
{
...changedTag.source[0].tokens,
// `comment-parser` puts the `end` within the `tags` section, so
// avoid adding another to jsdoc.source
end: '',
},
);
for (const {
tokens,
} of changedTag.source.slice(1)) {
if (!tokens.end) {
utils.addLine(
extraTagCount + initialOffset + idx + 1,
{
...tokens,
end: '',
},
);
extraTagCount++;
}
}
}
};
utils.reportJSDoc(
`Tags are not in the prescribed order: ${
tagList.join(', ') || '(alphabetical)'
}`,
jsdoc.tags[/** @type {import('../iterateJsdoc.js').Integer} */ (
firstChangedTagIndex
)],
fix,
true,
);
}, {
iterateAllJsdocs: true,
meta: {
docs: {
description: 'Sorts tags by a specified sequence according to tag name, optionally adding line breaks between tag groups.',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/sort-tags.md#repos-sticky-header',
},
fixable: 'code',
schema: [
{
additionalProperties: false,
properties: {
alphabetizeExtras: {
description: `Defaults to \`false\`. Alphabetizes any items not within \`tagSequence\` after any
items within \`tagSequence\` (or in place of the special \`-other\` pseudo-tag)
are sorted.
If you want all your tags alphabetized, you can supply an empty array for
\`tagSequence\` along with setting this option to \`true\`.`,
type: 'boolean',
},
linesBetween: {
description: `Indicates the number of lines to be added between tag groups. Defaults to 1.
Do not set to 0 or 2+ if you are using \`tag-lines\` and \`"always"\` and do not
set to 1+ if you are using \`tag-lines\` and \`"never"\`.`,
type: 'integer',
},
reportIntraTagGroupSpacing: {
description: `Whether to enable reporting and fixing of line breaks within tags of a given
tag group. Defaults to \`true\` which will remove any line breaks at the end of
such tags. Do not use with \`true\` if you are using \`tag-lines\` and \`always\`.`,
type: 'boolean',
},
reportTagGroupSpacing: {
description: `Whether to enable reporting and fixing of line breaks between tag groups
as set by \`linesBetween\`. Defaults to \`true\`. Note that the very last tag
will not have spacing applied regardless. For adding line breaks there, you
may wish to use the \`endLines\` option of the \`tag-lines\` rule.`,
type: 'boolean',
},
tagExceptions: {
description: 'Allows specification by tag of a specific higher maximum number of lines. Keys are tags and values are the maximum number of lines allowed for such tags. Overrides `linesBetween`. Defaults to no special exceptions per tag.',
patternProperties: {
'.*': {
type: 'number',
},
},
type: 'object',
},
tagSequence: {
description: `An array of tag group objects indicating the preferred sequence for sorting tags.
Each item in the array should be an object with a \`tags\` property set to an array
of tag names.
Tag names earlier in the list will be arranged first. The relative position of
tags of the same name will not be changed.
Earlier groups will also be arranged before later groups, but with the added
feature that additional line breaks may be added between (or before or after)
such groups (depending on the setting of \`linesBetween\`).
Tag names not in the list will be grouped together at the end. The pseudo-tag
\`-other\` can be used to place them anywhere else if desired. The tags will be
placed in their order of appearance, or alphabetized if \`alphabetizeExtras\`
is enabled, see more below about that option.
Defaults to the array below (noting that it is just a single tag group with
no lines between groups by default).
Please note that this order is still experimental, so if you want to retain
a fixed order that doesn't change into the future, supply your own
\`tagSequence\`.
\`\`\`js
[{tags: [
// Brief descriptions
'summary',
'typeSummary',
// Module/file-level
'module',
'exports',
'file',
'fileoverview',
'overview',
'import',
// Identifying (name, type)
'typedef',
'interface',
'record',
'template',
'name',
'kind',
'type',
'alias',
'external',
'host',
'callback',
'func',
'function',
'method',
'class',
'constructor',
// Relationships
'modifies',
'mixes',
'mixin',
'mixinClass',
'mixinFunction',
'namespace',
'borrows',
'constructs',
'lends',
'implements',
'requires',
// Long descriptions
'desc',
'description',
'classdesc',
'tutorial',
'copyright',
'license',
// Simple annotations
'const',
'constant',
'final',
'global',
'readonly',
'abstract',
'virtual',
'var',
'member',
'memberof',
'memberof!',
'inner',
'instance',
'inheritdoc',
'inheritDoc',
'override',
'hideconstructor',
// Core function/object info
'param',
'arg',
'argument',
'prop',
'property',
'return',
'returns',
// Important behavior details
'async',
'generator',
'default',
'defaultvalue',
'enum',
'augments',
'extends',
'throws',
'exception',
'yield',
'yields',
'event',
'fires',
'emits',
'listens',
'this',
// Access
'static',
'private',
'protected',
'public',
'access',
'package',
'-other',
// Supplementary descriptions
'see',
'example',
// METADATA
// Other Closure (undocumented) metadata
'closurePrimitive',
'customElement',
'expose',
'hidden',
'idGenerator',
'meaning',
'ngInject',
'owner',
'wizaction',
// Other Closure (documented) metadata
'define',
'dict',
'export',
'externs',
'implicitCast',
'noalias',
'nocollapse',
'nocompile',
'noinline',
'nosideeffects',
'polymer',
'polymerBehavior',
'preserve',
'struct',
'suppress',
'unrestricted',
// @homer0/prettier-plugin-jsdoc metadata
'category',
// Non-Closure metadata
'ignore',
'author',
'version',
'variation',
'since',
'deprecated',
'todo',
]}];
\`\`\``,
items: {
additionalProperties: false,
properties: {
tags: {
description: 'See description on `tagSequence`.',
items: {
type: 'string',
},
type: 'array',
},
},
type: 'object',
},
type: 'array',
},
},
type: 'object',
},
],
type: 'suggestion',
},
});

View file

@ -0,0 +1,477 @@
import iterateJsdoc from '../iterateJsdoc.js';
/**
* @param {{
* maxBlockLines: null|number,
* startLines: null|number,
* utils: import('../iterateJsdoc.js').Utils
* }} cfg
*/
const checkMaxBlockLines = ({
maxBlockLines,
startLines,
utils,
}) => {
if (typeof maxBlockLines !== 'number') {
return false;
}
if (typeof startLines === 'number' && maxBlockLines < startLines) {
utils.reportJSDoc(
'If set to a number, `maxBlockLines` must be greater than or equal to `startLines`.',
);
return true;
}
const {
description,
} = utils.getDescription();
const excessBlockLinesRegex = new RegExp('\n{' + (maxBlockLines + 2) + ',}', 'v');
const excessBlockLinesMatch = description.match(excessBlockLinesRegex);
const excessBlockLines = excessBlockLinesMatch?.[0]?.length ?? 0;
if (excessBlockLinesMatch) {
const excessIndexLine = description.slice(0, excessBlockLinesMatch.index).match(/\n/gv)?.length ?? 0;
utils.reportJSDoc(
`Expected a maximum of ${maxBlockLines} line${maxBlockLines === 1 ? '' : 's'} within block description`,
{
line: excessIndexLine,
},
() => {
utils.setBlockDescription((info, seedTokens, descLines, postDelims) => {
const newPostDelims = [
...postDelims.slice(0, excessIndexLine),
...postDelims.slice(excessIndexLine + excessBlockLines - 1 - maxBlockLines),
];
return [
...descLines.slice(0, excessIndexLine),
...descLines.slice(excessIndexLine + excessBlockLines - 1 - maxBlockLines),
].map((desc, idx) => {
return {
number: 0,
source: '',
tokens: seedTokens({
...info,
description: desc,
postDelimiter: newPostDelims[idx],
}),
};
});
});
},
);
return true;
}
return false;
};
export default iterateJsdoc(({
context,
jsdoc,
utils,
}) => {
const [
alwaysNever = 'never',
{
applyToEndTag = true,
count = 1,
endLines = 0,
maxBlockLines = null,
startLines = 0,
tags = {},
} = {},
] = context.options;
jsdoc.tags.some((tg, tagIdx) => {
let lastTag;
/**
* @type {null|import('../iterateJsdoc.js').Integer}
*/
let lastEmpty = null;
/**
* @type {null|import('../iterateJsdoc.js').Integer}
*/
let reportIndex = null;
let emptyLinesCount = 0;
for (const [
idx,
{
tokens: {
description,
end,
name,
tag,
type,
},
},
] of tg.source.entries()) {
// May be text after a line break within a tag description
if (description) {
reportIndex = null;
}
if (lastTag && [
'always', 'any',
].includes(tags[lastTag.slice(1)]?.lines)) {
continue;
}
const empty = !tag && !name && !type && !description;
if (
empty && !end &&
(alwaysNever === 'never' ||
lastTag && tags[lastTag.slice(1)]?.lines === 'never'
)
) {
reportIndex = idx;
continue;
}
if (!end) {
if (empty) {
emptyLinesCount++;
} else {
emptyLinesCount = 0;
}
lastEmpty = empty ? idx : null;
}
lastTag = tag;
}
if (
typeof endLines === 'number' &&
lastEmpty !== null && tagIdx === jsdoc.tags.length - 1
) {
const lineDiff = endLines - emptyLinesCount;
if (lineDiff < 0) {
const fixer = () => {
utils.removeTag(tagIdx, {
tagSourceOffset: /** @type {import('../iterateJsdoc.js').Integer} */ (
lastEmpty
) + lineDiff + 1,
});
};
utils.reportJSDoc(
`Expected ${endLines} trailing lines`,
{
line: tg.source[lastEmpty].number + lineDiff + 1,
},
fixer,
);
} else if (lineDiff > 0) {
const fixer = () => {
utils.addLines(
tagIdx,
/** @type {import('../iterateJsdoc.js').Integer} */ (lastEmpty),
endLines - emptyLinesCount,
);
};
utils.reportJSDoc(
`Expected ${endLines} trailing lines`,
{
line: tg.source[lastEmpty].number,
},
fixer,
);
}
return true;
}
if (reportIndex !== null) {
const fixer = () => {
utils.removeTag(tagIdx, {
tagSourceOffset: /** @type {import('../iterateJsdoc.js').Integer} */ (
reportIndex
),
});
};
utils.reportJSDoc(
'Expected no lines between tags',
{
line: tg.source[0].number + 1,
},
fixer,
);
return true;
}
return false;
});
(applyToEndTag ? jsdoc.tags : jsdoc.tags.slice(0, -1)).some((tg, tagIdx) => {
/**
* @type {{
* idx: import('../iterateJsdoc.js').Integer,
* number: import('../iterateJsdoc.js').Integer
* }[]}
*/
const lines = [];
let currentTag;
let tagSourceIdx = 0;
for (const [
idx,
{
number,
tokens: {
description,
end,
name,
tag,
type,
},
},
] of tg.source.entries()) {
if (description) {
lines.splice(0);
tagSourceIdx = idx;
}
if (tag) {
currentTag = tag;
}
if (!tag && !name && !type && !description && !end) {
lines.push({
idx,
number,
});
}
}
const currentTg = currentTag && tags[currentTag.slice(1)];
const tagCount = currentTg?.count;
const defaultAlways = alwaysNever === 'always' && currentTg?.lines !== 'never' &&
currentTg?.lines !== 'any' && lines.length < count;
let overrideAlways;
let fixCount = count;
if (!defaultAlways) {
fixCount = typeof tagCount === 'number' ? tagCount : count;
overrideAlways = currentTg?.lines === 'always' &&
lines.length < fixCount;
}
if (defaultAlways || overrideAlways) {
const fixer = () => {
utils.addLines(tagIdx, lines[lines.length - 1]?.idx || tagSourceIdx + 1, fixCount - lines.length);
};
const line = lines[lines.length - 1]?.number || tg.source[tagSourceIdx].number;
utils.reportJSDoc(
`Expected ${fixCount} line${fixCount === 1 ? '' : 's'} between tags but found ${lines.length}`,
{
line,
},
fixer,
);
return true;
}
return false;
});
if (checkMaxBlockLines({
maxBlockLines,
startLines,
utils,
})) {
return;
}
if (typeof startLines === 'number') {
if (!jsdoc.tags.length) {
return;
}
const {
description,
lastDescriptionLine,
} = utils.getDescription();
if (!(/\S/v).test(description)) {
return;
}
const trailingLines = description.match(/\n+$/v)?.[0]?.length;
const trailingDiff = (trailingLines ?? 0) - startLines;
if (trailingDiff > 0) {
utils.reportJSDoc(
`Expected only ${startLines} line${startLines === 1 ? '' : 's'} after block description`,
{
line: lastDescriptionLine - trailingDiff,
},
() => {
utils.setBlockDescription((info, seedTokens, descLines, postDelims) => {
return descLines.slice(0, -trailingDiff).map((desc, idx) => {
return {
number: 0,
source: '',
tokens: seedTokens({
...info,
description: desc,
postDelimiter: postDelims[idx],
}),
};
});
});
},
);
} else if (trailingDiff < 0) {
utils.reportJSDoc(
`Expected ${startLines} lines after block description`,
{
line: lastDescriptionLine,
},
() => {
utils.setBlockDescription((info, seedTokens, descLines, postDelims) => {
return [
...descLines,
...Array.from({
length: -trailingDiff,
}, () => {
return '';
}),
].map((desc, idx) => {
return {
number: 0,
source: '',
tokens: seedTokens({
...info,
description: desc,
postDelimiter: desc.trim() ? postDelims[idx] : '',
}),
};
});
});
},
);
}
}
}, {
iterateAllJsdocs: true,
meta: {
docs: {
description: 'Enforces lines (or no lines) before, after, or between tags.',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/tag-lines.md#repos-sticky-header',
},
fixable: 'code',
schema: [
{
description: `Defaults to "never". "any" is only useful with \`tags\` (allowing non-enforcement of lines except
for particular tags) or with \`startLines\`, \`endLines\`, or \`maxBlockLines\`. It is also
necessary if using the linebreak-setting options of the \`sort-tags\` rule
so that the two rules won't conflict in both attempting to set lines
between tags.`,
enum: [
'always', 'any', 'never',
],
type: 'string',
},
{
additionalProperties: false,
properties: {
applyToEndTag: {
description: `Set to \`false\` and use with "always" to indicate the normal lines to be
added after tags should not be added after the final tag.
Defaults to \`true\`.`,
type: 'boolean',
},
count: {
description: `Use with "always" to indicate the number of lines to require be present.
Defaults to 1.`,
type: 'integer',
},
endLines: {
anyOf: [
{
type: 'integer',
},
{
type: 'null',
},
],
description: `If not set to \`null\`, will enforce end lines to the given count on the
final tag only.
Defaults to \`0\`.`,
},
maxBlockLines: {
anyOf: [
{
type: 'integer',
},
{
type: 'null',
},
],
description: `If not set to \`null\`, will enforce a maximum number of lines to the given count anywhere in the block description.
Note that if non-\`null\`, \`maxBlockLines\` must be greater than or equal to \`startLines\`.
Defaults to \`null\`.`,
},
startLines: {
anyOf: [
{
type: 'integer',
},
{
type: 'null',
},
],
description: `If not set to \`null\`, will enforce end lines to the given count before the
first tag only, unless there is only whitespace content, in which case,
a line count will not be enforced.
Defaults to \`0\`.`,
},
tags: {
description: `Overrides the default behavior depending on specific tags.
An object whose keys are tag names and whose values are objects with the
following keys:
1. \`lines\` - Set to \`always\`, \`never\`, or \`any\` to override.
2. \`count\` - Overrides main \`count\` (for "always")
Defaults to empty object.`,
patternProperties: {
'.*': {
additionalProperties: false,
properties: {
count: {
type: 'integer',
},
lines: {
enum: [
'always', 'never', 'any',
],
type: 'string',
},
},
},
},
type: 'object',
},
},
type: 'object',
},
],
type: 'suggestion',
},
});

View file

@ -0,0 +1,158 @@
import iterateJsdoc from '../iterateJsdoc.js';
// We could disallow raw gt, quot, and apos, but allow for parity; but we do
// not allow hex or decimal character references
const htmlRegex = /(<|&(?!(?:amp|lt|gt|quot|apos);))(?=\S)/v;
const markdownRegex = /(?<!\\)(`+)([^`]+)\1(?!`)/v;
/**
* @param {string} desc
* @returns {string}
*/
const htmlReplacer = (desc) => {
return desc.replaceAll(new RegExp(htmlRegex, 'gv'), (_) => {
if (_ === '<') {
return '&lt;';
}
return '&amp;';
});
};
/**
* @param {string} desc
* @returns {string}
*/
const markdownReplacer = (desc) => {
return desc.replaceAll(new RegExp(markdownRegex, 'gv'), (_, backticks, encapsed) => {
const bookend = '`'.repeat(backticks.length);
return `\\${bookend}${encapsed}${bookend}`;
});
};
export default iterateJsdoc(({
context,
jsdoc,
utils,
}) => {
const {
escapeHTML,
escapeMarkdown,
} = context.options[0] || {};
if (!escapeHTML && !escapeMarkdown) {
context.report({
loc: {
end: {
column: 1,
line: 1,
},
start: {
column: 1,
line: 1,
},
},
message: 'You must include either `escapeHTML` or `escapeMarkdown`',
});
return;
}
const {
descriptions,
} = utils.getDescription();
if (escapeHTML) {
if (descriptions.some((desc) => {
return htmlRegex.test(desc);
})) {
const line = utils.setDescriptionLines(htmlRegex, htmlReplacer);
utils.reportJSDoc('You have unescaped HTML characters < or &', {
line,
}, () => {}, true);
return;
}
for (const tag of jsdoc.tags) {
if (tag.tag === 'example') {
continue;
}
if (/** @type {string[]} */ (
utils.getTagDescription(tag, true)
).some((desc) => {
return htmlRegex.test(desc);
})) {
const line = utils.setTagDescription(tag, htmlRegex, htmlReplacer) +
tag.source[0].number;
utils.reportJSDoc('You have unescaped HTML characters < or & in a tag', {
line,
}, () => {}, true);
}
}
return;
}
if (descriptions.some((desc) => {
return markdownRegex.test(desc);
})) {
const line = utils.setDescriptionLines(markdownRegex, markdownReplacer);
utils.reportJSDoc('You have unescaped Markdown backtick sequences', {
line,
}, () => {}, true);
return;
}
for (const tag of jsdoc.tags) {
if (tag.tag === 'example') {
continue;
}
if (/** @type {string[]} */ (
utils.getTagDescription(tag, true)
).some((desc) => {
return markdownRegex.test(desc);
})) {
const line = utils.setTagDescription(
tag, markdownRegex, markdownReplacer,
) + tag.source[0].number;
utils.reportJSDoc(
'You have unescaped Markdown backtick sequences in a tag',
{
line,
},
() => {},
true,
);
}
}
}, {
iterateAllJsdocs: true,
meta: {
docs: {
description: 'Auto-escape certain characters that are input within block and tag descriptions.',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/text-escaping.md#repos-sticky-header',
},
fixable: 'code',
schema: [
{
additionalProperties: false,
properties: {
escapeHTML: {
description: `This option escapes all \`<\` and \`&\` characters (except those followed by
whitespace which are treated as literals by Visual Studio Code). Defaults to
\`false\`.`,
type: 'boolean',
},
escapeMarkdown: {
description: `This option escapes the first backtick (\`\` \` \`\`) in a paired sequence.
Defaults to \`false\`.`,
type: 'boolean',
},
},
type: 'object',
},
],
type: 'suggestion',
},
});

View file

@ -0,0 +1,300 @@
import iterateJsdoc from '../iterateJsdoc.js';
import {
rewireByParsedType,
} from '../jsdocUtils.js';
import {
parse as parseType,
traverse,
} from '@es-joy/jsdoccomment';
export default iterateJsdoc(({
context,
indent,
jsdoc,
utils,
}) => {
const functionType = context.options[0] ?? 'property';
const {
enableFixer = true,
} = context.options[1] ?? {};
/**
* @param {import('@es-joy/jsdoccomment').JsdocTagWithInline} tag
*/
const checkType = (tag) => {
const potentialType = tag.type;
let parsedType;
try {
parsedType = parseType(
/** @type {string} */ (potentialType), 'typescript',
);
} catch {
return;
}
traverse(parsedType, (nde, parentNode) => {
// @ts-expect-error Adding our own property for use below
nde.parentNode = parentNode;
});
traverse(parsedType, (nde, parentNode, property, idx) => {
switch (nde.type) {
case 'JsdocTypeFunction': {
if (functionType !== 'method') {
break;
}
if (parentNode?.type === 'JsdocTypeObjectField' &&
typeof parentNode.key === 'string'
) {
utils.reportJSDoc(
'Found function property; prefer method signature.',
tag,
enableFixer ? () => {
const objectField = parentNode;
const obj =
/**
* @type {import('jsdoc-type-pratt-parser').ObjectFieldResult & {
* parentNode: import('jsdoc-type-pratt-parser').ObjectResult
* }}
*/
(objectField).parentNode;
const index = obj.elements.indexOf(parentNode);
obj.elements[index] = {
/* c8 ignore next 5 -- Guard */
meta: nde.meta ?
{
quote: objectField.meta.quote,
...nde.meta,
} :
{
quote: objectField.meta.quote,
},
name: /** @type {string} */ (objectField.key),
parameters: nde.parameters,
returnType: /** @type {import('jsdoc-type-pratt-parser').RootResult} */ (
nde.returnType
),
type: 'JsdocTypeMethodSignature',
typeParameters: nde.typeParameters,
};
rewireByParsedType(jsdoc, tag, parsedType, indent);
} : null,
);
break;
}
if (parentNode?.type === 'JsdocTypeParenthesis' &&
// @ts-expect-error Our own added API
parentNode.parentNode?.type === 'JsdocTypeIntersection' &&
// @ts-expect-error Our own added API
parentNode.parentNode.parentNode.type === 'JsdocTypeObjectField' &&
// @ts-expect-error Our own added API
typeof parentNode.parentNode.parentNode.key === 'string'
) {
// @ts-expect-error Our own added API
const intersection = parentNode.parentNode;
const objectField = intersection.parentNode;
const object = objectField.parentNode;
// const objFieldIndex = object.elements.indexOf(objectField);
/**
* @param {import('jsdoc-type-pratt-parser').FunctionResult} func
*/
const convertToMethod = (func) => {
return /** @type {import('jsdoc-type-pratt-parser').MethodSignatureResult} */ ({
/* c8 ignore next 5 -- Guard */
meta: func.meta ?
{
quote: objectField.meta.quote,
...func.meta,
} :
{
quote: objectField.meta.quote,
},
name: /** @type {string} */ (objectField.key),
parameters: func.parameters,
returnType: /** @type {import('jsdoc-type-pratt-parser').RootResult} */ (
func.returnType
),
type: 'JsdocTypeMethodSignature',
typeParameters: func.typeParameters,
});
};
/** @type {import('jsdoc-type-pratt-parser').MethodSignatureResult[]} */
const methods = [];
/** @type {number[]} */
const methodIndexes = [];
for (const [
index,
element,
] of intersection.elements.entries()) {
if (
element.type !== 'JsdocTypeParenthesis' ||
element.element.type !== 'JsdocTypeFunction'
) {
return;
}
methods.push(convertToMethod(element.element));
methodIndexes.push(index);
}
utils.reportJSDoc(
'Found function property; prefer method signature.',
tag,
enableFixer ? () => {
for (const methodIndex of methodIndexes.toReversed()) {
object.elements.splice(methodIndex, 1);
}
object.elements.splice(methodIndexes[0], 0, ...methods);
rewireByParsedType(jsdoc, tag, parsedType, indent);
} : null,
);
}
break;
}
case 'JsdocTypeMethodSignature': {
if (functionType !== 'property') {
break;
}
/**
* @param {import('jsdoc-type-pratt-parser').MethodSignatureResult} node
*/
const convertToFunction = (node) => {
return {
arrow: true,
constructor: false,
meta: /** @type {Required<import('jsdoc-type-pratt-parser').MethodSignatureResult['meta']>} */ (
node.meta
),
parameters: node.parameters,
parenthesis: true,
returnType: node.returnType,
type: 'JsdocTypeFunction',
typeParameters: node.typeParameters,
};
};
utils.reportJSDoc(
'Found method signature; prefer function property.',
tag,
enableFixer ? () => {
/* c8 ignore next 3 -- TS guard */
if (!parentNode || !property || typeof idx !== 'number') {
throw new Error('Unexpected lack of parent or property');
}
const object = /** @type {import('jsdoc-type-pratt-parser').ObjectResult} */ (
parentNode
);
const funcs = [];
const removals = [];
for (const [
index,
element,
] of object.elements.entries()) {
if (element.type === 'JsdocTypeMethodSignature' &&
element.name === nde.name
) {
funcs.push(convertToFunction(element));
if (index !== idx) {
removals.push(index);
}
}
}
if (funcs.length === 1) {
object.elements[idx] = /** @type {import('jsdoc-type-pratt-parser').ObjectFieldResult} */ ({
key: nde.name,
meta: nde.meta,
optional: false,
readonly: false,
right: funcs[0],
type: 'JsdocTypeObjectField',
});
} else {
for (const removal of removals.toReversed()) {
object.elements.splice(removal, 1);
}
object.elements[idx] = {
key: nde.name,
meta: nde.meta,
optional: false,
readonly: false,
right: {
elements: funcs.map((func) => {
return /** @type {import('jsdoc-type-pratt-parser').ParenthesisResult} */ ({
element: func,
type: 'JsdocTypeParenthesis',
});
}),
type: 'JsdocTypeIntersection',
},
type: 'JsdocTypeObjectField',
};
}
rewireByParsedType(jsdoc, tag, parsedType, indent);
} : null,
);
}
break;
}
});
};
const tags = utils.filterTags(({
tag,
}) => {
return Boolean(tag !== 'import' && utils.tagMightHaveTypePosition(tag));
});
for (const tag of tags) {
if (tag.type) {
checkType(tag);
}
}
}, {
iterateAllJsdocs: true,
meta: {
docs: {
description: 'Prefers either function properties or method signatures',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/ts-method-signature-style.md#repos-sticky-header',
},
fixable: 'code',
schema: [
{
enum: [
'method',
'property',
],
type: 'string',
},
{
additionalProperties: false,
properties: {
enableFixer: {
description: 'Whether to enable the fixer. Defaults to `true`.',
type: 'boolean',
},
},
type: 'object',
},
],
type: 'suggestion',
},
});

View file

@ -0,0 +1,61 @@
import iterateJsdoc from '../iterateJsdoc.js';
import {
parse as parseType,
traverse,
} from '@es-joy/jsdoccomment';
export default iterateJsdoc(({
settings,
utils,
}) => {
if (settings.mode !== 'typescript') {
return;
}
/**
* @param {import('@es-joy/jsdoccomment').JsdocTagWithInline} tag
*/
const checkType = (tag) => {
const potentialType = tag.type;
let parsedType;
try {
parsedType = parseType(
/** @type {string} */ (potentialType), 'typescript',
);
} catch {
return;
}
traverse(parsedType, (nde) => {
switch (nde.type) {
case 'JsdocTypeObject': {
if (!nde.elements.length) {
utils.reportJSDoc('No empty object type.', tag);
}
}
}
});
};
const tags = utils.filterTags(({
tag,
}) => {
return Boolean(tag !== 'import' && utils.tagMightHaveTypePosition(tag));
});
for (const tag of tags) {
if (tag.type) {
checkType(tag);
}
}
}, {
iterateAllJsdocs: true,
meta: {
docs: {
description: 'Warns against use of the empty object type',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/ts-no-empty-object-type.md#repos-sticky-header',
},
schema: [],
type: 'suggestion',
},
});

View file

@ -0,0 +1,130 @@
import iterateJsdoc from '../iterateJsdoc.js';
import {
rewireByParsedType,
} from '../jsdocUtils.js';
import {
parse as parseType,
traverse,
} from '@es-joy/jsdoccomment';
export default iterateJsdoc(({
context,
indent,
jsdoc,
settings,
utils,
}) => {
if (settings.mode !== 'typescript') {
return;
}
const {
enableFixer = true,
} = context.options[0] ?? {};
/**
* @param {import('@es-joy/jsdoccomment').JsdocTagWithInline} tag
*/
const checkType = (tag) => {
const potentialType = tag.type;
/** @type {import('jsdoc-type-pratt-parser').RootResult} */
let parsedType;
try {
parsedType = parseType(
/** @type {string} */ (potentialType), 'typescript',
);
} catch {
return;
}
traverse(parsedType, (nde, parentNode, property, index) => {
switch (nde.type) {
case 'JsdocTypeTemplateLiteral': {
const stringInterpolationIndex = nde.interpolations.findIndex((interpolation) => {
return interpolation.type === 'JsdocTypeStringValue';
});
if (stringInterpolationIndex > -1) {
utils.reportJSDoc(
'Found an unnecessary string literal within a template.',
tag,
enableFixer ? () => {
nde.literals.splice(
stringInterpolationIndex,
2,
nde.literals[stringInterpolationIndex] +
/** @type {import('jsdoc-type-pratt-parser').StringValueResult} */
(nde.interpolations[stringInterpolationIndex]).value +
nde.literals[stringInterpolationIndex + 1],
);
nde.interpolations.splice(
stringInterpolationIndex, 1,
);
rewireByParsedType(jsdoc, tag, parsedType, indent);
} : null,
);
} else if (nde.literals.length === 2 && nde.literals[0] === '' &&
nde.literals[1] === ''
) {
utils.reportJSDoc(
'Found a lone template expression within a template.',
tag,
enableFixer ? () => {
const interpolation = nde.interpolations[0];
if (parentNode && property) {
if (typeof index === 'number') {
// @ts-expect-error Safe
parentNode[property][index] = interpolation;
} else {
// @ts-expect-error Safe
parentNode[property] = interpolation;
}
} else {
parsedType = interpolation;
}
rewireByParsedType(jsdoc, tag, parsedType, indent);
} : null,
);
}
}
}
});
};
const tags = utils.filterTags(({
tag,
}) => {
return Boolean(tag !== 'import' && utils.tagMightHaveTypePosition(tag));
});
for (const tag of tags) {
if (tag.type) {
checkType(tag);
}
}
}, {
iterateAllJsdocs: true,
meta: {
docs: {
description: 'Catches unnecessary template expressions such as string expressions within a template literal.',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/ts-no-unnecessary-template-expression.md#repos-sticky-header',
},
fixable: 'code',
schema: [
{
additionalProperties: false,
properties: {
enableFixer: {
description: 'Whether to enable the fixer. Defaults to `true`.',
type: 'boolean',
},
},
type: 'object',
},
],
type: 'suggestion',
},
});

View file

@ -0,0 +1,127 @@
import iterateJsdoc from '../iterateJsdoc.js';
import {
rewireByParsedType,
} from '../jsdocUtils.js';
import {
parse as parseType,
traverse,
} from '@es-joy/jsdoccomment';
export default iterateJsdoc(({
context,
indent,
jsdoc,
utils,
}) => {
const {
enableFixer = true,
} = context.options[0] || {};
/**
* @param {import('@es-joy/jsdoccomment').JsdocTagWithInline} tag
*/
const checkType = (tag) => {
const potentialType = tag.type;
/** @type {import('jsdoc-type-pratt-parser').RootResult} */
let parsedType;
try {
parsedType = parseType(
/** @type {string} */ (potentialType), 'typescript',
);
} catch {
return;
}
traverse(parsedType, (nde, parentNode) => {
// @ts-expect-error Adding our own property for use below
nde.parentNode = parentNode;
});
traverse(parsedType, (nde, parentNode, property, index) => {
switch (nde.type) {
case 'JsdocTypeCallSignature': {
const object = /** @type {import('jsdoc-type-pratt-parser').ObjectResult} */ (
parentNode
);
if (typeof index === 'number' && object.elements.length === 1) {
utils.reportJSDoc(
'Call signature found; function type preferred.',
tag,
enableFixer ? () => {
const func = /** @type {import('jsdoc-type-pratt-parser').FunctionResult} */ ({
arrow: true,
constructor: false,
meta: /** @type {Required<import('jsdoc-type-pratt-parser').MethodSignatureResult['meta']>} */ (
nde.meta
),
parameters: nde.parameters,
parenthesis: true,
returnType: nde.returnType,
type: 'JsdocTypeFunction',
typeParameters: nde.typeParameters,
});
if (property && 'parentNode' in object && object.parentNode) {
if (typeof object.parentNode === 'object' &&
'elements' in object.parentNode &&
Array.isArray(object.parentNode.elements)
) {
const idx = object.parentNode.elements.indexOf(object);
object.parentNode.elements[idx] = func;
/* c8 ignore next 6 -- Guard */
} else {
throw new Error(
// @ts-expect-error Ok
`Rule currently unable to handle type ${object.parentNode.type}`,
);
}
} else {
parsedType = func;
}
rewireByParsedType(jsdoc, tag, parsedType, indent);
} : null,
);
}
break;
}
}
});
};
const tags = utils.filterTags(({
tag,
}) => {
return Boolean(tag !== 'import' && utils.tagMightHaveTypePosition(tag));
});
for (const tag of tags) {
if (tag.type) {
checkType(tag);
}
}
}, {
iterateAllJsdocs: true,
meta: {
docs: {
description: 'Prefers function types over call signatures when there are no other properties.',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/ts-prefer-function-type.md#repos-sticky-header',
},
fixable: 'code',
schema: [
{
additionalProperties: false,
properties: {
enableFixer: {
description: 'Whether to enable the fixer or not',
type: 'boolean',
},
},
type: 'object',
},
],
type: 'suggestion',
},
});

View file

@ -0,0 +1,670 @@
import iterateJsdoc from '../iterateJsdoc.js';
import {
rewireByParsedType,
} from '../jsdocUtils.js';
import {
parse as parseType,
stringify,
traverse,
tryParse as tryParseType,
} from '@es-joy/jsdoccomment';
const digitRegex = (/^(\d+(\.\d*)?|\.\d+)([eE][\-+]?\d+)?$/v);
export default iterateJsdoc(({
context,
indent,
jsdoc,
settings,
utils,
// eslint-disable-next-line complexity -- Todo
}) => {
const {
arrayBrackets = 'square',
arrowFunctionPostReturnMarkerSpacing = ' ',
arrowFunctionPreReturnMarkerSpacing = ' ',
enableFixer = true,
functionOrClassParameterSpacing = ' ',
functionOrClassPostGenericSpacing = '',
functionOrClassPostReturnMarkerSpacing = ' ',
functionOrClassPreReturnMarkerSpacing = '',
functionOrClassTypeParameterSpacing = ' ',
genericAndTupleElementSpacing = ' ',
genericDot = false,
keyValuePostColonSpacing = ' ',
keyValuePostKeySpacing = '',
keyValuePostOptionalSpacing = '',
keyValuePostVariadicSpacing = '',
methodQuotes = 'double',
objectFieldIndent = '',
objectFieldQuote = null,
objectFieldSeparator = 'comma',
objectFieldSeparatorOptionalLinebreak = true,
objectFieldSeparatorTrailingPunctuation = false,
parameterDefaultValueSpacing = ' ',
postMethodNameSpacing = '',
postNewSpacing = ' ',
// propertyQuotes = null,
separatorForSingleObjectField = false,
stringQuotes = 'double',
typeBracketSpacing = '',
unionSpacing = ' ',
} = context.options[0] || {};
const {
mode,
} = settings;
/**
* @param {import('@es-joy/jsdoccomment').JsdocTagWithInline} tag
*/
const checkTypeFormats = (tag) => {
const potentialType = tag.type;
let parsedType;
try {
parsedType = mode === 'permissive' ?
tryParseType(/** @type {string} */ (potentialType)) :
parseType(/** @type {string} */ (potentialType), mode);
} catch {
return;
}
const fix = () => {
rewireByParsedType(jsdoc, tag, parsedType, indent, typeBracketSpacing);
};
/** @type {string[]} */
const errorMessages = [];
if (typeBracketSpacing && (!tag.type.startsWith(typeBracketSpacing) || !tag.type.endsWith(typeBracketSpacing))) {
errorMessages.push(`Must have initial and final "${typeBracketSpacing}" spacing`);
} else if (!typeBracketSpacing && ((/^\s/v).test(tag.type) || (/\s$/v).test(tag.type))) {
errorMessages.push('Must have no initial spacing');
}
// eslint-disable-next-line complexity -- Todo
traverse(parsedType, (nde) => {
let errorMessage = '';
/**
* @param {Partial<import('jsdoc-type-pratt-parser').FunctionResult['meta']> & {
* postNewSpacing?: string,
* postMethodNameSpacing?: string
* }} meta
* @returns {Required<import('jsdoc-type-pratt-parser').FunctionResult['meta']> & {
* postNewSpacing?: string,
* postMethodNameSpacing?: string
* }}
*/
const conditionalAdds = (meta) => {
const typNode =
/**
* @type {import('jsdoc-type-pratt-parser').FunctionResult|
* import('jsdoc-type-pratt-parser').CallSignatureResult|
* import('jsdoc-type-pratt-parser').ComputedMethodResult|
* import('jsdoc-type-pratt-parser').ConstructorSignatureResult|
* import('jsdoc-type-pratt-parser').MethodSignatureResult
* }
*/ (nde);
/**
* @type {Required<import('jsdoc-type-pratt-parser').FunctionResult['meta']> & {
* postNewSpacing?: string,
* postMethodNameSpacing?: string
* }}
*/
const newMeta = {
parameterSpacing: meta.parameterSpacing ?? typNode.meta?.parameterSpacing ?? ' ',
postGenericSpacing: meta.postGenericSpacing ?? typNode.meta?.postGenericSpacing ?? '',
postReturnMarkerSpacing: meta.postReturnMarkerSpacing ?? typNode.meta?.postReturnMarkerSpacing ?? ' ',
preReturnMarkerSpacing: meta.preReturnMarkerSpacing ?? typNode.meta?.preReturnMarkerSpacing ?? '',
typeParameterSpacing: meta.typeParameterSpacing ?? typNode.meta?.typeParameterSpacing ?? ' ',
};
if (typNode.type === 'JsdocTypeConstructorSignature') {
newMeta.postNewSpacing = meta.postNewSpacing;
}
if (typNode.type === 'JsdocTypeMethodSignature') {
newMeta.postMethodNameSpacing = meta.postMethodNameSpacing ?? typNode.meta?.postMethodNameSpacing ?? '';
}
return newMeta;
};
switch (nde.type) {
case 'JsdocTypeConstructorSignature': {
const typeNode = /** @type {import('jsdoc-type-pratt-parser').ConstructorSignatureResult} */ (nde);
/* c8 ignore next -- Guard */
if ((typeNode.meta?.postNewSpacing ?? ' ') !== postNewSpacing) {
typeNode.meta =
/**
* @type {Required<import('jsdoc-type-pratt-parser').FunctionResult['meta']> & {
* postNewSpacing: string,
* }}
*/ (conditionalAdds({
postNewSpacing,
}));
errorMessage = `Post-\`new\` spacing should be "${postNewSpacing}"`;
break;
}
}
case 'JsdocTypeFunction': {
const typeNode =
/**
* @type {import('jsdoc-type-pratt-parser').FunctionResult}
*/ nde;
if ('arrow' in typeNode && typeNode.arrow) {
/* c8 ignore next -- Guard */
if ((typeNode.meta?.postReturnMarkerSpacing ?? ' ') !== arrowFunctionPostReturnMarkerSpacing) {
typeNode.meta =
/**
* @type {Required<import('jsdoc-type-pratt-parser').FunctionResult['meta']> & {
* postNewSpacing: string,
* }}
*/ (conditionalAdds({
postReturnMarkerSpacing: arrowFunctionPostReturnMarkerSpacing,
/* c8 ignore next -- Guard */
preReturnMarkerSpacing: typeNode.meta?.preReturnMarkerSpacing ?? ' ',
}));
errorMessage = `Post-return-marker spacing should be "${arrowFunctionPostReturnMarkerSpacing}"`;
break;
/* c8 ignore next -- Guard */
} else if ((typeNode.meta?.preReturnMarkerSpacing ?? ' ') !== arrowFunctionPreReturnMarkerSpacing) {
typeNode.meta =
/**
* @type {Required<import('jsdoc-type-pratt-parser').FunctionResult['meta']> & {
* postNewSpacing: string,
* }}
*/ (conditionalAdds({
/* c8 ignore next -- Guard */
postReturnMarkerSpacing: typeNode.meta?.postReturnMarkerSpacing ?? ' ',
preReturnMarkerSpacing: arrowFunctionPreReturnMarkerSpacing,
}));
errorMessage = `Pre-return-marker spacing should be "${arrowFunctionPreReturnMarkerSpacing}"`;
break;
}
break;
}
}
case 'JsdocTypeCallSignature':
case 'JsdocTypeComputedMethod':
case 'JsdocTypeMethodSignature': {
const typeNode =
/**
* @type {import('jsdoc-type-pratt-parser').FunctionResult|
* import('jsdoc-type-pratt-parser').CallSignatureResult|
* import('jsdoc-type-pratt-parser').ComputedMethodResult|
* import('jsdoc-type-pratt-parser').ConstructorSignatureResult|
* import('jsdoc-type-pratt-parser').MethodSignatureResult
* }
*/ (nde);
if (typeNode.type === 'JsdocTypeMethodSignature' &&
(typeNode.meta?.postMethodNameSpacing ?? '') !== postMethodNameSpacing
) {
typeNode.meta = {
quote: typeNode.meta.quote,
...conditionalAdds({
postMethodNameSpacing,
}),
};
errorMessage = `Post-method-name spacing should be "${postMethodNameSpacing}"`;
break;
} else if (typeNode.type === 'JsdocTypeMethodSignature' &&
typeNode.meta.quote !== undefined &&
typeNode.meta.quote !== methodQuotes
) {
typeNode.meta = {
...conditionalAdds({
postMethodNameSpacing: typeNode.meta.postMethodNameSpacing ?? '',
}),
quote: methodQuotes,
};
errorMessage = `Method quoting style should be "${methodQuotes}"`;
break;
}
if ((typeNode.meta?.parameterSpacing ?? ' ') !== functionOrClassParameterSpacing) {
typeNode.meta = conditionalAdds({
parameterSpacing: functionOrClassParameterSpacing,
});
errorMessage = `Parameter spacing should be "${functionOrClassParameterSpacing}"`;
} else if ((typeNode.meta?.postGenericSpacing ?? '') !== functionOrClassPostGenericSpacing) {
typeNode.meta = conditionalAdds({
postGenericSpacing: functionOrClassPostGenericSpacing,
});
errorMessage = `Post-generic spacing should be "${functionOrClassPostGenericSpacing}"`;
} else if ((typeNode.meta?.postReturnMarkerSpacing ?? ' ') !== functionOrClassPostReturnMarkerSpacing) {
typeNode.meta = conditionalAdds({
postReturnMarkerSpacing: functionOrClassPostReturnMarkerSpacing,
});
errorMessage = `Post-return-marker spacing should be "${functionOrClassPostReturnMarkerSpacing}"`;
} else if ((typeNode.meta?.preReturnMarkerSpacing ?? '') !== functionOrClassPreReturnMarkerSpacing) {
typeNode.meta = conditionalAdds({
preReturnMarkerSpacing: functionOrClassPreReturnMarkerSpacing,
});
errorMessage = `Pre-return-marker spacing should be "${functionOrClassPreReturnMarkerSpacing}"`;
} else if ((typeNode.meta?.typeParameterSpacing ?? ' ') !== functionOrClassTypeParameterSpacing) {
typeNode.meta = conditionalAdds({
typeParameterSpacing: functionOrClassTypeParameterSpacing,
});
errorMessage = `Type parameter spacing should be "${functionOrClassTypeParameterSpacing}"`;
}
break;
}
case 'JsdocTypeGeneric': {
const typeNode = /** @type {import('jsdoc-type-pratt-parser').GenericResult} */ (nde);
if ('value' in typeNode.left && typeNode.left.value === 'Array') {
if (typeNode.meta.brackets !== arrayBrackets) {
typeNode.meta.brackets = arrayBrackets;
errorMessage = `Array bracket style should be ${arrayBrackets}`;
}
} else if (typeNode.meta.dot !== genericDot) {
typeNode.meta.dot = genericDot;
errorMessage = `Dot usage should be ${genericDot}`;
} else if ((typeNode.meta.elementSpacing ?? ' ') !== genericAndTupleElementSpacing) {
typeNode.meta.elementSpacing = genericAndTupleElementSpacing;
errorMessage = `Element spacing should be "${genericAndTupleElementSpacing}"`;
}
break;
}
case 'JsdocTypeKeyValue': {
const typeNode = /** @type {import('jsdoc-type-pratt-parser').KeyValueResult} */ (nde);
/* c8 ignore next -- Guard */
if ((typeNode.meta?.postKeySpacing ?? '') !== keyValuePostKeySpacing) {
typeNode.meta = {
/* c8 ignore next -- Guard */
postColonSpacing: typeNode.meta?.postColonSpacing ?? ' ',
postKeySpacing: keyValuePostKeySpacing,
/* c8 ignore next 2 -- Guard */
postOptionalSpacing: typeNode.meta?.postOptionalSpacing ?? '',
postVariadicSpacing: typeNode.meta?.postVariadicSpacing ?? '',
};
errorMessage = `Post key spacing should be "${keyValuePostKeySpacing}"`;
/* c8 ignore next -- Guard */
} else if ((typeNode.meta?.postColonSpacing ?? ' ') !== keyValuePostColonSpacing) {
typeNode.meta = {
postColonSpacing: keyValuePostColonSpacing,
/* c8 ignore next 3 -- Guard */
postKeySpacing: typeNode.meta?.postKeySpacing ?? '',
postOptionalSpacing: typeNode.meta?.postOptionalSpacing ?? '',
postVariadicSpacing: typeNode.meta?.postVariadicSpacing ?? '',
};
errorMessage = `Post colon spacing should be "${keyValuePostColonSpacing}"`;
/* c8 ignore next -- Guard */
} else if ((typeNode.meta?.postOptionalSpacing ?? '') !== keyValuePostOptionalSpacing) {
typeNode.meta = {
/* c8 ignore next 2 -- Guard */
postColonSpacing: typeNode.meta?.postColonSpacing ?? ' ',
postKeySpacing: typeNode.meta?.postKeySpacing ?? '',
postOptionalSpacing: keyValuePostOptionalSpacing,
/* c8 ignore next -- Guard */
postVariadicSpacing: typeNode.meta?.postVariadicSpacing ?? '',
};
errorMessage = `Post optional (\`?\`) spacing should be "${keyValuePostOptionalSpacing}"`;
/* c8 ignore next -- Guard */
} else if (typeNode.variadic && (typeNode.meta?.postVariadicSpacing ?? '') !== keyValuePostVariadicSpacing) {
typeNode.meta = {
/* c8 ignore next 3 -- Guard */
postColonSpacing: typeNode.meta?.postColonSpacing ?? ' ',
postKeySpacing: typeNode.meta?.postKeySpacing ?? '',
postOptionalSpacing: typeNode.meta?.postOptionalSpacing ?? '',
postVariadicSpacing: keyValuePostVariadicSpacing,
};
errorMessage = `Post variadic (\`...\`) spacing should be "${keyValuePostVariadicSpacing}"`;
}
break;
}
case 'JsdocTypeObject': {
const typeNode = /** @type {import('jsdoc-type-pratt-parser').ObjectResult} */ (nde);
/* c8 ignore next -- Guard */
const separator = typeNode.meta.separator ?? 'comma';
if (
(separator !== objectFieldSeparator &&
(!objectFieldSeparatorOptionalLinebreak ||
!(objectFieldSeparator.endsWith('-linebreak') &&
objectFieldSeparator.startsWith(separator)))) ||
(typeNode.meta.separatorForSingleObjectField ?? false) !== separatorForSingleObjectField ||
((typeNode.meta.propertyIndent ?? '') !== objectFieldIndent &&
separator.endsWith('-linebreak')) ||
(typeNode.meta.trailingPunctuation ?? false) !== objectFieldSeparatorTrailingPunctuation
) {
typeNode.meta.separator = objectFieldSeparatorOptionalLinebreak && !separator.endsWith('and-linebreak') ?
objectFieldSeparator.replace(/-and-linebreak$/v, '') :
objectFieldSeparator;
typeNode.meta.separatorForSingleObjectField = separatorForSingleObjectField;
typeNode.meta.propertyIndent = objectFieldIndent;
typeNode.meta.trailingPunctuation = objectFieldSeparatorTrailingPunctuation;
errorMessage = `Inconsistent ${objectFieldSeparator} separator usage`;
}
break;
}
case 'JsdocTypeObjectField': {
const typeNode = /** @type {import('jsdoc-type-pratt-parser').ObjectFieldResult} */ (nde);
if ((objectFieldQuote ||
(typeof typeNode.key === 'string' &&
(
(/^[\p{ID_Start}$_][\p{ID_Continue}$\u200C\u200D]*$/v).test(typeNode.key) ||
digitRegex.test(typeNode.key)
)
)) &&
typeNode.meta.quote !== (objectFieldQuote ?? undefined) &&
(typeof typeNode.key !== 'string' ||
!digitRegex.test(typeNode.key))
) {
typeNode.meta.quote = objectFieldQuote ?? undefined;
errorMessage = `Inconsistent object field quotes ${objectFieldQuote}`;
} else if ((typeNode.meta?.postKeySpacing ?? '') !== keyValuePostKeySpacing) {
typeNode.meta.postKeySpacing = keyValuePostKeySpacing;
errorMessage = `Post key spacing should be "${keyValuePostKeySpacing}"`;
} else if ((typeNode.meta?.postColonSpacing ?? ' ') !== keyValuePostColonSpacing) {
typeNode.meta.postColonSpacing = keyValuePostColonSpacing;
errorMessage = `Post colon spacing should be "${keyValuePostColonSpacing}"`;
} else if ((typeNode.meta?.postOptionalSpacing ?? '') !== keyValuePostOptionalSpacing) {
typeNode.meta.postOptionalSpacing = keyValuePostOptionalSpacing;
errorMessage = `Post optional (\`?\`) spacing should be "${keyValuePostOptionalSpacing}"`;
}
break;
}
case 'JsdocTypeStringValue': {
const typeNode = /** @type {import('jsdoc-type-pratt-parser').StringValueResult} */ (nde);
if (typeNode.meta.quote !== stringQuotes) {
typeNode.meta.quote = stringQuotes;
errorMessage = `Inconsistent ${stringQuotes} string quotes usage`;
}
break;
}
// Only suitable for namepaths (and would need changes); see https://github.com/gajus/eslint-plugin-jsdoc/issues/1524
// case 'JsdocTypeProperty': {
// const typeNode = /** @type {import('jsdoc-type-pratt-parser').PropertyResult} */ (nde);
// if ((propertyQuotes ||
// (typeof typeNode.value === 'string' && !(/\s/v).test(typeNode.value))) &&
// typeNode.meta.quote !== (propertyQuotes ?? undefined)
// ) {
// typeNode.meta.quote = propertyQuotes ?? undefined;
// errorMessage = `Inconsistent ${propertyQuotes} property quotes usage`;
// }
// break;
// }
case 'JsdocTypeTuple': {
const typeNode = /** @type {import('jsdoc-type-pratt-parser').TupleResult} */ (nde);
/* c8 ignore next -- Guard */
if ((typeNode.meta?.elementSpacing ?? ' ') !== genericAndTupleElementSpacing) {
typeNode.meta = {
elementSpacing: genericAndTupleElementSpacing,
};
errorMessage = `Element spacing should be "${genericAndTupleElementSpacing}"`;
}
break;
}
case 'JsdocTypeTypeParameter': {
const typeNode = /** @type {import('jsdoc-type-pratt-parser').TypeParameterResult} */ (nde);
/* c8 ignore next -- Guard */
if (typeNode.defaultValue && (typeNode.meta?.defaultValueSpacing ?? ' ') !== parameterDefaultValueSpacing) {
typeNode.meta = {
defaultValueSpacing: parameterDefaultValueSpacing,
};
errorMessage = `Default value spacing should be "${parameterDefaultValueSpacing}"`;
}
break;
}
case 'JsdocTypeUnion': {
const typeNode = /** @type {import('jsdoc-type-pratt-parser').UnionResult} */ (nde);
/* c8 ignore next -- Guard */
if ((typeNode.meta?.spacing ?? ' ') !== unionSpacing) {
typeNode.meta = {
spacing: unionSpacing,
};
errorMessage = `Inconsistent "${unionSpacing}" union spacing usage`;
}
break;
}
default:
break;
}
if (errorMessage) {
errorMessages.push(errorMessage);
}
});
const differentResult = tag.type !==
typeBracketSpacing + stringify(parsedType) + typeBracketSpacing;
if (errorMessages.length && differentResult) {
for (const errorMessage of errorMessages) {
utils.reportJSDoc(
errorMessage, tag, enableFixer ? fix : null,
);
}
// Stringification may have been equal previously (and thus no error reported)
// because the stringification doesn't preserve everything
} else if (differentResult) {
utils.reportJSDoc(
'There was an error with type formatting', tag, enableFixer ? fix : null,
);
}
};
const tags = utils.getPresentTags([
'param',
'property',
'returns',
'this',
'throws',
'type',
'typedef',
'yields',
]);
for (const tag of tags) {
if (tag.type) {
checkTypeFormats(tag);
}
}
}, {
iterateAllJsdocs: true,
meta: {
docs: {
description: 'Formats JSDoc type values.',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/type-formatting.md#repos-sticky-header',
},
fixable: 'code',
schema: [
{
additionalProperties: false,
properties: {
arrayBrackets: {
description: 'Determines how array generics are represented. Set to `angle` for the style `Array<type>` or `square` for the style `type[]`. Defaults to "square".',
enum: [
'angle',
'square',
],
type: 'string',
},
arrowFunctionPostReturnMarkerSpacing: {
description: 'The space character (if any) to use after return markers (`=>`). Defaults to " ".',
type: 'string',
},
arrowFunctionPreReturnMarkerSpacing: {
description: 'The space character (if any) to use before return markers (`=>`). Defaults to " ".',
type: 'string',
},
enableFixer: {
description: 'Whether to enable the fixer. Defaults to `true`.',
type: 'boolean',
},
functionOrClassParameterSpacing: {
description: 'The space character (if any) to use between function or class parameters. Defaults to " ".',
type: 'string',
},
functionOrClassPostGenericSpacing: {
description: 'The space character (if any) to use after a generic expression in a function or class. Defaults to "".',
type: 'string',
},
functionOrClassPostReturnMarkerSpacing: {
description: 'The space character (if any) to use after return markers (`:`). Defaults to "".',
type: 'string',
},
functionOrClassPreReturnMarkerSpacing: {
description: 'The space character (if any) to use before return markers (`:`). Defaults to "".',
type: 'string',
},
functionOrClassTypeParameterSpacing: {
description: 'The space character (if any) to use between type parameters in a function or class. Defaults to " ".',
type: 'string',
},
genericAndTupleElementSpacing: {
description: 'The space character (if any) to use between elements in generics and tuples. Defaults to " ".',
type: 'string',
},
genericDot: {
description: 'Boolean value of whether to use a dot before the angled brackets of a generic (e.g., `SomeType.<AnotherType>`). Defaults to `false`.',
type: 'boolean',
},
keyValuePostColonSpacing: {
description: 'The amount of spacing (if any) after the colon of a key-value or object-field pair. Defaults to " ".',
type: 'string',
},
keyValuePostKeySpacing: {
description: 'The amount of spacing (if any) immediately after keys in a key-value or object-field pair. Defaults to "".',
type: 'string',
},
keyValuePostOptionalSpacing: {
description: 'The amount of spacing (if any) after the optional operator (`?`) in a key-value or object-field pair. Defaults to "".',
type: 'string',
},
keyValuePostVariadicSpacing: {
description: 'The amount of spacing (if any) after a variadic operator (`...`) in a key-value pair. Defaults to "".',
type: 'string',
},
methodQuotes: {
description: 'The style of quotation mark for surrounding method names when quoted. Defaults to `double`',
enum: [
'double',
'single',
],
type: 'string',
},
objectFieldIndent: {
description: `A string indicating the whitespace to be added on each line preceding an
object property-value field. Defaults to the empty string.`,
type: 'string',
},
objectFieldQuote: {
description: `Whether and how object field properties should be quoted (e.g., \`{"a": string}\`).
Set to \`single\`, \`double\`, or \`null\`. Defaults to \`null\` (no quotes unless
required due to special characters within the field). Digits will be kept as is,
regardless of setting (they can either represent a digit or a string digit).`,
enum: [
'double',
'single',
null,
],
},
objectFieldSeparator: {
description: `For object properties, specify whether a "semicolon", "comma", "linebreak",
"semicolon-and-linebreak", or "comma-and-linebreak" should be used after
each object property-value pair.
Defaults to \`"comma"\`.`,
enum: [
'comma',
'comma-and-linebreak',
'linebreak',
'semicolon',
'semicolon-and-linebreak',
],
type: 'string',
},
objectFieldSeparatorOptionalLinebreak: {
description: `Whether \`objectFieldSeparator\` set to \`"semicolon-and-linebreak"\` or
\`"comma-and-linebreak"\` should be allowed to optionally drop the linebreak.
Defaults to \`true\`.`,
type: 'boolean',
},
objectFieldSeparatorTrailingPunctuation: {
description: `If \`separatorForSingleObjectField\` is not in effect (i.e., if it is \`false\`
or there are multiple property-value object fields present), this property
will determine whether to add punctuation corresponding to the
\`objectFieldSeparator\` (e.g., a semicolon) to the final object field.
Defaults to \`false\`.`,
type: 'boolean',
},
parameterDefaultValueSpacing: {
description: 'The space character (if any) to use between the equal signs of a default value. Defaults to " ".',
type: 'string',
},
postMethodNameSpacing: {
description: 'The space character (if any) to add after a method name. Defaults to "".',
type: 'string',
},
postNewSpacing: {
description: 'The space character (if any) to add after "new" in a constructor. Defaults to " ".',
type: 'string',
},
// propertyQuotes: {
// description: `Whether and how namepath properties should be quoted (e.g., \`ab."cd"."ef"\`).
// Set to \`single\`, \`double\`, or \`null\`. Defaults to \`null\` (no quotes unless
// required due to whitespace within the property).`,
// enum: [
// 'double',
// 'single',
// null,
// ],
// },
separatorForSingleObjectField: {
description: `Whether to apply the \`objectFieldSeparator\` (e.g., a semicolon) when there
is only one property-value object field present. Defaults to \`false\`.`,
type: 'boolean',
},
stringQuotes: {
description: `How string literals should be quoted (e.g., \`"abc"\`). Set to \`single\`
or \`double\`. Defaults to 'double'.`,
enum: [
'double',
'single',
],
type: 'string',
},
typeBracketSpacing: {
description: `A string of spaces that will be added immediately after the type's initial
curly bracket and immediately before its ending curly bracket. Defaults
to the empty string.`,
type: 'string',
},
unionSpacing: {
description: 'Determines the spacing to add to unions (`|`). Defaults to a single space (`" "`).',
type: 'string',
},
},
type: 'object',
},
],
type: 'suggestion',
},
});

View file

@ -0,0 +1,449 @@
import iterateJsdoc from '../iterateJsdoc.js';
import {
parse,
parseName,
parseNamePath,
traverse,
tryParse,
} from '@es-joy/jsdoccomment';
const inlineTags = new Set([
'link', 'linkcode', 'linkplain',
'tutorial',
]);
const asExpression = /as\s+/v;
const suppressTypes = new Set([
// https://github.com/google/closure-compiler/wiki/@suppress-annotations
// https://github.com/google/closure-compiler/blob/master/src/com/google/javascript/jscomp/parsing/ParserConfig.properties#L154
'accessControls',
'checkDebuggerStatement',
'checkEs5InheritanceCorrectnessConditions',
'checkPrototypalTypes',
'checkRegExp',
'checkTypes',
'checkVars',
'closureClassChecks',
'closureDepMethodUsageChecks',
'const',
'constantProperty',
'dangerousUnrecognizedTypeError',
'deprecated',
'duplicate',
'es5Strict',
'externsValidation',
'extraProvide',
'extraRequire',
'globalThis',
'invalidCasts',
'lateProvide',
'legacyGoogScopeRequire',
'lintChecks',
'lintVarDeclarations',
'messageConventions',
'misplacedTypeAnnotation',
'missingOverride',
'missingPolyfill',
'missingProperties',
'missingProvide',
'missingRequire',
'missingReturn',
'missingSourcesWarnings',
'moduleLoad',
'msgDescriptions',
'nonStandardJsDocs',
'partialAlias',
'polymer',
'reportUnknownTypes',
'strictCheckTypes',
'strictMissingProperties',
'strictModuleDepCheck',
'strictPrimitiveOperators',
'suspiciousCode',
// Not documented in enum
'switch',
'transitionalSuspiciousCodeWarnings',
'undefinedNames',
'undefinedVars',
'underscore',
'unknownDefines',
'untranspilableFeatures',
'unusedLocalVariables',
// Not documented?
'unusedPrivateMembers',
'uselessCode',
'useOfGoogProvide',
'visibility',
'with',
]);
/**
* @param {string} path
* @param {import('jsdoc-type-pratt-parser').ParseMode|"permissive"} mode
* @returns {boolean}
*/
const tryParsePathIgnoreError = (path, mode) => {
try {
parseNamePath(path, mode === 'permissive' ? 'jsdoc' : mode, {
includeSpecial: true,
});
return true;
} catch {
// Keep the original error for including the whole type
}
return false;
};
/**
* @param {string} name
* @param {import('jsdoc-type-pratt-parser').ParseMode} mode
* @returns {boolean}
*/
const tryParseNameIgnoreError = (name, mode) => {
try {
parseName(name, mode);
return true;
} catch {
// Keep the original error for including the whole type
}
return false;
};
export default iterateJsdoc(({
context,
jsdoc,
report,
settings,
utils,
// eslint-disable-next-line complexity
}) => {
const {
allowEmptyNamepaths = false,
} = context.options[0] || {};
const {
mode,
} = settings;
for (const tag of jsdoc.tags) {
/**
* @param {string} namepath
* @param {string} [tagName]
* @returns {boolean}
*/
const validNamepathParsing = function (namepath, tagName) {
if (
tryParsePathIgnoreError(namepath, mode)
) {
return true;
}
let handled = false;
if (tagName) {
switch (tagName) {
case 'memberof':
case 'memberof!': {
const endChar = namepath.slice(-1);
if ([
'#', '.', '~',
].includes(endChar)) {
handled = tryParsePathIgnoreError(namepath.slice(0, -1), mode);
}
break;
}
case 'module': case 'requires': {
if (!namepath.startsWith('module:')) {
handled = tryParsePathIgnoreError(`module:${namepath}`, mode);
}
break;
}
case 'borrows': {
const startChar = namepath.charAt(0);
if ([
'#', '.', '~',
].includes(startChar)) {
handled = tryParsePathIgnoreError(namepath.slice(1), mode);
}
}
}
}
if (!handled) {
report(`Syntax error in namepath: ${namepath}`, null, tag);
return false;
}
return true;
};
/**
* @param {string} type
* @returns {boolean}
*/
const validTypeParsing = function (type) {
let parsedTypes;
try {
if (mode === 'permissive') {
parsedTypes = tryParse(type, undefined, {
classContext: true,
});
} else {
parsedTypes = parse(type, mode, {
classContext: true,
});
}
} catch {
report(`Syntax error in type: ${type}`, null, tag);
return false;
}
if (mode === 'closure' || mode === 'typescript') {
traverse(parsedTypes, (node) => {
const {
type: typ,
} = node;
if (
(typ === 'JsdocTypeObjectField' || typ === 'JsdocTypeKeyValue') &&
node.right?.type === 'JsdocTypeNullable' &&
node.right?.meta?.position === 'suffix'
) {
report(`Syntax error in type: ${node.right.type}`, null, tag);
}
});
}
return true;
};
if (tag.problems.length) {
const msg = tag.problems.reduce((str, {
message,
}) => {
return str + '; ' + message;
}, '').slice(2);
report(`Invalid name: ${msg}`, null, tag);
continue;
}
if (tag.tag === 'import') {
// A named import will look like a type, but not be valid; we also don't
// need to check the name/namepath
continue;
}
if (tag.tag === 'borrows') {
const thisNamepath = /** @type {string} */ (
utils.getTagDescription(tag)
).replace(asExpression, '')
.trim();
if (!asExpression.test(/** @type {string} */ (
utils.getTagDescription(tag)
)) || !thisNamepath) {
report(`@borrows must have an "as" expression. Found "${utils.getTagDescription(tag)}"`, null, tag);
continue;
}
if (validNamepathParsing(thisNamepath, 'borrows')) {
const thatNamepath = tag.name;
validNamepathParsing(thatNamepath);
}
continue;
}
if (tag.tag === 'suppress' && mode === 'closure') {
let parsedTypes;
try {
parsedTypes = tryParse(tag.type);
} catch {
// Ignore
}
if (parsedTypes) {
traverse(parsedTypes, (node) => {
let type;
if ('value' in node && typeof node.value === 'string') {
type = node.value;
}
if (type !== undefined && !suppressTypes.has(type)) {
report(`Syntax error in suppress type: ${type}`, null, tag);
}
});
}
}
const otherModeMaps = /** @type {import('../jsdocUtils.js').ParserMode[]} */ ([
'jsdoc', 'typescript', 'closure', 'permissive',
]).filter(
(mde) => {
return mde !== mode;
},
).map((mde) => {
return utils.getTagStructureForMode(mde);
});
const tagMightHaveNamePosition = utils.tagMightHaveNamePosition(tag.tag, otherModeMaps);
if (tagMightHaveNamePosition !== true && tag.name) {
const modeInfo = tagMightHaveNamePosition === false ? '' : ` in "${mode}" mode`;
report(`@${tag.tag} should not have a name${modeInfo}.`, null, tag);
continue;
}
// Documentation like `@returns {@link SomeType}` is technically ambiguous. Specifically it
// could either be intepreted as a type `"@link SomeType"` or a description `"{@link SomeType}"`.
// However this is a good heuristic.
if (tag.type.trim().startsWith('@')) {
tag.description = `{${tag.type}} ${tag.description}`;
tag.type = '';
}
const mightHaveTypePosition = utils.tagMightHaveTypePosition(tag.tag, otherModeMaps);
if (mightHaveTypePosition !== true && tag.type) {
const modeInfo = mightHaveTypePosition === false ? '' : ` in "${mode}" mode`;
report(`@${tag.tag} should not have a bracketed type${modeInfo}.`, null, tag);
continue;
}
// REQUIRED NAME
const tagMustHaveNamePosition = utils.tagMustHaveNamePosition(tag.tag, otherModeMaps);
// Don't handle `@param` here though it does require name as handled by
// `require-param-name` (`@property` would similarly seem to require one,
// but is handled by `require-property-name`)
if (tagMustHaveNamePosition !== false && !tag.name && !allowEmptyNamepaths && ![
'arg', 'argument', 'param',
'prop', 'property',
].includes(tag.tag) &&
(tag.tag !== 'see' || !utils.getTagDescription(tag).includes('{@link'))
) {
const modeInfo = tagMustHaveNamePosition === true ? '' : ` in "${mode}" mode`;
report(`Tag @${tag.tag} must have a name/namepath${modeInfo}.`, null, tag);
continue;
}
// REQUIRED TYPE
const mustHaveTypePosition = utils.tagMustHaveTypePosition(tag.tag, otherModeMaps);
if (mustHaveTypePosition !== false && !tag.type &&
// Auto-added to settings and has own rule already, so don't duplicate
tag.tag !== 'next'
) {
const modeInfo = mustHaveTypePosition === true ? '' : ` in "${mode}" mode`;
report(`Tag @${tag.tag} must have a type${modeInfo}.`, null, tag);
continue;
}
// REQUIRED TYPE OR NAME/NAMEPATH
const tagMissingRequiredTypeOrNamepath = utils.tagMissingRequiredTypeOrNamepath(tag, otherModeMaps);
if (tagMissingRequiredTypeOrNamepath !== false && !allowEmptyNamepaths) {
const modeInfo = tagMissingRequiredTypeOrNamepath === true ? '' : ` in "${mode}" mode`;
report(`Tag @${tag.tag} must have either a type or namepath${modeInfo}.`, null, tag);
continue;
}
// VALID TYPE
const hasTypePosition = mightHaveTypePosition === true && Boolean(tag.type);
if (hasTypePosition && (tag.type !== 'const' || tag.tag !== 'type')) {
validTypeParsing(tag.type);
}
// VALID NAME/NAMEPATH
const hasNamepathPosition = (
tagMustHaveNamePosition !== false ||
utils.tagMightHaveNamepath(tag.tag)
) && Boolean(tag.name);
if (hasNamepathPosition) {
if (mode !== 'jsdoc' && tag.tag === 'template') {
if (!tryParsePathIgnoreError(
// May be an issue with the commas of
// `utils.parseClosureTemplateTag`, so first try a raw
// value; we really need a proper parser instead, however.
tag.name.trim().replace(/^\[?(?<name>.*?)=.*$/v, '$<name>'),
mode,
)) {
for (const namepath of utils.parseClosureTemplateTag(tag)) {
validNamepathParsing(namepath);
}
}
} else {
validNamepathParsing(tag.name, tag.tag);
}
}
const hasNamePosition = utils.tagMightHaveName(tag.tag) &&
Boolean(tag.name);
if (
hasNamePosition &&
mode === 'typescript' &&
!tryParseNameIgnoreError(tag.name, mode)
) {
report(`Syntax error in name: ${tag.name}`, null, tag);
} else if (hasNamePosition && mode !== 'typescript') {
validNamepathParsing(tag.name, tag.tag);
}
for (const inlineTag of tag.inlineTags) {
if (inlineTags.has(inlineTag.tag) && !inlineTag.text && !inlineTag.namepathOrURL) {
report(`Inline tag "${inlineTag.tag}" missing content`, null, tag);
}
}
}
for (const inlineTag of jsdoc.inlineTags) {
if (inlineTags.has(inlineTag.tag) && !inlineTag.text && !inlineTag.namepathOrURL) {
report(`Inline tag "${inlineTag.tag}" missing content`);
}
}
}, {
iterateAllJsdocs: true,
meta: {
docs: {
description: 'Requires all types/namepaths to be valid JSDoc, Closure compiler, or TypeScript types (configurable in settings).',
url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/valid-types.md#repos-sticky-header',
},
schema: [
{
additionalProperties: false,
properties: {
allowEmptyNamepaths: {
default: false,
description: `Set to \`false\` to bulk disallow
empty name paths with namepath groups 2 and 4 (these might often be
expected to have an accompanying name path, though they have some
indicative value without one; these may also allow names to be defined
in another manner elsewhere in the block); you can use
\`settings.jsdoc.structuredTags\` with the \`required\` key set to "name" if you
wish to require name paths on a tag-by-tag basis. Defaults to \`true\`.`,
type: 'boolean',
},
},
type: 'object',
},
],
type: 'suggestion',
},
});

View file

@ -0,0 +1,241 @@
/**
* @typedef {{
* [key: string]: string[]
* }} AliasedTags
*/
/**
* @type {AliasedTags}
*/
const jsdocTagsUndocumented = {
// Undocumented but present; see
// https://github.com/jsdoc/jsdoc/issues/1283#issuecomment-516816802
// https://github.com/jsdoc/jsdoc/blob/master/packages/jsdoc/lib/jsdoc/tag/dictionary/definitions.js#L594
modifies: [],
};
/**
* @type {AliasedTags}
*/
const jsdocTags = {
...jsdocTagsUndocumented,
abstract: [
'virtual',
],
access: [],
alias: [],
async: [],
augments: [
'extends',
],
author: [],
borrows: [],
callback: [],
class: [
'constructor',
],
classdesc: [],
constant: [
'const',
],
constructs: [],
copyright: [],
default: [
'defaultvalue',
],
deprecated: [],
description: [
'desc',
],
enum: [],
event: [],
example: [],
exports: [],
external: [
'host',
],
file: [
'fileoverview',
'overview',
],
fires: [
'emits',
],
function: [
'func',
'method',
],
generator: [],
global: [],
hideconstructor: [],
ignore: [],
implements: [],
inheritdoc: [],
// Allowing casing distinct from jsdoc `definitions.js` (required in Closure)
inheritDoc: [],
inner: [],
instance: [],
interface: [],
kind: [],
lends: [],
license: [],
listens: [],
member: [
'var',
],
memberof: [],
'memberof!': [],
mixes: [],
mixin: [],
module: [],
name: [],
namespace: [],
override: [],
package: [],
param: [
'arg',
'argument',
],
private: [],
property: [
'prop',
],
protected: [],
public: [],
readonly: [],
requires: [],
returns: [
'return',
],
see: [],
since: [],
static: [],
summary: [],
this: [],
throws: [
'exception',
],
todo: [],
tutorial: [],
type: [],
typedef: [],
variation: [],
version: [],
yields: [
'yield',
],
};
/**
* @type {AliasedTags}
*/
const typeScriptTags = {
...jsdocTags,
// https://github.com/microsoft/TypeScript/issues/22160
// https://devblogs.microsoft.com/typescript/announcing-typescript-5-5/#the-jsdoc-import-tag
import: [],
// https://www.typescriptlang.org/tsconfig/#stripInternal
internal: [],
// https://devblogs.microsoft.com/typescript/announcing-typescript-5-0/#overload-support-in-jsdoc
overload: [],
// https://devblogs.microsoft.com/typescript/announcing-typescript-5-0/#satisfies-support-in-jsdoc
satisfies: [],
// `@template` is also in TypeScript per:
// https://www.typescriptlang.org/docs/handbook/jsdoc-supported-types.html#template
template: [
// Alias as per https://typedoc.org/documents/Tags._typeParam.html
'typeParam',
],
};
/**
* @type {AliasedTags}
*/
const undocumentedClosureTags = {
// These are in Closure source but not in jsdoc source nor in the Closure
// docs: https://github.com/google/closure-compiler/blob/master/src/com/google/javascript/jscomp/parsing/Annotation.java
closurePrimitive: [],
customElement: [],
expose: [],
hidden: [],
idGenerator: [],
meaning: [],
mixinClass: [],
mixinFunction: [],
ngInject: [],
owner: [],
typeSummary: [],
wizaction: [],
};
const {
/* eslint-disable no-unused-vars */
inheritdoc,
internal,
overload,
// Will be inverted to prefer `return`
returns,
satisfies,
/* eslint-enable no-unused-vars */
...typeScriptTagsInClosure
} = typeScriptTags;
/**
* @type {AliasedTags}
*/
const closureTags = {
...typeScriptTagsInClosure,
...undocumentedClosureTags,
// From https://github.com/google/closure-compiler/wiki/Annotating-JavaScript-for-the-Closure-Compiler
// These are all recognized in https://github.com/jsdoc/jsdoc/blob/master/packages/jsdoc/lib/jsdoc/tag/dictionary/definitions.js
// except for the experimental `noinline` and the casing differences noted below
// Defined as a synonym of `const` in jsdoc `definitions.js`
define: [],
dict: [],
export: [],
externs: [],
final: [],
// With casing distinct from jsdoc `definitions.js`
implicitCast: [],
noalias: [],
nocollapse: [],
nocompile: [],
noinline: [],
nosideeffects: [],
polymer: [],
polymerBehavior: [],
preserve: [],
// Defined as a synonym of `interface` in jsdoc `definitions.js`
record: [],
return: [
'returns',
],
struct: [],
suppress: [],
unrestricted: [],
};
export {
closureTags,
jsdocTags,
typeScriptTags,
};

View file

@ -0,0 +1,572 @@
/**
* @typedef {import('estree').Node|
* import('@typescript-eslint/types').TSESTree.Node} ESTreeOrTypeScriptNode
*/
/**
* Checks if a node is a promise but has no resolve value or an empty value.
* An `undefined` resolve does not count.
* @param {ESTreeOrTypeScriptNode|undefined|null} node
* @returns {boolean|undefined|null}
*/
const isNewPromiseExpression = (node) => {
return node && node.type === 'NewExpression' && node.callee.type === 'Identifier' &&
node.callee.name === 'Promise';
};
/**
* @param {ESTreeOrTypeScriptNode|null|undefined} node
* @returns {boolean}
*/
const isVoidPromise = (node) => {
return /** @type {import('@typescript-eslint/types').TSESTree.TSTypeReference} */ (node)?.typeArguments?.params?.[0]?.type === 'TSVoidKeyword'
/* c8 ignore next 5 */
// eslint-disable-next-line @stylistic/operator-linebreak -- c8
|| /** @type {import('@typescript-eslint/types').TSESTree.TSTypeReference} */ (
node
// @ts-expect-error Ok
)?.typeParameters?.params?.[0]?.type === 'TSVoidKeyword';
};
const undefinedKeywords = new Set([
'TSNeverKeyword', 'TSUndefinedKeyword', 'TSVoidKeyword',
]);
/**
* Checks if a node has a return statement. Void return does not count.
* @param {ESTreeOrTypeScriptNode|undefined|null} node
* @param {boolean} [throwOnNullReturn]
* @param {PromiseFilter} [promFilter]
* @returns {boolean|undefined}
*/
// eslint-disable-next-line complexity
const hasReturnValue = (node, throwOnNullReturn, promFilter) => {
if (!node) {
return false;
}
switch (node.type) {
case 'ArrowFunctionExpression':
case 'FunctionDeclaration':
case 'FunctionExpression': {
return 'expression' in node && node.expression && (!isNewPromiseExpression(
node.body,
) || !isVoidPromise(node.body)) ||
hasReturnValue(node.body, throwOnNullReturn, promFilter);
}
case 'BlockStatement': {
return node.body.some((bodyNode) => {
return bodyNode.type !== 'FunctionDeclaration' && hasReturnValue(bodyNode, throwOnNullReturn, promFilter);
});
}
case 'DoWhileStatement':
case 'ForInStatement':
case 'ForOfStatement':
case 'ForStatement':
case 'LabeledStatement':
case 'WhileStatement':
case 'WithStatement': {
return hasReturnValue(node.body, throwOnNullReturn, promFilter);
}
case 'IfStatement': {
return hasReturnValue(node.consequent, throwOnNullReturn, promFilter) ||
hasReturnValue(node.alternate, throwOnNullReturn, promFilter);
}
case 'MethodDefinition':
return hasReturnValue(node.value, throwOnNullReturn, promFilter);
case 'ReturnStatement': {
// void return does not count.
if (node.argument === null) {
if (throwOnNullReturn) {
throw new Error('Null return');
}
return false;
}
if (promFilter && isNewPromiseExpression(node.argument)) {
// Let caller decide how to filter, but this is, at the least,
// a return of sorts and truthy
return promFilter(node.argument);
}
return true;
}
case 'SwitchStatement': {
return node.cases.some(
(someCase) => {
return someCase.consequent.some((nde) => {
return hasReturnValue(nde, throwOnNullReturn, promFilter);
});
},
);
}
case 'TryStatement': {
return hasReturnValue(node.block, throwOnNullReturn, promFilter) ||
hasReturnValue(node.handler && node.handler.body, throwOnNullReturn, promFilter) ||
hasReturnValue(node.finalizer, throwOnNullReturn, promFilter);
}
case 'TSDeclareFunction':
case 'TSFunctionType':
case 'TSMethodSignature': {
const type = node?.returnType?.typeAnnotation?.type;
return type && !undefinedKeywords.has(type);
}
default: {
return false;
}
}
};
/**
* Checks if a node has a return statement. Void return does not count.
* @param {ESTreeOrTypeScriptNode|null|undefined} node
* @param {PromiseFilter} promFilter
* @returns {undefined|boolean|ESTreeOrTypeScriptNode}
*/
// eslint-disable-next-line complexity
const allBrancheshaveReturnValues = (node, promFilter) => {
if (!node) {
return false;
}
switch (node.type) {
// case 'MethodDefinition':
// return allBrancheshaveReturnValues(node.value, promFilter);
case 'ArrowFunctionExpression':
case 'FunctionDeclaration':
case 'FunctionExpression': {
return 'expression' in node && node.expression && (!isNewPromiseExpression(node.body) || !isVoidPromise(node.body)) ||
allBrancheshaveReturnValues(node.body, promFilter) ||
/** @type {import('@typescript-eslint/types').TSESTree.BlockStatement} */
(node.body).body.some((nde) => {
return nde.type === 'ReturnStatement';
});
}
case 'BlockStatement': {
const lastBodyNode = node.body.slice(-1)[0];
return allBrancheshaveReturnValues(lastBodyNode, promFilter);
}
case 'DoWhileStatement':
case 'WhileStatement':
if (
/**
* @type {import('@typescript-eslint/types').TSESTree.Literal}
*/
(node.test).value === true
) {
// If this is an infinite loop, we assume only one branch
// is needed to provide a return
return hasReturnValue(node.body, false, promFilter);
}
// Fallthrough
case 'ForStatement':
if (node.test === null) {
// If this is an infinite loop, we assume only one branch
// is needed to provide a return
return hasReturnValue(node.body, false, promFilter);
}
case 'ForInStatement':
case 'ForOfStatement':
case 'LabeledStatement':
case 'WithStatement': {
return allBrancheshaveReturnValues(node.body, promFilter);
}
case 'IfStatement': {
return allBrancheshaveReturnValues(node.consequent, promFilter) &&
allBrancheshaveReturnValues(node.alternate, promFilter);
}
case 'ReturnStatement': {
// void return does not count.
if (node.argument === null) {
return false;
}
if (promFilter && isNewPromiseExpression(node.argument)) {
// Let caller decide how to filter, but this is, at the least,
// a return of sorts and truthy
return promFilter(node.argument);
}
return true;
}
case 'SwitchStatement': {
return /** @type {import('@typescript-eslint/types').TSESTree.SwitchStatement} */ (node).cases.every(
(someCase) => {
return !someCase.consequent.some((consNode) => {
return consNode.type === 'BreakStatement' ||
consNode.type === 'ReturnStatement' && consNode.argument === null;
});
},
);
}
case 'ThrowStatement': {
return true;
}
case 'TryStatement': {
// If `finally` returns, all return
return node.finalizer && allBrancheshaveReturnValues(node.finalizer, promFilter) ||
// Return in `try`/`catch` may still occur despite `finally`
allBrancheshaveReturnValues(node.block, promFilter) &&
(!node.handler ||
allBrancheshaveReturnValues(node.handler && node.handler.body, promFilter)) &&
(!node.finalizer || (() => {
try {
hasReturnValue(node.finalizer, true, promFilter);
} catch (error) {
if (/** @type {Error} */ (error).message === 'Null return') {
return false;
}
/* c8 ignore next 3 */
// eslint-disable-next-line @stylistic/padding-line-between-statements -- c8
throw error;
}
// As long as not an explicit empty return, then return true
return true;
})());
}
case 'TSDeclareFunction':
case 'TSFunctionType':
case 'TSMethodSignature': {
const type = node?.returnType?.typeAnnotation?.type;
return type && !undefinedKeywords.has(type);
}
default: {
return false;
}
}
};
/**
* @callback PromiseFilter
* @param {ESTreeOrTypeScriptNode|undefined} node
* @returns {boolean}
*/
/**
* Avoids further checking child nodes if a nested function shadows the
* resolver, but otherwise, if name is used (by call or passed in as an
* argument to another function), will be considered as non-empty.
*
* This could check for redeclaration of the resolver, but as such is
* unlikely, we avoid the performance cost of checking everywhere for
* (re)declarations or assignments.
* @param {import('@typescript-eslint/types').TSESTree.Node|null|undefined} node
* @param {string} resolverName
* @returns {boolean}
*/
// eslint-disable-next-line complexity
const hasNonEmptyResolverCall = (node, resolverName) => {
if (!node) {
return false;
}
// Arrow function without block
switch (node.type) {
case 'ArrayExpression':
case 'ArrayPattern':
return node.elements.some((element) => {
return hasNonEmptyResolverCall(element, resolverName);
});
case 'ArrowFunctionExpression':
case 'FunctionDeclaration':
case 'FunctionExpression': {
// Shadowing
if (/** @type {import('@typescript-eslint/types').TSESTree.Identifier} */ (
node.params[0]
)?.name === resolverName) {
return false;
}
return hasNonEmptyResolverCall(node.body, resolverName);
}
case 'AssignmentExpression':
case 'BinaryExpression':
case 'LogicalExpression': {
return hasNonEmptyResolverCall(node.left, resolverName) ||
hasNonEmptyResolverCall(node.right, resolverName);
}
case 'AssignmentPattern':
return hasNonEmptyResolverCall(node.right, resolverName);
case 'AwaitExpression':
case 'SpreadElement':
case 'UnaryExpression':
case 'YieldExpression':
return hasNonEmptyResolverCall(node.argument, resolverName);
case 'BlockStatement':
case 'ClassBody':
return node.body.some((bodyNode) => {
return hasNonEmptyResolverCall(bodyNode, resolverName);
});
/* c8 ignore next 2 -- In Babel? */
case 'CallExpression':
// @ts-expect-error Babel?
case 'OptionalCallExpression':
return /** @type {import('@typescript-eslint/types').TSESTree.Identifier} */ (
node.callee
).name === resolverName && (
// Implicit or explicit undefined
node.arguments.length > 1 || node.arguments[0] !== undefined
) ||
node.arguments.some((nde) => {
// Being passed in to another function (which might invoke it)
return nde.type === 'Identifier' && nde.name === resolverName ||
// Handle nested items
hasNonEmptyResolverCall(nde, resolverName);
});
case 'ChainExpression':
case 'Decorator':
case 'ExpressionStatement':
return hasNonEmptyResolverCall(node.expression, resolverName);
case 'ClassDeclaration':
case 'ClassExpression':
return hasNonEmptyResolverCall(node.body, resolverName);
/* c8 ignore next 2 -- In Babel? */
// @ts-expect-error Babel?
case 'ClassMethod':
case 'MethodDefinition':
return node.decorators && node.decorators.some((decorator) => {
return hasNonEmptyResolverCall(decorator, resolverName);
}) ||
node.computed && hasNonEmptyResolverCall(node.key, resolverName) ||
hasNonEmptyResolverCall(node.value, resolverName);
/* c8 ignore next 2 -- In Babel? */
// @ts-expect-error Babel?
case 'ClassProperty':
/* c8 ignore next 2 -- In Babel? */
// @ts-expect-error Babel?
case 'ObjectProperty':
case 'Property':
case 'PropertyDefinition':
return node.computed && hasNonEmptyResolverCall(node.key, resolverName) ||
hasNonEmptyResolverCall(node.value, resolverName);
case 'ConditionalExpression':
case 'IfStatement': {
return hasNonEmptyResolverCall(node.test, resolverName) ||
hasNonEmptyResolverCall(node.consequent, resolverName) ||
hasNonEmptyResolverCall(node.alternate, resolverName);
}
case 'DoWhileStatement':
case 'ForInStatement':
case 'ForOfStatement':
case 'ForStatement':
case 'LabeledStatement':
case 'WhileStatement':
case 'WithStatement': {
return hasNonEmptyResolverCall(node.body, resolverName);
}
/* c8 ignore next 2 -- In Babel? */
// @ts-expect-error Babel?
case 'Import':
case 'ImportExpression':
return hasNonEmptyResolverCall(node.source, resolverName);
// ?.
/* c8 ignore next 2 -- In Babel? */
case 'MemberExpression':
// @ts-expect-error Babel?
case 'OptionalMemberExpression':
return hasNonEmptyResolverCall(node.object, resolverName) ||
hasNonEmptyResolverCall(node.property, resolverName);
case 'ObjectExpression':
case 'ObjectPattern':
return node.properties.some((property) => {
return hasNonEmptyResolverCall(property, resolverName);
});
/* c8 ignore next 2 -- In Babel? */
// @ts-expect-error Babel?
case 'ObjectMethod':
/* c8 ignore next 6 -- In Babel? */
// @ts-expect-error
return node.computed && hasNonEmptyResolverCall(node.key, resolverName) ||
// @ts-expect-error
node.arguments.some((nde) => {
return hasNonEmptyResolverCall(nde, resolverName);
});
case 'ReturnStatement': {
if (node.argument === null) {
return false;
}
return hasNonEmptyResolverCall(node.argument, resolverName);
}
// Comma
case 'SequenceExpression':
case 'TemplateLiteral':
return node.expressions.some((subExpression) => {
return hasNonEmptyResolverCall(subExpression, resolverName);
});
case 'SwitchStatement': {
return node.cases.some(
(someCase) => {
return someCase.consequent.some((nde) => {
return hasNonEmptyResolverCall(nde, resolverName);
});
},
);
}
case 'TaggedTemplateExpression':
return hasNonEmptyResolverCall(node.quasi, resolverName);
case 'TryStatement': {
return hasNonEmptyResolverCall(node.block, resolverName) ||
hasNonEmptyResolverCall(node.handler && node.handler.body, resolverName) ||
hasNonEmptyResolverCall(node.finalizer, resolverName);
}
case 'VariableDeclaration': {
return node.declarations.some((nde) => {
return hasNonEmptyResolverCall(nde, resolverName);
});
}
case 'VariableDeclarator': {
return hasNonEmptyResolverCall(node.id, resolverName) ||
hasNonEmptyResolverCall(node.init, resolverName);
}
/*
// Shouldn't need to parse literals/literal components, etc.
case 'Identifier':
case 'TemplateElement':
case 'Super':
// Exports not relevant in this context
*/
default:
return false;
}
};
/**
* Checks if a Promise executor has no resolve value or an empty value.
* An `undefined` resolve does not count.
* @param {ESTreeOrTypeScriptNode} node
* @param {boolean} anyPromiseAsReturn
* @param {boolean} [allBranches]
* @returns {boolean}
*/
const hasValueOrExecutorHasNonEmptyResolveValue = (node, anyPromiseAsReturn, allBranches) => {
const hasReturnMethod = allBranches ?
/**
* @param {ESTreeOrTypeScriptNode} nde
* @param {PromiseFilter} promiseFilter
* @returns {boolean}
*/
(nde, promiseFilter) => {
let hasReturn;
try {
hasReturn = hasReturnValue(nde, true, promiseFilter);
} catch (error) {
// c8 ignore else
if (/** @type {Error} */ (error).message === 'Null return') {
return false;
}
/* c8 ignore next 3 */
// eslint-disable-next-line @stylistic/padding-line-between-statements -- c8
throw error;
}
// `hasReturn` check needed since `throw` treated as valid return by
// `allBrancheshaveReturnValues`
return Boolean(hasReturn && allBrancheshaveReturnValues(nde, promiseFilter));
} :
/**
* @param {ESTreeOrTypeScriptNode} nde
* @param {PromiseFilter} promiseFilter
* @returns {boolean}
*/
(nde, promiseFilter) => {
return Boolean(hasReturnValue(nde, false, promiseFilter));
};
return hasReturnMethod(node, (prom) => {
if (anyPromiseAsReturn) {
return true;
}
if (isVoidPromise(prom)) {
return false;
}
const {
body,
params,
} =
/**
* @type {import('@typescript-eslint/types').TSESTree.FunctionExpression|
* import('@typescript-eslint/types').TSESTree.ArrowFunctionExpression}
*/ (
/** @type {import('@typescript-eslint/types').TSESTree.NewExpression} */ (
prom
).arguments[0]
) || {};
if (!params?.length) {
return false;
}
const {
name: resolverName,
} = /** @type {import('@typescript-eslint/types').TSESTree.Identifier} */ (
params[0]
);
return hasNonEmptyResolverCall(body, resolverName);
});
};
export {
hasReturnValue,
hasValueOrExecutorHasNonEmptyResolveValue,
};