import { TokenType, tokenize } from '@csstools/css-tokenizer'; import { isFunctionNode, isTokenNode, parseListOfComponentValues, walk, } from '@csstools/css-parser-algorithms'; import { atRuleParamIndex, declarationValueIndex } from '../../utils/nodeFieldIndices.mjs'; import { isRegExp, isString } from '../../utils/validateTypes.mjs'; import getAtRuleParams from '../../utils/getAtRuleParams.mjs'; import getDeclarationValue from '../../utils/getDeclarationValue.mjs'; import isNonNegativeInteger from '../../utils/isNonNegativeInteger.mjs'; import matchesStringOrRegExp from '../../utils/matchesStringOrRegExp.mjs'; import optionsMatches from '../../utils/optionsMatches.mjs'; import report from '../../utils/report.mjs'; import ruleMessages from '../../utils/ruleMessages.mjs'; import validateObjectWithProps from '../../utils/validateObjectWithProps.mjs'; import validateOptions from '../../utils/validateOptions.mjs'; const ruleName = 'number-max-precision'; const messages = ruleMessages(ruleName, { expected: (actual, expected) => `Expected "${actual}" to be "${expected}"`, }); const meta = { url: 'https://stylelint.io/user-guide/rules/number-max-precision', }; /** @type {import('stylelint').CoreRules[ruleName]} */ const rule = (primary, secondaryOptions) => { return (root, result) => { const validOptions = validateOptions( result, ruleName, { actual: primary, possible: [isNonNegativeInteger], }, { optional: true, actual: secondaryOptions, possible: { ignoreProperties: [isString, isRegExp], ignoreUnits: [isString, isRegExp], insideFunctions: [validateObjectWithProps(isNonNegativeInteger)], }, }, ); if (!validOptions) { return; } /** @type {Map} */ const insideFunctions = new Map(Object.entries(secondaryOptions?.insideFunctions ?? {})); root.walkAtRules((atRule) => { if (atRule.name.toLowerCase() === 'import') { return; } check(atRule, atRuleParamIndex, getAtRuleParams(atRule)); }); root.walkDecls((decl) => { check(decl, declarationValueIndex, getDeclarationValue(decl)); }); /** * @template {import('postcss').AtRule | import('postcss').Declaration} T * @param {T} node * @param {(node: T) => number} getIndex * @param {string} value */ function check(node, getIndex, value) { // Get out quickly if there are no periods if (!value.includes('.')) return; const prop = 'prop' in node ? node.prop : undefined; if (optionsMatches(secondaryOptions, 'ignoreProperties', prop)) { return; } const initialState = { ignored: false, precision: primary, }; walk( parseListOfComponentValues(tokenize({ css: value })), ({ node: mediaNode, state }) => { if (!state) return; if (state.ignored) return; walker(node, getIndex, mediaNode, state); }, initialState, ); } /** * @template {import('postcss').AtRule | import('postcss').Declaration} T * @param {T} node * @param {(node: T) => number} getIndex * @param {import('@csstools/css-parser-algorithms').ComponentValue} componentValue * @param {{ ignored: boolean, precision: number }} state */ function walker(node, getIndex, componentValue, state) { if (isFunctionNode(componentValue)) { const name = componentValue.getName().toLowerCase(); if (name === 'url') { // postcss-value-parser exposed url token contents as "word" tokens, these were indistinguishable from numeric values in any other function. // With @csstools/css-tokenizer this is no longer relevant, but we preserve the old condition to avoid breaking changes. state.ignored = true; return; } state.precision = precisionInsideFunction(name, state.precision); return; } if (!isTokenNode(componentValue)) { return; } const [tokenType, raw, startIndex, endIndex, parsedValue] = componentValue.value; if ( tokenType !== TokenType.Number && tokenType !== TokenType.Dimension && tokenType !== TokenType.Percentage ) { return; } let unitStringLength = 0; if (tokenType === TokenType.Dimension) { const unit = parsedValue.unit; unitStringLength = unit.length; if (optionsMatches(secondaryOptions, 'ignoreUnits', unit)) { return; } } else if (tokenType === TokenType.Percentage) { unitStringLength = 1; if (optionsMatches(secondaryOptions, 'ignoreUnits', '%')) { return; } } const match = /\d*\.(\d+)/.exec(raw); if (match == null || match[0] == null || match[1] == null) { return; } if (match[1].length <= state.precision) { return; } const nodeIndex = getIndex(node); report({ result, ruleName, node, index: nodeIndex + startIndex, endIndex: nodeIndex + (endIndex + 1) - unitStringLength, message: messages.expected, messageArgs: [parsedValue.value, parsedValue.value.toFixed(state.precision)], }); } /** * @param {string} functionName * @param {number} currentPrecision * @returns {number} */ function precisionInsideFunction(functionName, currentPrecision) { const precisionForFunction = insideFunctions.get(functionName); const hasPrecision = typeof precisionForFunction !== 'undefined'; if (hasPrecision) return precisionForFunction; for (const [name, precision] of insideFunctions) { if (matchesStringOrRegExp(functionName, name)) { return precision; } } return currentPrecision; } }; }; rule.ruleName = ruleName; rule.messages = messages; rule.meta = meta; export default rule;