1/** 2 * @fileoverview Validates JSDoc comments are syntactically correct 3 * @author Nicholas C. Zakas 4 */ 5"use strict"; 6 7//------------------------------------------------------------------------------ 8// Requirements 9//------------------------------------------------------------------------------ 10 11const doctrine = require("doctrine"); 12 13//------------------------------------------------------------------------------ 14// Rule Definition 15//------------------------------------------------------------------------------ 16 17module.exports = { 18 meta: { 19 type: "suggestion", 20 21 docs: { 22 description: "enforce valid JSDoc comments", 23 category: "Possible Errors", 24 recommended: false, 25 url: "https://eslint.org/docs/rules/valid-jsdoc" 26 }, 27 28 schema: [ 29 { 30 type: "object", 31 properties: { 32 prefer: { 33 type: "object", 34 additionalProperties: { 35 type: "string" 36 } 37 }, 38 preferType: { 39 type: "object", 40 additionalProperties: { 41 type: "string" 42 } 43 }, 44 requireReturn: { 45 type: "boolean", 46 default: true 47 }, 48 requireParamDescription: { 49 type: "boolean", 50 default: true 51 }, 52 requireReturnDescription: { 53 type: "boolean", 54 default: true 55 }, 56 matchDescription: { 57 type: "string" 58 }, 59 requireReturnType: { 60 type: "boolean", 61 default: true 62 }, 63 requireParamType: { 64 type: "boolean", 65 default: true 66 } 67 }, 68 additionalProperties: false 69 } 70 ], 71 72 fixable: "code", 73 messages: { 74 unexpectedTag: "Unexpected @{{title}} tag; function has no return statement.", 75 expected: "Expected JSDoc for '{{name}}' but found '{{jsdocName}}'.", 76 use: "Use @{{name}} instead.", 77 useType: "Use '{{expectedTypeName}}' instead of '{{currentTypeName}}'.", 78 syntaxError: "JSDoc syntax error.", 79 missingBrace: "JSDoc type missing brace.", 80 missingParamDesc: "Missing JSDoc parameter description for '{{name}}'.", 81 missingParamType: "Missing JSDoc parameter type for '{{name}}'.", 82 missingReturnType: "Missing JSDoc return type.", 83 missingReturnDesc: "Missing JSDoc return description.", 84 missingReturn: "Missing JSDoc @{{returns}} for function.", 85 missingParam: "Missing JSDoc for parameter '{{name}}'.", 86 duplicateParam: "Duplicate JSDoc parameter '{{name}}'.", 87 unsatisfiedDesc: "JSDoc description does not satisfy the regex pattern." 88 }, 89 90 deprecated: true, 91 replacedBy: [] 92 }, 93 94 create(context) { 95 96 const options = context.options[0] || {}, 97 prefer = options.prefer || {}, 98 sourceCode = context.getSourceCode(), 99 100 // these both default to true, so you have to explicitly make them false 101 requireReturn = options.requireReturn !== false, 102 requireParamDescription = options.requireParamDescription !== false, 103 requireReturnDescription = options.requireReturnDescription !== false, 104 requireReturnType = options.requireReturnType !== false, 105 requireParamType = options.requireParamType !== false, 106 preferType = options.preferType || {}, 107 checkPreferType = Object.keys(preferType).length !== 0; 108 109 //-------------------------------------------------------------------------- 110 // Helpers 111 //-------------------------------------------------------------------------- 112 113 // Using a stack to store if a function returns or not (handling nested functions) 114 const fns = []; 115 116 /** 117 * Check if node type is a Class 118 * @param {ASTNode} node node to check. 119 * @returns {boolean} True is its a class 120 * @private 121 */ 122 function isTypeClass(node) { 123 return node.type === "ClassExpression" || node.type === "ClassDeclaration"; 124 } 125 126 /** 127 * When parsing a new function, store it in our function stack. 128 * @param {ASTNode} node A function node to check. 129 * @returns {void} 130 * @private 131 */ 132 function startFunction(node) { 133 fns.push({ 134 returnPresent: (node.type === "ArrowFunctionExpression" && node.body.type !== "BlockStatement") || 135 isTypeClass(node) || node.async 136 }); 137 } 138 139 /** 140 * Indicate that return has been found in the current function. 141 * @param {ASTNode} node The return node. 142 * @returns {void} 143 * @private 144 */ 145 function addReturn(node) { 146 const functionState = fns[fns.length - 1]; 147 148 if (functionState && node.argument !== null) { 149 functionState.returnPresent = true; 150 } 151 } 152 153 /** 154 * Check if return tag type is void or undefined 155 * @param {Object} tag JSDoc tag 156 * @returns {boolean} True if its of type void or undefined 157 * @private 158 */ 159 function isValidReturnType(tag) { 160 return tag.type === null || tag.type.name === "void" || tag.type.type === "UndefinedLiteral"; 161 } 162 163 /** 164 * Check if type should be validated based on some exceptions 165 * @param {Object} type JSDoc tag 166 * @returns {boolean} True if it can be validated 167 * @private 168 */ 169 function canTypeBeValidated(type) { 170 return type !== "UndefinedLiteral" && // {undefined} as there is no name property available. 171 type !== "NullLiteral" && // {null} 172 type !== "NullableLiteral" && // {?} 173 type !== "FunctionType" && // {function(a)} 174 type !== "AllLiteral"; // {*} 175 } 176 177 /** 178 * Extract the current and expected type based on the input type object 179 * @param {Object} type JSDoc tag 180 * @returns {{currentType: Doctrine.Type, expectedTypeName: string}} The current type annotation and 181 * the expected name of the annotation 182 * @private 183 */ 184 function getCurrentExpectedTypes(type) { 185 let currentType; 186 187 if (type.name) { 188 currentType = type; 189 } else if (type.expression) { 190 currentType = type.expression; 191 } 192 193 return { 194 currentType, 195 expectedTypeName: currentType && preferType[currentType.name] 196 }; 197 } 198 199 /** 200 * Gets the location of a JSDoc node in a file 201 * @param {Token} jsdocComment The comment that this node is parsed from 202 * @param {{range: number[]}} parsedJsdocNode A tag or other node which was parsed from this comment 203 * @returns {{start: SourceLocation, end: SourceLocation}} The 0-based source location for the tag 204 */ 205 function getAbsoluteRange(jsdocComment, parsedJsdocNode) { 206 return { 207 start: sourceCode.getLocFromIndex(jsdocComment.range[0] + 2 + parsedJsdocNode.range[0]), 208 end: sourceCode.getLocFromIndex(jsdocComment.range[0] + 2 + parsedJsdocNode.range[1]) 209 }; 210 } 211 212 /** 213 * Validate type for a given JSDoc node 214 * @param {Object} jsdocNode JSDoc node 215 * @param {Object} type JSDoc tag 216 * @returns {void} 217 * @private 218 */ 219 function validateType(jsdocNode, type) { 220 if (!type || !canTypeBeValidated(type.type)) { 221 return; 222 } 223 224 const typesToCheck = []; 225 let elements = []; 226 227 switch (type.type) { 228 case "TypeApplication": // {Array.<String>} 229 elements = type.applications[0].type === "UnionType" ? type.applications[0].elements : type.applications; 230 typesToCheck.push(getCurrentExpectedTypes(type)); 231 break; 232 case "RecordType": // {{20:String}} 233 elements = type.fields; 234 break; 235 case "UnionType": // {String|number|Test} 236 case "ArrayType": // {[String, number, Test]} 237 elements = type.elements; 238 break; 239 case "FieldType": // Array.<{count: number, votes: number}> 240 if (type.value) { 241 typesToCheck.push(getCurrentExpectedTypes(type.value)); 242 } 243 break; 244 default: 245 typesToCheck.push(getCurrentExpectedTypes(type)); 246 } 247 248 elements.forEach(validateType.bind(null, jsdocNode)); 249 250 typesToCheck.forEach(typeToCheck => { 251 if (typeToCheck.expectedTypeName && 252 typeToCheck.expectedTypeName !== typeToCheck.currentType.name) { 253 context.report({ 254 node: jsdocNode, 255 messageId: "useType", 256 loc: getAbsoluteRange(jsdocNode, typeToCheck.currentType), 257 data: { 258 currentTypeName: typeToCheck.currentType.name, 259 expectedTypeName: typeToCheck.expectedTypeName 260 }, 261 fix(fixer) { 262 return fixer.replaceTextRange( 263 typeToCheck.currentType.range.map(indexInComment => jsdocNode.range[0] + 2 + indexInComment), 264 typeToCheck.expectedTypeName 265 ); 266 } 267 }); 268 } 269 }); 270 } 271 272 /** 273 * Validate the JSDoc node and output warnings if anything is wrong. 274 * @param {ASTNode} node The AST node to check. 275 * @returns {void} 276 * @private 277 */ 278 function checkJSDoc(node) { 279 const jsdocNode = sourceCode.getJSDocComment(node), 280 functionData = fns.pop(), 281 paramTagsByName = Object.create(null), 282 paramTags = []; 283 let hasReturns = false, 284 returnsTag, 285 hasConstructor = false, 286 isInterface = false, 287 isOverride = false, 288 isAbstract = false; 289 290 // make sure only to validate JSDoc comments 291 if (jsdocNode) { 292 let jsdoc; 293 294 try { 295 jsdoc = doctrine.parse(jsdocNode.value, { 296 strict: true, 297 unwrap: true, 298 sloppy: true, 299 range: true 300 }); 301 } catch (ex) { 302 303 if (/braces/iu.test(ex.message)) { 304 context.report({ node: jsdocNode, messageId: "missingBrace" }); 305 } else { 306 context.report({ node: jsdocNode, messageId: "syntaxError" }); 307 } 308 309 return; 310 } 311 312 jsdoc.tags.forEach(tag => { 313 314 switch (tag.title.toLowerCase()) { 315 316 case "param": 317 case "arg": 318 case "argument": 319 paramTags.push(tag); 320 break; 321 322 case "return": 323 case "returns": 324 hasReturns = true; 325 returnsTag = tag; 326 break; 327 328 case "constructor": 329 case "class": 330 hasConstructor = true; 331 break; 332 333 case "override": 334 case "inheritdoc": 335 isOverride = true; 336 break; 337 338 case "abstract": 339 case "virtual": 340 isAbstract = true; 341 break; 342 343 case "interface": 344 isInterface = true; 345 break; 346 347 // no default 348 } 349 350 // check tag preferences 351 if (Object.prototype.hasOwnProperty.call(prefer, tag.title) && tag.title !== prefer[tag.title]) { 352 const entireTagRange = getAbsoluteRange(jsdocNode, tag); 353 354 context.report({ 355 node: jsdocNode, 356 messageId: "use", 357 loc: { 358 start: entireTagRange.start, 359 end: { 360 line: entireTagRange.start.line, 361 column: entireTagRange.start.column + `@${tag.title}`.length 362 } 363 }, 364 data: { name: prefer[tag.title] }, 365 fix(fixer) { 366 return fixer.replaceTextRange( 367 [ 368 jsdocNode.range[0] + tag.range[0] + 3, 369 jsdocNode.range[0] + tag.range[0] + tag.title.length + 3 370 ], 371 prefer[tag.title] 372 ); 373 } 374 }); 375 } 376 377 // validate the types 378 if (checkPreferType && tag.type) { 379 validateType(jsdocNode, tag.type); 380 } 381 }); 382 383 paramTags.forEach(param => { 384 if (requireParamType && !param.type) { 385 context.report({ 386 node: jsdocNode, 387 messageId: "missingParamType", 388 loc: getAbsoluteRange(jsdocNode, param), 389 data: { name: param.name } 390 }); 391 } 392 if (!param.description && requireParamDescription) { 393 context.report({ 394 node: jsdocNode, 395 messageId: "missingParamDesc", 396 loc: getAbsoluteRange(jsdocNode, param), 397 data: { name: param.name } 398 }); 399 } 400 if (paramTagsByName[param.name]) { 401 context.report({ 402 node: jsdocNode, 403 messageId: "duplicateParam", 404 loc: getAbsoluteRange(jsdocNode, param), 405 data: { name: param.name } 406 }); 407 } else if (param.name.indexOf(".") === -1) { 408 paramTagsByName[param.name] = param; 409 } 410 }); 411 412 if (hasReturns) { 413 if (!requireReturn && !functionData.returnPresent && (returnsTag.type === null || !isValidReturnType(returnsTag)) && !isAbstract) { 414 context.report({ 415 node: jsdocNode, 416 messageId: "unexpectedTag", 417 loc: getAbsoluteRange(jsdocNode, returnsTag), 418 data: { 419 title: returnsTag.title 420 } 421 }); 422 } else { 423 if (requireReturnType && !returnsTag.type) { 424 context.report({ node: jsdocNode, messageId: "missingReturnType" }); 425 } 426 427 if (!isValidReturnType(returnsTag) && !returnsTag.description && requireReturnDescription) { 428 context.report({ node: jsdocNode, messageId: "missingReturnDesc" }); 429 } 430 } 431 } 432 433 // check for functions missing @returns 434 if (!isOverride && !hasReturns && !hasConstructor && !isInterface && 435 node.parent.kind !== "get" && node.parent.kind !== "constructor" && 436 node.parent.kind !== "set" && !isTypeClass(node)) { 437 if (requireReturn || (functionData.returnPresent && !node.async)) { 438 context.report({ 439 node: jsdocNode, 440 messageId: "missingReturn", 441 data: { 442 returns: prefer.returns || "returns" 443 } 444 }); 445 } 446 } 447 448 // check the parameters 449 const jsdocParamNames = Object.keys(paramTagsByName); 450 451 if (node.params) { 452 node.params.forEach((param, paramsIndex) => { 453 const bindingParam = param.type === "AssignmentPattern" 454 ? param.left 455 : param; 456 457 // TODO(nzakas): Figure out logical things to do with destructured, default, rest params 458 if (bindingParam.type === "Identifier") { 459 const name = bindingParam.name; 460 461 if (jsdocParamNames[paramsIndex] && (name !== jsdocParamNames[paramsIndex])) { 462 context.report({ 463 node: jsdocNode, 464 messageId: "expected", 465 loc: getAbsoluteRange(jsdocNode, paramTagsByName[jsdocParamNames[paramsIndex]]), 466 data: { 467 name, 468 jsdocName: jsdocParamNames[paramsIndex] 469 } 470 }); 471 } else if (!paramTagsByName[name] && !isOverride) { 472 context.report({ 473 node: jsdocNode, 474 messageId: "missingParam", 475 data: { 476 name 477 } 478 }); 479 } 480 } 481 }); 482 } 483 484 if (options.matchDescription) { 485 const regex = new RegExp(options.matchDescription, "u"); 486 487 if (!regex.test(jsdoc.description)) { 488 context.report({ node: jsdocNode, messageId: "unsatisfiedDesc" }); 489 } 490 } 491 492 } 493 494 } 495 496 //-------------------------------------------------------------------------- 497 // Public 498 //-------------------------------------------------------------------------- 499 500 return { 501 ArrowFunctionExpression: startFunction, 502 FunctionExpression: startFunction, 503 FunctionDeclaration: startFunction, 504 ClassExpression: startFunction, 505 ClassDeclaration: startFunction, 506 "ArrowFunctionExpression:exit": checkJSDoc, 507 "FunctionExpression:exit": checkJSDoc, 508 "FunctionDeclaration:exit": checkJSDoc, 509 "ClassExpression:exit": checkJSDoc, 510 "ClassDeclaration:exit": checkJSDoc, 511 ReturnStatement: addReturn 512 }; 513 514 } 515}; 516