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