stringFormatter.mjs 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308
  1. import { relative, sep } from 'node:path';
  2. import process from 'node:process';
  3. import { getBorderCharacters, table } from 'table';
  4. import picocolors from 'picocolors';
  5. import stringWidth from 'string-width';
  6. import { assertNumber } from '../utils/validateTypes.mjs';
  7. import calcSeverityCounts from './calcSeverityCounts.mjs';
  8. import isUnicodeSupported from '../utils/isUnicodeSupported.mjs';
  9. import pluralize from '../utils/pluralize.mjs';
  10. import preprocessWarnings from './preprocessWarnings.mjs';
  11. import terminalLink from './terminalLink.mjs';
  12. const { yellow, dim, underline, blue, red, green } = picocolors;
  13. const NON_ASCII_PATTERN = /\P{ASCII}/u;
  14. const MARGIN_WIDTHS = 9;
  15. /**
  16. * @param {string} s
  17. * @returns {string}
  18. */
  19. function identity(s) {
  20. return s;
  21. }
  22. const levelColors = {
  23. info: blue,
  24. warning: yellow,
  25. error: red,
  26. success: identity,
  27. };
  28. const supportsUnicode = isUnicodeSupported();
  29. const symbols = {
  30. info: blue(supportsUnicode ? 'ℹ' : 'i'),
  31. warning: yellow(supportsUnicode ? '⚠' : '‼'),
  32. error: red(supportsUnicode ? '✖' : '×'),
  33. success: green(supportsUnicode ? '✔' : '√'),
  34. };
  35. /**
  36. * @param {import('stylelint').LintResult[]} results
  37. * @returns {string}
  38. */
  39. function deprecationsFormatter(results) {
  40. const allDeprecationWarnings = results.flatMap((result) => result.deprecations || []);
  41. if (allDeprecationWarnings.length === 0) {
  42. return '';
  43. }
  44. const seenText = new Set();
  45. const lines = [];
  46. for (const { text, reference } of allDeprecationWarnings) {
  47. if (seenText.has(text)) continue;
  48. seenText.add(text);
  49. let line = ` ${dim('-')} ${text}`;
  50. if (reference) {
  51. line += dim(` See: ${underline(reference)}`);
  52. }
  53. lines.push(line);
  54. }
  55. return ['', yellow('Deprecation warnings:'), ...lines, ''].join('\n');
  56. }
  57. /**
  58. * @param {import('stylelint').LintResult[]} results
  59. * @returns {string}
  60. */
  61. function invalidOptionsFormatter(results) {
  62. const allInvalidOptionWarnings = results.flatMap((result) =>
  63. (result.invalidOptionWarnings || []).map((warning) => warning.text),
  64. );
  65. const uniqueInvalidOptionWarnings = [...new Set(allInvalidOptionWarnings)];
  66. return uniqueInvalidOptionWarnings.reduce((output, warning) => {
  67. output += red('Invalid Option: ');
  68. output += warning;
  69. return `${output}\n`;
  70. }, '\n');
  71. }
  72. /**
  73. * @param {string} fromValue
  74. * @param {string} cwd
  75. * @returns {string}
  76. */
  77. function logFrom(fromValue, cwd) {
  78. if (fromValue.startsWith('<')) {
  79. return underline(fromValue);
  80. }
  81. const filePath = relative(cwd, fromValue).split(sep).join('/');
  82. return terminalLink(filePath, `file://${fromValue}`);
  83. }
  84. /**
  85. * @param {{[k: number]: number}} columnWidths
  86. * @returns {number}
  87. */
  88. function getMessageWidth(columnWidths) {
  89. const width = columnWidths[3];
  90. assertNumber(width);
  91. if (!process.stdout.isTTY) {
  92. return width;
  93. }
  94. const availableWidth = process.stdout.columns < 80 ? 80 : process.stdout.columns;
  95. const fullWidth = Object.values(columnWidths).reduce((a, b) => a + b);
  96. // If there is no reason to wrap the text, we won't align the last column to the right
  97. if (availableWidth > fullWidth + MARGIN_WIDTHS) {
  98. return width;
  99. }
  100. return availableWidth - (fullWidth - width + MARGIN_WIDTHS);
  101. }
  102. /**
  103. * @param {import('stylelint').Warning[]} messages
  104. * @param {string} source
  105. * @param {string} cwd
  106. * @returns {string}
  107. */
  108. function formatter(messages, source, cwd) {
  109. if (messages.length === 0) return '';
  110. /**
  111. * Create a list of column widths, needed to calculate
  112. * the size of the message column and if needed wrap it.
  113. * @type {{[k: string]: number}}
  114. */
  115. const columnWidths = { 0: 1, 1: 1, 2: 1, 3: 1, 4: 1 };
  116. /**
  117. * @param {[string, string, string, string, string]} columns
  118. * @returns {[string, string, string, string, string]}
  119. */
  120. function calculateWidths(columns) {
  121. for (const [key, value] of Object.entries(columns)) {
  122. const normalisedValue = value ? value.toString() : value;
  123. const width = columnWidths[key];
  124. assertNumber(width);
  125. columnWidths[key] = Math.max(width, stringWidth(normalisedValue));
  126. }
  127. return columns;
  128. }
  129. let output = '\n';
  130. if (source) {
  131. output += `${logFrom(source, cwd)}\n`;
  132. }
  133. /**
  134. * @param {import('stylelint').Warning} message
  135. * @returns {string}
  136. */
  137. function formatMessageText(message) {
  138. let result = message.text;
  139. result = result
  140. // Remove all control characters (newline, tab and etc)
  141. .replace(/[\u0001-\u001A]+/g, ' ') // eslint-disable-line no-control-regex
  142. .replace(/\.$/, '');
  143. const ruleString = ` (${message.rule})`;
  144. if (result.endsWith(ruleString)) {
  145. result = result.slice(0, result.lastIndexOf(ruleString));
  146. }
  147. return result;
  148. }
  149. const cleanedMessages = messages.map((message) => {
  150. const { line, column, severity } = message;
  151. /**
  152. * @type {[string, string, string, string, string]}
  153. */
  154. const row = [
  155. line ? line.toString() : '',
  156. column ? column.toString() : '',
  157. symbols[severity] ? levelColors[severity](symbols[severity]) : severity,
  158. formatMessageText(message),
  159. dim(message.rule || ''),
  160. ];
  161. calculateWidths(row);
  162. return row;
  163. });
  164. const messageWidth = getMessageWidth(columnWidths);
  165. const hasNonAsciiChar = messages.some((msg) => NON_ASCII_PATTERN.test(msg.text));
  166. output += table(cleanedMessages, {
  167. border: getBorderCharacters('void'),
  168. columns: {
  169. 0: { alignment: 'right', width: columnWidths[0], paddingRight: 0, paddingLeft: 2 },
  170. 1: { alignment: 'left', width: columnWidths[1] },
  171. 2: { alignment: 'center', width: columnWidths[2] },
  172. 3: {
  173. alignment: 'left',
  174. width: messageWidth,
  175. wrapWord: messageWidth > 1 && !hasNonAsciiChar,
  176. },
  177. 4: { alignment: 'left', width: columnWidths[4], paddingRight: 0 },
  178. },
  179. drawHorizontalLine: () => false,
  180. })
  181. .split('\n')
  182. .map((el) => el.replace(/(\d+)\s+(\d+)/, (_m, p1, p2) => dim(`${p1}:${p2}`)).trimEnd())
  183. .join('\n');
  184. return output;
  185. }
  186. /**
  187. * @type {import('stylelint').Formatter}
  188. */
  189. export default function stringFormatter(results, returnValue) {
  190. let output = invalidOptionsFormatter(results);
  191. output += deprecationsFormatter(results);
  192. const resultCounts = { error: 0, warning: 0 };
  193. const fixableCounts = { error: 0, warning: 0 };
  194. output = results.reduce((accum, result) => {
  195. preprocessWarnings(result);
  196. accum += formatter(
  197. result.warnings,
  198. result.source || '',
  199. (returnValue && returnValue.cwd) || process.cwd(),
  200. );
  201. for (const warning of result.warnings) {
  202. calcSeverityCounts(warning.severity, resultCounts);
  203. const fixable = returnValue.ruleMetadata?.[warning.rule]?.fixable;
  204. if (fixable === true) {
  205. calcSeverityCounts(warning.severity, fixableCounts);
  206. }
  207. }
  208. return accum;
  209. }, output);
  210. // Ensure consistent padding
  211. output = output.trim();
  212. if (output !== '') {
  213. output = `\n${output}\n`;
  214. const errorCount = resultCounts.error;
  215. const warningCount = resultCounts.warning;
  216. const total = errorCount + warningCount;
  217. if (total > 0) {
  218. const error = red(`${errorCount} ${pluralize('error', errorCount)}`);
  219. const warning = yellow(`${warningCount} ${pluralize('warning', warningCount)}`);
  220. const symbol = errorCount > 0 ? symbols.error : symbols.warning;
  221. output += `\n${symbol} ${total} ${pluralize('problem', total)} (${error}, ${warning})`;
  222. }
  223. const fixErrorCount = fixableCounts.error;
  224. const fixWarningCount = fixableCounts.warning;
  225. if (fixErrorCount > 0 || fixWarningCount > 0) {
  226. let fixErrorText;
  227. let fixWarningText;
  228. if (fixErrorCount > 0) {
  229. fixErrorText = `${fixErrorCount} ${pluralize('error', fixErrorCount)}`;
  230. }
  231. if (fixWarningCount > 0) {
  232. fixWarningText = `${fixWarningCount} ${pluralize('warning', fixWarningCount)}`;
  233. }
  234. const countText = [fixErrorText, fixWarningText].filter(Boolean).join(' and ');
  235. output += `\n ${countText} potentially fixable with the "--fix" option.`;
  236. }
  237. output += '\n\n';
  238. }
  239. return output;
  240. }