diff --git a/.changeset/hungry-cherries-promise.md b/.changeset/hungry-cherries-promise.md new file mode 100644 index 0000000000..e7b7c97857 --- /dev/null +++ b/.changeset/hungry-cherries-promise.md @@ -0,0 +1,5 @@ +--- +"stylelint": minor +--- + +Added: `insideFunctions: {"function": int}` to `number-max-precision` diff --git a/lib/rules/number-max-precision/README.md b/lib/rules/number-max-precision/README.md index 2c1cf45659..d8728a18b0 100644 --- a/lib/rules/number-max-precision/README.md +++ b/lib/rules/number-max-precision/README.md @@ -133,3 +133,44 @@ a { width: 10.989my-other-unit; } ``` + +### `insideFunctions: {"/regex/": int, /regex/: int, "string": int}` + +The `insideFunctions` option can change a primary option value for specified functions. + +For example, with `2`. + +Given: + +```json +{"/^(oklch|oklab|lch|lab)$/", 4} +``` + +The following patterns are considered problems: + + +```css +a { color: rgb(127.333 0 0); } +``` + + +```css +a { color: rgb(calc(127.333 / 3) 0 0); } +``` + +The following patterns are _not_ considered problems: + + +```css +a { color: oklch(0.333 0 0); } +``` + + +```css +a { color: lab(0.3333 0 0); } +``` + + +```css +a { color: oklab(calc(127.333 / 3) 0 0); } +``` diff --git a/lib/rules/number-max-precision/__tests__/index.js b/lib/rules/number-max-precision/__tests__/index.js index 3d5c905735..eab6754b40 100644 --- a/lib/rules/number-max-precision/__tests__/index.js +++ b/lib/rules/number-max-precision/__tests__/index.js @@ -41,7 +41,7 @@ testRule({ code: "@IMPORT '1.123.css'", }, { - code: 'a { background: url(1.123.jpg) }', + code: 'a { background: url(1.123.jpg) url("foo" 1.123.jpg); }', }, { code: 'a { my-string: "1.2345"; }', @@ -373,3 +373,62 @@ testRule({ }, ], }); + +testRule({ + ruleName, + config: [ + 2, + { + insideFunctions: { + oklch: 4, + zero: 0, + one: 1, + '/^two/': 2, + [/^three/i]: 3, + }, + }, + ], + + accept: [ + { + code: 'a { color: oklch(0.1234 0.1234 0.1234); }', + }, + { + code: 'a { color: oklch(calc(0.1234 + 1.0001) 0.1234 0.1234); }', + }, + { + code: 'a { color: zero(0, one(1.1, two(2.22%, three(3.333px)))); }', + }, + { + code: 'a { color: ONE(1.1); }', + }, + { + code: 'a { color: two(2.22) two-percentage(2.22%); }', + }, + { + code: 'a { color: ThReE(3.333px) three-percentage(3.333%); }', + }, + { + code: 'a { color: three(zero(0) 3.333); }', + }, + ], + + reject: [ + { + code: 'a { color: oklch(0.12345 0.1234 0.1234); }', + message: messages.expected(0.12345, 0.1235), + line: 1, + column: 18, + endLine: 1, + endColumn: 25, + }, + { + code: 'a { color: zero(0, one(1.1, two(2.22, ThReE(3.333, zero(calc(0.1)))))); }', + message: messages.expected(0.1, 0), + line: 1, + column: 62, + endLine: 1, + endColumn: 65, + }, + ], +}); diff --git a/lib/rules/number-max-precision/index.js b/lib/rules/number-max-precision/index.js index ed48707c8f..027950635d 100644 --- a/lib/rules/number-max-precision/index.js +++ b/lib/rules/number-max-precision/index.js @@ -1,16 +1,24 @@ 'use strict'; -const valueParser = require('postcss-value-parser'); +const { tokenize, TokenType } = require('@csstools/css-tokenizer'); +const { + isFunctionNode, + isSimpleBlockNode, + isTokenNode, + parseListOfComponentValues, +} = require('@csstools/css-parser-algorithms'); const atRuleParamIndex = require('../../utils/atRuleParamIndex'); const declarationValueIndex = require('../../utils/declarationValueIndex'); -const getDimension = require('../../utils/getDimension'); +const getAtRuleParams = require('../../utils/getAtRuleParams'); +const getDeclarationValue = require('../../utils/getDeclarationValue'); +const matchesStringOrRegExp = require('../../utils/matchesStringOrRegExp'); const optionsMatches = require('../../utils/optionsMatches'); const report = require('../../utils/report'); const ruleMessages = require('../../utils/ruleMessages'); -const { isAtRule } = require('../../utils/typeGuards'); const validateOptions = require('../../utils/validateOptions'); const { isNumber, isRegExp, isString } = require('../../utils/validateTypes'); +const validateObjectWithProps = require('../../utils/validateObjectWithProps'); const ruleName = 'number-max-precision'; @@ -22,7 +30,7 @@ const meta = { url: 'https://stylelint.io/user-guide/rules/number-max-precision', }; -/** @type {import('stylelint').Rule} */ +/** @type {import('stylelint').Rule} */ const rule = (primary, secondaryOptions) => { return (root, result) => { const validOptions = validateOptions( @@ -38,6 +46,7 @@ const rule = (primary, secondaryOptions) => { possible: { ignoreProperties: [isString, isRegExp], ignoreUnits: [isString, isRegExp], + insideFunctions: [validateObjectWithProps(isNumber)], }, }, ); @@ -46,21 +55,28 @@ const rule = (primary, secondaryOptions) => { return; } + /** @type {Map} */ + const insideFunctions = new Map(Object.entries(secondaryOptions?.insideFunctions ?? {})); + root.walkAtRules((atRule) => { if (atRule.name.toLowerCase() === 'import') { return; } - check(atRule, atRule.params); + check(atRule, atRuleParamIndex, getAtRuleParams(atRule)); }); - root.walkDecls((decl) => check(decl, decl.value)); + root.walkDecls((decl) => { + check(decl, declarationValueIndex, getDeclarationValue(decl)); + }); /** - * @param {import('postcss').AtRule | import('postcss').Declaration} node + * @template {import('postcss').AtRule | import('postcss').Declaration} T + * @param {T} node + * @param {(node: T) => number} getIndex * @param {string} value */ - function check(node, value) { + function check(node, getIndex, value) { // Get out quickly if there are no periods if (!value.includes('.')) { return; @@ -72,47 +88,123 @@ const rule = (primary, secondaryOptions) => { return; } - valueParser(value).walk((valueNode) => { - const { unit } = getDimension(valueNode); + parseListOfComponentValues(tokenize({ css: value })).forEach((componentValue) => { + const state = { + ignored: false, + precision: primary, + }; - if (optionsMatches(secondaryOptions, 'ignoreUnits', unit)) { - return; - } + walker(node, getIndex, componentValue, state); + + if (isFunctionNode(componentValue) || isSimpleBlockNode(componentValue)) { + componentValue.walk((entry) => { + if (!entry.state) return; - // Ignore `url` function - if (valueNode.type === 'function' && valueNode.value.toLowerCase() === 'url') { - return false; + if (entry.state.ignored) return; + + walker(node, getIndex, entry.node, entry.state); + }, state); } + }); + } + + /** + * @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; - // Ignore strings, comments, etc - if (valueNode.type !== 'word') { return; } - const match = /\d*\.(\d+)/.exec(valueNode.value); + 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; + } - if (match == null || match[0] == null || match[1] == null) { + 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 (match[1].length <= primary) { + 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); - const baseIndex = isAtRule(node) ? atRuleParamIndex(node) : declarationValueIndex(node); - const actual = Number.parseFloat(match[0]); - - report({ - result, - ruleName, - node, - index: baseIndex + valueNode.sourceIndex + match.index, - word: actual.toString(), - message: messages.expected, - messageArgs: [actual, actual.toFixed(primary)], - }); + 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); + + if (isNumber(precisionForFunction)) return precisionForFunction; + + for (const [name, precision] of insideFunctions) { + if (matchesStringOrRegExp(functionName, name)) { + return precision; + } + } + + return currentPrecision; + } }; };