diff --git a/lib/rules/function-calc-no-unspaced-operator/__tests__/index.js b/lib/rules/function-calc-no-unspaced-operator/__tests__/index.js index 2bf9083171..057b558db7 100644 --- a/lib/rules/function-calc-no-unspaced-operator/__tests__/index.js +++ b/lib/rules/function-calc-no-unspaced-operator/__tests__/index.js @@ -87,6 +87,10 @@ testRule({ code: 'a { top: calc(\t+1px)}', description: 'tab before sign at start', }, + { + code: 'a { top: calc(100% - --my-custom-function(1rem)); }', + description: 'custom function with hyphens in name', + }, { code: 'a { top: calc(-$x - 2rem); }', description: 'postcss-simple-vars and SCSS variable syntax', @@ -100,7 +104,7 @@ testRule({ description: 'postcss-simple-vars and SCSS variable with hyphens', }, { - code: 'a { top: calc(2rem + @fh+d*sf-as); }', + code: 'a { top: calc(2rem + @fh+dsf-as); }', description: 'Less variable with symbols', }, { @@ -154,6 +158,15 @@ testRule({ ], reject: [ + { + code: 'a { top: calc(2px+1px) }', + fixed: 'a { top: calc(2px + 1px) }', + description: 'no space before or after operator', + warnings: [ + { message: messages.expectedBefore('+'), line: 1, column: 18 }, + { message: messages.expectedAfter('+'), line: 1, column: 19 }, + ], + }, { code: 'a { top: calc(1px +\t-1px)}', fixed: 'a { top: calc(1px + -1px)}', diff --git a/lib/rules/function-calc-no-unspaced-operator/index.js b/lib/rules/function-calc-no-unspaced-operator/index.js index 5a7323db35..d902154c8a 100644 --- a/lib/rules/function-calc-no-unspaced-operator/index.js +++ b/lib/rules/function-calc-no-unspaced-operator/index.js @@ -1,12 +1,13 @@ 'use strict'; -const balancedMatch = require('balanced-match'); -const isWhitespace = require('../../utils/isWhitespace'); +const valueParser = require('postcss-value-parser'); + +const declarationValueIndex = require('../../utils/declarationValueIndex'); +const getDeclarationValue = require('../../utils/getDeclarationValue'); const report = require('../../utils/report'); const ruleMessages = require('../../utils/ruleMessages'); -const styleSearch = require('style-search'); +const setDeclarationValue = require('../../utils/setDeclarationValue'); const validateOptions = require('../../utils/validateOptions'); -const valueParser = require('postcss-value-parser'); const ruleName = 'function-calc-no-unspaced-operator'; @@ -16,16 +17,15 @@ const messages = ruleMessages(ruleName, { expectedOperatorBeforeSign: (operator) => `Expected an operator before sign "${operator}"`, }); -/** @typedef {{ index: number, insert: boolean }} SymbolToFix */ +const OPERATORS = new Set(['*', '/', '+', '-']); +const OPERATOR_REGEX = /[*/+-]/; /** @type {import('stylelint').Rule} */ const rule = (primary, _secondaryOptions, context) => { return (root, result) => { const validOptions = validateOptions(result, ruleName, { actual: primary }); - if (!validOptions) { - return; - } + if (!validOptions) return; /** * @param {string} message @@ -37,197 +37,268 @@ const rule = (primary, _secondaryOptions, context) => { } root.walkDecls((decl) => { - /** @type {SymbolToFix[]} */ - const symbolsToFix = []; + let needsFix = false; + const valueIndex = declarationValueIndex(decl); + const parsedValue = valueParser(getDeclarationValue(decl)); + + /** + * @param {import('postcss-value-parser').Node[]} nodes + * @param {number} operatorIndex + * @param {-1 | 1} direction + */ + function checkAroundOperator(nodes, operatorIndex, direction) { + const isBeforeOp = direction === -1; + const currentNode = nodes[operatorIndex + direction]; + const operator = nodes[operatorIndex].value; + const operatorSourceIndex = nodes[operatorIndex].sourceIndex; + + if (currentNode && !isSingleSpace(currentNode)) { + if (currentNode.type === 'word') { + if (isBeforeOp) { + const lastChar = currentNode.value.slice(-1); + + if (OPERATORS.has(lastChar)) { + if (context.fix) { + currentNode.value = `${currentNode.value.slice(0, -1)} ${lastChar}`; + + return true; + } + + complain(messages.expectedOperatorBeforeSign(operator), decl, operatorSourceIndex); + + return true; + } + } else { + const firstChar = currentNode.value.slice(0, 1); + + if (OPERATORS.has(firstChar)) { + if (context.fix) { + currentNode.value = `${firstChar} ${currentNode.value.slice(1)}`; + + return true; + } + + complain(messages.expectedAfter(operator), decl, operatorSourceIndex); + + return true; + } + } + + if (context.fix) { + needsFix = true; + currentNode.value = isBeforeOp ? `${currentNode.value} ` : ` ${currentNode.value}`; + + return true; + } + + complain( + isBeforeOp ? messages.expectedBefore(operator) : messages.expectedAfter(operator), + decl, + valueIndex + operatorSourceIndex, + ); + + return true; + } + + if (currentNode.type === 'space') { + const indexOfFirstNewLine = currentNode.value.search(/(\n|\r\n)/); + + if (indexOfFirstNewLine === 0) return; + + if (context.fix) { + needsFix = true; + + currentNode.value = + indexOfFirstNewLine === -1 ? ' ' : currentNode.value.slice(indexOfFirstNewLine); - valueParser(decl.value).walk((node) => { - if (node.type !== 'function' || node.value.toLowerCase() !== 'calc') { - return; + return true; + } + + const message = isBeforeOp + ? messages.expectedBefore(operator) + : messages.expectedAfter(operator); + + complain(message, decl, valueIndex + operatorSourceIndex); + + return true; + } + + if (currentNode.type === 'function') { + if (context.fix) { + needsFix = true; + nodes.splice(operatorIndex, 0, { type: 'space', value: ' ', sourceIndex: 0 }); + + return true; + } + + const message = isBeforeOp + ? messages.expectedBefore(operator) + : messages.expectedAfter(operator); + + complain(message, decl, valueIndex + operatorSourceIndex); + + return true; + } } - const nodeText = valueParser.stringify(node); - const parensMatch = balancedMatch('(', ')', nodeText); + return false; + } - if (!parensMatch) { - throw new Error(`No parens match: "${nodeText}"`); + /** + * @param {import('postcss-value-parser').Node[]} nodes + */ + function checkForOperatorInFirstNode(nodes) { + const firstNode = nodes[0]; + + const operatorIndex = + (firstNode.type === 'word' || -1) && firstNode.value.search(OPERATOR_REGEX); + const operator = firstNode.value.slice(operatorIndex, operatorIndex + 1); + + if (operatorIndex <= 0) return false; + + const charBefore = firstNode.value.charAt(operatorIndex - 1); + const charAfter = firstNode.value.charAt(operatorIndex + 1); + + if (charBefore && charBefore !== ' ' && charAfter && charAfter !== ' ') { + if (context.fix) { + needsFix = true; + firstNode.value = insertCharAtIndex(firstNode.value, operatorIndex + 1, ' '); + firstNode.value = insertCharAtIndex(firstNode.value, operatorIndex, ' '); + } else { + complain( + messages.expectedBefore(operator), + decl, + valueIndex + firstNode.sourceIndex + operatorIndex, + ); + complain( + messages.expectedAfter(operator), + decl, + valueIndex + firstNode.sourceIndex + operatorIndex + 1, + ); + } + } else if (charBefore && charBefore !== ' ') { + if (context.fix) { + needsFix = true; + firstNode.value = insertCharAtIndex(firstNode.value, operatorIndex, ' '); + } else { + complain( + messages.expectedBefore(operator), + decl, + valueIndex + firstNode.sourceIndex + operatorIndex, + ); + } + } else if (charAfter && charAfter !== ' ') { + if (context.fix) { + needsFix = true; + firstNode.value = insertCharAtIndex(firstNode.value, operatorIndex, ' '); + } else { + complain( + messages.expectedAfter(operator), + decl, + valueIndex + firstNode.sourceIndex + operatorIndex + 1, + ); + } } - if (decl.source == null || decl.source.start == null) { - throw new Error('Declaration source must be present'); + return true; + } + + /** + * @param {import('postcss-value-parser').Node[]} nodes + */ + function checkForOperatorInLastNode(nodes) { + if (nodes.length === 1) return false; + + const lastNode = nodes[nodes.length - 1]; + + const operatorIndex = + (lastNode.type === 'word' || -1) && lastNode.value.search(OPERATOR_REGEX); + + if (lastNode.value[operatorIndex - 1] === ' ') return false; + + if (context.fix) { + needsFix = true; + lastNode.value = insertCharAtIndex(lastNode.value, operatorIndex + 1, ' ').trim(); + lastNode.value = insertCharAtIndex(lastNode.value, operatorIndex, ' ').trim(); + + return true; } - const rawExpression = parensMatch.body; - const expressionIndex = - decl.source.start.column + - decl.prop.length + - (decl.raws.between || '').length + - node.sourceIndex; - const expression = blurVariables(rawExpression); - - const parensMatchStart = parensMatch.start; - - checkSymbol('+'); - checkSymbol('-'); - checkSymbol('*'); - checkSymbol('/'); - - /** - * @param {string} symbol - */ - function checkSymbol(symbol) { - /** @type {import('style-search').Options} */ - const styleSearchOptions = { - source: expression, - target: symbol, - functionArguments: 'skip', - }; - - styleSearch(styleSearchOptions, (match) => { - const index = match.startIndex; - const symbolIndex = node.sourceIndex + parensMatchStart + index + 1; - - // Deal with signs. - // (@ and $ are considered "digits" here to allow for variable syntaxes - // that permit signs in front of variables, e.g. `-$number`) - // As is "." to deal with fractional numbers without a leading zero - if ((symbol === '+' || symbol === '-') && /[\d@$.]/.test(expression[index + 1])) { - const expressionBeforeSign = expression.slice(0, index); - - // Ignore signs that directly follow a opening bracket - if (expressionBeforeSign[expressionBeforeSign.length - 1] === '(') { - return; - } + complain( + messages.expectedOperatorBeforeSign(lastNode.value[operatorIndex]), + decl, + valueIndex + lastNode.sourceIndex + operatorIndex, + ); - // Ignore signs at the beginning of the expression - if (/^\s*$/.test(expressionBeforeSign)) { - return; - } + return true; + } + + /** + * @param {import('postcss-value-parser').Node[]} nodes + */ + function checkWords(nodes) { + if (checkForOperatorInFirstNode(nodes)) return; + + if (checkForOperatorInLastNode(nodes)) return; + + nodes.forEach((node, index) => { + const lastChar = node.value.slice(-1); + const firstChar = node.value.slice(0, 1); + + if (node.type === 'word') { + if (index === 0 && OPERATORS.has(lastChar)) { + if (context.fix) { + node.value = `${node.value.slice(0, -1)} ${lastChar}`; - // Otherwise, ensure that there is a real operator preceding them - if (/[*/+-]\s*$/.test(expressionBeforeSign)) { return; } - if (!context.fix) { - // And if not, complain - complain( - messages.expectedOperatorBeforeSign(symbol), - decl, - expressionIndex + index, - ); + complain(messages.expectedBefore(lastChar), decl, node.sourceIndex); + } else if (index === nodes.length && OPERATORS.has(firstChar)) { + if (context.fix) { + node.value = `${firstChar} ${node.value.slice(1)}`; return; } + + complain(messages.expectedOperatorBeforeSign(firstChar), decl, node.sourceIndex); } + } + }); + } - const beforeOk = - (expression[index - 1] === ' ' && !isWhitespace(expression[index - 2])) || - newlineBefore(expression, index - 1); + parsedValue.walk((node) => { + if (node.type !== 'function' || node.value.toLowerCase() !== 'calc') return; - if (!beforeOk) { - if (context.fix) { - let step = 1; + let foundOperatorNode = false; - // Remove all whitespace characters before the operator, e.g. `\t` - while (isWhitespace(expression[index - step])) { - symbolsToFix.push({ - index: symbolIndex - step, - insert: false, - }); + for (const [nodeIndex, currNode] of node.nodes.entries()) { + if (currNode.type !== 'word' || !OPERATORS.has(currNode.value)) continue; - step++; - } + foundOperatorNode = true; - // Add only one space character - symbolsToFix.push({ - index: symbolIndex, - insert: true, - }); - } else { - complain(messages.expectedBefore(symbol), decl, expressionIndex + index); - } - } + const nodeBefore = node.nodes[nodeIndex - 1]; + const nodeAfter = node.nodes[nodeIndex + 1]; - const afterOk = - (expression[index + 1] === ' ' && !isWhitespace(expression[index + 2])) || - isNewlineAtIndex(expression, index + 1); + if (isSingleSpace(nodeBefore) && isSingleSpace(nodeAfter)) continue; - if (!afterOk) { - if (context.fix) { - let step = 1; - let spaceNeeded = true; - - // Remove all whitespace characters before the operator or \n, e.g. \t - while (isWhitespace(expression[index + step])) { - if (isNewlineAtIndex(expression, index + step)) { - spaceNeeded = false; - break; - } - - symbolsToFix.push({ - index: symbolIndex + step, - insert: false, - }); - step++; - } + if (checkAroundOperator(node.nodes, nodeIndex, 1)) continue; - // Insert one space character if there is no \n - if (spaceNeeded) { - symbolsToFix.push({ - index: symbolIndex + 1, - insert: true, - }); - } - } else { - complain(messages.expectedAfter(symbol), decl, expressionIndex + index); - } - } - }); + checkAroundOperator(node.nodes, nodeIndex, -1); } - }); - if (context.fix) { - decl.value = symbolsToFix.reduce((/** @type {string} */ fixedValue, { insert, index }) => { - shiftIndexes(symbolsToFix, index, insert); + if (!foundOperatorNode) { + checkWords(node.nodes); + } + }); - return insert - ? insertCharAtIndex(fixedValue, index, ' ') - : removeCharAtIndex(fixedValue, index); - }, decl.value); + if (needsFix) { + setDeclarationValue(decl, parsedValue.toString()); } }); }; }; -/** - * @param {string} str - * @param {number} index - */ -function isNewlineAtIndex(str, index) { - return str[index] === '\n' || str.slice(index, index + 2) === '\r\n'; -} - -/** - * @param {SymbolToFix[]} symbolsToFix - * @param {number} index - * @param {boolean} insert - */ -function shiftIndexes(symbolsToFix, index, insert) { - symbolsToFix.forEach((symbol) => { - if (symbol.index > index) { - symbol.index += insert ? 1 : -1; - } - }); -} - -/** - * @param {string} str - * @param {number} index - */ -function removeCharAtIndex(str, index) { - return str.slice(0, index) + str.slice(index + 1, str.length); -} - /** * @param {string} str * @param {number} index @@ -238,26 +309,11 @@ function insertCharAtIndex(str, index, char) { } /** - * @param {string} source + * @param {import('postcss-value-parser').Node} node + * @returns {node is import('postcss-value-parser').SpaceNode & { value: ' ' } } */ -function blurVariables(source) { - return source.replace(/[$@][^)\s]+|#\{.+?\}/g, '0'); -} - -/** - * @param {string} str - * @param {number} startIndex - */ -function newlineBefore(str, startIndex) { - let index = startIndex; - - while (index && isWhitespace(str[index])) { - if (str[index] === '\n') return true; - - index--; - } - - return false; +function isSingleSpace(node) { + return node && node.type === 'space' && node.value === ' '; } rule.ruleName = ruleName;