From 49cce3c40ef0f5d10325d6eb824da1e39dee60f2 Mon Sep 17 00:00:00 2001 From: Tanuj Kanti Date: Mon, 30 Jun 2025 21:22:35 +0530 Subject: [PATCH 1/3] fix: property value with fallback --- src/rules/no-invalid-properties.js | 178 ++++++++++++++++++++-- tests/rules/no-invalid-properties.test.js | 17 +++ 2 files changed, 182 insertions(+), 13 deletions(-) diff --git a/src/rules/no-invalid-properties.js b/src/rules/no-invalid-properties.js index 271b0b87..87e23a47 100644 --- a/src/rules/no-invalid-properties.js +++ b/src/rules/no-invalid-properties.js @@ -58,6 +58,94 @@ function replaceWithOffsets(text, varName, replaceValue) { return { text: result, offsets }; } +/** + * Simplifies the `var()` function calls in a CSS value by removing fallbacks. + * @param {string} value The property value that using `var()` function. + * @returns {string} The simplified value with fallbacks removed. + */ +function simplifyVarsWithFallbacks(value) { + let output = ""; + let i = 0; + + while (i < value.length) { + const start = value.indexOf("var(", i); + + if (start === -1) { + output += value.slice(i); + break; + } + + output += value.slice(i, start); + + let j = start + 4; + let openParens = 1; + + while (j < value.length && openParens > 0) { + if (value[j] === "(") { + openParens++; + } else if (value[j] === ")") { + openParens--; + } + + j++; + } + + const fullText = value.slice(start, j); + const firstArgMatch = fullText.match(/var\(\s*(--[\w-]+)\s*,/u); + + if (firstArgMatch) { + output += `var(${firstArgMatch[1]})`; + } else { + output += fullText; + } + + i = j; + } + + return output; +} + +/** + * Extracts the list of fallback value or variable name used in a `var()` that is used as fallback function. + * For example, for `var(--my-color, var(--fallback-color, red));` it will return `["--fallback-color", "red"]`. + * @param {string} value The fallback value that is used in `var()`. + * @return {Array} The list of variable names of fallback value. + */ +function getVarFallbackList(value) { + const list = []; + let currentValue = value; + + while (true) { + const match = currentValue.match( + /var\(\s*(--[^,\s)]+)\s*(?:,\s*(.+))?\)/u, + ); + + if (!match) { + break; + } + + const prop = match[1].trim(); + const fallback = match[2]?.trim(); + + list.push(prop); + + if (!fallback) { + break; + } + + // If fallback is not another var(), we're done + if (!fallback.includes("var(")) { + list.push(fallback); + break; + } + + // Continue parsing from fallback + currentValue = fallback; + } + + return list; +} + //----------------------------------------------------------------------------- // Rule Definition //----------------------------------------------------------------------------- @@ -155,17 +243,24 @@ export default { // need to use a text version of the value here value = sourceCode.getText(node.value); let offsets; + let valueWithNoFallback = simplifyVarsWithFallbacks( + sourceCode.getText(node.value), + ).trim(); // replace any custom properties with their values for (const [name, func] of varsFound) { const varValue = vars.get(name); if (varValue) { - ({ text: value, offsets } = replaceWithOffsets( - value, + const result = replaceWithOffsets( + valueWithNoFallback, name, sourceCode.getText(varValue).trim(), - )); + ); + + valueWithNoFallback = result.text; + offsets = result.offsets; + value = valueWithNoFallback; /* * Store the offsets of the replacements so we can @@ -174,16 +269,73 @@ export default { offsets.forEach(offset => { varsFoundLocs.set(offset, func.loc); }); - } else if (!allowUnknownVariables) { - context.report({ - loc: func.children[0].loc, - messageId: "unknownVar", - data: { - var: name, - }, - }); - - return; + } else { + if (func.children.length > 1) { + if (func.children[2].type === "Raw") { + if ( + func.children[2].value + .trim() + .startsWith("var") + ) { + const fallbackVarList = + getVarFallbackList( + func.children[2].value.trim(), + ); + + let gotFallbackVarValue = false; + + for (const fallbackVar of fallbackVarList) { + if (fallbackVar.startsWith("--")) { + const fallbackVarValue = + vars.get(fallbackVar); + + if (!fallbackVarValue) { + continue; // Try the next fallback + } + + value = sourceCode + .getText(fallbackVarValue) + .trim(); + gotFallbackVarValue = true; + break; // Stop after finding the first valid variable + } else { + value = fallbackVar.trim(); + gotFallbackVarValue = true; + break; // Stop after finding the first non-variable fallback + } + } + + if ( + !allowUnknownVariables && + !gotFallbackVarValue + ) { + context.report({ + loc: func.children[0].loc, + messageId: "unknownVar", + data: { + var: name, + }, + }); + + return; + } + } else { + value = func.children[2].value.trim(); + } + } + } else { + if (!allowUnknownVariables) { + context.report({ + loc: func.children[0].loc, + messageId: "unknownVar", + data: { + var: name, + }, + }); + + return; + } + } } } } diff --git a/tests/rules/no-invalid-properties.test.js b/tests/rules/no-invalid-properties.test.js index b35627a1..bc551cb7 100644 --- a/tests/rules/no-invalid-properties.test.js +++ b/tests/rules/no-invalid-properties.test.js @@ -37,6 +37,23 @@ ruleTester.run("no-invalid-properties", rule, { ":root { --my-color: red; }\na { color: var( --my-color ) }", ":root { --my-color: red;\n.foo { color: var(--my-color) }\n}", ".fluidHeading {font-size: clamp(2.1rem, calc(7.2vw - 0.2rem), 2.5rem);}", + "a { color: var(--my-color, red) }", + ":root { --my-heading: 3rem; }\na { color: var(--my-color, red) }", + ":root { --my-heading: 3rem; --foo: red }\na { color: var(--my-color, var(--foo, blue)) }", + ":root { --my-heading: 3rem; }\na { color: var(--my-color, var(--foo, blue)) }", + "a { color: var(--my-color, var(--foo, var(--bar, blue))) }", + ":root { --my-color: red; }\na { color: var(--my-color, blue) }", + ":root { --my-fallback: red; }\na { color: var(--my-color, var(--my-fallback)) }", + ":root { --my-fallback: red; }\na { color: var(--my-color, var(--my-fallback, blue)) }", + ":root { --foo: red; }\na { color: var(--my-color, var(--my-fallback, var(--foo))) }", + "a { color: var(--my-color, var(--my-fallback, var(--foo, blue))) }", + ":root { --my-color: red; }\na { color: var(--my-color, var(--fallback-color)) }", + ":root { --my-color: red; --fallback-color: blue; }\na { color: var(--my-color, var(--fallback-color)) }", + ":root { --my-color: red; }\na { color: var(--my-color, var(--fallback-color, blue)) }", + ":root { --my-color: red; }\na { color: var(--my-color, var(--fallback-color, var(--foo))) }", + ":root { --my-color: red; }\na { color: var(--my-color, var(--fallback-color, var(--foo, blue))) }", + ":root { --my-color: red; }\na { color: var(--my-color, var(--fallback-color, var(--foo, var(--bar)))) }", + ":root { --my-color: red; }\na { color: var(--my-color, var(--fallback-color, var(--foo, var(--bar, blue)))) }", { code: "a { my-custom-color: red; }", languageOptions: { From fa4bd5ee2b533da190804d653825a6d66a390c1d Mon Sep 17 00:00:00 2001 From: Tanuj Kanti Date: Sun, 6 Jul 2025 23:28:41 +0530 Subject: [PATCH 2/3] update code to fix location and multiple var() --- src/rules/no-invalid-properties.js | 302 ++++++++++------------ tests/rules/no-invalid-properties.test.js | 121 ++++++++- 2 files changed, 250 insertions(+), 173 deletions(-) diff --git a/src/rules/no-invalid-properties.js b/src/rules/no-invalid-properties.js index 87e23a47..74890921 100644 --- a/src/rules/no-invalid-properties.js +++ b/src/rules/no-invalid-properties.js @@ -25,86 +25,6 @@ import { isSyntaxMatchError, isSyntaxReferenceError } from "../util.js"; // Helpers //----------------------------------------------------------------------------- -/** - * Replaces all instances of a regex pattern with a replacement and tracks the offsets - * @param {string} text The text to perform replacements on - * @param {string} varName The regex pattern string to search for - * @param {string} replaceValue The string to replace with - * @returns {{text: string, offsets: Array}} The updated text and array of offsets - * where replacements occurred - */ -function replaceWithOffsets(text, varName, replaceValue) { - const offsets = []; - let result = ""; - let lastIndex = 0; - - const regex = new RegExp(`var\\(\\s*${varName}\\s*\\)`, "gu"); - let match; - - while ((match = regex.exec(text)) !== null) { - result += text.slice(lastIndex, match.index); - - /* - * We need the offset of the replacement after other replacements have - * been made, so we push the current length of the result before appending - * the replacement value. - */ - offsets.push(result.length); - result += replaceValue; - lastIndex = match.index + match[0].length; - } - - result += text.slice(lastIndex); - return { text: result, offsets }; -} - -/** - * Simplifies the `var()` function calls in a CSS value by removing fallbacks. - * @param {string} value The property value that using `var()` function. - * @returns {string} The simplified value with fallbacks removed. - */ -function simplifyVarsWithFallbacks(value) { - let output = ""; - let i = 0; - - while (i < value.length) { - const start = value.indexOf("var(", i); - - if (start === -1) { - output += value.slice(i); - break; - } - - output += value.slice(i, start); - - let j = start + 4; - let openParens = 1; - - while (j < value.length && openParens > 0) { - if (value[j] === "(") { - openParens++; - } else if (value[j] === ")") { - openParens--; - } - - j++; - } - - const fullText = value.slice(start, j); - const firstArgMatch = fullText.match(/var\(\s*(--[\w-]+)\s*,/u); - - if (firstArgMatch) { - output += `var(${firstArgMatch[1]})`; - } else { - output += fullText; - } - - i = j; - } - - return output; -} - /** * Extracts the list of fallback value or variable name used in a `var()` that is used as fallback function. * For example, for `var(--my-color, var(--fallback-color, red));` it will return `["--fallback-color", "red"]`. @@ -234,110 +154,141 @@ export default { const varsFound = replacements.pop(); - /** @type {Map} */ - const varsFoundLocs = new Map(); + /** @type {Map} */ + const valuesWithVarLocs = new Map(); const usingVars = varsFound?.size > 0; let value = node.value; if (usingVars) { - // need to use a text version of the value here - value = sourceCode.getText(node.value); - let offsets; - let valueWithNoFallback = simplifyVarsWithFallbacks( - sourceCode.getText(node.value), - ).trim(); - - // replace any custom properties with their values - for (const [name, func] of varsFound) { - const varValue = vars.get(name); - - if (varValue) { - const result = replaceWithOffsets( - valueWithNoFallback, - name, - sourceCode.getText(varValue).trim(), - ); - - valueWithNoFallback = result.text; - offsets = result.offsets; - value = valueWithNoFallback; - - /* - * Store the offsets of the replacements so we can - * report the correct location of any validation error. - */ - offsets.forEach(offset => { - varsFoundLocs.set(offset, func.loc); - }); - } else { - if (func.children.length > 1) { - if (func.children[2].type === "Raw") { - if ( - func.children[2].value - .trim() - .startsWith("var") - ) { - const fallbackVarList = - getVarFallbackList( - func.children[2].value.trim(), - ); - - let gotFallbackVarValue = false; - - for (const fallbackVar of fallbackVarList) { - if (fallbackVar.startsWith("--")) { - const fallbackVarValue = - vars.get(fallbackVar); - - if (!fallbackVarValue) { - continue; // Try the next fallback + const valueList = []; + const valueNodes = node.value.children; + + // When `var()` is used, we store all the values to `valueList` with the replacement of `var()` with there values or fallback values + for (const child of valueNodes) { + // If value is a function starts with `var()` + if (child.type === "Function" && child.name === "var") { + const varValue = vars.get(child.children[0].name); + + // If the variable is found, use its value, otherwise check for fallback values + if (varValue) { + const varValueText = sourceCode + .getText(varValue) + .trim(); + + valueList.push(varValueText); + valuesWithVarLocs.set(varValueText, child.loc); + } else { + // If the variable is not found and doesn't have a fallback value, report it + if (child.children.length === 1) { + if (!allowUnknownVariables) { + context.report({ + loc: child.children[0].loc, + messageId: "unknownVar", + data: { + var: child.children[0].name, + }, + }); + + return; + } + } else { + // If it has a fallback value, use that + if (child.children[2].type === "Raw") { + if ( + child.children[2].value + .trim() + .startsWith("var") + ) { + const fallbackVarList = + getVarFallbackList( + child.children[2].value.trim(), + ); + let gotFallbackVarValue = false; + + for (const fallbackVar of fallbackVarList) { + if ( + fallbackVar.startsWith("--") + ) { + const fallbackVarValue = + vars.get(fallbackVar); + + if (!fallbackVarValue) { + continue; // Try the next fallback + } + + valueList.push( + sourceCode + .getText( + fallbackVarValue, + ) + .trim(), + ); + valuesWithVarLocs.set( + sourceCode + .getText( + fallbackVarValue, + ) + .trim(), + child.loc, + ); + gotFallbackVarValue = true; + break; // Stop after finding the first valid variable + } else { + const fallbackValue = + fallbackVar.trim(); + valueList.push( + fallbackValue, + ); + valuesWithVarLocs.set( + fallbackValue, + child.loc, + ); + gotFallbackVarValue = true; + break; // Stop after finding the first non-variable fallback } - - value = sourceCode - .getText(fallbackVarValue) - .trim(); - gotFallbackVarValue = true; - break; // Stop after finding the first valid variable - } else { - value = fallbackVar.trim(); - gotFallbackVarValue = true; - break; // Stop after finding the first non-variable fallback } - } - if ( - !allowUnknownVariables && - !gotFallbackVarValue - ) { - context.report({ - loc: func.children[0].loc, - messageId: "unknownVar", - data: { - var: name, - }, - }); - - return; + // If none of the fallback value is defined then report an error + if ( + !allowUnknownVariables && + !gotFallbackVarValue + ) { + context.report({ + loc: child.children[0].loc, + messageId: "unknownVar", + data: { + var: child.children[0] + .name, + }, + }); + + return; + } + } else { + // if it has a fallback value, use that + const fallbackValue = + child.children[2].value.trim(); + valueList.push(fallbackValue); + valuesWithVarLocs.set( + fallbackValue, + child.loc, + ); } - } else { - value = func.children[2].value.trim(); } } - } else { - if (!allowUnknownVariables) { - context.report({ - loc: func.children[0].loc, - messageId: "unknownVar", - data: { - var: name, - }, - }); - - return; - } } + } else { + // If the child is not a `var()` function, just add its text to the `valueList` + const valueText = sourceCode.getText(child).trim(); + valueList.push(valueText); + valuesWithVarLocs.set(valueText, child.loc); } } + + value = + valueList.length > 0 + ? valueList.join(" ") + : sourceCode.getText(node.value); } const { error } = lexer.matchProperty(node.property, value); @@ -345,6 +296,13 @@ export default { if (error) { // validation failure if (isSyntaxMatchError(error)) { + const errorValue = + usingVars && + value.slice( + error.mismatchOffset, + error.mismatchOffset + error.mismatchLength, + ); + context.report({ /* * When using variables, check to see if the error @@ -353,7 +311,7 @@ export default { * reported location. */ loc: usingVars - ? (varsFoundLocs.get(error.mismatchOffset) ?? + ? (valuesWithVarLocs.get(errorValue) ?? node.value.loc) : error.loc, messageId: "invalidPropertyValue", diff --git a/tests/rules/no-invalid-properties.test.js b/tests/rules/no-invalid-properties.test.js index bc551cb7..d8768749 100644 --- a/tests/rules/no-invalid-properties.test.js +++ b/tests/rules/no-invalid-properties.test.js @@ -54,6 +54,7 @@ ruleTester.run("no-invalid-properties", rule, { ":root { --my-color: red; }\na { color: var(--my-color, var(--fallback-color, var(--foo, blue))) }", ":root { --my-color: red; }\na { color: var(--my-color, var(--fallback-color, var(--foo, var(--bar)))) }", ":root { --my-color: red; }\na { color: var(--my-color, var(--fallback-color, var(--foo, var(--bar, blue)))) }", + ":root { --color: red }\na { border-top: 1px var(--style, var(--fallback, solid)) var(--color, blue); }", { code: "a { my-custom-color: red; }", languageOptions: { @@ -72,6 +73,10 @@ ruleTester.run("no-invalid-properties", rule, { code: "a { --my-color: red; color: var(--my-color); background-color: var(--unknown-var); }", options: [{ allowUnknownVariables: true }], }, + { + code: ":root { --color: red }\na { border-top: 1px var(--style, var(--fallback)) var(--color, blue); }", + options: [{ allowUnknownVariables: true }], + }, /* * CSSTree doesn't currently support custom functions properly, so leaving @@ -389,7 +394,7 @@ ruleTester.run("no-invalid-properties", rule, { expected: " || || ", }, line: 1, - column: 31, + column: 50, endLine: 1, endColumn: 53, }, @@ -473,5 +478,119 @@ ruleTester.run("no-invalid-properties", rule, { }, ], }, + { + code: "a { border-top: 1px var(--style, solid) var(--color); }", + errors: [ + { + messageId: "unknownVar", + data: { + var: "--color", + }, + line: 1, + column: 45, + endLine: 1, + endColumn: 52, + }, + ], + }, + { + code: ":root { --style: foo }\na { border-top: 1px var(--style) var(--color, red); }", + errors: [ + { + messageId: "invalidPropertyValue", + data: { + property: "border-top", + value: "foo", + expected: " || || ", + }, + line: 2, + column: 21, + endLine: 2, + endColumn: 33, + }, + ], + }, + { + code: ":root { --style: foo }\na { border-top: 1px var(--style, solid) var(--color, red); }", + errors: [ + { + messageId: "invalidPropertyValue", + data: { + property: "border-top", + value: "foo", + expected: " || || ", + }, + line: 2, + column: 21, + endLine: 2, + endColumn: 40, + }, + ], + }, + { + code: ":root { --color: foo }\na { border-top: 1px var(--style, var(--fallback, solid)) var(--color); }", + errors: [ + { + messageId: "invalidPropertyValue", + data: { + property: "border-top", + value: "foo", + expected: " || || ", + }, + line: 2, + column: 58, + endLine: 2, + endColumn: 70, + }, + ], + }, + { + code: ":root { --color: foo }\na { border-top: 1px var(--style, var(--fallback)) var(--color); }", + options: [{ allowUnknownVariables: true }], + errors: [ + { + messageId: "invalidPropertyValue", + data: { + property: "border-top", + value: "foo", + expected: " || || ", + }, + line: 2, + column: 51, + endLine: 2, + endColumn: 63, + }, + ], + }, + { + code: ":root { --color: foo }\na { border-top: 1px var(--style, var(--fallback)) var(--color); }", + errors: [ + { + messageId: "unknownVar", + data: { + var: "--style", + }, + line: 2, + column: 25, + endLine: 2, + endColumn: 32, + }, + ], + }, + { + code: ":root { --color: red }\na { colorr: var(--color, blue); }", + errors: [ + { + messageId: "unknownProperty", + data: { + property: "colorr", + }, + line: 2, + column: 5, + endLine: 2, + endColumn: 11, + }, + ], + }, ], }); From 6c867cdcca497d7c6fb5a7444638e7ffacfef767 Mon Sep 17 00:00:00 2001 From: Tanuj Kanti Date: Fri, 18 Jul 2025 15:44:55 +0530 Subject: [PATCH 3/3] check fallback list in condition --- src/rules/no-invalid-properties.js | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/src/rules/no-invalid-properties.js b/src/rules/no-invalid-properties.js index 74890921..104d236b 100644 --- a/src/rules/no-invalid-properties.js +++ b/src/rules/no-invalid-properties.js @@ -194,15 +194,11 @@ export default { } else { // If it has a fallback value, use that if (child.children[2].type === "Raw") { - if ( - child.children[2].value - .trim() - .startsWith("var") - ) { - const fallbackVarList = - getVarFallbackList( - child.children[2].value.trim(), - ); + const fallbackVarList = + getVarFallbackList( + child.children[2].value.trim(), + ); + if (fallbackVarList.length > 0) { let gotFallbackVarValue = false; for (const fallbackVar of fallbackVarList) {