• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1/**
2 * @fileoverview Rule to check for max length on a line.
3 * @author Matt DuVall <http://www.mattduvall.com>
4 */
5
6"use strict";
7
8//------------------------------------------------------------------------------
9// Constants
10//------------------------------------------------------------------------------
11
12const OPTIONS_SCHEMA = {
13    type: "object",
14    properties: {
15        code: {
16            type: "integer",
17            minimum: 0
18        },
19        comments: {
20            type: "integer",
21            minimum: 0
22        },
23        tabWidth: {
24            type: "integer",
25            minimum: 0
26        },
27        ignorePattern: {
28            type: "string"
29        },
30        ignoreComments: {
31            type: "boolean"
32        },
33        ignoreStrings: {
34            type: "boolean"
35        },
36        ignoreUrls: {
37            type: "boolean"
38        },
39        ignoreTemplateLiterals: {
40            type: "boolean"
41        },
42        ignoreRegExpLiterals: {
43            type: "boolean"
44        },
45        ignoreTrailingComments: {
46            type: "boolean"
47        }
48    },
49    additionalProperties: false
50};
51
52const OPTIONS_OR_INTEGER_SCHEMA = {
53    anyOf: [
54        OPTIONS_SCHEMA,
55        {
56            type: "integer",
57            minimum: 0
58        }
59    ]
60};
61
62//------------------------------------------------------------------------------
63// Rule Definition
64//------------------------------------------------------------------------------
65
66module.exports = {
67    meta: {
68        type: "layout",
69
70        docs: {
71            description: "enforce a maximum line length",
72            category: "Stylistic Issues",
73            recommended: false,
74            url: "https://eslint.org/docs/rules/max-len"
75        },
76
77        schema: [
78            OPTIONS_OR_INTEGER_SCHEMA,
79            OPTIONS_OR_INTEGER_SCHEMA,
80            OPTIONS_SCHEMA
81        ],
82        messages: {
83            max: "This line has a length of {{lineLength}}. Maximum allowed is {{maxLength}}.",
84            maxComment: "This line has a comment length of {{lineLength}}. Maximum allowed is {{maxCommentLength}}."
85        }
86    },
87
88    create(context) {
89
90        /*
91         * Inspired by http://tools.ietf.org/html/rfc3986#appendix-B, however:
92         * - They're matching an entire string that we know is a URI
93         * - We're matching part of a string where we think there *might* be a URL
94         * - We're only concerned about URLs, as picking out any URI would cause
95         *   too many false positives
96         * - We don't care about matching the entire URL, any small segment is fine
97         */
98        const URL_REGEXP = /[^:/?#]:\/\/[^?#]/u;
99
100        const sourceCode = context.getSourceCode();
101
102        /**
103         * Computes the length of a line that may contain tabs. The width of each
104         * tab will be the number of spaces to the next tab stop.
105         * @param {string} line The line.
106         * @param {int} tabWidth The width of each tab stop in spaces.
107         * @returns {int} The computed line length.
108         * @private
109         */
110        function computeLineLength(line, tabWidth) {
111            let extraCharacterCount = 0;
112
113            line.replace(/\t/gu, (match, offset) => {
114                const totalOffset = offset + extraCharacterCount,
115                    previousTabStopOffset = tabWidth ? totalOffset % tabWidth : 0,
116                    spaceCount = tabWidth - previousTabStopOffset;
117
118                extraCharacterCount += spaceCount - 1; // -1 for the replaced tab
119            });
120            return Array.from(line).length + extraCharacterCount;
121        }
122
123        // The options object must be the last option specified…
124        const options = Object.assign({}, context.options[context.options.length - 1]);
125
126        // …but max code length…
127        if (typeof context.options[0] === "number") {
128            options.code = context.options[0];
129        }
130
131        // …and tabWidth can be optionally specified directly as integers.
132        if (typeof context.options[1] === "number") {
133            options.tabWidth = context.options[1];
134        }
135
136        const maxLength = typeof options.code === "number" ? options.code : 80,
137            tabWidth = typeof options.tabWidth === "number" ? options.tabWidth : 4,
138            ignoreComments = !!options.ignoreComments,
139            ignoreStrings = !!options.ignoreStrings,
140            ignoreTemplateLiterals = !!options.ignoreTemplateLiterals,
141            ignoreRegExpLiterals = !!options.ignoreRegExpLiterals,
142            ignoreTrailingComments = !!options.ignoreTrailingComments || !!options.ignoreComments,
143            ignoreUrls = !!options.ignoreUrls,
144            maxCommentLength = options.comments;
145        let ignorePattern = options.ignorePattern || null;
146
147        if (ignorePattern) {
148            ignorePattern = new RegExp(ignorePattern, "u");
149        }
150
151        //--------------------------------------------------------------------------
152        // Helpers
153        //--------------------------------------------------------------------------
154
155        /**
156         * Tells if a given comment is trailing: it starts on the current line and
157         * extends to or past the end of the current line.
158         * @param {string} line The source line we want to check for a trailing comment on
159         * @param {number} lineNumber The one-indexed line number for line
160         * @param {ASTNode} comment The comment to inspect
161         * @returns {boolean} If the comment is trailing on the given line
162         */
163        function isTrailingComment(line, lineNumber, comment) {
164            return comment &&
165                (comment.loc.start.line === lineNumber && lineNumber <= comment.loc.end.line) &&
166                (comment.loc.end.line > lineNumber || comment.loc.end.column === line.length);
167        }
168
169        /**
170         * Tells if a comment encompasses the entire line.
171         * @param {string} line The source line with a trailing comment
172         * @param {number} lineNumber The one-indexed line number this is on
173         * @param {ASTNode} comment The comment to remove
174         * @returns {boolean} If the comment covers the entire line
175         */
176        function isFullLineComment(line, lineNumber, comment) {
177            const start = comment.loc.start,
178                end = comment.loc.end,
179                isFirstTokenOnLine = !line.slice(0, comment.loc.start.column).trim();
180
181            return comment &&
182                (start.line < lineNumber || (start.line === lineNumber && isFirstTokenOnLine)) &&
183                (end.line > lineNumber || (end.line === lineNumber && end.column === line.length));
184        }
185
186        /**
187         * Check if a node is a JSXEmptyExpression contained in a single line JSXExpressionContainer.
188         * @param {ASTNode} node A node to check.
189         * @returns {boolean} True if the node is a JSXEmptyExpression contained in a single line JSXExpressionContainer.
190         */
191        function isJSXEmptyExpressionInSingleLineContainer(node) {
192            if (!node || !node.parent || node.type !== "JSXEmptyExpression" || node.parent.type !== "JSXExpressionContainer") {
193                return false;
194            }
195
196            const parent = node.parent;
197
198            return parent.loc.start.line === parent.loc.end.line;
199        }
200
201        /**
202         * Gets the line after the comment and any remaining trailing whitespace is
203         * stripped.
204         * @param {string} line The source line with a trailing comment
205         * @param {ASTNode} comment The comment to remove
206         * @returns {string} Line without comment and trailing whitespace
207         */
208        function stripTrailingComment(line, comment) {
209
210            // loc.column is zero-indexed
211            return line.slice(0, comment.loc.start.column).replace(/\s+$/u, "");
212        }
213
214        /**
215         * Ensure that an array exists at [key] on `object`, and add `value` to it.
216         * @param {Object} object the object to mutate
217         * @param {string} key the object's key
218         * @param {*} value the value to add
219         * @returns {void}
220         * @private
221         */
222        function ensureArrayAndPush(object, key, value) {
223            if (!Array.isArray(object[key])) {
224                object[key] = [];
225            }
226            object[key].push(value);
227        }
228
229        /**
230         * Retrieves an array containing all strings (" or ') in the source code.
231         * @returns {ASTNode[]} An array of string nodes.
232         */
233        function getAllStrings() {
234            return sourceCode.ast.tokens.filter(token => (token.type === "String" ||
235                (token.type === "JSXText" && sourceCode.getNodeByRangeIndex(token.range[0] - 1).type === "JSXAttribute")));
236        }
237
238        /**
239         * Retrieves an array containing all template literals in the source code.
240         * @returns {ASTNode[]} An array of template literal nodes.
241         */
242        function getAllTemplateLiterals() {
243            return sourceCode.ast.tokens.filter(token => token.type === "Template");
244        }
245
246
247        /**
248         * Retrieves an array containing all RegExp literals in the source code.
249         * @returns {ASTNode[]} An array of RegExp literal nodes.
250         */
251        function getAllRegExpLiterals() {
252            return sourceCode.ast.tokens.filter(token => token.type === "RegularExpression");
253        }
254
255
256        /**
257         * A reducer to group an AST node by line number, both start and end.
258         * @param {Object} acc the accumulator
259         * @param {ASTNode} node the AST node in question
260         * @returns {Object} the modified accumulator
261         * @private
262         */
263        function groupByLineNumber(acc, node) {
264            for (let i = node.loc.start.line; i <= node.loc.end.line; ++i) {
265                ensureArrayAndPush(acc, i, node);
266            }
267            return acc;
268        }
269
270        /**
271         * Returns an array of all comments in the source code.
272         * If the element in the array is a JSXEmptyExpression contained with a single line JSXExpressionContainer,
273         * the element is changed with JSXExpressionContainer node.
274         * @returns {ASTNode[]} An array of comment nodes
275         */
276        function getAllComments() {
277            const comments = [];
278
279            sourceCode.getAllComments()
280                .forEach(commentNode => {
281                    const containingNode = sourceCode.getNodeByRangeIndex(commentNode.range[0]);
282
283                    if (isJSXEmptyExpressionInSingleLineContainer(containingNode)) {
284
285                        // push a unique node only
286                        if (comments[comments.length - 1] !== containingNode.parent) {
287                            comments.push(containingNode.parent);
288                        }
289                    } else {
290                        comments.push(commentNode);
291                    }
292                });
293
294            return comments;
295        }
296
297        /**
298         * Check the program for max length
299         * @param {ASTNode} node Node to examine
300         * @returns {void}
301         * @private
302         */
303        function checkProgramForMaxLength(node) {
304
305            // split (honors line-ending)
306            const lines = sourceCode.lines,
307
308                // list of comments to ignore
309                comments = ignoreComments || maxCommentLength || ignoreTrailingComments ? getAllComments() : [];
310
311            // we iterate over comments in parallel with the lines
312            let commentsIndex = 0;
313
314            const strings = getAllStrings();
315            const stringsByLine = strings.reduce(groupByLineNumber, {});
316
317            const templateLiterals = getAllTemplateLiterals();
318            const templateLiteralsByLine = templateLiterals.reduce(groupByLineNumber, {});
319
320            const regExpLiterals = getAllRegExpLiterals();
321            const regExpLiteralsByLine = regExpLiterals.reduce(groupByLineNumber, {});
322
323            lines.forEach((line, i) => {
324
325                // i is zero-indexed, line numbers are one-indexed
326                const lineNumber = i + 1;
327
328                /*
329                 * if we're checking comment length; we need to know whether this
330                 * line is a comment
331                 */
332                let lineIsComment = false;
333                let textToMeasure;
334
335                /*
336                 * We can short-circuit the comment checks if we're already out of
337                 * comments to check.
338                 */
339                if (commentsIndex < comments.length) {
340                    let comment = null;
341
342                    // iterate over comments until we find one past the current line
343                    do {
344                        comment = comments[++commentsIndex];
345                    } while (comment && comment.loc.start.line <= lineNumber);
346
347                    // and step back by one
348                    comment = comments[--commentsIndex];
349
350                    if (isFullLineComment(line, lineNumber, comment)) {
351                        lineIsComment = true;
352                        textToMeasure = line;
353                    } else if (ignoreTrailingComments && isTrailingComment(line, lineNumber, comment)) {
354                        textToMeasure = stripTrailingComment(line, comment);
355
356                        // ignore multiple trailing comments in the same line
357                        let lastIndex = commentsIndex;
358
359                        while (isTrailingComment(textToMeasure, lineNumber, comments[--lastIndex])) {
360                            textToMeasure = stripTrailingComment(textToMeasure, comments[lastIndex]);
361                        }
362                    } else {
363                        textToMeasure = line;
364                    }
365                } else {
366                    textToMeasure = line;
367                }
368                if (ignorePattern && ignorePattern.test(textToMeasure) ||
369                    ignoreUrls && URL_REGEXP.test(textToMeasure) ||
370                    ignoreStrings && stringsByLine[lineNumber] ||
371                    ignoreTemplateLiterals && templateLiteralsByLine[lineNumber] ||
372                    ignoreRegExpLiterals && regExpLiteralsByLine[lineNumber]
373                ) {
374
375                    // ignore this line
376                    return;
377                }
378
379                const lineLength = computeLineLength(textToMeasure, tabWidth);
380                const commentLengthApplies = lineIsComment && maxCommentLength;
381
382                if (lineIsComment && ignoreComments) {
383                    return;
384                }
385
386                const loc = {
387                    start: {
388                        line: lineNumber,
389                        column: 0
390                    },
391                    end: {
392                        line: lineNumber,
393                        column: textToMeasure.length
394                    }
395                };
396
397                if (commentLengthApplies) {
398                    if (lineLength > maxCommentLength) {
399                        context.report({
400                            node,
401                            loc,
402                            messageId: "maxComment",
403                            data: {
404                                lineLength,
405                                maxCommentLength
406                            }
407                        });
408                    }
409                } else if (lineLength > maxLength) {
410                    context.report({
411                        node,
412                        loc,
413                        messageId: "max",
414                        data: {
415                            lineLength,
416                            maxLength
417                        }
418                    });
419                }
420            });
421        }
422
423
424        //--------------------------------------------------------------------------
425        // Public API
426        //--------------------------------------------------------------------------
427
428        return {
429            Program: checkProgramForMaxLength
430        };
431
432    }
433};
434