UNPKG

13.7 kBJavaScriptView Raw
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 docs: {
69 description: "enforce a maximum line length",
70 category: "Stylistic Issues",
71 recommended: false,
72 url: "https://eslint.org/docs/rules/max-len"
73 },
74
75 schema: [
76 OPTIONS_OR_INTEGER_SCHEMA,
77 OPTIONS_OR_INTEGER_SCHEMA,
78 OPTIONS_SCHEMA
79 ]
80 },
81
82 create(context) {
83
84 /*
85 * Inspired by http://tools.ietf.org/html/rfc3986#appendix-B, however:
86 * - They're matching an entire string that we know is a URI
87 * - We're matching part of a string where we think there *might* be a URL
88 * - We're only concerned about URLs, as picking out any URI would cause
89 * too many false positives
90 * - We don't care about matching the entire URL, any small segment is fine
91 */
92 const URL_REGEXP = /[^:/?#]:\/\/[^?#]/;
93
94 const sourceCode = context.getSourceCode();
95
96 /**
97 * Computes the length of a line that may contain tabs. The width of each
98 * tab will be the number of spaces to the next tab stop.
99 * @param {string} line The line.
100 * @param {int} tabWidth The width of each tab stop in spaces.
101 * @returns {int} The computed line length.
102 * @private
103 */
104 function computeLineLength(line, tabWidth) {
105 let extraCharacterCount = 0;
106
107 line.replace(/\t/g, (match, offset) => {
108 const totalOffset = offset + extraCharacterCount,
109 previousTabStopOffset = tabWidth ? totalOffset % tabWidth : 0,
110 spaceCount = tabWidth - previousTabStopOffset;
111
112 extraCharacterCount += spaceCount - 1; // -1 for the replaced tab
113 });
114 return Array.from(line).length + extraCharacterCount;
115 }
116
117 // The options object must be the last option specified…
118 const lastOption = context.options[context.options.length - 1];
119 const options = typeof lastOption === "object" ? Object.create(lastOption) : {};
120
121 // …but max code length…
122 if (typeof context.options[0] === "number") {
123 options.code = context.options[0];
124 }
125
126 // …and tabWidth can be optionally specified directly as integers.
127 if (typeof context.options[1] === "number") {
128 options.tabWidth = context.options[1];
129 }
130
131 const maxLength = options.code || 80,
132 tabWidth = options.tabWidth || 4,
133 ignoreComments = options.ignoreComments || false,
134 ignoreStrings = options.ignoreStrings || false,
135 ignoreTemplateLiterals = options.ignoreTemplateLiterals || false,
136 ignoreRegExpLiterals = options.ignoreRegExpLiterals || false,
137 ignoreTrailingComments = options.ignoreTrailingComments || options.ignoreComments || false,
138 ignoreUrls = options.ignoreUrls || false,
139 maxCommentLength = options.comments;
140 let ignorePattern = options.ignorePattern || null;
141
142 if (ignorePattern) {
143 ignorePattern = new RegExp(ignorePattern);
144 }
145
146 //--------------------------------------------------------------------------
147 // Helpers
148 //--------------------------------------------------------------------------
149
150 /**
151 * Tells if a given comment is trailing: it starts on the current line and
152 * extends to or past the end of the current line.
153 * @param {string} line The source line we want to check for a trailing comment on
154 * @param {number} lineNumber The one-indexed line number for line
155 * @param {ASTNode} comment The comment to inspect
156 * @returns {boolean} If the comment is trailing on the given line
157 */
158 function isTrailingComment(line, lineNumber, comment) {
159 return comment &&
160 (comment.loc.start.line === lineNumber && lineNumber <= comment.loc.end.line) &&
161 (comment.loc.end.line > lineNumber || comment.loc.end.column === line.length);
162 }
163
164 /**
165 * Tells if a comment encompasses the entire line.
166 * @param {string} line The source line with a trailing comment
167 * @param {number} lineNumber The one-indexed line number this is on
168 * @param {ASTNode} comment The comment to remove
169 * @returns {boolean} If the comment covers the entire line
170 */
171 function isFullLineComment(line, lineNumber, comment) {
172 const start = comment.loc.start,
173 end = comment.loc.end,
174 isFirstTokenOnLine = !line.slice(0, comment.loc.start.column).trim();
175
176 return comment &&
177 (start.line < lineNumber || (start.line === lineNumber && isFirstTokenOnLine)) &&
178 (end.line > lineNumber || (end.line === lineNumber && end.column === line.length));
179 }
180
181 /**
182 * Gets the line after the comment and any remaining trailing whitespace is
183 * stripped.
184 * @param {string} line The source line with a trailing comment
185 * @param {ASTNode} comment The comment to remove
186 * @returns {string} Line without comment and trailing whitepace
187 */
188 function stripTrailingComment(line, comment) {
189
190 // loc.column is zero-indexed
191 return line.slice(0, comment.loc.start.column).replace(/\s+$/, "");
192 }
193
194 /**
195 * Ensure that an array exists at [key] on `object`, and add `value` to it.
196 *
197 * @param {Object} object the object to mutate
198 * @param {string} key the object's key
199 * @param {*} value the value to add
200 * @returns {void}
201 * @private
202 */
203 function ensureArrayAndPush(object, key, value) {
204 if (!Array.isArray(object[key])) {
205 object[key] = [];
206 }
207 object[key].push(value);
208 }
209
210 /**
211 * Retrieves an array containing all strings (" or ') in the source code.
212 *
213 * @returns {ASTNode[]} An array of string nodes.
214 */
215 function getAllStrings() {
216 return sourceCode.ast.tokens.filter(token => (token.type === "String" ||
217 (token.type === "JSXText" && sourceCode.getNodeByRangeIndex(token.range[0] - 1).type === "JSXAttribute")));
218 }
219
220 /**
221 * Retrieves an array containing all template literals in the source code.
222 *
223 * @returns {ASTNode[]} An array of template literal nodes.
224 */
225 function getAllTemplateLiterals() {
226 return sourceCode.ast.tokens.filter(token => token.type === "Template");
227 }
228
229
230 /**
231 * Retrieves an array containing all RegExp literals in the source code.
232 *
233 * @returns {ASTNode[]} An array of RegExp literal nodes.
234 */
235 function getAllRegExpLiterals() {
236 return sourceCode.ast.tokens.filter(token => token.type === "RegularExpression");
237 }
238
239
240 /**
241 * A reducer to group an AST node by line number, both start and end.
242 *
243 * @param {Object} acc the accumulator
244 * @param {ASTNode} node the AST node in question
245 * @returns {Object} the modified accumulator
246 * @private
247 */
248 function groupByLineNumber(acc, node) {
249 for (let i = node.loc.start.line; i <= node.loc.end.line; ++i) {
250 ensureArrayAndPush(acc, i, node);
251 }
252 return acc;
253 }
254
255 /**
256 * Check the program for max length
257 * @param {ASTNode} node Node to examine
258 * @returns {void}
259 * @private
260 */
261 function checkProgramForMaxLength(node) {
262
263 // split (honors line-ending)
264 const lines = sourceCode.lines,
265
266 // list of comments to ignore
267 comments = ignoreComments || maxCommentLength || ignoreTrailingComments ? sourceCode.getAllComments() : [];
268
269 // we iterate over comments in parallel with the lines
270 let commentsIndex = 0;
271
272 const strings = getAllStrings();
273 const stringsByLine = strings.reduce(groupByLineNumber, {});
274
275 const templateLiterals = getAllTemplateLiterals();
276 const templateLiteralsByLine = templateLiterals.reduce(groupByLineNumber, {});
277
278 const regExpLiterals = getAllRegExpLiterals();
279 const regExpLiteralsByLine = regExpLiterals.reduce(groupByLineNumber, {});
280
281 lines.forEach((line, i) => {
282
283 // i is zero-indexed, line numbers are one-indexed
284 const lineNumber = i + 1;
285
286 /*
287 * if we're checking comment length; we need to know whether this
288 * line is a comment
289 */
290 let lineIsComment = false;
291 let textToMeasure;
292
293 /*
294 * We can short-circuit the comment checks if we're already out of
295 * comments to check.
296 */
297 if (commentsIndex < comments.length) {
298 let comment = null;
299
300 // iterate over comments until we find one past the current line
301 do {
302 comment = comments[++commentsIndex];
303 } while (comment && comment.loc.start.line <= lineNumber);
304
305 // and step back by one
306 comment = comments[--commentsIndex];
307
308 if (isFullLineComment(line, lineNumber, comment)) {
309 lineIsComment = true;
310 textToMeasure = line;
311 } else if (ignoreTrailingComments && isTrailingComment(line, lineNumber, comment)) {
312 textToMeasure = stripTrailingComment(line, comment);
313 } else {
314 textToMeasure = line;
315 }
316 } else {
317 textToMeasure = line;
318 }
319 if (ignorePattern && ignorePattern.test(textToMeasure) ||
320 ignoreUrls && URL_REGEXP.test(textToMeasure) ||
321 ignoreStrings && stringsByLine[lineNumber] ||
322 ignoreTemplateLiterals && templateLiteralsByLine[lineNumber] ||
323 ignoreRegExpLiterals && regExpLiteralsByLine[lineNumber]
324 ) {
325
326 // ignore this line
327 return;
328 }
329
330 const lineLength = computeLineLength(textToMeasure, tabWidth);
331 const commentLengthApplies = lineIsComment && maxCommentLength;
332
333 if (lineIsComment && ignoreComments) {
334 return;
335 }
336
337 if (commentLengthApplies) {
338 if (lineLength > maxCommentLength) {
339 context.report({
340 node,
341 loc: { line: lineNumber, column: 0 },
342 message: "Line {{lineNumber}} exceeds the maximum comment line length of {{maxCommentLength}}.",
343 data: {
344 lineNumber: i + 1,
345 maxCommentLength
346 }
347 });
348 }
349 } else if (lineLength > maxLength) {
350 context.report({
351 node,
352 loc: { line: lineNumber, column: 0 },
353 message: "Line {{lineNumber}} exceeds the maximum line length of {{maxLength}}.",
354 data: {
355 lineNumber: i + 1,
356 maxLength
357 }
358 });
359 }
360 });
361 }
362
363
364 //--------------------------------------------------------------------------
365 // Public API
366 //--------------------------------------------------------------------------
367
368 return {
369 Program: checkProgramForMaxLength
370 };
371
372 }
373};