blob: 78d6c10fa53dbc9fd10130fe67f644c7eb83a59d [file] [log] [blame]
Mathias Bynens79e2cf02020-05-29 16:46:17 +02001'use strict';
2
3const _ = require('lodash');
4
5const COMMAND_PREFIX = 'stylelint-';
6const disableCommand = `${COMMAND_PREFIX}disable`;
7const enableCommand = `${COMMAND_PREFIX}enable`;
8const disableLineCommand = `${COMMAND_PREFIX}disable-line`;
9const disableNextLineCommand = `${COMMAND_PREFIX}disable-next-line`;
10const ALL_RULES = 'all';
11
Tim van der Lippeefb716a2020-12-01 12:54:04 +000012/** @typedef {import('postcss/lib/comment')} PostcssComment */
Mathias Bynens79e2cf02020-05-29 16:46:17 +020013/** @typedef {import('postcss').Root} PostcssRoot */
14/** @typedef {import('stylelint').PostcssResult} PostcssResult */
15/** @typedef {import('stylelint').DisabledRangeObject} DisabledRangeObject */
16/** @typedef {import('stylelint').DisabledRange} DisabledRange */
17
18/**
Tim van der Lippeefb716a2020-12-01 12:54:04 +000019 * @param {PostcssComment} comment
Mathias Bynens79e2cf02020-05-29 16:46:17 +020020 * @param {number} start
21 * @param {boolean} strictStart
Tim van der Lippeefb716a2020-12-01 12:54:04 +000022 * @param {string|undefined} description
Mathias Bynens79e2cf02020-05-29 16:46:17 +020023 * @param {number} [end]
24 * @param {boolean} [strictEnd]
25 * @returns {DisabledRange}
26 */
Tim van der Lippeefb716a2020-12-01 12:54:04 +000027function createDisableRange(comment, start, strictStart, description, end, strictEnd) {
Mathias Bynens79e2cf02020-05-29 16:46:17 +020028 return {
Tim van der Lippeefb716a2020-12-01 12:54:04 +000029 comment,
Mathias Bynens79e2cf02020-05-29 16:46:17 +020030 start,
31 end: end || undefined,
32 strictStart,
33 strictEnd: typeof strictEnd === 'boolean' ? strictEnd : undefined,
Tim van der Lippeefb716a2020-12-01 12:54:04 +000034 description,
Mathias Bynens79e2cf02020-05-29 16:46:17 +020035 };
36}
37
38/**
39 * Run it like a plugin ...
40 * @param {PostcssRoot} root
41 * @param {PostcssResult} result
42 * @returns {PostcssResult}
43 */
44module.exports = function (root, result) {
45 result.stylelint = result.stylelint || {
46 disabledRanges: {},
47 ruleSeverities: {},
48 customMessages: {},
49 };
50
51 /**
52 * Most of the functions below work via side effects mutating this object
53 * @type {DisabledRangeObject}
54 */
55 const disabledRanges = {
56 all: [],
57 };
58
59 result.stylelint.disabledRanges = disabledRanges;
Tim van der Lippeefb716a2020-12-01 12:54:04 +000060
61 // Work around postcss/postcss-scss#109 by merging adjacent `//` comments
62 // into a single node before passing to `checkComment`.
63
64 /** @type {PostcssComment?} */
65 let inlineEnd;
66
67 root.walkComments((/** @type {PostcssComment} */ comment) => {
68 if (inlineEnd) {
69 // Ignore comments already processed by grouping with a previous one.
70 if (inlineEnd === comment) inlineEnd = null;
71
72 return;
73 }
74
Tim van der Lippe38208902021-05-11 16:37:59 +010075 const nextComment = comment.next();
Tim van der Lippeefb716a2020-12-01 12:54:04 +000076
77 // If any of these conditions are not met, do not merge comments.
78 if (
79 !(
80 isInlineComment(comment) &&
81 isStylelintCommand(comment) &&
Tim van der Lippe38208902021-05-11 16:37:59 +010082 nextComment &&
83 nextComment.type === 'comment' &&
84 (comment.text.includes('--') || nextComment.text.startsWith('--'))
Tim van der Lippeefb716a2020-12-01 12:54:04 +000085 )
86 ) {
87 checkComment(comment);
88
89 return;
90 }
91
92 let lastLine = (comment.source && comment.source.end && comment.source.end.line) || 0;
93 const fullComment = comment.clone();
94
Tim van der Lippe38208902021-05-11 16:37:59 +010095 let current = nextComment;
Tim van der Lippeefb716a2020-12-01 12:54:04 +000096
97 while (isInlineComment(current) && !isStylelintCommand(current)) {
98 const currentLine = (current.source && current.source.end && current.source.end.line) || 0;
99
100 if (lastLine + 1 !== currentLine) break;
101
102 fullComment.text += `\n${current.text}`;
103
104 if (fullComment.source && current.source) {
105 fullComment.source.end = current.source.end;
106 }
107
108 inlineEnd = current;
Tim van der Lippeefb716a2020-12-01 12:54:04 +0000109 const next = current.next();
110
111 if (!next || next.type !== 'comment') break;
112
113 current = next;
114 lastLine = currentLine;
115 }
Tim van der Lippecc71b282021-02-12 15:51:14 +0000116
Tim van der Lippeefb716a2020-12-01 12:54:04 +0000117 checkComment(fullComment);
118 });
Mathias Bynens79e2cf02020-05-29 16:46:17 +0200119
120 return result;
121
122 /**
123 * @param {PostcssComment} comment
124 */
Tim van der Lippeefb716a2020-12-01 12:54:04 +0000125 function isInlineComment(comment) {
126 // We check both here because the Sass parser uses `raws.inline` to indicate
127 // inline comments, while the Less parser uses `inline`.
128 return comment.inline || comment.raws.inline;
129 }
130
131 /**
132 * @param {PostcssComment} comment
133 */
134 function isStylelintCommand(comment) {
135 return comment.text.startsWith(disableCommand) || comment.text.startsWith(enableCommand);
136 }
137
138 /**
139 * @param {PostcssComment} comment
140 */
Mathias Bynens79e2cf02020-05-29 16:46:17 +0200141 function processDisableLineCommand(comment) {
142 if (comment.source && comment.source.start) {
143 const line = comment.source.start.line;
Tim van der Lippeefb716a2020-12-01 12:54:04 +0000144 const description = getDescription(comment.text);
Mathias Bynens79e2cf02020-05-29 16:46:17 +0200145
146 getCommandRules(disableLineCommand, comment.text).forEach((ruleName) => {
Tim van der Lippeefb716a2020-12-01 12:54:04 +0000147 disableLine(comment, line, ruleName, description);
Mathias Bynens79e2cf02020-05-29 16:46:17 +0200148 });
149 }
150 }
151
152 /**
153 * @param {PostcssComment} comment
154 */
155 function processDisableNextLineCommand(comment) {
Tim van der Lippeefb716a2020-12-01 12:54:04 +0000156 if (comment.source && comment.source.end) {
157 const line = comment.source.end.line;
158 const description = getDescription(comment.text);
Mathias Bynens79e2cf02020-05-29 16:46:17 +0200159
160 getCommandRules(disableNextLineCommand, comment.text).forEach((ruleName) => {
Tim van der Lippeefb716a2020-12-01 12:54:04 +0000161 disableLine(comment, line + 1, ruleName, description);
Mathias Bynens79e2cf02020-05-29 16:46:17 +0200162 });
163 }
164 }
165
166 /**
Tim van der Lippeefb716a2020-12-01 12:54:04 +0000167 * @param {PostcssComment} comment
Mathias Bynens79e2cf02020-05-29 16:46:17 +0200168 * @param {number} line
169 * @param {string} ruleName
Tim van der Lippeefb716a2020-12-01 12:54:04 +0000170 * @param {string|undefined} description
Mathias Bynens79e2cf02020-05-29 16:46:17 +0200171 */
Tim van der Lippeefb716a2020-12-01 12:54:04 +0000172 function disableLine(comment, line, ruleName, description) {
Mathias Bynens79e2cf02020-05-29 16:46:17 +0200173 if (ruleIsDisabled(ALL_RULES)) {
174 throw comment.error('All rules have already been disabled', {
175 plugin: 'stylelint',
176 });
177 }
178
179 if (ruleName === ALL_RULES) {
180 Object.keys(disabledRanges).forEach((disabledRuleName) => {
181 if (ruleIsDisabled(disabledRuleName)) return;
182
183 const strict = disabledRuleName === ALL_RULES;
184
Tim van der Lippeefb716a2020-12-01 12:54:04 +0000185 startDisabledRange(comment, line, disabledRuleName, strict, description);
Mathias Bynens79e2cf02020-05-29 16:46:17 +0200186 endDisabledRange(line, disabledRuleName, strict);
187 });
188 } else {
189 if (ruleIsDisabled(ruleName)) {
190 throw comment.error(`"${ruleName}" has already been disabled`, {
191 plugin: 'stylelint',
192 });
193 }
194
Tim van der Lippeefb716a2020-12-01 12:54:04 +0000195 startDisabledRange(comment, line, ruleName, true, description);
Mathias Bynens79e2cf02020-05-29 16:46:17 +0200196 endDisabledRange(line, ruleName, true);
197 }
198 }
199
200 /**
201 * @param {PostcssComment} comment
202 */
203 function processDisableCommand(comment) {
Tim van der Lippeefb716a2020-12-01 12:54:04 +0000204 const description = getDescription(comment.text);
205
Mathias Bynens79e2cf02020-05-29 16:46:17 +0200206 getCommandRules(disableCommand, comment.text).forEach((ruleToDisable) => {
207 const isAllRules = ruleToDisable === ALL_RULES;
208
209 if (ruleIsDisabled(ruleToDisable)) {
210 throw comment.error(
211 isAllRules
212 ? 'All rules have already been disabled'
213 : `"${ruleToDisable}" has already been disabled`,
214 {
215 plugin: 'stylelint',
216 },
217 );
218 }
219
220 if (comment.source && comment.source.start) {
221 const line = comment.source.start.line;
222
223 if (isAllRules) {
224 Object.keys(disabledRanges).forEach((ruleName) => {
Tim van der Lippeefb716a2020-12-01 12:54:04 +0000225 startDisabledRange(comment, line, ruleName, ruleName === ALL_RULES, description);
Mathias Bynens79e2cf02020-05-29 16:46:17 +0200226 });
227 } else {
Tim van der Lippeefb716a2020-12-01 12:54:04 +0000228 startDisabledRange(comment, line, ruleToDisable, true, description);
Mathias Bynens79e2cf02020-05-29 16:46:17 +0200229 }
230 }
231 });
232 }
233
234 /**
235 * @param {PostcssComment} comment
236 */
237 function processEnableCommand(comment) {
238 getCommandRules(enableCommand, comment.text).forEach((ruleToEnable) => {
239 // TODO TYPES
240 // need fallback if endLine will be undefined
241 const endLine = /** @type {number} */ (comment.source &&
242 comment.source.end &&
243 comment.source.end.line);
244
245 if (ruleToEnable === ALL_RULES) {
246 if (
247 Object.values(disabledRanges).every(
248 (ranges) => ranges.length === 0 || typeof ranges[ranges.length - 1].end === 'number',
249 )
250 ) {
251 throw comment.error('No rules have been disabled', {
252 plugin: 'stylelint',
253 });
254 }
255
256 Object.keys(disabledRanges).forEach((ruleName) => {
257 if (!_.get(_.last(disabledRanges[ruleName]), 'end')) {
258 endDisabledRange(endLine, ruleName, ruleName === ALL_RULES);
259 }
260 });
261
262 return;
263 }
264
265 if (ruleIsDisabled(ALL_RULES) && disabledRanges[ruleToEnable] === undefined) {
266 // Get a starting point from the where all rules were disabled
267 if (!disabledRanges[ruleToEnable]) {
Tim van der Lippeefb716a2020-12-01 12:54:04 +0000268 disabledRanges[ruleToEnable] = disabledRanges.all.map(({ start, end, description }) =>
269 createDisableRange(comment, start, false, description, end, false),
Mathias Bynens79e2cf02020-05-29 16:46:17 +0200270 );
271 } else {
272 const range = _.last(disabledRanges[ALL_RULES]);
273
274 if (range) {
275 disabledRanges[ruleToEnable].push({ ...range });
276 }
277 }
278
279 endDisabledRange(endLine, ruleToEnable, true);
280
281 return;
282 }
283
284 if (ruleIsDisabled(ruleToEnable)) {
285 endDisabledRange(endLine, ruleToEnable, true);
286
287 return;
288 }
289
290 throw comment.error(`"${ruleToEnable}" has not been disabled`, {
291 plugin: 'stylelint',
292 });
293 });
294 }
295
296 /**
297 * @param {PostcssComment} comment
298 */
299 function checkComment(comment) {
300 const text = comment.text;
301
302 // Ignore comments that are not relevant commands
303
304 if (text.indexOf(COMMAND_PREFIX) !== 0) {
305 return result;
306 }
307
308 if (text.startsWith(disableLineCommand)) {
309 processDisableLineCommand(comment);
310 } else if (text.startsWith(disableNextLineCommand)) {
311 processDisableNextLineCommand(comment);
312 } else if (text.startsWith(disableCommand)) {
313 processDisableCommand(comment);
314 } else if (text.startsWith(enableCommand)) {
315 processEnableCommand(comment);
316 }
317 }
318
319 /**
320 * @param {string} command
321 * @param {string} fullText
322 * @returns {string[]}
323 */
324 function getCommandRules(command, fullText) {
325 const rules = fullText
326 .slice(command.length)
Tim van der Lippeefb716a2020-12-01 12:54:04 +0000327 .split(/\s-{2,}\s/u)[0] // Allow for description (f.e. /* stylelint-disable a, b -- Description */).
328 .trim()
Mathias Bynens79e2cf02020-05-29 16:46:17 +0200329 .split(',')
330 .filter(Boolean)
331 .map((r) => r.trim());
332
333 if (_.isEmpty(rules)) {
334 return [ALL_RULES];
335 }
336
337 return rules;
338 }
339
340 /**
Tim van der Lippeefb716a2020-12-01 12:54:04 +0000341 * @param {string} fullText
342 * @returns {string|undefined}
343 */
344 function getDescription(fullText) {
345 const descriptionStart = fullText.indexOf('--');
346
347 if (descriptionStart === -1) return;
348
349 return fullText.slice(descriptionStart + 2).trim();
350 }
351
352 /**
353 * @param {PostcssComment} comment
Mathias Bynens79e2cf02020-05-29 16:46:17 +0200354 * @param {number} line
355 * @param {string} ruleName
356 * @param {boolean} strict
Tim van der Lippeefb716a2020-12-01 12:54:04 +0000357 * @param {string|undefined} description
Mathias Bynens79e2cf02020-05-29 16:46:17 +0200358 */
Tim van der Lippeefb716a2020-12-01 12:54:04 +0000359 function startDisabledRange(comment, line, ruleName, strict, description) {
360 const rangeObj = createDisableRange(comment, line, strict, description);
Mathias Bynens79e2cf02020-05-29 16:46:17 +0200361
362 ensureRuleRanges(ruleName);
363 disabledRanges[ruleName].push(rangeObj);
364 }
365
366 /**
367 * @param {number} line
368 * @param {string} ruleName
369 * @param {boolean} strict
370 */
371 function endDisabledRange(line, ruleName, strict) {
372 const lastRangeForRule = _.last(disabledRanges[ruleName]);
373
374 if (!lastRangeForRule) {
375 return;
376 }
377
378 // Add an `end` prop to the last range of that rule
379 lastRangeForRule.end = line;
380 lastRangeForRule.strictEnd = strict;
381 }
382
383 /**
384 * @param {string} ruleName
385 */
386 function ensureRuleRanges(ruleName) {
387 if (!disabledRanges[ruleName]) {
Tim van der Lippeefb716a2020-12-01 12:54:04 +0000388 disabledRanges[ruleName] = disabledRanges.all.map(({ comment, start, end, description }) =>
389 createDisableRange(comment, start, false, description, end, false),
Mathias Bynens79e2cf02020-05-29 16:46:17 +0200390 );
391 }
392 }
393
394 /**
395 * @param {string} ruleName
396 * @returns {boolean}
397 */
398 function ruleIsDisabled(ruleName) {
399 if (disabledRanges[ruleName] === undefined) return false;
400
401 if (_.last(disabledRanges[ruleName]) === undefined) return false;
402
403 if (_.get(_.last(disabledRanges[ruleName]), 'end') === undefined) return true;
404
405 return false;
406 }
407};