Skip to content

Commit

Permalink
Fix media-feature-name-no-unknown false positive for not and or (
Browse files Browse the repository at this point in the history
…#6838)


---------

Co-authored-by: Masafumi Koba <473530+ybiquitous@users.noreply.github.com>
  • Loading branch information
romainmenke and ybiquitous committed May 16, 2023
1 parent 05e4c72 commit 838ffe8
Show file tree
Hide file tree
Showing 3 changed files with 253 additions and 43 deletions.
5 changes: 5 additions & 0 deletions .changeset/hungry-bugs-suffer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"stylelint": patch
---

Fixed: `media-feature-name-no-unknown` false positives for `not` and `or`
121 changes: 121 additions & 0 deletions lib/rules/media-feature-name-no-unknown/__tests__/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,21 @@ testRule({
{
code: '@media (forced-colors: active) { }',
},
{
code: '@media not (width) { }',
},
{
code: '@media (not (width)) { }',
},
{
code: '@media (width) or (height) { }',
},
{
code: '@media ((width) or (height)) { }',
},
{
code: '@media () { }',
},
],

reject: [
Expand Down Expand Up @@ -134,12 +149,36 @@ testRule({
{
code: '@media (min-width: @tablet) { }',
},
{
code: '@media (@feature: @tablet) { }',
},
{
code: '@media (@feature: 100px) { }',
},
{
code: '@media (@feature = @tablet) { }',
},
{
code: '@media (@feature = 100px) { }',
},
{
code: '@media (@feature < @tablet) { }',
},
{
code: '@media (@mobile < @feature < @tablet) { }',
},
{
code: '@media (10px < @feature < 100px) { }',
},
{
code: '@media @smartphones and (orientation: landscape) { }',
},
{
code: '@media @smartphones { }',
},
{
code: '@media (@smartphones) { }',
},
],

reject: [
Expand All @@ -151,6 +190,38 @@ testRule({
endLine: 1,
endColumn: 16,
},
{
code: '@media (unknown < @tablet) { }',
message: messages.rejected('unknown'),
line: 1,
column: 9,
endLine: 1,
endColumn: 16,
},
{
code: '@media (unknown = @tablet) { }',
message: messages.rejected('unknown'),
line: 1,
column: 9,
endLine: 1,
endColumn: 16,
},
{
code: '@media (@tablet = unknown) { }',
message: messages.rejected('unknown'),
line: 1,
column: 19,
endLine: 1,
endColumn: 26,
},
{
code: '@media (@mobile <= unknown < @tablet) { }',
message: messages.rejected('unknown'),
line: 1,
column: 20,
endLine: 1,
endColumn: 27,
},
],
});

Expand Down Expand Up @@ -184,6 +255,24 @@ testRule({
{
code: '@media #{$feature-name} { }',
},
{
code: '@media ($feature: 100px) { }',
},
{
code: '@media ($feature = $tablet) { }',
},
{
code: '@media ($feature = 100px) { }',
},
{
code: '@media ($feature < $tablet) { }',
},
{
code: '@media ($mobile < $feature < $tablet) { }',
},
{
code: '@media (10px < $feature < 100px) { }',
},
],

reject: [
Expand All @@ -195,6 +284,38 @@ testRule({
endLine: 1,
endColumn: 16,
},
{
code: '@media (unknown < $tablet) { }',
message: messages.rejected('unknown'),
line: 1,
column: 9,
endLine: 1,
endColumn: 16,
},
{
code: '@media (unknown = $tablet) { }',
message: messages.rejected('unknown'),
line: 1,
column: 9,
endLine: 1,
endColumn: 16,
},
{
code: '@media ($tablet = unknown) { }',
message: messages.rejected('unknown'),
line: 1,
column: 19,
endLine: 1,
endColumn: 26,
},
{
code: '@media ($mobile <= unknown < $tablet) { }',
message: messages.rejected('unknown'),
line: 1,
column: 20,
endLine: 1,
endColumn: 27,
},
],
});

Expand Down
170 changes: 127 additions & 43 deletions lib/rules/media-feature-name-no-unknown/index.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
'use strict';

const {
isMediaFeature,
isMediaQueryInvalid,
isGeneralEnclosed,
} = require('@csstools/media-query-list-parser');
const { TokenType, isToken } = require('@csstools/css-tokenizer');
const { isTokenNode } = require('@csstools/css-parser-algorithms');

const atRuleParamIndex = require('../../utils/atRuleParamIndex');
const isCustomMediaQuery = require('../../utils/isCustomMediaQuery');
const isRangeContextMediaFeature = require('../../utils/isRangeContextMediaFeature');
const isStandardSyntaxMediaFeatureName = require('../../utils/isStandardSyntaxMediaFeatureName');
const { mediaFeatureNames } = require('../../reference/mediaFeatures');
const mediaParser = require('postcss-media-query-parser').default;
const optionsMatches = require('../../utils/optionsMatches');
const rangeContextNodeParser = require('../rangeContextNodeParser');
const parseMediaQuery = require('../../utils/parseMediaQuery');
const report = require('../../utils/report');
const ruleMessages = require('../../utils/ruleMessages');
const validateOptions = require('../../utils/validateOptions');
Expand All @@ -24,6 +30,8 @@ const meta = {
url: 'https://stylelint.io/user-guide/rules/media-feature-name-no-unknown',
};

const rangeFeatureOperator = /[<>=]/;

/** @type {import('stylelint').Rule} */
const rule = (primary, secondaryOptions) => {
return (root, result) => {
Expand All @@ -44,53 +52,129 @@ const rule = (primary, secondaryOptions) => {
return;
}

/**
* @param {import('postcss').AtRule} atRule
* @param {string} featureName
* @param {number} startIndex
* @param {number} endIndex
*/
const validateFeatureName = (atRule, featureName, startIndex, endIndex) => {
if (!isStandardSyntaxMediaFeatureName(featureName) || isCustomMediaQuery(featureName)) {
return;
}

if (optionsMatches(secondaryOptions, 'ignoreMediaFeatureNames', featureName)) {
return;
}

if (vendor.prefix(featureName) || mediaFeatureNames.has(featureName.toLowerCase())) {
return;
}

const atRuleIndex = atRuleParamIndex(atRule);

report({
message: messages.rejected,
messageArgs: [featureName],
node: atRule,
index: atRuleIndex + startIndex,
endIndex: atRuleIndex + endIndex + 1,
ruleName,
result,
});
};

root.walkAtRules(/^media$/i, (atRule) => {
mediaParser(atRule.params).walk(/^media-feature$/i, (mediaFeatureNode) => {
const parent = mediaFeatureNode.parent;
const mediaFeatureRangeContext = isRangeContextMediaFeature(parent.value);

let value;
let sourceIndex;

if (mediaFeatureRangeContext) {
const parsedRangeContext = rangeContextNodeParser(mediaFeatureNode);

value = parsedRangeContext.name.value;
sourceIndex = parsedRangeContext.name.sourceIndex;
} else {
value = mediaFeatureNode.value;
sourceIndex = mediaFeatureNode.sourceIndex;
}

if (!isStandardSyntaxMediaFeatureName(value) || isCustomMediaQuery(value)) {
return;
}

if (optionsMatches(secondaryOptions, 'ignoreMediaFeatureNames', value)) {
return;
}

if (vendor.prefix(value) || mediaFeatureNames.has(value.toLowerCase())) {
return;
}

const index = atRuleParamIndex(atRule) + sourceIndex;
const endIndex = index + value.length;

report({
index,
endIndex,
message: messages.rejected,
messageArgs: [value],
node: atRule,
ruleName,
result,
const mediaQueryList = parseMediaQuery(atRule);

mediaQueryList.forEach((mediaQuery) => {
if (isMediaQueryInvalid(mediaQuery)) return;

mediaQuery.walk(({ node }) => {
if (isMediaFeature(node)) {
const [, , startIndex, endIndex] = node.getNameToken();

validateFeatureName(atRule, node.getName(), startIndex, endIndex);

return;
}

if (isGeneralEnclosed(node)) {
const relevantTokens = topLevelTokenNodes(node);

if (!relevantTokens) {
return;
}

relevantTokens.forEach((token, i) => {
if (token[0] !== TokenType.Ident) {
return;
}

const nextToken = relevantTokens[i + 1];
const prevToken = relevantTokens[i - 1];

if (
// Media Feature
(!prevToken && nextToken && nextToken[0] === TokenType.Colon) ||
// Range Feature
(nextToken &&
nextToken[0] === TokenType.Delim &&
rangeFeatureOperator.test(nextToken[4].value)) ||
// Range Feature
(prevToken &&
prevToken[0] === TokenType.Delim &&
rangeFeatureOperator.test(prevToken[4].value))
) {
const [, , startIndex, endIndex, { value: featureName }] = token;

validateFeatureName(atRule, featureName, startIndex, endIndex);
}
});
}
});
});
});
};
};

/** @param {import('@csstools/media-query-list-parser').GeneralEnclosed} node */
function topLevelTokenNodes(node) {
const components = node.value.value;

if (isToken(components) || components.length === 0 || isToken(components[0])) {
return false;
}

/** @type {Array<import('@csstools/css-tokenizer').CSSToken>} */
const relevantTokens = [];

// To consume the next token if it is a scss variable
let lastWasDollarSign = false;

components.forEach((component) => {
// Only preserve top level tokens (idents, delims, ...)
// Discard all blocks, functions, ...
if (component && isTokenNode(component)) {
if (component.value[0] === TokenType.Delim && component.value[4].value === '$') {
lastWasDollarSign = true;

return;
}

if (lastWasDollarSign) {
lastWasDollarSign = false;

return;
}

relevantTokens.push(component.value);
}
});

return relevantTokens;
}

rule.ruleName = ruleName;
rule.messages = messages;
rule.meta = meta;
Expand Down

0 comments on commit 838ffe8

Please sign in to comment.