assignDisabledRanges.mjs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483
  1. import { RULE_NAME_ALL } from './constants.mjs';
  2. import {
  3. DISABLE_COMMAND,
  4. DISABLE_LINE_COMMAND,
  5. DISABLE_NEXT_LINE_COMMAND,
  6. ENABLE_COMMAND,
  7. extractConfigurationComment,
  8. getConfigurationComment,
  9. isConfigurationComment,
  10. } from './utils/configurationComment.mjs';
  11. import { assert, assertNumber, assertString } from './utils/validateTypes.mjs';
  12. import { isAtRule, isComment, isDeclaration, isRule } from './utils/typeGuards.mjs';
  13. import isStandardSyntaxComment from './utils/isStandardSyntaxComment.mjs';
  14. import { isTokenComment, tokenize } from '@csstools/css-tokenizer';
  15. /** @import {Node as PostcssNode, Comment as PostcssComment, Document as PostcssDocument, Root as PostcssRoot, Source as PostcssSource} from 'postcss' */
  16. /** @import {DisabledRange, DisabledRangeObject, PostcssResult} from 'stylelint' */
  17. /** @typedef {Pick<PostcssSource, 'start' | 'end'>} Source */
  18. /**
  19. * @param {PostcssNode} node
  20. * @param {number} start
  21. * @param {boolean} strictStart
  22. * @param {string|undefined} description
  23. * @param {number} [end]
  24. * @param {boolean} [strictEnd]
  25. * @returns {DisabledRange}
  26. */
  27. function createDisableRange(node, start, strictStart, description, end, strictEnd) {
  28. return {
  29. node,
  30. start,
  31. end: end || undefined,
  32. strictStart,
  33. strictEnd: typeof strictEnd === 'boolean' ? strictEnd : undefined,
  34. description,
  35. };
  36. }
  37. /**
  38. * Run it like a PostCSS plugin
  39. * @param {PostcssRoot | PostcssDocument} root
  40. * @param {PostcssResult} result
  41. * @returns {PostcssResult}
  42. */
  43. export default function assignDisabledRanges(root, result) {
  44. /**
  45. * Most of the functions below work via side effects mutating this object
  46. * @type {DisabledRangeObject}
  47. */
  48. const disabledRanges = result.stylelint.disabledRanges;
  49. /** @type {DisabledRange[]} */
  50. const disabledRangesAll = [];
  51. disabledRanges[RULE_NAME_ALL] = disabledRangesAll;
  52. // Work around postcss/postcss-scss#109 by merging adjacent `//` comments
  53. // into a single node before passing to `checkComment`.
  54. /** @type {PostcssComment?} */
  55. let inlineEnd;
  56. const configurationComment = result.stylelint.config?.configurationComment;
  57. root.walk((node) => {
  58. if (isComment(node)) {
  59. if (inlineEnd) {
  60. // Ignore comments already processed by grouping with a previous one.
  61. if (inlineEnd === node) inlineEnd = null;
  62. return;
  63. }
  64. const nextComment = node.next();
  65. // If any of these conditions are not met, do not merge comments.
  66. if (
  67. !(
  68. !isStandardSyntaxComment(node) &&
  69. isConfigurationComment(node.text, configurationComment) &&
  70. nextComment &&
  71. isComment(nextComment) &&
  72. (node.text.includes('--') || nextComment.text.startsWith('--'))
  73. )
  74. ) {
  75. checkComment(node, node.source, node.text);
  76. return;
  77. }
  78. let lastLine = node.source?.end?.line ?? 0;
  79. const fullComment = node.clone();
  80. let current = nextComment;
  81. while (
  82. !isStandardSyntaxComment(current) &&
  83. !isConfigurationComment(current.text, configurationComment)
  84. ) {
  85. const currentLine = current.source?.end?.line ?? 0;
  86. if (lastLine + 1 !== currentLine) break;
  87. fullComment.text += `\n${current.text}`;
  88. if (fullComment.source && current.source) {
  89. fullComment.source.end = current.source.end;
  90. }
  91. inlineEnd = current;
  92. const next = current.next();
  93. if (!next || !isComment(next)) break;
  94. current = next;
  95. lastLine = currentLine;
  96. }
  97. checkComment(fullComment, fullComment.source, fullComment.text);
  98. }
  99. if (isRule(node)) {
  100. let offset = 0;
  101. const selector = node.raws?.selector?.raw;
  102. checkCommentsInNode(node, selector, offset);
  103. offset += selector?.length ?? node.selector.length;
  104. const between = node.raws?.between;
  105. checkCommentsInNode(node, between, offset);
  106. }
  107. if (isAtRule(node)) {
  108. let offset = node.name.length + 1; // `@` + name
  109. const afterName = node.raws?.afterName;
  110. checkCommentsInNode(node, afterName, offset);
  111. offset += afterName?.length ?? 0;
  112. const params = node.raws?.params?.raw;
  113. checkCommentsInNode(node, params, offset);
  114. offset += params?.length ?? node.params.length;
  115. const between = node.raws?.between;
  116. checkCommentsInNode(node, between, offset);
  117. }
  118. if (isDeclaration(node)) {
  119. let offset = node.prop.length;
  120. const between = node.raws?.between;
  121. checkCommentsInNode(node, between, offset);
  122. offset += between?.length ?? 0;
  123. const value = node.raws?.value?.raw;
  124. checkCommentsInNode(node, value, offset);
  125. }
  126. });
  127. return result;
  128. /**
  129. * @param {PostcssNode} node
  130. * @param {Source} source
  131. * @param {string} text
  132. */
  133. function processDisableLineCommand(node, source, text) {
  134. if (source.start) {
  135. const line = source.start.line;
  136. const description = getDescription(text);
  137. for (const ruleName of getCommandRules(DISABLE_LINE_COMMAND, text)) {
  138. disableLine(node, line, ruleName, description);
  139. }
  140. }
  141. }
  142. /**
  143. * @param {PostcssNode} node
  144. * @param {Source} source
  145. * @param {string} text
  146. */
  147. function processDisableNextLineCommand(node, source, text) {
  148. if (source.end) {
  149. const line = source.end.line;
  150. const description = getDescription(text);
  151. for (const ruleName of getCommandRules(DISABLE_NEXT_LINE_COMMAND, text)) {
  152. disableLine(node, line + 1, ruleName, description);
  153. }
  154. }
  155. }
  156. /**
  157. * @param {PostcssNode} node
  158. * @param {number} line
  159. * @param {string} ruleName
  160. * @param {string|undefined} description
  161. */
  162. function disableLine(node, line, ruleName, description) {
  163. if (ruleIsDisabled(RULE_NAME_ALL)) {
  164. throw node.error('All rules have already been disabled', {
  165. plugin: 'stylelint',
  166. });
  167. }
  168. if (ruleName === RULE_NAME_ALL) {
  169. for (const disabledRuleName of Object.keys(disabledRanges)) {
  170. if (ruleIsDisabled(disabledRuleName)) continue;
  171. const strict = disabledRuleName === RULE_NAME_ALL;
  172. startDisabledRange(node, line, disabledRuleName, strict, description);
  173. endDisabledRange(line, disabledRuleName, strict);
  174. }
  175. } else {
  176. if (ruleIsDisabled(ruleName)) {
  177. throw node.error(`"${ruleName}" has already been disabled`, {
  178. plugin: 'stylelint',
  179. });
  180. }
  181. startDisabledRange(node, line, ruleName, true, description);
  182. endDisabledRange(line, ruleName, true);
  183. }
  184. }
  185. /**
  186. * @param {PostcssNode} node
  187. * @param {Source} source
  188. * @param {string} text
  189. */
  190. function processDisableCommand(node, source, text) {
  191. const description = getDescription(text);
  192. for (const ruleToDisable of getCommandRules(DISABLE_COMMAND, text)) {
  193. const isAllRules = ruleToDisable === RULE_NAME_ALL;
  194. if (ruleIsDisabled(ruleToDisable)) {
  195. throw node.error(
  196. isAllRules
  197. ? 'All rules have already been disabled'
  198. : `"${ruleToDisable}" has already been disabled`,
  199. {
  200. plugin: 'stylelint',
  201. },
  202. );
  203. }
  204. if (source.start) {
  205. const line = source.start.line;
  206. if (isAllRules) {
  207. for (const ruleName of Object.keys(disabledRanges)) {
  208. startDisabledRange(node, line, ruleName, ruleName === RULE_NAME_ALL, description);
  209. }
  210. } else {
  211. startDisabledRange(node, line, ruleToDisable, true, description);
  212. }
  213. }
  214. }
  215. }
  216. /**
  217. * @param {PostcssNode} node
  218. * @param {Source} source
  219. * @param {string} text
  220. */
  221. function processEnableCommand(node, source, text) {
  222. for (const ruleToEnable of getCommandRules(ENABLE_COMMAND, text)) {
  223. // need fallback if endLine will be undefined
  224. const endLine = source.end?.line;
  225. assertNumber(endLine);
  226. if (ruleToEnable === RULE_NAME_ALL) {
  227. if (
  228. Object.values(disabledRanges).every((ranges) => {
  229. if (ranges.length === 0) return true;
  230. const lastRange = ranges[ranges.length - 1];
  231. return lastRange && typeof lastRange.end === 'number';
  232. })
  233. ) {
  234. throw node.error('No rules have been disabled', {
  235. plugin: 'stylelint',
  236. });
  237. }
  238. for (const [ruleName, ranges] of Object.entries(disabledRanges)) {
  239. const lastRange = ranges[ranges.length - 1];
  240. if (!lastRange || !lastRange.end) {
  241. endDisabledRange(endLine, ruleName, ruleName === RULE_NAME_ALL);
  242. }
  243. }
  244. continue;
  245. }
  246. if (ruleIsDisabled(RULE_NAME_ALL) && disabledRanges[ruleToEnable] === undefined) {
  247. // Get a starting point from the where all rules were disabled
  248. disabledRanges[ruleToEnable] = disabledRangesAll.map(({ start, end, description }) =>
  249. createDisableRange(node, start, false, description, end, false),
  250. );
  251. endDisabledRange(endLine, ruleToEnable, true);
  252. continue;
  253. }
  254. if (ruleIsDisabled(ruleToEnable)) {
  255. endDisabledRange(endLine, ruleToEnable, true);
  256. continue;
  257. }
  258. throw node.error(`"${ruleToEnable}" has not been disabled`, {
  259. plugin: 'stylelint',
  260. });
  261. }
  262. }
  263. /**
  264. * @param {PostcssNode} node
  265. * @param {string | undefined} part
  266. * @param {number} offset
  267. */
  268. function checkCommentsInNode(node, part, offset) {
  269. if (!(part && part.includes('/*') && part.includes('*/'))) return;
  270. tokenize({ css: part }).forEach((token) => {
  271. if (!isTokenComment(token)) return;
  272. const [, text, start, end] = token;
  273. const source = node.rangeBy({
  274. index: start + offset,
  275. endIndex: end + 1 + offset,
  276. });
  277. checkComment(node, source, text.slice(2, -2).trim());
  278. });
  279. }
  280. /**
  281. * @param {PostcssNode} node
  282. * @param {Source | undefined} source
  283. * @param {string} text
  284. */
  285. function checkComment(node, source, text) {
  286. if (!source) return;
  287. // Ignore comments that are not relevant commands
  288. if (!isConfigurationComment(text, configurationComment)) {
  289. return;
  290. }
  291. switch (extractConfigurationComment(text, configurationComment)) {
  292. case DISABLE_LINE_COMMAND:
  293. processDisableLineCommand(node, source, text);
  294. break;
  295. case DISABLE_NEXT_LINE_COMMAND:
  296. processDisableNextLineCommand(node, source, text);
  297. break;
  298. case DISABLE_COMMAND:
  299. processDisableCommand(node, source, text);
  300. break;
  301. case ENABLE_COMMAND:
  302. processEnableCommand(node, source, text);
  303. break;
  304. }
  305. }
  306. /**
  307. * @param {string} command
  308. * @param {string} fullText
  309. * @returns {string[]}
  310. */
  311. function getCommandRules(command, fullText) {
  312. // Allow for description (f.e. /* stylelint-disable a, b -- Description */).
  313. const fullCommand = getConfigurationComment(command, configurationComment);
  314. const rulesText = fullText.slice(fullCommand.length).split(/\s-{2,}\s/u)[0];
  315. assertString(rulesText);
  316. const rules = rulesText
  317. .trim()
  318. .split(',')
  319. .filter(Boolean)
  320. .map((r) => r.trim());
  321. if (rules.length === 0) {
  322. return [RULE_NAME_ALL];
  323. }
  324. return rules;
  325. }
  326. /**
  327. * @param {string} fullText
  328. * @returns {string|undefined}
  329. */
  330. function getDescription(fullText) {
  331. const descriptionStart = fullText.indexOf('--');
  332. if (descriptionStart === -1) return;
  333. return fullText.slice(descriptionStart + 2).trim();
  334. }
  335. /**
  336. * @param {PostcssNode} node
  337. * @param {number} line
  338. * @param {string} ruleName
  339. * @param {boolean} strict
  340. * @param {string|undefined} description
  341. */
  342. function startDisabledRange(node, line, ruleName, strict, description) {
  343. const rangeObj = createDisableRange(node, line, strict, description);
  344. ensureRuleRanges(ruleName);
  345. const range = disabledRanges[ruleName];
  346. assert(range);
  347. range.push(rangeObj);
  348. }
  349. /**
  350. * @param {number} line
  351. * @param {string} ruleName
  352. * @param {boolean} strict
  353. */
  354. function endDisabledRange(line, ruleName, strict) {
  355. const ranges = disabledRanges[ruleName];
  356. const lastRangeForRule = ranges ? ranges[ranges.length - 1] : null;
  357. if (!lastRangeForRule) {
  358. return;
  359. }
  360. // Add an `end` prop to the last range of that rule
  361. lastRangeForRule.end = line;
  362. lastRangeForRule.strictEnd = strict;
  363. }
  364. /**
  365. * @param {string} ruleName
  366. */
  367. function ensureRuleRanges(ruleName) {
  368. if (!disabledRanges[ruleName]) {
  369. disabledRanges[ruleName] = disabledRangesAll.map(({ node, start, end, description }) =>
  370. createDisableRange(node, start, false, description, end, false),
  371. );
  372. }
  373. }
  374. /**
  375. * @param {string} ruleName
  376. * @returns {boolean}
  377. */
  378. function ruleIsDisabled(ruleName) {
  379. const ranges = disabledRanges[ruleName];
  380. if (!ranges) return false;
  381. const lastRange = ranges[ranges.length - 1];
  382. if (!lastRange) return false;
  383. if (!lastRange.end) return true;
  384. return false;
  385. }
  386. }