• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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