assignDisabledRanges.js 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379
  1. 'use strict';
  2. const _ = require('lodash');
  3. const COMMAND_PREFIX = 'stylelint-';
  4. const disableCommand = `${COMMAND_PREFIX}disable`;
  5. const enableCommand = `${COMMAND_PREFIX}enable`;
  6. const disableLineCommand = `${COMMAND_PREFIX}disable-line`;
  7. const disableNextLineCommand = `${COMMAND_PREFIX}disable-next-line`;
  8. const ALL_RULES = 'all';
  9. /** @typedef {import('postcss/lib/comment')} PostcssComment */
  10. /** @typedef {import('postcss').Root} PostcssRoot */
  11. /** @typedef {import('stylelint').PostcssResult} PostcssResult */
  12. /** @typedef {import('stylelint').DisabledRangeObject} DisabledRangeObject */
  13. /** @typedef {import('stylelint').DisabledRange} DisabledRange */
  14. /**
  15. * @param {number} start
  16. * @param {boolean} strictStart
  17. * @param {string|undefined} description
  18. * @param {number} [end]
  19. * @param {boolean} [strictEnd]
  20. * @returns {DisabledRange}
  21. */
  22. function createDisableRange(start, strictStart, description, end, strictEnd) {
  23. return {
  24. start,
  25. end: end || undefined,
  26. strictStart,
  27. strictEnd: typeof strictEnd === 'boolean' ? strictEnd : undefined,
  28. description,
  29. };
  30. }
  31. /**
  32. * Run it like a plugin ...
  33. * @param {PostcssRoot} root
  34. * @param {PostcssResult} result
  35. * @returns {PostcssResult}
  36. */
  37. module.exports = function (root, result) {
  38. result.stylelint = result.stylelint || {
  39. disabledRanges: {},
  40. ruleSeverities: {},
  41. customMessages: {},
  42. };
  43. /**
  44. * Most of the functions below work via side effects mutating this object
  45. * @type {DisabledRangeObject}
  46. */
  47. const disabledRanges = {
  48. all: [],
  49. };
  50. result.stylelint.disabledRanges = disabledRanges;
  51. // Work around postcss/postcss-scss#109 by merging adjacent `//` comments
  52. // into a single node before passing to `checkComment`.
  53. /** @type {PostcssComment?} */
  54. let inlineEnd;
  55. root.walkComments((/** @type {PostcssComment} */ comment) => {
  56. if (inlineEnd) {
  57. // Ignore comments already processed by grouping with a previous one.
  58. if (inlineEnd === comment) inlineEnd = null;
  59. } else if (isInlineComment(comment)) {
  60. const fullComment = comment.clone();
  61. let next = comment.next();
  62. let lastLine = (comment.source && comment.source.end && comment.source.end.line) || 0;
  63. while (next && next.type === 'comment') {
  64. /** @type {PostcssComment} */
  65. const current = next;
  66. if (!isInlineComment(current)) break;
  67. const currentLine = (current.source && current.source.end && current.source.end.line) || 0;
  68. if (lastLine + 1 !== currentLine) break;
  69. fullComment.text += `\n${current.text}`;
  70. if (fullComment.source && current.source) {
  71. fullComment.source.end = current.source.end;
  72. }
  73. inlineEnd = current;
  74. next = current.next();
  75. lastLine = currentLine;
  76. }
  77. checkComment(fullComment);
  78. } else {
  79. checkComment(comment);
  80. }
  81. });
  82. return result;
  83. /**
  84. * @param {PostcssComment} comment
  85. */
  86. function isInlineComment(comment) {
  87. // We check both here because the Sass parser uses `raws.inline` to indicate
  88. // inline comments, while the Less parser uses `inline`.
  89. return comment.inline || comment.raws.inline;
  90. }
  91. /**
  92. * @param {PostcssComment} comment
  93. */
  94. function processDisableLineCommand(comment) {
  95. if (comment.source && comment.source.start) {
  96. const line = comment.source.start.line;
  97. const description = getDescription(comment.text);
  98. getCommandRules(disableLineCommand, comment.text).forEach((ruleName) => {
  99. disableLine(line, ruleName, comment, description);
  100. });
  101. }
  102. }
  103. /**
  104. * @param {PostcssComment} comment
  105. */
  106. function processDisableNextLineCommand(comment) {
  107. if (comment.source && comment.source.end) {
  108. const line = comment.source.end.line;
  109. const description = getDescription(comment.text);
  110. getCommandRules(disableNextLineCommand, comment.text).forEach((ruleName) => {
  111. disableLine(line + 1, ruleName, comment, description);
  112. });
  113. }
  114. }
  115. /**
  116. * @param {number} line
  117. * @param {string} ruleName
  118. * @param {PostcssComment} comment
  119. * @param {string|undefined} description
  120. */
  121. function disableLine(line, ruleName, comment, description) {
  122. if (ruleIsDisabled(ALL_RULES)) {
  123. throw comment.error('All rules have already been disabled', {
  124. plugin: 'stylelint',
  125. });
  126. }
  127. if (ruleName === ALL_RULES) {
  128. Object.keys(disabledRanges).forEach((disabledRuleName) => {
  129. if (ruleIsDisabled(disabledRuleName)) return;
  130. const strict = disabledRuleName === ALL_RULES;
  131. startDisabledRange(line, disabledRuleName, strict, description);
  132. endDisabledRange(line, disabledRuleName, strict);
  133. });
  134. } else {
  135. if (ruleIsDisabled(ruleName)) {
  136. throw comment.error(`"${ruleName}" has already been disabled`, {
  137. plugin: 'stylelint',
  138. });
  139. }
  140. startDisabledRange(line, ruleName, true, description);
  141. endDisabledRange(line, ruleName, true);
  142. }
  143. }
  144. /**
  145. * @param {PostcssComment} comment
  146. */
  147. function processDisableCommand(comment) {
  148. const description = getDescription(comment.text);
  149. getCommandRules(disableCommand, comment.text).forEach((ruleToDisable) => {
  150. const isAllRules = ruleToDisable === ALL_RULES;
  151. if (ruleIsDisabled(ruleToDisable)) {
  152. throw comment.error(
  153. isAllRules
  154. ? 'All rules have already been disabled'
  155. : `"${ruleToDisable}" has already been disabled`,
  156. {
  157. plugin: 'stylelint',
  158. },
  159. );
  160. }
  161. if (comment.source && comment.source.start) {
  162. const line = comment.source.start.line;
  163. if (isAllRules) {
  164. Object.keys(disabledRanges).forEach((ruleName) => {
  165. startDisabledRange(line, ruleName, ruleName === ALL_RULES, description);
  166. });
  167. } else {
  168. startDisabledRange(line, ruleToDisable, true, description);
  169. }
  170. }
  171. });
  172. }
  173. /**
  174. * @param {PostcssComment} comment
  175. */
  176. function processEnableCommand(comment) {
  177. getCommandRules(enableCommand, comment.text).forEach((ruleToEnable) => {
  178. // TODO TYPES
  179. // need fallback if endLine will be undefined
  180. const endLine = /** @type {number} */ (comment.source &&
  181. comment.source.end &&
  182. comment.source.end.line);
  183. if (ruleToEnable === ALL_RULES) {
  184. if (
  185. Object.values(disabledRanges).every(
  186. (ranges) => ranges.length === 0 || typeof ranges[ranges.length - 1].end === 'number',
  187. )
  188. ) {
  189. throw comment.error('No rules have been disabled', {
  190. plugin: 'stylelint',
  191. });
  192. }
  193. Object.keys(disabledRanges).forEach((ruleName) => {
  194. if (!_.get(_.last(disabledRanges[ruleName]), 'end')) {
  195. endDisabledRange(endLine, ruleName, ruleName === ALL_RULES);
  196. }
  197. });
  198. return;
  199. }
  200. if (ruleIsDisabled(ALL_RULES) && disabledRanges[ruleToEnable] === undefined) {
  201. // Get a starting point from the where all rules were disabled
  202. if (!disabledRanges[ruleToEnable]) {
  203. disabledRanges[ruleToEnable] = disabledRanges.all.map(({ start, end, description }) =>
  204. createDisableRange(start, false, description, end, false),
  205. );
  206. } else {
  207. const range = _.last(disabledRanges[ALL_RULES]);
  208. if (range) {
  209. disabledRanges[ruleToEnable].push({ ...range });
  210. }
  211. }
  212. endDisabledRange(endLine, ruleToEnable, true);
  213. return;
  214. }
  215. if (ruleIsDisabled(ruleToEnable)) {
  216. endDisabledRange(endLine, ruleToEnable, true);
  217. return;
  218. }
  219. throw comment.error(`"${ruleToEnable}" has not been disabled`, {
  220. plugin: 'stylelint',
  221. });
  222. });
  223. }
  224. /**
  225. * @param {PostcssComment} comment
  226. */
  227. function checkComment(comment) {
  228. const text = comment.text;
  229. // Ignore comments that are not relevant commands
  230. if (text.indexOf(COMMAND_PREFIX) !== 0) {
  231. return result;
  232. }
  233. if (text.startsWith(disableLineCommand)) {
  234. processDisableLineCommand(comment);
  235. } else if (text.startsWith(disableNextLineCommand)) {
  236. processDisableNextLineCommand(comment);
  237. } else if (text.startsWith(disableCommand)) {
  238. processDisableCommand(comment);
  239. } else if (text.startsWith(enableCommand)) {
  240. processEnableCommand(comment);
  241. }
  242. }
  243. /**
  244. * @param {string} command
  245. * @param {string} fullText
  246. * @returns {string[]}
  247. */
  248. function getCommandRules(command, fullText) {
  249. const rules = fullText
  250. .slice(command.length)
  251. .split(/\s-{2,}\s/u)[0] // Allow for description (f.e. /* stylelint-disable a, b -- Description */).
  252. .trim()
  253. .split(',')
  254. .filter(Boolean)
  255. .map((r) => r.trim());
  256. if (_.isEmpty(rules)) {
  257. return [ALL_RULES];
  258. }
  259. return rules;
  260. }
  261. /**
  262. * @param {string} fullText
  263. * @returns {string|undefined}
  264. */
  265. function getDescription(fullText) {
  266. const descriptionStart = fullText.indexOf('--');
  267. if (descriptionStart === -1) return;
  268. return fullText.slice(descriptionStart + 2).trim();
  269. }
  270. /**
  271. * @param {number} line
  272. * @param {string} ruleName
  273. * @param {boolean} strict
  274. * @param {string|undefined} description
  275. */
  276. function startDisabledRange(line, ruleName, strict, description) {
  277. const rangeObj = createDisableRange(line, strict, description);
  278. ensureRuleRanges(ruleName);
  279. disabledRanges[ruleName].push(rangeObj);
  280. }
  281. /**
  282. * @param {number} line
  283. * @param {string} ruleName
  284. * @param {boolean} strict
  285. */
  286. function endDisabledRange(line, ruleName, strict) {
  287. const lastRangeForRule = _.last(disabledRanges[ruleName]);
  288. if (!lastRangeForRule) {
  289. return;
  290. }
  291. // Add an `end` prop to the last range of that rule
  292. lastRangeForRule.end = line;
  293. lastRangeForRule.strictEnd = strict;
  294. }
  295. /**
  296. * @param {string} ruleName
  297. */
  298. function ensureRuleRanges(ruleName) {
  299. if (!disabledRanges[ruleName]) {
  300. disabledRanges[ruleName] = disabledRanges.all.map(({ start, end, description }) =>
  301. createDisableRange(start, false, description, end, false),
  302. );
  303. }
  304. }
  305. /**
  306. * @param {string} ruleName
  307. * @returns {boolean}
  308. */
  309. function ruleIsDisabled(ruleName) {
  310. if (disabledRanges[ruleName] === undefined) return false;
  311. if (_.last(disabledRanges[ruleName]) === undefined) return false;
  312. if (_.get(_.last(disabledRanges[ruleName]), 'end') === undefined) return true;
  313. return false;
  314. }
  315. };