max-len.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560
  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 {number} tabWidth The width of each tab stop in spaces.
  79. * @returns {number} The computed line length.
  80. * @private
  81. */
  82. function computeLineLength(line, tabWidth) {
  83. let extraCharacterCount = 0
  84. const re = /\t/gu
  85. let ret
  86. while ((ret = re.exec(line))) {
  87. const offset = ret.index
  88. const totalOffset = offset + extraCharacterCount
  89. const previousTabStopOffset = tabWidth ? totalOffset % tabWidth : 0
  90. const spaceCount = tabWidth - previousTabStopOffset
  91. extraCharacterCount += spaceCount - 1 // -1 for the replaced tab
  92. }
  93. return Array.from(line).length + extraCharacterCount
  94. }
  95. /**
  96. * Tells if a given comment is trailing: it starts on the current line and
  97. * extends to or past the end of the current line.
  98. * @param {string} line The source line we want to check for a trailing comment on
  99. * @param {number} lineNumber The one-indexed line number for line
  100. * @param {Token | null} comment The comment to inspect
  101. * @returns {comment is Token} If the comment is trailing on the given line
  102. */
  103. function isTrailingComment(line, lineNumber, comment) {
  104. return Boolean(
  105. comment &&
  106. comment.loc.start.line === lineNumber &&
  107. lineNumber <= comment.loc.end.line &&
  108. (comment.loc.end.line > lineNumber ||
  109. comment.loc.end.column === line.length)
  110. )
  111. }
  112. /**
  113. * Tells if a comment encompasses the entire line.
  114. * @param {string} line The source line with a trailing comment
  115. * @param {number} lineNumber The one-indexed line number this is on
  116. * @param {Token | null} comment The comment to remove
  117. * @returns {boolean} If the comment covers the entire line
  118. */
  119. function isFullLineComment(line, lineNumber, comment) {
  120. if (!comment) {
  121. return false
  122. }
  123. const start = comment.loc.start
  124. const end = comment.loc.end
  125. const isFirstTokenOnLine = !line.slice(0, comment.loc.start.column).trim()
  126. return (
  127. comment &&
  128. (start.line < lineNumber ||
  129. (start.line === lineNumber && isFirstTokenOnLine)) &&
  130. (end.line > lineNumber ||
  131. (end.line === lineNumber && end.column === line.length))
  132. )
  133. }
  134. /**
  135. * Gets the line after the comment and any remaining trailing whitespace is
  136. * stripped.
  137. * @param {string} line The source line with a trailing comment
  138. * @param {Token} comment The comment to remove
  139. * @returns {string} Line without comment and trailing whitepace
  140. */
  141. function stripTrailingComment(line, comment) {
  142. // loc.column is zero-indexed
  143. return line.slice(0, comment.loc.start.column).replace(/\s+$/u, '')
  144. }
  145. /**
  146. * Ensure that an array exists at [key] on `object`, and add `value` to it.
  147. *
  148. * @param { { [key: number]: Token[] } } object the object to mutate
  149. * @param {number} key the object's key
  150. * @param {Token} value the value to add
  151. * @returns {void}
  152. * @private
  153. */
  154. function ensureArrayAndPush(object, key, value) {
  155. if (!Array.isArray(object[key])) {
  156. object[key] = []
  157. }
  158. object[key].push(value)
  159. }
  160. /**
  161. * A reducer to group an AST node by line number, both start and end.
  162. *
  163. * @param { { [key: number]: Token[] } } acc the accumulator
  164. * @param {Token} node the AST node in question
  165. * @returns { { [key: number]: Token[] } } the modified accumulator
  166. * @private
  167. */
  168. function groupByLineNumber(acc, node) {
  169. for (let i = node.loc.start.line; i <= node.loc.end.line; ++i) {
  170. ensureArrayAndPush(acc, i, node)
  171. }
  172. return acc
  173. }
  174. // ------------------------------------------------------------------------------
  175. // Rule Definition
  176. // ------------------------------------------------------------------------------
  177. module.exports = {
  178. meta: {
  179. type: 'layout',
  180. docs: {
  181. description: 'enforce a maximum line length in `.vue` files',
  182. categories: undefined,
  183. url: 'https://eslint.vuejs.org/rules/max-len.html',
  184. extensionRule: true,
  185. coreRuleUrl: 'https://eslint.org/docs/rules/max-len'
  186. },
  187. schema: [
  188. OPTIONS_OR_INTEGER_SCHEMA,
  189. OPTIONS_OR_INTEGER_SCHEMA,
  190. OPTIONS_SCHEMA
  191. ],
  192. messages: {
  193. max: 'This line has a length of {{lineLength}}. Maximum allowed is {{maxLength}}.',
  194. maxComment:
  195. 'This line has a comment length of {{lineLength}}. Maximum allowed is {{maxCommentLength}}.'
  196. }
  197. },
  198. /**
  199. * @param {RuleContext} context - The rule context.
  200. * @returns {RuleListener} AST event handlers.
  201. */
  202. create(context) {
  203. /*
  204. * Inspired by http://tools.ietf.org/html/rfc3986#appendix-B, however:
  205. * - They're matching an entire string that we know is a URI
  206. * - We're matching part of a string where we think there *might* be a URL
  207. * - We're only concerned about URLs, as picking out any URI would cause
  208. * too many false positives
  209. * - We don't care about matching the entire URL, any small segment is fine
  210. */
  211. const URL_REGEXP = /[^:/?#]:\/\/[^?#]/u
  212. const sourceCode = context.getSourceCode()
  213. /** @type {Token[]} */
  214. const tokens = []
  215. /** @type {(HTMLComment | HTMLBogusComment | Comment)[]} */
  216. const comments = []
  217. /** @type {VLiteral[]} */
  218. const htmlAttributeValues = []
  219. // The options object must be the last option specified…
  220. const options = Object.assign(
  221. {},
  222. context.options[context.options.length - 1]
  223. )
  224. // …but max code length…
  225. if (typeof context.options[0] === 'number') {
  226. options.code = context.options[0]
  227. }
  228. // …and tabWidth can be optionally specified directly as integers.
  229. if (typeof context.options[1] === 'number') {
  230. options.tabWidth = context.options[1]
  231. }
  232. /** @type {number} */
  233. const scriptMaxLength = typeof options.code === 'number' ? options.code : 80
  234. /** @type {number} */
  235. const tabWidth = typeof options.tabWidth === 'number' ? options.tabWidth : 2 // default value of `vue/html-indent`
  236. /** @type {number} */
  237. const templateMaxLength =
  238. typeof options.template === 'number' ? options.template : scriptMaxLength
  239. const ignoreComments = !!options.ignoreComments
  240. const ignoreStrings = !!options.ignoreStrings
  241. const ignoreTemplateLiterals = !!options.ignoreTemplateLiterals
  242. const ignoreRegExpLiterals = !!options.ignoreRegExpLiterals
  243. const ignoreTrailingComments =
  244. !!options.ignoreTrailingComments || !!options.ignoreComments
  245. const ignoreUrls = !!options.ignoreUrls
  246. const ignoreHTMLAttributeValues = !!options.ignoreHTMLAttributeValues
  247. const ignoreHTMLTextContents = !!options.ignoreHTMLTextContents
  248. /** @type {number} */
  249. const maxCommentLength = options.comments
  250. /** @type {RegExp} */
  251. let ignorePattern = options.ignorePattern || null
  252. if (ignorePattern) {
  253. ignorePattern = new RegExp(ignorePattern, 'u')
  254. }
  255. // --------------------------------------------------------------------------
  256. // Helpers
  257. // --------------------------------------------------------------------------
  258. /**
  259. * Retrieves an array containing all strings (" or ') in the source code.
  260. *
  261. * @returns {Token[]} An array of string nodes.
  262. */
  263. function getAllStrings() {
  264. return tokens.filter(
  265. (token) =>
  266. token.type === 'String' ||
  267. (token.type === 'JSXText' &&
  268. sourceCode.getNodeByRangeIndex(token.range[0] - 1).type ===
  269. 'JSXAttribute')
  270. )
  271. }
  272. /**
  273. * Retrieves an array containing all template literals in the source code.
  274. *
  275. * @returns {Token[]} An array of template literal nodes.
  276. */
  277. function getAllTemplateLiterals() {
  278. return tokens.filter((token) => token.type === 'Template')
  279. }
  280. /**
  281. * Retrieves an array containing all RegExp literals in the source code.
  282. *
  283. * @returns {Token[]} An array of RegExp literal nodes.
  284. */
  285. function getAllRegExpLiterals() {
  286. return tokens.filter((token) => token.type === 'RegularExpression')
  287. }
  288. /**
  289. * Retrieves an array containing all HTML texts in the source code.
  290. *
  291. * @returns {Token[]} An array of HTML text nodes.
  292. */
  293. function getAllHTMLTextContents() {
  294. return tokens.filter((token) => token.type === 'HTMLText')
  295. }
  296. /**
  297. * Check the program for max length
  298. * @param {Program} node Node to examine
  299. * @returns {void}
  300. * @private
  301. */
  302. function checkProgramForMaxLength(node) {
  303. const programNode = node
  304. const templateBody = node.templateBody
  305. // setup tokens
  306. const scriptTokens = sourceCode.ast.tokens
  307. const scriptComments = sourceCode.getAllComments()
  308. if (context.parserServices.getTemplateBodyTokenStore && templateBody) {
  309. const tokenStore = context.parserServices.getTemplateBodyTokenStore()
  310. const templateTokens = tokenStore.getTokens(templateBody, {
  311. includeComments: true
  312. })
  313. if (templateBody.range[0] < programNode.range[0]) {
  314. tokens.push(...templateTokens, ...scriptTokens)
  315. } else {
  316. tokens.push(...scriptTokens, ...templateTokens)
  317. }
  318. } else {
  319. tokens.push(...scriptTokens)
  320. }
  321. if (ignoreComments || maxCommentLength || ignoreTrailingComments) {
  322. // list of comments to ignore
  323. if (templateBody) {
  324. if (templateBody.range[0] < programNode.range[0]) {
  325. comments.push(...templateBody.comments, ...scriptComments)
  326. } else {
  327. comments.push(...scriptComments, ...templateBody.comments)
  328. }
  329. } else {
  330. comments.push(...scriptComments)
  331. }
  332. }
  333. /** @type {Range} */
  334. let scriptLinesRange
  335. if (scriptTokens.length) {
  336. if (scriptComments.length) {
  337. scriptLinesRange = [
  338. Math.min(
  339. scriptTokens[0].loc.start.line,
  340. scriptComments[0].loc.start.line
  341. ),
  342. Math.max(
  343. scriptTokens[scriptTokens.length - 1].loc.end.line,
  344. scriptComments[scriptComments.length - 1].loc.end.line
  345. )
  346. ]
  347. } else {
  348. scriptLinesRange = [
  349. scriptTokens[0].loc.start.line,
  350. scriptTokens[scriptTokens.length - 1].loc.end.line
  351. ]
  352. }
  353. } else if (scriptComments.length) {
  354. scriptLinesRange = [
  355. scriptComments[0].loc.start.line,
  356. scriptComments[scriptComments.length - 1].loc.end.line
  357. ]
  358. }
  359. const templateLinesRange = templateBody && [
  360. templateBody.loc.start.line,
  361. templateBody.loc.end.line
  362. ]
  363. // split (honors line-ending)
  364. const lines = sourceCode.lines
  365. const strings = getAllStrings()
  366. const stringsByLine = strings.reduce(groupByLineNumber, {})
  367. const templateLiterals = getAllTemplateLiterals()
  368. const templateLiteralsByLine = templateLiterals.reduce(
  369. groupByLineNumber,
  370. {}
  371. )
  372. const regExpLiterals = getAllRegExpLiterals()
  373. const regExpLiteralsByLine = regExpLiterals.reduce(groupByLineNumber, {})
  374. const htmlAttributeValuesByLine = htmlAttributeValues.reduce(
  375. groupByLineNumber,
  376. {}
  377. )
  378. const htmlTextContents = getAllHTMLTextContents()
  379. const htmlTextContentsByLine = htmlTextContents.reduce(
  380. groupByLineNumber,
  381. {}
  382. )
  383. const commentsByLine = comments.reduce(groupByLineNumber, {})
  384. lines.forEach((line, i) => {
  385. // i is zero-indexed, line numbers are one-indexed
  386. const lineNumber = i + 1
  387. const inScript =
  388. scriptLinesRange &&
  389. scriptLinesRange[0] <= lineNumber &&
  390. lineNumber <= scriptLinesRange[1]
  391. const inTemplate =
  392. templateLinesRange &&
  393. templateLinesRange[0] <= lineNumber &&
  394. lineNumber <= templateLinesRange[1]
  395. // check if line is inside a script or template.
  396. if (!inScript && !inTemplate) {
  397. // out of range.
  398. return
  399. }
  400. const maxLength =
  401. inScript && inTemplate
  402. ? Math.max(scriptMaxLength, templateMaxLength)
  403. : inScript
  404. ? scriptMaxLength
  405. : templateMaxLength
  406. if (
  407. (ignoreStrings && stringsByLine[lineNumber]) ||
  408. (ignoreTemplateLiterals && templateLiteralsByLine[lineNumber]) ||
  409. (ignoreRegExpLiterals && regExpLiteralsByLine[lineNumber]) ||
  410. (ignoreHTMLAttributeValues &&
  411. htmlAttributeValuesByLine[lineNumber]) ||
  412. (ignoreHTMLTextContents && htmlTextContentsByLine[lineNumber])
  413. ) {
  414. // ignore this line
  415. return
  416. }
  417. /*
  418. * if we're checking comment length; we need to know whether this
  419. * line is a comment
  420. */
  421. let lineIsComment = false
  422. let textToMeasure
  423. /*
  424. * comments to check.
  425. */
  426. if (commentsByLine[lineNumber]) {
  427. const commentList = [...commentsByLine[lineNumber]]
  428. let comment = commentList.pop() || null
  429. if (isFullLineComment(line, lineNumber, comment)) {
  430. lineIsComment = true
  431. textToMeasure = line
  432. } else if (
  433. ignoreTrailingComments &&
  434. isTrailingComment(line, lineNumber, comment)
  435. ) {
  436. textToMeasure = stripTrailingComment(line, comment)
  437. // ignore multiple trailing comments in the same line
  438. comment = commentList.pop() || null
  439. while (isTrailingComment(textToMeasure, lineNumber, comment)) {
  440. textToMeasure = stripTrailingComment(textToMeasure, comment)
  441. }
  442. } else {
  443. textToMeasure = line
  444. }
  445. } else {
  446. textToMeasure = line
  447. }
  448. if (
  449. (ignorePattern && ignorePattern.test(textToMeasure)) ||
  450. (ignoreUrls && URL_REGEXP.test(textToMeasure))
  451. ) {
  452. // ignore this line
  453. return
  454. }
  455. const lineLength = computeLineLength(textToMeasure, tabWidth)
  456. const commentLengthApplies = lineIsComment && maxCommentLength
  457. if (lineIsComment && ignoreComments) {
  458. return
  459. }
  460. if (commentLengthApplies) {
  461. if (lineLength > maxCommentLength) {
  462. context.report({
  463. node,
  464. loc: { line: lineNumber, column: 0 },
  465. messageId: 'maxComment',
  466. data: {
  467. lineLength,
  468. maxCommentLength
  469. }
  470. })
  471. }
  472. } else if (lineLength > maxLength) {
  473. context.report({
  474. node,
  475. loc: { line: lineNumber, column: 0 },
  476. messageId: 'max',
  477. data: {
  478. lineLength,
  479. maxLength
  480. }
  481. })
  482. }
  483. })
  484. }
  485. // --------------------------------------------------------------------------
  486. // Public API
  487. // --------------------------------------------------------------------------
  488. return utils.compositingVisitors(
  489. utils.defineTemplateBodyVisitor(context, {
  490. /** @param {VLiteral} node */
  491. 'VAttribute[directive=false] > VLiteral'(node) {
  492. htmlAttributeValues.push(node)
  493. }
  494. }),
  495. {
  496. 'Program:exit'(node) {
  497. checkProgramForMaxLength(node)
  498. }
  499. }
  500. )
  501. }
  502. }