UNPKG

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