max-len.js 16 KB

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