diff --git a/.changeset/happy-laws-attack.md b/.changeset/happy-laws-attack.md new file mode 100644 index 0000000000..d8e5bd5021 --- /dev/null +++ b/.changeset/happy-laws-attack.md @@ -0,0 +1,5 @@ +--- +"stylelint": minor +--- + +Fixed: `function-calc-no-unspaced-operator` false negatives diff --git a/lib/reference/keywords.cjs b/lib/reference/keywords.cjs index 65d659366d..3bf1d8beed 100644 --- a/lib/reference/keywords.cjs +++ b/lib/reference/keywords.cjs @@ -479,9 +479,13 @@ const namedColorsKeywords = new Set([ 'yellowgreen', ]); +/** @see https://drafts.csswg.org/css-values/#typedef-calc-keyword */ +const calcKeywords = new Set(['e', 'pi', 'infinity', '-infinity', 'NaN']); + exports.animationNameKeywords = animationNameKeywords; exports.animationShorthandKeywords = animationShorthandKeywords; exports.basicKeywords = basicKeywords; +exports.calcKeywords = calcKeywords; exports.camelCaseKeywords = camelCaseKeywords; exports.counterIncrementKeywords = counterIncrementKeywords; exports.counterResetKeywords = counterResetKeywords; diff --git a/lib/reference/keywords.mjs b/lib/reference/keywords.mjs index 15ebf36e24..c1b17841a1 100644 --- a/lib/reference/keywords.mjs +++ b/lib/reference/keywords.mjs @@ -474,3 +474,6 @@ export const namedColorsKeywords = new Set([ 'yellow', 'yellowgreen', ]); + +/** @see https://drafts.csswg.org/css-values/#typedef-calc-keyword */ +export const calcKeywords = new Set(['e', 'pi', 'infinity', '-infinity', 'NaN']); diff --git a/lib/rules/function-calc-no-unspaced-operator/__tests__/index.mjs b/lib/rules/function-calc-no-unspaced-operator/__tests__/index.mjs index bed2a22eea..75b27579d8 100644 --- a/lib/rules/function-calc-no-unspaced-operator/__tests__/index.mjs +++ b/lib/rules/function-calc-no-unspaced-operator/__tests__/index.mjs @@ -83,23 +83,23 @@ testRule({ code: 'a { padding: calc(calc(1em * 2) / 3) calc(calc(1em * 2) / 3); }', }, { - code: 'a { top: calc(+1px)}', + code: 'a { top: calc(+1px); }', description: 'sign', }, { - code: 'a { top: calc(1px + -1px)}', + code: 'a { top: calc(1px + -1px); }', description: 'sign after operator', }, { - code: 'a { top: calc(-1px * -1)}', + code: 'a { top: calc(-1px * -1); }', description: 'sign after operator and at start', }, { - code: 'a { top: calc( +1px)}', + code: 'a { top: calc( +1px); }', description: 'multiple spaces before sign at start', }, { - code: 'a { top: calc(\t+1px)}', + code: 'a { top: calc(\t+1px); }', description: 'tab before sign at start', }, { @@ -163,35 +163,35 @@ testRule({ description: 'ignore function have calc in name', }, { - code: 'margin-top: calc(var(--some-variable) +\n var(--some-other-variable));', + code: 'a { margin-top: calc(var(--some-variable) +\n var(--some-other-variable)); }', description: 'newline and spaces after operator', }, { - code: 'margin-top: calc(var(--some-variable) +\r\n var(--some-other-variable));', + code: 'a { margin-top: calc(var(--some-variable) +\r\n var(--some-other-variable)); }', description: 'CRLF newline and spaces after operator', }, { - code: 'margin-top: calc(var(--some-variable) +\n\tvar(--some-other-variable));', + code: 'a { margin-top: calc(var(--some-variable) +\n\tvar(--some-other-variable)); }', description: 'newline and tab after operator', }, { - code: 'margin-top: calc(var(--some-variable) +\r\n\tvar(--some-other-variable));', + code: 'a { margin-top: calc(var(--some-variable) +\r\n\tvar(--some-other-variable)); }', description: 'CRLF newline and tab after operator', }, { - code: 'margin-top: calc(var(--some-variable)\n + var(--some-other-variable));', + code: 'a { margin-top: calc(var(--some-variable)\n + var(--some-other-variable)); }', description: 'newline and spaces before operator', }, { - code: 'margin-top: calc(var(--some-variable)\r\n + var(--some-other-variable));', + code: 'a { margin-top: calc(var(--some-variable)\r\n + var(--some-other-variable)); }', description: 'CRLF newline and spaces before operator', }, { - code: 'margin-top: calc(var(--some-variable)\n\t+ var(--some-other-variable));', + code: 'a { margin-top: calc(var(--some-variable)\n\t+ var(--some-other-variable)); }', description: 'newline and tab before operator', }, { - code: 'margin-top: calc(var(--some-variable)\r\n\t+ var(--some-other-variable));', + code: 'a { margin-top: calc(var(--some-variable)\r\n\t+ var(--some-other-variable)); }', description: 'CRLF newline and tab before operator', }, { @@ -237,24 +237,28 @@ testRule({ description: 'single argument: functions that accept more than one argument are not supported yet', }, + { + code: 'a { padding: calc(1px --2px); }', + description: '"--" prefixed token is considered as ', + }, ], reject: [ { - code: 'a { top: calc(2px+1px) }', - fixed: 'a { top: calc(2px + 1px) }', + 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, endLine: 1, endColumn: 19 }, - { message: messages.expectedAfter('+'), line: 1, column: 19, endLine: 1, endColumn: 20 }, + { message: messages.expectedAfter('+'), line: 1, column: 18, endLine: 1, endColumn: 19 }, ], }, { - code: 'a { transform: rotate(acos(2+1)) }', - fixed: 'a { transform: rotate(acos(2 + 1)) }', + code: 'a { transform: rotate(acos(2+1)); }', + fixed: 'a { transform: rotate(acos(2 + 1)); }', warnings: [ { message: messages.expectedBefore('+'), line: 1, column: 29, endLine: 1, endColumn: 30 }, - { message: messages.expectedAfter('+'), line: 1, column: 30, endLine: 1, endColumn: 31 }, + { message: messages.expectedAfter('+'), line: 1, column: 29, endLine: 1, endColumn: 30 }, ], }, { @@ -276,8 +280,8 @@ testRule({ skip: true, }, { - code: 'a { top: calc(1px +\t-1px) }', - fixed: 'a { top: calc(1px + -1px) }', + code: 'a { top: calc(1px +\t-1px); }', + fixed: 'a { top: calc(1px + -1px); }', description: 'tab before sign after operator', message: messages.expectedAfter('+'), line: 1, @@ -286,8 +290,8 @@ testRule({ endColumn: 20, }, { - code: 'a { top: calc(1px + -1px) }', - fixed: 'a { top: calc(1px + -1px) }', + code: 'a { top: calc(1px + -1px); }', + fixed: 'a { top: calc(1px + -1px); }', description: 'multiple spaces before sign after operator', message: messages.expectedAfter('+'), line: 1, @@ -370,7 +374,7 @@ testRule({ { code: 'a { top: calc(1px +2px); }', fixed: 'a { top: calc(1px + 2px); }', - message: messages.expectedOperatorBeforeSign('+'), + message: messages.expectedAfter('+'), line: 1, column: 19, endLine: 1, @@ -379,15 +383,15 @@ testRule({ { code: 'a { top: calc(1px -2px); }', fixed: 'a { top: calc(1px - 2px); }', - message: messages.expectedOperatorBeforeSign('-'), + message: messages.expectedAfter('-'), line: 1, column: 19, endLine: 1, endColumn: 20, }, { - code: 'a { padding: 10px calc(1px +\t-1px)}', - fixed: 'a { padding: 10px calc(1px + -1px)}', + code: 'a { padding: 10px calc(1px +\t-1px); }', + fixed: 'a { padding: 10px calc(1px + -1px); }', description: 'tab before sign after operator', message: messages.expectedAfter('+'), line: 1, @@ -396,8 +400,8 @@ testRule({ endColumn: 29, }, { - code: 'a { padding: 10px calc(1px + -1px)}', - fixed: 'a { padding: 10px calc(1px + -1px)}', + code: 'a { padding: 10px calc(1px + -1px); }', + fixed: 'a { padding: 10px calc(1px + -1px); }', description: 'multiple spaces before sign after operator', message: messages.expectedAfter('+'), line: 1, @@ -462,7 +466,7 @@ testRule({ { code: 'a { padding: 10px calc(1px +2px); }', fixed: 'a { padding: 10px calc(1px + 2px); }', - message: messages.expectedOperatorBeforeSign('+'), + message: messages.expectedAfter('+'), line: 1, column: 28, endLine: 1, @@ -471,15 +475,15 @@ testRule({ { code: 'a { padding: 10px calc(1px -2px); }', fixed: 'a { padding: 10px calc(1px - 2px); }', - message: messages.expectedOperatorBeforeSign('-'), + message: messages.expectedAfter('-'), line: 1, column: 28, endLine: 1, endColumn: 29, }, { - code: 'a { padding: calc(1rem\t + 1em)}', - fixed: 'a { padding: calc(1rem + 1em)}', + code: 'a { padding: calc(1rem\t + 1em); }', + fixed: 'a { padding: calc(1rem + 1em); }', description: 'several whitespace characters before operator starting from space', message: messages.expectedBefore('+'), line: 1, @@ -488,8 +492,8 @@ testRule({ endColumn: 26, }, { - code: 'a { padding: calc(1rem \t+ 1em)}', - fixed: 'a { padding: calc(1rem + 1em)}', + code: 'a { padding: calc(1rem \t+ 1em); }', + fixed: 'a { padding: calc(1rem + 1em); }', description: 'several whitespace characters before operator starting from tab', message: messages.expectedBefore('+'), line: 1, @@ -498,8 +502,8 @@ testRule({ endColumn: 27, }, { - code: 'a { padding: calc(1rem\t\f\r\t+ 1em)}', - fixed: 'a { padding: calc(1rem + 1em)}', + code: 'a { padding: calc(1rem\t\f\r\t+ 1em); }', + fixed: 'a { padding: calc(1rem + 1em); }', description: 'several incorrect whitespace characters before operator', message: messages.expectedBefore('+'), line: 1, @@ -508,8 +512,8 @@ testRule({ endColumn: 28, }, { - code: 'a { padding: calc(1rem + \t1em)}', - fixed: 'a { padding: calc(1rem + 1em)}', + code: 'a { padding: calc(1rem + \t1em); }', + fixed: 'a { padding: calc(1rem + 1em); }', description: 'several whitespace characters after operator starting from space', message: messages.expectedAfter('+'), line: 1, @@ -518,8 +522,8 @@ testRule({ endColumn: 25, }, { - code: 'a { padding: calc(1rem +\t \t1em)}', - fixed: 'a { padding: calc(1rem + 1em)}', + code: 'a { padding: calc(1rem +\t \t1em); }', + fixed: 'a { padding: calc(1rem + 1em); }', description: 'several whitespace characters after operator starting from tab', message: messages.expectedAfter('+'), line: 1, @@ -528,8 +532,8 @@ testRule({ endColumn: 25, }, { - code: 'a { padding: calc(1rem +\t\r\f\t1em)}', - fixed: 'a { padding: calc(1rem + 1em)}', + code: 'a { padding: calc(1rem +\t\r\f\t1em); }', + fixed: 'a { padding: calc(1rem + 1em); }', description: 'several incorrect whitespace characters after operator', message: messages.expectedAfter('+'), line: 1, @@ -538,8 +542,8 @@ testRule({ endColumn: 25, }, { - code: 'a { padding: calc(1rem +\t \t\f\n\f\t1em)}', - fixed: 'a { padding: calc(1rem +\n\f\t1em)}', + code: 'a { padding: calc(1rem +\t \t\f\n\f\t1em); }', + fixed: 'a { padding: calc(1rem +\n\f\t1em); }', description: 'several whitespace characters after operator but before the \\n', message: messages.expectedAfter('+'), line: 1, @@ -548,8 +552,8 @@ testRule({ endColumn: 25, }, { - code: 'a { padding: calc(1rem + \t\r\n 1em)}', - fixed: 'a { padding: calc(1rem +\r\n 1em)}', + code: 'a { padding: calc(1rem + \t\r\n 1em); }', + fixed: 'a { padding: calc(1rem +\r\n 1em); }', description: 'several whitespace characters after operator but before the \\r\\n', message: messages.expectedAfter('+'), line: 1, @@ -557,6 +561,25 @@ testRule({ endLine: 1, endColumn: 25, }, + { + code: 'a { padding: calc(1px +-2px); }', + fixed: 'a { padding: calc(1px + -2px); }', + message: messages.expectedAfter('+'), + line: 1, + column: 23, + endLine: 1, + endColumn: 24, + }, + { + code: 'a { padding: calc(1px+2px-3px); }', + fixed: 'a { padding: calc(1px + 2px - 3px); }', + warnings: [ + { message: messages.expectedBefore('+'), line: 1, endLine: 1, column: 22, endColumn: 23 }, + { message: messages.expectedAfter('+'), line: 1, endLine: 1, column: 22, endColumn: 23 }, + { message: messages.expectedBefore('-'), line: 1, endLine: 1, column: 26, endColumn: 27 }, + { message: messages.expectedAfter('-'), line: 1, endLine: 1, column: 26, endColumn: 27 }, + ], + }, ], }); @@ -606,7 +629,7 @@ testRule({ { code: 'a { top: calc(100% -#{$foo}); }', fixed: 'a { top: calc(100% - #{$foo}); }', - message: messages.expectedOperatorBeforeSign('-'), + message: messages.expectedAfter('-'), line: 1, column: 20, endLine: 1, diff --git a/lib/rules/function-calc-no-unspaced-operator/index.cjs b/lib/rules/function-calc-no-unspaced-operator/index.cjs index a22efe71ed..a10f4d7e71 100644 --- a/lib/rules/function-calc-no-unspaced-operator/index.cjs +++ b/lib/rules/function-calc-no-unspaced-operator/index.cjs @@ -2,11 +2,12 @@ // please instead edit the ESM counterpart and rebuild with Rollup (npm run build). 'use strict'; -const valueParser = require('postcss-value-parser'); -const typeGuards = require('../../utils/typeGuards.cjs'); -const validateTypes = require('../../utils/validateTypes.cjs'); +const cssParserAlgorithms = require('@csstools/css-parser-algorithms'); +const cssTokenizer = require('@csstools/css-tokenizer'); +const keywords = require('../../reference/keywords.cjs'); const declarationValueIndex = require('../../utils/declarationValueIndex.cjs'); const getDeclarationValue = require('../../utils/getDeclarationValue.cjs'); +const validateTypes = require('../../utils/validateTypes.cjs'); const isStandardSyntaxValue = require('../../utils/isStandardSyntaxValue.cjs'); const report = require('../../utils/report.cjs'); const ruleMessages = require('../../utils/ruleMessages.cjs'); @@ -19,7 +20,6 @@ const ruleName = 'function-calc-no-unspaced-operator'; const messages = ruleMessages(ruleName, { expectedBefore: (operator) => `Expected single space before "${operator}" operator`, expectedAfter: (operator) => `Expected single space after "${operator}" operator`, - expectedOperatorBeforeSign: (operator) => `Expected an operator before sign "${operator}"`, }); const meta = { @@ -27,14 +27,23 @@ const meta = { fixable: true, }; +/** @type {(operators: Set, flags?: string) => RegExp} */ +const operatorRegex = (operators, flags = undefined) => { + const escaped = [...operators].map((token) => `\\${token}`).join(''); + + return new RegExp(`[${escaped}]`, flags); +}; + const OPERATORS = new Set(['+', '-']); -const OPERATOR_REGEX = /[+-]/; +const OPERATOR_REGEX = operatorRegex(OPERATORS); const ALL_OPERATORS = new Set([...OPERATORS, '*', '/']); // #7618 const alternatives = [...functions.singleArgumentMathFunctions].join('|'); const FUNC_NAMES_REGEX = new RegExp(`^(?:${alternatives})$`, 'i'); const FUNC_CALLS_REGEX = new RegExp(`(?:${alternatives})\\(`, 'i'); +const NEWLINE_REGEX = /\n|\r\n/; + /** @type {import('stylelint').Rule} */ const rule = (primary, _secondaryOptions, context) => { return (root, result) => { @@ -65,167 +74,176 @@ const rule = (primary, _secondaryOptions, context) => { const valueIndex = declarationValueIndex(decl); /** - * @param {import('postcss-value-parser').WordNode} operatorNode - * @param {import('postcss-value-parser').SpaceNode} spaceNode + * @param {import('@csstools/css-parser-algorithms').TokenNode} operatorNode + * @param {import('@csstools/css-parser-algorithms').ContainerNode} parent + * @param {number} index * @param {'before' | 'after'} position * @returns {void} */ - function checkSpaceAroundOperator(operatorNode, spaceNode, position) { - const indexOfFirstNewLine = spaceNode.value.search(/(\n|\r\n)/); + function checkWhitespaceAroundOperator(operatorNode, parent, index, position) { + const aroundNode = parent.value[index + (position === 'before' ? -1 : 1)]; - if (indexOfFirstNewLine === 0) return; + if (cssParserAlgorithms.isWhitespaceNode(aroundNode)) { + const whitespace = getWhitespace(aroundNode); - if (context.fix) { - needsFix = true; - spaceNode.value = - indexOfFirstNewLine === -1 ? ' ' : spaceNode.value.slice(indexOfFirstNewLine); + if (whitespace === ' ') return; + + const indexOfFirstNewLine = whitespace.search(NEWLINE_REGEX); + + if (indexOfFirstNewLine === 0) return; + if (context.fix) { + needsFix = true; + + const [token] = aroundNode.value; + + if (token) { + token[1] = indexOfFirstNewLine === -1 ? ' ' : whitespace.slice(indexOfFirstNewLine); + } + + return; + } + } else if ( + cssParserAlgorithms.isTokenNode(aroundNode) && + (isOperandNode(aroundNode) || isScssInterpolation(aroundNode, parent)) + ) { + if (context.fix) { + needsFix = true; + + if (position === 'before') { + aroundNode.value[1] += ' '; + } else { + aroundNode.value[1] = ` ${aroundNode.value[1]}`; + } + + return; + } + } else { return; } - const operator = operatorNode.value; - const operatorSourceIndex = operatorNode.sourceIndex; + const delimToken = getDelimToken(operatorNode); + + if (!delimToken) return; + + const { value: operator, startIndex } = delimToken; const message = position === 'before' ? messages.expectedBefore(operator) : messages.expectedAfter(operator); - complain(message, decl, valueIndex + operatorSourceIndex, operator); + complain(message, decl, valueIndex + startIndex, operator); } /** - * @param {import('postcss-value-parser').Node[]} nodes - * @returns {boolean} + * @param {import('@csstools/css-parser-algorithms').TokenNode} operandNode + * @param {import('@csstools/css-parser-algorithms').ContainerNode} parent + * @param {number} index + * @returns {void} */ - function checkForOperatorInFirstNode(nodes) { - const [firstNode] = nodes; - - validateTypes.assert(firstNode); + function checkOperands(operandNode, parent, index) { + const operandToken = operandNode.value; - if (!typeGuards.isValueWord(firstNode)) return false; + if (!isOperandToken(operandToken)) return; - if (!isStandardSyntaxValue(firstNode.value)) return false; + const [, operand, operandIndex] = operandToken; - const operatorIndex = firstNode.value.search(OPERATOR_REGEX); + if (!OPERATOR_REGEX.test(operand)) return; - if (operatorIndex <= 0) return false; + if (!isStandardSyntaxValue(operand)) return; - const operator = firstNode.value.charAt(operatorIndex); - const charBefore = firstNode.value.charAt(operatorIndex - 1); - const charAfter = firstNode.value.charAt(operatorIndex + 1); + const beforeOperandNode = parent.value[index - 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, - operator, - ); - complain( - messages.expectedAfter(operator), - decl, - valueIndex + firstNode.sourceIndex + operatorIndex + 1, - operator, - ); - } - } 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, - operator, - ); - } - } + if (hasInvalidOperator(beforeOperandNode)) return; - return true; - } + const isFirstOperand = !parent.value.slice(0, index).some(cssParserAlgorithms.isTokenNode); + const isLastOperand = !parent.value.slice(index + 1).some(cssParserAlgorithms.isTokenNode); + const hasOperatorBeforeOperand = parent.value + .slice(0, index) + .findLast((node) => isOperatorNode(node, ALL_OPERATORS)); - /** - * @param {import('postcss-value-parser').Node[]} nodes - * @returns {boolean} - */ - function checkForOperatorInLastNode(nodes) { - if (nodes.length === 1) return false; + let fixedOperand = operand; + let fixedCount = 0; - const lastNode = nodes.at(-1); + // Scan operator characters in an operand token + for (const matched of operand.matchAll(new RegExp(OPERATOR_REGEX, 'g'))) { + const operator = matched[0]; + const operatorIndex = matched.index; - validateTypes.assert(lastNode); + // Ignore a number sign in the first operand, e.g. "calc(-1px)" + if (operatorIndex === 0 && isFirstOperand) continue; - if (!typeGuards.isValueWord(lastNode)) return false; + // Ignore a suffixed operator in the last operand, e.g. "calc(1px-)" + if (operatorIndex === operand.length - 1 && isLastOperand) continue; - const operatorIndex = lastNode.value.search(OPERATOR_REGEX); + const beforeOperator = operand.charAt(operatorIndex - 1); - if (operatorIndex === -1) return false; + if ((beforeOperator && beforeOperator !== ' ') || isOperandNode(beforeOperandNode)) { + if (context.fix) { + fixedOperand = insertCharAtIndex(fixedOperand, operatorIndex + fixedCount, ' '); + fixedCount++; + } else { + complain( + messages.expectedBefore(operator), + decl, + valueIndex + operandIndex + operatorIndex, + operator, + ); + } + } - // E.g. "10px * -2" when the last node is "-2" - if (isOperator(nodes.at(-3), ALL_OPERATORS) && isSingleSpace(nodes.at(-2))) { - return false; + const afterOperator = operand.charAt(operatorIndex + 1); + + if (afterOperator && afterOperator !== ' ' && !hasOperatorBeforeOperand) { + if (context.fix) { + fixedOperand = insertCharAtIndex(fixedOperand, operatorIndex + fixedCount + 1, ' '); + fixedCount++; + } else { + complain( + messages.expectedAfter(operator), + decl, + valueIndex + operandIndex + operatorIndex, + operator, + ); + } + } } - if (context.fix) { + if (fixedCount > 0) { needsFix = true; - lastNode.value = insertCharAtIndex(lastNode.value, operatorIndex + 1, ' ').trim(); - lastNode.value = insertCharAtIndex(lastNode.value, operatorIndex, ' ').trim(); - - return true; + operandToken[1] = fixedOperand; } - - const operator = lastNode.value.charAt(operatorIndex); - - complain( - messages.expectedOperatorBeforeSign(operator), - decl, - valueIndex + lastNode.sourceIndex + operatorIndex, - operator, - ); - - return true; } - const parsedValue = valueParser(value); - - parsedValue.walk((node) => { - if (!typeGuards.isValueFunction(node) || !FUNC_NAMES_REGEX.test(node.value)) return; + const nodes = cssParserAlgorithms.parseListOfComponentValues(cssTokenizer.tokenize({ css: value })); - const { nodes } = node; + if (nodes.length === 0) return; - let foundOperatorNode = false; + cssParserAlgorithms.walk(nodes, ({ node: funcNode }) => { + if (!cssParserAlgorithms.isFunctionNode(funcNode)) return; - for (const [nodeIndex, operatorNode] of nodes.entries()) { - if (!isOperator(operatorNode)) continue; + if (!FUNC_NAMES_REGEX.test(funcNode.getName())) return; - foundOperatorNode = true; + funcNode.forEach(({ node, parent }, index) => { + if (!validateTypes.isNumber(index)) return; - const nodeBefore = nodes[nodeIndex - 1]; - const nodeAfter = nodes[nodeIndex + 1]; + if (!cssParserAlgorithms.isTokenNode(node)) return; - if (nodeBefore && typeGuards.isValueSpace(nodeBefore) && nodeBefore.value !== ' ') { - checkSpaceAroundOperator(operatorNode, nodeBefore, 'before'); - } + if (isOperatorNode(node, OPERATORS)) { + // Skip if there are no tokens before the operator. + if (!parent.value.slice(0, index).some(cssParserAlgorithms.isTokenNode)) return; - if (nodeAfter && typeGuards.isValueSpace(nodeAfter) && nodeAfter.value !== ' ') { - checkSpaceAroundOperator(operatorNode, nodeAfter, 'after'); + checkWhitespaceAroundOperator(node, parent, index, 'before'); + checkWhitespaceAroundOperator(node, parent, index, 'after'); + } else { + checkOperands(node, parent, index); } - } - - if (!foundOperatorNode) { - checkForOperatorInFirstNode(nodes) || checkForOperatorInLastNode(nodes); - } + }); }); if (needsFix) { - setDeclarationValue(decl, parsedValue.toString()); + setDeclarationValue(decl, cssParserAlgorithms.stringify([nodes])); } }); }; @@ -241,20 +259,98 @@ function insertCharAtIndex(str, index, char) { } /** - * @param {import('postcss-value-parser').Node | undefined} node - * @returns {node is import('postcss-value-parser').SpaceNode} + * @param {import('@csstools/css-parser-algorithms').TokenNode} node + * @returns {{value: string, startIndex: number, endIndex: number} | undefined} */ -function isSingleSpace(node) { - return node != null && typeGuards.isValueSpace(node) && node.value === ' '; +function getDelimToken(node) { + const [type, value, startIndex, endIndex] = node.value; + + if (type !== 'delim-token') return; + + return { value, startIndex, endIndex }; } /** - * @param {import('postcss-value-parser').Node | undefined} node - * @param {Set} [operators] - * @returns {node is import('postcss-value-parser').WordNode} + * @param {import('@csstools/css-parser-algorithms').ComponentValue} node + * @param {Set} operators + * @returns {boolean} */ -function isOperator(node, operators = OPERATORS) { - return node != null && typeGuards.isValueWord(node) && operators.has(node.value); +function isOperatorNode(node, operators) { + if (!cssParserAlgorithms.isTokenNode(node)) return false; + + return operators.has(getDelimToken(node)?.value ?? ''); +} + +/** + * @param {import('@csstools/css-parser-algorithms').WhitespaceNode} node + * @returns {string} + */ +function getWhitespace(node) { + return node.value[0]?.[1] ?? ''; +} + +/** @see https://drafts.csswg.org/css-values/#typedef-calc-value */ +const OPERAND_TOKEN_TYPES = new Set([ + 'number-token', + 'dimension-token', + 'percentage-token', + 'ident-token', +]); + +/** + * @param {import('@csstools/css-tokenizer').CSSToken} token + * @returns {boolean} + */ +function isOperandToken(token) { + const [type, value] = token; + + if (!OPERAND_TOKEN_TYPES.has(type)) return false; + + if (type === 'ident-token' && !keywords.calcKeywords.has(value)) return false; + + return true; +} + +/** + * @param {import('@csstools/css-parser-algorithms').ComponentValue | undefined} node + * @returns {boolean} + */ +function isOperandNode(node) { + return cssParserAlgorithms.isTokenNode(node) && isOperandToken(node.value); +} + +/** + * @param {import('@csstools/css-parser-algorithms').ComponentValue | undefined} node + * @returns {boolean} + */ +function hasInvalidOperator(node) { + if (!cssParserAlgorithms.isTokenNode(node)) return false; + + const delimToken = getDelimToken(node)?.value ?? ''; + + if (!delimToken) return false; + + return !ALL_OPERATORS.has(delimToken); +} + +/** + * @deprecated This support for SCSS interpolation will be removed in the future. It remains only for backward compatiblity. + * + * @param {import('@csstools/css-parser-algorithms').TokenNode} node + * @param {import('@csstools/css-parser-algorithms').ContainerNode} parent + * @returns {boolean} + */ +function isScssInterpolation(node, parent) { + // E.g. "#{$foo}" + if (getDelimToken(node)?.value !== '#') return false; + + const afterNode = parent.at(Number(parent.indexOf(node)) + 1); + + if (!cssParserAlgorithms.isSimpleBlockNode(afterNode)) return false; + + const varNode = afterNode.at(0); + + return cssParserAlgorithms.isTokenNode(varNode) && getDelimToken(varNode)?.value === '$'; } rule.ruleName = ruleName; diff --git a/lib/rules/function-calc-no-unspaced-operator/index.mjs b/lib/rules/function-calc-no-unspaced-operator/index.mjs index 22667a6393..a600047afa 100644 --- a/lib/rules/function-calc-no-unspaced-operator/index.mjs +++ b/lib/rules/function-calc-no-unspaced-operator/index.mjs @@ -1,13 +1,18 @@ -import valueParser from 'postcss-value-parser'; - import { - isValueFunction as isFunction, - isValueSpace as isSpace, - isValueWord as isWord, -} from '../../utils/typeGuards.mjs'; -import { assert } from '../../utils/validateTypes.mjs'; + isFunctionNode, + isSimpleBlockNode, + isTokenNode, + isWhitespaceNode, + parseListOfComponentValues, + stringify, + walk, +} from '@csstools/css-parser-algorithms'; +import { tokenize } from '@csstools/css-tokenizer'; + +import { calcKeywords } from '../../reference/keywords.mjs'; import declarationValueIndex from '../../utils/declarationValueIndex.mjs'; import getDeclarationValue from '../../utils/getDeclarationValue.mjs'; +import { isNumber } from '../../utils/validateTypes.mjs'; import isStandardSyntaxValue from '../../utils/isStandardSyntaxValue.mjs'; import report from '../../utils/report.mjs'; import ruleMessages from '../../utils/ruleMessages.mjs'; @@ -20,7 +25,6 @@ const ruleName = 'function-calc-no-unspaced-operator'; const messages = ruleMessages(ruleName, { expectedBefore: (operator) => `Expected single space before "${operator}" operator`, expectedAfter: (operator) => `Expected single space after "${operator}" operator`, - expectedOperatorBeforeSign: (operator) => `Expected an operator before sign "${operator}"`, }); const meta = { @@ -28,14 +32,23 @@ const meta = { fixable: true, }; +/** @type {(operators: Set, flags?: string) => RegExp} */ +const operatorRegex = (operators, flags = undefined) => { + const escaped = [...operators].map((token) => `\\${token}`).join(''); + + return new RegExp(`[${escaped}]`, flags); +}; + const OPERATORS = new Set(['+', '-']); -const OPERATOR_REGEX = /[+-]/; +const OPERATOR_REGEX = operatorRegex(OPERATORS); const ALL_OPERATORS = new Set([...OPERATORS, '*', '/']); // #7618 const alternatives = [...singleArgumentMathFunctions].join('|'); const FUNC_NAMES_REGEX = new RegExp(`^(?:${alternatives})$`, 'i'); const FUNC_CALLS_REGEX = new RegExp(`(?:${alternatives})\\(`, 'i'); +const NEWLINE_REGEX = /\n|\r\n/; + /** @type {import('stylelint').Rule} */ const rule = (primary, _secondaryOptions, context) => { return (root, result) => { @@ -66,167 +79,176 @@ const rule = (primary, _secondaryOptions, context) => { const valueIndex = declarationValueIndex(decl); /** - * @param {import('postcss-value-parser').WordNode} operatorNode - * @param {import('postcss-value-parser').SpaceNode} spaceNode + * @param {import('@csstools/css-parser-algorithms').TokenNode} operatorNode + * @param {import('@csstools/css-parser-algorithms').ContainerNode} parent + * @param {number} index * @param {'before' | 'after'} position * @returns {void} */ - function checkSpaceAroundOperator(operatorNode, spaceNode, position) { - const indexOfFirstNewLine = spaceNode.value.search(/(\n|\r\n)/); + function checkWhitespaceAroundOperator(operatorNode, parent, index, position) { + const aroundNode = parent.value[index + (position === 'before' ? -1 : 1)]; - if (indexOfFirstNewLine === 0) return; + if (isWhitespaceNode(aroundNode)) { + const whitespace = getWhitespace(aroundNode); - if (context.fix) { - needsFix = true; - spaceNode.value = - indexOfFirstNewLine === -1 ? ' ' : spaceNode.value.slice(indexOfFirstNewLine); + if (whitespace === ' ') return; + + const indexOfFirstNewLine = whitespace.search(NEWLINE_REGEX); + + if (indexOfFirstNewLine === 0) return; + + if (context.fix) { + needsFix = true; + const [token] = aroundNode.value; + + if (token) { + token[1] = indexOfFirstNewLine === -1 ? ' ' : whitespace.slice(indexOfFirstNewLine); + } + + return; + } + } else if ( + isTokenNode(aroundNode) && + (isOperandNode(aroundNode) || isScssInterpolation(aroundNode, parent)) + ) { + if (context.fix) { + needsFix = true; + + if (position === 'before') { + aroundNode.value[1] += ' '; + } else { + aroundNode.value[1] = ` ${aroundNode.value[1]}`; + } + + return; + } + } else { return; } - const operator = operatorNode.value; - const operatorSourceIndex = operatorNode.sourceIndex; + const delimToken = getDelimToken(operatorNode); + + if (!delimToken) return; + + const { value: operator, startIndex } = delimToken; const message = position === 'before' ? messages.expectedBefore(operator) : messages.expectedAfter(operator); - complain(message, decl, valueIndex + operatorSourceIndex, operator); + complain(message, decl, valueIndex + startIndex, operator); } /** - * @param {import('postcss-value-parser').Node[]} nodes - * @returns {boolean} + * @param {import('@csstools/css-parser-algorithms').TokenNode} operandNode + * @param {import('@csstools/css-parser-algorithms').ContainerNode} parent + * @param {number} index + * @returns {void} */ - function checkForOperatorInFirstNode(nodes) { - const [firstNode] = nodes; - - assert(firstNode); + function checkOperands(operandNode, parent, index) { + const operandToken = operandNode.value; - if (!isWord(firstNode)) return false; + if (!isOperandToken(operandToken)) return; - if (!isStandardSyntaxValue(firstNode.value)) return false; + const [, operand, operandIndex] = operandToken; - const operatorIndex = firstNode.value.search(OPERATOR_REGEX); + if (!OPERATOR_REGEX.test(operand)) return; - if (operatorIndex <= 0) return false; + if (!isStandardSyntaxValue(operand)) return; - const operator = firstNode.value.charAt(operatorIndex); - const charBefore = firstNode.value.charAt(operatorIndex - 1); - const charAfter = firstNode.value.charAt(operatorIndex + 1); + const beforeOperandNode = parent.value[index - 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, - operator, - ); - complain( - messages.expectedAfter(operator), - decl, - valueIndex + firstNode.sourceIndex + operatorIndex + 1, - operator, - ); - } - } 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, - operator, - ); - } - } + if (hasInvalidOperator(beforeOperandNode)) return; - return true; - } + const isFirstOperand = !parent.value.slice(0, index).some(isTokenNode); + const isLastOperand = !parent.value.slice(index + 1).some(isTokenNode); + const hasOperatorBeforeOperand = parent.value + .slice(0, index) + .findLast((node) => isOperatorNode(node, ALL_OPERATORS)); - /** - * @param {import('postcss-value-parser').Node[]} nodes - * @returns {boolean} - */ - function checkForOperatorInLastNode(nodes) { - if (nodes.length === 1) return false; + let fixedOperand = operand; + let fixedCount = 0; - const lastNode = nodes.at(-1); + // Scan operator characters in an operand token + for (const matched of operand.matchAll(new RegExp(OPERATOR_REGEX, 'g'))) { + const operator = matched[0]; + const operatorIndex = matched.index; - assert(lastNode); + // Ignore a number sign in the first operand, e.g. "calc(-1px)" + if (operatorIndex === 0 && isFirstOperand) continue; - if (!isWord(lastNode)) return false; + // Ignore a suffixed operator in the last operand, e.g. "calc(1px-)" + if (operatorIndex === operand.length - 1 && isLastOperand) continue; - const operatorIndex = lastNode.value.search(OPERATOR_REGEX); + const beforeOperator = operand.charAt(operatorIndex - 1); - if (operatorIndex === -1) return false; + if ((beforeOperator && beforeOperator !== ' ') || isOperandNode(beforeOperandNode)) { + if (context.fix) { + fixedOperand = insertCharAtIndex(fixedOperand, operatorIndex + fixedCount, ' '); + fixedCount++; + } else { + complain( + messages.expectedBefore(operator), + decl, + valueIndex + operandIndex + operatorIndex, + operator, + ); + } + } - // E.g. "10px * -2" when the last node is "-2" - if (isOperator(nodes.at(-3), ALL_OPERATORS) && isSingleSpace(nodes.at(-2))) { - return false; + const afterOperator = operand.charAt(operatorIndex + 1); + + if (afterOperator && afterOperator !== ' ' && !hasOperatorBeforeOperand) { + if (context.fix) { + fixedOperand = insertCharAtIndex(fixedOperand, operatorIndex + fixedCount + 1, ' '); + fixedCount++; + } else { + complain( + messages.expectedAfter(operator), + decl, + valueIndex + operandIndex + operatorIndex, + operator, + ); + } + } } - if (context.fix) { + if (fixedCount > 0) { needsFix = true; - lastNode.value = insertCharAtIndex(lastNode.value, operatorIndex + 1, ' ').trim(); - lastNode.value = insertCharAtIndex(lastNode.value, operatorIndex, ' ').trim(); - - return true; + operandToken[1] = fixedOperand; } - - const operator = lastNode.value.charAt(operatorIndex); - - complain( - messages.expectedOperatorBeforeSign(operator), - decl, - valueIndex + lastNode.sourceIndex + operatorIndex, - operator, - ); - - return true; } - const parsedValue = valueParser(value); + const nodes = parseListOfComponentValues(tokenize({ css: value })); - parsedValue.walk((node) => { - if (!isFunction(node) || !FUNC_NAMES_REGEX.test(node.value)) return; + if (nodes.length === 0) return; - const { nodes } = node; + walk(nodes, ({ node: funcNode }) => { + if (!isFunctionNode(funcNode)) return; - let foundOperatorNode = false; + if (!FUNC_NAMES_REGEX.test(funcNode.getName())) return; - for (const [nodeIndex, operatorNode] of nodes.entries()) { - if (!isOperator(operatorNode)) continue; + funcNode.forEach(({ node, parent }, index) => { + if (!isNumber(index)) return; - foundOperatorNode = true; + if (!isTokenNode(node)) return; - const nodeBefore = nodes[nodeIndex - 1]; - const nodeAfter = nodes[nodeIndex + 1]; + if (isOperatorNode(node, OPERATORS)) { + // Skip if there are no tokens before the operator. + if (!parent.value.slice(0, index).some(isTokenNode)) return; - if (nodeBefore && isSpace(nodeBefore) && nodeBefore.value !== ' ') { - checkSpaceAroundOperator(operatorNode, nodeBefore, 'before'); - } - - if (nodeAfter && isSpace(nodeAfter) && nodeAfter.value !== ' ') { - checkSpaceAroundOperator(operatorNode, nodeAfter, 'after'); + checkWhitespaceAroundOperator(node, parent, index, 'before'); + checkWhitespaceAroundOperator(node, parent, index, 'after'); + } else { + checkOperands(node, parent, index); } - } - - if (!foundOperatorNode) { - checkForOperatorInFirstNode(nodes) || checkForOperatorInLastNode(nodes); - } + }); }); if (needsFix) { - setDeclarationValue(decl, parsedValue.toString()); + setDeclarationValue(decl, stringify([nodes])); } }); }; @@ -242,20 +264,98 @@ function insertCharAtIndex(str, index, char) { } /** - * @param {import('postcss-value-parser').Node | undefined} node - * @returns {node is import('postcss-value-parser').SpaceNode} + * @param {import('@csstools/css-parser-algorithms').TokenNode} node + * @returns {{value: string, startIndex: number, endIndex: number} | undefined} + */ +function getDelimToken(node) { + const [type, value, startIndex, endIndex] = node.value; + + if (type !== 'delim-token') return; + + return { value, startIndex, endIndex }; +} + +/** + * @param {import('@csstools/css-parser-algorithms').ComponentValue} node + * @param {Set} operators + * @returns {boolean} + */ +function isOperatorNode(node, operators) { + if (!isTokenNode(node)) return false; + + return operators.has(getDelimToken(node)?.value ?? ''); +} + +/** + * @param {import('@csstools/css-parser-algorithms').WhitespaceNode} node + * @returns {string} + */ +function getWhitespace(node) { + return node.value[0]?.[1] ?? ''; +} + +/** @see https://drafts.csswg.org/css-values/#typedef-calc-value */ +const OPERAND_TOKEN_TYPES = new Set([ + 'number-token', + 'dimension-token', + 'percentage-token', + 'ident-token', +]); + +/** + * @param {import('@csstools/css-tokenizer').CSSToken} token + * @returns {boolean} + */ +function isOperandToken(token) { + const [type, value] = token; + + if (!OPERAND_TOKEN_TYPES.has(type)) return false; + + if (type === 'ident-token' && !calcKeywords.has(value)) return false; + + return true; +} + +/** + * @param {import('@csstools/css-parser-algorithms').ComponentValue | undefined} node + * @returns {boolean} + */ +function isOperandNode(node) { + return isTokenNode(node) && isOperandToken(node.value); +} + +/** + * @param {import('@csstools/css-parser-algorithms').ComponentValue | undefined} node + * @returns {boolean} */ -function isSingleSpace(node) { - return node != null && isSpace(node) && node.value === ' '; +function hasInvalidOperator(node) { + if (!isTokenNode(node)) return false; + + const delimToken = getDelimToken(node)?.value ?? ''; + + if (!delimToken) return false; + + return !ALL_OPERATORS.has(delimToken); } /** - * @param {import('postcss-value-parser').Node | undefined} node - * @param {Set} [operators] - * @returns {node is import('postcss-value-parser').WordNode} + * @deprecated This support for SCSS interpolation will be removed in the future. It remains only for backward compatiblity. + * + * @param {import('@csstools/css-parser-algorithms').TokenNode} node + * @param {import('@csstools/css-parser-algorithms').ContainerNode} parent + * @returns {boolean} */ -function isOperator(node, operators = OPERATORS) { - return node != null && isWord(node) && operators.has(node.value); +function isScssInterpolation(node, parent) { + // E.g. "#{$foo}" + if (getDelimToken(node)?.value !== '#') return false; + + const afterNode = parent.at(Number(parent.indexOf(node)) + 1); + + if (!isSimpleBlockNode(afterNode)) return false; + + const varNode = afterNode.at(0); + + return isTokenNode(varNode) && getDelimToken(varNode)?.value === '$'; } rule.ruleName = ruleName; diff --git a/tsconfig.json b/tsconfig.json index 56fe581e91..6f2f6015c8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,7 @@ "module": "Node16", "moduleResolution": "Node16", "target": "ES2022", - "lib": ["ES2022"], + "lib": ["ES2023"], "checkJs": true, "noEmit": true, "strict": true,