You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
515 lines
20 KiB
515 lines
20 KiB
/** |
|
* @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 |
|
}; |
|
|
|
} |
|
};
|
|
|