index.js 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202
  1. 'use strict';
  2. const optionsMatches = require('../../utils/optionsMatches');
  3. const report = require('../../utils/report');
  4. const ruleMessages = require('../../utils/ruleMessages');
  5. const styleSearch = require('style-search');
  6. const validateOptions = require('../../utils/validateOptions');
  7. const { isNumber, isRegExp, isString, assert } = require('../../utils/validateTypes');
  8. const ruleName = 'max-line-length';
  9. const messages = ruleMessages(ruleName, {
  10. expected: (max) =>
  11. `Expected line length to be no more than ${max} ${max === 1 ? 'character' : 'characters'}`,
  12. });
  13. const meta = {
  14. url: 'https://stylelint.io/user-guide/rules/max-line-length',
  15. };
  16. /** @type {import('stylelint').Rule} */
  17. const rule = (primary, secondaryOptions, context) => {
  18. return (root, result) => {
  19. const validOptions = validateOptions(
  20. result,
  21. ruleName,
  22. {
  23. actual: primary,
  24. possible: isNumber,
  25. },
  26. {
  27. actual: secondaryOptions,
  28. possible: {
  29. ignore: ['non-comments', 'comments'],
  30. ignorePattern: [isString, isRegExp],
  31. },
  32. optional: true,
  33. },
  34. );
  35. if (!validOptions) {
  36. return;
  37. }
  38. if (root.source == null) {
  39. throw new Error('The root node must have a source');
  40. }
  41. const EXCLUDED_PATTERNS = [
  42. /url\(\s*(\S.*\S)\s*\)/gi, // allow tab, whitespace in url content
  43. /@import\s+(['"].*['"])/gi,
  44. ];
  45. const ignoreNonComments = optionsMatches(secondaryOptions, 'ignore', 'non-comments');
  46. const ignoreComments = optionsMatches(secondaryOptions, 'ignore', 'comments');
  47. const rootString = context.fix ? root.toString() : root.source.input.css;
  48. // Array of skipped sub strings, i.e `url(...)`, `@import "..."`
  49. /** @type {Array<[number, number]>} */
  50. let skippedSubStrings = [];
  51. let skippedSubStringsIndex = 0;
  52. for (const pattern of EXCLUDED_PATTERNS) {
  53. for (const match of rootString.matchAll(pattern)) {
  54. const subMatch = match[1] || '';
  55. const startOfSubString = (match.index || 0) + (match[0] || '').indexOf(subMatch);
  56. skippedSubStrings.push([startOfSubString, startOfSubString + subMatch.length]);
  57. }
  58. }
  59. skippedSubStrings = skippedSubStrings.sort((a, b) => a[0] - b[0]);
  60. // Check first line
  61. checkNewline({ endIndex: 0 });
  62. // Check subsequent lines
  63. styleSearch({ source: rootString, target: ['\n'], comments: 'check' }, (match) =>
  64. checkNewline(match),
  65. );
  66. /**
  67. * @param {number} index
  68. */
  69. function complain(index) {
  70. report({
  71. index,
  72. result,
  73. ruleName,
  74. message: messages.expected(primary),
  75. node: root,
  76. });
  77. }
  78. /**
  79. * @param {number} start
  80. * @param {number} end
  81. */
  82. function tryToPopSubString(start, end) {
  83. const skippedSubString = skippedSubStrings[skippedSubStringsIndex];
  84. assert(skippedSubString);
  85. const [startSubString, endSubString] = skippedSubString;
  86. // Excluded substring does not presented in current line
  87. if (end < startSubString) {
  88. return 0;
  89. }
  90. // Compute excluded substring size regarding to current line indexes
  91. const excluded = Math.min(end, endSubString) - Math.max(start, startSubString);
  92. // Current substring is out of range for next lines
  93. if (endSubString <= end) {
  94. skippedSubStringsIndex++;
  95. }
  96. return excluded;
  97. }
  98. /**
  99. * @param {import('style-search').StyleSearchMatch | { endIndex: number }} match
  100. */
  101. function checkNewline(match) {
  102. let nextNewlineIndex = rootString.indexOf('\n', match.endIndex);
  103. if (rootString[nextNewlineIndex - 1] === '\r') {
  104. nextNewlineIndex -= 1;
  105. }
  106. // Accommodate last line
  107. if (nextNewlineIndex === -1) {
  108. nextNewlineIndex = rootString.length;
  109. }
  110. const rawLineLength = nextNewlineIndex - match.endIndex;
  111. const excludedLength = skippedSubStrings[skippedSubStringsIndex]
  112. ? tryToPopSubString(match.endIndex, nextNewlineIndex)
  113. : 0;
  114. const lineText = rootString.slice(match.endIndex, nextNewlineIndex);
  115. // Case sensitive ignorePattern match
  116. if (optionsMatches(secondaryOptions, 'ignorePattern', lineText)) {
  117. return;
  118. }
  119. // If the line's length is less than or equal to the specified
  120. // max, ignore it ... So anything below is liable to be complained about.
  121. // **Note that the length of any url arguments or import urls
  122. // are excluded from the calculation.**
  123. if (rawLineLength - excludedLength <= primary) {
  124. return;
  125. }
  126. const complaintIndex = nextNewlineIndex - 1;
  127. if (ignoreComments) {
  128. if ('insideComment' in match && match.insideComment) {
  129. return;
  130. }
  131. // This trimming business is to notice when the line starts a
  132. // comment but that comment is indented, e.g.
  133. // /* something here */
  134. const nextTwoChars = rootString.slice(match.endIndex).trim().slice(0, 2);
  135. if (nextTwoChars === '/*' || nextTwoChars === '//') {
  136. return;
  137. }
  138. }
  139. if (ignoreNonComments) {
  140. if ('insideComment' in match && match.insideComment) {
  141. return complain(complaintIndex);
  142. }
  143. // This trimming business is to notice when the line starts a
  144. // comment but that comment is indented, e.g.
  145. // /* something here */
  146. const nextTwoChars = rootString.slice(match.endIndex).trim().slice(0, 2);
  147. if (nextTwoChars !== '/*' && nextTwoChars !== '//') {
  148. return;
  149. }
  150. return complain(complaintIndex);
  151. }
  152. // If there are no spaces besides initial (indent) spaces, ignore it
  153. const lineString = rootString.slice(match.endIndex, nextNewlineIndex);
  154. if (!lineString.replace(/^\s+/, '').includes(' ')) {
  155. return;
  156. }
  157. return complain(complaintIndex);
  158. }
  159. };
  160. };
  161. rule.ruleName = ruleName;
  162. rule.messages = messages;
  163. rule.meta = meta;
  164. module.exports = rule;