| /** | |
| * @fileoverview Validates JSDoc comments are syntactically correct | |
| * @author Nicholas C. Zakas | |
| */ | |
| "use strict"; | |
| //------------------------------------------------------------------------------ | |
| // Requirements | |
| //------------------------------------------------------------------------------ | |
| const doctrine = require("doctrine"); | |
| //------------------------------------------------------------------------------ | |
| // Rule Definition | |
| //------------------------------------------------------------------------------ | |
| module.exports = { | |
| meta: { | |
| type: "suggestion", | |
| docs: { | |
| description: "enforce valid JSDoc comments", | |
| category: "Possible Errors", | |
| recommended: false, | |
| url: "https://eslint.org/docs/rules/valid-jsdoc" | |
| }, | |
| schema: [ | |
| { | |
| type: "object", | |
| properties: { | |
| prefer: { | |
| type: "object", | |
| additionalProperties: { | |
| type: "string" | |
| } | |
| }, | |
| preferType: { | |
| type: "object", | |
| additionalProperties: { | |
| type: "string" | |
| } | |
| }, | |
| requireReturn: { | |
| type: "boolean", | |
| default: true | |
| }, | |
| requireParamDescription: { | |
| type: "boolean", | |
| default: true | |
| }, | |
| requireReturnDescription: { | |
| type: "boolean", | |
| default: true | |
| }, | |
| matchDescription: { | |
| type: "string" | |
| }, | |
| requireReturnType: { | |
| type: "boolean", | |
| default: true | |
| }, | |
| requireParamType: { | |
| type: "boolean", | |
| default: true | |
| } | |
| }, | |
| additionalProperties: false | |
| } | |
| ], | |
| fixable: "code", | |
| messages: { | |
| unexpectedTag: "Unexpected @{{title}} tag; function has no return statement.", | |
| expected: "Expected JSDoc for '{{name}}' but found '{{jsdocName}}'.", | |
| use: "Use @{{name}} instead.", | |
| useType: "Use '{{expectedTypeName}}' instead of '{{currentTypeName}}'.", | |
| syntaxError: "JSDoc syntax error.", | |
| missingBrace: "JSDoc type missing brace.", | |
| missingParamDesc: "Missing JSDoc parameter description for '{{name}}'.", | |
| missingParamType: "Missing JSDoc parameter type for '{{name}}'.", | |
| missingReturnType: "Missing JSDoc return type.", | |
| missingReturnDesc: "Missing JSDoc return description.", | |
| missingReturn: "Missing JSDoc @{{returns}} for function.", | |
| missingParam: "Missing JSDoc for parameter '{{name}}'.", | |
| duplicateParam: "Duplicate JSDoc parameter '{{name}}'.", | |
| unsatisfiedDesc: "JSDoc description does not satisfy the regex pattern." | |
| }, | |
| deprecated: true, | |
| replacedBy: [] | |
| }, | |
| create(context) { | |
| const options = context.options[0] || {}, | |
| prefer = options.prefer || {}, | |
| sourceCode = context.getSourceCode(), | |
| // these both default to true, so you have to explicitly make them false | |
| requireReturn = options.requireReturn !== false, | |
| requireParamDescription = options.requireParamDescription !== false, | |
| requireReturnDescription = options.requireReturnDescription !== false, | |
| requireReturnType = options.requireReturnType !== false, | |
| requireParamType = options.requireParamType !== false, | |
| preferType = options.preferType || {}, | |
| checkPreferType = Object.keys(preferType).length !== 0; | |
| //-------------------------------------------------------------------------- | |
| // Helpers | |
| //-------------------------------------------------------------------------- | |
| // Using a stack to store if a function returns or not (handling nested functions) | |
| const fns = []; | |
| /** | |
| * Check if node type is a Class | |
| * @param {ASTNode} node node to check. | |
| * @returns {boolean} True is its a class | |
| * @private | |
| */ | |
| function isTypeClass(node) { | |
| return node.type === "ClassExpression" || node.type === "ClassDeclaration"; | |
| } | |
| /** | |
| * When parsing a new function, store it in our function stack. | |
| * @param {ASTNode} node A function node to check. | |
| * @returns {void} | |
| * @private | |
| */ | |
| function startFunction(node) { | |
| fns.push({ | |
| returnPresent: (node.type === "ArrowFunctionExpression" && node.body.type !== "BlockStatement") || | |
| isTypeClass(node) || node.async | |
| }); | |
| } | |
| /** | |
| * Indicate that return has been found in the current function. | |
| * @param {ASTNode} node The return node. | |
| * @returns {void} | |
| * @private | |
| */ | |
| function addReturn(node) { | |
| const functionState = fns[fns.length - 1]; | |
| if (functionState && node.argument !== null) { | |
| functionState.returnPresent = true; | |
| } | |
| } | |
| /** | |
| * Check if return tag type is void or undefined | |
| * @param {Object} tag JSDoc tag | |
| * @returns {boolean} True if its of type void or undefined | |
| * @private | |
| */ | |
| function isValidReturnType(tag) { | |
| return tag.type === null || tag.type.name === "void" || tag.type.type === "UndefinedLiteral"; | |
| } | |
| /** | |
| * Check if type should be validated based on some exceptions | |
| * @param {Object} type JSDoc tag | |
| * @returns {boolean} True if it can be validated | |
| * @private | |
| */ | |
| function canTypeBeValidated(type) { | |
| return type !== "UndefinedLiteral" && // {undefined} as there is no name property available. | |
| type !== "NullLiteral" && // {null} | |
| type !== "NullableLiteral" && // {?} | |
| type !== "FunctionType" && // {function(a)} | |
| type !== "AllLiteral"; // {*} | |
| } | |
| /** | |
| * Extract the current and expected type based on the input type object | |
| * @param {Object} type JSDoc tag | |
| * @returns {{currentType: Doctrine.Type, expectedTypeName: string}} The current type annotation and | |
| * the expected name of the annotation | |
| * @private | |
| */ | |
| function getCurrentExpectedTypes(type) { | |
| let currentType; | |
| if (type.name) { | |
| currentType = type; | |
| } else if (type.expression) { | |
| currentType = type.expression; | |
| } | |
| return { | |
| currentType, | |
| expectedTypeName: currentType && preferType[currentType.name] | |
| }; | |
| } | |
| /** | |
| * Gets the location of a JSDoc node in a file | |
| * @param {Token} jsdocComment The comment that this node is parsed from | |
| * @param {{range: number[]}} parsedJsdocNode A tag or other node which was parsed from this comment | |
| * @returns {{start: SourceLocation, end: SourceLocation}} The 0-based source location for the tag | |
| */ | |
| function getAbsoluteRange(jsdocComment, parsedJsdocNode) { | |
| return { | |
| start: sourceCode.getLocFromIndex(jsdocComment.range[0] + 2 + parsedJsdocNode.range[0]), | |
| end: sourceCode.getLocFromIndex(jsdocComment.range[0] + 2 + parsedJsdocNode.range[1]) | |
| }; | |
| } | |
| /** | |
| * Validate type for a given JSDoc node | |
| * @param {Object} jsdocNode JSDoc node | |
| * @param {Object} type JSDoc tag | |
| * @returns {void} | |
| * @private | |
| */ | |
| function validateType(jsdocNode, type) { | |
| if (!type || !canTypeBeValidated(type.type)) { | |
| return; | |
| } | |
| const typesToCheck = []; | |
| let elements = []; | |
| switch (type.type) { | |
| case "TypeApplication": // {Array.<String>} | |
| elements = type.applications[0].type === "UnionType" ? type.applications[0].elements : type.applications; | |
| typesToCheck.push(getCurrentExpectedTypes(type)); | |
| break; | |
| case "RecordType": // {{20:String}} | |
| elements = type.fields; | |
| break; | |
| case "UnionType": // {String|number|Test} | |
| case "ArrayType": // {[String, number, Test]} | |
| elements = type.elements; | |
| break; | |
| case "FieldType": // Array.<{count: number, votes: number}> | |
| if (type.value) { | |
| typesToCheck.push(getCurrentExpectedTypes(type.value)); | |
| } | |
| break; | |
| default: | |
| typesToCheck.push(getCurrentExpectedTypes(type)); | |
| } | |
| elements.forEach(validateType.bind(null, jsdocNode)); | |
| typesToCheck.forEach(typeToCheck => { | |
| if (typeToCheck.expectedTypeName && | |
| typeToCheck.expectedTypeName !== typeToCheck.currentType.name) { | |
| context.report({ | |
| node: jsdocNode, | |
| messageId: "useType", | |
| loc: getAbsoluteRange(jsdocNode, typeToCheck.currentType), | |
| data: { | |
| currentTypeName: typeToCheck.currentType.name, | |
| expectedTypeName: typeToCheck.expectedTypeName | |
| }, | |
| fix(fixer) { | |
| return fixer.replaceTextRange( | |
| typeToCheck.currentType.range.map(indexInComment => jsdocNode.range[0] + 2 + indexInComment), | |
| typeToCheck.expectedTypeName | |
| ); | |
| } | |
| }); | |
| } | |
| }); | |
| } | |
| /** | |
| * Validate the JSDoc node and output warnings if anything is wrong. | |
| * @param {ASTNode} node The AST node to check. | |
| * @returns {void} | |
| * @private | |
| */ | |
| function checkJSDoc(node) { | |
| const jsdocNode = sourceCode.getJSDocComment(node), | |
| functionData = fns.pop(), | |
| paramTagsByName = Object.create(null), | |
| paramTags = []; | |
| let hasReturns = false, | |
| returnsTag, | |
| hasConstructor = false, | |
| isInterface = false, | |
| isOverride = false, | |
| isAbstract = false; | |
| // make sure only to validate JSDoc comments | |
| if (jsdocNode) { | |
| let jsdoc; | |
| try { | |
| jsdoc = doctrine.parse(jsdocNode.value, { | |
| strict: true, | |
| unwrap: true, | |
| sloppy: true, | |
| range: true | |
| }); | |
| } catch (ex) { | |
| if (/braces/iu.test(ex.message)) { | |
| context.report({ node: jsdocNode, messageId: "missingBrace" }); | |
| } else { | |
| context.report({ node: jsdocNode, messageId: "syntaxError" }); | |
| } | |
| return; | |
| } | |
| jsdoc.tags.forEach(tag => { | |
| switch (tag.title.toLowerCase()) { | |
| case "param": | |
| case "arg": | |
| case "argument": | |
| paramTags.push(tag); | |
| break; | |
| case "return": | |
| case "returns": | |
| hasReturns = true; | |
| returnsTag = tag; | |
| break; | |
| case "constructor": | |
| case "class": | |
| hasConstructor = true; | |
| break; | |
| case "override": | |
| case "inheritdoc": | |
| isOverride = true; | |
| break; | |
| case "abstract": | |
| case "virtual": | |
| isAbstract = true; | |
| break; | |
| case "interface": | |
| isInterface = true; | |
| break; | |
| // no default | |
| } | |
| // check tag preferences | |
| if (Object.prototype.hasOwnProperty.call(prefer, tag.title) && tag.title !== prefer[tag.title]) { | |
| const entireTagRange = getAbsoluteRange(jsdocNode, tag); | |
| context.report({ | |
| node: jsdocNode, | |
| messageId: "use", | |
| loc: { | |
| start: entireTagRange.start, | |
| end: { | |
| line: entireTagRange.start.line, | |
| column: entireTagRange.start.column + `@${tag.title}`.length | |
| } | |
| }, | |
| data: { name: prefer[tag.title] }, | |
| fix(fixer) { | |
| return fixer.replaceTextRange( | |
| [ | |
| jsdocNode.range[0] + tag.range[0] + 3, | |
| jsdocNode.range[0] + tag.range[0] + tag.title.length + 3 | |
| ], | |
| prefer[tag.title] | |
| ); | |
| } | |
| }); | |
| } | |
| // validate the types | |
| if (checkPreferType && tag.type) { | |
| validateType(jsdocNode, tag.type); | |
| } | |
| }); | |
| paramTags.forEach(param => { | |
| if (requireParamType && !param.type) { | |
| context.report({ | |
| node: jsdocNode, | |
| messageId: "missingParamType", | |
| loc: getAbsoluteRange(jsdocNode, param), | |
| data: { name: param.name } | |
| }); | |
| } | |
| if (!param.description && requireParamDescription) { | |
| context.report({ | |
| node: jsdocNode, | |
| messageId: "missingParamDesc", | |
| loc: getAbsoluteRange(jsdocNode, param), | |
| data: { name: param.name } | |
| }); | |
| } | |
| if (paramTagsByName[param.name]) { | |
| context.report({ | |
| node: jsdocNode, | |
| messageId: "duplicateParam", | |
| loc: getAbsoluteRange(jsdocNode, param), | |
| data: { name: param.name } | |
| }); | |
| } else if (param.name.indexOf(".") === -1) { | |
| paramTagsByName[param.name] = param; | |
| } | |
| }); | |
| if (hasReturns) { | |
| if (!requireReturn && !functionData.returnPresent && (returnsTag.type === null || !isValidReturnType(returnsTag)) && !isAbstract) { | |
| context.report({ | |
| node: jsdocNode, | |
| messageId: "unexpectedTag", | |
| loc: getAbsoluteRange(jsdocNode, returnsTag), | |
| data: { | |
| title: returnsTag.title | |
| } | |
| }); | |
| } else { | |
| if (requireReturnType && !returnsTag.type) { | |
| context.report({ node: jsdocNode, messageId: "missingReturnType" }); | |
| } | |
| if (!isValidReturnType(returnsTag) && !returnsTag.description && requireReturnDescription) { | |
| context.report({ node: jsdocNode, messageId: "missingReturnDesc" }); | |
| } | |
| } | |
| } | |
| // check for functions missing @returns | |
| if (!isOverride && !hasReturns && !hasConstructor && !isInterface && | |
| node.parent.kind !== "get" && node.parent.kind !== "constructor" && | |
| node.parent.kind !== "set" && !isTypeClass(node)) { | |
| if (requireReturn || (functionData.returnPresent && !node.async)) { | |
| context.report({ | |
| node: jsdocNode, | |
| messageId: "missingReturn", | |
| data: { | |
| returns: prefer.returns || "returns" | |
| } | |
| }); | |
| } | |
| } | |
| // check the parameters | |
| const jsdocParamNames = Object.keys(paramTagsByName); | |
| if (node.params) { | |
| node.params.forEach((param, paramsIndex) => { | |
| const bindingParam = param.type === "AssignmentPattern" | |
| ? param.left | |
| : param; | |
| // TODO(nzakas): Figure out logical things to do with destructured, default, rest params | |
| if (bindingParam.type === "Identifier") { | |
| const name = bindingParam.name; | |
| if (jsdocParamNames[paramsIndex] && (name !== jsdocParamNames[paramsIndex])) { | |
| context.report({ | |
| node: jsdocNode, | |
| messageId: "expected", | |
| loc: getAbsoluteRange(jsdocNode, paramTagsByName[jsdocParamNames[paramsIndex]]), | |
| data: { | |
| name, | |
| jsdocName: jsdocParamNames[paramsIndex] | |
| } | |
| }); | |
| } else if (!paramTagsByName[name] && !isOverride) { | |
| context.report({ | |
| node: jsdocNode, | |
| messageId: "missingParam", | |
| data: { | |
| name | |
| } | |
| }); | |
| } | |
| } | |
| }); | |
| } | |
| if (options.matchDescription) { | |
| const regex = new RegExp(options.matchDescription, "u"); | |
| if (!regex.test(jsdoc.description)) { | |
| context.report({ node: jsdocNode, messageId: "unsatisfiedDesc" }); | |
| } | |
| } | |
| } | |
| } | |
| //-------------------------------------------------------------------------- | |
| // Public | |
| //-------------------------------------------------------------------------- | |
| return { | |
| ArrowFunctionExpression: startFunction, | |
| FunctionExpression: startFunction, | |
| FunctionDeclaration: startFunction, | |
| ClassExpression: startFunction, | |
| ClassDeclaration: startFunction, | |
| "ArrowFunctionExpression:exit": checkJSDoc, | |
| "FunctionExpression:exit": checkJSDoc, | |
| "FunctionDeclaration:exit": checkJSDoc, | |
| "ClassExpression:exit": checkJSDoc, | |
| "ClassDeclaration:exit": checkJSDoc, | |
| ReturnStatement: addReturn | |
| }; | |
| } | |
| }; |