Skip to content

Commit

Permalink
Add insideFunctions: {"function": int} to number-max-precision (#…
Browse files Browse the repository at this point in the history
…6932)



Co-authored-by: Masafumi Koba <473530+ybiquitous@users.noreply.github.com>
  • Loading branch information
romainmenke and ybiquitous committed Jun 19, 2023
1 parent a68bee5 commit 719e446
Show file tree
Hide file tree
Showing 4 changed files with 231 additions and 34 deletions.
5 changes: 5 additions & 0 deletions .changeset/hungry-cherries-promise.md
@@ -0,0 +1,5 @@
---
"stylelint": minor
---

Added: `insideFunctions: {"function": int}` to `number-max-precision`
41 changes: 41 additions & 0 deletions lib/rules/number-max-precision/README.md
Expand Up @@ -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:

<!-- prettier-ignore -->
```css
a { color: rgb(127.333 0 0); }
```

<!-- prettier-ignore -->
```css
a { color: rgb(calc(127.333 / 3) 0 0); }
```

The following patterns are _not_ considered problems:

<!-- prettier-ignore -->
```css
a { color: oklch(0.333 0 0); }
```

<!-- prettier-ignore -->
```css
a { color: lab(0.3333 0 0); }
```

<!-- prettier-ignore -->
```css
a { color: oklab(calc(127.333 / 3) 0 0); }
```
61 changes: 60 additions & 1 deletion lib/rules/number-max-precision/__tests__/index.js
Expand Up @@ -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"; }',
Expand Down Expand Up @@ -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,
},
],
});
158 changes: 125 additions & 33 deletions 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';

Expand All @@ -22,7 +30,7 @@ const meta = {
url: 'https://stylelint.io/user-guide/rules/number-max-precision',
};

/** @type {import('stylelint').Rule} */
/** @type {import('stylelint').Rule<number>} */
const rule = (primary, secondaryOptions) => {
return (root, result) => {
const validOptions = validateOptions(
Expand All @@ -38,6 +46,7 @@ const rule = (primary, secondaryOptions) => {
possible: {
ignoreProperties: [isString, isRegExp],
ignoreUnits: [isString, isRegExp],
insideFunctions: [validateObjectWithProps(isNumber)],
},
},
);
Expand All @@ -46,21 +55,28 @@ const rule = (primary, secondaryOptions) => {
return;
}

/** @type {Map<string, number>} */
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;
Expand All @@ -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;
}
};
};

Expand Down

0 comments on commit 719e446

Please sign in to comment.