1 | /**
|
2 | * @fileoverview Validates JSDoc comments are syntactically correct
|
3 | * @author Nicholas C. Zakas
|
4 | */
|
5 | ;
|
6 |
|
7 | //------------------------------------------------------------------------------
|
8 | // Requirements
|
9 | //------------------------------------------------------------------------------
|
10 |
|
11 | const doctrine = require("doctrine");
|
12 |
|
13 | //------------------------------------------------------------------------------
|
14 | // Rule Definition
|
15 | //------------------------------------------------------------------------------
|
16 |
|
17 | module.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 | };
|