UNPKG

stylelint-scss

Version:

A collection of SCSS-specific rules for Stylelint

599 lines (486 loc) 18.4 kB
import { ParseErrorMessage, calcFromComponentValues } from "@csstools/css-calc"; import { TokenType, tokenize } from "@csstools/css-tokenizer"; import { lexer as cssTreelexer, find, fork, parse, string } from "css-tree"; import { isFunctionNode, parseListOfComponentValues, stringify, walk } from "@csstools/css-parser-algorithms"; import stylelint from "stylelint"; import { atRuleRegexes, mayIncludeRegexes } from "../../utils/regexes.js"; import { isAtRule, isDeclaration } from "../../utils/typeGuards.js"; import { isDollarVar, isFunctionCall, isIfStatement, isNestedProperty, isRegExp, isString } from "../../utils/validateTypes.js"; import declarationValueIndex from "../../utils/declarationValueIndex.js"; import findOperators from "../../utils/sassValueParser/index.js"; import getDeclarationValue from "../../utils/getDeclarationValue.js"; import isCustomProperty from "../../utils/isCustomPropertySet.js"; import isDescriptorDeclaration from "../../utils/isDescriptorDeclaration.js"; import isStandardSyntaxDeclaration from "../../utils/isStandardSyntaxDeclaration.js"; import isStandardSyntaxProperty from "../../utils/isStandardSyntaxProperty.js"; import isStandardSyntaxValue from "../../utils/isStandardSyntaxValue.js"; import namespace from "../../utils/namespace.js"; import { nestingSupportedAtKeywords } from "../../utils/atKeywords.js"; import optionsMatchesEntry from "../../utils/optionsMatchesEntry.js"; import { parseFunctionArguments } from "../../utils/parseFunctionArguments.js"; import ruleUrl from "../../utils/ruleUrl.js"; import syntaxPatchesJson from "@csstools/css-syntax-patches-for-csstree" with { type: "json" }; import validateObjectWithArrayProps from "../../utils/validateObjectWithArrayProps.js"; import validateObjectWithProps from "../../utils/validateObjectWithProps.js"; const { next: syntaxPatches } = syntaxPatchesJson; const { utils } = stylelint; const ruleName = namespace("declaration-property-value-no-unknown"); const messages = utils.ruleMessages(ruleName, { rejected: (property, value) => `Unknown value "${value}" for property "${property}"`, rejectedParseError: (property, value) => `Cannot parse property value "${value}" for property "${property}"`, rejectedMath: (property, expression) => `Invalid math expression "${expression}" for property "${property}"` }); const meta = { url: ruleUrl(ruleName) }; const SYNTAX_DESCRIPTOR = /^syntax$/i; const UNSUPPORTED_FUNCTIONS_IN_CSSTREE = new Set([ "clamp", "min", "max", "env" ]); /** @type {WeakMap<object, Set<string>>} */ const validMatchesCacheByLexer = new WeakMap(); const MAX_CACHE_SIZE = 10000; function extractFunctionName(inputString) { const matches = [...inputString.matchAll(/(?:\s*([\w\-$]+)\s*)?\(/g)].flat(); return matches; } function hasDollarVarArg(functionCall) { for (const i of parseFunctionArguments(functionCall)) { if (isFunctionCall(i.value)) return hasDollarVarArg(i.value); if (isDollarVar(i.value)) return true; } return false; } const mathOperators = ["+", "/", "-", "*", "%"]; const rule = (primary, secondaryOptions) => { return (root, result) => { const validOptions = utils.validateOptions( result, ruleName, { actual: primary }, { actual: secondaryOptions, possible: { ignoreProperties: [validateObjectWithArrayProps(isString, isRegExp)], propertiesSyntax: [validateObjectWithProps(isString)], typesSyntax: [validateObjectWithProps(isString)] }, optional: true } ); if (!validOptions) { return; } /** @type {(name: string, propValue: string) => boolean} */ const isPropIgnored = (name, value) => optionsMatchesEntry(secondaryOptions, "ignoreProperties", name, value); const propertiesSyntax = { ...syntaxPatches.properties, ...secondaryOptions?.propertiesSyntax }; const typesSyntax = { ...syntaxPatches.types, // Sass supports rgba(color, alpha). // https://sass-lang.com/documentation/modules/#rgb "rgba()": "| rgba( <hex-color> , <alpha-value>? )", ...secondaryOptions?.typesSyntax }; /** @type {Map<string, string>} */ const typedCustomPropertyNames = new Map(); // Unless we tracked return values of declared functions, they're all valid. root.walkAtRules("function", atRule => { UNSUPPORTED_FUNCTIONS_IN_CSSTREE.add( extractFunctionName(atRule.params)[1] ); }); root.walkAtRules(atRuleRegexes.propertyName, atRule => { const propName = atRule.params.trim(); if (!propName || !atRule.nodes || !isCustomProperty(propName)) return; for (const node of atRule.nodes) { if (isDeclaration(node) && SYNTAX_DESCRIPTOR.test(node.prop)) { const value = node.value.trim(); const unquoted = string.decode(value); // Only string values are valid. // We can not check the syntax of this property. if (unquoted === value) continue; // Any value is allowed in this custom property. // We don't need to check this property. if (unquoted === "*") continue; // https://github.com/csstree/csstree/pull/256 // We can circumvent this issue by prefixing the property name, // making it a vendor-prefixed property instead of a custom property. // No one should be using `-stylelint--` as a property prefix. // // When this is resolved `typedCustomPropertyNames` can become a `Set<string>` // and the prefix can be removed. const prefixedPropName = `-stylelint${propName}`; typedCustomPropertyNames.set(propName, prefixedPropName); propertiesSyntax[prefixedPropName] = unquoted; } } }); const forkedLexer = fork({ properties: propertiesSyntax, types: typesSyntax }).lexer; let validMatchesCache = validMatchesCacheByLexer.get(forkedLexer); if (!validMatchesCache) { validMatchesCache = new Set(); validMatchesCacheByLexer.set(forkedLexer, validMatchesCache); } root.walkDecls(decl => { let { prop } = decl; const { parent } = decl; const value = getDeclarationValue(decl).replace(/\n\s+/, " "); // Strip multiline values. // Handle nested properties by reasigning `prop` to the compound property. if ( (parent.selector && isNestedProperty(parent.selector)) || parent.type === "decl" ) { let pointer = parent; let parentSelector = pointer.selector ? pointer.selector .split(" ") ?.filter(sel => sel[sel.length - 1] === ":")[0] : parent.prop; prop = String(decl.prop); while (parentSelector && parentSelector.substring(0, 2) !== "--") { prop = `${parentSelector.replace(":", "")}-${prop}`; pointer = pointer.parent; parentSelector = pointer.selector ? pointer.selector .split(" ") .filter(sel => sel[sel.length - 1] === ":")[0] : pointer.prop; } } // csstree/csstree#243 // NOTE: CSSTree's `fork()` doesn't support `-moz-initial`, but it may be possible in the future. if (/^-moz-initial$/i.test(value)) return; if (!isStandardSyntaxDeclaration(decl)) return; if (isDescriptorDeclaration(decl)) return; if (!isStandardSyntaxProperty(prop)) return; if (!isStandardSyntaxValue(value)) return; if (isCustomProperty(prop) && !typedCustomPropertyNames.has(prop)) return; if (isPropIgnored(prop, value)) return; // TODO: csstree treats any value containing `var()` as valid, even if the `var()` expression itself is invalid. // csstree should be updated to mark invalidate values that contain invalid `var()` expressions. // skipping parsing by returning early until this is resolved upstream. if (/\bvar\s*\(/i.test(value)) return; const cacheKey = isCustomProperty(prop) ? null : `${prop}:${value}`; if (cacheKey && validMatchesCache.has(cacheKey)) return; // Unless we tracked values of variables, they're all valid. if (value.match(/\$[\w-]+/)?.some(isDollarVar)) return; if (value.split(" ").some(val => hasDollarVarArg(val))) return; if (value.split(" ").some(val => containsCustomFunction(val))) return; // Check if value contains math functions that need validation const [ mathFuncResult, mathFuncResultStartOffset, mathFuncResultEndOffset ] = validateMathFunctions( value, prop, forkedLexer, typedCustomPropertyNames ); if (mathFuncResult === "valid" || mathFuncResult === "skip-validation") { if (cacheKey && validMatchesCache.size < MAX_CACHE_SIZE) validMatchesCache.add(cacheKey); return; } if (mathFuncResult === "invalid") { const valueIndex = declarationValueIndex(decl); let expression = value; let index = valueIndex; let endIndex = index + expression.length; if ( mathFuncResultStartOffset !== -1 && mathFuncResultEndOffset !== -1 ) { expression = value.slice( mathFuncResultStartOffset, mathFuncResultEndOffset ); index = valueIndex + mathFuncResultStartOffset; endIndex = index + expression.length; } utils.report({ message: messages.rejectedMath, messageArgs: [prop, expression], node: decl, index, endIndex, result, ruleName }); return; } /** @type {CssNode} */ let cssTreeValueNode; try { cssTreeValueNode = parse(value, { context: "value", positions: true }); if (containsCustomFunction(cssTreeValueNode)) return; if (containsFunctionsNotSupportedInCSSTree(cssTreeValueNode)) return; } catch { const index = declarationValueIndex(decl); const endIndex = index + value.length; // Ignore parse errors for `attr()`, `if()` and custom functions // See: https://github.com/stylelint/stylelint/issues/8779 if (/(?:^|[^\w-])(?:attr|if|--[\w-]+)\(/i.test(value)) return; // Hidden declarations if (isIfStatement(value)) return; if (hasDollarVarArg(value)) return; const operators = findOperators({ string: value }).map(o => o.symbol); for (const operator of operators) { if (mathOperators.includes(operator)) { return; } } utils.report({ message: messages.rejectedParseError, messageArgs: [prop, value], node: decl, index, endIndex, result, ruleName }); return; } const { error } = parent && isAtRule(parent) && !nestingSupportedAtKeywords.has(parent.name.toLowerCase()) ? forkedLexer.matchAtruleDescriptor( parent.name, prop, cssTreeValueNode ) : forkedLexer.matchProperty( typedCustomPropertyNames.get(prop) ?? prop, cssTreeValueNode ); if (!error) { if (cacheKey && validMatchesCache.size < MAX_CACHE_SIZE) validMatchesCache.add(cacheKey); return; } if (!("mismatchLength" in error)) return; const { name, rawMessage, loc } = error; if (name !== "SyntaxMatchError") return; if (rawMessage !== "Mismatch") return; const valueIndex = declarationValueIndex(decl); const mismatchValue = value.slice(loc.start.offset, loc.end.offset); const operators = findOperators({ string: value }).map(o => o.symbol); for (const operator of operators) { if (mathOperators.includes(operator)) { return; } } const functionNode = find( cssTreeValueNode, node => node.type === "Function" && node.loc !== undefined && loc.start.offset >= node.loc.start.offset && loc.end.offset <= node.loc.end.offset ); if (functionNode?.loc) { const valueFunction = value.slice( functionNode.loc.start.offset, functionNode.loc.end.offset ); const index = valueIndex + functionNode.loc.start.offset; const endIndex = index + valueFunction.length; utils.report({ message: messages.rejected, messageArgs: [prop, valueFunction], node: decl, index, endIndex, result, ruleName }); return; } utils.report({ message: messages.rejected, messageArgs: [prop, mismatchValue], node: decl, index: valueIndex + loc.start.offset, endIndex: valueIndex + loc.end.offset, result, ruleName }); }); }; }; /** * @see csstree/csstree#245 env * @param {import('css-tree').CssNode} cssTreeNode * @returns {boolean} */ function containsFunctionsNotSupportedInCSSTree(cssTreeNode) { return Boolean( find( cssTreeNode, node => node.type === "Function" && UNSUPPORTED_FUNCTIONS_IN_CSSTREE.has(node.name.toLowerCase()) ) ); } function containsCustomFunction(cssTreeNode) { return Boolean( /[\w-]+\.[\w-]+\(.*\)/.test(cssTreeNode) || find( cssTreeNode, node => node.type === "Function" && (UNSUPPORTED_FUNCTIONS_IN_CSSTREE.has(node.name.toLowerCase()) || !cssTreelexer.types[`${node.name}()`]) ) ); } /** * Detect `calc-size()` with the `size` keyword in its arguments. * * @param {Array<import('@csstools/css-parser-algorithms').ComponentValue>} componentValues * @returns {boolean} */ function containsCalcSizeWithSizeKeyword(componentValues) { let contains = false; walk(componentValues, ({ node }) => { if (!isFunctionNode(node) || node.getName().toLowerCase() !== "calc-size") return; contains = node .tokens() .some( token => token[0] === TokenType.Ident && token[4]?.value.toLowerCase() === "size" ); if (contains) return false; // halt }); return contains; } /** * Validate math functions (calc, min, max, clamp, etc.) in a CSS value. * Uses @csstools/css-calc to solve expressions and validate the result. * * @param {string} value - The CSS property value * @param {string} prop - The property name * @param {ReturnType<import('css-tree')['fork']>['lexer']} lexer - The csstree lexer * @param {Map<string, string>} typedCustomPropertyNames - Map of typed custom property names * @returns {['undetermined' | 'valid' | 'invalid' | 'skip-validation', number, number]} - The validation result */ function validateMathFunctions(value, prop, lexer, typedCustomPropertyNames) { // If the value doesn't contain any math functions, continue with normal validation if (!mayIncludeRegexes.mathFunction.test(value)) return ["undetermined", -1, -1]; const nodes = parseListOfComponentValues(tokenize({ css: value }), {}); if (containsCalcSizeWithSizeKeyword(nodes)) { return ["skip-validation", -1, -1]; } /** @type {Array<import('@csstools/css-calc').ParseError>} */ const calcParseErrors = []; // Try to solve the math expression const solvedNodes = calcFromComponentValues([nodes], { onParseError: err => { calcParseErrors.push(err); } }); // Check if any known errors were detected during parsing for (const calcParseError of calcParseErrors) { switch (calcParseError.message) { case ParseErrorMessage.UnexpectedAdditionOfDimensionOrPercentageWithNumber: case ParseErrorMessage.UnexpectedSubtractionOfDimensionOrPercentageWithNumber: return [ "invalid", calcParseError.sourceStart, calcParseError.sourceEnd + 1 ]; default: break; } } const solvedValue = stringify(solvedNodes); // For other cases where calc can't be fully solved (like 100% - 10px), // skip validation and let csstree handle it (csstree allows these) if (mayIncludeRegexes.mathFunction.test(solvedValue)) { return ["undetermined", -1, -1]; } // If the expression was fully solved (no more math functions), // validate the result with csstree try { const solvedCssTreeNode = parse(solvedValue, { context: "value", positions: true }); if (containsFunctionsNotSupportedInCSSTree(solvedCssTreeNode)) { return ["skip-validation", -1, -1]; } const { error } = lexer.matchProperty( typedCustomPropertyNames.get(prop) ?? prop, solvedCssTreeNode ); // If the solved value is valid, skip further validation if (!error) return ["valid", -1, -1]; // If the solved value is invalid, it means the calc result type doesn't match the property // e.g., calc(2) for height: results in a number "2", but height expects a length if ( "mismatchLength" in error && error.name === "SyntaxMatchError" && error.rawMessage === "Mismatch" ) { const startOffset = error.loc.start.offset; const endOffset = error.loc.end.offset; // Lookup the original source position of the invalid expression // by finding the tokens that correspond to the positions reported by csstree const solvedTokens = solvedNodes.flatMap(componentValues => componentValues.flatMap(node => node.tokens()) ); let counter = 0; let startToken; let endToken; solvedTokens.forEach(token => { if (startOffset === counter) { startToken = token; } counter += token[1].length; if (endOffset === counter) { endToken = token; } }); if (!startToken || !endToken) return ["invalid", -1, -1]; return ["invalid", startToken[2], endToken[3] + 1]; } } catch { // If parsing fails, continue with normal validation return ["undetermined", -1, -1]; } return ["undetermined", -1, -1]; } rule.ruleName = ruleName; rule.messages = messages; rule.meta = meta; export default rule;