index.js 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329
  1. 'use strict';
  2. const valueParser = require('postcss-value-parser');
  3. const declarationValueIndex = require('../../utils/declarationValueIndex');
  4. const getDeclarationValue = require('../../utils/getDeclarationValue');
  5. const report = require('../../utils/report');
  6. const ruleMessages = require('../../utils/ruleMessages');
  7. const setDeclarationValue = require('../../utils/setDeclarationValue');
  8. const validateOptions = require('../../utils/validateOptions');
  9. const ruleName = 'function-calc-no-unspaced-operator';
  10. const messages = ruleMessages(ruleName, {
  11. expectedBefore: (operator) => `Expected single space before "${operator}" operator`,
  12. expectedAfter: (operator) => `Expected single space after "${operator}" operator`,
  13. expectedOperatorBeforeSign: (operator) => `Expected an operator before sign "${operator}"`,
  14. });
  15. const meta = {
  16. url: 'https://stylelint.io/user-guide/rules/list/function-calc-no-unspaced-operator',
  17. };
  18. const OPERATORS = new Set(['*', '/', '+', '-']);
  19. const OPERATOR_REGEX = /[*/+-]/;
  20. /** @type {import('stylelint').Rule} */
  21. const rule = (primary, _secondaryOptions, context) => {
  22. return (root, result) => {
  23. const validOptions = validateOptions(result, ruleName, { actual: primary });
  24. if (!validOptions) return;
  25. /**
  26. * @param {string} message
  27. * @param {import('postcss').Node} node
  28. * @param {number} index
  29. */
  30. function complain(message, node, index) {
  31. report({ message, node, index, result, ruleName });
  32. }
  33. root.walkDecls((decl) => {
  34. let needsFix = false;
  35. const valueIndex = declarationValueIndex(decl);
  36. const parsedValue = valueParser(getDeclarationValue(decl));
  37. /**
  38. * @param {import('postcss-value-parser').Node[]} nodes
  39. * @param {number} operatorIndex
  40. * @param {-1 | 1} direction
  41. */
  42. function checkAroundOperator(nodes, operatorIndex, direction) {
  43. const isBeforeOp = direction === -1;
  44. const currentNode = nodes[operatorIndex + direction];
  45. const operator = nodes[operatorIndex].value;
  46. const operatorSourceIndex = nodes[operatorIndex].sourceIndex;
  47. if (currentNode && !isSingleSpace(currentNode)) {
  48. if (currentNode.type === 'word') {
  49. if (isBeforeOp) {
  50. const lastChar = currentNode.value.slice(-1);
  51. if (OPERATORS.has(lastChar)) {
  52. if (context.fix) {
  53. currentNode.value = `${currentNode.value.slice(0, -1)} ${lastChar}`;
  54. return true;
  55. }
  56. complain(messages.expectedOperatorBeforeSign(operator), decl, operatorSourceIndex);
  57. return true;
  58. }
  59. } else {
  60. const firstChar = currentNode.value.slice(0, 1);
  61. if (OPERATORS.has(firstChar)) {
  62. if (context.fix) {
  63. currentNode.value = `${firstChar} ${currentNode.value.slice(1)}`;
  64. return true;
  65. }
  66. complain(messages.expectedAfter(operator), decl, operatorSourceIndex);
  67. return true;
  68. }
  69. }
  70. if (context.fix) {
  71. needsFix = true;
  72. currentNode.value = isBeforeOp ? `${currentNode.value} ` : ` ${currentNode.value}`;
  73. return true;
  74. }
  75. complain(
  76. isBeforeOp ? messages.expectedBefore(operator) : messages.expectedAfter(operator),
  77. decl,
  78. valueIndex + operatorSourceIndex,
  79. );
  80. return true;
  81. }
  82. if (currentNode.type === 'space') {
  83. const indexOfFirstNewLine = currentNode.value.search(/(\n|\r\n)/);
  84. if (indexOfFirstNewLine === 0) return;
  85. if (context.fix) {
  86. needsFix = true;
  87. currentNode.value =
  88. indexOfFirstNewLine === -1 ? ' ' : currentNode.value.slice(indexOfFirstNewLine);
  89. return true;
  90. }
  91. const message = isBeforeOp
  92. ? messages.expectedBefore(operator)
  93. : messages.expectedAfter(operator);
  94. complain(message, decl, valueIndex + operatorSourceIndex);
  95. return true;
  96. }
  97. if (currentNode.type === 'function') {
  98. if (context.fix) {
  99. needsFix = true;
  100. nodes.splice(operatorIndex, 0, {
  101. type: 'space',
  102. value: ' ',
  103. sourceIndex: 0,
  104. sourceEndIndex: 1,
  105. });
  106. return true;
  107. }
  108. const message = isBeforeOp
  109. ? messages.expectedBefore(operator)
  110. : messages.expectedAfter(operator);
  111. complain(message, decl, valueIndex + operatorSourceIndex);
  112. return true;
  113. }
  114. }
  115. return false;
  116. }
  117. /**
  118. * @param {import('postcss-value-parser').Node[]} nodes
  119. */
  120. function checkForOperatorInFirstNode(nodes) {
  121. const firstNode = nodes[0];
  122. const operatorIndex =
  123. (firstNode.type === 'word' || -1) && firstNode.value.search(OPERATOR_REGEX);
  124. const operator = firstNode.value.slice(operatorIndex, operatorIndex + 1);
  125. if (operatorIndex <= 0) return false;
  126. const charBefore = firstNode.value.charAt(operatorIndex - 1);
  127. const charAfter = firstNode.value.charAt(operatorIndex + 1);
  128. if (charBefore && charBefore !== ' ' && charAfter && charAfter !== ' ') {
  129. if (context.fix) {
  130. needsFix = true;
  131. firstNode.value = insertCharAtIndex(firstNode.value, operatorIndex + 1, ' ');
  132. firstNode.value = insertCharAtIndex(firstNode.value, operatorIndex, ' ');
  133. } else {
  134. complain(
  135. messages.expectedBefore(operator),
  136. decl,
  137. valueIndex + firstNode.sourceIndex + operatorIndex,
  138. );
  139. complain(
  140. messages.expectedAfter(operator),
  141. decl,
  142. valueIndex + firstNode.sourceIndex + operatorIndex + 1,
  143. );
  144. }
  145. } else if (charBefore && charBefore !== ' ') {
  146. if (context.fix) {
  147. needsFix = true;
  148. firstNode.value = insertCharAtIndex(firstNode.value, operatorIndex, ' ');
  149. } else {
  150. complain(
  151. messages.expectedBefore(operator),
  152. decl,
  153. valueIndex + firstNode.sourceIndex + operatorIndex,
  154. );
  155. }
  156. } else if (charAfter && charAfter !== ' ') {
  157. if (context.fix) {
  158. needsFix = true;
  159. firstNode.value = insertCharAtIndex(firstNode.value, operatorIndex, ' ');
  160. } else {
  161. complain(
  162. messages.expectedAfter(operator),
  163. decl,
  164. valueIndex + firstNode.sourceIndex + operatorIndex + 1,
  165. );
  166. }
  167. }
  168. return true;
  169. }
  170. /**
  171. * @param {import('postcss-value-parser').Node[]} nodes
  172. */
  173. function checkForOperatorInLastNode(nodes) {
  174. if (nodes.length === 1) return false;
  175. const lastNode = nodes[nodes.length - 1];
  176. const operatorIndex =
  177. (lastNode.type === 'word' || -1) && lastNode.value.search(OPERATOR_REGEX);
  178. if (lastNode.value[operatorIndex - 1] === ' ') return false;
  179. if (context.fix) {
  180. needsFix = true;
  181. lastNode.value = insertCharAtIndex(lastNode.value, operatorIndex + 1, ' ').trim();
  182. lastNode.value = insertCharAtIndex(lastNode.value, operatorIndex, ' ').trim();
  183. return true;
  184. }
  185. complain(
  186. messages.expectedOperatorBeforeSign(lastNode.value[operatorIndex]),
  187. decl,
  188. valueIndex + lastNode.sourceIndex + operatorIndex,
  189. );
  190. return true;
  191. }
  192. /**
  193. * @param {import('postcss-value-parser').Node[]} nodes
  194. */
  195. function checkWords(nodes) {
  196. if (checkForOperatorInFirstNode(nodes) || checkForOperatorInLastNode(nodes)) return;
  197. for (const [index, node] of nodes.entries()) {
  198. const lastChar = node.value.slice(-1);
  199. const firstChar = node.value.slice(0, 1);
  200. if (node.type === 'word') {
  201. if (index === 0 && OPERATORS.has(lastChar)) {
  202. if (context.fix) {
  203. node.value = `${node.value.slice(0, -1)} ${lastChar}`;
  204. continue;
  205. }
  206. complain(messages.expectedBefore(lastChar), decl, node.sourceIndex);
  207. } else if (index === nodes.length && OPERATORS.has(firstChar)) {
  208. if (context.fix) {
  209. node.value = `${firstChar} ${node.value.slice(1)}`;
  210. continue;
  211. }
  212. complain(messages.expectedOperatorBeforeSign(firstChar), decl, node.sourceIndex);
  213. }
  214. }
  215. }
  216. }
  217. parsedValue.walk((node) => {
  218. if (node.type !== 'function' || node.value.toLowerCase() !== 'calc') return;
  219. let foundOperatorNode = false;
  220. for (const [nodeIndex, currNode] of node.nodes.entries()) {
  221. if (currNode.type !== 'word' || !OPERATORS.has(currNode.value)) continue;
  222. foundOperatorNode = true;
  223. const nodeBefore = node.nodes[nodeIndex - 1];
  224. const nodeAfter = node.nodes[nodeIndex + 1];
  225. if (isSingleSpace(nodeBefore) && isSingleSpace(nodeAfter)) continue;
  226. if (checkAroundOperator(node.nodes, nodeIndex, 1)) continue;
  227. checkAroundOperator(node.nodes, nodeIndex, -1);
  228. }
  229. if (!foundOperatorNode) {
  230. checkWords(node.nodes);
  231. }
  232. });
  233. if (needsFix) {
  234. setDeclarationValue(decl, parsedValue.toString());
  235. }
  236. });
  237. };
  238. };
  239. /**
  240. * @param {string} str
  241. * @param {number} index
  242. * @param {string} char
  243. */
  244. function insertCharAtIndex(str, index, char) {
  245. return str.slice(0, index) + char + str.slice(index, str.length);
  246. }
  247. /**
  248. * @param {import('postcss-value-parser').Node} node
  249. * @returns {node is import('postcss-value-parser').SpaceNode & { value: ' ' } }
  250. */
  251. function isSingleSpace(node) {
  252. return node && node.type === 'space' && node.value === ' ';
  253. }
  254. rule.ruleName = ruleName;
  255. rule.messages = messages;
  256. rule.meta = meta;
  257. module.exports = rule;