diff --git a/src/rules/no-invalid-properties.js b/src/rules/no-invalid-properties.js index 271b0b87..104d236b 100644 --- a/src/rules/no-invalid-properties.js +++ b/src/rules/no-invalid-properties.js @@ -26,36 +26,44 @@ import { isSyntaxMatchError, isSyntaxReferenceError } from "../util.js"; //----------------------------------------------------------------------------- /** - * 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 + * 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 replaceWithOffsets(text, varName, replaceValue) { - const offsets = []; - let result = ""; - let lastIndex = 0; +function getVarFallbackList(value) { + const list = []; + let currentValue = value; - const regex = new RegExp(`var\\(\\s*${varName}\\s*\\)`, "gu"); - let match; + while (true) { + const match = currentValue.match( + /var\(\s*(--[^,\s)]+)\s*(?:,\s*(.+))?\)/u, + ); - while ((match = regex.exec(text)) !== null) { - result += text.slice(lastIndex, match.index); + if (!match) { + break; + } - /* - * 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; + 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; } - result += text.slice(lastIndex); - return { text: result, offsets }; + return list; } //----------------------------------------------------------------------------- @@ -146,46 +154,137 @@ 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; - - // 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, - name, - sourceCode.getText(varValue).trim(), - )); - - /* - * 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 (!allowUnknownVariables) { - context.report({ - loc: func.children[0].loc, - messageId: "unknownVar", - data: { - var: name, - }, - }); - - return; + 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") { + const fallbackVarList = + getVarFallbackList( + child.children[2].value.trim(), + ); + if (fallbackVarList.length > 0) { + 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 + } + } + + // 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 { + // 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); @@ -193,6 +292,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 @@ -201,7 +307,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 b35627a1..d8768749 100644 --- a/tests/rules/no-invalid-properties.test.js +++ b/tests/rules/no-invalid-properties.test.js @@ -37,6 +37,24 @@ 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)))) }", + ":root { --color: red }\na { border-top: 1px var(--style, var(--fallback, solid)) var(--color, blue); }", { code: "a { my-custom-color: red; }", languageOptions: { @@ -55,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 @@ -372,7 +394,7 @@ ruleTester.run("no-invalid-properties", rule, { expected: " || || ", }, line: 1, - column: 31, + column: 50, endLine: 1, endColumn: 53, }, @@ -456,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, + }, + ], + }, ], });