UNPKG

eslint-plugin-jsdoc

Version:
407 lines (365 loc) 10.2 kB
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', ]; 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(/(?:\.|<>|\.<>|\[\])$/u, ''); }; export default iterateJsdoc(({ context, node, report, settings, sourceCode, utils, }) => { const { scopeManager, } = sourceCode; // When is this ever `null`? const globalScope = /** @type {import('eslint').Scope.Scope} */ ( scopeManager.globalScope ); const /** * @type {{ * definedTypes: string[], * disableReporting: boolean, * markVariablesAsUsed: boolean * }} */ { 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 comments = sourceCode.getAllComments() .filter((comment) => { return (/^\*\s/u).test(comment.value); }) .map((commentNode) => { return parseComment(commentNode, ''); }); const typedefDeclarations = comments .flatMap((doc) => { return doc.tags.filter(({ tag, }) => { return utils.isNamepathDefiningTag(tag); }); }) .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 jsdoc = parseComment(commentNode, ''); return jsdoc.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; }; 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; }).map(({ name, }) => { return name; /* c8 ignore next */ }) : [], ) .concat(extraTypes) .concat(typedefDeclarations) .concat(importTags) .concat(definedTypes) .concat(/** @type {string[]} */ (definedPreferredTypes)) .concat(...getValidRuntimeIdentifiers(node && ( (sourceCode.getScope && /* c8 ignore next 2 */ sourceCode.getScope(node)) || 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 tagsWithTypes = /** @type {TypeAndTagInfo[]} */ ([ ...typeTags, ...namepathReferencingTags, ...namepathOrUrlReferencingTags, // Remove types which failed to parse ].filter(Boolean)); for (const { parsedType, tag, } of tagsWithTypes) { traverse(parsedType, (nde) => { const { type, value, } = /** @type {import('jsdoc-type-pratt-parser').NameResult} */ (nde); if (type === 'JsdocTypeName') { const structuredTypes = structuredTags[tag.tag]?.type; if (!allDefinedTypes.has(value) && (!Array.isArray(structuredTypes) || !structuredTypes.includes(value)) ) { if (!disableReporting) { report(`The type '${value}' is undefined.`, null, tag); } } else if (markVariablesAsUsed && !extraTypes.includes(value)) { if (sourceCode.markVariableAsUsed) { sourceCode.markVariableAsUsed(value); /* c8 ignore next 3 */ } else { context.markVariableAsUsed(value); } } } }); } }, { iterateAllJsdocs: true, meta: { docs: { description: 'Checks that types in jsdoc comments are defined.', url: 'https://github.com/gajus/eslint-plugin-jsdoc/blob/main/docs/rules/no-undefined-types.md#repos-sticky-header', }, schema: [ { additionalProperties: false, properties: { definedTypes: { items: { type: 'string', }, type: 'array', }, disableReporting: { type: 'boolean', }, markVariablesAsUsed: { type: 'boolean', }, }, type: 'object', }, ], type: 'suggestion', }, });