blob: c0ab00a5e1994860691972f91cdea71d4e7d53c2 [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
75 const next = comment.next();
76
77 // If any of these conditions are not met, do not merge comments.
78 if (
79 !(
80 isInlineComment(comment) &&
81 isStylelintCommand(comment) &&
82 next &&
83 next.type === 'comment' &&
84 (comment.text.includes('--') || next.text.startsWith('--'))
85 )
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
95 /** @type {PostcssComment} */
96 let current = next;
97
98 while (isInlineComment(current) && !isStylelintCommand(current)) {
99 const currentLine = (current.source && current.source.end && current.source.end.line) || 0;
100
101 if (lastLine + 1 !== currentLine) break;
102
103 fullComment.text += `\n${current.text}`;
104
105 if (fullComment.source && current.source) {
106 fullComment.source.end = current.source.end;
107 }
108
109 inlineEnd = current;
110 // TODO: Issue #4985
111 // eslint-disable-next-line no-shadow
112 const next = current.next();
113
114 if (!next || next.type !== 'comment') break;
115
116 current = next;
117 lastLine = currentLine;
118 }
Tim van der Lippecc71b282021-02-12 15:51:14 +0000119
Tim van der Lippeefb716a2020-12-01 12:54:04 +0000120 checkComment(fullComment);
121 });
Mathias Bynens79e2cf02020-05-29 16:46:17 +0200122
123 return result;
124
125 /**
126 * @param {PostcssComment} comment
127 */
Tim van der Lippeefb716a2020-12-01 12:54:04 +0000128 function isInlineComment(comment) {
129 // We check both here because the Sass parser uses `raws.inline` to indicate
130 // inline comments, while the Less parser uses `inline`.
131 return comment.inline || comment.raws.inline;
132 }
133
134 /**
135 * @param {PostcssComment} comment
136 */
137 function isStylelintCommand(comment) {
138 return comment.text.startsWith(disableCommand) || comment.text.startsWith(enableCommand);
139 }
140
141 /**
142 * @param {PostcssComment} comment
143 */
Mathias Bynens79e2cf02020-05-29 16:46:17 +0200144 function processDisableLineCommand(comment) {
145 if (comment.source && comment.source.start) {
146 const line = comment.source.start.line;
Tim van der Lippeefb716a2020-12-01 12:54:04 +0000147 const description = getDescription(comment.text);
Mathias Bynens79e2cf02020-05-29 16:46:17 +0200148
149 getCommandRules(disableLineCommand, comment.text).forEach((ruleName) => {
Tim van der Lippeefb716a2020-12-01 12:54:04 +0000150 disableLine(comment, line, ruleName, description);
Mathias Bynens79e2cf02020-05-29 16:46:17 +0200151 });
152 }
153 }
154
155 /**
156 * @param {PostcssComment} comment
157 */
158 function processDisableNextLineCommand(comment) {
Tim van der Lippeefb716a2020-12-01 12:54:04 +0000159 if (comment.source && comment.source.end) {
160 const line = comment.source.end.line;
161 const description = getDescription(comment.text);
Mathias Bynens79e2cf02020-05-29 16:46:17 +0200162
163 getCommandRules(disableNextLineCommand, comment.text).forEach((ruleName) => {
Tim van der Lippeefb716a2020-12-01 12:54:04 +0000164 disableLine(comment, line + 1, ruleName, description);
Mathias Bynens79e2cf02020-05-29 16:46:17 +0200165 });
166 }
167 }
168
169 /**
Tim van der Lippeefb716a2020-12-01 12:54:04 +0000170 * @param {PostcssComment} comment
Mathias Bynens79e2cf02020-05-29 16:46:17 +0200171 * @param {number} line
172 * @param {string} ruleName
Tim van der Lippeefb716a2020-12-01 12:54:04 +0000173 * @param {string|undefined} description
Mathias Bynens79e2cf02020-05-29 16:46:17 +0200174 */
Tim van der Lippeefb716a2020-12-01 12:54:04 +0000175 function disableLine(comment, line, ruleName, description) {
Mathias Bynens79e2cf02020-05-29 16:46:17 +0200176 if (ruleIsDisabled(ALL_RULES)) {
177 throw comment.error('All rules have already been disabled', {
178 plugin: 'stylelint',
179 });
180 }
181
182 if (ruleName === ALL_RULES) {
183 Object.keys(disabledRanges).forEach((disabledRuleName) => {
184 if (ruleIsDisabled(disabledRuleName)) return;
185
186 const strict = disabledRuleName === ALL_RULES;
187
Tim van der Lippeefb716a2020-12-01 12:54:04 +0000188 startDisabledRange(comment, line, disabledRuleName, strict, description);
Mathias Bynens79e2cf02020-05-29 16:46:17 +0200189 endDisabledRange(line, disabledRuleName, strict);
190 });
191 } else {
192 if (ruleIsDisabled(ruleName)) {
193 throw comment.error(`"${ruleName}" has already been disabled`, {
194 plugin: 'stylelint',
195 });
196 }
197
Tim van der Lippeefb716a2020-12-01 12:54:04 +0000198 startDisabledRange(comment, line, ruleName, true, description);
Mathias Bynens79e2cf02020-05-29 16:46:17 +0200199 endDisabledRange(line, ruleName, true);
200 }
201 }
202
203 /**
204 * @param {PostcssComment} comment
205 */
206 function processDisableCommand(comment) {
Tim van der Lippeefb716a2020-12-01 12:54:04 +0000207 const description = getDescription(comment.text);
208
Mathias Bynens79e2cf02020-05-29 16:46:17 +0200209 getCommandRules(disableCommand, comment.text).forEach((ruleToDisable) => {
210 const isAllRules = ruleToDisable === ALL_RULES;
211
212 if (ruleIsDisabled(ruleToDisable)) {
213 throw comment.error(
214 isAllRules
215 ? 'All rules have already been disabled'
216 : `"${ruleToDisable}" has already been disabled`,
217 {
218 plugin: 'stylelint',
219 },
220 );
221 }
222
223 if (comment.source && comment.source.start) {
224 const line = comment.source.start.line;
225
226 if (isAllRules) {
227 Object.keys(disabledRanges).forEach((ruleName) => {
Tim van der Lippeefb716a2020-12-01 12:54:04 +0000228 startDisabledRange(comment, line, ruleName, ruleName === ALL_RULES, description);
Mathias Bynens79e2cf02020-05-29 16:46:17 +0200229 });
230 } else {
Tim van der Lippeefb716a2020-12-01 12:54:04 +0000231 startDisabledRange(comment, line, ruleToDisable, true, description);
Mathias Bynens79e2cf02020-05-29 16:46:17 +0200232 }
233 }
234 });
235 }
236
237 /**
238 * @param {PostcssComment} comment
239 */
240 function processEnableCommand(comment) {
241 getCommandRules(enableCommand, comment.text).forEach((ruleToEnable) => {
242 // TODO TYPES
243 // need fallback if endLine will be undefined
244 const endLine = /** @type {number} */ (comment.source &&
245 comment.source.end &&
246 comment.source.end.line);
247
248 if (ruleToEnable === ALL_RULES) {
249 if (
250 Object.values(disabledRanges).every(
251 (ranges) => ranges.length === 0 || typeof ranges[ranges.length - 1].end === 'number',
252 )
253 ) {
254 throw comment.error('No rules have been disabled', {
255 plugin: 'stylelint',
256 });
257 }
258
259 Object.keys(disabledRanges).forEach((ruleName) => {
260 if (!_.get(_.last(disabledRanges[ruleName]), 'end')) {
261 endDisabledRange(endLine, ruleName, ruleName === ALL_RULES);
262 }
263 });
264
265 return;
266 }
267
268 if (ruleIsDisabled(ALL_RULES) && disabledRanges[ruleToEnable] === undefined) {
269 // Get a starting point from the where all rules were disabled
270 if (!disabledRanges[ruleToEnable]) {
Tim van der Lippeefb716a2020-12-01 12:54:04 +0000271 disabledRanges[ruleToEnable] = disabledRanges.all.map(({ start, end, description }) =>
272 createDisableRange(comment, start, false, description, end, false),
Mathias Bynens79e2cf02020-05-29 16:46:17 +0200273 );
274 } else {
275 const range = _.last(disabledRanges[ALL_RULES]);
276
277 if (range) {
278 disabledRanges[ruleToEnable].push({ ...range });
279 }
280 }
281
282 endDisabledRange(endLine, ruleToEnable, true);
283
284 return;
285 }
286
287 if (ruleIsDisabled(ruleToEnable)) {
288 endDisabledRange(endLine, ruleToEnable, true);
289
290 return;
291 }
292
293 throw comment.error(`"${ruleToEnable}" has not been disabled`, {
294 plugin: 'stylelint',
295 });
296 });
297 }
298
299 /**
300 * @param {PostcssComment} comment
301 */
302 function checkComment(comment) {
303 const text = comment.text;
304
305 // Ignore comments that are not relevant commands
306
307 if (text.indexOf(COMMAND_PREFIX) !== 0) {
308 return result;
309 }
310
311 if (text.startsWith(disableLineCommand)) {
312 processDisableLineCommand(comment);
313 } else if (text.startsWith(disableNextLineCommand)) {
314 processDisableNextLineCommand(comment);
315 } else if (text.startsWith(disableCommand)) {
316 processDisableCommand(comment);
317 } else if (text.startsWith(enableCommand)) {
318 processEnableCommand(comment);
319 }
320 }
321
322 /**
323 * @param {string} command
324 * @param {string} fullText
325 * @returns {string[]}
326 */
327 function getCommandRules(command, fullText) {
328 const rules = fullText
329 .slice(command.length)
Tim van der Lippeefb716a2020-12-01 12:54:04 +0000330 .split(/\s-{2,}\s/u)[0] // Allow for description (f.e. /* stylelint-disable a, b -- Description */).
331 .trim()
Mathias Bynens79e2cf02020-05-29 16:46:17 +0200332 .split(',')
333 .filter(Boolean)
334 .map((r) => r.trim());
335
336 if (_.isEmpty(rules)) {
337 return [ALL_RULES];
338 }
339
340 return rules;
341 }
342
343 /**
Tim van der Lippeefb716a2020-12-01 12:54:04 +0000344 * @param {string} fullText
345 * @returns {string|undefined}
346 */
347 function getDescription(fullText) {
348 const descriptionStart = fullText.indexOf('--');
349
350 if (descriptionStart === -1) return;
351
352 return fullText.slice(descriptionStart + 2).trim();
353 }
354
355 /**
356 * @param {PostcssComment} comment
Mathias Bynens79e2cf02020-05-29 16:46:17 +0200357 * @param {number} line
358 * @param {string} ruleName
359 * @param {boolean} strict
Tim van der Lippeefb716a2020-12-01 12:54:04 +0000360 * @param {string|undefined} description
Mathias Bynens79e2cf02020-05-29 16:46:17 +0200361 */
Tim van der Lippeefb716a2020-12-01 12:54:04 +0000362 function startDisabledRange(comment, line, ruleName, strict, description) {
363 const rangeObj = createDisableRange(comment, line, strict, description);
Mathias Bynens79e2cf02020-05-29 16:46:17 +0200364
365 ensureRuleRanges(ruleName);
366 disabledRanges[ruleName].push(rangeObj);
367 }
368
369 /**
370 * @param {number} line
371 * @param {string} ruleName
372 * @param {boolean} strict
373 */
374 function endDisabledRange(line, ruleName, strict) {
375 const lastRangeForRule = _.last(disabledRanges[ruleName]);
376
377 if (!lastRangeForRule) {
378 return;
379 }
380
381 // Add an `end` prop to the last range of that rule
382 lastRangeForRule.end = line;
383 lastRangeForRule.strictEnd = strict;
384 }
385
386 /**
387 * @param {string} ruleName
388 */
389 function ensureRuleRanges(ruleName) {
390 if (!disabledRanges[ruleName]) {
Tim van der Lippeefb716a2020-12-01 12:54:04 +0000391 disabledRanges[ruleName] = disabledRanges.all.map(({ comment, start, end, description }) =>
392 createDisableRange(comment, start, false, description, end, false),
Mathias Bynens79e2cf02020-05-29 16:46:17 +0200393 );
394 }
395 }
396
397 /**
398 * @param {string} ruleName
399 * @returns {boolean}
400 */
401 function ruleIsDisabled(ruleName) {
402 if (disabledRanges[ruleName] === undefined) return false;
403
404 if (_.last(disabledRanges[ruleName]) === undefined) return false;
405
406 if (_.get(_.last(disabledRanges[ruleName]), 'end') === undefined) return true;
407
408 return false;
409 }
410};