Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor to migrate unit-disallowed-list to new media query parser #6886

31 changes: 29 additions & 2 deletions lib/rules/unit-disallowed-list/__tests__/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ const { messages, ruleName } = require('..');
testRule({
ruleName,

config: ['px', 'vmin'],
config: ['px', 'vmin', 'f'],

accept: [
{
Expand Down Expand Up @@ -118,6 +118,9 @@ testRule({
code: '@media (min-width: 10em)\n and (max-width: 20em) {}',
description: 'complex @media',
},
{
code: '@font-face { unicode-range: U+0100-024F; }',
},
],

reject: [
Expand Down Expand Up @@ -231,7 +234,7 @@ testRule({
line: 1,
column: 38,
endLine: 1,
endColumn: 41,
endColumn: 40,
},
{
code: '@media (min-width: 13px) {}',
Expand Down Expand Up @@ -260,6 +263,24 @@ testRule({
endLine: 1,
endColumn: 24,
},
{
code: '@font-face { color: U+0100-024F; }',
message: messages.rejected('F'),
description: 'Unicode range value in something other than `unicode-range`',
line: 1,
column: 31,
endLine: 1,
endColumn: 32,
},
{
code: 'a { unicode-range: U+0100-024F; }',
message: messages.rejected('F'),
description: 'Unicode range value in something other than `@font-face`',
line: 1,
column: 30,
endLine: 1,
endColumn: 31,
},
],
});

Expand Down Expand Up @@ -402,6 +423,12 @@ testRule({
{
code: 'a { margin: calc(50% - 100px) }',
},
{
code: 'a { margin: min(calc(50px * 2)) }',
},
{
code: 'a { margin: calc(max(50px)) }',
},
{
code: 'a { transform: translate(100px, 100px) }',
},
Expand Down
237 changes: 146 additions & 91 deletions lib/rules/unit-disallowed-list/index.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,26 @@
'use strict';

const { TokenType, tokenize, NumberType } = require('@csstools/css-tokenizer');
const {
isTokenNode,
isFunctionNode,
parseCommaSeparatedListOfComponentValues,
isSimpleBlockNode,
} = require('@csstools/css-parser-algorithms');
const { parseFromTokens, isMediaFeature } = require('@csstools/media-query-list-parser');

const atRuleParamIndex = require('../../utils/atRuleParamIndex');
const declarationValueIndex = require('../../utils/declarationValueIndex');
const getDimension = require('../../utils/getDimension');
const mediaParser = require('postcss-media-query-parser').default;
const getAtRuleParams = require('../../utils/getAtRuleParams');
const getDeclarationValue = require('../../utils/getDeclarationValue');
const hasDimension = require('../../utils/hasDimension');
const optionsMatches = require('../../utils/optionsMatches');
const report = require('../../utils/report');
const ruleMessages = require('../../utils/ruleMessages');
const validateObjectWithArrayProps = require('../../utils/validateObjectWithArrayProps');
const validateOptions = require('../../utils/validateOptions');
const valueParser = require('postcss-value-parser');
const { isRegExp, isString } = require('../../utils/validateTypes');
const isUnicodeRangeDescriptor = require('../../utils/isUnicodeRangeDescriptor');

const ruleName = 'unit-disallowed-list';

Expand All @@ -22,21 +32,6 @@ const meta = {
url: 'https://stylelint.io/user-guide/rules/unit-disallowed-list',
};

/**
* a function to retrieve only the media feature name
* could be externalized in an utils function if needed in other code
*
* @param {import('postcss-media-query-parser').Child} mediaFeatureNode
* @returns {string | undefined}
*/
const getMediaFeatureName = (mediaFeatureNode) => {
const value = mediaFeatureNode.value.toLowerCase();

const match = /((?:-?\w*)*)/.exec(value);

return match ? match[1] : undefined;
};

/** @type {import('stylelint').Rule<string | string[]>} */
const rule = (primary, secondaryOptions) => {
return (root, result) => {
Expand Down Expand Up @@ -65,29 +60,52 @@ const rule = (primary, secondaryOptions) => {
const primaryValues = [primary].flat();

/**
* @param {import('postcss').Node} node
* @param {number} nodeIndex
* @param {import('postcss-value-parser').Node} valueNode
* @param {string | undefined} input
* @param {Record<string, unknown>} options
* Ignore wrong units within `url` function
* Ignore units within function that match `ignoreFunctions` option
*
* @param {import('@csstools/css-parser-algorithms').ComponentValue} componentValue
* @returns {boolean}
*/
function componentValueIsIgnored(componentValue) {
if (!isFunctionNode(componentValue)) {
return false;
}

const name = componentValue.getName().toLowerCase();

return name === 'url' || optionsMatches(secondaryOptions, 'ignoreFunctions', name);
}

/**
* @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 {string} input
* @param {Record<string, unknown> | undefined} options
* @returns {void}
*/
function check(node, nodeIndex, valueNode, input, options) {
const { number, unit } = getDimension(valueNode);
function check(node, getIndex, componentValue, input, options) {
if (!isTokenNode(componentValue)) return;

if (componentValue.value[0] !== TokenType.Dimension) return;

const [, , , endIndex, { unit }] = componentValue.value;

// There is not unit or it is not configured as a problem
if (!number || !unit || !primaryValues.includes(unit.toLowerCase())) {
const lowerCaseUnit = unit.toLowerCase();

if (!primaryValues.includes(lowerCaseUnit)) {
return;
}

// The unit has an ignore option for the specific input
if (optionsMatches(options, unit.toLowerCase(), input)) {
return;
}
if (options && optionsMatches(options, lowerCaseUnit, input)) return;

const startIndex = getIndex(node) + (endIndex + 1) - unit.length;

report({
index: nodeIndex + valueNode.sourceIndex + number.length,
endIndex: nodeIndex + valueNode.sourceEndIndex,
index: startIndex,
endIndex: startIndex + unit.length,
message: messages.rejected,
messageArgs: [unit],
node,
Expand All @@ -96,75 +114,112 @@ const rule = (primary, secondaryOptions) => {
});
}

/**
* @template {import('postcss').AtRule} T
* @param {T} node
* @param {string} value
* @param {(node: T) => number} getIndex
* @returns {void}
*/
function checkMedia(node, value, getIndex) {
mediaParser(node.params).walk(/^media-feature$/i, (mediaFeatureNode) => {
const mediaName = getMediaFeatureName(mediaFeatureNode);
const parentValue = mediaFeatureNode.parent.value;

valueParser(value).walk((valueNode) => {
// Ignore all non-word valueNode and
// the values not included in the parentValue string
if (valueNode.type !== 'word' || !parentValue.includes(valueNode.value)) {
return;
root.walkAtRules(/^media$/i, (atRule) => {
const params = getAtRuleParams(atRule);

if (!hasDimension(params)) return;

parseFromTokens(tokenizeWithoutPercentageTokens(params)).forEach((mediaQuery) => {
/** @type {{ mediaFeatureName: string | undefined }} */
const state = {
mediaFeatureName: undefined,
};

mediaQuery.walk((entry) => {
if (!entry.state) return;

if (isMediaFeature(entry.node)) {
entry.state.mediaFeatureName = entry.node.getName().toLowerCase();
}

if (!entry.state.mediaFeatureName) return;

if (!isTokenNode(entry.node)) return;

check(
node,
getIndex(node),
valueNode,
mediaName,
secondaryOptions ? secondaryOptions.ignoreMediaFeatureNames : {},
atRule,
atRuleParamIndex,
entry.node,
entry.state.mediaFeatureName,
secondaryOptions?.ignoreMediaFeatureNames,
);
});
}, state);
});
}
});
root.walkDecls((decl) => {
if (isUnicodeRangeDescriptor(decl)) return;

const value = getDeclarationValue(decl);

if (!hasDimension(value)) return;

parseCommaSeparatedListOfComponentValues(tokenize({ css: value }))
.flatMap((x) => x)
.forEach((componentValue) => {
if (isTokenNode(componentValue)) {
check(
decl,
declarationValueIndex,
componentValue,
decl.prop,
secondaryOptions?.ignoreProperties,
);

/**
* @template {import('postcss').Declaration} T
* @param {T} node
* @param {string} value
* @param {(node: T) => number} getIndex
* @returns {void}
*/
function checkDecl(node, value, getIndex) {
// make sure multiplication operations (*) are divided - not handled
// by postcss-value-parser
value = value.replace(/\*/g, ',');

valueParser(value).walk((valueNode) => {
const valueLowerCase = valueNode.value.toLowerCase();

// Ignore wrong units within `url` function
if (
valueNode.type === 'function' &&
(valueLowerCase === 'url' ||
optionsMatches(secondaryOptions, 'ignoreFunctions', valueLowerCase))
) {
return false;
}

check(
node,
getIndex(node),
valueNode,
node.prop,
secondaryOptions ? secondaryOptions.ignoreProperties : {},
);
});
}
return;
}

if (!isFunctionNode(componentValue) && !isSimpleBlockNode(componentValue)) return;

const state = {
ignored: componentValueIsIgnored(componentValue),
};

componentValue.walk((entry) => {
if (!entry.state) return;

if (entry.state.ignored) return;

root.walkAtRules(/^media$/i, (atRule) => checkMedia(atRule, atRule.params, atRuleParamIndex));
root.walkDecls((decl) => checkDecl(decl, decl.value, declarationValueIndex));
if (isTokenNode(entry.node)) {
check(
decl,
declarationValueIndex,
entry.node,
decl.prop,
secondaryOptions?.ignoreProperties,
);

return;
}

if (componentValueIsIgnored(entry.node)) {
entry.state.ignored = true;
}
}, state);
});
});
};
};

/**
* @param {string} value
* @returns {Array<import('@csstools/css-tokenizer').CSSToken>}
*/
function tokenizeWithoutPercentageTokens(value) {
return tokenize({ css: value }).map((x) => {
if (x[0] !== TokenType.Percentage) return x;

// Percentage values are not valid in media queries, so we can't parse them and get something valid back.
// Dimension tokens with a unit of "%" work just fine.
return [
TokenType.Dimension,
x[1],
x[2],
x[3],
{ value: x[4].value, unit: '%', type: NumberType.Number },
];
});
}

rule.primaryOptionArray = true;

rule.ruleName = ruleName;
Expand Down
4 changes: 2 additions & 2 deletions lib/rules/unit-no-unknown/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const atRuleParamIndex = require('../../utils/atRuleParamIndex');
const declarationValueIndex = require('../../utils/declarationValueIndex');
const getAtRuleParams = require('../../utils/getAtRuleParams');
const getDeclarationValue = require('../../utils/getDeclarationValue');
const hasDimension = require('../../utils/hasDimension');
const isStandardSyntaxAtRule = require('../../utils/isStandardSyntaxAtRule');
const isStandardSyntaxDeclaration = require('../../utils/isStandardSyntaxDeclaration');
const isStandardSyntaxValue = require('../../utils/isStandardSyntaxValue');
Expand All @@ -35,7 +36,6 @@ const meta = {
url: 'https://stylelint.io/user-guide/rules/unit-no-unknown',
};

const HAS_DIMENSION_LIKE_VALUES = /\d[\w-]/;
const RESOLUTION_MEDIA_FEATURE_NAME = /^(?:min-|max-)?resolution$/i;

/** @type {import('stylelint').Rule} */
Expand Down Expand Up @@ -63,7 +63,7 @@ const rule = (primary, secondaryOptions) => {
* @param {string} value
*/
const tokenizeIfValueMightContainUnknownUnits = (value) => {
if (!HAS_DIMENSION_LIKE_VALUES.test(value)) return;
if (!hasDimension(value)) return;

const tokens = tokenize({ css: value });
const hasUnknownUnits = tokens.some((token) => {
Expand Down
13 changes: 13 additions & 0 deletions lib/utils/hasDimension.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
'use strict';

const HAS_DIMENSION_LIKE_VALUES = /\d[%\w-]/;

/**
* Check if a value contains any dimension-like values.
*
* @param {string} value
* @returns {boolean}
*/
module.exports = function hasDimension(value) {
return HAS_DIMENSION_LIKE_VALUES.test(value);
};