max-len.js 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561
  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',
  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:
  194. 'This line has a length of {{lineLength}}. Maximum allowed is {{maxLength}}.',
  195. maxComment:
  196. 'This line has a comment length of {{lineLength}}. Maximum allowed is {{maxCommentLength}}.'
  197. }
  198. },
  199. /**
  200. * @param {RuleContext} context - The rule context.
  201. * @returns {RuleListener} AST event handlers.
  202. */
  203. create(context) {
  204. /*
  205. * Inspired by http://tools.ietf.org/html/rfc3986#appendix-B, however:
  206. * - They're matching an entire string that we know is a URI
  207. * - We're matching part of a string where we think there *might* be a URL
  208. * - We're only concerned about URLs, as picking out any URI would cause
  209. * too many false positives
  210. * - We don't care about matching the entire URL, any small segment is fine
  211. */
  212. const URL_REGEXP = /[^:/?#]:\/\/[^?#]/u
  213. const sourceCode = context.getSourceCode()
  214. /** @type {Token[]} */
  215. const tokens = []
  216. /** @type {(HTMLComment | HTMLBogusComment | Comment)[]} */
  217. const comments = []
  218. /** @type {VLiteral[]} */
  219. const htmlAttributeValues = []
  220. // The options object must be the last option specified…
  221. const options = Object.assign(
  222. {},
  223. context.options[context.options.length - 1]
  224. )
  225. // …but max code length…
  226. if (typeof context.options[0] === 'number') {
  227. options.code = context.options[0]
  228. }
  229. // …and tabWidth can be optionally specified directly as integers.
  230. if (typeof context.options[1] === 'number') {
  231. options.tabWidth = context.options[1]
  232. }
  233. /** @type {number} */
  234. const scriptMaxLength = typeof options.code === 'number' ? options.code : 80
  235. /** @type {number} */
  236. const tabWidth = typeof options.tabWidth === 'number' ? options.tabWidth : 2 // default value of `vue/html-indent`
  237. /** @type {number} */
  238. const templateMaxLength =
  239. typeof options.template === 'number' ? options.template : scriptMaxLength
  240. const ignoreComments = !!options.ignoreComments
  241. const ignoreStrings = !!options.ignoreStrings
  242. const ignoreTemplateLiterals = !!options.ignoreTemplateLiterals
  243. const ignoreRegExpLiterals = !!options.ignoreRegExpLiterals
  244. const ignoreTrailingComments =
  245. !!options.ignoreTrailingComments || !!options.ignoreComments
  246. const ignoreUrls = !!options.ignoreUrls
  247. const ignoreHTMLAttributeValues = !!options.ignoreHTMLAttributeValues
  248. const ignoreHTMLTextContents = !!options.ignoreHTMLTextContents
  249. /** @type {number} */
  250. const maxCommentLength = options.comments
  251. /** @type {RegExp} */
  252. let ignorePattern = options.ignorePattern || null
  253. if (ignorePattern) {
  254. ignorePattern = new RegExp(ignorePattern, 'u')
  255. }
  256. // --------------------------------------------------------------------------
  257. // Helpers
  258. // --------------------------------------------------------------------------
  259. /**
  260. * Retrieves an array containing all strings (" or ') in the source code.
  261. *
  262. * @returns {Token[]} An array of string nodes.
  263. */
  264. function getAllStrings() {
  265. return tokens.filter(
  266. (token) =>
  267. token.type === 'String' ||
  268. (token.type === 'JSXText' &&
  269. sourceCode.getNodeByRangeIndex(token.range[0] - 1).type ===
  270. 'JSXAttribute')
  271. )
  272. }
  273. /**
  274. * Retrieves an array containing all template literals in the source code.
  275. *
  276. * @returns {Token[]} An array of template literal nodes.
  277. */
  278. function getAllTemplateLiterals() {
  279. return tokens.filter((token) => token.type === 'Template')
  280. }
  281. /**
  282. * Retrieves an array containing all RegExp literals in the source code.
  283. *
  284. * @returns {Token[]} An array of RegExp literal nodes.
  285. */
  286. function getAllRegExpLiterals() {
  287. return tokens.filter((token) => token.type === 'RegularExpression')
  288. }
  289. /**
  290. * Retrieves an array containing all HTML texts in the source code.
  291. *
  292. * @returns {Token[]} An array of HTML text nodes.
  293. */
  294. function getAllHTMLTextContents() {
  295. return tokens.filter((token) => token.type === 'HTMLText')
  296. }
  297. /**
  298. * Check the program for max length
  299. * @param {Program} node Node to examine
  300. * @returns {void}
  301. * @private
  302. */
  303. function checkProgramForMaxLength(node) {
  304. const programNode = node
  305. const templateBody = node.templateBody
  306. // setup tokens
  307. const scriptTokens = sourceCode.ast.tokens
  308. const scriptComments = sourceCode.getAllComments()
  309. if (context.parserServices.getTemplateBodyTokenStore && templateBody) {
  310. const tokenStore = context.parserServices.getTemplateBodyTokenStore()
  311. const templateTokens = tokenStore.getTokens(templateBody, {
  312. includeComments: true
  313. })
  314. if (templateBody.range[0] < programNode.range[0]) {
  315. tokens.push(...templateTokens, ...scriptTokens)
  316. } else {
  317. tokens.push(...scriptTokens, ...templateTokens)
  318. }
  319. } else {
  320. tokens.push(...scriptTokens)
  321. }
  322. if (ignoreComments || maxCommentLength || ignoreTrailingComments) {
  323. // list of comments to ignore
  324. if (templateBody) {
  325. if (templateBody.range[0] < programNode.range[0]) {
  326. comments.push(...templateBody.comments, ...scriptComments)
  327. } else {
  328. comments.push(...scriptComments, ...templateBody.comments)
  329. }
  330. } else {
  331. comments.push(...scriptComments)
  332. }
  333. }
  334. /** @type {Range} */
  335. let scriptLinesRange
  336. if (scriptTokens.length) {
  337. if (scriptComments.length) {
  338. scriptLinesRange = [
  339. Math.min(
  340. scriptTokens[0].loc.start.line,
  341. scriptComments[0].loc.start.line
  342. ),
  343. Math.max(
  344. scriptTokens[scriptTokens.length - 1].loc.end.line,
  345. scriptComments[scriptComments.length - 1].loc.end.line
  346. )
  347. ]
  348. } else {
  349. scriptLinesRange = [
  350. scriptTokens[0].loc.start.line,
  351. scriptTokens[scriptTokens.length - 1].loc.end.line
  352. ]
  353. }
  354. } else if (scriptComments.length) {
  355. scriptLinesRange = [
  356. scriptComments[0].loc.start.line,
  357. scriptComments[scriptComments.length - 1].loc.end.line
  358. ]
  359. }
  360. const templateLinesRange = templateBody && [
  361. templateBody.loc.start.line,
  362. templateBody.loc.end.line
  363. ]
  364. // split (honors line-ending)
  365. const lines = sourceCode.lines
  366. const strings = getAllStrings()
  367. const stringsByLine = strings.reduce(groupByLineNumber, {})
  368. const templateLiterals = getAllTemplateLiterals()
  369. const templateLiteralsByLine = templateLiterals.reduce(
  370. groupByLineNumber,
  371. {}
  372. )
  373. const regExpLiterals = getAllRegExpLiterals()
  374. const regExpLiteralsByLine = regExpLiterals.reduce(groupByLineNumber, {})
  375. const htmlAttributeValuesByLine = htmlAttributeValues.reduce(
  376. groupByLineNumber,
  377. {}
  378. )
  379. const htmlTextContents = getAllHTMLTextContents()
  380. const htmlTextContentsByLine = htmlTextContents.reduce(
  381. groupByLineNumber,
  382. {}
  383. )
  384. const commentsByLine = comments.reduce(groupByLineNumber, {})
  385. lines.forEach((line, i) => {
  386. // i is zero-indexed, line numbers are one-indexed
  387. const lineNumber = i + 1
  388. const inScript =
  389. scriptLinesRange &&
  390. scriptLinesRange[0] <= lineNumber &&
  391. lineNumber <= scriptLinesRange[1]
  392. const inTemplate =
  393. templateLinesRange &&
  394. templateLinesRange[0] <= lineNumber &&
  395. lineNumber <= templateLinesRange[1]
  396. // check if line is inside a script or template.
  397. if (!inScript && !inTemplate) {
  398. // out of range.
  399. return
  400. }
  401. const maxLength =
  402. inScript && inTemplate
  403. ? Math.max(scriptMaxLength, templateMaxLength)
  404. : inScript
  405. ? scriptMaxLength
  406. : templateMaxLength
  407. if (
  408. (ignoreStrings && stringsByLine[lineNumber]) ||
  409. (ignoreTemplateLiterals && templateLiteralsByLine[lineNumber]) ||
  410. (ignoreRegExpLiterals && regExpLiteralsByLine[lineNumber]) ||
  411. (ignoreHTMLAttributeValues &&
  412. htmlAttributeValuesByLine[lineNumber]) ||
  413. (ignoreHTMLTextContents && htmlTextContentsByLine[lineNumber])
  414. ) {
  415. // ignore this line
  416. return
  417. }
  418. /*
  419. * if we're checking comment length; we need to know whether this
  420. * line is a comment
  421. */
  422. let lineIsComment = false
  423. let textToMeasure
  424. /*
  425. * comments to check.
  426. */
  427. if (commentsByLine[lineNumber]) {
  428. const commentList = [...commentsByLine[lineNumber]]
  429. let comment = commentList.pop() || null
  430. if (isFullLineComment(line, lineNumber, comment)) {
  431. lineIsComment = true
  432. textToMeasure = line
  433. } else if (
  434. ignoreTrailingComments &&
  435. isTrailingComment(line, lineNumber, comment)
  436. ) {
  437. textToMeasure = stripTrailingComment(line, comment)
  438. // ignore multiple trailing comments in the same line
  439. comment = commentList.pop() || null
  440. while (isTrailingComment(textToMeasure, lineNumber, comment)) {
  441. textToMeasure = stripTrailingComment(textToMeasure, comment)
  442. }
  443. } else {
  444. textToMeasure = line
  445. }
  446. } else {
  447. textToMeasure = line
  448. }
  449. if (
  450. (ignorePattern && ignorePattern.test(textToMeasure)) ||
  451. (ignoreUrls && URL_REGEXP.test(textToMeasure))
  452. ) {
  453. // ignore this line
  454. return
  455. }
  456. const lineLength = computeLineLength(textToMeasure, tabWidth)
  457. const commentLengthApplies = lineIsComment && maxCommentLength
  458. if (lineIsComment && ignoreComments) {
  459. return
  460. }
  461. if (commentLengthApplies) {
  462. if (lineLength > maxCommentLength) {
  463. context.report({
  464. node,
  465. loc: { line: lineNumber, column: 0 },
  466. messageId: 'maxComment',
  467. data: {
  468. lineLength,
  469. maxCommentLength
  470. }
  471. })
  472. }
  473. } else if (lineLength > maxLength) {
  474. context.report({
  475. node,
  476. loc: { line: lineNumber, column: 0 },
  477. messageId: 'max',
  478. data: {
  479. lineLength,
  480. maxLength
  481. }
  482. })
  483. }
  484. })
  485. }
  486. // --------------------------------------------------------------------------
  487. // Public API
  488. // --------------------------------------------------------------------------
  489. return utils.compositingVisitors(
  490. utils.defineTemplateBodyVisitor(context, {
  491. /** @param {VLiteral} node */
  492. 'VAttribute[directive=false] > VLiteral'(node) {
  493. htmlAttributeValues.push(node)
  494. }
  495. }),
  496. {
  497. 'Program:exit'(node) {
  498. checkProgramForMaxLength(node)
  499. }
  500. }
  501. )
  502. }
  503. }