| /** |
| * @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 |
| }; |
| |
| } |
| }; |