report.mjs 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342
  1. import {
  2. isFunction as isFn,
  3. isNumber,
  4. isPlainObject,
  5. isRange,
  6. isString,
  7. } from '../utils/validateTypes.mjs';
  8. import {
  9. DEFAULT_SEVERITY,
  10. RULE_NAME_ALL,
  11. SEVERITY_ERROR,
  12. SEVERITY_WARNING,
  13. } from '../constants.mjs';
  14. import addSemicolonForEditInfo from './addSemicolonForEditInfo.mjs';
  15. import appendRuleName from './appendRuleName.mjs';
  16. import { emitDeprecationWarning } from './emitWarning.mjs';
  17. import narrowFixRange from './narrowFixRange.mjs';
  18. import rangesOverlap from './rangesOverlap.mjs';
  19. /** @import { Config, DisabledRangeObject, FixCallback, FixObject, Problem, Range, RuleMessage, StylelintPostcssResult, Utils, WarningOptions } from 'stylelint' */
  20. /** @import { Position as PostcssPosition, Node as PostcssNode } from 'postcss' */
  21. /**
  22. * Report a problem.
  23. *
  24. * This function accounts for `disabledRanges` attached to the result.
  25. * That is, if the reported problem is within a disabledRange,
  26. * it is ignored. Otherwise, it is attached to the result as a
  27. * postcss warning.
  28. *
  29. * It also accounts for the rule's severity.
  30. *
  31. * You *must* pass *either* a node or a line number.
  32. *
  33. * @type {Utils['report']}
  34. */
  35. export default function report(problem) {
  36. const { node, index, endIndex, line, start, end, result, ruleName, word, fix, ...rest } = problem;
  37. checkProblemRangeDeprecations(problem);
  38. const {
  39. disabledRanges,
  40. quiet,
  41. ruleSeverities,
  42. config: { defaultSeverity, ignoreDisables } = {},
  43. customMessages: { [ruleName]: message = rest.message },
  44. customUrls: { [ruleName]: customUrl },
  45. ruleMetadata: { [ruleName]: metadata },
  46. } = result.stylelint;
  47. const { messageArgs = [], severity = ruleSeverities[ruleName] } = rest;
  48. const ruleSeverity =
  49. (isFn(severity) ? severity(...messageArgs) : severity) ?? defaultSeverity ?? DEFAULT_SEVERITY;
  50. // In quiet mode, mere warnings are ignored
  51. if (quiet && ruleSeverity === SEVERITY_WARNING) return;
  52. if ((isFn(fix) || isFixObject(fix)) && metadata && !metadata.fixable) {
  53. throw new Error(
  54. `The "${ruleName}" rule requires "meta.fixable" to be truthy if the "fix" callback is being passed`,
  55. );
  56. }
  57. // If a line is not passed, use the node.rangeBy method to get the
  58. // line number that the complaint pertains to
  59. const startLine = line ?? node?.rangeBy({ index, endIndex }).start.line;
  60. if (!startLine) {
  61. throw new Error(
  62. `The "${ruleName}" rule failed to pass either a node or a line number to the \`report()\` function.`,
  63. );
  64. }
  65. if (isFixApplied({ ...problem, line: startLine })) return;
  66. if (isDisabledOnLine(ruleName, startLine, disabledRanges)) {
  67. // Collect disabled warnings
  68. // Used to report `needlessDisables` in subsequent processing.
  69. const disabledWarnings = (result.stylelint.disabledWarnings ||= []);
  70. disabledWarnings.push({
  71. rule: ruleName,
  72. line: startLine,
  73. });
  74. if (!ignoreDisables) return;
  75. }
  76. if (!result.stylelint.stylelintError && ruleSeverity === SEVERITY_ERROR) {
  77. result.stylelint.stylelintError = true;
  78. }
  79. if (!result.stylelint.stylelintWarning && ruleSeverity === SEVERITY_WARNING) {
  80. result.stylelint.stylelintWarning = true;
  81. }
  82. /** @type {WarningOptions} */
  83. const warningProperties = {
  84. severity: ruleSeverity,
  85. rule: ruleName,
  86. };
  87. if (node) {
  88. warningProperties.node = node;
  89. }
  90. if (start) {
  91. warningProperties.start = start;
  92. } else if (isNumber(index)) {
  93. warningProperties.index = index;
  94. }
  95. if (end) {
  96. warningProperties.end = end;
  97. } else if (isNumber(endIndex)) {
  98. warningProperties.endIndex = endIndex;
  99. }
  100. if (word) {
  101. warningProperties.word = word;
  102. }
  103. if (customUrl) {
  104. warningProperties.url = customUrl;
  105. }
  106. warningProperties.fix = computeEditInfo({ ...problem, line: startLine });
  107. const warningMessage = buildWarningMessage(message, messageArgs, ruleName);
  108. result.warn(warningMessage, warningProperties);
  109. }
  110. /**
  111. * @param {Problem} problem
  112. */
  113. function checkProblemRangeDeprecations(problem) {
  114. if (problem.result.stylelint.quietDeprecationWarnings) return;
  115. if (!problem.node) {
  116. emitDeprecationWarning(
  117. `Omitting the \`node\` argument in the \`utils.report()\` function is deprecated ("${problem.ruleName}").`,
  118. 'REPORT_AMBIGUOUS_POSITION',
  119. `Please pass a \`node\` argument in the \`utils.report()\` function of "${problem.ruleName}".`,
  120. );
  121. }
  122. if (!isRange(problem) && ('start' in problem || 'end' in problem)) {
  123. emitDeprecationWarning(
  124. `Partial position information in the \`utils.report()\` function is deprecated ("${problem.ruleName}").`,
  125. 'REPORT_AMBIGUOUS_POSITION',
  126. `Please pass both a valid \`start\` and \`end\` argument in the \`utils.report()\` function of "${problem.ruleName}".`,
  127. );
  128. }
  129. if (!hasIndices(problem) && ('index' in problem || 'endIndex' in problem)) {
  130. emitDeprecationWarning(
  131. `Partial position information in the \`utils.report()\` function is deprecated ("${problem.ruleName}").`,
  132. 'REPORT_AMBIGUOUS_POSITION',
  133. `Please pass both \`index\` and \`endIndex\` as arguments in the \`utils.report()\` function of "${problem.ruleName}".`,
  134. );
  135. }
  136. if ('line' in problem) {
  137. emitDeprecationWarning(
  138. `Providing the \`line\` argument in the \`utils.report()\` function is deprecated ("${problem.ruleName}").`,
  139. 'REPORT_AMBIGUOUS_POSITION',
  140. `Please pass both \`index\` and \`endIndex\` as arguments in the \`utils.report()\` function of "${problem.ruleName}" instead.`,
  141. );
  142. }
  143. }
  144. /**
  145. * @param {RuleMessage} message
  146. * @param {NonNullable<Problem['messageArgs']>} messageArgs
  147. * @param {string} ruleName
  148. * @returns {string}
  149. */
  150. function buildWarningMessage(message, messageArgs, ruleName) {
  151. return appendRuleName(
  152. isString(message) ? printfLike(message, ...messageArgs) : message(...messageArgs),
  153. ruleName,
  154. );
  155. }
  156. /**
  157. * @param {string} format
  158. * @param {Array<unknown>} args
  159. * @returns {string}
  160. */
  161. function printfLike(format, ...args) {
  162. return args.reduce((/** @type {string} */ result, arg) => {
  163. return result.replace(/%[ds]/, String(arg));
  164. }, format);
  165. }
  166. /**
  167. * Check whether a rule is disabled for a given line
  168. * @param {string} ruleName
  169. * @param {number} startLine
  170. * @param {DisabledRangeObject} disabledRanges
  171. */
  172. function isDisabledOnLine(ruleName, startLine, disabledRanges) {
  173. const ranges = disabledRanges[ruleName] ?? disabledRanges[RULE_NAME_ALL] ?? [];
  174. for (const range of ranges) {
  175. if (
  176. // If the problem is within a disabledRange,
  177. // and that disabledRange's rules include this one
  178. range.start <= startLine &&
  179. (range.end === undefined || range.end >= startLine) &&
  180. /** @todo populate rules in assignDisabledRanges util */
  181. (!range.rules || range.rules.includes(ruleName))
  182. ) {
  183. return true;
  184. }
  185. }
  186. return false;
  187. }
  188. /**
  189. * @param {Problem & { line: number }} problem
  190. * @returns {boolean}
  191. */
  192. function isFixApplied({ fix, line, result: { stylelint }, ruleName }) {
  193. if (!fix) return false;
  194. const { disabledRanges, config = {}, fixersData } = stylelint;
  195. if (!config.fix) return false;
  196. if (isFixDisabled(line, ruleName, config, disabledRanges)) return false;
  197. const apply = isFixObject(fix) ? fix.apply : fix;
  198. if (!isFn(apply)) return false;
  199. apply();
  200. incrementFixCounter({ fixersData, ruleName });
  201. return true;
  202. }
  203. /**
  204. * @param {Problem & { line: number }} problem
  205. * @returns {{range: [number, number], text: string} | undefined}
  206. */
  207. function computeEditInfo({ fix, line, result, ruleName }) {
  208. if (!fix) return;
  209. const { disabledRanges, config = {}, rangesOfComputedEditInfos } = result.stylelint;
  210. if (!config.computeEditInfo || config.fix) return;
  211. if (isFixDisabled(line, ruleName, config, disabledRanges)) return;
  212. if (!isFixObject(fix) || !fix.apply || !fix.node) return;
  213. const { apply, node } = fix;
  214. if (!isNumber(node.source?.start?.offset) || !isNumber(node.source?.end?.offset)) return;
  215. /** @type [number, number] */
  216. const fixedNodeRange = [node.source.start.offset, node.source.end.offset];
  217. // When recording edit info we want to ensure that there is no overlap with any other fix.
  218. // We only record the first fix for each node.
  219. if (rangesOfComputedEditInfos.some((range) => rangesOverlap(range, fixedNodeRange))) {
  220. return;
  221. }
  222. // Apply the fix
  223. apply();
  224. let fixData = { range: fixedNodeRange, text: node.toString(result.opts?.syntax) };
  225. fixData = addSemicolonForEditInfo(node, fixData);
  226. // Compute the smallest range and text of the fix
  227. fixData = narrowFixRange(node, fixData);
  228. // Mark the fixed range as mutated
  229. rangesOfComputedEditInfos.push(fixData.range);
  230. return fixData;
  231. }
  232. /**
  233. * @param {number} line
  234. * @param {string} ruleName
  235. * @param {Config} config
  236. * @param {DisabledRangeObject} disabledRanges
  237. * @returns {boolean}
  238. */
  239. function isFixDisabled(line, ruleName, config, disabledRanges) {
  240. if (config.rules?.[ruleName][1]?.disableFix) return true;
  241. if (!config.ignoreDisables && isDisabledOnLine(ruleName, line, disabledRanges)) return true;
  242. return false;
  243. }
  244. /**
  245. * @param {object} o
  246. * @param {StylelintPostcssResult['fixersData']} o.fixersData
  247. * @param {string} o.ruleName
  248. */
  249. function incrementFixCounter({ fixersData, ruleName }) {
  250. fixersData[ruleName] ??= 0;
  251. fixersData[ruleName]++;
  252. }
  253. /**
  254. * @param {unknown} value
  255. * @returns {value is { index: number, endIndex: number }}
  256. */
  257. function hasIndices(value) {
  258. if (!isPlainObject(value)) return false;
  259. if (!isNumber(value.index)) return false;
  260. if (!isNumber(value.endIndex)) return false;
  261. return true;
  262. }
  263. /**
  264. * @param {unknown} value
  265. * @returns {value is FixObject}
  266. */
  267. function isFixObject(value) {
  268. if (!isPlainObject(value)) return false;
  269. if (!value.node) return false;
  270. if (!isFn(value.apply)) return false;
  271. return true;
  272. }