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